uql-orm 0.41.1 → 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 (40) 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 +10 -2
  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 +91 -36
  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/schemaGenerator.d.ts +2 -2
  18. package/dist/migrate/schemaGenerator.js +3 -3
  19. package/dist/mongo/mongoDialect.js +17 -6
  20. package/dist/mongo/mongodbQuerier.js +6 -3
  21. package/dist/querier/abstractQuerier.d.ts +16 -12
  22. package/dist/querier/abstractQuerier.js +77 -40
  23. package/dist/querier/abstractQuerierPool.d.ts +8 -8
  24. package/dist/querier/abstractSqlQuerier.d.ts +1 -1
  25. package/dist/querier/abstractSqlQuerier.js +12 -7
  26. package/dist/querier/relationCount.js +40 -31
  27. package/dist/schema/schemaASTBuilder.js +35 -22
  28. package/dist/type/entity.d.ts +57 -14
  29. package/dist/type/queryWhere.d.ts +7 -2
  30. package/dist/type/universalQuerier.d.ts +8 -8
  31. package/dist/util/dialect.util.js +15 -14
  32. package/dist/util/index.d.ts +1 -0
  33. package/dist/util/index.js +1 -0
  34. package/dist/util/object.util.d.ts +6 -0
  35. package/dist/util/object.util.js +10 -0
  36. package/dist/util/relationQuery.util.d.ts +38 -7
  37. package/dist/util/relationQuery.util.js +50 -9
  38. package/dist/util/rowKey.util.d.ts +9 -0
  39. package/dist/util/rowKey.util.js +24 -0
  40. package/package.json +1 -1
@@ -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,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`
@@ -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,5 +1,5 @@
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';
@@ -269,7 +269,7 @@ export class SqlSchemaGenerator {
269
269
  if (field.references) {
270
270
  const refEntity = field.references();
271
271
  const refMeta = getMeta(refEntity);
272
- const refIdField = refMeta.fields[refMeta.id];
272
+ const refIdField = refMeta.fields[field.referencedKey ?? soleIdOf(refMeta, 'a foreign key')];
273
273
  return this.getSqlType({ ...refIdField, references: undefined, isId: undefined, autoIncrement: false }, refIdField.type);
274
274
  }
275
275
  // Get canonical type and convert to SQL
@@ -410,7 +410,7 @@ export class SqlSchemaGenerator {
410
410
  * would create for this field, against what it reported for the existing column.
411
411
  */
412
412
  fieldToColumnSchema(fieldKey, field, meta) {
413
- const isPrimaryKey = field.isId === true && meta.id === fieldKey;
413
+ const isPrimaryKey = field.isId === true;
414
414
  return {
415
415
  name: this.dialect.resolveColumnName(fieldKey, field),
416
416
  type: this.getSqlType(field, field.type),
@@ -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