Skip to main content

selene_core/changeset/
diff.rs

1//! Canonical label and property diffs carried by WAL changes.
2
3use serde::{Deserialize, Deserializer, Serialize, Serializer};
4use smallvec::SmallVec;
5
6use crate::{CoreError, CoreResult, DbString, Value};
7
8/// Label set difference.
9#[derive(Clone, Debug, PartialEq)]
10pub struct LabelDiff {
11    /// Labels added by the mutation.
12    pub added: SmallVec<[DbString; 2]>,
13    /// Labels removed by the mutation.
14    pub removed: SmallVec<[DbString; 2]>,
15}
16
17impl LabelDiff {
18    /// Construct a sorted, deduplicated label diff.
19    ///
20    /// # Errors
21    ///
22    /// Returns [`CoreError::OverlappingDiff`] when a label appears in both
23    /// `added` and `removed`. Contradictory diffs would make WAL replay
24    /// order-dependent, so the constructor refuses to build them.
25    pub fn new(
26        added: impl IntoIterator<Item = DbString>,
27        removed: impl IntoIterator<Item = DbString>,
28    ) -> CoreResult<Self> {
29        let added = sorted_deduped(added);
30        let removed = sorted_deduped(removed);
31        ensure_disjoint("label", &added, &removed)?;
32        Ok(Self { added, removed })
33    }
34
35    /// Return true if no labels changed.
36    #[must_use]
37    pub fn is_empty(&self) -> bool {
38        self.added.is_empty() && self.removed.is_empty()
39    }
40}
41
42#[derive(Deserialize, Serialize)]
43struct LabelDiffWire {
44    added: SmallVec<[DbString; 2]>,
45    removed: SmallVec<[DbString; 2]>,
46}
47
48impl Serialize for LabelDiff {
49    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
50    where
51        S: Serializer,
52    {
53        // Canonicalize on serialize. `LabelDiff::new` already sorts, so this is
54        // a no-op (byte-identical) for constructed diffs. The fields are public,
55        // so direct construction still emits canonical wire.
56        let mut added = self.added.clone();
57        let mut removed = self.removed.clone();
58        added.sort_by(|lhs, rhs| lhs.as_str().cmp(rhs.as_str()));
59        removed.sort_by(|lhs, rhs| lhs.as_str().cmp(rhs.as_str()));
60        LabelDiffWire { added, removed }.serialize(serializer)
61    }
62}
63
64impl<'de> Deserialize<'de> for LabelDiff {
65    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
66    where
67        D: Deserializer<'de>,
68    {
69        // Validate the canonical (strictly-ascending, dedup'd, disjoint)
70        // invariant rather than re-sorting; a non-canonical payload is
71        // rejected as malformed.
72        let wire = LabelDiffWire::deserialize(deserializer)?;
73        validate_sorted_unique(&wire.added, "LabelDiff.added")?;
74        validate_sorted_unique(&wire.removed, "LabelDiff.removed")?;
75        validate_disjoint(&wire.added, &wire.removed, "label")?;
76        Ok(Self {
77            added: wire.added,
78            removed: wire.removed,
79        })
80    }
81}
82
83/// Property map difference.
84#[derive(Clone, Debug, PartialEq)]
85pub struct PropertyDiff {
86    /// Keys set to a new value. Use [`Value::Null`] for an explicit null set.
87    pub set: SmallVec<[(DbString, Value); 4]>,
88    /// Keys whose entries are removed entirely.
89    pub removed: SmallVec<[DbString; 2]>,
90}
91
92impl PropertyDiff {
93    /// Construct a sorted, deduplicated property diff.
94    ///
95    /// # Errors
96    ///
97    /// Returns [`CoreError::OverlappingDiff`] when a key appears in both `set`
98    /// and `removed`. Contradictory diffs would make WAL replay
99    /// order-dependent, so the constructor refuses to build them.
100    pub fn new(
101        set: impl IntoIterator<Item = (DbString, Value)>,
102        removed: impl IntoIterator<Item = DbString>,
103    ) -> CoreResult<Self> {
104        let mut set: SmallVec<[(DbString, Value); 4]> = set.into_iter().collect();
105        if set.len() > 1 {
106            set.sort_by(|(lhs, _), (rhs, _)| lhs.cmp(rhs));
107            let mut deduped = SmallVec::new();
108            for (key, value) in set {
109                if let Some((last_key, last_value)) = deduped.last_mut()
110                    && last_key == &key
111                {
112                    *last_value = value;
113                    continue;
114                }
115                deduped.push((key, value));
116            }
117            set = deduped;
118        }
119        let removed = sorted_deduped(removed);
120        for (key, _) in set.iter() {
121            if removed.binary_search(key).is_ok() {
122                return Err(CoreError::OverlappingDiff {
123                    kind: "property",
124                    key: key.clone(),
125                });
126            }
127        }
128        Ok(Self { set, removed })
129    }
130
131    /// Return true if no properties changed.
132    #[must_use]
133    pub fn is_empty(&self) -> bool {
134        self.set.is_empty() && self.removed.is_empty()
135    }
136}
137
138#[derive(Deserialize, Serialize)]
139struct PropertyDiffWire {
140    set: SmallVec<[(DbString, Value); 4]>,
141    removed: SmallVec<[DbString; 2]>,
142}
143
144impl Serialize for PropertyDiff {
145    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
146    where
147        S: Serializer,
148    {
149        // Canonicalize on serialize. `PropertyDiff::new` already sorts, so this
150        // is a no-op (byte-identical) for constructed diffs. The fields are
151        // public, so direct construction still emits canonical wire.
152        let mut set = self.set.clone();
153        let mut removed = self.removed.clone();
154        set.sort_by(|(lhs, _), (rhs, _)| lhs.as_str().cmp(rhs.as_str()));
155        removed.sort_by(|lhs, rhs| lhs.as_str().cmp(rhs.as_str()));
156        PropertyDiffWire { set, removed }.serialize(serializer)
157    }
158}
159
160impl<'de> Deserialize<'de> for PropertyDiff {
161    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
162    where
163        D: Deserializer<'de>,
164    {
165        // Validate the canonical invariant (strictly-ascending set keys,
166        // strictly-ascending removed, disjoint) rather than re-sorting; a
167        // non-canonical payload is rejected as malformed.
168        let wire = PropertyDiffWire::deserialize(deserializer)?;
169        for window in wire.set.windows(2) {
170            if window[0].0 >= window[1].0 {
171                return Err(serde::de::Error::custom(
172                    "PropertyDiff.set entries must be sorted by DbString order with no duplicate keys",
173                ));
174            }
175        }
176        validate_sorted_unique(&wire.removed, "PropertyDiff.removed")?;
177        for (key, _) in wire.set.iter() {
178            if wire.removed.binary_search(key).is_ok() {
179                return Err(serde::de::Error::custom(format!(
180                    "PropertyDiff: key {key} appears in both set and removed",
181                )));
182            }
183        }
184        Ok(Self {
185            set: wire.set,
186            removed: wire.removed,
187        })
188    }
189}
190
191fn sorted_deduped(values: impl IntoIterator<Item = DbString>) -> SmallVec<[DbString; 2]> {
192    let mut values: SmallVec<[DbString; 2]> = values.into_iter().collect();
193    values.sort();
194    values.dedup();
195    values
196}
197
198fn ensure_disjoint(
199    kind: &'static str,
200    added: &SmallVec<[DbString; 2]>,
201    removed: &SmallVec<[DbString; 2]>,
202) -> CoreResult<()> {
203    for label in added.iter() {
204        if removed.binary_search(label).is_ok() {
205            return Err(CoreError::OverlappingDiff {
206                kind,
207                key: label.clone(),
208            });
209        }
210    }
211    Ok(())
212}
213
214fn validate_sorted_unique<E: serde::de::Error>(
215    values: &SmallVec<[DbString; 2]>,
216    label: &'static str,
217) -> Result<(), E> {
218    for window in values.windows(2) {
219        if window[0] >= window[1] {
220            return Err(E::custom(format!(
221                "{label} must be sorted by DbString order with no duplicates"
222            )));
223        }
224    }
225    Ok(())
226}
227
228fn validate_disjoint<E: serde::de::Error>(
229    added: &SmallVec<[DbString; 2]>,
230    removed: &SmallVec<[DbString; 2]>,
231    kind: &'static str,
232) -> Result<(), E> {
233    for label in added.iter() {
234        if removed.binary_search(label).is_ok() {
235            return Err(E::custom(format!(
236                "overlapping {kind} diff: {label} appears in both add/set and remove",
237            )));
238        }
239    }
240    Ok(())
241}