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}