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
//! The plain (deserialized) value model — the Rust counterpart of the Python
//! objects boto3's `TypeDeserializer` produces (`str`, `Decimal`, `bool`,
//! `None`, `list`, `dict`) and of the PHP `decodeAttr` output.
//!
//! Numbers are kept as their ORIGINAL DynamoDB "N" string so precision and the
//! integral/fractional distinction are exact (the parity foundation — see
//! [`crate::ddb_number`]). Object key order is preserved (insertion order),
//! mirroring Python dict / PHP array ordering, which the byte-identical cursor and
//! canonical-JSON forms depend on.

use crate::ddb_number::cursor_token;
use crate::errors::GraphDDBError;
use indexmap_shim::IndexMap;

/// A minimal insertion-ordered string map. Re-exported name kept local so the
/// crate has no external ordered-map dependency; `serde_json`'s `preserve_order`
/// feature backs `serde_json::Value` ordering, but our plain [`Value`] uses this
/// tiny purpose-built map.
pub mod indexmap_shim {
    /// Insertion-ordered string-keyed map (small, allocation-light).
    #[derive(Debug, Clone, PartialEq, Default)]
    pub struct IndexMap<V> {
        entries: Vec<(String, V)>,
    }

    impl<V> IndexMap<V> {
        /// A new empty map.
        pub fn new() -> Self {
            Self {
                entries: Vec::new(),
            }
        }
        /// Insert or overwrite `key`, preserving first-insertion position.
        pub fn insert(&mut self, key: String, value: V) {
            if let Some(slot) = self.entries.iter_mut().find(|(k, _)| *k == key) {
                slot.1 = value;
            } else {
                self.entries.push((key, value));
            }
        }
        /// Get a reference to `key`'s value.
        pub fn get(&self, key: &str) -> Option<&V> {
            self.entries.iter().find(|(k, _)| k == key).map(|(_, v)| v)
        }
        /// Remove `key`, returning its value if present (order of the rest kept).
        pub fn remove(&mut self, key: &str) -> Option<V> {
            let pos = self.entries.iter().position(|(k, _)| k == key)?;
            Some(self.entries.remove(pos).1)
        }
        /// True if `key` is present.
        pub fn contains_key(&self, key: &str) -> bool {
            self.entries.iter().any(|(k, _)| k == key)
        }
        /// Iterate entries in insertion order.
        pub fn iter(&self) -> impl Iterator<Item = (&String, &V)> {
            self.entries.iter().map(|(k, v)| (k, v))
        }
        /// The keys in insertion order.
        pub fn keys(&self) -> impl Iterator<Item = &String> {
            self.entries.iter().map(|(k, _)| k)
        }
        /// The number of entries.
        pub fn len(&self) -> usize {
            self.entries.len()
        }
        /// True when empty.
        pub fn is_empty(&self) -> bool {
            self.entries.is_empty()
        }
    }

    impl<V> FromIterator<(String, V)> for IndexMap<V> {
        fn from_iter<T: IntoIterator<Item = (String, V)>>(iter: T) -> Self {
            let mut map = IndexMap::new();
            for (k, v) in iter {
                map.insert(k, v);
            }
            map
        }
    }
}

/// A plain deserialized value.
#[derive(Debug, Clone, PartialEq)]
pub enum Value {
    /// A string.
    S(String),
    /// A number, kept as its original DynamoDB "N" string for exact precision.
    N(String),
    /// A boolean.
    Bool(bool),
    /// A null.
    Null,
    /// A list.
    L(Vec<Value>),
    /// A map (insertion-ordered).
    M(IndexMap<Value>),
}

impl Value {
    /// The string form used when a value is substituted into a `{param}` template
    /// (mirrors Python `str(value)` on the deserialized value). Only string / key
    /// values flow through templates in the single-table layout.
    pub fn to_template_string(&self) -> String {
        match self {
            Value::S(s) => s.clone(),
            Value::N(n) => n.clone(),
            Value::Bool(b) => {
                // Python str(True) == "True"; not reachable for key templates.
                if *b {
                    "True".to_string()
                } else {
                    "False".to_string()
                }
            }
            Value::Null => "None".to_string(),
            _ => String::new(),
        }
    }

    /// True when this is a JSON-null.
    pub fn is_null(&self) -> bool {
        matches!(self, Value::Null)
    }

