szehon-ho commented on code in PR #17509: URL: https://github.com/apache/iceberg/pull/17509#discussion_r3807722151
########## core/src/main/java/org/apache/iceberg/GeometryBoundsCollector.java: ########## @@ -0,0 +1,309 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import org.apache.iceberg.geospatial.BoundingBox; +import org.apache.iceberg.geospatial.GeospatialBound; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; + +/** + * Accumulates geometry bounds from values encoded as Well-Known Binary (WKB). + * + * <p>The seven OGC geometry types are supported: point, line string, polygon, multi point, multi + * line string, multi polygon, and geometry collection. + * + * <p>Coordinates are tracked independently for the X and Y dimensions. {@code NaN} values do not + * contribute to a dimension, and no bounds are produced unless both dimensions are present. + * + * <p>These bounds apply to {@code geometry} columns, whose edges are always interpolated linearly, + * so a box that contains every vertex contains the whole geometry. They are not valid for {@code + * geography} columns: geodesic edges can reach beyond their endpoints, longitude is periodic, and a + * geography box may cross the antimeridian. + * + * <p>Only the X and Y dimensions contribute to the box. Z and M ordinates are valid in the ISO WKB + * serializations that Iceberg accepts, so they are read past and ignored rather than rejected. + * + * <p>The bounds of a polygon are derived from its exterior ring alone, which assumes OGC-valid + * polygons whose interior rings lie within the shell. This matches the envelope computed for a + * polygon by geometry libraries such as JTS. Iceberg does not validate geometries, so a polygon + * with a hole extending past its shell produces bounds that do not contain the geometry. + */ +class GeometryBoundsCollector { + + private static final int TYPE_POINT = 1; + private static final int TYPE_LINE_STRING = 2; + private static final int TYPE_POLYGON = 3; + private static final int TYPE_MULTI_POINT = 4; + private static final int TYPE_MULTI_LINE_STRING = 5; + private static final int TYPE_MULTI_POLYGON = 6; + private static final int TYPE_GEOMETRY_COLLECTION = 7; + private static final int ANY_GEOMETRY = 0; + + // ISO WKB encodes the dimensions of a geometry in the thousands digit of its type code + private static final int XY_GROUP = 0; + private static final int XYZ_GROUP = 1; + private static final int XYM_GROUP = 2; + private static final int XYZM_GROUP = 3; + + private static final int MAX_DEPTH = 100; + + private final DimensionBounds xBounds = new DimensionBounds(); + private final DimensionBounds yBounds = new DimensionBounds(); + + /** + * Adds the coordinates from one WKB geometry to these bounds. + * + * <p>The input is read through a duplicate, so its position and limit are left unchanged. + * + * <p>If this throws, the collector's state is undefined: coordinates parsed before the failure + * may already be folded in. A caller that continues after a rejected value must discard this + * collector. + * + * @param wkb a buffer containing exactly one WKB geometry + * @throws IllegalArgumentException if the WKB is malformed or has a non-finite coordinate + */ + public void add(ByteBuffer wkb) { + Preconditions.checkArgument(wkb != null, "Invalid WKB buffer: null"); + ByteBuffer buffer = wkb.duplicate(); + parseGeometry(buffer, 0, ANY_GEOMETRY); + Preconditions.checkArgument(!buffer.hasRemaining(), "Invalid WKB: trailing data"); + } + + /** + * Returns the accumulated bounding box, or {@code null} if either the X or Y dimension has no + * value. + */ + public BoundingBox boundingBox() { + if (!xBounds.hasValue() || !yBounds.hasValue()) { + return null; + } + + GeospatialBound min = GeospatialBound.createXY(xBounds.lower(), yBounds.lower()); + GeospatialBound max = GeospatialBound.createXY(xBounds.upper(), yBounds.upper()); + return new BoundingBox(min, max); + } + + private void parseGeometry(ByteBuffer buffer, int depth, int expectedType) { + Preconditions.checkArgument(depth <= MAX_DEPTH, "Invalid WKB: nesting too deep"); + checkRemaining(buffer, 5); + + // each geometry sets its own byte order; restore the caller's order before returning so a + // sibling read after a nested geometry is not misread with the wrong endianness + ByteOrder callerOrder = buffer.order(); + byte order = buffer.get(); + if (order == 0) { + buffer.order(ByteOrder.BIG_ENDIAN); + } else if (order == 1) { + buffer.order(ByteOrder.LITTLE_ENDIAN); + } else { + throw new IllegalArgumentException("Invalid WKB byte order: " + order); + } + + try { + parseGeometryBody(buffer, depth, expectedType); + } finally { + buffer.order(callerOrder); + } + } + + private void parseGeometryBody(ByteBuffer buffer, int depth, int expectedType) { + long typeCode = buffer.getInt() & 0xFFFFFFFFL; + long dimensionGroup = typeCode / 1000; + int geometryType = (int) (typeCode % 1000); + Preconditions.checkArgument( + geometryType >= TYPE_POINT + && geometryType <= TYPE_GEOMETRY_COLLECTION + && dimensionGroup <= XYZM_GROUP, + "Invalid or unsupported WKB geometry type: %s", + typeCode); + Preconditions.checkArgument( + expectedType == ANY_GEOMETRY || geometryType == expectedType, + "Invalid WKB: expected geometry type %s but found %s", + typeName(expectedType), + typeName(geometryType)); + + int numDimensions = numDimensions(dimensionGroup); + + switch (geometryType) { + case TYPE_POINT: + readCoordinate(buffer, numDimensions); + break; + case TYPE_LINE_STRING: + readCoordinateSequence(buffer, numDimensions, true); + break; + case TYPE_POLYGON: + readPolygon(buffer, numDimensions); + break; + case TYPE_MULTI_POINT: + readCollection(buffer, depth, TYPE_POINT); + break; + case TYPE_MULTI_LINE_STRING: + readCollection(buffer, depth, TYPE_LINE_STRING); + break; + case TYPE_MULTI_POLYGON: + readCollection(buffer, depth, TYPE_POLYGON); + break; + case TYPE_GEOMETRY_COLLECTION: + readCollection(buffer, depth, ANY_GEOMETRY); + break; + default: + throw new IllegalArgumentException("Invalid or unsupported WKB geometry type: " + typeCode); + } + } + + private static String typeName(int geometryType) { + switch (geometryType) { + case TYPE_POINT: + return "Point"; + case TYPE_LINE_STRING: + return "LineString"; + case TYPE_POLYGON: + return "Polygon"; + case TYPE_MULTI_POINT: + return "MultiPoint"; + case TYPE_MULTI_LINE_STRING: + return "MultiLineString"; + case TYPE_MULTI_POLYGON: + return "MultiPolygon"; + case TYPE_GEOMETRY_COLLECTION: + return "GeometryCollection"; + default: + return String.valueOf(geometryType); + } + } + + private static int numDimensions(long dimensionGroup) { + switch ((int) dimensionGroup) { + case XY_GROUP: + return 2; + case XYZ_GROUP: + case XYM_GROUP: + return 3; + default: // XYZM_GROUP, the only remaining group the caller accepts + return 4; + } + } + + private void readPolygon(ByteBuffer buffer, int numDimensions) { + int numRings = readCount(buffer); + if (numRings > 0) { + readCoordinateSequence(buffer, numDimensions, true); + } + + for (int i = 1; i < numRings; i += 1) { + readCoordinateSequence(buffer, numDimensions, false); + } + } + + private void readCollection(ByteBuffer buffer, int depth, int expectedChildType) { + int numElements = readCount(buffer); + for (int i = 0; i < numElements; i += 1) { + // each child carries its own byte order, type code, and dimensions + parseGeometry(buffer, depth + 1, expectedChildType); + } + } + + private void readCoordinateSequence(ByteBuffer buffer, int numDimensions, boolean updateBounds) { + int numPoints = readCount(buffer); + long numBytes = (long) numPoints * numDimensions * Double.BYTES; + checkRemaining(buffer, numBytes); + if (!updateBounds) { + buffer.position(buffer.position() + (int) numBytes); + return; + } + + for (int i = 0; i < numPoints; i += 1) { + readCoordinate(buffer, numDimensions); + } + } + + private void readCoordinate(ByteBuffer buffer, int numDimensions) { + checkRemaining(buffer, (long) numDimensions * Double.BYTES); + double xCoord = buffer.getDouble(); + double yCoord = buffer.getDouble(); + // only X and Y contribute to the box; skip any Z and M ordinates + for (int i = 2; i < numDimensions; i += 1) { + buffer.getDouble(); + } + + // NaN marks an empty ordinate and is skipped per the spec, but an infinite coordinate is a real + // position that a finite box cannot cover; rejecting it avoids silently producing bounds that + // omit an object in the file. + Preconditions.checkArgument( Review Comment: Suggest deleting this check and letting ±Infinity become a bound. Infinite bounds are representable (`GeospatialBound` writes raw doubles, and applies no finiteness validation), spec-legal (`format/spec.md:748` bans only NaN as a bound, and `format/spec.md:651` places `-Infinity`/`Infinity` inside the ordered value range), and `DoubleFieldMetrics.Builder.addValue` already accepts them — a `double` column holding `+Infinity` writes `+Infinity` as its upper bound today. So the comment's reasoning — that an infinite ordinate is "a real position that a finite box cannot cover" — doesn't hold: `xmax = +Infinity` is constructible, round-trips exactly, and does contain the object. Nothing is lost by accepting it. As written this repeats the Z/M shape from the last round. Once #17161 calls `addValue` as the first statement of `GeometryWriter.write()` with no try/catch, this propagates out of `FileAppender.add()` and aborts the task, so a `POINT(Infinity 0)` produced by PostGIS or Sedona fails the `INSERT` and the file can never be compacted. ########## core/src/main/java/org/apache/iceberg/GeometryBoundsCollector.java: ########## @@ -0,0 +1,309 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import org.apache.iceberg.geospatial.BoundingBox; +import org.apache.iceberg.geospatial.GeospatialBound; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; + +/** + * Accumulates geometry bounds from values encoded as Well-Known Binary (WKB). + * + * <p>The seven OGC geometry types are supported: point, line string, polygon, multi point, multi + * line string, multi polygon, and geometry collection. + * + * <p>Coordinates are tracked independently for the X and Y dimensions. {@code NaN} values do not + * contribute to a dimension, and no bounds are produced unless both dimensions are present. + * + * <p>These bounds apply to {@code geometry} columns, whose edges are always interpolated linearly, + * so a box that contains every vertex contains the whole geometry. They are not valid for {@code + * geography} columns: geodesic edges can reach beyond their endpoints, longitude is periodic, and a + * geography box may cross the antimeridian. + * + * <p>Only the X and Y dimensions contribute to the box. Z and M ordinates are valid in the ISO WKB + * serializations that Iceberg accepts, so they are read past and ignored rather than rejected. + * + * <p>The bounds of a polygon are derived from its exterior ring alone, which assumes OGC-valid + * polygons whose interior rings lie within the shell. This matches the envelope computed for a + * polygon by geometry libraries such as JTS. Iceberg does not validate geometries, so a polygon + * with a hole extending past its shell produces bounds that do not contain the geometry. + */ +class GeometryBoundsCollector { + + private static final int TYPE_POINT = 1; + private static final int TYPE_LINE_STRING = 2; + private static final int TYPE_POLYGON = 3; + private static final int TYPE_MULTI_POINT = 4; + private static final int TYPE_MULTI_LINE_STRING = 5; + private static final int TYPE_MULTI_POLYGON = 6; + private static final int TYPE_GEOMETRY_COLLECTION = 7; + private static final int ANY_GEOMETRY = 0; + + // ISO WKB encodes the dimensions of a geometry in the thousands digit of its type code + private static final int XY_GROUP = 0; + private static final int XYZ_GROUP = 1; + private static final int XYM_GROUP = 2; + private static final int XYZM_GROUP = 3; + + private static final int MAX_DEPTH = 100; + + private final DimensionBounds xBounds = new DimensionBounds(); + private final DimensionBounds yBounds = new DimensionBounds(); + + /** + * Adds the coordinates from one WKB geometry to these bounds. + * + * <p>The input is read through a duplicate, so its position and limit are left unchanged. + * + * <p>If this throws, the collector's state is undefined: coordinates parsed before the failure + * may already be folded in. A caller that continues after a rejected value must discard this + * collector. + * + * @param wkb a buffer containing exactly one WKB geometry + * @throws IllegalArgumentException if the WKB is malformed or has a non-finite coordinate + */ + public void add(ByteBuffer wkb) { + Preconditions.checkArgument(wkb != null, "Invalid WKB buffer: null"); + ByteBuffer buffer = wkb.duplicate(); + parseGeometry(buffer, 0, ANY_GEOMETRY); + Preconditions.checkArgument(!buffer.hasRemaining(), "Invalid WKB: trailing data"); + } + + /** + * Returns the accumulated bounding box, or {@code null} if either the X or Y dimension has no + * value. + */ + public BoundingBox boundingBox() { + if (!xBounds.hasValue() || !yBounds.hasValue()) { + return null; + } + + GeospatialBound min = GeospatialBound.createXY(xBounds.lower(), yBounds.lower()); + GeospatialBound max = GeospatialBound.createXY(xBounds.upper(), yBounds.upper()); + return new BoundingBox(min, max); + } + + private void parseGeometry(ByteBuffer buffer, int depth, int expectedType) { + Preconditions.checkArgument(depth <= MAX_DEPTH, "Invalid WKB: nesting too deep"); + checkRemaining(buffer, 5); + + // each geometry sets its own byte order; restore the caller's order before returning so a + // sibling read after a nested geometry is not misread with the wrong endianness + ByteOrder callerOrder = buffer.order(); + byte order = buffer.get(); + if (order == 0) { + buffer.order(ByteOrder.BIG_ENDIAN); + } else if (order == 1) { + buffer.order(ByteOrder.LITTLE_ENDIAN); + } else { + throw new IllegalArgumentException("Invalid WKB byte order: " + order); + } + + try { + parseGeometryBody(buffer, depth, expectedType); + } finally { + buffer.order(callerOrder); + } + } + + private void parseGeometryBody(ByteBuffer buffer, int depth, int expectedType) { + long typeCode = buffer.getInt() & 0xFFFFFFFFL; + long dimensionGroup = typeCode / 1000; + int geometryType = (int) (typeCode % 1000); + Preconditions.checkArgument( + geometryType >= TYPE_POINT + && geometryType <= TYPE_GEOMETRY_COLLECTION + && dimensionGroup <= XYZM_GROUP, + "Invalid or unsupported WKB geometry type: %s", + typeCode); + Preconditions.checkArgument( + expectedType == ANY_GEOMETRY || geometryType == expectedType, + "Invalid WKB: expected geometry type %s but found %s", + typeName(expectedType), + typeName(geometryType)); + + int numDimensions = numDimensions(dimensionGroup); + + switch (geometryType) { + case TYPE_POINT: + readCoordinate(buffer, numDimensions); + break; + case TYPE_LINE_STRING: + readCoordinateSequence(buffer, numDimensions, true); + break; + case TYPE_POLYGON: + readPolygon(buffer, numDimensions); + break; + case TYPE_MULTI_POINT: + readCollection(buffer, depth, TYPE_POINT); + break; + case TYPE_MULTI_LINE_STRING: + readCollection(buffer, depth, TYPE_LINE_STRING); + break; + case TYPE_MULTI_POLYGON: + readCollection(buffer, depth, TYPE_POLYGON); + break; + case TYPE_GEOMETRY_COLLECTION: + readCollection(buffer, depth, ANY_GEOMETRY); + break; + default: + throw new IllegalArgumentException("Invalid or unsupported WKB geometry type: " + typeCode); + } + } + + private static String typeName(int geometryType) { + switch (geometryType) { + case TYPE_POINT: + return "Point"; + case TYPE_LINE_STRING: + return "LineString"; + case TYPE_POLYGON: + return "Polygon"; + case TYPE_MULTI_POINT: + return "MultiPoint"; + case TYPE_MULTI_LINE_STRING: + return "MultiLineString"; + case TYPE_MULTI_POLYGON: + return "MultiPolygon"; + case TYPE_GEOMETRY_COLLECTION: + return "GeometryCollection"; + default: + return String.valueOf(geometryType); + } + } + + private static int numDimensions(long dimensionGroup) { + switch ((int) dimensionGroup) { + case XY_GROUP: + return 2; + case XYZ_GROUP: + case XYM_GROUP: + return 3; + default: // XYZM_GROUP, the only remaining group the caller accepts + return 4; + } + } + + private void readPolygon(ByteBuffer buffer, int numDimensions) { Review Comment: Suggest passing interior rings through with `updateBounds` true, then deleting the parameter. I accepted the shell-only behavior last round once it was documented, and it is — but two things I didn't weigh then are worth surfacing before the test freezes it in. The failure mode is silent rather than loud: for a polygon whose hole extends past its shell, the box under-covers, `format/spec.md:768` is violated, and the file gets pruned from a query it should match, so the user sees missing rows and no error. Iceberg never validates geometry validity, so nothing upstream catches it. And reading every ring is a net deletion. `updateBounds` exists only for this case, so dropping it removes the branch in `readCoordinateSequence`, the parameter, the javadoc caveat, and the pinning test. The cost is reading doubles rather than advancing `position` over bytes already in cache, and a larger box is always conservative — for valid polygons it's byte-identical. The counterargument is real: JTS `Polygon.computeEnvelopeInternal()` uses the shell alone, so Iceberg would disagree with Parquet's footer `GeospatialStatistics`. If matching the footer is the priority this is defensible, but then the disagreement is that Iceberg is right and the footer is wrong. ########## core/src/main/java/org/apache/iceberg/GeometryBoundsCollector.java: ########## @@ -0,0 +1,309 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import org.apache.iceberg.geospatial.BoundingBox; +import org.apache.iceberg.geospatial.GeospatialBound; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; + +/** + * Accumulates geometry bounds from values encoded as Well-Known Binary (WKB). + * + * <p>The seven OGC geometry types are supported: point, line string, polygon, multi point, multi + * line string, multi polygon, and geometry collection. + * + * <p>Coordinates are tracked independently for the X and Y dimensions. {@code NaN} values do not + * contribute to a dimension, and no bounds are produced unless both dimensions are present. + * + * <p>These bounds apply to {@code geometry} columns, whose edges are always interpolated linearly, + * so a box that contains every vertex contains the whole geometry. They are not valid for {@code + * geography} columns: geodesic edges can reach beyond their endpoints, longitude is periodic, and a + * geography box may cross the antimeridian. + * + * <p>Only the X and Y dimensions contribute to the box. Z and M ordinates are valid in the ISO WKB + * serializations that Iceberg accepts, so they are read past and ignored rather than rejected. + * + * <p>The bounds of a polygon are derived from its exterior ring alone, which assumes OGC-valid + * polygons whose interior rings lie within the shell. This matches the envelope computed for a + * polygon by geometry libraries such as JTS. Iceberg does not validate geometries, so a polygon + * with a hole extending past its shell produces bounds that do not contain the geometry. + */ +class GeometryBoundsCollector { + + private static final int TYPE_POINT = 1; + private static final int TYPE_LINE_STRING = 2; + private static final int TYPE_POLYGON = 3; + private static final int TYPE_MULTI_POINT = 4; + private static final int TYPE_MULTI_LINE_STRING = 5; + private static final int TYPE_MULTI_POLYGON = 6; + private static final int TYPE_GEOMETRY_COLLECTION = 7; + private static final int ANY_GEOMETRY = 0; + + // ISO WKB encodes the dimensions of a geometry in the thousands digit of its type code + private static final int XY_GROUP = 0; + private static final int XYZ_GROUP = 1; + private static final int XYM_GROUP = 2; + private static final int XYZM_GROUP = 3; + + private static final int MAX_DEPTH = 100; + + private final DimensionBounds xBounds = new DimensionBounds(); + private final DimensionBounds yBounds = new DimensionBounds(); + + /** + * Adds the coordinates from one WKB geometry to these bounds. + * + * <p>The input is read through a duplicate, so its position and limit are left unchanged. + * + * <p>If this throws, the collector's state is undefined: coordinates parsed before the failure + * may already be folded in. A caller that continues after a rejected value must discard this + * collector. + * + * @param wkb a buffer containing exactly one WKB geometry + * @throws IllegalArgumentException if the WKB is malformed or has a non-finite coordinate + */ + public void add(ByteBuffer wkb) { + Preconditions.checkArgument(wkb != null, "Invalid WKB buffer: null"); + ByteBuffer buffer = wkb.duplicate(); + parseGeometry(buffer, 0, ANY_GEOMETRY); + Preconditions.checkArgument(!buffer.hasRemaining(), "Invalid WKB: trailing data"); + } + + /** + * Returns the accumulated bounding box, or {@code null} if either the X or Y dimension has no + * value. + */ + public BoundingBox boundingBox() { + if (!xBounds.hasValue() || !yBounds.hasValue()) { + return null; + } + + GeospatialBound min = GeospatialBound.createXY(xBounds.lower(), yBounds.lower()); + GeospatialBound max = GeospatialBound.createXY(xBounds.upper(), yBounds.upper()); + return new BoundingBox(min, max); + } + + private void parseGeometry(ByteBuffer buffer, int depth, int expectedType) { + Preconditions.checkArgument(depth <= MAX_DEPTH, "Invalid WKB: nesting too deep"); + checkRemaining(buffer, 5); + + // each geometry sets its own byte order; restore the caller's order before returning so a + // sibling read after a nested geometry is not misread with the wrong endianness + ByteOrder callerOrder = buffer.order(); + byte order = buffer.get(); + if (order == 0) { + buffer.order(ByteOrder.BIG_ENDIAN); + } else if (order == 1) { + buffer.order(ByteOrder.LITTLE_ENDIAN); + } else { + throw new IllegalArgumentException("Invalid WKB byte order: " + order); + } + + try { + parseGeometryBody(buffer, depth, expectedType); + } finally { + buffer.order(callerOrder); + } + } + + private void parseGeometryBody(ByteBuffer buffer, int depth, int expectedType) { + long typeCode = buffer.getInt() & 0xFFFFFFFFL; + long dimensionGroup = typeCode / 1000; + int geometryType = (int) (typeCode % 1000); + Preconditions.checkArgument( + geometryType >= TYPE_POINT + && geometryType <= TYPE_GEOMETRY_COLLECTION + && dimensionGroup <= XYZM_GROUP, + "Invalid or unsupported WKB geometry type: %s", + typeCode); + Preconditions.checkArgument( + expectedType == ANY_GEOMETRY || geometryType == expectedType, + "Invalid WKB: expected geometry type %s but found %s", + typeName(expectedType), + typeName(geometryType)); + + int numDimensions = numDimensions(dimensionGroup); + + switch (geometryType) { + case TYPE_POINT: + readCoordinate(buffer, numDimensions); + break; + case TYPE_LINE_STRING: + readCoordinateSequence(buffer, numDimensions, true); + break; + case TYPE_POLYGON: + readPolygon(buffer, numDimensions); + break; + case TYPE_MULTI_POINT: + readCollection(buffer, depth, TYPE_POINT); + break; + case TYPE_MULTI_LINE_STRING: + readCollection(buffer, depth, TYPE_LINE_STRING); + break; + case TYPE_MULTI_POLYGON: + readCollection(buffer, depth, TYPE_POLYGON); + break; + case TYPE_GEOMETRY_COLLECTION: + readCollection(buffer, depth, ANY_GEOMETRY); + break; + default: + throw new IllegalArgumentException("Invalid or unsupported WKB geometry type: " + typeCode); + } + } + + private static String typeName(int geometryType) { + switch (geometryType) { + case TYPE_POINT: + return "Point"; + case TYPE_LINE_STRING: + return "LineString"; + case TYPE_POLYGON: + return "Polygon"; + case TYPE_MULTI_POINT: + return "MultiPoint"; + case TYPE_MULTI_LINE_STRING: + return "MultiLineString"; + case TYPE_MULTI_POLYGON: + return "MultiPolygon"; + case TYPE_GEOMETRY_COLLECTION: + return "GeometryCollection"; + default: + return String.valueOf(geometryType); + } + } + + private static int numDimensions(long dimensionGroup) { + switch ((int) dimensionGroup) { + case XY_GROUP: + return 2; + case XYZ_GROUP: + case XYM_GROUP: + return 3; + default: // XYZM_GROUP, the only remaining group the caller accepts + return 4; + } + } + + private void readPolygon(ByteBuffer buffer, int numDimensions) { + int numRings = readCount(buffer); + if (numRings > 0) { + readCoordinateSequence(buffer, numDimensions, true); + } + + for (int i = 1; i < numRings; i += 1) { + readCoordinateSequence(buffer, numDimensions, false); + } + } + + private void readCollection(ByteBuffer buffer, int depth, int expectedChildType) { + int numElements = readCount(buffer); + for (int i = 0; i < numElements; i += 1) { + // each child carries its own byte order, type code, and dimensions + parseGeometry(buffer, depth + 1, expectedChildType); + } + } + + private void readCoordinateSequence(ByteBuffer buffer, int numDimensions, boolean updateBounds) { + int numPoints = readCount(buffer); + long numBytes = (long) numPoints * numDimensions * Double.BYTES; + checkRemaining(buffer, numBytes); + if (!updateBounds) { + buffer.position(buffer.position() + (int) numBytes); + return; + } + + for (int i = 0; i < numPoints; i += 1) { + readCoordinate(buffer, numDimensions); + } + } + + private void readCoordinate(ByteBuffer buffer, int numDimensions) { + checkRemaining(buffer, (long) numDimensions * Double.BYTES); + double xCoord = buffer.getDouble(); + double yCoord = buffer.getDouble(); + // only X and Y contribute to the box; skip any Z and M ordinates + for (int i = 2; i < numDimensions; i += 1) { + buffer.getDouble(); + } + + // NaN marks an empty ordinate and is skipped per the spec, but an infinite coordinate is a real + // position that a finite box cannot cover; rejecting it avoids silently producing bounds that + // omit an object in the file. + Preconditions.checkArgument( + !Double.isInfinite(xCoord) && !Double.isInfinite(yCoord), + "Invalid WKB: coordinate is not finite"); + + xBounds.add(xCoord); + yBounds.add(yCoord); + } + + private static int readCount(ByteBuffer buffer) { + checkRemaining(buffer, Integer.BYTES); + long count = buffer.getInt() & 0xFFFFFFFFL; + // every element or point occupies at least one more byte, so a count larger than the bytes left + // cannot be valid; catch it here with a precise message instead of looping until the buffer + // ends + Preconditions.checkArgument( Review Comment: Suggest adding an `invalidWkbCases` entry for this, e.g. a `LINESTRING` header declaring 1000 points followed by 20 bytes. Nothing in the suite reaches this precondition today. `truncated point` has no count field at all and fails in `checkRemaining` with "unexpected end of buffer", and `multi point with a line string child` carries `count = 1` against ample remaining bytes. Since this check was added specifically to replace a vague truncation error with a precise one, and it's the only path to this message, a future edit could delete it and every test would still pass. For what it's worth, I confirmed the bound can't false-reject: the cheapest element is a 4-byte empty ring, so `remaining >= count` holds for all well-formed input. ########## core/src/main/java/org/apache/iceberg/GeometryBoundsCollector.java: ########## @@ -0,0 +1,309 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import org.apache.iceberg.geospatial.BoundingBox; +import org.apache.iceberg.geospatial.GeospatialBound; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; + +/** + * Accumulates geometry bounds from values encoded as Well-Known Binary (WKB). + * + * <p>The seven OGC geometry types are supported: point, line string, polygon, multi point, multi + * line string, multi polygon, and geometry collection. + * + * <p>Coordinates are tracked independently for the X and Y dimensions. {@code NaN} values do not + * contribute to a dimension, and no bounds are produced unless both dimensions are present. + * + * <p>These bounds apply to {@code geometry} columns, whose edges are always interpolated linearly, + * so a box that contains every vertex contains the whole geometry. They are not valid for {@code + * geography} columns: geodesic edges can reach beyond their endpoints, longitude is periodic, and a + * geography box may cross the antimeridian. + * + * <p>Only the X and Y dimensions contribute to the box. Z and M ordinates are valid in the ISO WKB + * serializations that Iceberg accepts, so they are read past and ignored rather than rejected. + * + * <p>The bounds of a polygon are derived from its exterior ring alone, which assumes OGC-valid + * polygons whose interior rings lie within the shell. This matches the envelope computed for a + * polygon by geometry libraries such as JTS. Iceberg does not validate geometries, so a polygon + * with a hole extending past its shell produces bounds that do not contain the geometry. + */ +class GeometryBoundsCollector { + + private static final int TYPE_POINT = 1; + private static final int TYPE_LINE_STRING = 2; + private static final int TYPE_POLYGON = 3; + private static final int TYPE_MULTI_POINT = 4; + private static final int TYPE_MULTI_LINE_STRING = 5; + private static final int TYPE_MULTI_POLYGON = 6; + private static final int TYPE_GEOMETRY_COLLECTION = 7; + private static final int ANY_GEOMETRY = 0; + + // ISO WKB encodes the dimensions of a geometry in the thousands digit of its type code + private static final int XY_GROUP = 0; + private static final int XYZ_GROUP = 1; + private static final int XYM_GROUP = 2; + private static final int XYZM_GROUP = 3; + + private static final int MAX_DEPTH = 100; + + private final DimensionBounds xBounds = new DimensionBounds(); + private final DimensionBounds yBounds = new DimensionBounds(); + + /** + * Adds the coordinates from one WKB geometry to these bounds. + * + * <p>The input is read through a duplicate, so its position and limit are left unchanged. + * + * <p>If this throws, the collector's state is undefined: coordinates parsed before the failure + * may already be folded in. A caller that continues after a rejected value must discard this + * collector. + * + * @param wkb a buffer containing exactly one WKB geometry + * @throws IllegalArgumentException if the WKB is malformed or has a non-finite coordinate + */ + public void add(ByteBuffer wkb) { + Preconditions.checkArgument(wkb != null, "Invalid WKB buffer: null"); + ByteBuffer buffer = wkb.duplicate(); + parseGeometry(buffer, 0, ANY_GEOMETRY); + Preconditions.checkArgument(!buffer.hasRemaining(), "Invalid WKB: trailing data"); + } + + /** + * Returns the accumulated bounding box, or {@code null} if either the X or Y dimension has no + * value. + */ + public BoundingBox boundingBox() { + if (!xBounds.hasValue() || !yBounds.hasValue()) { + return null; + } + + GeospatialBound min = GeospatialBound.createXY(xBounds.lower(), yBounds.lower()); + GeospatialBound max = GeospatialBound.createXY(xBounds.upper(), yBounds.upper()); + return new BoundingBox(min, max); + } + + private void parseGeometry(ByteBuffer buffer, int depth, int expectedType) { + Preconditions.checkArgument(depth <= MAX_DEPTH, "Invalid WKB: nesting too deep"); + checkRemaining(buffer, 5); + + // each geometry sets its own byte order; restore the caller's order before returning so a + // sibling read after a nested geometry is not misread with the wrong endianness + ByteOrder callerOrder = buffer.order(); + byte order = buffer.get(); + if (order == 0) { + buffer.order(ByteOrder.BIG_ENDIAN); + } else if (order == 1) { + buffer.order(ByteOrder.LITTLE_ENDIAN); + } else { + throw new IllegalArgumentException("Invalid WKB byte order: " + order); + } + + try { + parseGeometryBody(buffer, depth, expectedType); + } finally { + buffer.order(callerOrder); + } + } + + private void parseGeometryBody(ByteBuffer buffer, int depth, int expectedType) { + long typeCode = buffer.getInt() & 0xFFFFFFFFL; + long dimensionGroup = typeCode / 1000; + int geometryType = (int) (typeCode % 1000); + Preconditions.checkArgument( + geometryType >= TYPE_POINT + && geometryType <= TYPE_GEOMETRY_COLLECTION + && dimensionGroup <= XYZM_GROUP, + "Invalid or unsupported WKB geometry type: %s", + typeCode); + Preconditions.checkArgument( Review Comment: Suggest replacing this precondition with an `if` and a `throw`, so the message is built only on failure. Precondition arguments are evaluated eagerly, so both `typeName` calls run on every parsed geometry. At the top level `expectedType` is `ANY_GEOMETRY`, which falls to `typeName`'s `default` branch and runs `String.valueOf(0)` — a `byte[]` plus a `String` per value written, in a per-row write path. The `typeCode` argument on the preceding precondition is fine, since Guava has a primitive `long` overload. ########## core/src/test/java/org/apache/iceberg/TestGeometryBoundsCollector.java: ########## @@ -0,0 +1,501 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Arrays; +import java.util.stream.Stream; +import org.apache.iceberg.geospatial.BoundingBox; +import org.apache.iceberg.geospatial.GeospatialBound; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class TestGeometryBoundsCollector { + + @ParameterizedTest(name = "{0}") + @MethodSource("boundingBoxCases") + void boundingBox(String wkt, Geom geom, BoundingBox expected) { + GeometryBoundsCollector bounds = new GeometryBoundsCollector(); + ByteBuffer wkb = ByteBuffer.wrap(wkb(geom)); + int position = wkb.position(); + int limit = wkb.limit(); + + bounds.add(wkb); + + assertThat(wkb.position()).as(wkt).isEqualTo(position); + assertThat(wkb.limit()).as(wkt).isEqualTo(limit); + assertThat(bounds.boundingBox()).as(wkt).isEqualTo(expected); + } + + @Test + void boundsFromBufferWithOffset() { + byte[] padded = new byte[64]; + byte[] wkb = wkb(point(1, 2)); + System.arraycopy(wkb, 0, padded, 11, wkb.length); + ByteBuffer slice = ByteBuffer.wrap(padded, 11, wkb.length).slice(); + + GeometryBoundsCollector bounds = new GeometryBoundsCollector(); + bounds.add(slice); + + assertThat(slice.position()).isEqualTo(0); + assertThat(bounds.boundingBox()).isEqualTo(box(1, 2, 1, 2)); + } + + @Test + void noBoundsWhenOneDimensionIsMissing() { + GeometryBoundsCollector bounds = new GeometryBoundsCollector(); + bounds.add(ByteBuffer.wrap(wkb(point(1, Double.NaN)))); + + assertThat(bounds.boundingBox()).as("POINT(1 NaN)").isNull(); + } + + @Test + void boundsAcrossValuesWithMissingCoordinates() { + GeometryBoundsCollector bounds = new GeometryBoundsCollector(); + bounds.add(ByteBuffer.wrap(wkb(point(1, Double.NaN)))); + bounds.add(ByteBuffer.wrap(wkb(point(Double.NaN, 2)))); + + assertThat(bounds.boundingBox()).isEqualTo(box(1, 2, 1, 2)); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("extraDimensionCases") + void extraDimensionsAreIgnored(String description, Geom geom, BoundingBox expected) { + GeometryBoundsCollector bounds = new GeometryBoundsCollector(); + + bounds.add(ByteBuffer.wrap(wkb(geom))); + + assertThat(bounds.boundingBox()).as(description).isEqualTo(expected); + } + + @Test + void boundsAcrossValuesWithDifferentDimensions() { + GeometryBoundsCollector bounds = new GeometryBoundsCollector(); + bounds.add(ByteBuffer.wrap(wkb(pointZ(1, 2, 3)))); + bounds.add(ByteBuffer.wrap(wkb(pointZM(1, 2, 3, 4)))); + + assertThat(bounds.boundingBox()).isEqualTo(box(1, 2, 1, 2)); + } + + @Test + void extraDimensionsNestedInCollectionAreIgnored() { + GeometryBoundsCollector bounds = new GeometryBoundsCollector(); + + bounds.add(ByteBuffer.wrap(wkb(collection(point(1, 2), pointZ(3, 4, 5))))); + + assertThat(bounds.boundingBox()).isEqualTo(box(1, 2, 3, 4)); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("invalidWkbCases") + void invalidWkb(String description, byte[] wkb, String expectedMessage) { + GeometryBoundsCollector bounds = new GeometryBoundsCollector(); + + assertThatThrownBy(() -> bounds.add(ByteBuffer.wrap(wkb))) + .as(description) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(expectedMessage); + } + + @Test + void nestingAtTheLimitIsAccepted() { + GeometryBoundsCollector bounds = new GeometryBoundsCollector(); + // 100 collection wrappers around POINT(1 2): the outermost is depth 0, the point is depth 100 + bounds.add(ByteBuffer.wrap(nestedCollections(100))); + + assertThat(bounds.boundingBox()).isEqualTo(box(1, 2, 1, 2)); + } + + @Test + void nestingPastTheLimitIsRejected() { + GeometryBoundsCollector bounds = new GeometryBoundsCollector(); + + assertThatThrownBy(() -> bounds.add(ByteBuffer.wrap(nestedCollections(101)))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("nesting too deep"); + } + + @Test + void bigEndianParentWithLittleEndianChild() { + GeometryBoundsCollector bounds = new GeometryBoundsCollector(); + // a big-endian multi point holding a little-endian point, the reverse of the MULTIPOINT case + bounds.add(ByteBuffer.wrap(wkb(multiPointBigEndian(point(1, 2))))); + + assertThat(bounds.boundingBox()).isEqualTo(box(1, 2, 1, 2)); + } + + @Test + void readsFromADirectBuffer() { + byte[] wkb = wkb(point(1, 2)); + ByteBuffer direct = ByteBuffer.allocateDirect(wkb.length); + direct.put(wkb).flip(); + + GeometryBoundsCollector bounds = new GeometryBoundsCollector(); + bounds.add(direct); + + assertThat(direct.hasArray()).isFalse(); + assertThat(bounds.boundingBox()).isEqualTo(box(1, 2, 1, 2)); + } + + @Test + void interiorRingOutsideShellIsNotCovered() { + GeometryBoundsCollector bounds = new GeometryBoundsCollector(); + // documented limitation: only the exterior ring is read, so an interior ring past the shell is + // not covered; this pins the behavior so a future change to read every ring is noticed + bounds.add( + ByteBuffer.wrap(wkb(polygon(ring(0, 0, 1, 0, 0, 1, 0, 0), ring(0, 0, 9, 0, 0, 9, 0, 0))))); + + assertThat(bounds.boundingBox()).isEqualTo(box(0, 0, 1, 1)); + } + + @Test + void stateIsUndefinedAfterAddThrows() { Review Comment: Suggest removing this test and keeping the javadoc contract on `add`. The comment concedes it documents "not a guaranteed rolled-back state," and what it actually asserts — that a truncated point throws "unexpected end of buffer" — is already covered by the `truncated point` case in `invalidWkbCases`. An undefined contract isn't testable, so the javadoc is the right and only place for it. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
