@typesql/pg 0.1.5 → 0.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.
@@ -10,25 +10,28 @@
10
10
  * else (function calls outside a short list, scalar subqueries, set-returning function columns)
11
11
  * is nullable. On PostgreSQL 15 and older every column reference counts as nullable.
12
12
  *
13
- * This module has no imports so that scripts/pg-catalog-from-pg.mjs can load it with Node's
13
+ * This module has no imports so that scripts/pg-catalog-from-pg.ts can load it with Node's
14
14
  * type stripping.
15
15
  */
16
16
 
17
17
  /** Runs a query: node-postgres' `client.query(text, values)` fits. */
18
- export type QueryFn = (text: string, values?: unknown[]) => Promise<{ readonly rows: readonly any[] }>;
18
+ export type QueryFn = (
19
+ text: string,
20
+ values?: unknown[],
21
+ ) => Promise<{ readonly rows: readonly any[] }>;
19
22
 
20
23
  export interface NullabilityOptions {
21
- /**
22
- * Columns known never to be NULL that the analysis cannot prove (typically columns of a
23
- * set-returning function). Called with the view's name and the column's name.
24
- */
25
- readonly notNull?: (view: string, column: string) => boolean;
24
+ /**
25
+ * Columns known never to be NULL that the analysis cannot prove (typically columns of a
26
+ * set-returning function). Called with the view's name and the column's name.
27
+ */
28
+ readonly notNull?: (view: string, column: string) => boolean;
26
29
  }
27
30
 
28
31
  /** A node of a parsed `pg_node_tree`: `{ _: "VAR", varno: "1", … }`. */
29
32
  export interface Node {
30
- readonly _: string;
31
- readonly [field: string]: NodeValue;
33
+ readonly _: string;
34
+ readonly [field: string]: NodeValue;
32
35
  }
33
36
  export type NodeValue = Node | readonly NodeValue[] | string | null;
34
37
 
@@ -38,86 +41,155 @@ type Token = string | { readonly str: string };
38
41
 
39
42
  /** Tokens of the text form of a node tree (`outfuncs.c`). `[ … ]` datum bytes are skipped. */
40
43
  function tokenize(s: string): Token[] {
41
- const out: Token[] = [];
42
- let i = 0;
43
- while (i < s.length) {
44
- const c = s[i]!;
45
- if (c === " " || c === "\n" || c === "\t") { i++; continue; }
46
- if (c === "(" || c === ")" || c === "{" || c === "}") { out.push(c); i++; continue; }
47
- if (c === "[") { i = s.indexOf("]", i) + 1; continue; }
48
- if (c === '"') {
49
- let v = "";
50
- i++;
51
- while (i < s.length && s[i] !== '"') { if (s[i] === "\\") i++; v += s[i++]; }
52
- i++;
53
- out.push({ str: v });
54
- continue;
55
- }
56
- let v = "";
57
- while (i < s.length && !" \n\t(){}".includes(s[i]!)) { if (s[i] === "\\") i++; v += s[i++]; }
58
- out.push(v);
59
- }
60
- return out;
44
+ const out: Token[] = [];
45
+ let i = 0;
46
+ while (i < s.length) {
47
+ const c = s[i]!;
48
+ if (c === " " || c === "\n" || c === "\t") {
49
+ i++;
50
+ continue;
51
+ }
52
+ if (c === "(" || c === ")" || c === "{" || c === "}") {
53
+ out.push(c);
54
+ i++;
55
+ continue;
56
+ }
57
+ if (c === "[") {
58
+ i = s.indexOf("]", i) + 1;
59
+ continue;
60
+ }
61
+ if (c === '"') {
62
+ let v = "";
63
+ i++;
64
+ while (i < s.length && s[i] !== '"') {
65
+ if (s[i] === "\\") i++;
66
+ v += s[i++];
67
+ }
68
+ i++;
69
+ out.push({ str: v });
70
+ continue;
71
+ }
72
+ let v = "";
73
+ while (i < s.length && !" \n\t(){}".includes(s[i]!)) {
74
+ if (s[i] === "\\") i++;
75
+ v += s[i++];
76
+ }
77
+ out.push(v);
78
+ }
79
+ return out;
61
80
  }
62
81
 
63
82
  /** Parse a node tree into plain values: nodes as objects, lists as arrays, `<>` as null. */
