use crate::bridge::envelope::PhysicalPlan;
use crate::bridge::scan_filter::ScanFilter;
use crate::data::executor::task::ExecutionTask;
use nodedb_physical::physical_plan::JoinProjection;
pub(crate) struct JoinParams<'a> {
pub task: &'a ExecutionTask,
pub on: &'a [(String, String)],
pub join_type: &'a str,
pub limit: usize,
pub projection: &'a [JoinProjection],
pub computed_projection_bytes: &'a [u8],
pub join_filter_bytes: &'a [u8],
pub post_filter_bytes: &'a [u8],
}
pub(crate) struct HashJoinParams<'a> {
pub join: JoinParams<'a>,
pub tid: u64,
pub left_collection: &'a str,
pub right_collection: &'a str,
pub left_alias: Option<&'a str>,
pub right_alias: Option<&'a str>,
pub left_input: Option<&'a PhysicalPlan>,
pub right_input: Option<&'a PhysicalPlan>,
pub left_bitmap: Option<&'a PhysicalPlan>,
pub right_bitmap: Option<&'a PhysicalPlan>,
}
pub(crate) struct NestedLoopJoinParams<'a> {
pub task: &'a ExecutionTask,
pub tid: u64,
pub left_collection: &'a str,
pub right_collection: &'a str,
pub condition: &'a [u8],
pub join_type: &'a str,
pub limit: usize,
}
pub(crate) struct SortMergeJoinParams<'a> {
pub task: &'a ExecutionTask,
pub tid: u64,
pub left_collection: &'a str,
pub right_collection: &'a str,
pub on: &'a [(String, String)],
pub join_type: &'a str,
pub limit: usize,
pub pre_sorted: bool,
}
#[cfg(test)]
fn make_dummy_task() -> ExecutionTask {
use crate::bridge::envelope::{PhysicalPlan, Priority};
use crate::types::{DatabaseId, ReadConsistency, RequestId, TenantId, TraceId, VShardId};
use nodedb_physical::physical_plan::DocumentOp;
use std::time::{Duration, Instant};
let request = crate::bridge::envelope::Request {
request_id: RequestId::new(1),
tenant_id: TenantId::new(0),
database_id: DatabaseId::DEFAULT,
vshard_id: VShardId::new(0),
plan: PhysicalPlan::Document(DocumentOp::PointGet {
collection: "test".into(),
document_id: "dummy".into(),
surrogate: nodedb_types::Surrogate::ZERO,
pk_bytes: Vec::new(),
rls_filters: Vec::new(),
system_time: nodedb_types::SystemTimeScope::Current,
valid_at_ms: None,
}),
deadline: Instant::now() + Duration::from_secs(30),
priority: Priority::Normal,
trace_id: TraceId::generate(),
consistency: ReadConsistency::Strong,
idempotency_key: None,
event_source: crate::event::EventSource::User,
user_roles: Vec::new(),
user_id: None,
statement_digest: None,
txn_id: None,
wal_lsn: None,
resolved_now_ms: None,
admission: crate::bridge::envelope::Admission::Exempt(
crate::bridge::envelope::ExemptReason::Read,
),
};
ExecutionTask::new(request)
}
impl JoinParams<'_> {
pub fn filter_and_project(&self, results: &mut Vec<Vec<u8>>) -> crate::Result<()> {
if !self.post_filter_bytes.is_empty() {
let filters: Vec<ScanFilter> =
zerompk::from_msgpack(self.post_filter_bytes).map_err(|e| {
crate::Error::Serialization {
format: "msgpack".into(),
detail: format!("decode join post-filters: {e}"),
}
})?;
if !filters.is_empty() {
results.retain(|row| super::binary_row_matches_filters(row, &filters));
}
}
if !self.computed_projection_bytes.is_empty() {
let computed: Vec<crate::bridge::expr_eval::ComputedColumn> =
zerompk::from_msgpack(self.computed_projection_bytes).map_err(|e| {
crate::Error::Serialization {
format: "msgpack".into(),
detail: format!("decode join computed projection: {e}"),
}
})?;
for row in results.iter_mut() {
*row = crate::data::executor::handlers::document::read::projection::apply_projection_msgpack(
row,
&computed,
&[],
);
}
} else if !self.projection.is_empty() {
for row in results.iter_mut() {
*row = super::binary_row_project(row, self.projection);
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn row_with_score(score: i64) -> Vec<u8> {
nodedb_types::json_to_msgpack(&serde_json::json!({"score": score}))
.expect("encode test row")
}
fn encode_eq99_filter() -> Vec<u8> {
use crate::bridge::scan_filter::{FilterOp, ScanFilter};
let filters = vec![ScanFilter {
field: "score".into(),
op: FilterOp::Eq,
value: nodedb_types::Value::Integer(99),
clauses: Vec::new(),
expr: None,
}];
zerompk::to_msgpack_vec(&filters).expect("encode filters")
}
#[test]
fn empty_post_filter_bytes_is_noop() {
let task = make_dummy_task();
let params = JoinParams {
task: &task,
on: &[],
join_type: "inner",
limit: usize::MAX,
projection: &[],
computed_projection_bytes: &[],
join_filter_bytes: &[],
post_filter_bytes: &[],
};
let mut results = vec![vec![1u8, 2, 3], vec![4u8, 5, 6]];
assert!(params.filter_and_project(&mut results).is_ok());
assert_eq!(results.len(), 2);
}
#[test]
fn corrupt_post_filter_bytes_returns_err_not_silent_noop() {
let task = make_dummy_task();
let corrupt: &[u8] = b"\xff\xfe\xfd this is not valid msgpack \x00";
let params = JoinParams {
task: &task,
on: &[],
join_type: "inner",
limit: usize::MAX,
projection: &[],
computed_projection_bytes: &[],
join_filter_bytes: &[],
post_filter_bytes: corrupt,
};
let mut results = vec![vec![0u8; 8]]; let err = params.filter_and_project(&mut results);
assert!(
err.is_err(),
"corrupt post-filter bytes must return Err, not silently skip the filter"
);
let msg = err.unwrap_err().to_string();
assert!(
msg.contains("decode join post-filters") || msg.contains("serialization"),
"error message should identify the decode failure, got: {msg}"
);
}
#[test]
fn valid_post_filter_retains_matching_rows() {
let task = make_dummy_task();
let filter_bytes = encode_eq99_filter();
let params = JoinParams {
task: &task,
on: &[],
join_type: "inner",
limit: usize::MAX,
projection: &[],
computed_projection_bytes: &[],
join_filter_bytes: &[],
post_filter_bytes: &filter_bytes,
};
let mut results = vec![
row_with_score(99), row_with_score(42), row_with_score(99), ];
assert!(params.filter_and_project(&mut results).is_ok());
assert_eq!(
results.len(),
2,
"only the two score=99 rows should survive the filter"
);
}
}