uql-orm 0.41.0 → 0.42.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 (50) hide show
  1. package/dist/browser/querier/httpQuerier.d.ts +4 -4
  2. package/dist/browser/querier/httpQuerier.js +21 -6
  3. package/dist/browser/uql-browser.min.js +2 -2
  4. package/dist/browser/uql-browser.min.js.map +4 -4
  5. package/dist/dialect/abstractSqlDialect.d.ts +1 -1
  6. package/dist/dialect/abstractSqlDialect.js +26 -21
  7. package/dist/dialect/mysqlLikeSqlDialect.d.ts +1 -1
  8. package/dist/dialect/mysqlLikeSqlDialect.js +2 -2
  9. package/dist/entity/decorator/members.d.ts +29 -3
  10. package/dist/entity/index.d.ts +1 -1
  11. package/dist/entity/index.js +1 -1
  12. package/dist/entity/metadata/definition.d.ts +18 -1
  13. package/dist/entity/metadata/definition.js +97 -38
  14. package/dist/http/handler.js +4 -4
  15. package/dist/maria/mariaDialect.d.ts +2 -2
  16. package/dist/maria/mariaDialect.js +2 -2
  17. package/dist/migrate/builder/migrationBuilder.js +2 -2
  18. package/dist/migrate/builder/tableBuilder.js +2 -2
  19. package/dist/migrate/schemaGenerator.d.ts +2 -2
  20. package/dist/migrate/schemaGenerator.js +12 -4
  21. package/dist/mongo/mongoDialect.js +17 -6
  22. package/dist/mongo/mongodbQuerier.js +6 -3
  23. package/dist/querier/abstractQuerier.d.ts +16 -12
  24. package/dist/querier/abstractQuerier.js +77 -40
  25. package/dist/querier/abstractQuerierPool.d.ts +8 -8
  26. package/dist/querier/abstractSqlQuerier.d.ts +1 -1
  27. package/dist/querier/abstractSqlQuerier.js +12 -7
  28. package/dist/querier/relationCount.js +40 -31
  29. package/dist/schema/schemaAST.js +1 -0
  30. package/dist/schema/schemaASTBuilder.js +37 -22
  31. package/dist/schema/types.d.ts +24 -0
  32. package/dist/type/entity.d.ts +85 -15
  33. package/dist/type/queryWhere.d.ts +7 -2
  34. package/dist/type/universalQuerier.d.ts +8 -8
  35. package/dist/util/ddlExpression.util.d.ts +15 -0
  36. package/dist/util/ddlExpression.util.js +27 -0
  37. package/dist/util/dialect.util.js +15 -14
  38. package/dist/util/index.d.ts +2 -1
  39. package/dist/util/index.js +2 -1
  40. package/dist/util/object.util.d.ts +6 -0
  41. package/dist/util/object.util.js +10 -0
  42. package/dist/util/relationQuery.util.d.ts +38 -7
  43. package/dist/util/relationQuery.util.js +50 -9
  44. package/dist/util/rowKey.util.d.ts +9 -0
  45. package/dist/util/rowKey.util.js +24 -0
  46. package/dist/util/sql.util.d.ts +2 -0
  47. package/dist/util/sql.util.js +4 -0
  48. package/package.json +2 -2
  49. package/dist/util/indexColumn.util.d.ts +0 -8
  50. package/dist/util/indexColumn.util.js +0 -30
@@ -1,5 +1,5 @@
1
1
  import { SOFT_DELETE_FILTER } from '../../type/index.js';
2
- import { getKeys, hasKeys, isToManyRelation, lowerFirst, normalizeIndexColumn, normalizeIndexWhere, upperFirst, } from '../../util/index.js';
2
+ import { getKeys, ddlText, hasKeys, isToManyRelation, lowerFirst, normalizeIndexColumn, upperFirst, } from '../../util/index.js';
3
3
  import { ownRegistrations } from '../decorator/bag.js';
4
4
  // Held on `globalThis` via the global symbol registry so a single metadata map survives multiple