64
83
  export function parseNodeTree(text: string): NodeValue {
65
- const toks = tokenize(text);
66
- let p = 0;
67
- const isLabel = (t: Token | undefined): boolean => typeof t === "string" && t.startsWith(":");
68
- const value = (): NodeValue => {
69
- const t = toks[p++];
70
- if (t === "{") {
71
- const node: Record<string, NodeValue> = { _: toks[p++] as string };
72
- while (p < toks.length && toks[p] !== "}") {
73
- const label = toks[p++];
74
- if (!isLabel(label)) continue; // a datum's length before its skipped bytes
75
- if (toks[p] === "}" || isLabel(toks[p])) { node[(label as string).slice(1)] = null; continue; }
76
- node[(label as string).slice(1)] = value();
77
- }
78
- p++;
79
- return node as Node;
80
- }
81
- if (t === "(") {
82
- const list: NodeValue[] = [];
83
- while (p < toks.length && toks[p] !== ")") list.push(value());
84
- p++;
85
- return list;
86
- }
87
- if (t === undefined || t === "<>") return null;
88
- return typeof t === "object" ? t.str : t;
89
- };
90
- return value();
84
+ const toks = tokenize(text);
85
+ let p = 0;
86
+ const isLabel = (t: Token | undefined): boolean =>
87
+ typeof t === "string" && t.startsWith(":");
88
+ const value = (): NodeValue => {
89
+ const t = toks[p++];
90
+ if (t === "{") {
91
+ const node: Record<string, NodeValue> = { _: toks[p++] as string };
92
+ while (p < toks.length && toks[p] !== "}") {
93
+ const label = toks[p++];
94
+ if (!isLabel(label)) continue; // a datum's length before its skipped bytes
95
+ if (toks[p] === "}" || isLabel(toks[p])) {
96
+ node[(label as string).slice(1)] = null;
97
+ continue;
98
+ }
99
+ node[(label as string).slice(1)] = value();
100
+ }
101
+ p++;
102
+ return node as Node;
103
+ }
104
+ if (t === "(") {
105
+ const list: NodeValue[] = [];
106
+ while (p < toks.length && toks[p] !== ")") list.push(value());
107
+ p++;
108
+ return list;
109
+ }
110
+ if (t === undefined || t === "<>") return null;
111
+ return typeof t === "object" ? t.str : t;
112
+ };
113
+ return value();
91
114
  }
92
115
 
93
- const list = (x: NodeValue | undefined): readonly NodeValue[] => (Array.isArray(x) ? x : []);
94
- const node = (x: NodeValue | undefined): Node | null => (x !== null && typeof x === "object" && !Array.isArray(x) ? (x as Node) : null);
95
- const str = (x: NodeValue | undefined): string | null => (typeof x === "string" ? x : null);
116
+ const list = (x: NodeValue | undefined): readonly NodeValue[] =>
117
+ Array.isArray(x) ? x : [];
118
+ const node = (x: NodeValue | undefined): Node | null =>
119
+ x !== null && typeof x === "object" && !Array.isArray(x) ? (x as Node) : null;
120
+ const str = (x: NodeValue | undefined): string | null =>
121
+ typeof x === "string" ? x : null;
96
122
 
97
123
  // ───────────────────────────── function knowledge ─────────────────────────────
98
124
 
99
125
  /** `pg_catalog` functions that return a non-NULL value for non-NULL arguments. */
100
126
  const NON_NULL_FUNCTIONS = new Set([
101
- "current_database", "current_user", "session_user", "current_schema", "pg_get_userbyid", "pg_my_temp_schema",
102
- "nameconcatoid", "pg_has_role", "has_table_privilege", "has_column_privilege", "has_any_column_privilege",
103
- "has_function_privilege", "has_schema_privilege", "has_sequence_privilege", "has_type_privilege",
104
- "pg_relation_is_updatable", "pg_column_is_updatable", "pg_is_other_temp_schema",
105
- "now", "statement_timestamp", "transaction_timestamp", "clock_timestamp",
106
- "lower", "upper", "length", "char_length", "octet_length", "concat", "concat_ws", "md5", "btrim", "ltrim", "rtrim",
107
- "abs", "round", "trunc", "floor", "ceil", "ceiling", "date_trunc", "age",
127
+ "current_database",
128
+ "current_user",
129
+ "session_user",
130
+ "current_schema",
131
+ "pg_get_userbyid",
132
+ "pg_my_temp_schema",
133
+ "nameconcatoid",
134
+ "pg_has_role",
135
+ "has_table_privilege",
136
+ "has_column_privilege",
137
+ "has_any_column_privilege",
138
+ "has_function_privilege",
139
+ "has_schema_privilege",
140
+ "has_sequence_privilege",
141
+ "has_type_privilege",
142
+ "pg_relation_is_updatable",
143
+ "pg_column_is_updatable",
144
+ "pg_is_other_temp_schema",
145
+ "now",
146
+ "statement_timestamp",
147
+ "transaction_timestamp",
148
+ "clock_timestamp",
149
+ "lower",
150
+ "upper",
151
+ "length",
152
+ "char_length",
153
+ "octet_length",
154
+ "concat",
155
+ "concat_ws",
156
+ "md5",
157
+ "btrim",
158
+ "ltrim",
159
+ "rtrim",
160
+ "abs",
161
+ "round",
162
+ "trunc",
163
+ "floor",
164
+ "ceil",
165
+ "ceiling",
166
+ "date_trunc",
167
+ "age",
108
168
  ]);
