fraiseql_cli/commands/
validate_facts.rs1use std::{collections::HashSet, fs, path::Path};
11
12use anyhow::Result;
13use fraiseql_core::{
14 compiler::fact_table::{DatabaseIntrospector, FactTableDetector, FactTableMetadata},
15 db::PostgresIntrospector,
16 schema::CompiledSchema,
17};
18
19use crate::{
20 output::OutputFormatter,
21 schema::{IntermediateSchema, SchemaConverter},
22};
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 declared_metadata = declared_fact_tables(&schema_str)?;
108
109 let declared_tables: HashSet<String> = declared_metadata.keys().cloned().collect();
110
111 formatter
112 .progress(&format!("Found {} declared fact table(s) in schema", declared_tables.len()));
113
114 if declared_tables.is_empty() {
115 formatter.progress(" No fact tables declared - nothing to validate");
116 formatter.progress("");
117 formatter.progress("Tip: Use 'fraiseql introspect facts' to discover fact tables");
118 return Ok(());
119 }
120
121 for table_name in &declared_tables {
122 formatter.progress(&format!(" - {table_name}"));
123 }
124 formatter.progress("");
125
126 let introspector = create_introspector(database_url).await?;
128
129 let actual_tables: HashSet<String> = introspector
130 .list_fact_tables()
131 .await
132 .map_err(|e| anyhow::anyhow!("Failed to list fact tables: {e}"))?
133 .into_iter()
134 .collect();
135
136 formatter.progress(&format!("Found {} fact table(s) in database", actual_tables.len()));
137 formatter.progress("");
138
139 let mut issues: Vec<ValidationIssue> = Vec::new();
141 let mut validated_count = 0;
142
143 for table_name in &declared_tables {
144 formatter.progress(&format!(" Validating {table_name}..."));
145
146 if !actual_tables.contains(table_name) {
148 issues.push(ValidationIssue::error(
149 table_name.clone(),
150 "Table does not exist in database".to_string(),
151 ));
152 continue;
153 }
154
155 match FactTableDetector::introspect(&introspector, table_name).await {
157 Ok(actual_metadata) => {
158 if let Some(declared) = declared_metadata.get(table_name) {
160 let comparison_issues =
161 compare_metadata(table_name, declared, &actual_metadata);
162 issues.extend(comparison_issues);
163 }
164 validated_count += 1;
165 },
166 Err(e) => {
167 issues.push(ValidationIssue::error(
168 table_name.clone(),
169 format!("Failed to introspect: {e}"),
170 ));
171 },
172 }
173 }
174
175 for table_name in &actual_tables {
177 if !declared_tables.contains(table_name) {
178 issues.push(ValidationIssue::warning(
179 table_name.clone(),
180 "Table exists in database but not declared in schema".to_string(),
181 ));
182 }
183 }
184
185 formatter.progress("");
187 let errors: Vec<&ValidationIssue> =
188 issues.iter().filter(|i| i.severity == IssueSeverity::Error).collect();
189 let warnings: Vec<&ValidationIssue> =
190 issues.iter().filter(|i| i.severity == IssueSeverity::Warning).collect();
191
192 if !errors.is_empty() {
193 formatter.progress(&format!("err: Errors ({}):", errors.len()));
194 for issue in &errors {
195 formatter.progress(&format!(" {} - {}", issue.table_name, issue.message));
196 }
197 formatter.progress("");
198 }
199
200 if !warnings.is_empty() {
201 formatter.progress(&format!("warn: Warnings ({}):", warnings.len()));
202 for issue in &warnings {
203 formatter.progress(&format!(" {} - {}", issue.table_name, issue.message));
204 }
205 formatter.progress("");
206 }
207
208 if errors.is_empty() {
209 formatter.progress("ok: Validation passed");
210 formatter.progress(&format!(" {validated_count} table(s) validated successfully"));
211 if !warnings.is_empty() {
212 formatter.progress(&format!(" {} warning(s)", warnings.len()));
213 }
214 Ok(())
215 } else {
216 Err(anyhow::anyhow!("Validation failed with {} error(s)", errors.len()))
217 }
218}
219
220pub(crate) fn compare_metadata(
222 table_name: &str,
223 declared: &FactTableMetadata,
224 actual: &FactTableMetadata,
225) -> Vec<ValidationIssue> {
226 let mut issues = Vec::new();
227
228 let declared_measure_names: HashSet<&str> =
229 declared.measures.iter().map(|m| m.name.as_str()).collect();
230 let actual_measure_names: HashSet<&str> =
231 actual.measures.iter().map(|m| m.name.as_str()).collect();
232
233 for name in &declared_measure_names {
235 if !actual_measure_names.contains(name) {
236 issues.push(ValidationIssue::error(
237 table_name.to_string(),
238 format!("Declared measure '{name}' not found in database"),
239 ));
240 }
241 }
242
243 for name in &actual_measure_names {
245 if !declared_measure_names.contains(name) {
246 issues.push(ValidationIssue::warning(
247 table_name.to_string(),
248 format!("Database has measure '{name}' not declared in schema"),
249 ));
250 }
251 }
252
253 for declared_measure in &declared.measures {
255 if let Some(actual_measure) =
256 actual.measures.iter().find(|m| m.name == declared_measure.name)
257 {
258 let declared_type = format!("{:?}", declared_measure.sql_type);
259 let actual_type = format!("{:?}", actual_measure.sql_type);
260 if declared_type != actual_type {
261 issues.push(ValidationIssue::warning(
262 table_name.to_string(),
263 format!(
264 "Measure '{}' type mismatch: declared '{declared_type}', actual \
265 '{actual_type}'",
266 declared_measure.name
267 ),
268 ));
269 }
270 }
271 }
272
273 if declared.dimensions.name != actual.dimensions.name {
275 issues.push(ValidationIssue::error(
276 table_name.to_string(),
277 format!(
278 "Dimensions column mismatch: declared '{}', actual '{}'",
279 declared.dimensions.name, actual.dimensions.name
280 ),
281 ));
282 }
283
284 let declared_filter_names: HashSet<&str> =
286 declared.denormalized_filters.iter().map(|f| f.name.as_str()).collect();
287 let actual_filter_names: HashSet<&str> =
288 actual.denormalized_filters.iter().map(|f| f.name.as_str()).collect();
289
290 for name in &declared_filter_names {
291 if !actual_filter_names.contains(name) {
292 issues.push(ValidationIssue::warning(
293 table_name.to_string(),
294 format!("Declared filter '{name}' not found in database"),
295 ));
296 }
297 }
298
299 issues
300}
301
302pub(crate) fn declared_fact_tables(
308 schema_json: &str,
309) -> Result<std::collections::HashMap<String, FactTableMetadata>> {
310 let authoring_err = match serde_json::from_str::<IntermediateSchema>(schema_json) {
311 Ok(authoring) => {
312 return Ok(authoring
313 .fact_tables
314 .unwrap_or_default()
315 .into_iter()
316 .map(|ft| (ft.table_name.clone(), SchemaConverter::convert_fact_table(ft)))
317 .collect());
318 },
319 Err(e) => e,
320 };
321 match serde_json::from_str::<CompiledSchema>(schema_json) {
322 Ok(compiled) => Ok(compiled.fact_tables),
323 Err(compiled_err) => Err(anyhow::anyhow!(
324 "the schema is neither an authoring schema.json ({authoring_err}) nor a \
325 schema.compiled.json ({compiled_err})"
326 )),
327 }
328}