@prisma-next/sql-runtime 0.3.0-dev.43 → 0.3.0-dev.44

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.
@@ -0,0 +1,204 @@
1
+ import type { ExecutionPlan, PlanMeta } 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 type {
5
+ DeleteAst,
6
+ QueryAst,
7
+ SelectAst,
8
+ UpdateAst,
9
+ } from '@prisma-next/sql-relational-core/ast';
10
+ import { ifDefined } from '@prisma-next/utils/defined';
11
+
12
+ const QUERY_AST_KINDS = new Set(['select', 'insert', 'update', 'delete']);
13
+
14
+ function isSqlQueryAst(ast: unknown): ast is QueryAst {
15
+ if (ast === null || typeof ast !== 'object' || !('kind' in ast)) return false;
16
+ const kind = (ast as { kind: string }).kind;
17
+ return typeof kind === 'string' && QUERY_AST_KINDS.has(kind);
18
+ }
19
+
20
+ export interface LintsOptions {
21
+ readonly severities?: {
22
+ readonly selectStar?: 'warn' | 'error';
23
+ readonly noLimit?: 'warn' | 'error';
24
+ readonly deleteWithoutWhere?: 'warn' | 'error';
25
+ readonly updateWithoutWhere?: 'warn' | 'error';
26
+ readonly readOnlyMutation?: 'warn' | 'error';
27
+ readonly unindexedPredicate?: 'warn' | 'error';
28
+ };
29
+ readonly fallbackWhenAstMissing?: 'raw' | 'skip';
30
+ }
31
+
32
+ export interface LintFinding {
33
+ readonly code: `LINT.${string}`;
34
+ readonly severity: 'error' | 'warn';
35
+ readonly message: string;
36
+ readonly details?: Record<string, unknown>;
37
+ }
38
+
39
+ function lintError(code: string, message: string, details?: Record<string, unknown>) {
40
+ const error = new Error(message) as Error & {
41
+ code: string;
42
+ category: 'LINT';
43
+ severity: 'error';
44
+ details?: Record<string, unknown>;
45
+ };
46
+ Object.defineProperty(error, 'name', {
47
+ value: 'RuntimeError',
48
+ configurable: true,
49
+ });
50
+ return Object.assign(error, {
51
+ code,
52
+ category: 'LINT' as const,
53
+ severity: 'error' as const,
54
+ details,
55
+ });
56
+ }
57
+
58
+ function evaluateAstLints(ast: QueryAst, meta: PlanMeta): LintFinding[] {
59
+ const findings: LintFinding[] = [];
60
+
61
+ if (ast.kind === 'delete') {
62
+ const deleteAst = ast as DeleteAst;
63
+ if (deleteAst.where === undefined) {
64
+ findings.push({
65
+ code: 'LINT.DELETE_WITHOUT_WHERE',
66
+ severity: 'error',
67
+ message:
68
+ 'DELETE without WHERE clause blocks execution to prevent accidental full-table deletion',
69
+ details: { table: deleteAst.table.name },
70
+ });
71
+ }
72
+ }
73
+
74
+ if (ast.kind === 'update') {
75
+ const updateAst = ast as UpdateAst;
76
+ if (updateAst.where === undefined) {
77
+ findings.push({
78
+ code: 'LINT.UPDATE_WITHOUT_WHERE',
79
+ severity: 'error',
80
+ message:
81
+ 'UPDATE without WHERE clause blocks execution to prevent accidental full-table update',
82
+ details: { table: updateAst.table.name },
83
+ });
84
+ }
85
+ }
86
+
87
+ if (ast.kind === 'select') {
88
+ const selectAst = ast as SelectAst;
89
+ if (selectAst.limit === undefined) {
90
+ findings.push({
91
+ code: 'LINT.NO_LIMIT',
92
+ severity: 'warn',
93
+ message: 'Unbounded SELECT may return large result sets',
94
+ details: { table: selectAst.from.name },
95
+ });
96
+ }
97
+ const hasSelectAllIntent =
98
+ selectAst.selectAllIntent !== undefined ||
99
+ (meta.annotations as { selectAllIntent?: unknown })?.selectAllIntent !== undefined;
100
+ if (hasSelectAllIntent) {
101
+ const table =
102
+ selectAst.selectAllIntent?.table ??
103
+ (meta.annotations as { selectAllIntent?: { table?: string } })?.selectAllIntent?.table;
104
+ findings.push({
105
+ code: 'LINT.SELECT_STAR',
106
+ severity: 'warn',
107
+ message: 'Query selects all columns via selectAll intent',
108
+ ...ifDefined('details', table !== undefined ? { table } : undefined),
109
+ });
110
+ }
111
+ }
112
+
113
+ return findings;
114
+ }
115
+
116
+ function getConfiguredSeverity(code: string, options?: LintsOptions): 'warn' | 'error' | undefined {
117
+ const severities = options?.severities;
118
+ if (!severities) return undefined;
119
+
120
+ switch (code) {
121
+ case 'LINT.SELECT_STAR':
122
+ return severities.selectStar;
123
+ case 'LINT.NO_LIMIT':
124
+ return severities.noLimit;
125
+ case 'LINT.DELETE_WITHOUT_WHERE':
126
+ return severities.deleteWithoutWhere;
127
+ case 'LINT.UPDATE_WITHOUT_WHERE':
128
+ return severities.updateWithoutWhere;
129
+ case 'LINT.READ_ONLY_MUTATION':
130
+ return severities.readOnlyMutation;
131
+ case 'LINT.UNINDEXED_PREDICATE':
132
+ return severities.unindexedPredicate;
133
+ default:
134
+ return undefined;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * AST-first lint plugin for SQL plans. When `plan.ast` is a SQL QueryAst, inspects
140
+ * the AST structurally. When `plan.ast` is missing, falls back to raw heuristic
141
+ * guardrails or skips linting depending on `fallbackWhenAstMissing`.
142
+ *
143
+ * Rules (AST-based):
144
+ * - DELETE without WHERE: blocks execution (configurable severity, default error)
145
+ * - UPDATE without WHERE: blocks execution (configurable severity, default error)
146
+ * - Unbounded SELECT: warn/error (severity from noLimit)
147
+ * - SELECT * intent: warn/error (severity from selectStar)
148
+ *
149
+ * Fallback: When ast is missing, `fallbackWhenAstMissing: 'raw'` uses heuristic
150
+ * SQL parsing; `'skip'` skips all lints. Default is `'raw'`.
151
+ */
152
+ export function lints<TContract = unknown, TAdapter = unknown, TDriver = unknown>(
153
+ options?: LintsOptions,
154
+ ): Plugin<TContract, TAdapter, TDriver> {
155
+ const fallback = options?.fallbackWhenAstMissing ?? 'raw';
156
+
157
+ return Object.freeze({
158
+ name: 'lints',
159
+
160
+ async beforeExecute(plan: ExecutionPlan, ctx: PluginContext<TContract, TAdapter, TDriver>) {
161
+ if (isSqlQueryAst(plan.ast)) {
162
+ const findings = evaluateAstLints(plan.ast, plan.meta);
163
+
164
+ for (const lint of findings) {
165
+ const configuredSeverity = getConfiguredSeverity(lint.code, options);
166
+ const effectiveSeverity = configuredSeverity ?? lint.severity;
167
+
168
+ if (effectiveSeverity === 'error') {
169
+ throw lintError(lint.code, lint.message, lint.details);
170
+ }
171
+ if (effectiveSeverity === 'warn') {
172
+ ctx.log.warn({
173
+ code: lint.code,
174
+ message: lint.message,
175
+ details: lint.details,
176
+ });
177
+ }
178
+ }
179
+ return;
180
+ }
181
+
182
+ if (fallback === 'skip') {
183
+ return;
184
+ }
185
+
186
+ const evaluation = evaluateRawGuardrails(plan);
187
+ for (const lint of evaluation.lints) {
188
+ const configuredSeverity = getConfiguredSeverity(lint.code, options);
189
+ const effectiveSeverity = configuredSeverity ?? lint.severity;
190
+
191
+ if (effectiveSeverity === 'error') {
192
+ throw lintError(lint.code, lint.message, lint.details);
193
+ }
194
+ if (effectiveSeverity === 'warn') {
195
+ ctx.log.warn({
196
+ code: lint.code,
197
+ message: lint.message,
198
+ details: lint.details,
199
+ });
200
+ }
201
+ }
202
+ },
203
+ });
204
+ }
@@ -97,6 +97,10 @@ export interface RuntimeQueryable {
97
97
  ): AsyncIterableResult<Row>;
98
98
  }
99
99
 
100
+ interface CoreQueryable {
101
+ execute<Row = Record<string, unknown>>(plan: ExecutionPlan<Row>): AsyncIterableResult<Row>;
102
+ }
103
+
100
104
  export type { RuntimeTelemetryEvent, RuntimeVerifyOptions, TelemetryOutcome };
101
105
 
102
106
  class SqlRuntimeImpl<TContract extends SqlContract<SqlStorage> = SqlContract<SqlStorage>>
@@ -156,18 +160,20 @@ class SqlRuntimeImpl<TContract extends SqlContract<SqlStorage> = SqlContract<Sql
156
160
  }