5
5
  // evaluations of this module (HMR, duplicated/federated bundles, ESM+CJS dual-loading). Version-suffixed
@@ -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
@@ -66,7 +60,7 @@ export function defineIndex(entity, index) {
66
60
  meta.indexes.push({
67
61
  ...index,
68
62
  unique: index.unique ?? false,
69
- where: normalizeIndexWhere(index.where),
63
+ where: ddlText(index.where, 'a partial-index predicate'),
70
64
  columns: index.columns.map(normalizeIndexColumn),
71
65
  });
72
66
  return meta;
@@ -124,6 +118,10 @@ export function defineEntity(entity, opts = {}) {
124
118
  // after class decorators return; draining empties the bag, so whichever runs second is a no-op.
125
119
  applyMembers(entity, ownRegistrations(entity));
126
120
  applyMembers(entity, opts);
121
+ // Unnamed checks are named by the generator, as unnamed indexes are.
122
+ for (const check of opts.checks ?? []) {
123
+ (meta.checks ??= []).push({ name: check.name, expression: ddlText(check.expression, 'a check constraint') });
124
+ }
127
125
  for (const index of opts.indexes ?? []) {
128
126
  defineIndex(entity, index);
129
127
  }
@@ -163,16 +161,51 @@ export function defineEntity(entity, opts = {}) {
163
161
  meta.filters = {};
164
162
  meta.filters[SOFT_DELETE_FILTER] = { condition: { [meta.softDelete]: null }, default: true };
165
163
  }
166
- const id = getIdKey(meta);
167
- if (!id) {
168
- 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 } }))`);
169
167
  }
170
- meta.id = id;
168
+ meta.ids = ids;
171
169
  return meta;
172
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
+ }
173
206
  export function getEntities() {
174
207
  return [...metas.entries()].reduce((acc, [key, val]) => {
175
- if (val.id) {
208
+ if (val.ids.length) {
176
209
  acc.push(key);
177
210
  }
178
211
  return acc;
@@ -183,7 +216,7 @@ function ensureMeta(entity) {
183
216
  if (meta) {
184
217
  return meta;
185
218
  }
186
- meta = { entity, id: '', fields: {}, relations: {} };
219
+ meta = { entity, ids: [], fields: {}, relations: {} };
187
220
  metas.set(entity, meta);
188
221
  return meta;
189
222
  }
@@ -206,7 +239,7 @@ function fillRelations(meta) {
206
239
  continue;
207
240
  const at = `'${meta.entity.name}.${relKey}'`;
208
241
  if (relOpts.mappedBy) {
209
- fillInverseSide(at, relOpts);
242
+ fillInverseSide(at, meta, relOpts);
210
243
  }
211
244
  else if (!relOpts.references) {
212
245
  fillOwningSide(at, meta, relKey, relOpts);
@@ -230,13 +263,13 @@ function fillRelations(meta) {
230
263
  }
231
264
  function fillOwningSide(at, meta, relKey, relOpts) {
232
265
  const relMeta = ensureMeta(relOpts.entity());
233
- const relIdKey = relMeta.id;
234
266
  if (relOpts.through) {
235
267
  // Both columns live on the junction, whatever the cardinality: `fillToManyThroughRelation`,
236
- // `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.
237
270
  relOpts.references = [
238
- { local: junctionColumn(meta, meta.id), foreign: meta.id },
239
- { 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 })),
240
273
  ];
241
274
  return;
242
275
  }
@@ -244,21 +277,29 @@ function fillOwningSide(at, meta, relKey, relOpts) {
244
277
  throw new TypeError(`${at} is a to-many relation with no way to join: it needs 'mappedBy' (the field on the other side), ` +
245
278
  "'through' (a junction entity), or 'references' (the columns).");
246
279
  }
247
- const fkKey = `${relKey}Id`;
248
- 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
+ }));
249
288
  // `typeFromReference` so schema generation resolves the referenced primary key's exact type
250
289
  // (columnType, length, chained keys) rather than trusting the fallback, as it does for an
