use anyhow::Result;
use deadpool_postgres::{Config, ManagerConfig, RecyclingMethod, Runtime};
use fraiseql_core::{
compiler::fact_table::{DatabaseIntrospector, FactTableDetector, FactTableMetadata},
db::PostgresIntrospector,
};
use serde_json::json;
use tokio_postgres::NoTls;
#[derive(Debug, Clone, Copy)]
pub enum OutputFormat {
Python,
Json,
}
impl OutputFormat {
pub fn parse(s: &str) -> std::result::Result<Self, String> {
match s.to_lowercase().as_str() {
"python" | "py" => Ok(Self::Python),
"json" => Ok(Self::Json),
_ => Err(format!("Invalid format '{s}', expected: python, json")),
}
}
}
async fn create_introspector(database_url: &str) -> Result<PostgresIntrospector> {
let mut cfg = Config::new();
cfg.url = Some(database_url.to_string());
cfg.manager = Some(ManagerConfig {
recycling_method: RecyclingMethod::Fast,
});
cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
let pool = cfg
.create_pool(Some(Runtime::Tokio1), NoTls)
.map_err(|e| anyhow::anyhow!("Failed to create database pool: {e}"))?;
let _client = pool
.get()
.await
.map_err(|e| anyhow::anyhow!("Failed to connect to database: {e}"))?;
Ok(PostgresIntrospector::new(pool))
}
pub async fn run(database_url: &str, format: OutputFormat) -> Result<()> {
eprintln!("🔍 Introspecting database for fact tables...");
eprintln!(" Database: {database_url}");
let introspector = create_introspector(database_url).await?;
let fact_tables = introspector
.list_fact_tables()
.await
.map_err(|e| anyhow::anyhow!("Failed to list fact tables: {e}"))?;
if fact_tables.is_empty() {
eprintln!("\n⚠️ No fact tables found (tables starting with 'tf_')");
eprintln!(" Fact tables should be named like: tf_sales, tf_events, tf_orders");
return Ok(());
}
eprintln!("\n📋 Found {} fact table(s):", fact_tables.len());
for table in &fact_tables {
eprintln!(" - {table}");
}
eprintln!();
let mut metadata_list: Vec<FactTableMetadata> = Vec::new();
let mut errors: Vec<(String, String)> = Vec::new();
for table_name in &fact_tables {
match FactTableDetector::introspect(&introspector, table_name).await {
Ok(metadata) => {
metadata_list.push(metadata);
},
Err(e) => {
errors.push((table_name.clone(), e.to_string()));
},
}
}
if !errors.is_empty() {
eprintln!("⚠️ Failed to introspect {} table(s):", errors.len());
for (table, error) in &errors {
eprintln!(" - {table}: {error}");
}
eprintln!();
}
match format {
OutputFormat::Python => {
println!("\n# Suggested fact table decorators:");
println!("# (Copy and paste into your Python schema)");
println!("# Generated by: fraiseql introspect facts");
println!();
println!("import fraiseql");
println!();
for metadata in &metadata_list {
println!("{}", format_as_python(metadata));
println!();
}
},
OutputFormat::Json => {
let output: serde_json::Value = metadata_list
.iter()
.map(|m| {
(
m.table_name.clone(),
json!({
"table_name": m.table_name,
"measures": m.measures.iter().map(|measure| {
json!({
"name": measure.name,
"sql_type": format!("{:?}", measure.sql_type),
"nullable": measure.nullable
})
}).collect::<Vec<_>>(),
"dimensions": {
"name": m.dimensions.name,
"paths": m.dimensions.paths.iter().map(|p| {
json!({
"name": p.name,
"json_path": p.json_path,
"data_type": p.data_type
})
}).collect::<Vec<_>>()
},
"denormalized_filters": m.denormalized_filters.iter().map(|f| {
json!({
"name": f.name,
"sql_type": format!("{:?}", f.sql_type),
"indexed": f.indexed
})
}).collect::<Vec<_>>(),
"calendar_dimensions": m.calendar_dimensions.iter().map(|c| {
json!({
"source_column": c.source_column,
"granularities": c.granularities.iter().map(|g| {
json!({
"column_name": g.column_name,
"buckets": g.buckets.iter().map(|b| {
json!({
"json_key": b.json_key,
"bucket_type": format!("{:?}", b.bucket_type)
})
}).collect::<Vec<_>>()
})
}).collect::<Vec<_>>()
})
}).collect::<Vec<_>>()
}),
)
})
.collect::<serde_json::Map<String, serde_json::Value>>()
.into();
println!("{}", serde_json::to_string_pretty(&output)?);
},
}
eprintln!("\n✅ Introspection complete");
eprintln!(" {} table(s) introspected successfully", metadata_list.len());
if !errors.is_empty() {
eprintln!(" {} table(s) failed", errors.len());
}
Ok(())
}
fn format_as_python(metadata: &FactTableMetadata) -> String {
let mut output = String::new();
let measures: Vec<String> = metadata.measures.iter().map(|m| format!("'{}'", m.name)).collect();
let filters: Vec<String> =
metadata.denormalized_filters.iter().map(|f| format!("'{}'", f.name)).collect();
let class_name = metadata
.table_name
.strip_prefix("tf_")
.unwrap_or(&metadata.table_name)
.split('_')
.map(|s| {
let mut c = s.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
}
})
.collect::<String>();
output.push_str(&format!("# Fact table: {}\n", metadata.table_name));
output.push_str("@fraiseql.fact_table(\n");
output.push_str(&format!(" measures=[{}],\n", measures.join(", ")));
output.push_str(&format!(" dimensions='{}',\n", metadata.dimensions.name));
if !filters.is_empty() {
output.push_str(&format!(" filters=[{}],\n", filters.join(", ")));
}
if !metadata.calendar_dimensions.is_empty() {
let calendar_cols: Vec<String> = metadata
.calendar_dimensions
.iter()
.map(|c| format!("'{}'", c.source_column))
.collect();
output.push_str(&format!(" calendar_columns=[{}],\n", calendar_cols.join(", ")));
}
output.push_str(")\n");
output.push_str(&format!("class {class_name}:\n"));
output.push_str(&format!(
" \"\"\"Fact table: {} ({} measures, {} filters)\"\"\"\n",
metadata.table_name,
metadata.measures.len(),
metadata.denormalized_filters.len()
));
output.push_str(" pass");
output
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_output_format_from_str() {
assert!(matches!(OutputFormat::parse("python"), Ok(OutputFormat::Python)));
assert!(matches!(OutputFormat::parse("json"), Ok(OutputFormat::Json)));
assert!(OutputFormat::parse("invalid").is_err());
}
#[test]
fn test_format_as_python() {
use fraiseql_core::compiler::fact_table::{
DimensionColumn, FilterColumn, MeasureColumn, SqlType,
};
let metadata = FactTableMetadata {
table_name: "tf_sales".to_string(),
measures: vec![
MeasureColumn {
name: "revenue".to_string(),
sql_type: SqlType::Decimal,
nullable: false,
},
MeasureColumn {
name: "quantity".to_string(),
sql_type: SqlType::Int,
nullable: false,
},
],
dimensions: DimensionColumn {
name: "data".to_string(),
paths: vec![],
},
denormalized_filters: vec![FilterColumn {
name: "customer_id".to_string(),
sql_type: SqlType::Uuid,
indexed: true,
}],
calendar_dimensions: vec![],
};
let output = format_as_python(&metadata);
assert!(output.contains("@fraiseql.fact_table"));
assert!(output.contains("'revenue'"));
assert!(output.contains("'quantity'"));
assert!(output.contains("'customer_id'"));
assert!(output.contains("class Sales:"));
}
}