fraiseql_cli/commands/
validate_facts.rs1use 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
234pub(crate) fn 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}