facett-graphnav/src/query.rs
//! The **query bar** engine — a small, tolerant subset of Cypher **and** Gremlin, run
//! against a [`PropGraph`]. Not a full parser; the shapes a graph-DB IDE's browse bar
//! actually needs, so a query returns a node/edge result set the canvas highlights.
//!
//! Supported (case-insensitive keywords):
//!
//! **Cypher-ish**
//! - `MATCH (n) RETURN n` — every node
//! - `MATCH (n:Person) RETURN n` — by label
//! - `MATCH (n:Person {name:"Alice"}) RETURN n` — by label + property
//! - `MATCH ()-[r:KNOWS]->() RETURN r` — edges of a relationship type
//!
//! **Gremlin-ish**
//! - `g.V()` / `g.V().hasLabel('Person')` / `g.V().has('name','Alice')`
//! - `g.E()` / `g.E().hasLabel('KNOWS')`
//!
//! Anything else is treated as a **free-text search** over ids / captions / labels.
use crate::model::PropGraph;
/// What a query selected.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ResultKind {
Nodes,
Edges,
/// The query didn't parse as Cypher/Gremlin; treated as free-text node search.
Search,
/// Empty / whitespace query.
Empty,
}
impl ResultKind {
/// The stable lowercase key (for `state_json`).
#[must_use]
pub fn key(self) -> &'static str {
match self {
ResultKind::Nodes => "nodes",
ResultKind::Edges => "edges",
ResultKind::Search => "search",
ResultKind::Empty => "empty",
}
}
}
/// A query result: the matched node ids and/or edge indices, the [`ResultKind`], and a
/// parse/eval `error` (when the query was malformed but recoverable).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct QueryResult {
pub nodes: Vec<String>,
pub edges: Vec<usize>,
pub kind_nodes: bool,
pub kind_edges: bool,
pub is_search: bool,
pub error: Option<String>,
}
impl QueryResult {
fn empty() -> Self {
QueryResult::default()
}
/// The [`ResultKind`] this result represents.
#[must_use]
pub fn kind(&self) -> ResultKind {
if self.error.is_some() && self.nodes.is_empty() && self.edges.is_empty() {
return ResultKind::Empty;
}
if self.is_search {
ResultKind::Search
} else if self.kind_edges {
ResultKind::Edges
} else if self.kind_nodes {
ResultKind::Nodes
} else {
ResultKind::Empty
}
}
/// Total selected elements.
#[must_use]
pub fn len(&self) -> usize {
self.nodes.len() + self.edges.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
/// Run `q` against `g`.
#[must_use]
pub fn run(g: &PropGraph, q: &str) -> QueryResult {
let t = q.trim();
if t.is_empty() {
return QueryResult::empty();
}
let lower = t.to_ascii_lowercase();
if lower.starts_with("g.") {
gremlin(g, t)
} else if lower.contains("match") {
cypher(g, t)
} else {
search(g, t)
}
}
/// **Free-text search** — fuzzy-ish substring over node id / caption / label / property
/// values (case-insensitive). The fuzzy-search panel's engine too.
#[must_use]
pub fn search(g: &PropGraph, needle: &str) -> QueryResult {
let n = needle.trim().to_ascii_lowercase();
let mut r = QueryResult { is_search: true, ..QueryResult::empty() };
if n.is_empty() {
return r;
}
for node in &g.nodes {
let hay = format!(
"{} {} {} {}",
node.id.to_ascii_lowercase(),
node.caption().to_ascii_lowercase(),
node.label.to_ascii_lowercase(),
node.props.values().map(|v| v.to_ascii_lowercase()).collect::<Vec<_>>().join(" "),
);
if hay.contains(&n) {
r.nodes.push(node.id.clone());
}
}
r
}
/// Parse the **Cypher-ish** MATCH.
fn cypher(g: &PropGraph, q: &str) -> QueryResult {
// Relationship query? `-[ ... ]-` present → return matching edges.
if let Some(open) = q.find("-[") {
let rest = &q[open + 2..];
if let Some(close) = rest.find(']') {
let inner = &rest[..close]; // e.g. `r:KNOWS` or `:KNOWS` or `r`
let etype = inner.split(':').nth(1).map(|s| s.trim().trim_matches(|c| c == ']' || c == ' ').to_string());
let mut r = QueryResult { kind_edges: true, ..QueryResult::empty() };
for (i, e) in g.edges.iter().enumerate() {
if etype.as_ref().is_none_or(|t| t.is_empty() || e.etype.eq_ignore_ascii_case(t)) {
r.edges.push(i);
}
}
return r;
}
}
// Node pattern `( ... )`.
let (label, props) = match extract_node_pattern(q) {
Some(x) => x,
None => return QueryResult { error: Some("no node pattern found".into()), ..QueryResult::empty() },
};
node_match(g, label.as_deref(), &props)
}
/// Parse the **Gremlin-ish** traversal.
fn gremlin(g: &PropGraph, q: &str) -> QueryResult {
let is_edges = q.contains(".E(");
// hasLabel('X')
let label = between(q, "hasLabel(", ")").map(|s| s.trim().trim_matches(['\'', '"']).to_string());
// has('k','v')
let has = between(q, "has(", ")").and_then(|inner| {
let parts: Vec<&str> = inner.split(',').collect();
if parts.len() == 2 {
Some((
parts[0].trim().trim_matches(['\'', '"']).to_string(),
parts[1].trim().trim_matches(['\'', '"']).to_string(),
))
} else {
None
}
});
if is_edges {
let mut r = QueryResult { kind_edges: true, ..QueryResult::empty() };
for (i, e) in g.edges.iter().enumerate() {
if label.as_ref().is_none_or(|l| e.etype.eq_ignore_ascii_case(l)) {
r.edges.push(i);
}
}
r
} else {
let props = has.into_iter().collect::<Vec<_>>();
node_match(g, label.as_deref(), &props)
}
}
/// Select nodes by optional `label` and a set of `(key, value)` property equalities.
fn node_match(g: &PropGraph, label: Option<&str>, props: &[(String, String)]) -> QueryResult {
let mut r = QueryResult { kind_nodes: true, ..QueryResult::empty() };
for n in &g.nodes {
if let Some(l) = label && !l.is_empty() && !n.label.eq_ignore_ascii_case(l) {
continue;
}
if props.iter().all(|(k, v)| n.props.get(k).is_some_and(|pv| pv.eq_ignore_ascii_case(v))) {
r.nodes.push(n.id.clone());
}
}
r
}
/// A node pattern's optional label + its `{k: "v"}` property pairs.
type NodePattern = (Option<String>, Vec<(String, String)>);
/// Extract `(label, props)` from the first `( ... )` node pattern.
fn extract_node_pattern(q: &str) -> Option<NodePattern> {
let inner = between(q, "(", ")")?;
// `n:Label {k:"v", k2:"v2"}` — split off the optional brace map first.
let (head, brace) = match inner.find('{') {
Some(b) => (&inner[..b], Some(inner[b..].trim_matches(['{', '}', ' ']).to_string())),
None => (inner.as_str(), None),
};
let label = head.split(':').nth(1).map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
let mut props = Vec::new();
if let Some(b) = brace {
for pair in b.split(',') {
if let Some((k, v)) = pair.split_once(':') {
props.push((k.trim().trim_matches(['\'', '"']).to_string(), v.trim().trim_matches(['\'', '"']).to_string()));
}
}
}
Some((label, props))
}
/// The substring between the first `open` and the next `close` after it.
fn between(s: &str, open: &str, close: &str) -> Option<String> {
let i = s.find(open)? + open.len();
let rest = &s[i..];
let j = rest.find(close)?;
Some(rest[..j].to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::PropGraph;
#[test]
fn cypher_match_all_and_by_label() {
let g = PropGraph::demo();
let all = run(&g, "MATCH (n) RETURN n");
assert_eq!(all.kind(), ResultKind::Nodes);
assert_eq!(all.nodes.len(), g.nodes.len());
let people = run(&g, "MATCH (n:Person) RETURN n");
assert_eq!(people.nodes.len(), 6);
assert!(people.nodes.iter().all(|id| g.node(id).unwrap().label == "Person"));
}
#[test]
fn cypher_match_by_property_and_relationship() {
let g = PropGraph::demo();
let alice = run(&g, r#"MATCH (n:Person {name:"Alice"}) RETURN n"#);
assert_eq!(alice.nodes, vec!["p1".to_string()]);
let knows = run(&g, "MATCH ()-[r:KNOWS]->() RETURN r");
assert_eq!(knows.kind(), ResultKind::Edges);
assert_eq!(knows.edges.len(), 7, "seven KNOWS edges in the demo");
}
#[test]
fn gremlin_v_and_has() {
let g = PropGraph::demo();
assert_eq!(run(&g, "g.V()").nodes.len(), g.nodes.len());
assert_eq!(run(&g, "g.V().hasLabel('Company')").nodes.len(), 2);
assert_eq!(run(&g, "g.V().has('name','Bob')").nodes, vec!["p2".to_string()]);
assert_eq!(run(&g, "g.E().hasLabel('WORKS_AT')").edges.len(), 4);
}
#[test]
fn free_text_search_matches_captions() {
let g = PropGraph::demo();
let r = run(&g, "acme");
assert_eq!(r.kind(), ResultKind::Search);
assert_eq!(r.nodes, vec!["c1".to_string()]);
// Empty query → empty.
assert_eq!(run(&g, " ").kind(), ResultKind::Empty);
}
}