@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
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/** Index builds and the two rowid traps every SQLite schema eventually meets. */
|
|
3
|
+
import type { Finding } from "@bolvrk/engine";
|
|
4
|
+
import { constraintOf, createdColumns, dataTypeName, nameOf, tableConstraints, tableCreatedInMigration, tableOf, tableOptions } from "../helpers";
|
|
5
|
+
import { sl016Meta, sl017Meta, sl018Meta } from "../metas/index";
|
|
6
|
+
import { finding, type SlRule } from "../rule";
|
|
7
|
+
|
|
8
|
+
type AnyNode = Record<string, any>;
|
|
9
|
+
|
|
10
|
+
/** SL016: CREATE INDEX on a table that already exists — built under the write lock, no CONCURRENTLY. */
|
|
11
|
+
export const sl016: SlRule = {
|
|
12
|
+
meta: sl016Meta,
|
|
13
|
+
check(ctx) {
|
|
14
|
+
const out: Finding[] = [];
|
|
15
|
+
for (const stmt of ctx.migration.statements) {
|
|
16
|
+
if (stmt.node.type !== "create_index_stmt") continue;
|
|
17
|
+
const table = tableOf(stmt) ?? "";
|
|
18
|
+
if (table && tableCreatedInMigration(ctx.migration, stmt.index, table)) continue;
|
|
19
|
+
const index = nameOf((stmt.node as AnyNode).name);
|
|
20
|
+
out.push(
|
|
21
|
+
finding(sl016, stmt, {
|
|
22
|
+
title: `Index "${index}" is built under the write lock on "${table}"`,
|
|
23
|
+
message:
|
|
24
|
+
`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. ` +
|
|
25
|
+
`Small table, no problem; large table, build it in a window with busy_timeout set on the application's connections.`,
|
|
26
|
+
object: { table, index },
|
|
27
|
+
}),
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Column-level PRIMARY KEY constraint on a column definition, if any. */
|
|
35
|
+
function primaryKeyOf(column: AnyNode): AnyNode | undefined {
|
|
36
|
+
return constraintOf(column, "constraint_primary_key");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** SL017: a PRIMARY KEY column that is not exactly `INTEGER` (or is `INTEGER ... DESC`) in a rowid table. */
|
|
40
|
+
export const sl017: SlRule = {
|
|
41
|
+
meta: sl017Meta,
|
|
42
|
+
check(ctx) {
|
|
43
|
+
const out: Finding[] = [];
|
|
44
|
+
for (const stmt of ctx.migration.statements) {
|
|
45
|
+
if (stmt.node.type !== "create_table_stmt") continue;
|
|
46
|
+
if (tableOptions(stmt).includes("WITHOUT ROWID")) continue;
|
|
47
|
+
const table = tableOf(stmt) ?? "";
|
|
48
|
+
const columns = createdColumns(stmt);
|
|
49
|
+
// Composite or table-level keys are not rowid aliases by design; the trap is the single integer-looking column.
|
|
50
|
+
if (tableConstraints(stmt).some((c) => c.type === "constraint_primary_key" || c.constraint?.type === "constraint_primary_key")) continue;
|
|
51
|
+
for (const column of columns) {
|
|
52
|
+
const pk = primaryKeyOf(column);
|
|
53
|
+
if (!pk) continue;
|
|
54
|
+
const type = dataTypeName(column);
|
|
55
|
+
const direction = (pk.direction ?? pk.constraint?.direction)?.type;
|
|
56
|
+
const integerLike = /^(INT|INTEGER|BIGINT|SMALLINT|TINYINT|MEDIUMINT|INT2|INT8|UNSIGNED BIG INT)$/.test(type);
|
|
57
|
+
if (!integerLike) continue;
|
|
58
|
+
const exact = type === "INTEGER" && direction !== "sort_direction_desc";
|
|
59
|
+
if (exact) continue;
|
|
60
|
+
const name = nameOf(column.name);
|
|
61
|
+
out.push(
|
|
62
|
+
finding(sl017, stmt, {
|
|
63
|
+
title: `"${name}" ${type} PRIMARY KEY${direction === "sort_direction_desc" ? " DESC" : ""} on "${table}" is not a rowid alias`,
|
|
64
|
+
message:
|
|
65
|
+
`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. ` +
|
|
66
|
+
`Spell it INTEGER PRIMARY KEY (ascending).`,
|
|
67
|
+
object: { table, column: name },
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** SL018: AUTOINCREMENT. */
|
|
77
|
+
export const sl018: SlRule = {
|
|
78
|
+
meta: sl018Meta,
|
|
79
|
+
check(ctx) {
|
|
80
|
+
const out: Finding[] = [];
|
|
81
|
+
for (const stmt of ctx.migration.statements) {
|
|
82
|
+
if (stmt.node.type !== "create_table_stmt") continue;
|
|
83
|
+
const table = tableOf(stmt) ?? "";
|
|
84
|
+
for (const column of createdColumns(stmt)) {
|
|
85
|
+
if (!constraintOf(column, "constraint_auto_increment")) continue;
|
|
86
|
+
const name = nameOf(column.name);
|
|
87
|
+
out.push(
|
|
88
|
+
finding(sl018, stmt, {
|
|
89
|
+
title: `AUTOINCREMENT on "${table}"."${name}" costs every insert an extra write`,
|
|
90
|
+
message:
|
|
91
|
+
`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.`,
|
|
92
|
+
object: { table, column: name },
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/** Performance rules: indexes that cost writes without paying back, rebuild ordering, table shape, and pragmas that persist (or do not). */
|
|
3
|
+
import type { Finding } from "@bolvrk/engine";
|
|
4
|
+
import { constraintOf, createdColumns, dataTypeName, items, nameOf, pragmaOf, tableConstraints, tableOf, tableOptions } from "../helpers";
|
|
5
|
+
import { sl031Meta, sl032Meta, sl033Meta, sl034Meta, sl035Meta } from "../metas/index";
|
|
6
|
+
import { finding, type SlRule, type SlRuleContext } from "../rule";
|
|
7
|
+
|
|
8
|
+
type AnyNode = Record<string, any>;
|
|
9
|
+
type Stmt = SlRuleContext["migration"]["statements"][number];
|
|
10
|
+
|
|
11
|
+
interface IndexDef {
|
|
12
|
+
stmt: Stmt;
|
|
13
|
+
name: string;
|
|
14
|
+
table: string;
|
|
15
|
+
columns: string[];
|
|
16
|
+
unique: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function indexColumns(stmt: Stmt): string[] {
|
|
20
|
+
return items((stmt.node as AnyNode).columns).map((spec: AnyNode) => (nameOf(spec.expr ?? spec) ?? "").toLowerCase());
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Indexes the migration creates, in order. Expression indexes are left out — their columns are not comparable. */
|
|
24
|
+
function createdIndexes(ctx: SlRuleContext): IndexDef[] {
|
|
25
|
+
const out: IndexDef[] = [];
|
|
26
|
+
for (const stmt of ctx.migration.statements) {
|
|
27
|
+
if (stmt.node.type !== "create_index_stmt") continue;
|
|
28
|
+
const specs = items((stmt.node as AnyNode).columns);
|
|
29
|
+
if (specs.some((spec: AnyNode) => (spec.expr ?? spec).type !== "identifier")) continue;
|
|
30
|
+
out.push({
|
|
31
|
+
stmt,
|
|
32
|
+
name: nameOf((stmt.node as AnyNode).name) ?? "",
|
|
33
|
+
table: (tableOf(stmt) ?? "").toLowerCase(),
|
|
34
|
+
columns: indexColumns(stmt),
|
|
35
|
+
unique: String((stmt.node as AnyNode).indexTypeKw?.name ?? "").toUpperCase() === "UNIQUE",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Columns a CREATE TABLE in this migration already keys: the rowid alias and UNIQUE columns. */
|
|
42
|
+
function keyedColumns(ctx: SlRuleContext, table: string): { rowid?: string; unique: string[][] } {
|
|
43
|
+
const out: { rowid?: string; unique: string[][] } = { unique: [] };
|
|
44
|
+
for (const stmt of ctx.migration.statements) {
|
|
45
|
+
if (stmt.node.type !== "create_table_stmt" || (tableOf(stmt) ?? "").toLowerCase() !== table) continue;
|
|
46
|
+
const withoutRowid = tableOptions(stmt).includes("WITHOUT ROWID");
|
|
47
|
+
for (const column of createdColumns(stmt)) {
|
|
48
|
+
const name = (nameOf(column.name) ?? "").toLowerCase();
|
|
49
|
+
const pk = constraintOf(column, "constraint_primary_key");
|
|
50
|
+
if (pk && !withoutRowid && dataTypeName(column) === "INTEGER" && (pk.direction ?? pk.constraint?.direction)?.type !== "sort_direction_desc") out.rowid = name;
|
|
51
|
+
else if (pk || constraintOf(column, "constraint_unique")) out.unique.push([name]);
|
|
52
|
+
}
|
|
53
|
+
for (const constraint of tableConstraints(stmt)) {
|
|
54
|
+
const inner = constraint.constraint ?? constraint;
|
|
55
|
+
if (inner.type === "constraint_primary_key" || inner.type === "constraint_unique") {
|
|
56
|
+
out.unique.push(items(inner.columns).map((spec: AnyNode) => (nameOf(spec.expr ?? spec) ?? "").toLowerCase()));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const isPrefix = (short: string[], long: string[]) => short.length <= long.length && short.every((c, i) => long[i] === c);
|
|
64
|
+
const sameColumns = (a: string[], b: string[]) => a.length === b.length && isPrefix(a, b);
|
|
65
|
+
|
|
66
|
+
/** SL031: an index the planner will never prefer over what already exists. */
|
|
67
|
+
export const sl031: SlRule = {
|
|
68
|
+
meta: sl031Meta,
|
|
69
|
+
check(ctx) {
|
|
70
|
+
const out: Finding[] = [];
|
|
71
|
+
const indexes = createdIndexes(ctx);
|
|
72
|
+
for (const index of indexes) {
|
|
73
|
+
const keyed = keyedColumns(ctx, index.table);
|
|
74
|
+
let why: string | undefined;
|
|
75
|
+
if (index.columns.length === 1 && index.columns[0] === keyed.rowid) {
|
|
76
|
+
why = `"${index.columns[0]}" is the INTEGER PRIMARY KEY — the rowid, which the table's own B-tree already indexes`;
|
|
77
|
+
} else if (keyed.unique.some((u) => sameColumns(u, index.columns))) {
|
|
78
|
+
why = `(${index.columns.join(", ")}) is already UNIQUE, which SQLite enforces with an index of its own`;
|
|
79
|
+
} else {
|
|
80
|
+
const wider = indexes.find(
|
|
81
|
+
(other) =>
|
|
82
|
+
other !== index &&
|
|
83
|
+
other.table === index.table &&
|
|
84
|
+
isPrefix(index.columns, other.columns) &&
|
|
85
|
+
// A unique index on a prefix is a constraint, not redundancy; a duplicate is reported once, on the later one.
|
|
86
|
+
(!index.unique || other.unique) &&
|
|
87
|
+
(other.columns.length > index.columns.length || other.stmt.index < index.stmt.index),
|
|
88
|
+
);
|
|
89
|
+
if (wider) {
|
|
90
|
+
why =
|
|
91
|
+
sameColumns(index.columns, wider.columns)
|
|
92
|
+
? `it duplicates "${wider.name}"`
|
|
93
|
+
: `(${index.columns.join(", ")}) is a leading prefix of "${wider.name}" (${wider.columns.join(", ")}), which serves the same lookups`;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (!why) continue;
|
|
97
|
+
out.push(
|
|
98
|
+
finding(sl031, index.stmt, {
|
|
99
|
+
title: `Index "${index.name}" on "${index.table}" is redundant`,
|
|
100
|
+
message: `${why[0]!.toUpperCase()}${why.slice(1)}. The index changes no plan and costs a write on every insert, update and delete. Drop it.`,
|
|
101
|
+
object: { table: index.table, index: index.name },
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** SL032: CREATE INDEX on a new table before the INSERT ... SELECT that fills it. */
|
|
110
|
+
export const sl032: SlRule = {
|
|
111
|
+
meta: sl032Meta,
|
|
112
|
+
check(ctx) {
|
|
113
|
+
const out: Finding[] = [];
|
|
114
|
+
const statements = ctx.migration.statements;
|
|
115
|
+
for (const stmt of statements) {
|
|
116
|
+
if (stmt.node.type !== "create_index_stmt") continue;
|
|
117
|
+
const table = (tableOf(stmt) ?? "").toLowerCase();
|
|
118
|
+
const created = statements.some((s) => s.index < stmt.index && s.node.type === "create_table_stmt" && (tableOf(s) ?? "").toLowerCase() === table);
|
|
119
|
+
if (!created) continue;
|
|
120
|
+
const copy = statements.find(
|
|
121
|
+
(s) => s.index > stmt.index && s.node.type === "insert_stmt" && (tableOf(s) ?? "").toLowerCase() === table && (s.node as AnyNode).clauses?.some((c: AnyNode) => c.type === "select_stmt" || c.type === "compound_select_stmt"),
|
|
122
|
+
);
|
|
123
|
+
if (!copy) continue;
|
|
124
|
+
const index = nameOf((stmt.node as AnyNode).name) ?? "";
|
|
125
|
+
out.push(
|
|
126
|
+
finding(sl032, stmt, {
|
|
127
|
+
title: `Index "${index}" is built before the copy into "${table}"`,
|
|
128
|
+
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.`,
|
|
129
|
+
object: { table, index },
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/** SL033: TEXT/BLOB primary key on a rowid table. */
|
|
138
|
+
export const sl033: SlRule = {
|
|
139
|
+
meta: sl033Meta,
|
|
140
|
+
check(ctx) {
|
|
141
|
+
const out: Finding[] = [];
|
|
142
|
+
for (const stmt of ctx.migration.statements) {
|
|
143
|
+
if (stmt.node.type !== "create_table_stmt" || tableOptions(stmt).includes("WITHOUT ROWID")) continue;
|
|
144
|
+
const columns = createdColumns(stmt);
|
|
145
|
+
const tableLevel = tableConstraints(stmt).some((c) => (c.constraint ?? c).type === "constraint_primary_key");
|
|
146
|
+
if (tableLevel) continue; // composite keys are SL017/SL024 territory and often deliberate
|
|
147
|
+
const pk = columns.find((c) => constraintOf(c, "constraint_primary_key"));
|
|
148
|
+
if (!pk) continue;
|
|
149
|
+
const type = dataTypeName(pk);
|
|
150
|
+
if (!/^(TEXT|BLOB|VARCHAR|CHAR|CLOB|CHARACTER|NCHAR|NVARCHAR|UUID)/.test(type)) continue;
|
|
151
|
+
const table = tableOf(stmt) ?? "";
|
|
152
|
+
const name = nameOf(pk.name) ?? "";
|
|
153
|
+
out.push(
|
|
154
|
+
finding(sl033, stmt, {
|
|
155
|
+
title: `"${table}" keys on ${type} "${name}" but stays a rowid table`,
|
|
156
|
+
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.`,
|
|
157
|
+
object: { table, column: name },
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/** SL034: journal_mode set to a rollback mode. */
|
|
166
|
+
export const sl034: SlRule = {
|
|
167
|
+
meta: sl034Meta,
|
|
168
|
+
check(ctx) {
|
|
169
|
+
const out: Finding[] = [];
|
|
170
|
+
for (const stmt of ctx.migration.statements) {
|
|
171
|
+
const pragma = pragmaOf(stmt);
|
|
172
|
+
if (pragma?.name !== "journal_mode" || pragma.value === undefined || pragma.value === "wal") continue;
|
|
173
|
+
out.push(
|
|
174
|
+
finding(sl034, stmt, {
|
|
175
|
+
title: `journal_mode = ${pragma.value.toUpperCase()} is stored in the file for every connection`,
|
|
176
|
+
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.`,
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const CONNECTION_PRAGMAS = new Set(["cache_size", "synchronous", "temp_store", "mmap_size", "busy_timeout"]);
|
|
185
|
+
|
|
186
|
+
/** SL035: a pragma that only affects the runner's connection. */
|
|
187
|
+
export const sl035: SlRule = {
|
|
188
|
+
meta: sl035Meta,
|
|
189
|
+
check(ctx) {
|
|
190
|
+
const out: Finding[] = [];
|
|
191
|
+
for (const stmt of ctx.migration.statements) {
|
|
192
|
+
const pragma = pragmaOf(stmt);
|
|
193
|
+
if (!pragma || pragma.value === undefined || !CONNECTION_PRAGMAS.has(pragma.name)) continue;
|
|
194
|
+
const durability = pragma.name === "synchronous" && ["off", "0"].includes(pragma.value);
|
|
195
|
+
out.push(
|
|
196
|
+
finding(sl035, stmt, {
|
|
197
|
+
title: `PRAGMA ${pragma.name} in a migration reaches only the runner's connection`,
|
|
198
|
+
message:
|
|
199
|
+
`${pragma.name} is per connection: the application never sees this value. ` +
|
|
200
|
+
(durability ? `And synchronous=OFF means a crash during the schema change can corrupt the file. ` : ``) +
|
|
201
|
+
`Set connection pragmas where the application opens connections.`,
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
},
|
|
207
|
+
};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* The twelve-step table rebuild and the pragmas around it. SQLite's ALTER
|
|
4
|
+
* TABLE does so little that most real schema changes are this recipe, and
|
|
5
|
+
* the recipe's steps 1, 10 and 12 are the ones people forget.
|
|
6
|
+
*/
|
|
7
|
+
import type { Finding } from "@bolvrk/engine";
|
|
8
|
+
import { insideTransaction, pragmaIsOff, pragmaIsOn, pragmaOf, tableRebuilds } from "../helpers";
|
|
9
|
+
import { sl009Meta, sl010Meta, sl011Meta, sl012Meta, sl019Meta } from "../metas/index";
|
|
10
|
+
import { finding, type SlRule, type SlRuleContext } from "../rule";
|
|
11
|
+
|
|
12
|
+
/** Statement indexes at which foreign keys are switched off / on / checked. */
|
|
13
|
+
function pragmaTimeline(ctx: SlRuleContext) {
|
|
14
|
+
const off: number[] = [];
|
|
15
|
+
const on: number[] = [];
|
|
16
|
+
const check: number[] = [];
|
|
17
|
+
for (const stmt of ctx.migration.statements) {
|
|
18
|
+
const pragma = pragmaOf(stmt);
|
|
19
|
+
if (!pragma) continue;
|
|
20
|
+
if (pragma.name === "foreign_keys" && pragmaIsOff(pragma.value)) off.push(stmt.index);
|
|
21
|
+
else if (pragma.name === "foreign_keys" && pragmaIsOn(pragma.value)) on.push(stmt.index);
|
|
22
|
+
else if (pragma.name === "foreign_key_check") check.push(stmt.index);
|
|
23
|
+
}
|
|
24
|
+
return { off, on, check };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** SL009: the rebuild recipe without PRAGMA foreign_keys=OFF before it. */
|
|
28
|
+
export const sl009: SlRule = {
|
|
29
|
+
meta: sl009Meta,
|
|
30
|
+
check(ctx) {
|
|
31
|
+
const out: Finding[] = [];
|
|
32
|
+
const { off } = pragmaTimeline(ctx);
|
|
33
|
+
const inTx = insideTransaction(ctx.migration);
|
|
34
|
+
for (const { drop, table } of tableRebuilds(ctx.migration)) {
|
|
35
|
+
// Foreign keys are off for this DROP when an earlier OFF exists that was itself issued outside a transaction (inside one it is a no-op — SL010's finding).
|
|
36
|
+
const effectiveOff = off.some((index) => index < drop.index && !inTx[index]);
|
|
37
|
+
if (effectiveOff) continue;
|
|
38
|
+
out.push(
|
|
39
|
+
finding(sl009, drop, {
|
|
40
|
+
title: `Rebuild of "${table}" drops it with foreign keys still enforced`,
|
|
41
|
+
message:
|
|
42
|
+
`This file rebuilds "${table}" (drop, then rename a new table onto the name) without PRAGMA foreign_keys=OFF first. ` +
|
|
43
|
+
`With enforcement on, DROP TABLE runs an implicit DELETE FROM "${table}": rows referencing it fail the migration, or ON DELETE CASCADE removes them. ` +
|
|
44
|
+
`Switch foreign keys off outside the transaction, rebuild, run PRAGMA foreign_key_check, commit, switch them on.`,
|
|
45
|
+
object: { table },
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const TRANSACTION_NOOP_PRAGMAS = new Set(["foreign_keys", "journal_mode"]);
|
|
54
|
+
|
|
55
|
+
/** SL010: PRAGMA foreign_keys / journal_mode after BEGIN — silently ignored. */
|
|
56
|
+
export const sl010: SlRule = {
|
|
57
|
+
meta: sl010Meta,
|
|
58
|
+
check(ctx) {
|
|
59
|
+
const out: Finding[] = [];
|
|
60
|
+
const inTx = insideTransaction(ctx.migration);
|
|
61
|
+
for (const stmt of ctx.migration.statements) {
|
|
62
|
+
const pragma = pragmaOf(stmt);
|
|
63
|
+
if (!pragma || pragma.value === undefined || !TRANSACTION_NOOP_PRAGMAS.has(pragma.name)) continue;
|
|
64
|
+
if (!inTx[stmt.index]) continue;
|
|
65
|
+
out.push(
|
|
66
|
+
finding(sl010, stmt, {
|
|
67
|
+
title: `PRAGMA ${pragma.name} inside a transaction does nothing`,
|
|
68
|
+
message:
|
|
69
|
+
`SQLite ignores PRAGMA ${pragma.name} while a transaction is open — no error, the setting stays as it was. ` +
|
|
70
|
+
`Move it before BEGIN (and its counterpart after COMMIT); if the runner wraps the file in a transaction, the runner has to set it.`,
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** SL011: foreign keys switched off and never back on in this file. */
|
|
79
|
+
export const sl011: SlRule = {
|
|
80
|
+
meta: sl011Meta,
|
|
81
|
+
check(ctx) {
|
|
82
|
+
const { off, on } = pragmaTimeline(ctx);
|
|
83
|
+
if (off.length === 0) return [];
|
|
84
|
+
const lastOff = off[off.length - 1]!;
|
|
85
|
+
if (on.some((index) => index > lastOff)) return [];
|
|
86
|
+
const stmt = ctx.migration.statements[lastOff]!;
|
|
87
|
+
return [
|
|
88
|
+
finding(sl011, stmt, {
|
|
89
|
+
title: "Foreign keys are switched off and not switched back on",
|
|
90
|
+
message:
|
|
91
|
+
`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. ` +
|
|
92
|
+
`End the file with PRAGMA foreign_keys=ON after the transaction that needed it off.`,
|
|
93
|
+
}),
|
|
94
|
+
];
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** SL012: foreign keys re-enabled without PRAGMA foreign_key_check in between. */
|
|
99
|
+
export const sl012: SlRule = {
|
|
100
|
+
meta: sl012Meta,
|
|
101
|
+
check(ctx) {
|
|
102
|
+
const out: Finding[] = [];
|
|
103
|
+
const { off, on, check } = pragmaTimeline(ctx);
|
|
104
|
+
for (const onIndex of on) {
|
|
105
|
+
const offIndex = [...off].reverse().find((index) => index < onIndex);
|
|
106
|
+
if (offIndex === undefined) continue;
|
|
107
|
+
if (check.some((index) => index > offIndex && index < onIndex)) continue;
|
|
108
|
+
const stmt = ctx.migration.statements[onIndex]!;
|
|
109
|
+
out.push(
|
|
110
|
+
finding(sl012, stmt, {
|
|
111
|
+
title: "Foreign keys re-enabled without a foreign_key_check",
|
|
112
|
+
message:
|
|
113
|
+
`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.`,
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** SL019: the rebuild recipe with the copy step missing — the old table is dropped before its rows reach the new one. */
|
|
122
|
+
export const sl019: SlRule = {
|
|
123
|
+
meta: sl019Meta,
|
|
124
|
+
check(ctx) {
|
|
125
|
+
const out: Finding[] = [];
|
|
126
|
+
for (const rebuild of tableRebuilds(ctx.migration)) {
|
|
127
|
+
// Only the documented recipe (new table created here, then swapped in) says anything about the rows; renaming one live table onto another is SL007's business.
|
|
128
|
+
if (!rebuild.replacementCreatedHere || rebuild.copy) continue;
|
|
129
|
+
const { drop, table, replacement } = rebuild;
|
|
130
|
+
out.push(
|
|
131
|
+
finding(sl019, drop, {
|
|
132
|
+
title: `Rebuild of "${table}" drops it without copying its rows into "${replacement}"`,
|
|
133
|
+
message:
|
|
134
|
+
`This file creates "${replacement}", drops "${table}" and renames "${replacement}" onto the name, but no INSERT INTO "${replacement}" ... SELECT ... FROM "${table}" runs before the DROP. ` +
|
|
135
|
+
`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.`,
|
|
136
|
+
object: { table },
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
},
|
|
142
|
+
};
|