@supabase/postgrest-typegen 0.5.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.
package/README.md CHANGED
@@ -89,8 +89,10 @@ A type reference carries `type_id`, `format` (the type's name, spelled like
89
89
  `PostgresColumn.format`) and `type_schema`, so a generator maps it with the
90
90
  same table it uses for columns. An overload's `error` is the message the
91
91
  TypeScript generator emits as the `Returns` type of an unresolvable overload:
92
- two overloads PostgREST cannot tell apart, or a computed field that returns no
93
- rows. Overloads are ordered by argument list, then by return type; the
92
+ two overloads PostgREST cannot tell apart, or a sole unnamed row parameter,
93
+ which PostgREST never matches under `/rpc` (such a function is a computed
94
+ field or computed relationship, reached by embedding it from its relation).
95
+ Overloads are ordered by argument list, then by return type; the
94
96
  TypeScript generator emits them in that order.
95
97
 
96
98
  `resolveRpcFunctions` is pure and deterministic, so a custom producer attaches
@@ -98,6 +100,38 @@ it the same way, and a document without the collection (produced before it
98
100
  existed) still generates: the TypeScript generator falls back to running the
99
101
  pass itself.
100
102
 
103
+ ### View column nullability
104
+
105
+ PostgreSQL never marks view columns `NOT NULL`, so `introspect()` works out
106
+ `is_nullable` for view and materialized view columns from the view's query
107
+ tree. A column is not nullable when it is a plain reference to a `NOT NULL`
108
+ column (directly, through another view, a subquery, a non-recursive CTE, a
109
+ join alias or a `GROUP BY` key) and nothing on the way puts it on the nullable
110
+ side of an outer join, in a set operation such as `UNION` or under
111
+ `GROUPING SETS`/`ROLLUP`/`CUBE`. This works the same on every supported
112
+ PostgreSQL version. Expressions stay nullable, even ones that cannot return
113
+ null.
114
+
115
+ For a column the catalog cannot prove non-null, such as `coalesce(x, 0)` or
116
+ `count(*)`, add a line containing only `@notNull` to its comment:
117
+
118
+ ```sql
119
+ comment on column my_view.total is '@notNull';
120
+ ```
121
+
122
+ The directive is honored on view and materialized view columns only, and the
123
+ comment is passed through unchanged.
124
+
125
+ The inference describes the rows a query reads, so a few values can still be
126
+ null where the type says otherwise:
127
+
128
+ - Rows returned by a write through an `INSTEAD OF` trigger are the trigger's
129
+ `NEW`, so a column the trigger leaves unset comes back null.
130
+ - PostgreSQL does not enforce `NOT NULL` inside composite values, so a column
131
+ whose type is a view's row type can hold a row with null fields.
132
+ - A materialized view keeps the rows of its last refresh, including nulls from
133
+ before its source column became `NOT NULL`.
134
+
101
135
  ### Runtime validation (opt-in)
102
136
 
