Skip to main content

uqa_graph/cypher/
executor.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Read-only Cypher executor: walks a `CypherQuery` AST and lowers it
8//! onto graph operators against a [`GraphStore`].
9//!
10//! Semantics follow Apache AGE 1.6.0 (verified against a live
11//! container): agtype total ordering for `ORDER BY` and comparisons,
12//! three-valued boolean logic with strict boolean inputs, C-style
13//! integer division / modulo (`n % 0` returns `n`, matching AGE),
14//! float `^` power, end-exclusive list slices, end-inclusive
15//! `range()`, byte-length `size()` on strings, unanchored `=~`, and
16//! graph entities that render as `::vertex` / `::edge` / `::path`.
17//!
18//! Supported clauses: `MATCH` (node, 1-hop rel, variable-length rel,
19//! path variables), `OPTIONAL MATCH`, `WHERE`, `RETURN` (with
20//! `DISTINCT`, `ORDER BY`, `SKIP`, `LIMIT`), `WITH`, and `UNWIND`. Mutation
21//! clauses live in [`crate::cypher::writer::CypherWriter`].
22
23use std::collections::{BTreeMap, BTreeSet};
24
25use uqa_core::{Edge, EdgeId, Value, Vertex, VertexId};
26
27use crate::agtype;
28use crate::cypher::ast::{
29    BinaryOp, CaseExpr, CypherClause, CypherExpr, CypherQuery, FunctionCall, InList, IsNotNull,
30    IsNull, ListComprehension, ListIndex, ListLiteral, ListSlice, Literal, MapLiteral, MatchClause,
31    NodePattern, OrderByItem, Parameter, PathElement, PathPattern, PropertyAccess, RelDirection,
32    RelPattern, ReturnItem, UnaryOp, UnwindClause, Variable,
33};
34use crate::store::GraphStore;
35use crate::types::Direction;
36
37/// One row in the binding table threaded through the clause pipeline.
38/// Variables can resolve to vertex / edge / arbitrary value bindings.
39#[derive(Debug, Clone)]
40pub enum Binding {
41    Vertex(Vertex),
42    Edge(Edge),
43    Value(Value),
44    /// Variable-length relationship binding: ordered list of edges.
45    EdgeList(Vec<Edge>),
46}
47
48impl Binding {
49    fn property(&self, key: &str) -> Value {
50        match self {
51            Binding::Vertex(v) => v.properties.get(key).cloned().unwrap_or(Value::Null),
52            Binding::Edge(e) => e.properties.get(key).cloned().unwrap_or(Value::Null),
53            Binding::Value(v) => value_property(v, key).unwrap_or(Value::Null),
54            Binding::EdgeList(_) => Value::Null,
55        }
56    }
57
58    fn to_value(&self) -> Result<Value, CypherError> {
59        match self {
60            Binding::Vertex(v) => agtype::vertex_to_value(v).map_err(Into::into),
61            Binding::Edge(e) => agtype::edge_to_value(e).map_err(Into::into),
62            Binding::Value(v) => Ok(v.clone()),
63            Binding::EdgeList(edges) => Ok(Value::List(
64                edges
65                    .iter()
66                    .map(agtype::edge_to_value)
67                    .collect::<Result<_, _>>()?,
68            )),
69        }
70    }
71}
72
73/// Property lookup on an evaluated value (map, entity envelope, or
74/// null). `None` signals "not addressable" so callers can raise AGE's
75/// `scalar object must be a vertex or edge` error.
76fn value_property(value: &Value, key: &str) -> Option<Value> {
77    if let Some(props) = agtype::entity_properties(value) {
78        return Some(props.get(key).cloned().unwrap_or(Value::Null));
79    }
80    match value {
81        Value::Map(map) => Some(map.get(key).cloned().unwrap_or(Value::Null)),
82        Value::Null => Some(Value::Null),
83        _ => None,
84    }
85}
86
87/// A row in the binding table: variable name -> bound value.
88pub type BindingRow = BTreeMap<String, Binding>;
89
90/// Result row produced by RETURN / WITH (column name -> value).
91pub type ResultRow = BTreeMap<String, Value>;
92
93#[derive(Debug, thiserror::Error, PartialEq)]
94pub enum CypherError {
95    #[error("undefined variable {0:?}")]
96    UndefinedVariable(String),
97    #[error("undefined parameter {0:?}")]
98    UndefinedParameter(String),
99    #[error("unsupported clause: {0}")]
100    Unsupported(String),
101    #[error("{0}")]
102    TypeError(String),
103    #[error("parse error: {0}")]
104    Parse(String),
105    #[error("relation \"{0}\" does not exist")]
106    MissingLabelRelation(String),
107    #[error("storage error: {0}")]
108    Storage(String),
109    #[error("serialization failure: {0}")]
110    SerializationFailure(String),
111}
112
113impl From<crate::cypher::parser::ParseError> for CypherError {
114    fn from(err: crate::cypher::parser::ParseError) -> Self {
115        CypherError::Parse(err.to_string())
116    }
117}
118
119impl From<agtype::AgtypeConversionError> for CypherError {
120    fn from(err: agtype::AgtypeConversionError) -> Self {
121        CypherError::Storage(err.to_string())
122    }
123}
124
125impl From<crate::store::GraphStoreError> for CypherError {
126    fn from(err: crate::store::GraphStoreError) -> Self {
127        match err {
128            crate::store::GraphStoreError::SerializationFailure(message) => {
129                Self::SerializationFailure(message)
130            }
131            other => Self::Storage(other.to_string()),
132        }
133    }
134}
135
136fn boolean_cast_error(value: &Value) -> CypherError {
137    CypherError::TypeError(format!(
138        "cannot cast agtype {} to type boolean",
139        agtype::agtype_type_name(value)
140    ))
141}
142
143/// Strict boolean coercion (AGE): booleans pass through, null is
144/// three-valued unknown, anything else raises a cast error.
145fn strict_bool(value: &Value) -> Result<Option<bool>, CypherError> {
146    match value {
147        Value::Bool(b) => Ok(Some(*b)),
148        Value::Null => Ok(None),
149        other => Err(boolean_cast_error(other)),
150    }
151}
152
153const MAX_EXACT_F64_INTEGER: i64 = 9_007_199_254_740_992;
154
155fn usize_to_i64(value: usize, context: &str) -> Result<i64, CypherError> {
156    i64::try_from(value).map_err(|_| {
157        CypherError::TypeError(format!(
158            "{context} {value} exceeds the agtype integer range"
159        ))
160    })
161}
162
163fn nonnegative_i64_to_usize(value: i64, context: &str) -> Result<usize, CypherError> {
164    if value < 0 {
165        return Err(CypherError::TypeError(format!(
166            "{context} must not be negative, got {value}"
167        )));
168    }
169    usize::try_from(value).map_err(|_| {
170        CypherError::TypeError(format!(
171            "{context} {value} exceeds the platform index range"
172        ))
173    })
174}
175
176fn nonnegative_i64_to_u64(value: i64, context: &str) -> Result<u64, CypherError> {
177    u64::try_from(value)
178        .map_err(|_| CypherError::TypeError(format!("{context} must not be negative, got {value}")))
179}
180
181fn exact_i64_to_f64(value: i64, context: &str) -> Result<f64, CypherError> {
182    if (-MAX_EXACT_F64_INTEGER..=MAX_EXACT_F64_INTEGER).contains(&value) {
183        Ok(value as f64)
184    } else {
185        Err(CypherError::TypeError(format!(
186            "{context} {value} cannot be represented exactly as a float"
187        )))
188    }
189}
190
191fn trunc_f64_to_i64(value: f64, context: &str) -> Result<i64, CypherError> {
192    if !value.is_finite() {
193        return Err(CypherError::TypeError(format!(
194            "{context} must be finite, got {value}"
195        )));
196    }
197    let truncated = value.trunc();
198    if !(-9_223_372_036_854_775_808.0..9_223_372_036_854_775_808.0).contains(&truncated) {
199        return Err(CypherError::TypeError(format!(
200            "{context} {value} is outside the agtype integer range"
201        )));
202    }
203    Ok(truncated as i64)
204}
205
206/// Read-only execution context.
207pub struct CypherExecutor<'a, G: GraphStore> {
208    pub store: &'a G,
209    pub graph: &'a str,
210    pub params: BTreeMap<String, Value>,
211}
212
213/// Intermediate state while a path pattern binds: the row so far, the
214/// vertex the pattern currently stands on (anonymous nodes have no
215/// variable to look up), and the ordered vertex / edge trail (for
216/// `p = (...)` path variables).
217#[derive(Debug, Clone)]
218struct MatchState {
219    row: BindingRow,
220    position: Option<Vertex>,
221    trail: Vec<Value>,
222}
223
224impl<'a, G: GraphStore> CypherExecutor<'a, G> {
225    pub fn new(store: &'a G, graph: &'a str) -> Self {
226        Self {
227            store,
228            graph,
229            params: BTreeMap::new(),
230        }
231    }
232
233    pub fn with_params(mut self, params: BTreeMap<String, Value>) -> Self {
234        self.params = params;
235        self
236    }
237
238    pub fn execute(
239        &self,
240        query: &CypherQuery,
241    ) -> Result<(Vec<String>, Vec<ResultRow>), CypherError> {
242        let mut bindings: Vec<BindingRow> = vec![BTreeMap::new()];
243        let mut columns: Vec<String> = Vec::new();
244        let mut rows: Vec<ResultRow> = Vec::new();
245        for clause in &query.clauses {
246            match clause {
247                CypherClause::Match(m) => {
248                    bindings = self.exec_match(m, &bindings)?;
249                }
250                CypherClause::With(w) => {
251                    let (cols, projected) = self.exec_return_like(
252                        &w.items,
253                        w.distinct,
254                        w.order_by.as_deref(),
255                        w.skip.as_ref(),
256                        w.limit.as_ref(),
257                        &bindings,
258                    )?;
259                    let mut next = Vec::with_capacity(projected.len());
260                    for row in projected {
261                        if let Some(filter) = &w.r#where {
262                            if !self.where_passes(filter, &row)? {
263                                continue;
264                            }
265                        }
266                        next.push(Self::row_to_bindings(&cols, &row));
267                    }
268                    bindings = next;
269                }
270                CypherClause::Return(r) => {
271                    let (cols, ret_rows) = self.exec_return_like(
272                        &r.items,
273                        r.distinct,
274                        r.order_by.as_deref(),
275                        r.skip.as_ref(),
276                        r.limit.as_ref(),
277                        &bindings,
278                    )?;
279                    columns = cols;
280                    rows = ret_rows;
281                }
282                CypherClause::Unwind(clause) => {
283                    bindings = self.exec_unwind(clause, bindings)?;
284                }
285                CypherClause::Create(_)
286                | CypherClause::Merge(_)
287                | CypherClause::Set(_)
288                | CypherClause::Delete(_) => {
289                    return Err(CypherError::Unsupported(format!("{clause:?}")));
290                }
291            }
292        }
293        Ok((columns, rows))
294    }
295
296    pub(crate) fn row_to_bindings(cols: &[String], row: &ResultRow) -> BindingRow {
297        let mut out = BindingRow::new();
298        for col in cols {
299            if let Some(v) = row.get(col) {
300                out.insert(col.clone(), Binding::Value(v.clone()));
301            }
302        }
303        out
304    }
305
306    pub(crate) fn exec_unwind(
307        &self,
308        clause: &UnwindClause,
309        bindings: Vec<BindingRow>,
310    ) -> Result<Vec<BindingRow>, CypherError> {
311        let mut next = Vec::new();
312        for row in bindings {
313            let items = match self.eval(&clause.expr, &row)? {
314                Value::List(items) => items,
315                Value::Null => continue,
316                other => vec![other],
317            };
318            for item in items {
319                let mut new_row = row.clone();
320                new_row.insert(clause.variable.clone(), Binding::Value(item));
321                next.push(new_row);
322            }
323        }
324        Ok(next)
325    }
326
327    // ------------------------------------------------------------------
328    // MATCH
329    // ------------------------------------------------------------------
330}
331
332mod expression;
333mod functions;
334mod helpers;
335mod matching;
336mod projection;
337
338use helpers::{
339    aggregate_avg, aggregate_extreme, aggregate_sum, agtype_add, agtype_div, agtype_mod,
340    agtype_pow, domain_float_fn, float_fn, is_aggregate, is_aggregate_name, null_or_bool,
341    numeric_op, pattern_variables, regex_match, return_label, sort_keyed, str_predicate, string_fn,
342    unsupported_argument, validated_path_elements,
343};