    /// Convert to a `serde_json::Value` for the output boundary, rendering a number
    /// as an integer when integral, else a float — mirroring the Python
    /// `_decimal_to_number` (int(Decimal) if integral else float(Decimal)) the
    /// runners apply. This is the value the conformance runner serializes.
    pub fn to_json(&self) -> serde_json::Value {
        use serde_json::Value as J;
        match self {
            Value::S(s) => J::String(s.clone()),
            Value::Bool(b) => J::Bool(*b),
            Value::Null => J::Null,
            Value::N(n) => number_to_json(n),
            Value::L(items) => J::Array(items.iter().map(Value::to_json).collect()),
            Value::M(map) => {
                let mut obj = serde_json::Map::new();
                for (k, v) in map.iter() {
                    obj.insert(k.clone(), v.to_json());
                }
                J::Object(obj)
            }
        }
    }
}

/// Render a DynamoDB "N" string as a JSON number: an integer when the value is
/// integral (arbitrary precision preserved when it fits i64/u64/f64), else the
/// float form. Mirrors the Python `_decimal_to_number`.
fn number_to_json(n: &str) -> serde_json::Value {
    use serde_json::Value as J;
    // Exact integrality via the same string arithmetic the cursor token uses.
    if let Ok(token) = crate::ddb_number::cursor_token(n) {
        // token is either an integer (integral path) or a float repr (fractional).
        if let Ok(num) = serde_json::from_str::<serde_json::Number>(&token) {
            return J::Number(num);
        }
    }
    // Fallback: parse directly.
    if let Ok(num) = serde_json::from_str::<serde_json::Number>(n) {
        return J::Number(num);
    }
    J::String(n.to_string())
}

/// Serialize a plain value into the base64url-cursor JSON form, byte-identical to
/// Python `json.dumps(..., separators=(",", ":"), ensure_ascii=False,
/// default=_default)` — numbers via [`cursor_token`], strings JSON-escaped,
/// objects in insertion order.
pub fn cursor_json(value: &Value) -> Result<String, GraphDDBError> {
    let mut out = String::new();
    write_cursor_json(value, &mut out)?;
    Ok(out)
}

fn write_cursor_json(value: &Value, out: &mut String) -> Result<(), GraphDDBError> {
    match value {
        Value::S(s) => write_json_string(s, out),
        Value::N(n) => out.push_str(&cursor_token(n)?),
        Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
        Value::Null => out.push_str("null"),
        Value::L(items) => {
            out.push('[');
            for (i, item) in items.iter().enumerate() {
                if i > 0 {
                    out.push(',');
                }
                write_cursor_json(item, out)?;
            }
            out.push(']');
        }
        Value::M(map) => {
            out.push('{');
            for (i, (k, v)) in map.iter().enumerate() {
                if i > 0 {
                    out.push(',');
                }
                write_json_string(k, out);
                out.push(':');
                write_cursor_json(v, out)?;
            }
            out.push('}');
        }
    }
    Ok(())
}

/// Write a JSON string literal matching Python `json.dumps(..., ensure_ascii=False)`
/// escaping: the mandatory escapes `\" \\ \n \r \t \b \f` and control chars as
/// `\uXXXX`; all other (incl. non-ASCII) characters verbatim.
pub(crate) fn write_json_string(s: &str, out: &mut String) {
    out.push('"');
    for ch in s.chars() {
        match ch {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            '\u{08}' => out.push_str("\\b"),
            '\u{0C}' => out.push_str("\\f"),
            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
            c => out.push(c),
        }
    }
    out.push('"');
}

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

    #[test]
    fn cursor_json_number_and_string_and_order() {
        let mut m = IndexMap::new();
        m.insert("PK".to_string(), Value::S("GROUP#g1".to_string()));
        m.insert("SK".to_string(), Value::S("USER#u1".to_string()));
        m.insert("n".to_string(), Value::N("100".to_string()));
        let v = Value::M(m);
        // Insertion order preserved, compact separators, number unquoted.
        assert_eq!(
            cursor_json(&v).unwrap(),
            "{\"PK\":\"GROUP#g1\",\"SK\":\"USER#u1\",\"n\":100}"
        );
    }

    #[test]
    fn cursor_json_escapes_like_python() {
        let s = Value::S("a\"b\\c\nd".to_string());
        assert_eq!(cursor_json(&s).unwrap(), "\"a\\\"b\\\\c\\nd\"");
    }
}