turbine-orm 0.31.0 → 0.32.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.
@@ -26,6 +26,7 @@ exports.isVectorFilter = isVectorFilter;
26
26
  exports.isVectorOrderBy = isVectorOrderBy;
27
27
  exports.isOrderBySpec = isOrderBySpec;
28
28
  exports.isJsonPathOrderBy = isJsonPathOrderBy;
29
+ exports.isRelationPickOrderBy = isRelationPickOrderBy;
29
30
  exports.normalizeOrderBy = normalizeOrderBy;
30
31
  const errors_js_1 = require("../errors.js");
31
32
  const utils_js_1 = require("./utils.js");
@@ -316,6 +317,33 @@ function isJsonPathOrderBy(value) {
316
317
  return false;
317
318
  return Array.isArray(value.path);
318
319
  }
320
+ /**
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
325
+ * relation whose target has real columns literally named `pick` and `by`
326
+ * (whose values are direction strings or `{ sort, nulls }` specs, never an
327
+ * object with `orderBy`) still falls through to column ordering. `distance`
328
+ * (vector), `sort` (OrderBySpec), and a top-level array `path` (JSON-path
329
+ * ordering) are excluded up front.
330
+ */
331
+ function isRelationPickOrderBy(value) {
332
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
333
+ return false;
334
+ if ('distance' in value || 'sort' in value || Array.isArray(value.path))
335
+ return false;
336
+ const v = value;
337
+ if (!('pick' in v) || !('by' in v))
338
+ return false;
339
+ if (typeof v.pick !== 'object' || v.pick === null || Array.isArray(v.pick) || !('orderBy' in v.pick))
340
+ return false;
341
+ for (const key of Object.keys(v)) {
342
+ if (key !== 'pick' && key !== 'by' && key !== 'direction' && key !== 'nulls')
343
+ return false;
344
+ }
345
+ return true;
346
+ }
319
347
  /**
320
348
  * Normalize an orderBy value into `{ direction, nulls }`. Accepts a plain
321
349
  * direction string or an {@link OrderBySpec}. Used by every ORDER BY compile
package/dist/index.d.ts CHANGED
@@ -43,7 +43,7 @@ export { type IntrospectOptions, introspect } from './introspect.js';
43
43
  export { executeNestedCreate, executeNestedUpdate, hasRelationFields, type NestedWriteContext, } from './nested-write.js';
44
44
  export type { ObserveConfig, ObserveHandle } from './observe.js';
45
45
  export { executePipeline, type PipelineOptions, type PipelineResults, pipelineSupported } from './pipeline.js';
46
- export { type AggregateArgs, type AggregateResult, type ArrayFilter, type ColumnRef, type ConnectOrCreateOp, type CountArgs, type CreateArgs, type CreateDataInput, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FieldResult, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GlobalFilters, type GroupByArgs, type HavingClause, type JsonFilter, type JsonPathOrderBy, type MiddlewareFn, type NestedCreateOp, type NestedUpdateOp, type NestedUpdateOpItem, type NestedUpsertOpItem, type OmitResult, type OrderByClause, type OrderDirection, type QueryEvent, type QueryEventListener, QueryInterface, type QueryResult, type RelationDescriptor, type RelationFilter, type SelectResult, type SkipGlobalFilters, type TextSearchFilter, type TypedWithClause, type UpdateArgs, type UpdateDataInput, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type VectorDistanceFilter, type VectorFilter, type VectorMetric, type VectorOrderBy, type VectorOrderByDistance, type WhereClause, type WhereOperator, type WhereValue, type WithClause, type WithOptions, type WithResult, } from './query/index.js';
46
+ export { type AggregateArgs, type AggregateResult, type ArrayFilter, type ColumnRef, type ConnectOrCreateOp, type CountArgs, type CreateArgs, type CreateDataInput, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FieldResult, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GlobalFilters, type GroupByAggregateSpec, type GroupByArgs, type GroupByDistinctOn, type HavingClause, type JsonFilter, type JsonPathAggregateTarget, type JsonPathGroupKey, type JsonPathOrderBy, type MiddlewareFn, type NestedCreateOp, type NestedUpdateOp, type NestedUpdateOpItem, type NestedUpsertOpItem, type OmitResult, type OrderByClause, type OrderDirection, type QueryEvent, type QueryEventListener, QueryInterface, type QueryResult, type RelationDescriptor, type RelationFilter, type RelationPickBy, type RelationPickOrderBy, type SelectResult, type SkipGlobalFilters, type TextSearchFilter, type TypedWithClause, type UpdateArgs, type UpdateDataInput, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type VectorDistanceFilter, type VectorFilter, type VectorMetric, type VectorOrderBy, type VectorOrderByDistance, type WhereClause, type WhereOperator, type WhereValue, type WithClause, type WithOptions, type WithResult, } from './query/index.js';
47
47
  export { type ActiveSubscription, type NotificationHandler, type Subscription, validateChannel } from './realtime.js';
48
48
  export type { CheckMetadata, ColumnMetadata, IndexMetadata, ReferentialAction, RelationDef, SchemaMetadata, TableMetadata, } from './schema.js';
49
49
  export { camelToSnake, isDateType, normalizeKeyColumns, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, } from './schema.js';
package/dist/powql.js CHANGED
@@ -38,6 +38,7 @@ import { randomUUID } from 'node:crypto';
38
38
  import { NotFoundError, TimeoutError, UnsupportedFeatureError, ValidationError } from './errors.js';
39
39
  import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from './nested-write.js';
40
40
  import { PowdbFloatParam, powqlColumnType, quotePowqlIdent, rowToEntity } from './powdb.js';
41
+ import { isRelationPickOrderBy } from './query/filters.js';
41
42
  import { escapeLike } from './query/utils.js';
42
43
  import { normalizeKeyColumns, snakeToCamel, } from './schema.js';
43
44
  /**
@@ -475,7 +476,22 @@ export class PowqlInterface {
475
476
  return '';
476
477
  const parts = keys.map(([field, dir]) => {
477
478
  if (dir && typeof dir === 'object') {
478
- throw new UnsupportedFeatureError('vector / distance ordering', 'PowDB', `field "${field}"`);
479
+ // Name the actual feature in the refusal — a pick-row ordering
480
+ // reported as "vector / distance ordering" sends users hunting for
481
+ // pgvector docs. All object-valued orderings stay E017 on PowDB.
482
+ const o = dir;
483
+ const feature = isRelationPickOrderBy(dir)
484
+ ? 'relation pick-row ordering'
485
+ : 'distance' in o
486
+ ? 'vector / distance ordering'
487
+ : Array.isArray(o.path)
488
+ ? 'JSON-path ordering'
489
+ : '_count' in o
490
+ ? 'relation _count ordering'
491
+ : 'sort' in o || 'nulls' in o
492
+ ? 'NULLS placement / sort-spec ordering'
493
+ : 'object-valued ordering';
494
+ throw new UnsupportedFeatureError(feature, 'PowDB', `field "${field}"`);
479
495
  }
480
496
  return `${this.ref(field)} ${dir === 'desc' ? 'desc' : 'asc'}`;
481
497
  });
@@ -1119,6 +1135,27 @@ export class PowqlInterface {
1119
1135
  }
1120
1136
  async groupBy(args) {
1121
1137
  return this.withMiddleware('groupBy', args, async () => {
1138
+ // The SQL-only groupBy extensions (DISTINCT ON row source, JSON-path
1139
+ // group keys / aggregate targets) have no PowQL equivalent: refuse
1140
+ // clearly instead of emitting broken PowQL.
1141
+ if (args.distinctOn) {
1142
+ throw new UnsupportedFeatureError('groupBy distinctOn row source', 'PowDB');
1143
+ }
1144
+ for (const entry of args.by) {
1145
+ if (typeof entry !== 'string') {
1146
+ throw new UnsupportedFeatureError('JSON-path groupBy keys', 'PowDB');
1147
+ }
1148
+ }
1149
+ for (const fn of ['_sum', '_avg', '_min', '_max']) {
1150
+ const spec = args[fn];
1151
+ if (!spec)
1152
+ continue;
1153
+ for (const value of Object.values(spec)) {
1154
+ if (value !== undefined && typeof value !== 'boolean') {
1155
+ throw new UnsupportedFeatureError(`JSON-path ${fn} aggregate targets`, 'PowDB');
1156
+ }
1157
+ }
1158
+ }
1122
1159
  const params = [];
1123
1160
  const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
1124
1161
  const where = this.buildWhere(resolvedWhere, params);
@@ -138,6 +138,18 @@ export declare function neededParentKeyFields(parentMeta: TableMetadata, withCla
138
138
  * ({@link ValidationError}) when a named relation is to-one.
139
139
  */
