@avelonjs/postgres 0.1.0 → 0.3.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/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/fixtures.ts
CHANGED
|
@@ -1,57 +1,7 @@
|
|
|
1
1
|
import type { SQL } from 'bun'
|
|
2
|
+
import { ASSAY_FIXTURE_SQL } from './fixtures-sql'
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
export const ASSAY_FIXTURE_SQL = `
|
|
5
|
-
DROP TABLE IF EXISTS assay_reactions CASCADE;
|
|
6
|
-
DROP TABLE IF EXISTS assay_comments CASCADE;
|
|
7
|
-
DROP TABLE IF EXISTS assay_posts CASCADE;
|
|
8
|
-
DROP TABLE IF EXISTS assay_profiles CASCADE;
|
|
9
|
-
DROP TABLE IF EXISTS assay_users CASCADE;
|
|
10
|
-
DROP FUNCTION IF EXISTS assay_echo(jsonb);
|
|
11
|
-
|
|
12
|
-
CREATE TABLE assay_users (
|
|
13
|
-
id text PRIMARY KEY,
|
|
14
|
-
email text NOT NULL UNIQUE,
|
|
15
|
-
name text NOT NULL,
|
|
16
|
-
age integer NOT NULL,
|
|
17
|
-
nickname text
|
|
18
|
-
);
|
|
19
|
-
|
|
20
|
-
CREATE TABLE assay_profiles (
|
|
21
|
-
id text PRIMARY KEY,
|
|
22
|
-
user_id text NOT NULL UNIQUE REFERENCES assay_users(id) ON DELETE CASCADE,
|
|
23
|
-
bio text NOT NULL
|
|
24
|
-
);
|
|
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
|
-
|
|
34
|
-
CREATE TABLE assay_comments (
|
|
35
|
-
id text PRIMARY KEY,
|
|
36
|
-
post_id text NOT NULL REFERENCES assay_posts(id) ON DELETE CASCADE,
|
|
37
|
-
body text NOT NULL,
|
|
38
|
-
position integer NOT NULL
|
|
39
|
-
);
|
|
40
|
-
|
|
41
|
-
CREATE TABLE assay_reactions (
|
|
42
|
-
id text PRIMARY KEY,
|
|
43
|
-
comment_id text NOT NULL REFERENCES assay_comments(id) ON DELETE CASCADE,
|
|
44
|
-
kind text NOT NULL
|
|
45
|
-
);
|
|
46
|
-
|
|
47
|
-
CREATE OR REPLACE FUNCTION assay_echo(args jsonb)
|
|
48
|
-
RETURNS jsonb
|
|
49
|
-
LANGUAGE sql
|
|
50
|
-
IMMUTABLE
|
|
51
|
-
AS $$
|
|
52
|
-
SELECT args;
|
|
53
|
-
$$;
|
|
54
|
-
`
|
|
4
|
+
export { ASSAY_FIXTURE_SQL, ASSAY_FIXTURE_STATEMENTS } from './fixtures-sql'
|
|
55
5
|
|
|
56
6
|
/** Provisions empty assay fixture tables and the `assay_echo` routine. */
|
|
57
7
|
export async function resetAssayFixtures(sql: SQL): Promise<void> {
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { bunSqlRunner } from './bun-client'
|
|
1
2
|
export { compilePostgres, compileSqlPredicate, type CompiledSql } from './compile'
|
|
2
3
|
export {
|
|
3
4
|
createPostgresDatabase,
|
|
@@ -6,7 +7,7 @@ export {
|
|
|
6
7
|
type PostgresDatabaseOptions,
|
|
7
8
|
} from './driver'
|
|
8
9
|
export { mapPostgresError, POSTGRES_ERROR_MAP } from './errors'
|
|
9
|
-
export { ASSAY_FIXTURE_SQL, resetAssayFixtures } from './fixtures'
|
|
10
|
+
export { ASSAY_FIXTURE_SQL, ASSAY_FIXTURE_STATEMENTS, resetAssayFixtures } from './fixtures'
|
|
10
11
|
export {
|
|
11
12
|
applyMigrations,
|
|
12
13
|
planMigrations,
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { MigrationPlan, MigrationStatus } from '@avelonjs/core'
|
|
2
|
+
import type { CompiledSql } from './compile'
|
|
3
|
+
import { mapPostgresError } from './errors'
|
|
4
|
+
import type { SqlBatchRunner, SqlRunner } from './sql-client'
|
|
5
|
+
|
|
6
|
+
/** One driver-owned SQL migration pair. */
|
|
7
|
+
export interface PostgresMigration {
|
|
8
|
+
/** Stable migration identifier. */
|
|
9
|
+
id: string
|
|
10
|
+
/** Statements applied in order. */
|
|
11
|
+
up: readonly string[]
|
|
12
|
+
/** Statements used to roll the migration back. */
|
|
13
|
+
down: readonly string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const HISTORY_TABLE = 'avelon_migrations'
|
|
17
|
+
|
|
18
|
+
function statement(text: string, parameters: unknown[] = []): CompiledSql {
|
|
19
|
+
return { text, parameters }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function asDate(value: unknown): Date | undefined {
|
|
23
|
+
if (value instanceof Date) return value
|
|
24
|
+
if (typeof value === 'string' || typeof value === 'number') {
|
|
25
|
+
const parsed = new Date(value)
|
|
26
|
+
return Number.isNaN(parsed.getTime()) ? undefined : parsed
|
|
27
|
+
}
|
|
28
|
+
return undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function ensureHistory(runner: SqlRunner): Promise<void> {
|
|
32
|
+
try {
|
|
33
|
+
await runner.unsafe(`
|
|
34
|
+
CREATE TABLE IF NOT EXISTS ${HISTORY_TABLE} (
|
|
35
|
+
id text PRIMARY KEY,
|
|
36
|
+
applied_at timestamptz NOT NULL DEFAULT now()
|
|
37
|
+
)
|
|
38
|
+
`)
|
|
39
|
+
} catch (error) {
|
|
40
|
+
mapPostgresError(error, 'migrations.ensureHistory')
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function appliedIds(runner: SqlRunner): Promise<Set<string>> {
|
|
45
|
+
await ensureHistory(runner)
|
|
46
|
+
try {
|
|
47
|
+
const result = await runner.unsafe(`SELECT id FROM ${HISTORY_TABLE}`)
|
|
48
|
+
return new Set(result.rows.map((row) => String(row.id)))
|
|
49
|
+
} catch (error) {
|
|
50
|
+
mapPostgresError(error, 'migrations.status')
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Builds a pending migration plan from registered migrations and history. */
|
|
55
|
+
export async function planMigrations(
|
|
56
|
+
runner: SqlRunner,
|
|
57
|
+
migrations: readonly PostgresMigration[],
|
|
58
|
+
): Promise<MigrationPlan> {
|
|
59
|
+
const applied = await appliedIds(runner)
|
|
60
|
+
const pending = migrations.filter((migration) => !applied.has(migration.id))
|
|
61
|
+
return {
|
|
62
|
+
id: `postgres-${pending.map((migration) => migration.id).join('+') || 'empty'}`,
|
|
63
|
+
migrations: pending.map((migration) => migration.id),
|
|
64
|
+
steps: pending.flatMap((migration) => migration.up),
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Applies every pending migration and returns the resulting statuses.
|
|
70
|
+
*
|
|
71
|
+
* Each migration's statements and its history row are known before the batch starts, so the whole
|
|
72
|
+
* migration reaches the runner as one atomic statement list.
|
|
73
|
+
*/
|
|
74
|
+
export async function applyMigrations(
|
|
75
|
+
runner: SqlBatchRunner,
|
|
76
|
+
migrations: readonly PostgresMigration[],
|
|
77
|
+
): Promise<readonly MigrationStatus[]> {
|
|
78
|
+
const plan = await planMigrations(runner, migrations)
|
|
79
|
+
for (const id of plan.migrations) {
|
|
80
|
+
const migration = migrations.find((entry) => entry.id === id)
|
|
81
|
+
if (migration === undefined) continue
|
|
82
|
+
try {
|
|
83
|
+
await runner.batch([
|
|
84
|
+
...migration.up.map((text) => statement(text)),
|
|
85
|
+
statement(`INSERT INTO ${HISTORY_TABLE} (id) VALUES ($1)`, [id]),
|
|
86
|
+
])
|
|
87
|
+
} catch (error) {
|
|
88
|
+
mapPostgresError(error, 'migrations.apply')
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return statusMigrations(runner, migrations)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Rolls back the newest applied migration batches. */
|
|
95
|
+
export async function rollbackMigrations(
|
|
96
|
+
runner: SqlBatchRunner,
|
|
97
|
+
migrations: readonly PostgresMigration[],
|
|
98
|
+
steps = 1,
|
|
99
|
+
): Promise<readonly MigrationStatus[]> {
|
|
100
|
+
await ensureHistory(runner)
|
|
101
|
+
const count = Number.isInteger(steps) && steps > 0 ? steps : 1
|
|
102
|
+
try {
|
|
103
|
+
const applied = await runner.unsafe(
|
|
104
|
+
`SELECT id, applied_at FROM ${HISTORY_TABLE} ORDER BY applied_at DESC LIMIT $1`,
|
|
105
|
+
[count],
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
for (const row of applied.rows) {
|
|
109
|
+
const id = String(row.id)
|
|
110
|
+
const migration = migrations.find((entry) => entry.id === id)
|
|
111
|
+
if (migration === undefined) continue
|
|
112
|
+
await runner.batch([
|
|
113
|
+
...migration.down.map((text) => statement(text)),
|
|
114
|
+
statement(`DELETE FROM ${HISTORY_TABLE} WHERE id = $1`, [id]),
|
|
115
|
+
])
|
|
116
|
+
}
|
|
117
|
+
} catch (error) {
|
|
118
|
+
mapPostgresError(error, 'migrations.rollback')
|
|
119
|
+
}
|
|
120
|
+
return statusMigrations(runner, migrations)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Returns applied/pending status for every registered migration. */
|
|
124
|
+
export async function statusMigrations(
|
|
125
|
+
runner: SqlRunner,
|
|
126
|
+
migrations: readonly PostgresMigration[],
|
|
127
|
+
): Promise<readonly MigrationStatus[]> {
|
|
128
|
+
await ensureHistory(runner)
|
|
129
|
+
try {
|
|
130
|
+
const result = await runner.unsafe(`SELECT id, applied_at FROM ${HISTORY_TABLE}`)
|
|
131
|
+
const byId = new Map(result.rows.map((row) => [String(row.id), asDate(row.applied_at)]))
|
|
132
|
+
return migrations.map((migration) => {
|
|
133
|
+
if (!byId.has(migration.id)) return { id: migration.id, applied: false }
|
|
134
|
+
const appliedAt = byId.get(migration.id)
|
|
135
|
+
return appliedAt === undefined
|
|
136
|
+
? { id: migration.id, applied: true }
|
|
137
|
+
: { id: migration.id, applied: true, appliedAt }
|
|
138
|
+
})
|
|
139
|
+
} catch (error) {
|
|
140
|
+
mapPostgresError(error, 'migrations.status')
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/migrations.ts
CHANGED
|
@@ -1,54 +1,22 @@
|
|
|
1
1
|
import type { SQL } from 'bun'
|
|
2
2
|
import type { MigrationPlan, MigrationStatus } from '@avelonjs/core'
|
|
3
|
-
import {
|
|
3
|
+
import { bunSqlRunner } from './bun-client'
|
|
4
|
+
import {
|
|
5
|
+
applyMigrations as applyMigrationsCore,
|
|
6
|
+
planMigrations as planMigrationsCore,
|
|
7
|
+
rollbackMigrations as rollbackMigrationsCore,
|
|
8
|
+
statusMigrations as statusMigrationsCore,
|
|
9
|
+
type PostgresMigration,
|
|
10
|
+
} from './migrations-core'
|
|
4
11
|
|
|
5
|
-
|
|
6
|
-
export interface PostgresMigration {
|
|
7
|
-
/** Stable migration identifier. */
|
|
8
|
-
id: string
|
|
9
|
-
/** Statements applied in order. */
|
|
10
|
-
up: readonly string[]
|
|
11
|
-
/** Statements used to roll the migration back. */
|
|
12
|
-
down: readonly string[]
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const HISTORY_TABLE = 'avelon_migrations'
|
|
16
|
-
|
|
17
|
-
async function ensureHistory(sql: SQL): Promise<void> {
|
|
18
|
-
try {
|
|
19
|
-
await sql.unsafe(`
|
|
20
|
-
CREATE TABLE IF NOT EXISTS ${HISTORY_TABLE} (
|
|
21
|
-
id text PRIMARY KEY,
|
|
22
|
-
applied_at timestamptz NOT NULL DEFAULT now()
|
|
23
|
-
)
|
|
24
|
-
`)
|
|
25
|
-
} catch (error) {
|
|
26
|
-
mapPostgresError(error, 'migrations.ensureHistory')
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
async function appliedIds(sql: SQL): Promise<Set<string>> {
|
|
31
|
-
await ensureHistory(sql)
|
|
32
|
-
try {
|
|
33
|
-
const rows = (await sql.unsafe(`SELECT id FROM ${HISTORY_TABLE}`)) as Array<{ id: string }>
|
|
34
|
-
return new Set(rows.map((row) => row.id))
|
|
35
|
-
} catch (error) {
|
|
36
|
-
mapPostgresError(error, 'migrations.status')
|
|
37
|
-
}
|
|
38
|
-
}
|
|
12
|
+
export type { PostgresMigration } from './migrations-core'
|
|
39
13
|
|
|
40
14
|
/** Builds a pending migration plan from registered migrations and history. */
|
|
41
15
|
export async function planMigrations(
|
|
42
16
|
sql: SQL,
|
|
43
17
|
migrations: readonly PostgresMigration[],
|
|
44
18
|
): Promise<MigrationPlan> {
|
|
45
|
-
|
|
46
|
-
const pending = migrations.filter((migration) => !applied.has(migration.id))
|
|
47
|
-
return {
|
|
48
|
-
id: `postgres-${pending.map((migration) => migration.id).join('+') || 'empty'}`,
|
|
49
|
-
migrations: pending.map((migration) => migration.id),
|
|
50
|
-
steps: pending.flatMap((migration) => migration.up),
|
|
51
|
-
}
|
|
19
|
+
return planMigrationsCore(bunSqlRunner(sql), migrations)
|
|
52
20
|
}
|
|
53
21
|
|
|
54
22
|
/** Applies every pending migration and returns the resulting statuses. */
|
|
@@ -56,20 +24,7 @@ export async function applyMigrations(
|
|
|
56
24
|
sql: SQL,
|
|
57
25
|
migrations: readonly PostgresMigration[],
|
|
58
26
|
): Promise<readonly MigrationStatus[]> {
|
|
59
|
-
|
|
60
|
-
for (const id of plan.migrations) {
|
|
61
|
-
const migration = migrations.find((entry) => entry.id === id)
|
|
62
|
-
if (migration === undefined) continue
|
|
63
|
-
try {
|
|
64
|
-
await sql.begin(async (tx) => {
|
|
65
|
-
for (const statement of migration.up) await tx.unsafe(statement)
|
|
66
|
-
await tx.unsafe(`INSERT INTO ${HISTORY_TABLE} (id) VALUES ($1)`, [id])
|
|
67
|
-
})
|
|
68
|
-
} catch (error) {
|
|
69
|
-
mapPostgresError(error, 'migrations.apply')
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
return statusMigrations(sql, migrations)
|
|
27
|
+
return applyMigrationsCore(bunSqlRunner(sql), migrations)
|
|
73
28
|
}
|
|
74
29
|
|
|
75
30
|
/** Rolls back the newest applied migration batches. */
|
|
@@ -78,26 +33,7 @@ export async function rollbackMigrations(
|
|
|
78
33
|
migrations: readonly PostgresMigration[],
|
|
79
34
|
steps = 1,
|
|
80
35
|
): Promise<readonly MigrationStatus[]> {
|
|
81
|
-
|
|
82
|
-
const count = Number.isInteger(steps) && steps > 0 ? steps : 1
|
|
83
|
-
try {
|
|
84
|
-
const applied = (await sql.unsafe(
|
|
85
|
-
`SELECT id, applied_at FROM ${HISTORY_TABLE} ORDER BY applied_at DESC LIMIT $1`,
|
|
86
|
-
[count],
|
|
87
|
-
)) as Array<{ id: string; applied_at: Date }>
|
|
88
|
-
|
|
89
|
-
for (const row of applied) {
|
|
90
|
-
const migration = migrations.find((entry) => entry.id === row.id)
|
|
91
|
-
if (migration === undefined) continue
|
|
92
|
-
await sql.begin(async (tx) => {
|
|
93
|
-
for (const statement of migration.down) await tx.unsafe(statement)
|
|
94
|
-
await tx.unsafe(`DELETE FROM ${HISTORY_TABLE} WHERE id = $1`, [row.id])
|
|
95
|
-
})
|
|
96
|
-
}
|
|
97
|
-
} catch (error) {
|
|
98
|
-
mapPostgresError(error, 'migrations.rollback')
|
|
99
|
-
}
|
|
100
|
-
return statusMigrations(sql, migrations)
|
|
36
|
+
return rollbackMigrationsCore(bunSqlRunner(sql), migrations, steps)
|
|
101
37
|
}
|
|
102
38
|
|
|
103
39
|
/** Returns applied/pending status for every registered migration. */
|
|
@@ -105,19 +41,5 @@ export async function statusMigrations(
|
|
|
105
41
|
sql: SQL,
|
|
106
42
|
migrations: readonly PostgresMigration[],
|
|
107
43
|
): Promise<readonly MigrationStatus[]> {
|
|
108
|
-
|
|
109
|
-
try {
|
|
110
|
-
const rows = (await sql.unsafe(
|
|
111
|
-
`SELECT id, applied_at FROM ${HISTORY_TABLE}`,
|
|
112
|
-
)) as Array<{ id: string; applied_at: Date }>
|
|
113
|
-
const byId = new Map(rows.map((row) => [row.id, row.applied_at]))
|
|
114
|
-
return migrations.map((migration) => {
|
|
115
|
-
const appliedAt = byId.get(migration.id)
|
|
116
|
-
return appliedAt === undefined
|
|
117
|
-
? { id: migration.id, applied: false }
|
|
118
|
-
: { id: migration.id, applied: true, appliedAt }
|
|
119
|
-
})
|
|
120
|
-
} catch (error) {
|
|
121
|
-
mapPostgresError(error, 'migrations.status')
|
|
122
|
-
}
|
|
44
|
+
return statusMigrationsCore(bunSqlRunner(sql), migrations)
|
|
123
45
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { Invalid, type QueryIR, type RelationLoad } from '@avelonjs/core'
|
|
2
|
+
import { mapPostgresError } from './errors'
|
|
3
|
+
import type { SqlRunner } from './sql-client'
|
|
4
|
+
|
|
5
|
+
/** Cached public-schema column sets keyed by table name. */
|
|
6
|
+
export type SchemaCache = Map<string, Set<string>>
|
|
7
|
+
|
|
8
|
+
/** Loads every public base table and its columns through any SQL runner. */
|
|
9
|
+
export async function loadSchemaCache(runner: SqlRunner): Promise<SchemaCache> {
|
|
10
|
+
try {
|
|
11
|
+
const result = await runner.unsafe(`
|
|
12
|
+
SELECT table_name, column_name
|
|
13
|
+
FROM information_schema.columns
|
|
14
|
+
WHERE table_schema = 'public'
|
|
15
|
+
ORDER BY table_name, ordinal_position
|
|
16
|
+
`)
|
|
17
|
+
|
|
18
|
+
const cache: SchemaCache = new Map()
|
|
19
|
+
for (const row of result.rows) {
|
|
20
|
+
const table = String(row.table_name)
|
|
21
|
+
const columns = cache.get(table) ?? new Set<string>()
|
|
22
|
+
columns.add(String(row.column_name))
|
|
23
|
+
cache.set(table, columns)
|
|
24
|
+
}
|
|
25
|
+
return cache
|
|
26
|
+
} catch (error) {
|
|
27
|
+
mapPostgresError(error, 'schema.load')
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function invalid(message: string, field: string, detail: string): never {
|
|
32
|
+
throw new Invalid(message, { metadata: { fields: { [field]: [detail] } } })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function assertTable(cache: SchemaCache, table: string, field: string): Set<string> {
|
|
36
|
+
const columns = cache.get(table)
|
|
37
|
+
if (columns === undefined) {
|
|
38
|
+
invalid(`Unknown table ${table}.`, field, 'Unknown identifier')
|
|
39
|
+
}
|
|
40
|
+
return columns
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function assertColumn(columns: Set<string>, table: string, column: string, field: string): void {
|
|
44
|
+
if (!columns.has(column)) {
|
|
45
|
+
invalid(`Unknown column ${column} on ${table}.`, field, `Unknown identifier: ${column}`)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function assertProjection(
|
|
50
|
+
columns: Set<string>,
|
|
51
|
+
table: string,
|
|
52
|
+
projection: string[] | '*',
|
|
53
|
+
field: string,
|
|
54
|
+
): void {
|
|
55
|
+
if (projection === '*') return
|
|
56
|
+
for (const column of projection) assertColumn(columns, table, column, field)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function assertRelation(cache: SchemaCache, parentTable: string, relation: RelationLoad): void {
|
|
60
|
+
const parentColumns = assertTable(cache, parentTable, 'relations')
|
|
61
|
+
assertColumn(parentColumns, parentTable, relation.localKey, 'relations')
|
|
62
|
+
const relatedColumns = assertTable(cache, relation.table, 'relations')
|
|
63
|
+
assertColumn(relatedColumns, relation.table, relation.foreignKey, 'relations')
|
|
64
|
+
assertProjection(relatedColumns, relation.table, relation.select, 'relations')
|
|
65
|
+
for (const term of relation.order) {
|
|
66
|
+
assertColumn(relatedColumns, relation.table, term.column, 'relations')
|
|
67
|
+
}
|
|
68
|
+
for (const nested of relation.relations) assertRelation(cache, relation.table, nested)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Rejects unknown tables and columns before a statement is sent.
|
|
73
|
+
*
|
|
74
|
+
* Needed so empty parent row sets cannot skip a relation table that does not exist.
|
|
75
|
+
*/
|
|
76
|
+
export function assertQueryAgainstSchema(cache: SchemaCache, query: QueryIR): void {
|
|
77
|
+
const columns = assertTable(cache, query.table, 'table')
|
|
78
|
+
assertProjection(columns, query.table, query.select, 'select')
|
|
79
|
+
if (query.returning !== undefined) {
|
|
80
|
+
assertProjection(columns, query.table, query.returning, 'returning')
|
|
81
|
+
}
|
|
82
|
+
for (const term of query.order) assertColumn(columns, query.table, term.column, 'order')
|
|
83
|
+
|
|
84
|
+
const checkPredicateColumns = (predicate: QueryIR['where'][number]): void => {
|
|
85
|
+
switch (predicate.kind) {
|
|
86
|
+
case 'compare':
|
|
87
|
+
case 'null':
|
|
88
|
+
case 'in':
|
|
89
|
+
assertColumn(columns, query.table, predicate.column, 'where')
|
|
90
|
+
return
|
|
91
|
+
case 'and':
|
|
92
|
+
case 'or':
|
|
93
|
+
predicate.predicates.forEach(checkPredicateColumns)
|
|
94
|
+
return
|
|
95
|
+
case 'not':
|
|
96
|
+
checkPredicateColumns(predicate.predicate)
|
|
97
|
+
return
|
|
98
|
+
case 'const':
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
query.where.forEach(checkPredicateColumns)
|
|
103
|
+
if (query.ward) checkPredicateColumns(query.ward)
|
|
104
|
+
|
|
105
|
+
if (query.values !== undefined) {
|
|
106
|
+
const rows = Array.isArray(query.values) ? query.values : [query.values]
|
|
107
|
+
for (const row of rows) {
|
|
108
|
+
for (const column of Object.keys(row)) assertColumn(columns, query.table, column, 'values')
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (query.conflict !== undefined) {
|
|
113
|
+
for (const column of query.conflict.columns) {
|
|
114
|
+
assertColumn(columns, query.table, column, 'conflict')
|
|
115
|
+
}
|
|
116
|
+
if (query.conflict.update !== '*') {
|
|
117
|
+
for (const column of query.conflict.update) {
|
|
118
|
+
assertColumn(columns, query.table, column, 'conflict')
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const relation of query.relations) assertRelation(cache, query.table, relation)
|
|
124
|
+
}
|
package/src/schema.ts
CHANGED
|
@@ -1,123 +1,11 @@
|
|
|
1
1
|
import type { SQL } from 'bun'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { bunSqlRunner } from './bun-client'
|
|
3
|
+
import { loadSchemaCache as loadSchemaCacheCore, type SchemaCache } from './schema-core'
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
export type SchemaCache
|
|
5
|
+
export { assertQueryAgainstSchema } from './schema-core'
|
|
6
|
+
export type { SchemaCache } from './schema-core'
|
|
7
7
|
|
|
8
8
|
/** Loads every public base table and its columns. */
|
|
9
9
|
export async function loadSchemaCache(sql: SQL): Promise<SchemaCache> {
|
|
10
|
-
|
|
11
|
-
const rows = (await sql.unsafe(`
|
|
12
|
-
SELECT table_name, column_name
|
|
13
|
-
FROM information_schema.columns
|
|
14
|
-
WHERE table_schema = 'public'
|
|
15
|
-
ORDER BY table_name, ordinal_position
|
|
16
|
-
`)) as Array<{ table_name: string; column_name: string }>
|
|
17
|
-
|
|
18
|
-
const cache: SchemaCache = new Map()
|
|
19
|
-
for (const row of rows) {
|
|
20
|
-
const columns = cache.get(row.table_name) ?? new Set<string>()
|
|
21
|
-
columns.add(row.column_name)
|
|
22
|
-
cache.set(row.table_name, columns)
|
|
23
|
-
}
|
|
24
|
-
return cache
|
|
25
|
-
} catch (error) {
|
|
26
|
-
mapPostgresError(error, 'schema.load')
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function invalid(message: string, field: string, detail: string): never {
|
|
31
|
-
throw new Invalid(message, { metadata: { fields: { [field]: [detail] } } })
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function assertTable(cache: SchemaCache, table: string, field: string): Set<string> {
|
|
35
|
-
const columns = cache.get(table)
|
|
36
|
-
if (columns === undefined) {
|
|
37
|
-
invalid(`Unknown table ${table}.`, field, 'Unknown identifier')
|
|
38
|
-
}
|
|
39
|
-
return columns
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function assertColumn(columns: Set<string>, table: string, column: string, field: string): void {
|
|
43
|
-
if (!columns.has(column)) {
|
|
44
|
-
invalid(`Unknown column ${column} on ${table}.`, field, `Unknown identifier: ${column}`)
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function assertProjection(
|
|
49
|
-
columns: Set<string>,
|
|
50
|
-
table: string,
|
|
51
|
-
projection: string[] | '*',
|
|
52
|
-
field: string,
|
|
53
|
-
): void {
|
|
54
|
-
if (projection === '*') return
|
|
55
|
-
for (const column of projection) assertColumn(columns, table, column, field)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function assertRelation(cache: SchemaCache, parentTable: string, relation: RelationLoad): void {
|
|
59
|
-
const parentColumns = assertTable(cache, parentTable, 'relations')
|
|
60
|
-
assertColumn(parentColumns, parentTable, relation.localKey, 'relations')
|
|
61
|
-
const relatedColumns = assertTable(cache, relation.table, 'relations')
|
|
62
|
-
assertColumn(relatedColumns, relation.table, relation.foreignKey, 'relations')
|
|
63
|
-
assertProjection(relatedColumns, relation.table, relation.select, 'relations')
|
|
64
|
-
for (const term of relation.order) {
|
|
65
|
-
assertColumn(relatedColumns, relation.table, term.column, 'relations')
|
|
66
|
-
}
|
|
67
|
-
for (const nested of relation.relations) assertRelation(cache, relation.table, nested)
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Rejects unknown tables and columns before a statement is sent.
|
|
72
|
-
*
|
|
73
|
-
* Needed so empty parent row sets cannot skip a relation table that does not exist.
|
|
74
|
-
*/
|
|
75
|
-
export function assertQueryAgainstSchema(cache: SchemaCache, query: QueryIR): void {
|
|
76
|
-
const columns = assertTable(cache, query.table, 'table')
|
|
77
|
-
assertProjection(columns, query.table, query.select, 'select')
|
|
78
|
-
if (query.returning !== undefined) {
|
|
79
|
-
assertProjection(columns, query.table, query.returning, 'returning')
|
|
80
|
-
}
|
|
81
|
-
for (const term of query.order) assertColumn(columns, query.table, term.column, 'order')
|
|
82
|
-
|
|
83
|
-
const checkPredicateColumns = (predicate: QueryIR['where'][number]): void => {
|
|
84
|
-
switch (predicate.kind) {
|
|
85
|
-
case 'compare':
|
|
86
|
-
case 'null':
|
|
87
|
-
case 'in':
|
|
88
|
-
assertColumn(columns, query.table, predicate.column, 'where')
|
|
89
|
-
return
|
|
90
|
-
case 'and':
|
|
91
|
-
case 'or':
|
|
92
|
-
predicate.predicates.forEach(checkPredicateColumns)
|
|
93
|
-
return
|
|
94
|
-
case 'not':
|
|
95
|
-
checkPredicateColumns(predicate.predicate)
|
|
96
|
-
return
|
|
97
|
-
case 'const':
|
|
98
|
-
return
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
query.where.forEach(checkPredicateColumns)
|
|
102
|
-
if (query.ward) checkPredicateColumns(query.ward)
|
|
103
|
-
|
|
104
|
-
if (query.values !== undefined) {
|
|
105
|
-
const rows = Array.isArray(query.values) ? query.values : [query.values]
|
|
106
|
-
for (const row of rows) {
|
|
107
|
-
for (const column of Object.keys(row)) assertColumn(columns, query.table, column, 'values')
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
if (query.conflict !== undefined) {
|
|
112
|
-
for (const column of query.conflict.columns) {
|
|
113
|
-
assertColumn(columns, query.table, column, 'conflict')
|
|
114
|
-
}
|
|
115
|
-
if (query.conflict.update !== '*') {
|
|
116
|
-
for (const column of query.conflict.update) {
|
|
117
|
-
assertColumn(columns, query.table, column, 'conflict')
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
for (const relation of query.relations) assertRelation(cache, query.table, relation)
|
|
10
|
+
return loadSchemaCacheCore(bunSqlRunner(sql))
|
|
123
11
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { CompiledSql } from './compile'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Normalized statement result.
|
|
5
|
+
*
|
|
6
|
+
* Bun returns an array carrying a non-enumerable `count`; Neon returns `{ rows, rowCount }`. Both
|
|
7
|
+
* client adapters flatten to this shape so the execution algorithm never inspects a vendor result.
|
|
8
|
+
*/
|
|
9
|
+
export interface SqlRows {
|
|
10
|
+
/** Rows the statement produced, owned by the caller and safe to mutate. */
|
|
11
|
+
rows: Record<string, unknown>[]
|
|
12
|
+
/** Rows the statement affected, used for writes without `RETURNING`. */
|
|
13
|
+
count: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Statement runner the vendor-neutral query execution needs. */
|
|
17
|
+
export interface SqlRunner {
|
|
18
|
+
/** Runs one parameterized statement and returns its normalized result. */
|
|
19
|
+
unsafe(text: string, parameters?: readonly unknown[]): Promise<SqlRows>
|
|
20
|
+
/** Releases the underlying connection. Absent on a runner that owns none. */
|
|
21
|
+
close?(): Promise<void>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A runner that can also apply a fully known statement list atomically. */
|
|
25
|
+
export interface SqlBatchRunner extends SqlRunner {
|
|
26
|
+
/** Applies every statement in one transaction, or none of them. */
|
|
27
|
+
batch(statements: readonly CompiledSql[]): Promise<void>
|
|
28
|
+
}
|
package/src/sql.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { compilePostgres, compileSqlPredicate, type CompiledSql } from './compile'
|
|
2
|
+
export { mapPostgresError, POSTGRES_ERROR_MAP } from './errors'
|
|
3
|
+
export { executeQueryIR, executeRpc, type RoundTripCounter } from './execute'
|
|
4
|
+
export { ASSAY_FIXTURE_SQL, ASSAY_FIXTURE_STATEMENTS } from './fixtures-sql'
|
|
5
|
+
export {
|
|
6
|
+
applyMigrations,
|
|
7
|
+
planMigrations,
|
|
8
|
+
rollbackMigrations,
|
|
9
|
+
statusMigrations,
|
|
10
|
+
type PostgresMigration,
|
|
11
|
+
} from './migrations-core'
|
|
12
|
+
export { combinedPredicate, normalizePredicate } from './normalize'
|
|
13
|
+
export { assertQueryAgainstSchema, loadSchemaCache, type SchemaCache } from './schema-core'
|
|
14
|
+
export type { SqlBatchRunner, SqlRows, SqlRunner } from './sql-client'
|
|
15
|
+
export { assertIdentifier, validateQueryIR } from './validate'
|