Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
127fcdd
wip: rollup/cube/grouping sets
techgopal Oct 22, 2026
412e17e
Merge remote-tracking branch 'origin/main' into sql/rollup-cube-group…
techgopal Oct 22, 2026
7bb4224
feat(sql): ROLLUP, CUBE, GROUPING SETS, grouping() and grouping_id() …
techgopal Oct 22, 2026
a41e41e
Address review threads for ROLLUP, CUBE and GROUPING SETS
shehatathe Oct 22, 2026
c997748
optimizer: name the IN-list literals in full (typos lint)
techgopal Oct 22, 2026
b3a96ac
optimizer: finish the literals rename (typos lint)
techgopal Oct 22, 2026
873c73a
optimizer: default selectivity for literal equalities without a disti…
shehatathe Oct 23, 2026
aa2f237
exec, plan-codec: join types that build their preserved side (format v4)
shehatathe Oct 23, 2026
e9c7056
exec: the build side a hash join prefers, from its inputs' statistics
shehatathe Oct 23, 2026
d8601de
Merge branch 'main' into perf/join-orientation-1a
techgopal Oct 23, 2026
73625e4
Merge origin/main into sql/rollup-cube-grouping-sets
techgopal Oct 23, 2026
80469d0
feat(plan-codec): carry the Expand node (format v5)
techgopal Oct 23, 2026
504b482
plan-codec: feature header instead of sequential format versions
shehatathe Oct 23, 2026
1bfbfe9
plan-codec: bound the number of feature ids in a header
shehatathe Oct 23, 2026
2b3faaa
optimizer: join conditions count the keys a filtered input keeps
shehatathe Oct 23, 2026
d3de2a4
Merge the ROLLUP/CUBE branch; map Expand and NullOf to the expand fea…
shehatathe Oct 23, 2026
4c05a6e
optimizer: join on the equalities every disjunct of a condition holds
shehatathe Oct 23, 2026
10e470e
optimizer: take shared equalities out of an `or` only to connect inputs
shehatathe Oct 23, 2026
72eab5c
review: null-safe NULL selectivity, WITH references as default estima…
shehatathe Oct 23, 2026
c8cfa9f
Merge origin/main into feat/plan-codec-features
shehatathe Oct 24, 2026
2d2acee
Merge the plan-codec feature flags into perf/join-orientation-1a
shehatathe Oct 24, 2026
d6c8c51
Merge origin/main into perf/join-orientation-1a
shehatathe Oct 24, 2026
1de5b2e
test(exec): assert the exact join when a default estimate keeps the o…
shehatathe Oct 24, 2026
ffc1d94
Merge remote-tracking branch 'origin/main' into HEAD
shehatathe Oct 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 61 additions & 2 deletions crates/krishiv-exec/src/join/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,10 @@ use crate::operators::Predicate;
use crate::pipeline::{ExecNode, PipelineBuilder};
use crate::rows;

/// The join type. The probe (left) side is preserved by the outer and anti
/// joins; the build (right) side never is.
/// The join type. The probe (left) side is preserved by the left outer and
/// anti joins; the build side is preserved by the right semi, right anti
/// and right outer joins, which build the input whose rows they return
/// (design P2-T166).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum JoinType {
/// Matching pairs.
Expand All @@ -166,12 +168,30 @@ pub enum JoinType {
LeftAnti,
/// `NOT IN`: see the module docs.
NullAwareAnti,
/// Build rows with at least one match, each once: a left semi join
/// that builds its left input. Planned data only for now: no operator
/// runs it yet.
RightSemi,
/// Build rows with no match: a left anti join that builds its left
/// input. Planned data only for now.
RightAnti,
/// Matching pairs, and each unmatched build row with NULL probe
/// columns: a left outer join that builds its preserved input. Planned
/// data only for now.
RightOuter,
}

