#![cfg_attr(not(feature = "gql"), allow(dead_code))]
use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use futures::StreamExt;
use crate::exec::{
AccessMode, CardinalityHint, ContextLevel, EvalContext, ExecOperator, ExecutionContext,
FlowResult, OperatorMetrics, PhysicalExpr, ValueBatch, ValueBatchStream, buffer_stream,
monitor_stream,
};
use crate::expr::ControlFlow;
use crate::val::{Object, Value};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JoinType {
Inner,
Left,
Cross,
}
impl JoinType {
fn label(self) -> &'static str {
match self {
JoinType::Inner => "Inner",
JoinType::Left => "Left",
JoinType::Cross => "Cross",
}
}
}
#[derive(Debug, Clone)]
pub struct HashJoin {
pub(crate) build: Arc<dyn ExecOperator>,
pub(crate) probe: Arc<dyn ExecOperator>,
pub(crate) keys: Vec<String>,
pub(crate) join_type: JoinType,
pub(crate) null_template: Vec<String>,
pub(crate) residual: Option<Arc<dyn PhysicalExpr>>,
pub(crate) metrics: Arc<OperatorMetrics>,
}
impl HashJoin {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
build: Arc<dyn ExecOperator>,
probe: Arc<dyn ExecOperator>,
keys: Vec<String>,
join_type: JoinType,
null_template: Vec<String>,
residual: Option<Arc<dyn PhysicalExpr>>,
) -> Self {
Self {
build,
probe,
keys,
join_type,
null_template,
residual,
metrics: Arc::new(OperatorMetrics::new()),
}
}
}
struct JoinSide {
op: Arc<dyn ExecOperator>,
access: AccessMode,
card: CardinalityHint,
}
impl JoinSide {
fn new(op: &Arc<dyn ExecOperator>) -> Self {
Self {
op: Arc::clone(op),
access: op.access_mode(),
card: op.cardinality_hint(),
}
}
fn mutates(&self) -> bool {
self.access.is_read_write()
}
fn stream(&self, ctx: &ExecutionContext, buffer_size: usize) -> FlowResult<ValueBatchStream> {
Ok(buffer_stream(self.op.execute(ctx)?, self.access, self.card, buffer_size))
}
}
impl ExecOperator for HashJoin {
fn name(&self) -> &'static str {
"HashJoin"
}
fn attrs(&self) -> Vec<(String, String)> {
let mut attrs = vec![("type".to_string(), self.join_type.label().to_string())];
if !self.keys.is_empty() {
attrs.push(("keys".to_string(), self.keys.join(", ")));
}
if !self.null_template.is_empty() {
attrs.push(("null_template".to_string(), self.null_template.join(", ")));
}
if let Some(residual) = self.residual.as_ref() {
attrs.push(("residual".to_string(), residual.to_sql()));
}
attrs
}
fn required_context(&self) -> ContextLevel {
let mut level = ContextLevel::Database
.max(self.build.required_context())
.max(self.probe.required_context());
if let Some(residual) = self.residual.as_ref() {
level = level.max(residual.required_context());
}
level
}
fn access_mode(&self) -> AccessMode {
let mut mode = self.build.access_mode().combine(self.probe.access_mode());
if let Some(residual) = self.residual.as_ref() {
mode = mode.combine(residual.access_mode());
}
mode
}
fn cardinality_hint(&self) -> CardinalityHint {
CardinalityHint::Unbounded
}
fn children(&self) -> Vec<&Arc<dyn ExecOperator>> {
vec![&self.build, &self.probe]
}
fn metrics(&self) -> Option<&OperatorMetrics> {
Some(&self.metrics)
}
fn expressions(&self) -> Vec<(&str, &Arc<dyn PhysicalExpr>)> {
match self.residual.as_ref() {
Some(residual) => vec![("residual", residual)],
None => vec![],
}
}
fn execute(&self, ctx: &ExecutionContext) -> FlowResult<ValueBatchStream> {
let buffer_size = ctx.root().ctx.config.operator_buffer_size;
let build = JoinSide::new(&self.build);
let probe = JoinSide::new(&self.probe);
let probe_first = probe.mutates() && !build.mutates();
let eager_build = if probe_first {
None
} else {
Some(build.stream(ctx, buffer_size)?)
};
let eager_probe = if build.mutates() || probe_first {
None
} else {
Some(probe.stream(ctx, buffer_size)?)
};
let keys = self.keys.clone();
let join_type = self.join_type;
let null_template = self.null_template.clone();
let residual = self.residual.clone();
let max_rows = ctx.root().ctx.config.gql_max_join_build_rows;
let max_output_rows = ctx.root().ctx.config.gql_max_output_rows;
let ctx = ctx.clone();
let joined = async_stream::try_stream! {
let prebuffered_probe: Option<Vec<ValueBatch>> = if probe_first {
let probe_stream = probe.stream(&ctx, buffer_size)?;
futures::pin_mut!(probe_stream);
let mut batches: Vec<ValueBatch> = Vec::new();
let mut total: usize = 0;
while let Some(batch_result) = probe_stream.next().await {
crate::exec::operators::check_cancelled(&ctx)?;
let batch = batch_result?;
total += batch.values.len();
if total > max_rows {
Err(ControlFlow::Err(anyhow::anyhow!(crate::err::Error::InvalidStatement(
format!(
"GQL MATCH join exceeded the maximum of {max_rows} buffered \
probe-side rows (configurable via SURREAL_GQL_MAX_JOIN_BUILD_ROWS)"
),
))))?;
}
batches.push(batch);
}
Some(batches)
} else {
None
};
let build_stream = match eager_build {
Some(stream) => stream,
None => build.stream(&ctx, buffer_size)?,
};
let mut table = BuildTable::new();
futures::pin_mut!(build_stream);
while let Some(batch_result) = build_stream.next().await {
crate::exec::operators::check_cancelled(&ctx)?;
let batch = batch_result?;
for row in batch.values {
match join_key(&row, &keys) {
Some(key) => table.insert(key, row, max_rows)?,
None => {}
}
}
}
let probe_stream: ValueBatchStream = match prebuffered_probe {
Some(batches) => Box::pin(futures::stream::iter(batches.into_iter().map(Ok))),
None => match eager_probe {
Some(stream) => stream,
None => probe.stream(&ctx, buffer_size)?,
},
};
let base_eval = EvalContext::from_exec_ctx(&ctx);
let mut emitted: usize = 0;
futures::pin_mut!(probe_stream);
while let Some(batch_result) = probe_stream.next().await {
crate::exec::operators::check_cancelled(&ctx)?;
let batch = batch_result?;
let mut out: Vec<Value> = Vec::new();
for row in batch.values {
match join_type {
JoinType::Cross => {
for build_row in table.all_rows() {
let merged = merge_rows(build_row, &row);
if residual_passes(residual.as_deref(), &base_eval, &merged).await? {
emit(&mut out, &mut emitted, max_output_rows, merged)?;
}
}
}
JoinType::Inner => {
if let Some(key) = join_key(&row, &keys)
&& let Some(matches) = table.get(&key)
{
for build_row in matches {
let merged = merge_rows(build_row, &row);
if residual_passes(residual.as_deref(), &base_eval, &merged).await? {
emit(&mut out, &mut emitted, max_output_rows, merged)?;
}
}
}
}
JoinType::Left => {
let mut matched = false;
if let Some(key) = join_key(&row, &keys)
&& let Some(matches) = table.get(&key)
{
for build_row in matches {
let merged = merge_rows(build_row, &row);
if residual_passes(residual.as_deref(), &base_eval, &merged).await? {
emit(&mut out, &mut emitted, max_output_rows, merged)?;
matched = true;
}
}
}
if !matched {
emit(
&mut out,
&mut emitted,
max_output_rows,
null_filled(&row, &null_template),
)?;
}
}
}
}
if !out.is_empty() {
yield ValueBatch { values: out };
}
}
};
Ok(monitor_stream(Box::pin(joined), "HashJoin", &self.metrics))
}
}
type JoinKey = Vec<Value>;
fn join_key(row: &Value, keys: &[String]) -> Option<JoinKey> {
let Value::Object(obj) = row else {
return None;
};
let mut key = Vec::with_capacity(keys.len());
for name in keys {
let id = binding_id(obj.get(name))?;
key.push(id);
}
Some(key)
}
fn binding_id(slot: Option<&Value>) -> Option<Value> {
match slot {
Some(Value::Object(node)) => match node.get("id") {
Some(Value::Null) | Some(Value::None) | None => None,
Some(id) => Some(id.clone()),
},
Some(Value::RecordId(rid)) => Some(Value::RecordId(rid.clone())),
_ => None,
}
}
fn merge_rows(build_row: &Value, probe_row: &Value) -> Value {
let mut merged = match build_row {
Value::Object(o) => o.clone(),
_ => Object::default(),
};
if let Value::Object(probe) = probe_row {
for (k, v) in probe.iter() {
merged.insert(k.clone(), v.clone());
}
}
Value::Object(merged)
}
fn null_filled(probe_row: &Value, null_template: &[String]) -> Value {
let mut row = match probe_row {
Value::Object(o) => o.clone(),
_ => Object::default(),
};
for name in null_template {
row.insert(name.clone(), Value::Null);
}
Value::Object(row)
}
async fn residual_passes(
residual: Option<&dyn PhysicalExpr>,
base_eval: &EvalContext<'_>,
merged: &Value,
) -> FlowResult<bool> {
match residual {
None => Ok(true),
Some(predicate) => Ok(predicate.evaluate(base_eval.with_value(merged)).await?.is_truthy()),
}
}
fn emit(out: &mut Vec<Value>, emitted: &mut usize, max_rows: usize, row: Value) -> FlowResult<()> {
*emitted += 1;
if *emitted > max_rows {
return Err(crate::exec::operators::gql_output_rows_exceeded(max_rows));
}
out.push(row);
Ok(())
}
struct BuildTable {
buckets: HashMap<u64, Vec<(JoinKey, Vec<Value>)>>,
ordered: Vec<Value>,
rows: usize,
}
impl BuildTable {
fn new() -> Self {
Self {
buckets: HashMap::new(),
ordered: Vec::new(),
rows: 0,
}
}
fn insert(&mut self, key: JoinKey, row: Value, max_rows: usize) -> Result<(), ControlFlow> {
if self.rows >= max_rows {
return Err(ControlFlow::Err(anyhow::anyhow!(crate::err::Error::InvalidStatement(
format!(
"GQL MATCH join exceeded the maximum of {max_rows} build-side rows \
(configurable via SURREAL_GQL_MAX_JOIN_BUILD_ROWS)"
),
))));
}
let hash = hash_key(&key);
let bucket = self.buckets.entry(hash).or_default();
match bucket.iter_mut().find(|(stored, _)| *stored == key) {
Some((_, rows)) => rows.push(row.clone()),
None => bucket.push((key, vec![row.clone()])),
}
self.ordered.push(row);
self.rows += 1;
Ok(())
}
fn get(&self, key: &JoinKey) -> Option<&[Value]> {
let hash = hash_key(key);
let bucket = self.buckets.get(&hash)?;
bucket.iter().find(|(stored, _)| stored == key).map(|(_, rows)| rows.as_slice())
}
fn all_rows(&self) -> impl Iterator<Item = &Value> {
self.ordered.iter()
}
}
fn hash_key(key: &JoinKey) -> u64 {
let mut hasher = DefaultHasher::new();
key.hash(&mut hasher);
hasher.finish()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::exec::operators::test_util::{ValuesOperator, collect, root_ctx};
use crate::val::{RecordId, RecordIdKey, TableName};
fn rid(table: &str, key: &str) -> RecordId {
RecordId {
table: TableName::new(table.to_string()),
key: RecordIdKey::from(key.to_string()),
}
}
fn node_row(pairs: &[(&str, RecordId)]) -> Value {
let mut row = Object::default();
for (binding, id) in pairs {
let mut node = Object::default();
node.insert("id".to_string(), Value::RecordId(id.clone()));
row.insert(binding.to_string(), Value::Object(node));
}
Value::Object(row)
}
fn raw_row(pairs: &[(&str, Value)]) -> Value {
let mut row = Object::default();
for (binding, value) in pairs {
row.insert(binding.to_string(), value.clone());
}
Value::Object(row)
}
fn row_id(row: &Value, binding: &str) -> Option<RecordId> {
let Value::Object(o) = row else {
return None;
};
match o.get(binding) {
Some(Value::Object(node)) => match node.get("id") {
Some(Value::RecordId(rid)) => Some(rid.clone()),
_ => None,
},
_ => None,
}
}
fn join(
build: Vec<Value>,
probe: Vec<Value>,
keys: &[&str],
join_type: JoinType,
null_template: &[&str],
) -> HashJoin {
join_with_residual(build, probe, keys, join_type, null_template, None)
}
fn join_with_residual(
build: Vec<Value>,
probe: Vec<Value>,
keys: &[&str],
join_type: JoinType,
null_template: &[&str],
residual: Option<Arc<dyn PhysicalExpr>>,
) -> HashJoin {
HashJoin::new(
ValuesOperator::new(build),
ValuesOperator::new(probe),
keys.iter().map(|s| s.to_string()).collect(),
join_type,
null_template.iter().map(|s| s.to_string()).collect(),
residual,
)
}
fn const_residual(value: bool) -> Arc<dyn PhysicalExpr> {
Arc::new(crate::exec::physical_expr::Literal(Value::Bool(value))) as Arc<dyn PhysicalExpr>
}
#[tokio::test]
async fn inner_join_matches_on_shared_binding() {
let build = vec![
node_row(&[("a", rid("person", "alice")), ("b", rid("person", "x"))]),
node_row(&[("a", rid("person", "amy")), ("b", rid("person", "y"))]),
];
let probe = vec![node_row(&[("c", rid("person", "carol")), ("b", rid("person", "x"))])];
let op: Arc<dyn ExecOperator> = Arc::new(join(build, probe, &["b"], JoinType::Inner, &[]));
let out = collect(&op, &root_ctx()).await;
assert_eq!(out.len(), 1, "only the b=x build row matches");
let merged = &out[0];
assert_eq!(row_id(merged, "a"), Some(rid("person", "alice")));
assert_eq!(row_id(merged, "b"), Some(rid("person", "x")));
assert_eq!(row_id(merged, "c"), Some(rid("person", "carol")));
}
#[tokio::test]
async fn inner_join_emits_one_row_per_build_match() {
let build = vec![
node_row(&[("a", rid("person", "a1")), ("b", rid("person", "x"))]),
node_row(&[("a", rid("person", "a2")), ("b", rid("person", "x"))]),
];
let probe = vec![node_row(&[("c", rid("person", "carol")), ("b", rid("person", "x"))])];
let op: Arc<dyn ExecOperator> = Arc::new(join(build, probe, &["b"], JoinType::Inner, &[]));
let out = collect(&op, &root_ctx()).await;
assert_eq!(out.len(), 2);
}
#[tokio::test]
async fn inner_join_no_match_emits_nothing() {
let build = vec![node_row(&[("a", rid("person", "alice")), ("b", rid("person", "x"))])];
let probe = vec![node_row(&[("c", rid("person", "carol")), ("b", rid("person", "y"))])];
let op: Arc<dyn ExecOperator> = Arc::new(join(build, probe, &["b"], JoinType::Inner, &[]));
assert!(collect(&op, &root_ctx()).await.is_empty());
}
#[tokio::test]
async fn left_join_miss_null_fills_template() {
let build = vec![node_row(&[("a", rid("person", "alice")), ("b", rid("person", "x"))])];
let probe = vec![node_row(&[("c", rid("person", "carol")), ("b", rid("person", "y"))])];
let op: Arc<dyn ExecOperator> =
Arc::new(join(build, probe, &["b"], JoinType::Left, &["a", "__e0"]));
let out = collect(&op, &root_ctx()).await;
assert_eq!(out.len(), 1);
let row = &out[0];
assert_eq!(row_id(row, "c"), Some(rid("person", "carol")));
assert_eq!(row_id(row, "b"), Some(rid("person", "y")));
let Value::Object(o) = row else {
panic!("expected object");
};
assert_eq!(o.get("a"), Some(&Value::Null));
assert_eq!(o.get("__e0"), Some(&Value::Null));
}
#[tokio::test]
async fn left_join_match_does_not_null_fill() {
let build = vec![node_row(&[("a", rid("person", "alice")), ("b", rid("person", "x"))])];
let probe = vec![node_row(&[("c", rid("person", "carol")), ("b", rid("person", "x"))])];
let op: Arc<dyn ExecOperator> =
Arc::new(join(build, probe, &["b"], JoinType::Left, &["a"]));
let out = collect(&op, &root_ctx()).await;
assert_eq!(out.len(), 1);
assert_eq!(row_id(&out[0], "a"), Some(rid("person", "alice")));
}
#[tokio::test]
async fn cross_join_emits_full_product() {
let build = vec![node_row(&[("a", rid("t", "1"))]), node_row(&[("a", rid("t", "2"))])];
let probe = vec![
node_row(&[("b", rid("t", "x"))]),
node_row(&[("b", rid("t", "y"))]),
node_row(&[("b", rid("t", "z"))]),
];
let op: Arc<dyn ExecOperator> = Arc::new(join(build, probe, &[], JoinType::Cross, &[]));
let out = collect(&op, &root_ctx()).await;
assert_eq!(out.len(), 6);
for row in &out {
assert!(row_id(row, "a").is_some());
assert!(row_id(row, "b").is_some());
}
}
#[tokio::test]
async fn cross_join_output_order_is_deterministic() {
let build = vec![
node_row(&[("a", rid("t", "1"))]),
node_row(&[("a", rid("t", "2"))]),
node_row(&[("a", rid("t", "3"))]),
];
let probe = vec![node_row(&[("b", rid("t", "x"))])];
let op: Arc<dyn ExecOperator> = Arc::new(join(build, probe, &[], JoinType::Cross, &[]));
let out = collect(&op, &root_ctx()).await;
let a_ids: Vec<_> = out.iter().filter_map(|r| row_id(r, "a")).collect();
assert_eq!(a_ids, vec![rid("t", "1"), rid("t", "2"), rid("t", "3")]);
}
#[tokio::test]
async fn left_join_residual_false_null_fills_not_drops() {
let build = vec![node_row(&[("a", rid("person", "alice")), ("b", rid("person", "x"))])];
let probe = vec![node_row(&[("c", rid("person", "carol")), ("b", rid("person", "x"))])];
let op: Arc<dyn ExecOperator> = Arc::new(join_with_residual(
build,
probe,
&["b"],
JoinType::Left,
&["a"],
Some(const_residual(false)),
));
let out = collect(&op, &root_ctx()).await;
assert_eq!(out.len(), 1, "the probe row survives, null-filled");
assert_eq!(row_id(&out[0], "c"), Some(rid("person", "carol")));
let Value::Object(o) = &out[0] else {
panic!("expected object");
};
assert_eq!(o.get("a"), Some(&Value::Null), "build binding nulled on residual miss");
}
#[tokio::test]
async fn left_join_residual_true_keeps_match() {
let build = vec![node_row(&[("a", rid("person", "alice")), ("b", rid("person", "x"))])];
let probe = vec![node_row(&[("c", rid("person", "carol")), ("b", rid("person", "x"))])];
let op: Arc<dyn ExecOperator> = Arc::new(join_with_residual(
build,
probe,
&["b"],
JoinType::Left,
&["a"],
Some(const_residual(true)),
));
let out = collect(&op, &root_ctx()).await;
assert_eq!(out.len(), 1);
assert_eq!(row_id(&out[0], "a"), Some(rid("person", "alice")), "match kept, not nulled");
}
#[tokio::test]
async fn inner_join_residual_false_drops_match() {
let build = vec![node_row(&[("a", rid("person", "alice")), ("b", rid("person", "x"))])];
let probe = vec![node_row(&[("c", rid("person", "carol")), ("b", rid("person", "x"))])];
let op: Arc<dyn ExecOperator> = Arc::new(join_with_residual(
build,
probe,
&["b"],
JoinType::Inner,
&[],
Some(const_residual(false)),
));
assert!(collect(&op, &root_ctx()).await.is_empty());
}
#[tokio::test]
async fn null_key_excluded_from_build_inner() {
let build = vec![
raw_row(&[("a", Value::RecordId(rid("person", "anon"))), ("b", Value::Null)]),
node_row(&[("a", rid("person", "alice")), ("b", rid("person", "x"))]),
];
let probe = vec![
node_row(&[("c", rid("person", "carol")), ("b", rid("person", "x"))]),
raw_row(&[("c", Value::RecordId(rid("person", "dave"))), ("b", Value::Null)]),
];
let op: Arc<dyn ExecOperator> = Arc::new(join(build, probe, &["b"], JoinType::Inner, &[]));
let out = collect(&op, &root_ctx()).await;
assert_eq!(out.len(), 1);
assert_eq!(row_id(&out[0], "a"), Some(rid("person", "alice")));
}
#[tokio::test]
async fn null_key_probe_passes_through_under_left() {
let build = vec![node_row(&[("a", rid("person", "alice")), ("b", rid("person", "x"))])];
let probe =
vec![raw_row(&[("c", Value::RecordId(rid("person", "dave"))), ("b", Value::Null)])];
let op: Arc<dyn ExecOperator> =
Arc::new(join(build, probe, &["b"], JoinType::Left, &["a"]));
let out = collect(&op, &root_ctx()).await;
assert_eq!(out.len(), 1);
let Value::Object(o) = &out[0] else {
panic!("expected object");
};
assert_eq!(o.get("c"), Some(&Value::RecordId(rid("person", "dave"))));
assert_eq!(o.get("a"), Some(&Value::Null));
}
#[tokio::test]
async fn empty_build_inner_yields_nothing_left_passes_through() {
let probe = vec![node_row(&[("c", rid("person", "carol")), ("b", rid("person", "x"))])];
let inner: Arc<dyn ExecOperator> =
Arc::new(join(Vec::new(), probe.clone(), &["b"], JoinType::Inner, &[]));
assert!(collect(&inner, &root_ctx()).await.is_empty());
let left: Arc<dyn ExecOperator> =
Arc::new(join(Vec::new(), probe, &["b"], JoinType::Left, &["a"]));
let out = collect(&left, &root_ctx()).await;
assert_eq!(out.len(), 1);
let Value::Object(o) = &out[0] else {
panic!("expected object");
};
assert_eq!(o.get("a"), Some(&Value::Null));
}
#[tokio::test]
async fn empty_keys_left_is_uncorrelated_outer_join() {
let build = vec![node_row(&[("b", rid("t", "x"))]), node_row(&[("b", rid("t", "y"))])];
let probe = vec![node_row(&[("a", rid("t", "1"))]), node_row(&[("a", rid("t", "2"))])];
let op: Arc<dyn ExecOperator> =
Arc::new(join(build, probe.clone(), &[], JoinType::Left, &["b"]));
let out = collect(&op, &root_ctx()).await;
assert_eq!(out.len(), 4);
for row in &out {
assert!(row_id(row, "a").is_some());
assert!(row_id(row, "b").is_some());
}
let empty: Arc<dyn ExecOperator> =
Arc::new(join(Vec::new(), probe, &[], JoinType::Left, &["b"]));
let out = collect(&empty, &root_ctx()).await;
assert_eq!(out.len(), 2);
for row in &out {
let Value::Object(o) = row else {
panic!("expected object");
};
assert_eq!(o.get("b"), Some(&Value::Null));
}
}
#[tokio::test]
async fn multi_key_join_uses_all_key_bindings() {
let build =
vec![node_row(&[("a", rid("t", "1")), ("b", rid("t", "2")), ("x", rid("t", "build"))])];
let probe = vec![
node_row(&[("a", rid("t", "1")), ("b", rid("t", "2")), ("y", rid("t", "p1"))]),
node_row(&[("a", rid("t", "1")), ("b", rid("t", "9")), ("y", rid("t", "p2"))]),
];
let op: Arc<dyn ExecOperator> =
Arc::new(join(build, probe, &["a", "b"], JoinType::Inner, &[]));
let out = collect(&op, &root_ctx()).await;
assert_eq!(out.len(), 1);
assert_eq!(row_id(&out[0], "y"), Some(rid("t", "p1")));
assert_eq!(row_id(&out[0], "x"), Some(rid("t", "build")));
}
#[test]
fn build_table_guard_names_the_knob() {
let mut table = BuildTable::new();
assert!(table.insert(vec![Value::from(1)], Value::from(10), 1).is_ok());
let err = table.insert(vec![Value::from(2)], Value::from(20), 1).unwrap_err();
let msg = match err {
ControlFlow::Err(e) => e.to_string(),
other => panic!("expected error, got {other:?}"),
};
assert!(
msg.contains("SURREAL_GQL_MAX_JOIN_BUILD_ROWS"),
"guard error must name the knob, got: {msg}"
);
}
#[test]
fn build_table_budget_counts_rows_not_keys() {
let mut table = BuildTable::new();
assert!(table.insert(vec![Value::from(1)], Value::from(10), 2).is_ok());
assert!(table.insert(vec![Value::from(1)], Value::from(11), 2).is_ok());
let err = table.insert(vec![Value::from(1)], Value::from(12), 2).unwrap_err();
assert!(matches!(err, ControlFlow::Err(_)));
assert_eq!(table.get(&vec![Value::from(1)]).map(|r| r.len()), Some(2));
}
#[test]
fn binding_id_extraction_rules() {
let node = node_row(&[("a", rid("t", "1"))]);
let Value::Object(o) = &node else {
panic!();
};
assert_eq!(binding_id(o.get("a")), Some(Value::RecordId(rid("t", "1"))));
assert_eq!(
binding_id(Some(&Value::RecordId(rid("t", "2")))),
Some(Value::RecordId(rid("t", "2")))
);
assert_eq!(binding_id(Some(&Value::Null)), None);
assert_eq!(binding_id(Some(&Value::None)), None);
assert_eq!(binding_id(None), None);
assert_eq!(binding_id(Some(&Value::Bool(true))), None);
let mut no_id = Object::default();
no_id.insert("name".to_string(), Value::from("x"));
assert_eq!(binding_id(Some(&Value::Object(no_id))), None);
let mut null_id = Object::default();
null_id.insert("id".to_string(), Value::Null);
assert_eq!(binding_id(Some(&Value::Object(null_id))), None);
}
#[test]
fn join_key_returns_none_on_any_null_slot() {
let row = raw_row(&[("a", Value::RecordId(rid("t", "1"))), ("b", Value::Null)]);
assert_eq!(join_key(&row, &["a".to_string(), "b".to_string()]), None);
}
#[test]
fn name_and_attrs() {
let op = join(Vec::new(), Vec::new(), &["b"], JoinType::Inner, &[]);
assert_eq!(op.name(), "HashJoin");
let attrs = op.attrs();
assert!(attrs.iter().any(|(k, v)| k == "type" && v == "Inner"));
assert!(attrs.iter().any(|(k, v)| k == "keys" && v == "b"));
let left = join(Vec::new(), Vec::new(), &["b"], JoinType::Left, &["a", "k"]);
let left_attrs = left.attrs();
assert!(left_attrs.iter().any(|(k, v)| k == "null_template" && v == "a, k"));
let cross = join(Vec::new(), Vec::new(), &[], JoinType::Cross, &[]);
let cross_attrs = cross.attrs();
assert!(cross_attrs.iter().any(|(k, v)| k == "type" && v == "Cross"));
assert!(!cross_attrs.iter().any(|(k, _)| k == "keys"));
}
#[test]
fn children_are_build_then_probe() {
let op = join(Vec::new(), Vec::new(), &["b"], JoinType::Inner, &[]);
assert_eq!(op.children().len(), 2);
}
}