Skip to content

Commit 688b2c9

Browse files
authored
Merge pull request #40 from nitrite/copilot/fix-false-positives-spatial-filter
Fix spatial filter false positives using FlattenableFilter pattern
2 parents eae1210 + 7f7d479 commit 688b2c9

File tree

7 files changed

+376
-23
lines changed

7 files changed

+376
-23
lines changed

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

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ class FindOptimizer {
1717

1818
FindPlan _createFilterPlan(
1919
Iterable<IndexDescriptor> indexDescriptors, Filter filter) {
20-
if (filter is AndFilter) {
21-
var filters = _flattenAndFilter(filter);
20+
if (filter is FlattenableFilter) {
21+
var filters = _flattenFilter(filter as FlattenableFilter);
2222
return _createAndPlan(indexDescriptors, filters);
2323
} else if (filter is OrFilter) {
2424
return _createOrPlan(indexDescriptors, filter.filters);
@@ -28,11 +28,12 @@ class FindOptimizer {
2828
}
2929
}
3030

31-
List<Filter> _flattenAndFilter(AndFilter andFilter) {
31+
List<Filter> _flattenFilter(FlattenableFilter flattenableFilter) {
3232
var flattenedFilters = <Filter>[];
33-
for (var filter in andFilter.filters) {
34-
if (filter is AndFilter) {
35-
flattenedFilters.addAll(_flattenAndFilter(filter));
33+
for (var filter in flattenableFilter.getFilters()) {
34+
if (filter is FlattenableFilter) {
35+
// Type is narrowed to FlattenableFilter here
36+
flattenedFilters.addAll(_flattenFilter(filter as FlattenableFilter));
3637
} else {
3738
flattenedFilters.add(filter);
3839
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,16 @@ abstract class Filter {
175175
}
176176
}
177177

178+
/// Represents a filter which can be flattened or consists of multiple constituent filters.
179+
///
180+
/// This interface allows filters to be decomposed into multiple sub-filters during query
181+
/// optimization. For example, spatial filters can be split into an index scan filter
182+
/// (for bounding box checks) and a validation filter (for actual geometry checks).
183+
abstract class FlattenableFilter {
184+
/// Returns the list of constituent filters that make up this filter.
185+
List<Filter> getFilters();
186+
}
187+
178188
/// An abstract class representing a filter for Nitrite database.
179189
abstract class NitriteFilter extends Filter {
180190
/// Gets the [NitriteConfig] instance.

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ class OrFilter extends LogicalFilter {
7070
}
7171

7272
/// @nodoc
73-
class AndFilter extends LogicalFilter {
73+
class AndFilter extends LogicalFilter implements FlattenableFilter {
7474
AndFilter(List<Filter> filters) : super(filters) {
7575
for (int i = 1; i < filters.length; i++) {
7676
if (filters[i] is TextFilter) {
@@ -91,6 +91,9 @@ class AndFilter extends LogicalFilter {
9191
return true;
9292
}
9393

94+
@override
95+
List<Filter> getFilters() => filters;
96+
9497
@override
9598
String toString() {
9699
StringBuffer buffer = StringBuffer();

packages/nitrite_spatial/lib/src/filter.dart

Lines changed: 240 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
import 'package:dart_jts/dart_jts.dart';
22
import 'package:nitrite/nitrite.dart';
3+
import 'package:nitrite_spatial/src/geom_utils.dart';
34
import 'package:nitrite_spatial/src/indexer.dart';
45

6+
/// Copies NitriteFilter context (nitriteConfig, collectionName, objectFilter) from source to target
7+
void _copyFilterContext(NitriteFilter source, NitriteFilter target) {
8+
if (source.nitriteConfig != null) {
9+
target.nitriteConfig = source.nitriteConfig;
10+
target.collectionName = source.collectionName;
11+
target.objectFilter = source.objectFilter;
12+
}
13+
}
14+
515
/// The abstract base class for all spatial filters in Nitrite.
616
///
717
/// A spatial filter is used to query Nitrite database for
@@ -31,9 +41,61 @@ abstract class SpatialFilter extends IndexOnlyFilter {
3141
}
3242
}
3343

34-
///@nodoc
35-
class WithinFilter extends SpatialFilter {
36-
WithinFilter(super.field, super.value);
44+
/// A non-index filter that validates actual geometry relationships.
45+
/// This filter is used as the second stage of spatial filtering after
46+
/// the R-Tree index has filtered candidates based on bounding boxes.
47+
class _GeometryValidationFilter extends FieldBasedFilter {
48+
final bool Function(Geometry, Geometry) _validator;
49+
50+
_GeometryValidationFilter(super.field, super.value, this._validator);
51+
52+
@override
53+
bool apply(Document doc) {
54+
var fieldValue = doc.get(field);
55+
if (fieldValue == null) {
56+
return false;
57+
}
58+
59+
Geometry? documentGeometry;
60+
if (fieldValue is Geometry) {
61+
documentGeometry = fieldValue;
62+
} else if (fieldValue is String) {
63+
// Try to parse WKT string
64+
try {
65+
var reader = WKTReader();
66+
documentGeometry = reader.read(fieldValue);
67+
} catch (e) {
68+
return false;
69+
}
70+
} else if (fieldValue is Document) {
71+
// For entity repositories, geometry is stored as a Document with serialized string
72+
try {
73+
var geometryString = fieldValue['geometry'] as String?;
74+
if (geometryString != null) {
75+
var deserialized = GeometrySerializer.deserialize(geometryString);
76+
if (deserialized != null) {
77+
documentGeometry = deserialized;
78+
}
79+
}
80+
} catch (e) {
81+
return false;
82+
}
83+
} else {
84+
return false;
85+
}
86+
87+
if (documentGeometry == null) {
88+
return false;
89+
}
90+
91+
return _validator(documentGeometry, value as Geometry);
92+
}
93+
}
94+
95+
/// Internal implementation of WithinFilter for index scanning only.
96+
/// Does not implement FlattenableFilter to avoid infinite recursion.
97+
class WithinIndexFilter extends SpatialFilter {
98+
WithinIndexFilter(super.field, super.value);
3799

38100
@override
39101
Stream<dynamic> applyOnIndex(IndexMap indexMap) {
@@ -47,9 +109,10 @@ class WithinFilter extends SpatialFilter {
47109
}
48110
}
49111

50-
///@nodoc
51-
class IntersectsFilter extends SpatialFilter {
52-
IntersectsFilter(super.field, super.value);
112+
/// Internal implementation of IntersectsFilter for index scanning only.
113+
/// Does not implement FlattenableFilter to avoid infinite recursion.
114+
class IntersectsIndexFilter extends SpatialFilter {
115+
IntersectsIndexFilter(super.field, super.value);
53116

54117
@override
55118
Stream<dynamic> applyOnIndex(IndexMap indexMap) {
@@ -64,17 +127,183 @@ class IntersectsFilter extends SpatialFilter {
64127
}
65128

66129
///@nodoc
67-
class NearFilter extends WithinFilter {
130+
class WithinFilter extends NitriteFilter implements FlattenableFilter {
131+
final String field;
132+
final Geometry geometry;
133+
134+
WithinFilter(this.field, this.geometry);
135+
136+
@override
137+
bool apply(Document doc) {
138+
// For non-indexed queries, apply the validation filter directly
139+
var validationFilter = _GeometryValidationFilter(
140+
field,
141+
geometry,
142+
(docGeom, filterGeom) => docGeom.within(filterGeom),
143+
);
144+
_copyFilterContext(this, validationFilter);
145+
return validationFilter.apply(doc);
146+
}
147+
148+
@override
149+
List<Filter> getFilters() {
150+
// Return two filters: one for index scan, one for validation
151+
return [
152+
WithinIndexFilter(field, geometry),
153+
_GeometryValidationFilter(
154+
field,
155+
geometry,
156+
(docGeom, filterGeom) => docGeom.within(filterGeom),
157+
),
158+
];
159+
}
160+
161+
@override
162+
String toString() {
163+
return '($field within $geometry)';
164+
}
165+
}
166+
167+
///@nodoc
168+
class IntersectsFilter extends NitriteFilter implements FlattenableFilter {
169+
final String field;
170+
final Geometry geometry;
171+
172+
IntersectsFilter(this.field, this.geometry);
173+
174+
@override
175+
bool apply(Document doc) {
176+
// For non-indexed queries, apply the validation filter directly
177+
var validationFilter = _GeometryValidationFilter(
178+
field,
179+
geometry,
180+
(docGeom, filterGeom) => docGeom.intersects(filterGeom),
181+
);
182+
_copyFilterContext(this, validationFilter);
183+
return validationFilter.apply(doc);
184+
}
185+
186+
@override
187+
List<Filter> getFilters() {
188+
// Return two filters: one for index scan, one for validation
189+
return [
190+
IntersectsIndexFilter(field, geometry),
191+
_GeometryValidationFilter(
192+
field,
193+
geometry,
194+
(docGeom, filterGeom) => docGeom.intersects(filterGeom),
195+
),
196+
];
197+
}
198+
199+
@override
200+
String toString() {
201+
return '($field intersects $geometry)';
202+
}
203+
}
204+
205+
///@nodoc
206+
class NearFilter extends NitriteFilter implements FlattenableFilter {
207+
final String field;
208+
final Geometry circle;
209+
final Coordinate center;
210+
final double radius;
211+
68212
factory NearFilter(String field, Coordinate center, double radius) {
69-
var geometry = _createCircle(center, radius);
70-
return NearFilter._(field, geometry);
213+
var circle = _createCircle(center, radius);
214+
return NearFilter._(field, circle, center, radius);
71215
}
72216

73217
factory NearFilter.fromPoint(String field, Point point, double radius) {
74-
return NearFilter._(field, _createCircle(point.getCoordinate(), radius));
218+
var center = point.getCoordinate();
219+
var circle = _createCircle(center, radius);
220+
return NearFilter._(field, circle, center!, radius);
221+
}
222+
223+
NearFilter._(this.field, this.circle, this.center, this.radius);
224+
225+
@override
226+
bool apply(Document doc) {
227+
// For non-indexed queries, apply the validation filter directly
228+
var validationFilter = _NearValidationFilter(field, center, radius);
229+
_copyFilterContext(this, validationFilter);
230+
return validationFilter.apply(doc);
75231
}
76232

77-
NearFilter._(super.field, super.geometry);
233+
@override
234+
List<Filter> getFilters() {
235+
// Return two filters: one for index scan (using within), one for distance validation
236+
return [
237+
WithinIndexFilter(field, circle),
238+
_NearValidationFilter(field, center, radius),
239+
];
240+
}
241+
242+
@override
243+
String toString() {
244+
return '($field near $center within $radius)';
245+
}
246+
}
247+
248+
/// Validation filter for near queries that checks actual distance.
249+
class _NearValidationFilter extends NitriteFilter {
250+
final String field;
251+
final Coordinate center;
252+
final double radius;
253+
254+
_NearValidationFilter(this.field, this.center, this.radius);
255+
256+
@override
257+
bool apply(Document doc) {
258+
var fieldValue = doc.get(field);
259+
if (fieldValue == null) {
260+
return false;
261+
}
262+
263+
Geometry? documentGeometry;
264+
if (fieldValue is Geometry) {
265+
documentGeometry = fieldValue;
266+
} else if (fieldValue is String) {
267+
try {
268+
var reader = WKTReader();
269+
documentGeometry = reader.read(fieldValue);
270+
} catch (e) {
271+
return false;
272+
}
273+
} else if (fieldValue is Document) {
274+
// For entity repositories, geometry is stored as a Document with serialized string
275+
try {
276+
var geometryString = fieldValue['geometry'] as String?;
277+
if (geometryString != null) {
278+
var deserialized = GeometrySerializer.deserialize(geometryString);
279+
if (deserialized != null) {
280+
documentGeometry = deserialized;
281+
}
282+
}
283+
} catch (e) {
284+
return false;
285+
}
286+
} else {
287+
return false;
288+
}
289+
290+
if (documentGeometry == null) {
291+
return false;
292+
}
293+
294+
// For near queries, check if the geometry is within the distance
295+
// For points, check direct distance. For other geometries, check if they intersect the circle.
296+
if (documentGeometry is Point) {
297+
var coord = documentGeometry.getCoordinate();
298+
if (coord == null) return false;
299+
var distance = center.distance(coord);
300+
return distance <= radius;
301+
} else {
302+
// For non-point geometries, check if they intersect the circle
303+
var circle = _createCircle(center, radius);
304+
return documentGeometry.intersects(circle);
305+
}
306+
}
78307
}
79308

80309
Geometry _createCircle(Coordinate? center, double radius) {

packages/nitrite_spatial/lib/src/spatial_index.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@ class SpatialIndex extends NitriteIndex {
3737
var boundingBox = _fromGeometry(geometry);
3838

3939
Stream<NitriteId> keys;
40-
if (filter is WithinFilter) {
40+
if (filter is WithinIndexFilter) {
4141
keys = indexMap.findContainedKeys(boundingBox);
42-
} else if (filter is IntersectsFilter) {
42+
} else if (filter is IntersectsIndexFilter) {
4343
keys = indexMap.findIntersectingKeys(boundingBox);
4444
} else {
4545
throw FilterException('Unsupported spatial filter: $filter');

packages/nitrite_spatial/test/index_test.dart

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import 'package:dart_jts/dart_jts.dart';
22
import 'package:nitrite/nitrite.dart' as no2;
33
import 'package:nitrite/nitrite.dart' hide where;
4-
import 'package:nitrite/src/filters/filter.dart' as filter;
54
import 'package:nitrite_spatial/nitrite_spatial.dart';
65
import 'package:nitrite_spatial/src/filter.dart';
76
import 'package:test/test.dart';
@@ -169,8 +168,8 @@ void main() {
169168
var findPlan = await cursor.findPlan;
170169
expect(findPlan, isNotNull);
171170
expect(findPlan.indexScanFilter?.filters.length, 1);
172-
expect(findPlan.indexScanFilter?.filters.first, isA<IntersectsFilter>());
173-
expect(findPlan.collectionScanFilter, isA<filter.EqualsFilter>());
171+
expect(findPlan.indexScanFilter?.filters.first, isA<IntersectsIndexFilter>());
172+
expect(findPlan.collectionScanFilter, isNotNull); // Now has validation filter too
174173

175174
var result = await cursor.map((doc) => doc['key']).toList();
176175
expect(result.length, 1);

0 commit comments

Comments
 (0)