Skip to main content

fraiseql_core/schema/compiled/
fact_table_links.rs

1//! The fact-table → type link: refusal of a link the runtime cannot enforce (ruling AB 1).
2//!
3//! A fact table linked to a type (`FactTableMetadata::type_name`) is read as that type: an
4//! aggregate or a window over it references its measures, filters and dimension paths as
5//! fields of the type, and each reference is gated as a read of that field. A declared column
6//! the type does not have would be a column no gate can reach, so the link must be complete —
7//! and the type must exist. Refused at load, whatever produced the schema, naming the fact
8//! table, the type and the names.
9
10use super::CompiledSchema;
11use crate::{
12    compiler::fact_table::FactTableMetadata,
13    schema::{FieldDefinition, TypeDefinition},
14};
15
16/// The field of `type_def` a fact-table name refers to: matched on its `snake_case` form, as a
17/// query filter's storage key is (`support::security`), so `organizationId` and
18/// `organization_id` are one field.
19pub fn fact_field<'a>(type_def: &'a TypeDefinition, name: &str) -> Option<&'a FieldDefinition> {
20    let key = crate::utils::to_snake_case(name);
21    type_def
22        .fields
23        .iter()
24        .find(|f| crate::utils::to_snake_case(f.name.as_str()) == key)
25}
26
27/// Every name a fact table declares: what a request over it can reference.
28fn declared_names(metadata: &FactTableMetadata) -> Vec<&str> {
29    let mut names: Vec<&str> = metadata
30        .measures
31        .iter()
32        .map(|m| m.name.as_str())
33        .chain(metadata.denormalized_filters.iter().map(|f| f.name.as_str()))
34        .chain(metadata.dimensions.paths.iter().map(|p| p.name.as_str()))
35        .chain(metadata.calendar_dimensions.iter().map(|c| c.source_column.as_str()))
36        .chain(metadata.native_measures.keys().map(String::as_str))
37        .chain(metadata.native_dimension_mapping.keys().map(String::as_str))
38        .collect();
39    names.sort_unstable();
40    names.dedup();
41    names
42}
43
44impl CompiledSchema {
45    /// Fact tables that declare one dimension twice, in ways a request could read two
46    /// ways (#1231): a `native_dimension_mapping` key that is also a
47    /// `denormalized_filters` column mapped to a different column, or two mapping keys
48    /// naming one dimension (in two casings) mapped to different columns. One message
49    /// per conflict, empty when every declaration agrees.
50    #[must_use]
51    pub fn fact_table_mapping_violations(&self) -> Vec<String> {
52        use crate::compiler::fact_table::dimension_key;
53
54        let mut tables: Vec<&FactTableMetadata> = self.fact_tables.values().collect();
55        tables.sort_by(|a, b| a.table_name.cmp(&b.table_name));
56        let mut out = Vec::new();
57        for table in tables {
58            let mut mapping: Vec<(&String, &String)> =
59                table.native_dimension_mapping.iter().collect();
60            mapping.sort();
61            for (i, (key, column)) in mapping.iter().enumerate() {
62                let dimension = dimension_key(key);
63                if let Some(filter) = table
64                    .denormalized_filters
65                    .iter()
66                    .find(|f| dimension_key(&f.name) == dimension && f.name != **column)
67                {
68                    out.push(format!(
69                        "fact table `{}`: native_dimension_mapping maps `{key}` to column \
70                         `{column}`, and denormalized_filters declares `{}` as a column of \
71                         its own; declare the dimension once",
72                        table.table_name, filter.name
73                    ));
74                }
75                for (other, other_column) in &mapping[i + 1..] {
76                    if dimension_key(other) == dimension && other_column != column {
77                        out.push(format!(
78                            "fact table `{}`: native_dimension_mapping maps `{key}` to \
79                             `{column}` and `{other}` to `{other_column}`, which name one \
80                             dimension; declare it once",
81                            table.table_name
82                        ));
83                    }
84                }
85            }
86        }
87        out
88    }
89
90    /// The type a fact table is read as, when it is linked to one that exists.
91    #[must_use]
92    pub fn fact_table_type(&self, metadata: &FactTableMetadata) -> Option<&TypeDefinition> {
93        let name = metadata.type_name.as_deref()?;
94        self.types.iter().find(|t| t.name == name)
95    }
96
97    /// Fact tables whose link to a type cannot be enforced: the type does not exist, or it
98    /// lacks a name the fact table declares. One message per fact table, empty when every
99    /// link is enforceable; unlinked fact tables are not concerned.
100    #[must_use]
101    pub fn fact_table_link_violations(&self) -> Vec<String> {
102        let mut tables: Vec<&FactTableMetadata> = self.fact_tables.values().collect();
103        tables.sort_by(|a, b| a.table_name.cmp(&b.table_name));
104        tables
105            .into_iter()
106            .filter_map(|metadata| {
107                let type_name = metadata.type_name.as_deref()?;
108                let Some(type_def) = self.fact_table_type(metadata) else {
109                    return Some(format!(
110                        "fact table '{}' is read as type '{type_name}', which the schema does \
111                         not declare",
112                        metadata.table_name
113                    ));
114                };
115                let missing: Vec<&str> = declared_names(metadata)
116                    .into_iter()
117                    .filter(|name| fact_field(type_def, name).is_none())
118                    .collect();
119                (!missing.is_empty()).then(|| {
120                    format!(
121                        "fact table '{}' is read as type '{type_name}', which has no field for \
122                         {}: a column the type does not declare is one no read gate can reach. \
123                         Declare the fields on '{type_name}', or drop them from the fact table.",
124                        metadata.table_name,
125                        missing.iter().map(|n| format!("'{n}'")).collect::<Vec<_>>().join(", ")
126                    )
127                })
128            })
129            .collect()
130    }
131}