Skip to main content

fraiseql_core/compiler/fact_table/
mod.rs

1//! Fact Table Introspection Module
2//!
3//! This module provides functionality to detect and introspect fact tables following
4//! FraiseQL's analytics architecture:
5//!
6//! # Fact Table Pattern
7//!
8//! - **Table naming**: `tf_*` prefix (table fact)
9//! - **Measures**: SQL columns with numeric types (INT, BIGINT, DECIMAL, FLOAT) - for fast
10//!   aggregation
11//! - **Dimensions**: JSONB `data` column - for flexible GROUP BY
12//! - **Denormalized filters**: Indexed SQL columns (`customer_id`, `occurred_at`) - for fast WHERE
13//!
14//! # No Joins Principle
15//!
16//! FraiseQL does NOT support joins. All dimensional data must be denormalized into the
17//! `data` JSONB column at ETL time (managed by DBA/data team, not FraiseQL).
18//!
19//! # Example Fact Table
20//!
21//! ```sql
22//! CREATE TABLE tf_sales (
23//!     id BIGSERIAL PRIMARY KEY,
24//!     -- Measures (SQL columns for fast aggregation)
25//!     revenue DECIMAL(10,2) NOT NULL,
26//!     quantity INT NOT NULL,
27//!     cost DECIMAL(10,2) NOT NULL,
28//!     -- Dimensions (JSONB for flexible grouping)
29//!     data JSONB NOT NULL,
30//!     -- Denormalized filters (indexed for fast WHERE)
31//!     customer_id UUID NOT NULL,
32//!     product_id UUID NOT NULL,
33//!     occurred_at TIMESTAMPTZ NOT NULL,
34//!     created_at TIMESTAMPTZ DEFAULT NOW()
35//! );
36//! ```
37
38use std::collections::HashMap;
39
40use serde::{Deserialize, Serialize};
41
42mod detector;
43// Re-export from fraiseql-db to avoid duplication
44pub use fraiseql_db::{introspector::DatabaseIntrospector, types::DatabaseType};
45
46pub use self::detector::FactTableDetector;
47
48#[cfg(test)]
49mod tests;
50
51/// Metadata about a fact table structure
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct FactTableMetadata {
54    /// Table name (e.g., "`tf_sales`")
55    pub table_name:               String,
56    /// Measures (aggregatable numeric columns)
57    pub measures:                 Vec<MeasureColumn>,
58    /// Dimension column (JSONB)
59    pub dimensions:               DimensionColumn,
60    /// Denormalized filter columns
61    pub denormalized_filters:     Vec<FilterColumn>,
62    /// Calendar dimensions for optimized temporal aggregations
63    #[serde(default)]
64    pub calendar_dimensions:      Vec<CalendarDimension>,
65    /// Optional partial-period awareness configuration.
66    ///
67    /// When a coarse-grain fact table (e.g. monthly pre-aggregated) is queried with
68    /// a date filter that falls mid-period, the runtime generates a UNION ALL query
69    /// combining fine-grain source data for boundary periods with pre-aggregated data
70    /// for complete intermediate periods.
71    #[serde(default)]
72    pub partial_period:           Option<PartialPeriodConfig>,
73    /// Maps JSONB measure paths to flat SQL column names for pre-aggregated views.
74    ///
75    /// When a materialized view stores measures as native columns (e.g. `volume BIGINT`)
76    /// instead of inside a JSONB `data` column, this mapping tells the SQL generator to
77    /// use `SUM("volume")` instead of `SUM((data->'measures'->>'volume')::numeric)`.
78    #[serde(default)]
79    pub native_measures:          HashMap<String, String>,
80    /// Maps deep JSONB dimension paths to flat SQL column names.
81    ///
82    /// When a materialized view denormalizes dimension values into flat columns
83    /// (e.g. `category_id INT` instead of `data->'dimensions'->'category'->>'id'`),
84    /// this mapping tells the GROUP BY generator to use `GROUP BY "category_id"`
85    /// instead of JSONB extraction. Enables btree index usage.
86    #[serde(default)]
87    pub native_dimension_mapping: HashMap<String, String>,
88}
89
90/// A measure column (aggregatable numeric type)
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct MeasureColumn {
93    /// Column name (e.g., "revenue")
94    pub name:     String,
95    /// SQL data type
96    pub sql_type: SqlType,
97    /// Is nullable
98    pub nullable: bool,
99}
100
101/// SQL data types
102#[non_exhaustive]
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104pub enum SqlType {
105    /// SMALLINT, INT, INTEGER
106    Int,
107    /// BIGINT
108    BigInt,
109    /// DECIMAL, NUMERIC
110    Decimal,
111    /// REAL, FLOAT, DOUBLE PRECISION
112    Float,
113    /// JSONB (PostgreSQL)
114    Jsonb,
115    /// JSON (MySQL, SQL Server)
116    Json,
117    /// TEXT, VARCHAR
118    Text,
119    /// UUID
120    Uuid,
121    /// TIMESTAMP, TIMESTAMPTZ
122    Timestamp,
123    /// DATE
124    Date,
125    /// BOOLEAN
126    Boolean,
127    /// Other types
128    Other(String),
129}
130
131/// Dimension column (JSONB)
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct DimensionColumn {
134    /// Column name (default: "dimensions" for fact tables)
135    pub name:  String,
136    /// Detected dimension paths (optional, extracted from sample data)
137    pub paths: Vec<DimensionPath>,
138}
139
140/// A dimension path within the JSONB column
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142pub struct DimensionPath {
143    /// Path name (e.g., "category")
144    pub name:      String,
145    /// JSON path (e.g., "dimensions->>'category'" for PostgreSQL)
146    pub json_path: String,
147    /// Data type hint
148    pub data_type: String,
149}
150
151/// Calendar dimension metadata (pre-computed temporal fields)
152///
153/// Calendar dimensions provide 10-20x performance improvements for temporal aggregations
154/// by using pre-computed JSONB columns (`date_info`, `month_info`, etc.) instead of runtime
155/// `DATE_TRUNC` operations.
156///
157/// # Multi-Column Pattern
158///
159/// - 7 JSONB columns: `date_info`, `week_info`, `month_info`, `quarter_info`, `semester_info`,
160///   `year_info`, `decade_info`
161/// - Each contains hierarchical temporal buckets (e.g., `date_info` has: date, week, month,
162///   quarter, year)
163/// - Pre-populated by user's ETL (FraiseQL reads, doesn't populate)
164///
165/// # Example
166///
167/// ```json
168/// {
169///   "date": "2024-03-15",
170///   "week": 11,
171///   "month": 3,
172///   "quarter": 1,
173///   "semester": 1,
174///   "year": 2024
175/// }
176/// ```
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub struct CalendarDimension {
179    /// Source timestamp column (e.g., "`occurred_at`")
180    pub source_column: String,
181
182    /// Available calendar granularity columns
183    pub granularities: Vec<CalendarGranularity>,
184}
185
186/// Calendar granularity column with pre-computed fields
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188pub struct CalendarGranularity {
189    /// Column name (e.g., "`date_info`", "`month_info`")
190    pub column_name: String,
191
192    /// Temporal buckets available in this column
193    pub buckets: Vec<CalendarBucket>,
194}
195
196/// Pre-computed temporal bucket in calendar JSONB
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198pub struct CalendarBucket {
199    /// JSON path key (e.g., "date", "month", "quarter")
200    pub json_key: String,
201
202    /// Corresponding `TemporalBucket` enum
203    pub bucket_type: crate::compiler::aggregate_types::TemporalBucket,
204
205    /// Data type (e.g., "date", "integer")
206    pub data_type: String,
207}
208
209/// A denormalized filter column
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211pub struct FilterColumn {
212    /// Column name (e.g., "`customer_id`")
213    pub name:     String,
214    /// SQL data type
215    pub sql_type: SqlType,
216    /// Is indexed (for performance)
217    pub indexed:  bool,
218}
219
220/// Configuration for partial-period awareness (UNION ALL optimization).
221///
222/// When a coarse-grain fact table (e.g. monthly pre-aggregated) is queried with
223/// a date filter that falls mid-period, the runtime generates a UNION ALL query
224/// combining fine-grain source data for boundary periods with pre-aggregated data
225/// for complete intermediate periods.
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
227pub struct PartialPeriodConfig {
228    /// Fine-grain source view (e.g., "`v_events_day`").
229    pub fine_grain_view:   String,
230    /// Column holding the period date (e.g., "`date`").
231    pub time_grain_column: String,
232    /// Truncation granularity for period boundaries.
233    pub time_grain_trunc:  TemporalGrain,
234}
235
236/// Temporal granularity for period boundary calculations.
237///
238/// Unlike `TemporalBucket` which
239/// includes sub-day granularities (`Second`, `Minute`, `Hour`) for GROUP BY bucketing,
240/// `TemporalGrain` is restricted to date-level granularities that define meaningful
241/// period boundaries for partial-period UNION ALL queries.
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
243#[serde(rename_all = "lowercase")]
244pub enum TemporalGrain {
245    /// Day-level periods.
246    Day,
247    /// ISO week (Monday-start) periods.
248    Week,
249    /// Calendar month periods.
250    Month,
251    /// Calendar quarter periods (Q1=Jan, Q2=Apr, Q3=Jul, Q4=Oct).
252    Quarter,
253    /// Calendar year periods.
254    Year,
255}
256
257impl TemporalGrain {
258    /// Returns the PostgreSQL `DATE_TRUNC` argument string.
259    ///
260    /// # Examples
261    ///
262    /// ```
263    /// use fraiseql_core::compiler::fact_table::TemporalGrain;
264    ///
265    /// assert_eq!(TemporalGrain::Month.postgres_trunc_arg(), "month");
266    /// assert_eq!(TemporalGrain::Quarter.postgres_trunc_arg(), "quarter");
267    /// ```
268    #[must_use]
269    pub const fn postgres_trunc_arg(self) -> &'static str {
270        match self {
271            Self::Day => "day",
272            Self::Week => "week",
273            Self::Month => "month",
274            Self::Quarter => "quarter",
275            Self::Year => "year",
276        }
277    }
278
279    /// Converts to the corresponding `TemporalBucket` for use with SQL generators.
280    #[must_use]
281    pub const fn to_temporal_bucket(self) -> super::aggregate_types::TemporalBucket {
282        match self {
283            Self::Day => super::aggregate_types::TemporalBucket::Day,
284            Self::Week => super::aggregate_types::TemporalBucket::Week,
285            Self::Month => super::aggregate_types::TemporalBucket::Month,
286            Self::Quarter => super::aggregate_types::TemporalBucket::Quarter,
287            Self::Year => super::aggregate_types::TemporalBucket::Year,
288        }
289    }
290}
291
292/// Aggregation strategy for fact tables
293///
294/// Determines how fact table data is updated and structured.
295///
296/// # Strategies
297///
298/// - **Incremental**: New records added (e.g., transaction logs)
299/// - **`AccumulatingSnapshot`**: Records updated with new events (e.g., order milestones)
300/// - **`PeriodicSnapshot`**: Complete snapshot at regular intervals (e.g., daily inventory)
301#[non_exhaustive]
302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
303pub enum AggregationStrategy {
304    /// New records are appended (e.g., transaction logs, event streams)
305    #[serde(rename = "incremental")]
306    #[default]
307    Incremental,
308
309    /// Records are updated with new events (e.g., order status changes)
310    #[serde(rename = "accumulating_snapshot")]
311    AccumulatingSnapshot,
312
313    /// Complete snapshots at regular intervals (e.g., daily inventory levels)
314    #[serde(rename = "periodic_snapshot")]
315    PeriodicSnapshot,
316}
317
318/// Explicit fact table schema declaration
319///
320/// Allows users to explicitly declare fact table metadata instead of relying on
321/// auto-detection. Explicit declarations take precedence over auto-detected metadata.
322///
323/// # Example
324///
325/// ```json
326/// {
327///   "name": "tf_sales",
328///   "measures": ["amount", "quantity", "discount"],
329///   "dimensions": ["product_id", "region_id", "date_id"],
330///   "primary_key": "id",
331///   "metadata": {
332///     "aggregation_strategy": "incremental",
333///     "grain": ["date", "product", "region"]
334///   }
335/// }
336/// ```
337#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
338pub struct FactTableDeclaration {
339    /// Fact table name (e.g., "`tf_sales`")
340    pub name: String,
341
342    /// Measure column names (aggregatable numeric fields)
343    pub measures: Vec<String>,
344
345    /// Dimension column names or paths within JSONB
346    pub dimensions: Vec<String>,
347
348    /// Primary key column name
349    pub primary_key: String,
350
351    /// Optional metadata about the fact table
352    pub metadata: Option<FactTableDeclarationMetadata>,
353}
354
355/// Metadata for explicitly declared fact tables
356#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
357pub struct FactTableDeclarationMetadata {
358    /// Aggregation strategy (how data is updated)
359    #[serde(default)]
360    pub aggregation_strategy: AggregationStrategy,
361
362    /// Grain of the fact table (combination of dimensions that makes a unique record)
363    pub grain: Vec<String>,
364
365    /// Column containing snapshot date (for periodic snapshots)
366    pub snapshot_date_column: Option<String>,
367
368    /// Whether this is a slowly changing dimension
369    #[serde(default)]
370    pub is_slowly_changing_dimension: bool,
371}
372
373impl SqlType {
374    /// Parse SQL type from string (database-specific)
375    #[must_use]
376    pub fn from_str_postgres(type_name: &str) -> Self {
377        match type_name.to_lowercase().as_str() {
378            "smallint" | "int" | "integer" | "int2" | "int4" => Self::Int,
379            "bigint" | "int8" => Self::BigInt,
380            "decimal" | "numeric" => Self::Decimal,
381            "real" | "float" | "double precision" | "float4" | "float8" => Self::Float,
382            "jsonb" => Self::Jsonb,
383            "json" => Self::Json,
384            "text" | "varchar" | "character varying" | "char" | "character" => Self::Text,
385            "uuid" => Self::Uuid,
386            "timestamp"
387            | "timestamptz"
388            | "timestamp with time zone"
389            | "timestamp without time zone" => Self::Timestamp,
390            "date" => Self::Date,
391            "boolean" | "bool" => Self::Boolean,
392            other => Self::Other(other.to_string()),
393        }
394    }
395
396    /// Parse SQL type from string (MySQL)
397    #[must_use]
398    pub fn from_str_mysql(type_name: &str) -> Self {
399        match type_name.to_lowercase().as_str() {
400            "tinyint" | "smallint" | "mediumint" | "int" | "integer" => Self::Int,
401            "bigint" => Self::BigInt,
402            "decimal" | "numeric" => Self::Decimal,
403            "float" | "double" | "real" => Self::Float,
404            "json" => Self::Json,
405            "text" | "varchar" | "char" | "tinytext" | "mediumtext" | "longtext" => Self::Text,
406            "timestamp" | "datetime" => Self::Timestamp,
407            "date" => Self::Date,
408            "boolean" | "bool" | "tinyint(1)" => Self::Boolean,
409            other => Self::Other(other.to_string()),
410        }
411    }
412
413    /// Parse SQL type from string (SQLite)
414    #[must_use]
415    pub fn from_str_sqlite(type_name: &str) -> Self {
416        match type_name.to_lowercase().as_str() {
417            "integer" | "int" => Self::BigInt, // SQLite INTEGER is 64-bit
418            "real" | "double" | "float" => Self::Float,
419            "numeric" | "decimal" => Self::Decimal,
420            "text" | "varchar" | "char" => Self::Text,
421            "blob" => Self::Other("BLOB".to_string()),
422            other => Self::Other(other.to_string()),
423        }
424    }
425
426    /// Parse SQL type from string (SQL Server)
427    #[must_use]
428    pub fn from_str_sqlserver(type_name: &str) -> Self {
429        match type_name.to_lowercase().as_str() {
430            "tinyint" | "smallint" | "int" => Self::Int,
431            "bigint" => Self::BigInt,
432            "decimal" | "numeric" | "money" | "smallmoney" => Self::Decimal,
433            "float" | "real" => Self::Float,
434            "nvarchar" | "varchar" | "char" | "nchar" | "text" | "ntext" => Self::Text,
435            "uniqueidentifier" => Self::Uuid,
436            "datetime" | "datetime2" | "smalldatetime" | "datetimeoffset" => Self::Timestamp,
437            "date" => Self::Date,
438            "bit" => Self::Boolean,
439            other => Self::Other(other.to_string()),
440        }
441    }
442}