251
290
  // explicit `@Field({ references })`.
252
- if (!meta.fields[fkKey]) {
253
- meta.fields[fkKey] = {
254
- name: fkKey,
255
- 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,
256
296
  references: relOpts.entity,
297
+ referencedKey: foreign,
257
298
  typeFromReference: true,
258
299
  };
259
300
  }
260
301
  }
261
- function fillInverseSide(at, relOpts) {
302
+ function fillInverseSide(at, meta, relOpts) {
262
303
  const relEntity = relOpts.entity();
263
304
  const relMeta = getMeta(relEntity);
264
305
  const mappedBy = getMappedByKey(relOpts);
@@ -266,7 +307,13 @@ function fillInverseSide(at, relOpts) {
266
307
  if (relOpts.references)
267
308
  return;
268
309
  if (relMeta.fields[mappedBy]) {
269
- 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 }];
270
317
  return;
271
318
  }
272
319
  // Authored view again: with each side mapped by the other, the target is still mid-resolution here and
@@ -278,11 +325,12 @@ function fillInverseSide(at, relOpts) {
278
325
  if (!owner.references?.length) {
279
326
  throw new TypeError(`${at} is mapped by '${relEntity.name}.${mappedBy}', an inverse side too, so neither owns the foreign key.`);
280
327
  }
281
- // Two different flips: a junction pair is `[thisSide, otherSide]`, so the array reverses; a plain
282
- // 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.
283
331
  relOpts.references =
284
332
  relOpts.cardinality === 'm1' || relOpts.cardinality === 'mm'
285
- ? owner.references.toReversed()
333
+ ? [...owner.references.slice(relMeta.ids.length), ...owner.references.slice(0, relMeta.ids.length)]
286
334
  : owner.references.map(({ local, foreign }) => ({ local: foreign, foreign: local }));
287
335
  relOpts.through = owner.through;
288
336
  }
@@ -299,7 +347,16 @@ function fillForeignKeyRelations(meta) {
299
347
  const references = meta.fields[fieldKey]?.references;
300
348
  if (!references || joined.has(fieldKey))
301
349
  continue;
302
- 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;
303
360
  // The relation takes the column's name minus the key it points at (`itemId` -> `item`); a column
304
361
  // named anything else has no name to take, so it stays a plain foreign key.
305
362
  const suffix = upperFirst(foreign);
@@ -326,16 +383,18 @@ function getMappedByKey(relOpts) {
326
383
  ? relOpts.mappedBy(RELATION_KEY_MAP)
327
384
  : relOpts.mappedBy;
328
385
  }
329
- function getIdKey(meta) {
330
- const id = getKeys(meta.fields).find((key) => meta.fields[key]?.isId);
331
- 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);
332
389
  }
333
390
  function extendMeta(target, source) {
334
391
  const sourceFields = { ...source.fields };
335
- const sourceId = getIdKey(source);
336
- // A subclass that declares its own primary key drops the parent's, so exactly one stays marked.
337
- if (sourceId && getIdKey(target)) {
338
- 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
+ }
339
398
  }
340
399
  target.fields = { ...sourceFields, ...target.fields };
341
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,8 @@ 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
+ return ` ${this.returningId(meta)}`;
23
23
  }
24
24
  /**
25
25
  * MariaDB supports neither MySQL's `->`/`->>` shorthand nor the base's chained form. `JSON_VALUE`
@@ -5,7 +5,7 @@
5
5
  * - OperationRecorder: Record operations only (for code generation)
6
6
  * - MigrationBuilder: Execute DDL operations (for integration tests/runtime)
7
7
  */
8
- import { normalizeIndexColumn, normalizeIndexWhere } from '../../util/index.js';
8
+ import { ddlText, normalizeIndexColumn } from '../../util/index.js';
9
9
  import { derivedIndexName } from '../../util/sql.util.js';
10
10
  import { createSchemaGenerator } from '../schemaGenerator.js';
11
11
  import { splitSqlStatements } from './splitSqlStatements.js';
