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