use super::super::ast::*;
use super::core::execute_clause;
use super::expansion::VarLenCaps;
use super::types::{BindingRow, ExecutionState};
use crate::engine::graph::csr::{CsrIndex, GraphOverlayDelta};
use crate::engine::graph::edge_store::EdgeStore;
use crate::engine::sparse::btree::SparseEngine;
pub struct PropertyLookup<'a> {
pub sparse: &'a SparseEngine,
pub csr: &'a CsrIndex,
pub database_id: u64,
pub tenant_id: u64,
pub collection: Option<&'a str>,
}
pub(super) fn apply_predicate(
rows: &[BindingRow],
predicate: &WherePredicate,
csr: &CsrIndex,
_edge_store: &EdgeStore,
varlen_caps: VarLenCaps,
props: &PropertyLookup<'_>,
overlay: Option<&GraphOverlayDelta>,
) -> Result<Vec<BindingRow>, crate::Error> {
match predicate {
WherePredicate::Equals {
binding,
field,
value,
} => {
if field.is_empty() {
Ok(rows
.iter()
.filter(|row| row.get(binding).is_some_and(|v| v == value))
.cloned()
.collect())
} else {
let expected_value = coerce_literal(value);
let mut kept = Vec::new();
for row in rows {
let keep = match row.get(binding) {
Some(node_id) => check_property(
props,
node_id,
field,
&ComparisonOp::Eq,
&expected_value,
)?,
None => false,
};
if keep {
kept.push(row.clone());
}
}
Ok(kept)
}
}
WherePredicate::Comparison {
binding,
field,
op,
value,
} => {
if field.is_empty() {
Ok(rows
.iter()
.filter(|row| {
let lhs = match row.get(binding.as_str()) {
Some(v) => v.as_str(),
None => return true,
};
let rhs: &str = match row.get(value.as_str()) {
Some(v) => v.as_str(),
None => value.as_str(),
};
apply_op(op, lhs, rhs)
})
.cloned()
.collect())
} else {
let expected_value = coerce_literal(value);
let mut kept = Vec::new();
for row in rows {
let keep = match row.get(binding.as_str()) {
Some(node_id) => {
check_property(props, node_id, field, op, &expected_value)?
}
None => false,
};
if keep {
kept.push(row.clone());
}
}
Ok(kept)
}
}
WherePredicate::NotExists { sub_pattern } => {
let mut result = Vec::new();
for row in rows {
let mut sub_state = ExecutionState::new(None, varlen_caps);
sub_state.collection_filter =
super::expansion::resolve_collection_filter(props.collection, csr);
let sub_rows = execute_clause(
sub_pattern,
csr,
std::slice::from_ref(row),
&mut sub_state,
None,
overlay,
)?;
if sub_state.truncated() {
continue;
}
if sub_rows.is_empty() {
result.push(row.clone());
}
}
Ok(result)
}
}
}
fn apply_op(op: &ComparisonOp, lhs: &str, rhs: &str) -> bool {
match op {
ComparisonOp::Eq => lhs == rhs,
ComparisonOp::Neq => lhs != rhs,
ComparisonOp::Lt | ComparisonOp::Lte | ComparisonOp::Gt | ComparisonOp::Gte => true,
}
}
fn coerce_literal(expected: &str) -> nodedb_types::Value {
use nodedb_types::Value;
if let Ok(i) = expected.parse::<i64>() {
Value::Integer(i)
} else if let Ok(f) = expected.parse::<f64>() {
Value::Float(f)
} else if let Ok(b) = expected.parse::<bool>() {
Value::Bool(b)
} else {
Value::String(expected.to_string())
}
}
fn fetch_node_doc(
props: &PropertyLookup<'_>,
collection: &str,
node_id: &str,
) -> Result<Option<nodedb_types::Value>, crate::Error> {
let Some(surrogate) = props.csr.node_surrogate(node_id) else {
return Ok(None);
};
let doc_id = crate::engine::document::store::key::surrogate_to_doc_id(surrogate);
let bytes = match props
.sparse
.get(props.database_id, props.tenant_id, collection, &doc_id)?
{
Some(b) => b,
None => return Ok(None),
};
let doc =
nodedb_types::value_from_msgpack(&bytes).map_err(|e| crate::Error::Serialization {
format: "msgpack".into(),
detail: format!("decode graph node `{node_id}` document: {e}"),
})?;
Ok(Some(doc))
}
fn check_property(
props: &PropertyLookup<'_>,
node_id: &str,
field: &str,
op: &ComparisonOp,
expected_value: &nodedb_types::Value,
) -> Result<bool, crate::Error> {
use nodedb_query::value_ops::{coerced_eq, compare_values};
use std::cmp::Ordering;
let collection = props.collection.ok_or_else(|| crate::Error::BadRequest {
detail: format!(
"MATCH property predicate `{node_id}.{field}` requires an \
`IN '<collection>'` clause to resolve node properties"
),
})?;
let doc = match fetch_node_doc(props, collection, node_id)? {
Some(d) => d,
None => return Ok(false),
};
let field_value = match &doc {
nodedb_types::Value::Object(map) => map.get(field),
_ => None,
};
let field_value = match field_value {
Some(v) => v,
None => return Ok(false),
};
let result = match op {
ComparisonOp::Eq => coerced_eq(field_value, expected_value),
ComparisonOp::Neq => !coerced_eq(field_value, expected_value),
ComparisonOp::Lt => compare_values(field_value, expected_value) == Ordering::Less,
ComparisonOp::Lte => {
matches!(
compare_values(field_value, expected_value),
Ordering::Less | Ordering::Equal
)
}
ComparisonOp::Gt => compare_values(field_value, expected_value) == Ordering::Greater,
ComparisonOp::Gte => {
matches!(
compare_values(field_value, expected_value),
Ordering::Greater | Ordering::Equal
)
}
};
Ok(result)
}
#[cfg(test)]
pub(super) fn check_property_for_test(
props: &PropertyLookup<'_>,
node_id: &str,
field: &str,
op: &ComparisonOp,
expected: &str,
) -> Result<bool, crate::Error> {
let expected_value = coerce_literal(expected);
check_property(props, node_id, field, op, &expected_value)
}
pub(super) fn project_columns(
rows: &[BindingRow],
columns: &[ReturnColumn],
props: &PropertyLookup<'_>,
) -> Result<Vec<BindingRow>, crate::Error> {
let mut out = Vec::with_capacity(rows.len());
for row in rows {
let mut projected = BindingRow::new();
for col in columns {
let key = col.alias.as_deref().unwrap_or(&col.expr);
let value = if let Some(dot) = col.expr.find('.') {
let binding = &col.expr[..dot];
let field = &col.expr[dot + 1..];
match row.get(binding) {
None => "NULL".to_string(),
Some(node_id) => project_property(props, node_id, field)?,
}
} else {
row.get(&col.expr)
.cloned()
.unwrap_or_else(|| "NULL".to_string())
};
projected.insert(key.to_string(), value);
}
out.push(projected);
}
Ok(out)
}
fn project_property(
props: &PropertyLookup<'_>,
node_id: &str,
field: &str,
) -> Result<String, crate::Error> {
use nodedb_query::value_ops::value_to_display_string;
let collection = props.collection.ok_or_else(|| crate::Error::BadRequest {
detail: format!(
"MATCH property projection `{node_id}.{field}` requires an \
`IN '<collection>'` clause to resolve node properties"
),
})?;
let doc = match fetch_node_doc(props, collection, node_id)? {
Some(d) => d,
None => return Ok("NULL".to_string()),
};
let field_value = match &doc {
nodedb_types::Value::Object(map) => map.get(field),
_ => None,
};
match field_value {
Some(v) => Ok(value_to_display_string(v)),
None => Ok("NULL".to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn neq_filters_equal_values() {
assert!(!apply_op(&ComparisonOp::Neq, "alice", "alice"));
assert!(apply_op(&ComparisonOp::Neq, "alice", "bob"));
}
#[test]
fn eq_keeps_only_matching_values() {
assert!(apply_op(&ComparisonOp::Eq, "alice", "alice"));
assert!(!apply_op(&ComparisonOp::Eq, "alice", "bob"));
}
#[test]
fn self_comparison_neq_is_always_false() {
assert!(!apply_op(&ComparisonOp::Neq, "x", "x"));
assert!(!apply_op(&ComparisonOp::Neq, "carol", "carol"));
}
#[test]
fn ordering_ops_on_node_identities_preserve_row() {
for op in &[
ComparisonOp::Lt,
ComparisonOp::Lte,
ComparisonOp::Gt,
ComparisonOp::Gte,
] {
assert!(apply_op(op, "alice", "bob"), "{op:?} should preserve row");
assert!(apply_op(op, "alice", "alice"), "{op:?} should preserve row");
}
}
#[test]
fn rhs_resolved_as_binding_when_present_in_row() {
use std::collections::HashMap;
let rows: Vec<HashMap<String, String>> = vec![
[("p1", "alice"), ("p2", "alice")]
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(), [("p1", "alice"), ("p2", "bob")]
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(), [("p1", "carol"), ("p2", "carol")]
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(), ];
let binding = "p1";
let value = "p2"; let op = ComparisonOp::Neq;
let result: Vec<_> = rows
.iter()
.filter(|row| {
let lhs = match row.get(binding) {
Some(v) => v.as_str(),
None => return true,
};
let rhs: &str = match row.get(value) {
Some(v) => v.as_str(),
None => value,
};
apply_op(&op, lhs, rhs)
})
.collect();
assert_eq!(
result.len(),
1,
"only the alice→bob row survives WHERE p1 <> p2"
);
assert_eq!(result[0]["p1"], "alice");
assert_eq!(result[0]["p2"], "bob");
}
#[test]
fn rhs_used_as_literal_when_not_a_binding_in_row() {
use std::collections::HashMap;
let rows: Vec<HashMap<String, String>> = vec![
[("p1", "alice")]
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(), [("p1", "bob")]
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(), ];
let binding = "p1";
let value = "alice"; let op = ComparisonOp::Neq;
let result: Vec<_> = rows
.iter()
.filter(|row| {
let lhs = match row.get(binding) {
Some(v) => v.as_str(),
None => return true,
};
let rhs: &str = match row.get(value) {
Some(v) => v.as_str(),
None => value,
};
apply_op(&op, lhs, rhs)
})
.collect();
assert_eq!(result.len(), 1);
assert_eq!(result[0]["p1"], "bob");
}
}