cloud-fan commented on code in PR #59097:
URL: https://github.com/apache/spark/pull/59097#discussion_r4292171745
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/variant/variantExpressions.scala:
##########
@@ -489,7 +566,9 @@ case class VariantGet(
path: Expression,
targetType: DataType,
failOnError: Boolean,
- timeZoneId: Option[String] = None)
+ timeZoneId: Option[String] = None,
+ legacyPathBackslashAsLiteral: Boolean =
Review Comment:
**Non-blocking (P2):** This field decides whether a backslash in a bracket
key is an escape, and Spark's own evaluation, `VariantMetadata` and
`ParquetFilters` all honor it. The unchanged `V2ExpressionBuilder` (lines
346-356), however, still turns every foldable-path `VariantGet` into the public
connector `VariantGet(child, path, targetType, failOnError, timeZoneId)`, which
has no mode and compares equal across modes. For `$['a\nb']`, legacy mode reads
the literal key `a\nb` and the default reads `a`+LF+`b`, yet both translate to
the same connector object.
That class (`@Evolving`, since 4.3.0) is documented as the connector-facing
form of `variant_get` and feeds DSv2 filter pushdown, metadata-only DELETE,
overwrite-by-filter, CHECK constraints and generated columns. A third-party
connector that reports such a predicate as fully handled, or accepts a
metadata-only delete, can silently filter or delete by a different key than
Spark's own evaluation in one of the two modes. `V2ExpressionBuilder` already
declines mode-dependent translations it cannot represent (`Cast` outside ANSI
or safe up-casts, `Abs` without `failOnError`). Spark's built-in sources are
not affected: `V2ExpressionSQLBuilder` rejects `VariantGet`, and Parquet reads
the mode from `VariantMetadata`.
**Recommended change:** In V2ExpressionBuilder, decline to translate a
VariantGet (return no connector expression) when it captured legacy mode and
its path contains a backslash inside a bracket-quoted key, a conservative
superset of the paths whose legacy and escaped parses can differ. Document on
the connector VariantGet that path() follows Spark's escaped JSONPath grammar
for bracket-quoted keys. Add DSv2 translation tests for both modes.
**Why this works:** Declining translation for mode-sensitive legacy paths
keeps those predicates and expressions in Spark, which evaluates them with the
captured mode. Connectors then only receive paths whose meaning matches the
documented grammar. This follows the existing V2ExpressionBuilder rule that
pushes Cast only for ANSI or safe up-casts and Abs only with failOnError.
**Scope:** Guard the DSv2 translation of variant_get on the captured parser
mode and document the connector-facing path grammar.
**Compatibility:** Default-mode DSv2 pushdown of variant_get predicates and
expressions.
**Risks:** In legacy mode, variant paths whose bracket keys contain
backslashes lose DSv2 pushdown, so such queries return more rows to Spark for
filtering and metadata-only deletes fall back to row-level rewrites.
**Constraints:** Default-mode translation of every variant path, and
legacy-mode translation of paths without backslashes in bracket keys, must stay
exactly as before, including boolean predicate wrapping.
**Exclusions:**
- Adding fields or accessors to the connector VariantGet class.
- Documenting the variant extraction metadata contract, which is a separate
connector surface.
**Success:** A VariantGet that captured legacy mode and whose bracket key
contains a backslash is not translated to a connector expression and is
evaluated by Spark. Default-mode VariantGet, and legacy-mode paths without such
keys, translate unchanged. The connector VariantGet documentation states the
path grammar connectors must apply.
**Verification:**
- **Behavior:** Translating a VariantGet whose bracket key contains a
backslash yields no connector expression when legacy mode was captured, and
yields the connector VariantGet with the raw path when the default mode was
captured.
- **Compatibility:** Translation of dot-notation and backslash-free bracket
paths, including predicate wrapping for boolean results, is unchanged in both
modes.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/variant/variantExpressions.scala:
##########
@@ -369,17 +370,87 @@ object VariantPathParser extends RegexParsers {
override def skipWhitespace: Boolean = false
// Parse key segment like `.name`, `['name']`, or `["name"]`.
- private def key: Parser[VariantPathSegment] =
+ private def literalBackslashKey: Parser[VariantPathSegment] =
for {
key <- '.' ~> "[^\\.\\[]+".r | "['" ~> "[^']*".r <~ "']" |
"[\"" ~> """[^"]*""".r <~ "\"]"
} yield {
ObjectExtraction(key)
}
- private val parser: Parser[List[VariantPathSegment]] = phrase(root ~>
rep(key | index))
+ // Only bracket keys decode escapes.
+ private def escapedKey: Parser[VariantPathSegment] =
+ '.' ~> "[^\\.\\[]+".r ^^ ObjectExtraction |
+ "['" ~> """(?:\\[\s\S]|[^'\\])*+""".r <~ "']" ^^ { k =>
Review Comment:
**Nit (P3):** This grammar now decodes escapes, but colon extraction
validates and names its path with a different parser.
`AstBuilder.visitSemiStructuredExtract` builds `"$" + ctx.path.getText` and
still checks it and derives the column alias with `JsonPathParser`, whose
bracket rule (`['`, `[^']+`, `']`) is literal. The `VariantGet` it then builds
parses the same text here, so the two disagree:
- `SELECT v:['a\tb'] FROM t` names the column `a\tb` (backslash, t) but
reads the key `a`+TAB+`b`.
- `v:['a\'b']` lexes as one string literal but fails `JsonPathParser` with
`PARSE_SYNTAX_ERROR`, although `variant_get` now accepts the same path as the
key `a'b`.
Extracted values are otherwise correct, and the `variant_get` /
`try_variant_get` function forms are unaffected; only colon-syntax bracket keys
containing a backslash are involved.
**Recommended change:** In visitSemiStructuredExtract, validate the built
path and derive the alias with VariantPathParser, using the session's current
legacy flag (the value the resulting VariantGet will capture) and taking the
alias from the last object-key segment. Keep raising PARSE_SYNTAX_ERROR when
the path does not parse. Add colon-syntax SQL tests for bracket keys with
escapes in both modes.
**Why this works:** Validation and alias then come from the same grammar and
mode as execution: escaped quotes are accepted, and decoded keys name their own
columns.
**Scope:** Make colon-syntax extraction validate and name paths with the
Variant path grammar.
**Compatibility:** Results and aliases of colon-syntax paths without
backslashes in bracket keys.
**Risks:** VariantPathParser accepts some bracket forms JsonPathParser
rejected, such as double-quoted and empty keys, so colon syntax would start
accepting them; `['*']` would also gain the alias `*` (execution already reads
it as the literal key `*`).
**Constraints:** Colon paths without backslashes in bracket keys must keep
their current results and aliases. Parse-time validation must use the same
legacy flag value the analyzer captures, so validation and execution never
disagree.
**Exclusions:**
- Changing JsonPathParser, get_json_object, or JSON_TABLE path handling.
**Success:** In default mode, `v:['a\'b']` parses and extracts the key a'b.
`v:['a\tb']` produces a column named after the decoded key it extracts. In
legacy mode, colon-syntax bracket keys containing backslashes keep their
pre-4.4 literal results and aliases.
**Verification:**
- **Behavior:** Colon-syntax extraction of bracket keys containing escapes
returns the decoded key's value and names the column after that key in default
mode, and keeps literal-backslash semantics and names in legacy mode.
- **Compatibility:** Existing colon-syntax extractions without backslashes
keep their results, aliases and analyzed plans under both the fixed-point and
single-pass analyzers.
- **Regression:** In default mode, colon-syntax `v:['a\'b']` parses and
returns the value of key a'b instead of raising a syntax error.
##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/variant/VariantExpressionSuite.scala:
##########
@@ -756,7 +789,69 @@ class VariantExpressionSuite extends SparkFunSuite with
ExpressionEvalHelper {
checkInvalidPath("""$['"]""")
checkInvalidPath("$[\"\"\"]")
- checkInvalidPath("$[\"\\\"\"]")
+ }
+
+ test("legacy Variant paths treat backslashes literally") {
+ val input = """{"a\nb":1,"a\\nb":2}"""
+ val path = "$['a\\nb']"
+
+ withSQLConf(SQLConf.LEGACY_VARIANT_PATH_BACKSLASH_AS_LITERAL.key ->
"false") {
+ testVariantGet(input, path, IntegerType, 1)
+ }
+
+ withSQLConf(SQLConf.LEGACY_VARIANT_PATH_BACKSLASH_AS_LITERAL.key ->
"true") {
+ testVariantGet(input, path, IntegerType, 2)
+
+ val escapedQuote = """$['a\'b']"""
+ checkErrorInExpression[SparkRuntimeException](
+ variantGet("""{"a'b":1}""", escapedQuote, IntegerType),
+ "INVALID_VARIANT_GET_PATH",
+ Map("path" -> escapedQuote, "functionName" -> "`variant_get`"))
+ }
+ }
+
+ gridTest("Variant path functions use the captured parser mode")(Seq(false,
true)) { legacy =>
Review Comment:
**Nit (P3):** This grid flips the conf between construction and evaluation,
but each function gets only one path kind: `variant_delete`, `variant_pick`,
`variant_insert` and `variant_array_append` use constant paths, while
`variant_get` and `variant_set` use the dynamic `BoundReference`. So the
dynamic-path branches of the first four, the constant-path branch of
`variant_get`, and the flag that `setAtPath` / `arrayAppendAtPath` pass to
`renderVariantPath` are never checked under a flip (only `variant_insert`
diagnostics are). SQL `variant_delete` and `variant_pick` also go through the
new `def this(children: Seq[Expression])` constructors that `FunctionRegistry`
reflects on, and no test runs them with the legacy flag set. Hard-coding
`false` at any of these sites would keep every test green while the legacy
fallback the migration guide promises silently stops working for that function
or path kind.
**Verification:**
- **Regression:** For each of the six path functions, evaluation with both
constant and dynamic paths follows the mode captured at construction after the
session flag is flipped, in interpreted and generated code.
- **Regression:** variant_set and variant_array_append type-mismatch
failedAt prefixes follow the captured mode.
- **Behavior:** SQL variant_delete and variant_pick honor
spark.sql.legacy.variantPathBackslashAsLiteral=true for a key whose meaning
depends on the mode.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/PushVariantIntoScan.scala:
##########
@@ -47,23 +47,27 @@ case class VariantMetadata(
// with the offending value when the paired data field's strict cast raises
// INVALID_VARIANT_CAST. We pair by NAME (not struct ordinal) because
later pruning or
// reordering of struct fields preserves names but may shift positions.
- castErrorFor: Option[String] = None) {
+ castErrorFor: Option[String] = None,
+ // Path parser mode captured from the originating VariantGet.
+ legacyPathBackslashAsLiteral: Boolean = false) {
// Produce a metadata contain one key-value pair. The key is the special
`METADATA_KEY`.
- // The value contains key-value pairs for `path`, `failOnError`,
`timeZoneId`, and -- for
- // companion fields only -- `castErrorFor`.
+ // The value contains key-value pairs for `path`, `failOnError`,
`timeZoneId`,
+ // `legacyPathBackslashAsLiteral`, and -- for companion fields only --
`castErrorFor`.
def toMetadata: Metadata = {
val inner = new MetadataBuilder()
.putString(VariantMetadata.PATH_KEY, path)
.putBoolean(VariantMetadata.FAIL_ON_ERROR_KEY, failOnError)
.putString(VariantMetadata.TIME_ZONE_ID_KEY, timeZoneId)
+ .putBoolean(
Review Comment:
**Nit (P3):** This key now reaches connectors: `V2ScanRelationPushDown`
passes `field.path.toMetadata` to `VariantExtractionImpl`, and third-party
`SupportsPushDownVariantExtractions` implementations read it through
`VariantExtraction.metadata()`. That public Javadoc still lists only `path`,
`failOnError` and `timeZoneId` and says `path` follows JSON path syntax. A
connector written from it cannot know that this key switches `path` to the
literal-backslash reading, or that default-mode bracket keys decode escapes, so
for a key containing a backslash it can extract a different key than Spark.
Spark's built-in Parquet reader reads the key through `VariantMetadata` and is
unaffected; the gap is in the documented connector contract.
**Verification:**
- **Inspection:** The connector documentation for variant extraction
metadata names every key Spark writes for a pushed extraction and defines how
path is parsed when legacyPathBackslashAsLiteral is false and when it is true.
##########
docs/sql-migration-guide.md:
##########
@@ -28,6 +28,7 @@ license: |
## Upgrading from Spark SQL 4.3 to 4.4
+- Since Spark 4.4, recognized escape sequences in quoted Variant path keys are
decoded. In Spark 4.3 and earlier, backslashes were treated literally. Existing
paths may therefore match different keys or become invalid. For example,
`$['a\']` now raises an invalid-path error; use `$['a\\']` instead.
Unrecognized escapes remain literal, and dot-notation keys are unchanged. To
restore the previous behavior, set
`spark.sql.legacy.variantPathBackslashAsLiteral` to `true` before creating the
query.
Review Comment:
**Nit (P3):** This note asks users to audit existing paths, but its key term
is undefined: it never lists the recognized escapes, and the `variant_get` docs
only say the path is JSONPath. In the parser the set is `\\`, `\/`, `\b`, `\f`,
`\n`, `\r`, `\t`, the enclosing quote only (`\'` inside `['...']`, `\"` inside
`["..."]`), `\uXXXX` for non-surrogate code units, and surrogate pairs; lone
surrogates and every other escape stay literal. Without that list a user cannot
tell that `$['C:\new']` now reads `C:`+LF+`ew` while `$['C:\data']` keeps its
4.3 meaning, or that `$['x\/y']` now reads `x/y`.
**Verification:**
- **Inspection:** The 4.4 migration entry, or the reference it links, lists
exactly the escapes the Variant path parser decodes, including the
quote-specific and surrogate rules.
##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/variant/VariantExpressionSuite.scala:
##########
@@ -756,7 +789,69 @@ class VariantExpressionSuite extends SparkFunSuite with
ExpressionEvalHelper {
checkInvalidPath("""$['"]""")
checkInvalidPath("$[\"\"\"]")
- checkInvalidPath("$[\"\\\"\"]")
Review Comment:
**Nit (P3):** This was the suite's only negative case involving a backslash;
it is now valid by design, but nothing replaces it. No assertion covers the
boundaries the new grammar and the migration note define: `$['a\']` and
`$["a\"]` (now invalid, and the former is the guide's own example), an escaped
quote just before `]` (`$['a\']b']` must stay the single key `a']b`), or a
default-mode dot key with a backslash (`$.a\nb` must remain the literal key).
On the encoder side, the `failedAt` cases exercise only the backslash, the
active quote and `\n`; `quoteKey`'s `\b`, `\f`, `\r`, `\t` and generic `\u%04x`
branches never run. A regression in any of these would keep the suite green
while changing which keys existing paths address, or making a rendered
`failedAt` stop parsing back to the same key.
**Verification:**
- **Regression:** In default mode `$['a\']` and `$["a\"]` raise the
invalid-path error for variant_get and for the manipulation functions.
- **Behavior:** In default mode `$['a\']b']` addresses the single key a']b,
and `$.a\nb` addresses the literal key a\nb.
- **Regression:** failedAt for bracket-forced keys containing each short
control character, and another control character below 0x20, re-parses to the
same key.
--
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]