Skip to main content

dynoxide/
types.rs

1use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
2use serde::de;
3use serde::ser::SerializeMap;
4use serde::{Deserialize, Deserializer, Serialize, Serializer};
5use std::collections::{BTreeSet, HashMap, HashSet};
6use std::fmt;
7
8/// DynamoDB AttributeValue — the core type system.
9///
10/// Each variant corresponds to a DynamoDB type descriptor:
11/// S (String), N (Number as string), B (Binary), BOOL, NULL,
12/// SS (String Set), NS (Number Set), BS (Binary Set),
13/// L (List), M (Map).
14#[derive(Debug, Clone, PartialEq)]
15pub enum AttributeValue {
16    /// String type
17    S(String),
18    /// Number type — stored as string per DynamoDB convention
19    N(String),
20    /// Binary type — raw bytes, serialized as base64
21    B(Vec<u8>),
22    /// Boolean type
23    BOOL(bool),
24    /// Null type
25    NULL(bool),
26    /// String Set
27    SS(Vec<String>),
28    /// Number Set — each number stored as string
29    NS(Vec<String>),
30    /// Binary Set — each element is raw bytes
31    BS(Vec<Vec<u8>>),
32    /// List — ordered collection of AttributeValues
33    L(Vec<AttributeValue>),
34    /// Map — key-value pairs
35    M(HashMap<String, AttributeValue>),
36}
37
38impl AttributeValue {
39    /// Calculate the size of this attribute value in bytes,
40    /// following DynamoDB's item size calculation rules.
41    ///
42    /// This does NOT include the attribute name — the caller
43    /// is responsible for adding the name's UTF-8 byte length.
44    pub fn size(&self) -> usize {
45        match self {
46            AttributeValue::S(s) => s.len(),
47            AttributeValue::N(n) => {
48                // DynamoDB: (number of significant digits / 2) + 1, minimum 1
49                let significant = n.chars().filter(|c| c.is_ascii_digit()).count();
50                let significant = significant.max(1);
51                (significant / 2) + 1
52            }
53            AttributeValue::B(b) => b.len(),
54            AttributeValue::BOOL(_) => 1,
55            AttributeValue::NULL(_) => 1,
56            AttributeValue::SS(ss) => ss.iter().map(|s| s.len()).sum(),
57            AttributeValue::NS(ns) => ns
58                .iter()
59                .map(|n| {
60                    let significant = n.chars().filter(|c| c.is_ascii_digit()).count().max(1);
61                    (significant / 2) + 1
62                })
63                .sum(),
64            AttributeValue::BS(bs) => bs.iter().map(|b| b.len()).sum(),
65            AttributeValue::L(items) => {
66                // List overhead: 3 bytes + 1 byte per element + sum of element sizes
67                3 + items.len() + items.iter().map(|v| v.size()).sum::<usize>()
68            }
69            AttributeValue::M(map) => {
70                // Map overhead: 3 bytes + sum of (key_len + 1 + value_size) per entry
71                3 + map
72                    .iter()
73                    .map(|(k, v)| k.len() + 1 + v.size())
74                    .sum::<usize>()
75            }
76        }
77    }
78
79    /// Returns the DynamoDB type descriptor string for this value.
80    pub fn type_name(&self) -> &'static str {
81        match self {
82            AttributeValue::S(_) => "S",
83            AttributeValue::N(_) => "N",
84            AttributeValue::B(_) => "B",
85            AttributeValue::BOOL(_) => "BOOL",
86            AttributeValue::NULL(_) => "NULL",
87            AttributeValue::SS(_) => "SS",
88            AttributeValue::NS(_) => "NS",
89            AttributeValue::BS(_) => "BS",
90            AttributeValue::L(_) => "L",
91            AttributeValue::M(_) => "M",
92        }
93    }
94
95    /// Returns true if this is a scalar type (S, N, B, BOOL, NULL).
96    pub fn is_scalar(&self) -> bool {
97        matches!(
98            self,
99            AttributeValue::S(_)
100                | AttributeValue::N(_)
101                | AttributeValue::B(_)
102                | AttributeValue::BOOL(_)
103                | AttributeValue::NULL(_)
104        )
105    }
106
107    /// Returns true if this is a set type (SS, NS, BS).
108    pub fn is_set(&self) -> bool {
109        matches!(
110            self,
111            AttributeValue::SS(_) | AttributeValue::NS(_) | AttributeValue::BS(_)
112        )
113    }
114
115    /// Serialize this value to a deterministic TEXT representation
116    /// for use as a SQLite primary key column (pk or sk).
117    ///
118    /// - S: stored as-is (UTF-8 text sorts correctly)
119    /// - N: normalized to a comparable string encoding
120    /// - B: hex-encoded (preserves byte ordering)
121    pub fn to_key_string(&self) -> Option<String> {
122        match self {
123            AttributeValue::S(s) => Some(format!("S:{s}")),
124            AttributeValue::N(n) => Some(format!("N:{}", normalize_number_for_sort(n))),
125            AttributeValue::B(b) => Some(format!("B:{}", hex_encode(b))),
126            _ => None, // Only S, N, B can be key types
127        }
128    }
129}
130
131impl fmt::Display for AttributeValue {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        match self {
134            AttributeValue::S(s) => write!(f, "\"{s}\""),
135            AttributeValue::N(n) => write!(f, "{n}"),
136            AttributeValue::B(b) => write!(f, "<binary {} bytes>", b.len()),
137            AttributeValue::BOOL(b) => write!(f, "{b}"),
138            AttributeValue::NULL(_) => write!(f, "null"),
139            AttributeValue::SS(ss) => write!(f, "{ss:?}"),
140            AttributeValue::NS(ns) => write!(f, "{ns:?}"),
141            AttributeValue::BS(bs) => write!(f, "<binary set {} items>", bs.len()),
142            AttributeValue::L(items) => write!(f, "<list {} items>", items.len()),
143            AttributeValue::M(map) => write!(f, "<map {} keys>", map.len()),
144        }
145    }
146}
147
148// ---------------------------------------------------------------------------
149// Custom serde: DynamoDB JSON format {"S": "hello"}, {"N": "42"}, etc.
150// ---------------------------------------------------------------------------
151
152impl Serialize for AttributeValue {
153    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
154    where
155        S: Serializer,
156    {
157        let mut map = serializer.serialize_map(Some(1))?;
158        match self {
159            AttributeValue::S(s) => map.serialize_entry("S", s)?,
160            AttributeValue::N(n) => map.serialize_entry("N", n)?,
161            AttributeValue::B(b) => {
162                map.serialize_entry("B", &BASE64.encode(b))?;
163            }
164            AttributeValue::BOOL(b) => map.serialize_entry("BOOL", b)?,
165            AttributeValue::NULL(n) => map.serialize_entry("NULL", n)?,
166            AttributeValue::SS(ss) => map.serialize_entry("SS", ss)?,
167            AttributeValue::NS(ns) => map.serialize_entry("NS", ns)?,
168            AttributeValue::BS(bs) => {
169                let encoded: Vec<String> = bs.iter().map(|b| BASE64.encode(b)).collect();
170                map.serialize_entry("BS", &encoded)?;
171            }
172            AttributeValue::L(items) => map.serialize_entry("L", items)?,
173            AttributeValue::M(m) => map.serialize_entry("M", m)?,
174        }
175        map.end()
176    }
177}
178
179impl<'de> Deserialize<'de> for AttributeValue {
180    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
181    where
182        D: Deserializer<'de>,
183    {
184        // Deserialize as raw JSON Value first so we can inspect all keys
185        let raw = serde_json::Value::deserialize(deserializer)?;
186
187        let obj = raw
188            .as_object()
189            .ok_or_else(|| de::Error::custom("empty AttributeValue object"))?;
190
191        if obj.is_empty() {
192            return Err(de::Error::custom("empty AttributeValue object"));
193        }
194
195        // Collect known type keys
196        let known_types = ["S", "N", "B", "BOOL", "NULL", "SS", "NS", "BS", "L", "M"];
197        let present: Vec<&str> = obj
198            .keys()
199            .filter(|k| known_types.contains(&k.as_str()))
200            .map(|k| k.as_str())
201            .collect();
202
203        if present.is_empty() {
204            return Err(de::Error::custom(
205                "Supplied AttributeValue is empty, must contain exactly one of the supported datatypes",
206            ));
207        }
208
209        // Validate numbers in ALL type keys before checking for multi-type.
210        // DynamoDB validates number format before rejecting multi-type.
211        for &type_key in &present {
212            match type_key {
213                "N" => {
214                    if let Some(n) = obj.get("N").and_then(|v| v.as_str()) {
215                        validate_number_in_deser(n).map_err(de::Error::custom)?;
216                    }
217                }
218                "NS" => {
219                    if let Some(arr) = obj.get("NS").and_then(|v| v.as_array()) {
220                        for item in arr {
221                            if let Some(n) = item.as_str() {
222                                validate_number_in_deser(n).map_err(de::Error::custom)?;
223                            }
224                        }
225                    }
226                }
227                _ => {}
228            }
229        }
230
231        // Check for multiple type keys
232        if present.len() > 1 {
233            return Err(de::Error::custom(
234                "VALIDATION:Supplied AttributeValue has more than one datatypes set, \
235                 must contain exactly one of the supported datatypes",
236            ));
237        }
238
239        let type_key = present[0];
240        let val = &obj[type_key];
241
242        match type_key {
243            "S" => {
244                let s = val
245                    .as_str()
246                    .ok_or_else(|| de::Error::custom("expected string for S"))?;
247                Ok(AttributeValue::S(s.to_string()))
248            }
249            "N" => {
250                let n = val
251                    .as_str()
252                    .ok_or_else(|| de::Error::custom("expected string for N"))?;
253                Ok(AttributeValue::N(n.to_string()))
254            }
255            "B" => {
256                let encoded = val
257                    .as_str()
258                    .ok_or_else(|| de::Error::custom("expected string for B"))?;
259                let bytes = BASE64
260                    .decode(encoded)
261                    .map_err(|e| de::Error::custom(format!("invalid base64: {e}")))?;
262                Ok(AttributeValue::B(bytes))
263            }
264            "BOOL" => {
265                let b = val
266                    .as_bool()
267                    .ok_or_else(|| de::Error::custom("expected boolean for BOOL"))?;
268                Ok(AttributeValue::BOOL(b))
269            }
270            "NULL" => {
271                // The NULL member is a plain boolean in the model. AWS accepts both
272                // {"NULL": true} and {"NULL": false} and reads either back as
273                // {"NULL": true}, so normalise false to true here. A non-boolean
274                // value (e.g. {"NULL": "no"}) is a type error.
275                if val.as_bool().is_none() {
276                    return Err(de::Error::custom(
277                        "VALIDATION:One or more parameter values were invalid: \
278                         Null attribute value types must have the value of true",
279                    ));
280                }
281                Ok(AttributeValue::NULL(true))
282            }
283            "SS" => {
284                let arr = val
285                    .as_array()
286                    .ok_or_else(|| de::Error::custom("expected array for SS"))?;
287                let ss: Result<Vec<String>, _> = arr
288                    .iter()
289                    .map(|v| {
290                        v.as_str()
291                            .map(|s| s.to_string())
292                            .ok_or_else(|| de::Error::custom("expected string in SS"))
293                    })
294                    .collect();
295                Ok(AttributeValue::SS(ss?))
296            }
297            "NS" => {
298                let arr = val
299                    .as_array()
300                    .ok_or_else(|| de::Error::custom("expected array for NS"))?;
301                let ns: Result<Vec<String>, _> = arr
302                    .iter()
303                    .map(|v| {
304                        v.as_str()
305                            .map(|s| s.to_string())
306                            .ok_or_else(|| de::Error::custom("expected string in NS"))
307                    })
308                    .collect();
309                Ok(AttributeValue::NS(ns?))
310            }
311            "BS" => {
312                let arr = val
313                    .as_array()
314                    .ok_or_else(|| de::Error::custom("expected array for BS"))?;
315                let mut decoded = Vec::with_capacity(arr.len());
316                for item in arr {
317                    let encoded = item
318                        .as_str()
319                        .ok_or_else(|| de::Error::custom("expected string in BS"))?;
320                    decoded.push(
321                        BASE64
322                            .decode(encoded)
323                            .map_err(|e| de::Error::custom(format!("invalid base64: {e}")))?,
324                    );
325                }
326                Ok(AttributeValue::BS(decoded))
327            }
328            "L" => {
329                let arr = val
330                    .as_array()
331                    .ok_or_else(|| de::Error::custom("expected array for L"))?;
332                let list: Result<Vec<AttributeValue>, _> = arr
333                    .iter()
334                    .map(|v| serde_json::from_value(v.clone()).map_err(de::Error::custom))
335                    .collect();
336                Ok(AttributeValue::L(list?))
337            }
338            "M" => {
339                let map_val = val
340                    .as_object()
341                    .ok_or_else(|| de::Error::custom("expected object for M"))?;
342                let mut result = std::collections::HashMap::new();
343                for (k, v) in map_val {
344                    let av: AttributeValue =
345                        serde_json::from_value(v.clone()).map_err(de::Error::custom)?;
346                    result.insert(k.clone(), av);
347                }
348                Ok(AttributeValue::M(result))
349            }
350            _ => unreachable!(),
351        }
352    }
353}
354
355/// Validate a number string during AttributeValue deserialization.
356///
357/// Returns DynamoDB-matching error messages for invalid numbers.
358/// Error messages are returned WITHOUT the VALIDATION: prefix since they
359/// bypass the normal validation flow — the server routes them based on
360/// message content (see `server::serde_errors::deserialize`).
361fn validate_number_in_deser(n: &str) -> Result<(), String> {
362    // validate_dynamo_number is the single source of truth for number format
363    // and precision/range; the deser path only reshapes the error message.
364    match validate_dynamo_number(n) {
365        Ok(()) => Ok(()),
366        Err(crate::errors::DynoxideError::ValidationException(m)) => Err(format!("VALIDATION:{m}")),
367        Err(e) => Err(format!("VALIDATION:{e}")),
368    }
369}
370
371// ---------------------------------------------------------------------------
372// Number sort key normalization
373// ---------------------------------------------------------------------------
374
375/// Normalize a DynamoDB number string into a comparable string that sorts
376/// correctly in SQLite TEXT collation.
377///
378/// Encoding scheme:
379/// - Positive numbers: "1" + zero-padded exponent (4 digits, offset by 5000) + normalized mantissa
380/// - Zero: "1" + "5000" + "0" (padded)
381/// - Negative numbers: "0" + complement of (exponent + mantissa) so they sort before positives
382///
383/// DynamoDB numbers: up to 38 digits of precision, range ~-1E+126 to ~+1E+126.
384pub fn normalize_number_for_sort(num_str: &str) -> String {
385    let trimmed = num_str.trim();
386
387    if trimmed.is_empty() || trimmed == "0" || trimmed == "-0" || trimmed == "0.0" {
388        return zero_encoding();
389    }
390
391    let negative = trimmed.starts_with('-');
392    let abs_str = if negative { &trimmed[1..] } else { trimmed };
393
394    // Parse into mantissa digits and exponent
395    let (mantissa_digits, exponent) = parse_number_parts(abs_str);
396
397    if mantissa_digits.is_empty() || mantissa_digits.iter().all(|&d| d == 0) {
398        return zero_encoding();
399    }
400
401    if negative {
402        encode_negative(&mantissa_digits, exponent)
403    } else {
404        encode_positive(&mantissa_digits, exponent)
405    }
406}
407
408/// Validate a DynamoDB number string against DynamoDB's constraints:
409/// - Up to 38 significant digits
410/// - Magnitude at most 9.9999999999999999999999999999999999999E+125
411/// - Positive values must be at least 1E-130
412/// - Negative values must be at most -1E-130
413pub fn validate_dynamo_number(
414    num_str: &str,
415) -> std::result::Result<(), crate::errors::DynoxideError> {
416    if num_str.is_empty() {
417        return Err(crate::errors::DynoxideError::ValidationException(
418            "The parameter cannot be converted to a numeric value".to_string(),
419        ));
420    }
421
422    // DynamoDB accepts a specific numeric grammar and rejects everything else,
423    // including any surrounding or internal whitespace. Verified against real
424    // DynamoDB (see the unit tests below and the dynamodb-conformance suite):
425    //   sign?  coefficient  exponent?
426    //   coefficient = at least one digit, at most one '.' (e.g. 5, 5., .5, +1.5)
427    //   exponent    = ('e'|'E') sign? at least one digit (e.g. e2, E+3, e-130)
428    // Accepts: +5, -7, +.5, 5., 1e+2, 1.5E+3, 1E-130, 00042
429    // Rejects: +e2, 1+2, 1.2.3, ++5, 1e, ., NaN, "1_000", " 5"
430    if !is_well_formed_dynamo_number(num_str) {
431        return Err(crate::errors::DynoxideError::ValidationException(format!(
432            "The parameter cannot be converted to a numeric value: {num_str}"
433        )));
434    }
435
436    // parse_number_parts ignores the sign characters, so the magnitude checks
437    // below hold for both signs.
438    let (mantissa_digits, exponent) = parse_number_parts(num_str);
439
440    // Zero is always valid
441    if mantissa_digits.is_empty() || mantissa_digits.iter().all(|&d| d == 0) {
442        return Ok(());
443    }
444
445    // Check significant digits (mantissa_digits has leading/trailing zeros already stripped)
446    if mantissa_digits.len() > 38 {
447        return Err(crate::errors::DynoxideError::ValidationException(
448            "Attempting to store more than 38 significant digits in a Number".to_string(),
449        ));
450    }
451
452    // Check magnitude: exponent represents the power such that value = 0.mantissa * 10^exponent
453    // Max magnitude: 9.999...E+125 means exponent = 126 (since 0.999... * 10^126 = 9.99...E+125)
454    if exponent > 126 {
455        return Err(crate::errors::DynoxideError::ValidationException(
456            "Number overflow. Attempting to store a number with magnitude larger than supported range"
457                .to_string(),
458        ));
459    }
460
461    // Check underflow for non-zero values
462    // Min positive: 1E-130 means exponent = -129 (since 0.1 * 10^-129 = 1E-130)
463    // But with more digits, exponent can be lower, e.g. 1.0E-130 has (mantissa=[1], exponent=-129)
464    // Actually, the smallest representable is 1E-130. In our representation, 1E-130 = 0.1 * 10^-129
465    // So exponent = -129 with mantissa [1].
466    // For 1E-131 = 0.1 * 10^-130, exponent = -130 — that's too small.
467    if exponent < -129 {
468        return Err(crate::errors::DynoxideError::ValidationException(
469            "Number underflow. Attempting to store a number with magnitude smaller than supported range"
470                .to_string(),
471        ));
472    }
473
474    Ok(())
475}
476
477/// Returns true when `s` matches DynamoDB's numeric grammar exactly:
478/// `sign? coefficient exponent?` where the coefficient carries at least one
479/// digit and at most one `.`, and the exponent (if present) carries at least
480/// one digit. No whitespace or stray characters are tolerated. This mirrors
481/// what real DynamoDB accepts (verified against AWS).
482fn is_well_formed_dynamo_number(s: &str) -> bool {
483    let bytes = s.as_bytes();
484    let n = bytes.len();
485    let mut i = 0;
486
487    // Optional leading sign.
488    if i < n && (bytes[i] == b'+' || bytes[i] == b'-') {
489        i += 1;
490    }
491
492    // Coefficient: digits with at most one decimal point, at least one digit.
493    let mut coeff_digits = 0usize;
494    let mut dots = 0usize;
495    while i < n && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
496        if bytes[i] == b'.' {
497            dots += 1;
498            if dots > 1 {
499                return false;
500            }
501        } else {
502            coeff_digits += 1;
503        }
504        i += 1;
505    }
506    if coeff_digits == 0 {
507        return false;
508    }
509
510    // Optional exponent: 'e'/'E', optional sign, at least one digit.
511    if i < n && (bytes[i] == b'e' || bytes[i] == b'E') {
512        i += 1;
513        if i < n && (bytes[i] == b'+' || bytes[i] == b'-') {
514            i += 1;
515        }
516        let mut exp_digits = 0usize;
517        while i < n && bytes[i].is_ascii_digit() {
518            exp_digits += 1;
519            i += 1;
520        }
521        if exp_digits == 0 {
522            return false;
523        }
524    }
525
526    // Anything left over (stray chars, trailing whitespace) is invalid.
527    i == n
528}
529
530/// Normalize a DynamoDB number string to its canonical form.
531///
532/// DynamoDB normalises numbers when storing them:
533/// - Leading zeros are stripped (`0042` → `42`)
534/// - Trailing zeros after decimal are stripped (`1.200` → `1.2`)
535/// - Scientific notation is expanded to full decimal form
536/// - Zero is represented as `0`
537pub fn normalize_dynamo_number(num_str: &str) -> String {
538    let trimmed = num_str.trim();
539    if trimmed.is_empty() {
540        return "0".to_string();
541    }
542
543    let negative = trimmed.starts_with('-');
544    let abs_str = if negative {
545        &trimmed[1..]
546    } else {
547        trimmed.trim_start_matches('+')
548    };
549
550    let (mantissa_digits, exponent) = parse_number_parts(abs_str);
551
552    // Zero
553    if mantissa_digits.is_empty() {
554        return "0".to_string();
555    }
556
557    // Reconstruct: mantissa_digits represent the significant digits,
558    // exponent is the power of 10 such that value = 0.mantissa * 10^exponent
559    // e.g., 12345 → mantissa=[1,2,3,4,5], exponent=5 → 12345
560    // e.g., 0.00123 → mantissa=[1,2,3], exponent=-2 → 0.00123
561    let num_digits = mantissa_digits.len() as i32;
562    let int_digits = exponent; // number of digits before the decimal point
563
564    let mut result = String::new();
565    if negative {
566        result.push('-');
567    }
568
569    if int_digits <= 0 {
570        // Pure fraction: 0.000...digits
571        result.push_str("0.");
572        for _ in 0..(-int_digits) {
573            result.push('0');
574        }
575        for &d in &mantissa_digits {
576            result.push((b'0' + d) as char);
577        }
578    } else if int_digits >= num_digits {
579        // Pure integer: digits followed by trailing zeros
580        for &d in &mantissa_digits {
581            result.push((b'0' + d) as char);
582        }
583        for _ in 0..(int_digits - num_digits) {
584            result.push('0');
585        }
586    } else {
587        // Mixed: some digits before decimal, some after
588        let int_part = int_digits as usize;
589        for &d in &mantissa_digits[..int_part] {
590            result.push((b'0' + d) as char);
591        }
592        result.push('.');
593        for &d in &mantissa_digits[int_part..] {
594            result.push((b'0' + d) as char);
595        }
596    }
597
598    result
599}
600
601fn zero_encoding() -> String {
602    // Zero sorts between negative (prefix "0") and positive (prefix "2")
603    format!("1{}{}", "0".repeat(4), "0".repeat(40))
604}
605
606fn encode_positive(mantissa: &[u8], exponent: i32) -> String {
607    let exp_encoded = (exponent + 5000) as u16;
608    let mantissa_str = mantissa_to_string(mantissa, 40);
609    format!("2{exp_encoded:04}{mantissa_str}")
610}
611
612fn encode_negative(mantissa: &[u8], exponent: i32) -> String {
613    // For negatives, we complement everything so larger absolute values sort first (smaller)
614    let exp_encoded = 9999 - (exponent + 5000) as u16;
615    let mantissa_str = complement_mantissa(mantissa, 40);
616    format!("0{exp_encoded:04}{mantissa_str}")
617}
618
619/// Parse a non-negative number string into (mantissa digits, exponent).
620/// Mantissa is normalized: first digit is non-zero, exponent is the power of 10
621/// such that the number = 0.mantissa * 10^exponent.
622pub(crate) fn parse_number_parts(s: &str) -> (Vec<u8>, i32) {
623    // Handle scientific notation
624    let (coeff, exp_part) = if let Some(pos) = s.to_ascii_lowercase().find('e') {
625        let coeff = &s[..pos];
626        let exp: i32 = s[pos + 1..].parse().unwrap_or(0);
627        (coeff, exp)
628    } else {
629        (s, 0)
630    };
631
632    // Split coefficient into integer and fraction parts
633    let (int_part, frac_part) = if let Some(dot) = coeff.find('.') {
634        (&coeff[..dot], &coeff[dot + 1..])
635    } else {
636        (coeff, "")
637    };
638
639    // Collect all digits
640    let mut digits: Vec<u8> = Vec::new();
641    for ch in int_part.chars().chain(frac_part.chars()) {
642        if ch.is_ascii_digit() {
643            digits.push(ch as u8 - b'0');
644        }
645    }
646
647    if digits.is_empty() {
648        return (vec![], 0);
649    }
650
651    // The integer part length gives us the base exponent
652    let int_len = int_part.chars().filter(|c| c.is_ascii_digit()).count() as i32;
653
654    // Find first non-zero digit
655    let leading_zeros = digits.iter().take_while(|&&d| d == 0).count();
656    digits.drain(..leading_zeros);
657
658    // Trim trailing zeros
659    while digits.last() == Some(&0) {
660        digits.pop();
661    }
662
663    if digits.is_empty() {
664        return (vec![], 0);
665    }
666
667    // exponent = int_len - leading_zeros + exp_part
668    // But we need to account for whether leading zeros were in int or frac part
669    let exponent = int_len - leading_zeros as i32 + exp_part;
670
671    (digits, exponent)
672}
673
674fn mantissa_to_string(digits: &[u8], width: usize) -> String {
675    let mut s = String::with_capacity(width);
676    for &d in digits.iter().take(width) {
677        s.push((b'0' + d) as char);
678    }
679    while s.len() < width {
680        s.push('0');
681    }
682    s
683}
684
685fn complement_mantissa(digits: &[u8], width: usize) -> String {
686    let mut s = String::with_capacity(width);
687    for i in 0..width {
688        let d = if i < digits.len() { digits[i] } else { 0 };
689        s.push((b'0' + (9 - d)) as char);
690    }
691    s
692}
693
694/// Hex-encode bytes (lowercase) for binary key storage.
695fn hex_encode(bytes: &[u8]) -> String {
696    let mut s = String::with_capacity(bytes.len() * 2);
697    for &b in bytes {
698        s.push_str(&format!("{b:02x}"));
699    }
700    s
701}
702
703// ---------------------------------------------------------------------------
704// Item helpers
705// ---------------------------------------------------------------------------
706
707/// A DynamoDB item: a map of attribute names to values.
708pub type Item = HashMap<String, AttributeValue>;
709
710/// SSE specification for server-side encryption settings.
711#[derive(Debug, Clone, Default, Serialize, Deserialize)]
712pub struct SseSpecification {
713    #[serde(rename = "Enabled", default)]
714    pub enabled: Option<bool>,
715    #[serde(rename = "SSEType", default)]
716    pub sse_type: Option<String>,
717    #[serde(rename = "KMSMasterKeyId", default)]
718    pub kms_master_key_id: Option<String>,
719}
720
721/// DynamoDB Tag (key-value pair attached to a resource).
722#[derive(Debug, Clone, Default, Serialize, Deserialize)]
723pub struct Tag {
724    #[serde(rename = "Key")]
725    pub key: String,
726    #[serde(rename = "Value")]
727    pub value: String,
728}
729
730/// Calculate the total size of a DynamoDB item in bytes.
731pub fn item_size(item: &Item) -> usize {
732    item.iter()
733        .map(|(name, value)| name.len() + value.size())
734        .sum()
735}
736
737/// Maximum item size in bytes (400 KB).
738pub const MAX_ITEM_SIZE: usize = 400 * 1024;
739
740/// ItemCollectionMetrics returned when `ReturnItemCollectionMetrics: SIZE` is set
741/// and the table has local secondary indexes.
742#[derive(Debug, Clone, Serialize, Deserialize)]
743pub struct ItemCollectionMetrics {
744    #[serde(rename = "ItemCollectionKey")]
745    pub item_collection_key: HashMap<String, AttributeValue>,
746    #[serde(rename = "SizeEstimateRangeGB")]
747    pub size_estimate_range_gb: Vec<f64>,
748}
749
750/// ConsumedCapacity returned when `ReturnConsumedCapacity` is set.
751#[derive(Debug, Clone, Default, Serialize, Deserialize)]
752pub struct ConsumedCapacity {
753    #[serde(rename = "TableName")]
754    pub table_name: String,
755    #[serde(rename = "CapacityUnits")]
756    pub capacity_units: f64,
757    #[serde(rename = "ReadCapacityUnits", skip_serializing_if = "Option::is_none")]
758    pub read_capacity_units: Option<f64>,
759    #[serde(rename = "WriteCapacityUnits", skip_serializing_if = "Option::is_none")]
760    pub write_capacity_units: Option<f64>,
761    #[serde(rename = "Table", skip_serializing_if = "Option::is_none")]
762    pub table: Option<CapacityDetail>,
763    #[serde(
764        rename = "GlobalSecondaryIndexes",
765        skip_serializing_if = "Option::is_none"
766    )]
767    pub global_secondary_indexes: Option<HashMap<String, CapacityDetail>>,
768    #[serde(
769        rename = "LocalSecondaryIndexes",
770        skip_serializing_if = "Option::is_none"
771    )]
772    pub local_secondary_indexes: Option<HashMap<String, CapacityDetail>>,
773}
774
775/// Per-resource capacity detail.
776#[derive(Debug, Clone, Default, Serialize, Deserialize)]
777pub struct CapacityDetail {
778    #[serde(rename = "CapacityUnits")]
779    pub capacity_units: f64,
780    #[serde(rename = "ReadCapacityUnits", skip_serializing_if = "Option::is_none")]
781    pub read_capacity_units: Option<f64>,
782    #[serde(rename = "WriteCapacityUnits", skip_serializing_if = "Option::is_none")]
783    pub write_capacity_units: Option<f64>,
784}
785
786/// The transactional capacity multiplier. `TransactWriteItems` and
787/// `TransactGetItems` cost twice the equivalent single-item operation, so each
788/// item's rounded-up units are doubled (the rounding happens per item, before
789/// the multiplier, to match AWS at the KB/4KB boundary).
790pub const TRANSACTIONAL_CAPACITY_FACTOR: f64 = 2.0;
791
792/// Calculate write capacity units (1 WCU = 1KB, rounded up).
793pub fn write_capacity_units(item_size_bytes: usize) -> f64 {
794    ((item_size_bytes as f64) / 1024.0).ceil().max(1.0)
795}
796
797/// Calculate read capacity units assuming strongly consistent reads
798/// (1 RCU per 4KB, rounded up). Used when ConsistentRead is true or
799/// when the read type is not specified.
800pub fn read_capacity_units(item_size_bytes: usize) -> f64 {
801    ((item_size_bytes as f64) / 4096.0).ceil().max(1.0)
802}
803
804/// Calculate read capacity units accounting for consistency mode.
805///
806/// Strongly consistent: 1 RCU per 4KB, rounded up.
807/// Eventually consistent: 0.5 RCU per 4KB (half the strongly consistent rate).
808pub fn read_capacity_units_with_consistency(item_size_bytes: usize, consistent: bool) -> f64 {
809    let strongly = read_capacity_units(item_size_bytes);
810    if consistent { strongly } else { strongly / 2.0 }
811}
812
813/// Build a `ConsumedCapacity` for a simple table operation.
814pub fn consumed_capacity(
815    table_name: &str,
816    capacity_units: f64,
817    mode: &Option<String>,
818) -> Option<ConsumedCapacity> {
819    let mode = mode.as_deref().unwrap_or("NONE");
820    match mode {
821        "TOTAL" => Some(ConsumedCapacity {
822            table_name: table_name.to_string(),
823            capacity_units,
824            table: None,
825            global_secondary_indexes: None,
826            local_secondary_indexes: None,
827            ..Default::default()
828        }),
829        "INDEXES" => Some(ConsumedCapacity {
830            table_name: table_name.to_string(),
831            capacity_units,
832            table: Some(CapacityDetail {
833                capacity_units,
834                ..Default::default()
835            }),
836            global_secondary_indexes: None,
837            local_secondary_indexes: None,
838            ..Default::default()
839        }),
840        _ => None,
841    }
842}
843
844/// Build a `ConsumedCapacity` with per-GSI breakdown for INDEXES mode.
845pub fn consumed_capacity_with_indexes(
846    table_name: &str,
847    table_units: f64,
848    gsi_units: &HashMap<String, f64>,
849    mode: &Option<String>,
850) -> Option<ConsumedCapacity> {
851    consumed_capacity_with_secondary_indexes(
852        table_name,
853        table_units,
854        gsi_units,
855        &HashMap::new(),
856        mode,
857    )
858}
859
860/// Build a `ConsumedCapacity` with per-GSI and per-LSI breakdown for INDEXES mode.
861pub fn consumed_capacity_with_secondary_indexes(
862    table_name: &str,
863    table_units: f64,
864    gsi_units: &HashMap<String, f64>,
865    lsi_units: &HashMap<String, f64>,
866    mode: &Option<String>,
867) -> Option<ConsumedCapacity> {
868    let units_to_map = |units: &HashMap<String, f64>| -> Option<HashMap<String, CapacityDetail>> {
869        if units.is_empty() {
870            None
871        } else {
872            Some(
873                units
874                    .iter()
875                    .map(|(name, &u)| {
876                        (
877                            name.clone(),
878                            CapacityDetail {
879                                capacity_units: u,
880                                ..Default::default()
881                            },
882                        )
883                    })
884                    .collect(),
885            )
886        }
887    };
888
889    match mode.as_deref().unwrap_or("NONE") {
890        "INDEXES" => {
891            let gsi_total: f64 = gsi_units.values().sum();
892            let lsi_total: f64 = lsi_units.values().sum();
893            Some(ConsumedCapacity {
894                table_name: table_name.to_string(),
895                capacity_units: table_units + gsi_total + lsi_total,
896                table: Some(CapacityDetail {
897                    capacity_units: table_units,
898                    ..Default::default()
899                }),
900                global_secondary_indexes: units_to_map(gsi_units),
901                local_secondary_indexes: units_to_map(lsi_units),
902                ..Default::default()
903            })
904        }
905        "TOTAL" => {
906            let gsi_total: f64 = gsi_units.values().sum();
907            let lsi_total: f64 = lsi_units.values().sum();
908            Some(ConsumedCapacity {
909                table_name: table_name.to_string(),
910                capacity_units: table_units + gsi_total + lsi_total,
911                table: None,
912                global_secondary_indexes: None,
913                local_secondary_indexes: None,
914                ..Default::default()
915            })
916        }
917        _ => None,
918    }
919}
920
921/// Build a `ConsumedCapacity` for one table in a transactional read
922/// (`TransactGetItems`). `units` is the table total and already includes the
923/// transactional 2x factor. Under `INDEXES` the Table detail reports
924/// `ReadCapacityUnits` alongside `CapacityUnits`, matching AWS.
925pub fn transactional_read_capacity(
926    table_name: &str,
927    units: f64,
928    mode: &Option<String>,
929) -> Option<ConsumedCapacity> {
930    match mode.as_deref().unwrap_or("NONE") {
931        "TOTAL" => Some(ConsumedCapacity {
932            table_name: table_name.to_string(),
933            capacity_units: units,
934            read_capacity_units: Some(units),
935            ..Default::default()
936        }),
937        "INDEXES" => Some(ConsumedCapacity {
938            table_name: table_name.to_string(),
939            capacity_units: units,
940            read_capacity_units: Some(units),
941            table: Some(CapacityDetail {
942                capacity_units: units,
943                read_capacity_units: Some(units),
944                ..Default::default()
945            }),
946            ..Default::default()
947        }),
948        _ => None,
949    }
950}
951
952/// Build the per-table `ConsumedCapacity` vec for a transactional op from the
953/// per-table units, using `builder` (`transactional_write_capacity` for a
954/// first-call write, `transactional_read_capacity` for a read set or a
955/// same-token replay). Returns `None` unless `ReturnConsumedCapacity` is
956/// `TOTAL` or `INDEXES`, so the mode guard lives in one place. Shared by
957/// `TransactWriteItems` and `ExecuteTransaction`.
958pub fn build_transactional_capacity(
959    table_units: &HashMap<String, f64>,
960    mode: &Option<String>,
961    builder: fn(&str, f64, &Option<String>) -> Option<ConsumedCapacity>,
962) -> Option<Vec<ConsumedCapacity>> {
963    if matches!(mode.as_deref(), Some("TOTAL") | Some("INDEXES")) {
964        Some(
965            table_units
966                .iter()
967                .filter_map(|(table, &units)| builder(table, units, mode))
968                .collect(),
969        )
970    } else {
971        None
972    }
973}
974
975/// Build a `ConsumedCapacity` for one table in a transactional write
976/// (`TransactWriteItems`). `units` is the table total and already includes the
977/// transactional 2x factor. Under `INDEXES` the Table detail reports
978/// `WriteCapacityUnits` alongside `CapacityUnits`, matching AWS.
979pub fn transactional_write_capacity(
980    table_name: &str,
981    units: f64,
982    mode: &Option<String>,
983) -> Option<ConsumedCapacity> {
984    match mode.as_deref().unwrap_or("NONE") {
985        "TOTAL" => Some(ConsumedCapacity {
986            table_name: table_name.to_string(),
987            capacity_units: units,
988            write_capacity_units: Some(units),
989            ..Default::default()
990        }),
991        "INDEXES" => Some(ConsumedCapacity {
992            table_name: table_name.to_string(),
993            capacity_units: units,
994            write_capacity_units: Some(units),
995            table: Some(CapacityDetail {
996                capacity_units: units,
997                write_capacity_units: Some(units),
998                ..Default::default()
999            }),
1000            ..Default::default()
1001        }),
1002        _ => None,
1003    }
1004}
1005
1006/// Key schema element — defines a key attribute.
1007#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1008pub struct KeySchemaElement {
1009    #[serde(rename = "AttributeName", alias = "attribute_name")]
1010    pub attribute_name: String,
1011    #[serde(rename = "KeyType", alias = "key_type")]
1012    pub key_type: KeyType,
1013}
1014
1015/// Key type: HASH (partition key) or RANGE (sort key).
1016#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1017pub enum KeyType {
1018    #[default]
1019    HASH,
1020    RANGE,
1021}
1022
1023/// Attribute definition — declares an attribute's type.
1024#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1025pub struct AttributeDefinition {
1026    #[serde(rename = "AttributeName", alias = "attribute_name")]
1027    pub attribute_name: String,
1028    #[serde(rename = "AttributeType", alias = "attribute_type")]
1029    pub attribute_type: ScalarAttributeType,
1030}
1031
1032/// Scalar attribute types that can be used as keys.
1033#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1034pub enum ScalarAttributeType {
1035    #[default]
1036    S,
1037    N,
1038    B,
1039}
1040
1041/// GSI projection type.
1042#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1043pub struct Projection {
1044    #[serde(
1045        rename = "ProjectionType",
1046        alias = "projection_type",
1047        default,
1048        skip_serializing_if = "Option::is_none"
1049    )]
1050    pub projection_type: Option<ProjectionType>,
1051    #[serde(
1052        rename = "NonKeyAttributes",
1053        alias = "non_key_attributes",
1054        skip_serializing_if = "Option::is_none"
1055    )]
1056    pub non_key_attributes: Option<Vec<String>>,
1057}
1058
1059/// Projection type enum.
1060#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1061#[allow(non_camel_case_types)]
1062pub enum ProjectionType {
1063    #[default]
1064    ALL,
1065    KEYS_ONLY,
1066    INCLUDE,
1067}
1068
1069/// Global Secondary Index definition.
1070#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1071pub struct GlobalSecondaryIndex {
1072    #[serde(rename = "IndexName", alias = "index_name")]
1073    pub index_name: String,
1074    #[serde(rename = "KeySchema", alias = "key_schema")]
1075    pub key_schema: Vec<KeySchemaElement>,
1076    #[serde(rename = "Projection", alias = "projection")]
1077    pub projection: Projection,
1078    #[serde(
1079        rename = "ProvisionedThroughput",
1080        alias = "provisioned_throughput",
1081        skip_serializing_if = "Option::is_none"
1082    )]
1083    pub provisioned_throughput: Option<ProvisionedThroughput>,
1084}
1085
1086/// Local Secondary Index definition.
1087#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1088pub struct LocalSecondaryIndex {
1089    #[serde(rename = "IndexName", alias = "index_name")]
1090    pub index_name: String,
1091    #[serde(rename = "KeySchema", alias = "key_schema")]
1092    pub key_schema: Vec<KeySchemaElement>,
1093    #[serde(rename = "Projection", alias = "projection")]
1094    pub projection: Projection,
1095}
1096
1097/// Provisioned throughput settings (stored but not enforced).
1098#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1099pub struct ProvisionedThroughput {
1100    #[serde(rename = "ReadCapacityUnits", default)]
1101    pub read_capacity_units: Option<i64>,
1102    #[serde(rename = "WriteCapacityUnits", default)]
1103    pub write_capacity_units: Option<i64>,
1104}
1105
1106/// On-demand (PAY_PER_REQUEST) throughput ceilings (stored but not enforced).
1107#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1108pub struct OnDemandThroughput {
1109    #[serde(
1110        rename = "MaxReadRequestUnits",
1111        default,
1112        skip_serializing_if = "Option::is_none"
1113    )]
1114    pub max_read_request_units: Option<i64>,
1115    #[serde(
1116        rename = "MaxWriteRequestUnits",
1117        default,
1118        skip_serializing_if = "Option::is_none"
1119    )]
1120    pub max_write_request_units: Option<i64>,
1121}
1122
1123// ---------------------------------------------------------------------------
1124// Type conversion: From<T> / TryFrom<T> for AttributeValue
1125// ---------------------------------------------------------------------------
1126
1127/// Error returned when converting between `AttributeValue` and Rust types.
1128#[derive(Debug, Clone, PartialEq)]
1129pub struct ConversionError {
1130    /// The expected DynamoDB or Rust type.
1131    pub expected: &'static str,
1132    /// The actual DynamoDB type encountered.
1133    pub actual: &'static str,
1134}
1135
1136impl fmt::Display for ConversionError {
1137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1138        write!(f, "expected {}, got {}", self.expected, self.actual)
1139    }
1140}
1141
1142impl std::error::Error for ConversionError {}
1143
1144// --- From<T> for AttributeValue: infallible conversions ---
1145
1146impl From<String> for AttributeValue {
1147    fn from(value: String) -> Self {
1148        AttributeValue::S(value)
1149    }
1150}
1151
1152impl From<&str> for AttributeValue {
1153    fn from(value: &str) -> Self {
1154        AttributeValue::S(value.to_string())
1155    }
1156}
1157
1158impl From<bool> for AttributeValue {
1159    fn from(value: bool) -> Self {
1160        AttributeValue::BOOL(value)
1161    }
1162}
1163
1164impl From<Vec<u8>> for AttributeValue {
1165    fn from(value: Vec<u8>) -> Self {
1166        AttributeValue::B(value)
1167    }
1168}
1169
1170impl From<&[u8]> for AttributeValue {
1171    fn from(value: &[u8]) -> Self {
1172        AttributeValue::B(value.to_vec())
1173    }
1174}
1175
1176// Integer types — all finite, all fit in DynamoDB's number range.
1177macro_rules! impl_from_integer {
1178    ($($t:ty),+) => {
1179        $(
1180            impl From<$t> for AttributeValue {
1181                fn from(value: $t) -> Self {
1182                    AttributeValue::N(value.to_string())
1183                }
1184            }
1185        )+
1186    };
1187}
1188
1189impl_from_integer!(i8, i16, i32, i64, i128, u8, u16, u32, u64, u128);
1190
1191// Container types
1192impl From<HashMap<String, AttributeValue>> for AttributeValue {
1193    fn from(value: HashMap<String, AttributeValue>) -> Self {
1194        AttributeValue::M(value)
1195    }
1196}
1197
1198impl From<Vec<AttributeValue>> for AttributeValue {
1199    fn from(value: Vec<AttributeValue>) -> Self {
1200        AttributeValue::L(value)
1201    }
1202}
1203
1204impl From<HashSet<String>> for AttributeValue {
1205    fn from(value: HashSet<String>) -> Self {
1206        AttributeValue::SS(value.into_iter().collect())
1207    }
1208}
1209
1210impl From<BTreeSet<String>> for AttributeValue {
1211    fn from(value: BTreeSet<String>) -> Self {
1212        AttributeValue::SS(value.into_iter().collect())
1213    }
1214}
1215
1216// --- TryFrom<T> for AttributeValue: fallible conversions (floats) ---
1217
1218impl TryFrom<f64> for AttributeValue {
1219    type Error = ConversionError;
1220
1221    fn try_from(value: f64) -> std::result::Result<Self, Self::Error> {
1222        if value.is_finite() {
1223            Ok(AttributeValue::N(value.to_string()))
1224        } else {
1225            Err(ConversionError {
1226                expected: "finite f64",
1227                actual: "NaN or Infinity",
1228            })
1229        }
1230    }
1231}
1232
1233impl TryFrom<f32> for AttributeValue {
1234    type Error = ConversionError;
1235
1236    fn try_from(value: f32) -> std::result::Result<Self, Self::Error> {
1237        if value.is_finite() {
1238            Ok(AttributeValue::N(value.to_string()))
1239        } else {
1240            Err(ConversionError {
1241                expected: "finite f32",
1242                actual: "NaN or Infinity",
1243            })
1244        }
1245    }
1246}
1247
1248// --- TryFrom<AttributeValue> for T: extract Rust types from AV ---
1249
1250impl TryFrom<AttributeValue> for String {
1251    type Error = ConversionError;
1252
1253    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1254        match value {
1255            AttributeValue::S(s) => Ok(s),
1256            other => Err(ConversionError {
1257                expected: "S",
1258                actual: other.type_name(),
1259            }),
1260        }
1261    }
1262}
1263
1264impl TryFrom<AttributeValue> for bool {
1265    type Error = ConversionError;
1266
1267    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1268        match value {
1269            AttributeValue::BOOL(b) => Ok(b),
1270            other => Err(ConversionError {
1271                expected: "BOOL",
1272                actual: other.type_name(),
1273            }),
1274        }
1275    }
1276}
1277
1278impl TryFrom<AttributeValue> for Vec<u8> {
1279    type Error = ConversionError;
1280
1281    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1282        match value {
1283            AttributeValue::B(b) => Ok(b),
1284            other => Err(ConversionError {
1285                expected: "B",
1286                actual: other.type_name(),
1287            }),
1288        }
1289    }
1290}
1291
1292macro_rules! impl_try_from_av_integer {
1293    ($($t:ty),+) => {
1294        $(
1295            impl TryFrom<AttributeValue> for $t {
1296                type Error = ConversionError;
1297
1298                fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1299                    match value {
1300                        AttributeValue::N(n) => n.parse::<$t>().map_err(|_| ConversionError {
1301                            expected: stringify!($t),
1302                            actual: "N (parse failed)",
1303                        }),
1304                        other => Err(ConversionError {
1305                            expected: "N",
1306                            actual: other.type_name(),
1307                        }),
1308                    }
1309                }
1310            }
1311        )+
1312    };
1313}
1314
1315impl_try_from_av_integer!(i8, i16, i32, i64, i128, u8, u16, u32, u64, u128);
1316
1317impl TryFrom<AttributeValue> for f64 {
1318    type Error = ConversionError;
1319
1320    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1321        match value {
1322            AttributeValue::N(n) => n.parse::<f64>().map_err(|_| ConversionError {
1323                expected: "f64",
1324                actual: "N (parse failed)",
1325            }),
1326            other => Err(ConversionError {
1327                expected: "N",
1328                actual: other.type_name(),
1329            }),
1330        }
1331    }
1332}
1333
1334impl TryFrom<AttributeValue> for f32 {
1335    type Error = ConversionError;
1336
1337    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1338        match value {
1339            AttributeValue::N(n) => n.parse::<f32>().map_err(|_| ConversionError {
1340                expected: "f32",
1341                actual: "N (parse failed)",
1342            }),
1343            other => Err(ConversionError {
1344                expected: "N",
1345                actual: other.type_name(),
1346            }),
1347        }
1348    }
1349}
1350
1351impl TryFrom<AttributeValue> for HashMap<String, AttributeValue> {
1352    type Error = ConversionError;
1353
1354    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1355        match value {
1356            AttributeValue::M(m) => Ok(m),
1357            other => Err(ConversionError {
1358                expected: "M",
1359                actual: other.type_name(),
1360            }),
1361        }
1362    }
1363}
1364
1365impl TryFrom<AttributeValue> for Vec<AttributeValue> {
1366    type Error = ConversionError;
1367
1368    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1369        match value {
1370            AttributeValue::L(l) => Ok(l),
1371            other => Err(ConversionError {
1372                expected: "L",
1373                actual: other.type_name(),
1374            }),
1375        }
1376    }
1377}
1378
1379impl TryFrom<AttributeValue> for Vec<String> {
1380    type Error = ConversionError;
1381
1382    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1383        match value {
1384            AttributeValue::SS(ss) => Ok(ss),
1385            AttributeValue::L(l) => {
1386                // Lenient: extract S values from a list
1387                l.into_iter()
1388                    .map(|av| match av {
1389                        AttributeValue::S(s) => Ok(s),
1390                        other => Err(ConversionError {
1391                            expected: "S (within L)",
1392                            actual: other.type_name(),
1393                        }),
1394                    })
1395                    .collect()
1396            }
1397            other => Err(ConversionError {
1398                expected: "SS or L",
1399                actual: other.type_name(),
1400            }),
1401        }
1402    }
1403}
1404
1405#[cfg(test)]
1406mod tests {
1407    use super::*;
1408
1409    #[test]
1410    fn test_serialize_string() {
1411        let val = AttributeValue::S("hello".to_string());
1412        let json = serde_json::to_string(&val).unwrap();
1413        assert_eq!(json, r#"{"S":"hello"}"#);
1414    }
1415
1416    #[test]
1417    fn test_serialize_number() {
1418        let val = AttributeValue::N("42".to_string());
1419        let json = serde_json::to_string(&val).unwrap();
1420        assert_eq!(json, r#"{"N":"42"}"#);
1421    }
1422
1423    #[test]
1424    fn test_serialize_binary() {
1425        let val = AttributeValue::B(vec![1, 2, 3]);
1426        let json = serde_json::to_string(&val).unwrap();
1427        assert_eq!(json, r#"{"B":"AQID"}"#);
1428    }
1429
1430    #[test]
1431    fn test_serialize_bool() {
1432        let val = AttributeValue::BOOL(true);
1433        let json = serde_json::to_string(&val).unwrap();
1434        assert_eq!(json, r#"{"BOOL":true}"#);
1435    }
1436
1437    #[test]
1438    fn test_serialize_null() {
1439        let val = AttributeValue::NULL(true);
1440        let json = serde_json::to_string(&val).unwrap();
1441        assert_eq!(json, r#"{"NULL":true}"#);
1442    }
1443
1444    #[test]
1445    fn test_deserialize_null_true() {
1446        let val: AttributeValue = serde_json::from_str(r#"{"NULL":true}"#).unwrap();
1447        assert_eq!(val, AttributeValue::NULL(true));
1448    }
1449
1450    #[test]
1451    fn test_deserialize_null_false_normalises_to_true() {
1452        // The NULL member is a plain boolean in the model, so {"NULL": false} is
1453        // valid input. AWS accepts it and reads it back as {"NULL": true}, so
1454        // normalise false to true on the way in.
1455        let val: AttributeValue = serde_json::from_str(r#"{"NULL":false}"#).unwrap();
1456        assert_eq!(val, AttributeValue::NULL(true));
1457    }
1458
1459    #[test]
1460    fn test_deserialize_null_non_boolean_rejected() {
1461        // A non-boolean NULL (e.g. {"NULL": "no"}) is a type error, not a value.
1462        let err = serde_json::from_str::<AttributeValue>(r#"{"NULL":"no"}"#).unwrap_err();
1463        assert!(
1464            err.to_string().contains("must have the value of true"),
1465            "unexpected error: {err}"
1466        );
1467    }
1468
1469    #[test]
1470    fn test_serialize_string_set() {
1471        let val = AttributeValue::SS(vec!["a".to_string(), "b".to_string()]);
1472        let json = serde_json::to_string(&val).unwrap();
1473        assert_eq!(json, r#"{"SS":["a","b"]}"#);
1474    }
1475
1476    #[test]
1477    fn test_serialize_list() {
1478        let val = AttributeValue::L(vec![
1479            AttributeValue::S("hello".to_string()),
1480            AttributeValue::N("42".to_string()),
1481        ]);
1482        let json = serde_json::to_string(&val).unwrap();
1483        assert_eq!(json, r#"{"L":[{"S":"hello"},{"N":"42"}]}"#);
1484    }
1485
1486    #[test]
1487    fn test_serialize_map() {
1488        let mut m = HashMap::new();
1489        m.insert("key".to_string(), AttributeValue::S("value".to_string()));
1490        let val = AttributeValue::M(m);
1491        let json = serde_json::to_string(&val).unwrap();
1492        assert_eq!(json, r#"{"M":{"key":{"S":"value"}}}"#);
1493    }
1494
1495    #[test]
1496    fn test_round_trip_all_types() {
1497        let values = vec![
1498            AttributeValue::S("hello".to_string()),
1499            AttributeValue::N("42.5".to_string()),
1500            AttributeValue::B(vec![0, 255, 128]),
1501            AttributeValue::BOOL(false),
1502            AttributeValue::NULL(true),
1503            AttributeValue::SS(vec!["x".to_string(), "y".to_string()]),
1504            AttributeValue::NS(vec!["1".to_string(), "2.5".to_string()]),
1505            AttributeValue::BS(vec![vec![1], vec![2, 3]]),
1506            AttributeValue::L(vec![
1507                AttributeValue::S("nested".to_string()),
1508                AttributeValue::N("99".to_string()),
1509            ]),
1510        ];
1511
1512        for val in values {
1513            let json = serde_json::to_string(&val).unwrap();
1514            let deserialized: AttributeValue = serde_json::from_str(&json).unwrap();
1515            assert_eq!(val, deserialized, "Round-trip failed for {json}");
1516        }
1517    }
1518
1519    #[test]
1520    fn test_size_string() {
1521        let val = AttributeValue::S("hello".to_string());
1522        assert_eq!(val.size(), 5);
1523    }
1524
1525    #[test]
1526    fn test_size_number() {
1527        // "42" has 2 significant digits → (2/2) + 1 = 2
1528        let val = AttributeValue::N("42".to_string());
1529        assert_eq!(val.size(), 2);
1530    }
1531
1532    #[test]
1533    fn test_size_bool() {
1534        assert_eq!(AttributeValue::BOOL(true).size(), 1);
1535    }
1536
1537    #[test]
1538    fn test_size_null() {
1539        assert_eq!(AttributeValue::NULL(true).size(), 1);
1540    }
1541
1542    #[test]
1543    fn test_key_string_s() {
1544        let val = AttributeValue::S("hello".to_string());
1545        assert_eq!(val.to_key_string(), Some("S:hello".to_string()));
1546    }
1547
1548    #[test]
1549    fn test_key_string_n() {
1550        let val = AttributeValue::N("42".to_string());
1551        let key = val.to_key_string().unwrap();
1552        assert!(key.starts_with("N:"));
1553    }
1554
1555    #[test]
1556    fn test_key_string_b() {
1557        let val = AttributeValue::B(vec![0xff, 0x00, 0xab]);
1558        assert_eq!(val.to_key_string(), Some("B:ff00ab".to_string()));
1559    }
1560
1561    #[test]
1562    fn test_key_string_non_key_type_returns_none() {
1563        assert_eq!(AttributeValue::BOOL(true).to_key_string(), None);
1564        assert_eq!(AttributeValue::L(vec![]).to_key_string(), None);
1565    }
1566
1567    // Number sort key ordering tests
1568    #[test]
1569    fn test_number_sort_ordering() {
1570        let numbers = vec![
1571            "-1000", "-100", "-10", "-1", "-0.5", "-0.001", "0", "0.001", "0.5", "1", "10", "100",
1572            "1000",
1573        ];
1574        let encoded: Vec<String> = numbers
1575            .iter()
1576            .map(|n| normalize_number_for_sort(n))
1577            .collect();
1578
1579        for i in 0..encoded.len() - 1 {
1580            assert!(
1581                encoded[i] < encoded[i + 1],
1582                "Sort order broken: {} ({}) should be < {} ({})",
1583                numbers[i],
1584                encoded[i],
1585                numbers[i + 1],
1586                encoded[i + 1]
1587            );
1588        }
1589    }
1590
1591    #[test]
1592    fn test_number_sort_zero_variants() {
1593        let z1 = normalize_number_for_sort("0");
1594        let z2 = normalize_number_for_sort("-0");
1595        let z3 = normalize_number_for_sort("0.0");
1596        assert_eq!(z1, z2);
1597        assert_eq!(z2, z3);
1598    }
1599
1600    #[test]
1601    fn test_number_sort_decimals() {
1602        let a = normalize_number_for_sort("1.5");
1603        let b = normalize_number_for_sort("2.5");
1604        assert!(a < b);
1605
1606        let c = normalize_number_for_sort("0.001");
1607        let d = normalize_number_for_sort("0.01");
1608        assert!(c < d);
1609    }
1610
1611    #[test]
1612    fn test_number_sort_scientific() {
1613        let a = normalize_number_for_sort("1e10");
1614        let b = normalize_number_for_sort("1e11");
1615        assert!(a < b);
1616
1617        let c = normalize_number_for_sort("-1e11");
1618        let d = normalize_number_for_sort("-1e10");
1619        assert!(c < d);
1620    }
1621
1622    // Number validation/normalisation is pinned to real DynamoDB behaviour
1623    // (captured against AWS for issue #109). validate_dynamo_number accepts
1624    // exactly the grammar DynamoDB accepts, including a leading '+'; the bare
1625    // and '+'-prefixed malformed forms are both rejected, matching AWS.
1626    #[test]
1627    fn test_validate_number_accepts_dynamodb_grammar() {
1628        for input in [
1629            "+5", "+1.5", "+0", "-0", "+0.0", "+1e2", "1e+2", "1.5E+3", "-7", "+.5", ".5", "5.",
1630            "00042", "1.23E10", "+1e-2", "1E-130", "+1E-130",
1631        ] {
1632            assert!(
1633                validate_dynamo_number(input).is_ok(),
1634                "expected {input} to validate, got {:?}",
1635                validate_dynamo_number(input)
1636            );
1637        }
1638    }
1639
1640    #[test]
1641    fn test_validate_number_rejects_malformed() {
1642        // Every one of these is rejected by real DynamoDB. Note that whitespace
1643        // (leading, trailing, or internal) is rejected, not trimmed.
1644        for input in [
1645            "+e2", "e2", "+1+2", "1+2", "+1.2.3", "1.2.3", "++5", "+-5", "-+5", "+", "-", "1e",
1646            "1e+", ".", "1.2e3.4", "0x5", "NaN", "Infinity", "1_000", " 5", "5 ", "1 5", "",
1647        ] {
1648            assert!(
1649                matches!(
1650                    validate_dynamo_number(input),
1651                    Err(crate::errors::DynoxideError::ValidationException(_))
1652                ),
1653                "expected {input:?} to be rejected with ValidationException, got {:?}",
1654                validate_dynamo_number(input)
1655            );
1656        }
1657    }
1658
1659    #[test]
1660    fn test_normalize_number_matches_dynamodb() {
1661        for (input, stored) in [
1662            ("+5", "5"),
1663            ("+1.5", "1.5"),
1664            ("+0", "0"),
1665            ("-0", "0"),
1666            ("+0.0", "0"),
1667            ("+1e2", "100"),
1668            ("1e+2", "100"),
1669            ("1.5E+3", "1500"),
1670            ("-7", "-7"),
1671            ("+.5", "0.5"),
1672            (".5", "0.5"),
1673            ("5.", "5"),
1674            ("00042", "42"),
1675            ("1.23E10", "12300000000"),
1676            ("+1e-2", "0.01"),
1677        ] {
1678            assert_eq!(
1679                normalize_dynamo_number(input),
1680                stored,
1681                "{input} should normalise to {stored}"
1682            );
1683        }
1684    }
1685
1686    #[test]
1687    fn test_type_name() {
1688        assert_eq!(AttributeValue::S("".to_string()).type_name(), "S");
1689        assert_eq!(AttributeValue::N("0".to_string()).type_name(), "N");
1690        assert_eq!(AttributeValue::B(vec![]).type_name(), "B");
1691        assert_eq!(AttributeValue::BOOL(true).type_name(), "BOOL");
1692        assert_eq!(AttributeValue::NULL(true).type_name(), "NULL");
1693        assert_eq!(AttributeValue::SS(vec![]).type_name(), "SS");
1694        assert_eq!(AttributeValue::NS(vec![]).type_name(), "NS");
1695        assert_eq!(AttributeValue::BS(vec![]).type_name(), "BS");
1696        assert_eq!(AttributeValue::L(vec![]).type_name(), "L");
1697        assert_eq!(AttributeValue::M(HashMap::new()).type_name(), "M");
1698    }
1699}