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