@avelonjs/postgres 0.1.0 → 0.3.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/README.md +89 -55
- package/package.json +3 -2
- package/src/bun-client.ts +32 -0
- package/src/compile.ts +62 -8
- package/src/driver.ts +33 -223
- package/src/errors.ts +9 -2
- package/src/execute.ts +209 -0
- package/src/fixtures-sql.ts +104 -0
- package/src/fixtures.ts +2 -52
- package/src/index.ts +2 -1
- package/src/migrations-core.ts +142 -0
- package/src/migrations.ts +13 -91
- package/src/schema-core.ts +124 -0
- package/src/schema.ts +5 -117
- package/src/sql-client.ts +28 -0
- package/src/sql.ts +15 -0
- package/src/validate.ts +24 -7
package/src/driver.ts
CHANGED
|
@@ -5,14 +5,13 @@ import {
|
|
|
5
5
|
type DatabaseTransaction,
|
|
6
6
|
type MigrationPlan,
|
|
7
7
|
type MigrationStatus,
|
|
8
|
-
type Predicate,
|
|
9
8
|
type QueryIR,
|
|
10
9
|
type QueryResult,
|
|
11
|
-
type RelationLoad,
|
|
12
10
|
type TransactionSurface,
|
|
13
11
|
} from '@avelonjs/core'
|
|
14
|
-
import {
|
|
12
|
+
import { bunSqlRunner } from './bun-client'
|
|
15
13
|
import { mapPostgresError } from './errors'
|
|
14
|
+
import { executeQueryIR, executeRpc } from './execute'
|
|
16
15
|
import { resetAssayFixtures } from './fixtures'
|
|
17
16
|
import {
|
|
18
17
|
applyMigrations,
|
|
@@ -20,10 +19,9 @@ import {
|
|
|
20
19
|
rollbackMigrations,
|
|
21
20
|
statusMigrations,
|
|
22
21
|
type PostgresMigration,
|
|
23
|
-
} from './migrations'
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import { validateQueryIR } from './validate'
|
|
22
|
+
} from './migrations-core'
|
|
23
|
+
import { loadSchemaCache, type SchemaCache } from './schema-core'
|
|
24
|
+
import type { SqlBatchRunner } from './sql-client'
|
|
27
25
|
|
|
28
26
|
/** Exact capability declaration for the Postgres database driver. */
|
|
29
27
|
export const postgresDatabaseCapabilities = {
|
|
@@ -48,9 +46,6 @@ export interface PostgresDatabaseOptions {
|
|
|
48
46
|
migrations?: readonly PostgresMigration[]
|
|
49
47
|
}
|
|
50
48
|
|
|
51
|
-
type Row = Record<string, unknown>
|
|
52
|
-
type SqlClient = SQL
|
|
53
|
-
|
|
54
49
|
function defaultUrl(): string {
|
|
55
50
|
return (
|
|
56
51
|
process.env.POSTGRES_URL ??
|
|
@@ -59,56 +54,14 @@ function defaultUrl(): string {
|
|
|
59
54
|
)
|
|
60
55
|
}
|
|
61
56
|
|
|
62
|
-
function includeColumns(select: string[] | '*', columns: string[]): string[] | '*' {
|
|
63
|
-
if (select === '*') return '*'
|
|
64
|
-
return [...new Set([...select, ...columns])]
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function projectRow(row: Row, select: string[] | '*', relations: readonly RelationLoad[]): Row {
|
|
68
|
-
const output: Row = {}
|
|
69
|
-
const columns =
|
|
70
|
-
select === '*'
|
|
71
|
-
? Object.keys(row).filter((column) => !relations.some((relation) => relation.relation === column))
|
|
72
|
-
: select
|
|
73
|
-
for (const column of columns) output[column] = row[column]
|
|
74
|
-
for (const relation of relations) output[relation.relation] = row[relation.relation]
|
|
75
|
-
return output
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function unique(values: unknown[]): unknown[] {
|
|
79
|
-
const output: unknown[] = []
|
|
80
|
-
for (const value of values) {
|
|
81
|
-
if (!output.some((existing) => sqlEqual(existing, value))) output.push(value)
|
|
82
|
-
}
|
|
83
|
-
return output
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function sqlEqual(left: unknown, right: unknown): boolean {
|
|
87
|
-
if (left === null || left === undefined || right === null || right === undefined) return false
|
|
88
|
-
return left === right
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function asRows(result: unknown): Row[] {
|
|
92
|
-
if (!Array.isArray(result)) return []
|
|
93
|
-
return result.map((row) => ({ ...(row as Row) }))
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function resultCount(result: unknown): number {
|
|
97
|
-
if (!Array.isArray(result)) return 0
|
|
98
|
-
const count = Reflect.get(result, 'count')
|
|
99
|
-
return typeof count === 'number' ? count : result.length
|
|
100
|
-
}
|
|
101
|
-
|
|
102
57
|
/**
|
|
103
58
|
* Postgres implementation of the frozen database contract.
|
|
104
59
|
*
|
|
105
|
-
* Vendor access stays inside this package through Bun's SQL client.
|
|
106
|
-
*
|
|
60
|
+
* Vendor access stays inside this package through Bun's SQL client. The query IR algorithm itself
|
|
61
|
+
* lives in `@avelonjs/postgres/sql`, which no Bun API reaches; this class only adapts the client.
|
|
107
62
|
*/
|
|
108
63
|
export class PostgresDatabase
|
|
109
|
-
implements
|
|
110
|
-
DatabaseDriver<typeof postgresDatabaseCapabilities, SQL>,
|
|
111
|
-
TransactionSurface
|
|
64
|
+
implements DatabaseDriver<typeof postgresDatabaseCapabilities, SQL>, TransactionSurface
|
|
112
65
|
{
|
|
113
66
|
/** Driver implementation name. */
|
|
114
67
|
readonly name = 'postgres'
|
|
@@ -120,6 +73,7 @@ export class PostgresDatabase
|
|
|
120
73
|
readonly capabilities: typeof postgresDatabaseCapabilities
|
|
121
74
|
|
|
122
75
|
readonly #sql: SQL
|
|
76
|
+
readonly #runner: SqlBatchRunner
|
|
123
77
|
readonly #migrations: readonly PostgresMigration[]
|
|
124
78
|
#schema: SchemaCache | undefined
|
|
125
79
|
#roundTrips = 0
|
|
@@ -130,6 +84,7 @@ export class PostgresDatabase
|
|
|
130
84
|
this.#migrations = options.migrations ?? []
|
|
131
85
|
this.capabilities = postgresDatabaseCapabilities
|
|
132
86
|
this.#sql = new SQL(options.url ?? defaultUrl())
|
|
87
|
+
this.#runner = bunSqlRunner(this.#sql)
|
|
133
88
|
}
|
|
134
89
|
|
|
135
90
|
/** Number of statements that reached Postgres. Used by conformance short-circuit checks. */
|
|
@@ -155,7 +110,7 @@ export class PostgresDatabase
|
|
|
155
110
|
|
|
156
111
|
/** Compiles and executes one query IR operation. */
|
|
157
112
|
async execute<TRow = Record<string, unknown>>(query: QueryIR): Promise<QueryResult<TRow>> {
|
|
158
|
-
return this.#execute(this.#
|
|
113
|
+
return this.#execute(this.#runner, query) as Promise<QueryResult<TRow>>
|
|
159
114
|
}
|
|
160
115
|
|
|
161
116
|
/** Invokes a named Postgres routine. Missing routines map to `Invalid`. */
|
|
@@ -163,37 +118,27 @@ export class PostgresDatabase
|
|
|
163
118
|
routine: string,
|
|
164
119
|
args: Readonly<Record<string, unknown>>,
|
|
165
120
|
): Promise<TResult> {
|
|
166
|
-
|
|
167
|
-
this.#roundTrips += 1
|
|
168
|
-
const rows = asRows(
|
|
169
|
-
await this.#sql.unsafe(`SELECT ${quoteRoutine(routine)}($1::jsonb) AS result`, [
|
|
170
|
-
JSON.stringify(args),
|
|
171
|
-
]),
|
|
172
|
-
)
|
|
173
|
-
return decodeJsonResult(rows[0]?.result) as TResult
|
|
174
|
-
} catch (error) {
|
|
175
|
-
mapPostgresError(error, 'rpc')
|
|
176
|
-
}
|
|
121
|
+
return executeRpc(this.#runner, routine, args, this.#countRoundTrip) as Promise<TResult>
|
|
177
122
|
}
|
|
178
123
|
|
|
179
124
|
/** Produces the pending migration plan. */
|
|
180
125
|
async plan(): Promise<MigrationPlan> {
|
|
181
|
-
return planMigrations(this.#
|
|
126
|
+
return planMigrations(this.#runner, this.#migrations)
|
|
182
127
|
}
|
|
183
128
|
|
|
184
129
|
/** Applies pending migrations. */
|
|
185
130
|
async apply(): Promise<readonly MigrationStatus[]> {
|
|
186
|
-
return applyMigrations(this.#
|
|
131
|
+
return applyMigrations(this.#runner, this.#migrations)
|
|
187
132
|
}
|
|
188
133
|
|
|
189
134
|
/** Rolls back applied migration batches. */
|
|
190
135
|
async rollback(steps?: number): Promise<readonly MigrationStatus[]> {
|
|
191
|
-
return rollbackMigrations(this.#
|
|
136
|
+
return rollbackMigrations(this.#runner, this.#migrations, steps)
|
|
192
137
|
}
|
|
193
138
|
|
|
194
139
|
/** Returns all migration states. */
|
|
195
140
|
async status(): Promise<readonly MigrationStatus[]> {
|
|
196
|
-
return statusMigrations(this.#
|
|
141
|
+
return statusMigrations(this.#runner, this.#migrations)
|
|
197
142
|
}
|
|
198
143
|
|
|
199
144
|
/** Runs a callback inside a Postgres transaction. */
|
|
@@ -202,25 +147,12 @@ export class PostgresDatabase
|
|
|
202
147
|
): Promise<TResult> {
|
|
203
148
|
try {
|
|
204
149
|
return await this.#sql.begin(async (tx) => {
|
|
150
|
+
const runner = bunSqlRunner(tx as SQL)
|
|
205
151
|
const transaction: DatabaseTransaction = {
|
|
206
152
|
execute: <TRow = Record<string, unknown>>(query: QueryIR) =>
|
|
207
|
-
this.#execute(
|
|
208
|
-
rpc:
|
|
209
|
-
routine
|
|
210
|
-
args: Readonly<Record<string, unknown>>,
|
|
211
|
-
) => {
|
|
212
|
-
try {
|
|
213
|
-
this.#roundTrips += 1
|
|
214
|
-
const rows = asRows(
|
|
215
|
-
await (tx as SqlClient).unsafe(`SELECT ${quoteRoutine(routine)}($1::jsonb) AS result`, [
|
|
216
|
-
JSON.stringify(args),
|
|
217
|
-
]),
|
|
218
|
-
)
|
|
219
|
-
return decodeJsonResult(rows[0]?.result) as TResultRpc
|
|
220
|
-
} catch (error) {
|
|
221
|
-
mapPostgresError(error, 'rpc')
|
|
222
|
-
}
|
|
223
|
-
},
|
|
153
|
+
this.#execute(runner, query) as Promise<QueryResult<TRow>>,
|
|
154
|
+
rpc: <TResultRpc = unknown>(routine: string, args: Readonly<Record<string, unknown>>) =>
|
|
155
|
+
executeRpc(runner, routine, args, this.#countRoundTrip) as Promise<TResultRpc>,
|
|
224
156
|
}
|
|
225
157
|
return callback(transaction)
|
|
226
158
|
})
|
|
@@ -238,145 +170,23 @@ export class PostgresDatabase
|
|
|
238
170
|
}
|
|
239
171
|
}
|
|
240
172
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
return this.#schema
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
async #execute(sql: SqlClient, ir: QueryIR): Promise<QueryResult> {
|
|
247
|
-
validateQueryIR(ir, this.capabilities.maxRelationDepth)
|
|
248
|
-
assertQueryAgainstSchema(await this.#schemaCache(), ir)
|
|
249
|
-
const predicate = combinedPredicate(ir)
|
|
250
|
-
|
|
251
|
-
if (predicate.kind === 'const' && !predicate.value) {
|
|
252
|
-
return ir.mode === 'count' ? { rows: [], affected: 0, count: 0 } : { rows: [], affected: 0 }
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
if ((ir.mode === 'insert' || ir.mode === 'upsert') && predicate.kind !== 'const') {
|
|
256
|
-
throw new Invalid(`${ir.mode} with a non-constant where or ward has no documented row scope.`, {
|
|
257
|
-
metadata: { fields: { where: [`${ir.mode} predicates are not documented`] } },
|
|
258
|
-
})
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
if (ir.mode === 'select') {
|
|
262
|
-
const primitive: QueryIR = {
|
|
263
|
-
...ir,
|
|
264
|
-
select: includeColumns(
|
|
265
|
-
ir.select,
|
|
266
|
-
ir.relations.map((relation) => relation.localKey),
|
|
267
|
-
),
|
|
268
|
-
relations: [],
|
|
269
|
-
}
|
|
270
|
-
const rows = await this.#executePrimitive(sql, primitive, predicate)
|
|
271
|
-
await this.#loadRelations(sql, rows, ir.relations)
|
|
272
|
-
return {
|
|
273
|
-
rows: rows.map((row) => projectRow(row, ir.select, ir.relations)),
|
|
274
|
-
affected: 0,
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
if (ir.mode === 'count') {
|
|
279
|
-
const rows = await this.#executePrimitive(sql, ir, predicate)
|
|
280
|
-
const count = Number(rows[0]?.count ?? 0)
|
|
281
|
-
return { rows: [], affected: 0, count }
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
const rows = await this.#executePrimitive(sql, ir, predicate)
|
|
285
|
-
const affected = resultCount(rows)
|
|
286
|
-
return {
|
|
287
|
-
rows: ir.returning === undefined ? [] : rows,
|
|
288
|
-
affected,
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
async #executePrimitive(sql: SqlClient, ir: QueryIR, predicate: Predicate): Promise<Row[]> {
|
|
293
|
-
const compiled = compilePostgres(ir, predicate)
|
|
294
|
-
try {
|
|
295
|
-
this.#roundTrips += 1
|
|
296
|
-
const result = await sql.unsafe(compiled.text, compiled.parameters)
|
|
297
|
-
const rows = asRows(result)
|
|
298
|
-
// Preserve Bun's statement count for writes without RETURNING (empty row arrays).
|
|
299
|
-
Object.defineProperty(rows, 'count', {
|
|
300
|
-
value: resultCount(result),
|
|
301
|
-
enumerable: false,
|
|
302
|
-
})
|
|
303
|
-
return rows
|
|
304
|
-
} catch (error) {
|
|
305
|
-
mapPostgresError(error, 'execute')
|
|
306
|
-
}
|
|
173
|
+
readonly #countRoundTrip = (): void => {
|
|
174
|
+
this.#roundTrips += 1
|
|
307
175
|
}
|
|
308
176
|
|
|
309
|
-
async #
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
relations: readonly RelationLoad[],
|
|
313
|
-
): Promise<void> {
|
|
314
|
-
for (const relation of relations) {
|
|
315
|
-
const keys = unique(
|
|
316
|
-
parents
|
|
317
|
-
.map((parent) => parent[relation.localKey])
|
|
318
|
-
.filter((value) => value !== null && value !== undefined),
|
|
319
|
-
)
|
|
320
|
-
|
|
321
|
-
if (keys.length === 0) {
|
|
322
|
-
for (const parent of parents) {
|
|
323
|
-
parent[relation.relation] = relation.kind === 'hasMany' ? [] : null
|
|
324
|
-
}
|
|
325
|
-
continue
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
const childSelect = includeColumns(relation.select, [
|
|
329
|
-
relation.foreignKey,
|
|
330
|
-
...relation.relations.map((nested) => nested.localKey),
|
|
331
|
-
])
|
|
332
|
-
const childWhere: Predicate[] = [
|
|
333
|
-
...(relation.where === null ? [] : [relation.where]),
|
|
334
|
-
{ kind: 'in', column: relation.foreignKey, values: keys, negated: false },
|
|
335
|
-
]
|
|
336
|
-
const childIR: QueryIR = {
|
|
337
|
-
table: relation.table,
|
|
338
|
-
mode: 'select',
|
|
339
|
-
select: childSelect,
|
|
340
|
-
where: childWhere,
|
|
341
|
-
relations: [],
|
|
342
|
-
order: relation.order,
|
|
343
|
-
}
|
|
344
|
-
const predicate = combinedPredicate(childIR)
|
|
345
|
-
const children =
|
|
346
|
-
predicate.kind === 'const' && !predicate.value
|
|
347
|
-
? []
|
|
348
|
-
: await this.#executePrimitive(sql, childIR, predicate)
|
|
349
|
-
await this.#loadRelations(sql, children, relation.relations)
|
|
350
|
-
|
|
351
|
-
for (const parent of parents) {
|
|
352
|
-
const matches = children.filter((child) =>
|
|
353
|
-
sqlEqual(child[relation.foreignKey], parent[relation.localKey]),
|
|
354
|
-
)
|
|
355
|
-
const limited = relation.limit === undefined ? matches : matches.slice(0, relation.limit)
|
|
356
|
-
const projected = limited.map((child) =>
|
|
357
|
-
projectRow(child, relation.select, relation.relations),
|
|
358
|
-
)
|
|
359
|
-
parent[relation.relation] = relation.kind === 'hasMany' ? projected : (projected[0] ?? null)
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
function quoteRoutine(routine: string): string {
|
|
366
|
-
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(routine)) {
|
|
367
|
-
throw new Invalid('Routine name must be a simple identifier.', {
|
|
368
|
-
metadata: { fields: { routine: [routine] } },
|
|
369
|
-
})
|
|
177
|
+
async #schemaCache(): Promise<SchemaCache> {
|
|
178
|
+
if (this.#schema === undefined) this.#schema = await loadSchemaCache(this.#runner)
|
|
179
|
+
return this.#schema
|
|
370
180
|
}
|
|
371
|
-
return `"${routine}"`
|
|
372
|
-
}
|
|
373
181
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
182
|
+
async #execute(runner: SqlBatchRunner, ir: QueryIR): Promise<QueryResult> {
|
|
183
|
+
return executeQueryIR(
|
|
184
|
+
runner,
|
|
185
|
+
await this.#schemaCache(),
|
|
186
|
+
ir,
|
|
187
|
+
this.capabilities.maxRelationDepth,
|
|
188
|
+
this.#countRoundTrip,
|
|
189
|
+
)
|
|
380
190
|
}
|
|
381
191
|
}
|
|
382
192
|
|
package/src/errors.ts
CHANGED
|
@@ -23,6 +23,8 @@ function asPostgresError(error: unknown): PostgresErrorLike | undefined {
|
|
|
23
23
|
function sqlState(error: PostgresErrorLike): string | undefined {
|
|
24
24
|
if (typeof error.errno === 'string') return error.errno
|
|
25
25
|
if (typeof error.errno === 'number') return String(error.errno)
|
|
26
|
+
// NeonDbError and node-postgres carry SQLSTATE on `code`; Bun carries it on `errno`.
|
|
27
|
+
if (typeof error.code === 'string') return error.code
|
|
26
28
|
return undefined
|
|
27
29
|
}
|
|
28
30
|
|
|
@@ -34,7 +36,8 @@ function sqlState(error: PostgresErrorLike): string | undefined {
|
|
|
34
36
|
export function mapPostgresError(error: unknown, operation: string): never {
|
|
35
37
|
const pg = asPostgresError(error)
|
|
36
38
|
const state = pg === undefined ? undefined : sqlState(pg)
|
|
37
|
-
const message =
|
|
39
|
+
const message =
|
|
40
|
+
pg?.message ?? (error instanceof Error ? error.message : 'Postgres driver failure')
|
|
38
41
|
|
|
39
42
|
if (state === '23505') {
|
|
40
43
|
throw new Conflict(message, {
|
|
@@ -96,5 +99,9 @@ export const POSTGRES_ERROR_MAP = [
|
|
|
96
99
|
{ sqlstate: '57P01', framework: 'Unavailable', meaning: 'admin_shutdown' },
|
|
97
100
|
{ sqlstate: '57P03', framework: 'Unavailable', meaning: 'cannot_connect_now' },
|
|
98
101
|
{ sqlstate: '08006', framework: 'Unavailable', meaning: 'connection_failure' },
|
|
99
|
-
{
|
|
102
|
+
{
|
|
103
|
+
sqlstate: '08001',
|
|
104
|
+
framework: 'Unavailable',
|
|
105
|
+
meaning: 'sqlclient_unable_to_establish_sqlconnection',
|
|
106
|
+
},
|
|
100
107
|
] as const
|
package/src/execute.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Invalid,
|
|
3
|
+
type Predicate,
|
|
4
|
+
type QueryIR,
|
|
5
|
+
type QueryResult,
|
|
6
|
+
type RelationLoad,
|
|
7
|
+
} from '@avelonjs/core'
|
|
8
|
+
import { compilePostgres } from './compile'
|
|
9
|
+
import { mapPostgresError } from './errors'
|
|
10
|
+
import { combinedPredicate } from './normalize'
|
|
11
|
+
import { assertQueryAgainstSchema, type SchemaCache } from './schema-core'
|
|
12
|
+
import type { SqlRows, SqlRunner } from './sql-client'
|
|
13
|
+
import { validateQueryIR } from './validate'
|
|
14
|
+
|
|
15
|
+
type Row = Record<string, unknown>
|
|
16
|
+
|
|
17
|
+
/** Called once per statement so drivers can keep the conformance round-trip counter. */
|
|
18
|
+
export type RoundTripCounter = () => void
|
|
19
|
+
|
|
20
|
+
function includeColumns(select: string[] | '*', columns: string[]): string[] | '*' {
|
|
21
|
+
if (select === '*') return '*'
|
|
22
|
+
return [...new Set([...select, ...columns])]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function projectRow(row: Row, select: string[] | '*', relations: readonly RelationLoad[]): Row {
|
|
26
|
+
const output: Row = {}
|
|
27
|
+
const columns =
|
|
28
|
+
select === '*'
|
|
29
|
+
? Object.keys(row).filter(
|
|
30
|
+
(column) => !relations.some((relation) => relation.relation === column),
|
|
31
|
+
)
|
|
32
|
+
: select
|
|
33
|
+
for (const column of columns) output[column] = row[column]
|
|
34
|
+
for (const relation of relations) output[relation.relation] = row[relation.relation]
|
|
35
|
+
return output
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function unique(values: unknown[]): unknown[] {
|
|
39
|
+
const output: unknown[] = []
|
|
40
|
+
for (const value of values) {
|
|
41
|
+
if (!output.some((existing) => sqlEqual(existing, value))) output.push(value)
|
|
42
|
+
}
|
|
43
|
+
return output
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sqlEqual(left: unknown, right: unknown): boolean {
|
|
47
|
+
if (left === null || left === undefined || right === null || right === undefined) return false
|
|
48
|
+
return left === right
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function quoteRoutine(routine: string): string {
|
|
52
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(routine)) {
|
|
53
|
+
throw new Invalid('Routine name must be a simple identifier.', {
|
|
54
|
+
metadata: { fields: { routine: [routine] } },
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
return `"${routine}"`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function decodeJsonResult(value: unknown): unknown {
|
|
61
|
+
if (typeof value !== 'string') return value
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(value)
|
|
64
|
+
} catch {
|
|
65
|
+
return value
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function executePrimitive(
|
|
70
|
+
runner: SqlRunner,
|
|
71
|
+
ir: QueryIR,
|
|
72
|
+
predicate: Predicate,
|
|
73
|
+
onRoundTrip: RoundTripCounter,
|
|
74
|
+
): Promise<SqlRows> {
|
|
75
|
+
const compiled = compilePostgres(ir, predicate)
|
|
76
|
+
try {
|
|
77
|
+
onRoundTrip()
|
|
78
|
+
return await runner.unsafe(compiled.text, compiled.parameters)
|
|
79
|
+
} catch (error) {
|
|
80
|
+
mapPostgresError(error, 'execute')
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function loadRelations(
|
|
85
|
+
runner: SqlRunner,
|
|
86
|
+
parents: Row[],
|
|
87
|
+
relations: readonly RelationLoad[],
|
|
88
|
+
onRoundTrip: RoundTripCounter,
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
for (const relation of relations) {
|
|
91
|
+
const keys = unique(
|
|
92
|
+
parents
|
|
93
|
+
.map((parent) => parent[relation.localKey])
|
|
94
|
+
.filter((value) => value !== null && value !== undefined),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if (keys.length === 0) {
|
|
98
|
+
for (const parent of parents) {
|
|
99
|
+
parent[relation.relation] = relation.kind === 'hasMany' ? [] : null
|
|
100
|
+
}
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const childSelect = includeColumns(relation.select, [
|
|
105
|
+
relation.foreignKey,
|
|
106
|
+
...relation.relations.map((nested) => nested.localKey),
|
|
107
|
+
])
|
|
108
|
+
const childWhere: Predicate[] = [
|
|
109
|
+
...(relation.where === null ? [] : [relation.where]),
|
|
110
|
+
{ kind: 'in', column: relation.foreignKey, values: keys, negated: false },
|
|
111
|
+
]
|
|
112
|
+
const childIR: QueryIR = {
|
|
113
|
+
table: relation.table,
|
|
114
|
+
mode: 'select',
|
|
115
|
+
select: childSelect,
|
|
116
|
+
where: childWhere,
|
|
117
|
+
relations: [],
|
|
118
|
+
order: relation.order,
|
|
119
|
+
}
|
|
120
|
+
const predicate = combinedPredicate(childIR)
|
|
121
|
+
const children =
|
|
122
|
+
predicate.kind === 'const' && !predicate.value
|
|
123
|
+
? []
|
|
124
|
+
: (await executePrimitive(runner, childIR, predicate, onRoundTrip)).rows
|
|
125
|
+
await loadRelations(runner, children, relation.relations, onRoundTrip)
|
|
126
|
+
|
|
127
|
+
for (const parent of parents) {
|
|
128
|
+
const matches = children.filter((child) =>
|
|
129
|
+
sqlEqual(child[relation.foreignKey], parent[relation.localKey]),
|
|
130
|
+
)
|
|
131
|
+
const limited = relation.limit === undefined ? matches : matches.slice(0, relation.limit)
|
|
132
|
+
const projected = limited.map((child) =>
|
|
133
|
+
projectRow(child, relation.select, relation.relations),
|
|
134
|
+
)
|
|
135
|
+
parent[relation.relation] = relation.kind === 'hasMany' ? projected : (projected[0] ?? null)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Validates, compiles, and executes one query IR operation against any SQL runner.
|
|
142
|
+
*
|
|
143
|
+
* Relations load application-side: each level reads its parent rows before the child statement is
|
|
144
|
+
* built, so this needs an interactive runner and cannot be flattened into one batch.
|
|
145
|
+
*/
|
|
146
|
+
export async function executeQueryIR(
|
|
147
|
+
runner: SqlRunner,
|
|
148
|
+
schema: SchemaCache,
|
|
149
|
+
ir: QueryIR,
|
|
150
|
+
maxRelationDepth: number,
|
|
151
|
+
onRoundTrip: RoundTripCounter,
|
|
152
|
+
): Promise<QueryResult> {
|
|
153
|
+
validateQueryIR(ir, maxRelationDepth)
|
|
154
|
+
assertQueryAgainstSchema(schema, ir)
|
|
155
|
+
const predicate = combinedPredicate(ir)
|
|
156
|
+
|
|
157
|
+
// An insert or upsert has no rows to scope: a ward that refuses its values is an error from
|
|
158
|
+
// `compilePostgres`, not an empty result, so it must not short-circuit here.
|
|
159
|
+
const rowScoped = ir.mode !== 'insert' && ir.mode !== 'upsert'
|
|
160
|
+
if (rowScoped && predicate.kind === 'const' && !predicate.value) {
|
|
161
|
+
return ir.mode === 'count' ? { rows: [], affected: 0, count: 0 } : { rows: [], affected: 0 }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (ir.mode === 'select') {
|
|
165
|
+
const primitive: QueryIR = {
|
|
166
|
+
...ir,
|
|
167
|
+
select: includeColumns(
|
|
168
|
+
ir.select,
|
|
169
|
+
ir.relations.map((relation) => relation.localKey),
|
|
170
|
+
),
|
|
171
|
+
relations: [],
|
|
172
|
+
}
|
|
173
|
+
const result = await executePrimitive(runner, primitive, predicate, onRoundTrip)
|
|
174
|
+
await loadRelations(runner, result.rows, ir.relations, onRoundTrip)
|
|
175
|
+
return {
|
|
176
|
+
rows: result.rows.map((row) => projectRow(row, ir.select, ir.relations)),
|
|
177
|
+
affected: 0,
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (ir.mode === 'count') {
|
|
182
|
+
const result = await executePrimitive(runner, ir, predicate, onRoundTrip)
|
|
183
|
+
return { rows: [], affected: 0, count: Number(result.rows[0]?.count ?? 0) }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const result = await executePrimitive(runner, ir, predicate, onRoundTrip)
|
|
187
|
+
return {
|
|
188
|
+
rows: ir.returning === undefined ? [] : result.rows,
|
|
189
|
+
affected: result.count,
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Invokes a named Postgres routine with a single jsonb argument. Missing routines map to `Invalid`. */
|
|
194
|
+
export async function executeRpc(
|
|
195
|
+
runner: SqlRunner,
|
|
196
|
+
routine: string,
|
|
197
|
+
args: Readonly<Record<string, unknown>>,
|
|
198
|
+
onRoundTrip: RoundTripCounter,
|
|
199
|
+
): Promise<unknown> {
|
|
200
|
+
try {
|
|
201
|
+
onRoundTrip()
|
|
202
|
+
const result = await runner.unsafe(`SELECT ${quoteRoutine(routine)}($1::jsonb) AS result`, [
|
|
203
|
+
JSON.stringify(args),
|
|
204
|
+
])
|
|
205
|
+
return decodeJsonResult(result.rows[0]?.result)
|
|
206
|
+
} catch (error) {
|
|
207
|
+
mapPostgresError(error, 'rpc')
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Statements that drop and recreate the database conformance fixtures.
|
|
3
|
+
*
|
|
4
|
+
* Authored as an array because Neon's HTTP endpoint rejects multi-statement query text, and the
|
|
5
|
+
* `assay_echo` body is dollar-quoted, so splitting the blob on `;` at runtime would corrupt it.
|
|
6
|
+
*/
|
|
7
|
+
export const ASSAY_FIXTURE_STATEMENTS: readonly string[] = [
|
|
8
|
+
'DROP TABLE IF EXISTS assay_reactions CASCADE',
|
|
9
|
+
'DROP TABLE IF EXISTS assay_comments CASCADE',
|
|
10
|
+
'DROP TABLE IF EXISTS assay_posts CASCADE',
|
|
11
|
+
'DROP TABLE IF EXISTS assay_profiles CASCADE',
|
|
12
|
+
'DROP TABLE IF EXISTS assay_users CASCADE',
|
|
13
|
+
'DROP FUNCTION IF EXISTS assay_echo(jsonb)',
|
|
14
|
+
`CREATE TABLE assay_users (
|
|
15
|
+
id text PRIMARY KEY,
|
|
16
|
+
email text NOT NULL UNIQUE,
|
|
17
|
+
name text NOT NULL,
|
|
18
|
+
age integer NOT NULL,
|
|
19
|
+
nickname text
|
|
20
|
+
)`,
|
|
21
|
+
`CREATE TABLE assay_profiles (
|
|
22
|
+
id text PRIMARY KEY,
|
|
23
|
+
user_id text NOT NULL UNIQUE REFERENCES assay_users(id) ON DELETE CASCADE,
|
|
24
|
+
bio text NOT NULL
|
|
25
|
+
)`,
|
|
26
|
+
`CREATE TABLE assay_posts (
|
|
27
|
+
id text PRIMARY KEY,
|
|
28
|
+
user_id text NOT NULL REFERENCES assay_users(id) ON DELETE CASCADE,
|
|
29
|
+
title text NOT NULL,
|
|
30
|
+
score integer NOT NULL,
|
|
31
|
+
published_at timestamptz
|
|
32
|
+
)`,
|
|
33
|
+
`CREATE TABLE assay_comments (
|
|
34
|
+
id text PRIMARY KEY,
|
|
35
|
+
post_id text NOT NULL REFERENCES assay_posts(id) ON DELETE CASCADE,
|
|
36
|
+
body text NOT NULL,
|
|
37
|
+
position integer NOT NULL
|
|
38
|
+
)`,
|
|
39
|
+
`CREATE TABLE assay_reactions (
|
|
40
|
+
id text PRIMARY KEY,
|
|
41
|
+
comment_id text NOT NULL REFERENCES assay_comments(id) ON DELETE CASCADE,
|
|
42
|
+
kind text NOT NULL
|
|
43
|
+
)`,
|
|
44
|
+
`CREATE OR REPLACE FUNCTION assay_echo(args jsonb)
|
|
45
|
+
RETURNS jsonb
|
|
46
|
+
LANGUAGE sql
|
|
47
|
+
IMMUTABLE
|
|
48
|
+
AS $$
|
|
49
|
+
SELECT args;
|
|
50
|
+
$$`,
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
/** SQL that drops and recreates the database conformance fixtures. */
|
|
54
|
+
export const ASSAY_FIXTURE_SQL = `
|
|
55
|
+
DROP TABLE IF EXISTS assay_reactions CASCADE;
|
|
56
|
+
DROP TABLE IF EXISTS assay_comments CASCADE;
|
|
57
|
+
DROP TABLE IF EXISTS assay_posts CASCADE;
|
|
58
|
+
DROP TABLE IF EXISTS assay_profiles CASCADE;
|
|
59
|
+
DROP TABLE IF EXISTS assay_users CASCADE;
|
|
60
|
+
DROP FUNCTION IF EXISTS assay_echo(jsonb);
|
|
61
|
+
|
|
62
|
+
CREATE TABLE assay_users (
|
|
63
|
+
id text PRIMARY KEY,
|
|
64
|
+
email text NOT NULL UNIQUE,
|
|
65
|
+
name text NOT NULL,
|
|
66
|
+
age integer NOT NULL,
|
|
67
|
+
nickname text
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
CREATE TABLE assay_profiles (
|
|
71
|
+
id text PRIMARY KEY,
|
|
72
|
+
user_id text NOT NULL UNIQUE REFERENCES assay_users(id) ON DELETE CASCADE,
|
|
73
|
+
bio text NOT NULL
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
CREATE TABLE assay_posts (
|
|
77
|
+
id text PRIMARY KEY,
|
|
78
|
+
user_id text NOT NULL REFERENCES assay_users(id) ON DELETE CASCADE,
|
|
79
|
+
title text NOT NULL,
|
|
80
|
+
score integer NOT NULL,
|
|
81
|
+
published_at timestamptz
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
CREATE TABLE assay_comments (
|
|
85
|
+
id text PRIMARY KEY,
|
|
86
|
+
post_id text NOT NULL REFERENCES assay_posts(id) ON DELETE CASCADE,
|
|
87
|
+
body text NOT NULL,
|
|
88
|
+
position integer NOT NULL
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
CREATE TABLE assay_reactions (
|
|
92
|
+
id text PRIMARY KEY,
|
|
93
|
+
comment_id text NOT NULL REFERENCES assay_comments(id) ON DELETE CASCADE,
|
|
94
|
+
kind text NOT NULL
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
CREATE OR REPLACE FUNCTION assay_echo(args jsonb)
|
|
98
|
+
RETURNS jsonb
|
|
99
|
+
LANGUAGE sql
|
|
100
|
+
IMMUTABLE
|
|
101
|
+
AS $$
|
|
102
|
+
SELECT args;
|
|
103
|
+
$$;
|
|
104
|
+
`
|