Skip to main content

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}