turbine-orm 0.81.0 → 0.90.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 +26 -25
- package/dist/checkout.d.ts +27 -2
- package/dist/checkout.js +75 -4
- package/dist/cjs/checkout.d.ts +27 -2
- package/dist/cjs/checkout.js +76 -3
- package/dist/cjs/cli/code-first-tags.d.ts +62 -0
- package/dist/cjs/cli/code-first-tags.js +124 -0
- package/dist/cjs/cli/compile-query.js +9 -0
- package/dist/cjs/cli/config.d.ts +35 -6
- package/dist/cjs/cli/config.js +17 -1
- package/dist/cjs/cli/destructive.d.ts +7 -4
- package/dist/cjs/cli/destructive.js +224 -20
- package/dist/cjs/cli/error-catalog.js +36 -0
- package/dist/cjs/cli/error-text.d.ts +15 -0
- package/dist/cjs/cli/error-text.js +40 -0
- package/dist/cjs/cli/index.d.ts +62 -4
- package/dist/cjs/cli/index.js +568 -108
- package/dist/cjs/cli/local-server-guard.d.ts +53 -0
- package/dist/cjs/cli/local-server-guard.js +119 -0
- package/dist/cjs/cli/local-server-output.d.ts +41 -0
- package/dist/cjs/cli/local-server-output.js +103 -0
- package/dist/cjs/cli/mcp.d.ts +8 -0
- package/dist/cjs/cli/mcp.js +121 -32
- package/dist/cjs/cli/migrate.d.ts +113 -5
- package/dist/cjs/cli/migrate.js +270 -21
- package/dist/cjs/cli/observe.d.ts +6 -1
- package/dist/cjs/cli/observe.js +85 -55
- package/dist/cjs/cli/pii-predicate-guard.d.ts +43 -23
- package/dist/cjs/cli/pii-predicate-guard.js +98 -37
- package/dist/cjs/cli/prisma-resolve.js +17 -13
- package/dist/cjs/cli/prisma-schema.js +12 -4
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.d.ts +14 -2
- package/dist/cjs/cli/studio.js +244 -69
- package/dist/cjs/cli/ui.d.ts +1 -1
- package/dist/cjs/client.d.ts +558 -85
- package/dist/cjs/client.js +1270 -395
- package/dist/cjs/connection-guard.d.ts +13 -0
- package/dist/cjs/connection-guard.js +28 -0
- package/dist/cjs/connection-url.d.ts +51 -16
- package/dist/cjs/connection-url.js +67 -34
- package/dist/cjs/diagnostics.d.ts +81 -0
- package/dist/cjs/diagnostics.js +147 -0
- package/dist/cjs/dialect.d.ts +449 -20
- package/dist/cjs/dialect.js +117 -76
- package/dist/cjs/engine-config.d.ts +7 -6
- package/dist/cjs/errors.d.ts +126 -6
- package/dist/cjs/errors.js +256 -13
- package/dist/cjs/generate.d.ts +57 -7
- package/dist/cjs/generate.js +635 -279
- package/dist/cjs/global-filters-config.d.ts +59 -0
- package/dist/cjs/global-filters-config.js +367 -0
- package/dist/cjs/index.d.ts +9 -4
- package/dist/cjs/index.js +10 -2
- package/dist/cjs/introspect.d.ts +31 -6
- package/dist/cjs/introspect.js +108 -50
- package/dist/cjs/log-codes.d.ts +12 -0
- package/dist/cjs/log-codes.js +77 -0
- package/dist/cjs/logger.d.ts +87 -0
- package/dist/cjs/logger.js +98 -0
- package/dist/cjs/mssql.d.ts +8 -5
- package/dist/cjs/mssql.js +352 -65
- package/dist/cjs/mysql.d.ts +3 -5
- package/dist/cjs/mysql.js +420 -70
- package/dist/cjs/nested-write.d.ts +3 -0
- package/dist/cjs/nested-write.js +179 -114
- package/dist/cjs/pipeline-submittable.d.ts +12 -0
- package/dist/cjs/pipeline-submittable.js +118 -44
- package/dist/cjs/pipeline.js +21 -26
- package/dist/cjs/powdb-introspect.d.ts +5 -2
- package/dist/cjs/powdb-introspect.js +20 -15
- package/dist/cjs/powdb-shared.d.ts +36 -5
- package/dist/cjs/powdb-shared.js +67 -8
- package/dist/cjs/powdb.d.ts +4 -0
- package/dist/cjs/powdb.js +183 -51
- package/dist/cjs/powql.d.ts +394 -51
- package/dist/cjs/powql.js +1829 -284
- package/dist/cjs/prisma-compat.d.ts +23 -15
- package/dist/cjs/prisma-compat.js +507 -372
- package/dist/cjs/query/aggregates.d.ts +17 -1
- package/dist/cjs/query/aggregates.js +261 -29
- package/dist/cjs/query/batched-loader.d.ts +25 -6
- package/dist/cjs/query/batched-loader.js +103 -20
- package/dist/cjs/query/builder.d.ts +325 -184
- package/dist/cjs/query/builder.js +1452 -490
- package/dist/cjs/query/column-ref.d.ts +61 -0
- package/dist/cjs/query/column-ref.js +73 -0
- package/dist/cjs/query/compound-unique.d.ts +21 -0
- package/dist/cjs/query/compound-unique.js +98 -18
- package/dist/cjs/query/deferred.d.ts +53 -16
- package/dist/cjs/query/expr.d.ts +217 -0
- package/dist/cjs/query/expr.js +859 -0
- package/dist/cjs/query/filters.d.ts +155 -53
- package/dist/cjs/query/filters.js +613 -76
- package/dist/cjs/query/index.d.ts +2 -0
- package/dist/cjs/query/index.js +7 -1
- package/dist/cjs/query/json-value.d.ts +43 -0
- package/dist/cjs/query/json-value.js +59 -0
- package/dist/cjs/query/normalize-args.d.ts +87 -0
- package/dist/cjs/query/normalize-args.js +527 -0
- package/dist/cjs/query/option-surface.d.ts +16 -4
- package/dist/cjs/query/option-surface.js +57 -14
- package/dist/cjs/query/relation-names.d.ts +14 -0
- package/dist/cjs/query/relation-names.js +77 -18
- package/dist/cjs/query/relations.d.ts +30 -3
- package/dist/cjs/query/relations.js +219 -63
- package/dist/cjs/query/row-lock.d.ts +49 -0
- package/dist/cjs/query/row-lock.js +138 -0
- package/dist/cjs/query/sql-comment.d.ts +161 -0
- package/dist/cjs/query/sql-comment.js +381 -0
- package/dist/cjs/query/stream-idle.d.ts +132 -0
- package/dist/cjs/query/stream-idle.js +212 -0
- package/dist/cjs/query/table-client.d.ts +101 -0
- package/dist/cjs/query/table-client.js +74 -0
- package/dist/cjs/query/types.d.ts +677 -298
- package/dist/cjs/query/utils.d.ts +105 -6
- package/dist/cjs/query/utils.js +426 -19
- package/dist/cjs/query/warn-registry.d.ts +18 -0
- package/dist/cjs/query/warn-registry.js +18 -0
- package/dist/cjs/query/where-compile.d.ts +138 -14
- package/dist/cjs/query/where-compile.js +513 -29
- package/dist/cjs/query/where.d.ts +159 -59
- package/dist/cjs/query/where.js +716 -246
- package/dist/cjs/query/writes.d.ts +112 -50
- package/dist/cjs/query/writes.js +1498 -200
- package/dist/cjs/query-events.d.ts +4 -2
- package/dist/cjs/query-events.js +3 -1
- package/dist/cjs/realtime.d.ts +7 -3
- package/dist/cjs/realtime.js +13 -2
- package/dist/cjs/schema-builder.d.ts +55 -4
- package/dist/cjs/schema-builder.js +382 -18
- package/dist/cjs/schema-metadata.d.ts +21 -1
- package/dist/cjs/schema-metadata.js +29 -7
- package/dist/cjs/schema-sql.d.ts +75 -5
- package/dist/cjs/schema-sql.js +497 -45
- package/dist/cjs/schema.d.ts +26 -5
- package/dist/cjs/schema.js +110 -13
- package/dist/cjs/sql-placeholders.d.ts +56 -0
- package/dist/cjs/sql-placeholders.js +204 -0
- package/dist/cjs/sqlite.d.ts +75 -20
- package/dist/cjs/sqlite.js +593 -95
- package/dist/cjs/supplied-pool-types.d.ts +48 -0
- package/dist/cjs/supplied-pool-types.js +256 -0
- package/dist/cjs/transaction-control.d.ts +51 -0
- package/dist/cjs/transaction-control.js +316 -0
- package/dist/cjs/transaction-state.d.ts +342 -0
- package/dist/cjs/transaction-state.js +707 -0
- package/dist/cjs/typed-sql.d.ts +37 -3
- package/dist/cjs/typed-sql.js +102 -8
- package/dist/cli/code-first-tags.d.ts +62 -0
- package/dist/cli/code-first-tags.js +120 -0
- package/dist/cli/compile-query.js +9 -0
- package/dist/cli/config.d.ts +35 -6
- package/dist/cli/config.js +16 -1
- package/dist/cli/destructive.d.ts +7 -4
- package/dist/cli/destructive.js +224 -20
- package/dist/cli/error-catalog.js +36 -0
- package/dist/cli/error-text.d.ts +15 -0
- package/dist/cli/error-text.js +37 -0
- package/dist/cli/index.d.ts +62 -4
- package/dist/cli/index.js +569 -112
- package/dist/cli/local-server-guard.d.ts +53 -0
- package/dist/cli/local-server-guard.js +113 -0
- package/dist/cli/local-server-output.d.ts +41 -0
- package/dist/cli/local-server-output.js +98 -0
- package/dist/cli/mcp.d.ts +8 -0
- package/dist/cli/mcp.js +121 -34
- package/dist/cli/migrate.d.ts +113 -5
- package/dist/cli/migrate.js +261 -21
- package/dist/cli/observe.d.ts +6 -1
- package/dist/cli/observe.js +84 -54
- package/dist/cli/pii-predicate-guard.d.ts +43 -23
- package/dist/cli/pii-predicate-guard.js +100 -35
- package/dist/cli/prisma-resolve.js +15 -11
- package/dist/cli/prisma-schema.js +12 -4
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +14 -2
- package/dist/cli/studio.js +246 -71
- package/dist/cli/ui.d.ts +1 -1
- package/dist/client.d.ts +558 -85
- package/dist/client.js +1275 -400
- package/dist/connection-guard.d.ts +13 -0
- package/dist/connection-guard.js +27 -0
- package/dist/connection-url.d.ts +51 -16
- package/dist/connection-url.js +63 -34
- package/dist/diagnostics.d.ts +81 -0
- package/dist/diagnostics.js +144 -0
- package/dist/dialect.d.ts +449 -20
- package/dist/dialect.js +115 -43
- package/dist/engine-config.d.ts +7 -6
- package/dist/errors.d.ts +126 -6
- package/dist/errors.js +252 -12
- package/dist/generate.d.ts +57 -7
- package/dist/generate.js +635 -281
- package/dist/global-filters-config.d.ts +59 -0
- package/dist/global-filters-config.js +361 -0
- package/dist/index.d.ts +9 -4
- package/dist/index.js +4 -2
- package/dist/introspect.d.ts +31 -6
- package/dist/introspect.js +106 -50
- package/dist/log-codes.d.ts +12 -0
- package/dist/log-codes.js +74 -0
- package/dist/logger.d.ts +87 -0
- package/dist/logger.js +93 -0
- package/dist/mssql.d.ts +8 -5
- package/dist/mssql.js +354 -67
- package/dist/mysql.d.ts +3 -5
- package/dist/mysql.js +423 -73
- package/dist/nested-write.d.ts +3 -0
- package/dist/nested-write.js +177 -112
- package/dist/pipeline-submittable.d.ts +12 -0
- package/dist/pipeline-submittable.js +117 -44
- package/dist/pipeline.js +21 -26
- package/dist/powdb-introspect.d.ts +5 -2
- package/dist/powdb-introspect.js +21 -16
- package/dist/powdb-shared.d.ts +36 -5
- package/dist/powdb-shared.js +65 -8
- package/dist/powdb.d.ts +4 -0
- package/dist/powdb.js +183 -51
- package/dist/powql.d.ts +394 -51
- package/dist/powql.js +1836 -291
- package/dist/prisma-compat.d.ts +23 -15
- package/dist/prisma-compat.js +508 -373
- package/dist/query/aggregates.d.ts +17 -1
- package/dist/query/aggregates.js +263 -32
- package/dist/query/batched-loader.d.ts +25 -6
- package/dist/query/batched-loader.js +101 -21
- package/dist/query/builder.d.ts +325 -184
- package/dist/query/builder.js +1458 -495
- package/dist/query/column-ref.d.ts +61 -0
- package/dist/query/column-ref.js +66 -0
- package/dist/query/compound-unique.d.ts +21 -0
- package/dist/query/compound-unique.js +97 -19
- package/dist/query/deferred.d.ts +53 -16
- package/dist/query/expr.d.ts +217 -0
- package/dist/query/expr.js +848 -0
- package/dist/query/filters.d.ts +155 -53
- package/dist/query/filters.js +597 -72
- package/dist/query/index.d.ts +2 -0
- package/dist/query/index.js +4 -0
- package/dist/query/json-value.d.ts +43 -0
- package/dist/query/json-value.js +54 -0
- package/dist/query/normalize-args.d.ts +87 -0
- package/dist/query/normalize-args.js +517 -0
- package/dist/query/option-surface.d.ts +16 -4
- package/dist/query/option-surface.js +56 -13
- package/dist/query/relation-names.d.ts +14 -0
- package/dist/query/relation-names.js +77 -19
- package/dist/query/relations.d.ts +30 -3
- package/dist/query/relations.js +220 -67
- package/dist/query/row-lock.d.ts +49 -0
- package/dist/query/row-lock.js +131 -0
- package/dist/query/sql-comment.d.ts +161 -0
- package/dist/query/sql-comment.js +374 -0
- package/dist/query/stream-idle.d.ts +132 -0
- package/dist/query/stream-idle.js +203 -0
- package/dist/query/table-client.d.ts +101 -0
- package/dist/query/table-client.js +70 -0
- package/dist/query/types.d.ts +677 -298
- package/dist/query/utils.d.ts +105 -6
- package/dist/query/utils.js +414 -18
- package/dist/query/warn-registry.d.ts +18 -0
- package/dist/query/warn-registry.js +18 -0
- package/dist/query/where-compile.d.ts +138 -14
- package/dist/query/where-compile.js +508 -31
- package/dist/query/where.d.ts +159 -59
- package/dist/query/where.js +711 -248
- package/dist/query/writes.d.ts +112 -50
- package/dist/query/writes.js +1496 -202
- package/dist/query-events.d.ts +4 -2
- package/dist/query-events.js +3 -1
- package/dist/realtime.d.ts +7 -3
- package/dist/realtime.js +13 -2
- package/dist/schema-builder.d.ts +55 -4
- package/dist/schema-builder.js +381 -18
- package/dist/schema-metadata.d.ts +21 -1
- package/dist/schema-metadata.js +28 -7
- package/dist/schema-sql.d.ts +75 -5
- package/dist/schema-sql.js +494 -44
- package/dist/schema.d.ts +26 -5
- package/dist/schema.js +109 -13
- package/dist/sql-placeholders.d.ts +56 -0
- package/dist/sql-placeholders.js +200 -0
- package/dist/sqlite.d.ts +75 -20
- package/dist/sqlite.js +597 -99
- package/dist/supplied-pool-types.d.ts +48 -0
- package/dist/supplied-pool-types.js +247 -0
- package/dist/transaction-control.d.ts +51 -0
- package/dist/transaction-control.js +311 -0
- package/dist/transaction-state.d.ts +342 -0
- package/dist/transaction-state.js +696 -0
- package/dist/typed-sql.d.ts +37 -3
- package/dist/typed-sql.js +100 -8
- package/package.json +7 -4
- package/skills/turbine-orm/SKILL.md +56 -6
package/dist/query/where.js
CHANGED
|
@@ -10,11 +10,29 @@
|
|
|
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 {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
13
|
+
import { resolveDiagnostics } from '../diagnostics.js';
|
|
14
|
+
import { getErrorMessageMode, InvariantError, UnsupportedFeatureError, ValidationError } from '../errors.js';
|
|
15
|
+
import { assertSkipListNames, skipListNamesTable } from '../global-filters-config.js';
|
|
16
|
+
import { consoleLogger } from '../logger.js';
|
|
17
|
+
import { camelToSnake, localDateTimeKind, normalizeKeyColumns } from '../schema.js';
|
|
18
|
+
import { assertArrayFilter, assertEqualityOperand, assertJsonFilterKeys, assertOperatorOperands, findArrayUniqueKey, findJsonUniqueKey, findUndefinedPath, holdsUndefinedValue, isArrayFilter, isColumnRef, isJsonFilter, isPlainRecord, isWhereOperator, JSON_RANGE_OPERATORS, JSON_STRING_OPERATORS, operandColumnKind, VECTOR_DISTANCE_COMPARATORS, VECTOR_METRIC_OPERATORS, validateTextSearchConfig, } from './filters.js';
|
|
19
|
+
import { coerceTemporalValue, escapeLike, isInternalCombinator, markInternalCombinator, ownLookup, resolveColumnName, } from './utils.js';
|
|
20
|
+
import { assertWhereDepth, classifyScalarForSql, fingerprintScalarToken, foldWhere, normalizeToOneRelationFilter, walkWhere, } from './where-compile.js';
|
|
21
|
+
// The JSON filter key check lives with the other operand checks in filters.ts;
|
|
22
|
+
// re-exported so the engines keep one import site for it.
|
|
23
|
+
export { assertJsonFilterKeys } from './filters.js';
|
|
24
|
+
/**
|
|
25
|
+
* Where per-query advice goes for this context: the client's logger when its
|
|
26
|
+
* `diagnostics.advice` is on, else `undefined`. A site calls this first and
|
|
27
|
+
* builds its message only on a logger.
|
|
28
|
+
*
|
|
29
|
+
* A context without the client's diagnostics (one built by hand) resolves
|
|
30
|
+
* them here; every context a QueryInterface builds carries them, so on a query
|
|
31
|
+
* path this never reads the environment.
|
|
32
|
+
*/
|
|
33
|
+
export function adviceLogger(qi) {
|
|
34
|
+
return (qi.diagnostics ?? resolveDiagnostics()).advice ? (qi.logger ?? consoleLogger) : undefined;
|
|
35
|
+
}
|
|
18
36
|
/**
|
|
19
37
|
* LIKE-escape one bound operand through the ACTIVE dialect, falling back to the
|
|
20
38
|
* shared {@link escapeLike} (`\`, `%`, `_`) when the dialect declares no
|
|
@@ -50,6 +68,21 @@ function likeOperand(qi, value) {
|
|
|
50
68
|
function valueForMessage(value) {
|
|
51
69
|
return getErrorMessageMode() === 'verbose' ? JSON.stringify(value) : '<value>';
|
|
52
70
|
}
|
|
71
|
+
/** The operand-validation context for an operator object on `refCtx`'s column. */
|
|
72
|
+
function operandContext(qi, column, refCtx) {
|
|
73
|
+
if (!refCtx)
|
|
74
|
+
return { column, table: qi.table, kind: 'scalar' };
|
|
75
|
+
return { column: refCtx.rawColumn, table: refCtx.table, kind: operandColumnKind(refCtx.pgType) };
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The inner operator object of `not: { ... }`, with this object's `mode`
|
|
79
|
+
* inherited when the inner one sets none. The fingerprint reads the same
|
|
80
|
+
* effective mode (`fingerprintOperatorShape`).
|
|
81
|
+
*/
|
|
82
|
+
function notOperand(op) {
|
|
83
|
+
const inner = op.not;
|
|
84
|
+
return inner.mode === undefined && op.mode !== undefined ? { ...inner, mode: op.mode } : inner;
|
|
85
|
+
}
|
|
53
86
|
/**
|
|
54
87
|
* Produce a value-invariant fingerprint of a where clause.
|
|
55
88
|
* Same keys + same operator shapes + same combinator structure => same string.
|
|
@@ -86,25 +119,20 @@ export function fingerprintWhere(qi, where, depth = 0) {
|
|
|
86
119
|
}
|
|
87
120
|
/**
|
|
88
121
|
* Fingerprint the present branches of a normalized relation filter, in the
|
|
89
|
-
* fixed order some→every→none→is→isNot. A
|
|
90
|
-
* `<branch>(null)`; a present branch recurses through
|
|
122
|
+
* fixed order some→every→none→is→isNot. A present branch recurses through
|
|
91
123
|
* {@link fingerprintRelFilter} so the FULL inner shape is captured (two
|
|
92
|
-
* different sub-wheres must never collide on one cached SQL text).
|
|
124
|
+
* different sub-wheres must never collide on one cached SQL text). `is: null`
|
|
125
|
+
* and `isNot: null` tokenize as `<branch>(null)`; the where walk refuses any
|
|
126
|
+
* other non-object value before this runs.
|
|
93
127
|
*/
|
|
94
128
|
export function fingerprintRelationParts(qi, relDef, filterObj, depth = 0) {
|
|
95
129
|
const relParts = [];
|
|
96
130
|
if (filterObj.some !== undefined)
|
|
97
|
-
relParts.push(filterObj.some
|
|
98
|
-
? 'some(null)'
|
|
99
|
-
: `some(${fingerprintRelFilter(qi, relDef.to, filterObj.some, depth + 1)})`);
|
|
131
|
+
relParts.push(`some(${fingerprintRelFilter(qi, relDef.to, filterObj.some, depth + 1)})`);
|
|
100
132
|
if (filterObj.every !== undefined)
|
|
101
|
-
relParts.push(filterObj.every
|
|
102
|
-
? 'every(null)'
|
|
103
|
-
: `every(${fingerprintRelFilter(qi, relDef.to, filterObj.every, depth + 1)})`);
|
|
133
|
+
relParts.push(`every(${fingerprintRelFilter(qi, relDef.to, filterObj.every, depth + 1)})`);
|
|
104
134
|
if (filterObj.none !== undefined)
|
|
105
|
-
relParts.push(filterObj.none
|
|
106
|
-
? 'none(null)'
|
|
107
|
-
: `none(${fingerprintRelFilter(qi, relDef.to, filterObj.none, depth + 1)})`);
|
|
135
|
+
relParts.push(`none(${fingerprintRelFilter(qi, relDef.to, filterObj.none, depth + 1)})`);
|
|
108
136
|
if (filterObj.is !== undefined)
|
|
109
137
|
relParts.push(filterObj.is === null
|
|
110
138
|
? 'is(null)'
|
|
@@ -129,32 +157,43 @@ export function fingerprintRelFilter(qi, targetTable, subWhere, depth = 0) {
|
|
|
129
157
|
/**
|
|
130
158
|
* Walk a where clause and push ONLY values into `params`, in the EXACT same
|
|
131
159
|
* order that `buildWhereClause` pushes them. Used on cache hit to fill params
|
|
132
|
-
* without rebuilding SQL.
|
|
160
|
+
* without rebuilding SQL. Returns whether the clause compiles to a condition,
|
|
161
|
+
* i.e. whether `buildWhereClause` returns non-null for it.
|
|
133
162
|
*
|
|
134
163
|
* @internal Exposed as package-private for testing.
|
|
135
164
|
*/
|
|
136
165
|
export function collectWhereParams(qi, where, params, depth = 0) {
|
|
137
166
|
assertWhereDepth(depth);
|
|
167
|
+
let emitted = false;
|
|
138
168
|
// ONE canonical walk (shared with fingerprintWhere + buildWhereClause), so
|
|
139
169
|
// the key order + combinator structure cannot drift out of lockstep.
|
|
140
170
|
for (const event of walkWhere(qi.whereHost, where)) {
|
|
141
171
|
switch (event.kind) {
|
|
142
172
|
case 'or':
|
|
173
|
+
collectOrParams(event.conditions, params, (cond) => collectWhereParams(qi, cond, params, depth + 1));
|
|
174
|
+
emitted = true;
|
|
175
|
+
break;
|
|
143
176
|
case 'and':
|
|
144
|
-
for (const cond of event.conditions)
|
|
145
|
-
collectWhereParams(qi, cond, params, depth + 1)
|
|
177
|
+
for (const cond of event.conditions) {
|
|
178
|
+
if (collectWhereParams(qi, cond, params, depth + 1))
|
|
179
|
+
emitted = true;
|
|
180
|
+
}
|
|
146
181
|
break;
|
|
147
182
|
case 'not':
|
|
148
|
-
collectWhereParams(qi, event.condition, params, depth + 1)
|
|
183
|
+
if (collectWhereParams(qi, event.condition, params, depth + 1))
|
|
184
|
+
emitted = true;
|
|
149
185
|
break;
|
|
150
186
|
case 'relation':
|
|
151
|
-
collectRelationFilterParams(qi, event.relDef, event.filterObj, params, depth + 1)
|
|
187
|
+
if (collectRelationFilterParams(qi, event.relDef, event.filterObj, params, depth + 1))
|
|
188
|
+
emitted = true;
|
|
152
189
|
break;
|
|
153
190
|
case 'scalar':
|
|
154
191
|
collectScalarParams(qi, event.key, event.value, params);
|
|
192
|
+
emitted = true;
|
|
155
193
|
break;
|
|
156
194
|
}
|
|
157
195
|
}
|
|
196
|
+
return emitted;
|
|
158
197
|
}
|
|
159
198
|
/**
|
|
160
199
|
* Push a scalar WHERE value's params, mirroring {@link buildScalarClause}'s
|
|
@@ -182,7 +221,7 @@ export function collectScalarParams(qi, key, value, params) {
|
|
|
182
221
|
collectJsonFilterParams(qi, value, params, qi.q(rawColumn));
|
|
183
222
|
return;
|
|
184
223
|
case 'array':
|
|
185
|
-
collectArrayFilterParams(qi, value, params);
|
|
224
|
+
collectArrayFilterParams(qi, value, params, qi.q(rawColumn));
|
|
186
225
|
return;
|
|
187
226
|
case 'textsearch':
|
|
188
227
|
// Same gate the build path applies, so the collect path never diverges
|
|
@@ -196,15 +235,22 @@ export function collectScalarParams(qi, key, value, params) {
|
|
|
196
235
|
table: qi.table,
|
|
197
236
|
prefix: '',
|
|
198
237
|
rawColumn,
|
|
238
|
+
pgType: getColumnPgType(qi, rawColumn),
|
|
199
239
|
});
|
|
200
240
|
return;
|
|
201
|
-
default:
|
|
241
|
+
default: {
|
|
202
242
|
// 'equality' | 'jsonThrow' | 'arrayThrow': same strict validation as
|
|
203
243
|
// the build path, so a cache hit can never silently bind a
|
|
204
244
|
// misspelled-operator object.
|
|
205
|
-
|
|
245
|
+
const pgType = getColumnPgType(qi, rawColumn);
|
|
246
|
+
assertBindableEqualityValue(qi, rawColumn, value, pgType, qi.table);
|
|
247
|
+
if (isBareJsonDocument(qi, pgType, value)) {
|
|
248
|
+
jsonDocumentEquals(qi, '', rawColumn, value, collectOnly(params));
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
206
251
|
params.push(coerceWhereOperand(qi, qi.tableMeta, rawColumn, value));
|
|
207
252
|
return;
|
|
253
|
+
}
|
|
208
254
|
}
|
|
209
255
|
}
|
|
210
256
|
/**
|
|
@@ -216,52 +262,69 @@ export function collectScalarParams(qi, key, value, params) {
|
|
|
216
262
|
* the gf calls are no-ops, so this stays byte-identical to the pre-0.28 path.
|
|
217
263
|
* Shared by every collect site that mirrors buildRelationFilter
|
|
218
264
|
* (collectWhereParams, collectRelFilterParams, collectAliasWhereParams).
|
|
265
|
+
* Returns whether {@link buildRelationFilter} emits a clause for it.
|
|
219
266
|
*/
|
|
220
267
|
export function collectRelationFilterParams(qi, relDef, filterObj, params, depth = 0) {
|
|
221
268
|
const target = relDef.to;
|
|
222
|
-
if (
|
|
269
|
+
if (!qi.schema.tables[target])
|
|
270
|
+
return false;
|
|
271
|
+
let emitted = false;
|
|
272
|
+
if (filterObj.some !== undefined) {
|
|
223
273
|
collectRelFilterParams(qi, target, filterObj.some, params, depth + 1);
|
|
224
274
|
collectTargetGlobalFilterExists(qi, target, params);
|
|
275
|
+
emitted = true;
|
|
225
276
|
}
|
|
226
|
-
if (filterObj.none !== undefined
|
|
277
|
+
if (filterObj.none !== undefined) {
|
|
227
278
|
collectRelFilterParams(qi, target, filterObj.none, params, depth + 1);
|
|
228
279
|
collectTargetGlobalFilterExists(qi, target, params);
|
|
280
|
+
emitted = true;
|
|
229
281
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
}
|
|
282
|
+
// gf is only emitted (build) when the `every` sub-where compiles to a
|
|
283
|
+
// filter, otherwise `every` is trivially true and no subquery is built. A
|
|
284
|
+
// sub-where that compiles to nothing pushes nothing.
|
|
285
|
+
if (filterObj.every !== undefined &&
|
|
286
|
+
collectRelFilterParams(qi, target, filterObj.every, params, depth + 1)) {
|
|
287
|
+
collectTargetGlobalFilterExists(qi, target, params);
|
|
288
|
+
emitted = true;
|
|
238
289
|
}
|
|
239
290
|
if (filterObj.is !== undefined) {
|
|
240
291
|
if (filterObj.is !== null)
|
|
241
292
|
collectRelFilterParams(qi, target, filterObj.is, params, depth + 1);
|
|
242
293
|
collectTargetGlobalFilterExists(qi, target, params);
|
|
294
|
+
emitted = true;
|
|
243
295
|
}
|
|
244
296
|
if (filterObj.isNot !== undefined) {
|
|
245
297
|
if (filterObj.isNot !== null)
|
|
246
298
|
collectRelFilterParams(qi, target, filterObj.isNot, params, depth + 1);
|
|
247
299
|
collectTargetGlobalFilterExists(qi, target, params);
|
|
300
|
+
emitted = true;
|
|
248
301
|
}
|
|
302
|
+
return emitted;
|
|
249
303
|
}
|
|
304
|
+
/** Collect a relation sub-where's params; returns whether it compiles to a condition. */
|
|
250
305
|
export function collectRelFilterParams(qi, targetTable, subWhere, params, depth = 0) {
|
|
251
306
|
const meta = qi.schema.tables[targetTable];
|
|
252
307
|
if (!meta)
|
|
253
|
-
return;
|
|
308
|
+
return false;
|
|
254
309
|
// The FROM-item reference only shapes SQL text, never params, so the bare
|
|
255
310
|
// name serves the collect mirror whether or not the build path aliased it.
|
|
256
|
-
collectScopedWhereParams(qi, relationWhereScope(qi, targetTable, meta, qi.q(targetTable)), subWhere, params, depth);
|
|
311
|
+
return collectScopedWhereParams(qi, relationWhereScope(qi, targetTable, meta, qi.q(targetTable)), subWhere, params, depth);
|
|
257
312
|
}
|
|
258
313
|
/**
|
|
259
314
|
* Collect params from operator clauses. Mirrors buildOperatorClauses:
|
|
260
315
|
* {@link ColumnRef} values compile into the SQL text, so they push NOTHING -
|
|
261
316
|
* but they re-run the same validation (unknown ref / insensitive mode) so a
|
|
262
|
-
* warmed cache can never skip a check the build path enforces.
|
|
317
|
+
* warmed cache can never skip a check the build path enforces. The operand
|
|
318
|
+
* validation ({@link assertOperatorOperands}) runs here too, for the same
|
|
319
|
+
* reason.
|
|
263
320
|
*/
|
|
264
321
|
export function collectOperatorParams(qi, column, op, params, refCtx) {
|
|
322
|
+
const operand = operandContext(qi, column, refCtx);
|
|
323
|
+
assertOperatorOperands(op, operand);
|
|
324
|
+
collectOperatorParamsChecked(qi, column, op, params, refCtx, operand);
|
|
325
|
+
}
|
|
326
|
+
/** {@link collectOperatorParams} after validation; recursive for a `not` operator object. */
|
|
327
|
+
function collectOperatorParamsChecked(qi, column, op, params, refCtx, operand) {
|
|
265
328
|
const skipRef = (v) => {
|
|
266
329
|
if (!isColumnRef(v))
|
|
267
330
|
return false;
|
|
@@ -273,7 +336,6 @@ export function collectOperatorParams(qi, column, op, params, refCtx) {
|
|
|
273
336
|
const cv = (v) => (refCtx ? coerceWhereOperand(qi, refCtx.meta, refCtx.rawColumn, v) : v);
|
|
274
337
|
const insensitive = op.mode === 'insensitive';
|
|
275
338
|
if (op.equals !== undefined && op.equals !== null && !skipRef(op.equals)) {
|
|
276
|
-
assertBindableEqualsOperand(op.equals, `"${column}"`);
|
|
277
339
|
if (insensitive)
|
|
278
340
|
assertInsensitiveOperand(op.equals, 'equals', column);
|
|
279
341
|
params.push(cv(op.equals));
|
|
@@ -287,9 +349,18 @@ export function collectOperatorParams(qi, column, op, params, refCtx) {
|
|
|
287
349
|
if (op.lte !== undefined && !skipRef(op.lte))
|
|
288
350
|
params.push(cv(op.lte));
|
|
289
351
|
if (op.not !== undefined && op.not !== null && !skipRef(op.not)) {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
352
|
+
const negatedDocument = jsonNegatedDocument(qi, op.not, operand);
|
|
353
|
+
if (negatedDocument !== NO_JSON_NEGATION) {
|
|
354
|
+
jsonDocumentEquals(qi, '', column, negatedDocument, collectOnly(params));
|
|
355
|
+
}
|
|
356
|
+
else if (isNegatedOperatorObject(op.not, operand)) {
|
|
357
|
+
collectOperatorParamsChecked(qi, column, notOperand(op), params, refCtx, operand);
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
if (insensitive)
|
|
361
|
+
assertInsensitiveOperand(op.not, 'not', column);
|
|
362
|
+
params.push(cv(op.not));
|
|
363
|
+
}
|
|
293
364
|
}
|
|
294
365
|
if (op.in !== undefined) {
|
|
295
366
|
if (insensitive) {
|
|
@@ -312,6 +383,14 @@ export function collectOperatorParams(qi, column, op, params, refCtx) {
|
|
|
312
383
|
if (op.endsWith !== undefined)
|
|
313
384
|
params.push(`%${likeOperand(qi, op.endsWith)}`);
|
|
314
385
|
}
|
|
386
|
+
/**
|
|
387
|
+
* Is this `not` operand an operator object to negate as a whole, rather than a
|
|
388
|
+
* value? On a JSON column an object is a JSON VALUE (`not: { a: 1 }` compares
|
|
389
|
+
* documents), so only non-JSON columns read it as an operator object.
|
|
390
|
+
*/
|
|
391
|
+
function isNegatedOperatorObject(value, operand) {
|
|
392
|
+
return operand.kind !== 'json' && isWhereOperator(value);
|
|
393
|
+
}
|
|
315
394
|
/**
|
|
316
395
|
* Collect params from JSON filter. Mirrors buildJsonFilterClauses exactly:
|
|
317
396
|
* the `path` is bound at most once (its placeholder is shared by every
|
|
@@ -330,14 +409,18 @@ export function collectJsonFilterParams(qi, filter, params, column) {
|
|
|
330
409
|
};
|
|
331
410
|
if (filter.path !== undefined && filter.equals !== undefined) {
|
|
332
411
|
pushPathOnce();
|
|
333
|
-
|
|
412
|
+
const hook = jsonPathEqualsHook(qi);
|
|
413
|
+
if (hook) {
|
|
414
|
+
hook.call(qi.dialect, jsonPathEqualsInput(qi, filter, column, '', '', collectOnly(params)));
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
params.push(jsonPathEqualsOperand(qi, filter.equals, column));
|
|
418
|
+
}
|
|
334
419
|
}
|
|
335
420
|
else if (filter.equals !== undefined) {
|
|
336
|
-
// Mirrors the build path
|
|
337
|
-
//
|
|
338
|
-
|
|
339
|
-
requireJsonContains(qi, 'equals');
|
|
340
|
-
params.push(JSON.stringify(filter.equals));
|
|
421
|
+
// Mirrors the build path, refusals included: a warmed template must never
|
|
422
|
+
// serve a shape the cold build refuses.
|
|
423
|
+
jsonDocumentEquals(qi, '', column, filter.equals, collectOnly(params));
|
|
341
424
|
}
|
|
342
425
|
if (filter.contains !== undefined) {
|
|
343
426
|
requireJsonContains(qi, 'contains');
|
|
@@ -356,8 +439,9 @@ export function collectJsonFilterParams(qi, filter, params, column) {
|
|
|
356
439
|
}
|
|
357
440
|
}
|
|
358
441
|
/** Collect params from array filter. Mirrors buildArrayFilterClauses. */
|
|
359
|
-
export function collectArrayFilterParams(qi, filter, params) {
|
|
442
|
+
export function collectArrayFilterParams(qi, filter, params, column) {
|
|
360
443
|
requireArrayColumns(qi);
|
|
444
|
+
assertArrayFilter(filter, column);
|
|
361
445
|
if (filter.has !== undefined)
|
|
362
446
|
params.push(filter.has);
|
|
363
447
|
if (filter.hasEvery !== undefined)
|
|
@@ -391,34 +475,57 @@ export function buildWhere(qi, where) {
|
|
|
391
475
|
* THE global-filter resolution rule, with no dependency on a `BuilderCtx`.
|
|
392
476
|
*
|
|
393
477
|
* Split out from {@link resolveGlobalFilter} so `PowqlInterface` can consume
|
|
394
|
-
* the SAME rule rather than a transcription of it
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
478
|
+
* the SAME rule rather than a transcription of it: a rule written twice is how
|
|
479
|
+
* two engines come to disagree. A function filter is evaluated PER BUILD so
|
|
480
|
+
* per-request tenancy works, and `skip` is honoured in both its whole-query and
|
|
481
|
+
* per-table forms.
|
|
482
|
+
*
|
|
483
|
+
* FAIL CLOSED. A filter that resolves to `undefined`, or to an object holding
|
|
484
|
+
* an `undefined` anywhere inside it, throws {@link ValidationError} (E003). A
|
|
485
|
+
* missing tenant context is exactly what produces `{ tenantId: undefined }`,
|
|
486
|
+
* and treating that as "no filter" read and wrote every tenant's rows. The
|
|
487
|
+
* explicit values `null` and `{}` still mean "no filter for this call": neither
|
|
488
|
+
* is what a missing value produces.
|
|
489
|
+
*
|
|
490
|
+
* A `skipGlobalFilters` list names tables by table name or client accessor,
|
|
491
|
+
* the two spellings `globalFilters` keys take. With `tables` (the schema's
|
|
492
|
+
* table map), an entry that names no table is refused (E003): it would skip
|
|
493
|
+
* nothing, so the caller who meant to lift a filter would silently keep it.
|
|
494
|
+
*/
|
|
495
|
+
export function resolveGlobalFilterFrom(filters, table, skip, tables) {
|
|
406
496
|
if (skip === true)
|
|
407
497
|
return null;
|
|
408
|
-
if (Array.isArray(skip)
|
|
498
|
+
if (Array.isArray(skip)) {
|
|
499
|
+
if (tables !== undefined)
|
|
500
|
+
assertSkipListNames(skip, tables);
|
|
501
|
+
if (skipListNamesTable(skip, table, tables))
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
if (!filters)
|
|
409
505
|
return null;
|
|
410
|
-
|
|
411
|
-
if (raw === undefined)
|
|
506
|
+
if (!Object.hasOwn(filters, table))
|
|
412
507
|
return null;
|
|
508
|
+
const raw = filters[table];
|
|
413
509
|
const resolved = typeof raw === 'function' ? raw() : raw;
|
|
414
|
-
if (resolved === null
|
|
415
|
-
return null;
|
|
416
|
-
const obj = resolved;
|
|
417
|
-
// An all-undefined filter (e.g. `{ tenantId: undefined }`) contributes
|
|
418
|
-
// nothing, treat it as absent so it never emits a dangling clause.
|
|
419
|
-
if (Object.keys(obj).every((k) => obj[k] === undefined))
|
|
510
|
+
if (resolved === null)
|
|
420
511
|
return null;
|
|
421
|
-
|
|
512
|
+
if (resolved === undefined)
|
|
513
|
+
throw unresolvedGlobalFilter(table, 'resolved to undefined');
|
|
514
|
+
if (typeof resolved !== 'object' || Array.isArray(resolved)) {
|
|
515
|
+
throw new ValidationError(`Global filter for "${table}" must be a where object (or a function returning one), received ` +
|
|
516
|
+
`${Array.isArray(resolved) ? 'an array' : typeof resolved}.`);
|
|
517
|
+
}
|
|
518
|
+
// The boolean scan allocates nothing; the path string is built only on the throw path.
|
|
519
|
+
const hole = holdsUndefinedValue(resolved, 0) ? findUndefinedPath(resolved, '', 0) : null;
|
|
520
|
+
if (hole !== null)
|
|
521
|
+
throw unresolvedGlobalFilter(table, `has an undefined value at "${hole}"`);
|
|
522
|
+
return Object.keys(resolved).length === 0 ? null : resolved;
|
|
523
|
+
}
|
|
524
|
+
function unresolvedGlobalFilter(table, what) {
|
|
525
|
+
return new ValidationError(`Global filter for "${table}" ${what}. A global filter is never dropped because its input is missing: ` +
|
|
526
|
+
"an undefined tenant id would otherwise read and write every tenant's rows. Supply the value it reads " +
|
|
527
|
+
'(for example the current tenant) before this query runs, or pass `skipGlobalFilters: UNSAFE` ' +
|
|
528
|
+
"(import { UNSAFE } from 'turbine-orm') on a query that must see every row.");
|
|
422
529
|
}
|
|
423
530
|
/**
|
|
424
531
|
* Resolve the configured global filter for `table`, evaluating a function
|
|
@@ -426,7 +533,7 @@ export function resolveGlobalFilterFrom(filters, table, skip) {
|
|
|
426
533
|
* no filter applies, the query opted out, or the filter is empty.
|
|
427
534
|
*/
|
|
428
535
|
export function resolveGlobalFilter(qi, table, skip = qi.currentSkip) {
|
|
429
|
-
return resolveGlobalFilterFrom(qi.globalFilters, table, skip);
|
|
536
|
+
return resolveGlobalFilterFrom(qi.globalFilters, table, skip, qi.schema.tables);
|
|
430
537
|
}
|
|
431
538
|
/**
|
|
432
539
|
* AND-merge this table's resolved global filter into a user `where`. Either
|
|
@@ -444,6 +551,45 @@ export function mergeGlobalFilter(qi, userWhere) {
|
|
|
444
551
|
// {@link markInternalCombinator}.
|
|
445
552
|
return markInternalCombinator({ AND: [userWhere, gf] });
|
|
446
553
|
}
|
|
554
|
+
/**
|
|
555
|
+
* The tables whose global filter is being compiled right now, per builder
|
|
556
|
+
* context. A table's filter applies to every relation target a query reaches,
|
|
557
|
+
* the targets inside another table's filter included, so two filters that
|
|
558
|
+
* reach each other through relations (or one that reaches its own table) would
|
|
559
|
+
* apply each other without end. While a table's filter is being compiled, a
|
|
560
|
+
* relation that reaches that table again reads its rows unfiltered, which ends
|
|
561
|
+
* the chain. A static filter that forms such a cycle is refused when the client
|
|
562
|
+
* is built (`normalizeGlobalFilters`); this covers function filters, whose
|
|
563
|
+
* relations are known only per query. Compilation is synchronous, so the stack
|
|
564
|
+
* is pushed and popped around each application, on the build and the collect
|
|
565
|
+
* pass alike.
|
|
566
|
+
*/
|
|
567
|
+
const filtersBeingApplied = new WeakMap();
|
|
568
|
+
/**
|
|
569
|
+
* Apply `table`'s global filter as a relation target: `apply(gf)` with the
|
|
570
|
+
* table on the stack, or `none` when no filter applies or the table's filter
|
|
571
|
+
* is already being compiled further out (see {@link filtersBeingApplied}).
|
|
572
|
+
* Every target application goes through here, build and collect alike.
|
|
573
|
+
*/
|
|
574
|
+
export function withTargetGlobalFilter(qi, table, none, apply) {
|
|
575
|
+
let stack = filtersBeingApplied.get(qi);
|
|
576
|
+
if (stack?.includes(table))
|
|
577
|
+
return none;
|
|
578
|
+
const gf = resolveGlobalFilter(qi, table);
|
|
579
|
+
if (!gf)
|
|
580
|
+
return none;
|
|
581
|
+
if (!stack) {
|
|
582
|
+
stack = [];
|
|
583
|
+
filtersBeingApplied.set(qi, stack);
|
|
584
|
+
}
|
|
585
|
+
stack.push(table);
|
|
586
|
+
try {
|
|
587
|
+
return apply(gf);
|
|
588
|
+
}
|
|
589
|
+
finally {
|
|
590
|
+
stack.pop();
|
|
591
|
+
}
|
|
592
|
+
}
|
|
447
593
|
/**
|
|
448
594
|
* SQL clause for `targetTable`'s global filter rendered against `alias`
|
|
449
595
|
* (relation subqueries, `_count`, relation `orderBy`). Pushes its params to
|
|
@@ -451,23 +597,17 @@ export function mergeGlobalFilter(qi, userWhere) {
|
|
|
451
597
|
* {@link collectTargetGlobalFilterAlias}.
|
|
452
598
|
*/
|
|
453
599
|
export function targetGlobalFilterAlias(qi, targetTable, alias, params) {
|
|
454
|
-
const gf = resolveGlobalFilter(qi, targetTable);
|
|
455
|
-
if (!gf)
|
|
456
|
-
return '';
|
|
457
600
|
const meta = qi.schema.tables[targetTable];
|
|
458
601
|
if (!meta)
|
|
459
602
|
return '';
|
|
460
|
-
return buildAliasWhere(qi, targetTable, meta, alias, gf, params) ?? '';
|
|
603
|
+
return withTargetGlobalFilter(qi, targetTable, '', (gf) => buildAliasWhere(qi, targetTable, meta, alias, gf, params) ?? '');
|
|
461
604
|
}
|
|
462
605
|
/** Param-collect mirror of {@link targetGlobalFilterAlias}. */
|
|
463
606
|
export function collectTargetGlobalFilterAlias(qi, targetTable, params) {
|
|
464
|
-
const gf = resolveGlobalFilter(qi, targetTable);
|
|
465
|
-
if (!gf)
|
|
466
|
-
return;
|
|
467
607
|
const meta = qi.schema.tables[targetTable];
|
|
468
608
|
if (!meta)
|
|
469
609
|
return;
|
|
470
|
-
collectAliasWhereParams(qi, targetTable, meta, gf, params);
|
|
610
|
+
withTargetGlobalFilter(qi, targetTable, false, (gf) => collectAliasWhereParams(qi, targetTable, meta, gf, params));
|
|
471
611
|
}
|
|
472
612
|
/**
|
|
473
613
|
* SQL clause for `targetTable`'s global filter rendered against `targetRef`,
|
|
@@ -476,17 +616,11 @@ export function collectTargetGlobalFilterAlias(qi, targetTable, params) {
|
|
|
476
616
|
* params; `''` when none. Mirror: {@link collectTargetGlobalFilterExists}.
|
|
477
617
|
*/
|
|
478
618
|
export function targetGlobalFilterExists(qi, targetTable, targetRef, params) {
|
|
479
|
-
|
|
480
|
-
if (!gf)
|
|
481
|
-
return '';
|
|
482
|
-
return buildSubWhereForRelation(qi, targetTable, targetRef, gf, params) ?? '';
|
|
619
|
+
return withTargetGlobalFilter(qi, targetTable, '', (gf) => buildSubWhereForRelation(qi, targetTable, targetRef, gf, params) ?? '');
|
|
483
620
|
}
|
|
484
621
|
/** Param-collect mirror of {@link targetGlobalFilterExists}. */
|
|
485
622
|
export function collectTargetGlobalFilterExists(qi, targetTable, params) {
|
|
486
|
-
|
|
487
|
-
if (!gf)
|
|
488
|
-
return;
|
|
489
|
-
collectRelFilterParams(qi, targetTable, gf, params);
|
|
623
|
+
withTargetGlobalFilter(qi, targetTable, false, (gf) => collectRelFilterParams(qi, targetTable, gf, params));
|
|
490
624
|
}
|
|
491
625
|
/**
|
|
492
626
|
* Value-invariant SQL-cache-key segment for the active global-filter
|
|
@@ -531,16 +665,41 @@ export function globalFilterCacheSegment(qi) {
|
|
|
531
665
|
return parts.length ? `|gf=${parts.join(';')}` : '';
|
|
532
666
|
}
|
|
533
667
|
/**
|
|
534
|
-
* True when the USER-supplied `where` compiles to no
|
|
535
|
-
* `{ id: undefined }`, `{ OR: [{ a: undefined }] }`, …)
|
|
536
|
-
*
|
|
537
|
-
*
|
|
538
|
-
*
|
|
539
|
-
*
|
|
668
|
+
* True when the USER-supplied `where` narrows no row: it compiles to no
|
|
669
|
+
* predicate (`{}`, `{ id: undefined }`, `{ OR: [{ a: undefined }] }`, …), or
|
|
670
|
+
* to one that is TRUE for every row (`{ NOT: { OR: [] } }`,
|
|
671
|
+
* `{ id: { notIn: [] } }`, and on a NOT NULL column `{ email: { contains: '' } }`
|
|
672
|
+
* or `{ id: { not: null } }`, see {@link foldWhere}). Either way a mutation
|
|
673
|
+
* guarded by it would write every row. It reads the compiled emptiness, NOT
|
|
674
|
+
* the fingerprint (which is non-empty for an all-undefined `OR`/`AND`), and it
|
|
675
|
+
* ignores any configured global filter, so a global filter never lets an
|
|
676
|
+
* unguarded mass mutation through.
|
|
540
677
|
*/
|
|
541
678
|
export function userPredicateIsEmpty(qi, userWhere) {
|
|
542
679
|
const throwaway = [];
|
|
543
|
-
|
|
680
|
+
if (buildWhereClause(qi, userWhere, throwaway) === null)
|
|
681
|
+
return true;
|
|
682
|
+
return foldWhere(qi.whereHost, userWhere, (table) => tableWhereHost(qi, table), 0, guardFoldOptions(qi)) === 'true';
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* The guard's fold reads NOT NULL columns, and trusts a declared foreign key
|
|
686
|
+
* except on SQLite, which enforces one only when the connection turned
|
|
687
|
+
* `foreign_keys` on, so a row there can point at a row that does not exist.
|
|
688
|
+
*/
|
|
689
|
+
function guardFoldOptions(qi) {
|
|
690
|
+
return {
|
|
691
|
+
notNull: {
|
|
692
|
+
foreignKeys: qi.dialect.capabilities.enforcesForeignKeys,
|
|
693
|
+
hasGlobalFilter: (table) => qi.globalFilters !== undefined && Object.hasOwn(qi.globalFilters, table),
|
|
694
|
+
},
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
/** The where host of `table` in this client's schema, or `undefined` when the schema has no such table. */
|
|
698
|
+
function tableWhereHost(qi, table) {
|
|
699
|
+
const meta = ownLookup(qi.schema.tables, table);
|
|
700
|
+
if (!meta)
|
|
701
|
+
return undefined;
|
|
702
|
+
return meta.name === qi.tableMeta.name ? qi.whereHost : scopedWhereHost(qi, meta);
|
|
544
703
|
}
|
|
545
704
|
export function assertMutationHasPredicate(qi, operation, whereSql,
|
|
546
705
|
// Already RESOLVED by the caller (writes.ts) through `resolveUnsafeFlag`, so
|
|
@@ -552,9 +711,18 @@ allowFullTableScan) {
|
|
|
552
711
|
return;
|
|
553
712
|
if (allowFullTableScan === true)
|
|
554
713
|
return;
|
|
555
|
-
throw new ValidationError(
|
|
714
|
+
throw new ValidationError(emptyMutationWhereMessage(operation, qi.table));
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* The mass-mutation refusal, one text for every engine. "Empty" covers a where
|
|
718
|
+
* that is always true (see {@link userPredicateIsEmpty}), which writes every
|
|
719
|
+
* row just the same.
|
|
720
|
+
*/
|
|
721
|
+
export function emptyMutationWhereMessage(operation, table) {
|
|
722
|
+
return (`${operation} on "${table}" refused: the \`where\` clause is empty or always true ` +
|
|
723
|
+
'(for example `NOT: { OR: [] }` or `{ id: { notIn: [] } }`), so it would apply to every row. ' +
|
|
556
724
|
"Pass `allowFullTableScan: UNSAFE` to opt in (import { UNSAFE } from 'turbine-orm'), " +
|
|
557
|
-
'or check that your filter values are defined.');
|
|
725
|
+
'or check that your filter values are defined and your lists are not empty.');
|
|
558
726
|
}
|
|
559
727
|
/**
|
|
560
728
|
* Build the inner WHERE expression (without the WHERE keyword).
|
|
@@ -578,18 +746,12 @@ export function buildWhereClause(qi, where, params, depth = 0) {
|
|
|
578
746
|
case 'or': {
|
|
579
747
|
if (!internal)
|
|
580
748
|
qi.markVariableArity();
|
|
581
|
-
|
|
582
|
-
for (const orCond of event.conditions) {
|
|
583
|
-
const sub = buildWhereClause(qi, orCond, params, depth + 1);
|
|
584
|
-
if (sub)
|
|
585
|
-
orClauses.push(sub);
|
|
586
|
-
}
|
|
587
|
-
if (orClauses.length > 0)
|
|
588
|
-
andClauses.push(`(${orClauses.join(' OR ')})`);
|
|
749
|
+
andClauses.push(buildOrClause(event.conditions, params, (cond) => buildWhereClause(qi, cond, params, depth + 1)));
|
|
589
750
|
break;
|
|
590
751
|
}
|
|
591
752
|
case 'and':
|
|
592
|
-
|
|
753
|
+
// `AND: { ... }` is one branch whatever the caller sends (fixedArity).
|
|
754
|
+
if (!internal && !event.fixedArity)
|
|
593
755
|
qi.markVariableArity();
|
|
594
756
|
for (const andCond of event.conditions) {
|
|
595
757
|
const sub = buildWhereClause(qi, andCond, params, depth + 1);
|
|
@@ -619,6 +781,46 @@ export function buildWhereClause(qi, where, params, depth = 0) {
|
|
|
619
781
|
return null;
|
|
620
782
|
return andClauses.join(' AND ');
|
|
621
783
|
}
|
|
784
|
+
/**
|
|
785
|
+
* The SQL an `OR` compiles to when one of its branches compiles to nothing.
|
|
786
|
+
* Written out rather than left empty, because `NOT` of an empty clause applies
|
|
787
|
+
* no condition while `NOT` of this matches no row.
|
|
788
|
+
*/
|
|
789
|
+
const ALWAYS_TRUE_SQL = '(1 = 1)';
|
|
790
|
+
/**
|
|
791
|
+
* Compile an `OR` from its branches. A branch with no condition (`{}`,
|
|
792
|
+
* `{ AND: [] }`) is TRUE for every row, so the whole `OR` is TRUE: the clause
|
|
793
|
+
* is {@link ALWAYS_TRUE_SQL} and the params the other branches pushed are
|
|
794
|
+
* taken back. Every branch is still built, so each is validated exactly as the
|
|
795
|
+
* param collect validates it. {@link collectOrParams} is the mirror.
|
|
796
|
+
*/
|
|
797
|
+
function buildOrClause(branches, params, build) {
|
|
798
|
+
const mark = params.length;
|
|
799
|
+
const parts = [];
|
|
800
|
+
let alwaysTrue = false;
|
|
801
|
+
for (const branch of branches) {
|
|
802
|
+
const sub = build(branch);
|
|
803
|
+
if (sub === null)
|
|
804
|
+
alwaysTrue = true;
|
|
805
|
+
else
|
|
806
|
+
parts.push(sub);
|
|
807
|
+
}
|
|
808
|
+
if (!alwaysTrue)
|
|
809
|
+
return `(${parts.join(' OR ')})`;
|
|
810
|
+
params.length = mark;
|
|
811
|
+
return ALWAYS_TRUE_SQL;
|
|
812
|
+
}
|
|
813
|
+
/** Param-collect mirror of {@link buildOrClause}; `collect` reports whether the branch compiled to a condition. */
|
|
814
|
+
function collectOrParams(branches, params, collect) {
|
|
815
|
+
const mark = params.length;
|
|
816
|
+
let alwaysTrue = false;
|
|
817
|
+
for (const branch of branches) {
|
|
818
|
+
if (!collect(branch))
|
|
819
|
+
alwaysTrue = true;
|
|
820
|
+
}
|
|
821
|
+
if (alwaysTrue)
|
|
822
|
+
params.length = mark;
|
|
823
|
+
}
|
|
622
824
|
/**
|
|
623
825
|
* Emit the SQL clause(s) for one scalar WHERE key onto `andClauses`, pushing
|
|
624
826
|
* any params. The shape decision comes from the shared
|
|
@@ -661,18 +863,52 @@ export function buildScalarClause(qi, key, value, params, andClauses) {
|
|
|
661
863
|
table: qi.table,
|
|
662
864
|
prefix: '',
|
|
663
865
|
rawColumn,
|
|
866
|
+
pgType: getColumnPgType(qi, rawColumn),
|
|
664
867
|
}));
|
|
665
868
|
return;
|
|
666
|
-
default:
|
|
869
|
+
default: {
|
|
667
870
|
// 'equality': a plain object literal that matched no known filter shape
|
|
668
871
|
// is almost always a misspelled operator (`startWith` for `startsWith`);
|
|
669
872
|
// the guard also runs on the cache-hit param-collect path.
|
|
670
|
-
|
|
873
|
+
const pgType = getColumnPgType(qi, rawColumn);
|
|
874
|
+
assertBindableEqualityValue(qi, rawColumn, value, pgType, qi.table);
|
|
875
|
+
if (isBareJsonDocument(qi, pgType, value)) {
|
|
876
|
+
andClauses.push(jsonDocumentEquals(qi, column, rawColumn, value, bindInto(qi, params)));
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
671
879
|
params.push(coerceWhereOperand(qi, qi.tableMeta, rawColumn, value));
|
|
672
880
|
andClauses.push(`${column} = ${qi.p(params.length)}`);
|
|
673
881
|
return;
|
|
882
|
+
}
|
|
674
883
|
}
|
|
675
884
|
}
|
|
885
|
+
/**
|
|
886
|
+
* A bare object on a JSON column is a whole-document equality, the same thing
|
|
887
|
+
* `equals` is. PostgreSQL's `jsonb = $1` already reads it so. MySQL compared
|
|
888
|
+
* the column with the bound TEXT, which matched no row (and its `NOT` every
|
|
889
|
+
* row), so there it compiles to the typed comparison `equals` uses. SQLite and
|
|
890
|
+
* SQL Server compared the stored text, so key order and whitespace decided the
|
|
891
|
+
* match; they refuse it (E017) as they refuse `equals`, and a bare array with
|
|
892
|
+
* it. Decided from the dialect, the column and the value's shape, all of which
|
|
893
|
+
* the cache key already carries (a bare object fingerprints as `obj(...)`; a
|
|
894
|
+
* bare array only ever refuses, so it changes no SQL).
|
|
895
|
+
*/
|
|
896
|
+
function isBareJsonDocument(qi, pgType, value) {
|
|
897
|
+
if (qi.dialect.capabilities.jsonDocumentEquality === 'jsonb' || operandColumnKind(pgType) !== 'json')
|
|
898
|
+
return false;
|
|
899
|
+
return isPlainRecord(value) || (Array.isArray(value) && !comparesJsonDocuments(qi));
|
|
900
|
+
}
|
|
901
|
+
/** Whether the engine compares two whole JSON documents exactly (key order and whitespace aside). */
|
|
902
|
+
function comparesJsonDocuments(qi) {
|
|
903
|
+
return qi.dialect.capabilities.jsonDocumentEquality !== 'text';
|
|
904
|
+
}
|
|
905
|
+
/** A bind callback for the build passes: pushes the value and returns its placeholder. */
|
|
906
|
+
function bindInto(qi, params) {
|
|
907
|
+
return (value) => {
|
|
908
|
+
params.push(value);
|
|
909
|
+
return qi.p(params.length);
|
|
910
|
+
};
|
|
911
|
+
}
|
|
676
912
|
/**
|
|
677
913
|
* A {@link WhereHost} with no relations, used to fingerprint a sub-where
|
|
678
914
|
* whose target table is unknown (`schema.tables[t]` miss). `walkWhere` reads
|
|
@@ -732,8 +968,8 @@ export function relationWhereScope(qi, targetTable, meta, targetRef) {
|
|
|
732
968
|
*/
|
|
733
969
|
export function aliasWhereScope(qi, targetTable, meta, alias) {
|
|
734
970
|
if (alias.includes('"')) {
|
|
735
|
-
throw new
|
|
736
|
-
'
|
|
971
|
+
throw new InvariantError('where-scope-reference', `aliasWhereScope was given the rendered reference ${alias} for table "${targetTable}", but it takes a ` +
|
|
972
|
+
'bare alias and quotes it itself. Use renderedRefWhereScope for an already-quoted reference.');
|
|
737
973
|
}
|
|
738
974
|
return {
|
|
739
975
|
meta,
|
|
@@ -789,17 +1025,24 @@ export function buildScopedWhere(qi, scope, where, params, depth = 0) {
|
|
|
789
1025
|
for (const event of walkWhere(scope.host, where)) {
|
|
790
1026
|
switch (event.kind) {
|
|
791
1027
|
case 'or':
|
|
792
|
-
case 'and': {
|
|
793
1028
|
// Same variable-arity rule as the top level: a nested relation filter
|
|
794
1029
|
// or a relation `with` where can carry a caller-sized OR just as easily.
|
|
795
1030
|
if (!internal)
|
|
796
1031
|
qi.markVariableArity();
|
|
1032
|
+
clauses.push(buildOrClause(event.conditions, params, (cond) => {
|
|
1033
|
+
const sub = buildScopedWhere(qi, scope, cond, params, depth + 1);
|
|
1034
|
+
return sub === null ? null : `(${sub})`;
|
|
1035
|
+
}));
|
|
1036
|
+
break;
|
|
1037
|
+
case 'and': {
|
|
1038
|
+
if (!internal && !event.fixedArity)
|
|
1039
|
+
qi.markVariableArity();
|
|
797
1040
|
const parts = event.conditions
|
|
798
1041
|
.map((cond) => buildScopedWhere(qi, scope, cond, params, depth + 1))
|
|
799
1042
|
.filter((s) => s !== null)
|
|
800
1043
|
.map((s) => `(${s})`);
|
|
801
1044
|
if (parts.length > 0)
|
|
802
|
-
clauses.push(`(${parts.join(
|
|
1045
|
+
clauses.push(`(${parts.join(' AND ')})`);
|
|
803
1046
|
break;
|
|
804
1047
|
}
|
|
805
1048
|
case 'not': {
|
|
@@ -869,10 +1112,16 @@ export function buildScopedScalarClause(qi, scope, field, value, params, clauses
|
|
|
869
1112
|
table: scope.table,
|
|
870
1113
|
prefix: scope.qualifier,
|
|
871
1114
|
rawColumn: col,
|
|
1115
|
+
pgType: pgTypeForColumn(qi, meta, col),
|
|
872
1116
|
}));
|
|
873
1117
|
return;
|
|
874
1118
|
}
|
|
875
|
-
|
|
1119
|
+
const pgType = pgTypeForColumn(qi, meta, col);
|
|
1120
|
+
assertBindableEqualityValue(qi, col, value, pgType, scope.table);
|
|
1121
|
+
if (isBareJsonDocument(qi, pgType, value)) {
|
|
1122
|
+
clauses.push(jsonDocumentEquals(qi, qCol, col, value, bindInto(qi, params)));
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
876
1125
|
params.push(coerceWhereOperand(qi, meta, col, value));
|
|
877
1126
|
clauses.push(`${qCol} = ${qi.p(params.length)}`);
|
|
878
1127
|
}
|
|
@@ -880,30 +1129,41 @@ export function buildScopedScalarClause(qi, scope, field, value, params, clauses
|
|
|
880
1129
|
* Cache-hit param-collect mirror of {@link buildScopedWhere}: pushes the exact
|
|
881
1130
|
* same params in the exact same order (driven by the same {@link walkWhere}),
|
|
882
1131
|
* without rebuilding SQL. Serves both {@link collectRelFilterParams} and
|
|
883
|
-
* {@link collectAliasWhereParams}.
|
|
1132
|
+
* {@link collectAliasWhereParams}. Returns whether the clause compiles to a
|
|
1133
|
+
* condition (`buildScopedWhere` non-null).
|
|
884
1134
|
*/
|
|
885
1135
|
export function collectScopedWhereParams(qi, scope, where, params, depth = 0) {
|
|
886
1136
|
assertWhereDepth(depth);
|
|
1137
|
+
let emitted = false;
|
|
887
1138
|
for (const event of walkWhere(scope.host, where)) {
|
|
888
1139
|
switch (event.kind) {
|
|
889
1140
|
case 'or':
|
|
1141
|
+
collectOrParams(event.conditions, params, (cond) => collectScopedWhereParams(qi, scope, cond, params, depth + 1));
|
|
1142
|
+
emitted = true;
|
|
1143
|
+
break;
|
|
890
1144
|
case 'and':
|
|
891
|
-
for (const cond of event.conditions)
|
|
892
|
-
collectScopedWhereParams(qi, scope, cond, params, depth + 1)
|
|
1145
|
+
for (const cond of event.conditions) {
|
|
1146
|
+
if (collectScopedWhereParams(qi, scope, cond, params, depth + 1))
|
|
1147
|
+
emitted = true;
|
|
1148
|
+
}
|
|
893
1149
|
break;
|
|
894
1150
|
case 'not':
|
|
895
|
-
collectScopedWhereParams(qi, scope, event.condition, params, depth + 1)
|
|
1151
|
+
if (collectScopedWhereParams(qi, scope, event.condition, params, depth + 1))
|
|
1152
|
+
emitted = true;
|
|
896
1153
|
break;
|
|
897
1154
|
case 'relation':
|
|
898
1155
|
// Same some→none→every→is→isNot (each: sub-where params then target
|
|
899
1156
|
// global-filter params) as buildRelationFilter emits.
|
|
900
|
-
collectRelationFilterParams(qi, event.relDef, event.filterObj, params, depth)
|
|
1157
|
+
if (collectRelationFilterParams(qi, event.relDef, event.filterObj, params, depth))
|
|
1158
|
+
emitted = true;
|
|
901
1159
|
break;
|
|
902
1160
|
case 'scalar':
|
|
903
1161
|
collectScopedScalarParams(qi, scope, event.key, event.value, params);
|
|
1162
|
+
emitted = true;
|
|
904
1163
|
break;
|
|
905
1164
|
}
|
|
906
1165
|
}
|
|
1166
|
+
return emitted;
|
|
907
1167
|
}
|
|
908
1168
|
/** Param-collect mirror of {@link buildScopedScalarClause}. */
|
|
909
1169
|
export function collectScopedScalarParams(qi, scope, field, value, params) {
|
|
@@ -925,15 +1185,26 @@ export function collectScopedScalarParams(qi, scope, field, value, params) {
|
|
|
925
1185
|
if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
|
|
926
1186
|
const colType = pgTypeForColumn(qi, meta, col);
|
|
927
1187
|
if (colType.startsWith('_')) {
|
|
928
|
-
collectArrayFilterParams(qi, value, params);
|
|
1188
|
+
collectArrayFilterParams(qi, value, params, `${qi.q(scope.table)}.${qi.q(col)}`);
|
|
929
1189
|
return;
|
|
930
1190
|
}
|
|
931
1191
|
}
|
|
932
1192
|
if (isWhereOperator(value)) {
|
|
933
|
-
collectOperatorParams(qi, col, value, params, {
|
|
1193
|
+
collectOperatorParams(qi, col, value, params, {
|
|
1194
|
+
meta,
|
|
1195
|
+
table: scope.table,
|
|
1196
|
+
prefix: '',
|
|
1197
|
+
rawColumn: col,
|
|
1198
|
+
pgType: pgTypeForColumn(qi, meta, col),
|
|
1199
|
+
});
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
const pgType = pgTypeForColumn(qi, meta, col);
|
|
1203
|
+
assertBindableEqualityValue(qi, col, value, pgType, scope.table);
|
|
1204
|
+
if (isBareJsonDocument(qi, pgType, value)) {
|
|
1205
|
+
jsonDocumentEquals(qi, '', col, value, collectOnly(params));
|
|
934
1206
|
return;
|
|
935
1207
|
}
|
|
936
|
-
assertBindableEqualityValue(qi, col, value, pgTypeForColumn(qi, meta, col), scope.table);
|
|
937
1208
|
params.push(coerceWhereOperand(qi, meta, col, value));
|
|
938
1209
|
}
|
|
939
1210
|
/**
|
|
@@ -1088,24 +1359,23 @@ depth = 0) {
|
|
|
1088
1359
|
return gf ? ` AND ${gf}` : '';
|
|
1089
1360
|
};
|
|
1090
1361
|
// "some": EXISTS (SELECT 1 FROM target WHERE correlation AND filter AND gf)
|
|
1091
|
-
//
|
|
1092
|
-
//
|
|
1093
|
-
|
|
1094
|
-
if (filterObj.some !== undefined && filterObj.some !== null) {
|
|
1362
|
+
// `some` / `none` / `every` hold a where object here: the where walk refuses
|
|
1363
|
+
// any other value (relationFilterValueProblem), so none compiles to nothing.
|
|
1364
|
+
if (filterObj.some !== undefined) {
|
|
1095
1365
|
const subWhere = filterObj.some;
|
|
1096
1366
|
const filterClause = buildSubWhereForRelation(qi, targetTable, qt, subWhere, params, depth + 1);
|
|
1097
1367
|
const filterAnd = filterClause ? ` AND ${filterClause}` : '';
|
|
1098
1368
|
clauses.push(`EXISTS (SELECT 1 FROM ${fromTarget} WHERE ${correlation}${filterAnd}${gfAnd()})`);
|
|
1099
1369
|
}
|
|
1100
1370
|
// "none": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND filter AND gf)
|
|
1101
|
-
if (filterObj.none !== undefined
|
|
1371
|
+
if (filterObj.none !== undefined) {
|
|
1102
1372
|
const subWhere = filterObj.none;
|
|
1103
1373
|
const filterClause = buildSubWhereForRelation(qi, targetTable, qt, subWhere, params, depth + 1);
|
|
1104
1374
|
const filterAnd = filterClause ? ` AND ${filterClause}` : '';
|
|
1105
1375
|
clauses.push(`NOT EXISTS (SELECT 1 FROM ${fromTarget} WHERE ${correlation}${filterAnd}${gfAnd()})`);
|
|
1106
1376
|
}
|
|
1107
1377
|
// "every": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND gf AND NOT (filter))
|
|
1108
|
-
if (filterObj.every !== undefined
|
|
1378
|
+
if (filterObj.every !== undefined) {
|
|
1109
1379
|
const subWhere = filterObj.every;
|
|
1110
1380
|
const filterClause = buildSubWhereForRelation(qi, targetTable, qt, subWhere, params, depth + 1);
|
|
1111
1381
|
if (filterClause) {
|
|
@@ -1176,16 +1446,96 @@ export function pgTypeForColumn(_qi, meta, column) {
|
|
|
1176
1446
|
* SQL-template cache is unaffected, and it is applied on the cache-hit
|
|
1177
1447
|
* param-collect path as well as the build path.
|
|
1178
1448
|
*
|
|
1449
|
+
* On MySQL a number, bigint or boolean compared with a text column binds as
|
|
1450
|
+
* text ({@link textComparedOperand}), and so does each such element of a list.
|
|
1451
|
+
*
|
|
1179
1452
|
* `timestamptz` and every non-temporal column are returned by identity.
|
|
1180
1453
|
*/
|
|
1181
1454
|
export function coerceWhereOperand(qi, meta, column, value) {
|
|
1182
|
-
if (
|
|
1455
|
+
if (value instanceof Date || Array.isArray(value)) {
|
|
1456
|
+
const dbType = pgTypeForColumn(qi, meta, column);
|
|
1457
|
+
const coerced = coerceTemporalValue(dbType, value, utcDateTimeRewrite(qi, dbType));
|
|
1458
|
+
return Array.isArray(coerced) ? textComparedList(qi, dbType, coerced) : coerced;
|
|
1459
|
+
}
|
|
1460
|
+
if (!qi.dialect.capabilities.numericTextComparison || typeof value === 'string' || typeof value === 'object') {
|
|
1461
|
+
return value;
|
|
1462
|
+
}
|
|
1463
|
+
return textComparedOperand(qi, pgTypeForColumn(qi, meta, column), value);
|
|
1464
|
+
}
|
|
1465
|
+
/**
|
|
1466
|
+
* Column types MySQL compares with a NUMBER numerically, converting each
|
|
1467
|
+
* stored text to a number: `'0zz' = 0` and `'abc' = 0` are both true, and a
|
|
1468
|
+
* boolean is the number 1 or 0 there. A request body that carries `0` or
|
|
1469
|
+
* `false` where a token string belongs would match the first row whose token
|
|
1470
|
+
* does not start with a digit. Exported so the write path's re-read keys use
|
|
1471
|
+
* this one list rather than a copy.
|
|
1472
|
+
*/
|
|
1473
|
+
export const TEXT_COMPARED_TYPES = new Set([
|
|
1474
|
+
'char',
|
|
1475
|
+
'varchar',
|
|
1476
|
+
'character',
|
|
1477
|
+
'character varying',
|
|
1478
|
+
'bpchar',
|
|
1479
|
+
'nchar',
|
|
1480
|
+
'nvarchar',
|
|
1481
|
+
'text',
|
|
1482
|
+
'tinytext',
|
|
1483
|
+
'mediumtext',
|
|
1484
|
+
'longtext',
|
|
1485
|
+
'binary',
|
|
1486
|
+
'varbinary',
|
|
1487
|
+
'tinyblob',
|
|
1488
|
+
'blob',
|
|
1489
|
+
'mediumblob',
|
|
1490
|
+
'longblob',
|
|
1491
|
+
'uuid',
|
|
1492
|
+
'citext',
|
|
1493
|
+
]);
|
|
1494
|
+
/**
|
|
1495
|
+
* A value bound against a column of `columnType`, as MySQL must receive it to
|
|
1496
|
+
* compare it as the value it is: a number or bigint becomes its decimal text
|
|
1497
|
+
* and a boolean `'1'` / `'0'` (what MySQL stores when a boolean is written to
|
|
1498
|
+
* a text column), so the comparison is text with text. Every other engine,
|
|
1499
|
+
* value and column type is returned unchanged. THE rule for every value Turbine
|
|
1500
|
+
* binds against a column on MySQL: the where compiler applies it to each
|
|
1501
|
+
* operand, and the writes apply it to the keys they re-read a row by.
|
|
1502
|
+
*/
|
|
1503
|
+
export function textComparedOperand(qi, columnType, value) {
|
|
1504
|
+
if (!qi.dialect.capabilities.numericTextComparison)
|
|
1505
|
+
return value;
|
|
1506
|
+
if (typeof value !== 'number' && typeof value !== 'bigint' && typeof value !== 'boolean')
|
|
1183
1507
|
return value;
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1508
|
+
if (columnType === undefined || !TEXT_COMPARED_TYPES.has(columnType.toLowerCase()))
|
|
1509
|
+
return value;
|
|
1510
|
+
return typeof value === 'boolean' ? (value ? '1' : '0') : String(value);
|
|
1511
|
+
}
|
|
1512
|
+
/** {@link textComparedOperand} for each element of an `in` / `notIn` list; the list itself when none changes. */
|
|
1513
|
+
function textComparedList(qi, columnType, list) {
|
|
1514
|
+
if (!qi.dialect.capabilities.numericTextComparison)
|
|
1515
|
+
return list;
|
|
1516
|
+
let out;
|
|
1517
|
+
for (let i = 0; i < list.length; i++) {
|
|
1518
|
+
const next = textComparedOperand(qi, columnType, list[i]);
|
|
1519
|
+
if (next === list[i])
|
|
1520
|
+
continue;
|
|
1521
|
+
out ??= list.slice();
|
|
1522
|
+
out[i] = next;
|
|
1523
|
+
}
|
|
1524
|
+
return out ?? list;
|
|
1525
|
+
}
|
|
1526
|
+
/**
|
|
1527
|
+
* Whether a `Date` bound against `dbType` gets the zone-less UTC rewrite (the
|
|
1528
|
+
* write path's gate). A `date` column takes the UTC date part on every SQL
|
|
1529
|
+
* engine: the write path stores `YYYY-MM-DD` there, and a full timestamp
|
|
1530
|
+
* compared with that text matches nothing. A zone-less `timestamp` is
|
|
1531
|
+
* rewritten on PostgreSQL only, because MySQL reads a zone-less literal in the
|
|
1532
|
+
* session time zone. `utcTimestamps: false` turns both off. Time-of-day
|
|
1533
|
+
* columns always rewrite.
|
|
1534
|
+
*/
|
|
1535
|
+
export function utcDateTimeRewrite(qi, dbType) {
|
|
1536
|
+
if (qi.utcTimestamps === false)
|
|
1537
|
+
return false;
|
|
1538
|
+
return qi.dialect.capabilities.utcTimestampRewrite || localDateTimeKind(dbType) === 'date';
|
|
1189
1539
|
}
|
|
1190
1540
|
/**
|
|
1191
1541
|
* The Postgres enum type name for a column, when the schema knows one.
|
|
@@ -1197,13 +1547,13 @@ export function coerceWhereOperand(qi, meta, column, value) {
|
|
|
1197
1547
|
* value as text and Postgres refuses the implicit text→enum coercion
|
|
1198
1548
|
* ("column X is of type Y but expression is of type text").
|
|
1199
1549
|
*
|
|
1200
|
-
* Postgres-only by construction: gated on the
|
|
1201
|
-
* `
|
|
1202
|
-
* produces them, `defineSchema` and the other engines leave it
|
|
1203
|
-
* SQLite/MySQL/MSSQL/PowDB output is byte-identical.
|
|
1550
|
+
* Postgres-only by construction: gated on the engine having named enum types
|
|
1551
|
+
* (`namedEnumTypes`) AND on `schema.enums` having entries (only PG
|
|
1552
|
+
* introspection produces them, `defineSchema` and the other engines leave it
|
|
1553
|
+
* empty), so SQLite/MySQL/MSSQL/PowDB output is byte-identical.
|
|
1204
1554
|
*/
|
|
1205
1555
|
export function enumTypeForColumn(qi, column) {
|
|
1206
|
-
if (qi.dialect.
|
|
1556
|
+
if (!qi.dialect.capabilities.namedEnumTypes)
|
|
1207
1557
|
return null;
|
|
1208
1558
|
const enums = qi.schema.enums;
|
|
1209
1559
|
if (!enums)
|
|
@@ -1232,25 +1582,14 @@ export function enumCastSuffix(qi, column) {
|
|
|
1232
1582
|
}
|
|
1233
1583
|
/**
|
|
1234
1584
|
* Equality-fallthrough guard shared by every SQL-build path AND every
|
|
1235
|
-
* cache-hit param-collect path
|
|
1236
|
-
*
|
|
1237
|
-
*
|
|
1238
|
-
*
|
|
1239
|
-
*
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
if (!isUnmatchedPlainObject(value))
|
|
1244
|
-
return;
|
|
1245
|
-
if (isJsonColumnType(qi, columnPgType))
|
|
1246
|
-
return;
|
|
1247
|
-
const badKeys = Object.keys(value);
|
|
1248
|
-
throw new ValidationError(badKeys.length === 0
|
|
1249
|
-
? `Empty filter object on "${rawColumn}" for table "${table}". ` +
|
|
1250
|
-
`Provide a value or an operator like { gt: 1 }.`
|
|
1251
|
-
: `Unknown operator${badKeys.length > 1 ? 's' : ''} ` +
|
|
1252
|
-
`${badKeys.map((k) => `"${k}"`).join(', ')} on "${rawColumn}" for table "${table}". ` +
|
|
1253
|
-
`Supported operators: ${[...OPERATOR_KEYS].join(', ')}.`);
|
|
1585
|
+
* cache-hit param-collect path: a bare value must fit its column
|
|
1586
|
+
* ({@link assertEqualityOperand}). A plain object literal that matched no
|
|
1587
|
+
* known filter shape on a non-JSON column is almost always a misspelled
|
|
1588
|
+
* operator (`startWith` for `startsWith`), and binding it as `col = $1` would
|
|
1589
|
+
* silently return wrong rows. Objects on json/jsonb columns are JSON values.
|
|
1590
|
+
*/
|
|
1591
|
+
export function assertBindableEqualityValue(_qi, rawColumn, value, columnPgType, table) {
|
|
1592
|
+
assertEqualityOperand(value, { column: rawColumn, table, kind: operandColumnKind(columnPgType) });
|
|
1254
1593
|
}
|
|
1255
1594
|
/**
|
|
1256
1595
|
* Build the user-supplied `where` filter of a relation `with` clause against
|
|
@@ -1269,7 +1608,7 @@ export function buildAliasWhere(qi, targetTable, targetMeta, alias, where, param
|
|
|
1269
1608
|
export function collectAliasWhereParams(qi, targetTable, targetMeta, where, params) {
|
|
1270
1609
|
// The alias identifier is irrelevant to param collection (it only shapes SQL
|
|
1271
1610
|
// text), so reuse the relation scope's host binding for `targetMeta`.
|
|
1272
|
-
collectScopedWhereParams(qi, aliasWhereScope(qi, targetTable, targetMeta, ''), where, params);
|
|
1611
|
+
return collectScopedWhereParams(qi, aliasWhereScope(qi, targetTable, targetMeta, ''), where, params);
|
|
1273
1612
|
}
|
|
1274
1613
|
/**
|
|
1275
1614
|
* Value-invariant, shape-aware fingerprint for a relation `with` clause's
|
|
@@ -1318,11 +1657,20 @@ export function columnRefSql(qi, ref, ctx, mode) {
|
|
|
1318
1657
|
* Each operator key becomes its own clause, all ANDed together.
|
|
1319
1658
|
*
|
|
1320
1659
|
* `equals`/`not`/`gt`/`gte`/`lt`/`lte` also accept a {@link ColumnRef}
|
|
1321
|
-
* (`
|
|
1660
|
+
* (`col('otherField')`) which compiles to a column-to-column comparison
|
|
1322
1661
|
* against `refCtx`: no param bound, so `collectOperatorParams` mirrors by
|
|
1323
1662
|
* pushing nothing and the referenced name lives in the fingerprint.
|
|
1663
|
+
*
|
|
1664
|
+
* `not` also accepts an operator object, compiled as `NOT (<inner clauses>)`
|
|
1665
|
+
* (see {@link WhereOperator.not}).
|
|
1324
1666
|
*/
|
|
1325
1667
|
export function buildOperatorClauses(qi, column, op, params, refCtx) {
|
|
1668
|
+
const operand = operandContext(qi, column, refCtx);
|
|
1669
|
+
assertOperatorOperands(op, operand);
|
|
1670
|
+
return buildOperatorClausesChecked(qi, column, op, params, refCtx, operand);
|
|
1671
|
+
}
|
|
1672
|
+
/** {@link buildOperatorClauses} after validation; recursive for a `not` operator object. */
|
|
1673
|
+
function buildOperatorClausesChecked(qi, column, op, params, refCtx, operand) {
|
|
1326
1674
|
const clauses = [];
|
|
1327
1675
|
// Temporal bind rewrite, identical to `collectOperatorParams`. Value-only, so
|
|
1328
1676
|
// the emitted SQL (and therefore the template cache) is untouched.
|
|
@@ -1336,7 +1684,6 @@ export function buildOperatorClauses(qi, column, op, params, refCtx) {
|
|
|
1336
1684
|
clauses.push(`${column} = ${columnRefSql(qi, op.equals, refCtx, op.mode)}`);
|
|
1337
1685
|
}
|
|
1338
1686
|
else {
|
|
1339
|
-
assertBindableEqualsOperand(op.equals, column);
|
|
1340
1687
|
if (insensitive)
|
|
1341
1688
|
assertInsensitiveOperand(op.equals, 'equals', column);
|
|
1342
1689
|
params.push(cv(op.equals));
|
|
@@ -1380,12 +1727,25 @@ export function buildOperatorClauses(qi, column, op, params, refCtx) {
|
|
|
1380
1727
|
}
|
|
1381
1728
|
}
|
|
1382
1729
|
if (op.not !== undefined) {
|
|
1730
|
+
const negatedDocument = op.not === null || isColumnRef(op.not) ? NO_JSON_NEGATION : jsonNegatedDocument(qi, op.not, operand);
|
|
1383
1731
|
if (op.not === null) {
|
|
1384
1732
|
clauses.push(`${column} IS NOT NULL`);
|
|
1385
1733
|
}
|
|
1386
1734
|
else if (isColumnRef(op.not)) {
|
|
1387
1735
|
clauses.push(`${column} != ${columnRefSql(qi, op.not, refCtx, op.mode)}`);
|
|
1388
1736
|
}
|
|
1737
|
+
else if (negatedDocument !== NO_JSON_NEGATION) {
|
|
1738
|
+
const equality = jsonDocumentEquals(qi, column, column, negatedDocument, (v) => {
|
|
1739
|
+
params.push(v);
|
|
1740
|
+
return qi.p(params.length);
|
|
1741
|
+
});
|
|
1742
|
+
clauses.push(`NOT (${equality})`);
|
|
1743
|
+
}
|
|
1744
|
+
else if (isNegatedOperatorObject(op.not, operand)) {
|
|
1745
|
+
const inner = buildOperatorClausesChecked(qi, column, notOperand(op), params, refCtx, operand);
|
|
1746
|
+
if (inner.length > 0)
|
|
1747
|
+
clauses.push(`NOT (${inner.join(' AND ')})`);
|
|
1748
|
+
}
|
|
1389
1749
|
else {
|
|
1390
1750
|
if (insensitive)
|
|
1391
1751
|
assertInsensitiveOperand(op.not, 'not', column);
|
|
@@ -1442,9 +1802,8 @@ export function requireFullTextSearch(qi) {
|
|
|
1442
1802
|
'Use `contains` (LIKE) on this engine, or run the query on PostgreSQL.');
|
|
1443
1803
|
}
|
|
1444
1804
|
/**
|
|
1445
|
-
* Gate the pathless JSON
|
|
1446
|
-
*
|
|
1447
|
-
* {@link Dialect.supportsJsonContains}.
|
|
1805
|
+
* Gate the pathless JSON filters, `contains` (containment) and `equals`
|
|
1806
|
+
* (whole-document equality), on {@link Dialect.supportsJsonContains}.
|
|
1448
1807
|
*
|
|
1449
1808
|
* The engine this exists for is SQLite, whose emulation compares a decoded
|
|
1450
1809
|
* `json_each.value` against a param bound as JSON TEXT and therefore matched
|
|
@@ -1463,11 +1822,12 @@ export function requireFullTextSearch(qi) {
|
|
|
1463
1822
|
export function requireJsonContains(qi, clause) {
|
|
1464
1823
|
if (qi.dialect.supportsJsonContains)
|
|
1465
1824
|
return;
|
|
1466
|
-
|
|
1467
|
-
|
|
1825
|
+
const what = clause === 'equals' ? 'way to compare two whole JSON documents' : "JSON containment (PostgreSQL's `@>`)";
|
|
1826
|
+
throw new UnsupportedFeatureError(clause === 'equals'
|
|
1827
|
+
? 'JSON document equality (`equals` without `path`)'
|
|
1828
|
+
: 'JSON containment (`contains` without `path`)', qi.dialect.name, `This engine has no ${what}, so the filter is refused rather than emulated. ` +
|
|
1468
1829
|
'Filter on the specific value instead: `{ path: [...], equals: ... }` compiles to a real JSON ' +
|
|
1469
|
-
'extraction (`json_extract`)
|
|
1470
|
-
'query on PostgreSQL or MySQL.');
|
|
1830
|
+
'extraction (`json_extract`). To compare or match whole documents, run the query on PostgreSQL or MySQL.');
|
|
1471
1831
|
}
|
|
1472
1832
|
/**
|
|
1473
1833
|
* Gate the array filter operators (`has` / `hasEvery` / `hasSome` / `isEmpty`)
|
|
@@ -1541,15 +1901,7 @@ export function pushVectorParam(qi, field, _rawColumn, to, params) {
|
|
|
1541
1901
|
* ambiguous and was never valid in Prisma either).
|
|
1542
1902
|
*/
|
|
1543
1903
|
export function normalizeRelationFilter(_qi, relDef, filterObj) {
|
|
1544
|
-
|
|
1545
|
-
!('some' in filterObj) &&
|
|
1546
|
-
!('every' in filterObj) &&
|
|
1547
|
-
!('none' in filterObj) &&
|
|
1548
|
-
!('is' in filterObj) &&
|
|
1549
|
-
!('isNot' in filterObj)) {
|
|
1550
|
-
return { is: filterObj };
|
|
1551
|
-
}
|
|
1552
|
-
return filterObj;
|
|
1904
|
+
return normalizeToOneRelationFilter(relDef, filterObj);
|
|
1553
1905
|
}
|
|
1554
1906
|
/**
|
|
1555
1907
|
* Case-insensitive json/jsonb column-type check. Postgres reports lowercase
|
|
@@ -1597,7 +1949,11 @@ export function getArrayElementType(_qi, pgType) {
|
|
|
1597
1949
|
*/
|
|
1598
1950
|
/**
|
|
1599
1951
|
* One LIKE comparison, honoring `mode: 'insensitive'` through the dialect and
|
|
1600
|
-
* always carrying the `ESCAPE
|
|
1952
|
+
* always carrying the `ESCAPE` clause that pairs with {@link likeOperand}: the
|
|
1953
|
+
* dialect's `likeEscapeCharacter`, else `\` (the character {@link escapeLike}
|
|
1954
|
+
* escapes with). MySQL names `!`, because `'\'` is a syntax error there unless
|
|
1955
|
+
* the session runs with `NO_BACKSLASH_ESCAPES`, which a caller-supplied pool
|
|
1956
|
+
* need not.
|
|
1601
1957
|
*
|
|
1602
1958
|
* Shared by the scalar `contains` / `startsWith` / `endsWith` operators and by
|
|
1603
1959
|
* the JSON substring operators, so the two can never drift into escaping their
|
|
@@ -1605,7 +1961,7 @@ export function getArrayElementType(_qi, pgType) {
|
|
|
1605
1961
|
*/
|
|
1606
1962
|
function buildLikeClause(qi, column, paramRef, insensitive) {
|
|
1607
1963
|
const base = insensitive ? qi.dialect.buildInsensitiveLike(column, paramRef) : `${column} LIKE ${paramRef}`;
|
|
1608
|
-
return `${base} ESCAPE '\\'`;
|
|
1964
|
+
return `${base} ESCAPE '${qi.dialect.likeEscapeCharacter ?? '\\'}'`;
|
|
1609
1965
|
}
|
|
1610
1966
|
/**
|
|
1611
1967
|
* One equality-family comparison (`=` / `!=`) honoring `mode: 'insensitive'`.
|
|
@@ -1655,11 +2011,12 @@ function insensitiveInClause(qi, column, paramRef, negated, operator) {
|
|
|
1655
2011
|
* on that engine.
|
|
1656
2012
|
*
|
|
1657
2013
|
* Folding the elements in SQL instead is not reachable from here for the other
|
|
1658
|
-
* dialects: each unpacks a bound IN list in
|
|
1659
|
-
*
|
|
1660
|
-
*
|
|
1661
|
-
*
|
|
1662
|
-
*
|
|
2014
|
+
* dialects: each unpacks a bound IN list in its OWN way (`json_each` on SQLite,
|
|
2015
|
+
* `OPENJSON` on SQL Server, one placeholder per element expanded by the MySQL
|
|
2016
|
+
* pool shim after the statement leaves the builder), that shape is the
|
|
2017
|
+
* dialect's and not this module's, and the length-independence the
|
|
2018
|
+
* SQL-template cache depends on rules out emitting one placeholder per element
|
|
2019
|
+
* here. Restoring the operator on those engines means a dialect hook that
|
|
1663
2020
|
* projects the folded element, one correct implementation per engine. Until
|
|
1664
2021
|
* then a typed refusal is the honest outcome. `equals`, `not` and the LIKE
|
|
1665
2022
|
* operators are unaffected: they fold a single operand the engine can reach.
|
|
@@ -1668,7 +2025,7 @@ function insensitiveInClause(qi, column, paramRef, negated, operator) {
|
|
|
1668
2025
|
* every other gate in this module.
|
|
1669
2026
|
*/
|
|
1670
2027
|
function requireInsensitiveList(qi, column, operator) {
|
|
1671
|
-
if (qi.dialect.
|
|
2028
|
+
if (qi.dialect.capabilities.insensitiveInList)
|
|
1672
2029
|
return;
|
|
1673
2030
|
throw new UnsupportedFeatureError(`mode: 'insensitive' on \`${operator}\` (${column})`, qi.dialect.name, "Case-folding a bound list needs the engine to lower every element in SQL, which this dialect's " +
|
|
1674
2031
|
'IN-list form cannot express. Lowering them in JavaScript instead would fold the list and the ' +
|
|
@@ -1692,54 +2049,6 @@ function assertInsensitiveOperand(value, operator, column) {
|
|
|
1692
2049
|
`(received ${Array.isArray(value) ? 'a list with a non-string element' : typeof value}). ` +
|
|
1693
2050
|
`Case folding applies to text; drop the mode or compare a string.`);
|
|
1694
2051
|
}
|
|
1695
|
-
/**
|
|
1696
|
-
* Refuse a {@link JsonFilter} carrying a key that is not a JSON operator, and
|
|
1697
|
-
* refuse a filter that selects a `path` but never compares it.
|
|
1698
|
-
*
|
|
1699
|
-
* Both shapes used to compile to NOTHING. `buildJsonFilterClauses` only ever
|
|
1700
|
-
* emitted a clause for a key it recognized, so `{ path: ['title'],
|
|
1701
|
-
* string_contains: 'x' }`, the Prisma spelling, and an easy typo besides -
|
|
1702
|
-
* produced an empty clause list, the predicate vanished, and the query
|
|
1703
|
-
* returned EVERY row. Inside an `AND` it silently dropped that conjunct, so a
|
|
1704
|
-
* tenant scope written this way widened to the whole table. The equivalent
|
|
1705
|
-
* typo on a scalar column has always thrown; this closes the inconsistency.
|
|
1706
|
-
*
|
|
1707
|
-
* Called from both the SQL-build path and the cache-hit param-collect path, so
|
|
1708
|
-
* a warmed SQL cache cannot skip the check.
|
|
1709
|
-
*/
|
|
1710
|
-
export function assertJsonFilterKeys(filter, column) {
|
|
1711
|
-
const obj = filter;
|
|
1712
|
-
const present = Object.keys(obj).filter((k) => obj[k] !== undefined);
|
|
1713
|
-
for (const key of present) {
|
|
1714
|
-
if (JSON_FILTER_KEYS.has(key))
|
|
1715
|
-
continue;
|
|
1716
|
-
const suggestion = JSON_PRISMA_SPELLINGS[key];
|
|
1717
|
-
throw new ValidationError(`Unknown JSON filter operator "${key}" on ${column}.` +
|
|
1718
|
-
(suggestion ? ` Did you mean \`${suggestion}\`?` : '') +
|
|
1719
|
-
` Supported operators: ${[...JSON_FILTER_KEYS].sort().join(', ')}.`);
|
|
1720
|
-
}
|
|
1721
|
-
// `mode` and `path` are modifiers, not comparisons: a filter made only of
|
|
1722
|
-
// those compares nothing, which is exactly the silent no-op shape above.
|
|
1723
|
-
if (present.length > 0 && present.every((k) => k === 'path' || k === 'mode')) {
|
|
1724
|
-
throw new ValidationError(`JSON filter on ${column} selects a \`path\` but has no comparison. ` +
|
|
1725
|
-
`Add one of: ${[...JSON_FILTER_KEYS]
|
|
1726
|
-
.filter((k) => k !== 'path' && k !== 'mode')
|
|
1727
|
-
.sort()
|
|
1728
|
-
.join(', ')}.`);
|
|
1729
|
-
}
|
|
1730
|
-
}
|
|
1731
|
-
/**
|
|
1732
|
-
* The Prisma spelling of each JSON operator, so a migrator who writes the
|
|
1733
|
-
* name they already know gets told the turbine one instead of a bare list.
|
|
1734
|
-
*/
|
|
1735
|
-
const JSON_PRISMA_SPELLINGS = {
|
|
1736
|
-
string_contains: 'stringContains',
|
|
1737
|
-
string_starts_with: 'stringStartsWith',
|
|
1738
|
-
string_ends_with: 'stringEndsWith',
|
|
1739
|
-
array_contains: 'contains',
|
|
1740
|
-
startsWith: 'stringStartsWith',
|
|
1741
|
-
endsWith: 'stringEndsWith',
|
|
1742
|
-
};
|
|
1743
2052
|
/**
|
|
1744
2053
|
* Validate and enumerate the substring comparisons on a JSON filter, in the
|
|
1745
2054
|
* fixed {@link JSON_STRING_OPERATORS} order. Shared by the build and collect
|
|
@@ -1786,6 +2095,136 @@ export function jsonRangeEntries(_qi, filter, column) {
|
|
|
1786
2095
|
}
|
|
1787
2096
|
return entries;
|
|
1788
2097
|
}
|
|
2098
|
+
/**
|
|
2099
|
+
* The `{ path, equals }` operand after the checks every engine shares: a
|
|
2100
|
+
* function, symbol or column reference is refused, a non-finite number is
|
|
2101
|
+
* refused (JSON.stringify would turn it into `null`, which matches JSON null),
|
|
2102
|
+
* and a Date becomes the ISO string JSON stores it as.
|
|
2103
|
+
*/
|
|
2104
|
+
function jsonPathEqualsValue(value, column) {
|
|
2105
|
+
if (typeof value === 'function' || typeof value === 'symbol' || isColumnRef(value)) {
|
|
2106
|
+
throw new ValidationError(`JSON path 'equals' on ${column} takes a JSON value (string, number, boolean, null, array or object), ` +
|
|
2107
|
+
`received ${isColumnRef(value) ? 'a column reference' : `a ${typeof value}`}.`);
|
|
2108
|
+
}
|
|
2109
|
+
const scalar = value instanceof Date ? value.toISOString() : value;
|
|
2110
|
+
if (typeof scalar === 'number' && !Number.isFinite(scalar)) {
|
|
2111
|
+
throw new ValidationError(`JSON path 'equals' on ${column} takes a finite number: JSON has no form for ${scalar}.`);
|
|
2112
|
+
}
|
|
2113
|
+
return scalar;
|
|
2114
|
+
}
|
|
2115
|
+
/** The engine's typed `{ path, equals }` hook; the jsonb form lives here, so an engine using it never takes one. */
|
|
2116
|
+
function jsonPathEqualsHook(qi) {
|
|
2117
|
+
return qi.dialect.capabilities.jsonDocumentEquality === 'jsonb' ? undefined : qi.dialect.buildJsonPathEquals;
|
|
2118
|
+
}
|
|
2119
|
+
/** The hook input, identical on the build and collect passes but for `column`, `pathRef` and `bind`. */
|
|
2120
|
+
function jsonPathEqualsInput(qi, filter, label, column, pathRef, bind) {
|
|
2121
|
+
return {
|
|
2122
|
+
column,
|
|
2123
|
+
pathRef,
|
|
2124
|
+
path: filter.path,
|
|
2125
|
+
value: jsonPathEqualsValue(filter.equals, label),
|
|
2126
|
+
label,
|
|
2127
|
+
bind,
|
|
2128
|
+
encodePath: (path) => jsonPathParam(qi, path),
|
|
2129
|
+
};
|
|
2130
|
+
}
|
|
2131
|
+
/**
|
|
2132
|
+
* A whole-document JSON `equals` (no `path`): the column equals the operand as
|
|
2133
|
+
* a JSON value, on the build and collect passes alike (`bind` pushes; on the
|
|
2134
|
+
* collect pass the returned SQL is discarded). PostgreSQL compares jsonb, which
|
|
2135
|
+
* ignores object key order and whitespace; the cast makes a `json` column
|
|
2136
|
+
* comparable too (it has no `=` operator). MySQL compares through its typed
|
|
2137
|
+
* hook; an engine without one refuses (E017) through
|
|
2138
|
+
* {@link requireJsonContains}.
|
|
2139
|
+
*/
|
|
2140
|
+
function jsonDocumentEquals(qi, column, label, value, bind) {
|
|
2141
|
+
const v = jsonPathEqualsValue(value, label);
|
|
2142
|
+
if (qi.dialect.capabilities.jsonDocumentEquality === 'jsonb') {
|
|
2143
|
+
return `${column}::jsonb = ${bind(typeof v === 'bigint' ? v.toString() : JSON.stringify(v))}::jsonb`;
|
|
2144
|
+
}
|
|
2145
|
+
const hook = qi.dialect.buildJsonPathEquals;
|
|
2146
|
+
if (hook) {
|
|
2147
|
+
return hook.call(qi.dialect, {
|
|
2148
|
+
column,
|
|
2149
|
+
pathRef: undefined,
|
|
2150
|
+
path: [],
|
|
2151
|
+
value: v,
|
|
2152
|
+
label,
|
|
2153
|
+
bind,
|
|
2154
|
+
encodePath: (path) => jsonPathParam(qi, path),
|
|
2155
|
+
});
|
|
2156
|
+
}
|
|
2157
|
+
requireJsonContains(qi, 'equals');
|
|
2158
|
+
throw new UnsupportedFeatureError(`JSON \`equals\` on the whole document (${label})`, qi.dialect.name, 'This engine has no exact comparison of JSON documents. Compare a value with `{ path: [...], equals }`.');
|
|
2159
|
+
}
|
|
2160
|
+
/** A bind callback for the collect passes: pushes the value, emits no SQL. */
|
|
2161
|
+
function collectOnly(params) {
|
|
2162
|
+
return (value) => {
|
|
2163
|
+
params.push(value);
|
|
2164
|
+
return '';
|
|
2165
|
+
};
|
|
2166
|
+
}
|
|
2167
|
+
/**
|
|
2168
|
+
* The operand of a `not` on a JSON column that negates a whole-document
|
|
2169
|
+
* equality, or `NO_JSON_NEGATION` when the `not` keeps its bound-value form.
|
|
2170
|
+
* `not: { equals: X }` is the negation of `equals: X` everywhere it compiles
|
|
2171
|
+
* (an engine without an exact comparison refuses it rather than comparing a
|
|
2172
|
+
* document `{"equals": X}`). A plain `not: X` means the same thing on the
|
|
2173
|
+
* engines that compare whole documents exactly (PostgreSQL, MySQL). On SQLite
|
|
2174
|
+
* and SQL Server a document (object or array) is refused the same way, since
|
|
2175
|
+
* comparing it as text lets key order decide; a scalar keeps comparing as the
|
|
2176
|
+
* bound value, as before.
|
|
2177
|
+
*/
|
|
2178
|
+
function jsonNegatedDocument(qi, not, operand) {
|
|
2179
|
+
if (operand.kind !== 'json' || not === null || not === undefined || isColumnRef(not))
|
|
2180
|
+
return NO_JSON_NEGATION;
|
|
2181
|
+
if (isWhereOperator(not)) {
|
|
2182
|
+
const keys = Object.keys(not).filter((k) => not[k] !== undefined);
|
|
2183
|
+
if (keys.length === 1 && keys[0] === 'equals')
|
|
2184
|
+
return not.equals;
|
|
2185
|
+
}
|
|
2186
|
+
if (comparesJsonDocuments(qi))
|
|
2187
|
+
return not;
|
|
2188
|
+
return isPlainRecord(not) || Array.isArray(not) ? not : NO_JSON_NEGATION;
|
|
2189
|
+
}
|
|
2190
|
+
const NO_JSON_NEGATION = Symbol('no JSON negation');
|
|
2191
|
+
/**
|
|
2192
|
+
* The bound operand of `{ path, equals }`, shared by the build and collect
|
|
2193
|
+
* paths so both bind (and refuse) identically.
|
|
2194
|
+
*
|
|
2195
|
+
* PostgreSQL compares JSON to JSON, so the operand is its JSON text: `5`,
|
|
2196
|
+
* `"5"`, `null`, `[1,2]` and `{"a":1}` each match only that JSON value at the
|
|
2197
|
+
* path (numbers compare numerically, so `5` also matches `5.0`). A Date is the
|
|
2198
|
+
* ISO string JSON would store, and a bigint is a JSON number.
|
|
2199
|
+
*
|
|
2200
|
+
* MySQL and SQL Server compare typed through `Dialect.buildJsonPathEquals`
|
|
2201
|
+
* and never reach this function. SQLite compares the value its path
|
|
2202
|
+
* extraction returns, so only a value with a text form can match: a string,
|
|
2203
|
+
* number, boolean, bigint or Date.
|
|
2204
|
+
* There a number and a numeric string match the same rows, except on SQLite,
|
|
2205
|
+
* whose extraction returns typed values: a number binds as a number there, and
|
|
2206
|
+
* a boolean is refused because SQLite reads `true` and `1` alike. `null`,
|
|
2207
|
+
* objects and arrays matched no row on those engines and are refused (E017).
|
|
2208
|
+
*/
|
|
2209
|
+
export function jsonPathEqualsOperand(qi, value, column) {
|
|
2210
|
+
const scalar = jsonPathEqualsValue(value, column);
|
|
2211
|
+
if (qi.dialect.capabilities.jsonDocumentEquality === 'jsonb') {
|
|
2212
|
+
return typeof scalar === 'bigint' ? scalar.toString() : JSON.stringify(scalar);
|
|
2213
|
+
}
|
|
2214
|
+
if (scalar === null || typeof scalar === 'object') {
|
|
2215
|
+
throw new UnsupportedFeatureError(`JSON path \`equals\` with ${scalar === null ? 'null' : Array.isArray(scalar) ? 'an array' : 'an object'} (${column})`, qi.dialect.name, 'This engine compares the text extracted at the path, which has no form for null, arrays or objects, so ' +
|
|
2216
|
+
'the comparison would match no row. Compare a scalar at a deeper path, or run the query on PostgreSQL.');
|
|
2217
|
+
}
|
|
2218
|
+
if (qi.dialect.capabilities.jsonExtractTyped) {
|
|
2219
|
+
if (typeof scalar === 'number')
|
|
2220
|
+
return scalar;
|
|
2221
|
+
if (typeof scalar === 'boolean') {
|
|
2222
|
+
throw new UnsupportedFeatureError(`JSON path \`equals\` with a boolean (${column})`, qi.dialect.name, 'SQLite extracts JSON true and false as the integers 1 and 0, so a boolean comparison would also match ' +
|
|
2223
|
+
'those numbers. Compare with 1 / 0 if the documents hold only booleans there.');
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
return String(scalar);
|
|
2227
|
+
}
|
|
1789
2228
|
/**
|
|
1790
2229
|
* Build SQL clauses for JSONB filter operators on a column.
|
|
1791
2230
|
* Supports: path, equals, contains, hasKey, gt, gte, lt, lte.
|
|
@@ -1799,29 +2238,45 @@ export function buildJsonFilterClauses(qi, column, filter, params) {
|
|
|
1799
2238
|
const clauses = [];
|
|
1800
2239
|
// Lazily bind the path once; reuse the same $N in every extraction clause.
|
|
1801
2240
|
let pathParamIdx = null;
|
|
1802
|
-
const
|
|
2241
|
+
const pathRef = () => {
|
|
1803
2242
|
if (pathParamIdx === null) {
|
|
1804
2243
|
// Only reached when a path-requiring clause validated filter.path.
|
|
1805
2244
|
params.push(jsonPathParam(qi, filter.path, filter.path));
|
|
1806
2245
|
pathParamIdx = params.length;
|
|
1807
2246
|
}
|
|
1808
|
-
return qi.
|
|
2247
|
+
return qi.p(pathParamIdx);
|
|
1809
2248
|
};
|
|
2249
|
+
const pathExtract = () => qi.dialect.buildJsonPathExtract(column, pathRef());
|
|
1810
2250
|
if (filter.path !== undefined && filter.equals !== undefined) {
|
|
1811
|
-
// Path access + equals
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
2251
|
+
// Path access + equals. PostgreSQL compares the JSON VALUE at the path,
|
|
2252
|
+
// typed: `(column #> $N::text[])::jsonb = $M::jsonb`, so 5 and "5" differ,
|
|
2253
|
+
// and null, arrays and objects compare as JSON. MySQL and SQL Server bring
|
|
2254
|
+
// their own typed form (Dialect.buildJsonPathEquals); SQLite compares the
|
|
2255
|
+
// extracted value (see jsonPathEqualsOperand).
|
|
2256
|
+
const ref = pathRef();
|
|
2257
|
+
const hook = jsonPathEqualsHook(qi);
|
|
2258
|
+
if (hook) {
|
|
2259
|
+
// The engine's typed comparison (MySQL, SQL Server).
|
|
2260
|
+
clauses.push(hook.call(qi.dialect, jsonPathEqualsInput(qi, filter, column, column, ref, (v) => {
|
|
2261
|
+
params.push(v);
|
|
2262
|
+
return qi.p(params.length);
|
|
2263
|
+
})));
|
|
2264
|
+
}
|
|
2265
|
+
else {
|
|
2266
|
+
const value = jsonPathEqualsOperand(qi, filter.equals, column);
|
|
2267
|
+
params.push(value);
|
|
2268
|
+
clauses.push(qi.dialect.capabilities.jsonDocumentEquality === 'jsonb'
|
|
2269
|
+
? `(${column} #> ${ref}::text[])::jsonb = ${qi.p(params.length)}::jsonb`
|
|
2270
|
+
: `${qi.dialect.buildJsonPathExtract(column, ref)} = ${qi.p(params.length)}`);
|
|
2271
|
+
}
|
|
1815
2272
|
}
|
|
1816
2273
|
else if (filter.equals !== undefined) {
|
|
1817
|
-
//
|
|
1818
|
-
//
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
params.push(JSON.stringify(filter.equals));
|
|
1824
|
-
clauses.push(qi.dialect.buildJsonContains(column, qi.p(params.length)));
|
|
2274
|
+
// Whole-document equality, NOT containment (`@>` matched every document
|
|
2275
|
+
// holding the operand's keys).
|
|
2276
|
+
clauses.push(jsonDocumentEquals(qi, column, column, filter.equals, (v) => {
|
|
2277
|
+
params.push(v);
|
|
2278
|
+
return qi.p(params.length);
|
|
2279
|
+
}));
|
|
1825
2280
|
}
|
|
1826
2281
|
if (filter.contains !== undefined) {
|
|
1827
2282
|
// Containment: column @> $N::jsonb
|
|
@@ -1862,12 +2317,19 @@ export function buildJsonFilterClauses(qi, column, filter, params) {
|
|
|
1862
2317
|
* params) and fail at runtime with the engine's bad-JSON-path error. The
|
|
1863
2318
|
* encoded path stays a bound parameter, never spliced into SQL text, so
|
|
1864
2319
|
* the build/collect param mirrors stay in lockstep and injection-safe.
|
|
2320
|
+
*
|
|
2321
|
+
* A key segment is written as a JSON string literal (`JSON.stringify`), the
|
|
2322
|
+
* form the engines' JSONPath grammars read a quoted key in: a backslash, a
|
|
2323
|
+
* quote and a control character are each escaped. Escaping only the quote
|
|
2324
|
+
* read a key holding a backslash as an escape sequence, so the key `a\b` was
|
|
2325
|
+
* looked up as a different key and matched nothing, a key ending in a
|
|
2326
|
+
* backslash was a bad path, and MySQL refused a key holding a newline.
|
|
1865
2327
|
*/
|
|
1866
2328
|
export function jsonPathParam(qi, path, nativeForm) {
|
|
1867
2329
|
if (qi.dialect.jsonPathSupport === 'native')
|
|
1868
2330
|
return nativeForm ?? path.map(String);
|
|
1869
2331
|
return `$${path
|
|
1870
|
-
.map((seg) => typeof seg === 'number' || /^\d+$/.test(String(seg)) ? `[${seg}]` :
|
|
2332
|
+
.map((seg) => typeof seg === 'number' || /^\d+$/.test(String(seg)) ? `[${seg}]` : `.${JSON.stringify(String(seg))}`)
|
|
1871
2333
|
.join('')}`;
|
|
1872
2334
|
}
|
|
1873
2335
|
/**
|
|
@@ -1878,7 +2340,7 @@ export function jsonPathParam(qi, path, nativeForm) {
|
|
|
1878
2340
|
* SQL Server have no `::` operator) as a float cast.
|
|
1879
2341
|
*/
|
|
1880
2342
|
export function castJsonNumeric(qi, extract) {
|
|
1881
|
-
if (qi.dialect.
|
|
2343
|
+
if (qi.dialect.capabilities.jsonExactNumericCast)
|
|
1882
2344
|
return `(${extract})::numeric`;
|
|
1883
2345
|
return qi.dialect.castAggregate ? qi.dialect.castAggregate(`(${extract})`, 'float') : `(${extract})::numeric`;
|
|
1884
2346
|
}
|
|
@@ -1888,6 +2350,7 @@ export function castJsonNumeric(qi, extract) {
|
|
|
1888
2350
|
*/
|
|
1889
2351
|
export function buildArrayFilterClauses(qi, column, filter, params, pgType) {
|
|
1890
2352
|
requireArrayColumns(qi);
|
|
2353
|
+
assertArrayFilter(filter, column);
|
|
1891
2354
|
const clauses = [];
|
|
1892
2355
|
const elementType = getArrayElementType(qi, pgType);
|
|
1893
2356
|
if (filter.has !== undefined) {
|