Skip to main content

fraiseql_cli/commands/
introspect_facts.rs

1//! Introspect database for fact tables and output suggestions.
2//!
3//! This command discovers `tf_*` tables in the database and outputs:
4//! - Python decorator suggestions (@`fraiseql.fact_table`)
5//! - JSON metadata for manual review
6//!
7//! **Purpose**: Help developers discover and declare fact tables.
8//! **Does NOT auto-modify schema** - outputs suggestions only.
9
10use anyhow::Result;
11use deadpool_postgres::{Config, ManagerConfig, RecyclingMethod, Runtime};
12use fraiseql_core::{
13    compiler::fact_table::{DatabaseIntrospector, FactTableDetector, FactTableMetadata},
14    db::PostgresIntrospector,
15};
16use serde_json::json;
17use tokio_postgres::NoTls;
18
19/// Output format for introspection results.
20#[derive(Debug, Clone, Copy)]
21pub enum OutputFormat {
22    /// Python decorator format
23    Python,
24    /// JSON format
25    Json,
26}
27
28impl OutputFormat {
29    /// Parse from string
30    pub fn parse(s: &str) -> std::result::Result<Self, String> {
31        match s.to_lowercase().as_str() {
32            "python" | "py" => Ok(Self::Python),
33            "json" => Ok(Self::Json),
34            _ => Err(format!("Invalid format '{s}', expected: python, json")),
35        }
36    }
37}
38
39/// Create a PostgreSQL introspector from a database URL
40async fn create_introspector(database_url: &str) -> Result<PostgresIntrospector> {
41    let mut cfg = Config::new();
42    cfg.url = Some(database_url.to_string());
43    cfg.manager = Some(ManagerConfig {
44        recycling_method: RecyclingMethod::Fast,
45    });
46    cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
47
48    let pool = cfg
49        .create_pool(Some(Runtime::Tokio1), NoTls)
50        .map_err(|e| anyhow::anyhow!("Failed to create database pool: {e}"))?;
51
52    // Test connection
53    let _client = pool
54        .get()
55        .await
56        .map_err(|e| anyhow::anyhow!("Failed to connect to database: {e}"))?;
57
58    Ok(PostgresIntrospector::new(pool))
59}
60
61/// Introspect database for fact tables and output suggestions.
62///
63/// # Arguments
64///
65/// * `database_url` - Database connection string (e.g., "postgresql://...")
66/// * `format` - Output format (Python or JSON)
67///
68/// # Returns
69///
70/// Success or error
71///
72/// # Example
73///
74/// ```bash
75/// fraiseql introspect facts --database postgresql://localhost/mydb --format python
76/// ```
77pub async fn run(database_url: &str, format: OutputFormat) -> Result<()> {
78    eprintln!("🔍 Introspecting database for fact tables...");
79    eprintln!("   Database: {database_url}");
80
81    // Create database introspector
82    let introspector = create_introspector(database_url).await?;
83
84    // List all fact tables
85    let fact_tables = introspector
86        .list_fact_tables()
87        .await
88        .map_err(|e| anyhow::anyhow!("Failed to list fact tables: {e}"))?;
89
90    if fact_tables.is_empty() {
91        eprintln!("\n⚠️  No fact tables found (tables starting with 'tf_')");
92        eprintln!("   Fact tables should be named like: tf_sales, tf_events, tf_orders");
93        return Ok(());
94    }
95
96    eprintln!("\n📋 Found {} fact table(s):", fact_tables.len());
97    for table in &fact_tables {
98        eprintln!("   - {table}");
99    }
100    eprintln!();
101
102    // Introspect each fact table
103    let mut metadata_list: Vec<FactTableMetadata> = Vec::new();
104    let mut errors: Vec<(String, String)> = Vec::new();
105
106    for table_name in &fact_tables {
107        match FactTableDetector::introspect(&introspector, table_name).await {
108            Ok(metadata) => {
109                metadata_list.push(metadata);
110            },
111            Err(e) => {
112                errors.push((table_name.clone(), e.to_string()));
113            },
114        }
115    }
116
117    // Report any errors
118    if !errors.is_empty() {
119        eprintln!("⚠️  Failed to introspect {} table(s):", errors.len());
120        for (table, error) in &errors {
121            eprintln!("   - {table}: {error}");
122        }
123        eprintln!();
124    }
125
126    // Output results
127    match format {
128        OutputFormat::Python => {
129            println!("\n# Suggested fact table decorators:");
130            println!("# (Copy and paste into your Python schema)");
131            println!("# Generated by: fraiseql introspect facts");
132            println!();
133            println!("import fraiseql");
134            println!();
135
136            for metadata in &metadata_list {
137                println!("{}", format_as_python(metadata));
138                println!();
139            }
140        },
141        OutputFormat::Json => {
142            let output: serde_json::Value = metadata_list
143                .iter()
144                .map(|m| {
145                    (
146                        m.table_name.clone(),
147                        json!({
148                            "table_name": m.table_name,
149                            "measures": m.measures.iter().map(|measure| {
150                                json!({
151                                    "name": measure.name,
152                                    "sql_type": format!("{:?}", measure.sql_type),
153                                    "nullable": measure.nullable
154                                })
155                            }).collect::<Vec<_>>(),
156                            "dimensions": {
157                                "name": m.dimensions.name,
158                                "paths": m.dimensions.paths.iter().map(|p| {
159                                    json!({
160                                        "name": p.name,
161                                        "json_path": p.json_path,
162                                        "data_type": p.data_type
163                                    })
164                                }).collect::<Vec<_>>()
165                            },
166                            "denormalized_filters": m.denormalized_filters.iter().map(|f| {
167                                json!({
168                                    "name": f.name,
169                                    "sql_type": format!("{:?}", f.sql_type),
170                                    "indexed": f.indexed
171                                })
172                            }).collect::<Vec<_>>(),
173                            "calendar_dimensions": m.calendar_dimensions.iter().map(|c| {
174                                json!({
175                                    "source_column": c.source_column,
176                                    "granularities": c.granularities.iter().map(|g| {
177                                        json!({
178                                            "column_name": g.column_name,
179                                            "buckets": g.buckets.iter().map(|b| {
180                                                json!({
181                                                    "json_key": b.json_key,
182                                                    "bucket_type": format!("{:?}", b.bucket_type)
183                                                })
184                                            }).collect::<Vec<_>>()
185                                        })
186                                    }).collect::<Vec<_>>()
187                                })
188                            }).collect::<Vec<_>>()
189                        }),
190                    )
191                })
192                .collect::<serde_json::Map<String, serde_json::Value>>()
193                .into();
194
195            println!("{}", serde_json::to_string_pretty(&output)?);
196        },
197    }
198
199    eprintln!("\n✅ Introspection complete");
200    eprintln!("   {} table(s) introspected successfully", metadata_list.len());
201    if !errors.is_empty() {
202        eprintln!("   {} table(s) failed", errors.len());
203    }
204
205    Ok(())
206}
207
208/// Format metadata as Python decorator.
209fn format_as_python(metadata: &FactTableMetadata) -> String {
210    let mut output = String::new();
211
212    // Extract measure names
213    let measures: Vec<String> = metadata.measures.iter().map(|m| format!("'{}'", m.name)).collect();
214
215    // Extract filter names
216    let filters: Vec<String> =
217        metadata.denormalized_filters.iter().map(|f| format!("'{}'", f.name)).collect();
218
219    // Extract class name from table name (tf_sales -> Sales)
220    let class_name = metadata
221        .table_name
222        .strip_prefix("tf_")
223        .unwrap_or(&metadata.table_name)
224        .split('_')
225        .map(|s| {
226            let mut c = s.chars();
227            match c.next() {
228                None => String::new(),
229                Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
230            }
231        })
232        .collect::<String>();
233
234    // Format decorator
235    output.push_str(&format!("# Fact table: {}\n", metadata.table_name));
236    output.push_str("@fraiseql.fact_table(\n");
237    output.push_str(&format!("    measures=[{}],\n", measures.join(", ")));
238    output.push_str(&format!("    dimensions='{}',\n", metadata.dimensions.name));
239
240    if !filters.is_empty() {
241        output.push_str(&format!("    filters=[{}],\n", filters.join(", ")));
242    }
243
244    // Add calendar dimensions if present
245    if !metadata.calendar_dimensions.is_empty() {
246        let calendar_cols: Vec<String> = metadata
247            .calendar_dimensions
248            .iter()
249            .map(|c| format!("'{}'", c.source_column))
250            .collect();
251        output.push_str(&format!("    calendar_columns=[{}],\n", calendar_cols.join(", ")));
252    }
253
254    output.push_str(")\n");
255    output.push_str(&format!("class {class_name}:\n"));
256    output.push_str(&format!(
257        "    \"\"\"Fact table: {} ({} measures, {} filters)\"\"\"\n",
258        metadata.table_name,
259        metadata.measures.len(),
260        metadata.denormalized_filters.len()
261    ));
262    output.push_str("    pass");
263
264    output
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn test_output_format_from_str() {
273        assert!(matches!(OutputFormat::parse("python"), Ok(OutputFormat::Python)));
274        assert!(matches!(OutputFormat::parse("json"), Ok(OutputFormat::Json)));
275        assert!(OutputFormat::parse("invalid").is_err());
276    }
277
278    #[test]
279    fn test_format_as_python() {
280        use fraiseql_core::compiler::fact_table::{
281            DimensionColumn, FilterColumn, MeasureColumn, SqlType,
282        };
283
284        let metadata = FactTableMetadata {
285            table_name:           "tf_sales".to_string(),
286            measures:             vec![
287                MeasureColumn {
288                    name:     "revenue".to_string(),
289                    sql_type: SqlType::Decimal,
290                    nullable: false,
291                },
292                MeasureColumn {
293                    name:     "quantity".to_string(),
294                    sql_type: SqlType::Int,
295                    nullable: false,
296                },
297            ],
298            dimensions:           DimensionColumn {
299                name:  "data".to_string(),
300                paths: vec![],
301            },
302            denormalized_filters: vec![FilterColumn {
303                name:     "customer_id".to_string(),
304                sql_type: SqlType::Uuid,
305                indexed:  true,
306            }],
307            calendar_dimensions:  vec![],
308        };
309
310        let output = format_as_python(&metadata);
311        assert!(output.contains("@fraiseql.fact_table"));
312        assert!(output.contains("'revenue'"));
313        assert!(output.contains("'quantity'"));
314        assert!(output.contains("'customer_id'"));
315        assert!(output.contains("class Sales:"));
316    }
317}