You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sedona.apache.org by ji...@apache.org on 2026/09/23 05:13:25 UTC

(sedona) branch master updated: [GH-3383] Preserve GeoParquet XYZ layouts and document M conversion (#3390)

This is an automated email from the ASF dual-hosted git repository.

jiayuasu pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/sedona.git


The following commit(s) were added to refs/heads/master by this push:
     new 986558a6dc2 [GH-3383] Preserve GeoParquet XYZ layouts and document M conversion (#3390)
986558a6dc2 is described below

commit 986558a6dc254c242380938a9edcbd1fa031effd
Author: Jia Yu <ji...@apache.org>
AuthorDate: Tue Sep 22 22:13:18 2026 -0700

    [GH-3383] Preserve GeoParquet XYZ layouts and document M conversion (#3390)
---
 docs/tutorial/files/geoparquet-sedona-spark.md     |   6 +-
 docs/tutorial/files/geoparquet-sedona-spark.zh.md  |   6 +
 .../geoparquet/GeoParquetWriteSupport.scala        |  48 +--
 .../sedona/sql/GeoParquetDimensionsSuite.scala     | 322 +++++++++++++++++++++
 .../geoparquet/GeoParquetWriteSupportTest.scala    |  45 +++
 5 files changed, 406 insertions(+), 21 deletions(-)

diff --git a/docs/tutorial/files/geoparquet-sedona-spark.md b/docs/tutorial/files/geoparquet-sedona-spark.md
index eeae899c038..503f07512d6 100644
--- a/docs/tutorial/files/geoparquet-sedona-spark.md
+++ b/docs/tutorial/files/geoparquet-sedona-spark.md
@@ -67,6 +67,10 @@ somewhere/
 
 Sedona writes many Parquet files in parallel because that's much faster than writing a single file.
 
+GeoParquet writes preserve the declared XY or XYZ coordinate layout of empty `Point`, `LineString`, and `Polygon` geometries. XYZ geometries also retain their Z dimension when Z ordinates are `NaN`.
+
+[GeoParquet 1.1](https://geoparquet.org/releases/v1.1.0/#wkb) does not support M coordinates. Sedona discards M when writing GeoParquet: XYM becomes XY, and XYZM becomes XYZ. If you need the M values, store them separately before writing.
+
 ## Read GeoParquet files into Sedona DataFrame with Spark
 
 Let's read the GeoParquet files into a Sedona DataFrame:
@@ -88,7 +92,7 @@ Here are the results:
 +---+---------------------+
 ```
 
-Since Sedona 2.0.0, reads preserve declared Z/M dimensions even for empty points, lines, and polygons or NaN ordinates. If collecting mixed layouts raises a heterogeneous-layout error, see [ST_Collect](../../api/sql/Geometry-Editors/ST_Collect.md).
+Since Sedona 2.0.0, reads preserve declared Z/M dimensions even for empty points, lines, and polygons or NaN ordinates. This includes measured WKB inputs, although M coordinates are outside the GeoParquet 1.1 specification. If collecting mixed layouts raises a heterogeneous-layout error, see [ST_Collect](../../api/sql/Geometry-Editors/ST_Collect.md).
 
 Here's how Sedona executes this query under the hood:
 
diff --git a/docs/tutorial/files/geoparquet-sedona-spark.zh.md b/docs/tutorial/files/geoparquet-sedona-spark.zh.md
index 4d3c1ccf40e..394ae53418f 100644
--- a/docs/tutorial/files/geoparquet-sedona-spark.zh.md
+++ b/docs/tutorial/files/geoparquet-sedona-spark.zh.md
@@ -67,6 +67,10 @@ somewhere/
 
 Sedona 会并行写出多个 Parquet 文件,比写单一文件快得多。
 
+写出 GeoParquet 时,Sedona 会保留空 `Point`、`LineString` 和 `Polygon` 几何对象声明的 XY 或 XYZ 坐标布局。即使 Z 坐标值为 `NaN`,XYZ 几何对象也会保留 Z 维度。
+
+[GeoParquet 1.1](https://geoparquet.org/releases/v1.1.0/#wkb) 不支持 M 坐标。Sedona 在写出 GeoParquet 时会丢弃 M:XYM 转换为 XY,XYZM 转换为 XYZ。如果需要保留 M 值,请在写出前将其单独存储。
+
 ## 使用 Spark 将 GeoParquet 读入 Sedona DataFrame
 
 将 GeoParquet 文件读入 Sedona DataFrame:
@@ -88,6 +92,8 @@ df.show(truncate=False)
 +---+---------------------+
 ```
 
+自 Sedona 2.0.0 起,读取时会保留声明的 Z/M 维度,即使点、线或多边形为空,或坐标值为 `NaN`。这也适用于包含 M 坐标的 WKB 输入,但此类输入不符合 GeoParquet 1.1 规范。如果收集不同坐标布局的几何对象时出现布局不一致的错误,请参阅 [ST_Collect](../../api/sql/Geometry-Editors/ST_Collect.md)。
+
 Sedona 在底层执行该查询的过程大致如下:
 
 1. 从 GeoParquet 文件尾部读取 schema,因此无需 schema 推断。
diff --git a/spark/common/src/main/scala/org/apache/spark/sql/execution/datasources/geoparquet/GeoParquetWriteSupport.scala b/spark/common/src/main/scala/org/apache/spark/sql/execution/datasources/geoparquet/GeoParquetWriteSupport.scala
index c63437d22f8..517b81b19ee 100644
--- a/spark/common/src/main/scala/org/apache/spark/sql/execution/datasources/geoparquet/GeoParquetWriteSupport.scala
+++ b/spark/common/src/main/scala/org/apache/spark/sql/execution/datasources/geoparquet/GeoParquetWriteSupport.scala
@@ -24,7 +24,6 @@ import org.apache.parquet.hadoop.api.WriteSupport.FinalizedWriteContext
 import org.apache.parquet.hadoop.api.WriteSupport.WriteContext
 import org.apache.parquet.io.api.Binary
 import org.apache.parquet.io.api.RecordConsumer
-import org.apache.sedona.common.utils.GeomUtils
 import org.apache.spark.SPARK_VERSION_SHORT
 import org.apache.spark.internal.Logging
 import org.apache.spark.sql.catalyst.InternalRow
@@ -38,6 +37,8 @@ import org.apache.spark.sql.types._
 import org.json4s.JValue
 import org.json4s.jackson.JsonMethods.parse
 import org.locationtech.jts.geom.Geometry
+import org.locationtech.jts.io.ByteOrderValues
+import org.datasyslab.jts.io.WKBWriter
 
 import java.nio.ByteBuffer
 import java.nio.ByteOrder
@@ -245,12 +246,9 @@ class GeoParquetWriteSupport extends WriteSupport[InternalRow] with Logging {
       val columns = geometryColumnInfoMap.map { case (ordinal, columnInfo) =>
         val columnName = schema.fields(ordinal).name
         val geometryTypes = columnInfo.seenGeometryTypes.toSeq
-        // Omit bbox from column metadata when no geometries were observed (e.g. an empty
-        // Spark partition produces a zero-row file). Per the GeoParquet 1.1 spec, bbox is
-        // optional and represents the extent of the geometries in the file; emitting
-        // [0, 0, 0, 0] for an empty file falsely advertises data at Null Island and breaks
-        // bbox-based file pruning in downstream readers.
-        val bbox = if (geometryTypes.nonEmpty) {
+        // Empty geometries still contribute geometry_types but have no spatial extent.
+        // Omit bbox when the file contains only empty/null geometries or no rows.
+        val bbox = if (!columnInfo.bbox.isEmpty) {
           Some(
             Seq(
               columnInfo.bbox.minX,
@@ -483,13 +481,23 @@ class GeoParquetWriteSupport extends WriteSupport[InternalRow] with Logging {
             geometryColumnInfoMap.getOrElseUpdate(ordinal, new GeometryColumnInfo())
           case None => null
         }
+        val byteOrder = if (ByteOrder.nativeOrder() == ByteOrder.BIG_ENDIAN) {
+          ByteOrderValues.BIG_ENDIAN
+        } else ByteOrderValues.LITTLE_ENDIAN
+        // Preserve declared Z even for empty geometries, but keep GeoParquet output limited
+        // to XY/XYZ: the configured XYZ ordinates continue to exclude M.
+        val wkbWriter = new WKBWriter(3, byteOrder, false)
+        wkbWriter.setPreserveCoordinateDimensions(true)
         (row: SpecializedGetters, ordinal: Int) => {
           val serializedGeometry = row.getBinary(ordinal)
           val geom = GeometryUDT.deserialize(serializedGeometry)
-          val wkbWriter = GeomUtils.createWKBWriter(GeomUtils.getDimension(geom))
-          recordConsumer.addBinary(Binary.fromReusedByteArray(wkbWriter.write(geom)))
+          val wkb = wkbWriter.write(geom)
+          recordConsumer.addBinary(Binary.fromReusedByteArray(wkb))
           if (geometryColumnInfo != null) {
-            geometryColumnInfo.update(geom)
+            // The writer resolves each geometry's actual layout, including collection members.
+            // Derive metadata from its EWKB type header rather than the first coordinate.
+            val typeCode = ByteBuffer.wrap(wkb, 1, 4).order(ByteOrder.nativeOrder()).getInt
+            geometryColumnInfo.update(geom, (typeCode & 0x80000000) != 0)
           }
         }
 
@@ -754,13 +762,9 @@ object GeoParquetWriteSupport {
       else Some(_srid)
     }
 
-    def update(geom: Geometry): Unit = {
+    def update(geom: Geometry, hasZ: Boolean): Unit = {
       bbox.update(geom)
-      // In case of 3D geometries, a " Z" suffix gets added (e.g. ["Point Z"]).
-      val hasZ = {
-        val coordinate = geom.getCoordinate
-        if (coordinate != null) !coordinate.getZ.isNaN else false
-      }
+      // Match the dimensionality encoded in WKB, including typed empty geometries.
       val geometryType = if (!hasZ) geom.getGeometryType else geom.getGeometryType + " Z"
       seenGeometryTypes.add(geometryType)
 
@@ -781,12 +785,16 @@ object GeoParquetWriteSupport {
       var minY: Double = Double.PositiveInfinity,
       var maxX: Double = Double.NegativeInfinity,
       var maxY: Double = Double.NegativeInfinity) {
+    def isEmpty: Boolean = minX > maxX || minY > maxY
+
     def update(geom: Geometry): Unit = {
       val env = geom.getEnvelopeInternal
-      minX = math.min(minX, env.getMinX)
-      minY = math.min(minY, env.getMinY)
-      maxX = math.max(maxX, env.getMaxX)
-      maxY = math.max(maxY, env.getMaxY)
+      if (!env.isNull) {
+        minX = math.min(minX, env.getMinX)
+        minY = math.min(minY, env.getMinY)
+        maxX = math.max(maxX, env.getMaxX)
+        maxY = math.max(maxY, env.getMaxY)
+      }
     }
   }
 
diff --git a/spark/common/src/test/scala/org/apache/sedona/sql/GeoParquetDimensionsSuite.scala b/spark/common/src/test/scala/org/apache/sedona/sql/GeoParquetDimensionsSuite.scala
new file mode 100644
index 00000000000..917d7079a8b
--- /dev/null
+++ b/spark/common/src/test/scala/org/apache/sedona/sql/GeoParquetDimensionsSuite.scala
@@ -0,0 +1,322 @@
+/*
+ * 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.sedona.sql
+
+import java.io.File
+import java.nio.{ByteBuffer, ByteOrder}
+import java.nio.file.Files
+import java.util.Collections
+import scala.collection.JavaConverters._
+
+import org.apache.commons.io.FileUtils
+import org.apache.hadoop.conf.Configuration
+import org.apache.hadoop.fs.Path
+import org.apache.parquet.example.data.simple.SimpleGroupFactory
+import org.apache.parquet.hadoop.ParquetFileReader
+import org.apache.parquet.hadoop.util.HadoopInputFile
+import org.apache.parquet.column.Encoding
+import org.apache.parquet.hadoop.example.ExampleParquetWriter
+import org.apache.parquet.io.api.Binary
+import org.apache.parquet.schema.MessageTypeParser
+import org.apache.sedona.common.utils.GeometryEquality
+import org.apache.spark.sql.{DataFrame, Row}
+import org.apache.spark.sql.types.{BinaryType, IntegerType, StructField, StructType}
+import org.json4s.{DefaultFormats, JNothing, JValue}
+import org.json4s.jackson.parseJson
+import org.apache.spark.sql.sedona_sql.UDT.GeometryUDT
+import org.datasyslab.jts.io.WKBReader
+
+/** File-level tests use hand-built ISO WKB, independently of the GeoParquet writer. */
+class GeoParquetDimensionsSuite extends TestBaseScala {
+  private case class Sample(name: String, wkb: Array[Byte])
+
+  private def isoWkb(
+      kind: Int,
+      z: Boolean,
+      coordinates: Seq[Double] = Seq.empty,
+      children: Seq[Array[Byte]] = Seq.empty,
+      order: ByteOrder = ByteOrder.LITTLE_ENDIAN,
+      m: Boolean = false): Array[Byte] = {
+    val dimension = 2 + (if (z) 1 else 0) + (if (m) 1 else 0)
+    val payload =
+      if (kind == 1) coordinates.size * 8
+      else if (kind == 2) 4 + coordinates.size * 8
+      else if (kind >= 4 && kind <= 7) 4 + children.map(_.length).sum
+      else 4
+    val buffer = ByteBuffer.allocate(5 + payload).order(order)
+    buffer.put(if (order == ByteOrder.LITTLE_ENDIAN) 1.toByte else 0.toByte)
+    buffer.putInt(kind + (if (z) 1000 else 0) + (if (m) 2000 else 0))
+    if (kind == 1) coordinates.foreach(buffer.putDouble)
+    else if (kind == 2) {
+      require(coordinates.size % dimension == 0)
+      buffer.putInt(coordinates.size / dimension)
+      coordinates.foreach(buffer.putDouble)
+    } else if (kind >= 4 && kind <= 7) {
+      buffer.putInt(children.size)
+      children.foreach(buffer.put)
+    } else buffer.putInt(0)
+    buffer.array()
+  }
+
+  private def samples: Seq[Sample] = {
+    val primitives = for {
+      order <- Seq(ByteOrder.LITTLE_ENDIAN, ByteOrder.BIG_ENDIAN)
+      z <- Seq(false, true)
+      kind <- Seq(1, 2, 3)
+    } yield Sample(
+      s"empty-$kind-z=$z-$order",
+      isoWkb(
+        kind,
+        z,
+        if (kind == 1) Seq.fill(if (z) 3 else 2)(Double.NaN) else Seq.empty,
+        order = order))
+    val lines = Seq(ByteOrder.LITTLE_ENDIAN, ByteOrder.BIG_ENDIAN).map { order =>
+      Sample(
+        s"later-finite-z-$order",
+        isoWkb(2, true, Seq(1, 2, Double.NaN, 3, 4, 5), order = order))
+    }
+    primitives ++ lines ++ Seq(
+      Sample("null", null),
+      Sample("nonempty-xyz", isoWkb(1, true, Seq(10, 20, 30))),
+      Sample("nan-z", isoWkb(1, true, Seq(10, 20, Double.NaN))),
+      Sample("empty-collection", isoWkb(7, false)),
+      Sample(
+        "empty-multipoint",
+        isoWkb(4, true, children = Seq(isoWkb(1, true, Seq.fill(3)(Double.NaN))))),
+      Sample("empty-multilinestring", isoWkb(5, true, children = Seq(isoWkb(2, true)))),
+      Sample("empty-multipolygon", isoWkb(6, true, children = Seq(isoWkb(3, true)))),
+      Sample(
+        "mixed-empty-children",
+        isoWkb(7, true, children = Seq(isoWkb(2, false), isoWkb(3, true)))),
+      Sample(
+        "nested-empty-children",
+        isoWkb(
+          7,
+          true,
+          children = Seq(isoWkb(7, true, children = Seq(isoWkb(2, true))), isoWkb(3, false)))))
+  }
+
+  private def withDirectory(f: File => Unit): Unit = {
+    val directory = Files.createTempDirectory("geoparquet-dimensions-").toFile
+    try f(directory)
+    finally FileUtils.deleteDirectory(directory)
+  }
+
+  private def writeFixture(file: File, dictionary: Boolean, values: Seq[Sample]): Unit = {
+    val schema = MessageTypeParser.parseMessageType(
+      "message fixture { required int32 id; optional binary geometry; }")
+    val factory = new SimpleGroupFactory(schema)
+    val metadata = Collections.singletonMap(
+      "geo",
+      """{"version":"1.1.0","primary_column":"geometry","columns":{"geometry":{"encoding":"WKB","geometry_types":[]}}}""")
+    val writer = ExampleParquetWriter
+      .builder(new Path(file.toURI))
+      .withConf(new Configuration())
+      .withType(schema)
+      .withExtraMetaData(metadata)
+      .withDictionaryEncoding(dictionary)
+      .build()
+    try {
+      // Repeated values exercise the dictionary decoder, while IDs retain stable ordering.
+      for (repeat <- 0 until 4; (sample, index) <- values.zipWithIndex) {
+        val row = factory.newGroup().append("id", repeat * values.size + index)
+        if (sample.wkb != null) row.append("geometry", Binary.fromConstantByteArray(sample.wkb))
+        writer.write(row)
+      }
+    } finally writer.close()
+    val reader =
+      ParquetFileReader.open(HadoopInputFile.fromPath(new Path(file.toURI), new Configuration()))
+    try {
+      val columns = reader.getFooter.getBlocks.asScala
+        .flatMap(_.getColumns.asScala)
+        .filter(_.getPath.toDotString == "geometry")
+      assert(columns.nonEmpty)
+      val encodedWithDictionary = columns.exists { column =>
+        column.getEncodings.contains(Encoding.RLE_DICTIONARY) ||
+        column.getEncodings.contains(Encoding.PLAIN_DICTIONARY)
+      }
+      assert(encodedWithDictionary == dictionary)
+    } finally reader.close()
+  }
+
+  private def assertFile(
+      path: String,
+      values: Seq[Sample],
+      repetitions: Int = 1,
+      expectedSrid: Int = 0): Unit = {
+    val df = sparkSession.read.format("geoparquet").load(path)
+    assertGeometries(df, values, "file read", repetitions, expectedSrid)
+  }
+
+  private def assertGeometries(
+      df: DataFrame,
+      values: Seq[Sample],
+      context: String,
+      repetitions: Int = 1,
+      expectedSrid: Int = 0): Unit = {
+    val projected = df.select("id", "geometry")
+    val rows = projected.queryExecution.toRdd.map(_.copy()).collect().sortBy(_.getInt(0))
+    assert(rows.length == values.size * repetitions)
+    rows.zipWithIndex.foreach { case (row, index) =>
+      val sample = values(index % values.size)
+      withClue(s"${sample.name}, $context: ") {
+        if (sample.wkb == null) assert(row.isNullAt(1))
+        else {
+          val expected = WKBReader.forDeclaredDimensions().read(sample.wkb)
+          val actual = GeometryUDT.deserialize(row.getBinary(1))
+          assert(actual.getSRID == expectedSrid, "column metadata SRID was not preserved")
+          assert(
+            GeometryEquality.equalsIdentical(actual, expected),
+            s"declared layout or ordinates changed: $actual")
+        }
+      }
+    }
+  }
+
+  private def hasZ(wkb: Array[Byte]): Boolean = {
+    val order = if (wkb(0) == 0) ByteOrder.BIG_ENDIAN else ByteOrder.LITTLE_ENDIAN
+    val kind = ByteBuffer.wrap(wkb).order(order).getInt(1)
+    (kind & 0x80000000) != 0 || ((kind & 0x0fffffff) / 1000) % 2 == 1
+  }
+
+  private def columnMetadata(file: File): JValue = {
+    val reader =
+      ParquetFileReader.open(HadoopInputFile.fromPath(new Path(file.toURI), new Configuration()))
+    try
+      parseJson(reader.getFooter.getFileMetaData.getKeyValueMetaData.get("geo")) \
+        "columns" \ "geometry"
+    finally reader.close()
+  }
+
+  private def assertWrittenFile(path: String, expected: Seq[Sample], allEmpty: Boolean): Unit = {
+    val rows = sparkSession.read
+      .parquet(path)
+      .select("id", "geometry")
+      .collect()
+      .sortBy(_.getInt(0))
+    assert(rows.length == expected.size)
+    rows.zip(expected).foreach { case (row, sample) =>
+      withClue(s"written ${sample.name}: ") {
+        if (sample.wkb == null) assert(row.isNullAt(1))
+        else {
+          val bytes = row.getAs[Array[Byte]](1)
+          assert(hasZ(bytes) == hasZ(sample.wkb), "top-level WKB Z header changed")
+          assert(
+            GeometryEquality.equalsIdentical(
+              WKBReader.forDeclaredDimensions().read(bytes),
+              WKBReader.forDeclaredDimensions().read(sample.wkb)),
+            "WKB coordinate layout or ordinates changed")
+        }
+      }
+    }
+    val files = new File(path).listFiles().filter(_.getName.endsWith(".parquet"))
+    assert(files.length == 1)
+    val metadata = columnMetadata(files.head)
+    implicit val formats: org.json4s.Formats = DefaultFormats
+    val expectedTypes = expected
+      .filter(_.wkb != null)
+      .map { sample =>
+        WKBReader.forDeclaredDimensions().read(sample.wkb).getGeometryType +
+          (if (hasZ(sample.wkb)) " Z" else "")
+      }
+      .toSet
+    assert((metadata \ "geometry_types").extract[Seq[String]].toSet == expectedTypes)
+    if (allEmpty) assert((metadata \ "bbox") == JNothing)
+    else assert((metadata \ "bbox").extract[Seq[Double]] == Seq(1, 2, 10, 20))
+  }
+
+  private def writeAndRoundTrip(directory: File, allEmpty: Boolean): Unit = {
+    val projectedMeasures = Seq(
+      Sample("xym", isoWkb(1, false, Seq(10, 20, 99), m = true)) ->
+        Sample("xym", isoWkb(1, false, Seq(10, 20))),
+      Sample("xyzm", isoWkb(1, true, Seq(10, 20, 30, 99), m = true)) ->
+        Sample("xyzm", isoWkb(1, true, Seq(10, 20, 30))))
+    val emptyMeasures = for {
+      z <- Seq(false, true)
+      kind <- Seq(1, 2, 3)
+    } yield {
+      val name = s"empty-measured-$kind-z=$z"
+      Sample(
+        name,
+        isoWkb(
+          kind,
+          z,
+          if (kind == 1) Seq.fill(if (z) 4 else 3)(Double.NaN) else Seq.empty,
+          m = true)) ->
+        Sample(
+          name,
+          isoWkb(kind, z, if (kind == 1) Seq.fill(if (z) 3 else 2)(Double.NaN) else Seq.empty))
+    }
+    val measuredLine = Sample(
+      "later-finite-z-measured",
+      isoWkb(2, true, Seq(1, 2, Double.NaN, 90, 3, 4, 5, 99), m = true)) ->
+      Sample("later-finite-z-measured", isoWkb(2, true, Seq(1, 2, Double.NaN, 3, 4, 5)))
+    val trailingXY = Sample("xy-after-measured", isoWkb(1, false, Seq(10, 20)))
+    val candidates = samples.map(s => s -> s) ++ projectedMeasures ++ emptyMeasures ++
+      Seq(measuredLine, trailingXY -> trailingXY)
+    val selected = if (allEmpty) candidates.filter { case (sample, _) =>
+      sample.wkb == null || WKBReader.forDeclaredDimensions().read(sample.wkb).isEmpty
+    }
+    else candidates
+    val input = selected.map(_._1)
+    val expected = selected.map(_._2)
+    val rows = input.zipWithIndex.map { case (sample, index) => Row(index, sample.wkb) }
+    val schema = StructType(
+      Seq(
+        StructField("id", IntegerType, nullable = false),
+        StructField("wkb", BinaryType, nullable = true)))
+    val path = new File(directory, "written").getPath
+    val df = sparkSession
+      .createDataFrame(rows.asJava, schema)
+      .selectExpr("id", "ST_GeomFromWKB(wkb) AS geometry")
+    assertGeometries(df, input, "before write")
+    df.coalesce(1).write.format("geoparquet").save(path)
+    assertWrittenFile(path, expected, allEmpty)
+    assertFile(path, expected)
+  }
+
+  describe("GeoParquet declared coordinate dimensions") {
+    for (dictionary <- Seq(false, true)) {
+      it(s"reads ISO WKB from a file (dictionary=$dictionary)") {
+        withDirectory { directory =>
+          val fixture = new File(directory, "fixture.parquet")
+          // Omitted CRS defaults to EPSG:4326, overriding embedded EWKB SRIDs.
+          val embeddedSrid = ByteBuffer
+            .allocate(33)
+            .order(ByteOrder.LITTLE_ENDIAN)
+            .put(1.toByte)
+            .putInt(0xa0000001)
+            .putInt(3857)
+            .putDouble(10)
+            .putDouble(20)
+            .putDouble(30)
+            .array()
+          val values = samples :+ Sample("embedded-srid", embeddedSrid)
+          writeFixture(fixture, dictionary, values)
+          assertFile(fixture.getPath, values, repetitions = 4, expectedSrid = 4326)
+        }
+      }
+    }
+    for (allEmpty <- Seq(false, true)) {
+      it(s"preserves XYZ, projects M, and writes matching metadata (allEmpty=$allEmpty)") {
+        withDirectory(directory => writeAndRoundTrip(directory, allEmpty))
+      }
+    }
+  }
+}
diff --git a/spark/common/src/test/scala/org/apache/spark/sql/execution/datasources/geoparquet/GeoParquetWriteSupportTest.scala b/spark/common/src/test/scala/org/apache/spark/sql/execution/datasources/geoparquet/GeoParquetWriteSupportTest.scala
new file mode 100644
index 00000000000..b8c59dfed74
--- /dev/null
+++ b/spark/common/src/test/scala/org/apache/spark/sql/execution/datasources/geoparquet/GeoParquetWriteSupportTest.scala
@@ -0,0 +1,45 @@
+/*
+ * 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.geoparquet
+
+import org.apache.spark.sql.execution.datasources.geoparquet.GeoParquetWriteSupport.GeometryColumnBoundingBox
+import org.locationtech.jts.geom.{Coordinate, GeometryFactory}
+import org.scalatest.funsuite.AnyFunSuite
+
+class GeoParquetWriteSupportTest extends AnyFunSuite {
+  test("empty geometries do not contribute a column bounding box") {
+    val factory = new GeometryFactory()
+    val bbox = new GeometryColumnBoundingBox()
+    bbox.update(factory.createPoint())
+    bbox.update(factory.createLineString())
+    bbox.update(factory.createPolygon())
+    assert(bbox.minX == Double.PositiveInfinity)
+    assert(bbox.minY == Double.PositiveInfinity)
+    assert(bbox.maxX == Double.NegativeInfinity)
+    assert(bbox.maxY == Double.NegativeInfinity)
+  }
+
+  test("empty geometries do not extend a nonempty column bounding box") {
+    val factory = new GeometryFactory()
+    val bbox = new GeometryColumnBoundingBox()
+    bbox.update(factory.createPoint(new Coordinate(10, 20)))
+    bbox.update(factory.createPolygon())
+    assert(Seq(bbox.minX, bbox.minY, bbox.maxX, bbox.maxY) == Seq(10, 20, 10, 20))
+  }
+}