use std::collections::HashMap;
use serde::{Deserialize, Serialize};
mod detector;
pub use fraiseql_db::{introspector::DatabaseIntrospector, types::DatabaseType};
pub use self::detector::FactTableDetector;
#[cfg(test)]
mod tests;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FactTableMetadata {
pub table_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub type_name: Option<String>,
pub measures: Vec<MeasureColumn>,
pub dimensions: DimensionColumn,
pub denormalized_filters: Vec<FilterColumn>,
#[serde(default)]
pub calendar_dimensions: Vec<CalendarDimension>,
#[serde(default)]
pub native_measures: HashMap<String, String>,
#[serde(default)]
pub native_dimension_mapping: HashMap<String, String>,
}
impl FactTableMetadata {
#[must_use]
pub fn additivity_violations(&self) -> Vec<String> {
let mut violations = Vec::new();
for measure in &self.measures {
let (over, entity) = match &measure.additivity {
Additivity::SemiAdditive { over, entity, .. }
| Additivity::Delta { over, entity } => (over, entity),
Additivity::Additive | Additivity::NonAdditive => continue,
};
let prefix = format!("fact table `{}`, measure `{}`", self.table_name, measure.name);
match self.denormalized_filters.iter().find(|f| f.name == *over) {
None => violations.push(format!(
"{prefix}: `over` names `{over}`, which is not a denormalized column"
)),
Some(f) if !matches!(f.sql_type, SqlType::Date | SqlType::Timestamp) => {
violations.push(format!(
"{prefix}: `over` names `{over}`, which is not a time column (date or \
timestamp)"
));
},
Some(_) => {},
}
if entity.is_empty() {
violations.push(format!(
"{prefix}: `entity` names no column; name the columns that identify what the \
value belongs to"
));
}
for column in entity {
if !self.denormalized_filters.iter().any(|f| f.name == *column) {
violations.push(format!(
"{prefix}: `entity` names `{column}`, which is not a denormalized column"
));
}
}
}
violations
}
#[must_use]
pub fn declared_dimension_segments(
&self,
field: &str,
) -> Option<crate::error::Result<Vec<String>>> {
let key = dimension_key(field);
self.dimensions
.paths
.iter()
.find(|p| p.name == field || dimension_key(&p.name) == key)
.map(|p| p.segments(&self.dimensions.name))
}
}
#[must_use]
pub fn dimension_key(key: &str) -> String {
key.split('.').map(crate::utils::to_snake_case).collect::<Vec<_>>().join(".")
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MeasureColumn {
pub name: String,
pub sql_type: SqlType,
pub nullable: bool,
#[serde(default, skip_serializing_if = "Additivity::is_additive")]
pub additivity: Additivity,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
#[non_exhaustive]
pub enum Additivity {
#[default]
Additive,
SemiAdditive {
over: String,
using: SemiAdditiveReduction,
entity: Vec<String>,
},
Delta {
over: String,
entity: Vec<String>,
},
NonAdditive,
}
impl Additivity {
#[must_use]
pub const fn is_additive(&self) -> bool {
matches!(self, Self::Additive)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum SemiAdditiveReduction {
Last,
First,
Avg,
Min,
Max,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum SqlType {
Int,
BigInt,
Decimal,
Float,
Jsonb,
Json,
Text,
Uuid,
Timestamp,
Date,
Boolean,
Ltree,
Other(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DimensionColumn {
pub name: String,
pub paths: Vec<DimensionPath>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DimensionPath {
pub name: String,
pub json_path: String,
pub data_type: String,
}
impl DimensionPath {
pub fn segments(&self, column: &str) -> crate::error::Result<Vec<String>> {
let refuse = || {
crate::error::FraiseQLError::validation(format!(
"dimension path `{}`: json_path `{}` must read the dimensions column `{column}` \
as `{column}->'key'->>'key'` (any number of `->'key'` steps, one final \
`->>'key'`)",
self.name, self.json_path
))
};
let mut rest = self.json_path.trim().strip_prefix(column).ok_or_else(refuse)?;
let mut segments = Vec::new();
loop {
let rest_trimmed = rest.trim_start();
let (last, after_arrow) = if let Some(r) = rest_trimmed.strip_prefix("->>") {
(true, r)
} else if let Some(r) = rest_trimmed.strip_prefix("->") {
(false, r)
} else {
return Err(refuse());
};
let (key, remainder) = quoted_literal(after_arrow.trim_start()).ok_or_else(refuse)?;
segments.push(key);
rest = remainder;
if last {
return if rest.trim().is_empty() {
Ok(segments)
} else {
Err(refuse())
};
}
}
}
}
fn quoted_literal(text: &str) -> Option<(String, &str)> {
let mut chars = text.char_indices();
if chars.next()?.1 != '\'' {
return None;
}
let mut value = String::new();
while let Some((i, c)) = chars.next() {
if c != '\'' {
value.push(c);
continue;
}
match chars.clone().next() {
Some((_, '\'')) => {
value.push('\'');
chars.next();
},
_ => return (!value.is_empty()).then(|| (value, &text[i + 1..])),
}
}
None
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CalendarDimension {
pub source_column: String,
pub granularities: Vec<CalendarGranularity>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CalendarGranularity {
pub column_name: String,
pub buckets: Vec<CalendarBucket>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CalendarBucket {
pub json_key: String,
pub bucket_type: crate::compiler::aggregate_types::TemporalBucket,
pub data_type: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FilterColumn {
pub name: String,
pub sql_type: SqlType,
pub indexed: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hierarchy: Option<String>,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum AggregationStrategy {
#[serde(rename = "incremental")]
#[default]
Incremental,
#[serde(rename = "accumulating_snapshot")]
AccumulatingSnapshot,
#[serde(rename = "periodic_snapshot")]
PeriodicSnapshot,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FactTableDeclaration {
pub name: String,
pub measures: Vec<String>,
pub dimensions: Vec<String>,
pub primary_key: String,
pub metadata: Option<FactTableDeclarationMetadata>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FactTableDeclarationMetadata {
#[serde(default)]
pub aggregation_strategy: AggregationStrategy,
pub grain: Vec<String>,
pub snapshot_date_column: Option<String>,
#[serde(default)]
pub is_slowly_changing_dimension: bool,
}
impl SqlType {
#[must_use]
pub fn from_str_postgres(type_name: &str) -> Self {
match type_name.to_lowercase().as_str() {
"smallint" | "int" | "integer" | "int2" | "int4" => Self::Int,
"bigint" | "int8" => Self::BigInt,
"decimal" | "numeric" => Self::Decimal,
"real" | "float" | "double precision" | "float4" | "float8" => Self::Float,
"jsonb" => Self::Jsonb,
"json" => Self::Json,
"text" | "varchar" | "character varying" | "char" | "character" => Self::Text,
"uuid" => Self::Uuid,
"timestamp"
| "timestamptz"
| "timestamp with time zone"
| "timestamp without time zone" => Self::Timestamp,
"date" => Self::Date,
"boolean" | "bool" => Self::Boolean,
"ltree" => Self::Ltree,
other => Self::Other(other.to_string()),
}
}
#[must_use]
pub fn from_str_mysql(type_name: &str) -> Self {
match type_name.to_lowercase().as_str() {
"tinyint" | "smallint" | "mediumint" | "int" | "integer" => Self::Int,
"bigint" => Self::BigInt,
"decimal" | "numeric" => Self::Decimal,
"float" | "double" | "real" => Self::Float,
"json" => Self::Json,
"text" | "varchar" | "char" | "tinytext" | "mediumtext" | "longtext" => Self::Text,
"timestamp" | "datetime" => Self::Timestamp,
"date" => Self::Date,
"boolean" | "bool" | "tinyint(1)" => Self::Boolean,
other => Self::Other(other.to_string()),
}
}
#[must_use]
pub fn from_str_sqlite(type_name: &str) -> Self {
match type_name.to_lowercase().as_str() {
"integer" | "int" => Self::BigInt, "real" | "double" | "float" => Self::Float,
"numeric" | "decimal" => Self::Decimal,
"text" | "varchar" | "char" => Self::Text,
"blob" => Self::Other("BLOB".to_string()),
other => Self::Other(other.to_string()),
}
}
#[must_use]
pub fn from_str_sqlserver(type_name: &str) -> Self {
match type_name.to_lowercase().as_str() {
"tinyint" | "smallint" | "int" => Self::Int,
"bigint" => Self::BigInt,
"decimal" | "numeric" | "money" | "smallmoney" => Self::Decimal,
"float" | "real" => Self::Float,
"nvarchar" | "varchar" | "char" | "nchar" | "text" | "ntext" => Self::Text,
"uniqueidentifier" => Self::Uuid,
"datetime" | "datetime2" | "smalldatetime" | "datetimeoffset" => Self::Timestamp,
"date" => Self::Date,
"bit" => Self::Boolean,
other => Self::Other(other.to_string()),
}
}
}