src/app/core/basic-datatypes/entity/entity.datatype.ts
Structure for the additional field of an entity reference ColumnMapping.
Can be a plain string (legacy) or an object with optional valueMapping config.
Properties |
|
| refField |
refField:
|
Type : string
|
|
The property of the referenced entity to match against the import value. |
| valueMapping |
valueMapping:
|
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;
}
}