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 fraiseql_core::{
14    compiler::{
15        fact_table::{DatabaseIntrospector, FactTableDetector, FactTableMetadata},
16        ir::AuthoringIR,
17        parser::SchemaParser,
18    },
19    db::PostgresIntrospector,
20};
21
22use crate::output::OutputFormatter;
23
24/// Validation error type.
25#[derive(Debug)]
26pub struct ValidationIssue {
27    /// Issue type (error or warning)
28    pub severity:   IssueSeverity,
29    /// Fact table name
30    pub table_name: String,
31    /// Issue description
32    pub message:    String,
33}
34
35/// Issue severity level.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37#[non_exhaustive]
38pub enum IssueSeverity {
39    /// Critical error - validation fails
40    Error,
41    /// Warning - validation passes with warnings
42    Warning,
43}
44
45impl ValidationIssue {
46    /// Create a new error issue.
47    pub const fn error(table_name: String, message: String) -> Self {
48        Self {
49            severity: IssueSeverity::Error,
50            table_name,
51            message,
52        }
53    }
54
55    /// Create a new warning issue.
56    pub const fn warning(table_name: String, message: String) -> Self {
57        Self {
58            severity: IssueSeverity::Warning,
59            table_name,
60            message,
61        }
62    }
63}
64
65/// Create a PostgreSQL introspector from a database URL
66async fn create_introspector(database_url: &str) -> Result<PostgresIntrospector> {
67    let pool = crate::connection::postgres_pool(
68        database_url,
69        "fact-table validation",
70        &fraiseql_db::postgres::PostgresTlsConfig::default(),
71    )
72    .await?;
73    Ok(PostgresIntrospector::new(pool))
74}
75
76/// Validate that declared fact tables match database schema.
77///
78/// # Arguments
79///
80/// * `schema_path` - Path to schema.json file
81/// * `database_url` - Database connection string
82/// * `formatter` - Output formatter controlling verbosity and format
83///
84/// # Errors
85///
86/// Returns an error if the schema file cannot be read, the database connection fails,
87/// or any fact table validation check fails.
88///
89/// # Example
90///
91/// ```bash
92/// fraiseql validate facts --schema schema.json --database postgresql://localhost/mydb
93/// ```
94pub async fn run(
95    schema_path: &Path,
96    database_url: &str,
97    formatter: &OutputFormatter,
98) -> Result<()> {
99    formatter.section("Validating fact tables");
100    formatter.progress(&format!("   Schema: {}", schema_path.display()));
101    formatter.progress(&format!("   Database: {database_url}"));
102    formatter.progress("");
103
104    // 1. Load and parse schema
105    let schema_str = fs::read_to_string(schema_path)?;
106
107    let parser = SchemaParser::new();
108    let ir: AuthoringIR = parser.parse(&schema_str)?;
109
110    let declared_tables: HashSet<String> = ir.fact_tables.keys().cloned().collect();
111
112    formatter
113        .progress(&format!("Found {} declared fact table(s) in schema", declared_tables.len()));
114
115    if declared_tables.is_empty() {
116        formatter.progress("   No fact tables declared - nothing to validate");
117        formatter.progress("");
118        formatter.progress("Tip: Use 'fraiseql introspect facts' to discover fact tables");
119        return Ok(());
120    }
121
122    for table_name in &declared_tables {
123        formatter.progress(&format!("   - {table_name}"));
124    }
125    formatter.progress("");
126
127    // 2. Connect to database and list actual fact tables
128    let introspector = create_introspector(database_url).await?;
129
130    let actual_tables: HashSet<String> = introspector
131        .list_fact_tables()
132        .await
133        .map_err(|e| anyhow::anyhow!("Failed to list fact tables: {e}"))?
134        .into_iter()
135        .collect();
136
137    formatter.progress(&format!("Found {} fact table(s) in database", actual_tables.len()));
138    formatter.progress("");
139
140    // 3. Validate each declared table
141    let mut issues: Vec<ValidationIssue> = Vec::new();
142    let mut validated_count = 0;
143
144    for table_name in &declared_tables {
145        formatter.progress(&format!("   Validating {table_name}..."));
146
147        // Check if table exists in database
148        if !actual_tables.contains(table_name) {
149            issues.push(ValidationIssue::error(
150                table_name.clone(),
151                "Table does not exist in database".to_string(),
152            ));
153            continue;
154        }
155
156        // Introspect actual table structure
157        match FactTableDetector::introspect(&introspector, table_name).await {
158            Ok(actual_metadata) => {
159                // Compare structures against declared metadata
160                if let Some(declared) = ir.fact_tables.get(table_name) {
161                    let comparison_issues =
162                        compare_metadata(table_name, declared, &actual_metadata);
163                    issues.extend(comparison_issues);
164                }
165                validated_count += 1;
166            },
167            Err(e) => {
168                issues.push(ValidationIssue::error(
169                    table_name.clone(),
170                    format!("Failed to introspect: {e}"),
171                ));
172            },
173        }
174    }
175
176    // 4. Check for undeclared tables in database
177    for table_name in &actual_tables {
178        if !declared_tables.contains(table_name) {
179            issues.push(ValidationIssue::warning(
180                table_name.clone(),
181                "Table exists in database but not declared in schema".to_string(),
182            ));
183        }
184    }
185
186    // 5. Report results
187    formatter.progress("");
188    let errors: Vec<&ValidationIssue> =
189        issues.iter().filter(|i| i.severity == IssueSeverity::Error).collect();
190    let warnings: Vec<&ValidationIssue> =
191        issues.iter().filter(|i| i.severity == IssueSeverity::Warning).collect();
192
193    if !errors.is_empty() {
194        formatter.progress(&format!("err: Errors ({}):", errors.len()));
195        for issue in &errors {
196            formatter.progress(&format!("   {} - {}", issue.table_name, issue.message));
197        }
198        formatter.progress("");
199    }
200
201    if !warnings.is_empty() {
202        formatter.progress(&format!("warn: Warnings ({}):", warnings.len()));
203        for issue in &warnings {
204            formatter.progress(&format!("   {} - {}", issue.table_name, issue.message));
205        }
206        formatter.progress("");
207    }
208
209    if errors.is_empty() {
210        formatter.progress("ok: Validation passed");
211        formatter.progress(&format!("   {validated_count} table(s) validated successfully"));
212        if !warnings.is_empty() {
213            formatter.progress(&format!("   {} warning(s)", warnings.len()));
214        }
215        Ok(())
216    } else {
217        Err(anyhow::anyhow!("Validation failed with {} error(s)", errors.len()))
218    }
219}
220
221/// Compare declared metadata with actual database metadata
222pub(crate) fn compare_metadata(
223    table_name: &str,
224    declared: &FactTableMetadata,
225    actual: &FactTableMetadata,
226) -> Vec<ValidationIssue> {
227    let mut issues = Vec::new();
228
229    let declared_measure_names: HashSet<&str> =
230        declared.measures.iter().map(|m| m.name.as_str()).collect();
231    let actual_measure_names: HashSet<&str> =
232        actual.measures.iter().map(|m| m.name.as_str()).collect();
233
234    // Check for missing measures in actual
235    for name in &declared_measure_names {
236        if !actual_measure_names.contains(name) {
237            issues.push(ValidationIssue::error(
238                table_name.to_string(),
239                format!("Declared measure '{name}' not found in database"),
240            ));
241        }
242    }
243
244    // Check for extra measures in actual (warning)
245    for name in &actual_measure_names {
246        if !declared_measure_names.contains(name) {
247            issues.push(ValidationIssue::warning(
248                table_name.to_string(),
249                format!("Database has measure '{name}' not declared in schema"),
250            ));
251        }
252    }
253
254    // Validate measure types
255    for declared_measure in &declared.measures {
256        if let Some(actual_measure) =
257            actual.measures.iter().find(|m| m.name == declared_measure.name)
258        {
259            let declared_type = format!("{:?}", declared_measure.sql_type);
260            let actual_type = format!("{:?}", actual_measure.sql_type);
261            if declared_type != actual_type {
262                issues.push(ValidationIssue::warning(
263                    table_name.to_string(),
264                    format!(
265                        "Measure '{}' type mismatch: declared '{declared_type}', actual \
266                         '{actual_type}'",
267                        declared_measure.name
268                    ),
269                ));
270            }
271        }
272    }
273
274    // Validate dimensions column
275    if declared.dimensions.name != actual.dimensions.name {
276        issues.push(ValidationIssue::error(
277            table_name.to_string(),
278            format!(
279                "Dimensions column mismatch: declared '{}', actual '{}'",
280                declared.dimensions.name, actual.dimensions.name
281            ),
282        ));
283    }
284
285    // Validate denormalized filters
286    let declared_filter_names: HashSet<&str> =
287        declared.denormalized_filters.iter().map(|f| f.name.as_str()).collect();
288    let actual_filter_names: HashSet<&str> =
289        actual.denormalized_filters.iter().map(|f| f.name.as_str()).collect();
290
291    for name in &declared_filter_names {
292        if !actual_filter_names.contains(name) {
293            issues.push(ValidationIssue::warning(
294                table_name.to_string(),
295                format!("Declared filter '{name}' not found in database"),
296            ));
297        }
298    }
299
300    issues
301}