src/app/core/basic-datatypes/schema-embed/schema-embed.datatype.ts

Description

Configuration for the "schema-embed" datatype's additional field.

Defines the inner schema of the embedded object, using the same format as entity attributes config: a map of field names to their schema definitions.

Example :
@DatabaseField({
  dataType: "schema-embed",
  additional: {
    "phoneNumber": { dataType: "string" },
    "type": { dataType: "string" }
  } as SchemaEmbedDatatypeAdditional
})
phoneNumber: { phoneNumber: string; type: string };

import {
  DefaultDatatype,
  referenceIdConditions,
} from "../../entity/default-datatype/default.datatype";
import { EntitySchemaService } from "../../entity/schema/entity-schema.service";
import { EntitySchemaField } from "../../entity/schema/entity-schema-field";
import { getInnerEntityReferenceFields } from "../../entity/schema/entity-reference-fields";
import type { DataFilter } from "../../filter/filters/filters";
import {
  EntitySchema,
  SchemaEmbeddedType,
} from "../../entity/schema/entity-schema";
import { inject, Injectable } from "@angular/core";

/**
 * Configuration for the "schema-embed" datatype's `additional` field.
 *
 * Defines the inner schema of the embedded object, using the same format as
 * entity `attributes` config: a map of field names to their schema definitions.
 *
 * @example
 * ```
 * @DatabaseField({
 *   dataType: "schema-embed",
 *   additional: {
 *     "phoneNumber": { dataType: "string" },
 *     "type": { dataType: "string" }
 *   } as SchemaEmbedDatatypeAdditional
 * })
 * phoneNumber: { phoneNumber: string; type: string };
 * ```
 */
export interface SchemaEmbedDatatypeAdditional {
  [fieldId: string]: EntitySchemaField;
}

/**
 * Build the inner schema of an embedded field from the embedded type's own schema
 * and the field's `additional` config (a {@link SchemaEmbedDatatypeAdditional} map).
 *
 * An entry in `additional` only overrides what it states explicitly (e.g. restricting the
 * allowed entity types of a reference), everything else (`dataType`, `label`, ...)
 * is inherited from the embedded type's field of the same id.
 *
 * @param baseSchema schema of the embedded type (e.g. `embeddedType.schema`), not mutated
 * @param additional the `additional` config of the field holding the embedded object(s)
 */
export function mergeEmbeddedSchema(
  baseSchema: EntitySchema | undefined,
  additional: unknown,
): EntitySchema {
  const schema: EntitySchema = new Map(baseSchema ?? []);
  if (
    !additional ||
    typeof additional !== "object" ||
    Array.isArray(additional)
  ) {
    return schema;
  }

  for (const [key, value] of Object.entries(
    additional as SchemaEmbedDatatypeAdditional,
  )) {
    if (value && typeof value === "object") {
      schema.set(key, { ...schema.get(key), ...value, id: key });
    }
  }
  return schema;
}

/**
 * Datatype for the EntitySchemaService transforming values of complex objects recursively.
 *
 * Can be used in two ways:
 * 1. **Config-based** (directly via annotation): Set `dataType: "schema-embed"` and define the inner schema
 *    in `additional` as a {@link SchemaEmbedDatatypeAdditional} map.
 * 2. **Subclass-based** (extending this class): Override `embeddedType` to point to a class
 *    with `@DatabaseField()` annotations.
 *
 * The config in `additional` is merged with the `embeddedType` schema (if present),
 * allowing runtime config to extend or override a class's annotations.
 */
@Injectable()
export class SchemaEmbedDatatype<
  EntityType = any,
  DBType = any,
> extends DefaultDatatype<EntityType, DBType> {
  static override readonly dataType: string = "schema-embed";

  embeddedType?: SchemaEmbeddedType<EntityType>;

  protected readonly schemaService = inject(EntitySchemaService);

  /**
   * Build the effective inner schema from the embedded type's annotations
   * and/or the `additional` config.
   */
  getEffectiveSchema(schemaField?: EntitySchemaField): EntitySchema {
    return mergeEmbeddedSchema(
      this.embeddedType?.schema,
      schemaField?.additional,
    );
  }

  /**
   * Whether the given field holds an array of embedded objects
   * (as opposed to a single embedded object).
   *
   * Can be removed once {@link EventAttendanceMapDatatype} is removed.
   */
  holdsArray(schemaField: EntitySchemaField): boolean {
    return schemaField.isArray === true;
  }

  /**
   * Path of an inner property within a stored embedded object, for database queries.
   * The embedded object is stored as-is, so this is the property name itself.
   *
   * Can be removed once {@link EventAttendanceMapDatatype} is removed.
   */
  getEmbeddedFieldDbPath(innerProp: string): string {
    return innerProp;
  }

  /**
   * The embedded object is stored as-is, so an inner reference is matched through the
   * outer field - either inside an array of embedded objects or, where a single object
   * is stored, through the dotted path to the inner property.
   *
   * The inner keys come from {@link getEffectiveSchema}.
   */
  override getReferenceSelector(
    fieldId: string,
    schemaField: EntitySchemaField,
    referencedId: string,
  ): DataFilter<any>[] | undefined {
    const innerKeys = getInnerEntityReferenceFields(
      this.getEffectiveSchema(schemaField),
    ).map(([key]) => key);
    if (innerKeys.length === 0) {
      // nothing we could match on - let the caller load the whole type instead
      return undefined;
    }

    return innerKeys.flatMap((key) => [
      { [fieldId]: { $elemMatch: { [key]: referencedId } } },
      {
        [fieldId]: {
          $elemMatch: { [key]: { $elemMatch: { $eq: referencedId } } },
        },
      },
      ...referenceIdConditions(`${fieldId}.${key}`, referencedId),
    ]);
  }

  override transformToDatabaseFormat(
    value: EntityType,
    schemaField?: EntitySchemaField,
  ): DBType {
    const schema = this.getEffectiveSchema(schemaField);
    return this.schemaService.transformEntityToDatabaseFormat(
      value as any,
      schema,
    ) as DBType;
  }

  override transformToObjectFormat(
    value: DBType,
    schemaField?: EntitySchemaField,
  ): EntityType {
    const schema = this.getEffectiveSchema(schemaField);

    const transformedValue =
      this.schemaService.transformDatabaseToEntityFormat<EntityType>(
        value,
        schema,
      );

    if (this.embeddedType) {
      const instance = new this.embeddedType();
      Object.assign(instance, transformedValue);
      return instance;
    }

    return transformedValue;
  }
}

results matching ""

    No results matching ""