turbine-orm 0.32.2 → 0.34.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 +2 -2
- package/dist/cjs/dialect.js +1 -0
- package/dist/cjs/index-advisor.js +0 -0
- package/dist/cjs/index.js +2 -1
- package/dist/cjs/mssql.js +3 -0
- package/dist/cjs/mysql.js +3 -0
- package/dist/cjs/optional-peer-import.cjs +28 -0
- package/dist/cjs/powdb-introspect.js +222 -0
- package/dist/cjs/powdb.js +446 -55
- package/dist/cjs/powql.js +566 -111
- package/dist/cjs/query/builder.js +136 -53
- package/dist/cjs/query/filters.js +4 -4
- package/dist/cjs/schema-builder.js +16 -0
- package/dist/cjs/schema-metadata.js +81 -10
- package/dist/cjs/sqlite.js +2 -0
- package/dist/dialect.d.ts +7 -0
- package/dist/dialect.js +1 -0
- package/dist/index-advisor.d.ts +15 -1
- package/dist/index-advisor.js +0 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/mssql.js +3 -0
- package/dist/mysql.js +3 -0
- package/dist/optional-peer-import.cjs +28 -0
- package/dist/optional-peer-import.d.cts +19 -0
- package/dist/powdb-introspect.d.ts +84 -0
- package/dist/powdb-introspect.js +219 -0
- package/dist/powdb.d.ts +249 -13
- package/dist/powdb.js +438 -54
- package/dist/powql.d.ts +113 -6
- package/dist/powql.js +568 -113
- package/dist/query/builder.d.ts +11 -0
- package/dist/query/builder.js +136 -53
- package/dist/query/filters.d.ts +3 -3
- package/dist/query/filters.js +4 -4
- package/dist/query/types.d.ts +50 -6
- package/dist/schema-builder.d.ts +46 -1
- package/dist/schema-builder.js +15 -0
- package/dist/schema-metadata.d.ts +13 -7
- package/dist/schema-metadata.js +82 -11
- package/dist/schema.d.ts +25 -0
- package/dist/sqlite.js +2 -0
- package/package.json +3 -3
package/dist/query/builder.d.ts
CHANGED
|
@@ -829,6 +829,17 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
829
829
|
* target global filter → `pick.where` → `pick.orderBy` JSON paths.
|
|
830
830
|
*/
|
|
831
831
|
private buildRelationPickOrderBy;
|
|
832
|
+
/**
|
|
833
|
+
* Compile the shared inner pieces of a pick-row ordering against `childAlias`
|
|
834
|
+
* (the table alias the related row is read from): the `by` value expression,
|
|
835
|
+
* the correlation + target global filter + `pick.where` predicate, and the
|
|
836
|
+
* `pick.orderBy` clause. Factored out of {@link buildRelationPickOrderBy} so
|
|
837
|
+
* the subquery and lateral plans build IDENTICAL pieces in the SAME param
|
|
838
|
+
* push order (`by` JSON path → target global filter → `pick.where` →
|
|
839
|
+
* `pick.orderBy` JSON paths), which is why the collect mirror
|
|
840
|
+
* ({@link collectRelationPickOrderParams}) is plan-agnostic.
|
|
841
|
+
*/
|
|
842
|
+
private compilePickPieces;
|
|
832
843
|
/**
|
|
833
844
|
* Param-collect mirror of {@link buildRelationPickOrderBy}: re-runs the same
|
|
834
845
|
* validation (a warmed cache can never skip it), then pushes in the same
|
package/dist/query/builder.js
CHANGED
|
@@ -959,14 +959,22 @@ export class QueryInterface {
|
|
|
959
959
|
else {
|
|
960
960
|
selectClause = `${qt}.*`;
|
|
961
961
|
}
|
|
962
|
-
|
|
962
|
+
// Piece-then-assemble. The join-sink between FROM and WHERE carries any
|
|
963
|
+
// `plan: 'lateral'` pick joins; it is populated during the ORDER BY build
|
|
964
|
+
// below and spliced in at final assembly. Empty for every other query
|
|
965
|
+
// shape → the assembled SQL is byte-identical to the incremental-append
|
|
966
|
+
// form for the default plan (asserted by the byte-equality snapshot test).
|
|
967
|
+
const lateralJoins = [];
|
|
968
|
+
// WHERE + cursor conditions accumulate into `tail`, pushing params in
|
|
969
|
+
// where → cursor order (the collect path mirrors this exactly).
|
|
970
|
+
let tail = freshWhereSql;
|
|
963
971
|
if (args?.cursor) {
|
|
964
972
|
// Sorted (canonical) order — MUST match cursorFp and the cache-hit collect below.
|
|
965
973
|
const cursorEntries = sortedEntries(args.cursor).filter(([, v]) => v !== undefined);
|
|
966
974
|
if (cursorEntries.length > 0) {
|
|
967
975
|
const cursorConditions = cursorEntries.map(([k, v]) => {
|
|
968
976
|
const col = this.toSqlColumn(k);
|
|
969
|
-
// orderBy values can be the { sort, nulls } spec form
|
|
977
|
+
// orderBy values can be the { sort, nulls } spec form: normalize
|
|
970
978
|
// before comparing, or a desc spec would seek the ascending side.
|
|
971
979
|
const dir = args.orderBy?.[k];
|
|
972
980
|
const desc = isOrderBySpec(dir) ? dir.sort === 'desc' : dir === 'desc';
|
|
@@ -974,33 +982,38 @@ export class QueryInterface {
|
|
|
974
982
|
freshParams.push(v);
|
|
975
983
|
return `${qt}.${col} ${op} ${this.p(freshParams.length)}`;
|
|
976
984
|
});
|
|
977
|
-
|
|
978
|
-
sql += ` AND ${cursorConditions.join(' AND ')}`;
|
|
979
|
-
}
|
|
980
|
-
else {
|
|
981
|
-
sql += ` WHERE ${cursorConditions.join(' AND ')}`;
|
|
982
|
-
}
|
|
985
|
+
tail += freshWhereSql ? ` AND ${cursorConditions.join(' AND ')}` : ` WHERE ${cursorConditions.join(' AND ')}`;
|
|
983
986
|
}
|
|
984
987
|
}
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
else {
|
|
1000
|
-
// Pass freshParams so vector KNN ordering binds its `$n::vector` query
|
|
1001
|
-
// vector at the correct position (after cursor params, before LIMIT).
|
|
1002
|
-
sql += ` ORDER BY ${this.buildOrderBy(args.orderBy, freshParams)}`;
|
|
988
|
+
// ORDER BY is built AFTER the cursor pushes (param order
|
|
989
|
+
// where → with → cursor → orderBy → limit → offset) and BEFORE final
|
|
990
|
+
// assembly (so the lateral sink is filled before the FROM clause is
|
|
991
|
+
// written). distinct + relation orderBy is refused up front, so a lateral
|
|
992
|
+
// pick can never reach the distinct branch (lateralJoins stays empty).
|
|
993
|
+
let sql;
|
|
994
|
+
if (args?.orderBy && distinctPrefix) {
|
|
995
|
+
// Postgres requires DISTINCT ON expressions to lead the ORDER BY. Prisma
|
|
996
|
+
// semantics ("first row per combination, result in the user's order")
|
|
997
|
+
// need two levels: inner DISTINCT ON ordered by the distinct columns then
|
|
998
|
+
// the user's order (picks the right representative row), outer re-ordered
|
|
999
|
+
// by the user's order alone.
|
|
1000
|
+
if (Object.values(args.orderBy).some((d) => isVectorOrderBy(d))) {
|
|
1001
|
+
throw new ValidationError('[turbine] `distinct` cannot be combined with vector distance ordering.');
|
|
1003
1002
|
}
|
|
1003
|
+
const userOrder = this.buildOrderBy(args.orderBy, freshParams);
|
|
1004
|
+
const inner = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${tail} ORDER BY ${distinctCols
|
|
1005
|
+
.map((c) => `${c} ASC`)
|
|
1006
|
+
.join(', ')}, ${userOrder}`;
|
|
1007
|
+
sql = `SELECT * FROM (${inner}) AS ${this.q(`${this.table}_distinct`)} ORDER BY ${userOrder}`;
|
|
1008
|
+
}
|
|
1009
|
+
else {
|
|
1010
|
+
// Pass freshParams so vector KNN ordering binds its `$n::vector` query
|
|
1011
|
+
// vector at the correct position (after cursor params, before LIMIT), and
|
|
1012
|
+
// lateralJoins so a lateral pick splices its join into the FROM clause.
|
|
1013
|
+
const orderBySql = args?.orderBy
|
|
1014
|
+
? ` ORDER BY ${this.buildOrderBy(args.orderBy, freshParams, lateralJoins)}`
|
|
1015
|
+
: '';
|
|
1016
|
+
sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${lateralJoins.join('')}${tail}${orderBySql}`;
|
|
1004
1017
|
}
|
|
1005
1018
|
// Pagination — push params in the same order the collect path mirrors
|
|
1006
1019
|
// (limit before offset); the SQL TEXT shape is dialect-owned via
|
|
@@ -4318,7 +4331,13 @@ export class QueryInterface {
|
|
|
4318
4331
|
const pickWhere = d.pick?.where
|
|
4319
4332
|
? `;pw=${this.fingerprintAliasWhere(d.pick.where, targetTable)}`
|
|
4320
4333
|
: '';
|
|
4321
|
-
|
|
4334
|
+
// Plan discriminator: the lateral plan emits DIFFERENT SQL (a FROM-clause
|
|
4335
|
+
// join + a qualified order term) so a warm cache must never serve one
|
|
4336
|
+
// plan's SQL for the other. Emitted ONLY for `'lateral'`: absent means
|
|
4337
|
+
// the default subquery plan, keeping every pre-existing cache key
|
|
4338
|
+
// byte-identical (no cold-cache churn on upgrade).
|
|
4339
|
+
const planTag = d.plan === 'lateral' ? ';plan=lat' : '';
|
|
4340
|
+
return `pick(${by},${d.direction ?? 'asc'},${d.nulls ?? ''};po=${pickOrder}${pickWhere}${planTag})`;
|
|
4322
4341
|
}
|
|
4323
4342
|
if (isOrderBySpec(d))
|
|
4324
4343
|
return `spec(${d.sort},${d.nulls ?? ''})`;
|
|
@@ -4335,7 +4354,7 @@ export class QueryInterface {
|
|
|
4335
4354
|
}
|
|
4336
4355
|
return String(d);
|
|
4337
4356
|
}
|
|
4338
|
-
buildOrderBy(orderBy, params) {
|
|
4357
|
+
buildOrderBy(orderBy, params, lateralSink) {
|
|
4339
4358
|
// Dev-only: validate that orderBy fields exist in the table schema. Relation
|
|
4340
4359
|
// orderBy keys (object values that are neither a vector nor an OrderBySpec)
|
|
4341
4360
|
// are validated in the relation branch below, so skip them here.
|
|
@@ -4378,7 +4397,7 @@ export class QueryInterface {
|
|
|
4378
4397
|
// keyed by a relation name (`{ posts: { _count: 'desc' } }` / `{ author:
|
|
4379
4398
|
// { name: 'asc' } }`).
|
|
4380
4399
|
if (this.isRelationOrderByValue(value)) {
|
|
4381
|
-
return this.buildRelationOrderBy(key, value, `ord${relOrdCounter++}`, params);
|
|
4400
|
+
return this.buildRelationOrderBy(key, value, `ord${relOrdCounter++}`, params, undefined, lateralSink);
|
|
4382
4401
|
}
|
|
4383
4402
|
// Scalar column ordering — a plain direction or an OrderBySpec (nulls).
|
|
4384
4403
|
if (meta && !(key in meta.columnMap)) {
|
|
@@ -4472,7 +4491,18 @@ export class QueryInterface {
|
|
|
4472
4491
|
const extract = this.dialect.buildJsonPathExtract(`${prefix}${this.q(col)}`, this.p(params.length));
|
|
4473
4492
|
const lhs = spec.type === 'numeric' ? this.castJsonNumeric(extract) : extract;
|
|
4474
4493
|
const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
4475
|
-
|
|
4494
|
+
// Rows whose document lacks the path extract to NULL. Without a nulls
|
|
4495
|
+
// clause, Postgres DESC defaults to NULLS FIRST, which both diverges from
|
|
4496
|
+
// pick-row ordering (NULLS LAST both directions since 0.33) and from
|
|
4497
|
+
// engines whose path ordering is nulls-last in both directions. Default to
|
|
4498
|
+
// NULLS LAST in BOTH directions unless the caller set `nulls` explicitly;
|
|
4499
|
+
// the grammar gate matches nullsSuffix.
|
|
4500
|
+
const nullsSql = spec.nulls
|
|
4501
|
+
? this.nullsSuffix(spec.nulls)
|
|
4502
|
+
: this.dialect.name === 'postgresql' || this.dialect.name === 'sqlite'
|
|
4503
|
+
? ' NULLS LAST'
|
|
4504
|
+
: '';
|
|
4505
|
+
return `${lhs} ${dir}${nullsSql}`;
|
|
4476
4506
|
}
|
|
4477
4507
|
/**
|
|
4478
4508
|
* Compile a relation ordering term. For a to-many relation the only allowed
|
|
@@ -4487,7 +4517,7 @@ export class QueryInterface {
|
|
|
4487
4517
|
* subquery's orderBy the relations live on the TARGET table's metadata and
|
|
4488
4518
|
* the correlation parent is the relation's alias, not `this.table`.
|
|
4489
4519
|
*/
|
|
4490
|
-
buildRelationOrderBy(relName, value, alias, params, ctx) {
|
|
4520
|
+
buildRelationOrderBy(relName, value, alias, params, ctx, lateralSink) {
|
|
4491
4521
|
const ownerMeta = ctx?.meta ?? this.tableMeta;
|
|
4492
4522
|
const ownerTable = ctx?.table ?? this.table;
|
|
4493
4523
|
const parentRef = ctx?.parentRef ?? this.table;
|
|
@@ -4503,7 +4533,7 @@ export class QueryInterface {
|
|
|
4503
4533
|
// scope errors, shared with the cache-hit collect mirror.
|
|
4504
4534
|
if (isRelationPickOrderBy(value)) {
|
|
4505
4535
|
this.validatePickOrderBy(relName, relDef, value, ctx !== undefined);
|
|
4506
|
-
return this.buildRelationPickOrderBy(relName, relDef, value, alias, parentRef, params);
|
|
4536
|
+
return this.buildRelationPickOrderBy(relName, relDef, value, alias, parentRef, params, lateralSink);
|
|
4507
4537
|
}
|
|
4508
4538
|
// To-many: only `_count` is meaningful → correlated COUNT(*) subquery.
|
|
4509
4539
|
if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
|
|
@@ -4595,6 +4625,25 @@ export class QueryInterface {
|
|
|
4595
4625
|
throw new ValidationError(`[turbine] Pick-row ordering on relation "${relName}" requires \`by\`: a target column name ` +
|
|
4596
4626
|
"or a JSON-path spec ({ field: 'data', path: ['title'] }).");
|
|
4597
4627
|
}
|
|
4628
|
+
// Physical plan gate. A typo like `plan: 'latreal'` must never silently run
|
|
4629
|
+
// the subquery plan (a silent plan change wearing a validation gap). Shared
|
|
4630
|
+
// by build and cache-hit collect so a warmed cache throws identically.
|
|
4631
|
+
if (spec.plan !== undefined && spec.plan !== 'subquery' && spec.plan !== 'lateral') {
|
|
4632
|
+
throw new ValidationError(`[turbine] Pick-row ordering on relation "${relName}" has an invalid \`plan\`: ` +
|
|
4633
|
+
`${JSON.stringify(spec.plan)}. Use 'subquery' (default) or 'lateral'.`);
|
|
4634
|
+
}
|
|
4635
|
+
if (spec.plan === 'lateral') {
|
|
4636
|
+
if (!this.dialect.supportsLateralJoin) {
|
|
4637
|
+
throw new UnsupportedFeatureError("pick-row ordering with plan: 'lateral'", this.dialect.name, "LATERAL joins are only available on PostgreSQL. Omit `plan` (or use 'subquery').");
|
|
4638
|
+
}
|
|
4639
|
+
// The lateral exposes one reserved output column, `__turbine_pick`. A
|
|
4640
|
+
// parent column with that exact name would make the unqualified WHERE
|
|
4641
|
+
// reference ambiguous once the join is in scope; refuse it explicitly.
|
|
4642
|
+
if (this.tableMeta.allColumns.includes('__turbine_pick')) {
|
|
4643
|
+
throw new ValidationError(`[turbine] Pick-row ordering with plan: 'lateral' cannot be used: table "${this.tableMeta.name}" ` +
|
|
4644
|
+
'has a column named "__turbine_pick", which the lateral join output reserves.');
|
|
4645
|
+
}
|
|
4646
|
+
}
|
|
4598
4647
|
}
|
|
4599
4648
|
/**
|
|
4600
4649
|
* Compile a {@link RelationPickOrderBy} term: a correlated scalar subquery
|
|
@@ -4613,36 +4662,83 @@ export class QueryInterface {
|
|
|
4613
4662
|
* {@link collectRelationPickOrderParams}): `by` JSON path (if any) →
|
|
4614
4663
|
* target global filter → `pick.where` → `pick.orderBy` JSON paths.
|
|
4615
4664
|
*/
|
|
4616
|
-
buildRelationPickOrderBy(relName, relDef, spec, alias, parentRef, params) {
|
|
4665
|
+
buildRelationPickOrderBy(relName, relDef, spec, alias, parentRef, params, lateralSink) {
|
|
4617
4666
|
if (!params) {
|
|
4618
4667
|
throw new ValidationError(`[turbine] Pick-row ordering on relation "${relName}" is only supported in a top-level findMany orderBy.`);
|
|
4619
4668
|
}
|
|
4620
4669
|
const targetMeta = this.schema.tables[relDef.to];
|
|
4621
4670
|
if (!targetMeta)
|
|
4622
4671
|
throw new RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
|
|
4672
|
+
const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
4673
|
+
const limitOne = this.buildPagination('1', undefined, true);
|
|
4674
|
+
// Parents with ZERO surviving related rows have no row to pick: the
|
|
4675
|
+
// correlated subquery yields NULL, and the LEFT JOIN LATERAL null-extends
|
|
4676
|
+
// its single row identically. Without a nulls clause, Postgres DESC
|
|
4677
|
+
// defaults to NULLS FIRST (every childless parent tops a "highest first"
|
|
4678
|
+
// sort). Default to NULLS LAST in BOTH directions (deterministic across
|
|
4679
|
+
// engines: SQLite's NULL-is-smallest default diverges from Postgres) unless
|
|
4680
|
+
// the caller set `nulls` explicitly; the grammar gate matches nullsSuffix.
|
|
4681
|
+
const nullsSql = spec.nulls
|
|
4682
|
+
? this.nullsSuffix(spec.nulls)
|
|
4683
|
+
: this.dialect.name === 'postgresql' || this.dialect.name === 'sqlite'
|
|
4684
|
+
? ' NULLS LAST'
|
|
4685
|
+
: '';
|
|
4686
|
+
// Lateral plan: splice a `LEFT JOIN LATERAL (... LIMIT 1) ON true` into the
|
|
4687
|
+
// FROM clause (via the sink) and order by its single reserved output column.
|
|
4688
|
+
// Param push order is IDENTICAL to the subquery plan (compilePickPieces is
|
|
4689
|
+
// shared), so the cache-hit collect mirror needs no changes. Scope +
|
|
4690
|
+
// capability were already enforced by validatePickOrderBy (shared with the
|
|
4691
|
+
// collect path); the missing-sink guard catches a non-findMany build
|
|
4692
|
+
// context and hard-fails rather than silently emitting a subquery.
|
|
4693
|
+
if (spec.plan === 'lateral') {
|
|
4694
|
+
if (!lateralSink) {
|
|
4695
|
+
throw new ValidationError(`[turbine] Pick-row ordering with plan: 'lateral' on relation "${relName}" is only supported ` +
|
|
4696
|
+
'in a top-level findMany orderBy.');
|
|
4697
|
+
}
|
|
4698
|
+
const childAlias = `${alias}i`;
|
|
4699
|
+
const { byExpr, where, orderClause } = this.compilePickPieces(relDef, targetMeta, spec, childAlias, parentRef, params);
|
|
4700
|
+
lateralSink.push(` LEFT JOIN LATERAL (SELECT ${byExpr} AS ${this.q('__turbine_pick')} FROM ${this.q(relDef.to)} ${childAlias}` +
|
|
4701
|
+
` WHERE ${where}${orderClause}${limitOne}) ${alias} ON true`);
|
|
4702
|
+
return `${alias}.${this.q('__turbine_pick')} ${dir}${nullsSql}`;
|
|
4703
|
+
}
|
|
4704
|
+
// Subquery plan (default): a correlated scalar subquery in ORDER BY.
|
|
4705
|
+
const { byExpr, where, orderClause } = this.compilePickPieces(relDef, targetMeta, spec, alias, parentRef, params);
|
|
4706
|
+
return `(SELECT ${byExpr} FROM ${this.q(relDef.to)} ${alias} WHERE ${where}${orderClause}${limitOne}) ${dir}${nullsSql}`;
|
|
4707
|
+
}
|
|
4708
|
+
/**
|
|
4709
|
+
* Compile the shared inner pieces of a pick-row ordering against `childAlias`
|
|
4710
|
+
* (the table alias the related row is read from): the `by` value expression,
|
|
4711
|
+
* the correlation + target global filter + `pick.where` predicate, and the
|
|
4712
|
+
* `pick.orderBy` clause. Factored out of {@link buildRelationPickOrderBy} so
|
|
4713
|
+
* the subquery and lateral plans build IDENTICAL pieces in the SAME param
|
|
4714
|
+
* push order (`by` JSON path → target global filter → `pick.where` →
|
|
4715
|
+
* `pick.orderBy` JSON paths), which is why the collect mirror
|
|
4716
|
+
* ({@link collectRelationPickOrderParams}) is plan-agnostic.
|
|
4717
|
+
*/
|
|
4718
|
+
compilePickPieces(relDef, targetMeta, spec, childAlias, parentRef, params) {
|
|
4623
4719
|
// The value surfaced from the picked row (SELECT list: its param binds first).
|
|
4624
4720
|
let byExpr;
|
|
4625
4721
|
if (typeof spec.by === 'string') {
|
|
4626
4722
|
const col = this.resolveOrderByColumn(relDef.to, targetMeta, spec.by);
|
|
4627
|
-
byExpr = `${
|
|
4723
|
+
byExpr = `${childAlias}.${this.q(col)}`;
|
|
4628
4724
|
}
|
|
4629
4725
|
else {
|
|
4630
4726
|
const col = this.validateJsonPathOrderBy(relDef.to, targetMeta, spec.by.field, {
|
|
4631
4727
|
path: spec.by.path,
|
|
4632
4728
|
});
|
|
4633
4729
|
params.push(this.jsonPathParam(spec.by.path));
|
|
4634
|
-
const extract = this.dialect.buildJsonPathExtract(`${
|
|
4730
|
+
const extract = this.dialect.buildJsonPathExtract(`${childAlias}.${this.q(col)}`, this.p(params.length));
|
|
4635
4731
|
byExpr = spec.by.type === 'numeric' ? this.castJsonNumeric(extract) : extract;
|
|
4636
4732
|
}
|
|
4637
4733
|
// Correlation to the parent row, then the target's global filter (a
|
|
4638
4734
|
// soft-deleted / other-tenant row must never be picked: matches the
|
|
4639
4735
|
// `with` subquery and to-one relation-orderBy semantics), then pick.where.
|
|
4640
|
-
let where = this.dialect.buildCorrelation(
|
|
4641
|
-
const gf = this.targetGlobalFilterAlias(relDef.to,
|
|
4736
|
+
let where = this.dialect.buildCorrelation(childAlias, relDef.foreignKey, this.q(parentRef), relDef.referenceKey);
|
|
4737
|
+
const gf = this.targetGlobalFilterAlias(relDef.to, childAlias, params);
|
|
4642
4738
|
if (gf)
|
|
4643
4739
|
where += ` AND ${gf}`;
|
|
4644
4740
|
if (spec.pick.where) {
|
|
4645
|
-
const pickWhere = this.buildAliasWhere(relDef.to, targetMeta,
|
|
4741
|
+
const pickWhere = this.buildAliasWhere(relDef.to, targetMeta, childAlias, spec.pick.where, params);
|
|
4646
4742
|
if (pickWhere)
|
|
4647
4743
|
where += ` AND ${pickWhere}`;
|
|
4648
4744
|
}
|
|
@@ -4650,21 +4746,8 @@ export class QueryInterface {
|
|
|
4650
4746
|
// (plain columns, OrderBySpec nulls, JSON-path specs); a nested pick in
|
|
4651
4747
|
// here routes back through buildRelationOrderBy with ctx set and throws
|
|
4652
4748
|
// the top-level-only E003.
|
|
4653
|
-
const orderClause = this.buildRelationOrderClause(relDef.to, targetMeta,
|
|
4654
|
-
|
|
4655
|
-
const limitOne = this.buildPagination('1', undefined, true);
|
|
4656
|
-
// Parents with ZERO surviving related rows make the correlated subquery
|
|
4657
|
-
// yield NULL. Without a nulls clause, Postgres DESC defaults to NULLS
|
|
4658
|
-
// FIRST — every childless parent would top a "highest first" sort. Default
|
|
4659
|
-
// to NULLS LAST in BOTH directions (deterministic across engines: SQLite's
|
|
4660
|
-
// NULL-is-smallest default diverges from Postgres) unless the caller set
|
|
4661
|
-
// `nulls` explicitly; the grammar gate matches nullsSuffix (PG + SQLite).
|
|
4662
|
-
const nullsSql = spec.nulls
|
|
4663
|
-
? this.nullsSuffix(spec.nulls)
|
|
4664
|
-
: this.dialect.name === 'postgresql' || this.dialect.name === 'sqlite'
|
|
4665
|
-
? ' NULLS LAST'
|
|
4666
|
-
: '';
|
|
4667
|
-
return `(SELECT ${byExpr} FROM ${this.q(relDef.to)} ${alias} WHERE ${where}${orderClause}${limitOne}) ${dir}${nullsSql}`;
|
|
4749
|
+
const orderClause = this.buildRelationOrderClause(relDef.to, targetMeta, childAlias, Object.entries(spec.pick.orderBy), params);
|
|
4750
|
+
return { byExpr, where, orderClause };
|
|
4668
4751
|
}
|
|
4669
4752
|
/**
|
|
4670
4753
|
* Param-collect mirror of {@link buildRelationPickOrderBy}: re-runs the same
|
package/dist/query/filters.d.ts
CHANGED
|
@@ -156,9 +156,9 @@ export declare function isOrderBySpec(value: unknown): value is OrderBySpec;
|
|
|
156
156
|
export declare function isJsonPathOrderBy(value: unknown): value is JsonPathOrderBy;
|
|
157
157
|
/**
|
|
158
158
|
* Check if an orderBy value is a pick-row relation ordering:
|
|
159
|
-
* `{ pick: { orderBy, ... }, by, direction?, nulls? }`. The full shape
|
|
160
|
-
* required
|
|
161
|
-
* present, and no keys outside `{ pick, by, direction, nulls }`
|
|
159
|
+
* `{ pick: { orderBy, ... }, by, direction?, nulls?, plan? }`. The full shape
|
|
160
|
+
* is required (`pick` must be an object carrying `orderBy`, `by` must be
|
|
161
|
+
* present, and no keys outside `{ pick, by, direction, nulls, plan }`), so a to-one
|
|
162
162
|
* relation whose target has real columns literally named `pick` and `by`
|
|
163
163
|
* (whose values are direction strings or `{ sort, nulls }` specs, never an
|
|
164
164
|
* object with `orderBy`) still falls through to column ordering. `distance`
|
package/dist/query/filters.js
CHANGED
|
@@ -296,9 +296,9 @@ export function isJsonPathOrderBy(value) {
|
|
|
296
296
|
}
|
|
297
297
|
/**
|
|
298
298
|
* Check if an orderBy value is a pick-row relation ordering:
|
|
299
|
-
* `{ pick: { orderBy, ... }, by, direction?, nulls? }`. The full shape
|
|
300
|
-
* required
|
|
301
|
-
* present, and no keys outside `{ pick, by, direction, nulls }`
|
|
299
|
+
* `{ pick: { orderBy, ... }, by, direction?, nulls?, plan? }`. The full shape
|
|
300
|
+
* is required (`pick` must be an object carrying `orderBy`, `by` must be
|
|
301
|
+
* present, and no keys outside `{ pick, by, direction, nulls, plan }`), so a to-one
|
|
302
302
|
* relation whose target has real columns literally named `pick` and `by`
|
|
303
303
|
* (whose values are direction strings or `{ sort, nulls }` specs, never an
|
|
304
304
|
* object with `orderBy`) still falls through to column ordering. `distance`
|
|
@@ -316,7 +316,7 @@ export function isRelationPickOrderBy(value) {
|
|
|
316
316
|
if (typeof v.pick !== 'object' || v.pick === null || Array.isArray(v.pick) || !('orderBy' in v.pick))
|
|
317
317
|
return false;
|
|
318
318
|
for (const key of Object.keys(v)) {
|
|
319
|
-
if (key !== 'pick' && key !== 'by' && key !== 'direction' && key !== 'nulls')
|
|
319
|
+
if (key !== 'pick' && key !== 'by' && key !== 'direction' && key !== 'nulls' && key !== 'plan')
|
|
320
320
|
return false;
|
|
321
321
|
}
|
|
322
322
|
return true;
|
package/dist/query/types.d.ts
CHANGED
|
@@ -618,6 +618,11 @@ export interface JsonPathGroupKey {
|
|
|
618
618
|
* json/jsonb column, e.g. `SUM((col #>> $n::text[])::numeric)`. The arg key
|
|
619
619
|
* is the result alias. `_sum`/`_avg` always cast numeric (a text sum is
|
|
620
620
|
* meaningless); `_min`/`_max` compare as text unless `type: 'numeric'`.
|
|
621
|
+
*
|
|
622
|
+
* Engine note: when a group has NO value at the path, SQL engines return
|
|
623
|
+
* `null` for `_sum` (SUM over zero rows), while PowDB returns `0` (engine
|
|
624
|
+
* sum semantics). Treat `null` and `0` totals as equivalent when a group can
|
|
625
|
+
* be empty at the path.
|
|
621
626
|
*/
|
|
622
627
|
export interface JsonPathAggregateTarget {
|
|
623
628
|
/** json/jsonb column (camelCase field name, columnMap-resolved). */
|
|
@@ -758,15 +763,37 @@ export interface RelationFilter {
|
|
|
758
763
|
is?: Record<string, unknown>;
|
|
759
764
|
isNot?: Record<string, unknown>;
|
|
760
765
|
}
|
|
761
|
-
/**
|
|
766
|
+
/**
|
|
767
|
+
* JSONB query operators for where clauses.
|
|
768
|
+
*
|
|
769
|
+
* PowDB (`turbine-orm/powdb`) semantic deltas. The PowDB engine evaluates
|
|
770
|
+
* `->` path filters with full type knowledge, so a few behaviours differ from
|
|
771
|
+
* the Postgres `#>>`-text driver (documented, never silently wrong):
|
|
772
|
+
* - `{ path, equals: null }` matches JSON null OR a MISSING key on PowDB
|
|
773
|
+
* (compiles to `is null`), whereas the PG driver compares extracted text
|
|
774
|
+
* against the string `'null'` and matches only a JSON string `"null"`.
|
|
775
|
+
* - equality is TYPE-STRICT on PowDB: `{ path, equals: 7 }` matches a stored
|
|
776
|
+
* JSON int `7` but not `7.0` or the JSON string `"7"` (PG text-extraction
|
|
777
|
+
* matches `equals: 7` against the string `"7"`). Range ops (`gt`/`lt`/…)
|
|
778
|
+
* still coerce int/float numerically.
|
|
779
|
+
* - a digit-only path segment (`path: ['tags', '0']`) is an ARRAY INDEX on
|
|
780
|
+
* both PowDB and the SQL engines (a json object key that is literally `"0"`
|
|
781
|
+
* is likewise addressed by index).
|
|
782
|
+
* - `contains`, and `equals` WITHOUT a `path` (whole-document containment),
|
|
783
|
+
* throw `UnsupportedFeatureError` (E017) on PowDB: PowQL has no containment
|
|
784
|
+
* operator.
|
|
785
|
+
*/
|
|
762
786
|
export interface JsonFilter {
|
|
763
|
-
/**
|
|
787
|
+
/**
|
|
788
|
+
* Access nested path via `#>>` operator (Postgres) / `->` path (PowDB). A
|
|
789
|
+
* digit-only segment (`'0'`) is treated as an array index on every engine.
|
|
790
|
+
*/
|
|
764
791
|
path?: string[];
|
|
765
|
-
/** Exact match: column @> value::jsonb (containment) */
|
|
792
|
+
/** Exact match: `column @> value::jsonb` (containment). On PowDB, requires `path` and compares the typed value (throws E017 without `path`). */
|
|
766
793
|
equals?: unknown;
|
|
767
|
-
/** Containment check: column @> value::jsonb */
|
|
794
|
+
/** Containment check: `column @> value::jsonb`. Unsupported on PowDB (E017: PowQL has no containment operator). */
|
|
768
795
|
contains?: unknown;
|
|
769
|
-
/** Key existence check: column ? key */
|
|
796
|
+
/** Key existence check: `column ? key`. */
|
|
770
797
|
hasKey?: string;
|
|
771
798
|
/**
|
|
772
799
|
* Greater-than comparison of the value at `path` (required). Numbers cast
|
|
@@ -897,7 +924,13 @@ export interface JsonPathOrderBy {
|
|
|
897
924
|
direction?: OrderDirection;
|
|
898
925
|
/** Comparison kind for the extracted value. Defaults to `'text'`; `'numeric'` adds a numeric cast. */
|
|
899
926
|
type?: 'numeric' | 'text';
|
|
900
|
-
/**
|
|
927
|
+
/**
|
|
928
|
+
* NULLS placement (PostgreSQL / SQLite only: see {@link OrderBySpec}).
|
|
929
|
+
* Rows whose document lacks the path extract to NULL and sort LAST in BOTH
|
|
930
|
+
* directions by default (matching pick-row ordering and the PowDB engine
|
|
931
|
+
* contract, so ordering is predictable across drivers); set `nulls` to
|
|
932
|
+
* override on PostgreSQL / SQLite.
|
|
933
|
+
*/
|
|
901
934
|
nulls?: 'first' | 'last';
|
|
902
935
|
}
|
|
903
936
|
/**
|
|
@@ -979,6 +1012,17 @@ export interface RelationPickOrderBy {
|
|
|
979
1012
|
* "highest first" sort). Set `nulls` explicitly to override.
|
|
980
1013
|
*/
|
|
981
1014
|
nulls?: 'first' | 'last';
|
|
1015
|
+
/**
|
|
1016
|
+
* Physical plan for the pick. `'subquery'` (default) compiles a correlated
|
|
1017
|
+
* scalar subquery in ORDER BY. `'lateral'` (PostgreSQL only, E017 elsewhere)
|
|
1018
|
+
* compiles a `LEFT JOIN LATERAL (... LIMIT 1) ON true` and orders by the
|
|
1019
|
+
* joined value. Identical results; the lateral form can be significantly
|
|
1020
|
+
* faster on large parent sets where the ordering subquery dominates the plan.
|
|
1021
|
+
* Never falls back silently: contexts that cannot take a lateral (non-Postgres
|
|
1022
|
+
* engines, `distinct`, nested `with` orderBy, a parent column literally named
|
|
1023
|
+
* `__turbine_pick`) throw.
|
|
1024
|
+
*/
|
|
1025
|
+
plan?: 'subquery' | 'lateral';
|
|
982
1026
|
}
|
|
983
1027
|
/**
|
|
984
1028
|
* An orderBy clause maps each key to one of:
|
package/dist/schema-builder.d.ts
CHANGED
|
@@ -126,6 +126,42 @@ export interface CheckDef {
|
|
|
126
126
|
/** Raw SQL boolean expression, e.g. `price > cost`. */
|
|
127
127
|
expression: string;
|
|
128
128
|
}
|
|
129
|
+
/** A plain (column-list) index declaration. */
|
|
130
|
+
export interface ColumnIndexDef {
|
|
131
|
+
/** camelCase field name(s) the index covers. */
|
|
132
|
+
columns: string[];
|
|
133
|
+
/** Whether the index enforces uniqueness. */
|
|
134
|
+
unique?: boolean;
|
|
135
|
+
/** Optional explicit index name (auto-derived when omitted). */
|
|
136
|
+
name?: string;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* A doc-field expression index on a JSON document column (PowDB ≥ 0.13).
|
|
140
|
+
* Indexes the value at `docField-><path>` inside the json document, so a
|
|
141
|
+
* `JsonFilter`/`orderBy` on that path can use an index instead of a scan.
|
|
142
|
+
*/
|
|
143
|
+
export interface DocFieldIndexDef {
|
|
144
|
+
/** camelCase field name of the json document column. */
|
|
145
|
+
docField: string;
|
|
146
|
+
/** JSON path into the document: string keys and integer array indexes. */
|
|
147
|
+
path: (string | number)[];
|
|
148
|
+
/** Whether the expression index enforces uniqueness. */
|
|
149
|
+
unique?: boolean;
|
|
150
|
+
/** Optional explicit index name (auto-derived when omitted). */
|
|
151
|
+
name?: string;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* A single index declaration on a table: either a plain column-list index
|
|
155
|
+
* ({@link ColumnIndexDef}) or a doc-field expression index into a json column
|
|
156
|
+
* ({@link DocFieldIndexDef}).
|
|
157
|
+
*
|
|
158
|
+
* Consumed today by the PowDB DDL generator (`powqlSchemaDDL`) and carried onto
|
|
159
|
+
* {@link import('./schema.js').IndexMetadata} by `schemaDefToMetadata`. The SQL
|
|
160
|
+
* DDL generators (`schema-sql.ts` / `schemaDiff`) do NOT consume these yet.
|
|
161
|
+
*/
|
|
162
|
+
export type SchemaIndexDef = ColumnIndexDef | DocFieldIndexDef;
|
|
163
|
+
/** Type guard: is this index declaration a doc-field expression index? */
|
|
164
|
+
export declare function isDocFieldIndexDef(idx: SchemaIndexDef): idx is DocFieldIndexDef;
|
|
129
165
|
export interface TableDef {
|
|
130
166
|
/**
|
|
131
167
|
* DDL-facing table name (snake_case). This is the name used when generating
|
|
@@ -159,6 +195,13 @@ export interface TableDef {
|
|
|
159
195
|
manyToMany?: readonly ManyToManyDef[];
|
|
160
196
|
/** Table-level `CHECK` constraints. */
|
|
161
197
|
checks?: readonly CheckDef[];
|
|
198
|
+
/**
|
|
199
|
+
* Index declarations for this table (plain column indexes and/or PowDB
|
|
200
|
+
* doc-field expression indexes). Consumed by the PowDB DDL generator
|
|
201
|
+
* (`powqlSchemaDDL`) and carried onto `IndexMetadata` by
|
|
202
|
+
* `schemaDefToMetadata`; the SQL DDL generators do not consume them yet.
|
|
203
|
+
*/
|
|
204
|
+
indexes?: readonly SchemaIndexDef[];
|
|
162
205
|
}
|
|
163
206
|
/**
|
|
164
207
|
* User-facing input shape for a single table when using the object format.
|
|
@@ -171,8 +214,10 @@ export interface TableInput {
|
|
|
171
214
|
manyToMany?: readonly ManyToManyDef[];
|
|
172
215
|
/** Optional table-level CHECK constraints */
|
|
173
216
|
checks?: readonly CheckDef[];
|
|
217
|
+
/** Optional index declarations (plain column and/or doc-field expression) */
|
|
218
|
+
indexes?: readonly SchemaIndexDef[];
|
|
174
219
|
/** Column definitions keyed by camelCase field name */
|
|
175
|
-
[columnName: string]: ColumnDef | readonly string[] | readonly ManyToManyDef[] | readonly CheckDef[] | undefined;
|
|
220
|
+
[columnName: string]: ColumnDef | readonly string[] | readonly ManyToManyDef[] | readonly CheckDef[] | readonly SchemaIndexDef[] | undefined;
|
|
176
221
|
}
|
|
177
222
|
export interface SchemaDef {
|
|
178
223
|
/**
|
package/dist/schema-builder.js
CHANGED
|
@@ -96,6 +96,10 @@ function resolveColumn(def) {
|
|
|
96
96
|
check: def.check ?? null,
|
|
97
97
|
};
|
|
98
98
|
}
|
|
99
|
+
/** Type guard: is this index declaration a doc-field expression index? */
|
|
100
|
+
export function isDocFieldIndexDef(idx) {
|
|
101
|
+
return 'docField' in idx && typeof idx.docField === 'string';
|
|
102
|
+
}
|
|
99
103
|
/** Check if a value is a TableDef (from legacy table() builder) */
|
|
100
104
|
function isTableDef(v) {
|
|
101
105
|
return typeof v === 'object' && v !== null && 'columns' in v && 'name' in v;
|
|
@@ -139,7 +143,17 @@ export function defineSchema(input, options) {
|
|
|
139
143
|
let pk;
|
|
140
144
|
let m2m;
|
|
141
145
|
let checks;
|
|
146
|
+
let indexes;
|
|
142
147
|
for (const [fieldName, def] of Object.entries(raw)) {
|
|
148
|
+
if (fieldName === 'indexes') {
|
|
149
|
+
if (def !== undefined) {
|
|
150
|
+
if (!Array.isArray(def)) {
|
|
151
|
+
throw new Error(`Table "${accessor}": "indexes" must be an array of index declarations`);
|
|
152
|
+
}
|
|
153
|
+
indexes = def;
|
|
154
|
+
}
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
143
157
|
if (fieldName === 'manyToMany') {
|
|
144
158
|
if (def !== undefined) {
|
|
145
159
|
if (!Array.isArray(def)) {
|
|
@@ -199,6 +213,7 @@ export function defineSchema(input, options) {
|
|
|
199
213
|
...(pk && pk.length > 0 ? { primaryKey: pk } : {}),
|
|
200
214
|
...(m2m && m2m.length > 0 ? { manyToMany: m2m } : {}),
|
|
201
215
|
...(checks && checks.length > 0 ? { checks } : {}),
|
|
216
|
+
...(indexes && indexes.length > 0 ? { indexes } : {}),
|
|
202
217
|
};
|
|
203
218
|
}
|
|
204
219
|
}
|
|
@@ -24,10 +24,12 @@
|
|
|
24
24
|
* the same conservative auto-`manyToMany` treatment as introspection.
|
|
25
25
|
* - Explicit `manyToMany` declarations on the SchemaDef are merged via
|
|
26
26
|
* {@link applyManyToManyRelations} (additive, never clobbering).
|
|
27
|
-
* - `indexes`
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
27
|
+
* - `indexes` carries any declared `TableDef.indexes` (plain column and/or
|
|
28
|
+
* PowDB doc-field expression indexes); a table with none declared gets
|
|
29
|
+
* `[]`, which keeps `schemaHasIndexInfo()` false so the index advisor and
|
|
30
|
+
* the dev-mode missing-index warning stay silent instead of producing
|
|
31
|
+
* blanket false positives. Doc-field (docPath) indexes are ignored by the
|
|
32
|
+
* advisor, so a doc-only index set never flips `schemaHasIndexInfo()`.
|
|
31
33
|
*
|
|
32
34
|
* @example
|
|
33
35
|
* ```ts
|
|
@@ -67,10 +69,14 @@ import { type SchemaDef } from './schema-builder.js';
|
|
|
67
69
|
* - Explicit `manyToMany` declarations → merged additively.
|
|
68
70
|
* - Schema-level `enums`.
|
|
69
71
|
*
|
|
72
|
+
* What maps (continued):
|
|
73
|
+
* - `indexes` → declared `TableDef.indexes` become `IndexMetadata` (plain
|
|
74
|
+
* column indexes and PowDB doc-field expression indexes, the latter
|
|
75
|
+
* carrying `docPath`). A table with no declared indexes gets `[]`, keeping
|
|
76
|
+
* `schemaHasIndexInfo()` false so index-advisor consumers produce no false
|
|
77
|
+
* positives on index-less code-first metadata.
|
|
78
|
+
*
|
|
70
79
|
* What SchemaDef cannot express (and how it degrades):
|
|
71
|
-
* - Indexes → every table gets `indexes: []`, which keeps
|
|
72
|
-
* `schemaHasIndexInfo()` false so index-advisor consumers produce no
|
|
73
|
-
* false positives on code-first metadata.
|
|
74
80
|
* - Views → never marked (`isView` is introspection-only).
|
|
75
81
|
* - Composite foreign keys → `references:` is single-column by design.
|
|
76
82
|
*/
|