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::fact_table::{DatabaseIntrospector, FactTableDetector, FactTableMetadata},
15    db::PostgresIntrospector,
16    schema::CompiledSchema,
17};
18
19use crate::{
20    output::OutputFormatter,
21    schema::{IntermediateSchema, SchemaConverter},
22};
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 declared_metadata = declared_fact_tables(&schema_str)?;
108
109    let declared_tables: HashSet<String> = declared_metadata.keys().cloned().collect();
110
111    formatter
112        .progress(&format!("Found {} declared fact table(s) in schema", declared_tables.len()));
113
114    if declared_tables.is_empty() {
115        formatter.progress("   No fact tables declared - nothing to validate");
116        formatter.progress("");
117        formatter.progress("Tip: Use 'fraiseql introspect facts' to discover fact tables");
118        return Ok(());
119    }
120
121    for table_name in &declared_tables {
122        formatter.progress(&format!("   - {table_name}"));
123    }
124    formatter.progress("");
125
126    // 2. Connect to database and list actual fact tables
127    let introspector = create_introspector(database_url).await?;
128
129    let actual_tables: HashSet<String> = introspector
130        .list_fact_tables()
131        .await
132        .map_err(|e| anyhow::anyhow!("Failed to list fact tables: {e}"))?
133        .into_iter()
134        .collect();
135
136    formatter.progress(&format!("Found {} fact table(s) in database", actual_tables.len()));
137    formatter.progress("");
138
139    // 3. Validate each declared table
140    let mut issues: Vec<ValidationIssue> = Vec::new();
141    let mut validated_count = 0;
142
143    for table_name in &declared_tables {
144        formatter.progress(&format!("   Validating {table_name}..."));
145
146        // Check if table exists in database
147        if !actual_tables.contains(table_name) {
148            issues.push(ValidationIssue::error(
149                table_name.clone(),
150                "Table does not exist in database".to_string(),
151            ));
152            continue;
153        }
154
155        // Introspect actual table structure
156        match FactTableDetector::introspect(&introspector, table_name).await {
157            Ok(actual_metadata) => {
158                // Compare structures against declared metadata
159                if let Some(declared) = declared_metadata.get(table_name) {
160                    let comparison_issues =
161                        compare_metadata(table_name, declared, &actual_metadata);
162                    issues.extend(comparison_issues);
163                }
164                validated_count += 1;
165            },
166            Err(e) => {
167                issues.push(ValidationIssue::error(
168                    table_name.clone(),
169                    format!("Failed to introspect: {e}"),
170                ));
171            },
172        }
173    }
174
175    // 4. Check for undeclared tables in database
176    for table_name in &actual_tables {
177        if !declared_tables.contains(table_name) {
178            issues.push(ValidationIssue::warning(
179                table_name.clone(),
180                "Table exists in database but not declared in schema".to_string(),
181            ));
182        }
183    }
184
185    // 5. Report results
186    formatter.progress("");
187    let errors: Vec<&ValidationIssue> =
188        issues.iter().filter(|i| i.severity == IssueSeverity::Error).collect();
189    let warnings: Vec<&ValidationIssue> =
190        issues.iter().filter(|i| i.severity == IssueSeverity::Warning).collect();
191
192    if !errors.is_empty() {
193        formatter.progress(&format!("err: Errors ({}):", errors.len()));
194        for issue in &errors {
195            formatter.progress(&format!("   {} - {}", issue.table_name, issue.message));
196        }
197        formatter.progress("");
198    }
199
200    if !warnings.is_empty() {
201        formatter.progress(&format!("warn: Warnings ({}):", warnings.len()));
202        for issue in &warnings {
203            formatter.progress(&format!("   {} - {}", issue.table_name, issue.message));
204        }
205        formatter.progress("");
206    }
207
208    if errors.is_empty() {
209        formatter.progress("ok: Validation passed");
210        formatter.progress(&format!("   {validated_count} table(s) validated successfully"));
211        if !warnings.is_empty() {
212            formatter.progress(&format!("   {} warning(s)", warnings.len()));
213        }
214        Ok(())
215    } else {
216        Err(anyhow::anyhow!("Validation failed with {} error(s)", errors.len()))
217    }
218}
219
220/// Compare declared metadata with actual database metadata
221pub(crate) fn compare_metadata(
222    table_name: &str,
223    declared: &FactTableMetadata,
224    actual: &FactTableMetadata,
225) -> Vec<ValidationIssue> {
226    let mut issues = Vec::new();
227
228    let declared_measure_names: HashSet<&str> =
229        declared.measures.iter().map(|m| m.name.as_str()).collect();
230    let actual_measure_names: HashSet<&str> =
231        actual.measures.iter().map(|m| m.name.as_str()).collect();
232
233    // Check for missing measures in actual
234    for name in &declared_measure_names {
235        if !actual_measure_names.contains(name) {
236            issues.push(ValidationIssue::error(
237                table_name.to_string(),
238                format!("Declared measure '{name}' not found in database"),
239            ));
240        }
241    }
242
243    // Check for extra measures in actual (warning)
244    for name in &actual_measure_names {
245        if !declared_measure_names.contains(name) {
246            issues.push(ValidationIssue::warning(
247                table_name.to_string(),
248                format!("Database has measure '{name}' not declared in schema"),
249            ));
250        }
251    }
252
253    // Validate measure types
254    for declared_measure in &declared.measures {
255        if let Some(actual_measure) =
256            actual.measures.iter().find(|m| m.name == declared_measure.name)
257        {
258            let declared_type = format!("{:?}", declared_measure.sql_type);
259            let actual_type = format!("{:?}", actual_measure.sql_type);
260            if declared_type != actual_type {
261                issues.push(ValidationIssue::warning(
262                    table_name.to_string(),
263                    format!(
264                        "Measure '{}' type mismatch: declared '{declared_type}', actual \
265                         '{actual_type}'",
266                        declared_measure.name
267                    ),
268                ));
269            }
270        }
271    }
272
273    // Validate dimensions column
274    if declared.dimensions.name != actual.dimensions.name {
275        issues.push(ValidationIssue::error(
276            table_name.to_string(),
277            format!(
278                "Dimensions column mismatch: declared '{}', actual '{}'",
279                declared.dimensions.name, actual.dimensions.name
280            ),
281        ));
282    }
283
284    // Validate denormalized filters
285    let declared_filter_names: HashSet<&str> =
286        declared.denormalized_filters.iter().map(|f| f.name.as_str()).collect();
287    let actual_filter_names: HashSet<&str> =
288        actual.denormalized_filters.iter().map(|f| f.name.as_str()).collect();
289
290    for name in &declared_filter_names {
291        if !actual_filter_names.contains(name) {
292            issues.push(ValidationIssue::warning(
293                table_name.to_string(),
294                format!("Declared filter '{name}' not found in database"),
295            ));
296        }
297    }
298
299    issues
300}
301
302/// The fact tables `schema_json` declares, by table name.
303///
304/// Reads an authoring `schema.json` (strict `IntermediateSchema`, whose `fact_tables` is an
305/// array) or a `schema.compiled.json` (whose `fact_tables` is a map). A file that is
306/// neither is an error naming both reasons, never "no fact tables".
307pub(crate) fn declared_fact_tables(
308    schema_json: &str,
309) -> Result<std::collections::HashMap<String, FactTableMetadata>> {
310    let authoring_err = match serde_json::from_str::<IntermediateSchema>(schema_json) {
311        Ok(authoring) => {
312            return Ok(authoring
313                .fact_tables
314                .unwrap_or_default()
315                .into_iter()
316                .map(|ft| (ft.table_name.clone(), SchemaConverter::convert_fact_table(ft)))
317                .collect());
318        },
319        Err(e) => e,
320    };
321    match serde_json::from_str::<CompiledSchema>(schema_json) {
322        Ok(compiled) => Ok(compiled.fact_tables),
323        Err(compiled_err) => Err(anyhow::anyhow!(
324            "the schema is neither an authoring schema.json ({authoring_err}) nor a \
325             schema.compiled.json ({compiled_err})"
326        )),
327    }
328}