Skip to content

Commit 37d325b

Browse files
author
Xin Huang
committed
✨ feat(api): Add WKB geometry bounds
- Accumulate two-dimensional bounds across ISO WKB geometry values - Reject unsupported dimensions and malformed structures Generated-by: Codex
1 parent e0afb38 commit 37d325b

2 files changed

Lines changed: 364 additions & 0 deletions

File tree

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
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+
* Accumulates geometry bounds from values encoded as Well-Known Binary (WKB).
27+
*
28+
* <p>Coordinates are tracked independently for the X and Y dimensions. Null or {@code NaN} values
29+
* do not contribute to a dimension, and no bounds are produced unless both dimensions are present.
30+
* WKB values with Z or M dimensions are not currently supported.
31+
*/
32+
public final class WKBGeometryBounds {
33+
34+
private static final int TYPE_POINT = 1;
35+
private static final int TYPE_LINE_STRING = 2;
36+
private static final int TYPE_POLYGON = 3;
37+
private static final int TYPE_MULTI_POINT = 4;
38+
private static final int TYPE_MULTI_LINE_STRING = 5;
39+
private static final int TYPE_MULTI_POLYGON = 6;
40+
private static final int TYPE_GEOMETRY_COLLECTION = 7;
41+
private static final int ANY_GEOMETRY = 0;
42+
43+
private static final int MAX_DEPTH = 100;
44+
45+
private final DimensionBounds xBounds = new DimensionBounds();
46+
private final DimensionBounds yBounds = new DimensionBounds();
47+
48+
/**
49+
* Adds the coordinates from one WKB geometry to these bounds.
50+
*
51+
* <p>The input is read through a duplicate, so its position and limit are left unchanged.
52+
*
53+
* @param wkb a buffer containing exactly one WKB geometry
54+
* @throws IllegalArgumentException if the WKB is invalid or unsupported
55+
*/
56+
public void add(ByteBuffer wkb) {
57+
Preconditions.checkArgument(wkb != null, "Invalid WKB buffer: null");
58+
ByteBuffer buffer = wkb.duplicate();
59+
parseGeometry(buffer, 0, ANY_GEOMETRY);
60+
Preconditions.checkArgument(!buffer.hasRemaining(), "Invalid WKB: trailing data");
61+
}
62+
63+
/** Returns whether both X and Y have accumulated a non-NaN value. */
64+
public boolean hasBounds() {
65+
return xBounds.hasValue() && yBounds.hasValue();
66+
}
67+
68+
/** Returns the lower bound, or {@code null} if either X or Y has no value. */
69+
public GeospatialBound lowerBound() {
70+
return hasBounds() ? GeospatialBound.createXY(xBounds.lower(), yBounds.lower()) : null;
71+
}
72+
73+
/** Returns the upper bound, or {@code null} if either X or Y has no value. */
74+
public GeospatialBound upperBound() {
75+
return hasBounds() ? GeospatialBound.createXY(xBounds.upper(), yBounds.upper()) : null;
76+
}
77+
78+
private void parseGeometry(ByteBuffer buffer, int depth, int expectedType) {
79+
Preconditions.checkArgument(depth <= MAX_DEPTH, "Invalid WKB: nesting too deep");
80+
checkRemaining(buffer, 5);
81+
82+
byte order = buffer.get();
83+
if (order == 0) {
84+
buffer.order(ByteOrder.BIG_ENDIAN);
85+
} else if (order == 1) {
86+
buffer.order(ByteOrder.LITTLE_ENDIAN);
87+
} else {
88+
throw new IllegalArgumentException("Invalid WKB byte order: " + order);
89+
}
90+
91+
long typeCode = buffer.getInt() & 0xFFFFFFFFL;
92+
int geometryType = (int) (typeCode % 1000);
93+
Preconditions.checkArgument(
94+
typeCode / 1000 == 0, "Unsupported WKB: only 2D geometries are supported");
95+
Preconditions.checkArgument(
96+
expectedType == ANY_GEOMETRY || geometryType == expectedType,
97+
"Invalid WKB: expected geometry type %s but found %s",
98+
expectedType,
99+
geometryType);
100+
101+
switch (geometryType) {
102+
case TYPE_POINT -> readCoordinate(buffer);
103+
case TYPE_LINE_STRING -> readCoordinateSequence(buffer, true);
104+
case TYPE_POLYGON -> {
105+
int numRings = readCount(buffer);
106+
if (numRings > 0) {
107+
readCoordinateSequence(buffer, true);
108+
}
109+
110+
for (int i = 1; i < numRings; i += 1) {
111+
readCoordinateSequence(buffer, false);
112+
}
113+
}
114+
case TYPE_MULTI_POINT -> readCollection(buffer, depth, TYPE_POINT);
115+
case TYPE_MULTI_LINE_STRING -> readCollection(buffer, depth, TYPE_LINE_STRING);
116+
case TYPE_MULTI_POLYGON -> readCollection(buffer, depth, TYPE_POLYGON);
117+
case TYPE_GEOMETRY_COLLECTION -> readCollection(buffer, depth, ANY_GEOMETRY);
118+
default ->
119+
throw new IllegalArgumentException(
120+
"Invalid or unsupported WKB geometry type: " + typeCode);
121+
}
122+
}
123+
124+
private void readCollection(ByteBuffer buffer, int depth, int expectedChildType) {
125+
int numElements = readCount(buffer);
126+
for (int i = 0; i < numElements; i += 1) {
127+
parseGeometry(buffer, depth + 1, expectedChildType);
128+
}
129+
}
130+
131+
private void readCoordinateSequence(ByteBuffer buffer, boolean updateBounds) {
132+
int numPoints = readCount(buffer);
133+
long numBytes = (long) numPoints * 2 * Double.BYTES;
134+
checkRemaining(buffer, numBytes);
135+
if (!updateBounds) {
136+
buffer.position(buffer.position() + (int) numBytes);
137+
return;
138+
}
139+
140+
for (int i = 0; i < numPoints; i += 1) {
141+
readCoordinate(buffer);
142+
}
143+
}
144+
145+
private void readCoordinate(ByteBuffer buffer) {
146+
checkRemaining(buffer, 2 * Double.BYTES);
147+
double x = buffer.getDouble();
148+
double y = buffer.getDouble();
149+
xBounds.add(x);
150+
yBounds.add(y);
151+
}
152+
153+
private static int readCount(ByteBuffer buffer) {
154+
checkRemaining(buffer, Integer.BYTES);
155+
long count = buffer.getInt() & 0xFFFFFFFFL;
156+
Preconditions.checkArgument(count <= Integer.MAX_VALUE, "Invalid WKB element count: %s", count);
157+
return (int) count;
158+
}
159+
160+
private static void checkRemaining(ByteBuffer buffer, long bytes) {
161+
Preconditions.checkArgument(
162+
buffer.remaining() >= bytes, "Invalid WKB: unexpected end of buffer");
163+
}
164+
165+
private static class DimensionBounds {
166+
private double lower;
167+
private double upper;
168+
private boolean hasValue = false;
169+
170+
private void add(double value) {
171+
if (Double.isNaN(value)) {
172+
return;
173+
}
174+
175+
if (hasValue) {
176+
lower = Math.min(lower, value);
177+
upper = Math.max(upper, value);
178+
} else {
179+
lower = value;
180+
upper = value;
181+
hasValue = true;
182+
}
183+
}
184+
185+
private boolean hasValue() {
186+
return hasValue;
187+
}
188+
189+
private double lower() {
190+
return lower;
191+
}
192+
193+
private double upper() {
194+
return upper;
195+
}
196+
}
197+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
23+
24+
import java.nio.ByteBuffer;
25+
import java.util.stream.Stream;
26+
import org.junit.jupiter.api.Test;
27+
import org.junit.jupiter.params.ParameterizedTest;
28+
import org.junit.jupiter.params.provider.Arguments;
29+
import org.junit.jupiter.params.provider.MethodSource;
30+
31+
public class TestWKBGeometryBounds {
32+
33+
@ParameterizedTest(name = "{0}")
34+
@MethodSource("boundingBoxCases")
35+
public void testBoundingBox(
36+
String wkt, String hexWkb, GeospatialBound expectedLower, GeospatialBound expectedUpper) {
37+
WKBGeometryBounds bounds = new WKBGeometryBounds();
38+
ByteBuffer wkb = decode(hexWkb);
39+
int position = wkb.position();
40+
int limit = wkb.limit();
41+
42+
bounds.add(wkb);
43+
44+
assertThat(wkb.position()).as(wkt).isEqualTo(position);
45+
assertThat(wkb.limit()).as(wkt).isEqualTo(limit);
46+
assertThat(bounds.lowerBound()).as(wkt).isEqualTo(expectedLower);
47+
assertThat(bounds.upperBound()).as(wkt).isEqualTo(expectedUpper);
48+
}
49+
50+
@Test
51+
public void testBoundsAcrossValuesWithMissingCoordinates() {
52+
WKBGeometryBounds bounds = new WKBGeometryBounds();
53+
bounds.add(decode("0101000000000000000000f03f000000000000f87f"));
54+
bounds.add(decode("0101000000000000000000f87f0000000000000040"));
55+
56+
assertThat(bounds.lowerBound()).isEqualTo(xy(1, 2));
57+
assertThat(bounds.upperBound()).isEqualTo(xy(1, 2));
58+
}
59+
60+
@ParameterizedTest(name = "{0}")
61+
@MethodSource("invalidWkbCases")
62+
public void testInvalidWkb(String description, String hexWkb, String expectedMessage) {
63+
WKBGeometryBounds bounds = new WKBGeometryBounds();
64+
assertThatThrownBy(() -> bounds.add(decode(hexWkb)))
65+
.isInstanceOf(IllegalArgumentException.class)
66+
.hasMessageContaining(expectedMessage);
67+
}
68+
69+
private static Stream<Arguments> boundingBoxCases() {
70+
return Stream.of(
71+
Arguments.of("POINT EMPTY", "0101000000000000000000f87f000000000000f87f", null, null),
72+
Arguments.of(
73+
"POINT(1 2)", "0101000000000000000000f03f0000000000000040", xy(1, 2), xy(1, 2)),
74+
Arguments.of(
75+
"POINT(1 2) big endian",
76+
"00000000013ff00000000000004000000000000000",
77+
xy(1, 2),
78+
xy(1, 2)),
79+
Arguments.of(
80+
"LINESTRING(0 1,1 0,2 -1,-1 -2,0 1)",
81+
"0102000000050000000000000000000000000000000000f03f000000000000f03f"
82+
+ "00000000000000000000000000000040000000000000f0bf000000000000f0bf"
83+
+ "00000000000000c00000000000000000000000000000f03f",
84+
xy(-1, -2),
85+
xy(2, 1)),
86+
Arguments.of(
87+
"POLYGON((0 0,10 0,0 10,0 0),(1 1,1 2,2 1,1 1))",
88+
"010300000002000000040000000000000000000000000000000000000000000000"
89+
+ "000024400000000000000000000000000000000000000000000024400000000000"
90+
+ "000000000000000000000004000000000000000000f03f000000000000f03f0000"
91+
+ "00000000f03f00000000000000400000000000000040000000000000f03f000000"
92+
+ "000000f03f000000000000f03f",
93+
xy(0, 0),
94+
xy(10, 10)),
95+
Arguments.of(
96+
"MULTIPOINT((1 2),EMPTY,EMPTY,(3 4))",
97+
"0104000000040000000101000000000000000000f03f000000000000004001010000"
98+
+ "00000000000000f87f000000000000f87f0101000000000000000000f87f00000000"
99+
+ "0000f87f010100000000000000000008400000000000001040",
100+
xy(1, 2),
101+
xy(3, 4)),
102+
Arguments.of(
103+
"MULTILINESTRING((1 2,3 4),(5 6,7 8))",
104+
"010500000002000000010200000002000000000000000000f03f0000000000000040"
105+
+ "0000000000000840000000000000104001020000000200000000000000000014400000"
106+
+ "0000000018400000000000001c400000000000002040",
107+
xy(1, 2),
108+
xy(7, 8)),
109+
Arguments.of(
110+
"MULTIPOLYGON(EMPTY,((0 0,10 0,0 10,0 0),(1 1,1 2,2 1,1 1)))",
111+
"01060000000200000001030000000000000001030000000200000004000000000000000000000000000"
112+
+ "00000000000000000000000244000000000000000000000000000000000000000000000244000000000"
113+
+ "00000000000000000000000004000000000000000000f03f000000000000f03f000000000000f03f000"
114+
+ "00000000000400000000000000040000000000000f03f000000000000f03f000000000000f03f",
115+
xy(0, 0),
116+
xy(10, 10)),
117+
Arguments.of(
118+
"GEOMETRYCOLLECTION(POINT(1 2),LINESTRING EMPTY,POLYGON EMPTY,"
119+
+ "MULTIPOINT EMPTY,MULTILINESTRING EMPTY,MULTIPOLYGON EMPTY,"
120+
+ "GEOMETRYCOLLECTION(POINT EMPTY,LINESTRING EMPTY,POLYGON EMPTY,"
121+
+ "MULTIPOINT EMPTY,MULTILINESTRING EMPTY,MULTIPOLYGON EMPTY))",
122+
"0107000000070000000101000000000000000000f03f000000000000004001020000"
123+
+ "00000000000103000000000000000104000000000000000105000000000000000106"
124+
+ "000000000000000107000000060000000101000000000000000000f87f0000000000"
125+
+ "00f87f01020000000000000001030000000000000001040000000000000001050000"
126+
+ "0000000000010600000000000000",
127+
xy(1, 2),
128+
xy(1, 2)));
129+
}
130+
131+
private static Stream<Arguments> invalidWkbCases() {
132+
return Stream.of(
133+
Arguments.of(
134+
"trailing data", "0101000000000000000000f03f000000000000004000", "trailing data"),
135+
Arguments.of(
136+
"invalid multi-point child",
137+
"010400000001000000010200000000000000",
138+
"expected geometry type"),
139+
Arguments.of(
140+
"unsupported Z geometry",
141+
"01e9030000000000000000f03f00000000000000400000000000000840",
142+
"only 2D geometries are supported"),
143+
Arguments.of(
144+
"unsupported M geometry",
145+
"01d1070000000000000000f03f00000000000000400000000000000840",
146+
"only 2D geometries are supported"),
147+
Arguments.of(
148+
"unsupported ZM geometry",
149+
"01b90b0000000000000000f03f000000000000004000000000000008400000000000001040",
150+
"only 2D geometries are supported"),
151+
Arguments.of("truncated point", "0101000000", "unexpected end of buffer"));
152+
}
153+
154+
private static GeospatialBound xy(double x, double y) {
155+
return GeospatialBound.createXY(x, y);
156+
}
157+
158+
private static ByteBuffer decode(String hex) {
159+
byte[] bytes = new byte[hex.length() / 2];
160+
for (int i = 0; i < bytes.length; i += 1) {
161+
int offset = i * 2;
162+
bytes[i] = (byte) Integer.parseInt(hex.substring(offset, offset + 2), 16);
163+
}
164+
165+
return ByteBuffer.wrap(bytes);
166+
}
167+
}

0 commit comments

Comments
 (0)