103
137
  `GeneratorMetadata` is backed by an [ArkType](https://arktype.io) schema, so a
@@ -29,7 +29,6 @@ const defaultFormat = async (code) => {
29
29
  */
30
30
  export const generateTypescript = async (metadata, opts = {}) => {
31
31
  const { schemas, tables, foreignTables, views, materializedViews, columns, relationships, types, } = metadata;
32
- // Documents predating the collection (still valid version 1) resolve it here.
33
32
  const rpcFunctions = metadata.rpcFunctions ?? resolveRpcFunctions(metadata);
34
33
  const { detectOneToOneRelationships = false, postgrestVersion, defaultSchema = "public", format = defaultFormat, } = opts;
35
34
  const resolvedDefaultSchema = schemas.some(({ name }) => name === defaultSchema) || !schemas[0]
@@ -177,7 +176,10 @@ export const generateTypescript = async (metadata, opts = {}) => {
177
176
  // Without that metadata PostgREST still returns an array, so the collapse would make
178
177
  // the type diverge from the actual response.
179
178
  const collapsibleSingleRow = setof_options?.is_one_to_one === true;
180
- return `${returnType}${overload.is_set_returning && !collapsibleSingleRow ? "[]" : ""}
179
+ const isArray = overload.error === null &&
180
+ overload.is_set_returning &&
181
+ !collapsibleSingleRow;
182
+ return `${returnType}${isArray ? "[]" : ""}
181
183
  ${setofOptionsInfo}`;
182
184
  };
183
185
  const getFunctionReturnType = (schema, { returns }) => {
@@ -11,7 +11,9 @@ import { PRIMARY_KEYS_SQL } from "./sql/primary_keys.sql.js";
11
11
  import { SCHEMAS_SQL } from "./sql/schemas.sql.js";
12
12
  import { TABLES_SQL } from "./sql/table.sql.js";
13
13
  import { TYPES_SQL } from "./sql/types.sql.js";
14
+ import { VIEW_DEFINITIONS_SQL } from "./sql/view_definitions.sql.js";
14
15
  import { VIEWS_SQL } from "./sql/views.sql.js";
16
+ import { applyViewColumnNullability } from "./view-nullability.js";
15
17
  /**
16
18
  * Introspect a database into the `GeneratorMetadata` contract.
17
19
  *
@@ -32,7 +34,7 @@ export async function introspect(db, opts = {}) {
32
34
  // introspection queries under Promise.all. A pooled Queryable runs them in
33
35
  // parallel; a single-connection one pipelines them, which is no worse than
34
36
  // awaiting each in turn.
35
- const [schemas, tables, foreignTables, views, materializedViews, columns, primaryKeys, relationships, functions, types,] = await Promise.all([
37
+ const [schemas, tables, foreignTables, views, materializedViews, columns, primaryKeys, relationships, functions, types, viewDefinitions,] = await Promise.all([
36
38
  queryRows(SCHEMAS_SQL({ schemaFilter: systemExcludingFilter })),
37
39
  queryRows(TABLES_SQL({ schemaFilter: systemExcludingFilter })),
38
40
  queryRows(FOREIGN_TABLES_SQL({ schemaFilter: plainFilter })),
@@ -43,6 +45,7 @@ export async function introspect(db, opts = {}) {
43
45
  listRelationships(db, systemExcludingFilter),
44
46
  queryRows(FUNCTIONS_SQL({ schemaFilter: systemExcludingFilter })),
45
47
  queryRows(TYPES_SQL),
48
+ queryRows(VIEW_DEFINITIONS_SQL({ schemaFilter: systemExcludingFilter })),
46
49
  ]);
47
50
  const metadata = {
48
51
  version: GENERATOR_METADATA_VERSION,
@@ -51,7 +54,7 @@ export async function introspect(db, opts = {}) {
51
54
  foreignTables,
52
55
  views,
53
56
  materializedViews,
54
- columns,
57
+ columns: applyViewColumnNullability(columns, viewDefinitions),
55
58
  primaryKeys,
56
59
  relationships,
57
60
  functions: functions.filter(({ return_type }) => !["trigger", "event_trigger"].includes(return_type)),
@@ -0,0 +1,18 @@
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
+ export interface NodeTreeNode {
8
+ type: string;
9
+ fields: Record<string, NodeTreeValue>;
10
+ }
11
+ /**
12
+ * Parse the text form of a `pg_node_tree` (for example
13
+ * `pg_rewrite.ev_action::text`), following the tokenizer of PostgreSQL's
14
+ * `read.c`: tokens are separated by whitespace, `(`, `)`, `{` and `}` are
15
+ * tokens of their own, and a backslash escapes the next character.
16
+ */
17
+ export declare function parseNodeTree(text: string): NodeTreeValue;
18
+ export declare function isNode(value: NodeTreeValue | undefined, type?: string): value is NodeTreeNode;
@@ -0,0 +1,100 @@
1
+ const LIST_MARKERS = new Set(["i", "o", "b", "x"]);
2
+ const WHITESPACE = " \t\n";
3
+ const PUNCTUATION = "(){}";
4
+ /**
5
+ * Parse the text form of a `pg_node_tree` (for example
6
+ * `pg_rewrite.ev_action::text`), following the tokenizer of PostgreSQL's
7
+ * `read.c`: tokens are separated by whitespace, `(`, `)`, `{` and `}` are
8
+ * tokens of their own, and a backslash escapes the next character.
9
+ */
10
+ export function parseNodeTree(text) {
11
+ const tokens = tokenize(text);
12
+ let position = 0;
13
+ const peek = () => tokens[position];
14
+ const next = () => {
15
+ const token = tokens[position++];
16
+ if (token === undefined) {
17
+ throw new Error("Unexpected end of pg_node_tree");
18
+ }
19
+ return token;
20
+ };
21
+ const isFieldEnd = (token) => token === undefined || token.raw === "}" || token.raw.startsWith(":");
22
+ const parseValue = () => {
23
+ const token = next();
24
+ if (token.raw === "{") {
25
+ const type = next().text;
26
+ const fields = {};
27
+ while (peek()?.raw !== "}") {
28
+ const name = next().raw.slice(1);
29
+ fields[name] = isFieldEnd(peek()) ? null : parseValue();
30
+ // Some fields span several tokens, such as a constant's
31
+ // `:constvalue 4 [ 1 0 0 0 ]`; only the first one is kept.
32
+ while (!isFieldEnd(peek())) {
33
+ parseValue();
34
+ }
35
+ }
36
+ next();
37
+ return { type, fields };
38
+ }
39
+ if (token.raw === "(") {
40
+ const items = [];
41
+ const first = peek();
42
+ if (first !== undefined && LIST_MARKERS.has(first.raw)) {
43
+ next();
44
+ }
45
+ while (peek()?.raw !== ")") {
46
+ items.push(parseValue());
47
+ }
48
+ next();
49
+ return items;
50
+ }
51
+ if (token.raw === "<>") {
52
+ return null;
53
+ }
54
+ return token.text;
55
+ };
56
+ const value = parseValue();
57
+ if (position !== tokens.length) {
58
+ throw new Error("Trailing tokens after pg_node_tree");
59
+ }
60
+ return value;
61
+ }
62
+ function tokenize(text) {
63
+ const tokens = [];
64
+ let index = 0;
65
+ while (index < text.length) {
66
+ const character = text.charAt(index);
67
+ if (WHITESPACE.includes(character)) {
68
+ index++;
69
+ continue;
70
+ }
71
+ if (PUNCTUATION.includes(character)) {
72
+ tokens.push({ raw: character, text: character });
73
+ index++;
74
+ continue;
75
+ }
76
+ const start = index;
77
+ let unescaped = "";
78
+ while (index < text.length) {
79
+ const current = text.charAt(index);
80
+ if (current === "\\" && index + 1 < text.length) {
81
+ unescaped += text.charAt(index + 1);
82
+ index += 2;
83
+ continue;
84
+ }
85
+ if (WHITESPACE.includes(current) || PUNCTUATION.includes(current)) {
86
+ break;
87
+ }
88
+ unescaped += current;
89
+ index++;
90
+ }
91
+ tokens.push({ raw: text.slice(start, index), text: unescaped });
92
+ }
93
+ return tokens;
94
+ }
95
+ export function isNode(value, type) {
96
+ return (typeof value === "object" &&
97
+ value !== null &&
98
+ !Array.isArray(value) &&
99
+ (type === undefined || value.type === type));
100
+ }
@@ -0,0 +1,8 @@
1
+ import type { SchemaFilterProps } from "./common.ts";
2
+ /**
3
+ * The query tree of every view and materialized view in the filtered schemas,
4
+ * plus every view they read from in any schema, with the `NOT NULL` columns
5
+ * each one references. `inferNonNullViewColumns` resolves view column
6
+ * nullability from it.
7
+ */
8
+ export declare const VIEW_DEFINITIONS_SQL: (props: SchemaFilterProps) => string;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The query tree of every view and materialized view in the filtered schemas,
3
+ * plus every view they read from in any schema, with the `NOT NULL` columns
4
+ * each one references. `inferNonNullViewColumns` resolves view column
5
+ * nullability from it.
6
+ */
7
+ export const VIEW_DEFINITIONS_SQL = (props) => /* SQL */ `
8
+ WITH RECURSIVE view_closure(oid) AS (
9
+ SELECT c.oid
10
+ FROM pg_class c
11
+ JOIN pg_namespace n ON n.oid = c.relnamespace
12
+ WHERE
13
+ ${props.schemaFilter ? `n.nspname ${props.schemaFilter} AND` : ""}
14
+ c.relkind IN ('v', 'm')
15
+ UNION
16
+ SELECT dep.refobjid
17
+ FROM view_closure vc
18
+ JOIN pg_rewrite r ON r.ev_class = vc.oid AND r.rulename = '_RETURN'
19
+ JOIN pg_depend dep ON dep.classid = 'pg_rewrite' :: regclass
20
+ AND dep.objid = r.oid
21
+ AND dep.refclassid = 'pg_class' :: regclass
22
+ JOIN pg_class rc ON rc.oid = dep.refobjid AND rc.relkind IN ('v', 'm')
23
+ )
24
+ SELECT
25
+ r.ev_class :: int8 AS view_id,
26
+ r.ev_action :: text AS definition,
27
+ COALESCE(
28
+ (
29
+ SELECT json_agg(json_build_array(dep.refobjid :: int8, dep.refobjsubid))
30
+ FROM pg_depend dep
31
+ JOIN pg_attribute a ON a.attrelid = dep.refobjid AND a.attnum = dep.refobjsubid
32
+ JOIN pg_type t ON t.oid = a.atttypid
33
+ WHERE dep.classid = 'pg_rewrite' :: regclass
34
+ AND dep.objid = r.oid
35
+ AND dep.refclassid = 'pg_class' :: regclass
36
+ AND dep.refobjsubid > 0
37
+ AND (a.attnotnull OR t.typtype = 'd' AND t.typnotnull)
38
+ ),
39
+ '[]'
40
+ ) AS non_null_columns
41
+ FROM view_closure vc
42
+ JOIN pg_rewrite r ON r.ev_class = vc.oid AND r.rulename = '_RETURN'
43
+ `;
@@ -0,0 +1,14 @@
1
+ import type { PostgresColumn } from "../types.ts";
2
+ /** A row of `VIEW_DEFINITIONS_SQL`. */
3
+ export interface ViewDefinitionRow {
4
+ view_id: number | string;
5
+ definition: string;
6
+ non_null_columns: [number | string, number | string][];
7
+ }
8
+ /**
9
+ * Mark view and materialized view columns as not nullable when PostgreSQL can
10
+ * prove it from the view's query tree (see {@link inferNonNullViewColumns}) or
11
+ * when the column comment has an `@notNull` line. PostgreSQL never sets
12
+ * `attnotnull` on view columns, so without this every one of them is nullable.
13
+ */
14
+ export declare function applyViewColumnNullability<T extends PostgresColumn>(columns: T[], rows: ViewDefinitionRow[]): T[];
@@ -0,0 +1,167 @@
1
+ import { isNode, parseNodeTree, } from "./node-tree.js";
2
+ const RTE_RELATION = "0";
3
+ const RTE_SUBQUERY = "1";
4
+ const RTE_JOIN = "2";
5
+ const RTE_CTE = "6";
6
+ const RTE_GROUP = "9";
7
+ const JOIN_INNER = "0";
8
+ const JOIN_LEFT = "1";
9
+ const JOIN_RIGHT = "3";
10
+ const NOT_NULL_DIRECTIVE = /^\s*@notNull\s*$/m;
11
+ /**
12
+ * Mark view and materialized view columns as not nullable when PostgreSQL can
13
+ * prove it from the view's query tree (see {@link inferNonNullViewColumns}) or
14
+ * when the column comment has an `@notNull` line. PostgreSQL never sets
15
+ * `attnotnull` on view columns, so without this every one of them is nullable.
16
+ */
17
+ export function applyViewColumnNullability(columns, rows) {
18
+ const viewIds = new Set(rows.map((row) => String(row.view_id)));
19
+ const nonNull = inferNonNullViewColumns(rows);
20
+ return columns.map((column) => column.is_nullable &&
21
+ viewIds.has(String(column.table_id)) &&
22
+ (nonNull.has(column.id) || NOT_NULL_DIRECTIVE.test(column.comment ?? ""))
23
+ ? { ...column, is_nullable: false }
24
+ : column);
25
+ }
26
+ /**
27
+ * The ids (`<view oid>.<attnum>`) of the view columns that can never be null.
28
+ * A column qualifies when it is a plain reference to a `NOT NULL` column, or
29
+ * to a view column that qualifies, followed through joins, subqueries,
30
+ * non-recursive CTEs and `GROUP BY`, and nothing on the way puts it on the
31
+ * nullable side of an outer join, in a set operation or under grouping sets.
32
+ * The outer-join check walks the join tree instead of reading
33
+ * `varnullingrels`, which PostgreSQL 15 does not have.
34
+ */
35
+ function inferNonNullViewColumns(rows) {
36
+ const definitions = new Map(rows.map((row) => [String(row.view_id), row.definition]));
37
+ const nonNullBaseColumns = new Set(rows.flatMap((row) => row.non_null_columns.map(([relation, attnum]) => `${relation}.${attnum}`)));
38
+ const resolved = new Map();
39
+ const nullableIndexesByQuery = new Map();
40
+ const nullableIndexesOf = (query) => {
41
+ let indexes = nullableIndexesByQuery.get(query);
42
+ if (indexes === undefined) {
43
+ indexes = nullableRangeTableIndexes(query);
44
+ nullableIndexesByQuery.set(query, indexes);
45
+ }
46
+ return indexes;
47
+ };
48
+ const nonNullColumnsOfView = (viewId) => {
49
+ const cached = resolved.get(viewId);
50
+ if (cached !== undefined) {
51
+ return cached;
52
+ }
53
+ const columns = new Set();
54
+ resolved.set(viewId, columns);
55
+ try {
56
+ const tree = parseNodeTree(definitions.get(viewId) ?? "");
57
+ const query = Array.isArray(tree) ? tree[0] : tree;
58
+ if (isNode(query, "QUERY") && allowsNonNull(query)) {
59
+ for (const entry of list(query.fields.targetList)) {
60
+ if (isNode(entry, "TARGETENTRY") &&
61
+ entry.fields.resjunk !== "true" &&
62
+ isNonNullExpression(entry.fields.expr, [query])) {
63
+ columns.add(Number(entry.fields.resno));
64
+ }
65
+ }
66
+ }
67
+ }
68
+ catch {
69
+ columns.clear();
70
+ }
71
+ return columns;
72
+ };
73
+ const isNonNullColumn = (relation, attnum) => definitions.has(relation)
74
+ ? nonNullColumnsOfView(relation).has(attnum)
75
+ : nonNullBaseColumns.has(`${relation}.${attnum}`);
76
+ const isNonNullExpression = (expression, queries) => {
77
+ const query = queries.at(-1);
78
+ if (query === undefined ||
79
+ !isNode(expression, "VAR") ||
80
+ expression.fields.varlevelsup !== "0") {
81
+ return false;
82
+ }
83
+ const varno = Number(expression.fields.varno);
84
+ const attnum = Number(expression.fields.varattno);
85
+ if (attnum <= 0 || nullableIndexesOf(query).has(varno)) {
86
+ return false;
87
+ }
88
+ const entry = list(query.fields.rtable)[varno - 1];
89
+ if (!isNode(entry, "RANGETBLENTRY")) {
90
+ return false;
91
+ }
92
+ switch (entry.fields.rtekind) {
93
+ case RTE_RELATION:
94
+ return (typeof entry.fields.relid === "string" &&
95
+ isNonNullColumn(entry.fields.relid, attnum));
96
+ case RTE_SUBQUERY:
97
+ return isNonNullOutput(entry.fields.subquery, attnum, queries);
98
+ case RTE_JOIN:
99
+ return isNonNullExpression(list(entry.fields.joinaliasvars)[attnum - 1], queries);
100
+ case RTE_GROUP:
101
+ return isNonNullExpression(list(entry.fields.groupexprs)[attnum - 1], queries);
102
+ case RTE_CTE: {
103
+ const levelsUp = Number(entry.fields.ctelevelsup);
104
+ const owner = queries.at(-1 - levelsUp);
105
+ const cte = list(owner?.fields.cteList).find((candidate) => isNode(candidate, "COMMONTABLEEXPR") &&
106
+ candidate.fields.ctename === entry.fields.ctename);
107
+ return (isNode(cte) &&
108
+ cte.fields.cterecursive !== "true" &&
109
+ isNonNullOutput(cte.fields.ctequery, attnum, queries.slice(0, queries.length - levelsUp)));
110
+ }
111
+ default:
112
+ return false;
113
+ }
114
+ };
115
+ const isNonNullOutput = (subquery, resno, queries) => {
116
+ if (!isNode(subquery, "QUERY") || !allowsNonNull(subquery)) {
117
+ return false;
118
+ }
119
+ const entry = list(subquery.fields.targetList).find((candidate) => isNode(candidate, "TARGETENTRY") &&
120
+ Number(candidate.fields.resno) === resno);
121
+ return (isNode(entry) &&
122
+ isNonNullExpression(entry.fields.expr, [...queries, subquery]));
123
+ };
124
+ const result = new Set();
125
+ for (const viewId of definitions.keys()) {
126
+ for (const attnum of nonNullColumnsOfView(viewId)) {
127
+ result.add(`${viewId}.${attnum}`);
128
+ }
129
+ }
130
+ return result;
131
+ }
132
+ function allowsNonNull(query) {
133
+ return (query.fields.setOperations === null &&
134
+ list(query.fields.groupingSets).length === 0);
135
+ }
136
+ /**
137
+ * The range table indexes that sit on the nullable side of an outer join in
138
+ * the query's join tree, including the indexes of nested joins themselves.
139
+ */
140
+ function nullableRangeTableIndexes(query) {
141
+ const nullable = new Set();
142
+ const visit = (node, isNullable) => {
143
+ if (isNode(node, "RANGETBLREF")) {
144
+ if (isNullable) {
145
+ nullable.add(Number(node.fields.rtindex));
146
+ }
147
+ }
148
+ else if (isNode(node, "FROMEXPR")) {
149
+ for (const item of list(node.fields.fromlist)) {
150
+ visit(item, isNullable);
151
+ }
152
+ }
153
+ else if (isNode(node, "JOINEXPR")) {
154
+ const joinType = node.fields.jointype;
155
+ if (isNullable) {
156
+ nullable.add(Number(node.fields.rtindex));
157
+ }
158
+ visit(node.fields.larg, isNullable || (joinType !== JOIN_INNER && joinType !== JOIN_LEFT));
159
+ visit(node.fields.rarg, isNullable || (joinType !== JOIN_INNER && joinType !== JOIN_RIGHT));
160
+ }
161
+ };
162
+ visit(query.fields.jointree, false);
163
+ return nullable;
164
+ }
165
+ function list(value) {
166
+ return Array.isArray(value) ? value : [];
167
+ }
package/dist/rpc.js CHANGED
@@ -18,18 +18,25 @@ import { compareStrings } from "./collation.js";
18
18
  /** json, jsonb and text: the types PostgREST accepts for a single unnamed parameter. */
19
19
  const UNNAMED_ARG_TYPE_IDS = new Set([114, 3802, 25]);
20
20
  const INPUT_ARG_MODES = new Set(["in", "inout", "variadic"]);
21
+ /** A NUL separator cannot occur in an identifier, so names with dots never collide. */
22
+ const qualifiedKey = (schema, name) => `${schema}\0${name}`;
21
23
  export function resolveRpcFunctions(metadata) {
22
24
  const schemaNames = new Set(metadata.schemas.map(({ name }) => name));
23
25
  const typesById = new Map(metadata.types.map((type) => [type.id, type]));
24
26
  const relationTypesById = new Map(metadata.types
25
27
  .filter((type) => type.type_relation_id !== null)
26
28
  .map((type) => [type.id, type]));
27
- const relationsById = new Map([
29
+ const relations = [
28
30
  ...metadata.tables,
29
31
  ...metadata.foreignTables,
30
32
  ...metadata.views,
31
33
  ...metadata.materializedViews,
32
- ].map((relation) => [relation.id, relation]));
34
+ ];
35
+ const relationsById = new Map(relations.map((relation) => [relation.id, relation]));
36
+ const relationsBySchemaName = new Map(relations.map((relation) => [
37
+ qualifiedKey(relation.schema, relation.name),
38
+ relation,
39
+ ]));
33
40
  const columnNamesByRelationId = new Map();
34
41
  for (const column of metadata.columns) {
35
42
  let names = columnNamesByRelationId.get(column.table_id);
@@ -66,11 +73,14 @@ export function resolveRpcFunctions(metadata) {
66
73
  // the rendered argument list may still name the relation.
67
74
  const computedFieldOf = ({ fn, inArgs }) => {
68
75
  const soleInArg = inArgs.length === 1 ? inArgs[0] : undefined;
69
- const argType = soleInArg && relationTypesById.get(soleInArg.type_id);
76
+ if (!soleInArg)
77
+ return null;
78
+ const argType = relationTypesById.get(soleInArg.type_id);
70
79
  const relation = argType
71
80
  ? relationsById.get(argType.type_relation_id)
72
- : [...relationsById.values()].find((relation) => relation.schema === fn.schema &&
73
- relation.name === fn.argument_types);
81
+ : typesById.has(soleInArg.type_id)
82
+ ? undefined
83
+ : relationsBySchemaName.get(qualifiedKey(fn.schema, fn.argument_types));
74
84
  if (!relation || relation.schema !== fn.schema)
75
85
  return null;
76
86
  if (columnNamesByRelationId.get(relation.id)?.has(fn.name))
@@ -125,12 +135,13 @@ export function resolveRpcFunctions(metadata) {
125
135
  }
126
136
  }
127
137
  }
128
- // A sole unnamed row parameter makes the function a computed field, which
129
- // PostgREST only exposes under `/rpc` when it also returns rows.
138
+ // PostgREST passes a sole unnamed parameter only when it is json, jsonb or
139
+ // text, so a function taking a row this way is a computed field or a
140
+ // computed relationship, reachable by embedding it from its relation and
141
+ // never through `/rpc`, whatever it returns.
130
142
  if (soleInArg &&
131
143
  soleInArg.name === "" &&
132
- relationTypesById.has(soleInArg.type_id) &&
133
- returnRelationName(fn) === null) {
144
+ relationTypesById.has(soleInArg.type_id)) {
134
145
  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`;
135
146
  }
136
147
  return null;
@@ -145,9 +156,7 @@ export function resolveRpcFunctions(metadata) {
145
156
  mode: arg.mode,
146
157
  ...typeReference(arg.type_id),
147
158
  has_default: arg.has_default === true,
148
- relation_id: relationId !== undefined &&
149
- relationId !== null &&
150
- relationsById.has(relationId)
159
+ relation_id: relationId !== undefined && relationsById.has(relationId)
151
160
  ? relationId
152
161
  : null,
153
162
  };
@@ -199,7 +208,7 @@ export function resolveRpcFunctions(metadata) {
199
208
  const candidate = { fn, inArgs };
200
209
  if (!isCallable(candidate))
201
210
  continue;
202
- const key = `${fn.schema}.${fn.name}`;
211
+ const key = qualifiedKey(fn.schema, fn.name);
203
212
  let group = groups.get(key);
204
213
  if (!group) {
205
214
  group = [];
package/dist/types.js CHANGED
@@ -179,8 +179,8 @@ const rpcFunctionReturnsSchema = rpcTypeReferenceSchema.merge({
179
179
  */
180
180
  relation_id: "number | null",
181
181
  /**
182
- * The columns of a `RETURNS TABLE (…)` or `OUT` parameter result, in name
183
- * order; `null` for every other return type.
182
+ * The columns of a `RETURNS TABLE (…)` result, in name order; `null` for
183
+ * every other return type, including a record built from `OUT` parameters.
184
184
  */
185
185
  columns: rpcTypeReferenceSchema.merge({ name: "string" }).array().or("null"),
186
186
  });
@@ -217,9 +217,10 @@ const rpcFunctionOverloadSchema = type({
217
217
  computed_field_of: "number | null",
218
218
  /**
219
219
  * Why PostgREST cannot resolve a call to this overload, when it cannot: the
220
- * function conflicts with another overload, or it is a computed field that
221
- * returns no rows. Generators surface the message instead of a callable
222
- * signature.
220
+ * function conflicts with another overload, or its sole parameter is an
221
+ * unnamed row, which PostgREST never matches under `/rpc` (the function is
222
+ * a computed field or computed relationship, reached by embedding).
223
+ * Generators surface the message instead of a callable signature.
223
224
  */
224
225
  error: "string | null",
225
226
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supabase/postgrest-typegen",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Type generation for PostgREST from PostgreSQL schemas",
5
5
  "keywords": [
6
6
  "codegen",
@@ -121,7 +121,6 @@ export const generateTypescript = async (
121
121
  relationships,
122
122
  types,
123
123
  } = metadata;
124
- // Documents predating the collection (still valid version 1) resolve it here.
125
124
  const rpcFunctions = metadata.rpcFunctions ?? resolveRpcFunctions(metadata);
126
125
  const {
127
126
  detectOneToOneRelationships = false,
@@ -334,7 +333,11 @@ export const generateTypescript = async (
334
333
  // Without that metadata PostgREST still returns an array, so the collapse would make
335
334
  // the type diverge from the actual response.
336
335
  const collapsibleSingleRow = setof_options?.is_one_to_one === true;
337
- return `${returnType}${overload.is_set_returning && !collapsibleSingleRow ? "[]" : ""}
336
+ const isArray =
337
+ overload.error === null &&
338
+ overload.is_set_returning &&
339
+ !collapsibleSingleRow;
340
+ return `${returnType}${isArray ? "[]" : ""}
338
341
  ${setofOptionsInfo}`;
339
342
  };
340
343
 
@@ -14,7 +14,9 @@ import { PRIMARY_KEYS_SQL } from "./sql/primary_keys.sql.ts";
14
14
  import { SCHEMAS_SQL } from "./sql/schemas.sql.ts";
15
15
  import { TABLES_SQL } from "./sql/table.sql.ts";
16
16
  import { TYPES_SQL } from "./sql/types.sql.ts";
17
+ import { VIEW_DEFINITIONS_SQL } from "./sql/view_definitions.sql.ts";
17
18
  import { VIEWS_SQL } from "./sql/views.sql.ts";
19
+ import { applyViewColumnNullability } from "./view-nullability.ts";
18
20
 
19
21
  /**
20
22
  * Minimal structural database interface. `pg.Pool` / `pg.Client` satisfy it,
@@ -72,6 +74,7 @@ export async function introspect(
72
74
  relationships,
73
75
  functions,
74
76
  types,
77
+ viewDefinitions,
75
78
  ] = await Promise.all([
76
79
  queryRows(SCHEMAS_SQL({ schemaFilter: systemExcludingFilter })),
77
80
  queryRows(TABLES_SQL({ schemaFilter: systemExcludingFilter })),
@@ -83,6 +86,7 @@ export async function introspect(
83
86
  listRelationships(db, systemExcludingFilter),
84
87
  queryRows(FUNCTIONS_SQL({ schemaFilter: systemExcludingFilter })),
85
88
  queryRows(TYPES_SQL),
89
+ queryRows(VIEW_DEFINITIONS_SQL({ schemaFilter: systemExcludingFilter })),
86
90
  ]);
87
91
 
88
92
  const metadata = {
@@ -92,7 +96,7 @@ export async function introspect(
92
96
  foreignTables,
93
97
  views,
94
98
  materializedViews,
95
- columns,
99
+ columns: applyViewColumnNullability(columns, viewDefinitions),
96
100
  primaryKeys,
97
101
  relationships,
98
102
  functions: functions.filter(
@@ -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
+ }
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
  });