@bolvrk/engine-sqlite 0.1.4
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.md +105 -0
- package/README.md +26 -0
- package/dist/engine.d.ts +24 -0
- package/dist/helpers.d.ts +77 -0
- package/dist/index-m4sztshc.js +615 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1421 -0
- package/dist/metas/index.d.ts +53 -0
- package/dist/metas/index.js +80 -0
- package/dist/parser.d.ts +33 -0
- package/dist/rule.d.ts +17 -0
- package/dist/rules/add-column.d.ts +13 -0
- package/dist/rules/dml-and-maintenance.d.ts +7 -0
- package/dist/rules/index.d.ts +5 -0
- package/dist/rules/indexes-and-types.d.ts +7 -0
- package/dist/rules/performance.d.ts +11 -0
- package/dist/rules/rebuild-pragmas.d.ts +11 -0
- package/dist/rules/schema-and-transactions.d.ts +23 -0
- package/dist/rules/table-shape.d.ts +5 -0
- package/package.json +45 -0
- package/src/engine.ts +89 -0
- package/src/helpers.ts +254 -0
- package/src/index.ts +5 -0
- package/src/metas/LICENSE +202 -0
- package/src/metas/index.ts +598 -0
- package/src/parser.ts +68 -0
- package/src/rule.ts +36 -0
- package/src/rules/LICENSE +202 -0
- package/src/rules/add-column.ts +180 -0
- package/src/rules/dml-and-maintenance.ts +75 -0
- package/src/rules/index.ts +18 -0
- package/src/rules/indexes-and-types.ts +99 -0
- package/src/rules/performance.ts +207 -0
- package/src/rules/rebuild-pragmas.ts +142 -0
- package/src/rules/schema-and-transactions.ts +446 -0
- package/src/rules/table-shape.ts +76 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1421 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SL_RULE_CORPUS_VERSION,
|
|
3
|
+
SQLITE_RULE_IDS,
|
|
4
|
+
sl001Meta,
|
|
5
|
+
sl002Meta,
|
|
6
|
+
sl003Meta,
|
|
7
|
+
sl004Meta,
|
|
8
|
+
sl005Meta,
|
|
9
|
+
sl006Meta,
|
|
10
|
+
sl007Meta,
|
|
11
|
+
sl008Meta,
|
|
12
|
+
sl009Meta,
|
|
13
|
+
sl010Meta,
|
|
14
|
+
sl011Meta,
|
|
15
|
+
sl012Meta,
|
|
16
|
+
sl013Meta,
|
|
17
|
+
sl014Meta,
|
|
18
|
+
sl015Meta,
|
|
19
|
+
sl016Meta,
|
|
20
|
+
sl017Meta,
|
|
21
|
+
sl018Meta,
|
|
22
|
+
sl019Meta,
|
|
23
|
+
sl020Meta,
|
|
24
|
+
sl021Meta,
|
|
25
|
+
sl022Meta,
|
|
26
|
+
sl023Meta,
|
|
27
|
+
sl024Meta,
|
|
28
|
+
sl025Meta,
|
|
29
|
+
sl026Meta,
|
|
30
|
+
sl027Meta,
|
|
31
|
+
sl028Meta,
|
|
32
|
+
sl029Meta,
|
|
33
|
+
sl030Meta,
|
|
34
|
+
sl031Meta,
|
|
35
|
+
sl032Meta,
|
|
36
|
+
sl033Meta,
|
|
37
|
+
sl034Meta,
|
|
38
|
+
sl035Meta,
|
|
39
|
+
sqliteMetas
|
|
40
|
+
} from "./index-m4sztshc.js";
|
|
41
|
+
|
|
42
|
+
// src/engine.ts
|
|
43
|
+
import {
|
|
44
|
+
applySuppressions,
|
|
45
|
+
collectSuppressions,
|
|
46
|
+
CONTRACT_VERSION,
|
|
47
|
+
scanSecrets,
|
|
48
|
+
sortFindings
|
|
49
|
+
} from "@bolvrk/engine";
|
|
50
|
+
// package.json
|
|
51
|
+
var package_default = {
|
|
52
|
+
name: "@bolvrk/engine-sqlite",
|
|
53
|
+
version: "0.1.4",
|
|
54
|
+
description: "Bolvrk SQLite migration danger rules (SL###) over a real SQLite grammar. ALPHA — static rules only, behind --engine=sqlite in the CLI.",
|
|
55
|
+
license: "(FSL-1.1-ALv2 AND Apache-2.0)",
|
|
56
|
+
homepage: "https://bolvrk.com",
|
|
57
|
+
repository: "github:bolvrk/bolvrk",
|
|
58
|
+
type: "module",
|
|
59
|
+
exports: {
|
|
60
|
+
".": {
|
|
61
|
+
types: "./dist/index.d.ts",
|
|
62
|
+
bun: "./src/index.ts",
|
|
63
|
+
import: "./dist/index.js"
|
|
64
|
+
},
|
|
65
|
+
"./metas": {
|
|
66
|
+
types: "./dist/metas/index.d.ts",
|
|
67
|
+
bun: "./src/metas/index.ts",
|
|
68
|
+
import: "./dist/metas/index.js"
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
files: [
|
|
72
|
+
"dist",
|
|
73
|
+
"src",
|
|
74
|
+
"LICENSE.md"
|
|
75
|
+
],
|
|
76
|
+
scripts: {
|
|
77
|
+
typecheck: "tsc --noEmit",
|
|
78
|
+
build: "rm -rf dist && bun build src/index.ts src/metas/index.ts --outdir dist --target node --external @bolvrk/engine --external sql-parser-cst --splitting && tsc -p tsconfig.build.json",
|
|
79
|
+
docs: "bun scripts/gen-rule-docs.ts"
|
|
80
|
+
},
|
|
81
|
+
dependencies: {
|
|
82
|
+
"sql-parser-cst": "0.42.1"
|
|
83
|
+
},
|
|
84
|
+
peerDependencies: {
|
|
85
|
+
"@bolvrk/engine": "workspace:*"
|
|
86
|
+
},
|
|
87
|
+
devDependencies: {
|
|
88
|
+
"@bolvrk/engine": "workspace:*"
|
|
89
|
+
},
|
|
90
|
+
keywords: [
|
|
91
|
+
"sqlite",
|
|
92
|
+
"migrations",
|
|
93
|
+
"sql",
|
|
94
|
+
"lint",
|
|
95
|
+
"ddl"
|
|
96
|
+
]
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// src/parser.ts
|
|
100
|
+
import { parse } from "sql-parser-cst";
|
|
101
|
+
class SqliteMigrationParseError extends Error {
|
|
102
|
+
cause;
|
|
103
|
+
constructor(message, cause) {
|
|
104
|
+
super(message);
|
|
105
|
+
this.cause = cause;
|
|
106
|
+
this.name = "SqliteMigrationParseError";
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
var PARSER_VERSION = package_default.dependencies["sql-parser-cst"];
|
|
110
|
+
function parseSqliteMigration(sql) {
|
|
111
|
+
let program;
|
|
112
|
+
try {
|
|
113
|
+
program = parse(sql, { dialect: "sqlite", includeRange: true });
|
|
114
|
+
} catch (err) {
|
|
115
|
+
const message = String(err?.message ?? err).split(`
|
|
116
|
+
`)[0];
|
|
117
|
+
throw new SqliteMigrationParseError(`Migration is not valid SQLite SQL: ${message}`, err);
|
|
118
|
+
}
|
|
119
|
+
const statements = [];
|
|
120
|
+
for (const node of program.statements) {
|
|
121
|
+
if (node.type === "empty")
|
|
122
|
+
continue;
|
|
123
|
+
const [start, stop] = node.range ?? [0, sql.length];
|
|
124
|
+
const end = Math.min(stop + 1, sql.length);
|
|
125
|
+
statements.push({
|
|
126
|
+
index: statements.length,
|
|
127
|
+
type: node.type,
|
|
128
|
+
node,
|
|
129
|
+
sql: sql.slice(start, end).trim(),
|
|
130
|
+
location: start,
|
|
131
|
+
end
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return { statements, parserVersion: PARSER_VERSION };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// src/helpers.ts
|
|
138
|
+
function nameOf(node) {
|
|
139
|
+
if (!node)
|
|
140
|
+
return;
|
|
141
|
+
if (node.type === "identifier")
|
|
142
|
+
return node.name;
|
|
143
|
+
if (node.type === "member_expr")
|
|
144
|
+
return nameOf(node.property);
|
|
145
|
+
if (node.type === "keyword")
|
|
146
|
+
return String(node.name).toLowerCase();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
function items(node) {
|
|
150
|
+
if (!node)
|
|
151
|
+
return [];
|
|
152
|
+
if (node.type === "list_expr")
|
|
153
|
+
return node.items ?? [];
|
|
154
|
+
if (node.type === "paren_expr")
|
|
155
|
+
return items(node.expr);
|
|
156
|
+
return [node];
|
|
157
|
+
}
|
|
158
|
+
function alterActions(stmt) {
|
|
159
|
+
if (stmt.node.type !== "alter_table_stmt")
|
|
160
|
+
return [];
|
|
161
|
+
return items(stmt.node.actions);
|
|
162
|
+
}
|
|
163
|
+
function addedColumns(stmt) {
|
|
164
|
+
return alterActions(stmt).filter((action) => action.type === "alter_action_add_column").map((action) => action.column).filter((column) => column?.type === "column_definition");
|
|
165
|
+
}
|
|
166
|
+
function createdColumns(stmt) {
|
|
167
|
+
if (stmt.node.type !== "create_table_stmt")
|
|
168
|
+
return [];
|
|
169
|
+
return items(stmt.node.columns).filter((item) => item.type === "column_definition");
|
|
170
|
+
}
|
|
171
|
+
function tableConstraints(stmt) {
|
|
172
|
+
if (stmt.node.type !== "create_table_stmt")
|
|
173
|
+
return [];
|
|
174
|
+
return items(stmt.node.columns).filter((item) => item.type !== "column_definition");
|
|
175
|
+
}
|
|
176
|
+
function constraintOf(column, type) {
|
|
177
|
+
return (column.constraints ?? []).find((c) => c.type === type || c.constraint?.type === type);
|
|
178
|
+
}
|
|
179
|
+
function dataTypeName(column) {
|
|
180
|
+
const name = column.dataType?.name;
|
|
181
|
+
if (!name)
|
|
182
|
+
return "";
|
|
183
|
+
const parts = Array.isArray(name) ? name : [name];
|
|
184
|
+
return parts.map((part) => String(part?.name ?? part?.text ?? "")).join(" ").toUpperCase();
|
|
185
|
+
}
|
|
186
|
+
function tableOptions(stmt) {
|
|
187
|
+
if (stmt.node.type !== "create_table_stmt")
|
|
188
|
+
return [];
|
|
189
|
+
return items(stmt.node.options).filter((option) => option.type === "table_option").map((option) => (Array.isArray(option.name) ? option.name : [option.name]).map((kw) => String(kw?.name ?? "")).join(" ").toUpperCase());
|
|
190
|
+
}
|
|
191
|
+
function tableOf(stmt) {
|
|
192
|
+
const node = stmt.node;
|
|
193
|
+
switch (node.type) {
|
|
194
|
+
case "alter_table_stmt":
|
|
195
|
+
case "create_index_stmt":
|
|
196
|
+
return nameOf(node.table);
|
|
197
|
+
case "create_table_stmt":
|
|
198
|
+
case "create_view_stmt":
|
|
199
|
+
return nameOf(node.name);
|
|
200
|
+
case "drop_table_stmt":
|
|
201
|
+
return nameOf(items(node.tables)[0]);
|
|
202
|
+
case "insert_stmt":
|
|
203
|
+
return nameOf(node.clauses?.find((c) => c.type === "insert_clause")?.table);
|
|
204
|
+
case "update_stmt":
|
|
205
|
+
return nameOf(items(node.clauses?.find((c) => c.type === "update_clause")?.tables)[0]);
|
|
206
|
+
case "delete_stmt":
|
|
207
|
+
return nameOf(items(node.clauses?.find((c) => c.type === "delete_clause")?.tables)[0]);
|
|
208
|
+
default:
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function droppedTables(stmt) {
|
|
213
|
+
if (stmt.node.type !== "drop_table_stmt")
|
|
214
|
+
return [];
|
|
215
|
+
return items(stmt.node.tables).map(nameOf).filter((n) => !!n);
|
|
216
|
+
}
|
|
217
|
+
function tableCreatedInMigration(migration, beforeIndex, table) {
|
|
218
|
+
const wanted = table.toLowerCase();
|
|
219
|
+
return migration.statements.some((stmt) => stmt.index < beforeIndex && stmt.node.type === "create_table_stmt" && tableOf(stmt)?.toLowerCase() === wanted);
|
|
220
|
+
}
|
|
221
|
+
function tableRebuilds(migration) {
|
|
222
|
+
const out = [];
|
|
223
|
+
for (const drop of migration.statements) {
|
|
224
|
+
for (const table of droppedTables(drop)) {
|
|
225
|
+
const wanted = table.toLowerCase();
|
|
226
|
+
const rename = migration.statements.find((later) => later.index > drop.index && alterActions(later).some((a) => a.type === "alter_action_rename" && String(nameOf(a.newName) ?? "").toLowerCase() === wanted));
|
|
227
|
+
if (!rename)
|
|
228
|
+
continue;
|
|
229
|
+
const replacement = tableOf(rename) ?? "";
|
|
230
|
+
const copy = migration.statements.find((stmt) => stmt.index < drop.index && stmt.node.type === "insert_stmt" && tableOf(stmt)?.toLowerCase() === replacement.toLowerCase() && readsFromTable(stmt, table));
|
|
231
|
+
out.push({ drop, rename, table, replacement, replacementCreatedHere: !!replacement && tableCreatedInMigration(migration, rename.index, replacement), copy });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
function readsFromTable(stmt, table) {
|
|
237
|
+
const wanted = table.toLowerCase();
|
|
238
|
+
const node = stmt.node;
|
|
239
|
+
const sources = (node.clauses ?? []).filter((c) => c.type !== "insert_clause");
|
|
240
|
+
return descendants(sources, (n) => n.type === "identifier" && String(n.name).toLowerCase() === wanted).length > 0;
|
|
241
|
+
}
|
|
242
|
+
function hasWhere(stmt) {
|
|
243
|
+
const clauses = stmt.node.clauses ?? [];
|
|
244
|
+
return clauses.some((c) => c.type === "where_clause");
|
|
245
|
+
}
|
|
246
|
+
function pragmaOf(stmt) {
|
|
247
|
+
if (stmt.node.type !== "pragma_stmt")
|
|
248
|
+
return;
|
|
249
|
+
const pragma = stmt.node.pragma;
|
|
250
|
+
if (!pragma)
|
|
251
|
+
return;
|
|
252
|
+
if (pragma.type === "pragma_assignment")
|
|
253
|
+
return { name: nameOf(pragma.name).toLowerCase(), value: literalText(pragma.value) };
|
|
254
|
+
if (pragma.type === "pragma_func_call") {
|
|
255
|
+
const arg = pragma.args?.expr;
|
|
256
|
+
return { name: nameOf(pragma.name).toLowerCase(), value: arg ? literalText(arg) : undefined };
|
|
257
|
+
}
|
|
258
|
+
const name = nameOf(pragma);
|
|
259
|
+
return name ? { name: name.toLowerCase() } : undefined;
|
|
260
|
+
}
|
|
261
|
+
function literalText(node) {
|
|
262
|
+
if (!node)
|
|
263
|
+
return;
|
|
264
|
+
if (node.type === "prefix_op_expr" && node.expr)
|
|
265
|
+
return `${String(node.operator ?? "")}${literalText(node.expr) ?? ""}`;
|
|
266
|
+
if (node.type === "keyword")
|
|
267
|
+
return String(node.name).toLowerCase();
|
|
268
|
+
if (node.type === "identifier")
|
|
269
|
+
return String(node.name).toLowerCase();
|
|
270
|
+
if ("value" in node && node.value !== undefined && node.value !== null)
|
|
271
|
+
return String(node.value).toLowerCase();
|
|
272
|
+
if (node.text)
|
|
273
|
+
return String(node.text).toLowerCase();
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
function pragmaIsOff(value) {
|
|
277
|
+
return value !== undefined && ["off", "0", "false", "no"].includes(value);
|
|
278
|
+
}
|
|
279
|
+
function pragmaIsOn(value) {
|
|
280
|
+
return value !== undefined && ["on", "1", "true", "yes"].includes(value);
|
|
281
|
+
}
|
|
282
|
+
function insideTransaction(migration) {
|
|
283
|
+
const out = [];
|
|
284
|
+
let open = false;
|
|
285
|
+
for (const stmt of migration.statements) {
|
|
286
|
+
const type = stmt.node.type;
|
|
287
|
+
if (type === "start_transaction_stmt") {
|
|
288
|
+
out.push(false);
|
|
289
|
+
open = true;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (type === "commit_transaction_stmt" || type === "rollback_transaction_stmt") {
|
|
293
|
+
out.push(open);
|
|
294
|
+
open = false;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
out.push(open);
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
function descendants(node, predicate) {
|
|
302
|
+
const out = [];
|
|
303
|
+
const walk = (value) => {
|
|
304
|
+
if (!value || typeof value !== "object")
|
|
305
|
+
return;
|
|
306
|
+
if (Array.isArray(value)) {
|
|
307
|
+
for (const item of value)
|
|
308
|
+
walk(item);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (value.type && predicate(value))
|
|
312
|
+
out.push(value);
|
|
313
|
+
for (const key of Object.keys(value))
|
|
314
|
+
if (key !== "range" && typeof value[key] === "object")
|
|
315
|
+
walk(value[key]);
|
|
316
|
+
};
|
|
317
|
+
walk(node);
|
|
318
|
+
return out;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// src/rule.ts
|
|
322
|
+
function finding(rule, stmt, props) {
|
|
323
|
+
return {
|
|
324
|
+
ruleId: rule.meta.id,
|
|
325
|
+
ruleVersion: rule.meta.version,
|
|
326
|
+
tier: rule.meta.tier,
|
|
327
|
+
severity: props.severity ?? rule.meta.defaultSeverity,
|
|
328
|
+
statementIndex: stmt.index,
|
|
329
|
+
sql: stmt.sql,
|
|
330
|
+
...props
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// src/rules/add-column.ts
|
|
335
|
+
function eachAddedColumn(ctx, visit) {
|
|
336
|
+
for (const stmt of ctx.migration.statements) {
|
|
337
|
+
const columns = addedColumns(stmt);
|
|
338
|
+
if (columns.length === 0)
|
|
339
|
+
continue;
|
|
340
|
+
const table = tableOf(stmt) ?? "";
|
|
341
|
+
if (table && tableCreatedInMigration(ctx.migration, stmt.index, table))
|
|
342
|
+
continue;
|
|
343
|
+
for (const column of columns)
|
|
344
|
+
visit(stmt, table, column);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
function isNullDefault(column) {
|
|
348
|
+
const def = constraintOf(column, "constraint_default");
|
|
349
|
+
return !def || def.expr?.type === "null_literal" || def.constraint?.expr?.type === "null_literal";
|
|
350
|
+
}
|
|
351
|
+
var sl001 = {
|
|
352
|
+
meta: sl001Meta,
|
|
353
|
+
check(ctx) {
|
|
354
|
+
const out = [];
|
|
355
|
+
eachAddedColumn(ctx, (stmt, table, column) => {
|
|
356
|
+
if (!constraintOf(column, "constraint_not_null"))
|
|
357
|
+
return;
|
|
358
|
+
if (constraintOf(column, "constraint_generated"))
|
|
359
|
+
return;
|
|
360
|
+
if (!isNullDefault(column))
|
|
361
|
+
return;
|
|
362
|
+
const name = nameOf(column.name);
|
|
363
|
+
out.push(finding(sl001, stmt, {
|
|
364
|
+
title: `ADD COLUMN "${name}" NOT NULL has no default — SQLite refuses it`,
|
|
365
|
+
message: `SQLite cannot fill existing rows of "${table}" with NULL for a NOT NULL column, so the statement fails: "Cannot add a NOT NULL column with default value NULL". ` + `Add a non-NULL DEFAULT (schema-only, no rewrite), or add the column nullable and enforce NOT NULL in a later rebuild.`,
|
|
366
|
+
object: { table, column: name }
|
|
367
|
+
}));
|
|
368
|
+
});
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
var sl002 = {
|
|
373
|
+
meta: sl002Meta,
|
|
374
|
+
check(ctx) {
|
|
375
|
+
const out = [];
|
|
376
|
+
eachAddedColumn(ctx, (stmt, table, column) => {
|
|
377
|
+
const kind = constraintOf(column, "constraint_primary_key") ? "PRIMARY KEY" : constraintOf(column, "constraint_unique") ? "UNIQUE" : undefined;
|
|
378
|
+
if (!kind)
|
|
379
|
+
return;
|
|
380
|
+
const name = nameOf(column.name);
|
|
381
|
+
out.push(finding(sl002, stmt, {
|
|
382
|
+
title: `ADD COLUMN "${name}" ${kind} is rejected by SQLite`,
|
|
383
|
+
message: `ALTER TABLE ADD COLUMN may not carry a ${kind} constraint; SQLite fails the statement and "${table}" is unchanged. ` + (kind === "UNIQUE" ? `Add the column plain and CREATE UNIQUE INDEX as its own statement.` : `A new primary key needs the table rebuilt (create, copy, drop, rename).`),
|
|
384
|
+
object: { table, column: name }
|
|
385
|
+
}));
|
|
386
|
+
});
|
|
387
|
+
return out;
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
var NON_CONSTANT_DEFAULTS = new Set(["CURRENT_TIME", "CURRENT_DATE", "CURRENT_TIMESTAMP"]);
|
|
391
|
+
var sl003 = {
|
|
392
|
+
meta: sl003Meta,
|
|
393
|
+
check(ctx) {
|
|
394
|
+
const out = [];
|
|
395
|
+
eachAddedColumn(ctx, (stmt, table, column) => {
|
|
396
|
+
const def = constraintOf(column, "constraint_default");
|
|
397
|
+
const expr = def?.expr ?? def?.constraint?.expr;
|
|
398
|
+
if (!expr)
|
|
399
|
+
return;
|
|
400
|
+
const isCurrent = expr.type === "func_call" && NON_CONSTANT_DEFAULTS.has(String(nameOf(expr.name) ?? "").toUpperCase());
|
|
401
|
+
const isExpression = expr.type === "paren_expr";
|
|
402
|
+
if (!isCurrent && !isExpression)
|
|
403
|
+
return;
|
|
404
|
+
const name = nameOf(column.name);
|
|
405
|
+
out.push(finding(sl003, stmt, {
|
|
406
|
+
title: `ADD COLUMN "${name}" has a non-constant default — SQLite refuses it`,
|
|
407
|
+
message: `ALTER TABLE ADD COLUMN only accepts a constant default; ${isCurrent ? String(nameOf(expr.name)).toUpperCase() : "an expression in parentheses"} fails with "Cannot add a column with non-constant default" and "${table}" is unchanged. ` + `Use a constant default (or none) and set the value from the application or a trigger.`,
|
|
408
|
+
object: { table, column: name }
|
|
409
|
+
}));
|
|
410
|
+
});
|
|
411
|
+
return out;
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
var sl004 = {
|
|
415
|
+
meta: sl004Meta,
|
|
416
|
+
check(ctx) {
|
|
417
|
+
const out = [];
|
|
418
|
+
eachAddedColumn(ctx, (stmt, table, column) => {
|
|
419
|
+
const generated = constraintOf(column, "constraint_generated");
|
|
420
|
+
const storage = generated?.storageKw ?? generated?.constraint?.storageKw;
|
|
421
|
+
if (!generated || String(storage?.name ?? "").toUpperCase() !== "STORED")
|
|
422
|
+
return;
|
|
423
|
+
const name = nameOf(column.name);
|
|
424
|
+
out.push(finding(sl004, stmt, {
|
|
425
|
+
title: `ADD COLUMN "${name}" GENERATED ... STORED is rejected by SQLite`,
|
|
426
|
+
message: `Only VIRTUAL generated columns can be added with ALTER TABLE; a STORED one fails ("cannot add a STORED column") and "${table}" is unchanged. ` + `Declare it VIRTUAL, or make it part of a table rebuild.`,
|
|
427
|
+
object: { table, column: name }
|
|
428
|
+
}));
|
|
429
|
+
});
|
|
430
|
+
return out;
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
var sl005 = {
|
|
434
|
+
meta: sl005Meta,
|
|
435
|
+
check(ctx) {
|
|
436
|
+
const out = [];
|
|
437
|
+
eachAddedColumn(ctx, (stmt, table, column) => {
|
|
438
|
+
const references = constraintOf(column, "references_specification") ?? constraintOf(column, "constraint_foreign_key");
|
|
439
|
+
if (!references)
|
|
440
|
+
return;
|
|
441
|
+
if (isNullDefault(column))
|
|
442
|
+
return;
|
|
443
|
+
const name = nameOf(column.name);
|
|
444
|
+
out.push(finding(sl005, stmt, {
|
|
445
|
+
title: `ADD COLUMN "${name}" REFERENCES with a non-NULL default fails when foreign keys are on`,
|
|
446
|
+
message: `With PRAGMA foreign_keys=ON — the setting most applications use — SQLite rejects this statement ("Cannot add a REFERENCES column with non-NULL default value"); with it off the statement passes, so the outcome depends on the runner's connection. ` + `Add the column with a NULL default and backfill it.`,
|
|
447
|
+
object: { table, column: name }
|
|
448
|
+
}));
|
|
449
|
+
});
|
|
450
|
+
return out;
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
var sl008 = {
|
|
454
|
+
meta: sl008Meta,
|
|
455
|
+
check(ctx) {
|
|
456
|
+
const out = [];
|
|
457
|
+
eachAddedColumn(ctx, (stmt, table, column) => {
|
|
458
|
+
const check = constraintOf(column, "constraint_check");
|
|
459
|
+
const generatedNotNull = constraintOf(column, "constraint_generated") && constraintOf(column, "constraint_not_null");
|
|
460
|
+
if (!check && !generatedNotNull)
|
|
461
|
+
return;
|
|
462
|
+
const name = nameOf(column.name);
|
|
463
|
+
const what = check ? "a CHECK constraint" : "NOT NULL on a generated column";
|
|
464
|
+
out.push(finding(sl008, stmt, {
|
|
465
|
+
title: `ADD COLUMN "${name}" with ${what} scans every row of "${table}"`,
|
|
466
|
+
message: `Adding a column is schema-only in SQLite — except with ${what}, where the whole table is read under the write lock to verify existing rows before the change commits. ` + `On a large table, schedule it; or add the column without the constraint and enforce it in a later rebuild.`,
|
|
467
|
+
object: { table, column: name }
|
|
468
|
+
}));
|
|
469
|
+
});
|
|
470
|
+
return out;
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
// src/rules/dml-and-maintenance.ts
|
|
475
|
+
var sl013 = {
|
|
476
|
+
meta: sl013Meta,
|
|
477
|
+
check(ctx) {
|
|
478
|
+
const out = [];
|
|
479
|
+
for (const stmt of ctx.migration.statements) {
|
|
480
|
+
if (stmt.node.type !== "update_stmt" && stmt.node.type !== "delete_stmt")
|
|
481
|
+
continue;
|
|
482
|
+
if (hasWhere(stmt))
|
|
483
|
+
continue;
|
|
484
|
+
const table = tableOf(stmt) ?? "";
|
|
485
|
+
if (table && tableCreatedInMigration(ctx.migration, stmt.index, table))
|
|
486
|
+
continue;
|
|
487
|
+
const kind = stmt.node.type === "update_stmt" ? "UPDATE" : "DELETE";
|
|
488
|
+
out.push(finding(sl013, stmt, {
|
|
489
|
+
title: `${kind} with no WHERE touches every row of "${table}"`,
|
|
490
|
+
message: `Every row changes in one write transaction; SQLite's single writer holds the lock for the whole statement and every other writer waits or fails with SQLITE_BUSY, while the journal grows by the size of the change. ` + `Add the predicate — and if this is a backfill, batch it by rowid range from a job.`,
|
|
491
|
+
object: { table }
|
|
492
|
+
}));
|
|
493
|
+
}
|
|
494
|
+
return out;
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
var sl014 = {
|
|
498
|
+
meta: sl014Meta,
|
|
499
|
+
check(ctx) {
|
|
500
|
+
const out = [];
|
|
501
|
+
const inTx = insideTransaction(ctx.migration);
|
|
502
|
+
for (const stmt of ctx.migration.statements) {
|
|
503
|
+
if (stmt.node.type !== "vacuum_stmt")
|
|
504
|
+
continue;
|
|
505
|
+
if (stmt.node.intoKw)
|
|
506
|
+
continue;
|
|
507
|
+
const inside = inTx[stmt.index] === true;
|
|
508
|
+
out.push(finding(sl014, stmt, {
|
|
509
|
+
severity: inside ? "critical" : sl014Meta.defaultSeverity,
|
|
510
|
+
title: inside ? "VACUUM inside a transaction fails" : "VACUUM rewrites the whole database file",
|
|
511
|
+
message: inside ? `SQLite refuses VACUUM while a transaction is open ("cannot VACUUM from within a transaction"), so the migration stops here. Vacuum from an operational job, outside any transaction.` : `VACUUM copies the entire database into a temporary file and swaps it in: up to twice the database's size in free space, and the write lock held for the whole rewrite. It belongs in a maintenance job, not a migration — and if the runner wraps this file in a transaction it fails outright.`
|
|
512
|
+
}));
|
|
513
|
+
}
|
|
514
|
+
return out;
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
var sl015 = {
|
|
518
|
+
meta: sl015Meta,
|
|
519
|
+
check(ctx) {
|
|
520
|
+
const out = [];
|
|
521
|
+
for (const stmt of ctx.migration.statements) {
|
|
522
|
+
if (stmt.node.type !== "reindex_stmt")
|
|
523
|
+
continue;
|
|
524
|
+
if (stmt.node.name)
|
|
525
|
+
continue;
|
|
526
|
+
out.push(finding(sl015, stmt, {
|
|
527
|
+
title: "REINDEX with no argument rebuilds every index in the database",
|
|
528
|
+
message: `All indexes of every attached database are rebuilt in one write transaction — the lock is held for the total. Name the index or table that needs it (REINDEX is only needed after a collation change).`
|
|
529
|
+
}));
|
|
530
|
+
}
|
|
531
|
+
return out;
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
// src/rules/indexes-and-types.ts
|
|
536
|
+
var sl016 = {
|
|
537
|
+
meta: sl016Meta,
|
|
538
|
+
check(ctx) {
|
|
539
|
+
const out = [];
|
|
540
|
+
for (const stmt of ctx.migration.statements) {
|
|
541
|
+
if (stmt.node.type !== "create_index_stmt")
|
|
542
|
+
continue;
|
|
543
|
+
const table = tableOf(stmt) ?? "";
|
|
544
|
+
if (table && tableCreatedInMigration(ctx.migration, stmt.index, table))
|
|
545
|
+
continue;
|
|
546
|
+
const index = nameOf(stmt.node.name);
|
|
547
|
+
out.push(finding(sl016, stmt, {
|
|
548
|
+
title: `Index "${index}" is built under the write lock on "${table}"`,
|
|
549
|
+
message: `SQLite has no concurrent index build: the table is scanned and the index written in one write transaction, and every other writer waits (or gets SQLITE_BUSY) until it finishes. ` + `Small table, no problem; large table, build it in a window with busy_timeout set on the application's connections.`,
|
|
550
|
+
object: { table, index }
|
|
551
|
+
}));
|
|
552
|
+
}
|
|
553
|
+
return out;
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
function primaryKeyOf(column) {
|
|
557
|
+
return constraintOf(column, "constraint_primary_key");
|
|
558
|
+
}
|
|
559
|
+
var sl017 = {
|
|
560
|
+
meta: sl017Meta,
|
|
561
|
+
check(ctx) {
|
|
562
|
+
const out = [];
|
|
563
|
+
for (const stmt of ctx.migration.statements) {
|
|
564
|
+
if (stmt.node.type !== "create_table_stmt")
|
|
565
|
+
continue;
|
|
566
|
+
if (tableOptions(stmt).includes("WITHOUT ROWID"))
|
|
567
|
+
continue;
|
|
568
|
+
const table = tableOf(stmt) ?? "";
|
|
569
|
+
const columns = createdColumns(stmt);
|
|
570
|
+
if (tableConstraints(stmt).some((c) => c.type === "constraint_primary_key" || c.constraint?.type === "constraint_primary_key"))
|
|
571
|
+
continue;
|
|
572
|
+
for (const column of columns) {
|
|
573
|
+
const pk = primaryKeyOf(column);
|
|
574
|
+
if (!pk)
|
|
575
|
+
continue;
|
|
576
|
+
const type = dataTypeName(column);
|
|
577
|
+
const direction = (pk.direction ?? pk.constraint?.direction)?.type;
|
|
578
|
+
const integerLike = /^(INT|INTEGER|BIGINT|SMALLINT|TINYINT|MEDIUMINT|INT2|INT8|UNSIGNED BIG INT)$/.test(type);
|
|
579
|
+
if (!integerLike)
|
|
580
|
+
continue;
|
|
581
|
+
const exact = type === "INTEGER" && direction !== "sort_direction_desc";
|
|
582
|
+
if (exact)
|
|
583
|
+
continue;
|
|
584
|
+
const name = nameOf(column.name);
|
|
585
|
+
out.push(finding(sl017, stmt, {
|
|
586
|
+
title: `"${name}" ${type} PRIMARY KEY${direction === "sort_direction_desc" ? " DESC" : ""} on "${table}" is not a rowid alias`,
|
|
587
|
+
message: `Only a column declared exactly INTEGER PRIMARY KEY aliases the rowid. This one gets its own unique index: every primary-key lookup is an extra B-tree hop, the value is not the rowid, and VACUUM may renumber the real rowid underneath it. ` + `Spell it INTEGER PRIMARY KEY (ascending).`,
|
|
588
|
+
object: { table, column: name }
|
|
589
|
+
}));
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return out;
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
var sl018 = {
|
|
596
|
+
meta: sl018Meta,
|
|
597
|
+
check(ctx) {
|
|
598
|
+
const out = [];
|
|
599
|
+
for (const stmt of ctx.migration.statements) {
|
|
600
|
+
if (stmt.node.type !== "create_table_stmt")
|
|
601
|
+
continue;
|
|
602
|
+
const table = tableOf(stmt) ?? "";
|
|
603
|
+
for (const column of createdColumns(stmt)) {
|
|
604
|
+
if (!constraintOf(column, "constraint_auto_increment"))
|
|
605
|
+
continue;
|
|
606
|
+
const name = nameOf(column.name);
|
|
607
|
+
out.push(finding(sl018, stmt, {
|
|
608
|
+
title: `AUTOINCREMENT on "${table}"."${name}" costs every insert an extra write`,
|
|
609
|
+
message: `Each insert also updates sqlite_sequence; the SQLite manual says AUTOINCREMENT "should be avoided if not strictly needed". INTEGER PRIMARY KEY already assigns increasing ids — keep the keyword only if reusing a deleted id would be a correctness bug.`,
|
|
610
|
+
object: { table, column: name }
|
|
611
|
+
}));
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return out;
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
// src/rules/performance.ts
|
|
619
|
+
function indexColumns(stmt) {
|
|
620
|
+
return items(stmt.node.columns).map((spec) => (nameOf(spec.expr ?? spec) ?? "").toLowerCase());
|
|
621
|
+
}
|
|
622
|
+
function createdIndexes(ctx) {
|
|
623
|
+
const out = [];
|
|
624
|
+
for (const stmt of ctx.migration.statements) {
|
|
625
|
+
if (stmt.node.type !== "create_index_stmt")
|
|
626
|
+
continue;
|
|
627
|
+
const specs = items(stmt.node.columns);
|
|
628
|
+
if (specs.some((spec) => (spec.expr ?? spec).type !== "identifier"))
|
|
629
|
+
continue;
|
|
630
|
+
out.push({
|
|
631
|
+
stmt,
|
|
632
|
+
name: nameOf(stmt.node.name) ?? "",
|
|
633
|
+
table: (tableOf(stmt) ?? "").toLowerCase(),
|
|
634
|
+
columns: indexColumns(stmt),
|
|
635
|
+
unique: String(stmt.node.indexTypeKw?.name ?? "").toUpperCase() === "UNIQUE"
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
return out;
|
|
639
|
+
}
|
|
640
|
+
function keyedColumns(ctx, table) {
|
|
641
|
+
const out = { unique: [] };
|
|
642
|
+
for (const stmt of ctx.migration.statements) {
|
|
643
|
+
if (stmt.node.type !== "create_table_stmt" || (tableOf(stmt) ?? "").toLowerCase() !== table)
|
|
644
|
+
continue;
|
|
645
|
+
const withoutRowid = tableOptions(stmt).includes("WITHOUT ROWID");
|
|
646
|
+
for (const column of createdColumns(stmt)) {
|
|
647
|
+
const name = (nameOf(column.name) ?? "").toLowerCase();
|
|
648
|
+
const pk = constraintOf(column, "constraint_primary_key");
|
|
649
|
+
if (pk && !withoutRowid && dataTypeName(column) === "INTEGER" && (pk.direction ?? pk.constraint?.direction)?.type !== "sort_direction_desc")
|
|
650
|
+
out.rowid = name;
|
|
651
|
+
else if (pk || constraintOf(column, "constraint_unique"))
|
|
652
|
+
out.unique.push([name]);
|
|
653
|
+
}
|
|
654
|
+
for (const constraint of tableConstraints(stmt)) {
|
|
655
|
+
const inner = constraint.constraint ?? constraint;
|
|
656
|
+
if (inner.type === "constraint_primary_key" || inner.type === "constraint_unique") {
|
|
657
|
+
out.unique.push(items(inner.columns).map((spec) => (nameOf(spec.expr ?? spec) ?? "").toLowerCase()));
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
return out;
|
|
662
|
+
}
|
|
663
|
+
var isPrefix = (short, long) => short.length <= long.length && short.every((c, i) => long[i] === c);
|
|
664
|
+
var sameColumns = (a, b) => a.length === b.length && isPrefix(a, b);
|
|
665
|
+
var sl031 = {
|
|
666
|
+
meta: sl031Meta,
|
|
667
|
+
check(ctx) {
|
|
668
|
+
const out = [];
|
|
669
|
+
const indexes = createdIndexes(ctx);
|
|
670
|
+
for (const index of indexes) {
|
|
671
|
+
const keyed = keyedColumns(ctx, index.table);
|
|
672
|
+
let why;
|
|
673
|
+
if (index.columns.length === 1 && index.columns[0] === keyed.rowid) {
|
|
674
|
+
why = `"${index.columns[0]}" is the INTEGER PRIMARY KEY — the rowid, which the table's own B-tree already indexes`;
|
|
675
|
+
} else if (keyed.unique.some((u) => sameColumns(u, index.columns))) {
|
|
676
|
+
why = `(${index.columns.join(", ")}) is already UNIQUE, which SQLite enforces with an index of its own`;
|
|
677
|
+
} else {
|
|
678
|
+
const wider = indexes.find((other) => other !== index && other.table === index.table && isPrefix(index.columns, other.columns) && (!index.unique || other.unique) && (other.columns.length > index.columns.length || other.stmt.index < index.stmt.index));
|
|
679
|
+
if (wider) {
|
|
680
|
+
why = sameColumns(index.columns, wider.columns) ? `it duplicates "${wider.name}"` : `(${index.columns.join(", ")}) is a leading prefix of "${wider.name}" (${wider.columns.join(", ")}), which serves the same lookups`;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
if (!why)
|
|
684
|
+
continue;
|
|
685
|
+
out.push(finding(sl031, index.stmt, {
|
|
686
|
+
title: `Index "${index.name}" on "${index.table}" is redundant`,
|
|
687
|
+
message: `${why[0].toUpperCase()}${why.slice(1)}. The index changes no plan and costs a write on every insert, update and delete. Drop it.`,
|
|
688
|
+
object: { table: index.table, index: index.name }
|
|
689
|
+
}));
|
|
690
|
+
}
|
|
691
|
+
return out;
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
var sl032 = {
|
|
695
|
+
meta: sl032Meta,
|
|
696
|
+
check(ctx) {
|
|
697
|
+
const out = [];
|
|
698
|
+
const statements = ctx.migration.statements;
|
|
699
|
+
for (const stmt of statements) {
|
|
700
|
+
if (stmt.node.type !== "create_index_stmt")
|
|
701
|
+
continue;
|
|
702
|
+
const table = (tableOf(stmt) ?? "").toLowerCase();
|
|
703
|
+
const created = statements.some((s) => s.index < stmt.index && s.node.type === "create_table_stmt" && (tableOf(s) ?? "").toLowerCase() === table);
|
|
704
|
+
if (!created)
|
|
705
|
+
continue;
|
|
706
|
+
const copy = statements.find((s) => s.index > stmt.index && s.node.type === "insert_stmt" && (tableOf(s) ?? "").toLowerCase() === table && s.node.clauses?.some((c) => c.type === "select_stmt" || c.type === "compound_select_stmt"));
|
|
707
|
+
if (!copy)
|
|
708
|
+
continue;
|
|
709
|
+
const index = nameOf(stmt.node.name) ?? "";
|
|
710
|
+
out.push(finding(sl032, stmt, {
|
|
711
|
+
title: `Index "${index}" is built before the copy into "${table}"`,
|
|
712
|
+
message: `The INSERT ... SELECT that fills "${table}" maintains this index row by row. Move the CREATE INDEX after the copy so SQLite builds it once, sorted, over the populated table.`,
|
|
713
|
+
object: { table, index }
|
|
714
|
+
}));
|
|
715
|
+
}
|
|
716
|
+
return out;
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
var sl033 = {
|
|
720
|
+
meta: sl033Meta,
|
|
721
|
+
check(ctx) {
|
|
722
|
+
const out = [];
|
|
723
|
+
for (const stmt of ctx.migration.statements) {
|
|
724
|
+
if (stmt.node.type !== "create_table_stmt" || tableOptions(stmt).includes("WITHOUT ROWID"))
|
|
725
|
+
continue;
|
|
726
|
+
const columns = createdColumns(stmt);
|
|
727
|
+
const tableLevel = tableConstraints(stmt).some((c) => (c.constraint ?? c).type === "constraint_primary_key");
|
|
728
|
+
if (tableLevel)
|
|
729
|
+
continue;
|
|
730
|
+
const pk = columns.find((c) => constraintOf(c, "constraint_primary_key"));
|
|
731
|
+
if (!pk)
|
|
732
|
+
continue;
|
|
733
|
+
const type = dataTypeName(pk);
|
|
734
|
+
if (!/^(TEXT|BLOB|VARCHAR|CHAR|CLOB|CHARACTER|NCHAR|NVARCHAR|UUID)/.test(type))
|
|
735
|
+
continue;
|
|
736
|
+
const table = tableOf(stmt) ?? "";
|
|
737
|
+
const name = nameOf(pk.name) ?? "";
|
|
738
|
+
out.push(finding(sl033, stmt, {
|
|
739
|
+
title: `"${table}" keys on ${type} "${name}" but stays a rowid table`,
|
|
740
|
+
message: `The primary key is a separate unique index over a rowid B-tree: every lookup by "${name}" walks two trees and the key is stored twice. Declare the table WITHOUT ROWID so the key is the table's own B-tree (small rows), or keep rowid deliberately for wide rows.`,
|
|
741
|
+
object: { table, column: name }
|
|
742
|
+
}));
|
|
743
|
+
}
|
|
744
|
+
return out;
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
var sl034 = {
|
|
748
|
+
meta: sl034Meta,
|
|
749
|
+
check(ctx) {
|
|
750
|
+
const out = [];
|
|
751
|
+
for (const stmt of ctx.migration.statements) {
|
|
752
|
+
const pragma = pragmaOf(stmt);
|
|
753
|
+
if (pragma?.name !== "journal_mode" || pragma.value === undefined || pragma.value === "wal")
|
|
754
|
+
continue;
|
|
755
|
+
out.push(finding(sl034, stmt, {
|
|
756
|
+
title: `journal_mode = ${pragma.value.toUpperCase()} is stored in the file for every connection`,
|
|
757
|
+
message: `${pragma.value.toUpperCase()} is a rollback-journal mode: a writer blocks every reader for the length of its transaction, and this setting outlives the migration — it is written into the database file. Set WAL, or leave the mode to the application's connection setup.`
|
|
758
|
+
}));
|
|
759
|
+
}
|
|
760
|
+
return out;
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
var CONNECTION_PRAGMAS = new Set(["cache_size", "synchronous", "temp_store", "mmap_size", "busy_timeout"]);
|
|
764
|
+
var sl035 = {
|
|
765
|
+
meta: sl035Meta,
|
|
766
|
+
check(ctx) {
|
|
767
|
+
const out = [];
|
|
768
|
+
for (const stmt of ctx.migration.statements) {
|
|
769
|
+
const pragma = pragmaOf(stmt);
|
|
770
|
+
if (!pragma || pragma.value === undefined || !CONNECTION_PRAGMAS.has(pragma.name))
|
|
771
|
+
continue;
|
|
772
|
+
const durability = pragma.name === "synchronous" && ["off", "0"].includes(pragma.value);
|
|
773
|
+
out.push(finding(sl035, stmt, {
|
|
774
|
+
title: `PRAGMA ${pragma.name} in a migration reaches only the runner's connection`,
|
|
775
|
+
message: `${pragma.name} is per connection: the application never sees this value. ` + (durability ? `And synchronous=OFF means a crash during the schema change can corrupt the file. ` : ``) + `Set connection pragmas where the application opens connections.`
|
|
776
|
+
}));
|
|
777
|
+
}
|
|
778
|
+
return out;
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
// src/rules/rebuild-pragmas.ts
|
|
783
|
+
function pragmaTimeline(ctx) {
|
|
784
|
+
const off = [];
|
|
785
|
+
const on = [];
|
|
786
|
+
const check = [];
|
|
787
|
+
for (const stmt of ctx.migration.statements) {
|
|
788
|
+
const pragma = pragmaOf(stmt);
|
|
789
|
+
if (!pragma)
|
|
790
|
+
continue;
|
|
791
|
+
if (pragma.name === "foreign_keys" && pragmaIsOff(pragma.value))
|
|
792
|
+
off.push(stmt.index);
|
|
793
|
+
else if (pragma.name === "foreign_keys" && pragmaIsOn(pragma.value))
|
|
794
|
+
on.push(stmt.index);
|
|
795
|
+
else if (pragma.name === "foreign_key_check")
|
|
796
|
+
check.push(stmt.index);
|
|
797
|
+
}
|
|
798
|
+
return { off, on, check };
|
|
799
|
+
}
|
|
800
|
+
var sl009 = {
|
|
801
|
+
meta: sl009Meta,
|
|
802
|
+
check(ctx) {
|
|
803
|
+
const out = [];
|
|
804
|
+
const { off } = pragmaTimeline(ctx);
|
|
805
|
+
const inTx = insideTransaction(ctx.migration);
|
|
806
|
+
for (const { drop, table } of tableRebuilds(ctx.migration)) {
|
|
807
|
+
const effectiveOff = off.some((index) => index < drop.index && !inTx[index]);
|
|
808
|
+
if (effectiveOff)
|
|
809
|
+
continue;
|
|
810
|
+
out.push(finding(sl009, drop, {
|
|
811
|
+
title: `Rebuild of "${table}" drops it with foreign keys still enforced`,
|
|
812
|
+
message: `This file rebuilds "${table}" (drop, then rename a new table onto the name) without PRAGMA foreign_keys=OFF first. ` + `With enforcement on, DROP TABLE runs an implicit DELETE FROM "${table}": rows referencing it fail the migration, or ON DELETE CASCADE removes them. ` + `Switch foreign keys off outside the transaction, rebuild, run PRAGMA foreign_key_check, commit, switch them on.`,
|
|
813
|
+
object: { table }
|
|
814
|
+
}));
|
|
815
|
+
}
|
|
816
|
+
return out;
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
var TRANSACTION_NOOP_PRAGMAS = new Set(["foreign_keys", "journal_mode"]);
|
|
820
|
+
var sl010 = {
|
|
821
|
+
meta: sl010Meta,
|
|
822
|
+
check(ctx) {
|
|
823
|
+
const out = [];
|
|
824
|
+
const inTx = insideTransaction(ctx.migration);
|
|
825
|
+
for (const stmt of ctx.migration.statements) {
|
|
826
|
+
const pragma = pragmaOf(stmt);
|
|
827
|
+
if (!pragma || pragma.value === undefined || !TRANSACTION_NOOP_PRAGMAS.has(pragma.name))
|
|
828
|
+
continue;
|
|
829
|
+
if (!inTx[stmt.index])
|
|
830
|
+
continue;
|
|
831
|
+
out.push(finding(sl010, stmt, {
|
|
832
|
+
title: `PRAGMA ${pragma.name} inside a transaction does nothing`,
|
|
833
|
+
message: `SQLite ignores PRAGMA ${pragma.name} while a transaction is open — no error, the setting stays as it was. ` + `Move it before BEGIN (and its counterpart after COMMIT); if the runner wraps the file in a transaction, the runner has to set it.`
|
|
834
|
+
}));
|
|
835
|
+
}
|
|
836
|
+
return out;
|
|
837
|
+
}
|
|
838
|
+
};
|
|
839
|
+
var sl011 = {
|
|
840
|
+
meta: sl011Meta,
|
|
841
|
+
check(ctx) {
|
|
842
|
+
const { off, on } = pragmaTimeline(ctx);
|
|
843
|
+
if (off.length === 0)
|
|
844
|
+
return [];
|
|
845
|
+
const lastOff = off[off.length - 1];
|
|
846
|
+
if (on.some((index) => index > lastOff))
|
|
847
|
+
return [];
|
|
848
|
+
const stmt = ctx.migration.statements[lastOff];
|
|
849
|
+
return [
|
|
850
|
+
finding(sl011, stmt, {
|
|
851
|
+
title: "Foreign keys are switched off and not switched back on",
|
|
852
|
+
message: `PRAGMA foreign_keys=OFF is per connection and survives COMMIT; nothing in this file re-enables it, so the runner's connection accepts orphaned rows for every statement that follows. ` + `End the file with PRAGMA foreign_keys=ON after the transaction that needed it off.`
|
|
853
|
+
})
|
|
854
|
+
];
|
|
855
|
+
}
|
|
856
|
+
};
|
|
857
|
+
var sl012 = {
|
|
858
|
+
meta: sl012Meta,
|
|
859
|
+
check(ctx) {
|
|
860
|
+
const out = [];
|
|
861
|
+
const { off, on, check } = pragmaTimeline(ctx);
|
|
862
|
+
for (const onIndex of on) {
|
|
863
|
+
const offIndex = [...off].reverse().find((index) => index < onIndex);
|
|
864
|
+
if (offIndex === undefined)
|
|
865
|
+
continue;
|
|
866
|
+
if (check.some((index) => index > offIndex && index < onIndex))
|
|
867
|
+
continue;
|
|
868
|
+
const stmt = ctx.migration.statements[onIndex];
|
|
869
|
+
out.push(finding(sl012, stmt, {
|
|
870
|
+
title: "Foreign keys re-enabled without a foreign_key_check",
|
|
871
|
+
message: `Turning enforcement back on does not validate rows written while it was off. Run PRAGMA foreign_key_check inside the transaction, before COMMIT, so a violation can still be rolled back.`
|
|
872
|
+
}));
|
|
873
|
+
}
|
|
874
|
+
return out;
|
|
875
|
+
}
|
|
876
|
+
};
|
|
877
|
+
var sl019 = {
|
|
878
|
+
meta: sl019Meta,
|
|
879
|
+
check(ctx) {
|
|
880
|
+
const out = [];
|
|
881
|
+
for (const rebuild of tableRebuilds(ctx.migration)) {
|
|
882
|
+
if (!rebuild.replacementCreatedHere || rebuild.copy)
|
|
883
|
+
continue;
|
|
884
|
+
const { drop, table, replacement } = rebuild;
|
|
885
|
+
out.push(finding(sl019, drop, {
|
|
886
|
+
title: `Rebuild of "${table}" drops it without copying its rows into "${replacement}"`,
|
|
887
|
+
message: `This file creates "${replacement}", drops "${table}" and renames "${replacement}" onto the name, but no INSERT INTO "${replacement}" ... SELECT ... FROM "${table}" runs before the DROP. ` + `DROP TABLE discards the rows; the rename brings back the name with an empty table. Copy first (INSERT INTO "${replacement}" SELECT ... FROM "${table}"), then drop — or, if the data is meant to go, say so in a bolvrk-ignore reason.`,
|
|
888
|
+
object: { table }
|
|
889
|
+
}));
|
|
890
|
+
}
|
|
891
|
+
return out;
|
|
892
|
+
}
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
// src/rules/schema-and-transactions.ts
|
|
896
|
+
var sl020 = {
|
|
897
|
+
meta: sl020Meta,
|
|
898
|
+
check(ctx) {
|
|
899
|
+
const out = [];
|
|
900
|
+
for (const stmt of ctx.migration.statements) {
|
|
901
|
+
if (stmt.node.type !== "create_table_stmt")
|
|
902
|
+
continue;
|
|
903
|
+
if (createdColumns(stmt).length === 0)
|
|
904
|
+
continue;
|
|
905
|
+
if (tableOptions(stmt).includes("STRICT"))
|
|
906
|
+
continue;
|
|
907
|
+
const table = tableOf(stmt) ?? "";
|
|
908
|
+
out.push(finding(sl020, stmt, {
|
|
909
|
+
title: `"${table}" is created without STRICT — column types are not enforced`,
|
|
910
|
+
message: `Without STRICT, SQLite stores whatever value arrives regardless of the declared type: text in an INTEGER column, a number in a TEXT one. ` + `Add STRICT now — it is a CREATE TABLE option only, and adding it later means rebuilding the table.`,
|
|
911
|
+
object: { table }
|
|
912
|
+
}));
|
|
913
|
+
}
|
|
914
|
+
return out;
|
|
915
|
+
}
|
|
916
|
+
};
|
|
917
|
+
function childKeys(stmt) {
|
|
918
|
+
if (stmt.node.type !== "create_table_stmt")
|
|
919
|
+
return [];
|
|
920
|
+
const table = tableOf(stmt) ?? "";
|
|
921
|
+
const out = [];
|
|
922
|
+
for (const column of createdColumns(stmt)) {
|
|
923
|
+
const ref = constraintOf(column, "references_specification");
|
|
924
|
+
if (!ref)
|
|
925
|
+
continue;
|
|
926
|
+
const spec = ref.type === "references_specification" ? ref : ref.constraint;
|
|
927
|
+
out.push({ table, columns: [nameOf(column.name) ?? ""], parent: nameOf(spec.table) ?? "", cascade: cascades(spec) });
|
|
928
|
+
}
|
|
929
|
+
for (const constraint of tableConstraints(stmt)) {
|
|
930
|
+
const fk = constraint.type === "constraint_foreign_key" ? constraint : constraint.constraint?.type === "constraint_foreign_key" ? constraint.constraint : undefined;
|
|
931
|
+
if (!fk)
|
|
932
|
+
continue;
|
|
933
|
+
const spec = fk.references ?? fk;
|
|
934
|
+
out.push({
|
|
935
|
+
table,
|
|
936
|
+
columns: items(fk.columns).map((c) => nameOf(c) ?? ""),
|
|
937
|
+
parent: nameOf(spec.table) ?? "",
|
|
938
|
+
cascade: cascades(spec) || cascades(fk)
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
return out;
|
|
942
|
+
}
|
|
943
|
+
function cascades(spec) {
|
|
944
|
+
return descendants(spec, (n) => n.type === "referential_action").some((action) => {
|
|
945
|
+
const words = descendants(action, (n) => n.type === "keyword").map((k) => String(k.name).toUpperCase());
|
|
946
|
+
return words.includes("DELETE") && words.includes("CASCADE");
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
function indexedPrefixes(ctx, table) {
|
|
950
|
+
const out = [];
|
|
951
|
+
for (const stmt of ctx.migration.statements) {
|
|
952
|
+
if (stmt.node.type === "create_index_stmt" && tableOf(stmt)?.toLowerCase() === table.toLowerCase()) {
|
|
953
|
+
out.push(items(stmt.node.columns).map((spec) => nameOf(spec.expr ?? spec) ?? ""));
|
|
954
|
+
}
|
|
955
|
+
if (stmt.node.type === "create_table_stmt" && tableOf(stmt)?.toLowerCase() === table.toLowerCase()) {
|
|
956
|
+
for (const column of createdColumns(stmt)) {
|
|
957
|
+
if (constraintOf(column, "constraint_primary_key") || constraintOf(column, "constraint_unique"))
|
|
958
|
+
out.push([nameOf(column.name) ?? ""]);
|
|
959
|
+
}
|
|
960
|
+
for (const constraint of tableConstraints(stmt)) {
|
|
961
|
+
const inner = constraint.constraint ?? constraint;
|
|
962
|
+
if (inner.type === "constraint_primary_key" || inner.type === "constraint_unique") {
|
|
963
|
+
out.push(items(inner.columns).map((spec) => nameOf(spec.expr ?? spec) ?? ""));
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
return out;
|
|
969
|
+
}
|
|
970
|
+
function isPrefix2(columns, index) {
|
|
971
|
+
return columns.every((column, i) => index[i]?.toLowerCase() === column.toLowerCase());
|
|
972
|
+
}
|
|
973
|
+
var sl021 = {
|
|
974
|
+
meta: sl021Meta,
|
|
975
|
+
check(ctx) {
|
|
976
|
+
const out = [];
|
|
977
|
+
for (const stmt of ctx.migration.statements) {
|
|
978
|
+
for (const key of childKeys(stmt)) {
|
|
979
|
+
if (indexedPrefixes(ctx, key.table).some((index) => isPrefix2(key.columns, index)))
|
|
980
|
+
continue;
|
|
981
|
+
out.push(finding(sl021, stmt, {
|
|
982
|
+
title: `Foreign key "${key.table}"(${key.columns.join(", ")}) has no index`,
|
|
983
|
+
message: `Every DELETE or UPDATE of a "${key.parent}" row scans all of "${key.table}" to check for references, because SQLite never indexes the child side of a foreign key. ` + `Add CREATE INDEX on (${key.columns.join(", ")}) in this migration.`,
|
|
984
|
+
object: { table: key.table, column: key.columns[0] }
|
|
985
|
+
}));
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
return out;
|
|
989
|
+
}
|
|
990
|
+
};
|
|
991
|
+
var sl022 = {
|
|
992
|
+
meta: sl022Meta,
|
|
993
|
+
check(ctx) {
|
|
994
|
+
const out = [];
|
|
995
|
+
let legacy = false;
|
|
996
|
+
for (const stmt of ctx.migration.statements) {
|
|
997
|
+
const pragma = pragmaOf(stmt);
|
|
998
|
+
if (pragma?.name === "legacy_alter_table") {
|
|
999
|
+
legacy = pragmaIsOn(pragma.value);
|
|
1000
|
+
continue;
|
|
1001
|
+
}
|
|
1002
|
+
if (!legacy)
|
|
1003
|
+
continue;
|
|
1004
|
+
for (const action of alterActions(stmt)) {
|
|
1005
|
+
if (action.type !== "alter_action_rename" && action.type !== "alter_action_rename_column")
|
|
1006
|
+
continue;
|
|
1007
|
+
const table = tableOf(stmt) ?? "";
|
|
1008
|
+
out.push(finding(sl022, stmt, {
|
|
1009
|
+
title: `Rename on "${table}" runs with legacy_alter_table on — views and triggers are not rewritten`,
|
|
1010
|
+
message: `PRAGMA legacy_alter_table=ON restores the pre-3.26 rename: any view or trigger that names the old identifier keeps naming it, and fails the first time it is used. ` + `Switch the pragma off before renaming.`,
|
|
1011
|
+
object: { table }
|
|
1012
|
+
}));
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
return out;
|
|
1016
|
+
}
|
|
1017
|
+
};
|
|
1018
|
+
function dependents(ctx, beforeIndex) {
|
|
1019
|
+
const live = new Map;
|
|
1020
|
+
for (const stmt of ctx.migration.statements) {
|
|
1021
|
+
if (stmt.index >= beforeIndex)
|
|
1022
|
+
break;
|
|
1023
|
+
const node = stmt.node;
|
|
1024
|
+
if (node.type === "create_view_stmt" || node.type === "create_trigger_stmt") {
|
|
1025
|
+
const kind = node.type === "create_view_stmt" ? "view" : "trigger";
|
|
1026
|
+
const identifiers = new Set(descendants([node.clauses, node.body], (n) => n.type === "identifier").map((n) => String(n.name).toLowerCase()));
|
|
1027
|
+
const target = kind === "trigger" ? nameOf(node.target?.table)?.toLowerCase() : undefined;
|
|
1028
|
+
if (target)
|
|
1029
|
+
identifiers.add(target);
|
|
1030
|
+
live.set(`${kind}:${(nameOf(node.name) ?? "").toLowerCase()}`, { kind, name: nameOf(node.name) ?? "", identifiers, target });
|
|
1031
|
+
} else if (node.type === "drop_view_stmt") {
|
|
1032
|
+
for (const view of items(node.views))
|
|
1033
|
+
live.delete(`view:${(nameOf(view) ?? "").toLowerCase()}`);
|
|
1034
|
+
} else if (node.type === "drop_trigger_stmt") {
|
|
1035
|
+
live.delete(`trigger:${(nameOf(node.trigger) ?? "").toLowerCase()}`);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
return [...live.values()];
|
|
1039
|
+
}
|
|
1040
|
+
var sl023 = {
|
|
1041
|
+
meta: sl023Meta,
|
|
1042
|
+
check(ctx) {
|
|
1043
|
+
const out = [];
|
|
1044
|
+
for (const stmt of ctx.migration.statements) {
|
|
1045
|
+
for (const table of droppedTables(stmt)) {
|
|
1046
|
+
const hit = dependents(ctx, stmt.index).find((d) => d.identifiers.has(table.toLowerCase()));
|
|
1047
|
+
if (!hit)
|
|
1048
|
+
continue;
|
|
1049
|
+
out.push(finding(sl023, stmt, {
|
|
1050
|
+
title: `DROP TABLE "${table}" breaks ${hit.kind} "${hit.name}" created in this migration`,
|
|
1051
|
+
message: hit.kind === "trigger" && hit.target === table.toLowerCase() ? `Dropping the table drops the trigger with it — if that is the intent, drop the trigger explicitly so the migration says so.` : `${hit.kind === "view" ? "The view" : "The trigger"} still names "${table}"; it fails the first time it is used. Drop or redefine it first.`,
|
|
1052
|
+
object: { table }
|
|
1053
|
+
}));
|
|
1054
|
+
}
|
|
1055
|
+
for (const action of alterActions(stmt)) {
|
|
1056
|
+
if (action.type !== "alter_action_drop_column")
|
|
1057
|
+
continue;
|
|
1058
|
+
const table = tableOf(stmt) ?? "";
|
|
1059
|
+
const column = nameOf(action.column) ?? "";
|
|
1060
|
+
const hit = dependents(ctx, stmt.index).find((d) => d.identifiers.has(table.toLowerCase()) && d.identifiers.has(column.toLowerCase()));
|
|
1061
|
+
if (!hit)
|
|
1062
|
+
continue;
|
|
1063
|
+
out.push(finding(sl023, stmt, {
|
|
1064
|
+
title: `DROP COLUMN "${column}" breaks ${hit.kind} "${hit.name}", which references it`,
|
|
1065
|
+
message: hit.kind === "view" ? `The view keeps naming "${column}" and fails with "no such column" the first time it is queried (older SQLite releases refuse the DROP instead). Drop or redefine "${hit.name}" first.` : `The trigger keeps naming "${column}", so every later write that fires it fails with "no such column" — writes to "${table}" break (older SQLite releases refuse the DROP instead). Drop or redefine "${hit.name}" first.`,
|
|
1066
|
+
object: { table, column }
|
|
1067
|
+
}));
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
return out;
|
|
1071
|
+
}
|
|
1072
|
+
};
|
|
1073
|
+
var sl024 = {
|
|
1074
|
+
meta: sl024Meta,
|
|
1075
|
+
check(ctx) {
|
|
1076
|
+
const out = [];
|
|
1077
|
+
for (const stmt of ctx.migration.statements) {
|
|
1078
|
+
if (stmt.node.type !== "create_table_stmt")
|
|
1079
|
+
continue;
|
|
1080
|
+
if (tableOptions(stmt).includes("WITHOUT ROWID"))
|
|
1081
|
+
continue;
|
|
1082
|
+
const table = tableOf(stmt) ?? "";
|
|
1083
|
+
const keyed = new Set;
|
|
1084
|
+
for (const column of createdColumns(stmt))
|
|
1085
|
+
if (constraintOf(column, "constraint_primary_key"))
|
|
1086
|
+
keyed.add(nameOf(column.name) ?? "");
|
|
1087
|
+
for (const constraint of tableConstraints(stmt)) {
|
|
1088
|
+
const inner = constraint.constraint ?? constraint;
|
|
1089
|
+
if (inner.type === "constraint_primary_key")
|
|
1090
|
+
for (const spec of items(inner.columns))
|
|
1091
|
+
keyed.add(nameOf(spec.expr ?? spec) ?? "");
|
|
1092
|
+
}
|
|
1093
|
+
for (const column of createdColumns(stmt)) {
|
|
1094
|
+
const name = nameOf(column.name) ?? "";
|
|
1095
|
+
if (!keyed.has(name))
|
|
1096
|
+
continue;
|
|
1097
|
+
if (constraintOf(column, "constraint_not_null"))
|
|
1098
|
+
continue;
|
|
1099
|
+
const pk = constraintOf(column, "constraint_primary_key");
|
|
1100
|
+
const direction = (pk?.direction ?? pk?.constraint?.direction)?.type;
|
|
1101
|
+
if (pk && keyed.size === 1 && dataTypeName(column) === "INTEGER" && direction !== "sort_direction_desc")
|
|
1102
|
+
continue;
|
|
1103
|
+
out.push(finding(sl024, stmt, {
|
|
1104
|
+
title: `PRIMARY KEY column "${name}" on "${table}" accepts NULL`,
|
|
1105
|
+
message: `A non-INTEGER primary key in a rowid table does not imply NOT NULL — SQLite keeps this old behaviour for compatibility — so NULL keys are accepted and each one counts as distinct. ` + `Add NOT NULL to the column.`,
|
|
1106
|
+
object: { table, column: name }
|
|
1107
|
+
}));
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
return out;
|
|
1111
|
+
}
|
|
1112
|
+
};
|
|
1113
|
+
var WRITE_TYPES = new Set([
|
|
1114
|
+
"alter_table_stmt",
|
|
1115
|
+
"create_table_stmt",
|
|
1116
|
+
"create_index_stmt",
|
|
1117
|
+
"create_view_stmt",
|
|
1118
|
+
"create_trigger_stmt",
|
|
1119
|
+
"drop_table_stmt",
|
|
1120
|
+
"drop_index_stmt",
|
|
1121
|
+
"drop_view_stmt",
|
|
1122
|
+
"drop_trigger_stmt",
|
|
1123
|
+
"insert_stmt",
|
|
1124
|
+
"update_stmt",
|
|
1125
|
+
"delete_stmt",
|
|
1126
|
+
"reindex_stmt"
|
|
1127
|
+
]);
|
|
1128
|
+
var sl025 = {
|
|
1129
|
+
meta: sl025Meta,
|
|
1130
|
+
check(ctx) {
|
|
1131
|
+
const out = [];
|
|
1132
|
+
const statements = ctx.migration.statements;
|
|
1133
|
+
for (const stmt of statements) {
|
|
1134
|
+
if (stmt.node.type !== "start_transaction_stmt")
|
|
1135
|
+
continue;
|
|
1136
|
+
const behavior = String(stmt.node.behaviorKw?.name ?? "DEFERRED").toUpperCase();
|
|
1137
|
+
if (behavior !== "DEFERRED")
|
|
1138
|
+
continue;
|
|
1139
|
+
const writes = statements.some((later) => later.index > stmt.index && WRITE_TYPES.has(later.node.type) && !statements.some((end) => end.index > stmt.index && end.index < later.index && (end.node.type === "commit_transaction_stmt" || end.node.type === "rollback_transaction_stmt")));
|
|
1140
|
+
if (!writes)
|
|
1141
|
+
continue;
|
|
1142
|
+
out.push(finding(sl025, stmt, {
|
|
1143
|
+
title: "Write migration starts with a DEFERRED transaction",
|
|
1144
|
+
message: `A plain BEGIN takes no lock until the first write, then upgrades a read lock to a write lock — and a lock upgrade that finds another writer fails at once with SQLITE_BUSY, busy_timeout or not. ` + `Use BEGIN IMMEDIATE for a migration that writes.`
|
|
1145
|
+
}));
|
|
1146
|
+
}
|
|
1147
|
+
return out;
|
|
1148
|
+
}
|
|
1149
|
+
};
|
|
1150
|
+
var sl026 = {
|
|
1151
|
+
meta: sl026Meta,
|
|
1152
|
+
check(ctx) {
|
|
1153
|
+
const out = [];
|
|
1154
|
+
let open = false;
|
|
1155
|
+
for (const stmt of ctx.migration.statements) {
|
|
1156
|
+
const type = stmt.node.type;
|
|
1157
|
+
if (type === "start_transaction_stmt") {
|
|
1158
|
+
if (open) {
|
|
1159
|
+
out.push(finding(sl026, stmt, {
|
|
1160
|
+
title: "BEGIN inside an open transaction fails",
|
|
1161
|
+
message: `SQLite cannot nest transactions: this BEGIN fails with "cannot start a transaction within a transaction". Remove it, or use SAVEPOINT for a nested scope.`
|
|
1162
|
+
}));
|
|
1163
|
+
}
|
|
1164
|
+
open = true;
|
|
1165
|
+
} else if (type === "commit_transaction_stmt" || type === "rollback_transaction_stmt") {
|
|
1166
|
+
if (!open) {
|
|
1167
|
+
out.push(finding(sl026, stmt, {
|
|
1168
|
+
title: `${type === "commit_transaction_stmt" ? "COMMIT" : "ROLLBACK"} with no open transaction fails`,
|
|
1169
|
+
message: `Nothing is open at this point in the file, so SQLite answers "cannot ${type === "commit_transaction_stmt" ? "commit" : "rollback"} - no transaction is active". If the runner wraps the file, drop the statement; otherwise add the BEGIN.`
|
|
1170
|
+
}));
|
|
1171
|
+
}
|
|
1172
|
+
open = false;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
return out;
|
|
1176
|
+
}
|
|
1177
|
+
};
|
|
1178
|
+
var sl027 = {
|
|
1179
|
+
meta: sl027Meta,
|
|
1180
|
+
check(ctx) {
|
|
1181
|
+
const out = [];
|
|
1182
|
+
const cascading = ctx.migration.statements.flatMap(childKeys).filter((key) => key.cascade);
|
|
1183
|
+
if (cascading.length === 0)
|
|
1184
|
+
return out;
|
|
1185
|
+
for (const stmt of ctx.migration.statements) {
|
|
1186
|
+
if (stmt.node.type !== "delete_stmt" || hasWhere(stmt))
|
|
1187
|
+
continue;
|
|
1188
|
+
const table = tableOf(stmt) ?? "";
|
|
1189
|
+
if (tableCreatedInMigration(ctx.migration, stmt.index, table))
|
|
1190
|
+
continue;
|
|
1191
|
+
const children = cascading.filter((key) => key.parent.toLowerCase() === table.toLowerCase()).map((key) => key.table);
|
|
1192
|
+
if (children.length === 0)
|
|
1193
|
+
continue;
|
|
1194
|
+
out.push(finding(sl027, stmt, {
|
|
1195
|
+
title: `DELETE FROM "${table}" with no WHERE also empties ${children.map((c) => `"${c}"`).join(", ")}`,
|
|
1196
|
+
message: `With foreign keys on, ON DELETE CASCADE removes every referencing row in ${children.join(", ")} in the same statement; with them off, nothing happens to those tables — the outcome depends on the runner's connection. ` + `Add the predicate, batch it, and delete the children explicitly so the migration reads the way it behaves.`,
|
|
1197
|
+
object: { table }
|
|
1198
|
+
}));
|
|
1199
|
+
}
|
|
1200
|
+
return out;
|
|
1201
|
+
}
|
|
1202
|
+
};
|
|
1203
|
+
var NON_DETERMINISTIC = new Set(["random", "randomblob", "changes", "total_changes", "last_insert_rowid", "sqlite_offset"]);
|
|
1204
|
+
var sl028 = {
|
|
1205
|
+
meta: sl028Meta,
|
|
1206
|
+
check(ctx) {
|
|
1207
|
+
const out = [];
|
|
1208
|
+
for (const stmt of ctx.migration.statements) {
|
|
1209
|
+
if (stmt.node.type !== "create_index_stmt")
|
|
1210
|
+
continue;
|
|
1211
|
+
const calls = descendants(stmt.node.columns, (n) => n.type === "func_call").map((call) => String(nameOf(call.name) ?? "").toLowerCase()).filter((name) => NON_DETERMINISTIC.has(name));
|
|
1212
|
+
if (calls.length === 0)
|
|
1213
|
+
continue;
|
|
1214
|
+
const table = tableOf(stmt) ?? "";
|
|
1215
|
+
const index = nameOf(stmt.node.name);
|
|
1216
|
+
out.push(finding(sl028, stmt, {
|
|
1217
|
+
title: `Index "${index}" uses ${calls[0]}() — SQLite refuses it`,
|
|
1218
|
+
message: `An index expression must be deterministic; ${calls[0]}() is not, so CREATE INDEX fails ("non-deterministic functions prohibited in index expressions") and the migration stops. Index a deterministic expression or a stored column instead.`,
|
|
1219
|
+
object: { table, index }
|
|
1220
|
+
}));
|
|
1221
|
+
}
|
|
1222
|
+
return out;
|
|
1223
|
+
}
|
|
1224
|
+
};
|
|
1225
|
+
var sl029 = {
|
|
1226
|
+
meta: sl029Meta,
|
|
1227
|
+
check(ctx) {
|
|
1228
|
+
const out = [];
|
|
1229
|
+
for (const stmt of ctx.migration.statements) {
|
|
1230
|
+
if (stmt.node.type !== "create_table_stmt" || !tableOptions(stmt).includes("WITHOUT ROWID"))
|
|
1231
|
+
continue;
|
|
1232
|
+
const column = createdColumns(stmt).find((c) => constraintOf(c, "constraint_auto_increment"));
|
|
1233
|
+
if (!column)
|
|
1234
|
+
continue;
|
|
1235
|
+
const table = tableOf(stmt) ?? "";
|
|
1236
|
+
out.push(finding(sl029, stmt, {
|
|
1237
|
+
title: `AUTOINCREMENT on WITHOUT ROWID table "${table}" is refused`,
|
|
1238
|
+
message: `A WITHOUT ROWID table has no rowid for AUTOINCREMENT to drive, so SQLite rejects the statement ("AUTOINCREMENT not allowed on WITHOUT ROWID tables") and the table is not created. Drop one of the two.`,
|
|
1239
|
+
object: { table, column: nameOf(column.name) }
|
|
1240
|
+
}));
|
|
1241
|
+
}
|
|
1242
|
+
return out;
|
|
1243
|
+
}
|
|
1244
|
+
};
|
|
1245
|
+
var sl030 = {
|
|
1246
|
+
meta: sl030Meta,
|
|
1247
|
+
check(ctx) {
|
|
1248
|
+
const out = [];
|
|
1249
|
+
for (const stmt of ctx.migration.statements) {
|
|
1250
|
+
if (stmt.node.type !== "attach_database_stmt")
|
|
1251
|
+
continue;
|
|
1252
|
+
out.push(finding(sl030, stmt, {
|
|
1253
|
+
title: "ATTACH DATABASE in a migration",
|
|
1254
|
+
message: `This opens a second file by a machine-specific path on the runner's connection, for the life of that connection. Move the import into an operational job that takes the path as a parameter, and keep the migration to the schema.`
|
|
1255
|
+
}));
|
|
1256
|
+
}
|
|
1257
|
+
return out;
|
|
1258
|
+
}
|
|
1259
|
+
};
|
|
1260
|
+
|
|
1261
|
+
// src/rules/table-shape.ts
|
|
1262
|
+
var sl006 = {
|
|
1263
|
+
meta: sl006Meta,
|
|
1264
|
+
check(ctx) {
|
|
1265
|
+
const out = [];
|
|
1266
|
+
for (const stmt of ctx.migration.statements) {
|
|
1267
|
+
for (const action of alterActions(stmt)) {
|
|
1268
|
+
if (action.type !== "alter_action_drop_column")
|
|
1269
|
+
continue;
|
|
1270
|
+
const table = tableOf(stmt) ?? "";
|
|
1271
|
+
if (table && tableCreatedInMigration(ctx.migration, stmt.index, table))
|
|
1272
|
+
continue;
|
|
1273
|
+
const column = nameOf(action.column);
|
|
1274
|
+
out.push(finding(sl006, stmt, {
|
|
1275
|
+
title: `DROP COLUMN "${column}" rewrites every row of "${table}"`,
|
|
1276
|
+
message: `SQLite purges the column by rewriting the table's content in one write transaction — the single write lock is held for the whole copy, and every other writer waits. ` + `The statement also fails if an index, foreign key, view, trigger or generated column still names "${column}". Remove code references first, drop dependent objects, and schedule the rewrite.`,
|
|
1277
|
+
object: { table, column }
|
|
1278
|
+
}));
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
return out;
|
|
1282
|
+
}
|
|
1283
|
+
};
|
|
1284
|
+
var sl007 = {
|
|
1285
|
+
meta: sl007Meta,
|
|
1286
|
+
check(ctx) {
|
|
1287
|
+
const out = [];
|
|
1288
|
+
for (const stmt of ctx.migration.statements) {
|
|
1289
|
+
for (const action of alterActions(stmt)) {
|
|
1290
|
+
const table = tableOf(stmt) ?? "";
|
|
1291
|
+
if (action.type === "alter_action_rename_column") {
|
|
1292
|
+
if (table && tableCreatedInMigration(ctx.migration, stmt.index, table))
|
|
1293
|
+
continue;
|
|
1294
|
+
const from = nameOf(action.oldName);
|
|
1295
|
+
const to = nameOf(action.newName);
|
|
1296
|
+
out.push(finding(sl007, stmt, {
|
|
1297
|
+
title: `RENAME COLUMN "${from}" → "${to}" on "${table}" breaks code still using the old name`,
|
|
1298
|
+
message: `The rename is instant; any process still running the previous release fails on "${from}" from the next statement it sends. ` + `Expand and contract instead: add "${to}", dual-write, backfill, switch readers, drop "${from}" in a later release.`,
|
|
1299
|
+
object: { table, column: from }
|
|
1300
|
+
}));
|
|
1301
|
+
} else if (action.type === "alter_action_rename") {
|
|
1302
|
+
const to = nameOf(action.newName) ?? "";
|
|
1303
|
+
const finishesRebuild = tableRebuilds(ctx.migration).some((r) => r.rename.index === stmt.index && r.replacementCreatedHere);
|
|
1304
|
+
if (finishesRebuild)
|
|
1305
|
+
continue;
|
|
1306
|
+
if (table && tableCreatedInMigration(ctx.migration, stmt.index, table))
|
|
1307
|
+
continue;
|
|
1308
|
+
out.push(finding(sl007, stmt, {
|
|
1309
|
+
title: `RENAME TABLE "${table}" → "${to}" breaks code still using the old name`,
|
|
1310
|
+
message: `The rename is instant; any process still running the previous release fails on "${table}" from the next statement it sends. ` + `Create the new table and copy, and swap names only when no deployed code reads the old one.`,
|
|
1311
|
+
object: { table }
|
|
1312
|
+
}));
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
return out;
|
|
1317
|
+
}
|
|
1318
|
+
};
|
|
1319
|
+
|
|
1320
|
+
// src/rules/index.ts
|
|
1321
|
+
var allSqliteRules = [
|
|
1322
|
+
sl001,
|
|
1323
|
+
sl002,
|
|
1324
|
+
sl003,
|
|
1325
|
+
sl004,
|
|
1326
|
+
sl005,
|
|
1327
|
+
sl006,
|
|
1328
|
+
sl007,
|
|
1329
|
+
sl008,
|
|
1330
|
+
sl009,
|
|
1331
|
+
sl010,
|
|
1332
|
+
sl011,
|
|
1333
|
+
sl012,
|
|
1334
|
+
sl013,
|
|
1335
|
+
sl014,
|
|
1336
|
+
sl015,
|
|
1337
|
+
sl016,
|
|
1338
|
+
sl017,
|
|
1339
|
+
sl018,
|
|
1340
|
+
sl019,
|
|
1341
|
+
sl020,
|
|
1342
|
+
sl021,
|
|
1343
|
+
sl022,
|
|
1344
|
+
sl023,
|
|
1345
|
+
sl024,
|
|
1346
|
+
sl025,
|
|
1347
|
+
sl026,
|
|
1348
|
+
sl027,
|
|
1349
|
+
sl028,
|
|
1350
|
+
sl029,
|
|
1351
|
+
sl030,
|
|
1352
|
+
sl031,
|
|
1353
|
+
sl032,
|
|
1354
|
+
sl033,
|
|
1355
|
+
sl034,
|
|
1356
|
+
sl035
|
|
1357
|
+
];
|
|
1358
|
+
|
|
1359
|
+
// src/engine.ts
|
|
1360
|
+
var CREDENTIAL_RULE_IDS = new Set(["BC003", "BC004"]);
|
|
1361
|
+
function credentialFindings(sql, migration) {
|
|
1362
|
+
const report = scanSecrets(sql);
|
|
1363
|
+
if (report.findings.length === 0)
|
|
1364
|
+
return [];
|
|
1365
|
+
const lineStarts = [0];
|
|
1366
|
+
for (let i = 0;i < sql.length; i++)
|
|
1367
|
+
if (sql.charCodeAt(i) === 10)
|
|
1368
|
+
lineStarts.push(i + 1);
|
|
1369
|
+
const lines = sql.split(/\r?\n/);
|
|
1370
|
+
const out = [];
|
|
1371
|
+
for (const hit of report.findings) {
|
|
1372
|
+
if (!CREDENTIAL_RULE_IDS.has(hit.ruleId))
|
|
1373
|
+
continue;
|
|
1374
|
+
const offset = lineStarts[hit.statementIndex] ?? -1;
|
|
1375
|
+
const stmt = migration.statements.find((s) => offset >= s.location && offset < s.end);
|
|
1376
|
+
if (!stmt)
|
|
1377
|
+
continue;
|
|
1378
|
+
const original = lines[hit.statementIndex] ?? "";
|
|
1379
|
+
out.push({ ...hit, statementIndex: stmt.index, sql: original ? stmt.sql.replace(original.trim(), hit.sql.trim()) : stmt.sql });
|
|
1380
|
+
}
|
|
1381
|
+
return out;
|
|
1382
|
+
}
|
|
1383
|
+
function checkSqliteMigration(sql, options = {}) {
|
|
1384
|
+
const migration = parseSqliteMigration(sql);
|
|
1385
|
+
const rules = options.rules ?? allSqliteRules;
|
|
1386
|
+
const raw = [...rules.flatMap((rule) => rule.check({ migration })), ...options.skipCredentials ? [] : credentialFindings(sql, migration)];
|
|
1387
|
+
const suppressions = collectSuppressions(sql, migration, new Set([...SQLITE_RULE_IDS, ...CREDENTIAL_RULE_IDS]));
|
|
1388
|
+
const findings = sortFindings(applySuppressions(raw, suppressions));
|
|
1389
|
+
return {
|
|
1390
|
+
contractVersion: CONTRACT_VERSION,
|
|
1391
|
+
engine: "sqlite",
|
|
1392
|
+
engineVersion: package_default.version,
|
|
1393
|
+
ruleCorpusVersion: SL_RULE_CORPUS_VERSION,
|
|
1394
|
+
schemaInspected: false,
|
|
1395
|
+
statementCount: migration.statements.length,
|
|
1396
|
+
findings,
|
|
1397
|
+
...suppressions.length > 0 ? { suppressions } : {}
|
|
1398
|
+
};
|
|
1399
|
+
}
|
|
1400
|
+
function sqliteStatementStartLines(sql) {
|
|
1401
|
+
const migration = parseSqliteMigration(sql);
|
|
1402
|
+
return migration.statements.map((stmt) => {
|
|
1403
|
+
let line = 1;
|
|
1404
|
+
for (let i = 0;i < stmt.location; i++)
|
|
1405
|
+
if (sql.charCodeAt(i) === 10)
|
|
1406
|
+
line++;
|
|
1407
|
+
return line;
|
|
1408
|
+
});
|
|
1409
|
+
}
|
|
1410
|
+
export {
|
|
1411
|
+
CREDENTIAL_RULE_IDS,
|
|
1412
|
+
SL_RULE_CORPUS_VERSION,
|
|
1413
|
+
SQLITE_RULE_IDS,
|
|
1414
|
+
SqliteMigrationParseError,
|
|
1415
|
+
allSqliteRules,
|
|
1416
|
+
checkSqliteMigration,
|
|
1417
|
+
finding,
|
|
1418
|
+
parseSqliteMigration,
|
|
1419
|
+
sqliteMetas,
|
|
1420
|
+
sqliteStatementStartLines
|
|
1421
|
+
};
|