rustrails-record 0.1.2

ORM layer (ActiveRecord equivalent)
Documentation
use std::collections::HashMap;

use serde_json::{Map, Value};

/// Predicate used to reject nested attribute payloads.
pub type RejectIf = fn(&Map<String, Value>) -> bool;

/// Metadata describing accepted nested attributes for an association.
#[derive(Debug, Clone)]
pub struct NestedAttributesConfig {
    /// The association name.
    pub association: String,
    /// Whether `_destroy` marks nested records for deletion.
    pub allow_destroy: bool,
    /// Maximum number of records allowed in one assignment.
    pub limit: Option<usize>,
    /// Optional predicate that rejects nested payloads.
    pub reject_if: Option<RejectIf>,
}

impl NestedAttributesConfig {
    /// Creates nested-attribute metadata for `association`.
    #[must_use]
    pub fn new(association: &str) -> Self {
        Self {
            association: association.to_owned(),
            allow_destroy: false,
            limit: None,
            reject_if: None,
        }
    }

    /// Enables `_destroy` handling.
    #[must_use]
    pub fn allow_destroy(mut self) -> Self {
        self.allow_destroy = true;
        self
    }

    /// Limits the number of accepted nested records.
    #[must_use]
    pub fn limit(mut self, limit: usize) -> Self {
        self.limit = Some(limit);
        self
    }

    /// Rejects nested records when `predicate` returns `true`.
    #[must_use]
    pub fn reject_if(mut self, predicate: RejectIf) -> Self {
        self.reject_if = Some(predicate);
        self
    }
}

/// Declares nested-attribute metadata for an association.
#[must_use]
pub fn accepts_nested_attributes_for(association: &str) -> NestedAttributesConfig {
    NestedAttributesConfig::new(association)
}

/// Registry of nested-attribute declarations.
#[derive(Debug, Clone, Default)]
pub struct NestedAttributesRegistry {
    configs: HashMap<String, NestedAttributesConfig>,
}

impl NestedAttributesRegistry {
    /// Creates an empty registry.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Registers nested-attribute metadata.
    pub fn add(&mut self, config: NestedAttributesConfig) {
        self.configs.insert(config.association.clone(), config);
    }

    /// Returns metadata for `association`.
    #[must_use]
    pub fn get(&self, association: &str) -> Option<&NestedAttributesConfig> {
        self.configs.get(association)
    }
}

/// Trait implemented by records that declare nested-attribute metadata.
pub trait NestedAttributes {
    /// Returns nested-attribute metadata for the record.
    fn nested_attributes_registry() -> &'static NestedAttributesRegistry;
}

/// A parsed nested-attributes assignment.
#[derive(Debug, Clone, PartialEq)]
pub struct NestedRecordAssignment {
    /// The association name.
    pub association: String,
    /// Position within the provided payload.
    pub index: usize,
    /// Nested attributes excluding `_destroy`.
    pub attributes: Map<String, Value>,
    /// Whether this nested record is marked for destruction.
    pub marked_for_destruction: bool,
}

/// Errors returned while parsing nested attributes.
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
pub enum NestedAttributesError {
    /// The root value was not an object.
    #[error("nested attributes root must be an object")]
    InvalidRoot,
    /// A nested association payload had the wrong shape.
    #[error("nested attributes for {0} must be an object or array of objects")]
    InvalidPayload(String),
    /// The payload exceeds the configured limit.
    #[error("nested attributes for {association} exceed limit {limit}")]
    TooManyRecords {
        /// The association name.
        association: String,
        /// The configured record limit.
        limit: usize,
    },
    /// Circular references are not allowed.
    #[error("nested attributes contain a circular reference through {0}")]
    CircularReference(String),
}

/// Parses nested attributes from a parameter payload.
pub fn assign_nested_attributes(
    params: &Value,
    registry: &NestedAttributesRegistry,
) -> Result<Vec<NestedRecordAssignment>, NestedAttributesError> {
    let root = root_object(params)?;
    let mut assignments = Vec::new();

    for (key, value) in root {
        let Some(association) = key.strip_suffix("_attributes") else {
            continue;
        };
        let Some(config) = registry.get(association) else {
            continue;
        };

        let entries = object_entries(value, association)?;
        if let Some(limit) = config.limit
            && entries.len() > limit
        {
            return Err(NestedAttributesError::TooManyRecords {
                association: association.to_owned(),
                limit,
            });
        }

        for (index, entry) in entries.into_iter().enumerate() {
            let mut stack = vec![association.to_owned()];
            validate_no_circular_references(&Value::Object(entry.clone()), &mut stack)?;

            let marked_for_destruction = config.allow_destroy && destroy_flag(&entry);
            if config.reject_if.is_some_and(|predicate| predicate(&entry))
                && !marked_for_destruction
            {
                continue;
            }

            let attributes = entry
                .into_iter()
                .filter(|(field, _)| field != "_destroy")
                .collect::<Map<_, _>>();

            assignments.push(NestedRecordAssignment {
                association: association.to_owned(),
                index,
                attributes,
                marked_for_destruction,
            });
        }
    }

    Ok(assignments)
}

fn root_object(params: &Value) -> Result<&Map<String, Value>, NestedAttributesError> {
    let object = params
        .as_object()
        .ok_or(NestedAttributesError::InvalidRoot)?;
    if object.keys().any(|key| key.ends_with("_attributes")) {
        return Ok(object);
    }

    if object.len() == 1
        && let Some(Value::Object(inner)) = object.values().next()
    {
        return Ok(inner);
    }

    Ok(object)
}

