@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.
@@ -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 oid of params) oids.add(oid);
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
- name,
446
- type: typeOf(`the slot :${name}`, description.params[index] ?? 0),
447
- nullable: false
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 described = await describeQueries(client, sources.queries);
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 SafeQL say about one query: the type OID of each slot,
78
- * and the name, type OID, and nullability of each column.
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 number[];
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 query, builds
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.1",
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/pglite": "0.0.1",
36
- "@bezoar/core": "0.0.1",
37
- "@bezoar/schema": "0.0.1"
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"