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