1use 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
16pub 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
33pub(super) struct CatalogRow(serde_json::Map<String, serde_json::Value>);
41
42impl CatalogRow {
43 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
59async 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
87fn 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
99fn text(value: &str) -> SqlParam {
101 SqlParam::Text(value.to_string())
102}
103
104fn texts(values: &[String]) -> SqlParam {
106 SqlParam::Array(values.iter().cloned().map(SqlParam::Text).collect())
107}
108
109fn 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
133fn can_become(role: &str) -> String {
139 format!("CASE WHEN {role} = 0 THEN true ELSE pg_has_role({role}, 'MEMBER') END")
140}
141
142const 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
153const RELATION_ACL: &str = "COALESCE(c.relacl, acldefault('r', c.relowner))";
155
156fn 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
177fn 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
191pub async fn load_tables<T: Tx>(tx: &mut T, schemas: &[String]) -> Result<TablesMap> {
193 let mut tables = HashMap::new();
194
195 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
322async 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 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 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
497pub 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 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 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 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
652async 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
743fn 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 type_max_length: item
769 .get("cast_type")
770 .and_then(|t| t.as_str())
771 .unwrap_or(¶m_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
787pub 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 let ret_typtype: Option<String> = row.get("ret_typtype")?;
950 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 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
1006pub 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
1013pub 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
1075async 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
1128pub async fn load_representations<T: Tx>(
1140 tx: &mut T,
1141) -> Result<std::collections::HashMap<(String, String), String>> {
1142 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
1178pub async fn load_computed_columns<T: Tx>(
1194 tx: &mut T,
1195 schemas: &[String],
1196 function_schemas: &[String],
1197) -> Result<Vec<ComputedColumnRow>> {
1198 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#[derive(Clone, Debug)]
1266pub struct ComputedColumnRow {
1267 pub table: QualifiedIdentifier,
1269 pub name: String,
1271 pub function: QualifiedIdentifier,
1273 pub return_type: String,
1275 pub description: Option<String>,
1277 pub row_argument: Option<String>,
1279 pub session_argument: Option<String>,
1281 pub takes_arguments: bool,
1283}
1284
1285#[derive(Clone, Debug)]
1287pub struct ViewColumn {
1288 pub view: QualifiedIdentifier,
1290 pub base: QualifiedIdentifier,
1292 pub base_column: String,
1294 pub view_column: String,
1296}
1297
1298pub 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
1438pub 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#[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 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 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}