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(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
38/// The derived form, except that a map's entries are written in key order.
39/// A map is a `HashMap`, so its own order is drawn afresh for every map, and
40/// two equal maps decoded by two requests would otherwise print differently.
41/// A PartiQL NextToken is bound to its statement's printed form, so it relies
42/// on equal values printing alike.
43impl fmt::Debug for AttributeValue {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        match self {
46            AttributeValue::S(v) => f.debug_tuple("S").field(v).finish(),
47            AttributeValue::N(v) => f.debug_tuple("N").field(v).finish(),
48            AttributeValue::B(v) => f.debug_tuple("B").field(v).finish(),
49            AttributeValue::BOOL(v) => f.debug_tuple("BOOL").field(v).finish(),
50            AttributeValue::NULL(v) => f.debug_tuple("NULL").field(v).finish(),
51            AttributeValue::SS(v) => f.debug_tuple("SS").field(v).finish(),
52            AttributeValue::NS(v) => f.debug_tuple("NS").field(v).finish(),
53            AttributeValue::BS(v) => f.debug_tuple("BS").field(v).finish(),
54            AttributeValue::L(v) => f.debug_tuple("L").field(v).finish(),
55            AttributeValue::M(map) => {
56                struct Sorted<'a>(&'a HashMap<String, AttributeValue>);
57                impl fmt::Debug for Sorted<'_> {
58                    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59                        let mut entries: Vec<_> = self.0.iter().collect();
60                        entries.sort_unstable_by(|a, b| a.0.cmp(b.0));
61                        f.debug_map().entries(entries).finish()
62                    }
63                }
64                f.debug_tuple("M").field(&Sorted(map)).finish()
65            }
66        }
67    }
68}
69
70impl AttributeValue {
71    /// Calculate the size of this attribute value in bytes,
72    /// following DynamoDB's item size calculation rules.
73    ///
74    /// This does NOT include the attribute name - the caller
75    /// is responsible for adding the name's UTF-8 byte length.
76    pub fn size(&self) -> usize {
77        match self {
78            AttributeValue::S(s) => s.len(),
79            AttributeValue::N(n) => number_size(n),
80            AttributeValue::B(b) => b.len(),
81            AttributeValue::BOOL(_) => 1,
82            AttributeValue::NULL(_) => 1,
83            AttributeValue::SS(ss) => ss.iter().map(|s| s.len()).sum(),
84            AttributeValue::NS(ns) => ns.iter().map(|n| number_size(n)).sum(),
85            AttributeValue::BS(bs) => bs.iter().map(|b| b.len()).sum(),
86            AttributeValue::L(items) => {
87                // List overhead: 3 bytes + 1 byte per element + sum of element sizes
88                3 + items.len() + items.iter().map(|v| v.size()).sum::<usize>()
89            }
90            AttributeValue::M(map) => {
91                // Map overhead: 3 bytes + sum of (key_len + 1 + value_size) per entry
92                3 + map
93                    .iter()
94                    .map(|(k, v)| k.len() + 1 + v.size())
95                    .sum::<usize>()
96            }
97        }
98    }
99
100    /// Returns the DynamoDB type descriptor string for this value.
101    pub fn type_name(&self) -> &'static str {
102        match self {
103            AttributeValue::S(_) => "S",
104            AttributeValue::N(_) => "N",
105            AttributeValue::B(_) => "B",
106            AttributeValue::BOOL(_) => "BOOL",
107            AttributeValue::NULL(_) => "NULL",
108            AttributeValue::SS(_) => "SS",
109            AttributeValue::NS(_) => "NS",
110            AttributeValue::BS(_) => "BS",
111            AttributeValue::L(_) => "L",
112            AttributeValue::M(_) => "M",
113        }
114    }
115
116    /// Returns true if this is a scalar type (S, N, B, BOOL, NULL).
117    pub fn is_scalar(&self) -> bool {
118        matches!(
119            self,
120            AttributeValue::S(_)
121                | AttributeValue::N(_)
122                | AttributeValue::B(_)
123                | AttributeValue::BOOL(_)
124                | AttributeValue::NULL(_)
125        )
126    }
127
128    /// Returns true if this is a set type (SS, NS, BS).
129    pub fn is_set(&self) -> bool {
130        matches!(
131            self,
132            AttributeValue::SS(_) | AttributeValue::NS(_) | AttributeValue::BS(_)
133        )
134    }
135
136    /// Serialize this value to a deterministic TEXT representation
137    /// for use as a SQLite primary key column (pk or sk).
138    ///
139    /// - S: stored as-is (UTF-8 text sorts correctly)
140    /// - N: normalized to a comparable string encoding
141    /// - B: hex-encoded (preserves byte ordering)
142    pub fn to_key_string(&self) -> Option<String> {
143        match self {
144            AttributeValue::S(s) => Some(format!("S:{s}")),
145            AttributeValue::N(n) => Some(format!("N:{}", normalize_number_for_sort(n))),
146            AttributeValue::B(b) => Some(format!("B:{}", hex_encode(b))),
147            _ => None, // Only S, N, B can be key types
148        }
149    }
150}
151
152impl fmt::Display for AttributeValue {
153    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154        match self {
155            AttributeValue::S(s) => write!(f, "\"{s}\""),
156            AttributeValue::N(n) => write!(f, "{n}"),
157            AttributeValue::B(b) => write!(f, "<binary {} bytes>", b.len()),
158            AttributeValue::BOOL(b) => write!(f, "{b}"),
159            AttributeValue::NULL(_) => write!(f, "null"),
160            AttributeValue::SS(ss) => write!(f, "{ss:?}"),
161            AttributeValue::NS(ns) => write!(f, "{ns:?}"),
162            AttributeValue::BS(bs) => write!(f, "<binary set {} items>", bs.len()),
163            AttributeValue::L(items) => write!(f, "<list {} items>", items.len()),
164            AttributeValue::M(map) => write!(f, "<map {} keys>", map.len()),
165        }
166    }
167}
168
169// ---------------------------------------------------------------------------
170// Custom serde: DynamoDB JSON format {"S": "hello"}, {"N": "42"}, etc.
171// ---------------------------------------------------------------------------
172
173impl Serialize for AttributeValue {
174    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
175    where
176        S: Serializer,
177    {
178        let mut map = serializer.serialize_map(Some(1))?;
179        match self {
180            AttributeValue::S(s) => map.serialize_entry("S", s)?,
181            AttributeValue::N(n) => map.serialize_entry("N", n)?,
182            AttributeValue::B(b) => {
183                map.serialize_entry("B", &BASE64.encode(b))?;
184            }
185            AttributeValue::BOOL(b) => map.serialize_entry("BOOL", b)?,
186            AttributeValue::NULL(n) => map.serialize_entry("NULL", n)?,
187            // DynamoDB returns set members sorted: strings and binaries by their
188            // bytes, numbers by value. Sorting here as well as on write keeps a
189            // row stored before sets were sorted in the same order on the way out.
190            AttributeValue::SS(ss) => {
191                map.serialize_entry("SS", &crate::set_order::sorted_strings(ss))?
192            }
193            AttributeValue::NS(ns) => {
194                map.serialize_entry("NS", &crate::set_order::sorted_numbers(ns))?
195            }
196            AttributeValue::BS(bs) => {
197                let encoded: Vec<String> = crate::set_order::sorted_binaries(bs)
198                    .into_iter()
199                    .map(|b| BASE64.encode(b))
200                    .collect();
201                map.serialize_entry("BS", &encoded)?;
202            }
203            AttributeValue::L(items) => map.serialize_entry("L", items)?,
204            AttributeValue::M(m) => map.serialize_entry("M", m)?,
205        }
206        map.end()
207    }
208}
209
210impl<'de> Deserialize<'de> for AttributeValue {
211    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
212    where
213        D: Deserializer<'de>,
214    {
215        // Deserialize as raw JSON Value first so we can inspect all keys
216        let raw = serde_json::Value::deserialize(deserializer)?;
217
218        let obj = raw
219            .as_object()
220            .ok_or_else(|| de::Error::custom("empty AttributeValue object"))?;
221
222        if obj.is_empty() {
223            return Err(de::Error::custom("empty AttributeValue object"));
224        }
225
226        // Collect known type keys. A member set to JSON null counts as absent:
227        // `{"S": null}` is an empty value and `{"S": "a", "N": null}` a string
228        // (captured eu-west-2 and us-east-1, 2026-10-10).
229        let present: Vec<&str> = obj
230            .iter()
231            .filter(|(k, v)| TYPE_MEMBERS.contains(&k.as_str()) && !v.is_null())
232            .map(|(k, _)| k.as_str())
233            .collect();
234
235        if present.is_empty() {
236            return Err(de::Error::custom(
237                "Supplied AttributeValue is empty, must contain exactly one of the supported datatypes",
238            ));
239        }
240
241        // BOOL and NULL members are read first, as booleans: a malformed one
242        // fails the request ahead of anything else about the value, a second
243        // member included (captured eu-west-2 and us-east-1, 2026-10-10).
244        let boolean = match obj.get("BOOL").filter(|v| !v.is_null()) {
245            Some(v) => Some(read_lenient_bool(v).map_err(de::Error::custom)?),
246            None => None,
247        };
248        let null = match obj.get("NULL").filter(|v| !v.is_null()) {
249            Some(v) => Some(read_lenient_bool(v).map_err(de::Error::custom)?),
250            None => None,
251        };
252
253        // A value with more than one member is read by DynamoDB's older
254        // validation layer, and so is every other value in its request (see
255        // `reads_on_older_layer`): its refusals are bare. An empty set comes
256        // first, then a number it can't read, then a NULL that reads as false,
257        // and only then the second member itself (captured eu-west-2 and
258        // us-east-1, 2026-10-10).
259        let layer = if present.len() > 1 {
260            crate::validation::ValueLayer::Older
261        } else {
262            current_value_layer()
263        };
264        if present.len() > 1
265            && let Some(refusal) = present
266                .iter()
267                .find_map(|&type_key| empty_set_refusal(type_key, &obj[type_key]))
268        {
269            return Err(de::Error::custom(format!(
270                "{}{refusal}",
271                crate::serde_errors::VALIDATION_MARKER
272            )));
273        }
274
275        // Validate numbers in ALL type keys before checking for multi-type.
276        // DynamoDB validates number format before rejecting multi-type.
277        for &type_key in &present {
278            match type_key {
279                "N" => {
280                    if let Some(n) = obj.get("N").and_then(|v| v.as_str()) {
281                        validate_number_in_deser(n, layer).map_err(de::Error::custom)?;
282                    }
283                }
284                "NS" => {
285                    if let Some(arr) = obj.get("NS").and_then(|v| v.as_array()) {
286                        for item in arr {
287                            if let Some(n) = item.as_str() {
288                                validate_number_in_deser(n, layer).map_err(de::Error::custom)?;
289                            }
290                        }
291                    }
292                }
293                _ => {}
294            }
295        }
296
297        // A NULL must read as true. JSON false on its own is refused by the
298        // request's validation, in the envelope PutItem, UpdateItem and
299        // DeleteItem give it, so it carries the request-validation marker. A
300        // string that reads as false, or a false NULL beside another member or
301        // in a request read on the older layer, is refused as the value is
302        // parsed: bare, and ahead of the check for more than one member
303        // (captured eu-west-2 and us-east-1, 2026-10-10).
304        if let Some(LenientBool {
305            value: false,
306            from_string,
307        }) = null
308        {
309            let marker = if from_string || layer == crate::validation::ValueLayer::Older {
310                crate::serde_errors::VALIDATION_MARKER
311            } else {
312                crate::serde_errors::REQUEST_VALIDATION_MARKER
313            };
314            return Err(de::Error::custom(format!(
315                "{marker}{}",
316                crate::serde_errors::NULL_NOT_TRUE
317            )));
318        }
319
320        // Check for multiple type keys
321        if present.len() > 1 {
322            return Err(de::Error::custom(
323                "VALIDATION:Supplied AttributeValue has more than one datatypes set, \
324                 must contain exactly one of the supported datatypes",
325            ));
326        }
327
328        let type_key = present[0];
329        let val = &obj[type_key];
330
331        match type_key {
332            "S" => {
333                let s = val
334                    .as_str()
335                    .ok_or_else(|| de::Error::custom("expected string for S"))?;
336                Ok(AttributeValue::S(s.to_string()))
337            }
338            "N" => {
339                let n = val
340                    .as_str()
341                    .ok_or_else(|| de::Error::custom("expected string for N"))?;
342                Ok(AttributeValue::N(n.to_string()))
343            }
344            "B" => {
345                let encoded = val
346                    .as_str()
347                    .ok_or_else(|| de::Error::custom("expected string for B"))?;
348                let bytes = BASE64
349                    .decode(encoded)
350                    .map_err(|e| de::Error::custom(format!("invalid base64: {e}")))?;
351                Ok(AttributeValue::B(bytes))
352            }
353            // Both were read above; a NULL that reached here reads as true.
354            "BOOL" => Ok(AttributeValue::BOOL(boolean.is_some_and(|b| b.value))),
355            "NULL" => Ok(AttributeValue::NULL(true)),
356            "SS" => {
357                let arr = val
358                    .as_array()
359                    .ok_or_else(|| de::Error::custom("expected array for SS"))?;
360                let ss: Result<Vec<String>, _> = arr
361                    .iter()
362                    .map(|v| {
363                        v.as_str()
364                            .map(|s| s.to_string())
365                            .ok_or_else(|| de::Error::custom("expected string in SS"))
366                    })
367                    .collect();
368                Ok(AttributeValue::SS(ss?))
369            }
370            "NS" => {
371                let arr = val
372                    .as_array()
373                    .ok_or_else(|| de::Error::custom("expected array for NS"))?;
374                let ns: Result<Vec<String>, _> = arr
375                    .iter()
376                    .map(|v| {
377                        v.as_str()
378                            .map(|s| s.to_string())
379                            .ok_or_else(|| de::Error::custom("expected string in NS"))
380                    })
381                    .collect();
382                Ok(AttributeValue::NS(ns?))
383            }
384            "BS" => {
385                let arr = val
386                    .as_array()
387                    .ok_or_else(|| de::Error::custom("expected array for BS"))?;
388                let mut decoded = Vec::with_capacity(arr.len());
389                for item in arr {
390                    let encoded = item
391                        .as_str()
392                        .ok_or_else(|| de::Error::custom("expected string in BS"))?;
393                    decoded.push(
394                        BASE64
395                            .decode(encoded)
396                            .map_err(|e| de::Error::custom(format!("invalid base64: {e}")))?,
397                    );
398                }
399                Ok(AttributeValue::BS(decoded))
400            }
401            "L" => {
402                let arr = val
403                    .as_array()
404                    .ok_or_else(|| de::Error::custom("expected array for L"))?;
405                let list: Result<Vec<AttributeValue>, _> = arr
406                    .iter()
407                    .map(|v| serde_json::from_value(v.clone()).map_err(de::Error::custom))
408                    .collect();
409                Ok(AttributeValue::L(list?))
410            }
411            "M" => {
412                let map_val = val
413                    .as_object()
414                    .ok_or_else(|| de::Error::custom("expected object for M"))?;
415                let mut result = std::collections::HashMap::new();
416                for (k, v) in map_val {
417                    let av: AttributeValue =
418                        serde_json::from_value(v.clone()).map_err(de::Error::custom)?;
419                    result.insert(k.clone(), av);
420                }
421                Ok(AttributeValue::M(result))
422            }
423            _ => unreachable!(),
424        }
425    }
426}
427
428/// A BOOL or NULL member's value read as a boolean, and whether it was
429/// written as a string.
430#[derive(Clone, Copy)]
431struct LenientBool {
432    value: bool,
433    from_string: bool,
434}
435
436/// Read a BOOL or NULL member's value the way DynamoDB does: JSON `true` and
437/// `false`, or one of the strings it takes for them in any case (`true`, `t`,
438/// `yes`, `y` and `1`; `false`, `f`, `no`, `n` and `0`). Anything else is a
439/// malformed request, in DynamoDB's words for what was found: another string,
440/// a number, a list (no message at all) or a map (captured eu-west-2 and
441/// us-east-1, 2026-10-10). The error carries the serialisation marker.
442fn read_lenient_bool(value: &serde_json::Value) -> Result<LenientBool, String> {
443    let refused = |message: &str| format!("{}{message}", crate::serde_errors::SERIALIZATION_MARKER);
444    match value {
445        serde_json::Value::Bool(value) => Ok(LenientBool {
446            value: *value,
447            from_string: false,
448        }),
449        serde_json::Value::String(text) => match lenient_bool_text(text) {
450            Some(value) => Ok(LenientBool {
451                value,
452                from_string: true,
453            }),
454            None => Err(refused("Unexpected token received from parser")),
455        },
456        // A number is named by how it was written: a whole number of any size
457        // is NUMBER_VALUE and one with a point or an exponent DECIMAL_VALUE
458        // (captured eu-west-2 and us-east-1, 2026-10-10). serde_json reads
459        // `-0` and integers past 64 bits as floats, so the wire seams rewrite
460        // those first (`serde_errors::boolean_numbers_as_written`) and a float
461        // here was written as one.
462        serde_json::Value::Number(n) if n.is_f64() => {
463            Err(refused("DECIMAL_VALUE cannot be converted to Boolean"))
464        }
465        serde_json::Value::Number(_) => Err(refused("NUMBER_VALUE cannot be converted to Boolean")),
466        serde_json::Value::Array(_) => Err(refused("")),
467        serde_json::Value::Object(_) => Err(refused(
468            "Start of structure or map found where not expected",
469        )),
470        // A null member counts as absent and is never read.
471        serde_json::Value::Null => Err(refused("Unexpected token received from parser")),
472    }
473}
474
475/// Deserialise a request's own boolean member (`ConsistentRead`,
476/// `ScanIndexForward`, a legacy condition's `Exists`) the way DynamoDB reads
477/// it: by the rule a `BOOL` member follows, so the strings that read as a
478/// boolean there read as one here too, and JSON `null` leaves the member
479/// unset (captured eu-west-2 and us-east-1, 2026-10-10). A list is refused as
480/// the member's own type, as the request's other members are.
481pub(crate) fn deserialize_lenient_flag<'de, D: serde::Deserializer<'de>>(
482    deserializer: D,
483) -> std::result::Result<Option<bool>, D::Error> {
484    match serde_json::Value::deserialize(deserializer)? {
485        serde_json::Value::Null => Ok(None),
486        serde_json::Value::Array(_) => Err(de::Error::custom(format!(
487            "{}Unrecognized collection type class java.lang.Boolean",
488            crate::serde_errors::SERIALIZATION_MARKER
489        ))),
490        value => read_lenient_bool(&value)
491            .map(|flag| Some(flag.value))
492            .map_err(de::Error::custom),
493    }
494}
495
496/// The malformed-request error, marked for a `SerializationException`, for the
497/// first `BOOL` or `NULL` member, or `Exists`, in a legacy condition map
498/// (`QueryFilter`, `ScanFilter`, `KeyConditions`, `Expected`) that doesn't read
499/// as a boolean; `None` when every one reads. DynamoDB fails the whole request
500/// as it is read (captured eu-west-2 and us-east-1, 2026-10-10), and the map's
501/// values are read later than the request's own members, so the request
502/// decoders look here first.
503pub(crate) fn malformed_condition_boolean(
504    conditions: Option<&serde_json::Value>,
505) -> Option<String> {
506    let malformed = |message: String| {
507        message
508            .starts_with(crate::serde_errors::SERIALIZATION_MARKER)
509            .then_some(message)
510    };
511    for condition in conditions?.as_object()?.values() {
512        let Some(condition) = condition.as_object() else {
513            continue;
514        };
515        if let Some(exists) = condition.get("Exists")
516            && let Err(e) = deserialize_lenient_flag(exists.clone())
517            && let Some(message) = malformed(e.to_string())
518        {
519            return Some(message);
520        }
521        let values = condition.get("Value").into_iter().chain(
522            condition
523                .get("AttributeValueList")
524                .and_then(serde_json::Value::as_array)
525                .into_iter()
526                .flatten(),
527        );
528        for value in values {
529            if let Err(e) = serde_json::from_value::<AttributeValue>(value.clone())
530                && let Some(message) = malformed(e.to_string())
531            {
532                return Some(message);
533            }
534        }
535    }
536    None
537}
538
539/// Whether a request's own boolean member written as a string reads as a
540/// boolean, by the rule [`deserialize_lenient_flag`] follows.
541#[cfg(any(feature = "http-server", feature = "wasm-sqlite", test))]
542pub(crate) fn reads_as_flag(text: &str) -> bool {
543    lenient_bool_text(text).is_some()
544}
545
546/// The boolean a string reads as by DynamoDB's rule, in any case: `true`, `t`,
547/// `yes`, `y` and `1`, or `false`, `f`, `no`, `n` and `0`. Nothing is trimmed.
548fn lenient_bool_text(text: &str) -> Option<bool> {
549    match text.to_ascii_lowercase().as_str() {
550        "true" | "t" | "yes" | "y" | "1" => Some(true),
551        "false" | "f" | "no" | "n" | "0" => Some(false),
552        _ => None,
553    }
554}
555
556/// Validate a number string during AttributeValue deserialization.
557///
558/// Returns DynamoDB-matching error messages for invalid numbers.
559/// Error messages are returned WITHOUT the VALIDATION: prefix since they
560/// bypass the normal validation flow; the server routes them based on
561/// message content (see `crate::serde_errors::deserialize`).
562fn validate_number_in_deser(n: &str, layer: crate::validation::ValueLayer) -> Result<(), String> {
563    // The layer the value is read under decides the rule: the current
564    // layer's numbers are refused inside the envelope on PutItem, UpdateItem
565    // and DeleteItem, so they carry the request-validation marker, and the
566    // older layer's stay bare.
567    let marker = match layer {
568        crate::validation::ValueLayer::Current => crate::serde_errors::REQUEST_VALIDATION_MARKER,
569        crate::validation::ValueLayer::Older => crate::serde_errors::VALIDATION_MARKER,
570    };
571    match layer.check_number(n) {
572        Ok(()) => Ok(()),
573        Err(crate::errors::DynoxideError::ValidationException(m)) => Err(format!("{marker}{m}")),
574        Err(e) => Err(format!("{marker}{e}")),
575    }
576}
577
578/// Validate a number by the current validation layer's rule: the stored-number
579/// rule, with an empty string refused as `... numeric value: `, colon and
580/// space (captured eu-west-2, us-east-1, eu-west-1 and us-west-2, 2026-10-09).
581pub(crate) fn validate_current_number(
582    num_str: &str,
583) -> std::result::Result<(), crate::errors::DynoxideError> {
584    if num_str.is_empty() {
585        return Err(crate::errors::DynoxideError::ValidationException(
586            "The parameter cannot be converted to a numeric value: ".to_string(),
587        ));
588    }
589    validate_dynamo_number(num_str)
590}
591
592// ---------------------------------------------------------------------------
593// Number sort key normalization
594// ---------------------------------------------------------------------------
595
596/// Normalize a DynamoDB number string into a comparable string that sorts
597/// correctly in SQLite TEXT collation.
598///
599/// Encoding scheme:
600/// - Positive numbers: "1" + zero-padded exponent (4 digits, offset by 5000) + normalized mantissa
601/// - Zero: "1" + "5000" + "0" (padded)
602/// - Negative numbers: "0" + complement of (exponent + mantissa) so they sort before positives
603///
604/// DynamoDB numbers: up to 38 digits of precision, range ~-1E+126 to ~+1E+126.
605pub fn normalize_number_for_sort(num_str: &str) -> String {
606    let trimmed = num_str.trim();
607
608    if trimmed.is_empty() || trimmed == "0" || trimmed == "-0" || trimmed == "0.0" {
609        return zero_encoding();
610    }
611
612    let negative = trimmed.starts_with('-');
613    let abs_str = if negative { &trimmed[1..] } else { trimmed };
614
615    // Parse into mantissa digits and exponent
616    let (mantissa_digits, exponent) = parse_number_parts(abs_str);
617
618    if mantissa_digits.is_empty() || mantissa_digits.iter().all(|&d| d == 0) {
619        return zero_encoding();
620    }
621
622    if negative {
623        encode_negative(&mantissa_digits, exponent)
624    } else {
625        encode_positive(&mantissa_digits, exponent)
626    }
627}
628
629/// DynamoDB's refusal of `type_key`'s member when it is an empty set.
630fn empty_set_refusal(type_key: &str, member: &serde_json::Value) -> Option<&'static str> {
631    if !member.as_array().is_some_and(Vec::is_empty) {
632        return None;
633    }
634    match type_key {
635        "SS" => Some("One or more parameter values were invalid: An string set  may not be empty"),
636        "NS" => Some("One or more parameter values were invalid: An number set  may not be empty"),
637        "BS" => Some("One or more parameter values were invalid: Binary sets should not be empty"),
638        _ => None,
639    }
640}
641
642/// The type members an attribute value can carry.
643const TYPE_MEMBERS: [&str; 10] = ["S", "N", "B", "BOOL", "NULL", "SS", "NS", "BS", "L", "M"];
644
645/// Whether a request is read by DynamoDB's older validation layer whatever
646/// operation it is: it holds a `BOOL` or `NULL` member, or a legacy
647/// condition's `Exists`, written as a string, or an attribute value with more
648/// than one member. Every value refusal in such a request takes the older
649/// layer's form: bare on PutItem, UpdateItem and DeleteItem, where the current
650/// layer gives the envelope, and naming its key in UpdateItem's
651/// `ExpressionAttributeValues` (captured eu-west-2 and us-east-1, 2026-10-10).
652///
653/// A map whose members are all maps is an item or an attribute map, not a
654/// value, so an attribute named `S` beside one named `N` doesn't count.
655pub(crate) fn reads_on_older_layer(value: &serde_json::Value) -> bool {
656    match value {
657        serde_json::Value::Object(map) => {
658            let string_flag = ["BOOL", "NULL", "Exists"]
659                .iter()
660                .any(|member| map.get(*member).is_some_and(serde_json::Value::is_string));
661            let members: Vec<&serde_json::Value> = TYPE_MEMBERS
662                .iter()
663                .filter_map(|member| map.get(*member))
664                .filter(|member| !member.is_null())
665                .collect();
666            string_flag
667                || (members.len() > 1 && members.iter().any(|member| !member.is_object()))
668                || map.values().any(reads_on_older_layer)
669        }
670        serde_json::Value::Array(items) => items.iter().any(reads_on_older_layer),
671        _ => false,
672    }
673}
674
675/// The validation layer values are being read under on this thread: the one
676/// a wire seam set for the request in hand, or the current layer outside one.
677pub(crate) fn current_value_layer() -> crate::validation::ValueLayer {
678    VALUE_LAYER.with(std::cell::Cell::get)
679}
680
681thread_local! {
682    /// The validation layer the values being decoded are read under. Set
683    /// around a request's deserialisation, which runs synchronously; outside
684    /// one it is the current layer.
685    static VALUE_LAYER: std::cell::Cell<crate::validation::ValueLayer> =
686        const { std::cell::Cell::new(crate::validation::ValueLayer::Current) };
687}
688
689/// Run `read` with values decoded under `layer`, restoring the layer in force
690/// before, however `read` ends. `read` must not await: the layer belongs to
691/// the thread.
692pub(crate) fn with_value_layer<T>(
693    layer: crate::validation::ValueLayer,
694    read: impl FnOnce() -> T,
695) -> T {
696    struct Restore(crate::validation::ValueLayer);
697    impl Drop for Restore {
698        fn drop(&mut self) {
699            VALUE_LAYER.with(|cell| cell.set(self.0));
700        }
701    }
702    let _restore = Restore(VALUE_LAYER.with(|cell| cell.replace(layer)));
703    read()
704}
705
706/// Run `future` with values read under `layer`, set around each of its polls
707/// and restored after, so the layer holds across the future's awaits without
708/// reaching anything polled in between. The wasm engine's single thread runs
709/// other requests between those polls.
710#[cfg(any(feature = "wasm-sqlite", test))]
711pub(crate) async fn with_value_layer_async<F: std::future::Future>(
712    layer: crate::validation::ValueLayer,
713    future: F,
714) -> F::Output {
715    struct Layered<F> {
716        layer: crate::validation::ValueLayer,
717        future: std::pin::Pin<Box<F>>,
718    }
719    impl<F: std::future::Future> std::future::Future for Layered<F> {
720        type Output = F::Output;
721        fn poll(
722            mut self: std::pin::Pin<&mut Self>,
723            cx: &mut std::task::Context<'_>,
724        ) -> std::task::Poll<F::Output> {
725            let layer = self.layer;
726            with_value_layer(layer, || self.future.as_mut().poll(cx))
727        }
728    }
729    Layered {
730        layer,
731        future: Box::pin(future),
732    }
733    .await
734}
735
736/// Run `read` with numbers read as PartiQL parameters are: the older layer's
737/// rule, which [`validate_parameter_number`] holds.
738pub(crate) fn reading_parameters<T>(read: impl FnOnce() -> T) -> T {
739    with_value_layer(crate::validation::ValueLayer::Older, read)
740}
741
742/// Deserialise an operation's `ExpressionAttributeValues` on the older
743/// validation layer, which refuses a number it can't store inside the
744/// `ExpressionAttributeValues contains invalid value: ... for key :k` wrapper
745/// (TransactWriteItems in three of four regions, Scan in seven of ten;
746/// 2026-10-09). Every other value error passes through as it is.
747pub(crate) fn deserialize_older_expression_values<'de, D: serde::Deserializer<'de>>(
748    deserializer: D,
749) -> std::result::Result<Option<HashMap<String, AttributeValue>>, D::Error> {
750    let Some(raw) =
751        Option::<serde_json::Map<String, serde_json::Value>>::deserialize(deserializer)?
752    else {
753        return Ok(None);
754    };
755    let mut values = HashMap::with_capacity(raw.len());
756    for (key, value) in raw {
757        let read = with_value_layer(crate::validation::ValueLayer::Older, || {
758            serde_json::from_value::<AttributeValue>(value)
759        });
760        match read {
761            Ok(av) => {
762                values.insert(key, av);
763            }
764            Err(e) => {
765                use crate::serde_errors::{ValueRefusal, named_value_refusal, value_refusal};
766                let msg = e.to_string();
767                return Err(de::Error::custom(match value_refusal(&msg) {
768                    ValueRefusal::Number(inner) => format!(
769                        "{}{}",
770                        crate::serde_errors::VALIDATION_MARKER,
771                        named_value_refusal(inner, &key)
772                    ),
773                    _ => msg,
774                }));
775            }
776        }
777    }
778    Ok(Some(values))
779}
780
781/// Deserialise UpdateItem's `ExpressionAttributeValues`, naming the key of a
782/// NULL refused as it was parsed (a string that reads as false, or false
783/// beside another member) the way DynamoDB does: `ExpressionAttributeValues
784/// contains invalid value: ... for key :v` (captured eu-west-2 and us-east-1,
785/// 2026-10-10). Every other value error passes through as it is.
786pub(crate) fn deserialize_named_null_refusals<'de, D: serde::Deserializer<'de>>(
787    deserializer: D,
788) -> std::result::Result<Option<HashMap<String, AttributeValue>>, D::Error> {
789    deserialize_named_refusals(deserializer, false, true)
790}
791
792/// Deserialise Query's `ExpressionAttributeValues`, naming the key of an empty
793/// value as well as of a NULL refused as it was parsed (captured eu-west-2 and
794/// us-east-1, 2026-10-10: `{":v": {"S": null}}` and `{":v": {"NULL": "no"}}`).
795pub(crate) fn deserialize_named_empty_and_null_refusals<'de, D: serde::Deserializer<'de>>(
796    deserializer: D,
797) -> std::result::Result<Option<HashMap<String, AttributeValue>>, D::Error> {
798    deserialize_named_refusals(deserializer, true, false)
799}
800
801/// Read `ExpressionAttributeValues`, naming the key of a NULL refused as it was
802/// parsed, of an empty value when `name_empty`, and, when `name_older` and the
803/// request is read on the older layer, of every other value refusal.
804fn deserialize_named_refusals<'de, D: serde::Deserializer<'de>>(
805    deserializer: D,
806    name_empty: bool,
807    name_older: bool,
808) -> std::result::Result<Option<HashMap<String, AttributeValue>>, D::Error> {
809    let Some(raw) =
810        Option::<serde_json::Map<String, serde_json::Value>>::deserialize(deserializer)?
811    else {
812        return Ok(None);
813    };
814    let mut values = HashMap::with_capacity(raw.len());
815    for (key, value) in raw {
816        match serde_json::from_value::<AttributeValue>(value) {
817            Ok(av) => {
818                values.insert(key, av);
819            }
820            Err(e) => {
821                use crate::serde_errors::{
822                    EMPTY_VALUE, MORE_THAN_ONE_TYPE, VALIDATION_MARKER, ValueRefusal,
823                    named_value_refusal, value_refusal,
824                };
825                let msg = e.to_string();
826                // UpdateItem, read on the older layer, names the key of every
827                // value refusal: a number, a value with more than one member
828                // (which puts the request on that layer), an empty set beside
829                // another member (captured eu-west-2 and us-east-1,
830                // 2026-10-10).
831                let older =
832                    name_older && current_value_layer() == crate::validation::ValueLayer::Older;
833                return Err(de::Error::custom(match value_refusal(&msg) {
834                    ValueRefusal::ParsedNull(inner) => {
835                        format!("{VALIDATION_MARKER}{}", named_value_refusal(inner, &key))
836                    }
837                    ValueRefusal::Empty if name_empty => {
838                        format!(
839                            "{VALIDATION_MARKER}{}",
840                            named_value_refusal(EMPTY_VALUE, &key)
841                        )
842                    }
843                    ValueRefusal::MoreThanOneType if name_older => format!(
844                        "{VALIDATION_MARKER}{}",
845                        named_value_refusal(MORE_THAN_ONE_TYPE, &key)
846                    ),
847                    ValueRefusal::EmptySet(inner) if name_older => {
848                        format!("{VALIDATION_MARKER}{}", named_value_refusal(inner, &key))
849                    }
850                    ValueRefusal::Number(inner) if older => {
851                        format!("{VALIDATION_MARKER}{}", named_value_refusal(inner, &key))
852                    }
853                    _ => msg,
854                }));
855            }
856        }
857    }
858    Ok(Some(values))
859}
860
861/// Deserialise a statement's `Parameters`, reading their numbers as PartiQL
862/// parameters.
863pub(crate) fn deserialize_parameters<'de, D: serde::Deserializer<'de>>(
864    deserializer: D,
865) -> std::result::Result<Option<Vec<AttributeValue>>, D::Error> {
866    reading_parameters(|| Option::<Vec<AttributeValue>>::deserialize(deserializer))
867}
868
869/// Validate a PartiQL parameter's number. DynamoDB reads it as Java's
870/// `BigDecimal(String)` does, so an exponent outside an `int`, or a scale (the
871/// digits after the point less the exponent) outside one, is a value it cannot
872/// convert. Within those, a precision less scale at an `int`'s limit is an
873/// underflow, as for a PartiQL literal, and otherwise the stored-number rule
874/// applies: `1e2147483647` is an underflow here and an overflow in an item
875/// (captured eu-west-2 and us-east-1, 2026-10-09).
876pub(crate) fn validate_parameter_number(
877    num_str: &str,
878) -> std::result::Result<(), crate::errors::DynoxideError> {
879    if num_str.is_empty() || !is_well_formed_dynamo_number(num_str) {
880        return validate_dynamo_number(num_str);
881    }
882    let unconvertible = || {
883        crate::errors::DynoxideError::ValidationException(format!(
884            "The parameter cannot be converted to a numeric value: {num_str}"
885        ))
886    };
887    let unsigned = num_str.trim_start_matches(['+', '-']);
888    let (coefficient, exponent) = match unsigned.find(['e', 'E']) {
889        Some(at) => (
890            &unsigned[..at],
891            unsigned[at + 1..]
892                .parse::<i32>()
893                .map_err(|_| unconvertible())?,
894        ),
895        None => (unsigned, 0),
896    };
897    let fraction_digits = coefficient
898        .split_once('.')
899        .map_or(0, |(_, fraction)| fraction.len());
900    let scale = i64::try_from(fraction_digits).unwrap_or(i64::MAX) - i64::from(exponent);
901    if i32::try_from(scale).is_err() {
902        return Err(unconvertible());
903    }
904    let significant = coefficient
905        .chars()
906        .filter(char::is_ascii_digit)
907        .skip_while(|&d| d == '0')
908        .count()
909        .max(1);
910    if i64::try_from(significant).unwrap_or(i64::MAX) - scale >= i64::from(i32::MAX) {
911        return Err(crate::errors::DynoxideError::ValidationException(
912            "Number underflow. Attempting to store a number with magnitude smaller than \
913             supported range"
914                .to_string(),
915        ));
916    }
917    validate_dynamo_number(num_str)
918}
919
920/// Validate a DynamoDB number string against DynamoDB's constraints:
921/// - Up to 38 significant digits
922/// - Magnitude at most 9.9999999999999999999999999999999999999E+125
923/// - Positive values must be at least 1E-130
924/// - Negative values must be at most -1E-130
925/// - Zero is held to the same range, as if its last written digit were a 1
926pub fn validate_dynamo_number(
927    num_str: &str,
928) -> std::result::Result<(), crate::errors::DynoxideError> {
929    if num_str.is_empty() {
930        return Err(crate::errors::DynoxideError::ValidationException(
931            "The parameter cannot be converted to a numeric value".to_string(),
932        ));
933    }
934
935    // DynamoDB accepts a specific numeric grammar and rejects everything else,
936    // including any surrounding or internal whitespace. Verified against real
937    // DynamoDB (see the unit tests below and the dynamodb-conformance suite):
938    //   sign?  coefficient  exponent?
939    //   coefficient = at least one digit, at most one '.' (e.g. 5, 5., .5, +1.5)
940    //   exponent    = ('e'|'E') sign? at least one digit (e.g. e2, E+3, e-130)
941    // Accepts: +5, -7, +.5, 5., 1e+2, 1.5E+3, 1E-130, 00042
942    // Rejects: +e2, 1+2, 1.2.3, ++5, 1e, ., NaN, "1_000", " 5"
943    if !is_well_formed_dynamo_number(num_str) {
944        return Err(crate::errors::DynoxideError::ValidationException(format!(
945            "The parameter cannot be converted to a numeric value: {num_str}"
946        )));
947    }
948
949    // DynamoDB reads the exponent as a 64-bit integer, and one that does not
950    // fit is a value it cannot convert rather than an overflow. Inside that,
951    // an exponent of any size is range-checked below (captured eu-west-2,
952    // 2026-10-08).
953    if num_str
954        .find(['e', 'E'])
955        .is_some_and(|pos| num_str[pos + 1..].parse::<i64>().is_err())
956    {
957        return Err(crate::errors::DynoxideError::ValidationException(format!(
958            "The parameter cannot be converted to a numeric value: {num_str}"
959        )));
960    }
961
962    // parse_number_parts ignores the sign characters, so the magnitude checks
963    // below hold for both signs.
964    let (mantissa_digits, exponent) = parse_number_parts(num_str);
965
966    let exponent = if mantissa_digits.is_empty() || mantissa_digits.iter().all(|&d| d == 0) {
967        // Zero has no magnitude of its own, but DynamoDB still range-checks
968        // it, as if its last written digit were a 1: `0e126` overflows like
969        // `1e126`, and `0.0e-130` underflows like `0.1e-130` (captured
970        // eu-west-2, 2026-10-08).
971        let (coeff, written_exponent) = split_exponent(num_str);
972        let frac_digits = coeff.find('.').map_or(0, |dot| coeff.len() - dot - 1);
973        written_exponent
974            .saturating_sub(frac_digits as i64)
975            .saturating_add(1)
976    } else {
977        // Check significant digits (mantissa_digits has leading/trailing zeros already stripped)
978        if mantissa_digits.len() > 38 {
979            return Err(crate::errors::DynoxideError::ValidationException(
980                "Attempting to store more than 38 significant digits in a Number".to_string(),
981            ));
982        }
983        i64::from(exponent)
984    };
985
986    // Check magnitude: exponent represents the power such that value = 0.mantissa * 10^exponent
987    // Max magnitude: 9.999...E+125 means exponent = 126 (since 0.999... * 10^126 = 9.99...E+125)
988    if exponent > 126 {
989        return Err(crate::errors::DynoxideError::ValidationException(
990            "Number overflow. Attempting to store a number with magnitude larger than supported range"
991                .to_string(),
992        ));
993    }
994
995    // Check underflow
996    // Min positive: 1E-130 means exponent = -129 (since 0.1 * 10^-129 = 1E-130)
997    // But with more digits, exponent can be lower, e.g. 1.0E-130 has (mantissa=[1], exponent=-129)
998    // Actually, the smallest representable is 1E-130. In our representation, 1E-130 = 0.1 * 10^-129
999    // So exponent = -129 with mantissa [1].
1000    // For 1E-131 = 0.1 * 10^-130, exponent = -130 - that's too small.
1001    if exponent < -129 {
1002        return Err(crate::errors::DynoxideError::ValidationException(
1003            "Number underflow. Attempting to store a number with magnitude smaller than supported range"
1004                .to_string(),
1005        ));
1006    }
1007
1008    Ok(())
1009}
1010
1011/// Returns true when `s` matches DynamoDB's numeric grammar exactly:
1012/// `sign? coefficient exponent?` where the coefficient carries at least one
1013/// digit and at most one `.`, and the exponent (if present) carries at least
1014/// one digit. No whitespace or stray characters are tolerated. This mirrors
1015/// what real DynamoDB accepts (verified against AWS).
1016fn is_well_formed_dynamo_number(s: &str) -> bool {
1017    let bytes = s.as_bytes();
1018    let n = bytes.len();
1019    let mut i = 0;
1020
1021    // Optional leading sign.
1022    if i < n && (bytes[i] == b'+' || bytes[i] == b'-') {
1023        i += 1;
1024    }
1025
1026    // Coefficient: digits with at most one decimal point, at least one digit.
1027    let mut coeff_digits = 0usize;
1028    let mut dots = 0usize;
1029    while i < n && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
1030        if bytes[i] == b'.' {
1031            dots += 1;
1032            if dots > 1 {
1033                return false;
1034            }
1035        } else {
1036            coeff_digits += 1;
1037        }
1038        i += 1;
1039    }
1040    if coeff_digits == 0 {
1041        return false;
1042    }
1043
1044    // Optional exponent: 'e'/'E', optional sign, at least one digit.
1045    if i < n && (bytes[i] == b'e' || bytes[i] == b'E') {
1046        i += 1;
1047        if i < n && (bytes[i] == b'+' || bytes[i] == b'-') {
1048            i += 1;
1049        }
1050        let mut exp_digits = 0usize;
1051        while i < n && bytes[i].is_ascii_digit() {
1052            exp_digits += 1;
1053            i += 1;
1054        }
1055        if exp_digits == 0 {
1056            return false;
1057        }
1058    }
1059
1060    // Anything left over (stray chars, trailing whitespace) is invalid.
1061    i == n
1062}
1063
1064/// Byte cost of a number, as DynamoDB accounts for it in an item's size.
1065///
1066/// One byte, plus `ceil(integer significant digits / 2)` and
1067/// `ceil(fraction significant digits / 2)`, over the value with leading and
1068/// trailing zeros trimmed. Measured byte-exact against eu-west-2 by bisecting
1069/// the 400KB item-size gate.
1070///
1071/// Counting significant digits rather than characters is what makes the answer
1072/// survive storage: DynamoDB expands scientific notation when it stores a
1073/// number, so a measure taken over the digits of the string would give one
1074/// answer for the request and another for the stored row. Every write path
1075/// checks the limit somewhere either side of that expansion, and they can only
1076/// agree if the measure does not move.
1077pub fn number_size(num_str: &str) -> usize {
1078    let trimmed = num_str.trim();
1079    let negative = trimmed.starts_with('-');
1080    let abs = trimmed.strip_prefix(['-', '+']).unwrap_or(trimmed);
1081    let (mantissa, exponent) = parse_number_parts(abs);
1082
1083    // `exponent` is how many significant digits fall before the decimal point:
1084    // negative for a pure fraction, past the end of the mantissa for a value
1085    // whose trailing zeros were trimmed. Either way the split is a clamp.
1086    let int_digits = exponent.clamp(0, mantissa.len() as i32) as usize;
1087    let frac_digits = mantissa.len() - int_digits;
1088
1089    // A negative number costs a byte more than the same magnitude positive:
1090    // `-42` measures 3 where `42` measures 2. Zero is never negative, whatever
1091    // the literal said, so `-0` costs the same as `0`.
1092    let sign = usize::from(negative && !mantissa.is_empty());
1093
1094    int_digits.div_ceil(2) + frac_digits.div_ceil(2) + 1 + sign
1095}
1096
1097/// Normalize a DynamoDB number string to its canonical form.
1098///
1099/// DynamoDB normalises numbers when storing them:
1100/// - Leading zeros are stripped (`0042` → `42`)
1101/// - Trailing zeros after decimal are stripped (`1.200` → `1.2`)
1102/// - Scientific notation is expanded to full decimal form
1103/// - Zero is represented as `0`
1104pub fn normalize_dynamo_number(num_str: &str) -> String {
1105    let trimmed = num_str.trim();
1106    if trimmed.is_empty() {
1107        return "0".to_string();
1108    }
1109    // The expansion below walks the exponent one character at a time, so an
1110    // exponent DynamoDB would never accept turns a short string into a
1111    // multi-gigabyte one. `1e-2147483647` is nine characters in and about two
1112    // gigabytes out. Outside the range DynamoDB itself accepts there is no
1113    // canonical form worth producing, so hand the input back untouched.
1114    if validate_dynamo_number(trimmed).is_err() {
1115        return trimmed.to_string();
1116    }
1117
1118    let negative = trimmed.starts_with('-');
1119    let abs_str = if negative {
1120        &trimmed[1..]
1121    } else {
1122        trimmed.trim_start_matches('+')
1123    };
1124
1125    let (mantissa_digits, exponent) = parse_number_parts(abs_str);
1126
1127    // Zero
1128    if mantissa_digits.is_empty() {
1129        return "0".to_string();
1130    }
1131
1132    // Reconstruct: mantissa_digits represent the significant digits,
1133    // exponent is the power of 10 such that value = 0.mantissa * 10^exponent
1134    // e.g., 12345 → mantissa=[1,2,3,4,5], exponent=5 → 12345
1135    // e.g., 0.00123 → mantissa=[1,2,3], exponent=-2 → 0.00123
1136    let num_digits = mantissa_digits.len() as i32;
1137    let int_digits = exponent; // number of digits before the decimal point
1138
1139    let mut result = String::new();
1140    if negative {
1141        result.push('-');
1142    }
1143
1144    if int_digits <= 0 {
1145        // Pure fraction: 0.000...digits
1146        result.push_str("0.");
1147        for _ in 0..(-int_digits) {
1148            result.push('0');
1149        }
1150        for &d in &mantissa_digits {
1151            result.push((b'0' + d) as char);
1152        }
1153    } else if int_digits >= num_digits {
1154        // Pure integer: digits followed by trailing zeros
1155        for &d in &mantissa_digits {
1156            result.push((b'0' + d) as char);
1157        }
1158        for _ in 0..(int_digits - num_digits) {
1159            result.push('0');
1160        }
1161    } else {
1162        // Mixed: some digits before decimal, some after
1163        let int_part = int_digits as usize;
1164        for &d in &mantissa_digits[..int_part] {
1165            result.push((b'0' + d) as char);
1166        }
1167        result.push('.');
1168        for &d in &mantissa_digits[int_part..] {
1169            result.push((b'0' + d) as char);
1170        }
1171    }
1172
1173    result
1174}
1175
1176fn zero_encoding() -> String {
1177    // Zero sorts between negative (prefix "0") and positive (prefix "2")
1178    format!("1{}{}", "0".repeat(4), "0".repeat(40))
1179}
1180
1181/// The exponent as four digits, offset by 5000.
1182///
1183/// DynamoDB's range only needs -129 to 126, but a library caller can reach the
1184/// encoder with a number it never validated. One outside -5000 to 4999 sorts at
1185/// the matching extreme rather than overflowing into an unrelated key.
1186fn encoded_exponent(exponent: i32) -> u16 {
1187    (exponent.clamp(-5000, 4999) + 5000) as u16
1188}
1189
1190fn encode_positive(mantissa: &[u8], exponent: i32) -> String {
1191    let exp_encoded = encoded_exponent(exponent);
1192    let mantissa_str = mantissa_to_string(mantissa, 40);
1193    format!("2{exp_encoded:04}{mantissa_str}")
1194}
1195
1196fn encode_negative(mantissa: &[u8], exponent: i32) -> String {
1197    // For negatives, we complement everything so larger absolute values sort first (smaller)
1198    let exp_encoded = 9999 - encoded_exponent(exponent);
1199    let mantissa_str = complement_mantissa(mantissa, 40);
1200    format!("0{exp_encoded:04}{mantissa_str}")
1201}
1202
1203/// Parse a non-negative number string into (mantissa digits, exponent).
1204/// Mantissa is normalized: first digit is non-zero, exponent is the power of 10
1205/// such that the number = 0.mantissa * 10^exponent.
1206///
1207/// An exponent outside the i32 range saturates at its bound, so a value far
1208/// outside DynamoDB's range still lands on the right side of it.
1209pub(crate) fn parse_number_parts(s: &str) -> (Vec<u8>, i32) {
1210    // Handle scientific notation
1211    let (coeff, exp_part) = split_exponent(s);
1212
1213    // Split coefficient into integer and fraction parts
1214    let (int_part, frac_part) = if let Some(dot) = coeff.find('.') {
1215        (&coeff[..dot], &coeff[dot + 1..])
1216    } else {
1217        (coeff, "")
1218    };
1219
1220    // Collect all digits
1221    let mut digits: Vec<u8> = Vec::new();
1222    for ch in int_part.chars().chain(frac_part.chars()) {
1223        if ch.is_ascii_digit() {
1224            digits.push(ch as u8 - b'0');
1225        }
1226    }
1227
1228    if digits.is_empty() {
1229        return (vec![], 0);
1230    }
1231
1232    // The integer part length gives us the base exponent
1233    let int_len = int_part.chars().filter(|c| c.is_ascii_digit()).count() as i64;
1234
1235    // Find first non-zero digit
1236    let leading_zeros = digits.iter().take_while(|&&d| d == 0).count();
1237    digits.drain(..leading_zeros);
1238
1239    // Trim trailing zeros
1240    while digits.last() == Some(&0) {
1241        digits.pop();
1242    }
1243
1244    if digits.is_empty() {
1245        return (vec![], 0);
1246    }
1247
1248    // exponent = int_len - leading_zeros + exp_part
1249    // But we need to account for whether leading zeros were in int or frac part
1250    let exponent = (int_len - leading_zeros as i64).saturating_add(exp_part);
1251    let exponent = exponent.clamp(i32::MIN.into(), i32::MAX.into()) as i32;
1252
1253    (digits, exponent)
1254}
1255
1256/// Split a number at its `e` or `E` into the coefficient and the exponent
1257/// written after it, which is 0 when there is none.
1258///
1259/// An exponent too large for an i64 saturates at the bound on its side rather
1260/// than reading as 0, which used to turn `1e99999999999` into 1. Anything
1261/// else that fails to parse also reads as 0, for callers that have not been
1262/// through `validate_dynamo_number`.
1263fn split_exponent(s: &str) -> (&str, i64) {
1264    let Some(pos) = s.find(['e', 'E']) else {
1265        return (s, 0);
1266    };
1267    let exponent = match s[pos + 1..].parse::<i64>() {
1268        Ok(exponent) => exponent,
1269        Err(e) => match e.kind() {
1270            std::num::IntErrorKind::PosOverflow => i64::MAX,
1271            std::num::IntErrorKind::NegOverflow => i64::MIN,
1272            _ => 0,
1273        },
1274    };
1275    (&s[..pos], exponent)
1276}
1277
1278fn mantissa_to_string(digits: &[u8], width: usize) -> String {
1279    let mut s = String::with_capacity(width);
1280    for &d in digits.iter().take(width) {
1281        s.push((b'0' + d) as char);
1282    }
1283    while s.len() < width {
1284        s.push('0');
1285    }
1286    s
1287}
1288
1289fn complement_mantissa(digits: &[u8], width: usize) -> String {
1290    let mut s = String::with_capacity(width);
1291    for i in 0..width {
1292        let d = if i < digits.len() { digits[i] } else { 0 };
1293        s.push((b'0' + (9 - d)) as char);
1294    }
1295    s
1296}
1297
1298/// Hex-encode bytes (lowercase) for binary key storage.
1299fn hex_encode(bytes: &[u8]) -> String {
1300    let mut s = String::with_capacity(bytes.len() * 2);
1301    for &b in bytes {
1302        s.push_str(&format!("{b:02x}"));
1303    }
1304    s
1305}
1306
1307// ---------------------------------------------------------------------------
1308// Item helpers
1309// ---------------------------------------------------------------------------
1310
1311/// A DynamoDB item: a map of attribute names to values.
1312pub type Item = HashMap<String, AttributeValue>;
1313
1314/// SSE specification for server-side encryption settings.
1315#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1316pub struct SseSpecification {
1317    #[serde(rename = "Enabled", default)]
1318    pub enabled: Option<bool>,
1319    #[serde(rename = "SSEType", default)]
1320    pub sse_type: Option<String>,
1321    #[serde(rename = "KMSMasterKeyId", default)]
1322    pub kms_master_key_id: Option<String>,
1323}
1324
1325/// DynamoDB Tag (key-value pair attached to a resource).
1326#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1327pub struct Tag {
1328    #[serde(rename = "Key")]
1329    pub key: String,
1330    #[serde(rename = "Value")]
1331    pub value: String,
1332}
1333
1334/// Calculate the total size of a DynamoDB item in bytes.
1335pub fn item_size(item: &Item) -> usize {
1336    item.iter()
1337        .map(|(name, value)| name.len() + value.size())
1338        .sum()
1339}
1340
1341/// The bytes DynamoDB bills a vector at: its f32 width, 4 bytes per
1342/// dimension, whatever numbers it holds. Shared by the base item's billed size
1343/// and the vector index entry.
1344pub(crate) fn vector_billed_bytes(dimensions: u32) -> usize {
1345    4 * dimensions as usize
1346}
1347
1348/// The size DynamoDB bills a write of `item` at, and the figure TableSizeBytes
1349/// sums, on a table whose vector indexes are `vector_indexes`.
1350///
1351/// An attribute a vector index reads costs its name plus
1352/// [`vector_billed_bytes`], once however many indexes read it; everything else
1353/// is sized as [`item_size`] sizes it. Read units and the 400 KB limit still
1354/// use [`item_size`]: DynamoDB sizes the vector by its numbers for both.
1355/// Captured eu-west-2 and us-east-1, 2026-10-09, at 1, 3, 6, 8, 256 and 1,024
1356/// dimensions, with two indexes on different attributes charged their own
1357/// dimensions each.
1358///
1359/// A value that is not a valid vector for its index is sized as usual. A live
1360/// write refuses one, so only an item written before the index existed can
1361/// hold one, and the index skips it too.
1362pub(crate) fn billed_item_size(item: &Item, vector_indexes: &[VectorIndex]) -> usize {
1363    if vector_indexes.is_empty() {
1364        return item_size(item);
1365    }
1366    item.iter()
1367        .map(|(name, value)| {
1368            let billed = vector_indexes
1369                .iter()
1370                .filter(|vix| vix.vector_attribute.attribute_name == *name)
1371                .find(|vix| vector_f32_values(value, vix.dimensions).is_some())
1372                .map(|vix| vector_billed_bytes(vix.dimensions));
1373            name.len() + billed.unwrap_or_else(|| value.size())
1374        })
1375        .sum()
1376}
1377
1378/// Maximum item size in bytes (400 KB).
1379pub const MAX_ITEM_SIZE: usize = 400 * 1024;
1380
1381/// ItemCollectionMetrics returned when `ReturnItemCollectionMetrics: SIZE` is set
1382/// and the table has local secondary indexes.
1383#[derive(Debug, Clone, Serialize, Deserialize)]
1384pub struct ItemCollectionMetrics {
1385    #[serde(rename = "ItemCollectionKey")]
1386    pub item_collection_key: HashMap<String, AttributeValue>,
1387    #[serde(rename = "SizeEstimateRangeGB")]
1388    pub size_estimate_range_gb: Vec<f64>,
1389}
1390
1391/// ConsumedCapacity returned when `ReturnConsumedCapacity` is set.
1392#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1393pub struct ConsumedCapacity {
1394    #[serde(rename = "TableName")]
1395    pub table_name: String,
1396    #[serde(rename = "CapacityUnits")]
1397    pub capacity_units: f64,
1398    #[serde(rename = "ReadCapacityUnits", skip_serializing_if = "Option::is_none")]
1399    pub read_capacity_units: Option<f64>,
1400    #[serde(rename = "WriteCapacityUnits", skip_serializing_if = "Option::is_none")]
1401    pub write_capacity_units: Option<f64>,
1402    #[serde(rename = "Table", skip_serializing_if = "Option::is_none")]
1403    pub table: Option<CapacityDetail>,
1404    #[serde(
1405        rename = "GlobalSecondaryIndexes",
1406        skip_serializing_if = "Option::is_none"
1407    )]
1408    pub global_secondary_indexes: Option<HashMap<String, CapacityDetail>>,
1409    #[serde(
1410        rename = "LocalSecondaryIndexes",
1411        skip_serializing_if = "Option::is_none"
1412    )]
1413    pub local_secondary_indexes: Option<HashMap<String, CapacityDetail>>,
1414    /// Per-vector-index replication cost. Reported under `INDEXES` alone, and
1415    /// never folded into `capacity_units`: vector replication is billed in
1416    /// bytes on its own axis rather than in capacity units.
1417    #[serde(rename = "VectorIndexes", skip_serializing_if = "Option::is_none")]
1418    pub vector_indexes: Option<HashMap<String, VectorCapacityDetail>>,
1419}
1420
1421/// Per-resource capacity detail.
1422#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1423pub struct CapacityDetail {
1424    #[serde(rename = "CapacityUnits")]
1425    pub capacity_units: f64,
1426    #[serde(rename = "ReadCapacityUnits", skip_serializing_if = "Option::is_none")]
1427    pub read_capacity_units: Option<f64>,
1428    #[serde(rename = "WriteCapacityUnits", skip_serializing_if = "Option::is_none")]
1429    pub write_capacity_units: Option<f64>,
1430}
1431
1432/// One vector index's replication cost for a write.
1433///
1434/// Vector indexes are billed in bytes rather than capacity units, so this
1435/// carries no `CapacityUnits` and the figure never reaches the response's
1436/// total. An index a write leaves alone is absent from the map rather than
1437/// present and zeroed (captured 2026-08-11, eu-west-2).
1438#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1439pub struct VectorCapacityDetail {
1440    #[serde(rename = "VectorWriteRequestBytes")]
1441    pub vector_write_request_bytes: f64,
1442}
1443
1444/// The `ConsumedCapacity` a `SearchVectors` response carries.
1445///
1446/// The shape has no `CapacityUnits` and no `TableName`, reads identically under
1447/// `TOTAL` and `INDEXES`, and is absent under `NONE` (captured 2026-08-11,
1448/// eu-west-2). Despite the field name, real DynamoDB bills the figure on the
1449/// data the search read rather than on the request, and does not reproduce it
1450/// between identical calls; Dynoxide reports a deterministic stand-in instead.
1451#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1452pub struct VectorSearchCapacity {
1453    #[serde(rename = "VectorSearchRequestBytes")]
1454    pub vector_search_request_bytes: f64,
1455}
1456
1457/// The billable minimum for a vector request, in bytes.
1458///
1459/// DynamoDB documents a 1KB minimum on vector billing and the capture observed
1460/// 1024.0 for a three-dimensional fixture, which is this floor rather than a
1461/// constant.
1462///
1463/// The two axes above the floor are not on the same footing. The write figure
1464/// is captured byte-exact (eu-west-2, 2026-08-18): five fixtures from 3 to 512
1465/// dimensions fit `4 * dimensions + vector attribute name + item size of the
1466/// rest of the projected entry` with no residual, and the tests fail on a
1467/// one-byte error. The search figure has no oracle at all, because real
1468/// DynamoDB does not reproduce its own, so what Dynoxide reports there is a
1469/// deterministic stand-in rather than a match.
1470pub const MIN_VECTOR_REQUEST_BYTES: f64 = 1024.0;
1471
1472/// The billable byte figure for a vector request measuring `bytes`.
1473pub fn vector_request_bytes(bytes: usize) -> f64 {
1474    (bytes as f64).max(MIN_VECTOR_REQUEST_BYTES)
1475}
1476
1477/// The transactional capacity multiplier. `TransactWriteItems` and
1478/// `TransactGetItems` cost twice the equivalent single-item operation, so each
1479/// item's rounded-up units are doubled (the rounding happens per item, before
1480/// the multiplier, to match AWS at the KB/4KB boundary).
1481pub const TRANSACTIONAL_CAPACITY_FACTOR: f64 = 2.0;
1482
1483/// Calculate write capacity units (1 WCU = 1KB, rounded up).
1484pub fn write_capacity_units(item_size_bytes: usize) -> f64 {
1485    ((item_size_bytes as f64) / 1024.0).ceil().max(1.0)
1486}
1487
1488/// Write capacity units the base table consumes for a single write.
1489///
1490/// DynamoDB sizes a write on the larger of the item's before and after images,
1491/// so shrinking an item still costs what the old one did. `old_size` is `None`
1492/// when nothing was there beforehand, and `new_size` is `None` on a delete,
1493/// where only the old image exists.
1494pub fn table_write_capacity_units(old_size: Option<usize>, new_size: Option<usize>) -> f64 {
1495    let old = old_size.map(write_capacity_units);
1496    let new = new_size.map(write_capacity_units);
1497    match (old, new) {
1498        (Some(old), Some(new)) => old.max(new),
1499        (Some(units), None) | (None, Some(units)) => units,
1500        // No image either side is not a write DynamoDB would charge for, but a
1501        // write is never free, so fall back to the one-unit minimum.
1502        (None, None) => write_capacity_units(0),
1503    }
1504}
1505
1506/// Read capacity units one write's images cost when charged as a read.
1507///
1508/// A same-token transactional replay is billed as a read against the image the
1509/// original write was sized on, which is the larger of the before and after
1510/// images, but rounded at 4KB rather than 1KB. Captured in eu-west-2: a replayed
1511/// put that shrank a 9KB item to nothing reports 6, and so does the replay of
1512/// the write that grew it, so neither side alone explains the figure.
1513pub fn table_read_capacity_units(old_size: Option<usize>, new_size: Option<usize>) -> f64 {
1514    let larger = old_size.unwrap_or(0).max(new_size.unwrap_or(0));
1515    read_capacity_units(larger)
1516}
1517
1518/// Calculate read capacity units assuming strongly consistent reads
1519/// (1 RCU per 4KB, rounded up). Used when ConsistentRead is true or
1520/// when the read type is not specified.
1521pub fn read_capacity_units(item_size_bytes: usize) -> f64 {
1522    ((item_size_bytes as f64) / 4096.0).ceil().max(1.0)
1523}
1524
1525/// Calculate read capacity units accounting for consistency mode.
1526///
1527/// Strongly consistent: 1 RCU per 4KB, rounded up.
1528/// Eventually consistent: 0.5 RCU per 4KB (half the strongly consistent rate).
1529pub fn read_capacity_units_with_consistency(item_size_bytes: usize, consistent: bool) -> f64 {
1530    let strongly = read_capacity_units(item_size_bytes);
1531    if consistent { strongly } else { strongly / 2.0 }
1532}
1533
1534/// Whether a `ReturnConsumedCapacity` value asks for a figure at all.
1535///
1536/// Every `consumed_capacity*` builder below throws its inputs away unless the
1537/// mode is `TOTAL` or `INDEXES`, so work done only to feed them is wasted in
1538/// every other mode, and `NONE` is the default. Callers that can skip that work
1539/// ask here rather than each spelling the comparison out, so the set of modes
1540/// that mean "yes" is written down once.
1541pub fn capacity_wanted(mode: Option<&str>) -> bool {
1542    matches!(mode, Some("TOTAL") | Some("INDEXES"))
1543}
1544
1545/// Whether a `ReturnConsumedCapacity` mode carries the vector write map.
1546///
1547/// Narrower than [`capacity_wanted`], because the per-index vector map reaches
1548/// the wire under `INDEXES` alone (captured 2026-08-11, eu-west-2); `TOTAL`
1549/// asks for capacity and still carries no vector fields. The sizing costs a
1550/// derivation of the old image per index, so the fan-out asks here and skips
1551/// the work rather than doing it and having the response builder drop it. Both
1552/// ends read the same predicate so they cannot disagree about which modes mean
1553/// yes.
1554pub fn vector_capacity_wanted(mode: Option<&str>) -> bool {
1555    matches!(mode, Some("INDEXES"))
1556}
1557
1558/// Build a `ConsumedCapacity` for a simple table operation.
1559pub fn consumed_capacity(
1560    table_name: &str,
1561    capacity_units: f64,
1562    mode: &Option<String>,
1563) -> Option<ConsumedCapacity> {
1564    let mode = mode.as_deref().unwrap_or("NONE");
1565    match mode {
1566        "TOTAL" => Some(ConsumedCapacity {
1567            table_name: table_name.to_string(),
1568            capacity_units,
1569            table: None,
1570            global_secondary_indexes: None,
1571            local_secondary_indexes: None,
1572            ..Default::default()
1573        }),
1574        "INDEXES" => Some(ConsumedCapacity {
1575            table_name: table_name.to_string(),
1576            capacity_units,
1577            table: Some(CapacityDetail {
1578                capacity_units,
1579                ..Default::default()
1580            }),
1581            global_secondary_indexes: None,
1582            local_secondary_indexes: None,
1583            ..Default::default()
1584        }),
1585        _ => None,
1586    }
1587}
1588
1589/// Build a `ConsumedCapacity` with per-GSI breakdown for INDEXES mode.
1590pub fn consumed_capacity_with_indexes(
1591    table_name: &str,
1592    table_units: f64,
1593    gsi_units: &HashMap<String, f64>,
1594    mode: &Option<String>,
1595) -> Option<ConsumedCapacity> {
1596    consumed_capacity_with_secondary_indexes(
1597        table_name,
1598        table_units,
1599        gsi_units,
1600        &HashMap::new(),
1601        mode,
1602    )
1603}
1604
1605/// Build a `ConsumedCapacity` with per-GSI and per-LSI breakdown for INDEXES mode.
1606pub fn consumed_capacity_with_secondary_indexes(
1607    table_name: &str,
1608    table_units: f64,
1609    gsi_units: &HashMap<String, f64>,
1610    lsi_units: &HashMap<String, f64>,
1611    mode: &Option<String>,
1612) -> Option<ConsumedCapacity> {
1613    secondary_index_capacity(
1614        table_name,
1615        table_units,
1616        gsi_units,
1617        lsi_units,
1618        mode,
1619        CapacityAxis::None,
1620    )
1621}
1622
1623/// Whether a response mirrors its units into a named axis alongside
1624/// `CapacityUnits`.
1625///
1626/// Single-item and PartiQL responses report `CapacityUnits` alone at every
1627/// level. Transactional responses mirror the units into `WriteCapacityUnits`
1628/// at every level, including each index arm. Both shapes are captured against
1629/// real DynamoDB.
1630#[derive(Clone, Copy, PartialEq)]
1631enum CapacityAxis {
1632    None,
1633    Write,
1634}
1635
1636impl CapacityAxis {
1637    /// The `WriteCapacityUnits` value for a detail carrying `units`.
1638    fn write_units(self, units: f64) -> Option<f64> {
1639        match self {
1640            Self::None => None,
1641            Self::Write => Some(units),
1642        }
1643    }
1644}
1645
1646/// Shared body for the per-index capacity builders. `table_units` carries the
1647/// transactional factor already, when one applies; the index maps never do.
1648fn secondary_index_capacity(
1649    table_name: &str,
1650    table_units: f64,
1651    gsi_units: &HashMap<String, f64>,
1652    lsi_units: &HashMap<String, f64>,
1653    mode: &Option<String>,
1654    axis: CapacityAxis,
1655) -> Option<ConsumedCapacity> {
1656    let total = table_units + gsi_units.values().sum::<f64>() + lsi_units.values().sum::<f64>();
1657
1658    match mode.as_deref().unwrap_or("NONE") {
1659        "INDEXES" => Some(ConsumedCapacity {
1660            table_name: table_name.to_string(),
1661            capacity_units: total,
1662            write_capacity_units: axis.write_units(total),
1663            table: Some(CapacityDetail {
1664                capacity_units: table_units,
1665                write_capacity_units: axis.write_units(table_units),
1666                ..Default::default()
1667            }),
1668            global_secondary_indexes: capacity_detail_map(gsi_units, axis),
1669            local_secondary_indexes: capacity_detail_map(lsi_units, axis),
1670            ..Default::default()
1671        }),
1672        "TOTAL" => Some(ConsumedCapacity {
1673            table_name: table_name.to_string(),
1674            capacity_units: total,
1675            write_capacity_units: axis.write_units(total),
1676            ..Default::default()
1677        }),
1678        _ => None,
1679    }
1680}
1681
1682/// Build a `ConsumedCapacity` carrying the classic index arms and the vector
1683/// arm together.
1684///
1685/// The single mode argument is the point: the classic builder and the vector
1686/// attach each need one, and passing them separately leaves a caller free to
1687/// build under one mode and attach under another, which would put a
1688/// `VectorIndexes` map on a `TOTAL`-shaped response.
1689pub fn consumed_capacity_with_vector_indexes(
1690    table_name: &str,
1691    table_units: f64,
1692    gsi_units: &HashMap<String, f64>,
1693    lsi_units: &HashMap<String, f64>,
1694    vector_bytes: &HashMap<String, f64>,
1695    mode: &Option<String>,
1696) -> Option<ConsumedCapacity> {
1697    attach_vector_index_capacity(
1698        consumed_capacity_with_secondary_indexes(
1699            table_name,
1700            table_units,
1701            gsi_units,
1702            lsi_units,
1703            mode,
1704        ),
1705        vector_bytes,
1706        mode,
1707    )
1708}
1709
1710/// Attach per-vector-index write bytes to a capacity report.
1711///
1712/// Vector replication sits on its own axis, so this never touches
1713/// `capacity_units`: the bytes are reported beside the classic arms, not
1714/// summed into them. The map appears under `INDEXES` alone, matching the
1715/// capture, and an empty `vector_bytes` leaves the response untouched so a
1716/// write that changed no vector index reports no map at all rather than an
1717/// empty one.
1718pub(crate) fn attach_vector_index_capacity(
1719    capacity: Option<ConsumedCapacity>,
1720    vector_bytes: &HashMap<String, f64>,
1721    mode: &Option<String>,
1722) -> Option<ConsumedCapacity> {
1723    let mut capacity = capacity?;
1724    if vector_bytes.is_empty() || !vector_capacity_wanted(mode.as_deref()) {
1725        return Some(capacity);
1726    }
1727    capacity.vector_indexes = Some(
1728        vector_bytes
1729            .iter()
1730            .map(|(name, bytes)| {
1731                (
1732                    name.clone(),
1733                    VectorCapacityDetail {
1734                        vector_write_request_bytes: *bytes,
1735                    },
1736                )
1737            })
1738            .collect(),
1739    );
1740    Some(capacity)
1741}
1742
1743/// Build a `ConsumedCapacity` for one table in a transactional read
1744/// (`TransactGetItems`). `units` is the table total and already includes the
1745/// transactional 2x factor. Under `INDEXES` the Table detail reports
1746/// `ReadCapacityUnits` alongside `CapacityUnits`, matching AWS.
1747pub fn transactional_read_capacity(
1748    table_name: &str,
1749    units: f64,
1750    mode: &Option<String>,
1751) -> Option<ConsumedCapacity> {
1752    match mode.as_deref().unwrap_or("NONE") {
1753        "TOTAL" => Some(ConsumedCapacity {
1754            table_name: table_name.to_string(),
1755            capacity_units: units,
1756            read_capacity_units: Some(units),
1757            ..Default::default()
1758        }),
1759        "INDEXES" => Some(ConsumedCapacity {
1760            table_name: table_name.to_string(),
1761            capacity_units: units,
1762            read_capacity_units: Some(units),
1763            table: Some(CapacityDetail {
1764                capacity_units: units,
1765                read_capacity_units: Some(units),
1766                ..Default::default()
1767            }),
1768            ..Default::default()
1769        }),
1770        _ => None,
1771    }
1772}
1773
1774/// Build the per-table `ConsumedCapacity` vec for a transactional op from the
1775/// per-table units, using `builder` (`transactional_write_capacity` for a
1776/// first-call write, `transactional_read_capacity` for a read set or a
1777/// same-token replay). Returns `None` unless `ReturnConsumedCapacity` is
1778/// `TOTAL` or `INDEXES`, so the mode guard lives in one place. Shared by
1779/// `TransactWriteItems` and `ExecuteTransaction`.
1780pub fn build_transactional_capacity(
1781    table_units: &HashMap<String, f64>,
1782    mode: &Option<String>,
1783    builder: fn(&str, f64, &Option<String>) -> Option<ConsumedCapacity>,
1784) -> Option<Vec<ConsumedCapacity>> {
1785    if !capacity_wanted(mode.as_deref()) {
1786        return None;
1787    }
1788
1789    // Sorted by table name, matching the write path. Iterating the map handed
1790    // back a different order per process, so a two-table read set or replay
1791    // could report its tables either way round while the write half of the same
1792    // response family was stable.
1793    let mut tables: Vec<&String> = table_units.keys().collect();
1794    tables.sort();
1795
1796    Some(
1797        tables
1798            .into_iter()
1799            .filter_map(|table| builder(table, *table_units.get(table)?, mode))
1800            .collect(),
1801    )
1802}
1803
1804/// Build a `ConsumedCapacity` for one table in a transactional write, with the
1805/// per-index breakdown.
1806///
1807/// `table_units` already carries the transactional 2x factor; the index maps do
1808/// not. DynamoDB applies that factor to the base table arm alone, so an index
1809/// arm inside a transaction costs what the same write costs outside one.
1810///
1811/// `CapacityUnits` and `WriteCapacityUnits` both report the table arm plus the
1812/// index arms. The `Table` detail reports the table arm on its own, and every
1813/// index arm carries the write axis too, which is where this shape differs from
1814/// the single-item one.
1815pub fn transactional_write_capacity_with_indexes(
1816    table_name: &str,
1817    table_units: f64,
1818    gsi_units: &HashMap<String, f64>,
1819    lsi_units: &HashMap<String, f64>,
1820    mode: &Option<String>,
1821) -> Option<ConsumedCapacity> {
1822    secondary_index_capacity(
1823        table_name,
1824        table_units,
1825        gsi_units,
1826        lsi_units,
1827        mode,
1828        CapacityAxis::Write,
1829    )
1830}
1831
1832/// Turn per-index units into the response's detail map, or `None` when nothing
1833/// was charged so the arm is absent rather than present and empty.
1834fn capacity_detail_map(
1835    units: &HashMap<String, f64>,
1836    axis: CapacityAxis,
1837) -> Option<HashMap<String, CapacityDetail>> {
1838    if units.is_empty() {
1839        return None;
1840    }
1841    Some(
1842        units
1843            .iter()
1844            .map(|(name, &u)| {
1845                (
1846                    name.clone(),
1847                    CapacityDetail {
1848                        capacity_units: u,
1849                        write_capacity_units: axis.write_units(u),
1850                        ..Default::default()
1851                    },
1852                )
1853            })
1854            .collect(),
1855    )
1856}
1857
1858/// Build a `ConsumedCapacity` for one table in a transactional write
1859/// (`TransactWriteItems`). `units` is the table total and already includes the
1860/// transactional 2x factor. Under `INDEXES` the Table detail reports
1861/// `WriteCapacityUnits` alongside `CapacityUnits`, matching AWS.
1862pub fn transactional_write_capacity(
1863    table_name: &str,
1864    units: f64,
1865    mode: &Option<String>,
1866) -> Option<ConsumedCapacity> {
1867    match mode.as_deref().unwrap_or("NONE") {
1868        "TOTAL" => Some(ConsumedCapacity {
1869            table_name: table_name.to_string(),
1870            capacity_units: units,
1871            write_capacity_units: Some(units),
1872            ..Default::default()
1873        }),
1874        "INDEXES" => Some(ConsumedCapacity {
1875            table_name: table_name.to_string(),
1876            capacity_units: units,
1877            write_capacity_units: Some(units),
1878            table: Some(CapacityDetail {
1879                capacity_units: units,
1880                write_capacity_units: Some(units),
1881                ..Default::default()
1882            }),
1883            ..Default::default()
1884        }),
1885        _ => None,
1886    }
1887}
1888
1889/// Key schema element - defines a key attribute.
1890#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1891pub struct KeySchemaElement {
1892    #[serde(rename = "AttributeName", alias = "attribute_name")]
1893    pub attribute_name: String,
1894    #[serde(rename = "KeyType", alias = "key_type")]
1895    pub key_type: KeyType,
1896}
1897
1898/// Key type: HASH (partition key) or RANGE (sort key).
1899#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1900pub enum KeyType {
1901    #[default]
1902    HASH,
1903    RANGE,
1904}
1905
1906/// Attribute definition - declares an attribute's type.
1907#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1908pub struct AttributeDefinition {
1909    #[serde(rename = "AttributeName", alias = "attribute_name")]
1910    pub attribute_name: String,
1911    #[serde(rename = "AttributeType", alias = "attribute_type")]
1912    pub attribute_type: ScalarAttributeType,
1913}
1914
1915/// Scalar attribute types that can be used as keys.
1916#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1917pub enum ScalarAttributeType {
1918    #[default]
1919    S,
1920    N,
1921    B,
1922}
1923
1924/// GSI projection type.
1925#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1926pub struct Projection {
1927    #[serde(
1928        rename = "ProjectionType",
1929        alias = "projection_type",
1930        default,
1931        skip_serializing_if = "Option::is_none"
1932    )]
1933    pub projection_type: Option<ProjectionType>,
1934    #[serde(
1935        rename = "NonKeyAttributes",
1936        alias = "non_key_attributes",
1937        skip_serializing_if = "Option::is_none"
1938    )]
1939    pub non_key_attributes: Option<Vec<String>>,
1940}
1941
1942/// Projection type enum.
1943#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1944#[allow(non_camel_case_types)]
1945pub enum ProjectionType {
1946    #[default]
1947    ALL,
1948    KEYS_ONLY,
1949    INCLUDE,
1950}
1951
1952/// Global Secondary Index definition.
1953#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1954pub struct GlobalSecondaryIndex {
1955    #[serde(rename = "IndexName", alias = "index_name")]
1956    pub index_name: String,
1957    #[serde(rename = "KeySchema", alias = "key_schema")]
1958    pub key_schema: Vec<KeySchemaElement>,
1959    #[serde(rename = "Projection", alias = "projection")]
1960    pub projection: Projection,
1961    #[serde(
1962        rename = "ProvisionedThroughput",
1963        alias = "provisioned_throughput",
1964        skip_serializing_if = "Option::is_none"
1965    )]
1966    pub provisioned_throughput: Option<ProvisionedThroughput>,
1967}
1968
1969/// Local Secondary Index definition.
1970#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1971pub struct LocalSecondaryIndex {
1972    #[serde(rename = "IndexName", alias = "index_name")]
1973    pub index_name: String,
1974    #[serde(rename = "KeySchema", alias = "key_schema")]
1975    pub key_schema: Vec<KeySchemaElement>,
1976    #[serde(rename = "Projection", alias = "projection")]
1977    pub projection: Projection,
1978}
1979
1980/// The vector attribute a vector index is built over. A structure with a
1981/// single `AttributeName` member on the wire, not a bare string.
1982#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1983pub struct VectorAttributeDefinition {
1984    #[serde(rename = "AttributeName", alias = "attribute_name")]
1985    pub attribute_name: String,
1986}
1987
1988/// One element of a vector index's search schema: a `HASH` partition
1989/// attribute or an `INLINE_FILTER` attribute.
1990#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1991pub struct SearchSchemaElement {
1992    #[serde(rename = "AttributeName", alias = "attribute_name")]
1993    pub attribute_name: String,
1994    #[serde(
1995        rename = "SearchSchemaElementType",
1996        alias = "search_schema_element_type"
1997    )]
1998    pub search_schema_element_type: String,
1999}
2000
2001/// Vector index definition as supplied on CreateTable's `VectorIndexes`.
2002///
2003/// Serialised verbatim into the `_tables.vector_index_definitions` column,
2004/// following the `gsi_definitions` convention.
2005#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2006pub struct VectorIndex {
2007    #[serde(rename = "IndexName", alias = "index_name")]
2008    pub index_name: String,
2009    #[serde(rename = "VectorAttribute", alias = "vector_attribute")]
2010    pub vector_attribute: VectorAttributeDefinition,
2011    #[serde(
2012        rename = "SearchSchema",
2013        alias = "search_schema",
2014        default,
2015        skip_serializing_if = "Option::is_none"
2016    )]
2017    pub search_schema: Option<Vec<SearchSchemaElement>>,
2018    #[serde(rename = "Projection", alias = "projection")]
2019    pub projection: Projection,
2020    #[serde(rename = "Dimensions", alias = "dimensions")]
2021    pub dimensions: u32,
2022    #[serde(rename = "DistanceFunction", alias = "distance_function")]
2023    pub distance_function: String,
2024}
2025
2026/// Why an attribute value is not a valid vector for an index with a given
2027/// dimension count. Carries the element-level detail the write-path
2028/// validation errors interpolate; the checks run in the order the variants
2029/// are listed, so a value can only report one failure.
2030#[derive(Debug, Clone, PartialEq)]
2031pub enum VectorValueError {
2032    /// The value is not a List at all.
2033    NotAList,
2034    /// The list's element count differs from the index's dimensions.
2035    WrongDimensions {
2036        /// The number of elements actually present.
2037        actual: usize,
2038    },
2039    /// An element is not a Number.
2040    ElementNotANumber {
2041        /// Zero-based element position.
2042        position: usize,
2043        /// The element's DynamoDB type descriptor.
2044        actual: &'static str,
2045    },
2046    /// An element does not fit the finite f32 range (out-of-range values
2047    /// overflow to infinity on conversion; real DynamoDB rejects them at
2048    /// write time, captured eu-west-2 and us-east-1, 2026-08-12).
2049    ElementOutOfRange {
2050        /// Zero-based element position.
2051        position: usize,
2052        /// The raw number string as written.
2053        value: String,
2054    },
2055}
2056
2057/// The f32 values of a vector attribute value, or the element-level reason it
2058/// is not a valid vector for an index with `dimensions` elements.
2059///
2060/// Vector indexes hold f32 copies while the base table keeps full precision
2061/// (captured from real DynamoDB, eu-west-2, 2026-08-11), so conversion
2062/// happens here, where the index copy is derived. The live write path turns
2063/// each error variant into its captured `IndexName:`-suffixed message; the
2064/// backfill path discards the detail via [`vector_f32_values`] and
2065/// sparse-skips, so the two paths agree on validity by construction.
2066pub fn check_vector_f32_values(
2067    value: &AttributeValue,
2068    dimensions: u32,
2069) -> std::result::Result<Vec<f32>, VectorValueError> {
2070    let AttributeValue::L(elems) = value else {
2071        return Err(VectorValueError::NotAList);
2072    };
2073    if elems.len() != dimensions as usize {
2074        return Err(VectorValueError::WrongDimensions {
2075            actual: elems.len(),
2076        });
2077    }
2078    let mut out = Vec::with_capacity(elems.len());
2079    for (position, elem) in elems.iter().enumerate() {
2080        let AttributeValue::N(n) = elem else {
2081            return Err(VectorValueError::ElementNotANumber {
2082                position,
2083                actual: elem.type_name(),
2084            });
2085        };
2086        // A well-formed DynamoDB number always parses (overflow yields
2087        // infinity, not an error); anything non-finite is out of f32 range.
2088        match n.parse::<f32>() {
2089            Ok(v) if v.is_finite() => out.push(v),
2090            _ => {
2091                return Err(VectorValueError::ElementOutOfRange {
2092                    position,
2093                    value: n.clone(),
2094                });
2095            }
2096        }
2097    }
2098    Ok(out)
2099}
2100
2101/// The f32 values of a vector attribute value, or `None` when the value is
2102/// not a valid vector for an index with `dimensions` elements. The validity
2103/// rule is exactly [`check_vector_f32_values`]'s, with the element-level
2104/// detail discarded; the backfill path uses this form to sparse-skip.
2105pub fn vector_f32_values(value: &AttributeValue, dimensions: u32) -> Option<Vec<f32>> {
2106    check_vector_f32_values(value, dimensions).ok()
2107}
2108
2109/// Serialise an f32 for a vector index's number copy: shortest-decimal via
2110/// serde_json's f32 formatter, which always keeps a fractional part ("1"
2111/// stores as "1.0", matching how the index copy reads back from real
2112/// DynamoDB; captured eu-west-2, 2026-08-11).
2113pub fn f32_number_string(v: f32) -> String {
2114    serde_json::to_string(&v).unwrap_or_else(|_| "0.0".to_string())
2115}
2116
2117/// Provisioned throughput settings (stored but not enforced).
2118#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2119pub struct ProvisionedThroughput {
2120    #[serde(rename = "ReadCapacityUnits", alias = "read_capacity_units", default)]
2121    pub read_capacity_units: Option<i64>,
2122    #[serde(rename = "WriteCapacityUnits", alias = "write_capacity_units", default)]
2123    pub write_capacity_units: Option<i64>,
2124}
2125
2126/// On-demand (PAY_PER_REQUEST) throughput ceilings (stored but not enforced).
2127#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2128pub struct OnDemandThroughput {
2129    #[serde(
2130        rename = "MaxReadRequestUnits",
2131        alias = "max_read_request_units",
2132        default,
2133        skip_serializing_if = "Option::is_none"
2134    )]
2135    pub max_read_request_units: Option<i64>,
2136    #[serde(
2137        rename = "MaxWriteRequestUnits",
2138        alias = "max_write_request_units",
2139        default,
2140        skip_serializing_if = "Option::is_none"
2141    )]
2142    pub max_write_request_units: Option<i64>,
2143}
2144
2145// ---------------------------------------------------------------------------
2146// Type conversion: From<T> / TryFrom<T> for AttributeValue
2147// ---------------------------------------------------------------------------
2148
2149/// Error returned when converting between `AttributeValue` and Rust types.
2150#[derive(Debug, Clone, PartialEq)]
2151pub struct ConversionError {
2152    /// The expected DynamoDB or Rust type.
2153    pub expected: &'static str,
2154    /// The actual DynamoDB type encountered.
2155    pub actual: &'static str,
2156}
2157
2158impl fmt::Display for ConversionError {
2159    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2160        write!(f, "expected {}, got {}", self.expected, self.actual)
2161    }
2162}
2163
2164impl std::error::Error for ConversionError {}
2165
2166// --- From<T> for AttributeValue: infallible conversions ---
2167
2168impl From<String> for AttributeValue {
2169    fn from(value: String) -> Self {
2170        AttributeValue::S(value)
2171    }
2172}
2173
2174impl From<&str> for AttributeValue {
2175    fn from(value: &str) -> Self {
2176        AttributeValue::S(value.to_string())
2177    }
2178}
2179
2180impl From<bool> for AttributeValue {
2181    fn from(value: bool) -> Self {
2182        AttributeValue::BOOL(value)
2183    }
2184}
2185
2186impl From<Vec<u8>> for AttributeValue {
2187    fn from(value: Vec<u8>) -> Self {
2188        AttributeValue::B(value)
2189    }
2190}
2191
2192impl From<&[u8]> for AttributeValue {
2193    fn from(value: &[u8]) -> Self {
2194        AttributeValue::B(value.to_vec())
2195    }
2196}
2197
2198// Integer types - all finite, all fit in DynamoDB's number range.
2199macro_rules! impl_from_integer {
2200    ($($t:ty),+) => {
2201        $(
2202            impl From<$t> for AttributeValue {
2203                fn from(value: $t) -> Self {
2204                    AttributeValue::N(value.to_string())
2205                }
2206            }
2207        )+
2208    };
2209}
2210
2211impl_from_integer!(i8, i16, i32, i64, i128, u8, u16, u32, u64, u128);
2212
2213// Container types
2214impl From<HashMap<String, AttributeValue>> for AttributeValue {
2215    fn from(value: HashMap<String, AttributeValue>) -> Self {
2216        AttributeValue::M(value)
2217    }
2218}
2219
2220impl From<Vec<AttributeValue>> for AttributeValue {
2221    fn from(value: Vec<AttributeValue>) -> Self {
2222        AttributeValue::L(value)
2223    }
2224}
2225
2226impl From<HashSet<String>> for AttributeValue {
2227    fn from(value: HashSet<String>) -> Self {
2228        AttributeValue::SS(value.into_iter().collect())
2229    }
2230}
2231
2232impl From<BTreeSet<String>> for AttributeValue {
2233    fn from(value: BTreeSet<String>) -> Self {
2234        AttributeValue::SS(value.into_iter().collect())
2235    }
2236}
2237
2238// --- TryFrom<T> for AttributeValue: fallible conversions (floats) ---
2239
2240impl TryFrom<f64> for AttributeValue {
2241    type Error = ConversionError;
2242
2243    fn try_from(value: f64) -> std::result::Result<Self, Self::Error> {
2244        if value.is_finite() {
2245            Ok(AttributeValue::N(value.to_string()))
2246        } else {
2247            Err(ConversionError {
2248                expected: "finite f64",
2249                actual: "NaN or Infinity",
2250            })
2251        }
2252    }
2253}
2254
2255impl TryFrom<f32> for AttributeValue {
2256    type Error = ConversionError;
2257
2258    fn try_from(value: f32) -> std::result::Result<Self, Self::Error> {
2259        if value.is_finite() {
2260            Ok(AttributeValue::N(value.to_string()))
2261        } else {
2262            Err(ConversionError {
2263                expected: "finite f32",
2264                actual: "NaN or Infinity",
2265            })
2266        }
2267    }
2268}
2269
2270// --- TryFrom<AttributeValue> for T: extract Rust types from AV ---
2271
2272impl TryFrom<AttributeValue> for String {
2273    type Error = ConversionError;
2274
2275    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
2276        match value {
2277            AttributeValue::S(s) => Ok(s),
2278            other => Err(ConversionError {
2279                expected: "S",
2280                actual: other.type_name(),
2281            }),
2282        }
2283    }
2284}
2285
2286impl TryFrom<AttributeValue> for bool {
2287    type Error = ConversionError;
2288
2289    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
2290        match value {
2291            AttributeValue::BOOL(b) => Ok(b),
2292            other => Err(ConversionError {
2293                expected: "BOOL",
2294                actual: other.type_name(),
2295            }),
2296        }
2297    }
2298}
2299
2300impl TryFrom<AttributeValue> for Vec<u8> {
2301    type Error = ConversionError;
2302
2303    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
2304        match value {
2305            AttributeValue::B(b) => Ok(b),
2306            other => Err(ConversionError {
2307                expected: "B",
2308                actual: other.type_name(),
2309            }),
2310        }
2311    }
2312}
2313
2314macro_rules! impl_try_from_av_integer {
2315    ($($t:ty),+) => {
2316        $(
2317            impl TryFrom<AttributeValue> for $t {
2318                type Error = ConversionError;
2319
2320                fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
2321                    match value {
2322                        AttributeValue::N(n) => n.parse::<$t>().map_err(|_| ConversionError {
2323                            expected: stringify!($t),
2324                            actual: "N (parse failed)",
2325                        }),
2326                        other => Err(ConversionError {
2327                            expected: "N",
2328                            actual: other.type_name(),
2329                        }),
2330                    }
2331                }
2332            }
2333        )+
2334    };
2335}
2336
2337impl_try_from_av_integer!(i8, i16, i32, i64, i128, u8, u16, u32, u64, u128);
2338
2339impl TryFrom<AttributeValue> for f64 {
2340    type Error = ConversionError;
2341
2342    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
2343        match value {
2344            AttributeValue::N(n) => n.parse::<f64>().map_err(|_| ConversionError {
2345                expected: "f64",
2346                actual: "N (parse failed)",
2347            }),
2348            other => Err(ConversionError {
2349                expected: "N",
2350                actual: other.type_name(),
2351            }),
2352        }
2353    }
2354}
2355
2356impl TryFrom<AttributeValue> for f32 {
2357    type Error = ConversionError;
2358
2359    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
2360        match value {
2361            AttributeValue::N(n) => n.parse::<f32>().map_err(|_| ConversionError {
2362                expected: "f32",
2363                actual: "N (parse failed)",
2364            }),
2365            other => Err(ConversionError {
2366                expected: "N",
2367                actual: other.type_name(),
2368            }),
2369        }
2370    }
2371}
2372
2373impl TryFrom<AttributeValue> for HashMap<String, AttributeValue> {
2374    type Error = ConversionError;
2375
2376    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
2377        match value {
2378            AttributeValue::M(m) => Ok(m),
2379            other => Err(ConversionError {
2380                expected: "M",
2381                actual: other.type_name(),
2382            }),
2383        }
2384    }
2385}
2386
2387impl TryFrom<AttributeValue> for Vec<AttributeValue> {
2388    type Error = ConversionError;
2389
2390    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
2391        match value {
2392            AttributeValue::L(l) => Ok(l),
2393            other => Err(ConversionError {
2394                expected: "L",
2395                actual: other.type_name(),
2396            }),
2397        }
2398    }
2399}
2400
2401impl TryFrom<AttributeValue> for Vec<String> {
2402    type Error = ConversionError;
2403
2404    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
2405        match value {
2406            AttributeValue::SS(ss) => Ok(ss),
2407            AttributeValue::L(l) => {
2408                // Lenient: extract S values from a list
2409                l.into_iter()
2410                    .map(|av| match av {
2411                        AttributeValue::S(s) => Ok(s),
2412                        other => Err(ConversionError {
2413                            expected: "S (within L)",
2414                            actual: other.type_name(),
2415                        }),
2416                    })
2417                    .collect()
2418            }
2419            other => Err(ConversionError {
2420                expected: "SS or L",
2421                actual: other.type_name(),
2422            }),
2423        }
2424    }
2425}
2426
2427#[cfg(test)]
2428mod tests {
2429    use super::*;
2430
2431    #[test]
2432    fn test_serialize_string() {
2433        let val = AttributeValue::S("hello".to_string());
2434        let json = serde_json::to_string(&val).unwrap();
2435        assert_eq!(json, r#"{"S":"hello"}"#);
2436    }
2437
2438    #[test]
2439    fn test_serialize_number() {
2440        let val = AttributeValue::N("42".to_string());
2441        let json = serde_json::to_string(&val).unwrap();
2442        assert_eq!(json, r#"{"N":"42"}"#);
2443    }
2444
2445    #[test]
2446    fn test_serialize_binary() {
2447        let val = AttributeValue::B(vec![1, 2, 3]);
2448        let json = serde_json::to_string(&val).unwrap();
2449        assert_eq!(json, r#"{"B":"AQID"}"#);
2450    }
2451
2452    #[test]
2453    fn test_serialize_bool() {
2454        let val = AttributeValue::BOOL(true);
2455        let json = serde_json::to_string(&val).unwrap();
2456        assert_eq!(json, r#"{"BOOL":true}"#);
2457    }
2458
2459    #[test]
2460    fn test_serialize_null() {
2461        let val = AttributeValue::NULL(true);
2462        let json = serde_json::to_string(&val).unwrap();
2463        assert_eq!(json, r#"{"NULL":true}"#);
2464    }
2465
2466    #[test]
2467    fn test_deserialize_null_true() {
2468        let val: AttributeValue = serde_json::from_str(r#"{"NULL":true}"#).unwrap();
2469        assert_eq!(val, AttributeValue::NULL(true));
2470    }
2471
2472    #[test]
2473    fn test_deserialize_null_false_rejected() {
2474        // AWS requires the NULL member to be exactly `true`; {"NULL": false} is
2475        // rejected with a ValidationException, same as a non-boolean value.
2476        let err = serde_json::from_str::<AttributeValue>(r#"{"NULL":false}"#).unwrap_err();
2477        assert!(
2478            err.to_string().contains("must have the value of true"),
2479            "unexpected error: {err}"
2480        );
2481    }
2482
2483    #[test]
2484    fn test_deserialize_null_non_boolean_rejected() {
2485        // A non-boolean NULL (e.g. {"NULL": "no"}) is a type error, not a value.
2486        let err = serde_json::from_str::<AttributeValue>(r#"{"NULL":"no"}"#).unwrap_err();
2487        assert!(
2488            err.to_string().contains("must have the value of true"),
2489            "unexpected error: {err}"
2490        );
2491    }
2492
2493    #[test]
2494    fn test_serialize_string_set() {
2495        let val = AttributeValue::SS(vec!["a".to_string(), "b".to_string()]);
2496        let json = serde_json::to_string(&val).unwrap();
2497        assert_eq!(json, r#"{"SS":["a","b"]}"#);
2498    }
2499
2500    #[test]
2501    fn test_serialize_list() {
2502        let val = AttributeValue::L(vec![
2503            AttributeValue::S("hello".to_string()),
2504            AttributeValue::N("42".to_string()),
2505        ]);
2506        let json = serde_json::to_string(&val).unwrap();
2507        assert_eq!(json, r#"{"L":[{"S":"hello"},{"N":"42"}]}"#);
2508    }
2509
2510    #[test]
2511    fn test_serialize_map() {
2512        let mut m = HashMap::new();
2513        m.insert("key".to_string(), AttributeValue::S("value".to_string()));
2514        let val = AttributeValue::M(m);
2515        let json = serde_json::to_string(&val).unwrap();
2516        assert_eq!(json, r#"{"M":{"key":{"S":"value"}}}"#);
2517    }
2518
2519    #[test]
2520    fn test_round_trip_all_types() {
2521        let values = vec![
2522            AttributeValue::S("hello".to_string()),
2523            AttributeValue::N("42.5".to_string()),
2524            AttributeValue::B(vec![0, 255, 128]),
2525            AttributeValue::BOOL(false),
2526            AttributeValue::NULL(true),
2527            AttributeValue::SS(vec!["x".to_string(), "y".to_string()]),
2528            AttributeValue::NS(vec!["1".to_string(), "2.5".to_string()]),
2529            AttributeValue::BS(vec![vec![1], vec![2, 3]]),
2530            AttributeValue::L(vec![
2531                AttributeValue::S("nested".to_string()),
2532                AttributeValue::N("99".to_string()),
2533            ]),
2534        ];
2535
2536        for val in values {
2537            let json = serde_json::to_string(&val).unwrap();
2538            let deserialized: AttributeValue = serde_json::from_str(&json).unwrap();
2539            assert_eq!(val, deserialized, "Round-trip failed for {json}");
2540        }
2541    }
2542
2543    #[test]
2544    fn test_size_string() {
2545        let val = AttributeValue::S("hello".to_string());
2546        assert_eq!(val.size(), 5);
2547    }
2548
2549    #[test]
2550    fn test_size_number() {
2551        // "42" has 2 significant digits → ceil(2/2) + 1 = 2
2552        let val = AttributeValue::N("42".to_string());
2553        assert_eq!(val.size(), 2);
2554    }
2555
2556    #[test]
2557    fn test_size_number_matches_dynamodb() {
2558        // Byte-exact ground truth, measured against eu-west-2 by bisecting the
2559        // 400KB item-size gate (which resolves to the byte, unlike the 1KB
2560        // granularity of consumed capacity). A number costs
2561        // ceil(integer significant digits / 2) + ceil(fraction significant
2562        // digits / 2) + 1, over the value with leading and trailing zeros
2563        // trimmed. The count is a property of the significant digits, so it does
2564        // not change when scientific notation is expanded on storage.
2565        let cases: &[(&str, usize)] = &[
2566            // digit counts either side of the decimal point
2567            ("1", 2),
2568            ("12", 2),
2569            ("123", 3),
2570            ("1234", 3),
2571            ("12345", 4),
2572            ("123456", 4),
2573            ("1234567", 5),
2574            ("12345678", 5),
2575            ("123456789", 6),
2576            ("1234567891", 6),
2577            ("12345678912", 7),
2578            ("123456789123", 7),
2579            ("1234567890123456789", 11),
2580            ("12345678901234567890123456789012345678", 20),
2581            // zeros the wire carries but DynamoDB trims
2582            ("0042", 2),
2583            ("100", 2),
2584            ("1010", 3),
2585            ("0.0000001", 2),
2586            ("0", 1),
2587            // scientific notation, which expands on storage without growing
2588            ("1E125", 2),
2589            ("1E-100", 2),
2590            // significant digits straddling the decimal point are counted in two
2591            // halves, so these cost a byte more than the digit total alone implies
2592            ("1.5", 3),
2593            ("1.2", 3),
2594            ("1.200", 3),
2595            ("1.234", 4),
2596            ("3.14159", 5),
2597            ("100.5", 4),
2598            ("0.15", 2),
2599            ("15", 2),
2600            // a sign costs a byte
2601            ("-42", 3),
2602            ("-12345", 5),
2603            ("-1E125", 3),
2604            ("-0.15", 3),
2605            ("-0", 1),
2606        ];
2607        for (literal, expected) in cases {
2608            assert_eq!(
2609                AttributeValue::N((*literal).to_string()).size(),
2610                *expected,
2611                "size of N {literal:?}"
2612            );
2613        }
2614    }
2615
2616    #[test]
2617    fn test_size_number_survives_normalisation() {
2618        // The reason the write paths could disagree: sizing has to be invariant
2619        // under the normalisation that happens on storage, or the same item
2620        // measures differently either side of it.
2621        for literal in [
2622            "1E125",
2623            "1E-100",
2624            "0042",
2625            "1.200",
2626            "0.0000001",
2627            "100",
2628            "3.14159",
2629            "42",
2630        ] {
2631            let raw = AttributeValue::N(literal.to_string()).size();
2632            let normalised = AttributeValue::N(normalize_dynamo_number(literal)).size();
2633            assert_eq!(
2634                raw, normalised,
2635                "N {literal:?} changed size when normalised"
2636            );
2637        }
2638    }
2639
2640    #[test]
2641    fn test_size_bool() {
2642        assert_eq!(AttributeValue::BOOL(true).size(), 1);
2643    }
2644
2645    #[test]
2646    fn test_size_null() {
2647        assert_eq!(AttributeValue::NULL(true).size(), 1);
2648    }
2649
2650    #[test]
2651    fn test_key_string_s() {
2652        let val = AttributeValue::S("hello".to_string());
2653        assert_eq!(val.to_key_string(), Some("S:hello".to_string()));
2654    }
2655
2656    #[test]
2657    fn test_key_string_n() {
2658        let val = AttributeValue::N("42".to_string());
2659        let key = val.to_key_string().unwrap();
2660        assert!(key.starts_with("N:"));
2661    }
2662
2663    #[test]
2664    fn test_key_string_b() {
2665        let val = AttributeValue::B(vec![0xff, 0x00, 0xab]);
2666        assert_eq!(val.to_key_string(), Some("B:ff00ab".to_string()));
2667    }
2668
2669    #[test]
2670    fn test_key_string_non_key_type_returns_none() {
2671        assert_eq!(AttributeValue::BOOL(true).to_key_string(), None);
2672        assert_eq!(AttributeValue::L(vec![]).to_key_string(), None);
2673    }
2674
2675    // Number sort key ordering tests
2676    #[test]
2677    fn test_number_sort_ordering() {
2678        let numbers = vec![
2679            "-1000", "-100", "-10", "-1", "-0.5", "-0.001", "0", "0.001", "0.5", "1", "10", "100",
2680            "1000",
2681        ];
2682        let encoded: Vec<String> = numbers
2683            .iter()
2684            .map(|n| normalize_number_for_sort(n))
2685            .collect();
2686
2687        for i in 0..encoded.len() - 1 {
2688            assert!(
2689                encoded[i] < encoded[i + 1],
2690                "Sort order broken: {} ({}) should be < {} ({})",
2691                numbers[i],
2692                encoded[i],
2693                numbers[i + 1],
2694                encoded[i + 1]
2695            );
2696        }
2697    }
2698
2699    #[test]
2700    fn test_number_sort_zero_variants() {
2701        let z1 = normalize_number_for_sort("0");
2702        let z2 = normalize_number_for_sort("-0");
2703        let z3 = normalize_number_for_sort("0.0");
2704        assert_eq!(z1, z2);
2705        assert_eq!(z2, z3);
2706    }
2707
2708    #[test]
2709    fn test_number_sort_decimals() {
2710        let a = normalize_number_for_sort("1.5");
2711        let b = normalize_number_for_sort("2.5");
2712        assert!(a < b);
2713
2714        let c = normalize_number_for_sort("0.001");
2715        let d = normalize_number_for_sort("0.01");
2716        assert!(c < d);
2717    }
2718
2719    #[test]
2720    fn test_number_sort_scientific() {
2721        let a = normalize_number_for_sort("1e10");
2722        let b = normalize_number_for_sort("1e11");
2723        assert!(a < b);
2724
2725        let c = normalize_number_for_sort("-1e11");
2726        let d = normalize_number_for_sort("-1e10");
2727        assert!(c < d);
2728    }
2729
2730    // A library caller can reach the key encoder with a number that never
2731    // went through validation. One far outside DynamoDB's range sorts at the
2732    // matching extreme rather than overflowing the encoded exponent.
2733    #[test]
2734    fn test_number_sort_out_of_range_sorts_at_the_extremes() {
2735        let ordered = [
2736            "-1e99999999999",
2737            "-9.9999999999999999999999999999999999999E125",
2738            "-1E-130",
2739            "-1e-99999999999",
2740            "0",
2741            "1e-99999999999",
2742            "1E-130",
2743            "9.9999999999999999999999999999999999999E125",
2744            "1e99999999999",
2745        ];
2746        for pair in ordered.windows(2) {
2747            let (a, b) = (pair[0], pair[1]);
2748            assert!(
2749                normalize_number_for_sort(a) < normalize_number_for_sort(b),
2750                "{a} should sort before {b}"
2751            );
2752        }
2753    }
2754
2755    // Only an exponent too large for an i64 saturates. Anything else that
2756    // fails to parse keeps reading as 0 for callers that never validated.
2757    #[test]
2758    fn test_split_exponent_reads_an_unparseable_exponent_as_zero() {
2759        assert_eq!(split_exponent("1e"), ("1", 0));
2760        assert_eq!(split_exponent("1e+"), ("1", 0));
2761        assert_eq!(split_exponent("1e99999999999999999999"), ("1", i64::MAX));
2762        assert_eq!(split_exponent("1e-99999999999999999999"), ("1", i64::MIN));
2763    }
2764
2765    // Number validation/normalisation is pinned to real DynamoDB behaviour
2766    // (captured against AWS for issue #109). validate_dynamo_number accepts
2767    // exactly the grammar DynamoDB accepts, including a leading '+'; the bare
2768    // and '+'-prefixed malformed forms are both rejected, matching AWS.
2769    #[test]
2770    fn test_validate_number_accepts_dynamodb_grammar() {
2771        for input in [
2772            "+5", "+1.5", "+0", "-0", "+0.0", "+1e2", "1e+2", "1.5E+3", "-7", "+.5", ".5", "5.",
2773            "00042", "1.23E10", "+1e-2", "1E-130", "+1E-130",
2774        ] {
2775            assert!(
2776                validate_dynamo_number(input).is_ok(),
2777                "expected {input} to validate, got {:?}",
2778                validate_dynamo_number(input)
2779            );
2780        }
2781    }
2782
2783    #[test]
2784    fn test_validate_number_rejects_malformed() {
2785        // Every one of these is rejected by real DynamoDB. Note that whitespace
2786        // (leading, trailing, or internal) is rejected, not trimmed.
2787        for input in [
2788            "+e2", "e2", "+1+2", "1+2", "+1.2.3", "1.2.3", "++5", "+-5", "-+5", "+", "-", "1e",
2789            "1e+", ".", "1.2e3.4", "0x5", "NaN", "Infinity", "1_000", " 5", "5 ", "1 5", "",
2790        ] {
2791            assert!(
2792                matches!(
2793                    validate_dynamo_number(input),
2794                    Err(crate::errors::DynoxideError::ValidationException(_))
2795                ),
2796                "expected {input:?} to be rejected with ValidationException, got {:?}",
2797                validate_dynamo_number(input)
2798            );
2799        }
2800    }
2801
2802    fn validation_message(input: &str) -> Option<String> {
2803        match validate_dynamo_number(input) {
2804            Ok(()) => None,
2805            Err(crate::errors::DynoxideError::ValidationException(m)) => Some(m),
2806            Err(e) => panic!("expected a ValidationException for {input:?}, got {e:?}"),
2807        }
2808    }
2809
2810    const OVERFLOW: &str =
2811        "Number overflow. Attempting to store a number with magnitude larger than supported range";
2812    const UNDERFLOW: &str = "Number underflow. Attempting to store a number with magnitude smaller than supported range";
2813
2814    // An exponent too large for an i32 used to be read as 0, so 1e99999999999
2815    // passed the range checks and was stored as 1. Every expectation here was
2816    // captured against eu-west-2, 2026-10-08.
2817    #[test]
2818    fn test_validate_number_range_checks_exponents_beyond_i32() {
2819        for (input, expected) in [
2820            ("1e99999999999", OVERFLOW),
2821            ("-1e99999999999", OVERFLOW),
2822            ("1E+99999999999", OVERFLOW),
2823            ("1e-99999999999", UNDERFLOW),
2824            ("-1e-99999999999", UNDERFLOW),
2825            ("1e2147483648", OVERFLOW),
2826            ("1e-2147483649", UNDERFLOW),
2827            // Fits in an i32, but the digits before the point push it past.
2828            ("10e2147483647", OVERFLOW),
2829            ("0.01e-2147483648", UNDERFLOW),
2830            ("1e9223372036854775807", OVERFLOW),
2831            ("1e-9223372036854775808", UNDERFLOW),
2832            ("10e9223372036854775807", OVERFLOW),
2833            ("0.01e-9223372036854775808", UNDERFLOW),
2834        ] {
2835            assert_eq!(
2836                validation_message(input).as_deref(),
2837                Some(expected),
2838                "{input}"
2839            );
2840        }
2841    }
2842
2843    // DynamoDB reads the exponent as a 64-bit integer. One that does not fit
2844    // is a value it cannot convert, not an overflow (captured eu-west-2,
2845    // 2026-10-08).
2846    #[test]
2847    fn test_validate_number_rejects_exponent_beyond_i64_as_unconvertible() {
2848        for input in [
2849            "1e9223372036854775808",
2850            "1e+9223372036854775808",
2851            "1e-9223372036854775809",
2852            "0e9223372036854775808",
2853        ] {
2854            assert_eq!(
2855                validation_message(input),
2856                Some(format!(
2857                    "The parameter cannot be converted to a numeric value: {input}"
2858                )),
2859                "{input}"
2860            );
2861        }
2862    }
2863
2864    // A zero is range-checked as if its last written digit were a 1, so
2865    // `0e126` overflows like `1e126` and `0.0e-130` underflows like
2866    // `0.1e-130`. Captured eu-west-2, 2026-10-08.
2867    #[test]
2868    fn test_validate_number_range_checks_zero_by_its_last_written_digit() {
2869        let zeros_130 = format!("0.{}", "0".repeat(130));
2870        let zeros_131 = format!("0.{}", "0".repeat(131));
2871        for (input, expected) in [
2872            ("0e125", None),
2873            ("0e126", Some(OVERFLOW)),
2874            ("0.0e126", None),
2875            ("0.0e127", Some(OVERFLOW)),
2876            ("0.00e127", None),
2877            ("00e126", Some(OVERFLOW)),
2878            ("00.00e126", None),
2879            ("-0e127", Some(OVERFLOW)),
2880            ("0e99999999999", Some(OVERFLOW)),
2881            ("0e-130", None),
2882            ("0e-131", Some(UNDERFLOW)),
2883            ("0.0e-129", None),
2884            ("0.0e-130", Some(UNDERFLOW)),
2885            ("0e-99999999999", Some(UNDERFLOW)),
2886            (zeros_130.as_str(), None),
2887            (zeros_131.as_str(), Some(UNDERFLOW)),
2888        ] {
2889            assert_eq!(validation_message(input).as_deref(), expected, "{input}");
2890        }
2891    }
2892
2893    #[test]
2894    fn test_normalize_number_matches_dynamodb() {
2895        for (input, stored) in [
2896            ("+5", "5"),
2897            ("+1.5", "1.5"),
2898            ("+0", "0"),
2899            ("-0", "0"),
2900            ("+0.0", "0"),
2901            ("+1e2", "100"),
2902            ("1e+2", "100"),
2903            ("1.5E+3", "1500"),
2904            ("-7", "-7"),
2905            ("+.5", "0.5"),
2906            (".5", "0.5"),
2907            ("5.", "5"),
2908            ("00042", "42"),
2909            ("1.23E10", "12300000000"),
2910            ("+1e-2", "0.01"),
2911        ] {
2912            assert_eq!(
2913                normalize_dynamo_number(input),
2914                stored,
2915                "{input} should normalise to {stored}"
2916            );
2917        }
2918    }
2919
2920    #[test]
2921    fn test_type_name() {
2922        assert_eq!(AttributeValue::S("".to_string()).type_name(), "S");
2923        assert_eq!(AttributeValue::N("0".to_string()).type_name(), "N");
2924        assert_eq!(AttributeValue::B(vec![]).type_name(), "B");
2925        assert_eq!(AttributeValue::BOOL(true).type_name(), "BOOL");
2926        assert_eq!(AttributeValue::NULL(true).type_name(), "NULL");
2927        assert_eq!(AttributeValue::SS(vec![]).type_name(), "SS");
2928        assert_eq!(AttributeValue::NS(vec![]).type_name(), "NS");
2929        assert_eq!(AttributeValue::BS(vec![]).type_name(), "BS");
2930        assert_eq!(AttributeValue::L(vec![]).type_name(), "L");
2931        assert_eq!(AttributeValue::M(HashMap::new()).type_name(), "M");
2932    }
2933}