@sqldoc/cli 0.0.1
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/package.json +37 -0
- package/src/__tests__/binary-entry.test.ts +19 -0
- package/src/__tests__/codegen.test.ts +103 -0
- package/src/__tests__/destructive.test.ts +132 -0
- package/src/__tests__/migration-formats.test.ts +480 -0
- package/src/__tests__/migrations.test.ts +129 -0
- package/src/__tests__/pretty-changes.test.ts +153 -0
- package/src/__tests__/rename-detection.test.ts +142 -0
- package/src/__tests__/validate.test.ts +110 -0
- package/src/commands/codegen.ts +127 -0
- package/src/commands/doctor.ts +175 -0
- package/src/commands/lint.ts +102 -0
- package/src/commands/migrate.ts +345 -0
- package/src/commands/schema.ts +329 -0
- package/src/commands/validate.ts +100 -0
- package/src/errors.ts +24 -0
- package/src/index.ts +103 -0
- package/src/runtime.ts +17 -0
- package/src/utils/auto-install.ts +116 -0
- package/src/utils/destructive.ts +99 -0
- package/src/utils/discover.ts +35 -0
- package/src/utils/format.ts +23 -0
- package/src/utils/generate-config-types.ts +73 -0
- package/src/utils/migration-formats.ts +347 -0
- package/src/utils/migrations.ts +74 -0
- package/src/utils/pipeline.ts +194 -0
- package/src/utils/pretty-changes.ts +149 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import * as fs from 'node:fs'
|
|
2
|
+
import * as os from 'node:os'
|
|
3
|
+
import * as path from 'node:path'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import type { ParsedMigration } from '../utils/migration-formats.ts'
|
|
6
|
+
import { concatUpScripts, readMigrations, sanitizeName, writeMigration } from '../utils/migration-formats.ts'
|
|
7
|
+
|
|
8
|
+
describe('readMigrations', () => {
|
|
9
|
+
let tmpDir: string
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sqldoc-formats-'))
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('returns empty array when directory does not exist', () => {
|
|
20
|
+
expect(readMigrations(path.join(tmpDir, 'nonexistent'), 'plain')).toEqual([])
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
// ── plain format ──────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
describe('plain format', () => {
|
|
26
|
+
it('reads entire file as up SQL', () => {
|
|
27
|
+
const dir = path.join(tmpDir, 'plain')
|
|
28
|
+
fs.mkdirSync(dir)
|
|
29
|
+
fs.writeFileSync(path.join(dir, '001_init.sql'), 'CREATE TABLE users (id INT);')
|
|
30
|
+
|
|
31
|
+
const result = readMigrations(dir, 'plain')
|
|
32
|
+
expect(result).toHaveLength(1)
|
|
33
|
+
expect(result[0].up).toBe('CREATE TABLE users (id INT);')
|
|
34
|
+
expect(result[0].down).toBeUndefined()
|
|
35
|
+
expect(result[0].sortKey).toBe('001')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('sorts lexicographically', () => {
|
|
39
|
+
const dir = path.join(tmpDir, 'plain')
|
|
40
|
+
fs.mkdirSync(dir)
|
|
41
|
+
fs.writeFileSync(path.join(dir, '003_third.sql'), 'SELECT 3;')
|
|
42
|
+
fs.writeFileSync(path.join(dir, '001_first.sql'), 'SELECT 1;')
|
|
43
|
+
fs.writeFileSync(path.join(dir, '002_second.sql'), 'SELECT 2;')
|
|
44
|
+
|
|
45
|
+
const result = readMigrations(dir, 'plain')
|
|
46
|
+
expect(result.map((m) => m.filename)).toEqual(['001_first.sql', '002_second.sql', '003_third.sql'])
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
// ── atlas format ──────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
describe('atlas format', () => {
|
|
53
|
+
it('reads entire file as up SQL with timestamp sort key', () => {
|
|
54
|
+
const dir = path.join(tmpDir, 'atlas')
|
|
55
|
+
fs.mkdirSync(dir)
|
|
56
|
+
fs.writeFileSync(path.join(dir, '20260322120000_init.sql'), 'CREATE TABLE users (id SERIAL);')
|
|
57
|
+
|
|
58
|
+
const result = readMigrations(dir, 'atlas')
|
|
59
|
+
expect(result).toHaveLength(1)
|
|
60
|
+
expect(result[0].up).toBe('CREATE TABLE users (id SERIAL);')
|
|
61
|
+
expect(result[0].sortKey).toBe('20260322120000')
|
|
62
|
+
expect(result[0].down).toBeUndefined()
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// ── goose format ──────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
describe('goose format', () => {
|
|
69
|
+
it('extracts up and down sections', () => {
|
|
70
|
+
const dir = path.join(tmpDir, 'goose')
|
|
71
|
+
fs.mkdirSync(dir)
|
|
72
|
+
fs.writeFileSync(
|
|
73
|
+
path.join(dir, '001_init.sql'),
|
|
74
|
+
`-- +goose Up
|
|
75
|
+
CREATE TABLE users (id INT);
|
|
76
|
+
|
|
77
|
+
-- +goose Down
|
|
78
|
+
DROP TABLE users;
|
|
79
|
+
`,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
const result = readMigrations(dir, 'goose')
|
|
83
|
+
expect(result).toHaveLength(1)
|
|
84
|
+
expect(result[0].up).toBe('CREATE TABLE users (id INT);')
|
|
85
|
+
expect(result[0].down).toBe('DROP TABLE users;')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('handles file with only up section', () => {
|
|
89
|
+
const dir = path.join(tmpDir, 'goose')
|
|
90
|
+
fs.mkdirSync(dir)
|
|
91
|
+
fs.writeFileSync(
|
|
92
|
+
path.join(dir, '001_init.sql'),
|
|
93
|
+
`-- +goose Up
|
|
94
|
+
CREATE TABLE users (id INT);
|
|
95
|
+
`,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
const result = readMigrations(dir, 'goose')
|
|
99
|
+
expect(result[0].up).toBe('CREATE TABLE users (id INT);')
|
|
100
|
+
expect(result[0].down).toBeUndefined()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('handles multi-statement up and down', () => {
|
|
104
|
+
const dir = path.join(tmpDir, 'goose')
|
|
105
|
+
fs.mkdirSync(dir)
|
|
106
|
+
fs.writeFileSync(
|
|
107
|
+
path.join(dir, '002_multi.sql'),
|
|
108
|
+
`-- +goose Up
|
|
109
|
+
CREATE TABLE a (id INT);
|
|
110
|
+
CREATE TABLE b (id INT);
|
|
111
|
+
|
|
112
|
+
-- +goose Down
|
|
113
|
+
DROP TABLE b;
|
|
114
|
+
DROP TABLE a;
|
|
115
|
+
`,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
const result = readMigrations(dir, 'goose')
|
|
119
|
+
expect(result[0].up).toContain('CREATE TABLE a')
|
|
120
|
+
expect(result[0].up).toContain('CREATE TABLE b')
|
|
121
|
+
expect(result[0].down).toContain('DROP TABLE b')
|
|
122
|
+
expect(result[0].down).toContain('DROP TABLE a')
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// ── golang-migrate format ─────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
describe('golang-migrate format', () => {
|
|
129
|
+
it('pairs .up.sql and .down.sql files', () => {
|
|
130
|
+
const dir = path.join(tmpDir, 'golang-migrate')
|
|
131
|
+
fs.mkdirSync(dir)
|
|
132
|
+
fs.writeFileSync(path.join(dir, '001_init.up.sql'), 'CREATE TABLE users (id INT);')
|
|
133
|
+
fs.writeFileSync(path.join(dir, '001_init.down.sql'), 'DROP TABLE users;')
|
|
134
|
+
|
|
135
|
+
const result = readMigrations(dir, 'golang-migrate')
|
|
136
|
+
expect(result).toHaveLength(1)
|
|
137
|
+
expect(result[0].up).toBe('CREATE TABLE users (id INT);')
|
|
138
|
+
expect(result[0].down).toBe('DROP TABLE users;')
|
|
139
|
+
expect(result[0].filename).toBe('001_init.up.sql')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('handles missing .down.sql', () => {
|
|
143
|
+
const dir = path.join(tmpDir, 'golang-migrate')
|
|
144
|
+
fs.mkdirSync(dir)
|
|
145
|
+
fs.writeFileSync(path.join(dir, '001_init.up.sql'), 'CREATE TABLE users (id INT);')
|
|
146
|
+
|
|
147
|
+
const result = readMigrations(dir, 'golang-migrate')
|
|
148
|
+
expect(result).toHaveLength(1)
|
|
149
|
+
expect(result[0].up).toBe('CREATE TABLE users (id INT);')
|
|
150
|
+
expect(result[0].down).toBeUndefined()
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('handles multiple migration pairs', () => {
|
|
154
|
+
const dir = path.join(tmpDir, 'golang-migrate')
|
|
155
|
+
fs.mkdirSync(dir)
|
|
156
|
+
fs.writeFileSync(path.join(dir, '001_init.up.sql'), 'CREATE TABLE a;')
|
|
157
|
+
fs.writeFileSync(path.join(dir, '001_init.down.sql'), 'DROP TABLE a;')
|
|
158
|
+
fs.writeFileSync(path.join(dir, '002_add_b.up.sql'), 'CREATE TABLE b;')
|
|
159
|
+
fs.writeFileSync(path.join(dir, '002_add_b.down.sql'), 'DROP TABLE b;')
|
|
160
|
+
|
|
161
|
+
const result = readMigrations(dir, 'golang-migrate')
|
|
162
|
+
expect(result).toHaveLength(2)
|
|
163
|
+
expect(result[0].sortKey).toBe('001')
|
|
164
|
+
expect(result[1].sortKey).toBe('002')
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// ── flyway format ─────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
describe('flyway format', () => {
|
|
171
|
+
it('reads V files as up and pairs with U files for down', () => {
|
|
172
|
+
const dir = path.join(tmpDir, 'flyway')
|
|
173
|
+
fs.mkdirSync(dir)
|
|
174
|
+
fs.writeFileSync(path.join(dir, 'V1__init.sql'), 'CREATE TABLE users (id INT);')
|
|
175
|
+
fs.writeFileSync(path.join(dir, 'U1__init.sql'), 'DROP TABLE users;')
|
|
176
|
+
|
|
177
|
+
const result = readMigrations(dir, 'flyway')
|
|
178
|
+
expect(result).toHaveLength(1)
|
|
179
|
+
expect(result[0].up).toBe('CREATE TABLE users (id INT);')
|
|
180
|
+
expect(result[0].down).toBe('DROP TABLE users;')
|
|
181
|
+
expect(result[0].filename).toBe('V1__init.sql')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('handles missing U file', () => {
|
|
185
|
+
const dir = path.join(tmpDir, 'flyway')
|
|
186
|
+
fs.mkdirSync(dir)
|
|
187
|
+
fs.writeFileSync(path.join(dir, 'V1__init.sql'), 'CREATE TABLE users (id INT);')
|
|
188
|
+
|
|
189
|
+
const result = readMigrations(dir, 'flyway')
|
|
190
|
+
expect(result[0].down).toBeUndefined()
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('reads multiple versions in order', () => {
|
|
194
|
+
const dir = path.join(tmpDir, 'flyway')
|
|
195
|
+
fs.mkdirSync(dir)
|
|
196
|
+
fs.writeFileSync(path.join(dir, 'V2__second.sql'), 'SELECT 2;')
|
|
197
|
+
fs.writeFileSync(path.join(dir, 'V1__first.sql'), 'SELECT 1;')
|
|
198
|
+
fs.writeFileSync(path.join(dir, 'V3__third.sql'), 'SELECT 3;')
|
|
199
|
+
|
|
200
|
+
const result = readMigrations(dir, 'flyway')
|
|
201
|
+
// Files are sorted lexicographically from the directory
|
|
202
|
+
expect(result.map((m) => m.filename)).toEqual(['V1__first.sql', 'V2__second.sql', 'V3__third.sql'])
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
// ── dbmate format ─────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
describe('dbmate format', () => {
|
|
209
|
+
it('extracts up and down sections', () => {
|
|
210
|
+
const dir = path.join(tmpDir, 'dbmate')
|
|
211
|
+
fs.mkdirSync(dir)
|
|
212
|
+
fs.writeFileSync(
|
|
213
|
+
path.join(dir, '001_init.sql'),
|
|
214
|
+
`-- migrate:up
|
|
215
|
+
CREATE TABLE users (id INT);
|
|
216
|
+
|
|
217
|
+
-- migrate:down
|
|
218
|
+
DROP TABLE users;
|
|
219
|
+
`,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
const result = readMigrations(dir, 'dbmate')
|
|
223
|
+
expect(result).toHaveLength(1)
|
|
224
|
+
expect(result[0].up).toBe('CREATE TABLE users (id INT);')
|
|
225
|
+
expect(result[0].down).toBe('DROP TABLE users;')
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('handles file with only up section', () => {
|
|
229
|
+
const dir = path.join(tmpDir, 'dbmate')
|
|
230
|
+
fs.mkdirSync(dir)
|
|
231
|
+
fs.writeFileSync(
|
|
232
|
+
path.join(dir, '001_init.sql'),
|
|
233
|
+
`-- migrate:up
|
|
234
|
+
CREATE TABLE users (id INT);
|
|
235
|
+
`,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
const result = readMigrations(dir, 'dbmate')
|
|
239
|
+
expect(result[0].up).toBe('CREATE TABLE users (id INT);')
|
|
240
|
+
expect(result[0].down).toBeUndefined()
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
// ── writeMigration ──────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
describe('writeMigration', () => {
|
|
248
|
+
let tmpDir: string
|
|
249
|
+
|
|
250
|
+
beforeEach(() => {
|
|
251
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sqldoc-write-'))
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
afterEach(() => {
|
|
255
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('creates directory if it does not exist', () => {
|
|
259
|
+
const dir = path.join(tmpDir, 'new', 'nested')
|
|
260
|
+
writeMigration({
|
|
261
|
+
dir,
|
|
262
|
+
name: 'init',
|
|
263
|
+
up: 'CREATE TABLE t;',
|
|
264
|
+
format: 'plain',
|
|
265
|
+
naming: 'timestamp',
|
|
266
|
+
})
|
|
267
|
+
expect(fs.existsSync(dir)).toBe(true)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
describe('plain format', () => {
|
|
271
|
+
it('writes timestamp-prefixed file', () => {
|
|
272
|
+
const dir = path.join(tmpDir, 'plain')
|
|
273
|
+
const files = writeMigration({
|
|
274
|
+
dir,
|
|
275
|
+
name: 'add_users',
|
|
276
|
+
up: 'CREATE TABLE users (id INT);',
|
|
277
|
+
format: 'plain',
|
|
278
|
+
naming: 'timestamp',
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
expect(files).toHaveLength(1)
|
|
282
|
+
const filename = path.basename(files[0])
|
|
283
|
+
expect(filename).toMatch(/^\d{14}_add_users\.sql$/)
|
|
284
|
+
expect(fs.readFileSync(files[0], 'utf-8')).toBe('CREATE TABLE users (id INT);\n')
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('writes sequential-prefixed file', () => {
|
|
288
|
+
const dir = path.join(tmpDir, 'plain')
|
|
289
|
+
const files = writeMigration({
|
|
290
|
+
dir,
|
|
291
|
+
name: 'add_users',
|
|
292
|
+
up: 'CREATE TABLE users;',
|
|
293
|
+
format: 'plain',
|
|
294
|
+
naming: 'sequential',
|
|
295
|
+
existing: [],
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
const filename = path.basename(files[0])
|
|
299
|
+
expect(filename).toBe('001_add_users.sql')
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('increments sequential prefix from existing', () => {
|
|
303
|
+
const dir = path.join(tmpDir, 'plain')
|
|
304
|
+
const existing: ParsedMigration[] = [
|
|
305
|
+
{ filename: '001_init.sql', up: '', sortKey: '001' },
|
|
306
|
+
{ filename: '002_add_a.sql', up: '', sortKey: '002' },
|
|
307
|
+
]
|
|
308
|
+
const files = writeMigration({
|
|
309
|
+
dir,
|
|
310
|
+
name: 'add_b',
|
|
311
|
+
up: 'CREATE TABLE b;',
|
|
312
|
+
format: 'plain',
|
|
313
|
+
naming: 'sequential',
|
|
314
|
+
existing,
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
expect(path.basename(files[0])).toBe('003_add_b.sql')
|
|
318
|
+
})
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
describe('goose format', () => {
|
|
322
|
+
it('writes file with goose markers', () => {
|
|
323
|
+
const dir = path.join(tmpDir, 'goose')
|
|
324
|
+
const files = writeMigration({
|
|
325
|
+
dir,
|
|
326
|
+
name: 'init',
|
|
327
|
+
up: 'CREATE TABLE users (id INT);',
|
|
328
|
+
down: 'DROP TABLE users;',
|
|
329
|
+
format: 'goose',
|
|
330
|
+
naming: 'timestamp',
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
const content = fs.readFileSync(files[0], 'utf-8')
|
|
334
|
+
expect(content).toContain('-- +goose Up')
|
|
335
|
+
expect(content).toContain('CREATE TABLE users (id INT);')
|
|
336
|
+
expect(content).toContain('-- +goose Down')
|
|
337
|
+
expect(content).toContain('DROP TABLE users;')
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it('omits down section when no down SQL', () => {
|
|
341
|
+
const dir = path.join(tmpDir, 'goose')
|
|
342
|
+
const files = writeMigration({
|
|
343
|
+
dir,
|
|
344
|
+
name: 'init',
|
|
345
|
+
up: 'CREATE TABLE users;',
|
|
346
|
+
format: 'goose',
|
|
347
|
+
naming: 'timestamp',
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
const content = fs.readFileSync(files[0], 'utf-8')
|
|
351
|
+
expect(content).toContain('-- +goose Up')
|
|
352
|
+
expect(content).not.toContain('-- +goose Down')
|
|
353
|
+
})
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
describe('golang-migrate format', () => {
|
|
357
|
+
it('writes separate .up.sql and .down.sql files', () => {
|
|
358
|
+
const dir = path.join(tmpDir, 'golang-migrate')
|
|
359
|
+
const files = writeMigration({
|
|
360
|
+
dir,
|
|
361
|
+
name: 'init',
|
|
362
|
+
up: 'CREATE TABLE users;',
|
|
363
|
+
down: 'DROP TABLE users;',
|
|
364
|
+
format: 'golang-migrate',
|
|
365
|
+
naming: 'timestamp',
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
expect(files).toHaveLength(2)
|
|
369
|
+
expect(files[0]).toMatch(/\.up\.sql$/)
|
|
370
|
+
expect(files[1]).toMatch(/\.down\.sql$/)
|
|
371
|
+
expect(fs.readFileSync(files[0], 'utf-8')).toBe('CREATE TABLE users;\n')
|
|
372
|
+
expect(fs.readFileSync(files[1], 'utf-8')).toBe('DROP TABLE users;\n')
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('writes only .up.sql when no down SQL', () => {
|
|
376
|
+
const dir = path.join(tmpDir, 'golang-migrate')
|
|
377
|
+
const files = writeMigration({
|
|
378
|
+
dir,
|
|
379
|
+
name: 'init',
|
|
380
|
+
up: 'CREATE TABLE users;',
|
|
381
|
+
format: 'golang-migrate',
|
|
382
|
+
naming: 'timestamp',
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
expect(files).toHaveLength(1)
|
|
386
|
+
expect(files[0]).toMatch(/\.up\.sql$/)
|
|
387
|
+
})
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
describe('flyway format', () => {
|
|
391
|
+
it('writes V and U files', () => {
|
|
392
|
+
const dir = path.join(tmpDir, 'flyway')
|
|
393
|
+
const files = writeMigration({
|
|
394
|
+
dir,
|
|
395
|
+
name: 'init',
|
|
396
|
+
up: 'CREATE TABLE users;',
|
|
397
|
+
down: 'DROP TABLE users;',
|
|
398
|
+
format: 'flyway',
|
|
399
|
+
naming: 'sequential',
|
|
400
|
+
existing: [],
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
expect(files).toHaveLength(2)
|
|
404
|
+
expect(path.basename(files[0])).toBe('V1__init.sql')
|
|
405
|
+
expect(path.basename(files[1])).toBe('U1__init.sql')
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
it('increments flyway version from existing', () => {
|
|
409
|
+
const dir = path.join(tmpDir, 'flyway')
|
|
410
|
+
const existing: ParsedMigration[] = [
|
|
411
|
+
{ filename: 'V1__init.sql', up: '', sortKey: '000001' },
|
|
412
|
+
{ filename: 'V2__add_a.sql', up: '', sortKey: '000002' },
|
|
413
|
+
]
|
|
414
|
+
const files = writeMigration({
|
|
415
|
+
dir,
|
|
416
|
+
name: 'add_b',
|
|
417
|
+
up: 'CREATE TABLE b;',
|
|
418
|
+
format: 'flyway',
|
|
419
|
+
naming: 'sequential',
|
|
420
|
+
existing,
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
expect(path.basename(files[0])).toBe('V3__add_b.sql')
|
|
424
|
+
})
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
describe('dbmate format', () => {
|
|
428
|
+
it('writes file with dbmate markers', () => {
|
|
429
|
+
const dir = path.join(tmpDir, 'dbmate')
|
|
430
|
+
const files = writeMigration({
|
|
431
|
+
dir,
|
|
432
|
+
name: 'init',
|
|
433
|
+
up: 'CREATE TABLE users;',
|
|
434
|
+
down: 'DROP TABLE users;',
|
|
435
|
+
format: 'dbmate',
|
|
436
|
+
naming: 'timestamp',
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
const content = fs.readFileSync(files[0], 'utf-8')
|
|
440
|
+
expect(content).toContain('-- migrate:up')
|
|
441
|
+
expect(content).toContain('CREATE TABLE users;')
|
|
442
|
+
expect(content).toContain('-- migrate:down')
|
|
443
|
+
expect(content).toContain('DROP TABLE users;')
|
|
444
|
+
})
|
|
445
|
+
})
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
// ── sanitizeName ────────────────────────────────────────────────────
|
|
449
|
+
|
|
450
|
+
describe('sanitizeName', () => {
|
|
451
|
+
it('lowercases and replaces non-alphanum with underscore', () => {
|
|
452
|
+
expect(sanitizeName('Add User Table!')).toBe('add_user_table')
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
it('strips leading and trailing underscores', () => {
|
|
456
|
+
expect(sanitizeName('--hello--')).toBe('hello')
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
it('defaults to "migration" when empty', () => {
|
|
460
|
+
expect(sanitizeName('')).toBe('migration')
|
|
461
|
+
expect(sanitizeName('---')).toBe('migration')
|
|
462
|
+
})
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
// ── concatUpScripts ─────────────────────────────────────────────────
|
|
466
|
+
|
|
467
|
+
describe('concatUpScripts', () => {
|
|
468
|
+
it('concatenates up scripts with double newlines', () => {
|
|
469
|
+
const migrations: ParsedMigration[] = [
|
|
470
|
+
{ filename: '001.sql', up: 'CREATE TABLE a;', sortKey: '001' },
|
|
471
|
+
{ filename: '002.sql', up: 'CREATE TABLE b;', sortKey: '002' },
|
|
472
|
+
]
|
|
473
|
+
const result = concatUpScripts(migrations)
|
|
474
|
+
expect(result).toBe('CREATE TABLE a;\n\nCREATE TABLE b;')
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
it('returns empty string for no migrations', () => {
|
|
478
|
+
expect(concatUpScripts([])).toBe('')
|
|
479
|
+
})
|
|
480
|
+
})
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import * as fs from 'node:fs'
|
|
2
|
+
import * as os from 'node:os'
|
|
3
|
+
import * as path from 'node:path'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import type { MigrationFile } from '../utils/migrations.ts'
|
|
6
|
+
import { migrationFilename, readMigrationDir, writeMigrationFile } from '../utils/migrations.ts'
|
|
7
|
+
|
|
8
|
+
describe('readMigrationDir', () => {
|
|
9
|
+
let tmpDir: string
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sqldoc-migrations-'))
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('returns empty array when directory does not exist', () => {
|
|
20
|
+
const result = readMigrationDir(path.join(tmpDir, 'nonexistent'))
|
|
21
|
+
expect(result).toEqual([])
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('returns .sql files sorted lexicographically', () => {
|
|
25
|
+
const dir = path.join(tmpDir, 'migrations')
|
|
26
|
+
fs.mkdirSync(dir)
|
|
27
|
+
fs.writeFileSync(path.join(dir, '20260322090000_second.sql'), 'CREATE TABLE b;')
|
|
28
|
+
fs.writeFileSync(path.join(dir, '20260321143000_first.sql'), 'CREATE TABLE a;')
|
|
29
|
+
fs.writeFileSync(path.join(dir, '20260323110000_third.sql'), 'CREATE TABLE c;')
|
|
30
|
+
|
|
31
|
+
const result = readMigrationDir(dir)
|
|
32
|
+
expect(result).toHaveLength(3)
|
|
33
|
+
expect(result[0].filename).toBe('20260321143000_first.sql')
|
|
34
|
+
expect(result[1].filename).toBe('20260322090000_second.sql')
|
|
35
|
+
expect(result[2].filename).toBe('20260323110000_third.sql')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('ignores non-.sql files', () => {
|
|
39
|
+
const dir = path.join(tmpDir, 'migrations')
|
|
40
|
+
fs.mkdirSync(dir)
|
|
41
|
+
fs.writeFileSync(path.join(dir, '20260321143000_first.sql'), 'CREATE TABLE a;')
|
|
42
|
+
fs.writeFileSync(path.join(dir, 'README.md'), '# Migrations')
|
|
43
|
+
fs.writeFileSync(path.join(dir, 'atlas.sum'), 'checksum')
|
|
44
|
+
fs.writeFileSync(path.join(dir, 'notes.txt'), 'some notes')
|
|
45
|
+
|
|
46
|
+
const result = readMigrationDir(dir)
|
|
47
|
+
expect(result).toHaveLength(1)
|
|
48
|
+
expect(result[0].filename).toBe('20260321143000_first.sql')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('returns MigrationFile objects with filename, content, timestamp', () => {
|
|
52
|
+
const dir = path.join(tmpDir, 'migrations')
|
|
53
|
+
fs.mkdirSync(dir)
|
|
54
|
+
fs.writeFileSync(path.join(dir, '20260321143000_initial.sql'), 'CREATE TABLE users (id INT);')
|
|
55
|
+
|
|
56
|
+
const result = readMigrationDir(dir)
|
|
57
|
+
expect(result).toHaveLength(1)
|
|
58
|
+
|
|
59
|
+
const file: MigrationFile = result[0]
|
|
60
|
+
expect(file.filename).toBe('20260321143000_initial.sql')
|
|
61
|
+
expect(file.content).toBe('CREATE TABLE users (id INT);')
|
|
62
|
+
expect(file.timestamp).toBe('20260321143000')
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('migrationFilename', () => {
|
|
67
|
+
it('produces YYYYMMDDHHMMSS_name.sql format', () => {
|
|
68
|
+
const filename = migrationFilename('add_users')
|
|
69
|
+
// Should match pattern: 14 digits, underscore, name, .sql
|
|
70
|
+
expect(filename).toMatch(/^\d{14}_add_users\.sql$/)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('sanitizes name: lowercase and replace non-alphanum with underscore', () => {
|
|
74
|
+
const filename = migrationFilename('Add User Table!')
|
|
75
|
+
// "Add User Table!" -> "add_user_table_" -> stripped trailing _ -> "add_user_table"
|
|
76
|
+
expect(filename).toMatch(/^\d{14}_add_user_table\.sql$/)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('strips leading and trailing underscores from sanitized name', () => {
|
|
80
|
+
const filename = migrationFilename('--hello--')
|
|
81
|
+
expect(filename).toMatch(/^\d{14}_hello\.sql$/)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('defaults to "migration" when name is empty', () => {
|
|
85
|
+
const filename = migrationFilename('')
|
|
86
|
+
expect(filename).toMatch(/^\d{14}_migration\.sql$/)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('defaults to "migration" when name becomes empty after sanitization', () => {
|
|
90
|
+
const filename = migrationFilename('---')
|
|
91
|
+
expect(filename).toMatch(/^\d{14}_migration\.sql$/)
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe('writeMigrationFile', () => {
|
|
96
|
+
let tmpDir: string
|
|
97
|
+
|
|
98
|
+
beforeEach(() => {
|
|
99
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sqldoc-migrations-write-'))
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('creates directory if it does not exist', () => {
|
|
107
|
+
const dir = path.join(tmpDir, 'new', 'nested', 'migrations')
|
|
108
|
+
writeMigrationFile(dir, '20260321143000_test.sql', ['CREATE TABLE a (id INT)'])
|
|
109
|
+
|
|
110
|
+
expect(fs.existsSync(dir)).toBe(true)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('writes statements joined by semicolons with trailing semicolon', () => {
|
|
114
|
+
const dir = path.join(tmpDir, 'migrations')
|
|
115
|
+
writeMigrationFile(dir, '20260321143000_test.sql', ['CREATE TABLE a (id INT)', 'CREATE TABLE b (id INT)'])
|
|
116
|
+
|
|
117
|
+
const content = fs.readFileSync(path.join(dir, '20260321143000_test.sql'), 'utf-8')
|
|
118
|
+
expect(content).toBe('CREATE TABLE a (id INT);\nCREATE TABLE b (id INT);\n')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('returns the absolute path of the written file', () => {
|
|
122
|
+
const dir = path.join(tmpDir, 'migrations')
|
|
123
|
+
const result = writeMigrationFile(dir, '20260321143000_test.sql', ['SELECT 1'])
|
|
124
|
+
|
|
125
|
+
expect(path.isAbsolute(result)).toBe(true)
|
|
126
|
+
expect(result).toBe(path.join(dir, '20260321143000_test.sql'))
|
|
127
|
+
expect(fs.existsSync(result)).toBe(true)
|
|
128
|
+
})
|
|
129
|
+
})
|