Skip to main content

dynoxide/expressions/
key_condition.rs

1//! KeyConditionExpression parsing.
2//!
3//! KeyConditionExpression supports: `pk = :val [AND sk_condition]`
4//! Sort key conditions: `=`, `<`, `<=`, `>`, `>=`, `BETWEEN ... AND ...`, `begins_with(sk, :prefix)`
5
6use crate::expressions::condition::parse_raw_path;
7use crate::expressions::tokenizer::{
8    Token, TokenSpan, TokenStream, check_redundant_parens, tokenize,
9};
10use crate::expressions::{PathElement, TrackedExpressionAttributes};
11use crate::types::AttributeValue;
12
13/// Parsed key condition.
14#[derive(Debug)]
15pub struct KeyCondition {
16    /// Partition key attribute name (resolved).
17    pub pk_name: String,
18    /// Partition key value reference (e.g. `:pk`).
19    pub pk_value_ref: String,
20    /// Optional sort key condition.
21    pub sk_condition: Option<SortKeyCondition>,
22}
23
24/// Sort key condition variants.
25#[derive(Debug)]
26pub enum SortKeyCondition {
27    Eq(String, String), // (sk_name, value_ref)
28    Lt(String, String),
29    Le(String, String),
30    Gt(String, String),
31    Ge(String, String),
32    Between(String, String, String), // (sk_name, lo_ref, hi_ref)
33    BeginsWith(String, String),      // (sk_name, prefix_ref)
34}
35
36/// Parse a KeyConditionExpression string, tracking attribute name usage.
37///
38/// Supports optional parentheses around individual conditions and around
39/// the entire expression, matching DynamoDB behavior.
40pub fn parse(expr: &str, tracker: &TrackedExpressionAttributes) -> Result<KeyCondition, String> {
41    let tokens = tokenize(expr).map_err(|e| format!("Invalid KeyConditionExpression: {e}"))?;
42    // Reject redundant parens before stripping outer ones (strip_outer_parens
43    // would otherwise silently accept `((pk = :pk))`).
44    check_redundant_parens(&tokens).map_err(|e| format!("Invalid KeyConditionExpression: {e}"))?;
45    let tokens = strip_outer_parens(tokens);
46    let mut stream = TokenStream::new(tokens);
47
48    let cond1 = parse_single_condition(&mut stream, tracker)?;
49
50    let (pk_cond, sk_cond) = if matches!(stream.peek(), Some(Token::And)) {
51        stream.next();
52        let cond2 = parse_single_condition(&mut stream, tracker)?;
53        match (cond1, cond2) {
54            (ParsedCond::Eq(n1, v1), c2) => ((n1, v1), Some(c2)),
55            (c1, ParsedCond::Eq(n2, v2)) => ((n2, v2), Some(c1)),
56            _ => {
57                return Err(
58                    "Invalid KeyConditionExpression: partition key must use equality".to_string(),
59                );
60            }
61        }
62    } else {
63        match cond1 {
64            ParsedCond::Eq(name, val_ref) => ((name, val_ref), None),
65            _ => {
66                return Err(
67                    "Invalid KeyConditionExpression: partition key must use equality".to_string(),
68                );
69            }
70        }
71    };
72
73    if !stream.at_end() {
74        return Err(format!(
75            "Unexpected token in KeyConditionExpression: {}",
76            stream.peek().unwrap()
77        ));
78    }
79
80    let (pk_name, pk_value_ref) = pk_cond;
81    let sk_condition = sk_cond.map(|c| c.into_sk_condition()).transpose()?;
82
83    Ok(KeyCondition {
84        pk_name,
85        pk_value_ref,
86        sk_condition,
87    })
88}
89
90/// Resolve the actual attribute values from the parsed key condition, tracking usage.
91pub fn resolve_values(
92    condition: &KeyCondition,
93    tracker: &TrackedExpressionAttributes,
94) -> Result<ResolvedKeyCondition, String> {
95    let pk_val = tracker.resolve_value(&condition.pk_value_ref)?.clone();
96
97    let sk = if let Some(ref sk_cond) = condition.sk_condition {
98        Some(resolve_sk_condition(sk_cond, tracker)?)
99    } else {
100        None
101    };
102
103    Ok(ResolvedKeyCondition {
104        pk_name: condition.pk_name.clone(),
105        pk_value: pk_val,
106        sk_condition: sk,
107    })
108}
109
110/// Resolved key condition with actual values.
111#[derive(Debug)]
112pub struct ResolvedKeyCondition {
113    pub pk_name: String,
114    pub pk_value: AttributeValue,
115    pub sk_condition: Option<ResolvedSortKeyCondition>,
116}
117
118#[derive(Debug)]
119pub enum ResolvedSortKeyCondition {
120    Eq(String, AttributeValue),
121    Lt(String, AttributeValue),
122    Le(String, AttributeValue),
123    Gt(String, AttributeValue),
124    Ge(String, AttributeValue),
125    Between(String, AttributeValue, AttributeValue),
126    BeginsWith(String, AttributeValue),
127}
128
129impl ResolvedSortKeyCondition {
130    pub fn sk_name(&self) -> &str {
131        match self {
132            Self::Eq(n, _)
133            | Self::Lt(n, _)
134            | Self::Le(n, _)
135            | Self::Gt(n, _)
136            | Self::Ge(n, _)
137            | Self::Between(n, _, _)
138            | Self::BeginsWith(n, _) => n,
139        }
140    }
141
142    /// Convert to SQL WHERE clause components for sk column.
143    /// Returns (operator, value_string) pairs.
144    /// For BETWEEN, returns two conditions.
145    pub fn to_sql_conditions(&self) -> Vec<(String, String)> {
146        match self {
147            Self::Eq(_, v) => vec![("=".into(), val_to_key_string(v))],
148            Self::Lt(_, v) => vec![("<".into(), val_to_key_string(v))],
149            Self::Le(_, v) => vec![("<=".into(), val_to_key_string(v))],
150            Self::Gt(_, v) => vec![(">".into(), val_to_key_string(v))],
151            Self::Ge(_, v) => vec![(">=".into(), val_to_key_string(v))],
152            Self::Between(_, lo, hi) => vec![
153                (">=".into(), val_to_key_string(lo)),
154                ("<=".into(), val_to_key_string(hi)),
155            ],
156            Self::BeginsWith(_, prefix) => {
157                let prefix_str = val_to_key_string(prefix);
158                // Escape LIKE wildcards in the prefix value before appending %
159                let escaped = prefix_str
160                    .replace('\\', "\\\\")
161                    .replace('%', "\\%")
162                    .replace('_', "\\_");
163                vec![("LIKE".into(), format!("{escaped}%"))]
164            }
165        }
166    }
167}
168
169fn val_to_key_string(val: &AttributeValue) -> String {
170    val.to_key_string().unwrap_or_default()
171}
172
173fn resolve_sk_condition(
174    cond: &SortKeyCondition,
175    tracker: &TrackedExpressionAttributes,
176) -> Result<ResolvedSortKeyCondition, String> {
177    match cond {
178        SortKeyCondition::Eq(sk, vr) => {
179            let v = tracker.resolve_value(vr)?.clone();
180            Ok(ResolvedSortKeyCondition::Eq(sk.clone(), v))
181        }
182        SortKeyCondition::Lt(sk, vr) => {
183            let v = tracker.resolve_value(vr)?.clone();
184            Ok(ResolvedSortKeyCondition::Lt(sk.clone(), v))
185        }
186        SortKeyCondition::Le(sk, vr) => {
187            let v = tracker.resolve_value(vr)?.clone();
188            Ok(ResolvedSortKeyCondition::Le(sk.clone(), v))
189        }
190        SortKeyCondition::Gt(sk, vr) => {
191            let v = tracker.resolve_value(vr)?.clone();
192            Ok(ResolvedSortKeyCondition::Gt(sk.clone(), v))
193        }
194        SortKeyCondition::Ge(sk, vr) => {
195            let v = tracker.resolve_value(vr)?.clone();
196            Ok(ResolvedSortKeyCondition::Ge(sk.clone(), v))
197        }
198        SortKeyCondition::Between(sk, lo_ref, hi_ref) => {
199            let lo = tracker.resolve_value(lo_ref)?.clone();
200            let hi = tracker.resolve_value(hi_ref)?.clone();
201            // Validate same type
202            if std::mem::discriminant(&lo) != std::mem::discriminant(&hi) {
203                return Err(format!(
204                    "Invalid KeyConditionExpression: The BETWEEN operator requires same data type \
205                     for lower and upper bounds; lower bound operand: AttributeValue: {{{}}}, \
206                     upper bound operand: AttributeValue: {{{}}}",
207                    format_attr_value_short(&lo),
208                    format_attr_value_short(&hi)
209                ));
210            }
211            // Validate ordering (upper >= lower)
212            if !between_order_valid(&lo, &hi) {
213                return Err(format!(
214                    "Invalid KeyConditionExpression: The BETWEEN operator requires upper bound \
215                     to be greater than or equal to lower bound; lower bound operand: \
216                     AttributeValue: {{{}}}, upper bound operand: AttributeValue: {{{}}}",
217                    format_attr_value_short(&lo),
218                    format_attr_value_short(&hi)
219                ));
220            }
221            Ok(ResolvedSortKeyCondition::Between(sk.clone(), lo, hi))
222        }
223        SortKeyCondition::BeginsWith(sk, vr) => {
224            let v = tracker.resolve_value(vr)?.clone();
225            Ok(ResolvedSortKeyCondition::BeginsWith(sk.clone(), v))
226        }
227    }
228}
229
230// ---------------------------------------------------------------------------
231// Internal parsing helpers
232// ---------------------------------------------------------------------------
233
234#[derive(Debug)]
235enum ParsedCond {
236    Eq(String, String), // (attr_name, value_ref)
237    Lt(String, String),
238    Le(String, String),
239    Gt(String, String),
240    Ge(String, String),
241    Between(String, String, String), // (attr_name, lo_ref, hi_ref)
242    BeginsWith(String, String),      // (attr_name, prefix_ref)
243}
244
245impl ParsedCond {
246    fn into_sk_condition(self) -> Result<SortKeyCondition, String> {
247        match self {
248            ParsedCond::Eq(n, v) => Ok(SortKeyCondition::Eq(n, v)),
249            ParsedCond::Lt(n, v) => Ok(SortKeyCondition::Lt(n, v)),
250            ParsedCond::Le(n, v) => Ok(SortKeyCondition::Le(n, v)),
251            ParsedCond::Gt(n, v) => Ok(SortKeyCondition::Gt(n, v)),
252            ParsedCond::Ge(n, v) => Ok(SortKeyCondition::Ge(n, v)),
253            ParsedCond::Between(n, lo, hi) => Ok(SortKeyCondition::Between(n, lo, hi)),
254            ParsedCond::BeginsWith(n, v) => Ok(SortKeyCondition::BeginsWith(n, v)),
255        }
256    }
257}
258
259/// Strip balanced outer parentheses from a token list.
260/// `(pk = :pk AND sk = :sk)` → `pk = :pk AND sk = :sk`
261/// `((pk = :pk))` → `pk = :pk` (applied repeatedly)
262/// `(pk = :pk) AND (sk = :sk)` → unchanged (closing paren is not at the end)
263fn strip_outer_parens(mut tokens: Vec<(Token, TokenSpan)>) -> Vec<(Token, TokenSpan)> {
264    loop {
265        if tokens.len() < 2 {
266            break;
267        }
268        if !matches!(tokens.first().map(|(t, _)| t), Some(Token::LParen)) {
269            break;
270        }
271        // Walk forward, tracking paren depth, to see if the opening paren's
272        // match is the very last token.
273        let mut depth = 0;
274        let mut close_pos = None;
275        for (i, (tok, _)) in tokens.iter().enumerate() {
276            match tok {
277                Token::LParen => depth += 1,
278                Token::RParen => {
279                    depth -= 1;
280                    if depth == 0 {
281                        close_pos = Some(i);
282                        break;
283                    }
284                }
285                _ => {}
286            }
287        }
288        if close_pos == Some(tokens.len() - 1) {
289            // The outermost parens wrap the entire expression — strip them.
290            tokens.remove(tokens.len() - 1);
291            tokens.remove(0);
292        } else {
293            break;
294        }
295    }
296    tokens
297}
298
299fn parse_single_condition(
300    stream: &mut TokenStream,
301    tracker: &TrackedExpressionAttributes,
302) -> Result<ParsedCond, String> {
303    // Count and skip optional wrapping parentheses: `(pk = :pk)`, `((pk = :pk))`
304    let mut parens = 0;
305    while matches!(stream.peek(), Some(Token::LParen)) {
306        stream.next();
307        parens += 1;
308    }
309
310    // Check for begins_with function
311    if let Some(Token::Identifier(name)) = stream.peek() {
312        if name.to_lowercase() == "begins_with" {
313            stream.next();
314            stream.expect(&Token::LParen)?;
315            let path = parse_raw_path(stream)?;
316            let attr_name = resolve_path_to_name(&path, tracker)?;
317            stream.expect(&Token::Comma)?;
318            let val_ref = expect_value_ref(stream)?;
319            stream.expect(&Token::RParen)?;
320            consume_close_parens(stream, parens)?;
321            return Ok(ParsedCond::BeginsWith(attr_name, val_ref));
322        }
323    }
324
325    // Reversed operand order (:val op #attr). AWS accepts the value on the left
326    // for the comparators and treats it as the attr-on-left form (`:lo <= #sk`
327    // is `#sk >= :lo`). begins_with keeps the key first and is handled above.
328    if matches!(stream.peek(), Some(Token::ValueRef(_))) {
329        let val_ref = expect_value_ref(stream)?;
330        // Match here rather than bind, to release the stream borrow before the path parse.
331        let build: Option<fn(String, String) -> ParsedCond> = match stream.next() {
332            Some(Token::Eq) => Some(ParsedCond::Eq),
333            Some(Token::Lt) => Some(ParsedCond::Gt),
334            Some(Token::Le) => Some(ParsedCond::Ge),
335            Some(Token::Gt) => Some(ParsedCond::Lt),
336            Some(Token::Ge) => Some(ParsedCond::Le),
337            _ => None,
338        };
339        let build = build.ok_or_else(|| {
340            "Invalid KeyConditionExpression: unsupported operator with a value on the left"
341                .to_string()
342        })?;
343        let path = parse_raw_path(stream)?;
344        let attr_name = resolve_path_to_name(&path, tracker)?;
345        consume_close_parens(stream, parens)?;
346        return Ok(build(attr_name, val_ref));
347    }
348
349    // attr op :val
350    let path = parse_raw_path(stream)?;
351    let attr_name = resolve_path_to_name(&path, tracker)?;
352
353    let result = match stream.next() {
354        Some(Token::Eq) => {
355            let val_ref = expect_value_ref(stream)?;
356            Ok(ParsedCond::Eq(attr_name, val_ref))
357        }
358        Some(Token::Lt) => {
359            let val_ref = expect_value_ref(stream)?;
360            Ok(ParsedCond::Lt(attr_name, val_ref))
361        }
362        Some(Token::Le) => {
363            let val_ref = expect_value_ref(stream)?;
364            Ok(ParsedCond::Le(attr_name, val_ref))
365        }
366        Some(Token::Gt) => {
367            let val_ref = expect_value_ref(stream)?;
368            Ok(ParsedCond::Gt(attr_name, val_ref))
369        }
370        Some(Token::Ge) => {
371            let val_ref = expect_value_ref(stream)?;
372            Ok(ParsedCond::Ge(attr_name, val_ref))
373        }
374        Some(Token::Between) => {
375            let lo_ref = expect_value_ref(stream)?;
376            stream.expect(&Token::And)?;
377            let hi_ref = expect_value_ref(stream)?;
378            Ok(ParsedCond::Between(attr_name, lo_ref, hi_ref))
379        }
380        Some(t) => Err(format!(
381            "Unexpected operator in KeyConditionExpression: {t}"
382        )),
383        None => Err("Unexpected end of KeyConditionExpression".to_string()),
384    };
385
386    consume_close_parens(stream, parens)?;
387    result
388}
389
390/// Consume exactly `count` closing parentheses from the stream.
391fn consume_close_parens(stream: &mut TokenStream, count: usize) -> Result<(), String> {
392    for _ in 0..count {
393        match stream.next() {
394            Some(Token::RParen) => {}
395            Some(t) => {
396                return Err(format!(
397                    "Expected closing parenthesis in KeyConditionExpression, got {t}"
398                ));
399            }
400            None => {
401                return Err(
402                    "Unexpected end of KeyConditionExpression, expected closing parenthesis"
403                        .to_string(),
404                );
405            }
406        }
407    }
408    Ok(())
409}
410
411fn resolve_path_to_name(
412    path: &[PathElement],
413    tracker: &TrackedExpressionAttributes,
414) -> Result<String, String> {
415    if path.len() != 1 {
416        // A dotted or indexed document path on a key attribute.
417        return Err(
418            "Invalid KeyConditionExpression: KeyConditionExpressions cannot have conditions on nested attributes"
419                .to_string(),
420        );
421    }
422    match &path[0] {
423        PathElement::Attribute(name) => {
424            if name.starts_with('#') {
425                tracker.resolve_name(name)
426            } else {
427                Ok(name.clone())
428            }
429        }
430        PathElement::Index(_) => Err("KeyConditionExpression cannot use index paths".to_string()),
431    }
432}
433
434/// Format an attribute value for error messages (DynamoDB short format).
435fn format_attr_value_short(val: &AttributeValue) -> String {
436    match val {
437        AttributeValue::S(s) => format!("S:{s}"),
438        AttributeValue::N(n) => format!("N:{n}"),
439        AttributeValue::B(b) => {
440            use base64::Engine;
441            let encoded = base64::engine::general_purpose::STANDARD.encode(b);
442            format!("B:{encoded}")
443        }
444        AttributeValue::BOOL(b) => format!("BOOL:{b}"),
445        AttributeValue::NULL(_) => "NULL:true".to_string(),
446        AttributeValue::SS(set) => format!("SS:{:?}", set),
447        AttributeValue::NS(set) => format!("NS:{:?}", set),
448        AttributeValue::BS(_) => "BS:[...]".to_string(),
449        AttributeValue::L(_) => "L:[...]".to_string(),
450        AttributeValue::M(_) => "M:{...}".to_string(),
451    }
452}
453
454/// Check if BETWEEN bounds are in valid order (lo <= hi).
455fn between_order_valid(lo: &AttributeValue, hi: &AttributeValue) -> bool {
456    match (lo, hi) {
457        (AttributeValue::S(a), AttributeValue::S(b)) => a <= b,
458        (AttributeValue::N(a), AttributeValue::N(b)) => {
459            let a_f = a.parse::<f64>().unwrap_or(0.0);
460            let b_f = b.parse::<f64>().unwrap_or(0.0);
461            a_f <= b_f
462        }
463        (AttributeValue::B(a), AttributeValue::B(b)) => a <= b,
464        _ => true,
465    }
466}
467
468fn expect_value_ref(stream: &mut TokenStream) -> Result<String, String> {
469    match stream.next() {
470        Some(Token::ValueRef(name)) => Ok(name.clone()),
471        Some(t) => Err(format!("Expected value reference (:name), got {t}")),
472        None => Err("Expected value reference, got end of expression".to_string()),
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use std::collections::HashMap;
480
481    fn make_tracker<'a>(
482        names: &'a Option<HashMap<String, String>>,
483        values: &'a Option<HashMap<String, AttributeValue>>,
484    ) -> TrackedExpressionAttributes<'a> {
485        TrackedExpressionAttributes::new(names, values)
486    }
487
488    #[test]
489    fn accepts_reversed_operand_sort_key() {
490        let names = Some(HashMap::from([
491            ("#pk".to_string(), "pk".to_string()),
492            ("#sk".to_string(), "sk".to_string()),
493        ]));
494        let values = Some(HashMap::from([
495            (":pk".to_string(), AttributeValue::S("x".into())),
496            (":lo".to_string(), AttributeValue::S("a".into())),
497        ]));
498        let tracker = make_tracker(&names, &values);
499        // Each reversed comparator maps to its attribute-on-left equivalent.
500        for (op, expected) in [("<", "Gt"), ("<=", "Ge"), (">", "Lt"), (">=", "Le")] {
501            let expr = format!("#pk = :pk AND :lo {op} #sk");
502            let kc = parse(&expr, &tracker).unwrap();
503            let tag = match &kc.sk_condition {
504                Some(SortKeyCondition::Gt(n, v)) if n == "sk" && v == ":lo" => "Gt",
505                Some(SortKeyCondition::Ge(n, v)) if n == "sk" && v == ":lo" => "Ge",
506                Some(SortKeyCondition::Lt(n, v)) if n == "sk" && v == ":lo" => "Lt",
507                Some(SortKeyCondition::Le(n, v)) if n == "sk" && v == ":lo" => "Le",
508                _ => "other",
509            };
510            assert_eq!(tag, expected, "reversed {op} should flip to {expected}");
511        }
512    }
513
514    #[test]
515    fn accepts_reversed_equality_on_sort_key() {
516        let names = Some(HashMap::from([
517            ("#pk".to_string(), "pk".to_string()),
518            ("#sk".to_string(), "sk".to_string()),
519        ]));
520        let values = Some(HashMap::from([
521            (":pk".to_string(), AttributeValue::S("x".into())),
522            (":sk".to_string(), AttributeValue::S("m".into())),
523        ]));
524        let tracker = make_tracker(&names, &values);
525        let kc = parse("#pk = :pk AND :sk = #sk", &tracker).unwrap();
526        assert!(
527            matches!(&kc.sk_condition, Some(SortKeyCondition::Eq(n, v)) if n == "sk" && v == ":sk")
528        );
529    }
530
531    #[test]
532    fn rejects_reversed_operand_with_nested_key_path() {
533        let names = Some(HashMap::from([
534            ("#pk".to_string(), "pk".to_string()),
535            ("#sk".to_string(), "sk".to_string()),
536        ]));
537        let values = Some(HashMap::from([
538            (":pk".to_string(), AttributeValue::S("x".into())),
539            (":lo".to_string(), AttributeValue::S("a".into())),
540        ]));
541        let tracker = make_tracker(&names, &values);
542        let err = parse("#pk = :pk AND :lo <= #sk.foo", &tracker).unwrap_err();
543        assert_eq!(
544            err,
545            "Invalid KeyConditionExpression: KeyConditionExpressions cannot have conditions on nested attributes"
546        );
547    }
548
549    #[test]
550    fn rejects_nested_path_on_key() {
551        let names = Some(HashMap::from([
552            ("#pk".to_string(), "pk".to_string()),
553            ("#sk".to_string(), "sk".to_string()),
554        ]));
555        let values = Some(HashMap::from([
556            (":pk".to_string(), AttributeValue::S("x".into())),
557            (":v".to_string(), AttributeValue::S("y".into())),
558        ]));
559        let tracker = make_tracker(&names, &values);
560        let err = parse("#pk = :pk AND #sk.foo = :v", &tracker).unwrap_err();
561        assert_eq!(
562            err,
563            "Invalid KeyConditionExpression: KeyConditionExpressions cannot have conditions on nested attributes"
564        );
565    }
566
567    #[test]
568    fn test_pk_only() {
569        let no_names = None;
570        let no_values = None;
571        let tracker = make_tracker(&no_names, &no_values);
572        let kc = parse("pk = :pk", &tracker).unwrap();
573        assert_eq!(kc.pk_name, "pk");
574        assert_eq!(kc.pk_value_ref, ":pk");
575        assert!(kc.sk_condition.is_none());
576    }
577
578    #[test]
579    fn test_pk_and_sk_eq() {
580        let no_names = None;
581        let no_values = None;
582        let tracker = make_tracker(&no_names, &no_values);
583        let kc = parse("pk = :pk AND sk = :sk", &tracker).unwrap();
584        assert_eq!(kc.pk_name, "pk");
585        assert!(matches!(kc.sk_condition, Some(SortKeyCondition::Eq(_, _))));
586    }
587
588    #[test]
589    fn test_pk_and_sk_between() {
590        let no_names = None;
591        let no_values = None;
592        let tracker = make_tracker(&no_names, &no_values);
593        let kc = parse("pk = :pk AND sk BETWEEN :lo AND :hi", &tracker).unwrap();
594        assert!(matches!(
595            kc.sk_condition,
596            Some(SortKeyCondition::Between(_, _, _))
597        ));
598    }
599
600    #[test]
601    fn test_pk_and_begins_with() {
602        let no_names = None;
603        let no_values = None;
604        let tracker = make_tracker(&no_names, &no_values);
605        let kc = parse("pk = :pk AND begins_with(sk, :prefix)", &tracker).unwrap();
606        assert!(matches!(
607            kc.sk_condition,
608            Some(SortKeyCondition::BeginsWith(_, _))
609        ));
610    }
611
612    #[test]
613    fn test_with_attribute_names() {
614        let an = Some(HashMap::from([
615            ("#pk".to_string(), "partitionKey".to_string()),
616            ("#sk".to_string(), "sortKey".to_string()),
617        ]));
618        let no_values = None;
619        let tracker = make_tracker(&an, &no_values);
620        let kc = parse("#pk = :pk AND #sk > :sk", &tracker).unwrap();
621        assert_eq!(kc.pk_name, "partitionKey");
622        assert!(matches!(kc.sk_condition, Some(SortKeyCondition::Gt(ref n, _)) if n == "sortKey"));
623    }
624
625    #[test]
626    fn test_resolve_values() {
627        let no_names = None;
628        let no_values = None;
629        let parse_tracker = make_tracker(&no_names, &no_values);
630        let kc = parse("pk = :pk AND sk >= :sk", &parse_tracker).unwrap();
631        let av = Some(HashMap::from([
632            (":pk".to_string(), AttributeValue::S("user#1".into())),
633            (":sk".to_string(), AttributeValue::S("2024-01-01".into())),
634        ]));
635        let resolve_tracker = make_tracker(&no_names, &av);
636        let resolved = resolve_values(&kc, &resolve_tracker).unwrap();
637        assert_eq!(resolved.pk_value, AttributeValue::S("user#1".into()));
638        assert!(matches!(
639            resolved.sk_condition,
640            Some(ResolvedSortKeyCondition::Ge(_, _))
641        ));
642    }
643
644    #[test]
645    fn test_parenthesized_conditions() {
646        let no_names = None;
647        let no_values = None;
648
649        // Parens around each condition
650        let tracker = make_tracker(&no_names, &no_values);
651        let kc = parse("(pk = :pk) AND (sk = :sk)", &tracker).unwrap();
652        assert_eq!(kc.pk_name, "pk");
653        assert!(matches!(kc.sk_condition, Some(SortKeyCondition::Eq(_, _))));
654
655        // Parens around entire expression
656        let tracker = make_tracker(&no_names, &no_values);
657        let kc = parse("(pk = :pk AND sk = :sk)", &tracker).unwrap();
658        assert_eq!(kc.pk_name, "pk");
659        assert!(matches!(kc.sk_condition, Some(SortKeyCondition::Eq(_, _))));
660
661        // Genuinely nested (non-redundant) parens are accepted.
662        let tracker = make_tracker(&no_names, &no_values);
663        let kc = parse("(pk = :pk AND (sk > :sk))", &tracker).unwrap();
664        assert_eq!(kc.pk_name, "pk");
665        assert!(matches!(kc.sk_condition, Some(SortKeyCondition::Gt(_, _))));
666
667        // Redundant parentheses are rejected, matching real DynamoDB. Dynoxide
668        // previously stripped them and silently accepted.
669        let tracker = make_tracker(&no_names, &no_values);
670        let err = parse("((pk = :pk)) AND ((sk > :sk))", &tracker).unwrap_err();
671        assert!(
672            err.contains("redundant parentheses"),
673            "expected redundant-parentheses rejection, got: {err}"
674        );
675
676        // Parens around begins_with
677        let tracker = make_tracker(&no_names, &no_values);
678        let kc = parse("(pk = :pk) AND (begins_with(sk, :prefix))", &tracker).unwrap();
679        assert!(matches!(
680            kc.sk_condition,
681            Some(SortKeyCondition::BeginsWith(_, _))
682        ));
683
684        // Parens with attribute name references
685        let an = Some(HashMap::from([
686            ("#pk".to_string(), "PK".to_string()),
687            ("#sk".to_string(), "SK".to_string()),
688        ]));
689        let tracker = make_tracker(&an, &no_values);
690        let kc = parse("(#pk = :pk) AND (#sk = :sk)", &tracker).unwrap();
691        assert_eq!(kc.pk_name, "PK");
692    }
693
694    #[test]
695    fn test_sk_comparisons() {
696        let no_names = None;
697        let no_values = None;
698        for (op, variant) in [("<", "Lt"), ("<=", "Le"), (">", "Gt"), (">=", "Ge")] {
699            let tracker = make_tracker(&no_names, &no_values);
700            let kc = parse(&format!("pk = :pk AND sk {op} :sk"), &tracker).unwrap();
701            let sk = kc.sk_condition.unwrap();
702            let name = match &sk {
703                SortKeyCondition::Lt(n, _) => format!("Lt:{n}"),
704                SortKeyCondition::Le(n, _) => format!("Le:{n}"),
705                SortKeyCondition::Gt(n, _) => format!("Gt:{n}"),
706                SortKeyCondition::Ge(n, _) => format!("Ge:{n}"),
707                _ => "other".to_string(),
708            };
709            assert!(name.starts_with(variant), "Expected {variant}, got {name}");
710        }
711    }
712}