uql-orm 0.42.0 → 0.42.1

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.
Files changed (47) hide show
  1. package/dist/browser/querier/httpQuerier.d.ts +3 -3
  2. package/dist/browser/type/clientQuerier.d.ts +3 -3
  3. package/dist/browser/uql-browser.min.js +2 -2
  4. package/dist/browser/uql-browser.min.js.map +4 -4
  5. package/dist/dialect/abstractSqlDialect.d.ts +31 -2
  6. package/dist/dialect/abstractSqlDialect.js +41 -10
  7. package/dist/dialect/mysqlLikeSqlDialect.d.ts +2 -1
  8. package/dist/dialect/mysqlLikeSqlDialect.js +3 -1
  9. package/dist/dialect/pgLikeSqlDialect.d.ts +1 -1
  10. package/dist/dialect/pgLikeSqlDialect.js +2 -1
  11. package/dist/entity/decorator/entity.d.ts +1 -1
  12. package/dist/entity/decorator/entity.js +1 -1
  13. package/dist/maria/mariaDialect.js +2 -1
  14. package/dist/migrate/builder/tableBuilder.js +5 -4
  15. package/dist/migrate/drift/driftDetector.js +16 -0
  16. package/dist/migrate/generator/mongoSchemaGenerator.d.ts +7 -0
  17. package/dist/migrate/generator/mongoSchemaGenerator.js +24 -28
  18. package/dist/migrate/introspection/abstractSqlSchemaIntrospector.d.ts +9 -1
  19. package/dist/migrate/introspection/abstractSqlSchemaIntrospector.js +11 -2
  20. package/dist/migrate/introspection/baseSqlIntrospector.js +6 -3
  21. package/dist/migrate/introspection/postgresIntrospector.js +1 -1
  22. package/dist/migrate/introspection/sqliteIntrospector.js +7 -3
  23. package/dist/migrate/migrator.js +6 -0
  24. package/dist/migrate/schemaGenerator.d.ts +43 -33
  25. package/dist/migrate/schemaGenerator.js +156 -139
  26. package/dist/mongo/mongoDialect.js +22 -8
  27. package/dist/postgres/postgresDialect.js +1 -1
  28. package/dist/querier/abstractQuerier.js +18 -7
  29. package/dist/querier/relationCount.js +9 -7
  30. package/dist/schema/indexDifferences.d.ts +28 -0
  31. package/dist/schema/indexDifferences.js +46 -0
  32. package/dist/schema/schemaASTBuilder.js +26 -32
  33. package/dist/schema/schemaASTDiffer.d.ts +27 -1
  34. package/dist/schema/schemaASTDiffer.js +54 -18
  35. package/dist/schema/types.d.ts +46 -7
  36. package/dist/sqlite/sqliteDialect.d.ts +2 -1
  37. package/dist/sqlite/sqliteDialect.js +4 -1
  38. package/dist/type/dialect.d.ts +6 -0
  39. package/dist/type/migration.d.ts +19 -0
  40. package/dist/util/field.util.d.ts +11 -1
  41. package/dist/util/field.util.js +12 -0
  42. package/dist/util/object.util.js +11 -3
  43. package/dist/util/relationQuery.util.d.ts +10 -0
  44. package/dist/util/relationQuery.util.js +22 -2
  45. package/dist/util/sql.util.d.ts +24 -7
  46. package/dist/util/sql.util.js +75 -10
  47. package/package.json +1 -1
@@ -1,9 +1,11 @@
1
1
  import { AbstractSqlDialect } from '../dialect/index.js';
2
2
  import { getMeta, soleIdOf } from '../entity/index.js';
3
3
  import { areTypesEqual, canonicalToSql, fieldOptionsToCanonical, isVectorCategory, sqlToCanonical, } from '../schema/canonicalType.js';
4
+ import { indexSignature } from '../schema/indexDifferences.js';
4
5
  import { buildSchemaAST } from '../schema/schemaASTBuilder.js';
5
- import { getKeys, isAutoIncrement, qualifyName } from '../util/index.js';
6
- import { derivedCheckName, derivedForeignKeyName } from '../util/sql.util.js';
6
+ import { diffTable } from '../schema/schemaASTDiffer.js';
7
+ import { getKeys, isAutoIncrement, isSoleIdField, qualifyName } from '../util/index.js';
8
+ import { derivedCheckName, derivedForeignKeyName, derivedPrimaryKeyName } from '../util/sql.util.js';
7
9
  import { formatDefaultValue, SqlExpression } from './builder/expressions.js';
8
10
  import { indexDdlFor } from './ddl/index.js';
9
11
  import { fullColumnDefinitionToNode, tableDefinitionToNode } from './generator/definitionToNode.js';
