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