srielau commented on code in PR #59325:
URL: https://github.com/apache/spark/pull/59325#discussion_r4238473321
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryExecutionErrors.scala:
##########
@@ -2739,6 +2739,19 @@ private[sql] object QueryExecutionErrors extends
QueryErrorsBase with ExecutionE
)
}
+ // A SparkRuntimeException (not SparkSQLFeatureNotSupportedException)
despite the 0A000
+ // SQLSTATE: restricting CHAR/VARCHAR JSON map keys is a limitation, but the
error is
+ // raised while parsing a row and must flow through Jackson's throw/catch
parse-mode
+ // model (PERMISSIVE wraps it as a bad record, FAILFAST surfaces it).
Review Comment:
I would like to keep `0A000`. The rejection is a static-semantics limitation
of the CHAR/VARCHAR map-key type (that type cannot represent an off-width
name), not a data-dependent runtime error, so the "unsupported feature" class
fits. You are right about the FAILFAST wrapping though: it surfaces as
`MALFORMED_RECORD_IN_PARSING.WITHOUT_SUGGESTION` (SQLSTATE `22023`) with this
error as the cause. I corrected that in the migration guide and the PR
description, and added a FAILFAST test that unwraps the cause.
##########
common/utils/src/main/resources/error/error-conditions.json:
##########
@@ -9605,6 +9605,13 @@
],
"sqlState" : "0A000"
},
+ "UNSUPPORTED_JSON_CHAR_VARCHAR_MAP_KEY" : {
+ "message" : [
+ "JSON map keys of CHAR or VARCHAR cannot be padded or trimmed.",
+ "The key <key> is not valid for type <dataType>."
Review Comment:
Reworded. The message now states the rule and the remedy: "A CHAR(n) key
must be exactly n characters and a VARCHAR(n) key at most n characters; keys
are never padded or trimmed. Use a STRING map key to accept any name." The
echoed key is truncated to 128 characters to bound the message size.
##########
docs/sql-migration-guide.md:
##########
@@ -28,6 +28,7 @@ license: |
## Upgrading from Spark SQL 4.3 to 4.4
+- Since Spark 4.4, when `spark.sql.charVarchar.standardSemantics.enabled` is
true, JSON object names used as `MAP<CHAR(n), _>` or `MAP<VARCHAR(n), _>` keys
in `from_json` and the JSON datasource are length-checked without padding or
trimming. A `CHAR(n)` key must already be exactly `n` characters, and a
`VARCHAR(n)` key must already be at most `n` characters. Mismatched keys fail
with `UNSUPPORTED_JSON_CHAR_VARCHAR_MAP_KEY` (SQLSTATE `0A000`) and follow the
JSON parse mode (`PERMISSIVE` or `FAILFAST`). Exact repeated member names
last-win; `spark.sql.mapKeyDedupPolicy` is not applied. With the flag off, CHAR
keys are still padded and VARCHAR overflow still uses `EXCEED_LIMIT_LENGTH`.
Review Comment:
Reworded for accuracy rather than removed - it sits alongside the existing
`charVarchar.standardSemantics` bullet, so readers enabling that flag will look
here. It now describes the actual behavior: in PERMISSIVE mode the bad record
becomes `null`; in FAILFAST mode it fails with
`UNSUPPORTED_JSON_CHAR_VARCHAR_MAP_KEY` (`0A000`) as the cause of
`MALFORMED_RECORD_IN_PARSING`; duplicate names are kept;
`spark.sql.mapKeyDedupPolicy` is not applied.
##########
sql/core/src/test/scala/org/apache/spark/sql/CharVarcharTestSuite.scala:
##########
@@ -3072,6 +3094,113 @@ class BasicCharVarcharTestSuite extends
SharedSparkSession {
}
}
+ test("SPARK-60108: JSON CHAR/VARCHAR map keys are not padded or trimmed") {
Review Comment:
Added all of these: a bad key followed by a sibling (asserting the sibling
survives, in both partial-result modes), nested `MAP`/`ARRAY` of maps (valid
and failing), the multiLine top-level-array row count with a bad element, a
view created flag-on and queried flag-off, and a non-BMP key (one code point =
`CHAR(1)`, rejected by `CHAR(2)`).
##########
sql/core/src/test/scala/org/apache/spark/sql/CharVarcharTestSuite.scala:
##########
@@ -3072,6 +3094,113 @@ class BasicCharVarcharTestSuite extends
SharedSparkSession {
}
}
+ test("SPARK-60108: JSON CHAR/VARCHAR map keys are not padded or trimmed") {
+ withSQLConf(SQLConf.CHAR_VARCHAR_STANDARD_SEMANTICS.key -> "true") {
+ checkAnswer(
+ sql("""SELECT from_json('{"ab ": 1}', 'MAP<CHAR(3), INT>')"""),
+ Row(Map("ab " -> 1)))
+ checkAnswer(
+ sql("""SELECT from_json('{"ab": 1}', 'MAP<VARCHAR(3), INT>')"""),
+ Row(Map("ab" -> 1)))
+ checkAnswer(
+ sql("""SELECT map_entries(from_json('{"ab": 1, "ab ": 2}',
'MAP<VARCHAR(3), INT>'))"""),
+ Row(Seq(Row("ab", 1), Row("ab ", 2))))
+ checkAnswer(
+ sql("""SELECT size(from_json('{"abc": 1, "abc": 2}', 'MAP<CHAR(3),
INT>'))"""),
+ Row(1))
+ checkAnswer(
+ sql("""SELECT from_json('{"abc": 1, "abc": 2}', 'MAP<CHAR(3),
INT>')"""),
+ Row(Map("abc" -> 2)))
+ checkAnswer(
+ sql("""SELECT from_json('{"outer": {"xy ": 1}}',
+ | 'MAP<STRING, MAP<CHAR(3), INT>>')""".stripMargin),
+ Row(Map("outer" -> Map("xy " -> 1))))
+
+ // Too-short CHAR, CHAR overflow, and VARCHAR overflow are not
EXCEED_LIMIT_LENGTH.
+ checkAnswer(sql("""SELECT from_json('{"a": 1}', 'MAP<CHAR(3),
INT>')"""), Row(null))
+ checkAnswer(sql("""SELECT from_json('{"abcd": 1}', 'MAP<CHAR(3),
INT>')"""), Row(null))
+ checkAnswer(sql("""SELECT from_json('{"abcd": 1}', 'MAP<VARCHAR(3),
INT>')"""), Row(null))
+ checkAnswer(sql("""SELECT from_json('{"ab ": 1}', 'MAP<VARCHAR(2),
INT>')"""), Row(null))
+ assertUnsupportedJsonMapKey(
+ """SELECT from_json('{"a": 1}', 'MAP<CHAR(3), INT>', map('mode',
'FAILFAST'))""",
+ key = "a",
+ dataTypeSql = "CHAR(3)")
+ assertUnsupportedJsonMapKey(
+ """SELECT from_json('{"abcd": 1}', 'MAP<CHAR(3), INT>', map('mode',
'FAILFAST'))""",
+ key = "abcd",
+ dataTypeSql = "CHAR(3)")
+ assertUnsupportedJsonMapKey(
+ """SELECT from_json('{"abcd": 1}', 'MAP<VARCHAR(3), INT>', map('mode',
'FAILFAST'))""",
+ key = "abcd",
+ dataTypeSql = "VARCHAR(3)")
+
+ withTempPath { path =>
+ Seq("""{"m":{"ab ":1}}""", """{"m":{"a":1}}""").toDS()
+ .repartition(1)
+ .write.text(path.getCanonicalPath)
+ val schema = "m MAP<CHAR(3), INT>"
+ val permissive = spark.read.schema(schema).json(path.getCanonicalPath)
+ checkAnswer(permissive, Seq(Row(Map("ab " -> 1)), Row(null)))
+ val failFast = spark.read.option("mode", "FAILFAST").schema(schema)
+ .json(path.getCanonicalPath)
+ assertUnsupportedJsonMapKeyError(failFast.collect(), key = "a",
dataTypeSql = "CHAR(3)")
+ }
+
+ withSQLConf(
+ SQLConf.MAP_KEY_DEDUP_POLICY.key ->
SQLConf.MapKeyDedupPolicy.EXCEPTION.toString) {
+ checkAnswer(
+ sql("""SELECT from_json('{"abc": 1, "abc": 2}', 'MAP<VARCHAR(3),
INT>')"""),
+ Row(Map("abc" -> 2)))
Review Comment:
Good point - `checkAnswer`'s map conversion would hide dropped duplicates.
The duplicate assertions now use `size(map_keys(...))` and `map_entries(...)`
so retention is checked directly instead of through a Scala `Map`.
##########
sql/core/src/test/scala/org/apache/spark/sql/CharVarcharTestSuite.scala:
##########
@@ -3072,6 +3094,113 @@ class BasicCharVarcharTestSuite extends
SharedSparkSession {
}
}
+ test("SPARK-60108: JSON CHAR/VARCHAR map keys are not padded or trimmed") {
+ withSQLConf(SQLConf.CHAR_VARCHAR_STANDARD_SEMANTICS.key -> "true") {
+ checkAnswer(
+ sql("""SELECT from_json('{"ab ": 1}', 'MAP<CHAR(3), INT>')"""),
+ Row(Map("ab " -> 1)))
+ checkAnswer(
+ sql("""SELECT from_json('{"ab": 1}', 'MAP<VARCHAR(3), INT>')"""),
+ Row(Map("ab" -> 1)))
+ checkAnswer(
+ sql("""SELECT map_entries(from_json('{"ab": 1, "ab ": 2}',
'MAP<VARCHAR(3), INT>'))"""),
+ Row(Seq(Row("ab", 1), Row("ab ", 2))))
+ checkAnswer(
+ sql("""SELECT size(from_json('{"abc": 1, "abc": 2}', 'MAP<CHAR(3),
INT>'))"""),
+ Row(1))
+ checkAnswer(
+ sql("""SELECT from_json('{"abc": 1, "abc": 2}', 'MAP<CHAR(3),
INT>')"""),
+ Row(Map("abc" -> 2)))
+ checkAnswer(
+ sql("""SELECT from_json('{"outer": {"xy ": 1}}',
+ | 'MAP<STRING, MAP<CHAR(3), INT>>')""".stripMargin),
+ Row(Map("outer" -> Map("xy " -> 1))))
+
+ // Too-short CHAR, CHAR overflow, and VARCHAR overflow are not
EXCEED_LIMIT_LENGTH.
+ checkAnswer(sql("""SELECT from_json('{"a": 1}', 'MAP<CHAR(3),
INT>')"""), Row(null))
+ checkAnswer(sql("""SELECT from_json('{"abcd": 1}', 'MAP<CHAR(3),
INT>')"""), Row(null))
+ checkAnswer(sql("""SELECT from_json('{"abcd": 1}', 'MAP<VARCHAR(3),
INT>')"""), Row(null))
+ checkAnswer(sql("""SELECT from_json('{"ab ": 1}', 'MAP<VARCHAR(2),
INT>')"""), Row(null))
+ assertUnsupportedJsonMapKey(
+ """SELECT from_json('{"a": 1}', 'MAP<CHAR(3), INT>', map('mode',
'FAILFAST'))""",
+ key = "a",
+ dataTypeSql = "CHAR(3)")
+ assertUnsupportedJsonMapKey(
+ """SELECT from_json('{"abcd": 1}', 'MAP<CHAR(3), INT>', map('mode',
'FAILFAST'))""",
+ key = "abcd",
+ dataTypeSql = "CHAR(3)")
+ assertUnsupportedJsonMapKey(
+ """SELECT from_json('{"abcd": 1}', 'MAP<VARCHAR(3), INT>', map('mode',
'FAILFAST'))""",
+ key = "abcd",
+ dataTypeSql = "VARCHAR(3)")
+
+ withTempPath { path =>
+ Seq("""{"m":{"ab ":1}}""", """{"m":{"a":1}}""").toDS()
+ .repartition(1)
+ .write.text(path.getCanonicalPath)
+ val schema = "m MAP<CHAR(3), INT>"
+ val permissive = spark.read.schema(schema).json(path.getCanonicalPath)
+ checkAnswer(permissive, Seq(Row(Map("ab " -> 1)), Row(null)))
+ val failFast = spark.read.option("mode", "FAILFAST").schema(schema)
+ .json(path.getCanonicalPath)
+ assertUnsupportedJsonMapKeyError(failFast.collect(), key = "a",
dataTypeSql = "CHAR(3)")
+ }
+
+ withSQLConf(
+ SQLConf.MAP_KEY_DEDUP_POLICY.key ->
SQLConf.MapKeyDedupPolicy.EXCEPTION.toString) {
+ checkAnswer(
+ sql("""SELECT from_json('{"abc": 1, "abc": 2}', 'MAP<VARCHAR(3),
INT>')"""),
+ Row(Map("abc" -> 2)))
+ }
+
+ // A last-win duplicate whose winning (second) value overflows nulls the
whole
+ // record and keeps EXCEED_LIMIT_LENGTH, matching the non-duplicate
case, instead
+ // of silently keeping the superseded first value. Holds for both
partial modes.
+ Seq(true, false).foreach { partial =>
+ withSQLConf(SQLConf.JSON_ENABLE_PARTIAL_RESULTS.key ->
partial.toString) {
+ checkAnswer(
+ sql("""SELECT from_json('{"abc": "x", "abc": "abcdef"}',
+ | 'MAP<CHAR(3), CHAR(2)>')""".stripMargin),
+ Row(null))
+ assertParseExceedLimit(
+ """SELECT from_json('{"abc": "x", "abc": "abcdef"}',
+ | 'MAP<CHAR(3), CHAR(2)>',
+ | map('mode', 'FAILFAST'))""".stripMargin,
+ expectedLimit = "2")
+ }
+ }
+
+ // Collation is not consulted for last-win: binary-distinct names are
both kept,
+ // unlike XML which pads keys and then applies the dedup policy.
+ withSQLConf(SQLConf.ALLOW_COLLATIONS_IN_MAP_KEYS.key -> "true") {
+ checkAnswer(
+ sql("""SELECT map_entries(from_json('{"ab": 1, "AB": 2}',
+ | 'MAP<VARCHAR(2) COLLATE UTF8_LCASE, INT>'))""".stripMargin),
+ Row(Seq(Row("ab", 1), Row("AB", 2))))
+ }
+ }
+
+ // Flag off is unchanged: write-side pad/trim still apply to keys, and the
new
+ // UNSUPPORTED_JSON_CHAR_VARCHAR_MAP_KEY check is not used.
+ withSQLConf(
+ SQLConf.CHAR_VARCHAR_STANDARD_SEMANTICS.key -> "false",
+ SQLConf.PRESERVE_CHAR_VARCHAR_TYPE_INFO.key -> "true") {
+ checkAnswer(
+ sql("""SELECT from_json('{"a": 1}', 'MAP<CHAR(3), INT>')"""),
+ Row(Map("a " -> 1)))
Review Comment:
Fixed. The flag-off cases now round-trip through `to_json(from_json(...))`
so they exercise the parser directly rather than the encoder, confirming
`{"a":1}` and `{"abcd":1}` are returned unchanged with the flag off.
--
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]