uql-orm 0.42.0 → 0.43.0

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 (61) 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 +5 -5
  5. package/dist/dialect/abstractSqlDialect.d.ts +41 -14
  6. package/dist/dialect/abstractSqlDialect.js +75 -44
  7. package/dist/dialect/jsonSql.d.ts +3 -2
  8. package/dist/dialect/jsonSql.js +7 -5
  9. package/dist/dialect/mysqlLikeSqlDialect.d.ts +2 -1
  10. package/dist/dialect/mysqlLikeSqlDialect.js +3 -1
  11. package/dist/dialect/pgLikeSqlDialect.d.ts +1 -1
  12. package/dist/dialect/pgLikeSqlDialect.js +2 -1
  13. package/dist/dialect/vectorCast.d.ts +0 -6
  14. package/dist/dialect/vectorCast.js +0 -8
  15. package/dist/entity/decorator/entity.d.ts +1 -1
  16. package/dist/entity/decorator/entity.js +1 -1
  17. package/dist/entity/decorator/members.d.ts +5 -2
  18. package/dist/entity/metadata/definition.js +29 -12
  19. package/dist/maria/mariaDialect.js +4 -3
  20. package/dist/migrate/builder/tableBuilder.js +5 -4
  21. package/dist/migrate/drift/driftDetector.js +21 -1
  22. package/dist/migrate/generator/mongoSchemaGenerator.d.ts +8 -1
  23. package/dist/migrate/generator/mongoSchemaGenerator.js +25 -29
  24. package/dist/migrate/introspection/abstractSqlSchemaIntrospector.d.ts +9 -1
  25. package/dist/migrate/introspection/abstractSqlSchemaIntrospector.js +11 -2
  26. package/dist/migrate/introspection/baseSqlIntrospector.js +6 -3
  27. package/dist/migrate/introspection/postgresIntrospector.js +1 -1
  28. package/dist/migrate/introspection/sqliteIntrospector.js +7 -3
  29. package/dist/migrate/migrator.js +6 -0
  30. package/dist/migrate/schemaGenerator.d.ts +43 -37
  31. package/dist/migrate/schemaGenerator.js +163 -150
  32. package/dist/mongo/mongoDialect.js +22 -8
  33. package/dist/postgres/postgresDialect.js +1 -1
  34. package/dist/querier/abstractQuerier.js +18 -7
  35. package/dist/querier/relationCount.js +9 -7
  36. package/dist/schema/canonicalType.d.ts +19 -4
  37. package/dist/schema/canonicalType.js +114 -164
  38. package/dist/schema/indexDifferences.d.ts +28 -0
  39. package/dist/schema/indexDifferences.js +46 -0
  40. package/dist/schema/schemaASTBuilder.js +27 -33
  41. package/dist/schema/schemaASTDiffer.d.ts +27 -1
  42. package/dist/schema/schemaASTDiffer.js +59 -19
  43. package/dist/schema/types.d.ts +46 -7
  44. package/dist/sqlite/sqliteDialect.d.ts +2 -1
  45. package/dist/sqlite/sqliteDialect.js +4 -1
  46. package/dist/type/dialect.d.ts +6 -0
  47. package/dist/type/entity.d.ts +23 -6
  48. package/dist/type/migration.d.ts +20 -1
  49. package/dist/type/query.d.ts +2 -6
  50. package/dist/util/field.util.d.ts +28 -7
  51. package/dist/util/field.util.js +56 -48
  52. package/dist/util/fieldOption.util.d.ts +79 -0
  53. package/dist/util/fieldOption.util.js +84 -0
  54. package/dist/util/index.d.ts +1 -0
  55. package/dist/util/index.js +1 -0
  56. package/dist/util/object.util.js +11 -3
  57. package/dist/util/relationQuery.util.d.ts +10 -0
  58. package/dist/util/relationQuery.util.js +22 -2
  59. package/dist/util/sql.util.d.ts +28 -7
  60. package/dist/util/sql.util.js +79 -10
  61. package/package.json +2 -2
@@ -1,9 +1,11 @@
1
1
  import { AbstractSqlDialect } from '../dialect/index.js';
2
2
  import { getMeta, soleIdOf } from '../entity/index.js';
