Skip to main content

qail_core/transpiler/nosql/
mongo.rs

1use 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
28/// Trait for converting QAIL AST to MongoDB shell commands.
29pub trait ToMongo {
30    /// Convert a QAIL query into a MongoDB shell command string.
31    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    // 1. $match
73    let filter = build_query_filter(cmd)?;
74    if filter != "{}" {
75        stages.push(format!("{{ \"$match\": {} }}", filter));
76    }
77
78    // 2. $lookup
79    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); // users -> user_id
83
84        // from: orders, localField: _id, foreignField: user_id, as: orders
85        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    // 3. $project & Add Fields logic if needed?
95    // For now simple projection if columns exist
96    let proj = build_projection(cmd)?;
97    if proj != "{}" {
98        stages.push(format!("{{ \"$project\": {} }}", proj));
99    }
100
101    // 4. Sort, Skip, Limit
102    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    // Base: db.collection.find(query, projection)
143    let mut mongo = format!(
144        "{}.find({}, {})",
145        mongo_collection(&cmd.table),
146        query,
147        projection
148    );
149
150    // Sort, Limit, Skip logic
151    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    // Payload logic for $set would go here
182    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    // Similar to update but with upsert: true
262    let query = build_query_filter(cmd)?;
263
264    // Payload logic for $set
265    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}