use std::collections::HashMap;
use std::sync::Arc;
use crate::core::NodeId;
use crate::core::error::{Error, QueryError, Result};
use crate::core::temporal::{TimeRange, Timestamp, time};
use crate::index::vector::DistanceMetric;
use super::ast::{
ComparisonOp, DepthSpec, EmbeddingRef, Expression, NodePattern, NodeRef, OrderClause, Pattern,
PatternElement, PredicateExpr, PropertyValue, QueryAst, RelationshipDirection,
RelationshipPattern, ReturnClause, SourceClause, TemporalClause, TimestampLiteral,
};
use super::builder::Query;
use super::ir::{Predicate, PredicateValue, QueryOp, SortKey, TraversalDepth};
use super::plan::{QueryHints, TemporalContext};
pub struct AstConverter {
parameters: HashMap<String, ParameterValue>,
}
#[derive(Debug, Clone)]
pub enum ParameterValue {
NodeId(NodeId),
Embedding(Arc<[f32]>),
Value(PredicateValue),
}
impl AstConverter {
pub fn new() -> Self {
AstConverter {
parameters: HashMap::new(),
}
}
pub fn with_parameters(parameters: HashMap<String, ParameterValue>) -> Self {
AstConverter { parameters }
}
pub fn bind(&mut self, name: impl Into<String>, value: ParameterValue) -> &mut Self {
self.parameters.insert(name.into(), value);
self
}
pub fn convert(&self, ast: &QueryAst) -> Result<Query> {
const INITIAL_OPS_CAPACITY: usize = 8;
let mut ops = Vec::with_capacity(INITIAL_OPS_CAPACITY);
let hints = QueryHints::default();
let temporal_context = self.convert_temporal(&ast.temporal)?;
self.convert_source(&ast.source, &mut ops)?;
if let Some(ref where_clause) = ast.where_clause {
self.convert_where_clause(where_clause, &mut ops)?;
}
if let Some(ref rank) = ast.rank {
self.convert_rank_clause(rank, &mut ops)?;
}
if let Some(ref return_clause) = ast.return_clause {
self.convert_return_clause_ops(return_clause, &mut ops)?;
}
if let Some(ref order_clause) = ast.order {
self.convert_order_clause(order_clause, &mut ops)?;
}
self.convert_pagination(ast.skip, ast.limit, &mut ops);
Ok(Query {
ops,
temporal_context,
hints,
})
}
fn convert_where_clause(
&self,
where_clause: &super::ast::WhereClause,
ops: &mut Vec<QueryOp>,
) -> Result<()> {
let predicate = self.convert_predicate(&where_clause.predicate)?;
ops.push(QueryOp::Filter(predicate));
Ok(())
}
fn convert_rank_clause(
&self,
rank: &super::ast::RankClause,
ops: &mut Vec<QueryOp>,
) -> Result<()> {
let embedding = self.resolve_embedding(&rank.embedding)?;
ops.push(QueryOp::RankBySimilarity {
embedding,
top_k: rank.top_k,
property_key: None,
});
Ok(())
}
fn convert_return_clause_ops(
&self,
return_clause: &ReturnClause,
ops: &mut Vec<QueryOp>,
) -> Result<()> {
let projection = self.convert_return(return_clause)?;
if !projection.is_empty() {
ops.push(QueryOp::Project(projection));
}
if return_clause.distinct {
ops.push(QueryOp::Distinct);
}
Ok(())
}
fn convert_pagination(
&self,
skip: Option<usize>,
limit: Option<usize>,
ops: &mut Vec<QueryOp>,
) {
if let Some(skip) = skip {
ops.push(QueryOp::Skip(skip));
}
if let Some(limit) = limit {
ops.push(QueryOp::Limit(limit));
}
}
fn convert_temporal(
&self,
temporal: &Option<TemporalClause>,
) -> Result<Option<TemporalContext>> {
match temporal {
None => Ok(None),
Some(TemporalClause::AsOf {
valid_time,
transaction_time,
}) => {
let vt = self.convert_timestamp(valid_time)?;
let tt = match transaction_time {
Some(t) => self.convert_timestamp(t)?,
None => time::now(),
};
Ok(Some(TemporalContext::as_of(vt, tt)))
}
Some(TemporalClause::Between { start, end }) => {
let start_ts = self.convert_timestamp(start)?;
let end_ts = self.convert_timestamp(end)?;
let range = TimeRange::new(start_ts, end_ts).map_err(|e| {
Error::Query(QueryError::InvalidParameter {
parameter: "time_range".to_string(),
reason: format!("Invalid time range: {}", e),
})
})?;
Ok(Some(TemporalContext::valid_time_between(range)))
}
}
}
fn convert_timestamp(&self, ts: &TimestampLiteral) -> Result<Timestamp> {
match ts {
TimestampLiteral::Integer(micros) => Ok(Timestamp::from(*micros)),
TimestampLiteral::String(s) => {
if let Ok(micros) = s.parse::<i64>() {
return Ok(Timestamp::from(micros));
}
Err(Error::Query(QueryError::InvalidParameter {
parameter: "timestamp".to_string(),
reason: format!(
"Invalid timestamp '{}'. Expected microseconds since epoch.",
s
),
}))
}
}
}
fn convert_source(&self, source: &SourceClause, ops: &mut Vec<QueryOp>) -> Result<()> {
match source {
SourceClause::Match(patterns) => {
self.convert_patterns(patterns, ops)?;
}
SourceClause::VectorSearch {
embedding,
metric,
limit,
} => {
let emb = self.resolve_embedding(embedding)?;
ops.push(QueryOp::VectorSearch {
embedding: emb,
k: *limit,
metric: metric.unwrap_or(DistanceMetric::Cosine),
property_key: None,
});
}
SourceClause::FindSimilar { node_ref, limit } => {
let node_id = self.resolve_node_ref(node_ref)?;
ops.push(QueryOp::SimilarTo {
source_node: node_id,
k: *limit,
property_key: None,
label_filter: None,
});
}
}
Ok(())
}
fn convert_patterns(&self, patterns: &[Pattern], ops: &mut Vec<QueryOp>) -> Result<()> {
for pattern in patterns {
self.convert_pattern(pattern, ops)?;
}
Ok(())
}
fn convert_pattern(&self, pattern: &Pattern, ops: &mut Vec<QueryOp>) -> Result<()> {
let mut is_first = true;
for element in &pattern.elements {
match element {
PatternElement::Node(node) => {
if is_first {
self.convert_node_pattern(node, ops)?;
is_first = false;
}
}
PatternElement::Relationship(rel) => {
self.convert_relationship_pattern(rel, ops)?;
}
}
}
Ok(())
}
fn convert_node_pattern(&self, node: &NodePattern, ops: &mut Vec<QueryOp>) -> Result<()> {
let mut optimized_start = false;
if let Some(ref props) = node.properties {
for (key, value) in props {
if key == "id" {
let node_id_res = match value {
PropertyValue::Int(id) => Some(NodeId::new(*id as u64)),
PropertyValue::Parameter(name) => {
self.parameters
.get(name)
.and_then(|param_val| match param_val {
ParameterValue::NodeId(id) => Some(Ok(*id)),
ParameterValue::Value(PredicateValue::Int(id)) => {
Some(NodeId::new(*id as u64))
}
_ => None, })
}
_ => None,
};
if let Some(res) = node_id_res {
ops.push(QueryOp::StartNode(res?));
optimized_start = true;
}
}
if optimized_start {
break;
}
}
}
if !optimized_start {
ops.push(QueryOp::ScanNodes {
label: node.label.clone(),
});
} else if let Some(ref label) = node.label {
ops.push(QueryOp::FilterLabel(label.clone()));
}
if let Some(ref props) = node.properties {
for (key, value) in props {
if key != "id" {
let pred_value = self.convert_property_value(value)?;
ops.push(QueryOp::Filter(Predicate::Eq {
key: key.clone(),
value: pred_value,
}));
}
}
}
Ok(())
}
fn convert_relationship_pattern(
&self,
rel: &RelationshipPattern,
ops: &mut Vec<QueryOp>,
) -> Result<()> {
let depth = self.convert_depth_spec(&rel.depth);
let label = rel.rel_type.clone();
match rel.direction {
RelationshipDirection::Outgoing => {
ops.push(QueryOp::TraverseOut { label, depth });
}
RelationshipDirection::Incoming => {
ops.push(QueryOp::TraverseIn { label, depth });
}
RelationshipDirection::Both => {
ops.push(QueryOp::TraverseBoth { label, depth });
}
}
Ok(())
}
fn convert_depth_spec(&self, depth: &Option<DepthSpec>) -> TraversalDepth {
match depth {
None => TraversalDepth::Exact(1),
Some(DepthSpec::Exact(n)) => TraversalDepth::Exact(*n),
Some(DepthSpec::Max(n)) => TraversalDepth::Max(*n),
Some(DepthSpec::Range { min, max }) => TraversalDepth::Range {
min: *min,
max: *max,
},
Some(DepthSpec::Variable) => TraversalDepth::Variable,
}
}
fn convert_predicate(&self, expr: &PredicateExpr) -> Result<Predicate> {
match expr {
PredicateExpr::Comparison { left, op, right } => {
self.convert_comparison(left, *op, right)
}
PredicateExpr::Exists(prop) => Ok(Predicate::Exists(prop.property.clone())),
PredicateExpr::IsNull(prop) => Ok(Predicate::NotExists(prop.property.clone())),
PredicateExpr::IsNotNull(prop) => Ok(Predicate::Exists(prop.property.clone())),
PredicateExpr::Contains { .. }
| PredicateExpr::StartsWith { .. }
| PredicateExpr::EndsWith { .. } => self.convert_string_predicate(expr),
PredicateExpr::In { property, values } => self.convert_in_predicate(property, values),
PredicateExpr::And(_, _) | PredicateExpr::Or(_, _) | PredicateExpr::Not(_) => {
self.convert_logic_predicate(expr)
}
PredicateExpr::Grouped(inner) => self.convert_predicate(inner),
}
}
fn convert_logic_predicate(&self, expr: &PredicateExpr) -> Result<Predicate> {
match expr {
PredicateExpr::And(left, right) => {
let l = self.convert_predicate(left)?;
let r = self.convert_predicate(right)?;
Ok(l.and(r))
}
PredicateExpr::Or(left, right) => {
let l = self.convert_predicate(left)?;
let r = self.convert_predicate(right)?;
Ok(l.or(r))
}
PredicateExpr::Not(inner) => {
let p = self.convert_predicate(inner)?;
Ok(!p)
}
_ => unreachable!("convert_logic_predicate called on non-logic expr"),
}
}
fn convert_string_predicate(&self, expr: &PredicateExpr) -> Result<Predicate> {
match expr {
PredicateExpr::Contains {
property,
substring,
} => Ok(Predicate::Contains {
key: property.property.clone(),
substring: substring.clone(),
}),
PredicateExpr::StartsWith { property, prefix } => Ok(Predicate::StartsWith {
key: property.property.clone(),
prefix: prefix.clone(),
}),
PredicateExpr::EndsWith { property, suffix } => Ok(Predicate::EndsWith {
key: property.property.clone(),
suffix: suffix.clone(),
}),
_ => unreachable!("convert_string_predicate called on non-string expr"),
}
}
fn convert_in_predicate(
&self,
property: &super::ast::PropertyAccess,
values: &[PropertyValue],
) -> Result<Predicate> {
let pred_values: Result<Vec<PredicateValue>> = values
.iter()
.map(|v| self.convert_property_value(v))
.collect();
Ok(Predicate::In {
key: property.property.clone(),
values: pred_values?,
})
}
fn convert_comparison(
&self,
left: &Expression,
op: ComparisonOp,
right: &Expression,
) -> Result<Predicate> {
if let Expression::Property(prop) = left {
let key = prop.property.clone();
let value = self.expression_to_predicate_value(right)?;
return Ok(match op {
ComparisonOp::Eq => Predicate::Eq { key, value },
ComparisonOp::Ne => Predicate::Ne { key, value },
ComparisonOp::Lt => Predicate::Lt { key, value },
ComparisonOp::Le => Predicate::Lte { key, value },
ComparisonOp::Gt => Predicate::Gt { key, value },
ComparisonOp::Ge => Predicate::Gte { key, value },
});
}
if let Expression::Property(prop) = right {
let key = prop.property.clone();
let value = self.expression_to_predicate_value(left)?;
return Ok(match op {
ComparisonOp::Eq => Predicate::Eq { key, value }, ComparisonOp::Ne => Predicate::Ne { key, value }, ComparisonOp::Lt => Predicate::Gt { key, value }, ComparisonOp::Le => Predicate::Gte { key, value }, ComparisonOp::Gt => Predicate::Lt { key, value }, ComparisonOp::Ge => Predicate::Lte { key, value }, });
}
Err(Error::Query(QueryError::SyntaxError {
message: "Comparison must involve a property access (e.g., n.age)".to_string(),
}))
}
fn expression_to_predicate_value(&self, expr: &Expression) -> Result<PredicateValue> {
match expr {
Expression::Literal(pv) => self.convert_property_value(pv),
Expression::Parameter(name) => self.resolve_value_param(name),
_ => Err(Error::Query(QueryError::SyntaxError {
message: "Expected literal or parameter in comparison".to_string(),
})),
}
}
fn convert_property_value(&self, value: &PropertyValue) -> Result<PredicateValue> {
match value {
PropertyValue::Null => Ok(PredicateValue::Null),
PropertyValue::Bool(b) => Ok(PredicateValue::Bool(*b)),
PropertyValue::Int(i) => Ok(PredicateValue::Int(*i)),
PropertyValue::Float(f) => Ok(PredicateValue::Float(*f)),
PropertyValue::String(s) => Ok(PredicateValue::String(s.clone())),
PropertyValue::Parameter(name) => self.resolve_value_param(name),
}
}
fn resolve_value_param(&self, name: &str) -> Result<PredicateValue> {
if let Some(ParameterValue::Value(v)) = self.parameters.get(name) {
Ok(v.clone())
} else {
Err(Error::Query(QueryError::InvalidParameter {
parameter: name.to_string(),
reason: "not found or has wrong type".to_string(),
}))
}
}
fn convert_return(&self, return_clause: &ReturnClause) -> Result<Vec<String>> {
let mut projections = Vec::with_capacity(return_clause.items.len());
for item in &return_clause.items {
match &item.expression {
Expression::Property(prop) => {
projections.push(prop.property.clone());
}
Expression::Identifier(_) => {
}
_ => {
}
}
}
Ok(projections)
}
fn convert_order_clause(
&self,
order_clause: &OrderClause,
ops: &mut Vec<QueryOp>,
) -> Result<()> {
for item in &order_clause.items {
let sort_key = match &item.expression {
Expression::Property(prop) => SortKey::Property(prop.property.clone()),
Expression::Identifier(name) => {
match name.as_str() {
"score" => SortKey::Score,
"timestamp" => SortKey::Timestamp,
_ => SortKey::Property(name.clone()),
}
}
_ => {
return Err(Error::Query(QueryError::SyntaxError {
message: "ORDER BY expression must be a property access or identifier"
.to_string(),
}));
}
};
ops.push(QueryOp::Sort {
key: sort_key,
descending: item.descending,
});
}
Ok(())
}
fn resolve_embedding(&self, emb_ref: &EmbeddingRef) -> Result<Arc<[f32]>> {
match emb_ref {
EmbeddingRef::Literal(arr) => Ok(arr.clone()),
EmbeddingRef::Parameter(name) => {
if let Some(ParameterValue::Embedding(emb)) = self.parameters.get(name) {
Ok(emb.clone())
} else {
Err(Error::Query(QueryError::InvalidParameter {
parameter: name.clone(),
reason: "embedding parameter not found".to_string(),
}))
}
}
}
}
fn resolve_node_ref(&self, node_ref: &NodeRef) -> Result<NodeId> {
match node_ref {
NodeRef::Id(id) => Ok(NodeId::new(*id)?),
NodeRef::Parameter(name) => {
if let Some(ParameterValue::NodeId(id)) = self.parameters.get(name) {
Ok(*id)
} else {
Err(Error::Query(QueryError::InvalidParameter {
parameter: name.clone(),
reason: "node ID parameter not found".to_string(),
}))
}
}
NodeRef::Identifier(name) => Err(Error::Query(QueryError::InvalidParameter {
parameter: name.clone(),
reason: "variable node references require execution context".to_string(),
})),
}
}
}
impl Default for AstConverter {
fn default() -> Self {
Self::new()
}
}
pub fn parse_query(aql: &str) -> Result<Query> {
let ast = super::parser::Parser::parse(aql).map_err(|e| {
Error::Query(QueryError::SyntaxError {
message: e.to_string(),
})
})?;
let converter = AstConverter::new();
converter.convert(&ast)
}
pub fn parse_query_with_params(
aql: &str,
params: HashMap<String, ParameterValue>,
) -> Result<Query> {
let ast = super::parser::Parser::parse(aql).map_err(|e| {
Error::Query(QueryError::SyntaxError {
message: e.to_string(),
})
})?;
let converter = AstConverter::with_parameters(params);
converter.convert(&ast)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::query::parser::Parser;
#[test]
fn test_convert_simple_match() {
let ast = Parser::parse("MATCH (n:Person) RETURN n").unwrap();
let converter = AstConverter::new();
let query = converter.convert(&ast).unwrap();
assert!(!query.ops.is_empty());
assert!(matches!(
&query.ops[0],
QueryOp::ScanNodes {
label: Some(l)
} if l == "Person"
));
}
#[test]
fn test_convert_match_with_traversal() {
let ast = Parser::parse("MATCH (n:Person)-[:KNOWS]->(m) RETURN m").unwrap();
let converter = AstConverter::new();
let query = converter.convert(&ast).unwrap();
assert!(query.ops.len() >= 2);
assert!(matches!(
&query.ops[0],
QueryOp::ScanNodes { label: Some(l) } if l == "Person"
));
assert!(matches!(
&query.ops[1],
QueryOp::TraverseOut {
label: Some(l),
depth: TraversalDepth::Exact(1)
} if l == "KNOWS"
));
}
#[test]
fn test_convert_match_with_where() {
let ast = Parser::parse("MATCH (n:Person) WHERE n.age > 25 RETURN n").unwrap();
let converter = AstConverter::new();
let query = converter.convert(&ast).unwrap();
assert!(query.ops.len() >= 2);
let filter_op = query.ops.iter().find(|op| matches!(op, QueryOp::Filter(_)));
assert!(filter_op.is_some());
if let Some(QueryOp::Filter(pred)) = filter_op {
assert!(matches!(pred, Predicate::Gt { key, .. } if key == "age"));
}
}
#[test]
fn test_convert_match_with_limit() {
let ast = Parser::parse("MATCH (n:Person) RETURN n LIMIT 10").unwrap();
let converter = AstConverter::new();
let query = converter.convert(&ast).unwrap();
let limit_op = query.ops.iter().find(|op| matches!(op, QueryOp::Limit(_)));
assert!(limit_op.is_some());
if let Some(QueryOp::Limit(n)) = limit_op {
assert_eq!(*n, 10);
}
}
#[test]
fn test_convert_match_with_skip() {
let ast = Parser::parse("MATCH (n:Person) RETURN n SKIP 5 LIMIT 10").unwrap();
let converter = AstConverter::new();
let query = converter.convert(&ast).unwrap();
let skip_op = query.ops.iter().find(|op| matches!(op, QueryOp::Skip(_)));
assert!(skip_op.is_some());
if let Some(QueryOp::Skip(n)) = skip_op {
assert_eq!(*n, 5);
}
}
#[test]
fn test_convert_vector_search() {
let ast = Parser::parse("SIMILAR TO [0.1, 0.2, 0.3] LIMIT 10").unwrap();
let converter = AstConverter::new();
let query = converter.convert(&ast).unwrap();
assert!(matches!(&query.ops[0], QueryOp::VectorSearch { k: 10, .. }));
}
#[test]
fn test_convert_find_similar_with_parameter() {
let ast = Parser::parse("FIND SIMILAR TO ($node_id) LIMIT 5").unwrap();
let mut converter = AstConverter::new();
converter.bind("node_id", ParameterValue::NodeId(NodeId::new(42).unwrap()));
let query = converter.convert(&ast).unwrap();
assert!(matches!(
&query.ops[0],
QueryOp::SimilarTo {
source_node,
k: 5,
..
} if source_node.as_u64() == 42
));
}
#[test]
fn test_convert_temporal_as_of() {
let ast = Parser::parse("AS OF 1000 MATCH (n:Person) RETURN n").unwrap();
let converter = AstConverter::new();
let query = converter.convert(&ast).unwrap();
assert!(query.temporal_context.is_some());
let ctx = query.temporal_context.unwrap();
let as_of_tuple = ctx.as_of_tuple();
assert!(as_of_tuple.is_some());
let (vt, _tt) = as_of_tuple.unwrap();
assert_eq!(vt.wallclock(), 1000);
}
#[test]
fn test_convert_temporal_between() {
let ast = Parser::parse("BETWEEN 1000 AND 2000 MATCH (n:Person) RETURN n").unwrap();
let converter = AstConverter::new();
let query = converter.convert(&ast).unwrap();
assert!(query.temporal_context.is_some());
let ctx = query.temporal_context.unwrap();
assert!(ctx.valid_time_between.is_some());
}
#[test]
fn test_convert_predicate_and() {
let ast = Parser::parse("MATCH (n) WHERE n.a = 1 AND n.b = 2 RETURN n").unwrap();
let converter = AstConverter::new();
let query = converter.convert(&ast).unwrap();
let filter_op = query.ops.iter().find(|op| matches!(op, QueryOp::Filter(_)));
assert!(filter_op.is_some());
if let Some(QueryOp::Filter(pred)) = filter_op {
assert!(matches!(pred, Predicate::And(_)));
}
}
#[test]
fn test_convert_predicate_or() {
let ast = Parser::parse("MATCH (n) WHERE n.a = 1 OR n.b = 2 RETURN n").unwrap();
let converter = AstConverter::new();
let query = converter.convert(&ast).unwrap();
let filter_op = query.ops.iter().find(|op| matches!(op, QueryOp::Filter(_)));
assert!(filter_op.is_some());
if let Some(QueryOp::Filter(pred)) = filter_op {
assert!(matches!(pred, Predicate::Or(_)));
}
}
#[test]
fn test_convert_predicate_not() {
let ast = Parser::parse("MATCH (n) WHERE NOT n.active = true RETURN n").unwrap();
let converter = AstConverter::new();
let query = converter.convert(&ast).unwrap();
let filter_op = query.ops.iter().find(|op| matches!(op, QueryOp::Filter(_)));
assert!(filter_op.is_some());
if let Some(QueryOp::Filter(pred)) = filter_op {
assert!(matches!(pred, Predicate::Not(_)));
}
}
#[test]
fn test_convert_predicate_contains() {
let ast = Parser::parse("MATCH (n) WHERE n.name CONTAINS 'test' RETURN n").unwrap();
let converter = AstConverter::new();
let query = converter.convert(&ast).unwrap();
let filter_op = query.ops.iter().find(|op| matches!(op, QueryOp::Filter(_)));
assert!(filter_op.is_some());
if let Some(QueryOp::Filter(pred)) = filter_op {
assert!(matches!(
pred,
Predicate::Contains { key, substring } if key == "name" && substring == "test"
));
}
}
#[test]
fn test_convert_predicate_starts_with() {
let ast = Parser::parse("MATCH (n) WHERE n.name STARTS WITH 'Al' RETURN n").unwrap();
let converter = AstConverter::new();
let query = converter.convert(&ast).unwrap();
let filter_op = query.ops.iter().find(|op| matches!(op, QueryOp::Filter(_)));
assert!(filter_op.is_some());
if let Some(QueryOp::Filter(pred)) = filter_op {
assert!(matches!(
pred,
Predicate::StartsWith { key, prefix } if key == "name" && prefix == "Al"
));
}
}
#[test]
fn test_convert_predicate_in() {
let ast =
Parser::parse("MATCH (n) WHERE n.status IN ['active', 'pending'] RETURN n").unwrap();
let converter = AstConverter::new();
let query = converter.convert(&ast).unwrap();
let filter_op = query.ops.iter().find(|op| matches!(op, QueryOp::Filter(_)));
assert!(filter_op.is_some());
if let Some(QueryOp::Filter(pred)) = filter_op {
assert!(
matches!(pred, Predicate::In { key, values } if key == "status" && values.len() == 2)
);
}
}
#[test]
fn test_convert_variable_length_traversal() {
let ast = Parser::parse("MATCH (n)-[:KNOWS*1..3]->(m) RETURN m").unwrap();
let converter = AstConverter::new();
let query = converter.convert(&ast).unwrap();
let traverse_op = query
.ops
.iter()
.find(|op| matches!(op, QueryOp::TraverseOut { .. }));
assert!(traverse_op.is_some());
if let Some(QueryOp::TraverseOut { depth, .. }) = traverse_op {
assert!(matches!(depth, TraversalDepth::Range { min: 1, max: 3 }));
}
}
#[test]
fn test_convert_incoming_traversal() {
let ast = Parser::parse("MATCH (n)<-[:KNOWS]-(m) RETURN m").unwrap();
let converter = AstConverter::new();
let query = converter.convert(&ast).unwrap();
let traverse_op = query
.ops
.iter()
.find(|op| matches!(op, QueryOp::TraverseIn { .. }));
assert!(traverse_op.is_some());
}
#[test]
fn test_convert_bidirectional_traversal() {
let ast = Parser::parse("MATCH (n)-[:KNOWS]-(m) RETURN m").unwrap();
let converter = AstConverter::new();
let query = converter.convert(&ast).unwrap();
let traverse_op = query
.ops
.iter()
.find(|op| matches!(op, QueryOp::TraverseBoth { .. }));
assert!(traverse_op.is_some());
}
#[test]
fn test_convert_rank_by_similarity() {
let ast =
Parser::parse("MATCH (n:Document) RANK BY SIMILARITY TO [0.1, 0.2] TOP 5 RETURN n")
.unwrap();
let converter = AstConverter::new();
let query = converter.convert(&ast).unwrap();
let rank_op = query
.ops
.iter()
.find(|op| matches!(op, QueryOp::RankBySimilarity { .. }));
assert!(rank_op.is_some());
if let Some(QueryOp::RankBySimilarity { top_k, .. }) = rank_op {
assert_eq!(*top_k, Some(5));
}
}
#[test]
fn test_convert_distinct() {
let ast = Parser::parse("MATCH (n) RETURN DISTINCT n").unwrap();
let converter = AstConverter::new();
let query = converter.convert(&ast).unwrap();
let distinct_op = query.ops.iter().find(|op| matches!(op, QueryOp::Distinct));
assert!(distinct_op.is_some());
}
#[test]
fn test_convert_with_embedding_parameter() {
let ast = Parser::parse("SIMILAR TO $embedding LIMIT 10").unwrap();
let mut converter = AstConverter::new();
converter.bind(
"embedding",
ParameterValue::Embedding(Arc::from([0.1f32, 0.2, 0.3].as_slice())),
);
let query = converter.convert(&ast).unwrap();
assert!(matches!(&query.ops[0], QueryOp::VectorSearch { k: 10, .. }));
}
#[test]
fn test_convert_error_missing_parameter() {
let ast = Parser::parse("SIMILAR TO $embedding LIMIT 10").unwrap();
let converter = AstConverter::new();
let result = converter.convert(&ast);
assert!(result.is_err());
}
#[test]
fn test_parse_query() {
let query = super::parse_query("MATCH (n:Person) RETURN n").unwrap();
assert!(!query.ops.is_empty());
}
#[test]
fn test_parse_query_with_params() {
use std::collections::HashMap;
let mut params = HashMap::new();
params.insert(
"embedding".to_string(),
ParameterValue::Embedding(Arc::from([0.1f32, 0.2, 0.3].as_slice())),
);
let query =
super::parse_query_with_params("SIMILAR TO $embedding LIMIT 10", params).unwrap();
assert!(matches!(&query.ops[0], QueryOp::VectorSearch { k: 10, .. }));
}
#[test]
fn test_planner_integration_simple_match() {
use crate::query::planner::{QueryPlanner, Statistics};
use crate::storage::CurrentStorage;
use std::sync::Arc;
let query = super::parse_query("MATCH (n:Person) RETURN n LIMIT 10").unwrap();
let storage = Arc::new(CurrentStorage::new());
let stats = Arc::new(Statistics::default());
let planner = QueryPlanner::new(stats, storage);
let result = planner.plan(query);
assert!(result.is_ok());
let plan = result.unwrap();
assert!(!matches!(
plan.root,
crate::query::planner::PhysicalOp::Empty
));
}
#[test]
fn test_planner_integration_with_traversal() {
use crate::query::planner::{QueryPlanner, Statistics};
use crate::storage::CurrentStorage;
use std::sync::Arc;
let query = super::parse_query("MATCH (n:Person)-[:KNOWS]->(m:Person) RETURN m").unwrap();
let storage = Arc::new(CurrentStorage::new());
let stats = Arc::new(Statistics::default());
let planner = QueryPlanner::new(stats, storage);
let result = planner.plan(query);
assert!(result.is_ok());
}
#[test]
fn test_planner_integration_with_filter() {
use crate::query::planner::{QueryPlanner, Statistics};
use crate::storage::CurrentStorage;
use std::sync::Arc;
let query =
super::parse_query("MATCH (n:Person) WHERE n.age > 25 RETURN n LIMIT 10").unwrap();
let storage = Arc::new(CurrentStorage::new());
let stats = Arc::new(Statistics::default());
let planner = QueryPlanner::new(stats, storage);
let result = planner.plan(query);
assert!(result.is_ok());
}
#[test]
fn test_planner_integration_temporal() {
use crate::query::planner::{QueryPlanner, Statistics};
use crate::storage::CurrentStorage;
use std::sync::Arc;
let query = super::parse_query("AS OF 1000000 MATCH (n:Person) RETURN n").unwrap();
assert!(query.temporal_context.is_some());
let storage = Arc::new(CurrentStorage::new());
let stats = Arc::new(Statistics::default());
let planner = QueryPlanner::new(stats, storage);
let result = planner.plan(query);
assert!(result.is_ok());
let plan = result.unwrap();
assert!(plan.is_temporal());
}
#[test]
fn test_full_pipeline_parse_convert_plan() {
use crate::query::planner::{QueryPlanner, Statistics};
use crate::storage::CurrentStorage;
use std::sync::Arc;
let aql = "MATCH (n:Person)-[:KNOWS*1..3]->(m:Person) WHERE n.age > 21 AND m.active = true RETURN m LIMIT 100";
let ast = Parser::parse(aql).unwrap();
let converter = AstConverter::new();
let query = converter.convert(&ast).unwrap();
assert!(
query
.ops
.iter()
.any(|op| matches!(op, QueryOp::ScanNodes { .. }))
);
assert!(
query
.ops
.iter()
.any(|op| matches!(op, QueryOp::TraverseOut { .. }))
);
assert!(query.ops.iter().any(|op| matches!(op, QueryOp::Filter(_))));
assert!(query.ops.iter().any(|op| matches!(op, QueryOp::Limit(100))));
let storage = Arc::new(CurrentStorage::new());
let stats = Arc::new(Statistics::default());
let planner = QueryPlanner::new(stats, storage);
let plan = planner.plan(query).unwrap();
assert!(!matches!(
plan.root,
crate::query::planner::PhysicalOp::Empty
));
}
#[test]
fn test_convert_order_by_property() {
let ast = Parser::parse("MATCH (n:Person) RETURN n ORDER BY n.age DESC").unwrap();
let converter = AstConverter::new();
let query = converter.convert(&ast).unwrap();
let sort_op = query
.ops
.iter()
.find(|op| matches!(op, QueryOp::Sort { .. }));
assert!(sort_op.is_some(), "Expected Sort operation");
if let Some(QueryOp::Sort { key, descending }) = sort_op {
assert!(
matches!(key, SortKey::Property(p) if p == "age"),
"Expected property key 'age'"
);
assert!(*descending, "Expected descending order");
}
}
#[test]
fn test_convert_order_by_ascending() {
let ast = Parser::parse("MATCH (n:Person) RETURN n ORDER BY n.name ASC").unwrap();
let converter = AstConverter::new();
let query = converter.convert(&ast).unwrap();
let sort_op = query
.ops
.iter()
.find(|op| matches!(op, QueryOp::Sort { .. }));
assert!(sort_op.is_some(), "Expected Sort operation");
if let Some(QueryOp::Sort { key, descending }) = sort_op {
assert!(
matches!(key, SortKey::Property(p) if p == "name"),
"Expected property key 'name'"
);
assert!(!*descending, "Expected ascending order");
}
}
#[test]
fn test_convert_order_by_score() {
let ast = Parser::parse("SIMILAR TO [0.1, 0.2, 0.3] LIMIT 10 ORDER BY score DESC").unwrap();
let converter = AstConverter::new();
let query = converter.convert(&ast).unwrap();
let sort_op = query
.ops
.iter()
.find(|op| matches!(op, QueryOp::Sort { .. }));
assert!(sort_op.is_some(), "Expected Sort operation");
if let Some(QueryOp::Sort { key, descending }) = sort_op {
assert!(matches!(key, SortKey::Score), "Expected Score key");
assert!(*descending, "Expected descending order");
}
}
#[test]
fn test_convert_order_by_multiple() {
let ast = Parser::parse("MATCH (n) RETURN n ORDER BY n.age DESC, n.name ASC").unwrap();
let converter = AstConverter::new();
let query = converter.convert(&ast).unwrap();
let sort_ops: Vec<_> = query
.ops
.iter()
.filter(|op| matches!(op, QueryOp::Sort { .. }))
.collect();
assert_eq!(sort_ops.len(), 2, "Expected 2 Sort operations");
if let QueryOp::Sort { key, descending } = sort_ops[0] {
assert!(
matches!(key, SortKey::Property(p) if p == "age"),
"First sort should be by age"
);
assert!(*descending, "First sort should be descending");
}
if let QueryOp::Sort { key, descending } = sort_ops[1] {
assert!(
matches!(key, SortKey::Property(p) if p == "name"),
"Second sort should be by name"
);
assert!(!*descending, "Second sort should be ascending");
}
}
#[test]
fn test_planner_integration_with_order_by() {
use crate::query::planner::{QueryPlanner, Statistics};
use crate::storage::CurrentStorage;
use std::sync::Arc;
let query =
super::parse_query("MATCH (n:Person) RETURN n ORDER BY n.age DESC LIMIT 10").unwrap();
let storage = Arc::new(CurrentStorage::new());
let stats = Arc::new(Statistics::default());
let planner = QueryPlanner::new(stats, storage);
let result = planner.plan(query);
assert!(result.is_ok(), "Planning should succeed");
let plan = result.unwrap();
assert!(!matches!(
plan.root,
crate::query::planner::PhysicalOp::Empty
));
}
}
#[cfg(test)]
mod sentry_tests {
use super::*;
use crate::core::NodeId;
use crate::query::parser::Parser;
#[test]
fn test_start_node_optimization_with_parameter() {
let ast = Parser::parse("MATCH (n {id: $id}) RETURN n").unwrap();
let mut converter = AstConverter::new();
converter.bind("id", ParameterValue::NodeId(NodeId::new(123).unwrap()));
let query = converter.convert(&ast).unwrap();
let has_start_node = query.ops.iter().any(|op| match op {
QueryOp::StartNode(id) => id.as_u64() == 123,
_ => false,
});
assert!(
has_start_node,
"Query should be optimized to use StartNode when id is a parameter. Ops: {:?}",
query.ops
);
}
#[test]
fn test_comparison_asymmetry() {
let ast = Parser::parse("MATCH (n) WHERE 1 = n.age RETURN n").unwrap();
let converter = AstConverter::new();
let query = converter.convert(&ast);
assert!(
query.is_ok(),
"Comparison should be symmetric (value = property). Error: {:?}",
query.err()
);
let query = query.unwrap();
let has_filter = query.ops.iter().any(|op| match op {
QueryOp::Filter(Predicate::Eq { key, .. }) => key == "age",
_ => false,
});
assert!(has_filter, "Should produce equality filter for age");
}
#[test]
fn test_invalid_node_id_in_match() {
let ast = Parser::parse("MATCH (n {id: -1}) RETURN n").unwrap();
let converter = AstConverter::new();
let result = converter.convert(&ast);
assert!(result.is_err());
}
#[test]
fn test_duplicate_property_keys() {
let ast = Parser::parse("MATCH (n {a: 1, a: 2}) RETURN n").unwrap();
let converter = AstConverter::new();
let query = converter.convert(&ast).unwrap();
let filters = query
.ops
.iter()
.filter(|op| matches!(op, QueryOp::Filter(_)))
.count();
assert_eq!(filters, 2);
}
#[test]
fn test_start_node_optimization_preserves_filters() {
let ast = Parser::parse("MATCH (n {id: $id, active: true}) RETURN n").unwrap();
let mut converter = AstConverter::new();
converter.bind("id", ParameterValue::NodeId(NodeId::new(123).unwrap()));
let query = converter.convert(&ast).unwrap();
let has_start_node = query
.ops
.iter()
.any(|op| matches!(op, QueryOp::StartNode(_)));
let has_filter = query
.ops
.iter()
.any(|op| matches!(op, QueryOp::Filter(Predicate::Eq { key, .. }) if key == "active"));
assert!(has_start_node, "Should use StartNode");
assert!(has_filter, "Should preserve 'active' filter");
}
#[test]
fn test_start_node_optimization_with_integer_parameter() {
let ast = Parser::parse("MATCH (n {id: $id}) RETURN n").unwrap();
let mut converter = AstConverter::new();
converter.bind("id", ParameterValue::Value(PredicateValue::Int(123)));
let query = converter.convert(&ast).unwrap();
let has_start_node = query.ops.iter().any(|op| match op {
QueryOp::StartNode(id) => id.as_u64() == 123,
_ => false,
});
assert!(has_start_node, "Should optimize Int parameter to StartNode");
}
#[test]
fn test_symmetric_comparison_operators() {
let cases = vec![
(
"10 = n.a",
Predicate::Eq {
key: "a".to_string(),
value: PredicateValue::Int(10),
},
),
(
"10 <> n.a",
Predicate::Ne {
key: "a".to_string(),
value: PredicateValue::Int(10),
},
),
(
"10 > n.a",
Predicate::Lt {
key: "a".to_string(),
value: PredicateValue::Int(10),
},
), (
"10 >= n.a",
Predicate::Lte {
key: "a".to_string(),
value: PredicateValue::Int(10),
},
), (
"10 < n.a",
Predicate::Gt {
key: "a".to_string(),
value: PredicateValue::Int(10),
},
), (
"10 <= n.a",
Predicate::Gte {
key: "a".to_string(),
value: PredicateValue::Int(10),
},
), ];
for (query_str, expected) in cases {
let full_query = format!("MATCH (n) WHERE {} RETURN n", query_str);
let ast = Parser::parse(&full_query).unwrap();
let converter = AstConverter::new();
let query = converter.convert(&ast).unwrap();
let found = query.ops.iter().any(|op| {
if let QueryOp::Filter(pred) = op {
*pred == expected
} else {
false
}
});
assert!(
found,
"Failed to convert '{}'. Expected {:?}",
query_str, expected
);
}
}
#[test]
fn test_comparison_invalid_syntax() {
let ast = Parser::parse("MATCH (n) WHERE 1 = 1 RETURN n").unwrap();
let converter = AstConverter::new();
let result = converter.convert(&ast);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Comparison must involve a property")
);
}
#[test]
fn test_comparison_with_parameter_resolution() {
let ast = Parser::parse("MATCH (n) WHERE n.age = $age RETURN n").unwrap();
let mut converter = AstConverter::new();
converter.bind("age", ParameterValue::Value(PredicateValue::Int(30)));
let query = converter.convert(&ast).unwrap();
let found = query.ops.iter().any(|op| {
matches!(
op,
QueryOp::Filter(Predicate::Eq {
value: PredicateValue::Int(30),
..
})
)
});
assert!(found, "Should resolve parameter in comparison");
let ast_swapped = Parser::parse("MATCH (n) WHERE $age = n.age RETURN n").unwrap();
let query_swapped = converter.convert(&ast_swapped).unwrap();
let found_swapped = query_swapped.ops.iter().any(|op| {
matches!(
op,
QueryOp::Filter(Predicate::Eq {
value: PredicateValue::Int(30),
..
})
)
});
assert!(
found_swapped,
"Should resolve parameter in swapped comparison"
);
}
#[test]
fn test_parameter_not_found_error() {
let ast = Parser::parse("MATCH (n) WHERE n.age = $missing RETURN n").unwrap();
let converter = AstConverter::new();
let result = converter.convert(&ast);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("missing"));
assert!(err.contains("not found"));
}
#[test]
fn test_start_node_optimization_preserves_labels() {
let ast = Parser::parse("MATCH (n:Secret {id: $id}) RETURN n").unwrap();
let mut converter = AstConverter::new();
converter.bind("id", ParameterValue::NodeId(NodeId::new(123).unwrap()));
let query = converter.convert(&ast).unwrap();
let has_start_node = query
.ops
.iter()
.any(|op| matches!(op, QueryOp::StartNode(_)));
let has_label_filter = query
.ops
.iter()
.any(|op| matches!(op, QueryOp::FilterLabel(label) if label == "Secret"));
assert!(has_start_node, "Should use StartNode");
assert!(has_label_filter, "Should preserve 'Secret' label check");
}
#[test]
fn test_pagination_order() {
let ast = Parser::parse("MATCH (n) RETURN n SKIP 5 LIMIT 10").unwrap();
let converter = AstConverter::new();
let query = converter.convert(&ast).unwrap();
let skip_idx = query
.ops
.iter()
.position(|op| matches!(op, QueryOp::Skip(_)));
let limit_idx = query
.ops
.iter()
.position(|op| matches!(op, QueryOp::Limit(_)));
assert!(skip_idx.is_some(), "Skip op missing");
assert!(limit_idx.is_some(), "Limit op missing");
assert!(
skip_idx.unwrap() < limit_idx.unwrap(),
"Skip operation must precede Limit operation"
);
}
#[test]
fn test_filter_before_project() {
let ast = Parser::parse("MATCH (n) WHERE n.age > 10 RETURN n.name").unwrap();
let converter = AstConverter::new();
let query = converter.convert(&ast).unwrap();
let filter_idx = query
.ops
.iter()
.position(|op| matches!(op, QueryOp::Filter(_)));
let project_idx = query
.ops
.iter()
.position(|op| matches!(op, QueryOp::Project(_)));
assert!(filter_idx.is_some(), "Filter op missing");
assert!(project_idx.is_some(), "Project op missing");
assert!(
filter_idx.unwrap() < project_idx.unwrap(),
"Filter operation must precede Project operation"
);
}
#[test]
fn test_inline_property_filter_does_not_use_start_node() {
let ast = Parser::parse("MATCH (n {age: 30}) RETURN n").unwrap();
let converter = AstConverter::new();
let query = converter.convert(&ast).unwrap();
let has_start_node = query
.ops
.iter()
.any(|op| matches!(op, QueryOp::StartNode(_)));
assert!(
!has_start_node,
"Should not use StartNode for non-id property"
);
let has_filter = query.ops.iter().any(|op| matches!(op, QueryOp::Filter(_)));
assert!(has_filter, "Should produce Filter for age");
}
#[test]
#[should_panic(expected = "convert_logic_predicate called on non-logic expr")]
fn test_convert_logic_predicate_unreachable() {
let converter = AstConverter::new();
let expr = PredicateExpr::Exists(crate::query::ast::PropertyAccess {
variable: "n".to_string(),
property: "prop".to_string(),
});
let _ = converter.convert_logic_predicate(&expr);
}
#[test]
#[should_panic(expected = "convert_string_predicate called on non-string expr")]
fn test_convert_string_predicate_unreachable() {
let converter = AstConverter::new();
let expr = PredicateExpr::Exists(crate::query::ast::PropertyAccess {
variable: "n".to_string(),
property: "prop".to_string(),
});
let _ = converter.convert_string_predicate(&expr);
}
}