shopify_sdk/rest/tracking.rs
1//! Dirty tracking for efficient partial updates.
2//!
3//! This module provides [`TrackedResource<T>`], a wrapper that tracks
4//! changes to a resource for efficient partial updates. Only modified
5//! fields are sent in PUT requests, reducing bandwidth and avoiding
6//! overwriting concurrent changes.
7//!
8//! # How It Works
9//!
10//! When a resource is loaded from the API or after a successful save,
11//! its state is captured as JSON. On subsequent saves, the current
12//! state is compared to the original, and only changed fields are
13//! serialized for the update request.
14//!
15//! # Example
16//!
17//! ```rust
18//! use shopify_sdk::rest::TrackedResource;
19//! use serde::{Serialize, Deserialize};
20//! use serde_json::json;
21//!
22//! #[derive(Debug, Clone, Serialize, Deserialize)]
23//! struct Product {
24//! id: u64,
25//! title: String,
26//! vendor: String,
27//! }
28//!
29//! // Create a tracked resource (simulating load from API)
30//! let product = Product {
31//! id: 123,
32//! title: "Original Title".to_string(),
33//! vendor: "Original Vendor".to_string(),
34//! };
35//! let mut tracked = TrackedResource::from_existing(product);
36//!
37//! // Resource is not dirty initially
38//! assert!(!tracked.is_dirty());
39//!
40//! // Modify via DerefMut
41//! tracked.title = "New Title".to_string();
42//!
43//! // Resource is now dirty
44//! assert!(tracked.is_dirty());
45//!
46//! // Get only changed fields for partial update
47//! let changes = tracked.changed_fields();
48//! assert!(changes.get("title").is_some());
49//! assert!(changes.get("vendor").is_none()); // Unchanged fields excluded
50//!
51//! // After successful save, mark clean
52//! tracked.mark_clean();
53//! assert!(!tracked.is_dirty());
54//! ```
55
56use std::ops::{Deref, DerefMut};
57
58use serde::{de::DeserializeOwned, Serialize};
59use serde_json::Value;
60
61/// A wrapper that tracks changes to a resource.
62///
63/// `TrackedResource<T>` stores both the current resource data and its
64/// original state (as JSON). This allows detecting which fields have
65/// changed since the resource was loaded or last saved.
66///
67/// # Type Parameters
68///
69/// * `T` - The resource type. Must implement `Serialize`, `DeserializeOwned`,
70/// and `Clone` for state tracking to work.
71///
72/// # Deref Pattern
73///
74/// Implements `Deref<Target = T>` and `DerefMut`, so you can access
75/// and modify the resource transparently:
76///
77/// ```rust
78/// use shopify_sdk::rest::TrackedResource;
79/// use serde::{Serialize, Deserialize};
80///
81/// #[derive(Debug, Clone, Serialize, Deserialize)]
82/// struct Product { title: String }
83///
84/// let mut tracked = TrackedResource::new(Product { title: "Test".to_string() });
85///
86/// // Read via Deref
87/// println!("{}", tracked.title);
88///
89/// // Write via DerefMut
90/// tracked.title = "Modified".to_string();
91/// ```
92#[derive(Debug, Clone)]
93pub struct TrackedResource<T> {
94 /// The actual resource data.
95 resource: T,
96 /// The original state captured when loaded or after save.
97 /// `None` for new resources that haven't been saved yet.
98 original_state: Option<Value>,
99}
100
101impl<T: Serialize + DeserializeOwned + Clone> TrackedResource<T> {
102 /// Creates a new tracked resource for a resource that doesn't exist yet.
103 ///
104 /// New resources have no original state, so `is_dirty()` returns `true`
105 /// and `changed_fields()` returns all fields.
106 ///
107 /// # Example
108 ///
109 /// ```rust
110 /// use shopify_sdk::rest::TrackedResource;
111 /// use serde::{Serialize, Deserialize};
112 ///
113 /// #[derive(Debug, Clone, Serialize, Deserialize)]
114 /// struct Product { title: String }
115 ///
116 /// let tracked = TrackedResource::new(Product { title: "New".to_string() });
117 /// assert!(tracked.is_dirty()); // New resources are always dirty
118 /// ```
119 #[must_use]
120 pub const fn new(resource: T) -> Self {
121 Self {
122 resource,
123 original_state: None,
124 }
125 }
126
127 /// Creates a tracked resource from an existing resource.
128 ///
129 /// The current state is captured as the original state, so `is_dirty()`
130 /// returns `false` until the resource is modified.
131 ///
132 /// # Example
133 ///
134 /// ```rust
135 /// use shopify_sdk::rest::TrackedResource;
136 /// use serde::{Serialize, Deserialize};
137 ///
138 /// #[derive(Debug, Clone, Serialize, Deserialize)]
139 /// struct Product { title: String }
140 ///
141 /// let tracked = TrackedResource::from_existing(Product { title: "Loaded".to_string() });
142 /// assert!(!tracked.is_dirty()); // Existing resources start clean
143 /// ```
144 #[must_use]
145 pub fn from_existing(resource: T) -> Self {
146 let original_state = serde_json::to_value(&resource).ok();
147 Self {
148 resource,
149 original_state,
150 }
151 }
152
153 /// Returns `true` if the resource has been modified since loading or last save.
154 ///
155 /// For new resources (no original state), always returns `true`.
156 ///
157 /// # Example
158 ///
159 /// ```rust
160 /// use shopify_sdk::rest::TrackedResource;
161 /// use serde::{Serialize, Deserialize};
162 ///
163 /// #[derive(Debug, Clone, Serialize, Deserialize)]
164 /// struct Product { title: String }
165 ///
166 /// let mut tracked = TrackedResource::from_existing(Product { title: "Test".to_string() });
167 /// assert!(!tracked.is_dirty());
168 ///
169 /// tracked.title = "Changed".to_string();
170 /// assert!(tracked.is_dirty());
171 /// ```
172 #[must_use]
173 #[allow(clippy::option_if_let_else)]
174 pub fn is_dirty(&self) -> bool {
175 match &self.original_state {
176 None => true, // New resource, always dirty
177 Some(original) => {
178 let current = serde_json::to_value(&self.resource).ok();
179 current.as_ref() != Some(original)
180 }
181 }
182 }
183
184 /// Returns only the fields that have changed since loading or last save.
185 ///
186 /// For new resources, returns all fields (since there's no original state
187 /// to compare against).
188 ///
189 /// For existing resources, returns only the fields whose values differ
190 /// from the original state. Nested objects are handled recursively.
191 ///
192 /// # Example
193 ///
194 /// ```rust
195 /// use shopify_sdk::rest::TrackedResource;
196 /// use serde::{Serialize, Deserialize};
197 ///
198 /// #[derive(Debug, Clone, Serialize, Deserialize)]
199 /// struct Product { title: String, vendor: String }
200 ///
201 /// let mut tracked = TrackedResource::from_existing(Product {
202 /// title: "Original".to_string(),
203 /// vendor: "Vendor".to_string(),
204 /// });
205 ///
206 /// tracked.title = "Changed".to_string();
207 ///
208 /// let changes = tracked.changed_fields();
209 /// assert!(changes.get("title").is_some());
210 /// assert!(changes.get("vendor").is_none()); // Unchanged
211 /// ```
212 #[must_use]
213 pub fn changed_fields(&self) -> Value {
214 let current = serde_json::to_value(&self.resource).unwrap_or(Value::Null);
215
216 match &self.original_state {
217 None => current, // New resource, return all fields
218 Some(original) => diff_json_objects(original, ¤t),
219 }
220 }
221
222 /// Marks the resource as clean by capturing the current state as original.
223 ///
224 /// Call this after a successful save operation to reset dirty tracking.
225 ///
226 /// # Example
227 ///
228 /// ```rust
229 /// use shopify_sdk::rest::TrackedResource;
230 /// use serde::{Serialize, Deserialize};
231 ///
232 /// #[derive(Debug, Clone, Serialize, Deserialize)]
233 /// struct Product { title: String }
234 ///
235 /// let mut tracked = TrackedResource::from_existing(Product { title: "Test".to_string() });
236 /// tracked.title = "Changed".to_string();
237 /// assert!(tracked.is_dirty());
238 ///
239 /// tracked.mark_clean();
240 /// assert!(!tracked.is_dirty());
241 /// ```
242 pub fn mark_clean(&mut self) {
243 self.original_state = serde_json::to_value(&self.resource).ok();
244 }
245
246 /// Returns a reference to the inner resource.
247 ///
248 /// In most cases, you can use Deref coercion instead.
249 #[must_use]
250 pub const fn inner(&self) -> &T {
251 &self.resource
252 }
253
254 /// Returns a mutable reference to the inner resource.
255 ///
256 /// In most cases, you can use `DerefMut` coercion instead.
257 #[must_use]
258 pub fn inner_mut(&mut self) -> &mut T {
259 &mut self.resource
260 }
261
262 /// Consumes the wrapper and returns the inner resource.
263 #[must_use]
264 pub fn into_inner(self) -> T {
265 self.resource
266 }
267
268 /// Returns `true` if this is a new resource (no original state).
269 #[must_use]
270 pub const fn is_new(&self) -> bool {
271 self.original_state.is_none()
272 }
273}
274
275/// Computes the difference between two JSON objects.
276///
277/// Returns a JSON object containing only the fields from `current` that
278/// differ from `original`. Handles nested objects recursively.
279fn diff_json_objects(original: &Value, current: &Value) -> Value {
280 match (original, current) {
281 (Value::Object(orig_map), Value::Object(curr_map)) => {
282 let mut diff = serde_json::Map::new();
283
284 for (key, curr_value) in curr_map {
285 match orig_map.get(key) {
286 Some(orig_value) => {
287 // Key exists in both - check if changed
288 if orig_value != curr_value {
289 // For nested objects, recursively diff
290 if orig_value.is_object() && curr_value.is_object() {
291 let nested_diff = diff_json_objects(orig_value, curr_value);
292 if !nested_diff.is_null()
293 && nested_diff.as_object().is_some_and(|m| !m.is_empty())
294 {
295 diff.insert(key.clone(), nested_diff);
296 }
297 } else {
298 // Primitive value changed
299 diff.insert(key.clone(), curr_value.clone());
300 }
301 }
302 }
303 None => {
304 // New field - include it
305 diff.insert(key.clone(), curr_value.clone());
306 }
307 }
308 }
309
310 // Note: We don't include deleted fields (fields in original but not in current)
311 // because REST APIs typically don't support field deletion via partial updates
312
313 Value::Object(diff)
314 }
315 // For non-objects, if they differ, return current
316 _ => {
317 if original == current {
318 Value::Null
319 } else {
320 current.clone()
321 }
322 }
323 }
324}
325
326/// Provides transparent read access to the inner resource.
327impl<T> Deref for TrackedResource<T> {
328 type Target = T;
329
330 fn deref(&self) -> &Self::Target {
331 &self.resource
332 }
333}
334
335/// Provides transparent mutable access to the inner resource.
336///
337/// Modifications via `DerefMut` will be detected by `is_dirty()`.
338impl<T> DerefMut for TrackedResource<T> {
339 fn deref_mut(&mut self) -> &mut Self::Target {
340 &mut self.resource
341 }
342}
343
344// Verify TrackedResource is Send + Sync when T is Send + Sync
345const _: fn() = || {
346 const fn assert_send_sync<T: Send + Sync>() {}
347 assert_send_sync::<TrackedResource<String>>();
348};
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use serde::{Deserialize, Serialize};
354 use serde_json::json;
355
356 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
357 struct TestProduct {
358 id: Option<u64>,
359 title: String,
360 vendor: String,
361 tags: Vec<String>,
362 }
363
364 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
365 struct TestProductWithNested {
366 id: u64,
367 title: String,
368 options: TestOptions,
369 }
370
371 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
372 struct TestOptions {
373 name: String,
374 values: Vec<String>,
375 }
376
377 #[test]
378 fn test_tracked_resource_new_captures_no_initial_state() {
379 let product = TestProduct {
380 id: None,
381 title: "New Product".to_string(),
382 vendor: "Vendor".to_string(),
383 tags: vec![],
384 };
385
386 let tracked = TrackedResource::new(product);
387
388 assert!(tracked.is_new());
389 assert!(tracked.original_state.is_none());
390 }
391
392 #[test]
393 fn test_is_dirty_returns_false_for_unchanged_resource() {
394 let product = TestProduct {
395 id: Some(123),
396 title: "Test".to_string(),
397 vendor: "Vendor".to_string(),
398 tags: vec!["tag1".to_string()],
399 };
400
401 let tracked = TrackedResource::from_existing(product);
402
403 assert!(!tracked.is_dirty());
404 }
405
406 #[test]
407 fn test_is_dirty_returns_true_after_field_modification() {
408 let product = TestProduct {
409 id: Some(123),
410 title: "Original".to_string(),
411 vendor: "Vendor".to_string(),
412 tags: vec![],
413 };
414
415 let mut tracked = TrackedResource::from_existing(product);
416 assert!(!tracked.is_dirty());
417
418 tracked.title = "Modified".to_string();
419 assert!(tracked.is_dirty());
420 }
421
422 #[test]
423 fn test_changed_fields_returns_empty_for_unchanged_resource() {
424 let product = TestProduct {
425 id: Some(123),
426 title: "Test".to_string(),
427 vendor: "Vendor".to_string(),
428 tags: vec![],
429 };
430
431 let tracked = TrackedResource::from_existing(product);
432 let changes = tracked.changed_fields();
433
434 assert!(changes.is_object());
435 assert!(changes.as_object().unwrap().is_empty());
436 }
437
438 #[test]
439 fn test_changed_fields_returns_only_modified_fields() {
440 let product = TestProduct {
441 id: Some(123),
442 title: "Original Title".to_string(),
443 vendor: "Original Vendor".to_string(),
444 tags: vec!["tag1".to_string()],
445 };
446
447 let mut tracked = TrackedResource::from_existing(product);
448 tracked.title = "New Title".to_string();
449
450 let changes = tracked.changed_fields();
451
452 assert_eq!(changes.get("title"), Some(&json!("New Title")));
453 assert!(changes.get("vendor").is_none()); // Unchanged
454 assert!(changes.get("id").is_none()); // Unchanged
455 assert!(changes.get("tags").is_none()); // Unchanged
456 }
457
458 #[test]
459 fn test_changed_fields_handles_nested_object_changes() {
460 let product = TestProductWithNested {
461 id: 123,
462 title: "Test".to_string(),
463 options: TestOptions {
464 name: "Color".to_string(),
465 values: vec!["Red".to_string(), "Blue".to_string()],
466 },
467 };
468
469 let mut tracked = TrackedResource::from_existing(product);
470
471 // Modify nested field
472 tracked.options.name = "Size".to_string();
473
474 let changes = tracked.changed_fields();
475
476 // The options object should be in changes with nested diff
477 assert!(changes.get("options").is_some());
478 let options_changes = changes.get("options").unwrap();
479 assert_eq!(options_changes.get("name"), Some(&json!("Size")));
480 // values should not be in changes since it wasn't modified
481 assert!(options_changes.get("values").is_none());
482 }
483
484 #[test]
485 fn test_mark_clean_resets_dirty_state() {
486 let product = TestProduct {
487 id: Some(123),
488 title: "Original".to_string(),
489 vendor: "Vendor".to_string(),
490 tags: vec![],
491 };
492
493 let mut tracked = TrackedResource::from_existing(product);
494 tracked.title = "Modified".to_string();
495 assert!(tracked.is_dirty());
496
497 tracked.mark_clean();
498 assert!(!tracked.is_dirty());
499
500 // Changes should now be empty
501 let changes = tracked.changed_fields();
502 assert!(changes.as_object().unwrap().is_empty());
503 }
504
505 #[test]
506 fn test_new_resources_serialize_all_fields() {
507 let product = TestProduct {
508 id: None,
509 title: "New Product".to_string(),
510 vendor: "New Vendor".to_string(),
511 tags: vec!["tag1".to_string()],
512 };
513
514 let tracked = TrackedResource::new(product);
515 let changes = tracked.changed_fields();
516
517 // All fields should be present
518 assert!(changes.get("id").is_some());
519 assert!(changes.get("title").is_some());
520 assert!(changes.get("vendor").is_some());
521 assert!(changes.get("tags").is_some());
522 }
523
524 #[test]
525 fn test_deref_allows_field_access() {
526 let product = TestProduct {
527 id: Some(123),
528 title: "Test".to_string(),
529 vendor: "Vendor".to_string(),
530 tags: vec![],
531 };
532
533 let tracked = TrackedResource::from_existing(product);
534
535 // Access fields via Deref
536 assert_eq!(tracked.title, "Test");
537 assert_eq!(tracked.vendor, "Vendor");
538 }
539
540 #[test]
541 fn test_deref_mut_allows_field_modification() {
542 let product = TestProduct {
543 id: Some(123),
544 title: "Original".to_string(),
545 vendor: "Vendor".to_string(),
546 tags: vec![],
547 };
548
549 let mut tracked = TrackedResource::from_existing(product);
550
551 // Modify via DerefMut
552 tracked.title = "Modified".to_string();
553 tracked.tags.push("new_tag".to_string());
554
555 assert_eq!(tracked.title, "Modified");
556 assert_eq!(tracked.tags, vec!["new_tag".to_string()]);
557 }
558
559 #[test]
560 fn test_into_inner_returns_resource() {
561 let product = TestProduct {
562 id: Some(123),
563 title: "Test".to_string(),
564 vendor: "Vendor".to_string(),
565 tags: vec![],
566 };
567
568 let tracked = TrackedResource::from_existing(product.clone());
569 let inner = tracked.into_inner();
570
571 assert_eq!(inner, product);
572 }
573
574 #[test]
575 fn test_is_new_differentiates_new_and_existing() {
576 let new_product = TestProduct {
577 id: None,
578 title: "New".to_string(),
579 vendor: "Vendor".to_string(),
580 tags: vec![],
581 };
582
583 let existing_product = TestProduct {
584 id: Some(123),
585 title: "Existing".to_string(),
586 vendor: "Vendor".to_string(),
587 tags: vec![],
588 };
589
590 let new_tracked = TrackedResource::new(new_product);
591 assert!(new_tracked.is_new());
592
593 let existing_tracked = TrackedResource::from_existing(existing_product);
594 assert!(!existing_tracked.is_new());
595 }
596
597 #[test]
598 fn test_changed_fields_detects_array_modifications() {
599 let product = TestProduct {
600 id: Some(123),
601 title: "Test".to_string(),
602 vendor: "Vendor".to_string(),
603 tags: vec!["original".to_string()],
604 };
605
606 let mut tracked = TrackedResource::from_existing(product);
607 tracked.tags.push("new_tag".to_string());
608
609 let changes = tracked.changed_fields();
610 assert!(changes.get("tags").is_some());
611 }
612
613 #[test]
614 fn test_diff_json_objects_handles_added_fields() {
615 let original = json!({"a": 1});
616 let current = json!({"a": 1, "b": 2});
617
618 let diff = diff_json_objects(&original, ¤t);
619
620 assert_eq!(diff.get("b"), Some(&json!(2)));
621 assert!(diff.get("a").is_none()); // Unchanged
622 }
623
624 #[test]
625 fn test_multiple_modifications_and_mark_clean() {
626 let product = TestProduct {
627 id: Some(123),
628 title: "Original".to_string(),
629 vendor: "Original Vendor".to_string(),
630 tags: vec![],
631 };
632
633 let mut tracked = TrackedResource::from_existing(product);
634
635 // First modification
636 tracked.title = "First Change".to_string();
637 assert!(tracked.is_dirty());
638
639 // Mark clean (simulating save)
640 tracked.mark_clean();
641 assert!(!tracked.is_dirty());
642
643 // Second modification
644 tracked.vendor = "New Vendor".to_string();
645 assert!(tracked.is_dirty());
646
647 let changes = tracked.changed_fields();
648 assert!(changes.get("title").is_none()); // Was cleaned
649 assert_eq!(changes.get("vendor"), Some(&json!("New Vendor")));
650 }
651}