turbine-orm 0.48.0 → 0.49.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 +58 -39
- package/dist/cjs/cli/destructive.js +233 -18
- package/dist/cjs/cli/index.js +56 -12
- package/dist/cjs/cli/mcp.js +23 -2
- package/dist/cjs/cli/migrate.js +28 -1
- package/dist/cjs/cli/pii-tags.js +111 -0
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.js +158 -0
- package/dist/cjs/cli/ui.js +8 -3
- package/dist/cjs/client.js +21 -1
- package/dist/cjs/dialect.js +2 -0
- package/dist/cjs/index-advisor.js +0 -0
- package/dist/cjs/index-stats.js +118 -6
- package/dist/cjs/mssql.js +5 -0
- package/dist/cjs/mysql.js +5 -0
- package/dist/cjs/nested-write.js +248 -18
- package/dist/cjs/observe.js +21 -15
- package/dist/cjs/powdb.js +3 -0
- package/dist/cjs/powql.js +13 -0
- package/dist/cjs/prisma-compat.js +9 -0
- package/dist/cjs/query/aggregates.js +41 -1
- package/dist/cjs/query/batched-loader.js +70 -6
- package/dist/cjs/query/builder.js +3 -3
- package/dist/cjs/query/relations.js +12 -2
- package/dist/cjs/query/where.js +36 -1
- package/dist/cjs/sqlite.js +5 -0
- package/dist/cli/destructive.d.ts +9 -3
- package/dist/cli/destructive.js +233 -18
- package/dist/cli/index.js +57 -13
- package/dist/cli/mcp.d.ts +7 -0
- package/dist/cli/mcp.js +23 -2
- package/dist/cli/migrate.d.ts +2 -1
- package/dist/cli/migrate.js +28 -1
- package/dist/cli/pii-tags.d.ts +53 -0
- package/dist/cli/pii-tags.js +106 -0
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +42 -0
- package/dist/cli/studio.js +157 -0
- package/dist/cli/ui.js +8 -3
- package/dist/client.js +21 -1
- package/dist/dialect.d.ts +19 -0
- package/dist/dialect.js +2 -0
- package/dist/index-advisor.d.ts +7 -0
- package/dist/index-advisor.js +0 -0
- package/dist/index-stats.d.ts +52 -1
- package/dist/index-stats.js +117 -5
- package/dist/mssql.js +5 -0
- package/dist/mysql.js +5 -0
- package/dist/nested-write.js +249 -19
- package/dist/observe.d.ts +0 -1
- package/dist/observe.js +21 -15
- package/dist/powdb.js +3 -0
- package/dist/powql.js +13 -0
- package/dist/prisma-compat.js +9 -0
- package/dist/query/aggregates.d.ts +18 -0
- package/dist/query/aggregates.js +40 -1
- package/dist/query/batched-loader.d.ts +29 -1
- package/dist/query/batched-loader.js +69 -6
- package/dist/query/builder.js +4 -4
- package/dist/query/relations.js +12 -2
- package/dist/query/types.d.ts +16 -0
- package/dist/query/where.d.ts +18 -1
- package/dist/query/where.js +34 -1
- package/dist/sqlite.js +5 -0
- package/package.json +3 -2
package/dist/observe.js
CHANGED
|
@@ -13,6 +13,15 @@
|
|
|
13
13
|
* carries SQL text or bound parameter values.
|
|
14
14
|
*/
|
|
15
15
|
import pg from 'pg';
|
|
16
|
+
import { ValidationError } from './errors.js';
|
|
17
|
+
/**
|
|
18
|
+
* Buffer key: minute bucket + model + action. Joined on a NUL, which cannot
|
|
19
|
+
* appear in a table name or an action, so `model: 'a:b'` and `action: 'c'`
|
|
20
|
+
* can never collapse into the same series as `model: 'a'` / `action: 'b:c'`.
|
|
21
|
+
*/
|
|
22
|
+
function bufferKey(bucket, model, action) {
|
|
23
|
+
return `${bucket.getTime()}\u0000${model}\u0000${action}`;
|
|
24
|
+
}
|
|
16
25
|
function floorToMinute(date) {
|
|
17
26
|
const d = new Date(date);
|
|
18
27
|
d.setSeconds(0, 0);
|
|
@@ -139,31 +148,28 @@ export class HttpJsonSink {
|
|
|
139
148
|
export class ObserveEngine {
|
|
140
149
|
sink;
|
|
141
150
|
buffer = new Map();
|
|
142
|
-
currentBucket;
|
|
143
151
|
flushIntervalMs;
|
|
144
152
|
timer;
|
|
145
153
|
listener;
|
|
146
154
|
stopped = false;
|
|
147
155
|
constructor(config) {
|
|
148
156
|
if (!config.sink && !config.connectionString) {
|
|
149
|
-
throw new
|
|
157
|
+
throw new ValidationError('ObserveEngine requires either a connectionString or a sink');
|
|
150
158
|
}
|
|
151
159
|
this.sink =
|
|
152
160
|
config.sink ??
|
|
153
161
|
new PgMetricsSink({ connectionString: config.connectionString, retentionDays: config.retentionDays ?? 30 });
|
|
154
162
|
this.flushIntervalMs = config.flushIntervalMs ?? 60_000;
|
|
155
|
-
this.currentBucket = floorToMinute(new Date());
|
|
156
163
|
this.listener = (event) => {
|
|
157
164
|
if (this.stopped)
|
|
158
165
|
return;
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const key = `${event.model}:${event.action}`;
|
|
166
|
+
// Bucket by the event's own timestamp so a late-arriving event is
|
|
167
|
+
// attributed to the minute it happened in, not the minute it was seen.
|
|
168
|
+
const bucket = floorToMinute(event.timestamp);
|
|
169
|
+
const key = bufferKey(bucket, event.model, event.action);
|
|
164
170
|
let entry = this.buffer.get(key);
|
|
165
171
|
if (!entry) {
|
|
166
|
-
entry = { durations: [], errors: 0 };
|
|
172
|
+
entry = { bucket, model: event.model, action: event.action, durations: [], errors: 0 };
|
|
167
173
|
this.buffer.set(key, entry);
|
|
168
174
|
}
|
|
169
175
|
entry.durations.push(event.duration);
|
|
@@ -187,19 +193,19 @@ export class ObserveEngine {
|
|
|
187
193
|
async flush() {
|
|
188
194
|
if (this.buffer.size === 0)
|
|
189
195
|
return;
|
|
190
|
-
const bucket = this.currentBucket;
|
|
191
196
|
const entries = new Map(this.buffer);
|
|
192
197
|
this.buffer.clear();
|
|
193
198
|
const rows = [];
|
|
194
|
-
for (const
|
|
195
|
-
const [model, action] = key.split(':');
|
|
199
|
+
for (const entry of entries.values()) {
|
|
196
200
|
const sorted = entry.durations.slice().sort((a, b) => a - b);
|
|
197
201
|
const count = sorted.length;
|
|
198
202
|
const avg = sorted.reduce((s, v) => s + v, 0) / count;
|
|
199
203
|
rows.push({
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
204
|
+
// Each entry carries the minute it accumulated in, so a flush that
|
|
205
|
+
// spans a rollover emits one correctly stamped row per minute.
|
|
206
|
+
bucket: entry.bucket,
|
|
207
|
+
model: entry.model,
|
|
208
|
+
action: entry.action,
|
|
203
209
|
count,
|
|
204
210
|
avg,
|
|
205
211
|
p50: percentile(sorted, 0.5),
|
package/dist/powdb.js
CHANGED
|
@@ -90,6 +90,9 @@ export const powdbDialect = {
|
|
|
90
90
|
resultStrategy: 'returning',
|
|
91
91
|
supportsReturning: true,
|
|
92
92
|
supportsVector: false,
|
|
93
|
+
// PowQL has no tsvector/tsquery surface and no array column type.
|
|
94
|
+
supportsFullTextSearch: false,
|
|
95
|
+
supportsArrayColumns: false,
|
|
93
96
|
supportsListenNotify: false,
|
|
94
97
|
supportsRLS: false,
|
|
95
98
|
supportsAdvisoryLock: false,
|
package/dist/powql.js
CHANGED
|
@@ -38,6 +38,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
38
38
|
import { NotFoundError, ReadOnlyError, TimeoutError, UnsupportedFeatureError, ValidationError } from './errors.js';
|
|
39
39
|
import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from './nested-write.js';
|
|
40
40
|
import { ALL_POWDB_CAPABILITIES, coerceNativeValue, isJsonColumn, isStaleFramePowdbError, PowdbFloatParam, PowdbJsonParam, powqlColumnType, quotePowqlIdent, requireCapability, rowToEntity, } from './powdb.js';
|
|
41
|
+
import { assertAggregatePiiOptIn } from './query/aggregates.js';
|
|
41
42
|
import { expandCompoundUniqueWhere } from './query/compound-unique.js';
|
|
42
43
|
import { isJsonFilter, isRelationPickOrderBy, orderByEntries } from './query/filters.js';
|
|
43
44
|
import { escapeLike } from './query/utils.js';
|
|
@@ -2246,6 +2247,10 @@ export class PowqlInterface {
|
|
|
2246
2247
|
const acc = {};
|
|
2247
2248
|
for (const field of Object.keys(spec).filter((f) => spec[f])) {
|
|
2248
2249
|
const powfn = fn.slice(1); // sum/avg/min/max
|
|
2250
|
+
// Same PII contract as the SQL engines: _min/_max return a stored cell.
|
|
2251
|
+
if (fn === '_min' || fn === '_max') {
|
|
2252
|
+
assertAggregatePiiOptIn(this.table, this.meta, field, this.column(field).name, `aggregate ${fn}`, args.includePii);
|
|
2253
|
+
}
|
|
2249
2254
|
acc[field] = await scalar(`${powfn}(${this.qt}${filter} { ${this.ref(field)} })`);
|
|
2250
2255
|
}
|
|
2251
2256
|
result[fn] = acc;
|
|
@@ -2297,6 +2302,7 @@ export class PowqlInterface {
|
|
|
2297
2302
|
for (const entry of args.by) {
|
|
2298
2303
|
if (typeof entry === 'string') {
|
|
2299
2304
|
const col = this.column(entry);
|
|
2305
|
+
assertAggregatePiiOptIn(this.table, this.meta, entry, col.name, 'groupBy `by` key', args.includePii);
|
|
2300
2306
|
claim(entry, `column "${col.name}"`);
|
|
2301
2307
|
if (col.name !== entry)
|
|
2302
2308
|
claim(col.name, `column "${col.name}"`);
|
|
@@ -2310,6 +2316,7 @@ export class PowqlInterface {
|
|
|
2310
2316
|
if (!isJsonColumn(col)) {
|
|
2311
2317
|
throw new ValidationError(`[turbine] groupBy JSON group key on "${entry.field}" (table "${this.table}") requires a json column.`);
|
|
2312
2318
|
}
|
|
2319
|
+
assertAggregatePiiOptIn(this.table, this.meta, entry.field, col.name, 'groupBy JSON `by` key', args.includePii);
|
|
2313
2320
|
this.assertJsonPath('group key', entry.field, entry.path);
|
|
2314
2321
|
const pathExpr = this.jsonPathExpr(col, entry.path, params);
|
|
2315
2322
|
const alias = entry.alias ?? String(entry.path[entry.path.length - 1]);
|
|
@@ -2359,6 +2366,9 @@ export class PowqlInterface {
|
|
|
2359
2366
|
const alias = `agg_${aggN++}`;
|
|
2360
2367
|
if (target === true) {
|
|
2361
2368
|
const col = this.column(key);
|
|
2369
|
+
if (fn === '_min' || fn === '_max') {
|
|
2370
|
+
assertAggregatePiiOptIn(this.table, this.meta, key, col.name, `groupBy ${fn}`, args.includePii);
|
|
2371
|
+
}
|
|
2362
2372
|
claim(`${fn}_${col.name}`, `${fn} of column "${col.name}"`);
|
|
2363
2373
|
const inner = `.${col.name}`;
|
|
2364
2374
|
proj.push(`${alias}: ${powfn}(${inner})`);
|
|
@@ -2371,6 +2381,9 @@ export class PowqlInterface {
|
|
|
2371
2381
|
if (!isJsonColumn(col)) {
|
|
2372
2382
|
throw new ValidationError(`[turbine] groupBy ${fn} target "${key}" on "${target.field}" (table "${this.table}") requires a json column.`);
|
|
2373
2383
|
}
|
|
2384
|
+
if (fn === '_min' || fn === '_max') {
|
|
2385
|
+
assertAggregatePiiOptIn(this.table, this.meta, target.field, col.name, `groupBy ${fn} JSON target`, args.includePii);
|
|
2386
|
+
}
|
|
2374
2387
|
this.assertJsonPath(`${fn} target "${key}"`, target.field, target.path);
|
|
2375
2388
|
const alwaysNumeric = fn === '_sum' || fn === '_avg';
|
|
2376
2389
|
if (alwaysNumeric && target.type === 'text') {
|
package/dist/prisma-compat.js
CHANGED
|
@@ -417,6 +417,11 @@ function translateReadArgs(ctx, mm, prismaArgs) {
|
|
|
417
417
|
t.relationLoadStrategy = prismaArgs.relationLoadStrategy;
|
|
418
418
|
if (typeof prismaArgs.timeout === 'number')
|
|
419
419
|
t.timeout = prismaArgs.timeout;
|
|
420
|
+
// Turbine-only passthrough. Prisma has no PII concept, so a compat caller
|
|
421
|
+
// whose schema tags columns needs SOME way to opt in; without this the
|
|
422
|
+
// adapter is a one-way door into redacted reads and refused aggregates.
|
|
423
|
+
if (prismaArgs.includePii !== undefined)
|
|
424
|
+
t.includePii = prismaArgs.includePii;
|
|
420
425
|
if (ctx.options.stablePkOrder)
|
|
421
426
|
t.stableRelationOrder = true;
|
|
422
427
|
translateCursor(ctx, mm, prismaArgs, t);
|
|
@@ -651,6 +656,10 @@ function translateAggregateArgs(ctx, mm, args, isGroupBy) {
|
|
|
651
656
|
}
|
|
652
657
|
if (typeof args.timeout === 'number')
|
|
653
658
|
t.timeout = args.timeout;
|
|
659
|
+
// Turbine-only passthrough: the PII gate on groupBy keys and _min/_max needs
|
|
660
|
+
// an opt-in that Prisma's arg shape has no equivalent for.
|
|
661
|
+
if (args.includePii !== undefined)
|
|
662
|
+
t.includePii = args.includePii;
|
|
654
663
|
if (isGroupBy) {
|
|
655
664
|
if (Array.isArray(args.by))
|
|
656
665
|
t.by = args.by.map((f) => renameField(mm, f));
|
|
@@ -8,9 +8,27 @@
|
|
|
8
8
|
* stay class-resident, reached through the ctx. See builder.ts for the thin
|
|
9
9
|
* delegating methods (buildGroupBy / buildAggregate).
|
|
10
10
|
*/
|
|
11
|
+
import type { TableMetadata } from '../schema.js';
|
|
11
12
|
import type { DeferredQuery } from './deferred.js';
|
|
12
13
|
import type { AggregateArgs, AggregateResult, GroupByArgs, GroupByOrderBy, HavingClause, HavingFilter } from './types.js';
|
|
13
14
|
import type { BuilderCtx } from './where.js';
|
|
15
|
+
/**
|
|
16
|
+
* Enforce the PII contract on the aggregate surface. A PII-tagged
|
|
17
|
+
* (`defineSchema` `pii: true`) column is excluded from every default
|
|
18
|
+
* projection, and a value-returning aggregate is a projection by another name:
|
|
19
|
+
* `groupBy({ by: ['email'] })` emits one row per distinct plaintext email, and
|
|
20
|
+
* `_min`/`_max` return a stored cell verbatim. Both therefore REQUIRE the same
|
|
21
|
+
* `includePii: true` opt-in reads use.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately NOT gated: `_count` (a count, never a value), `_sum` / `_avg`
|
|
24
|
+
* (a computed total across many rows, not a stored cell), and `where` /
|
|
25
|
+
* `orderBy` / `having` on PII columns (they return no values at all). Untagged
|
|
26
|
+
* schemas short-circuit on the `pii` lookup, so their SQL is byte-identical.
|
|
27
|
+
*
|
|
28
|
+
* Shared with the PowQL aggregate paths (src/powql.ts) so every engine applies
|
|
29
|
+
* one policy.
|
|
30
|
+
*/
|
|
31
|
+
export declare function assertAggregatePiiOptIn(table: string, meta: TableMetadata | undefined, field: string, column: string, usage: string, includePii: boolean | undefined): void;
|
|
14
32
|
export declare function buildGroupBy<T extends object>(qi: BuilderCtx, args: GroupByArgs<T>): DeferredQuery<Record<string, unknown>[]>;
|
|
15
33
|
/**
|
|
16
34
|
* Compile a groupBy `orderBy` into an ORDER BY body. Unlike findMany ORDER BY
|
package/dist/query/aggregates.js
CHANGED
|
@@ -12,6 +12,34 @@ import { UnsupportedFeatureError, ValidationError } from '../errors.js';
|
|
|
12
12
|
import { snakeToCamel } from '../schema.js';
|
|
13
13
|
import { isJsonPathOrderBy, isVectorOrderBy, normalizeOrderBy, orderByEntries } from './filters.js';
|
|
14
14
|
import * as whereMod from './where.js';
|
|
15
|
+
/**
|
|
16
|
+
* Enforce the PII contract on the aggregate surface. A PII-tagged
|
|
17
|
+
* (`defineSchema` `pii: true`) column is excluded from every default
|
|
18
|
+
* projection, and a value-returning aggregate is a projection by another name:
|
|
19
|
+
* `groupBy({ by: ['email'] })` emits one row per distinct plaintext email, and
|
|
20
|
+
* `_min`/`_max` return a stored cell verbatim. Both therefore REQUIRE the same
|
|
21
|
+
* `includePii: true` opt-in reads use.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately NOT gated: `_count` (a count, never a value), `_sum` / `_avg`
|
|
24
|
+
* (a computed total across many rows, not a stored cell), and `where` /
|
|
25
|
+
* `orderBy` / `having` on PII columns (they return no values at all). Untagged
|
|
26
|
+
* schemas short-circuit on the `pii` lookup, so their SQL is byte-identical.
|
|
27
|
+
*
|
|
28
|
+
* Shared with the PowQL aggregate paths (src/powql.ts) so every engine applies
|
|
29
|
+
* one policy.
|
|
30
|
+
*/
|
|
31
|
+
export function assertAggregatePiiOptIn(table, meta, field, column, usage, includePii) {
|
|
32
|
+
if (includePii === true || !meta)
|
|
33
|
+
return;
|
|
34
|
+
const colMeta = meta.columns.find((c) => c.name === column);
|
|
35
|
+
if (!colMeta?.pii)
|
|
36
|
+
return;
|
|
37
|
+
throw new ValidationError(`[turbine] ${usage} on column "${field}" of table "${table}" is refused: that column is ` +
|
|
38
|
+
'PII-tagged (`pii: true`), and this aggregate returns its stored values, which are excluded ' +
|
|
39
|
+
'from every default projection. Pass `includePii: true` on this call to opt in. ' +
|
|
40
|
+
'`_count` over a PII column (a count, not a value) and `where` / `orderBy` / `having` on PII ' +
|
|
41
|
+
'columns need no opt-in.');
|
|
42
|
+
}
|
|
15
43
|
export function buildGroupBy(qi, args) {
|
|
16
44
|
const meta = qi.schema.tables[qi.table];
|
|
17
45
|
if (meta) {
|
|
@@ -65,6 +93,7 @@ export function buildGroupBy(qi, args) {
|
|
|
65
93
|
for (const entry of args.by) {
|
|
66
94
|
if (typeof entry === 'string') {
|
|
67
95
|
const col = qi.toColumn(entry);
|
|
96
|
+
assertAggregatePiiOptIn(qi.table, meta, entry, col, 'groupBy `by` key', args.includePii);
|
|
68
97
|
claimResultKey(entry, `column "${col}"`);
|
|
69
98
|
// The emitted output column is the snake_case name; claim it too (when
|
|
70
99
|
// it differs from the result key) so a JSON alias like 'created_at'
|
|
@@ -78,6 +107,7 @@ export function buildGroupBy(qi, args) {
|
|
|
78
107
|
}
|
|
79
108
|
else {
|
|
80
109
|
const col = resolveJsonPathTarget(qi, 'group key', entry.field, entry.path);
|
|
110
|
+
assertAggregatePiiOptIn(qi.table, meta, entry.field, col, 'groupBy JSON `by` key', args.includePii);
|
|
81
111
|
params.push(whereMod.jsonPathParam(qi, entry.path));
|
|
82
112
|
const extract = qi.dialect.buildJsonPathExtract(qi.q(col), qi.p(params.length));
|
|
83
113
|
const alias = entry.alias ?? String(entry.path[entry.path.length - 1]);
|
|
@@ -141,6 +171,9 @@ export function buildGroupBy(qi, args) {
|
|
|
141
171
|
continue;
|
|
142
172
|
if (target === true) {
|
|
143
173
|
const col = qi.toColumn(key);
|
|
174
|
+
if (aggKey === '_min' || aggKey === '_max') {
|
|
175
|
+
assertAggregatePiiOptIn(qi.table, meta, key, col, `groupBy ${aggKey}`, args.includePii);
|
|
176
|
+
}
|
|
144
177
|
// Aggregate output aliases share the same output-name namespace as
|
|
145
178
|
// the group keys: `_sum: { totalPrice: true, total_price: {json} }`
|
|
146
179
|
// would emit two "_sum_total_price" columns and silently drop one.
|
|
@@ -152,6 +185,9 @@ export function buildGroupBy(qi, args) {
|
|
|
152
185
|
continue;
|
|
153
186
|
}
|
|
154
187
|
const col = resolveJsonPathTarget(qi, `${aggKey} target "${key}"`, target.field, target.path);
|
|
188
|
+
if (aggKey === '_min' || aggKey === '_max') {
|
|
189
|
+
assertAggregatePiiOptIn(qi.table, meta, target.field, col, `groupBy ${aggKey} JSON target`, args.includePii);
|
|
190
|
+
}
|
|
155
191
|
const alwaysNumeric = aggKey === '_sum' || aggKey === '_avg';
|
|
156
192
|
if (alwaysNumeric && target.type === 'text') {
|
|
157
193
|
throw new ValidationError(`[turbine] groupBy ${aggKey} target "${key}" on table "${qi.table}": ` +
|
|
@@ -611,11 +647,13 @@ export function buildAggregate(qi, args) {
|
|
|
611
647
|
}
|
|
612
648
|
}
|
|
613
649
|
}
|
|
614
|
-
// _min
|
|
650
|
+
// _min / _max return a stored cell verbatim, so a PII-tagged column needs the
|
|
651
|
+
// same `includePii` opt-in a row projection needs. _count / _sum / _avg do not.
|
|
615
652
|
if (args._min) {
|
|
616
653
|
for (const [field, enabled] of Object.entries(args._min)) {
|
|
617
654
|
if (enabled) {
|
|
618
655
|
const col = qi.toColumn(field);
|
|
656
|
+
assertAggregatePiiOptIn(qi.table, meta, field, col, 'aggregate _min', args.includePii);
|
|
619
657
|
selectExprs.push(`MIN(${qi.q(col)}) AS ${qi.q(`_min_${col}`)}`);
|
|
620
658
|
}
|
|
621
659
|
}
|
|
@@ -625,6 +663,7 @@ export function buildAggregate(qi, args) {
|
|
|
625
663
|
for (const [field, enabled] of Object.entries(args._max)) {
|
|
626
664
|
if (enabled) {
|
|
627
665
|
const col = qi.toColumn(field);
|
|
666
|
+
assertAggregatePiiOptIn(qi.table, meta, field, col, 'aggregate _max', args.includePii);
|
|
628
667
|
selectExprs.push(`MAX(${qi.q(col)}) AS ${qi.q(`_max_${col}`)}`);
|
|
629
668
|
}
|
|
630
669
|
}
|
|
@@ -112,6 +112,19 @@ export interface RelationLoadContext {
|
|
|
112
112
|
params: unknown[];
|
|
113
113
|
} | null;
|
|
114
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* The default projection of `meta` expressed in FIELD names: which fields the
|
|
117
|
+
* default (no `select`/`omit`) projection hides, and which it returns. Today the
|
|
118
|
+
* only hidden class is PII-tagged columns, and only when `includePii` is off.
|
|
119
|
+
*
|
|
120
|
+
* Returns `undefined` for the overwhelmingly common untagged case, so callers
|
|
121
|
+
* keep the `select: undefined, omit: undefined` fast path and the emitted SQL
|
|
122
|
+
* stays byte-identical.
|
|
123
|
+
*/
|
|
124
|
+
export declare function defaultProjectionFields(meta: TableMetadata, includePii: boolean | undefined): {
|
|
125
|
+
hidden: ReadonlySet<string>;
|
|
126
|
+
visible: string[];
|
|
127
|
+
} | undefined;
|
|
115
128
|
/**
|
|
116
129
|
* Adjust a `select`/`omit` pair so that `fields` are guaranteed present in the
|
|
117
130
|
* query result, returning the adjusted projection plus the list of fields that
|
|
@@ -121,7 +134,22 @@ export interface RelationLoadContext {
|
|
|
121
134
|
* keys) so a caller's `select: { title: true }` on a relation still stitches even
|
|
122
135
|
* though the FK was not requested — and the FK never appears in the output.
|
|
123
136
|
*/
|
|
124
|
-
export declare function includeKeysForBatching(select: Record<string, boolean> | undefined, omit: Record<string, boolean> | undefined, fields: string[]
|
|
137
|
+
export declare function includeKeysForBatching(select: Record<string, boolean> | undefined, omit: Record<string, boolean> | undefined, fields: string[],
|
|
138
|
+
/**
|
|
139
|
+
* The default projection for this table when it is NOT `select`/`omit`-driven:
|
|
140
|
+
* `hidden` are fields the default projection leaves out (today: PII-tagged
|
|
141
|
+
* columns without `includePii`), `visible` is everything it does return.
|
|
142
|
+
*
|
|
143
|
+
* Without this, a correlation key that is itself PII-tagged is absent from
|
|
144
|
+
* every row, the loader sees no keys, and it silently hands back empty
|
|
145
|
+
* relation arrays. Passing it turns that case into an explicit select that
|
|
146
|
+
* re-adds only the key, which is then stripped like any other stitch-only
|
|
147
|
+
* field, so no PII value ever reaches the caller.
|
|
148
|
+
*/
|
|
149
|
+
defaultProjection?: {
|
|
150
|
+
hidden: ReadonlySet<string>;
|
|
151
|
+
visible: string[];
|
|
152
|
+
}): {
|
|
125
153
|
select?: Record<string, boolean>;
|
|
126
154
|
omit?: Record<string, boolean>;
|
|
127
155
|
strip: string[];
|
|
@@ -61,6 +61,29 @@ import { ownLookup } from './utils.js';
|
|
|
61
61
|
const MAX_RELATION_KEYS = 32_000;
|
|
62
62
|
/** Nesting cap — parity with the join strategy's depth-10 guard. */
|
|
63
63
|
const MAX_DEPTH = 10;
|
|
64
|
+
/**
|
|
65
|
+
* The default projection of `meta` expressed in FIELD names: which fields the
|
|
66
|
+
* default (no `select`/`omit`) projection hides, and which it returns. Today the
|
|
67
|
+
* only hidden class is PII-tagged columns, and only when `includePii` is off.
|
|
68
|
+
*
|
|
69
|
+
* Returns `undefined` for the overwhelmingly common untagged case, so callers
|
|
70
|
+
* keep the `select: undefined, omit: undefined` fast path and the emitted SQL
|
|
71
|
+
* stays byte-identical.
|
|
72
|
+
*/
|
|
73
|
+
export function defaultProjectionFields(meta, includePii) {
|
|
74
|
+
if (includePii)
|
|
75
|
+
return undefined;
|
|
76
|
+
const hidden = new Set();
|
|
77
|
+
const visible = [];
|
|
78
|
+
for (const col of meta.columns) {
|
|
79
|
+
const field = meta.reverseColumnMap[col.name] ?? col.name;
|
|
80
|
+
if (col.pii)
|
|
81
|
+
hidden.add(field);
|
|
82
|
+
else
|
|
83
|
+
visible.push(field);
|
|
84
|
+
}
|
|
85
|
+
return hidden.size === 0 ? undefined : { hidden, visible };
|
|
86
|
+
}
|
|
64
87
|
/**
|
|
65
88
|
* Adjust a `select`/`omit` pair so that `fields` are guaranteed present in the
|
|
66
89
|
* query result, returning the adjusted projection plus the list of fields that
|
|
@@ -70,7 +93,19 @@ const MAX_DEPTH = 10;
|
|
|
70
93
|
* keys) so a caller's `select: { title: true }` on a relation still stitches even
|
|
71
94
|
* though the FK was not requested — and the FK never appears in the output.
|
|
72
95
|
*/
|
|
73
|
-
export function includeKeysForBatching(select, omit, fields
|
|
96
|
+
export function includeKeysForBatching(select, omit, fields,
|
|
97
|
+
/**
|
|
98
|
+
* The default projection for this table when it is NOT `select`/`omit`-driven:
|
|
99
|
+
* `hidden` are fields the default projection leaves out (today: PII-tagged
|
|
100
|
+
* columns without `includePii`), `visible` is everything it does return.
|
|
101
|
+
*
|
|
102
|
+
* Without this, a correlation key that is itself PII-tagged is absent from
|
|
103
|
+
* every row, the loader sees no keys, and it silently hands back empty
|
|
104
|
+
* relation arrays. Passing it turns that case into an explicit select that
|
|
105
|
+
* re-adds only the key, which is then stripped like any other stitch-only
|
|
106
|
+
* field, so no PII value ever reaches the caller.
|
|
107
|
+
*/
|
|
108
|
+
defaultProjection) {
|
|
74
109
|
const unique = [...new Set(fields)];
|
|
75
110
|
if (select) {
|
|
76
111
|
const next = { ...select };
|
|
@@ -94,7 +129,19 @@ export function includeKeysForBatching(select, omit, fields) {
|
|
|
94
129
|
}
|
|
95
130
|
return { select, omit: next, strip };
|
|
96
131
|
}
|
|
97
|
-
// Neither select nor omit
|
|
132
|
+
// Neither select nor omit. Every column the DEFAULT projection returns is
|
|
133
|
+
// already present, so normally there is nothing to strip; the exception is a
|
|
134
|
+
// key the default projection hides (a PII-tagged correlation column), which
|
|
135
|
+
// has to be asked for explicitly.
|
|
136
|
+
const hiddenKeys = defaultProjection ? unique.filter((f) => defaultProjection.hidden.has(f)) : [];
|
|
137
|
+
if (hiddenKeys.length > 0 && defaultProjection) {
|
|
138
|
+
const explicit = {};
|
|
139
|
+
for (const f of defaultProjection.visible)
|
|
140
|
+
explicit[f] = true;
|
|
141
|
+
for (const f of hiddenKeys)
|
|
142
|
+
explicit[f] = true;
|
|
143
|
+
return { select: explicit, omit: undefined, strip: hiddenKeys };
|
|
144
|
+
}
|
|
98
145
|
return { select, omit, strip: [] };
|
|
99
146
|
}
|
|
100
147
|
/** Delete stitch-only key fields from each row (no-op when `fields` is empty). */
|
|
@@ -272,7 +319,7 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
|
|
|
272
319
|
}
|
|
273
320
|
// The follow-up must project the child correlation key even if the caller's
|
|
274
321
|
// select/omit excluded it; strip it back off afterwards so the shape matches join.
|
|
275
|
-
const proj = includeKeysForBatching(options.select, options.omit, [childKeyField]);
|
|
322
|
+
const proj = includeKeysForBatching(options.select, options.omit, [childKeyField], defaultProjectionFields(targetMeta, ctx.includePii));
|
|
276
323
|
const child = ctx.makeChild(rel.to);
|
|
277
324
|
const chunks = [];
|
|
278
325
|
for (let i = 0; i < keys.length; i += MAX_RELATION_KEYS)
|
|
@@ -374,7 +421,7 @@ async function loadManyToMany(ctx, parents, rel, relName, options, timeout, dept
|
|
|
374
421
|
}
|
|
375
422
|
}
|
|
376
423
|
// (2) Target rows by PK, honouring the relation's own where/select/omit/orderBy.
|
|
377
|
-
const proj = includeKeysForBatching(options.select, options.omit, [targetPkField]);
|
|
424
|
+
const proj = includeKeysForBatching(options.select, options.omit, [targetPkField], defaultProjectionFields(targetMeta, ctx.includePii));
|
|
378
425
|
const child = ctx.makeChild(rel.to);
|
|
379
426
|
const targetVals = [...targetValSet];
|
|
380
427
|
const tChunks = [];
|
|
@@ -527,9 +574,25 @@ async function loadOneCount(ctx, parents, rel) {
|
|
|
527
574
|
// ---------------------------------------------------------------------------
|
|
528
575
|
// Small helpers
|
|
529
576
|
// ---------------------------------------------------------------------------
|
|
530
|
-
/**
|
|
577
|
+
/**
|
|
578
|
+
* AND the batched correlation predicate (`key IN chunk`) onto the relation's own
|
|
579
|
+
* `where`, matching the join strategy, which appends the correlation with
|
|
580
|
+
* ` AND <extra>` and so never lets one predicate replace the other.
|
|
581
|
+
*
|
|
582
|
+
* A flat spread is kept for the overwhelmingly common case where the caller's
|
|
583
|
+
* `where` does not name the correlation field, so the emitted SQL is unchanged
|
|
584
|
+
* there. When it DOES name it (e.g. `with: { posts: { where: { userId: 1 } } }`,
|
|
585
|
+
* or a belongsTo `where` on the child's PK), a bare spread would let the chunk
|
|
586
|
+
* predicate silently overwrite the caller's filter and return rows the join
|
|
587
|
+
* strategy excludes; the two are combined with `AND` instead so both apply.
|
|
588
|
+
*/
|
|
531
589
|
function mergeChildWhere(where, keyField, chunk) {
|
|
532
|
-
|
|
590
|
+
const correlation = { [keyField]: { in: chunk } };
|
|
591
|
+
if (!where)
|
|
592
|
+
return correlation;
|
|
593
|
+
if (Object.hasOwn(where, keyField))
|
|
594
|
+
return { AND: [where, correlation] };
|
|
595
|
+
return { ...where, ...correlation };
|
|
533
596
|
}
|
|
534
597
|
/** Distinct, non-null values of `field` across `rows`. */
|
|
535
598
|
function uniqueKeys(rows, field) {
|
package/dist/query/builder.js
CHANGED
|
@@ -16,7 +16,7 @@ import { missingIndexForRelation, schemaHasIndexInfo } from '../index-advisor.js
|
|
|
16
16
|
import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from '../nested-write.js';
|
|
17
17
|
import { camelToSnake, normalizeKeyColumns, snakeToCamel } from '../schema.js';
|
|
18
18
|
import * as aggMod from './aggregates.js';
|
|
19
|
-
import { includeKeysForBatching, loadRelationsBatched, neededParentKeyFields, rejectNestedPickOrder, resolveCountRelations, stripFields, } from './batched-loader.js';
|
|
19
|
+
import { defaultProjectionFields, includeKeysForBatching, loadRelationsBatched, neededParentKeyFields, rejectNestedPickOrder, resolveCountRelations, stripFields, } from './batched-loader.js';
|
|
20
20
|
import { expandCompoundUniqueWhere } from './compound-unique.js';
|
|
21
21
|
import { isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isVectorOrderBy, isWhereOperator, orderByEntries, sortedEntries, } from './filters.js';
|
|
22
22
|
import * as relationsMod from './relations.js';
|
|
@@ -745,7 +745,7 @@ export class QueryInterface {
|
|
|
745
745
|
rejectNestedPickOrder(batchedWith);
|
|
746
746
|
const skip = args.skipGlobalFilters;
|
|
747
747
|
const needed = neededParentKeyFields(this.tableMeta, batchedWith);
|
|
748
|
-
const proj = includeKeysForBatching(args.select, args.omit, needed);
|
|
748
|
+
const proj = includeKeysForBatching(args.select, args.omit, needed, defaultProjectionFields(this.tableMeta, args.includePii));
|
|
749
749
|
const hasJoin = Object.keys(joinWith).length > 0;
|
|
750
750
|
// Force the residual `with` onto the join plan so the base query never
|
|
751
751
|
// re-enters this auto planning.
|
|
@@ -858,7 +858,7 @@ export class QueryInterface {
|
|
|
858
858
|
*/
|
|
859
859
|
prepareBatchedBase(args, withClause) {
|
|
860
860
|
const needed = neededParentKeyFields(this.tableMeta, withClause);
|
|
861
|
-
const proj = includeKeysForBatching(args.select, args.omit, needed);
|
|
861
|
+
const proj = includeKeysForBatching(args.select, args.omit, needed, defaultProjectionFields(this.tableMeta, args.includePii));
|
|
862
862
|
const baseArgs = {
|
|
863
863
|
...args,
|
|
864
864
|
with: undefined,
|
|
@@ -1164,7 +1164,7 @@ export class QueryInterface {
|
|
|
1164
1164
|
// Same scope-rule parity as runFindManyBatched: reject before querying.
|
|
1165
1165
|
rejectNestedPickOrder(withClause);
|
|
1166
1166
|
const needed = neededParentKeyFields(this.tableMeta, withClause);
|
|
1167
|
-
const proj = includeKeysForBatching(args.select, args.omit, needed);
|
|
1167
|
+
const proj = includeKeysForBatching(args.select, args.omit, needed, defaultProjectionFields(this.tableMeta, args.includePii));
|
|
1168
1168
|
const baseArgs = { ...args, with: undefined, select: proj.select, omit: proj.omit };
|
|
1169
1169
|
const deferred = this.buildFindUnique(baseArgs);
|
|
1170
1170
|
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
package/dist/query/relations.js
CHANGED
|
@@ -480,8 +480,18 @@ export function buildRelationOrderBy(qi, relName, value, alias, params, ctx, lat
|
|
|
480
480
|
const parentRef = ctx?.parentRef ?? qi.table;
|
|
481
481
|
const relDef = ownerMeta.relations[relName];
|
|
482
482
|
if (!relDef) {
|
|
483
|
-
|
|
484
|
-
|
|
483
|
+
// A table with no relations at all would otherwise render a dangling
|
|
484
|
+
// "Available: " and read as a broken message; and the most likely cause of
|
|
485
|
+
// landing here on such a table is an orderBy VALUE of the wrong shape on a
|
|
486
|
+
// scalar column, which deserves to be named rather than reported as a
|
|
487
|
+
// missing relation.
|
|
488
|
+
const known = Object.keys(ownerMeta.relations);
|
|
489
|
+
const isColumn = Object.hasOwn(ownerMeta.columnMap, relName) || ownerMeta.allColumns.includes(relName);
|
|
490
|
+
throw new RelationError(isColumn
|
|
491
|
+
? `[turbine] orderBy on "${ownerTable}.${relName}" got a relation-shaped value, but "${relName}" is a ` +
|
|
492
|
+
`column. Order a column with 'asc' / 'desc' (or { sort, nulls }); the object form is for relations.`
|
|
493
|
+
: `[turbine] Unknown relation "${relName}" in orderBy on table "${ownerTable}". ` +
|
|
494
|
+
(known.length > 0 ? `Available: ${known.join(', ')}` : `"${ownerTable}" has no relations.`));
|
|
485
495
|
}
|
|
486
496
|
// Pick-row ordering (`{ pick, by }`): order by a value from ONE related
|
|
487
497
|
// row: a correlated scalar subquery with its own ORDER BY … LIMIT 1.
|
package/dist/query/types.d.ts
CHANGED
|
@@ -802,6 +802,14 @@ export interface GroupByArgs<T> {
|
|
|
802
802
|
* grouped results; combine with `limit` and a deterministic `orderBy`.
|
|
803
803
|
*/
|
|
804
804
|
offset?: number;
|
|
805
|
+
/**
|
|
806
|
+
* Opt in to grouping on / aggregating PII-tagged (`defineSchema` `pii: true`)
|
|
807
|
+
* columns. A PII column in `by`, and `_min` / `_max` over a PII column, return
|
|
808
|
+
* STORED VALUES, so without this they throw `ValidationError` (E003).
|
|
809
|
+
* `_count` (a count, not a value), `_sum` / `_avg`, and `where` / `orderBy` /
|
|
810
|
+
* `having` on PII columns stay allowed.
|
|
811
|
+
*/
|
|
812
|
+
includePii?: boolean;
|
|
805
813
|
/** Query timeout in milliseconds. Rejects with an error if exceeded. */
|
|
806
814
|
timeout?: number;
|
|
807
815
|
/** Opt out of configured {@link GlobalFilters}. See {@link SkipGlobalFilters}. */
|
|
@@ -894,6 +902,14 @@ export interface AggregateArgs<T> {
|
|
|
894
902
|
_min?: Partial<Record<keyof T & string, boolean>>;
|
|
895
903
|
/** Maximum value of fields */
|
|
896
904
|
_max?: Partial<Record<keyof T & string, boolean>>;
|
|
905
|
+
/**
|
|
906
|
+
* Opt in to aggregating PII-tagged (`defineSchema` `pii: true`) columns.
|
|
907
|
+
* `_min` / `_max` return a STORED VALUE, so without this they throw
|
|
908
|
+
* `ValidationError` (E003) on a PII column. `_count` (a count, not a value)
|
|
909
|
+
* and `_sum` / `_avg` (a computed total over many rows) stay allowed, as do
|
|
910
|
+
* `where` filters on PII columns.
|
|
911
|
+
*/
|
|
912
|
+
includePii?: boolean;
|
|
897
913
|
/** Query timeout in milliseconds. Rejects with an error if exceeded. */
|
|
898
914
|
timeout?: number;
|
|
899
915
|
/** Opt out of configured {@link GlobalFilters}. See {@link SkipGlobalFilters}. */
|
package/dist/query/where.d.ts
CHANGED
|
@@ -170,7 +170,7 @@ export declare function collectOperatorParams(qi: BuilderCtx, column: string, op
|
|
|
170
170
|
*/
|
|
171
171
|
export declare function collectJsonFilterParams(qi: BuilderCtx, filter: JsonFilter, params: unknown[], column: string): void;
|
|
172
172
|
/** Collect params from array filter. Mirrors buildArrayFilterClauses. */
|
|
173
|
-
export declare function collectArrayFilterParams(
|
|
173
|
+
export declare function collectArrayFilterParams(qi: BuilderCtx, filter: ArrayFilter, params: unknown[]): void;
|
|
174
174
|
/**
|
|
175
175
|
* Collect params for a vector distance WHERE filter. Mirrors
|
|
176
176
|
* {@link buildVectorFilterClauses}: the `$n::vector` query vector first, then
|
|
@@ -386,6 +386,23 @@ export declare function columnRefSql(qi: BuilderCtx, ref: ColumnRef, ctx: Column
|
|
|
386
386
|
* pushing nothing and the referenced name lives in the fingerprint.
|
|
387
387
|
*/
|
|
388
388
|
export declare function buildOperatorClauses(qi: BuilderCtx, column: string, op: WhereOperator, params: unknown[], refCtx?: ColumnRefContext): string[];
|
|
389
|
+
/**
|
|
390
|
+
* Gate the full-text `search` filter on {@link Dialect.supportsFullTextSearch}.
|
|
391
|
+
* The clause it guards is `to_tsvector(...) @@ to_tsquery(...)`, which only
|
|
392
|
+
* PostgreSQL parses, so every other engine gets a typed
|
|
393
|
+
* {@link UnsupportedFeatureError} (E017) instead of a raw driver syntax error.
|
|
394
|
+
* Called from BOTH the build and the param-collect side (mirroring the vector
|
|
395
|
+
* gate) so the two paths can never diverge.
|
|
396
|
+
*/
|
|
397
|
+
export declare function requireFullTextSearch(qi: BuilderCtx): void;
|
|
398
|
+
/**
|
|
399
|
+
* Gate the array filter operators (`has` / `hasEvery` / `hasSome` / `isEmpty`)
|
|
400
|
+
* on {@link Dialect.supportsArrayColumns}. They compile to PostgreSQL array
|
|
401
|
+
* operators (`= ANY(col)`, `@>`, `&&`, `cardinality(col)`) over a native array
|
|
402
|
+
* column, which no other supported engine has. Called from BOTH the build and
|
|
403
|
+
* the param-collect side.
|
|
404
|
+
*/
|
|
405
|
+
export declare function requireArrayColumns(qi: BuilderCtx): void;
|
|
389
406
|
/**
|
|
390
407
|
* Resolve a {@link VectorMetric} to its pgvector distance operator from a
|
|
391
408
|
* fixed allow-list, validating the target column is actually a `vector`
|
package/dist/query/where.js
CHANGED
|
@@ -148,6 +148,9 @@ export function collectScalarParams(qi, key, value, params) {
|
|
|
148
148
|
collectArrayFilterParams(qi, value, params);
|
|
149
149
|
return;
|
|
150
150
|
case 'textsearch':
|
|
151
|
+
// Same gate the build path applies, so the collect path never diverges
|
|
152
|
+
// (it throws before any param is pushed).
|
|
153
|
+
requireFullTextSearch(qi);
|
|
151
154
|
params.push(value.search);
|
|
152
155
|
return;
|
|
153
156
|
case 'operator':
|
|
@@ -284,7 +287,8 @@ export function collectJsonFilterParams(qi, filter, params, column) {
|
|
|
284
287
|
}
|
|
285
288
|
}
|
|
286
289
|
/** Collect params from array filter. Mirrors buildArrayFilterClauses. */
|
|
287
|
-
export function collectArrayFilterParams(
|
|
290
|
+
export function collectArrayFilterParams(qi, filter, params) {
|
|
291
|
+
requireArrayColumns(qi);
|
|
288
292
|
if (filter.has !== undefined)
|
|
289
293
|
params.push(filter.has);
|
|
290
294
|
if (filter.hasEvery !== undefined)
|
|
@@ -1161,6 +1165,33 @@ export function buildOperatorClauses(qi, column, op, params, refCtx) {
|
|
|
1161
1165
|
}
|
|
1162
1166
|
return clauses;
|
|
1163
1167
|
}
|
|
1168
|
+
/**
|
|
1169
|
+
* Gate the full-text `search` filter on {@link Dialect.supportsFullTextSearch}.
|
|
1170
|
+
* The clause it guards is `to_tsvector(...) @@ to_tsquery(...)`, which only
|
|
1171
|
+
* PostgreSQL parses, so every other engine gets a typed
|
|
1172
|
+
* {@link UnsupportedFeatureError} (E017) instead of a raw driver syntax error.
|
|
1173
|
+
* Called from BOTH the build and the param-collect side (mirroring the vector
|
|
1174
|
+
* gate) so the two paths can never diverge.
|
|
1175
|
+
*/
|
|
1176
|
+
export function requireFullTextSearch(qi) {
|
|
1177
|
+
if (qi.dialect.supportsFullTextSearch)
|
|
1178
|
+
return;
|
|
1179
|
+
throw new UnsupportedFeatureError('the full-text search filter (`search`)', qi.dialect.name, 'Full-text `search` compiles to PostgreSQL to_tsvector/to_tsquery. ' +
|
|
1180
|
+
'Use `contains` (LIKE) on this engine, or run the query on PostgreSQL.');
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* Gate the array filter operators (`has` / `hasEvery` / `hasSome` / `isEmpty`)
|
|
1184
|
+
* on {@link Dialect.supportsArrayColumns}. They compile to PostgreSQL array
|
|
1185
|
+
* operators (`= ANY(col)`, `@>`, `&&`, `cardinality(col)`) over a native array
|
|
1186
|
+
* column, which no other supported engine has. Called from BOTH the build and
|
|
1187
|
+
* the param-collect side.
|
|
1188
|
+
*/
|
|
1189
|
+
export function requireArrayColumns(qi) {
|
|
1190
|
+
if (qi.dialect.supportsArrayColumns)
|
|
1191
|
+
return;
|
|
1192
|
+
throw new UnsupportedFeatureError('the array column filter set (`has` / `hasEvery` / `hasSome` / `isEmpty`)', qi.dialect.name, 'Array filters compile to PostgreSQL array operators over a native array ' +
|
|
1193
|
+
'column; this engine has no array column type.');
|
|
1194
|
+
}
|
|
1164
1195
|
/**
|
|
1165
1196
|
* Resolve a {@link VectorMetric} to its pgvector distance operator from a
|
|
1166
1197
|
* fixed allow-list, validating the target column is actually a `vector`
|
|
@@ -1378,6 +1409,7 @@ export function castJsonNumeric(qi, extract) {
|
|
|
1378
1409
|
* Supports: has, hasEvery, hasSome, isEmpty.
|
|
1379
1410
|
*/
|
|
1380
1411
|
export function buildArrayFilterClauses(qi, column, filter, params, pgType) {
|
|
1412
|
+
requireArrayColumns(qi);
|
|
1381
1413
|
const clauses = [];
|
|
1382
1414
|
const elementType = getArrayElementType(qi, pgType);
|
|
1383
1415
|
if (filter.has !== undefined) {
|
|
@@ -1443,6 +1475,7 @@ export function buildVectorFilterClauses(qi, field, rawColumn, filter, params) {
|
|
|
1443
1475
|
* The config name is validated to prevent injection (only alphanumeric + underscore).
|
|
1444
1476
|
*/
|
|
1445
1477
|
export function buildTextSearchClause(qi, column, filter, params) {
|
|
1478
|
+
requireFullTextSearch(qi);
|
|
1446
1479
|
const config = filter.config ?? 'english';
|
|
1447
1480
|
if (!validateTextSearchConfig(config)) {
|
|
1448
1481
|
throw new ValidationError(`[turbine] Invalid text search config "${config}": only alphanumeric characters and underscores are allowed.`);
|