turbine-orm 0.32.2 → 0.33.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.
@@ -58,6 +58,7 @@ exports.postgresDialect = {
58
58
  supportsListenNotify: true,
59
59
  supportsRLS: true,
60
60
  supportsAdvisoryLock: true,
61
+ supportsLateralJoin: true,
61
62
  paramPlaceholder(index) {
62
63
  return `$${index}`;
63
64
  },
package/dist/cjs/mssql.js CHANGED
@@ -478,6 +478,9 @@ exports.mssqlDialect = {
478
478
  supportsVector: false,
479
479
  supportsListenNotify: false,
480
480
  supportsRLS: false,
481
+ // SQL Server has OUTER APPLY, not FROM-clause LATERAL: the lateral pick plan
482
+ // is Postgres-only (out of scope here).
483
+ supportsLateralJoin: false,
481
484
  // sp_getapplock / sp_releaseapplock exist (used by a future migrate adapter).
482
485
  supportsAdvisoryLock: true,
483
486
  // FOR JSON over zero rows is NULL → coalesced in the relation override.
package/dist/cjs/mysql.js CHANGED
@@ -379,6 +379,9 @@ exports.mysqlDialect = {
379
379
  supportsVector: false,
380
380
  supportsListenNotify: false,
381
381
  supportsRLS: false,
382
+ // MySQL 8.0.14+ supports LATERAL, but the opt-in lateral pick plan stays
383
+ // Postgres-only in this release (flipping it on is a one-line change + tests).
384
+ supportsLateralJoin: false,
382
385
  // GET_LOCK / RELEASE_LOCK exist (used by a future migrate adapter).
383
386
  supportsAdvisoryLock: true,
384
387
  // JSON_ARRAYAGG has no inline ORDER BY argument → force the inner-subquery
package/dist/cjs/powdb.js CHANGED
@@ -142,6 +142,9 @@ exports.powdbDialect = {
142
142
  supportsRLS: false,
143
143
  supportsAdvisoryLock: false,
144
144
  supportsILike: false,
145
+ // PowQL has no LATERAL construct; PowqlInterface refuses pick ordering
146
+ // earlier, this override keeps the flag truthful if a future path consults it.
147
+ supportsLateralJoin: false,
145
148
  beginStatement: () => 'begin',
146
149
  commitStatement: () => 'commit',
147
150
  rollbackStatement: () => 'rollback',
@@ -995,14 +995,22 @@ class QueryInterface {
995
995
  else {
996
996
  selectClause = `${qt}.*`;
997
997
  }
998
- let sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${freshWhereSql}`;
998
+ // Piece-then-assemble. The join-sink between FROM and WHERE carries any
999
+ // `plan: 'lateral'` pick joins; it is populated during the ORDER BY build
1000
+ // below and spliced in at final assembly. Empty for every other query
1001
+ // shape → the assembled SQL is byte-identical to the incremental-append
1002
+ // form for the default plan (asserted by the byte-equality snapshot test).
1003
+ const lateralJoins = [];
1004
+ // WHERE + cursor conditions accumulate into `tail`, pushing params in
1005
+ // where → cursor order (the collect path mirrors this exactly).
1006
+ let tail = freshWhereSql;
999
1007
  if (args?.cursor) {
1000
1008
  // Sorted (canonical) order — MUST match cursorFp and the cache-hit collect below.
1001
1009
  const cursorEntries = (0, filters_js_1.sortedEntries)(args.cursor).filter(([, v]) => v !== undefined);
1002
1010
  if (cursorEntries.length > 0) {
1003
1011
  const cursorConditions = cursorEntries.map(([k, v]) => {
1004
1012
  const col = this.toSqlColumn(k);
1005
- // orderBy values can be the { sort, nulls } spec form normalize
1013
+ // orderBy values can be the { sort, nulls } spec form: normalize
1006
1014
  // before comparing, or a desc spec would seek the ascending side.
1007
1015
  const dir = args.orderBy?.[k];
1008
1016
  const desc = (0, filters_js_1.isOrderBySpec)(dir) ? dir.sort === 'desc' : dir === 'desc';
@@ -1010,33 +1018,38 @@ class QueryInterface {
1010
1018
  freshParams.push(v);
1011
1019
  return `${qt}.${col} ${op} ${this.p(freshParams.length)}`;
1012
1020
  });
1013
- if (freshWhereSql) {
1014
- sql += ` AND ${cursorConditions.join(' AND ')}`;
1015
- }
1016
- else {
1017
- sql += ` WHERE ${cursorConditions.join(' AND ')}`;
1018
- }
1021
+ tail += freshWhereSql ? ` AND ${cursorConditions.join(' AND ')}` : ` WHERE ${cursorConditions.join(' AND ')}`;
1019
1022
  }
1020
1023
  }
1021
- if (args?.orderBy) {
1022
- if (distinctPrefix) {
1023
- // Postgres requires DISTINCT ON expressions to lead the ORDER BY.
1024
- // Prisma semantics ("first row per combination, result in the user's
1025
- // order") need two levels: inner DISTINCT ON ordered by the distinct
1026
- // columns then the user's order (picks the right representative row),
1027
- // outer re-ordered by the user's order alone.
1028
- if (Object.values(args.orderBy).some((d) => (0, filters_js_1.isVectorOrderBy)(d))) {
1029
- throw new errors_js_1.ValidationError('[turbine] `distinct` cannot be combined with vector distance ordering.');
1030
- }
1031
- const userOrder = this.buildOrderBy(args.orderBy, freshParams);
1032
- sql += ` ORDER BY ${distinctCols.map((c) => `${c} ASC`).join(', ')}, ${userOrder}`;
1033
- sql = `SELECT * FROM (${sql}) AS ${this.q(`${this.table}_distinct`)} ORDER BY ${userOrder}`;
1034
- }
1035
- else {
1036
- // Pass freshParams so vector KNN ordering binds its `$n::vector` query
1037
- // vector at the correct position (after cursor params, before LIMIT).
1038
- sql += ` ORDER BY ${this.buildOrderBy(args.orderBy, freshParams)}`;
1024
+ // ORDER BY is built AFTER the cursor pushes (param order
1025
+ // where → with → cursor → orderBy → limit → offset) and BEFORE final
1026
+ // assembly (so the lateral sink is filled before the FROM clause is
1027
+ // written). distinct + relation orderBy is refused up front, so a lateral
1028
+ // pick can never reach the distinct branch (lateralJoins stays empty).
1029
+ let sql;
1030
+ if (args?.orderBy && distinctPrefix) {
1031
+ // Postgres requires DISTINCT ON expressions to lead the ORDER BY. Prisma
1032
+ // semantics ("first row per combination, result in the user's order")
1033
+ // need two levels: inner DISTINCT ON ordered by the distinct columns then
1034
+ // the user's order (picks the right representative row), outer re-ordered
1035
+ // by the user's order alone.
1036
+ if (Object.values(args.orderBy).some((d) => (0, filters_js_1.isVectorOrderBy)(d))) {
1037
+ throw new errors_js_1.ValidationError('[turbine] `distinct` cannot be combined with vector distance ordering.');
1039
1038
  }
1039
+ const userOrder = this.buildOrderBy(args.orderBy, freshParams);
1040
+ const inner = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${tail} ORDER BY ${distinctCols
1041
+ .map((c) => `${c} ASC`)
1042
+ .join(', ')}, ${userOrder}`;
1043
+ sql = `SELECT * FROM (${inner}) AS ${this.q(`${this.table}_distinct`)} ORDER BY ${userOrder}`;
1044
+ }
1045
+ else {
1046
+ // Pass freshParams so vector KNN ordering binds its `$n::vector` query
1047
+ // vector at the correct position (after cursor params, before LIMIT), and
1048
+ // lateralJoins so a lateral pick splices its join into the FROM clause.
1049
+ const orderBySql = args?.orderBy
1050
+ ? ` ORDER BY ${this.buildOrderBy(args.orderBy, freshParams, lateralJoins)}`
1051
+ : '';
1052
+ sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${lateralJoins.join('')}${tail}${orderBySql}`;
1040
1053
  }
1041
1054
  // Pagination — push params in the same order the collect path mirrors
1042
1055
  // (limit before offset); the SQL TEXT shape is dialect-owned via
@@ -4354,7 +4367,13 @@ class QueryInterface {
4354
4367
  const pickWhere = d.pick?.where
4355
4368
  ? `;pw=${this.fingerprintAliasWhere(d.pick.where, targetTable)}`
4356
4369
  : '';
4357
- return `pick(${by},${d.direction ?? 'asc'},${d.nulls ?? ''};po=${pickOrder}${pickWhere})`;
4370
+ // Plan discriminator: the lateral plan emits DIFFERENT SQL (a FROM-clause
4371
+ // join + a qualified order term) so a warm cache must never serve one
4372
+ // plan's SQL for the other. Emitted ONLY for `'lateral'`: absent means
4373
+ // the default subquery plan, keeping every pre-existing cache key
4374
+ // byte-identical (no cold-cache churn on upgrade).
4375
+ const planTag = d.plan === 'lateral' ? ';plan=lat' : '';
4376
+ return `pick(${by},${d.direction ?? 'asc'},${d.nulls ?? ''};po=${pickOrder}${pickWhere}${planTag})`;
4358
4377
  }
4359
4378
  if ((0, filters_js_1.isOrderBySpec)(d))
4360
4379
  return `spec(${d.sort},${d.nulls ?? ''})`;
@@ -4371,7 +4390,7 @@ class QueryInterface {
4371
4390
  }
4372
4391
  return String(d);
4373
4392
  }
4374
- buildOrderBy(orderBy, params) {
4393
+ buildOrderBy(orderBy, params, lateralSink) {
4375
4394
  // Dev-only: validate that orderBy fields exist in the table schema. Relation
4376
4395
  // orderBy keys (object values that are neither a vector nor an OrderBySpec)
4377
4396
  // are validated in the relation branch below, so skip them here.
@@ -4414,7 +4433,7 @@ class QueryInterface {
4414
4433
  // keyed by a relation name (`{ posts: { _count: 'desc' } }` / `{ author:
4415
4434
  // { name: 'asc' } }`).
4416
4435
  if (this.isRelationOrderByValue(value)) {
4417
- return this.buildRelationOrderBy(key, value, `ord${relOrdCounter++}`, params);
4436
+ return this.buildRelationOrderBy(key, value, `ord${relOrdCounter++}`, params, undefined, lateralSink);
4418
4437
  }
4419
4438
  // Scalar column ordering — a plain direction or an OrderBySpec (nulls).
4420
4439
  if (meta && !(key in meta.columnMap)) {
@@ -4523,7 +4542,7 @@ class QueryInterface {
4523
4542
  * subquery's orderBy the relations live on the TARGET table's metadata and
4524
4543
  * the correlation parent is the relation's alias, not `this.table`.
4525
4544
  */
4526
- buildRelationOrderBy(relName, value, alias, params, ctx) {
4545
+ buildRelationOrderBy(relName, value, alias, params, ctx, lateralSink) {
4527
4546
  const ownerMeta = ctx?.meta ?? this.tableMeta;
4528
4547
  const ownerTable = ctx?.table ?? this.table;
4529
4548
  const parentRef = ctx?.parentRef ?? this.table;
@@ -4539,7 +4558,7 @@ class QueryInterface {
4539
4558
  // scope errors, shared with the cache-hit collect mirror.
4540
4559
  if ((0, filters_js_1.isRelationPickOrderBy)(value)) {
4541
4560
  this.validatePickOrderBy(relName, relDef, value, ctx !== undefined);
4542
- return this.buildRelationPickOrderBy(relName, relDef, value, alias, parentRef, params);
4561
+ return this.buildRelationPickOrderBy(relName, relDef, value, alias, parentRef, params, lateralSink);
4543
4562
  }
4544
4563
  // To-many: only `_count` is meaningful → correlated COUNT(*) subquery.
4545
4564
  if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
@@ -4631,6 +4650,25 @@ class QueryInterface {
4631
4650
  throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering on relation "${relName}" requires \`by\`: a target column name ` +
4632
4651
  "or a JSON-path spec ({ field: 'data', path: ['title'] }).");
4633
4652
  }
4653
+ // Physical plan gate. A typo like `plan: 'latreal'` must never silently run
4654
+ // the subquery plan (a silent plan change wearing a validation gap). Shared
4655
+ // by build and cache-hit collect so a warmed cache throws identically.
4656
+ if (spec.plan !== undefined && spec.plan !== 'subquery' && spec.plan !== 'lateral') {
4657
+ throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering on relation "${relName}" has an invalid \`plan\`: ` +
4658
+ `${JSON.stringify(spec.plan)}. Use 'subquery' (default) or 'lateral'.`);
4659
+ }
4660
+ if (spec.plan === 'lateral') {
4661
+ if (!this.dialect.supportsLateralJoin) {
4662
+ throw new errors_js_1.UnsupportedFeatureError("pick-row ordering with plan: 'lateral'", this.dialect.name, "LATERAL joins are only available on PostgreSQL. Omit `plan` (or use 'subquery').");
4663
+ }
4664
+ // The lateral exposes one reserved output column, `__turbine_pick`. A
4665
+ // parent column with that exact name would make the unqualified WHERE
4666
+ // reference ambiguous once the join is in scope; refuse it explicitly.
4667
+ if (this.tableMeta.allColumns.includes('__turbine_pick')) {
4668
+ throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering with plan: 'lateral' cannot be used: table "${this.tableMeta.name}" ` +
4669
+ 'has a column named "__turbine_pick", which the lateral join output reserves.');
4670
+ }
4671
+ }
4634
4672
  }
4635
4673
  /**
4636
4674
  * Compile a {@link RelationPickOrderBy} term: a correlated scalar subquery
@@ -4649,36 +4687,83 @@ class QueryInterface {
4649
4687
  * {@link collectRelationPickOrderParams}): `by` JSON path (if any) →
4650
4688
  * target global filter → `pick.where` → `pick.orderBy` JSON paths.
4651
4689
  */
4652
- buildRelationPickOrderBy(relName, relDef, spec, alias, parentRef, params) {
4690
+ buildRelationPickOrderBy(relName, relDef, spec, alias, parentRef, params, lateralSink) {
4653
4691
  if (!params) {
4654
4692
  throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering on relation "${relName}" is only supported in a top-level findMany orderBy.`);
4655
4693
  }
4656
4694
  const targetMeta = this.schema.tables[relDef.to];
4657
4695
  if (!targetMeta)
4658
4696
  throw new errors_js_1.RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
4697
+ const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
4698
+ const limitOne = this.buildPagination('1', undefined, true);
4699
+ // Parents with ZERO surviving related rows have no row to pick: the
4700
+ // correlated subquery yields NULL, and the LEFT JOIN LATERAL null-extends
4701
+ // its single row identically. Without a nulls clause, Postgres DESC
4702
+ // defaults to NULLS FIRST (every childless parent tops a "highest first"
4703
+ // sort). Default to NULLS LAST in BOTH directions (deterministic across
4704
+ // engines: SQLite's NULL-is-smallest default diverges from Postgres) unless
4705
+ // the caller set `nulls` explicitly; the grammar gate matches nullsSuffix.
4706
+ const nullsSql = spec.nulls
4707
+ ? this.nullsSuffix(spec.nulls)
4708
+ : this.dialect.name === 'postgresql' || this.dialect.name === 'sqlite'
4709
+ ? ' NULLS LAST'
4710
+ : '';
4711
+ // Lateral plan: splice a `LEFT JOIN LATERAL (... LIMIT 1) ON true` into the
4712
+ // FROM clause (via the sink) and order by its single reserved output column.
4713
+ // Param push order is IDENTICAL to the subquery plan (compilePickPieces is
4714
+ // shared), so the cache-hit collect mirror needs no changes. Scope +
4715
+ // capability were already enforced by validatePickOrderBy (shared with the
4716
+ // collect path); the missing-sink guard catches a non-findMany build
4717
+ // context and hard-fails rather than silently emitting a subquery.
4718
+ if (spec.plan === 'lateral') {
4719
+ if (!lateralSink) {
4720
+ throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering with plan: 'lateral' on relation "${relName}" is only supported ` +
4721
+ 'in a top-level findMany orderBy.');
4722
+ }
4723
+ const childAlias = `${alias}i`;
4724
+ const { byExpr, where, orderClause } = this.compilePickPieces(relDef, targetMeta, spec, childAlias, parentRef, params);
4725
+ lateralSink.push(` LEFT JOIN LATERAL (SELECT ${byExpr} AS ${this.q('__turbine_pick')} FROM ${this.q(relDef.to)} ${childAlias}` +
4726
+ ` WHERE ${where}${orderClause}${limitOne}) ${alias} ON true`);
4727
+ return `${alias}.${this.q('__turbine_pick')} ${dir}${nullsSql}`;
4728
+ }
4729
+ // Subquery plan (default): a correlated scalar subquery in ORDER BY.
4730
+ const { byExpr, where, orderClause } = this.compilePickPieces(relDef, targetMeta, spec, alias, parentRef, params);
4731
+ return `(SELECT ${byExpr} FROM ${this.q(relDef.to)} ${alias} WHERE ${where}${orderClause}${limitOne}) ${dir}${nullsSql}`;
4732
+ }
4733
+ /**
4734
+ * Compile the shared inner pieces of a pick-row ordering against `childAlias`
4735
+ * (the table alias the related row is read from): the `by` value expression,
4736
+ * the correlation + target global filter + `pick.where` predicate, and the
4737
+ * `pick.orderBy` clause. Factored out of {@link buildRelationPickOrderBy} so
4738
+ * the subquery and lateral plans build IDENTICAL pieces in the SAME param
4739
+ * push order (`by` JSON path → target global filter → `pick.where` →
4740
+ * `pick.orderBy` JSON paths), which is why the collect mirror
4741
+ * ({@link collectRelationPickOrderParams}) is plan-agnostic.
4742
+ */
4743
+ compilePickPieces(relDef, targetMeta, spec, childAlias, parentRef, params) {
4659
4744
  // The value surfaced from the picked row (SELECT list: its param binds first).
4660
4745
  let byExpr;
4661
4746
  if (typeof spec.by === 'string') {
4662
4747
  const col = this.resolveOrderByColumn(relDef.to, targetMeta, spec.by);
4663
- byExpr = `${alias}.${this.q(col)}`;
4748
+ byExpr = `${childAlias}.${this.q(col)}`;
4664
4749
  }
4665
4750
  else {
4666
4751
  const col = this.validateJsonPathOrderBy(relDef.to, targetMeta, spec.by.field, {
4667
4752
  path: spec.by.path,
4668
4753
  });
4669
4754
  params.push(this.jsonPathParam(spec.by.path));
4670
- const extract = this.dialect.buildJsonPathExtract(`${alias}.${this.q(col)}`, this.p(params.length));
4755
+ const extract = this.dialect.buildJsonPathExtract(`${childAlias}.${this.q(col)}`, this.p(params.length));
4671
4756
  byExpr = spec.by.type === 'numeric' ? this.castJsonNumeric(extract) : extract;
4672
4757
  }
4673
4758
  // Correlation to the parent row, then the target's global filter (a
4674
4759
  // soft-deleted / other-tenant row must never be picked: matches the
4675
4760
  // `with` subquery and to-one relation-orderBy semantics), then pick.where.
4676
- let where = this.dialect.buildCorrelation(alias, relDef.foreignKey, this.q(parentRef), relDef.referenceKey);
4677
- const gf = this.targetGlobalFilterAlias(relDef.to, alias, params);
4761
+ let where = this.dialect.buildCorrelation(childAlias, relDef.foreignKey, this.q(parentRef), relDef.referenceKey);
4762
+ const gf = this.targetGlobalFilterAlias(relDef.to, childAlias, params);
4678
4763
  if (gf)
4679
4764
  where += ` AND ${gf}`;
4680
4765
  if (spec.pick.where) {
4681
- const pickWhere = this.buildAliasWhere(relDef.to, targetMeta, alias, spec.pick.where, params);
4766
+ const pickWhere = this.buildAliasWhere(relDef.to, targetMeta, childAlias, spec.pick.where, params);
4682
4767
  if (pickWhere)
4683
4768
  where += ` AND ${pickWhere}`;
4684
4769
  }
@@ -4686,21 +4771,8 @@ class QueryInterface {
4686
4771
  // (plain columns, OrderBySpec nulls, JSON-path specs); a nested pick in
4687
4772
  // here routes back through buildRelationOrderBy with ctx set and throws
4688
4773
  // the top-level-only E003.
4689
- const orderClause = this.buildRelationOrderClause(relDef.to, targetMeta, alias, Object.entries(spec.pick.orderBy), params);
4690
- const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
4691
- const limitOne = this.buildPagination('1', undefined, true);
4692
- // Parents with ZERO surviving related rows make the correlated subquery
4693
- // yield NULL. Without a nulls clause, Postgres DESC defaults to NULLS
4694
- // FIRST — every childless parent would top a "highest first" sort. Default
4695
- // to NULLS LAST in BOTH directions (deterministic across engines: SQLite's
4696
- // NULL-is-smallest default diverges from Postgres) unless the caller set
4697
- // `nulls` explicitly; the grammar gate matches nullsSuffix (PG + SQLite).
4698
- const nullsSql = spec.nulls
4699
- ? this.nullsSuffix(spec.nulls)
4700
- : this.dialect.name === 'postgresql' || this.dialect.name === 'sqlite'
4701
- ? ' NULLS LAST'
4702
- : '';
4703
- return `(SELECT ${byExpr} FROM ${this.q(relDef.to)} ${alias} WHERE ${where}${orderClause}${limitOne}) ${dir}${nullsSql}`;
4774
+ const orderClause = this.buildRelationOrderClause(relDef.to, targetMeta, childAlias, Object.entries(spec.pick.orderBy), params);
4775
+ return { byExpr, where, orderClause };
4704
4776
  }
4705
4777
  /**
4706
4778
  * Param-collect mirror of {@link buildRelationPickOrderBy}: re-runs the same
@@ -319,9 +319,9 @@ function isJsonPathOrderBy(value) {
319
319
  }
320
320
  /**
321
321
  * Check if an orderBy value is a pick-row relation ordering:
322
- * `{ pick: { orderBy, ... }, by, direction?, nulls? }`. The full shape is
323
- * required `pick` must be an object carrying `orderBy`, `by` must be
324
- * present, and no keys outside `{ pick, by, direction, nulls }` so a to-one
322
+ * `{ pick: { orderBy, ... }, by, direction?, nulls?, plan? }`. The full shape
323
+ * is required (`pick` must be an object carrying `orderBy`, `by` must be
324
+ * present, and no keys outside `{ pick, by, direction, nulls, plan }`), so a to-one
325
325
  * relation whose target has real columns literally named `pick` and `by`
326
326
  * (whose values are direction strings or `{ sort, nulls }` specs, never an
327
327
  * object with `orderBy`) still falls through to column ordering. `distance`
@@ -339,7 +339,7 @@ function isRelationPickOrderBy(value) {
339
339
  if (typeof v.pick !== 'object' || v.pick === null || Array.isArray(v.pick) || !('orderBy' in v.pick))
340
340
  return false;
341
341
  for (const key of Object.keys(v)) {
342
- if (key !== 'pick' && key !== 'by' && key !== 'direction' && key !== 'nulls')
342
+ if (key !== 'pick' && key !== 'by' && key !== 'direction' && key !== 'nulls' && key !== 'plan')
343
343
  return false;
344
344
  }
345
345
  return true;
@@ -378,6 +378,8 @@ exports.sqliteDialect = {
378
378
  supportsListenNotify: false,
379
379
  supportsRLS: false,
380
380
  supportsAdvisoryLock: false,
381
+ // No FROM-clause LATERAL: the opt-in lateral pick plan is Postgres-only.
382
+ supportsLateralJoin: false,
381
383
  // json_group_array / json_object have no inline ORDER BY argument, so every
382
384
  // ordered to-many relation is forced through the inner-subquery rewrite.
383
385
  aggSupportsInlineOrderBy: false,
package/dist/dialect.d.ts CHANGED
@@ -295,6 +295,13 @@ export interface Dialect {
295
295
  readonly supportsRLS: boolean;
296
296
  /** Whether this dialect/engine supports advisory-lock-style migration locking. */
297
297
  readonly supportsAdvisoryLock: boolean;
298
+ /**
299
+ * Whether this dialect supports `LEFT JOIN LATERAL (...) ON true` in the FROM
300
+ * clause. Gates the opt-in `plan: 'lateral'` pick-row ordering. Optional:
301
+ * absent is treated as `false`, so only dialects that set it true admit the
302
+ * lateral plan (else E017). PostgreSQL only in this release.
303
+ */
304
+ readonly supportsLateralJoin?: boolean;
298
305
  /** Build a dialect-specific RETURNING clause. Return an empty string when unsupported. */
299
306
  buildReturningClause(selection?: string): string;
300
307
  /** Build a single-row INSERT statement. Inputs are SQL-ready quoted fragments. */
package/dist/dialect.js CHANGED
@@ -22,6 +22,7 @@ export const postgresDialect = {
22
22
  supportsListenNotify: true,
23
23
  supportsRLS: true,
24
24
  supportsAdvisoryLock: true,
25
+ supportsLateralJoin: true,
25
26
  paramPlaceholder(index) {
26
27
  return `$${index}`;
27
28
  },
package/dist/mssql.js CHANGED
@@ -467,6 +467,9 @@ export const mssqlDialect = {
467
467
  supportsVector: false,
468
468
  supportsListenNotify: false,
469
469
  supportsRLS: false,
470
+ // SQL Server has OUTER APPLY, not FROM-clause LATERAL: the lateral pick plan
471
+ // is Postgres-only (out of scope here).
472
+ supportsLateralJoin: false,
470
473
  // sp_getapplock / sp_releaseapplock exist (used by a future migrate adapter).
471
474
  supportsAdvisoryLock: true,
472
475
  // FOR JSON over zero rows is NULL → coalesced in the relation override.
package/dist/mysql.js CHANGED
@@ -368,6 +368,9 @@ export const mysqlDialect = {
368
368
  supportsVector: false,
369
369
  supportsListenNotify: false,
370
370
  supportsRLS: false,
371
+ // MySQL 8.0.14+ supports LATERAL, but the opt-in lateral pick plan stays
372
+ // Postgres-only in this release (flipping it on is a one-line change + tests).
373
+ supportsLateralJoin: false,
371
374
  // GET_LOCK / RELEASE_LOCK exist (used by a future migrate adapter).
372
375
  supportsAdvisoryLock: true,
373
376
  // JSON_ARRAYAGG has no inline ORDER BY argument → force the inner-subquery
package/dist/powdb.js CHANGED
@@ -92,6 +92,9 @@ export const powdbDialect = {
92
92
  supportsRLS: false,
93
93
  supportsAdvisoryLock: false,
94
94
  supportsILike: false,
95
+ // PowQL has no LATERAL construct; PowqlInterface refuses pick ordering
96
+ // earlier, this override keeps the flag truthful if a future path consults it.
97
+ supportsLateralJoin: false,
95
98
  beginStatement: () => 'begin',
96
99
  commitStatement: () => 'commit',
97
100
  rollbackStatement: () => 'rollback',
@@ -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
@@ -959,14 +959,22 @@ export class QueryInterface {
959
959
  else {
960
960
  selectClause = `${qt}.*`;
961
961
  }
962
- let sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${freshWhereSql}`;
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 normalize
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
- if (freshWhereSql) {
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
- if (args?.orderBy) {
986
- if (distinctPrefix) {
987
- // Postgres requires DISTINCT ON expressions to lead the ORDER BY.
988
- // Prisma semantics ("first row per combination, result in the user's
989
- // order") need two levels: inner DISTINCT ON ordered by the distinct
990
- // columns then the user's order (picks the right representative row),
991
- // outer re-ordered by the user's order alone.
992
- if (Object.values(args.orderBy).some((d) => isVectorOrderBy(d))) {
993
- throw new ValidationError('[turbine] `distinct` cannot be combined with vector distance ordering.');
994
- }
995
- const userOrder = this.buildOrderBy(args.orderBy, freshParams);
996
- sql += ` ORDER BY ${distinctCols.map((c) => `${c} ASC`).join(', ')}, ${userOrder}`;
997
- sql = `SELECT * FROM (${sql}) AS ${this.q(`${this.table}_distinct`)} ORDER BY ${userOrder}`;
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
- return `pick(${by},${d.direction ?? 'asc'},${d.nulls ?? ''};po=${pickOrder}${pickWhere})`;
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)) {
@@ -4487,7 +4506,7 @@ export class QueryInterface {
4487
4506
  * subquery's orderBy the relations live on the TARGET table's metadata and
4488
4507
  * the correlation parent is the relation's alias, not `this.table`.
4489
4508
  */
4490
- buildRelationOrderBy(relName, value, alias, params, ctx) {
4509
+ buildRelationOrderBy(relName, value, alias, params, ctx, lateralSink) {
4491
4510
  const ownerMeta = ctx?.meta ?? this.tableMeta;
4492
4511
  const ownerTable = ctx?.table ?? this.table;
4493
4512
  const parentRef = ctx?.parentRef ?? this.table;
@@ -4503,7 +4522,7 @@ export class QueryInterface {
4503
4522
  // scope errors, shared with the cache-hit collect mirror.
4504
4523
  if (isRelationPickOrderBy(value)) {
4505
4524
  this.validatePickOrderBy(relName, relDef, value, ctx !== undefined);
4506
- return this.buildRelationPickOrderBy(relName, relDef, value, alias, parentRef, params);
4525
+ return this.buildRelationPickOrderBy(relName, relDef, value, alias, parentRef, params, lateralSink);
4507
4526
  }
4508
4527
  // To-many: only `_count` is meaningful → correlated COUNT(*) subquery.
4509
4528
  if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
@@ -4595,6 +4614,25 @@ export class QueryInterface {
4595
4614
  throw new ValidationError(`[turbine] Pick-row ordering on relation "${relName}" requires \`by\`: a target column name ` +
4596
4615
  "or a JSON-path spec ({ field: 'data', path: ['title'] }).");
4597
4616
  }
4617
+ // Physical plan gate. A typo like `plan: 'latreal'` must never silently run
4618
+ // the subquery plan (a silent plan change wearing a validation gap). Shared
4619
+ // by build and cache-hit collect so a warmed cache throws identically.
4620
+ if (spec.plan !== undefined && spec.plan !== 'subquery' && spec.plan !== 'lateral') {
4621
+ throw new ValidationError(`[turbine] Pick-row ordering on relation "${relName}" has an invalid \`plan\`: ` +
4622
+ `${JSON.stringify(spec.plan)}. Use 'subquery' (default) or 'lateral'.`);
4623
+ }
4624
+ if (spec.plan === 'lateral') {
4625
+ if (!this.dialect.supportsLateralJoin) {
4626
+ throw new UnsupportedFeatureError("pick-row ordering with plan: 'lateral'", this.dialect.name, "LATERAL joins are only available on PostgreSQL. Omit `plan` (or use 'subquery').");
4627
+ }
4628
+ // The lateral exposes one reserved output column, `__turbine_pick`. A
4629
+ // parent column with that exact name would make the unqualified WHERE
4630
+ // reference ambiguous once the join is in scope; refuse it explicitly.
4631
+ if (this.tableMeta.allColumns.includes('__turbine_pick')) {
4632
+ throw new ValidationError(`[turbine] Pick-row ordering with plan: 'lateral' cannot be used: table "${this.tableMeta.name}" ` +
4633
+ 'has a column named "__turbine_pick", which the lateral join output reserves.');
4634
+ }
4635
+ }
4598
4636
  }
4599
4637
  /**
4600
4638
  * Compile a {@link RelationPickOrderBy} term: a correlated scalar subquery
@@ -4613,36 +4651,83 @@ export class QueryInterface {
4613
4651
  * {@link collectRelationPickOrderParams}): `by` JSON path (if any) →
4614
4652
  * target global filter → `pick.where` → `pick.orderBy` JSON paths.
4615
4653
  */
4616
- buildRelationPickOrderBy(relName, relDef, spec, alias, parentRef, params) {
4654
+ buildRelationPickOrderBy(relName, relDef, spec, alias, parentRef, params, lateralSink) {
4617
4655
  if (!params) {
4618
4656
  throw new ValidationError(`[turbine] Pick-row ordering on relation "${relName}" is only supported in a top-level findMany orderBy.`);
4619
4657
  }
4620
4658
  const targetMeta = this.schema.tables[relDef.to];
4621
4659
  if (!targetMeta)
4622
4660
  throw new RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
4661
+ const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
4662
+ const limitOne = this.buildPagination('1', undefined, true);
4663
+ // Parents with ZERO surviving related rows have no row to pick: the
4664
+ // correlated subquery yields NULL, and the LEFT JOIN LATERAL null-extends
4665
+ // its single row identically. Without a nulls clause, Postgres DESC
4666
+ // defaults to NULLS FIRST (every childless parent tops a "highest first"
4667
+ // sort). Default to NULLS LAST in BOTH directions (deterministic across
4668
+ // engines: SQLite's NULL-is-smallest default diverges from Postgres) unless
4669
+ // the caller set `nulls` explicitly; the grammar gate matches nullsSuffix.
4670
+ const nullsSql = spec.nulls
4671
+ ? this.nullsSuffix(spec.nulls)
4672
+ : this.dialect.name === 'postgresql' || this.dialect.name === 'sqlite'
4673
+ ? ' NULLS LAST'
4674
+ : '';
4675
+ // Lateral plan: splice a `LEFT JOIN LATERAL (... LIMIT 1) ON true` into the
4676
+ // FROM clause (via the sink) and order by its single reserved output column.
4677
+ // Param push order is IDENTICAL to the subquery plan (compilePickPieces is
4678
+ // shared), so the cache-hit collect mirror needs no changes. Scope +
4679
+ // capability were already enforced by validatePickOrderBy (shared with the
4680
+ // collect path); the missing-sink guard catches a non-findMany build
4681
+ // context and hard-fails rather than silently emitting a subquery.
4682
+ if (spec.plan === 'lateral') {
4683
+ if (!lateralSink) {
4684
+ throw new ValidationError(`[turbine] Pick-row ordering with plan: 'lateral' on relation "${relName}" is only supported ` +
4685
+ 'in a top-level findMany orderBy.');
4686
+ }
4687
+ const childAlias = `${alias}i`;
4688
+ const { byExpr, where, orderClause } = this.compilePickPieces(relDef, targetMeta, spec, childAlias, parentRef, params);
4689
+ lateralSink.push(` LEFT JOIN LATERAL (SELECT ${byExpr} AS ${this.q('__turbine_pick')} FROM ${this.q(relDef.to)} ${childAlias}` +
4690
+ ` WHERE ${where}${orderClause}${limitOne}) ${alias} ON true`);
4691
+ return `${alias}.${this.q('__turbine_pick')} ${dir}${nullsSql}`;
4692
+ }
4693
+ // Subquery plan (default): a correlated scalar subquery in ORDER BY.
4694
+ const { byExpr, where, orderClause } = this.compilePickPieces(relDef, targetMeta, spec, alias, parentRef, params);
4695
+ return `(SELECT ${byExpr} FROM ${this.q(relDef.to)} ${alias} WHERE ${where}${orderClause}${limitOne}) ${dir}${nullsSql}`;
4696
+ }
4697
+ /**
4698
+ * Compile the shared inner pieces of a pick-row ordering against `childAlias`
4699
+ * (the table alias the related row is read from): the `by` value expression,
4700
+ * the correlation + target global filter + `pick.where` predicate, and the
4701
+ * `pick.orderBy` clause. Factored out of {@link buildRelationPickOrderBy} so
4702
+ * the subquery and lateral plans build IDENTICAL pieces in the SAME param
4703
+ * push order (`by` JSON path → target global filter → `pick.where` →
4704
+ * `pick.orderBy` JSON paths), which is why the collect mirror
4705
+ * ({@link collectRelationPickOrderParams}) is plan-agnostic.
4706
+ */
4707
+ compilePickPieces(relDef, targetMeta, spec, childAlias, parentRef, params) {
4623
4708
  // The value surfaced from the picked row (SELECT list: its param binds first).
4624
4709
  let byExpr;
4625
4710
  if (typeof spec.by === 'string') {
4626
4711
  const col = this.resolveOrderByColumn(relDef.to, targetMeta, spec.by);
4627
- byExpr = `${alias}.${this.q(col)}`;
4712
+ byExpr = `${childAlias}.${this.q(col)}`;
4628
4713
  }
4629
4714
  else {
4630
4715
  const col = this.validateJsonPathOrderBy(relDef.to, targetMeta, spec.by.field, {
4631
4716
  path: spec.by.path,
4632
4717
  });
4633
4718
  params.push(this.jsonPathParam(spec.by.path));
4634
- const extract = this.dialect.buildJsonPathExtract(`${alias}.${this.q(col)}`, this.p(params.length));
4719
+ const extract = this.dialect.buildJsonPathExtract(`${childAlias}.${this.q(col)}`, this.p(params.length));
4635
4720
  byExpr = spec.by.type === 'numeric' ? this.castJsonNumeric(extract) : extract;
4636
4721
  }
4637
4722
  // Correlation to the parent row, then the target's global filter (a
4638
4723
  // soft-deleted / other-tenant row must never be picked: matches the
4639
4724
  // `with` subquery and to-one relation-orderBy semantics), then pick.where.
4640
- let where = this.dialect.buildCorrelation(alias, relDef.foreignKey, this.q(parentRef), relDef.referenceKey);
4641
- const gf = this.targetGlobalFilterAlias(relDef.to, alias, params);
4725
+ let where = this.dialect.buildCorrelation(childAlias, relDef.foreignKey, this.q(parentRef), relDef.referenceKey);
4726
+ const gf = this.targetGlobalFilterAlias(relDef.to, childAlias, params);
4642
4727
  if (gf)
4643
4728
  where += ` AND ${gf}`;
4644
4729
  if (spec.pick.where) {
4645
- const pickWhere = this.buildAliasWhere(relDef.to, targetMeta, alias, spec.pick.where, params);
4730
+ const pickWhere = this.buildAliasWhere(relDef.to, targetMeta, childAlias, spec.pick.where, params);
4646
4731
  if (pickWhere)
4647
4732
  where += ` AND ${pickWhere}`;
4648
4733
  }
@@ -4650,21 +4735,8 @@ export class QueryInterface {
4650
4735
  // (plain columns, OrderBySpec nulls, JSON-path specs); a nested pick in
4651
4736
  // here routes back through buildRelationOrderBy with ctx set and throws
4652
4737
  // the top-level-only E003.
4653
- const orderClause = this.buildRelationOrderClause(relDef.to, targetMeta, alias, Object.entries(spec.pick.orderBy), params);
4654
- const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
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}`;
4738
+ const orderClause = this.buildRelationOrderClause(relDef.to, targetMeta, childAlias, Object.entries(spec.pick.orderBy), params);
4739
+ return { byExpr, where, orderClause };
4668
4740
  }
4669
4741
  /**
4670
4742
  * Param-collect mirror of {@link buildRelationPickOrderBy}: re-runs the same
@@ -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 is
160
- * required `pick` must be an object carrying `orderBy`, `by` must be
161
- * present, and no keys outside `{ pick, by, direction, nulls }` so a to-one
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`
@@ -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 is
300
- * required `pick` must be an object carrying `orderBy`, `by` must be
301
- * present, and no keys outside `{ pick, by, direction, nulls }` so a to-one
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;
@@ -979,6 +979,17 @@ export interface RelationPickOrderBy {
979
979
  * "highest first" sort). Set `nulls` explicitly to override.
980
980
  */
981
981
  nulls?: 'first' | 'last';
982
+ /**
983
+ * Physical plan for the pick. `'subquery'` (default) compiles a correlated
984
+ * scalar subquery in ORDER BY. `'lateral'` (PostgreSQL only, E017 elsewhere)
985
+ * compiles a `LEFT JOIN LATERAL (... LIMIT 1) ON true` and orders by the
986
+ * joined value. Identical results; the lateral form can be significantly
987
+ * faster on large parent sets where the ordering subquery dominates the plan.
988
+ * Never falls back silently: contexts that cannot take a lateral (non-Postgres
989
+ * engines, `distinct`, nested `with` orderBy, a parent column literally named
990
+ * `__turbine_pick`) throw.
991
+ */
992
+ plan?: 'subquery' | 'lateral';
982
993
  }
983
994
  /**
984
995
  * An orderBy clause maps each key to one of:
package/dist/sqlite.js CHANGED
@@ -370,6 +370,8 @@ export const sqliteDialect = {
370
370
  supportsListenNotify: false,
371
371
  supportsRLS: false,
372
372
  supportsAdvisoryLock: false,
373
+ // No FROM-clause LATERAL: the opt-in lateral pick plan is Postgres-only.
374
+ supportsLateralJoin: false,
373
375
  // json_group_array / json_object have no inline ORDER BY argument, so every
374
376
  // ordered to-many relation is forced through the inner-subquery rewrite.
375
377
  aggSupportsInlineOrderBy: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.32.2",
3
+ "version": "0.33.0",
4
4
  "description": "Postgres-native TypeScript ORM — runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. One dependency, no WASM engine",
5
5
  "type": "module",
6
6
  "exports": {