turbine-orm 0.35.0 → 0.36.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.
Files changed (68) hide show
  1. package/README.md +18 -16
  2. package/dist/cjs/cli/index.js +109 -16
  3. package/dist/cjs/cli/migrate.js +78 -3
  4. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  5. package/dist/cjs/cli/studio.js +333 -22
  6. package/dist/cjs/cli/ui.js +7 -1
  7. package/dist/cjs/dialect.js +1 -1
  8. package/dist/cjs/generate.js +23 -2
  9. package/dist/cjs/index.js +2 -1
  10. package/dist/cjs/mssql.js +22 -5
  11. package/dist/cjs/powdb.js +41 -1
  12. package/dist/cjs/powql.js +80 -25
  13. package/dist/cjs/query/aggregates.js +683 -0
  14. package/dist/cjs/query/batched-loader.js +2 -0
  15. package/dist/cjs/query/builder.js +297 -4504
  16. package/dist/cjs/query/filters.js +12 -0
  17. package/dist/cjs/query/relations.js +1698 -0
  18. package/dist/cjs/query/where-compile.js +180 -0
  19. package/dist/cjs/query/where.js +1491 -0
  20. package/dist/cjs/query/writes.js +680 -0
  21. package/dist/cjs/schema-builder.js +6 -0
  22. package/dist/cjs/schema-metadata.js +4 -0
  23. package/dist/cjs/schema-sql.js +265 -3
  24. package/dist/cjs/sqlite.js +1 -1
  25. package/dist/cli/index.d.ts +8 -2
  26. package/dist/cli/index.js +111 -18
  27. package/dist/cli/migrate.d.ts +24 -1
  28. package/dist/cli/migrate.js +77 -3
  29. package/dist/cli/studio-ui.generated.js +1 -1
  30. package/dist/cli/studio.d.ts +46 -13
  31. package/dist/cli/studio.js +331 -23
  32. package/dist/cli/ui.js +7 -1
  33. package/dist/dialect.d.ts +15 -6
  34. package/dist/dialect.js +1 -1
  35. package/dist/generate.js +23 -2
  36. package/dist/index.d.ts +1 -1
  37. package/dist/index.js +1 -1
  38. package/dist/mssql.js +22 -5
  39. package/dist/powdb.d.ts +20 -0
  40. package/dist/powdb.js +40 -0
  41. package/dist/powql.d.ts +33 -1
  42. package/dist/powql.js +80 -25
  43. package/dist/query/aggregates.d.ts +74 -0
  44. package/dist/query/aggregates.js +641 -0
  45. package/dist/query/batched-loader.d.ts +6 -0
  46. package/dist/query/batched-loader.js +2 -0
  47. package/dist/query/builder.d.ts +62 -829
  48. package/dist/query/builder.js +302 -4509
  49. package/dist/query/deferred.d.ts +7 -0
  50. package/dist/query/filters.d.ts +7 -0
  51. package/dist/query/filters.js +11 -0
  52. package/dist/query/relations.d.ts +441 -0
  53. package/dist/query/relations.js +1627 -0
  54. package/dist/query/types.d.ts +15 -0
  55. package/dist/query/where-compile.d.ts +139 -0
  56. package/dist/query/where-compile.js +175 -0
  57. package/dist/query/where.d.ts +494 -0
  58. package/dist/query/where.js +1431 -0
  59. package/dist/query/writes.d.ts +131 -0
  60. package/dist/query/writes.js +626 -0
  61. package/dist/schema-builder.d.ts +18 -3
  62. package/dist/schema-builder.js +6 -0
  63. package/dist/schema-metadata.js +4 -0
  64. package/dist/schema-sql.d.ts +60 -3
  65. package/dist/schema-sql.js +261 -4
  66. package/dist/schema.d.ts +10 -0
  67. package/dist/sqlite.js +1 -1
  68. package/package.json +2 -2