109
169
 
110
170
  /** `pg_stat_get_*` accessors returning a number report 0 when no statistics exist, except these. */
111
171
  const NULLABLE_STAT_FUNCTIONS = new Set([
112
- "pg_stat_get_db_checksum_failures", "pg_stat_get_backend_pid", "pg_stat_get_backend_dbid", "pg_stat_get_backend_userid",
172
+ "pg_stat_get_db_checksum_failures",
173
+ "pg_stat_get_backend_pid",
174
+ "pg_stat_get_backend_dbid",
175
+ "pg_stat_get_backend_userid",
113
176
  ]);
114
177
  const isNonNullStatFunction = (name: string, ret: string): boolean =>
115
- name.startsWith("pg_stat_get_") && (ret === "int8" || ret === "int4" || ret === "float8") && !NULLABLE_STAT_FUNCTIONS.has(name);
178
+ name.startsWith("pg_stat_get_") &&
179
+ (ret === "int8" || ret === "int4" || ret === "float8") &&
180
+ !NULLABLE_STAT_FUNCTIONS.has(name);
116
181
 
117
182
  /** Aggregates that never return NULL. */
118
183
  const NON_NULL_AGGREGATES = new Set(["count"]);
119
184
  /** Window functions that never return NULL. */
120
- const NON_NULL_WINDOW_FUNCTIONS = new Set(["count", "row_number", "rank", "dense_rank", "percent_rank", "cume_dist"]);
185
+ const NON_NULL_WINDOW_FUNCTIONS = new Set([
186
+ "count",
187
+ "row_number",
188
+ "rank",
189
+ "dense_rank",
190
+ "percent_rank",
191
+ "cume_dist",
192
+ ]);
121
193
 
122
194
  /**
123
195
  * Set-returning functions in FROM and their output columns (1-based) that are never NULL. A
@@ -126,10 +198,18 @@ const NON_NULL_WINDOW_FUNCTIONS = new Set(["count", "row_number", "rank", "dense
126
198
  * the `_text` variants turn a JSON null into SQL NULL.
127
199
  */
128
200
  const NON_NULL_SRF_COLUMNS: Readonly<Record<string, readonly number[]>> = {
129
- generate_series: [1], generate_subscripts: [1],
130
- jsonb_each: [1, 2], json_each: [1, 2], jsonb_each_text: [1], json_each_text: [1],
131
- jsonb_object_keys: [1], json_object_keys: [1], jsonb_array_elements: [1], json_array_elements: [1],
132
- regexp_matches: [1], regexp_split_to_table: [1],
201
+ generate_series: [1],
202
+ generate_subscripts: [1],
203
+ jsonb_each: [1, 2],
204
+ json_each: [1, 2],
205
+ jsonb_each_text: [1],
206
+ json_each_text: [1],
207
+ jsonb_object_keys: [1],
208
+ json_object_keys: [1],
209
+ jsonb_array_elements: [1],
210
+ json_array_elements: [1],
211
+ regexp_matches: [1],
212
+ regexp_split_to_table: [1],
133
213
  };
134
214
 
135
215
  /**
@@ -139,84 +219,106 @@ const NON_NULL_SRF_COLUMNS: Readonly<Record<string, readonly number[]>> = {
139
219
  */
140
220
  const NULLABLE_OPERATORS = new Set(["->", "->>", "#>", "#>>", "@@", "@?"]);
141
221
 
142
- interface Proc { readonly name: string; readonly ret: string; readonly system: boolean }
143
- interface Att { readonly attnum: number; readonly notnull: boolean; readonly relkind: string }
222
+ interface Proc {
223
+ readonly name: string;
224
+ readonly ret: string;
225
+ readonly system: boolean;
226
+ }
227
+ interface Att {
228
+ readonly attnum: number;
229
+ readonly notnull: boolean;
230
+ readonly relkind: string;
231
+ }
144
232
 
145
233
  // ───────────────────────────── analysis ─────────────────────────────
146
234
 
147
235
  /** Per-column NOT NULL of views and materialized views; caches what it reads. */
