Skip to content

Commit 7a7afef

Browse files
committed
#78 Support geospatial statistics and bounding box metadata
1 parent 4d8674a commit 7a7afef

44 files changed

Lines changed: 338 additions & 27 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package dev.hardwood.internal.thrift;
2+
3+
import java.io.IOException;
4+
5+
import dev.hardwood.metadata.BoundingBox;
6+
7+
/// Reader for the Thrift BoundingBox struct from Parquet metadata.
8+
public class BoundingBoxReader {
9+
public static BoundingBox read(ThriftCompactReader reader) throws IOException {
10+
short saved = reader.pushFieldIdContext();
11+
try {
12+
return readInternal(reader);
13+
}
14+
finally {
15+
reader.popFieldIdContext(saved);
16+
}
17+
}
18+
19+
private static BoundingBox readInternal(ThriftCompactReader reader) throws IOException {
20+
Double xmin = null;
21+
Double xmax = null;
22+
Double ymin = null;
23+
Double ymax = null;
24+
Double zmin = null;
25+
Double zmax = null;
26+
Double mmin = null;
27+
Double mmax = null;
28+
while(true) {
29+
ThriftCompactReader.FieldHeader header = reader.readFieldHeader();
30+
if (header == null) {
31+
break;
32+
}
33+
34+
switch (header.fieldId()) {
35+
case 1:
36+
xmin = readField(header, reader);
37+
break;
38+
case 2:
39+
xmax = readField(header, reader);
40+
break;
41+
case 3:
42+
ymin = readField(header, reader);
43+
break;
44+
case 4:
45+
ymax = readField(header, reader);
46+
break;
47+
case 5:
48+
zmin = readField(header, reader);
49+
break;
50+
case 6:
51+
zmax = readField(header, reader);
52+
break;
53+
case 7:
54+
mmin = readField(header, reader);
55+
break;
56+
case 8:
57+
mmax = readField(header, reader);
58+
break;
59+
default:
60+
reader.skipField(header.type());
61+
break;
62+
}
63+
}
64+
if(xmin == null || xmax == null || ymin == null || ymax == null)
65+
return null;
66+
else
67+
return new BoundingBox(xmin, xmax, ymin, ymax, zmin, zmax, mmin, mmax);
68+
}
69+
70+
private static Double readField(ThriftCompactReader.FieldHeader header, ThriftCompactReader reader) throws IOException {
71+
Double value = null;
72+
if(header.type() == 0x07) {
73+
value = reader.readDouble();
74+
}
75+
else {
76+
reader.skipField(header.type());
77+
}
78+
79+
return value;
80+
}
81+
}

core/src/main/java/dev/hardwood/internal/thrift/ColumnMetaDataReader.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import dev.hardwood.metadata.CompressionCodec;
1818
import dev.hardwood.metadata.Encoding;
1919
import dev.hardwood.metadata.FieldPath;
20+
import dev.hardwood.metadata.GeospatialStatistics;
2021
import dev.hardwood.metadata.PhysicalType;
2122
import dev.hardwood.metadata.Statistics;
2223

@@ -45,6 +46,7 @@ private static ColumnMetaData readInternal(ThriftCompactReader reader) throws IO
4546
long dataPageOffset = 0;
4647
Long dictionaryPageOffset = null;
4748
Statistics statistics = null;
49+
GeospatialStatistics geospatialStatistics = null;
4850

4951
while (true) {
5052
ThriftCompactReader.FieldHeader header = reader.readFieldHeader();
@@ -150,6 +152,14 @@ private static ColumnMetaData readInternal(ThriftCompactReader reader) throws IO
150152
reader.skipField(header.type());
151153
}
152154
break;
155+
case 17: // geospatial statistics (optional)
156+
if(header.type() == 0x0C) {
157+
geospatialStatistics = GeospatialStatisticsReader.read(reader);
158+
}
159+
else {
160+
reader.skipField(header.type());
161+
}
162+
break;
153163
default:
154164
reader.skipField(header.type());
155165
break;
@@ -158,6 +168,6 @@ private static ColumnMetaData readInternal(ThriftCompactReader reader) throws IO
158168

