Skip to main content

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, &current),
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, &current);
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}