Skip to content

Commit 816dafa

Browse files
committed
Apply the ring array's slice offset, and correct the record
An Arrow list's offsets are logical indices into its child, so the child's own slice offset has to be added before its offsets buffer is read in turn. NativeReader did that for the outermost list and for the coordinate array, but not for the ring array in between: a polygon column whose rings child carries a slice offset was read from the wrong rings, silently, because nothing in the outer array says so and geoarrow-c bounds-checks nothing. Reachable from pyarrow by building a ListArray over an already-sliced ListArray - the test does exactly that. Fixed by routing every index into offsets[level] through offsetAt, which is the one place the level's own offset is added, so no level can be forgotten again. This also corrects a claim made in ba046f9 and repeated in the code: geoarrow-c does not read a sliced coordinate array from the wrong place. It records each level's offset in the array view and deliberately leaves its coordinate pointers unshifted for the consumer to apply - which its own visitors do, per geometry, at array_view.c's VisitNativeLinestring and VisitNativePolygon. NativeReader::setArray applies the innermost one to the base pointers instead, which comes to the same thing and costs nothing per piece. That was always what the compensation was doing; the comment saying the library had it wrong was mine, and it was wrong. Reading those callers rather than the setter alone is what turned up the ring-level gap above. Tests, both confirmed failing before the fix: - a Catch2 case building a polygon column with a decoy ring sliced off the front, which read the decoy (2 vertices) rather than the real ring (3) - a Python case doing the same through pyarrow, which split to no pieces at all because the decoy ring has no area Verified: 137 Python tests and 50 C++ cases (36,085 assertions); pieces bit-identical against the golden arrays, since unsliced input takes the same path; ru_maxrss flat across fourteen stream lifecycles; clean under -Wall -Wextra -Wpedantic on gcc and clang. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqdWshmD4AUHhrqeMh86GS
1 parent 1159447 commit 816dafa

3 files changed

Lines changed: 162 additions & 16 deletions

File tree

extension/src/geoarrow_arrays.cpp

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -187,11 +187,13 @@ void NativeReader::setArray(const ArrowArray *array) {
187187
std::string("Could not read a batch of geometries: ") + error.message);
188188
}
189189

