uql-orm 0.41.1 → 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 (69) hide show
  1. package/dist/browser/querier/httpQuerier.d.ts +7 -7
  2. package/dist/browser/querier/httpQuerier.js +21 -6
  3. package/dist/browser/type/clientQuerier.d.ts +3 -3
  4. package/dist/browser/uql-browser.min.js +2 -2
  5. package/dist/browser/uql-browser.min.js.map +4 -4
  6. package/dist/dialect/abstractSqlDialect.d.ts +32 -3
  7. package/dist/dialect/abstractSqlDialect.js +58 -22
  8. package/dist/dialect/mysqlLikeSqlDialect.d.ts +3 -2
  9. package/dist/dialect/mysqlLikeSqlDialect.js +5 -3
  10. package/dist/dialect/pgLikeSqlDialect.d.ts +1 -1
  11. package/dist/dialect/pgLikeSqlDialect.js +2 -1
  12. package/dist/entity/decorator/entity.d.ts +1 -1
  13. package/dist/entity/decorator/entity.js +1 -1
  14. package/dist/entity/decorator/members.d.ts +10 -2
  15. package/dist/entity/index.d.ts +1 -1
  16. package/dist/entity/index.js +1 -1
  17. package/dist/entity/metadata/definition.d.ts +18 -1
  18. package/dist/entity/metadata/definition.js +91 -36
  19. package/dist/http/handler.js +4 -4
  20. package/dist/maria/mariaDialect.d.ts +2 -2
  21. package/dist/maria/mariaDialect.js +3 -2
  22. package/dist/migrate/builder/tableBuilder.js +5 -4
  23. package/dist/migrate/drift/driftDetector.js +16 -0
  24. package/dist/migrate/generator/mongoSchemaGenerator.d.ts +7 -0
  25. package/dist/migrate/generator/mongoSchemaGenerator.js +24 -28
  26. package/dist/migrate/introspection/abstractSqlSchemaIntrospector.d.ts +9 -1
  27. package/dist/migrate/introspection/abstractSqlSchemaIntrospector.js +11 -2
  28. package/dist/migrate/introspection/baseSqlIntrospector.js +6 -3
  29. package/dist/migrate/introspection/postgresIntrospector.js +1 -1
  30. package/dist/migrate/introspection/sqliteIntrospector.js +7 -3
  31. package/dist/migrate/migrator.js +6 -0
  32. package/dist/migrate/schemaGenerator.d.ts +44 -34
  33. package/dist/migrate/schemaGenerator.js +158 -141
  34. package/dist/mongo/mongoDialect.js +39 -14
  35. package/dist/mongo/mongodbQuerier.js +6 -3
  36. package/dist/postgres/postgresDialect.js +1 -1
  37. package/dist/querier/abstractQuerier.d.ts +16 -12
  38. package/dist/querier/abstractQuerier.js +88 -40
  39. package/dist/querier/abstractQuerierPool.d.ts +8 -8
  40. package/dist/querier/abstractSqlQuerier.d.ts +1 -1
  41. package/dist/querier/abstractSqlQuerier.js +12 -7
  42. package/dist/querier/relationCount.js +44 -33
  43. package/dist/schema/indexDifferences.d.ts +28 -0
  44. package/dist/schema/indexDifferences.js +46 -0
  45. package/dist/schema/schemaASTBuilder.js +38 -31
  46. package/dist/schema/schemaASTDiffer.d.ts +27 -1
  47. package/dist/schema/schemaASTDiffer.js +54 -18
  48. package/dist/schema/types.d.ts +46 -7
  49. package/dist/sqlite/sqliteDialect.d.ts +2 -1
  50. package/dist/sqlite/sqliteDialect.js +4 -1
  51. package/dist/type/dialect.d.ts +6 -0
  52. package/dist/type/entity.d.ts +57 -14
  53. package/dist/type/migration.d.ts +19 -0
  54. package/dist/type/queryWhere.d.ts +7 -2
  55. package/dist/type/universalQuerier.d.ts +8 -8
  56. package/dist/util/dialect.util.js +15 -14
  57. package/dist/util/field.util.d.ts +11 -1
  58. package/dist/util/field.util.js +12 -0
  59. package/dist/util/index.d.ts +1 -0
  60. package/dist/util/index.js +1 -0
  61. package/dist/util/object.util.d.ts +6 -0
  62. package/dist/util/object.util.js +18 -0
  63. package/dist/util/relationQuery.util.d.ts +48 -7
  64. package/dist/util/relationQuery.util.js +70 -9
  65. package/dist/util/rowKey.util.d.ts +9 -0
  66. package/dist/util/rowKey.util.js +24 -0
  67. package/dist/util/sql.util.d.ts +24 -7
  68. package/dist/util/sql.util.js +75 -10
  69. package/package.json +1 -1
@@ -31,6 +31,7 @@ export class MysqlLikeSqlDialect extends AbstractSqlDialect {
31
31
  dropTableCascade: false,
32
32
  renameColumn: true,
33
33
  foreignKeyAlter: true,
34
+ primaryKeyAlter: true,
34
35
  columnComment: true,
35
36
  vectorIndexRequiresNotNull: false,
36
37
  vectorSupportsLength: false,
@@ -62,7 +63,7 @@ export class MysqlLikeSqlDialect extends AbstractSqlDialect {
62
63
  }
63
64
  super.pager(ctx, opts);
64
65
  }
