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