Skip to main content

qail_core/transpiler/nosql/
dynamo.rs

1use crate::ast::*;
2
3fn json_string(value: &str) -> String {
4    serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
5}
6
7#[derive(Default)]
8struct DynamoExpression {
9    expression: String,
10    values: String,
11    names: Vec<(String, String)>,
12}
13
14fn attribute_names_json(names: &[(String, String)]) -> String {
15    names
16        .iter()
17        .map(|(placeholder, name)| format!("{}: {}", json_string(placeholder), json_string(name)))
18        .collect::<Vec<_>>()
19        .join(", ")
20}
21
22/// Trait for converting QAIL AST to DynamoDB JSON.
23pub trait ToDynamo {
24    /// Convert a QAIL query into a DynamoDB request JSON body.
25    fn to_dynamo(&self) -> String;
26}
27
28impl ToDynamo for Qail {
29    fn to_dynamo(&self) -> String {
30        let result = match self.action {
31            Action::Get => build_get_item(self),
32            Action::Add | Action::Put => build_put_item(self),
33            Action::Set => build_update_item(self),
34            Action::Del => build_delete_item(self),
35            Action::Make => Ok(build_create_table(self)),
36            Action::Drop => Ok(format!("{{ \"TableName\": {} }}", json_string(&self.table))), // DeleteTable input
37            _ => {
38                return format!(
39                    "{{ \"error\": {} }}",
40                    json_string(&format!("Action {:?} not supported", self.action))
41                );
42            }
43        };
44
45        result.unwrap_or_else(|err| dynamo_error(&err))
46    }
47}
48
49fn dynamo_error(message: &str) -> String {
50    format!("{{ \"error\": {} }}", json_string(message))
51}
52
53fn build_get_item(cmd: &Qail) -> Result<String, String> {
54    let mut parts = Vec::new();
55    parts.push(format!("\"TableName\": {}", json_string(&cmd.table)));
56
57    let mut filter = build_expression(cmd)?;
58    if !filter.expression.is_empty() {
59        parts.push(format!(
60            "\"FilterExpression\": {}",
61            json_string(&filter.expression)
62        ));
63        parts.push(format!(
64            "\"ExpressionAttributeValues\": {{ {} }}",
65            filter.values
66        ));
67    }
68
69    for cage in &cmd.cages {
70        if let CageKind::Filter = cage.kind {
71            for cond in &cage.conditions {
72                if let Expr::Named(name) = &cond.left {
73                    match name.as_str() {
74                        "gsi" | "index" => {
75                            let index_name = match &cond.value {
76                                Value::String(s) => s.clone(),
77                                _ => {
78                                    return Err("DynamoDB index name must be provided as a string"
79                                        .to_string());
80                                }
81                            };
82                            parts.push(format!("\"IndexName\": {}", json_string(&index_name)));
83                        }
84                        "consistency" | "consistent" => {
85                            if consistent_read_value(&cond.value)? {
86                                parts.push("\"ConsistentRead\": true".to_string());
87                            } else {
88                                parts.push("\"ConsistentRead\": false".to_string());
89                            }
90                        }
91                        _ => {}
92                    }
93                }
94            }
95        }
96    }
97
98    if !cmd.columns.is_empty() {
99        let mut cols = Vec::new();
100        for (idx, col) in cmd.columns.iter().enumerate() {
101            if let Expr::Named(n) = col {
102                let placeholder = format!("#p{}", idx + 1);
103                cols.push(placeholder.clone());
104                filter.names.push((placeholder, n.clone()));
105            }
106        }
107        if !cols.is_empty() {
108            parts.push(format!(
109                "\"ProjectionExpression\": {}",
110                json_string(&cols.join(", "))
111            ));
112        }
113    }
114
115    if !filter.names.is_empty() {
116        parts.push(format!(
117            "\"ExpressionAttributeNames\": {{ {} }}",
118            attribute_names_json(&filter.names)
119        ));
120    }
121
122    if let Some(n) = get_limit(cmd) {
123        parts.push(format!("\"Limit\": {}", n))
124    }
125
126    Ok(format!("{{ {} }}", parts.join(", ")))
127}
128
129fn build_put_item(cmd: &Qail) -> Result<String, String> {
130    let mut parts = Vec::new();
131    parts.push(format!("\"TableName\": {}", json_string(&cmd.table)));
132
133    let item = build_item_json(cmd)?;
134    parts.push(format!("\"Item\": {{ {} }}", item));
135
136    Ok(format!("{{ {} }}", parts.join(", ")))
137}
138
139fn build_update_item(cmd: &Qail) -> Result<String, String> {
140    let mut parts = Vec::new();
141    parts.push(format!("\"TableName\": {}", json_string(&cmd.table)));
142
143    let key = build_key_from_filter(cmd)?;
144    parts.push(format!("\"Key\": {{ {} }}", key));
145
146    let update = build_update_expression(cmd)?;
147    parts.push(format!(
148        "\"UpdateExpression\": {}",
149        json_string(&update.expression)
150    ));
151    parts.push(format!(
152        "\"ExpressionAttributeValues\": {{ {} }}",
153        update.values
154    ));
155    if !update.names.is_empty() {
156        parts.push(format!(
157            "\"ExpressionAttributeNames\": {{ {} }}",
158            attribute_names_json(&update.names)
159        ));
160    }
161
162    Ok(format!("{{ {} }}", parts.join(", ")))
163}
164
165fn build_delete_item(cmd: &Qail) -> Result<String, String> {
166    let mut parts = Vec::new();
167    parts.push(format!("\"TableName\": {}", json_string(&cmd.table)));
168
169    // Key logic
170    let key = build_key_from_filter(cmd)?;
171    parts.push(format!("\"Key\": {{ {} }}", key));
172
173    Ok(format!("{{ {} }}", parts.join(", ")))
174}
175
176fn build_expression(cmd: &Qail) -> Result<DynamoExpression, String> {
177    let mut expr_parts = Vec::new();
178    let mut values_parts = Vec::new();
179    let mut names = Vec::new();
180    let mut counter = 0;
181
182    for cage in &cmd.cages {
183        if let CageKind::Filter = cage.kind {
184            for cond in &cage.conditions {
185                let Expr::Named(name) = &cond.left else {
186                    return Err(format!(
187                        "DynamoDB filters require named fields, got expression `{}`",
188                        cond.left
189                    ));
190                };
191
192                if matches!(
193                    name.as_str(),
194                    "gsi" | "index" | "consistency" | "consistent"
195                ) {
196                    continue;
197                }
198
199                counter += 1;
200                let placeholder = format!(":v{}", counter);
201                let name_placeholder = format!("#f{}", counter);
202                let op = match cond.op {
203                    Operator::Eq => "=",
204                    Operator::Ne => "<>",
205                    Operator::Gt => ">",
206                    Operator::Lt => "<",
207                    Operator::Gte => ">=",
208                    Operator::Lte => "<=",
209                    _ => {
210                        return Err(format!(
211                            "unsupported DynamoDB filter operator {:?}",
212                            cond.op
213                        ));
214                    }
215                };
216
217                expr_parts.push(format!("{} {} {}", name_placeholder, op, placeholder));
218                names.push((name_placeholder, name.clone()));
219
220                let val_json = value_to_dynamo(&cond.value)?;
221                values_parts.push(format!("{}: {}", json_string(&placeholder), val_json));
222            }
223        }
224    }
225
226    Ok(DynamoExpression {
227        expression: expr_parts.join(" AND "),
228        values: values_parts.join(", "),
229        names,
230    })
231}
232
233fn build_item_json(cmd: &Qail) -> Result<String, String> {
234    let mut parts = Vec::new();
235    for cage in &cmd.cages {
236        match cage.kind {
237            CageKind::Payload | CageKind::Filter => {
238                for cond in &cage.conditions {
239                    let val = value_to_dynamo(&cond.value)?;
240                    let Expr::Named(name) = &cond.left else {
241                        return Err(format!(
242                            "DynamoDB item fields must be named, got expression `{}`",
243                            cond.left
244                        ));
245                    };
246                    parts.push(format!("{}: {}", json_string(name), val));
247                }
248            }
249            _ => {}
250        }
251    }
252
253    if parts.is_empty() {
254        return Err("DynamoDB put item requires at least one item field".to_string());
255    }
256
257    Ok(parts.join(", "))
258}
259
260fn build_key_from_filter(cmd: &Qail) -> Result<String, String> {
261    for cage in &cmd.cages {
262        if let CageKind::Filter = cage.kind {
263            for cond in &cage.conditions {
264                let Expr::Named(name) = &cond.left else {
265                    return Err(format!(
266                        "DynamoDB key fields must be named, got expression `{}`",
267                        cond.left
268                    ));
269                };
270                if matches!(
271                    name.as_str(),
272                    "gsi" | "index" | "consistency" | "consistent"
273                ) {
274                    continue;
275                }
276                if cond.op != Operator::Eq {
277                    return Err("DynamoDB key filters must use equality".to_string());
278                }
279                let val = value_to_dynamo(&cond.value)?;
280                return Ok(format!("{}: {}", json_string(name), val));
281            }
282        }
283    }
284    Err("DynamoDB update/delete requires an equality key filter".to_string())
285}
286
287fn build_update_expression(cmd: &Qail) -> Result<DynamoExpression, String> {
288    let mut sets = Vec::new();
289    let mut vals = Vec::new();
290    let mut names = Vec::new();
291    let mut counter = 100; // Offset to avoid collision with filters
292
293    for cage in &cmd.cages {
294        if let CageKind::Payload = cage.kind {
295            for cond in &cage.conditions {
296                counter += 1;
297                let placeholder = format!(":u{}", counter);
298                let Expr::Named(name) = &cond.left else {
299                    return Err(format!(
300                        "DynamoDB update fields must be named, got expression `{}`",
301                        cond.left
302                    ));
303                };
304                let name_placeholder = format!("#u{}", counter);
305                sets.push(format!("{} = {}", name_placeholder, placeholder));
306                names.push((name_placeholder, name.clone()));
307
308                let val = value_to_dynamo(&cond.value)?;
309                vals.push(format!("{}: {}", json_string(&placeholder), val));
310            }
311        }
312    }
313
314    if sets.is_empty() {
315        return Err("DynamoDB update requires at least one payload field".to_string());
316    }
317
318    Ok(DynamoExpression {
319        expression: format!("SET {}", sets.join(", ")),
320        values: vals.join(", "),
321        names,
322    })
323}
324
325fn get_limit(cmd: &Qail) -> Option<usize> {
326    for cage in &cmd.cages {
327        if let CageKind::Limit(n) = cage.kind {
328            return Some(n);
329        }
330    }
331    None
332}
333
334fn build_create_table(cmd: &Qail) -> String {
335    let mut attr_defs = Vec::new();
336    let mut key_schema = Vec::new();
337
338    for col in &cmd.columns {
339        if let Expr::Def {
340            name,
341            data_type,
342            constraints,
343        } = col
344            && constraints.contains(&Constraint::PrimaryKey)
345        {
346            let dtype = match data_type.as_str() {
347                "int" | "i32" | "float" => "N",
348                _ => "S",
349            };
350            attr_defs.push(format!(
351                "{{ \"AttributeName\": {}, \"AttributeType\": {} }}",
352                json_string(name),
353                json_string(dtype)
354            ));
355            key_schema.push(format!(
356                "{{ \"AttributeName\": {}, \"KeyType\": \"HASH\" }}",
357                json_string(name)
358            ));
359        }
360    }
361
362    if key_schema.is_empty() {
363        attr_defs.push("{ \"AttributeName\": \"id\", \"AttributeType\": \"S\" }".to_string());
364        key_schema.push("{ \"AttributeName\": \"id\", \"KeyType\": \"HASH\" }".to_string());
365    }
366
367    format!(
368        "{{ \"TableName\": {}, \"KeySchema\": [{}], \"AttributeDefinitions\": [{}], \"BillingMode\": \"PAY_PER_REQUEST\" }}",
369        json_string(&cmd.table),
370        key_schema.join(", "),
371        attr_defs.join(", ")
372    )
373}
374
375fn consistent_read_value(value: &Value) -> Result<bool, String> {
376    match value {
377        Value::Bool(value) => Ok(*value),
378        Value::String(value) => match value.to_ascii_uppercase().as_str() {
379            "STRONG" | "TRUE" => Ok(true),
380            "EVENTUAL" | "FALSE" => Ok(false),
381            _ => Err("DynamoDB consistency must be STRONG, EVENTUAL, true, or false".to_string()),
382        },
383        other => Err(format!(
384            "DynamoDB consistency must be a bool or string, got {other}"
385        )),
386    }
387}
388
389fn value_to_dynamo(v: &Value) -> Result<String, String> {
390    match v {
391        Value::String(s) => Ok(format!("{{ \"S\": {} }}", json_string(s))),
392        Value::Int(n) => Ok(format!("{{ \"N\": \"{}\" }}", n)),
393        Value::Float(n) if n.is_finite() => Ok(format!("{{ \"N\": \"{}\" }}", n)),
394        Value::Float(_) => {
395            Err("non-finite floats cannot be encoded as DynamoDB numbers".to_string())
396        }
397        Value::Bool(b) => Ok(format!("{{ \"BOOL\": {} }}", b)),
398        Value::Null | Value::NullUuid => Ok("{ \"NULL\": true }".to_string()),
399        Value::Uuid(uuid) => Ok(format!("{{ \"S\": {} }}", json_string(&uuid.to_string()))),
400        Value::Timestamp(ts) => Ok(format!("{{ \"S\": {} }}", json_string(ts))),
401        Value::Array(values) => {
402            let values: Result<Vec<String>, String> = values.iter().map(value_to_dynamo).collect();
403            Ok(format!("{{ \"L\": [{}] }}", values?.join(", ")))
404        }
405        Value::Vector(values) => {
406            let values: Result<Vec<String>, String> = values
407                .iter()
408                .map(|value| {
409                    if value.is_finite() {
410                        Ok(format!("{{ \"N\": \"{}\" }}", value))
411                    } else {
412                        Err(
413                            "non-finite vector values cannot be encoded as DynamoDB numbers"
414                                .to_string(),
415                        )
416                    }
417                })
418                .collect();
419            Ok(format!("{{ \"L\": [{}] }}", values?.join(", ")))
420        }
421        Value::Json(json) => serde_json::from_str::<serde_json::Value>(json)
422            .map_err(|err| format!("invalid JSON value for DynamoDB attribute: {err}"))
423            .and_then(|value| json_value_to_dynamo(&value)),
424        other => Err(format!("unsupported DynamoDB attribute value: {other}")),
425    }
426}
427
428fn json_value_to_dynamo(value: &serde_json::Value) -> Result<String, String> {
429    match value {
430        serde_json::Value::Null => Ok("{ \"NULL\": true }".to_string()),
431        serde_json::Value::Bool(value) => Ok(format!("{{ \"BOOL\": {} }}", value)),
432        serde_json::Value::Number(value) => {
433            Ok(format!("{{ \"N\": {} }}", json_string(&value.to_string())))
434        }
435        serde_json::Value::String(value) => Ok(format!("{{ \"S\": {} }}", json_string(value))),
436        serde_json::Value::Array(values) => {
437            let values: Result<Vec<String>, String> =
438                values.iter().map(json_value_to_dynamo).collect();
439            Ok(format!("{{ \"L\": [{}] }}", values?.join(", ")))
440        }
441        serde_json::Value::Object(values) => {
442            let values: Result<Vec<String>, String> = values
443                .iter()
444                .map(|(key, value)| {
445                    Ok(format!(
446                        "{}: {}",
447                        json_string(key),
448                        json_value_to_dynamo(value)?
449                    ))
450                })
451                .collect();
452            Ok(format!("{{ \"M\": {{ {} }} }}", values?.join(", ")))
453        }
454    }
455}