fraiseql-cli 2.3.2

CLI tools for FraiseQL v2 - Schema compilation and development utilities
Documentation
//! Validate that declared fact tables match database schema.
//!
//! This command checks:
//! - Declared fact tables exist in database
//! - Metadata structure matches actual database schema
//! - Warns about undeclared tf_* tables
//!
//! **Purpose**: CI/CD validation step to catch schema drift.

use std::{collections::HashSet, fs, path::Path};

use anyhow::Result;
use deadpool_postgres::{Config, ManagerConfig, RecyclingMethod, Runtime};
use fraiseql_core::{
    compiler::{
        fact_table::{DatabaseIntrospector, FactTableDetector, FactTableMetadata},
        ir::AuthoringIR,
        parser::SchemaParser,
    },
    db::PostgresIntrospector,
};
use tokio_postgres::NoTls;

use crate::output::OutputFormatter;

/// Validation error type.
#[derive(Debug)]
pub struct ValidationIssue {
    /// Issue type (error or warning)
    pub severity:   IssueSeverity,
    /// Fact table name
    pub table_name: String,
    /// Issue description
    pub message:    String,
}

/// Issue severity level.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum IssueSeverity {
    /// Critical error - validation fails
    Error,
    /// Warning - validation passes with warnings
    Warning,
}

impl ValidationIssue {
    /// Create a new error issue.
    pub const fn error(table_name: String, message: String) -> Self {
        Self {
            severity: IssueSeverity::Error,
            table_name,
            message,
        }
    }

    /// Create a new warning issue.
    pub const fn warning(table_name: String, message: String) -> Self {
        Self {
            severity: IssueSeverity::Warning,
            table_name,
            message,
        }
    }
}

/// Create a PostgreSQL introspector from a database URL
async fn create_introspector(database_url: &str) -> Result<PostgresIntrospector> {
    let mut cfg = Config::new();
    cfg.url = Some(database_url.to_string());
    cfg.manager = Some(ManagerConfig {
        recycling_method: RecyclingMethod::Fast,
    });
    cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));

    let pool = cfg
        .create_pool(Some(Runtime::Tokio1), NoTls)
        .map_err(|e| anyhow::anyhow!("Failed to create database pool: {e}"))?;

    // Test connection
    let _client = pool
        .get()
        .await
        .map_err(|e| anyhow::anyhow!("Failed to connect to database: {e}"))?;

    Ok(PostgresIntrospector::new(pool))
}

/// Validate that declared fact tables match database schema.
///
/// # Arguments
///
/// * `schema_path` - Path to schema.json file
/// * `database_url` - Database connection string
/// * `formatter` - Output formatter controlling verbosity and format
///
/// # Errors
///
/// Returns an error if the schema file cannot be read, the database connection fails,
/// or any fact table validation check fails.
///
/// # Example
///
/// ```bash
/// fraiseql validate facts --schema schema.json --database postgresql://localhost/mydb
/// ```
pub async fn run(
    schema_path: &Path,
    database_url: &str,
    formatter: &OutputFormatter,
) -> Result<()> {
    formatter.section("Validating fact tables");
    formatter.progress(&format!("   Schema: {}", schema_path.display()));
    formatter.progress(&format!("   Database: {database_url}"));
    formatter.progress("");

    // 1. Load and parse schema
    let schema_str = fs::read_to_string(schema_path)?;

    let parser = SchemaParser::new();
    let ir: AuthoringIR = parser.parse(&schema_str)?;

    let declared_tables: HashSet<String> = ir.fact_tables.keys().cloned().collect();

    formatter
        .progress(&format!("Found {} declared fact table(s) in schema", declared_tables.len()));

    if declared_tables.is_empty() {
        formatter.progress("   No fact tables declared - nothing to validate");
        formatter.progress("");
        formatter.progress("Tip: Use 'fraiseql introspect facts' to discover fact tables");
        return Ok(());
    }

    for table_name in &declared_tables {
        formatter.progress(&format!("   - {table_name}"));
    }
    formatter.progress("");

    // 2. Connect to database and list actual fact tables
    let introspector = create_introspector(database_url).await?;

    let actual_tables: HashSet<String> = introspector
        .list_fact_tables()
        .await
        .map_err(|e| anyhow::anyhow!("Failed to list fact tables: {e}"))?
        .into_iter()
        .collect();

    formatter.progress(&format!("Found {} fact table(s) in database", actual_tables.len()));
    formatter.progress("");

    // 3. Validate each declared table
    let mut issues: Vec<ValidationIssue> = Vec::new();
    let mut validated_count = 0;

    for table_name in &declared_tables {
        formatter.progress(&format!("   Validating {table_name}..."));

        // Check if table exists in database
        if !actual_tables.contains(table_name) {
            issues.push(ValidationIssue::error(
                table_name.clone(),
                "Table does not exist in database".to_string(),
            ));
            continue;
        }

        // Introspect actual table structure
        match FactTableDetector::introspect(&introspector, table_name).await {
            Ok(actual_metadata) => {
                // Compare structures against declared metadata
                if let Some(declared) = ir.fact_tables.get(table_name) {
                    let comparison_issues =
                        compare_metadata(table_name, declared, &actual_metadata);
                    issues.extend(comparison_issues);
                }
                validated_count += 1;
            },
            Err(e) => {
                issues.push(ValidationIssue::error(
                    table_name.clone(),
                    format!("Failed to introspect: {e}"),
                ));
            },
        }
    }

    // 4. Check for undeclared tables in database
    for table_name in &actual_tables {
        if !declared_tables.contains(table_name) {
            issues.push(ValidationIssue::warning(
                table_name.clone(),
                "Table exists in database but not declared in schema".to_string(),
            ));
        }
    }

    // 5. Report results
    formatter.progress("");
    let errors: Vec<&ValidationIssue> =
        issues.iter().filter(|i| i.severity == IssueSeverity::Error).collect();
    let warnings: Vec<&ValidationIssue> =
        issues.iter().filter(|i| i.severity == IssueSeverity::Warning).collect();

    if !errors.is_empty() {
        formatter.progress(&format!("err: Errors ({}):", errors.len()));
        for issue in &errors {
            formatter.progress(&format!("   {} - {}", issue.table_name, issue.message));
        }
        formatter.progress("");
    }

    if !warnings.is_empty() {
        formatter.progress(&format!("warn: Warnings ({}):", warnings.len()));
        for issue in &warnings {
            formatter.progress(&format!("   {} - {}", issue.table_name, issue.message));
        }
        formatter.progress("");
    }

    if errors.is_empty() {
        formatter.progress("ok: Validation passed");
        formatter.progress(&format!("   {validated_count} table(s) validated successfully"));
        if !warnings.is_empty() {
            formatter.progress(&format!("   {} warning(s)", warnings.len()));
        }
        Ok(())
    } else {
        Err(anyhow::anyhow!("Validation failed with {} error(s)", errors.len()))
    }
}

