File

src/app/core/export/query.service.ts

Description

A query service which uses the json-query library (https://github.com/auditassistant/json-query).

Index

Methods

Constructor

constructor(entityMapper: EntityMapperService, childrenService: ChildrenService, attendanceService: AttendanceService, entityRegistry: EntityRegistry)
Parameters :
Name Type Optional
entityMapper EntityMapperService No
childrenService ChildrenService No
attendanceService AttendanceService No
entityRegistry EntityRegistry No

Methods

Async cacheRequiredData
cacheRequiredData(query: string, from: Date, to: Date)

Call this function to prefetch required data

Parameters :
Name Type Optional Description
query string No

single query or concatenation of all query strings that will be executed soon

from Date No

date from which data should be available

to Date No

date to which data should be available

Returns : any
Public queryData
queryData(query: string, from?: Date, to?: Date, data?: any)

Runs the query on the passed data object

Parameters :
Name Type Optional Description
query string No

a string or array according to the json-query language (https://github.com/auditassistant/json-query)

from Date Yes

a date which can be accessed in the query using a ?.

to Date Yes

a date which can be accessed in the query using another ?

data any Yes

the data on which the query should run, default is all entities

Returns : any

the results of the query on the data

import { Injectable } from "@angular/core";
import { Entity, EntityConstructor } from "../entity/model/entity";
import { Note } from "../../child-dev-project/notes/model/note";
import { EventNote } from "../../child-dev-project/attendance/model/event-note";
import { EntityMapperService } from "../entity/entity-mapper/entity-mapper.service";
import { ChildSchoolRelation } from "../../child-dev-project/children/model/childSchoolRelation";
import { ChildrenService } from "../../child-dev-project/children/children.service";
import { AttendanceService } from "../../child-dev-project/attendance/attendance.service";
import { EventAttendance } from "../../child-dev-project/attendance/model/event-attendance";
import jsonQuery from "json-query";
import { EntityRegistry } from "../entity/database-entity.decorator";

/**
 * A query service which uses the json-query library (https://github.com/auditassistant/json-query).
 */
@Injectable({
  providedIn: "root",
})
export class QueryService {
  private entities: { [type: string]: { [id: string]: Entity } } = {};

  /**
   * A map of information about the loading state of the different entity types
   * @private
   */
  private entityInfo: {
    [type: string]: {
      /**
       * A optional function which can be used to load this entity that might use a start and end date
       * @param form
       * @param to
       */
      dataFunction?: (form, to) => Promise<Entity[]>;
      /**
       * Whether already all entities of this type have been loaded
       */
      allLoaded?: boolean;
      /**
       * A certain range in which entities of this type have been loaded
       */
      rangeLoaded?: { from: Date; to: Date };
      /**
       * Whether updates of this entity are listened to
       */
      updating?: boolean;
    };
  } = {
    Note: {
      dataFunction: (from, to) =>
        this.childrenService.getNotesInTimespan(from, to),
    },
    EventNote: {
      dataFunction: (from, to) =>
        this.attendanceService.getEventsOnDate(from, to),
    },
  };

  /**
   * A list of further aliases for which a certain entity needs to be loaded.
   * This can be necessary if a function requires a certain entity to be present.
   * @private
   */
  private queryStringMap: [string, EntityConstructor][] = [
    ["getAttendanceArray\\(true\\)", ChildSchoolRelation],
  ];

  constructor(
    private entityMapper: EntityMapperService,
    private childrenService: ChildrenService,
    private attendanceService: AttendanceService,
    entityRegistry: EntityRegistry,
  ) {
    entityRegistry.forEach((entity, name) =>
      this.queryStringMap.push([name, entity]),
    );
  }

  /**
   * Runs the query on the passed data object
   * @param query a string or array according to the json-query language (https://github.com/auditassistant/json-query)
   * @param from a date which can be accessed in the query using a ?.
   * @param to a date which can be accessed in the query using another ?
   * @param data the data on which the query should run, default is all entities
   * @returns the results of the query on the data
   */
  public queryData(query: string, from?: Date, to?: Date, data?: any): any {
    from = from ?? new Date(0);
    to = to ?? new Date();

    if (!data) {
      data = this.entities;
    }

    return jsonQuery([query, from, to], {
      data: data,
      locals: {
        toArray: this.toArray,
        unique: this.unique,
        count: this.count,
        sum: this.sum,
        avg: this.avg,
        toEntities: this.toEntities.bind(this),
        getRelated: this.getRelated.bind(this),
        filterByObjectAttribute: this.filterByObjectAttribute,
        getIds: this.getIds,
        getParticipantsWithAttendance: this.getParticipantsWithAttendance,
        getAttendanceArray: this.getAttendanceArray.bind(this),
        getAttendanceReport: this.getAttendanceReport,
        addEntities: this.addEntities.bind(this),
        setString: this.setString,
      },
    }).value;
  }

  /**
   * Call this function to prefetch required data
   * @param query single query or concatenation of all query strings that will be executed soon
   * @param from date from which data should be available
   * @param to date to which data should be available
   */
  async cacheRequiredData(query: string, from: Date, to: Date) {
    from = from ?? new Date(0);
    to = to ?? new Date();
    const uncachedEntities = this.getUncachedEntities(query, from, to);
    const dataPromises = uncachedEntities.map((entity) => {
      const info = this.entityInfo[entity.ENTITY_TYPE];
      if (info?.dataFunction) {
        return info.dataFunction(from, to).then((loadedEntities) => {
          this.setEntities(entity, loadedEntities);
          info.rangeLoaded = { from, to };
        });
      } else {
        return this.entityMapper.loadType(entity).then((loadedEntities) => {
          this.setEntities(entity, loadedEntities);
          this.entityInfo[entity.ENTITY_TYPE] = { allLoaded: true };
        });
      }
    });
    await Promise.all(dataPromises);
    this.applyEntityUpdates(uncachedEntities);
  }

  private applyEntityUpdates(uncachedEntities: EntityConstructor[]) {
    uncachedEntities
      .filter(({ ENTITY_TYPE }) => !this.entityInfo[ENTITY_TYPE].updating)
      .forEach(({ ENTITY_TYPE }) => {
        this.entityInfo[ENTITY_TYPE].updating = true;
        this.entityMapper
          .receiveUpdates(ENTITY_TYPE)
          .subscribe(({ entity, type }) => {
            if (type === "remove") {
              delete this.entities[ENTITY_TYPE][entity.getId()];
            } else {
              this.entities[ENTITY_TYPE][entity.getId()] = entity;
            }
          });
      });
  }

  /**
   * Get entities that are referenced in the query string and are not sufficiently cached.
   * @param query
   * @param from
   * @param to
   * @private
   */
  private getUncachedEntities(query: string, from: Date, to: Date) {
    return this.queryStringMap
      .filter(([matcher]) =>
        // matches query string without any alphanumeric characters before or after (e.g. so Child does not match ChildSchoolRelation)
        query?.match(new RegExp(`(^|\\W)${matcher}(\\W|$)`)),
      )
      .map(([_, entity]) => entity)
      .filter((entity) => {
        const info = this.entityInfo[entity.ENTITY_TYPE];
        return (
          info === undefined ||
          !(
            info.allLoaded ||
            (info.rangeLoaded?.from <= from && info.rangeLoaded?.to >= to)
          )
        );
      });
  }

  private setEntities<T extends Entity>(
    entityClass: EntityConstructor<T>,
    entities: T[],
  ) {
    this.entities[entityClass.ENTITY_TYPE] = {};
    entities.forEach(
      (entity) =>
        (this.entities[entityClass.ENTITY_TYPE][entity.getId()] = entity),
    );
  }

  /**
   * Creates an array containing the value of each key of the object.
   * e.g. `{a: 1, b: 2} => [1,2]`
   * This should be used when iterating over all documents of a given entity type because they are stored as
   * `"{entity._id}": {entity}`
   * @param obj the object which should be transformed to an array
   * @returns the values of the input object as a list
   */
  private toArray(obj): any[] {
    return Object.values(obj);
  }

  /**
   * Returns a copy of the input array without duplicates
   * @param data the array where duplicates should be removed
   * @returns a list without duplicates
   */
  private unique(data: any[]): any[] {
    return new Array(...new Set(data));
  }

  /**
   * Get the size of an array
   * @param data the data for which the length should be returned
   * @returns the length of the input array or 0 if no array is provided
   */
  private count(data: any[]): number {
    return data ? data.length : 0;
  }

  /**
   * Returns the (integer) sum of the provided array.
   * It can also handle integers in strings, e.g. "3"
   * @param data an integer array
   * @private
   */
  private sum(data: any[]): number {
    return data.reduce((res, cur) => {
      const parsed = Number.parseInt(cur);
      return Number.isNaN(parsed) ? res : res + parsed;
    }, 0);
  }

  /**
   * Returns the avg of the provided array as string.
   * It can also handle integers in strings, e.g. "3".
   * The average is only calculated if the value exists and is a valid number.
   * @param data an integer array
   * @param decimals the amount of decimals for the result, default 0
   * @private
   */
  private avg(data: any[], decimals = 0): string {
    const numbers = data
      .map((d) => Number.parseInt(d))
      .filter((i) => !Number.isNaN(i));
    const result =
      numbers.length === 0
        ? 0
        : numbers.reduce((i, sum) => sum + i, 0) / numbers.length;
    return result.toFixed(decimals);
  }

  /**
   * Turns a list of ids (with the entity prefix) into a list of entities
   * @param ids the array of ids with entity prefix
   * @param entityPrefix indicate the type of entity that should be loaded. This is required for pre-loading the required entities.
   * @returns a list of entity objects
   */
  private toEntities(ids: string[], entityPrefix: string): Entity[] {
    if (!entityPrefix) {
      throw new Error("Entity type not defined");
    }
    if (!ids) {
      return [];
    }

    return ids
      .filter((id) => {
        if (typeof id !== "string") {
          console.debug("invalid entity id in Query :toEntities", id);
          return false;
        }
        return true;
      })
      .map((id) => {
        const prefix = id.split(":")[0];
        return this.entities[prefix][id];
      })
      .filter((entity) => !!entity);
  }

  /**
   * Returns all entities which reference a entity from the passed list of entities (by their id)
   * @param srcEntities the entities for which relations should be found
   * @param entityType the type of entities where relations should be looked for
   * @param relationKey the name of the attribute that holds the reference.
   *                    The attribute can be a string or a list of strings
   * @returns a list of the related unique entities
   */
  private getRelated(
    srcEntities: Entity[],
    entityType: string,
    relationKey: string,
  ): Entity[] {
    const targetEntities = this.toArray(this.entities[entityType]);
    const srcIds = srcEntities
      .filter((entity) => typeof entity.getId === "function") // skip empty placeholder objects
      .map((entity) => entity.getId());
    if (
      targetEntities.length > 0 &&
      Array.isArray(targetEntities[0][relationKey])
    ) {
      return targetEntities.filter((entity) =>
        (entity[relationKey] as string[]).some((id) => srcIds.includes(id)),
      );
    } else {
      return targetEntities.filter((entity) =>
        entity[relationKey] ? srcIds.includes(entity[relationKey]) : false,
      );
    }
  }

  /**
   * Filters the data when the filter value is a object (e.g. configurable enum) rather than a simple value
   * @param objs the objects to be filtered
   * @param attr the attribute of the objects which is a object itself
   * @param key the key of the attribute-object which should be compared
   * @param value the value which will be compared with `obj[attr][key]` for each obj in objs.
   *              The value can be a simple value or list of values separated by `|` (e.g. SCHOOL_CLASS|LIFE_SKILLS).
   *              If it is a list of values, then the object is returned if its value matches any of the given values.
   * @returns the filtered objects
   */
  private filterByObjectAttribute(
    objs: any[],
    attr: string,
    key: string,
    value: string,
  ): any[] {
    // splits at "|" and removes optional whitespace before or after the symbol
    const values = value.trim().split(/\s*\|\s*/);
    return objs.filter((obj) => {
      if (obj?.hasOwnProperty(attr)) {
        return values.includes(obj[attr][key]?.toString());
      }
      return false;
    });
  }

  /**
   * Returns a list of IDs held by each object (e.g. the children-IDs held by an array of notes)
   * @param objs the objects which each holds a list of IDs
   * @param key the key on which each object holds a list of IDs
   * @returns a one dimensional string array holding all IDs which are held by the objects.
   *            This list may contain duplicate IDs. If this is not desired, use `:unique` afterwards.
   */
  private getIds(objs: any[], key: string): string[] {
    const ids: string[] = [];
    objs.forEach((obj) => {
      if (obj.hasOwnProperty(key)) {
        ids.push(...obj[key]);
      }
    });
    return ids;
  }

  /**
   * Return the ids of all the participants of the passed events with the defined attendance status using the `countAs`
   * attribute. The list may contain duplicates and the id does not necessarily have the entity prefix.
   * @param events the array of events
   * @param attendanceStatus the status for which should be looked for
   * @returns the ids of children which have the specified attendance in an event
   */
  private getParticipantsWithAttendance(
    events: EventNote[],
    attendanceStatus: string,
  ): string[] {
    const attendedChildren: string[] = [];
    events.forEach((e) =>
      e.children.forEach((childId) => {
        if (e.getAttendance(childId).status.countAs === attendanceStatus) {
          attendedChildren.push(childId);
        }
      }),
    );
    return attendedChildren;
  }

  /**
   * Transforms a list of notes or event-notes into a flattened list of participants and their attendance for each event.
   * @param events the input list of type Note or EventNote
   * @param includeSchool (optional) also include the school to which a participant belongs
   * @returns AttendanceInfo[] a list holding information about the attendance of a single participant
   */
  private getAttendanceArray(
    events: Note[],
    includeSchool = false,
  ): AttendanceInfo[] {
    const attendances: AttendanceInfo[] = [];
    for (const event of events) {
      const linkedRelations = includeSchool
        ? this.getMembersOfGroupsForEvent(event)
        : [];

      for (const child of event.children) {
        const attendance: AttendanceInfo = {
          participant: child,
          status: event.getAttendance(child),
        };

        const relation = linkedRelations.find((rel) => rel.childId === child);
        if (relation) {
          attendance.school = relation.schoolId;
        }

        attendances.push(attendance);
      }
    }
    return attendances;
  }

  private getMembersOfGroupsForEvent(event: Note) {
    return this.toArray(this.entities[ChildSchoolRelation.ENTITY_TYPE]).filter(
      (relation) =>
        event.schools.includes(relation.schoolId) &&
        relation.isActiveAt(event.date),
    );
  }

  /**
   * Transforms a list of attendances infos into an aggregated report for each participant
   * @param attendances an array of AttendanceInfo objects
   * @returns AttendanceReport[] for each participant the ID, the number of present and total absences as well as the attendance percentage.
   */
  private getAttendanceReport(
    attendances: AttendanceInfo[],
  ): AttendanceReport[] {
    const participantMap: { [key in string]: AttendanceReport } = {};
    attendances.forEach((attendance) => {
      if (!participantMap.hasOwnProperty(attendance.participant)) {
        participantMap[attendance.participant] = {
          participant: attendance.participant,
          total: 0,
          present: 0,
          percentage: "",
          detailedStatus: {},
        };
      }
      const report = participantMap[attendance.participant];
      report.detailedStatus[attendance.status.status.id] = report
        .detailedStatus[attendance.status.status.id]
        ? report.detailedStatus[attendance.status.status.id] + 1
        : 1;
      if (attendance.status.status.countAs === "PRESENT") {
        report.present++;
      }
      if (attendance.status.status.countAs !== "IGNORE") {
        report.total++;
      }
      if (report.total > 0) {
        report.percentage = (report.present / report.total).toFixed(2);
      }
    });
    return Object.values(participantMap);
  }

  /**
   * Adds all entities of the given type to the input array
   * @param entities the array before
   * @param entityType the type of entities which should be added
   * @returns the input array concatenated with all entities of the entityType
   */
  private addEntities(entities: Entity[], entityType: string): Entity[] {
    return entities.concat(...this.toArray(this.entities[entityType]));
  }

  /**
   * Replaces all input values by the string provided
   * @param data the data which will be replaced
   * @param value the string which should replace initial data
   * @returns array of same length as data where every input is value
   */
  private setString(data: any[], value: string): string[] | string {
    return Array.isArray(data) ? data.map(() => value) : value;
  }
}

export interface AttendanceInfo {
  participant: string;
  status: EventAttendance;
  school?: string;
}

export interface AttendanceReport {
  participant: string;
  total: number;
  present: number;
  percentage: string;

  /** counts by all custom configured status **/
  detailedStatus?: { [key: string]: number };
}

results matching ""

    No results matching ""