@prisma-next/sql-runtime 0.3.0-dev.13 → 0.3.0-dev.146
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/LICENSE +201 -0
- package/README.md +141 -24
- package/dist/exports-DGa0ipuP.mjs +956 -0
- package/dist/exports-DGa0ipuP.mjs.map +1 -0
- package/dist/index-CDbmlDcn.d.mts +177 -0
- package/dist/index-CDbmlDcn.d.mts.map +1 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +3 -0
- package/dist/test/utils.d.mts +77 -0
- package/dist/test/utils.d.mts.map +1 -0
- package/dist/test/utils.mjs +221 -0
- package/dist/test/utils.mjs.map +1 -0
- package/package.json +29 -22
- package/src/codecs/decoding.ts +84 -3
- package/src/codecs/encoding.ts +5 -15
- package/src/codecs/json-schema-validation.ts +61 -0
- package/src/codecs/validation.ts +6 -5
- package/src/exports/index.ts +19 -7
- package/src/lower-sql-plan.ts +9 -9
- package/src/plugins/budgets.ts +375 -0
- package/src/plugins/lints.ts +211 -0
- package/src/sql-context.ts +454 -108
- package/src/sql-family-adapter.ts +16 -22
- package/src/sql-marker.ts +2 -2
- package/src/sql-runtime.ts +136 -47
- package/test/async-iterable-result.test.ts +42 -37
- package/test/budgets.test.ts +481 -0
- package/test/context.types.test-d.ts +68 -0
- package/test/execution-stack.test.ts +164 -0
- package/test/json-schema-validation.test.ts +571 -0
- package/test/lints.test.ts +161 -0
- package/test/mutation-default-generators.test.ts +254 -0
- package/test/parameterized-types.test.ts +529 -0
- package/test/sql-context.test.ts +301 -134
- package/test/sql-family-adapter.test.ts +37 -20
- package/test/sql-runtime.test.ts +220 -49
- package/test/utils.ts +102 -64
- package/dist/accelerate-EEKAFGN3-P6A6XJWJ.js +0 -137863
- package/dist/accelerate-EEKAFGN3-P6A6XJWJ.js.map +0 -1
- package/dist/amcheck-24VY6X5V.js +0 -13
- package/dist/amcheck-24VY6X5V.js.map +0 -1
- package/dist/bloom-VS74NLHT.js +0 -13
- package/dist/bloom-VS74NLHT.js.map +0 -1
- package/dist/btree_gin-WBC4EAAI.js +0 -13
- package/dist/btree_gin-WBC4EAAI.js.map +0 -1
- package/dist/btree_gist-UNC6QD3M.js +0 -13
- package/dist/btree_gist-UNC6QD3M.js.map +0 -1
- package/dist/chunk-3KTOEDFX.js +0 -49
- package/dist/chunk-3KTOEDFX.js.map +0 -1
- package/dist/chunk-47DZBRQC.js +0 -1280
- package/dist/chunk-47DZBRQC.js.map +0 -1
- package/dist/chunk-52N6AFZM.js +0 -133
- package/dist/chunk-52N6AFZM.js.map +0 -1
- package/dist/chunk-7D4SUZUM.js +0 -38
- package/dist/chunk-7D4SUZUM.js.map +0 -1
- package/dist/chunk-C6I3V3DM.js +0 -455
- package/dist/chunk-C6I3V3DM.js.map +0 -1
- package/dist/chunk-ECWIHLAT.js +0 -37
- package/dist/chunk-ECWIHLAT.js.map +0 -1
- package/dist/chunk-EI626SDC.js +0 -105
- package/dist/chunk-EI626SDC.js.map +0 -1
- package/dist/chunk-UKKOYUGL.js +0 -578
- package/dist/chunk-UKKOYUGL.js.map +0 -1
- package/dist/chunk-XPLNMXQV.js +0 -1537
- package/dist/chunk-XPLNMXQV.js.map +0 -1
- package/dist/citext-T7MXGUY7.js +0 -13
- package/dist/citext-T7MXGUY7.js.map +0 -1
- package/dist/client-5FENX6AW.js +0 -299
- package/dist/client-5FENX6AW.js.map +0 -1
- package/dist/cube-TFDQBZCI.js +0 -13
- package/dist/cube-TFDQBZCI.js.map +0 -1
- package/dist/dict_int-AEUOPGWP.js +0 -13
- package/dist/dict_int-AEUOPGWP.js.map +0 -1
- package/dist/dict_xsyn-DAAYX3FL.js +0 -13
- package/dist/dict_xsyn-DAAYX3FL.js.map +0 -1
- package/dist/dist-AQ3LWXOX.js +0 -570
- package/dist/dist-AQ3LWXOX.js.map +0 -1
- package/dist/dist-LBVX6BJW.js +0 -189
- package/dist/dist-LBVX6BJW.js.map +0 -1
- package/dist/dist-WLKUVDN2.js +0 -5127
- package/dist/dist-WLKUVDN2.js.map +0 -1
- package/dist/earthdistance-KIGTF4LE.js +0 -13
- package/dist/earthdistance-KIGTF4LE.js.map +0 -1
- package/dist/file_fdw-5N55UP6I.js +0 -13
- package/dist/file_fdw-5N55UP6I.js.map +0 -1
- package/dist/fuzzystrmatch-KN3YWBFP.js +0 -13
- package/dist/fuzzystrmatch-KN3YWBFP.js.map +0 -1
- package/dist/hstore-YX726NKN.js +0 -13
- package/dist/hstore-YX726NKN.js.map +0 -1
- package/dist/http-exception-FZY2H4OF.js +0 -8
- package/dist/http-exception-FZY2H4OF.js.map +0 -1
- package/dist/index.js +0 -30
- package/dist/index.js.map +0 -1
- package/dist/intarray-NKVXNO2D.js +0 -13
- package/dist/intarray-NKVXNO2D.js.map +0 -1
- package/dist/isn-FTEMJGEV.js +0 -13
- package/dist/isn-FTEMJGEV.js.map +0 -1
- package/dist/lo-DB7L4NGI.js +0 -13
- package/dist/lo-DB7L4NGI.js.map +0 -1
- package/dist/logger-WQ7SHNDD.js +0 -68
- package/dist/logger-WQ7SHNDD.js.map +0 -1
- package/dist/ltree-Z32TZT6W.js +0 -13
- package/dist/ltree-Z32TZT6W.js.map +0 -1
- package/dist/nodefs-NM46ACH7.js +0 -31
- package/dist/nodefs-NM46ACH7.js.map +0 -1
- package/dist/opfs-ahp-NJO33LVZ.js +0 -332
- package/dist/opfs-ahp-NJO33LVZ.js.map +0 -1
- package/dist/pageinspect-YP3IZR4X.js +0 -13
- package/dist/pageinspect-YP3IZR4X.js.map +0 -1
- package/dist/pg_buffercache-7TD5J2FB.js +0 -13
- package/dist/pg_buffercache-7TD5J2FB.js.map +0 -1
- package/dist/pg_dump-SG4KYBUB.js +0 -2492
- package/dist/pg_dump-SG4KYBUB.js.map +0 -1
- package/dist/pg_freespacemap-DZDNCPZK.js +0 -13
- package/dist/pg_freespacemap-DZDNCPZK.js.map +0 -1
- package/dist/pg_surgery-J2MUEWEP.js +0 -13
- package/dist/pg_surgery-J2MUEWEP.js.map +0 -1
- package/dist/pg_trgm-7VNQOYS6.js +0 -13
- package/dist/pg_trgm-7VNQOYS6.js.map +0 -1
- package/dist/pg_visibility-TTSIPHFL.js +0 -13
- package/dist/pg_visibility-TTSIPHFL.js.map +0 -1
- package/dist/pg_walinspect-KPFHSHRJ.js +0 -13
- package/dist/pg_walinspect-KPFHSHRJ.js.map +0 -1
- package/dist/proxy-signals-GUDAMDHV.js +0 -39
- package/dist/proxy-signals-GUDAMDHV.js.map +0 -1
- package/dist/seg-IYVDLE4O.js +0 -13
- package/dist/seg-IYVDLE4O.js.map +0 -1
- package/dist/src/codecs/decoding.d.ts +0 -4
- package/dist/src/codecs/decoding.d.ts.map +0 -1
- package/dist/src/codecs/encoding.d.ts +0 -5
- package/dist/src/codecs/encoding.d.ts.map +0 -1
- package/dist/src/codecs/validation.d.ts +0 -6
- package/dist/src/codecs/validation.d.ts.map +0 -1
- package/dist/src/exports/index.d.ts +0 -11
- package/dist/src/exports/index.d.ts.map +0 -1
- package/dist/src/index.d.ts +0 -2
- package/dist/src/index.d.ts.map +0 -1
- package/dist/src/lower-sql-plan.d.ts +0 -15
- package/dist/src/lower-sql-plan.d.ts.map +0 -1
- package/dist/src/sql-context.d.ts +0 -65
- package/dist/src/sql-context.d.ts.map +0 -1
- package/dist/src/sql-family-adapter.d.ts +0 -10
- package/dist/src/sql-family-adapter.d.ts.map +0 -1
- package/dist/src/sql-marker.d.ts +0 -22
- package/dist/src/sql-marker.d.ts.map +0 -1
- package/dist/src/sql-runtime.d.ts +0 -25
- package/dist/src/sql-runtime.d.ts.map +0 -1
- package/dist/tablefunc-EF4RCS7S.js +0 -13
- package/dist/tablefunc-EF4RCS7S.js.map +0 -1
- package/dist/tcn-3VT5BQYW.js +0 -13
- package/dist/tcn-3VT5BQYW.js.map +0 -1
- package/dist/test/utils.d.ts +0 -59
- package/dist/test/utils.d.ts.map +0 -1
- package/dist/test/utils.js +0 -24634
- package/dist/test/utils.js.map +0 -1
- package/dist/tiny-CW6F4GX6.js +0 -10
- package/dist/tiny-CW6F4GX6.js.map +0 -1
- package/dist/tsm_system_rows-ES7KNUQH.js +0 -13
- package/dist/tsm_system_rows-ES7KNUQH.js.map +0 -1
- package/dist/tsm_system_time-76WEIMBG.js +0 -13
- package/dist/tsm_system_time-76WEIMBG.js.map +0 -1
- package/dist/unaccent-7RYF3R64.js +0 -13
- package/dist/unaccent-7RYF3R64.js.map +0 -1
- package/dist/utility-Q5A254LJ-J4HTKZPT.js +0 -347
- package/dist/utility-Q5A254LJ-J4HTKZPT.js.map +0 -1
- package/dist/uuid_ossp-4ETE4FPE.js +0 -13
- package/dist/uuid_ossp-4ETE4FPE.js.map +0 -1
- package/dist/vector-74GPNV7V.js +0 -13
- package/dist/vector-74GPNV7V.js.map +0 -1
- package/src/index.ts +0 -1
package/src/lower-sql-plan.ts
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
import type { ExecutionPlan } from '@prisma-next/contract/types';
|
|
1
|
+
import type { Contract, ExecutionPlan } from '@prisma-next/contract/types';
|
|
2
|
+
import type { SqlStorage } from '@prisma-next/sql-contract/types';
|
|
3
|
+
import type { Adapter, AnyQueryAst, LoweredStatement } from '@prisma-next/sql-relational-core/ast';
|
|
2
4
|
import type { SqlQueryPlan } from '@prisma-next/sql-relational-core/plan';
|
|
3
|
-
import type { RuntimeContext } from './sql-context';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Lowers a SQL query plan to an executable Plan by calling the adapter's lower method.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* @param context - Runtime context containing the adapter
|
|
9
|
+
* @param adapter - Adapter to lower AST to SQL
|
|
10
|
+
* @param contract - Contract for lowering context
|
|
12
11
|
* @param queryPlan - SQL query plan from a lane (contains AST, params, meta, but no SQL)
|
|
13
12
|
* @returns Fully executable Plan with SQL string
|
|
14
13
|
*/
|
|
15
14
|
export function lowerSqlPlan<Row>(
|
|
16
|
-
|
|
15
|
+
adapter: Adapter<AnyQueryAst, Contract<SqlStorage>, LoweredStatement>,
|
|
16
|
+
contract: Contract<SqlStorage>,
|
|
17
17
|
queryPlan: SqlQueryPlan<Row>,
|
|
18
18
|
): ExecutionPlan<Row> {
|
|
19
|
-
const lowered =
|
|
20
|
-
contract
|
|
19
|
+
const lowered = adapter.lower(queryPlan.ast, {
|
|
20
|
+
contract,
|
|
21
21
|
params: queryPlan.params,
|
|
22
22
|
});
|
|
23
23
|
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import type { ExecutionPlan } from '@prisma-next/contract/types';
|
|
2
|
+
import type { AfterExecuteResult, Plugin, PluginContext } from '@prisma-next/runtime-executor';
|
|
3
|
+
import { isQueryAst, type SelectAst } from '@prisma-next/sql-relational-core/ast';
|
|
4
|
+
|
|
5
|
+
export interface BudgetsOptions {
|
|
6
|
+
readonly maxRows?: number;
|
|
7
|
+
readonly defaultTableRows?: number;
|
|
8
|
+
readonly tableRows?: Record<string, number>;
|
|
9
|
+
readonly maxLatencyMs?: number;
|
|
10
|
+
readonly severities?: {
|
|
11
|
+
readonly rowCount?: 'warn' | 'error';
|
|
12
|
+
readonly latency?: 'warn' | 'error';
|
|
13
|
+
};
|
|
14
|
+
readonly explain?: {
|
|
15
|
+
readonly enabled?: boolean;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface DriverWithExplain {
|
|
20
|
+
explain?(request: {
|
|
21
|
+
sql: string;
|
|
22
|
+
params: readonly unknown[];
|
|
23
|
+
}): Promise<{ rows: ReadonlyArray<Record<string, unknown>> }>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function computeEstimatedRows(
|
|
27
|
+
plan: ExecutionPlan,
|
|
28
|
+
driver: DriverWithExplain,
|
|
29
|
+
): Promise<number | undefined> {
|
|
30
|
+
if (typeof driver.explain !== 'function') {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const result = await driver.explain({ sql: plan.sql, params: plan.params });
|
|
36
|
+
return extractEstimatedRows(result.rows);
|
|
37
|
+
} catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function extractEstimatedRows(rows: ReadonlyArray<Record<string, unknown>>): number | undefined {
|
|
43
|
+
for (const row of rows) {
|
|
44
|
+
const estimate = findPlanRows(row);
|
|
45
|
+
if (estimate !== undefined) {
|
|
46
|
+
return estimate;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type ExplainNode = {
|
|
54
|
+
Plan?: unknown;
|
|
55
|
+
Plans?: unknown[];
|
|
56
|
+
'Plan Rows'?: number;
|
|
57
|
+
[key: string]: unknown;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function findPlanRows(node: unknown): number | undefined {
|
|
61
|
+
if (!node || typeof node !== 'object') {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const explainNode = node as ExplainNode;
|
|
66
|
+
const planRows = explainNode['Plan Rows'];
|
|
67
|
+
if (typeof planRows === 'number') {
|
|
68
|
+
return planRows;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if ('Plan' in explainNode && explainNode.Plan !== undefined) {
|
|
72
|
+
const nested = findPlanRows(explainNode.Plan);
|
|
73
|
+
if (nested !== undefined) {
|
|
74
|
+
return nested;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (Array.isArray(explainNode.Plans)) {
|
|
79
|
+
for (const child of explainNode.Plans) {
|
|
80
|
+
const nested = findPlanRows(child);
|
|
81
|
+
if (nested !== undefined) {
|
|
82
|
+
return nested;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const value of Object.values(node as Record<string, unknown>)) {
|
|
88
|
+
if (typeof value === 'object' && value !== null) {
|
|
89
|
+
const nested = findPlanRows(value);
|
|
90
|
+
if (nested !== undefined) {
|
|
91
|
+
return nested;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function budgetError(code: string, message: string, details?: Record<string, unknown>) {
|
|
100
|
+
const error = new Error(message) as Error & {
|
|
101
|
+
code: string;
|
|
102
|
+
category: 'BUDGET';
|
|
103
|
+
severity: 'error';
|
|
104
|
+
details?: Record<string, unknown>;
|
|
105
|
+
};
|
|
106
|
+
Object.defineProperty(error, 'name', {
|
|
107
|
+
value: 'RuntimeError',
|
|
108
|
+
configurable: true,
|
|
109
|
+
});
|
|
110
|
+
return Object.assign(error, {
|
|
111
|
+
code,
|
|
112
|
+
category: 'BUDGET' as const,
|
|
113
|
+
severity: 'error' as const,
|
|
114
|
+
details,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function hasAggregateWithoutGroupBy(ast: SelectAst): boolean {
|
|
119
|
+
if (ast.groupBy !== undefined) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
return ast.projection.some((item) => item.expr.kind === 'aggregate');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function estimateRowsFromAst(
|
|
126
|
+
ast: SelectAst,
|
|
127
|
+
tableRows: Record<string, number>,
|
|
128
|
+
defaultTableRows: number,
|
|
129
|
+
refs: { tables?: readonly string[] } | undefined,
|
|
130
|
+
hasAggregateWithoutGroup: boolean,
|
|
131
|
+
): number | null {
|
|
132
|
+
if (hasAggregateWithoutGroup) {
|
|
133
|
+
return 1;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const table = refs?.tables?.[0];
|
|
137
|
+
if (!table) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const tableEstimate = tableRows[table] ?? defaultTableRows;
|
|
142
|
+
|
|
143
|
+
if (ast.limit !== undefined) {
|
|
144
|
+
return Math.min(ast.limit, tableEstimate);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return tableEstimate;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function estimateRowsFromHeuristics(
|
|
151
|
+
plan: ExecutionPlan,
|
|
152
|
+
tableRows: Record<string, number>,
|
|
153
|
+
defaultTableRows: number,
|
|
154
|
+
): number | null {
|
|
155
|
+
const table = plan.meta.refs?.tables?.[0];
|
|
156
|
+
if (!table) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const tableEstimate = tableRows[table] ?? defaultTableRows;
|
|
161
|
+
|
|
162
|
+
const limit = plan.meta.annotations?.['limit'];
|
|
163
|
+
if (typeof limit === 'number') {
|
|
164
|
+
return Math.min(limit, tableEstimate);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return tableEstimate;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function hasDetectableLimitFromHeuristics(plan: ExecutionPlan): boolean {
|
|
171
|
+
return typeof plan.meta.annotations?.['limit'] === 'number';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function emitBudgetViolation(
|
|
175
|
+
error: ReturnType<typeof budgetError>,
|
|
176
|
+
shouldBlock: boolean,
|
|
177
|
+
ctx: PluginContext<unknown, unknown, unknown>,
|
|
178
|
+
): void {
|
|
179
|
+
if (shouldBlock) {
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
ctx.log.warn({
|
|
183
|
+
code: error.code,
|
|
184
|
+
message: error.message,
|
|
185
|
+
details: error.details,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function budgets<TContract = unknown, TAdapter = unknown, TDriver = unknown>(
|
|
190
|
+
options?: BudgetsOptions,
|
|
191
|
+
): Plugin<TContract, TAdapter, TDriver> {
|
|
192
|
+
const maxRows = options?.maxRows ?? 10_000;
|
|
193
|
+
const defaultTableRows = options?.defaultTableRows ?? 10_000;
|
|
194
|
+
const tableRows = options?.tableRows ?? {};
|
|
195
|
+
const maxLatencyMs = options?.maxLatencyMs ?? 1_000;
|
|
196
|
+
const rowSeverity = options?.severities?.rowCount ?? 'error';
|
|
197
|
+
|
|
198
|
+
const observedRowsByPlan = new WeakMap<ExecutionPlan, { count: number }>();
|
|
199
|
+
|
|
200
|
+
return Object.freeze({
|
|
201
|
+
name: 'budgets',
|
|
202
|
+
|
|
203
|
+
async beforeExecute(plan: ExecutionPlan, ctx: PluginContext<TContract, TAdapter, TDriver>) {
|
|
204
|
+
observedRowsByPlan.set(plan, { count: 0 });
|
|
205
|
+
|
|
206
|
+
if (isQueryAst(plan.ast)) {
|
|
207
|
+
if (plan.ast.kind === 'select') {
|
|
208
|
+
return evaluateSelectAst(plan, plan.ast, ctx);
|
|
209
|
+
}
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return evaluateWithHeuristics(plan, ctx);
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
async onRow(
|
|
217
|
+
_row: Record<string, unknown>,
|
|
218
|
+
plan: ExecutionPlan,
|
|
219
|
+
_ctx: PluginContext<TContract, TAdapter, TDriver>,
|
|
220
|
+
) {
|
|
221
|
+
const state = observedRowsByPlan.get(plan);
|
|
222
|
+
if (!state) return;
|
|
223
|
+
state.count += 1;
|
|
224
|
+
if (state.count > maxRows) {
|
|
225
|
+
throw budgetError('BUDGET.ROWS_EXCEEDED', 'Observed row count exceeds budget', {
|
|
226
|
+
source: 'observed',
|
|
227
|
+
observedRows: state.count,
|
|
228
|
+
maxRows,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
async afterExecute(
|
|
234
|
+
_plan: ExecutionPlan,
|
|
235
|
+
result: AfterExecuteResult,
|
|
236
|
+
ctx: PluginContext<TContract, TAdapter, TDriver>,
|
|
237
|
+
) {
|
|
238
|
+
const latencyMs = result.latencyMs;
|
|
239
|
+
if (latencyMs > maxLatencyMs) {
|
|
240
|
+
const shouldBlock = ctx.mode === 'strict';
|
|
241
|
+
emitBudgetViolation(
|
|
242
|
+
budgetError('BUDGET.TIME_EXCEEDED', 'Query latency exceeds budget', {
|
|
243
|
+
latencyMs,
|
|
244
|
+
maxLatencyMs,
|
|
245
|
+
}),
|
|
246
|
+
shouldBlock,
|
|
247
|
+
ctx as PluginContext<unknown, unknown, unknown>,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
function evaluateSelectAst(
|
|
254
|
+
plan: ExecutionPlan,
|
|
255
|
+
ast: SelectAst,
|
|
256
|
+
ctx: PluginContext<TContract, TAdapter, TDriver>,
|
|
257
|
+
) {
|
|
258
|
+
const hasAggNoGroup = hasAggregateWithoutGroupBy(ast);
|
|
259
|
+
const estimated = estimateRowsFromAst(
|
|
260
|
+
ast,
|
|
261
|
+
tableRows,
|
|
262
|
+
defaultTableRows,
|
|
263
|
+
plan.meta.refs,
|
|
264
|
+
hasAggNoGroup,
|
|
265
|
+
);
|
|
266
|
+
const isUnbounded = ast.limit === undefined && !hasAggNoGroup;
|
|
267
|
+
const shouldBlock = rowSeverity === 'error' || ctx.mode === 'strict';
|
|
268
|
+
|
|
269
|
+
if (isUnbounded) {
|
|
270
|
+
if (estimated !== null && estimated >= maxRows) {
|
|
271
|
+
emitBudgetViolation(
|
|
272
|
+
budgetError('BUDGET.ROWS_EXCEEDED', 'Unbounded SELECT query exceeds budget', {
|
|
273
|
+
source: 'ast',
|
|
274
|
+
estimatedRows: estimated,
|
|
275
|
+
maxRows,
|
|
276
|
+
}),
|
|
277
|
+
shouldBlock,
|
|
278
|
+
ctx as PluginContext<unknown, unknown, unknown>,
|
|
279
|
+
);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
emitBudgetViolation(
|
|
284
|
+
budgetError('BUDGET.ROWS_EXCEEDED', 'Unbounded SELECT query exceeds budget', {
|
|
285
|
+
source: 'ast',
|
|
286
|
+
maxRows,
|
|
287
|
+
}),
|
|
288
|
+
shouldBlock,
|
|
289
|
+
ctx as PluginContext<unknown, unknown, unknown>,
|
|
290
|
+
);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (estimated !== null && estimated > maxRows) {
|
|
295
|
+
emitBudgetViolation(
|
|
296
|
+
budgetError('BUDGET.ROWS_EXCEEDED', 'Estimated row count exceeds budget', {
|
|
297
|
+
source: 'ast',
|
|
298
|
+
estimatedRows: estimated,
|
|
299
|
+
maxRows,
|
|
300
|
+
}),
|
|
301
|
+
shouldBlock,
|
|
302
|
+
ctx as PluginContext<unknown, unknown, unknown>,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function evaluateWithHeuristics(
|
|
308
|
+
plan: ExecutionPlan,
|
|
309
|
+
ctx: PluginContext<TContract, TAdapter, TDriver>,
|
|
310
|
+
) {
|
|
311
|
+
const estimated = estimateRowsFromHeuristics(plan, tableRows, defaultTableRows);
|
|
312
|
+
const isUnbounded = !hasDetectableLimitFromHeuristics(plan);
|
|
313
|
+
const sqlUpper = plan.sql.trimStart().toUpperCase();
|
|
314
|
+
const isSelect = sqlUpper.startsWith('SELECT');
|
|
315
|
+
const shouldBlock = rowSeverity === 'error' || ctx.mode === 'strict';
|
|
316
|
+
|
|
317
|
+
if (isSelect && isUnbounded) {
|
|
318
|
+
if (estimated !== null && estimated >= maxRows) {
|
|
319
|
+
emitBudgetViolation(
|
|
320
|
+
budgetError('BUDGET.ROWS_EXCEEDED', 'Unbounded SELECT query exceeds budget', {
|
|
321
|
+
source: 'heuristic',
|
|
322
|
+
estimatedRows: estimated,
|
|
323
|
+
maxRows,
|
|
324
|
+
}),
|
|
325
|
+
shouldBlock,
|
|
326
|
+
ctx as PluginContext<unknown, unknown, unknown>,
|
|
327
|
+
);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
emitBudgetViolation(
|
|
332
|
+
budgetError('BUDGET.ROWS_EXCEEDED', 'Unbounded SELECT query exceeds budget', {
|
|
333
|
+
source: 'heuristic',
|
|
334
|
+
maxRows,
|
|
335
|
+
}),
|
|
336
|
+
shouldBlock,
|
|
337
|
+
ctx as PluginContext<unknown, unknown, unknown>,
|
|
338
|
+
);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (estimated !== null) {
|
|
343
|
+
if (estimated > maxRows) {
|
|
344
|
+
emitBudgetViolation(
|
|
345
|
+
budgetError('BUDGET.ROWS_EXCEEDED', 'Estimated row count exceeds budget', {
|
|
346
|
+
source: 'heuristic',
|
|
347
|
+
estimatedRows: estimated,
|
|
348
|
+
maxRows,
|
|
349
|
+
}),
|
|
350
|
+
shouldBlock,
|
|
351
|
+
ctx as PluginContext<unknown, unknown, unknown>,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const explainEnabled = options?.explain?.enabled === true;
|
|
358
|
+
if (explainEnabled && isSelect && typeof ctx.driver === 'object' && ctx.driver !== null) {
|
|
359
|
+
const estimatedRows = await computeEstimatedRows(plan, ctx.driver as DriverWithExplain);
|
|
360
|
+
if (estimatedRows !== undefined) {
|
|
361
|
+
if (estimatedRows > maxRows) {
|
|
362
|
+
emitBudgetViolation(
|
|
363
|
+
budgetError('BUDGET.ROWS_EXCEEDED', 'Estimated row count exceeds budget', {
|
|
364
|
+
source: 'explain',
|
|
365
|
+
estimatedRows,
|
|
366
|
+
maxRows,
|
|
367
|
+
}),
|
|
368
|
+
shouldBlock,
|
|
369
|
+
ctx as PluginContext<unknown, unknown, unknown>,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import type { ExecutionPlan } from '@prisma-next/contract/types';
|
|
2
|
+
import type { Plugin, PluginContext } from '@prisma-next/runtime-executor';
|
|
3
|
+
import { evaluateRawGuardrails } from '@prisma-next/runtime-executor';
|
|
4
|
+
import {
|
|
5
|
+
type AnyFromSource,
|
|
6
|
+
type AnyQueryAst,
|
|
7
|
+
isQueryAst,
|
|
8
|
+
} from '@prisma-next/sql-relational-core/ast';
|
|
9
|
+
import { ifDefined } from '@prisma-next/utils/defined';
|
|
10
|
+
|
|
11
|
+
export interface LintsOptions {
|
|
12
|
+
readonly severities?: {
|
|
13
|
+
readonly selectStar?: 'warn' | 'error';
|
|
14
|
+
readonly noLimit?: 'warn' | 'error';
|
|
15
|
+
readonly deleteWithoutWhere?: 'warn' | 'error';
|
|
16
|
+
readonly updateWithoutWhere?: 'warn' | 'error';
|
|
17
|
+
readonly readOnlyMutation?: 'warn' | 'error';
|
|
18
|
+
readonly unindexedPredicate?: 'warn' | 'error';
|
|
19
|
+
};
|
|
20
|
+
readonly fallbackWhenAstMissing?: 'raw' | 'skip';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface LintFinding {
|
|
24
|
+
readonly code: `LINT.${string}`;
|
|
25
|
+
readonly severity: 'error' | 'warn';
|
|
26
|
+
readonly message: string;
|
|
27
|
+
readonly details?: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function lintError(code: string, message: string, details?: Record<string, unknown>) {
|
|
31
|
+
const error = new Error(message) as Error & {
|
|
32
|
+
code: string;
|
|
33
|
+
category: 'LINT';
|
|
34
|
+
severity: 'error';
|
|
35
|
+
details?: Record<string, unknown>;
|
|
36
|
+
};
|
|
37
|
+
Object.defineProperty(error, 'name', {
|
|
38
|
+
value: 'RuntimeError',
|
|
39
|
+
configurable: true,
|
|
40
|
+
});
|
|
41
|
+
return Object.assign(error, {
|
|
42
|
+
code,
|
|
43
|
+
category: 'LINT' as const,
|
|
44
|
+
severity: 'error' as const,
|
|
45
|
+
details,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getFromSourceTableDetail(source: AnyFromSource): string | undefined {
|
|
50
|
+
switch (source.kind) {
|
|
51
|
+
case 'table-source':
|
|
52
|
+
return source.name;
|
|
53
|
+
case 'derived-table-source':
|
|
54
|
+
return source.alias;
|
|
55
|
+
// v8 ignore next 4
|
|
56
|
+
default:
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Unsupported source kind: ${(source satisfies never as { kind: string }).kind}`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function evaluateAstLints(ast: AnyQueryAst): LintFinding[] {
|
|
64
|
+
const findings: LintFinding[] = [];
|
|
65
|
+
|
|
66
|
+
switch (ast.kind) {
|
|
67
|
+
case 'delete':
|
|
68
|
+
if (ast.where === undefined) {
|
|
69
|
+
findings.push({
|
|
70
|
+
code: 'LINT.DELETE_WITHOUT_WHERE',
|
|
71
|
+
severity: 'error',
|
|
72
|
+
message:
|
|
73
|
+
'DELETE without WHERE clause blocks execution to prevent accidental full-table deletion',
|
|
74
|
+
details: { table: ast.table.name },
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
break;
|
|
78
|
+
|
|
79
|
+
case 'update':
|
|
80
|
+
if (ast.where === undefined) {
|
|
81
|
+
findings.push({
|
|
82
|
+
code: 'LINT.UPDATE_WITHOUT_WHERE',
|
|
83
|
+
severity: 'error',
|
|
84
|
+
message:
|
|
85
|
+
'UPDATE without WHERE clause blocks execution to prevent accidental full-table update',
|
|
86
|
+
details: { table: ast.table.name },
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
break;
|
|
90
|
+
|
|
91
|
+
case 'select':
|
|
92
|
+
if (ast.limit === undefined) {
|
|
93
|
+
const table = getFromSourceTableDetail(ast.from);
|
|
94
|
+
findings.push({
|
|
95
|
+
code: 'LINT.NO_LIMIT',
|
|
96
|
+
severity: 'warn',
|
|
97
|
+
message: 'Unbounded SELECT may return large result sets',
|
|
98
|
+
...ifDefined('details', table !== undefined ? { table } : undefined),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
if (ast.selectAllIntent !== undefined) {
|
|
102
|
+
const table = ast.selectAllIntent.table;
|
|
103
|
+
findings.push({
|
|
104
|
+
code: 'LINT.SELECT_STAR',
|
|
105
|
+
severity: 'warn',
|
|
106
|
+
message: 'Query selects all columns via selectAll intent',
|
|
107
|
+
...ifDefined('details', table !== undefined ? { table } : undefined),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
break;
|
|
111
|
+
|
|
112
|
+
case 'insert':
|
|
113
|
+
break;
|
|
114
|
+
|
|
115
|
+
// v8 ignore next 2
|
|
116
|
+
default:
|
|
117
|
+
throw new Error(`Unsupported AST kind: ${(ast satisfies never as { kind: string }).kind}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return findings;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function getConfiguredSeverity(code: string, options?: LintsOptions): 'warn' | 'error' | undefined {
|
|
124
|
+
const severities = options?.severities;
|
|
125
|
+
if (!severities) return undefined;
|
|
126
|
+
|
|
127
|
+
switch (code) {
|
|
128
|
+
case 'LINT.SELECT_STAR':
|
|
129
|
+
return severities.selectStar;
|
|
130
|
+
case 'LINT.NO_LIMIT':
|
|
131
|
+
return severities.noLimit;
|
|
132
|
+
case 'LINT.DELETE_WITHOUT_WHERE':
|
|
133
|
+
return severities.deleteWithoutWhere;
|
|
134
|
+
case 'LINT.UPDATE_WITHOUT_WHERE':
|
|
135
|
+
return severities.updateWithoutWhere;
|
|
136
|
+
case 'LINT.READ_ONLY_MUTATION':
|
|
137
|
+
return severities.readOnlyMutation;
|
|
138
|
+
case 'LINT.UNINDEXED_PREDICATE':
|
|
139
|
+
return severities.unindexedPredicate;
|
|
140
|
+
default:
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* AST-first lint plugin for SQL plans. When `plan.ast` is a SQL QueryAst, inspects
|
|
147
|
+
* the AST structurally. When `plan.ast` is missing, falls back to raw heuristic
|
|
148
|
+
* guardrails or skips linting depending on `fallbackWhenAstMissing`.
|
|
149
|
+
*
|
|
150
|
+
* Rules (AST-based):
|
|
151
|
+
* - DELETE without WHERE: blocks execution (configurable severity, default error)
|
|
152
|
+
* - UPDATE without WHERE: blocks execution (configurable severity, default error)
|
|
153
|
+
* - Unbounded SELECT: warn/error (severity from noLimit)
|
|
154
|
+
* - SELECT * intent: warn/error (severity from selectStar)
|
|
155
|
+
*
|
|
156
|
+
* Fallback: When ast is missing, `fallbackWhenAstMissing: 'raw'` uses heuristic
|
|
157
|
+
* SQL parsing; `'skip'` skips all lints. Default is `'raw'`.
|
|
158
|
+
*/
|
|
159
|
+
export function lints<TContract = unknown, TAdapter = unknown, TDriver = unknown>(
|
|
160
|
+
options?: LintsOptions,
|
|
161
|
+
): Plugin<TContract, TAdapter, TDriver> {
|
|
162
|
+
const fallback = options?.fallbackWhenAstMissing ?? 'raw';
|
|
163
|
+
|
|
164
|
+
return Object.freeze({
|
|
165
|
+
name: 'lints',
|
|
166
|
+
|
|
167
|
+
async beforeExecute(plan: ExecutionPlan, ctx: PluginContext<TContract, TAdapter, TDriver>) {
|
|
168
|
+
if (isQueryAst(plan.ast)) {
|
|
169
|
+
const findings = evaluateAstLints(plan.ast);
|
|
170
|
+
|
|
171
|
+
for (const lint of findings) {
|
|
172
|
+
const configuredSeverity = getConfiguredSeverity(lint.code, options);
|
|
173
|
+
const effectiveSeverity = configuredSeverity ?? lint.severity;
|
|
174
|
+
|
|
175
|
+
if (effectiveSeverity === 'error') {
|
|
176
|
+
throw lintError(lint.code, lint.message, lint.details);
|
|
177
|
+
}
|
|
178
|
+
if (effectiveSeverity === 'warn') {
|
|
179
|
+
ctx.log.warn({
|
|
180
|
+
code: lint.code,
|
|
181
|
+
message: lint.message,
|
|
182
|
+
details: lint.details,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (fallback === 'skip') {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const evaluation = evaluateRawGuardrails(plan);
|
|
194
|
+
for (const lint of evaluation.lints) {
|
|
195
|
+
const configuredSeverity = getConfiguredSeverity(lint.code, options);
|
|
196
|
+
const effectiveSeverity = configuredSeverity ?? lint.severity;
|
|
197
|
+
|
|
198
|
+
if (effectiveSeverity === 'error') {
|
|
199
|
+
throw lintError(lint.code, lint.message, lint.details);
|
|
200
|
+
}
|
|
201
|
+
if (effectiveSeverity === 'warn') {
|
|
202
|
+
ctx.log.warn({
|
|
203
|
+
code: lint.code,
|
|
204
|
+
message: lint.message,
|
|
205
|
+
details: lint.details,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
}
|