use std::collections::HashSet;
use std::sync::Arc;
use surrealdb_types::ToSql;
use super::common::{RecursionBounds, eval_buffered, is_recursion_target};
use crate::exec::FlowResult;
use crate::exec::parts::recurse::value_hash;
use crate::exec::parts::{evaluate_physical_path, is_final};
use crate::exec::physical_expr::{EvalContext, PhysicalExpr};
use crate::val::Value;
pub(crate) async fn evaluate_recurse_collect(
start: &Value,
path: &[Arc<dyn PhysicalExpr>],
bounds: RecursionBounds,
inclusive: bool,
ctx: EvalContext<'_>,
) -> FlowResult<Value> {
let min_depth = bounds.min;
let max_depth = bounds.cap();
let mut collected = Vec::new();
let mut seen: HashSet<u64> = HashSet::new();
let mut expanded: HashSet<u64> = HashSet::new();
let mut frontier = vec![start.clone()];
if inclusive {
collected.push(start.clone());
seen.insert(value_hash(start));
}
let mut depth = 0u32;
while depth < max_depth && !frontier.is_empty() {
let mut next_frontier = Vec::new();
let collecting = depth + 1 >= min_depth;
let mut level_seen: HashSet<u64> = HashSet::new();
let futures: Vec<_> = frontier
.iter()
.map(|value| evaluate_physical_path(value, path, ctx.with_value(value)))
.collect();
let eval_results = eval_buffered(futures).await?;
for result in eval_results {
let values = match result {
Value::Array(arr) => arr.0,
Value::None | Value::Null => continue,
other => vec![other],
};
for v in values {
if is_final(&v) {
continue;
}
if !is_recursion_target(&v) {
return Err(crate::exec::Error::InvalidRecursionTarget {
value: v.to_sql(),
}
.into());
}
let hash = value_hash(&v);
if collecting {
if seen.insert(hash) {
collected.push(v.clone());
}
if expanded.insert(hash) {
next_frontier.push(v);
}
} else {
if level_seen.insert(hash) {
next_frontier.push(v);
}
}
}
}
frontier = next_frontier;
depth += 1;
}
if bounds.errors_on_limit() && !frontier.is_empty() {
return Err(crate::exec::Error::IdiomRecursionLimitExceeded {
limit: bounds.system_limit,
}
.into());
}
Ok(Value::Array(collected.into()))
}
#[cfg(test)]
mod tests {
use super::super::tests::{
FIXTURES, Raise, SYSTEM_LIMIT, body_path, bounds, exec_error, raise_path,
};
use super::*;
use crate::exec::ExecutionContext;
use crate::exec::operators::test_util::{TestDb, val};
use crate::expr::ControlFlow;
async fn run(
start: &str,
src: &str,
min: u32,
max: Option<u32>,
inclusive: bool,
system_limit: u32,
ctx: &ExecutionContext,
) -> FlowResult<Value> {
let start = val(start).await;
let path = body_path(src, ctx).await;
let base = crate::exec::physical_expr::EvalContext::from_exec_ctx(ctx);
evaluate_recurse_collect(
&start,
&path,
bounds(min, max, system_limit),
inclusive,
base.with_value(&start),
)
.await
}
async fn links(
start: &str,
min: u32,
max: Option<u32>,
inclusive: bool,
ctx: &ExecutionContext,
) -> FlowResult<Value> {
run(start, "link:a.next", min, max, inclusive, SYSTEM_LIMIT, ctx).await
}
#[tokio::test]
async fn every_reachable_node_is_collected_once_in_breadth_first_order() {
let db = TestDb::new(FIXTURES).await;
let ctx = db.exec_ctx().await;
assert_eq!(
links("link:x", 1, Some(1), false, &ctx).await.unwrap(),
val("[link:y, link:z]").await
);
assert_eq!(
links("link:x", 1, Some(2), false, &ctx).await.unwrap(),
val("[link:y, link:z, link:w]").await
);
}
#[tokio::test]
async fn inclusive_collects_the_start_and_never_collects_it_again() {
let db = TestDb::new(FIXTURES).await;
let ctx = db.exec_ctx().await;
assert_eq!(
links("link:p", 1, Some(3), true, &ctx).await.unwrap(),
val("[link:p, link:q]").await
);
}
#[tokio::test]
async fn the_inclusive_start_is_still_expanded_when_a_cycle_returns_to_it() {
let db = TestDb::new(FIXTURES).await;
let ctx = db.exec_ctx().await;
assert_eq!(
links("link:e", 2, Some(3), true, &ctx).await.unwrap(),
val("[link:e, link:f, link:g]").await
);
}
#[tokio::test]
async fn nodes_below_min_depth_are_not_collected_but_are_still_expanded() {
let db = TestDb::new(FIXTURES).await;
let ctx = db.exec_ctx().await;
assert_eq!(
links("link:a", 2, Some(3), false, &ctx).await.unwrap(),
val("[link:c, link:d]").await
);
}
#[tokio::test]
async fn a_node_seen_below_min_depth_is_still_collected_when_re_reached_in_range() {
let db = TestDb::new(FIXTURES).await;
let ctx = db.exec_ctx().await;
assert_eq!(
links("link:p", 2, Some(3), false, &ctx).await.unwrap(),
val("[link:p, link:q]").await
);
}
#[tokio::test]
async fn a_cycle_is_closed_by_the_visited_set_so_an_unbounded_walk_terminates() {
let db = TestDb::new(FIXTURES).await;
let ctx = db.exec_ctx().await;
assert_eq!(
run("link:p", "link:a.next", 1, None, false, 8, &ctx).await.unwrap(),
val("[link:q, link:p]").await
);
}
#[tokio::test]
async fn an_unbounded_walk_still_advancing_at_the_cap_raises_the_limit() {
let db = TestDb::new(FIXTURES).await;
let ctx = db.exec_ctx().await;
let err = run("link:a", "link:a.next", 1, None, false, 2, &ctx).await.unwrap_err();
assert!(matches!(
exec_error(err),
crate::exec::Error::IdiomRecursionLimitExceeded {
limit: 2
}
));
}
#[tokio::test]
async fn an_explicit_bound_truncates_silently_instead_of_raising() {
let db = TestDb::new(FIXTURES).await;
let ctx = db.exec_ctx().await;
assert_eq!(
run("link:a", "link:a.next", 1, Some(2), false, 2, &ctx).await.unwrap(),
val("[link:b, link:c]").await
);
}
#[tokio::test]
async fn a_dead_end_ends_its_own_branch_only() {
let db = TestDb::new(FIXTURES).await;
let ctx = db.exec_ctx().await;
assert_eq!(
links("link:m", 1, Some(3), false, &ctx).await.unwrap(),
val("[link:n, link:o]").await
);
}
#[tokio::test]
async fn a_dead_end_start_collects_nothing_unless_it_is_inclusive() {
let db = TestDb::new(FIXTURES).await;
let ctx = db.exec_ctx().await;
assert_eq!(links("link:d", 1, Some(3), false, &ctx).await.unwrap(), val("[]").await);
assert_eq!(links("link:d", 1, Some(3), true, &ctx).await.unwrap(), val("[link:d]").await);
}
#[tokio::test]
async fn dedup_keys_on_the_value_hash_alone_so_colliding_array_targets_merge() {
let db = TestDb::new(FIXTURES).await;
db.run(
"UPSERT link:h SET next = [
[link:a, link:b, link:c, link:d, link:w, link:x, link:y, link:z, link:m, link:n],
[link:a, link:b, link:c, link:d, link:w, link:x, link:y, link:z, link:o, link:p]
];",
)
.await;
let ctx = db.exec_ctx().await;
assert_eq!(
links("link:h", 1, Some(1), false, &ctx).await.unwrap(),
val(
"[[link:a, link:b, link:c, link:d, link:w, link:x, link:y, link:z, link:m, link:n]]"
)
.await
);
}
#[tokio::test]
async fn a_non_record_value_is_rejected_because_recursion_is_record_only() {
let db = TestDb::new(FIXTURES).await;
let ctx = db.exec_ctx().await;
let err =
run("link:a", "link:a.name", 1, Some(2), false, SYSTEM_LIMIT, &ctx).await.unwrap_err();
match exec_error(err) {
crate::exec::Error::InvalidRecursionTarget {
value,
} => assert_eq!(value, "'A'"),
other => panic!("expected InvalidRecursionTarget, got {other:?}"),
}
}
#[tokio::test]
async fn control_flow_out_of_the_body_aborts_the_traversal() {
let db = TestDb::new(FIXTURES).await;
let ctx = db.exec_ctx().await;
let start = val("link:a").await;
let base = crate::exec::physical_expr::EvalContext::from_exec_ctx(&ctx);
let path = raise_path(Raise::Break);
let err = evaluate_recurse_collect(
&start,
&path,
bounds(1, Some(3), SYSTEM_LIMIT),
false,
base.with_value(&start),
)
.await
.unwrap_err();
assert!(matches!(err, ControlFlow::Break));
}
}