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
@@ -1,5 +1,5 @@
1
- import { getMeta } from '../entity/index.js';
2
- import { asSelectMap, augmentWhere, clone, filterPersistableRelationKeys, forEachRequestedRelation, getKeys, getRelationRequestSummary, idOnlyQuery, LoggerWrapper, parentKeyColumn, parseRelationAtKey, parseRelationQueryValue, runHooks, someKey, targetKeyColumn, withoutSoftDeleteFilter, } from '../util/index.js';
1
+ import { getMeta, idOf, soleIdOf } from '../entity/index.js';
2
+ import { asSelectMap, augmentWhere, childrenOf, clone, filterPersistableRelationKeys, forEachRequestedRelation, getKeys, getRelationRequestSummary, idOnlyQuery, isScalarId, LoggerWrapper, parentJoins, parentsIn, parseRelationAtKey, parseRelationQueryValue, rowKey, runHooks, someKey, targetKeyColumns, withoutSoftDeleteFilter, } from '../util/index.js';
3
3
  import { enrichError } from './queryError.js';
4
4
  import { fillRelationCounts, withIdForCounts } from './relationCount.js';
5
5
  /**
@@ -15,6 +15,29 @@ function assertIdValue(entity, id) {
15
15
  if (id === undefined || id === null) {
16
16
  throw new TypeError(`'${entity.name}' was addressed by id, but the id is ${String(id)}`);
17
17
  }
18
+ if (isScalarId(id)) {
19
+ // One value names one column, which `buildQueryWhereAsMap` refuses on a composite.
20
+ return;
21
+ }
22
+ // Every key, or the `$where` names only some of the columns and addresses each row that agrees on
23
+ // those - which for an update or a delete is silently far more than the one row asked for. `{}`
24
+ // reaches here too, and would otherwise be an unfiltered statement over the whole table.
25
+ const given = id;
26
+ const { ids } = getMeta(entity);
27
+ const missing = ids.filter((key) => given[key] == null);
28
+ if (missing.length) {
29
+ throw new TypeError(`'${entity.name}' is addressed by an object carrying every key of its primary key (${ids.join(', ')}); missing ${missing.join(', ')}.`);
30
+ }
31
+ }
32
+ /**
33
+ * The one column a write path matches a parent's key against.
34
+ *
35
+ * Every caller reaches its parents through a sole id - `soleIdOf` refused a composite before any of
36
+ * them - so the parent contributes exactly one column here, whether the relation goes through a
37
+ * junction or straight to the child.
38
+ */
39
+ function soleParentColumn(relOpts) {
40
+ return parentJoins(relOpts, 1)[0].joined;
18
41
  }
