mikro-orm/mikro-orm

View on GitHub
packages/core/src/metadata/MetadataValidator.ts

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
import type { EntityMetadata, EntityName, EntityProperty } from '../typings';
import { type MetadataDiscoveryOptions, Utils } from '../utils';
import { MetadataError } from '../errors';
import { ReferenceKind } from '../enums';
import type { MetadataStorage } from './MetadataStorage';

/**
 * @internal
 */
export class MetadataValidator {

  /**
   * Validate there is only one property decorator. This disallows using `@Property()` together with e.g. `@ManyToOne()`
   * on the same property. One should use only `@ManyToOne()` in such case.
   * We allow the existence of the property in metadata if the reference type is the same, this should allow things like HMR to work.
   */
  static validateSingleDecorator(meta: EntityMetadata, propertyName: string, reference: ReferenceKind): void {
    if (meta.properties[propertyName] && meta.properties[propertyName].kind !== reference) {
      throw MetadataError.multipleDecorators(meta.className, propertyName);
    }
  }

  validateEntityDefinition<T>(metadata: MetadataStorage, name: string, options: MetadataDiscoveryOptions): void {
    const meta = metadata.get<T>(name);

    if (meta.virtual || meta.expression) {
      for (const prop of Utils.values(meta.properties)) {
        if (![ReferenceKind.SCALAR, ReferenceKind.EMBEDDED, ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind)) {
          throw new MetadataError(`Only scalars, embedded properties and to-many relations are allowed inside virtual entity. Found '${prop.kind}' in ${meta.className}.${prop.name}`);
        }

        if (prop.primary) {
          throw new MetadataError(`Virtual entity ${meta.className} cannot have primary key ${meta.className}.${prop.name}`);
        }
      }

      return;
    }

    // entities have PK
    if (!meta.embeddable && (!meta.primaryKeys || meta.primaryKeys.length === 0)) {
      throw MetadataError.fromMissingPrimaryKey(meta);
    }

    this.validateVersionField(meta);
    this.validateDuplicateFieldNames(meta, options);
    this.validateIndexes(meta, meta.indexes ?? [], 'index');
    this.validateIndexes(meta, meta.uniques ?? [], 'unique');

    for (const prop of Utils.values(meta.properties)) {
      if (prop.kind !== ReferenceKind.SCALAR) {
        this.validateReference(meta, prop, metadata);
        this.validateBidirectional(meta, prop, metadata);
      } else if (metadata.has(prop.type)) {
        throw MetadataError.propertyTargetsEntityType(meta, prop, metadata.get(prop.type));
      }
    }
  }

  validateDiscovered(discovered: EntityMetadata[], options: MetadataDiscoveryOptions): void {
    if (discovered.length === 0 && options.warnWhenNoEntities) {
      throw MetadataError.noEntityDiscovered();
    }

    const duplicates = Utils.findDuplicates(discovered.map(meta => meta.className));

    if (duplicates.length > 0 && options.checkDuplicateEntities) {
      throw MetadataError.duplicateEntityDiscovered(duplicates);
    }

    const tableNames = discovered.filter(meta => !meta.abstract && meta === meta.root && (meta.tableName || meta.collection) && meta.schema !== '*');
    const duplicateTableNames = Utils.findDuplicates(tableNames.map(meta => {
      const tableName = meta.tableName || meta.collection;
      return (meta.schema ? '.' + meta.schema : '') + tableName;
    }));

    if (duplicateTableNames.length > 0 && options.checkDuplicateTableNames && options.checkDuplicateEntities) {
      throw MetadataError.duplicateEntityDiscovered(duplicateTableNames, 'table names');
    }

    // validate we found at least one entity (not just abstract/base entities)
    if (discovered.filter(meta => meta.name).length === 0 && options.warnWhenNoEntities) {
      throw MetadataError.onlyAbstractEntitiesDiscovered();
    }

    const unwrap = (type: string) => type
      .replace(/Array<(.*)>/, '$1') // unwrap array
      .replace(/\[]$/, '')          // remove array suffix
      .replace(/\((.*)\)/, '$1');   // unwrap union types

    const name = <T> (p: EntityName<T> | (() => EntityName<T>)): string => {
      if (typeof p === 'function') {
        return Utils.className((p as () => EntityName<T>)());
      }

      return Utils.className(p);
    };

    const pivotProps = new Map<string, { prop: EntityProperty; meta: EntityMetadata }[]>();

    // check for not discovered entities
    discovered.forEach(meta => Object.values(meta.properties).forEach(prop => {
      if (prop.kind !== ReferenceKind.SCALAR && !unwrap(prop.type).split(/ ?\| ?/).every(type => discovered.find(m => m.className === type))) {
        throw MetadataError.fromUnknownEntity(prop.type, `${meta.className}.${prop.name}`);
      }

      if (prop.pivotEntity) {
        const props = pivotProps.get(name(prop.pivotEntity)) ?? [];
        props.push({ meta, prop });
        pivotProps.set(name(prop.pivotEntity), props);
      }
    }));

    pivotProps.forEach(props => {
      // if the pivot entity is used in more than one property, check if they are linked
      if (props.length > 1 && props.every(p => !p.prop.mappedBy && !p.prop.inversedBy)) {
        throw MetadataError.invalidManyToManyWithPivotEntity(props[0].meta, props[0].prop, props[1].meta, props[1].prop);
      }
    });
  }

