Skip to main content

sqry_core/query/
results.rs

1//! Query results with Arc-based ownership for zero-copy access.
2//!
3//! This module provides the `QueryResults` type which wraps matched `NodeId`s
4//! with the source `CodeGraph`, enabling zero-copy access to node data.
5//!
6//! # Design (v6 - CodeGraph-Native Query Executor)
7//!
8//! The interners return `Arc<str>` and `Arc<Path>`, so `QueryResults` uses
9//! these types for efficient, reference-counted string/path access.
10//!
11//! # Usage
12//!
13//! ```ignore
14//! let results = executor.execute_on_graph("kind:function", path)?;
15//! for m in results.iter() {
16//!     println!("{}: {} at line {}", m.kind().as_str(), m.name().unwrap_or_default(), m.start_line());
17//! }
18//! ```
19
20use crate::graph::node::Language;
21use crate::graph::unified::concurrent::CodeGraph;
22use crate::graph::unified::node::{NodeId, NodeKind};
23use crate::graph::unified::storage::arena::NodeEntry;
24use crate::query::pipeline::AggregationResult;
25use crate::query::types::JoinEdgeKind;
26use std::path::{Path, PathBuf};
27use std::sync::Arc;
28
29/// Query results containing matched node IDs and the source graph.
30///
31/// This type stores the matched `NodeId`s along with an `Arc<CodeGraph>` reference,
32/// enabling zero-copy access to node names, paths, and other data through the
33/// string interners.
34pub struct QueryResults {
35    /// The graph (Arc for cheap cloning across threads)
36    graph: Arc<CodeGraph>,
37    /// Matched node IDs (8 bytes each, no allocation)
38    matches: Vec<NodeId>,
39    /// Workspace root for relative path display
40    workspace_root: Option<PathBuf>,
41}
42
43impl QueryResults {
44    /// Creates new query results from a graph and matched node IDs.
45    #[must_use]
46    pub fn new(graph: Arc<CodeGraph>, matches: Vec<NodeId>) -> Self {
47        Self {
48            graph,
49            matches,
50            workspace_root: None,
51        }
52    }
53
54    /// Sets the workspace root for relative path resolution.
55    #[must_use]
56    pub fn with_workspace_root(mut self, root: PathBuf) -> Self {
57        self.workspace_root = Some(root);
58        self
59    }
60
61    /// Returns the number of matched nodes.
62    #[inline]
63    #[must_use]
64    pub fn len(&self) -> usize {
65        self.matches.len()
66    }
67
68    /// Returns true if no nodes matched.
69    #[inline]
70    #[must_use]
71    pub fn is_empty(&self) -> bool {
72        self.matches.is_empty()
73    }
74
75    /// Returns the matched node IDs.
76    #[must_use]
77    pub fn node_ids(&self) -> &[NodeId] {
78        &self.matches
79    }
80
81    /// Returns a reference to the underlying graph.
82    #[must_use]
83    pub fn graph(&self) -> &CodeGraph {
84        &self.graph
85    }
86
87    /// Test-only accessor for the underlying `Arc<CodeGraph>`.
88    ///
89    /// Used by unit tests that need to compare graph identity via
90    /// [`Arc::ptr_eq`] (e.g., verifying that the preloaded-graph execution
91    /// path threads the caller-supplied `Arc` through to the results without
92    /// cloning the underlying graph data).
93    #[cfg(test)]
94    #[must_use]
95    pub(crate) fn graph_arc_for_test(&self) -> &Arc<CodeGraph> {
96        &self.graph
97    }
98
99    /// Returns the workspace root, if set.
100    #[must_use]
101    pub fn workspace_root(&self) -> Option<&Path> {
102        self.workspace_root.as_deref()
103    }
104
105    /// Iterates over matched nodes with accessor methods.
106    pub fn iter(&self) -> impl Iterator<Item = QueryMatch<'_>> + '_ {
107        self.matches.iter().filter_map(|&id| {
108            self.graph.nodes().get(id).map(|entry| QueryMatch {
109                id,
110                entry,
111                graph: &self.graph,
112                workspace_root: self.workspace_root.as_deref(),
113            })
114        })
115    }
116
117    /// Sort by resolved path, then line, then name for deterministic output.
118    ///
119    /// This ensures consistent CLI output regardless of graph traversal order.
120    pub fn sort_by_location(&mut self) {
121        let graph = &self.graph;
122        self.matches.sort_by(|&a, &b| {
123            let ea = graph.nodes().get(a);
124            let eb = graph.nodes().get(b);
125            match (ea, eb) {
126                (Some(ea), Some(eb)) => {
127                    // Sort by resolved path string, not FileId
128                    let path_a = graph.files().resolve(ea.file);
129                    let path_b = graph.files().resolve(eb.file);
130                    // Resolve names for comparison
131                    let name_a = graph.strings().resolve(ea.name);
132                    let name_b = graph.strings().resolve(eb.name);
133                    path_a
134                        .cmp(&path_b)
135                        .then(ea.start_line.cmp(&eb.start_line))
136                        .then(name_a.cmp(&name_b))
137                }
138                _ => std::cmp::Ordering::Equal,
139            }
140        });
141    }
142
143    /// Takes the matched node IDs, consuming self.
144    #[must_use]
145    pub fn into_node_ids(self) -> Vec<NodeId> {
146        self.matches
147    }
148
149    /// Consumes self and returns the underlying graph and matches.
150    #[must_use]
151    pub fn into_parts(self) -> (Arc<CodeGraph>, Vec<NodeId>) {
152        (self.graph, self.matches)
153    }
154}
155
156impl std::fmt::Debug for QueryResults {
157    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        f.debug_struct("QueryResults")
159            .field("match_count", &self.matches.len())
160            .field("graph", &self.graph)
161            .field("workspace_root", &self.workspace_root)
162            .finish()
163    }
164}
165
166/// A single query match with accessors for node data.
167///
168/// This provides a convenient API for accessing node properties without
169/// needing to manually resolve string IDs through the interners.
170pub struct QueryMatch<'a> {
171    /// The node ID
172    pub id: NodeId,
173    /// Reference to the node entry
174    pub entry: &'a NodeEntry,
175    /// Reference to the graph for string resolution
176    graph: &'a CodeGraph,
177    /// Workspace root for relative path computation
178    workspace_root: Option<&'a Path>,
179}
180
181impl QueryMatch<'_> {
182    /// Returns the node's name (zero-copy from interner).
183    #[must_use]
184    pub fn name(&self) -> Option<Arc<str>> {
185        self.graph.strings().resolve(self.entry.name)
186    }
187
188    /// Returns the node's kind.
189    #[must_use]
190    pub fn kind(&self) -> NodeKind {
191        self.entry.kind
192    }
193
194    /// Returns the file path (zero-copy from file registry).
195    #[must_use]
196    pub fn file_path(&self) -> Option<Arc<Path>> {
197        self.graph.files().resolve(self.entry.file)
198    }
199
200    /// Returns the file path relative to workspace root, if set.
201    #[must_use]
202    pub fn relative_path(&self) -> Option<PathBuf> {
203        let path = self.file_path()?;
204        if let Some(root) = self.workspace_root {
205            path.strip_prefix(root)
206                .ok()
207                .map(std::path::Path::to_path_buf)
208        } else {
209            Some(path.to_path_buf())
210        }
211    }
212
213    /// Returns the start line (1-indexed).
214    #[inline]
215    #[must_use]
216    pub fn start_line(&self) -> u32 {
217        self.entry.start_line
218    }
219
220    /// Returns the end line (1-indexed).
221    #[inline]
222    #[must_use]
223    pub fn end_line(&self) -> u32 {
224        self.entry.end_line
225    }
226
227    /// Returns the start column (0-indexed).
228    #[inline]
229    #[must_use]
230    pub fn start_column(&self) -> u32 {
231        self.entry.start_column
232    }
233
234    /// Returns the end column (0-indexed).
235    #[inline]
236    #[must_use]
237    pub fn end_column(&self) -> u32 {
238        self.entry.end_column
239    }
240
241    /// Returns the start byte offset.
242    #[inline]
243    #[must_use]
244    pub fn start_byte(&self) -> u32 {
245        self.entry.start_byte
246    }
247
248    /// Returns the end byte offset.
249    #[inline]
250    #[must_use]
251    pub fn end_byte(&self) -> u32 {
252        self.entry.end_byte
253    }
254
255    /// Returns the visibility modifier (e.g., "public", "private").
256    ///
257    /// Returns `None` if no visibility is set, which typically means "private".
258    #[must_use]
259    pub fn visibility(&self) -> Option<Arc<str>> {
260        self.entry
261            .visibility
262            .and_then(|id| self.graph.strings().resolve(id))
263    }
264
265    /// Returns the signature/type information, if available.
266    #[must_use]
267    pub fn signature(&self) -> Option<Arc<str>> {
268        self.entry
269            .signature
270            .and_then(|id| self.graph.strings().resolve(id))
271    }
272
273    /// Returns the qualified name (e.g., "module.Class.method"), if available.
274    #[must_use]
275    pub fn qualified_name(&self) -> Option<Arc<str>> {
276        self.entry
277            .qualified_name
278            .and_then(|id| self.graph.strings().resolve(id))
279    }
280
281    /// Returns the docstring, if available.
282    #[must_use]
283    pub fn doc(&self) -> Option<Arc<str>> {
284        self.entry
285            .doc
286            .and_then(|id| self.graph.strings().resolve(id))
287    }
288
289    /// Returns whether this is an async function/method.
290    #[inline]
291    #[must_use]
292    pub fn is_async(&self) -> bool {
293        self.entry.is_async
294    }
295
296    /// Returns whether this is a static member.
297    #[inline]
298    #[must_use]
299    pub fn is_static(&self) -> bool {
300        self.entry.is_static
301    }
302
303    /// Get language from file registry (NOT from `NodeEntry` field).
304    ///
305    /// Language is determined by the file's extension through the `FileRegistry`,
306    /// not stored per-node. This matches how `sqry index` determines language.
307    #[must_use]
308    pub fn language(&self) -> Option<Language> {
309        self.graph.files().language_for_file(self.entry.file)
310    }
311
312    /// Access the underlying graph for edge-based field evaluation.
313    #[must_use]
314    pub fn graph(&self) -> &CodeGraph {
315        self.graph
316    }
317}
318
319impl std::fmt::Debug for QueryMatch<'_> {
320    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
321        f.debug_struct("QueryMatch")
322            .field("id", &self.id)
323            .field("kind", &self.entry.kind)
324            .field("name", &self.name())
325            .field("start_line", &self.entry.start_line)
326            .finish()
327    }
328}
329
330/// Join query results containing matched node pairs connected by edges.
331///
332/// Each pair represents an (LHS, RHS) match where LHS has an edge of the
333/// specified kind connecting to RHS.
334pub struct JoinResults {
335    /// The graph (Arc for cheap cloning)
336    graph: Arc<CodeGraph>,
337    /// Matched (source, target) node ID pairs
338    pairs: Vec<(NodeId, NodeId)>,
339    /// The edge kind used for the join
340    edge_kind: JoinEdgeKind,
341    /// Workspace root for relative path display
342    workspace_root: Option<PathBuf>,
343    /// Whether results were truncated by a result cap
344    truncated: bool,
345}
346
347impl JoinResults {
348    /// Creates new join results.
349    #[must_use]
350    pub fn new(
351        graph: Arc<CodeGraph>,
352        pairs: Vec<(NodeId, NodeId)>,
353        edge_kind: JoinEdgeKind,
354        truncated: bool,
355    ) -> Self {
356        Self {
357            graph,
358            pairs,
359            edge_kind,
360            workspace_root: None,
361            truncated,
362        }
363    }
364
365    /// Sets the workspace root for relative path resolution.
366    #[must_use]
367    pub fn with_workspace_root(mut self, root: PathBuf) -> Self {
368        self.workspace_root = Some(root);
369        self
370    }
371
372    /// Returns the number of matched pairs.
373    #[inline]
374    #[must_use]
375    pub fn len(&self) -> usize {
376        self.pairs.len()
377    }
378
379    /// Returns true if no pairs matched.
380    #[inline]
381    #[must_use]
382    pub fn is_empty(&self) -> bool {
383        self.pairs.is_empty()
384    }
385
386    /// Returns the edge kind used for the join.
387    #[must_use]
388    pub fn edge_kind(&self) -> &JoinEdgeKind {
389        &self.edge_kind
390    }
391
392    /// Returns whether the results were truncated by a result cap.
393    #[must_use]
394    pub fn truncated(&self) -> bool {
395        self.truncated
396    }
397
398    /// Returns the underlying graph.
399    #[must_use]
400    pub fn graph(&self) -> &CodeGraph {
401        &self.graph
402    }
403
404    /// Iterates over matched pairs with accessor methods.
405    pub fn iter(&self) -> impl Iterator<Item = JoinMatch<'_>> + '_ {
406        self.pairs.iter().filter_map(|&(left_id, right_id)| {
407            let left = self.graph.nodes().get(left_id)?;
408            let right = self.graph.nodes().get(right_id)?;
409            Some(JoinMatch {
410                left: QueryMatch {
411                    id: left_id,
412                    entry: left,
413                    graph: &self.graph,
414                    workspace_root: self.workspace_root.as_deref(),
415                },
416                right: QueryMatch {
417                    id: right_id,
418                    entry: right,
419                    graph: &self.graph,
420                    workspace_root: self.workspace_root.as_deref(),
421                },
422                edge_kind: &self.edge_kind,
423            })
424        })
425    }
426}
427
428impl std::fmt::Debug for JoinResults {
429    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
430        f.debug_struct("JoinResults")
431            .field("graph", &self.graph)
432            .field("pairs", &self.pairs)
433            .field("edge_kind", &self.edge_kind)
434            .field("workspace_root", &self.workspace_root)
435            .field("truncated", &self.truncated)
436            .finish()
437    }
438}
439
440/// A single join match with accessors for the left and right nodes.
441pub struct JoinMatch<'a> {
442    /// The left (source) node.
443    pub left: QueryMatch<'a>,
444    /// The right (target) node.
445    pub right: QueryMatch<'a>,
446    /// The edge kind connecting them.
447    pub edge_kind: &'a JoinEdgeKind,
448}
449
450/// Unified query output that can be either regular results, join results, or aggregation.
451pub enum QueryOutput {
452    /// Standard query results (matched nodes).
453    Results(QueryResults),
454    /// Join query results (matched node pairs).
455    Join(JoinResults),
456    /// Aggregation results (pipeline output).
457    Aggregation(AggregationResult),
458}
459
460impl QueryOutput {
461    /// Returns the number of results (nodes for Results, pairs for Join, 0 for Aggregation).
462    #[must_use]
463    pub fn len(&self) -> usize {
464        match self {
465            Self::Results(r) => r.len(),
466            Self::Join(j) => j.len(),
467            Self::Aggregation(_) => 0,
468        }
469    }
470
471    /// Returns true if there are no results.
472    #[must_use]
473    pub fn is_empty(&self) -> bool {
474        match self {
475            Self::Results(r) => r.is_empty(),
476            Self::Join(j) => j.is_empty(),
477            Self::Aggregation(_) => false,
478        }
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    #[test]
487    fn test_query_results_empty() {
488        let graph = Arc::new(CodeGraph::new());
489        let results = QueryResults::new(graph, vec![]);
490        assert!(results.is_empty());
491        assert_eq!(results.len(), 0);
492        assert_eq!(results.iter().count(), 0);
493    }
494
495    #[test]
496    fn test_query_results_debug() {
497        let graph = Arc::new(CodeGraph::new());
498        let results = QueryResults::new(graph, vec![]);
499        let debug_str = format!("{results:?}");
500        assert!(debug_str.contains("QueryResults"));
501        assert!(debug_str.contains("match_count"));
502    }
503
504    #[test]
505    fn test_query_results_with_workspace_root() {
506        let graph = Arc::new(CodeGraph::new());
507        let results =
508            QueryResults::new(graph, vec![]).with_workspace_root(PathBuf::from("/test/path"));
509        assert_eq!(results.workspace_root(), Some(Path::new("/test/path")));
510    }
511
512    #[test]
513    fn test_query_results_into_parts() {
514        let graph = Arc::new(CodeGraph::new());
515        let results = QueryResults::new(graph.clone(), vec![]);
516        let (returned_graph, matches) = results.into_parts();
517        assert!(Arc::ptr_eq(&returned_graph, &graph));
518        assert!(matches.is_empty());
519    }
520}