mikro-orm/mikro-orm

View on GitHub
packages/core/src/serialization/EntitySerializer.ts

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
import type { Collection } from '../entity/Collection';
import type {
  ArrayElement,
  AutoPath,
  CleanTypeConfig,
  Dictionary,
  EntityDTO,
  EntityDTOProp,
  EntityKey,
  EntityMetadata,
  EntityValue,
  FromEntityType,
  IPrimaryKey,
  Loaded,
  TypeConfig,
  UnboxArray,
} from '../typings';
import { helper } from '../entity/wrap';
import type { Platform } from '../platforms';
import { Utils } from '../utils/Utils';
import { type PopulatePath, ReferenceKind } from '../enums';
import { Reference } from '../entity/Reference';
import { SerializationContext } from './SerializationContext';
import { RawQueryFragment } from '../utils/RawQueryFragment';

function isVisible<T extends object>(meta: EntityMetadata<T>, propName: EntityKey<T>, options: SerializeOptions<T, any, any>): boolean {
  const prop = meta.properties[propName];

  if (options.groups && prop?.groups) {
     return prop.groups.some(g => options.groups!.includes(g));
  }

  if (Array.isArray(options.populate) && options.populate?.find(item => item === propName || item.startsWith(propName + '.') || item === '*')) {
    return true;
  }

  if (options.exclude?.find(item => item === propName)) {
    return false;
  }

  const visible = prop && !prop.hidden;
  const prefixed = prop && !prop.primary && propName.startsWith('_'); // ignore prefixed properties, if it's not a PK

  return visible && !prefixed;
}

function isPopulated(propName: string, options: SerializeOptions<any, any, any>): boolean {
  if (typeof options.populate !== 'boolean' && (options.populate as string[])?.find(item => item === propName || item.startsWith(propName + '.') || item === '*')) {
    return true;
  }

  if (typeof options.populate === 'boolean') {
    return options.populate;
  }

  return false;
}

export class EntitySerializer {

  static serialize<T extends object, P extends string = never, E extends string = never>(entity: T, options: SerializeOptions<T, P, E> = {}): EntityDTO<Loaded<T, P>> {
    const wrapped = helper(entity);
    const meta = wrapped.__meta;
    let contextCreated = false;

    if (!wrapped.__serializationContext.root) {
      const root = new SerializationContext<T>(wrapped.__config);
      SerializationContext.propagate(root, entity, (meta, prop) => meta.properties[prop]?.kind !== ReferenceKind.SCALAR);
      options.populate = (options.populate ? Utils.asArray(options.populate) : options.populate) as any;
      contextCreated = true;
    }

    const root = wrapped.__serializationContext.root!;
    const ret = {} as Dictionary;
    const keys = new Set<EntityKey<T>>(meta.primaryKeys);
    Utils.keys(entity as object).forEach(prop => keys.add(prop));
    const visited = root.visited.has(entity);

    if (!visited) {
      root.visited.add(entity);
    }

    [...keys]
      .filter(prop => isVisible<T>(meta, prop, options))
      .map(prop => {
        const cycle = root.visit(meta.className, prop);

        if (cycle && visited) {
          return [prop, undefined];
        }

        const val = this.processProperty<T>(prop, entity, options);

        if (!cycle) {
          root.leave(meta.className, prop);
        }

        if (options.skipNull && Utils.isPlainObject(val)) {
          Utils.dropUndefinedProperties(val, null);
        }

        if (val instanceof RawQueryFragment) {
          throw new Error(`Trying to serialize raw SQL fragment: '${val.sql}'`);
        }

        return [prop, val] as const;
      })
      .filter(([, value]) => typeof value !== 'undefined' && !(value === null && options.skipNull))
      .forEach(([prop, value]) => ret[this.propertyName(meta, prop!, wrapped.__platform)] = value as EntityDTOProp<T, EntityValue<T>>);

    if (contextCreated) {
      root.close();
    }

    if (!wrapped.isInitialized()) {
      return ret as EntityDTO<Loaded<T, P>>;
    }

    // decorated getters
    meta.props
      .filter(prop => prop.getter && prop.getterName === undefined && typeof entity[prop.name] !== 'undefined' && isVisible(meta, prop.name, options))
      // @ts-ignore
      .forEach(prop => ret[this.propertyName(meta, prop.name, wrapped.__platform)] = this.processProperty(prop.name, entity, options));

    // decorated get methods
    meta.props
      .filter(prop => prop.getterName && entity[prop.getterName] as unknown instanceof Function && isVisible(meta, prop.name, options))
      // @ts-ignore
      .forEach(prop => ret[this.propertyName(meta, prop.name, wrapped.__platform)] = this.processProperty(prop.getterName as keyof T & string, entity, options));

    return ret as EntityDTO<Loaded<T, P>>;
  }

