turbine-orm 0.19.1 → 0.19.2
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 +75 -7
- package/dist/cjs/cli/index.js +17 -9
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.js +0 -30
- package/dist/cjs/client.js +12 -13
- package/dist/cjs/query/builder.js +115 -49
- package/dist/cjs/query/utils.js +1 -0
- package/dist/cli/index.js +17 -9
- package/dist/cli/migrate.d.ts +2 -2
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +0 -10
- package/dist/cli/studio.js +0 -29
- package/dist/client.d.ts +12 -13
- package/dist/client.js +12 -13
- package/dist/index.d.ts +1 -1
- package/dist/query/builder.d.ts +8 -6
- package/dist/query/builder.js +115 -49
- package/dist/query/index.d.ts +1 -1
- package/dist/query/types.d.ts +62 -12
- package/dist/query/utils.js +1 -0
- package/package.json +3 -3
package/dist/cli/studio.d.ts
CHANGED
|
@@ -79,14 +79,4 @@ export declare function apiBuilder(req: IncomingMessage, res: ServerResponse, ct
|
|
|
79
79
|
export declare function apiListSavedQueries(res: ServerResponse, ctx: StudioContext, params: URLSearchParams): void;
|
|
80
80
|
export declare function apiCreateSavedQuery(req: IncomingMessage, res: ServerResponse, ctx: StudioContext): Promise<void>;
|
|
81
81
|
export declare function apiDeleteSavedQuery(res: ServerResponse, ctx: StudioContext, id: string): void;
|
|
82
|
-
/**
|
|
83
|
-
* Accept only SELECT or WITH (CTE) statements. Reject any statement that
|
|
84
|
-
* contains a semicolon followed by non-whitespace (prevents statement
|
|
85
|
-
* stacking), and require the first non-comment keyword to be SELECT or WITH.
|
|
86
|
-
*
|
|
87
|
-
* This is a first-line filter — the transaction's READ ONLY mode is the
|
|
88
|
-
* second line of defense. Both must fail before a destructive statement
|
|
89
|
-
* could run.
|
|
90
|
-
*/
|
|
91
|
-
export declare function isReadOnlyStatement(sql: string): boolean;
|
|
92
82
|
//# sourceMappingURL=studio.d.ts.map
|
package/dist/cli/studio.js
CHANGED
|
@@ -535,35 +535,6 @@ function clampInt(value, fallback, min, max) {
|
|
|
535
535
|
return fallback;
|
|
536
536
|
return Math.min(Math.max(n, min), max);
|
|
537
537
|
}
|
|
538
|
-
/**
|
|
539
|
-
* Accept only SELECT or WITH (CTE) statements. Reject any statement that
|
|
540
|
-
* contains a semicolon followed by non-whitespace (prevents statement
|
|
541
|
-
* stacking), and require the first non-comment keyword to be SELECT or WITH.
|
|
542
|
-
*
|
|
543
|
-
* This is a first-line filter — the transaction's READ ONLY mode is the
|
|
544
|
-
* second line of defense. Both must fail before a destructive statement
|
|
545
|
-
* could run.
|
|
546
|
-
*/
|
|
547
|
-
export function isReadOnlyStatement(sql) {
|
|
548
|
-
const stripped = stripSqlComments(sql).trim();
|
|
549
|
-
if (!stripped)
|
|
550
|
-
return false;
|
|
551
|
-
// Disallow statement stacking. A single trailing `;` is fine.
|
|
552
|
-
const withoutTrailingSemi = stripped.replace(/;+\s*$/, '');
|
|
553
|
-
if (withoutTrailingSemi.includes(';'))
|
|
554
|
-
return false;
|
|
555
|
-
const firstWord = withoutTrailingSemi.slice(0, 6).toUpperCase();
|
|
556
|
-
if (firstWord.startsWith('SELECT'))
|
|
557
|
-
return true;
|
|
558
|
-
if (firstWord.startsWith('WITH'))
|
|
559
|
-
return true;
|
|
560
|
-
return false;
|
|
561
|
-
}
|
|
562
|
-
function stripSqlComments(sql) {
|
|
563
|
-
// Strip -- line comments and /* block comments */. Not a full SQL parser,
|
|
564
|
-
// but sufficient to catch the common bypass attempts.
|
|
565
|
-
return sql.replace(/--[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
566
|
-
}
|
|
567
538
|
function serializeRow(row) {
|
|
568
539
|
const out = {};
|
|
569
540
|
for (const [k, v] of Object.entries(row)) {
|
package/dist/client.d.ts
CHANGED
|
@@ -255,12 +255,14 @@ export declare class TurbineClient {
|
|
|
255
255
|
private readonly activeSubscriptions;
|
|
256
256
|
constructor(config: TurbineConfig | undefined, schema: SchemaMetadata);
|
|
257
257
|
/**
|
|
258
|
-
* Register a middleware function that runs
|
|
258
|
+
* Register a middleware function that runs around every query.
|
|
259
259
|
*
|
|
260
|
-
* Middleware can inspect and log query parameters,
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
260
|
+
* Middleware can inspect and log query parameters, measure timing, and
|
|
261
|
+
* transform the result returned by `next()`. Note: query SQL is generated
|
|
262
|
+
* BEFORE middleware runs — `params.args` is a read-only snapshot, and
|
|
263
|
+
* mutating it does NOT change the executed SQL. Cross-cutting filters
|
|
264
|
+
* (e.g. soft deletes) belong in the query itself: pass an explicit
|
|
265
|
+
* `where: { deletedAt: null }` or wrap the table accessor in a small helper.
|
|
264
266
|
*
|
|
265
267
|
* @example
|
|
266
268
|
* ```ts
|
|
@@ -272,16 +274,13 @@ export declare class TurbineClient {
|
|
|
272
274
|
* return result;
|
|
273
275
|
* });
|
|
274
276
|
*
|
|
275
|
-
* //
|
|
277
|
+
* // Result transformation middleware — redact a field on the way out
|
|
276
278
|
* db.$use(async (params, next) => {
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
* if (params.action === 'delete') {
|
|
281
|
-
* params.action = 'update';
|
|
282
|
-
* params.args = { where: params.args.where, data: { deletedAt: new Date() } };
|
|
279
|
+
* const result = await next(params);
|
|
280
|
+
* if (params.model === 'users' && Array.isArray(result)) {
|
|
281
|
+
* for (const row of result as { email?: string }[]) row.email = '[redacted]';
|
|
283
282
|
* }
|
|
284
|
-
* return
|
|
283
|
+
* return result;
|
|
285
284
|
* });
|
|
286
285
|
* ```
|
|
287
286
|
*/
|
package/dist/client.js
CHANGED
|
@@ -323,12 +323,14 @@ export class TurbineClient {
|
|
|
323
323
|
// Middleware — intercept all queries
|
|
324
324
|
// -------------------------------------------------------------------------
|
|
325
325
|
/**
|
|
326
|
-
* Register a middleware function that runs
|
|
326
|
+
* Register a middleware function that runs around every query.
|
|
327
327
|
*
|
|
328
|
-
* Middleware can inspect and log query parameters,
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
328
|
+
* Middleware can inspect and log query parameters, measure timing, and
|
|
329
|
+
* transform the result returned by `next()`. Note: query SQL is generated
|
|
330
|
+
* BEFORE middleware runs — `params.args` is a read-only snapshot, and
|
|
331
|
+
* mutating it does NOT change the executed SQL. Cross-cutting filters
|
|
332
|
+
* (e.g. soft deletes) belong in the query itself: pass an explicit
|
|
333
|
+
* `where: { deletedAt: null }` or wrap the table accessor in a small helper.
|
|
332
334
|
*
|
|
333
335
|
* @example
|
|
334
336
|
* ```ts
|
|
@@ -340,16 +342,13 @@ export class TurbineClient {
|
|
|
340
342
|
* return result;
|
|
341
343
|
* });
|
|
342
344
|
*
|
|
343
|
-
* //
|
|
345
|
+
* // Result transformation middleware — redact a field on the way out
|
|
344
346
|
* db.$use(async (params, next) => {
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
* if (params.action === 'delete') {
|
|
349
|
-
* params.action = 'update';
|
|
350
|
-
* params.args = { where: params.args.where, data: { deletedAt: new Date() } };
|
|
347
|
+
* const result = await next(params);
|
|
348
|
+
* if (params.model === 'users' && Array.isArray(result)) {
|
|
349
|
+
* for (const row of result as { email?: string }[]) row.email = '[redacted]';
|
|
351
350
|
* }
|
|
352
|
-
* return
|
|
351
|
+
* return result;
|
|
353
352
|
* });
|
|
354
353
|
* ```
|
|
355
354
|
*/
|
package/dist/index.d.ts
CHANGED
|
@@ -43,7 +43,7 @@ export { type IntrospectOptions, introspect } from './introspect.js';
|
|
|
43
43
|
export { executeNestedCreate, executeNestedUpdate, hasRelationFields, type NestedWriteContext, } from './nested-write.js';
|
|
44
44
|
export type { ObserveConfig, ObserveHandle } from './observe.js';
|
|
45
45
|
export { executePipeline, type PipelineOptions, type PipelineResults, pipelineSupported } from './pipeline.js';
|
|
46
|
-
export { type AggregateArgs, type AggregateResult, type ArrayFilter, type ConnectOrCreateOp, type CountArgs, type CreateArgs, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FieldResult, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GroupByArgs, type HavingClause, type JsonFilter, type MiddlewareFn, type NestedCreateOp, type NestedUpdateOp, type OmitResult, type OrderByClause, type OrderDirection, type QueryEvent, type QueryEventListener, QueryInterface, type QueryResult, type RelationDescriptor, type RelationFilter, type SelectResult, type TextSearchFilter, type TypedWithClause, type UpdateArgs, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type VectorDistanceFilter, type VectorFilter, type VectorMetric, type VectorOrderBy, type VectorOrderByDistance, type WhereClause, type WhereOperator, type WhereValue, type WithClause, type WithOptions, type WithResult, } from './query/index.js';
|
|
46
|
+
export { type AggregateArgs, type AggregateResult, type ArrayFilter, type ConnectOrCreateOp, type CountArgs, type CreateArgs, type CreateDataInput, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FieldResult, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GroupByArgs, type HavingClause, type JsonFilter, type MiddlewareFn, type NestedCreateOp, type NestedUpdateOp, type NestedUpdateOpItem, type NestedUpsertOpItem, type OmitResult, type OrderByClause, type OrderDirection, type QueryEvent, type QueryEventListener, QueryInterface, type QueryResult, type RelationDescriptor, type RelationFilter, type SelectResult, type TextSearchFilter, type TypedWithClause, type UpdateArgs, type UpdateDataInput, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type VectorDistanceFilter, type VectorFilter, type VectorMetric, type VectorOrderBy, type VectorOrderByDistance, type WhereClause, type WhereOperator, type WhereValue, type WithClause, type WithOptions, type WithResult, } from './query/index.js';
|
|
47
47
|
export { type ActiveSubscription, type NotificationHandler, type Subscription, validateChannel } from './realtime.js';
|
|
48
48
|
export type { ColumnMetadata, IndexMetadata, RelationDef, SchemaMetadata, TableMetadata, } from './schema.js';
|
|
49
49
|
export { camelToSnake, isDateType, normalizeKeyColumns, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, } from './schema.js';
|
package/dist/query/builder.d.ts
CHANGED
|
@@ -168,10 +168,12 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
168
168
|
* Execute a query through the middleware chain.
|
|
169
169
|
* If no middlewares are registered, executes directly.
|
|
170
170
|
*
|
|
171
|
-
* Middleware can inspect and log query parameters,
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
171
|
+
* Middleware can inspect and log query parameters, measure timing, and
|
|
172
|
+
* transform the result returned by `next()`. Note: query SQL is generated
|
|
173
|
+
* BEFORE middleware runs — `params.args` is a read-only snapshot, and
|
|
174
|
+
* mutating it does NOT change the executed SQL. Cross-cutting filters
|
|
175
|
+
* (e.g. soft deletes) belong in the query itself: pass an explicit
|
|
176
|
+
* `where: { deletedAt: null }` or wrap the table accessor in a small helper.
|
|
175
177
|
*/
|
|
176
178
|
private executeWithMiddleware;
|
|
177
179
|
findUnique<W extends TypedWithClause<R> = {}, S extends Record<string, boolean> | undefined = undefined, O extends Record<string, boolean> | undefined = undefined>(args: FindUniqueArgs<T, R, W, S, O>): Promise<QueryResult<T, R, W, S, O> | null>;
|
|
@@ -228,11 +230,11 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
228
230
|
buildFindFirstOrThrow<W extends TypedWithClause<R> = {}>(args?: FindManyArgs<T, R, W, Record<string, boolean> | undefined, Record<string, boolean> | undefined>): DeferredQuery<T>;
|
|
229
231
|
findUniqueOrThrow<W extends TypedWithClause<R> = {}, S extends Record<string, boolean> | undefined = undefined, O extends Record<string, boolean> | undefined = undefined>(args: FindUniqueArgs<T, R, W, S, O>): Promise<QueryResult<T, R, W, S, O>>;
|
|
230
232
|
buildFindUniqueOrThrow<W extends TypedWithClause<R> = {}>(args: FindUniqueArgs<T, R, W, Record<string, boolean> | undefined, Record<string, boolean> | undefined>): DeferredQuery<T>;
|
|
231
|
-
create(args: CreateArgs<T>): Promise<T>;
|
|
233
|
+
create(args: CreateArgs<T, R>): Promise<T>;
|
|
232
234
|
buildCreate(args: CreateArgs<T>): DeferredQuery<T>;
|
|
233
235
|
createMany(args: CreateManyArgs<T>): Promise<T[]>;
|
|
234
236
|
buildCreateMany(args: CreateManyArgs<T>): DeferredQuery<T[]>;
|
|
235
|
-
update(args: UpdateArgs<T>): Promise<T>;
|
|
237
|
+
update(args: UpdateArgs<T, R>): Promise<T>;
|
|
236
238
|
buildUpdate(args: UpdateArgs<T>): DeferredQuery<T>;
|
|
237
239
|
private nestedCreate;
|
|
238
240
|
private nestedUpdate;
|
package/dist/query/builder.js
CHANGED
|
@@ -44,6 +44,53 @@ function isUnmatchedPlainObject(value) {
|
|
|
44
44
|
const proto = Object.getPrototypeOf(value);
|
|
45
45
|
return proto === Object.prototype || proto === null;
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Fingerprint the SHAPE of a where-operator object. Null-valued `equals` /
|
|
49
|
+
* `not` compile to parameterless `IS NULL` / `IS NOT NULL` (different SQL, no
|
|
50
|
+
* param pushed), so null-ness is part of the shape — without it a cache entry
|
|
51
|
+
* warmed by `{ not: 5 }` would serve `{ not: null }` with a desynced param list.
|
|
52
|
+
*/
|
|
53
|
+
function fingerprintOperatorShape(value) {
|
|
54
|
+
const obj = value;
|
|
55
|
+
const opKeys = Object.keys(obj)
|
|
56
|
+
.filter((k) => k !== 'mode')
|
|
57
|
+
.map((k) => ((k === 'equals' || k === 'not') && obj[k] === null ? `${k}:null` : k))
|
|
58
|
+
.sort();
|
|
59
|
+
const modeStr = value.mode === 'insensitive' ? ':i' : '';
|
|
60
|
+
return `op(${opKeys.join(',')}${modeStr})`;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Guard for the value of an `equals` operator reaching the plain-equality
|
|
64
|
+
* operator path. A plain object literal can only legitimately be an equality
|
|
65
|
+
* value on a json/jsonb column — and those route to the JSONB filter branch
|
|
66
|
+
* BEFORE the operator branch, so any plain object that reaches here is a
|
|
67
|
+
* mistake (e.g. `{ equals: { foo: 1 } }` on a text column). Shared by the
|
|
68
|
+
* SQL-build path and the cache-hit param-collect path so a warmed cache can
|
|
69
|
+
* never skip the check.
|
|
70
|
+
*/
|
|
71
|
+
function assertBindableEqualsOperand(value, column) {
|
|
72
|
+
if (!isUnmatchedPlainObject(value))
|
|
73
|
+
return;
|
|
74
|
+
throw new ValidationError(`[turbine] Plain-object value for operator 'equals' on ${column}: ` +
|
|
75
|
+
`objects are only valid 'equals' values on JSON (json/jsonb) columns, ` +
|
|
76
|
+
`where 'equals' is the JSONB containment filter.`);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Object keys in sorted order, mirroring the canonical order used by every
|
|
80
|
+
* cache fingerprint. The SQL-build and cache-hit param-collect paths MUST
|
|
81
|
+
* enumerate object keys in this exact order: fingerprints sort keys, so two
|
|
82
|
+
* where clauses with the same fields in different insertion order share one
|
|
83
|
+
* cache entry — if build/collect iterated insertion order, the cached SQL's
|
|
84
|
+
* `$N` placeholders would bind the wrong values (cross-tenant-leak class).
|
|
85
|
+
* Array order (OR/AND members) is positional and is never sorted.
|
|
86
|
+
*/
|
|
87
|
+
function sortedKeys(obj) {
|
|
88
|
+
return Object.keys(obj).sort();
|
|
89
|
+
}
|
|
90
|
+
/** {@link sortedKeys}, but yielding `[key, value]` pairs. */
|
|
91
|
+
function sortedEntries(obj) {
|
|
92
|
+
return Object.entries(obj).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
93
|
+
}
|
|
47
94
|
/** Known atomic-update operator keys — used to detect operator objects vs plain JSON values */
|
|
48
95
|
const UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
|
|
49
96
|
/** Known JSONB operator keys */
|
|
@@ -53,9 +100,11 @@ const JSONB_OPERATOR_KEYS = new Set(['path', 'equals', 'contains', 'hasKey']);
|
|
|
53
100
|
* appear in any other where-filter shape, so the presence of one of these is
|
|
54
101
|
* an unambiguous signal that the user meant a JSON filter. Used by the
|
|
55
102
|
* strict-validation path so that `{ contains: 'foo' }` (which is also a valid
|
|
56
|
-
* `WhereOperator` for LIKE) is not misclassified.
|
|
103
|
+
* `WhereOperator` for LIKE) is not misclassified. Note `equals` is NOT in this
|
|
104
|
+
* set: on non-JSON columns it is a plain equality operator (`WhereOperator`),
|
|
105
|
+
* so it must fall through instead of throwing.
|
|
57
106
|
*/
|
|
58
|
-
const JSONB_UNIQUE_KEYS = new Set(['path', '
|
|
107
|
+
const JSONB_UNIQUE_KEYS = new Set(['path', 'hasKey']);
|
|
59
108
|
/** Check if a value is a JSONB filter object */
|
|
60
109
|
function isJsonFilter(value) {
|
|
61
110
|
if (value === null ||
|
|
@@ -356,10 +405,12 @@ export class QueryInterface {
|
|
|
356
405
|
* Execute a query through the middleware chain.
|
|
357
406
|
* If no middlewares are registered, executes directly.
|
|
358
407
|
*
|
|
359
|
-
* Middleware can inspect and log query parameters,
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
408
|
+
* Middleware can inspect and log query parameters, measure timing, and
|
|
409
|
+
* transform the result returned by `next()`. Note: query SQL is generated
|
|
410
|
+
* BEFORE middleware runs — `params.args` is a read-only snapshot, and
|
|
411
|
+
* mutating it does NOT change the executed SQL. Cross-cutting filters
|
|
412
|
+
* (e.g. soft deletes) belong in the query itself: pass an explicit
|
|
413
|
+
* `where: { deletedAt: null }` or wrap the table accessor in a small helper.
|
|
363
414
|
*/
|
|
364
415
|
async executeWithMiddleware(action, args, executor) {
|
|
365
416
|
this.currentAction = action;
|
|
@@ -398,8 +449,12 @@ export class QueryInterface {
|
|
|
398
449
|
const withFp = args.with ? this.withFingerprint(args.with) : '';
|
|
399
450
|
const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}`;
|
|
400
451
|
const params = [];
|
|
401
|
-
// Check if all where values are simple (plain equality, no operators/null/OR)
|
|
402
|
-
|
|
452
|
+
// Check if all where values are simple (plain equality, no operators/null/OR).
|
|
453
|
+
// Keys are sorted to match fingerprintWhere — insertion order here would let
|
|
454
|
+
// permuted where literals share a cache entry with misaligned params.
|
|
455
|
+
const whereKeys = Object.keys(whereObj)
|
|
456
|
+
.filter((k) => whereObj[k] !== undefined)
|
|
457
|
+
.sort();
|
|
403
458
|
const isSimpleWhere = !whereObj.OR &&
|
|
404
459
|
!whereObj.AND &&
|
|
405
460
|
!whereObj.NOT &&
|
|
@@ -603,7 +658,8 @@ export class QueryInterface {
|
|
|
603
658
|
}
|
|
604
659
|
let sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${freshWhereSql}`;
|
|
605
660
|
if (args?.cursor) {
|
|
606
|
-
|
|
661
|
+
// Sorted (canonical) order — MUST match cursorFp and the cache-hit collect below.
|
|
662
|
+
const cursorEntries = sortedEntries(args.cursor).filter(([, v]) => v !== undefined);
|
|
607
663
|
if (cursorEntries.length > 0) {
|
|
608
664
|
const cursorConditions = cursorEntries.map(([k, v]) => {
|
|
609
665
|
const col = this.toSqlColumn(k);
|
|
@@ -644,9 +700,9 @@ export class QueryInterface {
|
|
|
644
700
|
if (args?.with) {
|
|
645
701
|
this.collectWithParams(args.with, params);
|
|
646
702
|
}
|
|
647
|
-
// 3. Cursor params
|
|
703
|
+
// 3. Cursor params — sorted (canonical) order, matching cursorFp and the build path.
|
|
648
704
|
if (args?.cursor) {
|
|
649
|
-
const cursorEntries =
|
|
705
|
+
const cursorEntries = sortedEntries(args.cursor).filter(([, v]) => v !== undefined);
|
|
650
706
|
for (const [, v] of cursorEntries) {
|
|
651
707
|
params.push(v);
|
|
652
708
|
}
|
|
@@ -1886,12 +1942,7 @@ export class QueryInterface {
|
|
|
1886
1942
|
}
|
|
1887
1943
|
// Operator objects
|
|
1888
1944
|
if (isWhereOperator(value)) {
|
|
1889
|
-
|
|
1890
|
-
.filter((k) => k !== 'mode')
|
|
1891
|
-
.sort();
|
|
1892
|
-
const mode = value.mode;
|
|
1893
|
-
const modeStr = mode === 'insensitive' ? ':i' : '';
|
|
1894
|
-
parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
|
|
1945
|
+
parts.push(`${key}:${fingerprintOperatorShape(value)}`);
|
|
1895
1946
|
continue;
|
|
1896
1947
|
}
|
|
1897
1948
|
// Vector distance filter — metric (operator) and present comparators
|
|
@@ -1964,12 +2015,7 @@ export class QueryInterface {
|
|
|
1964
2015
|
parts.push(`${key}:null`);
|
|
1965
2016
|
}
|
|
1966
2017
|
else if (isWhereOperator(value)) {
|
|
1967
|
-
|
|
1968
|
-
.filter((k) => k !== 'mode')
|
|
1969
|
-
.sort();
|
|
1970
|
-
const mode = value.mode;
|
|
1971
|
-
const modeStr = mode === 'insensitive' ? ':i' : '';
|
|
1972
|
-
parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
|
|
2018
|
+
parts.push(`${key}:${fingerprintOperatorShape(value)}`);
|
|
1973
2019
|
}
|
|
1974
2020
|
else if (isUnmatchedPlainObject(value)) {
|
|
1975
2021
|
parts.push(`${key}:obj(${Object.keys(value)
|
|
@@ -1990,7 +2036,8 @@ export class QueryInterface {
|
|
|
1990
2036
|
* @internal Exposed as package-private for testing.
|
|
1991
2037
|
*/
|
|
1992
2038
|
collectWhereParams(where, params) {
|
|
1993
|
-
|
|
2039
|
+
// Sorted (canonical) order — MUST match fingerprintWhere and buildWhereClause.
|
|
2040
|
+
const keys = sortedKeys(where);
|
|
1994
2041
|
for (const key of keys) {
|
|
1995
2042
|
const value = where[key];
|
|
1996
2043
|
if (value === undefined)
|
|
@@ -2075,7 +2122,7 @@ export class QueryInterface {
|
|
|
2075
2122
|
}
|
|
2076
2123
|
// Operator objects
|
|
2077
2124
|
if (isWhereOperator(value)) {
|
|
2078
|
-
this.collectOperatorParams(value, params);
|
|
2125
|
+
this.collectOperatorParams(rawColumn, value, params);
|
|
2079
2126
|
continue;
|
|
2080
2127
|
}
|
|
2081
2128
|
// Plain equality — same strict validation as the build path, so a
|
|
@@ -2089,22 +2136,28 @@ export class QueryInterface {
|
|
|
2089
2136
|
const meta = this.schema.tables[targetTable];
|
|
2090
2137
|
if (!meta)
|
|
2091
2138
|
return;
|
|
2092
|
-
|
|
2139
|
+
// Sorted (canonical) order — MUST match fingerprintRelFilter and buildSubWhereForRelation.
|
|
2140
|
+
for (const field of sortedKeys(subWhere)) {
|
|
2141
|
+
const value = subWhere[field];
|
|
2093
2142
|
if (value === undefined)
|
|
2094
2143
|
continue;
|
|
2095
2144
|
if (value === null)
|
|
2096
2145
|
continue;
|
|
2146
|
+
const col = meta.columnMap[field] ?? camelToSnake(field);
|
|
2097
2147
|
if (isWhereOperator(value)) {
|
|
2098
|
-
this.collectOperatorParams(value, params);
|
|
2148
|
+
this.collectOperatorParams(col, value, params);
|
|
2099
2149
|
continue;
|
|
2100
2150
|
}
|
|
2101
|
-
const col = meta.columnMap[field] ?? camelToSnake(field);
|
|
2102
2151
|
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
|
|
2103
2152
|
params.push(value);
|
|
2104
2153
|
}
|
|
2105
2154
|
}
|
|
2106
2155
|
/** Collect params from operator clauses. Mirrors buildOperatorClauses. */
|
|
2107
|
-
collectOperatorParams(op, params) {
|
|
2156
|
+
collectOperatorParams(column, op, params) {
|
|
2157
|
+
if (op.equals !== undefined && op.equals !== null) {
|
|
2158
|
+
assertBindableEqualsOperand(op.equals, `"${column}"`);
|
|
2159
|
+
params.push(op.equals);
|
|
2160
|
+
}
|
|
2108
2161
|
if (op.gt !== undefined)
|
|
2109
2162
|
params.push(op.gt);
|
|
2110
2163
|
if (op.gte !== undefined)
|
|
@@ -2260,7 +2313,7 @@ export class QueryInterface {
|
|
|
2260
2313
|
const meta = this.schema.tables[table ?? this.table];
|
|
2261
2314
|
if (!meta)
|
|
2262
2315
|
return;
|
|
2263
|
-
for (const [relName, relSpec] of
|
|
2316
|
+
for (const [relName, relSpec] of sortedEntries(withClause)) {
|
|
2264
2317
|
const relDef = meta.relations[relName];
|
|
2265
2318
|
if (!relDef)
|
|
2266
2319
|
continue;
|
|
@@ -2287,7 +2340,7 @@ export class QueryInterface {
|
|
|
2287
2340
|
params.push(Number(spec.limit));
|
|
2288
2341
|
}
|
|
2289
2342
|
if (spec.with) {
|
|
2290
|
-
for (const [nestedRelName, nestedSpec] of
|
|
2343
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
2291
2344
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
2292
2345
|
if (!nestedRelDef)
|
|
2293
2346
|
continue;
|
|
@@ -2301,7 +2354,7 @@ export class QueryInterface {
|
|
|
2301
2354
|
const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || hasOrder);
|
|
2302
2355
|
// Non-wrapped path: nested relations BEFORE where/limit
|
|
2303
2356
|
if (!willWrap && spec.with) {
|
|
2304
|
-
for (const [nestedRelName, nestedSpec] of
|
|
2357
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
2305
2358
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
2306
2359
|
if (!nestedRelDef)
|
|
2307
2360
|
continue;
|
|
@@ -2321,7 +2374,7 @@ export class QueryInterface {
|
|
|
2321
2374
|
}
|
|
2322
2375
|
// Wrapped path: nested relations AFTER where/limit (inside inner subquery)
|
|
2323
2376
|
if (willWrap && spec.with) {
|
|
2324
|
-
for (const [nestedRelName, nestedSpec] of
|
|
2377
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
2325
2378
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
2326
2379
|
if (!nestedRelDef)
|
|
2327
2380
|
continue;
|
|
@@ -2403,7 +2456,8 @@ export class QueryInterface {
|
|
|
2403
2456
|
* Supports: equality, operators, NULL, OR, AND, NOT, relation filters (some/every/none).
|
|
2404
2457
|
*/
|
|
2405
2458
|
buildWhereClause(where, params) {
|
|
2406
|
-
|
|
2459
|
+
// Sorted (canonical) order — MUST match fingerprintWhere and collectWhereParams.
|
|
2460
|
+
const keys = sortedKeys(where);
|
|
2407
2461
|
if (keys.length === 0)
|
|
2408
2462
|
return null;
|
|
2409
2463
|
const andClauses = [];
|
|
@@ -2610,7 +2664,9 @@ export class QueryInterface {
|
|
|
2610
2664
|
return null;
|
|
2611
2665
|
const qt = this.q(targetTable);
|
|
2612
2666
|
const conditions = [];
|
|
2613
|
-
|
|
2667
|
+
// Sorted (canonical) order — MUST match fingerprintRelFilter and collectRelFilterParams.
|
|
2668
|
+
for (const field of sortedKeys(subWhere)) {
|
|
2669
|
+
const value = subWhere[field];
|
|
2614
2670
|
if (value === undefined)
|
|
2615
2671
|
continue;
|
|
2616
2672
|
const col = meta.columnMap[field] ?? camelToSnake(field);
|
|
@@ -2675,7 +2731,9 @@ export class QueryInterface {
|
|
|
2675
2731
|
*/
|
|
2676
2732
|
buildAliasWhere(targetTable, targetMeta, alias, where, params) {
|
|
2677
2733
|
const clauses = [];
|
|
2678
|
-
|
|
2734
|
+
// Sorted (canonical) order — MUST match fingerprintAliasWhere and collectAliasWhereParams.
|
|
2735
|
+
for (const key of sortedKeys(where)) {
|
|
2736
|
+
const value = where[key];
|
|
2679
2737
|
if (value === undefined)
|
|
2680
2738
|
continue;
|
|
2681
2739
|
if (key === 'OR' || key === 'AND') {
|
|
@@ -2717,7 +2775,9 @@ export class QueryInterface {
|
|
|
2717
2775
|
}
|
|
2718
2776
|
/** Mirrors {@link buildAliasWhere} param-push order for the cache-hit collect path. */
|
|
2719
2777
|
collectAliasWhereParams(targetTable, targetMeta, where, params) {
|
|
2720
|
-
|
|
2778
|
+
// Sorted (canonical) order — MUST match fingerprintAliasWhere and buildAliasWhere.
|
|
2779
|
+
for (const key of sortedKeys(where)) {
|
|
2780
|
+
const value = where[key];
|
|
2721
2781
|
if (value === undefined)
|
|
2722
2782
|
continue;
|
|
2723
2783
|
if (key === 'OR' || key === 'AND') {
|
|
@@ -2735,11 +2795,11 @@ export class QueryInterface {
|
|
|
2735
2795
|
}
|
|
2736
2796
|
if (value === null)
|
|
2737
2797
|
continue;
|
|
2798
|
+
const col = targetMeta.columnMap[key] ?? camelToSnake(key);
|
|
2738
2799
|
if (isWhereOperator(value)) {
|
|
2739
|
-
this.collectOperatorParams(value, params);
|
|
2800
|
+
this.collectOperatorParams(col, value, params);
|
|
2740
2801
|
continue;
|
|
2741
2802
|
}
|
|
2742
|
-
const col = targetMeta.columnMap[key] ?? camelToSnake(key);
|
|
2743
2803
|
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
|
|
2744
2804
|
params.push(value);
|
|
2745
2805
|
}
|
|
@@ -2773,11 +2833,7 @@ export class QueryInterface {
|
|
|
2773
2833
|
continue;
|
|
2774
2834
|
}
|
|
2775
2835
|
if (isWhereOperator(value)) {
|
|
2776
|
-
|
|
2777
|
-
.filter((k) => k !== 'mode')
|
|
2778
|
-
.sort();
|
|
2779
|
-
const mode = value.mode;
|
|
2780
|
-
parts.push(`${key}:op(${opKeys.join(',')}${mode === 'insensitive' ? ':i' : ''})`);
|
|
2836
|
+
parts.push(`${key}:${fingerprintOperatorShape(value)}`);
|
|
2781
2837
|
continue;
|
|
2782
2838
|
}
|
|
2783
2839
|
if (isUnmatchedPlainObject(value)) {
|
|
@@ -2796,6 +2852,16 @@ export class QueryInterface {
|
|
|
2796
2852
|
*/
|
|
2797
2853
|
buildOperatorClauses(column, op, params) {
|
|
2798
2854
|
const clauses = [];
|
|
2855
|
+
if (op.equals !== undefined) {
|
|
2856
|
+
if (op.equals === null) {
|
|
2857
|
+
clauses.push(`${column} IS NULL`);
|
|
2858
|
+
}
|
|
2859
|
+
else {
|
|
2860
|
+
assertBindableEqualsOperand(op.equals, column);
|
|
2861
|
+
params.push(op.equals);
|
|
2862
|
+
clauses.push(`${column} = ${this.p(params.length)}`);
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2799
2865
|
if (op.gt !== undefined) {
|
|
2800
2866
|
params.push(op.gt);
|
|
2801
2867
|
clauses.push(`${column} > ${this.p(params.length)}`);
|
|
@@ -3094,7 +3160,7 @@ export class QueryInterface {
|
|
|
3094
3160
|
const baseCols = cols.map((col) => `${qtbl}.${this.q(col)}`).join(', ');
|
|
3095
3161
|
const relationSelects = [];
|
|
3096
3162
|
const aliasCounter = { n: 0 };
|
|
3097
|
-
for (const [relName, relSpec] of
|
|
3163
|
+
for (const [relName, relSpec] of sortedEntries(withClause)) {
|
|
3098
3164
|
const relDef = meta.relations[relName];
|
|
3099
3165
|
if (!relDef) {
|
|
3100
3166
|
throw new RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
|
|
@@ -3249,7 +3315,7 @@ export class QueryInterface {
|
|
|
3249
3315
|
}
|
|
3250
3316
|
// Nested relations — only in the non-wrapped path (wrapped path builds them separately)
|
|
3251
3317
|
if (!willWrap && spec !== true && spec.with) {
|
|
3252
|
-
for (const [nestedRelName, nestedSpec] of
|
|
3318
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
3253
3319
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
3254
3320
|
if (!nestedRelDef) {
|
|
3255
3321
|
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
@@ -3325,7 +3391,7 @@ export class QueryInterface {
|
|
|
3325
3391
|
]);
|
|
3326
3392
|
// Build nested relation subqueries referencing innerAlias
|
|
3327
3393
|
if (spec !== true && spec.with) {
|
|
3328
|
-
for (const [nestedRelName, nestedSpec] of
|
|
3394
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
3329
3395
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
3330
3396
|
if (!nestedRelDef) {
|
|
3331
3397
|
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
@@ -3443,7 +3509,7 @@ export class QueryInterface {
|
|
|
3443
3509
|
]);
|
|
3444
3510
|
// Nested relations reference the inner alias.
|
|
3445
3511
|
if (spec !== true && spec.with) {
|
|
3446
|
-
for (const [nestedRelName, nestedSpec] of
|
|
3512
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
3447
3513
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
3448
3514
|
if (!nestedRelDef) {
|
|
3449
3515
|
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
@@ -3466,7 +3532,7 @@ export class QueryInterface {
|
|
|
3466
3532
|
`${talias}.${this.q(col)}`,
|
|
3467
3533
|
]);
|
|
3468
3534
|
if (spec !== true && spec.with) {
|
|
3469
|
-
for (const [nestedRelName, nestedSpec] of
|
|
3535
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
3470
3536
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
3471
3537
|
if (!nestedRelDef) {
|
|
3472
3538
|
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
package/dist/query/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* `import { … } from './query/index.js'` is a drop-in replacement for the
|
|
6
6
|
* former monolithic `import { … } from './query.js'`.
|
|
7
7
|
*/
|
|
8
|
-
export type { AggregateArgs, AggregateResult, ArrayFilter, ConnectOrCreateOp, CountArgs, CreateArgs, CreateManyArgs, DeleteArgs, DeleteManyArgs, FieldResult, FindManyArgs, FindManyStreamArgs, FindUniqueArgs, GroupByArgs, HavingClause, JsonFilter, NestedCreateOp, NestedUpdateOp, OmitResult, OrderByClause, OrderDirection, QueryResult, RelationDescriptor, RelationFilter, SelectResult, TextSearchFilter, TypedWithClause, UpdateArgs, UpdateInput, UpdateManyArgs, UpdateOperatorInput, UpsertArgs, VectorDistanceFilter, VectorFilter, VectorMetric, VectorOrderBy, VectorOrderByDistance, WhereClause, WhereOperator, WhereValue, WithClause, WithOptions, WithResult, } from './types.js';
|
|
8
|
+
export type { AggregateArgs, AggregateResult, ArrayFilter, ConnectOrCreateOp, CountArgs, CreateArgs, CreateDataInput, CreateManyArgs, DeleteArgs, DeleteManyArgs, FieldResult, FindManyArgs, FindManyStreamArgs, FindUniqueArgs, GroupByArgs, HavingClause, JsonFilter, NestedCreateOp, NestedUpdateOp, NestedUpdateOpItem, NestedUpsertOpItem, OmitResult, OrderByClause, OrderDirection, QueryResult, RelationDescriptor, RelationFilter, SelectResult, TextSearchFilter, TypedWithClause, UpdateArgs, UpdateDataInput, UpdateInput, UpdateManyArgs, UpdateOperatorInput, UpsertArgs, VectorDistanceFilter, VectorFilter, VectorMetric, VectorOrderBy, VectorOrderByDistance, WhereClause, WhereOperator, WhereValue, WithClause, WithOptions, WithResult, } from './types.js';
|
|
9
9
|
export type { BuiltStatement, BulkInsertStatementInput, ColumnDefinitionInput, ColumnTypeInput, CreateIndexStatementInput, CreateTableStatementInput, Dialect, InsertStatementInput, UpsertStatementInput, } from '../dialect.js';
|
|
10
10
|
export { postgresDialect } from '../dialect.js';
|
|
11
11
|
export type { SqlCacheEntry } from './utils.js';
|