From 57c6bc840e3688b723fd0d38887733947a1ef25e Mon Sep 17 00:00:00 2001 From: Victor Grigoriu Date: Mon, 7 Sep 2026 13:16:17 +0300 Subject: [PATCH] fix: support reading and writing JSON columns --- .../java/org/lance/spark/JsonColumnTest.java | 16 + .../java/org/lance/spark/JsonColumnTest.java | 16 + .../java/org/lance/spark/utils/JsonUtils.java | 135 +++++++ .../spark/sql/util/LanceArrowUtils.scala | 25 +- .../org/lance/spark/BaseJsonColumnTest.java | 339 ++++++++++++++++++ .../org/lance/spark/utils/JsonUtilsTest.java | 176 +++++++++ 6 files changed, 704 insertions(+), 3 deletions(-) create mode 100644 lance-spark-3.4_2.12/src/test/java/org/lance/spark/JsonColumnTest.java create mode 100644 lance-spark-3.5_2.12/src/test/java/org/lance/spark/JsonColumnTest.java create mode 100644 lance-spark-base_2.12/src/main/java/org/lance/spark/utils/JsonUtils.java create mode 100644 lance-spark-base_2.12/src/test/java/org/lance/spark/BaseJsonColumnTest.java create mode 100644 lance-spark-base_2.12/src/test/java/org/lance/spark/utils/JsonUtilsTest.java diff --git a/lance-spark-3.4_2.12/src/test/java/org/lance/spark/JsonColumnTest.java b/lance-spark-3.4_2.12/src/test/java/org/lance/spark/JsonColumnTest.java new file mode 100644 index 000000000..de57a1472 --- /dev/null +++ b/lance-spark-3.4_2.12/src/test/java/org/lance/spark/JsonColumnTest.java @@ -0,0 +1,16 @@ +/* + * Licensed 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.lance.spark; + +public class JsonColumnTest extends BaseJsonColumnTest {} diff --git a/lance-spark-3.5_2.12/src/test/java/org/lance/spark/JsonColumnTest.java b/lance-spark-3.5_2.12/src/test/java/org/lance/spark/JsonColumnTest.java new file mode 100644 index 000000000..de57a1472 --- /dev/null +++ b/lance-spark-3.5_2.12/src/test/java/org/lance/spark/JsonColumnTest.java @@ -0,0 +1,16 @@ +/* + * Licensed 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.lance.spark; + +public class JsonColumnTest extends BaseJsonColumnTest {} diff --git a/lance-spark-base_2.12/src/main/java/org/lance/spark/utils/JsonUtils.java b/lance-spark-base_2.12/src/main/java/org/lance/spark/utils/JsonUtils.java new file mode 100644 index 000000000..29d07d5c4 --- /dev/null +++ b/lance-spark-base_2.12/src/main/java/org/lance/spark/utils/JsonUtils.java @@ -0,0 +1,135 @@ +/* + * Licensed 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.lance.spark.utils; + +import org.apache.arrow.vector.types.pojo.Field; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.StringType; +import org.apache.spark.sql.types.StructField; + +import java.util.Map; + +/** + * Helpers for Lance JSON columns, which Lance models as an Arrow extension type over UTF-8 storage. + * + *

Spark has no JSON type, so a JSON column surfaces as {@link StringType} carrying the extension + * name in the field metadata — the same approach {@link LargeVarCharUtils} uses for Arrow + * LargeUtf8. The JSON text itself is what Spark reads and writes; Lance encodes it to its internal + * JSONB form on write and decodes on read, so the connector never handles JSONB bytes. + * + *

Two spellings of the extension name are in play, and they are not interchangeable: + * + *

+ * + *

Both are recognized at the Arrow boundary. The connector normalizes either spelling to + * {@link #ARROW_JSON_EXTENSION_NAME} when constructing Spark metadata, so Spark schemas and all + * connector writes use the canonical Arrow name. + */ +public class JsonUtils { + + /** The canonical Arrow extension name, and the only one safe to write. */ + public static final String ARROW_JSON_EXTENSION_NAME = "arrow.json"; + + /** + * The internal spelling lance-core reports when a dataset is opened. Recognized, never written. + */ + public static final String LANCE_JSON_EXTENSION_NAME = "lance.json"; + + private JsonUtils() {} + + /** + * Checks whether an extension name denotes a JSON column, in either spelling. + * + * @param extensionName the value of the {@code ARROW:extension:name} key, may be null + * @return true if the name denotes a JSON column + */ + public static boolean isJsonExtensionName(String extensionName) { + return ARROW_JSON_EXTENSION_NAME.equals(extensionName) + || LANCE_JSON_EXTENSION_NAME.equals(extensionName); + } + + /** + * Checks whether an Arrow field is a JSON column. + * + *

The storage type is deliberately not checked. Lance reports JSON columns as LargeBinary + * because Arrow Java does not register the extension type, while other producers may present Utf8 + * or LargeUtf8; the extension name is the reliable signal in every case. + * + * @param field the Arrow field to check + * @return true if the field is a JSON column + */ + public static boolean hasJsonArrowExtension(Field field) { + if (field == null) { + return false; + } + + Map metadata = field.getMetadata(); + if (metadata == null) { + return false; + } + + return isJsonExtensionName(metadata.get(BlobUtils.ARROW_EXTENSION_NAME_KEY)); + } + + /** + * Checks whether an Arrow field uses Lance's physical JSON representation. + * + * @param field the Arrow field to check + * @return true if the field has the {@code lance.json} extension name + */ + public static boolean isLanceJsonField(Field field) { + if (field == null) { + return false; + } + + Map metadata = field.getMetadata(); + return metadata != null + && LANCE_JSON_EXTENSION_NAME.equals(metadata.get(BlobUtils.ARROW_EXTENSION_NAME_KEY)); + } + + /** + * Checks whether Spark metadata carries a JSON extension marker. + * + * @param metadata the Spark field metadata, may be null + * @return true if the metadata marks a JSON column + */ + public static boolean hasJsonMetadata(Metadata metadata) { + if (metadata == null || !metadata.contains(BlobUtils.ARROW_EXTENSION_NAME_KEY)) { + return false; + } + + return isJsonExtensionName(metadata.getString(BlobUtils.ARROW_EXTENSION_NAME_KEY)); + } + + /** + * Checks whether a Spark field is a JSON column. + * + * @param field the Spark struct field to check + * @return true if the field is a StringType column marked as JSON + */ + public static boolean isJsonSparkField(StructField field) { + if (field == null || !(field.dataType() instanceof StringType)) { + return false; + } + + return hasJsonMetadata(field.metadata()); + } +} diff --git a/lance-spark-base_2.12/src/main/scala/org/apache/spark/sql/util/LanceArrowUtils.scala b/lance-spark-base_2.12/src/main/scala/org/apache/spark/sql/util/LanceArrowUtils.scala index 76cba616c..67ec52227 100644 --- a/lance-spark-base_2.12/src/main/scala/org/apache/spark/sql/util/LanceArrowUtils.scala +++ b/lance-spark-base_2.12/src/main/scala/org/apache/spark/sql/util/LanceArrowUtils.scala @@ -30,7 +30,7 @@ import org.apache.arrow.vector.types.pojo.{ArrowType, Field, FieldType, Schema} import org.apache.spark.{SparkException, SparkUnsupportedOperationException} import org.apache.spark.sql.types._ import org.lance.spark.LanceConstant -import org.lance.spark.utils.{BlobUtils, DateMilliUtils, FixedSizeBinaryUtils, Float16Utils, LargeVarBinaryUtils, LargeVarCharUtils, ListChildUtils, VectorUtils} +import org.lance.spark.utils.{BlobUtils, DateMilliUtils, FixedSizeBinaryUtils, Float16Utils, JsonUtils, LargeVarBinaryUtils, LargeVarCharUtils, ListChildUtils, VectorUtils} import java.util.Locale import java.util.concurrent.atomic.AtomicInteger @@ -47,6 +47,7 @@ object LanceArrowUtils { val ARROW_EXT_NAME_KEY = BlobUtils.ARROW_EXTENSION_NAME_KEY val BLOB_V2_EXT_NAME = BlobUtils.ARROW_EXTENSION_BLOB_V2 val ARROW_LARGE_VAR_CHAR_KEY = LargeVarCharUtils.ARROW_LARGE_VAR_CHAR_KEY + val JSON_EXT_NAME = JsonUtils.ARROW_JSON_EXTENSION_NAME val ARROW_LARGE_VAR_BINARY_KEY = LargeVarBinaryUtils.ARROW_LARGE_VAR_BINARY_KEY val ARROW_DATE_MILLISECOND_KEY = DateMilliUtils.ARROW_DATE_MILLISECOND_KEY val ARROW_FIXED_SIZE_BINARY_BYTE_WIDTH_KEY = @@ -137,6 +138,9 @@ object LanceArrowUtils { // Lance returns LargeBinary in schema but Struct in data for blob columns // We need to handle this as binary to match the schema BinaryType + case _: ArrowType.LargeBinary if JsonUtils.hasJsonArrowExtension(field) => + // Lance stores JSON as LargeBinary JSONB, but Spark reads the decoded values as strings. + StringType case _: ArrowType.LargeUtf8 => // LargeUtf8 maps back to StringType in Spark StringType @@ -243,6 +247,10 @@ object LanceArrowUtils { if (Float16Utils.isFloat16ArrowField(field)) { builder.putString(ARROW_FLOAT16_KEY, Float16Utils.ARROW_FLOAT16_VALUE) } + case _: ArrowType.LargeBinary if JsonUtils.isLanceJsonField(field) => + // Dataset.getSchema exposes Lance's physical JSONB representation. Spark uses the + // logical Arrow extension name for the decoded StringType it presents to callers. + builder.putString(ARROW_EXT_NAME_KEY, JsonUtils.ARROW_JSON_EXTENSION_NAME) case _: ArrowType.LargeUtf8 => builder.putString(ARROW_LARGE_VAR_CHAR_KEY, LargeVarCharUtils.ARROW_LARGE_VAR_CHAR_VALUE) // Spark has a single BinaryType covering both Arrow Binary (32-bit offsets) and LargeBinary @@ -251,8 +259,10 @@ object LanceArrowUtils { // the schema (UPDATE, ADD COLUMNS FROM, or simply read -> transform -> write), and the // resulting write fails type validation against the existing Lance schema. // Blob columns are excluded: they are already LargeBinary-backed via the blob marker, which - // toArrowField honors on its own. - case _: ArrowType.LargeBinary if !isBlobField(field) => + // toArrowField honors on its own. JSON columns are excluded too: they surface as StringType, + // so a binary marker would contradict the Spark type and steer writeback to LargeBinary. + case _: ArrowType.LargeBinary + if !isBlobField(field) && !JsonUtils.hasJsonArrowExtension(field) => builder.putString( ARROW_LARGE_VAR_BINARY_KEY, LargeVarBinaryUtils.ARROW_LARGE_VAR_BINARY_VALUE) @@ -515,6 +525,15 @@ object LanceArrowUtils { toArrowField("uri", StringType, nullable = true, timeZoneId), arrowUInt64Field("position"), arrowUInt64Field("size")).asJava) + case _: StringType if JsonUtils.hasJsonMetadata(metadata) => + // Lance JSON column. Two things matter here. First, the storage must be UTF-8: Lance + // encodes the JSON text to its internal JSONB form itself, and rejects a LargeBinary + // array of pre-encoded bytes. The extension name must be the canonical `arrow.json`. + // Read metadata is normalized to that name, and forcing it here also supports callers + // that provide the physical `lance.json` spelling directly. + val jsonMeta = (meta + (ARROW_EXT_NAME_KEY -> JSON_EXT_NAME)).asJava + val jsonType = if (large) ArrowType.LargeUtf8.INSTANCE else ArrowType.Utf8.INSTANCE + new Field(name, new FieldType(nullable, jsonType, null, jsonMeta), Seq.empty[Field].asJava) case dataType => val fieldType = new FieldType(nullable, toArrowType(dataType, timeZoneId, large, name), null, meta.asJava) diff --git a/lance-spark-base_2.12/src/test/java/org/lance/spark/BaseJsonColumnTest.java b/lance-spark-base_2.12/src/test/java/org/lance/spark/BaseJsonColumnTest.java new file mode 100644 index 000000000..6673a712c --- /dev/null +++ b/lance-spark-base_2.12/src/test/java/org/lance/spark/BaseJsonColumnTest.java @@ -0,0 +1,339 @@ +/* + * Licensed 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.lance.spark; + +import org.lance.spark.utils.JsonUtils; + +import org.apache.arrow.c.ArrowArrayStream; +import org.apache.arrow.c.Data; +import org.apache.arrow.memory.BufferAllocator; +import org.apache.arrow.vector.IntVector; +import org.apache.arrow.vector.VarCharVector; +import org.apache.arrow.vector.VectorSchemaRoot; +import org.apache.arrow.vector.ipc.ArrowStreamReader; +import org.apache.arrow.vector.ipc.ArrowStreamWriter; +import org.apache.arrow.vector.types.pojo.ArrowType; +import org.apache.arrow.vector.types.pojo.Field; +import org.apache.arrow.vector.types.pojo.FieldType; +import org.apache.arrow.vector.types.pojo.Schema; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.RowFactory; +import org.apache.spark.sql.SaveMode; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.MetadataBuilder; +import org.apache.spark.sql.types.StringType; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests reading and writing Lance JSON columns through Spark. + * + *

Lance physically persists JSON as JSONB in a LargeBinary column carrying the {@code + * lance.json} extension name. Its Arrow read/write interface uses UTF-8 JSON text with the {@code + * arrow.json} extension name: Lance encodes the text to JSONB on write and decodes it on read. + * Spark has no JSON type, so the connector surfaces this logical representation as StringType + * carrying {@code arrow.json} in field metadata. + * + *

The datasets are created through the lance Java API rather than Spark DDL, matching what + * {@code BaseFixedSizeBinaryReadTest} does for a type Spark cannot express either. + * + *

Current lance-core exposes the physical {@code lance.json} schema from {@code + * Dataset.getSchema()}, so the connector translates that field to its logical UTF-8/{@code + * arrow.json} representation at the Spark boundary. Both extension names are recognized there to + * remain compatible if lance-core begins returning the logical Arrow schema. + */ +public abstract class BaseJsonColumnTest { + + private static SparkSession spark; + + @TempDir static Path tempDir; + + private static final String JSON_1 = "{\"ingested_at\":\"2026-09-04T17:00:00Z\",\"version\":7}"; + private static final String JSON_2 = "{\"ingested_at\":\"2026-09-05T09:30:00Z\",\"version\":8}"; + + @BeforeAll + static void setup() { + spark = SparkSession.builder().appName("json-column-test").master("local[*]").getOrCreate(); + } + + @AfterAll + static void tearDown() { + if (spark != null) { + spark.stop(); + } + } + + /** + * Builds the Arrow field a producer would declare for a JSON column: Utf8 + the extension name. + */ + private static Field jsonField(String name) { + Map metadata = new HashMap<>(); + metadata.put("ARROW:extension:name", JsonUtils.ARROW_JSON_EXTENSION_NAME); + return new Field(name, new FieldType(true, ArrowType.Utf8.INSTANCE, null, metadata), null); + } + + /** + * Creates a lance dataset with an id column and a JSON column, optionally populated. + * + *

Goes through the Arrow C Data interface for the same reason {@code + * BaseFixedSizeBinaryReadTest} does: it keeps lance's JNI bridge and our buffers on the same + * allocator root. + */ + private static void createJsonDataset(String datasetUri, String... jsonValues) throws Exception { + Field idField = new Field("id", FieldType.nullable(new ArrowType.Int(32, true)), null); + Schema arrowSchema = new Schema(Arrays.asList(idField, jsonField("payload"))); + + BufferAllocator allocator = LanceRuntime.allocator(); + try (VectorSchemaRoot root = VectorSchemaRoot.create(arrowSchema, allocator)) { + root.allocateNew(); + IntVector idVec = (IntVector) root.getVector("id"); + VarCharVector jsonVec = (VarCharVector) root.getVector("payload"); + + for (int i = 0; i < jsonValues.length; i++) { + idVec.setSafe(i, i + 1); + jsonVec.setSafe(i, jsonValues[i].getBytes(StandardCharsets.UTF_8)); + } + root.setRowCount(jsonValues.length); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ArrowStreamWriter writer = new ArrowStreamWriter(root, null, baos)) { + writer.start(); + writer.writeBatch(); + writer.end(); + } + + try (ArrowStreamReader reader = + new ArrowStreamReader(new ByteArrayInputStream(baos.toByteArray()), allocator); + ArrowArrayStream arrowStream = ArrowArrayStream.allocateNew(allocator)) { + Data.exportArrayStream(allocator, reader, arrowStream); + org.lance.Dataset.write().stream(arrowStream).uri(datasetUri).execute().close(); + } + } + } + + @Test + public void testJsonColumnSurfacesAsStringType() throws Exception { + String datasetUri = tempDir.resolve("json_schema_test.lance").toString(); + createJsonDataset(datasetUri, JSON_1); + + Dataset df = spark.read().format(LanceDataSource.name).load(datasetUri); + StructField field = df.schema().apply("payload"); + + assertInstanceOf( + StringType.class, + field.dataType(), + "A JSON column should surface as StringType, not binary"); + assertEquals( + JsonUtils.ARROW_JSON_EXTENSION_NAME, + field.metadata().getString("ARROW:extension:name"), + "The Spark field must carry the canonical Arrow JSON extension name"); + } + + @Test + public void testJsonValuesAreReadAsText() throws Exception { + String datasetUri = tempDir.resolve("json_values_test.lance").toString(); + createJsonDataset(datasetUri, JSON_1, JSON_2); + + spark.read().format(LanceDataSource.name).load(datasetUri).createOrReplaceTempView("json_read"); + List rows = spark.sql("SELECT id, payload FROM json_read ORDER BY id").collectAsList(); + + assertEquals(2, rows.size()); + assertEquals(JSON_1, rows.get(0).getString(1)); + assertEquals(JSON_2, rows.get(1).getString(1)); + } + + /** The JSON text is an ordinary string to Spark, so Spark's own JSON functions apply to it. */ + @Test + public void testJsonColumnIsQueryableWithSparkJsonFunctions() throws Exception { + String datasetUri = tempDir.resolve("json_query_test.lance").toString(); + createJsonDataset(datasetUri, JSON_1, JSON_2); + + spark + .read() + .format(LanceDataSource.name) + .load(datasetUri) + .createOrReplaceTempView("json_query"); + List rows = + spark + .sql("SELECT id FROM json_query WHERE get_json_object(payload, '$.version') = '8'") + .collectAsList(); + + assertEquals(1, rows.size()); + assertEquals(2, rows.get(0).getInt(0)); + } + + /** + * Appends to a JSON column created by another producer. + * + *

The DataFrame is built from the logical schema Spark read back: StringType with the + * canonical {@code arrow.json} extension name. This verifies that the normalized schema can be + * written back to a table whose physical column is JSONB/LargeBinary with {@code lance.json}. + */ + @Test + public void testWriteToExistingJsonColumn() throws Exception { + String datasetUri = tempDir.resolve("json_append_test.lance").toString(); + createJsonDataset(datasetUri, JSON_1); + + StructType readSchema = spark.read().format(LanceDataSource.name).load(datasetUri).schema(); + Dataset toAppend = + spark.createDataFrame(Collections.singletonList(RowFactory.create(2, JSON_2)), readSchema); + + toAppend.write().format(LanceDataSource.name).mode(SaveMode.Append).save(datasetUri); + + spark + .read() + .format(LanceDataSource.name) + .load(datasetUri) + .createOrReplaceTempView("json_appended"); + List rows = spark.sql("SELECT id, payload FROM json_appended ORDER BY id").collectAsList(); + + assertEquals(2, rows.size(), "The appended row should be present"); + assertEquals(JSON_1, rows.get(0).getString(1)); + assertEquals(JSON_2, rows.get(1).getString(1), "The appended JSON should round-trip intact"); + } + + /** A JSON column must survive a full read-transform-write cycle without losing its type. */ + @Test + public void testJsonColumnRoundTripsThroughSparkWrite() throws Exception { + String sourceUri = tempDir.resolve("json_roundtrip_source.lance").toString(); + String targetUri = tempDir.resolve("json_roundtrip_target.lance").toString(); + createJsonDataset(sourceUri, JSON_1, JSON_2); + + spark + .read() + .format(LanceDataSource.name) + .load(sourceUri) + .write() + .format(LanceDataSource.name) + .mode(SaveMode.ErrorIfExists) + .save(targetUri); + + StructField field = + spark.read().format(LanceDataSource.name).load(targetUri).schema().apply("payload"); + assertInstanceOf( + StringType.class, field.dataType(), "The copied column should still be a string"); + assertTrue( + JsonUtils.isJsonSparkField(field), + "The copied column should still be a JSON column, not a plain string"); + } + + /** + * A plain string column must not become a JSON column, and a JSON marker must not be invented + * where the producer declared none. + */ + @Test + public void testPlainStringColumnIsUnaffected() throws Exception { + String datasetUri = tempDir.resolve("plain_string_test.lance").toString(); + + Field idField = new Field("id", FieldType.nullable(new ArrowType.Int(32, true)), null); + Field textField = new Field("payload", FieldType.nullable(ArrowType.Utf8.INSTANCE), null); + Schema arrowSchema = new Schema(Arrays.asList(idField, textField)); + + BufferAllocator allocator = LanceRuntime.allocator(); + try (VectorSchemaRoot root = VectorSchemaRoot.create(arrowSchema, allocator)) { + root.allocateNew(); + ((IntVector) root.getVector("id")).setSafe(0, 1); + ((VarCharVector) root.getVector("payload")) + .setSafe(0, JSON_1.getBytes(StandardCharsets.UTF_8)); + root.setRowCount(1); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ArrowStreamWriter writer = new ArrowStreamWriter(root, null, baos)) { + writer.start(); + writer.writeBatch(); + writer.end(); + } + try (ArrowStreamReader reader = + new ArrowStreamReader(new ByteArrayInputStream(baos.toByteArray()), allocator); + ArrowArrayStream arrowStream = ArrowArrayStream.allocateNew(allocator)) { + Data.exportArrayStream(allocator, reader, arrowStream); + org.lance.Dataset.write().stream(arrowStream).uri(datasetUri).execute().close(); + } + } + + StructField field = + spark.read().format(LanceDataSource.name).load(datasetUri).schema().apply("payload"); + assertInstanceOf(StringType.class, field.dataType()); + assertFalse( + JsonUtils.isJsonSparkField(field), + "A column declared without the extension name must not be treated as JSON"); + } + + /** Spark writes logical Arrow JSON fields as Lance's physical JSONB representation. */ + @Test + public void testSparkCanCreateJsonColumnViaMetadata() { + String datasetUri = tempDir.resolve("json_create_test.lance").toString(); + + StructType schema = + new StructType( + new StructField[] { + new StructField("id", DataTypes.IntegerType, true, Metadata.empty()), + new StructField( + "payload", + DataTypes.StringType, + true, + new MetadataBuilder() + .putString("ARROW:extension:name", JsonUtils.ARROW_JSON_EXTENSION_NAME) + .build()) + }); + + spark + .createDataFrame(Collections.singletonList(RowFactory.create(1, JSON_1)), schema) + .write() + .format(LanceDataSource.name) + .mode(SaveMode.ErrorIfExists) + .save(datasetUri); + + StructField field = + spark.read().format(LanceDataSource.name).load(datasetUri).schema().apply("payload"); + assertTrue( + JsonUtils.isJsonSparkField(field), + "A string column marked with the JSON extension name should be created as a JSON column"); + + try (org.lance.Dataset lanceDataset = + org.lance.Dataset.open().allocator(LanceRuntime.allocator()).uri(datasetUri).build()) { + Field physicalField = lanceDataset.getSchema().findField("payload"); + assertEquals( + ArrowType.LargeBinary.INSTANCE, + physicalField.getType(), + "Lance must persist JSON as LargeBinary JSONB"); + assertEquals( + JsonUtils.LANCE_JSON_EXTENSION_NAME, + physicalField.getMetadata().get("ARROW:extension:name"), + "Lance must persist the internal JSON extension name"); + } + } +} diff --git a/lance-spark-base_2.12/src/test/java/org/lance/spark/utils/JsonUtilsTest.java b/lance-spark-base_2.12/src/test/java/org/lance/spark/utils/JsonUtilsTest.java new file mode 100644 index 000000000..7da679401 --- /dev/null +++ b/lance-spark-base_2.12/src/test/java/org/lance/spark/utils/JsonUtilsTest.java @@ -0,0 +1,176 @@ +/* + * Licensed 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.lance.spark.utils; + +import org.apache.arrow.vector.types.pojo.ArrowType; +import org.apache.arrow.vector.types.pojo.Field; +import org.apache.arrow.vector.types.pojo.FieldType; +import org.apache.arrow.vector.types.pojo.Schema; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.MetadataBuilder; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.sql.util.LanceArrowUtils; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Unit tests for JSON column detection and the Arrow/Spark type mapping it drives. */ +public class JsonUtilsTest { + + private static final String EXT_KEY = "ARROW:extension:name"; + + private static Field arrowField(ArrowType type, String extensionName) { + Map metadata = new HashMap<>(); + if (extensionName != null) { + metadata.put(EXT_KEY, extensionName); + } + return new Field("payload", new FieldType(true, type, null, metadata), null); + } + + private static StructField sparkField(String extensionName) { + Metadata metadata = + extensionName == null + ? Metadata.empty() + : new MetadataBuilder().putString(EXT_KEY, extensionName).build(); + return new StructField("payload", DataTypes.StringType, true, metadata); + } + + @Test + public void testRecognizesBothExtensionNames() { + assertTrue(JsonUtils.isJsonExtensionName("arrow.json"), "the canonical declared name"); + assertTrue( + JsonUtils.isJsonExtensionName("lance.json"), + "the internal name lance-core reports when a dataset is reopened"); + assertFalse(JsonUtils.isJsonExtensionName("lance.blob.v2")); + assertFalse(JsonUtils.isJsonExtensionName(null)); + } + + /** + * Lance reports JSON columns as LargeBinary because Arrow Java does not register the extension, + * so detection must not depend on the storage type. + */ + @Test + public void testDetectsJsonRegardlessOfStorageType() { + assertTrue(JsonUtils.hasJsonArrowExtension(arrowField(ArrowType.Utf8.INSTANCE, "arrow.json"))); + assertTrue(JsonUtils.hasJsonArrowExtension(arrowField(ArrowType.LargeUtf8.INSTANCE, "arrow.json"))); + assertTrue( + JsonUtils.hasJsonArrowExtension(arrowField(ArrowType.LargeBinary.INSTANCE, "lance.json"))); + assertFalse(JsonUtils.hasJsonArrowExtension(arrowField(ArrowType.Utf8.INSTANCE, null))); + assertFalse(JsonUtils.hasJsonArrowExtension(null)); + } + + @Test + public void testDetectsPhysicalLanceJsonFields() { + assertTrue( + JsonUtils.isLanceJsonField(arrowField(ArrowType.LargeBinary.INSTANCE, "lance.json"))); + assertFalse(JsonUtils.isLanceJsonField(arrowField(ArrowType.Utf8.INSTANCE, "arrow.json"))); + assertFalse(JsonUtils.isLanceJsonField(null)); + } + + @Test + public void testSparkFieldDetectionRequiresStringType() { + assertTrue(JsonUtils.isJsonSparkField(sparkField("arrow.json"))); + assertFalse(JsonUtils.isJsonSparkField(sparkField(null))); + assertFalse( + JsonUtils.isJsonSparkField( + new StructField( + "payload", + DataTypes.BinaryType, + true, + new MetadataBuilder().putString(EXT_KEY, "arrow.json").build())), + "the marker alone must not make a binary column JSON"); + } + + @Test + public void testJsonArrowFieldMapsToStringType() { + assertEquals( + DataTypes.StringType, + LanceArrowUtils.fromArrowField(arrowField(ArrowType.LargeBinary.INSTANCE, "lance.json")), + "a JSON column reported as LargeBinary must still surface as a string"); + } + + /** A LargeBinary column with no JSON marker keeps its existing mapping. */ + @Test + public void testPlainLargeBinaryStillMapsToBinaryType() { + assertEquals( + DataTypes.BinaryType, + LanceArrowUtils.fromArrowField(arrowField(ArrowType.LargeBinary.INSTANCE, null))); + } + + /** + * The write path must emit the canonical name over UTF-8 storage. Echoing back the internal + * {@code lance.json} spelling is silently ignored by lance-core on a UTF-8 field, producing a + * plain string column and an append rejected as {@code should have type json}. + */ + @Test + public void testWriteTranslatesInternalNameToCanonicalName() { + Metadata readMetadata = new MetadataBuilder().putString(EXT_KEY, "lance.json").build(); + + Field written = + LanceArrowUtils.toArrowField( + "payload", DataTypes.StringType, true, null, readMetadata, false); + + assertEquals(ArrowType.Utf8.INSTANCE, written.getType(), "JSON storage must be UTF-8"); + assertEquals( + JsonUtils.ARROW_JSON_EXTENSION_NAME, + written.getMetadata().get(EXT_KEY), + "the internal name must be translated to the canonical one on write"); + } + + @Test + public void testWritePreservesCanonicalName() { + Metadata metadata = new MetadataBuilder().putString(EXT_KEY, "arrow.json").build(); + + Field written = + LanceArrowUtils.toArrowField("payload", DataTypes.StringType, true, null, metadata, false); + + assertEquals(ArrowType.Utf8.INSTANCE, written.getType()); + assertEquals(JsonUtils.ARROW_JSON_EXTENSION_NAME, written.getMetadata().get(EXT_KEY)); + } + + /** A JSON column read back and written again must not pick up a large-binary marker. */ + @Test + public void testJsonFieldDoesNotGetLargeBinaryMarker() { + StructType sparkSchema = + LanceArrowUtils.fromArrowSchema( + new Schema( + Collections.singletonList( + arrowField(ArrowType.LargeBinary.INSTANCE, "lance.json")))); + StructField sparkField = sparkSchema.apply("payload"); + + assertEquals(DataTypes.StringType, sparkField.dataType()); + assertEquals( + JsonUtils.ARROW_JSON_EXTENSION_NAME, + sparkField.metadata().getString(EXT_KEY), + "Spark metadata must use the canonical Arrow extension name"); + assertFalse( + sparkField.metadata().contains(LargeVarBinaryUtils.ARROW_LARGE_VAR_BINARY_KEY), + "a large-binary marker would contradict the StringType mapping and steer writeback " + + "back to LargeBinary"); + + Field written = + LanceArrowUtils.toArrowField( + "payload", DataTypes.StringType, true, null, sparkField.metadata(), false); + assertEquals(ArrowType.Utf8.INSTANCE, written.getType()); + assertEquals(JsonUtils.ARROW_JSON_EXTENSION_NAME, written.getMetadata().get(EXT_KEY)); + } +}