19
42
  /**
20
43
  * Base class for all database queriers.
@@ -187,7 +210,11 @@ export class AbstractQuerier {
187
210
  // Where a snapshot was taken, the statement names those rows rather than resolving `q` a second
188
211
  // time. Two reads of one `$limit` with no total order are free to disagree, which would fire the
189
212
  // hooks for one row and delete another; naming them also spares the second read.
190
- const target = doomed ? { $where: doomed.map((it) => it[getMeta(entity).id]) } : q;
213
+ let target = q;
214
+ if (doomed) {
215
+ const meta = getMeta(entity);
216
+ target = { $where: doomed.map((it) => idOf(meta, it)) };
217
+ }
191
218
  await this.emitHook(entity, 'beforeDelete', doomed ?? []);
192
219
  const changes = await this.internalDeleteMany(entity, target, opts);
193
220
  await this.emitHook(entity, 'afterDelete', doomed ?? []);
@@ -218,12 +245,16 @@ export class AbstractQuerier {
218
245
  const toInsert = [];
219
246
  const toUpdate = [];
220
247
  const existingIds = [];
248
+ // Save reads an id as proof the row exists, and inserts the rest. A composite key is supplied by
249
+ // the caller on every row, insert included, so that proof does not exist for one: telling the two
250
+ // apart takes a read, which is `upsertMany`'s job and not this one's.
251
+ const idKey = soleIdOf(meta, 'saving a row');
221
252
  for (const it of payload) {
222
- const id = it[meta.id];
253
+ const id = it[idKey];
223
254
  if (!id) {
224
255
  toInsert.push(it);
225
256
  }
226
- else if (!someKey(it, (key) => key !== meta.id)) {
257
+ else if (!someKey(it, (key) => key !== idKey)) {
227
258
  existingIds.push(id);
228
259
  }
229
260
  else {
@@ -233,9 +264,9 @@ export class AbstractQuerier {
233
264
  const [insertedIds, updatedIds] = await Promise.all([
234
265
  toInsert.length ? this.insertMany(entity, toInsert) : [],
235
266
  Promise.all(toUpdate.map(async (it) => {
236
- const id = it[meta.id];
267
+ const id = it[idKey];
237
268
  const data = { ...it };
238
- delete data[meta.id];
269
+ delete data[idKey];
239
270
  await this.updateOneById(entity, id, data);
240
271
  return id;
241
272
  })),
@@ -263,11 +294,11 @@ export class AbstractQuerier {
263
294
  }
264
295
  }
265
296
  async fillToManyThroughRelation(payload, meta, relKey, relOpts, relationQuery) {
266
- const localField = parentKeyColumn(relOpts);
297
+ const joins = parentJoins(relOpts, meta.ids.length);
298
+ const [targetColumn] = targetKeyColumns(relOpts, meta.ids.length);
267
299
  const throughEntity = relOpts.through();
268
300
  const throughMeta = getMeta(throughEntity);
269
- const targetRelKey = getKeys(throughMeta.relations).find((key) => throughMeta.relations[key]?.references.some(({ local }) => local === targetKeyColumn(relOpts)));
270
- const ids = payload.map((it) => it[meta.id]);
301
+ const targetRelKey = getKeys(throughMeta.relations).find((key) => throughMeta.relations[key]?.references.some(({ local }) => local === targetColumn));
271
302
  // A relation query names the target's columns, not the join table's, so the projection and the
272
303
  // filter belong on the populate below - resolved there against the entity that has them. Spread
273
304
  // onto the through query they asked `ItemTag` for `Tag`'s columns: `$where`/`$sort` failed with
@@ -275,53 +306,52 @@ export class AbstractQuerier {
275
306
  const { $select: _select, $exclude: _exclude, $where: _where, ...throughQuery } = relationQuery;
276
307
  const throughFounds = await this.findMany(throughEntity, {
277
308
  ...throughQuery,
278
- $select: {
279
- [localField]: true,
280
- },
309
+ $select: Object.fromEntries(joins.map(({ joined }) => [joined, true])),
281
310
  $populate: {
282
311
  [targetRelKey]: {
283
312
  ...relationQuery,
284
313
  $required: true,
285
314
  },
286
315
  },
287
- $where: {
288
- [localField]: ids,
289
- },
316
+ $where: parentsIn(joins, payload),
290
317
  });
291
318
  const founds = throughFounds.map((it) => ({
292
319
  ...it[targetRelKey],
293
- [localField]: it[localField],
320
+ ...Object.fromEntries(joins.map(({ joined }) => [joined, it[joined]])),
294
321
  }));
295
- this.putChildrenInParents(payload, founds, meta.id, localField, relKey);
322
+ this.putChildrenInParents(payload, founds, joins, relKey);
296
323
  }
297
324
  async fillToManyOneToMany(payload, meta, relKey, relOpts, relationQuery, relEntity) {
298
- const foreignField = parentKeyColumn(relOpts);
325
+ const joins = parentJoins(relOpts, meta.ids.length);
299
326
  // The FK is what putChildrenInParents groups on, so it outlives the relation's projection
300
327
  // either way: added to a whitelisting `$select` (the raw-array form has nothing to augment),
301
328
  // dropped from a subtractive `$exclude`. `relationQuery` is already a clone.
302
329
  const select = asSelectMap(relationQuery.$select);
303
- if (select && !select[foreignField]) {
304
- select[foreignField] = true;
330
+ const exclude = relationQuery.$exclude;
331
+ for (const { joined } of joins) {
332
+ if (select && !select[joined]) {
333
+ select[joined] = true;
334
+ }
335
+ delete exclude?.[joined];
305
336
  }
306
- delete relationQuery.$exclude?.[foreignField];
307
- const ids = payload.map((it) => it[meta.id]);
308
- relationQuery.$where = { ...relationQuery.$where, [foreignField]: ids };
337
+ relationQuery.$where = { ...relationQuery.$where, ...parentsIn(joins, payload) };
309
338
  const founds = await this.findMany(relEntity, relationQuery);
310
- this.putChildrenInParents(payload, founds, meta.id, foreignField, relKey);
339
+ this.putChildrenInParents(payload, founds, joins, relKey);
311
340
  }
312
- putChildrenInParents(parents, children, parentIdKey, referenceKey, relKey) {
341
+ putChildrenInParents(parents, children, joins, relKey) {
313
342
  const childrenByParentId = {};
314
343
  for (const child of children) {
315
- const parentId = String(child[referenceKey]);
316
- if (!childrenByParentId[parentId])
317
- childrenByParentId[parentId] = [];
318
- childrenByParentId[parentId].push(child);
344
+ // Every joined column, so two children agreeing on one column of a composite key are not
345
+ // gathered under the same parent.
346
+ const parentId = rowKey(joins.map(({ joined }) => child[joined]));
347
+ (childrenByParentId[parentId] ??= []).push(child);
319
348
  }
320
349
  for (const parent of parents) {
321
350
  // `[]` rather than nothing for a parent with no children: a populated to-many is a list the
322
351
  // caller asked for, so it maps and counts without a guard, and its type can say so. An
323
352
  // unpopulated one stays absent, which is what tells the two apart.
324
- parent[relKey] = (childrenByParentId[String(parent[parentIdKey])] ?? []);
353
+ const key = rowKey(joins.map(({ parent: parentKey }) => parent[parentKey]));
354
+ parent[relKey] = (childrenByParentId[key] ?? []);
325
355
  }
326
356
  }
327
357
  async insertRelations(entity, payload) {
@@ -334,7 +364,8 @@ export class AbstractQuerier {
334
364
  }, []);
335
365
  if (!entries.length)
336
366
  return;
337
- await Promise.all(entries.map(({ it, relKeys }) => Promise.all(relKeys.map((relKey) => this.saveRelation(entity, [it[meta.id]], it[relKey], relKey)))));
367
+ const idKey = soleIdOf(meta, 'saving a relation');
368
+ await Promise.all(entries.map(({ it, relKeys }) => Promise.all(relKeys.map((relKey) => this.saveRelation(entity, [it[idKey]], it[relKey], relKey)))));
338
369
  }
339
370
  async updateRelations(entity, q, payload, opts) {
340
371
  const meta = getMeta(entity);
@@ -342,8 +373,9 @@ export class AbstractQuerier {
342
373
  if (!relKeys.length) {
343
374
  return;
344
375
  }
376
+ const idKey = soleIdOf(meta, 'saving a relation');
345
377
  const founds = await this.findMany(entity, idOnlyQuery(meta, q), opts);
346
- const ids = founds.map((found) => found[meta.id]);
378
+ const ids = founds.map((found) => found[idKey]);
347
379
  if (!ids.length) {
348
380
  return;
349
381
  }
@@ -351,6 +383,10 @@ export class AbstractQuerier {
351
383
  await this.saveRelation(entity, ids, payload[relKey], relKey, true);
352
384
  }
353
385
  }
386
+ /**
387
+ * `EntityId` because a settled set names composite rows as objects, which is also what the parent's
388
+ * own delete takes - and what {@link childrenOf} reads each child's foreign key columns out of.
389
+ */
354
390
  async deleteRelations(entity, ids, opts) {
355
391
  const meta = getMeta(entity);
356
392
  const relKeys = filterPersistableRelationKeys(meta, meta.relations, 'delete');
@@ -360,9 +396,9 @@ export class AbstractQuerier {
360
396
  if (!relOpts)
361
397
  continue;
362
398
  const relEntity = relOpts.entity();
363
- const parentKey = parentKeyColumn(relOpts);
364
399
  const target = relOpts.through ? relOpts.through() : relEntity;
365
- await this.deleteMany(target, { $where: { [parentKey]: ids } }, opts);
400
+ const where = childrenOf(parentJoins(relOpts, meta.ids.length), ids);
401
+ await this.deleteMany(target, { $where: where }, opts);
366
402
  }
367
403
  }