@@ -0,0 +1,1431 @@
1
+ /**
2
+ * turbine-orm: WHERE-clause compilation (extracted from builder.ts)
3
+ *
4
+ * The whole WHERE web: the top-level build/collect/fingerprint trio, the
5
+ * table-scoped trio for relation-filter EXISTS sub-wheres and relation
6
+ * `with`-clause wheres, the leaf JSON/array/vector/text-search clause builders,
7
+ * operator-clause + column-reference compilation, and the client-level
8
+ * global-filter helpers. All functions take a {@link BuilderCtx} as their first
9
+ * argument: the privacy-preserving view of the owning {@link QueryInterface}
10
+ * instance (built once in its constructor) exposing exactly the class-resident
11
+ * primitives this module needs. See builder.ts for the thin delegating methods.
12
+ */
13
+ import { UnsupportedFeatureError, ValidationError } from '../errors.js';
14
+ import { camelToSnake } from '../schema.js';
15
+ import { assertBindableEqualsOperand, findArrayUniqueKey, findJsonUniqueKey, isArrayFilter, isColumnRef, isJsonFilter, isUnmatchedPlainObject, isWhereOperator, JSON_RANGE_OPERATORS, VECTOR_DISTANCE_COMPARATORS, VECTOR_METRIC_OPERATORS, validateTextSearchConfig, } from './filters.js';
16
+ import { escapeLike, OPERATOR_KEYS } from './utils.js';
17
+ import { classifyScalarForSql, fingerprintScalarToken, walkWhere, } from './where-compile.js';
18
+ /**
19
+ * Produce a value-invariant fingerprint of a where clause.
20
+ * Same keys + same operator shapes + same combinator structure => same string.
21
+ * Different values (e.g. id=1 vs id=999) => identical fingerprint.
22
+ *
23
+ * @internal Exposed as package-private for testing via class access.
24
+ */
25
+ export function fingerprintWhere(qi, where) {
26
+ const parts = [];
27
+ for (const event of walkWhere(qi.whereHost, where)) {
28
+ switch (event.kind) {
29
+ case 'or':
30
+ parts.push(`OR[${event.conditions.map((cond) => fingerprintWhere(qi, cond)).join(',')}]`);
31
+ break;
32
+ case 'and':
33
+ parts.push(`AND[${event.conditions.map((cond) => fingerprintWhere(qi, cond)).join(',')}]`);
34
+ break;
35
+ case 'not':
36
+ parts.push(`NOT(${fingerprintWhere(qi, event.condition)})`);
37
+ break;
38
+ case 'relation':
39
+ // { posts: { some: { published: true } } } → `posts:{some(...)}`
40
+ parts.push(`${event.key}:{${fingerprintRelationParts(qi, event.relDef, event.filterObj).join(',')}}`);
41
+ break;
42
+ case 'scalar':
43
+ // Column-blind scalar token (see fingerprintScalarToken): the value's
44
+ // shape alone distinguishes the SQL, so no column lookup is needed.
45
+ parts.push(`${event.key}:${fingerprintScalarToken(event.value)}`);
46
+ break;
47
+ }
48
+ }
49
+ return parts.join('&');
50
+ }
51
+ /**
52
+ * Fingerprint the present branches of a normalized relation filter, in the
53
+ * fixed order some→every→none→is→isNot. A `null` branch tokenizes as
54
+ * `<branch>(null)`; a present branch recurses through
55
+ * {@link fingerprintRelFilter} so the FULL inner shape is captured (two
56
+ * different sub-wheres must never collide on one cached SQL text).
57
+ */
58
+ export function fingerprintRelationParts(qi, relDef, filterObj) {
59
+ const relParts = [];
60
+ if (filterObj.some !== undefined)
61
+ relParts.push(filterObj.some === null
62
+ ? 'some(null)'
63
+ : `some(${fingerprintRelFilter(qi, relDef.to, filterObj.some)})`);
64
+ if (filterObj.every !== undefined)
65
+ relParts.push(filterObj.every === null
66
+ ? 'every(null)'
67
+ : `every(${fingerprintRelFilter(qi, relDef.to, filterObj.every)})`);
68
+ if (filterObj.none !== undefined)
69
+ relParts.push(filterObj.none === null
70
+ ? 'none(null)'
71
+ : `none(${fingerprintRelFilter(qi, relDef.to, filterObj.none)})`);
72
+ if (filterObj.is !== undefined)
73
+ relParts.push(filterObj.is === null
74
+ ? 'is(null)'
75
+ : `is(${fingerprintRelFilter(qi, relDef.to, filterObj.is)})`);
76
+ if (filterObj.isNot !== undefined)
77
+ relParts.push(filterObj.isNot === null
78
+ ? 'isNot(null)'
79
+ : `isNot(${fingerprintRelFilter(qi, relDef.to, filterObj.isNot)})`);
80
+ return relParts;
81
+ }
82
+ /**
83
+ * Fingerprint a relation filter sub-where for some/every/none. Thin wrapper
84
+ * over the unified {@link fingerprintScopedWhere}. When the target table is
85
+ * unknown, an empty-relations host makes every key scalar (matching the old
86
+ * `meta?.relations` short-circuit).
87
+ */
88
+ export function fingerprintRelFilter(qi, targetTable, subWhere) {
89
+ const meta = qi.schema.tables[targetTable];
90
+ const host = meta ? scopedWhereHost(qi, meta) : emptyRelationsHost(qi, targetTable);
91
+ return fingerprintScopedWhere(qi, host, subWhere);
92
+ }
93
+ /**
94
+ * Walk a where clause and push ONLY values into `params`, in the EXACT same
95
+ * order that `buildWhereClause` pushes them. Used on cache hit to fill params
96
+ * without rebuilding SQL.
97
+ *
98
+ * @internal Exposed as package-private for testing.
99
+ */
100
+ export function collectWhereParams(qi, where, params) {
101
+ // ONE canonical walk (shared with fingerprintWhere + buildWhereClause), so
102
+ // the key order + combinator structure cannot drift out of lockstep.
103
+ for (const event of walkWhere(qi.whereHost, where)) {
104
+ switch (event.kind) {
105
+ case 'or':
106
+ case 'and':
107
+ for (const cond of event.conditions)
108
+ collectWhereParams(qi, cond, params);
109
+ break;
110
+ case 'not':
111
+ collectWhereParams(qi, event.condition, params);
112
+ break;
113
+ case 'relation':
114
+ collectRelationFilterParams(qi, event.relDef, event.filterObj, params);
115
+ break;
116
+ case 'scalar':
117
+ collectScalarParams(qi, event.key, event.value, params);
118
+ break;
119
+ }
120
+ }
121
+ }
122
+ /**
123
+ * Push a scalar WHERE value's params, mirroring {@link buildScalarClause}'s
124
+ * emissions exactly. Both resolve the value's shape via the shared
125
+ * {@link classifyScalarForSql}, so a cache HIT binds each `$N` to the value
126
+ * the cached SQL expects. A JSON/array-shaped value on a non-JSON/array column
127
+ * (`jsonThrow`/`arrayThrow`) falls through to the equality path here, the
128
+ * same fall-through the collect path has always taken (the build path's typed
129
+ * error there is only reachable on a MISS, before anything is cached).
130
+ */
131
+ export function collectScalarParams(qi, key, value, params) {
132
+ const rawColumn = qi.toColumn(key);
133
+ const cls = classifyScalarForSql(qi.whereHost, rawColumn, value);
134
+ switch (cls.kind) {
135
+ case 'null':
136
+ // IS NULL is parameterless.
137
+ return;
138
+ case 'vector':
139
+ // Validate the same way the build path does so the collect path never
140
+ // diverges (it would throw before any param was pushed).
141
+ vectorOperator(qi, key, rawColumn, value.distance.metric);
142
+ collectVectorFilterParams(qi, key, rawColumn, value, params);
143
+ return;
144
+ case 'json':
145
+ collectJsonFilterParams(qi, value, params, qi.q(rawColumn));
146
+ return;
147
+ case 'array':
148
+ collectArrayFilterParams(qi, value, params);
149
+ return;
150
+ case 'textsearch':
151
+ params.push(value.search);
152
+ return;
153
+ case 'operator':
154
+ collectOperatorParams(qi, rawColumn, value, params, {
155
+ meta: qi.tableMeta,
156
+ table: qi.table,
157
+ prefix: '',
158
+ });
159
+ return;
160
+ default:
161
+ // 'equality' | 'jsonThrow' | 'arrayThrow': same strict validation as
162
+ // the build path, so a cache hit can never silently bind a
163
+ // misspelled-operator object.
164
+ assertBindableEqualityValue(qi, rawColumn, value, getColumnPgType(qi, rawColumn), qi.table);
165
+ params.push(value);
166
+ return;
167
+ }
168
+ }
169
+ /**
170
+ * Param-collect mirror of {@link buildRelationFilter} for one relation-filter
171
+ * object (`{ some/every/none/is/isNot }`, already normalized). Pushes, per
172
+ * present branch and in the canonical order some→none→every→is→isNot, the
173
+ * branch's sub-where params THEN the target table's global-filter params —
174
+ * exactly the order buildRelationFilter emits. When no global filter applies
175
+ * the gf calls are no-ops, so this stays byte-identical to the pre-0.28 path.
176
+ * Shared by every collect site that mirrors buildRelationFilter
177
+ * (collectWhereParams, collectRelFilterParams, collectAliasWhereParams).
178
+ */
179
+ export function collectRelationFilterParams(qi, relDef, filterObj, params) {
180
+ const target = relDef.to;
181
+ if (filterObj.some !== undefined && filterObj.some !== null) {
182
+ collectRelFilterParams(qi, target, filterObj.some, params);
183
+ collectTargetGlobalFilterExists(qi, target, params);
184
+ }
185
+ if (filterObj.none !== undefined && filterObj.none !== null) {
186
+ collectRelFilterParams(qi, target, filterObj.none, params);
187
+ collectTargetGlobalFilterExists(qi, target, params);
188
+ }
189
+ if (filterObj.every !== undefined && filterObj.every !== null) {
190
+ // gf is only emitted (build) when the `every` sub-where compiles to a
191
+ // filter — otherwise `every` is trivially true and no subquery is built.
192
+ if (buildSubWhereForRelation(qi, target, filterObj.every, []) !== null) {
193
+ collectRelFilterParams(qi, target, filterObj.every, params);
194
+ collectTargetGlobalFilterExists(qi, target, params);
195
+ }
196
+ }
197
+ if (filterObj.is !== undefined) {
198
+ if (filterObj.is !== null)
199
+ collectRelFilterParams(qi, target, filterObj.is, params);
200
+ collectTargetGlobalFilterExists(qi, target, params);
201
+ }
202
+ if (filterObj.isNot !== undefined) {
203
+ if (filterObj.isNot !== null)
204
+ collectRelFilterParams(qi, target, filterObj.isNot, params);
205
+ collectTargetGlobalFilterExists(qi, target, params);
206
+ }
207
+ }
208
+ export function collectRelFilterParams(qi, targetTable, subWhere, params) {
209
+ const meta = qi.schema.tables[targetTable];
210
+ if (!meta)
211
+ return;
212
+ collectScopedWhereParams(qi, relationWhereScope(qi, targetTable, meta), subWhere, params);
213
+ }
214
+ /**
215
+ * Collect params from operator clauses. Mirrors buildOperatorClauses:
216
+ * {@link ColumnRef} values compile into the SQL text, so they push NOTHING -
217
+ * but they re-run the same validation (unknown ref / insensitive mode) so a
218
+ * warmed cache can never skip a check the build path enforces.
219
+ */
220
+ export function collectOperatorParams(qi, column, op, params, refCtx) {
221
+ const skipRef = (v) => {
222
+ if (!isColumnRef(v))
223
+ return false;
224
+ if (refCtx)
225
+ resolveColumnRef(qi, v, refCtx, op.mode);
226
+ return true;
227
+ };
228
+ if (op.equals !== undefined && op.equals !== null && !skipRef(op.equals)) {
229
+ assertBindableEqualsOperand(op.equals, `"${column}"`);
230
+ params.push(op.equals);
231
+ }
232
+ if (op.gt !== undefined && !skipRef(op.gt))
233
+ params.push(op.gt);
234
+ if (op.gte !== undefined && !skipRef(op.gte))
235
+ params.push(op.gte);
236
+ if (op.lt !== undefined && !skipRef(op.lt))
237
+ params.push(op.lt);
238
+ if (op.lte !== undefined && !skipRef(op.lte))
239
+ params.push(op.lte);
240
+ if (op.not !== undefined && op.not !== null && !skipRef(op.not))
241
+ params.push(op.not);
242
+ if (op.in !== undefined)
243
+ params.push(qi.inParam(op.in));
244
+ if (op.notIn !== undefined)
245
+ params.push(qi.inParam(op.notIn));
246
+ if (op.contains !== undefined)
247
+ params.push(`%${escapeLike(op.contains)}%`);
248
+ if (op.startsWith !== undefined)
249
+ params.push(`${escapeLike(op.startsWith)}%`);
250
+ if (op.endsWith !== undefined)
251
+ params.push(`%${escapeLike(op.endsWith)}`);
252
+ }
253
+ /**
254
+ * Collect params from JSON filter. Mirrors buildJsonFilterClauses exactly:
255
+ * the `path` is bound at most once (its placeholder is shared by every
256
+ * extraction clause), then equals/contains/hasKey values, then the range
257
+ * comparison values in {@link JSON_RANGE_OPERATORS} order.
258
+ */
259
+ export function collectJsonFilterParams(qi, filter, params, column) {
260
+ let pathPushed = false;
261
+ const pushPathOnce = () => {
262
+ if (!pathPushed) {
263
+ // Only reached when a path-requiring clause validated filter.path.
264
+ params.push(jsonPathParam(qi, filter.path, filter.path));
265
+ pathPushed = true;
266
+ }
267
+ };
268
+ if (filter.path !== undefined && filter.equals !== undefined) {
269
+ pushPathOnce();
270
+ params.push(String(filter.equals));
271
+ }
272
+ else if (filter.equals !== undefined) {
273
+ params.push(JSON.stringify(filter.equals));
274
+ }
275
+ if (filter.contains !== undefined) {
276
+ params.push(JSON.stringify(filter.contains));
277
+ }
278
+ if (filter.hasKey !== undefined) {
279
+ params.push(filter.hasKey);
280
+ }
281
+ for (const { value } of jsonRangeEntries(qi, filter, column)) {
282
+ pushPathOnce();
283
+ params.push(value);
284
+ }
285
+ }
286
+ /** Collect params from array filter. Mirrors buildArrayFilterClauses. */
287
+ export function collectArrayFilterParams(_qi, filter, params) {
288
+ if (filter.has !== undefined)
289
+ params.push(filter.has);
290
+ if (filter.hasEvery !== undefined)
291
+ params.push(filter.hasEvery);
292
+ if (filter.hasSome !== undefined)
293
+ params.push(filter.hasSome);
294
+ // isEmpty has no params (IS NULL / IS NOT NULL)
295
+ }
296
+ /**
297
+ * Collect params for a vector distance WHERE filter. Mirrors
298
+ * {@link buildVectorFilterClauses}: the `$n::vector` query vector first, then
299
+ * the comparison threshold(s).
300
+ */
301
+ export function collectVectorFilterParams(qi, field, rawColumn, filter, params) {
302
+ const dist = filter.distance;
303
+ pushVectorParam(qi, field, rawColumn, dist.to, params);
304
+ for (const cmp of Object.keys(VECTOR_DISTANCE_COMPARATORS)) {
305
+ const threshold = dist[cmp];
306
+ if (threshold !== undefined)
307
+ params.push(threshold);
308
+ }
309
+ }
310
+ /** Build WHERE clause from a where object (supports operators, NULL, OR) */
311
+ export function buildWhere(qi, where) {
312
+ const params = [];
313
+ const clause = buildWhereClause(qi, where, params);
314
+ if (!clause)
315
+ return { sql: '', params: [] };
316
+ return { sql: ` WHERE ${clause}`, params };
317
+ }
318
+ /**
319
+ * Resolve the configured global filter for `table`, evaluating a function
320
+ * filter, honoring the active query's `skipGlobalFilters`. Returns `null` when
321
+ * no filter applies, the query opted out, or the filter is empty.
322
+ */
323
+ export function resolveGlobalFilter(qi, table, skip = qi.currentSkip) {
324
+ const filters = qi.globalFilters;
325
+ if (!filters)
326
+ return null;
327
+ if (skip === true)
328
+ return null;
329
+ if (Array.isArray(skip) && skip.includes(table))
330
+ return null;
331
+ const raw = filters[table];
332
+ if (raw === undefined)
333
+ return null;
334
+ const resolved = typeof raw === 'function' ? raw() : raw;
335
+ if (resolved === null || resolved === undefined)
336
+ return null;
337
+ const obj = resolved;
338
+ // An all-undefined filter (e.g. `{ tenantId: undefined }`) contributes
339
+ // nothing — treat it as absent so it never emits a dangling clause.
340
+ if (Object.keys(obj).every((k) => obj[k] === undefined))
341
+ return null;
342
+ return obj;
343
+ }
344
+ /**
345
+ * AND-merge this table's resolved global filter into a user `where`. Either
346
+ * side may be absent. When no filter applies the user where is returned by
347
+ * reference, so fingerprints/SQL stay byte-identical to the pre-0.28 path.
348
+ */
349
+ export function mergeGlobalFilter(qi, userWhere) {
350
+ const gf = resolveGlobalFilter(qi, qi.table);
351
+ if (!gf)
352
+ return userWhere;
353
+ if (userWhere === undefined)
354
+ return gf;
355
+ return { AND: [userWhere, gf] };
356
+ }
357
+ /**
358
+ * SQL clause for `targetTable`'s global filter rendered against `alias`
359
+ * (relation subqueries, `_count`, relation `orderBy`). Pushes its params to
360
+ * `params`; returns `''` when no filter applies. Mirror:
361
+ * {@link collectTargetGlobalFilterAlias}.
362
+ */
363
+ export function targetGlobalFilterAlias(qi, targetTable, alias, params) {
364
+ const gf = resolveGlobalFilter(qi, targetTable);
365
+ if (!gf)
366
+ return '';
367
+ const meta = qi.schema.tables[targetTable];
368
+ if (!meta)
369
+ return '';
370
+ return buildAliasWhere(qi, targetTable, meta, alias, gf, params) ?? '';
371
+ }
372
+ /** Param-collect mirror of {@link targetGlobalFilterAlias}. */
373
+ export function collectTargetGlobalFilterAlias(qi, targetTable, params) {
374
+ const gf = resolveGlobalFilter(qi, targetTable);
375
+ if (!gf)
376
+ return;
377
+ const meta = qi.schema.tables[targetTable];
378
+ if (!meta)
379
+ return;
380
+ collectAliasWhereParams(qi, targetTable, meta, gf, params);
381
+ }
382
+ /**
383
+ * SQL clause for `targetTable`'s global filter rendered against the bare
384
+ * (unaliased) table name — the form used inside relation-filter `EXISTS`
385
+ * subqueries. Pushes its params; `''` when none. Mirror:
386
+ * {@link collectTargetGlobalFilterExists}.
387
+ */
388
+ export function targetGlobalFilterExists(qi, targetTable, params) {
389
+ const gf = resolveGlobalFilter(qi, targetTable);
390
+ if (!gf)
391
+ return '';
392
+ return buildSubWhereForRelation(qi, targetTable, gf, params) ?? '';
393
+ }
394
+ /** Param-collect mirror of {@link targetGlobalFilterExists}. */
395
+ export function collectTargetGlobalFilterExists(qi, targetTable, params) {
396
+ const gf = resolveGlobalFilter(qi, targetTable);
397
+ if (!gf)
398
+ return;
399
+ collectRelFilterParams(qi, targetTable, gf, params);
400
+ }
401
+ /**
402
+ * Value-invariant SQL-cache-key segment for the active global-filter
403
+ * environment. Relation-subquery / relation-filter / `_count` / relation-
404
+ * `orderBy` global filters are rendered at build time but their SHAPE is not
405
+ * otherwise in the where/with fingerprint, so this segment guards the cache:
406
+ * two different filter shapes never collide on one cached SQL text, while two
407
+ * function-filter results of the SAME shape (differing only in values) share
408
+ * the entry and bind their own params. Empty (`''`) when no filter applies, so
409
+ * cache keys stay byte-identical when the feature is unused.
410
+ */
411
+ export function globalFilterCacheSegment(qi) {
412
+ const filters = qi.globalFilters;
413
+ if (!filters)
414
+ return '';
415
+ const parts = [];
416
+ for (const table of Object.keys(filters).sort()) {
417
+ // Function filters for OTHER tables may be request-scoped closures that
418
+ // throw outside their own context; a query on an unrelated table must not
419
+ // break on them. A throwing filter can't have contributed SQL to this
420
+ // query either (merging it would have thrown first), so a constant
421
+ // marker keeps the key shape-distinct without evaluating it.
422
+ let gf;
423
+ try {
424
+ gf = resolveGlobalFilter(qi, table);
425
+ }
426
+ catch {
427
+ parts.push(`${table}:!`);
428
+ continue;
429
+ }
430
+ if (gf) {
431
+ // Fingerprint with the FILTER's own table host, not the root table's:
432
+ // classifying another table's filter with the root host can mistake a
433
+ // relation filter for a scalar object and collide two shapes that
434
+ // compile to different SQL. Unknown meta falls back to the root host
435
+ // (same as scoped sub-wheres against an unknown target).
436
+ const meta = qi.schema.tables[table];
437
+ const host = meta && meta.name !== qi.tableMeta.name ? scopedWhereHost(qi, meta) : undefined;
438
+ parts.push(`${table}:${host ? fingerprintScopedWhere(qi, host, gf) : fingerprintWhere(qi, gf)}`);
439
+ }
440
+ }
441
+ return parts.length ? `|gf=${parts.join(';')}` : '';
442
+ }
443
+ /**
444
+ * True when the USER-supplied `where` compiles to no predicate (`{}`,
445
+ * `{ id: undefined }`, `{ OR: [{ a: undefined }] }`, …). This is the exact
446
+ * signal the empty-`where` guard needs — the compiled emptiness, NOT the
447
+ * fingerprint (which is non-empty for an all-undefined `OR`/`AND`). It ignores
448
+ * any configured global filter, so a global filter never lets an unguarded
449
+ * mass mutation through.
450
+ */
451
+ export function userPredicateIsEmpty(qi, userWhere) {
452
+ const throwaway = [];
453
+ return buildWhereClause(qi, userWhere, throwaway) === null;
454
+ }
455
+ export function assertMutationHasPredicate(qi, operation, whereSql, allowFullTableScan) {
456
+ if (whereSql.length > 0)
457
+ return;
458
+ if (allowFullTableScan === true)
459
+ return;
460
+ throw new ValidationError(`[turbine] ${operation} on "${qi.table}" refused: the \`where\` clause is empty. ` +
461
+ `Pass \`allowFullTableScan: true\` to opt in, or check that your filter values are defined.`);
462
+ }
463
+ /**
464
+ * Build the inner WHERE expression (without the WHERE keyword).
465
+ * Returns null if no conditions exist.
466
+ * Supports: equality, operators, NULL, OR, AND, NOT, relation filters (some/every/none).
467
+ */
468
+ export function buildWhereClause(qi, where, params) {
469
+ const andClauses = [];
470
+ // ONE canonical walk (shared with fingerprintWhere + collectWhereParams).
471
+ for (const event of walkWhere(qi.whereHost, where)) {
472
+ switch (event.kind) {
473
+ case 'or': {
474
+ const orClauses = [];
475
+ for (const orCond of event.conditions) {
476
+ const sub = buildWhereClause(qi, orCond, params);
477
+ if (sub)
478
+ orClauses.push(sub);
479
+ }
480
+ if (orClauses.length > 0)
481
+ andClauses.push(`(${orClauses.join(' OR ')})`);
482
+ break;
483
+ }
484
+ case 'and':
485
+ for (const andCond of event.conditions) {
486
+ const sub = buildWhereClause(qi, andCond, params);
487
+ if (sub)
488
+ andClauses.push(sub);
489
+ }
490
+ break;
491
+ case 'not': {
492
+ const sub = buildWhereClause(qi, event.condition, params);
493
+ if (sub)
494
+ andClauses.push(`NOT (${sub})`);
495
+ break;
496
+ }
497
+ case 'relation': {
498
+ // { posts: { some: { published: true } } } → EXISTS / NOT EXISTS
499
+ const relClause = buildRelationFilter(qi, event.key, event.relDef, event.filterObj, params);
500
+ if (relClause)
501
+ andClauses.push(relClause);
502
+ break;
503
+ }
504
+ case 'scalar':
505
+ buildScalarClause(qi, event.key, event.value, params, andClauses);
506
+ break;
507
+ }
508
+ }
509
+ if (andClauses.length === 0)
510
+ return null;
511
+ return andClauses.join(' AND ');
512
+ }
513
+ /**
514
+ * Emit the SQL clause(s) for one scalar WHERE key onto `andClauses`, pushing
515
+ * any params. The shape decision comes from the shared
516
+ * {@link classifyScalarForSql} so {@link collectScalarParams} pushes an
517
+ * identical param list on a cache hit. The `*Throw` branches preserve the
518
+ * strict-validation errors for a JSON/array operator on the wrong column type.
519
+ */
520
+ export function buildScalarClause(qi, key, value, params, andClauses) {
521
+ const rawColumn = qi.toColumn(key);
522
+ const column = qi.q(rawColumn);
523
+ const cls = classifyScalarForSql(qi.whereHost, rawColumn, value);
524
+ switch (cls.kind) {
525
+ case 'null':
526
+ andClauses.push(`${column} IS NULL`);
527
+ return;
528
+ case 'vector':
529
+ andClauses.push(...buildVectorFilterClauses(qi, key, rawColumn, value, params));
530
+ return;
531
+ case 'json':
532
+ andClauses.push(...buildJsonFilterClauses(qi, column, value, params));
533
+ return;
534
+ case 'jsonThrow':
535
+ // A JSON-only operator on a non-JSON column was almost certainly a typo
536
+ // or schema mismatch. `contains`/`equals` are shared with WhereOperator
537
+ // (LIKE / equality), so only shape-unique keys reach here.
538
+ throw new ValidationError(`[turbine] Column "${rawColumn}" on table "${qi.table}" is not a JSON column ` +
539
+ `(actual type: ${getColumnPgType(qi, rawColumn)}); cannot apply JSON operator '${cls.jsonKey}'.`);
540
+ case 'array':
541
+ andClauses.push(...buildArrayFilterClauses(qi, column, value, params, cls.colType));
542
+ return;
543
+ case 'arrayThrow':
544
+ throw new ValidationError(`[turbine] Column "${rawColumn}" on table "${qi.table}" is not an array column ` +
545
+ `(actual type: ${getColumnPgType(qi, rawColumn)}); cannot apply array operator '${cls.arrayKey}'.`);
546
+ case 'textsearch':
547
+ andClauses.push(buildTextSearchClause(qi, column, value, params));
548
+ return;
549
+ case 'operator':
550
+ andClauses.push(...buildOperatorClauses(qi, column, value, params, {
551
+ meta: qi.tableMeta,
552
+ table: qi.table,
553
+ prefix: '',
554
+ }));
555
+ return;
556
+ default:
557
+ // 'equality': a plain object literal that matched no known filter shape
558
+ // is almost always a misspelled operator (`startWith` for `startsWith`);
559
+ // the guard also runs on the cache-hit param-collect path.
560
+ assertBindableEqualityValue(qi, rawColumn, value, getColumnPgType(qi, rawColumn), qi.table);
561
+ params.push(value);
562
+ andClauses.push(`${column} = ${qi.p(params.length)}`);
563
+ return;
564
+ }
565
+ }
566
+ /**
567
+ * A {@link WhereHost} with no relations — used to fingerprint a sub-where
568
+ * whose target table is unknown (`schema.tables[t]` miss). `walkWhere` reads
569
+ * only `tableMeta.relations`, so every key falls to the scalar path, matching
570
+ * the pre-unification `meta?.relations` short-circuit.
571
+ */
572
+ export function emptyRelationsHost(qi, table) {
573
+ return {
574
+ tableMeta: { name: table, relations: {} },
575
+ normalizeRelationFilter: (relDef, filterObj) => normalizeRelationFilter(qi, relDef, filterObj),
576
+ getColumnPgType: () => 'text',
577
+ isJsonColumnType: (colType) => isJsonColumnType(qi, colType),
578
+ };
579
+ }
580
+ export function scopedWhereHost(qi, meta) {
581
+ let host = qi.scopedHostCache.get(meta.name);
582
+ if (!host) {
583
+ host = {
584
+ tableMeta: meta,
585
+ normalizeRelationFilter: (relDef, filterObj) => normalizeRelationFilter(qi, relDef, filterObj),
586
+ getColumnPgType: (column) => pgTypeForColumn(qi, meta, column),
587
+ isJsonColumnType: (colType) => isJsonColumnType(qi, colType),
588
+ };
589
+ qi.scopedHostCache.set(meta.name, host);
590
+ }
591
+ return host;
592
+ }
593
+ /** Build the scope for a relation-filter EXISTS sub-where over the bare target table. */
594
+ export function relationWhereScope(qi, targetTable, meta) {
595
+ return {
596
+ meta,
597
+ table: targetTable,
598
+ qualifier: `${qi.q(targetTable)}.`,
599
+ relationParent: targetTable,
600
+ host: scopedWhereHost(qi, meta),
601
+ unknownColumn: (field) => new ValidationError(`[turbine] Unknown field "${field}" in relation filter for table "${targetTable}". ` +
602
+ `Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`),
603
+ };
604
+ }
605
+ /** Build the scope for a relation `with`-clause `where` compiled against `alias`. */
606
+ export function aliasWhereScope(qi, targetTable, meta, alias) {
607
+ return {
608
+ meta,
609
+ table: targetTable,
610
+ qualifier: `${alias}.`,
611
+ relationParent: alias,
612
+ host: scopedWhereHost(qi, meta),
613
+ unknownColumn: (field) => new ValidationError(`[turbine] Unknown column "${field}" in where for table "${targetTable}"`),
614
+ };
615
+ }
616
+ /**
617
+ * Compile a scoped sub-where to SQL. Serves BOTH the relation-filter EXISTS
618
+ * body ({@link buildSubWhereForRelation}) and the relation `with`-clause
619
+ * `where` ({@link buildAliasWhere}) — the emitted SQL is byte-identical to the
620
+ * former hand-mirrored walkers, since it renders the same clauses in the same
621
+ * ({@link walkWhere}-canonical) key order.
622
+ */
623
+ export function buildScopedWhere(qi, scope, where, params) {
624
+ const clauses = [];
625
+ for (const event of walkWhere(scope.host, where)) {
626
+ switch (event.kind) {
627
+ case 'or':
628
+ case 'and': {
629
+ const parts = event.conditions
630
+ .map((cond) => buildScopedWhere(qi, scope, cond, params))
631
+ .filter((s) => s !== null)
632
+ .map((s) => `(${s})`);
633
+ if (parts.length > 0)
634
+ clauses.push(`(${parts.join(event.kind === 'or' ? ' OR ' : ' AND ')})`);
635
+ break;
636
+ }
637
+ case 'not': {
638
+ const sub = buildScopedWhere(qi, scope, event.condition, params);
639
+ if (sub)
640
+ clauses.push(`NOT (${sub})`);
641
+ break;
642
+ }
643
+ case 'relation': {
644
+ const c = buildRelationFilter(qi, event.key, event.relDef, event.filterObj, params, scope.relationParent);
645
+ if (c)
646
+ clauses.push(c);
647
+ break;
648
+ }
649
+ case 'scalar':
650
+ buildScopedScalarClause(qi, scope, event.key, event.value, params, clauses);
651
+ break;
652
+ }
653
+ }
654
+ return clauses.length > 0 ? clauses.join(' AND ') : null;
655
+ }
656
+ /**
657
+ * Emit the SQL clause(s) for one scalar key of a scoped sub-where. Reproduces
658
+ * the null / JSON / array / operator / equality fall-through both former
659
+ * walkers shared (relation sub-wheres and alias wheres carry no vector or
660
+ * text-search scalar surface, so — unlike the top-level {@link buildScalarClause}
661
+ * — those shapes are not special-cased here and keep their historical
662
+ * equality-guard behavior).
663
+ */
664
+ export function buildScopedScalarClause(qi, scope, field, value, params, clauses) {
665
+ const meta = scope.meta;
666
+ const col = meta.columnMap[field] ?? camelToSnake(field);
667
+ if (!meta.allColumns.includes(col))
668
+ throw scope.unknownColumn(field);
669
+ const qCol = `${scope.qualifier}${qi.q(col)}`;
670
+ if (value === null) {
671
+ clauses.push(`${qCol} IS NULL`);
672
+ return;
673
+ }
674
+ if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
675
+ const colType = pgTypeForColumn(qi, meta, col);
676
+ if (isJsonColumnType(qi, colType)) {
677
+ clauses.push(...buildJsonFilterClauses(qi, qCol, value, params));
678
+ return;
679
+ }
680
+ const jsonKey = findJsonUniqueKey(value);
681
+ if (jsonKey) {
682
+ throw new ValidationError(`[turbine] Column "${col}" on table "${scope.table}" is not a JSON column ` +
683
+ `(actual type: ${colType}); cannot apply JSON operator '${jsonKey}'.`);
684
+ }
685
+ }
686
+ if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
687
+ const colType = pgTypeForColumn(qi, meta, col);
688
+ if (colType.startsWith('_')) {
689
+ clauses.push(...buildArrayFilterClauses(qi, qCol, value, params, colType));
690
+ return;
691
+ }
692
+ const arrayKey = findArrayUniqueKey(value);
693
+ if (arrayKey) {
694
+ throw new ValidationError(`[turbine] Column "${col}" on table "${scope.table}" is not an array column ` +
695
+ `(actual type: ${colType}); cannot apply array operator '${arrayKey}'.`);
696
+ }
697
+ }
698
+ if (isWhereOperator(value)) {
699
+ clauses.push(...buildOperatorClauses(qi, qCol, value, params, {
700
+ meta,
701
+ table: scope.table,
702
+ prefix: scope.qualifier,
703
+ }));
704
+ return;
705
+ }
706
+ assertBindableEqualityValue(qi, col, value, pgTypeForColumn(qi, meta, col), scope.table);
707
+ params.push(value);
708
+ clauses.push(`${qCol} = ${qi.p(params.length)}`);
709
+ }
710
+ /**
711
+ * Cache-hit param-collect mirror of {@link buildScopedWhere}: pushes the exact
712
+ * same params in the exact same order (driven by the same {@link walkWhere}),
713
+ * without rebuilding SQL. Serves both {@link collectRelFilterParams} and
714
+ * {@link collectAliasWhereParams}.
715
+ */
716
+ export function collectScopedWhereParams(qi, scope, where, params) {
717
+ for (const event of walkWhere(scope.host, where)) {
718
+ switch (event.kind) {
719
+ case 'or':
720
+ case 'and':
721
+ for (const cond of event.conditions)
722
+ collectScopedWhereParams(qi, scope, cond, params);
723
+ break;
724
+ case 'not':
725
+ collectScopedWhereParams(qi, scope, event.condition, params);
726
+ break;
727
+ case 'relation':
728
+ // Same some→none→every→is→isNot (each: sub-where params then target
729
+ // global-filter params) as buildRelationFilter emits.
730
+ collectRelationFilterParams(qi, event.relDef, event.filterObj, params);
731
+ break;
732
+ case 'scalar':
733
+ collectScopedScalarParams(qi, scope, event.key, event.value, params);
734
+ break;
735
+ }
736
+ }
737
+ }
738
+ /** Param-collect mirror of {@link buildScopedScalarClause}. */
739
+ export function collectScopedScalarParams(qi, scope, field, value, params) {
740
+ if (value === null)
741
+ return;
742
+ const meta = scope.meta;
743
+ const col = meta.columnMap[field] ?? camelToSnake(field);
744
+ if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
745
+ const colType = pgTypeForColumn(qi, meta, col);
746
+ if (isJsonColumnType(qi, colType)) {
747
+ collectJsonFilterParams(qi, value, params, `${qi.q(scope.table)}.${qi.q(col)}`);
748
+ return;
749
+ }
750
+ }
751
+ if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
752
+ const colType = pgTypeForColumn(qi, meta, col);
753
+ if (colType.startsWith('_')) {
754
+ collectArrayFilterParams(qi, value, params);
755
+ return;
756
+ }
757
+ }
758
+ if (isWhereOperator(value)) {
759
+ collectOperatorParams(qi, col, value, params, { meta, table: scope.table, prefix: '' });
760
+ return;
761
+ }
762
+ assertBindableEqualityValue(qi, col, value, pgTypeForColumn(qi, meta, col), scope.table);
763
+ params.push(value);
764
+ }
765
+ /**
766
+ * Value-invariant fingerprint of a scoped sub-where. Same canonical
767
+ * {@link walkWhere} as {@link fingerprintWhere}, so two shapes that compile to
768
+ * different SQL never collide on one cached SQL string. Serves both
769
+ * {@link fingerprintRelFilter} and {@link fingerprintAliasWhere}. Fingerprint
770
+ * bytes are process-local cache keys (never persisted), so their exact text
771
+ * may differ from the pre-unification walkers as long as collisions stay
772
+ * impossible.
773
+ */
774
+ export function fingerprintScopedWhere(qi, host, where) {
775
+ const parts = [];
776
+ for (const event of walkWhere(host, where)) {
777
+ switch (event.kind) {
778
+ case 'or':
779
+ parts.push(`OR[${event.conditions.map((c) => fingerprintScopedWhere(qi, host, c)).join(',')}]`);
780
+ break;
781
+ case 'and':
782
+ parts.push(`AND[${event.conditions.map((c) => fingerprintScopedWhere(qi, host, c)).join(',')}]`);
783
+ break;
784
+ case 'not':
785
+ parts.push(`NOT(${fingerprintScopedWhere(qi, host, event.condition)})`);
786
+ break;
787
+ case 'relation':
788
+ parts.push(`${event.key}:{${fingerprintRelationParts(qi, event.relDef, event.filterObj).join(',')}}`);
789
+ break;
790
+ case 'scalar':
791
+ parts.push(`${event.key}:${fingerprintScalarToken(event.value)}`);
792
+ break;
793
+ }
794
+ }
795
+ return parts.join('&');
796
+ }
797
+ /**
798
+ * Build relation filter SQL: WHERE EXISTS / NOT EXISTS subquery
799
+ * Supports: some (EXISTS), every (NOT EXISTS ... NOT), none (NOT EXISTS)
800
+ */
801
+ export function buildRelationFilter(qi, _relName, relDef, filterObj, params, parentTable) {
802
+ const targetTable = relDef.to;
803
+ const targetMeta = qi.schema.tables[targetTable];
804
+ if (!targetMeta)
805
+ return null;
806
+ const qt = qi.q(targetTable);
807
+ const qSelf = qi.q(parentTable ?? qi.table);
808
+ const clauses = [];
809
+ // Correlation: link child table to parent table (supports composite FKs)
810
+ let correlation;
811
+ if (relDef.type === 'hasMany' || relDef.type === 'hasOne') {
812
+ // parent.pk = child.fk
813
+ correlation = qi.dialect.buildCorrelation(qt, relDef.foreignKey, qSelf, relDef.referenceKey);
814
+ }
815
+ else {
816
+ // belongsTo: parent.fk = child.pk
817
+ correlation = qi.dialect.buildCorrelation(qt, relDef.referenceKey, qSelf, relDef.foreignKey);
818
+ }
819
+ // The target table's global filter (soft-delete / tenancy) restricts the
820
+ // DOMAIN of correlated rows in EVERY branch: `some`/`none`/`is`/`isNot`
821
+ // ignore filtered-out rows, and `every` quantifies over only the surviving
822
+ // rows ("every NON-deleted related row matches P"). It is ANDed into the
823
+ // correlation and its params pushed AFTER the per-branch filter — mirrored
824
+ // exactly in collectWhereParams' relation-filter branch. `qt` is the bare
825
+ // target table, matching the `FROM ${qt}` here (see targetGlobalFilterExists).
826
+ const gfAnd = () => {
827
+ const gf = targetGlobalFilterExists(qi, targetTable, params);
828
+ return gf ? ` AND ${gf}` : '';
829
+ };
830
+ // "some": EXISTS (SELECT 1 FROM target WHERE correlation AND filter AND gf)
831
+ // A `null` branch is skipped (never reaches buildSubWhereForRelation, which
832
+ // would throw on Object.keys(null)), matching collectRelationFilterParams,
833
+ // which also skips null. Unreachable via normalization today, guarded anyway.
834
+ if (filterObj.some !== undefined && filterObj.some !== null) {
835
+ const subWhere = filterObj.some;
836
+ const filterClause = buildSubWhereForRelation(qi, targetTable, subWhere, params);
837
+ const filterAnd = filterClause ? ` AND ${filterClause}` : '';
838
+ clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
839
+ }
840
+ // "none": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND filter AND gf)
841
+ if (filterObj.none !== undefined && filterObj.none !== null) {
842
+ const subWhere = filterObj.none;
843
+ const filterClause = buildSubWhereForRelation(qi, targetTable, subWhere, params);
844
+ const filterAnd = filterClause ? ` AND ${filterClause}` : '';
845
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
846
+ }
847
+ // "every": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND gf AND NOT (filter))
848
+ if (filterObj.every !== undefined && filterObj.every !== null) {
849
+ const subWhere = filterObj.every;
850
+ const filterClause = buildSubWhereForRelation(qi, targetTable, subWhere, params);
851
+ if (filterClause) {
852
+ // gf params pushed AFTER filter params (collect mirrors this order), but
853
+ // placed textually inside the domain so it restricts which rows count.
854
+ const gf = gfAnd();
855
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${gf} AND NOT (${filterClause}))`);
856
+ }
857
+ else {
858
+ // "every" with empty filter = true (all match trivially) — gf irrelevant.
859
+ }
860
+ }
861
+ // "is": EXISTS — for to-one relations (same SQL as "some").
862
+ // `is: null` = "no related row" (Prisma semantics) → NOT EXISTS.
863
+ if (filterObj.is !== undefined) {
864
+ if (filterObj.is === null) {
865
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${gfAnd()})`);
866
+ }
867
+ else {
868
+ const subWhere = filterObj.is;
869
+ const filterClause = buildSubWhereForRelation(qi, targetTable, subWhere, params);
870
+ const filterAnd = filterClause ? ` AND ${filterClause}` : '';
871
+ clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
872
+ }
873
+ }
874
+ // "isNot": NOT EXISTS — for to-one relations (same SQL as "none").
875
+ // `isNot: null` = "a related row exists" → EXISTS.
876
+ if (filterObj.isNot !== undefined) {
877
+ if (filterObj.isNot === null) {
878
+ clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${gfAnd()})`);
879
+ }
880
+ else {
881
+ const subWhere = filterObj.isNot;
882
+ const filterClause = buildSubWhereForRelation(qi, targetTable, subWhere, params);
883
+ const filterAnd = filterClause ? ` AND ${filterClause}` : '';
884
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
885
+ }
886
+ }
887
+ return clauses.length > 0 ? clauses.join(' AND ') : null;
888
+ }
889
+ /**
890
+ * Build WHERE clause conditions for a relation filter subquery.
891
+ * Uses the target table's column mapping to resolve field names.
892
+ */
893
+ export function buildSubWhereForRelation(qi, targetTable, subWhere, params) {
894
+ const meta = qi.schema.tables[targetTable];
895
+ if (!meta)
896
+ return null;
897
+ return buildScopedWhere(qi, relationWhereScope(qi, targetTable, meta), subWhere, params);
898
+ }
899
+ /**
900
+ * Resolve a column's Postgres type from an arbitrary table's metadata
901
+ * (relation targets, not just `qi.table`).
902
+ */
903
+ export function pgTypeForColumn(_qi, meta, column) {
904
+ return meta.dialectTypes?.[column] ?? meta.pgTypes?.[column] ?? 'text';
905
+ }
906
+ /**
907
+ * The Postgres enum type name for a column, when the schema knows one.
908
+ *
909
+ * Introspection stores each column's `udt_name` in `pgTypes` and every
910
+ * database enum in `schema.enums` (typname → labels); a column whose type
911
+ * matches an enum key needs an explicit `::"EnumName"` cast on its write
912
+ * binds — bulk-insert forms like `UNNEST($1::text[])` otherwise type the
913
+ * value as text and Postgres refuses the implicit text→enum coercion
914
+ * ("column X is of type Y but expression is of type text").
915
+ *
916
+ * Postgres-only by construction: gated on the active dialect being
917
+ * `postgresql` AND on `schema.enums` having entries (only PG introspection
918
+ * produces them — `defineSchema` and the other engines leave it empty), so
919
+ * SQLite/MySQL/MSSQL/PowDB output is byte-identical.
920
+ */
921
+ export function enumTypeForColumn(qi, column) {
922
+ if (qi.dialect.name !== 'postgresql')
923
+ return null;
924
+ const enums = qi.schema.enums;
925
+ if (!enums)
926
+ return null;
927
+ // Cross-schema guard (N-5): introspection records pgTypeSchema ONLY when
928
+ // the column's type lives OUTSIDE the introspected schema. A same-named
929
+ // enum in another schema must not get this schema's cast — search_path
930
+ // would resolve `::"status"` to the wrong type. Skipping the cast restores
931
+ // the pre-cast behavior for such columns. Columns without pgTypeSchema
932
+ // (same-schema types, defineSchema/legacy metadata) keep the cast.
933
+ if (qi.crossSchemaTypeColumns.has(column))
934
+ return null;
935
+ const pgType = qi.columnPgTypeMap.get(column) ?? qi.tableMeta.pgTypes?.[column];
936
+ if (!pgType || pgType.startsWith('_'))
937
+ return null;
938
+ return Object.hasOwn(enums, pgType) ? pgType : null;
939
+ }
940
+ /**
941
+ * `::"EnumName"` cast suffix for a write-bind placeholder on an enum
942
+ * column; `''` for every other column, so non-enum SQL stays byte-identical.
943
+ * The type name is an introspected identifier and is quoted via the dialect.
944
+ */
945
+ export function enumCastSuffix(qi, column) {
946
+ const enumType = enumTypeForColumn(qi, column);
947
+ return enumType ? `::${qi.q(enumType)}` : '';
948
+ }
949
+ /**
950
+ * Equality-fallthrough guard shared by every SQL-build path AND every
951
+ * cache-hit param-collect path. A plain object literal that matched no known
952
+ * filter shape on a non-JSON column is almost always a misspelled operator
953
+ * (`startWith` for `startsWith`); binding it as `col = $1` silently returns
954
+ * wrong rows. Class instances (Buffer for bytea, Decimal wrappers, ...) are
955
+ * legitimate bind values and pass through, as do objects on json/jsonb
956
+ * columns (object equality).
957
+ */
958
+ export function assertBindableEqualityValue(qi, rawColumn, value, columnPgType, table) {
959
+ if (!isUnmatchedPlainObject(value))
960
+ return;
961
+ if (isJsonColumnType(qi, columnPgType))
962
+ return;
963
+ const badKeys = Object.keys(value);
964
+ throw new ValidationError(badKeys.length === 0
965
+ ? `[turbine] Empty filter object on "${rawColumn}" for table "${table}". ` +
966
+ `Provide a value or an operator like { gt: 1 }.`
967
+ : `[turbine] Unknown operator${badKeys.length > 1 ? 's' : ''} ` +
968
+ `${badKeys.map((k) => `"${k}"`).join(', ')} on "${rawColumn}" for table "${table}". ` +
969
+ `Supported operators: ${[...OPERATOR_KEYS].join(', ')}.`);
970
+ }
971
+ /**
972
+ * Build the user-supplied `where` filter of a relation `with` clause against
973
+ * the relation's table alias. Supports the same scalar surface as the
974
+ * top-level WHERE builder — equality, IS NULL, operator objects (incl.
975
+ * `mode: 'insensitive'`), and OR/AND/NOT combinators. Unknown operator
976
+ * objects throw via {@link assertBindableEqualityValue}.
977
+ *
978
+ * Param push order MUST mirror {@link collectAliasWhereParams} exactly, or
979
+ * cache hits and pipeline batching will desync.
980
+ */
981
+ export function buildAliasWhere(qi, targetTable, targetMeta, alias, where, params) {
982
+ return buildScopedWhere(qi, aliasWhereScope(qi, targetTable, targetMeta, alias), where, params);
983
+ }
984
+ /** Mirrors {@link buildAliasWhere} param-push order for the cache-hit collect path. */
985
+ export function collectAliasWhereParams(qi, targetTable, targetMeta, where, params) {
986
+ // The alias identifier is irrelevant to param collection (it only shapes SQL
987
+ // text), so reuse the relation scope's host binding for `targetMeta`.
988
+ collectScopedWhereParams(qi, aliasWhereScope(qi, targetTable, targetMeta, ''), where, params);
989
+ }
990
+ /**
991
+ * Value-invariant, shape-aware fingerprint for a relation `with` clause's
992
+ * `where` filter. Must distinguish every SQL shape {@link buildAliasWhere}
993
+ * can emit — equality vs null vs operator sets vs combinators — or two
994
+ * differently-shaped wheres would share one cached SQL string.
995
+ */
996
+ export function fingerprintAliasWhere(qi, where, targetTable) {
997
+ const meta = targetTable ? qi.schema.tables[targetTable] : undefined;
998
+ const host = meta ? scopedWhereHost(qi, meta) : emptyRelationsHost(qi, targetTable ?? '');
999
+ return fingerprintScopedWhere(qi, host, where);
1000
+ }
1001
+ /**
1002
+ * Validate a `{ col }` column reference against its table and return the
1003
+ * resolved snake_case column name. Shared by the SQL-build path
1004
+ * ({@link buildOperatorClauses}) and the cache-hit param-collect path
1005
+ * (`collectOperatorParams`) so both always throw identically: a warmed
1006
+ * cache can never skip the check.
1007
+ */
1008
+ export function resolveColumnRef(_qi, ref, ctx, mode) {
1009
+ if (mode === 'insensitive') {
1010
+ throw new ValidationError(`[turbine] mode: 'insensitive' cannot be combined with a column reference ({ col: "${ref.col}" }). ` +
1011
+ `Case-insensitive column-to-column comparison is not supported: use client.sql\`...\` ` +
1012
+ `for lower(a) = lower(b).`);
1013
+ }
1014
+ const col = ctx.meta.columnMap[ref.col] ?? camelToSnake(ref.col);
1015
+ if (!ctx.meta.allColumns.includes(col)) {
1016
+ throw new ValidationError(`[turbine] Unknown field "${ref.col}" referenced by { col } in where on table "${ctx.table}". ` +
1017
+ `Known fields: ${Object.keys(ctx.meta.columnMap).join(', ') || '(none)'}.`);
1018
+ }
1019
+ return col;
1020
+ }
1021
+ /**
1022
+ * Compile a `{ col }` reference to its quoted, prefix-matched SQL identifier.
1023
+ * NO param is bound: the referenced column is part of the SQL text (and of
1024
+ * the where fingerprint, via `fingerprintOperatorShape` in `filters.ts`).
1025
+ */
1026
+ export function columnRefSql(qi, ref, ctx, mode) {
1027
+ if (!ctx) {
1028
+ throw new ValidationError(`[turbine] Column reference { col: "${ref.col}" } is not supported in this filter context.`);
1029
+ }
1030
+ return `${ctx.prefix}${qi.q(resolveColumnRef(qi, ref, ctx, mode))}`;
1031
+ }
1032
+ /**
1033
+ * Build SQL clauses for a single operator object on a column.
1034
+ * Each operator key becomes its own clause, all ANDed together.
1035
+ *
1036
+ * `equals`/`not`/`gt`/`gte`/`lt`/`lte` also accept a {@link ColumnRef}
1037
+ * (`{ col: 'otherField' }`) which compiles to a column-to-column comparison
1038
+ * against `refCtx`: no param bound, so `collectOperatorParams` mirrors by
1039
+ * pushing nothing and the referenced name lives in the fingerprint.
1040
+ */
1041
+ export function buildOperatorClauses(qi, column, op, params, refCtx) {
1042
+ const clauses = [];
1043
+ if (op.equals !== undefined) {
1044
+ if (op.equals === null) {
1045
+ clauses.push(`${column} IS NULL`);
1046
+ }
1047
+ else if (isColumnRef(op.equals)) {
1048
+ clauses.push(`${column} = ${columnRefSql(qi, op.equals, refCtx, op.mode)}`);
1049
+ }
1050
+ else {
1051
+ assertBindableEqualsOperand(op.equals, column);
1052
+ params.push(op.equals);
1053
+ clauses.push(`${column} = ${qi.p(params.length)}`);
1054
+ }
1055
+ }
1056
+ if (op.gt !== undefined) {
1057
+ if (isColumnRef(op.gt)) {
1058
+ clauses.push(`${column} > ${columnRefSql(qi, op.gt, refCtx, op.mode)}`);
1059
+ }
1060
+ else {
1061
+ params.push(op.gt);
1062
+ clauses.push(`${column} > ${qi.p(params.length)}`);
1063
+ }
1064
+ }
1065
+ if (op.gte !== undefined) {
1066
+ if (isColumnRef(op.gte)) {
1067
+ clauses.push(`${column} >= ${columnRefSql(qi, op.gte, refCtx, op.mode)}`);
1068
+ }
1069
+ else {
1070
+ params.push(op.gte);
1071
+ clauses.push(`${column} >= ${qi.p(params.length)}`);
1072
+ }
1073
+ }
1074
+ if (op.lt !== undefined) {
1075
+ if (isColumnRef(op.lt)) {
1076
+ clauses.push(`${column} < ${columnRefSql(qi, op.lt, refCtx, op.mode)}`);
1077
+ }
1078
+ else {
1079
+ params.push(op.lt);
1080
+ clauses.push(`${column} < ${qi.p(params.length)}`);
1081
+ }
1082
+ }
1083
+ if (op.lte !== undefined) {
1084
+ if (isColumnRef(op.lte)) {
1085
+ clauses.push(`${column} <= ${columnRefSql(qi, op.lte, refCtx, op.mode)}`);
1086
+ }
1087
+ else {
1088
+ params.push(op.lte);
1089
+ clauses.push(`${column} <= ${qi.p(params.length)}`);
1090
+ }
1091
+ }
1092
+ if (op.not !== undefined) {
1093
+ if (op.not === null) {
1094
+ clauses.push(`${column} IS NOT NULL`);
1095
+ }
1096
+ else if (isColumnRef(op.not)) {
1097
+ clauses.push(`${column} != ${columnRefSql(qi, op.not, refCtx, op.mode)}`);
1098
+ }
1099
+ else {
1100
+ params.push(op.not);
1101
+ clauses.push(`${column} != ${qi.p(params.length)}`);
1102
+ }
1103
+ }
1104
+ if (op.in !== undefined) {
1105
+ params.push(qi.inParam(op.in));
1106
+ clauses.push(qi.inClause(column, qi.p(params.length), false));
1107
+ }
1108
+ if (op.notIn !== undefined) {
1109
+ params.push(qi.inParam(op.notIn));
1110
+ clauses.push(qi.inClause(column, qi.p(params.length), true));
1111
+ }
1112
+ const buildLikeClause = (paramRef) => op.mode === 'insensitive' ? qi.dialect.buildInsensitiveLike(column, paramRef) : `${column} LIKE ${paramRef}`;
1113
+ if (op.contains !== undefined) {
1114
+ params.push(`%${escapeLike(op.contains)}%`);
1115
+ clauses.push(`${buildLikeClause(qi.p(params.length))} ESCAPE '\\'`);
1116
+ }
1117
+ if (op.startsWith !== undefined) {
1118
+ params.push(`${escapeLike(op.startsWith)}%`);
1119
+ clauses.push(`${buildLikeClause(qi.p(params.length))} ESCAPE '\\'`);
1120
+ }
1121
+ if (op.endsWith !== undefined) {
1122
+ params.push(`%${escapeLike(op.endsWith)}`);
1123
+ clauses.push(`${buildLikeClause(qi.p(params.length))} ESCAPE '\\'`);
1124
+ }
1125
+ return clauses;
1126
+ }
1127
+ /**
1128
+ * Resolve a {@link VectorMetric} to its pgvector distance operator from a
1129
+ * fixed allow-list, validating the target column is actually a `vector`
1130
+ * column. Throws {@link ValidationError} for an unknown metric or a
1131
+ * non-vector column — a user-supplied string can never become a SQL operator.
1132
+ */
1133
+ export function vectorOperator(qi, field, rawColumn, metric) {
1134
+ if (!qi.dialect.supportsVector) {
1135
+ throw new UnsupportedFeatureError('pgvector distance operations', qi.dialect.name, 'Vector search requires PostgreSQL with the pgvector extension.');
1136
+ }
1137
+ const colType = getColumnPgType(qi, rawColumn);
1138
+ if (colType !== 'vector') {
1139
+ throw new ValidationError(`[turbine] Column "${field}" on table "${qi.table}" is not a vector column ` +
1140
+ `(actual type: ${colType}); cannot apply a vector distance operation.`);
1141
+ }
1142
+ const op = VECTOR_METRIC_OPERATORS[metric];
1143
+ if (!op) {
1144
+ throw new ValidationError(`[turbine] Unknown vector metric "${metric}" for column "${field}". ` +
1145
+ `Valid metrics: ${Object.keys(VECTOR_METRIC_OPERATORS).join(', ')}.`);
1146
+ }
1147
+ return op;
1148
+ }
1149
+ /**
1150
+ * Validate and bind a query vector as a single `$n::vector` parameter.
1151
+ * Every element must be a finite number (no NaN / Infinity / strings) so a
1152
+ * malformed array can never produce a broken `::vector` literal, and the array
1153
+ * is NEVER string-interpolated into the SQL text. Returns the `$n::vector`
1154
+ * placeholder string.
1155
+ */
1156
+ export function pushVectorParam(qi, field, _rawColumn, to, params) {
1157
+ if (!qi.dialect.supportsVector) {
1158
+ throw new UnsupportedFeatureError('pgvector distance operations', qi.dialect.name, 'Vector search requires PostgreSQL with the pgvector extension.');
1159
+ }
1160
+ if (!Array.isArray(to) || to.length === 0) {
1161
+ throw new ValidationError(`[turbine] Vector distance on "${field}" requires a non-empty array of numbers for "to".`);
1162
+ }
1163
+ for (const el of to) {
1164
+ if (typeof el !== 'number' || !Number.isFinite(el)) {
1165
+ throw new ValidationError(`[turbine] Vector "to" for column "${field}" must contain only finite numbers; ` + `got ${JSON.stringify(el)}.`);
1166
+ }
1167
+ }
1168
+ // Bind as a pgvector text literal '[1,2,3]'. Elements are already validated
1169
+ // as finite numbers, so the joined string is safe; it is still passed as a
1170
+ // bound param (never interpolated) and cast with ::vector.
1171
+ params.push(`[${to.join(',')}]`);
1172
+ return `${qi.p(params.length)}::vector`;
1173
+ }
1174
+ /**
1175
+ * Prisma-compat: a plain object on a to-one relation key —
1176
+ * `where: { vendor: { name: { contains: 'x' } } }` — is an implicit `is`
1177
+ * filter. Normalize it to `{ is: obj }` so all downstream handling (SQL,
1178
+ * params, fingerprint) sees one canonical shape. To-many relations still
1179
+ * require an explicit `some`/`every`/`none` (a bare object there is
1180
+ * ambiguous and was never valid in Prisma either).
1181
+ */
1182
+ export function normalizeRelationFilter(_qi, relDef, filterObj) {
1183
+ if ((relDef.type === 'belongsTo' || relDef.type === 'hasOne') &&
1184
+ !('some' in filterObj) &&
1185
+ !('every' in filterObj) &&
1186
+ !('none' in filterObj) &&
1187
+ !('is' in filterObj) &&
1188
+ !('isNot' in filterObj)) {
1189
+ return { is: filterObj };
1190
+ }
1191
+ return filterObj;
1192
+ }
1193
+ /**
1194
+ * Case-insensitive json/jsonb column-type check. Postgres reports lowercase
1195
+ * udt_names, but SQLite/MySQL introspection surfaces the DECLARED type
1196
+ * (e.g. `JSON`), so every JSON-feature gate compares through this predicate
1197
+ * — build and collect sides alike, keeping the SQL-cache lockstep.
1198
+ */
1199
+ export function isJsonColumnType(_qi, colType) {
1200
+ const t = colType.toLowerCase();
1201
+ return t === 'json' || t === 'jsonb';
1202
+ }
1203
+ export function getColumnPgType(qi, column) {
1204
+ return qi.columnPgTypeMap.get(column) ?? 'text';
1205
+ }
1206
+ /**
1207
+ * Get the Postgres base element type for an array column.
1208
+ * E.g. '_text' → 'text', '_int4' → 'integer'
1209
+ */
1210
+ export function getArrayElementType(_qi, pgType) {
1211
+ const baseType = pgType.startsWith('_') ? pgType.slice(1) : pgType;
1212
+ const typeMap = {
1213
+ int2: 'smallint',
1214
+ int4: 'integer',
1215
+ int8: 'bigint',
1216
+ float4: 'real',
1217
+ float8: 'double precision',
1218
+ bool: 'boolean',
1219
+ text: 'text',
1220
+ varchar: 'text',
1221
+ uuid: 'uuid',
1222
+ timestamptz: 'timestamptz',
1223
+ timestamp: 'timestamp',
1224
+ jsonb: 'jsonb',
1225
+ json: 'json',
1226
+ };
1227
+ return typeMap[baseType] ?? 'text';
1228
+ }
1229
+ /**
1230
+ * Validate and enumerate the range comparisons (`gt`/`gte`/`lt`/`lte`) on a
1231
+ * JSON filter, in the fixed {@link JSON_RANGE_OPERATORS} order. Shared by
1232
+ * the SQL-build path ({@link buildJsonFilterClauses}) and the cache-hit
1233
+ * param-collect path ({@link collectJsonFilterParams}) so both always agree
1234
+ * on which params are pushed — and both throw identically for invalid
1235
+ * shapes, so a warmed cache can never skip validation.
1236
+ */
1237
+ export function jsonRangeEntries(_qi, filter, column) {
1238
+ const entries = [];
1239
+ for (const [op, sqlOp] of Object.entries(JSON_RANGE_OPERATORS)) {
1240
+ const value = filter[op];
1241
+ if (value === undefined)
1242
+ continue;
1243
+ if (filter.path === undefined) {
1244
+ throw new ValidationError(`[turbine] JSON range operator '${op}' on ${column} requires a \`path\` ` +
1245
+ `(e.g. { path: ['meta', 'score'], ${op}: ${JSON.stringify(value)} }).`);
1246
+ }
1247
+ if (typeof value !== 'number' && typeof value !== 'string') {
1248
+ throw new ValidationError(`[turbine] JSON range operator '${op}' on ${column} requires a number or string, ` +
1249
+ `got ${JSON.stringify(value)}.`);
1250
+ }
1251
+ if (typeof value === 'number' && !Number.isFinite(value)) {
1252
+ throw new ValidationError(`[turbine] JSON range operator '${op}' on ${column} requires a finite number.`);
1253
+ }
1254
+ entries.push({ sqlOp, value });
1255
+ }
1256
+ return entries;
1257
+ }
1258
+ /**
1259
+ * Build SQL clauses for JSONB filter operators on a column.
1260
+ * Supports: path, equals, contains, hasKey, gt, gte, lt, lte.
1261
+ *
1262
+ * The `path` param is bound at most once and its placeholder is shared by
1263
+ * every clause that extracts it (equals + range ops), so the param list
1264
+ * stays byte-identical to {@link collectJsonFilterParams}.
1265
+ */
1266
+ export function buildJsonFilterClauses(qi, column, filter, params) {
1267
+ const clauses = [];
1268
+ // Lazily bind the path once; reuse the same $N in every extraction clause.
1269
+ let pathParamIdx = null;
1270
+ const pathExtract = () => {
1271
+ if (pathParamIdx === null) {
1272
+ // Only reached when a path-requiring clause validated filter.path.
1273
+ params.push(jsonPathParam(qi, filter.path, filter.path));
1274
+ pathParamIdx = params.length;
1275
+ }
1276
+ return qi.dialect.buildJsonPathExtract(column, qi.p(pathParamIdx));
1277
+ };
1278
+ if (filter.path !== undefined && filter.equals !== undefined) {
1279
+ // Path access + equals: column #>> $N::text[] = $M
1280
+ const extract = pathExtract();
1281
+ params.push(String(filter.equals));
1282
+ clauses.push(`${extract} = ${qi.p(params.length)}`);
1283
+ }
1284
+ else if (filter.equals !== undefined) {
1285
+ // Containment equality: column @> $N::jsonb
1286
+ params.push(JSON.stringify(filter.equals));
1287
+ clauses.push(qi.dialect.buildJsonContains(column, qi.p(params.length)));
1288
+ }
1289
+ if (filter.contains !== undefined) {
1290
+ // Containment: column @> $N::jsonb
1291
+ params.push(JSON.stringify(filter.contains));
1292
+ clauses.push(qi.dialect.buildJsonContains(column, qi.p(params.length)));
1293
+ }
1294
+ if (filter.hasKey !== undefined) {
1295
+ // Key existence: column ? $N
1296
+ params.push(filter.hasKey);
1297
+ clauses.push(`${column} ? ${qi.p(params.length)}`);
1298
+ }
1299
+ // Range comparisons on the extracted path: numbers compare numerically
1300
+ // (cast through the dialect), strings compare as text.
1301
+ for (const { sqlOp, value } of jsonRangeEntries(qi, filter, column)) {
1302
+ const extract = pathExtract();
1303
+ params.push(value);
1304
+ const lhs = typeof value === 'number' ? castJsonNumeric(qi, extract) : extract;
1305
+ clauses.push(`${lhs} ${sqlOp} ${qi.p(params.length)}`);
1306
+ }
1307
+ return clauses;
1308
+ }
1309
+ /**
1310
+ * Bind value for a JSON path parameter, encoded per dialect. PostgreSQL's
1311
+ * `#>>` takes a `text[]` (the segments as strings — or `nativeForm` when the
1312
+ * caller has a specific native binding, e.g. JsonFilter's raw path array).
1313
+ * Every other engine's JSON function (`json_extract` / `JSON_EXTRACT` /
1314
+ * `JSON_VALUE`) takes a `'$'`-rooted JSONPath STRING: binding the raw array
1315
+ * would arrive as `'["a"]'` (the driver shims JSON.stringify non-primitive
1316
+ * params) and fail at runtime with the engine's bad-JSON-path error. The
1317
+ * encoded path stays a bound parameter — never spliced into SQL text — so
1318
+ * the build/collect param mirrors stay in lockstep and injection-safe.
1319
+ */
1320
+ export function jsonPathParam(qi, path, nativeForm) {
1321
+ if (qi.dialect.jsonPathSupport === 'native')
1322
+ return nativeForm ?? path.map(String);
1323
+ return `$${path
1324
+ .map((seg) => typeof seg === 'number' || /^\d+$/.test(String(seg)) ? `[${seg}]` : `."${String(seg).replace(/"/g, '\\"')}"`)
1325
+ .join('')}`;
1326
+ }
1327
+ /**
1328
+ * Cast an extracted JSON path text value to a numeric type for range
1329
+ * comparison. PostgreSQL uses `(expr)::numeric` (exact — the right way to
1330
+ * compare JSON numbers, and `::float` would lose precision on big ints);
1331
+ * other dialects route through {@link Dialect.castAggregate} (SQLite/MySQL/
1332
+ * SQL Server have no `::` operator) as a float cast.
1333
+ */
1334
+ export function castJsonNumeric(qi, extract) {
1335
+ if (qi.dialect.name === 'postgresql')
1336
+ return `(${extract})::numeric`;
1337
+ return qi.dialect.castAggregate ? qi.dialect.castAggregate(`(${extract})`, 'float') : `(${extract})::numeric`;
1338
+ }
1339
+ /**
1340
+ * Build SQL clauses for Array filter operators on a column.
1341
+ * Supports: has, hasEvery, hasSome, isEmpty.
1342
+ */
1343
+ export function buildArrayFilterClauses(qi, column, filter, params, pgType) {
1344
+ const clauses = [];
1345
+ const elementType = getArrayElementType(qi, pgType);
1346
+ if (filter.has !== undefined) {
1347
+ // value = ANY(column)
1348
+ params.push(filter.has);
1349
+ clauses.push(`${qi.p(params.length)} = ANY(${column})`);
1350
+ }
1351
+ if (filter.hasEvery !== undefined) {
1352
+ // column @> ARRAY[...]::type[]
1353
+ params.push(filter.hasEvery);
1354
+ clauses.push(`${column} @> ${qi.p(params.length)}::${elementType}[]`);
1355
+ }
1356
+ if (filter.hasSome !== undefined) {
1357
+ // column && ARRAY[...]::type[]
1358
+ params.push(filter.hasSome);
1359
+ clauses.push(`${column} && ${qi.p(params.length)}::${elementType}[]`);
1360
+ }
1361
+ if (filter.isEmpty === true) {
1362
+ // Treat NULL and empty arrays as empty for Prisma-compatible ergonomics.
1363
+ clauses.push(`COALESCE(cardinality(${column}), 0) = 0`);
1364
+ }
1365
+ else if (filter.isEmpty === false) {
1366
+ // Require at least one element; excludes both NULL and ARRAY[] values.
1367
+ clauses.push(`cardinality(${column}) > 0`);
1368
+ }
1369
+ return clauses;
1370
+ }
1371
+ /**
1372
+ * Build SQL clauses for a pgvector distance WHERE filter:
1373
+ *
1374
+ * `"embedding" <-> $1::vector < $2`
1375
+ *
1376
+ * The query vector is bound as a `$n::vector` param (never interpolated), the
1377
+ * metric maps to an operator via a fixed allow-list, and each comparison
1378
+ * threshold (`lt`/`lte`/`gt`/`gte`) is its own bound param. Emits one clause
1379
+ * per supplied comparator (all ANDed). Param push order matches
1380
+ * {@link collectVectorFilterParams}.
1381
+ */
1382
+ export function buildVectorFilterClauses(qi, field, rawColumn, filter, params) {
1383
+ const dist = filter.distance;
1384
+ const operator = vectorOperator(qi, field, rawColumn, dist.metric);
1385
+ const placeholder = pushVectorParam(qi, field, rawColumn, dist.to, params);
1386
+ const distanceExpr = `${qi.q(rawColumn)} ${operator} ${placeholder}`;
1387
+ const clauses = [];
1388
+ for (const [cmp, sqlOp] of Object.entries(VECTOR_DISTANCE_COMPARATORS)) {
1389
+ const threshold = dist[cmp];
1390
+ if (threshold === undefined)
1391
+ continue;
1392
+ if (typeof threshold !== 'number' || !Number.isFinite(threshold)) {
1393
+ throw new ValidationError(`[turbine] Vector distance threshold "${cmp}" on "${field}" must be a finite number; ` +
1394
+ `got ${JSON.stringify(threshold)}.`);
1395
+ }
1396
+ params.push(threshold);
1397
+ clauses.push(`${distanceExpr} ${sqlOp} ${qi.p(params.length)}`);
1398
+ }
1399
+ if (clauses.length === 0) {
1400
+ throw new ValidationError(`[turbine] Vector distance filter on "${field}" requires at least one comparison (lt / lte / gt / gte).`);
1401
+ }
1402
+ return clauses;
1403
+ }
1404
+ /**
1405
+ * Build SQL clause for full-text search using to_tsvector @@ to_tsquery.
1406
+ * The config name is validated to prevent injection (only alphanumeric + underscore).
1407
+ */
1408
+ export function buildTextSearchClause(qi, column, filter, params) {
1409
+ const config = filter.config ?? 'english';
1410
+ if (!validateTextSearchConfig(config)) {
1411
+ throw new ValidationError(`[turbine] Invalid text search config "${config}": only alphanumeric characters and underscores are allowed.`);
1412
+ }
1413
+ params.push(filter.search);
1414
+ return `to_tsvector('${config}', ${column}) @@ to_tsquery('${config}', ${qi.p(params.length)})`;
1415
+ }
1416
+ /**
1417
+ * Get the Postgres array type for a column (used by UNNEST in createMany).
1418
+ * Uses pre-computed Map for O(1) lookup instead of linear scan.
1419
+ */
1420
+ export function getColumnArrayType(qi, column) {
1421
+ const arrayType = qi.columnArrayTypeMap.get(column);
1422
+ if (arrayType)
1423
+ return arrayType;
1424
+ // Fallback heuristic for unknown columns, routed through the active dialect
1425
+ // so non-Postgres packages can supply their own bulk-insert cast shape.
1426
+ if (column === 'id' || column.endsWith('_id'))
1427
+ return qi.dialect.arrayType?.('int8') ?? 'text[]';
1428
+ if (column.endsWith('_at'))
1429
+ return qi.dialect.arrayType?.('timestamptz') ?? 'text[]';
1430
+ return qi.dialect.arrayType?.('text') ?? 'text[]';
1431
+ }