Skip to main content

fraiseql_db/postgres/
introspector.rs

1/// ! PostgreSQL database introspection for fact tables.
2use deadpool_postgres::Pool;
3use fraiseql_error::{FraiseQLError, Result};
4use tokio_postgres::Row;
5
6use super::pg_detail;
7use crate::{
8    DatabaseType,
9    introspector::{DatabaseIntrospector, RelationInfo, RelationKind},
10};
11
12/// PostgreSQL introspector for fact table metadata.
13pub struct PostgresIntrospector {
14    pool: Pool,
15}
16
17/// Split `schema.relation` into its parts; an unqualified name yields `None` for
18/// the schema, which the catalog queries resolve against the search path.
19fn split_relation(relation: &str) -> (Option<&str>, &str) {
20    relation
21        .find('.')
22        .map_or((None, relation), |dot| (Some(&relation[..dot]), &relation[dot + 1..]))
23}
24
25/// One index on a relation, with its key list in **declared order** (#1307).
26///
27/// Order is the whole point: an index on `(status, pk_invoice)` serves
28/// `ORDER BY status, pk_invoice` without a sort, while separate indexes on
29/// `status` and on `pk_invoice` leave an incremental sort on every page. The flat
30/// column set `get_indexed_columns` returns cannot tell those two apart — it
31/// reports `{pk_invoice, status}` for both, which is why #1307 could not be built
32/// on it.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct IndexInfo {
35    /// The index's own relation name.
36    pub name:   String,
37    /// Whether the index is unique.
38    pub unique: bool,
39    /// Key definitions in declared order. A key is a bare column name (`status`)
40    /// or an expression as Postgres renders it (`(data ->> 'id'::text)`), so an
41    /// expression index is visible rather than silently dropped.
42    pub keys:   Vec<String>,
43}
44
45impl PostgresIntrospector {
46    /// Create new PostgreSQL introspector from connection pool.
47    #[must_use]
48    pub const fn new(pool: Pool) -> Self {
49        Self { pool }
50    }
51}
52
53impl DatabaseIntrospector for PostgresIntrospector {
54    async fn list_fact_tables(&self) -> Result<Vec<String>> {
55        let client = self.pool.get().await.map_err(|e| FraiseQLError::ConnectionPool {
56            message: format!("Failed to acquire connection: {e}"),
57        })?;
58
59        // Query information_schema for tables matching tf_* pattern across all schemas
60        // in the current search path.
61        let query = r"
62            SELECT table_name
63            FROM information_schema.tables
64            WHERE table_schema = ANY(current_schemas(false))
65              AND table_type = 'BASE TABLE'
66              AND table_name LIKE 'tf_%'
67            ORDER BY table_name
68        ";
69
70        let rows: Vec<Row> =
71            client.query(query, &[]).await.map_err(|e| FraiseQLError::Database {
72                message:   format!("Failed to list fact tables: {}", pg_detail(&e)),
73                sql_state: e.code().map(|c| c.code().to_string()),
74            })?;
75
76        let tables = rows
77            .into_iter()
78            .map(|row| {
79                let name: String = row.get(0);
80                name
81            })
82            .collect();
83
84        Ok(tables)
85    }
86
87    async fn get_columns(&self, table_name: &str) -> Result<Vec<(String, String, bool)>> {
88        let client = self.pool.get().await.map_err(|e| FraiseQLError::ConnectionPool {
89            message: format!("Failed to acquire connection: {e}"),
90        })?;
91
92        // Support schema-qualified names like "benchmark.tv_post".
93        // When no schema prefix is present, search across all schemas in the current
94        // search path so that non-public schemas work without explicit qualification.
95        let rows: Vec<Row> = if let Some(dot) = table_name.find('.') {
96            let (schema, name) = (&table_name[..dot], &table_name[dot + 1..]);
97            client
98                .query(
99                    r"SELECT column_name,
100                             -- An extension type (ltree, vector) reads as USER-DEFINED
101                             -- here; its own name is in udt_name (#1498).
102                             CASE WHEN data_type = 'USER-DEFINED' THEN udt_name ELSE data_type END,
103                             is_nullable = 'YES' as is_nullable
104                      FROM information_schema.columns
105                      WHERE table_name = $1 AND table_schema = $2
106                      ORDER BY ordinal_position",
107                    &[&name, &schema],
108                )
109                .await
110        } else {
111            client
112                .query(
113                    r"SELECT column_name,
114                             -- An extension type (ltree, vector) reads as USER-DEFINED
115                             -- here; its own name is in udt_name (#1498).
116                             CASE WHEN data_type = 'USER-DEFINED' THEN udt_name ELSE data_type END,
117                             is_nullable = 'YES' as is_nullable
118                      FROM information_schema.columns
119                      WHERE table_name = $1
120                        AND table_schema = ANY(current_schemas(false))
121                      ORDER BY ordinal_position",
122                    &[&table_name],
123                )
124                .await
125        }
126        .map_err(|e| FraiseQLError::Database {
127            message:   format!("Failed to query column information: {}", pg_detail(&e)),
128            sql_state: e.code().map(|c| c.code().to_string()),
129        })?;
130
131        let columns = rows
132            .into_iter()
133            .map(|row| {
134                let name: String = row.get(0);
135                let data_type: String = row.get(1);
136                let is_nullable: bool = row.get(2);
137                (name, data_type, is_nullable)
138            })
139            .collect();
140
141        Ok(columns)
142    }
143
144    async fn get_indexed_columns(&self, table_name: &str) -> Result<Vec<String>> {
145        let client = self.pool.get().await.map_err(|e| FraiseQLError::ConnectionPool {
146            message: format!("Failed to acquire connection: {e}"),
147        })?;
148
149        // Support schema-qualified names like "benchmark.tv_post".
150        // When no schema prefix is present, search across all schemas in the current
151        // search path so that non-public schemas work without explicit qualification.
152        let rows: Vec<Row> = if let Some(dot) = table_name.find('.') {
153            let (schema, name) = (&table_name[..dot], &table_name[dot + 1..]);
154            client
155                .query(
156                    r"SELECT DISTINCT a.attname AS column_name
157                      FROM pg_index i
158                      JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
159                      JOIN pg_class t ON t.oid = i.indrelid
160                      JOIN pg_namespace n ON n.oid = t.relnamespace
161                      WHERE t.relname = $1 AND n.nspname = $2 AND a.attnum > 0
162                      ORDER BY column_name",
163                    &[&name, &schema],
164                )
165                .await
166        } else {
167            client
168                .query(
169                    r"SELECT DISTINCT a.attname AS column_name
170                      FROM pg_index i
171                      JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
172                      JOIN pg_class t ON t.oid = i.indrelid
173                      JOIN pg_namespace n ON n.oid = t.relnamespace
174                      WHERE t.relname = $1
175                        AND n.nspname = ANY(current_schemas(false))
176                        AND a.attnum > 0
177                      ORDER BY column_name",
178                    &[&table_name],
179                )
180                .await
181        }
182        .map_err(|e| FraiseQLError::Database {
183            message:   format!("Failed to query index information: {}", pg_detail(&e)),
184            sql_state: e.code().map(|c| c.code().to_string()),
185        })?;
186
187        let indexed_columns = rows
188            .into_iter()
189            .map(|row| {
190                let name: String = row.get(0);
191                name
192            })
193            .collect();
194
195        Ok(indexed_columns)
196    }
197
198    fn database_type(&self) -> DatabaseType {
199        DatabaseType::PostgreSQL
200    }
201
202    async fn list_relations(&self) -> Result<Vec<RelationInfo>> {
203        let client = self.pool.get().await.map_err(|e| FraiseQLError::ConnectionPool {
204            message: format!("Failed to acquire connection: {e}"),
205        })?;
206
207        // List all tables and views visible in the current search path, excluding
208        // system schemas. Uses current_schemas(false) to match the same scope as
209        // get_columns / get_indexed_columns so that relation existence checks are
210        // consistent with column lookups.
211        let rows: Vec<Row> = client
212            .query(
213                r"SELECT table_schema, table_name,
214                         CASE table_type WHEN 'VIEW' THEN 'view' ELSE 'table' END AS kind
215                  FROM information_schema.tables
216                  WHERE table_schema = ANY(current_schemas(false))
217                  ORDER BY table_schema, table_name",
218                &[],
219            )
220            .await
221            .map_err(|e| FraiseQLError::Database {
222                message:   format!("Failed to list relations: {}", pg_detail(&e)),
223                sql_state: e.code().map(|c| c.code().to_string()),
224            })?;
225
226        let relations = rows
227            .into_iter()
228            .map(|row| {
229                let schema: String = row.get(0);
230                let name: String = row.get(1);
231                let kind_str: String = row.get(2);
232                let kind = if kind_str == "view" {
233                    RelationKind::View
234                } else {
235                    RelationKind::Table
236                };
237                RelationInfo { schema, name, kind }
238            })
239            .collect();
240
241        Ok(relations)
242    }
243
244    async fn get_sample_jsonb(
245        &self,
246        table_name: &str,
247        column_name: &str,
248    ) -> Result<Option<serde_json::Value>> {
249        let client = self.pool.get().await.map_err(|e| FraiseQLError::ConnectionPool {
250            message: format!("Failed to acquire connection: {e}"),
251        })?;
252
253        // Query for a sample row with non-null JSON data
254        // Use format! for identifiers (safe because we validate table_name pattern)
255        let query = format!(
256            r#"
257            SELECT "{column}"::text
258            FROM "{table}"
259            WHERE "{column}" IS NOT NULL
260            LIMIT 1
261            "#,
262            table = table_name,
263            column = column_name
264        );
265
266        let rows: Vec<Row> =
267            client.query(&query, &[]).await.map_err(|e| FraiseQLError::Database {
268                message:   format!("Failed to query sample JSONB: {}", pg_detail(&e)),
269                sql_state: e.code().map(|c| c.code().to_string()),
270            })?;
271
272        if rows.is_empty() {
273            return Ok(None);
274        }
275
276        let json_text: Option<String> = rows[0].get(0);
277        if let Some(text) = json_text {
278            let value: serde_json::Value =
279                serde_json::from_str(&text).map_err(|e| FraiseQLError::Parse {
280                    message:  format!("Failed to parse JSONB sample: {e}"),
281                    location: format!("{table_name}.{column_name}"),
282                })?;
283            Ok(Some(value))
284        } else {
285            Ok(None)
286        }
287    }
288
289    async fn get_sample_json_rows(
290        &self,
291        table_name: &str,
292        column_name: &str,
293        limit: usize,
294    ) -> Result<Vec<serde_json::Value>> {
295        let client = self.pool.get().await.map_err(|e| FraiseQLError::ConnectionPool {
296            message: format!("Failed to acquire connection: {e}"),
297        })?;
298
299        // Quote each dot-separated part (with `"` doubled) so a schema-qualified
300        // source like `analytics.v_thing` resolves as schema + relation instead
301        // of one bogus identifier.
302        let table = table_name
303            .split('.')
304            .map(|part| format!("\"{}\"", part.replace('"', "\"\"")))
305            .collect::<Vec<_>>()
306            .join(".");
307        let column = format!("\"{}\"", column_name.replace('"', "\"\""));
308        let query =
309            format!("SELECT {column}::text FROM {table} WHERE {column} IS NOT NULL LIMIT {limit}");
310
311        let rows: Vec<Row> =
312            client.query(&query, &[]).await.map_err(|e| FraiseQLError::Database {
313                message:   format!("Failed to query sample JSON rows: {}", pg_detail(&e)),
314                sql_state: e.code().map(|c| c.code().to_string()),
315            })?;
316
317        rows.iter()
318            .filter_map(|row| row.get::<_, Option<String>>(0))
319            .map(|text| {
320                serde_json::from_str(&text).map_err(|e| FraiseQLError::Parse {
321                    message:  format!("Failed to parse JSON sample: {e}"),
322                    location: format!("{table_name}.{column_name}"),
323                })
324            })
325            .collect()
326    }
327
328    async fn function_exists(&self, schema: Option<&str>, name: &str) -> Result<Option<bool>> {
329        let client = self.pool.get().await.map_err(|e| FraiseQLError::ConnectionPool {
330            message: format!("Failed to acquire connection: {e}"),
331        })?;
332
333        // Mirror `pg_catalog::resolve_functions` name/schema matching (verbatim,
334        // schema-qualified or `current_schemas(false)`-scoped). Restrict to kinds a
335        // mutation can actually call — regular function `f` or procedure `p` —
336        // excluding aggregate `a` / window `w`, which `resolve_functions` does not
337        // filter (a minor over-match there).
338        let row = if let Some(schema) = schema {
339            client
340                .query_one(
341                    "SELECT EXISTS(SELECT 1 FROM pg_proc p \
342                       JOIN pg_namespace n ON n.oid = p.pronamespace \
343                       WHERE n.nspname = $1 AND p.proname = $2 AND p.prokind IN ('f','p'))",
344                    &[&schema, &name],
345                )
346                .await
347        } else {
348            client
349                .query_one(
350                    "SELECT EXISTS(SELECT 1 FROM pg_proc p \
351                       JOIN pg_namespace n ON n.oid = p.pronamespace \
352                       WHERE p.proname = $1 AND n.nspname = ANY(current_schemas(false)) \
353                       AND p.prokind IN ('f','p'))",
354                    &[&name],
355                )
356                .await
357        }
358        .map_err(|e| FraiseQLError::Database {
359            message:   format!("Failed to probe function existence: {}", pg_detail(&e)),
360            sql_state: e.code().map(|c| c.code().to_string()),
361        })?;
362
363        Ok(Some(row.get(0)))
364    }
365
366    async fn qualified_relation_exists(&self, schema: &str, name: &str) -> Result<Option<bool>> {
367        let client = self.pool.get().await.map_err(|e| FraiseQLError::ConnectionPool {
368            message: format!("Failed to acquire connection: {e}"),
369        })?;
370
371        // Resolve exactly as the runtime does (`postgres/adapter/mod.rs` quotes each
372        // component case-sensitively), so a mixed-case relation in an off-search_path
373        // schema the runtime *can* serve is not falsely reported missing. `to_regclass`
374        // takes the quoted identifier as a string and ignores `search_path` for a
375        // schema-qualified name, returning NULL when the relation is genuinely absent.
376        let quoted = format!(
377            "{}.{}",
378            crate::identifier::quote_postgres_identifier(schema),
379            crate::identifier::quote_postgres_identifier(name),
380        );
381        let row = client
382            .query_one("SELECT to_regclass($1) IS NOT NULL", &[&quoted])
383            .await
384            .map_err(|e| FraiseQLError::Database {
385                message:   format!("Failed to probe relation existence: {}", pg_detail(&e)),
386                sql_state: e.code().map(|c| c.code().to_string()),
387            })?;
388
389        Ok(Some(row.get(0)))
390    }
391
392    async fn get_view_definition(&self, relation: &str) -> Result<Option<String>> {
393        let client = self.pool.get().await.map_err(|e| FraiseQLError::ConnectionPool {
394            message: format!("Failed to acquire connection: {e}"),
395        })?;
396
397        // `to_regclass` resolves a bare name through `search_path` and a qualified
398        // one verbatim, exactly as `qualified_relation_exists` does, and yields NULL
399        // rather than raising when the relation is absent. `pg_get_viewdef` of a
400        // non-view is NULL too, so a table and a missing relation are the same
401        // answer here — which is correct, because neither has a body to read.
402        let row = client
403            .query_one("SELECT pg_get_viewdef(to_regclass($1), true)", &[&relation])
404            .await
405            .map_err(|e| FraiseQLError::Database {
406                message:   format!("Failed to read view definition: {}", pg_detail(&e)),
407                sql_state: e.code().map(|c| c.code().to_string()),
408            })?;
409
410        Ok(row.get(0))
411    }
412}
413impl PostgresIntrospector {
414    /// Every index on `relation`, keys in declared order (#1307).
415    ///
416    /// Keys come from `pg_get_indexdef(indexrelid, n, true)` rather than from
417    /// `pg_attribute`, because an expression key has no `attnum`: the JSONB
418    /// identity a page is tie-broken on (`data->>'id'`) is indexed as an
419    /// expression, and reading `indkey` alone would report that index as having
420    /// fewer keys than it has.
421    ///
422    /// Returns an empty vector for a view — a view carries no indexes, so a
423    /// caller holding a `sql_source` must resolve it with
424    /// [`resolve_base_relations`](Self::resolve_base_relations) first.
425    ///
426    /// # Errors
427    ///
428    /// Returns [`FraiseQLError::Database`] if the catalog query fails.
429    pub async fn get_index_definitions(&self, relation: &str) -> Result<Vec<IndexInfo>> {
430        let client = self.pool.get().await.map_err(|e| FraiseQLError::ConnectionPool {
431            message: format!("Failed to acquire connection: {e}"),
432        })?;
433
434        let (schema, name) = split_relation(relation);
435        let query = r"
436            SELECT i.relname                       AS index_name,
437                   ix.indisunique                  AS is_unique,
438                   array_agg(pg_get_indexdef(ix.indexrelid, k.ord::int, true)
439                             ORDER BY k.ord)       AS keys
440            FROM pg_index ix
441            JOIN pg_class i ON i.oid = ix.indexrelid
442            JOIN pg_class t ON t.oid = ix.indrelid
443            JOIN pg_namespace n ON n.oid = t.relnamespace
444            JOIN LATERAL generate_series(1, ix.indnkeyatts) AS k(ord) ON true
445            WHERE t.relname = $1
446              AND ($2::text IS NULL OR n.nspname = $2)
447              AND ($2::text IS NOT NULL OR n.nspname = ANY(current_schemas(false)))
448            GROUP BY i.relname, ix.indisunique
449            ORDER BY i.relname
450        ";
451
452        let rows: Vec<Row> =
453            client
454                .query(query, &[&name, &schema])
455                .await
456                .map_err(|e| FraiseQLError::Database {
457                    message:   format!("Failed to query index definitions: {}", pg_detail(&e)),
458                    sql_state: e.code().map(|c| c.code().to_string()),
459                })?;
460
461        Ok(rows
462            .into_iter()
463            .map(|row| IndexInfo {
464                name:   row.get(0),
465                unique: row.get(1),
466                keys:   row.get::<_, Vec<String>>(2),
467            })
468            .collect())
469    }
470
471    /// The tables and materialized views a view reads from (#1307).
472    ///
473    /// A query binds to `v_invoice`, and `v_invoice` has no indexes — the indexes
474    /// that decide whether a page needs a sort live on `tb_invoice`. Resolved
475    /// through `pg_rewrite`/`pg_depend` rather than by assuming a `v_`/`tb_`
476    /// naming convention, which is a project convention and not a guarantee about
477    /// any particular database.
478    ///
479    /// Returns every base relation the view depends on. More than one means the
480    /// view is a join, and a caller cannot attribute an ordering to a single
481    /// table without reading the view body.
482    ///
483    /// # Errors
484    ///
485    /// Returns [`FraiseQLError::Database`] if the catalog query fails.
486    pub async fn resolve_base_relations(&self, view: &str) -> Result<Vec<String>> {
487        let client = self.pool.get().await.map_err(|e| FraiseQLError::ConnectionPool {
488            message: format!("Failed to acquire connection: {e}"),
489        })?;
490
491        let (schema, name) = split_relation(view);
492        let query = r"
493            SELECT DISTINCT base.relname
494            FROM pg_class v
495            JOIN pg_namespace vn ON vn.oid = v.relnamespace
496            JOIN pg_rewrite r ON r.ev_class = v.oid
497            JOIN pg_depend d ON d.objid = r.oid
498            JOIN pg_class base ON base.oid = d.refobjid
499            WHERE v.relname = $1
500              AND ($2::text IS NULL OR vn.nspname = $2)
501              AND ($2::text IS NOT NULL OR vn.nspname = ANY(current_schemas(false)))
502              AND base.relkind IN ('r', 'm', 'p')
503              AND base.oid <> v.oid
504            ORDER BY base.relname
505        ";
506
507        let rows: Vec<Row> =
508            client
509                .query(query, &[&name, &schema])
510                .await
511                .map_err(|e| FraiseQLError::Database {
512                    message:   format!("Failed to resolve view base relations: {}", pg_detail(&e)),
513                    sql_state: e.code().map(|c| c.code().to_string()),
514                })?;
515
516        Ok(rows.into_iter().map(|row| row.get(0)).collect())
517    }
518
519    /// Get all column names for a view/table.
520    ///
521    /// # Arguments
522    ///
523    /// * `view_name` - Name of the view or table to introspect
524    ///
525    /// # Returns
526    ///
527    /// List of all column names in the view/table.
528    ///
529    /// # Errors
530    ///
531    /// Returns `FraiseQLError::ConnectionPool` if a connection cannot be acquired,
532    /// or `FraiseQLError::Database` if the query fails.
533    pub async fn get_view_columns(&self, view_name: &str) -> Result<Vec<String>> {
534        let client = self.pool.get().await.map_err(|e| FraiseQLError::ConnectionPool {
535            message: format!("Failed to acquire connection: {e}"),
536        })?;
537
538        let query = r"
539            SELECT column_name
540            FROM information_schema.columns
541            WHERE table_name = $1
542              AND table_schema = ANY(current_schemas(false))
543            ORDER BY ordinal_position
544        ";
545
546        let rows: Vec<Row> =
547            client.query(query, &[&view_name]).await.map_err(|e| FraiseQLError::Database {
548                message:   format!("Failed to query view columns: {}", pg_detail(&e)),
549                sql_state: e.code().map(|c| c.code().to_string()),
550            })?;
551
552        let columns = rows
553            .into_iter()
554            .map(|row| {
555                let name: String = row.get(0);
556                name
557            })
558            .collect();
559
560        Ok(columns)
561    }
562}