@@ -47,8 +49,8 @@ export class SqlSchemaGenerator {
47
49
  /**
48
50
  * Primary key type for auto-increment integer IDs
49
51
  */
50
- get serialPrimaryKeyType() {
51
- return this.dialect.serialPrimaryKey;
52
+ get serialType() {
53
+ return this.dialect.serialType;
52
54
  }
53
55
  /**
54
56
  * Convert FieldOptions to CanonicalType using the unified type system.
@@ -128,6 +130,11 @@ export class SqlSchemaGenerator {
128
130
  generateAlterTable(diff) {
129
131
  const statements = [];
130
132
  const tableName = this.escapeId(diff.tableName);
133
+ // Before the columns, because a key column being added cannot be part of the old key, and after
134
+ // it is dropped the table is free to take the new one below.
135
+ if (diff.primaryKey?.from.length) {
136
+ statements.push(this.generateDropPrimaryKeySql(diff.tableName, diff.primaryKey.fromName));
137
+ }
131
138
  // Add new columns
132
139
  if (diff.columnsToAdd?.length) {
133
140
  for (const column of diff.columnsToAdd) {
@@ -161,11 +168,21 @@ export class SqlSchemaGenerator {
161
168
  statements.push(this.generateDropIndex(diff.tableName, indexName, diff.schema));
162
169
  }
163
170
  }
171
+ // Last, so every column it names exists by now.
172
+ if (diff.primaryKey?.to.length) {
173
+ statements.push(this.generateAddPrimaryKeySql(diff.tableName, diff.primaryKey.to));
174
+ }
164
175
  return statements;
165
176
  }
166
177
  generateAlterTableDown(diff) {
167
178
  const statements = [];
168
179
  const tableName = this.escapeId(diff.tableName);
180
+ // The key first, mirroring the up direction: a column the up added cannot be dropped below while
181
+ // the new key still names it. Restored under the name the database gave it, which is what the
182
+ // table had before, rather than a derived one that was never on it.
183
+ if (diff.primaryKey?.to.length) {
184
+ statements.push(this.generateDropPrimaryKeySql(diff.tableName, derivedPrimaryKeyName(diff.tableName, diff.primaryKey.to)));
185
+ }
169
186
  // Reverse column additions by dropping them
170
187
  if (diff.columnsToAdd?.length) {
171
188
  for (const column of diff.columnsToAdd) {
@@ -175,7 +192,7 @@ export class SqlSchemaGenerator {
175
192
  // Reverse column alterations by restoring original schema
176
193
  if (diff.columnsToAlter?.length) {
177
194
  for (const { from } of diff.columnsToAlter) {
178
- const colDef = this.generateColumnDefinitionFromSchema(from, { includePrimaryKey: false });
195
+ const colDef = this.generateColumnDefinitionFromSchema(from);
179
196
  const colStatements = this.generateAlterColumnStatements(diff.tableName, from, colDef);
180
197
  statements.push(...colStatements);
181
198
  }
@@ -186,6 +203,9 @@ export class SqlSchemaGenerator {
186
203
  statements.push(this.generateDropIndex(diff.tableName, index.name, diff.schema));
187
204
  }
188
205
  }
206
+ if (diff.primaryKey?.from.length) {
207
+ statements.push(this.generateAddPrimaryKeySql(diff.tableName, diff.primaryKey.from, diff.primaryKey.fromName));
208
+ }
189
209
  if (diff.columnsToDrop?.length || diff.indexesToDrop?.length) {
190
210
  statements.push(`-- TODO: Manual reversal needed for dropped columns/indexes`);
191
211
  }
@@ -205,11 +225,15 @@ export class SqlSchemaGenerator {
205
225
  return `DROP INDEX IF EXISTS ${this.dialect.escapeQualifiedId(indexName, schema)};`;
206
226
  }
207
227
  /**
208
- * Generate a column definition from a {@link ColumnSchema}, whose type is the engine's own spelling
209
- * and may already carry its precision (or even `PRIMARY KEY`, for a serial).
228
+ * A column definition from a {@link ColumnSchema}, whose type is already the engine's own spelling
229
+ * and may carry its own size.
230
+ *
231
+ * Kept apart from {@link generateColumnFromNode} rather than folded into it: a `ColumnSchema` has no
232
+ * `enum`, because introspection reads one back as a `CHECK` constraint and not as a property of the
233
+ * column, so only the node knows enough to emit that clause. Both spell the definition through
234
+ * {@link renderColumn}, which is the part that must not be written twice.
210
235
  */
211
- generateColumnDefinitionFromSchema(column, options = {}) {
212
- const { includePrimaryKey = true, includeUnique = true } = options;
236
+ generateColumnDefinitionFromSchema(column) {
213
237
  let type = column.type;
214
238
  if (!type.includes('(')) {
215
239
  if (column.precision !== undefined) {
@@ -219,17 +243,7 @@ export class SqlSchemaGenerator {
219
243
  type += `(${column.length})`;
220
244
  }
221
245
  }
222
- if (!includePrimaryKey) {
223
- type = type.replace(/\s+PRIMARY\s+KEY/i, '');
224
- }
225
- // `includePrimaryKey: false` suppresses the keyword, not the fact: the column is still the primary
226
- // key, so it must not pick up `NOT NULL` (implied) or `UNIQUE` (redundant) on the way out.
227
- return this.renderColumn({
228
- ...column,
229
- type,
230
- isUnique: column.isUnique && includeUnique,
231
- declaresPrimaryKey: includePrimaryKey && column.isPrimaryKey,
232
- });
246
+ return this.renderColumn({ ...column, type });
233
247
  }
234
248
  /**
235
249
  * The one place a column definition is spelled. Both callers reach it - the `ColumnSchema` path above
@@ -238,9 +252,6 @@ export class SqlSchemaGenerator {
238
252
  */
239
253
  renderColumn(column) {
240
254
  let def = `${this.escapeId(column.name)} ${column.type}`;
241
- if (column.declaresPrimaryKey && !column.type.includes('PRIMARY KEY')) {
242
- def += ' PRIMARY KEY';
243
- }
244
255
  if (!column.nullable && !column.isPrimaryKey) {
245
256
  def += ' NOT NULL';
246
257
  }
@@ -264,7 +275,7 @@ export class SqlSchemaGenerator {
264
275
  ? ''
265
276
  : ` DEFAULT ${formatDefaultValue(column.defaultValue, this.dialect, column.type)}`;
266
277
  }
267
- getSqlType(field, fieldType) {
278
+ getSqlType(field, fieldType, isSoleKey = field.isId === true) {
268
279
  // If field has a reference, inherit type from the target primary key
269
280
  if (field.references) {
270
281
  const refEntity = field.references();
@@ -275,8 +286,8 @@ export class SqlSchemaGenerator {
275
286
  // Get canonical type and convert to SQL
276
287
  const canonical = this.getCanonicalType(field, fieldType);
277
288
  // Special case for serial primary keys
278
- if (isAutoIncrement(field, field.isId === true)) {
279
- return this.dialect.serialPrimaryKey;
289
+ if (isAutoIncrement(field, isSoleKey)) {
290
+ return this.dialect.serialType;
280
291
  }
281
292
  return this.canonicalTypeToSql(canonical);
282
293
  }
@@ -323,80 +334,90 @@ export class SqlSchemaGenerator {
323
334
  /**
324
335
  * Compare an entity with a database table node and return the differences.
325
336
  */
337
+ /**
338
+ * How this entity differs from the table the database reported, as the migrator's `SchemaDiff`.
339
+ *
340
+ * The comparison itself is {@link diffTable}, the same one drift detection runs, so the two can no
341
+ * longer disagree about what has changed. Only two things are this side's own: the entity becomes a
342
+ * table node first, and types are compared as the *engine* would store them - see `normalizeType`.
343
+ */
326
344
  diffSchema(entity, currentTable) {
327
345
  const meta = getMeta(entity);
346
+ const tableName = this.resolveTableName(meta);
347
+ const schema = this.resolveSchema(meta);
328
348
  if (!currentTable) {
329
- return {
330
- tableName: this.resolveTableName(meta),
331
- schema: this.resolveSchema(meta),
332
- type: 'create',
333
- };
334
- }
335
- const columnsToAdd = [];
336
- const columnsToAlter = [];
337
- const columnsToDrop = [];
338
- const currentColumns = new Map(currentTable.columns);
339
- const fieldKeys = getKeys(meta.fields);
340
- for (const key of fieldKeys) {
341
- const field = meta.fields[key];
342
- if (!field || field.virtual)
343
- continue;
344
- const columnName = this.dialect.resolveColumnName(key, field);
345
- const currentColumn = currentColumns.get(columnName);
346
- if (!currentColumn) {
347
- columnsToAdd.push(this.fieldToColumnSchema(key, field, meta));
348
- }
349
- else {
350
- const desiredColumn = this.fieldToColumnSchema(key, field, meta);
351
- const currentColumnSchema = this.columnNodeToSchema(currentColumn);
352
- if (this.columnsNeedAlteration(currentColumnSchema, desiredColumn)) {
353
- columnsToAlter.push({ from: currentColumnSchema, to: desiredColumn });
354
- }
355
- }
356
- currentColumns.delete(columnName);
349
+ return { tableName, schema, type: 'create' };
357
350
  }
358
- for (const [name] of currentColumns) {
359
- columnsToDrop.push(name);
351
+ // Keyed by the qualified name this generator resolves, which is the key the AST it just built
352
+ // stores the table under.
353
+ const desired = buildEntityAST(this, [entity]).getTable(tableName);
354
+ if (!desired) {
355
+ return undefined;
360
356
  }
361
- const indexesToAdd = this.missingIndexes(entity, currentTable);
362
- if (columnsToAdd.length === 0 &&
363
- columnsToAlter.length === 0 &&
364
- columnsToDrop.length === 0 &&
365
- indexesToAdd.length === 0) {
357
+ // Indexes are matched here rather than by the differ, which pairs them by name so that a changed
358
+ // one reads as one index that altered. A migration needs the opposite: an index already in the
359
+ // table, under whatever name, must not be created again, and one whose shape differs is a
360
+ // separate index rather than a change - no engine alters an index's columns or uniqueness.
361
+ const tableDiff = diffTable(desired, currentTable, { ...this.diffOptions(), compareIndexes: false });
362
+ const indexesToAdd = this.missingIndexes(desired, currentTable);
363
+ const columnDiffs = tableDiff?.columnDiffs ?? [];
364
+ const columnsToAdd = columnDiffs.flatMap((it) => (it.type === 'add' ? [this.columnNodeToSchema(it.expected)] : []));
365
+ const columnsToDrop = columnDiffs.flatMap((it) => (it.type === 'drop' ? [it.column] : []));
366
+ const columnsToAlter = columnDiffs.flatMap((it) => it.type === 'alter'
367
+ ? [{ from: this.columnNodeToSchema(it.actual), to: this.columnNodeToSchema(it.expected) }]
368
+ : []);
369
+ const primaryKey = tableDiff?.primaryKeyDiff && {
370
+ from: tableDiff.primaryKeyDiff.actual,
371
+ to: tableDiff.primaryKeyDiff.expected,
372
+ fromName: tableDiff.primaryKeyDiff.actualName,
373
+ };
374
+ if (!columnsToAdd.length &&
375
+ !columnsToAlter.length &&
376
+ !columnsToDrop.length &&
377
+ !indexesToAdd.length &&
378
+ !primaryKey) {
366
379
  return undefined;
367
380
  }
368
381
  return {
369
- tableName: this.resolveTableName(meta),
370
- schema: this.resolveSchema(meta),
382
+ tableName,
383
+ schema,
371
384
  type: 'alter',
372
- columnsToAdd: columnsToAdd.length > 0 ? columnsToAdd : undefined,
373
- columnsToAlter: columnsToAlter.length > 0 ? columnsToAlter : undefined,
374
- columnsToDrop: columnsToDrop.length > 0 ? columnsToDrop : undefined,
375
- indexesToAdd: indexesToAdd.length > 0 ? indexesToAdd : undefined,
385
+ primaryKey,
386
+ columnsToAdd: columnsToAdd.length ? columnsToAdd : undefined,
387
+ columnsToAlter: columnsToAlter.length ? columnsToAlter : undefined,
388
+ columnsToDrop: columnsToDrop.length ? columnsToDrop : undefined,
389
+ indexesToAdd: indexesToAdd.length ? indexesToAdd : undefined,
376
390
  };
377
391
  }
378
392
  /**
379
- * Indexes the entity declares that the table does not have, matched by name and built the same way
380
- * `CREATE TABLE` builds them, so adding an `@Index` to an entity already in the database is picked
381
- * up rather than waiting for the table to be created from scratch somewhere else.
393
+ * What the shared differ needs from a dialect: a type as this engine would actually store it.
382
394
  *
383
- * Only ever additive. An index the entity does not name is left alone: it may well have been
384
- * created deliberately outside the ORM, and dropping it is a decision for a reviewed migration.
395
+ * `boolean` is `TINYINT(1)` on MySQL and `INTEGER` on SQLite, so two canonical types that differ on
396
+ * paper can be one column in the database. Round-tripping through the engine's own spelling is what
397
+ * stops every such column reporting an alteration on every sync.
398
+ */
399
+ /**
400
+ * Indexes the entity declares that the table does not already have, in any shape.
385
401
  *
386
- * A vector index is one of these like any other: MariaDB's `CREATE VECTOR INDEX ... ON t (col)`
387
- * adds one to a table that already exists, which the inline `CREATE TABLE` form it also has cannot.
402
+ * Additive only: an index the entity does not name may well have been created deliberately outside
403
+ * the ORM, and dropping it is a decision for a reviewed migration.
388
404
  */
389
- missingIndexes(entity, currentTable) {
390
- // Keyed by the qualified name, so a table in a schema finds itself rather than reporting that
391
- // the entity declares no indexes at all.
392
- const desired = buildEntityAST(this, [entity]).getTable(qualifyName(currentTable.name, currentTable.schema))?.indexes ?? [];
393
- const present = new Set(currentTable.indexes.map((index) => index.name));
394
- return desired.filter((index) => !present.has(index.name)).map(indexNodeToSchema);
405
+ missingIndexes(desired, currentTable) {
406
+ const present = new Set(currentTable.indexes.map(indexSignature));
407
+ return desired.indexes.filter((index) => !present.has(indexSignature(index))).map(indexNodeToSchema);
408
+ }
409
+ diffOptions() {
410
+ return {
411
+ normalizeType: (type) => sqlToCanonical(this.canonicalTypeToSql(type)),
412
+ defaultsEqual: (expected, actual) => this.isDefaultValueEqual(actual, expected),
413
+ };
395
414
  }
396
415
  columnNodeToSchema(col) {
397
416
  return {
398
417
  name: col.name,
399
- type: this.canonicalTypeToSql(col.type),
418
+ // The same rule `generateColumnFromNode` renders by, so a column added to an existing table
419
+ // gets the type it would have had if the table were created from scratch.
420
+ type: col.isPrimaryKey && col.isAutoIncrement ? this.serialType : this.canonicalTypeToSql(col.type),
400
421
  nullable: col.nullable,
401
422
  defaultValue: col.defaultValue,
402
423
  isPrimaryKey: col.isPrimaryKey,
@@ -405,65 +426,17 @@ export class SqlSchemaGenerator {
405
426
  comment: col.comment,
406
427
  };
407
428
  }
408
- /**
409
- * Convert field options to ColumnSchema. Both sides of a diff are the engine's SQL spelling: what it
410
- * would create for this field, against what it reported for the existing column.
411
- */
412
- fieldToColumnSchema(fieldKey, field, meta) {
413
- const isPrimaryKey = field.isId === true;
414
- return {
415
- name: this.dialect.resolveColumnName(fieldKey, field),
416
- type: this.getSqlType(field, field.type),
417
- nullable: field.nullable ?? !isPrimaryKey,
418
- defaultValue: field.defaultValue,
419
- isPrimaryKey,
420
- isAutoIncrement: isAutoIncrement(field, isPrimaryKey),
421
- isUnique: field.unique ?? false,
422
- length: field.length,
423
- precision: field.precision,
424
- scale: field.scale,
425
- comment: field.comment,
426
- };
427
- }
428
- /**
429
- * Check if two columns differ enough to require alteration
430
- */
431
- columnsNeedAlteration(current, desired) {
432
- if (current.isPrimaryKey && desired.isPrimaryKey) {
433
- return false;
434
- }
435
- if (current.isPrimaryKey !== desired.isPrimaryKey)
436
- return true;
437
- if (current.nullable !== desired.nullable)
438
- return true;
439
- if (current.isUnique !== desired.isUnique)
440
- return true;
441
- if (!this.isTypeEqual(current, desired))
442
- return true;
443
- if (!this.isDefaultValueEqual(current.defaultValue, desired.defaultValue))
444
- return true;
445
- return false;
446
- }
447
- /**
448
- * Whether two column types are the same *as this engine stores them*.
449
- *
450
- * Both sides are SQL spellings, parsed back to canonical so that `INT` and `INTEGER`, or `DATETIME`
451
- * and `TIMESTAMP`, do not read as a change. Do not "simplify" this into comparing the entity's
452
- * canonical type against the column's: several canonical types share one storage type per engine
453
- * (`boolean` is `TINYINT(1)` on MySQL and `INTEGER` on SQLite), so that comparison reports an
454
- * alteration for those columns on every single sync.
455
- */
456
- isTypeEqual(current, desired) {
457
- return areTypesEqual(sqlToCanonical(current.type), sqlToCanonical(desired.type));
458
- }
459
429
  /**
460
430
  * Compare two default values for equality
461
431
  */
462
432
  isDefaultValueEqual(current, desired) {
463
433
  if (current === desired)
464
434
  return true;
465
- if (current === undefined || desired === undefined)
466
- return current === desired;
435
+ // Both spellings of "no default" are the same fact, and engines disagree on which they report:
436
+ // MariaDB says `null` where MySQL says nothing at all. Reading them as different values asked to
437
+ // `MODIFY` every nullable column, on every sync, forever.
438
+ if (current == null || desired == null)
439
+ return current == null && desired == null;
467
440
  const normalize = (value) => {
468
441
  if (value === null)
469
442
  return 'null';
@@ -493,9 +466,16 @@ export class SqlSchemaGenerator {
493
466
  const colDef = this.generateColumnFromNode(indexedVectorColumns.has(col.name) ? { ...col, nullable: false } : col);
494
467
  columns.push(colDef);
495
468
  }
496
- if (table.primaryKey.length > 1) {
497
- const pkCols = table.primaryKey.map((c) => this.escapeId(c.name)).join(', ');
498
- constraints.push(`PRIMARY KEY (${pkCols})`);
469
+ // Every key, of any width, as one named constraint beside the checks and foreign keys - so a
470
+ // later `DROP` has something to name. The exception is a dialect whose serial type states the key
471
+ // itself (SQLite's `INTEGER PRIMARY KEY AUTOINCREMENT`, which cannot be split): there the column
472
+ // has already declared it, and saying it again is a second primary key.
473
+ const declaredByColumn = this.dialect.serialDeclaresPrimaryKey && table.primaryKey.length === 1 && table.primaryKey[0].isAutoIncrement;
474
+ if (table.primaryKey.length && !declaredByColumn) {
475
+ const pkColumns = table.primaryKey.map((c) => c.name);
476
+ const pkName = table.primaryKeyName ?? derivedPrimaryKeyName(table.name, pkColumns);
477
+ const pkCols = pkColumns.map((c) => this.escapeId(c)).join(', ');
478
+ constraints.push(`CONSTRAINT ${this.escapeId(pkName)} PRIMARY KEY (${pkCols})`);
499
479
  }
500
480
  (table.checks ?? []).forEach((check, i) => {
501
481
  const name = check.name ?? derivedCheckName(table.name, i + 1);
@@ -542,8 +522,7 @@ export class SqlSchemaGenerator {
542
522
  generateColumnFromNode(col) {
543
523
  return this.renderColumn({
544
524
  ...col,
545
- type: col.isPrimaryKey && col.isAutoIncrement ? this.serialPrimaryKeyType : this.canonicalTypeToSql(col.type),
546
- declaresPrimaryKey: col.isPrimaryKey && col.table.primaryKey.length === 1,
525
+ type: col.isPrimaryKey && col.isAutoIncrement ? this.serialType : this.canonicalTypeToSql(col.type),
547
526
  });
548
527
  }
549
528
  /**
@@ -594,6 +573,44 @@ export class SqlSchemaGenerator {
594
573
  generateDropForeignKeySql(tableName, constraintName) {
595
574
  return `ALTER TABLE ${this.escapeId(tableName)} ${this.dialect.dropForeignKeySyntax} ${this.escapeId(constraintName)};`;
596
575
  }
576
+ /**
577
+ * `ALTER TABLE ... ADD CONSTRAINT <name> PRIMARY KEY (...)`, the other half of
578
+ * {@link generateDropPrimaryKeySql}. Refused where the engine cannot alter a key at all, by name,
579
+ * rather than emitting DDL it will reject.
580
+ */
581
+ generateAddPrimaryKeySql(tableName, columns, name) {
582
+ this.assertPrimaryKeyAlterable(tableName);
583
+ const constraintName = this.escapeId(name ?? derivedPrimaryKeyName(tableName, columns));
584
+ const pkCols = columns.map((c) => this.escapeId(c)).join(', ');
585
+ return `ALTER TABLE ${this.escapeId(tableName)} ADD CONSTRAINT ${constraintName} PRIMARY KEY (${pkCols});`;
586
+ }
587
+ /**
588
+ * Drops whatever key the table has.
589
+ *
590
+ * `constraintName` has to be what the constraint is *actually* called: the name introspection
591
+ * reported for a key the database already had, or the derived one for a key this generator itself
592
+ * added, which is what reversing a migration drops. Guessing either way names nothing. MySQL takes
593
+ * no name at all - a table's key is always `PRIMARY` there.
594
+ */
595
+ generateDropPrimaryKeySql(tableName, constraintName) {
596
+ this.assertPrimaryKeyAlterable(tableName);
597
+ const table = this.escapeId(tableName);
598
+ if (this.dialect.dropPrimaryKeySyntax === 'DROP PRIMARY KEY') {
599
+ return `ALTER TABLE ${table} DROP PRIMARY KEY;`;
600
+ }
601
+ if (!constraintName) {
602
+ throw new TypeError(`Cannot drop the primary key of "${tableName}": ${this.dialect} names the constraint, and ` +
603
+ 'introspection did not report a name for it.');
604
+ }
605
+ return `ALTER TABLE ${table} DROP CONSTRAINT ${this.escapeId(constraintName)};`;
606
+ }
607
+ assertPrimaryKeyAlterable(tableName) {
608
+ if (this.features.primaryKeyAlter) {
609
+ return;
610
+ }
611
+ throw new TypeError(`${this.dialect}: Cannot change the primary key of "${tableName}" - this database has no ALTER ` +
612
+ 'for it. Recreate the table in a written migration.');
613
+ }
597
614
  }
598
615
  /**
599
616
  * The entities as an AST, named the way `generator` names things.
@@ -4,7 +4,7 @@ import { COUNT_ALIAS, REL_NESTED_KEY, REL_TEMP_PREFIX, sortCountField } from '..
4
4
  import { resolveQueryJoins, resolveSortableJoin } from '../dialect/queryJoins.js';
5
5
  import { assertSoleId, getMeta, soleIdOf } from '../entity/index.js';
6
6
  import { QueryRaw } from '../type/queryRaw.js';
7
- import { asSelectMap, assertAggregateColumns, assertNonNegativeInteger, buildQueryWhereAsMap, entityName, fillOnFields, filterFieldKeys, findVectorIndex, findVectorSort, getKeys, getRelationRequestSummary, hasKeys, isJsonUpdateOp, isOperatorMap, isOperatorObject, isVectorSearch, normalizeScalarFieldSelection, parseGroupMap, parseRelationSize, parseSortByCount, someKey, } from '../util/index.js';
7
+ import { asSelectMap, assertAggregateColumns, assertNonNegativeInteger, buildQueryWhereAsMap, entityName, fillOnFields, filterFieldKeys, findVectorIndex, findVectorSort, getKeys, getRelationRequestSummary, hasKeys, isJsonUpdateOp, isOperatorMap, isOperatorObject, isVectorSearch, normalizeScalarFieldSelection, parentJoins, parseGroupMap, parseRelationSize, parseSortByCount, someKey, targetKeyColumns, } from '../util/index.js';
8
8
  /** Default {@link DialectFeatures} for MongoDB; shared by {@link MongoDialect} and its schema generator. */
9
9
  export const mongoDialectFeatures = {
10
10
  explicitJsonCast: false,
@@ -16,6 +16,7 @@ export const mongoDialectFeatures = {
16
16
  dropTableCascade: false,
17
17
  renameColumn: false,
18
18
  foreignKeyAlter: false,
19
+ primaryKeyAlter: false,
19
20
  columnComment: false,
20
21
  vectorIndexRequiresNotNull: false,
21
22
  vectorSupportsLength: false,
@@ -168,7 +169,7 @@ export class MongoDialect extends AbstractDialect {
168
169
  */
169
170
  relationLookup(meta, relOpts, relMeta, relEntity, targetScope, temp, tail, opts) {
170
171
  return relOpts.cardinality === 'mm' && relOpts.through
171
- ? this.junctionLookup(relOpts, relMeta, relEntity, targetScope, temp, tail, opts)
172
+ ? this.junctionLookup(meta, relOpts, relMeta, targetScope, temp, tail, opts)
172
173
  : {
173
174
  $lookup: {
174
175
  from: this.resolveTableName(relMeta),
@@ -182,22 +183,29 @@ export class MongoDialect extends AbstractDialect {
182
183
  * ManyToMany counts/tests junction rows, so the target is reached from inside the junction's own
183
184
  * lookup - the junction's filters apply too, since a soft-deleted link is not a link.
184
185
  */
185
- junctionLookup(relOpts, relMeta, relEntity, targetScope, temp, tail, opts) {
186
+ junctionLookup(meta, relOpts, relMeta, targetScope, temp, tail, opts) {
186
187
  const throughEntity = relOpts.through();
187
188
  const throughMeta = getMeta(throughEntity);
188
189
  const junctionScope = this.renderFilter(throughEntity, this.scopedWhereMap(throughMeta, {}), opts);
189
190
  const nested = REL_NESTED_KEY;
191
+ // Both ends are one column here - each `$lookup` matches one field against `_id` - so both sides
192
+ // must be sole-keyed. Sliced rather than indexed positionally: `references[1]` is the parent's
193
+ // *second* column on a composite, a real column of the wrong side.
194
+ assertSoleId(meta, 'MongoDB');
195
+ assertSoleId(relMeta, 'MongoDB');
196
+ const [parentJoin] = parentJoins(relOpts, meta.ids.length);
197
+ const [targetColumn] = targetKeyColumns(relOpts, meta.ids.length);
190
198
  return {
191
199
  $lookup: {
192
200
  from: this.resolveTableName(throughMeta),
193
201
  localField: MongoDialect.ID_KEY,
194
- foreignField: this.columnOf(throughMeta, relOpts.references[0].local),
202
+ foreignField: this.columnOf(throughMeta, parentJoin.joined),
195
203
  pipeline: [
196
204
  ...(hasKeys(junctionScope) ? [{ $match: junctionScope }] : []),
197
205
  {
198
206
  $lookup: {
199
207
  from: this.resolveTableName(relMeta),
200
- localField: this.columnOf(throughMeta, relOpts.references[1].local),
208
+ localField: this.columnOf(throughMeta, targetColumn),
201
209
  foreignField: MongoDialect.ID_KEY,
202
210
  pipeline: [...(hasKeys(targetScope) ? [{ $match: targetScope }] : []), { $limit: 1 }],
203
211
  as: nested,
@@ -659,9 +667,15 @@ export class MongoDialect extends AbstractDialect {
659
667
  * direction joins the parent's `_id` to the target's FK.
660
668
  */
661
669
  joinKeys(meta, relMeta, relOpts) {
662
- return relOpts.cardinality === 'm1'
663
- ? { localField: this.columnOf(meta, relOpts.references[0].local), foreignField: MongoDialect.ID_KEY }
664
- : { localField: MongoDialect.ID_KEY, foreignField: this.columnOf(relMeta, relOpts.references[0].foreign) };
670
+ if (relOpts.cardinality === 'm1') {
671
+ // The target's side, not this entity's: a lookup matches one `localField` against one
672
+ // `foreignField`, so a composite target would join on its first column alone and gather the
673
+ // rows of every key that agrees on it. The other branch is refused by `columnOf` below, whose
674
+ // key *is* an id column; this one names a plain foreign key, so it has to say so itself.
675
+ assertSoleId(relMeta, 'MongoDB');
676
+ return { localField: this.columnOf(meta, relOpts.references[0].local), foreignField: MongoDialect.ID_KEY };
677
+ }
678
+ return { localField: MongoDialect.ID_KEY, foreignField: this.columnOf(relMeta, relOpts.references[0].foreign) };
665
679
  }
666
680
  /** `[column, key]` for the fields whose stored name differs from their property name, memoized per entity. */
667
681
  renamedColumns(meta) {
@@ -15,7 +15,7 @@ export class PostgresDialect extends PgLikeSqlDialect {
15
15
  hasNarrowVectorTypes = true;
16
16
  upsert(ctx, entity, conflictPaths, payload) {
17
17
  // The xmax system column is 0 for a newly inserted row and non-zero for an updated one (MVCC).
18
- super.upsert(ctx, entity, conflictPaths, payload, `, (xmax = 0) AS ${this.escapeId('_created')}`);
18
+ super.upsert(ctx, entity, conflictPaths, payload, `(xmax = 0) AS ${this.escapeId('_created')}`);
19
19
  }
20
20
  /**
21
21
  * `to_regclass` rather than a `::regclass` cast: it answers `NULL` for a table that does not exist
@@ -1,5 +1,5 @@
1
- import { getMeta, idOf, soleIdOf } from '../entity/index.js';
2
- import { asSelectMap, augmentWhere, childrenOf, clone, filterPersistableRelationKeys, forEachRequestedRelation, getKeys, getRelationRequestSummary, idOnlyQuery, isScalarId, LoggerWrapper, parentJoins, parentsIn, parseRelationAtKey, parseRelationQueryValue, rowKey, runHooks, someKey, targetKeyColumns, withoutSoftDeleteFilter, } from '../util/index.js';
1
+ import { assertSoleId, getMeta, idOf, soleIdOf } from '../entity/index.js';
2
+ import { asSelectMap, augmentWhere, childrenOf, clone, filterPersistableRelationKeys, forEachRequestedRelation, getKeys, getRelationRequestSummary, idOnlyQuery, isScalarId, joinedColumns, joinedRowKey, LoggerWrapper, parentJoins, parentRowKey, parentsIn, parseRelationAtKey, parseRelationQueryValue, runHooks, someKey, targetKeyColumns, withoutSoftDeleteFilter, } from '../util/index.js';
3
3
  import { enrichError } from './queryError.js';
4
4
  import { fillRelationCounts, withIdForCounts } from './relationCount.js';
5
5
  /**
@@ -306,7 +306,7 @@ export class AbstractQuerier {
306
306
  const { $select: _select, $exclude: _exclude, $where: _where, ...throughQuery } = relationQuery;
307
307
  const throughFounds = await this.findMany(throughEntity, {
308
308
  ...throughQuery,
309
- $select: Object.fromEntries(joins.map(({ joined }) => [joined, true])),
309
+ $select: joinedColumns(joins),
310
310
  $populate: {
311
311
  [targetRelKey]: {
312
312
  ...relationQuery,
@@ -315,6 +315,8 @@ export class AbstractQuerier {
315
315
  },
316
316
  $where: parentsIn(joins, payload),
317
317
  });
318
+ // The junction's own columns carried onto the target's row, which is where `putChildrenInParents`
319
+ // reads them back from - a junction row holds the parent's key under `joined`, not under `parent`.
318
320
  const founds = throughFounds.map((it) => ({
319
321
  ...it[targetRelKey],
320
322
  ...Object.fromEntries(joins.map(({ joined }) => [joined, it[joined]])),
@@ -343,15 +345,13 @@ export class AbstractQuerier {
343
345
  for (const child of children) {
344
346
  // Every joined column, so two children agreeing on one column of a composite key are not
345
347
  // gathered under the same parent.
346
- const parentId = rowKey(joins.map(({ joined }) => child[joined]));
347
- (childrenByParentId[parentId] ??= []).push(child);
348
+ (childrenByParentId[joinedRowKey(joins, child)] ??= []).push(child);
348
349
  }
349
350
  for (const parent of parents) {
350
351
  // `[]` rather than nothing for a parent with no children: a populated to-many is a list the
351
352
  // caller asked for, so it maps and counts without a guard, and its type can say so. An
352
353
  // unpopulated one stays absent, which is what tells the two apart.
353
- const key = rowKey(joins.map(({ parent: parentKey }) => parent[parentKey]));
354
- parent[relKey] = (childrenByParentId[key] ?? []);
354
+ parent[relKey] = (childrenByParentId[parentRowKey(joins, parent)] ?? []);
355
355
  }
356
356
  }
357
357
  async insertRelations(entity, payload) {
@@ -411,6 +411,11 @@ export class AbstractQuerier {
411
411
  const relOpts = meta.relations[relKey];
412
412
  if (!relOpts)
413
413
  return;
414
+ // Here rather than only in the callers below: writing the parent's key into a child is one column
415
+ // per key, so a composite takes a statement per parent. `soleParentColumn` and the sole
416
+ // `targetKeyColumns` under it read the *first* pair, which is a real column of a wrong pairing
417
+ // unless this has run - and this method is `protected`, so a caller can arrive without them.
418
+ assertSoleId(meta, 'saving a relation');
414
419
  const relEntity = relOpts.entity();
415
420
  const relPayload = relValue;
416
421
  switch (relOpts.cardinality) {
@@ -438,6 +443,12 @@ export class AbstractQuerier {
438
443
  // would link every parent to a single shared row instead.
439
444
  for (const id of ids) {
440
445
  const savedIds = await this.saveMany(relEntity, relPayload);
446
+ // A link needs the target's id, and a driver that cannot report one (a MySQL batch mixing
447
+ // supplied and generated keys) would otherwise write a row pointing at `undefined`.
448
+ if (savedIds.some((relId) => relId === undefined)) {
449
+ throw new TypeError(`'${relEntity.name}' rows saved through '${throughEntity.name}' reported no id, so they cannot be linked. ` +
450
+ 'Insert them with their own ids, or save the relation in its own statement.');
451
+ }
441
452
  await this.insertMany(throughEntity, savedIds.map((relId) => ({ [localField]: id, [targetColumn]: relId })));
442
453
  }
443
454
  }
@@ -1,7 +1,7 @@
1
1
  import { COUNT_ALIAS } from '../dialect/aliases.js';
2
2
  import { getMeta, soleIdOf } from '../entity/index.js';
3
3
  import { COUNT_RESULT_KEY } from '../type/index.js';
4
- import { asSelectMap, getKeys, parentJoins, parentsIn, rowKey, targetKeyColumns, } from '../util/index.js';
4
+ import { asSelectMap, getKeys, joinedColumns, joinedRowKey, parentJoins, parentRowKey, parentsIn, targetKeyColumns, } from '../util/index.js';
5
5
  /**
6
6
  * A `$count` groups its tallies by the parent's id, so the id has to outlive the projection - the
7
7
  * same reason populating a relation keeps it. A whitelisting `$select` gains the key and an
@@ -50,6 +50,9 @@ export async function fillRelationCounts(querier, entity, payload, count) {
50
50
  return;
51
51
  }
52
52
  const meta = getMeta(entity);
53
+ // The tallies come back keyed by the columns *this relation* joins from, so its `joins` are kept
54
+ // beside them: reading the parent through `meta.ids` instead matches only where the two coincide,
55
+ // which is a to-many and nothing else.
53
56
  const counted = new Map();
54
57
  for (const relKey of getKeys(count)) {
55
58
  const value = count[relKey];
@@ -59,15 +62,14 @@ export async function fillRelationCounts(querier, entity, payload, count) {
59
62
  }
60
63
  const where = typeof value === 'object' ? value.$where : undefined;
61
64
  const joins = parentJoins(relOpts, meta.ids.length);
62
- counted.set(relKey, await countPerParent(querier, relOpts, joins, payload, where));
65
+ counted.set(relKey, { joins, byParent: await countPerParent(querier, relOpts, joins, payload, where) });
63
66
  }
64
67
  for (const parent of payload) {
65
- const id = rowKey(meta.ids.map((key) => parent[key]));
66
68
  const row = {};
67
- for (const [relKey, byParent] of counted) {
69
+ for (const [relKey, { joins, byParent }] of counted) {
68
70
  // A parent the grouped result has no row for matched nothing, which is a zero rather than a
69
71
  // gap: `_count` names what the caller asked to count, so every key it asked for is present.
70
- row[relKey] = byParent[id] ?? 0;
72
+ row[relKey] = byParent[parentRowKey(joins, parent)] ?? 0;
71
73
  }
72
74
  parent[COUNT_RESULT_KEY] = row;
73
75
  }
@@ -101,13 +103,13 @@ async function countThroughPerParent(querier, relOpts, throughEntity, joins, par
101
103
  /** `SELECT <keys>, COUNT(*) ... GROUP BY <keys>`, as a lookup from parent key to tally. */
102
104
  async function groupedCount(querier, entity, joins, where) {
103
105
  const $agg = { [COUNT_ALIAS]: { $count: '*' } };
104
- const $group = Object.fromEntries(joins.map(({ joined }) => [joined, true]));
106
+ const $group = joinedColumns(joins);
105
107
  const rows = await querier.aggregate(entity, { $group, $agg, $where: where });
106
108
  const byParent = {};
107
109
  for (const row of rows) {
108
110
  // Keyed by every joined column, which is how a tally finds the one parent whose whole key it
109
111
  // matches - and how the rows an over-selecting `IN` brought back find no parent at all.
110
- byParent[rowKey(joins.map(({ joined }) => row[joined]))] = Number(row[COUNT_ALIAS]);
112
+ byParent[joinedRowKey(joins, row)] = Number(row[COUNT_ALIAS]);
111
113
  }
112
114
  return byParent;
113
115
  }