cloud-fan commented on code in PR #58648:
URL: https://github.com/apache/spark/pull/58648#discussion_r4068433115


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/statsEstimation/FilterEstimation.scala:
##########
@@ -269,6 +280,59 @@ case class FilterEstimation(plan: Filter) extends Logging {
     Some(percent)
   }
 
+  /**
+   * Returns a percentage of rows meeting a null-intolerant string predicate
+   * (StartsWith / EndsWith / Contains) with a string literal operand.
+   *
+   * These predicates never match a null input, so at most the non-null rows 
can match, giving an
+   * upper bound of `1 - nullPercent`. In addition, a value must have at least 
as many characters
+   * as the operand, so if the operand is longer than the column's `maxLen` no 
row can match. Both
+   * bounds reuse statistics already collected (`nullCount`, `maxLen`) and 
hold under any collation
+   * (`maxLen` is a code-point count). This is a conservative estimate -- it 
never under-estimates
+   * -- and improves on the default of 1.0 (all rows) used for unsupported 
predicates.
+   *
+   * @param attr an Attribute (or a column)
+   * @param literal the non-null string literal operand
+   * @param update a boolean flag to specify if we need to update ColumnStat 
of a given column
+   *               for subsequent conditions
+   * @return an optional double value to show the percentage of rows meeting a 
given condition.
+   *         It returns None if no statistics are collected for a given column.
+   */
+  def evaluateStringPredicate(
+      attr: Attribute,
+      literal: Literal,
+      update: Boolean): Option[Double] = {
+    if (!colStatsMap.contains(attr) || colStatsMap(attr).nullCount.isEmpty) {
+      logDebug("[CBO] No statistics for " + attr)
+      return None
+    }
+    val colStat = colStatsMap(attr)
+    val rowCountValue = childStats.rowCount.get
+    val nullPercent: Double = if (rowCountValue == 0) {
+      0
+    } else if (colStat.nullCount.get > rowCountValue) {
+      1
+    } else {
+      (BigDecimal(colStat.nullCount.get) / BigDecimal(rowCountValue)).toDouble
+    }
+
+    // A value must have at least as many characters as the operand to start 
with / end with /
+    // contain it. `maxLen` is the maximum code-point length, so this holds 
under any collation.
+    val operandLength = literal.value.asInstanceOf[UTF8String].numChars()
+    val percent = if (colStat.maxLen.exists(_ < operandLength)) {

Review Comment:
   **Non-blocking (P2):** After `IsNull(c)`, the mutable stats have 
`distinctCount = 0` but retain the original `nullCount`. This helper then 
recomputes the non-null fraction from the old row count and can retain rows for 
`IsNull(c) AND StartsWith(c, ...)`, although null values cannot match; 
reversing the conjunct order estimates zero. Please honor the all-null state 
established by the earlier conjunct before deriving this bound.
   
   See **Shared repair plan 1** in the review body.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/statsEstimation/FilterEstimation.scala:
##########
@@ -269,6 +280,59 @@ case class FilterEstimation(plan: Filter) extends Logging {
     Some(percent)
   }
 
+  /**
+   * Returns a percentage of rows meeting a null-intolerant string predicate
+   * (StartsWith / EndsWith / Contains) with a string literal operand.
+   *
+   * These predicates never match a null input, so at most the non-null rows 
can match, giving an
+   * upper bound of `1 - nullPercent`. In addition, a value must have at least 
as many characters
+   * as the operand, so if the operand is longer than the column's `maxLen` no 
row can match. Both
+   * bounds reuse statistics already collected (`nullCount`, `maxLen`) and 
hold under any collation
+   * (`maxLen` is a code-point count). This is a conservative estimate -- it 
never under-estimates
+   * -- and improves on the default of 1.0 (all rows) used for unsupported 
predicates.
+   *
+   * @param attr an Attribute (or a column)
+   * @param literal the non-null string literal operand
+   * @param update a boolean flag to specify if we need to update ColumnStat 
of a given column
+   *               for subsequent conditions
+   * @return an optional double value to show the percentage of rows meeting a 
given condition.
+   *         It returns None if no statistics are collected for a given column.
+   */
+  def evaluateStringPredicate(
+      attr: Attribute,
+      literal: Literal,
+      update: Boolean): Option[Double] = {
+    if (!colStatsMap.contains(attr) || colStatsMap(attr).nullCount.isEmpty) {

Review Comment:
   **Non-blocking (P2):** This path consumes `nullCount` even when the child is 
not a leaf. The adjacent `IsNull`/`IsNotNull` handling deliberately refuses 
that estimate because `JoinEstimation` does not maintain accurate null counts 
for outer joins. A string predicate above such a join can therefore 
under-estimate rows; please apply the same provenance restriction or use the 
conservative fallback.
   
   See **Shared repair plan 1** in the review body.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/statsEstimation/FilterEstimation.scala:
##########
@@ -214,10 +215,20 @@ case class FilterEstimation(plan: Filter) extends Logging 
{
       case op @ GreaterThanOrEqual(attrLeft: Attribute, attrRight: Attribute) 
=>
         evaluateBinaryForTwoColumns(op, attrLeft, attrRight, update)
 
+      // StartsWith/EndsWith/Contains are null-intolerant, so only non-null 
rows can match; and a
+      // value must be at least as long as the operand. We can bound their 
selectivity from
+      // `nullCount` and `maxLen` even without distribution statistics. `Like` 
still falls through
+      // (its common prefix/suffix/infix forms are already rewritten to the 
operators below).
+      case StartsWith(ar: Attribute, l @ Literal(v, StringType)) if v != null 
=>

Review Comment:
   **Blocking (P1):** `evaluateStringPredicate` returns a conservative upper 
bound, but the generic `Not` path complements every returned percentage as 
though it were ordinary selectivity. For a non-null column this helper returns 
1.0, so `NOT StartsWith(...)` is estimated at zero even when every row passes; 
simplified `NOT LIKE 'A%'` can reach the same path. Please preserve the 
upper-bound distinction or fall back conservatively when the result is negated.
   
   See **Shared repair plan 1** in the review body.



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/statsEstimation/FilterEstimationSuite.scala:
##########
@@ -579,6 +579,54 @@ class FilterEstimationSuite extends 
StatsEstimationTestBase {
       expectedRowCount = 10)
   }
 
+  test("cstring startsWith 'A' - bounded by the non-null fraction") {
+    // Only non-null rows can match, so selectivity <= 1 - nullPercent = 0.5.
+    val colStatNullableString = colStatString.copy(nullCount = Some(5))
+    validateEstimatedStats(
+      Filter(StartsWith(attrString, Literal("A")),
+        childStatsTestPlan(Seq(attrString), tableRowCount = 10L,
+          attributeMap = AttributeMap(Seq(attrString -> 
colStatNullableString)))),
+      Seq(attrString -> colStatString.copy(distinctCount = Some(5))),
+      expectedRowCount = 5)
+  }
+
+  test("cstring endsWith / contains 'A' - bounded by the non-null fraction") {
+    val colStatNullableString = colStatString.copy(nullCount = Some(5))
+    Seq(EndsWith(attrString, Literal("A")), Contains(attrString, 
Literal("A"))).foreach { cond =>
+      validateEstimatedStats(
+        Filter(cond,
+          childStatsTestPlan(Seq(attrString), tableRowCount = 10L,
+            attributeMap = AttributeMap(Seq(attrString -> 
colStatNullableString)))),
+        Seq(attrString -> colStatString.copy(distinctCount = Some(5))),
+        expectedRowCount = 5)
+    }
+  }
+
+  test("cstring startsWith operand longer than maxLen matches nothing") {
+    // colStatString.maxLen is 2, so a 3-character prefix cannot match any 
value.
+    validateEstimatedStats(
+      Filter(StartsWith(attrString, Literal("abc")), 
childStatsTestPlan(Seq(attrString), 10L)),
+      Seq(attrString -> colStatString),
+      expectedRowCount = 0)
+  }
+
+  test("cstring startsWith on an all-null column matches nothing") {
+    val colStatAllNull = colStatString.copy(nullCount = Some(10))
+    validateEstimatedStats(
+      Filter(StartsWith(attrString, Literal("A")),
+        childStatsTestPlan(Seq(attrString), tableRowCount = 10L,
+          attributeMap = AttributeMap(Seq(attrString -> colStatAllNull)))),
+      Seq(attrString -> colStatAllNull),
+      expectedRowCount = 0)
+  }
+
+  test("cstring startsWith on a non-null column is unchanged (selectivity 
1.0)") {
+    validateEstimatedStats(

Review Comment:
   **Non-blocking (P2):** These positive leaf cases stay green for the three 
failing interactions in the implementation: negating the new upper bound, 
evaluating a string predicate after `IsNull` has established an all-null state, 
and filtering above a non-leaf child with inaccurate `nullCount`. Please add 
regression-sensitive cases for those paths so the conservative-cardinality 
contract is enforced.
   
   See **Shared repair plan 1** in the review body.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to