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;
use crate::output::OutputFormatter;
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
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,
formatter: &OutputFormatter,
) -> Result<()> {
formatter.progress("Introspecting database for fact tables...");
formatter.progress(&format!(" 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() {
formatter.progress("\nwarn: No fact tables found (tables starting with 'tf_')");
formatter.progress(" Fact tables should be named like: tf_sales, tf_events, tf_orders");
return Ok(());
}
formatter.progress(&format!("\nFound {} fact table(s):", fact_tables.len()));
for table in &fact_tables {
formatter.progress(&format!(" - {table}"));
}
formatter.progress("");
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() {
formatter.progress(&format!("warn: Failed to introspect {} table(s):", errors.len()));
for (table, error) in &errors {
formatter.progress(&format!(" - {table}: {error}"));
}
formatter.progress("");
}
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)?);
},
}
formatter.progress("\nok: Introspection complete");
formatter.progress(&format!(" {} table(s) introspected successfully", metadata_list.len()));
if !errors.is_empty() {
formatter.progress(&format!(" {} table(s) failed", errors.len()));
}
Ok(())
}
pub(crate) 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
}