fraiseql_core/compiler/fact_table/detector.rs
1use super::{
2 CalendarBucket, CalendarDimension, CalendarGranularity, DatabaseIntrospector, DatabaseType,
3 DimensionColumn, DimensionPath, FactTableMetadata, FilterColumn, MeasureColumn, SqlType,
4};
5use crate::error::{FraiseQLError, Result};
6
7/// Detects and introspects fact tables
8pub struct FactTableDetector;
9
10impl FactTableDetector {
11 /// Detect if a table name follows the fact table pattern.
12 ///
13 /// Fact tables must follow the naming convention: `tf_<table_name>`
14 /// where the table name contains only lowercase letters, numbers, and underscores.
15 ///
16 /// # Arguments
17 ///
18 /// * `table_name` - Table name to check
19 ///
20 /// # Returns
21 ///
22 /// `true` if the table name starts with `tf_` and follows naming conventions,
23 /// `false` otherwise.
24 ///
25 /// # Notes
26 ///
27 /// - The check is strict: `tf_` is required as a prefix
28 /// - Table names like `TF_sales` (uppercase prefix) are NOT recognized as fact tables
29 /// - Empty strings and tables named just `tf_` without additional suffix are not valid
30 ///
31 /// # Examples
32 ///
33 /// ```
34 /// use fraiseql_core::compiler::fact_table::FactTableDetector;
35 ///
36 /// assert!(FactTableDetector::is_fact_table("tf_sales"));
37 /// assert!(FactTableDetector::is_fact_table("tf_events"));
38 /// assert!(FactTableDetector::is_fact_table("tf_page_views_daily"));
39 /// assert!(!FactTableDetector::is_fact_table("ta_sales_by_day"));
40 /// assert!(!FactTableDetector::is_fact_table("v_user"));
41 /// assert!(!FactTableDetector::is_fact_table("TF_sales")); // uppercase prefix not recognized
42 /// assert!(!FactTableDetector::is_fact_table("tf_")); // incomplete name
43 /// ```
44 #[must_use]
45 pub fn is_fact_table(table_name: &str) -> bool {
46 // Must start with "tf_" and have at least one more character
47 table_name.len() > 3 && table_name.starts_with("tf_")
48 }
49
50 /// Introspect a fact table from the database
51 ///
52 /// Queries the database schema to extract:
53 /// - Measures (numeric columns)
54 /// - Dimensions (JSONB/JSON columns)
55 /// - Denormalized filters (indexed columns)
56 ///
57 /// # Arguments
58 ///
59 /// * `introspector` - Database introspection implementation
60 /// * `table_name` - Fact table name (must start with "tf_")
61 ///
62 /// # Errors
63 ///
64 /// Returns error if:
65 /// - Table is not a fact table (doesn't start with "tf_")
66 /// - Database query fails
67 /// - Table structure is invalid
68 ///
69 /// # Example
70 ///
71 /// ```rust,no_run
72 /// use fraiseql_core::compiler::fact_table::{FactTableDetector, DatabaseIntrospector};
73 ///
74 /// # async fn example(db: impl DatabaseIntrospector) -> Result<(), Box<dyn std::error::Error>> {
75 /// let metadata = FactTableDetector::introspect(&db, "tf_sales").await?;
76 /// println!("Found {} measures", metadata.measures.len());
77 /// # Ok(())
78 /// # }
79 /// ```
80 pub async fn introspect(
81 introspector: &impl DatabaseIntrospector,
82 table_name: &str,
83 ) -> Result<FactTableMetadata> {
84 // Validate table name follows fact table pattern
85 if !Self::is_fact_table(table_name) {
86 return Err(FraiseQLError::Validation {
87 message: format!(
88 "Table '{}' is not a fact table (must start with 'tf_')",
89 table_name
90 ),
91 path: None,
92 });
93 }
94
95 // Query column information
96 let columns = introspector.get_columns(table_name).await?;
97 if columns.is_empty() {
98 return Err(FraiseQLError::Validation {
99 message: format!("Table '{}' not found or has no columns", table_name),
100 path: None,
101 });
102 }
103
104 // Query indexed columns
105 let indexed_columns = introspector.get_indexed_columns(table_name).await?;
106 let indexed_set: std::collections::HashSet<String> = indexed_columns.into_iter().collect();
107
108 // Parse SQL types based on database
109 let db_type = introspector.database_type();
110
111 let mut measures = Vec::new();
112 let mut dimension_candidates: Vec<String> = Vec::new();
113 let mut filters = Vec::new();
114
115 for (name, data_type, is_nullable) in &columns {
116 let sql_type = Self::parse_sql_type(data_type, db_type);
117
118 match sql_type {
119 SqlType::Jsonb | SqlType::Json => {
120 // A dimension-column CANDIDATE. The choice is made by role
121 // after the scan (#825): the old code assigned here
122 // unconditionally, so with several JSONB columns the last
123 // one in ordinal order silently won — guaranteed wrong for
124 // the documented calendar pattern, whose `*_info` columns
125 // come after the real dimensions column.
126 dimension_candidates.push(name.clone());
127 },
128 SqlType::Int | SqlType::BigInt | SqlType::Decimal | SqlType::Float => {
129 // Skip common non-measure columns
130 if name != "id" && !name.ends_with("_id") {
131 measures.push(MeasureColumn {
132 name: name.clone(),
133 sql_type: sql_type.clone(),
134 nullable: *is_nullable,
135 });
136 }
137
138 // A numeric `*_id` column is a denormalized filter whether
139 // or not it is indexed — dropping the unindexed ones made
140 // them vanish from the metadata entirely (#825 secondary).
141 if name.ends_with("_id") {
142 filters.push(FilterColumn {
143 name: name.clone(),
144 sql_type: sql_type.clone(),
145 indexed: indexed_set.contains(name.as_str()),
146 hierarchy: None,
147 });
148 }
149 },
150 _ => {
151 // Other types might be denormalized filters
152 if name != "id"
153 && name != "created_at"
154 && name != "updated_at"
155 && name != "occurred_at"
156 {
157 filters.push(FilterColumn {
158 name: name.clone(),
159 sql_type,
160 indexed: indexed_set.contains(name.as_str()),
161 hierarchy: None,
162 });
163 } else if (name == "occurred_at" || name == "created_at")
164 && indexed_set.contains(name.as_str())
165 {
166 // Timestamp columns are important filters if indexed
167 filters.push(FilterColumn {
168 name: name.clone(),
169 sql_type,
170 indexed: true,
171 hierarchy: None,
172 });
173 }
174 },
175 }
176 }
177
178 // Detect calendar dimensions
179 let calendar_dimensions = Self::detect_calendar_dimensions(&columns, &indexed_set)?;
180
181 // Choose the dimensions column by role, then sample only the chosen one.
182 let dimension_column = match Self::choose_dimension_column(&dimension_candidates)? {
183 None => None,
184 Some(name) => {
185 let paths = if let Ok(Some(sample)) =
186 introspector.get_sample_jsonb(table_name, &name).await
187 {
188 Self::extract_dimension_paths(&sample, &name, db_type)
189 } else {
190 Vec::new()
191 };
192 Some(DimensionColumn { name, paths })
193 },
194 };
195
196 let metadata = FactTableMetadata {
197 table_name: table_name.to_string(),
198 type_name: None,
199 measures,
200 dimensions: dimension_column.unwrap_or(DimensionColumn {
201 name: "dimensions".to_string(),
202 paths: Vec::new(),
203 }),
204 denormalized_filters: filters,
205 calendar_dimensions,
206 partial_period: None,
207 native_measures: std::collections::HashMap::new(),
208 native_dimension_mapping: std::collections::HashMap::new(),
209 };
210
211 Self::validate(&metadata)?;
212 Ok(metadata)
213 }
214
215 /// Parse SQL type string to `SqlType` enum
216 fn parse_sql_type(type_name: &str, db_type: DatabaseType) -> SqlType {
217 match db_type {
218 DatabaseType::PostgreSQL => SqlType::from_str_postgres(type_name),
219 }
220 }
221
222 /// Choose the dimensions column from the JSONB/JSON candidates **by role,
223 /// never by position** (#825).
224 ///
225 /// `*_info` columns belong to the calendar-dimension pattern
226 /// ([`Self::detect_calendar_dimensions`]) and are excluded. Of what
227 /// remains: a single candidate wins; with several, a conventional name
228 /// (`data`, then `dimensions`) wins; several unconventional candidates are
229 /// a reported ambiguity rather than a silent order-dependent pick.
230 ///
231 /// # Errors
232 ///
233 /// Returns `FraiseQLError::Validation` naming the candidates when the
234 /// choice is ambiguous.
235 fn choose_dimension_column(candidates: &[String]) -> Result<Option<String>> {
236 let non_calendar: Vec<&String> =
237 candidates.iter().filter(|n| !n.ends_with("_info")).collect();
238 match non_calendar.as_slice() {
239 [] => Ok(None),
240 [only] => Ok(Some((*only).clone())),
241 several => {
242 for conventional in ["data", "dimensions"] {
243 if let Some(name) = several.iter().find(|n| n.as_str() == conventional) {
244 return Ok(Some((*name).clone()));
245 }
246 }
247 let names = several.iter().map(|n| n.as_str()).collect::<Vec<_>>().join(", ");
248 Err(FraiseQLError::Validation {
249 message: format!(
250 "ambiguous dimension columns: several JSONB columns qualify ({names}) \
251 and none is named 'data' or 'dimensions' — rename the dimensions \
252 column to a conventional name"
253 ),
254 path: None,
255 })
256 },
257 }
258 }
259
260 /// Validate fact table structure
261 ///
262 /// # Errors
263 ///
264 /// Returns error if:
265 /// - No measures found
266 /// - No dimension column found
267 /// - Measures are not numeric types
268 pub fn validate(metadata: &FactTableMetadata) -> Result<()> {
269 // Must have at least one measure
270 if metadata.measures.is_empty() {
271 return Err(FraiseQLError::Validation {
272 message: format!(
273 "Fact table '{}' must have at least one measure column",
274 metadata.table_name
275 ),
276 path: None,
277 });
278 }
279
280 // Validate all measures are numeric
281 for measure in &metadata.measures {
282 if !Self::is_numeric_type(&measure.sql_type) {
283 return Err(FraiseQLError::Validation {
284 message: format!(
285 "Measure column '{}' must be numeric type, found {:?}",
286 measure.name, measure.sql_type
287 ),
288 path: None,
289 });
290 }
291 }
292
293 // Must have dimension column
294 if metadata.dimensions.name.is_empty() {
295 return Err(FraiseQLError::Validation {
296 message: format!(
297 "Fact table '{}' must have a dimension column (JSONB)",
298 metadata.table_name
299 ),
300 path: None,
301 });
302 }
303
304 Ok(())
305 }
306
307 /// Check if SQL type is numeric (suitable for aggregation)
308 pub(super) const fn is_numeric_type(sql_type: &SqlType) -> bool {
309 matches!(sql_type, SqlType::Int | SqlType::BigInt | SqlType::Decimal | SqlType::Float)
310 }
311
312 /// Extract dimension paths from a sample JSON value
313 ///
314 /// Walks through the JSON structure and extracts top-level keys as dimension paths.
315 /// Nested objects are represented with dot notation (e.g., "customer.region").
316 ///
317 /// # Arguments
318 ///
319 /// * `sample` - Sample JSON value from the dimension column
320 /// * `column_name` - Name of the JSONB column (e.g., "dimensions")
321 /// * `db_type` - Database type for generating correct JSON path syntax
322 ///
323 /// # Returns
324 ///
325 /// Vec of `DimensionPath` extracted from the sample data
326 #[must_use]
327 pub fn extract_dimension_paths(
328 sample: &serde_json::Value,
329 column_name: &str,
330 db_type: DatabaseType,
331 ) -> Vec<DimensionPath> {
332 let mut paths = Vec::new();
333 Self::extract_paths_recursive(sample, column_name, "", &mut paths, db_type, 0);
334 paths
335 }
336
337 /// Recursively extract paths from JSON structure
338 fn extract_paths_recursive(
339 value: &serde_json::Value,
340 column_name: &str,
341 prefix: &str,
342 paths: &mut Vec<DimensionPath>,
343 db_type: DatabaseType,
344 depth: usize,
345 ) {
346 // Limit depth to avoid infinite recursion on deeply nested structures
347 if depth > 3 {
348 return;
349 }
350
351 if let Some(obj) = value.as_object() {
352 for (key, val) in obj {
353 let full_path = if prefix.is_empty() {
354 key.clone()
355 } else {
356 format!("{}.{}", prefix, key)
357 };
358
359 // Determine data type from the value
360 let data_type = Self::infer_json_type(val);
361
362 // Generate database-specific JSON path syntax
363 let json_path = Self::generate_json_path(column_name, &full_path, db_type);
364
365 paths.push(DimensionPath {
366 name: full_path.replace('.', "_"), /* Convert dots to underscores for field
367 * names */
368 json_path,
369 data_type,
370 });
371
372 // Recurse into nested objects
373 if val.is_object() {
374 Self::extract_paths_recursive(
375 val,
376 column_name,
377 &full_path,
378 paths,
379 db_type,
380 depth + 1,
381 );
382 }
383 }
384 }
385 }
386
387 /// Infer JSON data type from a value
388 pub(super) fn infer_json_type(value: &serde_json::Value) -> String {
389 match value {
390 serde_json::Value::Bool(_) => "boolean".to_string(),
391 serde_json::Value::Number(n) => {
392 if n.is_i64() || n.is_u64() {
393 "integer".to_string()
394 } else {
395 "float".to_string()
396 }
397 },
398 serde_json::Value::Array(_) => "array".to_string(),
399 serde_json::Value::Object(_) => "object".to_string(),
400 // Null and String both infer as "string" type
401 serde_json::Value::Null | serde_json::Value::String(_) => "string".to_string(),
402 }
403 }
404
405 /// Generate database-specific JSON path syntax
406 pub(super) fn generate_json_path(
407 column_name: &str,
408 path: &str,
409 db_type: DatabaseType,
410 ) -> String {
411 let parts: Vec<&str> = path.split('.').collect();
412
413 match db_type {
414 DatabaseType::PostgreSQL => {
415 // PostgreSQL: column->>'key' for top-level, column->'nested'->>'key' for nested
416 if parts.is_empty() {
417 // Safety: handle empty path by returning raw column name
418 column_name.to_string()
419 } else if parts.len() == 1 {
420 format!("{}->>'{}'", column_name, parts[0])
421 } else {
422 // Safe: parts.len() >= 2 is guaranteed here
423 if let Some(last) = parts.last() {
424 let rest = &parts[..parts.len() - 1];
425 let nav = rest.iter().fold(String::new(), |mut acc, p| {
426 use std::fmt::Write;
427 let _ = write!(acc, "->'{}'", p);
428 acc
429 });
430 format!("{}{}->>'{}'", column_name, nav, last)
431 } else {
432 // This branch is unreachable due to length check, but safe fallback
433 column_name.to_string()
434 }
435 }
436 },
437 }
438 }
439
440 /// Detect calendar dimension columns (`date_info`, `week_info`, etc.)
441 ///
442 /// Looks for `*_info` JSONB/JSON columns following the calendar dimension pattern.
443 /// Returns calendar dimension metadata if calendar columns are found.
444 ///
445 /// # Arguments
446 ///
447 /// * `columns` - List of (name, `data_type`, nullable) tuples
448 /// * `_indexed_set` - Set of indexed columns (unused, for future optimization detection)
449 ///
450 /// # Returns
451 ///
452 /// Vec of calendar dimensions (empty if none found)
453 pub(super) fn detect_calendar_dimensions(
454 columns: &[(String, String, bool)],
455 _indexed_set: &std::collections::HashSet<String>,
456 ) -> Result<Vec<CalendarDimension>> {
457 // Look for *_info columns with JSONB/JSON type
458 let calendar_columns: Vec<String> = columns
459 .iter()
460 .filter(|(name, data_type, _)| {
461 name.ends_with("_info")
462 && (data_type.to_lowercase().contains("json")
463 || data_type.to_lowercase().contains("jsonb"))
464 })
465 .map(|(name, _, _)| name.clone())
466 .collect();
467
468 if calendar_columns.is_empty() {
469 return Ok(Vec::new());
470 }
471
472 // Build granularities based on calendar dimension pattern
473 let mut granularities = Vec::new();
474 for col_name in calendar_columns {
475 let buckets = Self::infer_calendar_buckets(&col_name);
476 if !buckets.is_empty() {
477 granularities.push(CalendarGranularity {
478 column_name: col_name,
479 buckets,
480 });
481 }
482 }
483
484 if granularities.is_empty() {
485 return Ok(Vec::new());
486 }
487
488 // Assume single source column "occurred_at"
489 // (could be enhanced to detect from schema later)
490 Ok(vec![CalendarDimension {
491 source_column: "occurred_at".to_string(),
492 granularities,
493 }])
494 }
495
496 /// Map calendar column names to available buckets (standard pattern)
497 ///
498 /// # Arguments
499 ///
500 /// * `column_name` - Name of the calendar column (e.g., "`date_info`", "`month_info`")
501 ///
502 /// # Returns
503 ///
504 /// Vec of calendar buckets available in this column
505 pub(super) fn infer_calendar_buckets(column_name: &str) -> Vec<CalendarBucket> {
506 use crate::compiler::aggregate_types::TemporalBucket;
507
508 match column_name {
509 "date_info" => vec![
510 CalendarBucket {
511 json_key: "date".to_string(),
512 bucket_type: TemporalBucket::Day,
513 data_type: "date".to_string(),
514 },
515 CalendarBucket {
516 json_key: "week".to_string(),
517 bucket_type: TemporalBucket::Week,
518 data_type: "integer".to_string(),
519 },
520 CalendarBucket {
521 json_key: "month".to_string(),
522 bucket_type: TemporalBucket::Month,
523 data_type: "integer".to_string(),
524 },
525 CalendarBucket {
526 json_key: "quarter".to_string(),
527 bucket_type: TemporalBucket::Quarter,
528 data_type: "integer".to_string(),
529 },
530 CalendarBucket {
531 json_key: "year".to_string(),
532 bucket_type: TemporalBucket::Year,
533 data_type: "integer".to_string(),
534 },
535 ],
536 "week_info" => vec![
537 CalendarBucket {
538 json_key: "week".to_string(),
539 bucket_type: TemporalBucket::Week,
540 data_type: "integer".to_string(),
541 },
542 CalendarBucket {
543 json_key: "month".to_string(),
544 bucket_type: TemporalBucket::Month,
545 data_type: "integer".to_string(),
546 },
547 CalendarBucket {
548 json_key: "quarter".to_string(),
549 bucket_type: TemporalBucket::Quarter,
550 data_type: "integer".to_string(),
551 },
552 CalendarBucket {
553 json_key: "year".to_string(),
554 bucket_type: TemporalBucket::Year,
555 data_type: "integer".to_string(),
556 },
557 ],
558 "month_info" => vec![
559 CalendarBucket {
560 json_key: "month".to_string(),
561 bucket_type: TemporalBucket::Month,
562 data_type: "integer".to_string(),
563 },
564 CalendarBucket {
565 json_key: "quarter".to_string(),
566 bucket_type: TemporalBucket::Quarter,
567 data_type: "integer".to_string(),
568 },
569 CalendarBucket {
570 json_key: "year".to_string(),
571 bucket_type: TemporalBucket::Year,
572 data_type: "integer".to_string(),
573 },
574 ],
575 "quarter_info" => vec![
576 CalendarBucket {
577 json_key: "quarter".to_string(),
578 bucket_type: TemporalBucket::Quarter,
579 data_type: "integer".to_string(),
580 },
581 CalendarBucket {
582 json_key: "year".to_string(),
583 bucket_type: TemporalBucket::Year,
584 data_type: "integer".to_string(),
585 },
586 ],
587 "semester_info" => vec![
588 CalendarBucket {
589 json_key: "semester".to_string(),
590 bucket_type: TemporalBucket::Quarter, // Map to Quarter for now
591 data_type: "integer".to_string(),
592 },
593 CalendarBucket {
594 json_key: "year".to_string(),
595 bucket_type: TemporalBucket::Year,
596 data_type: "integer".to_string(),
597 },
598 ],
599 "year_info" => vec![CalendarBucket {
600 json_key: "year".to_string(),
601 bucket_type: TemporalBucket::Year,
602 data_type: "integer".to_string(),
603 }],
604 _ => Vec::new(),
605 }
606 }
607
608 /// Create metadata from column definitions (for testing)
609 ///
610 /// # Errors
611 ///
612 /// Returns `FraiseQLError::Validation` if no time dimension column is found.
613 pub fn from_columns(
614 table_name: String,
615 columns: Vec<(&str, SqlType, bool)>,
616 ) -> Result<FactTableMetadata> {
617 let mut measures = Vec::new();
618 let mut dimension_candidates: Vec<String> = Vec::new();
619 let mut filters = Vec::new();
620
621 for (name, sql_type, nullable) in columns {
622 match sql_type {
623 SqlType::Jsonb | SqlType::Json => {
624 // Dimension-column candidate; chosen by role below (#825).
625 dimension_candidates.push(name.to_string());
626 },
627 SqlType::Int | SqlType::BigInt | SqlType::Decimal | SqlType::Float => {
628 // Skip id column
629 if name != "id" && !name.ends_with("_id") {
630 // This is a measure
631 measures.push(MeasureColumn {
632 name: name.to_string(),
633 sql_type,
634 nullable,
635 });
636 } else if name != "id" {
637 // This is a filter (_id columns)
638 filters.push(FilterColumn {
639 name: name.to_string(),
640 sql_type,
641 indexed: false,
642 hierarchy: None,
643 });
644 }
645 },
646 _ => {
647 // This might be a filter column (if not id/created_at/updated_at)
648 if name != "id" && name != "created_at" && name != "updated_at" {
649 filters.push(FilterColumn {
650 name: name.to_string(),
651 sql_type,
652 indexed: false, // Would need to query indexes to determine,
653 hierarchy: None,
654 });
655 }
656 },
657 }
658 }
659
660 let dimensions = Self::choose_dimension_column(&dimension_candidates)?.map_or_else(
661 || DimensionColumn {
662 name: "dimensions".to_string(),
663 paths: Vec::new(),
664 },
665 |name| DimensionColumn {
666 name,
667 paths: Vec::new(),
668 },
669 );
670
671 let metadata = FactTableMetadata {
672 table_name,
673 type_name: None,
674 measures,
675 dimensions,
676 denormalized_filters: filters,
677 calendar_dimensions: Vec::new(), // No calendar detection in test helper
678 partial_period: None,
679 native_measures: std::collections::HashMap::new(),
680 native_dimension_mapping: std::collections::HashMap::new(),
681 };
682
683 Self::validate(&metadata)?;
684 Ok(metadata)
685 }
686}