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::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 = "Table", skip_serializing_if = "Option::is_none")]
758    pub table: Option<CapacityDetail>,
759    #[serde(
760        rename = "GlobalSecondaryIndexes",
761        skip_serializing_if = "Option::is_none"
762    )]
763    pub global_secondary_indexes: Option<HashMap<String, CapacityDetail>>,
764    #[serde(
765        rename = "LocalSecondaryIndexes",
766        skip_serializing_if = "Option::is_none"
767    )]
768    pub local_secondary_indexes: Option<HashMap<String, CapacityDetail>>,
769}
770
771/// Per-resource capacity detail.
772#[derive(Debug, Clone, Default, Serialize, Deserialize)]
773pub struct CapacityDetail {
774    #[serde(rename = "CapacityUnits")]
775    pub capacity_units: f64,
776    #[serde(rename = "ReadCapacityUnits", skip_serializing_if = "Option::is_none")]
777    pub read_capacity_units: Option<f64>,
778    #[serde(rename = "WriteCapacityUnits", skip_serializing_if = "Option::is_none")]
779    pub write_capacity_units: Option<f64>,
780}
781
782/// The transactional capacity multiplier. `TransactWriteItems` and
783/// `TransactGetItems` cost twice the equivalent single-item operation, so each
784/// item's rounded-up units are doubled (the rounding happens per item, before
785/// the multiplier, to match AWS at the KB/4KB boundary).
786pub const TRANSACTIONAL_CAPACITY_FACTOR: f64 = 2.0;
787
788/// Calculate write capacity units (1 WCU = 1KB, rounded up).
789pub fn write_capacity_units(item_size_bytes: usize) -> f64 {
790    ((item_size_bytes as f64) / 1024.0).ceil().max(1.0)
791}
792
793/// Calculate read capacity units assuming strongly consistent reads
794/// (1 RCU per 4KB, rounded up). Used when ConsistentRead is true or
795/// when the read type is not specified.
796pub fn read_capacity_units(item_size_bytes: usize) -> f64 {
797    ((item_size_bytes as f64) / 4096.0).ceil().max(1.0)
798}
799
800/// Calculate read capacity units accounting for consistency mode.
801///
802/// Strongly consistent: 1 RCU per 4KB, rounded up.
803/// Eventually consistent: 0.5 RCU per 4KB (half the strongly consistent rate).
804pub fn read_capacity_units_with_consistency(item_size_bytes: usize, consistent: bool) -> f64 {
805    let strongly = read_capacity_units(item_size_bytes);
806    if consistent { strongly } else { strongly / 2.0 }
807}
808
809/// Build a `ConsumedCapacity` for a simple table operation.
810pub fn consumed_capacity(
811    table_name: &str,
812    capacity_units: f64,
813    mode: &Option<String>,
814) -> Option<ConsumedCapacity> {
815    let mode = mode.as_deref().unwrap_or("NONE");
816    match mode {
817        "TOTAL" => Some(ConsumedCapacity {
818            table_name: table_name.to_string(),
819            capacity_units,
820            table: None,
821            global_secondary_indexes: None,
822            local_secondary_indexes: None,
823        }),
824        "INDEXES" => Some(ConsumedCapacity {
825            table_name: table_name.to_string(),
826            capacity_units,
827            table: Some(CapacityDetail {
828                capacity_units,
829                ..Default::default()
830            }),
831            global_secondary_indexes: None,
832            local_secondary_indexes: None,
833        }),
834        _ => None,
835    }
836}
837
838/// Build a `ConsumedCapacity` with per-GSI breakdown for INDEXES mode.
839pub fn consumed_capacity_with_indexes(
840    table_name: &str,
841    table_units: f64,
842    gsi_units: &HashMap<String, f64>,
843    mode: &Option<String>,
844) -> Option<ConsumedCapacity> {
845    consumed_capacity_with_secondary_indexes(
846        table_name,
847        table_units,
848        gsi_units,
849        &HashMap::new(),
850        mode,
851    )
852}
853
854/// Build a `ConsumedCapacity` with per-GSI and per-LSI breakdown for INDEXES mode.
855pub fn consumed_capacity_with_secondary_indexes(
856    table_name: &str,
857    table_units: f64,
858    gsi_units: &HashMap<String, f64>,
859    lsi_units: &HashMap<String, f64>,
860    mode: &Option<String>,
861) -> Option<ConsumedCapacity> {
862    let units_to_map = |units: &HashMap<String, f64>| -> Option<HashMap<String, CapacityDetail>> {
863        if units.is_empty() {
864            None
865        } else {
866            Some(
867                units
868                    .iter()
869                    .map(|(name, &u)| {
870                        (
871                            name.clone(),
872                            CapacityDetail {
873                                capacity_units: u,
874                                ..Default::default()
875                            },
876                        )
877                    })
878                    .collect(),
879            )
880        }
881    };
882
883    match mode.as_deref().unwrap_or("NONE") {
884        "INDEXES" => {
885            let gsi_total: f64 = gsi_units.values().sum();
886            let lsi_total: f64 = lsi_units.values().sum();
887            Some(ConsumedCapacity {
888                table_name: table_name.to_string(),
889                capacity_units: table_units + gsi_total + lsi_total,
890                table: Some(CapacityDetail {
891                    capacity_units: table_units,
892                    ..Default::default()
893                }),
894                global_secondary_indexes: units_to_map(gsi_units),
895                local_secondary_indexes: units_to_map(lsi_units),
896            })
897        }
898        "TOTAL" => {
899            let gsi_total: f64 = gsi_units.values().sum();
900            let lsi_total: f64 = lsi_units.values().sum();
901            Some(ConsumedCapacity {
902                table_name: table_name.to_string(),
903                capacity_units: table_units + gsi_total + lsi_total,
904                table: None,
905                global_secondary_indexes: None,
906                local_secondary_indexes: None,
907            })
908        }
909        _ => None,
910    }
911}
912
913/// Build a `ConsumedCapacity` for one table in a transactional read
914/// (`TransactGetItems`). `units` is the table total and already includes the
915/// transactional 2x factor. Under `INDEXES` the Table detail reports
916/// `ReadCapacityUnits` alongside `CapacityUnits`, matching AWS.
917pub fn transactional_read_capacity(
918    table_name: &str,
919    units: f64,
920    mode: &Option<String>,
921) -> Option<ConsumedCapacity> {
922    match mode.as_deref().unwrap_or("NONE") {
923        "TOTAL" => Some(ConsumedCapacity {
924            table_name: table_name.to_string(),
925            capacity_units: units,
926            ..Default::default()
927        }),
928        "INDEXES" => Some(ConsumedCapacity {
929            table_name: table_name.to_string(),
930            capacity_units: units,
931            table: Some(CapacityDetail {
932                capacity_units: units,
933                read_capacity_units: Some(units),
934                ..Default::default()
935            }),
936            ..Default::default()
937        }),
938        _ => None,
939    }
940}
941
942/// Build a `ConsumedCapacity` for one table in a transactional write
943/// (`TransactWriteItems`). `units` is the table total and already includes the
944/// transactional 2x factor. Under `INDEXES` the Table detail reports
945/// `WriteCapacityUnits` alongside `CapacityUnits`, matching AWS.
946pub fn transactional_write_capacity(
947    table_name: &str,
948    units: f64,
949    mode: &Option<String>,
950) -> Option<ConsumedCapacity> {
951    match mode.as_deref().unwrap_or("NONE") {
952        "TOTAL" => Some(ConsumedCapacity {
953            table_name: table_name.to_string(),
954            capacity_units: units,
955            ..Default::default()
956        }),
957        "INDEXES" => Some(ConsumedCapacity {
958            table_name: table_name.to_string(),
959            capacity_units: units,
960            table: Some(CapacityDetail {
961                capacity_units: units,
962                write_capacity_units: Some(units),
963                ..Default::default()
964            }),
965            ..Default::default()
966        }),
967        _ => None,
968    }
969}
970
971/// Key schema element — defines a key attribute.
972#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
973pub struct KeySchemaElement {
974    #[serde(rename = "AttributeName", alias = "attribute_name")]
975    pub attribute_name: String,
976    #[serde(rename = "KeyType", alias = "key_type")]
977    pub key_type: KeyType,
978}
979
980/// Key type: HASH (partition key) or RANGE (sort key).
981#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
982pub enum KeyType {
983    #[default]
984    HASH,
985    RANGE,
986}
987
988/// Attribute definition — declares an attribute's type.
989#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
990pub struct AttributeDefinition {
991    #[serde(rename = "AttributeName", alias = "attribute_name")]
992    pub attribute_name: String,
993    #[serde(rename = "AttributeType", alias = "attribute_type")]
994    pub attribute_type: ScalarAttributeType,
995}
996
997/// Scalar attribute types that can be used as keys.
998#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
999pub enum ScalarAttributeType {
1000    #[default]
1001    S,
1002    N,
1003    B,
1004}
1005
1006/// GSI projection type.
1007#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1008pub struct Projection {
1009    #[serde(
1010        rename = "ProjectionType",
1011        alias = "projection_type",
1012        default,
1013        skip_serializing_if = "Option::is_none"
1014    )]
1015    pub projection_type: Option<ProjectionType>,
1016    #[serde(
1017        rename = "NonKeyAttributes",
1018        alias = "non_key_attributes",
1019        skip_serializing_if = "Option::is_none"
1020    )]
1021    pub non_key_attributes: Option<Vec<String>>,
1022}
1023
1024/// Projection type enum.
1025#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1026#[allow(non_camel_case_types)]
1027pub enum ProjectionType {
1028    #[default]
1029    ALL,
1030    KEYS_ONLY,
1031    INCLUDE,
1032}
1033
1034/// Global Secondary Index definition.
1035#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1036pub struct GlobalSecondaryIndex {
1037    #[serde(rename = "IndexName", alias = "index_name")]
1038    pub index_name: String,
1039    #[serde(rename = "KeySchema", alias = "key_schema")]
1040    pub key_schema: Vec<KeySchemaElement>,
1041    #[serde(rename = "Projection", alias = "projection")]
1042    pub projection: Projection,
1043    #[serde(
1044        rename = "ProvisionedThroughput",
1045        alias = "provisioned_throughput",
1046        skip_serializing_if = "Option::is_none"
1047    )]
1048    pub provisioned_throughput: Option<ProvisionedThroughput>,
1049}
1050
1051/// Local Secondary Index definition.
1052#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1053pub struct LocalSecondaryIndex {
1054    #[serde(rename = "IndexName", alias = "index_name")]
1055    pub index_name: String,
1056    #[serde(rename = "KeySchema", alias = "key_schema")]
1057    pub key_schema: Vec<KeySchemaElement>,
1058    #[serde(rename = "Projection", alias = "projection")]
1059    pub projection: Projection,
1060}
1061
1062/// Provisioned throughput settings (stored but not enforced).
1063#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1064pub struct ProvisionedThroughput {
1065    #[serde(rename = "ReadCapacityUnits", default)]
1066    pub read_capacity_units: Option<i64>,
1067    #[serde(rename = "WriteCapacityUnits", default)]
1068    pub write_capacity_units: Option<i64>,
1069}
1070
1071/// On-demand (PAY_PER_REQUEST) throughput ceilings (stored but not enforced).
1072#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1073pub struct OnDemandThroughput {
1074    #[serde(
1075        rename = "MaxReadRequestUnits",
1076        default,
1077        skip_serializing_if = "Option::is_none"
1078    )]
1079    pub max_read_request_units: Option<i64>,
1080    #[serde(
1081        rename = "MaxWriteRequestUnits",
1082        default,
1083        skip_serializing_if = "Option::is_none"
1084    )]
1085    pub max_write_request_units: Option<i64>,
1086}
1087
1088// ---------------------------------------------------------------------------
1089// Type conversion: From<T> / TryFrom<T> for AttributeValue
1090// ---------------------------------------------------------------------------
1091
1092/// Error returned when converting between `AttributeValue` and Rust types.
1093#[derive(Debug, Clone, PartialEq)]
1094pub struct ConversionError {
1095    /// The expected DynamoDB or Rust type.
1096    pub expected: &'static str,
1097    /// The actual DynamoDB type encountered.
1098    pub actual: &'static str,
1099}
1100
1101impl fmt::Display for ConversionError {
1102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1103        write!(f, "expected {}, got {}", self.expected, self.actual)
1104    }
1105}
1106
1107impl std::error::Error for ConversionError {}
1108
1109// --- From<T> for AttributeValue: infallible conversions ---
1110
1111impl From<String> for AttributeValue {
1112    fn from(value: String) -> Self {
1113        AttributeValue::S(value)
1114    }
1115}
1116
1117impl From<&str> for AttributeValue {
1118    fn from(value: &str) -> Self {
1119        AttributeValue::S(value.to_string())
1120    }
1121}
1122
1123impl From<bool> for AttributeValue {
1124    fn from(value: bool) -> Self {
1125        AttributeValue::BOOL(value)
1126    }
1127}
1128
1129impl From<Vec<u8>> for AttributeValue {
1130    fn from(value: Vec<u8>) -> Self {
1131        AttributeValue::B(value)
1132    }
1133}
1134
1135impl From<&[u8]> for AttributeValue {
1136    fn from(value: &[u8]) -> Self {
1137        AttributeValue::B(value.to_vec())
1138    }
1139}
1140
1141// Integer types — all finite, all fit in DynamoDB's number range.
1142macro_rules! impl_from_integer {
1143    ($($t:ty),+) => {
1144        $(
1145            impl From<$t> for AttributeValue {
1146                fn from(value: $t) -> Self {
1147                    AttributeValue::N(value.to_string())
1148                }
1149            }
1150        )+
1151    };
1152}
1153
1154impl_from_integer!(i8, i16, i32, i64, i128, u8, u16, u32, u64, u128);
1155
1156// Container types
1157impl From<HashMap<String, AttributeValue>> for AttributeValue {
1158    fn from(value: HashMap<String, AttributeValue>) -> Self {
1159        AttributeValue::M(value)
1160    }
1161}
1162
1163impl From<Vec<AttributeValue>> for AttributeValue {
1164    fn from(value: Vec<AttributeValue>) -> Self {
1165        AttributeValue::L(value)
1166    }
1167}
1168
1169impl From<HashSet<String>> for AttributeValue {
1170    fn from(value: HashSet<String>) -> Self {
1171        AttributeValue::SS(value.into_iter().collect())
1172    }
1173}
1174
1175impl From<BTreeSet<String>> for AttributeValue {
1176    fn from(value: BTreeSet<String>) -> Self {
1177        AttributeValue::SS(value.into_iter().collect())
1178    }
1179}
1180
1181// --- TryFrom<T> for AttributeValue: fallible conversions (floats) ---
1182
1183impl TryFrom<f64> for AttributeValue {
1184    type Error = ConversionError;
1185
1186    fn try_from(value: f64) -> std::result::Result<Self, Self::Error> {
1187        if value.is_finite() {
1188            Ok(AttributeValue::N(value.to_string()))
1189        } else {
1190            Err(ConversionError {
1191                expected: "finite f64",
1192                actual: "NaN or Infinity",
1193            })
1194        }
1195    }
1196}
1197
1198impl TryFrom<f32> for AttributeValue {
1199    type Error = ConversionError;
1200
1201    fn try_from(value: f32) -> std::result::Result<Self, Self::Error> {
1202        if value.is_finite() {
1203            Ok(AttributeValue::N(value.to_string()))
1204        } else {
1205            Err(ConversionError {
1206                expected: "finite f32",
1207                actual: "NaN or Infinity",
1208            })
1209        }
1210    }
1211}
1212
1213// --- TryFrom<AttributeValue> for T: extract Rust types from AV ---
1214
1215impl TryFrom<AttributeValue> for String {
1216    type Error = ConversionError;
1217
1218    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1219        match value {
1220            AttributeValue::S(s) => Ok(s),
1221            other => Err(ConversionError {
1222                expected: "S",
1223                actual: other.type_name(),
1224            }),
1225        }
1226    }
1227}
1228
1229impl TryFrom<AttributeValue> for bool {
1230    type Error = ConversionError;
1231
1232    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1233        match value {
1234            AttributeValue::BOOL(b) => Ok(b),
1235            other => Err(ConversionError {
1236                expected: "BOOL",
1237                actual: other.type_name(),
1238            }),
1239        }
1240    }
1241}
1242
1243impl TryFrom<AttributeValue> for Vec<u8> {
1244    type Error = ConversionError;
1245
1246    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1247        match value {
1248            AttributeValue::B(b) => Ok(b),
1249            other => Err(ConversionError {
1250                expected: "B",
1251                actual: other.type_name(),
1252            }),
1253        }
1254    }
1255}
1256
1257macro_rules! impl_try_from_av_integer {
1258    ($($t:ty),+) => {
1259        $(
1260            impl TryFrom<AttributeValue> for $t {
1261                type Error = ConversionError;
1262
1263                fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1264                    match value {
1265                        AttributeValue::N(n) => n.parse::<$t>().map_err(|_| ConversionError {
1266                            expected: stringify!($t),
1267                            actual: "N (parse failed)",
1268                        }),
1269                        other => Err(ConversionError {
1270                            expected: "N",
1271                            actual: other.type_name(),
1272                        }),
1273                    }
1274                }
1275            }
1276        )+
1277    };
1278}
1279
1280impl_try_from_av_integer!(i8, i16, i32, i64, i128, u8, u16, u32, u64, u128);
1281
1282impl TryFrom<AttributeValue> for f64 {
1283    type Error = ConversionError;
1284
1285    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1286        match value {
1287            AttributeValue::N(n) => n.parse::<f64>().map_err(|_| ConversionError {
1288                expected: "f64",
1289                actual: "N (parse failed)",
1290            }),
1291            other => Err(ConversionError {
1292                expected: "N",
1293                actual: other.type_name(),
1294            }),
1295        }
1296    }
1297}
1298
1299impl TryFrom<AttributeValue> for f32 {
1300    type Error = ConversionError;
1301
1302    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1303        match value {
1304            AttributeValue::N(n) => n.parse::<f32>().map_err(|_| ConversionError {
1305                expected: "f32",
1306                actual: "N (parse failed)",
1307            }),
1308            other => Err(ConversionError {
1309                expected: "N",
1310                actual: other.type_name(),
1311            }),
1312        }
1313    }
1314}
1315
1316impl TryFrom<AttributeValue> for HashMap<String, AttributeValue> {
1317    type Error = ConversionError;
1318
1319    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1320        match value {
1321            AttributeValue::M(m) => Ok(m),
1322            other => Err(ConversionError {
1323                expected: "M",
1324                actual: other.type_name(),
1325            }),
1326        }
1327    }
1328}
1329
1330impl TryFrom<AttributeValue> for Vec<AttributeValue> {
1331    type Error = ConversionError;
1332
1333    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1334        match value {
1335            AttributeValue::L(l) => Ok(l),
1336            other => Err(ConversionError {
1337                expected: "L",
1338                actual: other.type_name(),
1339            }),
1340        }
1341    }
1342}
1343
1344impl TryFrom<AttributeValue> for Vec<String> {
1345    type Error = ConversionError;
1346
1347    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1348        match value {
1349            AttributeValue::SS(ss) => Ok(ss),
1350            AttributeValue::L(l) => {
1351                // Lenient: extract S values from a list
1352                l.into_iter()
1353                    .map(|av| match av {
1354                        AttributeValue::S(s) => Ok(s),
1355                        other => Err(ConversionError {
1356                            expected: "S (within L)",
1357                            actual: other.type_name(),
1358                        }),
1359                    })
1360                    .collect()
1361            }
1362            other => Err(ConversionError {
1363                expected: "SS or L",
1364                actual: other.type_name(),
1365            }),
1366        }
1367    }
1368}
1369
1370#[cfg(test)]
1371mod tests {
1372    use super::*;
1373
1374    #[test]
1375    fn test_serialize_string() {
1376        let val = AttributeValue::S("hello".to_string());
1377        let json = serde_json::to_string(&val).unwrap();
1378        assert_eq!(json, r#"{"S":"hello"}"#);
1379    }
1380
1381    #[test]
1382    fn test_serialize_number() {
1383        let val = AttributeValue::N("42".to_string());
1384        let json = serde_json::to_string(&val).unwrap();
1385        assert_eq!(json, r#"{"N":"42"}"#);
1386    }
1387
1388    #[test]
1389    fn test_serialize_binary() {
1390        let val = AttributeValue::B(vec![1, 2, 3]);
1391        let json = serde_json::to_string(&val).unwrap();
1392        assert_eq!(json, r#"{"B":"AQID"}"#);
1393    }
1394
1395    #[test]
1396    fn test_serialize_bool() {
1397        let val = AttributeValue::BOOL(true);
1398        let json = serde_json::to_string(&val).unwrap();
1399        assert_eq!(json, r#"{"BOOL":true}"#);
1400    }
1401
1402    #[test]
1403    fn test_serialize_null() {
1404        let val = AttributeValue::NULL(true);
1405        let json = serde_json::to_string(&val).unwrap();
1406        assert_eq!(json, r#"{"NULL":true}"#);
1407    }
1408
1409    #[test]
1410    fn test_deserialize_null_true() {
1411        let val: AttributeValue = serde_json::from_str(r#"{"NULL":true}"#).unwrap();
1412        assert_eq!(val, AttributeValue::NULL(true));
1413    }
1414
1415    #[test]
1416    fn test_deserialize_null_false_normalises_to_true() {
1417        // The NULL member is a plain boolean in the model, so {"NULL": false} is
1418        // valid input. AWS accepts it and reads it back as {"NULL": true}, so
1419        // normalise false to true on the way in.
1420        let val: AttributeValue = serde_json::from_str(r#"{"NULL":false}"#).unwrap();
1421        assert_eq!(val, AttributeValue::NULL(true));
1422    }
1423
1424    #[test]
1425    fn test_deserialize_null_non_boolean_rejected() {
1426        // A non-boolean NULL (e.g. {"NULL": "no"}) is a type error, not a value.
1427        let err = serde_json::from_str::<AttributeValue>(r#"{"NULL":"no"}"#).unwrap_err();
1428        assert!(
1429            err.to_string().contains("must have the value of true"),
1430            "unexpected error: {err}"
1431        );
1432    }
1433
1434    #[test]
1435    fn test_serialize_string_set() {
1436        let val = AttributeValue::SS(vec!["a".to_string(), "b".to_string()]);
1437        let json = serde_json::to_string(&val).unwrap();
1438        assert_eq!(json, r#"{"SS":["a","b"]}"#);
1439    }
1440
1441    #[test]
1442    fn test_serialize_list() {
1443        let val = AttributeValue::L(vec![
1444            AttributeValue::S("hello".to_string()),
1445            AttributeValue::N("42".to_string()),
1446        ]);
1447        let json = serde_json::to_string(&val).unwrap();
1448        assert_eq!(json, r#"{"L":[{"S":"hello"},{"N":"42"}]}"#);
1449    }
1450
1451    #[test]
1452    fn test_serialize_map() {
1453        let mut m = HashMap::new();
1454        m.insert("key".to_string(), AttributeValue::S("value".to_string()));
1455        let val = AttributeValue::M(m);
1456        let json = serde_json::to_string(&val).unwrap();
1457        assert_eq!(json, r#"{"M":{"key":{"S":"value"}}}"#);
1458    }
1459
1460    #[test]
1461    fn test_round_trip_all_types() {
1462        let values = vec![
1463            AttributeValue::S("hello".to_string()),
1464            AttributeValue::N("42.5".to_string()),
1465            AttributeValue::B(vec![0, 255, 128]),
1466            AttributeValue::BOOL(false),
1467            AttributeValue::NULL(true),
1468            AttributeValue::SS(vec!["x".to_string(), "y".to_string()]),
1469            AttributeValue::NS(vec!["1".to_string(), "2.5".to_string()]),
1470            AttributeValue::BS(vec![vec![1], vec![2, 3]]),
1471            AttributeValue::L(vec![
1472                AttributeValue::S("nested".to_string()),
1473                AttributeValue::N("99".to_string()),
1474            ]),
1475        ];
1476
1477        for val in values {
1478            let json = serde_json::to_string(&val).unwrap();
1479            let deserialized: AttributeValue = serde_json::from_str(&json).unwrap();
1480            assert_eq!(val, deserialized, "Round-trip failed for {json}");
1481        }
1482    }
1483
1484    #[test]
1485    fn test_size_string() {
1486        let val = AttributeValue::S("hello".to_string());
1487        assert_eq!(val.size(), 5);
1488    }
1489
1490    #[test]
1491    fn test_size_number() {
1492        // "42" has 2 significant digits → (2/2) + 1 = 2
1493        let val = AttributeValue::N("42".to_string());
1494        assert_eq!(val.size(), 2);
1495    }
1496
1497    #[test]
1498    fn test_size_bool() {
1499        assert_eq!(AttributeValue::BOOL(true).size(), 1);
1500    }
1501
1502    #[test]
1503    fn test_size_null() {
1504        assert_eq!(AttributeValue::NULL(true).size(), 1);
1505    }
1506
1507    #[test]
1508    fn test_key_string_s() {
1509        let val = AttributeValue::S("hello".to_string());
1510        assert_eq!(val.to_key_string(), Some("S:hello".to_string()));
1511    }
1512
1513    #[test]
1514    fn test_key_string_n() {
1515        let val = AttributeValue::N("42".to_string());
1516        let key = val.to_key_string().unwrap();
1517        assert!(key.starts_with("N:"));
1518    }
1519
1520    #[test]
1521    fn test_key_string_b() {
1522        let val = AttributeValue::B(vec![0xff, 0x00, 0xab]);
1523        assert_eq!(val.to_key_string(), Some("B:ff00ab".to_string()));
1524    }
1525
1526    #[test]
1527    fn test_key_string_non_key_type_returns_none() {
1528        assert_eq!(AttributeValue::BOOL(true).to_key_string(), None);
1529        assert_eq!(AttributeValue::L(vec![]).to_key_string(), None);
1530    }
1531
1532    // Number sort key ordering tests
1533    #[test]
1534    fn test_number_sort_ordering() {
1535        let numbers = vec![
1536            "-1000", "-100", "-10", "-1", "-0.5", "-0.001", "0", "0.001", "0.5", "1", "10", "100",
1537            "1000",
1538        ];
1539        let encoded: Vec<String> = numbers
1540            .iter()
1541            .map(|n| normalize_number_for_sort(n))
1542            .collect();
1543
1544        for i in 0..encoded.len() - 1 {
1545            assert!(
1546                encoded[i] < encoded[i + 1],
1547                "Sort order broken: {} ({}) should be < {} ({})",
1548                numbers[i],
1549                encoded[i],
1550                numbers[i + 1],
1551                encoded[i + 1]
1552            );
1553        }
1554    }
1555
1556    #[test]
1557    fn test_number_sort_zero_variants() {
1558        let z1 = normalize_number_for_sort("0");
1559        let z2 = normalize_number_for_sort("-0");
1560        let z3 = normalize_number_for_sort("0.0");
1561        assert_eq!(z1, z2);
1562        assert_eq!(z2, z3);
1563    }
1564
1565    #[test]
1566    fn test_number_sort_decimals() {
1567        let a = normalize_number_for_sort("1.5");
1568        let b = normalize_number_for_sort("2.5");
1569        assert!(a < b);
1570
1571        let c = normalize_number_for_sort("0.001");
1572        let d = normalize_number_for_sort("0.01");
1573        assert!(c < d);
1574    }
1575
1576    #[test]
1577    fn test_number_sort_scientific() {
1578        let a = normalize_number_for_sort("1e10");
1579        let b = normalize_number_for_sort("1e11");
1580        assert!(a < b);
1581
1582        let c = normalize_number_for_sort("-1e11");
1583        let d = normalize_number_for_sort("-1e10");
1584        assert!(c < d);
1585    }
1586
1587    // Number validation/normalisation is pinned to real DynamoDB behaviour
1588    // (captured against AWS for issue #109). validate_dynamo_number accepts
1589    // exactly the grammar DynamoDB accepts, including a leading '+'; the bare
1590    // and '+'-prefixed malformed forms are both rejected, matching AWS.
1591    #[test]
1592    fn test_validate_number_accepts_dynamodb_grammar() {
1593        for input in [
1594            "+5", "+1.5", "+0", "-0", "+0.0", "+1e2", "1e+2", "1.5E+3", "-7", "+.5", ".5", "5.",
1595            "00042", "1.23E10", "+1e-2", "1E-130", "+1E-130",
1596        ] {
1597            assert!(
1598                validate_dynamo_number(input).is_ok(),
1599                "expected {input} to validate, got {:?}",
1600                validate_dynamo_number(input)
1601            );
1602        }
1603    }
1604
1605    #[test]
1606    fn test_validate_number_rejects_malformed() {
1607        // Every one of these is rejected by real DynamoDB. Note that whitespace
1608        // (leading, trailing, or internal) is rejected, not trimmed.
1609        for input in [
1610            "+e2", "e2", "+1+2", "1+2", "+1.2.3", "1.2.3", "++5", "+-5", "-+5", "+", "-", "1e",
1611            "1e+", ".", "1.2e3.4", "0x5", "NaN", "Infinity", "1_000", " 5", "5 ", "1 5", "",
1612        ] {
1613            assert!(
1614                matches!(
1615                    validate_dynamo_number(input),
1616                    Err(crate::errors::DynoxideError::ValidationException(_))
1617                ),
1618                "expected {input:?} to be rejected with ValidationException, got {:?}",
1619                validate_dynamo_number(input)
1620            );
1621        }
1622    }
1623
1624    #[test]
1625    fn test_normalize_number_matches_dynamodb() {
1626        for (input, stored) in [
1627            ("+5", "5"),
1628            ("+1.5", "1.5"),
1629            ("+0", "0"),
1630            ("-0", "0"),
1631            ("+0.0", "0"),
1632            ("+1e2", "100"),
1633            ("1e+2", "100"),
1634            ("1.5E+3", "1500"),
1635            ("-7", "-7"),
1636            ("+.5", "0.5"),
1637            (".5", "0.5"),
1638            ("5.", "5"),
1639            ("00042", "42"),
1640            ("1.23E10", "12300000000"),
1641            ("+1e-2", "0.01"),
1642        ] {
1643            assert_eq!(
1644                normalize_dynamo_number(input),
1645                stored,
1646                "{input} should normalise to {stored}"
1647            );
1648        }
1649    }
1650
1651    #[test]
1652    fn test_type_name() {
1653        assert_eq!(AttributeValue::S("".to_string()).type_name(), "S");
1654        assert_eq!(AttributeValue::N("0".to_string()).type_name(), "N");
1655        assert_eq!(AttributeValue::B(vec![]).type_name(), "B");
1656        assert_eq!(AttributeValue::BOOL(true).type_name(), "BOOL");
1657        assert_eq!(AttributeValue::NULL(true).type_name(), "NULL");
1658        assert_eq!(AttributeValue::SS(vec![]).type_name(), "SS");
1659        assert_eq!(AttributeValue::NS(vec![]).type_name(), "NS");
1660        assert_eq!(AttributeValue::BS(vec![]).type_name(), "BS");
1661        assert_eq!(AttributeValue::L(vec![]).type_name(), "L");
1662        assert_eq!(AttributeValue::M(HashMap::new()).type_name(), "M");
1663    }
1664}