Skip to main content

faucet_common_dynamodb/
convert.rs

1//! Lossless DynamoDB `AttributeValue` ⇄ plain JSON conversion.
2//!
3//! Reading (`AttributeValue` → JSON):
4//!
5//! | DynamoDB | JSON |
6//! |----------|------|
7//! | `S` | string |
8//! | `N` | number when the decimal value is exactly representable (an `i64`/`u64`, or an `f64` whose shortest decimal form equals the stored value); otherwise the original decimal **string** — precision is never lost |
9//! | `BOOL` | boolean |
10//! | `NULL` | `null` |
11//! | `B` | base64 string (standard alphabet, padded) |
12//! | `M` / `L` | object / array (recursive) |
13//! | `SS` / `NS` / `BS` | array of strings / numbers (same `N` rule) / base64 strings |
14//!
15//! Writing (JSON → `AttributeValue`): string → `S`, number → `N`, boolean →
16//! `BOOL`, `null` → `NULL`, array → `L`, object → `M`. JSON has no set or
17//! binary type, so sets and binaries read from DynamoDB come back as lists
18//! and strings respectively.
19//!
20//! [`ValueFormat::Typed`] instead carries every attribute in DynamoDB's typed
21//! attribute JSON (`{"S": "a"}`, `{"NS": ["1"]}`, `{"B": "<base64>"}`), so
22//! sets, binaries and exact numbers survive a DynamoDB-to-DynamoDB copy.
23
24use aws_sdk_dynamodb::primitives::Blob;
25use aws_sdk_dynamodb::types::AttributeValue;
26use base64::Engine as _;
27use faucet_core::FaucetError;
28use serde_json::{Map, Number, Value};
29use std::collections::HashMap;
30
31type StreamsAttr = aws_sdk_dynamodbstreams::types::AttributeValue;
32
33/// A DynamoDB decimal in canonical form: `digits × 10^exp` with no leading or
34/// trailing zeros in `digits` (zero is the empty digit string).
35#[derive(Debug, PartialEq, Eq)]
36struct Decimal {
37    negative: bool,
38    digits: String,
39    exp: i64,
40}
41
42fn canonical_decimal(raw: &str) -> Option<Decimal> {
43    let s = raw.trim();
44    let (negative, s) = match s.as_bytes().first()? {
45        b'-' => (true, &s[1..]),
46        b'+' => (false, &s[1..]),
47        _ => (false, s),
48    };
49    let (mantissa, exp_part) = match s.find(['e', 'E']) {
50        Some(i) => (&s[..i], Some(&s[i + 1..])),
51        None => (s, None),
52    };
53    let (int_part, frac_part) = match mantissa.find('.') {
54        Some(i) => (&mantissa[..i], &mantissa[i + 1..]),
55        None => (mantissa, ""),
56    };
57    if int_part.is_empty() && frac_part.is_empty() {
58        return None;
59    }
60    if !int_part.bytes().all(|b| b.is_ascii_digit())
61        || !frac_part.bytes().all(|b| b.is_ascii_digit())
62    {
63        return None;
64    }
65    let mut exp: i64 = match exp_part {
66        Some(e) => e.parse().ok()?,
67        None => 0,
68    };
69    exp = exp.checked_sub(frac_part.len() as i64)?;
70    let joined = format!("{int_part}{frac_part}");
71    let trimmed_leading = joined.trim_start_matches('0');
72    let digits = trimmed_leading.trim_end_matches('0');
73    exp = exp.checked_add((trimmed_leading.len() - digits.len()) as i64)?;
74    if digits.is_empty() {
75        return Some(Decimal {
76            negative: false,
77            digits: String::new(),
78            exp: 0,
79        });
80    }
81    Some(Decimal {
82        negative,
83        digits: digits.to_string(),
84        exp,
85    })
86}
87
88/// Convert a DynamoDB `N` decimal string to JSON: a JSON number when the value
89/// is exactly representable, otherwise the original string (never lossy).
90pub fn number_to_json(raw: &str) -> Value {
91    let Some(dec) = canonical_decimal(raw) else {
92        return Value::String(raw.to_string());
93    };
94    if dec.digits.is_empty() {
95        return Value::Number(0.into());
96    }
97    if dec.exp >= 0 && dec.digits.len() as i64 + dec.exp <= 20 {
98        let integer = format!("{}{}", dec.digits, "0".repeat(dec.exp as usize));
99        if dec.negative {
100            if let Ok(i) = format!("-{integer}").parse::<i64>() {
101                return Value::Number(i.into());
102            }
103        } else if let Ok(u) = integer.parse::<u64>() {
104            return Value::Number(u.into());
105        }
106    }
107    match faucet_core::fidelity::number_text(raw) {
108        Ok(Value::Number(n)) => Value::Number(n),
109        _ => Value::String(raw.to_string()),
110    }
111}
112
113/// Convert one DynamoDB attribute value to plain JSON (see the module docs).
114/// An attribute type this SDK version does not know is an error rather than
115/// a silent `null`.
116pub fn attribute_to_json(av: &AttributeValue) -> Result<Value, FaucetError> {
117    Ok(match av {
118        AttributeValue::S(s) => Value::String(s.clone()),
119        AttributeValue::N(n) => number_to_json(n),
120        AttributeValue::Bool(b) => Value::Bool(*b),
121        AttributeValue::Null(_) => Value::Null,
122        AttributeValue::B(b) => b64(b),
123        AttributeValue::Ss(v) => Value::Array(v.iter().cloned().map(Value::String).collect()),
124        AttributeValue::Ns(v) => Value::Array(v.iter().map(|n| number_to_json(n)).collect()),
125        AttributeValue::Bs(v) => Value::Array(v.iter().map(b64).collect()),
126        AttributeValue::L(v) => Value::Array(
127            v.iter()
128                .map(attribute_to_json)
129                .collect::<Result<Vec<_>, _>>()?,
130        ),
131        AttributeValue::M(m) => item_to_json(m)?,
132        other => {
133            return Err(FaucetError::Source(format!(
134                "dynamodb: unsupported attribute value type {other:?}"
135            )));
136        }
137    })
138}
139
140/// Convert a DynamoDB item (attribute map) to a JSON object.
141pub fn item_to_json(item: &HashMap<String, AttributeValue>) -> Result<Value, FaucetError> {
142    let mut obj = Map::with_capacity(item.len());
143    for (k, v) in item {
144        obj.insert(k.clone(), attribute_to_json(v)?);
145    }
146    Ok(Value::Object(obj))
147}
148
149/// Render a JSON number as a DynamoDB `N` string.
150fn json_number_to_n(n: &Number) -> String {
151    if let Some(i) = n.as_i64() {
152        i.to_string()
153    } else if let Some(u) = n.as_u64() {
154        u.to_string()
155    } else {
156        // The shortest text that names the double (`1e300`, not 301 digits).
157        n.to_string()
158    }
159}
160
161/// Convert a JSON value to a DynamoDB attribute value.
162pub fn json_to_attribute(v: &Value) -> AttributeValue {
163    match v {
164        Value::Null => AttributeValue::Null(true),
165        Value::Bool(b) => AttributeValue::Bool(*b),
166        Value::Number(n) => AttributeValue::N(json_number_to_n(n)),
167        Value::String(s) => AttributeValue::S(s.clone()),
168        Value::Array(a) => AttributeValue::L(a.iter().map(json_to_attribute).collect()),
169        Value::Object(o) => AttributeValue::M(
170            o.iter()
171                .map(|(k, v)| (k.clone(), json_to_attribute(v)))
172                .collect(),
173        ),
174    }
175}
176
177/// Convert a JSON key value to the table's declared key type, so a record
178/// read from another DynamoDB table (where `B` arrives as base64 and an
179/// imprecise `N` as a decimal string) addresses the same key. A value that
180/// cannot be that type is an error naming the attribute.
181pub fn json_to_key_attribute(
182    name: &str,
183    v: &Value,
184    scalar: crate::table::ScalarType,
185) -> Result<AttributeValue, String> {
186    use crate::table::ScalarType;
187    match (scalar, v) {
188        (ScalarType::String, Value::String(s)) => Ok(AttributeValue::S(s.clone())),
189        (ScalarType::Number, Value::Number(n)) => Ok(AttributeValue::N(json_number_to_n(n))),
190        (ScalarType::Number, Value::String(s)) if canonical_decimal(s).is_some() => {
191            Ok(AttributeValue::N(s.trim().to_string()))
192        }
193        (ScalarType::Binary, Value::String(s)) => base64::engine::general_purpose::STANDARD
194            .decode(s)
195            .map(|b| AttributeValue::B(Blob::new(b)))
196            .map_err(|e| {
197                format!("key attribute '{name}' is a binary (B) key but is not base64: {e}")
198            }),
199        (scalar, other) => Err(format!(
200            "key attribute '{name}' must be {} for the table's {} key, got {}",
201            match scalar {
202                ScalarType::String => "a string",
203                ScalarType::Number => "a number or a decimal string",
204                ScalarType::Binary => "a base64 string",
205            },
206            match scalar {
207                ScalarType::String => "S",
208                ScalarType::Number => "N",
209                ScalarType::Binary => "B",
210            },
211            json_kind(other)
212        )),
213    }
214}
215
216/// Convert a JSON object record to a DynamoDB item. Non-objects are rejected.
217pub fn json_to_item(v: &Value) -> Result<HashMap<String, AttributeValue>, FaucetError> {
218    match v {
219        Value::Object(o) => Ok(o
220            .iter()
221            .map(|(k, v)| (k.clone(), json_to_attribute(v)))
222            .collect()),
223        other => Err(FaucetError::Sink(format!(
224            "dynamodb: record must be a JSON object, got {}",
225            json_kind(other)
226        ))),
227    }
228}
229
230fn json_kind(v: &Value) -> &'static str {
231    match v {
232        Value::Null => "null",
233        Value::Bool(_) => "boolean",
234        Value::Number(_) => "number",
235        Value::String(_) => "string",
236        Value::Array(_) => "array",
237        Value::Object(_) => "object",
238    }
239}
240
241/// Re-type a DynamoDB Streams attribute value as a DynamoDB one (the two SDKs
242/// define structurally identical but distinct enums).
243pub fn streams_attribute_to_dynamodb(av: &StreamsAttr) -> Result<AttributeValue, FaucetError> {
244    Ok(match av {
245        StreamsAttr::S(s) => AttributeValue::S(s.clone()),
246        StreamsAttr::N(n) => AttributeValue::N(n.clone()),
247        StreamsAttr::Bool(b) => AttributeValue::Bool(*b),
248        StreamsAttr::Null(b) => AttributeValue::Null(*b),
249        StreamsAttr::B(b) => AttributeValue::B(Blob::new(b.as_ref().to_vec())),
250        StreamsAttr::Ss(v) => AttributeValue::Ss(v.clone()),
251        StreamsAttr::Ns(v) => AttributeValue::Ns(v.clone()),
252        StreamsAttr::Bs(v) => {
253            AttributeValue::Bs(v.iter().map(|b| Blob::new(b.as_ref().to_vec())).collect())
254        }
255        StreamsAttr::L(v) => AttributeValue::L(
256            v.iter()
257                .map(streams_attribute_to_dynamodb)
258                .collect::<Result<Vec<_>, _>>()?,
259        ),
260        StreamsAttr::M(m) => AttributeValue::M(
261            m.iter()
262                .map(|(k, v)| Ok((k.clone(), streams_attribute_to_dynamodb(v)?)))
263                .collect::<Result<HashMap<_, _>, FaucetError>>()?,
264        ),
265        other => {
266            return Err(FaucetError::Source(format!(
267                "dynamodb streams: unsupported attribute value type {other:?}"
268            )));
269        }
270    })
271}
272
273/// Convert a DynamoDB Streams image (attribute map) to a JSON object.
274pub fn streams_item_to_json(item: &HashMap<String, StreamsAttr>) -> Result<Value, FaucetError> {
275    let mut obj = Map::with_capacity(item.len());
276    for (k, v) in item {
277        obj.insert(
278            k.clone(),
279            attribute_to_json(&streams_attribute_to_dynamodb(v)?)?,
280        );
281    }
282    Ok(Value::Object(obj))
283}
284
285fn b64(b: &Blob) -> Value {
286    Value::String(base64::engine::general_purpose::STANDARD.encode(b.as_ref()))
287}
288
289fn unb64(v: &Value) -> Result<Blob, FaucetError> {
290    let s = v
291        .as_str()
292        .ok_or_else(|| FaucetError::Source("dynamodb: typed B value must be a string".into()))?;
293    base64::engine::general_purpose::STANDARD
294        .decode(s)
295        .map(Blob::new)
296        .map_err(|e| FaucetError::Source(format!("dynamodb: invalid base64 in typed B value: {e}")))
297}
298
299fn attribute_to_typed(av: &AttributeValue) -> Value {
300    use serde_json::json;
301    match av {
302        AttributeValue::S(s) => json!({ "S": s }),
303        AttributeValue::N(n) => json!({ "N": n }),
304        AttributeValue::B(b) => json!({ "B": b64(b) }),
305        AttributeValue::Bool(b) => json!({ "BOOL": b }),
306        AttributeValue::Null(b) => json!({ "NULL": b }),
307        AttributeValue::Ss(v) => json!({ "SS": v }),
308        AttributeValue::Ns(v) => json!({ "NS": v }),
309        AttributeValue::Bs(v) => json!({ "BS": v.iter().map(b64).collect::<Vec<_>>() }),
310        AttributeValue::L(v) => {
311            json!({ "L": v.iter().map(attribute_to_typed).collect::<Vec<_>>() })
312        }
313        AttributeValue::M(m) => json!({ "M": item_to_typed_json(m) }),
314        _ => Value::Null,
315    }
316}
317
318/// Serialize an item in DynamoDB's typed JSON form (`{"pk": {"S": "a"}}`) —
319/// lossless, used to persist `LastEvaluatedKey` cursors in bookmarks.
320pub fn item_to_typed_json(item: &HashMap<String, AttributeValue>) -> Value {
321    Value::Object(
322        item.iter()
323            .map(|(k, v)| (k.clone(), attribute_to_typed(v)))
324            .collect(),
325    )
326}
327
328fn strings(v: &Value) -> Result<Vec<String>, FaucetError> {
329    v.as_array()
330        .and_then(|a| a.iter().map(|s| s.as_str().map(str::to_string)).collect())
331        .ok_or_else(|| {
332            FaucetError::Source("dynamodb: typed set must be an array of strings".into())
333        })
334}
335
336fn typed_to_attribute(v: &Value) -> Result<AttributeValue, FaucetError> {
337    let bad = || FaucetError::Source(format!("dynamodb: malformed typed attribute value {v}"));
338    let obj = v.as_object().filter(|o| o.len() == 1).ok_or_else(bad)?;
339    let (tag, inner) = obj.iter().next().ok_or_else(bad)?;
340    Ok(match tag.as_str() {
341        "S" => AttributeValue::S(inner.as_str().ok_or_else(bad)?.to_string()),
342        "N" => AttributeValue::N(inner.as_str().ok_or_else(bad)?.to_string()),
343        "B" => AttributeValue::B(unb64(inner)?),
344        "BOOL" => AttributeValue::Bool(inner.as_bool().ok_or_else(bad)?),
345        "NULL" => AttributeValue::Null(inner.as_bool().ok_or_else(bad)?),
346        "SS" => AttributeValue::Ss(strings(inner)?),
347        "NS" => AttributeValue::Ns(strings(inner)?),
348        "BS" => AttributeValue::Bs(
349            inner
350                .as_array()
351                .ok_or_else(bad)?
352                .iter()
353                .map(unb64)
354                .collect::<Result<_, _>>()?,
355        ),
356        "L" => AttributeValue::L(
357            inner
358                .as_array()
359                .ok_or_else(bad)?
360                .iter()
361                .map(typed_to_attribute)
362                .collect::<Result<_, _>>()?,
363        ),
364        "M" => AttributeValue::M(typed_json_to_item(inner)?),
365        _ => return Err(bad()),
366    })
367}
368
369/// Parse DynamoDB typed JSON back into an item (inverse of
370/// [`item_to_typed_json`]).
371pub fn typed_json_to_item(v: &Value) -> Result<HashMap<String, AttributeValue>, FaucetError> {
372    let obj = v.as_object().ok_or_else(|| {
373        FaucetError::Source(format!("dynamodb: typed item must be an object, got {v}"))
374    })?;
375    obj.iter()
376        .map(|(k, v)| Ok((k.clone(), typed_to_attribute(v)?)))
377        .collect()
378}
379
380/// How records carry attribute values: `plain` JSON (the default) or
381/// DynamoDB's `typed` attribute JSON (`{"pk": {"S": "a"}, "tags": {"SS": [..]}}`).
382/// `typed` keeps sets, binaries and numbers of any precision exactly, so a
383/// DynamoDB-to-DynamoDB copy round-trips every attribute type.
384#[derive(
385    Debug,
386    Clone,
387    Copy,
388    Default,
389    PartialEq,
390    Eq,
391    serde::Serialize,
392    serde::Deserialize,
393    schemars::JsonSchema,
394)]
395#[serde(rename_all = "snake_case")]
396pub enum ValueFormat {
397    /// Plain JSON: sets become arrays, binaries base64 strings, numbers JSON
398    /// numbers unless that would lose precision (then decimal strings).
399    #[default]
400    Plain,
401    /// DynamoDB attribute JSON, one `{"<type>": value}` object per attribute.
402    Typed,
403}
404
405impl ValueFormat {
406    /// Convert an item read from DynamoDB in this format.
407    pub fn item_to_json(
408        self,
409        item: &HashMap<String, AttributeValue>,
410    ) -> Result<Value, FaucetError> {
411        match self {
412            ValueFormat::Plain => item_to_json(item),
413            ValueFormat::Typed => try_item_to_typed_json(item),
414        }
415    }
416
417    /// Convert a DynamoDB Streams image in this format.
418    pub fn streams_item_to_json(
419        self,
420        item: &HashMap<String, StreamsAttr>,
421    ) -> Result<Value, FaucetError> {
422        match self {
423            ValueFormat::Plain => streams_item_to_json(item),
424            ValueFormat::Typed => streams_item_to_typed_json(item),
425        }
426    }
427}
428
429/// [`item_to_typed_json`], failing on an attribute type this SDK version does
430/// not know instead of writing `null`.
431pub fn try_item_to_typed_json(
432    item: &HashMap<String, AttributeValue>,
433) -> Result<Value, FaucetError> {
434    fn check(av: &AttributeValue) -> Result<(), FaucetError> {
435        match av {
436            AttributeValue::L(v) => v.iter().try_for_each(check),
437            AttributeValue::M(m) => m.values().try_for_each(check),
438            AttributeValue::S(_)
439            | AttributeValue::N(_)
440            | AttributeValue::B(_)
441            | AttributeValue::Bool(_)
442            | AttributeValue::Null(_)
443            | AttributeValue::Ss(_)
444            | AttributeValue::Ns(_)
445            | AttributeValue::Bs(_) => Ok(()),
446            other => Err(FaucetError::Source(format!(
447                "dynamodb: unsupported attribute value type {other:?}"
448            ))),
449        }
450    }
451    item.values().try_for_each(check)?;
452    Ok(item_to_typed_json(item))
453}
454
455/// Convert a DynamoDB Streams image to typed attribute JSON.
456pub fn streams_item_to_typed_json(
457    item: &HashMap<String, StreamsAttr>,
458) -> Result<Value, FaucetError> {
459    let converted = item
460        .iter()
461        .map(|(k, v)| Ok((k.clone(), streams_attribute_to_dynamodb(v)?)))
462        .collect::<Result<HashMap<_, _>, FaucetError>>()?;
463    Ok(item_to_typed_json(&converted))
464}
465
466/// Parse one typed attribute (`{"S": "a"}`, `{"NS": ["1"]}`, …).
467pub fn typed_json_to_attribute(v: &Value) -> Result<AttributeValue, FaucetError> {
468    typed_to_attribute(v)
469}
470
471/// DynamoDB's size of a number attribute: roughly one byte per two
472/// significant digits plus one (capped at 21 bytes).
473fn number_size(n: &str) -> usize {
474    let digits = n.bytes().filter(u8::is_ascii_digit).count();
475    (digits.div_ceil(2) + 1).min(21)
476}
477
478fn attribute_size(av: &AttributeValue) -> usize {
479    match av {
480        AttributeValue::S(s) => s.len(),
481        AttributeValue::N(n) => number_size(n),
482        AttributeValue::B(b) => b.as_ref().len(),
483        AttributeValue::Bool(_) | AttributeValue::Null(_) => 1,
484        AttributeValue::Ss(v) => v.iter().map(String::len).sum(),
485        AttributeValue::Ns(v) => v.iter().map(|n| number_size(n)).sum(),
486        AttributeValue::Bs(v) => v.iter().map(|b| b.as_ref().len()).sum(),
487        AttributeValue::L(v) => 3 + v.iter().map(|a| 1 + attribute_size(a)).sum::<usize>(),
488        AttributeValue::M(m) => {
489            3 + m
490                .iter()
491                .map(|(k, a)| 1 + k.len() + attribute_size(a))
492                .sum::<usize>()
493        }
494        _ => 0,
495    }
496}
497
498/// Approximate DynamoDB item size in bytes (attribute names + values), per
499/// the documented sizing rules — used to enforce the 400 KB item and 16 MB
500/// `BatchWriteItem` request ceilings before sending.
501pub fn item_size(item: &HashMap<String, AttributeValue>) -> usize {
502    item.iter().map(|(k, v)| k.len() + attribute_size(v)).sum()
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508    use serde_json::json;
509
510    #[test]
511    fn numbers_become_json_numbers_only_when_exact() {
512        assert_eq!(number_to_json("42"), json!(42));
513        assert_eq!(number_to_json("-42"), json!(-42));
514        assert_eq!(number_to_json("+7"), json!(7));
515        assert_eq!(number_to_json("0"), json!(0));
516        assert_eq!(number_to_json("-0.000"), json!(0));
517        assert_eq!(number_to_json("1.50"), json!(1.5));
518        assert_eq!(number_to_json("1.0"), json!(1));
519        assert_eq!(number_to_json("1E+3"), json!(1000));
520        assert_eq!(number_to_json("2.5e-3"), json!(0.0025));
521        assert_eq!(number_to_json("0.1"), json!(0.1));
522        assert_eq!(number_to_json("18446744073709551615"), json!(u64::MAX));
523        assert_eq!(number_to_json("-9223372036854775808"), json!(i64::MIN));
524        // Beyond the integer types and not an exact f64: kept as the string.
525        assert_eq!(
526            number_to_json("12345678901234567890123"),
527            json!("12345678901234567890123")
528        );
529        assert_eq!(
530            number_to_json("-9223372036854775809"),
531            json!("-9223372036854775809")
532        );
533        assert_eq!(
534            number_to_json("3.14159265358979323846264338327950288"),
535            json!("3.14159265358979323846264338327950288")
536        );
537        // Very large but f64-exact in shortest form: a number.
538        assert_eq!(number_to_json("1E+30"), json!(1e30));
539        // Malformed: passed through verbatim.
540        assert_eq!(number_to_json("abc"), json!("abc"));
541        assert_eq!(number_to_json(""), json!(""));
542        assert_eq!(number_to_json("."), json!("."));
543        assert_eq!(number_to_json("1.2.3"), json!("1.2.3"));
544        assert_eq!(number_to_json("1ex"), json!("1ex"));
545        assert_eq!(
546            number_to_json("1e99999999999999999999"),
547            json!("1e99999999999999999999")
548        );
549    }
550
551    #[test]
552    fn key_attributes_follow_the_declared_type() {
553        use crate::table::ScalarType;
554        assert_eq!(
555            json_to_key_attribute("k", &json!("a"), ScalarType::String).unwrap(),
556            AttributeValue::S("a".into())
557        );
558        assert_eq!(
559            json_to_key_attribute("k", &json!(7), ScalarType::Number).unwrap(),
560            AttributeValue::N("7".into())
561        );
562        let big = "123456789012345678901234567890.000000001";
563        assert_eq!(
564            json_to_key_attribute("k", &json!(big), ScalarType::Number).unwrap(),
565            AttributeValue::N(big.into())
566        );
567        let bin = json_to_key_attribute("k", &json!("AAEC"), ScalarType::Binary).unwrap();
568        assert_eq!(bin, AttributeValue::B(Blob::new(vec![0u8, 1, 2])));
569        let raw = AttributeValue::B(Blob::new(vec![0u8, 1, 2]));
570        assert_eq!(
571            json_to_key_attribute("k", &attribute_to_json(&raw).unwrap(), ScalarType::Binary)
572                .unwrap(),
573            raw
574        );
575        let e = json_to_key_attribute("k", &json!("@@"), ScalarType::Binary).unwrap_err();
576        assert!(e.contains("not base64"), "{e}");
577        let e = json_to_key_attribute("k", &json!("x1"), ScalarType::Number).unwrap_err();
578        assert!(e.contains("table's N key") && e.contains("string"), "{e}");
579        let e = json_to_key_attribute("k", &json!(1), ScalarType::String).unwrap_err();
580        assert!(
581            e.contains("a string for the table's S key, got number"),
582            "{e}"
583        );
584        let e = json_to_key_attribute("k", &json!(true), ScalarType::Binary).unwrap_err();
585        assert!(e.contains("base64 string"), "{e}");
586    }
587
588    #[test]
589    fn canonical_decimal_normalizes() {
590        let d = canonical_decimal("001.2300e2").unwrap();
591        assert_eq!((d.negative, d.digits.as_str(), d.exp), (false, "123", 0));
592        let d = canonical_decimal("-.5").unwrap();
593        assert_eq!((d.negative, d.digits.as_str(), d.exp), (true, "5", -1));
594        let d = canonical_decimal("5.").unwrap();
595        assert_eq!((d.digits.as_str(), d.exp), ("5", 0));
596        assert!(canonical_decimal("-").is_none());
597    }
598
599    #[test]
600    fn attribute_to_json_covers_every_type() {
601        let mut inner = HashMap::new();
602        inner.insert(
603            "n".to_string(),
604            AttributeValue::N("99999999999999999999999".into()),
605        );
606        let item: HashMap<String, AttributeValue> = [
607            ("s", AttributeValue::S("x".into())),
608            ("n", AttributeValue::N("3".into())),
609            ("b", AttributeValue::Bool(true)),
610            ("nul", AttributeValue::Null(true)),
611            ("bin", AttributeValue::B(Blob::new(vec![1, 2, 3]))),
612            ("ss", AttributeValue::Ss(vec!["a".into(), "b".into()])),
613            ("ns", AttributeValue::Ns(vec!["1".into(), "1.5".into()])),
614            ("bs", AttributeValue::Bs(vec![Blob::new(vec![0xff])])),
615            (
616                "l",
617                AttributeValue::L(vec![
618                    AttributeValue::S("y".into()),
619                    AttributeValue::N("2".into()),
620                ]),
621            ),
622            ("m", AttributeValue::M(inner)),
623        ]
624        .into_iter()
625        .map(|(k, v)| (k.to_string(), v))
626        .collect();
627        let v = item_to_json(&item).unwrap();
628        assert_eq!(
629            v,
630            json!({
631                "s": "x", "n": 3, "b": true, "nul": null, "bin": "AQID",
632                "ss": ["a", "b"], "ns": [1, 1.5], "bs": ["/w=="],
633                "l": ["y", 2], "m": {"n": "99999999999999999999999"}
634            })
635        );
636    }
637
638    #[test]
639    fn json_round_trips_through_attributes() {
640        let rec = json!({
641            "id": "a", "n": 7, "neg": -3, "big": 18446744073709551615u64, "f": 2.5,
642            "flag": false, "none": null, "list": [1, "x", {"deep": true}], "obj": {"k": "v"}
643        });
644        let item = json_to_item(&rec).unwrap();
645        assert!(matches!(&item["n"], AttributeValue::N(n) if n == "7"));
646        assert!(matches!(&item["big"], AttributeValue::N(n) if n == "18446744073709551615"));
647        assert!(matches!(&item["f"], AttributeValue::N(n) if n == "2.5"));
648        assert!(matches!(&item["none"], AttributeValue::Null(true)));
649        assert_eq!(item_to_json(&item).unwrap(), rec);
650        let err = json_to_item(&json!([1])).unwrap_err().to_string();
651        assert!(err.contains("array"), "{err}");
652        for (v, kind) in [
653            (json!(null), "null"),
654            (json!(true), "boolean"),
655            (json!(1), "number"),
656            (json!("s"), "string"),
657        ] {
658            assert!(json_to_item(&v).unwrap_err().to_string().contains(kind));
659        }
660        assert_eq!(json_kind(&json!({})), "object");
661    }
662
663    #[test]
664    fn streams_attributes_convert() {
665        let mut m = HashMap::new();
666        m.insert("x".to_string(), StreamsAttr::N("1".into()));
667        let image: HashMap<String, StreamsAttr> = [
668            ("s", StreamsAttr::S("v".into())),
669            ("n", StreamsAttr::N("12345678901234567890123".into())),
670            ("b", StreamsAttr::Bool(false)),
671            ("z", StreamsAttr::Null(true)),
672            (
673                "bin",
674                StreamsAttr::B(aws_sdk_dynamodbstreams::primitives::Blob::new(vec![1])),
675            ),
676            ("ss", StreamsAttr::Ss(vec!["a".into()])),
677            ("ns", StreamsAttr::Ns(vec!["2".into()])),
678            (
679                "bs",
680                StreamsAttr::Bs(vec![aws_sdk_dynamodbstreams::primitives::Blob::new(vec![
681                    2,
682                ])]),
683            ),
684            ("l", StreamsAttr::L(vec![StreamsAttr::S("q".into())])),
685            ("m", StreamsAttr::M(m)),
686        ]
687        .into_iter()
688        .map(|(k, v)| (k.to_string(), v))
689        .collect();
690        let v = streams_item_to_json(&image).unwrap();
691        assert_eq!(
692            v,
693            json!({
694                "s": "v", "n": "12345678901234567890123", "b": false, "z": null,
695                "bin": "AQ==", "ss": ["a"], "ns": [2], "bs": ["Ag=="], "l": ["q"], "m": {"x": 1}
696            })
697        );
698    }
699
700    #[test]
701    fn typed_json_round_trips_losslessly() {
702        let mut inner = HashMap::new();
703        inner.insert("q".to_string(), AttributeValue::Bool(true));
704        let item: HashMap<String, AttributeValue> = [
705            ("s", AttributeValue::S("x".into())),
706            (
707                "n",
708                AttributeValue::N("123456789012345678901234567890".into()),
709            ),
710            ("b", AttributeValue::B(Blob::new(vec![9, 8]))),
711            ("t", AttributeValue::Bool(false)),
712            ("z", AttributeValue::Null(true)),
713            ("ss", AttributeValue::Ss(vec!["a".into()])),
714            ("ns", AttributeValue::Ns(vec!["1".into()])),
715            ("bs", AttributeValue::Bs(vec![Blob::new(vec![1])])),
716            ("l", AttributeValue::L(vec![AttributeValue::N("2".into())])),
717            ("m", AttributeValue::M(inner)),
718        ]
719        .into_iter()
720        .map(|(k, v)| (k.to_string(), v))
721        .collect();
722        let typed = item_to_typed_json(&item);
723        assert_eq!(typed["n"], json!({"N": "123456789012345678901234567890"}));
724        assert_eq!(typed["b"], json!({"B": "CQg="}));
725        assert_eq!(typed_json_to_item(&typed).unwrap(), item);
726    }
727
728    #[test]
729    fn typed_json_rejects_malformed_input() {
730        for bad in [
731            json!([1]),
732            json!({"a": "plain"}),
733            json!({"a": {"S": 1}}),
734            json!({"a": {"N": 1}}),
735            json!({"a": {"B": 1}}),
736            json!({"a": {"B": "!!"}}),
737            json!({"a": {"BOOL": "x"}}),
738            json!({"a": {"NULL": 1}}),
739            json!({"a": {"SS": [1]}}),
740            json!({"a": {"BS": "x"}}),
741            json!({"a": {"L": "x"}}),
742            json!({"a": {"M": []}}),
743            json!({"a": {"X": 1}}),
744            json!({"a": {"S": "x", "N": "1"}}),
745        ] {
746            assert!(typed_json_to_item(&bad).is_err(), "{bad}");
747        }
748    }
749
750    #[test]
751    fn item_size_follows_sizing_rules() {
752        let item = json_to_item(&json!({
753            "pk": "abcd", "n": 12345, "flag": true, "l": ["ab"], "m": {"k": "v"}
754        }))
755        .unwrap();
756        // pk: 2+4, n: 1+(3+1), flag: 4+1, l: 1+3+1+2, m: 1+3+(1+1+1)
757        assert_eq!(item_size(&item), 6 + 5 + 5 + 7 + 7);
758        let sets: HashMap<String, AttributeValue> = [
759            ("a", AttributeValue::Ss(vec!["xy".into(), "z".into()])),
760            ("b", AttributeValue::Ns(vec!["1".into(), "22".into()])),
761            ("c", AttributeValue::Bs(vec![Blob::new(vec![0; 4])])),
762            ("d", AttributeValue::B(Blob::new(vec![0; 10]))),
763            ("e", AttributeValue::Null(true)),
764        ]
765        .into_iter()
766        .map(|(k, v)| (k.to_string(), v))
767        .collect();
768        assert_eq!(
769            item_size(&sets),
770            (1 + 3) + (1 + 4) + (1 + 4) + (1 + 10) + (1 + 1)
771        );
772        assert_eq!(number_size(&"9".repeat(60)), 21);
773    }
774
775    #[test]
776    fn value_format_selects_plain_or_typed() {
777        let item: HashMap<String, AttributeValue> = [
778            ("ss".to_string(), AttributeValue::Ss(vec!["a".into()])),
779            ("n".to_string(), AttributeValue::N("1.50".into())),
780            (
781                "l".to_string(),
782                AttributeValue::L(vec![AttributeValue::M(HashMap::from([(
783                    "b".to_string(),
784                    AttributeValue::B(Blob::new(vec![1])),
785                )]))]),
786            ),
787        ]
788        .into_iter()
789        .collect();
790        assert_eq!(
791            ValueFormat::Plain.item_to_json(&item).unwrap(),
792            json!({"ss": ["a"], "n": 1.5, "l": [{"b": "AQ=="}]})
793        );
794        let typed = ValueFormat::Typed.item_to_json(&item).unwrap();
795        assert_eq!(
796            typed,
797            json!({"ss": {"SS": ["a"]}, "n": {"N": "1.50"}, "l": {"L": [{"M": {"b": {"B": "AQ=="}}}]}})
798        );
799        assert_eq!(typed_json_to_item(&typed).unwrap(), item);
800        assert_eq!(
801            typed_json_to_attribute(&json!({"NS": ["1", "2"]})).unwrap(),
802            AttributeValue::Ns(vec!["1".into(), "2".into()])
803        );
804        assert!(typed_json_to_attribute(&json!("plain")).is_err());
805        assert_eq!(ValueFormat::default(), ValueFormat::Plain);
806        let f: ValueFormat = serde_json::from_value(json!("typed")).unwrap();
807        assert_eq!(f, ValueFormat::Typed);
808    }
809
810    #[test]
811    fn streams_images_convert_to_typed_json() {
812        let image: HashMap<String, StreamsAttr> = [
813            (
814                "ns",
815                StreamsAttr::Ns(vec!["12345678901234567890123".into()]),
816            ),
817            (
818                "bs",
819                StreamsAttr::Bs(vec![aws_sdk_dynamodbstreams::primitives::Blob::new(vec![
820                    2,
821                ])]),
822            ),
823        ]
824        .into_iter()
825        .map(|(k, v)| (k.to_string(), v))
826        .collect();
827        assert_eq!(
828            ValueFormat::Typed.streams_item_to_json(&image).unwrap(),
829            json!({"ns": {"NS": ["12345678901234567890123"]}, "bs": {"BS": ["Ag=="]}})
830        );
831        assert_eq!(
832            ValueFormat::Plain.streams_item_to_json(&image).unwrap()["bs"],
833            json!(["Ag=="])
834        );
835    }
836}