148
236
  export class ViewNullability {
149
- private readonly query: QueryFn;
150
- private readonly options: NullabilityOptions;
151
- private readonly views = new Map<string, Promise<boolean[]>>();
152
- private readonly atts = new Map<string, Promise<Att[]>>();
153
- private procs: Promise<Map<string, Proc>> | null = null;
154
- private operators: Promise<Map<string, Proc>> | null = null;
237
+ private readonly query: QueryFn;
238
+ private readonly options: NullabilityOptions;
239
+ private readonly views = new Map<string, Promise<boolean[]>>();
240
+ private readonly analyzing = new Set<string>();
241
+ private readonly atts = new Map<string, Promise<Att[]>>();
242
+ private procs: Promise<Map<string, Proc>> | null = null;
243
+ private operators: Promise<Map<string, Proc>> | null = null;
155
244
 
156
- constructor(query: QueryFn, options: NullabilityOptions = {}) {
157
- this.query = query;
158
- this.options = options;
159
- }
245
+ constructor(query: QueryFn, options: NullabilityOptions = {}) {
246
+ this.query = query;
247
+ this.options = options;
248
+ }
160
249
 
161
- /** NOT NULL flags of the view's columns, in column order. `relid` is the view's `pg_class.oid`. */
162
- notNull(relid: string | number): Promise<boolean[]> {
163
- const key = String(relid);
164
- let cached = this.views.get(key);
165
- if (!cached) {
166
- cached = this.analyze(key);
167
- this.views.set(key, cached);
168
- }
169
- return cached;
170
- }
250
+ /** NOT NULL flags of the view's columns, in column order. `relid` is the view's `pg_class.oid`. */
251
+ notNull(relid: string | number): Promise<boolean[]> {
252
+ const key = String(relid);
253
+ // CREATE OR REPLACE VIEW allows a view that reads itself through others: waiting for its own
254
+ // analysis would never end
255
+ if (this.analyzing.has(key)) return Promise.resolve([]);
256
+ let cached = this.views.get(key);
257
+ if (!cached) {
258
+ this.analyzing.add(key);
259
+ cached = this.analyze(key).finally(() => this.analyzing.delete(key));
260
+ this.views.set(key, cached);
261
+ }
262
+ return cached;
263
+ }
171
264
 
172
- private async analyze(relid: string): Promise<boolean[]> {
173
- const { rows } = await this.query(
174
- `SELECT r.ev_action, c.relname FROM pg_rewrite r JOIN pg_class c ON c.oid = r.ev_class
265
+ private async analyze(relid: string): Promise<boolean[]> {
266
+ const { rows } = await this.query(
267
+ `SELECT r.ev_action, c.relname FROM pg_rewrite r JOIN pg_class c ON c.oid = r.ev_class
175
268
  WHERE r.ev_class = $1 AND r.rulename = '_RETURN'`,
176
- [relid],
177
- );
178
- const row = rows[0] as { ev_action: string; relname: string } | undefined;
179
- if (!row) return [];
180
- const query = node(list(parseNodeTree(row.ev_action))[0]);
181
- if (!query) return [];
182
- const out: boolean[] = [];
183
- for (const te of list(query["targetList"])) {
184
- const entry = node(te);
185
- if (!entry || entry["resjunk"] === "true") continue;
186
- const name = str(entry["resname"]) ?? "";
187
- out.push((await this.expr(entry["expr"], query)) || (this.options.notNull?.(row.relname, name) ?? false));
188
- }
189
- return out;
190
- }
269
+ [relid],
270
+ );
271
+ const row = rows[0] as { ev_action: string; relname: string } | undefined;
272
+ if (!row) return [];
273
+ const query = node(list(parseNodeTree(row.ev_action))[0]);
274
+ if (!query) return [];
275
+ const out: boolean[] = [];
276
+ for (const te of list(query["targetList"])) {
277
+ const entry = node(te);
278
+ if (!entry || entry["resjunk"] === "true") continue;
279
+ const name = str(entry["resname"]) ?? "";
280
+ out.push(
281
+ (await this.expr(entry["expr"], query)) ||
282
+ (this.options.notNull?.(row.relname, name) ?? false),
283
+ );
284
+ }
285
+ return out;
286
+ }
191
287
 
192
- private loadProcs(): Promise<Map<string, Proc>> {
193
- this.procs ??= this.query(
194
- `SELECT p.oid::int8::text AS oid, p.proname AS name, t.typname AS ret, p.pronamespace = 'pg_catalog'::regnamespace AS system
288
+ private loadProcs(): Promise<Map<string, Proc>> {
289
+ this.procs ??= this.query(
290
+ `SELECT p.oid::int8::text AS oid, p.proname AS name, t.typname AS ret, p.pronamespace = 'pg_catalog'::regnamespace AS system
195
291
  FROM pg_proc p JOIN pg_type t ON t.oid = p.prorettype`,
196
- ).then(({ rows }) => new Map((rows as (Proc & { oid: string })[]).map((r) => [r.oid, r])));
197
- return this.procs;
198
- }
292
+ ).then(
293
+ ({ rows }) =>
294
+ new Map((rows as (Proc & { oid: string })[]).map((r) => [r.oid, r])),
295
+ );
296
+ return this.procs;
297
+ }
199
298
 
200
- private loadOperators(): Promise<Map<string, Proc>> {
201
- this.operators ??= this.query(
202
- `SELECT o.oid::int8::text AS oid, o.oprname AS name, '' AS ret, o.oprnamespace = 'pg_catalog'::regnamespace AS system
299
+ private loadOperators(): Promise<Map<string, Proc>> {
300
+ this.operators ??= this.query(
301
+ `SELECT o.oid::int8::text AS oid, o.oprname AS name, '' AS ret, o.oprnamespace = 'pg_catalog'::regnamespace AS system
203
302
  FROM pg_operator o`,
204
- ).then(({ rows }) => new Map((rows as (Proc & { oid: string })[]).map((r) => [r.oid, r])));
205
- return this.operators;
206
- }
303
+ ).then(
304
+ ({ rows }) =>
305
+ new Map((rows as (Proc & { oid: string })[]).map((r) => [r.oid, r])),
306
+ );
307
+ return this.operators;
308
+ }
207
309
 
208
- /** A built-in operator that returns a value for non-NULL operands. */
209
- private async strictOperator(e: Node): Promise<boolean> {
210
- const op = (await this.loadOperators()).get(str(e["opno"]) ?? "");
211
- return op?.system === true && !NULLABLE_OPERATORS.has(op.name);
212
- }
310
+ /** A built-in operator that returns a value for non-NULL operands. */
311
+ private async strictOperator(e: Node): Promise<boolean> {
312
+ const op = (await this.loadOperators()).get(str(e["opno"]) ?? "");
313
+ return op?.system === true && !NULLABLE_OPERATORS.has(op.name);
314
+ }
213
315
 
214
- private columns(relid: string): Promise<Att[]> {
215
- let cached = this.atts.get(relid);
216
- if (!cached) {
217
- cached = this.query(
218
- // NOT NULL: the column's constraint, or a NOT NULL domain anywhere in its domain chain.
219
- `SELECT a.attnum, c.relkind,
316
+ private columns(relid: string): Promise<Att[]> {
317
+ let cached = this.atts.get(relid);
318
+ if (!cached) {
319
+ cached = this.query(
320
+ // NOT NULL: the column's constraint, or a NOT NULL domain anywhere in its domain chain.
321
+ `SELECT a.attnum, c.relkind,
220
322
  a.attnotnull OR coalesce((
221
323
  WITH RECURSIVE chain(oid, nn) AS (
222
324
  SELECT t.oid, t.typnotnull FROM pg_type t WHERE t.oid = a.atttypid AND t.typtype = 'd'
@@ -226,131 +328,171 @@ export class ViewNullability {
226
328
  SELECT bool_or(nn) FROM chain), false) AS notnull
227
329
  FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid
228
330
  WHERE a.attrelid = $1 AND a.attnum > 0 AND NOT a.attisdropped`,
229
- [relid],
230
- ).then(({ rows }) => rows as Att[]);
231
- this.atts.set(relid, cached);
232
- }
233
- return cached;
234
- }
331
+ [relid],
332
+ ).then(({ rows }) => rows as Att[]);
333
+ this.atts.set(relid, cached);
334
+ }
335
+ return cached;
336
+ }
235
337
 
