Skip to content

Commit 02b08c3

Browse files
Copilotanidotnet
andcommitted
Fix spatial intersects false positives by adding post-index validation
Co-authored-by: anidotnet <[email protected]>
1 parent 07c5c5b commit 02b08c3

File tree

4 files changed

+184
-1
lines changed

4 files changed

+184
-1
lines changed

packages/nitrite/lib/src/collection/operations/find_optimizer.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,13 @@ class FindOptimizer {
241241
if (filter != findPlan.byIdFilter) {
242242
columnScanFilters.add(filter);
243243
}
244+
} else if (filter is IndexOnlyFilter &&
245+
filter.needsPostIndexValidation()) {
246+
// Some index-only filters (like spatial filters) need post-index validation
247+
// because the index can return false positives. For example, R-Tree spatial
248+
// indexes store only bounding boxes, so they may return documents whose
249+
// bounding boxes overlap but actual geometries don't intersect.
250+
columnScanFilters.add(filter);
244251
}
245252
}
246253

packages/nitrite/lib/src/filters/filter.dart

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,17 @@ abstract class IndexOnlyFilter extends ComparableFilter {
330330

331331
/// Checks if `other` filter can be grouped together with this filter.
332332
bool canBeGrouped(IndexOnlyFilter other);
333+
334+
/// Indicates whether this filter requires post-index validation.
335+
///
336+
/// Some index-only filters (like spatial filters) use the index for
337+
/// preliminary filtering but need a second pass to validate the actual
338+
/// condition. For example, R-Tree spatial indexes store only bounding boxes,
339+
/// so they may return false positives that need to be filtered out.
340+
///
341+
/// Returns `true` if this filter needs to be applied again after index scan
342+
/// to validate results. Defaults to `false`.
343+
bool needsPostIndexValidation() => false;
333344
}
334345

335346
/// @nodoc

