1use 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#[derive(Debug, Clone, Copy)]
21pub enum OutputFormat {
22 Python,
24 Json,
26}
27
28impl OutputFormat {
29 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
39async 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 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
61pub async fn run(database_url: &str, format: OutputFormat) -> Result<()> {
78 eprintln!("đ Introspecting database for fact tables...");
79 eprintln!(" Database: {database_url}");
80
81 let introspector = create_introspector(database_url).await?;
83
84 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 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 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 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
208fn format_as_python(metadata: &FactTableMetadata) -> String {
210 let mut output = String::new();
211
212 let measures: Vec<String> = metadata.measures.iter().map(|m| format!("'{}'", m.name)).collect();
214
215 let filters: Vec<String> =
217 metadata.denormalized_filters.iter().map(|f| format!("'{}'", f.name)).collect();
218
219 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 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 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}