UNPKG

@mikro-orm/core

Version:

TypeScript ORM for Node.js based on Data Mapper, Unit of Work and Identity Map patterns. Supports MongoDB, MySQL, PostgreSQL and SQLite databases as well as usage with vanilla JavaScript.

1,012 lines 102 kB
import { EntityMetadata, } from '../typings.js'; import { compareArrays, Utils } from '../utils/Utils.js'; import { QueryHelper } from '../utils/QueryHelper.js'; import { MetadataValidator } from './MetadataValidator.js'; import { MetadataProvider } from './MetadataProvider.js'; import { MetadataStorage } from './MetadataStorage.js'; import { EntitySchema } from './EntitySchema.js'; import { Cascade, ReferenceKind } from '../enums.js'; import { MetadataError } from '../errors.js'; import { t, Type } from '../types/index.js'; import { colors } from '../logging/colors.js'; import { raw, Raw } from '../utils/RawQueryFragment.js'; import { BaseEntity } from '../entity/BaseEntity.js'; /** Discovers, validates, and processes entity metadata from configured sources. */ export class MetadataDiscovery { #namingStrategy; #metadataProvider; #logger; #schemaHelper; #validator = new MetadataValidator(); #discovered = []; #metadata; #platform; #config; constructor(metadata, platform, config) { this.#metadata = metadata; this.#platform = platform; this.#config = config; this.#namingStrategy = this.#config.getNamingStrategy(); this.#metadataProvider = this.#config.getMetadataProvider(); this.#logger = this.#config.getLogger(); this.#schemaHelper = this.#platform.getSchemaHelper(); } /** Discovers all entities asynchronously and returns the populated MetadataStorage. */ async discover(preferTs = true) { this.#discovered.length = 0; const startTime = Date.now(); const suffix = this.#metadataProvider.constructor === MetadataProvider ? '' : `, using ${colors.cyan(this.#metadataProvider.constructor.name)}`; this.#logger.log('discovery', `ORM entity discovery started${suffix}`); await this.findEntities(preferTs); for (const meta of this.#discovered) { /* v8 ignore next */ await this.#config.get('discovery').onMetadata?.(meta, this.#platform); } this.processDiscoveredEntities(this.#discovered); const diff = Date.now() - startTime; this.#logger.log('discovery', `- entity discovery finished, found ${colors.green('' + this.#discovered.length)} entities, took ${colors.green(`${diff} ms`)}`); const storage = this.mapDiscoveredEntities(); /* v8 ignore next */ await this.#config.get('discovery').afterDiscovered?.(storage, this.#platform); return storage; } /** Discovers all entities synchronously and returns the populated MetadataStorage. */ discoverSync() { this.#discovered.length = 0; const startTime = Date.now(); const suffix = this.#metadataProvider.constructor === MetadataProvider ? '' : `, using ${colors.cyan(this.#metadataProvider.constructor.name)}`; this.#logger.log('discovery', `ORM entity discovery started${suffix} in sync mode`); const refs = this.#config.get('entities'); this.discoverReferences(refs); for (const meta of this.#discovered) { /* v8 ignore next */ void this.#config.get('discovery').onMetadata?.(meta, this.#platform); } this.processDiscoveredEntities(this.#discovered); const diff = Date.now() - startTime; this.#logger.log('discovery', `- entity discovery finished, found ${colors.green('' + this.#discovered.length)} entities, took ${colors.green(`${diff} ms`)}`); const storage = this.mapDiscoveredEntities(); /* v8 ignore next */ void this.#config.get('discovery').afterDiscovered?.(storage, this.#platform); return storage; } mapDiscoveredEntities() { const discovered = new MetadataStorage(); this.#discovered .filter(meta => meta.root.name) .sort((a, b) => b.root.name.localeCompare(a.root.name)) .forEach(meta => { this.#platform.validateMetadata(meta); discovered.set(meta.class, meta); }); for (const meta of discovered) { meta.root = discovered.get(meta.root.class); if (meta.inheritanceType === 'tpt') { this.computeTPTOwnProps(meta); } } return discovered; } initAccessors(meta) { for (const prop of Object.values(meta.properties)) { if (!prop.accessor || meta.properties[prop.accessor]) { continue; } const desc = Object.getOwnPropertyDescriptor(meta.prototype, prop.name); if (desc?.get || desc?.set) { this.initRelation(prop); this.initFieldName(prop); const accessor = prop.name; prop.name = typeof prop.accessor === 'string' ? prop.accessor : prop.name; if (prop.accessor === true) { prop.getter = prop.setter = true; } else { prop.getter = prop.setter = false; } prop.accessor = accessor; prop.serializedName ??= accessor; Utils.renameKey(meta.properties, accessor, prop.name); } else { const name = prop.name; // For to-one relations, only swap to the accessor for the documented // backing-field convention (a `_`-prefixed property like `_draft` paired // with `accessor: 'draft'`). The non-prefixed sibling-helper pattern // (`author` + `accessor: 'authorId'`) keeps the property name as the // canonical FK column source, so this stays non-breaking. if (prop.kind === ReferenceKind.SCALAR || prop.kind === ReferenceKind.EMBEDDED || name.startsWith('_')) { prop.name = prop.accessor; } this.initRelation(prop); this.initFieldName(prop); prop.serializedName ??= prop.accessor; prop.name = name; } } } /** Processes discovered entities: initializes relations, embeddables, indexes, and inheritance. */ processDiscoveredEntities(discovered) { for (const meta of discovered) { let i = 1; Object.values(meta.properties).forEach(prop => meta.propertyOrder.set(prop.name, i++)); Object.values(meta.properties).forEach(prop => this.initPolyEmbeddables(prop, discovered)); this.initAccessors(meta); } // ignore base entities (not annotated with @Entity) const filtered = discovered.filter(meta => meta.root.name); // sort so we discover entities first to get around issues with nested embeddables filtered.sort((a, b) => (!a.embeddable === !b.embeddable ? 0 : a.embeddable ? 1 : -1)); filtered.forEach(meta => this.initSingleTableInheritance(meta, filtered)); filtered.forEach(meta => this.initTPTRelationships(meta, filtered)); filtered.forEach(meta => this.defineBaseEntityProperties(meta)); filtered.forEach(meta => { const newMeta = EntitySchema.fromMetadata(meta).init().meta; return this.#metadata.set(newMeta.class, newMeta); }); filtered.forEach(meta => this.initAutoincrement(meta)); const forEachProp = (cb) => { filtered.forEach(meta => Object.values(meta.properties).forEach(prop => cb(meta, prop))); }; forEachProp((m, p) => this.initFactoryField(m, p)); forEachProp((m, p) => this.initPolymorphicRelation(m, p, filtered)); forEachProp((_m, p) => this.initRelation(p)); forEachProp((m, p) => this.initEmbeddables(m, p)); forEachProp((_m, p) => this.initFieldName(p)); filtered.forEach(meta => this.finalizeTPTInheritance(meta, filtered)); filtered.forEach(meta => this.computeTPTOwnProps(meta)); forEachProp((m, p) => this.initVersionProperty(m, p)); forEachProp((m, p) => this.initCustomType(m, p)); forEachProp((m, p) => this.initGeneratedColumn(m, p)); filtered.forEach(meta => this.initAutoincrement(meta)); // once again after we init custom types filtered.forEach(meta => this.initCheckConstraints(meta)); filtered.forEach(meta => this.initTriggers(meta)); forEachProp((_m, p) => { this.initDefaultValue(p); this.inferTypeFromDefault(p); this.initRelation(p); this.initColumnType(p); }); forEachProp((m, p) => this.initIndexes(m, p)); filtered.forEach(meta => this.autoWireBidirectionalProperties(meta)); for (const meta of filtered) { discovered.push(...this.processEntity(meta)); } discovered.forEach(meta => meta.sync(true)); this.#metadataProvider.combineCache(); return discovered.map(meta => { meta = this.#metadata.get(meta.class); meta.sync(true); this.findReferencingProperties(meta, filtered); if (meta.inheritanceType === 'tpt') { this.computeTPTOwnProps(meta); } return meta; }); } async findEntities(preferTs) { const { entities, entitiesTs, baseDir } = this.#config.getAll(); const targets = preferTs && entitiesTs.length > 0 ? entitiesTs : entities; const processed = []; const paths = []; for (const entity of targets) { if (typeof entity === 'string') { paths.push(entity); } else { processed.push(entity); } } if (paths.length > 0) { const { discoverEntities } = await import('@mikro-orm/core/file-discovery'); processed.push(...(await discoverEntities(paths, { baseDir }))); } return this.discoverReferences(processed); } discoverMissingTargets() { const unwrap = (type) => type .replace(/Array<(.*)>/, '$1') // unwrap array .replace(/\[]$/, '') // remove array suffix .replace(/\((.*)\)/, '$1'); // unwrap union types const missing = []; this.#discovered.forEach(meta => Object.values(meta.properties).forEach(prop => { if (prop.kind === ReferenceKind.MANY_TO_MANY && prop.pivotEntity) { const pivotEntity = prop.pivotEntity; const target = typeof pivotEntity === 'function' && !pivotEntity.prototype ? pivotEntity() : pivotEntity; if (!this.#discovered.find(m => m.className === Utils.className(target))) { missing.push(target); } } if (prop.kind !== ReferenceKind.SCALAR) { const target = typeof prop.entity === 'function' && !prop.entity.prototype ? prop.entity() : prop.type; if (!unwrap(prop.type) .split(/ ?\| ?/) .every(type => this.#discovered.find(m => m.className === type))) { missing.push(...Utils.asArray(target)); } } })); if (missing.length > 0) { this.tryDiscoverTargets(missing); } } tryDiscoverTargets(targets) { for (const target of targets) { const schema = EntitySchema.is(target) ? target : undefined; const isDiscoverable = typeof target === 'function' || schema; if (isDiscoverable && target.name) { // Get the actual class for EntitySchema, or use target directly for classes const targetClass = schema ? schema.meta.class : target; if (!this.#metadata.has(targetClass)) { this.discoverReferences([target], false); this.discoverMissingTargets(); } } } } discoverReferences(refs, validate = true) { const found = []; for (const entity of refs) { if (typeof entity === 'string') { throw new Error('Folder based discovery requires the async `MikroORM.init()` method.'); } const schema = this.getSchema(entity); const meta = schema.init().meta; this.#metadata.set(meta.class, meta); found.push(schema); } // discover parents (base entities) automatically for (const meta of this.#metadata) { let parent = meta.extends; if (EntitySchema.is(parent) && !this.#metadata.has(parent.init().meta.class)) { this.discoverReferences([parent], false); } if (typeof parent === 'function' && parent.name && !this.#metadata.has(parent)) { this.discoverReferences([parent], false); } /* v8 ignore next */ if (!meta.class) { continue; } parent = Object.getPrototypeOf(meta.class); // Skip if parent is the auto-generated base class for the same entity (from setClass usage) if (parent.name !== '' && parent.name !== meta.className && !this.#metadata.has(parent) && parent !== BaseEntity) { this.discoverReferences([parent], false); } } for (const schema of found) { this.discoverEntity(schema); } this.discoverMissingTargets(); if (validate) { this.#validator.validateDiscovered(this.#discovered, this.#config.get('discovery')); } return this.#discovered.filter(meta => found.find(m => m.name === meta.className)); } reset(entityName) { const exists = this.#discovered.findIndex(m => m.class === entityName || m.className === Utils.className(entityName)); if (exists !== -1) { this.#metadata.reset(this.#discovered[exists].class); this.#discovered.splice(exists, 1); } } getSchema(entity) { if (EntitySchema.REGISTRY.has(entity)) { entity = EntitySchema.REGISTRY.get(entity); } if (EntitySchema.is(entity)) { const meta = Utils.copy(entity.meta, false); return EntitySchema.fromMetadata(meta); } // After the EntitySchema check, entity must be an EntityClass const cls = entity; const path = cls[MetadataStorage.PATH_SYMBOL]; if (path) { const meta = Utils.copy(MetadataStorage.getMetadata(cls.name, path), false); meta.path = path; this.#metadata.set(cls, meta); } const exists = this.#metadata.has(cls); const meta = this.#metadata.get(cls, true); meta.abstract ??= !(exists && meta.name); const schema = EntitySchema.fromMetadata(meta); schema.setClass(cls); return schema; } getRootEntity(meta) { const base = meta.extends && this.#metadata.find(meta.extends); if (!base || base === meta) { // make sure we do not fall into infinite loop return meta; } const root = this.getRootEntity(base); // For STI or TPT, use the root entity. // Check both `inheritanceType` (set during discovery) and raw `inheritance` option (set before discovery). if (root.discriminatorColumn || root.inheritanceType || root.inheritance === 'tpt') { return root; } return meta; } discoverEntity(schema) { const meta = schema.meta; const path = meta.path; this.#logger.log('discovery', `- processing entity ${colors.cyan(meta.className)}${colors.grey(path ? ` (${path})` : '')}`); const root = this.getRootEntity(meta); schema.meta.path = meta.path; const cache = this.#metadataProvider.getCachedMetadata(meta, root); if (cache) { this.#logger.log('discovery', `- using cached metadata for entity ${colors.cyan(meta.className)}`); this.#discovered.push(meta); return; } // infer default value from property initializer early, as the metadata provider might use some defaults, e.g. string for reflect-metadata for (const prop of meta.props) { this.inferDefaultValue(meta, prop); } // if the definition is using EntitySchema we still want it to go through the metadata provider to validate no types are missing this.#metadataProvider.loadEntityMetadata(meta); if (!meta.tableName && meta.name) { const entityName = root.discriminatorColumn ? root.name : meta.name; meta.tableName = this.#namingStrategy.classToTableName(entityName); } this.#metadataProvider.saveToCache(meta); meta.root = root; this.#discovered.push(meta); } initNullability(prop) { if (prop.kind === ReferenceKind.ONE_TO_ONE) { return Utils.defaultValue(prop, 'nullable', prop.optional || !prop.owner); } return Utils.defaultValue(prop, 'nullable', prop.optional); } applyNamingStrategy(meta, prop) { if (!prop.fieldNames) { this.initFieldName(prop); } if (prop.kind === ReferenceKind.MANY_TO_MANY) { this.initManyToManyFields(meta, prop); } if ([ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind)) { this.initManyToOneFields(prop); } if (prop.kind === ReferenceKind.ONE_TO_MANY) { this.initOneToManyFields(prop); } } initOwnColumns(meta) { meta.sync(); for (const prop of meta.props) { if (!prop.joinColumns || !prop.columnTypes || prop.ownColumns || ![ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind)) { continue; } // For polymorphic relations, ownColumns should include all fieldNames // (discriminator + ID columns) since they are all owned by this relation if (prop.polymorphic) { prop.ownColumns = prop.fieldNames; continue; } if (prop.joinColumns.length > 1) { prop.ownColumns = prop.joinColumns.filter(col => { return !meta.props.find(p => p.name !== prop.name && p.fieldNames?.includes(col)); }); } if (!prop.ownColumns || prop.ownColumns.length === 0) { prop.ownColumns = prop.joinColumns; } if (prop.joinColumns.length !== prop.columnTypes.length) { prop.columnTypes = prop.joinColumns.flatMap(field => { const matched = meta.props.find(p => p.fieldNames?.includes(field)); /* v8 ignore next */ if (!matched) { throw MetadataError.fromWrongForeignKey(meta, prop, 'columnTypes'); } return matched.columnTypes; }); } if (prop.joinColumns.length !== prop.referencedColumnNames.length) { throw MetadataError.fromWrongForeignKey(meta, prop, 'referencedColumnNames'); } } } initFieldName(prop, object = false) { if (prop.fieldNames && prop.fieldNames.length > 0) { return; } if (prop.kind === ReferenceKind.SCALAR || prop.kind === ReferenceKind.EMBEDDED) { prop.fieldNames = [this.#namingStrategy.propertyToColumnName(prop.name, object)]; } else if ([ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind) && !prop.polymorphic) { prop.fieldNames = this.initManyToOneFieldName(prop, prop.name); } else if (prop.kind === ReferenceKind.MANY_TO_MANY && prop.owner) { prop.fieldNames = this.initManyToManyFieldName(prop, prop.name); } } initManyToOneFieldName(prop, name) { const meta2 = prop.targetMeta; const ret = []; for (const primaryKey of meta2.primaryKeys) { this.initFieldName(meta2.properties[primaryKey]); for (const fieldName of meta2.properties[primaryKey].fieldNames) { ret.push(this.#namingStrategy.joinKeyColumnName(name, fieldName, meta2.compositePK)); } } return ret; } initManyToManyFieldName(prop, name) { const meta2 = prop.targetMeta; return meta2.primaryKeys.map(() => this.#namingStrategy.propertyToColumnName(name)); } initManyToManyFields(meta, prop) { const meta2 = prop.targetMeta; Utils.defaultValue(prop, 'fixedOrder', !!prop.fixedOrderColumn); const pivotMeta = this.#metadata.find(prop.pivotEntity); const props = Object.values(pivotMeta?.properties ?? {}); const pks = props.filter(p => p.primary); const fks = props.filter(p => p.kind === ReferenceKind.MANY_TO_ONE); if (pivotMeta) { pivotMeta.pivotTable = true; prop.pivotTable = pivotMeta.tableName; if (pks.length === 1) { prop.fixedOrder = true; prop.fixedOrderColumn = pks[0].name; } } if (pivotMeta && (pks.length === 2 || fks.length >= 2)) { const owner = prop.mappedBy ? meta2.properties[prop.mappedBy] : prop; const [first, second] = this.ensureCorrectFKOrderInPivotEntity(pivotMeta, owner); prop.joinColumns ??= first.fieldNames; prop.inverseJoinColumns ??= second.fieldNames; } if (!prop.pivotTable && prop.owner && this.#platform.usesPivotTable()) { prop.pivotTable = this.#namingStrategy.joinTableName(meta.className, meta2.tableName, prop.name, meta.tableName); } if (prop.mappedBy) { const prop2 = meta2.properties[prop.mappedBy]; this.initManyToManyFields(meta2, prop2); prop.pivotTable = prop2.pivotTable; prop.pivotEntity = prop2.pivotEntity; prop.fixedOrder = prop2.fixedOrder; prop.fixedOrderColumn = prop2.fixedOrderColumn; prop.joinColumns = prop2.inverseJoinColumns; prop.inverseJoinColumns = prop2.joinColumns; prop.polymorphic = prop2.polymorphic; prop.discriminator = prop2.discriminator; prop.discriminatorColumn = prop2.discriminatorColumn; // For a union-target pivot each inverse side sits on one specific target class, so its // discriminator value is that class's tableName. For Rails-style, prop2 has a single fixed value. prop.discriminatorValue = QueryHelper.isUnionTargetPolymorphic(prop2) ? meta.tableName : prop2.discriminatorValue; } prop.referencedColumnNames ??= Utils.flatten(meta.primaryKeys.map(primaryKey => meta.properties[primaryKey].fieldNames)); // Union-target polymorphic M:N: owner side is fixed (real FK), target side uses discriminator-derived names. const isUnionTargetMN = QueryHelper.isUnionTargetPolymorphic(prop); if (prop.polymorphic && prop.discriminator && !isUnionTargetMN) { // Rails-style: owner side is polymorphic, uses discriminator base name (e.g. taggable_id instead of post_id) prop.joinColumns ??= prop.referencedColumnNames.map(referencedColumnName => this.#namingStrategy.joinKeyColumnName(prop.discriminator, referencedColumnName, prop.referencedColumnNames.length > 1)); } else { prop.joinColumns ??= prop.referencedColumnNames.map(referencedColumnName => this.#namingStrategy.joinKeyColumnName(meta.root.className, referencedColumnName, meta.compositePK)); } if (isUnionTargetMN) { // Target side uses discriminator base name (e.g. attachable_id — shared across Image/Video) const targetPkCols = Utils.flatten(meta2.primaryKeys.map(pk => meta2.properties[pk].fieldNames)); prop.inverseJoinColumns ??= targetPkCols.map(fieldName => this.#namingStrategy.joinKeyColumnName(prop.discriminator, fieldName, targetPkCols.length > 1)); } else { prop.inverseJoinColumns ??= this.initManyToOneFieldName(prop, meta2.root.className); } } isExplicitTableName(meta) { return meta.tableName !== this.#namingStrategy.classToTableName(meta.className); } initManyToOneFields(prop) { if (prop.polymorphic && prop.polymorphTargets) { const fieldNames1 = prop.targetMeta.getPrimaryProps().flatMap(pk => pk.fieldNames); const idColumns = fieldNames1.map(fieldName => this.#namingStrategy.joinKeyColumnName(prop.discriminator, fieldName, fieldNames1.length > 1)); prop.fieldNames ??= [prop.discriminatorColumn, ...idColumns]; prop.joinColumns ??= idColumns; prop.referencedColumnNames ??= fieldNames1; prop.referencedTableName ??= prop.targetMeta.tableName; return; } const meta2 = prop.targetMeta; let fieldNames; // If targetKey is specified, use that property's field names instead of PKs if (prop.targetKey) { const targetProp = meta2.properties[prop.targetKey]; fieldNames = targetProp.fieldNames; } else { fieldNames = Utils.flatten(meta2.primaryKeys.map(primaryKey => meta2.properties[primaryKey].fieldNames)); } Utils.defaultValue(prop, 'referencedTableName', meta2.tableName); if (!prop.joinColumns) { prop.joinColumns = fieldNames.map(fieldName => this.#namingStrategy.joinKeyColumnName(prop.name, fieldName, fieldNames.length > 1)); } if (!prop.referencedColumnNames) { prop.referencedColumnNames = fieldNames; } // Relations to composite PK targets need cascade update by default, // since composite PKs are more likely to have mutable components if (meta2.compositePK) { prop.updateRule ??= 'cascade'; } // Nullable relations default to 'set null' on delete - when the referenced // entity is deleted, set the FK to null rather than failing if (prop.nullable) { prop.deleteRule ??= 'set null'; } } initOneToManyFields(prop) { const meta2 = prop.targetMeta; if (!prop.joinColumns) { prop.joinColumns = [this.#namingStrategy.joinColumnName(prop.name)]; } if (!prop.referencedColumnNames) { meta2.getPrimaryProps().forEach(pk => this.applyNamingStrategy(meta2, pk)); prop.referencedColumnNames = Utils.flatten(meta2.getPrimaryProps().map(pk => pk.fieldNames)); } } processEntity(meta) { const pks = Object.values(meta.properties).filter(prop => prop.primary); meta.primaryKeys = pks.map(prop => prop.name); meta.compositePK = pks.length > 1; // FK used as PK, we need to cascade - applies to both single FK-as-PK // and composite PKs where all PKs are FKs (e.g., pivot-like entities) const fkPks = pks.filter(pk => pk.kind !== ReferenceKind.SCALAR); if (fkPks.length > 0 && fkPks.length === pks.length) { for (const pk of fkPks) { pk.deleteRule ??= 'cascade'; pk.updateRule ??= 'cascade'; } } meta.forceConstructor ??= this.shouldForceConstructorUsage(meta); // Fail fast when the platform rejects the metadata, so users see the platform-specific // error (e.g. "SqlitePlatform does not support partitioned tables") instead of a // downstream core validator message that assumes partitioning is supported. if (meta.partitionBy && !this.#platform.supportsPartitionedTables()) { this.#platform.validateMetadata(meta); } this.#validator.validateEntityDefinition(this.#metadata, meta.class, this.#config.get('discovery')); for (const prop of Object.values(meta.properties)) { this.initNullability(prop); this.applyNamingStrategy(meta, prop); this.initDefaultValue(prop); this.inferTypeFromDefault(prop); this.initVersionProperty(meta, prop); this.initCustomType(meta, prop); this.initColumnType(prop); this.initRelation(prop); } this.initOwnColumns(meta); meta.simplePK = pks.length === 1 && pks[0].kind === ReferenceKind.SCALAR && !pks[0].customType && pks[0].runtimeType !== 'Date'; meta.serializedPrimaryKey ??= meta.props.find(prop => prop.serializedPrimaryKey)?.name; if (meta.serializedPrimaryKey && meta.serializedPrimaryKey !== meta.primaryKeys[0]) { meta.properties[meta.serializedPrimaryKey].persist ??= false; } if (this.#platform.usesPivotTable()) { return Object.values(meta.properties) .filter(prop => prop.kind === ReferenceKind.MANY_TO_MANY && prop.owner && prop.pivotTable) .map(prop => { const pivotMeta = this.definePivotTableEntity(meta, prop); prop.pivotEntity = pivotMeta.class; if (prop.inversedBy) { prop.targetMeta.properties[prop.inversedBy].pivotEntity = pivotMeta.class; const targetRoot = prop.targetMeta.root; if (targetRoot !== prop.targetMeta && targetRoot.properties[prop.inversedBy]) { targetRoot.properties[prop.inversedBy].pivotEntity = pivotMeta.class; } } // Propagate pivotEntity to ALL inverse collections using mappedBy pointing at this // owner prop. Covers three cases: // - regular inverse (Tag.posts mappedBy Post.tags) — handled by inversedBy above // - union-target inverse (Image.posts mappedBy Post.attachments) — on each polymorph target // - merged inverse (Tag.owners mappedBy [Post,Video].tags) — union collection on the target const inverseCandidates = QueryHelper.isUnionTargetPolymorphic(prop) ? prop.polymorphTargets : [prop.targetMeta]; for (const targetMeta of inverseCandidates) { for (const inverseProp of Object.values(targetMeta.properties)) { if (inverseProp.kind === ReferenceKind.MANY_TO_MANY && inverseProp.mappedBy === prop.name && !inverseProp.pivotEntity) { inverseProp.pivotEntity = pivotMeta.class; inverseProp.pivotTable = pivotMeta.tableName; } } } return pivotMeta; }); } return []; } findReferencingProperties(meta, metadata) { for (const meta2 of metadata) { for (const prop2 of meta2.relations) { if (prop2.kind !== ReferenceKind.SCALAR && prop2.type === meta.className) { meta.referencingProperties.push({ meta: meta2, prop: prop2 }); } } } } initFactoryField(meta, prop) { ['mappedBy', 'inversedBy', 'pivotEntity'].forEach(type => { const value = prop[type]; if (value instanceof Function) { const meta2 = prop.targetMeta ?? this.#metadata.get(prop.target); prop[type] = value(meta2.properties)?.name; if (type === 'pivotEntity' && value) { prop[type] = value(meta2.properties); } if (prop[type] == null) { throw MetadataError.fromWrongReference(meta, prop, type); } } }); } ensureCorrectFKOrderInPivotEntity(meta, owner) { const pks = Object.values(meta.properties).filter(p => p.primary); const fks = Object.values(meta.properties).filter(p => p.kind === ReferenceKind.MANY_TO_ONE); let first, second; if (pks.length === 2) { [first, second] = pks; } else if (fks.length >= 2) { [first, second] = fks; } else { /* v8 ignore next */ return []; } // wrong FK order, first FK needs to point to the owning side // (note that we can detect this only if the FKs target different types) if (owner.type === first.type && first.type !== second.type) { delete meta.properties[first.name]; meta.removeProperty(first.name, false); meta.addProperty(first); [first, second] = [second, first]; } return [first, second]; } definePivotTableEntity(meta, prop) { const pivotMeta = prop.pivotEntity ? this.#metadata.find(prop.pivotEntity) : this.#metadata.getByClassName(prop.pivotTable, false); // ensure inverse side exists so we can join it when populating via pivot tables if (!prop.inversedBy && prop.targetMeta) { const inverseName = `${meta.className}_${prop.name}__inverse`; prop.inversedBy = inverseName; const inverseProp = { name: inverseName, kind: ReferenceKind.MANY_TO_MANY, type: meta.className, target: meta.class, targetMeta: meta, mappedBy: prop.name, pivotEntity: prop.pivotEntity, pivotTable: prop.pivotTable, persist: false, hydrate: false, }; this.applyNamingStrategy(prop.targetMeta, inverseProp); this.initCustomType(prop.targetMeta, inverseProp); prop.targetMeta.properties[inverseName] = inverseProp; } if (pivotMeta) { prop.pivotEntity = pivotMeta.class; this.ensureCorrectFKOrderInPivotEntity(pivotMeta, prop); if (prop.polymorphic && prop.discriminatorValue) { pivotMeta.polymorphicDiscriminatorMap ??= {}; pivotMeta.polymorphicDiscriminatorMap[prop.discriminatorValue] = meta.class; // For composite PK entities sharing a polymorphic pivot table, // we need to add columns for each entity type's PKs this.addPolymorphicPivotColumns(pivotMeta, meta, prop); // Add virtual M:1 relation for this polymorphic owner (for join loading) const ownerRelationName = `${prop.discriminator}_${meta.tableName}`; if (!pivotMeta.properties[ownerRelationName]) { pivotMeta.properties[ownerRelationName] = this.definePolymorphicOwnerRelation(prop, ownerRelationName, meta); } } return pivotMeta; } let tableName = prop.pivotTable; let schemaName; if (prop.pivotTable.includes('.')) { [schemaName, tableName] = prop.pivotTable.split('.'); } schemaName ??= meta.schema; const targetMeta = prop.targetMeta; const targetType = targetMeta.className; const pivotMeta2 = new EntityMetadata({ name: prop.pivotTable, className: prop.pivotTable, collection: tableName, schema: schemaName, pivotTable: true, }); prop.pivotEntity = pivotMeta2.class; if (prop.fixedOrder) { const primaryProp = this.defineFixedOrderProperty(prop, targetMeta); pivotMeta2.properties[primaryProp.name] = primaryProp; } else { pivotMeta2.compositePK = true; } // handle self-referenced m:n with same default field names if (meta.className === targetType && prop.joinColumns.every((joinColumn, idx) => joinColumn === prop.inverseJoinColumns[idx])) { // use tableName only when explicitly provided by user, otherwise use className for backwards compatibility const baseName = this.isExplicitTableName(meta) ? meta.tableName : meta.className; prop.joinColumns = prop.referencedColumnNames.map(name => this.#namingStrategy.joinKeyColumnName(baseName + '_1', name, meta.compositePK)); prop.inverseJoinColumns = prop.referencedColumnNames.map(name => this.#namingStrategy.joinKeyColumnName(baseName + '_2', name, meta.compositePK)); if (prop.inversedBy) { const prop2 = targetMeta.properties[prop.inversedBy]; prop2.inverseJoinColumns = prop.joinColumns; prop2.joinColumns = prop.inverseJoinColumns; } // propagate updated joinColumns to all child entities that inherit this property (STI) for (const childMeta of this.#discovered.filter(m => m.root === meta && m !== meta)) { const childProp = childMeta.properties[prop.name]; if (childProp) { childProp.joinColumns = prop.joinColumns; childProp.inverseJoinColumns = prop.inverseJoinColumns; } } } // Union-target polymorphic M:N: discriminator + target FK share the pivot across multiple target types if (prop.discriminatorColumn && QueryHelper.isUnionTargetPolymorphic(prop)) { this.defineUnionTargetPolymorphicPivotProperties(pivotMeta2, meta, prop); } else if (prop.polymorphic && prop.discriminatorColumn) { // Rails-style polymorphic M:N: multiple owners share the pivot, single target type this.definePolymorphicPivotProperties(pivotMeta2, meta, prop, targetMeta); } else { pivotMeta2.properties[meta.name + '_owner'] = this.definePivotProperty(prop, meta.name + '_owner', meta.class, targetType + '_inverse', true, meta.className === targetType); pivotMeta2.properties[targetType + '_inverse'] = this.definePivotProperty(prop, targetType + '_inverse', targetMeta.class, meta.name + '_owner', false, meta.className === targetType); } return this.#metadata.set(pivotMeta2.class, EntitySchema.fromMetadata(pivotMeta2).init().meta); } /** * Create a scalar property for a pivot table column. */ createPivotScalarProperty(name, columnTypes, fieldNames = [name], options = {}) { return { name, fieldNames, columnTypes, type: options.type ?? 'number', kind: ReferenceKind.SCALAR, primary: options.primary ?? false, nullable: options.nullable ?? true, ...(options.persist !== undefined && { persist: options.persist }), }; } /** * Get column types for an entity's primary keys, initializing them if needed. */ getPrimaryKeyColumnTypes(meta) { const columnTypes = []; for (const pk of meta.primaryKeys) { const pkProp = meta.properties[pk]; this.initCustomType(meta, pkProp); this.initColumnType(pkProp); columnTypes.push(...pkProp.columnTypes); } return columnTypes; } /** * Add missing FK columns for a polymorphic entity to an existing pivot table. */ addPolymorphicPivotColumns(pivotMeta, meta, prop) { const existingFieldNames = new Set(Object.values(pivotMeta.properties).flatMap(p => p.fieldNames ?? [])); const columnTypes = this.getPrimaryKeyColumnTypes(meta); for (let i = 0; i < prop.joinColumns.length; i++) { const joinColumn = prop.joinColumns[i]; if (!existingFieldNames.has(joinColumn)) { pivotMeta.properties[joinColumn] = this.createPivotScalarProperty(joinColumn, [columnTypes[i]]); } } } /** * Define properties for a polymorphic pivot table. */ definePolymorphicPivotProperties(pivotMeta, meta, prop, targetMeta) { const discriminatorColumn = prop.discriminatorColumn; const isCompositePK = meta.compositePK; // For composite PK polymorphic M:N, we need fixedOrder (auto-increment PK) if (isCompositePK && !prop.fixedOrder) { prop.fixedOrder = true; const primaryProp = this.defineFixedOrderProperty(prop, targetMeta); pivotMeta.properties[primaryProp.name] = primaryProp; pivotMeta.compositePK = false; } const discriminatorProp = this.createPivotScalarProperty(discriminatorColumn, [this.#platform.getVarcharTypeDeclarationSQL(prop)], [discriminatorColumn], { type: 'string', primary: !isCompositePK, nullable: false }); this.initFieldName(discriminatorProp); pivotMeta.properties[discriminatorColumn] = discriminatorProp; const columnTypes = this.getPrimaryKeyColumnTypes(meta); if (isCompositePK) { // Create separate properties for each PK column (nullable for other entity types) for (let i = 0; i < prop.joinColumns.length; i++) { pivotMeta.properties[prop.joinColumns[i]] = this.createPivotScalarProperty(prop.joinColumns[i], [ columnTypes[i], ]); } // Virtual property combining all columns (for compatibility) pivotMeta.properties[prop.discriminator] = this.createPivotScalarProperty(prop.discriminator, columnTypes, [...prop.joinColumns], { type: meta.className, persist: false }); } else { pivotMeta.properties[prop.discriminator] = this.createPivotScalarProperty(prop.discriminator, columnTypes, [...prop.joinColumns], { type: meta.className, primary: true, nullable: false }); } pivotMeta.properties[targetMeta.className + '_inverse'] = this.definePivotProperty(prop, targetMeta.className + '_inverse', targetMeta.class, prop.discriminator, false, false); // Create virtual M:1 relation to the polymorphic owner for single-query join loading const ownerRelationName = `${prop.discriminator}_${meta.tableName}`; pivotMeta.properties[ownerRelationName] = this.definePolymorphicOwnerRelation(prop, ownerRelationName, meta); pivotMeta.polymorphicDiscriminatorMap ??= {}; pivotMeta.polymorphicDiscriminatorMap[prop.discriminatorValue] = meta.class; } /** * Mirror of definePolymorphicPivotProperties for union-target M:N * (e.g. Post.attachments -> Image | Video via shared pivot with a target-side discriminator). * * Pivot shape: * (owner_fk..., discriminator_column, target_fk...) * - owner side is a normal M:1 to the single owner entity * - target side is a discriminator column + per-target-type virtual M:1 relations */ defineUnionTargetPolymorphicPivotProperties(pivotMeta, meta, prop) { const discriminatorColumn = prop.discriminatorColumn; const targets = prop.polymorphTargets; pivotMeta.properties[meta.name + '_owner'] = this.definePivotProperty(prop, meta.name + '_owner', meta.class, prop.discriminator, true, false); const discriminatorProp = this.createPivotScalarProperty(discriminatorColumn, [this.#platform.getVarcharTypeDeclarationSQL(prop)], [discriminatorColumn], { type: 'string', primary: true, nullable: false }); this.initFieldName(discriminatorProp); pivotMeta.properties[discriminatorColumn] = discriminatorProp; const firstTargetColumnTypes = this.getPrimaryKeyColumnTypes(targets[0]); pivotMeta.properties[prop.discriminator] = this.createPivotScalarProperty(prop.discriminator, firstTargetColumnTypes, [...prop.inverseJoinColumns], { type: targets[0].className, primary: true, nullable: false }); pivotMeta.polymorphicDiscriminatorMap ??= {}; for (const targetMeta of targets) { const relationName = `${prop.discriminator}_${targetMeta.tableName}`; const relation = this.definePolymorphicOwnerRelation(prop, relationName, targetMeta); relation.joinColumns = relation.fieldNames = relation.ownColumns = [...prop.inverseJoinColumns]; pivotMeta.properties[relationName] = relation; pivotMeta.polymorphicDiscriminatorMap[targetMeta.tableName] = targetMeta.class; } } /** * Create a virtual M:1 relation from pivot to a polymorphic owner entity. * This enables single-query join loading for inverse-side polymorphic M:N. */ definePolymorphicOwnerRelation(prop, name, targetMeta) { const ret = { name, type: targetMeta.className, target: targetMeta.class, kind: ReferenceKind.MANY_TO_ONE, nullable: true, owner: true, primary: false, createForeignKeyConstraint: false, persist: false, index: false, }; ret.targetMeta = targetMeta; ret.fieldNames = ret.joinColumns = ret.ownColumns = [...prop.joinColumns]; ret.referencedColumnNames = []; ret.inverseJoinColumns = []; for (const primaryKey of targetMeta.primaryKeys) { const pkProp = targetMeta.properties[primaryKey]; ret.referencedColumnNames.push(...pkProp.fieldNames); ret.inverseJoinColumns.push(...pkProp.fieldNames); ret.length = pkProp.length; ret.precision = pkProp.precision; ret.scale = pkProp.scale; } const schema = targetMeta.schema ?? this.#config.get('schema') ?? this.#platform.getDefaultSchemaName(); ret.referencedTableName = schema && schema !== '*' ? schema + '.' + targetMeta.tableName : targetMeta.tableName; this.initColumnType(ret); this.initRelation(ret); return ret; } defineFixedOrderProperty(prop, targetMeta) { const pk = prop.fixedOrderColumn || this.#namingStrategy.referenceColumnName(); const primaryProp = { name: pk, type: 'number', runtimeType: 'number', kind: ReferenceKind.SCALAR, primary: true, autoincrement: true, unsigned: this.#platform.supportsUnsigned(), }; this.initFieldName(primaryProp); this.initColumnType(primaryProp); prop.fixedOrderColumn = pk; if (prop.inversedBy) { const prop2 = targetMeta.properties[prop.inversedBy]; prop2.fixedOrder = true; prop2.fixedOrderColumn = pk; } return primaryProp; } definePivotProperty(prop, name, type, inverse, owner, selfReferencing) { const ret = { name, type: Utils.className(type), target: type, kind: ReferenceKind.MANY_TO_ONE, cascade: [Cascade.ALL], fixedOrder: prop.fixedOrder, fixedOrderColumn: prop.fixedOrderColumn, index: this.#platform.indexForeignKeys(), primary: !prop.fixedOrder, autoincrement: false, updateRule: prop.updateRule, deleteRule: prop.deleteRule, createForeignKeyConstraint: prop.createForeignKeyConstraint, }; const defaultRule = selfReferencing && !this.#platform.supportsMultipleCascadePaths() ? 'no action' : 'cascade'; ret.updateRule ??= defaultRule; ret.deleteRule ??= defaultRule; const meta = this.#metadata.get(type); ret.targetMeta = meta; ret.joinColumns = []; ret.inverseJoinColumns = []; const schema = meta.schema ?? this.#config.get('schema') ?? this.#platform.getDefaultSchemaName(); ret.referencedTableName = schema && schema !== '*' ? schema + '.' + meta.tableName : meta.tableName; if (owner) { ret.owner = true; ret.inversedBy = inverse; ret.referencedColumnNames = prop.referencedColumnNames; ret.fieldNames = ret.joinColumns = prop.joinColumns; ret.inverseJoinColumns = prop.referencedColumnNames; meta.primaryKeys.forEach(primaryKey => { const prop2 = meta.properties[primaryKey]; ret.length = prop2.length; ret.precision = prop2.precision; ret.scale = prop2.scale; }); } else { ret.owner = false; ret.mappedBy = inverse; ret.fieldNames = ret.joinColumns = prop.inverseJoinColumns; ret.referencedColumnNames = []; ret.inverseJoinColumns = []; meta.primaryKeys.forEach(primaryKey => { const prop2 = meta.properties[primaryKey]; ret.referencedColumnNames.push(...prop2.fieldNames); ret.inverseJoinColumns.push(...prop2.fieldNames); ret.length = prop2.length; ret.precision = prop2.precision; ret.scale = prop2.scale; }); } this.initColumnType(ret); this.initRelation(ret); return ret; } autoWireBidirectionalProperties(meta) { Object.values(meta.properties) .filter(prop => prop.kind !== ReferenceKind.SCALAR && !prop.owner && prop.mappedBy) .forEach(prop => { const meta2 = prop.targetMeta; const prop2 = meta2.properties[prop.mappedBy]; if (prop2 && !prop2.inversedBy) { prop2.inversedBy = prop.name; } }); } defineBaseEntityP