@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.
- package/dist/{exports-BET5HxxT.mjs → exports-CwCgOv6w.mjs} +100 -176
- package/dist/exports-CwCgOv6w.mjs.map +1 -0
- package/dist/index-Df2GsLSH.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/test/utils.mjs +1 -1
- package/package.json +11 -11
- package/src/codecs/decoding.ts +77 -99
- package/src/codecs/encoding.ts +47 -34
- package/src/guardrails/raw.ts +1 -50
- package/src/middleware/before-compile-chain.ts +1 -31
- package/src/middleware/budgets.ts +16 -89
- package/src/sql-runtime.ts +6 -4
- package/dist/exports-BET5HxxT.mjs.map +0 -1
package/src/codecs/encoding.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
22
|
+
metadata: ParamMetadata,
|
|
27
23
|
paramIndex: number,
|
|
28
24
|
codecId: string,
|
|
29
25
|
): never {
|
|
30
|
-
const label = paramLabel(
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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);
|
package/src/guardrails/raw.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PlanMeta
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
}
|
package/src/sql-runtime.ts
CHANGED
|
@@ -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)
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
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(
|