Skip to content

Commit d8ba150

Browse files
committed
feat(nitrite): improve JTS spatial support with GeometryModule and expanded filter types
Replace the bedatadriven JtsModule with Nitrite's own GeometryModule for consistent JTS Geometry serialization — ensuring spatial data is written and read in the same format that nitrite-spatial expects internally. Expand $near filter handling to support GeoPoint, JTS Point, JTS Geometry (coordinate extraction), and JTS Coordinate — previously only Geometry was handled, requiring callers to always pass a full Geometry object. The $within and $intersects filters now also handle GeoPoint in addition to JTS Geometry, unwrapping to Point via reflection. Replace silent Filter.ALL fallback on failure with DataAccessException so that spatial filter errors (unrecognised type, API mismatch with nitrite-spatial version) surface immediately rather than returning all documents unexpectedly. All spatial class loading remains reflection-based so nitrite-spatial stays an optional dependency with no compile-time coupling from the main module. Add NitriteSpatialSpec covering $near, $within, and $intersects operators against a @SpatialIndex field, null geometry handling, and spatial index creation verification.
1 parent 4ebc015 commit d8ba150

3 files changed

Lines changed: 247 additions & 43 deletions

File tree

‎data-nitrite/src/main/java/io/micronaut/data/nitrite/runtime/NitriteOperationsFactory.java‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ public final class NitriteOperationsFactory {
5757
private static final Logger LOG = LoggerFactory.getLogger(NitriteOperationsFactory.class);
5858
private static final String ROCKSDB_MODULE_CLASS = "org.dizitart.no2.rocksdb.RocksDBModule";
5959
private static final String SPATIAL_MODULE_CLASS = "org.dizitart.no2.spatial.SpatialModule";
60-
private static final String JTS_MODULE_CLASS = "com.bedatadriven.jackson.datatype.jts.JtsModule";
60+
private static final String GEOMETRY_MODULE_CLASS = "org.dizitart.no2.spatial.jackson.GeometryModule";
6161

6262
NitriteOperationsFactory() {
6363
}
@@ -179,15 +179,19 @@ private NitriteModule createJacksonMapperModule() {
179179
}
180180
}
181181

