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

Wenchen Fan reassigned SPARK-57988:
-----------------------------------

    Assignee: Joel Robin

> 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
>            Assignee: Joel Robin
>            Priority: Minor
>              Labels: pull-request-available
>
> 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. Error
> Parsing the generated pushdown SQL fails, e.g.:
> {code}
> [PARSE_SYNTAX_ERROR] Syntax error at or near 'NOT'. SQLSTATE: 42601
> {code}
> (The exact offending token is {{IS}} or {{NOT}} depending on the surrounding 
> predicate and the source dialect.) When the operand is pushed to a JDBC 
> external engine the malformed SQL is rejected by that engine and surfaces as 
> a {{JDBC_EXTERNAL_ENGINE_SYNTAX_ERROR}} instead.
> 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