157
161
  }
158
162
 
159
- execute<Row = Record<string, unknown>>(
160
- plan: ExecutionPlan<Row> | SqlQueryPlan<Row>,
161
- ): AsyncIterableResult<Row> {
162
- this.ensureCodecRegistryValidated(this.contract);
163
-
163
+ private toExecutionPlan<Row>(plan: ExecutionPlan<Row> | SqlQueryPlan<Row>): ExecutionPlan<Row> {
164
164
  const isSqlQueryPlan = (p: ExecutionPlan<Row> | SqlQueryPlan<Row>): p is SqlQueryPlan<Row> => {
165
165
  return 'ast' in p && !('sql' in p);
166
166
  };
167
167
 
168
- const executablePlan: ExecutionPlan<Row> = isSqlQueryPlan(plan)
169
- ? lowerSqlPlan(this.adapter, this.contract, plan)
170
- : plan;
168
+ return isSqlQueryPlan(plan) ? lowerSqlPlan(this.adapter, this.contract, plan) : plan;
169
+ }
170
+
171
+ private executeAgainstQueryable<Row = Record<string, unknown>>(
172
+ plan: ExecutionPlan<Row> | SqlQueryPlan<Row>,
173
+ queryable: CoreQueryable,
174
+ ): AsyncIterableResult<Row> {
175
+ this.ensureCodecRegistryValidated(this.contract);
176
+ const executablePlan = this.toExecutionPlan(plan);
171
177
 
172
178
  const iterator = async function* (
173
179
  self: SqlRuntimeImpl<TContract>,
@@ -182,7 +188,7 @@ class SqlRuntimeImpl<TContract extends SqlContract<SqlStorage> = SqlContract<Sql
182
188
  params: encodedParams,
183
189
  };
184
190
 
185
- const coreIterator = self.core.execute(planWithEncodedParams);
191
+ const coreIterator = queryable.execute(planWithEncodedParams);
186
192
 
187
193
  for await (const rawRow of coreIterator) {
188
194
  const decodedRow = decodeRow(
@@ -198,8 +204,36 @@ class SqlRuntimeImpl<TContract extends SqlContract<SqlStorage> = SqlContract<Sql
198
204
  return new AsyncIterableResult(iterator(this));
199
205
  }
200
206
 
201
- connection(): Promise<RuntimeConnection> {
202
- return this.core.connection();
207
+ execute<Row = Record<string, unknown>>(
208
+ plan: ExecutionPlan<Row> | SqlQueryPlan<Row>,
209
+ ): AsyncIterableResult<Row> {
210
+ return this.executeAgainstQueryable(plan, this.core);
211
+ }
212
+
213
+ async connection(): Promise<RuntimeConnection> {
214
+ const coreConn = await this.core.connection();
215
+ const self = this;
216
+ const wrappedConnection: RuntimeConnection = {
217
+ async transaction(): Promise<RuntimeTransaction> {
218
+ const coreTx = await coreConn.transaction();
219
+ return {
220
+ commit: coreTx.commit.bind(coreTx),
221
+ rollback: coreTx.rollback.bind(coreTx),
222
+ execute<Row = Record<string, unknown>>(
223
+ plan: ExecutionPlan<Row> | SqlQueryPlan<Row>,
224
+ ): AsyncIterableResult<Row> {
225
+ return self.executeAgainstQueryable(plan, coreTx);
226
+ },
227
+ };
228
+ },
229
+ release: coreConn.release.bind(coreConn),
230
+ execute<Row = Record<string, unknown>>(
231
+ plan: ExecutionPlan<Row> | SqlQueryPlan<Row>,
232
+ ): AsyncIterableResult<Row> {
233
+ return self.executeAgainstQueryable(plan, coreConn);
234
+ },
235
+ };
236
+ return wrappedConnection;
203
237
  }
204
238
 
205
239
  telemetry(): RuntimeTelemetryEvent | null {
@@ -0,0 +1,330 @@
1
+ import type { ExecutionPlan, PlanMeta } from '@prisma-next/contract/types';
2
+ import type { PluginContext } from '@prisma-next/runtime-executor';
3
+ import type {
4
+ BinaryExpr,
5
+ DeleteAst,
6
+ SelectAst,
7
+ UpdateAst,
8
+ } from '@prisma-next/sql-relational-core/ast';
9
+ import {
10
+ createColumnRef,
11
+ createDeleteAst,
12
+ createSelectAst,
13
+ createTableRef,
14
+ createUpdateAst,
15
+ } from '@prisma-next/sql-relational-core/ast';
16
+ import { timeouts } from '@prisma-next/test-utils';
17
+ import { describe, expect, it, vi } from 'vitest';
18
+ import { lints } from '../src/plugins/lints';
19
+
20
+ function createPluginContext(): 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
+ };
33
+ }
34
+
35
+ const baseMeta: PlanMeta = {
36
+ target: 'postgres',
37
+ storageHash: 'sha256:test',
38
+ lane: 'dsl',
39
+ paramDescriptors: [],
40
+ };
41
+
42
+ type PlanOverrides = Partial<Omit<ExecutionPlan, 'meta'>> & { meta?: Partial<PlanMeta> };
43
+
44
+ function createPlan(overrides: PlanOverrides): ExecutionPlan {
45
+ const { meta: metaOverrides, ...rest } = overrides;
46
+ return {
47
+ sql: 'SELECT 1',
48
+ params: [],
49
+ meta: { ...baseMeta, ...(metaOverrides ?? {}) } as PlanMeta,
50
+ ...rest,
51
+ } as ExecutionPlan;
52
+ }
53
+
54
+ const userTable = createTableRef('user');
55
+ const idCol = createColumnRef('user', 'id');
56
+
57
+ describe('lints plugin', () => {
58
+ describe('DELETE without WHERE', () => {
59
+ it('blocks execution when ast is delete without where', async () => {
60
+ const deleteAst: DeleteAst = {
61
+ kind: 'delete',
62
+ table: userTable,
63
+ };
64
+ const plan = createPlan({ ast: deleteAst });
65
+ const plugin = lints();
66
+ const ctx = createPluginContext();
67
+
68
+ await expect(plugin.beforeExecute?.(plan, ctx)).rejects.toMatchObject({
69
+ code: 'LINT.DELETE_WITHOUT_WHERE',
70
+ message: expect.stringContaining('DELETE without WHERE'),
71
+ details: { table: 'user' },
72
+ });
73
+ });
74
+
75
+ it('allows delete with where clause', async () => {
76
+ const where: BinaryExpr = {
77
+ kind: 'bin',
78
+ op: 'eq',
79
+ left: idCol,
80
+ right: { kind: 'param', index: 1 },
81
+ };
82
+ const deleteAst = createDeleteAst({ table: userTable, where });
83
+ const plan = createPlan({ ast: deleteAst });
84
+ const plugin = lints();
85
+ const ctx = createPluginContext();
86
+
87
+ await plugin.beforeExecute?.(plan, ctx);
88
+ expect(ctx.log.warn).not.toHaveBeenCalled();
89
+ });
90
+ });
91
+
92
+ describe('UPDATE without WHERE', () => {
93
+ it('blocks execution when ast is update without where', async () => {
94
+ const updateAst: UpdateAst = {
95
+ kind: 'update',
96
+ table: userTable,
97
+ set: { email: { kind: 'param', index: 1 } },
98
+ };
99
+ const plan = createPlan({ ast: updateAst });
100
+ const plugin = lints();
101
+ const ctx = createPluginContext();
102
+
103
+ await expect(plugin.beforeExecute?.(plan, ctx)).rejects.toMatchObject({
104
+ code: 'LINT.UPDATE_WITHOUT_WHERE',
105
+ message: expect.stringContaining('UPDATE without WHERE'),
106
+ details: { table: 'user' },
107
+ });
108
+ });
109
+
110
+ it('allows update with where clause', async () => {
111
+ const where: BinaryExpr = {
112
+ kind: 'bin',
113
+ op: 'eq',
114
+ left: idCol,
115
+ right: { kind: 'param', index: 1 },
116
+ };
117
+ const updateAst = createUpdateAst({
118
+ table: userTable,
119
+ set: { email: { kind: 'param', index: 2 } },
120
+ where,
121
+ });
122
+ const plan = createPlan({ ast: updateAst });
123
+ const plugin = lints();
124
+ const ctx = createPluginContext();
125
+
126
+ await plugin.beforeExecute?.(plan, ctx);
127
+ expect(ctx.log.warn).not.toHaveBeenCalled();
128
+ });
129
+ });
130
+
131
+ describe('Unbounded SELECT', () => {
132
+ it('warns when select lacks limit', async () => {
133
+ const selectAst: SelectAst = createSelectAst({
134
+ from: userTable,
135
+ project: [{ alias: 'id', expr: idCol }],
136
+ });
137
+ const plan = createPlan({ ast: selectAst });
138
+ const plugin = lints();
139
+ const ctx = createPluginContext();
140
+
141
+ await plugin.beforeExecute?.(plan, ctx);
142
+ expect(ctx.log.warn).toHaveBeenCalledWith(
143
+ expect.objectContaining({
144
+ code: 'LINT.NO_LIMIT',
145
+ message: expect.stringContaining('Unbounded SELECT'),
146
+ }),
147
+ );
148
+ });
149
+
150
+ it('allows select with limit', async () => {
151
+ const selectAst = createSelectAst({
152
+ from: userTable,
153
+ project: [{ alias: 'id', expr: idCol }],
154
+ limit: 10,
155
+ });
156
+ const plan = createPlan({ ast: selectAst });
157
+ const plugin = lints();
158
+ const ctx = createPluginContext();
159
+
160
+ await plugin.beforeExecute?.(plan, ctx);
161
+ expect(ctx.log.warn).not.toHaveBeenCalled();
162
+ });
163
+
164
+ it('throws when noLimit severity is error', async () => {
165
+ const selectAst = createSelectAst({
166
+ from: userTable,
167
+ project: [{ alias: 'id', expr: idCol }],
168
+ });
169
+ const plan = createPlan({ ast: selectAst });
170
+ const plugin = lints({ severities: { noLimit: 'error' } });
171
+ const ctx = createPluginContext();
172
+
173
+ await expect(plugin.beforeExecute?.(plan, ctx)).rejects.toMatchObject({
174
+ code: 'LINT.NO_LIMIT',
175
+ message: expect.stringContaining('Unbounded SELECT'),
176
+ });
177
+ });
178
+ });
179
+
180
+ describe('SELECT * intent', () => {
181
+ it('warns when selectAllIntent present on ast', async () => {
182
+ const selectAst = createSelectAst({
183
+ from: userTable,
184
+ project: [{ alias: 'id', expr: idCol }],
185
+ limit: 1,
186
+ selectAllIntent: { table: 'user' },
187
+ });
188
+ const plan = createPlan({ ast: selectAst });
189
+ const plugin = lints();
190
+ const ctx = createPluginContext();
191
+
192
+ await plugin.beforeExecute?.(plan, ctx);
193
+ expect(ctx.log.warn).toHaveBeenCalledWith(
194
+ expect.objectContaining({
195
+ code: 'LINT.SELECT_STAR',
196
+ message: expect.stringContaining('selectAll intent'),
197
+ details: { table: 'user' },
198
+ }),
199
+ );
200
+ });
201
+
202
+ it('warns when selectAllIntent in meta.annotations', async () => {
203
+ const selectAst = createSelectAst({
204
+ from: userTable,
205
+ project: [{ alias: 'id', expr: idCol }],
206
+ limit: 1,
207
+ });
208
+ const plan = createPlan({
209
+ ast: selectAst,
210
+ meta: { annotations: { selectAllIntent: { table: 'user' } } },
211
+ });
212
+ const plugin = lints();
213
+ const ctx = createPluginContext();
214
+
215
+ await plugin.beforeExecute?.(plan, ctx);
216
+ expect(ctx.log.warn).toHaveBeenCalledWith(
217
+ expect.objectContaining({
218
+ code: 'LINT.SELECT_STAR',
219
+ message: expect.stringContaining('selectAll intent'),
220
+ }),
221
+ );
222
+ });
223
+
224
+ it('allows select without selectAll intent', async () => {
225
+ const selectAst = createSelectAst({
226
+ from: userTable,
227
+ project: [{ alias: 'id', expr: idCol }],
228
+ limit: 1,
229
+ });
230
+ const plan = createPlan({ ast: selectAst });
231
+ const plugin = lints();
232
+ const ctx = createPluginContext();
233
+
234
+ await plugin.beforeExecute?.(plan, ctx);
235
+ expect(ctx.log.warn).not.toHaveBeenCalled();
236
+ });
237
+
238
+ it('throws when selectStar severity is error', async () => {
239
+ const selectAst = createSelectAst({
240
+ from: userTable,
241
+ project: [{ alias: 'id', expr: idCol }],
242
+ limit: 1,
243
+ selectAllIntent: { table: 'user' },
244
+ });
245
+ const plan = createPlan({ ast: selectAst });
246
+ const plugin = lints({ severities: { selectStar: 'error' } });
247
+ const ctx = createPluginContext();
248
+
249
+ await expect(plugin.beforeExecute?.(plan, ctx)).rejects.toMatchObject({
250
+ code: 'LINT.SELECT_STAR',
251
+ message: expect.stringContaining('selectAll intent'),
252
+ });
253
+ });
254
+ });
255
+
256
+ describe('fallback when plan.ast missing', () => {
257
+ it(
258
+ 'runs raw heuristic when fallbackWhenAstMissing is raw',
259
+ async () => {
260
+ const plan = createPlan({
261
+ ast: undefined,
262
+ sql: 'SELECT id FROM user',
263
+ params: [],
264
+ meta: {},
265
+ });
266
+ const plugin = lints({ fallbackWhenAstMissing: 'raw' });
267
+ const ctx = createPluginContext();
268
+
269
+ await plugin.beforeExecute?.(plan, ctx);
270
+ expect(ctx.log.warn).toHaveBeenCalledWith(
271
+ expect.objectContaining({
272
+ code: 'LINT.NO_LIMIT',
273
+ message: expect.stringContaining('omits LIMIT'),
274
+ }),
275
+ );
276
+ },
277
+ timeouts.default,
278
+ );
279
+
280
+ it('skips linting when fallbackWhenAstMissing is skip', async () => {
281
+ const plan = createPlan({
282
+ ast: undefined,
283
+ sql: 'SELECT * FROM user',
284
+ params: [],
285
+ meta: {},
286
+ });
287
+ const plugin = lints({ fallbackWhenAstMissing: 'skip' });
288
+ const ctx = createPluginContext();
289
+
290
+ await plugin.beforeExecute?.(plan, ctx);
291
+ expect(ctx.log.warn).not.toHaveBeenCalled();
292
+ });
293
+
294
+ it('defaults to raw fallback when ast missing', async () => {
295
+ const plan = createPlan({
296
+ ast: undefined,
297
+ sql: 'SELECT id FROM user',
298
+ params: [],
299
+ meta: {},
300
+ });
301
+ const plugin = lints();
302
+ const ctx = createPluginContext();
303
+
304
+ await plugin.beforeExecute?.(plan, ctx);
305
+ expect(ctx.log.warn).toHaveBeenCalledWith(
306
+ expect.objectContaining({
307
+ code: 'LINT.NO_LIMIT',
308
+ message: expect.stringContaining('omits LIMIT'),
309
+ }),
310
+ );
311
+ });
312
+ });
313
+
314
+ describe('INSERT', () => {
315
+ it('passes when ast is insert', async () => {
316
+ const plan = createPlan({
317
+ ast: {
318
+ kind: 'insert',
319
+ table: userTable,
320
+ values: { email: { kind: 'param', index: 1 } },
321
+ },
322
+ });
323
+ const plugin = lints();
324
+ const ctx = createPluginContext();
325
+
326
+ await plugin.beforeExecute?.(plan, ctx);
327
+ expect(ctx.log.warn).not.toHaveBeenCalled();
328
+ });
329
+ });
330
+ });