From 02434e3c8c970e4bc0de11b6f2115e40788e07d3 Mon Sep 17 00:00:00 2001 From: David Mollitor Date: Wed, 9 Sep 2026 15:31:44 +0000 Subject: [PATCH] [SPARK-59371][SQL] Gate the startsAndEndsWith LikeSimplification rewrite on a cheap child `LikeSimplification` rewrites `LIKE 'prefix%suffix'` (the `startsAndEndsWith` shape, e.g. `'a%b'`) into `lengthGuard(input) && StartsWith(input, prefix) && EndsWith(input, postfix)`, referencing the child three times. The single-`Like` branch of `LikeSimplification.apply` applied this unconditionally, unlike the multi-LIKE branches, which only fire when `CollapseProject.isCheap(child)` (SPARK-40228). For a non-cheap child this duplicated the child: a nondeterministic child (e.g. `uuid()`) was evaluated three times with different values -- changing results -- and an expensive child (e.g. `sha2`) was evaluated repeatedly. Gate the `startsAndEndsWith` case on `CollapseProject.isCheap(input)`, leaving the pattern as a plain `Like` for a non-cheap child. The single-reference shapes evaluate the child once, like the original `LIKE`, so they remain enabled for any child. Generated-by: Claude Opus 4.8 Co-authored-by: Isaac --- .../sql/catalyst/optimizer/expressions.scala | 7 ++++- .../optimizer/LikeSimplificationSuite.scala | 26 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/expressions.scala b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/expressions.scala index cdc0444d74cc1..5cd12c1165cc7 100644 --- a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/expressions.scala +++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/expressions.scala @@ -831,7 +831,12 @@ object LikeSimplification extends Rule[LogicalPlan] with PredicateHelper { Some(EndsWith(input, Literal.create(postfix, input.dataType))) // 'a%a' pattern is basically same with 'a%' && '%a'. // However, the additional length condition is required to prevent 'a' match 'a%a'. - case startsAndEndsWith(prefix, postfix) => + // This rewrite references `input` three times (length guard, StartsWith, EndsWith), so + // gate it on `input` being cheap to duplicate -- mirroring the SPARK-40228 gate on the + // multiLike rules below. A non-cheap child (e.g. `sha2(col)`, or a nondeterministic + // `uuid()` that would otherwise draw a different value per reference) is left as `Like`; + // the single-reference shapes are unaffected. + case startsAndEndsWith(prefix, postfix) if CollapseProject.isCheap(input) => // The length guard only rejects inputs too short to hold both the prefix and the // suffix. When the collation matches raw bytes (supportsBinaryEquality), // StartsWith/EndsWith pin the literal bytes of the prefix and suffix, so a diff --git a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/LikeSimplificationSuite.scala b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/LikeSimplificationSuite.scala index 8b142f0c53d75..bc58c2601788c 100644 --- a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/LikeSimplificationSuite.scala +++ b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/LikeSimplificationSuite.scala @@ -312,6 +312,32 @@ class LikeSimplificationSuite extends PlanTest { comparePlans(Optimize.execute(originalQuery), originalQuery) } + test("SPARK-59371: do not simplify startsAndEndsWith LIKE for a non-cheap child") { + // 'a%b' rewrites to a length guard + StartsWith + EndsWith, referencing the child three + // times. Duplicating a non-cheap child would re-evaluate it (and yield different values for a + // nondeterministic one), so this shape must be left as Like -- cf. the SPARK-40228 gate above. + val originalQuery = testRelation.where($"a".substring(1, 5) like "a%b").analyze + comparePlans(Optimize.execute(originalQuery), originalQuery) + } + + test("SPARK-59371: still simplify single-reference LIKE shapes for a non-cheap child") { + // The single-reference shapes evaluate the child once, exactly like the original LIKE, so they + // stay enabled for a non-cheap child; only the duplicating startsAndEndsWith shape is gated. + val child = $"a".substring(1, 5) + comparePlans( + Optimize.execute(testRelation.where(child like "a%").analyze), + testRelation.where(StartsWith(child, "a")).analyze) + comparePlans( + Optimize.execute(testRelation.where(child like "%b").analyze), + testRelation.where(EndsWith(child, "b")).analyze) + comparePlans( + Optimize.execute(testRelation.where(child like "%ab%").analyze), + testRelation.where(Contains(child, "ab")).analyze) + comparePlans( + Optimize.execute(testRelation.where(child like "abc").analyze), + testRelation.where(EqualTo(child, "abc")).analyze) + } + // scalastyle:off nonascii test("SPARK-59063: LikeSimplification preserves LIKE semantics under non-binary collation") { // Under UTF8_LCASE, StartsWith/EndsWith are collation-aware, so a single code point