Skip to content

Commit 5ce6fc4

Browse files
author
Xin Huang
committed
✨ feat(api): Add WKB bounding box scanner
- Accumulate XY bounds across all OGC WKB geometry types\n- Validate byte order, dimensions, counts, truncation, and nesting limits Generated-by: Codex
1 parent e0afb38 commit 5ce6fc4

2 files changed

Lines changed: 355 additions & 0 deletions

File tree

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
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. A {@code NaN} value is skipped independently in its coordinate dimension,
32+
* matching the spec rule that null or NaN values in a dimension do not contribute to bounds (an
33+
* empty 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>NaN values are ignored independently for each dimension. An accumulator reports {@link
170+
* #hasBounds()} as {@code true} only after both dimensions have seen a non-NaN value.
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 hasXBounds = false;
178+
private boolean hasYBounds = false;
179+
180+
/**
181+
* Folds a single coordinate into the box, ignoring {@code NaN} values independently in each
182+
* dimension.
183+
*
184+
* @param xCoord the X coordinate
185+
* @param yCoord the Y coordinate
186+
*/
187+
public void addXY(double xCoord, double yCoord) {
188+
if (!Double.isNaN(xCoord)) {
189+
minX = Math.min(minX, xCoord);
190+
maxX = Math.max(maxX, xCoord);
191+
hasXBounds = true;
192+
}
193+
194+
if (!Double.isNaN(yCoord)) {
195+
minY = Math.min(minY, yCoord);
196+
maxY = Math.max(maxY, yCoord);
197+
hasYBounds = true;
198+
}
199+
}
200+
201+
/** Returns whether both dimensions have accumulated a non-NaN value. */
202+
public boolean hasBounds() {
203+
return hasXBounds && hasYBounds;
204+
}
205+
206+
/**
207+
* Returns the lower corner (minimum X and Y) of the box, or {@code null} if either dimension
208+
* has no accumulated value.
209+
*/
210+
public GeospatialBound minBound() {
211+
return hasBounds() ? GeospatialBound.createXY(minX, minY) : null;
212+
}
213+
214+
/**
215+
* Returns the upper corner (maximum X and Y) of the box, or {@code null} if either dimension
216+
* has no accumulated value.
217+
*/
218+
public GeospatialBound maxBound() {
219+
return hasBounds() ? GeospatialBound.createXY(maxX, maxY) : null;
220+
}
221+
}
222+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
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 static org.assertj.core.api.Assertions.assertThat;
22+
23+
import java.nio.ByteBuffer;
24+
import java.util.stream.Stream;
25+
import org.apache.iceberg.geospatial.WKBBoundingBox.XYAccumulator;
26+
import org.junit.jupiter.params.ParameterizedTest;
27+
import org.junit.jupiter.params.provider.Arguments;
28+
import org.junit.jupiter.params.provider.MethodSource;
29+
30+
public class TestWKBBoundingBox {
31+
32+
@ParameterizedTest(name = "{0}")
33+
@MethodSource("boundingBoxCases")
34+
public void testBoundingBox(
35+
String wkt, String hexWkb, GeospatialBound expectedMin, GeospatialBound expectedMax) {
36+
XYAccumulator accumulator = new XYAccumulator();
37+
WKBBoundingBox.accumulate(decode(hexWkb), accumulator);
38+
39+
assertThat(accumulator.minBound()).as(wkt).isEqualTo(expectedMin);
40+
assertThat(accumulator.maxBound()).as(wkt).isEqualTo(expectedMax);
41+
}
42+
43+
private static Stream<Arguments> boundingBoxCases() {
44+
return Stream.of(
45+
Arguments.of("POINT EMPTY", "0101000000000000000000f87f000000000000f87f", null, null),
46+
Arguments.of(
47+
"POINT(1 2)", "0101000000000000000000f03f0000000000000040", bound(1, 2), bound(1, 2)),
48+
Arguments.of(
49+
"POINT(1 2) big endian",
50+
"00000000013ff00000000000004000000000000000",
51+
bound(1, 2),
52+
bound(1, 2)),
53+
Arguments.of(
54+
"POINT Z (1 2 3)",
55+
"01e9030000000000000000f03f00000000000000400000000000000840",
56+
bound(1, 2),
57+
bound(1, 2)),
58+
Arguments.of(
59+
"POINT M (1 2 3)",
60+
"01d1070000000000000000f03f00000000000000400000000000000840",
61+
bound(1, 2),
62+
bound(1, 2)),
63+
Arguments.of(
64+
"POINT ZM (1 2 3 4)",
65+
"01b90b0000000000000000f03f000000000000004000000000000008400000000000001040",
66+
bound(1, 2),
67+
bound(1, 2)),
68+
Arguments.of(
69+
"LINESTRING(0 1,1 0,2 -1,-1 -2,0 1)",
70+
"0102000000050000000000000000000000000000000000f03f000000000000f03f"
71+
+ "00000000000000000000000000000040000000000000f0bf000000000000f0bf"
72+
+ "00000000000000c00000000000000000000000000000f03f",
73+
bound(-1, -2),
74+
bound(2, 1)),
75+
Arguments.of(
76+
"POLYGON((0 0,10 0,0 10,0 0),(1 1,1 2,2 1,1 1))",
77+
"010300000002000000040000000000000000000000000000000000000000000000"
78+
+ "000024400000000000000000000000000000000000000000000024400000000000"
79+
+ "000000000000000000000004000000000000000000f03f000000000000f03f0000"
80+
+ "00000000f03f00000000000000400000000000000040000000000000f03f000000"
81+
+ "000000f03f000000000000f03f",
82+
bound(0, 0),
83+
bound(10, 10)),
84+
Arguments.of(
85+
"MULTIPOINT((1 2),EMPTY,EMPTY,(3 4))",
86+
"0104000000040000000101000000000000000000f03f000000000000004001010000"
87+
+ "00000000000000f87f000000000000f87f0101000000000000000000f87f00000000"
88+
+ "0000f87f010100000000000000000008400000000000001040",
89+
bound(1, 2),
90+
bound(3, 4)),
91+
Arguments.of(
92+
"MULTILINESTRING((1 2,3 4),(5 6,7 8))",
93+
"010500000002000000010200000002000000000000000000f03f0000000000000040"
94+
+ "0000000000000840000000000000104001020000000200000000000000000014400000"
95+
+ "0000000018400000000000001c400000000000002040",
96+
bound(1, 2),
97+
bound(7, 8)),
98+
Arguments.of(
99+
"MULTIPOLYGON(EMPTY,((0 0,10 0,0 10,0 0),(1 1,1 2,2 1,1 1)))",
100+
"01060000000200000001030000000000000001030000000200000004000000000000000000000000000"
101+
+ "00000000000000000000000244000000000000000000000000000000000000000000000244000000000"
102+
+ "00000000000000000000000004000000000000000000f03f000000000000f03f000000000000f03f000"
103+
+ "00000000000400000000000000040000000000000f03f000000000000f03f000000000000f03f",
104+
bound(0, 0),
105+
bound(10, 10)),
106+
Arguments.of(
107+
"GEOMETRYCOLLECTION(POINT(1 2),LINESTRING EMPTY,POLYGON EMPTY,"
108+
+ "MULTIPOINT EMPTY,MULTILINESTRING EMPTY,MULTIPOLYGON EMPTY,"
109+
+ "GEOMETRYCOLLECTION(POINT EMPTY,LINESTRING EMPTY,POLYGON EMPTY,"
110+
+ "MULTIPOINT EMPTY,MULTILINESTRING EMPTY,MULTIPOLYGON EMPTY))",
111+
"0107000000070000000101000000000000000000f03f000000000000004001020000"
112+
+ "00000000000103000000000000000104000000000000000105000000000000000106"
113+
+ "000000000000000107000000060000000101000000000000000000f87f0000000000"
114+
+ "00f87f01020000000000000001030000000000000001040000000000000001050000"
115+
+ "0000000000010600000000000000",
116+
bound(1, 2),
117+
bound(1, 2)));
118+
}
119+
120+
private static GeospatialBound bound(double x, double y) {
121+
return GeospatialBound.createXY(x, y);
122+
}
123+
124+
private static ByteBuffer decode(String hex) {
125+
byte[] bytes = new byte[hex.length() / 2];
126+
for (int i = 0; i < bytes.length; i += 1) {
127+
int offset = i * 2;
128+
bytes[i] = (byte) Integer.parseInt(hex.substring(offset, offset + 2), 16);
129+
}
130+
131+
return ByteBuffer.wrap(bytes);
132+
}
133+
}

0 commit comments

Comments
 (0)