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) => number_size(n),
48            AttributeValue::B(b) => b.len(),
49            AttributeValue::BOOL(_) => 1,
50            AttributeValue::NULL(_) => 1,
51            AttributeValue::SS(ss) => ss.iter().map(|s| s.len()).sum(),
52            AttributeValue::NS(ns) => ns.iter().map(|n| number_size(n)).sum(),
53            AttributeValue::BS(bs) => bs.iter().map(|b| b.len()).sum(),
54            AttributeValue::L(items) => {
55                // List overhead: 3 bytes + 1 byte per element + sum of element sizes
56                3 + items.len() + items.iter().map(|v| v.size()).sum::<usize>()
57            }
58            AttributeValue::M(map) => {
59                // Map overhead: 3 bytes + sum of (key_len + 1 + value_size) per entry
60                3 + map
61                    .iter()
62                    .map(|(k, v)| k.len() + 1 + v.size())
63                    .sum::<usize>()
64            }
65        }
66    }
67
68    /// Returns the DynamoDB type descriptor string for this value.
69    pub fn type_name(&self) -> &'static str {
70        match self {
71            AttributeValue::S(_) => "S",
72            AttributeValue::N(_) => "N",
73            AttributeValue::B(_) => "B",
74            AttributeValue::BOOL(_) => "BOOL",
75            AttributeValue::NULL(_) => "NULL",
76            AttributeValue::SS(_) => "SS",
77            AttributeValue::NS(_) => "NS",
78            AttributeValue::BS(_) => "BS",
79            AttributeValue::L(_) => "L",
80            AttributeValue::M(_) => "M",
81        }
82    }
83
84    /// Returns true if this is a scalar type (S, N, B, BOOL, NULL).
85    pub fn is_scalar(&self) -> bool {
86        matches!(
87            self,
88            AttributeValue::S(_)
89                | AttributeValue::N(_)
90                | AttributeValue::B(_)
91                | AttributeValue::BOOL(_)
92                | AttributeValue::NULL(_)
93        )
94    }
95
96    /// Returns true if this is a set type (SS, NS, BS).
97    pub fn is_set(&self) -> bool {
98        matches!(
99            self,
100            AttributeValue::SS(_) | AttributeValue::NS(_) | AttributeValue::BS(_)
101        )
102    }
103
104    /// Serialize this value to a deterministic TEXT representation
105    /// for use as a SQLite primary key column (pk or sk).
106    ///
107    /// - S: stored as-is (UTF-8 text sorts correctly)
108    /// - N: normalized to a comparable string encoding
109    /// - B: hex-encoded (preserves byte ordering)
110    pub fn to_key_string(&self) -> Option<String> {
111        match self {
112            AttributeValue::S(s) => Some(format!("S:{s}")),
113            AttributeValue::N(n) => Some(format!("N:{}", normalize_number_for_sort(n))),
114            AttributeValue::B(b) => Some(format!("B:{}", hex_encode(b))),
115            _ => None, // Only S, N, B can be key types
116        }
117    }
118}
119
120impl fmt::Display for AttributeValue {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        match self {
123            AttributeValue::S(s) => write!(f, "\"{s}\""),
124            AttributeValue::N(n) => write!(f, "{n}"),
125            AttributeValue::B(b) => write!(f, "<binary {} bytes>", b.len()),
126            AttributeValue::BOOL(b) => write!(f, "{b}"),
127            AttributeValue::NULL(_) => write!(f, "null"),
128            AttributeValue::SS(ss) => write!(f, "{ss:?}"),
129            AttributeValue::NS(ns) => write!(f, "{ns:?}"),
130            AttributeValue::BS(bs) => write!(f, "<binary set {} items>", bs.len()),
131            AttributeValue::L(items) => write!(f, "<list {} items>", items.len()),
132            AttributeValue::M(map) => write!(f, "<map {} keys>", map.len()),
133        }
134    }
135}
136
137// ---------------------------------------------------------------------------
138// Custom serde: DynamoDB JSON format {"S": "hello"}, {"N": "42"}, etc.
139// ---------------------------------------------------------------------------
140
141impl Serialize for AttributeValue {
142    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
143    where
144        S: Serializer,
145    {
146        let mut map = serializer.serialize_map(Some(1))?;
147        match self {
148            AttributeValue::S(s) => map.serialize_entry("S", s)?,
149            AttributeValue::N(n) => map.serialize_entry("N", n)?,
150            AttributeValue::B(b) => {
151                map.serialize_entry("B", &BASE64.encode(b))?;
152            }
153            AttributeValue::BOOL(b) => map.serialize_entry("BOOL", b)?,
154            AttributeValue::NULL(n) => map.serialize_entry("NULL", n)?,
155            AttributeValue::SS(ss) => map.serialize_entry("SS", ss)?,
156            AttributeValue::NS(ns) => map.serialize_entry("NS", ns)?,
157            AttributeValue::BS(bs) => {
158                let encoded: Vec<String> = bs.iter().map(|b| BASE64.encode(b)).collect();
159                map.serialize_entry("BS", &encoded)?;
160            }
161            AttributeValue::L(items) => map.serialize_entry("L", items)?,
162            AttributeValue::M(m) => map.serialize_entry("M", m)?,
163        }
164        map.end()
165    }
166}
167
168impl<'de> Deserialize<'de> for AttributeValue {
169    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
170    where
171        D: Deserializer<'de>,
172    {
173        // Deserialize as raw JSON Value first so we can inspect all keys
174        let raw = serde_json::Value::deserialize(deserializer)?;
175
176        let obj = raw
177            .as_object()
178            .ok_or_else(|| de::Error::custom("empty AttributeValue object"))?;
179
180        if obj.is_empty() {
181            return Err(de::Error::custom("empty AttributeValue object"));
182        }
183
184        // Collect known type keys
185        let known_types = ["S", "N", "B", "BOOL", "NULL", "SS", "NS", "BS", "L", "M"];
186        let present: Vec<&str> = obj
187            .keys()
188            .filter(|k| known_types.contains(&k.as_str()))
189            .map(|k| k.as_str())
190            .collect();
191
192        if present.is_empty() {
193            return Err(de::Error::custom(
194                "Supplied AttributeValue is empty, must contain exactly one of the supported datatypes",
195            ));
196        }
197
198        // Validate numbers in ALL type keys before checking for multi-type.
199        // DynamoDB validates number format before rejecting multi-type.
200        for &type_key in &present {
201            match type_key {
202                "N" => {
203                    if let Some(n) = obj.get("N").and_then(|v| v.as_str()) {
204                        validate_number_in_deser(n).map_err(de::Error::custom)?;
205                    }
206                }
207                "NS" => {
208                    if let Some(arr) = obj.get("NS").and_then(|v| v.as_array()) {
209                        for item in arr {
210                            if let Some(n) = item.as_str() {
211                                validate_number_in_deser(n).map_err(de::Error::custom)?;
212                            }
213                        }
214                    }
215                }
216                _ => {}
217            }
218        }
219
220        // Check for multiple type keys
221        if present.len() > 1 {
222            return Err(de::Error::custom(
223                "VALIDATION:Supplied AttributeValue has more than one datatypes set, \
224                 must contain exactly one of the supported datatypes",
225            ));
226        }
227
228        let type_key = present[0];
229        let val = &obj[type_key];
230
231        match type_key {
232            "S" => {
233                let s = val
234                    .as_str()
235                    .ok_or_else(|| de::Error::custom("expected string for S"))?;
236                Ok(AttributeValue::S(s.to_string()))
237            }
238            "N" => {
239                let n = val
240                    .as_str()
241                    .ok_or_else(|| de::Error::custom("expected string for N"))?;
242                Ok(AttributeValue::N(n.to_string()))
243            }
244            "B" => {
245                let encoded = val
246                    .as_str()
247                    .ok_or_else(|| de::Error::custom("expected string for B"))?;
248                let bytes = BASE64
249                    .decode(encoded)
250                    .map_err(|e| de::Error::custom(format!("invalid base64: {e}")))?;
251                Ok(AttributeValue::B(bytes))
252            }
253            "BOOL" => {
254                let b = val
255                    .as_bool()
256                    .ok_or_else(|| de::Error::custom("expected boolean for BOOL"))?;
257                Ok(AttributeValue::BOOL(b))
258            }
259            "NULL" => {
260                // AWS requires the NULL member to be exactly `true`; `{"NULL": false}`
261                // and non-boolean values (e.g. `{"NULL": "no"}`) are both rejected.
262                // dynoxide previously normalised `false` to `true` (#62/#74); real
263                // DynamoDB (eu-west-2) rejects it, so we match that here. The
264                // VALIDATION_REQUEST marker puts the rejection in the
265                // request-validation class that PutItem and UpdateItem envelope
266                // (see `crate::serde_errors`); other operations report it bare.
267                if val.as_bool() != Some(true) {
268                    return Err(de::Error::custom(format!(
269                        "{}One or more parameter values were invalid: \
270                         Null attribute value types must have the value of true",
271                        crate::serde_errors::REQUEST_VALIDATION_MARKER
272                    )));
273                }
274                Ok(AttributeValue::NULL(true))
275            }
276            "SS" => {
277                let arr = val
278                    .as_array()
279                    .ok_or_else(|| de::Error::custom("expected array for SS"))?;
280                let ss: Result<Vec<String>, _> = arr
281                    .iter()
282                    .map(|v| {
283                        v.as_str()
284                            .map(|s| s.to_string())
285                            .ok_or_else(|| de::Error::custom("expected string in SS"))
286                    })
287                    .collect();
288                Ok(AttributeValue::SS(ss?))
289            }
290            "NS" => {
291                let arr = val
292                    .as_array()
293                    .ok_or_else(|| de::Error::custom("expected array for NS"))?;
294                let ns: Result<Vec<String>, _> = arr
295                    .iter()
296                    .map(|v| {
297                        v.as_str()
298                            .map(|s| s.to_string())
299                            .ok_or_else(|| de::Error::custom("expected string in NS"))
300                    })
301                    .collect();
302                Ok(AttributeValue::NS(ns?))
303            }
304            "BS" => {
305                let arr = val
306                    .as_array()
307                    .ok_or_else(|| de::Error::custom("expected array for BS"))?;
308                let mut decoded = Vec::with_capacity(arr.len());
309                for item in arr {
310                    let encoded = item
311                        .as_str()
312                        .ok_or_else(|| de::Error::custom("expected string in BS"))?;
313                    decoded.push(
314                        BASE64
315                            .decode(encoded)
316                            .map_err(|e| de::Error::custom(format!("invalid base64: {e}")))?,
317                    );
318                }
319                Ok(AttributeValue::BS(decoded))
320            }
321            "L" => {
322                let arr = val
323                    .as_array()
324                    .ok_or_else(|| de::Error::custom("expected array for L"))?;
325                let list: Result<Vec<AttributeValue>, _> = arr
326                    .iter()
327                    .map(|v| serde_json::from_value(v.clone()).map_err(de::Error::custom))
328                    .collect();
329                Ok(AttributeValue::L(list?))
330            }
331            "M" => {
332                let map_val = val
333                    .as_object()
334                    .ok_or_else(|| de::Error::custom("expected object for M"))?;
335                let mut result = std::collections::HashMap::new();
336                for (k, v) in map_val {
337                    let av: AttributeValue =
338                        serde_json::from_value(v.clone()).map_err(de::Error::custom)?;
339                    result.insert(k.clone(), av);
340                }
341                Ok(AttributeValue::M(result))
342            }
343            _ => unreachable!(),
344        }
345    }
346}
347
348/// Validate a number string during AttributeValue deserialization.
349///
350/// Returns DynamoDB-matching error messages for invalid numbers.
351/// Error messages are returned WITHOUT the VALIDATION: prefix since they
352/// bypass the normal validation flow; the server routes them based on
353/// message content (see `crate::serde_errors::deserialize`).
354fn validate_number_in_deser(n: &str) -> Result<(), String> {
355    // validate_dynamo_number is the single source of truth for number format
356    // and precision/range; the deser path only reshapes the error message.
357    match validate_dynamo_number(n) {
358        Ok(()) => Ok(()),
359        Err(crate::errors::DynoxideError::ValidationException(m)) => Err(format!("VALIDATION:{m}")),
360        Err(e) => Err(format!("VALIDATION:{e}")),
361    }
362}
363
364// ---------------------------------------------------------------------------
365// Number sort key normalization
366// ---------------------------------------------------------------------------
367
368/// Normalize a DynamoDB number string into a comparable string that sorts
369/// correctly in SQLite TEXT collation.
370///
371/// Encoding scheme:
372/// - Positive numbers: "1" + zero-padded exponent (4 digits, offset by 5000) + normalized mantissa
373/// - Zero: "1" + "5000" + "0" (padded)
374/// - Negative numbers: "0" + complement of (exponent + mantissa) so they sort before positives
375///
376/// DynamoDB numbers: up to 38 digits of precision, range ~-1E+126 to ~+1E+126.
377pub fn normalize_number_for_sort(num_str: &str) -> String {
378    let trimmed = num_str.trim();
379
380    if trimmed.is_empty() || trimmed == "0" || trimmed == "-0" || trimmed == "0.0" {
381        return zero_encoding();
382    }
383
384    let negative = trimmed.starts_with('-');
385    let abs_str = if negative { &trimmed[1..] } else { trimmed };
386
387    // Parse into mantissa digits and exponent
388    let (mantissa_digits, exponent) = parse_number_parts(abs_str);
389
390    if mantissa_digits.is_empty() || mantissa_digits.iter().all(|&d| d == 0) {
391        return zero_encoding();
392    }
393
394    if negative {
395        encode_negative(&mantissa_digits, exponent)
396    } else {
397        encode_positive(&mantissa_digits, exponent)
398    }
399}
400
401/// Validate a DynamoDB number string against DynamoDB's constraints:
402/// - Up to 38 significant digits
403/// - Magnitude at most 9.9999999999999999999999999999999999999E+125
404/// - Positive values must be at least 1E-130
405/// - Negative values must be at most -1E-130
406pub fn validate_dynamo_number(
407    num_str: &str,
408) -> std::result::Result<(), crate::errors::DynoxideError> {
409    if num_str.is_empty() {
410        return Err(crate::errors::DynoxideError::ValidationException(
411            "The parameter cannot be converted to a numeric value".to_string(),
412        ));
413    }
414
415    // DynamoDB accepts a specific numeric grammar and rejects everything else,
416    // including any surrounding or internal whitespace. Verified against real
417    // DynamoDB (see the unit tests below and the dynamodb-conformance suite):
418    //   sign?  coefficient  exponent?
419    //   coefficient = at least one digit, at most one '.' (e.g. 5, 5., .5, +1.5)
420    //   exponent    = ('e'|'E') sign? at least one digit (e.g. e2, E+3, e-130)
421    // Accepts: +5, -7, +.5, 5., 1e+2, 1.5E+3, 1E-130, 00042
422    // Rejects: +e2, 1+2, 1.2.3, ++5, 1e, ., NaN, "1_000", " 5"
423    if !is_well_formed_dynamo_number(num_str) {
424        return Err(crate::errors::DynoxideError::ValidationException(format!(
425            "The parameter cannot be converted to a numeric value: {num_str}"
426        )));
427    }
428
429    // parse_number_parts ignores the sign characters, so the magnitude checks
430    // below hold for both signs.
431    let (mantissa_digits, exponent) = parse_number_parts(num_str);
432
433    // Zero is always valid
434    if mantissa_digits.is_empty() || mantissa_digits.iter().all(|&d| d == 0) {
435        return Ok(());
436    }
437
438    // Check significant digits (mantissa_digits has leading/trailing zeros already stripped)
439    if mantissa_digits.len() > 38 {
440        return Err(crate::errors::DynoxideError::ValidationException(
441            "Attempting to store more than 38 significant digits in a Number".to_string(),
442        ));
443    }
444
445    // Check magnitude: exponent represents the power such that value = 0.mantissa * 10^exponent
446    // Max magnitude: 9.999...E+125 means exponent = 126 (since 0.999... * 10^126 = 9.99...E+125)
447    if exponent > 126 {
448        return Err(crate::errors::DynoxideError::ValidationException(
449            "Number overflow. Attempting to store a number with magnitude larger than supported range"
450                .to_string(),
451        ));
452    }
453
454    // Check underflow for non-zero values
455    // Min positive: 1E-130 means exponent = -129 (since 0.1 * 10^-129 = 1E-130)
456    // But with more digits, exponent can be lower, e.g. 1.0E-130 has (mantissa=[1], exponent=-129)
457    // Actually, the smallest representable is 1E-130. In our representation, 1E-130 = 0.1 * 10^-129
458    // So exponent = -129 with mantissa [1].
459    // For 1E-131 = 0.1 * 10^-130, exponent = -130 - that's too small.
460    if exponent < -129 {
461        return Err(crate::errors::DynoxideError::ValidationException(
462            "Number underflow. Attempting to store a number with magnitude smaller than supported range"
463                .to_string(),
464        ));
465    }
466
467    Ok(())
468}
469
470/// Returns true when `s` matches DynamoDB's numeric grammar exactly:
471/// `sign? coefficient exponent?` where the coefficient carries at least one
472/// digit and at most one `.`, and the exponent (if present) carries at least
473/// one digit. No whitespace or stray characters are tolerated. This mirrors
474/// what real DynamoDB accepts (verified against AWS).
475fn is_well_formed_dynamo_number(s: &str) -> bool {
476    let bytes = s.as_bytes();
477    let n = bytes.len();
478    let mut i = 0;
479
480    // Optional leading sign.
481    if i < n && (bytes[i] == b'+' || bytes[i] == b'-') {
482        i += 1;
483    }
484
485    // Coefficient: digits with at most one decimal point, at least one digit.
486    let mut coeff_digits = 0usize;
487    let mut dots = 0usize;
488    while i < n && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
489        if bytes[i] == b'.' {
490            dots += 1;
491            if dots > 1 {
492                return false;
493            }
494        } else {
495            coeff_digits += 1;
496        }
497        i += 1;
498    }
499    if coeff_digits == 0 {
500        return false;
501    }
502
503    // Optional exponent: 'e'/'E', optional sign, at least one digit.
504    if i < n && (bytes[i] == b'e' || bytes[i] == b'E') {
505        i += 1;
506        if i < n && (bytes[i] == b'+' || bytes[i] == b'-') {
507            i += 1;
508        }
509        let mut exp_digits = 0usize;
510        while i < n && bytes[i].is_ascii_digit() {
511            exp_digits += 1;
512            i += 1;
513        }
514        if exp_digits == 0 {
515            return false;
516        }
517    }
518
519    // Anything left over (stray chars, trailing whitespace) is invalid.
520    i == n
521}
522
523/// Byte cost of a number, as DynamoDB accounts for it in an item's size.
524///
525/// One byte, plus `ceil(integer significant digits / 2)` and
526/// `ceil(fraction significant digits / 2)`, over the value with leading and
527/// trailing zeros trimmed. Measured byte-exact against eu-west-2 by bisecting
528/// the 400KB item-size gate.
529///
530/// Counting significant digits rather than characters is what makes the answer
531/// survive storage: DynamoDB expands scientific notation when it stores a
532/// number, so a measure taken over the digits of the string would give one
533/// answer for the request and another for the stored row. Every write path
534/// checks the limit somewhere either side of that expansion, and they can only
535/// agree if the measure does not move.
536pub fn number_size(num_str: &str) -> usize {
537    let trimmed = num_str.trim();
538    let negative = trimmed.starts_with('-');
539    let abs = trimmed.strip_prefix(['-', '+']).unwrap_or(trimmed);
540    let (mantissa, exponent) = parse_number_parts(abs);
541
542    // `exponent` is how many significant digits fall before the decimal point:
543    // negative for a pure fraction, past the end of the mantissa for a value
544    // whose trailing zeros were trimmed. Either way the split is a clamp.
545    let int_digits = exponent.clamp(0, mantissa.len() as i32) as usize;
546    let frac_digits = mantissa.len() - int_digits;
547
548    // A negative number costs a byte more than the same magnitude positive:
549    // `-42` measures 3 where `42` measures 2. Zero is never negative, whatever
550    // the literal said, so `-0` costs the same as `0`.
551    let sign = usize::from(negative && !mantissa.is_empty());
552
553    int_digits.div_ceil(2) + frac_digits.div_ceil(2) + 1 + sign
554}
555
556/// Normalize a DynamoDB number string to its canonical form.
557///
558/// DynamoDB normalises numbers when storing them:
559/// - Leading zeros are stripped (`0042` → `42`)
560/// - Trailing zeros after decimal are stripped (`1.200` → `1.2`)
561/// - Scientific notation is expanded to full decimal form
562/// - Zero is represented as `0`
563pub fn normalize_dynamo_number(num_str: &str) -> String {
564    let trimmed = num_str.trim();
565    if trimmed.is_empty() {
566        return "0".to_string();
567    }
568    // The expansion below walks the exponent one character at a time, so an
569    // exponent DynamoDB would never accept turns a short string into a
570    // multi-gigabyte one. `1e-2147483647` is nine characters in and about two
571    // gigabytes out. Outside the range DynamoDB itself accepts there is no
572    // canonical form worth producing, so hand the input back untouched.
573    if validate_dynamo_number(trimmed).is_err() {
574        return trimmed.to_string();
575    }
576
577    let negative = trimmed.starts_with('-');
578    let abs_str = if negative {
579        &trimmed[1..]
580    } else {
581        trimmed.trim_start_matches('+')
582    };
583
584    let (mantissa_digits, exponent) = parse_number_parts(abs_str);
585
586    // Zero
587    if mantissa_digits.is_empty() {
588        return "0".to_string();
589    }
590
591    // Reconstruct: mantissa_digits represent the significant digits,
592    // exponent is the power of 10 such that value = 0.mantissa * 10^exponent
593    // e.g., 12345 → mantissa=[1,2,3,4,5], exponent=5 → 12345
594    // e.g., 0.00123 → mantissa=[1,2,3], exponent=-2 → 0.00123
595    let num_digits = mantissa_digits.len() as i32;
596    let int_digits = exponent; // number of digits before the decimal point
597
598    let mut result = String::new();
599    if negative {
600        result.push('-');
601    }
602
603    if int_digits <= 0 {
604        // Pure fraction: 0.000...digits
605        result.push_str("0.");
606        for _ in 0..(-int_digits) {
607            result.push('0');
608        }
609        for &d in &mantissa_digits {
610            result.push((b'0' + d) as char);
611        }
612    } else if int_digits >= num_digits {
613        // Pure integer: digits followed by trailing zeros
614        for &d in &mantissa_digits {
615            result.push((b'0' + d) as char);
616        }
617        for _ in 0..(int_digits - num_digits) {
618            result.push('0');
619        }
620    } else {
621        // Mixed: some digits before decimal, some after
622        let int_part = int_digits as usize;
623        for &d in &mantissa_digits[..int_part] {
624            result.push((b'0' + d) as char);
625        }
626        result.push('.');
627        for &d in &mantissa_digits[int_part..] {
628            result.push((b'0' + d) as char);
629        }
630    }
631
632    result
633}
634
635fn zero_encoding() -> String {
636    // Zero sorts between negative (prefix "0") and positive (prefix "2")
637    format!("1{}{}", "0".repeat(4), "0".repeat(40))
638}
639
640fn encode_positive(mantissa: &[u8], exponent: i32) -> String {
641    let exp_encoded = (exponent + 5000) as u16;
642    let mantissa_str = mantissa_to_string(mantissa, 40);
643    format!("2{exp_encoded:04}{mantissa_str}")
644}
645
646fn encode_negative(mantissa: &[u8], exponent: i32) -> String {
647    // For negatives, we complement everything so larger absolute values sort first (smaller)
648    let exp_encoded = 9999 - (exponent + 5000) as u16;
649    let mantissa_str = complement_mantissa(mantissa, 40);
650    format!("0{exp_encoded:04}{mantissa_str}")
651}
652
653/// Parse a non-negative number string into (mantissa digits, exponent).
654/// Mantissa is normalized: first digit is non-zero, exponent is the power of 10
655/// such that the number = 0.mantissa * 10^exponent.
656pub(crate) fn parse_number_parts(s: &str) -> (Vec<u8>, i32) {
657    // Handle scientific notation
658    let (coeff, exp_part) = if let Some(pos) = s.to_ascii_lowercase().find('e') {
659        let coeff = &s[..pos];
660        let exp: i32 = s[pos + 1..].parse().unwrap_or(0);
661        (coeff, exp)
662    } else {
663        (s, 0)
664    };
665
666    // Split coefficient into integer and fraction parts
667    let (int_part, frac_part) = if let Some(dot) = coeff.find('.') {
668        (&coeff[..dot], &coeff[dot + 1..])
669    } else {
670        (coeff, "")
671    };
672
673    // Collect all digits
674    let mut digits: Vec<u8> = Vec::new();
675    for ch in int_part.chars().chain(frac_part.chars()) {
676        if ch.is_ascii_digit() {
677            digits.push(ch as u8 - b'0');
678        }
679    }
680
681    if digits.is_empty() {
682        return (vec![], 0);
683    }
684
685    // The integer part length gives us the base exponent
686    let int_len = int_part.chars().filter(|c| c.is_ascii_digit()).count() as i32;
687
688    // Find first non-zero digit
689    let leading_zeros = digits.iter().take_while(|&&d| d == 0).count();
690    digits.drain(..leading_zeros);
691
692    // Trim trailing zeros
693    while digits.last() == Some(&0) {
694        digits.pop();
695    }
696
697    if digits.is_empty() {
698        return (vec![], 0);
699    }
700
701    // exponent = int_len - leading_zeros + exp_part
702    // But we need to account for whether leading zeros were in int or frac part
703    let exponent = int_len - leading_zeros as i32 + exp_part;
704
705    (digits, exponent)
706}
707
708fn mantissa_to_string(digits: &[u8], width: usize) -> String {
709    let mut s = String::with_capacity(width);
710    for &d in digits.iter().take(width) {
711        s.push((b'0' + d) as char);
712    }
713    while s.len() < width {
714        s.push('0');
715    }
716    s
717}
718
719fn complement_mantissa(digits: &[u8], width: usize) -> String {
720    let mut s = String::with_capacity(width);
721    for i in 0..width {
722        let d = if i < digits.len() { digits[i] } else { 0 };
723        s.push((b'0' + (9 - d)) as char);
724    }
725    s
726}
727
728/// Hex-encode bytes (lowercase) for binary key storage.
729fn hex_encode(bytes: &[u8]) -> String {
730    let mut s = String::with_capacity(bytes.len() * 2);
731    for &b in bytes {
732        s.push_str(&format!("{b:02x}"));
733    }
734    s
735}
736
737// ---------------------------------------------------------------------------
738// Item helpers
739// ---------------------------------------------------------------------------
740
741/// A DynamoDB item: a map of attribute names to values.
742pub type Item = HashMap<String, AttributeValue>;
743
744/// SSE specification for server-side encryption settings.
745#[derive(Debug, Clone, Default, Serialize, Deserialize)]
746pub struct SseSpecification {
747    #[serde(rename = "Enabled", default)]
748    pub enabled: Option<bool>,
749    #[serde(rename = "SSEType", default)]
750    pub sse_type: Option<String>,
751    #[serde(rename = "KMSMasterKeyId", default)]
752    pub kms_master_key_id: Option<String>,
753}
754
755/// DynamoDB Tag (key-value pair attached to a resource).
756#[derive(Debug, Clone, Default, Serialize, Deserialize)]
757pub struct Tag {
758    #[serde(rename = "Key")]
759    pub key: String,
760    #[serde(rename = "Value")]
761    pub value: String,
762}
763
764/// Calculate the total size of a DynamoDB item in bytes.
765pub fn item_size(item: &Item) -> usize {
766    item.iter()
767        .map(|(name, value)| name.len() + value.size())
768        .sum()
769}
770
771/// Maximum item size in bytes (400 KB).
772pub const MAX_ITEM_SIZE: usize = 400 * 1024;
773
774/// ItemCollectionMetrics returned when `ReturnItemCollectionMetrics: SIZE` is set
775/// and the table has local secondary indexes.
776#[derive(Debug, Clone, Serialize, Deserialize)]
777pub struct ItemCollectionMetrics {
778    #[serde(rename = "ItemCollectionKey")]
779    pub item_collection_key: HashMap<String, AttributeValue>,
780    #[serde(rename = "SizeEstimateRangeGB")]
781    pub size_estimate_range_gb: Vec<f64>,
782}
783
784/// ConsumedCapacity returned when `ReturnConsumedCapacity` is set.
785#[derive(Debug, Clone, Default, Serialize, Deserialize)]
786pub struct ConsumedCapacity {
787    #[serde(rename = "TableName")]
788    pub table_name: String,
789    #[serde(rename = "CapacityUnits")]
790    pub capacity_units: f64,
791    #[serde(rename = "ReadCapacityUnits", skip_serializing_if = "Option::is_none")]
792    pub read_capacity_units: Option<f64>,
793    #[serde(rename = "WriteCapacityUnits", skip_serializing_if = "Option::is_none")]
794    pub write_capacity_units: Option<f64>,
795    #[serde(rename = "Table", skip_serializing_if = "Option::is_none")]
796    pub table: Option<CapacityDetail>,
797    #[serde(
798        rename = "GlobalSecondaryIndexes",
799        skip_serializing_if = "Option::is_none"
800    )]
801    pub global_secondary_indexes: Option<HashMap<String, CapacityDetail>>,
802    #[serde(
803        rename = "LocalSecondaryIndexes",
804        skip_serializing_if = "Option::is_none"
805    )]
806    pub local_secondary_indexes: Option<HashMap<String, CapacityDetail>>,
807    /// Per-vector-index replication cost. Reported under `INDEXES` alone, and
808    /// never folded into `capacity_units`: vector replication is billed in
809    /// bytes on its own axis rather than in capacity units.
810    #[serde(rename = "VectorIndexes", skip_serializing_if = "Option::is_none")]
811    pub vector_indexes: Option<HashMap<String, VectorCapacityDetail>>,
812}
813
814/// Per-resource capacity detail.
815#[derive(Debug, Clone, Default, Serialize, Deserialize)]
816pub struct CapacityDetail {
817    #[serde(rename = "CapacityUnits")]
818    pub capacity_units: f64,
819    #[serde(rename = "ReadCapacityUnits", skip_serializing_if = "Option::is_none")]
820    pub read_capacity_units: Option<f64>,
821    #[serde(rename = "WriteCapacityUnits", skip_serializing_if = "Option::is_none")]
822    pub write_capacity_units: Option<f64>,
823}
824
825/// One vector index's replication cost for a write.
826///
827/// Vector indexes are billed in bytes rather than capacity units, so this
828/// carries no `CapacityUnits` and the figure never reaches the response's
829/// total. An index a write leaves alone is absent from the map rather than
830/// present and zeroed (captured 2026-08-11, eu-west-2).
831#[derive(Debug, Clone, Default, Serialize, Deserialize)]
832pub struct VectorCapacityDetail {
833    #[serde(rename = "VectorWriteRequestBytes")]
834    pub vector_write_request_bytes: f64,
835}
836
837/// The `ConsumedCapacity` a `SearchVectors` response carries.
838///
839/// The shape has no `CapacityUnits` and no `TableName`, reads identically under
840/// `TOTAL` and `INDEXES`, and is absent under `NONE` (captured 2026-08-11,
841/// eu-west-2). Despite the field name, real DynamoDB bills the figure on the
842/// data the search read rather than on the request, and does not reproduce it
843/// between identical calls; Dynoxide reports a deterministic stand-in instead.
844#[derive(Debug, Clone, Default, Serialize, Deserialize)]
845pub struct VectorSearchCapacity {
846    #[serde(rename = "VectorSearchRequestBytes")]
847    pub vector_search_request_bytes: f64,
848}
849
850/// The billable minimum for a vector request, in bytes.
851///
852/// DynamoDB documents a 1KB minimum on vector billing and the capture observed
853/// 1024.0 for a three-dimensional fixture, which is this floor rather than a
854/// constant.
855///
856/// The two axes above the floor are not on the same footing. The write figure
857/// is captured byte-exact (eu-west-2, 2026-08-18): five fixtures from 3 to 512
858/// dimensions fit `4 * dimensions + vector attribute name + item size of the
859/// rest of the projected entry` with no residual, and the tests fail on a
860/// one-byte error. The search figure has no oracle at all, because real
861/// DynamoDB does not reproduce its own, so what Dynoxide reports there is a
862/// deterministic stand-in rather than a match.
863pub const MIN_VECTOR_REQUEST_BYTES: f64 = 1024.0;
864
865/// The billable byte figure for a vector request measuring `bytes`.
866pub fn vector_request_bytes(bytes: usize) -> f64 {
867    (bytes as f64).max(MIN_VECTOR_REQUEST_BYTES)
868}
869
870/// The transactional capacity multiplier. `TransactWriteItems` and
871/// `TransactGetItems` cost twice the equivalent single-item operation, so each
872/// item's rounded-up units are doubled (the rounding happens per item, before
873/// the multiplier, to match AWS at the KB/4KB boundary).
874pub const TRANSACTIONAL_CAPACITY_FACTOR: f64 = 2.0;
875
876/// Calculate write capacity units (1 WCU = 1KB, rounded up).
877pub fn write_capacity_units(item_size_bytes: usize) -> f64 {
878    ((item_size_bytes as f64) / 1024.0).ceil().max(1.0)
879}
880
881/// Write capacity units the base table consumes for a single write.
882///
883/// DynamoDB sizes a write on the larger of the item's before and after images,
884/// so shrinking an item still costs what the old one did. `old_size` is `None`
885/// when nothing was there beforehand, and `new_size` is `None` on a delete,
886/// where only the old image exists.
887pub fn table_write_capacity_units(old_size: Option<usize>, new_size: Option<usize>) -> f64 {
888    let old = old_size.map(write_capacity_units);
889    let new = new_size.map(write_capacity_units);
890    match (old, new) {
891        (Some(old), Some(new)) => old.max(new),
892        (Some(units), None) | (None, Some(units)) => units,
893        // No image either side is not a write DynamoDB would charge for, but a
894        // write is never free, so fall back to the one-unit minimum.
895        (None, None) => write_capacity_units(0),
896    }
897}
898
899/// Read capacity units one write's images cost when charged as a read.
900///
901/// A same-token transactional replay is billed as a read against the image the
902/// original write was sized on, which is the larger of the before and after
903/// images, but rounded at 4KB rather than 1KB. Captured in eu-west-2: a replayed
904/// put that shrank a 9KB item to nothing reports 6, and so does the replay of
905/// the write that grew it, so neither side alone explains the figure.
906pub fn table_read_capacity_units(old_size: Option<usize>, new_size: Option<usize>) -> f64 {
907    let larger = old_size.unwrap_or(0).max(new_size.unwrap_or(0));
908    read_capacity_units(larger)
909}
910
911/// Calculate read capacity units assuming strongly consistent reads
912/// (1 RCU per 4KB, rounded up). Used when ConsistentRead is true or
913/// when the read type is not specified.
914pub fn read_capacity_units(item_size_bytes: usize) -> f64 {
915    ((item_size_bytes as f64) / 4096.0).ceil().max(1.0)
916}
917
918/// Calculate read capacity units accounting for consistency mode.
919///
920/// Strongly consistent: 1 RCU per 4KB, rounded up.
921/// Eventually consistent: 0.5 RCU per 4KB (half the strongly consistent rate).
922pub fn read_capacity_units_with_consistency(item_size_bytes: usize, consistent: bool) -> f64 {
923    let strongly = read_capacity_units(item_size_bytes);
924    if consistent { strongly } else { strongly / 2.0 }
925}
926
927/// Whether a `ReturnConsumedCapacity` value asks for a figure at all.
928///
929/// Every `consumed_capacity*` builder below throws its inputs away unless the
930/// mode is `TOTAL` or `INDEXES`, so work done only to feed them is wasted in
931/// every other mode, and `NONE` is the default. Callers that can skip that work
932/// ask here rather than each spelling the comparison out, so the set of modes
933/// that mean "yes" is written down once.
934pub fn capacity_wanted(mode: Option<&str>) -> bool {
935    matches!(mode, Some("TOTAL") | Some("INDEXES"))
936}
937
938/// Whether a `ReturnConsumedCapacity` mode carries the vector write map.
939///
940/// Narrower than [`capacity_wanted`], because the per-index vector map reaches
941/// the wire under `INDEXES` alone (captured 2026-08-11, eu-west-2); `TOTAL`
942/// asks for capacity and still carries no vector fields. The sizing costs a
943/// derivation of the old image per index, so the fan-out asks here and skips
944/// the work rather than doing it and having the response builder drop it. Both
945/// ends read the same predicate so they cannot disagree about which modes mean
946/// yes.
947pub fn vector_capacity_wanted(mode: Option<&str>) -> bool {
948    matches!(mode, Some("INDEXES"))
949}
950
951/// Build a `ConsumedCapacity` for a simple table operation.
952pub fn consumed_capacity(
953    table_name: &str,
954    capacity_units: f64,
955    mode: &Option<String>,
956) -> Option<ConsumedCapacity> {
957    let mode = mode.as_deref().unwrap_or("NONE");
958    match mode {
959        "TOTAL" => Some(ConsumedCapacity {
960            table_name: table_name.to_string(),
961            capacity_units,
962            table: None,
963            global_secondary_indexes: None,
964            local_secondary_indexes: None,
965            ..Default::default()
966        }),
967        "INDEXES" => Some(ConsumedCapacity {
968            table_name: table_name.to_string(),
969            capacity_units,
970            table: Some(CapacityDetail {
971                capacity_units,
972                ..Default::default()
973            }),
974            global_secondary_indexes: None,
975            local_secondary_indexes: None,
976            ..Default::default()
977        }),
978        _ => None,
979    }
980}
981
982/// Build a `ConsumedCapacity` with per-GSI breakdown for INDEXES mode.
983pub fn consumed_capacity_with_indexes(
984    table_name: &str,
985    table_units: f64,
986    gsi_units: &HashMap<String, f64>,
987    mode: &Option<String>,
988) -> Option<ConsumedCapacity> {
989    consumed_capacity_with_secondary_indexes(
990        table_name,
991        table_units,
992        gsi_units,
993        &HashMap::new(),
994        mode,
995    )
996}
997
998/// Build a `ConsumedCapacity` with per-GSI and per-LSI breakdown for INDEXES mode.
999pub fn consumed_capacity_with_secondary_indexes(
1000    table_name: &str,
1001    table_units: f64,
1002    gsi_units: &HashMap<String, f64>,
1003    lsi_units: &HashMap<String, f64>,
1004    mode: &Option<String>,
1005) -> Option<ConsumedCapacity> {
1006    secondary_index_capacity(
1007        table_name,
1008        table_units,
1009        gsi_units,
1010        lsi_units,
1011        mode,
1012        CapacityAxis::None,
1013    )
1014}
1015
1016/// Whether a response mirrors its units into a named axis alongside
1017/// `CapacityUnits`.
1018///
1019/// Single-item and PartiQL responses report `CapacityUnits` alone at every
1020/// level. Transactional responses mirror the units into `WriteCapacityUnits`
1021/// at every level, including each index arm. Both shapes are captured against
1022/// real DynamoDB.
1023#[derive(Clone, Copy, PartialEq)]
1024enum CapacityAxis {
1025    None,
1026    Write,
1027}
1028
1029impl CapacityAxis {
1030    /// The `WriteCapacityUnits` value for a detail carrying `units`.
1031    fn write_units(self, units: f64) -> Option<f64> {
1032        match self {
1033            Self::None => None,
1034            Self::Write => Some(units),
1035        }
1036    }
1037}
1038
1039/// Shared body for the per-index capacity builders. `table_units` carries the
1040/// transactional factor already, when one applies; the index maps never do.
1041fn secondary_index_capacity(
1042    table_name: &str,
1043    table_units: f64,
1044    gsi_units: &HashMap<String, f64>,
1045    lsi_units: &HashMap<String, f64>,
1046    mode: &Option<String>,
1047    axis: CapacityAxis,
1048) -> Option<ConsumedCapacity> {
1049    let total = table_units + gsi_units.values().sum::<f64>() + lsi_units.values().sum::<f64>();
1050
1051    match mode.as_deref().unwrap_or("NONE") {
1052        "INDEXES" => Some(ConsumedCapacity {
1053            table_name: table_name.to_string(),
1054            capacity_units: total,
1055            write_capacity_units: axis.write_units(total),
1056            table: Some(CapacityDetail {
1057                capacity_units: table_units,
1058                write_capacity_units: axis.write_units(table_units),
1059                ..Default::default()
1060            }),
1061            global_secondary_indexes: capacity_detail_map(gsi_units, axis),
1062            local_secondary_indexes: capacity_detail_map(lsi_units, axis),
1063            ..Default::default()
1064        }),
1065        "TOTAL" => Some(ConsumedCapacity {
1066            table_name: table_name.to_string(),
1067            capacity_units: total,
1068            write_capacity_units: axis.write_units(total),
1069            ..Default::default()
1070        }),
1071        _ => None,
1072    }
1073}
1074
1075/// Build a `ConsumedCapacity` carrying the classic index arms and the vector
1076/// arm together.
1077///
1078/// The single mode argument is the point: the classic builder and the vector
1079/// attach each need one, and passing them separately leaves a caller free to
1080/// build under one mode and attach under another, which would put a
1081/// `VectorIndexes` map on a `TOTAL`-shaped response.
1082pub fn consumed_capacity_with_vector_indexes(
1083    table_name: &str,
1084    table_units: f64,
1085    gsi_units: &HashMap<String, f64>,
1086    lsi_units: &HashMap<String, f64>,
1087    vector_bytes: &HashMap<String, f64>,
1088    mode: &Option<String>,
1089) -> Option<ConsumedCapacity> {
1090    attach_vector_index_capacity(
1091        consumed_capacity_with_secondary_indexes(
1092            table_name,
1093            table_units,
1094            gsi_units,
1095            lsi_units,
1096            mode,
1097        ),
1098        vector_bytes,
1099        mode,
1100    )
1101}
1102
1103/// Attach per-vector-index write bytes to a capacity report.
1104///
1105/// Vector replication sits on its own axis, so this never touches
1106/// `capacity_units`: the bytes are reported beside the classic arms, not
1107/// summed into them. The map appears under `INDEXES` alone, matching the
1108/// capture, and an empty `vector_bytes` leaves the response untouched so a
1109/// write that changed no vector index reports no map at all rather than an
1110/// empty one.
1111pub(crate) fn attach_vector_index_capacity(
1112    capacity: Option<ConsumedCapacity>,
1113    vector_bytes: &HashMap<String, f64>,
1114    mode: &Option<String>,
1115) -> Option<ConsumedCapacity> {
1116    let mut capacity = capacity?;
1117    if vector_bytes.is_empty() || !vector_capacity_wanted(mode.as_deref()) {
1118        return Some(capacity);
1119    }
1120    capacity.vector_indexes = Some(
1121        vector_bytes
1122            .iter()
1123            .map(|(name, bytes)| {
1124                (
1125                    name.clone(),
1126                    VectorCapacityDetail {
1127                        vector_write_request_bytes: *bytes,
1128                    },
1129                )
1130            })
1131            .collect(),
1132    );
1133    Some(capacity)
1134}
1135
1136/// Build a `ConsumedCapacity` for one table in a transactional read
1137/// (`TransactGetItems`). `units` is the table total and already includes the
1138/// transactional 2x factor. Under `INDEXES` the Table detail reports
1139/// `ReadCapacityUnits` alongside `CapacityUnits`, matching AWS.
1140pub fn transactional_read_capacity(
1141    table_name: &str,
1142    units: f64,
1143    mode: &Option<String>,
1144) -> Option<ConsumedCapacity> {
1145    match mode.as_deref().unwrap_or("NONE") {
1146        "TOTAL" => Some(ConsumedCapacity {
1147            table_name: table_name.to_string(),
1148            capacity_units: units,
1149            read_capacity_units: Some(units),
1150            ..Default::default()
1151        }),
1152        "INDEXES" => Some(ConsumedCapacity {
1153            table_name: table_name.to_string(),
1154            capacity_units: units,
1155            read_capacity_units: Some(units),
1156            table: Some(CapacityDetail {
1157                capacity_units: units,
1158                read_capacity_units: Some(units),
1159                ..Default::default()
1160            }),
1161            ..Default::default()
1162        }),
1163        _ => None,
1164    }
1165}
1166
1167/// Build the per-table `ConsumedCapacity` vec for a transactional op from the
1168/// per-table units, using `builder` (`transactional_write_capacity` for a
1169/// first-call write, `transactional_read_capacity` for a read set or a
1170/// same-token replay). Returns `None` unless `ReturnConsumedCapacity` is
1171/// `TOTAL` or `INDEXES`, so the mode guard lives in one place. Shared by
1172/// `TransactWriteItems` and `ExecuteTransaction`.
1173pub fn build_transactional_capacity(
1174    table_units: &HashMap<String, f64>,
1175    mode: &Option<String>,
1176    builder: fn(&str, f64, &Option<String>) -> Option<ConsumedCapacity>,
1177) -> Option<Vec<ConsumedCapacity>> {
1178    if !capacity_wanted(mode.as_deref()) {
1179        return None;
1180    }
1181
1182    // Sorted by table name, matching the write path. Iterating the map handed
1183    // back a different order per process, so a two-table read set or replay
1184    // could report its tables either way round while the write half of the same
1185    // response family was stable.
1186    let mut tables: Vec<&String> = table_units.keys().collect();
1187    tables.sort();
1188
1189    Some(
1190        tables
1191            .into_iter()
1192            .filter_map(|table| builder(table, *table_units.get(table)?, mode))
1193            .collect(),
1194    )
1195}
1196
1197/// Build a `ConsumedCapacity` for one table in a transactional write, with the
1198/// per-index breakdown.
1199///
1200/// `table_units` already carries the transactional 2x factor; the index maps do
1201/// not. DynamoDB applies that factor to the base table arm alone, so an index
1202/// arm inside a transaction costs what the same write costs outside one.
1203///
1204/// `CapacityUnits` and `WriteCapacityUnits` both report the table arm plus the
1205/// index arms. The `Table` detail reports the table arm on its own, and every
1206/// index arm carries the write axis too, which is where this shape differs from
1207/// the single-item one.
1208pub fn transactional_write_capacity_with_indexes(
1209    table_name: &str,
1210    table_units: f64,
1211    gsi_units: &HashMap<String, f64>,
1212    lsi_units: &HashMap<String, f64>,
1213    mode: &Option<String>,
1214) -> Option<ConsumedCapacity> {
1215    secondary_index_capacity(
1216        table_name,
1217        table_units,
1218        gsi_units,
1219        lsi_units,
1220        mode,
1221        CapacityAxis::Write,
1222    )
1223}
1224
1225/// Turn per-index units into the response's detail map, or `None` when nothing
1226/// was charged so the arm is absent rather than present and empty.
1227fn capacity_detail_map(
1228    units: &HashMap<String, f64>,
1229    axis: CapacityAxis,
1230) -> Option<HashMap<String, CapacityDetail>> {
1231    if units.is_empty() {
1232        return None;
1233    }
1234    Some(
1235        units
1236            .iter()
1237            .map(|(name, &u)| {
1238                (
1239                    name.clone(),
1240                    CapacityDetail {
1241                        capacity_units: u,
1242                        write_capacity_units: axis.write_units(u),
1243                        ..Default::default()
1244                    },
1245                )
1246            })
1247            .collect(),
1248    )
1249}
1250
1251/// Build a `ConsumedCapacity` for one table in a transactional write
1252/// (`TransactWriteItems`). `units` is the table total and already includes the
1253/// transactional 2x factor. Under `INDEXES` the Table detail reports
1254/// `WriteCapacityUnits` alongside `CapacityUnits`, matching AWS.
1255pub fn transactional_write_capacity(
1256    table_name: &str,
1257    units: f64,
1258    mode: &Option<String>,
1259) -> Option<ConsumedCapacity> {
1260    match mode.as_deref().unwrap_or("NONE") {
1261        "TOTAL" => Some(ConsumedCapacity {
1262            table_name: table_name.to_string(),
1263            capacity_units: units,
1264            write_capacity_units: Some(units),
1265            ..Default::default()
1266        }),
1267        "INDEXES" => Some(ConsumedCapacity {
1268            table_name: table_name.to_string(),
1269            capacity_units: units,
1270            write_capacity_units: Some(units),
1271            table: Some(CapacityDetail {
1272                capacity_units: units,
1273                write_capacity_units: Some(units),
1274                ..Default::default()
1275            }),
1276            ..Default::default()
1277        }),
1278        _ => None,
1279    }
1280}
1281
1282/// Key schema element - defines a key attribute.
1283#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1284pub struct KeySchemaElement {
1285    #[serde(rename = "AttributeName", alias = "attribute_name")]
1286    pub attribute_name: String,
1287    #[serde(rename = "KeyType", alias = "key_type")]
1288    pub key_type: KeyType,
1289}
1290
1291/// Key type: HASH (partition key) or RANGE (sort key).
1292#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1293pub enum KeyType {
1294    #[default]
1295    HASH,
1296    RANGE,
1297}
1298
1299/// Attribute definition - declares an attribute's type.
1300#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1301pub struct AttributeDefinition {
1302    #[serde(rename = "AttributeName", alias = "attribute_name")]
1303    pub attribute_name: String,
1304    #[serde(rename = "AttributeType", alias = "attribute_type")]
1305    pub attribute_type: ScalarAttributeType,
1306}
1307
1308/// Scalar attribute types that can be used as keys.
1309#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1310pub enum ScalarAttributeType {
1311    #[default]
1312    S,
1313    N,
1314    B,
1315}
1316
1317/// GSI projection type.
1318#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1319pub struct Projection {
1320    #[serde(
1321        rename = "ProjectionType",
1322        alias = "projection_type",
1323        default,
1324        skip_serializing_if = "Option::is_none"
1325    )]
1326    pub projection_type: Option<ProjectionType>,
1327    #[serde(
1328        rename = "NonKeyAttributes",
1329        alias = "non_key_attributes",
1330        skip_serializing_if = "Option::is_none"
1331    )]
1332    pub non_key_attributes: Option<Vec<String>>,
1333}
1334
1335/// Projection type enum.
1336#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1337#[allow(non_camel_case_types)]
1338pub enum ProjectionType {
1339    #[default]
1340    ALL,
1341    KEYS_ONLY,
1342    INCLUDE,
1343}
1344
1345/// Global Secondary Index definition.
1346#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1347pub struct GlobalSecondaryIndex {
1348    #[serde(rename = "IndexName", alias = "index_name")]
1349    pub index_name: String,
1350    #[serde(rename = "KeySchema", alias = "key_schema")]
1351    pub key_schema: Vec<KeySchemaElement>,
1352    #[serde(rename = "Projection", alias = "projection")]
1353    pub projection: Projection,
1354    #[serde(
1355        rename = "ProvisionedThroughput",
1356        alias = "provisioned_throughput",
1357        skip_serializing_if = "Option::is_none"
1358    )]
1359    pub provisioned_throughput: Option<ProvisionedThroughput>,
1360}
1361
1362/// Local Secondary Index definition.
1363#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1364pub struct LocalSecondaryIndex {
1365    #[serde(rename = "IndexName", alias = "index_name")]
1366    pub index_name: String,
1367    #[serde(rename = "KeySchema", alias = "key_schema")]
1368    pub key_schema: Vec<KeySchemaElement>,
1369    #[serde(rename = "Projection", alias = "projection")]
1370    pub projection: Projection,
1371}
1372
1373/// The vector attribute a vector index is built over. A structure with a
1374/// single `AttributeName` member on the wire, not a bare string.
1375#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1376pub struct VectorAttributeDefinition {
1377    #[serde(rename = "AttributeName", alias = "attribute_name")]
1378    pub attribute_name: String,
1379}
1380
1381/// One element of a vector index's search schema: a `HASH` partition
1382/// attribute or an `INLINE_FILTER` attribute.
1383#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1384pub struct SearchSchemaElement {
1385    #[serde(rename = "AttributeName", alias = "attribute_name")]
1386    pub attribute_name: String,
1387    #[serde(
1388        rename = "SearchSchemaElementType",
1389        alias = "search_schema_element_type"
1390    )]
1391    pub search_schema_element_type: String,
1392}
1393
1394/// Vector index definition as supplied on CreateTable's `VectorIndexes`.
1395///
1396/// Serialised verbatim into the `_tables.vector_index_definitions` column,
1397/// following the `gsi_definitions` convention.
1398#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1399pub struct VectorIndex {
1400    #[serde(rename = "IndexName", alias = "index_name")]
1401    pub index_name: String,
1402    #[serde(rename = "VectorAttribute", alias = "vector_attribute")]
1403    pub vector_attribute: VectorAttributeDefinition,
1404    #[serde(
1405        rename = "SearchSchema",
1406        alias = "search_schema",
1407        default,
1408        skip_serializing_if = "Option::is_none"
1409    )]
1410    pub search_schema: Option<Vec<SearchSchemaElement>>,
1411    #[serde(rename = "Projection", alias = "projection")]
1412    pub projection: Projection,
1413    #[serde(rename = "Dimensions", alias = "dimensions")]
1414    pub dimensions: u32,
1415    #[serde(rename = "DistanceFunction", alias = "distance_function")]
1416    pub distance_function: String,
1417}
1418
1419/// Why an attribute value is not a valid vector for an index with a given
1420/// dimension count. Carries the element-level detail the write-path
1421/// validation errors interpolate; the checks run in the order the variants
1422/// are listed, so a value can only report one failure.
1423#[derive(Debug, Clone, PartialEq)]
1424pub enum VectorValueError {
1425    /// The value is not a List at all.
1426    NotAList,
1427    /// The list's element count differs from the index's dimensions.
1428    WrongDimensions {
1429        /// The number of elements actually present.
1430        actual: usize,
1431    },
1432    /// An element is not a Number.
1433    ElementNotANumber {
1434        /// Zero-based element position.
1435        position: usize,
1436        /// The element's DynamoDB type descriptor.
1437        actual: &'static str,
1438    },
1439    /// An element does not fit the finite f32 range (out-of-range values
1440    /// overflow to infinity on conversion; real DynamoDB rejects them at
1441    /// write time, captured eu-west-2 and us-east-1, 2026-08-12).
1442    ElementOutOfRange {
1443        /// Zero-based element position.
1444        position: usize,
1445        /// The raw number string as written.
1446        value: String,
1447    },
1448}
1449
1450/// The f32 values of a vector attribute value, or the element-level reason it
1451/// is not a valid vector for an index with `dimensions` elements.
1452///
1453/// Vector indexes hold f32 copies while the base table keeps full precision
1454/// (captured from real DynamoDB, eu-west-2, 2026-08-11), so conversion
1455/// happens here, where the index copy is derived. The live write path turns
1456/// each error variant into its captured `IndexName:`-suffixed message; the
1457/// backfill path discards the detail via [`vector_f32_values`] and
1458/// sparse-skips, so the two paths agree on validity by construction.
1459pub fn check_vector_f32_values(
1460    value: &AttributeValue,
1461    dimensions: u32,
1462) -> std::result::Result<Vec<f32>, VectorValueError> {
1463    let AttributeValue::L(elems) = value else {
1464        return Err(VectorValueError::NotAList);
1465    };
1466    if elems.len() != dimensions as usize {
1467        return Err(VectorValueError::WrongDimensions {
1468            actual: elems.len(),
1469        });
1470    }
1471    let mut out = Vec::with_capacity(elems.len());
1472    for (position, elem) in elems.iter().enumerate() {
1473        let AttributeValue::N(n) = elem else {
1474            return Err(VectorValueError::ElementNotANumber {
1475                position,
1476                actual: elem.type_name(),
1477            });
1478        };
1479        // A well-formed DynamoDB number always parses (overflow yields
1480        // infinity, not an error); anything non-finite is out of f32 range.
1481        match n.parse::<f32>() {
1482            Ok(v) if v.is_finite() => out.push(v),
1483            _ => {
1484                return Err(VectorValueError::ElementOutOfRange {
1485                    position,
1486                    value: n.clone(),
1487                });
1488            }
1489        }
1490    }
1491    Ok(out)
1492}
1493
1494/// The f32 values of a vector attribute value, or `None` when the value is
1495/// not a valid vector for an index with `dimensions` elements. The validity
1496/// rule is exactly [`check_vector_f32_values`]'s, with the element-level
1497/// detail discarded; the backfill path uses this form to sparse-skip.
1498pub fn vector_f32_values(value: &AttributeValue, dimensions: u32) -> Option<Vec<f32>> {
1499    check_vector_f32_values(value, dimensions).ok()
1500}
1501
1502/// Serialise an f32 for a vector index's number copy: shortest-decimal via
1503/// serde_json's f32 formatter, which always keeps a fractional part ("1"
1504/// stores as "1.0", matching how the index copy reads back from real
1505/// DynamoDB; captured eu-west-2, 2026-08-11).
1506pub fn f32_number_string(v: f32) -> String {
1507    serde_json::to_string(&v).unwrap_or_else(|_| "0.0".to_string())
1508}
1509
1510/// Provisioned throughput settings (stored but not enforced).
1511#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1512pub struct ProvisionedThroughput {
1513    #[serde(rename = "ReadCapacityUnits", alias = "read_capacity_units", default)]
1514    pub read_capacity_units: Option<i64>,
1515    #[serde(rename = "WriteCapacityUnits", alias = "write_capacity_units", default)]
1516    pub write_capacity_units: Option<i64>,
1517}
1518
1519/// On-demand (PAY_PER_REQUEST) throughput ceilings (stored but not enforced).
1520#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1521pub struct OnDemandThroughput {
1522    #[serde(
1523        rename = "MaxReadRequestUnits",
1524        alias = "max_read_request_units",
1525        default,
1526        skip_serializing_if = "Option::is_none"
1527    )]
1528    pub max_read_request_units: Option<i64>,
1529    #[serde(
1530        rename = "MaxWriteRequestUnits",
1531        alias = "max_write_request_units",
1532        default,
1533        skip_serializing_if = "Option::is_none"
1534    )]
1535    pub max_write_request_units: Option<i64>,
1536}
1537
1538// ---------------------------------------------------------------------------
1539// Type conversion: From<T> / TryFrom<T> for AttributeValue
1540// ---------------------------------------------------------------------------
1541
1542/// Error returned when converting between `AttributeValue` and Rust types.
1543#[derive(Debug, Clone, PartialEq)]
1544pub struct ConversionError {
1545    /// The expected DynamoDB or Rust type.
1546    pub expected: &'static str,
1547    /// The actual DynamoDB type encountered.
1548    pub actual: &'static str,
1549}
1550
1551impl fmt::Display for ConversionError {
1552    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1553        write!(f, "expected {}, got {}", self.expected, self.actual)
1554    }
1555}
1556
1557impl std::error::Error for ConversionError {}
1558
1559// --- From<T> for AttributeValue: infallible conversions ---
1560
1561impl From<String> for AttributeValue {
1562    fn from(value: String) -> Self {
1563        AttributeValue::S(value)
1564    }
1565}
1566
1567impl From<&str> for AttributeValue {
1568    fn from(value: &str) -> Self {
1569        AttributeValue::S(value.to_string())
1570    }
1571}
1572
1573impl From<bool> for AttributeValue {
1574    fn from(value: bool) -> Self {
1575        AttributeValue::BOOL(value)
1576    }
1577}
1578
1579impl From<Vec<u8>> for AttributeValue {
1580    fn from(value: Vec<u8>) -> Self {
1581        AttributeValue::B(value)
1582    }
1583}
1584
1585impl From<&[u8]> for AttributeValue {
1586    fn from(value: &[u8]) -> Self {
1587        AttributeValue::B(value.to_vec())
1588    }
1589}
1590
1591// Integer types - all finite, all fit in DynamoDB's number range.
1592macro_rules! impl_from_integer {
1593    ($($t:ty),+) => {
1594        $(
1595            impl From<$t> for AttributeValue {
1596                fn from(value: $t) -> Self {
1597                    AttributeValue::N(value.to_string())
1598                }
1599            }
1600        )+
1601    };
1602}
1603
1604impl_from_integer!(i8, i16, i32, i64, i128, u8, u16, u32, u64, u128);
1605
1606// Container types
1607impl From<HashMap<String, AttributeValue>> for AttributeValue {
1608    fn from(value: HashMap<String, AttributeValue>) -> Self {
1609        AttributeValue::M(value)
1610    }
1611}
1612
1613impl From<Vec<AttributeValue>> for AttributeValue {
1614    fn from(value: Vec<AttributeValue>) -> Self {
1615        AttributeValue::L(value)
1616    }
1617}
1618
1619impl From<HashSet<String>> for AttributeValue {
1620    fn from(value: HashSet<String>) -> Self {
1621        AttributeValue::SS(value.into_iter().collect())
1622    }
1623}
1624
1625impl From<BTreeSet<String>> for AttributeValue {
1626    fn from(value: BTreeSet<String>) -> Self {
1627        AttributeValue::SS(value.into_iter().collect())
1628    }
1629}
1630
1631// --- TryFrom<T> for AttributeValue: fallible conversions (floats) ---
1632
1633impl TryFrom<f64> for AttributeValue {
1634    type Error = ConversionError;
1635
1636    fn try_from(value: f64) -> std::result::Result<Self, Self::Error> {
1637        if value.is_finite() {
1638            Ok(AttributeValue::N(value.to_string()))
1639        } else {
1640            Err(ConversionError {
1641                expected: "finite f64",
1642                actual: "NaN or Infinity",
1643            })
1644        }
1645    }
1646}
1647
1648impl TryFrom<f32> for AttributeValue {
1649    type Error = ConversionError;
1650
1651    fn try_from(value: f32) -> std::result::Result<Self, Self::Error> {
1652        if value.is_finite() {
1653            Ok(AttributeValue::N(value.to_string()))
1654        } else {
1655            Err(ConversionError {
1656                expected: "finite f32",
1657                actual: "NaN or Infinity",
1658            })
1659        }
1660    }
1661}
1662
1663// --- TryFrom<AttributeValue> for T: extract Rust types from AV ---
1664
1665impl TryFrom<AttributeValue> for String {
1666    type Error = ConversionError;
1667
1668    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1669        match value {
1670            AttributeValue::S(s) => Ok(s),
1671            other => Err(ConversionError {
1672                expected: "S",
1673                actual: other.type_name(),
1674            }),
1675        }
1676    }
1677}
1678
1679impl TryFrom<AttributeValue> for bool {
1680    type Error = ConversionError;
1681
1682    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1683        match value {
1684            AttributeValue::BOOL(b) => Ok(b),
1685            other => Err(ConversionError {
1686                expected: "BOOL",
1687                actual: other.type_name(),
1688            }),
1689        }
1690    }
1691}
1692
1693impl TryFrom<AttributeValue> for Vec<u8> {
1694    type Error = ConversionError;
1695
1696    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1697        match value {
1698            AttributeValue::B(b) => Ok(b),
1699            other => Err(ConversionError {
1700                expected: "B",
1701                actual: other.type_name(),
1702            }),
1703        }
1704    }
1705}
1706
1707macro_rules! impl_try_from_av_integer {
1708    ($($t:ty),+) => {
1709        $(
1710            impl TryFrom<AttributeValue> for $t {
1711                type Error = ConversionError;
1712
1713                fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1714                    match value {
1715                        AttributeValue::N(n) => n.parse::<$t>().map_err(|_| ConversionError {
1716                            expected: stringify!($t),
1717                            actual: "N (parse failed)",
1718                        }),
1719                        other => Err(ConversionError {
1720                            expected: "N",
1721                            actual: other.type_name(),
1722                        }),
1723                    }
1724                }
1725            }
1726        )+
1727    };
1728}
1729
1730impl_try_from_av_integer!(i8, i16, i32, i64, i128, u8, u16, u32, u64, u128);
1731
1732impl TryFrom<AttributeValue> for f64 {
1733    type Error = ConversionError;
1734
1735    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1736        match value {
1737            AttributeValue::N(n) => n.parse::<f64>().map_err(|_| ConversionError {
1738                expected: "f64",
1739                actual: "N (parse failed)",
1740            }),
1741            other => Err(ConversionError {
1742                expected: "N",
1743                actual: other.type_name(),
1744            }),
1745        }
1746    }
1747}
1748
1749impl TryFrom<AttributeValue> for f32 {
1750    type Error = ConversionError;
1751
1752    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1753        match value {
1754            AttributeValue::N(n) => n.parse::<f32>().map_err(|_| ConversionError {
1755                expected: "f32",
1756                actual: "N (parse failed)",
1757            }),
1758            other => Err(ConversionError {
1759                expected: "N",
1760                actual: other.type_name(),
1761            }),
1762        }
1763    }
1764}
1765
1766impl TryFrom<AttributeValue> for HashMap<String, AttributeValue> {
1767    type Error = ConversionError;
1768
1769    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1770        match value {
1771            AttributeValue::M(m) => Ok(m),
1772            other => Err(ConversionError {
1773                expected: "M",
1774                actual: other.type_name(),
1775            }),
1776        }
1777    }
1778}
1779
1780impl TryFrom<AttributeValue> for Vec<AttributeValue> {
1781    type Error = ConversionError;
1782
1783    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1784        match value {
1785            AttributeValue::L(l) => Ok(l),
1786            other => Err(ConversionError {
1787                expected: "L",
1788                actual: other.type_name(),
1789            }),
1790        }
1791    }
1792}
1793
1794impl TryFrom<AttributeValue> for Vec<String> {
1795    type Error = ConversionError;
1796
1797    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1798        match value {
1799            AttributeValue::SS(ss) => Ok(ss),
1800            AttributeValue::L(l) => {
1801                // Lenient: extract S values from a list
1802                l.into_iter()
1803                    .map(|av| match av {
1804                        AttributeValue::S(s) => Ok(s),
1805                        other => Err(ConversionError {
1806                            expected: "S (within L)",
1807                            actual: other.type_name(),
1808                        }),
1809                    })
1810                    .collect()
1811            }
1812            other => Err(ConversionError {
1813                expected: "SS or L",
1814                actual: other.type_name(),
1815            }),
1816        }
1817    }
1818}
1819
1820#[cfg(test)]
1821mod tests {
1822    use super::*;
1823
1824    #[test]
1825    fn test_serialize_string() {
1826        let val = AttributeValue::S("hello".to_string());
1827        let json = serde_json::to_string(&val).unwrap();
1828        assert_eq!(json, r#"{"S":"hello"}"#);
1829    }
1830
1831    #[test]
1832    fn test_serialize_number() {
1833        let val = AttributeValue::N("42".to_string());
1834        let json = serde_json::to_string(&val).unwrap();
1835        assert_eq!(json, r#"{"N":"42"}"#);
1836    }
1837
1838    #[test]
1839    fn test_serialize_binary() {
1840        let val = AttributeValue::B(vec![1, 2, 3]);
1841        let json = serde_json::to_string(&val).unwrap();
1842        assert_eq!(json, r#"{"B":"AQID"}"#);
1843    }
1844
1845    #[test]
1846    fn test_serialize_bool() {
1847        let val = AttributeValue::BOOL(true);
1848        let json = serde_json::to_string(&val).unwrap();
1849        assert_eq!(json, r#"{"BOOL":true}"#);
1850    }
1851
1852    #[test]
1853    fn test_serialize_null() {
1854        let val = AttributeValue::NULL(true);
1855        let json = serde_json::to_string(&val).unwrap();
1856        assert_eq!(json, r#"{"NULL":true}"#);
1857    }
1858
1859    #[test]
1860    fn test_deserialize_null_true() {
1861        let val: AttributeValue = serde_json::from_str(r#"{"NULL":true}"#).unwrap();
1862        assert_eq!(val, AttributeValue::NULL(true));
1863    }
1864
1865    #[test]
1866    fn test_deserialize_null_false_rejected() {
1867        // AWS requires the NULL member to be exactly `true`; {"NULL": false} is
1868        // rejected with a ValidationException, same as a non-boolean value.
1869        let err = serde_json::from_str::<AttributeValue>(r#"{"NULL":false}"#).unwrap_err();
1870        assert!(
1871            err.to_string().contains("must have the value of true"),
1872            "unexpected error: {err}"
1873        );
1874    }
1875
1876    #[test]
1877    fn test_deserialize_null_non_boolean_rejected() {
1878        // A non-boolean NULL (e.g. {"NULL": "no"}) is a type error, not a value.
1879        let err = serde_json::from_str::<AttributeValue>(r#"{"NULL":"no"}"#).unwrap_err();
1880        assert!(
1881            err.to_string().contains("must have the value of true"),
1882            "unexpected error: {err}"
1883        );
1884    }
1885
1886    #[test]
1887    fn test_serialize_string_set() {
1888        let val = AttributeValue::SS(vec!["a".to_string(), "b".to_string()]);
1889        let json = serde_json::to_string(&val).unwrap();
1890        assert_eq!(json, r#"{"SS":["a","b"]}"#);
1891    }
1892
1893    #[test]
1894    fn test_serialize_list() {
1895        let val = AttributeValue::L(vec![
1896            AttributeValue::S("hello".to_string()),
1897            AttributeValue::N("42".to_string()),
1898        ]);
1899        let json = serde_json::to_string(&val).unwrap();
1900        assert_eq!(json, r#"{"L":[{"S":"hello"},{"N":"42"}]}"#);
1901    }
1902
1903    #[test]
1904    fn test_serialize_map() {
1905        let mut m = HashMap::new();
1906        m.insert("key".to_string(), AttributeValue::S("value".to_string()));
1907        let val = AttributeValue::M(m);
1908        let json = serde_json::to_string(&val).unwrap();
1909        assert_eq!(json, r#"{"M":{"key":{"S":"value"}}}"#);
1910    }
1911
1912    #[test]
1913    fn test_round_trip_all_types() {
1914        let values = vec![
1915            AttributeValue::S("hello".to_string()),
1916            AttributeValue::N("42.5".to_string()),
1917            AttributeValue::B(vec![0, 255, 128]),
1918            AttributeValue::BOOL(false),
1919            AttributeValue::NULL(true),
1920            AttributeValue::SS(vec!["x".to_string(), "y".to_string()]),
1921            AttributeValue::NS(vec!["1".to_string(), "2.5".to_string()]),
1922            AttributeValue::BS(vec![vec![1], vec![2, 3]]),
1923            AttributeValue::L(vec![
1924                AttributeValue::S("nested".to_string()),
1925                AttributeValue::N("99".to_string()),
1926            ]),
1927        ];
1928
1929        for val in values {
1930            let json = serde_json::to_string(&val).unwrap();
1931            let deserialized: AttributeValue = serde_json::from_str(&json).unwrap();
1932            assert_eq!(val, deserialized, "Round-trip failed for {json}");
1933        }
1934    }
1935
1936    #[test]
1937    fn test_size_string() {
1938        let val = AttributeValue::S("hello".to_string());
1939        assert_eq!(val.size(), 5);
1940    }
1941
1942    #[test]
1943    fn test_size_number() {
1944        // "42" has 2 significant digits → ceil(2/2) + 1 = 2
1945        let val = AttributeValue::N("42".to_string());
1946        assert_eq!(val.size(), 2);
1947    }
1948
1949    #[test]
1950    fn test_size_number_matches_dynamodb() {
1951        // Byte-exact ground truth, measured against eu-west-2 by bisecting the
1952        // 400KB item-size gate (which resolves to the byte, unlike the 1KB
1953        // granularity of consumed capacity). A number costs
1954        // ceil(integer significant digits / 2) + ceil(fraction significant
1955        // digits / 2) + 1, over the value with leading and trailing zeros
1956        // trimmed. The count is a property of the significant digits, so it does
1957        // not change when scientific notation is expanded on storage.
1958        let cases: &[(&str, usize)] = &[
1959            // digit counts either side of the decimal point
1960            ("1", 2),
1961            ("12", 2),
1962            ("123", 3),
1963            ("1234", 3),
1964            ("12345", 4),
1965            ("123456", 4),
1966            ("1234567", 5),
1967            ("12345678", 5),
1968            ("123456789", 6),
1969            ("1234567891", 6),
1970            ("12345678912", 7),
1971            ("123456789123", 7),
1972            ("1234567890123456789", 11),
1973            ("12345678901234567890123456789012345678", 20),
1974            // zeros the wire carries but DynamoDB trims
1975            ("0042", 2),
1976            ("100", 2),
1977            ("1010", 3),
1978            ("0.0000001", 2),
1979            ("0", 1),
1980            // scientific notation, which expands on storage without growing
1981            ("1E125", 2),
1982            ("1E-100", 2),
1983            // significant digits straddling the decimal point are counted in two
1984            // halves, so these cost a byte more than the digit total alone implies
1985            ("1.5", 3),
1986            ("1.2", 3),
1987            ("1.200", 3),
1988            ("1.234", 4),
1989            ("3.14159", 5),
1990            ("100.5", 4),
1991            ("0.15", 2),
1992            ("15", 2),
1993            // a sign costs a byte
1994            ("-42", 3),
1995            ("-12345", 5),
1996            ("-1E125", 3),
1997            ("-0.15", 3),
1998            ("-0", 1),
1999        ];
2000        for (literal, expected) in cases {
2001            assert_eq!(
2002                AttributeValue::N((*literal).to_string()).size(),
2003                *expected,
2004                "size of N {literal:?}"
2005            );
2006        }
2007    }
2008
2009    #[test]
2010    fn test_size_number_survives_normalisation() {
2011        // The reason the write paths could disagree: sizing has to be invariant
2012        // under the normalisation that happens on storage, or the same item
2013        // measures differently either side of it.
2014        for literal in [
2015            "1E125",
2016            "1E-100",
2017            "0042",
2018            "1.200",
2019            "0.0000001",
2020            "100",
2021            "3.14159",
2022            "42",
2023        ] {
2024            let raw = AttributeValue::N(literal.to_string()).size();
2025            let normalised = AttributeValue::N(normalize_dynamo_number(literal)).size();
2026            assert_eq!(
2027                raw, normalised,
2028                "N {literal:?} changed size when normalised"
2029            );
2030        }
2031    }
2032
2033    #[test]
2034    fn test_size_bool() {
2035        assert_eq!(AttributeValue::BOOL(true).size(), 1);
2036    }
2037
2038    #[test]
2039    fn test_size_null() {
2040        assert_eq!(AttributeValue::NULL(true).size(), 1);
2041    }
2042
2043    #[test]
2044    fn test_key_string_s() {
2045        let val = AttributeValue::S("hello".to_string());
2046        assert_eq!(val.to_key_string(), Some("S:hello".to_string()));
2047    }
2048
2049    #[test]
2050    fn test_key_string_n() {
2051        let val = AttributeValue::N("42".to_string());
2052        let key = val.to_key_string().unwrap();
2053        assert!(key.starts_with("N:"));
2054    }
2055
2056    #[test]
2057    fn test_key_string_b() {
2058        let val = AttributeValue::B(vec![0xff, 0x00, 0xab]);
2059        assert_eq!(val.to_key_string(), Some("B:ff00ab".to_string()));
2060    }
2061
2062    #[test]
2063    fn test_key_string_non_key_type_returns_none() {
2064        assert_eq!(AttributeValue::BOOL(true).to_key_string(), None);
2065        assert_eq!(AttributeValue::L(vec![]).to_key_string(), None);
2066    }
2067
2068    // Number sort key ordering tests
2069    #[test]
2070    fn test_number_sort_ordering() {
2071        let numbers = vec![
2072            "-1000", "-100", "-10", "-1", "-0.5", "-0.001", "0", "0.001", "0.5", "1", "10", "100",
2073            "1000",
2074        ];
2075        let encoded: Vec<String> = numbers
2076            .iter()
2077            .map(|n| normalize_number_for_sort(n))
2078            .collect();
2079
2080        for i in 0..encoded.len() - 1 {
2081            assert!(
2082                encoded[i] < encoded[i + 1],
2083                "Sort order broken: {} ({}) should be < {} ({})",
2084                numbers[i],
2085                encoded[i],
2086                numbers[i + 1],
2087                encoded[i + 1]
2088            );
2089        }
2090    }
2091
2092    #[test]
2093    fn test_number_sort_zero_variants() {
2094        let z1 = normalize_number_for_sort("0");
2095        let z2 = normalize_number_for_sort("-0");
2096        let z3 = normalize_number_for_sort("0.0");
2097        assert_eq!(z1, z2);
2098        assert_eq!(z2, z3);
2099    }
2100
2101    #[test]
2102    fn test_number_sort_decimals() {
2103        let a = normalize_number_for_sort("1.5");
2104        let b = normalize_number_for_sort("2.5");
2105        assert!(a < b);
2106
2107        let c = normalize_number_for_sort("0.001");
2108        let d = normalize_number_for_sort("0.01");
2109        assert!(c < d);
2110    }
2111
2112    #[test]
2113    fn test_number_sort_scientific() {
2114        let a = normalize_number_for_sort("1e10");
2115        let b = normalize_number_for_sort("1e11");
2116        assert!(a < b);
2117
2118        let c = normalize_number_for_sort("-1e11");
2119        let d = normalize_number_for_sort("-1e10");
2120        assert!(c < d);
2121    }
2122
2123    // Number validation/normalisation is pinned to real DynamoDB behaviour
2124    // (captured against AWS for issue #109). validate_dynamo_number accepts
2125    // exactly the grammar DynamoDB accepts, including a leading '+'; the bare
2126    // and '+'-prefixed malformed forms are both rejected, matching AWS.
2127    #[test]
2128    fn test_validate_number_accepts_dynamodb_grammar() {
2129        for input in [
2130            "+5", "+1.5", "+0", "-0", "+0.0", "+1e2", "1e+2", "1.5E+3", "-7", "+.5", ".5", "5.",
2131            "00042", "1.23E10", "+1e-2", "1E-130", "+1E-130",
2132        ] {
2133            assert!(
2134                validate_dynamo_number(input).is_ok(),
2135                "expected {input} to validate, got {:?}",
2136                validate_dynamo_number(input)
2137            );
2138        }
2139    }
2140
2141    #[test]
2142    fn test_validate_number_rejects_malformed() {
2143        // Every one of these is rejected by real DynamoDB. Note that whitespace
2144        // (leading, trailing, or internal) is rejected, not trimmed.
2145        for input in [
2146            "+e2", "e2", "+1+2", "1+2", "+1.2.3", "1.2.3", "++5", "+-5", "-+5", "+", "-", "1e",
2147            "1e+", ".", "1.2e3.4", "0x5", "NaN", "Infinity", "1_000", " 5", "5 ", "1 5", "",
2148        ] {
2149            assert!(
2150                matches!(
2151                    validate_dynamo_number(input),
2152                    Err(crate::errors::DynoxideError::ValidationException(_))
2153                ),
2154                "expected {input:?} to be rejected with ValidationException, got {:?}",
2155                validate_dynamo_number(input)
2156            );
2157        }
2158    }
2159
2160    #[test]
2161    fn test_normalize_number_matches_dynamodb() {
2162        for (input, stored) in [
2163            ("+5", "5"),
2164            ("+1.5", "1.5"),
2165            ("+0", "0"),
2166            ("-0", "0"),
2167            ("+0.0", "0"),
2168            ("+1e2", "100"),
2169            ("1e+2", "100"),
2170            ("1.5E+3", "1500"),
2171            ("-7", "-7"),
2172            ("+.5", "0.5"),
2173            (".5", "0.5"),
2174            ("5.", "5"),
2175            ("00042", "42"),
2176            ("1.23E10", "12300000000"),
2177            ("+1e-2", "0.01"),
2178        ] {
2179            assert_eq!(
2180                normalize_dynamo_number(input),
2181                stored,
2182                "{input} should normalise to {stored}"
2183            );
2184        }
2185    }
2186
2187    #[test]
2188    fn test_type_name() {
2189        assert_eq!(AttributeValue::S("".to_string()).type_name(), "S");
2190        assert_eq!(AttributeValue::N("0".to_string()).type_name(), "N");
2191        assert_eq!(AttributeValue::B(vec![]).type_name(), "B");
2192        assert_eq!(AttributeValue::BOOL(true).type_name(), "BOOL");
2193        assert_eq!(AttributeValue::NULL(true).type_name(), "NULL");
2194        assert_eq!(AttributeValue::SS(vec![]).type_name(), "SS");
2195        assert_eq!(AttributeValue::NS(vec![]).type_name(), "NS");
2196        assert_eq!(AttributeValue::BS(vec![]).type_name(), "BS");
2197        assert_eq!(AttributeValue::L(vec![]).type_name(), "L");
2198        assert_eq!(AttributeValue::M(HashMap::new()).type_name(), "M");
2199    }
2200}