140
140
  export declare function resolveCountRelations(parentMeta: TableMetadata, countSpec: WithCount): RelationDef[];
141
+ /**
142
+ * Reject pick-row relation ordering anywhere inside a `with` tree's orderBy —
143
+ * strategy parity with the join path, which throws this exact E003 at SQL
144
+ * build time (`pickOrderNestedError` in builder.ts). Without this guard the
145
+ * loaders would forward `options.orderBy` as the child reader's TOP-LEVEL
146
+ * findMany orderBy, where the pick shape compiles fine — so the same query
147
+ * would execute on 'batched' but throw on 'join'. Walks the whole tree up
148
+ * front so acceptance never depends on which levels have rows — the batched
149
+ * runners in builder.ts call this BEFORE the base query (a zero-row base
150
+ * result must still reject, exactly like the join strategy's build-time throw).
151
+ */
152
+ export declare function rejectNestedPickOrder(withClause: WithClause): void;
141
153
  /**
142
154
  * Load every relation in `withClause` for `parents` and attach it onto each row
143
155
  * in place. Mirrors the join strategy's output shape exactly. Recurses for nested
@@ -48,6 +48,7 @@
48
48
  */
49
49
  import { CircularRelationError, RelationError, UnsupportedFeatureError, ValidationError } from '../errors.js';