236
- /** Is output column `attno` (1-based) of a (sub)query NOT NULL? */
237
- private async queryColumn(query: Node, attno: number): Promise<boolean> {
238
- if (query["setOperations"]) {
239
- // UNION / INTERSECT / EXCEPT: every arm (a subquery in the range table) must be NOT NULL.
240
- for (const r of list(query["rtable"])) {
241
- const rte = node(r);
242
- const sub = node(rte?.["subquery"]);
243
- if (rte?.["rtekind"] === "1" && sub && !(await this.queryColumn(sub, attno))) return false;
244
- }
245
- return true;
246
- }
247
- const entries = list(query["targetList"]).map(node).filter((t): t is Node => t !== null && t["resjunk"] !== "true");
248
- const te = entries[attno - 1];
249
- return te ? this.expr(te["expr"], query) : false;
250
- }
338
+ /** Is output column `attno` (1-based) of a (sub)query NOT NULL? */
339
+ private async queryColumn(query: Node, attno: number): Promise<boolean> {
340
+ if (query["setOperations"]) {
341
+ // UNION / INTERSECT / EXCEPT: every arm (a subquery in the range table) must be NOT NULL.
342
+ for (const r of list(query["rtable"])) {
343
+ const rte = node(r);
344
+ const sub = node(rte?.["subquery"]);
345
+ if (
346
+ rte?.["rtekind"] === "1" &&
347
+ sub &&
348
+ !(await this.queryColumn(sub, attno))
349
+ )
350
+ return false;
351
+ }
352
+ return true;
353
+ }
354
+ const entries = list(query["targetList"])
355
+ .map(node)
356
+ .filter((t): t is Node => t !== null && t["resjunk"] !== "true");
357
+ const te = entries[attno - 1];
358
+ return te ? this.expr(te["expr"], query) : false;
359
+ }
251
360
 
