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