cloud-fan commented on code in PR #59167:
URL: https://github.com/apache/spark/pull/59167#discussion_r4185886769
##########
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:
**Non-blocking (P2):** When JSON partial results are disabled, this still
emits the inner constrained map as a partial carrier. An enclosing array or
ordinary map does not consume that `PartialMapDataResultException`, but
`badRecord` serializes its `MapData` as the root value. An `ARRAY<MAP<CHAR(2),
INT>>` root can then fail in `getArray(0)`, while an outer MAP root can expose
the inner map at the wrong nesting level instead of reporting the original
conversion error.
See **Shared repair plan 1** in the review body.
##########
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:
**Non-blocking (P2):** For a failure positioned on an inner `START_OBJECT`
or `START_ARRAY`, `skipChildren()` stops at that child's matching end token,
but the owning map value may still be open. Returning here lets the next
enclosing-map iteration consume tail tokens from the failed value as if they
were new entries, which can create phantom keys, retain the wrong value
fragment, or replace the original error with a secondary one.
See **Shared repair plan 1** in the review body.
##########
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:
**Nit (P3):** This sentence is broader than the implemented partial-result
boundary. `convertOrdinaryMap` retains both the key and `err.partialResult`
when a nested converter throws `PartialValueException`; for example, a failed
field in a `MAP<STRING, STRUCT<...>>` can leave a non-empty partial map. Users
therefore cannot rely on every failed nested value being omitted or every
all-failed map becoming empty.
**Verification:**
- **Inspection:** The published wording agrees with both ordinary-map catch
branches: retained key plus partial value for PartialValueException and pair
omission for a drained failure with no partial value.
##########
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:
**Nit (P3):** The remove/update sequence replaces an exact raw name's
normalized key and optional value as one state transition. Current assertions
cover exact repeats when both values convert and failed values on distinct
colliding names, but not `success -> failure` or `failure -> success` for the
same raw name. A regression could therefore resurrect an earlier successful
occurrence or discard a later successful one without changing an existing
signal.
**Verification:**
- **Regression:** Assert both success-then-failure and failure-then-success
exact-repeat orders for a constrained JSON map and compare the resulting
partial map contents.
- **Compatibility:** Keep existing successful exact-repeat last-wins and
distinct-name normalized-collision expectations unchanged.
##########
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:
**Non-blocking (P2):** `SparkUpgradeException` is `NonFatal`, so it reaches
this branch and is later wrapped in `PartialMapDataResultException`. The
record-level catches preserve only a raw upgrade exception; for a
CHAR/VARCHAR-keyed timestamp map, PERMISSIVE can therefore return a partial map
and FAILFAST can report malformed JSON instead of surfacing the required
compatibility warning.
See **Shared repair plan 1** in the review body.
##########
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:
**Nit (P3):** This new failure branch has no constrained-key test through
the streamed multiline top-level-array entry point. The added data-source case
reads a line-delimited object and does not reach this handler, so removing or
reordering this escape could make `DUPLICATED_MAP_KEY` look like a malformed
array element without any changed test detecting it.
**Verification:**
- **Regression:** Parse a multiline top-level array through the JSON data
source with a colliding constrained map and assert the duplicate-policy error
at collection time.
- **Compatibility:** Confirm the same fixture retains the documented
normalized last value under LAST_WIN if that policy is exercised.
--
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]