Skip to main content

oxgraph_db/
query.rs

1//! Native `OxQL` and pinned Cypher-profile query execution.
2
3use oxgraph_graph::{
4    CanonicalElementIdentity, CanonicalRelationIdentity, EdgeSourceGraph, EdgeTargetGraph,
5    TopologyCounts,
6};
7use serde::{Deserialize, Serialize};
8
9use crate::{
10    DbError, ElementId, IncidenceRecord, ProjectionId, PropertyKeyId, PropertySubject,
11    PropertyValue, RelationId,
12    catalog::{ProjectionDefinition, PropertyFamily},
13    overlay::StateView,
14    projection::GraphProjection,
15    traversal::{self, TraversalDirection, TraversalOptions},
16    value::parse_value_token,
17};
18
19/// Query frontend language.
20///
21/// # Performance
22///
23/// Copying and comparing are `O(1)`.
24#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
25pub enum QueryLanguage {
26    /// Native topology-first `OxQL` profile.
27    Oxql,
28    /// Pinned Cypher language profile over property-graph projections.
29    Cypher,
30}
31
32/// Prepared query plan.
33///
34/// # Performance
35///
36/// Cloning is `O(query length)`.
37#[derive(Clone, Debug, Eq, PartialEq)]
38pub struct PreparedQuery {
39    /// Source language.
40    language: QueryLanguage,
41    /// Original query text.
42    text: String,
43    /// Physical plan.
44    plan: QueryPlan,
45}
46
47impl PreparedQuery {
48    /// Parses and prepares one query string against current catalog metadata.
49    ///
50    /// # Errors
51    ///
52    /// Returns [`DbError::EmptyQuery`] or [`DbError::UnsupportedQuery`] when the
53    /// query is outside the supported profile.
54    ///
55    /// # Performance
56    ///
57    /// This function is `O(query length + catalog lookup cost)`.
58    pub(crate) fn prepare(
59        language: QueryLanguage,
60        query: &str,
61        state: &impl StateView,
62    ) -> Result<Self, DbError> {
63        let trimmed = query.trim();
64        if trimmed.is_empty() {
65            return Err(DbError::EmptyQuery);
66        }
67        let logical = match language {
68            QueryLanguage::Oxql => parse_oxql(trimmed)?,
69            QueryLanguage::Cypher => parse_cypher(trimmed)?,
70        };
71        let plan = bind_and_lower(logical, state)?;
72        Ok(Self {
73            language,
74            text: trimmed.to_owned(),
75            plan,
76        })
77    }
78
79    /// Returns the query language.
80    ///
81    /// # Performance
82    ///
83    /// This method is `O(1)`.
84    #[must_use]
85    pub const fn language(&self) -> QueryLanguage {
86        self.language
87    }
88
89    /// Returns the source query text.
90    ///
91    /// # Performance
92    ///
93    /// This method is `O(1)`.
94    #[must_use]
95    pub fn text(&self) -> &str {
96        &self.text
97    }
98
99    /// Returns a stable physical explanation.
100    ///
101    /// # Performance
102    ///
103    /// This method is `O(plan size)`.
104    #[must_use]
105    pub fn explain(&self) -> String {
106        self.plan.explain()
107    }
108
109    /// Executes this prepared plan.
110    ///
111    /// # Errors
112    ///
113    /// Returns [`DbError`] when a referenced projection cannot be materialized.
114    ///
115    /// # Performance
116    ///
117    /// This method is `O(scanned rows × predicate size + plan output +
118    /// projection build cost when used)`.
119    pub(crate) fn execute(&self, state: &impl StateView) -> Result<QueryResult, DbError> {
120        self.plan.execute(state)
121    }
122}
123
124/// Query result materialized for JSON, CLI, and embedded consumers.
125///
126/// # Performance
127///
128/// Iterating rows is `O(row count)`.
129#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
130pub struct QueryResult {
131    /// Materialized rows.
132    rows: Vec<QueryRow>,
133}
134
135impl QueryResult {
136    /// Creates a result from rows.
137    ///
138    /// # Performance
139    ///
140    /// This function is `O(1)` excluding row ownership transfer.
141    #[must_use]
142    pub(crate) const fn new(rows: Vec<QueryRow>) -> Self {
143        Self { rows }
144    }
145
146    /// Returns result rows.
147    ///
148    /// # Performance
149    ///
150    /// This method is `O(1)`.
151    #[must_use]
152    pub fn rows(&self) -> &[QueryRow] {
153        &self.rows
154    }
155}
156
157/// One query result row.
158///
159/// # Performance
160///
161/// Cloning is `O(value count + string/property bytes)`.
162#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
163pub struct QueryRow {
164    /// Values carried by the row.
165    pub values: Vec<QueryValue>,
166}
167
168impl QueryRow {
169    /// Creates a single-value row.
170    ///
171    /// # Performance
172    ///
173    /// This function is `O(1)` excluding value ownership transfer.
174    #[must_use]
175    pub(crate) fn single(value: QueryValue) -> Self {
176        Self {
177            values: vec![value],
178        }
179    }
180}
181
182/// Query value variants used by `OxQL` and the pinned Cypher profile.
183///
184/// # Performance
185///
186/// Cloning is `O(value bytes)`.
187#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
188pub enum QueryValue {
189    /// Canonical element id.
190    Element(ElementId),
191    /// Canonical relation id.
192    Relation(RelationId),
193    /// Canonical incidence record.
194    Incidence(IncidenceRecord),
195    /// Property subject.
196    Subject(PropertySubject),
197    /// Typed property value.
198    Property(PropertyValue),
199    /// Human-readable text value.
200    Text(String),
201    /// Projection id.
202    Projection(ProjectionId),
203}
204
205/// Private physical query plan.
206#[derive(Clone, Debug, Eq, PartialEq)]
207enum QueryPlan {
208    /// Scan all elements.
209    ElementScan,
210    /// Scan all relations.
211    RelationScan,
212    /// Scan all incidences.
213    IncidenceScan,
214    /// Scan elements with a label.
215    ElementLabelScan {
216        /// Label name for explanations.
217        label: String,
218        /// Label ID.
219        label_id: crate::LabelId,
220    },
221    /// Scan elements by property equality.
222    ElementPropertyEqual {
223        /// Property key name for explanations.
224        key: String,
225        /// Property key ID.
226        key_id: PropertyKeyId,
227        /// Required property value.
228        value: PropertyValue,
229    },
230    /// Filter elements by a bound compound property predicate.
231    ElementFilter {
232        /// Bound predicate tree.
233        predicate: BoundPredicate,
234    },
235    /// Scan relations by relation type.
236    RelationTypeScan {
237        /// Relation type name for explanations.
238        relation_type: String,
239        /// Relation type ID.
240        relation_type_id: crate::RelationTypeId,
241    },
242    /// Walk a graph projection.
243    GraphWalk {
244        /// Projection ID.
245        projection: ProjectionId,
246        /// Canonical starting element.
247        element: ElementId,
248        /// Traversal options.
249        options: TraversalOptions,
250    },
251    /// Cypher directed edge triple scan.
252    CypherDirectedTriples {
253        /// Graph projection ID.
254        projection: ProjectionId,
255    },
256    /// Catalog metadata scan.
257    CatalogScan,
258}
259
260/// Bound element predicate produced by lowering a [`LogicalPredicate`].
261#[derive(Clone, Debug, Eq, PartialEq)]
262enum BoundPredicate {
263    /// `key <op> value` against a resolved key and typed value.
264    Compare {
265        /// Property key name for explanations.
266        key: String,
267        /// Resolved property key id.
268        key_id: PropertyKeyId,
269        /// Comparison operator.
270        op: CompareOp,
271        /// Typed comparison value.
272        value: PropertyValue,
273    },
274    /// Conjunction of two predicates.
275    And(Box<Self>, Box<Self>),
276    /// Disjunction of two predicates.
277    Or(Box<Self>, Box<Self>),
278}
279
280impl BoundPredicate {
281    /// Formats this predicate for plan explanations.
282    fn explain(&self) -> String {
283        match self {
284            Self::Compare { key, op, value, .. } => format!("{key} {} {value}", op.spelling()),
285            Self::And(left, right) => format!("({} AND {})", left.explain(), right.explain()),
286            Self::Or(left, right) => format!("({} OR {})", left.explain(), right.explain()),
287        }
288    }
289
290    /// Returns whether `element` satisfies this predicate in `state`.
291    ///
292    /// A missing property never satisfies a comparison.
293    fn evaluate(&self, state: &impl StateView, element: ElementId) -> bool {
294        match self {
295            Self::Compare {
296                key_id, op, value, ..
297            } => state
298                .property(PropertySubject::Element(element), *key_id)
299                .is_some_and(|actual| op.matches(actual.as_ref().cmp(value))),
300            Self::And(left, right) => {
301                left.evaluate(state, element) && right.evaluate(state, element)
302            }
303            Self::Or(left, right) => {
304                left.evaluate(state, element) || right.evaluate(state, element)
305            }
306        }
307    }
308}
309
310impl QueryPlan {
311    /// Explains this physical plan.
312    fn explain(&self) -> String {
313        match self {
314            Self::ElementScan => "oxql scan elements".to_owned(),
315            Self::RelationScan => "oxql scan relations".to_owned(),
316            Self::IncidenceScan => "oxql scan incidences".to_owned(),
317            Self::ElementLabelScan { label, .. } => {
318                format!("oxql label index lookup elements label={label}")
319            }
320            Self::ElementPropertyEqual { key, value, .. } => {
321                format!("oxql property equality lookup elements {key}={value}")
322            }
323            Self::ElementFilter { predicate } => {
324                format!("oxql element filter {}", predicate.explain())
325            }
326            Self::RelationTypeScan { relation_type, .. } => {
327                format!("oxql relation-type index lookup type={relation_type}")
328            }
329            Self::GraphWalk {
330                projection,
331                element,
332                options,
333            } => format!(
334                "oxql graph projection {projection} walk from {element} depth {} direction {:?} limit {}",
335                options.max_depth, options.direction, options.limit,
336            ),
337            Self::CypherDirectedTriples { projection } => {
338                format!("cypher directed triple scan projection={projection}")
339            }
340            Self::CatalogScan => "catalog metadata scan".to_owned(),
341        }
342    }
343
344    /// Executes this physical plan.
345    fn execute(&self, state: &impl StateView) -> Result<QueryResult, DbError> {
346        match self {
347            Self::ElementScan => Ok(scan_elements(state)),
348            Self::RelationScan => Ok(scan_relations(state)),
349            Self::IncidenceScan => Ok(scan_incidences(state)),
350            Self::ElementLabelScan { label_id, .. } => {
351                Ok(scan_elements_with_label(state, *label_id))
352            }
353            Self::ElementPropertyEqual { key_id, value, .. } => {
354                scan_elements_with_property(state, *key_id, value)
355            }
356            Self::ElementFilter { predicate } => Ok(filter_elements(state, predicate)),
357            Self::RelationTypeScan {
358                relation_type_id, ..
359            } => Ok(scan_relations_with_type(state, *relation_type_id)),
360            Self::GraphWalk {
361                projection,
362                element,
363                options,
364            } => execute_graph_walk(state, *projection, *element, *options),
365            Self::CypherDirectedTriples { projection } => {
366                execute_cypher_triples(state, *projection)
367            }
368            Self::CatalogScan => Ok(scan_catalog(state)),
369        }
370    }
371}
372
373/// Resolved logical operation emitted by parsing, before catalog binding.
374///
375/// The parsers ([`parse_oxql`], [`parse_cypher`]) are purely token/string
376/// driven and never touch the catalog; they produce one of these ops carrying
377/// raw names and literals. [`bind_and_lower`] then resolves names to catalog
378/// IDs, validates value families/types, and produces the physical
379/// [`QueryPlan`]. Cypher node and label scans lower onto the shared element ops
380/// rather than duplicating them.
381#[derive(Clone, Debug, Eq, PartialEq)]
382enum LogicalOp {
383    /// Scan all elements.
384    ElementScan,
385    /// Scan all relations.
386    RelationScan,
387    /// Scan all incidences.
388    IncidenceScan,
389    /// Scan catalog metadata.
390    CatalogScan,
391    /// Scan elements carrying a named label.
392    ElementLabelScan {
393        /// Unresolved label name.
394        label: String,
395    },
396    /// Scan relations of a named relation type.
397    RelationTypeScan {
398        /// Unresolved relation type name.
399        relation_type: String,
400    },
401    /// Scan elements whose named property equals a literal token.
402    ElementPropertyEqual {
403        /// Unresolved property key name.
404        key: String,
405        /// Unparsed property value token.
406        value: String,
407    },
408    /// Walk a named graph projection from a raw element-ID token.
409    GraphWalk {
410        /// Unresolved projection name.
411        projection: String,
412        /// Unparsed element-ID token.
413        element: String,
414        /// Parsed traversal options.
415        options: TraversalOptions,
416    },
417    /// Scan the first graph projection as directed source/relation/target triples.
418    CypherDirectedTriples,
419    /// Filter elements by a compound property predicate.
420    ElementWhere {
421        /// Unbound predicate tree carrying raw key names and value tokens.
422        predicate: LogicalPredicate,
423    },
424}
425
426/// Comparison operator in an element property predicate.
427#[derive(Clone, Copy, Debug, Eq, PartialEq)]
428enum CompareOp {
429    /// Equality (`=`).
430    Eq,
431    /// Strictly less than (`<`).
432    Lt,
433    /// Less than or equal (`<=`).
434    Le,
435    /// Strictly greater than (`>`).
436    Gt,
437    /// Greater than or equal (`>=`).
438    Ge,
439}
440
441impl CompareOp {
442    /// Returns whether `ordering` of the stored value against the literal
443    /// satisfies this operator.
444    const fn matches(self, ordering: core::cmp::Ordering) -> bool {
445        use core::cmp::Ordering::{Equal, Greater, Less};
446        match self {
447            Self::Eq => matches!(ordering, Equal),
448            Self::Lt => matches!(ordering, Less),
449            Self::Le => matches!(ordering, Less | Equal),
450            Self::Gt => matches!(ordering, Greater),
451            Self::Ge => matches!(ordering, Greater | Equal),
452        }
453    }
454
455    /// Returns the source spelling of this operator.
456    const fn spelling(self) -> &'static str {
457        match self {
458            Self::Eq => "=",
459            Self::Lt => "<",
460            Self::Le => "<=",
461            Self::Gt => ">",
462            Self::Ge => ">=",
463        }
464    }
465}
466
467/// Raw element predicate parsed from a `WHERE` clause, before catalog binding.
468///
469/// Precedence is `OR` (loosest) over `AND` over comparisons; parentheses
470/// override it. Leaves carry the raw key name and value token; binding resolves
471/// them to catalog ids and typed values.
472#[derive(Clone, Debug, Eq, PartialEq)]
473enum LogicalPredicate {
474    /// `key <op> value` comparison against a raw value token.
475    Compare {
476        /// Unresolved property key name.
477        key: String,
478        /// Comparison operator.
479        op: CompareOp,
480        /// Unparsed property value token.
481        value: String,
482    },
483    /// Conjunction of two predicates.
484    And(Box<Self>, Box<Self>),
485    /// Disjunction of two predicates.
486    Or(Box<Self>, Box<Self>),
487}
488
489/// Parses native `OxQL` into a logical operation without catalog access.
490fn parse_oxql(query: &str) -> Result<LogicalOp, DbError> {
491    let tokens = query.split_whitespace().collect::<Vec<_>>();
492    let upper = tokens
493        .iter()
494        .map(|token| token.to_ascii_uppercase())
495        .collect::<Vec<_>>();
496    if upper.len() >= 3
497        && upper[0].as_str() == "MATCH"
498        && upper[1].as_str() == "ELEMENTS"
499        && upper[2].as_str() == "WHERE"
500    {
501        return parse_element_where(&tokens[3..], &upper[3..]);
502    }
503    match upper.as_slice() {
504        [command] if command == "CATALOG" => Ok(LogicalOp::CatalogScan),
505        [verb, family] if verb == "MATCH" && family == "ELEMENTS" => Ok(LogicalOp::ElementScan),
506        [verb, family] if verb == "MATCH" && family == "RELATIONS" => Ok(LogicalOp::RelationScan),
507        [verb, family] if verb == "MATCH" && family == "INCIDENCES" => Ok(LogicalOp::IncidenceScan),
508        [verb, family, has, object, _name]
509            if verb == "MATCH" && family == "ELEMENTS" && has == "HAS" && object == "LABEL" =>
510        {
511            Ok(LogicalOp::ElementLabelScan {
512                label: tokens[4].to_owned(),
513            })
514        }
515        [verb, family, r#type, _name]
516            if verb == "MATCH" && family == "RELATIONS" && r#type == "TYPE" =>
517        {
518            Ok(LogicalOp::RelationTypeScan {
519                relation_type: tokens[3].to_owned(),
520            })
521        }
522        [graph, _projection, neighbors, _element]
523            if graph == "GRAPH" && neighbors == "NEIGHBORS" =>
524        {
525            Ok(LogicalOp::GraphWalk {
526                projection: tokens[1].to_owned(),
527                element: tokens[3].to_owned(),
528                options: TraversalOptions::default(),
529            })
530        }
531        [
532            graph,
533            _projection,
534            walk,
535            from,
536            _element,
537            depth,
538            _max_depth,
539            ..,
540        ] if graph == "GRAPH" && walk == "WALK" && from == "FROM" && depth == "DEPTH" => {
541            parse_graph_walk(&tokens, &upper)
542        }
543        _tokens => Err(DbError::unsupported("unsupported OxQL profile query")),
544    }
545}
546
547/// Parses the pinned Cypher language profile into a logical operation.
548fn parse_cypher(query: &str) -> Result<LogicalOp, DbError> {
549    if query == "MATCH (n) RETURN n" {
550        return Ok(LogicalOp::ElementScan);
551    }
552    if query == "MATCH (n)-[r]->(m) RETURN n,r,m" {
553        return Ok(LogicalOp::CypherDirectedTriples);
554    }
555    if let Some(label) = query
556        .strip_prefix("MATCH (n:")
557        .and_then(|rest| rest.strip_suffix(") RETURN n"))
558    {
559        return Ok(LogicalOp::ElementLabelScan {
560            label: label.to_owned(),
561        });
562    }
563    Err(DbError::unsupported("unsupported Cypher profile query"))
564}
565
566/// Parses the optional `DIRECTION`/`LIMIT` clauses of an `OxQL` graph walk.
567fn parse_graph_walk(tokens: &[&str], upper: &[String]) -> Result<LogicalOp, DbError> {
568    let max_depth = tokens[6]
569        .parse::<usize>()
570        .map_err(|_error| DbError::unsupported("walk depth must be an integer"))?;
571    let mut options = TraversalOptions {
572        max_depth,
573        ..TraversalOptions::default()
574    };
575    let mut saw_direction = false;
576    let mut saw_limit = false;
577    let mut index = 7;
578    while index < tokens.len() {
579        let Some(value) = tokens.get(index + 1) else {
580            return Err(DbError::unsupported("walk option requires a value"));
581        };
582        match upper[index].as_str() {
583            "DIRECTION" if !saw_direction => {
584                options.direction = parse_walk_direction(&upper[index + 1])?;
585                saw_direction = true;
586            }
587            "DIRECTION" => return Err(DbError::unsupported("walk direction specified twice")),
588            "LIMIT" if !saw_limit => {
589                options.limit = value
590                    .parse::<usize>()
591                    .map_err(|_error| DbError::unsupported("walk limit must be an integer"))?;
592                saw_limit = true;
593            }
594            "LIMIT" => return Err(DbError::unsupported("walk limit specified twice")),
595            _option => return Err(DbError::unsupported("unsupported walk option")),
596        }
597        index += 2;
598    }
599    Ok(LogicalOp::GraphWalk {
600        projection: tokens[1].to_owned(),
601        element: tokens[4].to_owned(),
602        options,
603    })
604}
605
606/// Parses one `OxQL` graph walk direction.
607fn parse_walk_direction(direction: &str) -> Result<TraversalDirection, DbError> {
608    match direction {
609        "OUTGOING" => Ok(TraversalDirection::Outgoing),
610        "INCOMING" => Ok(TraversalDirection::Incoming),
611        "BOTH" => Ok(TraversalDirection::Both),
612        _direction => Err(DbError::unsupported(
613            "walk direction must be outgoing, incoming, or both",
614        )),
615    }
616}
617
618/// Parses an `OxQL` element `WHERE` predicate into a logical operation.
619///
620/// Collapses a lone `key = value` to the indexed
621/// [`LogicalOp::ElementPropertyEqual`] fast path; any compound or ordered
622/// predicate becomes [`LogicalOp::ElementWhere`].
623fn parse_element_where(tokens: &[&str], upper: &[String]) -> Result<LogicalOp, DbError> {
624    let mut cursor = 0;
625    let predicate = parse_predicate_or(tokens, upper, &mut cursor)?;
626    if cursor != tokens.len() {
627        return Err(DbError::unsupported(
628            "trailing tokens after WHERE predicate",
629        ));
630    }
631    match predicate {
632        LogicalPredicate::Compare {
633            key,
634            op: CompareOp::Eq,
635            value,
636        } => Ok(LogicalOp::ElementPropertyEqual { key, value }),
637        predicate => Ok(LogicalOp::ElementWhere { predicate }),
638    }
639}
640
641/// Parses a disjunction: `and ( OR and )*`.
642fn parse_predicate_or(
643    tokens: &[&str],
644    upper: &[String],
645    cursor: &mut usize,
646) -> Result<LogicalPredicate, DbError> {
647    let mut left = parse_predicate_and(tokens, upper, cursor)?;
648    while upper.get(*cursor).map(String::as_str) == Some("OR") {
649        *cursor += 1;
650        let right = parse_predicate_and(tokens, upper, cursor)?;
651        left = LogicalPredicate::Or(Box::new(left), Box::new(right));
652    }
653    Ok(left)
654}
655
656/// Parses a conjunction: `factor ( AND factor )*`.
657fn parse_predicate_and(
658    tokens: &[&str],
659    upper: &[String],
660    cursor: &mut usize,
661) -> Result<LogicalPredicate, DbError> {
662    let mut left = parse_predicate_factor(tokens, upper, cursor)?;
663    while upper.get(*cursor).map(String::as_str) == Some("AND") {
664        *cursor += 1;
665        let right = parse_predicate_factor(tokens, upper, cursor)?;
666        left = LogicalPredicate::And(Box::new(left), Box::new(right));
667    }
668    Ok(left)
669}
670
671/// Parses a parenthesized group or a single comparison.
672fn parse_predicate_factor(
673    tokens: &[&str],
674    upper: &[String],
675    cursor: &mut usize,
676) -> Result<LogicalPredicate, DbError> {
677    if tokens.get(*cursor) == Some(&"(") {
678        *cursor += 1;
679        let inner = parse_predicate_or(tokens, upper, cursor)?;
680        if tokens.get(*cursor) != Some(&")") {
681            return Err(DbError::unsupported(
682                "unbalanced parentheses in WHERE predicate",
683            ));
684        }
685        *cursor += 1;
686        return Ok(inner);
687    }
688    parse_comparison(tokens, cursor)
689}
690
691/// Parses one `key <op> value` comparison.
692fn parse_comparison(tokens: &[&str], cursor: &mut usize) -> Result<LogicalPredicate, DbError> {
693    let key = tokens
694        .get(*cursor)
695        .ok_or_else(|| DbError::unsupported("expected property key in WHERE predicate"))?;
696    let operator = tokens
697        .get(*cursor + 1)
698        .ok_or_else(|| DbError::unsupported("expected comparison operator in WHERE predicate"))?;
699    let value = tokens
700        .get(*cursor + 2)
701        .ok_or_else(|| DbError::unsupported("expected value in WHERE predicate"))?;
702    let op = parse_compare_op(operator)?;
703    *cursor += 3;
704    Ok(LogicalPredicate::Compare {
705        key: (*key).to_owned(),
706        op,
707        value: (*value).to_owned(),
708    })
709}
710
711/// Parses one comparison operator token.
712fn parse_compare_op(token: &str) -> Result<CompareOp, DbError> {
713    match token {
714        "=" => Ok(CompareOp::Eq),
715        "<" => Ok(CompareOp::Lt),
716        "<=" => Ok(CompareOp::Le),
717        ">" => Ok(CompareOp::Gt),
718        ">=" => Ok(CompareOp::Ge),
719        _operator => Err(DbError::unsupported(
720            "comparison operator must be =, <, <=, >, or >=",
721        )),
722    }
723}
724
725/// Binds a raw element predicate against the catalog.
726fn bind_predicate(
727    predicate: LogicalPredicate,
728    state: &impl StateView,
729) -> Result<BoundPredicate, DbError> {
730    match predicate {
731        LogicalPredicate::Compare { key, op, value } => {
732            let key_id = state
733                .catalog()
734                .property_key_id(&key)
735                .ok_or_else(|| DbError::unsupported(format!("unknown property key {key}")))?;
736            let value = parse_value_token(&value).map_err(DbError::unsupported)?;
737            state.validate_lookup_value_for_family(key_id, PropertyFamily::Element, &value)?;
738            Ok(BoundPredicate::Compare {
739                key,
740                key_id,
741                op,
742                value,
743            })
744        }
745        LogicalPredicate::And(left, right) => Ok(BoundPredicate::And(
746            Box::new(bind_predicate(*left, state)?),
747            Box::new(bind_predicate(*right, state)?),
748        )),
749        LogicalPredicate::Or(left, right) => Ok(BoundPredicate::Or(
750            Box::new(bind_predicate(*left, state)?),
751            Box::new(bind_predicate(*right, state)?),
752        )),
753    }
754}
755
756/// Binds a logical operation against the catalog and lowers it to a plan.
757///
758/// This is the single seam where names resolve to catalog IDs and where
759/// property literals are parsed and family/type-validated. Parsing above never
760/// touches the catalog; execution below never resolves names.
761fn bind_and_lower(op: LogicalOp, state: &impl StateView) -> Result<QueryPlan, DbError> {
762    match op {
763        LogicalOp::ElementScan => Ok(QueryPlan::ElementScan),
764        LogicalOp::RelationScan => Ok(QueryPlan::RelationScan),
765        LogicalOp::IncidenceScan => Ok(QueryPlan::IncidenceScan),
766        LogicalOp::CatalogScan => Ok(QueryPlan::CatalogScan),
767        LogicalOp::ElementLabelScan { label } => {
768            let label_id = state
769                .catalog()
770                .label_id(&label)
771                .ok_or_else(|| DbError::unsupported(format!("unknown label {label}")))?;
772            Ok(QueryPlan::ElementLabelScan { label, label_id })
773        }
774        LogicalOp::RelationTypeScan { relation_type } => {
775            let relation_type_id = state
776                .catalog()
777                .relation_type_id(&relation_type)
778                .ok_or_else(|| {
779                    DbError::unsupported(format!("unknown relation type {relation_type}"))
780                })?;
781            Ok(QueryPlan::RelationTypeScan {
782                relation_type,
783                relation_type_id,
784            })
785        }
786        LogicalOp::ElementPropertyEqual { key, value } => {
787            let key_id = state
788                .catalog()
789                .property_key_id(&key)
790                .ok_or_else(|| DbError::unsupported(format!("unknown property key {key}")))?;
791            let value = parse_value_token(&value).map_err(DbError::unsupported)?;
792            state.validate_lookup_value_for_family(key_id, PropertyFamily::Element, &value)?;
793            Ok(QueryPlan::ElementPropertyEqual { key, key_id, value })
794        }
795        LogicalOp::GraphWalk {
796            projection,
797            element,
798            options,
799        } => lower_graph_walk(&projection, &element, options, state),
800        LogicalOp::CypherDirectedTriples => Ok(QueryPlan::CypherDirectedTriples {
801            projection: first_graph_projection(state)?,
802        }),
803        LogicalOp::ElementWhere { predicate } => Ok(QueryPlan::ElementFilter {
804            predicate: bind_predicate(predicate, state)?,
805        }),
806    }
807}
808
809/// Resolves and validates an `OxQL` graph traversal into a physical walk.
810fn lower_graph_walk(
811    projection: &str,
812    element: &str,
813    options: TraversalOptions,
814    state: &impl StateView,
815) -> Result<QueryPlan, DbError> {
816    let projection = state
817        .catalog()
818        .projection_id(projection)
819        .ok_or_else(|| DbError::unsupported(format!("unknown projection {projection}")))?;
820    let entry = state
821        .catalog()
822        .projection(projection)
823        .ok_or(DbError::UnknownProjection { id: projection })?;
824    if matches!(&entry.definition, ProjectionDefinition::Hypergraph(_)) {
825        return Err(DbError::invalid_projection("projection is not a graph"));
826    }
827    let raw = element
828        .parse::<u64>()
829        .map_err(|_error| DbError::unsupported("element id must be an integer"))?;
830    Ok(QueryPlan::GraphWalk {
831        projection,
832        element: ElementId::new(raw),
833        options,
834    })
835}
836
837/// Scans all elements.
838fn scan_elements(state: &impl StateView) -> QueryResult {
839    QueryResult::new(
840        state
841            .elements()
842            .map(|record| QueryRow::single(QueryValue::Element(record.id)))
843            .collect(),
844    )
845}
846
847/// Scans all relations.
848fn scan_relations(state: &impl StateView) -> QueryResult {
849    QueryResult::new(
850        state
851            .relations()
852            .map(|record| QueryRow::single(QueryValue::Relation(record.id)))
853            .collect(),
854    )
855}
856
857/// Scans all incidences.
858fn scan_incidences(state: &impl StateView) -> QueryResult {
859    QueryResult::new(
860        state
861            .incidences()
862            .map(|record| QueryRow::single(QueryValue::Incidence(*record)))
863            .collect(),
864    )
865}
866
867/// Scans elements with one label.
868fn scan_elements_with_label(state: &impl StateView, label_id: crate::LabelId) -> QueryResult {
869    QueryResult::new(
870        state
871            .elements_with_label(label_id)
872            .into_iter()
873            .map(|id| QueryRow::single(QueryValue::Element(id)))
874            .collect(),
875    )
876}
877
878/// Scans elements with a property value.
879fn scan_elements_with_property(
880    state: &impl StateView,
881    key: PropertyKeyId,
882    value: &PropertyValue,
883) -> Result<QueryResult, DbError> {
884    Ok(QueryResult::new(
885        state
886            .typed_property_equal_for_family(key, PropertyFamily::Element, value)?
887            .into_iter()
888            .filter_map(|subject| match subject {
889                PropertySubject::Element(id) => Some(QueryRow::single(QueryValue::Element(id))),
890                PropertySubject::Relation(_) | PropertySubject::Incidence(_) => None,
891            })
892            .collect(),
893    ))
894}
895
896/// Filters elements by a bound compound predicate.
897///
898/// # Performance
899///
900/// `O(elements × predicate nodes)`: one scan, evaluating the predicate per
901/// element. A lone equality is lowered to the indexed property fast path
902/// instead and never reaches this scan.
903fn filter_elements(state: &impl StateView, predicate: &BoundPredicate) -> QueryResult {
904    QueryResult::new(
905        state
906            .elements()
907            .filter(|record| predicate.evaluate(state, record.id))
908            .map(|record| QueryRow::single(QueryValue::Element(record.id)))
909            .collect(),
910    )
911}
912
913/// Scans relations with one relation type.
914fn scan_relations_with_type(
915    state: &impl StateView,
916    relation_type: crate::RelationTypeId,
917) -> QueryResult {
918    QueryResult::new(
919        state
920            .relations_with_type(relation_type)
921            .into_iter()
922            .map(|id| QueryRow::single(QueryValue::Relation(id)))
923            .collect(),
924    )
925}
926
927/// Scans catalog entries.
928fn scan_catalog(state: &impl StateView) -> QueryResult {
929    let rows = state
930        .catalog()
931        .projections()
932        .map(|entry| QueryRow {
933            values: vec![
934                QueryValue::Projection(entry.id),
935                QueryValue::Text(entry.definition.name().to_owned()),
936            ],
937        })
938        .collect();
939    QueryResult::new(rows)
940}
941
942/// Executes a graph walk.
943fn execute_graph_walk(
944    state: &impl StateView,
945    projection: ProjectionId,
946    element: ElementId,
947    options: TraversalOptions,
948) -> Result<QueryResult, DbError> {
949    let graph = graph_projection(state, projection)?;
950    let traversal = traversal::traverse_graph_projection(&graph, &[element], options)?;
951    let rows = traversal
952        .rows()
953        .iter()
954        .map(|row| QueryRow::single(QueryValue::Element(row.element)))
955        .collect();
956    Ok(QueryResult::new(rows))
957}
958
959/// Executes the Cypher directed triple profile.
960fn execute_cypher_triples(
961    state: &impl StateView,
962    projection: ProjectionId,
963) -> Result<QueryResult, DbError> {
964    let graph = graph_projection(state, projection)?;
965    let mut rows = Vec::with_capacity(graph.relation_count());
966    for index in 0..graph.relation_count() {
967        let edge = projection_relation(index)?;
968        rows.push(QueryRow {
969            values: vec![
970                QueryValue::Element(graph.canonical_element_id(graph.source(edge))),
971                QueryValue::Relation(graph.canonical_relation_id(edge)),
972                QueryValue::Element(graph.canonical_element_id(graph.target(edge))),
973            ],
974        });
975    }
976    Ok(QueryResult::new(rows))
977}
978
979/// Materializes a graph projection by ID.
980fn graph_projection(
981    state: &impl StateView,
982    projection: ProjectionId,
983) -> Result<GraphProjection, DbError> {
984    let entry = state
985        .catalog()
986        .projection(projection)
987        .ok_or(DbError::UnknownProjection { id: projection })?;
988    match &entry.definition {
989        ProjectionDefinition::Graph(definition) => {
990            GraphProjection::from_state(state, definition.clone())
991        }
992        ProjectionDefinition::Hypergraph(_definition) => {
993            Err(DbError::invalid_projection("projection is not a graph"))
994        }
995    }
996}
997
998/// Finds the first graph projection in catalog order.
999fn first_graph_projection(state: &impl StateView) -> Result<ProjectionId, DbError> {
1000    state
1001        .catalog()
1002        .projections()
1003        .find_map(|entry| match &entry.definition {
1004            ProjectionDefinition::Graph(_definition) => Some(entry.id),
1005            ProjectionDefinition::Hypergraph(_definition) => None,
1006        })
1007        .ok_or_else(|| DbError::unsupported("Cypher profile requires a graph projection"))
1008}
1009
1010/// Builds a projection relation ID for query execution.
1011fn projection_relation(index: usize) -> Result<crate::ProjectionRelationId, DbError> {
1012    u32::try_from(index)
1013        .map(crate::ProjectionRelationId::new)
1014        .map_err(|_error| DbError::IdOverflow)
1015}