50
50
  import { normalizeKeyColumns } from '../schema.js';
51
+ import { isRelationPickOrderBy } from './filters.js';
51
52
  /**
52
53
  * Max parent keys per follow-up query. On Postgres the whole key set travels as
53
54
  * ONE array parameter (`= ANY($1)`), so this is not a bind-parameter limit — it
@@ -177,6 +178,34 @@ export function resolveCountRelations(parentMeta, countSpec) {
177
178
  function keyOf(value) {
178
179
  return String(value);
179
180
  }
181
+ /**
182
+ * Reject pick-row relation ordering anywhere inside a `with` tree's orderBy —
183
+ * strategy parity with the join path, which throws this exact E003 at SQL
184
+ * build time (`pickOrderNestedError` in builder.ts). Without this guard the
185
+ * loaders would forward `options.orderBy` as the child reader's TOP-LEVEL
186
+ * findMany orderBy, where the pick shape compiles fine — so the same query
187
+ * would execute on 'batched' but throw on 'join'. Walks the whole tree up
188
+ * front so acceptance never depends on which levels have rows — the batched
189
+ * runners in builder.ts call this BEFORE the base query (a zero-row base
190
+ * result must still reject, exactly like the join strategy's build-time throw).
191
+ */
192
+ export function rejectNestedPickOrder(withClause) {
193
+ for (const spec of Object.values(withClause)) {
194
+ if (!spec || spec === true)
195
+ continue;
196
+ const options = spec;
197
+ if (options.orderBy) {
198
+ for (const [key, value] of Object.entries(options.orderBy)) {
199
+ if (isRelationPickOrderBy(value)) {
200
+ throw new ValidationError(`[turbine] Pick-row ordering on relation "${key}" is only supported in a top-level ` +
201
+ 'findMany orderBy: nested `with` orderBy does not support it.');
202
+ }
203
+ }
204
+ }
205
+ if (options.with)
206
+ rejectNestedPickOrder(options.with);
207
+ }
208
+ }
180
209
  /**
181
210
  * Load every relation in `withClause` for `parents` and attach it onto each row
182
211
  * in place. Mirrors the join strategy's output shape exactly. Recurses for nested
@@ -185,6 +214,10 @@ function keyOf(value) {
185
214
  export async function loadRelationsBatched(ctx, parents, withClause, timeout, depth = 0, path = [ctx.parentMeta.name]) {
186
215
  if (depth >= MAX_DEPTH)
187
216
  throw new CircularRelationError([...path, '…']);
217
+ // Scope-rule parity with the join strategy: validate the whole tree BEFORE
218
+ // the empty-parents early return, so accept/reject never depends on data.
219
+ if (depth === 0)
220
+ rejectNestedPickOrder(withClause);
188
221
  if (parents.length === 0)
189
222
  return;
190
223
  // Sibling relations are independent (each writes only its own parent[relName]
@@ -339,6 +339,27 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
339
339
  buildCount(args?: CountArgs<T>): DeferredQuery<number>;
340
340
  groupBy(args: GroupByArgs<T>): Promise<Record<string, unknown>[]>;
341
341
  buildGroupBy(args: GroupByArgs<T>): DeferredQuery<Record<string, unknown>[]>;
342
+ /**
343
+ * Validate a JSON-path target (group key or aggregate target) in groupBy:
344
+ * the field must resolve to a real json/jsonb column and the path must be a
345
+ * non-empty array of keys/indexes. Returns the resolved snake_case column.
346
+ */
347
+ private resolveJsonPathTarget;
348
+ /**
349
+ * Build the `distinctOn` row source for groupBy (PostgreSQL only: other
350
+ * engines throw {@link UnsupportedFeatureError} E017):
351
+ *
352
+ * ```sql
353
+ * (SELECT DISTINCT ON ("c1") * FROM "table"<WHERE> ORDER BY "c1", <orderBy>) AS "table"
354
+ * ```
355
+ *
356
+ * The wrapper is aliased as the table name so every outer expression (group
357
+ * keys, aggregates, HAVING, ORDER BY) is byte-identical to the plain path.
358
+ * `distinctOn.orderBy` is required (it decides which row survives) and
359
+ * supports plain columns, {@link OrderBySpec} nulls, and JSON-path specs;
360
+ * JSON paths push their text[] param here, after the WHERE params.
361
+ */
362
+ private buildDistinctOnSource;
342
363
  /**
343
364
  * Build the SQL fragments for a {@link HavingClause}.
344
365
  *
@@ -349,6 +370,12 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
349
370
  * comparison value is pushed onto the shared `params` array and referenced by
350
371
  * a `$N` placeholder via {@link buildHavingNumericClauses} — there is no string
351
372
  * interpolation of user values.
373
+ *
374
+ * `jsonAggExprs` (from {@link buildGroupBy}) maps `alias:aggKey` to the
375
+ * exact aggregate expression a JSON-path aggregate emitted in SELECT
376
+ * (including its already-bound path placeholder), so HAVING on a JSON-path
377
+ * aggregate alias reuses the same expression instead of resolving the alias
378
+ * as a column.
352
379
  */
