turbine-orm 0.65.0 → 0.66.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 +34 -32
- package/dist/adapters/cockroachdb.js +21 -3
- package/dist/adapters/index.d.ts +15 -0
- package/dist/adapters/yugabytedb.js +20 -3
- package/dist/cjs/adapters/cockroachdb.js +21 -3
- package/dist/cjs/adapters/index.d.ts +15 -0
- package/dist/cjs/adapters/yugabytedb.js +20 -3
- package/dist/cjs/cli/destructive.d.ts +18 -4
- package/dist/cjs/cli/destructive.js +230 -122
- package/dist/cjs/cli/index.d.ts +21 -4
- package/dist/cjs/cli/index.js +119 -22
- package/dist/cjs/cli/mcp.d.ts +28 -8
- package/dist/cjs/cli/mcp.js +170 -127
- package/dist/cjs/cli/migrate.d.ts +134 -13
- package/dist/cjs/cli/migrate.js +349 -241
- package/dist/cjs/cli/pii-predicate-guard.d.ts +112 -0
- package/dist/cjs/cli/pii-predicate-guard.js +390 -0
- package/dist/cjs/cli/prisma-resolve.js +75 -4
- package/dist/cjs/cli/prisma-schema.d.ts +17 -1
- package/dist/cjs/cli/prisma-schema.js +83 -17
- package/dist/cjs/cli/sql-statements.d.ts +125 -0
- package/dist/cjs/cli/sql-statements.js +378 -0
- package/dist/cjs/cli/studio.js +49 -118
- package/dist/cjs/cli/ui.d.ts +1 -1
- package/dist/cjs/client.d.ts +43 -0
- package/dist/cjs/client.js +125 -6
- package/dist/cjs/dialect.d.ts +123 -0
- package/dist/cjs/dialect.js +33 -0
- package/dist/cjs/errors.d.ts +74 -1
- package/dist/cjs/errors.js +239 -25
- package/dist/cjs/index-advisor.d.ts +33 -1
- package/dist/cjs/index-advisor.js +32 -1
- package/dist/cjs/introspect.d.ts +48 -0
- package/dist/cjs/introspect.js +222 -91
- package/dist/cjs/mssql.js +43 -1
- package/dist/cjs/mysql.d.ts +5 -2
- package/dist/cjs/mysql.js +202 -17
- package/dist/cjs/nested-write.js +6 -1
- package/dist/cjs/pipeline-submittable.js +17 -3
- package/dist/cjs/pipeline.js +75 -9
- package/dist/cjs/powdb.d.ts +23 -0
- package/dist/cjs/powdb.js +33 -1
- package/dist/cjs/powql.d.ts +61 -9
- package/dist/cjs/powql.js +186 -49
- package/dist/cjs/prisma-compat.js +160 -41
- package/dist/cjs/query/aggregates.d.ts +1 -1
- package/dist/cjs/query/aggregates.js +80 -18
- package/dist/cjs/query/batched-loader.d.ts +10 -0
- package/dist/cjs/query/batched-loader.js +268 -7
- package/dist/cjs/query/builder.d.ts +73 -0
- package/dist/cjs/query/builder.js +225 -28
- package/dist/cjs/query/filters.d.ts +162 -0
- package/dist/cjs/query/filters.js +250 -1
- package/dist/cjs/query/relations.d.ts +10 -10
- package/dist/cjs/query/relations.js +93 -12
- package/dist/cjs/query/types.d.ts +14 -1
- package/dist/cjs/query/utils.d.ts +146 -2
- package/dist/cjs/query/utils.js +210 -4
- package/dist/cjs/query/warn-registry.d.ts +10 -0
- package/dist/cjs/query/warn-registry.js +10 -0
- package/dist/cjs/query/where-compile.d.ts +30 -0
- package/dist/cjs/query/where-compile.js +41 -0
- package/dist/cjs/query/where.d.ts +128 -13
- package/dist/cjs/query/where.js +215 -77
- package/dist/cjs/query/writes.d.ts +1 -1
- package/dist/cjs/query/writes.js +39 -15
- package/dist/cjs/schema-builder.d.ts +2 -1
- package/dist/cjs/schema-sql.d.ts +94 -4
- package/dist/cjs/schema-sql.js +506 -30
- package/dist/cjs/schema.d.ts +3 -1
- package/dist/cjs/sqlite.d.ts +6 -0
- package/dist/cjs/sqlite.js +151 -10
- package/dist/cjs/typed-sql.d.ts +29 -1
- package/dist/cjs/typed-sql.js +30 -12
- package/dist/cli/destructive.d.ts +18 -4
- package/dist/cli/destructive.js +229 -121
- package/dist/cli/index.d.ts +21 -4
- package/dist/cli/index.js +120 -24
- package/dist/cli/mcp.d.ts +28 -8
- package/dist/cli/mcp.js +172 -129
- package/dist/cli/migrate.d.ts +134 -13
- package/dist/cli/migrate.js +347 -238
- package/dist/cli/pii-predicate-guard.d.ts +112 -0
- package/dist/cli/pii-predicate-guard.js +386 -0
- package/dist/cli/prisma-resolve.js +75 -4
- package/dist/cli/prisma-schema.d.ts +17 -1
- package/dist/cli/prisma-schema.js +83 -17
- package/dist/cli/sql-statements.d.ts +125 -0
- package/dist/cli/sql-statements.js +373 -0
- package/dist/cli/studio.js +49 -118
- package/dist/cli/ui.d.ts +1 -1
- package/dist/client.d.ts +43 -0
- package/dist/client.js +126 -7
- package/dist/dialect.d.ts +123 -0
- package/dist/dialect.js +33 -0
- package/dist/errors.d.ts +74 -1
- package/dist/errors.js +228 -19
- package/dist/index-advisor.d.ts +33 -1
- package/dist/index-advisor.js +31 -1
- package/dist/introspect.d.ts +48 -0
- package/dist/introspect.js +221 -91
- package/dist/mssql.js +44 -2
- package/dist/mysql.d.ts +5 -2
- package/dist/mysql.js +203 -18
- package/dist/nested-write.js +7 -2
- package/dist/pipeline-submittable.js +18 -4
- package/dist/pipeline.js +76 -10
- package/dist/powdb.d.ts +23 -0
- package/dist/powdb.js +33 -2
- package/dist/powql.d.ts +61 -9
- package/dist/powql.js +187 -50
- package/dist/prisma-compat.js +160 -41
- package/dist/query/aggregates.d.ts +1 -1
- package/dist/query/aggregates.js +82 -20
- package/dist/query/batched-loader.d.ts +10 -0
- package/dist/query/batched-loader.js +270 -9
- package/dist/query/builder.d.ts +73 -0
- package/dist/query/builder.js +226 -30
- package/dist/query/filters.d.ts +162 -0
- package/dist/query/filters.js +246 -1
- package/dist/query/relations.d.ts +10 -10
- package/dist/query/relations.js +94 -14
- package/dist/query/types.d.ts +14 -1
- package/dist/query/utils.d.ts +146 -2
- package/dist/query/utils.js +204 -3
- package/dist/query/warn-registry.d.ts +10 -0
- package/dist/query/warn-registry.js +10 -0
- package/dist/query/where-compile.d.ts +30 -0
- package/dist/query/where-compile.js +40 -1
- package/dist/query/where.d.ts +128 -13
- package/dist/query/where.js +216 -80
- package/dist/query/writes.d.ts +1 -1
- package/dist/query/writes.js +40 -16
- package/dist/schema-builder.d.ts +2 -1
- package/dist/schema-sql.d.ts +94 -4
- package/dist/schema-sql.js +505 -30
- package/dist/schema.d.ts +3 -1
- package/dist/sqlite.d.ts +6 -0
- package/dist/sqlite.js +151 -10
- package/dist/typed-sql.d.ts +29 -1
- package/dist/typed-sql.js +30 -12
- package/package.json +6 -4
package/dist/query/where.js
CHANGED
|
@@ -10,11 +10,46 @@
|
|
|
10
10
|
* instance (built once in its constructor) exposing exactly the class-resident
|
|
11
11
|
* primitives this module needs. See builder.ts for the thin delegating methods.
|
|
12
12
|
*/
|
|
13
|
-
import { UnsupportedFeatureError, ValidationError } from '../errors.js';
|
|
13
|
+
import { getErrorMessageMode, UnsupportedFeatureError, ValidationError } from '../errors.js';
|
|
14
14
|
import { camelToSnake, normalizeKeyColumns } from '../schema.js';
|
|
15
15
|
import { assertBindableEqualsOperand, findArrayUniqueKey, findJsonUniqueKey, isArrayFilter, isColumnRef, isJsonFilter, isUnmatchedPlainObject, isWhereOperator, JSON_FILTER_KEYS, JSON_RANGE_OPERATORS, JSON_STRING_OPERATORS, VECTOR_DISTANCE_COMPARATORS, VECTOR_METRIC_OPERATORS, validateTextSearchConfig, } from './filters.js';
|
|
16
|
-
import { coerceTemporalValue, escapeLike, OPERATOR_KEYS, ownLookup } from './utils.js';
|
|
17
|
-
import { classifyScalarForSql, fingerprintScalarToken, walkWhere, } from './where-compile.js';
|
|
16
|
+
import { coerceTemporalValue, escapeLike, isInternalCombinator, markInternalCombinator, OPERATOR_KEYS, ownLookup, } from './utils.js';
|
|
17
|
+
import { assertWhereDepth, classifyScalarForSql, fingerprintScalarToken, walkWhere, } from './where-compile.js';
|
|
18
|
+
/**
|
|
19
|
+
* LIKE-escape one bound operand through the ACTIVE dialect, falling back to the
|
|
20
|
+
* shared {@link escapeLike} (`\`, `%`, `_`) when the dialect declares no
|
|
21
|
+
* override. See {@link Dialect.escapeLikePattern} for why an engine would need
|
|
22
|
+
* one (T-SQL also treats `[` as a character-class opener, so the standard set
|
|
23
|
+
* leaves `{ contains: '[draft]' }` matching any title containing d, r, a, f or
|
|
24
|
+
* t).
|
|
25
|
+
*
|
|
26
|
+
* Every LIKE operand in this module goes through here, the scalar
|
|
27
|
+
* `contains`/`startsWith`/`endsWith` family and the JSON substring operators,
|
|
28
|
+
* on BOTH the SQL-build and the cache-hit param-collect side. That is the point
|
|
29
|
+
* of routing it through one function: the two sides must escape identically or
|
|
30
|
+
* a warmed template binds a differently-escaped value than the one its SQL was
|
|
31
|
+
* compiled for.
|
|
32
|
+
*/
|
|
33
|
+
function likeOperand(qi, value) {
|
|
34
|
+
return qi.dialect.escapeLikePattern?.(value) ?? escapeLike(value);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Render a caller-supplied VALUE for an error message, or a neutral placeholder
|
|
38
|
+
* when the process is in the default `'safe'` error-message mode.
|
|
39
|
+
*
|
|
40
|
+
* SECURITY.md states that where-clause values are rendered as key names only.
|
|
41
|
+
* These JSON-operator diagnostics used to interpolate the operand verbatim, and
|
|
42
|
+
* the `path`-missing branch fires on a perfectly VALID value whose only problem
|
|
43
|
+
* is a missing `path`, so the leak was not limited to malformed input: a
|
|
44
|
+
* `stringContains` search term reached the error text (and from there a log
|
|
45
|
+
* aggregator or an API error body) on a query the caller merely wrote wrong.
|
|
46
|
+
*
|
|
47
|
+
* Same gate the `NotFoundError` where-rendering already uses, so one setting
|
|
48
|
+
* governs both.
|
|
49
|
+
*/
|
|
50
|
+
function valueForMessage(value) {
|
|
51
|
+
return getErrorMessageMode() === 'verbose' ? JSON.stringify(value) : '<value>';
|
|
52
|
+
}
|
|
18
53
|
/**
|
|
19
54
|
* Produce a value-invariant fingerprint of a where clause.
|
|
20
55
|
* Same keys + same operator shapes + same combinator structure => same string.
|
|
@@ -22,22 +57,23 @@ import { classifyScalarForSql, fingerprintScalarToken, walkWhere, } from './wher
|
|
|
22
57
|
*
|
|
23
58
|
* @internal Exposed as package-private for testing via class access.
|
|
24
59
|
*/
|
|
25
|
-
export function fingerprintWhere(qi, where) {
|
|
60
|
+
export function fingerprintWhere(qi, where, depth = 0) {
|
|
61
|
+
assertWhereDepth(depth);
|
|
26
62
|
const parts = [];
|
|
27
63
|
for (const event of walkWhere(qi.whereHost, where)) {
|
|
28
64
|
switch (event.kind) {
|
|
29
65
|
case 'or':
|
|
30
|
-
parts.push(`OR[${event.conditions.map((cond) => fingerprintWhere(qi, cond)).join(',')}]`);
|
|
66
|
+
parts.push(`OR[${event.conditions.map((cond) => fingerprintWhere(qi, cond, depth + 1)).join(',')}]`);
|
|
31
67
|
break;
|
|
32
68
|
case 'and':
|
|
33
|
-
parts.push(`AND[${event.conditions.map((cond) => fingerprintWhere(qi, cond)).join(',')}]`);
|
|
69
|
+
parts.push(`AND[${event.conditions.map((cond) => fingerprintWhere(qi, cond, depth + 1)).join(',')}]`);
|
|
34
70
|
break;
|
|
35
71
|
case 'not':
|
|
36
|
-
parts.push(`NOT(${fingerprintWhere(qi, event.condition)})`);
|
|
72
|
+
parts.push(`NOT(${fingerprintWhere(qi, event.condition, depth + 1)})`);
|
|
37
73
|
break;
|
|
38
74
|
case 'relation':
|
|
39
75
|
// { posts: { some: { published: true } } } → `posts:{some(...)}`
|
|
40
|
-
parts.push(`${event.key}:{${fingerprintRelationParts(qi, event.relDef, event.filterObj).join(',')}}`);
|
|
76
|
+
parts.push(`${event.key}:{${fingerprintRelationParts(qi, event.relDef, event.filterObj, depth + 1).join(',')}}`);
|
|
41
77
|
break;
|
|
42
78
|
case 'scalar':
|
|
43
79
|
// Column-blind scalar token (see fingerprintScalarToken): the value's
|
|
@@ -55,28 +91,28 @@ export function fingerprintWhere(qi, where) {
|
|
|
55
91
|
* {@link fingerprintRelFilter} so the FULL inner shape is captured (two
|
|
56
92
|
* different sub-wheres must never collide on one cached SQL text).
|
|
57
93
|
*/
|
|
58
|
-
export function fingerprintRelationParts(qi, relDef, filterObj) {
|
|
94
|
+
export function fingerprintRelationParts(qi, relDef, filterObj, depth = 0) {
|
|
59
95
|
const relParts = [];
|
|
60
96
|
if (filterObj.some !== undefined)
|
|
61
97
|
relParts.push(filterObj.some === null
|
|
62
98
|
? 'some(null)'
|
|
63
|
-
: `some(${fingerprintRelFilter(qi, relDef.to, filterObj.some)})`);
|
|
99
|
+
: `some(${fingerprintRelFilter(qi, relDef.to, filterObj.some, depth + 1)})`);
|
|
64
100
|
if (filterObj.every !== undefined)
|
|
65
101
|
relParts.push(filterObj.every === null
|
|
66
102
|
? 'every(null)'
|
|
67
|
-
: `every(${fingerprintRelFilter(qi, relDef.to, filterObj.every)})`);
|
|
103
|
+
: `every(${fingerprintRelFilter(qi, relDef.to, filterObj.every, depth + 1)})`);
|
|
68
104
|
if (filterObj.none !== undefined)
|
|
69
105
|
relParts.push(filterObj.none === null
|
|
70
106
|
? 'none(null)'
|
|
71
|
-
: `none(${fingerprintRelFilter(qi, relDef.to, filterObj.none)})`);
|
|
107
|
+
: `none(${fingerprintRelFilter(qi, relDef.to, filterObj.none, depth + 1)})`);
|
|
72
108
|
if (filterObj.is !== undefined)
|
|
73
109
|
relParts.push(filterObj.is === null
|
|
74
110
|
? 'is(null)'
|
|
75
|
-
: `is(${fingerprintRelFilter(qi, relDef.to, filterObj.is)})`);
|
|
111
|
+
: `is(${fingerprintRelFilter(qi, relDef.to, filterObj.is, depth + 1)})`);
|
|
76
112
|
if (filterObj.isNot !== undefined)
|
|
77
113
|
relParts.push(filterObj.isNot === null
|
|
78
114
|
? 'isNot(null)'
|
|
79
|
-
: `isNot(${fingerprintRelFilter(qi, relDef.to, filterObj.isNot)})`);
|
|
115
|
+
: `isNot(${fingerprintRelFilter(qi, relDef.to, filterObj.isNot, depth + 1)})`);
|
|
80
116
|
return relParts;
|
|
81
117
|
}
|
|
82
118
|
/**
|
|
@@ -85,10 +121,10 @@ export function fingerprintRelationParts(qi, relDef, filterObj) {
|
|
|
85
121
|
* unknown, an empty-relations host makes every key scalar (matching the old
|
|
86
122
|
* `meta?.relations` short-circuit).
|
|
87
123
|
*/
|
|
88
|
-
export function fingerprintRelFilter(qi, targetTable, subWhere) {
|
|
124
|
+
export function fingerprintRelFilter(qi, targetTable, subWhere, depth = 0) {
|
|
89
125
|
const meta = qi.schema.tables[targetTable];
|
|
90
126
|
const host = meta ? scopedWhereHost(qi, meta) : emptyRelationsHost(qi, targetTable);
|
|
91
|
-
return fingerprintScopedWhere(qi, host, subWhere);
|
|
127
|
+
return fingerprintScopedWhere(qi, host, subWhere, depth);
|
|
92
128
|
}
|
|
93
129
|
/**
|
|
94
130
|
* Walk a where clause and push ONLY values into `params`, in the EXACT same
|
|
@@ -97,7 +133,8 @@ export function fingerprintRelFilter(qi, targetTable, subWhere) {
|
|
|
97
133
|
*
|
|
98
134
|
* @internal Exposed as package-private for testing.
|
|
99
135
|
*/
|
|
100
|
-
export function collectWhereParams(qi, where, params) {
|
|
136
|
+
export function collectWhereParams(qi, where, params, depth = 0) {
|
|
137
|
+
assertWhereDepth(depth);
|
|
101
138
|
// ONE canonical walk (shared with fingerprintWhere + buildWhereClause), so
|
|
102
139
|
// the key order + combinator structure cannot drift out of lockstep.
|
|
103
140
|
for (const event of walkWhere(qi.whereHost, where)) {
|
|
@@ -105,13 +142,13 @@ export function collectWhereParams(qi, where, params) {
|
|
|
105
142
|
case 'or':
|
|
106
143
|
case 'and':
|
|
107
144
|
for (const cond of event.conditions)
|
|
108
|
-
collectWhereParams(qi, cond, params);
|
|
145
|
+
collectWhereParams(qi, cond, params, depth + 1);
|
|
109
146
|
break;
|
|
110
147
|
case 'not':
|
|
111
|
-
collectWhereParams(qi, event.condition, params);
|
|
148
|
+
collectWhereParams(qi, event.condition, params, depth + 1);
|
|
112
149
|
break;
|
|
113
150
|
case 'relation':
|
|
114
|
-
collectRelationFilterParams(qi, event.relDef, event.filterObj, params);
|
|
151
|
+
collectRelationFilterParams(qi, event.relDef, event.filterObj, params, depth + 1);
|
|
115
152
|
break;
|
|
116
153
|
case 'scalar':
|
|
117
154
|
collectScalarParams(qi, event.key, event.value, params);
|
|
@@ -180,40 +217,40 @@ export function collectScalarParams(qi, key, value, params) {
|
|
|
180
217
|
* Shared by every collect site that mirrors buildRelationFilter
|
|
181
218
|
* (collectWhereParams, collectRelFilterParams, collectAliasWhereParams).
|
|
182
219
|
*/
|
|
183
|
-
export function collectRelationFilterParams(qi, relDef, filterObj, params) {
|
|
220
|
+
export function collectRelationFilterParams(qi, relDef, filterObj, params, depth = 0) {
|
|
184
221
|
const target = relDef.to;
|
|
185
222
|
if (filterObj.some !== undefined && filterObj.some !== null) {
|
|
186
|
-
collectRelFilterParams(qi, target, filterObj.some, params);
|
|
223
|
+
collectRelFilterParams(qi, target, filterObj.some, params, depth + 1);
|
|
187
224
|
collectTargetGlobalFilterExists(qi, target, params);
|
|
188
225
|
}
|
|
189
226
|
if (filterObj.none !== undefined && filterObj.none !== null) {
|
|
190
|
-
collectRelFilterParams(qi, target, filterObj.none, params);
|
|
227
|
+
collectRelFilterParams(qi, target, filterObj.none, params, depth + 1);
|
|
191
228
|
collectTargetGlobalFilterExists(qi, target, params);
|
|
192
229
|
}
|
|
193
230
|
if (filterObj.every !== undefined && filterObj.every !== null) {
|
|
194
231
|
// gf is only emitted (build) when the `every` sub-where compiles to a
|
|
195
232
|
// filter, otherwise `every` is trivially true and no subquery is built.
|
|
196
|
-
if (buildSubWhereForRelation(qi, target, filterObj.every, []) !== null) {
|
|
197
|
-
collectRelFilterParams(qi, target, filterObj.every, params);
|
|
233
|
+
if (buildSubWhereForRelation(qi, target, filterObj.every, [], depth + 1) !== null) {
|
|
234
|
+
collectRelFilterParams(qi, target, filterObj.every, params, depth + 1);
|
|
198
235
|
collectTargetGlobalFilterExists(qi, target, params);
|
|
199
236
|
}
|
|
200
237
|
}
|
|
201
238
|
if (filterObj.is !== undefined) {
|
|
202
239
|
if (filterObj.is !== null)
|
|
203
|
-
collectRelFilterParams(qi, target, filterObj.is, params);
|
|
240
|
+
collectRelFilterParams(qi, target, filterObj.is, params, depth + 1);
|
|
204
241
|
collectTargetGlobalFilterExists(qi, target, params);
|
|
205
242
|
}
|
|
206
243
|
if (filterObj.isNot !== undefined) {
|
|
207
244
|
if (filterObj.isNot !== null)
|
|
208
|
-
collectRelFilterParams(qi, target, filterObj.isNot, params);
|
|
245
|
+
collectRelFilterParams(qi, target, filterObj.isNot, params, depth + 1);
|
|
209
246
|
collectTargetGlobalFilterExists(qi, target, params);
|
|
210
247
|
}
|
|
211
248
|
}
|
|
212
|
-
export function collectRelFilterParams(qi, targetTable, subWhere, params) {
|
|
249
|
+
export function collectRelFilterParams(qi, targetTable, subWhere, params, depth = 0) {
|
|
213
250
|
const meta = qi.schema.tables[targetTable];
|
|
214
251
|
if (!meta)
|
|
215
252
|
return;
|
|
216
|
-
collectScopedWhereParams(qi, relationWhereScope(qi, targetTable, meta), subWhere, params);
|
|
253
|
+
collectScopedWhereParams(qi, relationWhereScope(qi, targetTable, meta), subWhere, params, depth);
|
|
217
254
|
}
|
|
218
255
|
/**
|
|
219
256
|
* Collect params from operator clauses. Mirrors buildOperatorClauses:
|
|
@@ -250,11 +287,11 @@ export function collectOperatorParams(qi, column, op, params, refCtx) {
|
|
|
250
287
|
if (op.notIn !== undefined)
|
|
251
288
|
params.push(qi.inParam(cv(op.notIn)));
|
|
252
289
|
if (op.contains !== undefined)
|
|
253
|
-
params.push(`%${
|
|
290
|
+
params.push(`%${likeOperand(qi, op.contains)}%`);
|
|
254
291
|
if (op.startsWith !== undefined)
|
|
255
|
-
params.push(`${
|
|
292
|
+
params.push(`${likeOperand(qi, op.startsWith)}%`);
|
|
256
293
|
if (op.endsWith !== undefined)
|
|
257
|
-
params.push(`%${
|
|
294
|
+
params.push(`%${likeOperand(qi, op.endsWith)}`);
|
|
258
295
|
}
|
|
259
296
|
/**
|
|
260
297
|
* Collect params from JSON filter. Mirrors buildJsonFilterClauses exactly:
|
|
@@ -277,9 +314,14 @@ export function collectJsonFilterParams(qi, filter, params, column) {
|
|
|
277
314
|
params.push(String(filter.equals));
|
|
278
315
|
}
|
|
279
316
|
else if (filter.equals !== undefined) {
|
|
317
|
+
// Mirrors the build path's refusal, and thrown before any param is pushed,
|
|
318
|
+
// like the text-search gate a few cases above. Not defensive: it is what
|
|
319
|
+
// stops a warmed template serving a shape the cold build refuses.
|
|
320
|
+
requireJsonContains(qi, 'equals');
|
|
280
321
|
params.push(JSON.stringify(filter.equals));
|
|
281
322
|
}
|
|
282
323
|
if (filter.contains !== undefined) {
|
|
324
|
+
requireJsonContains(qi, 'contains');
|
|
283
325
|
params.push(JSON.stringify(filter.contains));
|
|
284
326
|
}
|
|
285
327
|
if (filter.hasKey !== undefined) {
|
|
@@ -291,7 +333,7 @@ export function collectJsonFilterParams(qi, filter, params, column) {
|
|
|
291
333
|
}
|
|
292
334
|
for (const { pattern, value } of jsonStringEntries(filter, column)) {
|
|
293
335
|
pushPathOnce();
|
|
294
|
-
params.push(pattern(
|
|
336
|
+
params.push(pattern(likeOperand(qi, value)));
|
|
295
337
|
}
|
|
296
338
|
}
|
|
297
339
|
/** Collect params from array filter. Mirrors buildArrayFilterClauses. */
|
|
@@ -308,16 +350,15 @@ export function collectArrayFilterParams(qi, filter, params) {
|
|
|
308
350
|
/**
|
|
309
351
|
* Collect params for a vector distance WHERE filter. Mirrors
|
|
310
352
|
* {@link buildVectorFilterClauses}: the `$n::vector` query vector first, then
|
|
311
|
-
* the comparison threshold(s)
|
|
353
|
+
* the comparison threshold(s), both enumerated AND validated by the shared
|
|
354
|
+
* {@link vectorThresholdEntries} (see there for the production-only bug that
|
|
355
|
+
* inlining the loop on both sides produced).
|
|
312
356
|
*/
|
|
313
357
|
export function collectVectorFilterParams(qi, field, rawColumn, filter, params) {
|
|
314
358
|
const dist = filter.distance;
|
|
315
359
|
pushVectorParam(qi, field, rawColumn, dist.to, params);
|
|
316
|
-
for (const
|
|
317
|
-
|
|
318
|
-
if (threshold !== undefined)
|
|
319
|
-
params.push(threshold);
|
|
320
|
-
}
|
|
360
|
+
for (const { threshold } of vectorThresholdEntries(filter, field))
|
|
361
|
+
params.push(threshold);
|
|
321
362
|
}
|
|
322
363
|
/** Build WHERE clause from a where object (supports operators, NULL, OR) */
|
|
323
364
|
export function buildWhere(qi, where) {
|
|
@@ -364,7 +405,10 @@ export function mergeGlobalFilter(qi, userWhere) {
|
|
|
364
405
|
return userWhere;
|
|
365
406
|
if (userWhere === undefined)
|
|
366
407
|
return gf;
|
|
367
|
-
|
|
408
|
+
// Branded: this `AND` is Turbine's, always exactly two branches, so it must
|
|
409
|
+
// not put the statement on the variable-arity (unnamed) path. See
|
|
410
|
+
// {@link markInternalCombinator}.
|
|
411
|
+
return markInternalCombinator({ AND: [userWhere, gf] });
|
|
368
412
|
}
|
|
369
413
|
/**
|
|
370
414
|
* SQL clause for `targetTable`'s global filter rendered against `alias`
|
|
@@ -483,15 +527,26 @@ allowFullTableScan) {
|
|
|
483
527
|
* Returns null if no conditions exist.
|
|
484
528
|
* Supports: equality, operators, NULL, OR, AND, NOT, relation filters (some/every/none).
|
|
485
529
|
*/
|
|
486
|
-
export function buildWhereClause(qi, where, params) {
|
|
530
|
+
export function buildWhereClause(qi, where, params, depth = 0) {
|
|
531
|
+
assertWhereDepth(depth);
|
|
487
532
|
const andClauses = [];
|
|
533
|
+
// A combinator ARRAY is the one shape whose branch COUNT is written into the
|
|
534
|
+
// SQL text, so the statement stops being a fixed shape (see
|
|
535
|
+
// markVariableArity). Marked here, on the BUILD walk, because the mark
|
|
536
|
+
// is read immediately after `build()` inside `acquireSql`, so it decides the
|
|
537
|
+
// prepared-statement name of the very entry being created. The internal
|
|
538
|
+
// wrapper the global-filter merge synthesizes is exempt, see
|
|
539
|
+
// INTERNAL_COMBINATOR.
|
|
540
|
+
const internal = isInternalCombinator(where);
|
|
488
541
|
// ONE canonical walk (shared with fingerprintWhere + collectWhereParams).
|
|
489
542
|
for (const event of walkWhere(qi.whereHost, where)) {
|
|
490
543
|
switch (event.kind) {
|
|
491
544
|
case 'or': {
|
|
545
|
+
if (!internal)
|
|
546
|
+
qi.markVariableArity();
|
|
492
547
|
const orClauses = [];
|
|
493
548
|
for (const orCond of event.conditions) {
|
|
494
|
-
const sub = buildWhereClause(qi, orCond, params);
|
|
549
|
+
const sub = buildWhereClause(qi, orCond, params, depth + 1);
|
|
495
550
|
if (sub)
|
|
496
551
|
orClauses.push(sub);
|
|
497
552
|
}
|
|
@@ -500,21 +555,23 @@ export function buildWhereClause(qi, where, params) {
|
|
|
500
555
|
break;
|
|
501
556
|
}
|
|
502
557
|
case 'and':
|
|
558
|
+
if (!internal)
|
|
559
|
+
qi.markVariableArity();
|
|
503
560
|
for (const andCond of event.conditions) {
|
|
504
|
-
const sub = buildWhereClause(qi, andCond, params);
|
|
561
|
+
const sub = buildWhereClause(qi, andCond, params, depth + 1);
|
|
505
562
|
if (sub)
|
|
506
563
|
andClauses.push(sub);
|
|
507
564
|
}
|
|
508
565
|
break;
|
|
509
566
|
case 'not': {
|
|
510
|
-
const sub = buildWhereClause(qi, event.condition, params);
|
|
567
|
+
const sub = buildWhereClause(qi, event.condition, params, depth + 1);
|
|
511
568
|
if (sub)
|
|
512
569
|
andClauses.push(`NOT (${sub})`);
|
|
513
570
|
break;
|
|
514
571
|
}
|
|
515
572
|
case 'relation': {
|
|
516
573
|
// { posts: { some: { published: true } } } → EXISTS / NOT EXISTS
|
|
517
|
-
const relClause = buildRelationFilter(qi, event.key, event.relDef, event.filterObj, params);
|
|
574
|
+
const relClause = buildRelationFilter(qi, event.key, event.relDef, event.filterObj, params, undefined, depth);
|
|
518
575
|
if (relClause)
|
|
519
576
|
andClauses.push(relClause);
|
|
520
577
|
break;
|
|
@@ -639,14 +696,20 @@ export function aliasWhereScope(qi, targetTable, meta, alias) {
|
|
|
639
696
|
* former hand-mirrored walkers, since it renders the same clauses in the same
|
|
640
697
|
* ({@link walkWhere}-canonical) key order.
|
|
641
698
|
*/
|
|
642
|
-
export function buildScopedWhere(qi, scope, where, params) {
|
|
699
|
+
export function buildScopedWhere(qi, scope, where, params, depth = 0) {
|
|
700
|
+
assertWhereDepth(depth);
|
|
643
701
|
const clauses = [];
|
|
702
|
+
const internal = isInternalCombinator(where);
|
|
644
703
|
for (const event of walkWhere(scope.host, where)) {
|
|
645
704
|
switch (event.kind) {
|
|
646
705
|
case 'or':
|
|
647
706
|
case 'and': {
|
|
707
|
+
// Same variable-arity rule as the top level: a nested relation filter
|
|
708
|
+
// or a relation `with` where can carry a caller-sized OR just as easily.
|
|
709
|
+
if (!internal)
|
|
710
|
+
qi.markVariableArity();
|
|
648
711
|
const parts = event.conditions
|
|
649
|
-
.map((cond) => buildScopedWhere(qi, scope, cond, params))
|
|
712
|
+
.map((cond) => buildScopedWhere(qi, scope, cond, params, depth + 1))
|
|
650
713
|
.filter((s) => s !== null)
|
|
651
714
|
.map((s) => `(${s})`);
|
|
652
715
|
if (parts.length > 0)
|
|
@@ -654,13 +717,13 @@ export function buildScopedWhere(qi, scope, where, params) {
|
|
|
654
717
|
break;
|
|
655
718
|
}
|
|
656
719
|
case 'not': {
|
|
657
|
-
const sub = buildScopedWhere(qi, scope, event.condition, params);
|
|
720
|
+
const sub = buildScopedWhere(qi, scope, event.condition, params, depth + 1);
|
|
658
721
|
if (sub)
|
|
659
722
|
clauses.push(`NOT (${sub})`);
|
|
660
723
|
break;
|
|
661
724
|
}
|
|
662
725
|
case 'relation': {
|
|
663
|
-
const c = buildRelationFilter(qi, event.key, event.relDef, event.filterObj, params, scope.relationParent);
|
|
726
|
+
const c = buildRelationFilter(qi, event.key, event.relDef, event.filterObj, params, scope.relationParent, depth);
|
|
664
727
|
if (c)
|
|
665
728
|
clauses.push(c);
|
|
666
729
|
break;
|
|
@@ -733,21 +796,22 @@ export function buildScopedScalarClause(qi, scope, field, value, params, clauses
|
|
|
733
796
|
* without rebuilding SQL. Serves both {@link collectRelFilterParams} and
|
|
734
797
|
* {@link collectAliasWhereParams}.
|
|
735
798
|
*/
|
|
736
|
-
export function collectScopedWhereParams(qi, scope, where, params) {
|
|
799
|
+
export function collectScopedWhereParams(qi, scope, where, params, depth = 0) {
|
|
800
|
+
assertWhereDepth(depth);
|
|
737
801
|
for (const event of walkWhere(scope.host, where)) {
|
|
738
802
|
switch (event.kind) {
|
|
739
803
|
case 'or':
|
|
740
804
|
case 'and':
|
|
741
805
|
for (const cond of event.conditions)
|
|
742
|
-
collectScopedWhereParams(qi, scope, cond, params);
|
|
806
|
+
collectScopedWhereParams(qi, scope, cond, params, depth + 1);
|
|
743
807
|
break;
|
|
744
808
|
case 'not':
|
|
745
|
-
collectScopedWhereParams(qi, scope, event.condition, params);
|
|
809
|
+
collectScopedWhereParams(qi, scope, event.condition, params, depth + 1);
|
|
746
810
|
break;
|
|
747
811
|
case 'relation':
|
|
748
812
|
// Same some→none→every→is→isNot (each: sub-where params then target
|
|
749
813
|
// global-filter params) as buildRelationFilter emits.
|
|
750
|
-
collectRelationFilterParams(qi, event.relDef, event.filterObj, params);
|
|
814
|
+
collectRelationFilterParams(qi, event.relDef, event.filterObj, params, depth);
|
|
751
815
|
break;
|
|
752
816
|
case 'scalar':
|
|
753
817
|
collectScopedScalarParams(qi, scope, event.key, event.value, params);
|
|
@@ -791,21 +855,22 @@ export function collectScopedScalarParams(qi, scope, field, value, params) {
|
|
|
791
855
|
* may differ from the pre-unification walkers as long as collisions stay
|
|
792
856
|
* impossible.
|
|
793
857
|
*/
|
|
794
|
-
export function fingerprintScopedWhere(qi, host, where) {
|
|
858
|
+
export function fingerprintScopedWhere(qi, host, where, depth = 0) {
|
|
859
|
+
assertWhereDepth(depth);
|
|
795
860
|
const parts = [];
|
|
796
861
|
for (const event of walkWhere(host, where)) {
|
|
797
862
|
switch (event.kind) {
|
|
798
863
|
case 'or':
|
|
799
|
-
parts.push(`OR[${event.conditions.map((c) => fingerprintScopedWhere(qi, host, c)).join(',')}]`);
|
|
864
|
+
parts.push(`OR[${event.conditions.map((c) => fingerprintScopedWhere(qi, host, c, depth + 1)).join(',')}]`);
|
|
800
865
|
break;
|
|
801
866
|
case 'and':
|
|
802
|
-
parts.push(`AND[${event.conditions.map((c) => fingerprintScopedWhere(qi, host, c)).join(',')}]`);
|
|
867
|
+
parts.push(`AND[${event.conditions.map((c) => fingerprintScopedWhere(qi, host, c, depth + 1)).join(',')}]`);
|
|
803
868
|
break;
|
|
804
869
|
case 'not':
|
|
805
|
-
parts.push(`NOT(${fingerprintScopedWhere(qi, host, event.condition)})`);
|
|
870
|
+
parts.push(`NOT(${fingerprintScopedWhere(qi, host, event.condition, depth + 1)})`);
|
|
806
871
|
break;
|
|
807
872
|
case 'relation':
|
|
808
|
-
parts.push(`${event.key}:{${fingerprintRelationParts(qi, event.relDef, event.filterObj).join(',')}}`);
|
|
873
|
+
parts.push(`${event.key}:{${fingerprintRelationParts(qi, event.relDef, event.filterObj, depth).join(',')}}`);
|
|
809
874
|
break;
|
|
810
875
|
case 'scalar':
|
|
811
876
|
parts.push(`${event.key}:${fingerprintScalarToken(event.value)}`);
|
|
@@ -818,7 +883,14 @@ export function fingerprintScopedWhere(qi, host, where) {
|
|
|
818
883
|
* Build relation filter SQL: WHERE EXISTS / NOT EXISTS subquery
|
|
819
884
|
* Supports: some (EXISTS), every (NOT EXISTS ... NOT), none (NOT EXISTS)
|
|
820
885
|
*/
|
|
821
|
-
export function buildRelationFilter(qi, _relName, relDef, filterObj, params, parentTable
|
|
886
|
+
export function buildRelationFilter(qi, _relName, relDef, filterObj, params, parentTable,
|
|
887
|
+
/**
|
|
888
|
+
* Nesting depth of the WHERE walk that reached this relation filter. Each
|
|
889
|
+
* relation descent is a level too: `{ posts: { some: { comments: { some:
|
|
890
|
+
* … } } } }` recurses through here just as `NOT` does, and is exactly as
|
|
891
|
+
* unbounded without a cap (see {@link assertWhereDepth}).
|
|
892
|
+
*/
|
|
893
|
+
depth = 0) {
|
|
822
894
|
const targetTable = relDef.to;
|
|
823
895
|
const targetMeta = qi.schema.tables[targetTable];
|
|
824
896
|
if (!targetMeta)
|
|
@@ -890,21 +962,21 @@ export function buildRelationFilter(qi, _relName, relDef, filterObj, params, par
|
|
|
890
962
|
// which also skips null. Unreachable via normalization today, guarded anyway.
|
|
891
963
|
if (filterObj.some !== undefined && filterObj.some !== null) {
|
|
892
964
|
const subWhere = filterObj.some;
|
|
893
|
-
const filterClause = buildSubWhereForRelation(qi, targetTable, subWhere, params);
|
|
965
|
+
const filterClause = buildSubWhereForRelation(qi, targetTable, subWhere, params, depth + 1);
|
|
894
966
|
const filterAnd = filterClause ? ` AND ${filterClause}` : '';
|
|
895
967
|
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
|
|
896
968
|
}
|
|
897
969
|
// "none": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND filter AND gf)
|
|
898
970
|
if (filterObj.none !== undefined && filterObj.none !== null) {
|
|
899
971
|
const subWhere = filterObj.none;
|
|
900
|
-
const filterClause = buildSubWhereForRelation(qi, targetTable, subWhere, params);
|
|
972
|
+
const filterClause = buildSubWhereForRelation(qi, targetTable, subWhere, params, depth + 1);
|
|
901
973
|
const filterAnd = filterClause ? ` AND ${filterClause}` : '';
|
|
902
974
|
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
|
|
903
975
|
}
|
|
904
976
|
// "every": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND gf AND NOT (filter))
|
|
905
977
|
if (filterObj.every !== undefined && filterObj.every !== null) {
|
|
906
978
|
const subWhere = filterObj.every;
|
|
907
|
-
const filterClause = buildSubWhereForRelation(qi, targetTable, subWhere, params);
|
|
979
|
+
const filterClause = buildSubWhereForRelation(qi, targetTable, subWhere, params, depth + 1);
|
|
908
980
|
if (filterClause) {
|
|
909
981
|
// gf params pushed AFTER filter params (collect mirrors this order), but
|
|
910
982
|
// placed textually inside the domain so it restricts which rows count.
|
|
@@ -923,7 +995,7 @@ export function buildRelationFilter(qi, _relName, relDef, filterObj, params, par
|
|
|
923
995
|
}
|
|
924
996
|
else {
|
|
925
997
|
const subWhere = filterObj.is;
|
|
926
|
-
const filterClause = buildSubWhereForRelation(qi, targetTable, subWhere, params);
|
|
998
|
+
const filterClause = buildSubWhereForRelation(qi, targetTable, subWhere, params, depth + 1);
|
|
927
999
|
const filterAnd = filterClause ? ` AND ${filterClause}` : '';
|
|
928
1000
|
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
|
|
929
1001
|
}
|
|
@@ -936,7 +1008,7 @@ export function buildRelationFilter(qi, _relName, relDef, filterObj, params, par
|
|
|
936
1008
|
}
|
|
937
1009
|
else {
|
|
938
1010
|
const subWhere = filterObj.isNot;
|
|
939
|
-
const filterClause = buildSubWhereForRelation(qi, targetTable, subWhere, params);
|
|
1011
|
+
const filterClause = buildSubWhereForRelation(qi, targetTable, subWhere, params, depth + 1);
|
|
940
1012
|
const filterAnd = filterClause ? ` AND ${filterClause}` : '';
|
|
941
1013
|
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
|
|
942
1014
|
}
|
|
@@ -947,11 +1019,11 @@ export function buildRelationFilter(qi, _relName, relDef, filterObj, params, par
|
|
|
947
1019
|
* Build WHERE clause conditions for a relation filter subquery.
|
|
948
1020
|
* Uses the target table's column mapping to resolve field names.
|
|
949
1021
|
*/
|
|
950
|
-
export function buildSubWhereForRelation(qi, targetTable, subWhere, params) {
|
|
1022
|
+
export function buildSubWhereForRelation(qi, targetTable, subWhere, params, depth = 0) {
|
|
951
1023
|
const meta = qi.schema.tables[targetTable];
|
|
952
1024
|
if (!meta)
|
|
953
1025
|
return null;
|
|
954
|
-
return buildScopedWhere(qi, relationWhereScope(qi, targetTable, meta), subWhere, params);
|
|
1026
|
+
return buildScopedWhere(qi, relationWhereScope(qi, targetTable, meta), subWhere, params, depth);
|
|
955
1027
|
}
|
|
956
1028
|
/**
|
|
957
1029
|
* Resolve a column's Postgres type from an arbitrary table's metadata
|
|
@@ -1195,15 +1267,15 @@ export function buildOperatorClauses(qi, column, op, params, refCtx) {
|
|
|
1195
1267
|
}
|
|
1196
1268
|
const insensitive = op.mode === 'insensitive';
|
|
1197
1269
|
if (op.contains !== undefined) {
|
|
1198
|
-
params.push(`%${
|
|
1270
|
+
params.push(`%${likeOperand(qi, op.contains)}%`);
|
|
1199
1271
|
clauses.push(buildLikeClause(qi, column, qi.p(params.length), insensitive));
|
|
1200
1272
|
}
|
|
1201
1273
|
if (op.startsWith !== undefined) {
|
|
1202
|
-
params.push(`${
|
|
1274
|
+
params.push(`${likeOperand(qi, op.startsWith)}%`);
|
|
1203
1275
|
clauses.push(buildLikeClause(qi, column, qi.p(params.length), insensitive));
|
|
1204
1276
|
}
|
|
1205
1277
|
if (op.endsWith !== undefined) {
|
|
1206
|
-
params.push(`%${
|
|
1278
|
+
params.push(`%${likeOperand(qi, op.endsWith)}`);
|
|
1207
1279
|
clauses.push(buildLikeClause(qi, column, qi.p(params.length), insensitive));
|
|
1208
1280
|
}
|
|
1209
1281
|
return clauses;
|
|
@@ -1222,6 +1294,34 @@ export function requireFullTextSearch(qi) {
|
|
|
1222
1294
|
throw new UnsupportedFeatureError('the full-text search filter (`search`)', qi.dialect.name, 'Full-text `search` compiles to PostgreSQL to_tsvector/to_tsquery. ' +
|
|
1223
1295
|
'Use `contains` (LIKE) on this engine, or run the query on PostgreSQL.');
|
|
1224
1296
|
}
|
|
1297
|
+
/**
|
|
1298
|
+
* Gate the pathless JSON containment filters (`contains`, and the `equals`
|
|
1299
|
+
* spelling that compiles to the same expression) on
|
|
1300
|
+
* {@link Dialect.supportsJsonContains}.
|
|
1301
|
+
*
|
|
1302
|
+
* The engine this exists for is SQLite, whose emulation compares a decoded
|
|
1303
|
+
* `json_each.value` against a param bound as JSON TEXT and therefore matched
|
|
1304
|
+
* NOTHING, for every operand type: object, array, string and number alike all
|
|
1305
|
+
* returned zero rows where PostgreSQL and MySQL returned the document (measured
|
|
1306
|
+
* in-process, table in dialect.ts). Fewer rows with no error is precisely what
|
|
1307
|
+
* the capability contract exists to convert into a refusal, and since the
|
|
1308
|
+
* feature never worked there, refusing it removes nothing.
|
|
1309
|
+
*
|
|
1310
|
+
* Called from BOTH the build and the param-collect side, and here that is
|
|
1311
|
+
* load-bearing rather than symmetric-for-its-own-sake: a {@link JsonFilter}
|
|
1312
|
+
* fingerprints by which KEYS are present, not by what they hold, so every
|
|
1313
|
+
* `contains` on a column shares one cache entry and a build-only gate would be
|
|
1314
|
+
* skipped for the entire warm life of that entry.
|
|
1315
|
+
*/
|
|
1316
|
+
export function requireJsonContains(qi, clause) {
|
|
1317
|
+
if (qi.dialect.supportsJsonContains)
|
|
1318
|
+
return;
|
|
1319
|
+
throw new UnsupportedFeatureError(`JSON containment (\`${clause}\` without \`path\`)`, qi.dialect.name, `This engine has no equivalent of PostgreSQL's \`@>\`, and the emulation Turbine used for ` +
|
|
1320
|
+
`\`${clause}\` matched no rows for any operand rather than failing, so it is refused instead. ` +
|
|
1321
|
+
'Filter on the specific value instead: `{ path: [...], equals: ... }` compiles to a real JSON ' +
|
|
1322
|
+
'extraction (`json_extract`) rather than an emulation. For structural containment, run the ' +
|
|
1323
|
+
'query on PostgreSQL or MySQL.');
|
|
1324
|
+
}
|
|
1225
1325
|
/**
|
|
1226
1326
|
* Gate the array filter operators (`has` / `hasEvery` / `hasSome` / `isEmpty`)
|
|
1227
1327
|
* on {@link Dialect.supportsArrayColumns}. They compile to PostgreSQL array
|
|
@@ -1422,10 +1522,13 @@ export function jsonStringEntries(filter, column) {
|
|
|
1422
1522
|
continue;
|
|
1423
1523
|
if (filter.path === undefined) {
|
|
1424
1524
|
throw new ValidationError(`[turbine] JSON operator '${op}' on ${column} requires a \`path\` ` +
|
|
1425
|
-
`(e.g. { path: ['meta', 'title'], ${op}: ${
|
|
1525
|
+
`(e.g. { path: ['meta', 'title'], ${op}: ${valueForMessage(value)} }).`);
|
|
1426
1526
|
}
|
|
1427
1527
|
if (typeof value !== 'string') {
|
|
1428
|
-
|
|
1528
|
+
// `typeof` rather than the value: it says everything the reader needs
|
|
1529
|
+
// (they passed a number where a string belongs) and carries no data.
|
|
1530
|
+
throw new ValidationError(`[turbine] JSON operator '${op}' on ${column} requires a string, got ${typeof value} ` +
|
|
1531
|
+
`(${valueForMessage(value)}).`);
|
|
1429
1532
|
}
|
|
1430
1533
|
entries.push({ op, pattern, value });
|
|
1431
1534
|
}
|
|
@@ -1439,11 +1542,11 @@ export function jsonRangeEntries(_qi, filter, column) {
|
|
|
1439
1542
|
continue;
|
|
1440
1543
|
if (filter.path === undefined) {
|
|
1441
1544
|
throw new ValidationError(`[turbine] JSON range operator '${op}' on ${column} requires a \`path\` ` +
|
|
1442
|
-
`(e.g. { path: ['meta', 'score'], ${op}: ${
|
|
1545
|
+
`(e.g. { path: ['meta', 'score'], ${op}: ${valueForMessage(value)} }).`);
|
|
1443
1546
|
}
|
|
1444
1547
|
if (typeof value !== 'number' && typeof value !== 'string') {
|
|
1445
1548
|
throw new ValidationError(`[turbine] JSON range operator '${op}' on ${column} requires a number or string, ` +
|
|
1446
|
-
`got ${
|
|
1549
|
+
`got ${typeof value} (${valueForMessage(value)}).`);
|
|
1447
1550
|
}
|
|
1448
1551
|
if (typeof value === 'number' && !Number.isFinite(value)) {
|
|
1449
1552
|
throw new ValidationError(`[turbine] JSON range operator '${op}' on ${column} requires a finite number.`);
|
|
@@ -1480,12 +1583,18 @@ export function buildJsonFilterClauses(qi, column, filter, params) {
|
|
|
1480
1583
|
clauses.push(`${extract} = ${qi.p(params.length)}`);
|
|
1481
1584
|
}
|
|
1482
1585
|
else if (filter.equals !== undefined) {
|
|
1483
|
-
// Containment equality: column @> $N::jsonb
|
|
1586
|
+
// Containment equality: column @> $N::jsonb. A pathless `equals` compiles
|
|
1587
|
+
// to the SAME containment expression `contains` does, so it inherits the
|
|
1588
|
+
// same engine limitation and is refused under its own clause name (the two
|
|
1589
|
+
// spellings are far enough apart that naming the wrong one would send the
|
|
1590
|
+
// reader to the wrong line).
|
|
1591
|
+
requireJsonContains(qi, 'equals');
|
|
1484
1592
|
params.push(JSON.stringify(filter.equals));
|
|
1485
1593
|
clauses.push(qi.dialect.buildJsonContains(column, qi.p(params.length)));
|
|
1486
1594
|
}
|
|
1487
1595
|
if (filter.contains !== undefined) {
|
|
1488
1596
|
// Containment: column @> $N::jsonb
|
|
1597
|
+
requireJsonContains(qi, 'contains');
|
|
1489
1598
|
params.push(JSON.stringify(filter.contains));
|
|
1490
1599
|
clauses.push(qi.dialect.buildJsonContains(column, qi.p(params.length)));
|
|
1491
1600
|
}
|
|
@@ -1507,7 +1616,7 @@ export function buildJsonFilterClauses(qi, column, filter, params) {
|
|
|
1507
1616
|
// `_` matches literally instead of turning into a wildcard.
|
|
1508
1617
|
for (const { pattern, value } of jsonStringEntries(filter, column)) {
|
|
1509
1618
|
const extract = pathExtract();
|
|
1510
|
-
params.push(pattern(
|
|
1619
|
+
params.push(pattern(likeOperand(qi, value)));
|
|
1511
1620
|
clauses.push(buildLikeClause(qi, extract, qi.p(params.length), filter.mode === 'insensitive'));
|
|
1512
1621
|
}
|
|
1513
1622
|
return clauses;
|
|
@@ -1592,21 +1701,48 @@ export function buildVectorFilterClauses(qi, field, rawColumn, filter, params) {
|
|
|
1592
1701
|
const placeholder = pushVectorParam(qi, field, rawColumn, dist.to, params);
|
|
1593
1702
|
const distanceExpr = `${qi.q(rawColumn)} ${operator} ${placeholder}`;
|
|
1594
1703
|
const clauses = [];
|
|
1704
|
+
for (const { sqlOp, threshold } of vectorThresholdEntries(filter, field)) {
|
|
1705
|
+
params.push(threshold);
|
|
1706
|
+
clauses.push(`${distanceExpr} ${sqlOp} ${qi.p(params.length)}`);
|
|
1707
|
+
}
|
|
1708
|
+
return clauses;
|
|
1709
|
+
}
|
|
1710
|
+
/**
|
|
1711
|
+
* Validate and enumerate the distance comparisons on a vector filter, in the
|
|
1712
|
+
* fixed {@link VECTOR_DISTANCE_COMPARATORS} order. Shared by the SQL-build path
|
|
1713
|
+
* ({@link buildVectorFilterClauses}) and the cache-hit param-collect path
|
|
1714
|
+
* ({@link collectVectorFilterParams}), exactly like {@link jsonRangeEntries}.
|
|
1715
|
+
*
|
|
1716
|
+
* IT IS THE VALIDATION THAT MAKES THIS SHARED, not the enumeration. Both sides
|
|
1717
|
+
* used to inline the same `for` loop, and only the build side checked the
|
|
1718
|
+
* threshold. That build side does not run on a cache HIT, and outside dev the
|
|
1719
|
+
* lockstep cross-check (which re-runs it, and is what masked this) is off, so a
|
|
1720
|
+
* warmed template bound `lt: NaN`, `lt: '5'` or `lt: { a: 1 }` straight into
|
|
1721
|
+
* the statement. NaN is the one that matters: Postgres sorts it above every
|
|
1722
|
+
* real distance, so `distance < NaN` matches EVERY row, i.e. the predicate
|
|
1723
|
+
* inverts, silently, in production only. Same bug class as the 0.19.2 /
|
|
1724
|
+
* 0.32.1 cache-hit drifts, and the same fix: one function, both paths.
|
|
1725
|
+
*
|
|
1726
|
+
* The "at least one comparison" refusal lives here too, so the collect path
|
|
1727
|
+
* cannot quietly accept a filter the build path rejects.
|
|
1728
|
+
*/
|
|
1729
|
+
export function vectorThresholdEntries(filter, field) {
|
|
1730
|
+
const dist = filter.distance;
|
|
1731
|
+
const entries = [];
|
|
1595
1732
|
for (const [cmp, sqlOp] of Object.entries(VECTOR_DISTANCE_COMPARATORS)) {
|
|
1596
1733
|
const threshold = dist[cmp];
|
|
1597
1734
|
if (threshold === undefined)
|
|
1598
1735
|
continue;
|
|
1599
1736
|
if (typeof threshold !== 'number' || !Number.isFinite(threshold)) {
|
|
1600
1737
|
throw new ValidationError(`[turbine] Vector distance threshold "${cmp}" on "${field}" must be a finite number; ` +
|
|
1601
|
-
`got ${
|
|
1738
|
+
`got ${typeof threshold} (${valueForMessage(threshold)}).`);
|
|
1602
1739
|
}
|
|
1603
|
-
|
|
1604
|
-
clauses.push(`${distanceExpr} ${sqlOp} ${qi.p(params.length)}`);
|
|
1740
|
+
entries.push({ sqlOp, threshold });
|
|
1605
1741
|
}
|
|
1606
|
-
if (
|
|
1742
|
+
if (entries.length === 0) {
|
|
1607
1743
|
throw new ValidationError(`[turbine] Vector distance filter on "${field}" requires at least one comparison (lt / lte / gt / gte).`);
|
|
1608
1744
|
}
|
|
1609
|
-
return
|
|
1745
|
+
return entries;
|
|
1610
1746
|
}
|
|
1611
1747
|
/**
|
|
1612
1748
|
* Build SQL clause for full-text search using to_tsvector @@ to_tsquery.
|