Skip to main content

sqry_db/queries/
relation.rs

1//! Relation-predicate derived queries.
2//!
3//! Shared infrastructure for the six relation predicates used by the query
4//! planner. The planner's convention is "filter rows where `<relation>`
5//! endpoint matches value" — same shape as [`super::super::planner::execute`]
6//! inline `relation_matches` dispatch:
7//!
8//! | Predicate      | Direction | Edge discriminant            | Endpoint role |
9//! |----------------|-----------|------------------------------|---------------|
10//! | `callers:X`    | Reverse   | `Calls`                      | Source        |
11//! | `callees:X`    | Forward   | `Calls`                      | Target        |
12//! | `imports:X`    | Forward   | `Imports`                    | Target        |
13//! | `exports:X`    | Both      | `Exports`                    | Either        |
14//! | `references:X` | Reverse   | Calls/References/Imports/FFI | Source        |
15//! | `implements:X` | Forward   | `Implements`                 | Target        |
16//!
17//! The [`CallersQuery`] and [`CalleesQuery`] types live in sibling modules
18//! ([`super::callers`], [`super::callees`]) but share the [`RelationKey`]
19//! shape and helper functions defined here. All six queries set
20//! `TRACKS_EDGE_REVISION = true`: any edge change in the graph invalidates
21//! the cached result.
22//!
23//! # What this query migrates from `graph_eval`
24//!
25//! The planner already had the direction convention before DB14. What DB14
26//! adds is the full **language-aware name matching** from
27//! [`sqry_core::query::executor::graph_eval`]:
28//!
29//! * [`entry_query_texts`] produces the interned name, qualified name, and
30//!   display-qualified form so segment-aware matching has all three to try.
31//! * [`language_aware_segments_match`] supplies the canonicalization fallback
32//!   (C# `System.IO` → graph-internal `System::IO`, etc.).
33//! * [`extract_method_name`] provides the dynamic-language method-segment
34//!   fallback (for `Calls` edges only, mirroring `match_callers`).
35//!
36//! # Why not include subqueries in the cache key?
37//!
38//! Subquery values resolve to a `HashSet<NodeId>` at evaluate time. Hashing
39//! that set into a stable cache key would defeat the point — the set is
40//! already the expensive part. The executor memoises subquery results per
41//! plan via its own subquery cache; [`RelationKey`] only covers the
42//! name/regex-keyed variants that benefit from cross-plan reuse.
43//!
44//! # Cross-engine semantics
45//!
46//! As of DB15, `imports:` is per-node in BOTH engines (sqry-db planner
47//! queries here AND `sqry-core::query::executor::graph_eval`). The previous
48//! file-scoped `match_imports` was retired so MCP/CLI/LSP all share the
49//! planner-canonical semantic per
50//! `docs/development/phase-n-structural-semantics/02_DESIGN.md` §6
51//! ("Unified Surface Contract"). The convergence test
52//! `tests::imports_per_node_semantic_aligned_across_engines` locks the
53//! agreement so any future regression surfaces as a test failure.
54//!
55//! One intentional asymmetry remains for `callers:` / `callees:` direction:
56//! the planner treats `<relation>:X` as "filter rows whose `<relation>` set
57//! includes `X`", whereas `graph_eval` treats it as "filter rows that ARE in
58//! the `<relation>` set of `X`". MCP handlers route through inverted
59//! `mcp_callers_query`/`mcp_callees_query` wrappers in
60//! [`crate::queries::mcp_wrappers`] so MCP users see the graph_eval-style
61//! direction (the contract MCP tool consumers expect) while the planner
62//! cache contract is preserved.
63
64use std::collections::HashSet;
65use std::sync::Arc;
66
67use regex::RegexBuilder;
68use sqry_core::graph::unified::concurrent::GraphSnapshot;
69use sqry_core::graph::unified::edge::kind::EdgeKind;
70use sqry_core::graph::unified::node::id::NodeId;
71use sqry_core::graph::unified::storage::arena::NodeEntry;
72use sqry_core::query::executor::graph_eval::{
73    entry_query_texts, extract_method_name, language_aware_segments_match,
74};
75
76use crate::QueryDb;
77use crate::dependency::record_file_dep;
78use crate::planner::ir::{MatchMode, RegexPattern, StringPattern};
79use crate::query::DerivedQuery;
80
81// ============================================================================
82// Relation enum + shared key
83// ============================================================================
84
85/// The six relation predicates migrated from the planner's inline
86/// [`relation_matches`] dispatch.
87///
88/// Each variant has an associated direction, edge-kind discriminant set, and
89/// endpoint role — see the module-level documentation for the table. The
90/// discriminator lets every `DerivedQuery` in this module share a single
91/// [`compute_relation_source_set`] implementation.
92///
93/// [`relation_matches`]: super::super::planner::execute
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
95pub enum RelationKind {
96    /// `callers:X` — nodes whose incoming `Calls` edges have a source
97    /// matching `X`. A match means `X` is one of the node's callers.
98    Callers,
99    /// `callees:X` — nodes whose outgoing `Calls` edges have a target
100    /// matching `X`. A match means `X` is one of the node's callees.
101    Callees,
102    /// `imports:X` — nodes whose outgoing `Imports` edges have a target
103    /// matching `X`.
104    Imports,
105    /// `exports:X` — nodes that participate in an `Exports` edge (either
106    /// direction) whose *other* endpoint matches `X`.
107    Exports,
108    /// `references:X` — nodes whose incoming reference edges (`Calls`,
109    /// `References`, `Imports`, `FfiCall`) have a source matching `X`.
110    References,
111    /// `implements:X` — nodes whose outgoing `Implements` edges have a
112    /// target matching `X`.
113    Implements,
114}
115
116/// Cache key for the relation `DerivedQuery` impls.
117///
118/// Wraps [`StringPattern`] and [`RegexPattern`] from the planner IR so that
119/// planner-originating queries and future sqry-core callers share one cache
120/// shape. The [`StringPattern::mode`] selects exact / glob / prefix / suffix
121/// / contains semantics; [`MatchMode::Exact`] additionally falls through to
122/// segment-aware language-aware matching (see [`match_endpoint_name`]).
123// PN3 cold-start persistence: both inner types (StringPattern, RegexPattern)
124// already derive Serialize/Deserialize from the planner IR.
125#[derive(Debug, Clone, Hash, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
126pub enum RelationKey {
127    /// Literal / segment / glob / prefix / suffix / contains pattern.
128    Pattern(StringPattern),
129    /// Regex pattern with compilation flags.
130    Regex(RegexPattern),
131}
132
133impl RelationKey {
134    /// Creates a key for an exact-match (segment-aware) name comparison.
135    #[must_use]
136    pub fn exact(raw: impl Into<String>) -> Self {
137        RelationKey::Pattern(StringPattern::exact(raw))
138    }
139
140    /// Creates a key for a case-sensitive regex match.
141    #[must_use]
142    pub fn regex(pattern: impl Into<String>) -> Self {
143        RelationKey::Regex(RegexPattern::new(pattern))
144    }
145}
146
147// ============================================================================
148// Core computation
149// ============================================================================
150
151/// Traversal direction used by the relation dispatch.
152#[derive(Debug, Clone, Copy)]
153pub(super) enum Direction {
154    /// Outgoing edges from the node (`snapshot.edges().edges_from(id)`).
155    Forward,
156    /// Incoming edges to the node (`snapshot.edges().edges_to(id)`).
157    Reverse,
158    /// Both directions — used by `exports:` to catch edges on either side.
159    Both,
160}
161
162/// Which edge endpoint is compared against the relation key's name pattern.
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub(super) enum EndpointRole {
165    /// Edge's source endpoint is the subject under comparison.
166    Source,
167    /// Edge's target endpoint is the subject under comparison.
168    Target,
169    /// Whichever endpoint is *not* the query node itself. Handles `exports:`
170    /// where the edge may go either direction. Self-loop edges are skipped
171    /// to avoid the degenerate case where the node matches its own edge.
172    Either,
173}
174
175/// Resolves the relation-specific traversal parameters.
176///
177/// Several variants produce the same `(Direction, EndpointRole)` tuple —
178/// e.g. `Callers` and `References` both traverse in reverse and compare on
179/// the edge source — but the arms are kept separate so each relation has a
180/// single documented entry, matching the planner's `Predicate` variants
181/// one-to-one. The `#[allow]` below is deliberate.
182#[must_use]
183#[allow(clippy::match_same_arms)]
184pub(super) fn traversal_for(relation: RelationKind) -> (Direction, EndpointRole) {
185    match relation {
186        RelationKind::Callers => (Direction::Reverse, EndpointRole::Source),
187        RelationKind::Callees => (Direction::Forward, EndpointRole::Target),
188        RelationKind::Imports => (Direction::Forward, EndpointRole::Target),
189        RelationKind::Exports => (Direction::Both, EndpointRole::Either),
190        RelationKind::References => (Direction::Reverse, EndpointRole::Source),
191        RelationKind::Implements => (Direction::Forward, EndpointRole::Target),
192    }
193}
194
195/// Returns `true` if the given edge kind participates in the relation.
196#[must_use]
197pub(super) fn edge_kind_matches(relation: RelationKind, kind: &EdgeKind) -> bool {
198    match relation {
199        RelationKind::Callers | RelationKind::Callees => matches!(kind, EdgeKind::Calls { .. }),
200        RelationKind::Imports => matches!(kind, EdgeKind::Imports { .. }),
201        RelationKind::Exports => matches!(kind, EdgeKind::Exports { .. }),
202        RelationKind::References => matches!(
203            kind,
204            EdgeKind::Calls { .. }
205                | EdgeKind::References
206                | EdgeKind::Imports { .. }
207                | EdgeKind::FfiCall { .. }
208        ),
209        RelationKind::Implements => matches!(kind, EdgeKind::Implements),
210    }
211}
212
213/// Computes the sorted, deduplicated set of node IDs that satisfy the
214/// relation predicate with the given `key`. Shared by every `DerivedQuery`
215/// impl in this module and the sibling `callers.rs` / `callees.rs`.
216#[must_use]
217pub fn compute_relation_source_set(
218    relation: RelationKind,
219    key: &RelationKey,
220    snapshot: &GraphSnapshot,
221) -> Arc<Vec<NodeId>> {
222    for (fid, _) in snapshot.file_segments().iter() {
223        record_file_dep(fid);
224    }
225
226    let matcher = KeyMatcher::build(key);
227    let (direction, role) = traversal_for(relation);
228    let mut results: Vec<NodeId> = Vec::new();
229
230    for (node_id, entry) in snapshot.nodes().iter() {
231        // Gate 0d iter-2 fix: skip unified losers from relation
232        // query results. See `NodeEntry::is_unified_loser`.
233        if entry.is_unified_loser() {
234            continue;
235        }
236        if node_has_matching_endpoint(snapshot, node_id, relation, direction, role, &matcher) {
237            results.push(node_id);
238        }
239    }
240
241    results.sort_unstable_by_key(|id| (id.index(), id.generation()));
242    results.dedup();
243    Arc::new(results)
244}
245
246/// Tests whether a single node matches the relation predicate against a
247/// precomputed subquery result set. Used when the value is a
248/// [`crate::planner::ir::PredicateValue::Subquery`].
249///
250/// The `endpoints` set is generic over its hasher so callers don't have to
251/// round-trip through the default `RandomState` when their subquery result
252/// is already in a `HashSet` with a different hasher.
253#[must_use]
254pub fn relation_matches_node_via_set<S: std::hash::BuildHasher>(
255    relation: RelationKind,
256    node_id: NodeId,
257    endpoints: &HashSet<NodeId, S>,
258    snapshot: &GraphSnapshot,
259) -> bool {
260    let (direction, role) = traversal_for(relation);
261    for edge in neighbours(snapshot, node_id, direction) {
262        if !edge_kind_matches(relation, &edge.kind) {
263            continue;
264        }
265        let endpoint = match role {
266            EndpointRole::Source => edge.source,
267            EndpointRole::Target => edge.target,
268            EndpointRole::Either => {
269                if edge.source == node_id {
270                    edge.target
271                } else {
272                    edge.source
273                }
274            }
275        };
276        if role == EndpointRole::Either && endpoint == node_id {
277            // Degenerate self-loop under Either — skip to avoid matching the
278            // query node against itself through its own edge.
279            continue;
280        }
281        if endpoints.contains(&endpoint) {
282            return true;
283        }
284    }
285    false
286}
287
288// ============================================================================
289// Per-node probing
290// ============================================================================
291
292fn node_has_matching_endpoint(
293    snapshot: &GraphSnapshot,
294    node_id: NodeId,
295    relation: RelationKind,
296    direction: Direction,
297    role: EndpointRole,
298    matcher: &KeyMatcher,
299) -> bool {
300    // Imports fast-path: an `Import` node whose own name matches the key
301    // is a hit even with no outgoing edges. Mirrors the
302    // `entry.kind == NodeKind::Import` branch in
303    // `graph_eval::match_imports`. DB14's edge-only model missed this; the
304    // followup test `imports_per_node_semantic_aligned_across_engines`
305    // catches the divergence.
306    if relation == RelationKind::Imports
307        && let Some(entry) = snapshot.nodes().get(node_id)
308        && entry.kind == sqry_core::graph::unified::node::NodeKind::Import
309        && match_endpoint_name(snapshot, entry, matcher)
310    {
311        return true;
312    }
313
314    for edge in neighbours(snapshot, node_id, direction) {
315        if !edge_kind_matches(relation, &edge.kind) {
316            continue;
317        }
318        let endpoint_id = match role {
319            EndpointRole::Source => edge.source,
320            EndpointRole::Target => edge.target,
321            EndpointRole::Either => {
322                if edge.source == node_id {
323                    edge.target
324                } else {
325                    edge.source
326                }
327            }
328        };
329        if role == EndpointRole::Either && endpoint_id == node_id {
330            continue;
331        }
332        let Some(endpoint_entry) = snapshot.nodes().get(endpoint_id) else {
333            continue;
334        };
335        if match_endpoint_name(snapshot, endpoint_entry, matcher) {
336            return true;
337        }
338        // Dynamic-language fallback: for `Calls` edges the legacy
339        // `graph_eval::match_callers` also accepts matching on the trailing
340        // method segment (`Player::takeDamage` ↔ `Enemy::takeDamage`). Keep
341        // that in place so migrating Ruby/Python dispatch-style call sites
342        // stays covered.
343        if matches!(relation, RelationKind::Callers | RelationKind::Callees)
344            && method_segment_matches(snapshot, endpoint_entry, matcher)
345        {
346            return true;
347        }
348        // Imports edges carry alias and wildcard metadata that
349        // `graph_eval::import_edge_matches` honours (DB15 reconciled the
350        // two engines on per-node semantics; this preserves alias /
351        // wildcard parity so the convergence test
352        // `imports_per_node_semantic_aligned_across_engines` passes for
353        // every branch of `match_imports`, not just the target-name
354        // branch).
355        if relation == RelationKind::Imports
356            && imports_alias_or_wildcard_matches(snapshot, &edge, matcher)
357        {
358            return true;
359        }
360    }
361    false
362}
363
364/// Returns `true` if an `Imports` edge matches the key via alias text or
365/// the `*` wildcard, mirroring `graph_eval::import_edge_matches`.
366///
367/// Only `Exact`-mode literal patterns participate in the wildcard /
368/// alias fast paths; regex / glob / prefix-suffix-contains modes already
369/// flow through [`match_endpoint_name`] for the alias text by virtue of
370/// the alias being interned. We only run the fast paths for `Exact` mode
371/// because that is the convention `graph_eval::import_edge_matches`
372/// uses.
373fn imports_alias_or_wildcard_matches(
374    snapshot: &GraphSnapshot,
375    edge: &sqry_core::graph::unified::edge::store::StoreEdgeRef,
376    matcher: &KeyMatcher,
377) -> bool {
378    let EdgeKind::Imports { alias, is_wildcard } = &edge.kind else {
379        return false;
380    };
381    let KeyMatcher::Pattern(pattern) = matcher else {
382        return false;
383    };
384    if !matches!(pattern.mode, MatchMode::Exact) {
385        return false;
386    }
387    if *is_wildcard && pattern.raw == "*" {
388        return true;
389    }
390    let Some(alias_id) = alias else {
391        return false;
392    };
393    let Some(alias_str) = snapshot.strings().resolve(*alias_id) else {
394        return false;
395    };
396    // Match alias against the requested key; mirrors graph_eval's
397    // `import_text_matches`-anchored alias check (substring + canonical
398    // form). For the per-node planner contract, an exact substring match
399    // on the alias text is sufficient — the alias IS the importer's
400    // local name for the imported symbol.
401    alias_str.as_ref().contains(pattern.raw.as_str())
402}
403
404fn neighbours(
405    snapshot: &GraphSnapshot,
406    node_id: NodeId,
407    direction: Direction,
408) -> Vec<sqry_core::graph::unified::edge::store::StoreEdgeRef> {
409    match direction {
410        Direction::Forward => snapshot.edges().edges_from(node_id),
411        Direction::Reverse => snapshot.edges().edges_to(node_id),
412        Direction::Both => {
413            let mut out = snapshot.edges().edges_from(node_id);
414            out.extend(snapshot.edges().edges_to(node_id));
415            out
416        }
417    }
418}
419
420// ============================================================================
421// Key matcher — compile once per query invocation
422// ============================================================================
423
424/// Compiled representation of a [`RelationKey`]. Regex compilation fails
425/// silently into a rejecting matcher — relation predicates should return an
426/// empty set rather than panic on malformed user input.
427enum KeyMatcher {
428    Pattern(StringPattern),
429    Regex(regex::Regex),
430    RegexReject,
431}
432
433impl KeyMatcher {
434    fn build(key: &RelationKey) -> Self {
435        match key {
436            RelationKey::Pattern(pattern) => KeyMatcher::Pattern(pattern.clone()),
437            RelationKey::Regex(pattern) => {
438                let mut builder = RegexBuilder::new(&pattern.pattern);
439                builder
440                    .case_insensitive(pattern.flags.case_insensitive)
441                    .multi_line(pattern.flags.multiline)
442                    .dot_matches_new_line(pattern.flags.dot_all)
443                    .size_limit(1 << 20)
444                    .dfa_size_limit(1 << 20);
445                builder
446                    .build()
447                    .map(KeyMatcher::Regex)
448                    .unwrap_or(KeyMatcher::RegexReject)
449            }
450        }
451    }
452}
453
454/// Tests whether any of `entry`'s query texts match the matcher. Exact-mode
455/// literal patterns route through `language_aware_segments_match` so the
456/// legacy `graph_eval` segment semantics survive the migration; non-`Exact`
457/// literal modes use simple string operations; regex uses the compiled
458/// `regex::Regex`.
459fn match_endpoint_name(snapshot: &GraphSnapshot, entry: &NodeEntry, matcher: &KeyMatcher) -> bool {
460    let texts = entry_query_texts(snapshot, entry);
461    if texts.is_empty() {
462        return false;
463    }
464    match matcher {
465        KeyMatcher::Pattern(pattern) => match pattern.mode {
466            MatchMode::Exact => texts.iter().any(|candidate| {
467                language_aware_segments_match(snapshot, entry.file, candidate, &pattern.raw)
468            }),
469            MatchMode::Glob => globset::Glob::new(&pattern.raw).is_ok_and(|g| {
470                let m = g.compile_matcher();
471                texts.iter().any(|candidate| m.is_match(candidate))
472            }),
473            MatchMode::Prefix => {
474                let needle = case_adjust(&pattern.raw, pattern.case_insensitive);
475                texts.iter().any(|candidate| {
476                    case_adjust(candidate, pattern.case_insensitive).starts_with(&needle)
477                })
478            }
479            MatchMode::Suffix => {
480                let needle = case_adjust(&pattern.raw, pattern.case_insensitive);
481                texts.iter().any(|candidate| {
482                    case_adjust(candidate, pattern.case_insensitive).ends_with(&needle)
483                })
484            }
485            MatchMode::Contains => {
486                let needle = case_adjust(&pattern.raw, pattern.case_insensitive);
487                texts.iter().any(|candidate| {
488                    case_adjust(candidate, pattern.case_insensitive).contains(&needle)
489                })
490            }
491        },
492        KeyMatcher::Regex(re) => texts.iter().any(|candidate| re.is_match(candidate)),
493        KeyMatcher::RegexReject => false,
494    }
495}
496
497fn case_adjust(s: &str, case_insensitive: bool) -> String {
498    if case_insensitive {
499        s.to_lowercase()
500    } else {
501        s.to_string()
502    }
503}
504
505fn method_segment_matches(
506    snapshot: &GraphSnapshot,
507    entry: &NodeEntry,
508    matcher: &KeyMatcher,
509) -> bool {
510    let KeyMatcher::Pattern(pattern) = matcher else {
511        return false;
512    };
513    if !matches!(pattern.mode, MatchMode::Exact) {
514        return false;
515    }
516    let Some(method) = extract_method_name(&pattern.raw) else {
517        return false;
518    };
519    entry_query_texts(snapshot, entry)
520        .iter()
521        .filter_map(|candidate| extract_method_name(candidate))
522        .any(|candidate_method| candidate_method == method)
523}
524
525// ============================================================================
526// DerivedQuery impls — 4 of the 6 relations live here. Callers/Callees live
527// in sibling modules so the file layout matches the DAG's `files_create`.
528// ============================================================================
529
530/// `imports:X` — nodes whose outgoing `Imports` edges have a target
531/// matching `X`. See [`RelationKind::Imports`].
532pub struct ImportsQuery;
533
534impl DerivedQuery for ImportsQuery {
535    type Key = RelationKey;
536    type Value = Arc<Vec<NodeId>>;
537    const QUERY_TYPE_ID: u32 = crate::queries::type_ids::IMPORTS;
538    const TRACKS_EDGE_REVISION: bool = true;
539
540    fn execute(key: &RelationKey, _db: &QueryDb, snapshot: &GraphSnapshot) -> Arc<Vec<NodeId>> {
541        compute_relation_source_set(RelationKind::Imports, key, snapshot)
542    }
543}
544
545/// `exports:X` — nodes participating in an `Exports` edge whose other
546/// endpoint matches `X`. See [`RelationKind::Exports`].
547pub struct ExportsQuery;
548
549impl DerivedQuery for ExportsQuery {
550    type Key = RelationKey;
551    type Value = Arc<Vec<NodeId>>;
552    const QUERY_TYPE_ID: u32 = crate::queries::type_ids::EXPORTS;
553    const TRACKS_EDGE_REVISION: bool = true;
554
555    fn execute(key: &RelationKey, _db: &QueryDb, snapshot: &GraphSnapshot) -> Arc<Vec<NodeId>> {
556        compute_relation_source_set(RelationKind::Exports, key, snapshot)
557    }
558}
559
560/// `references:X` — nodes with incoming reference edges (`Calls`,
561/// `References`, `Imports`, `FfiCall`) whose source matches `X`.
562pub struct ReferencesQuery;
563
564impl DerivedQuery for ReferencesQuery {
565    type Key = RelationKey;
566    type Value = Arc<Vec<NodeId>>;
567    const QUERY_TYPE_ID: u32 = crate::queries::type_ids::REFERENCES;
568    const TRACKS_EDGE_REVISION: bool = true;
569
570    fn execute(key: &RelationKey, _db: &QueryDb, snapshot: &GraphSnapshot) -> Arc<Vec<NodeId>> {
571        compute_relation_source_set(RelationKind::References, key, snapshot)
572    }
573}
574
575/// `implements:X` — nodes whose outgoing `Implements` edges reach a target
576/// matching `X`.
577pub struct ImplementsQuery;
578
579impl DerivedQuery for ImplementsQuery {
580    type Key = RelationKey;
581    type Value = Arc<Vec<NodeId>>;
582    const QUERY_TYPE_ID: u32 = crate::queries::type_ids::IMPLEMENTS;
583    const TRACKS_EDGE_REVISION: bool = true;
584
585    fn execute(key: &RelationKey, _db: &QueryDb, snapshot: &GraphSnapshot) -> Arc<Vec<NodeId>> {
586        compute_relation_source_set(RelationKind::Implements, key, snapshot)
587    }
588}
589
590// ============================================================================
591// Tests
592// ============================================================================
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597    use crate::QueryDbConfig;
598    use sqry_core::graph::unified::concurrent::CodeGraph;
599    use sqry_core::graph::unified::edge::kind::ExportKind;
600    use sqry_core::graph::unified::node::kind::NodeKind;
601    use std::path::Path;
602    use std::sync::Arc;
603
604    fn alloc_function(graph: &mut CodeGraph, name: &str, file: &Path) -> NodeId {
605        let name_id = graph.strings_mut().intern(name).unwrap();
606        let file_id = graph.files_mut().register(file).unwrap();
607        let entry =
608            NodeEntry::new(NodeKind::Function, name_id, file_id).with_qualified_name(name_id);
609        graph.nodes_mut().alloc(entry).unwrap()
610    }
611
612    fn add_calls_edge(graph: &mut CodeGraph, src: NodeId, tgt: NodeId) {
613        let file_id = graph.nodes().get(src).unwrap().file;
614        graph.edges_mut().add_edge(
615            src,
616            tgt,
617            EdgeKind::Calls {
618                argument_count: 0,
619                is_async: false,
620            },
621            file_id,
622        );
623    }
624
625    fn build_db_for_graph(graph: CodeGraph) -> QueryDb {
626        let snapshot = Arc::new(graph.snapshot());
627        let mut db = QueryDb::new(snapshot, QueryDbConfig::default());
628        db.register::<ImportsQuery>();
629        db.register::<ExportsQuery>();
630        db.register::<ReferencesQuery>();
631        db.register::<ImplementsQuery>();
632        db
633    }
634
635    #[test]
636    fn callers_relation_returns_nodes_called_by_matching_source() {
637        // main --Calls--> {a, b}; `callers:main` = {a, b} because main is
638        // listed in each callee's `callers` set.
639        let mut graph = CodeGraph::new();
640        let path = Path::new("main.rs");
641        let main_fn = alloc_function(&mut graph, "main", path);
642        let a = alloc_function(&mut graph, "a", path);
643        let b = alloc_function(&mut graph, "b", path);
644        add_calls_edge(&mut graph, main_fn, a);
645        add_calls_edge(&mut graph, main_fn, b);
646
647        let snapshot = Arc::new(graph.snapshot());
648        let out = compute_relation_source_set(
649            RelationKind::Callers,
650            &RelationKey::exact("main"),
651            &snapshot,
652        );
653        let ids: Vec<NodeId> = out.as_ref().clone();
654        assert!(ids.contains(&a));
655        assert!(ids.contains(&b));
656        assert!(!ids.contains(&main_fn));
657    }
658
659    #[test]
660    fn callees_relation_returns_nodes_that_call_matching_target() {
661        // main --Calls--> parse_expr; `callees:parse_expr` = {main}.
662        let mut graph = CodeGraph::new();
663        let path = Path::new("main.rs");
664        let main_fn = alloc_function(&mut graph, "main", path);
665        let parse_expr = alloc_function(&mut graph, "parse_expr", path);
666        add_calls_edge(&mut graph, main_fn, parse_expr);
667
668        let snapshot = Arc::new(graph.snapshot());
669        let out = compute_relation_source_set(
670            RelationKind::Callees,
671            &RelationKey::exact("parse_expr"),
672            &snapshot,
673        );
674        assert_eq!(out.as_ref(), &vec![main_fn]);
675    }
676
677    #[test]
678    fn references_relation_matches_incoming_reference_edges() {
679        // caller --References--> target; `references:caller` = {target}.
680        let mut graph = CodeGraph::new();
681        let path = Path::new("lib.rs");
682        let file_id = graph.files_mut().register(path).unwrap();
683        let caller_name = graph.strings_mut().intern("caller").unwrap();
684        let target_name = graph.strings_mut().intern("target").unwrap();
685        let unrelated_name = graph.strings_mut().intern("unrelated").unwrap();
686
687        let caller = graph
688            .nodes_mut()
689            .alloc(
690                NodeEntry::new(NodeKind::Function, caller_name, file_id)
691                    .with_qualified_name(caller_name),
692            )
693            .unwrap();
694        let target = graph
695            .nodes_mut()
696            .alloc(
697                NodeEntry::new(NodeKind::Function, target_name, file_id)
698                    .with_qualified_name(target_name),
699            )
700            .unwrap();
701        let unrelated = graph
702            .nodes_mut()
703            .alloc(
704                NodeEntry::new(NodeKind::Function, unrelated_name, file_id)
705                    .with_qualified_name(unrelated_name),
706            )
707            .unwrap();
708
709        graph
710            .edges_mut()
711            .add_edge(caller, target, EdgeKind::References, file_id);
712
713        let snapshot = Arc::new(graph.snapshot());
714        let out = compute_relation_source_set(
715            RelationKind::References,
716            &RelationKey::exact("caller"),
717            &snapshot,
718        );
719        assert_eq!(out.as_ref(), &vec![target]);
720        assert!(!out.contains(&unrelated));
721    }
722
723    #[test]
724    fn implements_relation_matches_outgoing_implements_edge() {
725        let mut graph = CodeGraph::new();
726        let path = Path::new("lib.rs");
727        let name_id = graph.strings_mut().intern("MyStruct").unwrap();
728        let trait_name = graph.strings_mut().intern("Drawable").unwrap();
729        let file_id = graph.files_mut().register(path).unwrap();
730        let s = graph
731            .nodes_mut()
732            .alloc(NodeEntry::new(NodeKind::Struct, name_id, file_id).with_qualified_name(name_id))
733            .unwrap();
734        let t = graph
735            .nodes_mut()
736            .alloc(
737                NodeEntry::new(NodeKind::Trait, trait_name, file_id)
738                    .with_qualified_name(trait_name),
739            )
740            .unwrap();
741        graph
742            .edges_mut()
743            .add_edge(s, t, EdgeKind::Implements, file_id);
744
745        let snapshot = Arc::new(graph.snapshot());
746        let out = compute_relation_source_set(
747            RelationKind::Implements,
748            &RelationKey::exact("Drawable"),
749            &snapshot,
750        );
751        assert_eq!(out.as_ref(), &vec![s]);
752    }
753
754    #[test]
755    fn exports_relation_matches_either_direction() {
756        // run_fn --Exports--> main_fn. Under the Either/Both convention,
757        // `exports:main` should return run_fn (its export target is named
758        // "main"), and `exports:run_fn` should return main_fn (its export
759        // source is named "run_fn").
760        let mut graph = CodeGraph::new();
761        let path = Path::new("lib.rs");
762        let file_id = graph.files_mut().register(path).unwrap();
763        let run_name = graph.strings_mut().intern("run_fn").unwrap();
764        let main_name = graph.strings_mut().intern("main").unwrap();
765        let run_fn = graph
766            .nodes_mut()
767            .alloc(
768                NodeEntry::new(NodeKind::Function, run_name, file_id).with_qualified_name(run_name),
769            )
770            .unwrap();
771        let main_fn = graph
772            .nodes_mut()
773            .alloc(
774                NodeEntry::new(NodeKind::Function, main_name, file_id)
775                    .with_qualified_name(main_name),
776            )
777            .unwrap();
778        graph.edges_mut().add_edge(
779            run_fn,
780            main_fn,
781            EdgeKind::Exports {
782                kind: ExportKind::Direct,
783                alias: None,
784            },
785            file_id,
786        );
787
788        let snapshot = Arc::new(graph.snapshot());
789        let out_main = compute_relation_source_set(
790            RelationKind::Exports,
791            &RelationKey::exact("main"),
792            &snapshot,
793        );
794        assert_eq!(out_main.as_ref(), &vec![run_fn]);
795        let out_run = compute_relation_source_set(
796            RelationKind::Exports,
797            &RelationKey::exact("run_fn"),
798            &snapshot,
799        );
800        assert_eq!(out_run.as_ref(), &vec![main_fn]);
801    }
802
803    #[test]
804    fn imports_relation_matches_outgoing_import_target() {
805        let mut graph = CodeGraph::new();
806        let path = Path::new("lib.rs");
807        let file_id = graph.files_mut().register(path).unwrap();
808        let importer_name = graph.strings_mut().intern("importer").unwrap();
809        let target_name = graph.strings_mut().intern("serde").unwrap();
810        let importer = graph
811            .nodes_mut()
812            .alloc(
813                NodeEntry::new(NodeKind::Function, importer_name, file_id)
814                    .with_qualified_name(importer_name),
815            )
816            .unwrap();
817        let target = graph
818            .nodes_mut()
819            .alloc(
820                NodeEntry::new(NodeKind::Module, target_name, file_id)
821                    .with_qualified_name(target_name),
822            )
823            .unwrap();
824        graph.edges_mut().add_edge(
825            importer,
826            target,
827            EdgeKind::Imports {
828                alias: None,
829                is_wildcard: false,
830            },
831            file_id,
832        );
833
834        let snapshot = Arc::new(graph.snapshot());
835        let out = compute_relation_source_set(
836            RelationKind::Imports,
837            &RelationKey::exact("serde"),
838            &snapshot,
839        );
840        assert_eq!(out.as_ref(), &vec![importer]);
841    }
842
843    #[test]
844    fn derived_query_is_cached_and_invalidated_on_edge_bump() {
845        let mut graph = CodeGraph::new();
846        let path = Path::new("main.rs");
847        let a = alloc_function(&mut graph, "a", path);
848        let b = alloc_function(&mut graph, "b", path);
849        add_calls_edge(&mut graph, a, b);
850
851        let db = build_db_for_graph(graph);
852        let first = db.get::<ImplementsQuery>(&RelationKey::exact("nothing"));
853        assert!(first.as_ref().is_empty());
854        let second = db.get::<ImplementsQuery>(&RelationKey::exact("nothing"));
855        assert!(Arc::ptr_eq(&first, &second));
856
857        db.bump_edge_revision();
858        let third = db.get::<ImplementsQuery>(&RelationKey::exact("nothing"));
859        assert!(!Arc::ptr_eq(&first, &third));
860    }
861
862    #[test]
863    fn relation_matches_node_via_set_honours_direction() {
864        let mut graph = CodeGraph::new();
865        let path = Path::new("main.rs");
866        let a = alloc_function(&mut graph, "a", path);
867        let b = alloc_function(&mut graph, "b", path);
868        add_calls_edge(&mut graph, a, b);
869
870        let snapshot = graph.snapshot();
871        // Callers(X) semantics: does b have an incoming Calls from an
872        // endpoint in the set? Set = {a}. Yes → true.
873        let mut endpoints = HashSet::new();
874        endpoints.insert(a);
875        assert!(relation_matches_node_via_set(
876            RelationKind::Callers,
877            b,
878            &endpoints,
879            &snapshot
880        ));
881        // a has no incoming Calls, so its Callers set is empty → false.
882        assert!(!relation_matches_node_via_set(
883            RelationKind::Callers,
884            a,
885            &endpoints,
886            &snapshot
887        ));
888    }
889
890    /// Codex review (2026-04-15) — explicit regression for the
891    /// `EndpointRole::Either` self-loop skip on `exports:`.
892    ///
893    /// A node with an `Exports` self-edge must not satisfy `exports:<own
894    /// name>` purely through that self-edge. The original `match_*`
895    /// semantics rejected it (otherwise every exported symbol becomes its
896    /// own export target and the predicate degenerates), and the planner's
897    /// `relation_matches` always skipped self-loops under `Either`. DB14
898    /// must preserve both behaviours in both code paths.
899    #[test]
900    fn exports_self_loop_is_rejected_in_keymatcher_path() {
901        let mut graph = CodeGraph::new();
902        let path = Path::new("lib.rs");
903        let file_id = graph.files_mut().register(path).unwrap();
904        let sym_name = graph.strings_mut().intern("self_export").unwrap();
905        let sym = graph
906            .nodes_mut()
907            .alloc(
908                NodeEntry::new(NodeKind::Function, sym_name, file_id).with_qualified_name(sym_name),
909            )
910            .unwrap();
911        graph.edges_mut().add_edge(
912            sym,
913            sym,
914            EdgeKind::Exports {
915                kind: ExportKind::Direct,
916                alias: None,
917            },
918            file_id,
919        );
920
921        let snapshot = Arc::new(graph.snapshot());
922        let out = compute_relation_source_set(
923            RelationKind::Exports,
924            &RelationKey::exact("self_export"),
925            &snapshot,
926        );
927        assert!(
928            out.as_ref().is_empty(),
929            "self-export must not satisfy exports:<own name> (regression \
930             for the Either-role self-loop skip)"
931        );
932    }
933
934    #[test]
935    fn exports_self_loop_is_rejected_in_subquery_set_path() {
936        let mut graph = CodeGraph::new();
937        let path = Path::new("lib.rs");
938        let file_id = graph.files_mut().register(path).unwrap();
939        let sym_name = graph.strings_mut().intern("self_export").unwrap();
940        let sym = graph
941            .nodes_mut()
942            .alloc(
943                NodeEntry::new(NodeKind::Function, sym_name, file_id).with_qualified_name(sym_name),
944            )
945            .unwrap();
946        graph.edges_mut().add_edge(
947            sym,
948            sym,
949            EdgeKind::Exports {
950                kind: ExportKind::Direct,
951                alias: None,
952            },
953            file_id,
954        );
955
956        let snapshot = graph.snapshot();
957        let mut endpoints = HashSet::new();
958        endpoints.insert(sym);
959        assert!(
960            !relation_matches_node_via_set(RelationKind::Exports, sym, &endpoints, &snapshot,),
961            "subquery-set path must also skip the Either-role self-loop"
962        );
963    }
964
965    /// Codex review (2026-04-15) — semantic-divergence freeze for `imports:`.
966    ///
967    /// Locks the post-DB15 agreement between sqry-db's per-node
968    /// `ImportsQuery` and the `sqry-core::query::executor::graph_eval`
969    /// engine. Both must produce identical match decisions for the same
970    /// `(node, target_module)` pair. If a future change re-introduces the
971    /// pre-DB15 file-scoped behavior in either engine, this test fails so
972    /// the divergence cannot ship silently.
973    ///
974    /// Coverage matrix (post-DB15-followup):
975    /// * outgoing `Imports` edge with matching target text — both engines must agree
976    /// * outgoing `Imports` edge with matching alias text — both engines must agree
977    /// * outgoing `Imports` edge with `is_wildcard = true` and `*` key — both engines must agree
978    /// * an `Import` *node* whose own name matches the key (the
979    ///   `entry.kind == NodeKind::Import` fast-path in
980    ///   `graph_eval::match_imports` and the same fall-through in
981    ///   `compute_relation_source_set`) — both engines must agree
982    /// * negative case: a non-`Import` node in the same file with no
983    ///   outgoing `Imports` edge — both engines must reject (would have
984    ///   matched under the pre-DB15 file-scoped semantic)
985    #[test]
986    fn imports_per_node_semantic_aligned_across_engines() {
987        use sqry_core::query::executor::graph_eval::{import_edge_matches, import_entry_matches};
988
989        let mut graph = CodeGraph::new();
990        let path = Path::new("lib.rs");
991        let file_id = graph.files_mut().register(path).unwrap();
992        let importer_name = graph.strings_mut().intern("importer").unwrap();
993        let alias_importer_name = graph.strings_mut().intern("alias_importer").unwrap();
994        let wildcard_importer_name = graph.strings_mut().intern("wildcard_importer").unwrap();
995        let import_node_name = graph.strings_mut().intern("serde").unwrap();
996        let unrelated_name = graph.strings_mut().intern("unrelated").unwrap();
997        let target_name = graph.strings_mut().intern("serde").unwrap();
998        let aliased_target_name = graph.strings_mut().intern("private_target").unwrap();
999        let alias_text = graph.strings_mut().intern("serde").unwrap();
1000        let wildcard_target_name = graph.strings_mut().intern("anything").unwrap();
1001
1002        // Function with an outgoing Imports -> serde edge.
1003        let importer = graph
1004            .nodes_mut()
1005            .alloc(
1006                NodeEntry::new(NodeKind::Function, importer_name, file_id)
1007                    .with_qualified_name(importer_name),
1008            )
1009            .unwrap();
1010        // Function whose Imports edge target name does NOT match, but
1011        // whose alias does — exercises the alias-match branch.
1012        let alias_importer = graph
1013            .nodes_mut()
1014            .alloc(
1015                NodeEntry::new(NodeKind::Function, alias_importer_name, file_id)
1016                    .with_qualified_name(alias_importer_name),
1017            )
1018            .unwrap();
1019        // Function with a wildcard Imports edge — matches `*` key.
1020        let wildcard_importer = graph
1021            .nodes_mut()
1022            .alloc(
1023                NodeEntry::new(NodeKind::Function, wildcard_importer_name, file_id)
1024                    .with_qualified_name(wildcard_importer_name),
1025            )
1026            .unwrap();
1027        // Bare Import node whose own name is `serde` — exercises the
1028        // `entry.kind == NodeKind::Import` branch in match_imports.
1029        let import_node = graph
1030            .nodes_mut()
1031            .alloc(
1032                NodeEntry::new(NodeKind::Import, import_node_name, file_id)
1033                    .with_qualified_name(import_node_name),
1034            )
1035            .unwrap();
1036        // Same-file node with no outgoing Imports — pre-DB15 file-scoped
1037        // semantic would have matched this; post-DB15 per-node must reject.
1038        let unrelated = graph
1039            .nodes_mut()
1040            .alloc(
1041                NodeEntry::new(NodeKind::Function, unrelated_name, file_id)
1042                    .with_qualified_name(unrelated_name),
1043            )
1044            .unwrap();
1045        let target = graph
1046            .nodes_mut()
1047            .alloc(
1048                NodeEntry::new(NodeKind::Module, target_name, file_id)
1049                    .with_qualified_name(target_name),
1050            )
1051            .unwrap();
1052        let aliased_target = graph
1053            .nodes_mut()
1054            .alloc(
1055                NodeEntry::new(NodeKind::Module, aliased_target_name, file_id)
1056                    .with_qualified_name(aliased_target_name),
1057            )
1058            .unwrap();
1059        let wildcard_target = graph
1060            .nodes_mut()
1061            .alloc(
1062                NodeEntry::new(NodeKind::Module, wildcard_target_name, file_id)
1063                    .with_qualified_name(wildcard_target_name),
1064            )
1065            .unwrap();
1066
1067        // importer --Imports{None}--> serde
1068        graph.edges_mut().add_edge(
1069            importer,
1070            target,
1071            EdgeKind::Imports {
1072                alias: None,
1073                is_wildcard: false,
1074            },
1075            file_id,
1076        );
1077        // alias_importer --Imports{alias=serde}--> private_target
1078        graph.edges_mut().add_edge(
1079            alias_importer,
1080            aliased_target,
1081            EdgeKind::Imports {
1082                alias: Some(alias_text),
1083                is_wildcard: false,
1084            },
1085            file_id,
1086        );
1087        // wildcard_importer --Imports{wildcard}--> anything
1088        graph.edges_mut().add_edge(
1089            wildcard_importer,
1090            wildcard_target,
1091            EdgeKind::Imports {
1092                alias: None,
1093                is_wildcard: true,
1094            },
1095            file_id,
1096        );
1097
1098        let snapshot = Arc::new(graph.snapshot());
1099
1100        let sqry_db_matches_for = |key: &str| -> Arc<Vec<NodeId>> {
1101            compute_relation_source_set(RelationKind::Imports, &RelationKey::exact(key), &snapshot)
1102        };
1103
1104        // Per-node graph_eval check covering ALL of match_imports's
1105        // branches (Import node fast-path + outgoing-edge branch).
1106        let snapshot_ref: &GraphSnapshot = snapshot.as_ref();
1107        let graph_eval_matches = |node_id: NodeId, key: &str| -> bool {
1108            let entry = match snapshot_ref.nodes().get(node_id) {
1109                Some(e) => e,
1110                None => return false,
1111            };
1112            if entry.kind == NodeKind::Import && import_entry_matches(snapshot_ref, entry, key) {
1113                return true;
1114            }
1115            snapshot_ref
1116                .edges()
1117                .edges_from(node_id)
1118                .iter()
1119                .any(|edge| import_edge_matches(snapshot_ref, edge, key))
1120        };
1121
1122        // (key, node, label, expected_match)
1123        let cases: &[(&str, NodeId, &str, bool)] = &[
1124            ("serde", importer, "importer (target match)", true),
1125            (
1126                "serde",
1127                alias_importer,
1128                "alias_importer (alias match)",
1129                true,
1130            ),
1131            (
1132                "*",
1133                wildcard_importer,
1134                "wildcard_importer (wildcard *)",
1135                true,
1136            ),
1137            ("serde", import_node, "import_node (Import fast-path)", true),
1138            ("serde", unrelated, "unrelated (no outgoing Imports)", false),
1139            ("serde", target, "target (no outgoing Imports)", false),
1140        ];
1141
1142        for &(key, node, label, expected) in cases {
1143            let db_set = sqry_db_matches_for(key);
1144            let db_hit = db_set.contains(&node);
1145            let ge_hit = graph_eval_matches(node, key);
1146            assert_eq!(
1147                db_hit, ge_hit,
1148                "{label}: sqry-db ImportsQuery and graph_eval::match_imports \
1149                 must agree under per-node semantics for key={key:?} \
1150                 (db_hit={db_hit}, ge_hit={ge_hit})"
1151            );
1152            assert_eq!(
1153                db_hit, expected,
1154                "{label}: expected match={expected} for key={key:?}"
1155            );
1156        }
1157    }
1158}
1159
1160// ============================================================================
1161// PN3 serde roundtrip tests
1162// ============================================================================
1163
1164#[cfg(test)]
1165mod serde_roundtrip {
1166    use super::*;
1167    use crate::planner::ir::{MatchMode, RegexFlags, RegexPattern, StringPattern};
1168    use postcard::{from_bytes, to_allocvec};
1169
1170    #[test]
1171    fn relation_key_pattern_roundtrip() {
1172        let original = RelationKey::Pattern(StringPattern {
1173            raw: "my_function".to_string(),
1174            mode: MatchMode::Exact,
1175            case_insensitive: false,
1176        });
1177        let bytes = to_allocvec(&original).expect("serialize failed");
1178        let decoded: RelationKey = from_bytes(&bytes).expect("deserialize failed");
1179        assert_eq!(decoded, original);
1180    }
1181
1182    #[test]
1183    fn relation_key_regex_roundtrip() {
1184        let original = RelationKey::Regex(RegexPattern {
1185            pattern: "fn_.*".to_string(),
1186            flags: RegexFlags {
1187                case_insensitive: true,
1188                multiline: false,
1189                dot_all: false,
1190            },
1191        });
1192        let bytes = to_allocvec(&original).expect("serialize failed");
1193        let decoded: RelationKey = from_bytes(&bytes).expect("deserialize failed");
1194        assert_eq!(decoded, original);
1195    }
1196}