This is an automated email from the ASF dual-hosted git repository.
cloud-fan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/master by this push:
new 6507faafa40d [SPARK-57419][SQL] Read and infer JSON schema from tar
archives
6507faafa40d is described below
commit 6507faafa40d76a428284effdc2dc923482d5d2a
Author: akshatshenoi-db <[email protected]>
AuthorDate: Mon Jun 15 22:36:46 2026 -0700
[SPARK-57419][SQL] Read and infer JSON schema from tar archives
### What changes were proposed in this pull request?
SPARK-57135 added reading CSV files packed in tar archives
(`.tar`/`.tar.gz`/`.tgz`) and SPARK-57321 added schema inference for them, both
gated by `spark.sql.files.archive.reader.enabled`. This extends the same
capability to the JSON data source.
When the flag is enabled, the V1 JSON data source reads a tar archive as if
it were a directory of its entries: each entry is streamed through
`ArchiveReader` (never unpacked to disk) and parsed exactly like a standalone
JSON file, for both line-delimited and multi-line JSON
(`JsonDataSource.readArchive`/`readStream`). Schema inference reads every
archive entry together with any loose files in a single `JsonInferSchema` pass
(`inferWithArchives`), so the inferred schema matches a dire [...]
Unlike CSV, JSON needs no per-entry header handling (records are
self-describing, so one parser serves every entry) and no `mergeSchema`-style
branching (`JsonInferSchema` already merges record types by field name across
all inputs, so one pass is itself the union).
This also unifies the archive test suites: the format-agnostic inference
and complex-type tests are hoisted into `ArchiveReadSuiteBase` behind
capability hooks (`supportsSchemaInference`, `supportsComplexTypes`), so CSV,
JSON, and future archive formats share them instead of each duplicating them.
### Why are the changes needed?
To let JSON ingestion read tar archives without unpacking them to disk,
matching the CSV behavior already in Spark.
### Does this PR introduce _any_ user-facing change?
Yes. With `spark.sql.files.archive.reader.enabled=true` (default false),
the JSON data source can read and infer schemas from `.tar`/`.tar.gz`/`.tgz`
files.
### How was this patch tested?
New `JSONTarArchiveReadSuite` (mixing `JSONArchiveReadBase` with the shared
`ArchiveReadSuiteBase` and `TarArchiveReadBase`), plus the hoisted shared
inference and complex-type tests now also exercised by the CSV suites.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code
Closes #56480 from akshatshenoi-db/archive-json.
Authored-by: akshatshenoi-db <[email protected]>
Signed-off-by: Wenchen Fan <[email protected]>
---
.../sql/catalyst/json/CreateJacksonParser.scala | 10 +
.../datasources/json/JsonDataSource.scala | 225 ++++++++++++++++++---
.../datasources/json/JsonFileFormat.scala | 22 +-
.../sql/execution/datasources/json/JsonUtils.scala | 7 +-
.../execution/datasources/v2/json/JsonTable.scala | 4 +-
.../datasources/ArchiveReadSuiteBase.scala | 171 ++++++++++++++++
.../execution/datasources/CSVArchiveReadBase.scala | 92 +--------
.../datasources/JSONArchiveReadBase.scala | 223 ++++++++++++++++++++
.../datasources/JSONTarArchiveReadSuite.scala | 28 +++
9 files changed, 663 insertions(+), 119 deletions(-)
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/json/CreateJacksonParser.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/json/CreateJacksonParser.scala
index 7190f6fff79f..939500d8028d 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/json/CreateJacksonParser.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/json/CreateJacksonParser.scala
@@ -90,4 +90,14 @@ object CreateJacksonParser extends Serializable {
jsonFactory.createParser(sd)
}
+
+ def bytes(jsonFactory: JsonFactory, record: Array[Byte]): JsonParser = {
+ jsonFactory.createParser(record, 0, record.length)
+ }
+
+ def bytes(enc: String, jsonFactory: JsonFactory, record: Array[Byte]):
JsonParser = {
+ val sd = getStreamDecoder(enc, record, record.length)
+
+ jsonFactory.createParser(sd)
+ }
}
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/json/JsonDataSource.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/json/JsonDataSource.scala
index aaa5af478dbb..5f2891985564 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/json/JsonDataSource.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/json/JsonDataSource.scala
@@ -17,7 +17,10 @@
package org.apache.spark.sql.execution.datasources.json
-import java.io.InputStream
+import java.io.{ByteArrayInputStream, FileNotFoundException, InputStream,
IOException}
+
+import scala.reflect.ClassTag
+import scala.util.control.NonFatal
import com.fasterxml.jackson.core.{JsonFactory, JsonParser}
import org.apache.hadoop.conf.Configuration
@@ -28,12 +31,16 @@ import org.apache.hadoop.mapreduce.lib.input.FileInputFormat
import org.apache.spark.TaskContext
import org.apache.spark.input.{PortableDataStream, StreamInputFormat}
+import org.apache.spark.internal.Logging
+import org.apache.spark.internal.LogKeys.PATH
+import org.apache.spark.paths.SparkPath
import org.apache.spark.rdd.{BinaryFileRDD, RDD}
import org.apache.spark.sql.{Dataset, Encoders, SparkSession}
import org.apache.spark.sql.catalyst.InternalRow
import org.apache.spark.sql.catalyst.json.{CreateJacksonParser, JacksonParser,
JsonInferSchema, JSONOptions}
import org.apache.spark.sql.catalyst.util.FailureSafeParser
import org.apache.spark.sql.classic.ClassicConversions.castToImpl
+import org.apache.spark.sql.errors.QueryExecutionErrors
import org.apache.spark.sql.execution.SQLExecution
import org.apache.spark.sql.execution.datasources._
import org.apache.spark.sql.execution.datasources.text.TextFileFormat
@@ -44,7 +51,7 @@ import org.apache.spark.util.Utils
/**
* Common functions for parsing JSON files
*/
-abstract class JsonDataSource extends Serializable {
+abstract class JsonDataSource extends Serializable with Logging {
def isSplitable: Boolean
/**
@@ -56,14 +63,56 @@ abstract class JsonDataSource extends Serializable {
parser: JacksonParser,
schema: StructType): Iterator[InternalRow]
+ /**
+ * Parse a single already-open [[InputStream]] -- one decompressed archive
entry -- into 0 or more
+ * [[InternalRow]] instances, the same way this mode reads a standalone
file: line by line for
+ * [[TextInputJsonDataSource]], as one whole document for
[[MultiLineJsonDataSource]]. Used only
+ * by [[readArchive]]; the stream is not closed here.
+ */
+ protected def readStream(
+ in: InputStream,
+ parser: JacksonParser,
+ schema: StructType): Iterator[InternalRow]
+
+ /**
+ * Streams a tar archive (`.tar`/`.tar.gz`/`.tgz`) entry by entry through
the JSON parser without
+ * unpacking it to disk. The whole archive is a single split (see
`JsonFileFormat.isSplitable`);
+ * each entry's bytes are parsed exactly like a standalone JSON file via
[[readStream]], so this
+ * is mode-agnostic (line-delimited and multi-line both flow through
`readStream`) and a single
+ * `parser` serves every entry -- unlike CSV there is no per-entry header to
rebuild. Kept apart
+ * from [[readFile]] because only the V1 `JsonFileFormat` read path supports
archives; the V2 data
+ * source calls [[readFile]] directly and is intentionally left untouched.
+ */
+ def readArchive(
+ conf: Configuration,
+ file: PartitionedFile,
+ parser: JacksonParser,
+ schema: StructType): Iterator[InternalRow] =
+ ArchiveReader(file.toPath).readEntries(conf) { (_, in) =>
+ readStream(in, parser, schema)
+ }
+
final def inferSchema(
sparkSession: SparkSession,
inputPaths: Seq[FileStatus],
- parsedOptions: JSONOptions): Option[StructType] = {
+ parsedOptions: JSONOptions,
+ supportsArchiveScan: Boolean): Option[StructType] = {
parsedOptions.singleVariantColumn match {
case Some(columnName) => Some(StructType(Array(StructField(columnName,
VariantType))))
case None =>
- if (inputPaths.nonEmpty) {
+ val hasArchive = parsedOptions.archiveFormatEnabled &&
+ inputPaths.exists(f => ArchiveReader.isArchivePath(f.getPath))
+ if (hasArchive && supportsArchiveScan) {
+ // Archives (and any loose files alongside them) are inferred in a
single JsonInferSchema
+ // pass over all inputs -- archive entries are streamed, never
unpacked -- so the result
+ // matches what the scan returns for the same files.
+ Some(inferWithArchives(sparkSession, inputPaths, parsedOptions))
+ } else if (hasArchive) {
+ // The caller's scan path cannot read archives (e.g. the DSv2
reader), so refuse to infer
+ // a schema when any input is an archive: returning None raises
UNABLE_TO_INFER_SCHEMA,
+ // which fails loudly instead of letting the scan parse raw archive
bytes as JSON.
+ None
+ } else if (inputPaths.nonEmpty) {
Some(infer(sparkSession, inputPaths, parsedOptions))
} else {
None
@@ -75,6 +124,89 @@ abstract class JsonDataSource extends Serializable {
sparkSession: SparkSession,
inputPaths: Seq[FileStatus],
parsedOptions: JSONOptions): StructType
+
+ /**
+ * Infers a JSON schema when at least one input is a tar archive. Every
archive entry (streamed
+ * via `ArchiveReader`, never unpacked to disk) and every loose file is read
as JSON records --
+ * each line is a record, or the whole input is one document in multi-line
mode -- and all of them
+ * feed a single [[JsonInferSchema]] pass, exactly as a directory of the
same files would infer.
+ * Because [[JsonInferSchema]] already merges every record's type by field
name across all inputs,
+ * one pass is itself the union: a field empty in one input but typed in
another widens to the
+ * real type, and a `NullType` field survives to the single final
canonicalization rather than
+ * being collapsed per-input. A corrupt/missing input is skipped as a unit
(a whole archive or a
+ * whole file) when `ignoreCorruptFiles`/`ignoreMissingFiles` are set.
+ */
+ private def inferWithArchives(
+ sparkSession: SparkSession,
+ inputPaths: Seq[FileStatus],
+ parsedOptions: JSONOptions): StructType = {
+ val baseRdd = JsonDataSource.createBaseRdd(sparkSession, inputPaths,
parsedOptions)
+ val multiLine = parsedOptions.multiLine
+ val lineSeparator = parsedOptions.lineSeparatorInRead
+ val encoding = parsedOptions.encoding
+ val ignoreCorruptFiles = parsedOptions.ignoreCorruptFiles
+ val ignoreMissingFiles = parsedOptions.ignoreMissingFiles
+
+ // Applies `perEntry` to each input -- once per archive entry, once for a
loose file -- skipping
+ // a whole input when it is corrupt/missing and the ignore flags are set.
The entry/file stream
+ // is consumed lazily by `perEntry`, never buffered whole; mirrors CSV's
`inferWithArchives`.
+ //
+ // An archive entry's stream is a `CloseShieldInputStream` view over the
one shared
+ // `TarArchiveInputStream` cursor, valid only until `readEntries` advances
to the next entry
+ // (`getNextEntry` skips the prior entry's unread bytes). A consumer that
emits the raw stream
+ // (the multiLine path below) must therefore fully consume each element
before the iterator
+ // advances; `JsonInferSchema.infer` does, parsing each record before
pulling the next.
+ // Buffering, look-ahead, or parallelizing the per-partition consumption
would read from an
+ // advanced cursor and infer a wrong schema. The line-delimited path
sidesteps this by
+ // materializing each record (`copyBytes()`).
+ def perInput[T: ClassTag](perEntry: InputStream => Iterator[T]): RDD[T] =
baseRdd.flatMap {
+ stream =>
+ val path = new Path(stream.getPath())
+ try {
+ if (ArchiveReader.isArchivePath(path)) {
+ ArchiveReader(path).readEntries(stream.getConfiguration) { (_, in)
=> perEntry(in) }
+ } else {
+ perEntry(
+
CodecStreams.createInputStreamWithCloseResource(stream.getConfiguration, path))
+ }
+ } catch {
+ case e: FileNotFoundException if ignoreMissingFiles =>
+ logWarning(log"Skipped missing input: ${MDC(PATH,
stream.getPath())}", e)
+ Iterator.empty
+ case e: FileNotFoundException => throw e
+ case e @ (_: RuntimeException | _: IOException) if
ignoreCorruptFiles =>
+ logWarning(log"Skipped the corrupted input: ${MDC(PATH,
stream.getPath())}", e)
+ Iterator.empty
+ case NonFatal(e) =>
+ throw QueryExecutionErrors.cannotReadFilesError(
+ e, SparkPath.fromPathString(stream.getPath()).urlEncoded)
+ }
+ }
+
+ SQLExecution.withSQLConfPropagated(sparkSession) {
+ val inferSchema = new JsonInferSchema(parsedOptions)
+ if (multiLine) {
+ // Each input/entry is one JSON document: hand its stream straight to
the parser
+ // (`CreateJacksonParser.inputStream`, matching
MultiLineJsonDataSource and its charset
+ // auto-detect) so the document is parsed incrementally rather than
buffered.
+ val docs = perInput(in => Iterator.single(in))
+ val docParser: (JsonFactory, InputStream) => JsonParser = encoding
+ .map(enc => CreateJacksonParser.inputStream(enc, _: JsonFactory, _:
InputStream))
+ .getOrElse(CreateJacksonParser.inputStream(_: JsonFactory, _:
InputStream))
+ inferSchema.infer[InputStream](
+ JsonUtils.sample(docs, parsedOptions), docParser, isReadFile = true)
+ } else {
+ // Line-delimited: each line is a record, copied off the reused line
buffer and parsed from
+ // its bytes (`CreateJacksonParser.bytes`, matching
TextInputJsonDataSource).
+ val lines = perInput(in => ArchiveReader.lineIterator(in,
lineSeparator).map(_.copyBytes()))
+ val lineParser: (JsonFactory, Array[Byte]) => JsonParser = encoding
+ .map(enc => CreateJacksonParser.bytes(enc, _: JsonFactory, _:
Array[Byte]))
+ .getOrElse(CreateJacksonParser.bytes(_: JsonFactory, _: Array[Byte]))
+ inferSchema.infer[Array[Byte]](
+ JsonUtils.sample(lines, parsedOptions), lineParser, isReadFile =
false)
+ }
+ }
+ }
}
object JsonDataSource {
@@ -85,6 +217,31 @@ object JsonDataSource {
TextInputJsonDataSource
}
}
+
+ /**
+ * One `PortableDataStream` per input file (the whole file, never split),
shared by the multiLine
+ * schema-inference path and the archive inference path.
+ */
+ private[json] def createBaseRdd(
+ sparkSession: SparkSession,
+ inputPaths: Seq[FileStatus],
+ parsedOptions: JSONOptions): RDD[PortableDataStream] = {
+ val paths = inputPaths.map(_.getPath)
+ val job =
Job.getInstance(sparkSession.sessionState.newHadoopConfWithOptions(
+ parsedOptions.parameters))
+ val conf = job.getConfiguration
+ val name = paths.mkString(",")
+ FileInputFormat.setInputPaths(job, paths: _*)
+ new BinaryFileRDD(
+ sparkSession.sparkContext,
+ classOf[StreamInputFormat],
+ classOf[String],
+ classOf[PortableDataStream],
+ conf,
+ sparkSession.sparkContext.defaultMinPartitions)
+ .setName(s"JsonFile: $name")
+ .values
+ }
}
object TextInputJsonDataSource extends JsonDataSource {
@@ -149,6 +306,22 @@ object TextInputJsonDataSource extends JsonDataSource {
linesReader.flatMap(safeParser.parse)
}
+ override protected def readStream(
+ in: InputStream,
+ parser: JacksonParser,
+ schema: StructType): Iterator[InternalRow] = {
+ val textParser = parser.options.encoding
+ .map(enc => CreateJacksonParser.text(enc, _: JsonFactory, _: Text))
+ .getOrElse(CreateJacksonParser.text(_: JsonFactory, _: Text))
+
+ val safeParser = new FailureSafeParser[Text](
+ input => parser.parse(input, textParser, textToUTF8String),
+ parser.options.parseMode,
+ schema,
+ parser.options.columnNameOfCorruptRecord)
+ ArchiveReader.lineIterator(in,
parser.options.lineSeparatorInRead).flatMap(safeParser.parse)
+ }
+
private def textToUTF8String(value: Text): UTF8String = {
UTF8String.fromBytes(value.getBytes, 0, value.getLength)
}
@@ -163,7 +336,8 @@ object MultiLineJsonDataSource extends JsonDataSource {
sparkSession: SparkSession,
inputPaths: Seq[FileStatus],
parsedOptions: JSONOptions): StructType = {
- val json: RDD[PortableDataStream] = createBaseRdd(sparkSession,
inputPaths, parsedOptions)
+ val json: RDD[PortableDataStream] =
+ JsonDataSource.createBaseRdd(sparkSession, inputPaths, parsedOptions)
val sampled: RDD[PortableDataStream] = JsonUtils.sample(json,
parsedOptions)
val parser = parsedOptions.encoding
.map(enc => createParser(enc, _: JsonFactory, _: PortableDataStream))
@@ -175,27 +349,6 @@ object MultiLineJsonDataSource extends JsonDataSource {
}
}
- private def createBaseRdd(
- sparkSession: SparkSession,
- inputPaths: Seq[FileStatus],
- parsedOptions: JSONOptions): RDD[PortableDataStream] = {
- val paths = inputPaths.map(_.getPath)
- val job =
Job.getInstance(sparkSession.sessionState.newHadoopConfWithOptions(
- parsedOptions.parameters))
- val conf = job.getConfiguration
- val name = paths.mkString(",")
- FileInputFormat.setInputPaths(job, paths: _*)
- new BinaryFileRDD(
- sparkSession.sparkContext,
- classOf[StreamInputFormat],
- classOf[String],
- classOf[PortableDataStream],
- conf,
- sparkSession.sparkContext.defaultMinPartitions)
- .setName(s"JsonFile: $name")
- .values
- }
-
private def dataToInputStream(dataStream: PortableDataStream): InputStream =
{
val path = new Path(dataStream.getPath())
CodecStreams.createInputStreamWithCloseResource(dataStream.getConfiguration,
path)
@@ -237,4 +390,24 @@ object MultiLineJsonDataSource extends JsonDataSource {
safeParser.parse(
CodecStreams.createInputStreamWithCloseResource(conf, file.toPath))
}
+
+ override protected def readStream(
+ in: InputStream,
+ parser: JacksonParser,
+ schema: StructType): Iterator[InternalRow] = {
+ // The entry is a single JSON document. Buffer its bytes so the
corrupt-record column can echo
+ // the whole document on a parse failure, mirroring `readFile`'s
`partitionedFileString`.
+ val bytes = in.readAllBytes()
+ val streamParser = parser.options.encoding
+ .map(enc => CreateJacksonParser.inputStream(enc, _: JsonFactory, _:
InputStream))
+ .getOrElse(CreateJacksonParser.inputStream(_: JsonFactory, _:
InputStream))
+
+ val safeParser = new FailureSafeParser[InputStream](
+ input => parser.parse[InputStream](input, streamParser, _ =>
UTF8String.fromBytes(bytes)),
+ parser.options.parseMode,
+ schema,
+ parser.options.columnNameOfCorruptRecord)
+
+ safeParser.parse(new ByteArrayInputStream(bytes))
+ }
}
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/json/JsonFileFormat.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/json/JsonFileFormat.scala
index 331230c4446b..2ddce6286729 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/json/JsonFileFormat.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/json/JsonFileFormat.scala
@@ -40,6 +40,10 @@ case class JsonFileFormat() extends TextBasedFileFormat with
DataSourceRegister
options: Map[String, String],
path: Path): Boolean = {
val parsedOptions = getJsonOptions(sparkSession, options)
+ if (parsedOptions.archiveFormatEnabled &&
ArchiveReader.isArchivePath(path)) {
+ // A tar archive is read as one sequential stream (entry by entry), so
it is never split.
+ return false
+ }
JsonDataSource(parsedOptions).isSplitable &&
super.isSplitable(sparkSession, options, path)
}
@@ -48,7 +52,8 @@ case class JsonFileFormat() extends TextBasedFileFormat with
DataSourceRegister
options: Map[String, String],
files: Seq[FileStatus]): Option[StructType] = {
val parsedOptions = getJsonOptions(sparkSession, options)
- JsonDataSource(parsedOptions).inferSchema(sparkSession, files,
parsedOptions)
+ JsonDataSource(parsedOptions)
+ .inferSchema(sparkSession, files, parsedOptions, supportsArchiveScan =
true)
}
override def prepareWrite(
@@ -102,11 +107,16 @@ case class JsonFileFormat() extends TextBasedFileFormat
with DataSourceRegister
parsedOptions,
allowArrayAsStructs = true,
filters)
- JsonDataSource(parsedOptions).readFile(
- broadcastedHadoopConf.value.value,
- file,
- parser,
- requiredSchema)
+ if (parsedOptions.archiveFormatEnabled &&
ArchiveReader.isArchivePath(file.toPath)) {
+ JsonDataSource(parsedOptions).readArchive(
+ broadcastedHadoopConf.value.value, file, parser, requiredSchema)
+ } else {
+ JsonDataSource(parsedOptions).readFile(
+ broadcastedHadoopConf.value.value,
+ file,
+ parser,
+ requiredSchema)
+ }
}
}
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/json/JsonUtils.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/json/JsonUtils.scala
index 43fc69a52286..6a68a8d03525 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/json/JsonUtils.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/json/JsonUtils.scala
@@ -18,7 +18,6 @@
package org.apache.spark.sql.execution.datasources.json
import org.apache.spark.SparkException
-import org.apache.spark.input.PortableDataStream
import org.apache.spark.rdd.RDD
import
org.apache.spark.sql.catalyst.analysis.TypeCheckResult.{DataTypeMismatch,
TypeCheckSuccess}
import org.apache.spark.sql.catalyst.expressions.ExprUtils
@@ -42,9 +41,11 @@ object JsonUtils {
}
/**
- * Sample JSON RDD as configured by `samplingRatio`.
+ * Sample a JSON record RDD as configured by `samplingRatio`. Generic over
the record type so the
+ * multiLine path (`RDD[PortableDataStream]`) and the archive inference
paths (`RDD[InputStream]`
+ * for multi-line, `RDD[Array[Byte]]` for line-delimited) share one sampler.
*/
- def sample(json: RDD[PortableDataStream], options: JSONOptions):
RDD[PortableDataStream] = {
+ def sample[T](json: RDD[T], options: JSONOptions): RDD[T] = {
require(options.samplingRatio > 0,
s"samplingRatio (${options.samplingRatio}) should be greater than 0")
if (options.samplingRatio > 0.99) {
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/json/JsonTable.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/json/JsonTable.scala
index dc3729a119b1..2edc5702e0b8 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/json/JsonTable.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/json/JsonTable.scala
@@ -45,8 +45,10 @@ case class JsonTable(
options.asScala.toMap,
sparkSession.sessionState.conf.sessionLocalTimeZone,
sparkSession.sessionState.conf.columnNameOfCorruptRecord)
+ // The DSv2 reader calls `readFile` directly and cannot read archives, so
refuse to infer a
+ // schema for archive inputs (supportsArchiveScan = false) rather than
mis-reading raw bytes.
JsonDataSource(parsedOptions).inferSchema(
- sparkSession, files, parsedOptions)
+ sparkSession, files, parsedOptions, supportsArchiveScan = false)
}
override def newWriteBuilder(info: LogicalWriteInfo): WriteBuilder = {
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/ArchiveReadSuiteBase.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/ArchiveReadSuiteBase.scala
index 903adb9f98bb..b9bec4fd756a 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/ArchiveReadSuiteBase.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/ArchiveReadSuiteBase.scala
@@ -22,8 +22,10 @@ import java.nio.file.Files
import org.apache.spark.{SparkConf, SparkException}
import org.apache.spark.sql.{DataFrame, QueryTest, Row}
+import org.apache.spark.sql.functions.{col, struct}
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types.{StringType, StructType}
import org.apache.spark.util.Utils
/**
@@ -104,6 +106,51 @@ trait ArchiveReadSuiteBase extends QueryTest with
SharedSparkSession {
schema: String = readSchema): DataFrame =
spark.read.format(format).options(readOptions ++
extraOptions).schema(schema).load(path)
+ /**
+ * Whether this format infers its read schema from the textual content of
the files (CSV, JSON,
+ * XML), as opposed to carrying an embedded schema (Avro, Parquet) or none
(text). Gates the
+ * shared schema-inference tests. A format that needs an option to trigger
inference (e.g. CSV's
+ * `inferSchema`) also overrides [[inferenceOptions]]; a format that does
not infer overrides this
+ * to false.
+ */
+ protected def supportsSchemaInference: Boolean = true
+
+ /** Extra options that trigger/control inference for
[[supportsSchemaInference]] formats. */
+ protected def inferenceOptions: Map[String, String] = Map.empty
+
+ /**
+ * Whether this format can represent nested/complex types
(struct/array/map). Gates the shared
+ * complex-type round-trip test; JSON keeps it, CSV and text override it to
false.
+ */
+ protected def supportsComplexTypes: Boolean = true
+
+ /**
+ * Whether inputs with different field sets are unioned by field name -- the
self-describing,
+ * field-name-keyed model of JSON (and Avro/Parquet/XML), as opposed to
CSV's positional,
+ * first-header-keyed model. Gates the shared differing-field read/inference
tests; CSV and text
+ * override it to false.
+ */
+ protected def supportsSchemaMerge: Boolean = true
+
+ /** Sample data with a nested struct column, used by the complex-type test.
*/
+ protected def complexSampleDf: DataFrame =
+ Seq((1, "NYC", "10001"), (2, "SF", "94105")).toDF("id", "city", "zip")
+ .select(col("id"), struct(col("city"), col("zip")).as("addr"))
+
+ /** Read schema matching [[complexSampleDf]]. */
+ protected def complexReadSchema: String = "id INT, addr STRUCT<city: STRING,
zip: STRING>"
+
+ /**
+ * Schema [[format]] infers from `paths` under [[readOptions]] ++
[[inferenceOptions]] (plus
+ * `extraOptions`). Loading several paths reads them as one fileset, exactly
as a directory
+ * read does.
+ */
+ protected def inferredSchema(
+ paths: Seq[String],
+ extraOptions: Map[String, String] = Map.empty): StructType =
+ spark.read.options(readOptions ++ inferenceOptions ++ extraOptions)
+ .format(format).load(paths: _*).schema
+
/**
* Writes `entries` both into an archive and as loose files in a directory,
then asserts the
* archive read produces exactly the same rows as the directory read.
@@ -231,4 +278,128 @@ trait ArchiveReadSuiteBase extends QueryTest with
SharedSparkSession {
}
}
}
+
+ // ----- shared schema-inference tests (run when `supportsSchemaInference`)
--
+
+ if (supportsSchemaInference) {
+ test("archive infers the same schema as a directory of the same files") {
+ val entries = Seq(sampleDf((1, "Alice"), (2, "Bob")), sampleDf((3,
"Carol")))
+ .zipWithIndex.map { case (p, i) => entryName(i) -> encodeFile(p) }
+ withArchiveFile() { archive =>
+ writeArchive(archive, entries)
+ val archiveSchema = inferredSchema(Seq(archive.getCanonicalPath))
+ withTempDir { dir =>
+ entries.foreach { case (n, b) => Files.write(new File(dir,
n).toPath, b) }
+ assert(archiveSchema == inferredSchema(Seq(dir.getCanonicalPath)),
+ s"inference parity broken; archive=$archiveSchema")
+ }
+ }
+ }
+
+ test("all archive formats infer the same schema") {
+ val entries = Seq(sampleDf((1, "Alice"), (2, "Bob")), sampleDf((3,
"Carol")))
+ .zipWithIndex.map { case (p, i) => entryName(i) -> encodeFile(p) }
+ val schemas = archiveExtensions.map { ext =>
+ withArchiveFile(ext) { archive =>
+ writeArchive(archive, entries)
+ inferredSchema(Seq(archive.getCanonicalPath))
+ }
+ }
+ assert(schemas.distinct.size == 1,
+ s"archive formats inferred different schemas:
${archiveExtensions.zip(schemas)}")
+ }
+
+ test("inference skips a corrupt archive among good ones
(ignoreCorruptFiles)") {
+ withTempDir { dir =>
+ val good = sampleDf((1, "Alice"), (2, "Bob"))
+ writeArchive(new File(dir, s"good.${archiveExtensions.head}"),
+ Seq(entryName(0) -> encodeFile(good)))
+ writeCorruptArchive(new File(dir, s"bad.$corruptArchiveExtension"))
+ withSQLConf(SQLConf.IGNORE_CORRUPT_FILES.key -> "true") {
+ val schema = inferredSchema(Seq(dir.getCanonicalPath))
+ withTempDir { onlyGood =>
+ Files.write(new File(onlyGood, entryName(0)).toPath,
encodeFile(good))
+ assert(schema == inferredSchema(Seq(onlyGood.getCanonicalPath)),
+ s"corrupt archive not skipped during inference; got $schema")
+ }
+ }
+ }
+ }
+
+ test("archive inference widens a column's type across entries like a
directory") {
+ // The column is integral in the first entry and string in the second;
inference over all
+ // entries widens the merged type to string, exactly as a directory read
would.
+ withArchiveFile() { archive =>
+ writeArchive(archive, Seq(
+ entryName(0) -> encodeFile(Seq(1, 2).toDF("c")),
+ entryName(1) -> encodeFile(Seq("x").toDF("c"))))
+ val schema = inferredSchema(Seq(archive.getCanonicalPath))
+ assert(schema.length == 1 && schema.head.dataType == StringType,
+ s"expected the column widened to string across entries, got $schema")
+ }
+ }
+
+ test("inference merges archive entries with loose files in the same
directory") {
+ // An archive entry and a loose file with the same schema infer one
combined schema, matching
+ // a directory read of the same files.
+ val inArchive = sampleDf((1, "Alice"), (2, "Bob"))
+ val loose = sampleDf((3, "Carol"))
+ withTempDir { dir =>
+ writeArchive(new File(dir, s"data.${archiveExtensions.head}"),
+ Seq(entryName(0) -> encodeFile(inArchive)))
+ Files.write(new File(dir, s"loose.$fileExtension").toPath,
encodeFile(loose))
+ val schema = inferredSchema(Seq(dir.getCanonicalPath))
+ withTempDir { looseDir =>
+ Files.write(new File(looseDir, entryName(0)).toPath,
encodeFile(inArchive))
+ Files.write(new File(looseDir, s"loose.$fileExtension").toPath,
encodeFile(loose))
+ assert(schema == inferredSchema(Seq(looseDir.getCanonicalPath)),
+ s"mixed archive+loose inference diverged from a directory read;
got $schema")
+ }
+ }
+ }
+ }
+
+ // ----- shared schema-merge tests (run when `supportsSchemaMerge`)
----------
+
+ if (supportsSchemaMerge) {
+ test("archive entries with differing fields read like a directory") {
+ // One entry carries an extra field the other lacks; read under a schema
covering both, the
+ // missing field reads back null -- exactly as a directory read of the
same files does.
+ val withName = sampleDf((1, "Alice"), (2, "Bob"))
+ val idOnly = Seq(3).toDF("id")
+ assertArchiveMatchesDir(
+ Seq(entryName(0) -> encodeFile(withName), entryName(1) ->
encodeFile(idOnly)))
+ }
+
+ test("inference unions differing fields across archive entries and loose
files") {
+ // The archive entry has fields (id, name); the loose file has (id,
extra). A field-name-keyed
+ // format unions them by name -- exactly as a directory read of the same
files does.
+ val inArchive = sampleDf((1, "Alice"), (2, "Bob"))
+ val loose = Seq((3, 30)).toDF("id", "extra")
+ withTempDir { dir =>
+ writeArchive(new File(dir, s"data.${archiveExtensions.head}"),
+ Seq(entryName(0) -> encodeFile(inArchive)))
+ Files.write(new File(dir, s"loose.$fileExtension").toPath,
encodeFile(loose))
+ val schema = inferredSchema(Seq(dir.getCanonicalPath))
+ assert(schema.fieldNames.toSet == Set("id", "name", "extra"),
+ s"expected the union of the entry and loose fields, got $schema")
+ withTempDir { looseDir =>
+ Files.write(new File(looseDir, entryName(0)).toPath,
encodeFile(inArchive))
+ Files.write(new File(looseDir, s"loose.$fileExtension").toPath,
encodeFile(loose))
+ assert(schema == inferredSchema(Seq(looseDir.getCanonicalPath)),
+ s"differing-field inference diverged from a directory read; got
$schema")
+ }
+ }
+ }
+ }
+
+ // ----- shared complex-type test (run when `supportsComplexTypes`)
----------
+
+ if (supportsComplexTypes) {
+ test("archive round-trips nested struct types like a directory") {
+ assertArchiveMatchesDir(
+ Seq(entryName(0) -> encodeFile(complexSampleDf)),
+ schema = complexReadSchema)
+ }
+ }
}
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/CSVArchiveReadBase.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/CSVArchiveReadBase.scala
index e8076cfb9427..a18d950fb0f0 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/CSVArchiveReadBase.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/CSVArchiveReadBase.scala
@@ -44,6 +44,15 @@ trait CSVArchiveReadBase extends ArchiveReadSuiteBase {
override protected def readSchema: String = "id INT, name STRING"
+ // CSV infers its schema from row content (supportsSchemaInference defaults
true); inference is
+ // triggered by the `inferSchema` option. CSV is positional/header-keyed and
cannot represent
+ // nested types, so it opts out of the schema-merge and complex-type tests.
+ override protected def inferenceOptions: Map[String, String] =
Map("inferSchema" -> "true")
+
+ override protected def supportsComplexTypes: Boolean = false
+
+ override protected def supportsSchemaMerge: Boolean = false
+
override protected def encodeFile(
df: DataFrame,
writeOptions: Map[String, String]): Array[Byte] = {
@@ -65,93 +74,10 @@ trait CSVArchiveReadBase extends ArchiveReadSuiteBase {
/** Raw CSV bytes, for tests that need precise control over the row layout.
*/
protected def csvBytes(s: String): Array[Byte] =
s.getBytes(StandardCharsets.UTF_8)
- test("CSV: archive infers the same schema as a directory of the same files")
{
- val entries = Seq(sampleDf((1, "Alice"), (2, "Bob")), sampleDf((3,
"Carol")))
- .zipWithIndex.map { case (p, i) => entryName(i) -> encodeFile(p) }
- withArchiveFile() { archive =>
- writeArchive(archive, entries)
- val archiveSchema =
spark.read.options(readOptions).option("inferSchema", "true")
- .format(format).load(archive.getCanonicalPath).schema
- withTempDir { dir =>
- entries.foreach { case (n, b) => Files.write(new File(dir, n).toPath,
b) }
- val dirSchema = spark.read.options(readOptions).option("inferSchema",
"true")
- .format(format).load(dir.getCanonicalPath).schema
- assert(archiveSchema == dirSchema,
- s"inference parity broken; archive=$archiveSchema dir=$dirSchema")
- }
- }
- }
-
- test("CSV: all archive formats infer the same schema") {
- val entries = Seq(sampleDf((1, "Alice"), (2, "Bob")), sampleDf((3,
"Carol")))
- .zipWithIndex.map { case (p, i) => entryName(i) -> encodeFile(p) }
- val schemas = archiveExtensions.map { ext =>
- withArchiveFile(ext) { archive =>
- writeArchive(archive, entries)
- spark.read.options(readOptions).option("inferSchema", "true")
- .format(format).load(archive.getCanonicalPath).schema
- }
- }
- assert(schemas.distinct.size == 1,
- s"archive formats inferred different schemas:
${archiveExtensions.zip(schemas)}")
- }
-
/** CSV bytes for `rows`, prefixed with a `cols` header line when [[header]]
is set. */
private def csvEntry(cols: String, rows: String*): Array[Byte] =
csvBytes((if (header) cols +: rows else rows).mkString("", "\n", "\n"))
- test("CSV: inference skips a corrupt archive among good ones
(ignoreCorruptFiles)") {
- withTempDir { dir =>
- val good = sampleDf((1, "Alice"), (2, "Bob"))
- writeArchive(new File(dir, s"good.${archiveExtensions.head}"),
- Seq(entryName(0) -> encodeFile(good)))
- writeCorruptArchive(new File(dir, s"bad.$corruptArchiveExtension"))
- withSQLConf(SQLConf.IGNORE_CORRUPT_FILES.key -> "true") {
- val schema = spark.read.options(readOptions).option("inferSchema",
"true")
- .format(format).load(dir.getCanonicalPath).schema
- withTempDir { onlyGood =>
- Files.write(new File(onlyGood, entryName(0)).toPath,
encodeFile(good))
- val expected = spark.read.options(readOptions).option("inferSchema",
"true")
- .format(format).load(onlyGood.getCanonicalPath).schema
- assert(schema == expected,
- s"corrupt archive not skipped during inference; got $schema, want
$expected")
- }
- }
- }
- }
-
- test("CSV: inference widens a column's type across archive entries") {
- withArchiveFile() { archive =>
- writeArchive(archive, Seq(
- entryName(0) -> csvEntry("c", "1", "2"),
- entryName(1) -> csvEntry("c", "x")))
- val schema = spark.read.options(readOptions).option("inferSchema",
"true")
- .format(format).load(archive.getCanonicalPath).schema
- assert(schema.length == 1 && schema.head.dataType == StringType,
- s"expected the column widened to string across entries, got $schema")
- }
- }
-
- test("CSV: inference merges archive entries with loose files in the same
directory") {
- withTempDir { dir =>
- val inArchive = sampleDf((1, "Alice"), (2, "Bob"))
- val loose = sampleDf((3, "Carol"))
- writeArchive(new File(dir, s"data.${archiveExtensions.head}"),
- Seq(entryName(0) -> encodeFile(inArchive)))
- Files.write(new File(dir, s"loose.$fileExtension").toPath,
encodeFile(loose))
- val schema = spark.read.options(readOptions).option("inferSchema",
"true")
- .format(format).load(dir.getCanonicalPath).schema
- withTempDir { looseDir =>
- Files.write(new File(looseDir, entryName(0)).toPath,
encodeFile(inArchive))
- Files.write(new File(looseDir, s"loose.$fileExtension").toPath,
encodeFile(loose))
- val expected = spark.read.options(readOptions).option("inferSchema",
"true")
- .format(format).load(looseDir.getCanonicalPath).schema
- assert(schema == expected,
- s"mixed archive+loose inference diverged from directory; got
$schema, want $expected")
- }
- }
- }
-
test("CSV: a column empty in the archive but typed in a loose file is not
collapsed to string") {
// One inference pass over all inputs keeps the empty column NullType
until the end, so it
// widens with the loose file's Int. Merging two already-finished schemas
would have collapsed
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/JSONArchiveReadBase.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/JSONArchiveReadBase.scala
new file mode 100644
index 000000000000..fdad5df4b12d
--- /dev/null
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/JSONArchiveReadBase.scala
@@ -0,0 +1,223 @@
+/*
+ * 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
+
+import java.io.File
+import java.nio.charset.StandardCharsets
+import java.nio.file.Files
+
+import org.apache.spark.sql.{AnalysisException, DataFrame}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types.{NullType, StringType}
+import org.apache.spark.util.Utils
+
+/**
+ * Binds [[ArchiveReadSuiteBase]]'s file-format hooks to JSON. JSON opts into
the shared
+ * schema-inference and complex-type tests (see
`supportsSchemaInference`/`supportsComplexTypes`),
+ * and adds the JSON-specific tests with no format-agnostic analogue: NullType
canonicalization,
+ * field-union/null-in-loose merging, and multi-line documents. Reusable
across archive formats: a
+ * `JSON<Container>ArchiveReadSuite` mixes this in alongside the
archive-format trait.
+ */
+trait JSONArchiveReadBase extends ArchiveReadSuiteBase {
+
+ override protected def format: String = "json"
+
+ override protected def fileExtension: String = "json"
+
+ // JSON records are self-describing, so no per-file read options are
required.
+ override protected def readOptions: Map[String, String] = Map.empty
+
+ override protected def readSchema: String = "id INT, name STRING"
+
+ override protected def encodeFile(
+ df: DataFrame,
+ writeOptions: Map[String, String]): Array[Byte] = {
+ val dir = Utils.createTempDir(namePrefix = "archive-test-encode")
+ try {
+ df.coalesce(1).write.format("json")
+ .options(writeOptions)
+ .mode("overwrite").save(dir.getCanonicalPath)
+ val parts = dir.listFiles().filter { f =>
+ f.isFile && !f.getName.startsWith("_") && !f.getName.startsWith(".") &&
+ !f.getName.endsWith(".crc")
+ }
+ assert(parts.length == 1,
+ s"expected exactly one data file, got: ${parts.map(_.getName).toList}")
+ Files.readAllBytes(parts.head.toPath)
+ } finally Utils.deleteRecursively(dir)
+ }
+
+ /** Raw JSON bytes, for tests that need precise control over the record
layout. */
+ protected def jsonBytes(s: String): Array[Byte] =
s.getBytes(StandardCharsets.UTF_8)
+
+ test("JSON: a column null across all archive entries infers as string") {
+ // Field `v` is null in every record across both entries, so each
per-record type is NullType.
+ // The single inference pass canonicalizes the surviving NullType to
StringType at the end (a
+ // valid schema, no NullType), matching a directory read of the same files.
+ val entries = Seq(
+ entryName(0) ->
jsonBytes("{\"k\":1,\"v\":null}\n{\"k\":2,\"v\":null}\n"),
+ entryName(1) -> jsonBytes("{\"k\":3,\"v\":null}\n"))
+ withArchiveFile() { archive =>
+ writeArchive(archive, entries)
+ val archiveSchema = inferredSchema(Seq(archive.getCanonicalPath))
+ assert(archiveSchema.forall(_.dataType != NullType),
+ s"expected no NullType columns after canonicalization, got
$archiveSchema")
+ assert(archiveSchema.find(_.name == "v").exists(_.dataType ==
StringType),
+ s"expected the all-null column to canonicalize to string, got
$archiveSchema")
+ withTempDir { dir =>
+ entries.foreach { case (n, b) => Files.write(new File(dir, n).toPath,
b) }
+ val dirSchema = inferredSchema(Seq(dir.getCanonicalPath))
+ assert(archiveSchema == dirSchema,
+ s"all-null column inference diverged from a directory; " +
+ s"archive=$archiveSchema dir=$dirSchema")
+ }
+ }
+ }
+
+ test("JSON: multiline inference merges archive entries with loose files") {
+ // multiLine + mixed inputs: inference reads every archive entry and loose
file as one whole
+ // document and infers a single schema, matching a directory read of the
same files.
+ val opts = Map("multiLine" -> "true")
+ withTempDir { dir =>
+ writeArchive(new File(dir, s"data.${archiveExtensions.head}"),
+ Seq(entryName(0) -> jsonBytes("{\n \"id\": 1,\n \"name\":
\"Alice\"\n}")))
+ Files.write(new File(dir, s"loose.$fileExtension").toPath,
+ jsonBytes("{\n \"id\": 2,\n \"age\": 30\n}"))
+ val schema = inferredSchema(Seq(dir.getCanonicalPath), opts)
+ withTempDir { looseDir =>
+ Files.write(new File(looseDir, entryName(0)).toPath,
+ jsonBytes("{\n \"id\": 1,\n \"name\": \"Alice\"\n}"))
+ Files.write(new File(looseDir, s"loose.$fileExtension").toPath,
+ jsonBytes("{\n \"id\": 2,\n \"age\": 30\n}"))
+ val dirSchema = inferredSchema(Seq(looseDir.getCanonicalPath), opts)
+ assert(schema == dirSchema,
+ s"multiline mixed inference diverged from a directory read; got
$schema want $dirSchema")
+ }
+ }
+ }
+
+ test("JSON: a field typed in the archive but null in a loose file is not
collapsed to string") {
+ // One inference pass over all inputs keeps the loose file's null `v` as
NullType until the end,
+ // so it widens with the archive's int. Inferring the loose file alone and
merging two finished
+ // schemas would have canonicalized `v` to string first and yielded string
here -- diverging
+ // from a directory read of the same files.
+ val archived = jsonBytes("{\"k\":1,\"v\":10}\n{\"k\":2,\"v\":20}\n")
+ val loose = jsonBytes("{\"k\":3,\"v\":null}\n")
+ withTempDir { dir =>
+ writeArchive(new File(dir, s"data.${archiveExtensions.head}"),
Seq(entryName(0) -> archived))
+ Files.write(new File(dir, s"loose.$fileExtension").toPath, loose)
+ val schema = inferredSchema(Seq(dir.getCanonicalPath))
+ withTempDir { looseDir =>
+ Files.write(new File(looseDir, entryName(0)).toPath, archived)
+ Files.write(new File(looseDir, s"loose.$fileExtension").toPath, loose)
+ assert(schema == inferredSchema(Seq(looseDir.getCanonicalPath)),
+ s"null-in-loose field diverged from a directory read; got $schema")
+ }
+ assert(schema.find(_.name == "v").exists(_.dataType != StringType),
+ s"field null in the loose file should widen with the archive int, not
collapse: $schema")
+ }
+ }
+
+ test("JSON: archive inference honors the encoding option like a directory") {
+ // A non-UTF-8 (UTF-16) archive: inference must decode each entry with
`encoding`, exactly as
+ // the scan and a directory read do. Reading the bytes as UTF-8 would
mis-parse them, diverging
+ // from a directory read of the same files. multiLine makes the whole
document one record, so
+ // line-separator handling does not enter the test.
+ val opts = Map("encoding" -> "UTF-16", "multiLine" -> "true")
+ val bytes = "{\n \"id\": 1,\n \"name\":
\"Alice\"\n}".getBytes(StandardCharsets.UTF_16)
+ withTempDir { dir =>
+ writeArchive(new File(dir, s"data.${archiveExtensions.head}"),
Seq(entryName(0) -> bytes))
+ val schema = inferredSchema(Seq(dir.getCanonicalPath), opts)
+ withTempDir { looseDir =>
+ Files.write(new File(looseDir, entryName(0)).toPath, bytes)
+ val dirSchema = inferredSchema(Seq(looseDir.getCanonicalPath), opts)
+ assert(schema == dirSchema,
+ s"encoding inference diverged from a directory read; archive=$schema
dir=$dirSchema")
+ }
+ assert(schema.fieldNames.toSet == Set("id", "name"),
+ s"expected id/name decoded from UTF-16, got $schema")
+ }
+ }
+
+ test("JSON: archive inference auto-detects a non-UTF-8 charset with no
encoding option") {
+ // With no `encoding` set, inference parses each record from its raw
bytes, so Jackson
+ // auto-detects the charset (here UTF-16, carrying a BOM) exactly as the
scan and a directory
+ // read do. Forcing the bytes through UTF-8 would mis-parse them into a
corrupt-record-only
+ // schema, diverging from a directory read of the same files.
+ val opts = Map("multiLine" -> "true")
+ val bytes = "{\n \"id\": 1,\n \"name\":
\"Alice\"\n}".getBytes(StandardCharsets.UTF_16)
+ withTempDir { dir =>
+ writeArchive(new File(dir, s"data.${archiveExtensions.head}"),
Seq(entryName(0) -> bytes))
+ val schema = inferredSchema(Seq(dir.getCanonicalPath), opts)
+ withTempDir { looseDir =>
+ Files.write(new File(looseDir, entryName(0)).toPath, bytes)
+ val dirSchema = inferredSchema(Seq(looseDir.getCanonicalPath), opts)
+ assert(schema == dirSchema,
+ s"auto-detected inference diverged from a directory read;
archive=$schema dir=$dirSchema")
+ }
+ assert(schema.fieldNames.toSet == Set("id", "name"),
+ s"expected id/name auto-detected from UTF-16, got $schema")
+ }
+ }
+
+ // ----- JSON-specific read tests
--------------------------------------------
+
+ test("JSON: multi-line documents match a directory read") {
+ assertArchiveMatchesDir(
+ Seq(
+ "a.json" -> jsonBytes("{\n \"id\": 1,\n \"name\": \"Alice\"\n}"),
+ "b.json" -> jsonBytes("{\n \"id\": 2,\n \"name\": \"Bob\"\n}")),
+ extraOptions = Map("multiLine" -> "true"))
+ }
+
+ test("JSON: a malformed record in an archive entry matches a directory read
(both modes)") {
+ // Permissive mode (the default): a malformed record parses to nulls with
its raw text echoed
+ // into `_corrupt_record`. The archive path wires its own
FailureSafeParser in `readStream` --
+ // and in multiLine it echoes the entry's pre-buffered bytes rather than
re-reading the file --
+ // so assert the corrupt-record column matches a directory read of the
same files in both the
+ // line-delimited and whole-document modes.
+ val corruptSchema = s"$readSchema, _corrupt_record STRING"
+ // Line-delimited: a good record, then a malformed one on the next line.
+ assertArchiveMatchesDir(
+ Seq("a.json" -> jsonBytes("{\"id\":1,\"name\":\"Alice\"}\n{ not valid
json\n")),
+ schema = corruptSchema)
+ // multiLine: the whole entry is one malformed document.
+ assertArchiveMatchesDir(
+ Seq("a.json" -> jsonBytes("{ not valid json")),
+ extraOptions = Map("multiLine" -> "true"),
+ schema = corruptSchema)
+ }
+
+ test("JSON: the DSv2 path refuses to infer a schema for an archive
(UNABLE_TO_INFER_SCHEMA)") {
+ // Archive scanning is wired into the v1 file source only, so the DSv2
reader cannot read
+ // archives. On the v2 path inference must keep returning None for an
archive input -- raising
+ // UNABLE_TO_INFER_SCHEMA -- rather than inferring a schema the v2 scan
would then mis-read as
+ // raw archive bytes. Forcing json off the v1 source list routes the read
through JsonTable.
+ withArchiveFile() { archive =>
+ writeArchive(archive, Seq(entryName(0) -> encodeFile(sampleDf((1,
"Alice"), (2, "Bob")))))
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
+ val e = intercept[AnalysisException] {
+
spark.read.options(readOptions).format(format).load(archive.getCanonicalPath)
+ }
+ assert(e.getCondition == "UNABLE_TO_INFER_SCHEMA",
+ s"expected UNABLE_TO_INFER_SCHEMA on the DSv2 path, got " +
+ s"${e.getCondition}: ${e.getMessage}")
+ }
+ }
+ }
+}
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/JSONTarArchiveReadSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/JSONTarArchiveReadSuite.scala
new file mode 100644
index 000000000000..5a703a321c59
--- /dev/null
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/JSONTarArchiveReadSuite.scala
@@ -0,0 +1,28 @@
+/*
+ * 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
+
+/**
+ * Reads of JSON files packed in tar archives (`.tar`/`.tar.gz`/`.tgz`): the
shared archive tests
+ * from [[ArchiveReadSuiteBase]] plus the JSON-specific ones from
[[JSONArchiveReadBase]], run over
+ * tar containers via [[TarArchiveReadBase]].
+ */
+class JSONTarArchiveReadSuite
+ extends ArchiveReadSuiteBase
+ with JSONArchiveReadBase
+ with TarArchiveReadBase
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]