uql-orm 0.47.1 → 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.
@@ -81,7 +81,11 @@ export class MongodbQuerier extends AbstractQuerier {
81
81
  */
82
82
  async internalFindManyPerParent(entity, q, { joins, parents }) {
83
83
  const queries = parents.map((parent) => queryChildrenOf(q, joins, parent));
84
- // A vector sort needs a pipeline of its own shape, which only `internalFindMany` builds.
84
+ // A vector sort is not a degraded fallback here, it is the only expressible form: `$vectorSearch`
85
+ // has to be the first stage of a pipeline, so it cannot be one of N `$unionWith` branches. Read a
86
+ // parent at a time it stays correct, because `buildVectorSearchStage` passes the query's `$where`
87
+ // - which carries this parent's key - into the search as its filter, so each parent gets its own
88
+ // nearest rather than a share of the collection's.
85
89
  if (this.dialect.extractVectorSort(q.$sort)) {
86
90
  return this.readEachInTurn(entity, queries);
87
91
  }
@@ -264,7 +268,7 @@ export class MongodbQuerier extends AbstractQuerier {
264
268
  const meta = getMeta(entity);
265
269
  const persistables = this.dialect.getPersistables(meta, payloads, 'onInsert');
266
270
  const { insertedIds } = await this.execute((session) => this.collection(entity).insertMany(persistables, { session }));
267
- const ids = Object.values(insertedIds);
271
+ const ids = Object.values(insertedIds).map((id) => this.dialect.fromWireId(id));
268
272
  const idKey = soleIdOf(meta, 'insert');
269
273
  for (const [index, it] of payloads.entries()) {
270
274
  it[idKey] = ids[index];
@@ -282,7 +286,7 @@ export class MongodbQuerier extends AbstractQuerier {
282
286
  // relation condition has nowhere to go, and MongoDB takes no page on a write, so a paged one
283
287
  // has to name the rows it picked rather than touching every match.
284
288
  const where = this.dialect.constrainsRelations(entity, qm.$where) || isPagedQuery(qm)
285
- ? { _id: { $in: await this.settleIds(entity, qm, opts) } }
289
+ ? { _id: { $in: this.dialect.toWireId(await this.settleIds(entity, qm, opts)) } }
286
290
  : this.dialect.where(entity, qm.$where, opts);
287
291
  // Maps JSON operators ($set/$unset/$push/$pull) onto their native MongoDB equivalents.
288
292
  const update = this.dialect.getUpdateFilter(persistable);
@@ -293,6 +297,21 @@ export class MongodbQuerier extends AbstractQuerier {
293
297
  return matchedCount;
294
298
  });
295
299
  }
300
+ /**
301
+ * `_id` is immutable, so a key the payload names can only be written on the insert branch of an
302
+ * upsert; in `$set` it would refuse every matched document. Everything else updates either way.
303
+ */
304
+ upsertUpdate(persistable) {
305
+ const { _id, ...rest } = persistable;
306
+ const update = {};
307
+ if (hasKeys(rest)) {
308
+ update['$set'] = rest;
309
+ }
310
+ if (_id !== undefined) {
311
+ update['$setOnInsert'] = { _id };
312
+ }
313
+ return update;
314
+ }
296
315
  buildConflictFilter(entity, conflictPaths, item) {
297
316
  const where = getKeys(conflictPaths).reduce((acc, key) => {
298
317
  acc[key] = item[key];
@@ -300,26 +319,26 @@ export class MongodbQuerier extends AbstractQuerier {
300
319
  }, {});
301
320
  return this.dialect.where(entity, where);
302
321
  }
303
- async upsertOne(entity, conflictPaths, payload) {
322
+ async internalUpsertOne(entity, conflictPaths, payload) {
304
323
  return this.timed('upsertOne', undefined, async () => {
305
324
  payload = clone(payload);
306
325
  const meta = getMeta(entity);
307
326
  const persistable = this.dialect.getPersistable(meta, payload, 'onInsert');
308
327
  const filter = this.buildConflictFilter(entity, conflictPaths, payload);
309
- const update = { $set: persistable };
328
+ const update = this.upsertUpdate(persistable);
310
329
  const res = await this.execute((session) => this.collection(entity).findOneAndUpdate(filter, update, {
311
330
  upsert: true,
312
331
  returnDocument: 'after',
313
332
  includeResultMetadata: true,
314
333
  session,
315
334
  }));
316
- const firstId = res?.value?._id;
335
+ const firstId = this.dialect.fromWireId(res?.value?._id);
317
336
  // `updatedExisting` is false when a new document was inserted (upserted).
318
337
  const created = res?.lastErrorObject?.['updatedExisting'] === false;
319
338
  return { firstId, changes: firstId ? 1 : 0, created };
320
339
  });
321
340
  }
322
- async upsertMany(entity, conflictPaths, payload) {
341
+ async internalUpsertMany(entity, conflictPaths, payload) {
323
342
  return this.timed('upsertMany', undefined, async () => {
324
343
  if (!payload?.length) {
325
344
  return { changes: 0 };
@@ -329,7 +348,7 @@ export class MongodbQuerier extends AbstractQuerier {
329
348
  const operations = payload.map((item) => {
330
349
  const persistable = this.dialect.getPersistable(meta, item, 'onInsert');
331
350
  const filter = this.buildConflictFilter(entity, conflictPaths, item);
332
- const update = { $set: persistable };
351
+ const update = this.upsertUpdate(persistable);
333
352
  return {
334
353
  updateOne: {
335
354
  filter,
@@ -344,7 +363,7 @@ export class MongodbQuerier extends AbstractQuerier {
344
363
  // updated document's `_id` isn't in the response, so it's simply not represented here - same
345
364
  // "exact where knowable, absent otherwise" convention `RETURNING`-based SQL dialects use for
346
365
  // rows that hit `DO NOTHING`.
347
- const ids = Object.values(res.upsertedIds);
366
+ const ids = Object.values(res.upsertedIds).map((id) => this.dialect.fromWireId(id));
348
367
  return { changes, ids, firstId: ids[0] };
349
368
  });
350
369
  }
@@ -366,13 +385,13 @@ export class MongodbQuerier extends AbstractQuerier {
366
385
  // Stamp the mapped column: reads filter on it, so a `@Field({ name })` mismatch here would
367
386
  // report a successful delete and leave the row visible.
368
387
  const softDeleteColumn = this.dialect.resolveColumnName(meta.softDelete, field);
369
- const updateResult = await this.execute((session) => this.collection(entity).updateMany({ _id: { $in: ids } }, { $set: { [softDeleteColumn]: getSoftDeleteValue(field) } }, {
388
+ const updateResult = await this.execute((session) => this.collection(entity).updateMany({ _id: { $in: this.dialect.toWireId(ids) } }, { $set: { [softDeleteColumn]: getSoftDeleteValue(field) } }, {
370
389
  session,
371
390
  }));
372
391
  changes = updateResult.matchedCount;
373
392
  }
374
393
  else {
375
- const deleteResult = await this.execute((session) => this.collection(entity).deleteMany({ _id: { $in: ids } }, { session }));
394
+ const deleteResult = await this.execute((session) => this.collection(entity).deleteMany({ _id: { $in: this.dialect.toWireId(ids) } }, { session }));
376
395
  changes = deleteResult.deletedCount;
377
396
  }
378
397
  await this.deleteRelations(entity, ids, opts);
@@ -119,8 +119,16 @@ export declare abstract class AbstractQuerier implements Querier {
119
119
  protected abstract internalUpdateMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<number>;
120
120
  restoreOneById<E extends object>(entity: Type<E>, id: EntityId<E>): Promise<number>;
121
121
  restoreMany<E extends object>(entity: Type<E>, q: QuerySearch<E>): Promise<number>;
122
- abstract upsertOne<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>): Promise<QueryUpdateResult>;
123
- abstract upsertMany<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>[]): Promise<QueryUpdateResult>;
122
+ /**
123
+ * `beforeUpsert`/`afterUpsert` rather than the insert's or the update's pair: the database decides
124
+ * which branch each row takes as the statement runs, so neither of those could be fired honestly -
125
+ * but the upsert itself is a fact known before and after, and a row written with no hook at all
126
+ * was how an `@Id({ onInsert })` or an audit trail silently skipped this path.
127
+ */
128
+ upsertOne<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>): Promise<QueryUpdateResult>;
129
+ upsertMany<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>[]): Promise<QueryUpdateResult>;
130
+ protected abstract internalUpsertOne<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>): Promise<QueryUpdateResult>;
131
+ protected abstract internalUpsertMany<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>[]): Promise<QueryUpdateResult>;
124
132
  deleteOneById<E extends object>(entity: Type<E>, id: EntityId<E>, opts?: QueryOptions): Promise<number>;
125
133
  /**
126
134
  * Delete records matching the query. Soft-deletes when the entity has a soft-delete field (unless
@@ -140,8 +148,24 @@ export declare abstract class AbstractQuerier implements Querier {
140
148
  */
141
149
  private findDoomed;
142
150
  protected abstract internalDeleteMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, opts?: QueryOptions): Promise<number>;
143
- saveOne<E extends object>(entity: Type<E>, payload: EntityData<E>): Promise<IdValue<E> | undefined>;
144
- saveMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<(IdValue<E> | undefined)[]>;
151
+ saveOne<E extends object>(entity: Type<E>, payload: EntityData<E>): Promise<EntityId<E> | undefined>;
152
+ /**
153
+ * Insert or update, as the name has always promised - and now as one statement per kind rather
154
+ * than a guess.
155
+ *
156
+ * Whether a row names its key decides which statement it takes, never whether the row exists: an
157
+ * id the caller invented is not proof of anything, and a stale one used to issue an `UPDATE` that
158
+ * matched nothing and reported success. A named row upserts on its own key, so it is written
159
+ * either way and no read can go stale between deciding and writing. An unnamed one inserts, and
160
+ * the database assigns the key.
161
+ *
162
+ * A composite key is always supplied by the caller, so it always takes the upsert branch - which
163
+ * is why nothing here special-cases one, and why this is the method that stopped refusing them.
164
+ *
165
+ * The hooks follow the statement: a named row fires `beforeUpsert`/`afterUpsert`, never the
166
+ * update pair, because the database picks the branch as the statement runs.
167
+ */
168
+ saveMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<(EntityId<E> | undefined)[]>;
145
169
  protected fillToManyRelations<E>(entity: Type<E>, payload: E[], populate?: QueryPopulate<E>): Promise<void>;
146
170
  private fillToManyThroughRelation;
147
171
  private fillToManyOneToMany;
@@ -196,6 +220,11 @@ export declare abstract class AbstractQuerier implements Querier {
196
220
  * Emit a lifecycle hook event for the given entity.
197
221
  * Fires global listeners first, then entity-level hooks.
198
222
  */
223
+ /**
224
+ * Runs `write` between the event's `before`/`after` pair. Every hooked write is this shape, and
225
+ * each one spelled out was a place the pair could drift - `upsert` had none at all for a release.
226
+ */
227
+ private hooked;
199
228
  private emitHook;
200
229
  /**
201
230
  * Runs `task` after everything already queued on this querier, one at a time.
@@ -1,4 +1,4 @@
1
- import { assertSoleId, getMeta, idOf, soleIdOf } from '../entity/index.js';
1
+ import { assertSoleId, getMeta, idOf, namesKey, soleIdOf } from '../entity/index.js';
2
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';
@@ -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) {
@@ -461,7 +494,7 @@ export class AbstractQuerier {
461
494
  case 'mm':
462
495
  return this.saveToMany(relOpts, relEntity, ids, relPayload, isUpdate);
463
496
  case '11':
464
- return this.saveOneToOne(relEntity, relOpts, ids, relPayload);
497
+ return this.saveOneToOne(relEntity, relOpts, ids, relPayload, isUpdate);
465
498
  case 'm1':
466
499
  if (relPayload)
467
500
  return this.saveManyToOne(entity, relEntity, relOpts, ids, relPayload);
@@ -500,11 +533,15 @@ export class AbstractQuerier {
500
533
  await this.saveMany(relEntity, ids.flatMap((id) => relPayload.map((it) => ({ ...it, [foreignField]: id }))));
501
534
  }
502
535
  }
503
- async saveOneToOne(relEntity, relOpts, ids, relPayload) {
536
+ async saveOneToOne(relEntity, relOpts, ids, relPayload, isUpdate) {
504
537
  const foreignField = soleParentColumn(relOpts);
505
- 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) {
506
541
  await this.deleteMany(relEntity, { $where: { [foreignField]: ids } });
507
- return;
542
+ if (relPayload === null) {
543
+ return;
544
+ }
508
545
  }
509
546
  await this.saveMany(relEntity, ids.map((id) => ({ ...relPayload, [foreignField]: id })));
510
547
  }
@@ -563,6 +600,16 @@ export class AbstractQuerier {
563
600
  * Emit a lifecycle hook event for the given entity.
564
601
  * Fires global listeners first, then entity-level hooks.
565
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
+ }
566
613
  async emitHook(entity, event, payloads) {
567
614
  if (!this.hasHook(entity, event))
568
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);
@@ -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`)
@@ -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.