@bakery-framework/orm 2.1.2 → 2.2.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/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
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
import { Logger, messageLogger } from '@bakery-framework/core/logger'
|
|
3
|
+
import { resolveAdapter, type SQLAdapter } from '../adapters'
|
|
4
|
+
import { type MigrationFile, readMigrations } from './files'
|
|
5
|
+
import {
|
|
6
|
+
applyMigrations,
|
|
7
|
+
ensureLedger,
|
|
8
|
+
MigrationError,
|
|
9
|
+
planMigrations,
|
|
10
|
+
readApplied,
|
|
11
|
+
} from './runner'
|
|
12
|
+
|
|
13
|
+
export * from './files'
|
|
14
|
+
export * from './runner'
|
|
15
|
+
export {
|
|
16
|
+
type ScriptDialect,
|
|
17
|
+
scanStatements,
|
|
18
|
+
transactionControl,
|
|
19
|
+
} from './script'
|
|
20
|
+
|
|
21
|
+
const logger = new Logger('db-migrate')
|
|
22
|
+
|
|
23
|
+
// prettier-ignore
|
|
24
|
+
export const migrateMsgs = {
|
|
25
|
+
OFF: 'E Migrations are off. Set %ymigrations%* in server.config.ts to the folder that holds them.',
|
|
26
|
+
PROBLEM: 'E {problem}',
|
|
27
|
+
REFUSED:
|
|
28
|
+
'E Nothing was applied. Fix the %y{count}%* problem(s) above, then run %ydb:migrate%* again.',
|
|
29
|
+
UP_TO_DATE: 'I Up to date: %y{count}%* migration(s) have run.',
|
|
30
|
+
PENDING: 'I %y{count}%* migration(s) to apply from %y{dir}%*:',
|
|
31
|
+
PENDING_ITEM: 'I %y{name}%*',
|
|
32
|
+
APPLIED: 'I Applied %y{name}%* in %y{ms}%* ms.',
|
|
33
|
+
DONE: 'I %gApplied {count} migration(s).%*',
|
|
34
|
+
FAILED: 'E {message}',
|
|
35
|
+
} as const
|
|
36
|
+
|
|
37
|
+
export const MESSAGES = messageLogger(logger, migrateMsgs)
|
|
38
|
+
|
|
39
|
+
const HELP = `
|
|
40
|
+
Usage: bun run db:migrate [--status] [--help]
|
|
41
|
+
|
|
42
|
+
Applies the SQL files in the migrations folder (\`migrations\` in
|
|
43
|
+
server.config.ts) that have not run yet, in order, each in a transaction where
|
|
44
|
+
the database can roll DDL back (Postgres and SQLite; not MySQL).
|
|
45
|
+
|
|
46
|
+
Flags:
|
|
47
|
+
--status List what would run, and apply nothing.
|
|
48
|
+
--help This.
|
|
49
|
+
|
|
50
|
+
The connection is DB_MIGRATE_URL when it is set, so migrations can run as a
|
|
51
|
+
role that owns the schema while the app connects as one that does not. Without
|
|
52
|
+
it, DB_URL.
|
|
53
|
+
`
|
|
54
|
+
|
|
55
|
+
export interface MigrateResult {
|
|
56
|
+
applied: string[]
|
|
57
|
+
pending: string[]
|
|
58
|
+
problems: string[]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Read the folder, compare it with the ledger and, when `apply` is set and
|
|
63
|
+
* nothing is wrong, apply what is pending. The migration lock is held from the
|
|
64
|
+
* ledger read to the last file, so a second run started meanwhile waits and
|
|
65
|
+
* then finds nothing to do.
|
|
66
|
+
*/
|
|
67
|
+
export async function runMigrations(
|
|
68
|
+
adapter: SQLAdapter,
|
|
69
|
+
dir: string,
|
|
70
|
+
options: {
|
|
71
|
+
apply: boolean
|
|
72
|
+
onApplied?: (file: MigrationFile, durationMs: number) => void
|
|
73
|
+
},
|
|
74
|
+
): Promise<MigrateResult> {
|
|
75
|
+
const read = await readMigrations(dir)
|
|
76
|
+
if (!read.ok) return { applied: [], pending: [], problems: read.problems }
|
|
77
|
+
|
|
78
|
+
const release = await adapter.takeMigrationLock()
|
|
79
|
+
try {
|
|
80
|
+
await ensureLedger(adapter)
|
|
81
|
+
const plan = planMigrations(read.files, await readApplied(adapter), {
|
|
82
|
+
transactionalDDL: adapter.transactionalDDL,
|
|
83
|
+
dialect: adapter.scriptDialect,
|
|
84
|
+
})
|
|
85
|
+
const pending = plan.pending.map(file => file.name)
|
|
86
|
+
if (plan.problems.length || !options.apply) {
|
|
87
|
+
return { applied: [], pending, problems: plan.problems }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const applied: string[] = []
|
|
91
|
+
await applyMigrations(adapter, plan.pending, (file, durationMs) => {
|
|
92
|
+
applied.push(file.name)
|
|
93
|
+
options.onApplied?.(file, durationMs)
|
|
94
|
+
})
|
|
95
|
+
return { applied, pending, problems: [] }
|
|
96
|
+
} finally {
|
|
97
|
+
await release()
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The connection `db:migrate` uses: `DB_MIGRATE_URL`, then the app's own.
|
|
103
|
+
* Opened with a pool of one, because the migration lock belongs to a session
|
|
104
|
+
* and every statement has to run on the session that holds it.
|
|
105
|
+
*/
|
|
106
|
+
export async function openMigrationAdapter(): Promise<SQLAdapter> {
|
|
107
|
+
const url =
|
|
108
|
+
process.env.DB_MIGRATE_URL ||
|
|
109
|
+
process.env.DB_URL ||
|
|
110
|
+
process.env.DATABASE_URL ||
|
|
111
|
+
''
|
|
112
|
+
return await resolveAdapter(url).open(url || undefined, { max: 1 })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** `bun run db:migrate`. Returns the exit code rather than exiting. */
|
|
116
|
+
export async function migrate(
|
|
117
|
+
argv: string[] = process.argv.slice(2),
|
|
118
|
+
cwd: string = process.cwd(),
|
|
119
|
+
): Promise<number> {
|
|
120
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
121
|
+
// Usage text is program output, not a log line, as in `db:sync --help`.
|
|
122
|
+
console.log(HELP)
|
|
123
|
+
return 0
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const { initConfig } = await import('@bakery-framework/core/core/config')
|
|
127
|
+
const config = await initConfig()
|
|
128
|
+
if (!config.migrations) {
|
|
129
|
+
MESSAGES.OFF()
|
|
130
|
+
return 1
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const dir = resolve(cwd, config.migrations)
|
|
134
|
+
const apply = !argv.includes('--status')
|
|
135
|
+
const adapter = await openMigrationAdapter()
|
|
136
|
+
try {
|
|
137
|
+
const result = await runMigrations(adapter, dir, {
|
|
138
|
+
apply,
|
|
139
|
+
onApplied: (file, ms) => MESSAGES.APPLIED({ name: file.name, ms }),
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
if (result.problems.length) {
|
|
143
|
+
for (const problem of result.problems) MESSAGES.PROBLEM({ problem })
|
|
144
|
+
MESSAGES.REFUSED({ count: result.problems.length })
|
|
145
|
+
return 1
|
|
146
|
+
}
|
|
147
|
+
if (!result.pending.length) {
|
|
148
|
+
const ran = (await readApplied(adapter)).length
|
|
149
|
+
MESSAGES.UP_TO_DATE({ count: ran })
|
|
150
|
+
return 0
|
|
151
|
+
}
|
|
152
|
+
if (!apply) {
|
|
153
|
+
MESSAGES.PENDING({ count: result.pending.length, dir })
|
|
154
|
+
for (const name of result.pending) MESSAGES.PENDING_ITEM({ name })
|
|
155
|
+
return 0
|
|
156
|
+
}
|
|
157
|
+
MESSAGES.DONE({ count: result.applied.length })
|
|
158
|
+
return 0
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (!(error instanceof MigrationError)) throw error
|
|
161
|
+
MESSAGES.FAILED({ message: error.message })
|
|
162
|
+
return 1
|
|
163
|
+
} finally {
|
|
164
|
+
await adapter.close()
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (import.meta.main) process.exit(await migrate())
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import type { SQLAdapter } from '../adapters/base'
|
|
2
|
+
import { compareMigrations, type MigrationFile } from './files'
|
|
3
|
+
import {
|
|
4
|
+
type ScriptDialect,
|
|
5
|
+
scanStatements,
|
|
6
|
+
transactionControl,
|
|
7
|
+
} from './script'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Applied migrations, one row per file.
|
|
11
|
+
*
|
|
12
|
+
* Its own table, not a kind of row in `__bakery_schema`: that ledger holds
|
|
13
|
+
* snapshots of a declared schema, which `db:rollback` replays by altering and
|
|
14
|
+
* dropping, and a migration is a file that ran. `sync/ledger.ts` lists this
|
|
15
|
+
* name among the tables classic sync never offers to drop.
|
|
16
|
+
*/
|
|
17
|
+
export const MIGRATIONS_TABLE = '__bakery_migrations'
|
|
18
|
+
|
|
19
|
+
export interface AppliedMigration {
|
|
20
|
+
name: string
|
|
21
|
+
checksum: string
|
|
22
|
+
/** Unix seconds. */
|
|
23
|
+
appliedAt: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** What a run will apply, and anything that stops it from applying at all. */
|
|
27
|
+
export interface MigrationPlan {
|
|
28
|
+
pending: MigrationFile[]
|
|
29
|
+
/** Empty when the run may go ahead. One problem anywhere stops every file. */
|
|
30
|
+
problems: string[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PlanOptions {
|
|
34
|
+
/** `adapter.transactionalDDL`: whether each file runs inside a transaction. */
|
|
35
|
+
transactionalDDL: boolean
|
|
36
|
+
dialect: ScriptDialect
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Compare the folder with the ledger.
|
|
41
|
+
*
|
|
42
|
+
* Fails closed: an applied file that changed or went missing, or a new file
|
|
43
|
+
* that sorts before one that has run, refuses the whole run. Each of those
|
|
44
|
+
* means the database and the folder no longer tell the same story, and
|
|
45
|
+
* applying the rest on top would build on whichever one is wrong.
|
|
46
|
+
*/
|
|
47
|
+
export function planMigrations(
|
|
48
|
+
files: MigrationFile[],
|
|
49
|
+
applied: AppliedMigration[],
|
|
50
|
+
options: PlanOptions,
|
|
51
|
+
): MigrationPlan {
|
|
52
|
+
const problems: string[] = []
|
|
53
|
+
const byName = new Map(files.map(file => [file.name, file]))
|
|
54
|
+
const ran = new Set(applied.map(row => row.name))
|
|
55
|
+
|
|
56
|
+
for (const row of applied) {
|
|
57
|
+
const file = byName.get(row.name)
|
|
58
|
+
const on = new Date(row.appliedAt * 1000).toISOString().slice(0, 10)
|
|
59
|
+
if (!file) {
|
|
60
|
+
problems.push(
|
|
61
|
+
`${row.name} ran on ${on} and is no longer in the folder. Put it back: the folder has to hold every migration that has run.`,
|
|
62
|
+
)
|
|
63
|
+
} else if (file.checksum !== row.checksum) {
|
|
64
|
+
problems.push(
|
|
65
|
+
`${row.name} changed after it ran on ${on}. Undo the edit, and write the change as a new migration.`,
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const pending = files.filter(file => !ran.has(file.name))
|
|
71
|
+
const last = files.filter(file => ran.has(file.name)).at(-1)
|
|
72
|
+
for (const file of pending) {
|
|
73
|
+
if (last && compareMigrations(file, last) < 0) {
|
|
74
|
+
problems.push(
|
|
75
|
+
`${file.name} sorts before ${last.name}, which has already run. Renumber it to sort after ${last.name}.`,
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
problems.push(...fileProblems(file, options))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { pending, problems }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** What in one file's text would make the runner's handling of it unsafe. */
|
|
85
|
+
function fileProblems(file: MigrationFile, options: PlanOptions): string[] {
|
|
86
|
+
// MySQL runs every file statement by statement with nothing to roll back,
|
|
87
|
+
// so there is no transaction for a COMMIT to escape and no reason to hold a
|
|
88
|
+
// no-transaction file to one statement.
|
|
89
|
+
if (!options.transactionalDDL) return []
|
|
90
|
+
|
|
91
|
+
if (!file.transaction) {
|
|
92
|
+
const count = scanStatements(file.text, options.dialect).length
|
|
93
|
+
return count === 1
|
|
94
|
+
? []
|
|
95
|
+
: [
|
|
96
|
+
`${file.name} runs outside a transaction, so it may hold one statement, and it holds ${count}. Move the others into a file of their own.`,
|
|
97
|
+
]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return transactionControl(file.text, options.dialect).map(
|
|
101
|
+
statement =>
|
|
102
|
+
`${file.name}, line ${statement.line}: ${statement.head} would end the transaction the file runs in and commit part of it. Remove it; each file is committed whole.`,
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A migration that failed, and whether anything of it stayed applied. */
|
|
107
|
+
export class MigrationError extends Error {
|
|
108
|
+
constructor(
|
|
109
|
+
readonly file: MigrationFile,
|
|
110
|
+
readonly rolledBack: boolean,
|
|
111
|
+
options: { cause: unknown },
|
|
112
|
+
) {
|
|
113
|
+
const reason =
|
|
114
|
+
options.cause instanceof Error
|
|
115
|
+
? options.cause.message
|
|
116
|
+
: String(options.cause)
|
|
117
|
+
super(
|
|
118
|
+
`${file.name} failed: ${reason}. ${
|
|
119
|
+
rolledBack
|
|
120
|
+
? 'Nothing in it was applied.'
|
|
121
|
+
: 'It ran without a transaction, so statements before the failure stay applied. Check the database before running it again.'
|
|
122
|
+
}`,
|
|
123
|
+
options,
|
|
124
|
+
)
|
|
125
|
+
this.name = 'MigrationError'
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Create the ledger table when it is missing. */
|
|
130
|
+
export async function ensureLedger(adapter: SQLAdapter): Promise<void> {
|
|
131
|
+
const q = (name: string) => adapter.quote(name)
|
|
132
|
+
// Written out rather than through `colDef`: these four type names mean the
|
|
133
|
+
// same thing on all three dialects, and `applied_at` is a BIGINT because
|
|
134
|
+
// MySQL's INT is 32 bits and runs out in 2038.
|
|
135
|
+
await adapter
|
|
136
|
+
.query(
|
|
137
|
+
`CREATE TABLE IF NOT EXISTS ${q(MIGRATIONS_TABLE)} (` +
|
|
138
|
+
`${q('name')} VARCHAR(255) NOT NULL, ` +
|
|
139
|
+
`${q('checksum')} VARCHAR(64) NOT NULL, ` +
|
|
140
|
+
`${q('applied_at')} BIGINT NOT NULL, ` +
|
|
141
|
+
`${q('duration_ms')} INTEGER NOT NULL, ` +
|
|
142
|
+
`PRIMARY KEY (${q('name')}))`,
|
|
143
|
+
)
|
|
144
|
+
.run()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function readApplied(
|
|
148
|
+
adapter: SQLAdapter,
|
|
149
|
+
): Promise<AppliedMigration[]> {
|
|
150
|
+
const q = (name: string) => adapter.quote(name)
|
|
151
|
+
const rows = (await adapter
|
|
152
|
+
.query(
|
|
153
|
+
`SELECT ${q('name')}, ${q('checksum')}, ${q('applied_at')} FROM ${q(MIGRATIONS_TABLE)}`,
|
|
154
|
+
)
|
|
155
|
+
.all()) as Record<string, unknown>[]
|
|
156
|
+
return rows.map(row => ({
|
|
157
|
+
name: String(row.name),
|
|
158
|
+
checksum: String(row.checksum),
|
|
159
|
+
appliedAt: Number(row.applied_at ?? row.appliedAt),
|
|
160
|
+
}))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function recordApplied(
|
|
164
|
+
adapter: SQLAdapter,
|
|
165
|
+
file: MigrationFile,
|
|
166
|
+
durationMs: number,
|
|
167
|
+
): Promise<void> {
|
|
168
|
+
const q = (name: string) => adapter.quote(name)
|
|
169
|
+
await adapter
|
|
170
|
+
.query(
|
|
171
|
+
`INSERT INTO ${q(MIGRATIONS_TABLE)} ` +
|
|
172
|
+
`(${q('name')}, ${q('checksum')}, ${q('applied_at')}, ${q('duration_ms')}) VALUES (?, ?, ?, ?)`,
|
|
173
|
+
)
|
|
174
|
+
.run(file.name, file.checksum, Math.floor(Date.now() / 1000), durationMs)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Apply `pending` in order, each file in a transaction of its own with its
|
|
179
|
+
* ledger row inside it, where the dialect can roll DDL back. Stops at the first
|
|
180
|
+
* failure: the files after it may depend on it.
|
|
181
|
+
*/
|
|
182
|
+
export async function applyMigrations(
|
|
183
|
+
adapter: SQLAdapter,
|
|
184
|
+
pending: MigrationFile[],
|
|
185
|
+
onApplied?: (file: MigrationFile, durationMs: number) => void,
|
|
186
|
+
): Promise<void> {
|
|
187
|
+
for (const file of pending) {
|
|
188
|
+
const inTransaction = adapter.transactionalDDL && file.transaction
|
|
189
|
+
const started = performance.now()
|
|
190
|
+
let durationMs = 0
|
|
191
|
+
try {
|
|
192
|
+
if (inTransaction) {
|
|
193
|
+
await adapter.transaction(async tx => {
|
|
194
|
+
await tx.executeScript(file.text)
|
|
195
|
+
durationMs = Math.round(performance.now() - started)
|
|
196
|
+
await recordApplied(tx, file, durationMs)
|
|
197
|
+
})
|
|
198
|
+
} else {
|
|
199
|
+
await adapter.executeScript(file.text)
|
|
200
|
+
durationMs = Math.round(performance.now() - started)
|
|
201
|
+
await recordApplied(adapter, file, durationMs)
|
|
202
|
+
}
|
|
203
|
+
} catch (cause) {
|
|
204
|
+
throw new MigrationError(file, inTransaction, { cause })
|
|
205
|
+
}
|
|
206
|
+
onApplied?.(file, durationMs)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Just enough of a SQL lexer to read a migration file's statements without
|
|
3
|
+
* running them: where each one starts, what its first words are, and how many
|
|
4
|
+
* there are.
|
|
5
|
+
*
|
|
6
|
+
* It exists for two refusals. A migration runs inside the runner's own
|
|
7
|
+
* transaction, so a `COMMIT` in the file would commit part of it and leave the
|
|
8
|
+
* rest, and its ledger row, outside (measured on Postgres: the table created
|
|
9
|
+
* before a `COMMIT` survived the rollback of a later failure). And a file that
|
|
10
|
+
* opts out of the transaction must hold one statement, because a failure
|
|
11
|
+
* halfway through anything longer cannot be undone.
|
|
12
|
+
*
|
|
13
|
+
* The file itself goes to the driver as written. Splitting it here and sending
|
|
14
|
+
* the pieces would mean trusting this scanner with every construct a dialect
|
|
15
|
+
* has; reading it only to refuse means a construct it misreads costs a false
|
|
16
|
+
* refusal, never a corrupted migration.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type ScriptDialect = 'pgsql' | 'sqlite' | 'mysql'
|
|
20
|
+
|
|
21
|
+
export interface ScriptStatement {
|
|
22
|
+
/** The statement's first one or two words, upper-cased: `COMMIT`, `START TRANSACTION`. */
|
|
23
|
+
head: string
|
|
24
|
+
/** 1-based line the statement starts on, for messages. */
|
|
25
|
+
line: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Words that open a statement which ends or opens a transaction. */
|
|
29
|
+
const TRANSACTION_CONTROL = new Set([
|
|
30
|
+
'BEGIN',
|
|
31
|
+
'COMMIT',
|
|
32
|
+
'END',
|
|
33
|
+
'ROLLBACK',
|
|
34
|
+
'ABORT',
|
|
35
|
+
'SAVEPOINT',
|
|
36
|
+
'RELEASE',
|
|
37
|
+
'START TRANSACTION',
|
|
38
|
+
'PREPARE TRANSACTION',
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
const WORD = /[A-Za-z_][A-Za-z0-9_$]*/y
|
|
42
|
+
const DOLLAR_TAG = /\$([A-Za-z_][A-Za-z0-9_]*)?\$/y
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The statements in `text`, in order.
|
|
46
|
+
*
|
|
47
|
+
* Handles what a migration actually contains: `--` and `/* *\/` comments
|
|
48
|
+
* (nested on Postgres), single-quoted strings with doubled quotes and the
|
|
49
|
+
* `E'…'` backslash form, double-quoted and backticked identifiers, Postgres
|
|
50
|
+
* dollar-quoted bodies (`$$ … $$`, `$fn$ … $fn$`), and the `BEGIN … END` body
|
|
51
|
+
* of a trigger, which holds semicolons that do not end the statement.
|
|
52
|
+
*/
|
|
53
|
+
export function scanStatements(
|
|
54
|
+
text: string,
|
|
55
|
+
dialect: ScriptDialect,
|
|
56
|
+
): ScriptStatement[] {
|
|
57
|
+
const statements: ScriptStatement[] = []
|
|
58
|
+
let line = 1
|
|
59
|
+
let i = 0
|
|
60
|
+
|
|
61
|
+
// The statement being read: its first words, and how deep it is inside a
|
|
62
|
+
// BEGIN … END body (above 0 once a CREATE statement's BEGIN is seen).
|
|
63
|
+
let words: string[] = []
|
|
64
|
+
let startLine = 0
|
|
65
|
+
let bodyDepth = 0
|
|
66
|
+
|
|
67
|
+
const finish = () => {
|
|
68
|
+
if (words.length) {
|
|
69
|
+
statements.push({ head: headOf(words), line: startLine })
|
|
70
|
+
}
|
|
71
|
+
words = []
|
|
72
|
+
bodyDepth = 0
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const advance = (to: number) => {
|
|
76
|
+
for (let k = i; k < to; k++) if (text.charCodeAt(k) === 10) line++
|
|
77
|
+
i = to
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
while (i < text.length) {
|
|
81
|
+
const ch = text[i]!
|
|
82
|
+
|
|
83
|
+
if (
|
|
84
|
+
(ch === '-' && text[i + 1] === '-') ||
|
|
85
|
+
(ch === '#' && dialect === 'mysql')
|
|
86
|
+
) {
|
|
87
|
+
const end = text.indexOf('\n', i)
|
|
88
|
+
advance(end === -1 ? text.length : end)
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (ch === '/' && text[i + 1] === '*') {
|
|
93
|
+
advance(blockCommentEnd(text, i, dialect === 'pgsql'))
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (/\s/.test(ch)) {
|
|
98
|
+
advance(i + 1)
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (ch === ';') {
|
|
103
|
+
advance(i + 1)
|
|
104
|
+
if (bodyDepth === 0) finish()
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!words.length) startLine = line
|
|
109
|
+
|
|
110
|
+
if (ch === "'" || ((ch === 'E' || ch === 'e') && text[i + 1] === "'")) {
|
|
111
|
+
const backslashes = ch !== "'" || dialect === 'mysql'
|
|
112
|
+
const open = ch === "'" ? i : i + 1
|
|
113
|
+
advance(quotedEnd(text, open, "'", backslashes))
|
|
114
|
+
words.push('')
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (ch === '"' || (ch === '`' && dialect === 'mysql')) {
|
|
119
|
+
advance(quotedEnd(text, i, ch, false))
|
|
120
|
+
words.push('')
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (ch === '$' && dialect === 'pgsql') {
|
|
125
|
+
DOLLAR_TAG.lastIndex = i
|
|
126
|
+
const tag = DOLLAR_TAG.exec(text)
|
|
127
|
+
if (tag) {
|
|
128
|
+
const close = text.indexOf(tag[0], i + tag[0].length)
|
|
129
|
+
advance(close === -1 ? text.length : close + tag[0].length)
|
|
130
|
+
words.push('')
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
WORD.lastIndex = i
|
|
136
|
+
const word = WORD.exec(text)
|
|
137
|
+
if (word) {
|
|
138
|
+
const upper = word[0].toUpperCase()
|
|
139
|
+
advance(i + word[0].length)
|
|
140
|
+
if (words.length < 8) words.push(upper)
|
|
141
|
+
|
|
142
|
+
// A BEGIN inside a CREATE statement opens a body with statements of its
|
|
143
|
+
// own: a SQLite trigger's, a Postgres `BEGIN ATOMIC` function's. A CASE
|
|
144
|
+
// inside the body has an END of its own. A CREATE statement never holds
|
|
145
|
+
// a transaction's BEGIN, so a plain `BEGIN;` still reads as one.
|
|
146
|
+
if (words[0] === 'CREATE' && words.length > 1) {
|
|
147
|
+
if (upper === 'BEGIN' || (upper === 'CASE' && bodyDepth > 0)) {
|
|
148
|
+
bodyDepth++
|
|
149
|
+
} else if (upper === 'END' && bodyDepth > 0) {
|
|
150
|
+
bodyDepth--
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
advance(i + 1)
|
|
157
|
+
if (words.length < 8) words.push(ch)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
finish()
|
|
161
|
+
return statements
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The statements in `text` that would end or open a transaction. */
|
|
165
|
+
export function transactionControl(
|
|
166
|
+
text: string,
|
|
167
|
+
dialect: ScriptDialect,
|
|
168
|
+
): ScriptStatement[] {
|
|
169
|
+
return scanStatements(text, dialect).filter(s =>
|
|
170
|
+
TRANSACTION_CONTROL.has(s.head),
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function headOf(words: string[]): string {
|
|
175
|
+
const [first = '', second = ''] = words
|
|
176
|
+
if ((first === 'START' || first === 'PREPARE') && second === 'TRANSACTION') {
|
|
177
|
+
return `${first} TRANSACTION`
|
|
178
|
+
}
|
|
179
|
+
return first
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Index just past the comment opening at `start`. */
|
|
183
|
+
function blockCommentEnd(text: string, start: number, nests: boolean): number {
|
|
184
|
+
let depth = 0
|
|
185
|
+
let i = start
|
|
186
|
+
while (i < text.length) {
|
|
187
|
+
if (text[i] === '/' && text[i + 1] === '*') {
|
|
188
|
+
depth++
|
|
189
|
+
i += 2
|
|
190
|
+
if (!nests && depth > 1) depth = 1
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
if (text[i] === '*' && text[i + 1] === '/') {
|
|
194
|
+
depth--
|
|
195
|
+
i += 2
|
|
196
|
+
if (depth === 0) return i
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
i++
|
|
200
|
+
}
|
|
201
|
+
return text.length
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Index just past the quoted run opening at `start`. */
|
|
205
|
+
function quotedEnd(
|
|
206
|
+
text: string,
|
|
207
|
+
start: number,
|
|
208
|
+
quote: string,
|
|
209
|
+
backslashes: boolean,
|
|
210
|
+
): number {
|
|
211
|
+
let i = start + 1
|
|
212
|
+
while (i < text.length) {
|
|
213
|
+
const ch = text[i]
|
|
214
|
+
if (backslashes && ch === '\\') {
|
|
215
|
+
i += 2
|
|
216
|
+
continue
|
|
217
|
+
}
|
|
218
|
+
if (ch === quote) {
|
|
219
|
+
if (text[i + 1] === quote) {
|
|
220
|
+
i += 2
|
|
221
|
+
continue
|
|
222
|
+
}
|
|
223
|
+
return i + 1
|
|
224
|
+
}
|
|
225
|
+
i++
|
|
226
|
+
}
|
|
227
|
+
return text.length
|
|
228
|
+
}
|
package/src/orm/query.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { getActiveDb, txStorage } from '../connection'
|
|
|
4
4
|
import type { AppDBSchema as DBSchema } from '../schema-registry'
|
|
5
5
|
import { ColumnRef as schemaColumnRef, OperatorRef as schemaOperatorRef, SQLFunctionRef as schemaSQLFunctionRef, SQL_FUNCTIONS, WindowRef as schemaWindowRef, col as schemaCol, evalOperands, isSafeIdentifier, nullComparison, qId, qRaw } from '../schema-util'
|
|
6
6
|
import { Mutation } from './mutation'
|
|
7
|
+
import { ArrayValue } from '../array-value'
|
|
7
8
|
|
|
8
9
|
export namespace DB {
|
|
9
10
|
export type MapOf<T> = Record<string, T>
|
|
@@ -370,6 +371,31 @@ export namespace DB {
|
|
|
370
371
|
return new schemaOperatorRef('BETWEEN', [minVal, maxVal])
|
|
371
372
|
}
|
|
372
373
|
|
|
374
|
+
/**
|
|
375
|
+
* A Postgres array value, for an insert, an update or a comparison:
|
|
376
|
+
* `Insert.into('roles').values({ permissions: DB.array(['grades.post']) })`.
|
|
377
|
+
* Bun cannot bind a JavaScript array to a Postgres array, so a plain one
|
|
378
|
+
* fails; see `array-value.ts`. Postgres only: the other adapters refuse it.
|
|
379
|
+
*/
|
|
380
|
+
export function array<T>(values: readonly T[]): ArrayValue<T> {
|
|
381
|
+
return new ArrayValue(values)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** `column @> values`: the array column holds every one of `values`. Postgres only. */
|
|
385
|
+
export function contains<T>(values: readonly T[]): schemaOperatorRef<T[]> {
|
|
386
|
+
return new schemaOperatorRef('@>', new ArrayValue(values) as any)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** `column && values`: the array column holds at least one of `values`. Postgres only. */
|
|
390
|
+
export function overlaps<T>(values: readonly T[]): schemaOperatorRef<T[]> {
|
|
391
|
+
return new schemaOperatorRef('&&', new ArrayValue(values) as any)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** `column @> ARRAY[value]`: the array column holds `value`. Postgres only. */
|
|
395
|
+
export function has<T>(value: T): schemaOperatorRef<T[]> {
|
|
396
|
+
return new schemaOperatorRef('@>', new ArrayValue([value]) as any)
|
|
397
|
+
}
|
|
398
|
+
|
|
373
399
|
export type SelectValue<S extends TableSchemas, J extends string> =
|
|
374
400
|
| ColumnString<S, J>
|
|
375
401
|
| SQLFunctionRef<ColumnString<S, J> | '*'>
|
|
@@ -1213,6 +1239,10 @@ export namespace DB {
|
|
|
1213
1239
|
static isNull = isNull
|
|
1214
1240
|
static isNotNull = isNotNull
|
|
1215
1241
|
static between = between
|
|
1242
|
+
static array = array
|
|
1243
|
+
static contains = contains
|
|
1244
|
+
static overlaps = overlaps
|
|
1245
|
+
static has = has
|
|
1216
1246
|
|
|
1217
1247
|
/**
|
|
1218
1248
|
* Both columns, the joined table and the alias go through the convention-8
|
package/src/schema-util.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Case } from '@bakery-framework/core/utils'
|
|
2
2
|
import { is, throws } from '@bakery-framework/core/utils/common'
|
|
3
3
|
import { quoteIdentifier } from './adapters/base'
|
|
4
|
+
import { isArrayValue } from './array-value'
|
|
4
5
|
import { getActiveDb } from './connection'
|
|
5
6
|
import type * as SyncTypes from './sync/types'
|
|
6
7
|
|
|
@@ -320,6 +321,13 @@ export function evalOperands(
|
|
|
320
321
|
|
|
321
322
|
if (where === null) return 'NULL'
|
|
322
323
|
|
|
324
|
+
// A whole Postgres array, bound as one parameter; the adapter encodes it.
|
|
325
|
+
// Before the object branch below, which would read it as `{table: column}`.
|
|
326
|
+
if (isArrayValue(where)) {
|
|
327
|
+
params.push(where)
|
|
328
|
+
return '?'
|
|
329
|
+
}
|
|
330
|
+
|
|
323
331
|
if (typeof where === 'object' && where !== null) {
|
|
324
332
|
if (where instanceof WindowRef) {
|
|
325
333
|
// The function half only. `spec` was built by the query builder out of
|