1use 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#[derive(Debug)]
28pub struct ValidationIssue {
29 pub severity: IssueSeverity,
31 pub table_name: String,
33 pub message: String,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39#[non_exhaustive]
40pub enum IssueSeverity {
41 Error,
43 Warning,
45}
46
47impl ValidationIssue {
48 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 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
67async 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 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
89pub 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 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 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 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 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 match FactTableDetector::introspect(&introspector, table_name).await {
171 Ok(actual_metadata) => {
172 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 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 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
234fn 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 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 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 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 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 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}