uql-orm 0.83.0 → 0.84.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/README.md +138 -6
- package/dist/browser/uql-browser.min.js +2 -2
- package/dist/browser/uql-browser.min.js.map +4 -4
- package/dist/context/context.browser.d.ts +0 -1
- package/dist/context/context.browser.js +0 -1
- package/dist/context/context.d.ts +0 -1
- package/dist/context/context.js +0 -1
- package/dist/dialect/abstractSqlDialect.d.ts +0 -1
- package/dist/dialect/abstractSqlDialect.js +14 -9
- package/dist/dialect/operators.d.ts +16 -0
- package/dist/dialect/operators.js +47 -4
- package/dist/http/fetchHandler.js +2 -1
- package/dist/http/handler.d.ts +2 -1
- package/dist/http/handler.js +97 -104
- package/dist/http/query.d.ts +10 -3
- package/dist/http/query.js +14 -10
- package/dist/mongo/mongoDialect.js +12 -24
- package/dist/querier/abstractQuerier.d.ts +11 -3
- package/dist/querier/abstractQuerier.js +55 -8
- package/dist/querier/queryError.js +2 -2
- package/dist/type/queryWhere.d.ts +10 -8
- package/dist/util/dialect.util.d.ts +15 -7
- package/dist/util/dialect.util.js +62 -46
- package/dist/util/uqlError.d.ts +19 -8
- package/dist/util/uqlError.js +15 -6
- package/package.json +3 -3
- package/skills/uql-orm/SKILL.md +2 -2
- package/dist/context/securityError.d.ts +0 -4
- package/dist/context/securityError.js +0 -4
package/dist/http/handler.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { EntityMeta, QuerierPool, Query, RequestSuccessResponse, Type, UqlContext } from '../type/index.js';
|
|
2
2
|
import { type CrudOperation, type HttpMethod } from './contract.js';
|
|
3
|
+
import { type WireFlags } from './query.js';
|
|
3
4
|
/**
|
|
4
5
|
* Framework-normalized request: adapters (express, fetch, ...) reduce their native
|
|
5
6
|
* request to this shape and get back a status + JSON body.
|
|
@@ -30,7 +31,7 @@ export type HookContext<E extends object, Ctx = unknown> = {
|
|
|
30
31
|
readonly op: CrudOperation;
|
|
31
32
|
readonly method: HttpMethod;
|
|
32
33
|
/** The parsed query, to reshape in place. Scope rows with a `security` filter instead, which a client cannot override. */
|
|
33
|
-
query: Query<E
|
|
34
|
+
query: Query<E> & WireFlags;
|
|
34
35
|
/**
|
|
35
36
|
* request payload - reassignable for sanitization or field injection.
|
|
36
37
|
*/
|
package/dist/http/handler.js
CHANGED
|
@@ -2,7 +2,7 @@ import { withContext } from '../context/context.js';
|
|
|
2
2
|
import { getEntities, getMeta, soleIdOf } from '../entity/index.js';
|
|
3
3
|
import { whereIds, whereWith } from '../util/dialect.util.js';
|
|
4
4
|
import { UqlUsageError } from '../util/uqlError.js';
|
|
5
|
-
import { entityPath, matchRoute } from './contract.js';
|
|
5
|
+
import { CRUD_ROUTES, entityPath, matchRoute, } from './contract.js';
|
|
6
6
|
import { parseQueryParams } from './query.js';
|
|
7
7
|
/** `Company (crm.Company)`: the class, and the table it maps, which is what tells two apart. */
|
|
8
8
|
function tableOf(entity) {
|
|
@@ -29,6 +29,7 @@ export function createRequestHandler(opts) {
|
|
|
29
29
|
"pass an 'entityPath', or pass only one of them in 'include'.");
|
|
30
30
|
}
|
|
31
31
|
const entityByPath = new Map([...byPath].map(([path, [entity]]) => [path, entity]));
|
|
32
|
+
const served = new Set(entities);
|
|
32
33
|
return (req) => {
|
|
33
34
|
const entity = entityByPath.get(req.entityPath);
|
|
34
35
|
if (!entity) {
|
|
@@ -43,121 +44,113 @@ export function createRequestHandler(opts) {
|
|
|
43
44
|
async function run(entity, { op, method, id }, req) {
|
|
44
45
|
const meta = getMeta(entity);
|
|
45
46
|
// QUERY (RFC 10008) carries the JSON query in the body instead of the query string
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
body: req.body,
|
|
53
|
-
context: req.context,
|
|
54
|
-
};
|
|
47
|
+
const query = parseQueryParams(method === 'QUERY' ? req.body : req.query);
|
|
48
|
+
const { method: verb } = CRUD_ROUTES[op];
|
|
49
|
+
// What the client sent, before the hooks: a relation a hook adds is the server's own to add.
|
|
50
|
+
assertServed(meta, query, served);
|
|
51
|
+
assertServed(meta, req.body, served);
|
|
52
|
+
const hookCtx = { meta, op, method, query, body: req.body, context: req.context };
|
|
55
53
|
const appContext = (await getContext?.(req.context)) ?? {};
|
|
56
|
-
// Resolved where the statement runs, not up front: a hook that aborts the request must not reach
|
|
57
|
-
// the pool at all, and picking one per request may cost a lookup this request will never use.
|
|
58
|
-
const resolvePool = async () => (typeof pool === 'function' ? pool(req.context, appContext) : pool);
|
|
59
|
-
/** Read paths: the pool acquires and releases; nothing here owns a connection. */
|
|
60
|
-
const withQuerier = async (fn) => (await resolvePool()).withQuerier(fn);
|
|
61
|
-
/** Write paths: one transaction per request, so a cascade that fails takes its parent with it. */
|
|
62
|
-
const withTransaction = async (fn) => (await resolvePool()).transaction(fn);
|
|
63
54
|
// Scope the whole request (hooks + querier + relation/cascade queries) to the resolved context.
|
|
64
55
|
return withContext(appContext, async () => {
|
|
65
56
|
await pre?.(hookCtx);
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (post) {
|
|
74
|
-
await post(hookCtx, resp.body);
|
|
75
|
-
}
|
|
76
|
-
return resp;
|
|
57
|
+
await (verb === 'GET' || verb === 'DELETE' ? preFilter : preSave)?.(hookCtx);
|
|
58
|
+
// Resolved after the hooks: one that aborts the request must not reach the pool at all.
|
|
59
|
+
const resolved = typeof pool === 'function' ? await pool(req.context, appContext) : pool;
|
|
60
|
+
// A read acquires and releases; a write is one transaction, so a failing cascade takes its parent with it.
|
|
61
|
+
const envelope = await (verb === 'GET' ? resolved.withQuerier(execute) : resolved.transaction(execute));
|
|
62
|
+
await post?.(hookCtx, envelope);
|
|
63
|
+
return { status: 200, body: envelope };
|
|
77
64
|
});
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const query = hookCtx
|
|
81
|
-
const
|
|
82
|
-
const
|
|
65
|
+
/** Runs `op`, reading the query and body after the hooks, which may have reshaped or reassigned them. */
|
|
66
|
+
async function execute(querier) {
|
|
67
|
+
const { query, body } = hookCtx;
|
|
68
|
+
const { hardDelete = false, count: counts } = query;
|
|
69
|
+
const scoped = id === undefined
|
|
70
|
+
? query
|
|
71
|
+
: { ...query, $where: whereWith(soleIdOf(meta, 'the HTTP handler'), id, query.$where) };
|
|
83
72
|
switch (op) {
|
|
84
73
|
case 'findOne':
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
case 'count':
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
return withQuerier(async (querier) => {
|
|
101
|
-
const findManyPromise = querier.findMany(entity, query);
|
|
102
|
-
const countPromise = flags.count ? querier.count(entity, query) : undefined;
|
|
103
|
-
const [data, count] = await Promise.all([findManyPromise, countPromise]);
|
|
104
|
-
return ok({ data, count });
|
|
105
|
-
});
|
|
74
|
+
case 'findOneById': {
|
|
75
|
+
const data = await querier.findOne(entity, scoped);
|
|
76
|
+
return { data, count: data ? 1 : 0 };
|
|
77
|
+
}
|
|
78
|
+
case 'count': {
|
|
79
|
+
const count = await querier.count(entity, query);
|
|
80
|
+
return { data: count, count };
|
|
81
|
+
}
|
|
82
|
+
case 'findMany': {
|
|
83
|
+
const [data, count] = await Promise.all([
|
|
84
|
+
querier.findMany(entity, query),
|
|
85
|
+
counts ? querier.count(entity, query) : undefined,
|
|
86
|
+
]);
|
|
87
|
+
return { data, count };
|
|
88
|
+
}
|
|
106
89
|
case 'insertOne':
|
|
107
|
-
return
|
|
108
|
-
const data = await querier.insertOne(entity, hookCtx.body);
|
|
109
|
-
return ok({ data, count: 1 });
|
|
110
|
-
});
|
|
111
|
-
case 'insertMany':
|
|
112
|
-
return withTransaction(async (querier) => {
|
|
113
|
-
const data = await querier.insertMany(entity, hookCtx.body);
|
|
114
|
-
return ok({ data, count: data.length });
|
|
115
|
-
});
|
|
90
|
+
return { data: await querier.insertOne(entity, body), count: 1 };
|
|
116
91
|
case 'saveOne':
|
|
117
|
-
return
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
case 'updateOneById':
|
|
127
|
-
return withTransaction(async (querier) => {
|
|
128
|
-
const count = await querier.updateMany(entity, buildIdQuery(meta, id, query), hookCtx.body);
|
|
129
|
-
return ok({ data: id, count });
|
|
130
|
-
});
|
|
92
|
+
return { data: await querier.saveOne(entity, body), count: 1 };
|
|
93
|
+
case 'insertMany': {
|
|
94
|
+
const data = await querier.insertMany(entity, body);
|
|
95
|
+
return { data, count: data.length };
|
|
96
|
+
}
|
|
97
|
+
case 'saveMany': {
|
|
98
|
+
const data = await querier.saveMany(entity, body);
|
|
99
|
+
return { data, count: data.length };
|
|
100
|
+
}
|
|
131
101
|
case 'updateMany':
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
case 'deleteOneById':
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
});
|
|
102
|
+
case 'updateOneById': {
|
|
103
|
+
const count = await querier.updateMany(entity, scoped, body);
|
|
104
|
+
return { data: id ?? count, count };
|
|
105
|
+
}
|
|
106
|
+
case 'deleteOneById': {
|
|
107
|
+
const count = await querier.deleteMany(entity, scoped, { hardDelete });
|
|
108
|
+
return { data: id, count };
|
|
109
|
+
}
|
|
110
|
+
case 'deleteMany': {
|
|
111
|
+
const founds = await querier.findMany(entity, query);
|
|
112
|
+
if (!founds.length) {
|
|
113
|
+
return { data: [], count: 0 };
|
|
114
|
+
}
|
|
115
|
+
const idKey = soleIdOf(meta, 'the HTTP handler');
|
|
116
|
+
const ids = founds.map((found) => found[idKey]);
|
|
117
|
+
return {
|
|
118
|
+
data: ids,
|
|
119
|
+
count: await querier.deleteMany(entity, { $where: whereIds(meta, ids) }, { hardDelete }),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
153
122
|
}
|
|
154
123
|
}
|
|
155
124
|
}
|
|
156
125
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
126
|
+
/**
|
|
127
|
+
* Refuses a relation, at any depth of what a client sent, leading to an entity this handler does not
|
|
128
|
+
* serve: `include` fences the routes, and this the rows a `$populate`, a filter, a sort, a tally or a
|
|
129
|
+
* written row would otherwise reach through them. Every `$` clause holds keys of the entity it sits on.
|
|
130
|
+
*/
|
|
131
|
+
function assertServed(meta, sent, served, path = '') {
|
|
132
|
+
if (Array.isArray(sent)) {
|
|
133
|
+
for (const item of sent) {
|
|
134
|
+
assertServed(meta, item, served, path);
|
|
135
|
+
}
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (typeof sent !== 'object' || sent === null) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const relations = meta.relations;
|
|
142
|
+
for (const [key, value] of Object.entries(sent)) {
|
|
143
|
+
const relation = Object.hasOwn(relations, key) ? relations[key] : undefined;
|
|
144
|
+
if (relation) {
|
|
145
|
+
const target = relation.entity();
|
|
146
|
+
const at = path ? `${path}.${key}` : key;
|
|
147
|
+
if (!served.has(target)) {
|
|
148
|
+
throw new UqlUsageError(`'${at}' reaches '${target.name}', which this handler does not serve`);
|
|
149
|
+
}
|
|
150
|
+
assertServed(getMeta(target), value, served, at);
|
|
151
|
+
}
|
|
152
|
+
else if (key.startsWith('$')) {
|
|
153
|
+
assertServed(meta, value, served, path);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
163
156
|
}
|
package/dist/http/query.d.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import type { WireQuery } from '../type/index.js';
|
|
2
|
+
/** The flags a request carries beside its query: `hardDelete` on a delete, `count` on a `findMany`. */
|
|
3
|
+
declare const WIRE_FLAGS: ["hardDelete", "count"];
|
|
4
|
+
/** {@link WIRE_FLAGS} as the booleans {@link parseQueryParams} decodes them to, where a hook may also set them. */
|
|
5
|
+
export type WireFlags = {
|
|
6
|
+
readonly [K in (typeof WIRE_FLAGS)[number]]?: boolean;
|
|
7
|
+
};
|
|
2
8
|
/**
|
|
3
|
-
* Parse raw query-string entries (with JSON-stringified values) into a UQL query
|
|
4
|
-
* Symmetric counterpart of {@link stringifyQuery}. Only {@link ALLOWED_QUERY_KEYS} are honored.
|
|
9
|
+
* Parse raw query-string entries (with JSON-stringified values), or a `QUERY` body, into a UQL query
|
|
10
|
+
* object. Symmetric counterpart of {@link stringifyQuery}. Only {@link ALLOWED_QUERY_KEYS} are honored.
|
|
5
11
|
*/
|
|
6
|
-
export declare function parseQueryParams<E = unknown>(params?:
|
|
12
|
+
export declare function parseQueryParams<E = unknown>(params?: unknown): WireQuery<E> & WireFlags;
|
|
7
13
|
/**
|
|
8
14
|
* Serialize a UQL query object into a percent-encoded query string where object values
|
|
9
15
|
* are JSON-stringified. Symmetric counterpart of {@link parseQueryParams}.
|
|
@@ -17,3 +23,4 @@ export declare function stringifyQuery(query?: Record<string, unknown>): string;
|
|
|
17
23
|
* hits where the client's types already refuse a fragment.
|
|
18
24
|
*/
|
|
19
25
|
export declare function wireJson(value: unknown): string;
|
|
26
|
+
export {};
|
package/dist/http/query.js
CHANGED
|
@@ -4,13 +4,15 @@ import { QUERY_BOOLEAN_CLAUSES, QUERY_NUMBER_CLAUSES, QUERY_OBJECT_CLAUSES, QUER
|
|
|
4
4
|
// with it, in the browser bundle, which is on a size budget
|
|
5
5
|
import { RAW_VALUE } from '../type/queryRaw.js';
|
|
6
6
|
// the specific util module, not the barrel, so the browser bundle does not pull in entity metadata
|
|
7
|
-
import { getKeys, isWhereMap } from '../util/object.util.js';
|
|
7
|
+
import { getKeys, isRecord, isWhereMap } from '../util/object.util.js';
|
|
8
8
|
// the error class alone, from its own leaf module: `queryError.ts` carries every driver's code map
|
|
9
9
|
import { UqlUsageError } from '../util/uqlError.js';
|
|
10
|
+
/** The flags a request carries beside its query: `hardDelete` on a delete, `count` on a `findMany`. */
|
|
11
|
+
const WIRE_FLAGS = ['hardDelete', 'count'];
|
|
10
12
|
/**
|
|
11
|
-
* Keys accepted from the wire - query structure ({@link Query}) plus the
|
|
12
|
-
*
|
|
13
|
-
*
|
|
13
|
+
* Keys accepted from the wire - query structure ({@link Query}) plus the {@link WIRE_FLAGS}. Anything else
|
|
14
|
+
* (e.g. `filters`, `context`, `$entity`) is dropped so a remote client can't bypass a security filter or
|
|
15
|
+
* inject ambient context - those are server-only. The `satisfies` ties
|
|
14
16
|
* every entry to a real query/option key, so a typo or a renamed option fails to compile.
|
|
15
17
|
*/
|
|
16
18
|
const ALLOWED_QUERY_KEYS = new Set([
|
|
@@ -19,8 +21,7 @@ const ALLOWED_QUERY_KEYS = new Set([
|
|
|
19
21
|
...QUERY_NUMBER_CLAUSES,
|
|
20
22
|
...QUERY_ROOT_NUMBER_CLAUSES,
|
|
21
23
|
...QUERY_BOOLEAN_CLAUSES,
|
|
22
|
-
|
|
23
|
-
'count',
|
|
24
|
+
...WIRE_FLAGS,
|
|
24
25
|
]);
|
|
25
26
|
/**
|
|
26
27
|
* Keys that mean something locally but that this transport can never honor, so they are rejected
|
|
@@ -30,10 +31,13 @@ const ALLOWED_QUERY_KEYS = new Set([
|
|
|
30
31
|
*/
|
|
31
32
|
const REJECTED_QUERY_KEYS = new Set(['$lock']);
|
|
32
33
|
/**
|
|
33
|
-
* Parse raw query-string entries (with JSON-stringified values) into a UQL query
|
|
34
|
-
* Symmetric counterpart of {@link stringifyQuery}. Only {@link ALLOWED_QUERY_KEYS} are honored.
|
|
34
|
+
* Parse raw query-string entries (with JSON-stringified values), or a `QUERY` body, into a UQL query
|
|
35
|
+
* object. Symmetric counterpart of {@link stringifyQuery}. Only {@link ALLOWED_QUERY_KEYS} are honored.
|
|
35
36
|
*/
|
|
36
37
|
export function parseQueryParams(params = {}) {
|
|
38
|
+
if (!isRecord(params)) {
|
|
39
|
+
throw new UqlUsageError('the query must be a JSON object');
|
|
40
|
+
}
|
|
37
41
|
const query = {};
|
|
38
42
|
for (const key of getKeys(params)) {
|
|
39
43
|
if (REJECTED_QUERY_KEYS.has(key)) {
|
|
@@ -50,7 +54,7 @@ export function parseQueryParams(params = {}) {
|
|
|
50
54
|
query[key] = JSON.parse(value);
|
|
51
55
|
}
|
|
52
56
|
catch {
|
|
53
|
-
throw
|
|
57
|
+
throw new UqlUsageError(`invalid JSON in '${key}'`);
|
|
54
58
|
}
|
|
55
59
|
}
|
|
56
60
|
}
|
|
@@ -66,7 +70,7 @@ export function parseQueryParams(params = {}) {
|
|
|
66
70
|
query[key] = Number(query[key]);
|
|
67
71
|
}
|
|
68
72
|
}
|
|
69
|
-
for (const key of QUERY_BOOLEAN_CLAUSES) {
|
|
73
|
+
for (const key of [...QUERY_BOOLEAN_CLAUSES, ...WIRE_FLAGS]) {
|
|
70
74
|
if (query[key] !== undefined) {
|
|
71
75
|
query[key] = query[key] === true || query[key] === 'true';
|
|
72
76
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { ObjectId } from 'mongodb';
|
|
2
2
|
import { AbstractDialect } from '../dialect/abstractDialect.js';
|
|
3
3
|
import { AGGREGATE_VALUE_ALIAS, REL_NESTED_KEY, REL_TEMP_PREFIX, SUM_COUNT_ALIAS, nullsSortField, sortAggregateField, TEXT_SCORE_ALIAS, } from '../dialect/aliases.js';
|
|
4
|
-
import { betweenBounds, GROUP_OPS, groupClauses, isGroupOp, whereOperators } from '../dialect/operators.js';
|
|
4
|
+
import { betweenBounds, GROUP_OPS, groupClauses, isGroupOp, LIKE_OPS, likeRegex, whereOperators, } from '../dialect/operators.js';
|
|
5
5
|
import { aggregateColumnField, groupPathField, resolveGroupJoins, relationSortTerms, resolveQueryJoins, resolveSortableJoin, } from '../dialect/queryJoins.js';
|
|
6
6
|
import { assertSoleId, fieldOf, getMeta, relationOf, soleIdOf } from '../entity/index.js';
|
|
7
7
|
import { COUNT_RESULT_KEY } from '../type/query.js';
|
|
8
8
|
import { QueryRaw } from '../type/queryRaw.js';
|
|
9
|
-
import { aggregateOf,
|
|
9
|
+
import { aggregateOf, isSelectList, assertAggregateColumns, assertNonNegativeInteger, columnFamily, countedRelations, entityName, fieldUpdateOf, fillOnFields, filterFieldKeys, findVectorIndex, findVectorSort, getKeys, getRelationRequestSummary, hasKeys, isFieldUpdateOp, isJsonObject, isJsonUpdateOp, isOperatorMap, isOperatorObject, isRecord, isVectorSearch, normalizeScalarFieldSelection, parentJoins, parseGroupMap, parseRelationAtKey, parseRelationSize, rankedTextSearch, someKey, targetKeyColumns, textSortOf, vectorDistanceOf, } from '../util/index.js';
|
|
10
10
|
import { UqlUsageError } from '../util/uqlError.js';
|
|
11
11
|
import { decodeBigIntsExcept } from '../util/wideNumber.js';
|
|
12
12
|
import { textLanguage } from './textLanguage.js';
|
|
@@ -71,17 +71,6 @@ function compareCount(count, size) {
|
|
|
71
71
|
}
|
|
72
72
|
return comparisons.length === 1 ? comparisons[0] : { $and: comparisons };
|
|
73
73
|
}
|
|
74
|
-
/** String operators -> { pattern: (v) => regex, caseInsensitive } */
|
|
75
|
-
const REGEX_OP_MAP = new Map([
|
|
76
|
-
['$startsWith', { wrap: (v) => `^${v}`, ci: false }],
|
|
77
|
-
['$istartsWith', { wrap: (v) => `^${v}`, ci: true }],
|
|
78
|
-
['$endsWith', { wrap: (v) => `${v}$`, ci: false }],
|
|
79
|
-
['$iendsWith', { wrap: (v) => `${v}$`, ci: true }],
|
|
80
|
-
['$includes', { wrap: (v) => String(v), ci: false }],
|
|
81
|
-
['$iincludes', { wrap: (v) => String(v), ci: true }],
|
|
82
|
-
['$like', { wrap: (v) => String(v).replace(/%/g, '.*').replace(/_/g, '.'), ci: false }],
|
|
83
|
-
['$ilike', { wrap: (v) => String(v).replace(/%/g, '.*').replace(/_/g, '.'), ci: true }],
|
|
84
|
-
]);
|
|
85
74
|
/** MongoDB native operators - pass through as-is. */
|
|
86
75
|
const NATIVE_OPS = new Set([
|
|
87
76
|
'$all',
|
|
@@ -361,11 +350,11 @@ export class MongoDialect extends AbstractDialect {
|
|
|
361
350
|
result[op] = val;
|
|
362
351
|
continue;
|
|
363
352
|
}
|
|
364
|
-
//
|
|
365
|
-
const
|
|
366
|
-
if (
|
|
367
|
-
result['$regex'] =
|
|
368
|
-
if (
|
|
353
|
+
// The `$like` family, as the regex matching what its `LIKE` pattern matches on SQL.
|
|
354
|
+
const like = LIKE_OPS.get(op);
|
|
355
|
+
if (like) {
|
|
356
|
+
result['$regex'] = likeRegex(like.pattern(String(val)));
|
|
357
|
+
if (like.insensitive)
|
|
369
358
|
result['$options'] = 'i';
|
|
370
359
|
continue;
|
|
371
360
|
}
|
|
@@ -429,17 +418,16 @@ export class MongoDialect extends AbstractDialect {
|
|
|
429
418
|
if (!select && !exclude) {
|
|
430
419
|
return {};
|
|
431
420
|
}
|
|
432
|
-
if (
|
|
421
|
+
if (isSelectList(select)) {
|
|
433
422
|
throw new UqlUsageError('raw $select is not supported on MongoDB');
|
|
434
423
|
}
|
|
435
|
-
const selectMap = asSelectMap(select);
|
|
436
424
|
// Projected by column, not by field key; `normalizeId` maps them back on the way out.
|
|
437
|
-
const projection = normalizeScalarFieldSelection(meta,
|
|
425
|
+
const projection = normalizeScalarFieldSelection(meta, select, exclude).reduce((acc, key) => {
|
|
438
426
|
// A computed field writing SQL leaves the document nothing to project: refused asked for by
|
|
439
427
|
// name, skipped swept in with the rest. A relation aggregate is on it by now, like any column.
|
|
440
428
|
const field = meta.fields[key];
|
|
441
429
|
if (field?.computed && !aggregateOf(field)) {
|
|
442
|
-
if (
|
|
430
|
+
if (select && key in select) {
|
|
443
431
|
assertReadable(meta, key);
|
|
444
432
|
}
|
|
445
433
|
return acc;
|
|
@@ -450,7 +438,7 @@ export class MongoDialect extends AbstractDialect {
|
|
|
450
438
|
// MongoDB returns `_id` unless it is explicitly excluded, so subtracting the primary key needs
|
|
451
439
|
// `_id: 0` - the one inclusion/exclusion mix MongoDB allows - or `$exclude: { id: true }` would
|
|
452
440
|
// have no effect at all.
|
|
453
|
-
if (this.subtractsKey(soleIdOf(meta, 'MongoDB'),
|
|
441
|
+
if (this.subtractsKey(soleIdOf(meta, 'MongoDB'), select, exclude)) {
|
|
454
442
|
projection[ID_KEY] = 0;
|
|
455
443
|
}
|
|
456
444
|
return projection;
|
|
@@ -565,7 +553,7 @@ export class MongoDialect extends AbstractDialect {
|
|
|
565
553
|
/** The relation aggregates a read projects or sorts by; its `$where` puts its own on the document. */
|
|
566
554
|
aggregateKeys(entity, q) {
|
|
567
555
|
const meta = getMeta(entity);
|
|
568
|
-
const projected = normalizeScalarFieldSelection(meta,
|
|
556
|
+
const projected = normalizeScalarFieldSelection(meta, isSelectList(q.$select) ? undefined : q.$select, q.$exclude);
|
|
569
557
|
return [...projected, ...Object.keys(q.$sort ?? {})].filter((key) => aggregateOf(meta.fields[key]));
|
|
570
558
|
}
|
|
571
559
|
/**
|
|
@@ -88,6 +88,8 @@ export declare abstract class AbstractQuerier implements Querier {
|
|
|
88
88
|
* same rows the statement wrote.
|
|
89
89
|
*/
|
|
90
90
|
insertMany<E extends object>(entity: Type<E>, payload: readonly EntityWrite<E>[]): Promise<(WrittenId<E> | undefined)[]>;
|
|
91
|
+
/** Fills and guards `rows`, then writes them: an insert, and the insert half of a guarded upsert. */
|
|
92
|
+
private insertRows;
|
|
91
93
|
/** Writes `rows`, and onto each one the key the database generated for it, where it can tell. */
|
|
92
94
|
protected abstract internalInsertMany<E extends object>(entity: Type<E>, rows: EntityData<E>[]): Promise<void>;
|
|
93
95
|
updateOneById<E extends object>(entity: Type<E>, id: EntityId<E>, payload: UpdateWrite<E>, opts?: QueryOptions): Promise<number>;
|
|
@@ -126,6 +128,12 @@ export declare abstract class AbstractQuerier implements Querier {
|
|
|
126
128
|
/** 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. */
|
|
127
129
|
upsertOne<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityWrite<E>): Promise<QueryUpsertOneResult<E>>;
|
|
128
130
|
upsertMany<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: readonly EntityWrite<E>[]): Promise<QueryUpsertManyResult<E>>;
|
|
131
|
+
/**
|
|
132
|
+
* An upsert on an entity a `security` filter guards. `ON CONFLICT` updates the conflicting row whoever
|
|
133
|
+
* it belongs to, so the rows the conflict names are read through the filter: those found update, the
|
|
134
|
+
* rest insert, where another tenant's row fails on its key. See architecture/security-filter-writes.md.
|
|
135
|
+
*/
|
|
136
|
+
private guardedUpsert;
|
|
129
137
|
protected abstract internalUpsertOne<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>): Promise<QueryUpdateResult>;
|
|
130
138
|
protected abstract internalUpsertMany<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>[]): Promise<QueryUpdateResult>;
|
|
131
139
|
deleteOneById<E extends object>(entity: Type<E>, id: EntityId<E>, opts?: QueryOptions): Promise<number>;
|
|
@@ -171,9 +179,9 @@ export declare abstract class AbstractQuerier implements Querier {
|
|
|
171
179
|
*/
|
|
172
180
|
private emitLoaded;
|
|
173
181
|
/**
|
|
174
|
-
* The ids of `rows`, read
|
|
175
|
-
* not report them in payload order
|
|
176
|
-
* `undefined`: a missing id is honest where a guessed one is not.
|
|
182
|
+
* The ids of `rows`, read through the filters by the columns an upsert matches them on: after a
|
|
183
|
+
* statement that could not report them in payload order, or before a guarded upsert. A row that no
|
|
184
|
+
* read row matches, or that two do, keeps `undefined`: a missing id is honest where a guessed one is not.
|
|
177
185
|
*/
|
|
178
186
|
protected idsByConflict<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, rows: EntityData<E>[]): Promise<(PrimaryKey | undefined)[]>;
|
|
179
187
|
/**
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { assertSoleId, getMeta, idOf, namesKey, relationOf } from '../entity/index.js';
|
|
2
2
|
import { namesRows } from '../dialect/operators.js';
|
|
3
3
|
import { parseQueryLock } from '../type/index.js';
|
|
4
|
-
import { cascadesOnDelete, childrenOf, clone, entityName, fillOnFields, filterFieldKeys, filterPersistableRelationKeys, forEachRequestedRelation, getKeys, getRelationRequestSummary, idOnlyQuery, keySet, isPagedQuery, isScalarId, LoggerWrapper, parentJoins, queryLoggerFor, parseRelationAtKey, parseRelationQueryValue, rowKey, runHooks, someKey, targetKeyColumns, whereAnyOf, whereEach, whereIds, whereWith, withoutSoftDeleteFilter, } from '../util/index.js';
|
|
4
|
+
import { cascadesOnDelete, childrenOf, clone, entityName, fillOnFields, filterFieldKeys, filterPersistableRelationKeys, forEachRequestedRelation, getKeys, getRelationRequestSummary, guardWrite, idOnlyQuery, keySet, isPagedQuery, isScalarId, LoggerWrapper, parentJoins, queryLoggerFor, parseRelationAtKey, parseRelationQueryValue, rowKey, runHooks, securityConditions, someKey, targetKeyColumns, whereAnyOf, whereEach, whereIds, whereWith, withoutSoftDeleteFilter, } from '../util/index.js';
|
|
5
5
|
import { UqlOptimisticLockError, UqlUsageError } from '../util/uqlError.js';
|
|
6
6
|
import { enrichError } from './queryError.js';
|
|
7
7
|
/**
|
|
@@ -240,11 +240,17 @@ export class AbstractQuerier {
|
|
|
240
240
|
}
|
|
241
241
|
const meta = getMeta(entity);
|
|
242
242
|
return this.hooked(entity, 'Insert', payload, async (rows) => {
|
|
243
|
-
|
|
244
|
-
await this.internalInsertMany(entity, rows);
|
|
243
|
+
await this.insertRows(entity, rows);
|
|
245
244
|
return writtenIds(meta, rows);
|
|
246
245
|
});
|
|
247
246
|
}
|
|
247
|
+
/** Fills and guards `rows`, then writes them: an insert, and the insert half of a guarded upsert. */
|
|
248
|
+
async insertRows(entity, rows) {
|
|
249
|
+
const meta = getMeta(entity);
|
|
250
|
+
fillOnFields(meta, rows, 'onInsert');
|
|
251
|
+
guardWrite(meta, rows, 'insert');
|
|
252
|
+
await this.internalInsertMany(entity, rows);
|
|
253
|
+
}
|
|
248
254
|
async updateOneById(entity, id, payload, opts) {
|
|
249
255
|
assertIdValue(entity, id);
|
|
250
256
|
return this.updateMany(entity, { $where: whereIds(getMeta(entity), id) }, payload, opts);
|
|
@@ -261,6 +267,7 @@ export class AbstractQuerier {
|
|
|
261
267
|
async updateRows(entity, q, row, opts, lockKey) {
|
|
262
268
|
const meta = getMeta(entity);
|
|
263
269
|
fillOnFields(meta, [row], 'onUpdate');
|
|
270
|
+
guardWrite(meta, [row], 'update');
|
|
264
271
|
const relKeys = filterPersistableRelationKeys(meta, row, 'persist');
|
|
265
272
|
const settles = !!relKeys.length || this.settlesWrite(entity, q);
|
|
266
273
|
if (lockKey) {
|
|
@@ -345,7 +352,9 @@ export class AbstractQuerier {
|
|
|
345
352
|
const meta = getMeta(entity);
|
|
346
353
|
assertUnversioned(meta, "'upsertOne'");
|
|
347
354
|
return this.hooked(entity, 'Upsert', [payload], async (rows) => {
|
|
348
|
-
const { ids, changes, created } =
|
|
355
|
+
const { ids, changes, created } = securityConditions(meta).length
|
|
356
|
+
? await this.guardedUpsert(entity, conflictPaths, rows)
|
|
357
|
+
: await this.internalUpsertOne(entity, conflictPaths, rows[0]);
|
|
349
358
|
adoptReportedIds(meta, rows, ids);
|
|
350
359
|
const [id] = writtenIds(meta, rows);
|
|
351
360
|
return { id, changes, created };
|
|
@@ -355,11 +364,49 @@ export class AbstractQuerier {
|
|
|
355
364
|
const meta = getMeta(entity);
|
|
356
365
|
assertUnversioned(meta, "'upsertMany'");
|
|
357
366
|
return this.hooked(entity, 'Upsert', payload, async (rows) => {
|
|
358
|
-
const { ids, changes } =
|
|
367
|
+
const { ids, changes } = securityConditions(meta).length
|
|
368
|
+
? await this.guardedUpsert(entity, conflictPaths, rows)
|
|
369
|
+
: await this.internalUpsertMany(entity, conflictPaths, rows);
|
|
359
370
|
adoptReportedIds(meta, rows, ids);
|
|
360
371
|
return { ids: writtenIds(meta, rows), changes };
|
|
361
372
|
});
|
|
362
373
|
}
|
|
374
|
+
/**
|
|
375
|
+
* An upsert on an entity a `security` filter guards. `ON CONFLICT` updates the conflicting row whoever
|
|
376
|
+
* it belongs to, so the rows the conflict names are read through the filter: those found update, the
|
|
377
|
+
* rest insert, where another tenant's row fails on its key. See architecture/security-filter-writes.md.
|
|
378
|
+
*/
|
|
379
|
+
async guardedUpsert(entity, conflictPaths, rows) {
|
|
380
|
+
guardWrite(getMeta(entity), rows, 'insert');
|
|
381
|
+
if (!rows.length) {
|
|
382
|
+
return { changes: 0 };
|
|
383
|
+
}
|
|
384
|
+
const keys = getKeys(conflictPaths);
|
|
385
|
+
const write = async () => {
|
|
386
|
+
const ids = await this.idsByConflict(entity, conflictPaths, rows);
|
|
387
|
+
let changes = 0;
|
|
388
|
+
for (const [index, row] of rows.entries()) {
|
|
389
|
+
if (ids[index] !== undefined) {
|
|
390
|
+
// What `ON CONFLICT` would assign: the row, less the columns it matched on.
|
|
391
|
+
const payload = { ...row };
|
|
392
|
+
for (const key of keys) {
|
|
393
|
+
delete payload[key];
|
|
394
|
+
}
|
|
395
|
+
changes += await this.updateColumns(entity, { $where: whereEach(keys, (key) => row[key]) }, payload, undefined, 0);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const inserts = rows.filter((_, index) => ids[index] === undefined);
|
|
399
|
+
if (inserts.length) {
|
|
400
|
+
await this.insertRows(entity, inserts);
|
|
401
|
+
changes += inserts.length;
|
|
402
|
+
}
|
|
403
|
+
const created = rows.length === 1 ? ids[0] === undefined : undefined;
|
|
404
|
+
// A found row's key is adopted by the caller; an inserted one carries the key its insert wrote.
|
|
405
|
+
return { changes, created, ids };
|
|
406
|
+
};
|
|
407
|
+
// One row is one statement after the read, so it needs no transaction of its own.
|
|
408
|
+
return rows.length === 1 ? write() : this.transaction(write);
|
|
409
|
+
}
|
|
363
410
|
async deleteOneById(entity, id, opts) {
|
|
364
411
|
assertIdValue(entity, id);
|
|
365
412
|
return this.deleteMany(entity, { $where: whereIds(getMeta(entity), id) }, opts);
|
|
@@ -571,9 +618,9 @@ export class AbstractQuerier {
|
|
|
571
618
|
await this.emitHook(entity, 'afterLoad', rows);
|
|
572
619
|
}
|
|
573
620
|
/**
|
|
574
|
-
* The ids of `rows`, read
|
|
575
|
-
* not report them in payload order
|
|
576
|
-
* `undefined`: a missing id is honest where a guessed one is not.
|
|
621
|
+
* The ids of `rows`, read through the filters by the columns an upsert matches them on: after a
|
|
622
|
+
* statement that could not report them in payload order, or before a guarded upsert. A row that no
|
|
623
|
+
* read row matches, or that two do, keeps `undefined`: a missing id is honest where a guessed one is not.
|
|
577
624
|
*/
|
|
578
625
|
async idsByConflict(entity, conflictPaths, rows) {
|
|
579
626
|
const meta = getMeta(entity);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { UqlError } from '../util/uqlError.js';
|
|
2
2
|
/** Postgres, CockroachDB, PGlite and Neon in `code`; Bun SQL in `errno`. */
|
|
3
3
|
const SQLSTATE_KINDS = new Map([
|
|
4
4
|
['23505', 'uniqueViolation'],
|
|
@@ -53,7 +53,7 @@ export function queryErrorKind(err) {
|
|
|
53
53
|
if (typeof err !== 'object' || err === null) {
|
|
54
54
|
return undefined;
|
|
55
55
|
}
|
|
56
|
-
if (err instanceof
|
|
56
|
+
if (err instanceof UqlError) {
|
|
57
57
|
return err.kind;
|
|
58
58
|
}
|
|
59
59
|
const { code, errno, number, errorLabels, message } = err;
|