fraiseql_core/validation/
compile_time.rs1use std::collections::{HashMap, HashSet};
12
13#[derive(Debug, Clone)]
15pub struct SchemaContext {
16 pub types: HashMap<String, TypeDef>,
18 pub fields: HashMap<(String, String), FieldType>,
20}
21
22#[derive(Debug, Clone)]
24pub struct TypeDef {
25 pub name: String,
27 pub fields: Vec<String>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Hash)]
33#[non_exhaustive]
34pub enum FieldType {
35 String,
37 Integer,
39 Float,
41 Boolean,
43 Date,
45 DateTime,
47 Custom(String),
49}
50
51impl FieldType {
52 #[must_use]
54 pub fn is_comparable_with(&self, other: &FieldType) -> bool {
55 match (self, other) {
56 (a, b) if a == b => true,
58 (FieldType::Integer, FieldType::Float)
60 | (FieldType::Float, FieldType::Integer)
61 | (FieldType::Date, FieldType::DateTime)
62 | (FieldType::DateTime, FieldType::Date) => true,
63 _ => false,
65 }
66 }
67}
68
69#[derive(Debug, Clone)]
71pub struct CompileTimeValidationResult {
72 pub valid: bool,
74 pub errors: Vec<CompileTimeError>,
76 pub warnings: Vec<String>,
78 pub sql_constraint: Option<String>,
80}
81
82#[derive(Debug, Clone)]
84pub struct CompileTimeError {
85 pub field: String,
87 pub message: String,
89 pub suggestion: Option<String>,
91}
92
93#[derive(Debug)]
95pub struct CompileTimeValidator {
96 context: SchemaContext,
97}
98
99impl CompileTimeValidator {
100 #[must_use]
102 pub const fn new(context: SchemaContext) -> Self {
103 Self { context }
104 }
105
106 #[must_use]
108 pub fn validate_cross_field_rule(
109 &self,
110 type_name: &str,
111 left_field: &str,
112 operator: &str,
113 right_field: &str,
114 ) -> CompileTimeValidationResult {
115 let mut errors = Vec::new();
116 let warnings = Vec::new();
117
118 if !self.context.types.contains_key(type_name) {
120 return CompileTimeValidationResult {
121 valid: false,
122 errors: vec![CompileTimeError {
123 field: type_name.to_string(),
124 message: format!("Type '{}' not found in schema", type_name),
125 suggestion: Some("Check that the type is defined".to_string()),
126 }],
127 warnings,
128 sql_constraint: None,
129 };
130 }
131
132 let left_key = (type_name.to_string(), left_field.to_string());
134 let Some(left_type) = self.context.fields.get(&left_key) else {
135 errors.push(CompileTimeError {
136 field: left_field.to_string(),
137 message: format!("Field '{}' not found in type '{}'", left_field, type_name),
138 suggestion: Some(self.suggest_field(type_name, left_field)),
139 });
140 return CompileTimeValidationResult {
141 valid: false,
142 errors,
143 warnings,
144 sql_constraint: None,
145 };
146 };
147
148 let right_key = (type_name.to_string(), right_field.to_string());
150 let Some(right_type) = self.context.fields.get(&right_key) else {
151 errors.push(CompileTimeError {
152 field: right_field.to_string(),
153 message: format!("Field '{}' not found in type '{}'", right_field, type_name),
154 suggestion: Some(self.suggest_field(type_name, right_field)),
155 });
156 return CompileTimeValidationResult {
157 valid: false,
158 errors,
159 warnings,
160 sql_constraint: None,
161 };
162 };
163
164 if !left_type.is_comparable_with(right_type) {
166 errors.push(CompileTimeError {
167 field: format!("{} {} {}", left_field, operator, right_field),
168 message: format!("Cannot compare {:?} with {:?}", left_type, right_type),
169 suggestion: Some("Ensure both fields have comparable types".to_string()),
170 });
171 return CompileTimeValidationResult {
172 valid: false,
173 errors,
174 warnings,
175 sql_constraint: None,
176 };
177 }
178
179 let sql_constraint = self.generate_sql_constraint(
181 type_name,
182 left_field,
183 operator,
184 right_field,
185 left_type,
186 right_type,
187 );
188
189 CompileTimeValidationResult {
190 valid: true,
191 errors,
192 warnings,
193 sql_constraint,
194 }
195 }
196
197 #[must_use]
199 pub fn validate_elo_expression(
200 &self,
201 type_name: &str,
202 expression: &str,
203 ) -> CompileTimeValidationResult {
204 let mut errors = Vec::new();
205 let warnings = Vec::new();
206
207 if !self.context.types.contains_key(type_name) {
209 return CompileTimeValidationResult {
210 valid: false,
211 errors: vec![CompileTimeError {
212 field: type_name.to_string(),
213 message: format!("Type '{}' not found in schema", type_name),
214 suggestion: None,
215 }],
216 warnings,
217 sql_constraint: None,
218 };
219 }
220
221 let field_refs = self.extract_field_references(expression);
223
224 for field_name in field_refs {
226 let field_key = (type_name.to_string(), field_name.clone());
227 if !self.context.fields.contains_key(&field_key) {
228 errors.push(CompileTimeError {
229 field: field_name.clone(),
230 message: format!("Field '{}' not found in type '{}'", field_name, type_name),
231 suggestion: Some(self.suggest_field(type_name, &field_name)),
232 });
233 }
234 }
235
236 let valid_operators = vec!["<", ">", "<=", ">=", "==", "!=", "&&", "||", "!"];
238 for op in valid_operators {
239 if expression.contains(op) {
240 }
242 }
243
244 CompileTimeValidationResult {
245 valid: errors.is_empty(),
246 errors,
247 warnings,
248 sql_constraint: None,
249 }
250 }
251
252 pub(crate) fn extract_field_references(&self, expression: &str) -> Vec<String> {
254 let mut fields = HashSet::new();
255 let mut tokens = Vec::new();
256 let mut current_token = String::new();
257 let mut in_string = false;
258 let mut string_char = ' ';
259 let mut escape = false;
260
261 for ch in expression.chars() {
263 if escape {
265 escape = false;
266 current_token.push(ch);
267 continue;
268 }
269
270 if ch == '\\' && in_string {
271 escape = true;
272 current_token.push(ch);
273 continue;
274 }
275
276 if !in_string && (ch == '"' || ch == '\'') {
278 in_string = true;
279 string_char = ch;
280 current_token.push(ch);
281 } else if in_string && ch == string_char {
282 in_string = false;
283 current_token.push(ch);
284 } else if !in_string && (ch.is_whitespace() || ch == '(' || ch == ')') {
285 if !current_token.is_empty() {
286 tokens.push(current_token.clone());
287 current_token.clear();
288 }
289 } else {
290 current_token.push(ch);
291 }
292 }
293
294 if !current_token.is_empty() {
295 tokens.push(current_token);
296 }
297
298 let infix_operators = ["matches", "in", "contains"];
300
301 for (i, token) in tokens.iter().enumerate() {
302 if token.starts_with('"') || token.starts_with('\'') {
304 continue;
305 }
306
307 if infix_operators.contains(&token.as_str()) {
309 continue;
310 }
311
312 if i > 0 && infix_operators.contains(&tokens[i - 1].as_str()) {
314 continue;
315 }
316
317 if token == "true"
319 || token == "false"
320 || token == "null"
321 || token == "and"
322 || token == "or"
323 || token == "not"
324 {
325 continue;
326 }
327
328 if token.chars().next().is_some_and(|ch| ch.is_uppercase()) {
330 continue;
331 }
332
333 if token.chars().next().is_some_and(|ch| ch.is_lowercase()) {
335 fields.insert(token.clone());
336 }
337 }
338
339 fields.into_iter().collect()
340 }
341
342 fn generate_sql_constraint(
344 &self,
345 _type_name: &str,
346 left_field: &str,
347 operator: &str,
348 right_field: &str,
349 left_type: &FieldType,
350 _right_type: &FieldType,
351 ) -> Option<String> {
352 let sql_op = match operator {
354 "<" | "lt" => "<",
355 "<=" | "lte" => "<=",
356 ">" | "gt" => ">",
357 ">=" | "gte" => ">=",
358 "==" | "eq" => "=",
359 "!=" | "neq" => "!=",
360 _ => return None,
361 };
362
363 let left_quoted = format!("\"{}\"", left_field.replace('"', "\"\""));
365 let right_quoted = format!("\"{}\"", right_field.replace('"', "\"\""));
366 let constraint = match left_type {
367 FieldType::Date
368 | FieldType::DateTime
369 | FieldType::Integer
370 | FieldType::Float
371 | FieldType::String => {
372 format!("CHECK ({} {} {})", left_quoted, sql_op, right_quoted)
373 },
374 _ => return None,
375 };
376
377 Some(constraint)
378 }
379
380 fn suggest_field(&self, type_name: &str, _attempted_field: &str) -> String {
382 let Some(type_def) = self.context.types.get(type_name) else {
383 return "Check schema definition".to_string();
384 };
385
386 let available = type_def.fields.join(", ");
388 format!("Available fields: {}", available)
389 }
390}