src/app/core/basic-datatypes/entity/entity.datatype.ts

Description

Structure for the additional field of an entity reference ColumnMapping. Can be a plain string (legacy) or an object with optional valueMapping config.

Index

Properties

Properties

refField
refField: string
Type : string

The property of the referenced entity to match against the import value.

valueMapping
valueMapping: any
Type : any
Optional

Optional: additional config for transforming the import value (passed to the sub-field's importMapFunction).

import { inject, Injectable } from "@angular/core";
import { StringDatatype } from "../string/string.datatype";
import { EntitySchemaField } from "../../entity/schema/entity-schema-field";
import { EntityMapperService } from "../../entity/entity-mapper/entity-mapper.service";
import { EntityActionsService } from "../../entity/entity-actions/entity-actions.service";
import { Logging } from "app/core/logging/logging.service";
import { ImportProcessingContext } from "../../import/import-processing-context";
import { splitArrayValue } from "../../import/split-array-value";
import { ColumnMapping } from "../../import/column-mapping";
import { EntitySchemaService } from "../../entity/schema/entity-schema.service";
import { Entity, EntityConstructor } from "../../entity/model/entity";
import {
  ColumnImportInput,
  ExportColumnMapping,
} from "../../entity/default-datatype/default.datatype";
import { EntityRegistry } from "../../entity/database-entity.decorator";

/**
 * Datatype for the EntitySchemaService to handle a single reference to another entity.
 * Stored as a simple ID string.
 *
 * Example:
 *
 * `@DatabaseField({dataType: 'entity', additional: 'Child'}) relatedEntity: string;`
 */
@Injectable()
export class EntityDatatype extends StringDatatype {
  private entityMapper = inject(EntityMapperService);
  private removeService = inject(EntityActionsService);
  private schemaService = inject(EntitySchemaService);
  private readonly entityRegistry = inject(EntityRegistry);

  static override dataType = "entity";
  static override label: string = $localize`:datatype-label:link to another record`;
  override editComponent = "EditEntity";
  override viewComponent = "DisplayEntity";
  override importConfigComponent = "EntityImportConfig";
  override importAllowsMultiMapping = true;

  override getExportColumns(
    schemaField: EntitySchemaField,
  ): ExportColumnMapping[] {
    if (!schemaField.label) {
      return [];
    }

    return [
      {
        keySuffix: "",
        label: schemaField.label,
        resolveValue: (value) => value,
      },
      {
        keySuffix: "_readable",
        label: schemaField.label + " (readable)",
        resolveValue: async (value: string | string[]) =>
          this.loadRelatedEntitiesToString(value, schemaField),
      },
    ];
  }

  /**
   * Matches an import row to actual entities in the database
   * (per-field entry point, see DefaultDatatype.importMatchField).
   *
   * Splits every mapped column's cell into individual values, resolves any
   * per-column value mapping, then searches candidate entities for each
   * combination of one value per column:
   *
   * Case target field isArray===false:
   * simple:
   *  IMPORT: { x: "x1", y: "y1" }
   *  --> matches { x: "x1", y: "y1" } (if there is a single unique match)
   * complex, multi-value import:
   *  IMPORT: { x: "x1,x2", y: "y1,y2" }
   *  --> matches { x1, y1 } OR { x2, y2 } OR { x1, y2 } OR { x2, y1 } (if there is a single combination that matches)
   *
   * Case target field isArray===true:
   * simple:
   *  IMPORT: { x: "x1", y: "y1" }
   *  --> matches every record with { x: "x1", y: "y1" }
   * complex, multi-value import:
   *  IMPORT: { x: "x1,x2", y: "y1,y2" }
   *  --> matches every record for any combination { x1, y1 }, { x2, y2 }, { x1, y2 }, { x2, y1 }
   *
   * @param schemaField the target field the value(s) are imported into
   * @param columns every column mapped to this field, with its raw cell value
   * @param importProcessingContext context shared across columns and rows
   * @returns the id of the single match (isArray=false) or the deduped ids of
   *   all matches (isArray=true), or undefined / [] if nothing matched
   */
  override async importMatchField(
    schemaField: EntitySchemaField,
    columns: ColumnImportInput[],
    importProcessingContext: ImportProcessingContext,
  ): Promise<string | string[] | undefined> {
    const context = new EntityFieldImportContext(
      importProcessingContext,
      schemaField,
    );
    await this.loadImportMapEntities(schemaField.additional, context);
    const candidates = context.entities ?? [];

    const criteria = await this.buildMatchCriteria(
      columns,
      context,
      importProcessingContext,
    );
    if (criteria.length === 0) {
      return schemaField.isArray ? [] : undefined;
    }

    // A candidate matches when, for every mapped column, its referenced field
    // value is one of that column's (possibly multiple) values. A mapped column
    // with no value yields an empty list that matches nothing, so an incomplete
    // row cannot match.
    const matchedIds = candidates
      .filter((entity) =>
        criteria.every((criterion) =>
          criterion.values.includes(normalizeValue(entity[criterion.refField])),
        ),
      )
      .map((entity) => entity._id);
    const uniqueIds = [...new Set(matchedIds)];

    if (schemaField.isArray) {
      return uniqueIds;
    }
    if (uniqueIds.length === 1) {
      return uniqueIds[0];
    }
    if (uniqueIds.length > 1) {
      Logging.debug(
        "No unique match found in EntityDatatype importMatchField",
        uniqueIds.length,
      );
    }
    return undefined;
  }

  /**
   * Resolve a single import value to the id of the referenced entity,
   * looking that entity up by the property given in `additional.refField`.
   *
   * This is relevant when an entity reference is matched through another entity
   * reference (e.g. import a Note's related Child, identified by the name of the
   * user linked in the Child's "responsible user" field). The nested value has to
   * be resolved to an id before it can be compared with the stored reference.
   *
   * @returns the id of the single matching entity or undefined if there is no unique match
   */
  override async importMapFunction(
    val: any,
    schemaField: EntitySchemaField,
    additional?: any,
    importProcessingContext?: ImportProcessingContext,
  ): Promise<string> {
    const config = normalizeEntityAdditional(additional);
    if (!config?.refField || !importProcessingContext) {
      return super.importMapFunction(
        val,
        schemaField,
        additional,
        importProcessingContext,
      );
    }

    const match = await this.importMatchField(
      // the caller compares against a single stored value, so never return an array here
      { ...schemaField, isArray: false },
      [
        {
          mapping: {
            column: "",
            propertyName: schemaField.id,
            // the value has already been split by the calling column
            additional: { ...config, enableSplitting: false },
          },
          rawCell: val,
        },
      ],
      importProcessingContext,
    );
    return match as string;
  }

  /**
   * Build one match criterion per mapped column: the referenced field to
   * compare and the acceptable (normalized) values parsed from the column cell.
   */
  private async buildMatchCriteria(
    columns: ColumnImportInput[],
    context: EntityFieldImportContext,
    importProcessingContext: ImportProcessingContext,
  ): Promise<EntityMatchCriterion[]> {
    const separator =
      importProcessingContext.importSettings.additionalSettings
        ?.multiValueSeparator ?? ",";

    const criteria: EntityMatchCriterion[] = [];
    for (const { mapping, rawCell } of columns) {
      const config = normalizeEntityAdditional(mapping.additional);
      if (!config?.refField) {
        // column not usable as an identifier for this field
        continue;
      }

      const values: string[] = [];
      for (const rawValue of this.splitCellValues(
        rawCell,
        mapping,
        separator,
      )) {
        const value = await this.resolveColumnValue(
          rawValue,
          config.refField,
          config.valueMapping,
          context,
          importProcessingContext,
        );
        if (value !== undefined) {
          values.push(value);
        }
      }
      // the criterion is added even if no value could be resolved:
      // an empty list matches nothing, whereas dropping the criterion would
      // silently relax the condition and match unrelated records
      criteria.push({ refField: config.refField, values });
    }
    return criteria;
  }

  /**
   * Split a raw cell into the individual values to match, honoring the column's
   * enableSplitting flag.
   */
  private splitCellValues(
    rawCell: unknown,
    mapping: ColumnMapping,
    separator: string,
  ): unknown[] {
    if (rawCell === undefined || rawCell === null) {
      return [];
    }
    const enableSplitting = mapping.additional?.enableSplitting ?? true;
    return enableSplitting ? splitArrayValue(rawCell, separator) : [rawCell];
  }

  /**
   * Resolves the effective comparison value for a column,
   * applying any configured value mapping through the referenced field's datatype.
   *
   * @returns the normalized value to compare against, or undefined if the value
   *   mapping could not resolve the import value (which must match nothing)
   */
  private async resolveColumnValue(
    rawValue: any,
    refField: string,
    valueMapping: any | undefined,
    context: EntityFieldImportContext,
    importProcessingContext: ImportProcessingContext,
  ): Promise<string | undefined> {
    if (valueMapping === undefined) {
      return normalizeValue(rawValue);
    }

    const refFieldSchema = context.refEntityCtor?.schema?.get(refField);
    const refDatatype = refFieldSchema
      ? this.schemaService.getDatatypeOrDefault(refFieldSchema.dataType)
      : null;

    if (!refDatatype) {
      return normalizeValue(rawValue);
    }

    const mappedValue = await refDatatype.importMapFunction(
      rawValue,
      refFieldSchema,
      valueMapping,
      importProcessingContext,
    );
    if (mappedValue === undefined || mappedValue === null) {
      // the import value could not be interpreted, so it cannot identify a record
      return undefined;
    }

    const dbFormat = refDatatype.transformToDatabaseFormat(
      mappedValue,
      refFieldSchema,
    );
    return normalizeValue(dbFormat);
  }

  /**
   * Load the required entity type's entities into context's cache if not available yet.
   */
  private async loadImportMapEntities(
    entityType: string,
    context: EntityFieldImportContext,
  ): Promise<void> {
    if (context.entities) {
      return;
    }

    try {
      context.entities = (await this.entityMapper.loadType(entityType)).map(
        (e) => this.schemaService.transformEntityToDatabaseFormat(e),
      );
      context.refEntityCtor = this.entityRegistry.get(entityType);
    } catch (error) {
      Logging.error("Error loading entities for import mapping:", error);
      context.entities = [];
    }
  }

  /**
   * Recursively calls anonymize on the referenced entity and saves it.
   * @param value
   * @param schemaField
   * @param parent
   */
  override async anonymize(
    value,
    schemaField: EntitySchemaField,
    parent,
  ): Promise<string> {
    const referencedEntity = await this.entityMapper.load(
      schemaField.additional,
      value,
    );

    if (!referencedEntity) {
      // TODO: remove broken references?
      return value;
    }

    await this.removeService.anonymize(referencedEntity);
    return value;
  }

  private async loadRelatedEntitiesToString(
    value: string | string[],
    schemaField: EntitySchemaField,
  ): Promise<string[]> {
    if (!value) return [];

    const relatedEntitiesToStrings: string[] = [];

    const relatedEntitiesIds: string[] = Array.isArray(value) ? value : [value];
    for (const relatedEntityId of relatedEntitiesIds) {
      const entityType =
        Entity.extractTypeFromId(relatedEntityId) || schemaField.additional;
      const relatedEntity = await this.entityMapper
        .load(entityType, relatedEntityId)
        .catch(() => undefined);

      relatedEntitiesToStrings.push(relatedEntity?.toString() ?? "<not_found>");
    }

    return relatedEntitiesToStrings;
  }
}

/**
 * One matching condition derived from a mapped import column: candidates must
 * have `refField` equal to one of the (normalized) `values`.
 */
interface EntityMatchCriterion {
  refField: string;
  values: string[];
}

/**
 * Structure for the `additional` field of an entity reference ColumnMapping.
 * Can be a plain string (legacy) or an object with optional valueMapping config.
 */
export interface EntityAdditional {
  /** The property of the referenced entity to match against the import value. */
  refField: string;
  /** Optional: additional config for transforming the import value (passed to the sub-field's importMapFunction). */
  valueMapping?: any;
}

/**
 * Normalizes the `additional` config of an entity reference column mapping.
 * Accepts legacy string format or new object format.
 */
export function normalizeEntityAdditional(
  additional: string | EntityAdditional | any,
): EntityAdditional | undefined {
  if (!additional) {
    return undefined;
  }
  if (typeof additional === "string") {
    return { refField: additional };
  }
  return additional as EntityAdditional;
}

/**
 * Normalizes a value for comparison, converting it to a standardized string format.
 * Ensures both numbers and strings are treated consistently.
 *
 * @param val The value to normalize.
 * @returns The normalized value as a string.
 */
function normalizeValue(val: any): string {
  if (val == null) {
    return "";
  }
  return String(val).trim().toLowerCase(); // Convert everything to string and trim spaces
}

/**
 * Manage cache access to the current import processing context.
 */
class EntityFieldImportContext {
  constructor(
    private globalContext: ImportProcessingContext,
    private schemaField: EntitySchemaField,
  ) {}

  /**
   * Entities (in database format for easier comparison!)
   */
  get entities(): any[] | undefined {
    return this.globalContext[`entities_${this.schemaField.additional}`];
  }

  set entities(value: any[]) {
    this.globalContext[`entities_${this.schemaField.additional}`] = value;
  }

  /**
   * Constructor of the referenced entity type (to access schema for value mapping)
   */
  get refEntityCtor(): EntityConstructor | undefined {
    return this.globalContext[`ctor_${this.schemaField.additional}`];
  }

  set refEntityCtor(value: EntityConstructor) {
    this.globalContext[`ctor_${this.schemaField.additional}`] = value;
  }
}

results matching ""

    No results matching ""