rustrails-record 0.1.2

ORM layer (ActiveRecord equivalent)
Documentation
use std::fmt;
use std::sync::Arc;

use serde_json::Value;

/// Errors returned while serializing or deserializing attributes.
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
pub enum SerializationError {
    /// Encoding failed.
    #[error("encode failed: {0}")]
    Encode(String),
    /// Decoding failed.
    #[error("decode failed: {0}")]
    Decode(String),
}

/// Serializer contract for JSON-backed custom coders.
pub trait AttributeCoder: Send + Sync {
    /// Serializes a JSON value into a database column string.
    fn dump(&self, value: &Value) -> Result<String, SerializationError>;
    /// Deserializes a database column string into JSON.
    fn load(&self, raw: &str) -> Result<Value, SerializationError>;
}

/// Default coder that round-trips JSON with `serde_json`.
#[derive(Debug, Default)]
pub struct JsonCoder;

impl AttributeCoder for JsonCoder {
    fn dump(&self, value: &Value) -> Result<String, SerializationError> {
        serde_json::to_string(value).map_err(|error| SerializationError::Encode(error.to_string()))
    }

    fn load(&self, raw: &str) -> Result<Value, SerializationError> {
        serde_json::from_str(raw).map_err(|error| SerializationError::Decode(error.to_string()))
    }
}

/// Function-pointer based coder for custom serialization behavior.
#[derive(Clone)]
pub struct FunctionCoder {
    dump_fn: fn(&Value) -> Result<String, SerializationError>,
    load_fn: fn(&str) -> Result<Value, SerializationError>,
}

impl fmt::Debug for FunctionCoder {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("FunctionCoder(<functions>)")
    }
}

impl FunctionCoder {
    /// Creates a custom coder from `dump_fn` and `load_fn`.
    #[must_use]
    pub fn new(
        dump_fn: fn(&Value) -> Result<String, SerializationError>,
        load_fn: fn(&str) -> Result<Value, SerializationError>,
    ) -> Self {
        Self { dump_fn, load_fn }
    }
}

impl AttributeCoder for FunctionCoder {
    fn dump(&self, value: &Value) -> Result<String, SerializationError> {
        (self.dump_fn)(value)
    }

    fn load(&self, raw: &str) -> Result<Value, SerializationError> {
        (self.load_fn)(raw)
    }
}

/// Metadata describing a serialized attribute and its coder.
#[derive(Clone)]
pub struct SerializedFieldConfig {
    /// The attribute name.
    pub field: String,
    coder: Arc<dyn AttributeCoder>,
}

impl fmt::Debug for SerializedFieldConfig {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("SerializedFieldConfig")
            .field("field", &self.field)
            .field("coder", &"<coder>")
            .finish()
    }
}

impl SerializedFieldConfig {
    /// Creates serialization metadata for `field` using `coder`.
    #[must_use]
    pub fn new(field: &str, coder: impl AttributeCoder + 'static) -> Self {
        Self {
            field: field.to_owned(),
            coder: Arc::new(coder),
        }
    }

    /// Serializes `value` with the configured coder.
    pub fn dump(&self, value: &Value) -> Result<String, SerializationError> {
        self.coder.dump(value)
    }

    /// Deserializes `raw` with the configured coder.
    pub fn load(&self, raw: &str) -> Result<Value, SerializationError> {
        self.coder.load(raw)
    }
}

/// Declares a serialized attribute configuration.
#[must_use]
pub fn serialize_attribute(
    field: &str,
    coder: impl AttributeCoder + 'static,
) -> SerializedFieldConfig {
    SerializedFieldConfig::new(field, coder)
}

/// Trait implemented by records that declare serialized attributes.
pub trait SerializedAttribute {
    /// Returns serialized attribute metadata for the record type.
    fn serialized_attributes() -> &'static [SerializedFieldConfig] {
        &[]
    }
}

#[cfg(test)]
mod tests {
    use std::sync::LazyLock;

    use serde_json::json;

    use super::{
        FunctionCoder, JsonCoder, SerializationError, SerializedAttribute, SerializedFieldConfig,
        serialize_attribute,
    };

    struct ProfileRecord;

    impl SerializedAttribute for ProfileRecord {
        fn serialized_attributes() -> &'static [SerializedFieldConfig] {
            static CONFIGS: LazyLock<Vec<SerializedFieldConfig>> =
                LazyLock::new(|| vec![serialize_attribute("settings", JsonCoder)]);
            CONFIGS.as_slice()
        }
    }

    fn reverse_dump(value: &serde_json::Value) -> Result<String, SerializationError> {
        let text = value
            .as_str()
            .ok_or_else(|| SerializationError::Encode("expected string".to_owned()))?;
        Ok(text.chars().rev().collect())
    }

    fn reverse_load(raw: &str) -> Result<serde_json::Value, SerializationError> {
        if raw.is_empty() {
            return Err(SerializationError::Decode("empty payload".to_owned()));
        }
        Ok(json!(raw.chars().rev().collect::<String>()))
    }

    #[test]
    fn json_coder_round_trips_json_values() {
        let config = serialize_attribute("settings", JsonCoder);
        let dumped = config
            .dump(&json!({"theme": "dark"}))
            .expect("dump should succeed");
        let loaded = config.load(&dumped).expect("load should succeed");
        assert_eq!(loaded, json!({"theme": "dark"}));
    }

    #[test]
    fn function_coder_supports_custom_serialization() {
        let config =
            serialize_attribute("nickname", FunctionCoder::new(reverse_dump, reverse_load));
        let dumped = config
            .dump(&json!("stressed"))
            .expect("dump should succeed");
        let loaded = config.load(&dumped).expect("load should succeed");

        assert_eq!(dumped, "desserts");
        assert_eq!(loaded, json!("stressed"));
    }

    #[test]
    fn function_coder_surfaces_encode_errors() {
        let config =
            serialize_attribute("nickname", FunctionCoder::new(reverse_dump, reverse_load));

        assert_eq!(
            config.dump(&json!(1)).map_err(|error| error.to_string()),
            Err("encode failed: expected string".to_owned())
        );
    }

    #[test]
    fn function_coder_surfaces_decode_errors() {
        let config =
            serialize_attribute("nickname", FunctionCoder::new(reverse_dump, reverse_load));

        assert_eq!(
            config.load("").map_err(|error| error.to_string()),
            Err("decode failed: empty payload".to_owned())
        );
    }

    #[test]
    fn trait_exposes_declared_serialized_attributes() {
        assert_eq!(ProfileRecord::serialized_attributes().len(), 1);
        assert_eq!(ProfileRecord::serialized_attributes()[0].field, "settings");
    }
}