fraiseql_cli/commands/
validate_facts.rs1use std::{collections::HashSet, fs, path::Path};
11
12use anyhow::Result;
13use fraiseql_core::{
14 compiler::{
15 fact_table::{DatabaseIntrospector, FactTableDetector, FactTableMetadata},
16 ir::AuthoringIR,
17 parser::SchemaParser,
18 },
19 db::PostgresIntrospector,
20};
21
22use crate::output::OutputFormatter;
23
24#[derive(Debug)]
26pub struct ValidationIssue {
27 pub severity: IssueSeverity,
29 pub table_name: String,
31 pub message: String,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37#[non_exhaustive]
38pub enum IssueSeverity {
39 Error,
41 Warning,
43}
44
45impl ValidationIssue {
46 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 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
65async 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
76pub 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 let schema_str = fs::read_to_string(schema_path)?;
106
107 let parser = SchemaParser::new();
108 let ir: AuthoringIR = parser.parse(&schema_str)?;
109
110 let declared_tables: HashSet<String> = ir.fact_tables.keys().cloned().collect();
111
112 formatter
113 .progress(&format!("Found {} declared fact table(s) in schema", declared_tables.len()));
114
115 if declared_tables.is_empty() {
116 formatter.progress(" No fact tables declared - nothing to validate");
117 formatter.progress("");
118 formatter.progress("Tip: Use 'fraiseql introspect facts' to discover fact tables");
119 return Ok(());
120 }
121
122 for table_name in &declared_tables {
123 formatter.progress(&format!(" - {table_name}"));
124 }
125 formatter.progress("");
126
127 let introspector = create_introspector(database_url).await?;
129
130 let actual_tables: HashSet<String> = introspector
131 .list_fact_tables()
132 .await
133 .map_err(|e| anyhow::anyhow!("Failed to list fact tables: {e}"))?
134 .into_iter()
135 .collect();
136
137 formatter.progress(&format!("Found {} fact table(s) in database", actual_tables.len()));
138 formatter.progress("");
139
140 let mut issues: Vec<ValidationIssue> = Vec::new();
142 let mut validated_count = 0;
143
144 for table_name in &declared_tables {
145 formatter.progress(&format!(" Validating {table_name}..."));
146
147 if !actual_tables.contains(table_name) {
149 issues.push(ValidationIssue::error(
150 table_name.clone(),
151 "Table does not exist in database".to_string(),
152 ));
153 continue;
154 }
155
156 match FactTableDetector::introspect(&introspector, table_name).await {
158 Ok(actual_metadata) => {
159 if let Some(declared) = ir.fact_tables.get(table_name) {
161 let comparison_issues =
162 compare_metadata(table_name, declared, &actual_metadata);
163 issues.extend(comparison_issues);
164 }
165 validated_count += 1;
166 },
167 Err(e) => {
168 issues.push(ValidationIssue::error(
169 table_name.clone(),
170 format!("Failed to introspect: {e}"),
171 ));
172 },
173 }
174 }
175
176 for table_name in &actual_tables {
178 if !declared_tables.contains(table_name) {
179 issues.push(ValidationIssue::warning(
180 table_name.clone(),
181 "Table exists in database but not declared in schema".to_string(),
182 ));
183 }
184 }
185
186 formatter.progress("");
188 let errors: Vec<&ValidationIssue> =
189 issues.iter().filter(|i| i.severity == IssueSeverity::Error).collect();
190 let warnings: Vec<&ValidationIssue> =
191 issues.iter().filter(|i| i.severity == IssueSeverity::Warning).collect();
192
193 if !errors.is_empty() {
194 formatter.progress(&format!("err: Errors ({}):", errors.len()));
195 for issue in &errors {
196 formatter.progress(&format!(" {} - {}", issue.table_name, issue.message));
197 }
198 formatter.progress("");
199 }
200
201 if !warnings.is_empty() {
202 formatter.progress(&format!("warn: Warnings ({}):", warnings.len()));
203 for issue in &warnings {
204 formatter.progress(&format!(" {} - {}", issue.table_name, issue.message));
205 }
206 formatter.progress("");
207 }
208
209 if errors.is_empty() {
210 formatter.progress("ok: Validation passed");
211 formatter.progress(&format!(" {validated_count} table(s) validated successfully"));
212 if !warnings.is_empty() {
213 formatter.progress(&format!(" {} warning(s)", warnings.len()));
214 }
215 Ok(())
216 } else {
217 Err(anyhow::anyhow!("Validation failed with {} error(s)", errors.len()))
218 }
219}
220
221pub(crate) fn compare_metadata(
223 table_name: &str,
224 declared: &FactTableMetadata,
225 actual: &FactTableMetadata,
226) -> Vec<ValidationIssue> {
227 let mut issues = Vec::new();
228
229 let declared_measure_names: HashSet<&str> =
230 declared.measures.iter().map(|m| m.name.as_str()).collect();
231 let actual_measure_names: HashSet<&str> =
232 actual.measures.iter().map(|m| m.name.as_str()).collect();
233
234 for name in &declared_measure_names {
236 if !actual_measure_names.contains(name) {
237 issues.push(ValidationIssue::error(
238 table_name.to_string(),
239 format!("Declared measure '{name}' not found in database"),
240 ));
241 }
242 }
243
244 for name in &actual_measure_names {
246 if !declared_measure_names.contains(name) {
247 issues.push(ValidationIssue::warning(
248 table_name.to_string(),
249 format!("Database has measure '{name}' not declared in schema"),
250 ));
251 }
252 }
253
254 for declared_measure in &declared.measures {
256 if let Some(actual_measure) =
257 actual.measures.iter().find(|m| m.name == declared_measure.name)
258 {
259 let declared_type = format!("{:?}", declared_measure.sql_type);
260 let actual_type = format!("{:?}", actual_measure.sql_type);
261 if declared_type != actual_type {
262 issues.push(ValidationIssue::warning(
263 table_name.to_string(),
264 format!(
265 "Measure '{}' type mismatch: declared '{declared_type}', actual \
266 '{actual_type}'",
267 declared_measure.name
268 ),
269 ));
270 }
271 }
272 }
273
274 if declared.dimensions.name != actual.dimensions.name {
276 issues.push(ValidationIssue::error(
277 table_name.to_string(),
278 format!(
279 "Dimensions column mismatch: declared '{}', actual '{}'",
280 declared.dimensions.name, actual.dimensions.name
281 ),
282 ));
283 }
284
285 let declared_filter_names: HashSet<&str> =
287 declared.denormalized_filters.iter().map(|f| f.name.as_str()).collect();
288 let actual_filter_names: HashSet<&str> =
289 actual.denormalized_filters.iter().map(|f| f.name.as_str()).collect();
290
291 for name in &declared_filter_names {
292 if !actual_filter_names.contains(name) {
293 issues.push(ValidationIssue::warning(
294 table_name.to_string(),
295 format!("Declared filter '{name}' not found in database"),
296 ));
297 }
298 }
299
300 issues
301}