1use std::collections::HashMap;
2
3use serde_json::{Map, Value};
4
5pub type RejectIf = fn(&Map<String, Value>) -> bool;
7
8#[derive(Debug, Clone)]
10pub struct NestedAttributesConfig {
11 pub association: String,
13 pub allow_destroy: bool,
15 pub limit: Option<usize>,
17 pub reject_if: Option<RejectIf>,
19}
20
21impl NestedAttributesConfig {
22 #[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 #[must_use]
35 pub fn allow_destroy(mut self) -> Self {
36 self.allow_destroy = true;
37 self
38 }
39
40 #[must_use]
42 pub fn limit(mut self, limit: usize) -> Self {
43 self.limit = Some(limit);
44 self
45 }
46
47 #[must_use]
49 pub fn reject_if(mut self, predicate: RejectIf) -> Self {
50 self.reject_if = Some(predicate);
51 self
52 }
53}
54
55#[must_use]
57pub fn accepts_nested_attributes_for(association: &str) -> NestedAttributesConfig {
58 NestedAttributesConfig::new(association)
59}
60
61#[derive(Debug, Clone, Default)]
63pub struct NestedAttributesRegistry {
64 configs: HashMap<String, NestedAttributesConfig>,
65}
66
67impl NestedAttributesRegistry {
68 #[must_use]
70 pub fn new() -> Self {
71 Self::default()
72 }
73
74 pub fn add(&mut self, config: NestedAttributesConfig) {
76 self.configs.insert(config.association.clone(), config);
77 }
78
79 #[must_use]
81 pub fn get(&self, association: &str) -> Option<&NestedAttributesConfig> {
82 self.configs.get(association)
83 }
84}
85
86pub trait NestedAttributes {
88 fn nested_attributes_registry() -> &'static NestedAttributesRegistry;
90}
91
92#[derive(Debug, Clone, PartialEq)]
94pub struct NestedRecordAssignment {
95 pub association: String,
97 pub index: usize,
99 pub attributes: Map<String, Value>,
101 pub marked_for_destruction: bool,
103}
104
105#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
107pub enum NestedAttributesError {
108 #[error("nested attributes root must be an object")]
110 InvalidRoot,
111 #[error("nested attributes for {0} must be an object or array of objects")]
113 InvalidPayload(String),
114 #[error("nested attributes for {association} exceed limit {limit}")]
116 TooManyRecords {
117 association: String,
119 limit: usize,
121 },
122 #[error("nested attributes contain a circular reference through {0}")]
124 CircularReference(String),
125}
126
127pub 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(¶ms, 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(¶ms, 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(¶ms, 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(¶ms, 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(¶ms, 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(¶ms, 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(¶ms, UserRecord::nested_attributes_registry())
393 .expect("nested attributes should parse");
394 assert!(assignments.is_empty());
395 }
396}