Skip to content

Commit 4a1d8e8

Browse files
author
Xin Huang
committed
Parquet: Compute geometry bounding box metrics
The Parquet footer's lexicographic min/max over WKB bytes is not meaningful for geometry, so geometry columns previously wrote counts only and could not be data skipped. Scan each value's coordinates as it is written and accumulate a 2D (XY) bounding box, emitting it as a FieldMetrics through the writer-side metrics channel (the same path float and double use for NaN counts). The box's lower and upper corners serialize into the existing lower_bounds and upper_bounds maps via the geometry conversion. A new pure-Java WKB coordinate scanner (WKBBoundingBox, in api/geospatial, no JTS dependency) walks all OGC geometry types, skips Z and M, ignores NaN coordinates, and validates the buffer defensively. Geography, higher dimensions, and the content_stats geo struct bounds are left as follow-ups.
1 parent 5926f4f commit 4a1d8e8

7 files changed

Lines changed: 853 additions & 7 deletions

File tree

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.iceberg.geospatial;
20+
21+
import java.nio.ByteBuffer;
22+
import java.nio.ByteOrder;
23+
import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
24+
25+
/**
26+
* Computes a two-dimensional (XY) bounding box from geometry values encoded as Well-Known Binary
27+
* (WKB).
28+
*
29+
* <p>The bounding box is the minimum axis-aligned rectangle that contains every coordinate of the
30+
* geometries. Only the X and Y dimensions are considered; any Z or M values present in the WKB are
31+
* read past and ignored. Coordinates whose X or Y is {@code NaN} are skipped, matching the spec
32+
* rule that null or NaN values in a coordinate dimension do not contribute to bounds (an empty
33+
* geometry such as {@code POINT EMPTY} therefore contributes nothing).
34+
*
35+
* <p>Parsing follows the OGC Simple Feature Access WKB layout and validates the input defensively:
36+
* a malformed or truncated buffer results in an {@link IllegalArgumentException} rather than an
37+
* out-of-bounds read.
38+
*/
39+
public class WKBBoundingBox {
40+
41+
// OGC WKB base geometry type codes (after stripping the dimension offset).
42+
private static final int TYPE_POINT = 1;
43+
private static final int TYPE_LINE_STRING = 2;
44+
private static final int TYPE_POLYGON = 3;
45+
private static final int TYPE_MULTI_POINT = 4;
46+
private static final int TYPE_MULTI_LINE_STRING = 5;
47+
private static final int TYPE_MULTI_POLYGON = 6;
48+
private static final int TYPE_GEOMETRY_COLLECTION = 7;
49+
50+
// Bounds the recursion depth for nested collections to reject pathological or malicious input.
51+
private static final int MAX_DEPTH = 100;
52+
53+
private WKBBoundingBox() {}
54+
55+
/**
56+
* Parses one WKB geometry and folds all of its XY coordinates into the given accumulator.
57+
*
58+
* <p>The buffer is read via a duplicate, so the caller's position and limit are left unchanged.
59+
*
60+
* @param wkb a buffer containing a single WKB geometry
61+
* @param accumulator the accumulator to update with the geometry's coordinates
62+
* @throws IllegalArgumentException if the buffer is not a well-formed WKB geometry
63+
*/
64+
public static void accumulate(ByteBuffer wkb, XYAccumulator accumulator) {
65+
Preconditions.checkArgument(wkb != null, "Invalid WKB buffer: null");
66+
Preconditions.checkArgument(accumulator != null, "Invalid accumulator: null");
67+
ByteBuffer buffer = wkb.duplicate();
68+
parseGeometry(buffer, accumulator, 0);
69+
}
70+
71+
private static void parseGeometry(ByteBuffer buffer, XYAccumulator accumulator, int depth) {
72+
Preconditions.checkArgument(depth <= MAX_DEPTH, "Invalid WKB: nesting too deep");
73+
checkRemaining(buffer, 5);
74+
75+
byte order = buffer.get();
76+
if (order == 0) {
77+
buffer.order(ByteOrder.BIG_ENDIAN);
78+
} else if (order == 1) {
79+
buffer.order(ByteOrder.LITTLE_ENDIAN);
80+
} else {
81+
throw new IllegalArgumentException("Invalid WKB byte order: " + order);
82+
}
83+
84+
long typeCode = buffer.getInt() & 0xFFFFFFFFL;
85+
int geometryType = (int) (typeCode % 1000);
86+
int dimensionGroup = (int) (typeCode / 1000);
87+
int numDimensions = numDimensions(dimensionGroup, typeCode);
88+
89+
switch (geometryType) {
90+
case TYPE_POINT:
91+
readCoordinate(buffer, accumulator, numDimensions);
92+
break;
93+
case TYPE_LINE_STRING:
94+
readCoordinateSequence(buffer, accumulator, numDimensions);
95+
break;
96+
case TYPE_POLYGON:
97+
int numRings = readCount(buffer);
98+
for (int i = 0; i < numRings; i += 1) {
99+
readCoordinateSequence(buffer, accumulator, numDimensions);
100+
}
101+
break;
102+
case TYPE_MULTI_POINT:
103+
case TYPE_MULTI_LINE_STRING:
104+
case TYPE_MULTI_POLYGON:
105+
case TYPE_GEOMETRY_COLLECTION:
106+
int numElements = readCount(buffer);
107+
for (int i = 0; i < numElements; i += 1) {
108+
// Each child carries its own byte-order and type header.
109+
parseGeometry(buffer, accumulator, depth + 1);
110+
}
111+
break;
112+
default:
113+
throw new IllegalArgumentException("Invalid or unsupported WKB geometry type: " + typeCode);
114+
}
115+
}
116+
117+
private static int numDimensions(int dimensionGroup, long typeCode) {
118+
switch (dimensionGroup) {
119+
case 0: // XY
120+
return 2;
121+
case 1: // XYZ
122+
case 2: // XYM
123+
return 3;
124+
case 3: // XYZM
125+
return 4;
126+
default:
127+
throw new IllegalArgumentException("Invalid or unsupported WKB geometry type: " + typeCode);
128+
}
129+
}
130+
131+
private static void readCoordinateSequence(
132+
ByteBuffer buffer, XYAccumulator accumulator, int numDimensions) {
133+
int numPoints = readCount(buffer);
134+
// Validate the full extent up front so a bogus point count cannot drive a long read loop, but
135+
// never pre-allocate from the count itself.
136+
checkRemaining(buffer, (long) numPoints * numDimensions * Double.BYTES);
137+
for (int i = 0; i < numPoints; i += 1) {
138+
readCoordinate(buffer, accumulator, numDimensions);
139+
}
140+
}
141+
142+
private static void readCoordinate(
143+
ByteBuffer buffer, XYAccumulator accumulator, int numDimensions) {
144+
checkRemaining(buffer, (long) numDimensions * Double.BYTES);
145+
double xCoord = buffer.getDouble();
146+
double yCoord = buffer.getDouble();
147+
// Skip any Z and/or M values; only X and Y contribute to the box.
148+
for (int i = 2; i < numDimensions; i += 1) {
149+
buffer.getDouble();
150+
}
151+
accumulator.addXY(xCoord, yCoord);
152+
}
153+
154+
private static int readCount(ByteBuffer buffer) {
155+
checkRemaining(buffer, Integer.BYTES);
156+
long count = buffer.getInt() & 0xFFFFFFFFL;
157+
Preconditions.checkArgument(count <= Integer.MAX_VALUE, "Invalid WKB element count: %s", count);
158+
return (int) count;
159+
}
160+
161+
private static void checkRemaining(ByteBuffer buffer, long bytes) {
162+
Preconditions.checkArgument(
163+
buffer.remaining() >= bytes, "Invalid WKB: unexpected end of buffer");
164+
}
165+
166+
/**
167+
* A mutable accumulator of the minimum and maximum X and Y coordinates seen so far.
168+
*
169+
* <p>Coordinates whose X or Y is {@code NaN} are ignored. An accumulator that has seen no non-NaN
170+
* coordinate reports {@link #hasBounds()} as {@code false} and returns {@code null} bounds.
171+
*/
172+
public static class XYAccumulator {
173+
private double minX = Double.POSITIVE_INFINITY;
174+
private double minY = Double.POSITIVE_INFINITY;
175+
private double maxX = Double.NEGATIVE_INFINITY;
176+
private double maxY = Double.NEGATIVE_INFINITY;
177+
private boolean hasBounds = false;
178+
179+
/**
180+
* Folds a single coordinate into the box, ignoring it if either dimension is {@code NaN}.
181+
*
182+
* @param xCoord the X coordinate
183+
* @param yCoord the Y coordinate
184+
*/
185+
public void addXY(double xCoord, double yCoord) {
186+
if (Double.isNaN(xCoord) || Double.isNaN(yCoord)) {
187+
return;
188+
}
189+
minX = Math.min(minX, xCoord);
190+
minY = Math.min(minY, yCoord);
191+
maxX = Math.max(maxX, xCoord);
192+
maxY = Math.max(maxY, yCoord);
193+
hasBounds = true;
194+
}
195+
196+
/** Returns whether any non-NaN coordinate has been accumulated. */
197+
public boolean hasBounds() {
198+
return hasBounds;
199+
}
200+
201+
/**
202+
* Returns the lower corner (minimum X and Y) of the box, or {@code null} if no coordinate has
203+
* been accumulated.
204+
*/
205+
public GeospatialBound minBound() {
206+
return hasBounds ? GeospatialBound.createXY(minX, minY) : null;
207+
}
208+
209+
/**
210+
* Returns the upper corner (maximum X and Y) of the box, or {@code null} if no coordinate has
211+
* been accumulated.
212+
*/
213+
public GeospatialBound maxBound() {
214+
return hasBounds ? GeospatialBound.createXY(maxX, maxY) : null;
215+
}
216+
}
217+
}

0 commit comments

Comments
 (0)