turbine-orm 0.30.0 → 0.31.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/dist/cjs/client.js +14 -3
- package/dist/cjs/mssql.js +24 -3
- package/dist/cjs/powdb.js +197 -50
- package/dist/cjs/powql.js +7 -1
- package/dist/cjs/query/builder.js +311 -63
- package/dist/cjs/query/filters.js +49 -2
- package/dist/client.d.ts +13 -0
- package/dist/client.js +14 -3
- package/dist/index.d.ts +1 -1
- package/dist/mssql.js +24 -3
- package/dist/powdb.d.ts +33 -0
- package/dist/powdb.js +197 -50
- package/dist/powql.js +7 -1
- package/dist/query/builder.d.ts +85 -5
- package/dist/query/builder.js +312 -64
- package/dist/query/filters.d.ts +28 -1
- package/dist/query/filters.js +46 -1
- package/dist/query/index.d.ts +1 -1
- package/dist/query/types.d.ts +66 -11
- package/package.json +1 -1
package/dist/query/builder.js
CHANGED
|
@@ -16,7 +16,7 @@ import { missingIndexForRelation } 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 { includeKeysForBatching, loadRelationsBatched, neededParentKeyFields, resolveCountRelations, stripFields, } from './batched-loader.js';
|
|
19
|
-
import { assertBindableEqualsOperand, findArrayUniqueKey, findJsonUniqueKey, fingerprintJsonFilterShape, fingerprintOperatorShape, isArrayFilter, isJsonFilter, isOrderBySpec, isTextSearchFilter, isUnmatchedPlainObject, isVectorFilter, isVectorOrderBy, isWhereOperator, JSON_RANGE_OPERATORS, normalizeOrderBy, sortedEntries, sortedKeys, UPDATE_OPERATOR_KEYS, VECTOR_DISTANCE_COMPARATORS, VECTOR_METRIC_OPERATORS, validateTextSearchConfig, } from './filters.js';
|
|
19
|
+
import { assertBindableEqualsOperand, findArrayUniqueKey, findJsonUniqueKey, fingerprintJsonFilterShape, fingerprintOperatorShape, isArrayFilter, isColumnRef, isJsonFilter, isJsonPathOrderBy, isOrderBySpec, isTextSearchFilter, isUnmatchedPlainObject, isVectorFilter, isVectorOrderBy, isWhereOperator, JSON_RANGE_OPERATORS, normalizeOrderBy, sortedEntries, sortedKeys, UPDATE_OPERATOR_KEYS, VECTOR_DISTANCE_COMPARATORS, VECTOR_METRIC_OPERATORS, validateTextSearchConfig, } from './filters.js';
|
|
20
20
|
import { escapeLike, LRUCache, OPERATOR_KEYS, parseDbDate, sqlToPreparedName } from './utils.js';
|
|
21
21
|
/** Relations already warned about missing FK indexes (once per process, dev only). */
|
|
22
22
|
const unindexedRelationWarned = new Set();
|
|
@@ -2423,7 +2423,11 @@ export class QueryInterface {
|
|
|
2423
2423
|
}
|
|
2424
2424
|
// Operator objects
|
|
2425
2425
|
if (isWhereOperator(value)) {
|
|
2426
|
-
this.collectOperatorParams(rawColumn, value, params
|
|
2426
|
+
this.collectOperatorParams(rawColumn, value, params, {
|
|
2427
|
+
meta: this.tableMeta,
|
|
2428
|
+
table: this.table,
|
|
2429
|
+
prefix: '',
|
|
2430
|
+
});
|
|
2427
2431
|
continue;
|
|
2428
2432
|
}
|
|
2429
2433
|
// Plain equality — same strict validation as the build path, so a
|
|
@@ -2524,28 +2528,40 @@ export class QueryInterface {
|
|
|
2524
2528
|
}
|
|
2525
2529
|
}
|
|
2526
2530
|
if (isWhereOperator(value)) {
|
|
2527
|
-
this.collectOperatorParams(col, value, params);
|
|
2531
|
+
this.collectOperatorParams(col, value, params, { meta, table: targetTable, prefix: '' });
|
|
2528
2532
|
continue;
|
|
2529
2533
|
}
|
|
2530
2534
|
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
|
|
2531
2535
|
params.push(value);
|
|
2532
2536
|
}
|
|
2533
2537
|
}
|
|
2534
|
-
/**
|
|
2535
|
-
|
|
2536
|
-
|
|
2538
|
+
/**
|
|
2539
|
+
* Collect params from operator clauses. Mirrors buildOperatorClauses:
|
|
2540
|
+
* {@link ColumnRef} values compile into the SQL text, so they push NOTHING -
|
|
2541
|
+
* but they re-run the same validation (unknown ref / insensitive mode) so a
|
|
2542
|
+
* warmed cache can never skip a check the build path enforces.
|
|
2543
|
+
*/
|
|
2544
|
+
collectOperatorParams(column, op, params, refCtx) {
|
|
2545
|
+
const skipRef = (v) => {
|
|
2546
|
+
if (!isColumnRef(v))
|
|
2547
|
+
return false;
|
|
2548
|
+
if (refCtx)
|
|
2549
|
+
this.resolveColumnRef(v, refCtx, op.mode);
|
|
2550
|
+
return true;
|
|
2551
|
+
};
|
|
2552
|
+
if (op.equals !== undefined && op.equals !== null && !skipRef(op.equals)) {
|
|
2537
2553
|
assertBindableEqualsOperand(op.equals, `"${column}"`);
|
|
2538
2554
|
params.push(op.equals);
|
|
2539
2555
|
}
|
|
2540
|
-
if (op.gt !== undefined)
|
|
2556
|
+
if (op.gt !== undefined && !skipRef(op.gt))
|
|
2541
2557
|
params.push(op.gt);
|
|
2542
|
-
if (op.gte !== undefined)
|
|
2558
|
+
if (op.gte !== undefined && !skipRef(op.gte))
|
|
2543
2559
|
params.push(op.gte);
|
|
2544
|
-
if (op.lt !== undefined)
|
|
2560
|
+
if (op.lt !== undefined && !skipRef(op.lt))
|
|
2545
2561
|
params.push(op.lt);
|
|
2546
|
-
if (op.lte !== undefined)
|
|
2562
|
+
if (op.lte !== undefined && !skipRef(op.lte))
|
|
2547
2563
|
params.push(op.lte);
|
|
2548
|
-
if (op.not !== undefined && op.not !== null)
|
|
2564
|
+
if (op.not !== undefined && op.not !== null && !skipRef(op.not))
|
|
2549
2565
|
params.push(op.not);
|
|
2550
2566
|
if (op.in !== undefined)
|
|
2551
2567
|
params.push(this.inParam(op.in));
|
|
@@ -2601,10 +2617,10 @@ export class QueryInterface {
|
|
|
2601
2617
|
// isEmpty has no params (IS NULL / IS NOT NULL)
|
|
2602
2618
|
}
|
|
2603
2619
|
/**
|
|
2604
|
-
* Collect params for an orderBy clause.
|
|
2605
|
-
*
|
|
2606
|
-
* parameterless. Mirrors buildOrderBy's push
|
|
2607
|
-
* param re-collection stays in lockstep.
|
|
2620
|
+
* Collect params for an orderBy clause. Vector KNN ordering pushes the
|
|
2621
|
+
* `$n::vector` query vector and JSON-path ordering pushes its text[] path;
|
|
2622
|
+
* plain direction ordering is parameterless. Mirrors buildOrderBy's push
|
|
2623
|
+
* order exactly so the cached-SQL param re-collection stays in lockstep.
|
|
2608
2624
|
*/
|
|
2609
2625
|
collectOrderByParams(orderBy, params) {
|
|
2610
2626
|
for (const [key, dir] of Object.entries(orderBy)) {
|
|
@@ -2616,6 +2632,13 @@ export class QueryInterface {
|
|
|
2616
2632
|
this.pushVectorParam(key, rawColumn, dir.distance.to, params);
|
|
2617
2633
|
continue;
|
|
2618
2634
|
}
|
|
2635
|
+
// JSON-path ordering: mirrors buildJsonPathOrderEntry: same validation,
|
|
2636
|
+
// then the path bound as one text[] param.
|
|
2637
|
+
if (isJsonPathOrderBy(dir)) {
|
|
2638
|
+
this.validateJsonPathOrderBy(this.table, this.tableMeta, key, dir);
|
|
2639
|
+
params.push(dir.path.map(String));
|
|
2640
|
+
continue;
|
|
2641
|
+
}
|
|
2619
2642
|
// To-many relation orderBy (`{ posts: { _count } }`) uses the same count
|
|
2620
2643
|
// subquery as `_count` — mirror its global-filter params. To-one relation
|
|
2621
2644
|
// orderBy carries the target's global filter once per ordered column.
|
|
@@ -2762,9 +2785,20 @@ export class QueryInterface {
|
|
|
2762
2785
|
const targetMeta = this.schema.tables[targetTable];
|
|
2763
2786
|
if (!targetMeta)
|
|
2764
2787
|
return;
|
|
2788
|
+
// A dialect that owns the whole subquery (buildRelationSubquery override,
|
|
2789
|
+
// SQL Server FOR JSON) compiles orderBy through its OWN paging clause -
|
|
2790
|
+
// plain directions only, no order params: so the native order-param
|
|
2791
|
+
// mirrors below must stay off for it (its documented contract remains
|
|
2792
|
+
// where → limit → nested).
|
|
2793
|
+
const nativeOrderPath = !this.dialect.buildRelationSubquery;
|
|
2765
2794
|
// manyToMany param order mirrors buildManyToManySubquery:
|
|
2766
|
-
// where params → limit param → nested-with params
|
|
2795
|
+
// orderBy params → where params → limit param → nested-with params
|
|
2796
|
+
// (always, both paths).
|
|
2767
2797
|
if (relDef.type === 'manyToMany') {
|
|
2798
|
+
const m2mOrderEntries = spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
|
|
2799
|
+
if (nativeOrderPath && m2mOrderEntries.length > 0) {
|
|
2800
|
+
this.collectRelationOrderParams(targetTable, targetMeta, m2mOrderEntries, params);
|
|
2801
|
+
}
|
|
2768
2802
|
if (spec.where) {
|
|
2769
2803
|
this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
|
|
2770
2804
|
}
|
|
@@ -2783,7 +2817,8 @@ export class QueryInterface {
|
|
|
2783
2817
|
return;
|
|
2784
2818
|
}
|
|
2785
2819
|
// Mirrors buildRelationSubquery's willWrap: `orderBy: {}` is treated as absent.
|
|
2786
|
-
const
|
|
2820
|
+
const relOrderEntries = spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
|
|
2821
|
+
const hasOrder = relOrderEntries.length > 0;
|
|
2787
2822
|
const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || hasOrder);
|
|
2788
2823
|
// Non-wrapped path: nested relations BEFORE where/limit
|
|
2789
2824
|
if (!willWrap && spec.with) {
|
|
@@ -2794,6 +2829,12 @@ export class QueryInterface {
|
|
|
2794
2829
|
this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'alias', depth + 1);
|
|
2795
2830
|
}
|
|
2796
2831
|
}
|
|
2832
|
+
// orderBy params (JSON paths / relation-order global filters): mirrors
|
|
2833
|
+
// buildRelationSubquery, which builds its ORDER BY terms BEFORE compiling
|
|
2834
|
+
// spec.where (both wrapped and non-wrapped paths).
|
|
2835
|
+
if (nativeOrderPath && hasOrder) {
|
|
2836
|
+
this.collectRelationOrderParams(targetTable, targetMeta, relOrderEntries, params);
|
|
2837
|
+
}
|
|
2797
2838
|
// where params — mirrors buildAliasWhere push order
|
|
2798
2839
|
if (spec.where) {
|
|
2799
2840
|
this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
|
|
@@ -3144,7 +3185,11 @@ export class QueryInterface {
|
|
|
3144
3185
|
}
|
|
3145
3186
|
// Handle operator objects
|
|
3146
3187
|
if (isWhereOperator(value)) {
|
|
3147
|
-
const opClauses = this.buildOperatorClauses(column, value, params
|
|
3188
|
+
const opClauses = this.buildOperatorClauses(column, value, params, {
|
|
3189
|
+
meta: this.tableMeta,
|
|
3190
|
+
table: this.table,
|
|
3191
|
+
prefix: '',
|
|
3192
|
+
});
|
|
3148
3193
|
andClauses.push(...opClauses);
|
|
3149
3194
|
continue;
|
|
3150
3195
|
}
|
|
@@ -3338,7 +3383,11 @@ export class QueryInterface {
|
|
|
3338
3383
|
}
|
|
3339
3384
|
}
|
|
3340
3385
|
if (isWhereOperator(value)) {
|
|
3341
|
-
const opClauses = this.buildOperatorClauses(qCol, value, params
|
|
3386
|
+
const opClauses = this.buildOperatorClauses(qCol, value, params, {
|
|
3387
|
+
meta,
|
|
3388
|
+
table: targetTable,
|
|
3389
|
+
prefix: `${qt}.`,
|
|
3390
|
+
});
|
|
3342
3391
|
conditions.push(...opClauses);
|
|
3343
3392
|
continue;
|
|
3344
3393
|
}
|
|
@@ -3505,7 +3554,11 @@ export class QueryInterface {
|
|
|
3505
3554
|
}
|
|
3506
3555
|
}
|
|
3507
3556
|
if (isWhereOperator(value)) {
|
|
3508
|
-
clauses.push(...this.buildOperatorClauses(qCol, value, params
|
|
3557
|
+
clauses.push(...this.buildOperatorClauses(qCol, value, params, {
|
|
3558
|
+
meta: targetMeta,
|
|
3559
|
+
table: targetTable,
|
|
3560
|
+
prefix: `${alias}.`,
|
|
3561
|
+
}));
|
|
3509
3562
|
continue;
|
|
3510
3563
|
}
|
|
3511
3564
|
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
|
|
@@ -3564,7 +3617,7 @@ export class QueryInterface {
|
|
|
3564
3617
|
}
|
|
3565
3618
|
}
|
|
3566
3619
|
if (isWhereOperator(value)) {
|
|
3567
|
-
this.collectOperatorParams(col, value, params);
|
|
3620
|
+
this.collectOperatorParams(col, value, params, { meta: targetMeta, table: targetTable, prefix: '' });
|
|
3568
3621
|
continue;
|
|
3569
3622
|
}
|
|
3570
3623
|
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
|
|
@@ -3641,16 +3694,55 @@ export class QueryInterface {
|
|
|
3641
3694
|
}
|
|
3642
3695
|
return parts.join('&');
|
|
3643
3696
|
}
|
|
3697
|
+
/**
|
|
3698
|
+
* Validate a `{ col }` column reference against its table and return the
|
|
3699
|
+
* resolved snake_case column name. Shared by the SQL-build path
|
|
3700
|
+
* ({@link buildOperatorClauses}) and the cache-hit param-collect path
|
|
3701
|
+
* (`collectOperatorParams`) so both always throw identically: a warmed
|
|
3702
|
+
* cache can never skip the check.
|
|
3703
|
+
*/
|
|
3704
|
+
resolveColumnRef(ref, ctx, mode) {
|
|
3705
|
+
if (mode === 'insensitive') {
|
|
3706
|
+
throw new ValidationError(`[turbine] mode: 'insensitive' cannot be combined with a column reference ({ col: "${ref.col}" }). ` +
|
|
3707
|
+
`Case-insensitive column-to-column comparison is not supported: use client.sql\`...\` ` +
|
|
3708
|
+
`for lower(a) = lower(b).`);
|
|
3709
|
+
}
|
|
3710
|
+
const col = ctx.meta.columnMap[ref.col] ?? camelToSnake(ref.col);
|
|
3711
|
+
if (!ctx.meta.allColumns.includes(col)) {
|
|
3712
|
+
throw new ValidationError(`[turbine] Unknown field "${ref.col}" referenced by { col } in where on table "${ctx.table}". ` +
|
|
3713
|
+
`Known fields: ${Object.keys(ctx.meta.columnMap).join(', ') || '(none)'}.`);
|
|
3714
|
+
}
|
|
3715
|
+
return col;
|
|
3716
|
+
}
|
|
3717
|
+
/**
|
|
3718
|
+
* Compile a `{ col }` reference to its quoted, prefix-matched SQL identifier.
|
|
3719
|
+
* NO param is bound: the referenced column is part of the SQL text (and of
|
|
3720
|
+
* the where fingerprint, see {@link fingerprintOperatorShape}).
|
|
3721
|
+
*/
|
|
3722
|
+
columnRefSql(ref, ctx, mode) {
|
|
3723
|
+
if (!ctx) {
|
|
3724
|
+
throw new ValidationError(`[turbine] Column reference { col: "${ref.col}" } is not supported in this filter context.`);
|
|
3725
|
+
}
|
|
3726
|
+
return `${ctx.prefix}${this.q(this.resolveColumnRef(ref, ctx, mode))}`;
|
|
3727
|
+
}
|
|
3644
3728
|
/**
|
|
3645
3729
|
* Build SQL clauses for a single operator object on a column.
|
|
3646
3730
|
* Each operator key becomes its own clause, all ANDed together.
|
|
3731
|
+
*
|
|
3732
|
+
* `equals`/`not`/`gt`/`gte`/`lt`/`lte` also accept a {@link ColumnRef}
|
|
3733
|
+
* (`{ col: 'otherField' }`) which compiles to a column-to-column comparison
|
|
3734
|
+
* against `refCtx`: no param bound, so `collectOperatorParams` mirrors by
|
|
3735
|
+
* pushing nothing and the referenced name lives in the fingerprint.
|
|
3647
3736
|
*/
|
|
3648
|
-
buildOperatorClauses(column, op, params) {
|
|
3737
|
+
buildOperatorClauses(column, op, params, refCtx) {
|
|
3649
3738
|
const clauses = [];
|
|
3650
3739
|
if (op.equals !== undefined) {
|
|
3651
3740
|
if (op.equals === null) {
|
|
3652
3741
|
clauses.push(`${column} IS NULL`);
|
|
3653
3742
|
}
|
|
3743
|
+
else if (isColumnRef(op.equals)) {
|
|
3744
|
+
clauses.push(`${column} = ${this.columnRefSql(op.equals, refCtx, op.mode)}`);
|
|
3745
|
+
}
|
|
3654
3746
|
else {
|
|
3655
3747
|
assertBindableEqualsOperand(op.equals, column);
|
|
3656
3748
|
params.push(op.equals);
|
|
@@ -3658,25 +3750,48 @@ export class QueryInterface {
|
|
|
3658
3750
|
}
|
|
3659
3751
|
}
|
|
3660
3752
|
if (op.gt !== undefined) {
|
|
3661
|
-
|
|
3662
|
-
|
|
3753
|
+
if (isColumnRef(op.gt)) {
|
|
3754
|
+
clauses.push(`${column} > ${this.columnRefSql(op.gt, refCtx, op.mode)}`);
|
|
3755
|
+
}
|
|
3756
|
+
else {
|
|
3757
|
+
params.push(op.gt);
|
|
3758
|
+
clauses.push(`${column} > ${this.p(params.length)}`);
|
|
3759
|
+
}
|
|
3663
3760
|
}
|
|
3664
3761
|
if (op.gte !== undefined) {
|
|
3665
|
-
|
|
3666
|
-
|
|
3762
|
+
if (isColumnRef(op.gte)) {
|
|
3763
|
+
clauses.push(`${column} >= ${this.columnRefSql(op.gte, refCtx, op.mode)}`);
|
|
3764
|
+
}
|
|
3765
|
+
else {
|
|
3766
|
+
params.push(op.gte);
|
|
3767
|
+
clauses.push(`${column} >= ${this.p(params.length)}`);
|
|
3768
|
+
}
|
|
3667
3769
|
}
|
|
3668
3770
|
if (op.lt !== undefined) {
|
|
3669
|
-
|
|
3670
|
-
|
|
3771
|
+
if (isColumnRef(op.lt)) {
|
|
3772
|
+
clauses.push(`${column} < ${this.columnRefSql(op.lt, refCtx, op.mode)}`);
|
|
3773
|
+
}
|
|
3774
|
+
else {
|
|
3775
|
+
params.push(op.lt);
|
|
3776
|
+
clauses.push(`${column} < ${this.p(params.length)}`);
|
|
3777
|
+
}
|
|
3671
3778
|
}
|
|
3672
3779
|
if (op.lte !== undefined) {
|
|
3673
|
-
|
|
3674
|
-
|
|
3780
|
+
if (isColumnRef(op.lte)) {
|
|
3781
|
+
clauses.push(`${column} <= ${this.columnRefSql(op.lte, refCtx, op.mode)}`);
|
|
3782
|
+
}
|
|
3783
|
+
else {
|
|
3784
|
+
params.push(op.lte);
|
|
3785
|
+
clauses.push(`${column} <= ${this.p(params.length)}`);
|
|
3786
|
+
}
|
|
3675
3787
|
}
|
|
3676
3788
|
if (op.not !== undefined) {
|
|
3677
3789
|
if (op.not === null) {
|
|
3678
3790
|
clauses.push(`${column} IS NOT NULL`);
|
|
3679
3791
|
}
|
|
3792
|
+
else if (isColumnRef(op.not)) {
|
|
3793
|
+
clauses.push(`${column} != ${this.columnRefSql(op.not, refCtx, op.mode)}`);
|
|
3794
|
+
}
|
|
3680
3795
|
else {
|
|
3681
3796
|
params.push(op.not);
|
|
3682
3797
|
clauses.push(`${column} != ${this.p(params.length)}`);
|
|
@@ -3727,6 +3842,11 @@ export class QueryInterface {
|
|
|
3727
3842
|
if (isVectorOrderBy(d)) {
|
|
3728
3843
|
return `vec(${d.distance.metric},${d.distance.direction ?? 'asc'})`;
|
|
3729
3844
|
}
|
|
3845
|
+
// JSON-path ordering: direction, cast kind, and nulls placement change the
|
|
3846
|
+
// SQL text; the path itself is a bound param and stays OUT of the key.
|
|
3847
|
+
if (isJsonPathOrderBy(d)) {
|
|
3848
|
+
return `jp(${d.direction ?? 'asc'},${d.type === 'numeric' ? 'num' : 'text'},${d.nulls ?? ''})`;
|
|
3849
|
+
}
|
|
3730
3850
|
if (isOrderBySpec(d))
|
|
3731
3851
|
return `spec(${d.sort},${d.nulls ?? ''})`;
|
|
3732
3852
|
if (d && typeof d === 'object') {
|
|
@@ -3772,6 +3892,11 @@ export class QueryInterface {
|
|
|
3772
3892
|
const safeDir = value.distance.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3773
3893
|
return `${this.q(rawColumn)} ${operator} ${placeholder} ${safeDir}`;
|
|
3774
3894
|
}
|
|
3895
|
+
// JSON-path ordering: { path: [...], direction?, type?, nulls? } on a
|
|
3896
|
+
// json/jsonb column of THIS table. Path is bound as one text[] param.
|
|
3897
|
+
if (isJsonPathOrderBy(value)) {
|
|
3898
|
+
return this.buildJsonPathOrderEntry(this.table, this.tableMeta, key, value, '', params);
|
|
3899
|
+
}
|
|
3775
3900
|
// Relation ordering: an object value that is not a vector or OrderBySpec,
|
|
3776
3901
|
// keyed by a relation name (`{ posts: { _count: 'desc' } }` / `{ author:
|
|
3777
3902
|
// { name: 'asc' } }`).
|
|
@@ -3798,6 +3923,7 @@ export class QueryInterface {
|
|
|
3798
3923
|
value !== null &&
|
|
3799
3924
|
!Array.isArray(value) &&
|
|
3800
3925
|
!isVectorOrderBy(value) &&
|
|
3926
|
+
!isJsonPathOrderBy(value) &&
|
|
3801
3927
|
!isOrderBySpec(value));
|
|
3802
3928
|
}
|
|
3803
3929
|
/**
|
|
@@ -3814,6 +3940,63 @@ export class QueryInterface {
|
|
|
3814
3940
|
}
|
|
3815
3941
|
return nulls === 'first' ? ' NULLS FIRST' : ' NULLS LAST';
|
|
3816
3942
|
}
|
|
3943
|
+
/**
|
|
3944
|
+
* Resolve an orderBy key to its snake_case column via the table's columnMap
|
|
3945
|
+
* (camelToSnake fallback), throwing the SAME unknown-field E003 the top-level
|
|
3946
|
+
* where path uses. Shared by top-level JSON-path ordering and every nested
|
|
3947
|
+
* relation orderBy path so nested orderBy accepts exactly what top-level
|
|
3948
|
+
* accepts (the 0.30.x bug: nested orderBy skipped the columnMap and rejected
|
|
3949
|
+
* camelCase-named DB columns like "sortOrder").
|
|
3950
|
+
*/
|
|
3951
|
+
resolveOrderByColumn(table, meta, key) {
|
|
3952
|
+
const col = meta.columnMap[key] ?? camelToSnake(key);
|
|
3953
|
+
if (!meta.allColumns.includes(col)) {
|
|
3954
|
+
throw new ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${table}". ` +
|
|
3955
|
+
`Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
|
|
3956
|
+
}
|
|
3957
|
+
return col;
|
|
3958
|
+
}
|
|
3959
|
+
/**
|
|
3960
|
+
* Validate a {@link JsonPathOrderBy} entry: column must exist AND be
|
|
3961
|
+
* json/jsonb, path must be a non-empty array of keys/indexes: and return
|
|
3962
|
+
* the resolved column. Shared by the SQL-build path
|
|
3963
|
+
* ({@link buildJsonPathOrderEntry}) and the cache-hit param-collect mirrors
|
|
3964
|
+
* so both always throw identically.
|
|
3965
|
+
*/
|
|
3966
|
+
validateJsonPathOrderBy(table, meta, field, spec) {
|
|
3967
|
+
const col = this.resolveOrderByColumn(table, meta, field);
|
|
3968
|
+
if (spec.path.length === 0 ||
|
|
3969
|
+
spec.path.some((el) => typeof el !== 'string' && !(typeof el === 'number' && Number.isFinite(el)))) {
|
|
3970
|
+
throw new ValidationError(`[turbine] JSON-path orderBy on "${field}" (table "${table}") requires a non-empty \`path\` array ` +
|
|
3971
|
+
`of keys/indexes (e.g. { path: ['weight'], direction: 'asc' }).`);
|
|
3972
|
+
}
|
|
3973
|
+
const colType = this.pgTypeForColumn(meta, col);
|
|
3974
|
+
if (colType !== 'json' && colType !== 'jsonb') {
|
|
3975
|
+
throw new ValidationError(`[turbine] JSON-path orderBy on "${field}": column "${col}" on table "${table}" is not a JSON column ` +
|
|
3976
|
+
`(actual type: ${colType}).`);
|
|
3977
|
+
}
|
|
3978
|
+
return col;
|
|
3979
|
+
}
|
|
3980
|
+
/**
|
|
3981
|
+
* Compile one {@link JsonPathOrderBy} entry:
|
|
3982
|
+
* `("col" #>> $n::text[])::numeric ASC`: the numeric cast only with
|
|
3983
|
+
* `type: 'numeric'` (default is text comparison), the extraction routed
|
|
3984
|
+
* through the dialect's JSON hook exactly like the JSON where-filters, the
|
|
3985
|
+
* path bound as ONE text[] param (mirrored by the order-param collectors).
|
|
3986
|
+
* `prefix` scopes the column (`''` top-level, `t0.` inside a relation
|
|
3987
|
+
* subquery).
|
|
3988
|
+
*/
|
|
3989
|
+
buildJsonPathOrderEntry(table, meta, field, spec, prefix, params) {
|
|
3990
|
+
const col = this.validateJsonPathOrderBy(table, meta, field, spec);
|
|
3991
|
+
if (!params) {
|
|
3992
|
+
throw new ValidationError(`[turbine] JSON-path ordering on "${field}" is not supported in this orderBy context.`);
|
|
3993
|
+
}
|
|
3994
|
+
params.push(spec.path.map(String));
|
|
3995
|
+
const extract = this.dialect.buildJsonPathExtract(`${prefix}${this.q(col)}`, this.p(params.length));
|
|
3996
|
+
const lhs = spec.type === 'numeric' ? this.castJsonNumeric(extract) : extract;
|
|
3997
|
+
const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3998
|
+
return `${lhs} ${dir}${this.nullsSuffix(spec.nulls)}`;
|
|
3999
|
+
}
|
|
3817
4000
|
/**
|
|
3818
4001
|
* Compile a relation ordering term. For a to-many relation the only allowed
|
|
3819
4002
|
* key is `_count`, which becomes a correlated `COUNT(*)` subquery. For a
|
|
@@ -3822,12 +4005,19 @@ export class QueryInterface {
|
|
|
3822
4005
|
*
|
|
3823
4006
|
* Validation: relation must exist (E005); to-many only allows `_count`, and
|
|
3824
4007
|
* to-one only allows real target columns (E003).
|
|
4008
|
+
*
|
|
4009
|
+
* `ctx` generalizes the term beyond the root table: inside a relation
|
|
4010
|
+
* subquery's orderBy the relations live on the TARGET table's metadata and
|
|
4011
|
+
* the correlation parent is the relation's alias, not `this.table`.
|
|
3825
4012
|
*/
|
|
3826
|
-
buildRelationOrderBy(relName, value, alias, params) {
|
|
3827
|
-
const
|
|
4013
|
+
buildRelationOrderBy(relName, value, alias, params, ctx) {
|
|
4014
|
+
const ownerMeta = ctx?.meta ?? this.tableMeta;
|
|
4015
|
+
const ownerTable = ctx?.table ?? this.table;
|
|
4016
|
+
const parentRef = ctx?.parentRef ?? this.table;
|
|
4017
|
+
const relDef = ownerMeta.relations[relName];
|
|
3828
4018
|
if (!relDef) {
|
|
3829
|
-
throw new RelationError(`[turbine] Unknown relation "${relName}" in orderBy on table "${
|
|
3830
|
-
`Available: ${Object.keys(
|
|
4019
|
+
throw new RelationError(`[turbine] Unknown relation "${relName}" in orderBy on table "${ownerTable}". ` +
|
|
4020
|
+
`Available: ${Object.keys(ownerMeta.relations).join(', ')}`);
|
|
3831
4021
|
}
|
|
3832
4022
|
// To-many: only `_count` is meaningful → correlated COUNT(*) subquery.
|
|
3833
4023
|
if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
|
|
@@ -3837,14 +4027,14 @@ export class QueryInterface {
|
|
|
3837
4027
|
`(got: ${keys.join(', ') || '(empty)'}).`);
|
|
3838
4028
|
}
|
|
3839
4029
|
const { dir } = normalizeOrderBy(value._count);
|
|
3840
|
-
return `${this.buildRelationCountExpr(relDef,
|
|
4030
|
+
return `${this.buildRelationCountExpr(relDef, parentRef, alias, params)} ${dir}`;
|
|
3841
4031
|
}
|
|
3842
4032
|
// To-one: each entry orders by a correlated scalar subquery on a target column.
|
|
3843
4033
|
const targetMeta = this.schema.tables[relDef.to];
|
|
3844
4034
|
if (!targetMeta)
|
|
3845
4035
|
throw new RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
|
|
3846
4036
|
const qTarget = this.q(relDef.to);
|
|
3847
|
-
const qParent = this.q(
|
|
4037
|
+
const qParent = this.q(parentRef);
|
|
3848
4038
|
// belongsTo: alias.referenceKey = parent.foreignKey; hasOne: reversed.
|
|
3849
4039
|
const correlation = relDef.type === 'belongsTo'
|
|
3850
4040
|
? this.dialect.buildCorrelation(alias, relDef.referenceKey, qParent, relDef.foreignKey)
|
|
@@ -3855,7 +4045,9 @@ export class QueryInterface {
|
|
|
3855
4045
|
}
|
|
3856
4046
|
return entries
|
|
3857
4047
|
.map(([col, dirValue]) => {
|
|
3858
|
-
|
|
4048
|
+
// columnMap-first resolution (camelToSnake fallback): mirrors the
|
|
4049
|
+
// scalar orderBy path so camelCase-named DB columns resolve here too.
|
|
4050
|
+
const snakeCol = targetMeta.columnMap[col] ?? camelToSnake(col);
|
|
3859
4051
|
if (!targetMeta.allColumns.includes(snakeCol)) {
|
|
3860
4052
|
throw new ValidationError(`[turbine] Unknown column "${col}" in orderBy on relation "${relName}" (table "${relDef.to}").`);
|
|
3861
4053
|
}
|
|
@@ -3873,6 +4065,74 @@ export class QueryInterface {
|
|
|
3873
4065
|
})
|
|
3874
4066
|
.join(', ');
|
|
3875
4067
|
}
|
|
4068
|
+
/**
|
|
4069
|
+
* Compile the ORDER BY terms of a relation `with` clause against the
|
|
4070
|
+
* relation's table alias. One unified path for every relation shape
|
|
4071
|
+
* (hasMany / manyToMany / belongsTo / hasOne) supporting exactly what the
|
|
4072
|
+
* top-level orderBy accepts at this level:
|
|
4073
|
+
*
|
|
4074
|
+
* - scalar columns via columnMap resolution (camelToSnake fallback) with
|
|
4075
|
+
* {@link OrderBySpec} nulls placement,
|
|
4076
|
+
* - {@link JsonPathOrderBy} entries (path bound as one text[] param),
|
|
4077
|
+
* - relation ordering on the TARGET's relations (`_count` for to-many, a
|
|
4078
|
+
* target column for to-one), correlated to the relation alias,
|
|
4079
|
+
* - vector KNN ordering stays top-level-only (E003, same as before).
|
|
4080
|
+
*
|
|
4081
|
+
* Param pushes (JSON paths, relation-order global filters) MUST be mirrored,
|
|
4082
|
+
* in the same order, by {@link collectRelationOrderParams}.
|
|
4083
|
+
*/
|
|
4084
|
+
buildRelationOrderClause(targetTable, targetMeta, alias, orderEntries, params) {
|
|
4085
|
+
let relOrdCounter = 0;
|
|
4086
|
+
const orders = orderEntries
|
|
4087
|
+
.map(([key, dirValue]) => {
|
|
4088
|
+
if (isVectorOrderBy(dirValue)) {
|
|
4089
|
+
throw new ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
|
|
4090
|
+
}
|
|
4091
|
+
if (isJsonPathOrderBy(dirValue)) {
|
|
4092
|
+
return this.buildJsonPathOrderEntry(targetTable, targetMeta, key, dirValue, `${alias}.`, params);
|
|
4093
|
+
}
|
|
4094
|
+
if (this.isRelationOrderByValue(dirValue)) {
|
|
4095
|
+
return this.buildRelationOrderBy(key, dirValue, `${alias}ord${relOrdCounter++}`, params, { meta: targetMeta, table: targetTable, parentRef: alias });
|
|
4096
|
+
}
|
|
4097
|
+
const col = this.resolveOrderByColumn(targetTable, targetMeta, key);
|
|
4098
|
+
const { dir, nulls } = normalizeOrderBy(dirValue);
|
|
4099
|
+
return `${alias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
|
|
4100
|
+
})
|
|
4101
|
+
.join(', ');
|
|
4102
|
+
return ` ORDER BY ${orders}`;
|
|
4103
|
+
}
|
|
4104
|
+
/**
|
|
4105
|
+
* Param-collect mirror of {@link buildRelationOrderClause}: JSON-path
|
|
4106
|
+
* entries push their path (one text[] param each); relation-order entries
|
|
4107
|
+
* mirror {@link collectOrderByParams}' relation branch (count / to-one
|
|
4108
|
+
* global-filter params); scalar entries push nothing but re-run the same
|
|
4109
|
+
* column validation so a warmed cache can never skip it.
|
|
4110
|
+
*/
|
|
4111
|
+
collectRelationOrderParams(targetTable, targetMeta, orderEntries, params) {
|
|
4112
|
+
for (const [key, dirValue] of orderEntries) {
|
|
4113
|
+
if (isVectorOrderBy(dirValue)) {
|
|
4114
|
+
throw new ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
|
|
4115
|
+
}
|
|
4116
|
+
if (isJsonPathOrderBy(dirValue)) {
|
|
4117
|
+
this.validateJsonPathOrderBy(targetTable, targetMeta, key, dirValue);
|
|
4118
|
+
params.push(dirValue.path.map(String));
|
|
4119
|
+
continue;
|
|
4120
|
+
}
|
|
4121
|
+
if (this.isRelationOrderByValue(dirValue)) {
|
|
4122
|
+
const relDef = targetMeta.relations[key];
|
|
4123
|
+
if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
|
|
4124
|
+
this.collectRelationCountParams(relDef, params);
|
|
4125
|
+
}
|
|
4126
|
+
else if (relDef) {
|
|
4127
|
+
for (const _col of Object.keys(dirValue)) {
|
|
4128
|
+
this.collectTargetGlobalFilterAlias(relDef.to, params);
|
|
4129
|
+
}
|
|
4130
|
+
}
|
|
4131
|
+
continue;
|
|
4132
|
+
}
|
|
4133
|
+
this.resolveOrderByColumn(targetTable, targetMeta, key);
|
|
4134
|
+
}
|
|
4135
|
+
}
|
|
3876
4136
|
/**
|
|
3877
4137
|
* Build a correlated `(SELECT COUNT(*) …)` scalar subquery for a to-many
|
|
3878
4138
|
* relation, correlated to `parentRef`. hasMany counts child rows via the FK;
|
|
@@ -4592,20 +4852,13 @@ export class QueryInterface {
|
|
|
4592
4852
|
// Quote parent ref — can be a table name or auto-generated alias
|
|
4593
4853
|
const qParent = this.q(parentRef);
|
|
4594
4854
|
const qTarget = this.q(targetTable);
|
|
4595
|
-
// Build ORDER BY for json_agg
|
|
4855
|
+
// Build ORDER BY for json_agg: unified with the top-level orderBy surface
|
|
4856
|
+
// (columnMap resolution, OrderBySpec nulls, JSON-path, relation ordering).
|
|
4857
|
+
// Param pushes here land BEFORE the spec.where params, mirrored by
|
|
4858
|
+
// collectRelationSubqueryParams.
|
|
4596
4859
|
let orderClause = '';
|
|
4597
4860
|
if (relOrderEntries.length > 0) {
|
|
4598
|
-
|
|
4599
|
-
.map(([k, dirValue]) => {
|
|
4600
|
-
const col = camelToSnake(k);
|
|
4601
|
-
if (!targetMeta.allColumns.includes(col)) {
|
|
4602
|
-
throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
4603
|
-
}
|
|
4604
|
-
const { dir, nulls } = normalizeOrderBy(dirValue);
|
|
4605
|
-
return `${alias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
|
|
4606
|
-
})
|
|
4607
|
-
.join(', ');
|
|
4608
|
-
orderClause = ` ORDER BY ${orders}`;
|
|
4861
|
+
orderClause = this.buildRelationOrderClause(targetTable, targetMeta, alias, relOrderEntries, params);
|
|
4609
4862
|
}
|
|
4610
4863
|
// Build WHERE — correlate to parent via parentRef (alias or table name).
|
|
4611
4864
|
// For hasMany/hasOne: TARGET has the FK (RelationDef.foreignKey is always
|
|
@@ -4679,8 +4932,10 @@ export class QueryInterface {
|
|
|
4679
4932
|
const inlineOrder = this.dialect.aggSupportsInlineOrderBy ? orderClause.trim() || undefined : undefined;
|
|
4680
4933
|
return `SELECT ${this.dialect.buildJsonArrayAgg(jsonObj, inlineOrder)} FROM ${qTarget} ${alias} WHERE ${whereClause}`;
|
|
4681
4934
|
}
|
|
4682
|
-
// belongsTo / hasOne
|
|
4683
|
-
|
|
4935
|
+
// belongsTo / hasOne: return single object. An orderBy picks WHICH row
|
|
4936
|
+
// the LIMIT 1 keeps (deterministic hasOne over a non-unique FK): matching
|
|
4937
|
+
// the batched strategy, which orders its flat follow-up and takes bucket[0].
|
|
4938
|
+
return `SELECT ${jsonObj} FROM ${qTarget} ${alias} WHERE ${whereClause}${orderClause} LIMIT 1`;
|
|
4684
4939
|
}
|
|
4685
4940
|
/**
|
|
4686
4941
|
* Build the json_agg subquery for a `manyToMany` relation, JOINing the target
|
|
@@ -4738,22 +4993,15 @@ export class QueryInterface {
|
|
|
4738
4993
|
let whereClause = sourceKeys
|
|
4739
4994
|
.map((jcol, i) => `${jalias}.${this.q(jcol)} = ${qParent}.${this.q(refKeys[i])}`)
|
|
4740
4995
|
.join(' AND ');
|
|
4741
|
-
// ORDER BY on the target rows
|
|
4742
|
-
//
|
|
4996
|
+
// ORDER BY on the target rows: unified with the top-level orderBy surface
|
|
4997
|
+
// (columnMap resolution, OrderBySpec nulls, JSON-path, relation ordering).
|
|
4998
|
+
// `orderBy: {}` (no defined entries) is treated as absent: it must not
|
|
4999
|
+
// render a dangling `ORDER BY `. Param pushes here land BEFORE the
|
|
5000
|
+
// spec.where params, mirrored by collectRelationSubqueryParams' m2m branch.
|
|
4743
5001
|
const relOrderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
|
|
4744
5002
|
let orderClause = '';
|
|
4745
5003
|
if (relOrderEntries.length > 0) {
|
|
4746
|
-
|
|
4747
|
-
.map(([k, dirValue]) => {
|
|
4748
|
-
const col = camelToSnake(k);
|
|
4749
|
-
if (!targetMeta.allColumns.includes(col)) {
|
|
4750
|
-
throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
4751
|
-
}
|
|
4752
|
-
const { dir, nulls } = normalizeOrderBy(dirValue);
|
|
4753
|
-
return `${talias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
|
|
4754
|
-
})
|
|
4755
|
-
.join(', ');
|
|
4756
|
-
orderClause = ` ORDER BY ${orders}`;
|
|
5004
|
+
orderClause = this.buildRelationOrderClause(targetTable, targetMeta, talias, relOrderEntries, params);
|
|
4757
5005
|
}
|
|
4758
5006
|
// Additional WHERE filters on the target — full scalar where surface,
|
|
4759
5007
|
// properly parameterized against the target alias.
|
package/dist/query/filters.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* compiler. Kept out of builder.ts so the class file stays about SQL assembly
|
|
6
6
|
* and execution rather than filter-shape bookkeeping.
|
|
7
7
|
*/
|
|
8
|
-
import type { ArrayFilter, JsonFilter, OrderBySpec, OrderDirection, TextSearchFilter, VectorFilter, VectorOrderBy, WhereOperator } from './types.js';
|
|
8
|
+
import type { ArrayFilter, ColumnRef, JsonFilter, JsonPathOrderBy, OrderBySpec, OrderDirection, TextSearchFilter, VectorFilter, VectorOrderBy, WhereOperator } from './types.js';
|
|
9
9
|
/** Check if a value is a where operator object (has at least one known operator key) */
|
|
10
10
|
export declare function isWhereOperator(value: unknown): value is WhereOperator;
|
|
11
11
|
/**
|
|
@@ -15,11 +15,30 @@ export declare function isWhereOperator(value: unknown): value is WhereOperator;
|
|
|
15
15
|
* bind values and return false, as do arrays and Dates.
|
|
16
16
|
*/
|
|
17
17
|
export declare function isUnmatchedPlainObject(value: unknown): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Operator keys that accept a {@link ColumnRef} (`{ col: 'otherField' }`)
|
|
20
|
+
* value for column-to-column comparison. `in`/`notIn` and the LIKE operators
|
|
21
|
+
* take values only.
|
|
22
|
+
*/
|
|
23
|
+
export declare const COLUMN_REF_OPERATORS: Set<string>;
|
|
24
|
+
/**
|
|
25
|
+
* Check if an operator value is a column reference: a plain object whose ONLY
|
|
26
|
+
* key is `col` with a string value. Anything else (extra keys, non-string
|
|
27
|
+
* `col`) is treated as a plain value so JSON payloads that merely contain a
|
|
28
|
+
* `col` property keep their equality meaning.
|
|
29
|
+
*/
|
|
30
|
+
export declare function isColumnRef(value: unknown): value is ColumnRef;
|
|
18
31
|
/**
|
|
19
32
|
* Fingerprint the SHAPE of a where-operator object. Null-valued `equals` /
|
|
20
33
|
* `not` compile to parameterless `IS NULL` / `IS NOT NULL` (different SQL, no
|
|
21
34
|
* param pushed), so null-ness is part of the shape — without it a cache entry
|
|
22
35
|
* warmed by `{ not: 5 }` would serve `{ not: null }` with a desynced param list.
|
|
36
|
+
*
|
|
37
|
+
* Column references ({@link ColumnRef}) compile the referenced column into the
|
|
38
|
+
* SQL TEXT (no param bound), so the referenced field name is part of the shape
|
|
39
|
+
*: `{ equals: { col: 'a' } }` and `{ equals: { col: 'b' } }` must never share
|
|
40
|
+
* a cache entry. The name is JSON-encoded so exotic field names cannot collide
|
|
41
|
+
* with other fingerprint tokens.
|
|
23
42
|
*/
|
|
24
43
|
export declare function fingerprintOperatorShape(value: WhereOperator): string;
|
|
25
44
|
/**
|
|
@@ -127,6 +146,14 @@ export declare function isVectorFilter(value: unknown): value is VectorFilter;
|
|
|
127
146
|
export declare function isVectorOrderBy(value: unknown): value is VectorOrderBy;
|
|
128
147
|
/** Check if an orderBy value is an explicit `{ sort, nulls? }` spec. */
|
|
129
148
|
export declare function isOrderBySpec(value: unknown): value is OrderBySpec;
|
|
149
|
+
/**
|
|
150
|
+
* Check if an orderBy value is a JSON-path ordering: `{ path: [...] }` with an
|
|
151
|
+
* ARRAY path. The array requirement disambiguates from relation orderBy values
|
|
152
|
+
* (whose entries are directions/specs keyed by target column: a target column
|
|
153
|
+
* literally named `path` maps to a string direction, never an array), and the
|
|
154
|
+
* `distance`/`sort` exclusions keep vector and spec shapes out.
|
|
155
|
+
*/
|
|
156
|
+
export declare function isJsonPathOrderBy(value: unknown): value is JsonPathOrderBy;
|
|
130
157
|
/**
|
|
131
158
|
* Normalize an orderBy value into `{ direction, nulls }`. Accepts a plain
|
|
132
159
|
* direction string or an {@link OrderBySpec}. Used by every ORDER BY compile
|