@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 +36 -2
- package/dist/generation/typescript.js +4 -2
- package/dist/introspection/index.js +5 -2
- package/dist/introspection/node-tree.d.ts +18 -0
- package/dist/introspection/node-tree.js +100 -0
- package/dist/introspection/sql/view_definitions.sql.d.ts +8 -0
- package/dist/introspection/sql/view_definitions.sql.js +43 -0
- package/dist/introspection/view-nullability.d.ts +14 -0
- package/dist/introspection/view-nullability.js +167 -0
- package/dist/rpc.js +22 -13
- package/dist/types.js +6 -5
- package/package.json +1 -1
- package/src/generation/typescript.ts +5 -2
- package/src/introspection/index.ts +5 -1
- package/src/introspection/node-tree.ts +129 -0
- package/src/introspection/sql/view_definitions.sql.ts +45 -0
- package/src/introspection/view-nullability.ts +243 -0
- package/src/rpc.ts +28 -20
- package/src/types.ts +6 -5
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
|
|
93
|
-
|
|
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
|
-
|
|
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
|
|
29
|
+
const relations = [
|
|
28
30
|
...metadata.tables,
|
|
29
31
|
...metadata.foreignTables,
|
|
30
32
|
...metadata.views,
|
|
31
33
|
...metadata.materializedViews,
|
|
32
|
-
]
|
|
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
|
-
|
|
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
|
-
:
|
|
73
|
-
|
|
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
|
-
//
|
|
129
|
-
//
|
|
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 =
|
|
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 (…)`
|
|
183
|
-
*
|
|
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
|
|
221
|
-
*
|
|
222
|
-
*
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
//
|
|
182
|
-
//
|
|
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 =
|
|
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 (…)`
|
|
209
|
-
*
|
|
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
|
|
249
|
-
*
|
|
250
|
-
*
|
|
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
|
});
|