sqry_db/comparative/mod.rs
1//! `ComparativeQueryDb` for cross-snapshot operations.
2//!
3//! `semantic_diff` operates across two separate snapshots (different git refs
4//! or worktrees). This does not fit the single-snapshot `QueryDb` model because
5//! cross-snapshot results cannot be meaningfully invalidated.
6//!
7//! `ComparativeQueryDb` is a lightweight wrapper holding two snapshots. It
8//! bypasses the `ShardedCache` entirely — comparative queries are inherently
9//! one-shot, uncached operations.
10//!
11//! # DB20 design choice (Option A)
12//!
13//! The `diff` computation is exposed as an inherent method on
14//! `ComparativeQueryDb` rather than as a `DerivedQuery` (which would require
15//! a single-snapshot key) or as a free function with separate snapshots. This:
16//!
17//! 1. Keeps the public surface minimal (one wrapper, one method).
18//! 2. Co-locates cross-snapshot logic with the type that owns the two
19//! snapshots.
20//! 3. Avoids an awkward "uncached `DerivedQuery`" abstraction that would
21//! contradict the three-tier cache invariants.
22//!
23//! See `docs/superpowers/specs/2026-04-12-derived-analysis-db-query-planner-design.md`
24//! section M6 for the rationale.
25
26use std::sync::Arc;
27
28use sqry_core::graph::unified::concurrent::GraphSnapshot;
29
30pub mod diff;
31
32pub use diff::{ChangeType, DiffOptions, DiffOutput, DiffSummary, NodeChange, NodeLocation};
33
34/// A lightweight wrapper holding two `GraphSnapshot`s for cross-snapshot
35/// operations like `semantic_diff`.
36///
37/// Comparative queries are NOT cached (they are inherently one-shot
38/// cross-snapshot operations), so this type bypasses the `ShardedCache`
39/// entirely.
40///
41/// # Usage
42///
43/// ```rust,ignore
44/// use sqry_db::comparative::{ComparativeQueryDb, DiffOptions};
45///
46/// let cmp = ComparativeQueryDb::new(old_snapshot, new_snapshot);
47/// let out = cmp.diff(&DiffOptions::default()); // uncached, one-shot
48/// ```
49pub struct ComparativeQueryDb {
50 /// The "before" snapshot (e.g., older commit).
51 old: Arc<GraphSnapshot>,
52 /// The "after" snapshot (e.g., newer commit).
53 new: Arc<GraphSnapshot>,
54}
55
56impl ComparativeQueryDb {
57 /// Creates a new comparative DB from two snapshots.
58 #[must_use]
59 pub fn new(old: Arc<GraphSnapshot>, new: Arc<GraphSnapshot>) -> Self {
60 Self { old, new }
61 }
62
63 /// Returns the "before" snapshot.
64 #[inline]
65 #[must_use]
66 pub fn old(&self) -> &GraphSnapshot {
67 &self.old
68 }
69
70 /// Returns the "after" snapshot.
71 #[inline]
72 #[must_use]
73 pub fn new_snapshot(&self) -> &GraphSnapshot {
74 &self.new
75 }
76
77 /// Returns the "before" snapshot as an `Arc`.
78 #[inline]
79 #[must_use]
80 pub fn old_arc(&self) -> Arc<GraphSnapshot> {
81 Arc::clone(&self.old)
82 }
83
84 /// Returns the "after" snapshot as an `Arc`.
85 #[inline]
86 #[must_use]
87 pub fn new_arc(&self) -> Arc<GraphSnapshot> {
88 Arc::clone(&self.new)
89 }
90
91 /// Computes the semantic diff between the two snapshots.
92 ///
93 /// This is an uncached, one-shot operation. Results are NOT memoized by
94 /// the `ShardedCache` because cross-snapshot results have no meaningful
95 /// invalidation criterion (the two snapshots are immutable, and no
96 /// single-snapshot dependency bump would ever reinvalidate their diff).
97 ///
98 /// Pass the worktree roots via [`DiffOptions`] so per-file paths are
99 /// prefixed back to absolute worktree locations. Callers that do not
100 /// need worktree prefixing (e.g. unit tests or CLI callers that already
101 /// hold workspace-relative paths) may use [`Self::diff_default`].
102 #[must_use]
103 pub fn diff(&self, opts: &DiffOptions) -> DiffOutput {
104 // Defensive fast-path: when both sides point at the literal same
105 // snapshot Arc, the diff is provably empty. Callers that resolve
106 // identical git refs upstream (sqry-cli and sqry-mcp do) avoid
107 // the cost of materializing two snapshots in the first place;
108 // this guard catches in-process callers (tests, SDK users) that
109 // pass the same Arc into `new()` directly. (verivus-oss/sqry#213)
110 if Arc::ptr_eq(&self.old, &self.new) {
111 return DiffOutput::default();
112 }
113 diff::compute_diff(&self.old, &self.new, opts)
114 }
115
116 /// Computes the semantic diff using default options (no worktree
117 /// prefixing). See [`Self::diff`] for the full semantics.
118 #[must_use]
119 pub fn diff_default(&self) -> DiffOutput {
120 self.diff(&DiffOptions::default())
121 }
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127
128 // ComparativeQueryDb is a pure wrapper — construction and accessor tests
129 // are covered in integration tests that build real graph snapshots.
130 // This module verifies the type is Send + Sync (required for cross-thread
131 // usage in MCP handlers).
132 #[test]
133 fn comparative_query_db_is_send_sync() {
134 fn assert_send_sync<T: Send + Sync>() {}
135 assert_send_sync::<ComparativeQueryDb>();
136 }
137
138 /// `Arc::ptr_eq` fast-path: when both sides are the same `Arc`, `diff`
139 /// must return an empty `DiffOutput` without entering `compute_diff`.
140 /// (verivus-oss/sqry#213)
141 ///
142 /// Building a real `GraphSnapshot` here would require a workspace
143 /// fixture; instead we round-trip through a default-constructed
144 /// snapshot via `CodeGraph::new().snapshot()`, which is the cheapest
145 /// non-trivial snapshot the public API exposes.
146 #[test]
147 fn diff_short_circuits_when_both_sides_share_arc() {
148 use sqry_core::graph::unified::concurrent::CodeGraph;
149 let graph = CodeGraph::new();
150 let snap = Arc::new(graph.snapshot());
151 let cmp = ComparativeQueryDb::new(Arc::clone(&snap), Arc::clone(&snap));
152 let out = cmp.diff(&DiffOptions::default());
153 assert!(out.changes.is_empty(), "expected empty diff on shared Arc");
154 assert_eq!(out.summary, DiffSummary::default());
155 }
156}