@prisma-next/sql-runtime 0.3.0-dev.41 → 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.
- package/README.md +24 -3
- package/dist/{exports-C8hi0N-a.mjs → exports-BhZqJPVb.mjs} +159 -10
- package/dist/exports-BhZqJPVb.mjs.map +1 -0
- package/dist/{index-SlQIrV_t.d.mts → index-D59jqEKF.d.mts} +31 -3
- package/dist/index-D59jqEKF.d.mts.map +1 -0
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1 -1
- package/dist/test/utils.d.mts +1 -1
- package/dist/test/utils.mjs +1 -1
- package/package.json +12 -12
- package/src/exports/index.ts +3 -2
- package/src/plugins/lints.ts +204 -0
- package/src/sql-runtime.ts +45 -11
- package/test/lints.test.ts +330 -0
- package/test/sql-runtime.test.ts +120 -18
- package/dist/exports-C8hi0N-a.mjs.map +0 -1
- package/dist/index-SlQIrV_t.d.mts.map +0 -1
|
@@ -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
|
+
}
|
package/src/sql-runtime.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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 =
|
|
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
|
-
|
|
202
|
-
|
|
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
|
+
});
|