From 07c5c5baf0e70d612a0883d6c9317f2ddbd6bcaf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 22 Nov 2025 19:39:52 +0000 Subject: [PATCH 1/9] Initial plan From 02b08c3e81f686e6430fe2e64bdadf17730b7991 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 22 Nov 2025 19:49:15 +0000 Subject: [PATCH 2/9] Fix spatial intersects false positives by adding post-index validation Co-authored-by: anidotnet <696662+anidotnet@users.noreply.github.com> --- .../collection/operations/find_optimizer.dart | 7 ++ packages/nitrite/lib/src/filters/filter.dart | 11 ++ packages/nitrite_spatial/lib/src/filter.dart | 49 +++++++- .../test/intersects_false_positive_test.dart | 118 ++++++++++++++++++ 4 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 packages/nitrite_spatial/test/intersects_false_positive_test.dart diff --git a/packages/nitrite/lib/src/collection/operations/find_optimizer.dart b/packages/nitrite/lib/src/collection/operations/find_optimizer.dart index aba91e4..dad156d 100644 --- a/packages/nitrite/lib/src/collection/operations/find_optimizer.dart +++ b/packages/nitrite/lib/src/collection/operations/find_optimizer.dart @@ -241,6 +241,13 @@ class FindOptimizer { if (filter != findPlan.byIdFilter) { columnScanFilters.add(filter); } + } else if (filter is IndexOnlyFilter && + filter.needsPostIndexValidation()) { + // Some index-only filters (like spatial filters) need post-index validation + // because the index can return false positives. For example, R-Tree spatial + // indexes store only bounding boxes, so they may return documents whose + // bounding boxes overlap but actual geometries don't intersect. + columnScanFilters.add(filter); } } diff --git a/packages/nitrite/lib/src/filters/filter.dart b/packages/nitrite/lib/src/filters/filter.dart index c81a0e5..bc5d03b 100644 --- a/packages/nitrite/lib/src/filters/filter.dart +++ b/packages/nitrite/lib/src/filters/filter.dart @@ -330,6 +330,17 @@ abstract class IndexOnlyFilter extends ComparableFilter { /// Checks if `other` filter can be grouped together with this filter. bool canBeGrouped(IndexOnlyFilter other); + + /// Indicates whether this filter requires post-index validation. + /// + /// Some index-only filters (like spatial filters) use the index for + /// preliminary filtering but need a second pass to validate the actual + /// condition. For example, R-Tree spatial indexes store only bounding boxes, + /// so they may return false positives that need to be filtered out. + /// + /// Returns `true` if this filter needs to be applied again after index scan + /// to validate results. Defaults to `false`. + bool needsPostIndexValidation() => false; } /// @nodoc diff --git a/packages/nitrite_spatial/lib/src/filter.dart b/packages/nitrite_spatial/lib/src/filter.dart index af25cc6..cc17c1c 100644 --- a/packages/nitrite_spatial/lib/src/filter.dart +++ b/packages/nitrite_spatial/lib/src/filter.dart @@ -17,9 +17,41 @@ abstract class SpatialFilter extends IndexOnlyFilter { @override bool apply(Document doc) { - return false; + // This method validates the actual geometry intersection/containment + // after the R-Tree index has filtered candidates based on bounding boxes. + // The R-Tree only stores bounding boxes, so it can return false positives. + // This second-stage check ensures we only return documents whose geometries + // actually satisfy the spatial relationship. + var fieldValue = doc.get(field); + if (fieldValue == null) { + return false; + } + + Geometry? documentGeometry; + if (fieldValue is Geometry) { + documentGeometry = fieldValue; + } else if (fieldValue is String) { + // Try to parse WKT string + try { + var reader = WKTReader(); + documentGeometry = reader.read(fieldValue); + } catch (e) { + return false; + } + } else { + return false; + } + + if (documentGeometry == null) { + return false; + } + + return applyGeometryFilter(documentGeometry); } + /// Subclasses must implement this to define the specific spatial relationship check + bool applyGeometryFilter(Geometry documentGeometry); + @override String supportedIndexType() { return spatialIndex; @@ -29,6 +61,9 @@ abstract class SpatialFilter extends IndexOnlyFilter { bool canBeGrouped(IndexOnlyFilter other) { return other is SpatialFilter && other.field == field; } + + @override + bool needsPostIndexValidation() => true; } ///@nodoc @@ -41,6 +76,12 @@ class WithinFilter extends SpatialFilter { return const Stream.empty(); } + @override + bool applyGeometryFilter(Geometry documentGeometry) { + // Check if the document geometry is within the filter geometry + return documentGeometry.within(value); + } + @override String toString() { return '($field within $value)'; @@ -57,6 +98,12 @@ class IntersectsFilter extends SpatialFilter { return const Stream.empty(); } + @override + bool applyGeometryFilter(Geometry documentGeometry) { + // Check if the document geometry intersects the filter geometry + return documentGeometry.intersects(value); + } + @override String toString() { return '($field intersects $value)'; diff --git a/packages/nitrite_spatial/test/intersects_false_positive_test.dart b/packages/nitrite_spatial/test/intersects_false_positive_test.dart new file mode 100644 index 0000000..4609570 --- /dev/null +++ b/packages/nitrite_spatial/test/intersects_false_positive_test.dart @@ -0,0 +1,118 @@ +import 'package:dart_jts/dart_jts.dart'; +import 'package:nitrite/nitrite.dart' hide where; +import 'package:nitrite_spatial/nitrite_spatial.dart'; +import 'package:test/test.dart'; + +import 'base_test_loader.dart'; +import 'test_utils.dart'; + +void main() { + group(retry: 3, 'Spatial Intersects False Positive Test Suite', () { + var reader = WKTReader(); + + setUp(() async { + setUpLog(); + await setUpNitriteTest(); + }); + + tearDown(() async { + await tearDownNitriteTest(); + }); + + test('Test Intersects - Polygon and MultiPoint with overlapping bounding boxes but no intersection', () async { + // This is the test case from the issue report + // The polygon and multipoint have overlapping bounding boxes + // but the geometries themselves do not intersect + 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; + var multipoint = reader.read('MULTIPOINT ((40933.744 45423.275), (40395.332 45612.623), (40574.536 45576.665))') as MultiPoint; + + // Insert the multipoint into the collection + final doc = createDocument("geometry", multipoint); + await collection.insert([doc]); + + // Create spatial index + await collection.createIndex(["geometry"], indexOptions(spatialIndex)); + + // Query for geometries that intersect the polygon + final result = await collection + .find(filter: where('geometry').intersects(polygon)) + .toList(); + + // The multipoint does not intersect the polygon, so result should be empty + expect(result.length, 0, reason: 'MultiPoint does not intersect Polygon, should return no results'); + }); + + test('Test Intersects - Polygon and Point inside bounding box but outside geometry', () async { + // Create a polygon and a point that is inside the bounding box + // but outside the actual polygon + var polygon = reader.read('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as Polygon; + var pointOutside = reader.read('POINT (15 15)') as Point; // Outside bounding box + var pointInsideBBoxButOutsidePolygon = reader.read('POINT (12 5)') as Point; // Would be in bbox if expanded + + // Actually, let's use a non-convex polygon to make this clearer + // L-shaped polygon + var lShapedPolygon = reader.read('POLYGON ((0 0, 10 0, 10 5, 5 5, 5 10, 0 10, 0 0))') as Polygon; + var pointInBBoxButOutsidePolygon = reader.read('POINT (7 7)') as Point; // In bbox but outside L-shape + + // Insert the point + final doc = createDocument("geometry", pointInBBoxButOutsidePolygon); + await collection.insert([doc]); + + // Create spatial index + await collection.createIndex(["geometry"], indexOptions(spatialIndex)); + + // Query for geometries that intersect the L-shaped polygon + final result = await collection + .find(filter: where('geometry').intersects(lShapedPolygon)) + .toList(); + + // The point is inside the bounding box but outside the polygon + expect(result.length, 0, reason: 'Point is in bounding box but outside polygon geometry'); + }); + + test('Test Intersects - Actual intersection should return results', () async { + // Create geometries that actually intersect + var polygon = reader.read('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as Polygon; + var pointInside = reader.read('POINT (5 5)') as Point; + var lineIntersecting = reader.read('LINESTRING (-5 5, 15 5)') as LineString; + + // Insert the geometries + await collection.insert([ + createDocument("id", 1).put("geometry", pointInside), + createDocument("id", 2).put("geometry", lineIntersecting), + ]); + + // Create spatial index + await collection.createIndex(["geometry"], indexOptions(spatialIndex)); + + // Query for geometries that intersect the polygon + final result = await collection + .find(filter: where('geometry').intersects(polygon)) + .toList(); + + // Both geometries intersect the polygon + expect(result.length, 2, reason: 'Point and LineString both intersect the polygon'); + }); + + test('Test Within - Point in bounding box but outside geometry', () async { + // L-shaped polygon + var lShapedPolygon = reader.read('POLYGON ((0 0, 10 0, 10 5, 5 5, 5 10, 0 10, 0 0))') as Polygon; + var pointInBBoxButOutsidePolygon = reader.read('POINT (7 7)') as Point; + + // Insert the point + final doc = createDocument("geometry", pointInBBoxButOutsidePolygon); + await collection.insert([doc]); + + // Create spatial index + await collection.createIndex(["geometry"], indexOptions(spatialIndex)); + + // Query for geometries within the L-shaped polygon + final result = await collection + .find(filter: where('geometry').within(lShapedPolygon)) + .toList(); + + // The point is not within the polygon + expect(result.length, 0, reason: 'Point is in bounding box but not within polygon geometry'); + }); + }); +} From dcc99eae6b0add62625e0e3b7c8ead6c495cea6f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 22 Nov 2025 19:51:46 +0000 Subject: [PATCH 3/9] Address code review feedback - remove unused variables and redundant null check Co-authored-by: anidotnet <696662+anidotnet@users.noreply.github.com> --- packages/nitrite_spatial/lib/src/filter.dart | 6 +----- .../test/intersects_false_positive_test.dart | 9 +-------- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/packages/nitrite_spatial/lib/src/filter.dart b/packages/nitrite_spatial/lib/src/filter.dart index cc17c1c..3753060 100644 --- a/packages/nitrite_spatial/lib/src/filter.dart +++ b/packages/nitrite_spatial/lib/src/filter.dart @@ -42,11 +42,7 @@ abstract class SpatialFilter extends IndexOnlyFilter { return false; } - if (documentGeometry == null) { - return false; - } - - return applyGeometryFilter(documentGeometry); + return applyGeometryFilter(documentGeometry!); } /// Subclasses must implement this to define the specific spatial relationship check diff --git a/packages/nitrite_spatial/test/intersects_false_positive_test.dart b/packages/nitrite_spatial/test/intersects_false_positive_test.dart index 4609570..44e8790 100644 --- a/packages/nitrite_spatial/test/intersects_false_positive_test.dart +++ b/packages/nitrite_spatial/test/intersects_false_positive_test.dart @@ -43,14 +43,7 @@ void main() { }); test('Test Intersects - Polygon and Point inside bounding box but outside geometry', () async { - // Create a polygon and a point that is inside the bounding box - // but outside the actual polygon - var polygon = reader.read('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as Polygon; - var pointOutside = reader.read('POINT (15 15)') as Point; // Outside bounding box - var pointInsideBBoxButOutsidePolygon = reader.read('POINT (12 5)') as Point; // Would be in bbox if expanded - - // Actually, let's use a non-convex polygon to make this clearer - // L-shaped polygon + // Create a non-convex L-shaped polygon to test bbox vs geometry var lShapedPolygon = reader.read('POLYGON ((0 0, 10 0, 10 5, 5 5, 5 10, 0 10, 0 0))') as Polygon; var pointInBBoxButOutsidePolygon = reader.read('POINT (7 7)') as Point; // In bbox but outside L-shape From c65a49eb17516fdd5a15f454996041b5af76c635 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Nov 2025 04:38:25 +0000 Subject: [PATCH 4/9] Refactor to use FlattenableFilter pattern - keep geometry validation in spatial module Co-authored-by: anidotnet <696662+anidotnet@users.noreply.github.com> --- .../collection/operations/find_optimizer.dart | 19 ++--- packages/nitrite/lib/src/filters/filter.dart | 21 +++-- .../nitrite/lib/src/filters/filter_impl.dart | 5 +- packages/nitrite_spatial/lib/src/filter.dart | 77 +++++++++++-------- 4 files changed, 67 insertions(+), 55 deletions(-) diff --git a/packages/nitrite/lib/src/collection/operations/find_optimizer.dart b/packages/nitrite/lib/src/collection/operations/find_optimizer.dart index dad156d..6baac6c 100644 --- a/packages/nitrite/lib/src/collection/operations/find_optimizer.dart +++ b/packages/nitrite/lib/src/collection/operations/find_optimizer.dart @@ -17,8 +17,8 @@ class FindOptimizer { FindPlan _createFilterPlan( Iterable indexDescriptors, Filter filter) { - if (filter is AndFilter) { - var filters = _flattenAndFilter(filter); + if (filter is FlattenableFilter) { + var filters = _flattenFilter(filter); return _createAndPlan(indexDescriptors, filters); } else if (filter is OrFilter) { return _createOrPlan(indexDescriptors, filter.filters); @@ -28,11 +28,11 @@ class FindOptimizer { } } - List _flattenAndFilter(AndFilter andFilter) { + List _flattenFilter(FlattenableFilter flattenableFilter) { var flattenedFilters = []; - for (var filter in andFilter.filters) { - if (filter is AndFilter) { - flattenedFilters.addAll(_flattenAndFilter(filter)); + for (var filter in flattenableFilter.getFilters()) { + if (filter is FlattenableFilter) { + flattenedFilters.addAll(_flattenFilter(filter)); } else { flattenedFilters.add(filter); } @@ -241,13 +241,6 @@ class FindOptimizer { if (filter != findPlan.byIdFilter) { columnScanFilters.add(filter); } - } else if (filter is IndexOnlyFilter && - filter.needsPostIndexValidation()) { - // Some index-only filters (like spatial filters) need post-index validation - // because the index can return false positives. For example, R-Tree spatial - // indexes store only bounding boxes, so they may return documents whose - // bounding boxes overlap but actual geometries don't intersect. - columnScanFilters.add(filter); } } diff --git a/packages/nitrite/lib/src/filters/filter.dart b/packages/nitrite/lib/src/filters/filter.dart index bc5d03b..936bc88 100644 --- a/packages/nitrite/lib/src/filters/filter.dart +++ b/packages/nitrite/lib/src/filters/filter.dart @@ -175,6 +175,16 @@ abstract class Filter { } } +/// Represents a filter which can be flattened or consists of multiple constituent filters. +/// +/// This interface allows filters to be decomposed into multiple sub-filters during query +/// optimization. For example, spatial filters can be split into an index scan filter +/// (for bounding box checks) and a validation filter (for actual geometry checks). +abstract class FlattenableFilter { + /// Returns the list of constituent filters that make up this filter. + List getFilters(); +} + /// An abstract class representing a filter for Nitrite database. abstract class NitriteFilter extends Filter { /// Gets the [NitriteConfig] instance. @@ -330,17 +340,6 @@ abstract class IndexOnlyFilter extends ComparableFilter { /// Checks if `other` filter can be grouped together with this filter. bool canBeGrouped(IndexOnlyFilter other); - - /// Indicates whether this filter requires post-index validation. - /// - /// Some index-only filters (like spatial filters) use the index for - /// preliminary filtering but need a second pass to validate the actual - /// condition. For example, R-Tree spatial indexes store only bounding boxes, - /// so they may return false positives that need to be filtered out. - /// - /// Returns `true` if this filter needs to be applied again after index scan - /// to validate results. Defaults to `false`. - bool needsPostIndexValidation() => false; } /// @nodoc diff --git a/packages/nitrite/lib/src/filters/filter_impl.dart b/packages/nitrite/lib/src/filters/filter_impl.dart index ba74123..642c9f2 100644 --- a/packages/nitrite/lib/src/filters/filter_impl.dart +++ b/packages/nitrite/lib/src/filters/filter_impl.dart @@ -70,7 +70,7 @@ class OrFilter extends LogicalFilter { } /// @nodoc -class AndFilter extends LogicalFilter { +class AndFilter extends LogicalFilter implements FlattenableFilter { AndFilter(List filters) : super(filters) { for (int i = 1; i < filters.length; i++) { if (filters[i] is TextFilter) { @@ -91,6 +91,9 @@ class AndFilter extends LogicalFilter { return true; } + @override + List getFilters() => filters; + @override String toString() { StringBuffer buffer = StringBuffer(); diff --git a/packages/nitrite_spatial/lib/src/filter.dart b/packages/nitrite_spatial/lib/src/filter.dart index 3753060..36766fa 100644 --- a/packages/nitrite_spatial/lib/src/filter.dart +++ b/packages/nitrite_spatial/lib/src/filter.dart @@ -17,11 +17,30 @@ abstract class SpatialFilter extends IndexOnlyFilter { @override bool apply(Document doc) { - // This method validates the actual geometry intersection/containment - // after the R-Tree index has filtered candidates based on bounding boxes. - // The R-Tree only stores bounding boxes, so it can return false positives. - // This second-stage check ensures we only return documents whose geometries - // actually satisfy the spatial relationship. + return false; + } + + @override + String supportedIndexType() { + return spatialIndex; + } + + @override + bool canBeGrouped(IndexOnlyFilter other) { + return other is SpatialFilter && other.field == field; + } +} + +/// A non-index filter that validates actual geometry relationships. +/// This filter is used as the second stage of spatial filtering after +/// the R-Tree index has filtered candidates based on bounding boxes. +class _GeometryValidationFilter extends FieldBasedFilter { + final bool Function(Geometry, Geometry) _validator; + + _GeometryValidationFilter(super.field, super.value, this._validator); + + @override + bool apply(Document doc) { var fieldValue = doc.get(field); if (fieldValue == null) { return false; @@ -42,28 +61,12 @@ abstract class SpatialFilter extends IndexOnlyFilter { return false; } - return applyGeometryFilter(documentGeometry!); - } - - /// Subclasses must implement this to define the specific spatial relationship check - bool applyGeometryFilter(Geometry documentGeometry); - - @override - String supportedIndexType() { - return spatialIndex; + return _validator(documentGeometry!, value as Geometry); } - - @override - bool canBeGrouped(IndexOnlyFilter other) { - return other is SpatialFilter && other.field == field; - } - - @override - bool needsPostIndexValidation() => true; } ///@nodoc -class WithinFilter extends SpatialFilter { +class WithinFilter extends SpatialFilter implements FlattenableFilter { WithinFilter(super.field, super.value); @override @@ -73,9 +76,16 @@ class WithinFilter extends SpatialFilter { } @override - bool applyGeometryFilter(Geometry documentGeometry) { - // Check if the document geometry is within the filter geometry - return documentGeometry.within(value); + List getFilters() { + // Return two filters: one for index scan (this), one for validation + return [ + this, + _GeometryValidationFilter( + field, + value, + (docGeom, filterGeom) => docGeom.within(filterGeom), + ), + ]; } @override @@ -85,7 +95,7 @@ class WithinFilter extends SpatialFilter { } ///@nodoc -class IntersectsFilter extends SpatialFilter { +class IntersectsFilter extends SpatialFilter implements FlattenableFilter { IntersectsFilter(super.field, super.value); @override @@ -95,9 +105,16 @@ class IntersectsFilter extends SpatialFilter { } @override - bool applyGeometryFilter(Geometry documentGeometry) { - // Check if the document geometry intersects the filter geometry - return documentGeometry.intersects(value); + List getFilters() { + // Return two filters: one for index scan (this), one for validation + return [ + this, + _GeometryValidationFilter( + field, + value, + (docGeom, filterGeom) => docGeom.intersects(filterGeom), + ), + ]; } @override From 8d4f7639a4a8f505fc85e6deccb531eba270de07 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Nov 2025 11:13:23 +0000 Subject: [PATCH 5/9] Fix build and test failures - add explicit casts and fix insert API usage Co-authored-by: anidotnet <696662+anidotnet@users.noreply.github.com> --- .../collection/operations/find_optimizer.dart | 5 +- packages/nitrite_spatial/lib/src/filter.dart | 153 +++++++++++++++--- .../lib/src/spatial_index.dart | 4 +- packages/nitrite_spatial/test/index_test.dart | 4 +- .../test/intersects_false_positive_test.dart | 8 +- 5 files changed, 143 insertions(+), 31 deletions(-) diff --git a/packages/nitrite/lib/src/collection/operations/find_optimizer.dart b/packages/nitrite/lib/src/collection/operations/find_optimizer.dart index 6baac6c..880595c 100644 --- a/packages/nitrite/lib/src/collection/operations/find_optimizer.dart +++ b/packages/nitrite/lib/src/collection/operations/find_optimizer.dart @@ -18,7 +18,7 @@ class FindOptimizer { FindPlan _createFilterPlan( Iterable indexDescriptors, Filter filter) { if (filter is FlattenableFilter) { - var filters = _flattenFilter(filter); + var filters = _flattenFilter(filter as FlattenableFilter); return _createAndPlan(indexDescriptors, filters); } else if (filter is OrFilter) { return _createOrPlan(indexDescriptors, filter.filters); @@ -32,7 +32,8 @@ class FindOptimizer { var flattenedFilters = []; for (var filter in flattenableFilter.getFilters()) { if (filter is FlattenableFilter) { - flattenedFilters.addAll(_flattenFilter(filter)); + // Type is narrowed to FlattenableFilter here + flattenedFilters.addAll(_flattenFilter(filter as FlattenableFilter)); } else { flattenedFilters.add(filter); } diff --git a/packages/nitrite_spatial/lib/src/filter.dart b/packages/nitrite_spatial/lib/src/filter.dart index 36766fa..8778426 100644 --- a/packages/nitrite_spatial/lib/src/filter.dart +++ b/packages/nitrite_spatial/lib/src/filter.dart @@ -65,9 +65,27 @@ class _GeometryValidationFilter extends FieldBasedFilter { } } -///@nodoc -class WithinFilter extends SpatialFilter implements FlattenableFilter { - WithinFilter(super.field, super.value); +/// Internal implementation of WithinFilter for index scanning only. +/// Does not implement FlattenableFilter to avoid infinite recursion. +class WithinIndexFilter extends SpatialFilter { + WithinIndexFilter(super.field, super.value); + + @override + Stream applyOnIndex(IndexMap indexMap) { + // calculated from SpatialIndex + return const Stream.empty(); + } + + @override + String toString() { + return '($field within $value)'; + } +} + +/// Internal implementation of IntersectsFilter for index scanning only. +/// Does not implement FlattenableFilter to avoid infinite recursion. +class IntersectsIndexFilter extends SpatialFilter { + IntersectsIndexFilter(super.field, super.value); @override Stream applyOnIndex(IndexMap indexMap) { @@ -75,14 +93,33 @@ class WithinFilter extends SpatialFilter implements FlattenableFilter { return const Stream.empty(); } + @override + String toString() { + return '($field intersects $value)'; + } +} + +///@nodoc +class WithinFilter extends Filter implements FlattenableFilter { + final String field; + final Geometry geometry; + + WithinFilter(this.field, this.geometry); + + @override + bool apply(Document doc) { + // This should not be called directly as the filter is flattened + return false; + } + @override List getFilters() { - // Return two filters: one for index scan (this), one for validation + // Return two filters: one for index scan, one for validation return [ - this, + WithinIndexFilter(field, geometry), _GeometryValidationFilter( field, - value, + geometry, (docGeom, filterGeom) => docGeom.within(filterGeom), ), ]; @@ -90,28 +127,31 @@ class WithinFilter extends SpatialFilter implements FlattenableFilter { @override String toString() { - return '($field within $value)'; + return '($field within $geometry)'; } } ///@nodoc -class IntersectsFilter extends SpatialFilter implements FlattenableFilter { - IntersectsFilter(super.field, super.value); +class IntersectsFilter extends Filter implements FlattenableFilter { + final String field; + final Geometry geometry; + + IntersectsFilter(this.field, this.geometry); @override - Stream applyOnIndex(IndexMap indexMap) { - // calculated from SpatialIndex - return const Stream.empty(); + bool apply(Document doc) { + // This should not be called directly as the filter is flattened + return false; } @override List getFilters() { - // Return two filters: one for index scan (this), one for validation + // Return two filters: one for index scan, one for validation return [ - this, + IntersectsIndexFilter(field, geometry), _GeometryValidationFilter( field, - value, + geometry, (docGeom, filterGeom) => docGeom.intersects(filterGeom), ), ]; @@ -119,22 +159,93 @@ class IntersectsFilter extends SpatialFilter implements FlattenableFilter { @override String toString() { - return '($field intersects $value)'; + return '($field intersects $geometry)'; } } ///@nodoc -class NearFilter extends WithinFilter { +class NearFilter extends Filter implements FlattenableFilter { + final String field; + final Geometry circle; + final Coordinate center; + final double radius; + factory NearFilter(String field, Coordinate center, double radius) { - var geometry = _createCircle(center, radius); - return NearFilter._(field, geometry); + var circle = _createCircle(center, radius); + return NearFilter._(field, circle, center, radius); } factory NearFilter.fromPoint(String field, Point point, double radius) { - return NearFilter._(field, _createCircle(point.getCoordinate(), radius)); + var center = point.getCoordinate(); + var circle = _createCircle(center, radius); + return NearFilter._(field, circle, center!, radius); + } + + NearFilter._(this.field, this.circle, this.center, this.radius); + + @override + bool apply(Document doc) { + // This should not be called directly as the filter is flattened + return false; + } + + @override + List getFilters() { + // Return two filters: one for index scan (using within), one for distance validation + return [ + WithinIndexFilter(field, circle), + _NearValidationFilter(field, center, radius), + ]; + } + + @override + String toString() { + return '($field near $center within $radius)'; } +} + +/// Validation filter for near queries that checks actual distance. +class _NearValidationFilter extends Filter { + final String field; + final Coordinate center; + final double radius; + + _NearValidationFilter(this.field, this.center, this.radius); + + @override + bool apply(Document doc) { + var fieldValue = doc.get(field); + if (fieldValue == null) { + return false; + } + + Geometry? documentGeometry; + if (fieldValue is Geometry) { + documentGeometry = fieldValue; + } else if (fieldValue is String) { + try { + var reader = WKTReader(); + documentGeometry = reader.read(fieldValue); + } catch (e) { + return false; + } + } else { + return false; + } - NearFilter._(super.field, super.geometry); + // For near queries, check if the geometry is within the distance + // For points, check direct distance. For other geometries, check if they intersect the circle. + if (documentGeometry is Point) { + var coord = documentGeometry.getCoordinate(); + if (coord == null) return false; + var distance = center.distance(coord); + return distance <= radius; + } else { + // For non-point geometries, check if they intersect the circle + var circle = _createCircle(center, radius); + return documentGeometry!.intersects(circle); + } + } } Geometry _createCircle(Coordinate? center, double radius) { diff --git a/packages/nitrite_spatial/lib/src/spatial_index.dart b/packages/nitrite_spatial/lib/src/spatial_index.dart index 64dd1c4..8bf9455 100644 --- a/packages/nitrite_spatial/lib/src/spatial_index.dart +++ b/packages/nitrite_spatial/lib/src/spatial_index.dart @@ -37,9 +37,9 @@ class SpatialIndex extends NitriteIndex { var boundingBox = _fromGeometry(geometry); Stream keys; - if (filter is WithinFilter) { + if (filter is WithinIndexFilter) { keys = indexMap.findContainedKeys(boundingBox); - } else if (filter is IntersectsFilter) { + } else if (filter is IntersectsIndexFilter) { keys = indexMap.findIntersectingKeys(boundingBox); } else { throw FilterException('Unsupported spatial filter: $filter'); diff --git a/packages/nitrite_spatial/test/index_test.dart b/packages/nitrite_spatial/test/index_test.dart index 2d88815..4c77d76 100644 --- a/packages/nitrite_spatial/test/index_test.dart +++ b/packages/nitrite_spatial/test/index_test.dart @@ -169,8 +169,8 @@ void main() { var findPlan = await cursor.findPlan; expect(findPlan, isNotNull); expect(findPlan.indexScanFilter?.filters.length, 1); - expect(findPlan.indexScanFilter?.filters.first, isA()); - expect(findPlan.collectionScanFilter, isA()); + expect(findPlan.indexScanFilter?.filters.first, isA()); + expect(findPlan.collectionScanFilter, isNotNull); // Now has validation filter too var result = await cursor.map((doc) => doc['key']).toList(); expect(result.length, 1); diff --git a/packages/nitrite_spatial/test/intersects_false_positive_test.dart b/packages/nitrite_spatial/test/intersects_false_positive_test.dart index 44e8790..d24e438 100644 --- a/packages/nitrite_spatial/test/intersects_false_positive_test.dart +++ b/packages/nitrite_spatial/test/intersects_false_positive_test.dart @@ -28,7 +28,7 @@ void main() { // Insert the multipoint into the collection final doc = createDocument("geometry", multipoint); - await collection.insert([doc]); + await collection.insert(doc); // Create spatial index await collection.createIndex(["geometry"], indexOptions(spatialIndex)); @@ -49,7 +49,7 @@ void main() { // Insert the point final doc = createDocument("geometry", pointInBBoxButOutsidePolygon); - await collection.insert([doc]); + await collection.insert(doc); // Create spatial index await collection.createIndex(["geometry"], indexOptions(spatialIndex)); @@ -70,7 +70,7 @@ void main() { var lineIntersecting = reader.read('LINESTRING (-5 5, 15 5)') as LineString; // Insert the geometries - await collection.insert([ + await collection.insertMany([ createDocument("id", 1).put("geometry", pointInside), createDocument("id", 2).put("geometry", lineIntersecting), ]); @@ -94,7 +94,7 @@ void main() { // Insert the point final doc = createDocument("geometry", pointInBBoxButOutsidePolygon); - await collection.insert([doc]); + await collection.insert(doc); // Create spatial index await collection.createIndex(["geometry"], indexOptions(spatialIndex)); From fac59440176caaa6eb80ea43249e78e76b6df60c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Nov 2025 12:02:48 +0000 Subject: [PATCH 6/9] Fix filters to extend NitriteFilter and properly handle non-indexed queries Co-authored-by: anidotnet <696662+anidotnet@users.noreply.github.com> --- packages/nitrite_spatial/lib/src/filter.dart | 49 +++++++++++++++---- packages/nitrite_spatial/test/index_test.dart | 1 - 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/packages/nitrite_spatial/lib/src/filter.dart b/packages/nitrite_spatial/lib/src/filter.dart index 8778426..7baf60a 100644 --- a/packages/nitrite_spatial/lib/src/filter.dart +++ b/packages/nitrite_spatial/lib/src/filter.dart @@ -100,7 +100,7 @@ class IntersectsIndexFilter extends SpatialFilter { } ///@nodoc -class WithinFilter extends Filter implements FlattenableFilter { +class WithinFilter extends NitriteFilter implements FlattenableFilter { final String field; final Geometry geometry; @@ -108,8 +108,19 @@ class WithinFilter extends Filter implements FlattenableFilter { @override bool apply(Document doc) { - // This should not be called directly as the filter is flattened - return false; + // For non-indexed queries, apply the validation filter directly + var validationFilter = _GeometryValidationFilter( + field, + geometry, + (docGeom, filterGeom) => docGeom.within(filterGeom), + ); + // Copy context if available + if (nitriteConfig != null) { + validationFilter.nitriteConfig = nitriteConfig; + validationFilter.collectionName = collectionName; + validationFilter.objectFilter = objectFilter; + } + return validationFilter.apply(doc); } @override @@ -132,7 +143,7 @@ class WithinFilter extends Filter implements FlattenableFilter { } ///@nodoc -class IntersectsFilter extends Filter implements FlattenableFilter { +class IntersectsFilter extends NitriteFilter implements FlattenableFilter { final String field; final Geometry geometry; @@ -140,8 +151,19 @@ class IntersectsFilter extends Filter implements FlattenableFilter { @override bool apply(Document doc) { - // This should not be called directly as the filter is flattened - return false; + // For non-indexed queries, apply the validation filter directly + var validationFilter = _GeometryValidationFilter( + field, + geometry, + (docGeom, filterGeom) => docGeom.intersects(filterGeom), + ); + // Copy context if available + if (nitriteConfig != null) { + validationFilter.nitriteConfig = nitriteConfig; + validationFilter.collectionName = collectionName; + validationFilter.objectFilter = objectFilter; + } + return validationFilter.apply(doc); } @override @@ -164,7 +186,7 @@ class IntersectsFilter extends Filter implements FlattenableFilter { } ///@nodoc -class NearFilter extends Filter implements FlattenableFilter { +class NearFilter extends NitriteFilter implements FlattenableFilter { final String field; final Geometry circle; final Coordinate center; @@ -185,8 +207,15 @@ class NearFilter extends Filter implements FlattenableFilter { @override bool apply(Document doc) { - // This should not be called directly as the filter is flattened - return false; + // For non-indexed queries, apply the validation filter directly + var validationFilter = _NearValidationFilter(field, center, radius); + // Copy context if available + if (nitriteConfig != null) { + validationFilter.nitriteConfig = nitriteConfig; + validationFilter.collectionName = collectionName; + validationFilter.objectFilter = objectFilter; + } + return validationFilter.apply(doc); } @override @@ -205,7 +234,7 @@ class NearFilter extends Filter implements FlattenableFilter { } /// Validation filter for near queries that checks actual distance. -class _NearValidationFilter extends Filter { +class _NearValidationFilter extends NitriteFilter { final String field; final Coordinate center; final double radius; diff --git a/packages/nitrite_spatial/test/index_test.dart b/packages/nitrite_spatial/test/index_test.dart index 4c77d76..026b818 100644 --- a/packages/nitrite_spatial/test/index_test.dart +++ b/packages/nitrite_spatial/test/index_test.dart @@ -1,7 +1,6 @@ import 'package:dart_jts/dart_jts.dart'; import 'package:nitrite/nitrite.dart' as no2; import 'package:nitrite/nitrite.dart' hide where; -import 'package:nitrite/src/filters/filter.dart' as filter; import 'package:nitrite_spatial/nitrite_spatial.dart'; import 'package:nitrite_spatial/src/filter.dart'; import 'package:test/test.dart'; From 97ce7184e76992d88055e28d15d1a6bdede0f92b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Nov 2025 12:08:50 +0000 Subject: [PATCH 7/9] Fix repository queries by handling Document-serialized geometries Co-authored-by: anidotnet <696662+anidotnet@users.noreply.github.com> --- packages/nitrite_spatial/lib/src/filter.dart | 33 ++++++++++++++++++-- packages/nitrite_spatial/test_debug.dart | 23 ++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 packages/nitrite_spatial/test_debug.dart diff --git a/packages/nitrite_spatial/lib/src/filter.dart b/packages/nitrite_spatial/lib/src/filter.dart index 7baf60a..8ec5ce9 100644 --- a/packages/nitrite_spatial/lib/src/filter.dart +++ b/packages/nitrite_spatial/lib/src/filter.dart @@ -1,5 +1,6 @@ import 'package:dart_jts/dart_jts.dart'; import 'package:nitrite/nitrite.dart'; +import 'package:nitrite_spatial/src/geom_utils.dart'; import 'package:nitrite_spatial/src/indexer.dart'; /// The abstract base class for all spatial filters in Nitrite. @@ -57,11 +58,25 @@ class _GeometryValidationFilter extends FieldBasedFilter { } catch (e) { return false; } + } else if (fieldValue is Document) { + // For entity repositories, geometry is stored as a Document with serialized string + try { + var geometryString = fieldValue['geometry'] as String?; + if (geometryString != null) { + documentGeometry = GeometrySerializer.deserialize(geometryString); + } + } catch (e) { + return false; + } } else { return false; } - return _validator(documentGeometry!, value as Geometry); + if (documentGeometry == null) { + return false; + } + + return _validator(documentGeometry, value as Geometry); } } @@ -258,10 +273,24 @@ class _NearValidationFilter extends NitriteFilter { } catch (e) { return false; } + } else if (fieldValue is Document) { + // For entity repositories, geometry is stored as a Document with serialized string + try { + var geometryString = fieldValue['geometry'] as String?; + if (geometryString != null) { + documentGeometry = GeometrySerializer.deserialize(geometryString); + } + } catch (e) { + return false; + } } else { return false; } + if (documentGeometry == null) { + return false; + } + // For near queries, check if the geometry is within the distance // For points, check direct distance. For other geometries, check if they intersect the circle. if (documentGeometry is Point) { @@ -272,7 +301,7 @@ class _NearValidationFilter extends NitriteFilter { } else { // For non-point geometries, check if they intersect the circle var circle = _createCircle(center, radius); - return documentGeometry!.intersects(circle); + return documentGeometry.intersects(circle); } } } diff --git a/packages/nitrite_spatial/test_debug.dart b/packages/nitrite_spatial/test_debug.dart new file mode 100644 index 0000000..d05b09e --- /dev/null +++ b/packages/nitrite_spatial/test_debug.dart @@ -0,0 +1,23 @@ +import 'package:dart_jts/dart_jts.dart'; +import 'package:nitrite/nitrite.dart'; +import 'package:nitrite_spatial/nitrite_spatial.dart' as spatial; + +void main() async { + print('Testing filter apply directly...'); + + var reader = WKTReader(); + var polygon = reader.read('POLYGON ((490 490, 536 490, 536 515, 490 515, 490 490))'); + var point = reader.read('POINT (500 505)'); + + // Create a document with the geometry + var doc = createDocument('geometry', point); + + // Create the filter + var filter = spatial.where('geometry').intersects(polygon!); + + // Apply the filter + var result = filter.apply(doc); + + print('Filter result: $result'); + print('Expected: true'); +} From 44fd76d0c68e52bbd1d3fba720ba1153fdc5ebee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Nov 2025 12:09:30 +0000 Subject: [PATCH 8/9] Remove debug test file Co-authored-by: anidotnet <696662+anidotnet@users.noreply.github.com> --- packages/nitrite_spatial/test_debug.dart | 23 ----------------------- 1 file changed, 23 deletions(-) delete mode 100644 packages/nitrite_spatial/test_debug.dart diff --git a/packages/nitrite_spatial/test_debug.dart b/packages/nitrite_spatial/test_debug.dart deleted file mode 100644 index d05b09e..0000000 --- a/packages/nitrite_spatial/test_debug.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:dart_jts/dart_jts.dart'; -import 'package:nitrite/nitrite.dart'; -import 'package:nitrite_spatial/nitrite_spatial.dart' as spatial; - -void main() async { - print('Testing filter apply directly...'); - - var reader = WKTReader(); - var polygon = reader.read('POLYGON ((490 490, 536 490, 536 515, 490 515, 490 490))'); - var point = reader.read('POINT (500 505)'); - - // Create a document with the geometry - var doc = createDocument('geometry', point); - - // Create the filter - var filter = spatial.where('geometry').intersects(polygon!); - - // Apply the filter - var result = filter.apply(doc); - - print('Filter result: $result'); - print('Expected: true'); -} From 7f7d479c16d95b057cc8b013b1db22c80e24bc66 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Nov 2025 12:13:45 +0000 Subject: [PATCH 9/9] Address code review feedback - extract helper method and explicit null checks Co-authored-by: anidotnet <696662+anidotnet@users.noreply.github.com> --- packages/nitrite_spatial/lib/src/filter.dart | 40 ++++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/nitrite_spatial/lib/src/filter.dart b/packages/nitrite_spatial/lib/src/filter.dart index 8ec5ce9..3b2ae5d 100644 --- a/packages/nitrite_spatial/lib/src/filter.dart +++ b/packages/nitrite_spatial/lib/src/filter.dart @@ -3,6 +3,15 @@ import 'package:nitrite/nitrite.dart'; import 'package:nitrite_spatial/src/geom_utils.dart'; import 'package:nitrite_spatial/src/indexer.dart'; +/// Copies NitriteFilter context (nitriteConfig, collectionName, objectFilter) from source to target +void _copyFilterContext(NitriteFilter source, NitriteFilter target) { + if (source.nitriteConfig != null) { + target.nitriteConfig = source.nitriteConfig; + target.collectionName = source.collectionName; + target.objectFilter = source.objectFilter; + } +} + /// The abstract base class for all spatial filters in Nitrite. /// /// A spatial filter is used to query Nitrite database for @@ -63,7 +72,10 @@ class _GeometryValidationFilter extends FieldBasedFilter { try { var geometryString = fieldValue['geometry'] as String?; if (geometryString != null) { - documentGeometry = GeometrySerializer.deserialize(geometryString); + var deserialized = GeometrySerializer.deserialize(geometryString); + if (deserialized != null) { + documentGeometry = deserialized; + } } } catch (e) { return false; @@ -129,12 +141,7 @@ class WithinFilter extends NitriteFilter implements FlattenableFilter { geometry, (docGeom, filterGeom) => docGeom.within(filterGeom), ); - // Copy context if available - if (nitriteConfig != null) { - validationFilter.nitriteConfig = nitriteConfig; - validationFilter.collectionName = collectionName; - validationFilter.objectFilter = objectFilter; - } + _copyFilterContext(this, validationFilter); return validationFilter.apply(doc); } @@ -172,12 +179,7 @@ class IntersectsFilter extends NitriteFilter implements FlattenableFilter { geometry, (docGeom, filterGeom) => docGeom.intersects(filterGeom), ); - // Copy context if available - if (nitriteConfig != null) { - validationFilter.nitriteConfig = nitriteConfig; - validationFilter.collectionName = collectionName; - validationFilter.objectFilter = objectFilter; - } + _copyFilterContext(this, validationFilter); return validationFilter.apply(doc); } @@ -224,12 +226,7 @@ class NearFilter extends NitriteFilter implements FlattenableFilter { bool apply(Document doc) { // For non-indexed queries, apply the validation filter directly var validationFilter = _NearValidationFilter(field, center, radius); - // Copy context if available - if (nitriteConfig != null) { - validationFilter.nitriteConfig = nitriteConfig; - validationFilter.collectionName = collectionName; - validationFilter.objectFilter = objectFilter; - } + _copyFilterContext(this, validationFilter); return validationFilter.apply(doc); } @@ -278,7 +275,10 @@ class _NearValidationFilter extends NitriteFilter { try { var geometryString = fieldValue['geometry'] as String?; if (geometryString != null) { - documentGeometry = GeometrySerializer.deserialize(geometryString); + var deserialized = GeometrySerializer.deserialize(geometryString); + if (deserialized != null) { + documentGeometry = deserialized; + } } } catch (e) { return false;