dongjoon-hyun commented on code in PR #59992:
URL: https://github.com/apache/spark/pull/59992#discussion_r4433554380


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/constraints.scala:
##########
@@ -128,6 +132,25 @@ case class CheckConstraint(
 
   override def inputTypes: Seq[AbstractDataType] = Seq(BooleanType)
 
+  /**
+   * Fails the analysis if the condition calls a user-defined function. A 
write can read `condition`
+   * again and resolve the name of the function in its own session, where the 
name can refer to
+   * another function or to none, so the constraint would not enforce what 
this DDL declares.
+   */
+  def failIfCallsUserDefinedFunction(): Unit = {
+    child.collectFirst {
+      case f: SQLFunctionExpression => toSQLId(f.function.name.nameParts)
+      case f: UserDefinedExpression => toSQLId(Seq(f.name))
+      case i: StaticInvoke if i.scalarFunction.isDefined => 
toSQLId(Seq(i.scalarFunction.get.name))
+      case Invoke(Literal(f: ScalarFunction[_], _), _, _, _, _, _, _, _) => 
toSQLId(Seq(f.name))
+      case a: V2Aggregator[_, _] => toSQLId(Seq(a.aggrFunc.name))
+    }.foreach { functionName =>
+      child.failAnalysis(

Review Comment:
   Done. `failAnalysis` is now called on the matched node, so the query context 
points at the call, such as `fun_test_catalog.ns.strlen(name)`. `functionName` 
still shows the bound name because the name in the condition is not available 
after resolution.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/constraints.scala:
##########
@@ -128,6 +132,25 @@ case class CheckConstraint(
 
   override def inputTypes: Seq[AbstractDataType] = Seq(BooleanType)
 
+  /**
+   * Fails the analysis if the condition calls a user-defined function. A 
write can read `condition`
+   * again and resolve the name of the function in its own session, where the 
name can refer to
+   * another function or to none, so the constraint would not enforce what 
this DDL declares.
+   */
+  def failIfCallsUserDefinedFunction(): Unit = {
+    child.collectFirst {
+      case f: SQLFunctionExpression => toSQLId(f.function.name.nameParts)
+      case f: UserDefinedExpression => toSQLId(Seq(f.name))
+      case i: StaticInvoke if i.scalarFunction.isDefined => 
toSQLId(Seq(i.scalarFunction.get.name))
+      case Invoke(Literal(f: ScalarFunction[_], _), _, _, _, _, _, _, _) => 
toSQLId(Seq(f.name))
+      case a: V2Aggregator[_, _] => toSQLId(Seq(a.aggrFunc.name))

Review Comment:
   Kept. Without this case, `CREATE TABLE` accepts a V2 aggregate and every 
later write fails. I'll revisit it when `CREATE TABLE` and `REPLACE TABLE` 
reject all aggregates under a separate JIRA.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala:
##########
@@ -3110,6 +3110,9 @@ class Analyzer(
       // of this rule.
       case f: SQLTableFunction if hasSQLFunctionExpression(f.expressions) => f
 
+      // CheckAnalysis rejects a SQL function in the condition of a check 
constraint.
+      case a: AddCheckConstraint => a.mapChildren(rewrite)

Review Comment:
   Removed. `ALTER TABLE` keeps failing with `UNSUPPORTED_SQL_UDF_USAGE` on a 
SQL function, as it does with `INVALID_WHERE_CONDITION` on an aggregate.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveTableConstraints.scala:
##########
@@ -76,7 +77,20 @@ class ResolveTableConstraints(val catalogManager: 
CatalogManager) extends Rule[L
   private def buildCatalystExpression(c: Check): Expression = {
     Option(c.predicate())
       .flatMap(V2ExpressionUtils.toCatalyst)
-      
.getOrElse(catalogManager.v1SessionCatalog.parser.parseExpression(c.predicateSql()))
+      .getOrElse(qualifyBuiltinFunctions(
+        
catalogManager.v1SessionCatalog.parser.parseExpression(c.predicateSql())))
+  }
+
+  /**
+   * Qualifies the built-in functions a parsed check condition calls, so that 
a temporary function
+   * or the SQL path of the session of the write cannot shadow them. Any other 
name is left as is:
+   * a user-defined function in a constraint that the DDL no longer accepts 
still resolves here.
+   */
+  private def qualifyBuiltinFunctions(condition: Expression): Expression = 
condition.transform {
+    case f @ UnresolvedFunction(Seq(name), _, _, _, _, _, _, _)
+        if FunctionRegistry.builtin.functionExists(FunctionIdentifier(name)) =>

Review Comment:
   Done. The Scaladoc and the PR description say so now.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/CheckConstraintSuite.scala:
##########
@@ -170,6 +186,152 @@ class CheckConstraintSuite extends QueryTest with 
CommandSuiteBase with DDLComma
     }
   }
 
+  private def withV2Function(name: String, function: UnboundFunction)(f: => 
Unit): Unit = {
+    val funCatalog = spark.sessionState.catalogManager.catalog(s"fun_$catalog")
+      .asInstanceOf[InMemoryCatalog]
+    val ident = Identifier.of(Array("ns"), name)
+    withNamespace(s"fun_$catalog.ns") {
+      sql(s"CREATE NAMESPACE fun_$catalog.ns")
+      funCatalog.createFunction(ident, function)
+      try f finally funCatalog.dropFunction(ident)
+    }
+  }
+
+  // CREATE TABLE, REPLACE TABLE and, if `inAlter`, ALTER TABLE fail on the 
condition.
+  private def checkUserDefinedFunctionRejected(
+      cond: String,
+      functionName: String,
+      inAlter: Boolean = true): Unit = {
+    withNamespaceAndTable("ns", "tbl", nonPartitionCatalog) { t =>
+      sql(s"CREATE TABLE $t (id INT, name STRING) $defaultUsing")
+      val alter = if (inAlter) Seq(s"ALTER TABLE $t ADD CONSTRAINT c1 CHECK 
($cond)") else Nil
+      (alter ++ Seq(
+        s"CREATE TABLE ${t}_2 (id INT, name STRING, CONSTRAINT c1 CHECK 
($cond)) $defaultUsing",
+        s"REPLACE TABLE $t (id INT, name STRING, CONSTRAINT c1 CHECK ($cond)) 
$defaultUsing"
+      )).foreach { query =>
+        checkError(
+          exception = intercept[AnalysisException](sql(query)),
+          condition = "USER_DEFINED_FUNCTION_IN_CHECK_CONSTRAINT",
+          sqlState = "42621",
+          parameters = Map("checkCondition" -> cond, "functionName" -> 
functionName),
+          context = ExpectedContext(fragment = cond))
+      }
+    }
+  }
+
+  test("SPARK-60777: Check constraint cannot call a user-defined function") {
+    val call = s"fun_$catalog.ns.strlen"
+    Seq(
+      (StrLenDefault, s"$call(name) < 10", "`strlen_default`"),
+      // The optimizer folds a call with constant arguments, but the DDL does 
not.
+      (StrLenDefault, s"id < $call('abc')", "`strlen_default`"),
+      (StrLenMagic, s"$call(name) < 10", "`strlen_magic`"),
+      (new JavaStrLenStaticMagic, s"$call(name) < 10", "`strlen`")
+    ).foreach { case (function, cond, functionName) =>
+      withV2Function("strlen", StrLen(function)) {
+        checkUserDefinedFunctionRejected(cond, functionName)
+      }
+    }
+
+    withUserDefinedFunction("tmp_len" -> true, "tmp_sql_len" -> true, 
"sql_len" -> false) {
+      spark.udf.register("tmp_len", (s: String) => s.length)
+      checkUserDefinedFunctionRejected("tmp_len(name) < 10", "`tmp_len`")
+      sql("CREATE TEMPORARY FUNCTION tmp_sql_len(x STRING) RETURNS INT RETURN 
length(x)")
+      checkUserDefinedFunctionRejected("tmp_sql_len(name) < 10", 
"`tmp_sql_len`")
+      sql("CREATE FUNCTION sql_len(x STRING) RETURNS INT RETURN length(x)")
+      checkUserDefinedFunctionRejected("sql_len(name) < 10", 
"`spark_catalog`.`default`.`sql_len`")
+    }
+  }
+
+  test("SPARK-60777: Check constraint cannot call a V2 aggregate function") {
+    withV2Function("avg", new JavaAverage) {
+      // ALTER TABLE keeps failing with the error for an aggregate in a filter.
+      checkUserDefinedFunctionRejected(
+        s"fun_$catalog.ns.avg(CAST(id AS DOUBLE)) > 0", "`davg`", inAlter = 
false)
+    }
+  }
+
+  test("SPARK-60777: Existing check constraint calling a user-defined function 
still works") {
+    withUserDefinedFunction("tmp_len" -> true) {
+      spark.udf.register("tmp_len", (s: String) => s.length)
+      withNamespaceAndTable("ns", "tbl", nonPartitionCatalog) { t =>
+        sql(s"CREATE TABLE $t (id INT, name STRING) $defaultUsing")
+        val check = Constraint.check("c1").predicateSql("tmp_len(name) < 
10").build()
+        
spark.sessionState.catalogManager.catalog(nonPartitionCatalog).asInstanceOf[TableCatalog]
+          .alterTable(Identifier.of(Array("ns"), "tbl"), 
TableChange.addConstraint(check, null))
+        sql(s"INSERT INTO $t VALUES (1, 'abc')")
+        checkError(
+          exception = intercept[SparkRuntimeException] {
+            sql(s"INSERT INTO $t VALUES (2, 'abcdefghijklmnop')")
+          },
+          condition = "CHECK_CONSTRAINT_VIOLATION",
+          sqlState = "23001",
+          parameters = Map(
+            "constraintName" -> "c1",
+            "expression" -> "tmp_len(name) < 10",
+            "values" -> " - name : abcdefghijklmnop"))
+        checkAnswer(spark.table(t).select("id"), Row(1))
+      }
+    }
+  }
+
+  test("SPARK-60777: Check constraint is not shadowed in the session of a 
write") {
+    def checkEnforced(t: String): Unit = {
+      // The settings of the write do shadow the built-in function outside of 
the constraint.
+      checkAnswer(sql("SELECT upper('x')"), Row("OK"))
+      checkError(
+        exception = intercept[SparkRuntimeException] {
+          sql(s"INSERT INTO $t VALUES (1, 'x')")
+        },
+        condition = "CHECK_CONSTRAINT_VIOLATION",
+        sqlState = "23001",
+        parameters = Map(
+          "constraintName" -> "c1",
+          "expression" -> "UPPER(s) <> 'X'",
+          "values" -> " - s : x"))
+      checkAnswer(spark.table(t), Nil)
+    }
+
+    withV2Function("upper", StrLen(ConstantString)) {
+      withNamespaceAndTable("ns", "tbl", nonPartitionCatalog) { t =>
+        sql(s"CREATE TABLE $t (id INT, s STRING) $defaultUsing")
+        sql(s"ALTER TABLE $t ADD CONSTRAINT c1 CHECK (UPPER(s) <> 'X')")
+        withSQLConf(SQLConf.PATH_ENABLED.key -> "true") {
+          sql(s"SET PATH = fun_$catalog.ns, system.builtin")
+          try checkEnforced(t) finally sql("SET PATH = DEFAULT_PATH")
+        }
+        spark.udf.register("upper", (_: String) => "OK")
+        try {
+          withSQLConf(SQLConf.SESSION_FUNCTION_RESOLUTION_ORDER.key -> 
"first") {
+            checkEnforced(t)
+          }
+        } finally {
+          sql("DROP TEMPORARY FUNCTION upper")
+        }
+      }
+    }
+  }
+
+  test("SPARK-60777: Check constraint calling built-in functions is enforced") 
{
+    Seq(
+      "UPPER(s) <> 'X'",

Review Comment:
   Removed, along with the `s` column that only this case used.



##########
docs/sql-migration-guide.md:
##########
@@ -67,6 +67,7 @@ license: |
 - Since Spark 4.4, when a format is specified, `to_timestamp`, 
`try_to_timestamp`, and `to_timestamp_ltz` cast a numeric first argument to a 
string and parse it with the format, for example `to_timestamp(20201231, 
'yyyyMMdd')` returns `2020-12-31 00:00:00`. This matches the existing behavior 
when `spark.sql.timestampType` is `TIMESTAMP_NTZ`. Previously, with the default 
`TIMESTAMP_LTZ` timestamp type, such calls failed with `INTERNAL_ERROR`. Note 
that the string form of the number is parsed, so a DECIMAL `1.5` is parsed as 
`"1.5"`.
 - Since Spark 4.4, the Spark Thrift Server logs a warning at startup when 
`hive.server2.enable.doAs` is `true` and `hive.server2.authentication` verifies 
user identities (any recognized type other than `NONE`/`NOSASL`), because 
executor-side data access still runs as the server's own service identity 
rather than the connecting user (SPARK-5159). Set 
`spark.sql.hive.thriftServer.allowIneffectiveDoAs` to `true` to acknowledge the 
limitation and silence the warning. Spark 5.0 is expected to refuse to start on 
this configuration unless that option is set.
 - Since Spark 4.4, `UPDATE` and `MERGE` on a Data Source V2 table fail with 
`DATATYPE_MISMATCH.INVALID_ROW_LEVEL_OPERATION_ASSIGNMENTS` when they assign to 
a metadata column of the table. This includes `UPDATE SET *` and `INSERT *` in 
`MERGE WITH SCHEMA EVOLUTION` when the source has a column with the name of a 
metadata column of the target table. Previously, these assignments passed 
analysis and were usually ignored. To restore the previous behavior, set 
`spark.sql.legacy.ignoreMetadataColumnAssignments` to `true`.
+- Since Spark 4.4, `CREATE TABLE`, `REPLACE TABLE`, and `ALTER TABLE ... ADD 
CONSTRAINT` on a Data Source V2 table fail with 
`USER_DEFINED_FUNCTION_IN_CHECK_CONSTRAINT` when the condition of a CHECK 
constraint calls a user-defined function, including a Data Source V2 function. 
Previously, they accepted such a condition, except that `ALTER TABLE ... ADD 
CONSTRAINT` failed with `UNSUPPORTED_SQL_UDF_USAGE` on a SQL function, and a 
write could resolve the function name in its own session, so that it enforced 
another rule or failed (SPARK-60777). To run these statements, change the 
condition to call only built-in functions. Existing constraints that call a 
user-defined function are not rejected. Also, a write that parses the SQL text 
of a condition binds an unqualified built-in function name to `system.builtin`, 
regardless of the SQL path and `spark.sql.functionResolution.sessionOrder`, so 
an existing constraint no longer calls another function with the same name.

Review Comment:
   Done. It is split into two bullets.



-- 
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