uql-orm 0.47.0 → 0.48.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 (36) hide show
  1. package/dist/browser/querier/httpQuerier.d.ts +2 -2
  2. package/dist/browser/type/clientQuerier.d.ts +3 -3
  3. package/dist/browser/uql-browser.min.js.map +2 -2
  4. package/dist/dialect/abstractDialect.d.ts +35 -1
  5. package/dist/dialect/abstractDialect.js +29 -0
  6. package/dist/dialect/abstractSqlDialect.d.ts +13 -5
  7. package/dist/dialect/abstractSqlDialect.js +45 -49
  8. package/dist/entity/decorator/members.d.ts +2 -0
  9. package/dist/entity/decorator/members.js +2 -0
  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 +11 -2
  13. package/dist/entity/metadata/definition.js +11 -0
  14. package/dist/mongo/mongoDialect.d.ts +36 -6
  15. package/dist/mongo/mongoDialect.js +116 -31
  16. package/dist/mongo/mongodbQuerier.d.ts +8 -3
  17. package/dist/mongo/mongodbQuerier.js +30 -11
  18. package/dist/querier/abstractQuerier.d.ts +33 -4
  19. package/dist/querier/abstractQuerier.js +92 -43
  20. package/dist/querier/abstractQuerierPool.d.ts +2 -2
  21. package/dist/querier/abstractSqlQuerier.d.ts +3 -2
  22. package/dist/querier/abstractSqlQuerier.js +132 -31
  23. package/dist/querier/relationCount.js +15 -9
  24. package/dist/type/entity.d.ts +1 -1
  25. package/dist/type/queryRaw.d.ts +1 -1
  26. package/dist/type/queryWhere.d.ts +11 -1
  27. package/dist/type/universalQuerier.d.ts +2 -2
  28. package/dist/util/dialect.util.d.ts +7 -0
  29. package/dist/util/dialect.util.js +14 -0
  30. package/dist/util/fieldOption.util.d.ts +1 -1
  31. package/dist/util/fieldOption.util.js +1 -1
  32. package/dist/util/relationQuery.util.d.ts +9 -6
  33. package/dist/util/relationQuery.util.js +11 -11
  34. package/dist/util/rowKey.util.d.ts +18 -4
  35. package/dist/util/rowKey.util.js +29 -5
  36. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
