@prisma-next/sql-runtime 0.3.0-dev.14 → 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 +101 -67
- 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 -60
- package/dist/test/utils.d.ts.map +0 -1
- package/dist/test/utils.js +0 -24635
- 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
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import type { ExecutionPlan, PlanMeta } from '@prisma-next/contract/types';
|
|
2
|
+
import type { AfterExecuteResult, PluginContext } from '@prisma-next/runtime-executor';
|
|
3
|
+
import {
|
|
4
|
+
AggregateExpr,
|
|
5
|
+
ColumnRef,
|
|
6
|
+
DeleteAst,
|
|
7
|
+
ProjectionItem,
|
|
8
|
+
SelectAst,
|
|
9
|
+
TableSource,
|
|
10
|
+
} from '@prisma-next/sql-relational-core/ast';
|
|
11
|
+
import { timeouts } from '@prisma-next/test-utils';
|
|
12
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
13
|
+
import { budgets } from '../src/plugins/budgets';
|
|
14
|
+
|
|
15
|
+
const userTable = TableSource.named('user');
|
|
16
|
+
const idCol = ColumnRef.of('user', 'id');
|
|
17
|
+
|
|
18
|
+
function createPluginContext(
|
|
19
|
+
overrides?: Partial<PluginContext<unknown, unknown, unknown>>,
|
|
20
|
+
): PluginContext<unknown, unknown, unknown> {
|
|
21
|
+
return {
|
|
22
|
+
contract: {},
|
|
23
|
+
adapter: {},
|
|
24
|
+
driver: {},
|
|
25
|
+
mode: 'strict' as const,
|
|
26
|
+
now: () => Date.now(),
|
|
27
|
+
log: {
|
|
28
|
+
info: vi.fn(),
|
|
29
|
+
warn: vi.fn(),
|
|
30
|
+
error: vi.fn(),
|
|
31
|
+
},
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const baseMeta: PlanMeta = {
|
|
37
|
+
target: 'postgres',
|
|
38
|
+
storageHash: 'sha256:test',
|
|
39
|
+
lane: 'dsl',
|
|
40
|
+
paramDescriptors: [],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type PlanOverrides = Partial<Omit<ExecutionPlan, 'meta'>> & { meta?: Partial<PlanMeta> };
|
|
44
|
+
|
|
45
|
+
function createPlan(overrides: PlanOverrides): ExecutionPlan {
|
|
46
|
+
const { meta: metaOverrides, ...rest } = overrides;
|
|
47
|
+
return {
|
|
48
|
+
sql: 'SELECT 1',
|
|
49
|
+
params: [],
|
|
50
|
+
meta: { ...baseMeta, ...(metaOverrides ?? {}) } as unknown as PlanMeta,
|
|
51
|
+
...rest,
|
|
52
|
+
} as unknown as ExecutionPlan;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('budgets plugin', () => {
|
|
56
|
+
describe('heuristic row budget (no AST)', () => {
|
|
57
|
+
it(
|
|
58
|
+
'throws for unbounded raw SELECT exceeding budget',
|
|
59
|
+
async () => {
|
|
60
|
+
const plan = createPlan({
|
|
61
|
+
sql: 'SELECT id, email FROM "user"',
|
|
62
|
+
meta: { refs: { tables: ['user'] } },
|
|
63
|
+
});
|
|
64
|
+
const plugin = budgets({ maxRows: 50, defaultTableRows: 10_000 });
|
|
65
|
+
const ctx = createPluginContext();
|
|
66
|
+
|
|
67
|
+
await expect(plugin.beforeExecute?.(plan, ctx)).rejects.toMatchObject({
|
|
68
|
+
code: 'BUDGET.ROWS_EXCEEDED',
|
|
69
|
+
category: 'BUDGET',
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
timeouts.default,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
it(
|
|
76
|
+
'throws for unbounded raw SELECT even without table refs',
|
|
77
|
+
async () => {
|
|
78
|
+
const plan = createPlan({
|
|
79
|
+
sql: 'SELECT 1',
|
|
80
|
+
});
|
|
81
|
+
const plugin = budgets({ maxRows: 50 });
|
|
82
|
+
const ctx = createPluginContext();
|
|
83
|
+
|
|
84
|
+
await expect(plugin.beforeExecute?.(plan, ctx)).rejects.toMatchObject({
|
|
85
|
+
code: 'BUDGET.ROWS_EXCEEDED',
|
|
86
|
+
category: 'BUDGET',
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
timeouts.default,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
it(
|
|
93
|
+
'allows bounded raw SELECT with limit annotation within budget',
|
|
94
|
+
async () => {
|
|
95
|
+
const plan = createPlan({
|
|
96
|
+
sql: 'SELECT id FROM "user" LIMIT 5',
|
|
97
|
+
meta: {
|
|
98
|
+
refs: { tables: ['user'] },
|
|
99
|
+
annotations: { limit: 5 },
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
const plugin = budgets({ maxRows: 10_000, defaultTableRows: 10_000 });
|
|
103
|
+
const ctx = createPluginContext();
|
|
104
|
+
|
|
105
|
+
await plugin.beforeExecute?.(plan, ctx);
|
|
106
|
+
expect(ctx.log.warn).not.toHaveBeenCalled();
|
|
107
|
+
},
|
|
108
|
+
timeouts.default,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
it(
|
|
112
|
+
'throws when estimated rows exceed budget for bounded query',
|
|
113
|
+
async () => {
|
|
114
|
+
const plan = createPlan({
|
|
115
|
+
sql: 'SELECT id FROM "user" LIMIT 500',
|
|
116
|
+
meta: {
|
|
117
|
+
refs: { tables: ['user'] },
|
|
118
|
+
annotations: { limit: 500 },
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
const plugin = budgets({ maxRows: 50, defaultTableRows: 10_000 });
|
|
122
|
+
const ctx = createPluginContext();
|
|
123
|
+
|
|
124
|
+
await expect(plugin.beforeExecute?.(plan, ctx)).rejects.toMatchObject({
|
|
125
|
+
code: 'BUDGET.ROWS_EXCEEDED',
|
|
126
|
+
category: 'BUDGET',
|
|
127
|
+
details: expect.objectContaining({ source: 'heuristic' }),
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
timeouts.default,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
it(
|
|
134
|
+
'uses tableRows config for estimation',
|
|
135
|
+
async () => {
|
|
136
|
+
const plan = createPlan({
|
|
137
|
+
sql: 'SELECT id FROM "user"',
|
|
138
|
+
meta: { refs: { tables: ['user'] } },
|
|
139
|
+
});
|
|
140
|
+
const plugin = budgets({ maxRows: 100, tableRows: { user: 50 } });
|
|
141
|
+
const ctx = createPluginContext();
|
|
142
|
+
|
|
143
|
+
await expect(plugin.beforeExecute?.(plan, ctx)).rejects.toMatchObject({
|
|
144
|
+
code: 'BUDGET.ROWS_EXCEEDED',
|
|
145
|
+
});
|
|
146
|
+
},
|
|
147
|
+
timeouts.default,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
it(
|
|
151
|
+
'does not check row budget for non-SELECT statements',
|
|
152
|
+
async () => {
|
|
153
|
+
const plan = createPlan({
|
|
154
|
+
sql: 'INSERT INTO "user" (id, email) VALUES ($1, $2)',
|
|
155
|
+
});
|
|
156
|
+
const plugin = budgets({ maxRows: 1 });
|
|
157
|
+
const ctx = createPluginContext();
|
|
158
|
+
|
|
159
|
+
await plugin.beforeExecute?.(plan, ctx);
|
|
160
|
+
},
|
|
161
|
+
timeouts.default,
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('observed row count (onRow)', () => {
|
|
166
|
+
it(
|
|
167
|
+
'throws when observed rows exceed budget',
|
|
168
|
+
async () => {
|
|
169
|
+
const plugin = budgets({ maxRows: 2 });
|
|
170
|
+
const plan = createPlan({
|
|
171
|
+
sql: 'INSERT INTO "user" (id) VALUES ($1)',
|
|
172
|
+
});
|
|
173
|
+
const ctx = createPluginContext();
|
|
174
|
+
|
|
175
|
+
await plugin.beforeExecute?.(plan, ctx);
|
|
176
|
+
await plugin.onRow?.({}, plan, ctx);
|
|
177
|
+
await plugin.onRow?.({}, plan, ctx);
|
|
178
|
+
await expect(plugin.onRow?.({}, plan, ctx)).rejects.toMatchObject({
|
|
179
|
+
code: 'BUDGET.ROWS_EXCEEDED',
|
|
180
|
+
details: expect.objectContaining({ source: 'observed' }),
|
|
181
|
+
});
|
|
182
|
+
},
|
|
183
|
+
timeouts.default,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
it(
|
|
187
|
+
'tracks row counts independently per execution plan',
|
|
188
|
+
async () => {
|
|
189
|
+
const plugin = budgets({ maxRows: 2 });
|
|
190
|
+
const planA = createPlan({ sql: 'INSERT INTO "user" (id) VALUES ($1)' });
|
|
191
|
+
const planB = createPlan({ sql: 'INSERT INTO "user" (id) VALUES ($2)' });
|
|
192
|
+
const ctxA = createPluginContext();
|
|
193
|
+
const ctxB = createPluginContext();
|
|
194
|
+
|
|
195
|
+
await plugin.beforeExecute?.(planA, ctxA);
|
|
196
|
+
await plugin.beforeExecute?.(planB, ctxB);
|
|
197
|
+
|
|
198
|
+
await plugin.onRow?.({}, planA, ctxA);
|
|
199
|
+
await plugin.onRow?.({}, planB, ctxB);
|
|
200
|
+
await plugin.onRow?.({}, planA, ctxA);
|
|
201
|
+
await plugin.onRow?.({}, planB, ctxB);
|
|
202
|
+
|
|
203
|
+
await expect(plugin.onRow?.({}, planA, ctxA)).rejects.toMatchObject({
|
|
204
|
+
code: 'BUDGET.ROWS_EXCEEDED',
|
|
205
|
+
});
|
|
206
|
+
await expect(plugin.onRow?.({}, planB, ctxB)).rejects.toMatchObject({
|
|
207
|
+
code: 'BUDGET.ROWS_EXCEEDED',
|
|
208
|
+
});
|
|
209
|
+
},
|
|
210
|
+
timeouts.default,
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe('latency budget (afterExecute)', () => {
|
|
215
|
+
it(
|
|
216
|
+
'warns when latency exceeds budget in non-strict mode',
|
|
217
|
+
async () => {
|
|
218
|
+
const plugin = budgets({ maxLatencyMs: 100, severities: { latency: 'warn' } });
|
|
219
|
+
const plan = createPlan({ sql: 'SELECT 1', meta: { annotations: { limit: 1 } } });
|
|
220
|
+
const ctx = createPluginContext({ mode: 'permissive' });
|
|
221
|
+
const result: AfterExecuteResult = { rowCount: 1, latencyMs: 200, completed: true };
|
|
222
|
+
|
|
223
|
+
await plugin.afterExecute?.(plan, result, ctx);
|
|
224
|
+
expect(ctx.log.warn).toHaveBeenCalledWith(
|
|
225
|
+
expect.objectContaining({ code: 'BUDGET.TIME_EXCEEDED' }),
|
|
226
|
+
);
|
|
227
|
+
},
|
|
228
|
+
timeouts.default,
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
it(
|
|
232
|
+
'throws when latency exceeds budget in strict mode with error severity',
|
|
233
|
+
async () => {
|
|
234
|
+
const plugin = budgets({ maxLatencyMs: 100, severities: { latency: 'error' } });
|
|
235
|
+
const plan = createPlan({ sql: 'SELECT 1', meta: { annotations: { limit: 1 } } });
|
|
236
|
+
const ctx = createPluginContext({ mode: 'strict' });
|
|
237
|
+
const result: AfterExecuteResult = { rowCount: 1, latencyMs: 200, completed: true };
|
|
238
|
+
|
|
239
|
+
await expect(plugin.afterExecute?.(plan, result, ctx)).rejects.toMatchObject({
|
|
240
|
+
code: 'BUDGET.TIME_EXCEEDED',
|
|
241
|
+
category: 'BUDGET',
|
|
242
|
+
});
|
|
243
|
+
},
|
|
244
|
+
timeouts.default,
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
it(
|
|
248
|
+
'throws when latency exceeds budget in strict mode even with warn severity',
|
|
249
|
+
async () => {
|
|
250
|
+
const plugin = budgets({ maxLatencyMs: 100, severities: { latency: 'warn' } });
|
|
251
|
+
const plan = createPlan({ sql: 'SELECT 1', meta: { annotations: { limit: 1 } } });
|
|
252
|
+
const ctx = createPluginContext({ mode: 'strict' });
|
|
253
|
+
const result: AfterExecuteResult = { rowCount: 1, latencyMs: 200, completed: true };
|
|
254
|
+
|
|
255
|
+
await expect(plugin.afterExecute?.(plan, result, ctx)).rejects.toMatchObject({
|
|
256
|
+
code: 'BUDGET.TIME_EXCEEDED',
|
|
257
|
+
category: 'BUDGET',
|
|
258
|
+
});
|
|
259
|
+
},
|
|
260
|
+
timeouts.default,
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
it(
|
|
264
|
+
'does not warn when latency is within budget',
|
|
265
|
+
async () => {
|
|
266
|
+
const plugin = budgets({ maxLatencyMs: 1000 });
|
|
267
|
+
const plan = createPlan({ sql: 'SELECT 1', meta: { annotations: { limit: 1 } } });
|
|
268
|
+
const ctx = createPluginContext();
|
|
269
|
+
const result: AfterExecuteResult = { rowCount: 1, latencyMs: 50, completed: true };
|
|
270
|
+
|
|
271
|
+
await plugin.afterExecute?.(plan, result, ctx);
|
|
272
|
+
expect(ctx.log.warn).not.toHaveBeenCalled();
|
|
273
|
+
},
|
|
274
|
+
timeouts.default,
|
|
275
|
+
);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe('EXPLAIN fallback', () => {
|
|
279
|
+
it(
|
|
280
|
+
'uses EXPLAIN when enabled and driver supports it',
|
|
281
|
+
async () => {
|
|
282
|
+
const explainDriver = {
|
|
283
|
+
explain: vi.fn().mockResolvedValue({
|
|
284
|
+
rows: [{ 'Plan Rows': 50_000 }],
|
|
285
|
+
}),
|
|
286
|
+
};
|
|
287
|
+
const plan = createPlan({
|
|
288
|
+
sql: 'SELECT id FROM "user" LIMIT 100',
|
|
289
|
+
params: ['a', 'b'],
|
|
290
|
+
meta: { annotations: { limit: 100 } },
|
|
291
|
+
});
|
|
292
|
+
const plugin = budgets({ maxRows: 10_000, explain: { enabled: true } });
|
|
293
|
+
const ctx = createPluginContext({ driver: explainDriver });
|
|
294
|
+
|
|
295
|
+
await expect(plugin.beforeExecute?.(plan, ctx)).rejects.toMatchObject({
|
|
296
|
+
code: 'BUDGET.ROWS_EXCEEDED',
|
|
297
|
+
details: expect.objectContaining({ source: 'explain' }),
|
|
298
|
+
});
|
|
299
|
+
expect(explainDriver.explain).toHaveBeenCalledWith({
|
|
300
|
+
sql: plan.sql,
|
|
301
|
+
params: plan.params,
|
|
302
|
+
});
|
|
303
|
+
},
|
|
304
|
+
timeouts.default,
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
it(
|
|
308
|
+
'falls back gracefully when EXPLAIN fails',
|
|
309
|
+
async () => {
|
|
310
|
+
const explainDriver = {
|
|
311
|
+
explain: vi.fn().mockRejectedValue(new Error('EXPLAIN failed')),
|
|
312
|
+
};
|
|
313
|
+
const plan = createPlan({
|
|
314
|
+
sql: 'SELECT id FROM "user" LIMIT 100',
|
|
315
|
+
meta: { annotations: { limit: 100 } },
|
|
316
|
+
});
|
|
317
|
+
const plugin = budgets({ maxRows: 10_000, explain: { enabled: true } });
|
|
318
|
+
const ctx = createPluginContext({ driver: explainDriver });
|
|
319
|
+
|
|
320
|
+
await plugin.beforeExecute?.(plan, ctx);
|
|
321
|
+
},
|
|
322
|
+
timeouts.default,
|
|
323
|
+
);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
describe('severity configuration', () => {
|
|
327
|
+
it(
|
|
328
|
+
'warns instead of throwing when rowCount severity is warn and mode is permissive',
|
|
329
|
+
async () => {
|
|
330
|
+
const plan = createPlan({
|
|
331
|
+
sql: 'SELECT id FROM "user"',
|
|
332
|
+
meta: { refs: { tables: ['user'] } },
|
|
333
|
+
});
|
|
334
|
+
const plugin = budgets({
|
|
335
|
+
maxRows: 50,
|
|
336
|
+
defaultTableRows: 10_000,
|
|
337
|
+
severities: { rowCount: 'warn' },
|
|
338
|
+
});
|
|
339
|
+
const ctx = createPluginContext({ mode: 'permissive' });
|
|
340
|
+
|
|
341
|
+
await plugin.beforeExecute?.(plan, ctx);
|
|
342
|
+
expect(ctx.log.warn).toHaveBeenCalledWith(
|
|
343
|
+
expect.objectContaining({ code: 'BUDGET.ROWS_EXCEEDED' }),
|
|
344
|
+
);
|
|
345
|
+
},
|
|
346
|
+
timeouts.default,
|
|
347
|
+
);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
describe('AST-based row budget', () => {
|
|
351
|
+
it(
|
|
352
|
+
'allows bounded SelectAst with limit within budget',
|
|
353
|
+
async () => {
|
|
354
|
+
const ast = SelectAst.from(userTable)
|
|
355
|
+
.withProjection([ProjectionItem.of('id', idCol)])
|
|
356
|
+
.withLimit(5);
|
|
357
|
+
const plan = createPlan({
|
|
358
|
+
ast,
|
|
359
|
+
meta: { refs: { tables: ['user'] } },
|
|
360
|
+
});
|
|
361
|
+
const plugin = budgets({ maxRows: 10_000, defaultTableRows: 10_000 });
|
|
362
|
+
const ctx = createPluginContext();
|
|
363
|
+
|
|
364
|
+
await plugin.beforeExecute?.(plan, ctx);
|
|
365
|
+
expect(ctx.log.warn).not.toHaveBeenCalled();
|
|
366
|
+
},
|
|
367
|
+
timeouts.default,
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
it(
|
|
371
|
+
'throws for unbounded SelectAst without limit',
|
|
372
|
+
async () => {
|
|
373
|
+
const ast = SelectAst.from(userTable).withProjection([ProjectionItem.of('id', idCol)]);
|
|
374
|
+
const plan = createPlan({
|
|
375
|
+
ast,
|
|
376
|
+
meta: { refs: { tables: ['user'] } },
|
|
377
|
+
});
|
|
378
|
+
const plugin = budgets({ maxRows: 50, defaultTableRows: 10_000 });
|
|
379
|
+
const ctx = createPluginContext();
|
|
380
|
+
|
|
381
|
+
await expect(plugin.beforeExecute?.(plan, ctx)).rejects.toMatchObject({
|
|
382
|
+
code: 'BUDGET.ROWS_EXCEEDED',
|
|
383
|
+
category: 'BUDGET',
|
|
384
|
+
details: expect.objectContaining({ source: 'ast' }),
|
|
385
|
+
});
|
|
386
|
+
},
|
|
387
|
+
timeouts.default,
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
it(
|
|
391
|
+
'throws for unbounded SelectAst without table refs',
|
|
392
|
+
async () => {
|
|
393
|
+
const ast = SelectAst.from(userTable).withProjection([ProjectionItem.of('id', idCol)]);
|
|
394
|
+
const plan = createPlan({ ast });
|
|
395
|
+
const plugin = budgets({ maxRows: 50 });
|
|
396
|
+
const ctx = createPluginContext();
|
|
397
|
+
|
|
398
|
+
await expect(plugin.beforeExecute?.(plan, ctx)).rejects.toMatchObject({
|
|
399
|
+
code: 'BUDGET.ROWS_EXCEEDED',
|
|
400
|
+
details: expect.objectContaining({ source: 'ast' }),
|
|
401
|
+
});
|
|
402
|
+
},
|
|
403
|
+
timeouts.default,
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
it(
|
|
407
|
+
'reads limit from AST, not from annotations',
|
|
408
|
+
async () => {
|
|
409
|
+
const ast = SelectAst.from(userTable)
|
|
410
|
+
.withProjection([ProjectionItem.of('id', idCol)])
|
|
411
|
+
.withLimit(5);
|
|
412
|
+
const plan = createPlan({
|
|
413
|
+
ast,
|
|
414
|
+
meta: {
|
|
415
|
+
refs: { tables: ['user'] },
|
|
416
|
+
annotations: { limit: 99999 },
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
const plugin = budgets({ maxRows: 10_000, defaultTableRows: 10_000 });
|
|
420
|
+
const ctx = createPluginContext();
|
|
421
|
+
|
|
422
|
+
await plugin.beforeExecute?.(plan, ctx);
|
|
423
|
+
expect(ctx.log.warn).not.toHaveBeenCalled();
|
|
424
|
+
},
|
|
425
|
+
timeouts.default,
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
it(
|
|
429
|
+
'does not check row budget for non-SelectAst (e.g. DeleteAst)',
|
|
430
|
+
async () => {
|
|
431
|
+
const ast = DeleteAst.from(userTable);
|
|
432
|
+
const plan = createPlan({ ast });
|
|
433
|
+
const plugin = budgets({ maxRows: 1 });
|
|
434
|
+
const ctx = createPluginContext();
|
|
435
|
+
|
|
436
|
+
await plugin.beforeExecute?.(plan, ctx);
|
|
437
|
+
},
|
|
438
|
+
timeouts.default,
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
it(
|
|
442
|
+
'estimates 1 row for aggregate without GROUP BY',
|
|
443
|
+
async () => {
|
|
444
|
+
const ast = SelectAst.from(userTable).withProjection([
|
|
445
|
+
ProjectionItem.of('count', AggregateExpr.count()),
|
|
446
|
+
]);
|
|
447
|
+
const plan = createPlan({
|
|
448
|
+
ast,
|
|
449
|
+
meta: { refs: { tables: ['user'] } },
|
|
450
|
+
});
|
|
451
|
+
const plugin = budgets({ maxRows: 1, defaultTableRows: 10_000 });
|
|
452
|
+
const ctx = createPluginContext();
|
|
453
|
+
|
|
454
|
+
await plugin.beforeExecute?.(plan, ctx);
|
|
455
|
+
expect(ctx.log.warn).not.toHaveBeenCalled();
|
|
456
|
+
},
|
|
457
|
+
timeouts.default,
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
it(
|
|
461
|
+
'does not reduce estimate for aggregate with GROUP BY',
|
|
462
|
+
async () => {
|
|
463
|
+
const ast = SelectAst.from(userTable)
|
|
464
|
+
.withProjection([ProjectionItem.of('count', AggregateExpr.count())])
|
|
465
|
+
.withGroupBy([idCol]);
|
|
466
|
+
const plan = createPlan({
|
|
467
|
+
ast,
|
|
468
|
+
meta: { refs: { tables: ['user'] } },
|
|
469
|
+
});
|
|
470
|
+
const plugin = budgets({ maxRows: 50, defaultTableRows: 10_000 });
|
|
471
|
+
const ctx = createPluginContext();
|
|
472
|
+
|
|
473
|
+
await expect(plugin.beforeExecute?.(plan, ctx)).rejects.toMatchObject({
|
|
474
|
+
code: 'BUDGET.ROWS_EXCEEDED',
|
|
475
|
+
details: expect.objectContaining({ source: 'ast' }),
|
|
476
|
+
});
|
|
477
|
+
},
|
|
478
|
+
timeouts.default,
|
|
479
|
+
);
|
|
480
|
+
});
|
|
481
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Contract, StorageHashBase } from '@prisma-next/contract/types';
|
|
2
|
+
import type { SqlStorage } from '@prisma-next/sql-contract/types';
|
|
3
|
+
import { expectTypeOf, test } from 'vitest';
|
|
4
|
+
import type { ExecutionContext, TypeHelperRegistry } from '../src/sql-context';
|
|
5
|
+
|
|
6
|
+
// Contract type with storage.types using literal types (matching emission output)
|
|
7
|
+
type TestContract = Contract<
|
|
8
|
+
{
|
|
9
|
+
readonly storageHash: StorageHashBase<string>;
|
|
10
|
+
readonly tables: {
|
|
11
|
+
readonly document: {
|
|
12
|
+
readonly columns: {
|
|
13
|
+
readonly id: {
|
|
14
|
+
readonly nativeType: 'int4';
|
|
15
|
+
readonly codecId: 'pg/int4@1';
|
|
16
|
+
nullable: false;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
readonly primaryKey: { readonly columns: readonly ['id'] };
|
|
20
|
+
readonly uniques: readonly [];
|
|
21
|
+
readonly indexes: readonly [];
|
|
22
|
+
readonly foreignKeys: readonly [];
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
readonly types: {
|
|
26
|
+
readonly Vector1536: {
|
|
27
|
+
readonly codecId: 'pg/vector@1';
|
|
28
|
+
readonly nativeType: 'vector';
|
|
29
|
+
readonly typeParams: { readonly length: 1536 };
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
Record<string, never>
|
|
34
|
+
>;
|
|
35
|
+
|
|
36
|
+
test('ExecutionContext.types is TypeHelperRegistry', () => {
|
|
37
|
+
// ExecutionContext.types is intentionally loose (Record<string, unknown>)
|
|
38
|
+
// The strong typing comes from schema(context).types via ExtractSchemaTypes
|
|
39
|
+
expectTypeOf<ExecutionContext<TestContract>['types']>().toEqualTypeOf<TypeHelperRegistry>();
|
|
40
|
+
|
|
41
|
+
// TypeHelperRegistry allows any values - the actual type depends on init hooks
|
|
42
|
+
expectTypeOf<TypeHelperRegistry>().toEqualTypeOf<Record<string, unknown>>();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('ExecutionContext preserves contract type parameter', () => {
|
|
46
|
+
// Verify the contract type is preserved in ExecutionContext
|
|
47
|
+
expectTypeOf<ExecutionContext<TestContract>['contract']>().toEqualTypeOf<TestContract>();
|
|
48
|
+
|
|
49
|
+
// Verify we can access storage.types through the context's contract
|
|
50
|
+
type ContractStorageTypes = ExecutionContext<TestContract>['contract']['storage']['types'];
|
|
51
|
+
expectTypeOf<ContractStorageTypes>().toExtend<
|
|
52
|
+
| {
|
|
53
|
+
readonly Vector1536: {
|
|
54
|
+
readonly codecId: 'pg/vector@1';
|
|
55
|
+
readonly nativeType: 'vector';
|
|
56
|
+
readonly typeParams: { readonly length: 1536 };
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
| undefined
|
|
60
|
+
>();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('ExecutionContext accepts generic Contract', () => {
|
|
64
|
+
// Verify ExecutionContext defaults work
|
|
65
|
+
type DefaultContext = ExecutionContext;
|
|
66
|
+
expectTypeOf<DefaultContext['contract']>().toExtend<Contract<SqlStorage>>();
|
|
67
|
+
expectTypeOf<DefaultContext['types']>().toEqualTypeOf<TypeHelperRegistry>();
|
|
68
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { createExecutionStack } from '@prisma-next/framework-components/execution';
|
|
2
|
+
import { codec, createCodecRegistry } from '@prisma-next/sql-relational-core/ast';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { createExecutionContext, createSqlExecutionStack } from '../src/exports';
|
|
5
|
+
import type {
|
|
6
|
+
ExecutionContext,
|
|
7
|
+
SqlRuntimeAdapterDescriptor,
|
|
8
|
+
SqlRuntimeExtensionDescriptor,
|
|
9
|
+
SqlRuntimeTargetDescriptor,
|
|
10
|
+
} from '../src/sql-context';
|
|
11
|
+
import { createTestContract } from './utils';
|
|
12
|
+
|
|
13
|
+
function createStubAdapterDescriptor(): SqlRuntimeAdapterDescriptor<'postgres'> {
|
|
14
|
+
const registry = createCodecRegistry();
|
|
15
|
+
registry.register(
|
|
16
|
+
codec({
|
|
17
|
+
typeId: 'pg/text@1',
|
|
18
|
+
targetTypes: ['text'],
|
|
19
|
+
encode: (value: string) => value,
|
|
20
|
+
decode: (wire: string) => wire,
|
|
21
|
+
}),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
kind: 'adapter',
|
|
26
|
+
id: 'test-adapter',
|
|
27
|
+
version: '0.0.1',
|
|
28
|
+
familyId: 'sql' as const,
|
|
29
|
+
targetId: 'postgres' as const,
|
|
30
|
+
codecs: () => registry,
|
|
31
|
+
parameterizedCodecs: () => [],
|
|
32
|
+
create() {
|
|
33
|
+
return Object.assign(
|
|
34
|
+
{ familyId: 'sql' as const, targetId: 'postgres' as const },
|
|
35
|
+
{
|
|
36
|
+
profile: {
|
|
37
|
+
id: 'test-profile',
|
|
38
|
+
target: 'postgres',
|
|
39
|
+
capabilities: {},
|
|
40
|
+
codecs: () => registry,
|
|
41
|
+
readMarkerStatement: () => ({ sql: '', params: [] }),
|
|
42
|
+
},
|
|
43
|
+
lower() {
|
|
44
|
+
return {
|
|
45
|
+
profileId: 'test-profile',
|
|
46
|
+
body: Object.freeze({ sql: '', params: [] }),
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function createStubTargetDescriptor(): SqlRuntimeTargetDescriptor<'postgres'> {
|
|
56
|
+
return {
|
|
57
|
+
kind: 'target',
|
|
58
|
+
id: 'postgres',
|
|
59
|
+
version: '0.0.1',
|
|
60
|
+
familyId: 'sql' as const,
|
|
61
|
+
targetId: 'postgres' as const,
|
|
62
|
+
codecs: () => createCodecRegistry(),
|
|
63
|
+
parameterizedCodecs: () => [],
|
|
64
|
+
create() {
|
|
65
|
+
return { familyId: 'sql' as const, targetId: 'postgres' as const };
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function createStubExtensionDescriptor(): SqlRuntimeExtensionDescriptor<'postgres'> {
|
|
71
|
+
const registry = createCodecRegistry();
|
|
72
|
+
registry.register(
|
|
73
|
+
codec({
|
|
74
|
+
typeId: 'pg/uuid@1',
|
|
75
|
+
targetTypes: ['uuid'],
|
|
76
|
+
encode: (value: string) => value,
|
|
77
|
+
decode: (wire: string) => wire,
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const operations = [
|
|
82
|
+
{
|
|
83
|
+
method: 'example',
|
|
84
|
+
args: [] as const,
|
|
85
|
+
returns: { codecId: 'pg/text@1', nullable: false },
|
|
86
|
+
lowering: {
|
|
87
|
+
targetFamily: 'sql' as const,
|
|
88
|
+
strategy: 'function' as const,
|
|
89
|
+
template: 'example({args})',
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
kind: 'extension',
|
|
96
|
+
id: 'test-extension',
|
|
97
|
+
version: '0.0.1',
|
|
98
|
+
familyId: 'sql' as const,
|
|
99
|
+
targetId: 'postgres' as const,
|
|
100
|
+
codecs: () => registry,
|
|
101
|
+
queryOperations: () => operations,
|
|
102
|
+
parameterizedCodecs: () => [],
|
|
103
|
+
create() {
|
|
104
|
+
return {
|
|
105
|
+
familyId: 'sql' as const,
|
|
106
|
+
targetId: 'postgres' as const,
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
describe('createExecutionStack', () => {
|
|
113
|
+
it('defaults driver to undefined and extensions to empty', () => {
|
|
114
|
+
const stack = createExecutionStack({
|
|
115
|
+
target: createStubTargetDescriptor(),
|
|
116
|
+
adapter: createStubAdapterDescriptor(),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(stack.driver).toBeUndefined();
|
|
120
|
+
expect(stack.extensionPacks).toEqual([]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('creates an execution context from descriptors-only stack', () => {
|
|
124
|
+
const contract = createTestContract({
|
|
125
|
+
storage: { tables: {} },
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const context = createExecutionContext({
|
|
129
|
+
contract,
|
|
130
|
+
stack: {
|
|
131
|
+
target: createStubTargetDescriptor(),
|
|
132
|
+
adapter: createStubAdapterDescriptor(),
|
|
133
|
+
extensionPacks: [createStubExtensionDescriptor()],
|
|
134
|
+
},
|
|
135
|
+
}) as ExecutionContext<typeof contract>;
|
|
136
|
+
|
|
137
|
+
expect(context.contract).toBe(contract);
|
|
138
|
+
expect(context.codecs.get('pg/text@1')).toBeDefined();
|
|
139
|
+
expect(context.codecs.get('pg/uuid@1')).toBeDefined();
|
|
140
|
+
expect(context.queryOperations.entries()['example']).toBeDefined();
|
|
141
|
+
expect(context.types).toEqual({});
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('createSqlExecutionStack', () => {
|
|
146
|
+
it('preserves descriptor references and defaults extensions', () => {
|
|
147
|
+
const target = createStubTargetDescriptor();
|
|
148
|
+
const adapter = createStubAdapterDescriptor();
|
|
149
|
+
const stack = createSqlExecutionStack({ target, adapter });
|
|
150
|
+
|
|
151
|
+
expect(stack.target).toBe(target);
|
|
152
|
+
expect(stack.adapter).toBe(adapter);
|
|
153
|
+
expect(stack.extensionPacks).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('keeps extension packs intact', () => {
|
|
157
|
+
const target = createStubTargetDescriptor();
|
|
158
|
+
const adapter = createStubAdapterDescriptor();
|
|
159
|
+
const extension = createStubExtensionDescriptor();
|
|
160
|
+
const stack = createSqlExecutionStack({ target, adapter, extensionPacks: [extension] });
|
|
161
|
+
|
|
162
|
+
expect(stack.extensionPacks).toEqual([extension]);
|
|
163
|
+
});
|
|
164
|
+
});
|