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
176/// Assemble resolved sort key conditions into a SQL fragment over the sk
177/// column, together with the parameter values in placeholder order. The
178/// partition key occupies ?1, so sk placeholders are numbered from ?2.
179/// Returns None for the fragment when there are no conditions.
180pub fn sk_conditions_to_sql(
181    conditions: &[ResolvedSortKeyCondition],
182) -> (Option<String>, Vec<String>) {
183    let mut sql_parts = Vec::new();
184    let mut param_values = Vec::new();
185    for cond in conditions {
186        for (op, val) in cond.to_sql_conditions() {
187            let param_idx = param_values.len() + 2;
188            if op == "LIKE" {
189                sql_parts.push(format!("AND sk LIKE ?{param_idx} ESCAPE '\\'"));
190            } else {
191                sql_parts.push(format!("AND sk {op} ?{param_idx}"));
192            }
193            param_values.push(val);
194        }
195    }
196    let sql = if sql_parts.is_empty() {
197        None
198    } else {
199        Some(sql_parts.join(" "))
200    };
201    (sql, param_values)
202}
203
204fn val_to_key_string(val: &AttributeValue) -> String {
205    val.to_key_string().unwrap_or_default()
206}
207
208fn resolve_sk_condition(
209    cond: &SortKeyCondition,
210    tracker: &TrackedExpressionAttributes,
211) -> Result<ResolvedSortKeyCondition, String> {
212    match cond {
213        SortKeyCondition::Eq(sk, vr) => {
214            let v = tracker.resolve_value(vr)?.clone();
215            Ok(ResolvedSortKeyCondition::Eq(sk.clone(), v))
216        }
217        SortKeyCondition::Lt(sk, vr) => {
218            let v = tracker.resolve_value(vr)?.clone();
219            Ok(ResolvedSortKeyCondition::Lt(sk.clone(), v))
220        }
221        SortKeyCondition::Le(sk, vr) => {
222            let v = tracker.resolve_value(vr)?.clone();
223            Ok(ResolvedSortKeyCondition::Le(sk.clone(), v))
224        }
225        SortKeyCondition::Gt(sk, vr) => {
226            let v = tracker.resolve_value(vr)?.clone();
227            Ok(ResolvedSortKeyCondition::Gt(sk.clone(), v))
228        }
229        SortKeyCondition::Ge(sk, vr) => {
230            let v = tracker.resolve_value(vr)?.clone();
231            Ok(ResolvedSortKeyCondition::Ge(sk.clone(), v))
232        }
233        SortKeyCondition::Between(sk, lo_ref, hi_ref) => {
234            let lo = tracker.resolve_value(lo_ref)?.clone();
235            let hi = tracker.resolve_value(hi_ref)?.clone();
236            // Validate same type
237            if std::mem::discriminant(&lo) != std::mem::discriminant(&hi) {
238                return Err(format!(
239                    "Invalid KeyConditionExpression: The BETWEEN operator requires same data type \
240                     for lower and upper bounds; lower bound operand: AttributeValue: {{{}}}, \
241                     upper bound operand: AttributeValue: {{{}}}",
242                    format_attr_value_short(&lo),
243                    format_attr_value_short(&hi)
244                ));
245            }
246            // Validate ordering (upper >= lower)
247            if !between_order_valid(&lo, &hi) {
248                return Err(format!(
249                    "Invalid KeyConditionExpression: The BETWEEN operator requires upper bound \
250                     to be greater than or equal to lower bound; lower bound operand: \
251                     AttributeValue: {{{}}}, upper bound operand: AttributeValue: {{{}}}",
252                    format_attr_value_short(&lo),
253                    format_attr_value_short(&hi)
254                ));
255            }
256            Ok(ResolvedSortKeyCondition::Between(sk.clone(), lo, hi))
257        }
258        SortKeyCondition::BeginsWith(sk, vr) => {
259            let v = tracker.resolve_value(vr)?.clone();
260            Ok(ResolvedSortKeyCondition::BeginsWith(sk.clone(), v))
261        }
262    }
263}
264
265// ---------------------------------------------------------------------------
266// Internal parsing helpers
267// ---------------------------------------------------------------------------
268
269#[derive(Debug)]
270enum ParsedCond {
271    Eq(String, String), // (attr_name, value_ref)
272    Lt(String, String),
273    Le(String, String),
274    Gt(String, String),
275    Ge(String, String),
276    Between(String, String, String), // (attr_name, lo_ref, hi_ref)
277    BeginsWith(String, String),      // (attr_name, prefix_ref)
278}
279
280impl ParsedCond {
281    fn into_sk_condition(self) -> Result<SortKeyCondition, String> {
282        match self {
283            ParsedCond::Eq(n, v) => Ok(SortKeyCondition::Eq(n, v)),
284            ParsedCond::Lt(n, v) => Ok(SortKeyCondition::Lt(n, v)),
285            ParsedCond::Le(n, v) => Ok(SortKeyCondition::Le(n, v)),
286            ParsedCond::Gt(n, v) => Ok(SortKeyCondition::Gt(n, v)),
287            ParsedCond::Ge(n, v) => Ok(SortKeyCondition::Ge(n, v)),
288            ParsedCond::Between(n, lo, hi) => Ok(SortKeyCondition::Between(n, lo, hi)),
289            ParsedCond::BeginsWith(n, v) => Ok(SortKeyCondition::BeginsWith(n, v)),
290        }
291    }
292}
293
294/// Strip balanced outer parentheses from a token list.
295/// `(pk = :pk AND sk = :sk)` → `pk = :pk AND sk = :sk`
296/// `((pk = :pk))` → `pk = :pk` (applied repeatedly)
297/// `(pk = :pk) AND (sk = :sk)` → unchanged (closing paren is not at the end)
298fn strip_outer_parens(mut tokens: Vec<(Token, TokenSpan)>) -> Vec<(Token, TokenSpan)> {
299    loop {
300        if tokens.len() < 2 {
301            break;
302        }
303        if !matches!(tokens.first().map(|(t, _)| t), Some(Token::LParen)) {
304            break;
305        }
306        // Walk forward, tracking paren depth, to see if the opening paren's
307        // match is the very last token.
308        let mut depth = 0;
309        let mut close_pos = None;
310        for (i, (tok, _)) in tokens.iter().enumerate() {
311            match tok {
312                Token::LParen => depth += 1,
313                Token::RParen => {
314                    depth -= 1;
315                    if depth == 0 {
316                        close_pos = Some(i);
317                        break;
318                    }
319                }
320                _ => {}
321            }
322        }
323        if close_pos == Some(tokens.len() - 1) {
324            // The outermost parens wrap the entire expression — strip them.
325            tokens.remove(tokens.len() - 1);
326            tokens.remove(0);
327        } else {
328            break;
329        }
330    }
331    tokens
332}
333
334fn parse_single_condition(
335    stream: &mut TokenStream,
336    tracker: &TrackedExpressionAttributes,
337) -> Result<ParsedCond, String> {
338    // Count and skip optional wrapping parentheses: `(pk = :pk)`, `((pk = :pk))`
339    let mut parens = 0;
340    while matches!(stream.peek(), Some(Token::LParen)) {
341        stream.next();
342        parens += 1;
343    }
344
345    // Check for begins_with function
346    if let Some(Token::Identifier(name)) = stream.peek() {
347        if name.to_lowercase() == "begins_with" {
348            stream.next();
349            stream.expect(&Token::LParen)?;
350            let path = parse_raw_path(stream)?;
351            let attr_name = resolve_path_to_name(&path, tracker)?;
352            stream.expect(&Token::Comma)?;
353            let val_ref = expect_value_ref(stream)?;
354            stream.expect(&Token::RParen)?;
355            consume_close_parens(stream, parens)?;
356            return Ok(ParsedCond::BeginsWith(attr_name, val_ref));
357        }
358    }
359
360    // Reversed operand order (:val op #attr). AWS accepts the value on the left
361    // for the comparators and treats it as the attr-on-left form (`:lo <= #sk`
362    // is `#sk >= :lo`). begins_with keeps the key first and is handled above.
363    if matches!(stream.peek(), Some(Token::ValueRef(_))) {
364        let val_ref = expect_value_ref(stream)?;
365        // Match here rather than bind, to release the stream borrow before the path parse.
366        let build: Option<fn(String, String) -> ParsedCond> = match stream.next() {
367            Some(Token::Eq) => Some(ParsedCond::Eq),
368            Some(Token::Lt) => Some(ParsedCond::Gt),
369            Some(Token::Le) => Some(ParsedCond::Ge),
370            Some(Token::Gt) => Some(ParsedCond::Lt),
371            Some(Token::Ge) => Some(ParsedCond::Le),
372            _ => None,
373        };
374        let build = build.ok_or_else(|| {
375            "Invalid KeyConditionExpression: unsupported operator with a value on the left"
376                .to_string()
377        })?;
378        let path = parse_raw_path(stream)?;
379        let attr_name = resolve_path_to_name(&path, tracker)?;
380        consume_close_parens(stream, parens)?;
381        return Ok(build(attr_name, val_ref));
382    }
383
384    // attr op :val
385    let path = parse_raw_path(stream)?;
386    let attr_name = resolve_path_to_name(&path, tracker)?;
387
388    let result = match stream.next() {
389        Some(Token::Eq) => {
390            let val_ref = expect_value_ref(stream)?;
391            Ok(ParsedCond::Eq(attr_name, val_ref))
392        }
393        Some(Token::Lt) => {
394            let val_ref = expect_value_ref(stream)?;
395            Ok(ParsedCond::Lt(attr_name, val_ref))
396        }
397        Some(Token::Le) => {
398            let val_ref = expect_value_ref(stream)?;
399            Ok(ParsedCond::Le(attr_name, val_ref))
400        }
401        Some(Token::Gt) => {
402            let val_ref = expect_value_ref(stream)?;
403            Ok(ParsedCond::Gt(attr_name, val_ref))
404        }
405        Some(Token::Ge) => {
406            let val_ref = expect_value_ref(stream)?;
407            Ok(ParsedCond::Ge(attr_name, val_ref))
408        }
409        Some(Token::Between) => {
410            let lo_ref = expect_value_ref(stream)?;
411            stream.expect(&Token::And)?;
412            let hi_ref = expect_value_ref(stream)?;
413            Ok(ParsedCond::Between(attr_name, lo_ref, hi_ref))
414        }
415        Some(t) => Err(format!(
416            "Unexpected operator in KeyConditionExpression: {t}"
417        )),
418        None => Err("Unexpected end of KeyConditionExpression".to_string()),
419    };
420
421    consume_close_parens(stream, parens)?;
422    result
423}
424
425/// Consume exactly `count` closing parentheses from the stream.
426fn consume_close_parens(stream: &mut TokenStream, count: usize) -> Result<(), String> {
427    for _ in 0..count {
428        match stream.next() {
429            Some(Token::RParen) => {}
430            Some(t) => {
431                return Err(format!(
432                    "Expected closing parenthesis in KeyConditionExpression, got {t}"
433                ));
434            }
435            None => {
436                return Err(
437                    "Unexpected end of KeyConditionExpression, expected closing parenthesis"
438                        .to_string(),
439                );
440            }
441        }
442    }
443    Ok(())
444}
445
446fn resolve_path_to_name(
447    path: &[PathElement],
448    tracker: &TrackedExpressionAttributes,
449) -> Result<String, String> {
450    if path.len() != 1 {
451        // A dotted or indexed document path on a key attribute.
452        return Err(
453            "Invalid KeyConditionExpression: KeyConditionExpressions cannot have conditions on nested attributes"
454                .to_string(),
455        );
456    }
457    match &path[0] {
458        PathElement::Attribute(name) => {
459            if name.starts_with('#') {
460                tracker.resolve_name(name)
461            } else {
462                Ok(name.clone())
463            }
464        }
465        PathElement::Index(_) => Err("KeyConditionExpression cannot use index paths".to_string()),
466    }
467}
468
469/// Format an attribute value for error messages (DynamoDB short format).
470fn format_attr_value_short(val: &AttributeValue) -> String {
471    match val {
472        AttributeValue::S(s) => format!("S:{s}"),
473        AttributeValue::N(n) => format!("N:{n}"),
474        AttributeValue::B(b) => {
475            use base64::Engine;
476            let encoded = base64::engine::general_purpose::STANDARD.encode(b);
477            format!("B:{encoded}")
478        }
479        AttributeValue::BOOL(b) => format!("BOOL:{b}"),
480        AttributeValue::NULL(_) => "NULL:true".to_string(),
481        AttributeValue::SS(set) => format!("SS:{:?}", set),
482        AttributeValue::NS(set) => format!("NS:{:?}", set),
483        AttributeValue::BS(_) => "BS:[...]".to_string(),
484        AttributeValue::L(_) => "L:[...]".to_string(),
485        AttributeValue::M(_) => "M:{...}".to_string(),
486    }
487}
488
489/// Check if BETWEEN bounds are in valid order (lo <= hi).
490fn between_order_valid(lo: &AttributeValue, hi: &AttributeValue) -> bool {
491    match (lo, hi) {
492        (AttributeValue::S(a), AttributeValue::S(b)) => a <= b,
493        (AttributeValue::N(a), AttributeValue::N(b)) => {
494            let a_f = a.parse::<f64>().unwrap_or(0.0);
495            let b_f = b.parse::<f64>().unwrap_or(0.0);
496            a_f <= b_f
497        }
498        (AttributeValue::B(a), AttributeValue::B(b)) => a <= b,
499        _ => true,
500    }
501}
502
503fn expect_value_ref(stream: &mut TokenStream) -> Result<String, String> {
504    match stream.next() {
505        Some(Token::ValueRef(name)) => Ok(name.clone()),
506        Some(t) => Err(format!("Expected value reference (:name), got {t}")),
507        None => Err("Expected value reference, got end of expression".to_string()),
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514    use std::collections::HashMap;
515
516    fn make_tracker<'a>(
517        names: &'a Option<HashMap<String, String>>,
518        values: &'a Option<HashMap<String, AttributeValue>>,
519    ) -> TrackedExpressionAttributes<'a> {
520        TrackedExpressionAttributes::new(names, values)
521    }
522
523    #[test]
524    fn accepts_reversed_operand_sort_key() {
525        let names = Some(HashMap::from([
526            ("#pk".to_string(), "pk".to_string()),
527            ("#sk".to_string(), "sk".to_string()),
528        ]));
529        let values = Some(HashMap::from([
530            (":pk".to_string(), AttributeValue::S("x".into())),
531            (":lo".to_string(), AttributeValue::S("a".into())),
532        ]));
533        let tracker = make_tracker(&names, &values);
534        // Each reversed comparator maps to its attribute-on-left equivalent.
535        for (op, expected) in [("<", "Gt"), ("<=", "Ge"), (">", "Lt"), (">=", "Le")] {
536            let expr = format!("#pk = :pk AND :lo {op} #sk");
537            let kc = parse(&expr, &tracker).unwrap();
538            let tag = match &kc.sk_condition {
539                Some(SortKeyCondition::Gt(n, v)) if n == "sk" && v == ":lo" => "Gt",
540                Some(SortKeyCondition::Ge(n, v)) if n == "sk" && v == ":lo" => "Ge",
541                Some(SortKeyCondition::Lt(n, v)) if n == "sk" && v == ":lo" => "Lt",
542                Some(SortKeyCondition::Le(n, v)) if n == "sk" && v == ":lo" => "Le",
543                _ => "other",
544            };
545            assert_eq!(tag, expected, "reversed {op} should flip to {expected}");
546        }
547    }
548
549    #[test]
550    fn accepts_reversed_equality_on_sort_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            (":sk".to_string(), AttributeValue::S("m".into())),
558        ]));
559        let tracker = make_tracker(&names, &values);
560        let kc = parse("#pk = :pk AND :sk = #sk", &tracker).unwrap();
561        assert!(
562            matches!(&kc.sk_condition, Some(SortKeyCondition::Eq(n, v)) if n == "sk" && v == ":sk")
563        );
564    }
565
566    #[test]
567    fn rejects_reversed_operand_with_nested_key_path() {
568        let names = Some(HashMap::from([
569            ("#pk".to_string(), "pk".to_string()),
570            ("#sk".to_string(), "sk".to_string()),
571        ]));
572        let values = Some(HashMap::from([
573            (":pk".to_string(), AttributeValue::S("x".into())),
574            (":lo".to_string(), AttributeValue::S("a".into())),
575        ]));
576        let tracker = make_tracker(&names, &values);
577        let err = parse("#pk = :pk AND :lo <= #sk.foo", &tracker).unwrap_err();
578        assert_eq!(
579            err,
580            "Invalid KeyConditionExpression: KeyConditionExpressions cannot have conditions on nested attributes"
581        );
582    }
583
584    #[test]
585    fn rejects_nested_path_on_key() {
586        let names = Some(HashMap::from([
587            ("#pk".to_string(), "pk".to_string()),
588            ("#sk".to_string(), "sk".to_string()),
589        ]));
590        let values = Some(HashMap::from([
591            (":pk".to_string(), AttributeValue::S("x".into())),
592            (":v".to_string(), AttributeValue::S("y".into())),
593        ]));
594        let tracker = make_tracker(&names, &values);
595        let err = parse("#pk = :pk AND #sk.foo = :v", &tracker).unwrap_err();
596        assert_eq!(
597            err,
598            "Invalid KeyConditionExpression: KeyConditionExpressions cannot have conditions on nested attributes"
599        );
600    }
601
602    #[test]
603    fn test_pk_only() {
604        let no_names = None;
605        let no_values = None;
606        let tracker = make_tracker(&no_names, &no_values);
607        let kc = parse("pk = :pk", &tracker).unwrap();
608        assert_eq!(kc.pk_name, "pk");
609        assert_eq!(kc.pk_value_ref, ":pk");
610        assert!(kc.sk_condition.is_none());
611    }
612
613    #[test]
614    fn test_pk_and_sk_eq() {
615        let no_names = None;
616        let no_values = None;
617        let tracker = make_tracker(&no_names, &no_values);
618        let kc = parse("pk = :pk AND sk = :sk", &tracker).unwrap();
619        assert_eq!(kc.pk_name, "pk");
620        assert!(matches!(kc.sk_condition, Some(SortKeyCondition::Eq(_, _))));
621    }
622
623    #[test]
624    fn test_pk_and_sk_between() {
625        let no_names = None;
626        let no_values = None;
627        let tracker = make_tracker(&no_names, &no_values);
628        let kc = parse("pk = :pk AND sk BETWEEN :lo AND :hi", &tracker).unwrap();
629        assert!(matches!(
630            kc.sk_condition,
631            Some(SortKeyCondition::Between(_, _, _))
632        ));
633    }
634
635    #[test]
636    fn test_pk_and_begins_with() {
637        let no_names = None;
638        let no_values = None;
639        let tracker = make_tracker(&no_names, &no_values);
640        let kc = parse("pk = :pk AND begins_with(sk, :prefix)", &tracker).unwrap();
641        assert!(matches!(
642            kc.sk_condition,
643            Some(SortKeyCondition::BeginsWith(_, _))
644        ));
645    }
646
647    #[test]
648    fn test_with_attribute_names() {
649        let an = Some(HashMap::from([
650            ("#pk".to_string(), "partitionKey".to_string()),
651            ("#sk".to_string(), "sortKey".to_string()),
652        ]));
653        let no_values = None;
654        let tracker = make_tracker(&an, &no_values);
655        let kc = parse("#pk = :pk AND #sk > :sk", &tracker).unwrap();
656        assert_eq!(kc.pk_name, "partitionKey");
657        assert!(matches!(kc.sk_condition, Some(SortKeyCondition::Gt(ref n, _)) if n == "sortKey"));
658    }
659
660    #[test]
661    fn test_resolve_values() {
662        let no_names = None;
663        let no_values = None;
664        let parse_tracker = make_tracker(&no_names, &no_values);
665        let kc = parse("pk = :pk AND sk >= :sk", &parse_tracker).unwrap();
666        let av = Some(HashMap::from([
667            (":pk".to_string(), AttributeValue::S("user#1".into())),
668            (":sk".to_string(), AttributeValue::S("2024-01-01".into())),
669        ]));
670        let resolve_tracker = make_tracker(&no_names, &av);
671        let resolved = resolve_values(&kc, &resolve_tracker).unwrap();
672        assert_eq!(resolved.pk_value, AttributeValue::S("user#1".into()));
673        assert!(matches!(
674            resolved.sk_condition,
675            Some(ResolvedSortKeyCondition::Ge(_, _))
676        ));
677    }
678
679    #[test]
680    fn test_parenthesized_conditions() {
681        let no_names = None;
682        let no_values = None;
683
684        // Parens around each condition
685        let tracker = make_tracker(&no_names, &no_values);
686        let kc = parse("(pk = :pk) AND (sk = :sk)", &tracker).unwrap();
687        assert_eq!(kc.pk_name, "pk");
688        assert!(matches!(kc.sk_condition, Some(SortKeyCondition::Eq(_, _))));
689
690        // Parens around entire expression
691        let tracker = make_tracker(&no_names, &no_values);
692        let kc = parse("(pk = :pk AND sk = :sk)", &tracker).unwrap();
693        assert_eq!(kc.pk_name, "pk");
694        assert!(matches!(kc.sk_condition, Some(SortKeyCondition::Eq(_, _))));
695
696        // Genuinely nested (non-redundant) parens are accepted.
697        let tracker = make_tracker(&no_names, &no_values);
698        let kc = parse("(pk = :pk AND (sk > :sk))", &tracker).unwrap();
699        assert_eq!(kc.pk_name, "pk");
700        assert!(matches!(kc.sk_condition, Some(SortKeyCondition::Gt(_, _))));
701
702        // Redundant parentheses are rejected, matching real DynamoDB. Dynoxide
703        // previously stripped them and silently accepted.
704        let tracker = make_tracker(&no_names, &no_values);
705        let err = parse("((pk = :pk)) AND ((sk > :sk))", &tracker).unwrap_err();
706        assert!(
707            err.contains("redundant parentheses"),
708            "expected redundant-parentheses rejection, got: {err}"
709        );
710
711        // Parens around begins_with
712        let tracker = make_tracker(&no_names, &no_values);
713        let kc = parse("(pk = :pk) AND (begins_with(sk, :prefix))", &tracker).unwrap();
714        assert!(matches!(
715            kc.sk_condition,
716            Some(SortKeyCondition::BeginsWith(_, _))
717        ));
718
719        // Parens with attribute name references
720        let an = Some(HashMap::from([
721            ("#pk".to_string(), "PK".to_string()),
722            ("#sk".to_string(), "SK".to_string()),
723        ]));
724        let tracker = make_tracker(&an, &no_values);
725        let kc = parse("(#pk = :pk) AND (#sk = :sk)", &tracker).unwrap();
726        assert_eq!(kc.pk_name, "PK");
727    }
728
729    #[test]
730    fn test_sk_comparisons() {
731        let no_names = None;
732        let no_values = None;
733        for (op, variant) in [("<", "Lt"), ("<=", "Le"), (">", "Gt"), (">=", "Ge")] {
734            let tracker = make_tracker(&no_names, &no_values);
735            let kc = parse(&format!("pk = :pk AND sk {op} :sk"), &tracker).unwrap();
736            let sk = kc.sk_condition.unwrap();
737            let name = match &sk {
738                SortKeyCondition::Lt(n, _) => format!("Lt:{n}"),
739                SortKeyCondition::Le(n, _) => format!("Le:{n}"),
740                SortKeyCondition::Gt(n, _) => format!("Gt:{n}"),
741                SortKeyCondition::Ge(n, _) => format!("Ge:{n}"),
742                _ => "other".to_string(),
743            };
744            assert!(name.starts_with(variant), "Expected {variant}, got {name}");
745        }
746    }
747}