Skip to main content

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}