Skip to content

fix: IS NULL panic with invalid function without input arguments - #20306

Merged
alamb merged 3 commits into
apache:mainfrom
Acfboy:fix-isnull-not-check-input
Feb 26, 2026
Merged

alamb merged 3 commits into
apache:mainfrom
Acfboy:fix-isnull-not-check-input

Conversation

@Acfboy

@Acfboy Acfboy commented Feb 12, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

Unlike Projection, expressions within a Filter node do not always have their types resolved during the initial LogicalPlan generation. Validation is often deferred until the type_coercion phase. When invoking f_up on the expression tree to perform type coercion, the check is bypassed for function nodes with empty arguments, leading to a panic during subsequent execution.

For example, all statements below cause a panic:

SELECT * FROM (SELECT 1) WHERE (STARTS_WITH() IS NULL);
SELECT * FROM (SELECT 1) WHERE (STARTS_WITH() IS NOT NULL);
SELECT * FROM (SELECT 'a') WHERE (STARTS_WITH() SIMILAR TO 'abc%');
SELECT * FROM (SELECT 1) WHERE CAST(STARTS_WITH() AS STRING) = 'x';
SELECT * FROM (SELECT 1) WHERE TRY_CAST(STARTS_WITH() AS INT) IS NULL;

This pr aims to stop panic. It would be better if reject these invalid cases at planning.

What changes are included in this PR?

 fn coerce_arguments_for_signature<F: UDFCoercionExt>(
      schema: &DFSchema,
      func: &F,
  ) -> Result<Vec<Expr>> {
-    if expressions.is_empty() {
-        return Ok(expressions);
-    }

Deleted the early return. Thanks to @neilconway .

Are these changes tested?

All original tests passed. Added some unit tests and sqllogictests.

Are there any user-facing changes?

No.

@github-actions github-actions Bot added optimizer Optimizer rules sqllogictest SQL Logic Tests (.slt) labels Feb 12, 2026

@neilconway neilconway left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for taking a look at this!

});
Ok(Transformed::yes(new_expr))
}
// IS NULL / IS NOT NULL / SIMILAR TO / CAST / TRY_CAST validate

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think comments should reference "previously" (previously to what?); they should just talk about the current state of the code.

I'd rephrase this comment to something like:

// IS NULL, IS NOT NULL, SIMILAR TO, CAST, and TRY CAST don't require
// type coercion, but we need to handle them here to ensure that their
// arguments have type coercion and type checking applied (see #20201
// for context).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, modified.

Expr::ScalarFunction(ScalarFunction::new_udf(Arc::new(fun), vec![]))
}

#[test]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests feel redundant to the SLT tests, no? In my opinion we should be fine with just adding the SLT tests only (which arguably is closer to testing the user-visible behavior anyway).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see it as overlapping. slt ensures the whole path works, and unit tests are there to help us isolate the root cause when something goes wrong.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with @neilconway here -- I do think the tests are redundant and I think we can just use the slt tests


###############################################################################
## Test that IS NULL / IS NOT NULL / SIMILAR TO / CAST / TRY_CAST validate ##
## their inner expressions during type coercion (previously they were in ##

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm omit discussion of the previous incorrect behavior, I'd just talk about the current/correct behavior under test.