3
- import { areTypesEqual, canonicalToSql, fieldOptionsToCanonical, isVectorCategory, sqlToCanonical, } from '../schema/canonicalType.js';
3
+ import { areTypesEqual, canonicalToSql, engineType, fieldOptionsToCanonical, isVectorCategory, } 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,14 +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
- }
53
- /**
54
- * Convert FieldOptions to CanonicalType using the unified type system.
55
- */
56
- getCanonicalType(field, fieldType) {
57
- return fieldOptionsToCanonical(field, fieldType);
52
+ get serialType() {
53
+ return this.dialect.serialType;
58
54
  }
59
55
  canonicalTypeToSql(type) {
60
56
  return canonicalToSql(type, this.dialect);
@@ -128,6 +124,11 @@ export class SqlSchemaGenerator {
128
124
  generateAlterTable(diff) {
129
125
  const statements = [];
130
126
  const tableName = this.escapeId(diff.tableName);
127
+ // Before the columns, because a key column being added cannot be part of the old key, and after
128
+ // it is dropped the table is free to take the new one below.
129
+ if (diff.primaryKey?.from.length) {
130
+ statements.push(this.generateDropPrimaryKeySql(diff.tableName, diff.primaryKey.fromName));
131
+ }
131
132
  // Add new columns
132
133
  if (diff.columnsToAdd?.length) {
133
134
  for (const column of diff.columnsToAdd) {
@@ -161,11 +162,21 @@ export class SqlSchemaGenerator {
161
162
  statements.push(this.generateDropIndex(diff.tableName, indexName, diff.schema));
162
163
  }
163
164
  }
165
+ // Last, so every column it names exists by now.
166
+ if (diff.primaryKey?.to.length) {
167
+ statements.push(this.generateAddPrimaryKeySql(diff.tableName, diff.primaryKey.to));
168
+ }
164
169
  return statements;
165
170
  }
166
171
  generateAlterTableDown(diff) {
167
172
  const statements = [];
168
173
  const tableName = this.escapeId(diff.tableName);
174
+ // The key first, mirroring the up direction: a column the up added cannot be dropped below while
175
+ // the new key still names it. Restored under the name the database gave it, which is what the
176
+ // table had before, rather than a derived one that was never on it.
177
+ if (diff.primaryKey?.to.length) {
178
+ statements.push(this.generateDropPrimaryKeySql(diff.tableName, derivedPrimaryKeyName(diff.tableName, diff.primaryKey.to)));
179
+ }
169
180
  // Reverse column additions by dropping them
170
181
  if (diff.columnsToAdd?.length) {
171
182
  for (const column of diff.columnsToAdd) {
@@ -175,7 +186,7 @@ export class SqlSchemaGenerator {
175
186
  // Reverse column alterations by restoring original schema
176
187
  if (diff.columnsToAlter?.length) {
177
188
  for (const { from } of diff.columnsToAlter) {
178
- const colDef = this.generateColumnDefinitionFromSchema(from, { includePrimaryKey: false });
189
+ const colDef = this.generateColumnDefinitionFromSchema(from);
179
190
  const colStatements = this.generateAlterColumnStatements(diff.tableName, from, colDef);
180
191
  statements.push(...colStatements);
181
192
  }
@@ -186,6 +197,9 @@ export class SqlSchemaGenerator {
186
197
  statements.push(this.generateDropIndex(diff.tableName, index.name, diff.schema));
187
198
  }
188
199
  }
200
+ if (diff.primaryKey?.from.length) {
201
+ statements.push(this.generateAddPrimaryKeySql(diff.tableName, diff.primaryKey.from, diff.primaryKey.fromName));
202
+ }
189
203
  if (diff.columnsToDrop?.length || diff.indexesToDrop?.length) {
190
204
  statements.push(`-- TODO: Manual reversal needed for dropped columns/indexes`);
191
205
  }
@@ -205,11 +219,15 @@ export class SqlSchemaGenerator {
205
219
  return `DROP INDEX IF EXISTS ${this.dialect.escapeQualifiedId(indexName, schema)};`;
206
220
  }
207
221
  /**
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).
222
+ * A column definition from a {@link ColumnSchema}, whose type is already the engine's own spelling
223
+ * and may carry its own size.
224
+ *
225
+ * Kept apart from {@link generateColumnFromNode} rather than folded into it: a `ColumnSchema` has no
226
+ * `enum`, because introspection reads one back as a `CHECK` constraint and not as a property of the
227
+ * column, so only the node knows enough to emit that clause. Both spell the definition through
228
+ * {@link renderColumn}, which is the part that must not be written twice.
210
229
  */
211
- generateColumnDefinitionFromSchema(column, options = {}) {
212
- const { includePrimaryKey = true, includeUnique = true } = options;
230
+ generateColumnDefinitionFromSchema(column) {
213
231
  let type = column.type;
214
232
  if (!type.includes('(')) {
215
233
  if (column.precision !== undefined) {
@@ -219,17 +237,7 @@ export class SqlSchemaGenerator {
219
237
  type += `(${column.length})`;
220
238
  }
221
239
  }
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
- });
240
+ return this.renderColumn({ ...column, type });
233
241
  }
234
242
  /**
235
243
  * The one place a column definition is spelled. Both callers reach it - the `ColumnSchema` path above
@@ -238,9 +246,6 @@ export class SqlSchemaGenerator {
238
246
  */
239
247
  renderColumn(column) {
240
248
  let def = `${this.escapeId(column.name)} ${column.type}`;
241
- if (column.declaresPrimaryKey && !column.type.includes('PRIMARY KEY')) {
242
- def += ' PRIMARY KEY';
243
- }
244
249
  if (!column.nullable && !column.isPrimaryKey) {
245
250
  def += ' NOT NULL';
246
251
  }
@@ -264,19 +269,21 @@ export class SqlSchemaGenerator {
264
269
  ? ''
265
270
  : ` DEFAULT ${formatDefaultValue(column.defaultValue, this.dialect, column.type)}`;
266
271
  }
267
- getSqlType(field, fieldType) {
268
- // If field has a reference, inherit type from the target primary key
272
+ getSqlType(field) {
273
+ // A foreign key takes the type of the key it points at. A `referencedKey` the target does not
274
+ // have falls through to this column's own options, as the AST builder does with the same case.
269
275
  if (field.references) {
270
- const refEntity = field.references();
271
- const refMeta = getMeta(refEntity);
276
+ const refMeta = getMeta(field.references());
272
277
  const refIdField = refMeta.fields[field.referencedKey ?? soleIdOf(refMeta, 'a foreign key')];
273
- return this.getSqlType({ ...refIdField, references: undefined, isId: undefined, autoIncrement: false }, refIdField.type);
278
+ if (refIdField) {
279
+ return this.getSqlType({ ...refIdField, references: undefined, isId: undefined, autoIncrement: false });
280
+ }
274
281
  }
275
282
  // Get canonical type and convert to SQL
276
- const canonical = this.getCanonicalType(field, fieldType);
283
+ const canonical = fieldOptionsToCanonical(field);
277
284
  // Special case for serial primary keys
278
285
  if (isAutoIncrement(field, field.isId === true)) {
279
- return this.dialect.serialPrimaryKey;
286
+ return this.dialect.serialType;
280
287
  }
281
288
  return this.canonicalTypeToSql(canonical);
282
289
  }
@@ -323,80 +330,90 @@ export class SqlSchemaGenerator {
323
330
  /**
324
331
  * Compare an entity with a database table node and return the differences.
325
332
  */
333
+ /**
334
+ * How this entity differs from the table the database reported, as the migrator's `SchemaDiff`.
335
+ *
336
+ * The comparison itself is {@link diffTable}, the same one drift detection runs, so the two can no
337
+ * longer disagree about what has changed. Only two things are this side's own: the entity becomes a
338
+ * table node first, and types are compared as the *engine* would store them - see `normalizeType`.
339
+ */
326
340
  diffSchema(entity, currentTable) {
327
341
  const meta = getMeta(entity);
342
+ const tableName = this.resolveTableName(meta);
343
+ const schema = this.resolveSchema(meta);
328
344
  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);
345
+ return { tableName, schema, type: 'create' };
357
346
  }
358
- for (const [name] of currentColumns) {
359
- columnsToDrop.push(name);
347
+ // Keyed by the qualified name this generator resolves, which is the key the AST it just built
348
+ // stores the table under.
349
+ const desired = buildEntityAST(this, [entity]).getTable(tableName);
350
+ if (!desired) {
351
+ return undefined;
360
352
  }
361
- const indexesToAdd = this.missingIndexes(entity, currentTable);
362
- if (columnsToAdd.length === 0 &&
363
- columnsToAlter.length === 0 &&
364
- columnsToDrop.length === 0 &&
365
- indexesToAdd.length === 0) {
353
+ // Indexes are matched here rather than by the differ, which pairs them by name so that a changed
354
+ // one reads as one index that altered. A migration needs the opposite: an index already in the
355
+ // table, under whatever name, must not be created again, and one whose shape differs is a
356
+ // separate index rather than a change - no engine alters an index's columns or uniqueness.
357
+ const tableDiff = diffTable(desired, currentTable, { ...this.diffOptions(), compareIndexes: false });
358
+ const indexesToAdd = this.missingIndexes(desired, currentTable);
359
+ const columnDiffs = tableDiff?.columnDiffs ?? [];
360
+ const columnsToAdd = columnDiffs.flatMap((it) => (it.type === 'add' ? [this.columnNodeToSchema(it.expected)] : []));
361
+ const columnsToDrop = columnDiffs.flatMap((it) => (it.type === 'drop' ? [it.column] : []));
362
+ const columnsToAlter = columnDiffs.flatMap((it) => it.type === 'alter'
363
+ ? [{ from: this.columnNodeToSchema(it.actual), to: this.columnNodeToSchema(it.expected) }]
364
+ : []);
365
+ const primaryKey = tableDiff?.primaryKeyDiff && {
366
+ from: tableDiff.primaryKeyDiff.actual,
367
+ to: tableDiff.primaryKeyDiff.expected,
368
+ fromName: tableDiff.primaryKeyDiff.actualName,
369
+ };
370
+ if (!columnsToAdd.length &&
371
+ !columnsToAlter.length &&
372
+ !columnsToDrop.length &&
373
+ !indexesToAdd.length &&
374
+ !primaryKey) {
366
375
  return undefined;
367
376
  }
368
377
  return {
369
- tableName: this.resolveTableName(meta),
370
- schema: this.resolveSchema(meta),
378
+ tableName,
379
+ schema,
371
380
  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,
381
+ primaryKey,
382
+ columnsToAdd: columnsToAdd.length ? columnsToAdd : undefined,
383
+ columnsToAlter: columnsToAlter.length ? columnsToAlter : undefined,
384
+ columnsToDrop: columnsToDrop.length ? columnsToDrop : undefined,
385
+ indexesToAdd: indexesToAdd.length ? indexesToAdd : undefined,
376
386
  };
377
387
  }
378
388
  /**
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.
389
+ * What the shared differ needs from a dialect: a type as this engine would actually store it.
382
390
  *
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.
391
+ * `boolean` is `TINYINT(1)` on MySQL and `INTEGER` on SQLite, so two canonical types that differ on
392
+ * paper can be one column in the database. Round-tripping through the engine's own spelling is what
393
+ * stops every such column reporting an alteration on every sync.
394
+ */
395
+ /**
396
+ * Indexes the entity declares that the table does not already have, in any shape.
385
397
  *
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.
398
+ * Additive only: an index the entity does not name may well have been created deliberately outside
399
+ * the ORM, and dropping it is a decision for a reviewed migration.
388
400
  */
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);
401
+ missingIndexes(desired, currentTable) {
402
+ const present = new Set(currentTable.indexes.map(indexSignature));
403
+ return desired.indexes.filter((index) => !present.has(indexSignature(index))).map(indexNodeToSchema);
404
+ }
405
+ diffOptions() {
406
+ return {
407
+ normalizeType: engineType(this.dialect),
408
+ defaultsEqual: (expected, actual) => this.isDefaultValueEqual(actual, expected),
409
+ };
395
410
  }
396
411
  columnNodeToSchema(col) {
397
412
  return {
398
413
  name: col.name,
399
- type: this.canonicalTypeToSql(col.type),
414
+ // The same rule `generateColumnFromNode` renders by, so a column added to an existing table
415
+ // gets the type it would have had if the table were created from scratch.
416
+ type: col.isPrimaryKey && col.isAutoIncrement ? this.serialType : this.canonicalTypeToSql(col.type),
400
417
  nullable: col.nullable,
401
418
  defaultValue: col.defaultValue,
402
419
  isPrimaryKey: col.isPrimaryKey,
@@ -405,65 +422,17 @@ export class SqlSchemaGenerator {
405
422
  comment: col.comment,
406
423
  };
407
424
  }
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
425
  /**
460
426
  * Compare two default values for equality
461
427
  */
462
428
  isDefaultValueEqual(current, desired) {
463
429
  if (current === desired)
464
430
  return true;
465
- if (current === undefined || desired === undefined)
466
- return current === desired;
431
+ // Both spellings of "no default" are the same fact, and engines disagree on which they report:
432
+ // MariaDB says `null` where MySQL says nothing at all. Reading them as different values asked to
433
+ // `MODIFY` every nullable column, on every sync, forever.
434
+ if (current == null || desired == null)
435
+ return current == null && desired == null;
467
436
  const normalize = (value) => {
468
437
  if (value === null)
469
438
  return 'null';
@@ -493,9 +462,16 @@ export class SqlSchemaGenerator {
493
462
  const colDef = this.generateColumnFromNode(indexedVectorColumns.has(col.name) ? { ...col, nullable: false } : col);
494
463
  columns.push(colDef);
495
464
  }
496
- if (table.primaryKey.length > 1) {
497
- const pkCols = table.primaryKey.map((c) => this.escapeId(c.name)).join(', ');
498
- constraints.push(`PRIMARY KEY (${pkCols})`);
465
+ // Every key, of any width, as one named constraint beside the checks and foreign keys - so a
466
+ // later `DROP` has something to name. The exception is a dialect whose serial type states the key
467
+ // itself (SQLite's `INTEGER PRIMARY KEY AUTOINCREMENT`, which cannot be split): there the column
468
+ // has already declared it, and saying it again is a second primary key.
469
+ const declaredByColumn = this.dialect.serialDeclaresPrimaryKey && table.primaryKey.length === 1 && table.primaryKey[0].isAutoIncrement;
470
+ if (table.primaryKey.length && !declaredByColumn) {
471
+ const pkColumns = table.primaryKey.map((c) => c.name);
472
+ const pkName = table.primaryKeyName ?? derivedPrimaryKeyName(table.name, pkColumns);
473
+ const pkCols = pkColumns.map((c) => this.escapeId(c)).join(', ');
474
+ constraints.push(`CONSTRAINT ${this.escapeId(pkName)} PRIMARY KEY (${pkCols})`);
499
475
  }
500
476
  (table.checks ?? []).forEach((check, i) => {
501
477
  const name = check.name ?? derivedCheckName(table.name, i + 1);
@@ -542,8 +518,7 @@ export class SqlSchemaGenerator {
542
518
  generateColumnFromNode(col) {
543
519
  return this.renderColumn({
544
520
  ...col,
545
- type: col.isPrimaryKey && col.isAutoIncrement ? this.serialPrimaryKeyType : this.canonicalTypeToSql(col.type),
546
- declaresPrimaryKey: col.isPrimaryKey && col.table.primaryKey.length === 1,
521
+ type: col.isPrimaryKey && col.isAutoIncrement ? this.serialType : this.canonicalTypeToSql(col.type),
547
522
  });
548
523
  }
549
524
  /**
@@ -594,6 +569,44 @@ export class SqlSchemaGenerator {
594
569
  generateDropForeignKeySql(tableName, constraintName) {
595
570
  return `ALTER TABLE ${this.escapeId(tableName)} ${this.dialect.dropForeignKeySyntax} ${this.escapeId(constraintName)};`;
596
571
  }
572
+ /**
573
+ * `ALTER TABLE ... ADD CONSTRAINT <name> PRIMARY KEY (...)`, the other half of
574
+ * {@link generateDropPrimaryKeySql}. Refused where the engine cannot alter a key at all, by name,
575
+ * rather than emitting DDL it will reject.
576
+ */
577
+ generateAddPrimaryKeySql(tableName, columns, name) {
578
+ this.assertPrimaryKeyAlterable(tableName);
579
+ const constraintName = this.escapeId(name ?? derivedPrimaryKeyName(tableName, columns));
580
+ const pkCols = columns.map((c) => this.escapeId(c)).join(', ');
581
+ return `ALTER TABLE ${this.escapeId(tableName)} ADD CONSTRAINT ${constraintName} PRIMARY KEY (${pkCols});`;
582
+ }
583
+ /**
584
+ * Drops whatever key the table has.
585
+ *
586
+ * `constraintName` has to be what the constraint is *actually* called: the name introspection
587
+ * reported for a key the database already had, or the derived one for a key this generator itself
588
+ * added, which is what reversing a migration drops. Guessing either way names nothing. MySQL takes
589
+ * no name at all - a table's key is always `PRIMARY` there.
590
+ */
591
+ generateDropPrimaryKeySql(tableName, constraintName) {
592
+ this.assertPrimaryKeyAlterable(tableName);
593
+ const table = this.escapeId(tableName);
594
+ if (this.dialect.dropPrimaryKeySyntax === 'DROP PRIMARY KEY') {
595
+ return `ALTER TABLE ${table} DROP PRIMARY KEY;`;
596
+ }
597
+ if (!constraintName) {
598
+ throw new TypeError(`Cannot drop the primary key of "${tableName}": ${this.dialect} names the constraint, and ` +
599
+ 'introspection did not report a name for it.');
600
+ }
601
+ return `ALTER TABLE ${table} DROP CONSTRAINT ${this.escapeId(constraintName)};`;
602
+ }
603
+ assertPrimaryKeyAlterable(tableName) {
604
+ if (this.features.primaryKeyAlter) {
605
+ return;
606
+ }
607
+ throw new TypeError(`${this.dialect}: Cannot change the primary key of "${tableName}" - this database has no ALTER ` +
608
+ 'for it. Recreate the table in a written migration.');
609
+ }
597
610
  }
598
611
  /**
599
612
  * 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
  }