Repository navigation
fix: return an empty array from CASE WHEN for an empty batch #6763
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
base: main
Are you sure you want to change the base?
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 |
|---|---|---|
|
|
@@ -17,7 +17,8 @@ | |
|
|
||
| use crate::{Cast, EvalMode, IfExpr, NormalizeNaNAndZero, SparkCastOptions}; | ||
| use arrow::array::{ | ||
| downcast_primitive, Array, ArrayRef, AsArray, BooleanArray, GenericByteArray, PrimitiveArray, | ||
| downcast_primitive, new_empty_array, Array, ArrayRef, AsArray, BooleanArray, GenericByteArray, | ||
| PrimitiveArray, | ||
| }; | ||
| use arrow::buffer::{BooleanBuffer, NullBuffer, OffsetBuffer, ScalarBuffer}; | ||
| use arrow::compute::nullif; | ||
|
|
@@ -349,6 +350,13 @@ impl PhysicalExpr for CaseWhenExpr { | |
| } | ||
|
|
||
| fn evaluate(&self, batch: &RecordBatch) -> Result<ColumnarValue> { | ||
| // No row chooses a branch, which both the eager and the lazy evaluation answer with a | ||
| // scalar NULL. A scalar from an empty batch is taken to mean the expression is constant, | ||
| // as IN does to build its list once, so return an empty array instead. | ||
| if batch.num_rows() == 0 { | ||
| let data_type = self.data_type(&batch.schema())?; | ||
| return Ok(ColumnarValue::Array(new_empty_array(&data_type))); | ||
|
Member
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. [P2] Preserve per-row Evidence: Spark 4.1.3 with
Member
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. I reproduced this on Spark 4.1 with ANSI on, and it does not need a CASE. Could you open an issue for the whole-batch evaluation of later
Member
Author
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. Thanks both. As @andygrove found, this predates the PR: |
||
| } | ||
| match self | ||
| .eager | ||
| .get_or_init(|| self.eager_result_type(&batch.schema())) | ||
|
|
@@ -1238,14 +1246,37 @@ mod tests { | |
| assert_eq!(scalar(expr), ScalarValue::Int64(None)); | ||
| } | ||
|
|
||
| /// A scalar from an empty batch is taken to mean the expression is constant, so a CASE that | ||
| /// depends on a column has to return an empty array, whichever way it is evaluated. | ||
| #[test] | ||
| fn empty_batch() { | ||
| fn empty_batch_returns_an_empty_array() { | ||
| let batch = int_batch(vec![], vec![]); | ||
| let schema = batch.schema(); | ||
| let a = col("a", &schema).unwrap(); | ||
| let b = col("b", &schema).unwrap(); | ||
| let when_then = vec![(binary(Arc::clone(&a), Operator::Lt, lit(0i64)), a)]; | ||
| check_against_case_expr(&batch, when_then, Some(b)); | ||
| let null = || lit(ScalarValue::Int64(None)); | ||
| let a_is_1 = || binary(Arc::clone(&a), Operator::Eq, lit(1i64)); | ||
| let a_div_b = binary(Arc::clone(&a), Operator::Divide, b); | ||
| let new_case = |when_then, else_expr| CaseWhenExpr::try_new(when_then, else_expr).unwrap(); | ||
| for (expr, eager) in [ | ||
| // IF(a = 1, NULL, a), as nullif(a, 1) is planned | ||
| ( | ||
| new_case(vec![(a_is_1(), null())], Some(Arc::clone(&a))), | ||
| true, | ||
| ), | ||
| // A branch that can fail is evaluated lazily, by CaseExpr, which also returns the | ||
| // NULL literal as a scalar for an empty batch | ||
| (new_case(vec![(a_is_1(), null())], Some(a_div_b)), false), | ||
| ] { | ||
| assert_eq!(expr.eager_result_type(&schema).is_some(), eager, "{expr}"); | ||
| match expr.evaluate(&batch).unwrap() { | ||
| ColumnarValue::Array(array) => { | ||
| assert_eq!(array.len(), 0, "{expr}"); | ||
| assert_eq!(array.data_type(), &DataType::Int64, "{expr}"); | ||
| } | ||
| other => panic!("{expr} returned {other:?} for an empty batch"), | ||
| } | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| -- Licensed to the Apache Software Foundation (ASF) under one | ||
| -- or more contributor license agreements. See the NOTICE file | ||
| -- distributed with this work for additional information | ||
| -- regarding copyright ownership. The ASF licenses this file | ||
| -- to you under the Apache License, Version 2.0 (the | ||
| -- "License"); you may not use this file except in compliance | ||
| -- with the License. You may obtain a copy of the License at | ||
| -- | ||
| -- http://www.apache.org/licenses/LICENSE-2.0 | ||
| -- | ||
| -- Unless required by applicable law or agreed to in writing, | ||
| -- software distributed under the License is distributed on an | ||
| -- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | ||
| -- KIND, either express or implied. See the License for the | ||
| -- specific language governing permissions and limitations | ||
| -- under the License. | ||
|
|
||
| -- IN builds its list once when every candidate is a constant, which it decides by evaluating the | ||
| -- candidates on an empty batch and checking for a scalar. A CASE or IF that depends on a column | ||
| -- must not return a scalar NULL there, or IN compares every row against NULL. | ||
|
Comment on lines
+18
to
+20
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. The description says a
Member
Author
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. Added. Without the fix it returns NULL on every row, as you expected. |
||
|
|
||
| -- Config: spark.comet.exec.range.enabled=true | ||
|
|
||
| query | ||
| SELECT id, id IN (IF(id = 1, NULL, id)), id IN (nullif(id, 1), 5L) FROM range(0, 3) | ||
|
|
||
| query | ||
| SELECT id, id IN (CASE WHEN id = 1 THEN NULL ELSE id END) FROM range(0, 3) | ||
|
|
||
| -- coalesce is planned as a CASE | ||
| query | ||
| SELECT id, id IN (coalesce(nullif(id, 1), 5L)) FROM range(0, 3) | ||
|
|
||
| -- no ELSE | ||
| query | ||
| SELECT id, id IN (CASE WHEN id <> 1 THEN id END), id NOT IN (CASE WHEN id <> 1 THEN id END) | ||
| FROM range(0, 3) | ||
|
Member
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.
Member
Author
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. Added both, and the |
||
|
|
||
| query | ||
| SELECT id, id IN (CASE WHEN id = 0 THEN id WHEN id = 2 THEN id END) FROM range(0, 3) | ||
|
|
||
| -- A CASE that reads no column but that Spark cannot fold, because Comet plans | ||
| -- spark_partition_id() as a literal | ||
| query | ||
| SELECT id, id IN (CASE WHEN spark_partition_id() = 0 THEN 1L END, 5L) FROM range(0, 3, 1, 1) | ||
|
|
||
| -- nested operands | ||
| query | ||
| SELECT id, named_struct('a', id) IN (named_struct('a', IF(id = 1, NULL, id))) FROM range(0, 3) | ||
|
|
||
| query | ||
| SELECT id, named_struct('a', CAST(id AS DOUBLE)) | ||
| IN (named_struct('a', IF(id = 1, NULL, CAST(id AS DOUBLE)))) | ||
| FROM range(0, 3) | ||
|
|
||
| query | ||
| SELECT id, array(CAST(id AS DOUBLE)) IN (array(IF(id = 1, NULL, CAST(id AS DOUBLE)))) | ||
| FROM range(0, 3) | ||
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.
Should this also go to
branch-1.1? ThereCASE,IFandnullifevaluate through DataFusion'sCaseExpr, which treats a zero-row mask as all true, soIF(id = 1, NULL, id)returns itsNULLliteral as a scalar for the empty batch. From reading the code I expectid IN (IF(id = 1, NULL, id))to returnNULLfor every row in 1.1.0 as well. I have not run it.CaseWhenExpris not on that branch, so a backport would need this check in that branch'sIfExpr::evaluateand in a wrapper around theCaseExprthatcreate_case_exprreturns. If that holds, it is abackport-1.1candidate underbackporting.md.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.
Yes, I think it should.
branch-1.1uses the same DataFusion 55.1.0, and itsIfExpr::evaluatedelegates toCaseExpr, which returns a NULL THEN literal as a scalar for an empty batch, the same lazy path I confirmed returns a scalar NULL on main. I'll open abranch-1.1backport after this merges, with the check inIfExpr::evaluateand in a wrapper around theCaseExprfromcreate_case_expr, and verify the reproduction there first.