182-
// Optional: JTS module for Geometry serialization (spatial queries)
183-
if (ClassUtils.isPresent(JTS_MODULE_CLASS, null)) {
182+
// Optional: GeometryModule for JTS Geometry serialization (spatial queries)
183+
// Uses Nitrite's own GeometryModule for consistent serialization with nitrite-spatial
184+
if (ClassUtils.isPresent(GEOMETRY_MODULE_CLASS, null)) {
184185
try {
185-
Class<?> jtsModuleClass = Class.forName(JTS_MODULE_CLASS);
186-
Object jtsModule = jtsModuleClass.getDeclaredConstructor().newInstance();
187-
mapper.registerModule((Module) jtsModule);
186+
Class<?> geometryModuleClass = Class.forName(GEOMETRY_MODULE_CLASS);
187+
Object geometryModule = geometryModuleClass.getDeclaredConstructor().newInstance();
188+
mapper.registerModule((Module) geometryModule);
189+
if (LOG.isDebugEnabled()) {
190+
LOG.debug("GeometryModule registered for JTS Geometry serialization");
191+
}
188192
} catch (Exception e) {
189193
if (LOG.isWarnEnabled()) {
190-
LOG.warn("JTS module found but could not be registered: {}", e.getMessage());
194+
LOG.warn("GeometryModule found but could not be registered: {}", e.getMessage());
191195
}
192196
}
193197
}

‎data-nitrite/src/main/java/io/micronaut/data/nitrite/runtime/query/NitriteFilterBuilder.java‎

Lines changed: 90 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import io.micronaut.core.annotation.Internal;
1919
import io.micronaut.core.reflect.ClassUtils;
2020
import io.micronaut.data.annotation.Relation;
21+
import io.micronaut.data.exceptions.DataAccessException;
2122
import io.micronaut.data.model.runtime.RuntimeAssociation;
2223
import io.micronaut.data.model.runtime.RuntimePersistentEntity;
2324
import io.micronaut.data.model.runtime.RuntimePersistentProperty;
@@ -57,8 +58,13 @@
5758
public final class NitriteFilterBuilder {
5859

5960
private static final Logger LOG = LoggerFactory.getLogger(NitriteFilterBuilder.class);
61+
62+
// Spatial filter class names (optional dependencies)
6063
private static final String SPATIAL_FLUENT_FILTER_CLASS = "org.dizitart.no2.spatial.SpatialFluentFilter";
61-
private static final String GEOMETRY_CLASS = "org.locationtech.jts.geom.Geometry";
64+
private static final String GEO_POINT_CLASS = "org.dizitart.no2.spatial.GeoPoint";
65+
private static final String JTS_GEOMETRY_CLASS = "org.locationtech.jts.geom.Geometry";
66+
private static final String JTS_POINT_CLASS = "org.locationtech.jts.geom.Point";
67+
private static final String JTS_COORDINATE_CLASS = "org.locationtech.jts.geom.Coordinate";
6268

6369
/**
6470
* A filter that matches no documents (used for empty IN clauses).
@@ -1135,59 +1141,107 @@ private Filter buildNearFilter(String field, Object value, Object[] params, Map<
11351141
Object center = entityMapper.toNitriteFilterValue(preConvertForFilter(resolveValue(m.get("center"), params, namedParameters)), field);
11361142
Object distanceObj = resolveValue(m.get("distance"), params, namedParameters);
11371143
double distance = distanceObj instanceof Number n ? n.doubleValue() : 0.0;
1138-
return createSpatialFilter(field, "near", new Class<?>[]{Object.class, double.class}, center, distance);
1139-
}
1140-
return Filter.ALL;
1141-
}
1142-
1143-
private Filter createSpatialFilter(String field, String method, Class<?>[] argTypes, Object... args) {
1144-
if (ClassUtils.isPresent(SPATIAL_FLUENT_FILTER_CLASS, null)) {
1145-
try {
1146-
Class<?> spatialClass = Class.forName(SPATIAL_FLUENT_FILTER_CLASS);
1147-
Method whereMethod = spatialClass.getMethod("where", String.class);
1148-
Object spatialFluentFilter = whereMethod.invoke(null, field);
1149-
if ("near".equals(method) && args.length == 2) {
1150-
Object center = args[0];
1151-
double distance = args[1] instanceof Number n ? n.doubleValue() : 0.0;
1152-
Object coordinate = null;
1153-
if (center != null && ClassUtils.isPresent(GEOMETRY_CLASS, null)) {
1154-
Class<?> geometryClass = Class.forName(GEOMETRY_CLASS);
1144+
1145+
// Use reflection to create spatial filters (nitrite-spatial is optional)
1146+
if (ClassUtils.isPresent(SPATIAL_FLUENT_FILTER_CLASS, null)) {
1147+
try {
1148+
Class<?> spatialClass = Class.forName(SPATIAL_FLUENT_FILTER_CLASS);
1149+
Method whereMethod = spatialClass.getMethod("where", String.class);
1150+
Object spatialFluentFilter = whereMethod.invoke(null, field);
1151+
1152+
// Handle GeoPoint (via reflection to avoid hard dependency)
1153+
if (center != null && ClassUtils.isPresent(GEO_POINT_CLASS, null)) {
1154+
Class<?> geoPointClass = Class.forName(GEO_POINT_CLASS);
1155+
if (geoPointClass.isInstance(center)) {
1156+
Object point = geoPointClass.getMethod("getPoint").invoke(center);
1157+
Method nearMethod = spatialFluentFilter.getClass().getMethod("near", Class.forName(JTS_POINT_CLASS), Double.class);
1158+
return (Filter) nearMethod.invoke(spatialFluentFilter, point, distance);
1159+
}
1160+
}
1161+
1162+
// Handle JTS Point
1163+
if (center != null && ClassUtils.isPresent(JTS_POINT_CLASS, null)) {
1164+
Class<?> pointClass = Class.forName(JTS_POINT_CLASS);
1165+
if (pointClass.isInstance(center)) {
1166+
Method nearMethod = spatialFluentFilter.getClass().getMethod("near", pointClass, Double.class);
1167+
return (Filter) nearMethod.invoke(spatialFluentFilter, center, distance);
1168+
}
1169+
}
1170+
1171+
// Handle JTS Geometry - extract coordinate
1172+
if (center != null && ClassUtils.isPresent(JTS_GEOMETRY_CLASS, null)) {
1173+
Class<?> geometryClass = Class.forName(JTS_GEOMETRY_CLASS);
11551174
if (geometryClass.isInstance(center)) {
1156-
Method getCoordinateMethod = geometryClass.getMethod("getCoordinate");
1157-
coordinate = getCoordinateMethod.invoke(center);
1175+
Method getCoordMethod = geometryClass.getMethod("getCoordinate");
1176+
Object coord = getCoordMethod.invoke(center);
1177+
if (coord != null) {
1178+
Method nearMethod = spatialFluentFilter.getClass().getMethod("near", Class.forName(JTS_COORDINATE_CLASS), Double.class);
1179+
return (Filter) nearMethod.invoke(spatialFluentFilter, coord, distance);
1180+
}
11581181
}
11591182
}
1160-
if (coordinate != null) {
1161-
Method filterMethod = spatialFluentFilter.getClass().getMethod("near", Class.forName("org.locationtech.jts.geom.Coordinate"), Double.class);
1162-
return (Filter) filterMethod.invoke(spatialFluentFilter, coordinate, Double.valueOf(distance));
1183+
1184+
// Fallback for Coordinate
1185+
if (center != null && ClassUtils.isPresent(JTS_COORDINATE_CLASS, null)) {
1186+
Class<?> coordClass = Class.forName(JTS_COORDINATE_CLASS);
1187+
if (coordClass.isInstance(center)) {
1188+
Method nearMethod = spatialFluentFilter.getClass().getMethod("near", coordClass, Double.class);
1189+
return (Filter) nearMethod.invoke(spatialFluentFilter, center, distance);
1190+
}
11631191
}
1164-
} else {
1165-
Method filterMethod = spatialFluentFilter.getClass().getMethod(method, argTypes);
1166-
return (Filter) filterMethod.invoke(spatialFluentFilter, args);
1192+
1193+
if (center != null) {
1194+
throw new DataAccessException("Unsupported center type for $near spatial filter: " + center.getClass().getName());
1195+
}
1196+
} catch (DataAccessException e) {
1197+
throw e;
1198+
} catch (Exception e) {
1199+
throw new DataAccessException("Failed to create $near spatial filter on field '" + field + "': " + e.getMessage(), e);
11671200
}
1168-
} catch (Exception e) {
1169-
throw new RuntimeException("Failed to create spatial filter for method: " + method, e);
11701201
}
11711202
}
11721203
return Filter.ALL;
11731204
}
11741205

11751206
private Filter createSpatialFilter(String field, Object geometry, String method) {
1176-
if (geometry == null || !ClassUtils.isPresent(GEOMETRY_CLASS, null)) {
1207+
if (geometry == null) {
11771208
return Filter.ALL;
11781209
}
1179-
try {
1180-
Class<?> geometryClass = Class.forName(GEOMETRY_CLASS);
1181-
if (geometryClass.isInstance(geometry)) {
1210+
1211+
// Use reflection to create spatial filters (nitrite-spatial is optional)
1212+
if (ClassUtils.isPresent(SPATIAL_FLUENT_FILTER_CLASS, null)) {
1213+
try {
11821214
Class<?> spatialClass = Class.forName(SPATIAL_FLUENT_FILTER_CLASS);
11831215
Method whereMethod = spatialClass.getMethod("where", String.class);
11841216
Object spatialFluentFilter = whereMethod.invoke(null, field);
1185-
Method filterMethod = spatialFluentFilter.getClass().getMethod(method, geometryClass);
1186-
return (Filter) filterMethod.invoke(spatialFluentFilter, geometry);
1217+
1218+
// Handle GeoPoint for within/intersects (convert to Point via reflection)
1219+
if (ClassUtils.isPresent(GEO_POINT_CLASS, null)) {
1220+
Class<?> geoPointClass = Class.forName(GEO_POINT_CLASS);
1221+
if (geoPointClass.isInstance(geometry)) {
1222+
Object point = geoPointClass.getMethod("getPoint").invoke(geometry);
1223+
Method filterMethod = spatialFluentFilter.getClass().getMethod(method, Class.forName(JTS_POINT_CLASS));
1224+
return (Filter) filterMethod.invoke(spatialFluentFilter, point);
1225+
}
1226+
}
1227+
1228+
// Handle JTS Geometry directly
1229+
if (ClassUtils.isPresent(JTS_GEOMETRY_CLASS, null)) {
1230+
Class<?> geometryClass = Class.forName(JTS_GEOMETRY_CLASS);
1231+
if (geometryClass.isInstance(geometry)) {
1232+
Method filterMethod = spatialFluentFilter.getClass().getMethod(method, geometryClass);
1233+
return (Filter) filterMethod.invoke(spatialFluentFilter, geometry);
1234+
}
1235+
}
1236+
1237+
throw new DataAccessException("Unsupported geometry type for $" + method + " spatial filter: " + geometry.getClass().getName());
1238+
} catch (DataAccessException e) {
1239+
throw e;
1240+
} catch (Exception e) {
1241+
throw new DataAccessException("Failed to create $" + method + " spatial filter on field '" + field + "': " + e.getMessage(), e);
11871242
}
1188-
} catch (Exception e) {
1189-
throw new RuntimeException("Failed to create spatial filter for method: " + method, e);
11901243
}
1244+
11911245
return Filter.ALL;
11921246
}
11931247

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package io.micronaut.data.nitrite.storage
2+
3+
import io.micronaut.context.ApplicationContext
4+
import io.micronaut.data.nitrite.model.IndexedBook
5+
import io.micronaut.data.nitrite.repository.IndexedBookRepository
6+
import org.dizitart.no2.Nitrite
7+
import org.dizitart.no2.collection.NitriteCollection
8+
import org.dizitart.no2.index.IndexType
9+
import org.locationtech.jts.geom.Coordinate
10+
import org.locationtech.jts.geom.Geometry
11+
import org.locationtech.jts.geom.GeometryFactory
12+
import org.locationtech.jts.geom.Polygon
13+
import spock.lang.Specification
14+
15+
/**
16+
* Comprehensive spatial test suite for Nitrite integration.
17+
* Tests spatial indexes, JTS Geometry support, and all spatial operators.
18+
*
19+
* Note: GeoPoint tests require nitrite-spatial 4.3.3+ (not yet released).
20+
* Current tests use JTS Geometry types which work with nitrite-spatial 4.3.2.
21+
*/
22+
class NitriteSpatialSpec extends Specification {
23+
24+
void "test JTS Geometry spatial queries with index verification"() {
25+
given:
26+
def ctx = ApplicationContext.run([
27+
"nitrite.storage-mode": "IN_MEMORY"
28+
])
29+
def repository = ctx.getBean(IndexedBookRepository)
30+
def db = ctx.getBean(Nitrite)
31+
def factory = new GeometryFactory()
32+
33+
// Create points for different locations
34+
def maine = factory.createPoint(new Coordinate(-69.0, 45.0))
35+
def colorado = factory.createPoint(new Coordinate(-105.0, 40.0))
36+
def california = factory.createPoint(new Coordinate(-122.4194, 37.7749))
37+
38+
when: "Save books with JTS Geometry locations"
39+
def book1 = new IndexedBook("Book1", 100, "Horror novel", maine)
40+
def book2 = new IndexedBook("Book2", 200, "Mystery novel", colorado)
41+
def book3 = new IndexedBook("Book3", 300, "Sci-fi novel", california)
42+
repository.save(book1)
43+
repository.save(book2)
44+
repository.save(book3)
45+
46+
then: "Books should be saved with Geometry preserved"
47+
repository.findAll().size() == 3
48+
repository.findAll().every { it.location == null || it.location instanceof Geometry }
49+
50+
and: "Spatial index is created on location field"
51+
def collection = db.getCollection("IndexedBook")
52+
def indices = collection.listIndices()
53+
def spatialIndices = indices.findAll { it.indexType.toString() == "Spatial" }
54+
spatialIndices.size() == 1
55+
spatialIndices[0].fields.getFieldNames().contains("location")
56+
57+
and: "Spatial index descriptor shows correct type"
58+
def spatialIndexDesc = spatialIndices[0]
59+
spatialIndexDesc.indexType.toString() == "Spatial"
60+
61+
and: "\$near spatial filter works with JTS Point"
62+
def nearResults = repository.findByLocationNear(maine, 0.1)
63+
nearResults.size() == 1
64+
nearResults[0].title == "Book1"
65+
66+
and: "\$within spatial filter works with Polygon"
67+
def maineBox = factory.createPolygon([
68+
new Coordinate(-71.0, 43.0),
69+
new Coordinate(-67.0, 43.0),
70+
new Coordinate(-67.0, 47.0),
71+
new Coordinate(-71.0, 47.0),
72+
new Coordinate(-71.0, 43.0)
73+
] as Coordinate[])
74+
def withinResults = repository.findByLocationWithin(maineBox)
75+
withinResults.size() == 1
76+
withinResults[0].title == "Book1"
77+
78+
and: "\$intersects spatial filter works"
79+
def intersectingLine = factory.createLineString([
80+
new Coordinate(-70.0, 44.0),
81+
new Coordinate(-68.0, 46.0)
82+
] as Coordinate[])
83+
def intersectsResults = repository.findByLocationIntersects(intersectingLine)
84+
intersectsResults.size() == 1
85+
intersectsResults[0].title == "Book1"
86+
87+
cleanup:
88+
ctx.close()
89+
}
90+
91+
void "test spatial index is delegated to Nitrite's SpatialIndexer"() {
92+
given:
93+
def ctx = ApplicationContext.run([
94+
"nitrite.storage-mode": "IN_MEMORY"
95+
])
96+
def repository = ctx.getBean(IndexedBookRepository)
97+
def db = ctx.getBean(Nitrite)
98+
def factory = new GeometryFactory()
99+
def maine = factory.createPoint(new Coordinate(-69.0, 45.0))
100+
def book = new IndexedBook("Test Book", 100, "Test description", maine)
101+
102+
when: "Save a book to trigger index population"
103+
repository.save(book)
104+
def collection = db.getCollection("IndexedBook")
105+
def indices = collection.listIndices()
106+
def spatialIndices = indices.findAll { it.indexType.toString() == "Spatial" }
107+
108+
then: "Spatial index exists on the location field"
109+
spatialIndices.size() == 1
110+
spatialIndices[0].fields.getFieldNames().contains("location")
111+
112+
and: "Spatial near query uses the index successfully"
113+
def results = repository.findByLocationNear(maine, 0.1)
114+
results.size() == 1
115+
results[0].title == "Test Book"
116+
117+
cleanup:
118+
ctx.close()
119+
}
120+
121+
void "test spatial index with null geometry"() {
122+
given:
123+
def ctx = ApplicationContext.run([
124+
"nitrite.storage-mode": "IN_MEMORY"
125+
])
126+
def repository = ctx.getBean(IndexedBookRepository)
127+
128+
when: "Save book with null location"
129+
def book = new IndexedBook("No Location", 100, "No geometry", null)
130+
repository.save(book)
131+
132+
then: "Should handle null geometry gracefully"
133+
repository.findAll().size() == 1
134+
repository.findAll()[0].location == null
135+
136+
and: "Spatial index still exists"
137+
def db = ctx.getBean(Nitrite)
138+
def collection = db.getCollection("IndexedBook")
139+
def indices = collection.listIndices()
140+
def spatialIndices = indices.findAll { it.indexType.toString() == "Spatial" }
141+
spatialIndices.size() == 1
142+
143+
cleanup:
144+
ctx.close()
145+
}
146+
}

0 commit comments

Comments
 (0)