353
380
  private buildHavingClauses;
354
381
  /**
@@ -712,6 +739,46 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
712
739
  * the correlation parent is the relation's alias, not `this.table`.
713
740
  */
714
741
  private buildRelationOrderBy;
742
+ /**
743
+ * Validate a {@link RelationPickOrderBy} entry's scope and shape. Shared by
744
+ * the SQL-build path ({@link buildRelationPickOrderBy}) and the cache-hit
745
+ * param-collect mirror ({@link collectRelationPickOrderParams}) so both
746
+ * always throw identically:
747
+ *
748
+ * - `nested` (inside a relation subquery's orderBy or a pick.orderBy):
749
+ * top-level findMany only in this release (E003),
750
+ * - manyToMany: not supported (E003 naming the limitation),
751
+ * - to-one: order by the target column directly instead (E003),
752
+ * - `pick.orderBy` is REQUIRED (deterministic row choice),
753
+ * - `by` must be a target column name or a `{ field, path }` JSON-path spec.
754
+ */
755
+ private pickOrderNestedError;
756
+ private validatePickOrderBy;
757
+ /**
758
+ * Compile a {@link RelationPickOrderBy} term: a correlated scalar subquery
759
+ * that picks ONE related row (`ORDER BY <pick.orderBy> LIMIT 1`, optionally
760
+ * filtered by `pick.where` and the target's global filter) and surfaces one
761
+ * value from it (a plain target column or a JSON-path extraction) as the
762
+ * parent ORDER BY key:
763
+ *
764
+ * ```sql
765
+ * (SELECT ord0."data" #>> $1::text[] FROM "versions" ord0
766
+ * WHERE ord0."instance_id" = "instances"."id" AND ord0."is_current" = $2
767
+ * ORDER BY ord0."created_at" DESC LIMIT 1) ASC NULLS LAST
768
+ * ```
769
+ *
770
+ * Param-push order (mirrored EXACTLY by
771
+ * {@link collectRelationPickOrderParams}): `by` JSON path (if any) →
772
+ * target global filter → `pick.where` → `pick.orderBy` JSON paths.
773
+ */
774
+ private buildRelationPickOrderBy;
775
+ /**
776
+ * Param-collect mirror of {@link buildRelationPickOrderBy}: re-runs the same
777
+ * validation (a warmed cache can never skip it), then pushes in the same
778
+ * order: `by` JSON path → target global filter → `pick.where` →
779
+ * `pick.orderBy` JSON paths.
780
+ */
781
+ private collectRelationPickOrderParams;
715
782
  /**
716
783
  * Compile the ORDER BY terms of a relation `with` clause against the
717
784
  * relation's table alias. One unified path for every relation shape
@@ -1009,6 +1076,13 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
1009
1076
  * Used to detect JSONB/array columns for specialized operators.
1010
1077
  * Uses pre-computed Map for O(1) lookup instead of linear scan.
1011
1078
  */
