@mikro-orm/core 7.1.0-dev.3 → 7.1.0-dev.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/EntityManager.d.ts +44 -11
- package/EntityManager.js +139 -31
- package/drivers/IDatabaseDriver.d.ts +39 -1
- package/entity/BaseEntity.d.ts +68 -1
- package/entity/BaseEntity.js +18 -0
- package/entity/Collection.d.ts +6 -3
- package/entity/Collection.js +15 -4
- package/entity/EntityFactory.js +20 -1
- package/entity/EntityLoader.d.ts +3 -1
- package/entity/EntityLoader.js +39 -16
- package/entity/EntityRepository.d.ts +27 -9
- package/entity/EntityRepository.js +12 -0
- package/entity/Reference.d.ts +42 -1
- package/entity/Reference.js +9 -0
- package/entity/defineEntity.d.ts +105 -23
- package/entity/defineEntity.js +17 -6
- package/entity/utils.js +4 -5
- package/errors.d.ts +2 -0
- package/errors.js +4 -0
- package/index.d.ts +2 -2
- package/index.js +1 -1
- package/metadata/EntitySchema.js +3 -0
- package/metadata/MetadataDiscovery.d.ts +12 -0
- package/metadata/MetadataDiscovery.js +185 -16
- package/metadata/MetadataValidator.d.ts +24 -0
- package/metadata/MetadataValidator.js +202 -1
- package/metadata/types.d.ts +77 -4
- package/naming-strategy/AbstractNamingStrategy.d.ts +1 -1
- package/naming-strategy/NamingStrategy.d.ts +1 -1
- package/package.json +1 -1
- package/platforms/Platform.d.ts +8 -1
- package/platforms/Platform.js +40 -4
- package/serialization/EntitySerializer.js +2 -1
- package/typings.d.ts +176 -18
- package/typings.js +22 -5
- package/unit-of-work/UnitOfWork.js +3 -0
- package/utils/AbstractMigrator.d.ts +20 -5
- package/utils/AbstractMigrator.js +263 -28
- package/utils/AbstractSchemaGenerator.d.ts +1 -1
- package/utils/AbstractSchemaGenerator.js +4 -1
- package/utils/Configuration.d.ts +25 -0
- package/utils/Configuration.js +1 -0
- package/utils/DataloaderUtils.d.ts +10 -1
- package/utils/DataloaderUtils.js +78 -0
- package/utils/EntityComparator.js +1 -1
- package/utils/QueryHelper.d.ts +16 -0
- package/utils/QueryHelper.js +15 -0
- package/utils/Utils.js +1 -1
- package/utils/fs-utils.d.ts +2 -0
- package/utils/fs-utils.js +7 -1
- package/utils/index.d.ts +1 -0
- package/utils/index.js +1 -0
- package/utils/partition-utils.d.ts +17 -0
- package/utils/partition-utils.js +79 -0
|
@@ -114,7 +114,12 @@ export class MetadataDiscovery {
|
|
|
114
114
|
}
|
|
115
115
|
else {
|
|
116
116
|
const name = prop.name;
|
|
117
|
-
|
|
117
|
+
// For to-one relations, only swap to the accessor for the documented
|
|
118
|
+
// backing-field convention (a `_`-prefixed property like `_draft` paired
|
|
119
|
+
// with `accessor: 'draft'`). The non-prefixed sibling-helper pattern
|
|
120
|
+
// (`author` + `accessor: 'authorId'`) keeps the property name as the
|
|
121
|
+
// canonical FK column source, so this stays non-breaking.
|
|
122
|
+
if (prop.kind === ReferenceKind.SCALAR || prop.kind === ReferenceKind.EMBEDDED || name.startsWith('_')) {
|
|
118
123
|
prop.name = prop.accessor;
|
|
119
124
|
}
|
|
120
125
|
this.initRelation(prop);
|
|
@@ -159,6 +164,7 @@ export class MetadataDiscovery {
|
|
|
159
164
|
forEachProp((m, p) => this.initGeneratedColumn(m, p));
|
|
160
165
|
filtered.forEach(meta => this.initAutoincrement(meta)); // once again after we init custom types
|
|
161
166
|
filtered.forEach(meta => this.initCheckConstraints(meta));
|
|
167
|
+
filtered.forEach(meta => this.initTriggers(meta));
|
|
162
168
|
forEachProp((_m, p) => {
|
|
163
169
|
this.initDefaultValue(p);
|
|
164
170
|
this.inferTypeFromDefault(p);
|
|
@@ -479,17 +485,28 @@ export class MetadataDiscovery {
|
|
|
479
485
|
prop.polymorphic = prop2.polymorphic;
|
|
480
486
|
prop.discriminator = prop2.discriminator;
|
|
481
487
|
prop.discriminatorColumn = prop2.discriminatorColumn;
|
|
482
|
-
|
|
488
|
+
// For a union-target pivot each inverse side sits on one specific target class, so its
|
|
489
|
+
// discriminator value is that class's tableName. For Rails-style, prop2 has a single fixed value.
|
|
490
|
+
prop.discriminatorValue = QueryHelper.isUnionTargetPolymorphic(prop2) ? meta.tableName : prop2.discriminatorValue;
|
|
483
491
|
}
|
|
484
492
|
prop.referencedColumnNames ??= Utils.flatten(meta.primaryKeys.map(primaryKey => meta.properties[primaryKey].fieldNames));
|
|
485
|
-
//
|
|
486
|
-
|
|
493
|
+
// Union-target polymorphic M:N: owner side is fixed (real FK), target side uses discriminator-derived names.
|
|
494
|
+
const isUnionTargetMN = QueryHelper.isUnionTargetPolymorphic(prop);
|
|
495
|
+
if (prop.polymorphic && prop.discriminator && !isUnionTargetMN) {
|
|
496
|
+
// Rails-style: owner side is polymorphic, uses discriminator base name (e.g. taggable_id instead of post_id)
|
|
487
497
|
prop.joinColumns ??= prop.referencedColumnNames.map(referencedColumnName => this.#namingStrategy.joinKeyColumnName(prop.discriminator, referencedColumnName, prop.referencedColumnNames.length > 1));
|
|
488
498
|
}
|
|
489
499
|
else {
|
|
490
500
|
prop.joinColumns ??= prop.referencedColumnNames.map(referencedColumnName => this.#namingStrategy.joinKeyColumnName(meta.root.className, referencedColumnName, meta.compositePK));
|
|
491
501
|
}
|
|
492
|
-
|
|
502
|
+
if (isUnionTargetMN) {
|
|
503
|
+
// Target side uses discriminator base name (e.g. attachable_id — shared across Image/Video)
|
|
504
|
+
const targetPkCols = Utils.flatten(meta2.primaryKeys.map(pk => meta2.properties[pk].fieldNames));
|
|
505
|
+
prop.inverseJoinColumns ??= targetPkCols.map(fieldName => this.#namingStrategy.joinKeyColumnName(prop.discriminator, fieldName, targetPkCols.length > 1));
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
prop.inverseJoinColumns ??= this.initManyToOneFieldName(prop, meta2.root.className);
|
|
509
|
+
}
|
|
493
510
|
}
|
|
494
511
|
isExplicitTableName(meta) {
|
|
495
512
|
return meta.tableName !== this.#namingStrategy.classToTableName(meta.className);
|
|
@@ -556,6 +573,12 @@ export class MetadataDiscovery {
|
|
|
556
573
|
}
|
|
557
574
|
}
|
|
558
575
|
meta.forceConstructor ??= this.shouldForceConstructorUsage(meta);
|
|
576
|
+
// Fail fast when the platform rejects the metadata, so users see the platform-specific
|
|
577
|
+
// error (e.g. "SqlitePlatform does not support partitioned tables") instead of a
|
|
578
|
+
// downstream core validator message that assumes partitioning is supported.
|
|
579
|
+
if (meta.partitionBy && !this.#platform.supportsPartitionedTables()) {
|
|
580
|
+
this.#platform.validateMetadata(meta);
|
|
581
|
+
}
|
|
559
582
|
this.#validator.validateEntityDefinition(this.#metadata, meta.class, this.#config.get('discovery'));
|
|
560
583
|
for (const prop of Object.values(meta.properties)) {
|
|
561
584
|
this.initNullability(prop);
|
|
@@ -583,6 +606,24 @@ export class MetadataDiscovery {
|
|
|
583
606
|
if (prop.inversedBy) {
|
|
584
607
|
prop.targetMeta.properties[prop.inversedBy].pivotEntity = pivotMeta.class;
|
|
585
608
|
}
|
|
609
|
+
// Propagate pivotEntity to ALL inverse collections using mappedBy pointing at this
|
|
610
|
+
// owner prop. Covers three cases:
|
|
611
|
+
// - regular inverse (Tag.posts mappedBy Post.tags) — handled by inversedBy above
|
|
612
|
+
// - union-target inverse (Image.posts mappedBy Post.attachments) — on each polymorph target
|
|
613
|
+
// - merged inverse (Tag.owners mappedBy [Post,Video].tags) — union collection on the target
|
|
614
|
+
const inverseCandidates = QueryHelper.isUnionTargetPolymorphic(prop)
|
|
615
|
+
? prop.polymorphTargets
|
|
616
|
+
: [prop.targetMeta];
|
|
617
|
+
for (const targetMeta of inverseCandidates) {
|
|
618
|
+
for (const inverseProp of Object.values(targetMeta.properties)) {
|
|
619
|
+
if (inverseProp.kind === ReferenceKind.MANY_TO_MANY &&
|
|
620
|
+
inverseProp.mappedBy === prop.name &&
|
|
621
|
+
!inverseProp.pivotEntity) {
|
|
622
|
+
inverseProp.pivotEntity = pivotMeta.class;
|
|
623
|
+
inverseProp.pivotTable = pivotMeta.tableName;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
586
627
|
return pivotMeta;
|
|
587
628
|
});
|
|
588
629
|
}
|
|
@@ -721,8 +762,12 @@ export class MetadataDiscovery {
|
|
|
721
762
|
}
|
|
722
763
|
}
|
|
723
764
|
}
|
|
724
|
-
//
|
|
725
|
-
if (prop.
|
|
765
|
+
// Union-target polymorphic M:N: discriminator + target FK share the pivot across multiple target types
|
|
766
|
+
if (prop.discriminatorColumn && QueryHelper.isUnionTargetPolymorphic(prop)) {
|
|
767
|
+
this.defineUnionTargetPolymorphicPivotProperties(pivotMeta2, meta, prop);
|
|
768
|
+
}
|
|
769
|
+
else if (prop.polymorphic && prop.discriminatorColumn) {
|
|
770
|
+
// Rails-style polymorphic M:N: multiple owners share the pivot, single target type
|
|
726
771
|
this.definePolymorphicPivotProperties(pivotMeta2, meta, prop, targetMeta);
|
|
727
772
|
}
|
|
728
773
|
else {
|
|
@@ -809,6 +854,33 @@ export class MetadataDiscovery {
|
|
|
809
854
|
pivotMeta.polymorphicDiscriminatorMap ??= {};
|
|
810
855
|
pivotMeta.polymorphicDiscriminatorMap[prop.discriminatorValue] = meta.class;
|
|
811
856
|
}
|
|
857
|
+
/**
|
|
858
|
+
* Mirror of definePolymorphicPivotProperties for union-target M:N
|
|
859
|
+
* (e.g. Post.attachments -> Image | Video via shared pivot with a target-side discriminator).
|
|
860
|
+
*
|
|
861
|
+
* Pivot shape:
|
|
862
|
+
* (owner_fk..., discriminator_column, target_fk...)
|
|
863
|
+
* - owner side is a normal M:1 to the single owner entity
|
|
864
|
+
* - target side is a discriminator column + per-target-type virtual M:1 relations
|
|
865
|
+
*/
|
|
866
|
+
defineUnionTargetPolymorphicPivotProperties(pivotMeta, meta, prop) {
|
|
867
|
+
const discriminatorColumn = prop.discriminatorColumn;
|
|
868
|
+
const targets = prop.polymorphTargets;
|
|
869
|
+
pivotMeta.properties[meta.name + '_owner'] = this.definePivotProperty(prop, meta.name + '_owner', meta.class, prop.discriminator, true, false);
|
|
870
|
+
const discriminatorProp = this.createPivotScalarProperty(discriminatorColumn, [this.#platform.getVarcharTypeDeclarationSQL(prop)], [discriminatorColumn], { type: 'string', primary: true, nullable: false });
|
|
871
|
+
this.initFieldName(discriminatorProp);
|
|
872
|
+
pivotMeta.properties[discriminatorColumn] = discriminatorProp;
|
|
873
|
+
const firstTargetColumnTypes = this.getPrimaryKeyColumnTypes(targets[0]);
|
|
874
|
+
pivotMeta.properties[prop.discriminator] = this.createPivotScalarProperty(prop.discriminator, firstTargetColumnTypes, [...prop.inverseJoinColumns], { type: targets[0].className, primary: true, nullable: false });
|
|
875
|
+
pivotMeta.polymorphicDiscriminatorMap ??= {};
|
|
876
|
+
for (const targetMeta of targets) {
|
|
877
|
+
const relationName = `${prop.discriminator}_${targetMeta.tableName}`;
|
|
878
|
+
const relation = this.definePolymorphicOwnerRelation(prop, relationName, targetMeta);
|
|
879
|
+
relation.joinColumns = relation.fieldNames = relation.ownColumns = [...prop.inverseJoinColumns];
|
|
880
|
+
pivotMeta.properties[relationName] = relation;
|
|
881
|
+
pivotMeta.polymorphicDiscriminatorMap[targetMeta.tableName] = targetMeta.class;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
812
884
|
/**
|
|
813
885
|
* Create a virtual M:1 relation from pivot to a polymorphic owner entity.
|
|
814
886
|
* This enables single-query join loading for inverse-side polymorphic M:N.
|
|
@@ -961,6 +1033,7 @@ export class MetadataDiscovery {
|
|
|
961
1033
|
meta.indexes = Utils.unique([...base.indexes, ...meta.indexes]);
|
|
962
1034
|
meta.uniques = Utils.unique([...base.uniques, ...meta.uniques]);
|
|
963
1035
|
meta.checks = Utils.unique([...base.checks, ...meta.checks]);
|
|
1036
|
+
meta.triggers = Utils.unique([...base.triggers, ...meta.triggers]);
|
|
964
1037
|
const pks = Object.values(meta.properties)
|
|
965
1038
|
.filter(p => p.primary)
|
|
966
1039
|
.map(p => p.name);
|
|
@@ -1045,11 +1118,15 @@ export class MetadataDiscovery {
|
|
|
1045
1118
|
prop.discriminatorColumn ??= this.#namingStrategy.discriminatorColumnName(prop.discriminator);
|
|
1046
1119
|
prop.createForeignKeyConstraint = false;
|
|
1047
1120
|
const isToOne = [ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind);
|
|
1048
|
-
|
|
1121
|
+
const isUnionTargetMN = prop.kind === ReferenceKind.MANY_TO_MANY && Array.isArray(prop.target);
|
|
1122
|
+
if (isToOne || isUnionTargetMN) {
|
|
1049
1123
|
const types = prop.type.split(/ ?\| ?/);
|
|
1050
1124
|
prop.polymorphTargets = discovered.filter(m => types.includes(m.className) && !m.embeddable);
|
|
1051
1125
|
prop.targetMeta = prop.polymorphTargets[0];
|
|
1052
1126
|
prop.referencedPKs = prop.targetMeta?.primaryKeys;
|
|
1127
|
+
if (isUnionTargetMN && prop.polymorphTargets.length < 2) {
|
|
1128
|
+
throw new MetadataError(`${meta.className}.${prop.name} union-target polymorphic M:N requires at least two target entity types; use a regular M:N relation for a single target.`);
|
|
1129
|
+
}
|
|
1053
1130
|
}
|
|
1054
1131
|
if (prop.discriminatorMap) {
|
|
1055
1132
|
const normalizedMap = {};
|
|
@@ -1065,7 +1142,7 @@ export class MetadataDiscovery {
|
|
|
1065
1142
|
}
|
|
1066
1143
|
prop.discriminatorMap = normalizedMap;
|
|
1067
1144
|
}
|
|
1068
|
-
else if (isToOne) {
|
|
1145
|
+
else if (isToOne || isUnionTargetMN) {
|
|
1069
1146
|
prop.discriminatorMap = {};
|
|
1070
1147
|
const tableNameToTarget = new Map();
|
|
1071
1148
|
for (const target of prop.polymorphTargets) {
|
|
@@ -1187,6 +1264,19 @@ export class MetadataDiscovery {
|
|
|
1187
1264
|
});
|
|
1188
1265
|
}
|
|
1189
1266
|
}
|
|
1267
|
+
sameRelationTargetRoot(rootProp, prop) {
|
|
1268
|
+
if (!rootProp || rootProp.kind !== prop.kind) {
|
|
1269
|
+
return false;
|
|
1270
|
+
}
|
|
1271
|
+
if (prop.kind !== ReferenceKind.MANY_TO_ONE &&
|
|
1272
|
+
prop.kind !== ReferenceKind.ONE_TO_ONE &&
|
|
1273
|
+
prop.kind !== ReferenceKind.ONE_TO_MANY &&
|
|
1274
|
+
prop.kind !== ReferenceKind.MANY_TO_MANY) {
|
|
1275
|
+
return false;
|
|
1276
|
+
}
|
|
1277
|
+
const aRoot = this.#metadata.getByClassName(prop.type, false)?.root;
|
|
1278
|
+
return aRoot != null && aRoot === this.#metadata.getByClassName(rootProp.type, false)?.root;
|
|
1279
|
+
}
|
|
1190
1280
|
initSingleTableInheritance(meta, metadata) {
|
|
1191
1281
|
if (meta.root !== meta && !meta.__processed) {
|
|
1192
1282
|
meta.root = metadata.find(m => m.class === meta.root.class);
|
|
@@ -1227,12 +1317,24 @@ export class MetadataDiscovery {
|
|
|
1227
1317
|
Object.values(meta.properties).forEach(prop => {
|
|
1228
1318
|
const newProp = { ...prop };
|
|
1229
1319
|
const rootProp = meta.root.properties[prop.name];
|
|
1320
|
+
// A child that narrows a relation to a subclass of the root's declared
|
|
1321
|
+
// target (same STI hierarchy) shares the FK column with the root; treat
|
|
1322
|
+
// that as matching so the rename branch below doesn't run (which would
|
|
1323
|
+
// crash — `targetMeta` is only populated later, in `initRelation`).
|
|
1324
|
+
const narrowedRelationOverride = rootProp != null && rootProp.type !== prop.type && this.sameRelationTargetRoot(rootProp, prop);
|
|
1230
1325
|
// stiMerged is set during inlineProperties when a property was merged
|
|
1231
1326
|
// from multiple polymorphic variants with different types. The flag is
|
|
1232
1327
|
// cleared implicitly when the first child claims the root property via
|
|
1233
1328
|
// addProperty below, so subsequent children correctly trigger renaming.
|
|
1234
|
-
const typesMatch = rootProp?.type === prop.type || rootProp?.stiMerged === true;
|
|
1329
|
+
const typesMatch = rootProp?.type === prop.type || rootProp?.stiMerged === true || narrowedRelationOverride;
|
|
1330
|
+
// Inverse-side relations (1:m, inverse m:n) have no FK column on this
|
|
1331
|
+
// entity, so the rename branch — which exists to disambiguate physical
|
|
1332
|
+
// columns shared across STI children — doesn't apply. Skipping it also
|
|
1333
|
+
// avoids crashing on `[...rootProp.fieldNames]` since fieldNames is
|
|
1334
|
+
// never populated for inverse-side properties.
|
|
1335
|
+
const isInverseSideRelation = prop.kind === ReferenceKind.ONE_TO_MANY || (prop.kind === ReferenceKind.MANY_TO_MANY && !prop.owner);
|
|
1235
1336
|
if (rootProp &&
|
|
1337
|
+
!isInverseSideRelation &&
|
|
1236
1338
|
(!typesMatch ||
|
|
1237
1339
|
(rootProp.fieldNames && prop.fieldNames && !compareArrays(rootProp.fieldNames, prop.fieldNames)))) {
|
|
1238
1340
|
const name = newProp.name;
|
|
@@ -1281,6 +1383,20 @@ export class MetadataDiscovery {
|
|
|
1281
1383
|
}
|
|
1282
1384
|
newProp.nullable = true;
|
|
1283
1385
|
newProp.inherited = !rootProp;
|
|
1386
|
+
// For narrowed relation overrides, keep the root's declaration intact so
|
|
1387
|
+
// the full target union (e.g. `Food`) is preserved for populates from the
|
|
1388
|
+
// abstract root — otherwise the last child processed would win.
|
|
1389
|
+
if (narrowedRelationOverride) {
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
// STI siblings can declare the same property name on inverse-side relations
|
|
1393
|
+
// pointing to disjoint targets (e.g. `Dog.items → DogItem` and
|
|
1394
|
+
// `Cat.items → CatItem` with no shared base). There's no physical column
|
|
1395
|
+
// to manage at root, and propagating would clobber the first child's
|
|
1396
|
+
// declaration; each child keeps its local typed property instead.
|
|
1397
|
+
if (isInverseSideRelation && rootProp && rootProp.type !== prop.type) {
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1284
1400
|
meta.root.addProperty(newProp);
|
|
1285
1401
|
});
|
|
1286
1402
|
meta.tableName = meta.root.tableName;
|
|
@@ -1296,10 +1412,10 @@ export class MetadataDiscovery {
|
|
|
1296
1412
|
if (meta.root !== meta) {
|
|
1297
1413
|
meta.root = metadata.find(m => m.class === meta.root.class);
|
|
1298
1414
|
}
|
|
1299
|
-
|
|
1300
|
-
if (inheritance === 'tpt' && meta.root
|
|
1301
|
-
meta.inheritanceType = 'tpt';
|
|
1302
|
-
meta.tptChildren
|
|
1415
|
+
// init the root eagerly so children iterated before the root (e.g. alphabetical glob order) still get linked
|
|
1416
|
+
if (meta.root.inheritance === 'tpt' && meta.root.inheritanceType !== 'tpt') {
|
|
1417
|
+
meta.root.inheritanceType = 'tpt';
|
|
1418
|
+
meta.root.tptChildren ??= [];
|
|
1303
1419
|
}
|
|
1304
1420
|
if (meta.root.inheritanceType !== 'tpt') {
|
|
1305
1421
|
return;
|
|
@@ -1520,6 +1636,27 @@ export class MetadataDiscovery {
|
|
|
1520
1636
|
}
|
|
1521
1637
|
}
|
|
1522
1638
|
}
|
|
1639
|
+
initTriggers(meta) {
|
|
1640
|
+
if (meta.triggers.length === 0) {
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
const columns = meta.createSchemaColumnMappingObject();
|
|
1644
|
+
const table = this.createSchemaTable(meta);
|
|
1645
|
+
for (const trigger of meta.triggers) {
|
|
1646
|
+
if (trigger.body && trigger.expression) {
|
|
1647
|
+
throw new MetadataError(`Trigger "${trigger.name ?? '(unnamed)'}" on entity ${meta.className} defines both 'body' and 'expression'. Use one or the other.`);
|
|
1648
|
+
}
|
|
1649
|
+
if (!trigger.body && !trigger.expression) {
|
|
1650
|
+
throw new MetadataError(`Trigger "${trigger.name ?? '(unnamed)'}" on entity ${meta.className} must define either 'body' or 'expression'.`);
|
|
1651
|
+
}
|
|
1652
|
+
trigger.name ??= this.#namingStrategy.indexName(meta.tableName, trigger.events, 'trigger');
|
|
1653
|
+
trigger.forEach ??= 'row';
|
|
1654
|
+
if (trigger.body instanceof Function) {
|
|
1655
|
+
trigger.body = trigger.body(columns, table);
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
meta.hasTriggers = true;
|
|
1659
|
+
}
|
|
1523
1660
|
initGeneratedColumn(meta, prop) {
|
|
1524
1661
|
if (!prop.generated && prop.columnTypes) {
|
|
1525
1662
|
const match = /(.*) generated always as (.*)/i.exec(prop.columnTypes[0]);
|
|
@@ -1646,7 +1783,14 @@ export class MetadataDiscovery {
|
|
|
1646
1783
|
return (!Type.getType(t[type]).ensureComparable(meta, prop) && (prop.type === t[type] || brand === type));
|
|
1647
1784
|
});
|
|
1648
1785
|
if (type) {
|
|
1649
|
-
prop.
|
|
1786
|
+
if (prop.array) {
|
|
1787
|
+
// built-in type + array: true — force-create instance for ArrayType wrapping
|
|
1788
|
+
prop.customType = new prop.type();
|
|
1789
|
+
prop.type = prop.customType.constructor.name;
|
|
1790
|
+
}
|
|
1791
|
+
else {
|
|
1792
|
+
prop.type = type === 'datetime' ? 'Date' : type;
|
|
1793
|
+
}
|
|
1650
1794
|
}
|
|
1651
1795
|
else {
|
|
1652
1796
|
prop.customType = new prop.type();
|
|
@@ -1656,6 +1800,31 @@ export class MetadataDiscovery {
|
|
|
1656
1800
|
if (simple) {
|
|
1657
1801
|
return;
|
|
1658
1802
|
}
|
|
1803
|
+
if (prop.array &&
|
|
1804
|
+
prop.customType &&
|
|
1805
|
+
!(prop.customType instanceof t.array) &&
|
|
1806
|
+
!(prop.customType instanceof t.enumArray) &&
|
|
1807
|
+
!(prop.customType instanceof t.json) &&
|
|
1808
|
+
prop.kind === ReferenceKind.SCALAR) {
|
|
1809
|
+
const innerType = prop.customType;
|
|
1810
|
+
innerType.platform = this.#platform;
|
|
1811
|
+
innerType.meta = meta;
|
|
1812
|
+
innerType.prop = prop;
|
|
1813
|
+
if (!prop.columnTypes) {
|
|
1814
|
+
const arrayDecl = this.#platform.getArrayDeclarationSQL();
|
|
1815
|
+
if (arrayDecl.endsWith('[]')) {
|
|
1816
|
+
// native array support (e.g. postgres) — use inner type's column type with [] suffix
|
|
1817
|
+
prop.columnTypes = [innerType.getColumnType(prop, this.#platform) + '[]'];
|
|
1818
|
+
}
|
|
1819
|
+
else {
|
|
1820
|
+
// non-native (e.g. mysql, sqlite) — store as text/clob
|
|
1821
|
+
prop.columnTypes = [arrayDecl];
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
const platform = this.#platform;
|
|
1825
|
+
prop.customType = new t.array(v => innerType.convertToJSValue(v, platform), v => innerType.convertToDatabaseValue(v, platform));
|
|
1826
|
+
prop.type = prop.customType.constructor.name;
|
|
1827
|
+
}
|
|
1659
1828
|
if (!prop.customType && ['json', 'jsonb'].includes(prop.type?.toLowerCase())) {
|
|
1660
1829
|
prop.customType = new t.json();
|
|
1661
1830
|
}
|
|
@@ -1671,7 +1840,7 @@ export class MetadataDiscovery {
|
|
|
1671
1840
|
if (!prop.customType && prop.array && prop.items) {
|
|
1672
1841
|
prop.customType = new t.enumArray(`${meta.className}.${prop.name}`, prop.items);
|
|
1673
1842
|
}
|
|
1674
|
-
const isArray = prop.type?.toLowerCase() === 'array' || prop.type?.toString().endsWith('[]');
|
|
1843
|
+
const isArray = prop.array || prop.type?.toLowerCase() === 'array' || prop.type?.toString().endsWith('[]');
|
|
1675
1844
|
if (objectEmbeddable && !prop.customType && isArray) {
|
|
1676
1845
|
prop.customType = new t.json();
|
|
1677
1846
|
}
|
|
@@ -7,6 +7,30 @@ import type { MetadataStorage } from './MetadataStorage.js';
|
|
|
7
7
|
export declare class MetadataValidator {
|
|
8
8
|
validateEntityDefinition<T>(metadata: MetadataStorage, name: EntityName<T>, options: MetadataDiscoveryOptions): void;
|
|
9
9
|
validateDiscovered(discovered: EntityMetadata[], options: MetadataDiscoveryOptions): void;
|
|
10
|
+
private validatePartitioning;
|
|
11
|
+
/**
|
|
12
|
+
* Find the first partition name whose normalized form (case-folded for unquoted segments,
|
|
13
|
+
* quoted segments preserved) has already been seen. Returns the offending name in its
|
|
14
|
+
* original form for the error message.
|
|
15
|
+
*/
|
|
16
|
+
private findDuplicatePartitionName;
|
|
17
|
+
/**
|
|
18
|
+
* Partition names may be bare (`child`), schema-qualified (`schema.child`), or use quoted
|
|
19
|
+
* identifiers (`"my.schema"."child"`). Reject anything with more than one unquoted `.`.
|
|
20
|
+
*/
|
|
21
|
+
private hasValidPartitionName;
|
|
22
|
+
private hasPartitionExpression;
|
|
23
|
+
private validatePartitionKeyConstraints;
|
|
24
|
+
/**
|
|
25
|
+
* Returns the list of physical field names that a partition expression references, or
|
|
26
|
+
* `undefined` when the expression is opaque (callback, or raw SQL like `date_trunc('day', x)`
|
|
27
|
+
* that we cannot statically parse). Opaque expressions intentionally bypass the primary-key /
|
|
28
|
+
* unique-constraint coverage checks — users are trusted to ensure the referenced columns are
|
|
29
|
+
* part of the partition key, since PostgreSQL will surface the violation at DDL execution.
|
|
30
|
+
*/
|
|
31
|
+
private getPartitionKeyFields;
|
|
32
|
+
private resolvePartitionKeyField;
|
|
33
|
+
private getConstraintFields;
|
|
10
34
|
private validateReference;
|
|
11
35
|
private validateTargetKey;
|
|
12
36
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Utils } from '../utils/Utils.js';
|
|
2
|
+
import { normalizePartitionNameForComparison, splitCommaSeparatedIdentifiers } from '../utils/partition-utils.js';
|
|
2
3
|
import { MetadataError } from '../errors.js';
|
|
3
4
|
import { ReferenceKind } from '../enums.js';
|
|
4
5
|
/**
|
|
@@ -28,6 +29,9 @@ export class MetadataValidator {
|
|
|
28
29
|
// Virtual entities (expression without view flag) have restrictions - no PKs, limited relation types
|
|
29
30
|
// Note: meta.virtual is set later in sync(), so we check for expression && !view here
|
|
30
31
|
if (meta.virtual || (meta.expression && !meta.view)) {
|
|
32
|
+
if (meta.partitionBy) {
|
|
33
|
+
throw new MetadataError(`Virtual entity ${meta.className} cannot define partitionBy`);
|
|
34
|
+
}
|
|
31
35
|
for (const prop of Utils.values(meta.properties)) {
|
|
32
36
|
if (![ReferenceKind.SCALAR, ReferenceKind.EMBEDDED, ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind)) {
|
|
33
37
|
throw new MetadataError(`Only scalars, embedded properties and to-many relations are allowed inside virtual entity. Found '${prop.kind}' in ${meta.className}.${prop.name}`);
|
|
@@ -46,6 +50,7 @@ export class MetadataValidator {
|
|
|
46
50
|
this.validateDuplicateFieldNames(meta, options);
|
|
47
51
|
this.validateIndexes(meta, meta.indexes ?? [], 'index');
|
|
48
52
|
this.validateIndexes(meta, meta.uniques ?? [], 'unique');
|
|
53
|
+
this.validatePartitioning(meta);
|
|
49
54
|
this.validatePropertyNames(meta);
|
|
50
55
|
for (const prop of Utils.values(meta.properties)) {
|
|
51
56
|
if (prop.kind !== ReferenceKind.SCALAR) {
|
|
@@ -111,6 +116,189 @@ export class MetadataValidator {
|
|
|
111
116
|
}
|
|
112
117
|
});
|
|
113
118
|
}
|
|
119
|
+
validatePartitioning(meta) {
|
|
120
|
+
if (!meta.partitionBy) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (!this.hasPartitionExpression(meta.partitionBy.expression)) {
|
|
124
|
+
throw new MetadataError(`Entity ${meta.className} has invalid partitionBy option: missing expression`);
|
|
125
|
+
}
|
|
126
|
+
// Inheritance (STI/TPT) and partitioning both drive table layout, so combining them would
|
|
127
|
+
// require non-trivial DDL coordination that the schema generator does not produce today.
|
|
128
|
+
const hasInheritance = !!meta.root.discriminatorColumn || meta.root.inheritanceType === 'tpt' || meta.root.inheritance === 'tpt';
|
|
129
|
+
if (hasInheritance) {
|
|
130
|
+
throw new MetadataError(`Entity ${meta.className} has invalid partitionBy option: combining partitioning with inheritance is not supported`);
|
|
131
|
+
}
|
|
132
|
+
this.validatePartitionKeyConstraints(meta);
|
|
133
|
+
if (meta.partitionBy.type === 'hash') {
|
|
134
|
+
const { partitions } = meta.partitionBy;
|
|
135
|
+
if (Array.isArray(partitions)) {
|
|
136
|
+
if (partitions.length === 0) {
|
|
137
|
+
throw new MetadataError(`Entity ${meta.className} has invalid partitionBy option: hash partition name list must not be empty`);
|
|
138
|
+
}
|
|
139
|
+
const blank = partitions.find(name => typeof name !== 'string' || !name.trim());
|
|
140
|
+
if (blank !== undefined) {
|
|
141
|
+
throw new MetadataError(`Entity ${meta.className} has invalid partitionBy option: hash partition names must be non-empty strings`);
|
|
142
|
+
}
|
|
143
|
+
const ambiguous = partitions.find(name => !this.hasValidPartitionName(name));
|
|
144
|
+
if (ambiguous) {
|
|
145
|
+
throw new MetadataError(`Entity ${meta.className} has invalid partitionBy option: partition name '${ambiguous}' contains more than one '.' — use at most one '.' to separate schema from table`);
|
|
146
|
+
}
|
|
147
|
+
const duplicate = this.findDuplicatePartitionName(partitions);
|
|
148
|
+
if (duplicate !== undefined) {
|
|
149
|
+
throw new MetadataError(`Entity ${meta.className} has invalid partitionBy option: duplicate hash partition name '${duplicate}'`);
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (typeof partitions !== 'number' || !Number.isInteger(partitions) || partitions < 1) {
|
|
154
|
+
throw new MetadataError(`Entity ${meta.className} has invalid partitionBy option: hash partition count must be a positive integer`);
|
|
155
|
+
}
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (!Array.isArray(meta.partitionBy.partitions) || meta.partitionBy.partitions.length === 0) {
|
|
159
|
+
throw new MetadataError(`Entity ${meta.className} has invalid partitionBy option: list/range partitions must be a non-empty array`);
|
|
160
|
+
}
|
|
161
|
+
if (meta.partitionBy.partitions.some(partition => !partition.values?.trim())) {
|
|
162
|
+
throw new MetadataError(`Entity ${meta.className} has invalid partitionBy option: every partition must define values`);
|
|
163
|
+
}
|
|
164
|
+
const ambiguousName = meta.partitionBy.partitions.find(partition => partition.name != null && !this.hasValidPartitionName(partition.name));
|
|
165
|
+
if (ambiguousName) {
|
|
166
|
+
throw new MetadataError(`Entity ${meta.className} has invalid partitionBy option: partition name '${ambiguousName.name}' contains more than one '.' — use at most one '.' to separate schema from table`);
|
|
167
|
+
}
|
|
168
|
+
// Include auto-generated default names (`${tableName}_${index}`, matching
|
|
169
|
+
// `createExplicitPartitions` in the sql package) so an explicit name that collides with
|
|
170
|
+
// an unnamed peer's default is caught here rather than at DDL execution time.
|
|
171
|
+
const resolvedNames = meta.partitionBy.partitions.map((partition, index) => partition.name ?? `${meta.tableName}_${index}`);
|
|
172
|
+
const duplicate = this.findDuplicatePartitionName(resolvedNames);
|
|
173
|
+
if (duplicate !== undefined) {
|
|
174
|
+
throw new MetadataError(`Entity ${meta.className} has invalid partitionBy option: duplicate partition name '${duplicate}'`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Find the first partition name whose normalized form (case-folded for unquoted segments,
|
|
179
|
+
* quoted segments preserved) has already been seen. Returns the offending name in its
|
|
180
|
+
* original form for the error message.
|
|
181
|
+
*/
|
|
182
|
+
findDuplicatePartitionName(names) {
|
|
183
|
+
const seen = new Set();
|
|
184
|
+
for (const name of names) {
|
|
185
|
+
const normalized = normalizePartitionNameForComparison(name);
|
|
186
|
+
if (seen.has(normalized)) {
|
|
187
|
+
return name;
|
|
188
|
+
}
|
|
189
|
+
seen.add(normalized);
|
|
190
|
+
}
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Partition names may be bare (`child`), schema-qualified (`schema.child`), or use quoted
|
|
195
|
+
* identifiers (`"my.schema"."child"`). Reject anything with more than one unquoted `.`.
|
|
196
|
+
*/
|
|
197
|
+
hasValidPartitionName(name) {
|
|
198
|
+
let depth = 0;
|
|
199
|
+
let dots = 0;
|
|
200
|
+
for (let i = 0; i < name.length; i++) {
|
|
201
|
+
const ch = name[i];
|
|
202
|
+
if (ch === '"') {
|
|
203
|
+
if (name[i + 1] === '"') {
|
|
204
|
+
i++;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
depth = depth === 0 ? 1 : 0;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (ch === '.' && depth === 0) {
|
|
211
|
+
dots++;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return dots <= 1;
|
|
215
|
+
}
|
|
216
|
+
hasPartitionExpression(expression) {
|
|
217
|
+
if (expression == null) {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
if (typeof expression === 'function') {
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
if (Array.isArray(expression)) {
|
|
224
|
+
return expression.length > 0 && expression.every(key => typeof key === 'string' && key.trim().length > 0);
|
|
225
|
+
}
|
|
226
|
+
return String(expression).trim().length > 0;
|
|
227
|
+
}
|
|
228
|
+
validatePartitionKeyConstraints(meta) {
|
|
229
|
+
const partitionFields = this.getPartitionKeyFields(meta);
|
|
230
|
+
if (!partitionFields?.length) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const primaryKeyFields = meta.root.getPrimaryProps().flatMap(prop => prop.fieldNames);
|
|
234
|
+
if (partitionFields.some(field => !primaryKeyFields.includes(field))) {
|
|
235
|
+
throw new MetadataError(`Entity ${meta.className} has invalid partitionBy option: primary key must include partition key columns '${partitionFields.join("', '")}'`);
|
|
236
|
+
}
|
|
237
|
+
for (const prop of Object.values(meta.root.properties)) {
|
|
238
|
+
if (!prop.unique || !prop.fieldNames?.length) {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (partitionFields.some(field => !prop.fieldNames.includes(field))) {
|
|
242
|
+
throw new MetadataError(`Entity ${meta.root.className} has invalid partitionBy option: unique property ${meta.root.className}.${prop.name} must include partition key columns '${partitionFields.join("', '")}'`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
for (const unique of meta.root.uniques ?? []) {
|
|
246
|
+
const fields = this.getConstraintFields(meta.root, unique.properties);
|
|
247
|
+
if (!fields?.length) {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (partitionFields.some(field => !fields.includes(field))) {
|
|
251
|
+
const constraint = unique.name ? `unique constraint '${unique.name}'` : 'unique constraint';
|
|
252
|
+
throw new MetadataError(`Entity ${meta.root.className} has invalid partitionBy option: ${constraint} must include partition key columns '${partitionFields.join("', '")}'`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Returns the list of physical field names that a partition expression references, or
|
|
258
|
+
* `undefined` when the expression is opaque (callback, or raw SQL like `date_trunc('day', x)`
|
|
259
|
+
* that we cannot statically parse). Opaque expressions intentionally bypass the primary-key /
|
|
260
|
+
* unique-constraint coverage checks — users are trusted to ensure the referenced columns are
|
|
261
|
+
* part of the partition key, since PostgreSQL will surface the violation at DDL execution.
|
|
262
|
+
*/
|
|
263
|
+
getPartitionKeyFields(meta) {
|
|
264
|
+
const expression = meta.partitionBy?.expression;
|
|
265
|
+
if (!expression || typeof expression === 'function') {
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
if (Array.isArray(expression)) {
|
|
269
|
+
return expression.map(key => this.resolvePartitionKeyField(meta, key));
|
|
270
|
+
}
|
|
271
|
+
const keys = splitCommaSeparatedIdentifiers(String(expression).trim());
|
|
272
|
+
if (!keys) {
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
return keys.map(key => this.resolvePartitionKeyField(meta, key));
|
|
276
|
+
}
|
|
277
|
+
resolvePartitionKeyField(meta, key) {
|
|
278
|
+
const trimmed = key.trim().replaceAll('"', '');
|
|
279
|
+
if (!trimmed) {
|
|
280
|
+
throw new MetadataError(`Entity ${meta.className} has invalid partitionBy option: empty partition key`);
|
|
281
|
+
}
|
|
282
|
+
const prop = meta.root.properties[trimmed] ??
|
|
283
|
+
Object.values(meta.root.properties).find(candidate => candidate.fieldNames?.length === 1 && candidate.fieldNames[0] === trimmed);
|
|
284
|
+
if (!prop) {
|
|
285
|
+
throw new MetadataError(`Entity ${meta.className} has invalid partitionBy option: unknown partition key '${key.trim()}'`);
|
|
286
|
+
}
|
|
287
|
+
if (prop.fieldNames?.length !== 1) {
|
|
288
|
+
throw new MetadataError(`Entity ${meta.className} has invalid partitionBy option: partition key '${key.trim()}' maps to multiple columns ('${prop.fieldNames?.join("', '")}'); list them explicitly as partition keys`);
|
|
289
|
+
}
|
|
290
|
+
return prop.fieldNames[0];
|
|
291
|
+
}
|
|
292
|
+
getConstraintFields(meta, properties) {
|
|
293
|
+
if (!properties) {
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
296
|
+
const fields = Utils.asArray(properties).flatMap(propName => {
|
|
297
|
+
const prop = meta.root.properties[propName];
|
|
298
|
+
return prop?.fieldNames ?? [];
|
|
299
|
+
});
|
|
300
|
+
return fields.length > 0 ? fields : undefined;
|
|
301
|
+
}
|
|
114
302
|
validateReference(meta, prop, options) {
|
|
115
303
|
// references do have types
|
|
116
304
|
if (!prop.type) {
|
|
@@ -160,7 +348,7 @@ export class MetadataValidator {
|
|
|
160
348
|
* Composite unique constraints are not sufficient for targetKey.
|
|
161
349
|
*/
|
|
162
350
|
isPropertyUnique(prop, meta) {
|
|
163
|
-
if (prop.unique) {
|
|
351
|
+
if (prop.unique || (prop.primary && meta.primaryKeys.length === 1)) {
|
|
164
352
|
return true;
|
|
165
353
|
}
|
|
166
354
|
// Check for single-property unique constraint via @Unique decorator
|
|
@@ -171,6 +359,15 @@ export class MetadataValidator {
|
|
|
171
359
|
}
|
|
172
360
|
validatePolymorphicTargets(meta, prop) {
|
|
173
361
|
const targets = prop.polymorphTargets;
|
|
362
|
+
// Union-target M:N stores one scalar target FK per pivot row, so composite-PK targets
|
|
363
|
+
// can't round-trip through this schema.
|
|
364
|
+
if (prop.kind === ReferenceKind.MANY_TO_MANY && targets.length > 1) {
|
|
365
|
+
for (const target of targets) {
|
|
366
|
+
if (target.compositePK) {
|
|
367
|
+
throw MetadataError.incompatiblePolymorphicTargets(meta, prop, targets[0], target, `${target.className} has a composite primary key; union-target polymorphic M:N does not support composite-PK targets.`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
174
371
|
// Validate targetKey exists and is compatible across all targets
|
|
175
372
|
if (prop.targetKey) {
|
|
176
373
|
for (const target of targets) {
|
|
@@ -349,6 +546,10 @@ export class MetadataValidator {
|
|
|
349
546
|
if (!meta.expression) {
|
|
350
547
|
throw MetadataError.viewEntityWithoutExpression(meta);
|
|
351
548
|
}
|
|
549
|
+
// Views are not partitionable - reject explicitly instead of silently ignoring
|
|
550
|
+
if (meta.partitionBy) {
|
|
551
|
+
throw new MetadataError(`View entity ${meta.className} cannot define partitionBy`);
|
|
552
|
+
}
|
|
352
553
|
// Validate indexes if present
|
|
353
554
|
this.validateIndexes(meta, meta.indexes ?? [], 'index');
|
|
354
555
|
this.validateIndexes(meta, meta.uniques ?? [], 'unique');
|