This is an automated email from the ASF dual-hosted git repository.
sunchao pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/branch-4.x by this push:
new c04c87e76088 [SPARK-47670][SQL] Share repeated top-level JSON path
parsing
c04c87e76088 is described below
commit c04c87e76088a819041194ca5ecb9863b9999be8
Author: Chao Sun <[email protected]>
AuthorDate: Fri Jun 19 12:27:16 2026 -0700
[SPARK-47670][SQL] Share repeated top-level JSON path parsing
### Why are the changes needed?
Each `get_json_object` expression currently parses its JSON input
independently. A projection that extracts several fields from the same JSON
string therefore repeats the scan and parse work for every path, so its CPU
cost grows roughly with the number of extracted fields.
[SPARK-47670](https://issues.apache.org/jira/browse/SPARK-47670) proposes
sharing that parsing work. This PR implements a conservative first scope for
deterministic, simple top-level paths. Nested paths, wildcards, array
subscripts, computed JSON inputs, and evaluation-order-sensitive expression
shapes remain unchanged so that Hive-compatible behavior is preserved.
### What changes were proposed in this PR?
* Add an internal `MultiGetJsonObject` expression and evaluator that
extracts several top-level fields with one Jackson parse and returns them as a
struct.
* Extend `OptimizeCsvJsonExprs` to group eligible sibling `get_json_object`
expressions over the same input attribute and rewrite them to fields of one
shared parse.
* Preserve legacy behavior for malformed JSON, null and duplicate keys,
generator-side rendering failures, and parser-side failures. The shared
evaluator falls back to the existing per-path evaluator when sharing cannot
safely preserve independent results.
* Keep nested paths, wildcards, array subscripts, conditional or guarded
evaluation, unsupported arithmetic order, and separately projected `from_json`
expressions out of the rewrite.
* Gate the optimization behind the internal, default-disabled
`spark.sql.optimizer.getJsonObjectSharedParsing.enabled` configuration. The
existing JSON expression optimization must also be enabled.
* Add optimizer, runtime, code-generation, malformed-input, deep-nesting,
and microbenchmark coverage.
### Does this PR introduce _any_ user-facing change?
Yes. It adds an internal, default-disabled configuration. With the flag
disabled, existing analyzed and optimized plans are unchanged. When enabled,
eligible repeated top-level `get_json_object` calls use a shared parse; their
result semantics remain unchanged.
### How was this PR tested?
The following suites and style checks passed on JDK 17:
```
build/sbt "catalyst/testOnly
org.apache.spark.sql.catalyst.optimizer.OptimizeJsonExprsSuite"
build/sbt "sql/testOnly org.apache.spark.sql.JsonFunctionsSuite"
build/sbt "catalyst/scalastyle; catalyst/Test/scalastyle; sql/scalastyle;
sql/Test/scalastyle"
```
The complete optimizer suite passed 20 tests, and the complete
`JsonFunctionsSuite` passed 104 tests.
The added benchmark was generated with:
```
SPARK_GENERATE_BENCHMARK_FILES=1 build/sbt "sql/Test/runMain
org.apache.spark.sql.execution.datasources.json.SharedJsonParseBenchmark"
```
Best times for 200,000 rows containing 32 JSON fields were:
| Extracted paths | Shared parsing off | Shared parsing on | Relative
speedup |
|---:|---:|---:|---:|
| 2 | 740 ms | 390 ms | 1.9x |
| 4 | 1,387 ms | 480 ms | 2.9x |
| 8 | 3,154 ms | 727 ms | 4.3x |
| 16 | 5,109 ms | 709 ms | 7.2x |
The benchmark result file records the full JDK 17 environment and output.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: OpenAI Codex (GPT-5)
Closes #56547 from sunchao/codex/SPARK-47670-shared-get-json-object-parsing.
Lead-authored-by: Chao Sun <[email protected]>
Co-authored-by: sunchao <[email protected]>
Signed-off-by: Chao Sun <[email protected]>
(cherry picked from commit 32ce4feff374627792f3f7989b2c94453224a6da)
Signed-off-by: Chao Sun <[email protected]>
---
.../expressions/json/JsonExpressionEvalUtils.scala | 153 ++++++++++++++++
.../sql/catalyst/expressions/jsonExpressions.scala | 85 ++++++++-
.../catalyst/optimizer/OptimizeCsvJsonExprs.scala | 154 +++++++++++++++-
.../spark/sql/catalyst/trees/TreePatterns.scala | 1 +
.../org/apache/spark/sql/internal/SQLConf.scala | 14 ++
.../optimizer/OptimizeJsonExprsSuite.scala | 148 +++++++++++++++-
.../SharedJsonParseBenchmark-jdk21-results.txt | 33 ++++
.../SharedJsonParseBenchmark-jdk25-results.txt | 33 ++++
.../SharedJsonParseBenchmark-results.txt | 33 ++++
.../org/apache/spark/sql/JsonFunctionsSuite.scala | 193 ++++++++++++++++++++-
.../json/SharedJsonParseBenchmark.scala | 80 +++++++++
11 files changed, 916 insertions(+), 11 deletions(-)
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/json/JsonExpressionEvalUtils.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/json/JsonExpressionEvalUtils.scala
index d3dfc462da5c..c0203dd762f7 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/json/JsonExpressionEvalUtils.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/json/JsonExpressionEvalUtils.scala
@@ -573,3 +573,156 @@ case class GetJsonObjectEvaluator(cachedPath: UTF8String)
{
}
}
}
+
+/**
+ * Evaluates multiple simple top-level JSON fields in one parse.
+ */
+case class MultiGetJsonObjectEvaluator(
+ fieldNames: Seq[String],
+ fallbackPaths: Seq[UTF8String]) {
+ import SharedFactory._
+
+ require(
+ fieldNames.nonEmpty &&
+ fieldNames.distinct.length == fieldNames.length &&
+ fallbackPaths.length == fieldNames.length)
+
+ @transient
+ private lazy val fieldToOrdinal: Map[String, Int] =
fieldNames.zipWithIndex.toMap
+
+ @transient
+ private lazy val nullRow: InternalRow =
+ new GenericInternalRow(Array.ofDim[Any](fieldNames.length))
+
+ @transient
+ private lazy val fallbackEvaluators: Seq[GetJsonObjectEvaluator] =
+ fallbackPaths.map(new GetJsonObjectEvaluator(_))
+
+ @transient
+ private lazy val outputBuffer = new ByteArrayOutputStream()
+
+ private def fallback(json: UTF8String): InternalRow = {
+ new GenericInternalRow(fallbackEvaluators.map { evaluator =>
+ evaluator.setJson(json)
+ evaluator.evaluate()
+ }.toArray)
+ }
+
+ def evaluate(json: UTF8String): InternalRow = {
+ if (json == null) return null
+
+ val values = Array.ofDim[Any](fieldNames.length)
+ val matched = Array.ofDim[Boolean](fieldNames.length)
+
+ try {
+ val validObject = Utils.tryWithResource(
+ CreateJacksonParser.utf8String(jsonFactory, json)) { parser =>
+ if (parser.nextToken() != JsonToken.START_OBJECT) {
+ false
+ } else {
+ var token = parser.nextToken()
+ while (token != null && token != JsonToken.END_OBJECT) {
+ if (token == JsonToken.FIELD_NAME) {
+ val fieldName = parser.currentName
+ val ordinal = fieldToOrdinal.get(fieldName).filter(!matched(_))
+ val valueToken = parser.nextToken()
+ if (ordinal.nonEmpty && valueToken != JsonToken.VALUE_NULL) {
+ val index = ordinal.get
+ matched(index) = true
+ copyCurrentStructure(parser).foreach(value => values(index) =
value)
+ } else {
+ parser.skipChildren()
+ }
+ } else {
+ parser.skipChildren()
+ }
+ token = parser.nextToken()
+ }
+ token == JsonToken.END_OBJECT
+ }
+ }
+ if (validObject) {
+ new GenericInternalRow(values)
+ } else {
+ nullRow
+ }
+ } catch {
+ // Every simple top-level legacy extraction scans through the root
object's closing token,
+ // so a syntax failure makes every sibling null without needing per-path
reparsing.
+ case _: JsonParseException => nullRow
+ // A parser-side rendering failure can leave the shared token stream
unusable. Reparse each
+ // path with the legacy evaluator so one bad selected value cannot erase
sibling results.
+ case _: JsonProcessingException => fallback(json)
+ }
+ }
+
+ private def copyCurrentStructure(parser: JsonParser): Option[UTF8String] = {
+ outputBuffer.reset()
+ var renderingFailed = false
+
+ def render(write: => Unit): Unit = {
+ if (!renderingFailed) {
+ try {
+ write
+ } catch {
+ // A generator-side failure does not invalidate the parser's token
stream. Keep
+ // consuming that value so other requested fields remain independent.
+ case _: JsonGenerationException => renderingFailed = true
+ }
+ }
+ }
+
+ def copyValue(generator: JsonGenerator, rawString: Boolean): Unit = {
+ if (parser.currentToken == JsonToken.VALUE_STRING && rawString) {
+ render {
+ if (parser.hasTextCharacters) {
+ generator.writeRaw(
+ parser.getTextCharacters,
+ parser.getTextOffset,
+ parser.getTextLength)
+ } else {
+ generator.writeRaw(parser.getText)
+ }
+ }
+ } else {
+ // Keep this traversal iterative so a value near the configured
nesting limit does not
+ // consume one JVM frame per level.
+ var depth = 0
+ var done = false
+ while (!done && parser.currentToken != null) {
+ parser.currentToken match {
+ case JsonToken.START_OBJECT =>
+ render(generator.writeStartObject())
+ depth += 1
+ case JsonToken.START_ARRAY =>
+ render(generator.writeStartArray())
+ depth += 1
+ case JsonToken.END_OBJECT =>
+ render(generator.writeEndObject())
+ depth -= 1
+ case JsonToken.END_ARRAY =>
+ render(generator.writeEndArray())
+ depth -= 1
+ case _ =>
+ render(generator.copyCurrentEvent(parser))
+ }
+ done = depth == 0
+ if (!done) {
+ parser.nextToken()
+ }
+ }
+ }
+ }
+
+ try {
+ Utils.tryWithResource(
+ jsonFactory.createGenerator(outputBuffer, JsonEncoding.UTF8)) {
generator =>
+ copyValue(generator, rawString = true)
+ }
+ } catch {
+ case _: JsonGenerationException => renderingFailed = true
+ }
+
+ if (renderingFailed) None else
Some(UTF8String.fromBytes(outputBuffer.toByteArray))
+ }
+}
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/jsonExpressions.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/jsonExpressions.scala
index ed1b7191963c..76e94c3f5975 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/jsonExpressions.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/jsonExpressions.scala
@@ -22,10 +22,13 @@ import
org.apache.spark.sql.catalyst.analysis.TypeCheckResult
import org.apache.spark.sql.catalyst.analysis.TypeCheckResult.DataTypeMismatch
import org.apache.spark.sql.catalyst.expressions.codegen.{CodegenContext,
CodeGenerator, CodegenFallback, ExprCode}
import org.apache.spark.sql.catalyst.expressions.codegen.Block.BlockHelper
-import org.apache.spark.sql.catalyst.expressions.json.{GetJsonObjectEvaluator,
JsonExpressionUtils, JsonToStructsEvaluator, JsonTupleEvaluator,
SchemaOfJsonEvaluator, StructsToJsonEvaluator}
+import org.apache.spark.sql.catalyst.expressions.json.{GetJsonObjectEvaluator,
JsonExpressionUtils,
+ JsonPathParser, JsonToStructsEvaluator, JsonTupleEvaluator,
MultiGetJsonObjectEvaluator,
+ PathInstruction, SchemaOfJsonEvaluator, StructsToJsonEvaluator}
import org.apache.spark.sql.catalyst.expressions.objects.{Invoke, StaticInvoke}
import org.apache.spark.sql.catalyst.json._
-import org.apache.spark.sql.catalyst.trees.TreePattern.{JSON_TO_STRUCT,
RUNTIME_REPLACEABLE, TreePattern}
+import org.apache.spark.sql.catalyst.trees.TreePattern.{GET_JSON_OBJECT,
JSON_TO_STRUCT,
+ RUNTIME_REPLACEABLE, TreePattern}
import org.apache.spark.sql.errors.{QueryCompilationErrors, QueryErrorsBase}
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.internal.types.StringTypeWithCollation
@@ -63,6 +66,8 @@ case class GetJsonObject(json: Expression, path: Expression)
override def nullable: Boolean = true
override def prettyName: String = "get_json_object"
+ final override val nodePatterns: Seq[TreePattern] = Seq(GET_JSON_OBJECT)
+
@transient
private lazy val evaluator = if (path.foldable) {
new GetJsonObjectEvaluator(path.eval().asInstanceOf[UTF8String])
@@ -136,6 +141,82 @@ case class GetJsonObject(json: Expression, path:
Expression)
copy(json = newLeft, path = newRight)
}
+object GetJsonObject {
+ private[sql] def simpleTopLevelField(path: UTF8String): Option[String] = {
+ try {
+ Option(path).flatMap(value =>
JsonPathParser.parse(value.toString)).collect {
+ case List(PathInstruction.Key, PathInstruction.Named(fieldName)) =>
fieldName
+ }
+ } catch {
+ // Numeric subscripts are parsed as Long and can overflow before the
parser returns None.
+ case _: NumberFormatException => None
+ }
+ }
+}
+
+/**
+ * Extracts multiple simple top-level fields from a JSON string in one parse.
This is an internal
+ * expression used to share sibling [[GetJsonObject]] expressions; unsupported
JSON paths remain
+ * as independent GetJsonObject expressions.
+ */
+case class MultiGetJsonObject(
+ json: Expression,
+ fieldNames: Seq[String],
+ fallbackPaths: Seq[String])
+ extends UnaryExpression
+ with ExpectsInputTypes {
+
+ require(
+ fieldNames.nonEmpty &&
+ fieldNames.distinct.length == fieldNames.length &&
+ fallbackPaths.length == fieldNames.length)
+
+ override def child: Expression = json
+
+ override def inputTypes: Seq[AbstractDataType] =
+ Seq(StringTypeWithCollation(supportsTrimCollation = true))
+
+ override lazy val dataType: DataType = StructType(fieldNames.indices.map {
index =>
+ StructField(s"_$index", StringType, nullable = true)
+ })
+
+ override def nullable: Boolean = true
+
+ // This internal unary expression always returns null when its JSON child is
null.
+ override def nullIntolerant: Boolean = true
+
+ override def prettyName: String = "multi_get_json_object"
+
+ final override val nodePatterns: Seq[TreePattern] = Seq(GET_JSON_OBJECT)
+
+ @transient
+ private lazy val evaluator = MultiGetJsonObjectEvaluator(
+ fieldNames,
+ fallbackPaths.map(UTF8String.fromString))
+
+ override def eval(input: InternalRow): Any = {
+ evaluator.evaluate(json.eval(input).asInstanceOf[UTF8String])
+ }
+
+ override protected def doGenCode(ctx: CodegenContext, ev: ExprCode):
ExprCode = {
+ val refEvaluator = ctx.addReferenceObj("evaluator", evaluator)
+ val jsonEval = json.genCode(ctx)
+ val resultType = CodeGenerator.javaType(dataType)
+ ev.copy(code = code"""
+ |${jsonEval.code}
+ |boolean ${ev.isNull} = ${jsonEval.isNull};
+ |$resultType ${ev.value} = ${CodeGenerator.defaultValue(dataType)};
+ |if (!${ev.isNull}) {
+ | ${ev.value} = ($resultType)
$refEvaluator.evaluate(${jsonEval.value});
+ | ${ev.isNull} = ${ev.value} == null;
+ |}
+ |""".stripMargin)
+ }
+
+ override protected def withNewChildInternal(newChild: Expression):
MultiGetJsonObject =
+ copy(json = newChild)
+}
+
// scalastyle:off line.size.limit line.contains.tab
@ExpressionDescription(
usage = "_FUNC_(jsonStr, p1, p2, ..., pn) - Returns a tuple like the
function get_json_object, but it takes multiple names. All the input parameters
and output column types are string.",
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 04cc230f99b4..29d4073916f5 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
@@ -17,12 +17,16 @@
package org.apache.spark.sql.catalyst.optimizer
+import scala.collection.mutable
+
import org.apache.spark.sql.catalyst.expressions._
-import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
+import org.apache.spark.sql.catalyst.plans.logical.{LogicalPlan, Project}
import org.apache.spark.sql.catalyst.rules.Rule
import org.apache.spark.sql.catalyst.trees.TreePattern.{CREATE_NAMED_STRUCT,
EXTRACT_VALUE,
- JSON_TO_STRUCT}
-import org.apache.spark.sql.types.{ArrayType, StructType}
+ GET_JSON_OBJECT, JSON_TO_STRUCT}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types.{ArrayType, StringType, StructType}
+import org.apache.spark.unsafe.types.UTF8String
/**
* Simplify redundant csv/json related expressions.
@@ -34,16 +38,38 @@ import org.apache.spark.sql.types.{ArrayType, StructType}
* If(IsNull(json), nullStruct, KnownNotNull(JsonToStructs(prunedSchema,
..., json)))
* if JsonToStructs(json) is shared among all fields of
CreateNamedStruct. `prunedSchema`
* contains all accessed fields in original CreateNamedStruct.
- * 4. Prune unnecessary columns from GetStructField + CsvToStructs.
+ * 4. Share one MultiGetJsonObject when a Project extracts multiple simple
paths from a JSON.
+ * 5. Prune unnecessary columns from GetStructField + CsvToStructs.
*/
object OptimizeCsvJsonExprs extends Rule[LogicalPlan] {
private def nameOfCorruptRecord = conf.columnNameOfCorruptRecord
+ private case class SharedJsonFields(
+ json: Expression,
+ fieldNames: Seq[String],
+ alias: Alias) {
+ val ordinalMapping: Map[String, Int] = fieldNames.zipWithIndex.toMap
+ }
+
+ private def evaluatesLeftFirst(binary: BinaryArithmetic): Boolean = binary
match {
+ case _: Add | _: Subtract | _: Multiply | _: BitwiseAnd | _: BitwiseOr |
_: BitwiseXor => true
+ case _ => false
+ }
+
override def apply(plan: LogicalPlan): LogicalPlan =
plan.transformWithPruning(
- _.containsAnyPattern(CREATE_NAMED_STRUCT, EXTRACT_VALUE, JSON_TO_STRUCT),
ruleId) {
+ _.containsAnyPattern(CREATE_NAMED_STRUCT, EXTRACT_VALUE, GET_JSON_OBJECT,
JSON_TO_STRUCT),
+ ruleId) {
case p =>
val optimized = if (conf.jsonExpressionOptimization) {
- p.transformExpressionsWithPruning(
+ val withSharedJsonPaths = p match {
+ case project: Project
+ if conf.getJsonObjectSharedParsingEnabled &&
+ !conf.getConf(SQLConf.COLLAPSE_PROJECT_ALWAYS_INLINE) &&
+
project.projectList.exists(_.exists(_.isInstanceOf[GetJsonObject])) =>
+ shareGetJsonObjects(project)
+ case _ => p
+ }
+ withSharedJsonPaths.transformExpressionsWithPruning(
_.containsAnyPattern(CREATE_NAMED_STRUCT, EXTRACT_VALUE,
JSON_TO_STRUCT)
)(jsonOptimization)
} else {
@@ -58,6 +84,122 @@ object OptimizeCsvJsonExprs extends Rule[LogicalPlan] {
}
}
+ /**
+ * Share simple top-level GetJsonObject paths without changing the
Hive-compatible semantics of
+ * nested paths, wildcards, or array subscripts. [[MultiGetJsonObject]]
preserves the first
+ * non-null duplicate-key match used by GetJsonObject, unlike JsonTuple.
+ */
+ private def shareGetJsonObjects(project: Project): Project = {
+ val candidates = project.projectList.flatMap(collectGetJsonObjectFields)
+ val groups = mutable.ArrayBuffer.empty[
+ (Expression, mutable.ArrayBuffer[(String, String)])]
+ val groupsByHash = mutable.HashMap.empty[
+ Int, mutable.ArrayBuffer[(Expression, mutable.ArrayBuffer[(String,
String)])]]
+
+ candidates.foreach { case (getJsonObject, fieldName, path) =>
+ val bucket = groupsByHash.getOrElseUpdate(
+ getJsonObject.json.semanticHash(), mutable.ArrayBuffer.empty)
+ bucket.find(_._1.semanticEquals(getJsonObject.json)) match {
+ case Some((_, fields)) => fields += fieldName -> path
+ case None =>
+ val group = getJsonObject.json -> mutable.ArrayBuffer(fieldName ->
path)
+ bucket += group
+ groups += group
+ }
+ }
+
+ val sharedFields = groups.flatMap { case (json, requestedFields) =>
+ val fieldsByName = mutable.LinkedHashMap.empty[String, String]
+ requestedFields.foreach { case (fieldName, path) =>
+ fieldsByName.getOrElseUpdate(fieldName, path)
+ }
+ val fieldNames = fieldsByName.keys.toSeq
+ if (fieldNames.length > 1) {
+ val alias = Alias(
+ MultiGetJsonObject(json, fieldNames, fieldsByName.values.toSeq),
+ "_shared_json_paths")()
+ Some(SharedJsonFields(json, fieldNames, alias))
+ } else {
+ None
+ }
+ }.toSeq
+
+ if (sharedFields.isEmpty) {
+ project
+ } else {
+ val sharedFieldsByHash = sharedFields.groupBy(_.json.semanticHash())
+ val rewrittenProjectList = project.projectList.map { expression =>
+ rewriteGetJsonObjectFields(expression, sharedFieldsByHash)
+ .asInstanceOf[NamedExpression]
+ }
+ val innerProjectList = project.child.output ++ sharedFields.map(_.alias)
+ Project(rewrittenProjectList, Project(innerProjectList, project.child))
+ }
+ }
+
+ private def collectGetJsonObjectFields(
+ expression: Expression): Seq[(GetJsonObject, String, String)] = {
+ expression match {
+ case getJsonObject @ GetJsonObject(_: Attribute, Literal(path:
UTF8String, StringType))
+ if getJsonObject.deterministic =>
+ GetJsonObject.simpleTopLevelField(path)
+ .map(fieldName => (getJsonObject, fieldName, path.toString)).toSeq
+
+ case _: GetJsonObject =>
+ Nil
+
+ case other =>
+
getJsonObjectTraversalChild(other).toSeq.flatMap(collectGetJsonObjectFields)
+ }
+ }
+
+ private def rewriteGetJsonObjectFields(
+ expression: Expression,
+ sharedFieldsByHash: Map[Int, Seq[SharedJsonFields]]): Expression = {
+ expression match {
+ case getJsonObject @ GetJsonObject(json, Literal(path: UTF8String,
StringType)) =>
+ val replacement = for {
+ fieldName <- GetJsonObject.simpleTopLevelField(path)
+ shared <- sharedFieldsByHash.getOrElse(json.semanticHash(),
Nil).find { candidate =>
+ candidate.json.semanticEquals(json) &&
candidate.ordinalMapping.contains(fieldName)
+ }
+ } yield GetStructField(shared.alias.toAttribute,
shared.ordinalMapping(fieldName))
+ replacement.getOrElse(getJsonObject)
+
+ case _: GetJsonObject =>
+ expression
+
+ case other =>
+ getJsonObjectTraversalChild(other).map { child =>
+ other.withNewChildren(
+ rewriteGetJsonObjectFields(child, sharedFieldsByHash) +:
other.children.tail)
+ }.getOrElse(other)
+ }
+ }
+
+ private def getJsonObjectTraversalChild(expression: Expression):
Option[Expression] = {
+ expression match {
+ case _: ConditionalExpression | _: And | _: Or | _: In | _: TryEval |
+ _: LambdaFunction | _: CreateNamedStruct =>
+ None
+
+ case alias: Alias =>
+ Some(alias.child)
+
+ case getStructField: GetStructField =>
+ Some(getStructField.child)
+
+ case cast: Cast =>
+ Some(cast.child)
+
+ case binary: BinaryArithmetic if evaluatesLeftFirst(binary) =>
+ Some(binary.left)
+
+ case _ =>
+ None
+ }
+ }
+
private val jsonOptimization: PartialFunction[Expression, Expression] = {
case c: CreateNamedStruct
// If we create struct from various fields of the same `JsonToStructs`.
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/trees/TreePatterns.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/trees/TreePatterns.scala
index eed7ebd80dba..7c2abc001862 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/trees/TreePatterns.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/trees/TreePatterns.scala
@@ -58,6 +58,7 @@ object TreePattern extends Enumeration {
val FUNCTION_TABLE_RELATION_ARGUMENT_EXPRESSION: Value = Value
val GENERATOR: Value = Value
val GROUPING_ANALYTICS: Value = Value
+ val GET_JSON_OBJECT: Value = Value
val HIGH_ORDER_FUNCTION: Value = Value
val IF: Value = Value
val IN: Value = Value
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
index 2813dad9400f..db74d0378fc1 100644
--- a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
+++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
@@ -3865,6 +3865,17 @@ object SQLConf {
.booleanConf
.createWithDefault(true)
+ val GET_JSON_OBJECT_SHARED_PARSING_ENABLED =
+ buildConf("spark.sql.optimizer.getJsonObjectSharedParsing.enabled")
+ .internal()
+ .doc(s"When true and '${JSON_EXPRESSION_OPTIMIZATION.key}' is also true,
the optimizer " +
+ "replaces repeated simple top-level get_json_object expressions over
the same input " +
+ "with one shared parse.")
+ .version("4.3.0")
+ .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
+ .booleanConf
+ .createWithDefault(false)
+
val CSV_EXPRESSION_OPTIMIZATION =
buildConf("spark.sql.optimizer.enableCsvExpressionOptimization")
.doc("Whether to optimize CSV expressions in SQL optimizer. It includes
pruning " +
@@ -8304,6 +8315,9 @@ class SQLConf extends Serializable with Logging with
SqlApiConf {
def jsonExpressionOptimization: Boolean =
getConf(SQLConf.JSON_EXPRESSION_OPTIMIZATION)
+ def getJsonObjectSharedParsingEnabled: Boolean =
+ getConf(SQLConf.GET_JSON_OBJECT_SHARED_PARSING_ENABLED)
+
def csvExpressionOptimization: Boolean =
getConf(SQLConf.CSV_EXPRESSION_OPTIMIZATION)
def parallelFileListingInStatsComputation: Boolean =
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 eed06da609f8..85d621d2bc79 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
@@ -21,7 +21,7 @@ import org.apache.spark.sql.catalyst.dsl.expressions._
import org.apache.spark.sql.catalyst.dsl.plans._
import org.apache.spark.sql.catalyst.expressions._
import org.apache.spark.sql.catalyst.plans.PlanTest
-import org.apache.spark.sql.catalyst.plans.logical.{LocalRelation, LogicalPlan}
+import org.apache.spark.sql.catalyst.plans.logical.{LocalRelation,
LogicalPlan, Project}
import org.apache.spark.sql.catalyst.rules.RuleExecutor
import org.apache.spark.sql.catalyst.util.DateTimeUtils.getZoneId
import org.apache.spark.sql.internal.SQLConf
@@ -30,18 +30,40 @@ import org.apache.spark.sql.types._
class OptimizeJsonExprsSuite extends PlanTest with ExpressionEvalHelper {
private var jsonExpressionOptimizeEnabled: Boolean = _
+ private var getJsonObjectSharedParsingEnabled: Boolean = _
protected override def beforeAll(): Unit = {
jsonExpressionOptimizeEnabled = SQLConf.get.jsonExpressionOptimization
+ getJsonObjectSharedParsingEnabled =
SQLConf.get.getJsonObjectSharedParsingEnabled
+ SQLConf.get.setConf(SQLConf.GET_JSON_OBJECT_SHARED_PARSING_ENABLED, true)
}
protected override def afterAll(): Unit = {
SQLConf.get.setConf(SQLConf.JSON_EXPRESSION_OPTIMIZATION,
jsonExpressionOptimizeEnabled)
+ SQLConf.get.setConf(
+ SQLConf.GET_JSON_OBJECT_SHARED_PARSING_ENABLED,
getJsonObjectSharedParsingEnabled)
}
object Optimizer extends RuleExecutor[LogicalPlan] {
val batches = Batch("Json optimization", FixedPoint(10),
OptimizeCsvJsonExprs) :: Nil
}
+ object OptimizerWithCollapseProject extends RuleExecutor[LogicalPlan] {
+ val batches = Batch(
+ "Json optimization with project collapse",
+ FixedPoint(10),
+ CollapseProject,
+ OptimizeCsvJsonExprs) :: Nil
+ }
+
+ object OptimizerWithColumnPruning extends RuleExecutor[LogicalPlan] {
+ val batches = Batch(
+ "Json optimization with column pruning",
+ FixedPoint(10),
+ ColumnPruning,
+ CollapseProject,
+ OptimizeCsvJsonExprs) :: Nil
+ }
+
val schema = StructType.fromDDL("a int, b int")
private val structAtt = $"struct".struct(schema).notNull
@@ -175,6 +197,130 @@ class OptimizeJsonExprsSuite extends PlanTest with
ExpressionEvalHelper {
comparePlans(optimized2, expected2)
}
+ test("SPARK-47670: share simple top-level get_json_object paths") {
+ val query = testRelation2.select(
+ Cast(GetJsonObject($"json", Literal("$.b")), LongType).as("b"),
+ GetJsonObject($"json", Literal("$['a']")).as("a"))
+ val optimized = Optimizer.execute(query.analyze)
+
+ optimized match {
+ case Project(projectList, Project(innerProjectList, _: LocalRelation)) =>
+ val sharedAlias = innerProjectList.collectFirst {
+ case alias @ Alias(_: MultiGetJsonObject, "_shared_json_paths") =>
alias
+ }.getOrElse(fail(s"Missing shared JSON paths in plan:\n$optimized"))
+ val shared = sharedAlias.child.asInstanceOf[MultiGetJsonObject]
+ assert(shared.fieldNames == Seq("b", "a"))
+ assert(shared.fallbackPaths == Seq("$.b", "$['a']"))
+
+ val sharedAttr = sharedAlias.toAttribute
+ val extractedFields = projectList.flatMap(_.collect {
+ case getStructField: GetStructField
+ if getStructField.child.semanticEquals(sharedAttr) =>
getStructField
+ })
+ assert(extractedFields.map(_.ordinal) == Seq(0, 1))
+
+ case _ =>
+ fail(s"Expected shared JSON paths below the project, but
found:\n$optimized")
+ }
+ }
+
+ test("SPARK-47670: shared get_json_object parsing is disabled by default") {
+ assert(!new SQLConf().getJsonObjectSharedParsingEnabled)
+ val query = testRelation2.select(
+ GetJsonObject($"json", Literal("$.a")).as("a"),
+ GetJsonObject($"json", Literal("$.b")).as("b"))
+
+ withSQLConf(SQLConf.GET_JSON_OBJECT_SHARED_PARSING_ENABLED.key -> "false")
{
+ comparePlans(Optimizer.execute(query.analyze), query.analyze)
+ }
+ }
+
+ test("SPARK-47670: do not fail optimization for unparseable get_json_object
paths") {
+ val oversizedIndex = "$[" + "9" * 100 + "]"
+ val query = testRelation2.select(
+ GetJsonObject($"json", Literal(oversizedIndex)).as("value"))
+
+ comparePlans(Optimizer.execute(query.analyze), query.analyze)
+ }
+
+ test("SPARK-47670: do not combine nested get_json_object paths") {
+ val nested = GetJsonObject($"json", Literal("$.a.b"))
+ val query = testRelation2.select(
+ GetJsonObject($"json", Literal("$.a")).as("a"),
+ nested.as("nested"),
+ GetJsonObject($"json", Literal("$.c")).as("c"))
+ val optimized = Optimizer.execute(query.analyze)
+
+ val shared = optimized.collect {
+ case Project(projectList, _) => projectList.collectFirst {
+ case alias @ Alias(_: MultiGetJsonObject, "_shared_json_paths") =>
alias
+ }
+ }.flatten.headOption.getOrElse(fail(s"Missing shared JSON paths in
plan:\n$optimized"))
+ assert(shared.child.asInstanceOf[MultiGetJsonObject].fieldNames ==
Seq("a", "c"))
+ assert(optimized.expressions.exists(_.exists {
+ case GetJsonObject(_, Literal(path, StringType)) => path.toString ==
"$.a.b"
+ case _ => false
+ }))
+ }
+
+ test("SPARK-47670: shared get_json_object paths survive project collapsing")
{
+ val query = testRelation2.select(
+ GetJsonObject($"json", Literal("$.a")).as("a"),
+ GetJsonObject($"json", Literal("$.b")).as("b"))
+
+ val optimized = OptimizerWithCollapseProject.execute(query.analyze)
+
+ assert(optimized.exists { plan =>
+ plan.expressions.exists(_.exists(_.isInstanceOf[MultiGetJsonObject]))
+ })
+ assert(optimized.collect { case _: Project => true }.length == 2)
+ }
+
+ test("SPARK-47670: do not share get_json_object paths that are guarded or
pruned") {
+ val guardedQuery = testRelation2.select(
+ If(
+ IsNull($"json"),
+ Literal(null, StringType),
+ GetJsonObject($"json", Literal("$.a"))).as("a"),
+ GetJsonObject($"json", Literal("$.b")).as("b"))
+ assert(!Optimizer.execute(guardedQuery.analyze).exists { plan =>
+ plan.expressions.exists(_.exists(_.isInstanceOf[MultiGetJsonObject]))
+ })
+
+ val lowerProject = testRelation2.select(
+ GetJsonObject($"json", Literal("$.a")).as("a"),
+ GetJsonObject($"json", Literal("$.b")).as("b"))
+ val prunedQuery = lowerProject.select(lowerProject.output.head)
+ assert(!OptimizerWithColumnPruning.execute(prunedQuery.analyze).exists {
plan =>
+ plan.expressions.exists(_.exists(_.isInstanceOf[MultiGetJsonObject]))
+ })
+ }
+
+ test("SPARK-47670: do not share separately projected from_json fields") {
+ val schema = StructType.fromDDL("a int, b struct<x: int>")
+ val fromJson = JsonToStructs(schema, Map.empty, $"json")
+ val query = testRelation2.select(
+ GetStructField(fromJson, 0).as("a"),
+ GetStructField(fromJson, 1).as("b"))
+
+ val optimized = Optimizer.execute(query.analyze)
+ val parsedSchemas = optimized.expressions.flatMap(_.collect {
+ case jsonToStructs: JsonToStructs => jsonToStructs.schema
+ })
+ assert(parsedSchemas == Seq(
+ StructType.fromDDL("a int"),
+ StructType.fromDDL("b struct<x: int>")))
+ }
+
+ test("SPARK-47670: do not share get_json_object below right-first
arithmetic") {
+ val query = testRelation2.select(
+ Pmod(Cast(GetJsonObject($"json", Literal("$.a")), IntegerType),
Literal(0)).as("a"),
+ GetJsonObject($"json", Literal("$.b")).as("b"))
+ assert(!Optimizer.execute(query.analyze).exists { plan =>
+ plan.expressions.exists(_.exists(_.isInstanceOf[MultiGetJsonObject]))
+ })
+ }
+
test("SPARK-32958: prune unnecessary columns from GetArrayStructFields +
from_json") {
val options = Map.empty[String, String]
val schema1 = ArrayType(StructType.fromDDL("a int, b int"), containsNull =
true)
diff --git a/sql/core/benchmarks/SharedJsonParseBenchmark-jdk21-results.txt
b/sql/core/benchmarks/SharedJsonParseBenchmark-jdk21-results.txt
new file mode 100644
index 000000000000..dcda87f62293
--- /dev/null
+++ b/sql/core/benchmarks/SharedJsonParseBenchmark-jdk21-results.txt
@@ -0,0 +1,33 @@
+================================================================================================
+Benchmark for sharing repeated get_json_object parsing
+================================================================================================
+
+OpenJDK 64-Bit Server VM 21.0.11+10-LTS on Linux 6.17.0-1018-azure
+AMD EPYC 7763 64-Core Processor
+get_json_object extracting 2 of 32 fields: Best Time(ms) Avg Time(ms)
Stdev(ms) Rate(M/s) Per Row(ns) Relative
+-------------------------------------------------------------------------------------------------------------------------
+shared parsing off 1686 1712
25 0.1 8429.2 1.0X
+shared parsing on 979 1004
23 0.2 4892.7 1.7X
+
+OpenJDK 64-Bit Server VM 21.0.11+10-LTS on Linux 6.17.0-1018-azure
+AMD EPYC 7763 64-Core Processor
+get_json_object extracting 4 of 32 fields: Best Time(ms) Avg Time(ms)
Stdev(ms) Rate(M/s) Per Row(ns) Relative
+-------------------------------------------------------------------------------------------------------------------------
+shared parsing off 3273 3279
6 0.1 16365.1 1.0X
+shared parsing on 1242 1250
14 0.2 6209.0 2.6X
+
+OpenJDK 64-Bit Server VM 21.0.11+10-LTS on Linux 6.17.0-1018-azure
+AMD EPYC 7763 64-Core Processor
+get_json_object extracting 8 of 32 fields: Best Time(ms) Avg Time(ms)
Stdev(ms) Rate(M/s) Per Row(ns) Relative
+-------------------------------------------------------------------------------------------------------------------------
+shared parsing off 6449 6465
16 0.0 32247.0 1.0X
+shared parsing on 1317 1331
12 0.2 6586.7 4.9X
+
+OpenJDK 64-Bit Server VM 21.0.11+10-LTS on Linux 6.17.0-1018-azure
+AMD EPYC 7763 64-Core Processor
+get_json_object extracting 16 of 32 fields: Best Time(ms) Avg Time(ms)
Stdev(ms) Rate(M/s) Per Row(ns) Relative
+--------------------------------------------------------------------------------------------------------------------------
+shared parsing off 13118 13134
15 0.0 65592.1 1.0X
+shared parsing on 1823 1830
9 0.1 9113.0 7.2X
+
+
diff --git a/sql/core/benchmarks/SharedJsonParseBenchmark-jdk25-results.txt
b/sql/core/benchmarks/SharedJsonParseBenchmark-jdk25-results.txt
new file mode 100644
index 000000000000..b09050a476f3
--- /dev/null
+++ b/sql/core/benchmarks/SharedJsonParseBenchmark-jdk25-results.txt
@@ -0,0 +1,33 @@
+================================================================================================
+Benchmark for sharing repeated get_json_object parsing
+================================================================================================
+
+OpenJDK 64-Bit Server VM 25.0.3+9-LTS on Linux 6.17.0-1018-azure
+AMD EPYC 7763 64-Core Processor
+get_json_object extracting 2 of 32 fields: Best Time(ms) Avg Time(ms)
Stdev(ms) Rate(M/s) Per Row(ns) Relative
+-------------------------------------------------------------------------------------------------------------------------
+shared parsing off 1663 1680
26 0.1 8315.1 1.0X
+shared parsing on 964 980
14 0.2 4818.8 1.7X
+
+OpenJDK 64-Bit Server VM 25.0.3+9-LTS on Linux 6.17.0-1018-azure
+AMD EPYC 7763 64-Core Processor
+get_json_object extracting 4 of 32 fields: Best Time(ms) Avg Time(ms)
Stdev(ms) Rate(M/s) Per Row(ns) Relative
+-------------------------------------------------------------------------------------------------------------------------
+shared parsing off 3200 3215
14 0.1 15998.6 1.0X
+shared parsing on 1096 1111
14 0.2 5478.2 2.9X
+
+OpenJDK 64-Bit Server VM 25.0.3+9-LTS on Linux 6.17.0-1018-azure
+AMD EPYC 7763 64-Core Processor
+get_json_object extracting 8 of 32 fields: Best Time(ms) Avg Time(ms)
Stdev(ms) Rate(M/s) Per Row(ns) Relative
+-------------------------------------------------------------------------------------------------------------------------
+shared parsing off 6233 6248
13 0.0 31163.9 1.0X
+shared parsing on 1304 1319
14 0.2 6519.4 4.8X
+
+OpenJDK 64-Bit Server VM 25.0.3+9-LTS on Linux 6.17.0-1018-azure
+AMD EPYC 7763 64-Core Processor
+get_json_object extracting 16 of 32 fields: Best Time(ms) Avg Time(ms)
Stdev(ms) Rate(M/s) Per Row(ns) Relative
+--------------------------------------------------------------------------------------------------------------------------
+shared parsing off 12453 12474
19 0.0 62264.1 1.0X
+shared parsing on 1776 1785
9 0.1 8879.8 7.0X
+
+
diff --git a/sql/core/benchmarks/SharedJsonParseBenchmark-results.txt
b/sql/core/benchmarks/SharedJsonParseBenchmark-results.txt
new file mode 100644
index 000000000000..fdee38732184
--- /dev/null
+++ b/sql/core/benchmarks/SharedJsonParseBenchmark-results.txt
@@ -0,0 +1,33 @@
+================================================================================================
+Benchmark for sharing repeated get_json_object parsing
+================================================================================================
+
+OpenJDK 64-Bit Server VM 17.0.19+10-LTS on Linux 6.17.0-1018-azure
+AMD EPYC 7763 64-Core Processor
+get_json_object extracting 2 of 32 fields: Best Time(ms) Avg Time(ms)
Stdev(ms) Rate(M/s) Per Row(ns) Relative
+-------------------------------------------------------------------------------------------------------------------------
+shared parsing off 1526 1558
35 0.1 7629.3 1.0X
+shared parsing on 902 907
6 0.2 4508.0 1.7X
+
+OpenJDK 64-Bit Server VM 17.0.19+10-LTS on Linux 6.17.0-1018-azure
+AMD EPYC 7763 64-Core Processor
+get_json_object extracting 4 of 32 fields: Best Time(ms) Avg Time(ms)
Stdev(ms) Rate(M/s) Per Row(ns) Relative
+-------------------------------------------------------------------------------------------------------------------------
+shared parsing off 2978 2999
25 0.1 14890.0 1.0X
+shared parsing on 1027 1029
1 0.2 5136.9 2.9X
+
+OpenJDK 64-Bit Server VM 17.0.19+10-LTS on Linux 6.17.0-1018-azure
+AMD EPYC 7763 64-Core Processor
+get_json_object extracting 8 of 32 fields: Best Time(ms) Avg Time(ms)
Stdev(ms) Rate(M/s) Per Row(ns) Relative
+-------------------------------------------------------------------------------------------------------------------------
+shared parsing off 5871 5889
16 0.0 29355.9 1.0X
+shared parsing on 1245 1251
6 0.2 6226.5 4.7X
+
+OpenJDK 64-Bit Server VM 17.0.19+10-LTS on Linux 6.17.0-1018-azure
+AMD EPYC 7763 64-Core Processor
+get_json_object extracting 16 of 32 fields: Best Time(ms) Avg Time(ms)
Stdev(ms) Rate(M/s) Per Row(ns) Relative
+--------------------------------------------------------------------------------------------------------------------------
+shared parsing off 11620 11679
54 0.0 58098.9 1.0X
+shared parsing on 1704 1707
3 0.1 8519.4 6.8X
+
+
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 8857a9977b57..50d5b4be24ee 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
@@ -23,13 +23,15 @@ import java.util.Locale
import scala.jdk.CollectionConverters._
+import com.fasterxml.jackson.core.StreamReadConstraints
+
import org.apache.spark.{SparkException, SparkRuntimeException,
SparkUnsupportedOperationException}
import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions.{JsonToStructs, Literal,
MultiGetJsonObject}
import org.apache.spark.sql.catalyst.expressions.Cast._
-import org.apache.spark.sql.catalyst.expressions.Literal
import
org.apache.spark.sql.catalyst.util.TimestampNanosTestUtils.foreachNanosPrecision
import org.apache.spark.sql.errors.DataTypeErrors.toSQLType
-import org.apache.spark.sql.execution.WholeStageCodegenExec
+import org.apache.spark.sql.execution.{InputAdapter, SparkPlan,
WholeStageCodegenExec}
import org.apache.spark.sql.functions._
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.test.SharedSparkSession
@@ -83,6 +85,174 @@ class JsonFunctionsSuite extends SharedSparkSession {
expected)
}
+ test("SPARK-47670: share simple top-level get_json_object paths") {
+ val input = Seq[String](
+ """{"a":"one","a.b":"dotted","b":2,"obj":{"x":1},"arr":[1,2]}""",
+ """{"a":"first","a":"second","b":3}""",
+ """{"a":null,"a":"after_null","b":4}""",
+ """{"a":"before_error","b":"""",
+ """{'a':'single','b':5}""",
+ """[1,2,3]""",
+ """{"a":"trailing","b":6} trailing text""",
+ """{}""",
+ null)
+
+ def result(jsonOptimization: Boolean, sharedParsing: Boolean): Seq[Row] = {
+ var rows = Seq.empty[Row]
+ withSQLConf(
+ SQLConf.JSON_EXPRESSION_OPTIMIZATION.key ->
jsonOptimization.toString,
+ SQLConf.GET_JSON_OBJECT_SHARED_PARSING_ENABLED.key ->
sharedParsing.toString) {
+ val df = input.toDF("json")
+ rows = df.select(
+ get_json_object($"json", "$.a"),
+ get_json_object($"json", "$['a.b']"),
+ get_json_object($"json", "$.b"),
+ get_json_object($"json", "$.b").cast(IntegerType),
+ get_json_object($"json", "$.obj"),
+ get_json_object($"json", "$.arr"),
+ get_json_object($"json", "$.missing")).collect().toSeq
+ }
+ rows
+ }
+
+ val legacy = result(jsonOptimization = false, sharedParsing = false)
+ assert(result(jsonOptimization = true, sharedParsing = false) == legacy)
+ assert(result(jsonOptimization = true, sharedParsing = true) == legacy)
+ }
+
+ test("SPARK-47670: shared get_json_object isolates value rendering
failures") {
+ val invalidSurrogate = "\\" + "uD800"
+ val input = Seq(
+ s"""{"a":"before","b":"$invalidSurrogate","c":"after"}""",
+ s"""{"a":"before","b":{"nested":"$invalidSurrogate"},"c":"after"}""",
+ s"""{"a":"before","b":"$invalidSurrogate","b":"valid","c":"after"}""")
+
+ def result(jsonOptimization: Boolean): Seq[Row] = {
+ var rows = Seq.empty[Row]
+ withSQLConf(
+ SQLConf.JSON_EXPRESSION_OPTIMIZATION.key ->
jsonOptimization.toString,
+ SQLConf.GET_JSON_OBJECT_SHARED_PARSING_ENABLED.key -> "true") {
+ rows = input.toDF("json").select(
+ get_json_object($"json", "$.a"),
+ get_json_object($"json", "$.b"),
+ get_json_object($"json", "$.c")).collect().toSeq
+ }
+ rows
+ }
+
+ assert(result(jsonOptimization = true) == result(jsonOptimization = false))
+ assert(result(jsonOptimization = true) == Seq(
+ Row("before", null, "after"),
+ Row("before", s"""{"nested":"$invalidSurrogate"}""", "after"),
+ Row("before", null, "after")))
+ }
+
+ test("SPARK-47670: shared get_json_object falls back after parser-side
rendering failure") {
+ val oversized = "x" * (StreamReadConstraints.DEFAULT_MAX_STRING_LEN + 1)
+
+ def result(sharedParsingEnabled: Boolean): Seq[Row] = {
+ var rows = Seq.empty[Row]
+ withSQLConf(
+ SQLConf.JSON_EXPRESSION_OPTIMIZATION.key -> "true",
+ SQLConf.GET_JSON_OBJECT_SHARED_PARSING_ENABLED.key ->
sharedParsingEnabled.toString) {
+ val query = Seq(s"""{"a":"$oversized","b.c":2}""").toDF("json").select(
+ get_json_object($"json", "$.a"),
+ get_json_object($"json", "$['b.c']"))
+
+ if (sharedParsingEnabled) {
+ assert(query.queryExecution.optimizedPlan.exists { plan =>
+
plan.expressions.exists(_.exists(_.isInstanceOf[MultiGetJsonObject]))
+ })
+ }
+ rows = query.collect().toSeq
+ }
+ rows
+ }
+
+ val sharedResult = result(sharedParsingEnabled = true)
+ assert(sharedResult == result(sharedParsingEnabled = false))
+ assert(sharedResult == Seq(Row(null, "2")))
+ }
+
+ test("SPARK-47670: shared get_json_object does not return partial malformed
results") {
+ val malformed = """{"a":1,"b":"\q}"}"""
+
+ def result(jsonOptimization: Boolean): Seq[Row] = {
+ var rows = Seq.empty[Row]
+ withSQLConf(
+ SQLConf.JSON_EXPRESSION_OPTIMIZATION.key ->
jsonOptimization.toString,
+ SQLConf.GET_JSON_OBJECT_SHARED_PARSING_ENABLED.key -> "true") {
+ val query = Seq(malformed).toDF("json").select(
+ get_json_object($"json", "$.a"),
+ get_json_object($"json", "$.b"))
+ if (jsonOptimization) {
+ assert(query.queryExecution.optimizedPlan.exists { plan =>
+
plan.expressions.exists(_.exists(_.isInstanceOf[MultiGetJsonObject]))
+ })
+ }
+ rows = query.collect().toSeq
+ }
+ rows
+ }
+
+ val shared = result(jsonOptimization = true)
+ val legacy = result(jsonOptimization = false)
+ assert(shared == legacy)
+ assert(shared == Seq(Row(null, null)))
+ }
+
+ test("SPARK-47670: shared get_json_object handles deeply nested values on a
small stack") {
+ val depth = 999
+ val nested = "[" * depth + "1" + "]" * depth
+ val expression = MultiGetJsonObject(
+ Literal(s"""{"a":$nested,"b":2}"""), Seq("a", "b"), Seq("$.a", "$.b"))
+ val result = Array.ofDim[Any](1)
+ val thread = new Thread(
+ null,
+ new Runnable {
+ override def run(): Unit = {
+ try {
+ result(0) = expression.eval()
+ } catch {
+ case error: Throwable => result(0) = error
+ }
+ }
+ },
+ "deep-json-copy",
+ 256 * 1024)
+
+ thread.start()
+ thread.join(30000)
+ assert(!thread.isAlive, "Deep JSON extraction did not finish")
+ result(0) match {
+ case error: Throwable => fail("Deep JSON extraction failed", error)
+ case row: InternalRow =>
+ assert(row.getUTF8String(0).numBytes() == 2 * depth + 1)
+ assert(row.getUTF8String(1).toString == "2")
+ case other => fail(s"Unexpected deep JSON extraction result: $other")
+ }
+ }
+
+ test("SPARK-47670: shared get_json_object supports project code generation")
{
+ withSQLConf(SQLConf.GET_JSON_OBJECT_SHARED_PARSING_ENABLED.key -> "true") {
+ val df = Seq("""{"a":1,"b":2}""").toDF("json").select(
+ get_json_object($"json", "$.a"),
+ get_json_object($"json", "$.b"))
+
+ checkAnswer(df, Row("1", "2"))
+ def containsSharedExtraction(plan: SparkPlan): Boolean = plan match {
+ case _: InputAdapter => false
+ case other
+ if
other.expressions.exists(_.exists(_.isInstanceOf[MultiGetJsonObject])) => true
+ case other => other.children.exists(containsSharedExtraction)
+ }
+ assert(df.queryExecution.executedPlan.exists {
+ case stage: WholeStageCodegenExec =>
containsSharedExtraction(stage.child)
+ case _ => false
+ }, s"Shared get_json_object project was outside whole-stage
codegen:\n${df.queryExecution}")
+ }
+ }
+
test("SPARK-42782: Hive compatibility check for get_json_object") {
val book0 = "{\"author\":\"Nigel Rees\",\"title\":\"Sayings of the
Century\"" +
",\"category\":\"reference\",\"price\":8.95}"
@@ -1365,6 +1535,25 @@ class JsonFunctionsSuite extends SharedSparkSession {
}
}
+ test("SPARK-47670: separately projected from_json fields preserve
malformed-input semantics") {
+ withSQLConf(
+ SQLConf.JSON_EXPRESSION_OPTIMIZATION.key -> "true",
+ SQLConf.JSON_ENABLE_PARTIAL_RESULTS.key -> "true") {
+ val schema = StructType.fromDDL("a int, b struct<x: int>")
+ val input = Seq("""{"a":1,"b":{"x":""").toDF("json")
+ val parsed = from_json($"json", schema)
+ val query = input.select(parsed.getField("a"), parsed.getField("b"))
+
+ val parsedSchemas =
query.queryExecution.optimizedPlan.expressions.flatMap(_.collect {
+ case jsonToStructs: JsonToStructs => jsonToStructs.schema
+ })
+ assert(parsedSchemas == Seq(
+ StructType.fromDDL("a int"),
+ StructType.fromDDL("b struct<x: int>")))
+ checkAnswer(query, Row(null, null))
+ }
+ }
+
test("SPARK-35982: from_json/to_json for map types where value types are
year-month intervals") {
val ymDF = Seq(Period.of(1, 2, 0)).toDF()
Seq(
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/json/SharedJsonParseBenchmark.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/json/SharedJsonParseBenchmark.scala
new file mode 100644
index 000000000000..7d2f74b43a0c
--- /dev/null
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/json/SharedJsonParseBenchmark.scala
@@ -0,0 +1,80 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.spark.sql.execution.datasources.json
+
+import org.apache.spark.benchmark.Benchmark
+import org.apache.spark.sql.execution.benchmark.SqlBasedBenchmark
+import org.apache.spark.sql.functions._
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types.StringType
+
+/**
+ * Benchmarks sharing repeated simple top-level get_json_object parsing.
+ *
+ * To run this benchmark:
+ * {{{
+ * 1. build/sbt "sql/Test/runMain <this class>"
+ * 2. Generate the result file:
+ * SPARK_GENERATE_BENCHMARK_FILES=1 build/sbt "sql/Test/runMain <this
class>"
+ * Results will be written to
+ * "sql/core/benchmarks/SharedJsonParseBenchmark-results.txt" on JDK 17.
+ * }}}
+ */
+object SharedJsonParseBenchmark extends SqlBasedBenchmark {
+ import spark.implicits._
+
+ override def runBenchmarkSuite(mainArgs: Array[String]): Unit = {
+ runBenchmark("Benchmark for sharing repeated get_json_object parsing") {
+ val rows = 200000
+ val fieldCount = 32
+ val fieldValue = concat(lit("value-"), $"id".cast(StringType), lit("-" +
"x" * 64))
+ val data = spark.range(0, rows, 1, 4)
+ .select(to_json(struct(Seq.tabulate(fieldCount) { index =>
+ fieldValue.as(s"field_$index")
+ }: _*)).as("json"))
+ .cache()
+ data.count()
+
+ Seq(2, 4, 8, 16).foreach { selectedFieldCount =>
+ val pathBenchmark = new Benchmark(
+ s"get_json_object extracting $selectedFieldCount of $fieldCount
fields",
+ rows,
+ output = output)
+
+ def extractPaths(sharedParsing: Boolean): Unit = {
+ withSQLConf(
+ SQLConf.JSON_EXPRESSION_OPTIMIZATION.key -> "true",
+ SQLConf.GET_JSON_OBJECT_SHARED_PARSING_ENABLED.key ->
sharedParsing.toString) {
+ data.select(Seq.tabulate(selectedFieldCount) { index =>
+ get_json_object($"json", s"$$.field_$index")
+ }: _*).noop()
+ }
+ }
+
+ pathBenchmark.addCase("shared parsing off", 3) { _ =>
+ extractPaths(sharedParsing = false)
+ }
+ pathBenchmark.addCase("shared parsing on", 3) { _ =>
+ extractPaths(sharedParsing = true)
+ }
+ pathBenchmark.run()
+ }
+
+ data.unpersist()
+ }
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]