packages/nitrite_spatial/lib/src/filter.dart

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,41 @@ abstract class SpatialFilter extends IndexOnlyFilter {
1717

1818
@override
1919
bool apply(Document doc) {
20-
return false;
20+
// This method validates the actual geometry intersection/containment
21+
// after the R-Tree index has filtered candidates based on bounding boxes.
22+
// The R-Tree only stores bounding boxes, so it can return false positives.
23+
// This second-stage check ensures we only return documents whose geometries
24+
// actually satisfy the spatial relationship.
25+
var fieldValue = doc.get(field);
26+
if (fieldValue == null) {
27+
return false;
28+
}
29+
30+
Geometry? documentGeometry;
31+
if (fieldValue is Geometry) {
32+
documentGeometry = fieldValue;
33+
} else if (fieldValue is String) {
34+
// Try to parse WKT string
35+
try {
36+
var reader = WKTReader();
37+
documentGeometry = reader.read(fieldValue);
38+
} catch (e) {
39+
return false;
40+
}
41+
} else {
42+
return false;
43+
}
44+
45+
if (documentGeometry == null) {
46+
return false;
47+
}
48+
49+
return applyGeometryFilter(documentGeometry);
2150
}
2251

52+
/// Subclasses must implement this to define the specific spatial relationship check
53+
bool applyGeometryFilter(Geometry documentGeometry);
54+
2355
@override
2456
String supportedIndexType() {
2557
return spatialIndex;
@@ -29,6 +61,9 @@ abstract class SpatialFilter extends IndexOnlyFilter {
2961
bool canBeGrouped(IndexOnlyFilter other) {
3062
return other is SpatialFilter && other.field == field;
3163
}
64+
65+
@override
66+
bool needsPostIndexValidation() => true;
3267
}
3368

3469
///@nodoc
@@ -41,6 +76,12 @@ class WithinFilter extends SpatialFilter {
4176
return const Stream.empty();
4277
}
4378

79+
@override
80+
bool applyGeometryFilter(Geometry documentGeometry) {
81+
// Check if the document geometry is within the filter geometry
82+
return documentGeometry.within(value);
83+
}
84+
4485
@override
4586
String toString() {
4687
return '($field within $value)';
@@ -57,6 +98,12 @@ class IntersectsFilter extends SpatialFilter {
5798
return const Stream.empty();
5899
}
59100

101+
@override
102+
bool applyGeometryFilter(Geometry documentGeometry) {
103+
// Check if the document geometry intersects the filter geometry
104+
return documentGeometry.intersects(value);
105+
}
106+
60107
@override
61108
String toString() {
62109
return '($field intersects $value)';
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import 'package:dart_jts/dart_jts.dart';
2+
import 'package:nitrite/nitrite.dart' hide where;
3+
import 'package:nitrite_spatial/nitrite_spatial.dart';
4+
import 'package:test/test.dart';
5+
6+
import 'base_test_loader.dart';
7+
import 'test_utils.dart';
8+
9+
void main() {
10+
group(retry: 3, 'Spatial Intersects False Positive Test Suite', () {
11+
var reader = WKTReader();
12+
13+
setUp(() async {
14+
setUpLog();
15+
await setUpNitriteTest();
16+
});
17+
18+
tearDown(() async {
19+
await tearDownNitriteTest();
20+
});
21+
22+
test('Test Intersects - Polygon and MultiPoint with overlapping bounding boxes but no intersection', () async {
23+
// This is the test case from the issue report
24+
// The polygon and multipoint have overlapping bounding boxes
25+
// but the geometries themselves do not intersect
26+
var polygon = reader.read('POLYGON ((40486.563 45036.319, 40084.108 44545.927, 39496.171 44938.774, 39889.018 45526.712, 40486.563 45036.319))') as Polygon;
27+
var multipoint = reader.read('MULTIPOINT ((40933.744 45423.275), (40395.332 45612.623), (40574.536 45576.665))') as MultiPoint;
28+
29+
// Insert the multipoint into the collection
30+
final doc = createDocument("geometry", multipoint);
31+
await collection.insert([doc]);
32+
33+
// Create spatial index
34+
await collection.createIndex(["geometry"], indexOptions(spatialIndex));
35+
36+
// Query for geometries that intersect the polygon
37+
final result = await collection
38+
.find(filter: where('geometry').intersects(polygon))
39+
.toList();
40+
41+
// The multipoint does not intersect the polygon, so result should be empty
42+
expect(result.length, 0, reason: 'MultiPoint does not intersect Polygon, should return no results');
43+
});
44+
45+
test('Test Intersects - Polygon and Point inside bounding box but outside geometry', () async {
46+
// Create a polygon and a point that is inside the bounding box
47+
// but outside the actual polygon
48+
var polygon = reader.read('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as Polygon;
49+
var pointOutside = reader.read('POINT (15 15)') as Point; // Outside bounding box
50+
var pointInsideBBoxButOutsidePolygon = reader.read('POINT (12 5)') as Point; // Would be in bbox if expanded
51+
52+
// Actually, let's use a non-convex polygon to make this clearer
53+
// L-shaped polygon
54+
var lShapedPolygon = reader.read('POLYGON ((0 0, 10 0, 10 5, 5 5, 5 10, 0 10, 0 0))') as Polygon;
55+
var pointInBBoxButOutsidePolygon = reader.read('POINT (7 7)') as Point; // In bbox but outside L-shape
56+
57+
// Insert the point
58+
final doc = createDocument("geometry", pointInBBoxButOutsidePolygon);
59+
await collection.insert([doc]);
60+
61+
// Create spatial index
62+
await collection.createIndex(["geometry"], indexOptions(spatialIndex));
63+
64+
// Query for geometries that intersect the L-shaped polygon
65+
final result = await collection
66+
.find(filter: where('geometry').intersects(lShapedPolygon))
67+
.toList();
68+
69+
// The point is inside the bounding box but outside the polygon
70+
expect(result.length, 0, reason: 'Point is in bounding box but outside polygon geometry');
71+
});
72+
73+
test('Test Intersects - Actual intersection should return results', () async {
74+
// Create geometries that actually intersect
75+
var polygon = reader.read('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as Polygon;
76+
var pointInside = reader.read('POINT (5 5)') as Point;
77+
var lineIntersecting = reader.read('LINESTRING (-5 5, 15 5)') as LineString;
78+
79+
// Insert the geometries
80+
await collection.insert([
81+
createDocument("id", 1).put("geometry", pointInside),
82+
createDocument("id", 2).put("geometry", lineIntersecting),
83+
]);
84+
85+
// Create spatial index
86+
await collection.createIndex(["geometry"], indexOptions(spatialIndex));
87+
88+
// Query for geometries that intersect the polygon
89+
final result = await collection
90+
.find(filter: where('geometry').intersects(polygon))
91+
.toList();
92+
93+
// Both geometries intersect the polygon
94+
expect(result.length, 2, reason: 'Point and LineString both intersect the polygon');
95+
});
96+
97+
test('Test Within - Point in bounding box but outside geometry', () async {
98+
// L-shaped polygon
99+
var lShapedPolygon = reader.read('POLYGON ((0 0, 10 0, 10 5, 5 5, 5 10, 0 10, 0 0))') as Polygon;
100+
var pointInBBoxButOutsidePolygon = reader.read('POINT (7 7)') as Point;
101+
102+
// Insert the point
103+
final doc = createDocument("geometry", pointInBBoxButOutsidePolygon);
104+
await collection.insert([doc]);
105+
106+
// Create spatial index
107+
await collection.createIndex(["geometry"], indexOptions(spatialIndex));
108+
109+
// Query for geometries within the L-shaped polygon
110+
final result = await collection
111+
.find(filter: where('geometry').within(lShapedPolygon))
112+
.toList();
113+
114+
// The point is not within the polygon
115+
expect(result.length, 0, reason: 'Point is in bounding box but not within polygon geometry');
116+
});
117+
});
118+
}

0 commit comments

Comments
 (0)