use std::collections::HashMap;
use serde_json::{Map, Value};
pub type RejectIf = fn(&Map<String, Value>) -> bool;
#[derive(Debug, Clone)]
pub struct NestedAttributesConfig {
pub association: String,
pub allow_destroy: bool,
pub limit: Option<usize>,
pub reject_if: Option<RejectIf>,
}
impl NestedAttributesConfig {
#[must_use]
pub fn new(association: &str) -> Self {
Self {
association: association.to_owned(),
allow_destroy: false,
limit: None,
reject_if: None,
}
}
#[must_use]
pub fn allow_destroy(mut self) -> Self {
self.allow_destroy = true;
self
}
#[must_use]
pub fn limit(mut self, limit: usize) -> Self {
self.limit = Some(limit);
self
}
#[must_use]
pub fn reject_if(mut self, predicate: RejectIf) -> Self {
self.reject_if = Some(predicate);
self
}
}
#[must_use]
pub fn accepts_nested_attributes_for(association: &str) -> NestedAttributesConfig {
NestedAttributesConfig::new(association)
}
#[derive(Debug, Clone, Default)]
pub struct NestedAttributesRegistry {
configs: HashMap<String, NestedAttributesConfig>,
}
impl NestedAttributesRegistry {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add(&mut self, config: NestedAttributesConfig) {
self.configs.insert(config.association.clone(), config);
}
#[must_use]
pub fn get(&self, association: &str) -> Option<&NestedAttributesConfig> {
self.configs.get(association)
}
}
pub trait NestedAttributes {
fn nested_attributes_registry() -> &'static NestedAttributesRegistry;
}
#[derive(Debug, Clone, PartialEq)]
pub struct NestedRecordAssignment {
pub association: String,
pub index: usize,
pub attributes: Map<String, Value>,
pub marked_for_destruction: bool,
}
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
pub enum NestedAttributesError {
#[error("nested attributes root must be an object")]
InvalidRoot,
#[error("nested attributes for {0} must be an object or array of objects")]
InvalidPayload(String),
#[error("nested attributes for {association} exceed limit {limit}")]
TooManyRecords {
association: String,
limit: usize,
},
#[error("nested attributes contain a circular reference through {0}")]
CircularReference(String),
}
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(¶ms, 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(¶ms, 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(¶ms, 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(¶ms, 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(¶ms, 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(¶ms, 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(¶ms, UserRecord::nested_attributes_registry())
.expect("nested attributes should parse");
assert!(assignments.is_empty());
}
}