#![forbid(unsafe_code)]
use std::fmt;
pub mod cep;
pub mod expression;
pub mod governance;
mod graph;
mod lowering;
pub mod optimizer;
pub mod task_fragment;
pub mod udf;
pub mod window;
pub use expression::{
AggregateFunction as ExprAggregateFunction, BinaryOperator as ExprBinaryOperator,
EXPRESSION_FORMAT_VERSION, Expr, ExprDataType, ExprField, IntervalUnit, NullOrdering,
ScalarValue, SortDirection, TimeUnit,
};
pub use graph::lower_to_physical;
pub use task_fragment::{
TASK_FRAGMENT_VERSION, TypedTaskFragment, encode_typed_task_fragment,
execution_kind_from_fragment, task_body_for_profile, validate_job_fragments,
};
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum PlanError {
#[error("plan parse error: {0}")]
Parse(String),
#[error("plan encode error: {0}")]
Encode(String),
#[error("plan validation error: {0}")]
Validation(String),
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum FieldType {
Boolean,
Int32,
Int64,
Float64,
Utf8,
Binary,
Timestamp,
Variant,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SchemaField {
name: String,
field_type: FieldType,
nullable: bool,
}
impl SchemaField {
pub fn new(name: impl Into<String>, field_type: FieldType) -> Self {
Self {
name: name.into(),
field_type,
nullable: false,
}
}
#[must_use]
pub fn with_nullable(mut self, nullable: bool) -> Self {
self.nullable = nullable;
self
}
pub fn name(&self) -> &str {
&self.name
}
pub fn field_type(&self) -> &FieldType {
&self.field_type
}
pub fn nullable(&self) -> bool {
self.nullable
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub struct PlanSchema {
fields: Vec<SchemaField>,
}
impl PlanSchema {
pub fn new(fields: Vec<SchemaField>) -> Self {
Self { fields }
}
pub fn fields(&self) -> &[SchemaField] {
&self.fields
}
pub fn is_empty(&self) -> bool {
self.fields.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum JoinType {
Inner,
Left,
Right,
Full,
Semi,
Anti,
LeftSemi,
RightSemi,
LeftAnti,
RightAnti,
Cross,
NestedLoop,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum NodeOp {
Scan { table: String, filters: Vec<String> },
Filter { predicate: String },
Project { columns: Vec<String> },
Aggregate { group_keys: Vec<String> },
Join { join_type: JoinType },
Exchange { partitioning: Partitioning },
Sink { format: String },
CoalescePartitions {
target_partitions: usize,
},
CreateLiveTable { name: String, query: String },
RefreshLiveTable { name: String },
DropLiveTable { name: String },
KeyBy { key_column: String },
Watermark {
event_time_column: String,
lag_ms: u64,
},
Window {
spec: Box<window::WindowExecutionSpec>,
},
StreamSource { source_id: String, bounded: bool },
StateTtl { ttl_ms: u64 },
GlobalSort {
keys: Vec<(String, bool)>,
},
SortMergeJoin {
join_type: JoinType,
left_keys: Vec<String>,
right_keys: Vec<String>,
},
WindowJoin {
join_type: JoinType,
left_keys: Vec<String>,
right_keys: Vec<String>,
time_column: String,
window_ms: u64,
},
Unnest {
array_column: String,
output_column: String,
with_ordinality: bool,
},
Cep {
key_column: String,
event_time_column: String,
stage_column: String,
},
SkewJoin {
keys: Vec<String>,
factor: u32,
join_type: JoinType,
},
Other { description: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum ExecutionKind {
Batch,
Streaming,
DeltaBatch,
}
impl fmt::Display for ExecutionKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Batch => f.write_str("batch"),
Self::Streaming => f.write_str("streaming"),
Self::DeltaBatch => f.write_str("delta-batch"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Partitioning {
Unpartitioned,
Hash {
keys: Vec<String>,
buckets: u32,
},
RoundRobin {
buckets: u32,
},
Broadcast,
Range {
keys: Vec<(String, bool)>,
boundaries: Vec<String>,
buckets: u32,
},
}
impl fmt::Display for Partitioning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Unpartitioned => f.write_str("unpartitioned"),
Self::Hash { keys, buckets } => {
write!(f, "hash({}, buckets={})", keys.join(", "), buckets)
}
Self::RoundRobin { buckets } => write!(f, "round-robin(buckets={})", buckets),
Self::Broadcast => f.write_str("broadcast"),
Self::Range { keys, buckets, .. } => {
let key_str = keys
.iter()
.map(|(c, asc)| format!("{} {}", c, if *asc { "ASC" } else { "DESC" }))
.collect::<Vec<_>>()
.join(", ");
write!(f, "range({key_str}, buckets={buckets})")
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PlanNode {
id: String,
label: String,
kind: ExecutionKind,
inputs: Vec<String>,
partitioning: Partitioning,
broadcast_eligible: bool,
estimated_rows: Option<u64>,
op: Option<NodeOp>,
output_schema: PlanSchema,
}
impl PlanNode {
pub fn new(id: impl Into<String>, label: impl Into<String>, kind: ExecutionKind) -> Self {
Self {
id: id.into(),
label: label.into(),
kind,
inputs: Vec::new(),
partitioning: Partitioning::Unpartitioned,
broadcast_eligible: false,
estimated_rows: None,
op: None,
output_schema: PlanSchema::default(),
}
}
#[must_use]
pub fn with_inputs(mut self, inputs: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.inputs = inputs.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = label.into();
self
}
#[must_use]
pub fn with_partitioning(mut self, partitioning: Partitioning) -> Self {
self.partitioning = partitioning;
self
}
#[must_use]
pub fn with_broadcast_eligible(mut self, broadcast_eligible: bool) -> Self {
self.broadcast_eligible = broadcast_eligible;
self
}
#[must_use]
pub fn with_exchange(
self,
key_columns: impl IntoIterator<Item = impl Into<String>>,
num_partitions: u32,
) -> Self {
self.with_partitioning(Partitioning::Hash {
keys: key_columns.into_iter().map(Into::into).collect(),
buckets: num_partitions,
})
}
#[must_use]
pub fn with_estimated_rows(mut self, estimated_rows: Option<u64>) -> Self {
self.estimated_rows = estimated_rows;
self
}
#[must_use]
pub fn with_op(mut self, op: NodeOp) -> Self {
self.op = Some(op);
self
}
#[must_use]
pub fn with_output_schema(mut self, schema: PlanSchema) -> Self {
self.output_schema = schema;
self
}
pub fn id(&self) -> &str {
&self.id
}
pub fn label(&self) -> &str {
&self.label
}
pub fn kind(&self) -> ExecutionKind {
self.kind
}
pub fn inputs(&self) -> &[String] {
&self.inputs
}
pub fn partitioning(&self) -> &Partitioning {
&self.partitioning
}
pub fn set_partitioning(&mut self, partitioning: Partitioning) {
self.partitioning = partitioning;
}
pub fn broadcast_eligible(&self) -> bool {
self.broadcast_eligible
}
pub fn estimated_rows(&self) -> Option<u64> {
self.estimated_rows
}
pub fn op(&self) -> Option<&NodeOp> {
self.op.as_ref()
}
pub fn output_schema(&self) -> &PlanSchema {
&self.output_schema
}
}
pub const MAX_PLAN_NODES: usize = 10_000;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub(crate) struct PlanCore {
pub(crate) name: String,
pub(crate) kind: ExecutionKind,
pub(crate) nodes: Vec<PlanNode>,
shuffle_partitions: Option<u32>,
}
impl PlanCore {
fn new(name: impl Into<String>, kind: ExecutionKind) -> Self {
Self {
name: name.into(),
kind,
nodes: Vec::new(),
shuffle_partitions: None,
}
}
fn add_node(&mut self, node: PlanNode) {
self.nodes.push(node);
}
fn with_node(mut self, node: PlanNode) -> Self {
self.add_node(node);
self
}
fn name(&self) -> &str {
&self.name
}
fn kind(&self) -> ExecutionKind {
self.kind
}
fn nodes(&self) -> &[PlanNode] {
&self.nodes
}
fn nodes_mut(&mut self) -> &mut [PlanNode] {
&mut self.nodes
}
fn shuffle_partitions(&self) -> Option<u32> {
self.shuffle_partitions
}
fn with_shuffle_partitions(mut self, n: Option<u32>) -> Self {
self.shuffle_partitions = n;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct LogicalPlan {
pub(crate) core: PlanCore,
}
impl LogicalPlan {
pub fn new(name: impl Into<String>, kind: ExecutionKind) -> Self {
Self {
core: PlanCore::new(name, kind),
}
}
pub fn add_node(&mut self, node: PlanNode) {
self.core.add_node(node);
}
#[must_use]
pub fn with_node(mut self, node: PlanNode) -> Self {
self.core = self.core.with_node(node);
self
}
pub fn name(&self) -> &str {
self.core.name()
}
pub fn kind(&self) -> ExecutionKind {
self.core.kind()
}
pub fn nodes(&self) -> &[PlanNode] {
self.core.nodes()
}
pub fn validate(&self) -> Result<(), PlanError> {
graph::validate_plan("logical", self.name(), self.nodes())
}
pub fn describe(&self) -> String {
describe_plan(
"logical",
self.core.name(),
self.core.kind(),
self.core.nodes(),
)
}
pub fn shuffle_partitions(&self) -> Option<u32> {
self.core.shuffle_partitions()
}
#[must_use]
pub fn with_shuffle_partitions(mut self, n: Option<u32>) -> Self {
self.core = self.core.with_shuffle_partitions(n);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PhysicalPlan {
pub(crate) core: PlanCore,
coalesced_partition_count: Option<usize>,
}
impl PhysicalPlan {
pub fn new(name: impl Into<String>, kind: ExecutionKind) -> Self {
Self {
core: PlanCore::new(name, kind),
coalesced_partition_count: None,
}
}
pub fn coalesced_partition_count(&self) -> Option<usize> {
self.coalesced_partition_count
}
#[must_use]
pub fn with_coalesced_partition_count(mut self, count: usize) -> Self {
self.coalesced_partition_count = Some(count);
self
}
pub fn add_node(&mut self, node: PlanNode) {
self.core.add_node(node);
}
#[must_use]
pub fn with_node(mut self, node: PlanNode) -> Self {
self.core = self.core.with_node(node);
self
}
pub fn name(&self) -> &str {
self.core.name()
}
pub fn kind(&self) -> ExecutionKind {
self.core.kind()
}
pub fn nodes(&self) -> &[PlanNode] {
self.core.nodes()
}
pub fn nodes_mut(&mut self) -> &mut [PlanNode] {
self.core.nodes_mut()
}
pub fn shuffle_partitions(&self) -> Option<u32> {
self.core.shuffle_partitions()
}
#[must_use]
pub fn with_shuffle_partitions(mut self, n: Option<u32>) -> Self {
self.core = self.core.with_shuffle_partitions(n);
self
}
pub fn validate(&self) -> Result<(), PlanError> {
graph::validate_plan("physical", self.name(), self.nodes())
}
pub fn describe(&self) -> String {
describe_plan(
"physical",
self.core.name(),
self.core.kind(),
self.core.nodes(),
)
}
}
fn describe_plan(plan_type: &str, name: &str, kind: ExecutionKind, nodes: &[PlanNode]) -> String {
let mut output = format!("{plan_type} plan: {name}\nkind: {kind}\nnodes:");
if nodes.is_empty() {
output.push_str(" <empty>");
return output;
}
for node in nodes {
output.push_str(&format!(
"\n- {} [{}] {}",
node.id(),
node.kind(),
node.label()
));
if !node.inputs().is_empty() {
output.push_str(&format!(" <- {}", node.inputs().join(", ")));
}
if node.partitioning() != &Partitioning::Unpartitioned {
output.push_str(&format!(" [partitioning: {}]", node.partitioning()));
}
if node.broadcast_eligible() {
output.push_str(" [broadcast-eligible]");
}
if let Some(rows) = node.estimated_rows() {
output.push_str(&format!(" [est-rows: {rows}]"));
}
}
output
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PlanDiff {
pub added: Vec<String>,
pub removed: Vec<String>,
pub changed: Vec<String>,
}
impl PlanDiff {
pub fn is_empty(&self) -> bool {
self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty()
}
}
#[must_use]
pub fn diff_plans(before: &PhysicalPlan, after: &PhysicalPlan) -> PlanDiff {
use std::collections::HashMap;
let before_map: HashMap<&str, &PlanNode> = before.nodes().iter().map(|n| (n.id(), n)).collect();
let after_map: HashMap<&str, &PlanNode> = after.nodes().iter().map(|n| (n.id(), n)).collect();
let mut added = Vec::new();
let mut removed = Vec::new();
let mut changed = Vec::new();
for (id, after_node) in &after_map {
match before_map.get(id) {
None => added.push((*id).to_owned()),
Some(before_node) => {
let structurally_different = before_node.label() != after_node.label()
|| before_node.op() != after_node.op()
|| before_node.inputs() != after_node.inputs()
|| before_node.partitioning() != after_node.partitioning()
|| before_node.estimated_rows() != after_node.estimated_rows()
|| before_node.output_schema() != after_node.output_schema();
if structurally_different {
changed.push((*id).to_owned());
}
}
}
}
for id in before_map.keys() {
if !after_map.contains_key(id) {
removed.push((*id).to_owned());
}
}
added.sort();
removed.sort();
changed.sort();
PlanDiff {
added,
removed,
changed,
}
}
#[cfg(test)]
mod gap_tests;
#[cfg(test)]
mod tests {
use super::{
ExecutionKind, FieldType, JoinType, LogicalPlan, NodeOp, Partitioning, PhysicalPlan,
PlanNode, PlanSchema, SchemaField,
};
#[test]
fn describes_logical_plan_with_nodes() {
let plan = LogicalPlan::new("demo", ExecutionKind::Batch).with_node(PlanNode::new(
"scan",
"scan parquet",
ExecutionKind::Batch,
));
let description = plan.describe();
assert!(description.contains("logical plan: demo"));
assert!(description.contains("scan parquet"));
}
#[test]
fn join_type_variants_are_distinct() {
let all = [
JoinType::Inner,
JoinType::Left,
JoinType::Right,
JoinType::Full,
JoinType::Semi,
JoinType::Anti,
JoinType::LeftSemi,
JoinType::RightSemi,
JoinType::LeftAnti,
JoinType::RightAnti,
JoinType::Cross,
JoinType::NestedLoop,
];
assert_ne!(JoinType::LeftSemi, JoinType::Inner);
assert_ne!(JoinType::RightSemi, JoinType::Inner);
assert_ne!(JoinType::LeftAnti, JoinType::Inner);
assert_ne!(JoinType::RightAnti, JoinType::Inner);
assert_ne!(JoinType::LeftSemi, JoinType::Semi);
assert_ne!(JoinType::LeftAnti, JoinType::Anti);
for (i, a) in all.iter().enumerate() {
for b in &all[i + 1..] {
assert_ne!(a, b, "{a:?} and {b:?} must be distinct");
}
}
}
#[test]
fn plan_node_default_annotations() {
let node = PlanNode::new("n1", "label", ExecutionKind::Batch);
assert_eq!(node.partitioning(), &Partitioning::Unpartitioned);
assert!(!node.broadcast_eligible());
assert_eq!(node.estimated_rows(), None);
}
#[test]
fn plan_node_builder_methods() {
let node = PlanNode::new("n1", "label", ExecutionKind::Batch)
.with_partitioning(Partitioning::Hash {
keys: vec!["region".to_string()],
buckets: 8,
})
.with_broadcast_eligible(true)
.with_estimated_rows(Some(1_000));
assert_eq!(
node.partitioning(),
&Partitioning::Hash {
keys: vec!["region".to_string()],
buckets: 8,
}
);
assert!(node.broadcast_eligible());
assert_eq!(node.estimated_rows(), Some(1_000));
}
#[test]
fn plan_node_round_robin_partitioning() {
let node = PlanNode::new("n1", "label", ExecutionKind::Batch)
.with_partitioning(Partitioning::RoundRobin { buckets: 4 });
assert_eq!(
node.partitioning(),
&Partitioning::RoundRobin { buckets: 4 }
);
}
#[test]
fn plan_node_broadcast_partitioning() {
let node = PlanNode::new("n1", "label", ExecutionKind::Batch)
.with_partitioning(Partitioning::Broadcast);
assert_eq!(node.partitioning(), &Partitioning::Broadcast);
}
#[test]
fn describe_shows_partitioning_when_not_unpartitioned() {
let plan = LogicalPlan::new("q", ExecutionKind::Batch).with_node(
PlanNode::new("agg", "aggregate", ExecutionKind::Batch).with_partitioning(
Partitioning::Hash {
keys: vec!["city".to_string()],
buckets: 16,
},
),
);
let desc = plan.describe();
assert!(desc.contains("partitioning: hash(city, buckets=16)"));
}
#[test]
fn describe_does_not_show_partitioning_when_unpartitioned() {
let plan = LogicalPlan::new("q", ExecutionKind::Batch).with_node(PlanNode::new(
"scan",
"scan",
ExecutionKind::Batch,
));
let desc = plan.describe();
assert!(!desc.contains("partitioning:"));
}
#[test]
fn physical_plan_with_broadcast_node() {
let plan = PhysicalPlan::new("p", ExecutionKind::Batch).with_node(
PlanNode::new("dim", "dim scan", ExecutionKind::Batch)
.with_partitioning(Partitioning::Broadcast)
.with_broadcast_eligible(true)
.with_estimated_rows(Some(500)),
);
let node = &plan.nodes()[0];
assert_eq!(node.partitioning(), &Partitioning::Broadcast);
assert!(node.broadcast_eligible());
assert_eq!(node.estimated_rows(), Some(500));
let desc = plan.describe();
assert!(desc.contains("broadcast"));
}
#[test]
fn plan_node_with_typed_op() {
let node =
PlanNode::new("scan", "scan parquet", ExecutionKind::Batch).with_op(NodeOp::Scan {
table: String::from("orders"),
filters: vec![],
});
assert!(matches!(node.op(), Some(NodeOp::Scan { table, .. }) if table == "orders"));
}
#[test]
fn plan_node_schema_propagation() {
let schema = PlanSchema::new(vec![
SchemaField::new("id", FieldType::Int64),
SchemaField::new("name", FieldType::Utf8).with_nullable(true),
]);
let node = PlanNode::new("proj", "project", ExecutionKind::Batch)
.with_op(NodeOp::Project {
columns: vec![String::from("id"), String::from("name")],
})
.with_output_schema(schema);
assert_eq!(node.output_schema().fields().len(), 2);
assert_eq!(node.output_schema().fields()[0].name(), "id");
assert_eq!(
node.output_schema().fields()[0].field_type(),
&FieldType::Int64
);
assert!(!node.output_schema().fields()[0].nullable());
assert!(node.output_schema().fields()[1].nullable());
}
#[test]
fn plan_schema_empty_by_default() {
let node = PlanNode::new("n1", "label", ExecutionKind::Batch);
assert!(node.output_schema().is_empty());
}
#[test]
fn node_op_variants_round_trip() {
let ops: Vec<NodeOp> = vec![
NodeOp::Scan {
table: String::from("t1"),
filters: vec![],
},
NodeOp::Filter {
predicate: String::new(),
},
NodeOp::Project {
columns: vec![String::from("a")],
},
NodeOp::Aggregate {
group_keys: vec![String::from("region")],
},
NodeOp::Join {
join_type: JoinType::Inner,
},
NodeOp::Exchange {
partitioning: Partitioning::Broadcast,
},
NodeOp::Sink {
format: String::from("parquet"),
},
NodeOp::CoalescePartitions {
target_partitions: 4,
},
NodeOp::Other {
description: String::from("custom"),
},
];
for op in &ops {
let cloned = op.clone();
assert_eq!(&cloned, op);
let _ = format!("{cloned:?}");
}
}
#[test]
fn partitioning_display() {
assert_eq!(Partitioning::Unpartitioned.to_string(), "unpartitioned");
assert_eq!(
Partitioning::Hash {
keys: vec!["a".to_string(), "b".to_string()],
buckets: 4
}
.to_string(),
"hash(a, b, buckets=4)"
);
assert_eq!(
Partitioning::RoundRobin { buckets: 2 }.to_string(),
"round-robin(buckets=2)"
);
assert_eq!(Partitioning::Broadcast.to_string(), "broadcast");
}
fn make_plan(nodes: &[(&str, &str)]) -> PhysicalPlan {
let mut plan = PhysicalPlan::new("test", ExecutionKind::Batch);
for (id, label) in nodes {
plan.add_node(PlanNode::new(*id, *label, ExecutionKind::Batch));
}
plan
}
#[test]
fn diff_plans_identical_is_empty() {
let p = make_plan(&[("scan", "Scan"), ("agg", "Aggregate")]);
let diff = super::diff_plans(&p, &p);
assert!(diff.is_empty());
}
#[test]
fn diff_plans_added_node() {
let before = make_plan(&[("scan", "Scan")]);
let after = make_plan(&[("scan", "Scan"), ("filter", "Filter")]);
let diff = super::diff_plans(&before, &after);
assert_eq!(diff.added, vec!["filter"]);
assert!(diff.removed.is_empty());
assert!(diff.changed.is_empty());
}
#[test]
fn diff_plans_removed_node() {
let before = make_plan(&[("scan", "Scan"), ("filter", "Filter")]);
let after = make_plan(&[("scan", "Scan")]);
let diff = super::diff_plans(&before, &after);
assert!(diff.added.is_empty());
assert_eq!(diff.removed, vec!["filter"]);
assert!(diff.changed.is_empty());
}
#[test]
fn diff_plans_changed_label() {
let before = make_plan(&[("n1", "OldLabel")]);
let after = make_plan(&[("n1", "NewLabel")]);
let diff = super::diff_plans(&before, &after);
assert!(diff.added.is_empty());
assert!(diff.removed.is_empty());
assert_eq!(diff.changed, vec!["n1"]);
}
#[test]
fn diff_plans_detects_changed_partitioning() {
let mut before = PhysicalPlan::new("test", ExecutionKind::Batch);
before.add_node(
PlanNode::new("n1", "label", ExecutionKind::Batch)
.with_partitioning(Partitioning::Unpartitioned),
);
let mut after = PhysicalPlan::new("test", ExecutionKind::Batch);
after.add_node(
PlanNode::new("n1", "label", ExecutionKind::Batch)
.with_partitioning(Partitioning::Broadcast),
);
let diff = super::diff_plans(&before, &after);
assert_eq!(diff.changed, vec!["n1"]);
}
#[test]
fn diff_plans_detects_changed_estimated_rows() {
let mut before = PhysicalPlan::new("test", ExecutionKind::Batch);
before.add_node(
PlanNode::new("n1", "label", ExecutionKind::Batch).with_estimated_rows(Some(100)),
);
let mut after = PhysicalPlan::new("test", ExecutionKind::Batch);
after.add_node(
PlanNode::new("n1", "label", ExecutionKind::Batch).with_estimated_rows(Some(200)),
);
let diff = super::diff_plans(&before, &after);
assert_eq!(diff.changed, vec!["n1"]);
}
#[test]
fn diff_plans_detects_changed_inputs() {
let mut before = PhysicalPlan::new("test", ExecutionKind::Batch);
before.add_node(PlanNode::new("src", "source", ExecutionKind::Batch));
before.add_node(PlanNode::new("n1", "label", ExecutionKind::Batch).with_inputs(["src"]));
let mut after = PhysicalPlan::new("test", ExecutionKind::Batch);
after.add_node(PlanNode::new("src", "source", ExecutionKind::Batch));
after.add_node(PlanNode::new("n1", "label", ExecutionKind::Batch)); let diff = super::diff_plans(&before, &after);
assert_eq!(diff.changed, vec!["n1"]);
}
#[test]
fn graph_rejects_duplicate_input_edges() {
let plan = LogicalPlan::new("dup-edges", ExecutionKind::Batch)
.with_node(PlanNode::new("src", "source", ExecutionKind::Batch))
.with_node(
PlanNode::new("n1", "node", ExecutionKind::Batch).with_inputs(["src", "src"]),
);
let err = plan.validate().expect_err("duplicate inputs must fail");
assert!(
err.to_string().contains("duplicate input"),
"unexpected: {err}"
);
}
}