159169
return new ColumnMetaData(type, encodings, new FieldPath(List.copyOf(pathInSchema)), codec, numValues,
160170
totalUncompressedSize, totalCompressedSize, keyValueMetadata, dataPageOffset, dictionaryPageOffset,
161-
statistics);
171+
statistics, geospatialStatistics);
162172
}
163173
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package dev.hardwood.internal.thrift;
2+
3+
import java.io.IOException;
4+
import java.util.ArrayList;
5+
import java.util.List;
6+
7+
import dev.hardwood.metadata.BoundingBox;
8+
import dev.hardwood.metadata.GeospatialStatistics;
9+
10+
/// Reader for the Thrift GeospatialStatistics struct from Parquet metadata.
11+
public class GeospatialStatisticsReader {
12+
13+
public static GeospatialStatistics read(ThriftCompactReader reader) throws IOException {
14+
short saved = reader.pushFieldIdContext();
15+
try {
16+
return readInternal(reader);
17+
}
18+
finally {
19+
reader.popFieldIdContext(saved);
20+
}
21+
}
22+
23+
private static GeospatialStatistics readInternal(ThriftCompactReader reader) throws IOException {
24+
BoundingBox bbox = null;
25+
List<Integer> geospatialTypes = new ArrayList<>();
26+
27+
while(true) {
28+
ThriftCompactReader.FieldHeader header = reader.readFieldHeader();
29+
if (header == null) {
30+
break;
31+
}
32+
33+
switch (header.fieldId()) {
34+
case 1:
35+
if(header.type() == 0x0C) {
36+
bbox = BoundingBoxReader.read(reader);
37+
}
38+
else {
39+
reader.skipField(header.type());
40+
}
41+
break;
42+
case 2:
43+
if(header.type() == 0x09) {
44+
ThriftCompactReader.CollectionHeader listHeader = reader.readListHeader();
45+
for (int i = 0; i < listHeader.size(); i++) {
46+
geospatialTypes.add(reader.readI32());
47+
}
48+
}
49+
else {
50+
reader.skipField(header.type());
51+
}
52+
break;
53+
default:
54+
reader.skipField(header.type());
55+
break;
56+
}
57+
}
58+
59+
return new GeospatialStatistics(bbox, List.copyOf(geospatialTypes));
60+
}
61+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package dev.hardwood.metadata;
2+
3+
/// @param xmin x coordinate of bottom-left vertex
4+
/// @param ymin y coordinate of bottom-left vertex
5+
/// @param xmax x coordinate of top-right vertex
6+
/// @param ymax y coordinate of top-right vertex
7+
/// @param zmin minimum height of bounded volume, or `null` if absent
8+
/// @param zmax maximum height of bounded volume, or `null` if absent
9+
/// @param mmin minimum of a value in 4th dimension, or `null` if absent
10+
/// @param mmax maximum of a value in 4th dimension, or `null` if absent
11+
/// @see <a href="https://github.com/apache/parquet-format/blob/master/Geospatial.md#bounding-box">Geospatial - bounding-box</a>
12+
public record BoundingBox(
13+
Double xmin,
14+
Double xmax,
15+
Double ymin,
16+
Double ymax,
17+
Double zmin,
18+
Double zmax,
19+
Double mmin,
20+
Double mmax) {
21+
}

core/src/main/java/dev/hardwood/metadata/ColumnMetaData.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
/// @param dataPageOffset byte offset in the file where the first data page begins
2424
/// @param dictionaryPageOffset byte offset in the file where the dictionary page begins, or `null` if there is no dictionary page
2525
/// @param statistics column chunk statistics (min/max values, null count, distinct count), or `null` if absent
26+
/// @param geospatialStatistics column chunk geospatial statistics (bounding box, geospatial types), or `null` if absent
2627
/// @see <a href="https://parquet.apache.org/docs/file-format/data-pages/columnchunks/">File Format – Column Chunks</a>
2728
/// @see <a href="https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift">parquet.thrift</a>
2829
public record ColumnMetaData(
@@ -36,5 +37,6 @@ public record ColumnMetaData(
3637
Map<String, String> keyValueMetadata,
3738
long dataPageOffset,
3839
Long dictionaryPageOffset,
39-
Statistics statistics) {
40+
Statistics statistics,
41+
GeospatialStatistics geospatialStatistics) {
4042
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package dev.hardwood.metadata;
2+
3+
import java.util.List;
4+
5+
/// @param bbox bounding box, or `null` if absent
6+
/// @param geospatialTypes list of geospatial type for geometry/geography column, empty list if not known
7+
/// @see <a href="https://github.com/apache/parquet-format/blob/master/Geospatial.md#statistics">Geospatial – statistics</a>
8+
/// @see <a href="https://github.com/apache/parquet-format/blob/master/Geospatial.md#geospatial-types">Geospatial - types</a>
9+
public record GeospatialStatistics(
10+
BoundingBox bbox,
11+
List<Integer> geospatialTypes
12+
) {
13+
}

core/src/test/java/dev/hardwood/FilterPredicateTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -410,15 +410,15 @@ private static RowGroup createRowGroupWithStats(PhysicalType type, byte[] min, b
410410
Statistics stats = new Statistics(min, max, 0L, null, false);
411411
ColumnMetaData cmd = new ColumnMetaData(
412412
type, List.of(Encoding.PLAIN), FieldPath.of("col"),
413-
CompressionCodec.UNCOMPRESSED, 100, 1000, 1000, Map.of(), 0, null, stats);
413+
CompressionCodec.UNCOMPRESSED, 100, 1000, 1000, Map.of(), 0, null, stats, null);
414414
ColumnChunk chunk = new ColumnChunk(cmd, null, null, null, null);
415415
return new RowGroup(List.of(chunk), 1000, 100);
416416
}
417417

418418
private static RowGroup createRowGroupWithoutStatistics() {
419419
ColumnMetaData cmd = new ColumnMetaData(
420420
PhysicalType.INT32, List.of(Encoding.PLAIN), FieldPath.of("col"),
421-
CompressionCodec.UNCOMPRESSED, 100, 1000, 1000, Map.of(), 0, null, null);
421+
CompressionCodec.UNCOMPRESSED, 100, 1000, 1000, Map.of(), 0, null, null, null);
422422
ColumnChunk chunk = new ColumnChunk(cmd, null, null, null, null);
423423
return new RowGroup(List.of(chunk), 1000, 100);
424424
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package dev.hardwood;
2+
3+
import java.nio.file.Path;
4+
import java.nio.file.Paths;
5+
6+
import org.junit.jupiter.api.Test;
7+
8+
import dev.hardwood.metadata.*;
9+
import dev.hardwood.reader.ParquetFileReader;
10+
11+
import static org.assertj.core.api.Assertions.assertThat;
12+
13+
public class GeospatialStatisticsTest {
14+
15+
@Test
16+
public void tesGeospatialStatisticsAreReadCorrectly() throws Exception {
17+
Path parquetFile = Paths.get("src/test/resources/geospatial_stats_test.parquet");
18+
19+
try (ParquetFileReader reader = ParquetFileReader.open(InputFile.of(parquetFile))) {
20+
FileMetaData metadata = reader.getFileMetaData();
21+
assertThat(metadata.rowGroups()).hasSize(1);
22+
23+
RowGroup rg0 = metadata.rowGroups().get(0);
24+
assertThat(rg0.columns().get(0).metaData().geospatialStatistics()).isNull();
25+
ColumnChunk cc = rg0.columns().get(1);
26+
ColumnMetaData md = cc.metaData();
27+
GeospatialStatistics geospatialStatistics = md.geospatialStatistics();
28+
assertThat(geospatialStatistics).isNotNull();
29+
assertThat(geospatialStatistics.bbox().xmin()).isEqualTo(-4.0);
30+
assertThat(geospatialStatistics.bbox().xmax()).isEqualTo(7.5);
31+
assertThat(geospatialStatistics.bbox().ymin()).isEqualTo(20.96);
32+
assertThat(geospatialStatistics.bbox().ymax()).isEqualTo(77.08);
33+
assertThat(geospatialStatistics.bbox().zmin()).isEqualTo(10.5);
34+
assertThat(geospatialStatistics.bbox().zmax()).isEqualTo(90.0);
35+
assertThat(geospatialStatistics.bbox().mmin()).isNull();
36+
assertThat(geospatialStatistics.bbox().mmax()).isNull();
37+
assertThat(geospatialStatistics.geospatialTypes().size()).isEqualTo(2);
38+
assertThat(geospatialStatistics.geospatialTypes()).containsExactly(1, 6);
39+
}
40+
}
41+
}

core/src/test/java/dev/hardwood/internal/reader/PageRangeTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,15 +206,15 @@ private static ColumnChunk chunkWithoutDict(long dataPageOffset) {
206206
ColumnMetaData meta = new ColumnMetaData(
207207
PhysicalType.INT64, List.of(Encoding.PLAIN), FieldPath.of("col"),
208208
CompressionCodec.UNCOMPRESSED, 100, 1000, 1000, Map.of(),
209-
dataPageOffset, null, null);
209+
dataPageOffset, null, null, null);
210210
return new ColumnChunk(meta, null, null, null, null);
211211
}
212212

213213
private static ColumnChunk chunkWithDict(long dataPageOffset, long dictOffset) {
214214
ColumnMetaData meta = new ColumnMetaData(
215215
PhysicalType.INT64, List.of(Encoding.PLAIN), FieldPath.of("col"),
216216
CompressionCodec.UNCOMPRESSED, 100, 1000, 1000, Map.of(),
217-
dataPageOffset, dictOffset, null);
217+
dataPageOffset, dictOffset, null, null);
218218
return new ColumnChunk(meta, null, null, null, null);
219219
}
220220

0 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)