@supabase/postgrest-typegen 0.5.0 → 0.7.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.
@@ -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
+ }
package/src/rpc.ts CHANGED
@@ -35,6 +35,9 @@ type InputArg = PostgresFunction["args"][number] & {
35
35
 
36
36
  type Candidate = { fn: PostgresFunction; inArgs: InputArg[] };
37
37
 
38
+ /** A NUL separator cannot occur in an identifier, so names with dots never collide. */
39
+ const qualifiedKey = (schema: string, name: string) => `${schema}\0${name}`;
40
+
38
41
  export function resolveRpcFunctions(
39
42
  metadata: GeneratorMetadata,
40
43
  ): RpcFunction[] {
@@ -48,13 +51,20 @@ export function resolveRpcFunctions(
48
51
  )
49
52
  .map((type) => [type.id, type]),
50
53
  );
54
+ const relations = [
55
+ ...metadata.tables,
56
+ ...metadata.foreignTables,
57
+ ...metadata.views,
58
+ ...metadata.materializedViews,
59
+ ];
51
60
  const relationsById = new Map(
52
- [
53
- ...metadata.tables,
54
- ...metadata.foreignTables,
55
- ...metadata.views,
56
- ...metadata.materializedViews,
57
- ].map((relation) => [relation.id, relation]),
61
+ relations.map((relation) => [relation.id, relation]),
62
+ );
63
+ const relationsBySchemaName = new Map(
64
+ relations.map((relation) => [
65
+ qualifiedKey(relation.schema, relation.name),
66
+ relation,
67
+ ]),
58
68
  );
59
69
  const columnNamesByRelationId = new Map<number, Set<string>>();
60
70
  for (const column of metadata.columns) {
@@ -96,14 +106,13 @@ export function resolveRpcFunctions(
96
106
  // the rendered argument list may still name the relation.
97
107
  const computedFieldOf = ({ fn, inArgs }: Candidate): number | null => {
98
108
  const soleInArg = inArgs.length === 1 ? inArgs[0] : undefined;
99
- const argType = soleInArg && relationTypesById.get(soleInArg.type_id);
109
+ if (!soleInArg) return null;
110
+ const argType = relationTypesById.get(soleInArg.type_id);
100
111
  const relation = argType
101
112
  ? relationsById.get(argType.type_relation_id)
102
- : [...relationsById.values()].find(
103
- (relation) =>
104
- relation.schema === fn.schema &&
105
- relation.name === fn.argument_types,
106
- );
113
+ : typesById.has(soleInArg.type_id)
114
+ ? undefined
115
+ : relationsBySchemaName.get(qualifiedKey(fn.schema, fn.argument_types));
107
116
  if (!relation || relation.schema !== fn.schema) return null;
108
117
  if (columnNamesByRelationId.get(relation.id)?.has(fn.name)) return null;
109
118
  return relation.id;
@@ -178,13 +187,14 @@ export function resolveRpcFunctions(
178
187
  }
179
188
  }
180
189
  }
181
- // A sole unnamed row parameter makes the function a computed field, which
182
- // PostgREST only exposes under `/rpc` when it also returns rows.
190
+ // PostgREST passes a sole unnamed parameter only when it is json, jsonb or
191
+ // text, so a function taking a row this way is a computed field or a
192
+ // computed relationship, reachable by embedding it from its relation and
193
+ // never through `/rpc`, whatever it returns.
183
194
  if (
184
195
  soleInArg &&
185
196
  soleInArg.name === "" &&
186
- relationTypesById.has(soleInArg.type_id) &&
187
- returnRelationName(fn) === null
197
+ relationTypesById.has(soleInArg.type_id)
188
198
  ) {
189
199
  return `the function ${fn.schema}.${fn.name} with parameter or with a single unnamed json/jsonb parameter, but no matches were found in the schema cache`;
190
200
  }
@@ -205,9 +215,7 @@ export function resolveRpcFunctions(
205
215
  ...typeReference(arg.type_id),
206
216
  has_default: arg.has_default === true,
207
217
  relation_id:
208
- relationId !== undefined &&
209
- relationId !== null &&
210
- relationsById.has(relationId)
218
+ relationId !== undefined && relationsById.has(relationId)
211
219
  ? relationId
212
220
  : null,
213
221
  };
@@ -266,7 +274,7 @@ export function resolveRpcFunctions(
266
274
  );
267
275
  const candidate = { fn, inArgs };
268
276
  if (!isCallable(candidate)) continue;
269
- const key = `${fn.schema}.${fn.name}`;
277
+ const key = qualifiedKey(fn.schema, fn.name);
270
278
  let group = groups.get(key);
271
279
  if (!group) {
272
280
  group = [];
package/src/types.ts CHANGED
@@ -205,8 +205,8 @@ const rpcFunctionReturnsSchema = rpcTypeReferenceSchema.merge({
205
205
  */
206
206
  relation_id: "number | null",
207
207
  /**
208
- * The columns of a `RETURNS TABLE (…)` or `OUT` parameter result, in name
209
- * order; `null` for every other return type.
208
+ * The columns of a `RETURNS TABLE (…)` result, in name order; `null` for
209
+ * every other return type, including a record built from `OUT` parameters.
210
210
  */
211
211
  columns: rpcTypeReferenceSchema.merge({ name: "string" }).array().or("null"),
212
212
  });
@@ -245,9 +245,10 @@ const rpcFunctionOverloadSchema = type({
245
245
  computed_field_of: "number | null",
246
246
  /**
247
247
  * Why PostgREST cannot resolve a call to this overload, when it cannot: the
248
- * function conflicts with another overload, or it is a computed field that
249
- * returns no rows. Generators surface the message instead of a callable
250
- * signature.
248
+ * function conflicts with another overload, or its sole parameter is an
249
+ * unnamed row, which PostgREST never matches under `/rpc` (the function is
250
+ * a computed field or computed relationship, reached by embedding).
251
+ * Generators surface the message instead of a callable signature.
251
252
  */
252
253
  error: "string | null",
253
254
  });