impl JoinType {
fn emits_build(self) -> bool {
matches!(self, Self::Inner | Self::LeftOuter)
}

/// Whether the join returns rows of its build side by their matches
/// (right semi, right anti, right outer).
#[must_use]
pub fn builds_preserved(self) -> bool {
matches!(self, Self::RightSemi | Self::RightAnti | Self::RightOuter)
}
}

/// What a hash join joins.
Expand Down Expand Up @@ -403,6 +423,11 @@ impl Shared {
"a hash join needs matching, non-empty key lists".to_owned(),
));
}
if join_type.builds_preserved() {
return Err(unsupported(
"hash joins that return rows of their build side by their matches",
));
}
if join_type == JoinType::NullAwareAnti
&& (n != 1 || filter.is_some() || null_safe.contains(&true))
{
Expand Down Expand Up @@ -759,6 +784,40 @@ mod tests {
}
}

/// The joins that build their preserved side are plan data only: no
/// hash join runs them yet, and none is planned.
#[test]
fn joins_that_build_their_preserved_side_are_refused() {
for t in [
JoinType::RightSemi,
JoinType::RightAnti,
JoinType::RightOuter,
] {
assert!(t.builds_preserved());
let e = HashJoin::try_new(spec(t, 1, false))
.err()
.unwrap_or_else(|| panic!("{t:?} was accepted"));
assert_eq!(
e.error_class(),
krishiv_common::error::ErrorClass::KrishivUnsupportedFeature,
"{t:?}"
);
assert!(
e.to_string().contains("return rows of their build side"),
"{e}"
);
}
for t in [
JoinType::Inner,
JoinType::LeftOuter,
JoinType::LeftSemi,
JoinType::LeftAnti,
JoinType::NullAwareAnti,
] {
assert!(!t.builds_preserved(), "{t:?}");
}
}

