@supabase/postgrest-typegen 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -2
- package/dist/generation/typescript.js +4 -2
- package/dist/introspection/index.d.ts +6 -0
- package/dist/introspection/index.js +6 -3
- package/dist/introspection/node-tree.d.ts +18 -0
- package/dist/introspection/node-tree.js +100 -0
- package/dist/introspection/relationships.d.ts +15 -7
- package/dist/introspection/relationships.js +41 -19
- package/dist/introspection/sql/view_definitions.sql.d.ts +8 -0
- package/dist/introspection/sql/view_definitions.sql.js +43 -0
- package/dist/introspection/sql/views_key_dependencies.sql.d.ts +3 -1
- package/dist/introspection/sql/views_key_dependencies.sql.js +14 -2
- 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 +4 -4
- package/src/generation/typescript.ts +5 -2
- package/src/introspection/index.ts +12 -2
- package/src/introspection/node-tree.ts +129 -0
- package/src/introspection/relationships.ts +84 -26
- package/src/introspection/sql/view_definitions.sql.ts +45 -0
- package/src/introspection/sql/views_key_dependencies.sql.ts +17 -3
- 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 }) => {
|
|
@@ -13,6 +13,12 @@ export interface Queryable {
|
|
|
13
13
|
export interface IntrospectOptions {
|
|
14
14
|
includedSchemas?: string[];
|
|
15
15
|
excludedSchemas?: string[];
|
|
16
|
+
/**
|
|
17
|
+
* PostgREST's `db-extra-search-path` schemas. A view in a generated schema
|
|
18
|
+
* that selects from a view in one of them keeps the relationships of the
|
|
19
|
+
* underlying tables, as in PostgREST. Nothing in these schemas is generated.
|
|
20
|
+
*/
|
|
21
|
+
extraSearchPath?: string[];
|
|
16
22
|
}
|
|
17
23
|
/**
|
|
18
24
|
* Introspect a database into the `GeneratorMetadata` contract.
|
|
@@ -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 })),
|
|
@@ -40,9 +42,10 @@ export async function introspect(db, opts = {}) {
|
|
|
40
42
|
queryRows(MATERIALIZED_VIEWS_SQL({ schemaFilter: plainFilter })),
|
|
41
43
|
queryRows(COLUMNS_SQL({ schemaFilter: systemExcludingFilter })),
|
|
42
44
|
queryRows(PRIMARY_KEYS_SQL({ schemaFilter: systemExcludingFilter })),
|
|
43
|
-
listRelationships(db, systemExcludingFilter),
|
|
45
|
+
listRelationships(db, systemExcludingFilter, opts.extraSearchPath),
|
|
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
|
+
}
|
|
@@ -10,7 +10,8 @@ import type { Queryable } from "./index.ts";
|
|
|
10
10
|
* by mapping a base table's foreign key columns onto the view columns that
|
|
11
11
|
* select them (`VIEWS_KEY_DEPENDENCIES_SQL` + the cartesian-product
|
|
12
12
|
* expansion below). This second part is pure TypeScript, not SQL — ported
|
|
13
|
-
*
|
|
13
|
+
* from postgres-meta, except that the expansion sees every non-system
|
|
14
|
+
* schema's foreign keys, as PostgREST does (see `listRelationships`).
|
|
14
15
|
*
|
|
15
16
|
* Adapted from:
|
|
16
17
|
* https://github.com/PostgREST/postgrest/blob/f9f0f79fa914ac00c11fbf7f4c558e14821e67e2/src/PostgREST/SchemaCache.hs#L392
|
|
@@ -33,15 +34,22 @@ export type ViewKeyDependency = {
|
|
|
33
34
|
* view↔view relationships implied by the view key dependencies. Pure function
|
|
34
35
|
* (no database) so the cartesian-product expansion can be unit-tested directly.
|
|
35
36
|
*
|
|
36
|
-
* Ported
|
|
37
|
-
* `allViewM2oAndO2oRelationships` block).
|
|
37
|
+
* Ported from `PostgresMetaRelationships.list()` (the body of the
|
|
38
|
+
* `allViewM2oAndO2oRelationships` block). Every table relationship feeds the
|
|
39
|
+
* expansion, but a table→view relationship is emitted on the table itself, so
|
|
40
|
+
* `isTableEmitted` keeps it to the tables the caller returns relationships for.
|
|
38
41
|
*/
|
|
39
|
-
export declare function expandViewRelationships(allTableM2oAndO2oRelationships: PostgresRelationship[], viewsKeyDependencies: ViewKeyDependency[]): PostgresRelationship[];
|
|
42
|
+
export declare function expandViewRelationships(allTableM2oAndO2oRelationships: PostgresRelationship[], viewsKeyDependencies: ViewKeyDependency[], isTableEmitted: (relationship: PostgresRelationship) => boolean): PostgresRelationship[];
|
|
40
43
|
/**
|
|
41
44
|
* List all relationships (table↔table plus the view-derived ones) for the
|
|
42
45
|
* schemas selected by `schemaFilter`, the `IN (…)`/`NOT IN (…)` fragment
|
|
43
|
-
* `introspect()` builds with the system schemas excluded.
|
|
44
|
-
*
|
|
46
|
+
* `introspect()` builds with the system schemas excluded. Like PostgREST, view
|
|
47
|
+
* expansion sees the foreign keys of every non-system schema, so a view over a
|
|
48
|
+
* table in a schema that is not selected keeps the table's foreign keys; only
|
|
49
|
+
* the views and tables in the selected schemas get relationships. Views in the
|
|
50
|
+
* `extraSearchPath` schemas are followed to their base tables but get no
|
|
51
|
+
* relationships of their own. Errors from the injected `Queryable` propagate
|
|
52
|
+
* by throwing — callers adapt.
|
|
45
53
|
*/
|
|
46
|
-
export declare function listRelationships(db: Queryable, schemaFilter: string): Promise<PostgresRelationship[]>;
|
|
54
|
+
export declare function listRelationships(db: Queryable, schemaFilter: string, extraSearchPath?: string[]): Promise<PostgresRelationship[]>;
|
|
47
55
|
export {};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DEFAULT_SYSTEM_SCHEMAS, filterByList } from "./sql/helpers.js";
|
|
1
2
|
import { TABLE_RELATIONSHIPS_SQL } from "./sql/table_relationships.sql.js";
|
|
2
3
|
import { VIEWS_KEY_DEPENDENCIES_SQL } from "./sql/views_key_dependencies.sql.js";
|
|
3
4
|
/**
|
|
@@ -5,10 +6,22 @@ import { VIEWS_KEY_DEPENDENCIES_SQL } from "./sql/views_key_dependencies.sql.js"
|
|
|
5
6
|
* view↔view relationships implied by the view key dependencies. Pure function
|
|
6
7
|
* (no database) so the cartesian-product expansion can be unit-tested directly.
|
|
7
8
|
*
|
|
8
|
-
* Ported
|
|
9
|
-
* `allViewM2oAndO2oRelationships` block).
|
|
9
|
+
* Ported from `PostgresMetaRelationships.list()` (the body of the
|
|
10
|
+
* `allViewM2oAndO2oRelationships` block). Every table relationship feeds the
|
|
11
|
+
* expansion, but a table→view relationship is emitted on the table itself, so
|
|
12
|
+
* `isTableEmitted` keeps it to the tables the caller returns relationships for.
|
|
10
13
|
*/
|
|
11
|
-
export function expandViewRelationships(allTableM2oAndO2oRelationships, viewsKeyDependencies) {
|
|
14
|
+
export function expandViewRelationships(allTableM2oAndO2oRelationships, viewsKeyDependencies, isTableEmitted) {
|
|
15
|
+
const keyDepsByConstraint = new Map();
|
|
16
|
+
for (const vkd of viewsKeyDependencies) {
|
|
17
|
+
const key = keyDependencyKey(vkd.table_schema, vkd.table_name, vkd.constraint_name, vkd.constraint_type);
|
|
18
|
+
let bucket = keyDepsByConstraint.get(key);
|
|
19
|
+
if (!bucket) {
|
|
20
|
+
bucket = [];
|
|
21
|
+
keyDepsByConstraint.set(key, bucket);
|
|
22
|
+
}
|
|
23
|
+
bucket.push(vkd);
|
|
24
|
+
}
|
|
12
25
|
return allTableM2oAndO2oRelationships.flatMap((r) => {
|
|
13
26
|
const expandKeyDepCols = (colDeps) => {
|
|
14
27
|
const tableColumns = colDeps.map(({ table_column }) => table_column);
|
|
@@ -19,14 +32,8 @@ export function expandViewRelationships(allTableM2oAndO2oRelationships, viewsKey
|
|
|
19
32
|
viewColumns,
|
|
20
33
|
}));
|
|
21
34
|
};
|
|
22
|
-
const viewToTableKeyDeps =
|
|
23
|
-
|
|
24
|
-
vkd.constraint_name === r.foreign_key_name &&
|
|
25
|
-
vkd.constraint_type === "f");
|
|
26
|
-
const tableToViewKeyDeps = viewsKeyDependencies.filter((vkd) => vkd.table_schema === r.referenced_schema &&
|
|
27
|
-
vkd.table_name === r.referenced_relation &&
|
|
28
|
-
vkd.constraint_name === r.foreign_key_name &&
|
|
29
|
-
vkd.constraint_type === "f_ref");
|
|
35
|
+
const viewToTableKeyDeps = keyDepsByConstraint.get(keyDependencyKey(r.schema, r.relation, r.foreign_key_name, "f")) ?? [];
|
|
36
|
+
const tableToViewKeyDeps = keyDepsByConstraint.get(keyDependencyKey(r.referenced_schema, r.referenced_relation, r.foreign_key_name, "f_ref")) ?? [];
|
|
30
37
|
const viewToTableRelationships = viewToTableKeyDeps.flatMap((vtkd) => expandKeyDepCols(vtkd.column_dependencies).map(({ viewColumns }) => ({
|
|
31
38
|
foreign_key_name: r.foreign_key_name,
|
|
32
39
|
schema: vtkd.view_schema,
|
|
@@ -37,7 +44,7 @@ export function expandViewRelationships(allTableM2oAndO2oRelationships, viewsKey
|
|
|
37
44
|
referenced_relation: r.referenced_relation,
|
|
38
45
|
referenced_columns: r.referenced_columns,
|
|
39
46
|
})));
|
|
40
|
-
const tableToViewRelationships = tableToViewKeyDeps.flatMap((tvkd) => expandKeyDepCols(tvkd.column_dependencies).map(({ viewColumns }) => ({
|
|
47
|
+
const tableToViewRelationships = (isTableEmitted(r) ? tableToViewKeyDeps : []).flatMap((tvkd) => expandKeyDepCols(tvkd.column_dependencies).map(({ viewColumns }) => ({
|
|
41
48
|
foreign_key_name: r.foreign_key_name,
|
|
42
49
|
schema: r.schema,
|
|
43
50
|
relation: r.relation,
|
|
@@ -67,12 +74,27 @@ export function expandViewRelationships(allTableM2oAndO2oRelationships, viewsKey
|
|
|
67
74
|
/**
|
|
68
75
|
* List all relationships (table↔table plus the view-derived ones) for the
|
|
69
76
|
* schemas selected by `schemaFilter`, the `IN (…)`/`NOT IN (…)` fragment
|
|
70
|
-
* `introspect()` builds with the system schemas excluded.
|
|
71
|
-
*
|
|
77
|
+
* `introspect()` builds with the system schemas excluded. Like PostgREST, view
|
|
78
|
+
* expansion sees the foreign keys of every non-system schema, so a view over a
|
|
79
|
+
* table in a schema that is not selected keeps the table's foreign keys; only
|
|
80
|
+
* the views and tables in the selected schemas get relationships. Views in the
|
|
81
|
+
* `extraSearchPath` schemas are followed to their base tables but get no
|
|
82
|
+
* relationships of their own. Errors from the injected `Queryable` propagate
|
|
83
|
+
* by throwing — callers adapt.
|
|
72
84
|
*/
|
|
73
|
-
export async function listRelationships(db, schemaFilter) {
|
|
74
|
-
const
|
|
75
|
-
const { rows: viewsKeyDependencies } = await
|
|
76
|
-
|
|
77
|
-
|
|
85
|
+
export async function listRelationships(db, schemaFilter, extraSearchPath = []) {
|
|
86
|
+
const tableRelationshipsQuery = db.query(TABLE_RELATIONSHIPS_SQL({ schemaFilter }));
|
|
87
|
+
const [{ rows: tableRelationships }, { rows: allTableRelationships }, { rows: viewsKeyDependencies },] = await Promise.all([
|
|
88
|
+
tableRelationshipsQuery,
|
|
89
|
+
schemaFilter === NON_SYSTEM_SCHEMAS_FILTER
|
|
90
|
+
? tableRelationshipsQuery
|
|
91
|
+
: db.query(TABLE_RELATIONSHIPS_SQL({ schemaFilter: NON_SYSTEM_SCHEMAS_FILTER })),
|
|
92
|
+
db.query(VIEWS_KEY_DEPENDENCIES_SQL({ schemaFilter, extraSearchPath })),
|
|
93
|
+
]);
|
|
94
|
+
const emittedKeys = new Set(tableRelationships.map(relationshipKey));
|
|
95
|
+
const allViewM2oAndO2oRelationships = expandViewRelationships(allTableRelationships, viewsKeyDependencies, (r) => emittedKeys.has(relationshipKey(r)));
|
|
96
|
+
return tableRelationships.concat(allViewM2oAndO2oRelationships);
|
|
78
97
|
}
|
|
98
|
+
const NON_SYSTEM_SCHEMAS_FILTER = filterByList(undefined, undefined, DEFAULT_SYSTEM_SCHEMAS);
|
|
99
|
+
const relationshipKey = (r) => JSON.stringify([r.schema, r.relation, r.foreign_key_name]);
|
|
100
|
+
const keyDependencyKey = (tableSchema, tableName, constraintName, constraintType) => JSON.stringify([tableSchema, tableName, constraintName, constraintType]);
|
|
@@ -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
|
+
`;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { filterByList } from "./helpers.js";
|
|
1
2
|
import { literal } from "./pg-format.js";
|
|
2
3
|
export const VIEWS_KEY_DEPENDENCIES_SQL = (props) => /* SQL */ `
|
|
3
4
|
-- Adapted from
|
|
@@ -27,7 +28,6 @@ pks_fks as (
|
|
|
27
28
|
from pg_constraint
|
|
28
29
|
left join lateral unnest(confkey) with ordinality as _(col, ord) on true
|
|
29
30
|
where contype='f'
|
|
30
|
-
${props.schemaFilter ? `and connamespace::regnamespace::text ${props.schemaFilter}` : ""}
|
|
31
31
|
),
|
|
32
32
|
views as (
|
|
33
33
|
select
|
|
@@ -39,7 +39,7 @@ views as (
|
|
|
39
39
|
join pg_namespace n on n.oid = c.relnamespace
|
|
40
40
|
join pg_rewrite r on r.ev_class = c.oid
|
|
41
41
|
where c.relkind in ('v', 'm')
|
|
42
|
-
${props
|
|
42
|
+
${viewSchemaFilter(props)}
|
|
43
43
|
),
|
|
44
44
|
transform_json as (
|
|
45
45
|
select
|
|
@@ -120,6 +120,18 @@ group by sch.nspname, tbl.relname, rep.view_schema, rep.view_name, pks_fks.conn
|
|
|
120
120
|
-- make sure we only return key for which all columns are referenced in the view - no partial PKs or FKs
|
|
121
121
|
having ncol = array_length(array_agg(row(col.attname, view_columns) order by pks_fks.ord), 1)
|
|
122
122
|
`;
|
|
123
|
+
/**
|
|
124
|
+
* Like PostgREST, the views followed to their base tables also include the
|
|
125
|
+
* `db-extra-search-path` schemas; the recursion still starts from the selected
|
|
126
|
+
* schemas only.
|
|
127
|
+
*/
|
|
128
|
+
function viewSchemaFilter({ schemaFilter, extraSearchPath = [], }) {
|
|
129
|
+
if (!schemaFilter)
|
|
130
|
+
return "";
|
|
131
|
+
if (extraSearchPath.length === 0)
|
|
132
|
+
return `and n.nspname ${schemaFilter}`;
|
|
133
|
+
return `and (n.nspname ${schemaFilter} or n.nspname ${filterByList(extraSearchPath)})`;
|
|
134
|
+
}
|
|
123
135
|
/**
|
|
124
136
|
* The rewrites, innermost first, that turn the `pg_node_tree` text of a view
|
|
125
137
|
* definition into JSON exposing only `targetList`, `resno`, `resorigtbl` and
|
|
@@ -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
|
+
}
|