Skip to content

Geospatial Support

Hardwood reads the Parquet geospatial metadata layer (GEOMETRY / GEOGRAPHY logical types and per-chunk GeospatialStatistics) and offers a bounding-box filter predicate that pushes spatial selectivity down to the row group level. Hardwood does not decode WKB payloads itself. The snippets below decode WKB with the JTS Topology Suite; any WKB decoder works.

Try it yourself

Want to run it or explore the capabilities yourself? The Geospatial example reads a GEOMETRY column, pushes a bounding-box filter down to the row-group level, and decodes WKB points with JTS.

To use JTS in the examples below, add the jts-core dependency:

<dependency>
    <groupId>org.locationtech.jts</groupId>
    <artifactId>jts-core</artifactId>
    <version>1.20.0</version>
</dependency>

Identifying Geospatial Columns

GEOMETRY (planar) and GEOGRAPHY (geodesic on an ellipsoid) appear as LogicalType.GeometryType and LogicalType.GeographyType on the column schema. Both carry a CRS (defaulting to OGC:CRS84); GEOGRAPHY also carries an edge-interpolation algorithm, defaulting to SPHERICAL and reported as EdgeInterpolationAlgorithm.UNKNOWN when the file names one this version does not recognize.

import dev.hardwood.metadata.LogicalType;
import dev.hardwood.reader.ParquetFileReader;
import dev.hardwood.schema.ColumnSchema;
import dev.hardwood.schema.FileSchema;

try (ParquetFileReader reader = ParquetFileReader.open(InputFile.of(path))) {
    FileSchema schema = reader.getFileSchema();
    for (int i = 0; i < schema.getColumnCount(); i++) {
        ColumnSchema column = schema.getColumn(i);
        if (column.logicalType() instanceof LogicalType.GeometryType geomType) {
            System.out.println(column.name() + " is a GEOMETRY column, CRS: " + geomType.crs());
        }
        else if (column.logicalType() instanceof LogicalType.GeographyType geoType) {
            System.out.println(column.name() + " is a GEOGRAPHY column, CRS: " + geoType.crs()
                + ", edge interpolation: " + geoType.edgeInterpolation());
        }
    }
}

Bounding-Box Statistics

Per-chunk geospatial statistics are exposed on ColumnMetaData.geospatialStatistics(). The BoundingBox carries xmin/xmax/ymin/ymax (always present) plus optional zmin/zmax/mmin/mmax. For GEOGRAPHY columns, xmin > xmax is legal and indicates a chunk that wraps the antimeridian.

import dev.hardwood.metadata.BoundingBox;
import dev.hardwood.metadata.ColumnChunk;
import dev.hardwood.metadata.GeospatialStatistics;
import dev.hardwood.metadata.RowGroup;

for (RowGroup rowGroup : reader.getFileMetaData().rowGroups()) {
    for (ColumnChunk chunk : rowGroup.columns()) {
        GeospatialStatistics geo = chunk.metaData().geospatialStatistics();
        if (geo == null) {
            continue;
        }
        BoundingBox bbox = geo.bbox();
        if (bbox != null) {
            System.out.println("  bbox: x=[" + bbox.xmin() + ", " + bbox.xmax() + "], "
                + "y=[" + bbox.ymin() + ", " + bbox.ymax() + "]");
        }
        // Geospatial type codes (Point=1, LineString=2, Polygon=3, MultiPoint=4, etc.)
        System.out.println("  types: " + geo.geospatialTypes());
    }
}

Spatial Filter Pushdown

FilterPredicate.intersects(column, xmin, ymin, xmax, ymax) produces a predicate that drops row groups whose stored bounding box does not overlap the query box. The argument order follows the GeoJSON / WKT convention (bottom-left corner, then top-right corner). Antimeridian wrapping is handled both for a stored box and for a query box with xmin > xmax, which covers x >= xmin or x <= xmax on a GEOMETRY column as on a GEOGRAPHY one. A NaN bound throws IllegalArgumentException when the predicate is built.

import dev.hardwood.reader.FilterPredicate;
import dev.hardwood.reader.ParquetFileReader;
import dev.hardwood.reader.RowReader;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKBReader;

FilterPredicate filter = FilterPredicate.intersects("location", -25.0, 35.0, 45.0, 72.0);
try (ParquetFileReader fileReader = ParquetFileReader.open(InputFile.of(path));
        RowReader rowReader = fileReader.buildRowReader().filter(filter).build()) {
    WKBReader wkbReader = new WKBReader();
    while (rowReader.hasNext()) {
        rowReader.next();
        byte[] wkb = rowReader.getBinary("location");
        Geometry geom = wkbReader.read(wkb);
        // Apply any exact-geometry tests here.
    }
}

The predicate composes with the standard and / or combinators, e.g.:

FilterPredicate.and(
    FilterPredicate.intersects("location", -5.0, 50.0, 2.0, 60.0),
    FilterPredicate.gt("population", 100_000));

Limitations

intersects is coarse-grained: Hardwood drops row groups whose bounding box is disjoint from the query box, but every row in a surviving row group is returned. Apply your own per-row check on the WKB payload (e.g. via JTS) when you need exact geometric filtering.

Negation of intersects is not supported: FilterPredicate.not(FilterPredicate.intersects(...)), or not over any predicate holding one, throws IllegalArgumentException at reader creation, naming the column. If you need "geometries outside this box", read without a spatial filter and apply the negation yourself against getBinary("location").

Both limitations are tracked by hardwood#414.