Skip to main content

rustrails_record/
nested_attributes.rs

1use std::collections::HashMap;
2
3use serde_json::{Map, Value};
4
5/// Predicate used to reject nested attribute payloads.
6pub type RejectIf = fn(&Map<String, Value>) -> bool;
7
8/// Metadata describing accepted nested attributes for an association.
9#[derive(Debug, Clone)]
10pub struct NestedAttributesConfig {
11    /// The association name.
12    pub association: String,
13    /// Whether `_destroy` marks nested records for deletion.
14    pub allow_destroy: bool,
15    /// Maximum number of records allowed in one assignment.
16    pub limit: Option<usize>,
17    /// Optional predicate that rejects nested payloads.
18    pub reject_if: Option<RejectIf>,
19}
20
21impl NestedAttributesConfig {
22    /// Creates nested-attribute metadata for `association`.
23    #[must_use]
24    pub fn new(association: &str) -> Self {
25        Self {
26            association: association.to_owned(),
27            allow_destroy: false,
28            limit: None,
29            reject_if: None,
30        }
31    }
32
33    /// Enables `_destroy` handling.
34    #[must_use]
35    pub fn allow_destroy(mut self) -> Self {
36        self.allow_destroy = true;
37        self
38    }
39
40    /// Limits the number of accepted nested records.
41    #[must_use]
42    pub fn limit(mut self, limit: usize) -> Self {
43        self.limit = Some(limit);
44        self
45    }
46
47    /// Rejects nested records when `predicate` returns `true`.
48    #[must_use]
49    pub fn reject_if(mut self, predicate: RejectIf) -> Self {
50        self.reject_if = Some(predicate);
51        self
52    }
53}
54
55/// Declares nested-attribute metadata for an association.
56#[must_use]
57pub fn accepts_nested_attributes_for(association: &str) -> NestedAttributesConfig {
58    NestedAttributesConfig::new(association)
59}
60
61/// Registry of nested-attribute declarations.
62#[derive(Debug, Clone, Default)]
63pub struct NestedAttributesRegistry {
64    configs: HashMap<String, NestedAttributesConfig>,
65}
66
67impl NestedAttributesRegistry {
68    /// Creates an empty registry.
69    #[must_use]
70    pub fn new() -> Self {
71        Self::default()
72    }
73
74    /// Registers nested-attribute metadata.
75    pub fn add(&mut self, config: NestedAttributesConfig) {
76        self.configs.insert(config.association.clone(), config);
77    }
78
79    /// Returns metadata for `association`.
80    #[must_use]
81    pub fn get(&self, association: &str) -> Option<&NestedAttributesConfig> {
82        self.configs.get(association)
83    }
84}
85
86/// Trait implemented by records that declare nested-attribute metadata.
87pub trait NestedAttributes {
88    /// Returns nested-attribute metadata for the record.
89    fn nested_attributes_registry() -> &'static NestedAttributesRegistry;
90}
91
92/// A parsed nested-attributes assignment.
93#[derive(Debug, Clone, PartialEq)]
94pub struct NestedRecordAssignment {
95    /// The association name.
96    pub association: String,
97    /// Position within the provided payload.
98    pub index: usize,
99    /// Nested attributes excluding `_destroy`.
100    pub attributes: Map<String, Value>,
101    /// Whether this nested record is marked for destruction.
102    pub marked_for_destruction: bool,
103}
104
105/// Errors returned while parsing nested attributes.
106#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
107pub enum NestedAttributesError {
108    /// The root value was not an object.
109    #[error("nested attributes root must be an object")]
110    InvalidRoot,
111    /// A nested association payload had the wrong shape.
112    #[error("nested attributes for {0} must be an object or array of objects")]
113    InvalidPayload(String),
114    /// The payload exceeds the configured limit.
115    #[error("nested attributes for {association} exceed limit {limit}")]
116    TooManyRecords {
117        /// The association name.
118        association: String,
119        /// The configured record limit.
120        limit: usize,
121    },
122    /// Circular references are not allowed.
123    #[error("nested attributes contain a circular reference through {0}")]
124    CircularReference(String),
125}
126
127/// Parses nested attributes from a parameter payload.
128pub fn assign_nested_attributes(
129    params: &Value,
130    registry: &NestedAttributesRegistry,
131) -> Result<Vec<NestedRecordAssignment>, NestedAttributesError> {
132    let root = root_object(params)?;
133    let mut assignments = Vec::new();
134
135    for (key, value) in root {
136        let Some(association) = key.strip_suffix("_attributes") else {
137            continue;
138        };
139        let Some(config) = registry.get(association) else {
140            continue;
141        };
142
143        let entries = object_entries(value, association)?;
144        if let Some(limit) = config.limit
145            && entries.len() > limit
146        {
147            return Err(NestedAttributesError::TooManyRecords {
148                association: association.to_owned(),
149                limit,
150            });
151        }
152
153        for (index, entry) in entries.into_iter().enumerate() {
154            let mut stack = vec![association.to_owned()];
155            validate_no_circular_references(&Value::Object(entry.clone()), &mut stack)?;
156
157            let marked_for_destruction = config.allow_destroy && destroy_flag(&entry);
158            if config.reject_if.is_some_and(|predicate| predicate(&entry))
159                && !marked_for_destruction
160            {
161                continue;
162            }
163
164            let attributes = entry
165                .into_iter()
166                .filter(|(field, _)| field != "_destroy")
167                .collect::<Map<_, _>>();
168
169            assignments.push(NestedRecordAssignment {
170                association: association.to_owned(),
171                index,
172                attributes,
173                marked_for_destruction,
174            });
175        }
176    }
177
178    Ok(assignments)
179}
180
181fn root_object(params: &Value) -> Result<&Map<String, Value>, NestedAttributesError> {
182    let object = params
183        .as_object()
184        .ok_or(NestedAttributesError::InvalidRoot)?;
185    if object.keys().any(|key| key.ends_with("_attributes")) {
186        return Ok(object);
187    }
188
189    if object.len() == 1
190        && let Some(Value::Object(inner)) = object.values().next()
191    {
192        return Ok(inner);
193    }
194
195    Ok(object)
196}
197
198fn object_entries(
199    value: &Value,
200    association: &str,
201) -> Result<Vec<Map<String, Value>>, NestedAttributesError> {
202    match value {
203        Value::Object(map) => Ok(vec![map.clone()]),
204        Value::Array(entries) => entries
205            .iter()
206            .map(|entry| {
207                entry
208                    .as_object()
209                    .cloned()
210                    .ok_or_else(|| NestedAttributesError::InvalidPayload(association.to_owned()))
211            })
212            .collect(),
213        _ => Err(NestedAttributesError::InvalidPayload(
214            association.to_owned(),
215        )),
216    }
217}
218
219fn validate_no_circular_references(
220    value: &Value,
221    stack: &mut Vec<String>,
222) -> Result<(), NestedAttributesError> {
223    match value {
224        Value::Object(object) => {
225            for (key, nested) in object {
226                if let Some(association) = key.strip_suffix("_attributes") {
227                    if stack.iter().any(|ancestor| ancestor == association) {
228                        return Err(NestedAttributesError::CircularReference(
229                            association.to_owned(),
230                        ));
231                    }
232                    stack.push(association.to_owned());
233                    validate_no_circular_references(nested, stack)?;
234                    stack.pop();
235                } else {
236                    validate_no_circular_references(nested, stack)?;
237                }
238            }
239            Ok(())
240        }
241        Value::Array(values) => {
242            for nested in values {
243                validate_no_circular_references(nested, stack)?;
244            }
245            Ok(())
246        }
247        _ => Ok(()),
248    }
249}
250
251fn destroy_flag(attributes: &Map<String, Value>) -> bool {
252    match attributes.get("_destroy") {
253        Some(Value::Bool(flag)) => *flag,
254        Some(Value::Number(number)) => number.as_i64() == Some(1),
255        Some(Value::String(text)) => matches!(text.as_str(), "1" | "true" | "TRUE"),
256        _ => false,
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use std::sync::LazyLock;
263
264    use serde_json::json;
265
266    use super::{
267        NestedAttributes, NestedAttributesConfig, NestedAttributesError, NestedAttributesRegistry,
268        accepts_nested_attributes_for, assign_nested_attributes,
269    };
270
271    struct UserRecord;
272
273    fn reject_blank_title(attributes: &serde_json::Map<String, serde_json::Value>) -> bool {
274        attributes
275            .get("title")
276            .and_then(serde_json::Value::as_str)
277            .is_some_and(str::is_empty)
278    }
279
280    static NESTED: LazyLock<NestedAttributesRegistry> = LazyLock::new(|| {
281        let mut registry = NestedAttributesRegistry::new();
282        registry.add(
283            accepts_nested_attributes_for("posts")
284                .allow_destroy()
285                .limit(2)
286                .reject_if(reject_blank_title),
287        );
288        registry.add(NestedAttributesConfig::new("profile"));
289        registry
290    });
291
292    impl NestedAttributes for UserRecord {
293        fn nested_attributes_registry() -> &'static NestedAttributesRegistry {
294            &NESTED
295        }
296    }
297
298    #[test]
299    fn parses_nested_attributes_under_model_root() {
300        let params = json!({"user": {"posts_attributes": [{"title": "Hello"}]}});
301        let assignments =
302            assign_nested_attributes(&params, UserRecord::nested_attributes_registry())
303                .expect("nested attributes should parse");
304
305        assert_eq!(assignments.len(), 1);
306        assert_eq!(assignments[0].association, "posts");
307        assert_eq!(
308            assignments[0].attributes.get("title"),
309            Some(&json!("Hello"))
310        );
311    }
312
313    #[test]
314    fn parses_top_level_nested_attributes() {
315        let params = json!({"profile_attributes": {"bio": "Hello"}});
316        let assignments =
317            assign_nested_attributes(&params, UserRecord::nested_attributes_registry())
318                .expect("nested attributes should parse");
319
320        assert_eq!(assignments.len(), 1);
321        assert_eq!(assignments[0].association, "profile");
322    }
323
324    #[test]
325    fn marks_records_for_destruction_when_allowed() {
326        let params = json!({"posts_attributes": [{"title": "Hello", "_destroy": true}]});
327        let assignments =
328            assign_nested_attributes(&params, UserRecord::nested_attributes_registry())
329                .expect("nested attributes should parse");
330
331        assert!(assignments[0].marked_for_destruction);
332        assert!(!assignments[0].attributes.contains_key("_destroy"));
333    }
334
335    #[test]
336    fn reject_if_skips_matching_records() {
337        let params = json!({"posts_attributes": [{"title": ""}, {"title": "kept"}]});
338        let assignments =
339            assign_nested_attributes(&params, UserRecord::nested_attributes_registry())
340                .expect("nested attributes should parse");
341
342        assert_eq!(assignments.len(), 1);
343        assert_eq!(assignments[0].attributes.get("title"), Some(&json!("kept")));
344    }
345
346    #[test]
347    fn limit_rejects_excess_nested_records() {
348        let params = json!({
349            "posts_attributes": [
350                {"title": "one"},
351                {"title": "two"},
352                {"title": "three"}
353            ]
354        });
355        assert_eq!(
356            assign_nested_attributes(&params, UserRecord::nested_attributes_registry()),
357            Err(NestedAttributesError::TooManyRecords {
358                association: "posts".to_owned(),
359                limit: 2,
360            })
361        );
362    }
363
364    #[test]
365    fn invalid_root_returns_error() {
366        assert_eq!(
367            assign_nested_attributes(&json!(null), UserRecord::nested_attributes_registry()),
368            Err(NestedAttributesError::InvalidRoot)
369        );
370    }
371
372    #[test]
373    fn circular_references_are_rejected() {
374        let params = json!({
375            "posts_attributes": [{
376                "title": "Hello",
377                "user_attributes": {
378                    "posts_attributes": [{"title": "Again"}]
379                }
380            }]
381        });
382        assert_eq!(
383            assign_nested_attributes(&params, UserRecord::nested_attributes_registry()),
384            Err(NestedAttributesError::CircularReference("posts".to_owned()))
385        );
386    }
387
388    #[test]
389    fn unknown_nested_associations_are_ignored() {
390        let params = json!({"comments_attributes": [{"body": "ignored"}]});
391        let assignments =
392            assign_nested_attributes(&params, UserRecord::nested_attributes_registry())
393                .expect("nested attributes should parse");
394        assert!(assignments.is_empty());
395    }
396}