@supabase/postgrest-typegen 0.4.0 → 0.6.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.
@@ -2,6 +2,7 @@ import {
2
2
  GENERATOR_METADATA_VERSION,
3
3
  type GeneratorMetadata,
4
4
  } from "../types.ts";
5
+ import { resolveRpcFunctions } from "../rpc.ts";
5
6
  import { normalizeRows } from "./normalize.ts";
6
7
  import { listRelationships } from "./relationships.ts";
7
8
  import { COLUMNS_SQL } from "./sql/columns.sql.ts";
@@ -13,7 +14,9 @@ import { PRIMARY_KEYS_SQL } from "./sql/primary_keys.sql.ts";
13
14
  import { SCHEMAS_SQL } from "./sql/schemas.sql.ts";
14
15
  import { TABLES_SQL } from "./sql/table.sql.ts";
15
16
  import { TYPES_SQL } from "./sql/types.sql.ts";
17
+ import { VIEW_DEFINITIONS_SQL } from "./sql/view_definitions.sql.ts";
16
18
  import { VIEWS_SQL } from "./sql/views.sql.ts";
19
+ import { applyViewColumnNullability } from "./view-nullability.ts";
17
20
 
18
21
  /**
19
22
  * Minimal structural database interface. `pg.Pool` / `pg.Client` satisfy it,
@@ -71,6 +74,7 @@ export async function introspect(
71
74
  relationships,
72
75
  functions,
73
76
  types,
77
+ viewDefinitions,
74
78
  ] = await Promise.all([
75
79
  queryRows(SCHEMAS_SQL({ schemaFilter: systemExcludingFilter })),
76
80
  queryRows(TABLES_SQL({ schemaFilter: systemExcludingFilter })),
@@ -82,16 +86,17 @@ export async function introspect(
82
86
  listRelationships(db, systemExcludingFilter),
83
87
  queryRows(FUNCTIONS_SQL({ schemaFilter: systemExcludingFilter })),
84
88
  queryRows(TYPES_SQL),
89
+ queryRows(VIEW_DEFINITIONS_SQL({ schemaFilter: systemExcludingFilter })),
85
90
  ]);
86
91
 
87
- return {
92
+ const metadata = {
88
93
  version: GENERATOR_METADATA_VERSION,
89
94
  schemas,
90
95
  tables,
91
96
  foreignTables,
92
97
  views,
93
98
  materializedViews,
94
- columns,
99
+ columns: applyViewColumnNullability(columns, viewDefinitions),
95
100
  primaryKeys,
96
101
  relationships,
97
102
  functions: functions.filter(
@@ -99,4 +104,5 @@ export async function introspect(
99
104
  ),
100
105
  types,
101
106
  } as GeneratorMetadata;
107
+ return { ...metadata, rpcFunctions: resolveRpcFunctions(metadata) };
102
108
  }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * A value in the text form of a `pg_node_tree`: a scalar token, `null` for
3
+ * `<>`, a list, or a node. Integer, OID and bitmapset lists (`(i 1 2)`,
4
+ * `(o 23)`, `(b 3)`) lose their marker and become lists of scalar tokens.
5
+ */
6
+ export type NodeTreeValue = string | null | NodeTreeValue[] | NodeTreeNode;
7
+
8
+ export interface NodeTreeNode {
9
+ type: string;
10
+ fields: Record<string, NodeTreeValue>;
11
+ }
12
+
13
+ interface Token {
14
+ raw: string;
15
+ text: string;
16
+ }
17
+
18
+ const LIST_MARKERS = new Set(["i", "o", "b", "x"]);
19
+ const WHITESPACE = " \t\n";
20
+ const PUNCTUATION = "(){}";
21
+
22
+ /**
23
+ * Parse the text form of a `pg_node_tree` (for example
24
+ * `pg_rewrite.ev_action::text`), following the tokenizer of PostgreSQL's
25
+ * `read.c`: tokens are separated by whitespace, `(`, `)`, `{` and `}` are
26
+ * tokens of their own, and a backslash escapes the next character.
27
+ */
28
+ export function parseNodeTree(text: string): NodeTreeValue {
29
+ const tokens = tokenize(text);
30
+ let position = 0;
31
+
32
+ const peek = (): Token | undefined => tokens[position];
33
+ const next = (): Token => {
34
+ const token = tokens[position++];
35
+ if (token === undefined) {
36
+ throw new Error("Unexpected end of pg_node_tree");
37
+ }
38
+ return token;
39
+ };
40
+ const isFieldEnd = (token: Token | undefined) =>
41
+ token === undefined || token.raw === "}" || token.raw.startsWith(":");
42
+
43
+ const parseValue = (): NodeTreeValue => {
44
+ const token = next();
45
+ if (token.raw === "{") {
46
+ const type = next().text;
47
+ const fields: Record<string, NodeTreeValue> = {};
48
+ while (peek()?.raw !== "}") {
49
+ const name = next().raw.slice(1);
50
+ fields[name] = isFieldEnd(peek()) ? null : parseValue();
51
+ // Some fields span several tokens, such as a constant's
52
+ // `:constvalue 4 [ 1 0 0 0 ]`; only the first one is kept.
53
+ while (!isFieldEnd(peek())) {
54
+ parseValue();
55
+ }
56
+ }
57
+ next();
58
+ return { type, fields };
59
+ }
60
+ if (token.raw === "(") {
61
+ const items: NodeTreeValue[] = [];
62
+ const first = peek();
63
+ if (first !== undefined && LIST_MARKERS.has(first.raw)) {
64
+ next();
65
+ }
66
+ while (peek()?.raw !== ")") {
67
+ items.push(parseValue());
68
+ }
69
+ next();
70
+ return items;
71
+ }
72
+ if (token.raw === "<>") {
73
+ return null;
74
+ }
75
+ return token.text;
76
+ };
77
+
78
+ const value = parseValue();
79
+ if (position !== tokens.length) {
80
+ throw new Error("Trailing tokens after pg_node_tree");
81
+ }
82
+ return value;
83
+ }
84
+
85
+ function tokenize(text: string): Token[] {
86
+ const tokens: Token[] = [];
87
+ let index = 0;
88
+ while (index < text.length) {
89
+ const character = text.charAt(index);
90
+ if (WHITESPACE.includes(character)) {
91
+ index++;
92
+ continue;
93
+ }
94
+ if (PUNCTUATION.includes(character)) {
95
+ tokens.push({ raw: character, text: character });
96
+ index++;
97
+ continue;
98
+ }
99
+ const start = index;
100
+ let unescaped = "";
101
+ while (index < text.length) {
102
+ const current = text.charAt(index);
103
+ if (current === "\\" && index + 1 < text.length) {
104
+ unescaped += text.charAt(index + 1);
105
+ index += 2;
106
+ continue;
107
+ }
108
+ if (WHITESPACE.includes(current) || PUNCTUATION.includes(current)) {
109
+ break;
110
+ }
111
+ unescaped += current;
112
+ index++;
113
+ }
114
+ tokens.push({ raw: text.slice(start, index), text: unescaped });
115
+ }
116
+ return tokens;
117
+ }
118
+
119
+ export function isNode(
120
+ value: NodeTreeValue | undefined,
121
+ type?: string,
122
+ ): value is NodeTreeNode {
123
+ return (
124
+ typeof value === "object" &&
125
+ value !== null &&
126
+ !Array.isArray(value) &&
127
+ (type === undefined || value.type === type)
128
+ );
129
+ }
@@ -0,0 +1,45 @@
1
+ import type { SchemaFilterProps } from "./common.ts";
2
+
3
+ /**
4
+ * The query tree of every view and materialized view in the filtered schemas,
5
+ * plus every view they read from in any schema, with the `NOT NULL` columns
6
+ * each one references. `inferNonNullViewColumns` resolves view column
7
+ * nullability from it.
8
+ */
9
+ export const VIEW_DEFINITIONS_SQL = (props: SchemaFilterProps) => /* SQL */ `
10
+ WITH RECURSIVE view_closure(oid) AS (
11
+ SELECT c.oid
12
+ FROM pg_class c
13
+ JOIN pg_namespace n ON n.oid = c.relnamespace
14
+ WHERE
15
+ ${props.schemaFilter ? `n.nspname ${props.schemaFilter} AND` : ""}
16
+ c.relkind IN ('v', 'm')
17
+ UNION
18
+ SELECT dep.refobjid
19
+ FROM view_closure vc
20
+ JOIN pg_rewrite r ON r.ev_class = vc.oid AND r.rulename = '_RETURN'
21
+ JOIN pg_depend dep ON dep.classid = 'pg_rewrite' :: regclass
22
+ AND dep.objid = r.oid
23
+ AND dep.refclassid = 'pg_class' :: regclass
24
+ JOIN pg_class rc ON rc.oid = dep.refobjid AND rc.relkind IN ('v', 'm')
25
+ )
26
+ SELECT
27
+ r.ev_class :: int8 AS view_id,
28
+ r.ev_action :: text AS definition,
29
+ COALESCE(
30
+ (
31
+ SELECT json_agg(json_build_array(dep.refobjid :: int8, dep.refobjsubid))
32
+ FROM pg_depend dep
33
+ JOIN pg_attribute a ON a.attrelid = dep.refobjid AND a.attnum = dep.refobjsubid
34
+ JOIN pg_type t ON t.oid = a.atttypid
35
+ WHERE dep.classid = 'pg_rewrite' :: regclass
36
+ AND dep.objid = r.oid
37
+ AND dep.refclassid = 'pg_class' :: regclass
38
+ AND dep.refobjsubid > 0
39
+ AND (a.attnotnull OR t.typtype = 'd' AND t.typnotnull)
40
+ ),
41
+ '[]'
42
+ ) AS non_null_columns
43
+ FROM view_closure vc
44
+ JOIN pg_rewrite r ON r.ev_class = vc.oid AND r.rulename = '_RETURN'
45
+ `;
@@ -0,0 +1,243 @@
1
+ import type { PostgresColumn } from "../types.ts";
2
+ import {
3
+ isNode,
4
+ type NodeTreeNode,
5
+ type NodeTreeValue,
6
+ parseNodeTree,
7
+ } from "./node-tree.ts";
8
+
9
+ /** A row of `VIEW_DEFINITIONS_SQL`. */
10
+ export interface ViewDefinitionRow {
11
+ view_id: number | string;
12
+ definition: string;
13
+ non_null_columns: [number | string, number | string][];
14
+ }
15
+
16
+ const RTE_RELATION = "0";
17
+ const RTE_SUBQUERY = "1";
18
+ const RTE_JOIN = "2";
19
+ const RTE_CTE = "6";
20
+ const RTE_GROUP = "9";
21
+
22
+ const JOIN_INNER = "0";
23
+ const JOIN_LEFT = "1";
24
+ const JOIN_RIGHT = "3";
25
+
26
+ const NOT_NULL_DIRECTIVE = /^\s*@notNull\s*$/m;
27
+
28
+ /**
29
+ * Mark view and materialized view columns as not nullable when PostgreSQL can
30
+ * prove it from the view's query tree (see {@link inferNonNullViewColumns}) or
31
+ * when the column comment has an `@notNull` line. PostgreSQL never sets
32
+ * `attnotnull` on view columns, so without this every one of them is nullable.
33
+ */
34
+ export function applyViewColumnNullability<T extends PostgresColumn>(
35
+ columns: T[],
36
+ rows: ViewDefinitionRow[],
37
+ ): T[] {
38
+ const viewIds = new Set(rows.map((row) => String(row.view_id)));
39
+ const nonNull = inferNonNullViewColumns(rows);
40
+ return columns.map((column) =>
41
+ column.is_nullable &&
42
+ viewIds.has(String(column.table_id)) &&
43
+ (nonNull.has(column.id) || NOT_NULL_DIRECTIVE.test(column.comment ?? ""))
44
+ ? { ...column, is_nullable: false }
45
+ : column,
46
+ );
47
+ }
48
+
49
+ /**
50
+ * The ids (`<view oid>.<attnum>`) of the view columns that can never be null.
51
+ * A column qualifies when it is a plain reference to a `NOT NULL` column, or
52
+ * to a view column that qualifies, followed through joins, subqueries,
53
+ * non-recursive CTEs and `GROUP BY`, and nothing on the way puts it on the
54
+ * nullable side of an outer join, in a set operation or under grouping sets.
55
+ * The outer-join check walks the join tree instead of reading
56
+ * `varnullingrels`, which PostgreSQL 15 does not have.
57
+ */
58
+ function inferNonNullViewColumns(rows: ViewDefinitionRow[]): Set<string> {
59
+ const definitions = new Map(
60
+ rows.map((row) => [String(row.view_id), row.definition]),
61
+ );
62
+ const nonNullBaseColumns = new Set(
63
+ rows.flatMap((row) =>
64
+ row.non_null_columns.map(([relation, attnum]) => `${relation}.${attnum}`),
65
+ ),
66
+ );
67
+ const resolved = new Map<string, Set<number>>();
68
+ const nullableIndexesByQuery = new Map<NodeTreeNode, Set<number>>();
69
+ const nullableIndexesOf = (query: NodeTreeNode): Set<number> => {
70
+ let indexes = nullableIndexesByQuery.get(query);
71
+ if (indexes === undefined) {
72
+ indexes = nullableRangeTableIndexes(query);
73
+ nullableIndexesByQuery.set(query, indexes);
74
+ }
75
+ return indexes;
76
+ };
77
+
78
+ const nonNullColumnsOfView = (viewId: string): Set<number> => {
79
+ const cached = resolved.get(viewId);
80
+ if (cached !== undefined) {
81
+ return cached;
82
+ }
83
+ const columns = new Set<number>();
84
+ resolved.set(viewId, columns);
85
+ try {
86
+ const tree = parseNodeTree(definitions.get(viewId) ?? "");
87
+ const query = Array.isArray(tree) ? tree[0] : tree;
88
+ if (isNode(query, "QUERY") && allowsNonNull(query)) {
89
+ for (const entry of list(query.fields.targetList)) {
90
+ if (
91
+ isNode(entry, "TARGETENTRY") &&
92
+ entry.fields.resjunk !== "true" &&
93
+ isNonNullExpression(entry.fields.expr, [query])
94
+ ) {
95
+ columns.add(Number(entry.fields.resno));
96
+ }
97
+ }
98
+ }
99
+ } catch {
100
+ columns.clear();
101
+ }
102
+ return columns;
103
+ };
104
+
105
+ const isNonNullColumn = (relation: string, attnum: number): boolean =>
106
+ definitions.has(relation)
107
+ ? nonNullColumnsOfView(relation).has(attnum)
108
+ : nonNullBaseColumns.has(`${relation}.${attnum}`);
109
+
110
+ const isNonNullExpression = (
111
+ expression: NodeTreeValue | undefined,
112
+ queries: NodeTreeNode[],
113
+ ): boolean => {
114
+ const query = queries.at(-1);
115
+ if (
116
+ query === undefined ||
117
+ !isNode(expression, "VAR") ||
118
+ expression.fields.varlevelsup !== "0"
119
+ ) {
120
+ return false;
121
+ }
122
+ const varno = Number(expression.fields.varno);
123
+ const attnum = Number(expression.fields.varattno);
124
+ if (attnum <= 0 || nullableIndexesOf(query).has(varno)) {
125
+ return false;
126
+ }
127
+ const entry = list(query.fields.rtable)[varno - 1];
128
+ if (!isNode(entry, "RANGETBLENTRY")) {
129
+ return false;
130
+ }
131
+ switch (entry.fields.rtekind) {
132
+ case RTE_RELATION:
133
+ return (
134
+ typeof entry.fields.relid === "string" &&
135
+ isNonNullColumn(entry.fields.relid, attnum)
136
+ );
137
+ case RTE_SUBQUERY:
138
+ return isNonNullOutput(entry.fields.subquery, attnum, queries);
139
+ case RTE_JOIN:
140
+ return isNonNullExpression(
141
+ list(entry.fields.joinaliasvars)[attnum - 1],
142
+ queries,
143
+ );
144
+ case RTE_GROUP:
145
+ return isNonNullExpression(
146
+ list(entry.fields.groupexprs)[attnum - 1],
147
+ queries,
148
+ );
149
+ case RTE_CTE: {
150
+ const levelsUp = Number(entry.fields.ctelevelsup);
151
+ const owner = queries.at(-1 - levelsUp);
152
+ const cte = list(owner?.fields.cteList).find(
153
+ (candidate) =>
154
+ isNode(candidate, "COMMONTABLEEXPR") &&
155
+ candidate.fields.ctename === entry.fields.ctename,
156
+ );
157
+ return (
158
+ isNode(cte) &&
159
+ cte.fields.cterecursive !== "true" &&
160
+ isNonNullOutput(
161
+ cte.fields.ctequery,
162
+ attnum,
163
+ queries.slice(0, queries.length - levelsUp),
164
+ )
165
+ );
166
+ }
167
+ default:
168
+ return false;
169
+ }
170
+ };
171
+
172
+ const isNonNullOutput = (
173
+ subquery: NodeTreeValue | undefined,
174
+ resno: number,
175
+ queries: NodeTreeNode[],
176
+ ): boolean => {
177
+ if (!isNode(subquery, "QUERY") || !allowsNonNull(subquery)) {
178
+ return false;
179
+ }
180
+ const entry = list(subquery.fields.targetList).find(
181
+ (candidate) =>
182
+ isNode(candidate, "TARGETENTRY") &&
183
+ Number(candidate.fields.resno) === resno,
184
+ );
185
+ return (
186
+ isNode(entry) &&
187
+ isNonNullExpression(entry.fields.expr, [...queries, subquery])
188
+ );
189
+ };
190
+
191
+ const result = new Set<string>();
192
+ for (const viewId of definitions.keys()) {
193
+ for (const attnum of nonNullColumnsOfView(viewId)) {
194
+ result.add(`${viewId}.${attnum}`);
195
+ }
196
+ }
197
+ return result;
198
+ }
199
+
200
+ function allowsNonNull(query: NodeTreeNode): boolean {
201
+ return (
202
+ query.fields.setOperations === null &&
203
+ list(query.fields.groupingSets).length === 0
204
+ );
205
+ }
206
+
207
+ /**
208
+ * The range table indexes that sit on the nullable side of an outer join in
209
+ * the query's join tree, including the indexes of nested joins themselves.
210
+ */
211
+ function nullableRangeTableIndexes(query: NodeTreeNode): Set<number> {
212
+ const nullable = new Set<number>();
213
+ const visit = (node: NodeTreeValue | undefined, isNullable: boolean) => {
214
+ if (isNode(node, "RANGETBLREF")) {
215
+ if (isNullable) {
216
+ nullable.add(Number(node.fields.rtindex));
217
+ }
218
+ } else if (isNode(node, "FROMEXPR")) {
219
+ for (const item of list(node.fields.fromlist)) {
220
+ visit(item, isNullable);
221
+ }
222
+ } else if (isNode(node, "JOINEXPR")) {
223
+ const joinType = node.fields.jointype;
224
+ if (isNullable) {
225
+ nullable.add(Number(node.fields.rtindex));
226
+ }
227
+ visit(
228
+ node.fields.larg,
229
+ isNullable || (joinType !== JOIN_INNER && joinType !== JOIN_LEFT),
230
+ );
231
+ visit(
232
+ node.fields.rarg,
233
+ isNullable || (joinType !== JOIN_INNER && joinType !== JOIN_RIGHT),
234
+ );
235
+ }
236
+ };
237
+ visit(query.fields.jointree, false);
238
+ return nullable;
239
+ }
240
+
241
+ function list(value: NodeTreeValue | undefined): NodeTreeValue[] {
242
+ return Array.isArray(value) ? value : [];
243
+ }