@sigitex/outlaw 1.0.0
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 +7 -0
- package/README.md +9 -0
- package/package.json +52 -0
- package/src/api/DatabaseTable.ts +51 -0
- package/src/api/DatabaseView.ts +22 -0
- package/src/api/api.types.ts +200 -0
- package/src/api/createDatabase.ts +24 -0
- package/src/api/index.ts +4 -0
- package/src/bun/bun.ts +27 -0
- package/src/bun/index.ts +1 -0
- package/src/cloudflare/cloudflare.ts +18 -0
- package/src/cloudflare/index.ts +1 -0
- package/src/console.ts +14 -0
- package/src/cowboyMigration/Compare.ts +97 -0
- package/src/cowboyMigration/CowboyConnection.ts +57 -0
- package/src/cowboyMigration/CowboyMigrator.ts +437 -0
- package/src/cowboyMigration/CowboySeeder.ts +245 -0
- package/src/cowboyMigration/cowboyMigration.types.ts +29 -0
- package/src/cowboyMigration/createSchemaHacker.ts +28 -0
- package/src/cowboyMigration/index.ts +3 -0
- package/src/crypto.d.ts +9 -0
- package/src/framework/Format.ts +31 -0
- package/src/framework/definitions.d.ts +4 -0
- package/src/framework/index.ts +1 -0
- package/src/index.ts +10 -0
- package/src/queryBuilder/DeleteBuilder.ts +40 -0
- package/src/queryBuilder/InsertBuilder.ts +31 -0
- package/src/queryBuilder/Mappings.ts +55 -0
- package/src/queryBuilder/SelectBuilder.ts +39 -0
- package/src/queryBuilder/SelectQueryBuilder.ts +93 -0
- package/src/queryBuilder/UpdateBuilder.ts +44 -0
- package/src/queryBuilder/addWhereClause.ts +35 -0
- package/src/queryBuilder/index.ts +7 -0
- package/src/queryBuilder/operators.ts +35 -0
- package/src/queryBuilder/queryBuilders.types.ts +105 -0
- package/src/queryGenerator/Clause.ts +79 -0
- package/src/queryGenerator/generateDelete.ts +18 -0
- package/src/queryGenerator/generateInsert.ts +29 -0
- package/src/queryGenerator/generateSelect.ts +89 -0
- package/src/queryGenerator/generateUpdate.ts +29 -0
- package/src/queryGenerator/index.ts +4 -0
- package/src/reflection/Reflector.ts +47 -0
- package/src/reflection/index.ts +2 -0
- package/src/reflection/reflection.types.ts +12 -0
- package/src/schemaBuilder/Mapping.ts +44 -0
- package/src/schemaBuilder/columnBuilders.ts +67 -0
- package/src/schemaBuilder/createFixture.ts +112 -0
- package/src/schemaBuilder/createIndex.ts +33 -0
- package/src/schemaBuilder/createSchema.ts +23 -0
- package/src/schemaBuilder/createTable.ts +80 -0
- package/src/schemaBuilder/createView.ts +21 -0
- package/src/schemaBuilder/index.ts +9 -0
- package/src/schemaBuilder/metadata.ts +73 -0
- package/src/schemaBuilder/schemaBuilder.types.ts +139 -0
- package/src/schemaGenerator/generateCreateIndex.ts +18 -0
- package/src/schemaGenerator/generateCreateTable.ts +68 -0
- package/src/schemaGenerator/generateCreateView.ts +13 -0
- package/src/schemaGenerator/index.ts +3 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { newline, print } from "@sigitex/print"
|
|
2
|
+
import type { Connection } from "../api"
|
|
3
|
+
import { Format } from "../framework"
|
|
4
|
+
import { Reflector } from "../reflection"
|
|
5
|
+
import type {
|
|
6
|
+
BuildTable,
|
|
7
|
+
BuildView,
|
|
8
|
+
IndexData,
|
|
9
|
+
SchemaMembers,
|
|
10
|
+
Schema,
|
|
11
|
+
TableData,
|
|
12
|
+
ViewData,
|
|
13
|
+
} from "../schemaBuilder"
|
|
14
|
+
import { generateCreateTable } from "../schemaGenerator"
|
|
15
|
+
import { generateCreateIndex } from "../schemaGenerator/generateCreateIndex"
|
|
16
|
+
import { generateCreateView } from "../schemaGenerator/generateCreateView"
|
|
17
|
+
import { Compare } from "./Compare"
|
|
18
|
+
import type { SchemaHack } from "./cowboyMigration.types"
|
|
19
|
+
|
|
20
|
+
type Migratable = TableData | ViewData | IndexData
|
|
21
|
+
type MigratableType = "table" | "view" | "index"
|
|
22
|
+
|
|
23
|
+
const MIGRATION_TABLE = "cowboy_migration"
|
|
24
|
+
|
|
25
|
+
export class CowboyMigrator {
|
|
26
|
+
private readonly connection: Connection
|
|
27
|
+
private readonly schema: Schema<SchemaMembers>
|
|
28
|
+
private readonly hacks: SchemaHack[]
|
|
29
|
+
private readonly reflect: Reflector
|
|
30
|
+
|
|
31
|
+
constructor(
|
|
32
|
+
connection: Connection,
|
|
33
|
+
schema: Schema<SchemaMembers>,
|
|
34
|
+
hacks: SchemaHack[],
|
|
35
|
+
) {
|
|
36
|
+
this.connection = connection
|
|
37
|
+
this.schema = schema
|
|
38
|
+
this.hacks = hacks
|
|
39
|
+
this.reflect = new Reflector(connection)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async migrate() {
|
|
43
|
+
console.log("🤠checking for migrations")
|
|
44
|
+
|
|
45
|
+
const hasMigrationTable = await this.reflect.hasTable(MIGRATION_TABLE)
|
|
46
|
+
if (!hasMigrationTable) {
|
|
47
|
+
await this.connection.script([
|
|
48
|
+
"PRAGMA defer_foreign_keys = on",
|
|
49
|
+
createMigrationTable(),
|
|
50
|
+
])
|
|
51
|
+
this.reflect.invalidate()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// phase 1: execute hacks
|
|
55
|
+
await this.executeHacks()
|
|
56
|
+
|
|
57
|
+
// phase 2: schema convergence
|
|
58
|
+
const statements: string[] = ["PRAGMA defer_foreign_keys = on"]
|
|
59
|
+
for (const { $meta: table } of Object.values<BuildTable<unknown>>(
|
|
60
|
+
this.schema.tables,
|
|
61
|
+
)) {
|
|
62
|
+
const isMissingTable = await this.reflect.isMissingTable(table.name)
|
|
63
|
+
if (isMissingTable) {
|
|
64
|
+
console.log(`🤠creating table: ${table.name}`)
|
|
65
|
+
statements.push(generateCreateTable(table).trim())
|
|
66
|
+
statements.push(insertMigration("table", table))
|
|
67
|
+
} else {
|
|
68
|
+
const oldTable = await this.getTableData(table.name)
|
|
69
|
+
const equal = Compare.table(oldTable, table)
|
|
70
|
+
if (!equal) {
|
|
71
|
+
const migration = migrateTable(oldTable, table, this.hacks)
|
|
72
|
+
if (migration.length > 0) {
|
|
73
|
+
console.log(`🤠migrating table: ${table.name}`)
|
|
74
|
+
statements.push(...migration)
|
|
75
|
+
statements.push(updateMigration("table", table))
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (statements.length > 1) {
|
|
81
|
+
await this.connection.script(statements)
|
|
82
|
+
this.reflect.invalidate()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// phase 3: views and indexes (after tables are converged)
|
|
86
|
+
await this.convergeViewsAndIndexes()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private async convergeViewsAndIndexes() {
|
|
90
|
+
const statements: string[] = []
|
|
91
|
+
|
|
92
|
+
// Collect current schema view/index names for stale detection
|
|
93
|
+
const schemaViewNames = new Set<string>()
|
|
94
|
+
const schemaIndexNames = new Set<string>()
|
|
95
|
+
|
|
96
|
+
// Views
|
|
97
|
+
for (const member of Object.values(this.schema.views)) {
|
|
98
|
+
const view = (member as BuildView).$meta
|
|
99
|
+
schemaViewNames.add(view.name)
|
|
100
|
+
const exists = await this.hasMigration(view.name)
|
|
101
|
+
if (!exists) {
|
|
102
|
+
console.log(`🤠creating view: ${view.name}`)
|
|
103
|
+
statements.push(generateCreateView(view).trim())
|
|
104
|
+
statements.push(insertMigration("view", view))
|
|
105
|
+
} else {
|
|
106
|
+
const oldData = await this.getMigrationData(view.name)
|
|
107
|
+
if (oldData !== JSON.stringify(view)) {
|
|
108
|
+
console.log(`🤠recreating view: ${view.name}`)
|
|
109
|
+
statements.push(
|
|
110
|
+
print(["drop view if exists ", Format.name(view.name)]),
|
|
111
|
+
)
|
|
112
|
+
statements.push(generateCreateView(view).trim())
|
|
113
|
+
statements.push(updateMigration("view", view))
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Indexes
|
|
119
|
+
for (const member of Object.values(this.schema.indexes)) {
|
|
120
|
+
const index = (member as { $meta: IndexData }).$meta
|
|
121
|
+
schemaIndexNames.add(index.name)
|
|
122
|
+
const exists = await this.hasMigration(index.name)
|
|
123
|
+
if (!exists) {
|
|
124
|
+
console.log(`🤠creating index: ${index.name}`)
|
|
125
|
+
statements.push(generateCreateIndex(index).trim())
|
|
126
|
+
statements.push(insertMigration("index", index))
|
|
127
|
+
} else {
|
|
128
|
+
const oldData = await this.getMigrationData(index.name)
|
|
129
|
+
if (oldData !== JSON.stringify(index)) {
|
|
130
|
+
console.log(`🤠recreating index: ${index.name}`)
|
|
131
|
+
statements.push(
|
|
132
|
+
print(["drop index if exists ", Format.name(index.name)]),
|
|
133
|
+
)
|
|
134
|
+
statements.push(generateCreateIndex(index).trim())
|
|
135
|
+
statements.push(updateMigration("index", index))
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Drop stale views/indexes that are tracked but no longer in schema
|
|
141
|
+
const tracked = await this.getTrackedNonTables()
|
|
142
|
+
for (const { name, type } of tracked) {
|
|
143
|
+
if (type === "view" && !schemaViewNames.has(name)) {
|
|
144
|
+
console.log(`🤠dropping stale view: ${name}`)
|
|
145
|
+
statements.push(print(["drop view if exists ", Format.name(name)]))
|
|
146
|
+
statements.push(deleteMigration(name))
|
|
147
|
+
}
|
|
148
|
+
if (type === "index" && !schemaIndexNames.has(name)) {
|
|
149
|
+
console.log(`🤠dropping stale index: ${name}`)
|
|
150
|
+
statements.push(print(["drop index if exists ", Format.name(name)]))
|
|
151
|
+
statements.push(deleteMigration(name))
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (statements.length > 0) {
|
|
156
|
+
await this.connection.script(statements)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private async executeHacks() {
|
|
161
|
+
if (this.hacks.length === 0) {
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const statements: string[] = ["PRAGMA defer_foreign_keys = on"]
|
|
166
|
+
let dirty = false
|
|
167
|
+
|
|
168
|
+
for (const hack of this.hacks) {
|
|
169
|
+
switch (hack.type) {
|
|
170
|
+
case "droppedTable": {
|
|
171
|
+
if (await this.reflect.hasTable(hack.tableName)) {
|
|
172
|
+
console.log(`🤠dropping table: ${hack.tableName}`)
|
|
173
|
+
statements.push(print(["drop table ", Format.name(hack.tableName)]))
|
|
174
|
+
statements.push(deleteMigration(hack.tableName))
|
|
175
|
+
dirty = true
|
|
176
|
+
}
|
|
177
|
+
break
|
|
178
|
+
}
|
|
179
|
+
case "renamedTable": {
|
|
180
|
+
if (await this.reflect.hasTable(hack.fromTable)) {
|
|
181
|
+
if (await this.reflect.hasTable(hack.toTable)) {
|
|
182
|
+
// auto-recovery: previous hack-less migration created an empty
|
|
183
|
+
// table under the new name — drop it so we can rename the old one
|
|
184
|
+
console.log(`🤠dropping stale table: ${hack.toTable}`)
|
|
185
|
+
statements.push(print(["drop table ", Format.name(hack.toTable)]))
|
|
186
|
+
statements.push(deleteMigration(hack.toTable))
|
|
187
|
+
}
|
|
188
|
+
console.log(
|
|
189
|
+
`🤠renaming table: ${hack.fromTable} → ${hack.toTable}`,
|
|
190
|
+
)
|
|
191
|
+
statements.push(
|
|
192
|
+
print([
|
|
193
|
+
"alter table ",
|
|
194
|
+
Format.name(hack.fromTable),
|
|
195
|
+
" rename to ",
|
|
196
|
+
Format.name(hack.toTable),
|
|
197
|
+
]),
|
|
198
|
+
)
|
|
199
|
+
statements.push(renameMigration(hack.fromTable, hack.toTable))
|
|
200
|
+
dirty = true
|
|
201
|
+
}
|
|
202
|
+
break
|
|
203
|
+
}
|
|
204
|
+
case "droppedColumn": {
|
|
205
|
+
if (await this.reflect.hasTable(hack.tableName)) {
|
|
206
|
+
const tableData = await this.getTableData(hack.tableName)
|
|
207
|
+
const columnExists = tableData.columns.some(
|
|
208
|
+
({ name }) => name === hack.columnName,
|
|
209
|
+
)
|
|
210
|
+
if (columnExists) {
|
|
211
|
+
console.log(
|
|
212
|
+
`🤠dropping column: ${hack.tableName}.${hack.columnName}`,
|
|
213
|
+
)
|
|
214
|
+
statements.push(
|
|
215
|
+
print([
|
|
216
|
+
"alter table ",
|
|
217
|
+
Format.name(hack.tableName),
|
|
218
|
+
" drop column ",
|
|
219
|
+
Format.name(hack.columnName),
|
|
220
|
+
]),
|
|
221
|
+
)
|
|
222
|
+
const updated: TableData = {
|
|
223
|
+
...tableData,
|
|
224
|
+
columns: tableData.columns.filter(
|
|
225
|
+
({ name }) => name !== hack.columnName,
|
|
226
|
+
),
|
|
227
|
+
}
|
|
228
|
+
statements.push(updateMigration("table", updated))
|
|
229
|
+
dirty = true
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
break
|
|
233
|
+
}
|
|
234
|
+
case "renamedColumn": {
|
|
235
|
+
if (await this.reflect.hasTable(hack.fromTable)) {
|
|
236
|
+
const tableData = await this.getTableData(hack.fromTable)
|
|
237
|
+
const columnExists = tableData.columns.some(
|
|
238
|
+
({ name }) => name === hack.fromColumn,
|
|
239
|
+
)
|
|
240
|
+
if (columnExists) {
|
|
241
|
+
console.log(
|
|
242
|
+
`🤠renaming column: ${hack.fromTable}.${hack.fromColumn} → ${hack.toColumn}`,
|
|
243
|
+
)
|
|
244
|
+
statements.push(
|
|
245
|
+
print([
|
|
246
|
+
"alter table ",
|
|
247
|
+
Format.name(hack.fromTable),
|
|
248
|
+
" rename column ",
|
|
249
|
+
Format.name(hack.fromColumn),
|
|
250
|
+
" to ",
|
|
251
|
+
Format.name(hack.toColumn),
|
|
252
|
+
]),
|
|
253
|
+
)
|
|
254
|
+
const updated: TableData = {
|
|
255
|
+
...tableData,
|
|
256
|
+
columns: tableData.columns.map((col) =>
|
|
257
|
+
col.name === hack.fromColumn
|
|
258
|
+
? { ...col, name: hack.toColumn }
|
|
259
|
+
: col,
|
|
260
|
+
),
|
|
261
|
+
}
|
|
262
|
+
statements.push(updateMigration("table", updated))
|
|
263
|
+
dirty = true
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
break
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (dirty) {
|
|
272
|
+
await this.connection.script(statements)
|
|
273
|
+
this.reflect.invalidate()
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private async getTableData(tableName: string) {
|
|
278
|
+
const results = await this.connection.query<{ data: string }>(`
|
|
279
|
+
select data from ${MIGRATION_TABLE}
|
|
280
|
+
where name = ${Format.text(tableName)}
|
|
281
|
+
`)
|
|
282
|
+
return JSON.parse(results[0].data) as TableData
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private async hasMigration(name: string) {
|
|
286
|
+
const results = await this.connection.query<{ name: string }>(`
|
|
287
|
+
select name from ${MIGRATION_TABLE}
|
|
288
|
+
where name = ${Format.text(name)}
|
|
289
|
+
`)
|
|
290
|
+
return results.length > 0
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private async getMigrationData(name: string) {
|
|
294
|
+
const results = await this.connection.query<{ data: string }>(`
|
|
295
|
+
select data from ${MIGRATION_TABLE}
|
|
296
|
+
where name = ${Format.text(name)}
|
|
297
|
+
`)
|
|
298
|
+
return results[0]?.data
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private async getTrackedNonTables() {
|
|
302
|
+
return this.connection.query<{ name: string; type: string }>(`
|
|
303
|
+
select name, type from ${MIGRATION_TABLE}
|
|
304
|
+
where type != 'table'
|
|
305
|
+
`)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function createMigrationTable() {
|
|
310
|
+
return `
|
|
311
|
+
create table ${MIGRATION_TABLE} (
|
|
312
|
+
id integer primary key autoincrement,
|
|
313
|
+
timestamp integer not null,
|
|
314
|
+
type string not null,
|
|
315
|
+
name string not null unique,
|
|
316
|
+
data string not null
|
|
317
|
+
)
|
|
318
|
+
`
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function insertMigration(type: MigratableType, data: Migratable) {
|
|
322
|
+
const json = JSON.stringify(data)
|
|
323
|
+
return `
|
|
324
|
+
insert into ${MIGRATION_TABLE} (
|
|
325
|
+
timestamp,
|
|
326
|
+
type,
|
|
327
|
+
name,
|
|
328
|
+
data
|
|
329
|
+
)
|
|
330
|
+
values (
|
|
331
|
+
${Format.NOW},
|
|
332
|
+
'${type}',
|
|
333
|
+
${Format.text(data.name)},
|
|
334
|
+
${Format.text(json)}
|
|
335
|
+
)
|
|
336
|
+
`
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function updateMigration(_type: MigratableType, data: Migratable) {
|
|
340
|
+
const json = JSON.stringify(data)
|
|
341
|
+
return `
|
|
342
|
+
update ${MIGRATION_TABLE}
|
|
343
|
+
set timestamp = ${Format.NOW},
|
|
344
|
+
data = ${Format.text(json)}
|
|
345
|
+
where name = ${Format.text(data.name)}
|
|
346
|
+
`
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function deleteMigration(tableName: string) {
|
|
350
|
+
return `
|
|
351
|
+
delete from ${MIGRATION_TABLE}
|
|
352
|
+
where name = ${Format.text(tableName)}
|
|
353
|
+
`
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function renameMigration(fromName: string, toName: string) {
|
|
357
|
+
return `
|
|
358
|
+
update ${MIGRATION_TABLE}
|
|
359
|
+
set timestamp = ${Format.NOW},
|
|
360
|
+
name = ${Format.text(toName)}
|
|
361
|
+
where name = ${Format.text(fromName)}
|
|
362
|
+
`
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function migrateTable(
|
|
366
|
+
oldTable: TableData,
|
|
367
|
+
newTable: TableData,
|
|
368
|
+
hacks: SchemaHack[],
|
|
369
|
+
): string[] {
|
|
370
|
+
const interimName = `__interim_${newTable.name}`
|
|
371
|
+
const renames = hacks
|
|
372
|
+
.filter((hack) => hack.type === "renamedColumn")
|
|
373
|
+
.filter(({ fromTable }) => fromTable === newTable.name)
|
|
374
|
+
// oxlint-disable-next-line unicorn/no-array-reduce
|
|
375
|
+
const mappings = newTable.columns.reduce<{ from: string; to: string }[]>(
|
|
376
|
+
(mappings, column) => {
|
|
377
|
+
const oldColumnExists = oldTable.columns.some(
|
|
378
|
+
({ name }) => name === column.name,
|
|
379
|
+
)
|
|
380
|
+
const rename = renames.find((rename) => rename.toColumn === column.name)
|
|
381
|
+
const renamesOldColumn =
|
|
382
|
+
!!rename &&
|
|
383
|
+
oldTable.columns.some(({ name }) => name === rename.fromColumn)
|
|
384
|
+
if (!oldColumnExists && !renamesOldColumn) {
|
|
385
|
+
return mappings
|
|
386
|
+
}
|
|
387
|
+
return [
|
|
388
|
+
// biome-ignore lint/performance/noAccumulatingSpread: small n
|
|
389
|
+
...mappings,
|
|
390
|
+
{
|
|
391
|
+
from: renamesOldColumn ? rename.fromColumn : column.name,
|
|
392
|
+
to: column.name,
|
|
393
|
+
},
|
|
394
|
+
]
|
|
395
|
+
},
|
|
396
|
+
[],
|
|
397
|
+
)
|
|
398
|
+
const orphaned = oldTable.columns.filter((col) => {
|
|
399
|
+
const isMapped = mappings.some((m) => m.from === col.name)
|
|
400
|
+
const isDropped = hacks.some(
|
|
401
|
+
(h) =>
|
|
402
|
+
h.type === "droppedColumn" &&
|
|
403
|
+
h.tableName === newTable.name &&
|
|
404
|
+
h.columnName === col.name,
|
|
405
|
+
)
|
|
406
|
+
return !isMapped && !isDropped
|
|
407
|
+
})
|
|
408
|
+
if (orphaned.length > 0) {
|
|
409
|
+
const names = orphaned.map((c) => c.name).join(", ")
|
|
410
|
+
console.error(
|
|
411
|
+
`🤠refusing to migrate ${newTable.name}: columns [${names}] would be lost — add renamed.column() or dropped.column() hacks`,
|
|
412
|
+
)
|
|
413
|
+
return []
|
|
414
|
+
}
|
|
415
|
+
return [
|
|
416
|
+
generateCreateTable({ ...newTable, name: interimName }).trim(),
|
|
417
|
+
print([
|
|
418
|
+
"insert into ",
|
|
419
|
+
Format.name(interimName),
|
|
420
|
+
" (",
|
|
421
|
+
mappings.map((mapping, m) => [m > 0 && ", ", Format.name(mapping.to)]),
|
|
422
|
+
")",
|
|
423
|
+
newline,
|
|
424
|
+
"select ",
|
|
425
|
+
mappings.map((mapping, m) => [m > 0 && ", ", Format.name(mapping.from)]),
|
|
426
|
+
" from ",
|
|
427
|
+
Format.name(oldTable.name),
|
|
428
|
+
]),
|
|
429
|
+
print(["drop table ", Format.name(oldTable.name)]),
|
|
430
|
+
print([
|
|
431
|
+
"alter table ",
|
|
432
|
+
Format.name(interimName),
|
|
433
|
+
" rename to ",
|
|
434
|
+
Format.name(newTable.name),
|
|
435
|
+
]),
|
|
436
|
+
]
|
|
437
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// oxlint-disable typescript/no-explicit-any
|
|
2
|
+
import { join, print } from "@sigitex/print"
|
|
3
|
+
import stableStringify from "fast-json-stable-stringify"
|
|
4
|
+
import { createHash } from "node:crypto"
|
|
5
|
+
import type { Connection } from "../api"
|
|
6
|
+
import { Format } from "../framework"
|
|
7
|
+
import { Mappings } from "../queryBuilder/Mappings"
|
|
8
|
+
import { generateDelete, generateInsert } from "../queryGenerator"
|
|
9
|
+
import { Reflector } from "../reflection"
|
|
10
|
+
import { text } from "../schemaBuilder/columnBuilders"
|
|
11
|
+
import { Fixture, type Fixtures } from "../schemaBuilder/createFixture"
|
|
12
|
+
import { createTable } from "../schemaBuilder/createTable"
|
|
13
|
+
import type { RefBy } from "../schemaBuilder/schemaBuilder.types"
|
|
14
|
+
import { generateCreateTable } from "../schemaGenerator"
|
|
15
|
+
|
|
16
|
+
const SEED_TABLE = "cowboy_seed"
|
|
17
|
+
|
|
18
|
+
const seedTable = createTable(SEED_TABLE, {
|
|
19
|
+
name: text.primaryKey,
|
|
20
|
+
hash: text.notNull,
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
export class CowboySeeder {
|
|
24
|
+
private readonly connection: Connection
|
|
25
|
+
private readonly seeds: Fixtures
|
|
26
|
+
private readonly fixtures: Fixtures
|
|
27
|
+
private readonly runFixtures: boolean
|
|
28
|
+
private readonly reflect: Reflector
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
connection: Connection,
|
|
32
|
+
seeds: Fixtures,
|
|
33
|
+
fixtures: Fixtures,
|
|
34
|
+
runFixtures: boolean,
|
|
35
|
+
) {
|
|
36
|
+
this.connection = connection
|
|
37
|
+
this.seeds = seeds
|
|
38
|
+
this.fixtures = fixtures
|
|
39
|
+
this.runFixtures = runFixtures
|
|
40
|
+
this.reflect = new Reflector(connection)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async seed() {
|
|
44
|
+
console.log("🌱 checking for seed changes")
|
|
45
|
+
const statements: string[] = []
|
|
46
|
+
|
|
47
|
+
const hasSeedTable = await this.reflect.hasTable(SEED_TABLE)
|
|
48
|
+
if (!hasSeedTable) {
|
|
49
|
+
statements.push(generateCreateTable(seedTable.$meta).trim())
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const existingHashes = hasSeedTable
|
|
53
|
+
? await this.connection.query<{ name: string; hash: string }>(
|
|
54
|
+
`select name, hash from ${SEED_TABLE}`,
|
|
55
|
+
)
|
|
56
|
+
: []
|
|
57
|
+
|
|
58
|
+
const hashMap = new Map(existingHashes.map((r) => [r.name, r.hash]))
|
|
59
|
+
const combined = this.combineByTable()
|
|
60
|
+
|
|
61
|
+
for (const [tableName, { table, rows }] of combined) {
|
|
62
|
+
const hash = computeHash(rows)
|
|
63
|
+
const existing = hashMap.get(tableName)
|
|
64
|
+
if (existing === hash) {
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
console.log(`🌱 reseeding: ${tableName}`)
|
|
69
|
+
statements.push(...generateReseed(tableName, table, rows))
|
|
70
|
+
statements.push(upsertHash(tableName, hash))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (statements.length > 0) {
|
|
74
|
+
statements.unshift("PRAGMA defer_foreign_keys = on")
|
|
75
|
+
await this.connection.script(statements)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private combineByTable() {
|
|
80
|
+
const combined = new Map<
|
|
81
|
+
string,
|
|
82
|
+
{ table: Fixture<any>["table"]; rows: Record<string, unknown>[] }
|
|
83
|
+
>()
|
|
84
|
+
const allFixtures = {
|
|
85
|
+
...this.seeds,
|
|
86
|
+
...(this.runFixtures ? this.fixtures : {}),
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const fixture of Object.values(allFixtures)) {
|
|
90
|
+
const tableName: string = fixture.table.$meta.name
|
|
91
|
+
const existing = combined.get(tableName)
|
|
92
|
+
if (existing) {
|
|
93
|
+
existing.rows.push(...fixture.rows)
|
|
94
|
+
} else {
|
|
95
|
+
combined.set(tableName, {
|
|
96
|
+
table: fixture.table,
|
|
97
|
+
rows: [...fixture.rows],
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return combined
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function computeHash(rows: Record<string, unknown>[]) {
|
|
106
|
+
const sanitized = rows.map((row) => {
|
|
107
|
+
const out: Record<string, unknown> = {}
|
|
108
|
+
for (const key in row) {
|
|
109
|
+
const value = row[key]
|
|
110
|
+
if (Fixture.isRefBy(value)) {
|
|
111
|
+
out[key] =
|
|
112
|
+
`ref:${value.table.$meta.name}.${String(value.column)}=${value.value}`
|
|
113
|
+
} else {
|
|
114
|
+
out[key] = value
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return out
|
|
118
|
+
})
|
|
119
|
+
const serialized = stableStringify(sanitized)
|
|
120
|
+
return createHash("sha256").update(serialized).digest("hex")
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function generateReseed(
|
|
124
|
+
tableName: string,
|
|
125
|
+
table: Fixture<any>["table"],
|
|
126
|
+
rows: Record<string, unknown>[],
|
|
127
|
+
): string[] {
|
|
128
|
+
const tableData = table.$meta
|
|
129
|
+
const autoincrementCol = tableData.columns.find(
|
|
130
|
+
(c: any) => c.primaryKey?.autoincrement,
|
|
131
|
+
)?.name
|
|
132
|
+
|
|
133
|
+
const deleteSQL = generateDelete({ table: tableName }).trim()
|
|
134
|
+
if (rows.length === 0) {
|
|
135
|
+
return [deleteSQL]
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const strippedRows = autoincrementCol
|
|
139
|
+
? rows.map((row) => stripAutoincrement(row, autoincrementCol))
|
|
140
|
+
: rows
|
|
141
|
+
|
|
142
|
+
const processedRows = strippedRows.map((row) => {
|
|
143
|
+
const mapped: Record<string, unknown> = {}
|
|
144
|
+
for (const key in row) {
|
|
145
|
+
const value = row[key]
|
|
146
|
+
if (Fixture.isRefBy(value)) {
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
mapped[key] = value
|
|
150
|
+
}
|
|
151
|
+
return Mappings.row(tableData, mapped)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
const hasRefBy = strippedRows.some((row) =>
|
|
155
|
+
Object.values(row).some(Fixture.isRefBy),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if (!hasRefBy) {
|
|
159
|
+
const columns = Object.keys(strippedRows[0])
|
|
160
|
+
const insertSQL = generateInsert({
|
|
161
|
+
table: tableName,
|
|
162
|
+
columns,
|
|
163
|
+
rows: processedRows,
|
|
164
|
+
}).trim()
|
|
165
|
+
return [deleteSQL, insertSQL]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return [
|
|
169
|
+
deleteSQL,
|
|
170
|
+
...generateRefByInserts(tableName, tableData, strippedRows),
|
|
171
|
+
]
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function generateRefByInserts(
|
|
175
|
+
tableName: string,
|
|
176
|
+
tableData: Fixture<any>["table"]["$meta"],
|
|
177
|
+
rows: Record<string, unknown>[],
|
|
178
|
+
): string[] {
|
|
179
|
+
const statements: string[] = []
|
|
180
|
+
for (const row of rows) {
|
|
181
|
+
const columns: string[] = []
|
|
182
|
+
const values: string[] = []
|
|
183
|
+
|
|
184
|
+
for (const key in row) {
|
|
185
|
+
columns.push(key)
|
|
186
|
+
const value = row[key]
|
|
187
|
+
if (Fixture.isRefBy(value)) {
|
|
188
|
+
values.push(refBySubquery(value))
|
|
189
|
+
} else {
|
|
190
|
+
const mapped = Mappings.row(tableData, { [key]: value })
|
|
191
|
+
values.push(Format.value(mapped[key]))
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
statements.push(
|
|
196
|
+
print([
|
|
197
|
+
"insert into ",
|
|
198
|
+
Format.name(tableName),
|
|
199
|
+
" (",
|
|
200
|
+
join(", ", columns, Format.name),
|
|
201
|
+
") values (",
|
|
202
|
+
values.join(", "),
|
|
203
|
+
")",
|
|
204
|
+
]),
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
return statements
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function refBySubquery(ref: RefBy) {
|
|
211
|
+
const refTableName = ref.table.$meta.name
|
|
212
|
+
const pk = ref.table.$meta.columns.find((c: any) => c.primaryKey)
|
|
213
|
+
const pkColumn = pk ? pk.name : "id"
|
|
214
|
+
return print([
|
|
215
|
+
"(select ",
|
|
216
|
+
Format.name(pkColumn),
|
|
217
|
+
" from ",
|
|
218
|
+
Format.name(refTableName),
|
|
219
|
+
" where ",
|
|
220
|
+
Format.name(ref.column as string),
|
|
221
|
+
" = ",
|
|
222
|
+
Format.value(ref.value),
|
|
223
|
+
")",
|
|
224
|
+
])
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function stripAutoincrement(row: Record<string, unknown>, col: string) {
|
|
228
|
+
if (row[col] !== 0) {
|
|
229
|
+
return row
|
|
230
|
+
}
|
|
231
|
+
const { [col]: _, ...rest } = row
|
|
232
|
+
return rest
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function upsertHash(tableName: string, hash: string) {
|
|
236
|
+
return print([
|
|
237
|
+
"insert or replace into ",
|
|
238
|
+
SEED_TABLE,
|
|
239
|
+
" (name, hash) values (",
|
|
240
|
+
Format.text(tableName),
|
|
241
|
+
", ",
|
|
242
|
+
Format.text(hash),
|
|
243
|
+
")",
|
|
244
|
+
])
|
|
245
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type SchemaHack =
|
|
2
|
+
| RenamedTable
|
|
3
|
+
| RenamedColumn
|
|
4
|
+
| DroppedTable
|
|
5
|
+
| DroppedColumn
|
|
6
|
+
|
|
7
|
+
export type RenamedTable = {
|
|
8
|
+
readonly type: "renamedTable"
|
|
9
|
+
readonly fromTable: string
|
|
10
|
+
readonly toTable: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type RenamedColumn = {
|
|
14
|
+
readonly type: "renamedColumn"
|
|
15
|
+
readonly fromTable: string
|
|
16
|
+
readonly fromColumn: string
|
|
17
|
+
readonly toColumn: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type DroppedTable = {
|
|
21
|
+
readonly type: "droppedTable"
|
|
22
|
+
readonly tableName: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type DroppedColumn = {
|
|
26
|
+
readonly type: "droppedColumn"
|
|
27
|
+
readonly tableName: string
|
|
28
|
+
readonly columnName: string
|
|
29
|
+
}
|