fraiseql_core/validation/
js_codegen.rs1#[derive(Debug, Clone, Default)]
19pub struct JsCodegen {
20 _private: (),
21}
22
23impl JsCodegen {
24 #[must_use]
26 pub const fn new() -> Self {
27 Self { _private: () }
28 }
29
30 #[must_use]
34 pub fn emit_validator(&self, type_name: &str, expression: &str) -> String {
35 let js_expr = self.elo_to_js(expression);
36 format!(
37 "export function validate_{type_name}(data) {{\n\
38 \x20 const errors = [];\n\
39 \x20 if (!({js_expr})) errors.push({{ field: null, rule: {rule_str} }});\n\
40 \x20 return {{ valid: errors.length === 0, errors }};\n\
41 }}",
42 rule_str = Self::js_string_literal(expression),
43 )
44 }
45
46 #[must_use]
50 pub fn emit_module(&self, validators: &[(&str, &str)]) -> String {
51 let mut parts = Vec::with_capacity(validators.len() + 1);
52 parts.push("// Generated by FraiseQL — do not edit manually.".to_string());
53
54 for (type_name, expression) in validators {
55 parts.push(String::new());
56 parts.push(self.emit_validator(type_name, expression));
57 }
58
59 parts.join("\n")
60 }
61
62 #[must_use]
64 pub fn elo_to_js(&self, expression: &str) -> String {
65 let expr = expression.trim();
66
67 if let Some(idx) = find_op_outside_parens(expr, "&&") {
69 let left = self.elo_to_js(&expr[..idx]);
70 let right = self.elo_to_js(&expr[idx + 2..]);
71 return format!("({left} && {right})");
72 }
73
74 if let Some(idx) = find_op_outside_parens(expr, "||") {
76 let left = self.elo_to_js(&expr[..idx]);
77 let right = self.elo_to_js(&expr[idx + 2..]);
78 return format!("({left} || {right})");
79 }
80
81 if let Some(inner) = expr.strip_prefix('!') {
83 let inner_js = self.elo_to_js(inner.trim());
84 return format!("!{inner_js}");
85 }
86
87 if expr.starts_with('(') && expr.ends_with(')') && has_matching_parens(expr) {
89 let inner = &expr[1..expr.len() - 1];
90 let inner_js = self.elo_to_js(inner);
91 return format!("({inner_js})");
92 }
93
94 for op in &["<=", ">=", "!=", "==", "<", ">"] {
96 if let Some(idx) = expr.find(op) {
97 let left = self.elo_operand_to_js(expr[..idx].trim());
98 let right = self.elo_operand_to_js(expr[idx + op.len()..].trim());
99 let js_op = match *op {
100 "==" => "===",
101 "!=" => "!==",
102 other => other,
103 };
104 return format!("{left} {js_op} {right}");
105 }
106 }
107
108 self.elo_operand_to_js(expr)
110 }
111
112 fn elo_operand_to_js(&self, operand: &str) -> String {
114 let operand = operand.trim();
115
116 if operand.parse::<f64>().is_ok() {
118 return operand.to_string();
119 }
120
121 if operand == "true" || operand == "false" {
123 return operand.to_string();
124 }
125
126 if operand == "null" {
128 return "null".to_string();
129 }
130
131 if (operand.starts_with('"') && operand.ends_with('"'))
133 || (operand.starts_with('\'') && operand.ends_with('\''))
134 {
135 return operand.to_string();
136 }
137
138 if let Some(rest) = operand.strip_prefix("length(") {
140 if let Some(field) = rest.strip_suffix(')') {
141 let field = field.trim();
142 return format!("(data.{field} || '').length");
143 }
144 }
145
146 if let Some(rest) = operand.strip_prefix("age(") {
147 if let Some(field) = rest.strip_suffix(')') {
148 let field = field.trim();
149 return format!(
151 "(() => {{ \
152 const b = new Date(data.{field}); \
153 const t = new Date(); \
154 let a = t.getFullYear() - b.getFullYear(); \
155 if (t.getMonth() < b.getMonth() || \
156 (t.getMonth() === b.getMonth() && t.getDate() < b.getDate())) a--; \
157 return a; \
158 }})()"
159 );
160 }
161 }
162
163 if let Some(rest) = operand.strip_prefix("matches(") {
164 if let Some(args) = rest.strip_suffix(')') {
165 let parts: Vec<&str> = args.splitn(2, ',').collect();
166 if parts.len() == 2 {
167 let field = parts[0].trim();
168 let pattern = parts[1].trim();
169 return format!("new RegExp({pattern}).test(data.{field})");
170 }
171 }
172 }
173
174 if let Some(rest) = operand.strip_prefix("contains(") {
175 if let Some(args) = rest.strip_suffix(')') {
176 let parts: Vec<&str> = args.splitn(2, ',').collect();
177 if parts.len() == 2 {
178 let field = parts[0].trim();
179 let needle = parts[1].trim();
180 return format!("(data.{field} || '').includes({needle})");
181 }
182 }
183 }
184
185 format!("data.{operand}")
187 }
188
189 fn js_string_literal(s: &str) -> String {
191 let escaped = s.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n");
192 format!("\"{escaped}\"")
193 }
194}
195
196fn find_op_outside_parens(expr: &str, op: &str) -> Option<usize> {
198 let mut depth = 0i32;
199 let mut in_string = false;
200 let bytes = expr.as_bytes();
201
202 for i in 0..bytes.len() {
203 let ch = bytes[i];
204 if ch == b'"' || ch == b'\'' {
205 in_string = !in_string;
206 continue;
207 }
208 if in_string {
209 continue;
210 }
211 if ch == b'(' {
212 depth += 1;
213 } else if ch == b')' {
214 depth -= 1;
215 } else if depth == 0 && i + op.len() <= bytes.len() && &expr[i..i + op.len()] == op {
216 return Some(i);
217 }
218 }
219
220 None
221}
222
223fn has_matching_parens(expr: &str) -> bool {
225 let mut depth = 0i32;
226 for (i, ch) in expr.chars().enumerate() {
227 match ch {
228 '(' => depth += 1,
229 ')' => {
230 depth -= 1;
231 if depth == 0 && i < expr.len() - 1 {
232 return false;
233 }
234 },
235 _ => {},
236 }
237 }
238 depth == 0
239}