This is an automated email from the ASF dual-hosted git repository.

LuciferYang pushed a commit to branch branch-4.3
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-4.3 by this push:
     new d6209be75a6a [SPARK-58707][SQL] Do not prune a JSON schema down to the 
corrupt record column
d6209be75a6a is described below

commit d6209be75a6a11d8f1a15aedeb70c042afb7d63f
Author: YangJie <[email protected]>
AuthorDate: Thu Aug 13 11:01:28 2026 +0800

    [SPARK-58707][SQL] Do not prune a JSON schema down to the corrupt record 
column
    
    ### What changes were proposed in this pull request?
    
    `OptimizeCsvJsonExprs` prunes a `JsonToStructs` schema to the fields being 
selected. Dropping a field stops the parser from converting it, so a malformed 
value there is never detected and the corrupt record column comes back null 
instead of the record text. This PR stops the two JSON branches from pruning in 
that case.
    
    The two branches need different conditions:
    
    - The `GetStructField` branch prunes to exactly one field, so selecting the 
corrupt record column there always drops the rest. It gets the same guard the 
CSV branch added by SPARK-32968 already has.
    - The `CreateNamedStruct` branch keeps every selected field, so it only 
becomes unsafe when the selection is a strict subset of the schema. Guarding on 
"corrupt column is selected" alone would give up the parse-deduplication 
rewrite for an all-fields selection that drops nothing, turning one parse into 
N.
    
    `GetArrayStructFields` needs no guard: an `ArrayType` schema takes 
`JsonToStructsEvaluator`'s `case other =>`, where `corruptFieldIndex` is 
`None`, so corrupt-record semantics do not exist on that path.
    
    ### Why are the changes needed?
    
    The corrupt record column silently returns null for a record that did fail 
to parse. The rule requires `options.isEmpty`, which is the PERMISSIVE default, 
and `spark.sql.optimizer.enableJsonExpressionOptimization` defaults to true, so 
no configuration is needed to hit it:
    
    ```scala
    Seq("""{"a": 1, "b": "bad"}""").toDS()
      .selectExpr("from_json(value, 'a int, b int, _corrupt_record string') as 
p")
      .selectExpr("p._corrupt_record")
    ```
    
    | spark.sql.optimizer.enableJsonExpressionOptimization | result |
    |---|---|
    | true | `null` |
    | false | `{"a": 1, "b": "bad"}` |
    
    The optimized plan shows the schema pruned to the corrupt column alone, 
which leaves `actualSchema` empty so the parser has nothing to convert and 
never raises `BadRecordException`:
    
    ```
    Project [from_json(StructField(_corrupt_record,StringType,true), value#1, 
...)._corrupt_record ...]
    ```
    
    A type mismatch on a dropped field is needed to expose this. The existing 
SPARK-33907 test uses a structurally malformed record, which fails at 
tokenization whatever schema is requested, so it never depended on the pruning; 
that test is now annotated to say the rule no longer fires for it.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes, it fixes the wrong result above. With this PR the corrupt record 
column holds the record text under both settings of the config, matching the 
unoptimized behavior.
    
    ### How was this patch tested?
    
    New tests, plan-level and end-to-end:
    
    - `OptimizeJsonExprsSuite`: two `comparePlans` cases, each with a control. 
One asserts the `GetStructField` rewrite is skipped for the corrupt column 
while a normal field is still pruned; the other asserts the `CreateNamedStruct` 
rewrite still fires when every field is selected and is skipped when the 
selection drops a field.
    - `JsonFunctionsSuite`: two `checkAnswer` cases covering both branches with 
the optimization on and off.
    
    Verified by reverting each guard on the fixed tree: removing the 
`GetStructField` guard fails both new catalyst tests, and weakening 
`prunesCorruptRecord` to "corrupt column selected" fails the `named_struct` 
case. Full `JsonFunctionsSuite` (115) and `OptimizeJsonExprsSuite` + 
`OptimizeCsvExprsSuite` (36) pass.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Opus 5
    
    Closes #57924 from LuciferYang/SPARK-json-corrupt-record.
    
    Authored-by: YangJie <[email protected]>
    Signed-off-by: yangjie01 <[email protected]>
    (cherry picked from commit bdea0f8e9389b87be88d41660f7a8b7bc7f3e21b)
    Signed-off-by: yangjie01 <[email protected]>
---
 .../catalyst/optimizer/OptimizeCsvJsonExprs.scala  | 20 +++++++++-
 .../optimizer/OptimizeJsonExprsSuite.scala         | 43 ++++++++++++++++++++++
 .../org/apache/spark/sql/JsonFunctionsSuite.scala  | 34 +++++++++++++++++
 3 files changed, 95 insertions(+), 2 deletions(-)

diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/OptimizeCsvJsonExprs.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/OptimizeCsvJsonExprs.scala
index 4f6c3a2da457..f04ff990251d 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/OptimizeCsvJsonExprs.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/OptimizeCsvJsonExprs.scala
@@ -44,6 +44,10 @@ import org.apache.spark.unsafe.types.UTF8String
 object OptimizeCsvJsonExprs extends Rule[LogicalPlan] {
   private def nameOfCorruptRecord = conf.columnNameOfCorruptRecord
 
+  /** Whether the field this extracts is the corrupt record column of its 
`JsonToStructs`. */
+  private def selectsCorruptRecord(g: GetStructField): Boolean =
+    g.childSchema(g.ordinal).name == nameOfCorruptRecord
+
   private type SimpleJsonPath = Seq[GetJsonObject.SimpleJsonPathSegment]
   private type SharedJsonCandidate = (GetJsonObject, SimpleJsonPath, String)
   private type SharedJsonCandidateUnit = Seq[SharedJsonCandidate]
@@ -414,9 +418,18 @@ object OptimizeCsvJsonExprs extends Rule[LogicalPlan] {
       // `JsonToStructs` does not support parsing json with duplicated field 
names.
       val duplicateFields = c.names.map(_.toString).distinct.length != 
c.names.length
 
+      // Dropping a field stops the parser from converting it, so the parser 
never records the
+      // row when a dropped field contains a malformed value, and the corrupt 
record column comes
+      // back null. Selecting as many fields as the schema has drops nothing: 
with `sameFieldName`
+      // and no duplicates the selected names are distinct names of the 
schema, so the counts
+      // match only when every field is selected.
+      val fields = c.valExprs.map(_.asInstanceOf[GetStructField])
+      val prunesCorruptRecord = fields.exists(selectsCorruptRecord) &&
+        fields.length != fields.head.childSchema.length
+
       // If we create struct from various fields of the same `JsonToStructs` 
and we don't
       // alias field names and there is no duplicated field in the struct.
-      if (sameFieldName && !duplicateFields) {
+      if (sameFieldName && !duplicateFields && !prunesCorruptRecord) {
         val fromJson = 
jsonToStructs.head.asInstanceOf[JsonToStructs].copy(schema = c.dataType)
         val nullFields = c.children.grouped(2).flatMap {
           case Seq(name, value) => Seq(name, Literal(null, value.dataType))
@@ -440,12 +453,15 @@ object OptimizeCsvJsonExprs extends Rule[LogicalPlan] {
       child
 
     case g @ GetStructField(j @ JsonToStructs(schema: StructType, _, _, _), 
ordinal, _)
-        if schema.length > 1 && j.options.isEmpty =>
+        if schema.length > 1 && j.options.isEmpty && !selectsCorruptRecord(g) 
=>
         // Options here should be empty because the optimization should not be 
enabled
         // for some options. For example, when the parse mode is failfast it 
should not
         // optimize, and should force to parse the whole input JSON with 
failing fast for
         // an invalid input.
         // To be more conservative, it does not optimize when any option is 
set for now.
+        // Pruning to the corrupt record column alone is excluded as well: it 
leaves the
+        // parser nothing to convert, so the parser never records the row when 
a dropped
+        // field contains a malformed value, and the column comes back null.
       val prunedSchema = StructType(Array(schema(ordinal)))
       g.copy(child = j.copy(schema = prunedSchema), ordinal = 0)
 
diff --git 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/OptimizeJsonExprsSuite.scala
 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/OptimizeJsonExprsSuite.scala
index 96f46344ffda..801404ba55a0 100644
--- 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/OptimizeJsonExprsSuite.scala
+++ 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/OptimizeJsonExprsSuite.scala
@@ -708,6 +708,49 @@ class OptimizeJsonExprsSuite extends PlanTest with 
ExpressionEvalHelper {
     assert(optimized != query(Map.empty), "expected the empty-options plan to 
be rewritten")
   }
 
+  test("SPARK-58707: do not prune a from_json schema down to the corrupt 
record column") {
+    val schema = StructType.fromDDL("a int, b int, _corrupt_record string")
+
+    def getField(ordinal: Int): LogicalPlan = testRelation2
+      .select(GetStructField(JsonToStructs(schema, Map.empty, $"json"), 
ordinal)).analyze
+
+    // Pruning to the corrupt record column alone leaves the parser nothing to 
convert, so a
+    // malformed value in a dropped field would no longer populate the column.
+    comparePlans(Optimizer.execute(getField(2)), getField(2))
+
+    // Control: a non-corrupt field is still pruned, so the guard above is 
what blocks it rather
+    // than some other precondition of the rule.
+    val prunedSchema = StructType.fromDDL("a int")
+    comparePlans(
+      Optimizer.execute(getField(0)),
+      testRelation2
+        .select(GetStructField(JsonToStructs(prunedSchema, Map.empty, 
$"json"), 0)).analyze)
+  }
+
+  test("SPARK-58707: simplify named_struct + from_json when no field is 
dropped") {
+    val schema = StructType.fromDDL("a int, _corrupt_record string")
+
+    def query(fields: (String, Int)*): LogicalPlan = testRelation2.select(
+      namedStruct(fields.flatMap { case (name, ordinal) =>
+        Seq(Literal(name), GetStructField(JsonToStructs(schema, Map.empty, 
$"json"), ordinal))
+      }: _*).as("struct")).analyze
+
+    // Selecting the corrupt record column together with every other field 
drops nothing, so the
+    // rewrite is safe and still collapses the repeated parses into one.
+    val nullStruct = namedStruct(
+      "a", Literal(null, IntegerType), "_corrupt_record", Literal(null, 
StringType))
+    comparePlans(
+      Optimizer.execute(query("a" -> 0, "_corrupt_record" -> 1)),
+      testRelation2.select(
+        If(IsNull($"json"),
+          nullStruct,
+          KnownNotNull(JsonToStructs(schema, Map.empty, 
$"json"))).as("struct")).analyze)
+
+    // Dropping `a` while selecting the corrupt record column is what makes 
the column unreliable.
+    val pruning = query("_corrupt_record" -> 1)
+    comparePlans(Optimizer.execute(pruning), pruning)
+  }
+
   test("SPARK-33007: simplify named_struct + from_json") {
     val options = Map.empty[String, String]
     val schema = StructType.fromDDL("a int, b int, c long, d string")
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/JsonFunctionsSuite.scala 
b/sql/core/src/test/scala/org/apache/spark/sql/JsonFunctionsSuite.scala
index 6eccbb7a6a38..53a256a18a57 100644
--- a/sql/core/src/test/scala/org/apache/spark/sql/JsonFunctionsSuite.scala
+++ b/sql/core/src/test/scala/org/apache/spark/sql/JsonFunctionsSuite.scala
@@ -1798,6 +1798,37 @@ class JsonFunctionsSuite extends SharedSparkSession {
     }
   }
 
+  test("SPARK-58707: json pruning keeps the corrupt record column populated") {
+    Seq("true", "false").foreach { enabled =>
+      withSQLConf(SQLConf.JSON_EXPRESSION_OPTIMIZATION.key -> enabled) {
+        // `b` is a type mismatch rather than a structural malformation, so it 
only fails when the
+        // parser is asked for it. Pruning the schema down to the corrupt 
record column alone left
+        // the parser nothing to convert, so the record was reported as clean.
+        val df = Seq("""{"a": 1, "b": "bad"}""").toDS()
+          .selectExpr("from_json(value, 'a int, b int, _corrupt_record 
string') as p")
+          .selectExpr("p._corrupt_record")
+
+        checkAnswer(df, Row("""{"a": 1, "b": "bad"}"""))
+      }
+    }
+  }
+
+  test("SPARK-58707: named_struct keeps the corrupt record column populated") {
+    Seq("true", "false").foreach { enabled =>
+      withSQLConf(SQLConf.JSON_EXPRESSION_OPTIMIZATION.key -> enabled) {
+        // The two from_json calls are written inline so that CollapseProject 
does not keep them
+        // behind an alias, which would stop the rule from firing at all.
+        val fromJson = "from_json(value, 'a int, b int, _corrupt_record 
string')"
+        val df = Seq("""{"a": 1, "b": "bad"}""").toDS()
+          .selectExpr(
+            s"named_struct('a', $fromJson.a, '_corrupt_record', 
$fromJson._corrupt_record) as s")
+          .selectExpr("s.a", "s._corrupt_record")
+
+        checkAnswer(df, Row(1, """{"a": 1, "b": "bad"}"""))
+      }
+    }
+  }
+
   test("SPARK-33907: json pruning optimization with corrupt record field") {
     Seq("true", "false").foreach { enabled =>
       withSQLConf(SQLConf.JSON_EXPRESSION_OPTIMIZATION.key -> enabled) {
@@ -1806,6 +1837,9 @@ class JsonFunctionsSuite extends SharedSparkSession {
           .add("b", IntegerType)
         val badRec = """{"a" 1, "b": 11}"""
 
+        // Since SPARK-58707 the rule no longer prunes to the corrupt record 
column, so both
+        // iterations run the same plan. The record here is structurally 
malformed, which fails at
+        // tokenization whatever schema is requested, so the answer never 
depended on the pruning.
         val df = Seq(badRec, """{"a": 2, "b": 12}""").toDS()
           .selectExpr("from_json(value, 'a int, b int, _corrupt_record 
string') as parsed")
           .selectExpr("parsed._corrupt_record")


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

Reply via email to