368
404
  /**
@@ -391,7 +427,8 @@ export class AbstractQuerier {
391
427
  async saveToMany(relOpts, relEntity, ids, relPayload, isUpdate) {
392
428
  const { through } = relOpts;
393
429
  if (through) {
394
- const localField = parentKeyColumn(relOpts);
430
+ const localField = soleParentColumn(relOpts);
431
+ const [targetColumn] = targetKeyColumns(relOpts, 1);
395
432
  const throughEntity = through();
396
433
  if (isUpdate) {
397
434
  await this.deleteMany(throughEntity, { $where: { [localField]: ids } });
@@ -401,12 +438,12 @@ export class AbstractQuerier {
401
438
  // would link every parent to a single shared row instead.
402
439
  for (const id of ids) {
403
440
  const savedIds = await this.saveMany(relEntity, relPayload);
404
- await this.insertMany(throughEntity, savedIds.map((relId) => ({ [localField]: id, [targetKeyColumn(relOpts)]: relId })));
441
+ await this.insertMany(throughEntity, savedIds.map((relId) => ({ [localField]: id, [targetColumn]: relId })));
405
442
  }
406
443
  }
407
444
  return;
408
445
  }
409
- const foreignField = parentKeyColumn(relOpts);
446
+ const foreignField = soleParentColumn(relOpts);
410
447
  if (isUpdate) {
411
448
  await this.deleteMany(relEntity, { $where: { [foreignField]: ids } });
412
449
  }
@@ -415,7 +452,7 @@ export class AbstractQuerier {
415
452
  }
416
453
  }
417
454
  async saveOneToOne(relEntity, relOpts, ids, relPayload) {
418
- const foreignField = parentKeyColumn(relOpts);
455
+ const foreignField = soleParentColumn(relOpts);
419
456
  if (relPayload === null) {
420
457
  await this.deleteMany(relEntity, { $where: { [foreignField]: ids } });
421
458
  return;
@@ -423,7 +460,7 @@ export class AbstractQuerier {
423
460
  await this.saveMany(relEntity, ids.map((id) => ({ ...relPayload, [foreignField]: id })));
424
461
  }
425
462
  async saveManyToOne(entity, relEntity, relOpts, ids, relPayload) {
426
- // Not `parentKeyColumn`: a many-to-one points the other way, so this is the *parent's* own column
463
+ // Not `soleParentColumn`: a many-to-one points the other way, so this is the *parent's* own column
427
464
  // holding the child's id - the one place `references[0].local` does not name a key of the parent.
428
465
  const localField = relOpts.references[0].local;
429
466
  // Per parent: each gets its own reference row, so each `SET` carries a different value.
@@ -1,5 +1,5 @@
1
1
  import type { AbstractDialect } from '../dialect/index.js';
2
- import type { EntityData, ExtraOptions, FieldKey, IdValue, PoolRunOptions, Querier, QuerierPool, QueryAggMap, QueryAggregate, QueryAggregateResult, QueryConflictPaths, QueryFilter, QueryFindResult, QueryGroupMap, QueryOneProjected, QueryOptions, QueryPage, QueryProjected, QuerySearch, QueryStreamProjected, QueryUpdateResult, RelationKey, TransactionOptions, Type, UpdatePayload } from '../type/index.js';
2
+ import type { EntityData, EntityId, ExtraOptions, FieldKey, IdValue, PoolRunOptions, Querier, QuerierPool, QueryAggMap, QueryAggregate, QueryAggregateResult, QueryConflictPaths, QueryFilter, QueryFindResult, QueryGroupMap, QueryOneProjected, QueryOptions, QueryPage, QueryProjected, QuerySearch, QueryStreamProjected, QueryUpdateResult, RelationKey, TransactionOptions, Type, UpdatePayload } from '../type/index.js';
3
3
  /**
4
4
  * Base pool: dialect id and behavior come only from the `dialect` instance (see {@link QuerierPool}).
5
5
  */
@@ -24,7 +24,7 @@ export declare abstract class AbstractQuerierPool<Q extends Querier, D extends A
24
24
  withQuerier<T>(callback: (querier: Q) => Promise<T>, opts?: PoolRunOptions): Promise<T>;
25
25
  /** Run `fn` under `context` (an enclosing {@link withContext}) when provided, else run it as-is. */
26
26
  private runScoped;
27
- 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>;
27
+ 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>;
28
28
  findOne<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>, q: QueryOneProjected<E, S, V, X, P, C>, opts?: QueryOptions): Promise<QueryFindResult<E, S, V, X, P, C> | undefined>;
29
29
  findMany<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>, q: QueryProjected<E, S, V, X, P, C>, opts?: QueryOptions): Promise<QueryFindResult<E, S, V, X, P, C>[]>;
30
30
  /**
@@ -38,16 +38,16 @@ export declare abstract class AbstractQuerierPool<Q extends Querier, D extends A
38
38
  aggregate<E extends object, const G extends QueryGroupMap<E>, const A extends QueryAggMap<E>>(entity: Type<E>, q: QueryAggregate<E, G, A>, opts?: QueryOptions): Promise<QueryAggregateResult<E, G, A>[]>;
39
39
  estimatedCount<E extends object>(entity: Type<E>): Promise<number>;
40
40
  insertOne<E extends object>(entity: Type<E>, payload: EntityData<E>): Promise<IdValue<E> | undefined>;
41
- insertMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<IdValue<E>[]>;
42
- updateOneById<E extends object>(entity: Type<E>, id: IdValue<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<number>;
41
+ insertMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<(IdValue<E> | undefined)[]>;
42
+ updateOneById<E extends object>(entity: Type<E>, id: EntityId<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<number>;
43
43
  updateMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<number>;
44
44
  upsertOne<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>): Promise<QueryUpdateResult>;
45
45
  upsertMany<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>[]): Promise<QueryUpdateResult>;
46
- saveOne<E extends object>(entity: Type<E>, payload: EntityData<E>): Promise<IdValue<E>>;
47
- saveMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<IdValue<E>[]>;
48
- deleteOneById<E extends object>(entity: Type<E>, id: IdValue<E>, opts?: QueryOptions): Promise<number>;
46
+ saveOne<E extends object>(entity: Type<E>, payload: EntityData<E>): Promise<IdValue<E> | undefined>;
47
+ saveMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<(IdValue<E> | undefined)[]>;
48
+ deleteOneById<E extends object>(entity: Type<E>, id: EntityId<E>, opts?: QueryOptions): Promise<number>;
49
49
  deleteMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, opts?: QueryOptions): Promise<number>;
50
- restoreOneById<E extends object>(entity: Type<E>, id: IdValue<E>): Promise<number>;
50
+ restoreOneById<E extends object>(entity: Type<E>, id: EntityId<E>): Promise<number>;
51
51
  restoreMany<E extends object>(entity: Type<E>, q: QuerySearch<E>): Promise<number>;
52
52
  /**
53
53
  * end the pool.
@@ -105,7 +105,7 @@ export declare abstract class AbstractSqlQuerier extends AbstractQuerier impleme
105
105
  protected internalCount<E extends object>(entity: Type<E>, q?: QueryFilter<E>, opts?: QueryOptions): Promise<number>;
106
106
  estimatedCount<E extends object>(entity: Type<E>): Promise<number>;
107
107
  protected internalAggregate<E extends object, G extends QueryGroupMap<E>, A extends QueryAggMap<E>>(entity: Type<E>, q: QueryAggregate<E, G, A>, opts?: QueryOptions): Promise<QueryAggregateResult<E, G, A>[]>;
108
- internalInsertMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<IdValue<E>[]>;
108
+ internalInsertMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<(IdValue<E> | undefined)[]>;
109
109
  internalUpdateMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<number>;
110
110
  /** The ids matching `q`, in `q`'s own order and page, so a write can name the rows it settled on. */
