@bakery-framework/orm 2.1.2 → 2.2.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 +5 -3
- package/src/adapters/base.ts +137 -4
- package/src/adapters/live-types.ts +308 -0
- package/src/adapters/mysql.ts +75 -0
- package/src/adapters/pgsql.ts +124 -5
- package/src/adapters/sqlite.ts +39 -3
- package/src/array-value.ts +48 -0
- package/src/field.ts +98 -0
- package/src/migrate/check.ts +108 -0
- package/src/migrate/files.ts +111 -0
- package/src/migrate/index.ts +168 -0
- package/src/migrate/runner.ts +208 -0
- package/src/migrate/script.ts +228 -0
- package/src/orm/query.ts +30 -0
- package/src/schema-util.ts +8 -0
- package/src/sessions.ts +309 -0
- package/src/sync/index.ts +66 -4
- package/src/sync/ledger.ts +11 -5
- package/src/sync/rollback.ts +6 -0
- package/src/sync/types.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakery-framework/orm",
|
|
3
|
-
"version": "2.1
|
|
3
|
+
"version": "2.2.1",
|
|
4
4
|
"description": "Bakery database layer: adapters, query builder, schema sync and backup.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bakery",
|
|
@@ -36,13 +36,15 @@
|
|
|
36
36
|
"./sync/ledger": "./src/sync/ledger.ts",
|
|
37
37
|
"./sync/history": "./src/sync/history.ts",
|
|
38
38
|
"./sync/rollback": "./src/sync/rollback.ts",
|
|
39
|
+
"./migrate": "./src/migrate/index.ts",
|
|
39
40
|
"./templates/*": "./templates/*",
|
|
40
41
|
"./package.json": "./package.json",
|
|
41
42
|
"./schema-registry": "./src/schema-registry.ts",
|
|
42
43
|
"./schema-util": "./src/schema-util.ts",
|
|
43
44
|
"./connection": "./src/connection.ts",
|
|
44
45
|
"./pool": "./src/pool.ts",
|
|
45
|
-
"./backup": "./src/backup.ts"
|
|
46
|
+
"./backup": "./src/backup.ts",
|
|
47
|
+
"./sessions": "./src/sessions.ts"
|
|
46
48
|
},
|
|
47
49
|
"files": [
|
|
48
50
|
"src",
|
|
@@ -54,6 +56,6 @@
|
|
|
54
56
|
"bun": ">=1.4.0"
|
|
55
57
|
},
|
|
56
58
|
"dependencies": {
|
|
57
|
-
"@bakery-framework/core": "^2.1
|
|
59
|
+
"@bakery-framework/core": "^2.2.1"
|
|
58
60
|
}
|
|
59
61
|
}
|
package/src/adapters/base.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { Logger } from '@bakery-framework/core/logger'
|
|
|
3
3
|
import type { MapOf } from '@bakery-framework/core/types'
|
|
4
4
|
import { Case, Try } from '@bakery-framework/core/utils'
|
|
5
5
|
import { throws } from '@bakery-framework/core/utils/common'
|
|
6
|
+
import { isArrayValue } from '../array-value'
|
|
7
|
+
import type { ScriptDialect } from '../migrate/script'
|
|
6
8
|
import type * as SyncTypes from '../sync/types'
|
|
7
9
|
import { DatabaseMissingError, isMissingDatabase } from './missing-database'
|
|
8
10
|
import { observe, observeIterate } from './observe'
|
|
@@ -20,6 +22,27 @@ export namespace SQLAdapter {
|
|
|
20
22
|
lastInsertRowid: number | bigint | null
|
|
21
23
|
changes: number
|
|
22
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* A column as the database's catalog describes it, for the check that
|
|
27
|
+
* migrations mode runs in place of a sync (`migrate/check.ts`).
|
|
28
|
+
*/
|
|
29
|
+
export interface LiveColumn {
|
|
30
|
+
/** As the database spells it. */
|
|
31
|
+
table: string
|
|
32
|
+
column: string
|
|
33
|
+
/** In the dialect's own spelling, lower-cased: `character varying(64)`, `int`. */
|
|
34
|
+
type: string
|
|
35
|
+
nullable: boolean
|
|
36
|
+
primary: boolean
|
|
37
|
+
/** A view's column: catalogs report every one of them nullable. */
|
|
38
|
+
view: boolean
|
|
39
|
+
/** A native enum's labels, in order, where the dialect has native enums. */
|
|
40
|
+
enumValues?: string[]
|
|
41
|
+
}
|
|
42
|
+
/** The one call `executeScript` makes on a driver handle. */
|
|
43
|
+
export interface ScriptHandle {
|
|
44
|
+
unsafe(text: string): Promise<unknown>
|
|
45
|
+
}
|
|
23
46
|
export interface BackupResult {
|
|
24
47
|
file: string
|
|
25
48
|
cleanupCount?: number
|
|
@@ -298,6 +321,22 @@ function explained<A extends unknown[], R>(
|
|
|
298
321
|
}
|
|
299
322
|
}
|
|
300
323
|
|
|
324
|
+
/**
|
|
325
|
+
* The default `bind`: parameters as they are, refusing a `DB.array()` value,
|
|
326
|
+
* which only the Postgres adapter can encode. Bound as an object it would
|
|
327
|
+
* reach the driver as `[object Object]` or a driver error naming nothing.
|
|
328
|
+
*/
|
|
329
|
+
function refuseArrays(driver: SQLAdapter.Driver) {
|
|
330
|
+
return (params: unknown[]): unknown[] => {
|
|
331
|
+
if (params.some(isArrayValue)) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
`DB.array() writes a Postgres array, and the ${driver} adapter has no array type. Store the values as JSON (Field.Json) instead.`,
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
return params
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
301
340
|
export function createExecutor(
|
|
302
341
|
rawAll: SQLAdapter.Executor['all'],
|
|
303
342
|
rawRun: SQLAdapter.Executor['run'],
|
|
@@ -312,10 +351,28 @@ export function createExecutor(
|
|
|
312
351
|
* the paging `iterate` carry it too. See `SQLAdapter.explainError`.
|
|
313
352
|
*/
|
|
314
353
|
explain?: (error: unknown) => unknown
|
|
354
|
+
/**
|
|
355
|
+
* Turn the query's parameters into what the driver binds. Postgres
|
|
356
|
+
* encodes a `DB.array()` value as an array literal here; the default
|
|
357
|
+
* refuses one, since only Postgres has arrays.
|
|
358
|
+
*/
|
|
359
|
+
bind?: (params: unknown[]) => unknown[]
|
|
315
360
|
} = {},
|
|
316
361
|
): SQLAdapter.Executor {
|
|
317
|
-
const
|
|
318
|
-
const
|
|
362
|
+
const bind = options.bind ?? refuseArrays(driver)
|
|
363
|
+
const bound = <F extends SQLAdapter.Executor['all'] | SQLAdapter.Executor['run']>(
|
|
364
|
+
fn: F,
|
|
365
|
+
): F =>
|
|
366
|
+
(async (sqlText: string, params: unknown[] = []) =>
|
|
367
|
+
await fn(sqlText, bind(params))) as F
|
|
368
|
+
const explainedAll = options.explain
|
|
369
|
+
? explained(rawAll, options.explain)
|
|
370
|
+
: rawAll
|
|
371
|
+
const explainedRun = options.explain
|
|
372
|
+
? explained(rawRun, options.explain)
|
|
373
|
+
: rawRun
|
|
374
|
+
const all = bound(explainedAll)
|
|
375
|
+
const run = bound(explainedRun)
|
|
319
376
|
const iterate = options.iterate ?? pagedIterate(all, options.chunkSize)
|
|
320
377
|
// `get` and `values` call the raw `all` rather than `exec.all`, which is a
|
|
321
378
|
// behavioral detail worth stating: it is what keeps one executed statement
|
|
@@ -459,7 +516,10 @@ export abstract class SQLAdapter {
|
|
|
459
516
|
* `undefined` when there is no URL to rewrite. A static for the reason
|
|
460
517
|
* {@link usableTotal} gives.
|
|
461
518
|
*/
|
|
462
|
-
static withDatabase(
|
|
519
|
+
static withDatabase(
|
|
520
|
+
url: string | undefined,
|
|
521
|
+
name: string,
|
|
522
|
+
): string | undefined {
|
|
463
523
|
if (!url) return undefined
|
|
464
524
|
return Try.return(() => {
|
|
465
525
|
const target = new URL(url)
|
|
@@ -572,6 +632,76 @@ export abstract class SQLAdapter {
|
|
|
572
632
|
? await handle.savepoint(run)
|
|
573
633
|
: await handle.transaction(run)
|
|
574
634
|
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Whether DDL runs inside a transaction here and is undone by a rollback.
|
|
638
|
+
* True on Postgres and SQLite; MySQL commits each DDL statement as it runs,
|
|
639
|
+
* so a migration that fails halfway there leaves its first half applied.
|
|
640
|
+
*/
|
|
641
|
+
get transactionalDDL(): boolean {
|
|
642
|
+
return true
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* How this dialect's SQL text is lexed, for reading a migration file without
|
|
647
|
+
* running it (`migrate/script.ts`): standard quotes and comments, plus
|
|
648
|
+
* Postgres's dollar quotes or MySQL's backticks and `#` comments.
|
|
649
|
+
*/
|
|
650
|
+
get scriptDialect(): ScriptDialect {
|
|
651
|
+
return 'sqlite'
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Run a migration file's text as written: every statement in it, with no
|
|
656
|
+
* parameters and no rewriting.
|
|
657
|
+
*
|
|
658
|
+
* Not `query().run()`, which is built for one statement with placeholders:
|
|
659
|
+
* the Postgres executor rewrites `?` and backticks (it does not know about
|
|
660
|
+
* comments or `$$` bodies, and measured, it turned a backtick inside a quoted
|
|
661
|
+
* default into a double quote after a `-- don't` comment), and appends
|
|
662
|
+
* `RETURNING *` to anything starting with INSERT. With no parameters, Bun
|
|
663
|
+
* sends the text over the simple protocol, which takes several statements
|
|
664
|
+
* at once on all three dialects.
|
|
665
|
+
*/
|
|
666
|
+
async executeScript(text: string): Promise<void> {
|
|
667
|
+
await (this.sql as SQLAdapter.ScriptHandle).unsafe(text)
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Take the lock that keeps two migration runs from applying the same file,
|
|
672
|
+
* and return what releases it. A no-op here: SQLite serializes writers on
|
|
673
|
+
* its own. Held for the session, so the runner's connection pool has to be
|
|
674
|
+
* a single connection.
|
|
675
|
+
*/
|
|
676
|
+
async takeMigrationLock(): Promise<() => Promise<void>> {
|
|
677
|
+
return async () => {}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Every column of every table and view in this connection's schema, read
|
|
682
|
+
* from the catalog alone, for the migrations-mode check. Catalog alone,
|
|
683
|
+
* because the check runs as the app's role, which may hold no privilege on
|
|
684
|
+
* some tables at all: on Postgres `information_schema` hid such a table
|
|
685
|
+
* entirely, and hid the primary key of a table the role could only read
|
|
686
|
+
* (measured), while `pg_catalog` showed both.
|
|
687
|
+
*/
|
|
688
|
+
async liveColumns(): Promise<SQLAdapter.LiveColumn[]> {
|
|
689
|
+
throw new Error(
|
|
690
|
+
`The ${this.driver} adapter cannot read its catalog for the migrations check.`,
|
|
691
|
+
)
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Why a live column's type does not match its declaration, or `null` when
|
|
696
|
+
* it does. Each adapter states its own dialect's rules: which spellings a
|
|
697
|
+
* `Field` accepts, and how a `Field.Sql` type compares.
|
|
698
|
+
*/
|
|
699
|
+
columnTypeProblem(
|
|
700
|
+
_declared: SyncTypes.ColumnConstraint,
|
|
701
|
+
_live: SQLAdapter.LiveColumn,
|
|
702
|
+
): string | null {
|
|
703
|
+
return null
|
|
704
|
+
}
|
|
575
705
|
protected abstract parseConstraints(
|
|
576
706
|
col: unknown,
|
|
577
707
|
...params: unknown[]
|
|
@@ -1097,8 +1227,11 @@ export abstract class SQLAdapter {
|
|
|
1097
1227
|
* `axb`: `%` and `_` are the wildcards, and the filter passed user input
|
|
1098
1228
|
* through untouched. The escape character itself goes first, or escaping it
|
|
1099
1229
|
* afterwards would double the ones this method just added.
|
|
1230
|
+
*
|
|
1231
|
+
* Public for SQL written outside the adapter (the session store's search),
|
|
1232
|
+
* which pairs it with {@link likeEscapeClause}: the two are one decision.
|
|
1100
1233
|
*/
|
|
1101
|
-
|
|
1234
|
+
escapeLike(value: string): string {
|
|
1102
1235
|
const e = this.likeEscape
|
|
1103
1236
|
return value
|
|
1104
1237
|
.split(e)
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import type * as SyncTypes from '../sync/types'
|
|
2
|
+
import type { SQLAdapter } from './base'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Which live column types a declaration accepts, per dialect, for the check
|
|
6
|
+
* migrations mode runs instead of a sync (`migrate/check.ts`).
|
|
7
|
+
*
|
|
8
|
+
* The question each rule answers is "would a query typed by this declaration
|
|
9
|
+
* be right about what this column holds", which is looser than "is this the
|
|
10
|
+
* type classic sync would have created": a declared `Field.Int()` is right
|
|
11
|
+
* about a `smallint`, and a declared `Field.String()` about any `varchar`. A
|
|
12
|
+
* width or a precision the declaration states is held to exactly; one it
|
|
13
|
+
* leaves out is not asked about, the way an unsized `Field.String()` never
|
|
14
|
+
* asked for a column to shrink.
|
|
15
|
+
*
|
|
16
|
+
* Pure functions of the declaration and the catalog's description, so the
|
|
17
|
+
* rules are tested without a server.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
type Declared = SyncTypes.ColumnConstraint
|
|
21
|
+
type Live = SQLAdapter.LiveColumn
|
|
22
|
+
|
|
23
|
+
/** `declared X, the database has Y`. */
|
|
24
|
+
function mismatch(expected: string, live: Live): string {
|
|
25
|
+
return `declared ${expected}, the database has ${live.type}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A native enum whose labels differ from the declared members. Text with a
|
|
30
|
+
* CHECK is accepted unread: no catalog spells a CHECK's expression in a form
|
|
31
|
+
* worth parsing here.
|
|
32
|
+
*/
|
|
33
|
+
function enumProblem(declared: Declared, live: Live): string | null {
|
|
34
|
+
if (!declared._enum || !live.enumValues) return null
|
|
35
|
+
const want = [...declared._enum].sort().join(', ')
|
|
36
|
+
const have = [...live.enumValues].sort().join(', ')
|
|
37
|
+
return want === have
|
|
38
|
+
? null
|
|
39
|
+
: `declared the values ${want}, the database's enum has ${have}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The base name and modifier of a type: `numeric(5,2)` is `numeric` and `(5,2)`. */
|
|
43
|
+
function splitModifier(type: string): { base: string; modifier: string } {
|
|
44
|
+
const open = type.indexOf('(')
|
|
45
|
+
if (open === -1) return { base: type, modifier: '' }
|
|
46
|
+
const close = type.indexOf(')', open)
|
|
47
|
+
const modifier = type.slice(open, close + 1).replace(/\s+/g, '')
|
|
48
|
+
// Postgres puts a timestamp's precision in the middle:
|
|
49
|
+
// `timestamp(3) with time zone`.
|
|
50
|
+
const base = `${type.slice(0, open)}${type.slice(close + 1)}`
|
|
51
|
+
.replace(/\s+/g, ' ')
|
|
52
|
+
.trim()
|
|
53
|
+
return { base, modifier }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Two spellings that mean the same type, where `declared` may leave a width,
|
|
58
|
+
* a precision or an array's element modifier unsaid.
|
|
59
|
+
*/
|
|
60
|
+
function sameType(declared: string, live: string): boolean {
|
|
61
|
+
if (declared === live) return true
|
|
62
|
+
const d = splitModifier(declared)
|
|
63
|
+
const l = splitModifier(live)
|
|
64
|
+
return d.base === l.base && (d.modifier === '' || d.modifier === l.modifier)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const PG_ALIASES: Record<string, string> = {
|
|
68
|
+
int: 'integer',
|
|
69
|
+
int4: 'integer',
|
|
70
|
+
serial: 'integer',
|
|
71
|
+
serial4: 'integer',
|
|
72
|
+
int2: 'smallint',
|
|
73
|
+
smallserial: 'smallint',
|
|
74
|
+
serial2: 'smallint',
|
|
75
|
+
int8: 'bigint',
|
|
76
|
+
bigserial: 'bigint',
|
|
77
|
+
serial8: 'bigint',
|
|
78
|
+
float4: 'real',
|
|
79
|
+
float8: 'double precision',
|
|
80
|
+
float: 'double precision',
|
|
81
|
+
bool: 'boolean',
|
|
82
|
+
varchar: 'character varying',
|
|
83
|
+
char: 'character',
|
|
84
|
+
bpchar: 'character',
|
|
85
|
+
decimal: 'numeric',
|
|
86
|
+
timestamptz: 'timestamp with time zone',
|
|
87
|
+
timestamp: 'timestamp without time zone',
|
|
88
|
+
timetz: 'time with time zone',
|
|
89
|
+
time: 'time without time zone',
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A Postgres type as a declaration may spell it, in the spelling
|
|
94
|
+
* `format_type()` reports: `timestamptz` is `timestamp with time zone`,
|
|
95
|
+
* `varchar(64)` is `character varying(64)`, `int[]` is `integer[]`.
|
|
96
|
+
*/
|
|
97
|
+
export function normalizePgType(spelled: string): string {
|
|
98
|
+
let type = spelled.trim().toLowerCase().replace(/\s+/g, ' ')
|
|
99
|
+
let arrays = ''
|
|
100
|
+
while (type.endsWith('[]')) {
|
|
101
|
+
arrays += '[]'
|
|
102
|
+
type = type.slice(0, -2).trim()
|
|
103
|
+
}
|
|
104
|
+
const { base, modifier } = splitModifier(type)
|
|
105
|
+
const name = PG_ALIASES[base] ?? base
|
|
106
|
+
if (modifier && name.startsWith('timestamp ')) {
|
|
107
|
+
return `timestamp${modifier} ${name.slice('timestamp '.length)}${arrays}`
|
|
108
|
+
}
|
|
109
|
+
if (modifier && name.startsWith('time ')) {
|
|
110
|
+
return `time${modifier} ${name.slice('time '.length)}${arrays}`
|
|
111
|
+
}
|
|
112
|
+
return `${name}${modifier}${arrays}`
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Postgres: `live.type` is `format_type()`, a domain already resolved to its base. */
|
|
116
|
+
export function pgTypeProblem(declared: Declared, live: Live): string | null {
|
|
117
|
+
const type = live.type
|
|
118
|
+
const accepts = (ok: boolean, expected: string) =>
|
|
119
|
+
ok ? null : mismatch(expected, live)
|
|
120
|
+
|
|
121
|
+
switch (declared.type) {
|
|
122
|
+
case 'integer':
|
|
123
|
+
return accepts(
|
|
124
|
+
type === 'integer' || type === 'smallint',
|
|
125
|
+
'an integer (integer or smallint)',
|
|
126
|
+
)
|
|
127
|
+
case 'bigint':
|
|
128
|
+
return accepts(type === 'bigint', 'bigint')
|
|
129
|
+
case 'number':
|
|
130
|
+
return accepts(
|
|
131
|
+
type === 'double precision' || type === 'real',
|
|
132
|
+
'a float (double precision or real)',
|
|
133
|
+
)
|
|
134
|
+
case 'boolean':
|
|
135
|
+
return accepts(type === 'boolean', 'boolean')
|
|
136
|
+
case 'json':
|
|
137
|
+
return accepts(type === 'json' || type === 'jsonb', 'json or jsonb')
|
|
138
|
+
case 'buffer':
|
|
139
|
+
return accepts(type === 'bytea', 'bytea')
|
|
140
|
+
case 'sql': {
|
|
141
|
+
const want = normalizePgType(declared.sqlType ?? '')
|
|
142
|
+
return accepts(sameType(want, type), want)
|
|
143
|
+
}
|
|
144
|
+
case 'string': {
|
|
145
|
+
if (declared._enum) {
|
|
146
|
+
const textual = type === 'text' || type.startsWith('character varying')
|
|
147
|
+
if (!textual && !live.enumValues) {
|
|
148
|
+
return mismatch('an enum (text, varchar, or a native enum)', live)
|
|
149
|
+
}
|
|
150
|
+
return enumProblem(declared, live)
|
|
151
|
+
}
|
|
152
|
+
// Field.Uuid is a 36-character string; a native uuid holds the same.
|
|
153
|
+
if (declared.length === 36 && type === 'uuid') return null
|
|
154
|
+
if (declared.length) {
|
|
155
|
+
return accepts(
|
|
156
|
+
type === `character varying(${declared.length})` ||
|
|
157
|
+
type === `character(${declared.length})`,
|
|
158
|
+
`varchar(${declared.length})`,
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
return accepts(
|
|
162
|
+
type === 'text' ||
|
|
163
|
+
type === 'uuid' ||
|
|
164
|
+
type === 'citext' ||
|
|
165
|
+
type.startsWith('character varying') ||
|
|
166
|
+
type.startsWith('character('),
|
|
167
|
+
'text (text, varchar or char)',
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const MYSQL_ALIASES: Record<string, string> = {
|
|
174
|
+
integer: 'int',
|
|
175
|
+
bool: 'tinyint(1)',
|
|
176
|
+
boolean: 'tinyint(1)',
|
|
177
|
+
numeric: 'decimal',
|
|
178
|
+
dec: 'decimal',
|
|
179
|
+
'double precision': 'double',
|
|
180
|
+
real: 'double',
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** A MySQL type the way `column_type` reports it: no display width on an integer. */
|
|
184
|
+
export function normalizeMysqlType(spelled: string): string {
|
|
185
|
+
const type = spelled.trim().toLowerCase().replace(/\s+/g, ' ')
|
|
186
|
+
if (MYSQL_ALIASES[type]) return MYSQL_ALIASES[type]!
|
|
187
|
+
const { base, modifier } = splitModifier(type)
|
|
188
|
+
const name = MYSQL_ALIASES[base] ?? base
|
|
189
|
+
// `int(11)` is a display width, which MySQL 8 no longer reports: the type
|
|
190
|
+
// is `int`. `tinyint(1)` is the exception everyone relies on to mean bool.
|
|
191
|
+
if (/^(tinyint|smallint|mediumint|int|bigint)( unsigned)?$/.test(name)) {
|
|
192
|
+
return modifier === '(1)' && name === 'tinyint' ? 'tinyint(1)' : name
|
|
193
|
+
}
|
|
194
|
+
return `${name}${modifier}`
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** MySQL: `live.type` is `column_type`, lower-cased. */
|
|
198
|
+
export function mysqlTypeProblem(
|
|
199
|
+
declared: Declared,
|
|
200
|
+
live: Live,
|
|
201
|
+
): string | null {
|
|
202
|
+
const type = normalizeMysqlType(live.type)
|
|
203
|
+
const accepts = (ok: boolean, expected: string) =>
|
|
204
|
+
ok ? null : mismatch(expected, live)
|
|
205
|
+
const integer = /^(tinyint|smallint|mediumint|int)( unsigned)?$/
|
|
206
|
+
|
|
207
|
+
switch (declared.type) {
|
|
208
|
+
case 'integer':
|
|
209
|
+
return accepts(
|
|
210
|
+
integer.test(type),
|
|
211
|
+
'an integer (int, smallint, mediumint)',
|
|
212
|
+
)
|
|
213
|
+
case 'bigint':
|
|
214
|
+
return accepts(type.startsWith('bigint'), 'bigint')
|
|
215
|
+
case 'number':
|
|
216
|
+
return accepts(
|
|
217
|
+
type === 'double' || type === 'float',
|
|
218
|
+
'a float (double or float)',
|
|
219
|
+
)
|
|
220
|
+
case 'boolean':
|
|
221
|
+
return accepts(type === 'tinyint(1)', 'tinyint(1)')
|
|
222
|
+
case 'json':
|
|
223
|
+
return accepts(type === 'json', 'json')
|
|
224
|
+
case 'buffer':
|
|
225
|
+
return accepts(
|
|
226
|
+
type.endsWith('blob') || type.startsWith('varbinary'),
|
|
227
|
+
'a blob',
|
|
228
|
+
)
|
|
229
|
+
case 'sql': {
|
|
230
|
+
const want = normalizeMysqlType(declared.sqlType ?? '')
|
|
231
|
+
return accepts(sameType(want, type), want)
|
|
232
|
+
}
|
|
233
|
+
case 'string': {
|
|
234
|
+
if (declared._enum) {
|
|
235
|
+
if (type.startsWith('enum(')) {
|
|
236
|
+
const labels = [...type.matchAll(/'((?:[^']|'')*)'/g)].map(m =>
|
|
237
|
+
m[1]!.replace(/''/g, "'"),
|
|
238
|
+
)
|
|
239
|
+
return enumProblem(declared, { ...live, enumValues: labels })
|
|
240
|
+
}
|
|
241
|
+
return accepts(
|
|
242
|
+
type.endsWith('text') || type.startsWith('varchar'),
|
|
243
|
+
'an enum (enum, varchar or text)',
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
if (declared.length) {
|
|
247
|
+
return accepts(
|
|
248
|
+
type === `varchar(${declared.length})` ||
|
|
249
|
+
type === `char(${declared.length})`,
|
|
250
|
+
`varchar(${declared.length})`,
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
return accepts(
|
|
254
|
+
type.endsWith('text') ||
|
|
255
|
+
type.startsWith('varchar') ||
|
|
256
|
+
type.startsWith('char('),
|
|
257
|
+
'text (text, varchar or char)',
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* SQLite: `live.type` is the declared type text, which SQLite keeps but does
|
|
265
|
+
* not enforce. The rules are its type affinity rules, so a column declared
|
|
266
|
+
* `VARCHAR(10)` that holds text of any length still reads as text.
|
|
267
|
+
*/
|
|
268
|
+
export function sqliteTypeProblem(
|
|
269
|
+
declared: Declared,
|
|
270
|
+
live: Live,
|
|
271
|
+
): string | null {
|
|
272
|
+
const type = live.type
|
|
273
|
+
const accepts = (ok: boolean, expected: string) =>
|
|
274
|
+
ok ? null : mismatch(expected, live)
|
|
275
|
+
const integer = type.includes('int')
|
|
276
|
+
const textual =
|
|
277
|
+
type.includes('char') || type.includes('clob') || type.includes('text')
|
|
278
|
+
const real =
|
|
279
|
+
type.includes('real') || type.includes('floa') || type.includes('doub')
|
|
280
|
+
|
|
281
|
+
switch (declared.type) {
|
|
282
|
+
case 'integer':
|
|
283
|
+
case 'bigint':
|
|
284
|
+
return accepts(integer, 'an integer')
|
|
285
|
+
case 'boolean':
|
|
286
|
+
return accepts(integer || type.includes('bool'), 'an integer or boolean')
|
|
287
|
+
case 'number':
|
|
288
|
+
return accepts(real || type.includes('numeric'), 'a real')
|
|
289
|
+
case 'json':
|
|
290
|
+
return accepts(type.includes('json') || textual, 'json or text')
|
|
291
|
+
case 'buffer':
|
|
292
|
+
return accepts(type === '' || type.includes('blob'), 'a blob')
|
|
293
|
+
case 'sql': {
|
|
294
|
+
const want = (declared.sqlType ?? '').trim().toLowerCase()
|
|
295
|
+
return accepts(sameType(want, type.replace(/\s+/g, ' ')), want)
|
|
296
|
+
}
|
|
297
|
+
case 'string': {
|
|
298
|
+
if (declared.length && /\(\d+\)/.test(type)) {
|
|
299
|
+
return accepts(
|
|
300
|
+
type === `varchar(${declared.length})` ||
|
|
301
|
+
type === `character varying(${declared.length})`,
|
|
302
|
+
`varchar(${declared.length})`,
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
return accepts(textual, 'text')
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
package/src/adapters/mysql.ts
CHANGED
|
@@ -3,6 +3,22 @@ import { SQL } from 'bun'
|
|
|
3
3
|
import { type PoolOptions, withPoolOptions } from '../pool'
|
|
4
4
|
import type * as SyncTypes from '../sync/types'
|
|
5
5
|
import { createExecutor, isOpenConnection, SQLAdapter } from './base'
|
|
6
|
+
import { mysqlTypeProblem } from './live-types'
|
|
7
|
+
import type { ScriptDialect } from '../migrate/script'
|
|
8
|
+
|
|
9
|
+
/** The database's tables and views, a row per column, from `information_schema`. */
|
|
10
|
+
const MYSQL_LIVE_COLUMNS = `
|
|
11
|
+
SELECT c.table_name AS table_name,
|
|
12
|
+
t.table_type AS table_type,
|
|
13
|
+
c.column_name AS column_name,
|
|
14
|
+
c.column_type AS type,
|
|
15
|
+
c.is_nullable = 'YES' AS nullable,
|
|
16
|
+
c.column_key = 'PRI' AS is_primary
|
|
17
|
+
FROM information_schema.columns c
|
|
18
|
+
JOIN information_schema.tables t
|
|
19
|
+
ON t.table_schema = c.table_schema AND t.table_name = c.table_name
|
|
20
|
+
WHERE c.table_schema = DATABASE()
|
|
21
|
+
ORDER BY c.table_name, c.ordinal_position`
|
|
6
22
|
|
|
7
23
|
export class MySQLAdapter extends SQLAdapter {
|
|
8
24
|
protected readonly sql: SQL
|
|
@@ -267,6 +283,65 @@ export class MySQLAdapter extends SQLAdapter {
|
|
|
267
283
|
return false
|
|
268
284
|
}
|
|
269
285
|
|
|
286
|
+
/** MySQL commits every DDL statement as it runs; a rollback cannot undo one. */
|
|
287
|
+
override get transactionalDDL(): boolean {
|
|
288
|
+
return false
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
override get scriptDialect(): ScriptDialect {
|
|
292
|
+
return 'mysql'
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* A named lock, held by the session. `GET_LOCK` waits up to its timeout and
|
|
297
|
+
* answers 1 when it has the lock and 0 when the wait ran out, so 0 has to be
|
|
298
|
+
* refused here rather than taken for success.
|
|
299
|
+
*/
|
|
300
|
+
override async takeMigrationLock(): Promise<() => Promise<void>> {
|
|
301
|
+
const rows = (await this.sql.unsafe(
|
|
302
|
+
"SELECT GET_LOCK('bakery_migrations', 600) AS taken",
|
|
303
|
+
)) as { taken: unknown }[]
|
|
304
|
+
if (Number(rows[0]?.taken) !== 1) {
|
|
305
|
+
throw new Error(
|
|
306
|
+
'Another migration run has held the lock for 10 minutes. Wait for it to finish, then run db:migrate again.',
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
return async () => {
|
|
310
|
+
await this.sql.unsafe("SELECT RELEASE_LOCK('bakery_migrations')")
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* From `information_schema`, MySQL's only catalog. It lists what the user
|
|
316
|
+
* holds a privilege on, so a table the app's user cannot touch is reported
|
|
317
|
+
* missing, which for a query typed against it is the truth.
|
|
318
|
+
*/
|
|
319
|
+
override async liveColumns(): Promise<SQLAdapter.LiveColumn[]> {
|
|
320
|
+
const rows = (await this.sql.unsafe(MYSQL_LIVE_COLUMNS)) as {
|
|
321
|
+
table_name: string
|
|
322
|
+
table_type: string
|
|
323
|
+
column_name: string
|
|
324
|
+
type: string
|
|
325
|
+
nullable: number | boolean
|
|
326
|
+
is_primary: number | boolean
|
|
327
|
+
}[]
|
|
328
|
+
return rows.map(row => ({
|
|
329
|
+
table: row.table_name,
|
|
330
|
+
column: row.column_name,
|
|
331
|
+
type: String(row.type).toLowerCase(),
|
|
332
|
+
nullable: Boolean(Number(row.nullable)),
|
|
333
|
+
primary: Boolean(Number(row.is_primary)),
|
|
334
|
+
view: row.table_type === 'VIEW',
|
|
335
|
+
}))
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
override columnTypeProblem(
|
|
339
|
+
declared: SyncTypes.ColumnConstraint,
|
|
340
|
+
live: SQLAdapter.LiveColumn,
|
|
341
|
+
): string | null {
|
|
342
|
+
return mysqlTypeProblem(declared, live)
|
|
343
|
+
}
|
|
344
|
+
|
|
270
345
|
protected withConnection(sql: unknown): SQLAdapter {
|
|
271
346
|
return new MySQLAdapter(sql as SQL)
|
|
272
347
|
}
|