uql-orm 0.76.0 → 0.77.1
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/uql-browser.min.js +2 -2
- package/dist/browser/uql-browser.min.js.map +4 -4
- package/dist/d1/d1Querier.js +2 -1
- package/dist/d1/d1SqliteDialect.js +2 -1
- package/dist/dialect/abstractDialect.d.ts +7 -1
- package/dist/dialect/abstractDialect.js +17 -5
- package/dist/dialect/abstractSqlDialect.d.ts +4 -4
- package/dist/dialect/abstractSqlDialect.js +30 -26
- package/dist/dialect/mysqlLikeSqlDialect.d.ts +3 -1
- package/dist/dialect/mysqlLikeSqlDialect.js +3 -3
- package/dist/dialect/pgLikeSqlDialect.js +1 -3
- package/dist/dialect/queryJoins.js +6 -5
- package/dist/dialect/vectorSqlDialect.js +2 -1
- package/dist/http/query.js +4 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/maria/mariaDialect.js +2 -2
- package/dist/mongo/mongoDialect.d.ts +0 -6
- package/dist/mongo/mongoDialect.js +21 -29
- package/dist/mongo/mongodbQuerier.js +0 -1
- package/dist/mssql/mssqlDialect.d.ts +1 -5
- package/dist/mssql/mssqlDialect.js +4 -11
- package/dist/querier/abstractQuerier.d.ts +16 -3
- package/dist/querier/abstractQuerier.js +100 -60
- package/dist/querier/abstractSqlQuerier.d.ts +0 -5
- package/dist/querier/abstractSqlQuerier.js +4 -17
- package/dist/querier/queryError.d.ts +1 -17
- package/dist/querier/queryError.js +3 -18
- package/dist/sqlite/sqliteDialect.js +0 -2
- package/dist/type/dialect.d.ts +18 -6
- package/dist/type/queryAggregate.js +2 -1
- package/dist/type/queryLock.js +2 -1
- package/dist/type/queryWhere.d.ts +7 -6
- package/dist/type/utility.d.ts +8 -0
- package/dist/util/dialect.util.js +14 -13
- package/dist/util/relationQuery.util.js +4 -3
- package/dist/util/uqlError.d.ts +39 -0
- package/dist/util/uqlError.js +35 -0
- package/package.json +1 -1
- package/skills/uql-orm/SKILL.md +7 -3
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { assertSoleId, getMeta, idOf, namesKey, relationOf } from '../entity/index.js';
|
|
2
|
+
import { parseQueryLock } from '../type/index.js';
|
|
2
3
|
import { cascadesOnDelete, childrenOf, clone, entityName, fillOnFields, filterFieldKeys, filterPersistableRelationKeys, forEachRequestedRelation, getKeys, getRelationRequestSummary, idOnlyQuery, isPagedQuery, hasKeys, isScalarId, LoggerWrapper, parentJoins, queryLoggerFor, parseRelationAtKey, parseRelationQueryValue, rowKey, runHooks, someKey, targetKeyColumns, whereIds, withoutSoftDeleteFilter, } from '../util/index.js';
|
|
3
|
-
import {
|
|
4
|
+
import { UqlOptimisticLockError, UqlUsageError } from '../util/uqlError.js';
|
|
5
|
+
import { enrichError } from './queryError.js';
|
|
4
6
|
/**
|
|
5
7
|
* Refuses a nullish id, which would reduce to no filter at all, and a composite id missing a column,
|
|
6
8
|
* which would address every row agreeing on the rest. Callers are `async`, so it always rejects.
|
|
7
9
|
*/
|
|
8
10
|
function assertIdValue(entity, id) {
|
|
9
11
|
if (id === undefined || id === null) {
|
|
10
|
-
throw new
|
|
12
|
+
throw new UqlUsageError(`'${entity.name}' was addressed by id, but the id is ${String(id)}`);
|
|
11
13
|
}
|
|
12
14
|
if (isScalarId(id)) {
|
|
13
15
|
// One value names one column, which `whereIds` refuses on a composite.
|
|
@@ -20,7 +22,7 @@ function assertIdValue(entity, id) {
|
|
|
20
22
|
const { ids } = getMeta(entity);
|
|
21
23
|
const missing = ids.filter((key) => given[key] == null);
|
|
22
24
|
if (missing.length) {
|
|
23
|
-
throw new
|
|
25
|
+
throw new UqlUsageError(`'${entity.name}' is addressed by an object carrying every key of its primary key (${ids.join(', ')}); missing ${missing.join(', ')}.`);
|
|
24
26
|
}
|
|
25
27
|
}
|
|
26
28
|
/** The column a write matches a parent's sole key against, on the junction or the child. */
|
|
@@ -36,29 +38,44 @@ function assertNamesRows(entity, method, q, opts) {
|
|
|
36
38
|
if (opts?.unfiltered || hasKeys(q?.$where) || q?.$limit !== undefined) {
|
|
37
39
|
return;
|
|
38
40
|
}
|
|
39
|
-
throw new
|
|
41
|
+
throw new UqlUsageError(`'${method}' over '${entity.name}' names no rows, so it would address every one: pass '{ unfiltered: true }' to mean it`);
|
|
40
42
|
}
|
|
41
43
|
/**
|
|
42
|
-
* An optimistic lock as one update applies it: the
|
|
43
|
-
*
|
|
44
|
-
* arithmetic, since
|
|
44
|
+
* An optimistic lock as one update applies it: the version the payload carried, the one that replaces
|
|
45
|
+
* it, and the filter pinning what the column still holds. The bump is a plain value rather than SQL
|
|
46
|
+
* arithmetic, since that filter already pins it, which spares every engine a read-back.
|
|
45
47
|
*/
|
|
46
48
|
function lockVersion(meta, key, q, row) {
|
|
47
49
|
const expected = row[key];
|
|
48
|
-
if (expected
|
|
49
|
-
throw new
|
|
50
|
+
if (typeof expected !== 'number' && typeof expected !== 'bigint') {
|
|
51
|
+
throw new UqlUsageError(`an update of '${entityName(meta)}' carries no '${key}': a versioned row is written against the version it was read at`);
|
|
50
52
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
+
const next = typeof expected === 'bigint' ? expected + 1n : expected + 1;
|
|
54
|
+
// Spread, as every other added predicate here is: one flat `AND`, and a caller already filtering on
|
|
55
|
+
// the version contradicts itself into matching nothing, which is what they asked for.
|
|
56
|
+
return { expected, next, q: { ...q, $where: { ...q.$where, [key]: expected } } };
|
|
53
57
|
}
|
|
54
58
|
/**
|
|
55
59
|
* Refuses a write that cannot carry the lock, rather than writing over whatever the row holds now.
|
|
56
60
|
* An upsert has no portable way to match a version - MySQL's `ON DUPLICATE KEY UPDATE` takes no
|
|
57
61
|
* `WHERE` - and a write the library itself composes has no version to carry.
|
|
58
62
|
*/
|
|
59
|
-
function assertUnversioned(meta,
|
|
63
|
+
function assertUnversioned(meta, what) {
|
|
60
64
|
if (meta.version) {
|
|
61
|
-
throw new
|
|
65
|
+
throw new UqlUsageError(`cannot ${what} the versioned '${entityName(meta)}': it carries no '${meta.version}' to match, so update it by id`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* What a versioned update has to be for its lock to hold: one row, named by its id, written by one
|
|
70
|
+
* statement. A filter naming more than one row cannot say which of them the payload's single version
|
|
71
|
+
* belongs to, and anything settled first - a page, a relation write, a filter an engine cannot read in
|
|
72
|
+
* an `UPDATE` - reads the ids and writes them separately, putting the race back in the gap between.
|
|
73
|
+
*/
|
|
74
|
+
function assertLockableUpdate(meta, q, settles) {
|
|
75
|
+
const where = q.$where;
|
|
76
|
+
const namesOneRow = meta.ids.every((key) => where?.[key] !== undefined && isScalarId(where[key]));
|
|
77
|
+
if (!namesOneRow || settles) {
|
|
78
|
+
throw new UqlUsageError(`cannot update '${entityName(meta)}' this way: a versioned row is matched and written in one statement, so it is named by its ${meta.ids.map((id) => `'${id}'`).join(', ')}, takes no '$sort', '$limit' or '$skip', writes no relation, and filters by none`);
|
|
62
79
|
}
|
|
63
80
|
}
|
|
64
81
|
/**
|
|
@@ -101,15 +118,31 @@ export class AbstractQuerier {
|
|
|
101
118
|
this.extra = extra;
|
|
102
119
|
this.logger = queryLoggerFor(extra);
|
|
103
120
|
}
|
|
104
|
-
|
|
121
|
+
/** What every read is checked for before it runs, whichever backend runs it. */
|
|
122
|
+
validateReadQuery(entity, q) {
|
|
123
|
+
this.assertLockable(entity, q);
|
|
105
124
|
this.validateProjectionQueryRecursive(entity, q, entityName(getMeta(entity)));
|
|
106
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* Refuses a `$lock` the engine cannot take, then one outside a transaction, where the lock would
|
|
128
|
+
* drop as the statement commits: only the querier knows whether one is open. Here rather than in
|
|
129
|
+
* each backend's read, so the rule reaches a find, a stream and a paged count alike.
|
|
130
|
+
*/
|
|
131
|
+
assertLockable(entity, q) {
|
|
132
|
+
if (!parseQueryLock(q.$lock)) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
this.dialect.assertLockSupported(entity, q);
|
|
136
|
+
if (!this.hasOpenTransaction) {
|
|
137
|
+
throw new UqlUsageError('$lock requires an open transaction');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
107
140
|
validateProjectionQueryRecursive(entity, q, path) {
|
|
108
141
|
const meta = getMeta(entity);
|
|
109
142
|
if (q.$select && q.$exclude) {
|
|
110
143
|
for (const [key, value] of Object.entries(q.$select)) {
|
|
111
144
|
if (key in meta.fields && value) {
|
|
112
|
-
throw new
|
|
145
|
+
throw new UqlUsageError(`Cannot combine $select and $exclude when $select includes positive scalar fields (${key}) at ${path}. Use either $select (whitelist) or $exclude (subtractive) in a single query.`);
|
|
113
146
|
}
|
|
114
147
|
}
|
|
115
148
|
}
|
|
@@ -129,7 +162,7 @@ export class AbstractQuerier {
|
|
|
129
162
|
}
|
|
130
163
|
const q = entityOrQuery;
|
|
131
164
|
if (!q.$entity) {
|
|
132
|
-
throw new
|
|
165
|
+
throw new UqlUsageError('$entity is required when using query-object syntax');
|
|
133
166
|
}
|
|
134
167
|
const { $entity, ...query } = q;
|
|
135
168
|
return [$entity, query, maybeQueryOrOpts];
|
|
@@ -145,7 +178,7 @@ export class AbstractQuerier {
|
|
|
145
178
|
}
|
|
146
179
|
async findMany(entityOrQuery, maybeQueryOrOpts, maybeOpts) {
|
|
147
180
|
const [entity, q, opts] = this.resolveEntityQuery(entityOrQuery, maybeQueryOrOpts, maybeOpts);
|
|
148
|
-
this.
|
|
181
|
+
this.validateReadQuery(entity, q);
|
|
149
182
|
const founds = await this.internalFindMany(entity, q, opts);
|
|
150
183
|
// Guarded here rather than only inside: awaiting a call that returns at once still costs every read
|
|
151
184
|
// a promise and a turn of the microtask queue, and most reads hook nothing.
|
|
@@ -156,12 +189,12 @@ export class AbstractQuerier {
|
|
|
156
189
|
}
|
|
157
190
|
findManyStream(entityOrQuery, maybeQueryOrOpts, maybeOpts) {
|
|
158
191
|
const [entity, q, opts] = this.resolveEntityQuery(entityOrQuery, maybeQueryOrOpts, maybeOpts);
|
|
159
|
-
this.
|
|
192
|
+
this.validateReadQuery(entity, q);
|
|
160
193
|
return this.internalFindManyStream(entity, q, opts);
|
|
161
194
|
}
|
|
162
195
|
async findManyAndCount(entityOrQuery, maybeQueryOrOpts, maybeOpts) {
|
|
163
196
|
const [entity, q, opts] = this.resolveEntityQuery(entityOrQuery, maybeQueryOrOpts, maybeOpts);
|
|
164
|
-
this.
|
|
197
|
+
this.validateReadQuery(entity, q);
|
|
165
198
|
const [founds, count] = await this.internalFindManyAndCount(entity, q, opts);
|
|
166
199
|
if (this.listensForLoad(entity, q.$populate)) {
|
|
167
200
|
await this.emitLoaded(entity, founds, q.$populate);
|
|
@@ -218,45 +251,55 @@ export class AbstractQuerier {
|
|
|
218
251
|
/** Settles the rows first where the update cascades, so a payload changing what `$where` reads still names them. */
|
|
219
252
|
async updateMany(entity, q, payload, opts) {
|
|
220
253
|
assertNamesRows(entity, 'updateMany', q, opts);
|
|
254
|
+
return this.hooked(entity, 'Update', [payload], ([row]) => this.updateRows(entity, q, row, opts, getMeta(entity).version));
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* The write every update runs, matching the version `lockKey` names where one is being held. Only a
|
|
258
|
+
* restore passes none: it writes no content, so there is no update of anyone's to lose.
|
|
259
|
+
*/
|
|
260
|
+
async updateRows(entity, q, row, opts, lockKey) {
|
|
221
261
|
const meta = getMeta(entity);
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}
|
|
245
|
-
return changes;
|
|
246
|
-
});
|
|
262
|
+
fillOnFields(meta, [row], 'onUpdate');
|
|
263
|
+
const relKeys = filterPersistableRelationKeys(meta, row, 'persist');
|
|
264
|
+
const settles = !!relKeys.length || this.settlesWrite(entity, q);
|
|
265
|
+
if (lockKey) {
|
|
266
|
+
assertLockableUpdate(meta, q, settles);
|
|
267
|
+
const lock = lockVersion(meta, lockKey, q, row);
|
|
268
|
+
row[lockKey] = lock.next;
|
|
269
|
+
const changes = await this.updateColumns(entity, lock.q, row, opts, 0);
|
|
270
|
+
return changes || this.throwStaleVersion(entity, lockKey, q, lock.expected, opts);
|
|
271
|
+
}
|
|
272
|
+
if (!settles) {
|
|
273
|
+
return this.updateColumns(entity, q, row, opts, 0);
|
|
274
|
+
}
|
|
275
|
+
const ids = await this.settleIds(entity, q, opts);
|
|
276
|
+
if (!ids.length) {
|
|
277
|
+
return 0;
|
|
278
|
+
}
|
|
279
|
+
const changes = await this.updateColumns(entity, { $where: whereIds(meta, ids) }, row, opts, ids.length);
|
|
280
|
+
for (const relKey of relKeys) {
|
|
281
|
+
await this.saveRelation(entity, relKey, ids.map((id) => ({ id, value: row[relKey] })), true);
|
|
282
|
+
}
|
|
283
|
+
return changes;
|
|
247
284
|
}
|
|
248
285
|
/**
|
|
249
|
-
* Why an update matched no row
|
|
250
|
-
*
|
|
286
|
+
* Why an update matched no row. The filter named the row by its id, so reading by that id alone
|
|
287
|
+
* separates the three: the row is gone, another writer moved the version on, or the rest of the
|
|
288
|
+
* filter excluded a row still at that version. One read, only on the failure, so the happy path
|
|
251
289
|
* still costs one statement. Best effort by nature - the row can change again while we ask.
|
|
252
290
|
*/
|
|
253
291
|
async throwStaleVersion(entity, key, q, expected, opts) {
|
|
254
292
|
const meta = getMeta(entity);
|
|
255
|
-
const
|
|
293
|
+
const where = q.$where;
|
|
294
|
+
const byId = Object.fromEntries(meta.ids.map((id) => [id, where[id]]));
|
|
295
|
+
const row = await this.findOne(entity, { $select: { [key]: true }, $where: byId }, opts);
|
|
256
296
|
const actual = row?.[key];
|
|
257
|
-
|
|
258
|
-
? `no row of '${entityName(meta)}'
|
|
259
|
-
:
|
|
297
|
+
const message = actual === undefined
|
|
298
|
+
? `no row of '${entityName(meta)}' has that id any more: it is gone`
|
|
299
|
+
: actual === expected
|
|
300
|
+
? `'${entityName(meta)}' is still at '${key}' ${String(actual)}: another condition of the update's '$where' excluded it`
|
|
301
|
+
: `'${entityName(meta)}' moved on: the payload carries '${key}' ${String(expected)}, the row is at ${String(actual)}`;
|
|
302
|
+
throw new UqlOptimisticLockError(message, expected, actual);
|
|
260
303
|
}
|
|
261
304
|
/** The UPDATE, skipped where the payload writes no column, reporting `unwritten` instead. */
|
|
262
305
|
async updateColumns(entity, q, row, opts, unwritten) {
|
|
@@ -284,18 +327,17 @@ export class AbstractQuerier {
|
|
|
284
327
|
async restoreMany(entity, q) {
|
|
285
328
|
const meta = getMeta(entity);
|
|
286
329
|
if (!meta.softDelete) {
|
|
287
|
-
throw new
|
|
330
|
+
throw new UqlUsageError(`'${entity.name}' has not enabled 'softDelete'`);
|
|
288
331
|
}
|
|
289
|
-
assertUnversioned(meta, 'restoreMany');
|
|
290
332
|
const $where = { ...q.$where, [meta.softDelete]: { $ne: null } };
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
});
|
|
333
|
+
// No version: a restore only undoes the stamp a delete left, which takes none either, and two of
|
|
334
|
+
// them racing agree on the result anyway. A lock is for content, and a restore writes none.
|
|
335
|
+
return this.hooked(entity, 'Update', [{ [meta.softDelete]: null }], ([row]) => this.updateRows(entity, { ...q, $where }, row, { filters: { softDelete: false } }, undefined));
|
|
294
336
|
}
|
|
295
337
|
/** Fires `beforeUpsert`/`afterUpsert`: which branch a row takes is the database's to decide, so neither the insert's nor the update's pair fits. */
|
|
296
338
|
async upsertOne(entity, conflictPaths, payload) {
|
|
297
339
|
const meta = getMeta(entity);
|
|
298
|
-
assertUnversioned(meta, 'upsertOne');
|
|
340
|
+
assertUnversioned(meta, "'upsertOne'");
|
|
299
341
|
return this.hooked(entity, 'Upsert', [payload], async (rows) => {
|
|
300
342
|
const { ids, changes, created } = await this.internalUpsertOne(entity, conflictPaths, rows[0]);
|
|
301
343
|
adoptReportedIds(meta, rows, ids);
|
|
@@ -305,7 +347,7 @@ export class AbstractQuerier {
|
|
|
305
347
|
}
|
|
306
348
|
async upsertMany(entity, conflictPaths, payload) {
|
|
307
349
|
const meta = getMeta(entity);
|
|
308
|
-
assertUnversioned(meta, 'upsertMany');
|
|
350
|
+
assertUnversioned(meta, "'upsertMany'");
|
|
309
351
|
return this.hooked(entity, 'Upsert', payload, async (rows) => {
|
|
310
352
|
const { ids, changes } = await this.internalUpsertMany(entity, conflictPaths, rows);
|
|
311
353
|
adoptReportedIds(meta, rows, ids);
|
|
@@ -343,8 +385,6 @@ export class AbstractQuerier {
|
|
|
343
385
|
return changes;
|
|
344
386
|
}
|
|
345
387
|
async saveOne(entity, payload) {
|
|
346
|
-
// Named here as well as in `saveMany`, so the refusal names the method the caller reached for.
|
|
347
|
-
assertUnversioned(getMeta(entity), 'saveOne');
|
|
348
388
|
const [id] = await this.saveMany(entity, [payload]);
|
|
349
389
|
return id;
|
|
350
390
|
}
|
|
@@ -355,7 +395,7 @@ export class AbstractQuerier {
|
|
|
355
395
|
*/
|
|
356
396
|
async saveMany(entity, payload) {
|
|
357
397
|
const meta = getMeta(entity);
|
|
358
|
-
assertUnversioned(meta, '
|
|
398
|
+
assertUnversioned(meta, "'save'");
|
|
359
399
|
// Indexes, not rows: the result is reported in payload order so it can be zipped with what was
|
|
360
400
|
// passed, which concatenating the branches did not do.
|
|
361
401
|
const toInsert = [];
|
|
@@ -38,11 +38,6 @@ export declare abstract class AbstractSqlQuerier extends AbstractQuerier impleme
|
|
|
38
38
|
private query;
|
|
39
39
|
/** Runs a statement the dialect builds. */
|
|
40
40
|
private exec;
|
|
41
|
-
/**
|
|
42
|
-
* Refuses a `$lock` the engine lacks, then one outside a transaction, where the lock would drop as the
|
|
43
|
-
* statement commits; only the querier knows whether one is open.
|
|
44
|
-
*/
|
|
45
|
-
protected assertLockable<E>(entity: Type<E>, q: Query<E>): void;
|
|
46
41
|
/**
|
|
47
42
|
* Runs the `SET`s tuning an ANN index for the query on its connection, refusing where they would apply to
|
|
48
43
|
* nothing: a `SET LOCAL` outside a transaction.
|
|
@@ -3,6 +3,7 @@ import { decodeColumn } from '../dialect/hydrateColumn.js';
|
|
|
3
3
|
import { getMeta, namesKey } from '../entity/index.js';
|
|
4
4
|
import { COUNT_RESULT_KEY } from '../type/index.js';
|
|
5
5
|
import { buildUpdateResult, clone, getInsertFieldKeys, insertShapeOf, isAutoIncrement, isRecord, obtainAttrsPaths, throwNoPendingTransaction, throwPendingTransaction, unflatObject, unflatObjects, } from '../util/index.js';
|
|
6
|
+
import { UqlUsageError } from '../util/uqlError.js';
|
|
6
7
|
import { AbstractQuerier } from './abstractQuerier.js';
|
|
7
8
|
import { streamViaCursor } from './cursorStream.js';
|
|
8
9
|
import { enrichError } from './queryError.js';
|
|
@@ -117,19 +118,6 @@ export class AbstractSqlQuerier extends AbstractQuerier {
|
|
|
117
118
|
build(ctx);
|
|
118
119
|
return this.run(ctx.sql, ctx.values);
|
|
119
120
|
}
|
|
120
|
-
/**
|
|
121
|
-
* Refuses a `$lock` the engine lacks, then one outside a transaction, where the lock would drop as the
|
|
122
|
-
* statement commits; only the querier knows whether one is open.
|
|
123
|
-
*/
|
|
124
|
-
assertLockable(entity, q) {
|
|
125
|
-
if (!q.$lock) {
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
this.dialect.assertLockSupported(entity, q);
|
|
129
|
-
if (!this.hasOpenTransaction) {
|
|
130
|
-
throw new TypeError('$lock requires an open transaction');
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
121
|
/**
|
|
134
122
|
* Runs the `SET`s tuning an ANN index for the query on its connection, refusing where they would apply to
|
|
135
123
|
* nothing: a `SET LOCAL` outside a transaction.
|
|
@@ -143,7 +131,7 @@ export class AbstractSqlQuerier extends AbstractQuerier {
|
|
|
143
131
|
return;
|
|
144
132
|
}
|
|
145
133
|
if (this.dialect.features.vectorTuningNeedsTransaction && !this.hasOpenTransaction) {
|
|
146
|
-
throw new
|
|
134
|
+
throw new UqlUsageError(`$candidates requires an open transaction on ${this.dialect.dialectName}; run the query inside pool.transaction(...)`);
|
|
147
135
|
}
|
|
148
136
|
for (const statement of statements) {
|
|
149
137
|
await this.internalRun(statement);
|
|
@@ -164,7 +152,8 @@ export class AbstractSqlQuerier extends AbstractQuerier {
|
|
|
164
152
|
* so those count apart.
|
|
165
153
|
*/
|
|
166
154
|
async internalFindManyAndCount(entity, q, opts) {
|
|
167
|
-
|
|
155
|
+
const { rowLocks } = this.dialect.features;
|
|
156
|
+
if (q.$distinct || (q.$lock && !(rowLocks && rowLocks.withWindow))) {
|
|
168
157
|
return Promise.all([this.internalFindMany(entity, q, opts), this.countUnpaged(entity, q, opts)]);
|
|
169
158
|
}
|
|
170
159
|
const rows = await this.selectRows(entity, q, opts, TOTAL_ALIAS);
|
|
@@ -175,7 +164,6 @@ export class AbstractSqlQuerier extends AbstractQuerier {
|
|
|
175
164
|
return [this.hydrateRows(entity, rows), total];
|
|
176
165
|
}
|
|
177
166
|
async selectRows(entity, q, opts, totalAlias) {
|
|
178
|
-
this.assertLockable(entity, q);
|
|
179
167
|
// Guarded rather than awaited unconditionally, here and in the stream below: an `await` on this
|
|
180
168
|
// path defers a microtask on every read, which reorders the two statements `findManyAndCount`
|
|
181
169
|
// issues concurrently. Keep the guard at any new call site.
|
|
@@ -190,7 +178,6 @@ export class AbstractSqlQuerier extends AbstractQuerier {
|
|
|
190
178
|
return founds;
|
|
191
179
|
}
|
|
192
180
|
async *internalFindManyStream(entity, q, opts) {
|
|
193
|
-
this.assertLockable(entity, q);
|
|
194
181
|
// Guarded for the reason `selectRows` above spells out.
|
|
195
182
|
if (q.$candidates !== undefined) {
|
|
196
183
|
await this.applyVectorTuning(entity, q);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { LoggerWrapper } from '../util/logger.js';
|
|
2
|
+
import { type QueryErrorKind } from '../util/uqlError.js';
|
|
2
3
|
/**
|
|
3
4
|
* A driver error tagged by {@link enrichError}: `query` always, `values` only when the logger already
|
|
4
5
|
* surfaces them, since they can carry PII or tokens into whatever serializes the error.
|
|
@@ -7,23 +8,6 @@ export interface QueryError extends Error {
|
|
|
7
8
|
query?: string;
|
|
8
9
|
values?: unknown[];
|
|
9
10
|
}
|
|
10
|
-
/**
|
|
11
|
-
* What a failed query ran into, named the same on every engine. `retryable` is a deadlock, a
|
|
12
|
-
* serialization failure, a lock timeout or a busy database: the transaction can simply run again.
|
|
13
|
-
*/
|
|
14
|
-
export type QueryErrorKind = 'uniqueViolation' | 'foreignKeyViolation' | 'notNullViolation' | 'checkViolation' | 'optimisticLock' | 'retryable';
|
|
15
|
-
/**
|
|
16
|
-
* Thrown when an update's `@Field({ version })` no longer matches the row: another writer moved it on,
|
|
17
|
-
* or it is gone. `expected` is what the payload carried, `actual` what the row holds now, `undefined`
|
|
18
|
-
* where there is no row left. `status` is what an HTTP transport answers with.
|
|
19
|
-
*/
|
|
20
|
-
export declare class UqlOptimisticLockError extends Error {
|
|
21
|
-
readonly expected: unknown;
|
|
22
|
-
readonly actual: unknown;
|
|
23
|
-
name: string;
|
|
24
|
-
readonly status = 409;
|
|
25
|
-
constructor(message: string, expected: unknown, actual: unknown);
|
|
26
|
-
}
|
|
27
11
|
/**
|
|
28
12
|
* Names what `err` ran into on any engine, or `undefined` for anything else. Pure: the error is only
|
|
29
13
|
* read, so it works on any driver error, whether or not a querier saw it first.
|
|
@@ -1,19 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* Thrown when an update's `@Field({ version })` no longer matches the row: another writer moved it on,
|
|
3
|
-
* or it is gone. `expected` is what the payload carried, `actual` what the row holds now, `undefined`
|
|
4
|
-
* where there is no row left. `status` is what an HTTP transport answers with.
|
|
5
|
-
*/
|
|
6
|
-
export class UqlOptimisticLockError extends Error {
|
|
7
|
-
expected;
|
|
8
|
-
actual;
|
|
9
|
-
name = 'UqlOptimisticLockError';
|
|
10
|
-
status = 409;
|
|
11
|
-
constructor(message, expected, actual) {
|
|
12
|
-
super(message);
|
|
13
|
-
this.expected = expected;
|
|
14
|
-
this.actual = actual;
|
|
15
|
-
}
|
|
16
|
-
}
|
|
1
|
+
import { UqlOptimisticLockError, UqlUsageError } from '../util/uqlError.js';
|
|
17
2
|
/** Postgres, CockroachDB, PGlite and Neon in `code`; Bun SQL in `errno`. */
|
|
18
3
|
const SQLSTATE_KINDS = new Map([
|
|
19
4
|
['23505', 'uniqueViolation'],
|
|
@@ -68,8 +53,8 @@ export function queryErrorKind(err) {
|
|
|
68
53
|
if (typeof err !== 'object' || err === null) {
|
|
69
54
|
return undefined;
|
|
70
55
|
}
|
|
71
|
-
if (err instanceof UqlOptimisticLockError) {
|
|
72
|
-
return
|
|
56
|
+
if (err instanceof UqlOptimisticLockError || err instanceof UqlUsageError) {
|
|
57
|
+
return err.kind;
|
|
73
58
|
}
|
|
74
59
|
const { code, errno, number, errorLabels, message } = err;
|
|
75
60
|
const text = typeof message === 'string' ? message : '';
|
|
@@ -34,9 +34,7 @@ export const SQLITE_FEATURES = {
|
|
|
34
34
|
serverSideCursors: false,
|
|
35
35
|
correlatedWrites: true,
|
|
36
36
|
rowLocks: false,
|
|
37
|
-
rowLockWithWindow: true,
|
|
38
37
|
nullsOrdering: 'clause',
|
|
39
|
-
rowLockOf: true,
|
|
40
38
|
textScoreIndexes: false,
|
|
41
39
|
orderedUpsertReturning: true,
|
|
42
40
|
orderedJsonAggregates: true,
|
package/dist/type/dialect.d.ts
CHANGED
|
@@ -135,15 +135,27 @@ export interface DialectFeatures {
|
|
|
135
135
|
* such a write reads the ids of the rows it names first.
|
|
136
136
|
*/
|
|
137
137
|
readonly correlatedWrites: boolean;
|
|
138
|
+
/**
|
|
139
|
+
* What the engine's row locks can do, or `false` where it has none: the SQLite family locks the
|
|
140
|
+
* database and MongoDB has no row lock at all, so both refuse `$lock` rather than ignoring it. One
|
|
141
|
+
* value rather than a flag each, since the details mean nothing without a lock.
|
|
142
|
+
*/
|
|
143
|
+
readonly rowLocks: RowLockFeatures | false;
|
|
144
|
+
}
|
|
145
|
+
/** How a dialect spells a row lock, once {@link DialectFeatures.rowLocks} says it has one. */
|
|
146
|
+
export interface RowLockFeatures {
|
|
147
|
+
/** Whether a lock can be narrowed to one table of a join, `FOR UPDATE OF`, which MariaDB lacks. */
|
|
148
|
+
readonly of: boolean;
|
|
149
|
+
/** Whether the lock may share a statement with a window function, which the Postgres family refuses. */
|
|
150
|
+
readonly withWindow: boolean;
|
|
151
|
+
/**
|
|
152
|
+
* Where the lock is spelled: after the statement (`FOR UPDATE`), or as a hint on the table it reads
|
|
153
|
+
* (`WITH (UPDLOCK)`, SQL Server). A dialect's `lockHint` states the hint itself.
|
|
154
|
+
*/
|
|
155
|
+
readonly placement: 'suffix' | 'tableHint';
|
|
138
156
|
}
|
|
139
157
|
/** What a SQL engine can do beyond {@link DialectFeatures}, read where a statement is built. */
|
|
140
158
|
export interface SqlDialectFeatures extends DialectFeatures {
|
|
141
|
-
/** Whether the engine has row locks at all. The SQLite family locks the database instead. */
|
|
142
|
-
readonly rowLocks: boolean;
|
|
143
|
-
/** Whether `FOR UPDATE` may share a statement with a window function, which the Postgres family refuses. */
|
|
144
|
-
readonly rowLockWithWindow: boolean;
|
|
145
|
-
/** Whether a lock can be narrowed to one table of a join, `FOR UPDATE OF`, which MariaDB lacks. */
|
|
146
|
-
readonly rowLockOf: boolean;
|
|
147
159
|
/**
|
|
148
160
|
* How a `$sort` states where nulls land: the `NULLS FIRST/LAST` clause, a leading `IS NULL` term
|
|
149
161
|
* (MySQL, MariaDB), or a leading `CASE` (SQL Server, which has no orderable boolean).
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { UqlUsageError } from '../util/uqlError.js';
|
|
1
2
|
const QUERY_AGGREGATE_OPS = ['$count', '$sum', '$avg', '$min', '$max'];
|
|
2
3
|
/**
|
|
3
4
|
* Whether `op` is one of {@link QueryAggregateOp}'s known aggregate operators - validates operator
|
|
@@ -17,5 +18,5 @@ export function resolveAggregateOp(key) {
|
|
|
17
18
|
if (isQueryAggregateOp(key)) {
|
|
18
19
|
return { op: key, distinct: false };
|
|
19
20
|
}
|
|
20
|
-
throw new
|
|
21
|
+
throw new UqlUsageError(`unsupported aggregate operator: ${key}`);
|
|
21
22
|
}
|
package/dist/type/queryLock.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { UqlUsageError } from '../util/uqlError.js';
|
|
1
2
|
const QUERY_LOCK_WAITS = ['nowait', 'skip'];
|
|
2
3
|
function isOneOf(vals, val) {
|
|
3
4
|
return vals.includes(val);
|
|
@@ -15,7 +16,7 @@ export function parseQueryLock(lock) {
|
|
|
15
16
|
return 'block';
|
|
16
17
|
}
|
|
17
18
|
if (!isOneOf(QUERY_LOCK_WAITS, lock.$wait)) {
|
|
18
|
-
throw new
|
|
19
|
+
throw new UqlUsageError(`unknown $lock wait policy: ${String(lock.$wait)}`);
|
|
19
20
|
}
|
|
20
21
|
return lock.$wait;
|
|
21
22
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { FieldKey, JsonFieldPaths, JsonFieldPathValue, RelationKey, RelationTarget } from './entity.js';
|
|
2
2
|
import type { QuerySelect } from './query.js';
|
|
3
3
|
import type { QueryRaw } from './queryRaw.js';
|
|
4
|
-
import type { ExpandScalar, IsMany, QueryComparableScalar, Scalar } from './utility.js';
|
|
4
|
+
import type { AtLeastOne, ExpandScalar, IsMany, QueryComparableScalar, Scalar } from './utility.js';
|
|
5
5
|
import type { QueryVectorQuery } from './vector.js';
|
|
6
6
|
/**
|
|
7
7
|
* options for full-text-search operator.
|
|
@@ -95,12 +95,13 @@ export type QuerySizeComparisonOps = {
|
|
|
95
95
|
};
|
|
96
96
|
/**
|
|
97
97
|
* Filter by distance to a vector, `{ $near: { $vector: v, $lt: 0.35 } }`: ordered bounds only, since a
|
|
98
|
-
* distance is a float
|
|
99
|
-
*
|
|
98
|
+
* distance is a float, and at least one, since none would filter nothing. Each clause names its own
|
|
99
|
+
* `$vector`, and `$distance` falls back to the field's. `/http` input is untyped, so the dialect
|
|
100
|
+
* checks it again at run time.
|
|
100
101
|
*/
|
|
101
|
-
export type QueryVectorNear = QueryVectorQuery & {
|
|
102
|
-
[K in QueryOrderedOp]
|
|
103
|
-
}
|
|
102
|
+
export type QueryVectorNear = QueryVectorQuery & AtLeastOne<{
|
|
103
|
+
[K in QueryOrderedOp]: NonNullable<QueryWhereFieldOperatorMap<number>[K]>;
|
|
104
|
+
}>;
|
|
104
105
|
export type QueryWhereFieldOperatorMap<T, Raw = QueryRaw> = {
|
|
105
106
|
/**
|
|
106
107
|
* whether a value is equal to the given value.
|
package/dist/type/utility.d.ts
CHANGED
|
@@ -59,6 +59,14 @@ export type RejectKeys<K> = [K] extends [never] ? unknown : Record<K & string, n
|
|
|
59
59
|
export type ExactlyOne<T> = {
|
|
60
60
|
[K in keyof T]: Readonly<Pick<T, K>> & Partial<Readonly<Record<Exclude<keyof T, K>, never>>>;
|
|
61
61
|
}[keyof T];
|
|
62
|
+
/**
|
|
63
|
+
* At least one key of `T` with its value, the rest optional: the looser sibling of {@link ExactlyOne},
|
|
64
|
+
* for options that combine but cannot all be left out. `Pick`, so every key stays linked to `T`'s own
|
|
65
|
+
* property and renames follow it through.
|
|
66
|
+
*/
|
|
67
|
+
export type AtLeastOne<T> = Partial<T> & {
|
|
68
|
+
[K in keyof T]: Pick<T, K>;
|
|
69
|
+
}[keyof T];
|
|
62
70
|
export type Unpacked<T> = T extends readonly (infer U)[] ? U : T extends (...args: unknown[]) => infer U ? U : T extends Promise<infer U> ? U : T;
|
|
63
71
|
/**
|
|
64
72
|
* Whether the value a property holds is many rather than one: a to-many relation, a scalar array, a
|