Skip to content

Commit 02bad14

Browse files
authored
perf: single-pass plan traversal in Predicate::new (#113)
* perf: single-pass plan traversal in Predicate::new * address comments * add join error test
1 parent 4071577 commit 02bad14

1 file changed

Lines changed: 163 additions & 72 deletions

File tree

src/rewrite/normal_form.rs

Lines changed: 163 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -233,22 +233,11 @@ impl SpjNormalForm {
233233
.map(|expr| predicate.normalize_expr(expr))
234234
.collect();
235235

236-
let mut referenced_tables = vec![];
237-
original_plan
238-
.apply(|plan| {
239-
if let LogicalPlan::TableScan(scan) = plan {
240-
referenced_tables.push(scan.table_name.clone());
241-
}
242-
243-
Ok(TreeNodeRecursion::Continue)
244-
})
245-
// No chance of error since we never return Err -- this unwrap is safe
246-
.unwrap();
247-
248236
Ok(Self {
249237
output_schema: Arc::clone(original_plan.schema()),
250238
output_exprs,
251-
referenced_tables,
239+
// Reuse referenced_tables collected during Predicate::new to avoid extra traversal
240+
referenced_tables: predicate.referenced_tables.clone(),
252241
predicate,
253242
})
254243
}
@@ -344,84 +333,95 @@ struct Predicate {
344333
ranges_by_equivalence_class: Vec<Option<Interval>>,
345334
/// Filter expressions that aren't column equality predicates or range filters.
346335
residuals: HashSet<Expr>,
336+
/// Tables referenced in this plan (collected during single-pass traversal)
337+
referenced_tables: Vec<TableReference>,
347338
}
348339

349340
impl Predicate {
341+
/// Create a new Predicate by analyzing the given logical plan.
342+
/// Uses single-pass traversal to collect schema, columns, filters, and referenced tables.
350343
fn new(plan: &LogicalPlan) -> Result<Self> {
351344
let mut schema = DFSchema::empty();
352-
plan.apply(|plan| {
353-
if let LogicalPlan::TableScan(scan) = plan {
354-
let new_schema = DFSchema::try_from_qualified_schema(
355-
scan.table_name.clone(),
356-
scan.source.schema().as_ref(),
357-
)?;
358-
schema = if schema.fields().is_empty() {
359-
new_schema
360-
} else {
361-
schema.join(&new_schema)?
362-
}
363-
}
345+
let mut columns_info: Vec<(Column, arrow::datatypes::DataType)> = Vec::new();
346+
let mut filters: Vec<Expr> = Vec::new();
347+
let mut referenced_tables: Vec<TableReference> = Vec::new();
348+
349+
// Single traversal to collect everything
350+
plan.apply(|node| {
351+
match node {
352+
LogicalPlan::TableScan(scan) => {
353+
// Collect referenced table
354+
referenced_tables.push(scan.table_name.clone());
364355

365-
Ok(TreeNodeRecursion::Continue)
366-
})?;
356+
// Build schema
357+
let new_schema = DFSchema::try_from_qualified_schema(
358+
scan.table_name.clone(),
359+
scan.source.schema().as_ref(),
360+
)?;
361+
362+
// Collect columns with their data types
363+
for (table_ref, field) in new_schema.iter() {
364+
columns_info.push((
365+
Column::new(table_ref.cloned(), field.name()),
366+
field.data_type().clone(),
367+
));
368+
}
367369

368-
let mut new = Self {
369-
schema,
370-
eq_classes: vec![],
371-
eq_class_idx_by_column: HashMap::default(),
372-
ranges_by_equivalence_class: vec![],
373-
residuals: HashSet::new(),
374-
};
370+
// Merge schema
371+
schema = if schema.fields().is_empty() {
372+
new_schema
373+
} else {
374+
schema.join(&new_schema)?
375+
};
375376

376-
// Collect all referenced columns
377-
plan.apply(|plan| {
378-
if let LogicalPlan::TableScan(scan) = plan {
379-
for (i, (table_ref, field)) in DFSchema::try_from_qualified_schema(
380-
scan.table_name.clone(),
381-
scan.source.schema().as_ref(),
382-
)?
383-
.iter()
384-
.enumerate()
385-
{
386-
let column = Column::new(table_ref.cloned(), field.name());
387-
let data_type = field.data_type();
388-
new.eq_classes
389-
.push(ColumnEquivalenceClass::new_singleton(column.clone()));
390-
new.eq_class_idx_by_column.insert(column, i);
391-
new.ranges_by_equivalence_class
392-
.push(Some(Interval::make_unbounded(data_type)?));
377+
// Collect filters from TableScan
378+
filters.extend(scan.filters.iter().cloned());
379+
}
380+
LogicalPlan::Filter(filter) => {
381+
filters.push(filter.predicate.clone());
393382
}
394-
}
395-
396-
Ok(TreeNodeRecursion::Continue)
397-
})?;
398-
399-
// Collect any filters
400-
plan.apply(|plan| {
401-
let filters = match plan {
402-
LogicalPlan::TableScan(scan) => scan.filters.as_slice(),
403-
LogicalPlan::Filter(filter) => core::slice::from_ref(&filter.predicate),
404383
LogicalPlan::Join(_join) => {
405384
return Err(DataFusionError::Internal(
406385
"joins are not supported yet".to_string(),
407-
))
386+
));
408387
}
409-
LogicalPlan::Projection(_) => &[],
388+
LogicalPlan::Projection(_) => {}
410389
_ => {
411390
return Err(DataFusionError::Plan(format!(
412391
"unsupported logical plan: {}",
413-
plan.display()
414-
)))
392+
node.display()
393+
)));
415394
}
416-
};
417-
418-
for expr in filters.iter().flat_map(split_conjunction) {
419-
new.insert_conjuct(expr)?;
420395
}
421-
422396
Ok(TreeNodeRecursion::Continue)
423397
})?;
424398

399+
// Initialize data structures with known capacity
400+
let num_columns = columns_info.len();
401+
let mut eq_classes = Vec::with_capacity(num_columns);
402+
let mut eq_class_idx_by_column = HashMap::with_capacity(num_columns);
403+
let mut ranges_by_equivalence_class = Vec::with_capacity(num_columns);
404+
405+
for (i, (column, data_type)) in columns_info.into_iter().enumerate() {
406+
eq_classes.push(ColumnEquivalenceClass::new_singleton(column.clone()));
407+
eq_class_idx_by_column.insert(column, i);
408+
ranges_by_equivalence_class.push(Some(Interval::make_unbounded(&data_type)?));
409+
}
410+
411+
let mut new = Self {
412+
schema,
413+
eq_classes,
414+
eq_class_idx_by_column,
415+
ranges_by_equivalence_class,
416+
residuals: HashSet::new(),
417+
referenced_tables,
418+
};
419+
420+
// Process all collected filters
421+
for expr in filters.iter().flat_map(split_conjunction) {
422+
new.insert_conjuct(expr)?;
423+
}
424+
425425
Ok(new)
426426
}
427427

@@ -1163,11 +1163,11 @@ mod test {
11631163
TestCase {
11641164
name: "range filter + equality predicate",
11651165
base:
1166-
"SELECT column1, column2 FROM t1 WHERE column1 = column3 AND column1 >= '2022'",
1166+
"SELECT column1, column2 FROM t1 WHERE column1 = column3 AND column1 >= '2022'",
11671167
query:
11681168
// Since column1 = column3 in the original view,
11691169
// we are allowed to substitute column1 for column3 and vice versa.
1170-
"SELECT column2, column3 FROM t1 WHERE column1 = column3 AND column3 >= '2023'",
1170+
"SELECT column2, column3 FROM t1 WHERE column1 = column3 AND column3 >= '2023'",
11711171
},
11721172
TestCase {
11731173
name: "range filter with inequality on non-discrete type",
@@ -1229,4 +1229,95 @@ mod test {
12291229

12301230
Ok(())
12311231
}
1232+
1233+
#[tokio::test]
1234+
async fn test_predicate_new_collects_expected_data() -> Result<()> {
1235+
let ctx = SessionContext::new();
1236+
1237+
// Create a table with known schema
1238+
ctx.sql(
1239+
"CREATE TABLE test_table (
1240+
col1 INT,
1241+
col2 VARCHAR,
1242+
col3 DOUBLE
1243+
)",
1244+
)
1245+
.await?
1246+
.collect()
1247+
.await?;
1248+
1249+
// Create a plan with filters
1250+
let plan = ctx
1251+
.sql("SELECT col1, col2 FROM test_table WHERE col1 >= 10 AND col2 = col3")
1252+
.await?
1253+
.into_optimized_plan()?;
1254+
1255+
let normal_form = SpjNormalForm::new(&plan)?;
1256+
1257+
// Verify referenced_tables is collected
1258+
assert_eq!(normal_form.referenced_tables().len(), 1);
1259+
assert_eq!(normal_form.referenced_tables()[0].to_string(), "test_table");
1260+
1261+
// Verify output_exprs matches the projection (2 columns)
1262+
assert_eq!(normal_form.output_exprs().len(), 2);
1263+
1264+
// Verify schema is preserved
1265+
assert_eq!(normal_form.output_schema().fields().len(), 2);
1266+
1267+
Ok(())
1268+
}
1269+
1270+
#[tokio::test]
1271+
async fn test_predicate_new_with_join_returns_error() -> Result<()> {
1272+
let ctx = SessionContext::new();
1273+
1274+
ctx.sql("CREATE TABLE t1 (a INT, b INT)")
1275+
.await?
1276+
.collect()
1277+
.await?;
1278+
ctx.sql("CREATE TABLE t2 (c INT, d INT)")
1279+
.await?
1280+
.collect()
1281+
.await?;
1282+
1283+
// Test that join returns an error as it's not supported yet
1284+
let plan = ctx
1285+
.sql("SELECT t1.a, t2.d FROM t1 JOIN t2 ON t1.b = t2.c WHERE t1.a >= 0 AND t2.d <= 100")
1286+
.await?
1287+
.into_optimized_plan()?;
1288+
1289+
let result = SpjNormalForm::new(&plan);
1290+
1291+
// Verify that join returns an error
1292+
assert!(result.is_err());
1293+
assert!(result
1294+
.unwrap_err()
1295+
.to_string()
1296+
.contains("joins are not supported yet"));
1297+
1298+
Ok(())
1299+
}
1300+
1301+
#[tokio::test]
1302+
async fn test_predicate_new_with_range_filters() -> Result<()> {
1303+
let ctx = SessionContext::new();
1304+
1305+
ctx.sql("CREATE TABLE range_test (x INT, y INT, z VARCHAR)")
1306+
.await?
1307+
.collect()
1308+
.await?;
1309+
1310+
let plan = ctx
1311+
.sql("SELECT * FROM range_test WHERE x >= 10 AND x <= 100 AND y = 50")
1312+
.await?
1313+
.into_optimized_plan()?;
1314+
1315+
let normal_form = SpjNormalForm::new(&plan)?;
1316+
1317+
// Verify all columns are in output
1318+
assert_eq!(normal_form.output_exprs().len(), 3);
1319+
assert_eq!(normal_form.referenced_tables().len(), 1);
1320+
1321+
Ok(())
1322+
}
12321323
}

0 commit comments

Comments
 (0)