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;
#[derive(Debug)]
pub struct ValidationIssue {
pub severity: IssueSeverity,
pub table_name: String,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum IssueSeverity {
Error,
Warning,
}
impl ValidationIssue {
pub const fn error(table_name: String, message: String) -> Self {
Self {
severity: IssueSeverity::Error,
table_name,
message,
}
}
pub const fn warning(table_name: String, message: String) -> Self {
Self {
severity: IssueSeverity::Warning,
table_name,
message,
}
}
}
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}"))?;
let _client = pool
.get()
.await
.map_err(|e| anyhow::anyhow!("Failed to connect to database: {e}"))?;
Ok(PostgresIntrospector::new(pool))
}
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("");
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("");
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("");
let mut issues: Vec<ValidationIssue> = Vec::new();
let mut validated_count = 0;
for table_name in &declared_tables {
formatter.progress(&format!(" Validating {table_name}..."));
if !actual_tables.contains(table_name) {
issues.push(ValidationIssue::error(
table_name.clone(),
"Table does not exist in database".to_string(),
));
continue;
}
match FactTableDetector::introspect(&introspector, table_name).await {
Ok(actual_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}"),
));
},
}
}
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(),
));
}
}
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()))
}
}
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();
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"),
));
}
}
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"),
));
}
}
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
),
));
}
}
}
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
),
));
}
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
}