190-
// geoarrow-c points its coordinate pointers past the innermost child's
191-
// slice offset but not past the coordinate array's own - the fixed-size
192-
// list's, or the struct's for separated coordinates - so an array sliced
193-
// there would be read from the wrong place. A list's offsets count from
194-
// the coordinate array's start, so that offset has to be applied.
190+
// The innermost level needs the same shift offsetAt applies to the list
191+
// levels, and geoarrow-c leaves it to the consumer in the same way: its
192+
// coordinate pointers stop at the innermost double child's offset, and
193+
// the coordinate array's own - the fixed-size list's, or the struct's for
194+
// separated coordinates - is recorded in the array view for us to add.
195+
// Its own visitors add it per geometry; applying it once to the base
196+
// pointers here comes to the same thing and costs nothing per piece.
195197
const ArrowArray *coordinates = array;
196198
for (int32_t level = 0; level < view.n_offsets; level++) {
197199
coordinates = coordinates->children[0];
@@ -206,8 +208,13 @@ void NativeReader::setArray(const ArrowArray *array) {
206208

207209
int64_t NativeReader::length() const { return view.length[0]; }
208210

209-
/// Where element i of the list at `level` begins. geoarrow-c leaves the
210-
/// offsets buffer unshifted, so a slice's own start is added here.
211+
/// Where element i of the list at `level` begins.
212+
///
213+
/// An Arrow list's offsets are logical indices into its child, so the
214+
/// child's own slice offset has to be added before its offsets buffer is
215+
/// read in turn. geoarrow-c records each level's offset in the array view
216+
/// for exactly this, and every index into `offsets[level]` goes through
217+
/// here so that no level can be forgotten.
211218
int64_t NativeReader::offsetAt(int level, int64_t i) const {
212219
return view.offsets[level][view.offset[level] + i];
213220
}
@@ -262,8 +269,7 @@ void NativeReader::rings(int64_t i, std::vector<operations::CoordSpan> &out,
262269
const int64_t last = offsetAt(0, i + 1);
263270
if (contiguous()) {
264271
for (int64_t r = first; r < last; r++) {
265-
// the polygon offsets already count from the ring array's own start
266-
out.push_back(run(view.offsets[1][r], view.offsets[1][r + 1]));
272+
out.push_back(run(offsetAt(1, r), offsetAt(1, r + 1)));
267273
}
268274
return;
269275
}
@@ -275,8 +281,8 @@ void NativeReader::rings(int64_t i, std::vector<operations::CoordSpan> &out,
275281
// the ring buffers keep their capacity from the previous polygon
276282
linestr &ring = scratch[used++];
277283
ring.clear();
278-
const int64_t begin = view.offsets[1][r];
279-
const int64_t end = view.offsets[1][r + 1];
284+
const int64_t begin = offsetAt(1, r);
285+
const int64_t end = offsetAt(1, r + 1);
280286
ring.reserve(static_cast<std::size_t>(end - begin));
281287
for (int64_t v = begin; v < end; v++) {
282288
ring.push_back(at(v));

extension/tests/tests_geoarrow.cpp

Lines changed: 119 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -397,15 +397,107 @@ class NativeColumn {
397397
ArrowArray *array_children[1]{}, *vertices_children[1]{};
398398
};
399399

400+
/// A geoarrow.polygon array built by hand, so that a test can put a slice
401+
/// offset on the *rings* array - a level pyarrow will not slice on its own
402+
class NativePolygonColumn {
403+
public:
404+
/// `pad` decoy rings of two vertices each are written at the front and
405+
/// then sliced off by giving the ring list an offset. The polygon offsets
406+
/// count from the ring array's own logical start, so they still begin at
407+
/// 0, and a reader that forgets the offset lands on the decoys.
408+
NativePolygonColumn(
409+
const std::vector<std::vector<std::pair<double, double>>> &rings_in,
410+
int64_t pad) {
411+
ArrowSchemaInit(schema.get());
412+
REQUIRE(ArrowSchemaSetType(schema.get(), NANOARROW_TYPE_LIST) ==
413+
NANOARROW_OK);
414+
ArrowSchema *ring_field = schema->children[0];
415+
REQUIRE(ArrowSchemaSetType(ring_field, NANOARROW_TYPE_LIST) ==
416+
NANOARROW_OK);
417+
REQUIRE(ArrowSchemaSetName(ring_field, "rings") == NANOARROW_OK);
418+
ArrowSchema *vertex_field = ring_field->children[0];
419+
REQUIRE(ArrowSchemaSetTypeFixedSize(
420+
vertex_field, NANOARROW_TYPE_FIXED_SIZE_LIST, 2) ==
421+
NANOARROW_OK);
422+
REQUIRE(ArrowSchemaSetName(vertex_field, "vertices") == NANOARROW_OK);
423+
REQUIRE(ArrowSchemaSetType(vertex_field->children[0],
424+
NANOARROW_TYPE_DOUBLE) == NANOARROW_OK);
425+
REQUIRE(ArrowSchemaSetName(vertex_field->children[0], "xy") ==
426+
NANOARROW_OK);
427+
428+
ring_offsets.push_back(0);
429+
for (int64_t i = 0; i < pad; i++) {
430+
xy.insert(xy.end(), {-999.0, -999.0, -999.0, -999.0});
431+
ring_offsets.push_back(static_cast<int32_t>(ring_offsets.back() + 2));
432+
}
433+
for (const auto &ring : rings_in) {
434+
for (const auto &point : ring) {
435+
xy.push_back(point.first);
436+
xy.push_back(point.second);
437+
}
438+
ring_offsets.push_back(
439+
static_cast<int32_t>(ring_offsets.back() + ring.size()));
440+
}
441+
// one polygon holding every real ring, counted from the slice's start
442+
polygon_offsets = {0, static_cast<int32_t>(rings_in.size())};
443+
444+
xy_child.length = static_cast<int64_t>(xy.size());
445+
xy_child.n_buffers = 2;
446+
xy_buffers[1] = xy.data();
447+
xy_child.buffers = xy_buffers;
448+
xy_child.null_count = 0;
449+
450+
vertices.length = static_cast<int64_t>(xy.size() / 2);
451+
vertices.n_buffers = 1;
452+
vertices.buffers = vertices_buffers;
453+
vertices.n_children = 1;
454+
vertices_children[0] = &xy_child;
455+
vertices.children = vertices_children;
456+
vertices.null_count = 0;
457+
458+
rings.length = static_cast<int64_t>(rings_in.size());
459+
rings.offset = pad;
460+
rings.n_buffers = 2;
461+
rings_buffers[1] = ring_offsets.data();
462+
rings.buffers = rings_buffers;
463+
rings.n_children = 1;
464+
rings_children[0] = &vertices;
465+
rings.children = rings_children;
466+
rings.null_count = 0;
467+
468+
array.length = 1;
469+
array.n_buffers = 2;
470+
array_buffers[1] = polygon_offsets.data();
471+
array.buffers = array_buffers;
472+
array.n_children = 1;
473+
array_children[0] = &rings;
474+
array.children = array_children;
475+
array.null_count = 0;
476+
}
477+
478+
const ArrowSchema *arrowSchema() const { return schema.get(); }
479+
const ArrowArray *arrowArray() const { return &array; }
480+
481+
private:
482+
nanoarrow::UniqueSchema schema;
483+
std::vector<double> xy;
484+
std::vector<int32_t> ring_offsets, polygon_offsets;
485+
ArrowArray array{}, rings{}, vertices{}, xy_child{};
486+
const void *array_buffers[2]{}, *rings_buffers[2]{}, *vertices_buffers[1]{},
487+
*xy_buffers[2]{};
488+
ArrowArray *array_children[1]{}, *rings_children[1]{},
489+
*vertices_children[1]{};
490+
};
491+
400492
} // namespace
401493

402494
TEST_CASE("A sliced coordinate array is read from the right place",
403495
"[geoarrow]") {
404-
// The list's offsets count from the coordinate array's own start, so a
405-
// fixed-size list carrying a slice offset of its own has to be accounted
406-
// for. geoarrow-c's array view applies only the innermost double child's
407-
// offset, not the fixed-size list's, so this is compensated on our side -
408-
// and this is the case that says whether it still is.
496+
// A list's offsets are logical indices into its child, so the child's own
497+
// slice offset has to be added to them. geoarrow-c records each level's
498+
// offset in the array view and leaves its coordinate pointers unshifted
499+
// for the consumer to apply; NativeReader::setArray applies the innermost
500+
// one to the pointers themselves, and this is the case that says so.
409501
NativeReader reader;
410502
NativeColumn column({{{0.5, 0.5}, {1.5, 1.5}, {2.5, 2.5}}}, /*pad=*/2);
411503
reader.init(column.arrowSchema(), GeometryType::linestring);
@@ -419,3 +511,25 @@ TEST_CASE("A sliced coordinate array is read from the right place",
419511
REQUIRE(vertices[2].x == 2.5);
420512
REQUIRE(vertices[2].y == 2.5);
421513
}
514+
515+
TEST_CASE("A sliced ring array is read from the right rings", "[geoarrow]") {
516+
// The same rule one level up: a polygon's offsets index the ring array
517+
// logically, so the ring array's own slice offset has to be added before
518+
// its offsets buffer is read. Forgetting it lands on the decoy rings.
519+
NativeReader reader;
520+
NativePolygonColumn column({{{0.5, 0.5}, {2.5, 0.5}, {2.5, 2.5}}}, /*pad=*/1);
521+
reader.init(column.arrowSchema(), GeometryType::polygon);
522+
reader.setArray(column.arrowArray());
523+
524+
REQUIRE(reader.length() == 1);
525+
std::vector<snail::operations::CoordSpan> rings;
526+
std::vector<snail::geoarrow::linestr> scratch;
527+
reader.rings(0, rings, scratch);
528+
529+
REQUIRE(rings.size() == 1);
530+
REQUIRE(rings[0].size() == 3);
531+
REQUIRE(rings[0][0].x == 0.5);
532+
REQUIRE(rings[0][0].y == 0.5);
533+
REQUIRE(rings[0][2].x == 2.5);
534+
REQUIRE(rings[0][2].y == 2.5);
535+
}

tests/test_geoarrow.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,32 @@ def test_sliced_arrays(self, many_linestrings, polygons):
406406

407407
assert_same_pieces(actual, expected)
408408

409+
def test_polygon_over_a_sliced_ring_array(self):
410+
"""The slice can sit on an inner level too. A polygon's offsets index
411+
its ring array logically, so a ring array that is itself a slice
412+
shifts which rings a polygon is made of - and nothing in the outer
413+
array says so."""
414+
415+
def polygon_of(xy, ring_offsets, decoy_rings):
416+
rings = pa.ListArray.from_arrays(
417+
pa.array(ring_offsets, type=pa.int32()),
418+
pa.FixedSizeListArray.from_arrays(pa.array(xy, type=pa.float64()), 2),
419+
).slice(decoy_rings)
420+
return pa.ListArray.from_arrays(
421+
pa.array([0, len(rings)], type=pa.int32()), rings
422+
)
423+
424+
ring = [0.5, 0.5, 2.5, 0.5, 2.5, 2.5]
425+
sliced = polygon_of([-9.0, -9.0, -9.0, -9.0] + ring, [0, 2, 5], 1)
426+
assert sliced.values.offset == 1
427+
plain = polygon_of(ring, [0, 3], 0)
428+
429+
actual = geometry_of(core_split_polygons(sliced, NROWS, NCOLS, TRANSFORM))
430+
expected = geometry_of(core_split_polygons(plain, NROWS, NCOLS, TRANSFORM))
431+
432+
assert len(actual) > 0
433+
assert_same_pieces(actual, expected)
434+
409435

410436
class TestMixed:
411437
"""split_geometries takes each geometry on its own terms, and gives the

0 commit comments

Comments
 (0)