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/// The key attribute a stored key string (see [`AttributeValue::to_key_string`])
1299/// came from, or `None` for a string that is not one. A number comes back in a
1300/// form that has the same value, which is all its hash depends on; the
1301/// sortable encoding keeps 40 digits, more than DynamoDB's 38.
1302pub(crate) fn key_value_from_key_string(key: &str) -> Option<AttributeValue> {
1303    let (kind, body) = key.split_once(':')?;
1304    match kind {
1305        "S" => Some(AttributeValue::S(body.to_string())),
1306        "B" => hex_decode(body).map(AttributeValue::B),
1307        "N" => number_from_sortable(body).map(AttributeValue::N),
1308        _ => None,
1309    }
1310}
1311
1312/// Undo [`normalize_number_for_sort`]: a sign digit, a four-digit exponent
1313/// offset by 5000 and a 40-digit mantissa, both complemented for a negative.
1314fn number_from_sortable(body: &str) -> Option<String> {
1315    if body.len() != 45 || !body.bytes().all(|b| b.is_ascii_digit()) {
1316        return None;
1317    }
1318    let (sign, rest) = body.split_at(1);
1319    let (exponent, mantissa) = rest.split_at(4);
1320    let exponent: i32 = exponent.parse().ok()?;
1321    let (negative, exponent, mantissa): (bool, i32, String) = match sign {
1322        "1" => return Some("0".to_string()),
1323        "2" => (false, exponent - 5000, mantissa.to_string()),
1324        "0" => (
1325            true,
1326            (9999 - exponent) - 5000,
1327            mantissa
1328                .bytes()
1329                .map(|b| (b'0' + (9 - (b - b'0'))) as char)
1330                .collect(),
1331        ),
1332        _ => return None,
1333    };
1334    let digits = mantissa.trim_end_matches('0');
1335    if digits.is_empty() {
1336        return Some("0".to_string());
1337    }
1338    Some(format!(
1339        "{}0.{digits}E{exponent}",
1340        if negative { "-" } else { "" }
1341    ))
1342}
1343
1344fn hex_decode(hex: &str) -> Option<Vec<u8>> {
1345    if !hex.len().is_multiple_of(2) {
1346        return None;
1347    }
1348    (0..hex.len())
1349        .step_by(2)
1350        .map(|i| u8::from_str_radix(hex.get(i..i + 2)?, 16).ok())
1351        .collect()
1352}
1353
1354/// Hex-encode bytes (lowercase) for binary key storage.
1355fn hex_encode(bytes: &[u8]) -> String {
1356    let mut s = String::with_capacity(bytes.len() * 2);
1357    for &b in bytes {
1358        s.push_str(&format!("{b:02x}"));
1359    }
1360    s
1361}
1362
1363// ---------------------------------------------------------------------------
1364// Item helpers
1365// ---------------------------------------------------------------------------
1366
1367/// A DynamoDB item: a map of attribute names to values.
1368pub type Item = HashMap<String, AttributeValue>;
1369
1370/// SSE specification for server-side encryption settings.
1371#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1372pub struct SseSpecification {
1373    #[serde(rename = "Enabled", default)]
1374    pub enabled: Option<bool>,
1375    #[serde(rename = "SSEType", default)]
1376    pub sse_type: Option<String>,
1377    #[serde(rename = "KMSMasterKeyId", default)]
1378    pub kms_master_key_id: Option<String>,
1379}
1380
1381/// DynamoDB Tag (key-value pair attached to a resource).
1382#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1383pub struct Tag {
1384    #[serde(rename = "Key")]
1385    pub key: String,
1386    #[serde(rename = "Value")]
1387    pub value: String,
1388}
1389
1390/// Calculate the total size of a DynamoDB item in bytes.
1391pub fn item_size(item: &Item) -> usize {
1392    item.iter()
1393        .map(|(name, value)| name.len() + value.size())
1394        .sum()
1395}
1396
1397/// The bytes DynamoDB bills a vector at: its f32 width, 4 bytes per
1398/// dimension, whatever numbers it holds. Shared by the base item's billed size
1399/// and the vector index entry.
1400pub(crate) fn vector_billed_bytes(dimensions: u32) -> usize {
1401    4 * dimensions as usize
1402}
1403
1404/// The size DynamoDB bills a write of `item` at, and the figure TableSizeBytes
1405/// sums, on a table whose vector indexes are `vector_indexes`.
1406///
1407/// An attribute a vector index reads costs its name plus
1408/// [`vector_billed_bytes`], once however many indexes read it; everything else
1409/// is sized as [`item_size`] sizes it. Read units and the 400 KB limit still
1410/// use [`item_size`]: DynamoDB sizes the vector by its numbers for both.
1411/// Captured eu-west-2 and us-east-1, 2026-10-09, at 1, 3, 6, 8, 256 and 1,024
1412/// dimensions, with two indexes on different attributes charged their own
1413/// dimensions each.
1414///
1415/// A value that is not a valid vector for its index is sized as usual. A live
1416/// write refuses one, so only an item written before the index existed can
1417/// hold one, and the index skips it too.
1418pub(crate) fn billed_item_size(item: &Item, vector_indexes: &[VectorIndex]) -> usize {
1419    if vector_indexes.is_empty() {
1420        return item_size(item);
1421    }
1422    item.iter()
1423        .map(|(name, value)| {
1424            let billed = vector_indexes
1425                .iter()
1426                .filter(|vix| vix.vector_attribute.attribute_name == *name)
1427                .find(|vix| vector_f32_values(value, vix.dimensions).is_some())
1428                .map(|vix| vector_billed_bytes(vix.dimensions));
1429            name.len() + billed.unwrap_or_else(|| value.size())
1430        })
1431        .sum()
1432}
1433
1434/// Maximum item size in bytes (400 KB).
1435pub const MAX_ITEM_SIZE: usize = 400 * 1024;
1436
1437/// ItemCollectionMetrics returned when `ReturnItemCollectionMetrics: SIZE` is set
1438/// and the table has local secondary indexes.
1439#[derive(Debug, Clone, Serialize, Deserialize)]
1440pub struct ItemCollectionMetrics {
1441    #[serde(rename = "ItemCollectionKey")]
1442    pub item_collection_key: HashMap<String, AttributeValue>,
1443    #[serde(rename = "SizeEstimateRangeGB")]
1444    pub size_estimate_range_gb: Vec<f64>,
1445}
1446
1447/// ConsumedCapacity returned when `ReturnConsumedCapacity` is set.
1448#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1449pub struct ConsumedCapacity {
1450    #[serde(rename = "TableName")]
1451    pub table_name: String,
1452    #[serde(rename = "CapacityUnits")]
1453    pub capacity_units: f64,
1454    #[serde(rename = "ReadCapacityUnits", skip_serializing_if = "Option::is_none")]
1455    pub read_capacity_units: Option<f64>,
1456    #[serde(rename = "WriteCapacityUnits", skip_serializing_if = "Option::is_none")]
1457    pub write_capacity_units: Option<f64>,
1458    #[serde(rename = "Table", skip_serializing_if = "Option::is_none")]
1459    pub table: Option<CapacityDetail>,
1460    #[serde(
1461        rename = "GlobalSecondaryIndexes",
1462        skip_serializing_if = "Option::is_none"
1463    )]
1464    pub global_secondary_indexes: Option<HashMap<String, CapacityDetail>>,
1465    #[serde(
1466        rename = "LocalSecondaryIndexes",
1467        skip_serializing_if = "Option::is_none"
1468    )]
1469    pub local_secondary_indexes: Option<HashMap<String, CapacityDetail>>,
1470    /// Per-vector-index replication cost. Reported under `INDEXES` alone, and
1471    /// never folded into `capacity_units`: vector replication is billed in
1472    /// bytes on its own axis rather than in capacity units.
1473    #[serde(rename = "VectorIndexes", skip_serializing_if = "Option::is_none")]
1474    pub vector_indexes: Option<HashMap<String, VectorCapacityDetail>>,
1475}
1476
1477/// Per-resource capacity detail.
1478#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1479pub struct CapacityDetail {
1480    #[serde(rename = "CapacityUnits")]
1481    pub capacity_units: f64,
1482    #[serde(rename = "ReadCapacityUnits", skip_serializing_if = "Option::is_none")]
1483    pub read_capacity_units: Option<f64>,
1484    #[serde(rename = "WriteCapacityUnits", skip_serializing_if = "Option::is_none")]
1485    pub write_capacity_units: Option<f64>,
1486}
1487
1488/// One vector index's replication cost for a write.
1489///
1490/// Vector indexes are billed in bytes rather than capacity units, so this
1491/// carries no `CapacityUnits` and the figure never reaches the response's
1492/// total. An index a write leaves alone is absent from the map rather than
1493/// present and zeroed (captured 2026-08-11, eu-west-2).
1494#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1495pub struct VectorCapacityDetail {
1496    #[serde(rename = "VectorWriteRequestBytes")]
1497    pub vector_write_request_bytes: f64,
1498}
1499
1500/// The `ConsumedCapacity` a `SearchVectors` response carries.
1501///
1502/// The shape has no `CapacityUnits` and no `TableName`, reads identically under
1503/// `TOTAL` and `INDEXES`, and is absent under `NONE` (captured 2026-08-11,
1504/// eu-west-2). Despite the field name, real DynamoDB bills the figure on the
1505/// data the search read rather than on the request, and does not reproduce it
1506/// between identical calls; Dynoxide reports a deterministic stand-in instead.
1507#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1508pub struct VectorSearchCapacity {
1509    #[serde(rename = "VectorSearchRequestBytes")]
1510    pub vector_search_request_bytes: f64,
1511}
1512
1513/// The billable minimum for a vector request, in bytes.
1514///
1515/// DynamoDB documents a 1KB minimum on vector billing and the capture observed
1516/// 1024.0 for a three-dimensional fixture, which is this floor rather than a
1517/// constant.
1518///
1519/// The two axes above the floor are not on the same footing. The write figure
1520/// is captured byte-exact (eu-west-2, 2026-08-18): five fixtures from 3 to 512
1521/// dimensions fit `4 * dimensions + vector attribute name + item size of the
1522/// rest of the projected entry` with no residual, and the tests fail on a
1523/// one-byte error. The search figure has no oracle at all, because real
1524/// DynamoDB does not reproduce its own, so what Dynoxide reports there is a
1525/// deterministic stand-in rather than a match.
1526pub const MIN_VECTOR_REQUEST_BYTES: f64 = 1024.0;
1527
1528/// The billable byte figure for a vector request measuring `bytes`.
1529pub fn vector_request_bytes(bytes: usize) -> f64 {
1530    (bytes as f64).max(MIN_VECTOR_REQUEST_BYTES)
1531}
1532
1533/// The transactional capacity multiplier. `TransactWriteItems` and
1534/// `TransactGetItems` cost twice the equivalent single-item operation, so each
1535/// item's rounded-up units are doubled (the rounding happens per item, before
1536/// the multiplier, to match AWS at the KB/4KB boundary).
1537pub const TRANSACTIONAL_CAPACITY_FACTOR: f64 = 2.0;
1538
1539/// Calculate write capacity units (1 WCU = 1KB, rounded up).
1540pub fn write_capacity_units(item_size_bytes: usize) -> f64 {
1541    ((item_size_bytes as f64) / 1024.0).ceil().max(1.0)
1542}
1543
1544/// Write capacity units the base table consumes for a single write.
1545///
1546/// DynamoDB sizes a write on the larger of the item's before and after images,
1547/// so shrinking an item still costs what the old one did. `old_size` is `None`
1548/// when nothing was there beforehand, and `new_size` is `None` on a delete,
1549/// where only the old image exists.
1550pub fn table_write_capacity_units(old_size: Option<usize>, new_size: Option<usize>) -> f64 {
1551    let old = old_size.map(write_capacity_units);
1552    let new = new_size.map(write_capacity_units);
1553    match (old, new) {
1554        (Some(old), Some(new)) => old.max(new),
1555        (Some(units), None) | (None, Some(units)) => units,
1556        // No image either side is not a write DynamoDB would charge for, but a
1557        // write is never free, so fall back to the one-unit minimum.
1558        (None, None) => write_capacity_units(0),
1559    }
1560}
1561
1562/// Read capacity units one write's images cost when charged as a read.
1563///
1564/// A same-token transactional replay is billed as a read against the image the
1565/// original write was sized on, which is the larger of the before and after
1566/// images, but rounded at 4KB rather than 1KB. Captured in eu-west-2: a replayed
1567/// put that shrank a 9KB item to nothing reports 6, and so does the replay of
1568/// the write that grew it, so neither side alone explains the figure.
1569pub fn table_read_capacity_units(old_size: Option<usize>, new_size: Option<usize>) -> f64 {
1570    let larger = old_size.unwrap_or(0).max(new_size.unwrap_or(0));
1571    read_capacity_units(larger)
1572}
1573
1574/// Calculate read capacity units assuming strongly consistent reads
1575/// (1 RCU per 4KB, rounded up). Used when ConsistentRead is true or
1576/// when the read type is not specified.
1577pub fn read_capacity_units(item_size_bytes: usize) -> f64 {
1578    ((item_size_bytes as f64) / 4096.0).ceil().max(1.0)
1579}
1580
1581/// Calculate read capacity units accounting for consistency mode.
1582///
1583/// Strongly consistent: 1 RCU per 4KB, rounded up.
1584/// Eventually consistent: 0.5 RCU per 4KB (half the strongly consistent rate).
1585pub fn read_capacity_units_with_consistency(item_size_bytes: usize, consistent: bool) -> f64 {
1586    let strongly = read_capacity_units(item_size_bytes);
1587    if consistent { strongly } else { strongly / 2.0 }
1588}
1589
1590/// Whether a `ReturnConsumedCapacity` value asks for a figure at all.
1591///
1592/// Every `consumed_capacity*` builder below throws its inputs away unless the
1593/// mode is `TOTAL` or `INDEXES`, so work done only to feed them is wasted in
1594/// every other mode, and `NONE` is the default. Callers that can skip that work
1595/// ask here rather than each spelling the comparison out, so the set of modes
1596/// that mean "yes" is written down once.
1597pub fn capacity_wanted(mode: Option<&str>) -> bool {
1598    matches!(mode, Some("TOTAL") | Some("INDEXES"))
1599}
1600
1601/// Whether a `ReturnConsumedCapacity` mode carries the vector write map.
1602///
1603/// Narrower than [`capacity_wanted`], because the per-index vector map reaches
1604/// the wire under `INDEXES` alone (captured 2026-08-11, eu-west-2); `TOTAL`
1605/// asks for capacity and still carries no vector fields. The sizing costs a
1606/// derivation of the old image per index, so the fan-out asks here and skips
1607/// the work rather than doing it and having the response builder drop it. Both
1608/// ends read the same predicate so they cannot disagree about which modes mean
1609/// yes.
1610pub fn vector_capacity_wanted(mode: Option<&str>) -> bool {
1611    matches!(mode, Some("INDEXES"))
1612}
1613
1614/// Build a `ConsumedCapacity` for a simple table operation.
1615pub fn consumed_capacity(
1616    table_name: &str,
1617    capacity_units: f64,
1618    mode: &Option<String>,
1619) -> Option<ConsumedCapacity> {
1620    let mode = mode.as_deref().unwrap_or("NONE");
1621    match mode {
1622        "TOTAL" => Some(ConsumedCapacity {
1623            table_name: table_name.to_string(),
1624            capacity_units,
1625            table: None,
1626            global_secondary_indexes: None,
1627            local_secondary_indexes: None,
1628            ..Default::default()
1629        }),
1630        "INDEXES" => Some(ConsumedCapacity {
1631            table_name: table_name.to_string(),
1632            capacity_units,
1633            table: Some(CapacityDetail {
1634                capacity_units,
1635                ..Default::default()
1636            }),
1637            global_secondary_indexes: None,
1638            local_secondary_indexes: None,
1639            ..Default::default()
1640        }),
1641        _ => None,
1642    }
1643}
1644
1645/// Build a `ConsumedCapacity` with per-GSI breakdown for INDEXES mode.
1646pub fn consumed_capacity_with_indexes(
1647    table_name: &str,
1648    table_units: f64,
1649    gsi_units: &HashMap<String, f64>,
1650    mode: &Option<String>,
1651) -> Option<ConsumedCapacity> {
1652    consumed_capacity_with_secondary_indexes(
1653        table_name,
1654        table_units,
1655        gsi_units,
1656        &HashMap::new(),
1657        mode,
1658    )
1659}
1660
1661/// Build a `ConsumedCapacity` with per-GSI and per-LSI breakdown for INDEXES mode.
1662pub fn consumed_capacity_with_secondary_indexes(
1663    table_name: &str,
1664    table_units: f64,
1665    gsi_units: &HashMap<String, f64>,
1666    lsi_units: &HashMap<String, f64>,
1667    mode: &Option<String>,
1668) -> Option<ConsumedCapacity> {
1669    secondary_index_capacity(
1670        table_name,
1671        table_units,
1672        gsi_units,
1673        lsi_units,
1674        mode,
1675        CapacityAxis::None,
1676    )
1677}
1678
1679/// Whether a response mirrors its units into a named axis alongside
1680/// `CapacityUnits`.
1681///
1682/// Single-item and PartiQL responses report `CapacityUnits` alone at every
1683/// level. Transactional responses mirror the units into `WriteCapacityUnits`
1684/// at every level, including each index arm. Both shapes are captured against
1685/// real DynamoDB.
1686#[derive(Clone, Copy, PartialEq)]
1687enum CapacityAxis {
1688    None,
1689    Write,
1690}
1691
1692impl CapacityAxis {
1693    /// The `WriteCapacityUnits` value for a detail carrying `units`.
1694    fn write_units(self, units: f64) -> Option<f64> {
1695        match self {
1696            Self::None => None,
1697            Self::Write => Some(units),
1698        }
1699    }
1700}
1701
1702/// Shared body for the per-index capacity builders. `table_units` carries the
1703/// transactional factor already, when one applies; the index maps never do.
1704fn secondary_index_capacity(
1705    table_name: &str,
1706    table_units: f64,
1707    gsi_units: &HashMap<String, f64>,
1708    lsi_units: &HashMap<String, f64>,
1709    mode: &Option<String>,
1710    axis: CapacityAxis,
1711) -> Option<ConsumedCapacity> {
1712    let total = table_units + gsi_units.values().sum::<f64>() + lsi_units.values().sum::<f64>();
1713
1714    match mode.as_deref().unwrap_or("NONE") {
1715        "INDEXES" => Some(ConsumedCapacity {
1716            table_name: table_name.to_string(),
1717            capacity_units: total,
1718            write_capacity_units: axis.write_units(total),
1719            table: Some(CapacityDetail {
1720                capacity_units: table_units,
1721                write_capacity_units: axis.write_units(table_units),
1722                ..Default::default()
1723            }),
1724            global_secondary_indexes: capacity_detail_map(gsi_units, axis),
1725            local_secondary_indexes: capacity_detail_map(lsi_units, axis),
1726            ..Default::default()
1727        }),
1728        "TOTAL" => Some(ConsumedCapacity {
1729            table_name: table_name.to_string(),
1730            capacity_units: total,
1731            write_capacity_units: axis.write_units(total),
1732            ..Default::default()
1733        }),
1734        _ => None,
1735    }
1736}
1737
1738/// Build a `ConsumedCapacity` carrying the classic index arms and the vector
1739/// arm together.
1740///
1741/// The single mode argument is the point: the classic builder and the vector
1742/// attach each need one, and passing them separately leaves a caller free to
1743/// build under one mode and attach under another, which would put a
1744/// `VectorIndexes` map on a `TOTAL`-shaped response.
1745pub fn consumed_capacity_with_vector_indexes(
1746    table_name: &str,
1747    table_units: f64,
1748    gsi_units: &HashMap<String, f64>,
1749    lsi_units: &HashMap<String, f64>,
1750    vector_bytes: &HashMap<String, f64>,
1751    mode: &Option<String>,
1752) -> Option<ConsumedCapacity> {
1753    attach_vector_index_capacity(
1754        consumed_capacity_with_secondary_indexes(
1755            table_name,
1756            table_units,
1757            gsi_units,
1758            lsi_units,
1759            mode,
1760        ),
1761        vector_bytes,
1762        mode,
1763    )
1764}
1765
1766/// Attach per-vector-index write bytes to a capacity report.
1767///
1768/// Vector replication sits on its own axis, so this never touches
1769/// `capacity_units`: the bytes are reported beside the classic arms, not
1770/// summed into them. The map appears under `INDEXES` alone, matching the
1771/// capture, and an empty `vector_bytes` leaves the response untouched so a
1772/// write that changed no vector index reports no map at all rather than an
1773/// empty one.
1774pub(crate) fn attach_vector_index_capacity(
1775    capacity: Option<ConsumedCapacity>,
1776    vector_bytes: &HashMap<String, f64>,
1777    mode: &Option<String>,
1778) -> Option<ConsumedCapacity> {
1779    let mut capacity = capacity?;
1780    if vector_bytes.is_empty() || !vector_capacity_wanted(mode.as_deref()) {
1781        return Some(capacity);
1782    }
1783    capacity.vector_indexes = Some(
1784        vector_bytes
1785            .iter()
1786            .map(|(name, bytes)| {
1787                (
1788                    name.clone(),
1789                    VectorCapacityDetail {
1790                        vector_write_request_bytes: *bytes,
1791                    },
1792                )
1793            })
1794            .collect(),
1795    );
1796    Some(capacity)
1797}
1798
1799/// Build a `ConsumedCapacity` for one table in a transactional read
1800/// (`TransactGetItems`). `units` is the table total and already includes the
1801/// transactional 2x factor. Under `INDEXES` the Table detail reports
1802/// `ReadCapacityUnits` alongside `CapacityUnits`, matching AWS.
1803pub fn transactional_read_capacity(
1804    table_name: &str,
1805    units: f64,
1806    mode: &Option<String>,
1807) -> Option<ConsumedCapacity> {
1808    match mode.as_deref().unwrap_or("NONE") {
1809        "TOTAL" => Some(ConsumedCapacity {
1810            table_name: table_name.to_string(),
1811            capacity_units: units,
1812            read_capacity_units: Some(units),
1813            ..Default::default()
1814        }),
1815        "INDEXES" => Some(ConsumedCapacity {
1816            table_name: table_name.to_string(),
1817            capacity_units: units,
1818            read_capacity_units: Some(units),
1819            table: Some(CapacityDetail {
1820                capacity_units: units,
1821                read_capacity_units: Some(units),
1822                ..Default::default()
1823            }),
1824            ..Default::default()
1825        }),
1826        _ => None,
1827    }
1828}
1829
1830/// Build the per-table `ConsumedCapacity` vec for a transactional op from the
1831/// per-table units, using `builder` (`transactional_write_capacity` for a
1832/// first-call write, `transactional_read_capacity` for a read set or a
1833/// same-token replay). Returns `None` unless `ReturnConsumedCapacity` is
1834/// `TOTAL` or `INDEXES`, so the mode guard lives in one place. Shared by
1835/// `TransactWriteItems` and `ExecuteTransaction`.
1836pub fn build_transactional_capacity(
1837    table_units: &HashMap<String, f64>,
1838    mode: &Option<String>,
1839    builder: fn(&str, f64, &Option<String>) -> Option<ConsumedCapacity>,
1840) -> Option<Vec<ConsumedCapacity>> {
1841    if !capacity_wanted(mode.as_deref()) {
1842        return None;
1843    }
1844
1845    // Sorted by table name, matching the write path. Iterating the map handed
1846    // back a different order per process, so a two-table read set or replay
1847    // could report its tables either way round while the write half of the same
1848    // response family was stable.
1849    let mut tables: Vec<&String> = table_units.keys().collect();
1850    tables.sort();
1851
1852    Some(
1853        tables
1854            .into_iter()
1855            .filter_map(|table| builder(table, *table_units.get(table)?, mode))
1856            .collect(),
1857    )
1858}
1859
1860/// Build a `ConsumedCapacity` for one table in a transactional write, with the
1861/// per-index breakdown.
1862///
1863/// `table_units` already carries the transactional 2x factor; the index maps do
1864/// not. DynamoDB applies that factor to the base table arm alone, so an index
1865/// arm inside a transaction costs what the same write costs outside one.
1866///
1867/// `CapacityUnits` and `WriteCapacityUnits` both report the table arm plus the
1868/// index arms. The `Table` detail reports the table arm on its own, and every
1869/// index arm carries the write axis too, which is where this shape differs from
1870/// the single-item one.
1871pub fn transactional_write_capacity_with_indexes(
1872    table_name: &str,
1873    table_units: f64,
1874    gsi_units: &HashMap<String, f64>,
1875    lsi_units: &HashMap<String, f64>,
1876    mode: &Option<String>,
1877) -> Option<ConsumedCapacity> {
1878    secondary_index_capacity(
1879        table_name,
1880        table_units,
1881        gsi_units,
1882        lsi_units,
1883        mode,
1884        CapacityAxis::Write,
1885    )
1886}
1887
1888/// Turn per-index units into the response's detail map, or `None` when nothing
1889/// was charged so the arm is absent rather than present and empty.
1890fn capacity_detail_map(
1891    units: &HashMap<String, f64>,
1892    axis: CapacityAxis,
1893) -> Option<HashMap<String, CapacityDetail>> {
1894    if units.is_empty() {
1895        return None;
1896    }
1897    Some(
1898        units
1899            .iter()
1900            .map(|(name, &u)| {
1901                (
1902                    name.clone(),
1903                    CapacityDetail {
1904                        capacity_units: u,
1905                        write_capacity_units: axis.write_units(u),
1906                        ..Default::default()
1907                    },
1908                )
1909            })
1910            .collect(),
1911    )
1912}
1913
1914/// Build a `ConsumedCapacity` for one table in a transactional write
1915/// (`TransactWriteItems`). `units` is the table total and already includes the
1916/// transactional 2x factor. Under `INDEXES` the Table detail reports
1917/// `WriteCapacityUnits` alongside `CapacityUnits`, matching AWS.
1918pub fn transactional_write_capacity(
1919    table_name: &str,
1920    units: f64,
1921    mode: &Option<String>,
1922) -> Option<ConsumedCapacity> {
1923    match mode.as_deref().unwrap_or("NONE") {
1924        "TOTAL" => Some(ConsumedCapacity {
1925            table_name: table_name.to_string(),
1926            capacity_units: units,
1927            write_capacity_units: Some(units),
1928            ..Default::default()
1929        }),
1930        "INDEXES" => Some(ConsumedCapacity {
1931            table_name: table_name.to_string(),
1932            capacity_units: units,
1933            write_capacity_units: Some(units),
1934            table: Some(CapacityDetail {
1935                capacity_units: units,
1936                write_capacity_units: Some(units),
1937                ..Default::default()
1938            }),
1939            ..Default::default()
1940        }),
1941        _ => None,
1942    }
1943}
1944
1945/// Key schema element - defines a key attribute.
1946#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1947pub struct KeySchemaElement {
1948    #[serde(rename = "AttributeName", alias = "attribute_name")]
1949    pub attribute_name: String,
1950    #[serde(rename = "KeyType", alias = "key_type")]
1951    pub key_type: KeyType,
1952}
1953
1954/// Key type: HASH (partition key) or RANGE (sort key).
1955#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1956pub enum KeyType {
1957    #[default]
1958    HASH,
1959    RANGE,
1960}
1961
1962/// Attribute definition - declares an attribute's type.
1963#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1964pub struct AttributeDefinition {
1965    #[serde(rename = "AttributeName", alias = "attribute_name")]
1966    pub attribute_name: String,
1967    #[serde(rename = "AttributeType", alias = "attribute_type")]
1968    pub attribute_type: ScalarAttributeType,
1969}
1970
1971/// Scalar attribute types that can be used as keys.
1972#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1973pub enum ScalarAttributeType {
1974    #[default]
1975    S,
1976    N,
1977    B,
1978}
1979
1980/// GSI projection type.
1981#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1982pub struct Projection {
1983    #[serde(
1984        rename = "ProjectionType",
1985        alias = "projection_type",
1986        default,
1987        skip_serializing_if = "Option::is_none"
1988    )]
1989    pub projection_type: Option<ProjectionType>,
1990    #[serde(
1991        rename = "NonKeyAttributes",
1992        alias = "non_key_attributes",
1993        skip_serializing_if = "Option::is_none"
1994    )]
1995    pub non_key_attributes: Option<Vec<String>>,
1996}
1997
1998/// Projection type enum.
1999#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2000#[allow(non_camel_case_types)]
2001pub enum ProjectionType {
2002    #[default]
2003    ALL,
2004    KEYS_ONLY,
2005    INCLUDE,
2006}
2007
2008/// Global Secondary Index definition.
2009#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2010pub struct GlobalSecondaryIndex {
2011    #[serde(rename = "IndexName", alias = "index_name")]
2012    pub index_name: String,
2013    #[serde(rename = "KeySchema", alias = "key_schema")]
2014    pub key_schema: Vec<KeySchemaElement>,
2015    #[serde(rename = "Projection", alias = "projection")]
2016    pub projection: Projection,
2017    #[serde(
2018        rename = "ProvisionedThroughput",
2019        alias = "provisioned_throughput",
2020        skip_serializing_if = "Option::is_none"
2021    )]
2022    pub provisioned_throughput: Option<ProvisionedThroughput>,
2023}
2024
2025/// Local Secondary Index definition.
2026#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2027pub struct LocalSecondaryIndex {
2028    #[serde(rename = "IndexName", alias = "index_name")]
2029    pub index_name: String,
2030    #[serde(rename = "KeySchema", alias = "key_schema")]
2031    pub key_schema: Vec<KeySchemaElement>,
2032    #[serde(rename = "Projection", alias = "projection")]
2033    pub projection: Projection,
2034}
2035
2036/// The vector attribute a vector index is built over. A structure with a
2037/// single `AttributeName` member on the wire, not a bare string.
2038#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2039pub struct VectorAttributeDefinition {
2040    #[serde(rename = "AttributeName", alias = "attribute_name")]
2041    pub attribute_name: String,
2042}
2043
2044/// One element of a vector index's search schema: a `HASH` partition
2045/// attribute or an `INLINE_FILTER` attribute.
2046#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2047pub struct SearchSchemaElement {
2048    #[serde(rename = "AttributeName", alias = "attribute_name")]
2049    pub attribute_name: String,
2050    #[serde(
2051        rename = "SearchSchemaElementType",
2052        alias = "search_schema_element_type"
2053    )]
2054    pub search_schema_element_type: String,
2055}
2056
2057/// Vector index definition as supplied on CreateTable's `VectorIndexes`.
2058///
2059/// Serialised verbatim into the `_tables.vector_index_definitions` column,
2060/// following the `gsi_definitions` convention.
2061#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2062pub struct VectorIndex {
2063    #[serde(rename = "IndexName", alias = "index_name")]
2064    pub index_name: String,
2065    #[serde(rename = "VectorAttribute", alias = "vector_attribute")]
2066    pub vector_attribute: VectorAttributeDefinition,
2067    #[serde(
2068        rename = "SearchSchema",
2069        alias = "search_schema",
2070        default,
2071        skip_serializing_if = "Option::is_none"
2072    )]
2073    pub search_schema: Option<Vec<SearchSchemaElement>>,
2074    #[serde(rename = "Projection", alias = "projection")]
2075    pub projection: Projection,
2076    #[serde(rename = "Dimensions", alias = "dimensions")]
2077    pub dimensions: u32,
2078    #[serde(rename = "DistanceFunction", alias = "distance_function")]
2079    pub distance_function: String,
2080}
2081
2082/// Why an attribute value is not a valid vector for an index with a given
2083/// dimension count. Carries the element-level detail the write-path
2084/// validation errors interpolate; the checks run in the order the variants
2085/// are listed, so a value can only report one failure.
2086#[derive(Debug, Clone, PartialEq)]
2087pub enum VectorValueError {
2088    /// The value is not a List at all.
2089    NotAList,
2090    /// The list's element count differs from the index's dimensions.
2091    WrongDimensions {
2092        /// The number of elements actually present.
2093        actual: usize,
2094    },
2095    /// An element is not a Number.
2096    ElementNotANumber {
2097        /// Zero-based element position.
2098        position: usize,
2099        /// The element's DynamoDB type descriptor.
2100        actual: &'static str,
2101    },
2102    /// An element does not fit the finite f32 range (out-of-range values
2103    /// overflow to infinity on conversion; real DynamoDB rejects them at
2104    /// write time, captured eu-west-2 and us-east-1, 2026-08-12).
2105    ElementOutOfRange {
2106        /// Zero-based element position.
2107        position: usize,
2108        /// The raw number string as written.
2109        value: String,
2110    },
2111}
2112
2113/// The f32 values of a vector attribute value, or the element-level reason it
2114/// is not a valid vector for an index with `dimensions` elements.
2115///
2116/// Vector indexes hold f32 copies while the base table keeps full precision
2117/// (captured from real DynamoDB, eu-west-2, 2026-08-11), so conversion
2118/// happens here, where the index copy is derived. The live write path turns
2119/// each error variant into its captured `IndexName:`-suffixed message; the
2120/// backfill path discards the detail via [`vector_f32_values`] and
2121/// sparse-skips, so the two paths agree on validity by construction.
2122pub fn check_vector_f32_values(
2123    value: &AttributeValue,
2124    dimensions: u32,
2125) -> std::result::Result<Vec<f32>, VectorValueError> {
2126    let AttributeValue::L(elems) = value else {
2127        return Err(VectorValueError::NotAList);
2128    };
2129    if elems.len() != dimensions as usize {
2130        return Err(VectorValueError::WrongDimensions {
2131            actual: elems.len(),
2132        });
2133    }
2134    let mut out = Vec::with_capacity(elems.len());
2135    for (position, elem) in elems.iter().enumerate() {
2136        let AttributeValue::N(n) = elem else {
2137            return Err(VectorValueError::ElementNotANumber {
2138                position,
2139                actual: elem.type_name(),
2140            });
2141        };
2142        // A well-formed DynamoDB number always parses (overflow yields
2143        // infinity, not an error); anything non-finite is out of f32 range.
2144        match n.parse::<f32>() {
2145            Ok(v) if v.is_finite() => out.push(v),
2146            _ => {
2147                return Err(VectorValueError::ElementOutOfRange {
2148                    position,
2149                    value: n.clone(),
2150                });
2151            }
2152        }
2153    }
2154    Ok(out)
2155}
2156
2157/// The f32 values of a vector attribute value, or `None` when the value is
2158/// not a valid vector for an index with `dimensions` elements. The validity
2159/// rule is exactly [`check_vector_f32_values`]'s, with the element-level
2160/// detail discarded; the backfill path uses this form to sparse-skip.
2161pub fn vector_f32_values(value: &AttributeValue, dimensions: u32) -> Option<Vec<f32>> {
2162    check_vector_f32_values(value, dimensions).ok()
2163}
2164
2165/// Serialise an f32 for a vector index's number copy: shortest-decimal via
2166/// serde_json's f32 formatter, which always keeps a fractional part ("1"
2167/// stores as "1.0", matching how the index copy reads back from real
2168/// DynamoDB; captured eu-west-2, 2026-08-11).
2169pub fn f32_number_string(v: f32) -> String {
2170    serde_json::to_string(&v).unwrap_or_else(|_| "0.0".to_string())
2171}
2172
2173/// Provisioned throughput settings (stored but not enforced).
2174#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2175pub struct ProvisionedThroughput {
2176    #[serde(rename = "ReadCapacityUnits", alias = "read_capacity_units", default)]
2177    pub read_capacity_units: Option<i64>,
2178    #[serde(rename = "WriteCapacityUnits", alias = "write_capacity_units", default)]
2179    pub write_capacity_units: Option<i64>,
2180}
2181
2182/// On-demand (PAY_PER_REQUEST) throughput ceilings (stored but not enforced).
2183#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2184pub struct OnDemandThroughput {
2185    #[serde(
2186        rename = "MaxReadRequestUnits",
2187        alias = "max_read_request_units",
2188        default,
2189        skip_serializing_if = "Option::is_none"
2190    )]
2191    pub max_read_request_units: Option<i64>,
2192    #[serde(
2193        rename = "MaxWriteRequestUnits",
2194        alias = "max_write_request_units",
2195        default,
2196        skip_serializing_if = "Option::is_none"
2197    )]
2198    pub max_write_request_units: Option<i64>,
2199}
2200
2201// ---------------------------------------------------------------------------
2202// Type conversion: From<T> / TryFrom<T> for AttributeValue
2203// ---------------------------------------------------------------------------
2204
2205/// Error returned when converting between `AttributeValue` and Rust types.
2206#[derive(Debug, Clone, PartialEq)]
2207pub struct ConversionError {
2208    /// The expected DynamoDB or Rust type.
2209    pub expected: &'static str,
2210    /// The actual DynamoDB type encountered.
2211    pub actual: &'static str,
2212}
2213
2214impl fmt::Display for ConversionError {
2215    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2216        write!(f, "expected {}, got {}", self.expected, self.actual)
2217    }
2218}
2219
2220impl std::error::Error for ConversionError {}
2221
2222// --- From<T> for AttributeValue: infallible conversions ---
2223
2224impl From<String> for AttributeValue {
2225    fn from(value: String) -> Self {
2226        AttributeValue::S(value)
2227    }
2228}
2229
2230impl From<&str> for AttributeValue {
2231    fn from(value: &str) -> Self {
2232        AttributeValue::S(value.to_string())
2233    }
2234}
2235
2236impl From<bool> for AttributeValue {
2237    fn from(value: bool) -> Self {
2238        AttributeValue::BOOL(value)
2239    }
2240}
2241
2242impl From<Vec<u8>> for AttributeValue {
2243    fn from(value: Vec<u8>) -> Self {
2244        AttributeValue::B(value)
2245    }
2246}
2247
2248impl From<&[u8]> for AttributeValue {
2249    fn from(value: &[u8]) -> Self {
2250        AttributeValue::B(value.to_vec())
2251    }
2252}
2253
2254// Integer types - all finite, all fit in DynamoDB's number range.
2255macro_rules! impl_from_integer {
2256    ($($t:ty),+) => {
2257        $(
2258            impl From<$t> for AttributeValue {
2259                fn from(value: $t) -> Self {
2260                    AttributeValue::N(value.to_string())
2261                }
2262            }
2263        )+
2264    };
2265}
2266
2267impl_from_integer!(i8, i16, i32, i64, i128, u8, u16, u32, u64, u128);
2268
2269// Container types
2270impl From<HashMap<String, AttributeValue>> for AttributeValue {
2271    fn from(value: HashMap<String, AttributeValue>) -> Self {
2272        AttributeValue::M(value)
2273    }
2274}
2275
2276impl From<Vec<AttributeValue>> for AttributeValue {
2277    fn from(value: Vec<AttributeValue>) -> Self {
2278        AttributeValue::L(value)
2279    }
2280}
2281
2282impl From<HashSet<String>> for AttributeValue {
2283    fn from(value: HashSet<String>) -> Self {
2284        AttributeValue::SS(value.into_iter().collect())
2285    }
2286}
2287
2288impl From<BTreeSet<String>> for AttributeValue {
2289    fn from(value: BTreeSet<String>) -> Self {
2290        AttributeValue::SS(value.into_iter().collect())
2291    }
2292}
2293
2294// --- TryFrom<T> for AttributeValue: fallible conversions (floats) ---
2295
2296impl TryFrom<f64> for AttributeValue {
2297    type Error = ConversionError;
2298
2299    fn try_from(value: f64) -> std::result::Result<Self, Self::Error> {
2300        if value.is_finite() {
2301            Ok(AttributeValue::N(value.to_string()))
2302        } else {
2303            Err(ConversionError {
2304                expected: "finite f64",
2305                actual: "NaN or Infinity",
2306            })
2307        }
2308    }
2309}
2310
2311impl TryFrom<f32> for AttributeValue {
2312    type Error = ConversionError;
2313
2314    fn try_from(value: f32) -> std::result::Result<Self, Self::Error> {
2315        if value.is_finite() {
2316            Ok(AttributeValue::N(value.to_string()))
2317        } else {
2318            Err(ConversionError {
2319                expected: "finite f32",
2320                actual: "NaN or Infinity",
2321            })
2322        }
2323    }
2324}
2325
2326// --- TryFrom<AttributeValue> for T: extract Rust types from AV ---
2327
2328impl TryFrom<AttributeValue> for String {
2329    type Error = ConversionError;
2330
2331    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
2332        match value {
2333            AttributeValue::S(s) => Ok(s),
2334            other => Err(ConversionError {
2335                expected: "S",
2336                actual: other.type_name(),
2337            }),
2338        }
2339    }
2340}
2341
2342impl TryFrom<AttributeValue> for bool {
2343    type Error = ConversionError;
2344
2345    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
2346        match value {
2347            AttributeValue::BOOL(b) => Ok(b),
2348            other => Err(ConversionError {
2349                expected: "BOOL",
2350                actual: other.type_name(),
2351            }),
2352        }
2353    }
2354}
2355
2356impl TryFrom<AttributeValue> for Vec<u8> {
2357    type Error = ConversionError;
2358
2359    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
2360        match value {
2361            AttributeValue::B(b) => Ok(b),
2362            other => Err(ConversionError {
2363                expected: "B",
2364                actual: other.type_name(),
2365            }),
2366        }
2367    }
2368}
2369
2370macro_rules! impl_try_from_av_integer {
2371    ($($t:ty),+) => {
2372        $(
2373            impl TryFrom<AttributeValue> for $t {
2374                type Error = ConversionError;
2375
2376                fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
2377                    match value {
2378                        AttributeValue::N(n) => n.parse::<$t>().map_err(|_| ConversionError {
2379                            expected: stringify!($t),
2380                            actual: "N (parse failed)",
2381                        }),
2382                        other => Err(ConversionError {
2383                            expected: "N",
2384                            actual: other.type_name(),
2385                        }),
2386                    }
2387                }
2388            }
2389        )+
2390    };
2391}
2392
2393impl_try_from_av_integer!(i8, i16, i32, i64, i128, u8, u16, u32, u64, u128);
2394
2395impl TryFrom<AttributeValue> for f64 {
2396    type Error = ConversionError;
2397
2398    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
2399        match value {
2400            AttributeValue::N(n) => n.parse::<f64>().map_err(|_| ConversionError {
2401                expected: "f64",
2402                actual: "N (parse failed)",
2403            }),
2404            other => Err(ConversionError {
2405                expected: "N",
2406                actual: other.type_name(),
2407            }),
2408        }
2409    }
2410}
2411
2412impl TryFrom<AttributeValue> for f32 {
2413    type Error = ConversionError;
2414
2415    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
2416        match value {
2417            AttributeValue::N(n) => n.parse::<f32>().map_err(|_| ConversionError {
2418                expected: "f32",
2419                actual: "N (parse failed)",
2420            }),
2421            other => Err(ConversionError {
2422                expected: "N",
2423                actual: other.type_name(),
2424            }),
2425        }
2426    }
2427}
2428
2429impl TryFrom<AttributeValue> for HashMap<String, AttributeValue> {
2430    type Error = ConversionError;
2431
2432    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
2433        match value {
2434            AttributeValue::M(m) => Ok(m),
2435            other => Err(ConversionError {
2436                expected: "M",
2437                actual: other.type_name(),
2438            }),
2439        }
2440    }
2441}
2442
2443impl TryFrom<AttributeValue> for Vec<AttributeValue> {
2444    type Error = ConversionError;
2445
2446    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
2447        match value {
2448            AttributeValue::L(l) => Ok(l),
2449            other => Err(ConversionError {
2450                expected: "L",
2451                actual: other.type_name(),
2452            }),
2453        }
2454    }
2455}
2456
2457impl TryFrom<AttributeValue> for Vec<String> {
2458    type Error = ConversionError;
2459
2460    fn try_from(value: AttributeValue) -> std::result::Result<Self, ConversionError> {
2461        match value {
2462            AttributeValue::SS(ss) => Ok(ss),
2463            AttributeValue::L(l) => {
2464                // Lenient: extract S values from a list
2465                l.into_iter()
2466                    .map(|av| match av {
2467                        AttributeValue::S(s) => Ok(s),
2468                        other => Err(ConversionError {
2469                            expected: "S (within L)",
2470                            actual: other.type_name(),
2471                        }),
2472                    })
2473                    .collect()
2474            }
2475            other => Err(ConversionError {
2476                expected: "SS or L",
2477                actual: other.type_name(),
2478            }),
2479        }
2480    }
2481}
2482
2483#[cfg(test)]
2484mod tests {
2485    use super::*;
2486
2487    /// A future that reports the layer in force at each of its polls, and
2488    /// waits once between them.
2489    struct SeesLayer {
2490        seen: Vec<crate::validation::ValueLayer>,
2491        waited: bool,
2492    }
2493
2494    impl std::future::Future for SeesLayer {
2495        type Output = Vec<crate::validation::ValueLayer>;
2496        fn poll(
2497            mut self: std::pin::Pin<&mut Self>,
2498            cx: &mut std::task::Context<'_>,
2499        ) -> std::task::Poll<Self::Output> {
2500            self.seen.push(current_value_layer());
2501            if self.waited {
2502                std::task::Poll::Ready(std::mem::take(&mut self.seen))
2503            } else {
2504                self.waited = true;
2505                cx.waker().wake_by_ref();
2506                std::task::Poll::Pending
2507            }
2508        }
2509    }
2510
2511    fn sees_layer() -> SeesLayer {
2512        SeesLayer {
2513            seen: Vec::new(),
2514            waited: false,
2515        }
2516    }
2517
2518    /// The layer holds across a future's awaits without reaching anything
2519    /// polled in between: on the wasm engine another request runs between two
2520    /// polls of this one, on the same thread.
2521    #[test]
2522    fn the_value_layer_follows_a_future_across_its_polls_and_no_further() {
2523        use crate::validation::ValueLayer::{Current, Older};
2524        use std::future::Future;
2525        let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
2526        let mut older = Box::pin(with_value_layer_async(Older, sees_layer()));
2527        let mut current = Box::pin(with_value_layer_async(Current, sees_layer()));
2528
2529        assert_eq!(current_value_layer(), Current);
2530        assert!(older.as_mut().poll(&mut cx).is_pending());
2531        // Out of its poll the layer is back to what the thread had.
2532        assert_eq!(current_value_layer(), Current);
2533        // A neighbour polled between the two reads its own layer, not the
2534        // one left by the poll before it.
2535        assert!(current.as_mut().poll(&mut cx).is_pending());
2536        assert_eq!(current_value_layer(), Current);
2537        assert_eq!(
2538            with_value_layer(Older, || {
2539                let mut neighbour = Box::pin(sees_layer());
2540                let _ = neighbour.as_mut().poll(&mut cx);
2541                current_value_layer()
2542            }),
2543            Older
2544        );
2545        match older.as_mut().poll(&mut cx) {
2546            std::task::Poll::Ready(seen) => assert_eq!(seen, vec![Older, Older]),
2547            std::task::Poll::Pending => panic!("the future was ready on its second poll"),
2548        }
2549        match current.as_mut().poll(&mut cx) {
2550            std::task::Poll::Ready(seen) => assert_eq!(seen, vec![Current, Current]),
2551            std::task::Poll::Pending => panic!("the future was ready on its second poll"),
2552        }
2553        assert_eq!(current_value_layer(), Current);
2554    }
2555
2556    #[test]
2557    fn test_serialize_string() {
2558        let val = AttributeValue::S("hello".to_string());
2559        let json = serde_json::to_string(&val).unwrap();
2560        assert_eq!(json, r#"{"S":"hello"}"#);
2561    }
2562
2563    #[test]
2564    fn test_serialize_number() {
2565        let val = AttributeValue::N("42".to_string());
2566        let json = serde_json::to_string(&val).unwrap();
2567        assert_eq!(json, r#"{"N":"42"}"#);
2568    }
2569
2570    #[test]
2571    fn test_serialize_binary() {
2572        let val = AttributeValue::B(vec![1, 2, 3]);
2573        let json = serde_json::to_string(&val).unwrap();
2574        assert_eq!(json, r#"{"B":"AQID"}"#);
2575    }
2576
2577    #[test]
2578    fn test_serialize_bool() {
2579        let val = AttributeValue::BOOL(true);
2580        let json = serde_json::to_string(&val).unwrap();
2581        assert_eq!(json, r#"{"BOOL":true}"#);
2582    }
2583
2584    #[test]
2585    fn test_serialize_null() {
2586        let val = AttributeValue::NULL(true);
2587        let json = serde_json::to_string(&val).unwrap();
2588        assert_eq!(json, r#"{"NULL":true}"#);
2589    }
2590
2591    #[test]
2592    fn test_deserialize_null_true() {
2593        let val: AttributeValue = serde_json::from_str(r#"{"NULL":true}"#).unwrap();
2594        assert_eq!(val, AttributeValue::NULL(true));
2595    }
2596
2597    #[test]
2598    fn test_deserialize_null_false_rejected() {
2599        // AWS requires the NULL member to be exactly `true`; {"NULL": false} is
2600        // rejected with a ValidationException, same as a non-boolean value.
2601        let err = serde_json::from_str::<AttributeValue>(r#"{"NULL":false}"#).unwrap_err();
2602        assert!(
2603            err.to_string().contains("must have the value of true"),
2604            "unexpected error: {err}"
2605        );
2606    }
2607
2608    #[test]
2609    fn test_deserialize_null_non_boolean_rejected() {
2610        // A non-boolean NULL (e.g. {"NULL": "no"}) is a type error, not a value.
2611        let err = serde_json::from_str::<AttributeValue>(r#"{"NULL":"no"}"#).unwrap_err();
2612        assert!(
2613            err.to_string().contains("must have the value of true"),
2614            "unexpected error: {err}"
2615        );
2616    }
2617
2618    #[test]
2619    fn test_serialize_string_set() {
2620        let val = AttributeValue::SS(vec!["a".to_string(), "b".to_string()]);
2621        let json = serde_json::to_string(&val).unwrap();
2622        assert_eq!(json, r#"{"SS":["a","b"]}"#);
2623    }
2624
2625    #[test]
2626    fn test_serialize_list() {
2627        let val = AttributeValue::L(vec![
2628            AttributeValue::S("hello".to_string()),
2629            AttributeValue::N("42".to_string()),
2630        ]);
2631        let json = serde_json::to_string(&val).unwrap();
2632        assert_eq!(json, r#"{"L":[{"S":"hello"},{"N":"42"}]}"#);
2633    }
2634
2635    #[test]
2636    fn test_serialize_map() {
2637        let mut m = HashMap::new();
2638        m.insert("key".to_string(), AttributeValue::S("value".to_string()));
2639        let val = AttributeValue::M(m);
2640        let json = serde_json::to_string(&val).unwrap();
2641        assert_eq!(json, r#"{"M":{"key":{"S":"value"}}}"#);
2642    }
2643
2644    #[test]
2645    fn test_round_trip_all_types() {
2646        let values = vec![
2647            AttributeValue::S("hello".to_string()),
2648            AttributeValue::N("42.5".to_string()),
2649            AttributeValue::B(vec![0, 255, 128]),
2650            AttributeValue::BOOL(false),
2651            AttributeValue::NULL(true),
2652            AttributeValue::SS(vec!["x".to_string(), "y".to_string()]),
2653            AttributeValue::NS(vec!["1".to_string(), "2.5".to_string()]),
2654            AttributeValue::BS(vec![vec![1], vec![2, 3]]),
2655            AttributeValue::L(vec![
2656                AttributeValue::S("nested".to_string()),
2657                AttributeValue::N("99".to_string()),
2658            ]),
2659        ];
2660
2661        for val in values {
2662            let json = serde_json::to_string(&val).unwrap();
2663            let deserialized: AttributeValue = serde_json::from_str(&json).unwrap();
2664            assert_eq!(val, deserialized, "Round-trip failed for {json}");
2665        }
2666    }
2667
2668    #[test]
2669    fn test_size_string() {
2670        let val = AttributeValue::S("hello".to_string());
2671        assert_eq!(val.size(), 5);
2672    }
2673
2674    #[test]
2675    fn test_size_number() {
2676        // "42" has 2 significant digits → ceil(2/2) + 1 = 2
2677        let val = AttributeValue::N("42".to_string());
2678        assert_eq!(val.size(), 2);
2679    }
2680
2681    #[test]
2682    fn test_size_number_matches_dynamodb() {
2683        // Byte-exact ground truth, measured against eu-west-2 by bisecting the
2684        // 400KB item-size gate (which resolves to the byte, unlike the 1KB
2685        // granularity of consumed capacity). A number costs
2686        // ceil(integer significant digits / 2) + ceil(fraction significant
2687        // digits / 2) + 1, over the value with leading and trailing zeros
2688        // trimmed. The count is a property of the significant digits, so it does
2689        // not change when scientific notation is expanded on storage.
2690        let cases: &[(&str, usize)] = &[
2691            // digit counts either side of the decimal point
2692            ("1", 2),
2693            ("12", 2),
2694            ("123", 3),
2695            ("1234", 3),
2696            ("12345", 4),
2697            ("123456", 4),
2698            ("1234567", 5),
2699            ("12345678", 5),
2700            ("123456789", 6),
2701            ("1234567891", 6),
2702            ("12345678912", 7),
2703            ("123456789123", 7),
2704            ("1234567890123456789", 11),
2705            ("12345678901234567890123456789012345678", 20),
2706            // zeros the wire carries but DynamoDB trims
2707            ("0042", 2),
2708            ("100", 2),
2709            ("1010", 3),
2710            ("0.0000001", 2),
2711            ("0", 1),
2712            // scientific notation, which expands on storage without growing
2713            ("1E125", 2),
2714            ("1E-100", 2),
2715            // significant digits straddling the decimal point are counted in two
2716            // halves, so these cost a byte more than the digit total alone implies
2717            ("1.5", 3),
2718            ("1.2", 3),
2719            ("1.200", 3),
2720            ("1.234", 4),
2721            ("3.14159", 5),
2722            ("100.5", 4),
2723            ("0.15", 2),
2724            ("15", 2),
2725            // a sign costs a byte
2726            ("-42", 3),
2727            ("-12345", 5),
2728            ("-1E125", 3),
2729            ("-0.15", 3),
2730            ("-0", 1),
2731        ];
2732        for (literal, expected) in cases {
2733            assert_eq!(
2734                AttributeValue::N((*literal).to_string()).size(),
2735                *expected,
2736                "size of N {literal:?}"
2737            );
2738        }
2739    }
2740
2741    #[test]
2742    fn test_size_number_survives_normalisation() {
2743        // The reason the write paths could disagree: sizing has to be invariant
2744        // under the normalisation that happens on storage, or the same item
2745        // measures differently either side of it.
2746        for literal in [
2747            "1E125",
2748            "1E-100",
2749            "0042",
2750            "1.200",
2751            "0.0000001",
2752            "100",
2753            "3.14159",
2754            "42",
2755        ] {
2756            let raw = AttributeValue::N(literal.to_string()).size();
2757            let normalised = AttributeValue::N(normalize_dynamo_number(literal)).size();
2758            assert_eq!(
2759                raw, normalised,
2760                "N {literal:?} changed size when normalised"
2761            );
2762        }
2763    }
2764
2765    #[test]
2766    fn test_size_bool() {
2767        assert_eq!(AttributeValue::BOOL(true).size(), 1);
2768    }
2769
2770    #[test]
2771    fn test_size_null() {
2772        assert_eq!(AttributeValue::NULL(true).size(), 1);
2773    }
2774
2775    #[test]
2776    fn test_key_string_s() {
2777        let val = AttributeValue::S("hello".to_string());
2778        assert_eq!(val.to_key_string(), Some("S:hello".to_string()));
2779    }
2780
2781    #[test]
2782    fn test_key_string_n() {
2783        let val = AttributeValue::N("42".to_string());
2784        let key = val.to_key_string().unwrap();
2785        assert!(key.starts_with("N:"));
2786    }
2787
2788    #[test]
2789    fn test_key_string_b() {
2790        let val = AttributeValue::B(vec![0xff, 0x00, 0xab]);
2791        assert_eq!(val.to_key_string(), Some("B:ff00ab".to_string()));
2792    }
2793
2794    #[test]
2795    fn test_key_string_non_key_type_returns_none() {
2796        assert_eq!(AttributeValue::BOOL(true).to_key_string(), None);
2797        assert_eq!(AttributeValue::L(vec![]).to_key_string(), None);
2798    }
2799
2800    // Number sort key ordering tests
2801    #[test]
2802    fn test_number_sort_ordering() {
2803        let numbers = vec![
2804            "-1000", "-100", "-10", "-1", "-0.5", "-0.001", "0", "0.001", "0.5", "1", "10", "100",
2805            "1000",
2806        ];
2807        let encoded: Vec<String> = numbers
2808            .iter()
2809            .map(|n| normalize_number_for_sort(n))
2810            .collect();
2811
2812        for i in 0..encoded.len() - 1 {
2813            assert!(
2814                encoded[i] < encoded[i + 1],
2815                "Sort order broken: {} ({}) should be < {} ({})",
2816                numbers[i],
2817                encoded[i],
2818                numbers[i + 1],
2819                encoded[i + 1]
2820            );
2821        }
2822    }
2823
2824    #[test]
2825    fn test_number_sort_zero_variants() {
2826        let z1 = normalize_number_for_sort("0");
2827        let z2 = normalize_number_for_sort("-0");
2828        let z3 = normalize_number_for_sort("0.0");
2829        assert_eq!(z1, z2);
2830        assert_eq!(z2, z3);
2831    }
2832
2833    #[test]
2834    fn test_number_sort_decimals() {
2835        let a = normalize_number_for_sort("1.5");
2836        let b = normalize_number_for_sort("2.5");
2837        assert!(a < b);
2838
2839        let c = normalize_number_for_sort("0.001");
2840        let d = normalize_number_for_sort("0.01");
2841        assert!(c < d);
2842    }
2843
2844    #[test]
2845    fn test_number_sort_scientific() {
2846        let a = normalize_number_for_sort("1e10");
2847        let b = normalize_number_for_sort("1e11");
2848        assert!(a < b);
2849
2850        let c = normalize_number_for_sort("-1e11");
2851        let d = normalize_number_for_sort("-1e10");
2852        assert!(c < d);
2853    }
2854
2855    // A library caller can reach the key encoder with a number that never
2856    // went through validation. One far outside DynamoDB's range sorts at the
2857    // matching extreme rather than overflowing the encoded exponent.
2858    #[test]
2859    fn test_number_sort_out_of_range_sorts_at_the_extremes() {
2860        let ordered = [
2861            "-1e99999999999",
2862            "-9.9999999999999999999999999999999999999E125",
2863            "-1E-130",
2864            "-1e-99999999999",
2865            "0",
2866            "1e-99999999999",
2867            "1E-130",
2868            "9.9999999999999999999999999999999999999E125",
2869            "1e99999999999",
2870        ];
2871        for pair in ordered.windows(2) {
2872            let (a, b) = (pair[0], pair[1]);
2873            assert!(
2874                normalize_number_for_sort(a) < normalize_number_for_sort(b),
2875                "{a} should sort before {b}"
2876            );
2877        }
2878    }
2879
2880    // Only an exponent too large for an i64 saturates. Anything else that
2881    // fails to parse keeps reading as 0 for callers that never validated.
2882    #[test]
2883    fn test_split_exponent_reads_an_unparseable_exponent_as_zero() {
2884        assert_eq!(split_exponent("1e"), ("1", 0));
2885        assert_eq!(split_exponent("1e+"), ("1", 0));
2886        assert_eq!(split_exponent("1e99999999999999999999"), ("1", i64::MAX));
2887        assert_eq!(split_exponent("1e-99999999999999999999"), ("1", i64::MIN));
2888    }
2889
2890    // Number validation/normalisation is pinned to real DynamoDB behaviour
2891    // (captured against AWS for issue #109). validate_dynamo_number accepts
2892    // exactly the grammar DynamoDB accepts, including a leading '+'; the bare
2893    // and '+'-prefixed malformed forms are both rejected, matching AWS.
2894    #[test]
2895    fn test_validate_number_accepts_dynamodb_grammar() {
2896        for input in [
2897            "+5", "+1.5", "+0", "-0", "+0.0", "+1e2", "1e+2", "1.5E+3", "-7", "+.5", ".5", "5.",
2898            "00042", "1.23E10", "+1e-2", "1E-130", "+1E-130",
2899        ] {
2900            assert!(
2901                validate_dynamo_number(input).is_ok(),
2902                "expected {input} to validate, got {:?}",
2903                validate_dynamo_number(input)
2904            );
2905        }
2906    }
2907
2908    #[test]
2909    fn test_validate_number_rejects_malformed() {
2910        // Every one of these is rejected by real DynamoDB. Note that whitespace
2911        // (leading, trailing, or internal) is rejected, not trimmed.
2912        for input in [
2913            "+e2", "e2", "+1+2", "1+2", "+1.2.3", "1.2.3", "++5", "+-5", "-+5", "+", "-", "1e",
2914            "1e+", ".", "1.2e3.4", "0x5", "NaN", "Infinity", "1_000", " 5", "5 ", "1 5", "",
2915        ] {
2916            assert!(
2917                matches!(
2918                    validate_dynamo_number(input),
2919                    Err(crate::errors::DynoxideError::ValidationException(_))
2920                ),
2921                "expected {input:?} to be rejected with ValidationException, got {:?}",
2922                validate_dynamo_number(input)
2923            );
2924        }
2925    }
2926
2927    fn validation_message(input: &str) -> Option<String> {
2928        match validate_dynamo_number(input) {
2929            Ok(()) => None,
2930            Err(crate::errors::DynoxideError::ValidationException(m)) => Some(m),
2931            Err(e) => panic!("expected a ValidationException for {input:?}, got {e:?}"),
2932        }
2933    }
2934
2935    const OVERFLOW: &str =
2936        "Number overflow. Attempting to store a number with magnitude larger than supported range";
2937    const UNDERFLOW: &str = "Number underflow. Attempting to store a number with magnitude smaller than supported range";
2938
2939    // An exponent too large for an i32 used to be read as 0, so 1e99999999999
2940    // passed the range checks and was stored as 1. Every expectation here was
2941    // captured against eu-west-2, 2026-10-08.
2942    #[test]
2943    fn test_validate_number_range_checks_exponents_beyond_i32() {
2944        for (input, expected) in [
2945            ("1e99999999999", OVERFLOW),
2946            ("-1e99999999999", OVERFLOW),
2947            ("1E+99999999999", OVERFLOW),
2948            ("1e-99999999999", UNDERFLOW),
2949            ("-1e-99999999999", UNDERFLOW),
2950            ("1e2147483648", OVERFLOW),
2951            ("1e-2147483649", UNDERFLOW),
2952            // Fits in an i32, but the digits before the point push it past.
2953            ("10e2147483647", OVERFLOW),
2954            ("0.01e-2147483648", UNDERFLOW),
2955            ("1e9223372036854775807", OVERFLOW),
2956            ("1e-9223372036854775808", UNDERFLOW),
2957            ("10e9223372036854775807", OVERFLOW),
2958            ("0.01e-9223372036854775808", UNDERFLOW),
2959        ] {
2960            assert_eq!(
2961                validation_message(input).as_deref(),
2962                Some(expected),
2963                "{input}"
2964            );
2965        }
2966    }
2967
2968    // DynamoDB reads the exponent as a 64-bit integer. One that does not fit
2969    // is a value it cannot convert, not an overflow (captured eu-west-2,
2970    // 2026-10-08).
2971    #[test]
2972    fn test_validate_number_rejects_exponent_beyond_i64_as_unconvertible() {
2973        for input in [
2974            "1e9223372036854775808",
2975            "1e+9223372036854775808",
2976            "1e-9223372036854775809",
2977            "0e9223372036854775808",
2978        ] {
2979            assert_eq!(
2980                validation_message(input),
2981                Some(format!(
2982                    "The parameter cannot be converted to a numeric value: {input}"
2983                )),
2984                "{input}"
2985            );
2986        }
2987    }
2988
2989    // A zero is range-checked as if its last written digit were a 1, so
2990    // `0e126` overflows like `1e126` and `0.0e-130` underflows like
2991    // `0.1e-130`. Captured eu-west-2, 2026-10-08.
2992    #[test]
2993    fn test_validate_number_range_checks_zero_by_its_last_written_digit() {
2994        let zeros_130 = format!("0.{}", "0".repeat(130));
2995        let zeros_131 = format!("0.{}", "0".repeat(131));
2996        for (input, expected) in [
2997            ("0e125", None),
2998            ("0e126", Some(OVERFLOW)),
2999            ("0.0e126", None),
3000            ("0.0e127", Some(OVERFLOW)),
3001            ("0.00e127", None),
3002            ("00e126", Some(OVERFLOW)),
3003            ("00.00e126", None),
3004            ("-0e127", Some(OVERFLOW)),
3005            ("0e99999999999", Some(OVERFLOW)),
3006            ("0e-130", None),
3007            ("0e-131", Some(UNDERFLOW)),
3008            ("0.0e-129", None),
3009            ("0.0e-130", Some(UNDERFLOW)),
3010            ("0e-99999999999", Some(UNDERFLOW)),
3011            (zeros_130.as_str(), None),
3012            (zeros_131.as_str(), Some(UNDERFLOW)),
3013        ] {
3014            assert_eq!(validation_message(input).as_deref(), expected, "{input}");
3015        }
3016    }
3017
3018    #[test]
3019    fn test_normalize_number_matches_dynamodb() {
3020        for (input, stored) in [
3021            ("+5", "5"),
3022            ("+1.5", "1.5"),
3023            ("+0", "0"),
3024            ("-0", "0"),
3025            ("+0.0", "0"),
3026            ("+1e2", "100"),
3027            ("1e+2", "100"),
3028            ("1.5E+3", "1500"),
3029            ("-7", "-7"),
3030            ("+.5", "0.5"),
3031            (".5", "0.5"),
3032            ("5.", "5"),
3033            ("00042", "42"),
3034            ("1.23E10", "12300000000"),
3035            ("+1e-2", "0.01"),
3036        ] {
3037            assert_eq!(
3038                normalize_dynamo_number(input),
3039                stored,
3040                "{input} should normalise to {stored}"
3041            );
3042        }
3043    }
3044
3045    #[test]
3046    fn test_type_name() {
3047        assert_eq!(AttributeValue::S("".to_string()).type_name(), "S");
3048        assert_eq!(AttributeValue::N("0".to_string()).type_name(), "N");
3049        assert_eq!(AttributeValue::B(vec![]).type_name(), "B");
3050        assert_eq!(AttributeValue::BOOL(true).type_name(), "BOOL");
3051        assert_eq!(AttributeValue::NULL(true).type_name(), "NULL");
3052        assert_eq!(AttributeValue::SS(vec![]).type_name(), "SS");
3053        assert_eq!(AttributeValue::NS(vec![]).type_name(), "NS");
3054        assert_eq!(AttributeValue::BS(vec![]).type_name(), "BS");
3055        assert_eq!(AttributeValue::L(vec![]).type_name(), "L");
3056        assert_eq!(AttributeValue::M(HashMap::new()).type_name(), "M");
3057    }
3058}