Skip to main content

dynoxide/expressions/
projection.rs

1//! ProjectionExpression parsing and application.
2//!
3//! Parses comma-separated attribute paths, supports dot notation and bracket indexing.
4//! Always includes key attributes in the result.
5
6use crate::expressions::tokenizer::{Token, TokenStream, near_window_tokenizer, tokenize};
7use crate::expressions::{
8    PathElement, TrackedExpressionAttributes, resolve_path, resolve_path_elements,
9};
10use crate::types::AttributeValue;
11use std::collections::HashMap;
12
13/// A parsed projection — list of attribute paths.
14#[derive(Debug, Clone)]
15pub struct ProjectionExpr {
16    pub paths: Vec<Vec<PathElement>>,
17}
18
19/// Parse a ProjectionExpression string.
20pub fn parse(expr: &str) -> Result<ProjectionExpr, String> {
21    let tokens = match tokenize(expr) {
22        Ok(t) => t,
23        Err(err) => {
24            // Tokenizer-level syntax error (e.g. stray `!`): build the
25            // AWS-style `near: "..."` window from the offending byte position.
26            let bad = &expr[err.position..err.position + err.bad_len];
27            let near = near_window_tokenizer(expr, err.position);
28            return Err(format!(
29                r#"Invalid ProjectionExpression: Syntax error; token: "{bad}", near: "{near}""#
30            ));
31        }
32    };
33    let mut stream = TokenStream::new(tokens);
34
35    let mut paths = Vec::new();
36
37    if stream.at_end() {
38        return Ok(ProjectionExpr { paths });
39    }
40
41    paths.push(parse_path(&mut stream).map_err(|e| projection_parser_error(expr, &mut stream, e))?);
42
43    while matches!(stream.peek(), Some(Token::Comma)) {
44        stream.next();
45        paths.push(
46            parse_path(&mut stream).map_err(|e| projection_parser_error(expr, &mut stream, e))?,
47        );
48    }
49
50    if !stream.at_end() {
51        return Err(format!(
52            "Unexpected token in ProjectionExpression: {}",
53            stream.peek().unwrap()
54        ));
55    }
56
57    Ok(ProjectionExpr { paths })
58}
59
60/// Wrap a parser-level ProjectionExpression error in the standard envelope.
61/// For now this is a passthrough through the existing message shape; if the
62/// conformance suite later pins a `near:` window for parser-level projection
63/// errors, the offending span is available via `stream.current_span()` and
64/// the next span via `stream.peek_span()`.
65fn projection_parser_error(_expr: &str, _stream: &mut TokenStream, msg: String) -> String {
66    format!("Invalid ProjectionExpression: {msg}")
67}
68
69/// Apply a projection to an item, returning only the specified attributes.
70/// Key attributes are always included.
71pub fn apply(
72    item: &HashMap<String, AttributeValue>,
73    projection: &ProjectionExpr,
74    tracker: &TrackedExpressionAttributes,
75    key_attrs: &[String],
76) -> Result<HashMap<String, AttributeValue>, String> {
77    let mut result = HashMap::new();
78
79    // Always include key attributes
80    for key_attr in key_attrs {
81        if let Some(val) = item.get(key_attr) {
82            result.insert(key_attr.clone(), val.clone());
83        }
84    }
85
86    // Add projected attributes
87    for raw_path in &projection.paths {
88        let resolved = resolve_path_elements(raw_path, tracker)?;
89        if let Some(val) = resolve_path(item, &resolved) {
90            insert_at_path(&mut result, &resolved, val);
91        }
92    }
93
94    Ok(result)
95}
96
97fn parse_path(stream: &mut TokenStream) -> Result<Vec<PathElement>, String> {
98    let first = match stream.next() {
99        Some(Token::Identifier(name)) => {
100            if super::reserved::is_reserved_keyword(name) {
101                return Err(format!(
102                    "Attribute name is a reserved keyword; reserved keyword: {name}"
103                ));
104            }
105            PathElement::Attribute(name.clone())
106        }
107        Some(Token::NameRef(name)) => PathElement::Attribute(name.clone()),
108        Some(t) => return Err(format!("Expected attribute name, got {t}")),
109        None => return Err("Expected attribute name, got end of expression".to_string()),
110    };
111
112    let mut path = vec![first];
113
114    loop {
115        match stream.peek() {
116            Some(Token::Dot) => {
117                stream.next();
118                match stream.next() {
119                    Some(Token::Identifier(name)) => {
120                        if super::reserved::is_reserved_keyword(name) {
121                            return Err(format!(
122                                "Attribute name is a reserved keyword; reserved keyword: {name}"
123                            ));
124                        }
125                        path.push(PathElement::Attribute(name.clone()));
126                    }
127                    Some(Token::NameRef(name)) => {
128                        path.push(PathElement::Attribute(name.clone()));
129                    }
130                    Some(t) => return Err(format!("Expected attribute name after '.', got {t}")),
131                    None => return Err("Expected attribute name after '.'".to_string()),
132                }
133            }
134            Some(Token::LBracket) => {
135                stream.next();
136                match stream.next() {
137                    Some(Token::Number(n)) => {
138                        let idx: usize = n.parse().map_err(|_| format!("Invalid index: {n}"))?;
139                        path.push(PathElement::Index(idx));
140                    }
141                    Some(t) => return Err(format!("Expected number in brackets, got {t}")),
142                    None => return Err("Expected number in brackets".to_string()),
143                }
144                stream.expect(&Token::RBracket)?;
145            }
146            _ => break,
147        }
148    }
149
150    Ok(path)
151}
152
153/// Create the appropriate default structure for the next path element.
154fn default_for_next(next: &PathElement) -> AttributeValue {
155    match next {
156        PathElement::Attribute(_) => AttributeValue::M(HashMap::new()),
157        PathElement::Index(_) => AttributeValue::L(Vec::new()),
158    }
159}
160
161/// Insert a value at the path location in the result map.
162/// For simple top-level attributes, this is a direct insert.
163/// For nested paths, we build the necessary intermediate structure.
164pub(crate) fn insert_at_path(
165    result: &mut HashMap<String, AttributeValue>,
166    path: &[PathElement],
167    value: AttributeValue,
168) {
169    if path.is_empty() {
170        return;
171    }
172
173    if path.len() == 1 {
174        if let PathElement::Attribute(name) = &path[0] {
175            result.insert(name.clone(), value);
176        }
177        return;
178    }
179
180    // For nested paths, we need the top-level attribute name
181    if let PathElement::Attribute(name) = &path[0] {
182        let entry = result
183            .entry(name.clone())
184            .or_insert_with(|| default_for_next(&path[1]));
185        insert_nested(entry, &path[1..], value);
186    }
187}
188
189fn insert_nested(current: &mut AttributeValue, path: &[PathElement], value: AttributeValue) {
190    if path.is_empty() {
191        return;
192    }
193
194    if path.len() == 1 {
195        match &path[0] {
196            PathElement::Attribute(name) => {
197                if let AttributeValue::M(map) = current {
198                    map.insert(name.clone(), value);
199                }
200            }
201            PathElement::Index(_) => {
202                if let AttributeValue::L(list) = current {
203                    list.push(value);
204                }
205            }
206        }
207        return;
208    }
209
210    match &path[0] {
211        PathElement::Attribute(name) => {
212            if let AttributeValue::M(map) = current {
213                let entry = map
214                    .entry(name.clone())
215                    .or_insert_with(|| default_for_next(&path[1]));
216                insert_nested(entry, &path[1..], value);
217            }
218        }
219        PathElement::Index(_) => {
220            if let AttributeValue::L(list) = current {
221                // Push a new element for this projected index
222                list.push(default_for_next(&path[1]));
223                let last = list.last_mut().unwrap();
224                insert_nested(last, &path[1..], value);
225            }
226        }
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    fn make_item(pairs: &[(&str, AttributeValue)]) -> HashMap<String, AttributeValue> {
235        pairs
236            .iter()
237            .map(|(k, v)| (k.to_string(), v.clone()))
238            .collect()
239    }
240
241    #[test]
242    fn test_parse_simple() {
243        let proj = parse("Title, Price, Color").unwrap();
244        assert_eq!(proj.paths.len(), 3);
245    }
246
247    #[test]
248    fn test_parse_nested() {
249        let proj = parse("ProductReviews.FiveStar").unwrap();
250        assert_eq!(proj.paths[0].len(), 2);
251        assert_eq!(
252            proj.paths[0][0],
253            PathElement::Attribute("ProductReviews".into())
254        );
255        assert_eq!(proj.paths[0][1], PathElement::Attribute("FiveStar".into()));
256    }
257
258    #[test]
259    fn test_parse_with_index() {
260        let proj = parse("RelatedItems[0]").unwrap();
261        assert_eq!(proj.paths[0].len(), 2);
262        assert_eq!(proj.paths[0][1], PathElement::Index(0));
263    }
264
265    #[test]
266    fn test_apply_simple() {
267        let proj = parse("label").unwrap();
268        let item = make_item(&[
269            ("pk", AttributeValue::S("key1".into())),
270            ("label", AttributeValue::S("Alice".into())),
271            ("age", AttributeValue::N("30".into())),
272        ]);
273        let no_names = None;
274        let no_values = None;
275        let tracker = TrackedExpressionAttributes::new(&no_names, &no_values);
276        let result = apply(&item, &proj, &tracker, &["pk".to_string()]).unwrap();
277        assert!(result.contains_key("pk")); // Always included
278        assert!(result.contains_key("label")); // Projected
279        assert!(!result.contains_key("age")); // Not projected
280    }
281
282    #[test]
283    fn test_apply_nested() {
284        let mut nested = HashMap::new();
285        nested.insert("nested_val".to_string(), AttributeValue::S("value".into()));
286        nested.insert("extra".to_string(), AttributeValue::S("skip".into()));
287
288        let proj = parse("payload.nested_val").unwrap();
289        let item = make_item(&[
290            ("pk", AttributeValue::S("key1".into())),
291            ("payload", AttributeValue::M(nested)),
292        ]);
293        let no_names = None;
294        let no_values = None;
295        let tracker = TrackedExpressionAttributes::new(&no_names, &no_values);
296        let result = apply(&item, &proj, &tracker, &["pk".to_string()]).unwrap();
297        assert!(result.contains_key("payload"));
298        if let AttributeValue::M(map) = &result["payload"] {
299            assert!(map.contains_key("nested_val"));
300            assert!(!map.contains_key("extra"));
301        } else {
302            panic!("Expected map");
303        }
304    }
305
306    #[test]
307    fn test_apply_with_name_refs() {
308        let proj = parse("#n").unwrap();
309        let item = make_item(&[
310            ("pk", AttributeValue::S("key1".into())),
311            ("name", AttributeValue::S("Alice".into())),
312        ]);
313        let names = Some(HashMap::from([("#n".to_string(), "name".to_string())]));
314        let no_values = None;
315        let tracker = TrackedExpressionAttributes::new(&names, &no_values);
316        let result = apply(&item, &proj, &tracker, &["pk".to_string()]).unwrap();
317        assert!(result.contains_key("name"));
318    }
319
320    #[test]
321    fn test_apply_missing_attribute() {
322        let proj = parse("nonexistent").unwrap();
323        let item = make_item(&[("pk", AttributeValue::S("key1".into()))]);
324        let no_names = None;
325        let no_values = None;
326        let tracker = TrackedExpressionAttributes::new(&no_names, &no_values);
327        let result = apply(&item, &proj, &tracker, &["pk".to_string()]).unwrap();
328        assert!(!result.contains_key("nonexistent"));
329        assert!(result.contains_key("pk")); // Key always present
330    }
331}