Skip to main content

uni_fork/
types.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2026 Dragonscale Team
3
4//! Phase 6 — Fork diff & promote types.
5//!
6//! `ForkDiff` describes the structural delta between two fork views
7//! (or a fork and primary). The convention is *forward*: `diff(a, b)`
8//! is the delta that, if applied to `a`, would produce `b`. So
9//! `added` rows exist in `b` only, `deleted` exist in `a` only, and
10//! `changed` is a per-row before/after on rows with matching identity.
11//!
12//! **Identity** is `UniId` for vertices and `(src_uid, dst_uid, type)`
13//! for edges. Both are content-addressed (vertex UID = SHA3-256 of
14//! `(label, ext_id, properties)`; edge UID is the tuple of endpoint
15//! UIDs plus the edge type), so the diff is correct across two
16//! unrelated forks that happen to have rolled the same VIDs. The
17//! per-side VID is preserved on `DiffVertex` as informational; pairing
18//! never depends on it.
19//!
20//! Phase 6a (the initial MVP) keyed diffs by VID. Phase 6b lifted
21//! identity to UID so siblings-off-a-shared-parent and totally
22//! unrelated forks compare correctly.
23//!
24//! `PromotePattern` is the spec for what to scan on a fork during
25//! `Uni::promote_from_fork`. Phase 6 supports the most common shape
26//! (label + optional Cypher WHERE clause); future phases may grow
27//! relationship-aware patterns.
28
29use std::fmt;
30
31use uni_common::Properties;
32use uni_common::Value;
33use uni_common::core::id::{UniId, Vid};
34
35/// The full delta from one fork view to another.
36#[derive(Debug, Clone, Default)]
37pub struct ForkDiff {
38    /// Per-label vertex deltas.
39    pub vertices: VertexDiff,
40    /// Per-edge-type edge deltas.
41    pub edges: EdgeDiff,
42}
43
44impl ForkDiff {
45    /// Returns `true` when there are no vertex or edge differences.
46    pub fn is_empty(&self) -> bool {
47        self.vertices.is_empty() && self.edges.is_empty()
48    }
49
50    /// Total rows in this diff across vertices and edges.
51    pub fn total_rows(&self) -> usize {
52        self.vertices.total_rows() + self.edges.total_rows()
53    }
54
55    /// Return the inverse: swap added/deleted and swap before/after in
56    /// every property change. By construction
57    /// `diff(a,b).invert() == diff(b,a)`.
58    pub fn invert(mut self) -> Self {
59        self.vertices = self.vertices.invert();
60        self.edges = self.edges.invert();
61        self
62    }
63}
64
65/// Vertex-side of [`ForkDiff`].
66#[derive(Debug, Clone, Default)]
67pub struct VertexDiff {
68    /// Rows present in `b` but not `a`.
69    pub added: Vec<DiffVertex>,
70    /// Rows present in `a` but not `b`.
71    pub deleted: Vec<DiffVertex>,
72    /// Rows with matching identity in both sides but differing properties.
73    pub changed: Vec<VertexPropertyChange>,
74}
75
76impl VertexDiff {
77    /// Returns `true` when added, deleted, and changed are all empty.
78    pub fn is_empty(&self) -> bool {
79        self.added.is_empty() && self.deleted.is_empty() && self.changed.is_empty()
80    }
81
82    /// Sum of added + deleted + changed counts.
83    pub fn total_rows(&self) -> usize {
84        self.added.len() + self.deleted.len() + self.changed.len()
85    }
86
87    fn invert(self) -> Self {
88        Self {
89            added: self.deleted,
90            deleted: self.added,
91            changed: self
92                .changed
93                .into_iter()
94                .map(VertexPropertyChange::invert)
95                .collect(),
96        }
97    }
98}
99
100/// Edge-side of [`ForkDiff`].
101#[derive(Debug, Clone, Default)]
102pub struct EdgeDiff {
103    /// Edges present in `b` but not `a`.
104    pub added: Vec<DiffEdge>,
105    /// Edges present in `a` but not `b`.
106    pub deleted: Vec<DiffEdge>,
107    /// Edges with matching `(src_uid, dst_uid, type)` but differing properties.
108    pub changed: Vec<EdgePropertyChange>,
109}
110
111impl EdgeDiff {
112    /// Returns `true` when added, deleted, and changed are all empty.
113    pub fn is_empty(&self) -> bool {
114        self.added.is_empty() && self.deleted.is_empty() && self.changed.is_empty()
115    }
116
117    /// Sum of added + deleted + changed counts.
118    pub fn total_rows(&self) -> usize {
119        self.added.len() + self.deleted.len() + self.changed.len()
120    }
121
122    fn invert(self) -> Self {
123        Self {
124            added: self.deleted,
125            deleted: self.added,
126            changed: self
127                .changed
128                .into_iter()
129                .map(EdgePropertyChange::invert)
130                .collect(),
131        }
132    }
133}
134
135/// A vertex row from one side of a diff.
136#[derive(Debug, Clone)]
137pub struct DiffVertex {
138    /// The vertex's label.
139    pub label: String,
140    /// Content-addressed identity (`compute_vertex_uid(label, None,
141    /// properties)`). This is the bucketing key during diff.
142    pub uid: UniId,
143    /// Informational: which VID this row carried on the side it was
144    /// scanned from. `None` if the per-side scan returned a node
145    /// without a VID, which should not happen in practice.
146    pub vid: Option<Vid>,
147    /// Property bag for the vertex (user properties only).
148    pub properties: Properties,
149}
150
151/// A change to one vertex's properties.
152#[derive(Debug, Clone)]
153pub struct VertexPropertyChange {
154    /// The vertex's label.
155    pub label: String,
156    /// UID of the vertex — the pairing key across sides.
157    pub uid: UniId,
158    /// One entry per property whose value differs between sides.
159    pub changes: Vec<PropertyChange>,
160}
161
162impl VertexPropertyChange {
163    fn invert(self) -> Self {
164        Self {
165            label: self.label,
166            uid: self.uid,
167            changes: self
168                .changes
169                .into_iter()
170                .map(PropertyChange::invert)
171                .collect(),
172        }
173    }
174}
175
176/// An edge row from one side of a diff.
177#[derive(Debug, Clone)]
178pub struct DiffEdge {
179    /// The edge type.
180    pub edge_type: String,
181    /// Content-addressed edge UID (computed via
182    /// `MainEdgeDataset::compute_edge_uid` over
183    /// `(src_uid, dst_uid, edge_type, sorted_properties)`). Two
184    /// parallel edges between the same endpoints with different
185    /// property bags have different `edge_uid`s — that's how the
186    /// diff distinguishes them.
187    pub edge_uid: UniId,
188    /// Source vertex UID (content-addressed).
189    pub src_uid: UniId,
190    /// Destination vertex UID (content-addressed).
191    pub dst_uid: UniId,
192    /// Property bag for the edge.
193    pub properties: Properties,
194}
195
196/// A change to one edge's properties.
197#[derive(Debug, Clone)]
198pub struct EdgePropertyChange {
199    /// The edge type.
200    pub edge_type: String,
201    /// Source vertex UID.
202    pub src_uid: UniId,
203    /// Destination vertex UID.
204    pub dst_uid: UniId,
205    /// One entry per property whose value differs between sides.
206    pub changes: Vec<PropertyChange>,
207}
208
209impl EdgePropertyChange {
210    fn invert(self) -> Self {
211        Self {
212            edge_type: self.edge_type,
213            src_uid: self.src_uid,
214            dst_uid: self.dst_uid,
215            changes: self
216                .changes
217                .into_iter()
218                .map(PropertyChange::invert)
219                .collect(),
220        }
221    }
222}
223
224/// A single property's before/after pair.
225#[derive(Debug, Clone)]
226pub struct PropertyChange {
227    /// Property key.
228    pub key: String,
229    /// Value on the `a` side, or `None` if absent.
230    pub before: Option<Value>,
231    /// Value on the `b` side, or `None` if absent.
232    pub after: Option<Value>,
233}
234
235impl PropertyChange {
236    fn invert(self) -> Self {
237        Self {
238            key: self.key,
239            before: self.after,
240            after: self.before,
241        }
242    }
243}
244
245/// Selector for `Uni::promote_from_fork`.
246///
247/// Two shapes:
248/// - [`PromotePattern::label`] — match every vertex with this label;
249///   bulk-inserted on primary, deduplicated by content-derived UID.
250/// - [`PromotePattern::edge_type`] — match every edge of this type
251///   whose endpoints already exist on primary; the edge is inserted
252///   between the resolved primary endpoints, deduplicated by
253///   `(src_uid, dst_uid, edge_type)`.
254///
255/// Both variants accept an optional Cypher `WHERE` clause, interpolated
256/// verbatim into the fork-side scan. Callers are responsible for
257/// quoting and parameter safety.
258#[derive(Debug, Clone)]
259#[non_exhaustive]
260pub enum PromotePattern {
261    /// Promote vertices.
262    Vertex {
263        /// Vertex label.
264        label: String,
265        /// Optional `WHERE` predicate on the fork-side scan.
266        where_clause: Option<String>,
267    },
268    /// Promote edges. Endpoints must already exist on primary (by UID);
269    /// fork-only endpoints are skipped and counted in
270    /// [`PromoteReport::edges_skipped_no_endpoint`].
271    Edge {
272        /// Edge type.
273        edge_type: String,
274        /// Optional `WHERE` predicate on the fork-side scan. The bound
275        /// names are `a` (source), `r` (edge), `b` (destination).
276        where_clause: Option<String>,
277    },
278}
279
280impl PromotePattern {
281    /// Match every vertex with this label.
282    pub fn label(label: impl Into<String>) -> Self {
283        Self::Vertex {
284            label: label.into(),
285            where_clause: None,
286        }
287    }
288
289    /// Match every edge with this type. Endpoints must already exist
290    /// on primary (resolved by UID); fork-only endpoints are counted
291    /// and skipped — they need to be promoted first via a vertex
292    /// pattern.
293    pub fn edge_type(edge_type: impl Into<String>) -> Self {
294        Self::Edge {
295            edge_type: edge_type.into(),
296            where_clause: None,
297        }
298    }
299
300    /// Restrict the scan to rows matching this Cypher predicate.
301    /// Verbatim interpolation — caller owns quoting.
302    pub fn where_clause(mut self, expr: impl Into<String>) -> Self {
303        let expr = expr.into();
304        match &mut self {
305            Self::Vertex { where_clause, .. } | Self::Edge { where_clause, .. } => {
306                *where_clause = Some(expr)
307            }
308        }
309        self
310    }
311
312    /// Vertex label for vertex patterns. Empty string for edge patterns.
313    pub fn label_name(&self) -> &str {
314        match self {
315            Self::Vertex { label, .. } => label,
316            Self::Edge { .. } => "",
317        }
318    }
319
320    /// Edge type for edge patterns. Empty string for vertex patterns.
321    pub fn edge_type_name(&self) -> &str {
322        match self {
323            Self::Edge { edge_type, .. } => edge_type,
324            Self::Vertex { .. } => "",
325        }
326    }
327
328    /// The optional `WHERE` predicate.
329    pub fn where_expr(&self) -> Option<&str> {
330        match self {
331            Self::Vertex { where_clause, .. } | Self::Edge { where_clause, .. } => {
332                where_clause.as_deref()
333            }
334        }
335    }
336
337    /// `true` if this pattern targets edges.
338    pub fn is_edge(&self) -> bool {
339        matches!(self, Self::Edge { .. })
340    }
341}
342
343impl fmt::Display for PromotePattern {
344    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
345        match self {
346            Self::Vertex {
347                label,
348                where_clause: Some(w),
349            } => write!(f, "(:{} WHERE {})", label, w),
350            Self::Vertex {
351                label,
352                where_clause: None,
353            } => write!(f, "(:{})", label),
354            Self::Edge {
355                edge_type,
356                where_clause: Some(w),
357            } => write!(f, "[:{} WHERE {}]", edge_type, w),
358            Self::Edge {
359                edge_type,
360                where_clause: None,
361            } => write!(f, "[:{}]", edge_type),
362        }
363    }
364}
365
366/// Outcome of `Uni::promote_from_fork`.
367#[derive(Debug, Clone, Default)]
368pub struct PromoteReport {
369    /// Number of vertices inserted into primary.
370    pub vertices_inserted: usize,
371    /// Number of fork rows skipped because primary already has the same UID.
372    pub vertices_skipped_uid_conflict: usize,
373    /// Number of edges inserted into primary.
374    pub edges_inserted: usize,
375    /// Number of fork edges skipped because primary already has an
376    /// edge of the same type between the resolved endpoints.
377    pub edges_skipped_duplicate: usize,
378    /// Number of fork edges skipped because at least one endpoint had
379    /// no UID match on primary. To insert these edges, promote the
380    /// missing vertices first via a vertex pattern, then re-run.
381    pub edges_skipped_no_endpoint: usize,
382    /// Number of edges that touched a promoted vertex but were not
383    /// themselves promoted (no edge pattern in the call). Phase 6
384    /// MVP's behaviour: silently skip + warn. Phase 6b adds explicit
385    /// edge patterns; when no edge pattern is given, this counter
386    /// still surfaces incidental edges for visibility.
387    pub edges_skipped: usize,
388    /// Per-pattern row counts so callers can see which pattern matched
389    /// what. Indexed by pattern position in the input slice.
390    pub per_pattern_inserted: Vec<usize>,
391}
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    #[test]
397    fn property_change_inverts_before_after() {
398        let pc = PropertyChange {
399            key: "age".into(),
400            before: Some(Value::Int(30)),
401            after: Some(Value::Int(31)),
402        };
403        let inv = pc.clone().invert();
404        assert_eq!(inv.before, pc.after);
405        assert_eq!(inv.after, pc.before);
406    }
407
408    #[test]
409    fn vertex_diff_invert_swaps_added_deleted() {
410        let v_a = DiffVertex {
411            label: "Person".into(),
412            uid: UniId::from_bytes([1; 32]),
413            vid: Some(Vid::new(1)),
414            properties: Default::default(),
415        };
416        let v_b = DiffVertex {
417            label: "Person".into(),
418            uid: UniId::from_bytes([2; 32]),
419            vid: Some(Vid::new(2)),
420            properties: Default::default(),
421        };
422        let d = VertexDiff {
423            added: vec![v_a.clone()],
424            deleted: vec![v_b.clone()],
425            changed: vec![],
426        };
427        let inv = d.invert();
428        assert_eq!(inv.added.len(), 1);
429        assert_eq!(inv.deleted.len(), 1);
430    }
431
432    #[test]
433    fn fork_diff_default_is_empty() {
434        let d = ForkDiff::default();
435        assert!(d.is_empty());
436        assert_eq!(d.total_rows(), 0);
437    }
438
439    #[test]
440    fn promote_pattern_display() {
441        let p = PromotePattern::label("Person");
442        assert_eq!(format!("{}", p), "(:Person)");
443        let p2 = PromotePattern::label("Person").where_clause("n.age > 30");
444        assert_eq!(format!("{}", p2), "(:Person WHERE n.age > 30)");
445    }
446}