fn object_entries(
    value: &Value,
    association: &str,
) -> Result<Vec<Map<String, Value>>, NestedAttributesError> {
    match value {
        Value::Object(map) => Ok(vec![map.clone()]),
        Value::Array(entries) => entries
            .iter()
            .map(|entry| {
                entry
                    .as_object()
                    .cloned()
                    .ok_or_else(|| NestedAttributesError::InvalidPayload(association.to_owned()))
            })
            .collect(),
        _ => Err(NestedAttributesError::InvalidPayload(
            association.to_owned(),
        )),
    }
}

fn validate_no_circular_references(
    value: &Value,
    stack: &mut Vec<String>,
) -> Result<(), NestedAttributesError> {
    match value {
        Value::Object(object) => {
            for (key, nested) in object {
                if let Some(association) = key.strip_suffix("_attributes") {
                    if stack.iter().any(|ancestor| ancestor == association) {
                        return Err(NestedAttributesError::CircularReference(
                            association.to_owned(),
                        ));
                    }
                    stack.push(association.to_owned());
                    validate_no_circular_references(nested, stack)?;
                    stack.pop();
                } else {
                    validate_no_circular_references(nested, stack)?;
                }
            }
            Ok(())
        }
        Value::Array(values) => {
            for nested in values {
                validate_no_circular_references(nested, stack)?;
            }
            Ok(())
        }
        _ => Ok(()),
    }
}

fn destroy_flag(attributes: &Map<String, Value>) -> bool {
    match attributes.get("_destroy") {
        Some(Value::Bool(flag)) => *flag,
        Some(Value::Number(number)) => number.as_i64() == Some(1),
        Some(Value::String(text)) => matches!(text.as_str(), "1" | "true" | "TRUE"),
        _ => false,
    }
}

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

    use serde_json::json;

    use super::{
        NestedAttributes, NestedAttributesConfig, NestedAttributesError, NestedAttributesRegistry,
        accepts_nested_attributes_for, assign_nested_attributes,
    };

    struct UserRecord;

    fn reject_blank_title(attributes: &serde_json::Map<String, serde_json::Value>) -> bool {
        attributes
            .get("title")
            .and_then(serde_json::Value::as_str)
            .is_some_and(str::is_empty)
    }

    static NESTED: LazyLock<NestedAttributesRegistry> = LazyLock::new(|| {
        let mut registry = NestedAttributesRegistry::new();
        registry.add(
            accepts_nested_attributes_for("posts")
                .allow_destroy()
                .limit(2)
                .reject_if(reject_blank_title),
        );
        registry.add(NestedAttributesConfig::new("profile"));
        registry
    });

    impl NestedAttributes for UserRecord {
        fn nested_attributes_registry() -> &'static NestedAttributesRegistry {
            &NESTED
        }
    }

    #[test]
    fn parses_nested_attributes_under_model_root() {
        let params = json!({"user": {"posts_attributes": [{"title": "Hello"}]}});
        let assignments =
            assign_nested_attributes(&params, UserRecord::nested_attributes_registry())
                .expect("nested attributes should parse");

        assert_eq!(assignments.len(), 1);
        assert_eq!(assignments[0].association, "posts");
        assert_eq!(
            assignments[0].attributes.get("title"),
            Some(&json!("Hello"))
        );
    }

    #[test]
    fn parses_top_level_nested_attributes() {
        let params = json!({"profile_attributes": {"bio": "Hello"}});
        let assignments =
            assign_nested_attributes(&params, UserRecord::nested_attributes_registry())
                .expect("nested attributes should parse");

        assert_eq!(assignments.len(), 1);
        assert_eq!(assignments[0].association, "profile");
    }

    #[test]
    fn marks_records_for_destruction_when_allowed() {
        let params = json!({"posts_attributes": [{"title": "Hello", "_destroy": true}]});
        let assignments =
            assign_nested_attributes(&params, UserRecord::nested_attributes_registry())
                .expect("nested attributes should parse");

        assert!(assignments[0].marked_for_destruction);
        assert!(!assignments[0].attributes.contains_key("_destroy"));
    }

    #[test]
    fn reject_if_skips_matching_records() {
        let params = json!({"posts_attributes": [{"title": ""}, {"title": "kept"}]});
        let assignments =
            assign_nested_attributes(&params, UserRecord::nested_attributes_registry())
                .expect("nested attributes should parse");

        assert_eq!(assignments.len(), 1);
        assert_eq!(assignments[0].attributes.get("title"), Some(&json!("kept")));
    }

    #[test]
    fn limit_rejects_excess_nested_records() {
        let params = json!({
            "posts_attributes": [
                {"title": "one"},
                {"title": "two"},
                {"title": "three"}
            ]
        });
        assert_eq!(
            assign_nested_attributes(&params, UserRecord::nested_attributes_registry()),
            Err(NestedAttributesError::TooManyRecords {
                association: "posts".to_owned(),
                limit: 2,
            })
        );
    }

    #[test]
    fn invalid_root_returns_error() {
        assert_eq!(
            assign_nested_attributes(&json!(null), UserRecord::nested_attributes_registry()),
            Err(NestedAttributesError::InvalidRoot)
        );
    }

    #[test]
    fn circular_references_are_rejected() {
        let params = json!({
            "posts_attributes": [{
                "title": "Hello",
                "user_attributes": {
                    "posts_attributes": [{"title": "Again"}]
                }
            }]
        });
        assert_eq!(
            assign_nested_attributes(&params, UserRecord::nested_attributes_registry()),
            Err(NestedAttributesError::CircularReference("posts".to_owned()))
        );
    }

    #[test]
    fn unknown_nested_associations_are_ignored() {
        let params = json!({"comments_attributes": [{"body": "ignored"}]});
        let assignments =
            assign_nested_attributes(&params, UserRecord::nested_attributes_registry())
                .expect("nested attributes should parse");
        assert!(assignments.is_empty());
    }
}