/// Compare declared metadata with actual database metadata
pub(crate) fn compare_metadata(
    table_name: &str,
    declared: &FactTableMetadata,
    actual: &FactTableMetadata,
) -> Vec<ValidationIssue> {
    let mut issues = Vec::new();

    let declared_measure_names: HashSet<&str> =
        declared.measures.iter().map(|m| m.name.as_str()).collect();
    let actual_measure_names: HashSet<&str> =
        actual.measures.iter().map(|m| m.name.as_str()).collect();

    // Check for missing measures in actual
    for name in &declared_measure_names {
        if !actual_measure_names.contains(name) {
            issues.push(ValidationIssue::error(
                table_name.to_string(),
                format!("Declared measure '{name}' not found in database"),
            ));
        }
    }

    // Check for extra measures in actual (warning)
    for name in &actual_measure_names {
        if !declared_measure_names.contains(name) {
            issues.push(ValidationIssue::warning(
                table_name.to_string(),
                format!("Database has measure '{name}' not declared in schema"),
            ));
        }
    }

    // Validate measure types
    for declared_measure in &declared.measures {
        if let Some(actual_measure) =
            actual.measures.iter().find(|m| m.name == declared_measure.name)
        {
            let declared_type = format!("{:?}", declared_measure.sql_type);
            let actual_type = format!("{:?}", actual_measure.sql_type);
            if declared_type != actual_type {
                issues.push(ValidationIssue::warning(
                    table_name.to_string(),
                    format!(
                        "Measure '{}' type mismatch: declared '{declared_type}', actual \
                         '{actual_type}'",
                        declared_measure.name
                    ),
                ));
            }
        }
    }

    // Validate dimensions column
    if declared.dimensions.name != actual.dimensions.name {
        issues.push(ValidationIssue::error(
            table_name.to_string(),
            format!(
                "Dimensions column mismatch: declared '{}', actual '{}'",
                declared.dimensions.name, actual.dimensions.name
            ),
        ));
    }

    // Validate denormalized filters
    let declared_filter_names: HashSet<&str> =
        declared.denormalized_filters.iter().map(|f| f.name.as_str()).collect();
    let actual_filter_names: HashSet<&str> =
        actual.denormalized_filters.iter().map(|f| f.name.as_str()).collect();

    for name in &declared_filter_names {
        if !actual_filter_names.contains(name) {
            issues.push(ValidationIssue::warning(
                table_name.to_string(),
                format!("Declared filter '{name}' not found in database"),
            ));
        }
    }

    issues
}