Skip to content

[SPARK-59348][SQL] Rewrite match-all LIKE '%' to IsNotNull in predicates - #58637

Open
david-mollitor-db wants to merge 1 commit into
apache:masterfrom
david-mollitor-db:like-percent-to-isnotnull
Open

[SPARK-59348][SQL] Rewrite match-all LIKE '%' to IsNotNull in predicates#58637
david-mollitor-db wants to merge 1 commit into
apache:masterfrom
david-mollitor-db:like-percent-to-isnotnull

Conversation

@david-mollitor-db

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

col LIKE '%' (a pattern of only unescaped %) matches every non-null value, so a filter
WHERE col LIKE '%' keeps exactly the non-null rows. Today Spark evaluates it as a per-row
regex (.*) and pushes nothing to the data source.

This PR rewrites such patterns, in null-rejecting predicate positions, to IsNotNull(col):

Aggregate [count(1)]                    Aggregate [count(1)]
+- Filter (col LIKE '%')       ==>      +- Filter (isnotnull(col))
   +- Relation t                           +- Relation t

The rewrite is implemented in ReplaceNullWithFalseInPredicate, which already performs this
class of null-as-false simplification (cf. its existing Not(In(...))/Not(InSet(...))
handling). It fires only for a literal pattern of one or more % with the escape character not
being %; LIKE '', prefixes, and _ patterns are excluded. LikeAll/LikeAny are out of
scope.

Why are the changes needed?

  • IsNotNull is far cheaper than compiling and running a regex per row.
  • IsNotNull pushes down: Parquet/ORC skip row groups via null-count statistics; the raw LIKE
    regex does not.
  • If col is non-nullable, IsNotNull(col) then folds to true and downstream
    PruneFilters/NullPropagation drop the filter entirely.

The pattern shows up in practice from dynamically-generated SQL (e.g. an empty search box
becoming LIKE '%').

Correctness. col LIKE '%' returns true for a non-null value and null for null,
whereas IsNotNull(col) returns false for null. They are equivalent only in null-rejecting
predicate positions, where null is treated as false. ReplaceNullWithFalseInPredicate
targets exactly those plan nodes (Filter, Join condition, MergeIntoTable,
DeleteFromTable, UpdateTable, ReplaceData, WriteDelta) and recurses only through
null-preserving operators (And, Or, If, CaseWhen), not into Not — so
NOT (col LIKE '%') and col LIKE '%' in a projection are left untouched, where null vs
false would be observable. No collation gate is needed: '%' matches every non-null value
under any collation.

Does this PR introduce any user-facing change?

No. Query results are identical; this is a performance improvement.

How was this patch tested?

New tests in ReplaceNullWithFalseInPredicateSuite:

  • Rewritten: '%', '%%', '%%%' -> IsNotNull; through AND/OR; in a join condition.
  • Not rewritten: prefix 'a%', empty '', % escaped (ESCAPE '%'), under NOT, and in a
    projection (the null-vs-false safety boundary).

build/sbt 'catalyst/testOnly *ReplaceNullWithFalseInPredicateSuite' passes (36/36); scalastyle
clean.

Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Opus 4.8

`col LIKE '%'` (a pattern of only unescaped `%`) matches every non-null value, so a filter
`WHERE col LIKE '%'` keeps exactly the non-null rows. Spark evaluates it as a per-row regex
(`.*`) and pushes nothing to the data source.

This rewrites such patterns, in null-rejecting predicate positions, to `IsNotNull(col)`:

    Filter (col LIKE '%')   ==>   Filter (isnotnull(col))

`IsNotNull` is far cheaper than a per-row regex, and it pushes down (Parquet/ORC skip row
groups via null-count statistics). If `col` is non-nullable, `IsNotNull(col)` then folds to
`true` and downstream `PruneFilters`/`NullPropagation` drop the filter entirely. The pattern
shows up from dynamically-generated SQL (an empty search box becoming `LIKE '%'`).

`col LIKE '%'` returns `true` for a non-null value and `null` for `null`, whereas
`IsNotNull(col)` returns `false` for `null`. They are equivalent only in null-rejecting
predicate positions, where `null` is treated as `false`. The rewrite is therefore implemented
in `ReplaceNullWithFalseInPredicate`, which already applies this kind of null-as-false
simplification (see its `Not(In(...))` handling): it targets exactly the null-rejecting plan
nodes and recurses only through null-preserving operators (`And`, `Or`, `If`, `CaseWhen`), not
into `Not` -- so `NOT (col LIKE '%')` and `col LIKE '%'` in a projection are left untouched,
where `null` vs `false` would be observable.

The rewrite fires only for a literal pattern of one or more `%` with the escape character not
being `%`; `LIKE ''`, prefixes, and `_` patterns are excluded. No collation gate is needed --
`'%'` matches every non-null value under any collation. `LikeAll`/`LikeAny` are out of scope.

Generated-by: Claude Opus 4.8
@david-mollitor-db
david-mollitor-db force-pushed the like-percent-to-isnotnull branch from ebb58fa to 7d9ab94 Compare September 9, 2026 13:21
@uros-b

uros-b commented Sep 10, 2026

Copy link
Copy Markdown
Member

Nice rewrite, thank you @david-mollitor-db! Adding @stevomitric to also review this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants