@bezoar/kit 0.0.1 → 0.0.3
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/.dist/_chunks/build.mjs +108 -9
- package/.dist/index.d.mts +19 -5
- package/README.md +28 -1
- package/package.json +4 -4
package/.dist/_chunks/build.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { createServer } from "node:net";
|
|
|
5
5
|
import { PGLiteSocketServer } from "@electric-sql/pglite-socket";
|
|
6
6
|
import { createGenerator } from "@ts-safeql/generate";
|
|
7
7
|
import postgres from "postgres";
|
|
8
|
+
import { parse } from "libpg-query";
|
|
8
9
|
import { record, within } from "objectively";
|
|
9
10
|
import { createHash } from "node:crypto";
|
|
10
11
|
import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
@@ -74,6 +75,36 @@ const apply = async (executor, sources) => {
|
|
|
74
75
|
throw new InvalidSourceError(file.path, `failed: ${reason(cause)}`, { cause });
|
|
75
76
|
}
|
|
76
77
|
};
|
|
78
|
+
const COLUMNS = `
|
|
79
|
+
SELECT n.nspname AS schema, c.relname AS table,
|
|
80
|
+
pg_catalog.pg_table_is_visible(c.oid) AS visible,
|
|
81
|
+
a.attname AS name, NOT (a.attnotnull OR t.typnotnull) AS nullable
|
|
82
|
+
FROM pg_attribute a
|
|
83
|
+
JOIN pg_class c ON c.oid = a.attrelid
|
|
84
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
85
|
+
JOIN pg_type t ON t.oid = a.atttypid
|
|
86
|
+
WHERE c.relkind IN ('r', 'p')
|
|
87
|
+
AND a.attnum > 0
|
|
88
|
+
AND NOT a.attisdropped
|
|
89
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
90
|
+
ORDER BY n.nspname, c.relname, a.attnum`;
|
|
91
|
+
const readColumns = async (executor) => {
|
|
92
|
+
const columns = /* @__PURE__ */ new Map();
|
|
93
|
+
for (const row of (await executor.query(COLUMNS, [])).rows) {
|
|
94
|
+
const column = {
|
|
95
|
+
name: String(row.name),
|
|
96
|
+
nullable: row.nullable === true
|
|
97
|
+
};
|
|
98
|
+
const keys = [`${String(row.schema)}.${String(row.table)}`];
|
|
99
|
+
if (row.visible === true) keys.push(String(row.table));
|
|
100
|
+
for (const key of keys) {
|
|
101
|
+
const list = columns.get(key);
|
|
102
|
+
if (list === void 0) columns.set(key, [column]);
|
|
103
|
+
else list.push(column);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return columns;
|
|
107
|
+
};
|
|
77
108
|
const ENUMS = `
|
|
78
109
|
SELECT t.typname AS name,
|
|
79
110
|
array_agg(e.enumlabel::text ORDER BY e.enumsortorder) AS labels
|
|
@@ -88,6 +119,66 @@ const readEnums = async (executor) => {
|
|
|
88
119
|
for (const row of (await executor.query(ENUMS, [SCHEMA])).rows) enums[String(row.name)] = Array.isArray(row.labels) ? row.labels.map(String) : [];
|
|
89
120
|
return enums;
|
|
90
121
|
};
|
|
122
|
+
const is = (value, key) => key in value;
|
|
123
|
+
const paramOf = (node) => {
|
|
124
|
+
if (node === void 0) return;
|
|
125
|
+
if ("TypeCast" in node) return paramOf(node.TypeCast.arg);
|
|
126
|
+
if ("ParamRef" in node) return node.ParamRef.number;
|
|
127
|
+
};
|
|
128
|
+
const tableOf = (columns, relation) => {
|
|
129
|
+
if (relation?.relname === void 0) return;
|
|
130
|
+
const { schemaname, relname } = relation;
|
|
131
|
+
return columns.get(schemaname === void 0 ? relname : `${schemaname}.${relname}`);
|
|
132
|
+
};
|
|
133
|
+
const nameOf$1 = (node) => node !== void 0 && "ResTarget" in node && node.ResTarget.indirection === void 0 ? node.ResTarget.name : void 0;
|
|
134
|
+
const valueOf = (node) => "ResTarget" in node ? node.ResTarget.val : void 0;
|
|
135
|
+
const rowsOf = (node) => {
|
|
136
|
+
if (node === void 0 || !("SelectStmt" in node)) return [];
|
|
137
|
+
const { valuesLists, targetList } = node.SelectStmt;
|
|
138
|
+
if (valuesLists !== void 0) return valuesLists.map((row) => "List" in row ? row.List.items ?? [] : []);
|
|
139
|
+
if (targetList !== void 0) return [targetList.map(valueOf)];
|
|
140
|
+
return [];
|
|
141
|
+
};
|
|
142
|
+
const collect = (tree, columns) => {
|
|
143
|
+
const found = /* @__PURE__ */ new Set();
|
|
144
|
+
const mark = (node) => {
|
|
145
|
+
const number = paramOf(node);
|
|
146
|
+
if (number !== void 0) found.add(number);
|
|
147
|
+
};
|
|
148
|
+
const written = (table, name, value) => {
|
|
149
|
+
if (table?.find((column) => column.name === name)?.nullable) mark(value);
|
|
150
|
+
};
|
|
151
|
+
const walk = (value) => {
|
|
152
|
+
if (Array.isArray(value)) {
|
|
153
|
+
for (const item of value) walk(item);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (!record(value)) return;
|
|
157
|
+
if (is(value, "NullTest")) mark(value.NullTest.arg);
|
|
158
|
+
else if (is(value, "CoalesceExpr")) for (const arg of value.CoalesceExpr.args ?? []) mark(arg);
|
|
159
|
+
else if (is(value, "A_Expr")) {
|
|
160
|
+
const { kind, lexpr, rexpr } = value.A_Expr;
|
|
161
|
+
if (kind === "AEXPR_DISTINCT" || kind === "AEXPR_NOT_DISTINCT") {
|
|
162
|
+
mark(lexpr);
|
|
163
|
+
mark(rexpr);
|
|
164
|
+
}
|
|
165
|
+
} else if (is(value, "InsertStmt")) {
|
|
166
|
+
const { relation, cols, selectStmt, onConflictClause } = value.InsertStmt;
|
|
167
|
+
const table = tableOf(columns, relation);
|
|
168
|
+
const names = cols === void 0 ? (table ?? []).map((column) => column.name) : cols.map(nameOf$1);
|
|
169
|
+
for (const row of rowsOf(selectStmt)) for (const [index, item] of row.entries()) written(table, names[index], item);
|
|
170
|
+
for (const target of onConflictClause?.targetList ?? []) written(table, nameOf$1(target), valueOf(target));
|
|
171
|
+
} else if (is(value, "UpdateStmt")) {
|
|
172
|
+
const { relation, targetList } = value.UpdateStmt;
|
|
173
|
+
const table = tableOf(columns, relation);
|
|
174
|
+
for (const target of targetList ?? []) written(table, nameOf$1(target), valueOf(target));
|
|
175
|
+
}
|
|
176
|
+
for (const member of Object.values(value)) walk(member);
|
|
177
|
+
};
|
|
178
|
+
walk(tree);
|
|
179
|
+
return found;
|
|
180
|
+
};
|
|
181
|
+
const nullableParams = async (sql, columns) => collect(await parse(sql), columns);
|
|
91
182
|
const free = () => new Promise((resolve, reject) => {
|
|
92
183
|
const server = createServer();
|
|
93
184
|
server.listen(0, HOST, () => {
|
|
@@ -104,7 +195,7 @@ const admitsNull = (target) => {
|
|
|
104
195
|
if (target.kind === "union") return target.value.some(admitsNull);
|
|
105
196
|
return false;
|
|
106
197
|
};
|
|
107
|
-
const describeQueries = async (client, queries) => {
|
|
198
|
+
const describeQueries = async (client, queries, columns) => {
|
|
108
199
|
const described = /* @__PURE__ */ new Map();
|
|
109
200
|
if (queries.length === 0) return described;
|
|
110
201
|
const port = await free();
|
|
@@ -137,8 +228,12 @@ const describeQueries = async (client, queries) => {
|
|
|
137
228
|
if (result._tag === "Left") throw new InvalidQueryError(query.path, `failed: ${result.left.message}`, { cause: result.left });
|
|
138
229
|
const { stmt, output } = result.right;
|
|
139
230
|
const targets = new Map(output?.value ?? []);
|
|
231
|
+
const nullable = await nullableParams(query.sql, columns);
|
|
140
232
|
described.set(query.name, {
|
|
141
|
-
params: stmt.types,
|
|
233
|
+
params: stmt.types.map((type, index) => ({
|
|
234
|
+
type,
|
|
235
|
+
nullable: nullable.has(index + 1)
|
|
236
|
+
})),
|
|
142
237
|
columns: stmt.columns.map((column) => {
|
|
143
238
|
const target = targets.get(column.name);
|
|
144
239
|
return {
|
|
@@ -417,7 +512,7 @@ const TYPES = `
|
|
|
417
512
|
const readTypes = async (executor, described) => {
|
|
418
513
|
const oids = /* @__PURE__ */ new Set();
|
|
419
514
|
for (const { params, columns } of described.values()) {
|
|
420
|
-
for (const
|
|
515
|
+
for (const param of params) oids.add(param.type);
|
|
421
516
|
for (const column of columns) oids.add(column.type);
|
|
422
517
|
}
|
|
423
518
|
const names = /* @__PURE__ */ new Map();
|
|
@@ -441,11 +536,14 @@ const define = (queries, described, types, enums) => {
|
|
|
441
536
|
return name;
|
|
442
537
|
};
|
|
443
538
|
statements[query.name] = {
|
|
444
|
-
slots: query.slots.map((name, index) =>
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
539
|
+
slots: query.slots.map((name, index) => {
|
|
540
|
+
const param = description.params[index];
|
|
541
|
+
return {
|
|
542
|
+
name,
|
|
543
|
+
type: typeOf(`the slot :${name}`, param?.type ?? 0),
|
|
544
|
+
nullable: param?.nullable ?? false
|
|
545
|
+
};
|
|
546
|
+
}),
|
|
449
547
|
columns: description.columns.map((column) => ({
|
|
450
548
|
name: column.name,
|
|
451
549
|
type: typeOf(`the column ${column.name}`, column.type),
|
|
@@ -490,7 +588,8 @@ const generate = async (config, root) => {
|
|
|
490
588
|
try {
|
|
491
589
|
await apply(executor, sources);
|
|
492
590
|
const enums = await readEnums(executor);
|
|
493
|
-
const
|
|
591
|
+
const columns = await readColumns(executor);
|
|
592
|
+
const described = await describeQueries(client, sources.queries, columns);
|
|
494
593
|
const types = await readTypes(executor, described);
|
|
495
594
|
const contract = define(sources.queries, described, types, enums);
|
|
496
595
|
return {
|
package/.dist/index.d.mts
CHANGED
|
@@ -74,11 +74,15 @@ export interface Sources {
|
|
|
74
74
|
queries: Query[];
|
|
75
75
|
}
|
|
76
76
|
/**
|
|
77
|
-
* What Postgres and
|
|
78
|
-
* and the name, type OID, and nullability
|
|
77
|
+
* What Postgres, SafeQL, and the parse tree say about one query: the type
|
|
78
|
+
* OID and nullability of each slot, and the name, type OID, and nullability
|
|
79
|
+
* of each column.
|
|
79
80
|
*/
|
|
80
81
|
export interface Described {
|
|
81
|
-
readonly params: readonly
|
|
82
|
+
readonly params: readonly {
|
|
83
|
+
readonly type: number;
|
|
84
|
+
readonly nullable: boolean;
|
|
85
|
+
}[];
|
|
82
86
|
readonly columns: readonly {
|
|
83
87
|
readonly name: string;
|
|
84
88
|
readonly type: number;
|
|
@@ -90,6 +94,16 @@ export interface Described {
|
|
|
90
94
|
* its labels, in declaration order. What the contract carries as `enums`.
|
|
91
95
|
*/
|
|
92
96
|
export type Enums = Readonly<Record<string, readonly string[]>>;
|
|
97
|
+
/**
|
|
98
|
+
* The columns of each table of the database after the migrations ran, in
|
|
99
|
+
* declaration order, with whether each takes null. A table is keyed by
|
|
100
|
+
* `schema.name`, and by its bare name when the search path finds it. What
|
|
101
|
+
* decides the nullability of a slot a query writes into a column.
|
|
102
|
+
*/
|
|
103
|
+
export type Columns = ReadonlyMap<string, readonly {
|
|
104
|
+
readonly name: string;
|
|
105
|
+
readonly nullable: boolean;
|
|
106
|
+
}[]>;
|
|
93
107
|
/**
|
|
94
108
|
* One file of the output.
|
|
95
109
|
*/
|
|
@@ -246,8 +260,8 @@ export declare class InvalidQueryError extends Error {
|
|
|
246
260
|
/**
|
|
247
261
|
* Generates the output of a project without writing it. The function
|
|
248
262
|
* validates the config, reads the SQL, applies the migrations and the seeds
|
|
249
|
-
* to a database in memory, reads the enums, describes each
|
|
250
|
-
* the contract, and emits the files.
|
|
263
|
+
* to a database in memory, reads the enums and the columns, describes each
|
|
264
|
+
* query, builds the contract, and emits the files.
|
|
251
265
|
*
|
|
252
266
|
* @param config - The authored config.
|
|
253
267
|
* @param root - The project root.
|
package/README.md
CHANGED
|
@@ -89,6 +89,31 @@ is an error: a query names its slots.
|
|
|
89
89
|
Every lever of a query is a SQL lever. A cast sets the type of a slot. An
|
|
90
90
|
alias names a column. Two columns with one name fail the build.
|
|
91
91
|
|
|
92
|
+
A slot is required unless its null has a meaning. The SQL gives it one when
|
|
93
|
+
the slot is the operand of `IS NULL` or `IS NOT NULL`, an argument of
|
|
94
|
+
`COALESCE`, or a side of `IS DISTINCT FROM` or `IS NOT DISTINCT FROM`,
|
|
95
|
+
through any cast. The schema gives it one when an `INSERT`, an `UPDATE`, or
|
|
96
|
+
an `ON CONFLICT DO UPDATE` writes the slot into a column without `NOT NULL`.
|
|
97
|
+
Such a slot is an optional argument that also takes `null`, and an absent
|
|
98
|
+
argument binds null. Any other slot is required: a null there matches
|
|
99
|
+
nothing.
|
|
100
|
+
|
|
101
|
+
```sql
|
|
102
|
+
-- src/queries/events/search.sql
|
|
103
|
+
SELECT id, slug, title
|
|
104
|
+
FROM events
|
|
105
|
+
WHERE (day = :day OR :day IS NULL)
|
|
106
|
+
AND slug = COALESCE(:slug, slug)
|
|
107
|
+
ORDER BY id;
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
```sql
|
|
111
|
+
-- src/queries/events/insert.sql: day has no NOT NULL, so :day takes null.
|
|
112
|
+
INSERT INTO events (slug, title, day)
|
|
113
|
+
VALUES (:slug, :title, :day)
|
|
114
|
+
RETURNING id;
|
|
115
|
+
```
|
|
116
|
+
|
|
92
117
|
## Build
|
|
93
118
|
|
|
94
119
|
```sh
|
|
@@ -100,7 +125,9 @@ migrations and the seeds in order. It reads the enums. Then it serves the
|
|
|
100
125
|
database on a local port and describes each query through
|
|
101
126
|
[SafeQL](https://safeql.dev): Postgres reports the type of each slot and
|
|
102
127
|
each column, and SafeQL reports whether each column can be null, through
|
|
103
|
-
outer joins, `COALESCE`, aggregates, CTEs, and views.
|
|
128
|
+
outer joins, `COALESCE`, aggregates, CTEs, and views. The kit parses each
|
|
129
|
+
query with [libpg-query](https://github.com/pganalyze/libpg-query-node) and
|
|
130
|
+
reads the columns of the catalog to decide whether each slot can be null.
|
|
104
131
|
|
|
105
132
|
A file that Postgres rejects fails the build with the file and the error of
|
|
106
133
|
Postgres:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bezoar/kit",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "Build kit: SQL in a hermetic Postgres to a typed contract",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
"libpg-query": "18.1.5",
|
|
33
33
|
"objectively": "^0.3.0",
|
|
34
34
|
"postgres": "^3.4.9",
|
|
35
|
-
"@bezoar/
|
|
36
|
-
"@bezoar/
|
|
37
|
-
"@bezoar/schema": "0.0.
|
|
35
|
+
"@bezoar/core": "0.0.3",
|
|
36
|
+
"@bezoar/pglite": "0.0.3",
|
|
37
|
+
"@bezoar/schema": "0.0.3"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@types/node": "^25.9.9"
|