  private validateReference(meta: EntityMetadata, prop: EntityProperty, metadata: MetadataStorage): void {
    // references do have types
    if (!prop.type) {
      throw MetadataError.fromWrongTypeDefinition(meta, prop);
    }

    // references do have type of known entity
    if (!metadata.find(prop.type)) {
      throw MetadataError.fromWrongTypeDefinition(meta, prop);
    }

    if (metadata.find(prop.type)!.abstract && !metadata.find(prop.type)!.discriminatorColumn) {
      throw MetadataError.targetIsAbstract(meta, prop);
    }
  }

  private validateBidirectional(meta: EntityMetadata, prop: EntityProperty, metadata: MetadataStorage): void {
    if (prop.inversedBy) {
      const inverse = metadata.get(prop.type).properties[prop.inversedBy];
      this.validateOwningSide(meta, prop, inverse, metadata);
    } else if (prop.mappedBy) {
      const inverse = metadata.get(prop.type).properties[prop.mappedBy];
      this.validateInverseSide(meta, prop, inverse, metadata);
    } else {
      // 1:m property has `mappedBy`
      if (prop.kind === ReferenceKind.ONE_TO_MANY && !prop.mappedBy) {
        throw MetadataError.fromMissingOption(meta, prop, 'mappedBy');
      }
    }
  }

  private validateOwningSide(meta: EntityMetadata, prop: EntityProperty, inverse: EntityProperty, metadata: MetadataStorage): void {
    // has correct `inversedBy` on owning side
    if (!inverse) {
      throw MetadataError.fromWrongReference(meta, prop, 'inversedBy');
    }

    const targetClassName = metadata.find(inverse.type)?.root.className;

    // has correct `inversedBy` reference type
    if (inverse.type !== meta.className && targetClassName !== meta.root.className) {
      throw MetadataError.fromWrongReference(meta, prop, 'inversedBy', inverse);
    }

    // inverse side is not defined as owner
    if (inverse.inversedBy || inverse.owner) {
      throw MetadataError.fromWrongOwnership(meta, prop, 'inversedBy');
    }
  }

  private validateInverseSide(meta: EntityMetadata, prop: EntityProperty, owner: EntityProperty, metadata: MetadataStorage): void {
    // has correct `mappedBy` on inverse side
    if (prop.mappedBy && !owner) {
      throw MetadataError.fromWrongReference(meta, prop, 'mappedBy');
    }

    // has correct `mappedBy` reference type
    if (owner.type !== meta.className && metadata.find(owner.type)?.root.className !== meta.root.className) {
      throw MetadataError.fromWrongReference(meta, prop, 'mappedBy', owner);
    }

    // owning side is not defined as inverse
    if (owner.mappedBy) {
      throw MetadataError.fromWrongOwnership(meta, prop, 'mappedBy');
    }

    // owning side is not defined as inverse
    const valid = [
      { owner: ReferenceKind.MANY_TO_ONE, inverse: ReferenceKind.ONE_TO_MANY },
      { owner: ReferenceKind.MANY_TO_MANY, inverse: ReferenceKind.MANY_TO_MANY },
      { owner: ReferenceKind.ONE_TO_ONE, inverse: ReferenceKind.ONE_TO_ONE },
    ];

    if (!valid.find(spec => spec.owner === owner.kind && spec.inverse === prop.kind)) {
      throw MetadataError.fromWrongReferenceKind(meta, owner, prop);
    }

    if (prop.primary) {
      throw MetadataError.fromInversideSidePrimary(meta, owner, prop);
    }
  }

  private validateIndexes(meta: EntityMetadata, indexes: { properties: string | string[] }[], type: 'index' | 'unique'): void {
    for (const index of indexes) {
      for (const propName of Utils.asArray(index.properties)) {
        const prop = meta.root.properties[propName];

        if (!prop && !Object.values(meta.root.properties).some(p => propName.startsWith(p.name + '.'))) {
          throw MetadataError.unknownIndexProperty(meta, propName, type);
        }
      }
    }
  }

  private validateDuplicateFieldNames(meta: EntityMetadata, options: MetadataDiscoveryOptions): void {
    const candidates = Object.values(meta.properties)
      .filter(prop => prop.persist !== false && !prop.inherited && prop.fieldNames?.length === 1 && (prop.kind !== ReferenceKind.EMBEDDED || prop.object))
      .map(prop => prop.fieldNames[0]);
    const duplicates = Utils.findDuplicates(candidates);

    if (duplicates.length > 0 && options.checkDuplicateFieldNames) {
      const pairs = duplicates.flatMap(name => {
        return Object.values(meta.properties)
          .filter(p => p.fieldNames?.[0] === name)
          .map(prop => {
            return [prop.embedded ? prop.embedded.join('.') : prop.name, prop.fieldNames[0]] as [string, string];
          });
      });

      throw MetadataError.duplicateFieldName(meta.className, pairs);
    }
  }

  private validateVersionField(meta: EntityMetadata): void {
    if (!meta.versionProperty) {
      return;
    }

    const props = Object.values(meta.properties).filter(p => p.version);

    if (props.length > 1) {
      throw MetadataError.multipleVersionFields(meta, props.map(p => p.name));
    }

    const prop = meta.properties[meta.versionProperty];
    const type = prop.runtimeType ?? prop.columnTypes?.[0] ?? prop.type;

    if (type !== 'number' && type !== 'Date' && !type.startsWith('timestamp') && !type.startsWith('datetime')) {
      throw MetadataError.invalidVersionFieldType(meta);
    }
  }

}