qail_core/transpiler/nosql/
mongo.rs1use crate::ast::*;
2
3fn js_string(value: &str) -> String {
4 serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
5}
6
7fn is_js_identifier(value: &str) -> bool {
8 let mut chars = value.chars();
9 let Some(first) = chars.next() else {
10 return false;
11 };
12
13 if !(first == '_' || first == '$' || first.is_ascii_alphabetic()) {
14 return false;
15 }
16
17 chars.all(|c| c == '_' || c == '$' || c.is_ascii_alphanumeric())
18}
19
20fn mongo_collection(name: &str) -> String {
21 if is_js_identifier(name) {
22 format!("db.{name}")
23 } else {
24 format!("db.getCollection({})", js_string(name))
25 }
26}
27
28pub trait ToMongo {
30 fn to_mongo(&self) -> String;
32}
33
34impl ToMongo for Qail {
35 fn to_mongo(&self) -> String {
36 let result = match self.action {
37 Action::Get => {
38 if !self.joins.is_empty() {
39 build_aggregate(self)
40 } else {
41 build_find(self)
42 }
43 }
44 Action::Set => build_update(self),
45 Action::Add => build_insert(self),
46 Action::Put => build_upsert(self),
47 Action::Del => build_delete(self),
48 Action::Make => Ok(format!("db.createCollection({})", js_string(&self.table))),
49 Action::Drop => Ok(format!("{}.drop()", mongo_collection(&self.table))),
50 Action::TxnStart => Ok("session.startTransaction()".to_string()),
51 Action::TxnCommit => Ok("session.commitTransaction()".to_string()),
52 Action::TxnRollback => Ok("session.abortTransaction()".to_string()),
53 _ => {
54 return mongo_error(&format!(
55 "Action {:?} not supported for MongoDB",
56 self.action
57 ));
58 }
59 };
60
61 result.unwrap_or_else(|err| mongo_error(&err))
62 }
63}
64
65fn mongo_error(message: &str) -> String {
66 format!("throw new Error({})", js_string(message))
67}
68
69fn build_aggregate(cmd: &Qail) -> Result<String, String> {
70 let mut stages = Vec::new();
71
72 let filter = build_query_filter(cmd)?;
74 if filter != "{}" {
75 stages.push(format!("{{ \"$match\": {} }}", filter));
76 }
77
78 for join in &cmd.joins {
80 let target = &join.table;
81 let source_singular = cmd.table.trim_end_matches('s');
82 let pk = format!("{}_id", source_singular); let lookup = format!(
86 "{{ \"$lookup\": {{ \"from\": {}, \"localField\": \"_id\", \"foreignField\": {}, \"as\": {} }} }}",
87 js_string(target),
88 js_string(&pk),
89 js_string(target)
90 );
91 stages.push(lookup);
92 }
93
94 let proj = build_projection(cmd)?;
97 if proj != "{}" {
98 stages.push(format!("{{ \"$project\": {} }}", proj));
99 }
100
101 for cage in &cmd.cages {
103 match &cage.kind {
104 CageKind::Sort(order) => {
105 let val = match order {
106 SortOrder::Asc | SortOrder::AscNullsFirst | SortOrder::AscNullsLast => 1,
107 SortOrder::Desc | SortOrder::DescNullsFirst | SortOrder::DescNullsLast => -1,
108 };
109 if let Some(cond) = cage.conditions.first() {
110 let col_str = match &cond.left {
111 Expr::Named(name) => name.clone(),
112 expr => {
113 return Err(format!(
114 "MongoDB sort fields must be named, got expression `{expr}`"
115 ));
116 }
117 };
118 stages.push(format!(
119 "{{ \"$sort\": {{ {}: {} }} }}",
120 js_string(&col_str),
121 val
122 ));
123 }
124 }
125 CageKind::Offset(n) => stages.push(format!("{{ \"$skip\": {} }}", n)),
126 CageKind::Limit(n) => stages.push(format!("{{ \"$limit\": {} }}", n)),
127 _ => {}
128 }
129 }
130
131 Ok(format!(
132 "{}.aggregate([{}])",
133 mongo_collection(&cmd.table),
134 stages.join(", ")
135 ))
136}
137
138fn build_find(cmd: &Qail) -> Result<String, String> {
139 let query = build_query_filter(cmd)?;
140 let projection = build_projection(cmd)?;
141
142 let mut mongo = format!(
144 "{}.find({}, {})",
145 mongo_collection(&cmd.table),
146 query,
147 projection
148 );
149
150 for cage in &cmd.cages {
152 match &cage.kind {
153 CageKind::Limit(n) => mongo.push_str(&format!(".limit({})", n)),
154 CageKind::Offset(n) => mongo.push_str(&format!(".skip({})", n)),
155 CageKind::Sort(order) => {
156 let val = match order {
157 SortOrder::Asc | SortOrder::AscNullsFirst | SortOrder::AscNullsLast => 1,
158 SortOrder::Desc | SortOrder::DescNullsFirst | SortOrder::DescNullsLast => -1,
159 };
160 if let Some(cond) = cage.conditions.first() {
161 let col_str = match &cond.left {
162 Expr::Named(name) => name.clone(),
163 expr => {
164 return Err(format!(
165 "MongoDB sort fields must be named, got expression `{expr}`"
166 ));
167 }
168 };
169 mongo.push_str(&format!(".sort({{ {}: {} }})", js_string(&col_str), val));
170 }
171 }
172 _ => {}
173 }
174 }
175
176 Ok(mongo)
177}
178
179fn build_update(cmd: &Qail) -> Result<String, String> {
180 let query = build_query_filter(cmd)?;
181 let mut update_doc = String::from("{ $set: { ");
183 let mut first = true;
184
185 for cage in &cmd.cages {
186 if let CageKind::Payload = cage.kind {
187 for cond in &cage.conditions {
188 if !first {
189 update_doc.push_str(", ");
190 }
191 let col_str = match &cond.left {
192 Expr::Named(name) => name.clone(),
193 expr => {
194 return Err(format!(
195 "MongoDB update fields must be named, got expression `{expr}`"
196 ));
197 }
198 };
199 update_doc.push_str(&format!(
200 "{}: {}",
201 js_string(&col_str),
202 value_to_json(&cond.value)?
203 ));
204 first = false;
205 }
206 }
207 }
208 if first {
209 return Err("MongoDB update requires at least one update field".to_string());
210 }
211 update_doc.push_str(" } }");
212
213 Ok(format!(
214 "{}.updateMany({}, {})",
215 mongo_collection(&cmd.table),
216 query,
217 update_doc
218 ))
219}
220
221fn build_insert(cmd: &Qail) -> Result<String, String> {
222 let mut doc = String::from("{ ");
223 let mut first = true;
224
225 for cage in &cmd.cages {
226 if let CageKind::Payload = cage.kind {
227 for cond in &cage.conditions {
228 if !first {
229 doc.push_str(", ");
230 }
231 let col_str = match &cond.left {
232 Expr::Named(name) => name.clone(),
233 expr => {
234 return Err(format!(
235 "MongoDB insert fields must be named, got expression `{expr}`"
236 ));
237 }
238 };
239 doc.push_str(&format!(
240 "{}: {}",
241 js_string(&col_str),
242 value_to_json(&cond.value)?
243 ));
244 first = false;
245 }
246 }
247 }
248 if first {
249 return Err("MongoDB insert requires at least one document field".to_string());
250 }
251 doc.push_str(" }");
252
253 Ok(format!(
254 "{}.insertOne({})",
255 mongo_collection(&cmd.table),
256 doc
257 ))
258}
259
260fn build_upsert(cmd: &Qail) -> Result<String, String> {
261 let query = build_query_filter(cmd)?;
263
264 let mut update_doc = String::from("{ $set: { ");
266 let mut first = true;
267
268 for cage in &cmd.cages {
269 if let CageKind::Payload = cage.kind {
270 for cond in &cage.conditions {
271 if !first {
272 update_doc.push_str(", ");
273 }
274 let col_str = match &cond.left {
275 Expr::Named(name) => name.clone(),
276 expr => {
277 return Err(format!(
278 "MongoDB upsert fields must be named, got expression `{expr}`"
279 ));
280 }
281 };
282 update_doc.push_str(&format!(
283 "{}: {}",
284 js_string(&col_str),
285 value_to_json(&cond.value)?
286 ));
287 first = false;
288 }
289 }
290 }
291 if first {
292 return Err("MongoDB upsert requires at least one update field".to_string());
293 }
294 update_doc.push_str(" } }");
295
296 Ok(format!(
297 "{}.updateOne({}, {}, {{ \"upsert\": true }})",
298 mongo_collection(&cmd.table),
299 query,
300 update_doc
301 ))
302}
303
304fn build_delete(cmd: &Qail) -> Result<String, String> {
305 let query = build_query_filter(cmd)?;
306 if query == "{}" {
307 return Err("MongoDB delete requires at least one filter condition".to_string());
308 }
309 Ok(format!(
310 "{}.deleteMany({})",
311 mongo_collection(&cmd.table),
312 query
313 ))
314}
315
316fn build_query_filter(cmd: &Qail) -> Result<String, String> {
317 let mut and_clauses = Vec::new();
318
319 for cage in &cmd.cages {
320 if let CageKind::Filter = cage.kind {
321 let mut cage_clauses = Vec::new();
322 for cond in &cage.conditions {
323 cage_clauses.push(mongo_condition_clause(cond)?);
324 }
325
326 if cage_clauses.is_empty() {
327 continue;
328 }
329
330 match cage.logical_op {
331 LogicalOp::And => and_clauses.extend(cage_clauses),
332 LogicalOp::Or => {
333 if cage_clauses.len() == 1 {
334 and_clauses.push(cage_clauses[0].clone());
335 } else {
336 and_clauses.push(format!("{{ \"$or\": [{}] }}", cage_clauses.join(", ")));
337 }
338 }
339 }
340 }
341 }
342
343 match and_clauses.len() {
344 0 => Ok("{}".to_string()),
345 1 => Ok(and_clauses.remove(0)),
346 _ => Ok(format!("{{ \"$and\": [{}] }}", and_clauses.join(", "))),
347 }
348}
349
350fn mongo_condition_clause(cond: &Condition) -> Result<String, String> {
351 let op = match cond.op {
352 Operator::Eq => "$eq",
353 Operator::Ne => "$ne",
354 Operator::Gt => "$gt",
355 Operator::Lt => "$lt",
356 Operator::Gte => "$gte",
357 Operator::Lte => "$lte",
358 _ => return Err(format!("unsupported MongoDB filter operator {:?}", cond.op)),
359 };
360
361 let col_str = match &cond.left {
362 Expr::Named(name) => name.clone(),
363 expr => {
364 return Err(format!(
365 "MongoDB filters require named fields, got expression `{expr}`"
366 ));
367 }
368 };
369
370 if let Operator::Eq = cond.op {
371 Ok(format!(
372 "{{ {}: {} }}",
373 js_string(&col_str),
374 value_to_json(&cond.value)?
375 ))
376 } else {
377 Ok(format!(
378 "{{ {}: {{ \"{}\": {} }} }}",
379 js_string(&col_str),
380 op,
381 value_to_json(&cond.value)?
382 ))
383 }
384}
385
386fn build_projection(cmd: &Qail) -> Result<String, String> {
387 if cmd.columns.is_empty() {
388 return Ok("{}".to_string());
389 }
390
391 let mut proj = String::from("{ ");
392 for (i, col) in cmd.columns.iter().enumerate() {
393 if i > 0 {
394 proj.push_str(", ");
395 }
396 let Expr::Named(name) = col else {
397 return Err(format!(
398 "MongoDB projections require named fields, got expression `{col}`"
399 ));
400 };
401 proj.push_str(&format!("{}: 1", js_string(name)));
402 }
403 proj.push_str(" }");
404 Ok(proj)
405}
406
407fn value_to_json(v: &Value) -> Result<String, String> {
408 match v {
409 Value::Null | Value::NullUuid => Ok("null".to_string()),
410 Value::String(s) => Ok(js_string(s)),
411 Value::Int(n) => Ok(n.to_string()),
412 Value::Float(n) if n.is_finite() => Ok(n.to_string()),
413 Value::Float(_) => Err("non-finite floats cannot be encoded as MongoDB JSON".to_string()),
414 Value::Bool(b) => Ok(b.to_string()),
415 Value::Uuid(uuid) => Ok(js_string(&uuid.to_string())),
416 Value::Timestamp(ts) => Ok(js_string(ts)),
417 Value::Array(values) => {
418 let values: Result<Vec<String>, String> = values.iter().map(value_to_json).collect();
419 Ok(format!("[{}]", values?.join(", ")))
420 }
421 Value::Vector(values) => {
422 let values: Result<Vec<String>, String> = values
423 .iter()
424 .map(|value| {
425 if value.is_finite() {
426 Ok(value.to_string())
427 } else {
428 Err("non-finite vector values cannot be encoded as MongoDB JSON"
429 .to_string())
430 }
431 })
432 .collect();
433 Ok(format!("[{}]", values?.join(", ")))
434 }
435 Value::Json(json) => serde_json::from_str::<serde_json::Value>(json)
436 .map(|value| value.to_string())
437 .map_err(|err| format!("invalid JSON value for MongoDB document: {err}")),
438 other => Err(format!("unsupported MongoDB value: {other}")),
439 }
440}