252
- private async variable(v: Node, query: Node): Promise<boolean> {
253
- // No varnullingrels (PostgreSQL ≤ 15): outer joins are not visible, so nothing is proven.
254
- if (!("varnullingrels" in v) || v["varlevelsup"] !== "0") return false;
255
- if (list(v["varnullingrels"]).length > 1) return false; // `(b 3)`: an outer join can null it
256
- // ROLLUP / CUBE / GROUPING SETS null the grouped columns (PostgreSQL ≤ 17 does not mark them).
257
- if (list(query["groupingSets"]).length > 0) return false;
258
- const rte = node(list(query["rtable"])[Number(v["varno"]) - 1]);
259
- const attno = Number(v["varattno"]);
260
- if (!rte || !(attno > 0)) return false;
261
- switch (rte["rtekind"]) {
262
- case "0": { // relation
263
- const relid = str(rte["relid"])!;
264
- const att = (await this.columns(relid)).find((a) => a.attnum === attno);
265
- if (!att) return false;
266
- if (att.relkind === "v" || att.relkind === "m") return (await this.notNull(relid))[attno - 1] ?? false;
267
- return att.notnull;
268
- }
269
- case "1": { // subquery
270
- const sub = node(rte["subquery"]);
271
- return sub ? this.queryColumn(sub, attno) : false;
272
- }
273
- case "2": // join alias variable
274
- return this.expr(list(rte["joinaliasvars"])[attno - 1] ?? null, query);
275
- case "3": // function in FROM
276
- return this.functionColumn(rte, attno);
277
- case "9": // PostgreSQL 18 GROUP BY expressions
278
- return this.expr(list(rte["groupexprs"])[attno - 1] ?? null, query);
279
- default:
280
- return false;
281
- }
282
- }
361
+ private async variable(v: Node, query: Node): Promise<boolean> {
362
+ // No varnullingrels (PostgreSQL ≤ 15): outer joins are not visible, so nothing is proven.
363
+ if (!("varnullingrels" in v) || v["varlevelsup"] !== "0") return false;
364
+ if (list(v["varnullingrels"]).length > 1) return false; // `(b 3)`: an outer join can null it
365
+ // ROLLUP / CUBE / GROUPING SETS null the grouped columns (PostgreSQL ≤ 17 does not mark them).
366
+ if (list(query["groupingSets"]).length > 0) return false;
367
+ const rte = node(list(query["rtable"])[Number(v["varno"]) - 1]);
368
+ const attno = Number(v["varattno"]);
369
+ if (!rte || !(attno > 0)) return false;
370
+ switch (rte["rtekind"]) {
371
+ case "0": {
372
+ // relation
373
+ const relid = str(rte["relid"])!;
374
+ const att = (await this.columns(relid)).find((a) => a.attnum === attno);
375
+ if (!att) return false;
376
+ if (att.relkind === "v" || att.relkind === "m")
377
+ return (await this.notNull(relid))[attno - 1] ?? false;
378
+ return att.notnull;
379
+ }
380
+ case "1": {
381
+ // subquery
382
+ const sub = node(rte["subquery"]);
383
+ return sub ? this.queryColumn(sub, attno) : false;
384
+ }
385
+ case "2": // join alias variable
386
+ return this.expr(list(rte["joinaliasvars"])[attno - 1] ?? null, query);
387
+ case "3": // function in FROM
388
+ return this.functionColumn(rte, attno);
389
+ case "9": // PostgreSQL 18 GROUP BY expressions
390
+ return this.expr(list(rte["groupexprs"])[attno - 1] ?? null, query);
391
+ default:
392
+ return false;
393
+ }
394
+ }
283
395
 
