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,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
  }
@@ -36,17 +36,32 @@ export declare function canonicalToSql(type: CanonicalType, dialect: AbstractDia
36
36
  * Convert a canonical type to a TypeScript type string.
37
37
  */
38
38
  export declare function canonicalToTypeScript(type: CanonicalType): string;
39
+ /**
40
+ * A type as `dialect` would actually store it: rendered to that engine's SQL and read back.
41
+ *
42
+ * Several canonical types share one storage type per engine - a `boolean` is `TINYINT(1)` on MySQL and
43
+ * `INTEGER` on SQLite - and only the engine settles an unstated bound, since `VARCHAR` is 255 on MySQL
44
+ * and `TEXT` on Postgres. Both paths that diff a schema compare through this, so a migration and a
45
+ * drift report cannot disagree about what changed.
46
+ */
47
+ export declare function engineType(dialect: AbstractDialect): (type: CanonicalType) => CanonicalType;
39
48
  /**
40
49
  * Convert UQL FieldOptions to a canonical type.
41
50
  */
42
- export declare function fieldOptionsToCanonical(options: FieldOptions, tsType?: unknown): CanonicalType;
51
+ export declare function fieldOptionsToCanonical(options: FieldOptions): CanonicalType;
43
52
  /**
44
- * Compare two canonical types for equality.
45
- * Used for schema diffing.
53
+ * Compare two canonical types for equality. Used for schema diffing.
54
+ *
55
+ * Nothing here guesses an engine's own default for an unstated bound: `VARCHAR` is 255 on MySQL and
56
+ * `TEXT` on Postgres, and a comparison that assumed either was blind to that difference on the other.
57
+ * `DiffOptions.normalizeType` is what settles it, by putting both sides through the engine first.
46
58
  */
47
59
  export declare function areTypesEqual(a: CanonicalType, b: CanonicalType): boolean;
48
60
  /**
49
- * Check if changing from type A to type B could cause data loss.
61
+ * Whether changing a column from one type to the other can lose what it holds.
62
+ *
63
+ * An unstated bound is the engine's widest, so stating one for the first time narrows the column just
64
+ * as lowering one does: `TEXT` to `VARCHAR(50)` truncates, and `NUMERIC` to `NUMERIC(5,2)` rounds.
50
65
  */
51
66
  export declare function isBreakingTypeChange(from: CanonicalType, to: CanonicalType): boolean;
52
67
  /**
@@ -6,6 +6,7 @@
6
6
  * - Canonical types (dialect-agnostic)
7
7
  * - TypeScript types (for entity generation)
8
8
  */
9
+ import { columnFamily } from '../util/field.util.js';
9
10
  /** Whether a category is one of the vector types, narrowing it to the cast pgvector names use. */
10
11
  export function isVectorCategory(category) {
11
12
  return category === 'vector' || category === 'halfvec' || category === 'sparsevec';
@@ -108,66 +109,57 @@ const PG_TYPE_MAP = {
108
109
  halfvec: 'HALFVEC',
109
110
  sparsevec: 'SPARSEVEC',
110
111
  };
112
+ const MYSQL_SCALAR_MAP = {
113
+ integer: 'INT',
114
+ float: 'FLOAT',
115
+ decimal: 'DECIMAL',
116
+ string: 'VARCHAR',
117
+ boolean: 'TINYINT(1)',
118
+ date: 'DATE',
119
+ time: 'TIME',
120
+ timestamp: 'DATETIME',
121
+ json: 'JSON',
122
+ uuid: 'CHAR(36)',
123
+ blob: 'BLOB',
124
+ };
125
+ const SQLITE_SCALAR_MAP = {
126
+ integer: 'INTEGER',
127
+ float: 'REAL',
128
+ decimal: 'REAL',
129
+ string: 'TEXT',
130
+ boolean: 'INTEGER',
131
+ date: 'TEXT',
132
+ time: 'TEXT',
133
+ timestamp: 'TEXT',
134
+ json: 'TEXT',
135
+ uuid: 'TEXT',
136
+ blob: 'BLOB',
137
+ };
138
+ /** MongoDB uses BSON types, not SQL types. These are placeholders for compatibility. */
139
+ const MONGO_SCALAR_MAP = {
140
+ integer: 'int',
141
+ float: 'double',
142
+ decimal: 'decimal128',
143
+ string: 'string',
144
+ boolean: 'bool',
145
+ date: 'date',
146
+ time: 'string',
147
+ timestamp: 'timestamp',
148
+ json: 'object',
149
+ uuid: 'binData',
150
+ blob: 'binData',
151
+ };
152
+ /** Every engine's scalars, and the one spelling it gives all three vector widths. */
111
153
  const CANONICAL_TO_SQL = {
112
154
  postgres: PG_TYPE_MAP,
113
155
  // CockroachDB's VECTOR is native, no extension needed.
114
156
  cockroachdb: withVectorType(PG_SCALAR_MAP, 'VECTOR'),
115
- mysql: withVectorType({
116
- integer: 'INT',
117
- float: 'FLOAT',
118
- decimal: 'DECIMAL',
119
- string: 'VARCHAR',
120
- boolean: 'TINYINT(1)',
121
- date: 'DATE',
122
- time: 'TIME',
123
- timestamp: 'DATETIME',
124
- json: 'JSON',
125
- uuid: 'CHAR(36)',
126
- blob: 'BLOB',
127
- },
128
157
  // MySQL does have a `VECTOR` type (26.7), but no distance function outside HeatWave and no vector
129
158
  // index, so JSON keeps the column queryable with the JSON operators and needs no conversion.
130
- 'JSON'),
131
- sqlite: withVectorType({
132
- integer: 'INTEGER',
133
- float: 'REAL',
134
- decimal: 'REAL',
135
- string: 'TEXT',
136
- boolean: 'INTEGER',
137
- date: 'TEXT',
138
- time: 'TEXT',
139
- timestamp: 'TEXT',
140
- json: 'TEXT',
141
- uuid: 'TEXT',
142
- blob: 'BLOB',
143
- }, 'TEXT'),
144
- mariadb: withVectorType({
145
- integer: 'INT',
146
- float: 'FLOAT',
147
- decimal: 'DECIMAL',
148
- string: 'VARCHAR',
149
- boolean: 'TINYINT(1)',
150
- date: 'DATE',
151
- time: 'TIME',
152
- timestamp: 'DATETIME',
153
- json: 'JSON',
154
- uuid: 'CHAR(36)',
155
- blob: 'BLOB',
156
- }, 'VECTOR'),
157
- // MongoDB uses BSON types, not SQL types. These are placeholders for compatibility.
158
- mongodb: withVectorType({
159
- integer: 'int',
160
- float: 'double',
161
- decimal: 'decimal128',
162
- string: 'string',
163
- boolean: 'bool',
164
- date: 'date',
165
- time: 'string',
166
- timestamp: 'timestamp',
167
- json: 'object',
168
- uuid: 'binData',
169
- blob: 'binData',
170
- }, 'array'),
159
+ mysql: withVectorType(MYSQL_SCALAR_MAP, 'JSON'),
160
+ mariadb: withVectorType(MYSQL_SCALAR_MAP, 'VECTOR'),
161
+ sqlite: withVectorType(SQLITE_SCALAR_MAP, 'TEXT'),
162
+ mongodb: withVectorType(MONGO_SCALAR_MAP, 'array'),
171
163
  };
172
164
  /**
173
165
  * Size variant modifiers for SQL types.
@@ -239,7 +231,7 @@ const CANONICAL_TO_TS = {
239
231
  timestamp: 'Date',
240
232
  json: 'unknown',
241
233
  uuid: 'string',
242
- blob: 'Buffer',
234
+ blob: 'Uint8Array',
243
235
  vector: 'number[]',
244
236
  halfvec: 'number[]',
245
237
  sparsevec: 'number[]',
@@ -387,136 +379,94 @@ function formatDecimalSqlType(type, dialect, baseType) {
387
379
  export function canonicalToTypeScript(type) {
388
380
  return CANONICAL_TO_TS[type.category];
389
381
  }
382
+ /**
383
+ * A type as `dialect` would actually store it: rendered to that engine's SQL and read back.
384
+ *
385
+ * Several canonical types share one storage type per engine - a `boolean` is `TINYINT(1)` on MySQL and
386
+ * `INTEGER` on SQLite - and only the engine settles an unstated bound, since `VARCHAR` is 255 on MySQL
387
+ * and `TEXT` on Postgres. Both paths that diff a schema compare through this, so a migration and a
388
+ * drift report cannot disagree about what changed.
389
+ */
390
+ export function engineType(dialect) {
391
+ return (type) => sqlToCanonical(canonicalToSql(type, dialect));
392
+ }
390
393
  /**
391
394
  * Convert UQL FieldOptions to a canonical type.
392
395
  */
393
- export function fieldOptionsToCanonical(options, tsType) {
394
- // If explicit columnType is specified, use it
396
+ export function fieldOptionsToCanonical(options) {
397
+ // An explicit column type is read exactly as an introspected one is: the SQL type, plus whatever
398
+ // bounds are stated beside it.
395
399
  if (options.columnType) {
396
- const base = sqlToCanonical(options.columnType);
397
- return {
398
- ...base,
399
- length: options.length ?? base.length,
400
- precision: options.precision ?? base.precision,
401
- scale: options.scale ?? base.scale,
402
- };
403
- }
404
- // Infer from type (could be constructor or string)
405
- const type = options.type || tsType;
406
- if (type === String) {
407
- return {
408
- category: 'string',
409
- length: options.length,
410
- };
411
- }
412
- if (type === Number) {
413
- if (options.precision || options.scale) {
414
- return {
415
- category: 'decimal',
416
- precision: options.precision,
417
- scale: options.scale,
418
- };
419
- }
420
- // BIGINT for every `Number`, key or not: a 32-bit column is a migration waiting to happen, and
421
- // the pools decode it back to a JS number at the wire (see `pgNumericTypes`).
422
- return { category: 'integer', size: 'big' };
423
- }
424
- if (type === Boolean) {
425
- return { category: 'boolean' };
426
- }
427
- if (type === Date) {
428
- return { category: 'timestamp' };
429
- }
430
- if (type === BigInt) {
431
- return { category: 'integer', size: 'big' };
400
+ return canonicalColumnType(options.columnType, options);
432
401
  }
402
+ // Infer from type, which is a SQL type string or one of the constructors.
403
+ const { type } = options;
433
404
  if (typeof type === 'string') {
434
405
  const canonical = sqlToCanonical(type);
435
406
  // Propagate explicit dimensions into CanonicalType.length for vector types
436
- if (options.dimensions && isVectorCategory(canonical.category)) {
437
- return { ...canonical, length: options.dimensions };
438
- }
439
- return canonical;
407
+ return options.dimensions && isVectorCategory(canonical.category)
408
+ ? { ...canonical, length: options.dimensions }
409
+ : canonical;
410
+ }
411
+ switch (columnFamily(type)) {
412
+ case 'numeric':
413
+ // BIGINT for every `Number` without a scale, key or not: a 32-bit column is a migration waiting
414
+ // to happen, and the pools decode it back to a JS number at the wire (see `pgNumericTypes`).
415
+ return type === Number && (options.precision || options.scale)
416
+ ? { category: 'decimal', precision: options.precision, scale: options.scale }
417
+ : { category: 'integer', size: 'big' };
418
+ case 'boolean':
419
+ return { category: 'boolean' };
420
+ case 'date':
421
+ return { category: 'timestamp' };
422
+ // `String`, and anything a reflected type left unrecognised.
423
+ default:
424
+ return { category: 'string', length: options.length };
440
425
  }
441
- // Default to string
442
- return { category: 'string', length: options.length };
443
426
  }
444
427
  /**
445
- * Compare two canonical types for equality.
446
- * Used for schema diffing.
428
+ * Compare two canonical types for equality. Used for schema diffing.
429
+ *
430
+ * Nothing here guesses an engine's own default for an unstated bound: `VARCHAR` is 255 on MySQL and
431
+ * `TEXT` on Postgres, and a comparison that assumed either was blind to that difference on the other.
432
+ * `DiffOptions.normalizeType` is what settles it, by putting both sides through the engine first.
447
433
  */
448
434
  export function areTypesEqual(a, b) {
449
- // Category must match
450
- if (a.category !== b.category) {
451
- return false;
452
- }
453
- // Size must match (if specified)
454
- if (a.size !== b.size) {
455
- return false;
456
- }
457
- // For strings, compare length (allowing for default 255)
458
- if (a.category === 'string') {
459
- const lengthA = a.length ?? 255;
460
- const lengthB = b.length ?? 255;
461
- if (lengthA !== lengthB) {
462
- return false;
463
- }
464
- }
465
- // For decimals, compare precision and scale (allowing for default 10, 2)
466
- if (a.category === 'decimal') {
467
- const pA = a.precision ?? 10;
468
- const pB = b.precision ?? 10;
469
- const sA = a.scale ?? 2;
470
- const sB = b.scale ?? 2;
471
- if (pA !== pB || sA !== sB) {
472
- return false;
473
- }
474
- }
475
- // For timestamps, compare timezone
476
- if (a.category === 'timestamp') {
477
- if (!!a.withTimezone !== !!b.withTimezone) {
478
- return false;
479
- }
480
- }
481
- // Unsigned must match
482
- if (!!a.unsigned !== !!b.unsigned) {
483
- return false;
484
- }
485
- return true;
435
+ return (a.category === b.category &&
436
+ a.size === b.size &&
437
+ a.length === b.length &&
438
+ a.precision === b.precision &&
439
+ a.scale === b.scale &&
440
+ !!a.withTimezone === !!b.withTimezone &&
441
+ !!a.unsigned === !!b.unsigned);
486
442
  }
487
443
  /**
488
- * Check if changing from type A to type B could cause data loss.
444
+ * Whether changing a column from one type to the other can lose what it holds.
445
+ *
446
+ * An unstated bound is the engine's widest, so stating one for the first time narrows the column just
447
+ * as lowering one does: `TEXT` to `VARCHAR(50)` truncates, and `NUMERIC` to `NUMERIC(5,2)` rounds.
489
448
  */
490
449
  export function isBreakingTypeChange(from, to) {
491
- // Changing category is always potentially breaking
492
450
  if (from.category !== to.category) {
493
451
  return true;
494
452
  }
495
- // Reducing size is breaking
496
- const sizeOrder = ['tiny', 'small', 'medium', 'big'];
497
- if (from.size && to.size) {
498
- const fromIndex = sizeOrder.indexOf(from.size);
499
- const toIndex = sizeOrder.indexOf(to.size);
500
- if (toIndex < fromIndex) {
501
- return true;
502
- }
503
- }
504
- // Reducing string length is breaking
505
- if (from.category === 'string' && from.length && to.length) {
506
- if (to.length < from.length) {
507
- return true;
508
- }
509
- }
510
- // Reducing precision/scale is breaking
511
- if (from.category === 'decimal') {
512
- if (from.precision && to.precision && to.precision < from.precision) {
513
- return true;
514
- }
515
- if (from.scale && to.scale && to.scale < from.scale) {
516
- return true;
517
- }
453
+ // Only where both state a size: an unsized member of a family is the engine's base type, wider than
454
+ // `tiny` and narrower than `big`, and which of those it is depends on a family this does not know.
455
+ if (from.size && to.size && SIZE_ORDER.indexOf(to.size) < SIZE_ORDER.indexOf(from.size)) {
456
+ return true;
518
457
  }
519
- return false;
458
+ return (narrows(from.length, to.length) ||
459
+ narrows(from.precision, to.precision) ||
460
+ narrows(from.scale, to.scale) ||
461
+ // Either direction drops half the range: signed loses the top bit, unsigned the negatives.
462
+ !!from.unsigned !== !!to.unsigned ||
463
+ // An offset the column stops keeping cannot be recovered from what is left.
464
+ (!!from.withTimezone && !to.withTimezone));
465
+ }
466
+ const SIZE_ORDER = ['tiny', 'small', 'medium', 'big'];
467
+ /** A bound narrowed, where an unstated one is unbounded. */
468
+ function narrows(from, to) {
469
+ return to !== undefined && (from === undefined || to < from);
520
470
  }
521
471
  /**
522
472
  * Get the UQL ColumnType that best matches a canonical type.
@@ -9,6 +9,34 @@ import type { IndexNode } from './types.js';
9
9
  * back, MySQL emits one it cannot describe afterwards.
10
10
  */
11
11
  export type IndexFacet = 'order' | 'nulls' | 'opsClass' | 'accessMethod' | 'include';
12
+ /**
13
+ * Whether the table already has this index, for the additive sync that only ever *creates* one.
14
+ *
15
+ * Its shape, never its name: the table's indexes were named by whoever created them, so an index
16
+ * that is already there must not be created a second time under a name we happen to prefer. A
17
+ * derived name is no handle at all - the convention can change, an engine silently truncates one
18
+ * past its identifier limit, and SQLite reports names it made up.
19
+ *
20
+ * Uniqueness counts, because a unique index and a plain one over the same columns enforce different
21
+ * things and no engine can alter one into the other. An index over an expression or a JSON path has
22
+ * no comparable columns - engines reprint SQL text from their parse tree, the same reason
23
+ * {@link describeIndexDifferences} leaves those entries alone - so it falls back to its name.
24
+ */
25
+ export declare function indexSignature(index: Pick<IndexNode, 'name' | 'entries' | 'unique'>): string;
26
+ /**
27
+ * A constraint name without its kind marker.
28
+ *
29
+ * What pairs two sides of a *report*: an index whose uniqueness or columns changed is one index that
30
+ * differs, not one dropped and another created, and only a handle independent of its shape can say
31
+ * so. Stripping the marker is what lets `idx_User_email`, named before the convention moved it to
32
+ * the end, recognise the `User__email_idx` derived for it now, so upgrading reports no drift.
33
+ *
34
+ * Exactly one marker, and the trailing one first. Stripping both ends would eat a leading marker
35
+ * that belongs to the *table* - an index over `pk_registry` is not a primary key - leaving it unable
36
+ * to pair with its own older name. The separator is levelled last, since only one convention doubles
37
+ * it.
38
+ */
39
+ export declare function indexNameStem(name: string): string;
12
40
  /**
13
41
  * Everything an index differs by, named, or nothing when the two match.
14
42
  *
@@ -1,3 +1,49 @@
1
+ /**
2
+ * Whether the table already has this index, for the additive sync that only ever *creates* one.
3
+ *
4
+ * Its shape, never its name: the table's indexes were named by whoever created them, so an index
5
+ * that is already there must not be created a second time under a name we happen to prefer. A
6
+ * derived name is no handle at all - the convention can change, an engine silently truncates one
7
+ * past its identifier limit, and SQLite reports names it made up.
8
+ *
9
+ * Uniqueness counts, because a unique index and a plain one over the same columns enforce different
10
+ * things and no engine can alter one into the other. An index over an expression or a JSON path has
11
+ * no comparable columns - engines reprint SQL text from their parse tree, the same reason
12
+ * {@link describeIndexDifferences} leaves those entries alone - so it falls back to its name.
13
+ */
14
+ export function indexSignature(index) {
15
+ const comparable = !index.entries.some((entry) => entry.expression || entry.jsonPath || entry.jsonArray);
16
+ const identity = comparable
17
+ ? index.entries.map((entry) => entry.column).join(',')
18
+ : `name:${indexNameStem(index.name)}`;
19
+ return `${index.unique ? 'unique' : 'plain'}(${identity})`;
20
+ }
21
+ /**
22
+ * A constraint name without its kind marker.
23
+ *
24
+ * What pairs two sides of a *report*: an index whose uniqueness or columns changed is one index that
25
+ * differs, not one dropped and another created, and only a handle independent of its shape can say
26
+ * so. Stripping the marker is what lets `idx_User_email`, named before the convention moved it to
27
+ * the end, recognise the `User__email_idx` derived for it now, so upgrading reports no drift.
28
+ *
29
+ * Exactly one marker, and the trailing one first. Stripping both ends would eat a leading marker
30
+ * that belongs to the *table* - an index over `pk_registry` is not a primary key - leaving it unable
31
+ * to pair with its own older name. The separator is levelled last, since only one convention doubles
32
+ * it.
33
+ */
34
+ export function indexNameStem(name) {
35
+ const withoutSuffix = name.replace(KIND_SUFFIX, '');
36
+ const bare = withoutSuffix === name ? name.replace(KIND_PREFIX, '') : withoutSuffix;
37
+ return bare.replace(/__/g, '_');
38
+ }
39
+ /** What this version emits. */
40
+ const KIND_SUFFIX = /_(?:idx|fk|ck|pk|uk|uq)$/i;
41
+ /**
42
+ * What it only ever *reads*: uql wrote `idx_User_email` until 0.42.1, and a database it did not
43
+ * create at all - the one `generate:from-db` points at - most often spells it that way too. Tried
44
+ * second, so a name already marked at the end keeps a leading `pk_` that is part of its table.
45
+ */
46
+ const KIND_PREFIX = /^(?:idx|fk|ck|pk|uk|uq)_/i;
1
47
  /**
2
48
  * Everything an index differs by, named, or nothing when the two match.
3
49
  *
@@ -6,6 +6,7 @@
6
6
  * - Database introspection results (TableSchema[])
7
7
  */
8
8
  import { getMeta, soleIdOf } from '../entity/metadata/definition.js';
9
+ import { isSoleIdField } from '../util/field.util.js';
9
10
  import { derivedForeignKeyName, derivedIndexName, qualifyName } from '../util/sql.util.js';
10
11
  import { fieldOptionsToCanonical } from './canonicalType.js';
11
12
  import { createTableNode, SchemaAST } from './schemaAST.js';
@@ -64,7 +65,7 @@ function resolveColumnCanonicalType(field, seen = new Set()) {
64
65
  return resolveColumnCanonicalType(referencedIdField, seen);
65
66
  }
66
67
  }
67
- return fieldOptionsToCanonical(field, field.type);
68
+ return fieldOptionsToCanonical(field);
68
69
  }
69
70
  /**
70
71
  * Add a table from entity metadata.
@@ -86,7 +87,7 @@ function addTableFromEntity(ctx, meta) {
86
87
  const columnName = ctx.resolveColumnName(key, field);
87
88
  const type = resolveColumnCanonicalType(field);
88
89
  const isPrimaryKey = field.isId === true;
89
- const isSoleKey = isPrimaryKey && meta.ids.length === 1;
90
+ const isSoleKey = isSoleIdField(meta, field);
90
91
  const column = {
91
92
  name: columnName,
92
93
  type,
@@ -95,9 +96,7 @@ function addTableFromEntity(ctx, meta) {
95
96
  nullable: isPrimaryKey ? false : (field.nullable ?? true),
96
97
  defaultValue: field.defaultValue,
97
98
  isPrimaryKey,
98
- // Only a sole integer key defaults to auto-increment: a composite's columns are values the
99
- // caller supplies, and the serial type carries an inline `PRIMARY KEY` the table already states.
100
- isAutoIncrement: field.autoIncrement ?? (isPrimaryKey && isSoleKey && type.category === 'integer'),
99
+ isAutoIncrement: field.autoIncrement ?? (isSoleKey && type.category === 'integer'),
101
100
  isUnique: field.unique ?? false,
102
101
  comment: field.comment,
103
102
  enum: field.enum,
@@ -142,37 +141,32 @@ function addRelationshipsFromEntity(ctx, meta) {
142
141
  // the engine requires the referenced columns to match a unique constraint as a whole.
143
142
  const localColumns = [];
144
143
  const foreignColumns = [];
145
- let firstLocalField;
146
- for (const { local: localPropName, foreign: foreignPropName } of relation.references) {
147
- const localField = meta.fields[localPropName];
148
- const foreignField = relatedMeta.fields[foreignPropName];
149
- if (!localField || !foreignField) {
150
- continue;
151
- }
152
- const localColumn = table.columns.get(ctx.resolveColumnName(localPropName, localField));
153
- const foreignColumn = relatedTable.columns.get(ctx.resolveColumnName(foreignPropName, foreignField));
154
- if (!localColumn || !foreignColumn) {
155
- continue;
156
- }
157
- firstLocalField ??= localField;
144
+ for (const { local: localProp, foreign: foreignProp } of relation.references) {
145
+ const localField = meta.fields[localProp];
146
+ const foreignField = relatedMeta.fields[foreignProp];
147
+ const localColumn = localField && table.columns.get(ctx.resolveColumnName(localProp, localField));
148
+ const foreignColumn = foreignField && relatedTable.columns.get(ctx.resolveColumnName(foreignProp, foreignField));
149
+ if (!localColumn || !foreignColumn)
150
+ break;
158
151
  localColumns.push(localColumn);
159
152
  foreignColumns.push(foreignColumn);
160
153
  }
161
- if (localColumns.length === relation.references.length && firstLocalField) {
162
- const relNode = {
163
- name: derivedForeignKeyName(table.name, localColumns.map((column) => column.name)),
164
- type: relation.cardinality === 'm1' ? 'ManyToOne' : 'OneToOne',
165
- from: { table, columns: localColumns },
166
- to: { table: relatedTable, columns: foreignColumns },
167
- // Falls back to the FK column's own `onDelete`, which is what makes a bare `@Field({
168
- // references, onDelete })` work with no relation declared at all.
169
- onDelete: relation.onDelete ?? firstLocalField.onDelete ?? ctx.defaultForeignKeyAction,
170
- onUpdate: relation.onUpdate ?? ctx.defaultForeignKeyAction,
171
- confidence: 1.0,
172
- inferredFrom: 'entity_decorator',
173
- };
174
- ctx.ast.addRelationship(relNode);
175
- }
154
+ // A pair that cannot be resolved drops the whole constraint: half of one enforces a rule
155
+ // nobody declared, over a subset of the key.
156
+ if (localColumns.length !== relation.references.length)
157
+ continue;
158
+ ctx.ast.addRelationship({
159
+ name: derivedForeignKeyName(table.name, localColumns.map((column) => column.name)),
160
+ type: relation.cardinality === 'm1' ? 'ManyToOne' : 'OneToOne',
161
+ from: { table, columns: localColumns },
162
+ to: { table: relatedTable, columns: foreignColumns },
163
+ // Falls back to the FK column's own `onDelete`, which is what makes a bare `@Field({
164
+ // references, onDelete })` work with no relation declared at all.
165
+ onDelete: relation.onDelete ?? meta.fields[relation.references[0].local]?.onDelete ?? ctx.defaultForeignKeyAction,
166
+ onUpdate: relation.onUpdate ?? ctx.defaultForeignKeyAction,
167
+ confidence: 1.0,
168
+ inferredFrom: 'entity_decorator',
169
+ });
176
170
  }
177
171
  }
178
172
  }
@@ -9,7 +9,8 @@
9
9
  */
10
10
  import { type IndexFacet } from './indexDifferences.js';
11
11
  import type { SchemaAST } from './schemaAST.js';
12
- import type { SchemaDiffResult } from './types.js';
12
+ import type { CanonicalType } from './types.js';
13
+ import type { SchemaDiffResult, TableDiff, TableNode } from './types.js';
13
14
  /**
14
15
  * Options for schema diffing.
15
16
  */
@@ -24,6 +25,23 @@ export interface DiffOptions {
24
25
  ignoreCase?: boolean;
25
26
  /** Tables to exclude from comparison */
26
27
  excludeTables?: string[];
28
+ /**
29
+ * A type as the engine would actually store it, for the caller that has a dialect.
30
+ *
31
+ * Several canonical types share one storage type per engine - a `boolean` is `TINYINT(1)` on MySQL
32
+ * and `INTEGER` on SQLite - so comparing them canonically reports an alteration on every sync for
33
+ * those columns. Passing both sides through the engine first is what settles that, and it is the
34
+ * only thing here a dialect is needed for, so it arrives as a function rather than as a dependency.
35
+ */
36
+ normalizeType?: (type: CanonicalType) => CanonicalType;
37
+ /**
38
+ * Whether two defaults are the same value, for the caller that has a dialect.
39
+ *
40
+ * A database reprints a default from its parse tree, so `'active'` comes back as
41
+ * `'active'::character varying` on Postgres and a symbolic `now()` matches no spelling of
42
+ * `CURRENT_TIMESTAMP`. Undoing that needs the dialect that wrote it, so it arrives as a function.
43
+ */
44
+ defaultsEqual?: (expected: unknown, actual: unknown) => boolean;
27
45
  }
28
46
  /**
29
47
  * Compare two schemas and return the differences.
@@ -34,3 +52,11 @@ export interface DiffOptions {
34
52
  * @returns Detailed diff result
35
53
  */
36
54
  export declare function diffSchemas(source: SchemaAST, target: SchemaAST, options?: DiffOptions): SchemaDiffResult;
55
+ /**
56
+ * Compare two tables and return the differences.
57
+ *
58
+ * Exported because it is also how a migration is planned: the generator diffs one entity's table
59
+ * against the one the database reported, then projects the result into a `SchemaDiff`. One
60
+ * comparison serves both, so drift and migrations can no longer disagree about what has changed.
61
+ */
62
+ export declare function diffTable(source: TableNode, target: TableNode, options?: DiffOptions): TableDiff | undefined;