Skip to main content

postrust_core/schema_cache/
queries.rs

1//! SQL queries for schema introspection.
2
3use super::relationship::{
4    Cardinality, MediaHandler, MediaHandlerMap, Relationship, RelationshipsMap,
5};
6use super::routine::{FuncVolatility, RetType, Routine, RoutineMap, RoutineParam};
7use super::table::{Column, ColumnMap, Table, TablesMap};
8use crate::api_request::QualifiedIdentifier;
9use crate::db::Tx;
10use crate::error::{Error, Result};
11use indexmap::IndexMap;
12use postrust_sql::SqlParam;
13use std::collections::{HashMap, HashSet};
14use std::sync::LazyLock;
15
16/// Get PostgreSQL version.
17pub async fn get_pg_version<T: Tx>(tx: &mut T) -> Result<i32> {
18    let rows = catalog(
19        tx,
20        "SELECT current_setting('server_version_num')::int as version",
21        &[],
22        &[],
23    )
24    .await?;
25    match rows.first() {
26        Some(row) => row.get("version"),
27        None => Err(Error::SchemaCacheLoadFailed(
28            "no rows returned by a query that expected to return at least one row".into(),
29        )),
30    }
31}
32
33/// A row of a catalog query, as PostgreSQL's own `row_to_json` rendered it.
34///
35/// The catalog is read through the same driver-neutral interface as requests
36/// (`crate::db`), whose drivers decode the types a response carries. Catalog
37/// rows carry others -- `name[]`, `text[]` -- so each query is wrapped to
38/// return its row as one `json` value and PostgreSQL does the decoding. A
39/// driver then needs nothing but `json` to load a schema cache.
40pub(super) struct CatalogRow(serde_json::Map<String, serde_json::Value>);
41
42impl CatalogRow {
43    /// A column, by name. A value of the wrong type fails the load rather than
44    /// the process.
45    ///
46    /// A column the query does not have is an error too, not a null.
47    /// `row_to_json` writes every column, null ones included, so an absent key
48    /// is a name misspelled here or in the query's alias -- and read as null it
49    /// would turn every `Option` field it fills into `None`, silently.
50    pub(super) fn get<V: serde::de::DeserializeOwned>(&self, column: &str) -> Result<V> {
51        let value = self.0.get(column).ok_or_else(|| {
52            Error::SchemaCacheLoadFailed(format!("catalog query has no column \"{column}\""))
53        })?;
54        V::deserialize(value)
55            .map_err(|e| Error::SchemaCacheLoadFailed(format!("catalog column \"{column}\": {e}")))
56    }
57}
58
59/// Run a catalog query, reading each row as [`CatalogRow`], ordered by the
60/// query's output columns `order_by`. Empty where the order does not matter.
61///
62/// The query's own `ORDER BY` is not enough. It sorts the subquery the
63/// wrapper reads from, and SQL promises nothing about the order a query
64/// reads a sorted subquery in. PostgreSQL keeps it today, but where a row
65/// order reaches the client (see `load_relationships`) that is not a thing to
66/// lean on. The planner sees that the subquery is already in this order, so
67/// asking again costs no second sort.
68async fn catalog<T: Tx>(
69    tx: &mut T,
70    sql: &str,
71    order_by: &[&str],
72    params: &[SqlParam],
73) -> Result<Vec<CatalogRow>> {
74    tx.fetch_values(&catalog_sql(sql, order_by), params)
75        .await
76        .map_err(|e| Error::SchemaCacheLoadFailed(e.to_string()))?
77        .into_iter()
78        .map(|row| match row.into_iter().next() {
79            Some(serde_json::Value::Object(map)) => Ok(CatalogRow(map)),
80            other => Err(Error::SchemaCacheLoadFailed(format!(
81                "a catalog row was not an object: {other:?}"
82            ))),
83        })
84        .collect()
85}
86
87/// A catalog query, wrapped to return each row as one `json` value, in the
88/// order of its output columns `order_by`. See [`catalog`].
89fn catalog_sql(sql: &str, order_by: &[&str]) -> String {
90    let mut wrapped = format!("SELECT row_to_json(pgrst_catalog) FROM ({sql}) AS pgrst_catalog");
91    for (i, column) in order_by.iter().enumerate() {
92        wrapped.push_str(if i == 0 { " ORDER BY " } else { ", " });
93        wrapped.push_str("pgrst_catalog.");
94        wrapped.push_str(column);
95    }
96    wrapped
97}
98
99/// A text parameter.
100fn text(value: &str) -> SqlParam {
101    SqlParam::Text(value.to_string())
102}
103
104/// A `text[]` parameter.
105fn texts(values: &[String]) -> SqlParam {
106    SqlParam::Array(values.iter().cloned().map(SqlParam::Text).collect())
107}
108
109/// Whether a role this connection can become holds one of `privileges` in the
110/// ACL `acl`, or `PUBLIC` does.
111///
112/// This is the test `information_schema` makes, with one word changed. Its
113/// views ask `pg_has_role(role, 'USAGE')` -- does this connection hold the
114/// role's privileges *without* switching to it -- which is false of every role
115/// granted to a `NOINHERIT` authenticator. That is the setup PostgREST
116/// recommends, and the one where the API runs every request after `SET ROLE`:
117/// what the authenticator could do *as itself* is beside the point, and with
118/// `USAGE` the schema cache saw no tables at all. `MEMBER` asks whether the
119/// connection can become the role, which is what a request does.
120///
121/// For a superuser, or an authenticator that inherits, the two answers are
122/// the same, so nothing about those deployments changes.
123///
124fn acl_grants(acl: &str, privileges: &str) -> String {
125    format!(
126        "EXISTS (SELECT 1 FROM aclexplode({acl}) g \
127          WHERE g.privilege_type = ANY ('{{{privileges}}}'::text[]) \
128            AND {grantee})",
129        grantee = can_become("g.grantee"),
130    )
131}
132
133/// Whether this connection can become the role `role` (an OID expression), or
134/// `role` is `PUBLIC`, which every role is.
135///
136/// `CASE` rather than `OR`: SQL does not promise to short-circuit, and
137/// `pg_has_role` refuses OID 0, which is how an ACL spells `PUBLIC`.
138fn can_become(role: &str) -> String {
139    format!("CASE WHEN {role} = 0 THEN true ELSE pg_has_role({role}, 'MEMBER') END")
140}
141
142/// Whether this connection can become a predefined role that holds privileges
143/// on every table without appearing in any ACL: `pg_read_all_data` and
144/// `pg_write_all_data` (PostgreSQL 14 and later). `has_table_privilege`, which
145/// `information_schema` asks, counts them, so a scan of the ACLs alone hid
146/// every table from a deployment that grants one of these instead of
147/// per-table privileges. Looked up by name, so on a server too old to have
148/// them this is simply false rather than an error.
149const PREDEFINED_DATA_ROLE: &str = "EXISTS (SELECT 1 FROM pg_roles pr \
150     WHERE pr.rolname IN ('pg_read_all_data', 'pg_write_all_data') \
151       AND pg_has_role(pr.oid, 'MEMBER'))";
152
153/// A relation's own ACL, or the owner-only default it stands for when unset.
154const RELATION_ACL: &str = "COALESCE(c.relacl, acldefault('r', c.relowner))";
155
156/// Whether a relation is visible to this connection: `information_schema.tables`'
157/// condition, with `MEMBER` for `USAGE` (see `acl_grants`). A role it can
158/// become owns it, or holds a privilege on it or on one of its columns, or
159/// reads or writes every table (see `PREDEFINED_DATA_ROLE`).
160fn relation_visible() -> String {
161    format!(
162        "(pg_has_role(c.relowner, 'MEMBER') \
163          OR {PREDEFINED_DATA_ROLE} \
164          OR {table} \
165          OR EXISTS (SELECT 1 FROM pg_attribute ca \
166                      WHERE ca.attrelid = c.oid AND ca.attnum > 0 \
167                        AND NOT ca.attisdropped AND ca.attacl IS NOT NULL \
168                        AND {column}))",
169        table = acl_grants(
170            RELATION_ACL,
171            "SELECT,INSERT,UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER"
172        ),
173        column = acl_grants("ca.attacl", "SELECT,INSERT,UPDATE,REFERENCES"),
174    )
175}
176
177/// Whether a privilege on a relation is held by anyone this connection can
178/// act for: `information_schema.table_privileges`' condition -- the grant is
179/// visible when its grantor or its grantee is a role this connection can
180/// become, or the grantee is `PUBLIC` -- with `MEMBER` for `USAGE`.
181fn privilege_granted(privilege: &str) -> String {
182    format!(
183        "(c.relkind IN ('r', 'v', 'f', 'p') AND EXISTS (\
184            SELECT 1 FROM aclexplode({RELATION_ACL}) g \
185             WHERE g.privilege_type = '{privilege}' \
186               AND ({grantee} OR pg_has_role(g.grantor, 'MEMBER'))))",
187        grantee = can_become("g.grantee"),
188    )
189}
190
191/// Load all tables and their columns.
192pub async fn load_tables<T: Tx>(tx: &mut T, schemas: &[String]) -> Result<TablesMap> {
193    let mut tables = HashMap::new();
194
195    // From the catalogue rather than `information_schema`, whose views show a
196    // connection only what it can use without switching role -- nothing, for
197    // a NOINHERIT authenticator. See `acl_grants`. Built once: it does not
198    // change between loads.
199    static SQL: LazyLock<String> = LazyLock::new(|| {
200        format!(
201            r#"
202        SELECT
203            t.table_schema,
204            t.table_name,
205            t.table_type,
206            pg_catalog.obj_description(t.oid, 'pg_class') as description,
207            COALESCE(
208                (SELECT array_agg(a.attname::text ORDER BY array_position(i.indkey, a.attnum))
209                FROM pg_index i
210                JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
211                WHERE i.indrelid = t.oid
212                  AND i.indisprimary),
213                ARRAY[]::text[]
214            ) as pk_cols,
215            -- Being granted INSERT on a view does not make the view
216            -- insertable. A view is written through only where it is simple
217            -- enough for PostgreSQL to write through automatically, or where
218            -- an INSTEAD OF trigger says how -- `projects_view_without_triggers`
219            -- carries every grant and accepts nothing. Both halves have to
220            -- hold. A table is not a view, so the grant is the whole answer
221            -- there.
222            --
223            -- The view half is `information_schema.views`' own: the bits of
224            -- `pg_relation_is_updatable` it reads, and the INSTEAD OF trigger
225            -- types (`tgtype` 69 insert, 81 update, 73 delete).
226            {insert} AND (
227                t.relkind <> 'v'
228                OR (pg_relation_is_updatable(t.oid, false) & 8) = 8
229                OR EXISTS (SELECT 1 FROM pg_trigger tg
230                            WHERE tg.tgrelid = t.oid AND (tg.tgtype::int & 69) = 69)
231            ) as insertable,
232            {update} AND (
233                t.relkind <> 'v'
234                OR (pg_relation_is_updatable(t.oid, false) & 20) = 20
235                OR EXISTS (SELECT 1 FROM pg_trigger tg
236                            WHERE tg.tgrelid = t.oid AND (tg.tgtype::int & 81) = 81)
237            ) as updatable,
238            -- Updatable rather than a deletable bit, as `information_schema`
239            -- has it: a view PostgreSQL can write through automatically is
240            -- one it can delete through as well. Only the trigger half is
241            -- asked separately.
242            {delete} AND (
243                t.relkind <> 'v'
244                OR (pg_relation_is_updatable(t.oid, false) & 20) = 20
245                OR EXISTS (SELECT 1 FROM pg_trigger tg
246                            WHERE tg.tgrelid = t.oid AND (tg.tgtype::int & 73) = 73)
247            ) as deletable,
248            t.relkind = 'p' as is_partitioned
249        FROM (
250            -- A foreign table is a table: it has columns, it can be selected
251            -- from and written to, and which server stores the rows is not
252            -- the API's business. Leaving it out meant a schema that exposes
253            -- one answered "no such table".
254            --
255            -- A materialized view is a view that cannot be written to, which
256            -- is what the privilege tests report for it -- they cover only
257            -- the relation kinds `information_schema.table_privileges` does,
258            -- and this is not one of them.
259            SELECT c.oid, c.relkind, c.relispartition,
260                   n.nspname::text AS table_schema,
261                   c.relname::text AS table_name,
262                   CASE WHEN c.relkind IN ('v', 'm') THEN 'VIEW'
263                        WHEN c.relkind = 'f' THEN 'FOREIGN'
264                        ELSE 'BASE TABLE' END AS table_type,
265                   {visible} OR c.relkind = 'm' AS visible
266              FROM pg_class c
267              JOIN pg_namespace n ON n.oid = c.relnamespace
268             WHERE c.relkind IN ('r', 'v', 'f', 'p', 'm')
269               AND NOT pg_is_other_temp_schema(n.oid)
270               AND n.oid <> pg_my_temp_schema()
271        ) t
272        JOIN pg_class c ON c.oid = t.oid
273        WHERE t.table_schema = ANY($1)
274          AND t.visible
275          -- A partition is reached through the table it partitions, not on its
276          -- own. Listing every one of them gave a schema with a year of
277          -- monthly partitions twelve endpoints PostgREST does not have -- and
278          -- a request naming one was answered as though the table were real,
279          -- which made a missing relationship read as a missing column. The
280          -- partitioned parent itself (`relkind = 'p'`) is not a partition and
281          -- stays.
282          AND NOT t.relispartition
283        ORDER BY t.table_schema, t.table_name
284        "#,
285            insert = privilege_granted("INSERT"),
286            update = privilege_granted("UPDATE"),
287            delete = privilege_granted("DELETE"),
288            visible = relation_visible(),
289        )
290    });
291    let rows = catalog(tx, &SQL, &["table_schema", "table_name"], &[texts(schemas)]).await?;
292
293    for row in rows {
294        let schema: String = row.get("table_schema")?;
295        let name: String = row.get("table_name")?;
296        let qi = QualifiedIdentifier::new(&schema, &name);
297
298        let table_type: String = row.get("table_type")?;
299        let pk_cols: Vec<String> = row.get("pk_cols")?;
300
301        let table = Table {
302            schema: schema.clone(),
303            name: name.clone(),
304            description: row.get("description")?,
305            is_view: table_type == "VIEW",
306            is_partitioned: row.get("is_partitioned")?,
307            insertable: row.get("insertable")?,
308            updatable: row.get("updatable")?,
309            deletable: row.get("deletable")?,
310            pk_cols: pk_cols.clone(),
311            unique_constraints: Vec::new(),
312            columns: load_columns(tx, &schema, &name, &pk_cols).await?,
313            computed_columns: Default::default(),
314        };
315
316        tables.insert(qi, table);
317    }
318
319    Ok(tables)
320}
321
322/// Load columns for a table.
323async fn load_columns<T: Tx>(
324    tx: &mut T,
325    schema: &str,
326    table: &str,
327    pk_cols: &[String],
328) -> Result<ColumnMap> {
329    let mut columns = IndexMap::new();
330
331    // `information_schema.columns`, read from the catalogue: the view's own
332    // expressions for every column used here, so a type is named exactly as it
333    // was, and its visibility test with `MEMBER` for `USAGE` -- see
334    // `acl_grants` for why the view itself shows a NOINHERIT authenticator
335    // nothing. Built once, as it runs once per table.
336    static SQL: LazyLock<String> = LazyLock::new(|| {
337        format!(
338            r#"
339        SELECT
340            c.column_name,
341            c.ordinal_position,
342            c.is_nullable,
343            c.data_type,
344            c.udt_name,
345            -- A domain's own name. `information_schema` reports the type
346            -- underneath it in `data_type` and `udt_name`, so this is the only
347            -- column that says a value is a `color` rather than an integer --
348            -- and the domain is what a data representation is declared on.
349            c.domain_name,
350            c.character_maximum_length,
351            -- An identity column has no `column_default`: `information_schema`
352            -- describes it as generated rather than defaulted. What it would
353            -- take if a row left it out is the next value of the sequence the
354            -- column owns, which is the answer `Prefer: missing=default`
355            -- needs.
356            COALESCE(
357                c.column_default,
358                (SELECT format('nextval(%L)', s.oid::regclass)
359                   FROM pg_class rel
360                   JOIN pg_namespace ns ON ns.oid = rel.relnamespace
361                   JOIN pg_attribute att ON att.attrelid = rel.oid
362                                        AND att.attname = c.column_name
363                                        AND att.attidentity = 'd'
364                   JOIN pg_depend dep ON dep.refobjid = rel.oid
365                                     AND dep.refobjsubid = att.attnum
366                                     AND dep.deptype = 'i'
367                   JOIN pg_class s ON s.oid = dep.objid AND s.relkind = 'S'
368                  WHERE ns.nspname = c.table_schema
369                    AND rel.relname = c.table_name
370                  LIMIT 1)
371            ) AS column_default,
372            -- What a write may not name at all. `GENERATED ALWAYS AS
373            -- IDENTITY` and a stored generated column both belong to
374            -- PostgreSQL, which answers `cannot insert a non-DEFAULT value`
375            -- to anything that names one. `BY DEFAULT` is deliberately not
376            -- here: it has a default, and a value given for it is taken.
377            COALESCE(
378                (c.is_identity = 'YES' AND c.identity_generation = 'ALWAYS')
379                    OR c.is_generated = 'ALWAYS',
380                false
381            ) AS always_generated,
382            -- By OID: naming the table and casting to `regclass` needs USAGE
383            -- on its schema, which a NOINHERIT authenticator does not have.
384            pg_catalog.col_description(c.relid, c.ordinal_position) as description,
385            CASE WHEN e.enumtypid IS NOT NULL
386                 THEN array_agg(e.enumlabel ORDER BY e.enumsortorder)
387                 ELSE ARRAY[]::text[]
388            END as enum_values
389        FROM (
390            SELECT
391                c.oid AS relid,
392                nc.nspname::text AS table_schema,
393                c.relname::text AS table_name,
394                a.attname::text AS column_name,
395                a.attnum::int AS ordinal_position,
396                CASE WHEN a.attgenerated = '' THEN pg_get_expr(ad.adbin, ad.adrelid) END
397                    AS column_default,
398                CASE WHEN a.attnotnull OR (t.typtype = 'd' AND t.typnotnull)
399                     THEN 'NO' ELSE 'YES' END AS is_nullable,
400                CASE WHEN t.typtype = 'd' THEN
401                         CASE WHEN bt.typelem <> 0 AND bt.typlen = -1 THEN 'ARRAY'
402                              WHEN nbt.nspname = 'pg_catalog' THEN format_type(t.typbasetype, NULL)
403                              ELSE 'USER-DEFINED' END
404                     ELSE
405                         CASE WHEN t.typelem <> 0 AND t.typlen = -1 THEN 'ARRAY'
406                              WHEN nt.nspname = 'pg_catalog' THEN format_type(a.atttypid, NULL)
407                              ELSE 'USER-DEFINED' END
408                END AS data_type,
409                information_schema._pg_char_max_length(
410                    information_schema._pg_truetypid(a.*, t.*),
411                    information_schema._pg_truetypmod(a.*, t.*)
412                )::int AS character_maximum_length,
413                CASE WHEN t.typtype = 'd' THEN t.typname::text END AS domain_name,
414                COALESCE(bt.typname, t.typname)::text AS udt_name,
415                -- The same type by OID, for its enum labels: two schemas may
416                -- each have an enum of one name.
417                COALESCE(bt.oid, t.oid) AS udt_oid,
418                CASE WHEN a.attidentity IN ('a', 'd') THEN 'YES' ELSE 'NO' END AS is_identity,
419                CASE a.attidentity WHEN 'a' THEN 'ALWAYS' WHEN 'd' THEN 'BY DEFAULT' END
420                    AS identity_generation,
421                CASE WHEN a.attgenerated <> '' THEN 'ALWAYS' ELSE 'NEVER' END AS is_generated
422            FROM pg_attribute a
423            LEFT JOIN pg_attrdef ad ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum
424            JOIN pg_class c ON a.attrelid = c.oid
425            JOIN pg_namespace nc ON c.relnamespace = nc.oid
426            JOIN pg_type t ON a.atttypid = t.oid
427            JOIN pg_namespace nt ON t.typnamespace = nt.oid
428            LEFT JOIN (pg_type bt JOIN pg_namespace nbt ON bt.typnamespace = nbt.oid)
429                   ON t.typtype = 'd' AND t.typbasetype = bt.oid
430            WHERE nc.nspname = $1 AND c.relname = $2
431              AND NOT pg_is_other_temp_schema(nc.oid)
432              AND a.attnum > 0 AND NOT a.attisdropped
433              AND c.relkind IN ('r', 'v', 'f', 'p')
434              AND (pg_has_role(c.relowner, 'MEMBER')
435                   OR {predefined}
436                   OR {table_grant}
437                   OR (a.attacl IS NOT NULL AND {column_grant}))
438        ) c
439        LEFT JOIN pg_enum e ON e.enumtypid = c.udt_oid
440        WHERE c.table_schema = $1 AND c.table_name = $2
441        GROUP BY c.relid, c.table_schema, c.table_name, c.column_name, c.ordinal_position, c.is_nullable,
442                 c.data_type, c.udt_name, c.domain_name, c.character_maximum_length,
443                 c.column_default, c.is_identity, c.identity_generation, c.is_generated,
444                 c.udt_oid, e.enumtypid
445        ORDER BY c.ordinal_position
446        "#,
447            predefined = PREDEFINED_DATA_ROLE,
448            table_grant = acl_grants(RELATION_ACL, "SELECT,INSERT,UPDATE,REFERENCES"),
449            column_grant = acl_grants("a.attacl", "SELECT,INSERT,UPDATE,REFERENCES"),
450        )
451    });
452    let rows = catalog(
453        tx,
454        &SQL,
455        &["ordinal_position"],
456        &[text(schema), text(table)],
457    )
458    .await?;
459
460    // A materialized view has no rows in `information_schema.columns`; its
461    // columns come from the catalogue instead.
462    let rows = match rows.is_empty() {
463        true => load_catalog_columns(tx, schema, table).await?,
464        false => rows,
465    };
466
467    for row in rows {
468        let name: String = row.get("column_name")?;
469        let is_nullable: String = row.get("is_nullable")?;
470        let data_type: String = row.get("data_type")?;
471        let udt_name: String = row.get("udt_name")?;
472        let max_len: Option<i32> = row.get("character_maximum_length")?;
473        let enum_values: Vec<String> = row.get("enum_values")?;
474        let position: i32 = row.get("ordinal_position")?;
475
476        let column = Column {
477            name: name.clone(),
478            description: row.get("description")?,
479            nullable: is_nullable == "YES",
480            data_type,
481            nominal_type: udt_name,
482            domain_type: row.get("domain_name")?,
483            max_len,
484            default: row.get("column_default")?,
485            enum_values,
486            is_pk: pk_cols.contains(&name),
487            position,
488            always_generated: row.get("always_generated")?,
489        };
490
491        columns.insert(name, column);
492    }
493
494    Ok(columns)
495}
496
497/// Load foreign key relationships.
498pub async fn load_relationships<T: Tx>(tx: &mut T, schemas: &[String]) -> Result<RelationshipsMap> {
499    let mut relationships: RelationshipsMap = HashMap::new();
500
501    let rows = catalog(
502        tx,
503        r#"
504        SELECT
505            c.conname as constraint_name,
506            ns1.nspname as table_schema,
507            t1.relname as table_name,
508            ns2.nspname as foreign_table_schema,
509            t2.relname as foreign_table_name,
510            -- Each key's columns are read in their own subquery, by ordinal
511            -- position. Joining pg_attribute twice in the outer query instead
512            -- would multiply the two keys together: a two-column foreign key
513            -- came back as four pairs, of which two paired a column with the
514            -- wrong one on the other side.
515            (SELECT array_agg(a.attname ORDER BY k.ord)
516               FROM unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord)
517               JOIN pg_attribute a
518                 ON a.attrelid = c.conrelid AND a.attnum = k.attnum) as columns,
519            (SELECT array_agg(a.attname ORDER BY k.ord)
520               FROM unnest(c.confkey) WITH ORDINALITY AS k(attnum, ord)
521               JOIN pg_attribute a
522                 ON a.attrelid = c.confrelid AND a.attnum = k.attnum) as foreign_columns,
523            t1.relkind = 'v' as table_is_view,
524            t2.relkind = 'v' as foreign_table_is_view,
525            -- The key is unique when a unique index is *covered by* it, not
526            -- when the index merely contains it. Asked the other way round,
527            -- any key that is part of a wider unique index looked unique --
528            -- so a foreign key on two of a three-column primary key became a
529            -- one-to-one, and the far side embedded a single object where it
530            -- should have embedded an array.
531            EXISTS (
532                SELECT 1 FROM pg_index i
533                WHERE i.indrelid = c.conrelid
534                  AND i.indisunique
535                  AND c.conkey::int[] @> i.indkey::int[]
536            ) as is_unique
537        FROM pg_constraint c
538        JOIN pg_class t1 ON t1.oid = c.conrelid
539        JOIN pg_namespace ns1 ON ns1.oid = t1.relnamespace
540        JOIN pg_class t2 ON t2.oid = c.confrelid
541        JOIN pg_namespace ns2 ON ns2.oid = t2.relnamespace
542        WHERE c.contype = 'f'
543          AND ns1.nspname = ANY($1)
544        -- Ordered, because the order reaches the client. Where two foreign
545        -- keys join the same pair of tables the embed is ambiguous, and the
546        -- PGRST201 that says so lists the candidates -- in this order, both in
547        -- `details` and in the `hint` that tells the caller what to write
548        -- instead. `pg_constraint` unordered is heap order, which differs
549        -- between two databases holding the same schema and can differ between
550        -- two reads of one database. That made the error message arbitrary and
551        -- the conformance cases covering it flip between runs.
552        ORDER BY ns1.nspname, t1.relname, c.conname
553        "#,
554        &["table_schema", "table_name", "constraint_name"],
555        &[texts(schemas)],
556    )
557    .await?;
558
559    for row in rows {
560        let table_schema: String = row.get("table_schema")?;
561        let table_name: String = row.get("table_name")?;
562        let foreign_schema: String = row.get("foreign_table_schema")?;
563        let foreign_name: String = row.get("foreign_table_name")?;
564        let constraint_name: String = row.get("constraint_name")?;
565        let columns: Vec<String> = row.get("columns")?;
566        let foreign_columns: Vec<String> = row.get("foreign_columns")?;
567        let table_is_view: bool = row.get("table_is_view")?;
568        let foreign_is_view: bool = row.get("foreign_table_is_view")?;
569        let is_unique: bool = row.get("is_unique")?;
570
571        let table_qi = QualifiedIdentifier::new(&table_schema, &table_name);
572        let foreign_qi = QualifiedIdentifier::new(&foreign_schema, &foreign_name);
573
574        let column_pairs: Vec<(String, String)> =
575            columns.into_iter().zip(foreign_columns).collect();
576
577        let is_self = table_qi == foreign_qi;
578
579        // M2O relationship (this table has FK to foreign table)
580        let cardinality = if is_unique {
581            Cardinality::O2O {
582                constraint: constraint_name.clone(),
583                columns: column_pairs.clone(),
584                is_parent: false,
585            }
586        } else {
587            Cardinality::M2O {
588                constraint: constraint_name.clone(),
589                columns: column_pairs.clone(),
590            }
591        };
592
593        let rel = Relationship::ForeignKey {
594            table: table_qi.clone(),
595            foreign_table: foreign_qi.clone(),
596            is_self,
597            cardinality,
598            table_is_view,
599            foreign_table_is_view: foreign_is_view,
600            constraint_name: constraint_name.clone(),
601        };
602
603        relationships
604            .entry((table_qi.clone(), table_schema.clone()))
605            .or_default()
606            .push(rel);
607
608        // O2M relationship (foreign table has many of this table)
609        let reverse_columns: Vec<(String, String)> = column_pairs
610            .iter()
611            .map(|(a, b)| (b.clone(), a.clone()))
612            .collect();
613
614        let reverse_cardinality = if is_unique {
615            Cardinality::O2O {
616                constraint: constraint_name.clone(),
617                columns: reverse_columns.clone(),
618                is_parent: true,
619            }
620        } else {
621            // Reversed, like the O2O case above: column pairs are always
622            // (local column, foreign column) relative to the table the
623            // relationship is stored under. For this direction the local table
624            // is the referenced one, so the pair must be swapped.
625            Cardinality::O2M {
626                constraint: constraint_name.clone(),
627                columns: reverse_columns,
628            }
629        };
630
631        let reverse_rel = Relationship::ForeignKey {
632            table: foreign_qi.clone(),
633            foreign_table: table_qi,
634            is_self,
635            cardinality: reverse_cardinality,
636            table_is_view: foreign_is_view,
637            foreign_table_is_view: table_is_view,
638            constraint_name,
639        };
640
641        relationships
642            .entry((foreign_qi, foreign_schema))
643            .or_default()
644            .push(reverse_rel);
645    }
646
647    load_computed_relationships(tx, schemas, &mut relationships).await?;
648
649    Ok(relationships)
650}
651
652/// Add relationships that are computed by a function rather than declared by a
653/// foreign key.
654///
655/// A function qualifies when it takes exactly one argument, that argument is
656/// the composite type of an exposed table, and it returns rows of an exposed
657/// table. `SETOF t` yields many rows per parent and a bare `t` yields one;
658/// `ROWS 1` on a set-returning function declares it yields one as well, which
659/// is how a to-one computed relationship is written.
660///
661/// The relationship is named after the function, not after the table it
662/// returns, so that two functions returning the same table stay distinct.
663async fn load_computed_relationships<T: Tx>(
664    tx: &mut T,
665    schemas: &[String],
666    relationships: &mut RelationshipsMap,
667) -> Result<()> {
668    let rows = catalog(
669        tx,
670        r#"
671        SELECT
672            pn.nspname  AS function_schema,
673            p.proname   AS function_name,
674            tn.nspname  AS table_schema,
675            t.relname   AS table_name,
676            fn.nspname  AS foreign_table_schema,
677            f.relname   AS foreign_table_name,
678            (NOT p.proretset) OR p.prorows = 1 AS to_one,
679            (SELECT p.proargnames[i]
680               FROM generate_series(1, p.pronargs) AS i
681              WHERE p.proargtypes[i - 1] = argt.oid
682              LIMIT 1) AS row_argument
683        FROM pg_proc p
684        JOIN pg_namespace pn ON pn.oid = p.pronamespace
685        -- one argument is a table's composite type. Usually the only one, and
686        -- then the call is positional; a function that also takes a search
687        -- term or the caller's session has it somewhere among the rest, and is
688        -- called by name.
689        JOIN pg_type argt ON argt.oid = ANY (p.proargtypes::oid[])
690        JOIN pg_class t   ON t.oid = argt.typrelid
691        JOIN pg_namespace tn ON tn.oid = t.relnamespace
692        -- and the return type is a table as well
693        JOIN pg_type rett ON rett.oid = p.prorettype
694        JOIN pg_class f   ON f.oid = rett.typrelid
695        JOIN pg_namespace fn ON fn.oid = f.relnamespace
696        -- Every argument named, since one that is not cannot be given by name
697        -- and a call with more than the row has to be.
698        WHERE (p.pronargs = 1 OR (SELECT count(*)
699                                    FROM generate_series(1, p.pronargs) AS i
700                                   WHERE COALESCE(p.proargnames[i], '') = '') = 0)
701          AND pn.nspname = ANY($1)
702          AND tn.nspname = ANY($1)
703          AND fn.nspname = ANY($1)
704          -- relkind 'c' would be a standalone composite type, not a relation
705          AND t.relkind = ANY (ARRAY['r','v','m','f','p'])
706          AND f.relkind = ANY (ARRAY['r','v','m','f','p'])
707        "#,
708        &[],
709        &[texts(schemas)],
710    )
711    .await?;
712
713    for row in rows {
714        let function = QualifiedIdentifier::new(
715            row.get::<String>("function_schema")?,
716            row.get::<String>("function_name")?,
717        );
718        let table_schema: String = row.get("table_schema")?;
719        let table = QualifiedIdentifier::new(&table_schema, row.get::<String>("table_name")?);
720        let foreign_table = QualifiedIdentifier::new(
721            row.get::<String>("foreign_table_schema")?,
722            row.get::<String>("foreign_table_name")?,
723        );
724        let is_self = table == foreign_table;
725
726        relationships
727            .entry((table.clone(), table_schema))
728            .or_default()
729            .push(Relationship::Computed {
730                function,
731                table: table.clone(),
732                foreign_table: foreign_table.clone(),
733                table_alias: table,
734                to_one: row.get("to_one")?,
735                is_self,
736                row_argument: row.get::<Option<String>>("row_argument")?,
737            });
738    }
739
740    Ok(())
741}
742
743/// Decode the `params` JSON built by [`load_routines`]'s query.
744///
745/// Anything malformed yields no parameters rather than an error: an argument
746/// whose type we don't know is bound untyped, which is how the server behaved
747/// before parameter types were loaded at all.
748fn parse_routine_params(value: serde_json::Value) -> Vec<RoutineParam> {
749    let Some(items) = value.as_array() else {
750        return Vec::new();
751    };
752
753    items
754        .iter()
755        .filter_map(|item| {
756            let param_type = item.get("type")?.as_str()?.to_string();
757            Some(RoutineParam {
758                name: item
759                    .get("name")
760                    .and_then(|n| n.as_str())
761                    .unwrap_or_default()
762                    .to_string(),
763                // A parameter declared `char(4)` is stored as bare
764                // `character`, whose length is 1, so casting an argument to
765                // the declared type truncates it. PostgreSQL does not enforce
766                // a length on a function parameter at all, so the unbounded
767                // spelling is the honest one to cast to.
768                type_max_length: item
769                    .get("cast_type")
770                    .and_then(|t| t.as_str())
771                    .unwrap_or(&param_type)
772                    .to_string(),
773                param_type,
774                required: item
775                    .get("required")
776                    .and_then(|r| r.as_bool())
777                    .unwrap_or(true),
778                variadic: item
779                    .get("variadic")
780                    .and_then(|v| v.as_bool())
781                    .unwrap_or(false),
782            })
783        })
784        .collect()
785}
786
787/// Load stored functions.
788pub async fn load_routines<T: Tx>(tx: &mut T, schemas: &[String]) -> Result<RoutineMap> {
789    let mut routines: RoutineMap = HashMap::new();
790
791    let rows = catalog(
792        tx,
793        r#"
794        -- What a type is once every domain over it is stripped away.
795        --
796        -- `RETURNS SETOF projects_domain` returns rows of `projects`, and a
797        -- domain is transparent to everything that matters here: whether the
798        -- result is composite, and which table's columns it has. Reading the
799        -- declared type alone finds a type name that names no table.
800        WITH RECURSIVE base_types AS (
801            SELECT oid, typbasetype, oid AS base_type FROM pg_catalog.pg_type
802            UNION
803            SELECT t.oid, b.typbasetype, b.oid AS base_type
804              FROM base_types t
805              JOIN pg_catalog.pg_type b ON b.oid = t.typbasetype
806        ),
807        resolved AS (
808            SELECT oid, base_type FROM base_types WHERE typbasetype = 0
809        )
810        SELECT
811            n.nspname as schema,
812            p.proname as name,
813            pg_catalog.obj_description(p.oid) as description,
814            p.provolatile::text as volatility,
815            p.provariadic <> 0 as has_variadic,
816            EXISTS (
817                SELECT 1 FROM unnest(COALESCE(p.proargmodes, '{}'::"char"[])) AS mode
818                 WHERE mode IN ('o', 't', 'b')
819            ) as has_out_params,
820            p.prokind = 'p' as is_procedure,
821            pg_get_function_identity_arguments(p.oid) as args,
822            -- Input parameters, in declaration order. Built from the catalog
823            -- rather than parsed out of the identity-arguments string, whose
824            -- types can themselves contain commas (`numeric(10,2)`).
825            -- `proargtypes` covers the IN parameters only and is 0-indexed;
826            -- `proargnames` is 1-indexed and lists IN names first, so the two
827            -- line up over 1..pronargs. A parameter is optional when it falls
828            -- inside the trailing run that has defaults.
829            COALESCE((
830                SELECT json_agg(
831                    json_build_object(
832                        'name', COALESCE(p.proargnames[i], ''),
833                        'type', pg_catalog.format_type(p.proargtypes[i - 1], NULL),
834                        -- What an argument is cast to. `character` and `bit`
835                        -- name themselves without a length, and casting to
836                        -- either truncates to one character; a parameter's
837                        -- length is not enforced anyway, so the varying
838                        -- spelling is what the value should reach.
839                        'cast_type', CASE p.proargtypes[i - 1]
840                            WHEN 'character'::regtype   THEN 'character varying'
841                            WHEN 'character[]'::regtype THEN 'character varying[]'
842                            WHEN 'bit'::regtype         THEN 'bit varying'
843                            WHEN 'bit[]'::regtype       THEN 'bit varying[]'
844                            ELSE pg_catalog.format_type(p.proargtypes[i - 1], NULL)
845                        END,
846                        'required', i <= (p.pronargs - p.pronargdefaults),
847                        'variadic', p.provariadic <> 0 AND i = p.pronargs
848                    ) ORDER BY i
849                )
850                FROM generate_series(1, p.pronargs) AS i
851            ), '[]'::json) as params,
852            -- The columns the function's rows have, where it names them:
853            -- `RETURNS TABLE(a text, b colour)` declares them as OUT
854            -- parameters, and they are the only account of the result's shape
855            -- -- there is no table to read it off. `proallargtypes` is a
856            -- plain 1-indexed array and lines up with `proargnames`.
857            COALESCE((
858                SELECT json_agg(
859                    json_build_object(
860                        'name', COALESCE(p.proargnames[i], ''),
861                        'type', pg_catalog.format_type(p.proallargtypes[i], NULL)
862                    ) ORDER BY i
863                )
864                FROM generate_series(
865                    1, COALESCE(array_length(p.proallargtypes, 1), 0)) AS i
866                WHERE p.proargmodes[i] IN ('o', 't', 'b')
867            ), '[]'::json) as out_params,
868            CASE
869                WHEN p.proretset THEN 'SETOF ' || pg_catalog.format_type(rt.base_type, NULL)
870                ELSE pg_catalog.format_type(rt.base_type, NULL)
871            END as return_type,
872            p.proretset as returns_set,
873            (SELECT t.typtype::text FROM pg_catalog.pg_type t WHERE t.oid = rt.base_type)
874                as ret_typtype,
875            -- A domain named after a media type says the value already is
876            -- that media type: `returns "image/png"` is a PNG, not a row to
877            -- serialise. Only a single value can be one, so a set-returning
878            -- function is not it.
879            (SELECT lower(dt.typname) FROM pg_catalog.pg_type dt
880              WHERE dt.oid = p.prorettype
881                AND dt.typbasetype <> 0
882                AND NOT p.proretset
883                AND (dt.typname ~ '^[A-Za-z0-9.-]+/[A-Za-z0-9.+-]+$'
884                     OR dt.typname = '*/*')) as media_type,
885            -- What that domain is a domain *over*. A value whose type this
886            -- side does not recognise arrives as PostgreSQL's text rendering
887            -- of it, and a `bytea` renders as `\x` and hex -- which is a
888            -- description of the bytes, not the bytes. Knowing the base type
889            -- is what tells the two apart.
890            (SELECT lower(bt.typname) FROM pg_catalog.pg_type dt
891               JOIN pg_catalog.pg_type bt ON bt.oid = dt.typbasetype
892              WHERE dt.oid = p.prorettype
893                AND NOT p.proretset
894                AND (dt.typname ~ '^[A-Za-z0-9.-]+/[A-Za-z0-9.+-]+$'
895                     OR dt.typname = '*/*')) as media_base_type
896        FROM pg_proc p
897        JOIN pg_namespace n ON n.oid = p.pronamespace
898        JOIN resolved rt ON rt.oid = p.prorettype
899        WHERE n.nspname = ANY($1)
900          AND p.prokind IN ('f', 'p')
901          -- A parameter with no name cannot be given an argument by name, so
902          -- a function that has one is not callable over the API at all --
903          -- and leaving it in the cache makes it a candidate that no request
904          -- can ever match, and a hint that sends the client nowhere.
905          --
906          -- The exception is the single unnamed parameter that takes the
907          -- whole request body, which is how a function is called with one
908          -- json, text, xml or bytea argument and no name for it.
909          AND (
910            (SELECT count(*) FROM generate_series(1, p.pronargs) AS i
911              WHERE COALESCE(p.proargnames[i], '') = '') = 0
912            OR (
913              p.pronargs = 1
914              AND COALESCE(p.proargnames[1], '') = ''
915              AND p.proargtypes[0] IN ('bytea'::regtype, 'json'::regtype,
916                                       'jsonb'::regtype, 'text'::regtype,
917                                       'xml'::regtype)
918            )
919          )
920        ORDER BY n.nspname, p.proname
921        "#,
922        &["schema", "name"],
923        &[texts(schemas)],
924    )
925    .await?;
926
927    for row in rows {
928        let schema: String = row.get("schema")?;
929        let name: String = row.get("name")?;
930        let qi = QualifiedIdentifier::new(&schema, &name);
931
932        let volatility: String = row.get("volatility")?;
933        let return_type_str: String = row.get("return_type")?;
934        let returns_set: bool = row.get("returns_set")?;
935
936        let return_type = if return_type_str == "void" {
937            RetType::Void
938        } else if returns_set {
939            RetType::SetOf(return_type_str.replace("SETOF ", ""))
940        } else {
941            RetType::Single(return_type_str)
942        };
943
944        // Composite ('c') and pseudo ('p', e.g. `record` from RETURNS TABLE)
945        // return types expand to their own columns in `SELECT * FROM fn()`;
946        // scalar returns yield a single column named after the function. Void
947        // is pseudo too but renders as a function-named null column, so it
948        // counts as non-composite.
949        let ret_typtype: Option<String> = row.get("ret_typtype")?;
950        // A named row type always expands. `record` expands only when the
951        // function declares its columns as `OUT` parameters or a `RETURNS
952        // TABLE` -- a bare `RETURNS record` has none, and PostgreSQL refuses
953        // to put one in a `FROM` clause without being told what they are.
954        let has_out_params: bool = row.get("has_out_params")?;
955        let returns_composite = !matches!(return_type, RetType::Void)
956            && (ret_typtype.as_deref() == Some("c")
957                || (ret_typtype.as_deref() == Some("p") && has_out_params));
958
959        let routine = Routine {
960            schema,
961            name,
962            description: row.get("description")?,
963            params: parse_routine_params(row.get("params")?),
964            return_type,
965            returns_composite,
966            volatility: FuncVolatility::from_char(volatility.chars().next().unwrap_or('v')),
967            has_variadic: row.get("has_variadic")?,
968            isolation_level: None,
969            settings: vec![],
970            is_procedure: row.get("is_procedure")?,
971            media_type: row.get("media_type")?,
972            media_base_type: row.get("media_base_type")?,
973            output_columns: parse_routine_params(row.get("out_params")?)
974                .into_iter()
975                .filter(|param| !param.name.is_empty())
976                .map(|param| (param.name, param.param_type))
977                .collect(),
978        };
979
980        routines.entry(qi).or_default().push(routine);
981    }
982
983    // Overloads of one name in a fixed order: fewest parameters first, then by
984    // the parameters themselves. Catalogue order is whatever the planner
985    // happened to return, and it decides which signature a client is offered
986    // first when a call cannot be told apart -- an answer that should not
987    // change between two servers reading the same schema.
988    for overloads in routines.values_mut() {
989        overloads.sort_by(|a, b| {
990            let key = |r: &Routine| {
991                (
992                    r.params.len(),
993                    r.params
994                        .iter()
995                        .map(|p| (p.name.clone(), p.param_type.clone()))
996                        .collect::<Vec<_>>(),
997                )
998            };
999            key(a).cmp(&key(b))
1000        });
1001    }
1002
1003    Ok(routines)
1004}
1005
1006/// Load valid timezone names.
1007pub async fn load_timezones<T: Tx>(tx: &mut T) -> Result<HashSet<String>> {
1008    let rows = catalog(tx, "SELECT name FROM pg_timezone_names", &[], &[]).await?;
1009
1010    rows.iter().map(|r| r.get("name")).collect()
1011}
1012
1013/// Load user-defined renderers for media types.
1014///
1015/// A renderer is an aggregate whose state type is a domain named after a media
1016/// type. The domain is how the name survives -- `application/geo+json` is not
1017/// a legal identifier otherwise -- and the `/` in it is what makes an
1018/// ordinary-looking domain recognisable as one.
1019///
1020/// The aggregate's argument says what it renders: a table's composite type
1021/// renders that table, and `anyelement` renders anything in the schema.
1022pub async fn load_media_handlers<T: Tx>(tx: &mut T, schemas: &[String]) -> Result<MediaHandlerMap> {
1023    let rows = catalog(
1024        tx,
1025        r#"
1026        SELECT
1027            n.nspname    AS agg_schema,
1028            p.proname    AS agg_name,
1029            t.typname    AS media_type,
1030            bt.typname   AS media_base_type,
1031            argn.nspname AS arg_schema,
1032            argc.relname AS arg_table
1033        FROM pg_aggregate a
1034        JOIN pg_proc p       ON p.oid = a.aggfnoid
1035        JOIN pg_namespace n  ON n.oid = p.pronamespace
1036        JOIN pg_type t       ON t.oid = a.aggtranstype
1037        JOIN pg_type bt      ON bt.oid = t.typbasetype
1038        LEFT JOIN pg_type argt      ON argt.oid = p.proargtypes[0]
1039        LEFT JOIN pg_class argc     ON argc.oid = argt.typrelid
1040        LEFT JOIN pg_namespace argn ON argn.oid = argc.relnamespace
1041        WHERE n.nspname = ANY($1)
1042          AND t.typtype = 'd'
1043          AND t.typname LIKE '%/%'
1044        "#,
1045        &[],
1046        &[texts(schemas)],
1047    )
1048    .await?;
1049
1050    let mut handlers: MediaHandlerMap = HashMap::new();
1051    for row in rows {
1052        let agg_schema: String = row.get("agg_schema")?;
1053        let media_type: String = row.get("media_type")?;
1054        let table = match (
1055            row.get::<Option<String>>("arg_schema")?,
1056            row.get::<Option<String>>("arg_table")?,
1057        ) {
1058            (Some(schema), Some(name)) => Some(QualifiedIdentifier::new(schema, name)),
1059            _ => None,
1060        };
1061
1062        handlers
1063            .entry((agg_schema.clone(), media_type))
1064            .or_default()
1065            .push(MediaHandler {
1066                aggregate: QualifiedIdentifier::new(agg_schema, row.get::<String>("agg_name")?),
1067                table,
1068                base_type: row.get("media_base_type")?,
1069            });
1070    }
1071
1072    Ok(handlers)
1073}
1074
1075/// Which base-table column each of a view's columns comes from.
1076///
1077/// A view has no foreign keys of its own, but it can still be embedded: the
1078/// relationships of the tables it selects from apply to it, through whichever
1079/// of their columns it carries.
1080///
1081/// The mapping is made by name. PostgreSQL records which base columns a view
1082/// depends on, but not which of the view's own columns each one became -- that
1083/// lives in the parsed rule tree. Matching by name covers a view that selects
1084/// its columns through unchanged, and misses one that renames them, which is
1085/// Columns read straight from the catalogue, for a relation
1086/// `information_schema` does not describe.
1087///
1088/// The columns are shaped to match what the `information_schema` query
1089/// returns, so the caller cannot tell which of the two answered.
1090async fn load_catalog_columns<T: Tx>(
1091    tx: &mut T,
1092    schema: &str,
1093    table: &str,
1094) -> Result<Vec<CatalogRow>> {
1095    catalog(
1096        tx,
1097        r#"
1098        SELECT
1099            a.attname AS column_name,
1100            a.attnum::int AS ordinal_position,
1101            CASE WHEN a.attnotnull THEN 'NO' ELSE 'YES' END AS is_nullable,
1102            pg_catalog.format_type(a.atttypid, NULL) AS data_type,
1103            t.typname AS udt_name,
1104            CASE WHEN t.typtype = 'd' THEN t.typname END AS domain_name,
1105            NULL::int AS character_maximum_length,
1106            pg_catalog.pg_get_expr(d.adbin, d.adrelid) AS column_default,
1107            (a.attidentity = 'a' OR a.attgenerated <> '') AS always_generated,
1108            pg_catalog.col_description(c.oid, a.attnum) AS description,
1109            COALESCE(
1110                (SELECT array_agg(e.enumlabel ORDER BY e.enumsortorder)
1111                   FROM pg_enum e WHERE e.enumtypid = t.oid),
1112                ARRAY[]::text[]
1113            ) AS enum_values
1114        FROM pg_class c
1115        JOIN pg_namespace n ON n.oid = c.relnamespace
1116        JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
1117        JOIN pg_type t ON t.oid = a.atttypid
1118        LEFT JOIN pg_attrdef d ON d.adrelid = c.oid AND d.adnum = a.attnum
1119        WHERE n.nspname = $1 AND c.relname = $2
1120        ORDER BY a.attnum
1121        "#,
1122        &["ordinal_position"],
1123        &[text(schema), text(table)],
1124    )
1125    .await
1126}
1127
1128/// Casts a schema declares between one of its domains and `json` or `text`.
1129///
1130/// PostgREST calls these data representations: a domain over `integer` with a
1131/// cast to `json` decides how a column of that domain is rendered, and a cast
1132/// back from `text` decides how a filter value written against it is read. It
1133/// lets a schema say that a colour is `"#01E240"` on the wire and an integer in
1134/// storage, with neither the client nor the table having to know about the
1135/// other.
1136///
1137/// Keyed by `(source type, target type)` so both directions come out of one
1138/// query: the render is `(domain, json)` and the parse is `(text, domain)`.
1139pub async fn load_representations<T: Tx>(
1140    tx: &mut T,
1141) -> Result<std::collections::HashMap<(String, String), String>> {
1142    // Whether a request may call the cast function: owned by, or executable
1143    // by, a role this connection can become. `has_function_privilege` asked
1144    // whether the connection could call it *as itself*, which a NOINHERIT
1145    // authenticator cannot once EXECUTE is revoked from PUBLIC -- and the
1146    // representation was dropped. See `acl_grants`.
1147    static SQL: LazyLock<String> = LazyLock::new(|| {
1148        format!(
1149            r#"
1150        SELECT c.castsource::regtype::text AS source,
1151               c.casttarget::regtype::text AS target,
1152               c.castfunc::regproc::text   AS function
1153          FROM pg_catalog.pg_cast c
1154          JOIN pg_catalog.pg_type src ON src.oid = c.castsource
1155          JOIN pg_catalog.pg_type dst ON dst.oid = c.casttarget
1156          JOIN pg_catalog.pg_proc p ON p.oid = c.castfunc
1157         WHERE c.castcontext = 'i'
1158           AND c.castmethod = 'f'
1159           AND (pg_has_role(p.proowner, 'MEMBER') OR {execute})
1160           AND ((src.typtype = 'd' AND c.casttarget IN ('json'::regtype, 'text'::regtype))
1161             OR (dst.typtype = 'd' AND c.castsource IN ('json'::regtype, 'text'::regtype)))
1162        "#,
1163            execute = acl_grants("COALESCE(p.proacl, acldefault('f', p.proowner))", "EXECUTE"),
1164        )
1165    });
1166    let rows = catalog(tx, &SQL, &[], &[]).await?;
1167
1168    rows.into_iter()
1169        .map(|row| {
1170            Ok((
1171                (row.get::<String>("source")?, row.get::<String>("target")?),
1172                row.get::<String>("function")?,
1173            ))
1174        })
1175        .collect()
1176}
1177
1178/// Functions of a table's row type, which read as though they were columns.
1179///
1180/// `create function always_true(items) returns boolean` makes
1181/// `?select=id,always_true` and `?always_true=is.true` work on `items`:
1182/// PostgreSQL resolves `items.always_true` to the function call, and PostgREST
1183/// exposes that. Only single-argument functions returning a single value
1184/// qualify -- one returning `setof` another table is a computed *relationship*,
1185/// which is embedded rather than selected.
1186/// `function_schemas` is where the function itself may live: the exposed
1187/// schemas plus whatever `db-extra-search-path` adds. The table has to be
1188/// exposed -- it is the thing being read -- but the function need not be, and
1189/// PostgREST resolves it along the search path. `always_false(test.items)`
1190/// lives in `public`, so requiring the function to be in an exposed schema
1191/// left `?always_false=is.false` answered "column does not exist" for a field
1192/// PostgREST reads as a column.
1193pub async fn load_computed_columns<T: Tx>(
1194    tx: &mut T,
1195    schemas: &[String],
1196    function_schemas: &[String],
1197) -> Result<Vec<ComputedColumnRow>> {
1198    // One argument is the table's row. The rest are the caller's, and are
1199    // exposed as the field's own arguments -- except the session document
1200    // Hasura fills in, `hasura_session json`, which a client never sends and
1201    // which is what lets a computed field know who is asking.
1202    //
1203    // Every argument has to be named: one that is not cannot be given by name,
1204    // and a call with more than the row has to be.
1205    let rows = catalog(
1206        tx,
1207        r#"
1208        SELECT tn.nspname AS table_schema,
1209               t.relname  AS table_name,
1210               fn.nspname AS function_schema,
1211               p.proname  AS function_name,
1212               pg_catalog.format_type(p.prorettype, null) AS return_type,
1213               pg_catalog.obj_description(p.oid, 'pg_proc') AS description,
1214               (SELECT p.proargnames[i]
1215                  FROM generate_series(1, p.pronargs) AS i
1216                 WHERE p.proargtypes[i - 1] = t.reltype
1217                 LIMIT 1) AS row_argument,
1218               (SELECT p.proargnames[i]
1219                  FROM generate_series(1, p.pronargs) AS i
1220                 WHERE p.proargnames[i] = 'hasura_session'
1221                 LIMIT 1) AS session_argument,
1222               p.pronargs > 1 AS takes_arguments
1223          FROM pg_proc p
1224          JOIN pg_namespace fn ON fn.oid = p.pronamespace
1225          JOIN pg_class t ON t.reltype = ANY (p.proargtypes::oid[])
1226          JOIN pg_namespace tn ON tn.oid = t.relnamespace
1227         WHERE (p.pronargs = 1 OR (SELECT count(*)
1228                                     FROM generate_series(1, p.pronargs) AS i
1229                                    WHERE COALESCE(p.proargnames[i], '') = '') = 0)
1230           AND NOT p.proretset
1231           AND p.prokind = 'f'
1232           AND p.prorettype <> 'pg_catalog.trigger'::pg_catalog.regtype
1233           AND t.relkind = ANY (ARRAY['r', 'v', 'm', 'p', 'f'])
1234           AND fn.nspname = ANY($2)
1235           AND tn.nspname = ANY($1)
1236        "#,
1237        &[],
1238        &[texts(schemas), texts(function_schemas)],
1239    )
1240    .await?;
1241
1242    rows.into_iter()
1243        .map(|row| -> Result<_> {
1244            Ok(ComputedColumnRow {
1245                table: QualifiedIdentifier::new(
1246                    row.get::<String>("table_schema")?,
1247                    row.get::<String>("table_name")?,
1248                ),
1249                name: row.get::<String>("function_name")?,
1250                function: QualifiedIdentifier::new(
1251                    row.get::<String>("function_schema")?,
1252                    row.get::<String>("function_name")?,
1253                ),
1254                return_type: row.get::<String>("return_type")?,
1255                description: row.get::<Option<String>>("description")?,
1256                row_argument: row.get::<Option<String>>("row_argument")?,
1257                session_argument: row.get::<Option<String>>("session_argument")?,
1258                takes_arguments: row.get::<bool>("takes_arguments")?,
1259            })
1260        })
1261        .collect()
1262}
1263
1264/// One function that reads as a column of a table.
1265#[derive(Clone, Debug)]
1266pub struct ComputedColumnRow {
1267    /// The table it belongs to.
1268    pub table: QualifiedIdentifier,
1269    /// The name it is read under, which is the function's.
1270    pub name: String,
1271    /// The function itself.
1272    pub function: QualifiedIdentifier,
1273    /// What it returns.
1274    pub return_type: String,
1275    /// The function's comment.
1276    pub description: Option<String>,
1277    /// The parameter that takes the row.
1278    pub row_argument: Option<String>,
1279    /// The parameter filled from the session, if any.
1280    pub session_argument: Option<String>,
1281    /// Whether it takes anything besides the row.
1282    pub takes_arguments: bool,
1283}
1284
1285/// One view column, and the base-table column it was selected from.
1286#[derive(Clone, Debug)]
1287pub struct ViewColumn {
1288    /// The view.
1289    pub view: QualifiedIdentifier,
1290    /// The table the column ultimately comes from.
1291    pub base: QualifiedIdentifier,
1292    /// The column's name on the base table.
1293    pub base_column: String,
1294    /// The name the view gives it.
1295    pub view_column: String,
1296}
1297
1298/// Which base-table column each view column came from.
1299///
1300/// A view may rename what it selects -- `select article_id as "articleId"` --
1301/// so matching by name gets the mapping wrong exactly where it matters, and a
1302/// view over another view has to be followed through. PostgreSQL records the
1303/// answer in the rewrite rule's target list, where every entry carries the
1304/// table and column it originated from, but only as a `pg_node_tree`, which
1305/// has no catalogue view and no parser exposed to SQL.
1306///
1307/// The shape below is PostgREST's: turn the node tree into JSON with a fixed
1308/// series of string replacements -- the fields wanted are quoted first so the
1309/// one regular expression can strip everything else -- read the target list
1310/// out of it, then follow view-on-view chains recursively, stopping on a cycle.
1311///
1312/// One base column may arrive under several names (`select t1_id as t1_id1,
1313/// t1_id as t1_id2`), so a single mapping is a row per name.
1314pub async fn load_view_columns<T: Tx>(tx: &mut T, schemas: &[String]) -> Result<Vec<ViewColumn>> {
1315    let rows = catalog(
1316        tx,
1317        r#"
1318        WITH RECURSIVE
1319        views AS (
1320            SELECT c.oid AS view_id, c.relnamespace AS view_schema_id,
1321                   n.nspname AS view_schema, c.relname AS view_name,
1322                   r.ev_action AS view_definition
1323              FROM pg_class c
1324              JOIN pg_namespace n ON n.oid = c.relnamespace
1325              JOIN pg_rewrite r ON r.ev_class = c.oid
1326             WHERE c.relkind IN ('v', 'm')
1327        ),
1328        transform_json AS (
1329            SELECT view_id, view_schema_id, view_schema, view_name,
1330                replace(
1331                replace(
1332                replace(
1333                replace(
1334                replace(
1335                replace(
1336                replace(
1337                regexp_replace(
1338                replace(
1339                replace(
1340                replace(
1341                replace(
1342                replace(
1343                replace(
1344                replace(
1345                replace(
1346                replace(
1347                replace(
1348                replace(
1349                    view_definition::text,
1350                    '<>', '()'
1351                ), ',', ''
1352                ), E'\\{', ''
1353                ), E'\\}', ''
1354                ), ' :targetList ', ',"targetList":'
1355                ), ' :resno ', ',"resno":'
1356                ), ' :resorigtbl ', ',"resorigtbl":'
1357                ), ' :resorigcol ', ',"resorigcol":'
1358                ), '{', '{ :'
1359                ), '((', '{(('
1360                ), '({', '{({'
1361                ), ' :[^}{,]+', ',"":', 'g'
1362                ), ',"":}', '}'
1363                ), ',"":,', ','
1364                ), '{(', '('
1365                ), '{,', '{'
1366                ), '(', '['
1367                ), ')', ']'
1368                ), ' ', ','
1369                )::json AS view_definition
1370              FROM views
1371        ),
1372        results AS (
1373            SELECT view_id, view_schema_id, view_schema, view_name,
1374                   (entry->>'resno')::int AS view_column,
1375                   (entry->>'resorigtbl')::oid AS resorigtbl,
1376                   (entry->>'resorigcol')::int AS resorigcol
1377              FROM (
1378                SELECT view_id, view_schema_id, view_schema, view_name,
1379                       json_array_elements(view_definition->0->'targetList') AS entry
1380                  FROM transform_json
1381              ) target_entries
1382        ),
1383        -- A view over a view is followed down to the base table. `path`
1384        -- carries the tables already visited so a cyclic definition stops
1385        -- rather than recursing forever.
1386        recursion(view_id, view_schema, view_name, view_column,
1387                  resorigtbl, resorigcol, is_cycle, path) AS (
1388            SELECT r.view_id, r.view_schema, r.view_name, r.view_column,
1389                   r.resorigtbl, r.resorigcol, false, ARRAY[r.resorigtbl]
1390              FROM results r
1391             WHERE r.view_schema = ANY($1)
1392            UNION ALL
1393            SELECT v.view_id, v.view_schema, v.view_name, v.view_column,
1394                   t.resorigtbl, t.resorigcol,
1395                   t.resorigtbl = ANY(v.path), v.path || t.resorigtbl
1396              FROM recursion v
1397              JOIN results t ON v.resorigtbl = t.view_id AND v.resorigcol = t.view_column
1398             WHERE NOT v.is_cycle
1399        )
1400        SELECT DISTINCT
1401               rec.view_schema, rec.view_name,
1402               sch.nspname AS base_schema, tbl.relname AS base_name,
1403               col.attname AS base_column, vcol.attname AS view_column
1404          FROM recursion rec
1405          JOIN pg_attribute vcol ON vcol.attrelid = rec.view_id
1406                                AND vcol.attnum = rec.view_column
1407                                AND NOT vcol.attisdropped
1408          JOIN pg_class tbl ON tbl.oid = rec.resorigtbl
1409          JOIN pg_namespace sch ON sch.oid = tbl.relnamespace
1410          JOIN pg_attribute col ON col.attrelid = tbl.oid
1411                               AND col.attnum = rec.resorigcol
1412                               AND NOT col.attisdropped
1413         WHERE rec.resorigtbl <> 0
1414        "#,
1415        &[],
1416        &[texts(schemas)],
1417    )
1418    .await?;
1419
1420    rows.into_iter()
1421        .map(|row| -> Result<_> {
1422            Ok(ViewColumn {
1423                view: QualifiedIdentifier::new(
1424                    row.get::<String>("view_schema")?,
1425                    row.get::<String>("view_name")?,
1426                ),
1427                base: QualifiedIdentifier::new(
1428                    row.get::<String>("base_schema")?,
1429                    row.get::<String>("base_name")?,
1430                ),
1431                base_column: row.get::<String>("base_column")?,
1432                view_column: row.get::<String>("view_column")?,
1433            })
1434        })
1435        .collect()
1436}
1437
1438/// Primary key columns for every table in the given schemas.
1439///
1440/// Junction detection needs the key of the table doing the joining, and that
1441/// table may sit in a schema which is not exposed -- the junction behind two
1442/// public views. Loading the key alone keeps it out of the API surface while
1443/// still letting the relationship be derived.
1444/// Every unique constraint, by the table it belongs to.
1445///
1446/// Returned as `(constraint name, columns)`. The primary key is one of them:
1447/// PostgreSQL treats it as a unique constraint and `ON CONFLICT ON CONSTRAINT`
1448/// accepts it, which is what an upsert on a table's key needs.
1449///
1450/// Only constraints, not bare unique indexes. `ON CONFLICT ON CONSTRAINT`
1451/// takes a constraint name, and a unique index created without one has no name
1452/// to give it.
1453pub async fn load_unique_constraints<T: Tx>(
1454    tx: &mut T,
1455    schemas: &[String],
1456) -> Result<HashMap<QualifiedIdentifier, Vec<(String, Vec<String>)>>> {
1457    let rows = catalog(
1458        tx,
1459        r#"
1460        SELECT
1461            n.nspname AS table_schema,
1462            c.relname AS table_name,
1463            con.conname AS constraint_name,
1464            (SELECT array_agg(a.attname ORDER BY k.ord)
1465               FROM unnest(con.conkey) WITH ORDINALITY AS k(attnum, ord)
1466               JOIN pg_attribute a
1467                 ON a.attrelid = c.oid AND a.attnum = k.attnum) AS columns
1468        FROM pg_constraint con
1469        JOIN pg_class c     ON c.oid = con.conrelid
1470        JOIN pg_namespace n ON n.oid = c.relnamespace
1471        WHERE con.contype IN ('p', 'u')
1472          AND n.nspname = ANY($1)
1473        "#,
1474        &[],
1475        &[texts(schemas)],
1476    )
1477    .await?;
1478
1479    let mut by_table: HashMap<QualifiedIdentifier, Vec<(String, Vec<String>)>> = HashMap::new();
1480    for row in rows {
1481        let Some(columns): Option<Vec<String>> = row.get("columns")? else {
1482            continue;
1483        };
1484        by_table
1485            .entry(QualifiedIdentifier::new(
1486                row.get::<String>("table_schema")?,
1487                row.get::<String>("table_name")?,
1488            ))
1489            .or_default()
1490            .push((row.get::<String>("constraint_name")?, columns));
1491    }
1492    for constraints in by_table.values_mut() {
1493        constraints.sort();
1494    }
1495    Ok(by_table)
1496}
1497
1498pub async fn load_primary_keys<T: Tx>(
1499    tx: &mut T,
1500    schemas: &[String],
1501) -> Result<HashMap<QualifiedIdentifier, Vec<String>>> {
1502    let rows = catalog(
1503        tx,
1504        r#"
1505        SELECT
1506            n.nspname AS table_schema,
1507            c.relname AS table_name,
1508            (SELECT array_agg(a.attname ORDER BY k.ord)
1509               FROM unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord)
1510               JOIN pg_attribute a
1511                 ON a.attrelid = c.oid AND a.attnum = k.attnum) AS pk_cols
1512        FROM pg_index i
1513        JOIN pg_class c      ON c.oid = i.indrelid
1514        JOIN pg_namespace n  ON n.oid = c.relnamespace
1515        WHERE i.indisprimary
1516          AND n.nspname = ANY($1)
1517        "#,
1518        &[],
1519        &[texts(schemas)],
1520    )
1521    .await?;
1522
1523    let mut keys = HashMap::new();
1524    for row in rows {
1525        let Some(columns): Option<Vec<String>> = row.get("pk_cols")? else {
1526            continue;
1527        };
1528        keys.insert(
1529            QualifiedIdentifier::new(
1530                row.get::<String>("table_schema")?,
1531                row.get::<String>("table_name")?,
1532            ),
1533            columns,
1534        );
1535    }
1536    Ok(keys)
1537}
1538
1539/// The members of an enum table: its key column, and its `comment` column
1540/// when it has one, ordered by key.
1541///
1542/// Read as a role that may read the table. The connection reads it as itself
1543/// when it can, which a superuser or an inheriting authenticator always
1544/// could; a NOINHERIT authenticator can read nothing as itself, so it becomes
1545/// the first role it can become that holds SELECT on the table, inside a
1546/// transaction that is then rolled back.
1547#[cfg(feature = "sqlx")]
1548pub async fn enum_values(
1549    pool: &sqlx::PgPool,
1550    schema: &str,
1551    table: &str,
1552    key_column: &str,
1553    has_comment: bool,
1554) -> Result<Vec<(String, Option<String>)>> {
1555    let failed = |e: sqlx::Error| Error::SchemaCacheLoadFailed(e.to_string());
1556    let mut tx = pool.begin().await.map_err(failed)?;
1557
1558    let reader: Option<String> = sqlx::query_scalar(
1559        r#"
1560        SELECT r.rolname::text
1561          FROM pg_class c
1562          JOIN pg_namespace n ON n.oid = c.relnamespace
1563          JOIN pg_roles r ON pg_has_role(r.oid, 'MEMBER')
1564         WHERE n.nspname = $1 AND c.relname = $2
1565           AND has_schema_privilege(r.oid, n.oid, 'USAGE')
1566           AND has_table_privilege(r.oid, c.oid, 'SELECT')
1567         ORDER BY r.rolname = current_user DESC, r.rolname
1568         LIMIT 1
1569        "#,
1570    )
1571    .bind(schema)
1572    .bind(table)
1573    .fetch_optional(&mut *tx)
1574    .await
1575    .map_err(failed)?;
1576    let current: String = sqlx::query_scalar("SELECT current_user::text")
1577        .fetch_one(&mut *tx)
1578        .await
1579        .map_err(failed)?;
1580    // No role can read it: read as the connection anyway, so the error says
1581    // what PostgreSQL says rather than something invented here.
1582    if let Some(reader) = reader.filter(|reader| *reader != current) {
1583        sqlx::query(&format!(
1584            "SET LOCAL ROLE {}",
1585            postrust_sql::escape_ident(&reader)
1586        ))
1587        .execute(&mut *tx)
1588        .await
1589        .map_err(failed)?;
1590    }
1591
1592    let comment = match has_comment {
1593        true => format!("{}::text", postrust_sql::escape_ident("comment")),
1594        false => "NULL::text".to_string(),
1595    };
1596    let sql = format!(
1597        "SELECT {}::text, {} FROM {}.{} ORDER BY 1",
1598        postrust_sql::escape_ident(key_column),
1599        comment,
1600        postrust_sql::escape_ident(schema),
1601        postrust_sql::escape_ident(table)
1602    );
1603    let rows: Vec<(Option<String>, Option<String>)> = sqlx::query_as(&sql)
1604        .fetch_all(&mut *tx)
1605        .await
1606        .map_err(failed)?;
1607    tx.rollback().await.map_err(failed)?;
1608
1609    Ok(rows
1610        .into_iter()
1611        .filter_map(|(value, comment)| value.map(|v| (v, comment)))
1612        .collect())
1613}
1614
1615#[cfg(test)]
1616mod tests {
1617    use super::*;
1618    use serde_json::json;
1619
1620    fn row(value: serde_json::Value) -> CatalogRow {
1621        match value {
1622            serde_json::Value::Object(map) => CatalogRow(map),
1623            other => panic!("not an object: {other}"),
1624        }
1625    }
1626
1627    #[test]
1628    fn a_null_column_reads_as_none() {
1629        let row = row(json!({"description": null}));
1630        assert_eq!(row.get::<Option<String>>("description").unwrap(), None);
1631    }
1632
1633    #[test]
1634    fn a_column_the_query_does_not_have_fails_the_load() {
1635        // A misspelled name: read as null, it would quietly be `None`.
1636        let row = row(json!({"description": "a table"}));
1637        let error = row.get::<Option<String>>("descripton").unwrap_err();
1638        assert!(
1639            matches!(&error, Error::SchemaCacheLoadFailed(m) if m.contains("descripton")),
1640            "{error:?}"
1641        );
1642    }
1643
1644    #[test]
1645    fn a_value_of_the_wrong_type_fails_the_load() {
1646        let row = row(json!({"is_unique": "yes"}));
1647        assert!(matches!(
1648            row.get::<bool>("is_unique"),
1649            Err(Error::SchemaCacheLoadFailed(_))
1650        ));
1651    }
1652
1653    #[test]
1654    fn the_wrapper_sorts_by_the_order_it_is_given() {
1655        assert_eq!(
1656            catalog_sql("SELECT 1 AS a, 2 AS b ORDER BY a", &["a", "b"]),
1657            "SELECT row_to_json(pgrst_catalog) FROM (SELECT 1 AS a, 2 AS b ORDER BY a) \
1658             AS pgrst_catalog ORDER BY pgrst_catalog.a, pgrst_catalog.b"
1659        );
1660        assert_eq!(
1661            catalog_sql("SELECT 1 AS a", &[]),
1662            "SELECT row_to_json(pgrst_catalog) FROM (SELECT 1 AS a) AS pgrst_catalog"
1663        );
1664    }
1665}