@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 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 }) => {
@@ -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
- * verbatim from upstream.
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 verbatim from `PostgresMetaRelationships.list()` (the body of the
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. Errors from the
44
- * injected `Queryable` propagate by throwing — callers adapt.
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 verbatim from `PostgresMetaRelationships.list()` (the body of the
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 = viewsKeyDependencies.filter((vkd) => vkd.table_schema === r.schema &&
23
- vkd.table_name === r.relation &&
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. Errors from the
71
- * injected `Queryable` propagate by throwing — callers adapt.
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 { rows: allTableM2oAndO2oRelationships } = await db.query(TABLE_RELATIONSHIPS_SQL({ schemaFilter }));
75
- const { rows: viewsKeyDependencies } = await db.query(VIEWS_KEY_DEPENDENCIES_SQL({ schemaFilter }));
76
- const allViewM2oAndO2oRelationships = expandViewRelationships(allTableM2oAndO2oRelationships, viewsKeyDependencies);
77
- return allTableM2oAndO2oRelationships.concat(allViewM2oAndO2oRelationships);
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,2 +1,4 @@
1
1
  import type { SchemaFilterProps } from "./common.ts";
2
- export declare const VIEWS_KEY_DEPENDENCIES_SQL: (props: SchemaFilterProps) => string;
2
+ export declare const VIEWS_KEY_DEPENDENCIES_SQL: (props: SchemaFilterProps & {
3
+ extraSearchPath?: string[];
4
+ }) => string;
@@ -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.schemaFilter ? `and n.nspname ${props.schemaFilter}` : ""}
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
+ }