  private static propertyName<T>(meta: EntityMetadata<T>, prop: EntityKey<T>, platform?: Platform): EntityKey<T> {
    /* istanbul ignore next */
    if (meta.properties[prop]?.serializedName) {
      return meta.properties[prop].serializedName as EntityKey<T>;
    }

    if (meta.properties[prop]?.primary && platform) {
      return platform.getSerializedPrimaryKeyField(prop) as EntityKey<T>;
    }

    return prop;
  }

  private static processProperty<T extends object>(prop: EntityKey<T>, entity: T, options: SerializeOptions<T, any, any>): EntityValue<T> | undefined {
    const parts = prop.split('.');
    prop = parts[0] as EntityKey<T>;
    const wrapped = helper(entity);
    const property = wrapped.__meta.properties[prop];
    const serializer = property?.serializer;
    const value = entity[prop];

    // getter method
    if (entity[prop] as unknown instanceof Function) {
      const returnValue = (entity[prop] as unknown as () => T[keyof T & string])();
      if (!options.ignoreSerializers && serializer) {
        return serializer(returnValue);
      }

      return returnValue as EntityValue<T>;
    }

    /* istanbul ignore next */
    if (!options.ignoreSerializers && serializer) {
      return serializer(value);
    }

    if (Utils.isCollection(value)) {
      return this.processCollection(prop, entity, options);
    }

    if (Utils.isEntity(value, true)) {
      return this.processEntity(prop, entity, wrapped.__platform, options);
    }

    if (Utils.isScalarReference(value)) {
      return value.unwrap();
    }

    /* istanbul ignore next */
    if (property?.kind === ReferenceKind.EMBEDDED) {
      if (Array.isArray(value)) {
        return (value as object[]).map(item => helper(item).toJSON()) as EntityValue<T>;
      }

      if (Utils.isObject(value)) {
        return helper(value!).toJSON() as EntityValue<T>;
      }
    }

    const customType = property?.customType;

    if (customType) {
      return customType.toJSON(value, wrapped.__platform);
    }

    return wrapped.__platform.normalizePrimaryKey(value as unknown as IPrimaryKey) as unknown as EntityValue<T>;
  }

  private static extractChildOptions<T extends object, U extends object>(options: SerializeOptions<T, any, any>, prop: EntityKey<T>): SerializeOptions<U, any> {
    return {
      ...options,
      populate: Array.isArray(options.populate) ? Utils.extractChildElements(options.populate, prop, '*') : options.populate,
      exclude: Array.isArray(options.exclude) ? Utils.extractChildElements(options.exclude, prop) : options.exclude,
    } as SerializeOptions<U, any>;
  }

  private static processEntity<T extends object>(prop: EntityKey<T>, entity: T, platform: Platform, options: SerializeOptions<T, any, any>): EntityValue<T> | undefined {
    const child = Reference.unwrapReference(entity[prop] as T);
    const wrapped = helper(child);
    const populated = isPopulated(prop, options) && wrapped.isInitialized();
    const expand = populated || !wrapped.__managed;
    const meta = wrapped.__meta;
    const childOptions = this.extractChildOptions(options, prop) as Dictionary;
    const visible = meta.primaryKeys.filter(prop => isVisible(meta, prop, childOptions));

    if (expand) {
      return this.serialize(child, childOptions) as EntityValue<T>;
    }

    const pk = wrapped.getPrimaryKey(true)!;

    if (options.forceObject || wrapped.__config.get('serialization').forceObject) {
      return Utils.primaryKeyToObject(meta, pk, visible) as EntityValue<T>;
    }

    if (Utils.isPlainObject(pk)) {
      const pruned = Utils.primaryKeyToObject(meta, pk, visible) as EntityValue<T>;

      if (visible.length === 1) {
        return platform.normalizePrimaryKey(pruned[visible[0]] as IPrimaryKey) as EntityValue<T>;
      }

      return pruned;
    }

    return platform.normalizePrimaryKey(pk as IPrimaryKey) as EntityValue<T>;
  }

