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}