@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.
@@ -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
+ }