let _check_type = inner_expr.get_type(self.schema)?;
Ok(Transformed::no(expr))
}
Expr::SimilarTo(Like {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder whether the handling of SIMILAR TO should be more similar to how we handle LIKE?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect this is because the current implementation has focused more on optimizing LIKE. Many of these optimizations could likely be applied to SIMILAR TO as well, but they are probably just yet to be implemented.

For this issue, let's maintain consistency with the existing implementation. Maybe we should create separate issues to refine how SIMILAR TO is handled.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't thinking about optimizations; my concern was more whether the type coercion that is done for LIKE should also be done for IS SIMILAR TO for correctness reasons. Some digging suggests this might be the case:

-- works
  SELECT CAST('hello' AS BYTEA) LIKE 'hello%';

-- fails ("Cannot infer common argument type for regex operation Binary ~ Utf8")
  SELECT CAST('hello' AS BYTEA) SIMILAR TO 'hello%';

-- works
  SELECT arrow_cast('hello', 'Dictionary(Int32, Utf8)') LIKE 'hello%';

-- fails ("Data type Dictionary(Int32, Utf8) not supported for regex_match_dyn")
  SELECT arrow_cast('hello', 'Dictionary(Int32, Utf8)') SIMILAR TO 'hello%';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I filed #20342, I'll take a look at fixing shortly.

@neilconway

Copy link
Copy Markdown
Contributor

Digging into this a bit further, this fix seems cleaner to me:

diff --git a/datafusion/optimizer/src/analyzer/type_coercion.rs b/datafusion/optimizer/src/analyzer/type_coercion.rs
index c2121f45c..1a8218a1e 100644
--- a/datafusion/optimizer/src/analyzer/type_coercion.rs
+++ b/datafusion/optimizer/src/analyzer/type_coercion.rs
@@ -954,10 +954,6 @@ fn coerce_arguments_for_signature<F: UDFCoercionExt>(
     schema: &DFSchema,
     func: &F,
 ) -> Result<Vec<Expr>> {
-    if expressions.is_empty() {
-        return Ok(expressions);
-    }
-
     let current_fields = expressions
         .iter()
         .map(|e| e.to_field(schema).map(|(_, f)| f))

i.e., coerce_arguments_for_signature() -> fields_with_udf() is already checking for functions that require arguments but that are invoked with no arguments, but this is effectively dead code because we exit early before we get there. If we just remove the early-exit then the behavior seems right to me. What do you think?

@alamb alamb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @Acfboy and @neilconway

I think this looks good to me

Expr::ScalarFunction(ScalarFunction::new_udf(Arc::new(fun), vec![]))
}

#[test]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with @neilconway here -- I do think the tests are redundant and I think we can just use the slt tests


# https://github.com/apache/datafusion/issues/20201

# IS NULL should reject invalid inner expression

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ran these tests without the code changes to verify they cover the issue and they fail like

Completed 403 test files in 8 seconds                                                                                                                                         failure in type_coercion.slt for sql SELECT * FROM (SELECT 1) WHERE (STARTS_WITH() IS NULL);
caused by
External error: task 22188 panicked with message "index out of bounds: the len is 0 but the index is 1"
Error: Execution("1 failures")
error: test failed, to rerun pass `-p datafusion-sqllogictest --test sqllogictests`

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review, @alamb and @neilconway. I have removed the redundant unit tests in type_coercion.rs and will rely on the .slt tests for coverage as suggested.

@neilconway

Copy link
Copy Markdown
Contributor

Thanks @Acfboy and @neilconway

I think this looks good to me

Thanks @alamb ! I'm curious what you think of the alternative approach I suggested #20306 (comment)

@alamb

alamb commented Feb 13, 2026

Copy link
Copy Markdown
Contributor

If we just remove the early-exit then the behavior seems right to me. What do you think?

I think that sounds like a great change to me

@Acfboy

Acfboy commented Feb 14, 2026

Copy link
Copy Markdown
Contributor Author

i.e., coerce_arguments_for_signature() -> fields_with_udf() is already checking for functions that require arguments but that are invoked with no arguments, but this is effectively dead code because we exit early before we get there. If we just remove the early-exit then the behavior seems right to me. What do you think?

It seems this only works for cases where UDFs are called without arguments. However, there are other cases failing due to a lack of type-checking in IS NULL. For example, SELECT * FROM (SELECT 1) WHERE ((1.0 << 2.0) IS NULL); is expected to report that we can't run float << float, but it actually runs without error after constant folding by the optimizer.

@neilconway

Copy link
Copy Markdown
Contributor

It seems this only works for cases where UDFs are called without arguments. However, there are other cases failing due to a lack of type-checking in IS NULL. For example, SELECT * FROM (SELECT 1) WHERE ((1.0 << 2.0) IS NULL); is expected to report that we can't run float << float, but it actually runs without error after constant folding by the optimizer.

Good point!

Can you add one or more example queries like this to the SLT tests?

@Acfboy
Acfboy force-pushed the fix-isnull-not-check-input branch 2 times, most recently from 137947b to 2c04325 Compare February 14, 2026 02:47
@Acfboy

Acfboy commented Feb 14, 2026

Copy link
Copy Markdown
Contributor Author

@neilconway Thanks! While trying to add some tests, another bug occurs: #20355 . So I can't add something like SELECT * FROM (SELECT 1) WHERE ((-'abc') IS NULL); to slt tests now.

Also, my apologies for the oversight. Binary operators are indeed type-checked. The bitwise operation issue was a bug that's already been fixed in a merged PR. I've checked the other branches, and it appears that only unary negation and function calls still have this problem.

@neilconway

neilconway commented Feb 15, 2026 •

Copy link
Copy Markdown
Contributor

Proposal:

  1. Remove the early return from coerce_arguments_for_signature(), because that seems wrong in any case. (I'm happy to send a PR for this or you can, @Acfboy -- lmk what you prefer)
  2. Fix The negation operator escapes type checking at logical planning phase #20355 and Incorrect type coercion for IS SIMILAR TO #20342 separately

My understanding is that if we do the above, we don't need the changes to TypeCoercionRewriter proposed in this PR -- is that correct?

@Acfboy
Acfboy force-pushed the fix-isnull-not-check-input branch from 2c04325 to 9f54b51 Compare February 16, 2026 04:44
@Acfboy
Acfboy requested review from alamb and neilconway February 16, 2026 04:44
@Acfboy

Acfboy commented Feb 16, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @neilconway ! You are right. I have changed the pr and re-request reivews.

@neilconway neilconway left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎉🎉🎉

Can you update the PR description to match the current approach?

@Acfboy Acfboy changed the title fix: IS NULL doesn't type-check its input and panic fix: IS NULL panic with invalid function without input arguments Feb 17, 2026

@alamb alamb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense to me -- thank you @Acfboy and @neilconway

@alamb
alamb added this pull request to the merge queue Feb 26, 2026
Merged via the queue into apache:main with commit e76f0ee Feb 26, 2026
32 checks passed
de-bgunter pushed a commit to de-bgunter/datafusion that referenced this pull request Mar 24, 2026
…che#20306)

## Which issue does this PR close?

<!--
We generally require a GitHub issue to be filed for all bug fixes and
enhancements and this helps us generate change logs for our releases.
You can link an issue to this PR using the GitHub syntax. For example
`Closes apache#123` indicates that this PR will close issue apache#123.
-->

- Closes apache#20201 .

## Rationale for this change

Unlike `Projection`, expressions within a `Filter` node do not always
have their types resolved during the initial LogicalPlan generation.
Validation is often deferred until the type_coercion phase. When
invoking f_up on the expression tree to perform type coercion, the check
is bypassed for function nodes with empty arguments, leading to a panic
during subsequent execution.

For example, all statements below cause a panic:
```
SELECT * FROM (SELECT 1) WHERE (STARTS_WITH() IS NULL);
SELECT * FROM (SELECT 1) WHERE (STARTS_WITH() IS NOT NULL);
SELECT * FROM (SELECT 'a') WHERE (STARTS_WITH() SIMILAR TO 'abc%');
SELECT * FROM (SELECT 1) WHERE CAST(STARTS_WITH() AS STRING) = 'x';
SELECT * FROM (SELECT 1) WHERE TRY_CAST(STARTS_WITH() AS INT) IS NULL;
```

This pr aims to stop panic. It would be better if reject these invalid
cases at planning.

## What changes are included in this PR?

<!--
There is no need to duplicate the description in the issue here but it
is sometimes worth providing a summary of the individual changes in this
PR.
-->

```diff
 fn coerce_arguments_for_signature<F: UDFCoercionExt>(
      schema: &DFSchema,
      func: &F,
  ) -> Result<Vec<Expr>> {
-    if expressions.is_empty() {
-        return Ok(expressions);
-    }
```

Deleted the early return. Thanks to @neilconway .

## Are these changes tested?
All original tests passed. Added some unit tests and sqllogictests.


## Are there any user-facing changes?

<!--
If there are user-facing changes then we may require documentation to be
updated before approving the PR.
-->

<!--
If there are any breaking changes to public APIs, please add the `api
change` label.
-->
No.

---------

Co-authored-by: Andrew Lamb <andrew@nerdnetworks.org>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

optimizer Optimizer rules sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

IS NULL doesn't type-check its input

3 participants