- import { assertSoleId, getMeta, idOf, soleIdOf } from '../entity/index.js';
2
- import { asSelectMap, augmentWhere, childrenOf, clone, filterPersistableRelationKeys, forEachRequestedRelation, getKeys, getRelationRequestSummary, idOnlyQuery, isScalarId, joinedColumns, joinedRowKey, LoggerWrapper, isBoundedPerParent, parentJoins, parentRowKey, queryChildrenOfAll, parseRelationAtKey, parseRelationQueryValue, runHooks, someKey, targetKeyColumns, withoutSoftDeleteFilter, } from '../util/index.js';
1
+ import { assertSoleId, getMeta, idOf, namesKey, soleIdOf } from '../entity/index.js';
2
+ import { asSelectMap, augmentWhere, childrenOf, clone, dataKeyed, filterPersistableRelationKeys, forEachRequestedRelation, getKeys, getRelationRequestSummary, idOnlyQuery, isScalarId, joinedColumns, keyColumns, LoggerWrapper, isBoundedPerParent, parentJoins, queryChildrenOfAll, 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
  /**
@@ -168,20 +168,14 @@ export class AbstractQuerier {
168
168
  return id;
169
169
  }
170
170
  async insertMany(entity, payload) {
171
- await this.emitHook(entity, 'beforeInsert', payload);
172
- const ids = await this.internalInsertMany(entity, payload);
173
- await this.emitHook(entity, 'afterInsert', payload);
174
- return ids;
171
+ return this.hooked(entity, 'Insert', payload, () => this.internalInsertMany(entity, payload));
175
172
  }
176
173
  async updateOneById(entity, id, payload, opts) {
177
174
  assertIdValue(entity, id);
178
175
  return this.updateMany(entity, { $where: id }, payload, opts);
179
176
  }
180
177
  async updateMany(entity, q, payload, opts) {
181
- await this.emitHook(entity, 'beforeUpdate', [payload]);
182
- const changes = await this.internalUpdateMany(entity, q, payload, opts);
183
- await this.emitHook(entity, 'afterUpdate', [payload]);
184
- return changes;
178
+ return this.hooked(entity, 'Update', [payload], () => this.internalUpdateMany(entity, q, payload, opts));
185
179
  }
186
180
  async restoreOneById(entity, id) {
187
181
  assertIdValue(entity, id);
@@ -197,6 +191,18 @@ export class AbstractQuerier {
197
191
  filters: { softDelete: false },
198
192
  });
199
193
  }
194
+ /**
195
+ * `beforeUpsert`/`afterUpsert` rather than the insert's or the update's pair: the database decides
196
+ * which branch each row takes as the statement runs, so neither of those could be fired honestly -
197
+ * but the upsert itself is a fact known before and after, and a row written with no hook at all
198
+ * was how an `@Id({ onInsert })` or an audit trail silently skipped this path.
199
+ */
200
+ async upsertOne(entity, conflictPaths, payload) {
201
+ return this.hooked(entity, 'Upsert', [payload], () => this.internalUpsertOne(entity, conflictPaths, payload));
202
+ }
203
+ async upsertMany(entity, conflictPaths, payload) {
204
+ return this.hooked(entity, 'Upsert', payload, () => this.internalUpsertMany(entity, conflictPaths, payload));
205
+ }
200
206
  async deleteOneById(entity, id, opts) {
201
207
  assertIdValue(entity, id);
202
208
  return this.deleteMany(entity, { $where: id }, opts);
@@ -240,38 +246,65 @@ export class AbstractQuerier {
240
246
  const [id] = await this.saveMany(entity, [payload]);
241
247
  return id;
242
248
  }
249
+ /**
250
+ * Insert or update, as the name has always promised - and now as one statement per kind rather
251
+ * than a guess.
252
+ *
253
+ * Whether a row names its key decides which statement it takes, never whether the row exists: an
254
+ * id the caller invented is not proof of anything, and a stale one used to issue an `UPDATE` that
255
+ * matched nothing and reported success. A named row upserts on its own key, so it is written
256
+ * either way and no read can go stale between deciding and writing. An unnamed one inserts, and
257
+ * the database assigns the key.
258
+ *
259
+ * A composite key is always supplied by the caller, so it always takes the upsert branch - which
260
+ * is why nothing here special-cases one, and why this is the method that stopped refusing them.
261
+ *
262
+ * The hooks follow the statement: a named row fires `beforeUpsert`/`afterUpsert`, never the
263
+ * update pair, because the database picks the branch as the statement runs.
264
+ */
243
265
  async saveMany(entity, payload) {
244
266
  const meta = getMeta(entity);
267
+ // Indexes, not rows: the result is reported in payload order so it can be zipped with what was
268
+ // passed, which concatenating the branches did not do.
245
269
  const toInsert = [];
246
- const toUpdate = [];
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');
252
- for (const it of payload) {
253
- const id = it[idKey];
254
- if (!id) {
255
- toInsert.push(it);
270
+ const toUpsert = [];
271
+ const ids = new Array(payload.length);
272
+ /** Whether the row carries anything its primary key does not - something to write. */
273
+ const writesMoreThanItsKey = (row) => someKey(row, (key) => !meta.ids.some((idKey) => idKey === key));
274
+ for (let index = 0; index < payload.length; index++) {
275
+ const it = payload[index];
276
+ if (!namesKey(meta, it)) {
277
+ toInsert.push(index);
256
278
  }
257
- else if (!someKey(it, (key) => key !== idKey)) {
258
- existingIds.push(id);
279
+ else if (writesMoreThanItsKey(it)) {
280
+ toUpsert.push(index);
259
281
  }
260
282
  else {
261
- toUpdate.push(it);
283
+ // A row that names its key and carries nothing else is a *reference*, not a write - the
284
+ // shape a to-many uses to link rows it did not author. Upserting it would stamp `onUpdate`
285
+ // fields on a row the caller never asked to change, and create one that was meant to exist.
286
+ ids[index] = idOf(meta, it);
262
287
  }
263
288
  }
264
- const [insertedIds, updatedIds] = await Promise.all([
265
- toInsert.length ? this.insertMany(entity, toInsert) : [],
266
- Promise.all(toUpdate.map(async (it) => {
267
- const id = it[idKey];
268
- const data = { ...it };
269
- delete data[idKey];
270
- await this.updateOneById(entity, id, data);
271
- return id;
272
- })),
273
- ]);
274
- return [...existingIds, ...insertedIds, ...updatedIds];
289
+ const write = async () => {
290
+ if (toInsert.length) {
291
+ const inserted = await this.insertMany(entity, toInsert.map((index) => payload[index]));
292
+ toInsert.forEach((index, position) => {
293
+ ids[index] = inserted[position];
294
+ });
295
+ }
296
+ if (toUpsert.length) {
297
+ const conflictPaths = Object.fromEntries(meta.ids.map((key) => [key, true]));
298
+ await this.upsertMany(entity, conflictPaths, toUpsert.map((index) => payload[index]));
299
+ for (const index of toUpsert) {
300
+ ids[index] = idOf(meta, payload[index]);
301
+ }
302
+ }
303
+ };
304
+ // Only a batch carrying both kinds is more than one statement; `transaction` is re-entrant, so
305
+ // this is free inside a caller's own.
306
+ await (toInsert.length && toUpsert.length ? this.transaction(write) : write());
307
+ return ids;
275
308
  }
276
309
  async fillToManyRelations(entity, payload, populate) {
277
310
  if (!payload.length) {
@@ -377,17 +410,19 @@ export class AbstractQuerier {
377
410
  return founds;
378
411
  }
379
412
  putChildrenInParents(parents, children, joins, relKey) {
380
- const childrenByParentId = {};
413
+ const childrenByParentId = dataKeyed();
414
+ // Every joined column, so two children agreeing on one column of a composite key are not
415
+ // gathered under the same parent. Both column lists are read once, not once per row.
416
+ const joinedKeys = keyColumns(joins, 'joined');
417
+ const parentKeys = keyColumns(joins, 'parent');
381
418
  for (const child of children) {
382
- // Every joined column, so two children agreeing on one column of a composite key are not
383
- // gathered under the same parent.
384
- (childrenByParentId[joinedRowKey(joins, child)] ??= []).push(child);
419
+ (childrenByParentId[rowKey(child, joinedKeys)] ??= []).push(child);
385
420
  }
386
421
  for (const parent of parents) {
387
422
  // `[]` rather than nothing for a parent with no children: a populated to-many is a list the
388
423
  // caller asked for, so it maps and counts without a guard, and its type can say so. An
389
424
  // unpopulated one stays absent, which is what tells the two apart.
390
- parent[relKey] = (childrenByParentId[parentRowKey(joins, parent)] ?? []);
425
+ parent[relKey] = (childrenByParentId[rowKey(parent, parentKeys)] ?? []);
391
426
  }
392
427
  }
393
428
  async insertRelations(entity, payload) {
@@ -459,7 +494,7 @@ export class AbstractQuerier {
459
494
  case 'mm':
460
495
  return this.saveToMany(relOpts, relEntity, ids, relPayload, isUpdate);
461
496
  case '11':
462
- return this.saveOneToOne(relEntity, relOpts, ids, relPayload);
497
+ return this.saveOneToOne(relEntity, relOpts, ids, relPayload, isUpdate);
463
498
  case 'm1':
464
499
  if (relPayload)
465
500
  return this.saveManyToOne(entity, relEntity, relOpts, ids, relPayload);
@@ -498,11 +533,15 @@ export class AbstractQuerier {
498
533
  await this.saveMany(relEntity, ids.flatMap((id) => relPayload.map((it) => ({ ...it, [foreignField]: id }))));
499
534
  }
500
535
  }
501
- async saveOneToOne(relEntity, relOpts, ids, relPayload) {
536
+ async saveOneToOne(relEntity, relOpts, ids, relPayload, isUpdate) {
502
537
  const foreignField = soleParentColumn(relOpts);
503
- if (relPayload === null) {
538
+ // The same rule a to-many follows: the parent owns its child, so an update replaces it. Without
539
+ // this the old row stayed behind and a one-to-one `$populate` had two rows to choose from.
540
+ if (relPayload === null || isUpdate) {
504
541
  await this.deleteMany(relEntity, { $where: { [foreignField]: ids } });
505
- return;
542
+ if (relPayload === null) {
543
+ return;
544
+ }
506
545
  }
507
546
  await this.saveMany(relEntity, ids.map((id) => ({ ...relPayload, [foreignField]: id })));
508
547
  }
@@ -561,6 +600,16 @@ export class AbstractQuerier {
561
600
  * Emit a lifecycle hook event for the given entity.
562
601
  * Fires global listeners first, then entity-level hooks.
563
602
  */
603
+ /**
604
+ * Runs `write` between the event's `before`/`after` pair. Every hooked write is this shape, and
605
+ * each one spelled out was a place the pair could drift - `upsert` had none at all for a release.
606
+ */
607
+ async hooked(entity, event, payloads, write) {
608
+ await this.emitHook(entity, `before${event}`, payloads);
609
+ const result = await write();
610
+ await this.emitHook(entity, `after${event}`, payloads);
611
+ return result;
612
+ }
564
613
  async emitHook(entity, event, payloads) {
565
614
  if (!this.hasHook(entity, event))
566
615
  return;
@@ -43,8 +43,8 @@ export declare abstract class AbstractQuerierPool<Q extends Querier, D extends A
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> | undefined>;
47
- saveMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<(IdValue<E> | undefined)[]>;
46
+ saveOne<E extends object>(entity: Type<E>, payload: EntityData<E>): Promise<EntityId<E> | undefined>;
47
+ saveMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<(EntityId<E> | undefined)[]>;
48
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
50
  restoreOneById<E extends object>(entity: Type<E>, id: EntityId<E>): Promise<number>;
@@ -123,8 +123,9 @@ export declare abstract class AbstractSqlQuerier extends AbstractQuerier impleme
123
123
  internalUpdateMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<number>;
124
124
  /** The ids matching `q`, in `q`'s own order and page, so a write can name the rows it settled on. */
125
125
  private settleIds;
126
- upsertOne<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>): Promise<QueryUpdateResult>;
127
- upsertMany<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>[]): Promise<QueryUpdateResult>;
126
+ protected internalUpsertOne<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>): Promise<QueryUpdateResult>;
127
+ protected internalUpsertMany<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>[]): Promise<QueryUpdateResult>;
128
+ private runUpsert;
128
129
  protected internalDeleteMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, opts?: QueryOptions): Promise<number>;
129
130
  get hasOpenTransaction(): boolean;
130
131
  beginTransaction(opts?: TransactionOptions): Promise<void>;
@@ -1,9 +1,74 @@
1
1
  import { COUNT_ALIAS, TOTAL_ALIAS } from '../dialect/aliases.js';
2
2
  import { decodeColumn } from '../dialect/hydrateColumn.js';
3
3
  import { getMeta, idOf, soleIdOf } from '../entity/index.js';
4
- import { buildUpdateResult, cascadesOnDelete, clone, getInsertFieldKeys, getRelationRequestSummary, hasKeys, idOnlyQuery, isAutoIncrement, isPagedQuery, obtainAttrsPaths, throwNoPendingTransaction, throwPendingTransaction, unflatObject, unflatObjects, withoutSoftDeleteFilter, } from '../util/index.js';
4
+ import { buildUpdateResult, cascadesOnDelete, clone, getInsertFieldKeys, insertShapeOf, 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';
7
+ /**
8
+ * Row indexes grouped by whether the caller supplied the key, payload order kept within each group.
9
+ * One group when the batch agrees on it, which is the single statement it has always been.
10
+ *
11
+ * Deliberately coarser than {@link groupByInsertShape}, and the two must not be merged: an insert's
12
+ * `VALUES` list takes the union of the batch's columns and fills the rest with `DEFAULT`, so the
13
+ * key's presence is the only thing that changes what the statement can report. Splitting an insert
14
+ * by full shape instead would turn a batch of optional fields into a statement per combination.
15
+ */
16
+ function partitionBySuppliedId(payload, idKey) {
17
+ const supplied = [];
18
+ const generated = [];
19
+ for (let index = 0; index < payload.length; index++) {
20
+ (payload[index][idKey] === undefined ? generated : supplied).push(index);
21
+ }
22
+ return supplied.length && generated.length ? [supplied, generated] : [supplied.length ? supplied : generated];
23
+ }
24
+ /**
25
+ * Rows grouped by the columns they carry, payload order kept within each group - or `undefined` when
26
+ * they all carry the same ones, which is the batch as it stands and needs no grouping at all.
27
+ *
28
+ * Deliberately finer than {@link partitionBySuppliedId}: an upsert's `DO UPDATE SET` is one
29
+ * assignment list for the whole statement, so rows of different shapes cannot share one at all.
30
+ */
31
+ function groupByInsertShape(meta, payload) {
32
+ const first = insertShapeOf(meta, payload[0]);
33
+ let index = 1;
34
+ while (index < payload.length && insertShapeOf(meta, payload[index]) === first) {
35
+ index++;
36
+ }
37
+ if (index === payload.length) {
38
+ return undefined;
39
+ }
40
+ const groups = new Map([[first, payload.slice(0, index)]]);
41
+ for (; index < payload.length; index++) {
42
+ const row = payload[index];
43
+ const shape = insertShapeOf(meta, row);
44
+ const group = groups.get(shape);
45
+ if (group) {
46
+ group.push(row);
47
+ }
48
+ else {
49
+ groups.set(shape, [row]);
50
+ }
51
+ }
52
+ return [...groups.values()];
53
+ }
54
+ /**
55
+ * How many rows one statement can carry within the dialect's bind budget. `DEFAULT` cells bind no
56
+ * parameter, so fields-per-record is a safe upper bound. Every multi-row write splits on this: D1
57
+ * allows 100 binds, which a couple of dozen rows reach.
58
+ */
59
+ function bindBudgetChunkSize(meta, rows, maxBindValues) {
60
+ const fieldsPerRecord = getInsertFieldKeys(meta, rows).length || 1;
61
+ return Math.max(1, Math.floor(maxBindValues / fieldsPerRecord));
62
+ }
63
+ /** `rows` split into statement-sized slices, payload order kept. */
64
+ function chunkByBindBudget(meta, rows, maxBindValues) {
65
+ const size = bindBudgetChunkSize(meta, rows, maxBindValues);
66
+ const chunks = [];
67
+ for (let start = 0; start < rows.length; start += size) {
68
+ chunks.push(rows.slice(start, start + size));
69
+ }
70
+ return chunks;
71
+ }
7
72
  export class AbstractSqlQuerier extends AbstractQuerier {
8
73
  dialect;
9
74
  extra;
@@ -306,33 +371,39 @@ export class AbstractSqlQuerier extends AbstractQuerier {
306
371
  const [idKey] = meta.ids;
307
372
  const sole = meta.ids.length === 1;
308
373
  const idField = sole ? meta.fields[idKey] : undefined;
309
- // RETURNING-based IDs are exact per row. Header-derived IDs (LAST_INSERT_ID /
310
- // lastInsertRowid arithmetic) are only sound when the primary key is database-generated
311
- // and no record supplies an explicit ID (a mixed batch shifts the positional mapping and
312
- // MySQL stops guaranteeing consecutive values); otherwise generated IDs stay `undefined`.
313
- const idsReliable = sole &&
314
- (this.dialect.insertIdSource === 'returning' ||
315
- (!!idField && isAutoIncrement(idField, true) && payload.every((it) => it[idKey] === undefined)));
316
- // Inferring multiple ids from the single header id (MySQL) assumes a known stride; a clustered
317
- // server may set `auto_increment_increment` > 1, so probe it (once, cached) before inferring.
318
- if (idsReliable && payload.length > 1 && this.dialect.insertIdSource === 'firstId') {
319
- this.#insertIdIncrement ??= await this.loadInsertIdIncrement();
320
- }
321
- // `DEFAULT` cells bind no parameter, so fields-per-record is a safe upper bound per row.
322
- const fieldsPerRecord = getInsertFieldKeys(meta, payload).length || 1;
323
- const chunkSize = Math.max(1, Math.floor(this.dialect.maxBindValues / fieldsPerRecord));
324
- const payloadIds = [];
325
- for (let start = 0; start < payload.length; start += chunkSize) {
326
- const chunk = payload.slice(start, start + chunkSize);
327
- const ctx = this.dialect.createContext();
328
- this.dialect.insert(ctx, entity, chunk);
329
- const { ids = [] } = await this.run(ctx.sql, ctx.values);
330
- chunk.forEach((it, index) => {
331
- if (idsReliable) {
332
- it[idKey] ??= ids[index];
333
- }
334
- payloadIds.push(sole ? it[idKey] : undefined);
335
- });
374
+ const generatedKey = !!idField && isAutoIncrement(idField, true);
375
+ const payloadIds = new Array(payload.length);
376
+ for (const group of partitionBySuppliedId(payload, idKey)) {
377
+ // Per group, not per batch: the two carry different columns - one names the key, one does not -
378
+ // so a budget taken over their union would under-fill the statement that is missing one.
379
+ const chunkSize = bindBudgetChunkSize(meta, group.map((index) => payload[index]), this.dialect.maxBindValues);
380
+ // RETURNING-based ids are exact per row. Header-derived ones (LAST_INSERT_ID / lastInsertRowid
381
+ // arithmetic) are only sound when the key is database-generated and every row *in this
382
+ // statement* left it to the database. That is a property of the statement, not of the batch:
383
+ // asking it of the whole batch meant one supplied id made every id `undefined`, which a
384
+ // cascade then wrote into a child as a null foreign key. Splitting on that one axis costs at
385
+ // most one extra statement and keeps each of them inferable.
386
+ const idsReliable = sole &&
387
+ (this.dialect.insertIdSource === 'returning' ||
388
+ (generatedKey && group.every((index) => payload[index][idKey] === undefined)));
389
+ // Inferring multiple ids from the single header id (MySQL) assumes a known stride; a clustered
390
+ // server may set `auto_increment_increment` > 1, so probe it (once, cached) before inferring.
391
+ if (idsReliable && group.length > 1 && this.dialect.insertIdSource === 'firstId') {
392
+ this.#insertIdIncrement ??= await this.loadInsertIdIncrement();
393
+ }
394
+ for (let start = 0; start < group.length; start += chunkSize) {
395
+ const indexes = group.slice(start, start + chunkSize);
396
+ const ctx = this.dialect.createContext();
397
+ this.dialect.insert(ctx, entity, indexes.map((index) => payload[index]));
398
+ const { ids = [] } = await this.run(ctx.sql, ctx.values);
399
+ indexes.forEach((index, position) => {
400
+ const it = payload[index];
401
+ if (idsReliable) {
402
+ it[idKey] ??= ids[position];
403
+ }
404
+ payloadIds[index] = sole ? it[idKey] : undefined;
405
+ });
406
+ }
336
407
  }
337
408
  await this.insertRelations(entity, payload);
338
409
  return payloadIds;
@@ -363,14 +434,44 @@ export class AbstractSqlQuerier extends AbstractQuerier {
363
434
  const founds = await this.all(ctx.sql, ctx.values);
364
435
  return founds.map((found) => idOf(meta, found));
365
436
  }
366
- async upsertOne(entity, conflictPaths, payload) {
367
- return this.upsertMany(entity, conflictPaths, [payload]);
437
+ async internalUpsertOne(entity, conflictPaths, payload) {
438
+ return this.internalUpsertMany(entity, conflictPaths, [payload]);
368
439
  }
369
- async upsertMany(entity, conflictPaths, payload) {
440
+ async internalUpsertMany(entity, conflictPaths, payload) {
370
441
  if (!payload?.length) {
371
442
  return { changes: 0 };
372
443
  }
373
444
  payload = clone(payload);
445
+ const meta = getMeta(entity);
446
+ // One statement per shape, each split again to stay inside the bind budget. Grouping first is
447
+ // what makes the budget arithmetic right: every row of a group carries the same columns, so the
448
+ // `DO UPDATE SET` resolves to non-binding `EXCLUDED` references rather than inlined values.
449
+ const groups = groupByInsertShape(meta, payload);
450
+ const statements = (groups ?? [payload]).flatMap((group) => chunkByBindBudget(meta, group, this.dialect.maxBindValues));
451
+ if (statements.length === 1) {
452
+ return this.runUpsert(entity, conflictPaths, payload);
453
+ }
454
+ // `ON CONFLICT DO UPDATE SET` carries one assignment list for the whole statement, so rows of
455
+ // different shapes cannot share one. Neither obvious single-statement form works: taking the
456
+ // union writes the omitting row's `DEFAULT` (null) over a column it never mentioned, and
457
+ // sampling one row drops every column that row happens to lack. One statement per shape is the
458
+ // only form that writes exactly what each row asked for. Transactional because it is now more
459
+ // than one statement; `transaction` is re-entrant, so this is free inside a caller's own.
460
+ return this.transaction(async () => {
461
+ let changes = 0;
462
+ const ids = [];
463
+ for (const statement of statements) {
464
+ const result = await this.runUpsert(entity, conflictPaths, statement);
465
+ changes += result.changes ?? 0;
466
+ if (result.ids) {
467
+ ids.push(...result.ids);
468
+ }
469
+ }
470
+ // No `created`/`firstId`: both speak for a single statement, and there were several.
471
+ return ids.length ? { changes, ids } : { changes };
472
+ });
473
+ }
474
+ async runUpsert(entity, conflictPaths, payload) {
374
475
  const ctx = this.dialect.createContext();
375
476
  this.dialect.upsert(ctx, entity, conflictPaths, payload);
376
477
  const result = await this.run(ctx.sql, ctx.values);
@@ -1,7 +1,7 @@
1
1
  import { COUNT_ALIAS } from '../dialect/aliases.js';
2
2
  import { getMeta, soleIdOf } from '../entity/index.js';
3
3
  import { COUNT_RESULT_KEY } from '../type/index.js';
4
- import { asSelectMap, getKeys, joinedColumns, joinedRowKey, parentJoins, parentRowKey, parentsIn, targetKeyColumns, } from '../util/index.js';
4
+ import { asSelectMap, dataKeyed, getKeys, joinedColumns, keyColumns, 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
@@ -50,7 +50,7 @@ export async function fillRelationCounts(querier, entity, payload, count) {
50
50
  return;
51
51
  }
52
52
  const meta = getMeta(entity);
53
- // The tallies come back keyed by the columns *this relation* joins from, so its `joins` are kept
53
+ // The tallies come back keyed by the columns *this relation* joins from, so those columns are kept
54
54
  // beside them: reading the parent through `meta.ids` instead matches only where the two coincide,
55
55
  // which is a to-many and nothing else.
56
56
  const counted = new Map();
@@ -62,14 +62,19 @@ export async function fillRelationCounts(querier, entity, payload, count) {
62
62
  }
63
63
  const where = typeof value === 'object' ? value.$where : undefined;
64
64
  const joins = parentJoins(relOpts, meta.ids.length);
65
- counted.set(relKey, { joins, byParent: await countPerParent(querier, relOpts, joins, payload, where) });
65
+ counted.set(relKey, {
66
+ parentKeys: keyColumns(joins, 'parent'),
67
+ byParent: await countPerParent(querier, relOpts, joins, payload, where),
68
+ });
66
69
  }
67
70
  for (const parent of payload) {
71
+ // A plain object, unlike the tallies below: this one is keyed by relation names the entity
72
+ // declares, not by data, and it is handed to the caller - who would meet a null prototype.
68
73
  const row = {};
69
- for (const [relKey, { joins, byParent }] of counted) {
74
+ for (const [relKey, { parentKeys, byParent }] of counted) {
70
75
  // A parent the grouped result has no row for matched nothing, which is a zero rather than a
71
76
  // gap: `_count` names what the caller asked to count, so every key it asked for is present.
72
- row[relKey] = byParent[parentRowKey(joins, parent)] ?? 0;
77
+ row[relKey] = byParent[rowKey(parent, parentKeys)] ?? 0;
73
78
  }
74
79
  parent[COUNT_RESULT_KEY] = row;
75
80
  }
@@ -105,11 +110,12 @@ async function groupedCount(querier, entity, joins, where) {
105
110
  const $agg = { [COUNT_ALIAS]: { $count: '*' } };
106
111
  const $group = joinedColumns(joins);
107
112
  const rows = await querier.aggregate(entity, { $group, $agg, $where: where });
108
- const byParent = {};
113
+ const byParent = dataKeyed();
114
+ // Keyed by every joined column, which is how a tally finds the one parent whose whole key it
115
+ // matches - and how the rows an over-selecting `IN` brought back find no parent at all.
116
+ const joinedKeys = keyColumns(joins, 'joined');
109
117
  for (const row of rows) {
110
- // Keyed by every joined column, which is how a tally finds the one parent whose whole key it
111
- // matches - and how the rows an over-selecting `IN` brought back find no parent at all.
112
- byParent[joinedRowKey(joins, row)] = Number(row[COUNT_ALIAS]);
118
+ byParent[rowKey(row, joinedKeys)] = Number(row[COUNT_ALIAS]);
113
119
  }
114
120
  return byParent;
115
121
  }
@@ -570,7 +570,7 @@ export type RelationManyToManyOptions<E> = RelationOptionsThroughOwner<E> | Rela
570
570
  /**
571
571
  * Lifecycle hook event names.
572
572
  */
573
- export type HookEvent = 'beforeInsert' | 'afterInsert' | 'beforeUpdate' | 'afterUpdate' | 'beforeDelete' | 'afterDelete' | 'afterLoad';
573
+ export type HookEvent = 'beforeInsert' | 'afterInsert' | 'beforeUpdate' | 'afterUpdate' | 'beforeUpsert' | 'afterUpsert' | 'beforeDelete' | 'afterDelete' | 'afterLoad';
574
574
  /**
575
575
  * A registered hook: the method name on the entity class to call.
576
576
  */
@@ -25,7 +25,7 @@ export type QueryRawFnOptions = {
25
25
  /**
26
26
  * A `raw` callback: write into `ctx`, or return a string or number to have it appended. Anything else
27
27
  * it returns is ignored, which is why the return type is `unknown` rather than `void | Scalar` - the
28
- * latter rejected `({ ctx }) => ctx.append(...)`, the form every virtual field is written in, because
28
+ * latter rejected `({ ctx }) => ctx.append(...)`, the form every computed field is written in, because
29
29
  * TypeScript's "returning a value where void is expected" allowance does not apply to a union.
30
30
  *
31
31
  * `Required`, and the parameter not optional, because the one place that calls it (`getRawValue`)
@@ -85,6 +85,16 @@ export type QueryWhereRootOperator<E> = {
85
85
  * {@link QueryWhereRootOperator} so a rename there breaks this union at compile time.
86
86
  */
87
87
  export type QueryNegateOp = keyof Pick<QueryWhereRootOperator<unknown>, '$not' | '$nor'>;
88
+ /**
89
+ * The root operators that join their clauses instead of negating them, tied back to
90
+ * {@link QueryWhereRootOperator} on the same terms as {@link QueryNegateOp}.
91
+ */
92
+ export type QueryJoinOp = keyof Pick<QueryWhereRootOperator<unknown>, '$and' | '$or'>;
93
+ /**
94
+ * Every root operator whose value is a {@link QueryWhereArray} rather than a field condition: the
95
+ * two that join their clauses and the two that negate the join.
96
+ */
97
+ export type QueryGroupOp = QueryJoinOp | QueryNegateOp;
88
98
  /**
89
99
  * Comparison operators accepted by `$size` for range queries: {@link QueryHavingOp} plus `$between`.
90
100
  * Strips `null` from picked operators since array size is always numeric.
@@ -320,7 +330,7 @@ type IsUntypedColumn<T> = [Scalar] extends [NonNullable<T>] ? true : false;
320
330
  */
321
331
  export type QueryWhereFieldValue<T> = T | (undefined extends T ? null : never) | (IsMany<T> extends true ? never : T[]) | QueryWhereFieldOperators<T> | QueryRaw;
322
332
  /**
323
- * query filter array - used for `$and`, `$or`, `$not`, `$nor` operators.
333
+ * query filter array - the value every {@link QueryGroupOp} takes.
324
334
  */
325
335
  export type QueryWhereArray<E> = (QueryWhereMap<E> | QueryRaw)[];
326
336
  /**
@@ -153,14 +153,14 @@ export interface UniversalQuerier extends SharedQuerier<'server', QueryOptions>
153
153
  * @param payload the data to be persisted
154
154
  * @return the ID
155
155
  */
156
- saveOne<E extends object>(entity: Type<E>, payload: EntityData<E>): Promise<IdValue<E> | undefined>;
156
+ saveOne<E extends object>(entity: Type<E>, payload: EntityData<E>): Promise<EntityId<E> | undefined>;
157
157
  /**
158
158
  * Insert or update records.
159
159
  * @param entity the entity to persist on
160
160
  * @param payload the data to be persisted
161
161
  * @return the IDs
162
162
  */
163
- saveMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<(IdValue<E> | undefined)[]>;
163
+ saveMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<(EntityId<E> | undefined)[]>;
164
164
  /**
165
165
  * Restore soft-deleted records (sets the soft-delete field back to `null`). Throws if the
166
166
  * entity has no soft-delete field.
@@ -1,6 +1,13 @@
1
1
  import { type CascadeType, type EntityData, type EntityIndexMeta, type EntityMeta, type FieldKey, type FieldOptions, type JsonUpdateOp, type OnFieldCallback, type Query, type QueryAggMap, type QueryAggregateOp, type QueryExclude, type QueryGroupMap, type QueryOptions, QueryRaw, type QuerySearch, type QuerySelect, type QuerySelectValue, type QuerySizeComparisonOps, type QuerySortMap, type QueryVectorSearch, type QueryWhere, type QueryWhereMap, type RelationKey } from '../type/index.js';
2
2
  export type CallbackKey = keyof Pick<FieldOptions, 'onInsert' | 'onUpdate'>;
3
3
  export declare function filterFieldKeys<E>(meta: EntityMeta<E>, payload: EntityData<E>, callbackKey: CallbackKey): FieldKey<E>[];
4
+ /** Appends `record`'s not-yet-`seen` insertable keys (real, caller-written, defined value) to `keys`. */
5
+ /**
6
+ * The insertable keys `record` itself carries, as a string, for grouping rows by the statement they
7
+ * can share. Only the row's own keys: the `onInsert` columns {@link getInsertFieldKeys} appends are a
8
+ * property of the entity, identical for every row, so they cannot tell two rows apart.
9
+ */
10
+ export declare function insertShapeOf<E>(meta: EntityMeta<E>, record: EntityData<E>): string;
4
11
  /**
5
12
  * Resolves the columns of an INSERT statement: the union of the persistable fields provided by
6
13
  * any record (in first-seen order), plus every `onInsert` field. Records missing one of these
@@ -16,6 +16,20 @@ function isInsertableField(meta, record, key) {
16
16
  return !!field && !isDatabaseWritten(field) && record[key] !== undefined;
17
17
  }
18
18
  /** Appends `record`'s not-yet-`seen` insertable keys (real, caller-written, defined value) to `keys`. */
19
+ /**
20
+ * The insertable keys `record` itself carries, as a string, for grouping rows by the statement they
21
+ * can share. Only the row's own keys: the `onInsert` columns {@link getInsertFieldKeys} appends are a
22
+ * property of the entity, identical for every row, so they cannot tell two rows apart.
23
+ */
24
+ export function insertShapeOf(meta, record) {
25
+ let shape = '';
26
+ for (const key of getKeys(record)) {
27
+ if (isInsertableField(meta, record, key)) {
28
+ shape += `${key},`;
29
+ }
30
+ }
31
+ return shape;
32
+ }
19
33
  function addInsertFieldKeys(meta, record, seen, keys) {
20
34
  for (const key of getKeys(record)) {
21
35
  if (!seen.has(key) && isInsertableField(meta, record, key)) {
@@ -34,7 +34,7 @@ declare const FIELD_OPTION_FAMILY: {
34
34
  readonly comment: '*';
35
35
  };
36
36
  /**
37
- * The only options a `virtual` field reaches: it is skipped in DDL and dropped from every insert and
37
+ * The only options an inlined computed field reaches: it is skipped in DDL and dropped from every insert and
38
38
  * update, so the whole persistence half of the options above is dead on one. Stated as what survives
39
39
  * rather than on each option that dies, because it is one fact rather than nineteen - and because an
40
40
  * option added without a thought then lands on the safe side of it.
@@ -34,7 +34,7 @@ const FIELD_OPTION_FAMILY = {
34
34
  comment: '*',
35
35
  };
36
36
  /**
37
- * The only options a `virtual` field reaches: it is skipped in DDL and dropped from every insert and
37
+ * The only options an inlined computed field reaches: it is skipped in DDL and dropped from every insert and
38
38
  * update, so the whole persistence half of the options above is dead on one. Stated as what survives
39
39
  * rather than on each option that dies, because it is one fact rather than nineteen - and because an
40
40
  * option added without a thought then lands on the safe side of it.