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.
- package/dist/browser/querier/httpQuerier.d.ts +2 -2
- package/dist/browser/type/clientQuerier.d.ts +3 -3
- package/dist/browser/uql-browser.min.js.map +2 -2
- package/dist/dialect/abstractSqlDialect.d.ts +1 -1
- package/dist/dialect/abstractSqlDialect.js +14 -6
- package/dist/entity/decorator/members.d.ts +2 -0
- package/dist/entity/decorator/members.js +2 -0
- package/dist/entity/index.d.ts +1 -1
- package/dist/entity/index.js +1 -1
- package/dist/entity/metadata/definition.d.ts +11 -2
- package/dist/entity/metadata/definition.js +11 -0
- package/dist/mongo/mongoDialect.d.ts +25 -5
- package/dist/mongo/mongoDialect.js +82 -21
- package/dist/mongo/mongodbQuerier.d.ts +8 -3
- package/dist/mongo/mongodbQuerier.js +30 -11
- package/dist/querier/abstractQuerier.d.ts +33 -4
- package/dist/querier/abstractQuerier.js +84 -37
- package/dist/querier/abstractQuerierPool.d.ts +2 -2
- package/dist/querier/abstractSqlQuerier.d.ts +3 -2
- package/dist/querier/abstractSqlQuerier.js +132 -31
- package/dist/type/entity.d.ts +1 -1
- package/dist/type/queryRaw.d.ts +1 -1
- package/dist/type/universalQuerier.d.ts +2 -2
- package/dist/util/dialect.util.d.ts +7 -0
- package/dist/util/dialect.util.js +14 -0
- package/dist/util/fieldOption.util.d.ts +1 -1
- package/dist/util/fieldOption.util.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
|
|
123
|
-
|
|
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<
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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 (
|
|
258
|
-
|
|
279
|
+
else if (writesMoreThanItsKey(it)) {
|
|
280
|
+
toUpsert.push(index);
|
|
259
281
|
}
|
|
260
282
|
else {
|
|
261
|
-
|
|
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
|
|
265
|
-
toInsert.length
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
47
|
-
saveMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<(
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
(this.dialect.
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
}
|
|
334
|
-
|
|
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
|
|
367
|
-
return this.
|
|
437
|
+
async internalUpsertOne(entity, conflictPaths, payload) {
|
|
438
|
+
return this.internalUpsertMany(entity, conflictPaths, [payload]);
|
|
368
439
|
}
|
|
369
|
-
async
|
|
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);
|
package/dist/type/entity.d.ts
CHANGED
|
@@ -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
|
*/
|
package/dist/type/queryRaw.d.ts
CHANGED
|
@@ -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
|
|
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<
|
|
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<(
|
|
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.
|