Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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);
    }
}