@@ -29,7 +29,7 @@ function createIndexOperation(tableName, columns, options = {}) {
29
29
  ...index,
30
30
  name: name ??
31
31
  derivedIndexName(tableName, entries.map((entry) => entry.column)),
32
- where: normalizeIndexWhere(index.where),
32
+ where: ddlText(index.where, 'a partial-index predicate'),
33
33
  entries,
34
34
  unique: unique ?? false,
35
35
  },
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Fluent API for defining tables in migrations.
5
5
  */
6
- import { normalizeIndexColumn, normalizeIndexWhere } from '../../util/index.js';
6
+ import { ddlText, normalizeIndexColumn } from '../../util/index.js';
7
7
  import { derivedIndexName } from '../../util/sql.util.js';
8
8
  import { ColumnBuilder } from './columnBuilder.js';
9
9
  import { expr } from './expressions.js';
@@ -171,7 +171,7 @@ export class TableBuilder {
171
171
  this._indexes.push({
172
172
  ...rest,
173
173
  name: name ?? `${prefix}_${this._name}_${entries.map((entry) => entry.column).join('_')}`,
174
- where: normalizeIndexWhere(rest.where),
174
+ where: ddlText(rest.where, 'a partial-index predicate'),
175
175
  entries,
176
176
  unique,
177
177
  });
@@ -1,7 +1,7 @@
1
1
  import { type AbstractDialect, AbstractSqlDialect } from '../dialect/index.js';
2
2
  import type { SchemaAST } from '../schema/schemaAST.js';
3
3
  import type { CanonicalType, ColumnNode, ForeignKeyAction, IndexNode, TableNode } from '../schema/types.js';
4
- import type { ColumnSchema, CreateSchemaOptions, DialectFeatures, DropSchemaOptions, EntityMeta, FieldOptions, IndexSchema, NamingStrategy, SchemaDiff, SchemaGenerator, SqlDdlGenerator, Type } from '../type/index.js';
4
+ import type { ColumnSchema, CreateSchemaOptions, DialectFeatures, DropSchemaOptions, EntityMeta, FieldMeta, FieldOptions, IndexSchema, NamingStrategy, SchemaDiff, SchemaGenerator, SqlDdlGenerator, Type } from '../type/index.js';
5
5
  import type { FullColumnDefinition, TableDefinition, TableForeignKeyDefinition } from './builder/types.js';
6
6
  import { type IndexDdl } from './ddl/index.js';
7
7
  /**
@@ -82,7 +82,7 @@ export declare class SqlSchemaGenerator implements SqlDdlGenerator {
82
82
  /** ` DEFAULT <sql>`, or nothing where the column declares none. Empty rather than `DEFAULT NULL`
83
83
  * so an absent default stays absent - `defaultValue: null` is the way to ask for one. */
84
84
  private defaultClause;
85
- getSqlType(field: FieldOptions, fieldType?: unknown): string;
85
+ getSqlType(field: FieldMeta, fieldType?: unknown): string;
86
86
  /**
87
87
  * Generate ALTER COLUMN statements (database-specific)
88
88
  */
@@ -1,9 +1,9 @@
1
1
  import { AbstractSqlDialect } from '../dialect/index.js';
2
- import { getMeta } from '../entity/index.js';
2
+ import { getMeta, soleIdOf } from '../entity/index.js';
3
3
  import { areTypesEqual, canonicalToSql, fieldOptionsToCanonical, isVectorCategory, sqlToCanonical, } from '../schema/canonicalType.js';
4
4
  import { buildSchemaAST } from '../schema/schemaASTBuilder.js';
5
5
  import { getKeys, isAutoIncrement, qualifyName } from '../util/index.js';
6
- import { derivedForeignKeyName } from '../util/sql.util.js';
6
+ import { derivedCheckName, derivedForeignKeyName } from '../util/sql.util.js';
7
7
  import { formatDefaultValue, SqlExpression } from './builder/expressions.js';
8
8
  import { indexDdlFor } from './ddl/index.js';
9
9
  import { fullColumnDefinitionToNode, tableDefinitionToNode } from './generator/definitionToNode.js';
@@ -247,6 +247,10 @@ export class SqlSchemaGenerator {
247
247
  if (column.isUnique && !column.isPrimaryKey) {
248
248
  def += ' UNIQUE';
249
249
  }
250
+ if (column.enum?.length) {
251
+ const values = column.enum.map((value) => this.dialect.escape(value)).join(', ');
252
+ def += ` CHECK (${this.escapeId(column.name)} IN (${values}))`;
253
+ }
250
254
  def += this.defaultClause(column);
251
255
  if (column.comment) {
252
256
  def += this.generateColumnComment(column.name, column.comment);
@@ -265,7 +269,7 @@ export class SqlSchemaGenerator {
265
269
  if (field.references) {
266
270
  const refEntity = field.references();
267
271
  const refMeta = getMeta(refEntity);
268
- const refIdField = refMeta.fields[refMeta.id];
272
+ const refIdField = refMeta.fields[field.referencedKey ?? soleIdOf(refMeta, 'a foreign key')];
269
273
  return this.getSqlType({ ...refIdField, references: undefined, isId: undefined, autoIncrement: false }, refIdField.type);
270
274
  }
271
275
  // Get canonical type and convert to SQL
@@ -406,7 +410,7 @@ export class SqlSchemaGenerator {
406
410
  * would create for this field, against what it reported for the existing column.
407
411
  */
408
412
  fieldToColumnSchema(fieldKey, field, meta) {
409
- const isPrimaryKey = field.isId === true && meta.id === fieldKey;
413
+ const isPrimaryKey = field.isId === true;
410
414
  return {
411
415
  name: this.dialect.resolveColumnName(fieldKey, field),
412
416
  type: this.getSqlType(field, field.type),
@@ -493,6 +497,10 @@ export class SqlSchemaGenerator {
493
497
  const pkCols = table.primaryKey.map((c) => this.escapeId(c.name)).join(', ');
494
498
  constraints.push(`PRIMARY KEY (${pkCols})`);
495
499
  }
500
+ (table.checks ?? []).forEach((check, i) => {
501
+ const name = check.name ?? derivedCheckName(table.name, i + 1);
502
+ constraints.push(`CONSTRAINT ${this.escapeId(name)} CHECK (${check.expression})`);
503
+ });
496
504
  for (const rel of table.outgoingRelations) {
497
505
  if (rel.from.columns.length > 0) {
498
506
  const fromCols = rel.from.columns.map((c) => this.escapeId(c.name)).join(', ');
@@ -2,7 +2,7 @@ import { ObjectId } from 'mongodb';
2
2
  import { AbstractDialect } from '../dialect/abstractDialect.js';
3
3
  import { COUNT_ALIAS, REL_NESTED_KEY, REL_TEMP_PREFIX, sortCountField } from '../dialect/aliases.js';
4
4
  import { resolveQueryJoins, resolveSortableJoin } from '../dialect/queryJoins.js';
5
- import { getMeta } from '../entity/index.js';
5
+ import { assertSoleId, getMeta, soleIdOf } from '../entity/index.js';
6
6
  import { QueryRaw } from '../type/queryRaw.js';
7
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';
8
8
  /** Default {@link DialectFeatures} for MongoDB; shared by {@link MongoDialect} and its schema generator. */
@@ -44,7 +44,14 @@ export class MongoDialect extends AbstractDialect {
44
44
  * the document does not use.
45
45
  */
46
46
  columnOf(meta, key) {
47
- if (key === MongoDialect.ID_KEY || key === meta.id) {
47
+ if (key === MongoDialect.ID_KEY) {
48
+ return MongoDialect.ID_KEY;
49
+ }
50
+ if (meta.fields[key]?.isId) {
51
+ // A document has one `_id`. A composite key is a different document shape - a sub-document,
52
+ // whose field order decides equality - rather than a translation, so it is refused here, which
53
+ // every read reaches, and in `getPersistables`, which every write reaches.
54
+ assertSoleId(meta, 'MongoDB');
48
55
  return MongoDialect.ID_KEY;
49
56
  }
50
57
  return super.columnOf(meta, key);
@@ -250,7 +257,7 @@ export class MongoDialect extends AbstractDialect {
250
257
  */
251
258
  assertKnownPathRoot(meta, key) {
252
259
  const root = key.includes('.') ? key.slice(0, key.indexOf('.')) : key;
253
- if (root === MongoDialect.ID_KEY || root === meta.id || meta.fields[root]) {
260
+ if (root === MongoDialect.ID_KEY || meta.fields[root]) {
254
261
  return;
255
262
  }
256
263
  throw new TypeError(`path ${key} does not exist in ${entityName(meta)}`);
@@ -372,7 +379,7 @@ export class MongoDialect extends AbstractDialect {
372
379
  // MongoDB returns `_id` unless it is explicitly excluded, so subtracting the primary key needs
373
380
  // `_id: 0` - the one inclusion/exclusion mix MongoDB allows - or `$exclude: { id: true }` would
374
381
  // have no effect at all.
375
- if (this.subtractsKey(meta.id, selectMap, exclude)) {
382
+ if (this.subtractsKey(soleIdOf(meta, 'MongoDB'), selectMap, exclude)) {
376
383
  projection[MongoDialect.ID_KEY] = 0;
377
384
  }
378
385
  return projection;
@@ -679,8 +686,9 @@ export class MongoDialect extends AbstractDialect {
679
686
  const res = doc;
680
687
  const _id = MongoDialect.ID_KEY;
681
688
  if (res[_id]) {
682
- res[meta.id] = res[_id];
683
- if (meta.id !== _id) {
689
+ const idKey = soleIdOf(meta, 'MongoDB');
690
+ res[idKey] = res[_id];
691
+ if (idKey !== _id) {
684
692
  delete res[_id];
685
693
  }
686
694
  }
@@ -797,6 +805,9 @@ export class MongoDialect extends AbstractDialect {
797
805
  return [{ $set: assignments }, ...(unset.size > 0 ? [{ $unset: [...unset] }] : [])];
798
806
  }
799
807
  getPersistables(meta, payload, callbackKey) {
808
+ // Not `columnOf`, which maps the primary key to `_id`: an update may not touch `_id` at all, and
809
+ // an insert leaves it to the driver. What that mapping refuses, a write has to refuse too.
810
+ assertSoleId(meta, 'MongoDB');
800
811
  const payloads = fillOnFields(meta, payload, callbackKey);
801
812
  // Keys are resolved per document so heterogeneous payloads keep every provided field.
802
813
  return payloads.map((it) => filterFieldKeys(meta, it, callbackKey).reduce((acc, key) => {
@@ -1,6 +1,6 @@
1
1
  import { COUNT_ALIAS } from '../dialect/aliases.js';
2
2
  import { hasRequiredJoin } from '../dialect/queryJoins.js';
3
- import { getMeta } from '../entity/index.js';
3
+ import { getMeta, idOf, soleIdOf } from '../entity/index.js';
4
4
  import { AbstractQuerier, enrichError } from '../querier/index.js';
5
5
  import { clone, getKeys, getRelationRequestSummary, getSoftDeleteValue, hasKeys, idOnlyQuery, isPagedQuery, populatesRelations, throwNoPendingTransaction, throwPendingTransaction, withoutSoftDeleteFilter, } from '../util/index.js';
6
6
  /**
@@ -202,7 +202,9 @@ export class MongodbQuerier extends AbstractQuerier {
202
202
  const meta = getMeta(entity);
203
203
  const pipeline = this.dialect.aggregationPipeline(entity, idOnlyQuery(meta, q), opts);
204
204
  const founds = await this.execute((session) => this.collection(entity).aggregate(pipeline, { session }).toArray());
205
- return (this.dialect.normalizeIds(meta, founds) || []).map((found) => found[meta.id]);
205
+ // `normalizeIds` has already spread a compound `_id` back into its columns, so the settled rows
206
+ // are named the same way every other driver names them.
207
+ return (this.dialect.normalizeIds(meta, founds) || []).map((found) => idOf(meta, found));
206
208
  }
207
209
  async internalInsertMany(entity, payloads) {
208
210
  return this.timed('internalInsertMany', undefined, async () => {
@@ -214,8 +216,9 @@ export class MongodbQuerier extends AbstractQuerier {
214
216
  const persistables = this.dialect.getPersistables(meta, payloads, 'onInsert');
215
217
  const { insertedIds } = await this.execute((session) => this.collection(entity).insertMany(persistables, { session }));
216
218
  const ids = Object.values(insertedIds);
219
+ const idKey = soleIdOf(meta, 'insert');
217
220
  for (const [index, it] of payloads.entries()) {
218
- it[meta.id] = ids[index];
221
+ it[idKey] = ids[index];
219
222
  }
220
223
  await this.insertRelations(entity, payloads);
221
224
  return ids;
@@ -1,5 +1,5 @@
1
- import type { EntityData, ExtraOptions, FieldKey, IdValue, Querier, Query, QueryAggMap, QueryAggregate, QueryAggregateResult, QueryConflictPaths, QueryFilter, QueryFindResult, QueryGroupMap, QueryOneProjected, QueryOptions, QueryPage, QueryPopulate, QueryProjected, QuerySearch, QueryStreamProjected, QueryUpdateResult, RawRow, RelationKey, TransactionOptions, Type, UpdatePayload } from '../type/index.js';
2
- import { LoggerWrapper } from '../util/index.js';
1
+ import type { EntityData, EntityId, ExtraOptions, FieldKey, IdValue, Querier, Query, QueryAggMap, QueryAggregate, QueryAggregateResult, QueryConflictPaths, QueryFilter, QueryFindResult, QueryGroupMap, QueryOneProjected, QueryOptions, QueryPage, QueryPopulate, QueryProjected, QuerySearch, QueryStreamProjected, QueryUpdateResult, RawRow, RelationKey, TransactionOptions, Type, UpdatePayload } from '../type/index.js';
2
+ import { LoggerWrapper, type ParentJoin } from '../util/index.js';
3
3
  /**
4
4
  * Base class for all database queriers.
5
5
  * It provides a standardized way to execute tasks serially to prevent race conditions on database connections.
@@ -30,7 +30,7 @@ export declare abstract class AbstractQuerier implements Querier {
30
30
  protected resolveEntityQuery<E extends object, Q extends object>(entityOrQuery: Type<E> | (Q & {
31
31
  $entity: Type<E>;
32
32
  }), maybeQueryOrOpts?: Q | QueryOptions, maybeOpts?: QueryOptions): [Type<E>, Q, QueryOptions | undefined];
33
- findOneById<E extends object, const S extends FieldKey<E> = never, const V = true, const X extends FieldKey<E> = never, const P extends RelationKey<E> = never, const C extends RelationKey<E> = never>(entity: Type<E>, id: IdValue<E>, q?: QueryOneProjected<E, S, V, X, P, C>, opts?: QueryOptions): Promise<QueryFindResult<E, S, V, X, P, C> | undefined>;
33
+ findOneById<E extends object, const S extends FieldKey<E> = never, const V = true, const X extends FieldKey<E> = never, const P extends RelationKey<E> = never, const C extends RelationKey<E> = never>(entity: Type<E>, id: EntityId<E>, q?: QueryOneProjected<E, S, V, X, P, C>, opts?: QueryOptions): Promise<QueryFindResult<E, S, V, X, P, C> | undefined>;
34
34
  /**
35
35
  * Find a single record matching the query.
36
36
  * Supports both entity-as-argument and entity-as-field patterns.
@@ -104,16 +104,16 @@ export declare abstract class AbstractQuerier implements Querier {
104
104
  /** Abstract outright: nothing is shared to do around it. See {@link UniversalQuerier.estimatedCount}. */
105
105
  abstract estimatedCount<E extends object>(entity: Type<E>): Promise<number>;
106
106
  insertOne<E extends object>(entity: Type<E>, payload: EntityData<E>): Promise<IdValue<E> | undefined>;
107
- insertMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<IdValue<E>[]>;
108
- protected abstract internalInsertMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<IdValue<E>[]>;
109
- updateOneById<E extends object>(entity: Type<E>, id: IdValue<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<number>;
107
+ insertMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<(IdValue<E> | undefined)[]>;
108
+ protected abstract internalInsertMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<(IdValue<E> | undefined)[]>;
109
+ updateOneById<E extends object>(entity: Type<E>, id: EntityId<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<number>;
110
110
  updateMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<number>;
111
111
  protected abstract internalUpdateMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<number>;
112
- restoreOneById<E extends object>(entity: Type<E>, id: IdValue<E>): Promise<number>;
112
+ restoreOneById<E extends object>(entity: Type<E>, id: EntityId<E>): Promise<number>;
113
113
  restoreMany<E extends object>(entity: Type<E>, q: QuerySearch<E>): Promise<number>;
114
114
  abstract upsertOne<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>): Promise<QueryUpdateResult>;
115
115
  abstract upsertMany<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>[]): Promise<QueryUpdateResult>;
116
- deleteOneById<E extends object>(entity: Type<E>, id: IdValue<E>, opts?: QueryOptions): Promise<number>;
116
+ deleteOneById<E extends object>(entity: Type<E>, id: EntityId<E>, opts?: QueryOptions): Promise<number>;
117
117
  /**
118
118
  * Delete records matching the query. Soft-deletes when the entity has a soft-delete field (unless
119
119
  * `opts.hardDelete`), otherwise removes the rows. Supports both entity-as-argument and entity-as-field patterns.
@@ -132,15 +132,19 @@ export declare abstract class AbstractQuerier implements Querier {
132
132
  */
133
133
  private findDoomed;
134
134
  protected abstract internalDeleteMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, opts?: QueryOptions): Promise<number>;
135
- saveOne<E extends object>(entity: Type<E>, payload: EntityData<E>): Promise<IdValue<E>>;
136
- saveMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<IdValue<E>[]>;
135
+ saveOne<E extends object>(entity: Type<E>, payload: EntityData<E>): Promise<IdValue<E> | undefined>;
136
+ saveMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<(IdValue<E> | undefined)[]>;
137
137
  protected fillToManyRelations<E>(entity: Type<E>, payload: E[], populate?: QueryPopulate<E>): Promise<void>;
138
138
  private fillToManyThroughRelation;
139
139
  private fillToManyOneToMany;
140
- protected putChildrenInParents<E>(parents: E[], children: RawRow[], parentIdKey: keyof E & string, referenceKey: string, relKey: keyof E & string): void;
140
+ protected putChildrenInParents<E>(parents: E[], children: RawRow[], joins: readonly ParentJoin[], relKey: keyof E & string): void;
141
141
  protected insertRelations<E extends object>(entity: Type<E>, payload: E[]): Promise<void>;
142
142
  protected updateRelations<E extends object>(entity: Type<E>, q: QuerySearch<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<void>;
143
- protected deleteRelations<E extends object>(entity: Type<E>, ids: IdValue<E>[], opts?: QueryOptions): Promise<void>;
143
+ /**
144
+ * `EntityId` because a settled set names composite rows as objects, which is also what the parent's
145
+ * own delete takes - and what {@link childrenOf} reads each child's foreign key columns out of.
146
+ */
147
+ protected deleteRelations<E extends object>(entity: Type<E>, ids: EntityId<E>[], opts?: QueryOptions): Promise<void>;
144
148
  /**
145
149
  * Persists `relValue` against every id in `ids`, which an update hands the whole page of rows it
146
150
  * settled: the value is the same for all of them, so the statements are per relation rather than per