@mikro-orm/sql 7.2.0-dev.1 → 7.2.0-dev.10
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/AbstractSqlConnection.d.ts +18 -2
- package/AbstractSqlConnection.js +43 -17
- package/AbstractSqlDriver.d.ts +1 -1
- package/AbstractSqlDriver.js +61 -20
- package/SqlEntityManager.d.ts +2 -2
- package/SqlEntityManager.js +1 -2
- package/dialects/mssql/MsSqlNativeQueryBuilder.js +1 -1
- package/dialects/mysql/MySqlSchemaHelper.d.ts +1 -0
- package/dialects/mysql/MySqlSchemaHelper.js +4 -1
- package/dialects/oracledb/OracleNativeQueryBuilder.js +1 -1
- package/dialects/postgresql/PostgreSqlSchemaHelper.d.ts +4 -0
- package/dialects/postgresql/PostgreSqlSchemaHelper.js +29 -4
- package/dialects/sqlite/BaseSqliteConnection.d.ts +3 -0
- package/dialects/sqlite/BaseSqliteConnection.js +15 -5
- package/dialects/sqlite/SqliteSchemaHelper.js +1 -1
- package/package.json +4 -4
- package/query/NativeQueryBuilder.js +1 -1
- package/query/QueryBuilder.d.ts +14 -4
- package/query/QueryBuilder.js +44 -16
- package/query/QueryBuilderHelper.js +6 -1
- package/schema/DatabaseTable.d.ts +2 -0
- package/schema/DatabaseTable.js +63 -18
- package/schema/SchemaComparator.d.ts +1 -0
- package/schema/SchemaComparator.js +29 -6
- package/schema/SchemaHelper.d.ts +20 -1
- package/schema/SchemaHelper.js +52 -9
- package/schema/SqlSchemaGenerator.d.ts +4 -0
- package/schema/SqlSchemaGenerator.js +51 -22
- package/typings.d.ts +2 -2
package/query/QueryBuilder.d.ts
CHANGED
|
@@ -152,11 +152,10 @@ type ContextFilterKeys<Context> = {
|
|
|
152
152
|
type RawFilterKeys<RawAliases extends string> = {
|
|
153
153
|
[K in RawAliases]?: AliasedFilterValue;
|
|
154
154
|
};
|
|
155
|
-
type NestedFilterCondition<Entity, RootAlias extends string, Context, RawAliases extends string> = ObjectQuery<Entity> & (IsNever<RootAlias> extends true ? {} : string extends RootAlias ? {} : RootAliasFilterKeys<RootAlias, Entity>) & ([Context] extends [never] ? {} : ContextFilterKeys<Context>) & (IsNever<RawAliases> extends true ? {} : string extends RawAliases ? {} : RawFilterKeys<RawAliases>);
|
|
156
155
|
type GroupOperators<RootAlias extends string, Context, Entity, RawAliases extends string> = {
|
|
157
|
-
$and?:
|
|
158
|
-
$or?:
|
|
159
|
-
$not?:
|
|
156
|
+
$and?: QBFilterQuery<Entity, RootAlias, Context, RawAliases>[];
|
|
157
|
+
$or?: QBFilterQuery<Entity, RootAlias, Context, RawAliases>[];
|
|
158
|
+
$not?: QBFilterQuery<Entity, RootAlias, Context, RawAliases>;
|
|
160
159
|
};
|
|
161
160
|
export type AliasedFilterCondition<RootAlias extends string, Context, Entity, RawAliases extends string = never> = (IsNever<RootAlias> extends true ? {} : string extends RootAlias ? {} : RootAliasFilterKeys<RootAlias, Entity>) & ([Context] extends [never] ? {} : ContextFilterKeys<Context>) & (IsNever<RawAliases> extends true ? {} : string extends RawAliases ? {} : RawFilterKeys<RawAliases>) & GroupOperators<RootAlias, Context, Entity, RawAliases>;
|
|
162
161
|
export type QBFilterQuery<Entity, RootAlias extends string = never, Context = never, RawAliases extends string = never> = FilterObject<Entity> & AliasedFilterCondition<RootAlias, Context, Entity, RawAliases>;
|
|
@@ -506,6 +505,17 @@ export declare class QueryBuilder<Entity extends object = AnyEntity, RootAlias e
|
|
|
506
505
|
* @internal
|
|
507
506
|
*/
|
|
508
507
|
applyJoinedFilters(em: EntityManager, filterOptions: FilterOptions | undefined): Promise<void>;
|
|
508
|
+
/**
|
|
509
|
+
* The `on` clause of `condJoin` — its explicit join condition or a filter condition merged into
|
|
510
|
+
* it — can reference the alias of any join in its subtree, both auto-joins created while
|
|
511
|
+
* processing the condition and pre-existing joined paths, all of which render after `condJoin`
|
|
512
|
+
* and would be forward alias references (issues #7681, #8090, #8099). When that happens, fold the
|
|
513
|
+
* subtree into `condJoin`, so it renders as a single parenthesized join group and every alias
|
|
514
|
+
* shares the scope of the outer `on` clause.
|
|
515
|
+
*/
|
|
516
|
+
private nestReferencedJoins;
|
|
517
|
+
private getJoinSubtree;
|
|
518
|
+
private condReferencesAlias;
|
|
509
519
|
withSubQuery(subQuery: RawQueryFragment | NativeQueryBuilder, alias: string): this;
|
|
510
520
|
/**
|
|
511
521
|
* Adds a WHERE clause to the query using an object condition.
|
package/query/QueryBuilder.js
CHANGED
|
@@ -433,6 +433,7 @@ export class QueryBuilder {
|
|
|
433
433
|
else {
|
|
434
434
|
join.cond = { ...cond };
|
|
435
435
|
}
|
|
436
|
+
this.nestReferencedJoins(join);
|
|
436
437
|
// For polymorphic LEFT JOIN filters, add a WHERE condition to enforce the filter
|
|
437
438
|
// only for rows matching this target's discriminator value. This ensures rows pointing
|
|
438
439
|
// to other polymorphic targets are not excluded.
|
|
@@ -456,6 +457,45 @@ export class QueryBuilder {
|
|
|
456
457
|
}
|
|
457
458
|
}
|
|
458
459
|
}
|
|
460
|
+
/**
|
|
461
|
+
* The `on` clause of `condJoin` — its explicit join condition or a filter condition merged into
|
|
462
|
+
* it — can reference the alias of any join in its subtree, both auto-joins created while
|
|
463
|
+
* processing the condition and pre-existing joined paths, all of which render after `condJoin`
|
|
464
|
+
* and would be forward alias references (issues #7681, #8090, #8099). When that happens, fold the
|
|
465
|
+
* subtree into `condJoin`, so it renders as a single parenthesized join group and every alias
|
|
466
|
+
* shares the scope of the outer `on` clause.
|
|
467
|
+
*/
|
|
468
|
+
nestReferencedJoins(condJoin) {
|
|
469
|
+
// m:n pivot joins might not have the target join entry created
|
|
470
|
+
if (!condJoin) {
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
const subtree = this.getJoinSubtree(condJoin);
|
|
474
|
+
if (!subtree.some(j => this.condReferencesAlias(condJoin.cond, j.alias))) {
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
for (const j of subtree) {
|
|
478
|
+
const parent = j.ownerAlias === condJoin.alias ? condJoin : subtree.find(p => p.alias === j.ownerAlias);
|
|
479
|
+
if (!parent.nested?.has(j)) {
|
|
480
|
+
const nested = (parent.nested ??= new Set());
|
|
481
|
+
j.type = j.type === JoinType.innerJoin ? JoinType.nestedInnerJoin : JoinType.nestedLeftJoin;
|
|
482
|
+
nested.add(j);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
getJoinSubtree(join) {
|
|
487
|
+
const children = Object.values(this.#state.joins).filter(j => j !== join && j.ownerAlias === join.alias);
|
|
488
|
+
return children.flatMap(j => [j, ...this.getJoinSubtree(j)]);
|
|
489
|
+
}
|
|
490
|
+
condReferencesAlias(cond, alias) {
|
|
491
|
+
if (Array.isArray(cond)) {
|
|
492
|
+
return cond.some(c => this.condReferencesAlias(c, alias));
|
|
493
|
+
}
|
|
494
|
+
if (Utils.isPlainObject(cond)) {
|
|
495
|
+
return Object.entries(cond).some(([key, value]) => key.startsWith(`${alias}.`) || this.condReferencesAlias(value, alias));
|
|
496
|
+
}
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
459
499
|
withSubQuery(subQuery, alias) {
|
|
460
500
|
this.ensureNotFinalized();
|
|
461
501
|
if (isRaw(subQuery)) {
|
|
@@ -1104,7 +1144,9 @@ export class QueryBuilder {
|
|
|
1104
1144
|
}
|
|
1105
1145
|
if (stack.length > 0) {
|
|
1106
1146
|
const merged = this.driver.mergeJoinedResult(stack, this.mainAlias.meta, joinedProps);
|
|
1107
|
-
|
|
1147
|
+
for (const row of merged) {
|
|
1148
|
+
yield this.mapResult(row, options.mapResults);
|
|
1149
|
+
}
|
|
1108
1150
|
}
|
|
1109
1151
|
}
|
|
1110
1152
|
/**
|
|
@@ -1412,7 +1454,6 @@ export class QueryBuilder {
|
|
|
1412
1454
|
aliased: [QueryType.SELECT, QueryType.COUNT].includes(this.type),
|
|
1413
1455
|
});
|
|
1414
1456
|
const criteriaNode = CriteriaNodeFactory.createNode(this.metadata, prop.targetMeta.class, cond);
|
|
1415
|
-
const joinCountBefore = Object.keys(this.#state.joins).length;
|
|
1416
1457
|
cond = criteriaNode.process(this, { ignoreBranching: true, alias });
|
|
1417
1458
|
let aliasedName = `${fromAlias}.${prop.name}#${alias}`;
|
|
1418
1459
|
path ??= `${Object.values(this.#state.joins).find(j => j.alias === fromAlias)?.path ?? Utils.className(entityName)}.${prop.name}`;
|
|
@@ -1442,20 +1483,7 @@ export class QueryBuilder {
|
|
|
1442
1483
|
this.#state.joins[aliasedName] = this.helper.joinManyToOneReference(prop, ownerAlias, alias, type, cond, schema);
|
|
1443
1484
|
this.#state.joins[aliasedName].path ??= path;
|
|
1444
1485
|
}
|
|
1445
|
-
|
|
1446
|
-
// forward reference (the auto-join's ON refers to alias, while alias's ON refers back to it);
|
|
1447
|
-
// fold them into the new join so both aliases share scope in the outer ON clause (issue #7681)
|
|
1448
|
-
const condJoin = this.#state.joins[aliasedName];
|
|
1449
|
-
const joinKeys = Object.keys(this.#state.joins);
|
|
1450
|
-
for (let i = joinCountBefore; i < joinKeys.length; i++) {
|
|
1451
|
-
const j = this.#state.joins[joinKeys[i]];
|
|
1452
|
-
if (j === condJoin || j.ownerAlias !== alias) {
|
|
1453
|
-
continue;
|
|
1454
|
-
}
|
|
1455
|
-
const nested = (condJoin.nested ??= new Set());
|
|
1456
|
-
j.type = j.type === JoinType.innerJoin ? JoinType.nestedInnerJoin : JoinType.nestedLeftJoin;
|
|
1457
|
-
nested.add(j);
|
|
1458
|
-
}
|
|
1486
|
+
this.nestReferencedJoins(this.#state.joins[aliasedName]);
|
|
1459
1487
|
return { prop, key: aliasedName };
|
|
1460
1488
|
}
|
|
1461
1489
|
prepareFields(fields, type = 'where', schema) {
|
|
@@ -414,7 +414,8 @@ export class QueryBuilderHelper {
|
|
|
414
414
|
}
|
|
415
415
|
if (k === '$not') {
|
|
416
416
|
const res = this._appendQueryCondition(type, cond[k]);
|
|
417
|
-
|
|
417
|
+
// negating a vacuously true condition (e.g. an empty `$and`) matches nothing
|
|
418
|
+
parts.push(res.sql ? `not (${res.sql})` : '1 = 0');
|
|
418
419
|
res.params.forEach(p => params.push(p));
|
|
419
420
|
continue;
|
|
420
421
|
}
|
|
@@ -785,6 +786,10 @@ export class QueryBuilderHelper {
|
|
|
785
786
|
appendGroupCondition(type, operator, subCondition) {
|
|
786
787
|
const parts = [];
|
|
787
788
|
const params = [];
|
|
789
|
+
// an empty disjunction is false, same as `$in: []`, while an empty conjunction is vacuously true
|
|
790
|
+
if (operator === '$or' && subCondition.length === 0) {
|
|
791
|
+
return { sql: '1 = 0', params };
|
|
792
|
+
}
|
|
788
793
|
// single sub-condition can be ignored to reduce nesting of parens
|
|
789
794
|
if (subCondition.length === 1 || operator === '$and') {
|
|
790
795
|
for (const sub of subCondition) {
|
|
@@ -49,6 +49,8 @@ export declare class DatabaseTable {
|
|
|
49
49
|
getEntityDeclaration(namingStrategy: NamingStrategy, schemaHelper: SchemaHelper, scalarPropertiesForRelations: 'always' | 'never' | 'smart'): EntityMetadata;
|
|
50
50
|
private foreignKeysToProps;
|
|
51
51
|
private findFkIndex;
|
|
52
|
+
/** Advanced options require an entity-level declaration, as the property-level `index`/`unique` cannot carry them. */
|
|
53
|
+
private hasAdvancedIndexOptions;
|
|
52
54
|
private getIndexProperties;
|
|
53
55
|
private getSafeBaseNameForFkProp;
|
|
54
56
|
/**
|
package/schema/DatabaseTable.js
CHANGED
|
@@ -219,6 +219,8 @@ export class DatabaseTable {
|
|
|
219
219
|
skippedColumnNames.includes(index.columnNames[0]) || // Non-composite indexes for skipped columns are to be mapped as entity decorators.
|
|
220
220
|
index.deferMode ||
|
|
221
221
|
index.expression ||
|
|
222
|
+
index.where ||
|
|
223
|
+
this.hasAdvancedIndexOptions(index) ||
|
|
222
224
|
!(index.columnNames[0] in columnFks)) && // Trivial non-composite indexes for scalar props are to be mapped to the column.
|
|
223
225
|
// ignore indexes that don't have all column names (this can happen in sqlite where there is no way to infer this for expressions)
|
|
224
226
|
!(index.columnNames.some(col => !col) && !index.expression));
|
|
@@ -254,14 +256,7 @@ export class DatabaseTable {
|
|
|
254
256
|
}
|
|
255
257
|
}
|
|
256
258
|
// An index is trivial if it has no special options that require entity-level declaration
|
|
257
|
-
const
|
|
258
|
-
index.include?.length ||
|
|
259
|
-
index.fillFactor ||
|
|
260
|
-
index.type ||
|
|
261
|
-
index.invisible ||
|
|
262
|
-
index.disabled ||
|
|
263
|
-
index.clustered;
|
|
264
|
-
const isTrivial = !index.deferMode && !index.expression && !index.where && !hasAdvancedOptions;
|
|
259
|
+
const isTrivial = !index.deferMode && !index.expression && !index.where && !this.hasAdvancedIndexOptions(index);
|
|
265
260
|
if (isTrivial) {
|
|
266
261
|
// Index is for FK. Map to the FK prop and move on.
|
|
267
262
|
const fkForIndex = fkIndexes.get(index);
|
|
@@ -299,6 +294,19 @@ export class DatabaseTable {
|
|
|
299
294
|
}
|
|
300
295
|
schema.addIndex(ret);
|
|
301
296
|
}
|
|
297
|
+
for (const check of this.getChecks()) {
|
|
298
|
+
// skip checks that were consumed by enum conversion — the enum property recreates an
|
|
299
|
+
// equivalent check under the conventional name during discovery (only on platforms that
|
|
300
|
+
// emulate enums via check constraints; mysql/mariadb enums are native and recreate nothing)
|
|
301
|
+
const enumItems = check.columnName ? this.getColumn(check.columnName)?.enumItems : undefined;
|
|
302
|
+
if (this.#platform.usesEnumCheckConstraints() &&
|
|
303
|
+
enumItems?.length &&
|
|
304
|
+
(check.expression === this.#platform.getEnumCheckConstraintExpression(check.columnName, enumItems) ||
|
|
305
|
+
check.name === this.#platform.getIndexName(this.name, [check.columnName], 'check'))) {
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
schema.meta.checks.push({ name: check.name, expression: check.expression });
|
|
309
|
+
}
|
|
302
310
|
const addedStandaloneFkPropsBasedOnColumn = new Set();
|
|
303
311
|
const nonSkippedColumns = this.getColumns().filter(column => !skippedColumnNames.includes(column.name));
|
|
304
312
|
for (const column of nonSkippedColumns) {
|
|
@@ -507,7 +515,9 @@ export class DatabaseTable {
|
|
|
507
515
|
findFkIndex(currentFk) {
|
|
508
516
|
const fkColumnsLength = currentFk.columnNames.length;
|
|
509
517
|
const possibleIndexes = this.#indexes.filter(index => {
|
|
510
|
-
return (index.
|
|
518
|
+
return (!index.where &&
|
|
519
|
+
!this.hasAdvancedIndexOptions(index) &&
|
|
520
|
+
index.columnNames.length === fkColumnsLength &&
|
|
511
521
|
!currentFk.columnNames.some((columnName, i) => index.columnNames[i] !== columnName));
|
|
512
522
|
});
|
|
513
523
|
possibleIndexes.sort((a, b) => {
|
|
@@ -521,13 +531,31 @@ export class DatabaseTable {
|
|
|
521
531
|
});
|
|
522
532
|
return possibleIndexes.at(0);
|
|
523
533
|
}
|
|
534
|
+
/** Advanced options require an entity-level declaration, as the property-level `index`/`unique` cannot carry them. */
|
|
535
|
+
hasAdvancedIndexOptions(index) {
|
|
536
|
+
return !!(index.columns?.length ||
|
|
537
|
+
index.include?.length ||
|
|
538
|
+
index.fillFactor ||
|
|
539
|
+
index.type ||
|
|
540
|
+
index.invisible ||
|
|
541
|
+
index.disabled ||
|
|
542
|
+
index.clustered);
|
|
543
|
+
}
|
|
524
544
|
getIndexProperties(index, columnFks, fksOnColumnProps, fksOnStandaloneProps, namingStrategy) {
|
|
525
|
-
const propBaseNames = new
|
|
545
|
+
const propBaseNames = new Map();
|
|
526
546
|
const columnNames = index.columnNames;
|
|
527
547
|
const l = columnNames.length;
|
|
528
548
|
if (columnNames.some(col => !col)) {
|
|
529
549
|
return;
|
|
530
550
|
}
|
|
551
|
+
const addPropBaseName = (baseName, position) => {
|
|
552
|
+
const positions = propBaseNames.get(baseName);
|
|
553
|
+
if (positions) {
|
|
554
|
+
positions.last = position;
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
propBaseNames.set(baseName, { first: position, last: position });
|
|
558
|
+
};
|
|
531
559
|
for (let i = 0; i < l; ++i) {
|
|
532
560
|
const columnName = columnNames[i];
|
|
533
561
|
// The column is not involved with FKs.
|
|
@@ -538,14 +566,14 @@ export class DatabaseTable {
|
|
|
538
566
|
}
|
|
539
567
|
// It has a prop named after it.
|
|
540
568
|
// Add it and move on.
|
|
541
|
-
|
|
569
|
+
addPropBaseName(columnName, i);
|
|
542
570
|
continue;
|
|
543
571
|
}
|
|
544
572
|
// If the prop named after the column has a FK and the FK's columns are a subset of this index,
|
|
545
573
|
// include this prop and move on.
|
|
546
574
|
const columnPropFk = fksOnColumnProps.get(columnName);
|
|
547
575
|
if (columnPropFk && !columnPropFk.columnNames.some(fkColumnName => !columnNames.includes(fkColumnName))) {
|
|
548
|
-
|
|
576
|
+
addPropBaseName(columnName, i);
|
|
549
577
|
continue;
|
|
550
578
|
}
|
|
551
579
|
// If there is at least one standalone FK featuring this column,
|
|
@@ -557,7 +585,7 @@ export class DatabaseTable {
|
|
|
557
585
|
continue;
|
|
558
586
|
}
|
|
559
587
|
if (!fk.columnNames.some(fkColumnName => !columnNames.includes(fkColumnName))) {
|
|
560
|
-
|
|
588
|
+
addPropBaseName(propName, i);
|
|
561
589
|
propAdded = true;
|
|
562
590
|
}
|
|
563
591
|
}
|
|
@@ -568,7 +596,10 @@ export class DatabaseTable {
|
|
|
568
596
|
// Break the whole prop creation.
|
|
569
597
|
return;
|
|
570
598
|
}
|
|
571
|
-
|
|
599
|
+
// Props sharing their first column would otherwise follow FK discovery order, so break ties on the last one.
|
|
600
|
+
return Array.from(propBaseNames)
|
|
601
|
+
.sort(([, a], [, b]) => a.first - b.first || a.last - b.last)
|
|
602
|
+
.map(([baseName]) => this.getPropertyName(namingStrategy, baseName, fksOnColumnProps.get(baseName)));
|
|
572
603
|
}
|
|
573
604
|
getSafeBaseNameForFkProp(namingStrategy, currentFk, fks, columnName) {
|
|
574
605
|
if (columnName &&
|
|
@@ -694,9 +725,19 @@ export class DatabaseTable {
|
|
|
694
725
|
const prop = this.getPropertyName(namingStrategy, column.name, fk);
|
|
695
726
|
const persist = !(column.name in columnFks && typeof fk === 'undefined');
|
|
696
727
|
const index = compositeFkIndexes[prop] ||
|
|
697
|
-
this.#indexes.find(idx => idx.columnNames[0] === column.name &&
|
|
728
|
+
this.#indexes.find(idx => idx.columnNames[0] === column.name &&
|
|
729
|
+
!idx.composite &&
|
|
730
|
+
!idx.unique &&
|
|
731
|
+
!idx.primary &&
|
|
732
|
+
!idx.where &&
|
|
733
|
+
!this.hasAdvancedIndexOptions(idx));
|
|
698
734
|
const unique = compositeFkUniques[prop] ||
|
|
699
|
-
this.#indexes.find(idx => idx.columnNames[0] === column.name &&
|
|
735
|
+
this.#indexes.find(idx => idx.columnNames[0] === column.name &&
|
|
736
|
+
!idx.composite &&
|
|
737
|
+
idx.unique &&
|
|
738
|
+
!idx.primary &&
|
|
739
|
+
!idx.where &&
|
|
740
|
+
!this.hasAdvancedIndexOptions(idx));
|
|
700
741
|
const kind = this.getReferenceKind(fk, unique);
|
|
701
742
|
const runtimeType = this.getPropertyTypeForColumn(namingStrategy, column, fk);
|
|
702
743
|
const type = fk
|
|
@@ -989,8 +1030,12 @@ export class DatabaseTable {
|
|
|
989
1030
|
// mysql stores decimal defaults padded to scale (`0` → `0.00`); collapse to canonical numeric form
|
|
990
1031
|
// so the metadata-side (`0`) and introspection-side (`0.00`) snapshots agree
|
|
991
1032
|
let defaultValue = c.default ?? null;
|
|
992
|
-
if (defaultValue != null && c.mappedType instanceof DecimalType
|
|
993
|
-
|
|
1033
|
+
if (defaultValue != null && c.mappedType instanceof DecimalType) {
|
|
1034
|
+
// string defaults like `default: '0.00'` are quoted in metadata, so strip the quotes first
|
|
1035
|
+
const unquoted = defaultValue.replace(/^'(.*)'$/, '$1');
|
|
1036
|
+
if (Number.isFinite(+unquoted)) {
|
|
1037
|
+
defaultValue = this.#platform.formatDecimal(unquoted, c.scale).toString();
|
|
1038
|
+
}
|
|
994
1039
|
}
|
|
995
1040
|
const normalized = {
|
|
996
1041
|
name: c.name,
|
|
@@ -89,6 +89,7 @@ export declare class SchemaComparator {
|
|
|
89
89
|
private diffViewExpression;
|
|
90
90
|
private diffTrigger;
|
|
91
91
|
parseJsonDefault(defaultValue?: string | null): Dictionary | string | null;
|
|
92
|
+
private parseDecimalDefault;
|
|
92
93
|
hasSameDefaultValue(from: Column, to: Column): boolean;
|
|
93
94
|
private mapColumnToProperty;
|
|
94
95
|
private log;
|
|
@@ -820,6 +820,10 @@ export class SchemaComparator {
|
|
|
820
820
|
if (!!index1.clustered !== !!index2.clustered) {
|
|
821
821
|
return false;
|
|
822
822
|
}
|
|
823
|
+
// Compare the index access method (e.g. `using gin` on PostgreSQL); unset means the platform default
|
|
824
|
+
if (this.#helper.getIndexAccessMethod(index1) !== this.#helper.getIndexAccessMethod(index2)) {
|
|
825
|
+
return false;
|
|
826
|
+
}
|
|
823
827
|
// Compare WHERE predicate of partial indexes structurally (whitespace/quoting/casing
|
|
824
828
|
// are normalized via the same helper used for check constraints).
|
|
825
829
|
if (this.diffExpression(index1.where ?? '', index2.where ?? '')) {
|
|
@@ -897,9 +901,13 @@ export class SchemaComparator {
|
|
|
897
901
|
// lookbehind: only strip a real charset introducer, never an underscore inside a literal like 'a_b'
|
|
898
902
|
?.replace(/(?<![\w'])_\w+'(.*?)'/g, '$1')
|
|
899
903
|
.replace(/!=/g, '<>')
|
|
900
|
-
|
|
904
|
+
// `\b` keeps this from firing inside identifiers like `min(...)`
|
|
905
|
+
.replace(/\bin\s*\((.*?)\)/gi, '= any (array[$1])')
|
|
901
906
|
// MySQL normalizes count(*) to count(0)
|
|
902
907
|
.replace(/\bcount\s*\(\s*0\s*\)/gi, 'count(*)')
|
|
908
|
+
// multi word type names in casts, the generic `::\w+` below only covers single word ones
|
|
909
|
+
// the precision is kept, so `timestamptz(3)` and `timestamp(3) with time zone` leave the same residue
|
|
910
|
+
.replace(/::\s*(?:character\s+varying|bit\s+varying|double\s+precision|(?:timestamp|time)\b(\s*\(\d+\))?(?:\s+with(?:out)?\s+time\s+zone)?)/gi, '$1')
|
|
903
911
|
// Remove quotes first so we can process identifiers
|
|
904
912
|
.replace(/['"`]/g, '')
|
|
905
913
|
// MySQL adds table/alias prefixes to columns (e.g., a.name or table_name.column vs just column)
|
|
@@ -907,12 +915,18 @@ export class SchemaComparator {
|
|
|
907
915
|
.replace(/\b\w+\.(\w+)/g, '$1')
|
|
908
916
|
// Normalize JOIN syntax: inner join -> join (equivalent in SQL)
|
|
909
917
|
.replace(/\binner\s+join\b/gi, 'join')
|
|
918
|
+
// PostgreSQL names an unaliased bare function call after the function itself,
|
|
919
|
+
// so `max(created_at)` comes back as `max(created_at) AS max`
|
|
920
|
+
// the lookahead skips table function column alias lists like `unnest(a) AS unnest(c)`, which are meaningful
|
|
921
|
+
.replace(/\b(\w+)\s*\(((?:[^()]|\([^()]*\))*)\)\s+as\s+\1\b(?!\s*\()/gi, '$1($2)')
|
|
910
922
|
// Remove redundant column aliases like `title AS title` -> `title`
|
|
911
923
|
.replace(/\b(\w+)\s+as\s+\1\b/gi, '$1')
|
|
912
924
|
// Remove AS keyword (optional in SQL, MySQL may add/remove it)
|
|
913
925
|
.replace(/\bas\b/gi, '')
|
|
914
926
|
// Remove remaining special chars, parentheses, type casts, asterisks, and normalize whitespace
|
|
915
|
-
|
|
927
|
+
// tabs and CRs included — the schema generator trims every line before executing the DDL,
|
|
928
|
+
// so indentation and CRLF endings can never come back from introspection
|
|
929
|
+
.replace(/[()\n\r\t[\]*]|::\w+| +/g, '')
|
|
916
930
|
.replace(/anyarray\[(.*)]/gi, '$1')
|
|
917
931
|
.toLowerCase()
|
|
918
932
|
// PostgreSQL adds default aliases to aggregate functions (e.g., count(*) AS count)
|
|
@@ -979,6 +993,10 @@ export class SchemaComparator {
|
|
|
979
993
|
const val = defaultValue.replace(/^(_\w+\\)?'(.*?)\\?'$/, '$2').replace(/^\(?'(.*?)'\)?$/, '$1');
|
|
980
994
|
return parseJsonSafe(val);
|
|
981
995
|
}
|
|
996
|
+
parseDecimalDefault(defaultValue) {
|
|
997
|
+
const value = +('' + defaultValue).replace(/^'(.+)'$/, '$1');
|
|
998
|
+
return Number.isFinite(value) ? value : null;
|
|
999
|
+
}
|
|
982
1000
|
hasSameDefaultValue(from, to) {
|
|
983
1001
|
if (from.default == null ||
|
|
984
1002
|
from.default.toString().toLowerCase() === 'null' ||
|
|
@@ -1004,10 +1022,15 @@ export class SchemaComparator {
|
|
|
1004
1022
|
const defaultValueTo = to.default.toLowerCase().replace('current_timestamp', 'now').replace(/\(\)$/, '');
|
|
1005
1023
|
return defaultValueFrom === defaultValueTo;
|
|
1006
1024
|
}
|
|
1007
|
-
// mysql
|
|
1008
|
-
//
|
|
1009
|
-
if (to.mappedType instanceof DecimalType
|
|
1010
|
-
|
|
1025
|
+
// mysql pads decimal defaults to scale (`0` → `0.00`) and postgres reports them unquoted
|
|
1026
|
+
// while metadata keeps them quoted; compare numerically so neither churns a no-op migration
|
|
1027
|
+
if (to.mappedType instanceof DecimalType) {
|
|
1028
|
+
const defaultValueFrom = this.parseDecimalDefault(from.default);
|
|
1029
|
+
const defaultValueTo = this.parseDecimalDefault(to.default);
|
|
1030
|
+
if (defaultValueFrom != null && defaultValueTo != null) {
|
|
1031
|
+
return (this.#platform.formatDecimal(defaultValueFrom, to.scale) ===
|
|
1032
|
+
this.#platform.formatDecimal(defaultValueTo, to.scale));
|
|
1033
|
+
}
|
|
1011
1034
|
}
|
|
1012
1035
|
if (from.default && to.default) {
|
|
1013
1036
|
return from.default.toString().toLowerCase() === to.default.toString().toLowerCase();
|
package/schema/SchemaHelper.d.ts
CHANGED
|
@@ -4,7 +4,13 @@ import type { AbstractSqlPlatform } from '../AbstractSqlPlatform.js';
|
|
|
4
4
|
import type { CheckDef, Column, ForeignKey, IndexDef, Table, TableDifference, SqlTriggerDef, SqlRoutineDef } from '../typings.js';
|
|
5
5
|
import type { DatabaseSchema } from './DatabaseSchema.js';
|
|
6
6
|
import type { DatabaseTable } from './DatabaseTable.js';
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Flattens `;\n` boundaries and drops blank lines so the schema-generator's statement splitter
|
|
9
|
+
* doesn't break the routine or trigger DDL apart — it treats both as statement/group separators.
|
|
10
|
+
* Blank lines go first, otherwise a `;` followed by one would keep its newline. Like
|
|
11
|
+
* `normalizeViewDefinition`, this is not string-literal aware, so a blank line inside a multi-line
|
|
12
|
+
* literal is dropped too. Other whitespace is preserved.
|
|
13
|
+
*/
|
|
8
14
|
export declare function stripStatementNewlines(body: string): string;
|
|
9
15
|
/**
|
|
10
16
|
* Strips SQL line comments and blank lines from a view definition. Comments are dropped by the
|
|
@@ -64,6 +70,13 @@ export declare abstract class SchemaHelper {
|
|
|
64
70
|
* Hook for adding driver-specific index options (e.g., fill factor for PostgreSQL).
|
|
65
71
|
*/
|
|
66
72
|
protected getCreateIndexSuffix(_index: IndexDef): string;
|
|
73
|
+
/**
|
|
74
|
+
* Normalized index access method (e.g. `gin` on PostgreSQL), empty string when the
|
|
75
|
+
* platform default applies. Used for both DDL emission and index diffing.
|
|
76
|
+
*/
|
|
77
|
+
getIndexAccessMethod(_index: IndexDef): string;
|
|
78
|
+
/** Emits the access method between the table name and the column list (e.g. ` using gin`). */
|
|
79
|
+
protected getIndexAccessMethodClause(index: IndexDef): string;
|
|
67
80
|
/**
|
|
68
81
|
* Default emits ` where <predicate>` for partial indexes. Only Oracle overrides this to
|
|
69
82
|
* return `''` (it emulates partials via CASE-WHEN columns). MySQL sidesteps the whole path
|
|
@@ -118,6 +131,8 @@ export declare abstract class SchemaHelper {
|
|
|
118
131
|
getAddColumnsSQL(table: DatabaseTable, columns: Column[]): string[];
|
|
119
132
|
getDropColumnsSQL(tableName: string, columns: Column[], schemaName?: string): string;
|
|
120
133
|
hasNonDefaultPrimaryKeyName(table: DatabaseTable): boolean;
|
|
134
|
+
/** Returns the `constraint <name> ` prefix for a primary key definition, empty when the server assigns the default name on its own. */
|
|
135
|
+
protected getPrimaryKeyConstraintPrefix(table: DatabaseTable, index: IndexDef): string;
|
|
121
136
|
castColumn(name: string, type: string): string;
|
|
122
137
|
alterTableColumn(column: Column, table: DatabaseTable, changedProperties: Set<string>): string[];
|
|
123
138
|
/** Returns the bare `collate <name>` clause for column DDL. Overridden by PostgreSQL to quote the identifier. */
|
|
@@ -137,6 +152,8 @@ export declare abstract class SchemaHelper {
|
|
|
137
152
|
}[];
|
|
138
153
|
}[], safe: boolean): string[];
|
|
139
154
|
getChangeColumnCommentSQL(tableName: string, to: Column, schemaName?: string): string;
|
|
155
|
+
/** Whether the column comment is part of the column declaration, as opposed to a separate statement. */
|
|
156
|
+
protected hasInlineColumnComment(): boolean;
|
|
140
157
|
getNamespaces(connection: AbstractSqlConnection, ctx?: Transaction): Promise<string[]>;
|
|
141
158
|
protected mapIndexes(indexes: IndexDef[]): Promise<IndexDef[]>;
|
|
142
159
|
mapForeignKeys(fks: any[], tableName: string, schemaName?: string): Dictionary;
|
|
@@ -176,6 +193,8 @@ export declare abstract class SchemaHelper {
|
|
|
176
193
|
createRoutine(_routine: SqlRoutineDef): string;
|
|
177
194
|
dropRoutine(_routine: SqlRoutineDef): string;
|
|
178
195
|
getAllRoutines(_connection: AbstractSqlConnection, _schemas?: string[]): Promise<SqlRoutineDef[]>;
|
|
196
|
+
/** Flattens internal `;\n` so the statement splitter doesn't tear the DDL, and ensures exactly one trailing `;` for the enclosing `begin ... end` block. */
|
|
197
|
+
protected normalizeTriggerBody(body: string): string;
|
|
179
198
|
/** Wraps the body in `BEGIN ... END` if not already, and flattens internal `;\n` so the schema-generator's statement splitter doesn't tear the DDL. */
|
|
180
199
|
protected wrapRoutineBody(body: string): string;
|
|
181
200
|
protected stripRoutineBody(body: string): string;
|
package/schema/SchemaHelper.js
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import { isRaw, Utils, } from '@mikro-orm/core';
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* Flattens `;\n` boundaries and drops blank lines so the schema-generator's statement splitter
|
|
4
|
+
* doesn't break the routine or trigger DDL apart — it treats both as statement/group separators.
|
|
5
|
+
* Blank lines go first, otherwise a `;` followed by one would keep its newline. Like
|
|
6
|
+
* `normalizeViewDefinition`, this is not string-literal aware, so a blank line inside a multi-line
|
|
7
|
+
* literal is dropped too. Other whitespace is preserved.
|
|
8
|
+
*/
|
|
3
9
|
export function stripStatementNewlines(body) {
|
|
4
|
-
return body
|
|
10
|
+
return body
|
|
11
|
+
.split('\n')
|
|
12
|
+
.filter(line => line.trim() !== '')
|
|
13
|
+
.join('\n')
|
|
14
|
+
.replace(/;[\t ]*\r?\n/g, '; ');
|
|
5
15
|
}
|
|
6
16
|
/**
|
|
7
17
|
* Strips SQL line comments and blank lines from a view definition. Comments are dropped by the
|
|
@@ -157,13 +167,14 @@ export class SchemaHelper {
|
|
|
157
167
|
tableName = this.quote(tableName);
|
|
158
168
|
const keyName = this.quote(index.keyName);
|
|
159
169
|
const defer = index.deferMode ? ` deferrable initially ${index.deferMode}` : '';
|
|
160
|
-
|
|
170
|
+
const using = this.getIndexAccessMethodClause(index);
|
|
171
|
+
let sql = `create ${index.unique ? 'unique ' : ''}index ${keyName} on ${tableName}${using}`;
|
|
161
172
|
if (index.unique && index.constraint) {
|
|
162
173
|
sql = `alter table ${tableName} add constraint ${keyName} unique`;
|
|
163
174
|
}
|
|
164
175
|
if (index.columnNames.some(column => column.includes('.'))) {
|
|
165
176
|
// JSON columns can have unique index but not unique constraint, and we need to distinguish those, so we can properly drop them
|
|
166
|
-
sql = `create ${index.unique ? 'unique ' : ''}index ${keyName} on ${tableName}`;
|
|
177
|
+
sql = `create ${index.unique ? 'unique ' : ''}index ${keyName} on ${tableName}${using}`;
|
|
167
178
|
const columns = this.platform.getJsonIndexDefinition(index);
|
|
168
179
|
return `${sql} (${columns.join(', ')})${this.getCreateIndexSuffix(index)}${this.getIndexWhereClause(index)}${defer}`;
|
|
169
180
|
}
|
|
@@ -182,6 +193,18 @@ export class SchemaHelper {
|
|
|
182
193
|
getCreateIndexSuffix(_index) {
|
|
183
194
|
return '';
|
|
184
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* Normalized index access method (e.g. `gin` on PostgreSQL), empty string when the
|
|
198
|
+
* platform default applies. Used for both DDL emission and index diffing.
|
|
199
|
+
*/
|
|
200
|
+
getIndexAccessMethod(_index) {
|
|
201
|
+
return '';
|
|
202
|
+
}
|
|
203
|
+
/** Emits the access method between the table name and the column list (e.g. ` using gin`). */
|
|
204
|
+
getIndexAccessMethodClause(index) {
|
|
205
|
+
const method = this.getIndexAccessMethod(index);
|
|
206
|
+
return method ? ` using ${method}` : '';
|
|
207
|
+
}
|
|
185
208
|
/**
|
|
186
209
|
* Default emits ` where <predicate>` for partial indexes. Only Oracle overrides this to
|
|
187
210
|
* return `''` (it emulates partials via CASE-WHEN columns). MySQL sidesteps the whole path
|
|
@@ -436,7 +459,8 @@ export class SchemaHelper {
|
|
|
436
459
|
this.append(ret, this.alterTableColumn(column, diff.fromTable, changedProperties));
|
|
437
460
|
}
|
|
438
461
|
for (const { column, changedProperties } of Object.values(diff.changedColumns).filter(diff => diff.changedProperties.has('comment'))) {
|
|
439
|
-
if (
|
|
462
|
+
if (this.hasInlineColumnComment() &&
|
|
463
|
+
['type', 'nullable', 'autoincrement', 'unsigned', 'default', 'enumItems', 'collation'].some(t => changedProperties.has(t))) {
|
|
440
464
|
continue; // will be handled via column update
|
|
441
465
|
}
|
|
442
466
|
ret.push(this.getChangeColumnCommentSQL(tableName, column, schemaName));
|
|
@@ -489,7 +513,13 @@ export class SchemaHelper {
|
|
|
489
513
|
return `add ${this.createTableColumn(column, table)}`;
|
|
490
514
|
})
|
|
491
515
|
.join(', ');
|
|
492
|
-
|
|
516
|
+
const ret = [`alter table ${table.getQuotedName()} ${adds}`];
|
|
517
|
+
if (!this.hasInlineColumnComment()) {
|
|
518
|
+
for (const column of columns.filter(column => column.comment)) {
|
|
519
|
+
ret.push(this.getChangeColumnCommentSQL(table.name, column, table.schema));
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return ret;
|
|
493
523
|
}
|
|
494
524
|
getDropColumnsSQL(tableName, columns, schemaName) {
|
|
495
525
|
const name = this.quote(this.getTableName(tableName, schemaName));
|
|
@@ -504,6 +534,10 @@ export class SchemaHelper {
|
|
|
504
534
|
const defaultName = this.platform.getDefaultPrimaryName(table.name, pkIndex.columnNames);
|
|
505
535
|
return pkIndex?.keyName !== defaultName;
|
|
506
536
|
}
|
|
537
|
+
/** Returns the `constraint <name> ` prefix for a primary key definition, empty when the server assigns the default name on its own. */
|
|
538
|
+
getPrimaryKeyConstraintPrefix(table, index) {
|
|
539
|
+
return this.hasNonDefaultPrimaryKeyName(table) ? `constraint ${this.quote(index.keyName)} ` : '';
|
|
540
|
+
}
|
|
507
541
|
/* v8 ignore next */
|
|
508
542
|
castColumn(name, type) {
|
|
509
543
|
return '';
|
|
@@ -608,6 +642,10 @@ export class SchemaHelper {
|
|
|
608
642
|
getChangeColumnCommentSQL(tableName, to, schemaName) {
|
|
609
643
|
return '';
|
|
610
644
|
}
|
|
645
|
+
/** Whether the column comment is part of the column declaration, as opposed to a separate statement. */
|
|
646
|
+
hasInlineColumnComment() {
|
|
647
|
+
return false;
|
|
648
|
+
}
|
|
611
649
|
async getNamespaces(connection, ctx) {
|
|
612
650
|
return [];
|
|
613
651
|
}
|
|
@@ -745,7 +783,7 @@ export class SchemaHelper {
|
|
|
745
783
|
const primaryKey = table.getPrimaryKey();
|
|
746
784
|
const createPrimary = !table.getColumns().some(c => c.autoincrement && c.primary) || this.hasNonDefaultPrimaryKeyName(table);
|
|
747
785
|
if (createPrimary && primaryKey) {
|
|
748
|
-
const name = this.
|
|
786
|
+
const name = this.getPrimaryKeyConstraintPrefix(table, primaryKey);
|
|
749
787
|
sql += `, ${name}primary key (${primaryKey.columnNames.map(c => this.quote(c)).join(', ')})`;
|
|
750
788
|
}
|
|
751
789
|
sql += ')';
|
|
@@ -828,7 +866,7 @@ export class SchemaHelper {
|
|
|
828
866
|
const columns = index.columnNames.map(c => this.quote(c)).join(', ');
|
|
829
867
|
const defer = index.deferMode ? ` deferrable initially ${index.deferMode}` : '';
|
|
830
868
|
if (index.primary) {
|
|
831
|
-
const keyName = this.
|
|
869
|
+
const keyName = this.getPrimaryKeyConstraintPrefix(table, index);
|
|
832
870
|
return `alter table ${table.getQuotedName()} add ${keyName}primary key (${columns})${defer}`;
|
|
833
871
|
}
|
|
834
872
|
if (index.type === 'fulltext') {
|
|
@@ -861,7 +899,7 @@ export class SchemaHelper {
|
|
|
861
899
|
const events = trigger.events.map(e => e.toUpperCase()).join(' OR ');
|
|
862
900
|
const forEach = trigger.forEach === 'statement' ? 'STATEMENT' : 'ROW';
|
|
863
901
|
const when = trigger.when ? ` when (${trigger.when})` : '';
|
|
864
|
-
return `create trigger ${this.quote(trigger.name)} ${timing} ${events} on ${table.getQuotedName()} for each ${forEach}${when} begin ${trigger.body}
|
|
902
|
+
return `create trigger ${this.quote(trigger.name)} ${timing} ${events} on ${table.getQuotedName()} for each ${forEach}${when} begin ${this.normalizeTriggerBody(trigger.body)} end`;
|
|
865
903
|
}
|
|
866
904
|
/**
|
|
867
905
|
* Generates SQL to drop a database trigger from a table.
|
|
@@ -885,6 +923,11 @@ export class SchemaHelper {
|
|
|
885
923
|
async getAllRoutines(_connection, _schemas = []) {
|
|
886
924
|
return [];
|
|
887
925
|
}
|
|
926
|
+
/** Flattens internal `;\n` so the statement splitter doesn't tear the DDL, and ensures exactly one trailing `;` for the enclosing `begin ... end` block. */
|
|
927
|
+
normalizeTriggerBody(body) {
|
|
928
|
+
const trimmed = stripStatementNewlines(body).trim();
|
|
929
|
+
return /;\s*$/.test(trimmed) ? trimmed : `${trimmed};`;
|
|
930
|
+
}
|
|
888
931
|
/** Wraps the body in `BEGIN ... END` if not already, and flattens internal `;\n` so the schema-generator's statement splitter doesn't tear the DDL. */
|
|
889
932
|
wrapRoutineBody(body) {
|
|
890
933
|
const trimmed = stripStatementNewlines(body).trim();
|
|
@@ -54,6 +54,10 @@ export declare class SqlSchemaGenerator extends AbstractSchemaGenerator<Abstract
|
|
|
54
54
|
wrap?: boolean;
|
|
55
55
|
ctx?: Transaction;
|
|
56
56
|
}): Promise<void>;
|
|
57
|
+
/** Splits the SQL on the separator, keeping the separators that fall inside a string literal. */
|
|
58
|
+
private splitOutsideLiterals;
|
|
59
|
+
/** Whether the statement has to be the first one in a query batch, e.g. `create trigger` on MSSQL. */
|
|
60
|
+
protected startsBatch(_statement: string): boolean;
|
|
57
61
|
dropTableIfExists(name: string, schema?: string): Promise<void>;
|
|
58
62
|
private wrapSchema;
|
|
59
63
|
private append;
|