284
- /** Column `attno` of a single set-returning function in FROM (`WITH ORDINALITY` adds a NOT NULL counter). */
285
- private async functionColumn(rte: Node, attno: number): Promise<boolean> {
286
- const functions = list(rte["functions"]);
287
- const only = node(functions[0]);
288
- if (functions.length !== 1 || !only) return false;
289
- const count = Number(str(only["funccolcount"]));
290
- if (rte["funcordinality"] === "true" && attno === count + 1) return true;
291
- const call = node(only["funcexpr"]);
292
- if (call?._ !== "FUNCEXPR") return false;
293
- const proc = (await this.loadProcs()).get(str(call["funcid"]) ?? "");
294
- return proc?.system === true && (NON_NULL_SRF_COLUMNS[proc.name]?.includes(attno) ?? false);
295
- }
396
+ /** Column `attno` of a single set-returning function in FROM (`WITH ORDINALITY` adds a NOT NULL counter). */
397
+ private async functionColumn(rte: Node, attno: number): Promise<boolean> {
398
+ const functions = list(rte["functions"]);
399
+ const only = node(functions[0]);
400
+ if (functions.length !== 1 || !only) return false;
401
+ const count = Number(str(only["funccolcount"]));
402
+ if (rte["funcordinality"] === "true" && attno === count + 1) return true;
403
+ const call = node(only["funcexpr"]);
404
+ if (call?._ !== "FUNCEXPR") return false;
405
+ const proc = (await this.loadProcs()).get(str(call["funcid"]) ?? "");
406
+ return (
407
+ proc?.system === true &&
408
+ (NON_NULL_SRF_COLUMNS[proc.name]?.includes(attno) ?? false)
409
+ );
410
+ }
296
411
 
297
- private async all(args: NodeValue | undefined, query: Node): Promise<boolean> {
298
- for (const a of list(args)) if (!(await this.expr(a, query))) return false;
299
- return true;
300
- }
412
+ private async all(
413
+ args: NodeValue | undefined,
414
+ query: Node,
415
+ ): Promise<boolean> {
416
+ for (const a of list(args)) if (!(await this.expr(a, query))) return false;
417
+ return true;
418
+ }
301
419
 
