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
569    let negative = trimmed.starts_with('-');
570    let abs_str = if negative {
571        &trimmed[1..]
572    } else {
573        trimmed.trim_start_matches('+')
574    };
575
576    let (mantissa_digits, exponent) = parse_number_parts(abs_str);
577
578    // Zero
579    if mantissa_digits.is_empty() {
580        return "0".to_string();
581    }
582
583    // Reconstruct: mantissa_digits represent the significant digits,
584    // exponent is the power of 10 such that value = 0.mantissa * 10^exponent
585    // e.g., 12345 → mantissa=[1,2,3,4,5], exponent=5 → 12345
586    // e.g., 0.00123 → mantissa=[1,2,3], exponent=-2 → 0.00123
587    let num_digits = mantissa_digits.len() as i32;
588    let int_digits = exponent; // number of digits before the decimal point
589
590    let mut result = String::new();
591    if negative {
592        result.push('-');
593    }
594
595    if int_digits <= 0 {
596        // Pure fraction: 0.000...digits
597        result.push_str("0.");
598        for _ in 0..(-int_digits) {
599            result.push('0');
600        }
601        for &d in &mantissa_digits {
602            result.push((b'0' + d) as char);
603        }
604    } else if int_digits >= num_digits {
605        // Pure integer: digits followed by trailing zeros
606        for &d in &mantissa_digits {
607            result.push((b'0' + d) as char);
608        }
609        for _ in 0..(int_digits - num_digits) {
610            result.push('0');
611        }
612    } else {
613        // Mixed: some digits before decimal, some after
614        let int_part = int_digits as usize;
615        for &d in &mantissa_digits[..int_part] {
616            result.push((b'0' + d) as char);
617        }
618        result.push('.');
619        for &d in &mantissa_digits[int_part..] {
620            result.push((b'0' + d) as char);
621        }
622    }
623
624    result
625}
626
627fn zero_encoding() -> String {
628    // Zero sorts between negative (prefix "0") and positive (prefix "2")
629    format!("1{}{}", "0".repeat(4), "0".repeat(40))
630}
631
632fn encode_positive(mantissa: &[u8], exponent: i32) -> String {
633    let exp_encoded = (exponent + 5000) as u16;
634    let mantissa_str = mantissa_to_string(mantissa, 40);
635    format!("2{exp_encoded:04}{mantissa_str}")
636}
637
638fn encode_negative(mantissa: &[u8], exponent: i32) -> String {
639    // For negatives, we complement everything so larger absolute values sort first (smaller)
640    let exp_encoded = 9999 - (exponent + 5000) as u16;
641    let mantissa_str = complement_mantissa(mantissa, 40);
642    format!("0{exp_encoded:04}{mantissa_str}")
643}
644
645/// Parse a non-negative number string into (mantissa digits, exponent).
646/// Mantissa is normalized: first digit is non-zero, exponent is the power of 10
647/// such that the number = 0.mantissa * 10^exponent.
648pub(crate) fn parse_number_parts(s: &str) -> (Vec<u8>, i32) {
649    // Handle scientific notation
650    let (coeff, exp_part) = if let Some(pos) = s.to_ascii_lowercase().find('e') {
651        let coeff = &s[..pos];
652        let exp: i32 = s[pos + 1..].parse().unwrap_or(0);
653        (coeff, exp)
654    } else {
655        (s, 0)
656    };
657
658    // Split coefficient into integer and fraction parts
659    let (int_part, frac_part) = if let Some(dot) = coeff.find('.') {
660        (&coeff[..dot], &coeff[dot + 1..])
661    } else {
662        (coeff, "")
663    };
664
665    // Collect all digits
666    let mut digits: Vec<u8> = Vec::new();
667    for ch in int_part.chars().chain(frac_part.chars()) {
668        if ch.is_ascii_digit() {
669            digits.push(ch as u8 - b'0');
670        }
671    }
672
673    if digits.is_empty() {
674        return (vec![], 0);
675    }
676
677    // The integer part length gives us the base exponent
678    let int_len = int_part.chars().filter(|c| c.is_ascii_digit()).count() as i32;
679
680    // Find first non-zero digit
681    let leading_zeros = digits.iter().take_while(|&&d| d == 0).count();
682    digits.drain(..leading_zeros);
683
684    // Trim trailing zeros
685    while digits.last() == Some(&0) {
686        digits.pop();
687    }
688
689    if digits.is_empty() {
690        return (vec![], 0);
691    }
692
693    // exponent = int_len - leading_zeros + exp_part
694    // But we need to account for whether leading zeros were in int or frac part
695    let exponent = int_len - leading_zeros as i32 + exp_part;
696
697    (digits, exponent)
698}
699
700fn mantissa_to_string(digits: &[u8], width: usize) -> String {
701    let mut s = String::with_capacity(width);
702    for &d in digits.iter().take(width) {
703        s.push((b'0' + d) as char);
704    }
705    while s.len() < width {
706        s.push('0');
707    }
708    s
709}
710
711fn complement_mantissa(digits: &[u8], width: usize) -> String {
712    let mut s = String::with_capacity(width);
713    for i in 0..width {
714        let d = if i < digits.len() { digits[i] } else { 0 };
715        s.push((b'0' + (9 - d)) as char);
716    }
717    s
718}
719
720/// Hex-encode bytes (lowercase) for binary key storage.
721fn hex_encode(bytes: &[u8]) -> String {
722    let mut s = String::with_capacity(bytes.len() * 2);
723    for &b in bytes {
724        s.push_str(&format!("{b:02x}"));
725    }
726    s
727}
728
729// ---------------------------------------------------------------------------
730// Item helpers
731// ---------------------------------------------------------------------------
732
733/// A DynamoDB item: a map of attribute names to values.
734pub type Item = HashMap<String, AttributeValue>;
735
736/// SSE specification for server-side encryption settings.
737#[derive(Debug, Clone, Default, Serialize, Deserialize)]
738pub struct SseSpecification {
739    #[serde(rename = "Enabled", default)]
740    pub enabled: Option<bool>,
741    #[serde(rename = "SSEType", default)]
742    pub sse_type: Option<String>,
743    #[serde(rename = "KMSMasterKeyId", default)]
744    pub kms_master_key_id: Option<String>,
745}
746
747/// DynamoDB Tag (key-value pair attached to a resource).
748#[derive(Debug, Clone, Default, Serialize, Deserialize)]
749pub struct Tag {
750    #[serde(rename = "Key")]
751    pub key: String,
752    #[serde(rename = "Value")]
753    pub value: String,
754}
755
756/// Calculate the total size of a DynamoDB item in bytes.
757pub fn item_size(item: &Item) -> usize {
758    item.iter()
759        .map(|(name, value)| name.len() + value.size())
760        .sum()
761}
762
763/// Maximum item size in bytes (400 KB).
764pub const MAX_ITEM_SIZE: usize = 400 * 1024;
765
766/// ItemCollectionMetrics returned when `ReturnItemCollectionMetrics: SIZE` is set
767/// and the table has local secondary indexes.
768#[derive(Debug, Clone, Serialize, Deserialize)]
769pub struct ItemCollectionMetrics {
770    #[serde(rename = "ItemCollectionKey")]
771    pub item_collection_key: HashMap<String, AttributeValue>,
772    #[serde(rename = "SizeEstimateRangeGB")]
773    pub size_estimate_range_gb: Vec<f64>,
774}
775
776/// ConsumedCapacity returned when `ReturnConsumedCapacity` is set.
777#[derive(Debug, Clone, Default, Serialize, Deserialize)]
778pub struct ConsumedCapacity {
779    #[serde(rename = "TableName")]
780    pub table_name: String,
781    #[serde(rename = "CapacityUnits")]
782    pub capacity_units: f64,
783    #[serde(rename = "ReadCapacityUnits", skip_serializing_if = "Option::is_none")]
784    pub read_capacity_units: Option<f64>,
785    #[serde(rename = "WriteCapacityUnits", skip_serializing_if = "Option::is_none")]
786    pub write_capacity_units: Option<f64>,
787    #[serde(rename = "Table", skip_serializing_if = "Option::is_none")]
788    pub table: Option<CapacityDetail>,
789    #[serde(
790        rename = "GlobalSecondaryIndexes",
791        skip_serializing_if = "Option::is_none"
792    )]
793    pub global_secondary_indexes: Option<HashMap<String, CapacityDetail>>,
794    #[serde(
795        rename = "LocalSecondaryIndexes",
796        skip_serializing_if = "Option::is_none"
797    )]
798    pub local_secondary_indexes: Option<HashMap<String, CapacityDetail>>,
799    /// Per-vector-index replication cost. Reported under `INDEXES` alone, and
800    /// never folded into `capacity_units`: vector replication is billed in
801    /// bytes on its own axis rather than in capacity units.
802    #[serde(rename = "VectorIndexes", skip_serializing_if = "Option::is_none")]
803    pub vector_indexes: Option<HashMap<String, VectorCapacityDetail>>,
804}
805
806/// Per-resource capacity detail.
807#[derive(Debug, Clone, Default, Serialize, Deserialize)]
808pub struct CapacityDetail {
809    #[serde(rename = "CapacityUnits")]
810    pub capacity_units: f64,
811    #[serde(rename = "ReadCapacityUnits", skip_serializing_if = "Option::is_none")]
812    pub read_capacity_units: Option<f64>,
813    #[serde(rename = "WriteCapacityUnits", skip_serializing_if = "Option::is_none")]
814    pub write_capacity_units: Option<f64>,
815}
816
817/// One vector index's replication cost for a write.
818///
819/// Vector indexes are billed in bytes rather than capacity units, so this
820/// carries no `CapacityUnits` and the figure never reaches the response's
821/// total. An index a write leaves alone is absent from the map rather than
822/// present and zeroed (captured 2026-08-11, eu-west-2).
823#[derive(Debug, Clone, Default, Serialize, Deserialize)]
824pub struct VectorCapacityDetail {
825    #[serde(rename = "VectorWriteRequestBytes")]
826    pub vector_write_request_bytes: f64,
827}
828
829/// The `ConsumedCapacity` a `SearchVectors` response carries.
830///
831/// The shape has no `CapacityUnits` and no `TableName`, reads identically under
832/// `TOTAL` and `INDEXES`, and is absent under `NONE` (captured 2026-08-11,
833/// eu-west-2). Despite the field name, real DynamoDB bills the figure on the
834/// data the search read rather than on the request, and does not reproduce it
835/// between identical calls; Dynoxide reports a deterministic stand-in instead.
836#[derive(Debug, Clone, Default, Serialize, Deserialize)]
837pub struct VectorSearchCapacity {
838    #[serde(rename = "VectorSearchRequestBytes")]
839    pub vector_search_request_bytes: f64,
840}
841
842/// The billable minimum for a vector request, in bytes.
843///
844/// DynamoDB documents a 1KB minimum on vector billing and the capture observed
845/// 1024.0 for a three-dimensional fixture, which is this floor rather than a
846/// constant.
847///
848/// The two axes above the floor are not on the same footing. The write figure
849/// is captured byte-exact (eu-west-2, 2026-08-18): five fixtures from 3 to 512
850/// dimensions fit `4 * dimensions + vector attribute name + item size of the
851/// rest of the projected entry` with no residual, and the tests fail on a
852/// one-byte error. The search figure has no oracle at all, because real
853/// DynamoDB does not reproduce its own, so what Dynoxide reports there is a
854/// deterministic stand-in rather than a match.
855pub const MIN_VECTOR_REQUEST_BYTES: f64 = 1024.0;
856
857/// The billable byte figure for a vector request measuring `bytes`.
858pub fn vector_request_bytes(bytes: usize) -> f64 {
859    (bytes as f64).max(MIN_VECTOR_REQUEST_BYTES)
860}
861
862/// The transactional capacity multiplier. `TransactWriteItems` and
863/// `TransactGetItems` cost twice the equivalent single-item operation, so each
864/// item's rounded-up units are doubled (the rounding happens per item, before
865/// the multiplier, to match AWS at the KB/4KB boundary).
866pub const TRANSACTIONAL_CAPACITY_FACTOR: f64 = 2.0;
867
868/// Calculate write capacity units (1 WCU = 1KB, rounded up).
869pub fn write_capacity_units(item_size_bytes: usize) -> f64 {
870    ((item_size_bytes as f64) / 1024.0).ceil().max(1.0)
871}
872
873/// Write capacity units the base table consumes for a single write.
874///
875/// DynamoDB sizes a write on the larger of the item's before and after images,
876/// so shrinking an item still costs what the old one did. `old_size` is `None`
877/// when nothing was there beforehand, and `new_size` is `None` on a delete,
878/// where only the old image exists.
879pub fn table_write_capacity_units(old_size: Option<usize>, new_size: Option<usize>) -> f64 {
880    let old = old_size.map(write_capacity_units);
881    let new = new_size.map(write_capacity_units);
882    match (old, new) {
883        (Some(old), Some(new)) => old.max(new),
884        (Some(units), None) | (None, Some(units)) => units,
885        // No image either side is not a write DynamoDB would charge for, but a
886        // write is never free, so fall back to the one-unit minimum.
887        (None, None) => write_capacity_units(0),
888    }
889}
890
891/// Read capacity units one write's images cost when charged as a read.
892///
893/// A same-token transactional replay is billed as a read against the image the
894/// original write was sized on, which is the larger of the before and after
895/// images, but rounded at 4KB rather than 1KB. Captured in eu-west-2: a replayed
896/// put that shrank a 9KB item to nothing reports 6, and so does the replay of
897/// the write that grew it, so neither side alone explains the figure.
898pub fn table_read_capacity_units(old_size: Option<usize>, new_size: Option<usize>) -> f64 {
899    let larger = old_size.unwrap_or(0).max(new_size.unwrap_or(0));
900    read_capacity_units(larger)
901}
902
903/// Calculate read capacity units assuming strongly consistent reads
904/// (1 RCU per 4KB, rounded up). Used when ConsistentRead is true or
905/// when the read type is not specified.
906pub fn read_capacity_units(item_size_bytes: usize) -> f64 {
907    ((item_size_bytes as f64) / 4096.0).ceil().max(1.0)
908}
909
910/// Calculate read capacity units accounting for consistency mode.
911///
912/// Strongly consistent: 1 RCU per 4KB, rounded up.
913/// Eventually consistent: 0.5 RCU per 4KB (half the strongly consistent rate).
914pub fn read_capacity_units_with_consistency(item_size_bytes: usize, consistent: bool) -> f64 {
915    let strongly = read_capacity_units(item_size_bytes);
916    if consistent { strongly } else { strongly / 2.0 }
917}
918
919/// Whether a `ReturnConsumedCapacity` value asks for a figure at all.
920///
921/// Every `consumed_capacity*` builder below throws its inputs away unless the
922/// mode is `TOTAL` or `INDEXES`, so work done only to feed them is wasted in
923/// every other mode, and `NONE` is the default. Callers that can skip that work
924/// ask here rather than each spelling the comparison out, so the set of modes
925/// that mean "yes" is written down once.
926pub fn capacity_wanted(mode: Option<&str>) -> bool {
927    matches!(mode, Some("TOTAL") | Some("INDEXES"))
928}
929
930/// Whether a `ReturnConsumedCapacity` mode carries the vector write map.
931///
932/// Narrower than [`capacity_wanted`], because the per-index vector map reaches
933/// the wire under `INDEXES` alone (captured 2026-08-11, eu-west-2); `TOTAL`
934/// asks for capacity and still carries no vector fields. The sizing costs a
935/// derivation of the old image per index, so the fan-out asks here and skips
936/// the work rather than doing it and having the response builder drop it. Both
937/// ends read the same predicate so they cannot disagree about which modes mean
938/// yes.
939pub fn vector_capacity_wanted(mode: Option<&str>) -> bool {
940    matches!(mode, Some("INDEXES"))
941}
942
943/// Build a `ConsumedCapacity` for a simple table operation.
944pub fn consumed_capacity(
945    table_name: &str,
946    capacity_units: f64,
947    mode: &Option<String>,
948) -> Option<ConsumedCapacity> {
949    let mode = mode.as_deref().unwrap_or("NONE");
950    match mode {
951        "TOTAL" => Some(ConsumedCapacity {
952            table_name: table_name.to_string(),
953            capacity_units,
954            table: None,
955            global_secondary_indexes: None,
956            local_secondary_indexes: None,
957            ..Default::default()
958        }),
959        "INDEXES" => Some(ConsumedCapacity {
960            table_name: table_name.to_string(),
961            capacity_units,
962            table: Some(CapacityDetail {
963                capacity_units,
964                ..Default::default()
965            }),
966            global_secondary_indexes: None,
967            local_secondary_indexes: None,
968            ..Default::default()
969        }),
970        _ => None,
971    }
972}
973
974/// Build a `ConsumedCapacity` with per-GSI breakdown for INDEXES mode.
975pub fn consumed_capacity_with_indexes(
976    table_name: &str,
977    table_units: f64,
978    gsi_units: &HashMap<String, f64>,
979    mode: &Option<String>,
980) -> Option<ConsumedCapacity> {
981    consumed_capacity_with_secondary_indexes(
982        table_name,
983        table_units,
984        gsi_units,
985        &HashMap::new(),
986        mode,
987    )
988}
989
990/// Build a `ConsumedCapacity` with per-GSI and per-LSI breakdown for INDEXES mode.
991pub fn consumed_capacity_with_secondary_indexes(
992    table_name: &str,
993    table_units: f64,
994    gsi_units: &HashMap<String, f64>,
995    lsi_units: &HashMap<String, f64>,
996    mode: &Option<String>,
997) -> Option<ConsumedCapacity> {
998    secondary_index_capacity(
999        table_name,
1000        table_units,
1001        gsi_units,
1002        lsi_units,
1003        mode,
1004        CapacityAxis::None,
1005    )
1006}
1007
1008/// Whether a response mirrors its units into a named axis alongside
1009/// `CapacityUnits`.
1010///
1011/// Single-item and PartiQL responses report `CapacityUnits` alone at every
1012/// level. Transactional responses mirror the units into `WriteCapacityUnits`
1013/// at every level, including each index arm. Both shapes are captured against
1014/// real DynamoDB.
1015#[derive(Clone, Copy, PartialEq)]
1016enum CapacityAxis {
1017    None,
1018    Write,
1019}
1020
1021impl CapacityAxis {
1022    /// The `WriteCapacityUnits` value for a detail carrying `units`.
1023    fn write_units(self, units: f64) -> Option<f64> {
1024        match self {
1025            Self::None => None,
1026            Self::Write => Some(units),
1027        }
1028    }
1029}
1030
1031/// Shared body for the per-index capacity builders. `table_units` carries the
1032/// transactional factor already, when one applies; the index maps never do.
1033fn secondary_index_capacity(
1034    table_name: &str,
1035    table_units: f64,
1036    gsi_units: &HashMap<String, f64>,
1037    lsi_units: &HashMap<String, f64>,
1038    mode: &Option<String>,
1039    axis: CapacityAxis,
1040) -> Option<ConsumedCapacity> {
1041    let total = table_units + gsi_units.values().sum::<f64>() + lsi_units.values().sum::<f64>();
1042
1043    match mode.as_deref().unwrap_or("NONE") {
1044        "INDEXES" => Some(ConsumedCapacity {
1045            table_name: table_name.to_string(),
1046            capacity_units: total,
1047            write_capacity_units: axis.write_units(total),
1048            table: Some(CapacityDetail {
1049                capacity_units: table_units,
1050                write_capacity_units: axis.write_units(table_units),
1051                ..Default::default()
1052            }),
1053            global_secondary_indexes: capacity_detail_map(gsi_units, axis),
1054            local_secondary_indexes: capacity_detail_map(lsi_units, axis),
1055            ..Default::default()
1056        }),
1057        "TOTAL" => Some(ConsumedCapacity {
1058            table_name: table_name.to_string(),
1059            capacity_units: total,
1060            write_capacity_units: axis.write_units(total),
1061            ..Default::default()
1062        }),
1063        _ => None,
1064    }
1065}
1066
1067/// Build a `ConsumedCapacity` carrying the classic index arms and the vector
1068/// arm together.
1069///
1070/// The single mode argument is the point: the classic builder and the vector
1071/// attach each need one, and passing them separately leaves a caller free to
1072/// build under one mode and attach under another, which would put a
1073/// `VectorIndexes` map on a `TOTAL`-shaped response.
1074pub fn consumed_capacity_with_vector_indexes(
1075    table_name: &str,
1076    table_units: f64,
1077    gsi_units: &HashMap<String, f64>,
1078    lsi_units: &HashMap<String, f64>,
1079    vector_bytes: &HashMap<String, f64>,
1080    mode: &Option<String>,
1081) -> Option<ConsumedCapacity> {
1082    attach_vector_index_capacity(
1083        consumed_capacity_with_secondary_indexes(
1084            table_name,
1085            table_units,
1086            gsi_units,
1087            lsi_units,
1088            mode,
1089        ),
1090        vector_bytes,
1091        mode,
1092    )
1093}
1094
1095/// Attach per-vector-index write bytes to a capacity report.
1096///
1097/// Vector replication sits on its own axis, so this never touches
1098/// `capacity_units`: the bytes are reported beside the classic arms, not
1099/// summed into them. The map appears under `INDEXES` alone, matching the
1100/// capture, and an empty `vector_bytes` leaves the response untouched so a
1101/// write that changed no vector index reports no map at all rather than an
1102/// empty one.
1103pub(crate) fn attach_vector_index_capacity(
1104    capacity: Option<ConsumedCapacity>,
1105    vector_bytes: &HashMap<String, f64>,
1106    mode: &Option<String>,
1107) -> Option<ConsumedCapacity> {
1108    let mut capacity = capacity?;
1109    if vector_bytes.is_empty() || !vector_capacity_wanted(mode.as_deref()) {
1110        return Some(capacity);
1111    }
1112    capacity.vector_indexes = Some(
1113        vector_bytes
1114            .iter()
1115            .map(|(name, bytes)| {
1116                (
1117                    name.clone(),
1118                    VectorCapacityDetail {
1119                        vector_write_request_bytes: *bytes,
1120                    },
1121                )
1122            })
1123            .collect(),
1124    );
1125    Some(capacity)
1126}
1127
1128/// Build a `ConsumedCapacity` for one table in a transactional read
1129/// (`TransactGetItems`). `units` is the table total and already includes the
1130/// transactional 2x factor. Under `INDEXES` the Table detail reports
1131/// `ReadCapacityUnits` alongside `CapacityUnits`, matching AWS.
1132pub fn transactional_read_capacity(
1133    table_name: &str,
1134    units: f64,
1135    mode: &Option<String>,
1136) -> Option<ConsumedCapacity> {
1137    match mode.as_deref().unwrap_or("NONE") {
1138        "TOTAL" => Some(ConsumedCapacity {
1139            table_name: table_name.to_string(),
1140            capacity_units: units,
1141            read_capacity_units: Some(units),
1142            ..Default::default()
1143        }),
1144        "INDEXES" => Some(ConsumedCapacity {
1145            table_name: table_name.to_string(),
1146            capacity_units: units,
1147            read_capacity_units: Some(units),
1148            table: Some(CapacityDetail {
1149                capacity_units: units,
1150                read_capacity_units: Some(units),
1151                ..Default::default()
1152            }),
1153            ..Default::default()
1154        }),
1155        _ => None,
1156    }
1157}
1158
1159/// Build the per-table `ConsumedCapacity` vec for a transactional op from the
1160/// per-table units, using `builder` (`transactional_write_capacity` for a
1161/// first-call write, `transactional_read_capacity` for a read set or a
1162/// same-token replay). Returns `None` unless `ReturnConsumedCapacity` is
1163/// `TOTAL` or `INDEXES`, so the mode guard lives in one place. Shared by
1164/// `TransactWriteItems` and `ExecuteTransaction`.
1165pub fn build_transactional_capacity(
1166    table_units: &HashMap<String, f64>,
1167    mode: &Option<String>,
1168    builder: fn(&str, f64, &Option<String>) -> Option<ConsumedCapacity>,
1169) -> Option<Vec<ConsumedCapacity>> {
1170    if !capacity_wanted(mode.as_deref()) {
1171        return None;
1172    }
1173
1174    // Sorted by table name, matching the write path. Iterating the map handed
1175    // back a different order per process, so a two-table read set or replay
1176    // could report its tables either way round while the write half of the same
1177    // response family was stable.
1178    let mut tables: Vec<&String> = table_units.keys().collect();
1179    tables.sort();
1180
1181    Some(
1182        tables
1183            .into_iter()
1184            .filter_map(|table| builder(table, *table_units.get(table)?, mode))
1185            .collect(),
1186    )
1187}
1188
1189/// Build a `ConsumedCapacity` for one table in a transactional write, with the
1190/// per-index breakdown.
1191///
1192/// `table_units` already carries the transactional 2x factor; the index maps do
1193/// not. DynamoDB applies that factor to the base table arm alone, so an index
1194/// arm inside a transaction costs what the same write costs outside one.
1195///
1196/// `CapacityUnits` and `WriteCapacityUnits` both report the table arm plus the
1197/// index arms. The `Table` detail reports the table arm on its own, and every
1198/// index arm carries the write axis too, which is where this shape differs from
1199/// the single-item one.
1200pub fn transactional_write_capacity_with_indexes(
1201    table_name: &str,
1202    table_units: f64,
1203    gsi_units: &HashMap<String, f64>,
1204    lsi_units: &HashMap<String, f64>,
1205    mode: &Option<String>,
1206) -> Option<ConsumedCapacity> {
1207    secondary_index_capacity(
1208        table_name,
1209        table_units,
1210        gsi_units,
1211        lsi_units,
1212        mode,
1213        CapacityAxis::Write,
1214    )
1215}
1216
1217/// Turn per-index units into the response's detail map, or `None` when nothing
1218/// was charged so the arm is absent rather than present and empty.
1219fn capacity_detail_map(
1220    units: &HashMap<String, f64>,
1221    axis: CapacityAxis,
1222) -> Option<HashMap<String, CapacityDetail>> {
1223    if units.is_empty() {
1224        return None;
1225    }
1226    Some(
1227        units
1228            .iter()
1229            .map(|(name, &u)| {
1230                (
1231                    name.clone(),
1232                    CapacityDetail {
1233                        capacity_units: u,
1234                        write_capacity_units: axis.write_units(u),
1235                        ..Default::default()
1236                    },
1237                )
1238            })
1239            .collect(),
1240    )
1241}
1242
1243/// Build a `ConsumedCapacity` for one table in a transactional write
1244/// (`TransactWriteItems`). `units` is the table total and already includes the
1245/// transactional 2x factor. Under `INDEXES` the Table detail reports
1246/// `WriteCapacityUnits` alongside `CapacityUnits`, matching AWS.
1247pub fn transactional_write_capacity(
1248    table_name: &str,
1249    units: f64,
1250    mode: &Option<String>,
1251) -> Option<ConsumedCapacity> {
1252    match mode.as_deref().unwrap_or("NONE") {
1253        "TOTAL" => Some(ConsumedCapacity {
1254            table_name: table_name.to_string(),
1255            capacity_units: units,
1256            write_capacity_units: Some(units),
1257            ..Default::default()
1258        }),
1259        "INDEXES" => Some(ConsumedCapacity {
1260            table_name: table_name.to_string(),
1261            capacity_units: units,
1262            write_capacity_units: Some(units),
1263            table: Some(CapacityDetail {
1264                capacity_units: units,
1265                write_capacity_units: Some(units),
1266                ..Default::default()
1267            }),
1268            ..Default::default()
1269        }),
1270        _ => None,
1271    }
1272}
1273
1274/// Key schema element - defines a key attribute.
1275#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1276pub struct KeySchemaElement {
1277    #[serde(rename = "AttributeName", alias = "attribute_name")]
1278    pub attribute_name: String,
1279    #[serde(rename = "KeyType", alias = "key_type")]
1280    pub key_type: KeyType,
1281}
1282
1283/// Key type: HASH (partition key) or RANGE (sort key).
1284#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1285pub enum KeyType {
1286    #[default]
1287    HASH,
1288    RANGE,
1289}
1290
1291/// Attribute definition - declares an attribute's type.
1292#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1293pub struct AttributeDefinition {
1294    #[serde(rename = "AttributeName", alias = "attribute_name")]
1295    pub attribute_name: String,
1296    #[serde(rename = "AttributeType", alias = "attribute_type")]
1297    pub attribute_type: ScalarAttributeType,
1298}
1299
1300/// Scalar attribute types that can be used as keys.
1301#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1302pub enum ScalarAttributeType {
1303    #[default]
1304    S,
1305    N,
1306    B,
1307}
1308
1309/// GSI projection type.
1310#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1311pub struct Projection {
1312    #[serde(
1313        rename = "ProjectionType",
1314        alias = "projection_type",
1315        default,
1316        skip_serializing_if = "Option::is_none"
1317    )]
1318    pub projection_type: Option<ProjectionType>,
1319    #[serde(
1320        rename = "NonKeyAttributes",
1321        alias = "non_key_attributes",
1322        skip_serializing_if = "Option::is_none"
1323    )]
1324    pub non_key_attributes: Option<Vec<String>>,
1325}
1326
1327/// Projection type enum.
1328#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1329#[allow(non_camel_case_types)]
1330pub enum ProjectionType {
1331    #[default]
1332    ALL,
1333    KEYS_ONLY,
1334    INCLUDE,
1335}
1336
1337/// Global Secondary Index definition.
1338#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1339pub struct GlobalSecondaryIndex {
1340    #[serde(rename = "IndexName", alias = "index_name")]
1341    pub index_name: String,
1342    #[serde(rename = "KeySchema", alias = "key_schema")]
1343    pub key_schema: Vec<KeySchemaElement>,
1344    #[serde(rename = "Projection", alias = "projection")]
1345    pub projection: Projection,
1346    #[serde(
1347        rename = "ProvisionedThroughput",
1348        alias = "provisioned_throughput",
1349        skip_serializing_if = "Option::is_none"
1350    )]
1351    pub provisioned_throughput: Option<ProvisionedThroughput>,
1352}
1353
1354/// Local Secondary Index definition.
1355#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1356pub struct LocalSecondaryIndex {
1357    #[serde(rename = "IndexName", alias = "index_name")]
1358    pub index_name: String,
1359    #[serde(rename = "KeySchema", alias = "key_schema")]
1360    pub key_schema: Vec<KeySchemaElement>,
1361    #[serde(rename = "Projection", alias = "projection")]
1362    pub projection: Projection,
1363}
1364
1365/// The vector attribute a vector index is built over. A structure with a
1366/// single `AttributeName` member on the wire, not a bare string.
1367#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1368pub struct VectorAttributeDefinition {
1369    #[serde(rename = "AttributeName", alias = "attribute_name")]
1370    pub attribute_name: String,
1371}
1372
1373/// One element of a vector index's search schema: a `HASH` partition
1374/// attribute or an `INLINE_FILTER` attribute.
1375#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1376pub struct SearchSchemaElement {
1377    #[serde(rename = "AttributeName", alias = "attribute_name")]
1378    pub attribute_name: String,
1379    #[serde(
1380        rename = "SearchSchemaElementType",
1381        alias = "search_schema_element_type"
1382    )]
1383    pub search_schema_element_type: String,
1384}
1385
1386/// Vector index definition as supplied on CreateTable's `VectorIndexes`.
1387///
1388/// Serialised verbatim into the `_tables.vector_index_definitions` column,
1389/// following the `gsi_definitions` convention.
1390#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1391pub struct VectorIndex {
1392    #[serde(rename = "IndexName", alias = "index_name")]
1393    pub index_name: String,
1394    #[serde(rename = "VectorAttribute", alias = "vector_attribute")]
1395    pub vector_attribute: VectorAttributeDefinition,
1396    #[serde(
1397        rename = "SearchSchema",
1398        alias = "search_schema",
1399        default,
1400        skip_serializing_if = "Option::is_none"
1401    )]
1402    pub search_schema: Option<Vec<SearchSchemaElement>>,
1403    #[serde(rename = "Projection", alias = "projection")]
1404    pub projection: Projection,
1405    #[serde(rename = "Dimensions", alias = "dimensions")]
1406    pub dimensions: u32,
1407    #[serde(rename = "DistanceFunction", alias = "distance_function")]
1408    pub distance_function: String,
1409}
1410
1411/// Why an attribute value is not a valid vector for an index with a given
1412/// dimension count. Carries the element-level detail the write-path
1413/// validation errors interpolate; the checks run in the order the variants
1414/// are listed, so a value can only report one failure.
1415#[derive(Debug, Clone, PartialEq)]
1416pub enum VectorValueError {
1417    /// The value is not a List at all.
1418    NotAList,
1419    /// The list's element count differs from the index's dimensions.
1420    WrongDimensions {
1421        /// The number of elements actually present.
1422        actual: usize,
1423    },
1424    /// An element is not a Number.
1425    ElementNotANumber {
1426        /// Zero-based element position.
1427        position: usize,
1428        /// The element's DynamoDB type descriptor.
1429        actual: &'static str,
1430    },
1431    /// An element does not fit the finite f32 range (out-of-range values
1432    /// overflow to infinity on conversion; real DynamoDB rejects them at
1433    /// write time, captured eu-west-2 and us-east-1, 2026-08-12).
1434    ElementOutOfRange {
1435        /// Zero-based element position.
1436        position: usize,
1437        /// The raw number string as written.
1438        value: String,
1439    },
1440}
1441
1442/// The f32 values of a vector attribute value, or the element-level reason it
1443/// is not a valid vector for an index with `dimensions` elements.
1444///
1445/// Vector indexes hold f32 copies while the base table keeps full precision
1446/// (captured from real DynamoDB, eu-west-2, 2026-08-11), so conversion
1447/// happens here, where the index copy is derived. The live write path turns
1448/// each error variant into its captured `IndexName:`-suffixed message; the
1449/// backfill path discards the detail via [`vector_f32_values`] and
1450/// sparse-skips, so the two paths agree on validity by construction.
1451pub fn check_vector_f32_values(
1452    value: &AttributeValue,
1453    dimensions: u32,
1454) -> std::result::Result<Vec<f32>, VectorValueError> {
1455    let AttributeValue::L(elems) = value else {
1456        return Err(VectorValueError::NotAList);
1457    };
1458    if elems.len() != dimensions as usize {
1459        return Err(VectorValueError::WrongDimensions {
1460            actual: elems.len(),
1461        });
1462    }
1463    let mut out = Vec::with_capacity(elems.len());
1464    for (position, elem) in elems.iter().enumerate() {
1465        let AttributeValue::N(n) = elem else {
1466            return Err(VectorValueError::ElementNotANumber {
1467                position,
1468                actual: elem.type_name(),
1469            });
1470        };
1471        // A well-formed DynamoDB number always parses (overflow yields
1472        // infinity, not an error); anything non-finite is out of f32 range.
1473        match n.parse::<f32>() {
1474            Ok(v) if v.is_finite() => out.push(v),
1475            _ => {
1476                return Err(VectorValueError::ElementOutOfRange {
1477                    position,
1478                    value: n.clone(),
1479                });
1480            }
1481        }
1482    }
1483    Ok(out)
1484}
1485
1486/// The f32 values of a vector attribute value, or `None` when the value is
1487/// not a valid vector for an index with `dimensions` elements. The validity
1488/// rule is exactly [`check_vector_f32_values`]'s, with the element-level
1489/// detail discarded; the backfill path uses this form to sparse-skip.
1490pub fn vector_f32_values(value: &AttributeValue, dimensions: u32) -> Option<Vec<f32>> {
1491    check_vector_f32_values(value, dimensions).ok()
1492}
1493
1494/// Serialise an f32 for a vector index's number copy: shortest-decimal via
1495/// serde_json's f32 formatter, which always keeps a fractional part ("1"
1496/// stores as "1.0", matching how the index copy reads back from real
1497/// DynamoDB; captured eu-west-2, 2026-08-11).
1498pub fn f32_number_string(v: f32) -> String {
1499    serde_json::to_string(&v).unwrap_or_else(|_| "0.0".to_string())
1500}
1501
1502/// Provisioned throughput settings (stored but not enforced).
1503#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1504pub struct ProvisionedThroughput {
1505    #[serde(rename = "ReadCapacityUnits", alias = "read_capacity_units", default)]
1506    pub read_capacity_units: Option<i64>,
1507    #[serde(rename = "WriteCapacityUnits", alias = "write_capacity_units", default)]
1508    pub write_capacity_units: Option<i64>,
1509}
1510
1511/// On-demand (PAY_PER_REQUEST) throughput ceilings (stored but not enforced).
1512#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1513pub struct OnDemandThroughput {
1514    #[serde(
1515        rename = "MaxReadRequestUnits",
1516        alias = "max_read_request_units",
1517        default,
1518        skip_serializing_if = "Option::is_none"
1519    )]
1520    pub max_read_request_units: Option<i64>,
1521    #[serde(
1522        rename = "MaxWriteRequestUnits",
1523        alias = "max_write_request_units",
1524        default,
1525        skip_serializing_if = "Option::is_none"
1526    )]
1527    pub max_write_request_units: Option<i64>,
1528}
1529
1530// ---------------------------------------------------------------------------
1531// Type conversion: From<T> / TryFrom<T> for AttributeValue
1532// ---------------------------------------------------------------------------
1533
1534/// Error returned when converting between `AttributeValue` and Rust types.
1535#[derive(Debug, Clone, PartialEq)]
1536pub struct ConversionError {
1537    /// The expected DynamoDB or Rust type.
1538    pub expected: &'static str,
1539    /// The actual DynamoDB type encountered.
1540    pub actual: &'static str,
1541}
1542
1543impl fmt::Display for ConversionError {
1544    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1545        write!(f, "expected {}, got {}", self.expected, self.actual)
1546    }
1547}
1548
1549impl std::error::Error for ConversionError {}
1550
1551// --- From<T> for AttributeValue: infallible conversions ---
1552
1553impl From<String> for AttributeValue {
1554    fn from(value: String) -> Self {
1555        AttributeValue::S(value)
1556    }
1557}
1558
1559impl From<&str> for AttributeValue {
1560    fn from(value: &str) -> Self {
1561        AttributeValue::S(value.to_string())
1562    }
1563}
1564
1565impl From<bool> for AttributeValue {
1566    fn from(value: bool) -> Self {
1567        AttributeValue::BOOL(value)
1568    }
1569}
1570
1571impl From<Vec<u8>> for AttributeValue {
1572    fn from(value: Vec<u8>) -> Self {
1573        AttributeValue::B(value)
1574    }
1575}
1576
1577impl From<&[u8]> for AttributeValue {
1578    fn from(value: &[u8]) -> Self {
1579        AttributeValue::B(value.to_vec())
1580    }
1581}
1582
1583// Integer types - all finite, all fit in DynamoDB's number range.
1584macro_rules! impl_from_integer {
1585    ($($t:ty),+) => {
1586        $(
1587            impl From<$t> for AttributeValue {
1588                fn from(value: $t) -> Self {
1589                    AttributeValue::N(value.to_string())
1590                }
1591            }
1592        )+
1593    };
1594}
1595
1596impl_from_integer!(i8, i16, i32, i64, i128, u8, u16, u32, u64, u128);
1597
1598// Container types
1599impl From<HashMap<String, AttributeValue>> for AttributeValue {
1600    fn from(value: HashMap<String, AttributeValue>) -> Self {
1601        AttributeValue::M(value)
1602    }
1603}
1604
1605impl From<Vec<AttributeValue>> for AttributeValue {
1606    fn from(value: Vec<AttributeValue>) -> Self {
1607        AttributeValue::L(value)
1608    }
1609}
1610
1611impl From<HashSet<String>> for AttributeValue {
1612    fn from(value: HashSet<String>) -> Self {
1613        AttributeValue::SS(value.into_iter().collect())
1614    }
1615}
1616
1617impl From<BTreeSet<String>> for AttributeValue {
1618    fn from(value: BTreeSet<String>) -> Self {
1619        AttributeValue::SS(value.into_iter().collect())
1620    }
1621}
1622
1623// --- TryFrom<T> for AttributeValue: fallible conversions (floats) ---
1624
1625impl TryFrom<f64> for AttributeValue {
1626    type Error = ConversionError;
1627
1628    fn try_from(value: f64) -> std::result::Result<Self, Self::Error> {
1629        if value.is_finite() {
1630            Ok(AttributeValue::N(value.to_string()))
1631        } else {
1632            Err(ConversionError {
1633                expected: "finite f64",
1634                actual: "NaN or Infinity",
1635            })
1636        }
1637    }
1638}
1639
1640impl TryFrom<f32> for AttributeValue {
1641    type Error = ConversionError;
1642
1643    fn try_from(value: f32) -> std::result::Result<Self, Self::Error> {
1644        if value.is_finite() {
1645            Ok(AttributeValue::N(value.to_string()))
1646        } else {
1647            Err(ConversionError {
1648                expected: "finite f32",
1649                actual: "NaN or Infinity",
1650            })
1651        }
1652    }
1653}
1654
1655// --- TryFrom<AttributeValue> for T: extract Rust types from AV ---
1656
1657impl TryFrom<AttributeValue> for String {
1658    type Error = ConversionError;
1659
1660    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1661        match value {
1662            AttributeValue::S(s) => Ok(s),
1663            other => Err(ConversionError {
1664                expected: "S",
1665                actual: other.type_name(),
1666            }),
1667        }
1668    }
1669}
1670
1671impl TryFrom<AttributeValue> for bool {
1672    type Error = ConversionError;
1673
1674    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1675        match value {
1676            AttributeValue::BOOL(b) => Ok(b),
1677            other => Err(ConversionError {
1678                expected: "BOOL",
1679                actual: other.type_name(),
1680            }),
1681        }
1682    }
1683}
1684
1685impl TryFrom<AttributeValue> for Vec<u8> {
1686    type Error = ConversionError;
1687
1688    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1689        match value {
1690            AttributeValue::B(b) => Ok(b),
1691            other => Err(ConversionError {
1692                expected: "B",
1693                actual: other.type_name(),
1694            }),
1695        }
1696    }
1697}
1698
1699macro_rules! impl_try_from_av_integer {
1700    ($($t:ty),+) => {
1701        $(
1702            impl TryFrom<AttributeValue> for $t {
1703                type Error = ConversionError;
1704
1705                fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1706                    match value {
1707                        AttributeValue::N(n) => n.parse::<$t>().map_err(|_| ConversionError {
1708                            expected: stringify!($t),
1709                            actual: "N (parse failed)",
1710                        }),
1711                        other => Err(ConversionError {
1712                            expected: "N",
1713                            actual: other.type_name(),
1714                        }),
1715                    }
1716                }
1717            }
1718        )+
1719    };
1720}
1721
1722impl_try_from_av_integer!(i8, i16, i32, i64, i128, u8, u16, u32, u64, u128);
1723
1724impl TryFrom<AttributeValue> for f64 {
1725    type Error = ConversionError;
1726
1727    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1728        match value {
1729            AttributeValue::N(n) => n.parse::<f64>().map_err(|_| ConversionError {
1730                expected: "f64",
1731                actual: "N (parse failed)",
1732            }),
1733            other => Err(ConversionError {
1734                expected: "N",
1735                actual: other.type_name(),
1736            }),
1737        }
1738    }
1739}
1740
1741impl TryFrom<AttributeValue> for f32 {
1742    type Error = ConversionError;
1743
1744    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1745        match value {
1746            AttributeValue::N(n) => n.parse::<f32>().map_err(|_| ConversionError {
1747                expected: "f32",
1748                actual: "N (parse failed)",
1749            }),
1750            other => Err(ConversionError {
1751                expected: "N",
1752                actual: other.type_name(),
1753            }),
1754        }
1755    }
1756}
1757
1758impl TryFrom<AttributeValue> for HashMap<String, AttributeValue> {
1759    type Error = ConversionError;
1760
1761    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1762        match value {
1763            AttributeValue::M(m) => Ok(m),
1764            other => Err(ConversionError {
1765                expected: "M",
1766                actual: other.type_name(),
1767            }),
1768        }
1769    }
1770}
1771
1772impl TryFrom<AttributeValue> for Vec<AttributeValue> {
1773    type Error = ConversionError;
1774
1775    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1776        match value {
1777            AttributeValue::L(l) => Ok(l),
1778            other => Err(ConversionError {
1779                expected: "L",
1780                actual: other.type_name(),
1781            }),
1782        }
1783    }
1784}
1785
1786impl TryFrom<AttributeValue> for Vec<String> {
1787    type Error = ConversionError;
1788
1789    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
1790        match value {
1791            AttributeValue::SS(ss) => Ok(ss),
1792            AttributeValue::L(l) => {
1793                // Lenient: extract S values from a list
1794                l.into_iter()
1795                    .map(|av| match av {
1796                        AttributeValue::S(s) => Ok(s),
1797                        other => Err(ConversionError {
1798                            expected: "S (within L)",
1799                            actual: other.type_name(),
1800                        }),
1801                    })
1802                    .collect()
1803            }
1804            other => Err(ConversionError {
1805                expected: "SS or L",
1806                actual: other.type_name(),
1807            }),
1808        }
1809    }
1810}
1811
1812#[cfg(test)]
1813mod tests {
1814    use super::*;
1815
1816    #[test]
1817    fn test_serialize_string() {
1818        let val = AttributeValue::S("hello".to_string());
1819        let json = serde_json::to_string(&val).unwrap();
1820        assert_eq!(json, r#"{"S":"hello"}"#);
1821    }
1822
1823    #[test]
1824    fn test_serialize_number() {
1825        let val = AttributeValue::N("42".to_string());
1826        let json = serde_json::to_string(&val).unwrap();
1827        assert_eq!(json, r#"{"N":"42"}"#);
1828    }
1829
1830    #[test]
1831    fn test_serialize_binary() {
1832        let val = AttributeValue::B(vec![1, 2, 3]);
1833        let json = serde_json::to_string(&val).unwrap();
1834        assert_eq!(json, r#"{"B":"AQID"}"#);
1835    }
1836
1837    #[test]
1838    fn test_serialize_bool() {
1839        let val = AttributeValue::BOOL(true);
1840        let json = serde_json::to_string(&val).unwrap();
1841        assert_eq!(json, r#"{"BOOL":true}"#);
1842    }
1843
1844    #[test]
1845    fn test_serialize_null() {
1846        let val = AttributeValue::NULL(true);
1847        let json = serde_json::to_string(&val).unwrap();
1848        assert_eq!(json, r#"{"NULL":true}"#);
1849    }
1850
1851    #[test]
1852    fn test_deserialize_null_true() {
1853        let val: AttributeValue = serde_json::from_str(r#"{"NULL":true}"#).unwrap();
1854        assert_eq!(val, AttributeValue::NULL(true));
1855    }
1856
1857    #[test]
1858    fn test_deserialize_null_false_rejected() {
1859        // AWS requires the NULL member to be exactly `true`; {"NULL": false} is
1860        // rejected with a ValidationException, same as a non-boolean value.
1861        let err = serde_json::from_str::<AttributeValue>(r#"{"NULL":false}"#).unwrap_err();
1862        assert!(
1863            err.to_string().contains("must have the value of true"),
1864            "unexpected error: {err}"
1865        );
1866    }
1867
1868    #[test]
1869    fn test_deserialize_null_non_boolean_rejected() {
1870        // A non-boolean NULL (e.g. {"NULL": "no"}) is a type error, not a value.
1871        let err = serde_json::from_str::<AttributeValue>(r#"{"NULL":"no"}"#).unwrap_err();
1872        assert!(
1873            err.to_string().contains("must have the value of true"),
1874            "unexpected error: {err}"
1875        );
1876    }
1877
1878    #[test]
1879    fn test_serialize_string_set() {
1880        let val = AttributeValue::SS(vec!["a".to_string(), "b".to_string()]);
1881        let json = serde_json::to_string(&val).unwrap();
1882        assert_eq!(json, r#"{"SS":["a","b"]}"#);
1883    }
1884
1885    #[test]
1886    fn test_serialize_list() {
1887        let val = AttributeValue::L(vec![
1888            AttributeValue::S("hello".to_string()),
1889            AttributeValue::N("42".to_string()),
1890        ]);
1891        let json = serde_json::to_string(&val).unwrap();
1892        assert_eq!(json, r#"{"L":[{"S":"hello"},{"N":"42"}]}"#);
1893    }
1894
1895    #[test]
1896    fn test_serialize_map() {
1897        let mut m = HashMap::new();
1898        m.insert("key".to_string(), AttributeValue::S("value".to_string()));
1899        let val = AttributeValue::M(m);
1900        let json = serde_json::to_string(&val).unwrap();
1901        assert_eq!(json, r#"{"M":{"key":{"S":"value"}}}"#);
1902    }
1903
1904    #[test]
1905    fn test_round_trip_all_types() {
1906        let values = vec![
1907            AttributeValue::S("hello".to_string()),
1908            AttributeValue::N("42.5".to_string()),
1909            AttributeValue::B(vec![0, 255, 128]),
1910            AttributeValue::BOOL(false),
1911            AttributeValue::NULL(true),
1912            AttributeValue::SS(vec!["x".to_string(), "y".to_string()]),
1913            AttributeValue::NS(vec!["1".to_string(), "2.5".to_string()]),
1914            AttributeValue::BS(vec![vec![1], vec![2, 3]]),
1915            AttributeValue::L(vec![
1916                AttributeValue::S("nested".to_string()),
1917                AttributeValue::N("99".to_string()),
1918            ]),
1919        ];
1920
1921        for val in values {
1922            let json = serde_json::to_string(&val).unwrap();
1923            let deserialized: AttributeValue = serde_json::from_str(&json).unwrap();
1924            assert_eq!(val, deserialized, "Round-trip failed for {json}");
1925        }
1926    }
1927
1928    #[test]
1929    fn test_size_string() {
1930        let val = AttributeValue::S("hello".to_string());
1931        assert_eq!(val.size(), 5);
1932    }
1933
1934    #[test]
1935    fn test_size_number() {
1936        // "42" has 2 significant digits → ceil(2/2) + 1 = 2
1937        let val = AttributeValue::N("42".to_string());
1938        assert_eq!(val.size(), 2);
1939    }
1940
1941    #[test]
1942    fn test_size_number_matches_dynamodb() {
1943        // Byte-exact ground truth, measured against eu-west-2 by bisecting the
1944        // 400KB item-size gate (which resolves to the byte, unlike the 1KB
1945        // granularity of consumed capacity). A number costs
1946        // ceil(integer significant digits / 2) + ceil(fraction significant
1947        // digits / 2) + 1, over the value with leading and trailing zeros
1948        // trimmed. The count is a property of the significant digits, so it does
1949        // not change when scientific notation is expanded on storage.
1950        let cases: &[(&str, usize)] = &[
1951            // digit counts either side of the decimal point
1952            ("1", 2),
1953            ("12", 2),
1954            ("123", 3),
1955            ("1234", 3),
1956            ("12345", 4),
1957            ("123456", 4),
1958            ("1234567", 5),
1959            ("12345678", 5),
1960            ("123456789", 6),
1961            ("1234567891", 6),
1962            ("12345678912", 7),
1963            ("123456789123", 7),
1964            ("1234567890123456789", 11),
1965            ("12345678901234567890123456789012345678", 20),
1966            // zeros the wire carries but DynamoDB trims
1967            ("0042", 2),
1968            ("100", 2),
1969            ("1010", 3),
1970            ("0.0000001", 2),
1971            ("0", 1),
1972            // scientific notation, which expands on storage without growing
1973            ("1E125", 2),
1974            ("1E-100", 2),
1975            // significant digits straddling the decimal point are counted in two
1976            // halves, so these cost a byte more than the digit total alone implies
1977            ("1.5", 3),
1978            ("1.2", 3),
1979            ("1.200", 3),
1980            ("1.234", 4),
1981            ("3.14159", 5),
1982            ("100.5", 4),
1983            ("0.15", 2),
1984            ("15", 2),
1985            // a sign costs a byte
1986            ("-42", 3),
1987            ("-12345", 5),
1988            ("-1E125", 3),
1989            ("-0.15", 3),
1990            ("-0", 1),
1991        ];
1992        for (literal, expected) in cases {
1993            assert_eq!(
1994                AttributeValue::N((*literal).to_string()).size(),
1995                *expected,
1996                "size of N {literal:?}"
1997            );
1998        }
1999    }
2000
2001    #[test]
2002    fn test_size_number_survives_normalisation() {
2003        // The reason the write paths could disagree: sizing has to be invariant
2004        // under the normalisation that happens on storage, or the same item
2005        // measures differently either side of it.
2006        for literal in [
2007            "1E125",
2008            "1E-100",
2009            "0042",
2010            "1.200",
2011            "0.0000001",
2012            "100",
2013            "3.14159",
2014            "42",
2015        ] {
2016            let raw = AttributeValue::N(literal.to_string()).size();
2017            let normalised = AttributeValue::N(normalize_dynamo_number(literal)).size();
2018            assert_eq!(
2019                raw, normalised,
2020                "N {literal:?} changed size when normalised"
2021            );
2022        }
2023    }
2024
2025    #[test]
2026    fn test_size_bool() {
2027        assert_eq!(AttributeValue::BOOL(true).size(), 1);
2028    }
2029
2030    #[test]
2031    fn test_size_null() {
2032        assert_eq!(AttributeValue::NULL(true).size(), 1);
2033    }
2034
2035    #[test]
2036    fn test_key_string_s() {
2037        let val = AttributeValue::S("hello".to_string());
2038        assert_eq!(val.to_key_string(), Some("S:hello".to_string()));
2039    }
2040
2041    #[test]
2042    fn test_key_string_n() {
2043        let val = AttributeValue::N("42".to_string());
2044        let key = val.to_key_string().unwrap();
2045        assert!(key.starts_with("N:"));
2046    }
2047
2048    #[test]
2049    fn test_key_string_b() {
2050        let val = AttributeValue::B(vec![0xff, 0x00, 0xab]);
2051        assert_eq!(val.to_key_string(), Some("B:ff00ab".to_string()));
2052    }
2053
2054    #[test]
2055    fn test_key_string_non_key_type_returns_none() {
2056        assert_eq!(AttributeValue::BOOL(true).to_key_string(), None);
2057        assert_eq!(AttributeValue::L(vec![]).to_key_string(), None);
2058    }
2059
2060    // Number sort key ordering tests
2061    #[test]
2062    fn test_number_sort_ordering() {
2063        let numbers = vec![
2064            "-1000", "-100", "-10", "-1", "-0.5", "-0.001", "0", "0.001", "0.5", "1", "10", "100",
2065            "1000",
2066        ];
2067        let encoded: Vec<String> = numbers
2068            .iter()
2069            .map(|n| normalize_number_for_sort(n))
2070            .collect();
2071
2072        for i in 0..encoded.len() - 1 {
2073            assert!(
2074                encoded[i] < encoded[i + 1],
2075                "Sort order broken: {} ({}) should be < {} ({})",
2076                numbers[i],
2077                encoded[i],
2078                numbers[i + 1],
2079                encoded[i + 1]
2080            );
2081        }
2082    }
2083
2084    #[test]
2085    fn test_number_sort_zero_variants() {
2086        let z1 = normalize_number_for_sort("0");
2087        let z2 = normalize_number_for_sort("-0");
2088        let z3 = normalize_number_for_sort("0.0");
2089        assert_eq!(z1, z2);
2090        assert_eq!(z2, z3);
2091    }
2092
2093    #[test]
2094    fn test_number_sort_decimals() {
2095        let a = normalize_number_for_sort("1.5");
2096        let b = normalize_number_for_sort("2.5");
2097        assert!(a < b);
2098
2099        let c = normalize_number_for_sort("0.001");
2100        let d = normalize_number_for_sort("0.01");
2101        assert!(c < d);
2102    }
2103
2104    #[test]
2105    fn test_number_sort_scientific() {
2106        let a = normalize_number_for_sort("1e10");
2107        let b = normalize_number_for_sort("1e11");
2108        assert!(a < b);
2109
2110        let c = normalize_number_for_sort("-1e11");
2111        let d = normalize_number_for_sort("-1e10");
2112        assert!(c < d);
2113    }
2114
2115    // Number validation/normalisation is pinned to real DynamoDB behaviour
2116    // (captured against AWS for issue #109). validate_dynamo_number accepts
2117    // exactly the grammar DynamoDB accepts, including a leading '+'; the bare
2118    // and '+'-prefixed malformed forms are both rejected, matching AWS.
2119    #[test]
2120    fn test_validate_number_accepts_dynamodb_grammar() {
2121        for input in [
2122            "+5", "+1.5", "+0", "-0", "+0.0", "+1e2", "1e+2", "1.5E+3", "-7", "+.5", ".5", "5.",
2123            "00042", "1.23E10", "+1e-2", "1E-130", "+1E-130",
2124        ] {
2125            assert!(
2126                validate_dynamo_number(input).is_ok(),
2127                "expected {input} to validate, got {:?}",
2128                validate_dynamo_number(input)
2129            );
2130        }
2131    }
2132
2133    #[test]
2134    fn test_validate_number_rejects_malformed() {
2135        // Every one of these is rejected by real DynamoDB. Note that whitespace
2136        // (leading, trailing, or internal) is rejected, not trimmed.
2137        for input in [
2138            "+e2", "e2", "+1+2", "1+2", "+1.2.3", "1.2.3", "++5", "+-5", "-+5", "+", "-", "1e",
2139            "1e+", ".", "1.2e3.4", "0x5", "NaN", "Infinity", "1_000", " 5", "5 ", "1 5", "",
2140        ] {
2141            assert!(
2142                matches!(
2143                    validate_dynamo_number(input),
2144                    Err(crate::errors::DynoxideError::ValidationException(_))
2145                ),
2146                "expected {input:?} to be rejected with ValidationException, got {:?}",
2147                validate_dynamo_number(input)
2148            );
2149        }
2150    }
2151
2152    #[test]
2153    fn test_normalize_number_matches_dynamodb() {
2154        for (input, stored) in [
2155            ("+5", "5"),
2156            ("+1.5", "1.5"),
2157            ("+0", "0"),
2158            ("-0", "0"),
2159            ("+0.0", "0"),
2160            ("+1e2", "100"),
2161            ("1e+2", "100"),
2162            ("1.5E+3", "1500"),
2163            ("-7", "-7"),
2164            ("+.5", "0.5"),
2165            (".5", "0.5"),
2166            ("5.", "5"),
2167            ("00042", "42"),
2168            ("1.23E10", "12300000000"),
2169            ("+1e-2", "0.01"),
2170        ] {
2171            assert_eq!(
2172                normalize_dynamo_number(input),
2173                stored,
2174                "{input} should normalise to {stored}"
2175            );
2176        }
2177    }
2178
2179    #[test]
2180    fn test_type_name() {
2181        assert_eq!(AttributeValue::S("".to_string()).type_name(), "S");
2182        assert_eq!(AttributeValue::N("0".to_string()).type_name(), "N");
2183        assert_eq!(AttributeValue::B(vec![]).type_name(), "B");
2184        assert_eq!(AttributeValue::BOOL(true).type_name(), "BOOL");
2185        assert_eq!(AttributeValue::NULL(true).type_name(), "NULL");
2186        assert_eq!(AttributeValue::SS(vec![]).type_name(), "SS");
2187        assert_eq!(AttributeValue::NS(vec![]).type_name(), "NS");
2188        assert_eq!(AttributeValue::BS(vec![]).type_name(), "BS");
2189        assert_eq!(AttributeValue::L(vec![]).type_name(), "L");
2190        assert_eq!(AttributeValue::M(HashMap::new()).type_name(), "M");
2191    }
2192}