@bakery-framework/orm 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 +19 -0
- package/README.md +88 -0
- package/package.json +59 -0
- package/src/adapters/base.ts +1128 -0
- package/src/adapters/mysql.ts +619 -0
- package/src/adapters/observe.ts +261 -0
- package/src/adapters/pgsql.ts +611 -0
- package/src/adapters/registry.ts +204 -0
- package/src/adapters/sqlite.ts +588 -0
- package/src/adapters.ts +72 -0
- package/src/backup.ts +37 -0
- package/src/connection.ts +69 -0
- package/src/define.ts +380 -0
- package/src/field.ts +595 -0
- package/src/globals.d.ts +22 -0
- package/src/index.ts +63 -0
- package/src/orm/index.ts +24 -0
- package/src/orm/mutation.ts +692 -0
- package/src/orm/query.ts +1680 -0
- package/src/pool.ts +83 -0
- package/src/schema-registry.ts +75 -0
- package/src/schema-util.ts +467 -0
- package/src/sync/builder.ts +618 -0
- package/src/sync/engine.ts +399 -0
- package/src/sync/helpers.ts +1119 -0
- package/src/sync/history.ts +169 -0
- package/src/sync/index.ts +113 -0
- package/src/sync/ledger.ts +335 -0
- package/src/sync/load.ts +368 -0
- package/src/sync/rollback.ts +200 -0
- package/src/sync/types.ts +101 -0
- package/src/sync/view-sql.ts +160 -0
- package/templates/schema.example.ts +94 -0
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
import { Case, Try } from '@bakery-framework/core/utils'
|
|
2
|
+
import { SQL } from 'bun'
|
|
3
|
+
import { type PoolOptions, withPoolOptions } from '../pool'
|
|
4
|
+
import type * as SyncTypes from '../sync/types'
|
|
5
|
+
import { createExecutor, isOpenConnection, SQLAdapter } from './base'
|
|
6
|
+
|
|
7
|
+
interface PGSQLParserState {
|
|
8
|
+
inSingleQuote: boolean
|
|
9
|
+
inDoubleQuote: boolean
|
|
10
|
+
paramIndex: number
|
|
11
|
+
skipNext: boolean
|
|
12
|
+
paramsLength: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class PGAdapter extends SQLAdapter {
|
|
16
|
+
protected readonly sql: SQL
|
|
17
|
+
override readonly quoteChar: string = '"'
|
|
18
|
+
|
|
19
|
+
constructor(connectionTarget?: string | URL | SQL, pool: PoolOptions = {}) {
|
|
20
|
+
const target =
|
|
21
|
+
typeof connectionTarget === 'string' || connectionTarget instanceof URL
|
|
22
|
+
? connectionTarget.toString()
|
|
23
|
+
: undefined
|
|
24
|
+
super('postgres', undefined, target)
|
|
25
|
+
// `isOpenConnection`, not `instanceof SQL` — see the helper for why the
|
|
26
|
+
// latter throws rather than answering.
|
|
27
|
+
//
|
|
28
|
+
// Pool options apply only when this opens its own connection. A handle
|
|
29
|
+
// handed in is already someone else's pool — notably a transaction's, where
|
|
30
|
+
// re-sizing anything would be meaningless.
|
|
31
|
+
this.sql = isOpenConnection(connectionTarget)
|
|
32
|
+
? (connectionTarget as SQL)
|
|
33
|
+
: target
|
|
34
|
+
? new SQL(target, withPoolOptions({}, pool) as any)
|
|
35
|
+
: new SQL(withPoolOptions({}, pool) as any)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private static handleQuote(
|
|
39
|
+
char: string,
|
|
40
|
+
nextChar: string | undefined,
|
|
41
|
+
state: PGSQLParserState,
|
|
42
|
+
): string | null {
|
|
43
|
+
if (char === "'") {
|
|
44
|
+
if (state.inSingleQuote && nextChar === "'") {
|
|
45
|
+
state.skipNext = true
|
|
46
|
+
return "''"
|
|
47
|
+
}
|
|
48
|
+
state.inSingleQuote = !state.inSingleQuote && !state.inDoubleQuote
|
|
49
|
+
return char
|
|
50
|
+
}
|
|
51
|
+
if (char === '"') {
|
|
52
|
+
state.inDoubleQuote = !state.inDoubleQuote && !state.inSingleQuote
|
|
53
|
+
return char
|
|
54
|
+
}
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private static handleSpecial(
|
|
59
|
+
char: string,
|
|
60
|
+
nextChar: string | undefined,
|
|
61
|
+
state: PGSQLParserState,
|
|
62
|
+
): string | null {
|
|
63
|
+
if (char === '\\') {
|
|
64
|
+
if ((state.inSingleQuote || state.inDoubleQuote) && nextChar) {
|
|
65
|
+
state.skipNext = true
|
|
66
|
+
return `\\${nextChar}`
|
|
67
|
+
}
|
|
68
|
+
return char
|
|
69
|
+
}
|
|
70
|
+
if (char === '`') {
|
|
71
|
+
return !state.inSingleQuote && !state.inDoubleQuote ? '"' : char
|
|
72
|
+
}
|
|
73
|
+
if (char === '?') {
|
|
74
|
+
return !state.inSingleQuote &&
|
|
75
|
+
!state.inDoubleQuote &&
|
|
76
|
+
state.paramsLength > 0
|
|
77
|
+
? `$${++state.paramIndex}`
|
|
78
|
+
: char
|
|
79
|
+
}
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private static normalizePostgresSQL(sql: string, params: unknown[]) {
|
|
84
|
+
let result = ''
|
|
85
|
+
const state: PGSQLParserState = {
|
|
86
|
+
inSingleQuote: false,
|
|
87
|
+
inDoubleQuote: false,
|
|
88
|
+
paramIndex: 0,
|
|
89
|
+
skipNext: false,
|
|
90
|
+
paramsLength: params.length,
|
|
91
|
+
}
|
|
92
|
+
// `skipNext` is consumed in exactly one place — the `i++` below — and
|
|
93
|
+
// cleared there.
|
|
94
|
+
//
|
|
95
|
+
// It used to be consumed twice: a top-of-loop
|
|
96
|
+
// `if (state.skipNext) continue`
|
|
97
|
+
// *and* the `i++`. A handler that set the flag therefore ate two characters
|
|
98
|
+
// instead of one, so every doubled quote swallowed whatever followed it:
|
|
99
|
+
// `'a''b'` was rewritten to `'a'''`, which Postgres reads as `a'`. A string
|
|
100
|
+
// default containing an apostrophe silently lost the rest of its value, and
|
|
101
|
+
// the same applied to a backslash escape. Values bind as parameters, so
|
|
102
|
+
// this
|
|
103
|
+
// only ever reached literals the framework itself emits — DDL defaults —
|
|
104
|
+
// which is why it survived: `ddl.test.ts` asserts the SQL *before*
|
|
105
|
+
// normalisation, and no live server ran until now.
|
|
106
|
+
for (let i = 0; i < sql.length; i++) {
|
|
107
|
+
const char = sql[i]
|
|
108
|
+
const nextChar = sql[i + 1]
|
|
109
|
+
|
|
110
|
+
const handled =
|
|
111
|
+
PGAdapter.handleQuote(char, nextChar, state) ??
|
|
112
|
+
PGAdapter.handleSpecial(char, nextChar, state)
|
|
113
|
+
|
|
114
|
+
if (handled !== null) {
|
|
115
|
+
result += handled
|
|
116
|
+
if (state.skipNext) {
|
|
117
|
+
state.skipNext = false
|
|
118
|
+
i++
|
|
119
|
+
}
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
result += char
|
|
123
|
+
}
|
|
124
|
+
return result
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
readonly execute: SQLAdapter.Executor = createExecutor(
|
|
128
|
+
async (sqlText: string, params: unknown[] = []) =>
|
|
129
|
+
(await this.sql.unsafe(
|
|
130
|
+
PGAdapter.normalizePostgresSQL(sqlText, params),
|
|
131
|
+
params,
|
|
132
|
+
)) as any,
|
|
133
|
+
async (
|
|
134
|
+
sqlText: string,
|
|
135
|
+
params: unknown[] = [],
|
|
136
|
+
): Promise<SQLAdapter.RunResult> => {
|
|
137
|
+
let sql = sqlText
|
|
138
|
+
const isInsert = /^\s*insert\s+into\s+/i.test(sql)
|
|
139
|
+
if (isInsert && !/\breturning\b/i.test(sql)) sql += ' RETURNING *'
|
|
140
|
+
|
|
141
|
+
const rows = (await this.sql.unsafe(
|
|
142
|
+
PGAdapter.normalizePostgresSQL(sql, params),
|
|
143
|
+
params,
|
|
144
|
+
)) as any
|
|
145
|
+
// `count` is authoritative on Postgres for every command — it is the
|
|
146
|
+
// row count from the command tag — so it is read first and `rows.length`
|
|
147
|
+
// is only a fallback.
|
|
148
|
+
//
|
|
149
|
+
// It used to be the other way round, behind an `Array.isArray` check
|
|
150
|
+
// that was always true: Bun returns an *array* for a write too, just an
|
|
151
|
+
// empty one, so the `count` branch was unreachable and `changes` was
|
|
152
|
+
// `rows.length`. `UPDATE` and `DELETE` return no rows, so both reported
|
|
153
|
+
// 0. `INSERT` was right only by accident — the `RETURNING *` appended
|
|
154
|
+
// above happens to make `rows.length` the number inserted.
|
|
155
|
+
const changes = Number(
|
|
156
|
+
rows?.count ?? (Array.isArray(rows) ? rows.length : 0),
|
|
157
|
+
)
|
|
158
|
+
let lastInsertRowid = null
|
|
159
|
+
|
|
160
|
+
if (isInsert && Array.isArray(rows) && rows.length > 0) {
|
|
161
|
+
const firstRow = rows[0]
|
|
162
|
+
if (firstRow)
|
|
163
|
+
lastInsertRowid =
|
|
164
|
+
firstRow.id ??
|
|
165
|
+
firstRow.id_user ??
|
|
166
|
+
Object.values(firstRow)[0] ??
|
|
167
|
+
null
|
|
168
|
+
}
|
|
169
|
+
return { lastInsertRowid, changes }
|
|
170
|
+
},
|
|
171
|
+
this.driver,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
async hasCol(table: string, column: string): Promise<boolean> {
|
|
175
|
+
const res = await this.query(
|
|
176
|
+
'SELECT 1 FROM information_schema.columns' +
|
|
177
|
+
' WHERE table_name = ? AND column_name = ?' +
|
|
178
|
+
' AND table_schema = current_schema()',
|
|
179
|
+
).all(table, column)
|
|
180
|
+
return res.length > 0
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
colDef(def: unknown, column?: string): string {
|
|
184
|
+
const d = def as any
|
|
185
|
+
let typeStr =
|
|
186
|
+
{
|
|
187
|
+
integer: 'INTEGER',
|
|
188
|
+
string: 'TEXT',
|
|
189
|
+
number: 'DOUBLE PRECISION',
|
|
190
|
+
boolean: 'BOOLEAN',
|
|
191
|
+
buffer: 'BYTEA',
|
|
192
|
+
bigint: 'BIGINT',
|
|
193
|
+
json: 'JSONB',
|
|
194
|
+
}[d.type as string] || 'TEXT'
|
|
195
|
+
if (d.type === 'string' && typeof d.length === 'number')
|
|
196
|
+
typeStr = `VARCHAR(${d.length})`
|
|
197
|
+
if (d.autoIncrement && d.type === 'integer')
|
|
198
|
+
typeStr = 'INTEGER GENERATED BY DEFAULT AS IDENTITY'
|
|
199
|
+
|
|
200
|
+
let sql = `${typeStr}`
|
|
201
|
+
if (d.primary) sql += ' PRIMARY KEY'
|
|
202
|
+
if (!d.nullable && !d.primary) sql += ' NOT NULL'
|
|
203
|
+
// The CHECK names the column, which is why colDef takes it. Emitted only
|
|
204
|
+
// when both are known: an ALTER path that has no name yet gets a plain
|
|
205
|
+
// sized column rather than a syntax error.
|
|
206
|
+
const check =
|
|
207
|
+
Array.isArray(d._enum) && d._enum.length && column
|
|
208
|
+
? this.enumClause(column, d._enum)
|
|
209
|
+
: ''
|
|
210
|
+
return sql + this.formatDefault(d.default, 'TRUE', 'FALSE') + check
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async backup(keepCount = 10): Promise<SQLAdapter.BackupResult | null> {
|
|
214
|
+
if (!this.url) return null
|
|
215
|
+
const base = Try.return(
|
|
216
|
+
() => new URL(this.url!).pathname.replace(/^\//, ''),
|
|
217
|
+
'postgres',
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
const parsed = new URL(this.url!)
|
|
221
|
+
const safeUrl = new URL(this.url!)
|
|
222
|
+
safeUrl.password = ''
|
|
223
|
+
const envOverride = parsed.password
|
|
224
|
+
? { PGPASSWORD: decodeURIComponent(parsed.password) }
|
|
225
|
+
: undefined
|
|
226
|
+
|
|
227
|
+
return await this.spawnBackup(
|
|
228
|
+
'pg_dump',
|
|
229
|
+
fullPath => [
|
|
230
|
+
'pg_dump',
|
|
231
|
+
'--dbname',
|
|
232
|
+
safeUrl.toString(),
|
|
233
|
+
'--no-owner',
|
|
234
|
+
'--no-privileges',
|
|
235
|
+
'--file',
|
|
236
|
+
fullPath,
|
|
237
|
+
],
|
|
238
|
+
'.sql',
|
|
239
|
+
keepCount,
|
|
240
|
+
base,
|
|
241
|
+
envOverride,
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
protected withConnection(sql: unknown): SQLAdapter {
|
|
246
|
+
return new PGAdapter(sql as SQL)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async getSchema(): Promise<SQLAdapter.TableDetails[]> {
|
|
250
|
+
const res = (await this.query(
|
|
251
|
+
'SELECT table_name AS name, table_type AS type' +
|
|
252
|
+
' FROM information_schema.tables' +
|
|
253
|
+
" WHERE table_schema NOT IN ('pg_catalog', 'information_schema')" +
|
|
254
|
+
' ORDER BY table_name',
|
|
255
|
+
).all()) as any[]
|
|
256
|
+
const tablesWithDetails: SQLAdapter.TableDetails[] = []
|
|
257
|
+
|
|
258
|
+
for (const t of res) {
|
|
259
|
+
const qName = this.quote(t.name)
|
|
260
|
+
const [countRes, cols, pkCols, idxs] = (await Promise.all([
|
|
261
|
+
this.query(`SELECT COUNT(*)::int as count FROM ${qName}`).get(),
|
|
262
|
+
this.query(
|
|
263
|
+
'SELECT column_name AS name, data_type AS type,' +
|
|
264
|
+
' is_nullable AS is_nullable' +
|
|
265
|
+
' FROM information_schema.columns' +
|
|
266
|
+
' WHERE table_name = ?' +
|
|
267
|
+
" AND table_schema NOT IN ('pg_catalog', 'information_schema')" +
|
|
268
|
+
' ORDER BY ordinal_position',
|
|
269
|
+
).all(t.name),
|
|
270
|
+
this.query(
|
|
271
|
+
'SELECT a.attname AS name' +
|
|
272
|
+
' FROM pg_index i' +
|
|
273
|
+
' JOIN pg_attribute a ON a.attrelid = i.indrelid' +
|
|
274
|
+
' AND a.attnum = ANY(i.indkey)' +
|
|
275
|
+
` WHERE i.indisprimary AND i.indrelid = ${qName}::regclass`,
|
|
276
|
+
).all(),
|
|
277
|
+
this.query(
|
|
278
|
+
'SELECT indexname AS name, indexdef AS def' +
|
|
279
|
+
' FROM pg_indexes' +
|
|
280
|
+
" WHERE schemaname NOT IN ('pg_catalog', 'information_schema')" +
|
|
281
|
+
' AND tablename = ?',
|
|
282
|
+
).all(t.name),
|
|
283
|
+
])) as [SQLAdapter.CountRow, any[], any[], any[]]
|
|
284
|
+
tablesWithDetails.push({
|
|
285
|
+
name: t.name,
|
|
286
|
+
rowCount: countRes?.count || 0,
|
|
287
|
+
columns: cols.map(c => ({
|
|
288
|
+
name: c.name,
|
|
289
|
+
type: c.type,
|
|
290
|
+
notnull: c.is_nullable === 'NO',
|
|
291
|
+
pk: pkCols.some(pk => pk.name === c.name),
|
|
292
|
+
})),
|
|
293
|
+
indexes: idxs.map(i => ({
|
|
294
|
+
name: i.name,
|
|
295
|
+
unique: /UNIQUE/i.test(i.def),
|
|
296
|
+
})),
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
return tablesWithDetails
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async getData(
|
|
303
|
+
tableName: string,
|
|
304
|
+
options: SQLAdapter.TableDataOptions,
|
|
305
|
+
): Promise<SQLAdapter.TableDataResult> {
|
|
306
|
+
const cols = (await this.query(
|
|
307
|
+
'SELECT column_name AS name' +
|
|
308
|
+
' FROM information_schema.columns' +
|
|
309
|
+
' WHERE table_name = ?' +
|
|
310
|
+
" AND table_schema NOT IN ('pg_catalog', 'information_schema')",
|
|
311
|
+
).all(tableName)) as SQLAdapter.NameRow[]
|
|
312
|
+
const { whereSql, orderSql, whereParams } = this.buildFilterSort(
|
|
313
|
+
options,
|
|
314
|
+
new Set(cols.map(c => c.name)),
|
|
315
|
+
)
|
|
316
|
+
const tName = this.quote(tableName)
|
|
317
|
+
const countRes = (await this.query(
|
|
318
|
+
`SELECT COUNT(*) as count FROM ${tName}${whereSql}`,
|
|
319
|
+
).get(...whereParams)) as SQLAdapter.CountRow
|
|
320
|
+
const totalRows = countRes?.count || 0
|
|
321
|
+
const rows = (await this.query(
|
|
322
|
+
`SELECT ctid::text AS rowid, * FROM ${tName}` +
|
|
323
|
+
`${whereSql}${orderSql} LIMIT ? OFFSET ?`,
|
|
324
|
+
).all(
|
|
325
|
+
...whereParams,
|
|
326
|
+
options.pageSize,
|
|
327
|
+
(options.page - 1) * options.pageSize,
|
|
328
|
+
)) as any[]
|
|
329
|
+
return {
|
|
330
|
+
rows,
|
|
331
|
+
totalRows,
|
|
332
|
+
page: options.page,
|
|
333
|
+
pageSize: options.pageSize,
|
|
334
|
+
totalPages: Math.ceil(totalRows / options.pageSize),
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async remove(
|
|
339
|
+
tableName: string,
|
|
340
|
+
rowid: unknown,
|
|
341
|
+
): Promise<SQLAdapter.RunResult> {
|
|
342
|
+
return await this.query(
|
|
343
|
+
`DELETE FROM ${this.quote(tableName)} WHERE ctid::text = ?`,
|
|
344
|
+
).run(rowid)
|
|
345
|
+
}
|
|
346
|
+
async truncate(tableName: string): Promise<SQLAdapter.RunResult> {
|
|
347
|
+
return await this.query(
|
|
348
|
+
`TRUNCATE TABLE ${this.quote(tableName)} RESTART IDENTITY CASCADE`,
|
|
349
|
+
).run()
|
|
350
|
+
}
|
|
351
|
+
async update(
|
|
352
|
+
tableName: string,
|
|
353
|
+
rowid: unknown,
|
|
354
|
+
row: SQLAdapter.RowRecord,
|
|
355
|
+
): Promise<SQLAdapter.RunResult> {
|
|
356
|
+
const keys = Object.keys(row).filter(k => k !== 'rowid')
|
|
357
|
+
return await this.query(
|
|
358
|
+
`UPDATE ${this.quote(tableName)}` +
|
|
359
|
+
` SET ${keys.map(k => `${this.quote(k)} = ?`).join(', ')}` +
|
|
360
|
+
' WHERE ctid::text = ?',
|
|
361
|
+
).run(...keys.map(k => row[k]), rowid)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
override async getForeignKeys(): Promise<SyncTypes.DBForeignKeys> {
|
|
365
|
+
const rows = (await this.query(
|
|
366
|
+
'SELECT con.conname AS name, c.relname AS child,' +
|
|
367
|
+
' att.attname AS child_col,' +
|
|
368
|
+
' pc.relname AS parent, patt.attname AS parent_col,' +
|
|
369
|
+
' con.confdeltype AS on_delete, con.confupdtype AS on_update' +
|
|
370
|
+
' FROM pg_constraint con' +
|
|
371
|
+
' JOIN pg_class c ON c.oid = con.conrelid' +
|
|
372
|
+
' JOIN pg_class pc ON pc.oid = con.confrelid' +
|
|
373
|
+
' JOIN unnest(con.conkey) WITH ORDINALITY AS k(attnum, ord) ON true' +
|
|
374
|
+
' JOIN unnest(con.confkey) WITH ORDINALITY AS fk(attnum, ord)' +
|
|
375
|
+
' ON fk.ord = k.ord' +
|
|
376
|
+
' JOIN pg_attribute att' +
|
|
377
|
+
' ON att.attrelid = con.conrelid AND att.attnum = k.attnum' +
|
|
378
|
+
' JOIN pg_attribute patt' +
|
|
379
|
+
' ON patt.attrelid = con.confrelid AND patt.attnum = fk.attnum' +
|
|
380
|
+
" WHERE con.contype = 'f'" +
|
|
381
|
+
' ORDER BY con.conname, k.ord',
|
|
382
|
+
).all()) as any[]
|
|
383
|
+
return SQLAdapter.groupForeignKeyRows(rows)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async getConstraints(): Promise<SyncTypes.DBConstraints> {
|
|
387
|
+
const tables = (await this.query(
|
|
388
|
+
'SELECT table_name, table_type' +
|
|
389
|
+
' FROM information_schema.tables' +
|
|
390
|
+
' WHERE table_schema = current_schema()' +
|
|
391
|
+
" AND table_type IN ('BASE TABLE','VIEW')",
|
|
392
|
+
).all()) as any[]
|
|
393
|
+
const pkRows = (await this.query(
|
|
394
|
+
'SELECT tc.table_name, kcu.column_name' +
|
|
395
|
+
' FROM information_schema.table_constraints tc' +
|
|
396
|
+
' JOIN information_schema.key_column_usage kcu' +
|
|
397
|
+
' ON tc.constraint_name = kcu.constraint_name' +
|
|
398
|
+
' AND tc.constraint_schema = kcu.constraint_schema' +
|
|
399
|
+
" WHERE tc.constraint_type = 'PRIMARY KEY'" +
|
|
400
|
+
' AND tc.constraint_schema = current_schema()',
|
|
401
|
+
).all()) as any[]
|
|
402
|
+
const pkMap = pkRows.reduce(
|
|
403
|
+
(acc, r) => {
|
|
404
|
+
if (!acc[r.table_name]) {
|
|
405
|
+
acc[r.table_name] = new Set()
|
|
406
|
+
}
|
|
407
|
+
acc[r.table_name].add(r.column_name)
|
|
408
|
+
return acc
|
|
409
|
+
},
|
|
410
|
+
{} as Record<string, Set<string>>,
|
|
411
|
+
)
|
|
412
|
+
const dbConstraints: SyncTypes.DBConstraints = {}
|
|
413
|
+
|
|
414
|
+
for (const t of tables) {
|
|
415
|
+
const tName = Case.camel(t.table_name)
|
|
416
|
+
dbConstraints[tName] = {} as SyncTypes.TableConstraints
|
|
417
|
+
|
|
418
|
+
if (t.table_type === 'VIEW') {
|
|
419
|
+
const viewDef = (await this.query(
|
|
420
|
+
'SELECT view_definition' +
|
|
421
|
+
' FROM information_schema.views' +
|
|
422
|
+
' WHERE table_schema = current_schema() AND table_name = ?',
|
|
423
|
+
).get(t.table_name)) as any
|
|
424
|
+
if (viewDef?.view_definition)
|
|
425
|
+
dbConstraints[tName]._view = viewDef.view_definition
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// is_identity/identity_generation are selected because an identity column
|
|
429
|
+
// — what colDef() emits — carries a NULL column_default; only the legacy
|
|
430
|
+
// serial style leaves a nextval(...) marker there.
|
|
431
|
+
const cols = (await this.query(
|
|
432
|
+
'SELECT column_name, data_type, is_nullable, column_default,' +
|
|
433
|
+
' udt_name, is_identity, identity_generation,' +
|
|
434
|
+
' character_maximum_length' +
|
|
435
|
+
' FROM information_schema.columns' +
|
|
436
|
+
' WHERE table_schema = current_schema() AND table_name = ?' +
|
|
437
|
+
' ORDER BY ordinal_position',
|
|
438
|
+
).all(t.table_name)) as any[]
|
|
439
|
+
|
|
440
|
+
for (const col of cols) {
|
|
441
|
+
const primary = pkMap[t.table_name]?.has(col.column_name) || false
|
|
442
|
+
dbConstraints[tName][Case.camel(col.column_name)] =
|
|
443
|
+
this.parseConstraints(col, primary)
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return dbConstraints
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async getIndexes(): Promise<SyncTypes.DBIndexes> {
|
|
450
|
+
const rows = (await this.query(
|
|
451
|
+
'SELECT indexname, indexdef, tablename' +
|
|
452
|
+
' FROM pg_indexes' +
|
|
453
|
+
' WHERE schemaname = current_schema()',
|
|
454
|
+
).all()) as any[]
|
|
455
|
+
const dbIndexes: SyncTypes.DBIndexes = {}
|
|
456
|
+
for (const r of rows) {
|
|
457
|
+
if (
|
|
458
|
+
!r.indexname ||
|
|
459
|
+
r.indexname.endsWith('_pkey') ||
|
|
460
|
+
/PRIMARY KEY/i.test(r.indexdef)
|
|
461
|
+
)
|
|
462
|
+
continue
|
|
463
|
+
const m = r.indexdef.match(/\(([^)]+)\)/)
|
|
464
|
+
dbIndexes[Case.camel(r.indexname)] = {
|
|
465
|
+
type: /UNIQUE/i.test(r.indexdef) ? 'unique' : 'index',
|
|
466
|
+
table: Case.camel(r.tablename),
|
|
467
|
+
cols: m
|
|
468
|
+
? m[1]
|
|
469
|
+
.split(',')
|
|
470
|
+
.map((c: string) => Case.camel(c.trim().replace(/"/g, '')))
|
|
471
|
+
: [],
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return dbIndexes
|
|
475
|
+
}
|
|
476
|
+
// Two patterns because Postgres does not store DDL text — it stores a parsed
|
|
477
|
+
// expression and re-renders it, and how it renders depends on the version.
|
|
478
|
+
// PG 14+ reports `(EXTRACT(epoch FROM now()))::integer`; PG <= 13 parses
|
|
479
|
+
// EXTRACT into `date_part('epoch'::text, now())`, which the EXTRACT pattern
|
|
480
|
+
// does not match. Missing it means the column diffs dirty on every single
|
|
481
|
+
// sync — the same perpetual-rebuild failure the SQLite quote bug caused.
|
|
482
|
+
override readonly dateNowDefaults: string[] = [
|
|
483
|
+
'EXTRACT(EPOCH FROM',
|
|
484
|
+
'DATE_PART',
|
|
485
|
+
]
|
|
486
|
+
// The emitted expression is deliberately not `dateNowDefaults[0]`: that entry
|
|
487
|
+
// is a match *prefix* for reading a default back out (Postgres re-renders the
|
|
488
|
+
// expression, so the tail varies), and emitting it produced the unbalanced
|
|
489
|
+
// `DEFAULT (EXTRACT(EPOCH FROM)`. Round trip: Postgres reports this back as
|
|
490
|
+
// `(EXTRACT(epoch FROM now()))::integer`, which isDateNowDefault() strips
|
|
491
|
+
// parens from and matches against the prefix above.
|
|
492
|
+
override readonly dateNowExpression: string = 'EXTRACT(EPOCH FROM NOW())'
|
|
493
|
+
|
|
494
|
+
// `gen_random_uuid()` is built in from Postgres 13; before that it lived in
|
|
495
|
+
// the pgcrypto extension. Postgres reports it back as
|
|
496
|
+
// `gen_random_uuid()`, so unlike the epoch expression the emitted form and
|
|
497
|
+
// the match pattern coincide — stated rather than assumed, because the two
|
|
498
|
+
// being equal here is a coincidence of this expression, not a rule.
|
|
499
|
+
override readonly uuidDefaults: string[] = ['GEN_RANDOM_UUID']
|
|
500
|
+
override readonly uuidExpression: string = 'gen_random_uuid()'
|
|
501
|
+
|
|
502
|
+
protected override parseConstraints(
|
|
503
|
+
col: any,
|
|
504
|
+
primary = false,
|
|
505
|
+
): SyncTypes.ColumnConstraint {
|
|
506
|
+
const cons: SyncTypes.ColumnConstraint = {
|
|
507
|
+
type: PGAdapter.mapPgTypeToTsType(
|
|
508
|
+
String(col.data_type || col.udt_name || ''),
|
|
509
|
+
),
|
|
510
|
+
}
|
|
511
|
+
// Postgres calls it 'character varying' and reports null for TEXT, so the
|
|
512
|
+
// guard has less to do here than on MySQL — but it is the same guard.
|
|
513
|
+
const length = this.sizedTextLength(
|
|
514
|
+
String(col.data_type || col.udt_name || ''),
|
|
515
|
+
col.character_maximum_length,
|
|
516
|
+
)
|
|
517
|
+
if (length !== undefined) cons.length = length
|
|
518
|
+
if (primary) cons.primary = true
|
|
519
|
+
if (PGAdapter.isAutoIncrement(col)) cons.autoIncrement = true
|
|
520
|
+
if (col.is_nullable === 'YES' && !primary) cons.nullable = true
|
|
521
|
+
|
|
522
|
+
let def = col.column_default
|
|
523
|
+
if (
|
|
524
|
+
typeof def === 'string' &&
|
|
525
|
+
(def.replace(/[()'::\w]+$/, '').trim() === '%dateNow%' ||
|
|
526
|
+
def === '%dateNow%')
|
|
527
|
+
) {
|
|
528
|
+
def = `'${def}'`
|
|
529
|
+
} else {
|
|
530
|
+
// Postgres does not store the DDL text of a default; it re-renders the
|
|
531
|
+
// parsed expression and appends the column's type. `''` comes back as
|
|
532
|
+
// `''::character varying` and `'x'` as `'x'::text`, neither of which
|
|
533
|
+
// equals what the schema says — so the column diffs on every sync.
|
|
534
|
+
//
|
|
535
|
+
// The cast is only stripped when it ends the string, which leaves
|
|
536
|
+
// `nextval('seq'::regclass)` and the `%dateNow%` expressions alone: both
|
|
537
|
+
// end in `)`, and both are recognised elsewhere.
|
|
538
|
+
if (typeof def === 'string') {
|
|
539
|
+
const bare = def.replace(/::[\w ]+$/, '').trim()
|
|
540
|
+
const quoted = /^'([\s\S]*)'$/.exec(bare)
|
|
541
|
+
def = quoted ? quoted[1].replaceAll("''", "'") : bare
|
|
542
|
+
}
|
|
543
|
+
def = this.parseDefault(def)
|
|
544
|
+
}
|
|
545
|
+
if (def !== undefined) cons.default = def
|
|
546
|
+
return cons
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Postgres spells auto-increment two ways, and they are mutually exclusive in
|
|
551
|
+
* `information_schema.columns`.
|
|
552
|
+
*
|
|
553
|
+
* - Identity (`GENERATED ... AS IDENTITY`, what `colDef` emits): the sequence
|
|
554
|
+
* is a property of the column, so `column_default` is NULL and the fact
|
|
555
|
+
* lives in `is_identity` / `identity_generation`.
|
|
556
|
+
* - Legacy `serial`: sugar for a plain column whose default is
|
|
557
|
+
* `nextval('<seq>'::regclass)`, with no identity flags at all.
|
|
558
|
+
*
|
|
559
|
+
* Checking only the second made the adapter report a Bakery-created primary
|
|
560
|
+
* key as not auto-incrementing. `is_identity` is a `yes_or_no` domain, hence
|
|
561
|
+
* the string compare; the boolean arm is there in case a driver coerces it.
|
|
562
|
+
*/
|
|
563
|
+
private static isAutoIncrement(col: any): boolean {
|
|
564
|
+
const identity = col?.is_identity
|
|
565
|
+
if (identity === true) return true
|
|
566
|
+
if (typeof identity === 'string' && identity.trim().toUpperCase() === 'YES')
|
|
567
|
+
return true
|
|
568
|
+
if (
|
|
569
|
+
typeof col?.identity_generation === 'string' &&
|
|
570
|
+
col.identity_generation.trim() !== ''
|
|
571
|
+
)
|
|
572
|
+
return true
|
|
573
|
+
return (
|
|
574
|
+
typeof col?.column_default === 'string' &&
|
|
575
|
+
col.column_default.includes('nextval')
|
|
576
|
+
)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
private static readonly pgTypes = [
|
|
580
|
+
{ test: (t: string) => t.includes('json'), type: 'json' as const },
|
|
581
|
+
{ test: (t: string) => t.includes('bigint'), type: 'bigint' as const },
|
|
582
|
+
{
|
|
583
|
+
test: (t: string) =>
|
|
584
|
+
t.includes('int') ||
|
|
585
|
+
t.includes('serial') ||
|
|
586
|
+
t.includes('bigint') ||
|
|
587
|
+
t.includes('smallint'),
|
|
588
|
+
type: 'integer' as const,
|
|
589
|
+
},
|
|
590
|
+
{ test: (t: string) => t.includes('bool'), type: 'boolean' as const },
|
|
591
|
+
{ test: (t: string) => t.includes('bytea'), type: 'buffer' as const },
|
|
592
|
+
{
|
|
593
|
+
test: (t: string) =>
|
|
594
|
+
t.includes('double') ||
|
|
595
|
+
t.includes('real') ||
|
|
596
|
+
t.includes('numeric') ||
|
|
597
|
+
t.includes('decimal'),
|
|
598
|
+
type: 'number' as const,
|
|
599
|
+
},
|
|
600
|
+
]
|
|
601
|
+
|
|
602
|
+
private static mapPgTypeToTsType(
|
|
603
|
+
sqlType: string,
|
|
604
|
+
): SyncTypes.ColumnConstraint['type'] {
|
|
605
|
+
const t = (sqlType || '').toLowerCase()
|
|
606
|
+
for (const m of PGAdapter.pgTypes) {
|
|
607
|
+
if (m.test(t)) return m.type
|
|
608
|
+
}
|
|
609
|
+
return 'string'
|
|
610
|
+
}
|
|
611
|
+
}
|