#[test]
fn specs_are_validated() {
assert!(HashJoin::try_new(spec(JoinType::Inner, 0, false)).is_err());
Expand Down
8 changes: 7 additions & 1 deletion crates/krishiv-exec/src/join/nested.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,17 @@ impl NestedLoopJoin {
///
/// # Errors
/// `KRISHIV_UNSUPPORTED_FEATURE` for the null-aware anti join (it needs
/// an equality key; the planner uses the hash join).
/// an equality key; the planner uses the hash join) and for the joins
/// that return rows of their build side by their matches.
pub fn try_new(spec: NestedLoopSpec) -> Result<Self> {
if spec.join_type == JoinType::NullAwareAnti {
return Err(unsupported("a null-aware anti join without a key"));
}
if spec.join_type.builds_preserved() {
return Err(unsupported(
"nested-loop joins that return rows of their build side by their matches",
));
}
let fields = |nullable: bool| -> Vec<Field> {
spec.probe_schema
.fields()
Expand Down
7 changes: 6 additions & 1 deletion crates/krishiv-exec/src/join/probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,12 @@ impl Cursor {
/// The rows the join type emits once every match is known.
fn tail(&self, shared: &Shared) -> Result<Option<Batch>> {
let want = match shared.join_type {
JoinType::Inner => return Ok(None),
// No probe row waits for the end: inner joins emit pairs as
// they match, and the joins that build their preserved side
// return build rows, never unmatched probe rows.
JoinType::Inner | JoinType::RightSemi | JoinType::RightAnti | JoinType::RightOuter => {
return Ok(None);
}
JoinType::LeftSemi => MATCHED,
JoinType::LeftOuter | JoinType::LeftAnti | JoinType::NullAwareAnti => UNMATCHED,
};
Expand Down
20 changes: 20 additions & 0 deletions crates/krishiv-exec/src/planner/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,26 @@ impl<'a> Planner<'a> {
krishiv_optimizer::estimated_rows(plan, &stats)
}

/// Whether every table `plan` reads reports statistics, so its estimate
/// does not rest on default row counts. A `WITH` reference counts as
/// default-sized, as the estimator treats it.
pub(crate) fn has_statistics(&self, plan: &Arc<LogicalPlan>) -> bool {
use krishiv_optimizer::Statistics as _;
let stats = super::ProviderStatistics(Arc::clone(&self.cx.tables));
let mut stack = vec![plan];
while let Some(p) = stack.pop() {
match &p.node {
PlanNode::Scan(scan) if stats.table(scan).is_none() => return false,
// The estimator reads a reference to a `WITH` definition
// as a default-sized relation, not through its definition.
PlanNode::CteRef(_) => return false,
_ => {}
}
p.for_each_child(&mut |c| stack.push(c));
}
true
}

fn scan(
&mut self,
plan: &Arc<LogicalPlan>,
Expand Down
40 changes: 39 additions & 1 deletion crates/krishiv-exec/src/planner/join.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ use krishiv_plan::{

use super::build::{Planned, Planner, unsupported};
use super::explain::ExplainNode;
use super::orient::{InputStats, StatsSource, orient};
use super::properties::Distribution;
use crate::error::Result;
use crate::join::JoinType;
Expand Down Expand Up @@ -131,8 +132,23 @@ impl Planner<'_> {
let build_exprs: Vec<Arc<Expr>> = keys.iter().map(|k| Arc::clone(&k.1)).collect();
let (probe, probe_keys) = self.with_columns(probe, &probe_exprs)?;
let (build, build_keys) = self.with_columns(build, &build_exprs)?;
let build_plan = if build_left { &logical.0 } else { &logical.1 };
let (probe_plan, build_plan) = if build_left {
(&logical.1, &logical.0)
} else {
(&logical.0, &logical.1)
};
let build_rows = self.estimated_rows(build_plan);
// Which side the statistics say to build (design P2-T166). The
// joins that build their preserved side do not run yet: the plan
// keeps the right build and shows the preference.
let preferred = if choice.distribution.is_none() {
let probe_stats = self.input_stats(probe_plan, &probe.schema);
let build_stats = self.input_stats(build_plan, &build.schema);
let other = orient(join_type, &probe_stats, &build_stats);
(other != join_type).then_some((other, probe_stats, build_stats))
} else {
None
};
// A hint decides, else the build's estimated size. The null-aware
// anti join reads whether the whole build has a NULL key, so its
// build is always broadcast.
Expand Down Expand Up @@ -200,6 +216,13 @@ impl Planner<'_> {
for p in published {
detail.push_str(&format!(", {p}"));
}
if let Some((other, probe_stats, build_stats)) = preferred {
detail.push_str(&format!(
", prefers {other:?} (probe: {}; build: {})",
probe_stats.describe(),
build_stats.describe()
));
}
let (probe_schema, build_schema) = (Arc::clone(&probe.schema), Arc::clone(&build.schema));
let plan = HashJoinPlan {
probe: Box::new(probe.node),
Expand Down Expand Up @@ -245,6 +268,21 @@ fn residual_of(probe: &Planned, build: &Planned, residual: &[&Expr]) -> Option<R
}

impl Planner<'_> {
/// The statistics of a join input planned as `schema` from `plan`.
fn input_stats(&self, plan: &Arc<LogicalPlan>, schema: &arrow_schema::Schema) -> InputStats {
let rows = self.estimated_rows(plan);
let source = if self.has_statistics(plan) {
StatsSource::Statistics
} else {
StatsSource::Default
};
InputStats {
rows,
bytes: estimated_bytes(rows, schema),
source,
}
}

/// A join without an equality conjunct: a nested-loop join building the
/// right input.
fn nested_loop(
Expand Down
1 change: 1 addition & 0 deletions crates/krishiv-exec/src/planner/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ mod explain;
mod file_table;
mod join;
pub(crate) mod nodes;
mod orient;
mod properties;
pub(crate) mod pushdown;
mod runtime_filter;
Expand Down
Loading
Loading