Repository navigation
fix: treat an uncorrelated scalar subquery as a constant in ordering checks #26836
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -29,6 +29,7 @@ use crate::{PhysicalExpr, PhysicalExprRef, PhysicalSortExpr, PhysicalSortRequire | |
|
|
||
| use datafusion_common::tree_node::{Transformed, TransformedResult, TreeNode}; | ||
| use datafusion_common::{JoinType, Result, ScalarValue}; | ||
| use datafusion_expr::sort_properties::SortProperties; | ||
| use datafusion_physical_expr_common::physical_expr::format_physical_expr_list; | ||
|
|
||
| use indexmap::{IndexMap, IndexSet}; | ||
|
|
@@ -361,8 +362,9 @@ impl EquivalenceGroup { | |
| return; | ||
| } | ||
| // If the expression is not in any equivalence class, but has the same | ||
| // constant value with some class, add it to that class: | ||
| if let AcrossPartitions::Uniform(_) = &const_expr.across_partitions { | ||
| // constant value with some class, add it to that class. An unknown | ||
| // value is never known to match, so it gets a class of its own: | ||
| if let AcrossPartitions::Uniform(Some(_)) = &const_expr.across_partitions { | ||
| for (idx, cls) in self.classes.iter_mut().enumerate() { | ||
| if cls | ||
| .constant | ||
|
|
@@ -792,9 +794,21 @@ impl EquivalenceGroup { | |
| // TODO: This function should be able to return values of non-literal | ||
| // complex constants as well; e.g. it should return `8` for the | ||
| // expression `3 + 5`, not an unknown `heterogenous` value. | ||
| // A volatile expression can change from row to row, even when all of | ||
| // its inputs are constant | ||
| if expr.is_volatile_node() { | ||
| return None; | ||
| } | ||
| let children = expr.children(); | ||
| if children.is_empty() { | ||
| return None; | ||
| // A leaf that reads no column can still be constant, e.g. an | ||
| // uncorrelated scalar subquery, which reports itself as a | ||
| // singleton. It does not depend on the row or the partition, so | ||
| // its value is the same everywhere, even if it is not known yet. | ||
| let singleton = expr | ||
| .get_properties(&[]) | ||
| .is_ok_and(|props| props.sort_properties == SortProperties::Singleton); | ||
| return singleton.then_some(AcrossPartitions::Uniform(None)); | ||
| } | ||
| for child in children { | ||
| self.is_expr_constant(child)?; | ||
|
|
@@ -822,21 +836,35 @@ impl EquivalenceGroup { | |
| let group = match join_type { | ||
| JoinType::Inner | JoinType::Left | JoinType::Full | JoinType::Right => { | ||
| let mut result = Self::new( | ||
| self.iter().cloned().chain( | ||
| right_equivalences | ||
| .iter() | ||
| .map(|cls| { | ||
| let mut cls = cls.try_with_offset(left_size as _)?; | ||
| // Rows without a match on the right side are | ||
| // padded with NULLs, so its constants no | ||
| // longer hold. | ||
| if matches!(join_type, JoinType::Left | JoinType::Full) { | ||
| cls.constant = None; | ||
| } | ||
| Ok(cls) | ||
| }) | ||
| .collect::<Result<Vec<_>>>()?, | ||
| ), | ||
| self.iter() | ||
| .cloned() | ||
| .map(|mut cls| { | ||
| // Rows without a match on the left side are | ||
| // padded with NULLs, so its constants no | ||
| // longer hold. | ||
| if matches!(join_type, JoinType::Right | JoinType::Full) { | ||
| cls.constant = None; | ||
| } | ||
| cls | ||
| }) | ||
| .chain( | ||
| right_equivalences | ||
| .iter() | ||
| .map(|cls| { | ||
| let mut cls = cls.try_with_offset(left_size as _)?; | ||
| // Rows without a match on the right side are | ||
| // padded with NULLs, so its constants no | ||
| // longer hold. | ||
| if matches!( | ||
| join_type, | ||
| JoinType::Left | JoinType::Full | ||
| ) { | ||
| cls.constant = None; | ||
| } | ||
| Ok(cls) | ||
| }) | ||
| .collect::<Result<Vec<_>>>()?, | ||
| ), | ||
| ); | ||
| // In we have an inner join, expressions in the "on" condition | ||
| // are equal in the resulting table. | ||
|
|
@@ -1362,6 +1390,117 @@ mod tests { | |
| Ok(()) | ||
| } | ||
|
|
||
| /// An uncorrelated scalar subquery is a leaf with no columns, so the only | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Optional cleanup: the new test is inserted between |
||
| /// way to know it is constant is to ask the expression itself. | ||
| /// <https://github.com/apache/datafusion/issues/26572> | ||
| #[test] | ||
| fn test_scalar_subquery_is_uniform_constant() -> Result<()> { | ||
| use crate::scalar_subquery::ScalarSubqueryExpr; | ||
| use datafusion_expr::physical_planning_context::{ | ||
| ScalarSubqueryResults, SubqueryIndex, | ||
| }; | ||
|
|
||
| let subquery: Arc<dyn PhysicalExpr> = Arc::new(ScalarSubqueryExpr::new( | ||
| DataType::Int32, | ||
| true, | ||
| SubqueryIndex::new(0), | ||
| ScalarSubqueryResults::new(1), | ||
| )); | ||
| let group = EquivalenceGroup::default(); | ||
| assert_eq!( | ||
| group.is_expr_constant(&subquery), | ||
| Some(AcrossPartitions::Uniform(None)) | ||
| ); | ||
| // A column is a leaf too, but it is not constant | ||
| let schema = abc_schema(); | ||
| assert_eq!(group.is_expr_constant(&col("a", &schema)?), None); | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_volatile_function_of_constants_is_not_constant() -> Result<()> { | ||
| use crate::ScalarFunctionExpr; | ||
| use crate::scalar_subquery::ScalarSubqueryExpr; | ||
| use datafusion_common::config::ConfigOptions; | ||
| use datafusion_expr::physical_planning_context::{ | ||
| ScalarSubqueryResults, SubqueryIndex, | ||
| }; | ||
| use datafusion_expr::{ColumnarValue, Volatility, create_udf}; | ||
|
|
||
| let schema = abc_schema(); | ||
| let group = EquivalenceGroup::default(); | ||
| let subquery: Arc<dyn PhysicalExpr> = Arc::new(ScalarSubqueryExpr::new( | ||
| DataType::Int32, | ||
| true, | ||
| SubqueryIndex::new(0), | ||
| ScalarSubqueryResults::new(1), | ||
| )); | ||
| let call = |volatility, arg: Arc<dyn PhysicalExpr>| -> Result<_> { | ||
| let udf = Arc::new(create_udf( | ||
| "f", | ||
| vec![DataType::Int32], | ||
| DataType::Int32, | ||
| volatility, | ||
| Arc::new(|args: &[ColumnarValue]| Ok(args[0].clone())), | ||
| )); | ||
| let expr = ScalarFunctionExpr::try_new( | ||
| udf, | ||
| vec![arg], | ||
| &schema, | ||
| Arc::new(ConfigOptions::new()), | ||
| )?; | ||
| Ok(Arc::new(expr) as Arc<dyn PhysicalExpr>) | ||
| }; | ||
|
|
||
| // A volatile function can return a new value for every row, even | ||
| // when its argument is constant | ||
| for arg in [lit(1i32), Arc::clone(&subquery)] { | ||
| let volatile = call(Volatility::Volatile, arg)?; | ||
| assert_eq!(group.is_expr_constant(&volatile), None); | ||
| } | ||
| // A stable function of a constant is constant | ||
| let stable = call(Volatility::Stable, subquery)?; | ||
| assert_eq!( | ||
| group.is_expr_constant(&stable), | ||
| Some(AcrossPartitions::Heterogeneous) | ||
| ); | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_unknown_constants_stay_apart() -> Result<()> { | ||
| // Two constants whose values are unknown are not known to be equal, | ||
| // so they must not be merged into one class | ||
| let schema = abc_schema(); | ||
| let mut group = EquivalenceGroup::default(); | ||
| group.add_constant(ConstExpr::new( | ||
| col("a", &schema)?, | ||
| AcrossPartitions::Uniform(None), | ||
| )); | ||
| group.add_constant(ConstExpr::new( | ||
| col("b", &schema)?, | ||
| AcrossPartitions::Uniform(None), | ||
| )); | ||
| assert_eq!(group.len(), 2); | ||
|
|
||
| // Two constants with the same known value are merged | ||
| let mut group = EquivalenceGroup::default(); | ||
| let one = Some(ScalarValue::Int32(Some(1))); | ||
| group.add_constant(ConstExpr::new( | ||
| col("a", &schema)?, | ||
| AcrossPartitions::Uniform(one.clone()), | ||
| )); | ||
| group.add_constant(ConstExpr::new( | ||
| col("b", &schema)?, | ||
| AcrossPartitions::Uniform(one), | ||
| )); | ||
| assert_eq!(group.len(), 1); | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| /// A constant on the right side of a join stays constant in the join | ||
| /// output, unless unmatched rows pad the right side with NULLs. | ||
| /// <https://github.com/apache/datafusion/issues/26283> | ||
|
|
@@ -1390,6 +1529,31 @@ mod tests { | |
| Ok(()) | ||
| } | ||
|
|
||
| /// The same for a constant on the left side, whose rows are padded with | ||
| /// NULLs by a right or full join. | ||
| #[test] | ||
| fn test_join_keeps_left_constants() -> Result<()> { | ||
| let schema = abc_schema(); | ||
| let mut left = EquivalenceGroup::default(); | ||
| left.add_constant(ConstExpr::from(col("a", &schema)?)); | ||
| let left_a = col("a", &schema)?; | ||
| for (join_type, expected) in [ | ||
| (JoinType::Inner, true), | ||
| (JoinType::Left, true), | ||
| (JoinType::Right, false), | ||
| (JoinType::Full, false), | ||
| ] { | ||
| let joined = left.join(&EquivalenceGroup::default(), &join_type, 4, &[])?; | ||
| assert_eq!( | ||
| joined.is_expr_constant(&left_a).is_some(), | ||
| expected, | ||
| "{join_type}" | ||
| ); | ||
| } | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_has_same_classes_ignores_the_map() -> Result<()> { | ||
| // `map` indexes into `classes`, so equal classes must imply equal | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@raghavyadav01 thanks for the fix! Treating the subquery as a constant now reaches an existing gap in
EquivalenceGroup::join: it keeps the left side's constants forRightandFulljoins, even though that side is padded with NULLs (#26042 would also fix it, but it is still open). ALEFT JOINgets swapped into a physicalRightjoin with the subquery on the left, the sort onmis dropped, and the NULL rows come back in the middle. The same query returns the right order on main:Clearing the left side's constants the same way as the right side's fixes it. With this change, the query above and the full sqllogictest suite pass:
Could you add the query above to
subquery.slt?