  private static processCollection<T extends object>(prop: EntityKey<T>, entity: T, options: SerializeOptions<T, any, any>): EntityValue<T> | undefined {
    const col = entity[prop] as unknown as Collection<T>;

    if (!col.isInitialized()) {
      return undefined;
    }

    return col.getItems(false).map(item => {
      const populated = isPopulated(prop, options);
      const wrapped = helper(item);

      if (populated || !wrapped.__managed) {
        return this.serialize(item, this.extractChildOptions(options, prop));
      }

      if (options.forceObject || wrapped.__config.get('serialization').forceObject) {
        return Utils.primaryKeyToObject(wrapped.__meta, wrapped.getPrimaryKey(true)!) as EntityValue<T>;
      }

      return helper(item).getPrimaryKey();
    }) as unknown as EntityValue<T>;
  }

}

export interface SerializeOptions<T, P extends string = never, E extends string = never> {
  /** Specify which relation should be serialized as populated and which as a FK. */
  populate?: readonly AutoPath<T, P, `${PopulatePath.ALL}`>[];

  /** Specify which properties should be omitted. */
  exclude?: readonly AutoPath<T, E>[];

  /** Enforce unpopulated references to be returned as objects, e.g. `{ author: { id: 1 } }` instead of `{ author: 1 }`. */
  forceObject?: boolean;

  /** Ignore custom property serializers. */
  ignoreSerializers?: boolean;

  /** Skip properties with `null` value. */
  skipNull?: boolean;

  /** Only include properties for a specific group. If a property does not specify any group, it will be included, otherwise only properties with a matching group are included. */
  groups?: string[];
}

/**
 * Converts entity instance to POJO, converting the `Collection`s to arrays and unwrapping the `Reference` wrapper, while respecting the serialization options.
 * This method accepts either a single entity or an array of entities, and returns the corresponding POJO or an array of POJO.
 * To serialize a single entity, you can also use `wrap(entity).serialize()` which handles a single entity only.
 *
 * ```ts
 * const dtos = serialize([user1, user, ...], { exclude: ['id', 'email'], forceObject: true });
 * const [dto2, dto3] = serialize([user2, user3], { exclude: ['id', 'email'], forceObject: true });
 * const dto1 = serialize(user, { exclude: ['id', 'email'], forceObject: true });
 * const dto2 = wrap(user).serialize({ exclude: ['id', 'email'], forceObject: true });
 * ```
 */
export function serialize<
  Entity extends object,
  Naked extends FromEntityType<Entity> = FromEntityType<Entity>,
  Populate extends string = never,
  Exclude extends string = never,
  Config extends TypeConfig = never,
>(entity: Entity, options?: Config & SerializeOptions<UnboxArray<Entity>, Populate, Exclude>): Naked extends object[] ? EntityDTO<Loaded<ArrayElement<Naked>, Populate>, CleanTypeConfig<Config>>[] : EntityDTO<Loaded<Naked, Populate>, CleanTypeConfig<Config>>;

/**
 * Converts entity instance to POJO, converting the `Collection`s to arrays and unwrapping the `Reference` wrapper, while respecting the serialization options.
 * This method accepts either a single entity or an array of entities, and returns the corresponding POJO or an array of POJO.
 * To serialize a single entity, you can also use `wrap(entity).serialize()` which handles a single entity only.
 *
 * ```ts
 * const dtos = serialize([user1, user, ...], { exclude: ['id', 'email'], forceObject: true });
 * const [dto2, dto3] = serialize([user2, user3], { exclude: ['id', 'email'], forceObject: true });
 * const dto1 = serialize(user, { exclude: ['id', 'email'], forceObject: true });
 * const dto2 = wrap(user).serialize({ exclude: ['id', 'email'], forceObject: true });
 * ```
 */
export function serialize<
  Entity extends object,
  Naked extends FromEntityType<Entity> = FromEntityType<Entity>,
  Populate extends string = never,
  Exclude extends string = never,
  Config extends TypeConfig = never,
>(entities: Entity | Entity[], options?: SerializeOptions<Entity, Populate, Exclude>): EntityDTO<Loaded<Naked, Populate>, CleanTypeConfig<Config>> | EntityDTO<Loaded<Naked, Populate>, CleanTypeConfig<Config>>[] {
  if (Array.isArray(entities)) {
    return entities.map(e => EntitySerializer.serialize(e, options)) as any;
  }

  return EntitySerializer.serialize(entities, options) as any;
}