65
- serialPrimaryKey = 'BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY';
66
+ serialType = 'BIGINT UNSIGNED AUTO_INCREMENT';
66
67
  escapeIdChar = '`';
67
68
  tableOptions = 'ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
68
69
  beginTransactionCommand = 'START TRANSACTION';
@@ -70,6 +71,7 @@ export class MysqlLikeSqlDialect extends AbstractSqlDialect {
70
71
  rollbackTransactionCommand = 'ROLLBACK';
71
72
  isolationLevelStrategy = 'set-before';
72
73
  dropForeignKeySyntax = 'DROP FOREIGN KEY';
74
+ dropPrimaryKeySyntax = 'DROP PRIMARY KEY';
73
75
  dropIndexSyntax = 'on-table';
74
76
  renameTableSyntax = 'rename-table';
75
77
  alterColumnSyntax = 'MODIFY COLUMN';
@@ -91,7 +93,7 @@ export class MysqlLikeSqlDialect extends AbstractSqlDialect {
91
93
  const alias = this.upsertNewRowAlias && this.escapeId(this.upsertNewRowAlias, true);
92
94
  const updateCtx = this.createContext();
93
95
  const update = this.getUpsertUpdateAssignments(updateCtx, meta, conflictPaths, payload, (name) => alias ? `${alias}.${name}` : `VALUE(${name})`);
94
- const returning = this.upsertReturning(entity);
96
+ const returning = this.upsertReturning(meta);
95
97
  if (update) {
96
98
  this.appendInsertValues(ctx, entity, payload);
97
99
  ctx.append(`${alias ? ` AS ${alias}` : ''} ON DUPLICATE KEY UPDATE ${update}${returning}`);
@@ -108,7 +110,7 @@ export class MysqlLikeSqlDialect extends AbstractSqlDialect {
108
110
  * Appended to both branches above. Empty on MySQL, which has no `INSERT ... RETURNING`; MariaDB
109
111
  * 10.5+ has it, and used to restate this whole method just to add it.
110
112
  */
111
- upsertReturning(_entity) {
113
+ upsertReturning(_meta) {
112
114
  return '';
113
115
  }
114
116
  /**
@@ -15,7 +15,7 @@ export declare abstract class PgLikeSqlDialect extends AbstractSqlDialect {
15
15
  /** Default {@link DialectFeatures} for Postgres-wire dialects. */
16
16
  protected readonly featureDefaults: DialectFeatures;
17
17
  readonly escapeIdChar = "\"";
18
- readonly serialPrimaryKey: string;
18
+ readonly serialType: string;
19
19
  readonly tableOptions = "";
20
20
  readonly beginTransactionCommand = "BEGIN";
21
21
  readonly commitTransactionCommand = "COMMIT";
@@ -28,6 +28,7 @@ export class PgLikeSqlDialect extends AbstractSqlDialect {
28
28
  dropTableCascade: true,
29
29
  renameColumn: true,
30
30
  foreignKeyAlter: true,
31
+ primaryKeyAlter: true,
31
32
  columnComment: false,
32
33
  vectorIndexRequiresNotNull: false,
33
34
  vectorSupportsLength: true,
@@ -39,7 +40,7 @@ export class PgLikeSqlDialect extends AbstractSqlDialect {
39
40
  // under heavy concurrent insert load (writes concentrate on one range); this default still beats
40
41
  // `SERIAL` (CockroachDB's `unique_rowid()`, a ~64-bit value that overflows JS's safe-integer
41
42
  // range). High-throughput CockroachDB users should override this per-entity with a UUID PK.
42
- serialPrimaryKey = 'BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY';
43
+ serialType = 'BIGINT GENERATED BY DEFAULT AS IDENTITY';
43
44
  tableOptions = '';
44
45
  beginTransactionCommand = 'BEGIN';
45
46
  commitTransactionCommand = 'COMMIT';
@@ -21,7 +21,7 @@ export declare function Filter<E>(name: string, opts: FilterOptions<E>): (entity
21
21
  * `E` is inferred from the class the returned decorator is applied to, which is what lets the column
22
22
  * names be checked against it: `@Index(['nope'])` does not compile.
23
23
  *
24
- * @example `@Index(['lastName', 'firstName'], { name: 'idx_users_fullname' })`
24
+ * @example `@Index(['lastName', 'firstName'], { name: 'users_fullname_idx' })`
25
25
  * @example `@Index(['email'], { unique: true })`
26
26
  * @example `@Index(['status'], { where: "status = 'active'" })`
27
27
  */
@@ -33,7 +33,7 @@ export function Filter(name, opts) {
33
33
  * `E` is inferred from the class the returned decorator is applied to, which is what lets the column
34
34
  * names be checked against it: `@Index(['nope'])` does not compile.
35
35
  *
36
- * @example `@Index(['lastName', 'firstName'], { name: 'idx_users_fullname' })`
36
+ * @example `@Index(['lastName', 'firstName'], { name: 'users_fullname_idx' })`
37
37
  * @example `@Index(['email'], { unique: true })`
38
38
  * @example `@Index(['status'], { where: "status = 'active'" })`
39
39
  */
@@ -7,6 +7,14 @@ type MemberDecorator<V> = (value: undefined, context: ClassFieldDecoratorContext
7
7
  * primary key's own type. Which is what makes `@Field({ references: () => User })` on a `number`, where
8
8
  * `User.id` is a `uuid`, a compile error rather than a column that disagrees with its property.
9
9
  */
10
+ /**
11
+ * Maps any option the type does not declare to `never`, turning a typo into a compile error.
12
+ *
13
+ * Needed because the decorators capture their options as a naked type parameter, and TypeScript
14
+ * skips excess-property checking on one of those: `@Field({ nulable: true })` compiled and was
15
+ * silently ignored. Resolves to `unknown` - an inert intersection member - when there are none.
16
+ */
17
+ type RejectUnknown<O, Known> = [Exclude<keyof O, keyof Known>] extends [never] ? unknown : Record<Exclude<keyof O, keyof Known> & string, never>;
10
18
  /**
11
19
  * The value type the options declare, which the decorated property is then checked against.
12
20
  *
@@ -43,7 +51,7 @@ export declare function Field<O extends FieldOptions<DeclaredValue<O>> & ({
43
51
  type: FieldType;
44
52
  } | {
45
53
  references: EntityGetter;
46
- })>(opts: O): MemberDecorator<DeclaredValue<O> | undefined>;
54
+ }) & RejectUnknown<O, FieldOptions>>(opts: O): MemberDecorator<DeclaredValue<O> | undefined>;
47
55
  /**
48
56
  * Declares the primary key, checked the same way as `@Field`.
49
57
  *
@@ -52,7 +60,7 @@ export declare function Field<O extends FieldOptions<DeclaredValue<O>> & ({
52
60
  */
53
61
  export declare function Id<O extends FieldOptions<DeclaredValue<O>> & {
54
62
  type: FieldType;
55
- }>(opts: O): MemberDecorator<DeclaredValue<O> | undefined>;
63
+ } & RejectUnknown<O, FieldOptions>>(opts: O): MemberDecorator<DeclaredValue<O> | undefined>;
56
64
  /**
57
65
  * `E` comes from the mandatory `entity` getter, so the context can insist the property really holds that
58
66
  * entity: `@ManyToOne({ entity: () => Other })` on a `Company` field stops compiling, and a to-many
@@ -1,3 +1,3 @@
1
1
  export * from './decorator/entity.js';
2
2
  export * from './decorator/members.js';
3
- export { defineEntity, defineField, defineFilter, defineHook, defineId, defineIndex, defineRelation, getEntities, getMeta, } from './metadata/definition.js';
3
+ export { defineEntity, defineField, defineFilter, defineHook, defineId, defineIndex, defineRelation, getEntities, getMeta, assertSoleId, idOf, soleIdOf, } from './metadata/definition.js';
@@ -1,3 +1,3 @@
1
1
  export * from './decorator/entity.js';
2
2
  export * from './decorator/members.js';
3
- export { defineEntity, defineField, defineFilter, defineHook, defineId, defineIndex, defineRelation, getEntities, getMeta, } from './metadata/definition.js';
3
+ export { defineEntity, defineField, defineFilter, defineHook, defineId, defineIndex, defineRelation, getEntities, getMeta, assertSoleId, idOf, soleIdOf, } from './metadata/definition.js';
@@ -1,4 +1,4 @@
1
- import type { EntityIndexInput, EntityMeta, EntityOptions, FieldKey, FieldOptions, FilterOptions, HookEvent, RelationOptions, Type } from '../../type/index.js';
1
+ import type { EntityId, EntityIndexInput, EntityMeta, EntityOptions, FieldKey, FieldOptions, FilterOptions, HookEvent, IdKey, RelationOptions, Type } from '../../type/index.js';
2
2
  export declare function defineField<E>(entity: Type<E>, key: string, opts?: FieldOptions): EntityMeta<E>;
3
3
  export declare function defineId<E>(entity: Type<E>, key: string, opts: FieldOptions): EntityMeta<E>;
4
4
  export declare function defineRelation<E>(entity: Type<E>, key: string, opts: RelationOptions): EntityMeta<E>;
@@ -25,6 +25,23 @@ type MemberSpecs = {
25
25
  */
26
26
  export declare function applyMembers<E>(entity: Type<E>, specs: MemberSpecs | undefined): void;
27
27
  export declare function defineEntity<E>(entity: Type<E>, opts?: EntityOptions<E>): EntityMeta<E>;
28
+ /**
29
+ * Refuses an entity whose primary key is not one column, naming the path that cannot express it.
30
+ *
31
+ * Refusing rather than taking the first of several is the whole guarantee: pairing one column of a
32
+ * two-column key is how a statement silently addresses rows that merely agree on it.
33
+ */
34
+ export declare function assertSoleId<E>(meta: EntityMeta<E>, what: string): void;
35
+ /** The entity's one primary key, for a path that cannot express a composite. See {@link assertSoleId}. */
36
+ export declare function soleIdOf<E>(meta: EntityMeta<E>, what: string): IdKey<E>;
37
+ /**
38
+ * A row's primary key in the shape a `$where` takes: the value itself for a single key, an object
39
+ * carrying every key for a composite - which is exactly {@link EntityId}.
40
+ *
41
+ * What a settled write names its rows by, and what an insert hands back. Naming a composite row by
42
+ * one of its columns would address every row agreeing on that one.
43
+ */
44
+ export declare function idOf<E>(meta: EntityMeta<E>, row: E): EntityId<E>;
28
45
  export declare function getEntities(): Type<unknown>[];
29
46
  export declare function getMeta<E>(entity: Type<E>): EntityMeta<E>;
30
47
  export {};
@@ -24,12 +24,6 @@ export function defineField(entity, key, opts = {}) {
24
24
  return meta;
25
25
  }
26
26
  export function defineId(entity, key, opts) {
27
- const meta = ensureMeta(entity);
28
- const id = getIdKey(meta);
29
- if (id) {
30
- // A subclass narrowing the inherited primary key: drop the old one so exactly one stays marked.
31
- delete meta.fields[id];
32
- }
33
27
  return defineField(entity, key, { ...opts, isId: true });
34
28
  }
35
29
  // `RelationOptions` is parameterized by the *target* entity, which is independent of the owner `E`, so it
@@ -167,16 +161,51 @@ export function defineEntity(entity, opts = {}) {
167
161
  meta.filters = {};
168
162
  meta.filters[SOFT_DELETE_FILTER] = { condition: { [meta.softDelete]: null }, default: true };
169
163
  }
170
- const id = getIdKey(meta);
171
- if (!id) {
172
- throw TypeError(`'${entity.name}' must have exactly one id field (use @Id, defineId, or defineEntity({ fields: { ..., isId: true } }))`);
164
+ const ids = getIdKeys(meta);
165
+ if (!ids.length) {
166
+ throw TypeError(`'${entity.name}' must have at least one id field (use @Id, defineId, or defineEntity({ fields: { ..., isId: true } }))`);
173
167
  }
174
- meta.id = id;
168
+ meta.ids = ids;
175
169
  return meta;
176
170
  }
171
+ /**
172
+ * Refuses an entity whose primary key is not one column, naming the path that cannot express it.
173
+ *
174
+ * Refusing rather than taking the first of several is the whole guarantee: pairing one column of a
175
+ * two-column key is how a statement silently addresses rows that merely agree on it.
176
+ */
177
+ export function assertSoleId(meta, what) {
178
+ const { ids } = meta;
179
+ if (ids.length === 1) {
180
+ return;
181
+ }
182
+ throw new TypeError(ids.length
183
+ ? `'${meta.entity.name}' has a composite primary key (${ids.join(', ')}), which ${what} does not support yet.`
184
+ : // An entity registered with `@Field` but no `@Entity` never ran the check in `defineEntity`.
185
+ `'${meta.entity.name}' has no primary key, which ${what} needs.`);
186
+ }
187
+ /** The entity's one primary key, for a path that cannot express a composite. See {@link assertSoleId}. */
188
+ export function soleIdOf(meta, what) {
189
+ assertSoleId(meta, what);
190
+ return meta.ids[0];
191
+ }
192
+ /**
193
+ * A row's primary key in the shape a `$where` takes: the value itself for a single key, an object
194
+ * carrying every key for a composite - which is exactly {@link EntityId}.
195
+ *
196
+ * What a settled write names its rows by, and what an insert hands back. Naming a composite row by
197
+ * one of its columns would address every row agreeing on that one.
198
+ */
199
+ export function idOf(meta, row) {
200
+ const { ids } = meta;
201
+ if (ids.length === 1) {
202
+ return row[ids[0]];
203
+ }
204
+ return Object.fromEntries(ids.map((key) => [key, row[key]]));
205
+ }
177
206
  export function getEntities() {
178
207
  return [...metas.entries()].reduce((acc, [key, val]) => {
179
- if (val.id) {
208
+ if (val.ids.length) {
180
209
  acc.push(key);
181
210
  }
182
211
  return acc;
@@ -187,7 +216,7 @@ function ensureMeta(entity) {
187
216
  if (meta) {
188
217
  return meta;
189
218
  }
190
- meta = { entity, id: '', fields: {}, relations: {} };
219
+ meta = { entity, ids: [], fields: {}, relations: {} };
191
220
  metas.set(entity, meta);
192
221
  return meta;
193
222
  }
@@ -210,7 +239,7 @@ function fillRelations(meta) {
210
239
  continue;
211
240
  const at = `'${meta.entity.name}.${relKey}'`;
212
241
  if (relOpts.mappedBy) {
213
- fillInverseSide(at, relOpts);
242
+ fillInverseSide(at, meta, relOpts);
214
243
  }
215
244
  else if (!relOpts.references) {
216
245
  fillOwningSide(at, meta, relKey, relOpts);
@@ -234,13 +263,13 @@ function fillRelations(meta) {
234
263
  }
235
264
  function fillOwningSide(at, meta, relKey, relOpts) {
236
265
  const relMeta = ensureMeta(relOpts.entity());
237
- const relIdKey = relMeta.id;
238
266
  if (relOpts.through) {
239
267
  // Both columns live on the junction, whatever the cardinality: `fillToManyThroughRelation`,
240
- // `deleteRelations` and every dialect read them as junction columns.
268
+ // `deleteRelations` and every dialect read them as junction columns. A composite key contributes
269
+ // one pair per column of it, which is what makes the join address a whole key rather than part.
241
270
  relOpts.references = [
242
- { local: junctionColumn(meta, meta.id), foreign: meta.id },
243
- { local: junctionColumn(relMeta, relIdKey), foreign: relIdKey },
271
+ ...meta.ids.map((key) => ({ local: junctionColumn(meta, key), foreign: key })),
272
+ ...relMeta.ids.map((key) => ({ local: junctionColumn(relMeta, key), foreign: key })),
244
273
  ];
245
274
  return;
246
275
  }
@@ -248,21 +277,29 @@ function fillOwningSide(at, meta, relKey, relOpts) {
248
277
  throw new TypeError(`${at} is a to-many relation with no way to join: it needs 'mappedBy' (the field on the other side), ` +
249
278
  "'through' (a junction entity), or 'references' (the columns).");
250
279
  }
251
- const fkKey = `${relKey}Id`;
252
- relOpts.references = [{ local: fkKey, foreign: relIdKey }];
280
+ // `<rel>Id` for the one-key case it has always been; `<rel><Key>` per column otherwise. Both name a
281
+ // property, so both are spelled from the referenced *property* - a column name is what the naming
282
+ // strategy makes of this afterwards.
283
+ const sole = relMeta.ids.length === 1;
284
+ relOpts.references = relMeta.ids.map((key) => ({
285
+ local: sole ? `${relKey}Id` : `${relKey}${upperFirst(key)}`,
286
+ foreign: key,
287
+ }));
253
288
  // `typeFromReference` so schema generation resolves the referenced primary key's exact type
254
289
  // (columnType, length, chained keys) rather than trusting the fallback, as it does for an
255
290
  // explicit `@Field({ references })`.
256
- if (!meta.fields[fkKey]) {
257
- meta.fields[fkKey] = {
258
- name: fkKey,
259
- type: relMeta.fields[relIdKey]?.type ?? Number,
291
+ const fields = meta.fields;
292
+ for (const { local, foreign } of relOpts.references) {
293
+ fields[local] ??= {
294
+ name: local,
295
+ type: relMeta.fields[foreign]?.type ?? Number,
260
296
  references: relOpts.entity,
297
+ referencedKey: foreign,
261
298
  typeFromReference: true,
262
299
  };
263
300
  }
264
301
  }
265
- function fillInverseSide(at, relOpts) {
302
+ function fillInverseSide(at, meta, relOpts) {
266
303
  const relEntity = relOpts.entity();
267
304
  const relMeta = getMeta(relEntity);
268
305
  const mappedBy = getMappedByKey(relOpts);
@@ -270,7 +307,13 @@ function fillInverseSide(at, relOpts) {
270
307
  if (relOpts.references)
271
308
  return;
272
309
  if (relMeta.fields[mappedBy]) {
273
- relOpts.references = [{ local: relMeta.id, foreign: mappedBy }];
310
+ if (meta.ids.length > 1) {
311
+ throw new TypeError(`${at} is mapped by '${relEntity.name}.${mappedBy}', one column, but the primary key of ` +
312
+ `'${meta.entity.name}' is composite (${meta.ids.join(', ')}). Map it by the relation on the other side ` +
313
+ 'instead, which joins every column of the key.');
314
+ }
315
+ // `local` is this entity's own key, as in every other pair.
316
+ relOpts.references = [{ local: meta.ids[0], foreign: mappedBy }];
274
317
  return;
275
318
  }
276
319
  // Authored view again: with each side mapped by the other, the target is still mid-resolution here and
@@ -282,11 +325,12 @@ function fillInverseSide(at, relOpts) {
282
325
  if (!owner.references?.length) {
283
326
  throw new TypeError(`${at} is mapped by '${relEntity.name}.${mappedBy}', an inverse side too, so neither owns the foreign key.`);
284
327
  }
285
- // Two different flips: a junction pair is `[thisSide, otherSide]`, so the array reverses; a plain
286
- // foreign key is one pair whose ends swap.
328
+ // Two different flips: a junction's pairs are the owner's group followed by ours, so the two groups
329
+ // swap - `toReversed` would also reverse each group, pairing a composite's columns crosswise. A
330
+ // plain foreign key is one pair per key whose ends swap.
287
331
  relOpts.references =
288
332
  relOpts.cardinality === 'm1' || relOpts.cardinality === 'mm'
289
- ? owner.references.toReversed()
333
+ ? [...owner.references.slice(relMeta.ids.length), ...owner.references.slice(0, relMeta.ids.length)]
290
334
  : owner.references.map(({ local, foreign }) => ({ local: foreign, foreign: local }));
291
335
  relOpts.through = owner.through;
292
336
  }
@@ -303,7 +347,16 @@ function fillForeignKeyRelations(meta) {
303
347
  const references = meta.fields[fieldKey]?.references;
304
348
  if (!references || joined.has(fieldKey))
305
349
  continue;
306
- const foreign = ensureMeta(references()).id;
350
+ const target = ensureMeta(references());
351
+ // Nothing to derive from an entity that has not registered its own fields yet.
352
+ if (!target.ids.length)
353
+ continue;
354
+ if (target.ids.length > 1) {
355
+ throw new TypeError(`'${meta.entity.name}.${fieldKey}' cannot reference '${target.entity.name}', whose primary key is composite ` +
356
+ `(${target.ids.join(', ')}): a column points at one. Use ` +
357
+ `'@ManyToOne({ entity: () => ${target.entity.name} })', which declares one column per key.`);
358
+ }
359
+ const [foreign] = target.ids;
307
360
  // The relation takes the column's name minus the key it points at (`itemId` -> `item`); a column
308
361
  // named anything else has no name to take, so it stays a plain foreign key.
309
362
  const suffix = upperFirst(foreign);
@@ -330,16 +383,18 @@ function getMappedByKey(relOpts) {
330
383
  ? relOpts.mappedBy(RELATION_KEY_MAP)
331
384
  : relOpts.mappedBy;
332
385
  }
333
- function getIdKey(meta) {
334
- const id = getKeys(meta.fields).find((key) => meta.fields[key]?.isId);
335
- return id;
386
+ /** Every key the entity marks, in declaration order. More than one is a composite primary key. */
387
+ function getIdKeys(meta) {
388
+ return getKeys(meta.fields).filter((key) => meta.fields[key]?.isId);
336
389
  }
337
390
  function extendMeta(target, source) {
338
391
  const sourceFields = { ...source.fields };
339
- const sourceId = getIdKey(source);
340
- // A subclass that declares its own primary key drops the parent's, so exactly one stays marked.
341
- if (sourceId && getIdKey(target)) {
342
- delete sourceFields[sourceId];
392
+ // A subclass declaring its own primary key drops the parent's - every column of it, or a composite
393
+ // parent would leave its remaining keys marked and silently widen the child's key.
394
+ if (getIdKeys(target).length) {
395
+ for (const key of getIdKeys(source)) {
396
+ delete sourceFields[key];
397
+ }
343
398
  }
344
399
  target.fields = { ...sourceFields, ...target.fields };
345
400
  target.relations = { ...source.relations, ...target.relations };
@@ -1,5 +1,5 @@
1
1
  import { withContext } from '../context/context.js';
2
- import { getEntities, getMeta } from '../entity/index.js';
2
+ import { getEntities, getMeta, soleIdOf } from '../entity/index.js';
3
3
  import { entityPath, matchRoute } from './contract.js';
4
4
  import { parseQueryParams } from './query.js';
5
5
  /** `Company (crm.Company)`: the class, and the table it maps, which is what tells two apart. */
@@ -142,8 +142,8 @@ export function createRequestHandler(opts) {
142
142
  const founds = await querier.findMany(entity, query);
143
143
  let ids = [];
144
144
  let count = 0;
145
- if (founds.length && meta.id) {
146
- const idKey = meta.id;
145
+ if (founds.length) {
146
+ const idKey = soleIdOf(meta, 'the HTTP handler');
147
147
  ids = founds.map((found) => found[idKey]);
148
148
  count = await querier.deleteMany(entity, { $where: ids }, { hardDelete });
149
149
  }
@@ -157,7 +157,7 @@ function ok(body) {
157
157
  return { status: 200, body };
158
158
  }
159
159
  function buildIdQuery(meta, id, query) {
160
- const idKey = meta.id;
160
+ const idKey = soleIdOf(meta, 'the HTTP handler');
161
161
  const where = query.$where;
162
162
  if (Array.isArray(where)) {
163
163
  query.$where = { $and: [{ [idKey]: { $in: where } }, { [idKey]: id }] };
@@ -1,5 +1,5 @@
1
1
  import { MysqlLikeSqlDialect } from '../dialect/mysqlLikeSqlDialect.js';
2
- import type { DialectFeatures, FieldOptions, Query, QueryContext, QueryOptions, Type, VectorDistance, VectorMetric } from '../type/index.js';
2
+ import type { DialectFeatures, EntityMeta, FieldOptions, Query, QueryContext, QueryOptions, Type, VectorDistance, VectorMetric } from '../type/index.js';
3
3
  export declare class MariaDialect extends MysqlLikeSqlDialect {
4
4
  readonly dialectName = "mariadb";
5
5
  readonly insertIdSource = "returning";
@@ -10,7 +10,7 @@ export declare class MariaDialect extends MysqlLikeSqlDialect {
10
10
  * and `CREATE INDEX` takes `IF NOT EXISTS` - which MySQL's grammar has no place for.
11
11
  */
12
12
  protected readonly featureOverrides: Partial<DialectFeatures>;
13
- protected upsertReturning<E>(entity: Type<E>): string;
13
+ protected upsertReturning<E>(meta: EntityMeta<E>): string;
14
14
  /**
15
15
  * MariaDB supports neither MySQL's `->`/`->>` shorthand nor the base's chained form. `JSON_VALUE`
16
16
  * reads a scalar and `JSON_EXTRACT` the subtree that the array operators need.
@@ -18,8 +18,9 @@ export class MariaDialect extends MysqlLikeSqlDialect {
18
18
  vectorIndexRequiresNotNull: true,
19
19
  indexIfNotExists: true,
20
20
  };
21
- upsertReturning(entity) {
22
- return ` ${this.returningId(entity)}`;
21
+ upsertReturning(meta) {
22
+ const returning = this.returningId(meta);
23
+ return returning ? ` ${returning}` : '';
23
24
  }
24
25
  /**
25
26
  * MariaDB supports neither MySQL's `->`/`->>` shorthand nor the base's chained form. `JSON_VALUE`
@@ -154,10 +154,10 @@ export class TableBuilder {
154
154
  return this;
155
155
  }
156
156
  unique(columns, options) {
157
- return this.addIndex('uq', columns, options, true);
157
+ return this.addIndex(columns, options, true);
158
158
  }
159
159
  index(columns, options) {
160
- return this.addIndex('idx', columns, options, false);
160
+ return this.addIndex(columns, options, false);
161
161
  }
162
162
  /**
163
163
  * `@Index` and `table.index(...)` differ only in how the name is defaulted, so both normalize their
@@ -165,12 +165,13 @@ export class TableBuilder {
165
165
  * form, and anything left as a bare string would reach it as a column literally named `[object
166
166
  * Object]`.
167
167
  */
168
- addIndex(prefix, columns, options, unique) {
168
+ addIndex(columns, options, unique) {
169
169
  const { name, ...rest } = typeof options === 'string' ? { name: options } : (options ?? {});
170
170
  const entries = columns.map(normalizeIndexColumn);
171
171
  this._indexes.push({
172
172
  ...rest,
173
- name: name ?? `${prefix}_${this._name}_${entries.map((entry) => entry.column).join('_')}`,
173
+ name: name ??
174
+ derivedIndexName(this._name, entries.map((entry) => entry.column), unique),
174
175
  where: ddlText(rest.where, 'a partial-index predicate'),
175
176
  entries,
176
177
  unique,
@@ -34,6 +34,7 @@ export function detectDrift(expectedAST, actualAST, options = {}) {
34
34
  ...detectTableDrifts(diff),
35
35
  ...detectColumnDrifts(diff, opts),
36
36
  ...detectIndexDrifts(diff),
37
+ ...detectPrimaryKeyDrifts(diff),
37
38
  ...detectRelationshipDrifts(diff),
38
39
  ];
39
40
  return {
@@ -43,6 +44,21 @@ export function detectDrift(expectedAST, actualAST, options = {}) {
43
44
  generatedAt: new Date(),
44
45
  };
45
46
  }
47
+ /**
48
+ * A table whose key holds different columns than the entity declares.
49
+ *
50
+ * Critical, and reported as a `constraint_mismatch` like any other: rows the database will accept
51
+ * are not the rows the ORM believes are unique, so it addresses by a key nothing enforces.
52
+ */
53
+ function detectPrimaryKeyDrifts(diff) {
54
+ return diff.primaryKeyDiffs.map((pkDiff) => ({
55
+ type: 'constraint_mismatch',
56
+ severity: 'critical',
57
+ table: pkDiff.table,
58
+ details: `Primary key of "${pkDiff.table}" is (${pkDiff.actual.join(', ') || 'none'}) in the database but (${pkDiff.expected.join(', ') || 'none'}) in the entity`,
59
+ suggestion: 'Generate a migration to change the primary key',
60
+ }));
61
+ }
46
62
  /**
47
63
  * Detect table-level drifts (missing/unexpected tables).
48
64
  */
@@ -16,6 +16,13 @@ export declare class MongoSchemaGenerator extends AbstractDialect implements Sch
16
16
  generateCreateSchema(entities: readonly Type<unknown>[], options?: CreateSchemaOptions): string[];
17
17
  generateDropSchema(entities: readonly Type<unknown>[]): string[];
18
18
  private selected;
19
+ /**
20
+ * The indexes `@Field({ index })` declares, as the collection would hold them.
21
+ *
22
+ * One owner because two paths need it: creating a collection, and working out which of its indexes
23
+ * are missing. Derived twice, they drifted the moment either changed how a name is settled.
24
+ */
25
+ private fieldIndexes;
19
26
  generateCreateTable<E>(entity: Type<E>, _options?: {
20
27
  ifNotExists?: boolean;
21
28
  }): string[];
@@ -32,22 +32,32 @@ export class MongoSchemaGenerator extends AbstractDialect {
32
32
  const wanted = new Set(only);
33
33
  return entities.filter((entity) => wanted.has(this.resolveTableName(getMeta(entity))));
34
34
  }
35
- generateCreateTable(entity, _options) {
36
- const meta = getMeta(entity);
37
- const collectionName = this.resolveTableName(meta);
38
- const indexes = [];
39
- for (const key of getKeys(meta.fields)) {
35
+ /**
36
+ * The indexes `@Field({ index })` declares, as the collection would hold them.
37
+ *
38
+ * One owner because two paths need it: creating a collection, and working out which of its indexes
39
+ * are missing. Derived twice, they drifted the moment either changed how a name is settled.
40
+ */
41
+ fieldIndexes(meta, collectionName) {
42
+ return getKeys(meta.fields).flatMap((key) => {
40
43
  const field = meta.fields[key];
41
- if (field?.index) {
42
- const columnName = this.resolveColumnName(key, field);
43
- const indexName = typeof field.index === 'string' ? field.index : derivedIndexName(collectionName, [columnName]);
44
- indexes.push({
45
- name: indexName,
44
+ if (!field?.index) {
45
+ return [];
46
+ }
47
+ const columnName = this.resolveColumnName(key, field);
48
+ return [
49
+ {
50
+ name: typeof field.index === 'string' ? field.index : derivedIndexName(collectionName, [columnName]),
46
51
  entries: [{ column: columnName }],
47
52
  unique: !!field.unique,
48
- });
49
- }
50
- }
53
+ },
54
+ ];
55
+ });
56
+ }
57
+ generateCreateTable(entity, _options) {
58
+ const meta = getMeta(entity);
59
+ const collectionName = this.resolveTableName(meta);
60
+ const indexes = this.fieldIndexes(meta, collectionName);
51
61
  // One `createIndex` command each, mirroring the SQL generator's `[CREATE TABLE, ...CREATE INDEX]`,
52
62
  // so the key spec is built here and the migrator only executes it.
53
63
  return [
@@ -137,22 +147,8 @@ export class MongoSchemaGenerator extends AbstractDialect {
137
147
  if (!currentTable) {
138
148
  return { tableName: collectionName, type: 'create' };
139
149
  }
140
- const indexesToAdd = [];
141
150
  const existingIndexes = new Set(currentTable.indexes?.map((i) => i.name) ?? []);
142
- for (const key of getKeys(meta.fields)) {
143
- const field = meta.fields[key];
144
- if (field?.index) {
145
- const columnName = this.resolveColumnName(key, field);
146
- const indexName = typeof field.index === 'string' ? field.index : derivedIndexName(collectionName, [columnName]);
147
- if (!existingIndexes.has(indexName)) {
148
- indexesToAdd.push({
149
- name: indexName,
150
- entries: [{ column: columnName }],
151
- unique: !!field.unique,
152
- });
153
- }
154
- }
155
- }
151
+ const indexesToAdd = this.fieldIndexes(meta, collectionName).filter((index) => !existingIndexes.has(index.name));
156
152
  if (indexesToAdd.length === 0) {
157
153
  return undefined;
158
154
  }
@@ -60,7 +60,10 @@ export declare abstract class AbstractSqlSchemaIntrospector extends BaseSqlIntro
60
60
  protected getColumns(read: TableRowReader, tableName: string): Promise<ColumnSchema[]>;
61
61
  protected getIndexes(read: TableRowReader, tableName: string): Promise<IndexSchema[]>;
62
62
  protected getForeignKeys(read: TableRowReader, tableName: string): Promise<ForeignKeySchema[]>;
63
- protected getPrimaryKey(read: TableRowReader, tableName: string): Promise<string[] | undefined>;
63
+ protected getPrimaryKey(read: TableRowReader, tableName: string): Promise<{
64
+ columns?: string[];
65
+ name?: string;
66
+ }>;
64
67
  protected tableExistsParams(tableName: string): unknown[];
65
68
  protected getColumnsParams(tableName: string): unknown[];
66
69
  protected getIndexesParams(tableName: string): unknown[];
@@ -108,6 +111,11 @@ export declare abstract class AbstractSqlSchemaIntrospector extends BaseSqlIntro
108
111
  * overrides this.
109
112
  */
110
113
  protected mapPrimaryKeyResult(results: RawRow[]): string[] | undefined;
114
+ /**
115
+ * What the engine calls the key's constraint, where the query reported one. Only a `DROP` needs it,
116
+ * and only the reported name will do - see {@link TableSchema.primaryKeyName}.
117
+ */
118
+ protected mapPrimaryKeyName(results: RawRow[]): string | undefined;
111
119
  /** Parse default value string to appropriate type. */
112
120
  protected abstract parseDefaultValue(defaultValue: string | null): unknown;
113
121
  }
@@ -55,7 +55,8 @@ export class AbstractSqlSchemaIntrospector extends BaseSqlIntrospector {
55
55
  return {
56
56
  name: tableName,
57
57
  columns,
58
- primaryKey,
58
+ primaryKey: primaryKey.columns,
59
+ primaryKeyName: primaryKey.name,
59
60
  indexes,
60
61
  foreignKeys,
61
62
  };
@@ -100,7 +101,7 @@ export class AbstractSqlSchemaIntrospector extends BaseSqlIntrospector {
100
101
  }
101
102
  async getPrimaryKey(read, tableName) {
102
103
  const results = await read(this.getPrimaryKeyQuery(tableName), this.getPrimaryKeyParams(tableName));
103
- return this.mapPrimaryKeyResult(results);
104
+ return { columns: this.mapPrimaryKeyResult(results), name: this.mapPrimaryKeyName(results) };
104
105
  }
105
106
  tableExistsParams(tableName) {
106
107
  return [tableName];
@@ -162,6 +163,14 @@ export class AbstractSqlSchemaIntrospector extends BaseSqlIntrospector {
162
163
  const columns = results.map((row) => String(row['column_name']));
163
164
  return columns.length ? columns : undefined;
164
165
  }
166
+ /**
167
+ * What the engine calls the key's constraint, where the query reported one. Only a `DROP` needs it,
168
+ * and only the reported name will do - see {@link TableSchema.primaryKeyName}.
169
+ */
170
+ mapPrimaryKeyName(results) {
171
+ const name = results[0]?.['constraint_name'];
172
+ return name === undefined || name === null ? undefined : String(name);
173
+ }
165
174
  }
166
175
  /** A {@link TableRowReader} over one querier: the same statement is only ever sent once. */
167
176
  function createTableRowReader(querier) {