graphddb_runtime 0.7.5

Rust runtime for GraphDDB — interprets the language-neutral IR (manifest.json + operations.json) and executes the validated access patterns against DynamoDB.
Documentation
//! Marshaling between the plain value model and `aws_sdk_dynamodb`
//! `AttributeValue`, mirroring boto3's `TypeSerializer` / `TypeDeserializer`.
//!
//! - **serialize** a template-resolved string or a typed JSON param into an
//!   `AttributeValue` (str -> S, number -> N with the Python `str(int)` /
//!   `repr(float)` string, bool -> BOOL, null -> NULL, list -> L, object -> M) —
//!   the `TypeSerializer` counterpart;
//! - **deserialize** an `AttributeValue` into a plain [`Value`], keeping numbers
//!   as their original "N" string for exact precision — the `TypeDeserializer`
//!   counterpart, matching boto3's `Decimal` and PHP's `decodeAttr`.

use std::collections::HashMap;

use aws_sdk_dynamodb::types::AttributeValue;
use serde_json::Value as Json;

use crate::errors::GraphDDBError;
use crate::pyfloat::py_repr;
use crate::value::indexmap_shim::IndexMap;
use crate::value::Value;

/// Serialize a template-resolved STRING key/value into an `AttributeValue::S`.
/// Key and sort-key values are strings in the single-table layout, so the
/// dominant serialize path is string -> S (parity with Python
/// `serializer.serialize(resolve_template(...))`).
pub fn serialize_string(s: impl Into<String>) -> AttributeValue {
    AttributeValue::S(s.into())
}

/// Serialize a typed JSON value the way boto3 `TypeSerializer.serialize` does.
///
/// - string -> S
/// - integer number -> N (`str(int)`), float number -> N (`repr(float)`)
/// - bool -> BOOL
/// - null -> NULL(true)
/// - array -> L
/// - object -> M
pub fn serialize_json(value: &Json) -> Result<AttributeValue, GraphDDBError> {
    Ok(match value {
        Json::String(s) => AttributeValue::S(s.clone()),
        Json::Bool(b) => AttributeValue::Bool(*b),
        Json::Null => AttributeValue::Null(true),
        Json::Number(n) => AttributeValue::N(number_to_ddb_string(n)?),
        Json::Array(items) => {
            let mut out = Vec::with_capacity(items.len());
            for it in items {
                out.push(serialize_json(it)?);
            }
            AttributeValue::L(out)
        }
        Json::Object(map) => {
            let mut out: HashMap<String, AttributeValue> = HashMap::new();
            for (k, v) in map {
                out.insert(k.clone(), serialize_json(v)?);
            }
            AttributeValue::M(out)
        }
    })
}

/// Serialize an integer into an `AttributeValue::N` — used for ADD/counter deltas
/// (the Python `serializer.serialize(int(value))` path).
pub fn serialize_int(n: i64) -> AttributeValue {
    AttributeValue::N(n.to_string())
}

/// The DynamoDB "N" string for a `serde_json::Number`, matching Python
/// `str(int)` (integers) / `repr(float)` (floats) as boto3's serializer would
/// (it stores a Decimal built from the value's string form).
fn number_to_ddb_string(n: &serde_json::Number) -> Result<String, GraphDDBError> {
    if let Some(i) = n.as_i64() {
        Ok(i.to_string())
    } else if let Some(u) = n.as_u64() {
        Ok(u.to_string())
    } else if let Some(f) = n.as_f64() {
        py_repr(f)
    } else {
        Err(GraphDDBError::new(format!("cannot serialize number {n}")))
    }
}

/// Deserialize an `AttributeValue` into a plain [`Value`], keeping numbers as the
/// original "N" string (parity with boto3 `Decimal` / PHP `decodeAttr`).
pub fn deserialize(av: &AttributeValue) -> Value {
    match av {
        AttributeValue::S(s) => Value::S(s.clone()),
        AttributeValue::N(n) => Value::N(n.clone()),
        AttributeValue::Bool(b) => Value::Bool(*b),
        AttributeValue::Null(_) => Value::Null,
        AttributeValue::L(items) => Value::L(items.iter().map(deserialize).collect()),
        AttributeValue::M(map) => {
            // boto3 preserves the map order it receives; DynamoDB returns map
            // attributes in an unspecified order, but the runtime never encodes a
            // deserialized M into a cursor (keys are flat S/N), so order here is
            // not parity-load-bearing. Kept stable by iterating the SDK map.
            let mut out = IndexMap::new();
            for (k, v) in map {
                out.insert(k.clone(), deserialize(v));
            }
            Value::M(out)
        }
        AttributeValue::Ss(items) => Value::L(items.iter().map(|s| Value::S(s.clone())).collect()),
        AttributeValue::Ns(items) => Value::L(items.iter().map(|n| Value::N(n.clone())).collect()),
        _ => Value::Null,
    }
}

/// Deserialize a whole item (`HashMap<String, AttributeValue>`) into an ordered
/// plain map. Order is stabilized (sorted) for reproducibility; the runtime does
/// not rely on item attribute order for parity (hydration selects by name).
pub fn deserialize_item(item: &HashMap<String, AttributeValue>) -> IndexMap<Value> {
    let mut keys: Vec<&String> = item.keys().collect();
    keys.sort();
    let mut out = IndexMap::new();
    for k in keys {
        out.insert(k.clone(), deserialize(&item[k]));
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn serialize_int_and_float_ddb_strings() {
        let n = serialize_json(&json!(1)).unwrap();
        assert!(matches!(n, AttributeValue::N(ref s) if s == "1"));
        let neg = serialize_json(&json!(-1)).unwrap();
        assert!(matches!(neg, AttributeValue::N(ref s) if s == "-1"));
        let f = serialize_json(&json!(1.5)).unwrap();
        assert!(matches!(f, AttributeValue::N(ref s) if s == "1.5"));
    }

    #[test]
    fn deserialize_keeps_number_string() {
        let av = AttributeValue::N("12345678901234567890".to_string());
        assert_eq!(
            deserialize(&av),
            Value::N("12345678901234567890".to_string())
        );
    }
}