[ 
https://issues.apache.org/jira/browse/SPARK-57988?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

Joel Robin updated SPARK-57988:
-------------------------------
    Description: 
h3. What happens

{{V2ExpressionSQLBuilder}} (the DSv2 pushdown SQL generator used by JDBC 
dialects, connectors, and other V2 sources) serializes {{IS NULL}} / {{IS NOT 
NULL}} by appending the keyword to the operand's SQL. SPARK-57243 added 
{{visitIsNullOperand}} to parenthesize the operand, but it only does so when 
the operand is a binary comparison ({{{}={}}}, {{{}<>{}}}, {{{}<{}}}, …). When 
the operand is any other compound predicate, most importantly an {{IN}} 
predicate, it is emitted unparenthesized:
{code:sql}
 "a" IN (1, 2) IS NOT NULL 
{code}
This SQL is rejected by SQL parsers because the operand of {{IS [NOT] NULL}} 
must be a scalar expression. For example, PostgreSQL reports a syntax error 
near {{{}NOT{}}}.
h3. How the predicate arises

This is not a hand-written expression. An ordinary filter such as
{code:sql}
WHERE col IN ('a', 'b') OR col NOT IN ('a', 'b')
{code}
is simplified by Catalyst's {{BooleanSimplification}} (the {{p OR NOT p}} rule) 
to {{IsNotNull(In(col, 'a', 'b'))}} – an {{IN}} predicate wrapped by 
{{{}IsNotNull{}}}. When that scan is pushed to a V2 source, the builder emits 
the invalid string above and the query fails.
h3. How to reproduce

{{sources.IsNotNull(String)}} cannot nest an {{{}IN{}}}, so construct the V2 
predicate directly – this is exactly the shape {{V2ExpressionBuilder}} produces 
for the optimized {{{}IsNotNull(In(a, 1, 2)){}}}:
{code:scala}
val dialect = JdbcDialects.get("jdbc:")
val a = FieldReference("a")
val inPred = new Predicate("IN",
  Array[V2Expression](a, LiteralValue(1, IntegerType), LiteralValue(2, 
IntegerType)))

Seq("IS_NULL" -> "IS NULL", "IS_NOT_NULL" -> "IS NOT NULL").foreach { case (op, 
kw) =>
  val expr = new Predicate(op, Array[V2Expression](inPred))
  println(dialect.compileExpression(expr))
}
{code}
Actual (buggy) output:
{code:java}
Some("a" IN (1, 2) IS NULL)
Some("a" IN (1, 2) IS NOT NULL)
{code}
Expected output:
{code:java}
Some(("a" IN (1, 2)) IS NULL)
Some(("a" IN (1, 2)) IS NOT NULL)
{code}
h3. Root cause

In {{V2ExpressionSQLBuilder.visitIsNullOperand}} the guard only parenthesizes 
binary-comparison operands:
{code:java}
protected String visitIsNullOperand(Expression operand) {
  if (operand instanceof GeneralScalarExpression e && 
isBinaryComparisonOperator(e.name())) {
    return "(" + build(operand) + ")";
  }
  return build(operand);
}
{code}
{{IN}} is not a binary comparison, so it is not wrapped. The same gap applies 
to the other operators whose rendered SQL is not a self-delimiting primary – 
the boolean connectives ({{{}AND{}}} / {{OR}} / {{{}NOT{}}}) and the 
LIKE-family – where the missing parentheses additionally cause a precedence 
mis-association (e.g. {{a AND b IS NULL}} parses as {{{}a AND (b IS NULL){}}}), 
not just a parse error. On lines that predate SPARK-57243 the operand is not 
parenthesized at all, so even binary comparisons are affected there.
h3. Fix

Extend {{visitIsNullOperand}} to parenthesize the operand for the operators 
that, like binary comparisons, render as non-self-delimiting SQL – {{{}IN{}}}, 
the boolean connectives, the LIKE-family, and arithmetic. Function calls, 
{{{}CASE{}}}, and {{CAST}} already render self-delimited ({{{}f(...){}}}, 
{{{}CASE ... END{}}}, {{{}CAST(...){}}}) and are intentionally left unwrapped 
so already-valid generated SQL does not change:
{code:java}
protected String visitIsNullOperand(Expression operand) {
  if (operand instanceof GeneralScalarExpression e && 
isNullOperandNeedsParens(e.name())) {
    return "(" + build(operand) + ")";
  }
  return build(operand);
}

// Operators whose rendered SQL is not a self-delimiting primary and therefore 
must be
// parenthesized before a trailing IS [NOT] NULL: binary comparisons 
(SPARK-57243) plus IN,
// the boolean connectives, LIKE-family, and arithmetic.
protected boolean isNullOperandNeedsParens(String name) {
  return isBinaryComparisonOperator(name) || switch (name) {
    case "IN", "NOT", "AND", "OR", "STARTS_WITH", "ENDS_WITH", "CONTAINS",
         "+", "-", "*", "/", "%", "&", "|", "^", "~" -> true;
    default -> false;
  };
}
{code}
Adding parentheses is semantics-preserving, so the change cannot alter query 
results – only the generated SQL string, and only for operands that previously 
produced invalid (or precedence-wrong) SQL.
h3. Impact

Any DataSource V2 implementation that relies on {{V2ExpressionSQLBuilder}} to 
serialize pushed filters may generate invalid SQL that pushes an {{IsNull}} / 
{{IsNotNull}} over a non-comparison predicate through 
{{V2ExpressionSQLBuilder}} receives invalid SQL and the query fails. The {{col 
IN (...) OR col NOT IN (...)}} -> {{IsNotNull(In)}} simplification is a common 
way to hit it. Pre-existing and long-standing.
h3. Testing

New regression test in {{org.apache.spark.sql.jdbc.JDBCSuite}} asserting the 
parenthesized output for both {{IS NULL}} and {{IS NOT NULL}} over an {{IN}} 
operand. Validated locally, all pass, no regressions:
 * {{JDBCSuite}} (includes the new test)
 * {{DataSourceV2StrategySuite}}
 * {{JDBCV2Suite}}
 * {{MsSqlServerDialectSuite}}
 * {{PostgresDialectSuite}}
 * {{DataSourceV2DataFrameSuite}}
 * {{V2ExpressionRoundTripTrackerSuite}}

 

  was:
h3. What happens

{{V2ExpressionSQLBuilder}} (the DSv2 pushdown SQL generator used by JDBC 
dialects, connectors, and other V2 sources) serializes {{IS NULL}} / {{IS NOT 
NULL}} by appending the keyword to the operand's SQL. SPARK-57243 added 
{{visitIsNullOperand}} to parenthesize the operand, but it only does so when 
the operand is a binary comparison ({{{}={}}}, {{{}<>{}}}, {{{}<{}}}, …). When 
the operand is any other compound predicate, most importantly an {{IN}} 
predicate, it is emitted unparenthesized:
{code:sql}
 
"a" IN (1, 2) IS NOT NULL 
{code}
This SQL is rejected by SQL parsers because the operand of {{IS [NOT] NULL}} 
must be a scalar expression. For example, PostgreSQL reports a syntax error 
near {{{}NOT{}}}.
h3. How the predicate arises

This is not a hand-written expression. An ordinary filter such as
{code:sql}
WHERE col IN ('a', 'b') OR col NOT IN ('a', 'b')
{code}
is simplified by Catalyst's {{BooleanSimplification}} (the {{p OR NOT p}} rule) 
to {{IsNotNull(In(col, 'a', 'b'))}} – an {{IN}} predicate wrapped by 
{{{}IsNotNull{}}}. When that scan is pushed to a V2 source, the builder emits 
the invalid string above and the query fails.
h3. How to reproduce

{{sources.IsNotNull(String)}} cannot nest an {{{}IN{}}}, so construct the V2 
predicate directly – this is exactly the shape {{V2ExpressionBuilder}} produces 
for the optimized {{{}IsNotNull(In(a, 1, 2)){}}}:
{code:scala}
val dialect = JdbcDialects.get("jdbc:")
val a = FieldReference("a")
val inPred = new Predicate("IN",
  Array[V2Expression](a, LiteralValue(1, IntegerType), LiteralValue(2, 
IntegerType)))

Seq("IS_NULL" -> "IS NULL", "IS_NOT_NULL" -> "IS NOT NULL").foreach { case (op, 
kw) =>
  val expr = new Predicate(op, Array[V2Expression](inPred))
  println(dialect.compileExpression(expr))
}
{code}
Actual (buggy) output:
{code:java}
Some("a" IN (1, 2) IS NULL)
Some("a" IN (1, 2) IS NOT NULL)
{code}
Expected output:
{code:java}
Some(("a" IN (1, 2)) IS NULL)
Some(("a" IN (1, 2)) IS NOT NULL)
{code}
h3. Root cause

In {{V2ExpressionSQLBuilder.visitIsNullOperand}} the guard only parenthesizes 
binary-comparison operands:
{code:java}
protected String visitIsNullOperand(Expression operand) {
  if (operand instanceof GeneralScalarExpression e && 
isBinaryComparisonOperator(e.name())) {
    return "(" + build(operand) + ")";
  }
  return build(operand);
}
{code}
{{IN}} is not a binary comparison, so it is not wrapped. The same gap applies 
to the other operators whose rendered SQL is not a self-delimiting primary – 
the boolean connectives ({{{}AND{}}} / {{OR}} / {{{}NOT{}}}) and the 
LIKE-family – where the missing parentheses additionally cause a precedence 
mis-association (e.g. {{a AND b IS NULL}} parses as {{{}a AND (b IS NULL){}}}), 
not just a parse error. On lines that predate SPARK-57243 the operand is not 
parenthesized at all, so even binary comparisons are affected there.
h3. Fix

Extend {{visitIsNullOperand}} to parenthesize the operand for the operators 
that, like binary comparisons, render as non-self-delimiting SQL – {{{}IN{}}}, 
the boolean connectives, the LIKE-family, and arithmetic. Function calls, 
{{{}CASE{}}}, and {{CAST}} already render self-delimited ({{{}f(...){}}}, 
{{{}CASE ... END{}}}, {{{}CAST(...){}}}) and are intentionally left unwrapped 
so already-valid generated SQL does not change:
{code:java}
protected String visitIsNullOperand(Expression operand) {
  if (operand instanceof GeneralScalarExpression e && 
isNullOperandNeedsParens(e.name())) {
    return "(" + build(operand) + ")";
  }
  return build(operand);
}

// Operators whose rendered SQL is not a self-delimiting primary and therefore 
must be
// parenthesized before a trailing IS [NOT] NULL: binary comparisons 
(SPARK-57243) plus IN,
// the boolean connectives, LIKE-family, and arithmetic.
protected boolean isNullOperandNeedsParens(String name) {
  return isBinaryComparisonOperator(name) || switch (name) {
    case "IN", "NOT", "AND", "OR", "STARTS_WITH", "ENDS_WITH", "CONTAINS",
         "+", "-", "*", "/", "%", "&", "|", "^", "~" -> true;
    default -> false;
  };
}
{code}
Adding parentheses is semantics-preserving, so the change cannot alter query 
results – only the generated SQL string, and only for operands that previously 
produced invalid (or precedence-wrong) SQL.
h3. Impact

Any DataSource V2 implementation that relies on {{V2ExpressionSQLBuilder}} to 
serialize pushed filters may generate invalid SQL that pushes an {{IsNull}} / 
{{IsNotNull}} over a non-comparison predicate through 
{{V2ExpressionSQLBuilder}} receives invalid SQL and the query fails. The {{col 
IN (...) OR col NOT IN (...)}} -> {{IsNotNull(In)}} simplification is a common 
way to hit it. Pre-existing and long-standing.
h3. Testing

New regression test in {{org.apache.spark.sql.jdbc.JDBCSuite}} asserting the 
parenthesized output for both {{IS NULL}} and {{IS NOT NULL}} over an {{IN}} 
operand. Validated locally, all pass, no regressions:
 * {{JDBCSuite}} (includes the new test)
 * {{DataSourceV2StrategySuite}}
 * {{JDBCV2Suite}}
 * {{MsSqlServerDialectSuite}}
 * {{PostgresDialectSuite}}
 * {{DataSourceV2DataFrameSuite}}
 * {{V2ExpressionRoundTripTrackerSuite}}

 


> V2ExpressionSQLBuilder produces invalid SQL for IS [NOT] NULL over an IN (and 
> other non-comparison predicate) operand
> ---------------------------------------------------------------------------------------------------------------------
>
>                 Key: SPARK-57988
>                 URL: https://issues.apache.org/jira/browse/SPARK-57988
>             Project: Spark
>          Issue Type: Bug
>          Components: SQL
>    Affects Versions: 4.1.2
>            Reporter: Joel Robin
>            Priority: Minor
>
> h3. What happens
> {{V2ExpressionSQLBuilder}} (the DSv2 pushdown SQL generator used by JDBC 
> dialects, connectors, and other V2 sources) serializes {{IS NULL}} / {{IS NOT 
> NULL}} by appending the keyword to the operand's SQL. SPARK-57243 added 
> {{visitIsNullOperand}} to parenthesize the operand, but it only does so when 
> the operand is a binary comparison ({{{}={}}}, {{{}<>{}}}, {{{}<{}}}, …). 
> When the operand is any other compound predicate, most importantly an {{IN}} 
> predicate, it is emitted unparenthesized:
> {code:sql}
>  "a" IN (1, 2) IS NOT NULL 
> {code}
> This SQL is rejected by SQL parsers because the operand of {{IS [NOT] NULL}} 
> must be a scalar expression. For example, PostgreSQL reports a syntax error 
> near {{{}NOT{}}}.
> h3. How the predicate arises
> This is not a hand-written expression. An ordinary filter such as
> {code:sql}
> WHERE col IN ('a', 'b') OR col NOT IN ('a', 'b')
> {code}
> is simplified by Catalyst's {{BooleanSimplification}} (the {{p OR NOT p}} 
> rule) to {{IsNotNull(In(col, 'a', 'b'))}} – an {{IN}} predicate wrapped by 
> {{{}IsNotNull{}}}. When that scan is pushed to a V2 source, the builder emits 
> the invalid string above and the query fails.
> h3. How to reproduce
> {{sources.IsNotNull(String)}} cannot nest an {{{}IN{}}}, so construct the V2 
> predicate directly – this is exactly the shape {{V2ExpressionBuilder}} 
> produces for the optimized {{{}IsNotNull(In(a, 1, 2)){}}}:
> {code:scala}
> val dialect = JdbcDialects.get("jdbc:")
> val a = FieldReference("a")
> val inPred = new Predicate("IN",
>   Array[V2Expression](a, LiteralValue(1, IntegerType), LiteralValue(2, 
> IntegerType)))
> Seq("IS_NULL" -> "IS NULL", "IS_NOT_NULL" -> "IS NOT NULL").foreach { case 
> (op, kw) =>
>   val expr = new Predicate(op, Array[V2Expression](inPred))
>   println(dialect.compileExpression(expr))
> }
> {code}
> Actual (buggy) output:
> {code:java}
> Some("a" IN (1, 2) IS NULL)
> Some("a" IN (1, 2) IS NOT NULL)
> {code}
> Expected output:
> {code:java}
> Some(("a" IN (1, 2)) IS NULL)
> Some(("a" IN (1, 2)) IS NOT NULL)
> {code}
> h3. Root cause
> In {{V2ExpressionSQLBuilder.visitIsNullOperand}} the guard only parenthesizes 
> binary-comparison operands:
> {code:java}
> protected String visitIsNullOperand(Expression operand) {
>   if (operand instanceof GeneralScalarExpression e && 
> isBinaryComparisonOperator(e.name())) {
>     return "(" + build(operand) + ")";
>   }
>   return build(operand);
> }
> {code}
> {{IN}} is not a binary comparison, so it is not wrapped. The same gap applies 
> to the other operators whose rendered SQL is not a self-delimiting primary – 
> the boolean connectives ({{{}AND{}}} / {{OR}} / {{{}NOT{}}}) and the 
> LIKE-family – where the missing parentheses additionally cause a precedence 
> mis-association (e.g. {{a AND b IS NULL}} parses as {{{}a AND (b IS 
> NULL){}}}), not just a parse error. On lines that predate SPARK-57243 the 
> operand is not parenthesized at all, so even binary comparisons are affected 
> there.
> h3. Fix
> Extend {{visitIsNullOperand}} to parenthesize the operand for the operators 
> that, like binary comparisons, render as non-self-delimiting SQL – 
> {{{}IN{}}}, the boolean connectives, the LIKE-family, and arithmetic. 
> Function calls, {{{}CASE{}}}, and {{CAST}} already render self-delimited 
> ({{{}f(...){}}}, {{{}CASE ... END{}}}, {{{}CAST(...){}}}) and are 
> intentionally left unwrapped so already-valid generated SQL does not change:
> {code:java}
> protected String visitIsNullOperand(Expression operand) {
>   if (operand instanceof GeneralScalarExpression e && 
> isNullOperandNeedsParens(e.name())) {
>     return "(" + build(operand) + ")";
>   }
>   return build(operand);
> }
> // Operators whose rendered SQL is not a self-delimiting primary and 
> therefore must be
> // parenthesized before a trailing IS [NOT] NULL: binary comparisons 
> (SPARK-57243) plus IN,
> // the boolean connectives, LIKE-family, and arithmetic.
> protected boolean isNullOperandNeedsParens(String name) {
>   return isBinaryComparisonOperator(name) || switch (name) {
>     case "IN", "NOT", "AND", "OR", "STARTS_WITH", "ENDS_WITH", "CONTAINS",
>          "+", "-", "*", "/", "%", "&", "|", "^", "~" -> true;
>     default -> false;
>   };
> }
> {code}
> Adding parentheses is semantics-preserving, so the change cannot alter query 
> results – only the generated SQL string, and only for operands that 
> previously produced invalid (or precedence-wrong) SQL.
> h3. Impact
> Any DataSource V2 implementation that relies on {{V2ExpressionSQLBuilder}} to 
> serialize pushed filters may generate invalid SQL that pushes an {{IsNull}} / 
> {{IsNotNull}} over a non-comparison predicate through 
> {{V2ExpressionSQLBuilder}} receives invalid SQL and the query fails. The 
> {{col IN (...) OR col NOT IN (...)}} -> {{IsNotNull(In)}} simplification is a 
> common way to hit it. Pre-existing and long-standing.
> h3. Testing
> New regression test in {{org.apache.spark.sql.jdbc.JDBCSuite}} asserting the 
> parenthesized output for both {{IS NULL}} and {{IS NOT NULL}} over an {{IN}} 
> operand. Validated locally, all pass, no regressions:
>  * {{JDBCSuite}} (includes the new test)
>  * {{DataSourceV2StrategySuite}}
>  * {{JDBCV2Suite}}
>  * {{MsSqlServerDialectSuite}}
>  * {{PostgresDialectSuite}}
>  * {{DataSourceV2DataFrameSuite}}
>  * {{V2ExpressionRoundTripTrackerSuite}}
>  



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

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

Reply via email to