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.
- 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/abstractDialect.d.ts +35 -1
- package/dist/dialect/abstractDialect.js +29 -0
- package/dist/dialect/abstractSqlDialect.d.ts +13 -5
- package/dist/dialect/abstractSqlDialect.js +45 -49
- 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 +36 -6
- package/dist/mongo/mongoDialect.js +116 -31
- 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 +92 -43
- 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/querier/relationCount.js +15 -9
- package/dist/type/entity.d.ts +1 -1
- package/dist/type/queryRaw.d.ts +1 -1
- package/dist/type/queryWhere.d.ts +11 -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/dist/util/relationQuery.util.d.ts +9 -6
- package/dist/util/relationQuery.util.js +11 -11
- package/dist/util/rowKey.util.d.ts +18 -4
- package/dist/util/rowKey.util.js +29 -5
- 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,
|
|
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
|
-
|
|
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) {
|
|
@@ -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
|
-
|
|
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[
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
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);
|
|
@@ -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,
|
|
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
|
|
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, {
|
|
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, {
|
|
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[
|
|
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
|
-
|
|
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
|
}
|
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`)
|
|
@@ -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 -
|
|
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<
|
|
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.
|
|
@@ -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
|
|
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
|
|
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.
|