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