File

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

Description

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;

Extends

StringDatatype

Index

Properties
Methods

Methods

Async anonymize
anonymize(value: unknown, schemaField: EntitySchemaField, parent: unknown)
Inherited from DefaultDatatype

Recursively calls anonymize on the referenced entity and saves it.

Parameters :
Name Type Optional
value unknown No
schemaField EntitySchemaField No
parent unknown No
Returns : Promise<string>
getExportColumns
getExportColumns(schemaField: EntitySchemaField)
Inherited from DefaultDatatype
Defined in DefaultDatatype:52
Parameters :
Name Type Optional
schemaField EntitySchemaField No
Returns : ExportColumnMapping[]
Async importMapFunction
importMapFunction(val: any, schemaField: EntitySchemaField, additional: string | EntityAdditional, importProcessingContext: ImportProcessingContext)
Inherited from DefaultDatatype
Defined in DefaultDatatype:88

Maps a value from an import to an actual entity in the database.

Finds all column mappings targeting this field, resolves each column's comparison value (applying any configured value mapping), and progressively filters candidate entities until a unique match is found.

Can be a plain string (legacy) or an object { refField: string, valueMapping?: any }.

Parameters :
Name Type Optional Description
val any No

The value from an import that should be mapped to an entity reference.

schemaField EntitySchemaField No

The config defining details of the field that will hold the entity reference after mapping.

additional string | EntityAdditional No

The field of the referenced entity that should be compared with the val. Can be a plain string (legacy) or an object { refField: string, valueMapping?: any }.

importProcessingContext ImportProcessingContext No

context to share information across calls for multiple columns and rows.

Returns : Promise<string | undefined>

Promise resolving to the ID of the matched entity or undefined if no match is found.

transformToDatabaseFormat
transformToDatabaseFormat(value: unknown)
Inherited from DefaultDatatype
Defined in DefaultDatatype:39
Parameters :
Name Type Optional
value unknown No
Returns : any
transformToObjectFormat
transformToObjectFormat(value: unknown)
Inherited from DefaultDatatype
Defined in DefaultDatatype:46
Parameters :
Name Type Optional
value unknown No
Returns : any
Static detectAllFieldsInEntity
detectAllFieldsInEntity(entityOrType: Entity | EntityConstructor, dataTypes: string | string[])
Inherited from DefaultDatatype
Defined in DefaultDatatype:98

Detect all fields of the given datatype(s) in an entity's schema.

Parameters :
Name Type Optional Description
entityOrType Entity | EntityConstructor No

An entity instance or entity constructor to inspect.

dataTypes string | string[] No

One or more datatype identifiers to match against.

Returns : literal type[]

Array of matching fields with their id and schema definition.

Static detectFieldInEntity
detectFieldInEntity(entityOrType: Entity | EntityConstructor, dataTypes: string | string[])
Inherited from DefaultDatatype
Defined in DefaultDatatype:83

Detect the first field of the given datatype(s) in an entity's schema.

Scans the schema for a field whose dataType matches one of the provided strings and returns its property name.

Subclasses typically override this without the extra dataTypes parameter, forwarding their own relevant datatype identifiers.

Parameters :
Name Type Optional Description
entityOrType Entity | EntityConstructor No

An entity instance or entity constructor to inspect.

dataTypes string | string[] No

One or more datatype identifiers to match against.

Returns : string | undefined

The field name of the first matching field, or undefined if none is found.

normalizeSchemaField
normalizeSchemaField(schemaField: EntitySchemaField)
Inherited from DefaultDatatype

Return the (potentially adjusted) schema field for this datatype.

Called when schema fields are set up (e.g. from config), allowing the datatype to normalize or fill in required settings.

Override this in a subclass to enforce constraints (e.g. always setting isArray: true).

Parameters :
Name Type Optional Description
schemaField EntitySchemaField No

The current schema field definition

Returns : EntitySchemaField

The schema field to use (default: unchanged)

sortValue
sortValue(_fieldValue: EntityType)
Inherited from DefaultDatatype

Return a comparable primitive for sorting this field's value in a list column. Return undefined to fall through to the default sort logic. Override this in datatypes that store arrays or complex objects where the raw value cannot be sorted meaningfully.

Parameters :
Name Type Optional
_fieldValue EntityType No
Returns : number | string | undefined

Properties

