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}