302
- private async expr(value: NodeValue | undefined, query: Node): Promise<boolean> {
303
- const e = node(value);
304
- if (!e) return false;
305
- switch (e._) {
306
- case "VAR":
307
- return this.variable(e, query);
308
- case "CONST":
309
- return e["constisnull"] === "false";
310
- case "RELABELTYPE": case "COERCEVIAIO": case "COERCETODOMAIN": case "ARRAYCOERCEEXPR": case "COLLATEEXPR":
311
- return this.expr(e["arg"], query);
312
- case "FUNCEXPR": {
313
- if (e["funcretset"] !== "false") return false;
314
- const proc = (await this.loadProcs()).get(str(e["funcid"]) ?? "");
315
- // format_type(type, typmod) is not strict: a NULL typmod still gives a name.
316
- if (proc?.system && proc.name === "format_type") return this.expr(list(e["args"])[0] ?? null, query);
317
- // Explicit and implicit casts (funcformat 1, 2) keep a non-NULL input non-NULL.
318
- const castLike = e["funcformat"] === "1" || e["funcformat"] === "2";
319
- const known = proc?.system === true && (NON_NULL_FUNCTIONS.has(proc.name) || isNonNullStatFunction(proc.name, proc.ret));
320
- return (castLike || known) && this.all(e["args"], query);
321
- }
322
- case "BOOLEXPR":
323
- return this.all(e["args"], query);
324
- case "OPEXPR":
325
- return (await this.strictOperator(e)) && this.all(e["args"], query);
326
- case "SCALARARRAYOPEXPR": {
327
- // `x = ANY (arr)` is NULL when there is no match and `arr` holds a NULL: only an
328
- // `ARRAY[…]` of NOT NULL elements is safe.
329
- const [left, right] = list(e["args"]);
330
- const array = node(right);
331
- return (await this.strictOperator(e)) && array?._ === "ARRAYEXPR"
332
- && (await this.expr(left ?? null, query)) && this.all(array["elements"], query);
333
- }
334
- case "DISTINCTEXPR": case "NULLTEST": case "BOOLEANTEST": case "ARRAYEXPR": case "ROW":
335
- return true;
336
- case "COALESCEEXPR": case "MINMAXEXPR":
337
- for (const a of list(e["args"])) if (await this.expr(a, query)) return true;
338
- return false;
339
- case "CASEEXPR":
340
- for (const w of list(e["args"])) if (!(await this.expr(node(w)?.["result"], query))) return false;
341
- return this.expr(e["defresult"], query);
342
- case "SUBLINK": // EXISTS (0) and ARRAY (6) sublinks are never NULL
343
- return e["subLinkType"] === "0" || e["subLinkType"] === "6";
344
- case "AGGREF": {
345
- const proc = (await this.loadProcs()).get(str(e["aggfnoid"]) ?? "");
346
- return proc?.system === true && NON_NULL_AGGREGATES.has(proc.name);
347
- }
348
- case "WINDOWFUNC": {
349
- const proc = (await this.loadProcs()).get(str(e["winfnoid"]) ?? "");
350
- return proc?.system === true && NON_NULL_WINDOW_FUNCTIONS.has(proc.name);
351
- }
352
- default:
353
- return false;
354
- }
355
- }
420
+ private async expr(
421
+ value: NodeValue | undefined,
422
+ query: Node,
423
+ ): Promise<boolean> {
424
+ const e = node(value);
425
+ if (!e) return false;
426
+ switch (e._) {
427
+ case "VAR":
428
+ return this.variable(e, query);
429
+ case "CONST":
430
+ return e["constisnull"] === "false";
431
+ case "RELABELTYPE":
432
+ case "COERCEVIAIO":
433
+ case "COERCETODOMAIN":
434
+ case "ARRAYCOERCEEXPR":
435
+ case "COLLATEEXPR":
436
+ return this.expr(e["arg"], query);
437
+ case "FUNCEXPR": {
438
+ if (e["funcretset"] !== "false") return false;
439
+ const proc = (await this.loadProcs()).get(str(e["funcid"]) ?? "");
440
+ // format_type(type, typmod) is not strict: a NULL typmod still gives a name.
441
+ if (proc?.system && proc.name === "format_type")
442
+ return this.expr(list(e["args"])[0] ?? null, query);
443
+ // Explicit and implicit casts (funcformat 1, 2) keep a non-NULL input non-NULL.
444
+ const castLike = e["funcformat"] === "1" || e["funcformat"] === "2";
445
+ const known =
446
+ proc?.system === true &&
447
+ (NON_NULL_FUNCTIONS.has(proc.name) ||
448
+ isNonNullStatFunction(proc.name, proc.ret));
449
+ return (castLike || known) && this.all(e["args"], query);
450
+ }
451
+ case "BOOLEXPR":
452
+ return this.all(e["args"], query);
453
+ case "OPEXPR":
454
+ return (await this.strictOperator(e)) && this.all(e["args"], query);
455
+ case "SCALARARRAYOPEXPR": {
456
+ // `x = ANY (arr)` is NULL when there is no match and `arr` holds a NULL: only an
457
+ // `ARRAY[…]` of NOT NULL elements is safe.
458
+ const [left, right] = list(e["args"]);
459
+ const array = node(right);
460
+ return (
461
+ (await this.strictOperator(e)) &&
462
+ array?._ === "ARRAYEXPR" &&
463
+ (await this.expr(left ?? null, query)) &&
464
+ this.all(array["elements"], query)
465
+ );
466
+ }
467
+ case "DISTINCTEXPR":
468
+ case "NULLTEST":
469
+ case "BOOLEANTEST":
470
+ case "ARRAYEXPR":
471
+ case "ROW":
472
+ return true;
473
+ case "COALESCEEXPR":
474
+ case "MINMAXEXPR":
475
+ for (const a of list(e["args"]))
476
+ if (await this.expr(a, query)) return true;
477
+ return false;
478
+ case "CASEEXPR":
479
+ for (const w of list(e["args"]))
480
+ if (!(await this.expr(node(w)?.["result"], query))) return false;
481
+ return this.expr(e["defresult"], query);
482
+ case "SUBLINK": // EXISTS (0) and ARRAY (6) sublinks are never NULL
483
+ return e["subLinkType"] === "0" || e["subLinkType"] === "6";
484
+ case "AGGREF": {
485
+ const proc = (await this.loadProcs()).get(str(e["aggfnoid"]) ?? "");
486
+ return proc?.system === true && NON_NULL_AGGREGATES.has(proc.name);
487
+ }
488
+ case "WINDOWFUNC": {
489
+ const proc = (await this.loadProcs()).get(str(e["winfnoid"]) ?? "");
490
+ return (
491
+ proc?.system === true && NON_NULL_WINDOW_FUNCTIONS.has(proc.name)
492
+ );
493
+ }
494
+ default:
495
+ return false;
496
+ }
497
+ }
356
498
  }