@pikku/migrator-sql 0.12.2 → 0.12.3
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/CHANGELOG.md +8 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +2 -2
- package/run-tests.sh +0 -0
- package/src/index.ts +34 -0
- package/src/migration-identifiers.test.ts +142 -0
- package/src/migration-identifiers.ts +321 -0
- package/src/postgres/index.ts +4 -0
- package/src/postgres/postgres-migrator.ts +81 -0
- package/src/schema-sql.test.ts +66 -0
- package/src/schema-sql.ts +155 -0
- package/src/sql-migrator.ts +219 -0
- package/src/sqlite/index.ts +8 -0
- package/src/sqlite/sqlite-migrator.ts +49 -0
- package/src/sqlite/sqlite-runtime-bun.ts +83 -0
- package/src/sqlite/sqlite-runtime-node.ts +118 -0
- package/src/sqlite/sqlite-runtime.ts +41 -0
- package/tsconfig.json +13 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# @pikku/migrator-sql
|
|
2
2
|
|
|
3
|
+
## 0.12.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- b2e038b: Rename `@pikku/sql-migrator` to `@pikku/migrator-sql`, so a future migrator for
|
|
8
|
+
another store sorts beside it rather than under a second prefix. The package has
|
|
9
|
+
never been published under either name, so nothing depends on the old one.
|
|
10
|
+
|
|
3
11
|
## 0.12.2
|
|
4
12
|
|
|
5
13
|
### Patch Changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"root":["../src/index.ts","../src/migration-identifiers.ts","../src/schema-sql.ts","../src/sql-migrator.ts","../src/postgres/index.ts","../src/postgres/postgres-migrator.ts","../src/sqlite/index.ts","../src/sqlite/sqlite-migrator.ts","../src/sqlite/sqlite-runtime-bun.ts","../src/sqlite/sqlite-runtime-node.ts","../src/sqlite/sqlite-runtime.ts"],"version":"6.0.3"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/migrator-sql",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.3",
|
|
4
4
|
"description": "The SQL migration applier shared by the Pikku CLI and the standalone runtime",
|
|
5
5
|
"author": "yasser.fadl@gmail.com",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,4 +30,4 @@
|
|
|
30
30
|
"./sqlite": "./dist/src/sqlite/index.js",
|
|
31
31
|
"./postgres": "./dist/src/postgres/index.js"
|
|
32
32
|
}
|
|
33
|
-
}
|
|
33
|
+
}
|
package/run-tests.sh
CHANGED
|
File without changes
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SQL migration applier, and nothing that knows where it is running.
|
|
3
|
+
*
|
|
4
|
+
* Two programs apply the same migrations to the same database: `pikku db
|
|
5
|
+
* migrate` from a checkout, and a shipped standalone bundle from a machine that
|
|
6
|
+
* has no checkout. They have to agree on the bookkeeping table, the hash, and
|
|
7
|
+
* the file order, because the second runs against a database the first has
|
|
8
|
+
* already written to — a second implementation that differs in any of the three
|
|
9
|
+
* reports every migration the other applied as drifted.
|
|
10
|
+
*/
|
|
11
|
+
export {
|
|
12
|
+
migrate,
|
|
13
|
+
baselineMigrations,
|
|
14
|
+
pendingMigrations,
|
|
15
|
+
MigrationDriftError,
|
|
16
|
+
MIGRATION_TRACKING_TABLE,
|
|
17
|
+
type MigrationExecutor,
|
|
18
|
+
type MigrateResult,
|
|
19
|
+
type AppliedMigration,
|
|
20
|
+
} from './sql-migrator.js'
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
assertSnakeCaseIdentifiers,
|
|
24
|
+
findCamelCaseIdentifiers,
|
|
25
|
+
stripSqlComments,
|
|
26
|
+
CamelCaseIdentifierError,
|
|
27
|
+
type CamelCaseIdentifier,
|
|
28
|
+
} from './migration-identifiers.js'
|
|
29
|
+
|
|
30
|
+
export {
|
|
31
|
+
splitStatements,
|
|
32
|
+
bareTableName,
|
|
33
|
+
tableCreationSql,
|
|
34
|
+
} from './schema-sql.js'
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
assertSnakeCaseIdentifiers,
|
|
6
|
+
CamelCaseIdentifierError,
|
|
7
|
+
findCamelCaseIdentifiers,
|
|
8
|
+
} from './migration-identifiers.js'
|
|
9
|
+
|
|
10
|
+
test('a snake_case migration declares nothing to complain about', () => {
|
|
11
|
+
const sql = `CREATE TABLE animal (
|
|
12
|
+
id TEXT NOT NULL PRIMARY KEY,
|
|
13
|
+
price_cents INTEGER NOT NULL,
|
|
14
|
+
weight NUMERIC(10,2),
|
|
15
|
+
status TEXT CHECK (status IN ('for_sale','sold')) DEFAULT 'for_sale',
|
|
16
|
+
created_at DATE NOT NULL,
|
|
17
|
+
CONSTRAINT animal_price_positive CHECK (price_cents > 0),
|
|
18
|
+
FOREIGN KEY (id) REFERENCES owner (id)
|
|
19
|
+
);
|
|
20
|
+
CREATE INDEX animal_status_idx ON animal (status);`
|
|
21
|
+
|
|
22
|
+
assert.deepEqual(findCamelCaseIdentifiers('0001-animal.sql', sql), [])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('a camelCase column is reported with its snake_case name', () => {
|
|
26
|
+
const sql = 'CREATE TABLE animal (id TEXT PRIMARY KEY, priceCents INTEGER);'
|
|
27
|
+
|
|
28
|
+
assert.deepEqual(findCamelCaseIdentifiers('0001-animal.sql', sql), [
|
|
29
|
+
{
|
|
30
|
+
file: '0001-animal.sql',
|
|
31
|
+
table: 'animal',
|
|
32
|
+
column: 'priceCents',
|
|
33
|
+
suggestion: 'price_cents',
|
|
34
|
+
},
|
|
35
|
+
])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('the Better Auth schema quoting style passes as written', () => {
|
|
39
|
+
const sql =
|
|
40
|
+
'create table "user" ("id" text not null primary key, "email_verified" integer not null, "created_at" date not null);'
|
|
41
|
+
|
|
42
|
+
assert.deepEqual(findCamelCaseIdentifiers('0001-better-auth.sql', sql), [])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('quoting a camelCase column does not exempt it', () => {
|
|
46
|
+
const sql = 'create table "user" ("id" text, "emailVerified" integer);'
|
|
47
|
+
|
|
48
|
+
assert.deepEqual(findCamelCaseIdentifiers('0001-better-auth.sql', sql), [
|
|
49
|
+
{
|
|
50
|
+
file: '0001-better-auth.sql',
|
|
51
|
+
table: 'user',
|
|
52
|
+
column: 'emailVerified',
|
|
53
|
+
suggestion: 'email_verified',
|
|
54
|
+
},
|
|
55
|
+
])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('a camelCase table name is reported on its own', () => {
|
|
59
|
+
const sql = 'CREATE TABLE animalSale (id TEXT PRIMARY KEY);'
|
|
60
|
+
|
|
61
|
+
assert.deepEqual(findCamelCaseIdentifiers('0001-sales.sql', sql), [
|
|
62
|
+
{
|
|
63
|
+
file: '0001-sales.sql',
|
|
64
|
+
table: 'animalSale',
|
|
65
|
+
column: null,
|
|
66
|
+
suggestion: 'animal_sale',
|
|
67
|
+
},
|
|
68
|
+
])
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('camelCase inside comments and string literals is not a declaration', () => {
|
|
72
|
+
const sql = `-- priceCents used to live here; renamed for CamelCasePlugin
|
|
73
|
+
/* the createdAt column is a date,
|
|
74
|
+
not a datetime */
|
|
75
|
+
CREATE TABLE animal (
|
|
76
|
+
id TEXT PRIMARY KEY, -- was animalId
|
|
77
|
+
status TEXT NOT NULL DEFAULT 'forSale'
|
|
78
|
+
);`
|
|
79
|
+
|
|
80
|
+
assert.deepEqual(findCamelCaseIdentifiers('0001-animal.sql', sql), [])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('ALTER TABLE ADD COLUMN is a declaration too', () => {
|
|
84
|
+
const sql = `ALTER TABLE animal ADD COLUMN soldAt DATE;
|
|
85
|
+
ALTER TABLE "animal" ADD priceCents INTEGER NOT NULL DEFAULT 0;
|
|
86
|
+
ALTER TABLE animal ADD COLUMN IF NOT EXISTS ownerId TEXT;`
|
|
87
|
+
|
|
88
|
+
assert.deepEqual(
|
|
89
|
+
findCamelCaseIdentifiers('0002-animal.sql', sql).map((o) => o.column),
|
|
90
|
+
['soldAt', 'priceCents', 'ownerId']
|
|
91
|
+
)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('an ALTER that adds a constraint declares no column', () => {
|
|
95
|
+
const sql = `ALTER TABLE animal ADD CONSTRAINT animalPriceCheck CHECK (price_cents > 0);
|
|
96
|
+
ALTER TABLE animal ADD PRIMARY KEY (id);
|
|
97
|
+
ALTER TABLE animal RENAME TO beast;`
|
|
98
|
+
|
|
99
|
+
assert.deepEqual(findCamelCaseIdentifiers('0003-animal.sql', sql), [])
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('every offender across every file is reported at once', () => {
|
|
103
|
+
const migrations = [
|
|
104
|
+
{
|
|
105
|
+
name: '0001-animal.sql',
|
|
106
|
+
sql: 'CREATE TABLE animal (id TEXT, priceCents INTEGER, soldAt DATE);',
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: '0002-owner.sql',
|
|
110
|
+
sql: 'ALTER TABLE owner ADD COLUMN displayName TEXT;',
|
|
111
|
+
},
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
assertSnakeCaseIdentifiers(migrations)
|
|
116
|
+
assert.fail('expected a CamelCaseIdentifierError')
|
|
117
|
+
} catch (error) {
|
|
118
|
+
assert.ok(error instanceof CamelCaseIdentifierError)
|
|
119
|
+
assert.deepEqual(
|
|
120
|
+
error.offenders.map((o) => `${o.file}:${o.table}.${o.column}`),
|
|
121
|
+
[
|
|
122
|
+
'0001-animal.sql:animal.priceCents',
|
|
123
|
+
'0001-animal.sql:animal.soldAt',
|
|
124
|
+
'0002-owner.sql:owner.displayName',
|
|
125
|
+
]
|
|
126
|
+
)
|
|
127
|
+
assert.match(error.message, /priceCents {2}→ {2}price_cents/)
|
|
128
|
+
assert.match(error.message, /displayName {2}→ {2}display_name/)
|
|
129
|
+
assert.match(error.message, /replay your dev\ndatabase from scratch/)
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('a clean set of migrations passes the assertion', () => {
|
|
134
|
+
assert.doesNotThrow(() =>
|
|
135
|
+
assertSnakeCaseIdentifiers([
|
|
136
|
+
{
|
|
137
|
+
name: '0001-animal.sql',
|
|
138
|
+
sql: 'CREATE TABLE animal (price_cents INT);',
|
|
139
|
+
},
|
|
140
|
+
])
|
|
141
|
+
)
|
|
142
|
+
})
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catching camelCase columns at migrate time, before they can half-work.
|
|
3
|
+
*
|
|
4
|
+
* Pikku's Kysely runs with `CamelCasePlugin`, so `priceCents` in TypeScript is
|
|
5
|
+
* `price_cents` in SQL and nothing else. A migration that declares the column
|
|
6
|
+
* *as* `priceCents` is not merely unconventional — it is broken in the one way
|
|
7
|
+
* that hides itself: `.selectAll()` compiles to `SELECT *` and never names an
|
|
8
|
+
* identifier, so the table reads back perfectly, while the first query that
|
|
9
|
+
* names the column (`.select(['animal.priceCents'])`) asks for `price_cents`
|
|
10
|
+
* and gets `no such column`. The usual conclusion is that the plugin is broken,
|
|
11
|
+
* and the usual response is a raw `sql` template or a retreat to `.selectAll()`
|
|
12
|
+
* — both of which keep the real cause alive.
|
|
13
|
+
*
|
|
14
|
+
* There is no exception to escape. Even the Better Auth schema `db generate`
|
|
15
|
+
* writes is snake_case (`email_verified`, `user_id`), because Better Auth is
|
|
16
|
+
* handed the app's own Kysely and its camelCase field names compile the same
|
|
17
|
+
* way everyone else's do.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { splitStatements } from './schema-sql.js'
|
|
21
|
+
|
|
22
|
+
/** A camelCase identifier a migration declares, and what it should have said. */
|
|
23
|
+
export interface CamelCaseIdentifier {
|
|
24
|
+
file: string
|
|
25
|
+
table: string
|
|
26
|
+
/** `null` when the table name itself is the offender. */
|
|
27
|
+
column: string | null
|
|
28
|
+
suggestion: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class CamelCaseIdentifierError extends Error {
|
|
32
|
+
constructor(public readonly offenders: CamelCaseIdentifier[]) {
|
|
33
|
+
const lines = offenders.map(
|
|
34
|
+
(o) =>
|
|
35
|
+
` ${o.file} ${o.column === null ? o.table : `${o.table}.${o.column}`} → ${o.suggestion}`
|
|
36
|
+
)
|
|
37
|
+
super(
|
|
38
|
+
`[PKU-DB-CAMEL] Migrations declare camelCase identifiers.\n\n` +
|
|
39
|
+
`Pikku's Kysely runs with CamelCasePlugin, which maps camelCase in TypeScript\n` +
|
|
40
|
+
`to snake_case in SQL. A camelCase column half-works: \`.selectAll()\` emits\n` +
|
|
41
|
+
`\`SELECT *\` so the table reads fine, but naming the column compiles it to\n` +
|
|
42
|
+
`snake_case and the database answers \`no such column\`.\n\n` +
|
|
43
|
+
`${lines.join('\n')}\n\n` +
|
|
44
|
+
`Fix the column definition in the migration file itself and replay your dev\n` +
|
|
45
|
+
`database from scratch. Do not write a RENAME COLUMN migration — that leaves\n` +
|
|
46
|
+
`the banned identifier in a SQL file forever.`
|
|
47
|
+
)
|
|
48
|
+
this.name = 'CamelCaseIdentifierError'
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Strip comments so a word in prose can never be read as an identifier.
|
|
54
|
+
*
|
|
55
|
+
* `splitStatements` already skips over comments when it looks for a boundary,
|
|
56
|
+
* but it hands back the statement with them still in it, and `-- the userId
|
|
57
|
+
* column` would otherwise be flagged. Quoting rules are honoured for the same
|
|
58
|
+
* reason they are there: `'a -- b'` is a string, not a comment.
|
|
59
|
+
*/
|
|
60
|
+
export function stripSqlComments(sql: string): string {
|
|
61
|
+
let out = ''
|
|
62
|
+
let i = 0
|
|
63
|
+
|
|
64
|
+
while (i < sql.length) {
|
|
65
|
+
const ch = sql[i]!
|
|
66
|
+
|
|
67
|
+
if (ch === "'" || ch === '"' || ch === '`') {
|
|
68
|
+
const start = i
|
|
69
|
+
i++
|
|
70
|
+
while (i < sql.length) {
|
|
71
|
+
if (sql[i] === '\\' && ch === "'") {
|
|
72
|
+
i += 2
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
if (sql[i] === ch) {
|
|
76
|
+
if (sql[i + 1] === ch) {
|
|
77
|
+
i += 2
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
i++
|
|
81
|
+
break
|
|
82
|
+
}
|
|
83
|
+
i++
|
|
84
|
+
}
|
|
85
|
+
out += sql.slice(start, i)
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (ch === '-' && sql[i + 1] === '-') {
|
|
90
|
+
const end = sql.indexOf('\n', i)
|
|
91
|
+
i = end === -1 ? sql.length : end
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (ch === '/' && sql[i + 1] === '*') {
|
|
96
|
+
const end = sql.indexOf('*/', i + 2)
|
|
97
|
+
i = end === -1 ? sql.length : end + 2
|
|
98
|
+
// A block comment can span lines, and removing it outright would join two
|
|
99
|
+
// statements onto one line. A space keeps the tokens either side apart.
|
|
100
|
+
out += ' '
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
out += ch
|
|
105
|
+
i++
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return out
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const IDENTIFIER = String.raw`"[^"]*"|\`[^\`]*\`|\[[^\]]*\]|[A-Za-z_][A-Za-z_0-9$]*`
|
|
112
|
+
|
|
113
|
+
const CREATE_TABLE = new RegExp(
|
|
114
|
+
String.raw`^CREATE\s+(?:TEMP(?:ORARY)?\s+|UNLOGGED\s+)*TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(\S+?)\s*\(`,
|
|
115
|
+
'i'
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
const ALTER_TABLE = new RegExp(
|
|
119
|
+
String.raw`^ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?(\S+)`,
|
|
120
|
+
'i'
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
const ADD_COLUMN = new RegExp(
|
|
124
|
+
String.raw`^\s*ADD\s+(?:COLUMN\s+)?(?:IF\s+NOT\s+EXISTS\s+)?(${IDENTIFIER})`,
|
|
125
|
+
'i'
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
const COLUMN_NAME = new RegExp(String.raw`^\s*(${IDENTIFIER})`)
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The words that open a table-level constraint rather than a column.
|
|
132
|
+
*
|
|
133
|
+
* Only consulted for an unquoted first token: `"check"` in quotes is a column
|
|
134
|
+
* named check, however unwise, and skipping it would let `"checkedAt"`'s
|
|
135
|
+
* neighbours hide behind it.
|
|
136
|
+
*/
|
|
137
|
+
const CONSTRAINT_KEYWORDS = new Set([
|
|
138
|
+
'constraint',
|
|
139
|
+
'primary',
|
|
140
|
+
'foreign',
|
|
141
|
+
'unique',
|
|
142
|
+
'check',
|
|
143
|
+
'exclude',
|
|
144
|
+
'index',
|
|
145
|
+
'key',
|
|
146
|
+
'like',
|
|
147
|
+
'period',
|
|
148
|
+
'fulltext',
|
|
149
|
+
'spatial',
|
|
150
|
+
])
|
|
151
|
+
|
|
152
|
+
/** Drop the quoting a dialect happens to use, leaving the identifier itself. */
|
|
153
|
+
const unquote = (name: string): string =>
|
|
154
|
+
/^["`[]/.test(name) ? name.slice(1, -1) : name
|
|
155
|
+
|
|
156
|
+
const isCamelCase = (name: string): boolean => /[a-z][A-Z]/.test(name)
|
|
157
|
+
|
|
158
|
+
const toSnakeCase = (name: string): string =>
|
|
159
|
+
name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Walk `sql` from `from` and return the offsets just inside and just outside
|
|
163
|
+
* the parenthesised group that starts there, or `null` if it never closes.
|
|
164
|
+
*
|
|
165
|
+
* Quote-aware, because a `)` inside `DEFAULT ')'` closes nothing.
|
|
166
|
+
*/
|
|
167
|
+
function parenSpan(
|
|
168
|
+
sql: string,
|
|
169
|
+
from: number
|
|
170
|
+
): { start: number; end: number } | null {
|
|
171
|
+
let depth = 0
|
|
172
|
+
let i = from
|
|
173
|
+
|
|
174
|
+
while (i < sql.length) {
|
|
175
|
+
const ch = sql[i]!
|
|
176
|
+
|
|
177
|
+
if (ch === "'" || ch === '"' || ch === '`') {
|
|
178
|
+
i++
|
|
179
|
+
while (i < sql.length && sql[i] !== ch) i++
|
|
180
|
+
i++
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (ch === '(') {
|
|
185
|
+
depth++
|
|
186
|
+
if (depth === 1) from = i + 1
|
|
187
|
+
} else if (ch === ')') {
|
|
188
|
+
depth--
|
|
189
|
+
if (depth === 0) return { start: from, end: i }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
i++
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Split a definition list on the commas that separate its items.
|
|
200
|
+
*
|
|
201
|
+
* The commas inside `NUMERIC(10,2)`, a `CHECK (x IN ('a','b'))` or a compound
|
|
202
|
+
* `PRIMARY KEY (a, b)` belong to those and not to the list, so only depth zero
|
|
203
|
+
* counts.
|
|
204
|
+
*/
|
|
205
|
+
function splitTopLevel(list: string): string[] {
|
|
206
|
+
const items: string[] = []
|
|
207
|
+
let depth = 0
|
|
208
|
+
let start = 0
|
|
209
|
+
let i = 0
|
|
210
|
+
|
|
211
|
+
while (i < list.length) {
|
|
212
|
+
const ch = list[i]!
|
|
213
|
+
|
|
214
|
+
if (ch === "'" || ch === '"' || ch === '`') {
|
|
215
|
+
i++
|
|
216
|
+
while (i < list.length && list[i] !== ch) i++
|
|
217
|
+
i++
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (ch === '(') depth++
|
|
222
|
+
else if (ch === ')') depth--
|
|
223
|
+
else if (ch === ',' && depth === 0) {
|
|
224
|
+
items.push(list.slice(start, i))
|
|
225
|
+
start = i + 1
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
i++
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
items.push(list.slice(start))
|
|
232
|
+
return items.filter((item) => item.trim().length > 0)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Every camelCase identifier one migration file declares.
|
|
237
|
+
*
|
|
238
|
+
* Only declarations are read — a `CREATE TABLE` column list and an `ALTER TABLE
|
|
239
|
+
* … ADD COLUMN`. Everywhere else an identifier merely refers to something that
|
|
240
|
+
* was declared somewhere, and reporting those would name the same mistake once
|
|
241
|
+
* per reference while adding nothing to the fix.
|
|
242
|
+
*/
|
|
243
|
+
export function findCamelCaseIdentifiers(
|
|
244
|
+
file: string,
|
|
245
|
+
sql: string
|
|
246
|
+
): CamelCaseIdentifier[] {
|
|
247
|
+
const offenders: CamelCaseIdentifier[] = []
|
|
248
|
+
|
|
249
|
+
for (const statement of splitStatements(stripSqlComments(sql))) {
|
|
250
|
+
const create = CREATE_TABLE.exec(statement)
|
|
251
|
+
if (create) {
|
|
252
|
+
const table = unquote(create[1]!.split('.').pop()!)
|
|
253
|
+
if (isCamelCase(table)) {
|
|
254
|
+
offenders.push({
|
|
255
|
+
file,
|
|
256
|
+
table,
|
|
257
|
+
column: null,
|
|
258
|
+
suggestion: toSnakeCase(table),
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const span = parenSpan(statement, create.index + create[0]!.length - 1)
|
|
263
|
+
if (!span) continue
|
|
264
|
+
|
|
265
|
+
for (const item of splitTopLevel(statement.slice(span.start, span.end))) {
|
|
266
|
+
const name = COLUMN_NAME.exec(item)?.[1]
|
|
267
|
+
if (!name) continue
|
|
268
|
+
if (CONSTRAINT_KEYWORDS.has(name.toLowerCase())) continue
|
|
269
|
+
const column = unquote(name)
|
|
270
|
+
if (isCamelCase(column)) {
|
|
271
|
+
offenders.push({
|
|
272
|
+
file,
|
|
273
|
+
table,
|
|
274
|
+
column,
|
|
275
|
+
suggestion: toSnakeCase(column),
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
continue
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const alter = ALTER_TABLE.exec(statement)
|
|
283
|
+
if (!alter) continue
|
|
284
|
+
|
|
285
|
+
const table = unquote(alter[1]!.split('.').pop()!)
|
|
286
|
+
// Postgres lets one ALTER carry several actions, and only the ADDs declare.
|
|
287
|
+
for (const action of splitTopLevel(
|
|
288
|
+
statement.slice(alter[0]!.length).replace(/;\s*$/, '')
|
|
289
|
+
)) {
|
|
290
|
+
const name = ADD_COLUMN.exec(action)?.[1]
|
|
291
|
+
if (!name) continue
|
|
292
|
+
if (CONSTRAINT_KEYWORDS.has(name.toLowerCase())) continue
|
|
293
|
+
const column = unquote(name)
|
|
294
|
+
if (isCamelCase(column)) {
|
|
295
|
+
offenders.push({
|
|
296
|
+
file,
|
|
297
|
+
table,
|
|
298
|
+
column,
|
|
299
|
+
suggestion: toSnakeCase(column),
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return offenders
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Bail unless every migration on disk is snake_case throughout.
|
|
310
|
+
*
|
|
311
|
+
* Reports all of them at once: a camelCase column is rarely alone, and a
|
|
312
|
+
* one-at-a-time failure turns a single edit into one migrate run per column.
|
|
313
|
+
*/
|
|
314
|
+
export function assertSnakeCaseIdentifiers(
|
|
315
|
+
migrations: Array<{ name: string; sql: string }>
|
|
316
|
+
): void {
|
|
317
|
+
const offenders = migrations.flatMap(({ name, sql }) =>
|
|
318
|
+
findCamelCaseIdentifiers(name, sql)
|
|
319
|
+
)
|
|
320
|
+
if (offenders.length > 0) throw new CamelCaseIdentifierError(offenders)
|
|
321
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { MigrationExecutor, AppliedMigration } from '../sql-migrator.js'
|
|
2
|
+
import { MIGRATION_TRACKING_TABLE as TRACKING_TABLE } from '../sql-migrator.js'
|
|
3
|
+
|
|
4
|
+
export interface PostgresMigrationClient {
|
|
5
|
+
query<T = unknown>(sql: string, params?: unknown[]): Promise<{ rows: T[] }>
|
|
6
|
+
exec?(sql: string): Promise<unknown>
|
|
7
|
+
/**
|
|
8
|
+
* Run a migration and its bookkeeping row on one connection.
|
|
9
|
+
*
|
|
10
|
+
* A pooled client is free to answer `BEGIN`, the migration and `COMMIT` on
|
|
11
|
+
* three different connections, which leaves a transaction open on one and the
|
|
12
|
+
* DDL committed outside it on another — a failed migration then stays half
|
|
13
|
+
* applied with nothing to roll back. A client that hands out a connection
|
|
14
|
+
* implements this; one that only ever has a single connection does not need
|
|
15
|
+
* to, and the statement pair below is correct for it.
|
|
16
|
+
*/
|
|
17
|
+
begin?<T>(handler: (client: PostgresMigrationClient) => Promise<T>): Promise<T>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class PostgresMigrationExecutor implements MigrationExecutor {
|
|
21
|
+
constructor(private readonly client: PostgresMigrationClient) {}
|
|
22
|
+
|
|
23
|
+
async ensureTrackingTable(): Promise<void> {
|
|
24
|
+
await this.client.query(`
|
|
25
|
+
CREATE TABLE IF NOT EXISTS ${TRACKING_TABLE} (
|
|
26
|
+
name TEXT PRIMARY KEY,
|
|
27
|
+
hash TEXT NOT NULL,
|
|
28
|
+
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
29
|
+
)
|
|
30
|
+
`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async getApplied(): Promise<AppliedMigration[]> {
|
|
34
|
+
const { rows } = await this.client.query<AppliedMigration>(
|
|
35
|
+
`SELECT name, hash, applied_at FROM ${TRACKING_TABLE} ORDER BY name`
|
|
36
|
+
)
|
|
37
|
+
return rows
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async recordMigration(name: string, hash: string): Promise<void> {
|
|
41
|
+
await this.client.query(
|
|
42
|
+
`INSERT INTO ${TRACKING_TABLE} (name, hash) VALUES ($1, $2)`,
|
|
43
|
+
[name, hash]
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async runMigration(sql: string, name: string, hash: string): Promise<void> {
|
|
48
|
+
if (typeof this.client.begin === 'function') {
|
|
49
|
+
await this.client.begin(async (tx) => {
|
|
50
|
+
await this.applyOn(tx, sql, name, hash)
|
|
51
|
+
})
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
await this.client.query('BEGIN')
|
|
56
|
+
try {
|
|
57
|
+
await this.applyOn(this.client, sql, name, hash)
|
|
58
|
+
await this.client.query('COMMIT')
|
|
59
|
+
} catch (err) {
|
|
60
|
+
await this.client.query('ROLLBACK')
|
|
61
|
+
throw err
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private async applyOn(
|
|
66
|
+
client: PostgresMigrationClient,
|
|
67
|
+
sql: string,
|
|
68
|
+
name: string,
|
|
69
|
+
hash: string
|
|
70
|
+
): Promise<void> {
|
|
71
|
+
if (typeof client.exec === 'function') {
|
|
72
|
+
await client.exec(sql)
|
|
73
|
+
} else {
|
|
74
|
+
await client.query(sql)
|
|
75
|
+
}
|
|
76
|
+
await client.query(
|
|
77
|
+
`INSERT INTO ${TRACKING_TABLE} (name, hash) VALUES ($1, $2)`,
|
|
78
|
+
[name, hash]
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { splitStatements, tableCreationSql } from './schema-sql.js'
|
|
5
|
+
|
|
6
|
+
test('a semicolon inside a string literal is not a statement boundary', () => {
|
|
7
|
+
const sql = `CREATE TABLE a (id TEXT, note TEXT DEFAULT 'one; two');
|
|
8
|
+
CREATE TABLE b (id TEXT);`
|
|
9
|
+
|
|
10
|
+
assert.deepEqual(splitStatements(sql), [
|
|
11
|
+
`CREATE TABLE a (id TEXT, note TEXT DEFAULT 'one; two');`,
|
|
12
|
+
'CREATE TABLE b (id TEXT);',
|
|
13
|
+
])
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('a doubled quote inside a literal does not end it', () => {
|
|
17
|
+
const sql = `CREATE TABLE a (note TEXT DEFAULT 'it''s; fine');`
|
|
18
|
+
assert.deepEqual(splitStatements(sql), [sql])
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('semicolons in comments and dollar-quoted bodies are ignored', () => {
|
|
22
|
+
const sql = `-- a comment; with a semicolon
|
|
23
|
+
CREATE TABLE a (id TEXT);
|
|
24
|
+
/* another; one */
|
|
25
|
+
CREATE FUNCTION f() RETURNS void AS $$ BEGIN; END; $$ LANGUAGE plpgsql;`
|
|
26
|
+
|
|
27
|
+
assert.deepEqual(splitStatements(sql), [
|
|
28
|
+
`-- a comment; with a semicolon
|
|
29
|
+
CREATE TABLE a (id TEXT);`,
|
|
30
|
+
`/* another; one */
|
|
31
|
+
CREATE FUNCTION f() RETURNS void AS $$ BEGIN; END; $$ LANGUAGE plpgsql;`,
|
|
32
|
+
])
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('a trailing statement with no semicolon still counts', () => {
|
|
36
|
+
assert.deepEqual(splitStatements('CREATE TABLE a (id TEXT)'), [
|
|
37
|
+
'CREATE TABLE a (id TEXT)',
|
|
38
|
+
])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test("a table's own SQL comes back with its indexes and constraints", () => {
|
|
42
|
+
const sql = `CREATE TABLE "user" ("id" text primary key);
|
|
43
|
+
CREATE TABLE "two_factor" ("id" text primary key, "user_id" text not null references "user" ("id"));
|
|
44
|
+
CREATE UNIQUE INDEX "two_factor_user_id_idx" ON "two_factor" ("user_id");
|
|
45
|
+
CREATE INDEX "user_id_idx" ON "user" ("id");
|
|
46
|
+
ALTER TABLE "two_factor" ADD CONSTRAINT "two_factor_secret_check" CHECK (length("id") > 0);`
|
|
47
|
+
|
|
48
|
+
assert.deepEqual(tableCreationSql(sql, 'two_factor'), [
|
|
49
|
+
`CREATE TABLE "two_factor" ("id" text primary key, "user_id" text not null references "user" ("id"));`,
|
|
50
|
+
`CREATE UNIQUE INDEX "two_factor_user_id_idx" ON "two_factor" ("user_id");`,
|
|
51
|
+
`ALTER TABLE "two_factor" ADD CONSTRAINT "two_factor_secret_check" CHECK (length("id") > 0);`,
|
|
52
|
+
])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('quoting, casing and a schema qualifier all name the same table', () => {
|
|
56
|
+
const sql = 'create table if not exists public."Two_Factor" (id text);'
|
|
57
|
+
assert.deepEqual(tableCreationSql(sql, '"two_factor"'), [sql])
|
|
58
|
+
assert.deepEqual(tableCreationSql(sql, 'app.two_factor'), [sql])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('a table the SQL never creates yields nothing to copy', () => {
|
|
62
|
+
const sql = `CREATE TABLE "user" ("id" text primary key);
|
|
63
|
+
CREATE INDEX "orphan_idx" ON "orders" ("id");`
|
|
64
|
+
|
|
65
|
+
assert.deepEqual(tableCreationSql(sql, 'orders'), [])
|
|
66
|
+
})
|