1079
+ /**
1080
+ * Case-insensitive json/jsonb column-type check. Postgres reports lowercase
1081
+ * udt_names, but SQLite/MySQL introspection surfaces the DECLARED type
1082
+ * (e.g. `JSON`), so every JSON-feature gate compares through this predicate
1083
+ * — build and collect sides alike, keeping the SQL-cache lockstep.
1084
+ */
1085
+ private isJsonColumnType;
1012
1086
  private getColumnPgType;
1013
1087
  /**
1014
1088
  * Get the Postgres base element type for an array column.
@@ -1033,6 +1107,18 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
1033
1107
  * stays byte-identical to {@link collectJsonFilterParams}.
1034
1108
  */
1035
1109
  private buildJsonFilterClauses;
1110
+ /**
1111
+ * Bind value for a JSON path parameter, encoded per dialect. PostgreSQL's
1112
+ * `#>>` takes a `text[]` (the segments as strings — or `nativeForm` when the
1113
+ * caller has a specific native binding, e.g. JsonFilter's raw path array).
1114
+ * Every other engine's JSON function (`json_extract` / `JSON_EXTRACT` /
1115
+ * `JSON_VALUE`) takes a `'$'`-rooted JSONPath STRING: binding the raw array
1116
+ * would arrive as `'["a"]'` (the driver shims JSON.stringify non-primitive
1117
+ * params) and fail at runtime with the engine's bad-JSON-path error. The
1118
+ * encoded path stays a bound parameter — never spliced into SQL text — so
1119
+ * the build/collect param mirrors stay in lockstep and injection-safe.
1120
+ */
1121
+ private jsonPathParam;
1036
1122
  /**
1037
1123
  * Cast an extracted JSON path text value to a numeric type for range
1038
1124
  * comparison. PostgreSQL uses `(expr)::numeric` (exact — the right way to