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 crate::{
7    DatabaseType,
8    introspector::{DatabaseIntrospector, RelationInfo, RelationKind},
9};
10
11/// PostgreSQL introspector for fact table metadata.
12pub struct PostgresIntrospector {
13    pool: Pool,
14}
15
16impl PostgresIntrospector {
17    /// Create new PostgreSQL introspector from connection pool.
18    #[must_use]
19    pub const fn new(pool: Pool) -> Self {
20        Self { pool }
21    }
22}
23
24impl DatabaseIntrospector for PostgresIntrospector {
25    async fn list_fact_tables(&self) -> Result<Vec<String>> {
26        let client = self.pool.get().await.map_err(|e| FraiseQLError::ConnectionPool {
27            message: format!("Failed to acquire connection: {e}"),
28        })?;
29
30        // Query information_schema for tables matching tf_* pattern across all schemas
31        // in the current search path.
32        let query = r"
33            SELECT table_name
34            FROM information_schema.tables
35            WHERE table_schema = ANY(current_schemas(false))
36              AND table_type = 'BASE TABLE'
37              AND table_name LIKE 'tf_%'
38            ORDER BY table_name
39        ";
40
41        let rows: Vec<Row> =
42            client.query(query, &[]).await.map_err(|e| FraiseQLError::Database {
43                message:   format!("Failed to list fact tables: {e}"),
44                sql_state: e.code().map(|c| c.code().to_string()),
45            })?;
46
47        let tables = rows
48            .into_iter()
49            .map(|row| {
50                let name: String = row.get(0);
51                name
52            })
53            .collect();
54
55        Ok(tables)
56    }
57
58    async fn get_columns(&self, table_name: &str) -> Result<Vec<(String, String, bool)>> {
59        let client = self.pool.get().await.map_err(|e| FraiseQLError::ConnectionPool {
60            message: format!("Failed to acquire connection: {e}"),
61        })?;
62
63        // Support schema-qualified names like "benchmark.tv_post".
64        // When no schema prefix is present, search across all schemas in the current
65        // search path so that non-public schemas work without explicit qualification.
66        let rows: Vec<Row> = if let Some(dot) = table_name.find('.') {
67            let (schema, name) = (&table_name[..dot], &table_name[dot + 1..]);
68            client
69                .query(
70                    r"SELECT column_name, data_type, is_nullable = 'YES' as is_nullable
71                      FROM information_schema.columns
72                      WHERE table_name = $1 AND table_schema = $2
73                      ORDER BY ordinal_position",
74                    &[&name, &schema],
75                )
76                .await
77        } else {
78            client
79                .query(
80                    r"SELECT column_name, data_type, is_nullable = 'YES' as is_nullable
81                      FROM information_schema.columns
82                      WHERE table_name = $1
83                        AND table_schema = ANY(current_schemas(false))
84                      ORDER BY ordinal_position",
85                    &[&table_name],
86                )
87                .await
88        }
89        .map_err(|e| FraiseQLError::Database {
90            message:   format!("Failed to query column information: {e}"),
91            sql_state: e.code().map(|c| c.code().to_string()),
92        })?;
93
94        let columns = rows
95            .into_iter()
96            .map(|row| {
97                let name: String = row.get(0);
98                let data_type: String = row.get(1);
99                let is_nullable: bool = row.get(2);
100                (name, data_type, is_nullable)
101            })
102            .collect();
103
104        Ok(columns)
105    }
106
107    async fn get_indexed_columns(&self, table_name: &str) -> Result<Vec<String>> {
108        let client = self.pool.get().await.map_err(|e| FraiseQLError::ConnectionPool {
109            message: format!("Failed to acquire connection: {e}"),
110        })?;
111
112        // Support schema-qualified names like "benchmark.tv_post".
113        // When no schema prefix is present, search across all schemas in the current
114        // search path so that non-public schemas work without explicit qualification.
115        let rows: Vec<Row> = if let Some(dot) = table_name.find('.') {
116            let (schema, name) = (&table_name[..dot], &table_name[dot + 1..]);
117            client
118                .query(
119                    r"SELECT DISTINCT a.attname AS column_name
120                      FROM pg_index i
121                      JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
122                      JOIN pg_class t ON t.oid = i.indrelid
123                      JOIN pg_namespace n ON n.oid = t.relnamespace
124                      WHERE t.relname = $1 AND n.nspname = $2 AND a.attnum > 0
125                      ORDER BY column_name",
126                    &[&name, &schema],
127                )
128                .await
129        } else {
130            client
131                .query(
132                    r"SELECT DISTINCT a.attname AS column_name
133                      FROM pg_index i
134                      JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
135                      JOIN pg_class t ON t.oid = i.indrelid
136                      JOIN pg_namespace n ON n.oid = t.relnamespace
137                      WHERE t.relname = $1
138                        AND n.nspname = ANY(current_schemas(false))
139                        AND a.attnum > 0
140                      ORDER BY column_name",
141                    &[&table_name],
142                )
143                .await
144        }
145        .map_err(|e| FraiseQLError::Database {
146            message:   format!("Failed to query index information: {e}"),
147            sql_state: e.code().map(|c| c.code().to_string()),
148        })?;
149
150        let indexed_columns = rows
151            .into_iter()
152            .map(|row| {
153                let name: String = row.get(0);
154                name
155            })
156            .collect();
157
158        Ok(indexed_columns)
159    }
160
161    fn database_type(&self) -> DatabaseType {
162        DatabaseType::PostgreSQL
163    }
164
165    async fn list_relations(&self) -> Result<Vec<RelationInfo>> {
166        let client = self.pool.get().await.map_err(|e| FraiseQLError::ConnectionPool {
167            message: format!("Failed to acquire connection: {e}"),
168        })?;
169
170        // List all tables and views visible in the current search path, excluding
171        // system schemas. Uses current_schemas(false) to match the same scope as
172        // get_columns / get_indexed_columns so that relation existence checks are
173        // consistent with column lookups.
174        let rows: Vec<Row> = client
175            .query(
176                r"SELECT table_schema, table_name,
177                         CASE table_type WHEN 'VIEW' THEN 'view' ELSE 'table' END AS kind
178                  FROM information_schema.tables
179                  WHERE table_schema = ANY(current_schemas(false))
180                  ORDER BY table_schema, table_name",
181                &[],
182            )
183            .await
184            .map_err(|e| FraiseQLError::Database {
185                message:   format!("Failed to list relations: {e}"),
186                sql_state: e.code().map(|c| c.code().to_string()),
187            })?;
188
189        let relations = rows
190            .into_iter()
191            .map(|row| {
192                let schema: String = row.get(0);
193                let name: String = row.get(1);
194                let kind_str: String = row.get(2);
195                let kind = if kind_str == "view" {
196                    RelationKind::View
197                } else {
198                    RelationKind::Table
199                };
200                RelationInfo { schema, name, kind }
201            })
202            .collect();
203
204        Ok(relations)
205    }
206
207    async fn get_sample_jsonb(
208        &self,
209        table_name: &str,
210        column_name: &str,
211    ) -> Result<Option<serde_json::Value>> {
212        let client = self.pool.get().await.map_err(|e| FraiseQLError::ConnectionPool {
213            message: format!("Failed to acquire connection: {e}"),
214        })?;
215
216        // Query for a sample row with non-null JSON data
217        // Use format! for identifiers (safe because we validate table_name pattern)
218        let query = format!(
219            r#"
220            SELECT "{column}"::text
221            FROM "{table}"
222            WHERE "{column}" IS NOT NULL
223            LIMIT 1
224            "#,
225            table = table_name,
226            column = column_name
227        );
228
229        let rows: Vec<Row> =
230            client.query(&query, &[]).await.map_err(|e| FraiseQLError::Database {
231                message:   format!("Failed to query sample JSONB: {e}"),
232                sql_state: e.code().map(|c| c.code().to_string()),
233            })?;
234
235        if rows.is_empty() {
236            return Ok(None);
237        }
238
239        let json_text: Option<String> = rows[0].get(0);
240        if let Some(text) = json_text {
241            let value: serde_json::Value =
242                serde_json::from_str(&text).map_err(|e| FraiseQLError::Parse {
243                    message:  format!("Failed to parse JSONB sample: {e}"),
244                    location: format!("{table_name}.{column_name}"),
245                })?;
246            Ok(Some(value))
247        } else {
248            Ok(None)
249        }
250    }
251
252    async fn function_exists(&self, schema: Option<&str>, name: &str) -> Result<Option<bool>> {
253        let client = self.pool.get().await.map_err(|e| FraiseQLError::ConnectionPool {
254            message: format!("Failed to acquire connection: {e}"),
255        })?;
256
257        // Mirror `pg_catalog::resolve_functions` name/schema matching (verbatim,
258        // schema-qualified or `current_schemas(false)`-scoped). Restrict to kinds a
259        // mutation can actually call — regular function `f` or procedure `p` —
260        // excluding aggregate `a` / window `w`, which `resolve_functions` does not
261        // filter (a minor over-match there).
262        let row = if let Some(schema) = schema {
263            client
264                .query_one(
265                    "SELECT EXISTS(SELECT 1 FROM pg_proc p \
266                       JOIN pg_namespace n ON n.oid = p.pronamespace \
267                       WHERE n.nspname = $1 AND p.proname = $2 AND p.prokind IN ('f','p'))",
268                    &[&schema, &name],
269                )
270                .await
271        } else {
272            client
273                .query_one(
274                    "SELECT EXISTS(SELECT 1 FROM pg_proc p \
275                       JOIN pg_namespace n ON n.oid = p.pronamespace \
276                       WHERE p.proname = $1 AND n.nspname = ANY(current_schemas(false)) \
277                       AND p.prokind IN ('f','p'))",
278                    &[&name],
279                )
280                .await
281        }
282        .map_err(|e| FraiseQLError::Database {
283            message:   format!("Failed to probe function existence: {e}"),
284            sql_state: e.code().map(|c| c.code().to_string()),
285        })?;
286
287        Ok(Some(row.get(0)))
288    }
289
290    async fn qualified_relation_exists(&self, schema: &str, name: &str) -> Result<Option<bool>> {
291        let client = self.pool.get().await.map_err(|e| FraiseQLError::ConnectionPool {
292            message: format!("Failed to acquire connection: {e}"),
293        })?;
294
295        // Resolve exactly as the runtime does (`postgres/adapter/mod.rs` quotes each
296        // component case-sensitively), so a mixed-case relation in an off-search_path
297        // schema the runtime *can* serve is not falsely reported missing. `to_regclass`
298        // takes the quoted identifier as a string and ignores `search_path` for a
299        // schema-qualified name, returning NULL when the relation is genuinely absent.
300        let quoted = format!(
301            "{}.{}",
302            crate::identifier::quote_postgres_identifier(schema),
303            crate::identifier::quote_postgres_identifier(name),
304        );
305        let row = client
306            .query_one("SELECT to_regclass($1) IS NOT NULL", &[&quoted])
307            .await
308            .map_err(|e| FraiseQLError::Database {
309                message:   format!("Failed to probe relation existence: {e}"),
310                sql_state: e.code().map(|c| c.code().to_string()),
311            })?;
312
313        Ok(Some(row.get(0)))
314    }
315}
316
317impl PostgresIntrospector {
318    /// Get indexed columns for a view/table that match the nested path naming convention.
319    ///
320    /// This method introspects the database to find columns that follow the FraiseQL
321    /// indexed column naming conventions:
322    /// - Human-readable: `items__product__category__code` (double underscore separated)
323    /// - Entity ID format: `f{entity_id}__{field_name}` (e.g., `f200100__code`)
324    ///
325    /// These columns are created by DBAs to optimize filtering on nested GraphQL paths
326    /// by avoiding JSONB extraction at runtime.
327    ///
328    /// # Arguments
329    ///
330    /// * `view_name` - Name of the view or table to introspect
331    ///
332    /// # Returns
333    ///
334    /// Set of column names that match the indexed column naming conventions.
335    ///
336    /// # Errors
337    ///
338    /// Returns `FraiseQLError::ConnectionPool` if a connection cannot be acquired,
339    /// or `FraiseQLError::Database` if the query fails.
340    ///
341    /// # Example
342    ///
343    /// ```no_run
344    /// use fraiseql_db::postgres::PostgresIntrospector;
345    /// # use fraiseql_error::Result;
346    /// use deadpool_postgres::Pool;
347    ///
348    /// # async fn example(pool: Pool) -> Result<()> {
349    /// let introspector = PostgresIntrospector::new(pool);
350    /// let indexed_cols = introspector.get_indexed_nested_columns("v_order_items").await?;
351    /// // Returns: {"items__product__category__code", "f200100__code", ...}
352    /// # Ok(())
353    /// # }
354    /// ```
355    pub async fn get_indexed_nested_columns(
356        &self,
357        view_name: &str,
358    ) -> Result<std::collections::HashSet<String>> {
359        let client = self.pool.get().await.map_err(|e| FraiseQLError::ConnectionPool {
360            message: format!("Failed to acquire connection: {e}"),
361        })?;
362
363        // Query information_schema for columns matching __ pattern.
364        // This works for both views and tables across all schemas in the search path.
365        let query = r"
366            SELECT column_name
367            FROM information_schema.columns
368            WHERE table_name = $1
369              AND table_schema = ANY(current_schemas(false))
370              AND column_name LIKE '%__%'
371            ORDER BY column_name
372        ";
373
374        let rows: Vec<Row> =
375            client.query(query, &[&view_name]).await.map_err(|e| FraiseQLError::Database {
376                message:   format!("Failed to query view columns: {e}"),
377                sql_state: e.code().map(|c| c.code().to_string()),
378            })?;
379
380        let indexed_columns: std::collections::HashSet<String> = rows
381            .into_iter()
382            .map(|row| {
383                let name: String = row.get(0);
384                name
385            })
386            .filter(|name| {
387                // Filter to only columns that match our naming conventions:
388                // 1. Human-readable: path__to__field (at least one __ separator)
389                // 2. Entity ID: f{digits}__field_name
390                Self::is_indexed_column_name(name)
391            })
392            .collect();
393
394        Ok(indexed_columns)
395    }
396
397    /// Check if a column name matches the indexed column naming convention.
398    ///
399    /// Valid patterns:
400    /// - `items__product__category__code` (human-readable nested path)
401    /// - `f200100__code` (entity ID format)
402    fn is_indexed_column_name(name: &str) -> bool {
403        // Must contain at least one double underscore
404        if !name.contains("__") {
405            return false;
406        }
407
408        // Check for entity ID format: f{digits}__field
409        if let Some(rest) = name.strip_prefix('f') {
410            if let Some(underscore_pos) = rest.find("__") {
411                let digits = &rest[..underscore_pos];
412                if digits.chars().all(|c| c.is_ascii_digit()) && !digits.is_empty() {
413                    // Verify the field part is valid
414                    let field_part = &rest[underscore_pos + 2..];
415                    if !field_part.is_empty()
416                        && field_part.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
417                        && !field_part.starts_with(|c: char| c.is_ascii_digit())
418                    {
419                        return true;
420                    }
421                }
422            }
423        }
424
425        // Human-readable format: split by __ and check each segment is valid identifier
426        // Must have at least 2 segments, and first segment must NOT be just 'f'
427        let segments: Vec<&str> = name.split("__").collect();
428        if segments.len() < 2 {
429            return false;
430        }
431
432        // Reject if first segment is just 'f' (reserved for entity ID format)
433        if segments[0] == "f" {
434            return false;
435        }
436
437        // Each segment should be a valid identifier (alphanumeric + underscore, not starting with
438        // digit)
439        segments.iter().all(|s| {
440            !s.is_empty()
441                && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
442                && !s.starts_with(|c: char| c.is_ascii_digit())
443        })
444    }
445
446    /// Get all column names for a view/table.
447    ///
448    /// # Arguments
449    ///
450    /// * `view_name` - Name of the view or table to introspect
451    ///
452    /// # Returns
453    ///
454    /// List of all column names in the view/table.
455    ///
456    /// # Errors
457    ///
458    /// Returns `FraiseQLError::ConnectionPool` if a connection cannot be acquired,
459    /// or `FraiseQLError::Database` if the query fails.
460    pub async fn get_view_columns(&self, view_name: &str) -> Result<Vec<String>> {
461        let client = self.pool.get().await.map_err(|e| FraiseQLError::ConnectionPool {
462            message: format!("Failed to acquire connection: {e}"),
463        })?;
464
465        let query = r"
466            SELECT column_name
467            FROM information_schema.columns
468            WHERE table_name = $1
469              AND table_schema = ANY(current_schemas(false))
470            ORDER BY ordinal_position
471        ";
472
473        let rows: Vec<Row> =
474            client.query(query, &[&view_name]).await.map_err(|e| FraiseQLError::Database {
475                message:   format!("Failed to query view columns: {e}"),
476                sql_state: e.code().map(|c| c.code().to_string()),
477            })?;
478
479        let columns = rows
480            .into_iter()
481            .map(|row| {
482                let name: String = row.get(0);
483                name
484            })
485            .collect();
486
487        Ok(columns)
488    }
489}
490
491#[cfg(test)]
492mod tests;