Static dataType
Type : string
Default value : "entity"
Inherited from DefaultDatatype
Defined in DefaultDatatype:45
editComponent
Type : string
Default value : "EditEntity"
Inherited from DefaultDatatype
Defined in DefaultDatatype:47
importAllowsMultiMapping
Type : unknown
Default value : true
Inherited from DefaultDatatype
Defined in DefaultDatatype:50
importConfigComponent
Type : string
Default value : "EntityImportConfig"
Inherited from DefaultDatatype
Defined in DefaultDatatype:49
Static label
Type : string
Default value : $localize`:datatype-label:link to another record`
Inherited from DefaultDatatype
Defined in DefaultDatatype:46
viewComponent
Type : string
Default value : "DisplayEntity"
Inherited from DefaultDatatype
Defined in DefaultDatatype:48
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 { EntitySchemaService } from "../../entity/schema/entity-schema.service";
import { Entity, EntityConstructor } from "../../entity/model/entity";
import { 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),
      },
    ];
  }

  /**
   * Maps a value from an import to an actual entity in the database.
   *
   * Finds all column mappings targeting this field, resolves each column's comparison value
   * (applying any configured value mapping), and progressively filters candidate entities
   * until a unique match is found.
   *
   * @param val The value from an import that should be mapped to an entity reference.
   * @param schemaField The config defining details of the field that will hold the entity reference after mapping.
   * @param additional The field of the referenced entity that should be compared with the val.
   *   Can be a plain string (legacy) or an object `{ refField: string, valueMapping?: any }`.
   * @param importProcessingContext context to share information across calls for multiple columns and rows.
   * @returns Promise resolving to the ID of the matched entity or undefined if no match is found.
   */
  override async importMapFunction(
    val: any,
    schemaField: EntitySchemaField,
    additional: string | EntityAdditional,
    importProcessingContext: ImportProcessingContext,
  ): Promise<string | undefined> {
    const fieldConfig = normalizeEntityAdditional(additional);
    if (!fieldConfig?.refField || val == null) {
      return undefined;
    }

    const context = new EntityFieldImportContext(
      importProcessingContext,
      schemaField,
    );

    await this.loadImportMapEntities(schemaField.additional, context);

    const columnMappings = this.getColumnMappingsForField(
      schemaField.id,
      importProcessingContext,
    );

    for (const mapping of columnMappings) {
      const mappingConfig = normalizeEntityAdditional(mapping.additional);
      const rawValue = importProcessingContext.row[mapping.column];

      const expectedValue = await this.resolveColumnValue(
        rawValue,
        mappingConfig?.refField,
        mappingConfig?.valueMapping,
        context,
        importProcessingContext,
      );

      context.filteredEntities = context.filteredEntities.filter(
        (entity) =>
          normalizeValue(entity[mappingConfig?.refField]) === expectedValue,
      );
    }

    return this.pickSingleMatch(context.filteredEntities);
  }

  /**
   * Returns all column mappings that target the given field.
   */
  private getColumnMappingsForField(
    fieldId: string,
    importProcessingContext: ImportProcessingContext,
  ) {
    return importProcessingContext.importSettings.columnMapping.filter(
      (m) => m.propertyName === fieldId,
    );
  }

  /**
   * Resolves the effective comparison value for a column,
   * applying any configured value mapping through the referenced field's datatype.
   */
  private async resolveColumnValue(
    rawValue: any,
    refField: string,
    valueMapping: any | undefined,
    context: EntityFieldImportContext,
    importProcessingContext: ImportProcessingContext,
  ): Promise<string> {
    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,
    );
    const dbFormat = refDatatype.transformToDatabaseFormat(
      mappedValue,
      refFieldSchema,
    );
    return normalizeValue(dbFormat);
  }

  /**
   * Returns the entity ID if exactly one candidate remains, otherwise undefined.
   */
  private pickSingleMatch(candidates: any[]): string | undefined {
    if (candidates.length === 1) {
      return candidates[0]._id;
    }
    if (candidates.length > 1) {
      Logging.debug(
        "No unique match found in EntityDatatype importMapFunction",
        candidates.length,
      );
    }
    return undefined;
  }

  /**
   * 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;
  }
}

/**
 * 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 {
  private contextKey: string;

  constructor(
    private globalContext: ImportProcessingContext,
    private schemaField: EntitySchemaField,
  ) {
    this.contextKey = `${schemaField.id}_${globalContext.rowIndex}`;

    if (!globalContext[this.contextKey]) {
      globalContext[this.contextKey] = {};
    }
  }

  /**
   * 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;
  }

  /**
   * Entities already filter by any other column conditions
   * (in database format for easier comparison!)
   */
  get filteredEntities(): any[] {
    return (
      this.globalContext[this.contextKey].filteredEntities ??
      this.entities ??
      []
    );
  }

  set filteredEntities(value: any[]) {
    this.globalContext[this.contextKey].filteredEntities = value;
  }
}

results matching ""

    No results matching ""