111
111
  private settleIds;
@@ -1,6 +1,6 @@
1
1
  import { COUNT_ALIAS, TOTAL_ALIAS } from '../dialect/aliases.js';
2
2
  import { decodeColumn } from '../dialect/hydrateColumn.js';
3
- import { getMeta } from '../entity/index.js';
3
+ import { getMeta, idOf, soleIdOf } from '../entity/index.js';
4
4
  import { buildUpdateResult, cascadesOnDelete, clone, getInsertFieldKeys, getRelationRequestSummary, hasKeys, idOnlyQuery, isAutoIncrement, isPagedQuery, obtainAttrsPaths, throwNoPendingTransaction, throwPendingTransaction, unflatObject, unflatObjects, withoutSoftDeleteFilter, } from '../util/index.js';
5
5
  import { AbstractQuerier } from './abstractQuerier.js';
6
6
  import { enrichError } from './queryError.js';
@@ -283,14 +283,19 @@ export class AbstractSqlQuerier extends AbstractQuerier {
283
283
  }
284
284
  payload = clone(payload);
285
285
  const meta = getMeta(entity);
286
- const idKey = meta.id;
287
- const idField = meta.fields[idKey];
286
+ // A composite key is supplied whole by the caller, so nothing is generated and nothing comes
287
+ // back: the rows insert, and their ids are `undefined` for the reason MySQL's are below - there
288
+ // is no id to report. `idOf` names such a row for the caller that wants one.
289
+ const [idKey] = meta.ids;
290
+ const sole = meta.ids.length === 1;
291
+ const idField = sole ? meta.fields[idKey] : undefined;
288
292
  // RETURNING-based IDs are exact per row. Header-derived IDs (LAST_INSERT_ID /
289
293
  // lastInsertRowid arithmetic) are only sound when the primary key is database-generated
290
294
  // and no record supplies an explicit ID (a mixed batch shifts the positional mapping and
291
295
  // MySQL stops guaranteeing consecutive values); otherwise generated IDs stay `undefined`.
292
- const idsReliable = this.dialect.insertIdSource === 'returning' ||
293
- (!!idField && isAutoIncrement(idField, true) && payload.every((it) => it[idKey] === undefined));
296
+ const idsReliable = sole &&
297
+ (this.dialect.insertIdSource === 'returning' ||
298
+ (!!idField && isAutoIncrement(idField, true) && payload.every((it) => it[idKey] === undefined)));
294
299
  // Inferring multiple ids from the single header id (MySQL) assumes a known stride; a clustered
295
300
  // server may set `auto_increment_increment` > 1, so probe it (once, cached) before inferring.
296
301
  if (idsReliable && payload.length > 1 && this.dialect.insertIdSource === 'firstId') {
@@ -309,7 +314,7 @@ export class AbstractSqlQuerier extends AbstractQuerier {
309
314
  if (idsReliable) {
310
315
  it[idKey] ??= ids[index];
311
316
  }
312
- payloadIds.push(it[idKey]);
317
+ payloadIds.push(sole ? it[idKey] : undefined);
313
318
  });
314
319
  }
315
320
  await this.insertRelations(entity, payload);
@@ -339,7 +344,7 @@ export class AbstractSqlQuerier extends AbstractQuerier {
339
344
  const ctx = this.dialect.createContext();
340
345
  this.dialect.find(ctx, entity, idOnlyQuery(meta, q), opts);
341
346
  const founds = await this.all(ctx.sql, ctx.values);
342
- return founds.map((it) => it[meta.id]);
347
+ return founds.map((found) => idOf(meta, found));
343
348
  }
