Skip to content

Commit 798a3dc

Browse files
Copilotanidotnet
andcommitted
Add GeoPoint and GeoNearFilter for explicit geographic coordinate support
Implements comprehensive #1126 enhancements in single PR as requested: - Created GeoPoint class with lat/lon validation and clear accessors - Created GeoNearFilter for explicit geodesic distance queries - Added GeoPointConverter for serialization support - Updated SpatialModule to register GeoPointConverter - Enhanced SpatialIndex to handle GeoPoint fields - Added geoNear() methods to SpatialFluentFilter DSL - Comprehensive test suite with 13 new tests Features: - Explicit type safety eliminates auto-detection ambiguity - Validates coordinates on construction (-90/90 lat, -180/180 lon) - Serializable for Nitrite storage (MVStore/RocksDB) - Works seamlessly with existing spatial index - Maintains backward compatibility (NearFilter still works) All 34 tests passing (21 existing + 13 new) Co-authored-by: anidotnet <696662+anidotnet@users.noreply.github.com>
1 parent 0adc2ef commit 798a3dc

File tree

7 files changed

+576
-2
lines changed

7 files changed

+576
-2
lines changed
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/*
2+
* Copyright (c) 2017-2020. Nitrite author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.dizitart.no2.spatial;
18+
19+
import org.locationtech.jts.geom.Coordinate;
20+
import org.locationtech.jts.geom.Geometry;
21+
import org.locationtech.jts.util.GeometricShapeFactory;
22+
23+
/**
24+
* Spatial filter for finding geometries near a geographic point,
25+
* using geodesic distance on Earth's surface (WGS84 ellipsoid).
26+
*
27+
* <p>This filter is specifically designed for geographic coordinates (lat/long).
28+
* It always uses geodesic distance calculations, eliminating the ambiguity
29+
* of {@link NearFilter}'s auto-detection.</p>
30+
*
31+
* <p><strong>Usage Example:</strong></p>
32+
* <pre>{@code
33+
* GeoPoint center = new GeoPoint(45.0, -93.2650); // Minneapolis
34+
* collection.find(where("location").geoNear(center, 5000.0)); // 5km radius
35+
* }</pre>
36+
*
37+
* <p><strong>Distance Units:</strong> The distance parameter must be in meters.</p>
38+
*
39+
* <p><strong>Accuracy Note:</strong> This filter uses a bounding box approximation
40+
* for the R-tree index search, which may return some false positives (points
41+
* slightly outside the geodesic circle but within its bounding box). A future
42+
* enhancement will add two-pass filtering for exact results.</p>
43+
*
44+
* @since 4.3.3
45+
* @author Anindya Chatterjee
46+
* @see GeoPoint
47+
* @see NearFilter
48+
*/
49+
class GeoNearFilter extends WithinFilter {
50+
51+
/**
52+
* Creates a filter to find geometries near a GeoPoint.
53+
*
54+
* @param field the field to filter on
55+
* @param point the geographic point to check proximity to
56+
* @param distanceMeters the maximum distance in meters
57+
*/
58+
GeoNearFilter(String field, GeoPoint point, Double distanceMeters) {
59+
super(field, createGeodesicCircle(point.getCoordinate(), distanceMeters));
60+
}
61+
62+
/**
63+
* Creates a filter to find geometries near a coordinate.
64+
* The coordinate is validated to ensure it represents a valid geographic point.
65+
*
66+
* @param field the field to filter on
67+
* @param point the coordinate to check proximity to (x=longitude, y=latitude)
68+
* @param distanceMeters the maximum distance in meters
69+
* @throws IllegalArgumentException if coordinates are not valid geographic coordinates
70+
*/
71+
GeoNearFilter(String field, Coordinate point, Double distanceMeters) {
72+
super(field, createGeodesicCircle(validateAndGetCoordinate(point), distanceMeters));
73+
}
74+
75+
private static Coordinate validateAndGetCoordinate(Coordinate coord) {
76+
double lat = coord.getY();
77+
double lon = coord.getX();
78+
79+
if (lat < -90.0 || lat > 90.0) {
80+
throw new IllegalArgumentException(
81+
"GeoNearFilter requires valid latitude (-90 to 90), got: " + lat);
82+
}
83+
if (lon < -180.0 || lon > 180.0) {
84+
throw new IllegalArgumentException(
85+
"GeoNearFilter requires valid longitude (-180 to 180), got: " + lon);
86+
}
87+
88+
return coord;
89+
}
90+
91+
private static Geometry createGeodesicCircle(Coordinate center, double radiusMeters) {
92+
GeometricShapeFactory shapeFactory = new GeometricShapeFactory();
93+
shapeFactory.setNumPoints(64);
94+
shapeFactory.setCentre(center);
95+
96+
// Always use geodesic calculations for GeoNearFilter
97+
double radiusInDegrees = GeodesicUtils.metersToDegreesRadius(center, radiusMeters);
98+
shapeFactory.setSize(radiusInDegrees * 2);
99+
return shapeFactory.createCircle();
100+
}
101+
102+
@Override
103+
public String toString() {
104+
return "(" + getField() + " geoNear " + getValue() + ")";
105+
}
106+
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/*
2+
* Copyright (c) 2017-2020. Nitrite author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.dizitart.no2.spatial;
18+
19+
import org.locationtech.jts.geom.Coordinate;
20+
import org.locationtech.jts.geom.GeometryFactory;
21+
import org.locationtech.jts.geom.Point;
22+
import org.locationtech.jts.geom.PrecisionModel;
23+
24+
import java.io.Serializable;
25+
26+
/**
27+
* Represents a geographic point with latitude and longitude coordinates
28+
* on Earth's surface (WGS84 ellipsoid).
29+
*
30+
* <p>This class provides explicit type safety for geographic coordinates,
31+
* eliminating the ambiguity of auto-detection. It validates coordinates
32+
* on construction and provides clear latitude/longitude accessors.</p>
33+
*
34+
* <p><strong>Usage Example:</strong></p>
35+
* <pre>{@code
36+
* // Create a geographic point for Minneapolis
37+
* GeoPoint minneapolis = new GeoPoint(45.0, -93.2650);
38+
*
39+
* // Use with GeoNearFilter
40+
* collection.find(where("location").geoNear(minneapolis, 5000.0));
41+
* }</pre>
42+
*
43+
* <p><strong>Coordinate Order:</strong> Constructor takes (latitude, longitude)
44+
* which differs from JTS Point (x, y) = (longitude, latitude) to avoid confusion.</p>
45+
*
46+
* @since 4.3.3
47+
* @author Anindya Chatterjee
48+
*/
49+
public class GeoPoint implements Serializable {
50+
private static final long serialVersionUID = 1L;
51+
private static final GeometryFactory FACTORY = new GeometryFactory(new PrecisionModel(), 4326);
52+
private final Point point;
53+
private final double latitude;
54+
private final double longitude;
55+
56+
/**
57+
* Creates a new GeoPoint with the specified geographic coordinates.
58+
*
59+
* @param latitude the latitude in degrees (-90 to 90)
60+
* @param longitude the longitude in degrees (-180 to 180)
61+
* @throws IllegalArgumentException if coordinates are out of valid range
62+
*/
63+
public GeoPoint(double latitude, double longitude) {
64+
validateCoordinates(latitude, longitude);
65+
this.latitude = latitude;
66+
this.longitude = longitude;
67+
this.point = FACTORY.createPoint(new Coordinate(longitude, latitude));
68+
}
69+
70+
/**
71+
* Creates a GeoPoint from a JTS Coordinate.
72+
* The coordinate's Y value is treated as latitude and X as longitude.
73+
*
74+
* @param coordinate the coordinate (x=longitude, y=latitude)
75+
* @throws IllegalArgumentException if coordinates are out of valid range
76+
*/
77+
public GeoPoint(Coordinate coordinate) {
78+
this(coordinate.getY(), coordinate.getX());
79+
}
80+
81+
private void validateCoordinates(double latitude, double longitude) {
82+
if (latitude < -90.0 || latitude > 90.0) {
83+
throw new IllegalArgumentException(
84+
"Latitude must be between -90 and 90 degrees, got: " + latitude);
85+
}
86+
if (longitude < -180.0 || longitude > 180.0) {
87+
throw new IllegalArgumentException(
88+
"Longitude must be between -180 and 180 degrees, got: " + longitude);
89+
}
90+
}
91+
92+
/**
93+
* Gets the latitude in degrees.
94+
*
95+
* @return the latitude (-90 to 90)
96+
*/
97+
public double getLatitude() {
98+
return latitude;
99+
}
100+
101+
/**
102+
* Gets the longitude in degrees.
103+
*
104+
* @return the longitude (-180 to 180)
105+
*/
106+
public double getLongitude() {
107+
return longitude;
108+
}
109+
110+
/**
111+
* Gets the underlying JTS Point.
112+
*
113+
* @return the JTS Point representation
114+
*/
115+
public Point getPoint() {
116+
return point;
117+
}
118+
119+
/**
120+
* Gets the coordinate of this GeoPoint.
121+
*
122+
* @return the coordinate (x=longitude, y=latitude)
123+
*/
124+
public Coordinate getCoordinate() {
125+
return point.getCoordinate();
126+
}
127+
128+
@Override
129+
public String toString() {
130+
return String.format("GeoPoint(lat=%.6f, lon=%.6f)", latitude, longitude);
131+
}
132+
133+
@Override
134+
public boolean equals(Object obj) {
135+
if (this == obj) return true;
136+
if (obj == null || getClass() != obj.getClass()) return false;
137+
GeoPoint other = (GeoPoint) obj;
138+
return Double.compare(latitude, other.latitude) == 0
139+
&& Double.compare(longitude, other.longitude) == 0;
140+
}
141+
142+
@Override
143+
public int hashCode() {
144+
long latBits = Double.doubleToLongBits(latitude);
145+
long lonBits = Double.doubleToLongBits(longitude);
146+
return (int) (latBits ^ (latBits >>> 32) ^ lonBits ^ (lonBits >>> 32));
147+
}
148+
}

nitrite-spatial/src/main/java/org/dizitart/no2/spatial/SpatialFluentFilter.java

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,4 +91,40 @@ public Filter near(Coordinate point, Double distance) {
9191
public Filter near(Point point, Double distance) {
9292
return new NearFilter(field, point, distance);
9393
}
94+
95+
/**
96+
* Creates a spatial filter for geographic coordinates that matches documents
97+
* where the spatial data is near the specified point, using geodesic distance.
98+
*
99+
* <p>This method is specifically for geographic coordinates (latitude/longitude)
100+
* and always uses geodesic distance calculations on Earth's WGS84 ellipsoid.</p>
101+
*
102+
* <p><strong>Usage Example:</strong></p>
103+
* <pre>{@code
104+
* GeoPoint minneapolis = new GeoPoint(45.0, -93.2650);
105+
* collection.find(where("location").geoNear(minneapolis, 5000.0)); // 5km radius
106+
* }</pre>
107+
*
108+
* @param point the geographic point to check proximity to
109+
* @param distanceMeters the maximum distance in meters
110+
* @return the new {@link Filter} instance
111+
*/
112+
public Filter geoNear(GeoPoint point, Double distanceMeters) {
113+
return new GeoNearFilter(field, point, distanceMeters);
114+
}
115+
116+
/**
117+
* Creates a spatial filter for geographic coordinates that matches documents
118+
* where the spatial data is near the specified coordinate, using geodesic distance.
119+
*
120+
* <p>The coordinate is validated to ensure it represents valid geographic coordinates.</p>
121+
*
122+
* @param point the coordinate to check proximity to (x=longitude, y=latitude)
123+
* @param distanceMeters the maximum distance in meters
124+
* @return the new {@link Filter} instance
125+
* @throws IllegalArgumentException if coordinates are not valid geographic coordinates
126+
*/
127+
public Filter geoNear(Coordinate point, Double distanceMeters) {
128+
return new GeoNearFilter(field, point, distanceMeters);
129+
}
94130
}

nitrite-spatial/src/main/java/org/dizitart/no2/spatial/SpatialIndex.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,14 +165,25 @@ private Geometry parseGeometry(String field, Object fieldValue) {
165165
if (fieldValue == null) return null;
166166
if (fieldValue instanceof String) {
167167
return GeometryUtils.fromString((String) fieldValue);
168+
} else if (fieldValue instanceof GeoPoint) {
169+
// Handle GeoPoint - get its underlying Point geometry
170+
return ((GeoPoint) fieldValue).getPoint();
168171
} else if (fieldValue instanceof Geometry) {
169172
return (Geometry) fieldValue;
170173
} else if (fieldValue instanceof Document) {
171-
// in case of document, check if it contains geometry field
174+
// in case of document, check if it contains geometry field or lat/lon fields
172175
// GeometryConverter convert a geometry to document with geometry field
176+
// GeoPointConverter converts to document with latitude/longitude fields
173177
Document document = (Document) fieldValue;
174178
if (document.containsField("geometry")) {
175179
return GeometryUtils.fromString(document.get("geometry", String.class));
180+
} else if (document.containsField("latitude") && document.containsField("longitude")) {
181+
// Reconstruct GeoPoint from lat/lon
182+
Double lat = document.get("latitude", Double.class);
183+
Double lon = document.get("longitude", Double.class);
184+
if (lat != null && lon != null) {
185+
return new GeoPoint(lat, lon).getPoint();
186+
}
176187
}
177188
}
178189
throw new IndexingException("Field " + field + " does not contain Geometry data");

nitrite-spatial/src/main/java/org/dizitart/no2/spatial/SpatialModule.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import org.dizitart.no2.common.module.NitriteModule;
2020
import org.dizitart.no2.common.module.NitritePlugin;
21+
import org.dizitart.no2.spatial.converter.GeoPointConverter;
2122
import org.dizitart.no2.spatial.converter.GeometryConverter;
2223

2324
import java.util.Set;
@@ -41,6 +42,6 @@ public class SpatialModule implements NitriteModule {
4142
*/
4243
@Override
4344
public Set<NitritePlugin> plugins() {
44-
return setOf(new SpatialIndexer(), new GeometryConverter());
45+
return setOf(new SpatialIndexer(), new GeometryConverter(), new GeoPointConverter());
4546
}
4647
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
* Copyright (c) 2017-2020. Nitrite author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.dizitart.no2.spatial.converter;
18+
19+
import org.dizitart.no2.collection.Document;
20+
import org.dizitart.no2.common.mapper.EntityConverter;
21+
import org.dizitart.no2.common.mapper.NitriteMapper;
22+
import org.dizitart.no2.spatial.GeoPoint;
23+
24+
/**
25+
* Converter for {@link GeoPoint} to/from Nitrite {@link Document}.
26+
*
27+
* <p>Stores GeoPoint as a document with latitude and longitude fields.</p>
28+
*
29+
* @since 4.3.3
30+
* @author Anindya Chatterjee
31+
*/
32+
public class GeoPointConverter implements EntityConverter<GeoPoint> {
33+
34+
@Override
35+
public Class<GeoPoint> getEntityType() {
36+
return GeoPoint.class;
37+
}
38+
39+
@Override
40+
public Document toDocument(GeoPoint entity, NitriteMapper nitriteMapper) {
41+
return Document.createDocument("latitude", entity.getLatitude())
42+
.put("longitude", entity.getLongitude());
43+
}
44+
45+
@Override
46+
public GeoPoint fromDocument(Document document, NitriteMapper nitriteMapper) {
47+
Double latitude = document.get("latitude", Double.class);
48+
Double longitude = document.get("longitude", Double.class);
49+
50+
if (latitude == null || longitude == null) {
51+
return null;
52+
}
53+
54+
return new GeoPoint(latitude, longitude);
55+
}
56+
}

0 commit comments

Comments
 (0)