#![cfg_attr(not(feature = "gql"), allow(dead_code))]
use std::sync::Arc;
use futures::StreamExt;
use tracing::debug;
use super::expand::ExpandDir;
use crate::exec::operators::scan::fetch::{FetchFieldStateCache, resolve_with_field_state};
use crate::exec::operators::scan::graph_keys::{
EdgeTableSpec, compute_graph_ranges, decode_graph_edge,
};
use crate::exec::{
AccessMode, CardinalityHint, ContextLevel, ControlFlowExt, ExecOperator, ExecutionContext,
FlowResult, OperatorMetrics, ValueBatch, ValueBatchStream, buffer_stream, monitor_stream,
};
use crate::expr::{ControlFlow, Dir};
use crate::idx::planner::ScanDirection;
use crate::val::{Array, Object, RecordId, TableName, Value};
pub(crate) fn scan_dir(direction: ExpandDir) -> Dir {
match direction {
ExpandDir::Out => Dir::Out,
ExpandDir::In => Dir::In,
}
}
pub(crate) fn target_field(direction: ExpandDir) -> &'static str {
match direction {
ExpandDir::Out => "out",
ExpandDir::In => "in",
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PathMode {
Walk,
Trail,
Simple,
Acyclic,
}
impl PathMode {
pub(crate) fn attr(self) -> Option<&'static str> {
match self {
PathMode::Walk => None,
PathMode::Trail => Some("trail"),
PathMode::Simple => Some("simple"),
PathMode::Acyclic => Some("acyclic"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum StepKind {
Reject,
EmitOnly,
EmitAndExtend,
}
pub(crate) fn step_kind(mode: PathMode, path_nodes: &[Value], target: &RecordId) -> StepKind {
match mode {
PathMode::Walk | PathMode::Trail => StepKind::EmitAndExtend,
PathMode::Acyclic => {
if nodes_contain(path_nodes, target) {
StepKind::Reject
} else {
StepKind::EmitAndExtend
}
}
PathMode::Simple => {
let is_start = path_nodes.first().and_then(node_id).as_ref() == Some(target);
if is_start {
StepKind::EmitOnly
} else if nodes_contain(path_nodes, target) {
StepKind::Reject
} else {
StepKind::EmitAndExtend
}
}
}
}
fn nodes_contain(nodes: &[Value], target: &RecordId) -> bool {
nodes.iter().filter_map(node_id).any(|id| &id == target)
}
pub(crate) struct PartialPath {
pub(crate) tip: RecordId,
pub(crate) edges: Vec<(RecordId, Value)>,
pub(crate) nodes: Vec<Value>,
}
impl PartialPath {
pub(crate) fn depth(&self) -> u32 {
self.edges.len() as u32
}
pub(crate) fn contains_edge(&self, id: &RecordId) -> bool {
self.edges.iter().any(|(eid, _)| eid == id)
}
}
#[derive(Debug, Clone)]
pub struct PathExpand {
pub(crate) input: Arc<dyn ExecOperator>,
pub(crate) source: String,
pub(crate) direction: ExpandDir,
pub(crate) edge_tables: Vec<TableName>,
pub(crate) min: u32,
pub(crate) max: Option<u32>,
pub(crate) target_binding: String,
pub(crate) target_label: Option<TableName>,
pub(crate) group_binding: Option<String>,
pub(crate) path_binding: Option<String>,
pub(crate) mode: PathMode,
pub(crate) reversed: bool,
pub(crate) metrics: Arc<OperatorMetrics>,
}
impl PathExpand {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
input: Arc<dyn ExecOperator>,
source: String,
direction: ExpandDir,
edge_tables: Vec<TableName>,
min: u32,
max: Option<u32>,
target_binding: String,
target_label: Option<TableName>,
group_binding: Option<String>,
path_binding: Option<String>,
mode: PathMode,
reversed: bool,
) -> Self {
Self {
input,
source,
direction,
edge_tables,
min,
max,
target_binding,
target_label,
group_binding,
path_binding,
mode,
reversed,
metrics: Arc::new(OperatorMetrics::new()),
}
}
fn edge_specs(&self) -> Vec<EdgeTableSpec> {
self.edge_tables
.iter()
.cloned()
.map(|table| EdgeTableSpec {
table,
range_start: std::ops::Bound::Unbounded,
range_end: std::ops::Bound::Unbounded,
})
.collect()
}
#[cfg(test)]
fn emits_at(&self, d: u32) -> bool {
d >= self.min && self.max.is_none_or(|m| d <= m)
}
#[cfg(test)]
fn extends_past(&self, d: u32) -> bool {
self.max.is_none_or(|m| d < m)
}
}
pub(crate) fn source_record_id(row: &Value, source: &str) -> Option<RecordId> {
crate::exec::operators::binding_record_id(row, source)
}
pub(crate) fn edge_target(edge_obj: &Value, field: &str) -> Option<RecordId> {
let Value::Object(obj) = edge_obj else {
return None;
};
match obj.get(field) {
Some(Value::RecordId(rid)) => Some(rid.clone()),
_ => None,
}
}
pub(crate) fn node_id(node_obj: &Value) -> Option<RecordId> {
let Value::Object(obj) = node_obj else {
return None;
};
match obj.get("id") {
Some(Value::RecordId(rid)) => Some(rid.clone()),
_ => None,
}
}
pub(crate) fn node_passes_label(node_obj: &Value, target_label: Option<&TableName>) -> bool {
let Some(label) = target_label else {
return true;
};
node_id(node_obj).map(|rid| &rid.table == label).unwrap_or(false)
}
impl ExecOperator for PathExpand {
fn name(&self) -> &'static str {
"PathExpand"
}
fn attrs(&self) -> Vec<(String, String)> {
let dir = match self.direction {
ExpandDir::Out => "->",
ExpandDir::In => "<-",
};
let tables = if self.edge_tables.is_empty() {
"*".to_string()
} else {
self.edge_tables.iter().map(|t| t.as_str()).collect::<Vec<_>>().join(", ")
};
let max = self.max.map(|m| m.to_string()).unwrap_or_else(|| "∞".to_string());
let mut attrs = vec![
("source".to_string(), self.source.clone()),
("direction".to_string(), dir.to_string()),
("tables".to_string(), tables),
("min".to_string(), self.min.to_string()),
("max".to_string(), max),
("target_binding".to_string(), self.target_binding.clone()),
];
if let Some(label) = self.target_label.as_ref() {
attrs.push(("target_label".to_string(), label.as_str().to_string()));
}
if let Some(group) = self.group_binding.as_ref() {
attrs.push(("group".to_string(), group.clone()));
}
if let Some(path) = self.path_binding.as_ref() {
attrs.push(("path".to_string(), path.clone()));
}
if let Some(mode) = self.mode.attr() {
attrs.push(("mode".to_string(), mode.to_string()));
}
if self.reversed {
attrs.push(("reversed".to_string(), "true".to_string()));
}
attrs
}
fn required_context(&self) -> ContextLevel {
self.input.required_context().max(ContextLevel::Database)
}
fn access_mode(&self) -> AccessMode {
self.input.access_mode()
}
fn cardinality_hint(&self) -> CardinalityHint {
CardinalityHint::Unbounded
}
fn metrics(&self) -> Option<&OperatorMetrics> {
Some(&self.metrics)
}
fn children(&self) -> Vec<&Arc<dyn ExecOperator>> {
vec![&self.input]
}
fn execute(&self, ctx: &ExecutionContext) -> FlowResult<ValueBatchStream> {
let db_ctx = ctx.database()?.clone();
let input_stream = buffer_stream(
self.input.execute(ctx)?,
self.input.access_mode(),
self.input.cardinality_hint(),
ctx.root().ctx.config.operator_buffer_size,
);
let source = self.source.clone();
let direction = self.direction;
let edge_specs = self.edge_specs();
let min = self.min;
let max = self.max;
let target_binding = self.target_binding.clone();
let target_label = self.target_label.clone();
let group_binding = self.group_binding.clone();
let path_binding = self.path_binding.clone();
let mode = self.mode;
let reversed = self.reversed;
let emits_at = move |d: u32| d >= min && max.is_none_or(|m| d <= m);
let extends_past = move |d: u32| max.is_none_or(|m| d < m);
let scan_batch_size = ctx.root().ctx.config.scan_batch_size;
let path_row_limit = ctx.root().ctx.config.gql_max_path_rows;
let dir = scan_dir(direction);
let tgt_field = target_field(direction);
let ctx = ctx.clone();
let stream = async_stream::try_stream! {
let txn = ctx.txn();
let ns_id = db_ctx.ns_ctx.ns.namespace_id;
let db_id = db_ctx.db.database_id;
let version = ctx.version_stamp();
let mut edge_cache = FetchFieldStateCache::new();
let mut node_cache = FetchFieldStateCache::new();
let mut out: Vec<Value> = Vec::with_capacity(scan_batch_size);
futures::pin_mut!(input_stream);
while let Some(batch_result) = input_stream.next().await {
crate::exec::operators::check_cancelled(&ctx)?;
let batch = batch_result?;
for row in batch.values {
let Some(source_rid) = source_record_id(&row, &source) else {
debug!(
target: "surrealdb::exec::path_expand",
"PathExpand source binding {source} is not a bound node; dropping row",
);
continue;
};
let source_obj = match &row {
Value::Object(obj) => obj.get(&source).cloned().unwrap_or(Value::Null),
_ => Value::Null,
};
let mut path_count: usize = 0;
if min == 0 && node_passes_label(&source_obj, target_label.as_ref()) {
path_count += 1;
if path_count > path_row_limit {
Err(path_rows_exceeded(path_row_limit))?;
}
let assembled = assemble_row(
&row,
&target_binding,
source_obj.clone(),
group_binding.as_deref(),
&[],
path_binding.as_deref(),
std::slice::from_ref(&source_obj),
);
out.push(assembled);
if out.len() >= scan_batch_size {
yield ValueBatch { values: std::mem::take(&mut out) };
out = Vec::with_capacity(scan_batch_size);
}
}
let mut stack: Vec<PartialPath> = Vec::new();
if extends_past(0) {
path_count += 1;
if path_count > path_row_limit {
Err(path_rows_exceeded(path_row_limit))?;
}
stack.push(PartialPath {
tip: source_rid.clone(),
edges: Vec::new(),
nodes: vec![source_obj.clone()],
});
}
while let Some(path) = stack.pop() {
crate::exec::operators::check_cancelled(&ctx)?;
let depth = path.depth();
let ranges = compute_graph_ranges(
ns_id, db_id, &path.tip, dir, &edge_specs, &ctx,
).await?;
let mut edge_ids: Vec<RecordId> = Vec::new();
let mut decoded_targets: Vec<Option<RecordId>> = Vec::new();
for (beg, end) in ranges {
let mut cursor = txn
.open_keys_cursor(beg..end, ScanDirection::Forward, 0, version)
.await
.context("Failed to open PathExpand graph cursor")?;
loop {
crate::exec::operators::check_cancelled(&ctx)?;
let keys = cursor
.next_batch(crate::kvs::NORMAL_BATCH_SIZE)
.await
.context("Failed to scan PathExpand graph edge")?;
if keys.is_empty() {
break;
}
for key in &keys {
let decoded = decode_graph_edge(key)?;
if !path.contains_edge(&decoded.edge) {
edge_ids.push(decoded.edge);
decoded_targets.push(decoded.target);
}
}
}
drop(cursor);
}
if edge_ids.is_empty() {
continue;
}
let edge_objs =
resolve_with_field_state(&ctx, &mut edge_cache, &edge_ids).await?;
let mut step_edges: Vec<(RecordId, Value)> = Vec::new();
let mut node_ids: Vec<RecordId> = Vec::new();
for ((edge_id, decoded_target), edge_obj) in
edge_ids.into_iter().zip(decoded_targets).zip(edge_objs)
{
let Some(edge_obj) = edge_obj else {
continue;
};
let next_id = decoded_target
.or_else(|| edge_target(&edge_obj, tgt_field));
let Some(next_id) = next_id else {
continue;
};
step_edges.push((edge_id, edge_obj));
node_ids.push(next_id);
}
if node_ids.is_empty() {
continue;
}
let node_objs =
resolve_with_field_state(&ctx, &mut node_cache, &node_ids).await?;
let new_depth = depth + 1;
let mut successors: Vec<PartialPath> = Vec::new();
for ((edge_id, edge_obj), node_obj) in
step_edges.into_iter().zip(node_objs)
{
let Some(node_obj) = node_obj else {
continue;
};
let target_id = node_id(&node_obj);
let kind = match target_id.as_ref() {
Some(tid) => step_kind(mode, &path.nodes, tid),
None => StepKind::EmitOnly,
};
if matches!(kind, StepKind::Reject) {
continue;
}
let mut edges = path.edges.clone();
edges.push((edge_id, edge_obj));
let mut nodes = path.nodes.clone();
nodes.push(node_obj.clone());
if emits_at(new_depth)
&& node_passes_label(&node_obj, target_label.as_ref())
{
path_count += 1;
if path_count > path_row_limit {
Err(path_rows_exceeded(path_row_limit))?;
}
let (group, path_arr) =
build_group_and_path(&nodes, &edges, reversed);
let assembled = assemble_row(
&row,
&target_binding,
node_obj.clone(),
group_binding.as_deref(),
&group,
path_binding.as_deref(),
&path_arr,
);
out.push(assembled);
if out.len() >= scan_batch_size {
yield ValueBatch { values: std::mem::take(&mut out) };
out = Vec::with_capacity(scan_batch_size);
}
}
if matches!(kind, StepKind::EmitAndExtend)
&& extends_past(new_depth)
&& let Some(tip) = target_id
{
path_count += 1;
if path_count > path_row_limit {
Err(path_rows_exceeded(path_row_limit))?;
}
successors.push(PartialPath {
tip,
edges,
nodes,
});
}
}
for succ in successors.into_iter().rev() {
stack.push(succ);
}
}
}
}
if !out.is_empty() {
yield ValueBatch { values: out };
}
};
Ok(monitor_stream(Box::pin(stream), "PathExpand", &self.metrics))
}
}
pub(crate) fn path_rows_exceeded(limit: usize) -> ControlFlow {
ControlFlow::Err(anyhow::anyhow!(
"GQL MATCH path traversal exceeded SURREAL_GQL_MAX_PATH_ROWS ({limit}); the quantified \
pattern produced too many paths from a single source row"
))
}
pub(crate) fn build_path_array(nodes: &[Value], edges: &[(RecordId, Value)]) -> Vec<Value> {
let mut arr = Vec::with_capacity(nodes.len() + edges.len());
for (i, node) in nodes.iter().enumerate() {
arr.push(node.clone());
if let Some((_, edge_obj)) = edges.get(i) {
arr.push(edge_obj.clone());
}
}
arr
}
pub(crate) fn build_group_and_path(
nodes: &[Value],
edges: &[(RecordId, Value)],
reversed: bool,
) -> (Vec<Value>, Vec<Value>) {
let mut group: Vec<Value> = edges.iter().map(|(_, o)| o.clone()).collect();
let mut path_arr = build_path_array(nodes, edges);
if reversed {
group.reverse();
path_arr.reverse();
}
(group, path_arr)
}
pub(crate) fn assemble_row(
input_row: &Value,
target_binding: &str,
target_obj: Value,
group_binding: Option<&str>,
group: &[Value],
path_binding: Option<&str>,
path_arr: &[Value],
) -> Value {
let mut obj: Object = match input_row {
Value::Object(o) => o.clone(),
_ => Object::default(),
};
obj.insert(target_binding.to_string(), target_obj);
if let Some(group_name) = group_binding {
obj.insert(group_name.to_string(), Value::Array(Array::from(group.to_vec())));
}
if let Some(path_name) = path_binding {
obj.insert(path_name.to_string(), Value::Array(Array::from(path_arr.to_vec())));
}
Value::Object(obj)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::exec::operators::CurrentValueSource;
use crate::val::RecordIdKey;
fn rid(table: &str, key: &str) -> RecordId {
RecordId {
table: TableName::new(table.to_string()),
key: RecordIdKey::from(key.to_string()),
}
}
fn node(table: &str, key: &str) -> Value {
let mut obj = Object::default();
obj.insert("id", Value::RecordId(rid(table, key)));
Value::Object(obj)
}
fn rid_str(s: &str) -> RecordId {
let (table, key) = s.split_once(':').expect("test record id missing ':'");
rid(table, key)
}
fn edge(table: &str, key: &str, in_node: &str, out_node: &str) -> Value {
let mut obj = Object::default();
obj.insert("id", Value::RecordId(rid(table, key)));
obj.insert("in", Value::RecordId(rid_str(in_node)));
obj.insert("out", Value::RecordId(rid_str(out_node)));
Value::Object(obj)
}
fn edge_id(table: &str, key: &str) -> RecordId {
rid(table, key)
}
fn sample(min: u32, max: Option<u32>) -> PathExpand {
PathExpand::new(
Arc::new(CurrentValueSource::new()),
"a".to_string(),
ExpandDir::Out,
vec![TableName::new("knows")],
min,
max,
"b".to_string(),
None,
None,
None,
PathMode::Walk,
false,
)
}
#[test]
fn source_id_from_full_object_row() {
let mut row = Object::default();
row.insert("a", node("person", "alice"));
let rid = source_record_id(&Value::Object(row), "a").unwrap();
assert_eq!(rid.table, TableName::new("person"));
}
#[test]
fn source_id_missing_or_null_drops() {
assert!(source_record_id(&Value::Object(Object::default()), "a").is_none());
let mut row = Object::default();
row.insert("a", Value::Null);
assert!(source_record_id(&Value::Object(row), "a").is_none());
assert!(source_record_id(&Value::None, "a").is_none());
}
#[test]
fn direction_maps_dir_and_field() {
assert_eq!(target_field(ExpandDir::Out), "out");
assert_eq!(target_field(ExpandDir::In), "in");
assert_eq!(scan_dir(ExpandDir::Out), Dir::Out);
assert_eq!(scan_dir(ExpandDir::In), Dir::In);
}
#[test]
fn edge_target_reads_endpoint() {
let e = edge("knows", "1", "person:a", "person:b");
assert_eq!(edge_target(&e, "out"), Some(edge_id("person", "b")));
assert_eq!(edge_target(&e, "in"), Some(edge_id("person", "a")));
}
#[test]
fn label_filter_matches_table() {
let n = node("person", "alice");
assert!(node_passes_label(&n, Some(&TableName::new("person"))));
assert!(!node_passes_label(&n, Some(&TableName::new("company"))));
assert!(node_passes_label(&n, None));
assert!(!node_passes_label(&Value::None, Some(&TableName::new("person"))));
}
#[test]
fn build_path_array_alternates() {
let nodes = vec![node("person", "a"), node("person", "b"), node("person", "c")];
let edges = vec![
(edge_id("knows", "1"), edge("knows", "1", "person:a", "person:b")),
(edge_id("knows", "2"), edge("knows", "2", "person:b", "person:c")),
];
let arr = build_path_array(&nodes, &edges);
assert_eq!(arr.len(), 5);
assert!(matches!(&arr[0], Value::Object(_)));
assert!(matches!(&arr[1], Value::Object(_)));
assert_eq!(build_path_array(&nodes[..1], &[]).len(), 1);
}
#[test]
fn assemble_row_carries_input_and_bindings() {
let mut input = Object::default();
input.insert("a", node("person", "a"));
let input = Value::Object(input);
let group = vec![edge("knows", "1", "person:a", "person:b")];
let path = vec![node("person", "a"), group[0].clone(), node("person", "b")];
let row =
assemble_row(&input, "b", node("person", "b"), Some("g"), &group, Some("p"), &path);
let Value::Object(obj) = row else {
panic!("expected object row");
};
assert!(obj.contains_key("a")); assert!(obj.contains_key("b")); match obj.get("g") {
Some(Value::Array(a)) => assert_eq!(a.len(), 1),
other => panic!("expected group array, got {other:?}"),
}
match obj.get("p") {
Some(Value::Array(a)) => assert_eq!(a.len(), 3),
other => panic!("expected path array, got {other:?}"),
}
}
#[test]
fn zero_length_group_is_empty_array() {
let input = {
let mut o = Object::default();
o.insert("a", node("person", "a"));
Value::Object(o)
};
let src = node("person", "a");
let row = assemble_row(&input, "a", src.clone(), Some("g"), &[], Some("p"), &[src]);
let Value::Object(obj) = row else {
panic!("expected object row");
};
match obj.get("g") {
Some(Value::Array(a)) => assert!(a.is_empty()),
other => panic!("expected empty group array, got {other:?}"),
}
match obj.get("p") {
Some(Value::Array(a)) => assert_eq!(a.len(), 1),
other => panic!("expected single-node path, got {other:?}"),
}
}
#[test]
fn emit_and_extend_windows_bounded() {
let pe = sample(1, Some(3));
assert!(!pe.emits_at(0));
assert!(pe.emits_at(1));
assert!(pe.emits_at(3));
assert!(!pe.emits_at(4));
assert!(pe.extends_past(0));
assert!(pe.extends_past(2));
assert!(!pe.extends_past(3));
}
#[test]
fn unbounded_max_always_extends() {
let pe = sample(0, None);
assert!(pe.emits_at(0));
assert!(pe.emits_at(100));
assert!(pe.extends_past(0));
assert!(pe.extends_past(1000));
}
#[test]
fn exact_quantifier_window() {
let pe = sample(2, Some(2));
assert!(!pe.emits_at(1));
assert!(pe.emits_at(2));
assert!(!pe.emits_at(3));
assert!(pe.extends_past(1));
assert!(!pe.extends_past(2));
}
#[test]
fn attrs_render_min_max_group_path() {
let pe = PathExpand::new(
Arc::new(CurrentValueSource::new()),
"a".to_string(),
ExpandDir::Out,
vec![TableName::new("knows")],
1,
None,
"b".to_string(),
Some(TableName::new("person")),
Some("g".to_string()),
Some("p".to_string()),
PathMode::Trail,
false,
);
assert_eq!(pe.name(), "PathExpand");
let attrs = pe.attrs();
assert!(attrs.iter().any(|(k, v)| k == "min" && v == "1"));
assert!(attrs.iter().any(|(k, v)| k == "max" && v == "∞"));
assert!(attrs.iter().any(|(k, v)| k == "tables" && v.contains("knows")));
assert!(attrs.iter().any(|(k, v)| k == "group" && v == "g"));
assert!(attrs.iter().any(|(k, v)| k == "path" && v == "p"));
assert!(attrs.iter().any(|(k, v)| k == "target_label" && v == "person"));
assert!(attrs.iter().any(|(k, v)| k == "mode" && v == "trail"));
}
#[test]
fn step_kind_enforces_path_modes() {
let nodes = vec![node("hub", "a"), node("hub", "b")];
let start = rid("hub", "a");
let other = rid("hub", "c");
let mid = rid("hub", "b");
assert_eq!(step_kind(PathMode::Walk, &nodes, &start), StepKind::EmitAndExtend);
assert_eq!(step_kind(PathMode::Trail, &nodes, &mid), StepKind::EmitAndExtend);
assert_eq!(step_kind(PathMode::Acyclic, &nodes, &start), StepKind::Reject);
assert_eq!(step_kind(PathMode::Acyclic, &nodes, &mid), StepKind::Reject);
assert_eq!(step_kind(PathMode::Acyclic, &nodes, &other), StepKind::EmitAndExtend);
assert_eq!(step_kind(PathMode::Simple, &nodes, &start), StepKind::EmitOnly);
assert_eq!(step_kind(PathMode::Simple, &nodes, &mid), StepKind::Reject);
assert_eq!(step_kind(PathMode::Simple, &nodes, &other), StepKind::EmitAndExtend);
}
#[test]
fn edge_specs_unbounded_per_table() {
let pe = sample(1, Some(2));
let specs = pe.edge_specs();
assert_eq!(specs.len(), 1);
assert_eq!(specs[0].table, TableName::new("knows"));
assert!(matches!(specs[0].range_start, std::ops::Bound::Unbounded));
}
#[test]
fn partial_path_edge_uniqueness() {
let path = PartialPath {
tip: edge_id("person", "b"),
edges: vec![(edge_id("knows", "1"), edge("knows", "1", "person:a", "person:b"))],
nodes: vec![node("person", "a"), node("person", "b")],
};
assert_eq!(path.depth(), 1);
assert!(path.contains_edge(&edge_id("knows", "1")));
assert!(!path.contains_edge(&edge_id("knows", "2")));
}
}