Skip to main content

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    pub fn matches(&self, element: &Value) -> bool {
109        let scalar = lookup(element, &self.path).and_then(scalar_text);
110        match (self.op, scalar) {
111            (FilterOp::Equals, Some(text)) => text == self.value,
112            (FilterOp::Equals, None) => false,
113            (FilterOp::NotEquals, Some(text)) => text != self.value,
114            (FilterOp::NotEquals, None) => true,
115            (FilterOp::Contains, Some(text)) => {
116                text.to_lowercase().contains(&self.value.to_lowercase())
117            }
118            (FilterOp::Contains, None) => false,
119        }
120    }
121}
122
123/// Walks a dotted path inside `value`.
124pub fn lookup<'a>(value: &'a Value, path: &[String]) -> Option<&'a Value> {
125    let mut cursor = value;
126    for segment in path {
127        cursor = cursor.as_object()?.get(segment.as_str())?;
128    }
129    Some(cursor)
130}
131
132/// Walks a dotted path given as one unsplit string.
133///
134/// The sibling of [`lookup`], for callers that hold the key as the caller wrote
135/// it rather than pre-split. [`FilterExpr`] splits once at parse time because it
136/// then walks the path for every element; the gate resolves a key against many
137/// elements too, but it holds the key as text and splitting it per call would
138/// allocate a `Vec<String>` the walk never needs.
139pub fn resolve<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
140    let mut cursor = value;
141    for segment in key.split('.') {
142        cursor = cursor.as_object()?.get(segment)?;
143    }
144    Some(cursor)
145}
146
147/// Renders a JSON scalar as the text used for comparison and dedup keys.
148///
149/// Containers return `None`: comparing an array to a string would only produce
150/// surprising matches.
151pub fn scalar_text(value: &Value) -> Option<String> {
152    match value {
153        Value::String(s) => Some(s.clone()),
154        Value::Number(n) => Some(n.to_string()),
155        Value::Bool(b) => Some(b.to_string()),
156        Value::Null => Some(String::new()),
157        Value::Array(_) | Value::Object(_) => None,
158    }
159}
160
161/// Returns `true` when every predicate accepts `element`.
162pub fn matches_all(filters: &[FilterExpr], element: &Value) -> bool {
163    filters.iter().all(|f| f.matches(element))
164}