@prisma-next/sql-orm-client 0.10.0-dev.9 → 0.11.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/package.json CHANGED
@@ -1,32 +1,31 @@
1
1
  {
2
2
  "name": "@prisma-next/sql-orm-client",
3
- "version": "0.10.0-dev.9",
3
+ "version": "0.11.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "description": "ORM client for Prisma Next — fluent, type-safe model collections",
8
8
  "dependencies": {
9
- "@prisma-next/contract": "0.10.0-dev.9",
10
- "@prisma-next/framework-components": "0.10.0-dev.9",
11
- "@prisma-next/operations": "0.10.0-dev.9",
12
- "@prisma-next/sql-contract": "0.10.0-dev.9",
13
- "@prisma-next/sql-operations": "0.10.0-dev.9",
14
- "@prisma-next/sql-relational-core": "0.10.0-dev.9",
15
- "@prisma-next/sql-runtime": "0.10.0-dev.9",
16
- "@prisma-next/utils": "0.10.0-dev.9"
9
+ "@prisma-next/contract": "0.11.0",
10
+ "@prisma-next/framework-components": "0.11.0",
11
+ "@prisma-next/operations": "0.11.0",
12
+ "@prisma-next/sql-contract": "0.11.0",
13
+ "@prisma-next/sql-operations": "0.11.0",
14
+ "@prisma-next/sql-relational-core": "0.11.0",
15
+ "@prisma-next/sql-runtime": "0.11.0",
16
+ "@prisma-next/utils": "0.11.0"
17
17
  },
18
18
  "devDependencies": {
19
- "@prisma-next/adapter-postgres": "0.10.0-dev.9",
20
- "@prisma-next/driver-postgres": "0.10.0-dev.9",
21
- "@prisma-next/cli": "0.10.0-dev.9",
22
- "@prisma-next/extension-pgvector": "0.10.0-dev.9",
23
- "@prisma-next/family-sql": "0.10.0-dev.9",
24
- "@prisma-next/ids": "0.10.0-dev.9",
25
- "@prisma-next/sql-contract-ts": "0.10.0-dev.9",
26
- "@prisma-next/target-postgres": "0.10.0-dev.9",
27
- "@prisma-next/test-utils": "0.10.0-dev.9",
28
- "@prisma-next/tsconfig": "0.10.0-dev.9",
29
- "@prisma-next/tsdown": "0.10.0-dev.9",
19
+ "@prisma-next/adapter-postgres": "0.11.0",
20
+ "@prisma-next/cli": "0.11.0",
21
+ "@prisma-next/driver-postgres": "0.11.0",
22
+ "@prisma-next/family-sql": "0.11.0",
23
+ "@prisma-next/ids": "0.11.0",
24
+ "@prisma-next/sql-contract-ts": "0.11.0",
25
+ "@prisma-next/target-postgres": "0.11.0",
26
+ "@prisma-next/test-utils": "0.11.0",
27
+ "@prisma-next/tsconfig": "0.11.0",
28
+ "@prisma-next/tsdown": "0.11.0",
30
29
  "@types/pg": "8.20.0",
31
30
  "pg": "8.20.0",
32
31
  "tsdown": "0.22.0",
@@ -52,7 +51,7 @@
52
51
  },
53
52
  "scripts": {
54
53
  "build": "tsdown",
55
- "emit": "node ../../1-framework/3-tooling/cli/dist/cli.js contract emit --config test/fixtures/prisma-next.config.ts",
54
+ "emit": "cd ../../../test/integration && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/prisma-next.config.ts && cp test/sql-orm-client/fixtures/generated/contract.json test/sql-orm-client/fixtures/generated/contract.d.ts ../../packages/3-extensions/sql-orm-client/test/fixtures/generated/ && cd ../../packages/3-extensions/sql-orm-client && node scripts/strip-pgvector-fixture.mjs test/fixtures/generated/contract.d.ts",
56
55
  "emit:check": "pnpm emit && git diff --exit-code test/fixtures/generated/",
57
56
  "test": "vitest run",
58
57
  "test:coverage": "vitest run --coverage",
@@ -31,6 +31,10 @@ import {
31
31
  } from './collection-runtime';
32
32
  import { executeQueryPlan } from './execute-query-plan';
33
33
  import { selectIncludeStrategy } from './include-strategy';
34
+ import {
35
+ hasNonLeafIncludeWithDistinct,
36
+ hasScalarOrCombineIncludeDescriptors,
37
+ } from './include-tree-predicates';
34
38
  import {
35
39
  compileRelationSelect,
36
40
  compileSelect,
@@ -77,9 +81,23 @@ function dispatchWithIncludeStrategy<Row>(options: {
77
81
  }): AsyncIterableResult<Row> {
78
82
  const strategy = selectIncludeStrategy(options.contract);
79
83
 
84
+ // Nested row includes (depth >= 2) are emitted recursively by the
85
+ // lateral / correlated builders — they no longer force a fallback to
86
+ // multi-query (TML-2594). Scalar (`count`/`sum`/...) and `combine()`
87
+ // descriptors still do, until TML-2595 lands the matching lowering;
88
+ // the recursive scan below catches them at any depth so a nested
89
+ // `count()` inside a row include doesn't accidentally hit the
90
+ // throw in `compileSelectWithIncludeStrategy`.
91
+ //
92
+ // `distinct()` on a non-leaf include is also forced through multi-query:
93
+ // under the single-query strategies the child SELECT carries nested
94
+ // JSON aggregate columns, and `SELECT DISTINCT` over those fails on
95
+ // Postgres (`json` has no equality operator). The multi-query stitcher
96
+ // applies distinct to scalar-only child rows before grandchildren are
97
+ // joined, which is the semantically correct behavior we preserve.
80
98
  if (
81
- hasNestedIncludes(options.state.includes) ||
82
- hasComplexIncludeDescriptors(options.state.includes)
99
+ hasScalarOrCombineIncludeDescriptors(options.state.includes) ||
100
+ hasNonLeafIncludeWithDistinct(options.state.includes)
83
101
  ) {
84
102
  return dispatchWithMultiQueryIncludes<Row>(options);
85
103
  }
@@ -144,18 +162,10 @@ function dispatchWithSingleQueryIncludes<Row>(options: {
144
162
 
145
163
  for (const parent of parentRows) {
146
164
  for (const include of state.includes) {
147
- if (include.scalar || include.combine) {
148
- throw new Error(
149
- 'single-query include strategy does not support scalar include selectors or combine()',
150
- );
151
- }
152
- const rawChildren = parseIncludedRows(parent.raw[include.relationName]);
153
- const mappedChildren = rawChildren.map((childRow) =>
154
- mapStorageRowToModelFields(contract, include.relatedModelName, childRow),
155
- );
156
- parent.mapped[include.relationName] = coerceSingleQueryIncludeResult(
157
- mappedChildren,
158
- include.cardinality,
165
+ parent.mapped[include.relationName] = decodeIncludePayload(
166
+ contract,
167
+ include,
168
+ parent.raw[include.relationName],
159
169
  );
160
170
  }
161
171
 
@@ -372,8 +382,22 @@ async function resolveRowsByParent(
372
382
  state: CollectionState,
373
383
  parentJoinValues: readonly unknown[],
374
384
  ): Promise<Map<unknown, Record<string, unknown>[]>> {
385
+ // Multi-query stitching reads `child.raw[grandchildInclude.localColumn]`
386
+ // for every immediate include in `state.includes` to bucket children by
387
+ // join value. A user-supplied `.select(...)` on the child that omits any
388
+ // of those join columns would silently yield `undefined` for the join
389
+ // value, producing empty nested arrays at depth >= 2. Force every
390
+ // direct nested include's `localColumn` into the child's selected set
391
+ // alongside `include.targetColumn` so the stitcher always sees a
392
+ // defined join value. The next level of recursion (when grandchild
393
+ // stitching itself dispatches through `resolveRowsByParent`) repeats
394
+ // this same augmentation for its own children.
395
+ const nestedJoinColumns = state.includes.map((nested) => nested.localColumn);
396
+ const requiredChildColumns = Array.from(
397
+ new Set<string>([include.targetColumn, ...nestedJoinColumns]),
398
+ );
375
399
  const { selectedForQuery: childSelectedForQuery, hiddenColumns: hiddenChildColumns } =
376
- augmentSelectionForJoinColumns(state.selectedFields, [include.targetColumn]);
400
+ augmentSelectionForJoinColumns(state.selectedFields, requiredChildColumns);
377
401
 
378
402
  const childCompiled = compileRelationSelect(
379
403
  contract,
@@ -471,12 +495,44 @@ function uniqueValues(values: unknown[]): unknown[] {
471
495
  return [...new Set(values)];
472
496
  }
473
497
 
474
- function hasNestedIncludes(includes: readonly IncludeExpr[]): boolean {
475
- return includes.some((include) => include.nested.includes.length > 0);
476
- }
477
-
478
- function hasComplexIncludeDescriptors(includes: readonly IncludeExpr[]): boolean {
479
- return includes.some((include) => include.scalar !== undefined || include.combine !== undefined);
498
+ /**
499
+ * Decode a single-query include payload from a parent row's raw cell
500
+ * into the model-shaped value that downstream consumers see. Recurses
501
+ * through `include.nested.includes` so depth-2+ trees — emitted by the
502
+ * recursive lateral / correlated builders — are decoded symmetrically.
503
+ *
504
+ * The shape produced by the SQL side is one JSON column per top-level
505
+ * include; values nested inside that JSON are already-parsed JS values
506
+ * after the outer `JSON.parse`, so `parseIncludedRows` recognises both
507
+ * the string (top-level) and array (nested) forms.
508
+ */
509
+ function decodeIncludePayload(
510
+ contract: Contract<SqlStorage>,
511
+ include: IncludeExpr,
512
+ raw: unknown,
513
+ ): Record<string, unknown>[] | Record<string, unknown> | null {
514
+ const rawChildren = parseIncludedRows(raw);
515
+ const mappedChildren = rawChildren.map((childRow) => {
516
+ const mapped = mapStorageRowToModelFields(contract, include.relatedModelName, childRow);
517
+ for (const nestedInclude of include.nested.includes) {
518
+ // Defence in depth: the dispatch gate filters scalar/combine at
519
+ // any depth via `hasScalarOrCombineIncludeDescriptors`. This branch
520
+ // is unreachable in production but documents the contract the
521
+ // recursion relies on.
522
+ if (nestedInclude.scalar || nestedInclude.combine) {
523
+ throw new Error(
524
+ 'single-query include strategy does not support nested scalar include selectors or combine()',
525
+ );
526
+ }
527
+ mapped[nestedInclude.relationName] = decodeIncludePayload(
528
+ contract,
529
+ nestedInclude,
530
+ mapped[nestedInclude.relationName],
531
+ );
532
+ }
533
+ return mapped;
534
+ });
535
+ return coerceSingleQueryIncludeResult(mappedChildren, include.cardinality);
480
536
  }
481
537
 
482
538
  function assignEmptyIncludeResult(parentRows: RowEnvelope[], include: IncludeExpr): void {
@@ -1,6 +1,7 @@
1
1
  export { Collection } from '../collection';
2
2
  export { all, and, not, or } from '../filters';
3
3
  export { GroupedCollection } from '../grouped-collection';
4
+ export { createModelAccessor } from '../model-accessor';
4
5
  export type { OrmOptions } from '../orm';
5
6
  export { orm } from '../orm';
6
7
  export type {
@@ -17,6 +18,7 @@ export type {
17
18
  DefaultModelRow,
18
19
  IncludeExpr,
19
20
  ModelAccessor,
21
+ NumericFieldNames,
20
22
  RelatedModelName,
21
23
  RelationFilterAccessor,
22
24
  RelationMutator,
@@ -0,0 +1,50 @@
1
+ import type { IncludeExpr } from './types';
2
+
3
+ /**
4
+ * Recursive predicate: does any include in the tree carry a
5
+ * non-leaf `distinct()` — i.e. `nested.distinct` set on an include
6
+ * whose `nested.includes` is non-empty?
7
+ *
8
+ * Such shapes cannot be lowered into the lateral / correlated
9
+ * single-query strategies: the child SELECT would emit
10
+ * `SELECT DISTINCT <scalars>, json_agg(<nested>) FROM ...`, and
11
+ * Postgres rejects equality on `json`. The dispatch path routes
12
+ * these to multi-query (which applies distinct to scalar-only rows
13
+ * before grandchildren stitch in JS); the planner rejects them at
14
+ * the boundary.
15
+ *
16
+ * `distinctOn` is intentionally not included: Postgres only
17
+ * compares the `ON (...)` expressions for equality, so a hashable
18
+ * key column plus json projections is well-defined.
19
+ */
20
+ export function hasNonLeafIncludeWithDistinct(includes: readonly IncludeExpr[]): boolean {
21
+ return includes.some(
22
+ (include) =>
23
+ (include.nested.distinct !== undefined &&
24
+ include.nested.distinct.length > 0 &&
25
+ include.nested.includes.length > 0) ||
26
+ hasNonLeafIncludeWithDistinct(include.nested.includes),
27
+ );
28
+ }
29
+
30
+ /**
31
+ * Recursive predicate: does any include in the tree carry a scalar
32
+ * selector (`count` / `sum` / ...) or a `combine()` descriptor?
33
+ *
34
+ * Such shapes cannot be lowered into the lateral / correlated
35
+ * single-query strategies (TML-2595). The dispatch path uses this
36
+ * to gate the whole tree to multi-query at any depth; the planner
37
+ * (`compileSelectWithIncludeStrategy`) uses the same predicate to
38
+ * fail fast at the boundary rather than build a malformed plan.
39
+ * Without the recursion, a depth-2+ row include containing a
40
+ * depth-3 `count()` would fall through to the planner and hit its
41
+ * explicit `throw` instead of routing to multi-query.
42
+ */
43
+ export function hasScalarOrCombineIncludeDescriptors(includes: readonly IncludeExpr[]): boolean {
44
+ return includes.some(
45
+ (include) =>
46
+ include.scalar !== undefined ||
47
+ include.combine !== undefined ||
48
+ hasScalarOrCombineIncludeDescriptors(include.nested.includes),
49
+ );
50
+ }
@@ -239,7 +239,7 @@ export function compileUpsertReturning(
239
239
  ).doNothing();
240
240
 
241
241
  const ast = InsertAst.into(TableSource.named(tableName))
242
- .withValues(createAssignments.assignments)
242
+ .withRows([createAssignments.assignments])
243
243
  .withOnConflict(onConflict)
244
244
  .withReturning(buildReturningColumns(contract, tableName, returningColumns));
245
245
 
@@ -29,6 +29,10 @@ import {
29
29
  resolvePolymorphismInfo,
30
30
  resolvePrimaryKeyColumn,
31
31
  } from './collection-contract';
32
+ import {
33
+ hasNonLeafIncludeWithDistinct,
34
+ hasScalarOrCombineIncludeDescriptors,
35
+ } from './include-tree-predicates';
32
36
  import { buildOrmQueryPlan, deriveParamsFromAst, resolveTableColumns } from './query-plan-meta';
33
37
  import type { CollectionState, IncludeExpr } from './types';
34
38
  import { bindWhereExpr } from './where-binding';
@@ -210,10 +214,54 @@ function buildIncludeOrderArtifacts(
210
214
  };
211
215
  }
212
216
 
217
+ /**
218
+ * Recursively build the join + projection artifacts for the nested
219
+ * includes attached to a child SELECT. Used by
220
+ * `buildIncludeChildRowsSelect` to wire depth-2+ aggregates into the
221
+ * inner SELECT at each level.
222
+ *
223
+ * Under the `lateral` strategy each nested include contributes one
224
+ * LEFT JOIN LATERAL plus one projection item that references the
225
+ * lateral alias. Under the `correlated` strategy each nested include
226
+ * contributes a single projection item whose expression is a
227
+ * correlated subquery; no joins are produced. The two cases are
228
+ * symmetric, which is why both paths share `buildIncludeChildRowsSelect`.
229
+ */
230
+ function buildNestedIncludeArtifacts(
231
+ contract: Contract<SqlStorage>,
232
+ parentTableRef: string,
233
+ includes: readonly IncludeExpr[],
234
+ strategy: 'lateral' | 'correlated',
235
+ ): {
236
+ readonly joins: ReadonlyArray<JoinAst>;
237
+ readonly projections: ReadonlyArray<ProjectionItem>;
238
+ } {
239
+ if (includes.length === 0) {
240
+ return { joins: [], projections: [] };
241
+ }
242
+
243
+ const joins: JoinAst[] = [];
244
+ const projections: ProjectionItem[] = [];
245
+
246
+ for (const nested of includes) {
247
+ if (strategy === 'lateral') {
248
+ const artifact = buildLateralIncludeArtifacts(contract, parentTableRef, nested);
249
+ joins.push(artifact.join);
250
+ projections.push(artifact.projection);
251
+ continue;
252
+ }
253
+ const artifact = buildCorrelatedIncludeProjection(contract, parentTableRef, nested);
254
+ projections.push(artifact.projection);
255
+ }
256
+
257
+ return { joins, projections };
258
+ }
259
+
213
260
  function buildIncludeChildRowsSelect(
214
261
  contract: Contract<SqlStorage>,
215
262
  parentTableName: string,
216
263
  include: IncludeExpr,
264
+ strategy: 'lateral' | 'correlated',
217
265
  ): {
218
266
  readonly childRows: SelectAst;
219
267
  readonly childProjection: ReadonlyArray<ProjectionItem>;
@@ -225,16 +273,28 @@ function buildIncludeChildRowsSelect(
225
273
  include.relatedTableName === parentTableName ? `${include.relationName}__child` : undefined;
226
274
  const childTableRef = childTableAlias ?? include.relatedTableName;
227
275
  const rowsAlias = `${include.relationName}__rows`;
228
- const childProjection = buildProjection(
276
+ const scalarProjection = buildProjection(
229
277
  contract,
230
278
  include.relatedTableName,
231
279
  childState.selectedFields,
232
280
  childTableRef,
233
281
  );
282
+ // Self-relations rename the inner table source via `childTableAlias`,
283
+ // so any ColumnRef the user-supplied `orderBy` carries against the
284
+ // original `include.relatedTableName` is no longer in scope inside the
285
+ // child SELECT. Remap before lowering to the hidden order projection
286
+ // — mirrors the `filterTableName` remap `buildStateWhere` applies to
287
+ // the where clauses just below.
288
+ const remappedChildOrderBy =
289
+ childTableAlias && childState.orderBy
290
+ ? childState.orderBy.map((item) =>
291
+ item.rewrite(createTableRefRemapper(include.relatedTableName, childTableRef)),
292
+ )
293
+ : childState.orderBy;
234
294
  const { childOrderBy, hiddenOrderProjection, aggregateOrderBy } = buildIncludeOrderArtifacts(
235
295
  include.relationName,
236
296
  rowsAlias,
237
- childState.orderBy,
297
+ remappedChildOrderBy,
238
298
  );
239
299
  const childWhere = buildStateWhere(contract, childTableRef, childState, {
240
300
  filterTableName: include.relatedTableName,
@@ -245,8 +305,30 @@ function buildIncludeChildRowsSelect(
245
305
  );
246
306
  const whereExpr = childWhere ? AndExpr.of([joinExpr, childWhere]) : joinExpr;
247
307
 
308
+ // Recurse: each nested include produces either a LATERAL JOIN (under
309
+ // `lateral`) or a correlated subquery projection (under `correlated`).
310
+ // The nested aggregates are attached to *this* child SELECT, so they
311
+ // correlate against `childTableRef` — which may itself be an alias if
312
+ // the relation is self-referential.
313
+ const { joins: nestedJoins, projections: nestedProjections } = buildNestedIncludeArtifacts(
314
+ contract,
315
+ childTableRef,
316
+ childState.includes,
317
+ strategy,
318
+ );
319
+
320
+ // `childProjection` is the set of items that survive into the parent's
321
+ // JSON object — the scalar columns plus any nested-include aggregate
322
+ // columns. The hidden order-by projection is separate and is dropped
323
+ // before assembling the parent's json_object_expr.
324
+ const childProjection: ReadonlyArray<ProjectionItem> = [
325
+ ...scalarProjection,
326
+ ...nestedProjections,
327
+ ];
328
+
248
329
  let childRows = SelectAst.from(TableSource.named(include.relatedTableName, childTableAlias))
249
330
  .withProjection([...childProjection, ...hiddenOrderProjection])
331
+ .withJoins(nestedJoins)
250
332
  .withWhere(whereExpr);
251
333
 
252
334
  if (childOrderBy) {
@@ -286,6 +368,7 @@ function buildLateralIncludeArtifacts(
286
368
  contract,
287
369
  parentTableName,
288
370
  include,
371
+ 'lateral',
289
372
  );
290
373
  const lateralAlias = `${include.relationName}_lateral`;
291
374
  const jsonObjectExpr = JsonObjectExpr.fromEntries(
@@ -323,6 +406,7 @@ function buildCorrelatedIncludeProjection(
323
406
  contract,
324
407
  parentTableName,
325
408
  include,
409
+ 'correlated',
326
410
  );
327
411
  const jsonObjectExpr = JsonObjectExpr.fromEntries(
328
412
  childProjection.map((item) =>
@@ -483,14 +567,24 @@ export function compileSelectWithIncludeStrategy(
483
567
  strategy: 'lateral' | 'correlated',
484
568
  modelName?: string,
485
569
  ): SqlQueryPlan<Record<string, unknown>> {
486
- if (
487
- state.includes.some((include) => include.scalar !== undefined || include.combine !== undefined)
488
- ) {
570
+ if (hasScalarOrCombineIncludeDescriptors(state.includes)) {
489
571
  throw new Error(
490
572
  'single-query include strategy does not support scalar include selectors or combine()',
491
573
  );
492
574
  }
493
575
 
576
+ // Same recursive shape as the scalar/combine gate above. A non-leaf
577
+ // `distinct()` cannot be lowered into the single-query strategies:
578
+ // the child SELECT would emit `SELECT DISTINCT <scalars>, json_agg(...)`,
579
+ // and Postgres rejects equality on `json`. The dispatch path routes
580
+ // these to multi-query; direct planner callers (tests, rich-plan
581
+ // consumers) fail fast here instead of building an invalid plan.
582
+ if (hasNonLeafIncludeWithDistinct(state.includes)) {
583
+ throw new Error(
584
+ 'single-query include strategy does not support distinct() on a non-leaf include',
585
+ );
586
+ }
587
+
494
588
  const includeJoins: JoinAst[] = [];
495
589
  const includeProjection: ProjectionItem[] = [];
496
590
  const topLevelWhere = buildStateWhere(contract, tableName, state);