Skip to main content

fraiseql_cli/commands/
validate_facts.rs

1//! Validate that declared fact tables match database schema.
2//!
3//! This command checks:
4//! - Declared fact tables exist in database
5//! - Metadata structure matches actual database schema
6//! - Warns about undeclared tf_* tables
7//!
8//! **Purpose**: CI/CD validation step to catch schema drift.
9
10use std::{collections::HashSet, fs, path::Path};
11
12use anyhow::Result;
13use deadpool_postgres::{Config, ManagerConfig, RecyclingMethod, Runtime};
14use fraiseql_core::{
15    compiler::{
16        fact_table::{DatabaseIntrospector, FactTableDetector, FactTableMetadata},
17        ir::AuthoringIR,
18        parser::SchemaParser,
19    },
20    db::PostgresIntrospector,
21};
22use tokio_postgres::NoTls;
23
24use crate::output::OutputFormatter;
25
26/// Validation error type.
27#[derive(Debug)]
28pub struct ValidationIssue {
29    /// Issue type (error or warning)
30    pub severity:   IssueSeverity,
31    /// Fact table name
32    pub table_name: String,
33    /// Issue description
34    pub message:    String,
35}
36
37/// Issue severity level.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39#[non_exhaustive]
40pub enum IssueSeverity {
41    /// Critical error - validation fails
42    Error,
43    /// Warning - validation passes with warnings
44    Warning,
45}
46
47impl ValidationIssue {
48    /// Create a new error issue.
49    pub const fn error(table_name: String, message: String) -> Self {
50        Self {
51            severity: IssueSeverity::Error,
52            table_name,
53            message,
54        }
55    }
56
57    /// Create a new warning issue.
58    pub const fn warning(table_name: String, message: String) -> Self {
59        Self {
60            severity: IssueSeverity::Warning,
61            table_name,
62            message,
63        }
64    }
65}
66
67/// Create a PostgreSQL introspector from a database URL
68async fn create_introspector(database_url: &str) -> Result<PostgresIntrospector> {
69    let mut cfg = Config::new();
70    cfg.url = Some(database_url.to_string());
71    cfg.manager = Some(ManagerConfig {
72        recycling_method: RecyclingMethod::Fast,
73    });
74    cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
75
76    let pool = cfg
77        .create_pool(Some(Runtime::Tokio1), NoTls)
78        .map_err(|e| anyhow::anyhow!("Failed to create database pool: {e}"))?;
79
80    // Test connection
81    let _client = pool
82        .get()
83        .await
84        .map_err(|e| anyhow::anyhow!("Failed to connect to database: {e}"))?;
85
86    Ok(PostgresIntrospector::new(pool))
87}
88
89/// Validate that declared fact tables match database schema.
90///
91/// # Arguments
92///
93/// * `schema_path` - Path to schema.json file
94/// * `database_url` - Database connection string
95/// * `formatter` - Output formatter controlling verbosity and format
96///
97/// # Errors
98///
99/// Returns an error if the schema file cannot be read, the database connection fails,
100/// or any fact table validation check fails.
101///
102/// # Example
103///
104/// ```bash
105/// fraiseql validate facts --schema schema.json --database postgresql://localhost/mydb
106/// ```
107pub async fn run(
108    schema_path: &Path,
109    database_url: &str,
110    formatter: &OutputFormatter,
111) -> Result<()> {
112    formatter.section("Validating fact tables");
113    formatter.progress(&format!("   Schema: {}", schema_path.display()));
114    formatter.progress(&format!("   Database: {database_url}"));
115    formatter.progress("");
116
117    // 1. Load and parse schema
118    let schema_str = fs::read_to_string(schema_path)?;
119
120    let parser = SchemaParser::new();
121    let ir: AuthoringIR = parser.parse(&schema_str)?;
122
123    let declared_tables: HashSet<String> = ir.fact_tables.keys().cloned().collect();
124
125    formatter
126        .progress(&format!("Found {} declared fact table(s) in schema", declared_tables.len()));
127
128    if declared_tables.is_empty() {
129        formatter.progress("   No fact tables declared - nothing to validate");
130        formatter.progress("");
131        formatter.progress("Tip: Use 'fraiseql introspect facts' to discover fact tables");
132        return Ok(());
133    }
134
135    for table_name in &declared_tables {
136        formatter.progress(&format!("   - {table_name}"));
137    }
138    formatter.progress("");
139
140    // 2. Connect to database and list actual fact tables
141    let introspector = create_introspector(database_url).await?;
142
143    let actual_tables: HashSet<String> = introspector
144        .list_fact_tables()
145        .await
146        .map_err(|e| anyhow::anyhow!("Failed to list fact tables: {e}"))?
147        .into_iter()
148        .collect();
149
150    formatter.progress(&format!("Found {} fact table(s) in database", actual_tables.len()));
151    formatter.progress("");
152
153    // 3. Validate each declared table
154    let mut issues: Vec<ValidationIssue> = Vec::new();
155    let mut validated_count = 0;
156
157    for table_name in &declared_tables {
158        formatter.progress(&format!("   Validating {table_name}..."));
159
160        // Check if table exists in database
161        if !actual_tables.contains(table_name) {
162            issues.push(ValidationIssue::error(
163                table_name.clone(),
164                "Table does not exist in database".to_string(),
165            ));
166            continue;
167        }
168
169        // Introspect actual table structure
170        match FactTableDetector::introspect(&introspector, table_name).await {
171            Ok(actual_metadata) => {
172                // Compare structures against declared metadata
173                if let Some(declared) = ir.fact_tables.get(table_name) {
174                    let comparison_issues =
175                        compare_metadata(table_name, declared, &actual_metadata);
176                    issues.extend(comparison_issues);
177                }
178                validated_count += 1;
179            },
180            Err(e) => {
181                issues.push(ValidationIssue::error(
182                    table_name.clone(),
183                    format!("Failed to introspect: {e}"),
184                ));
185            },
186        }
187    }
188
189    // 4. Check for undeclared tables in database
190    for table_name in &actual_tables {
191        if !declared_tables.contains(table_name) {
192            issues.push(ValidationIssue::warning(
193                table_name.clone(),
194                "Table exists in database but not declared in schema".to_string(),
195            ));
196        }
197    }
198
199    // 5. Report results
200    formatter.progress("");
201    let errors: Vec<&ValidationIssue> =
202        issues.iter().filter(|i| i.severity == IssueSeverity::Error).collect();
203    let warnings: Vec<&ValidationIssue> =
204        issues.iter().filter(|i| i.severity == IssueSeverity::Warning).collect();
205
206    if !errors.is_empty() {
207        formatter.progress(&format!("err: Errors ({}):", errors.len()));
208        for issue in &errors {
209            formatter.progress(&format!("   {} - {}", issue.table_name, issue.message));
210        }
211        formatter.progress("");
212    }
213
214    if !warnings.is_empty() {
215        formatter.progress(&format!("warn: Warnings ({}):", warnings.len()));
216        for issue in &warnings {
217            formatter.progress(&format!("   {} - {}", issue.table_name, issue.message));
218        }
219        formatter.progress("");
220    }
221
222    if errors.is_empty() {
223        formatter.progress("ok: Validation passed");
224        formatter.progress(&format!("   {validated_count} table(s) validated successfully"));
225        if !warnings.is_empty() {
226            formatter.progress(&format!("   {} warning(s)", warnings.len()));
227        }
228        Ok(())
229    } else {
230        Err(anyhow::anyhow!("Validation failed with {} error(s)", errors.len()))
231    }
232}
233
234/// Compare declared metadata with actual database metadata
235fn compare_metadata(
236    table_name: &str,
237    declared: &FactTableMetadata,
238    actual: &FactTableMetadata,
239) -> Vec<ValidationIssue> {
240    let mut issues = Vec::new();
241
242    let declared_measure_names: HashSet<&str> =
243        declared.measures.iter().map(|m| m.name.as_str()).collect();
244    let actual_measure_names: HashSet<&str> =
245        actual.measures.iter().map(|m| m.name.as_str()).collect();
246
247    // Check for missing measures in actual
248    for name in &declared_measure_names {
249        if !actual_measure_names.contains(name) {
250            issues.push(ValidationIssue::error(
251                table_name.to_string(),
252                format!("Declared measure '{name}' not found in database"),
253            ));
254        }
255    }
256
257    // Check for extra measures in actual (warning)
258    for name in &actual_measure_names {
259        if !declared_measure_names.contains(name) {
260            issues.push(ValidationIssue::warning(
261                table_name.to_string(),
262                format!("Database has measure '{name}' not declared in schema"),
263            ));
264        }
265    }
266
267    // Validate measure types
268    for declared_measure in &declared.measures {
269        if let Some(actual_measure) =
270            actual.measures.iter().find(|m| m.name == declared_measure.name)
271        {
272            let declared_type = format!("{:?}", declared_measure.sql_type);
273            let actual_type = format!("{:?}", actual_measure.sql_type);
274            if declared_type != actual_type {
275                issues.push(ValidationIssue::warning(
276                    table_name.to_string(),
277                    format!(
278                        "Measure '{}' type mismatch: declared '{declared_type}', actual \
279                         '{actual_type}'",
280                        declared_measure.name
281                    ),
282                ));
283            }
284        }
285    }
286
287    // Validate dimensions column
288    if declared.dimensions.name != actual.dimensions.name {
289        issues.push(ValidationIssue::error(
290            table_name.to_string(),
291            format!(
292                "Dimensions column mismatch: declared '{}', actual '{}'",
293                declared.dimensions.name, actual.dimensions.name
294            ),
295        ));
296    }
297
298    // Validate denormalized filters
299    let declared_filter_names: HashSet<&str> =
300        declared.denormalized_filters.iter().map(|f| f.name.as_str()).collect();
301    let actual_filter_names: HashSet<&str> =
302        actual.denormalized_filters.iter().map(|f| f.name.as_str()).collect();
303
304    for name in &declared_filter_names {
305        if !actual_filter_names.contains(name) {
306            issues.push(ValidationIssue::warning(
307                table_name.to_string(),
308                format!("Declared filter '{name}' not found in database"),
309            ));
310        }
311    }
312
313    issues
314}
315
316#[cfg(test)]
317mod tests {
318    use fraiseql_core::compiler::fact_table::{
319        DimensionColumn, FactTableMetadata, FilterColumn, MeasureColumn, SqlType,
320    };
321
322    use super::*;
323
324    #[test]
325    fn test_validation_issue_error() {
326        let issue = ValidationIssue::error("tf_sales".to_string(), "Table not found".to_string());
327        assert_eq!(issue.severity, IssueSeverity::Error);
328        assert_eq!(issue.table_name, "tf_sales");
329    }
330
331    #[test]
332    fn test_validation_issue_warning() {
333        let issue = ValidationIssue::warning(
334            "tf_orders".to_string(),
335            "Table exists but not declared".to_string(),
336        );
337        assert_eq!(issue.severity, IssueSeverity::Warning);
338    }
339
340    fn make_metadata(
341        measures: Vec<MeasureColumn>,
342        dim_name: &str,
343        filters: Vec<FilterColumn>,
344    ) -> FactTableMetadata {
345        FactTableMetadata {
346            table_name: "tf_sales".to_string(),
347            measures,
348            dimensions: DimensionColumn {
349                name:  dim_name.to_string(),
350                paths: vec![],
351            },
352            denormalized_filters: filters,
353            calendar_dimensions: vec![],
354        }
355    }
356
357    #[test]
358    fn test_compare_metadata_matching() {
359        let declared = make_metadata(
360            vec![
361                MeasureColumn {
362                    name:     "revenue".to_string(),
363                    sql_type: SqlType::Decimal,
364                    nullable: false,
365                },
366                MeasureColumn {
367                    name:     "quantity".to_string(),
368                    sql_type: SqlType::Int,
369                    nullable: false,
370                },
371            ],
372            "data",
373            vec![FilterColumn {
374                name:     "customer_id".to_string(),
375                sql_type: SqlType::Uuid,
376                indexed:  true,
377            }],
378        );
379        let actual = declared.clone();
380
381        let issues = compare_metadata("tf_sales", &declared, &actual);
382        let errors: Vec<_> = issues.iter().filter(|i| i.severity == IssueSeverity::Error).collect();
383        assert!(errors.is_empty(), "Unexpected errors: {errors:?}");
384    }
385
386    #[test]
387    fn test_compare_metadata_missing_measure() {
388        let declared = make_metadata(
389            vec![
390                MeasureColumn {
391                    name:     "revenue".to_string(),
392                    sql_type: SqlType::Decimal,
393                    nullable: false,
394                },
395                MeasureColumn {
396                    name:     "profit".to_string(),
397                    sql_type: SqlType::Decimal,
398                    nullable: false,
399                },
400            ],
401            "data",
402            vec![],
403        );
404        let actual = make_metadata(
405            vec![MeasureColumn {
406                name:     "revenue".to_string(),
407                sql_type: SqlType::Decimal,
408                nullable: false,
409            }],
410            "data",
411            vec![],
412        );
413
414        let issues = compare_metadata("tf_sales", &declared, &actual);
415        let errors: Vec<_> = issues.iter().filter(|i| i.severity == IssueSeverity::Error).collect();
416        assert_eq!(errors.len(), 1);
417        assert!(errors[0].message.contains("profit"));
418    }
419}