srielau commented on code in PR #59167:
URL: https://github.com/apache/spark/pull/59167#discussion_r4190362425


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/json/JacksonParser.scala:
##########
@@ -659,6 +721,63 @@ class JacksonParser(
     }
   }
 
+  /**
+   * CHAR/VARCHAR JSON maps apply assignment to object names, then
+   * `spark.sql.mapKeyDedupPolicy` to collisions created by that normalization.
+   * Exact repeated names stay historical last-wins.
+   */
+  private def convertConstrainedMap(
+      parser: JsonParser,
+      fieldConverter: ValueConverter,
+      keyType: DataType,
+      valueType: DataType,
+      mapDepth: Int): MapData = {
+    val lastEntries =
+      mutable.LinkedHashMap.empty[UTF8String, (UTF8String, Option[Any])]
+    var partialResultException: Option[Throwable] = None
+    var badMapException: Option[Throwable] = None
+
+    while (nextUntil(parser, JsonToken.END_OBJECT)) {
+      val rawKey = UTF8String.fromString(parser.currentName)
+      val value = try {
+        Some(fieldConverter.apply(parser))
+      } catch {
+        case err: PartialValueException if enablePartialResults =>
+          partialResultException = 
partialResultException.orElse(Some(err.cause))
+          Some(err.partialResult)
+        case DuplicateMapKeyUtils(e) => throw e
+        case NonFatal(e) =>
+          badMapException = badMapException.orElse(Some(e))
+          skipRemainingValue(parser, mapDepth)
+          None
+      }
+      try {
+        val key = CharVarcharUtils.applyTextParseSemantics(rawKey, keyType)
+        // Re-insert so LAST_WIN follows the latest occurrence of this raw 
name,
+        // matching StaxXmlParser.convertConstrainedMap.
+        lastEntries.remove(rawKey)
+        lastEntries.update(rawKey, (key, value))
+      } catch {
+        case NonFatal(e) =>
+          badMapException = badMapException.orElse(Some(e))
+      }
+    }
+
+    // Dedup runs on the drained object before any stored conversion failure is
+    // rethrown. A normalization collision therefore surfaces as 
DUPLICATED_MAP_KEY
+    // in preference to EXCEED_LIMIT_LENGTH (oversize key) or a 
value-conversion
+    // error. LAST_WIN resolves the collision and then reports the stored 
failure
+    // (PERMISSIVE keeps the partial map via PartialMapDataResultException).
+    val mapData = DuplicateMapKeyUtils.buildConstrainedMap(
+      lastEntries, keyType, valueType)
+    val firstError = badMapException.orElse(partialResultException)
+    if (firstError.isEmpty) {
+      mapData
+    } else {
+      throw PartialMapDataResultException(mapData, firstError.get)

Review Comment:
   Fixed on the current head (`c492d9f` / `30d6247`). When 
`enablePartialResults` is false, `convertArray` and `convertOrdinaryMap` unwrap 
`PartialValueException` to `err.cause` so an inner `MapData` cannot be 
serialized as the root value. `ARRAY<MAP<CHAR(2), INT>>` and `MAP<STRING, 
MAP<CHAR(2), INT>>` now assert `null` / `MALFORMED_RECORD_IN_PARSING` in that 
configuration.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/json/JacksonParser.scala:
##########
@@ -617,27 +620,86 @@ class JacksonParser(
     }
   }
 
+  /**
+   * Finish a JSON value that failed conversion. `skipChildren` only works at
+   * START_OBJECT / START_ARRAY; if the converter already consumed tokens 
inside a nested
+   * object, leftover FIELD_NAMEs would otherwise leak as keys of the 
enclosing map.
+   *
+   * The `_` branch is for a converter that stopped on a scalar inside a nested
+   * value. The loop advances until Jackson's nesting depth returns to this 
map,
+   * which is the nested value's END_OBJECT / END_ARRAY (or EOF if truncated).
+   * A null parsing context is treated as "keep draining" so leftover tokens
+   * cannot be misread as the next map key.
+   */
+  private def skipRemainingValue(parser: JsonParser, mapDepth: Int): Unit = {
+    parser.getCurrentToken match {
+      case START_OBJECT | START_ARRAY =>

Review Comment:
   Fixed: after `skipChildren()` on an inner `START_OBJECT` / `START_ARRAY`, 
`skipRemainingValue` keeps advancing until Jackson's nesting depth returns to 
the owning map. Tests `nestedObjectStartFailureQuery` (`STRUCT<x INT>` vs 
nested object) and `nestedArrayStartFailureQuery` (`ARRAY<INT>` vs nested 
array) assert `map_keys` is only `b `.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/json/JacksonParser.scala:
##########
@@ -659,6 +721,63 @@ class JacksonParser(
     }
   }
 
+  /**
+   * CHAR/VARCHAR JSON maps apply assignment to object names, then
+   * `spark.sql.mapKeyDedupPolicy` to collisions created by that normalization.
+   * Exact repeated names stay historical last-wins.
+   */
+  private def convertConstrainedMap(
+      parser: JsonParser,
+      fieldConverter: ValueConverter,
+      keyType: DataType,
+      valueType: DataType,
+      mapDepth: Int): MapData = {
+    val lastEntries =
+      mutable.LinkedHashMap.empty[UTF8String, (UTF8String, Option[Any])]
+    var partialResultException: Option[Throwable] = None
+    var badMapException: Option[Throwable] = None
+
+    while (nextUntil(parser, JsonToken.END_OBJECT)) {
+      val rawKey = UTF8String.fromString(parser.currentName)
+      val value = try {
+        Some(fieldConverter.apply(parser))
+      } catch {
+        case err: PartialValueException if enablePartialResults =>
+          partialResultException = 
partialResultException.orElse(Some(err.cause))
+          Some(err.partialResult)
+        case DuplicateMapKeyUtils(e) => throw e
+        case NonFatal(e) =>

Review Comment:
   Fixed: `convertConstrainedMap` rethrows `SparkUpgradeException` before the 
`NonFatal` drain. `SPARK-59722: JSON constrained maps preserve 
SparkUpgradeException` covers `from_json` and the JSON datasource with 
`MAP<CHAR(2), TIMESTAMP>` and `legacy.timeParserPolicy=exception`.



##########
docs/sql-migration-guide.md:
##########
@@ -28,6 +28,7 @@ license: |
 
 ## Upgrading from Spark SQL 4.3 to 4.4
 
+- Since Spark 4.4, `from_json` and the JSON datasource apply CHAR/VARCHAR 
assignment to map keys when `spark.sql.charVarchar.standardSemantics.enabled` 
is true. Distinct object names that become the same key after CHAR padding or 
VARCHAR trailing-space trimming (`"a"` vs `"a "` as `CHAR(2)`) honor 
`spark.sql.mapKeyDedupPolicy`. Exact repeated names stay last-wins. A 
CHAR/VARCHAR map is always scanned to its closing brace so that policy can run, 
even when `spark.sql.json.enablePartialResults` is false (ordinary STRING maps 
still fail at the first bad value in that configuration). When both a 
normalization collision and a conversion failure occur, `DUPLICATED_MAP_KEY` is 
raised first; with `LAST_WIN`, PERMISSIVE mode keeps the partial map. 
Independently, when `spark.sql.json.enablePartialResults` is true (the 
default), a STRING-keyed JSON map whose nested value fails conversion omits 
that entry and keeps later keys instead of nulling the whole map; a map whose 
every entry fails become
 s empty rather than null.

Review Comment:
   The 4.4 note now distinguishes the two `convertOrdinaryMap` branches: a 
drained failure with no partial value omits the pair (and an all-failed map is 
empty), while `PartialValueException` retains the key with its typed partial 
value.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/json/JacksonParser.scala:
##########
@@ -811,6 +932,9 @@ class JacksonParser(
     def handleFailure[R](elementStart: Option[JsonToken])(operation: => R): R 
= {
       try operation catch {
         case e: SparkUpgradeException => fail(e)
+        case DuplicateMapKeyUtils(e) =>

Review Comment:
   Added a multiline top-level-array datasource case with 
`spark.sql.json.streamMultilineTopLevelArray=true`: colliding `MAP<CHAR(2), 
INT>` raises `DUPLICATED_MAP_KEY` at collect, and LAST_WIN keeps `a ` -> 2 then 
`b ` -> 3.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/json/JacksonParser.scala:
##########
@@ -659,6 +721,63 @@ class JacksonParser(
     }
   }
 
+  /**
+   * CHAR/VARCHAR JSON maps apply assignment to object names, then
+   * `spark.sql.mapKeyDedupPolicy` to collisions created by that normalization.
+   * Exact repeated names stay historical last-wins.
+   */
+  private def convertConstrainedMap(
+      parser: JsonParser,
+      fieldConverter: ValueConverter,
+      keyType: DataType,
+      valueType: DataType,
+      mapDepth: Int): MapData = {
+    val lastEntries =
+      mutable.LinkedHashMap.empty[UTF8String, (UTF8String, Option[Any])]
+    var partialResultException: Option[Throwable] = None
+    var badMapException: Option[Throwable] = None
+
+    while (nextUntil(parser, JsonToken.END_OBJECT)) {
+      val rawKey = UTF8String.fromString(parser.currentName)
+      val value = try {
+        Some(fieldConverter.apply(parser))
+      } catch {
+        case err: PartialValueException if enablePartialResults =>
+          partialResultException = 
partialResultException.orElse(Some(err.cause))
+          Some(err.partialResult)
+        case DuplicateMapKeyUtils(e) => throw e
+        case NonFatal(e) =>
+          badMapException = badMapException.orElse(Some(e))
+          skipRemainingValue(parser, mapDepth)
+          None
+      }
+      try {
+        val key = CharVarcharUtils.applyTextParseSemantics(rawKey, keyType)
+        // Re-insert so LAST_WIN follows the latest occurrence of this raw 
name,
+        // matching StaxXmlParser.convertConstrainedMap.
+        lastEntries.remove(rawKey)

Review Comment:
   `{"a":1,"a":"bad"}` yields an empty map and `{"a":"bad","a":2}` keeps `a ` 
-> 2, under both EXCEPTION and LAST_WIN. Successful exact-repeat last-wins and 
distinct-name collision cases are unchanged.



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