344
349
  async upsertOne(entity, conflictPaths, payload) {
345
350
  return this.upsertMany(entity, conflictPaths, [payload]);
@@ -1,7 +1,7 @@
1
1
  import { COUNT_ALIAS } from '../dialect/aliases.js';
2
- import { getMeta } from '../entity/index.js';
2
+ import { getMeta, soleIdOf } from '../entity/index.js';
3
3
  import { COUNT_RESULT_KEY } from '../type/index.js';
4
- import { asSelectMap, getKeys, parentKeyColumn, targetKeyColumn } from '../util/index.js';
4
+ import { asSelectMap, getKeys, parentJoins, parentsIn, rowKey, targetKeyColumns, } from '../util/index.js';
5
5
  /**
6
6
  * A `$count` groups its tallies by the parent's id, so the id has to outlive the projection - the
7
7
  * same reason populating a relation keeps it. A whitelisting `$select` gains the key and an
@@ -9,27 +9,33 @@ import { asSelectMap, getKeys, parentKeyColumn, targetKeyColumn } from '../util/
9
9
  */
10
10
  export function withIdForCounts(entity, q) {
11
11
  const meta = getMeta(entity);
12
- if (!q.$count || !meta.id) {
12
+ if (!q.$count) {
13
13
  return q;
14
14
  }
15
+ const { ids } = meta;
16
+ const named = ids.join(', ');
15
17
  if (q.$distinct) {
16
- // The id would have to join the projection for the tallies to group by, and that is the set
17
- // `$distinct` collapses on: adding it silently stops the grouping collapsing anything at all.
18
- throw new TypeError(`$count cannot be combined with $distinct: the tallies group by each row's '${String(meta.id)}', which the grouping does not keep.`);
18
+ // The keys would have to join the projection for the tallies to group by, and that is the set
19
+ // `$distinct` collapses on: adding them silently stops the grouping collapsing anything at all.
20
+ throw new TypeError(`$count cannot be combined with $distinct: the tallies group by each row's '${named}', which the grouping does not keep.`);
19
21
  }
20
22
  if (Array.isArray(q.$select)) {
21
- // Nothing to add the id to, and without it every tally would be looked up by `undefined` and
23
+ // Nothing to add the keys to, and without them every tally would be looked up by `undefined` and
22
24
  // come back zero. Refused rather than answered wrong, the way an unorderable clause is.
23
- throw new TypeError(`$count needs the '${String(meta.id)}' of each row to group its tallies by, which a raw $select cannot carry. Use a $select map, or drop the $count.`);
25
+ throw new TypeError(`$count needs the '${named}' of each row to group its tallies by, which a raw $select cannot carry. Use a $select map, or drop the $count.`);
24
26
  }
25
27
  const next = { ...q };
26
28
  const select = asSelectMap(q.$select);
27
- if (select && !select[meta.id]) {
28
- next.$select = { ...select, [meta.id]: true };
29
+ const missing = select ? ids.filter((key) => !select[key]) : [];
30
+ if (missing.length) {
31
+ next.$select = { ...select, ...Object.fromEntries(missing.map((key) => [key, true])) };
29
32
  }
30
- if (q.$exclude?.[meta.id]) {
33
+ const excluded = ids.filter((key) => q.$exclude?.[key]);
34
+ if (excluded.length) {
31
35
  const kept = { ...q.$exclude };
32
- delete kept[meta.id];
36
+ for (const key of excluded) {
37
+ delete kept[key];
38
+ }
33
39
  next.$exclude = kept;
34
40
  }
35
41
  return next;
@@ -44,9 +50,6 @@ export async function fillRelationCounts(querier, entity, payload, count) {
44
50
  return;
45
51
  }
46
52
  const meta = getMeta(entity);
47
- // The ids cross the same boundary the entity does: past here every column is named by metadata,
48
- // so they are read as {@link CountedRow}'s values rather than as this entity's own id type.
49
- const ids = payload.map((it) => it[meta.id]);
50
53
  const counted = new Map();
51
54
  for (const relKey of getKeys(count)) {
52
55
  const value = count[relKey];
@@ -55,10 +58,11 @@ export async function fillRelationCounts(querier, entity, payload, count) {
55
58
  continue;
56
59
  }
57
60
  const where = typeof value === 'object' ? value.$where : undefined;
58
- counted.set(relKey, await countPerParent(querier, relOpts, ids, where));
61
+ const joins = parentJoins(relOpts, meta.ids.length);
62
+ counted.set(relKey, await countPerParent(querier, relOpts, joins, payload, where));
59
63
  }
60
64
  for (const parent of payload) {
61
- const id = String(parent[meta.id]);
65
+ const id = rowKey(meta.ids.map((key) => parent[key]));
62
66
  const row = {};
63
67
  for (const [relKey, byParent] of counted) {
64
68
  // A parent the grouped result has no row for matched nothing, which is a zero rather than a
@@ -68,37 +72,42 @@ export async function fillRelationCounts(querier, entity, payload, count) {
68
72
  parent[COUNT_RESULT_KEY] = row;
69
73
  }
70
74
  }
71
- async function countPerParent(querier, relOpts, ids, where) {
75
+ async function countPerParent(querier, relOpts, joins, parents, where) {
72
76
  const through = relOpts.through;
73
77
  if (through) {
74
- return countThroughPerParent(querier, relOpts, through(), ids, where);
78
+ return countThroughPerParent(querier, relOpts, through(), joins, parents, where);
75
79
  }
76
- const foreign = parentKeyColumn(relOpts);
77
- return groupedCount(querier, relOpts.entity(), foreign, { ...where, [foreign]: ids });
80
+ return groupedCount(querier, relOpts.entity(), joins, {
81
+ ...where,
82
+ ...parentsIn(joins, parents),
83
+ });
78
84
  }
79
85
  /**
80
86
  * A many-to-many counts its junction rows, one per pairing. A filter names the target's columns,
81
87
  * which the junction does not have, so the matching targets are resolved first and the junction
82
88
  * counted against them - the one shape here that costs a second statement, and only when filtered.
83
89
  */
84
- async function countThroughPerParent(querier, relOpts, throughEntity, ids, where) {
85
- const local = parentKeyColumn(relOpts);
86
- const throughWhere = { [local]: ids };
90
+ async function countThroughPerParent(querier, relOpts, throughEntity, joins, parents, where) {
91
+ const throughWhere = parentsIn(joins, parents);
87
92
  if (where) {
88
93
  const target = relOpts.entity();
89
- const targetId = getMeta(target).id;
94
+ const targetId = soleIdOf(getMeta(target), 'a many-to-many target');
90
95
  const targets = await querier.findMany(target, { $select: { [targetId]: true }, $where: where });
91
- throughWhere[targetKeyColumn(relOpts)] = targets.map((it) => it[targetId]);
96
+ const [targetColumn] = targetKeyColumns(relOpts, joins.length);
97
+ throughWhere[targetColumn] = targets.map((it) => it[targetId]);
92
98
  }
93
- return groupedCount(querier, throughEntity, local, throughWhere);
99
+ return groupedCount(querier, throughEntity, joins, throughWhere);
94
100
  }
95
- /** `SELECT <key>, COUNT(*) ... GROUP BY <key>`, as a lookup from parent key to tally. */
96
- async function groupedCount(querier, entity, groupKey, where) {
101
+ /** `SELECT <keys>, COUNT(*) ... GROUP BY <keys>`, as a lookup from parent key to tally. */
102
+ async function groupedCount(querier, entity, joins, where) {
97
103
  const $agg = { [COUNT_ALIAS]: { $count: '*' } };
98
- const rows = await querier.aggregate(entity, { $group: { [groupKey]: true }, $agg, $where: where });
104
+ const $group = Object.fromEntries(joins.map(({ joined }) => [joined, true]));
105
+ const rows = await querier.aggregate(entity, { $group, $agg, $where: where });
99
106
  const byParent = {};
100
107
  for (const row of rows) {
101
- byParent[String(row[groupKey])] = Number(row[COUNT_ALIAS]);
108
+ // Keyed by every joined column, which is how a tally finds the one parent whose whole key it
109
+ // matches - and how the rows an over-selecting `IN` brought back find no parent at all.
110
+ byParent[rowKey(joins.map(({ joined }) => row[joined]))] = Number(row[COUNT_ALIAS]);
102
111
  }
103
112
  return byParent;
104
113
  }
@@ -5,7 +5,7 @@
5
5
  * - Entity metadata (decorator-based entities)
6
6
  * - Database introspection results (TableSchema[])
7
7
  */
8
- import { getMeta } from '../entity/metadata/definition.js';
8
+ import { getMeta, soleIdOf } from '../entity/metadata/definition.js';
9
9
  import { derivedForeignKeyName, derivedIndexName, qualifyName } from '../util/sql.util.js';
10
10
  import { fieldOptionsToCanonical } from './canonicalType.js';
11
11
  import { createTableNode, SchemaAST } from './schemaAST.js';
@@ -57,7 +57,9 @@ function resolveColumnCanonicalType(field, seen = new Set()) {
57
57
  if (!hasExplicitType && field.references && !seen.has(field.references)) {
58
58
  seen.add(field.references);
59
59
  const referencedMeta = getMeta(field.references());
60
- const referencedIdField = referencedMeta.fields[referencedMeta.id];
60
+ // The column names which key it points at when the target has several; otherwise there is one.
61
+ const referencedKey = field.referencedKey ?? soleIdOf(referencedMeta, 'a foreign key');
62
+ const referencedIdField = referencedMeta.fields[referencedKey];
61
63
  if (referencedIdField) {
62
64
  return resolveColumnCanonicalType(referencedIdField, seen);
63
65
  }
@@ -83,7 +85,8 @@ function addTableFromEntity(ctx, meta) {
83
85
  continue;
84
86
  const columnName = ctx.resolveColumnName(key, field);
85
87
  const type = resolveColumnCanonicalType(field);
86
- const isPrimaryKey = key === meta.id;
88
+ const isPrimaryKey = field.isId === true;
89
+ const isSoleKey = isPrimaryKey && meta.ids.length === 1;
87
90
  const column = {
88
91
  name: columnName,
89
92
  type,
@@ -92,7 +95,9 @@ function addTableFromEntity(ctx, meta) {
92
95
  nullable: isPrimaryKey ? false : (field.nullable ?? true),
93
96
  defaultValue: field.defaultValue,
94
97
  isPrimaryKey,
95
- isAutoIncrement: field.autoIncrement ?? (isPrimaryKey && type.category === 'integer'),
98
+ // Only a sole integer key defaults to auto-increment: a composite's columns are values the
99
+ // caller supplies, and the serial type carries an inline `PRIMARY KEY` the table already states.
100
+ isAutoIncrement: field.autoIncrement ?? (isPrimaryKey && isSoleKey && type.category === 'integer'),
96
101
  isUnique: field.unique ?? false,
97
102
  comment: field.comment,
98
103
  enum: field.enum,
@@ -101,7 +106,7 @@ function addTableFromEntity(ctx, meta) {
101
106
  references: undefined,
102
107
  };
103
108
  columns.set(columnName, column);
104
- if (key === meta.id) {
109
+ if (field.isId) {
105
110
  primaryKey.push(column);
106
111
  }
107
112
  }
@@ -133,27 +138,35 @@ function addRelationshipsFromEntity(ctx, meta) {
133
138
  // user_profile(creatorId)`), which SQLite rejects outright as a foreign key mismatch.
134
139
  const ownsForeignKey = relation.cardinality === 'm1' || (relation.cardinality === '11' && !relation.mappedBy);
135
140
  if (ownsForeignKey) {
136
- const localPropName = relation.references[0].local;
137
- const foreignPropName = relation.references[0].foreign;
138
- const localField = meta.fields[localPropName];
139
- if (!localField)
140
- continue;
141
- const localColName = ctx.resolveColumnName(localPropName, localField);
142
- const foreignField = relatedMeta.fields[foreignPropName];
143
- if (!foreignField)
144
- continue;
145
- const foreignColName = ctx.resolveColumnName(foreignPropName, foreignField);
146
- const localColumn = table.columns.get(localColName);
147
- const foreignColumn = relatedTable.columns.get(foreignColName);
148
- if (localColumn && foreignColumn) {
141
+ // Every pair, not just the first: a composite key is one constraint over all its columns, and
142
+ // the engine requires the referenced columns to match a unique constraint as a whole.
143
+ const localColumns = [];
144
+ const foreignColumns = [];
145
+ let firstLocalField;
146
+ for (const { local: localPropName, foreign: foreignPropName } of relation.references) {
147
+ const localField = meta.fields[localPropName];
148
+ const foreignField = relatedMeta.fields[foreignPropName];
149
+ if (!localField || !foreignField) {
150
+ continue;
151
+ }
152
+ const localColumn = table.columns.get(ctx.resolveColumnName(localPropName, localField));
153
+ const foreignColumn = relatedTable.columns.get(ctx.resolveColumnName(foreignPropName, foreignField));
154
+ if (!localColumn || !foreignColumn) {
155
+ continue;
156
+ }
157
+ firstLocalField ??= localField;
158
+ localColumns.push(localColumn);
159
+ foreignColumns.push(foreignColumn);
160
+ }
161
+ if (localColumns.length === relation.references.length && firstLocalField) {
149
162
  const relNode = {
150
- name: derivedForeignKeyName(table.name, [localColName]),
163
+ name: derivedForeignKeyName(table.name, localColumns.map((column) => column.name)),
151
164
  type: relation.cardinality === 'm1' ? 'ManyToOne' : 'OneToOne',
152
- from: { table, columns: [localColumn] },
153
- to: { table: relatedTable, columns: [foreignColumn] },
165
+ from: { table, columns: localColumns },
166
+ to: { table: relatedTable, columns: foreignColumns },
154
167
  // Falls back to the FK column's own `onDelete`, which is what makes a bare `@Field({
155
168
  // references, onDelete })` work with no relation declared at all.
156
- onDelete: relation.onDelete ?? localField.onDelete ?? ctx.defaultForeignKeyAction,
169
+ onDelete: relation.onDelete ?? firstLocalField.onDelete ?? ctx.defaultForeignKeyAction,
157
170
  onUpdate: relation.onUpdate ?? ctx.defaultForeignKeyAction,
158
171
  confidence: 1.0,
159
172
  inferredFrom: 'entity_decorator',