sqlite_graphrag/agent_surface/filter.rs
1//! Filter expression grammar for `--filter`.
2//!
3//! Three operators are accepted, deliberately kept small so an agent can emit
4//! them without a parser of its own:
5//!
6//! | Form | Meaning |
7//! |-------------------|----------------------------------------------------|
8//! | `key=value` | scalar at `key` equals `value` (case sensitive) |
9//! | `key!=value` | scalar at `key` differs from `value` |
10//! | `key~substring` | scalar at `key` contains `substring` (case folded) |
11//!
12//! `==` is accepted as a synonym of `=` for parity with sibling CLIs. `key`
13//! may be a dotted path (`stats.total`) to reach a nested scalar. Repeating
14//! `--filter` conjoins the predicates with a logical AND.
15//!
16//! Parsing is fail-fast: a malformed expression is rejected before any work is
17//! done, so a typo can never be mistaken for "no rows matched".
18
19use serde_json::Value;
20
21/// Comparison performed by a single [`FilterExpr`].
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum FilterOp {
24 /// `key=value` / `key==value`.
25 Equals,
26 /// `key!=value`.
27 NotEquals,
28 /// `key~substring`, case-insensitive substring test.
29 Contains,
30}
31
32/// One parsed `--filter` predicate.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct FilterExpr {
35 /// Dotted path segments addressing a scalar inside each element.
36 path: Vec<String>,
37 /// Comparison to perform.
38 op: FilterOp,
39 /// Right-hand side, compared against the scalar rendered as text.
40 value: String,
41}
42
43/// Separator candidates ordered so the longest token wins at a given offset.
44const SEPARATORS: &[(&str, FilterOp)] = &[
45 ("!=", FilterOp::NotEquals),
46 ("==", FilterOp::Equals),
47 ("~", FilterOp::Contains),
48 ("=", FilterOp::Equals),
49];
50
51impl FilterExpr {
52 /// Parses one `--filter` expression.
53 ///
54 /// # Errors
55 /// Returns a localized message when no operator is present or when the
56 /// key side is empty.
57 pub fn parse(raw: &str) -> Result<Self, String> {
58 let mut best: Option<(usize, usize, FilterOp)> = None;
59 for (token, op) in SEPARATORS {
60 if let Some(idx) = raw.find(token) {
61 let better = match best {
62 None => true,
63 // Earliest position wins; at the same position the longer
64 // token wins so `!=` is never read as `=`.
65 Some((cur_idx, cur_len, _)) => {
66 idx < cur_idx || (idx == cur_idx && token.len() > cur_len)
67 }
68 };
69 if better {
70 best = Some((idx, token.len(), *op));
71 }
72 }
73 }
74 let Some((idx, len, op)) = best else {
75 return Err(crate::i18n::validation::agent_surface_filter_invalid(raw));
76 };
77 let key = raw[..idx].trim();
78 if key.is_empty() {
79 return Err(crate::i18n::validation::agent_surface_filter_empty_key(raw));
80 }
81 let value = &raw[idx + len..];
82 Ok(Self {
83 path: key.split('.').map(str::to_string).collect(),
84 op,
85 value: value.to_string(),
86 })
87 }
88
89 /// The dotted path this predicate addresses, as the caller wrote it.
90 ///
91 /// The gate needs the key to resolve it against the envelope vocabulary and
92 /// to name it in a refusal; rebuilding the string from [`Self::path`] at
93 /// every call site would duplicate the join.
94 pub fn key(&self) -> String {
95 self.path.join(".")
96 }
97
98 /// The parsed path segments, for callers that resolve rather than compare.
99 pub fn path(&self) -> &[String] {
100 &self.path
101 }
102
103 /// Evaluates the predicate against one element.
104 ///
105 /// A missing or non-scalar path never satisfies [`FilterOp::Equals`] or
106 /// [`FilterOp::Contains`]; it *does* satisfy [`FilterOp::NotEquals`],
107 /// which reads as "this element does not carry that value".
108 ///
109 /// GAP-SG-274: `command` is the surface's subcommand slug, and it scopes the
110 /// synonym table exactly as it scopes the gate. Passing `None` here would not
111 /// merely narrow the lookup — it would let the gate ACCEPT a key the walk
112 /// then fails to find, which is the accepted-and-ignored shape the surface
113 /// exists to remove.
114 pub fn matches(&self, element: &Value, command: Option<&str>) -> bool {
115 let scalar = lookup(element, &self.path, command).and_then(scalar_text);
116 match (self.op, scalar) {
117 (FilterOp::Equals, Some(text)) => text == self.value,
118 (FilterOp::Equals, None) => false,
119 (FilterOp::NotEquals, Some(text)) => text != self.value,
120 (FilterOp::NotEquals, None) => true,
121 (FilterOp::Contains, Some(text)) => {
122 text.to_lowercase().contains(&self.value.to_lowercase())
123 }
124 (FilterOp::Contains, None) => false,
125 }
126 }
127}
128
129/// Walks a dotted path inside `value`.
130///
131/// GAP-SG-230: when the last segment names a field this project spells more than
132/// one way, the sibling spellings are tried before answering `None`. This is the
133/// one place worth doing it, because it is the ONE accessor all four shaping
134/// knobs share — `FilterExpr::matches`, [`super::shape::sort`],
135/// [`super::shape::dedupe`] and `shape::project_with` all reach the payload
136/// through here. Resolving anywhere upstream would have meant rewriting the
137/// caller's key in four places, and `FilterExpr` keeps its path private
138/// precisely so nobody does that.
139///
140/// The fallback runs only when the direct walk already failed, so the hot path
141/// pays one `Option` test and nothing else. A payload that carries the requested
142/// spelling never consults the table at all.
143///
144/// GAP-SG-274: `command` is the subcommand slug the surface resolved, and it
145/// selects which groups of the table are in force — `kind` names the entity type
146/// under `graph` and the line discriminator under `graph-ndjson`.
147pub fn lookup<'a>(value: &'a Value, path: &[String], command: Option<&str>) -> Option<&'a Value> {
148 if let Some(found) = walk(value, path.iter().map(String::as_str)) {
149 return Some(found);
150 }
151 let (last, prefix) = path.split_last()?;
152 for spelling in synonyms_of(last, command) {
153 if let Some(found) = walk(
154 value,
155 prefix
156 .iter()
157 .map(String::as_str)
158 .chain(std::iter::once(spelling)),
159 ) {
160 return Some(found);
161 }
162 }
163 None
164}
165
166/// Walks an already-split path, one segment at a time.
167fn walk<'a, 'b>(value: &'a Value, path: impl Iterator<Item = &'b str>) -> Option<&'a Value> {
168 let mut cursor = value;
169 for segment in path {
170 cursor = cursor.as_object()?.get(segment)?;
171 }
172 Some(cursor)
173}
174
175/// Sibling spellings of a leaf field name, excluding the name itself.
176///
177/// Empty for every field that has only one spelling, which is almost all of
178/// them, so the fallback above walks nothing in the common case.
179///
180/// GAP-SG-274: `command` selects the groups in force. Two groups may both list
181/// the same spelling — `type` belongs to the unscoped entity-type group and to
182/// the `graph` group that adds `kind` — so a name is yielded once and only once,
183/// and the walk never retries a path it already rejected.
184fn synonyms_of(leaf: &str, command: Option<&str>) -> Vec<&'static str> {
185 let mut out: Vec<&'static str> = Vec::new();
186 for group in crate::constants::agent_surface_field_synonym_groups(command) {
187 if !group.contains(&leaf) {
188 continue;
189 }
190 for spelling in group {
191 if *spelling != leaf && !out.contains(spelling) {
192 out.push(spelling);
193 }
194 }
195 }
196 out
197}
198
199/// Walks a dotted path given as one unsplit string.
200///
201/// The sibling of [`lookup`], for callers that hold the key as the caller wrote
202/// it rather than pre-split. [`FilterExpr`] splits once at parse time because it
203/// then walks the path for every element; the gate resolves a key against many
204/// elements too, but it holds the key as text and splitting it per call would
205/// allocate a `Vec<String>` the walk never needs.
206/// Deliberately does NOT apply the synonym table that [`lookup`] applies.
207/// `Scope` calls this to ask which spellings a payload literally carries, and it
208/// applies the table itself, one spelling at a time. Folding the fallback in here
209/// too would make every spelling in a group answer for every other, and
210/// `Scope::effective_key` — whose whole job is telling them apart — would always
211/// return the first candidate it tried.
212pub fn resolve<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
213 walk(value, key.split('.'))
214}
215
216/// Renders a JSON scalar as the text used for comparison and dedup keys.
217///
218/// Containers return `None`: comparing an array to a string would only produce
219/// surprising matches.
220pub fn scalar_text(value: &Value) -> Option<String> {
221 match value {
222 Value::String(s) => Some(s.clone()),
223 Value::Number(n) => Some(n.to_string()),
224 Value::Bool(b) => Some(b.to_string()),
225 Value::Null => Some(String::new()),
226 Value::Array(_) | Value::Object(_) => None,
227 }
228}
229
230/// Returns `true` when every predicate accepts `element`.
231///
232/// GAP-SG-274: `command` is forwarded to [`FilterExpr::matches`] so every
233/// predicate reads the payload under the same synonym scope the gate used to
234/// admit it.
235pub fn matches_all(filters: &[FilterExpr], element: &Value, command: Option<&str>) -> bool {
236 filters.iter().all(|f| f.matches(element, command))
237}