@prisma-next/sql-runtime 0.5.0-dev.22 → 0.5.0-dev.24

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.
@@ -1,33 +1,29 @@
1
- import type { ParamDescriptor } from '@prisma-next/contract/types';
2
1
  import { runtimeError } from '@prisma-next/framework-components/runtime';
3
- import type { Codec, CodecRegistry } from '@prisma-next/sql-relational-core/ast';
2
+ import {
3
+ type Codec,
4
+ type CodecRegistry,
5
+ collectOrderedParamRefs,
6
+ } from '@prisma-next/sql-relational-core/ast';
4
7
  import type { SqlExecutionPlan } from '@prisma-next/sql-relational-core/plan';
5
8
 
6
- function resolveParamCodec(
7
- paramDescriptor: ParamDescriptor,
8
- registry: CodecRegistry,
9
- ): Codec | null {
10
- if (paramDescriptor.codecId) {
11
- const codec = registry.get(paramDescriptor.codecId);
12
- if (codec) {
13
- return codec;
14
- }
15
- }
16
-
17
- return null;
9
+ interface ParamMetadata {
10
+ readonly codecId: string | undefined;
11
+ readonly name: string | undefined;
18
12
  }
19
13
 
20
- function paramLabel(paramDescriptor: ParamDescriptor, paramIndex: number): string {
21
- return paramDescriptor.name ?? `param[${paramIndex}]`;
14
+ const NO_METADATA: ParamMetadata = Object.freeze({ codecId: undefined, name: undefined });
15
+
16
+ function paramLabel(metadata: ParamMetadata, paramIndex: number): string {
17
+ return metadata.name ?? `param[${paramIndex}]`;
22
18
  }
23
19
 
24
20
  function wrapEncodeFailure(
25
21
  error: unknown,
26
- paramDescriptor: ParamDescriptor,
22
+ metadata: ParamMetadata,
27
23
  paramIndex: number,
28
24
  codecId: string,
29
25
  ): never {
30
- const label = paramLabel(paramDescriptor, paramIndex);
26
+ const label = paramLabel(metadata, paramIndex);
31
27
  const message = error instanceof Error ? error.message : String(error);
32
28
  const wrapped = runtimeError(
33
29
  'RUNTIME.ENCODE_FAILED',
@@ -47,7 +43,21 @@ function wrapEncodeFailure(
47
43
  */
48
44
  export async function encodeParam(
49
45
  value: unknown,
50
- paramDescriptor: ParamDescriptor,
46
+ paramRef: { readonly codecId?: string; readonly name?: string },
47
+ paramIndex: number,
48
+ registry: CodecRegistry,
49
+ ): Promise<unknown> {
50
+ return encodeParamValue(
51
+ value,
52
+ { codecId: paramRef.codecId, name: paramRef.name },
53
+ paramIndex,
54
+ registry,
55
+ );
56
+ }
57
+
58
+ async function encodeParamValue(
59
+ value: unknown,
60
+ metadata: ParamMetadata,
51
61
  paramIndex: number,
52
62
  registry: CodecRegistry,
53
63
  ): Promise<unknown> {
@@ -55,7 +65,11 @@ export async function encodeParam(
55
65
  return null;
56
66
  }
57
67
 
58
- const codec = resolveParamCodec(paramDescriptor, registry);
68
+ if (!metadata.codecId) {
69
+ return value;
70
+ }
71
+
72
+ const codec: Codec | undefined = registry.get(metadata.codecId);
59
73
  if (!codec) {
60
74
  return value;
61
75
  }
@@ -63,7 +77,7 @@ export async function encodeParam(
63
77
  try {
64
78
  return await codec.encode(value);
65
79
  } catch (error) {
66
- wrapEncodeFailure(error, paramDescriptor, paramIndex, codec.id);
80
+ wrapEncodeFailure(error, metadata, paramIndex, codec.id);
67
81
  }
68
82
  }
69
83
 
@@ -80,23 +94,22 @@ export async function encodeParams(
80
94
  return plan.params;
81
95
  }
82
96
 
83
- const descriptorCount = plan.meta.paramDescriptors.length;
84
97
  const paramCount = plan.params.length;
98
+ const metadata: ParamMetadata[] = new Array(paramCount).fill(NO_METADATA);
85
99
 
86
- const tasks: Promise<unknown>[] = new Array(paramCount);
87
- for (let i = 0; i < paramCount; i++) {
88
- const paramValue = plan.params[i];
89
- const paramDescriptor = plan.meta.paramDescriptors[i];
90
-
91
- if (!paramDescriptor) {
92
- throw runtimeError(
93
- 'RUNTIME.MISSING_PARAM_DESCRIPTOR',
94
- `Missing paramDescriptor for parameter at index ${i} (plan has ${paramCount} params, ${descriptorCount} descriptors). The planner must emit one descriptor per param; this is a contract violation.`,
95
- { paramIndex: i, paramCount, descriptorCount },
96
- );
100
+ if (plan.ast) {
101
+ const refs = collectOrderedParamRefs(plan.ast);
102
+ for (let i = 0; i < paramCount && i < refs.length; i++) {
103
+ const ref = refs[i];
104
+ if (ref) {
105
+ metadata[i] = { codecId: ref.codecId, name: ref.name };
106
+ }
97
107
  }
108
+ }
98
109
 
99
- tasks[i] = encodeParam(paramValue, paramDescriptor, i, registry);
110
+ const tasks: Promise<unknown>[] = new Array(paramCount);
111
+ for (let i = 0; i < paramCount; i++) {
112
+ tasks[i] = encodeParamValue(plan.params[i], metadata[i] ?? NO_METADATA, i, registry);
100
113
  }
101
114
 
102
115
  const encoded = await Promise.all(tasks);
@@ -1,4 +1,4 @@
1
- import type { PlanMeta, PlanRefs } from '@prisma-next/contract/types';
1
+ import type { PlanMeta } from '@prisma-next/contract/types';
2
2
 
3
3
  export type LintSeverity = 'error' | 'warn';
4
4
  export type BudgetSeverity = 'error' | 'warn';
@@ -103,58 +103,9 @@ export function evaluateRawGuardrails(
103
103
  );
104
104
  }
105
105
 
106
- const refs = plan.meta.refs;
107
- if (refs) {
108
- evaluateIndexCoverage(refs, lints);
109
- }
110
-
111
106
  return { lints, budgets, statement: statementType };
112
107
  }
113
108
 
114
- function evaluateIndexCoverage(refs: PlanRefs, lints: LintFinding[]) {
115
- const predicateColumns = refs.columns ?? [];
116
- if (predicateColumns.length === 0) {
117
- return;
118
- }
119
-
120
- const indexes = refs.indexes ?? [];
121
-
122
- if (indexes.length === 0) {
123
- lints.push(
124
- createLint(
125
- 'LINT.UNINDEXED_PREDICATE',
126
- 'warn',
127
- 'Raw SQL plan predicates lack supporting indexes',
128
- {
129
- predicates: predicateColumns,
130
- },
131
- ),
132
- );
133
- return;
134
- }
135
-
136
- const hasSupportingIndex = predicateColumns.every((column) =>
137
- indexes.some(
138
- (index) =>
139
- index.table === column.table &&
140
- index.columns.some((col) => col.toLowerCase() === column.column.toLowerCase()),
141
- ),
142
- );
143
-
144
- if (!hasSupportingIndex) {
145
- lints.push(
146
- createLint(
147
- 'LINT.UNINDEXED_PREDICATE',
148
- 'warn',
149
- 'Raw SQL plan predicates lack supporting indexes',
150
- {
151
- predicates: predicateColumns,
152
- },
153
- ),
154
- );
155
- }
156
- }
157
-
158
109
  function classifyStatement(sql: string): 'select' | 'mutation' | 'other' {
159
110
  const trimmed = sql.trim();
160
111
  const lower = trimmed.toLowerCase();
@@ -1,5 +1,3 @@
1
- import type { ParamDescriptor, PlanMeta } from '@prisma-next/contract/types';
2
- import type { AnyQueryAst } from '@prisma-next/sql-relational-core/ast';
3
1
  import type { DraftPlan, SqlMiddleware, SqlMiddlewareContext } from './sql-middleware';
4
2
 
5
3
  export async function runBeforeCompileChain(
@@ -27,33 +25,5 @@ export async function runBeforeCompileChain(
27
25
  current = result;
28
26
  }
29
27
 
30
- if (current.ast === initial.ast) {
31
- return current;
32
- }
33
-
34
- // The rewritten AST may have introduced, removed, or replaced ParamRefs, so
35
- // the descriptors collected at lane build time no longer line up with what
36
- // the adapter will emit when it walks the new AST. Re-derive descriptors
37
- // from the rewritten AST so `params` and `paramDescriptors` stay in lockstep
38
- // by the time `encodeParams` runs.
39
- const paramDescriptors = deriveParamDescriptorsFromAst(current.ast);
40
- const meta: PlanMeta = { ...current.meta, paramDescriptors };
41
- return { ast: current.ast, meta };
42
- }
43
-
44
- function deriveParamDescriptorsFromAst(ast: AnyQueryAst): ReadonlyArray<ParamDescriptor> {
45
- const refs = ast.collectParamRefs();
46
- const seen = new Set<unknown>();
47
- const descriptors: ParamDescriptor[] = [];
48
- for (const ref of refs) {
49
- if (seen.has(ref)) continue;
50
- seen.add(ref);
51
- descriptors.push({
52
- index: descriptors.length + 1,
53
- ...(ref.name !== undefined ? { name: ref.name } : {}),
54
- source: 'dsl',
55
- ...(ref.codecId !== undefined ? { codecId: ref.codecId } : {}),
56
- });
57
- }
58
- return descriptors;
28
+ return current;
59
29
  }
@@ -25,18 +25,28 @@ function hasAggregateWithoutGroupBy(ast: SelectAst): boolean {
25
25
  return ast.projection.some((item) => item.expr.kind === 'aggregate');
26
26
  }
27
27
 
28
+ function primaryTableFromAst(ast: SelectAst): string | undefined {
29
+ switch (ast.from.kind) {
30
+ case 'table-source':
31
+ return ast.from.name;
32
+ case 'derived-table-source':
33
+ return ast.from.alias;
34
+ default:
35
+ return undefined;
36
+ }
37
+ }
38
+
28
39
  function estimateRowsFromAst(
29
40
  ast: SelectAst,
30
41
  tableRows: Record<string, number>,
31
42
  defaultTableRows: number,
32
- refs: { tables?: readonly string[] } | undefined,
33
43
  hasAggregateWithoutGroup: boolean,
34
44
  ): number | null {
35
45
  if (hasAggregateWithoutGroup) {
36
46
  return 1;
37
47
  }
38
48
 
39
- const table = refs?.tables?.[0];
49
+ const table = primaryTableFromAst(ast);
40
50
  if (!table) {
41
51
  return null;
42
52
  }
@@ -50,30 +60,6 @@ function estimateRowsFromAst(
50
60
  return tableEstimate;
51
61
  }
52
62
 
53
- function estimateRowsFromHeuristics(
54
- plan: SqlExecutionPlan,
55
- tableRows: Record<string, number>,
56
- defaultTableRows: number,
57
- ): number | null {
58
- const table = plan.meta.refs?.tables?.[0];
59
- if (!table) {
60
- return null;
61
- }
62
-
63
- const tableEstimate = tableRows[table] ?? defaultTableRows;
64
-
65
- const limit = plan.meta.annotations?.['limit'];
66
- if (typeof limit === 'number') {
67
- return Math.min(limit, tableEstimate);
68
- }
69
-
70
- return tableEstimate;
71
- }
72
-
73
- function hasDetectableLimitFromHeuristics(plan: SqlExecutionPlan): boolean {
74
- return typeof plan.meta.annotations?.['limit'] === 'number';
75
- }
76
-
77
63
  function emitBudgetViolation(
78
64
  error: RuntimeErrorEnvelope,
79
65
  shouldBlock: boolean,
@@ -105,14 +91,9 @@ export function budgets(options?: BudgetsOptions): SqlMiddleware {
105
91
  async beforeExecute(plan: SqlExecutionPlan, ctx: SqlMiddlewareContext) {
106
92
  observedRowsByPlan.set(plan, { count: 0 });
107
93
 
108
- if (isQueryAst(plan.ast)) {
109
- if (plan.ast.kind === 'select') {
110
- return evaluateSelectAst(plan, plan.ast, ctx);
111
- }
112
- return;
94
+ if (isQueryAst(plan.ast) && plan.ast.kind === 'select') {
95
+ return evaluateSelectAst(plan.ast, ctx);
113
96
  }
114
-
115
- return evaluateWithHeuristics(plan, ctx);
116
97
  },
117
98
 
118
99
  async onRow(_row: Record<string, unknown>, plan: SqlExecutionPlan, _ctx: SqlMiddlewareContext) {
@@ -148,15 +129,9 @@ export function budgets(options?: BudgetsOptions): SqlMiddleware {
148
129
  },
149
130
  });
150
131
 
151
- function evaluateSelectAst(plan: SqlExecutionPlan, ast: SelectAst, ctx: SqlMiddlewareContext) {
132
+ function evaluateSelectAst(ast: SelectAst, ctx: SqlMiddlewareContext) {
152
133
  const hasAggNoGroup = hasAggregateWithoutGroupBy(ast);
153
- const estimated = estimateRowsFromAst(
154
- ast,
155
- tableRows,
156
- defaultTableRows,
157
- plan.meta.refs,
158
- hasAggNoGroup,
159
- );
134
+ const estimated = estimateRowsFromAst(ast, tableRows, defaultTableRows, hasAggNoGroup);
160
135
  const isUnbounded = ast.limit === undefined && !hasAggNoGroup;
161
136
  const shouldBlock = rowSeverity === 'error' || ctx.mode === 'strict';
162
137
 
@@ -197,52 +172,4 @@ export function budgets(options?: BudgetsOptions): SqlMiddleware {
197
172
  );
198
173
  }
199
174
  }
200
-
201
- async function evaluateWithHeuristics(plan: SqlExecutionPlan, ctx: SqlMiddlewareContext) {
202
- const estimated = estimateRowsFromHeuristics(plan, tableRows, defaultTableRows);
203
- const isUnbounded = !hasDetectableLimitFromHeuristics(plan);
204
- const sqlUpper = plan.sql.trimStart().toUpperCase();
205
- const isSelect = sqlUpper.startsWith('SELECT');
206
- const shouldBlock = rowSeverity === 'error' || ctx.mode === 'strict';
207
-
208
- if (isSelect && isUnbounded) {
209
- if (estimated !== null && estimated >= maxRows) {
210
- emitBudgetViolation(
211
- runtimeError('BUDGET.ROWS_EXCEEDED', 'Unbounded SELECT query exceeds budget', {
212
- source: 'heuristic',
213
- estimatedRows: estimated,
214
- maxRows,
215
- }),
216
- shouldBlock,
217
- ctx,
218
- );
219
- return;
220
- }
221
-
222
- emitBudgetViolation(
223
- runtimeError('BUDGET.ROWS_EXCEEDED', 'Unbounded SELECT query exceeds budget', {
224
- source: 'heuristic',
225
- maxRows,
226
- }),
227
- shouldBlock,
228
- ctx,
229
- );
230
- return;
231
- }
232
-
233
- if (estimated !== null) {
234
- if (estimated > maxRows) {
235
- emitBudgetViolation(
236
- runtimeError('BUDGET.ROWS_EXCEEDED', 'Estimated row count exceeds budget', {
237
- source: 'heuristic',
238
- estimatedRows: estimated,
239
- maxRows,
240
- }),
241
- shouldBlock,
242
- ctx,
243
- );
244
- }
245
- return;
246
- }
247
- }
248
175
  }
@@ -211,10 +211,12 @@ class SqlRuntimeImpl<TContract extends Contract<SqlStorage> = Contract<SqlStorag
211
211
 
212
212
  /**
213
213
  * SQL pre-compile hook. Runs the registered middleware `beforeCompile`
214
- * chain over the plan's draft (AST + meta) and returns a `SqlQueryPlan`
215
- * with the rewritten AST and meta when the chain mutates them. The chain
216
- * re-derives `meta.paramDescriptors` from the rewritten AST so descriptors
217
- * stay in lockstep with the params the adapter will emit during lowering.
214
+ * chain over the plan's draft (AST + meta). Returns the original plan
215
+ * unchanged when no middleware rewrote the AST; otherwise returns a new
216
+ * plan carrying the rewritten AST and meta. The AST is the authoritative
217
+ * source of execution metadata, so a rewrite needs no sidecar
218
+ * reconciliation here — the lowering adapter and the encoder both walk
219
+ * the rewritten AST directly.
218
220
  */
219
221
  protected override async runBeforeCompile(plan: SqlQueryPlan): Promise<SqlQueryPlan> {
220
222
  const rewrittenDraft = await runBeforeCompileChain(