Skip to content

Commit 0e40303

Browse files
committed
feat: add native repository method support for querying nested fields in Map<String, ComplexObject>
Extends Redis OM Spring to support repository method patterns like findByPositionsMapContainsCusip for querying nested fields within Map values containing complex objects. This enables RDI-compatible queries such as @positions_cusip:{AAPL} without requiring @query annotations. - Add MapContains pattern detection in QueryClause for nested fields - Extend MetamodelGenerator to create metamodel fields for Map nested properties - Implement special query processing in RediSearchQuery for MapContains patterns - Support both simple Map value queries and complex nested field queries - Enable numeric comparisons on nested fields (GreaterThan, Between, etc.) This allows Spring Data repository methods to naturally express queries on nested fields within Map values, matching the RDI index structure for JSON documents with complex Map fields.
1 parent 3995472 commit 0e40303

File tree

12 files changed

+970
-14
lines changed

12 files changed

+970
-14
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,4 @@ build/
4545
.gradle/
4646
compile_debug.log
4747
docs/.cache/*
48+
/test_output.log

docs/content/modules/ROOT/pages/json-map-fields.adoc

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ Redis OM Spring supports Maps with the following value types:
2727
=== Spatial Types
2828
* `Point` - Indexed as GEO fields for spatial queries
2929

30+
=== Complex Object Types (New in 1.0.0)
31+
* Any custom class with `@Indexed` fields - Enables querying nested properties within map values
32+
3033
== Basic Usage
3134

3235
=== Entity Definition with Map Fields
@@ -136,9 +139,159 @@ LocalDateTime lastWeek = LocalDateTime.now().minusWeeks(1);
136139
List<Product> recentlyUpdated = repository.findByTimestampsMapContainsAfter(lastWeek);
137140
----
138141

142+
== Complex Object Values in Maps
143+
144+
=== Defining Complex Objects as Map Values
145+
146+
Redis OM Spring now supports Maps with complex object values, enabling you to query nested fields within those objects. This is particularly useful for scenarios like financial portfolios, inventory systems, or any domain requiring dynamic collections of structured data.
147+
148+
[source,java]
149+
----
150+
// Define the complex object
151+
@Data
152+
public class Position {
153+
@Indexed
154+
private String cusip; // Security identifier
155+
156+
@Indexed
157+
private String description;
158+
159+
@Indexed
160+
private String manager;
161+
162+
@Indexed
163+
private Integer quantity;
164+
165+
@Indexed
166+
private BigDecimal price;
167+
168+
@Indexed
169+
private LocalDate asOfDate;
170+
}
171+
172+
// Use it in a Map field
173+
@Data
174+
@Document
175+
public class Account {
176+
@Id
177+
private String id;
178+
179+
@Indexed
180+
private String accountNumber;
181+
182+
@Indexed
183+
private String accountHolder;
184+
185+
// Map with complex object values
186+
@Indexed(schemaFieldType = SchemaFieldType.NESTED)
187+
private Map<String, Position> positions = new HashMap<>();
188+
189+
@Indexed
190+
private BigDecimal totalValue;
191+
}
192+
----
193+
194+
=== Querying Nested Fields in Complex Map Values
195+
196+
Redis OM Spring provides a special query pattern `MapContains<NestedField>` for querying nested properties within map values:
197+
198+
[source,java]
199+
----
200+
public interface AccountRepository extends RedisDocumentRepository<Account, String> {
201+
202+
// Query by nested CUSIP field
203+
List<Account> findByPositionsMapContainsCusip(String cusip);
204+
205+
// Query by nested Manager field
206+
List<Account> findByPositionsMapContainsManager(String manager);
207+
208+
// Numeric comparisons on nested fields
209+
List<Account> findByPositionsMapContainsQuantityGreaterThan(Integer quantity);
210+
List<Account> findByPositionsMapContainsPriceLessThan(BigDecimal price);
211+
212+
// Temporal queries on nested fields
213+
List<Account> findByPositionsMapContainsAsOfDateAfter(LocalDate date);
214+
List<Account> findByPositionsMapContainsAsOfDateBetween(LocalDate start, LocalDate end);
215+
216+
// Combine with regular field queries
217+
List<Account> findByAccountHolderAndPositionsMapContainsManager(
218+
String accountHolder, String manager
219+
);
220+
221+
// Multiple nested field conditions
222+
List<Account> findByPositionsMapContainsCusipAndPositionsMapContainsQuantityGreaterThan(
223+
String cusip, Integer minQuantity
224+
);
225+
}
226+
----
227+
228+
=== Usage Example
229+
230+
[source,java]
231+
----
232+
// Create account with positions
233+
Account account = new Account();
234+
account.setAccountNumber("10190001");
235+
account.setAccountHolder("John Doe");
236+
account.setTotalValue(new BigDecimal("100000.00"));
237+
238+
// Add positions
239+
Position applePosition = new Position();
240+
applePosition.setCusip("AAPL");
241+
applePosition.setDescription("Apple Inc.");
242+
applePosition.setManager("Jane Smith");
243+
applePosition.setQuantity(100);
244+
applePosition.setPrice(new BigDecimal("150.00"));
245+
applePosition.setAsOfDate(LocalDate.now());
246+
account.getPositions().put("AAPL", applePosition);
247+
248+
Position googlePosition = new Position();
249+
googlePosition.setCusip("GOOGL");
250+
googlePosition.setDescription("Alphabet Inc.");
251+
googlePosition.setManager("Bob Johnson");
252+
googlePosition.setQuantity(50);
253+
googlePosition.setPrice(new BigDecimal("2800.00"));
254+
googlePosition.setAsOfDate(LocalDate.now());
255+
account.getPositions().put("GOOGL", googlePosition);
256+
257+
accountRepository.save(account);
258+
259+
// Query examples
260+
// Find all accounts holding Apple stock
261+
List<Account> appleHolders = repository.findByPositionsMapContainsCusip("AAPL");
262+
263+
// Find accounts with positions managed by Jane Smith
264+
List<Account> janesManagedAccounts = repository.findByPositionsMapContainsManager("Jane Smith");
265+
266+
// Find accounts with any position having quantity > 75
267+
List<Account> largePositions = repository.findByPositionsMapContainsQuantityGreaterThan(75);
268+
269+
// Find accounts with positions priced below $200
270+
List<Account> affordablePositions = repository.findByPositionsMapContainsPriceLessThan(
271+
new BigDecimal("200.00")
272+
);
273+
----
274+
275+
=== Index Structure
276+
277+
When you use complex objects in Maps, Redis OM Spring creates indexes for each nested field using JSONPath expressions:
278+
279+
[source,text]
280+
----
281+
// Generated index fields for Map<String, Position>
282+
$.positions.*.cusip -> TAG field (positions_cusip)
283+
$.positions.*.manager -> TAG field (positions_manager)
284+
$.positions.*.quantity -> NUMERIC field (positions_quantity)
285+
$.positions.*.price -> NUMERIC field (positions_price)
286+
$.positions.*.asOfDate -> NUMERIC field (positions_asOfDate)
287+
$.positions.*.description -> TAG field (positions_description)
288+
----
289+
290+
This structure enables efficient queries across all map values, regardless of their keys.
291+
139292
== Advanced Examples
140293

141-
=== Working with Complex Value Types
294+
=== Working with Other Complex Value Types
142295

143296
[source,java]
144297
----
@@ -326,6 +479,7 @@ List<Entity> findByRegularFieldAndMapFieldMapContains(
326479
* **No partial matching**: String values in maps use TAG indexing (exact match only)
327480
* **GEO queries**: Point values support equality through proximity search with minimal radius
328481
* **Collection values**: Maps with collection-type values are not supported
482+
* **Complex object nesting depth**: While you can query nested fields in complex Map values, deeply nested objects (object within object within map) may have limited query support
329483

330484
== Best Practices
331485

docs/content/modules/ROOT/pages/json_mappings.adoc

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ public class Company {
207207

208208
=== Map Field Support
209209

210-
Redis OM Spring provides comprehensive support for `Map<String, T>` fields, enabling dynamic key-value pairs with full indexing and query capabilities:
210+
Redis OM Spring provides comprehensive support for `Map<String, T>` fields, enabling dynamic key-value pairs with full indexing and query capabilities. Starting with version 1.0.0, this includes support for complex object values with queryable nested fields:
211211

212212
[source,java]
213213
----
@@ -227,6 +227,9 @@ public class Product {
227227
228228
@Indexed
229229
private Map<String, LocalDateTime> events; // Temporal data
230+
231+
@Indexed(schemaFieldType = SchemaFieldType.NESTED)
232+
private Map<String, ComplexObject> items; // Complex objects (v1.0.0+)
230233
}
231234
----
232235

@@ -239,6 +242,9 @@ public interface ProductRepository extends RedisDocumentRepository<Product, Stri
239242
List<Product> findByAttributesMapContains(String value);
240243
List<Product> findBySpecificationsMapContainsGreaterThan(Double value);
241244
List<Product> findByFeaturesMapContains(Boolean hasFeature);
245+
246+
// Query nested fields in complex map values (v1.0.0+)
247+
List<Product> findByItemsMapContainsPropertyName(String propertyValue);
242248
}
243249
----
244250

redis-om-spring/src/main/java/com/redis/om/spring/indexing/RediSearchIndexer.java

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -601,9 +601,63 @@ else if (Map.class.isAssignableFrom(fieldType) && isDocument) {
601601
GeoField geoField = GeoField.of(FieldName.of(mapJsonPath).as(mapFieldAlias));
602602
fields.add(SearchField.of(field, geoField));
603603
logger.info(String.format("Added GEO field for Map: %s as %s", field.getName(), mapFieldAlias));
604+
} else {
605+
// Handle complex object values in Map by recursively indexing their @Indexed fields
606+
logger.info(String.format("Processing complex object Map field: %s with value type %s", field.getName(),
607+
valueType.getName()));
608+
609+
// Recursively process @Indexed fields within the Map value type
610+
for (java.lang.reflect.Field subfield : getDeclaredFieldsTransitively(valueType)) {
611+
if (subfield.isAnnotationPresent(Indexed.class)) {
612+
Indexed subfieldIndexed = subfield.getAnnotation(Indexed.class);
613+
String nestedJsonPath = (prefix == null || prefix.isBlank()) ?
614+
"$." + field.getName() + ".*." + subfield.getName() :
615+
"$." + prefix + "." + field.getName() + ".*." + subfield.getName();
616+
String nestedFieldAlias = field.getName() + "_" + subfield.getName();
617+
618+
logger.info(String.format("Processing nested field %s in Map value type, path: %s, alias: %s",
619+
subfield.getName(), nestedJsonPath, nestedFieldAlias));
620+
621+
Class<?> subfieldType = subfield.getType();
622+
623+
// Create appropriate index field based on subfield type
624+
if (CharSequence.class.isAssignableFrom(
625+
subfieldType) || subfieldType == UUID.class || subfieldType == Ulid.class || subfieldType
626+
.isEnum()) {
627+
// Index as TAG field
628+
TagField tagField = TagField.of(FieldName.of(nestedJsonPath).as(nestedFieldAlias));
629+
if (subfieldIndexed.sortable())
630+
tagField.sortable();
631+
if (subfieldIndexed.indexMissing())
632+
tagField.indexMissing();
633+
if (subfieldIndexed.indexEmpty())
634+
tagField.indexEmpty();
635+
if (!subfieldIndexed.separator().isEmpty()) {
636+
tagField.separator(subfieldIndexed.separator().charAt(0));
637+
}
638+
fields.add(SearchField.of(subfield, tagField));
639+
logger.info(String.format("Added nested TAG field for Map value: %s", nestedFieldAlias));
640+
} else if (Number.class.isAssignableFrom(
641+
subfieldType) || subfieldType == Boolean.class || subfieldType == LocalDateTime.class || subfieldType == LocalDate.class || subfieldType == Date.class || subfieldType == Instant.class || subfieldType == OffsetDateTime.class) {
642+
// Index as NUMERIC field
643+
NumericField numField = NumericField.of(FieldName.of(nestedJsonPath).as(nestedFieldAlias));
644+
if (subfieldIndexed.sortable())
645+
numField.sortable();
646+
if (subfieldIndexed.noindex())
647+
numField.noIndex();
648+
if (subfieldIndexed.indexMissing())
649+
numField.indexMissing();
650+
fields.add(SearchField.of(subfield, numField));
651+
logger.info(String.format("Added nested NUMERIC field for Map value: %s", nestedFieldAlias));
652+
} else if (subfieldType == Point.class) {
653+
// Index as GEO field
654+
GeoField geoField = GeoField.of(FieldName.of(nestedJsonPath).as(nestedFieldAlias));
655+
fields.add(SearchField.of(subfield, geoField));
656+
logger.info(String.format("Added nested GEO field for Map value: %s", nestedFieldAlias));
657+
}
658+
}
659+
}
604660
}
605-
// For complex object values, we could recursively index their fields
606-
// but that would require more complex implementation
607661
}
608662
}
609663
//

redis-om-spring/src/main/java/com/redis/om/spring/metamodel/MetamodelGenerator.java

Lines changed: 96 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -501,9 +501,67 @@ else if (Map.class.isAssignableFrom(targetCls)) {
501501
// We still want the regular Map field for direct access
502502
// targetInterceptor remains set for the Map field itself
503503
} catch (ClassNotFoundException cnfe) {
504-
messager.printMessage(Diagnostic.Kind.WARNING,
505-
"Processing class " + entityName + " could not resolve map value type " + mapValueTypeName);
506-
targetInterceptor = null; // Don't generate field if we can't resolve the type
504+
// Try to find the Map value type using annotation processing API
505+
TypeElement valueTypeElement = processingEnvironment.getElementUtils().getTypeElement(mapValueTypeName);
506+
if (valueTypeElement != null) {
507+
messager.printMessage(Diagnostic.Kind.NOTE,
508+
"Processing complex Map value type: " + mapValueTypeName + " for field: " + field.getSimpleName());
509+
510+
// Process all field elements in the Map value type
511+
for (Element enclosedElement : valueTypeElement.getEnclosedElements()) {
512+
if (enclosedElement.getKind() == ElementKind.FIELD) {
513+
Element subfieldElement = enclosedElement;
514+
if (subfieldElement.getAnnotation(com.redis.om.spring.annotations.Indexed.class) != null) {
515+
String subfieldName = subfieldElement.getSimpleName().toString();
516+
String nestedFieldName = field.getSimpleName().toString().toUpperCase().replace("_",
517+
"") + "_" + subfieldName.toUpperCase().replace("_", "");
518+
519+
TypeMirror subfieldTypeMirror = subfieldElement.asType();
520+
String subfieldTypeName = subfieldTypeMirror.toString();
521+
Class<?> nestedInterceptor = null;
522+
523+
// Determine interceptor type based on type name
524+
if (subfieldTypeName.equals("java.lang.String") || subfieldTypeName.equals(
525+
"java.util.UUID") || subfieldTypeName.contains("Ulid") || isEnum(processingEnvironment,
526+
subfieldTypeMirror)) {
527+
nestedInterceptor = TextTagField.class;
528+
} else if (subfieldTypeName.equals("java.lang.Integer") || subfieldTypeName.equals(
529+
"java.lang.Long") || subfieldTypeName.equals("java.lang.Double") || subfieldTypeName.equals(
530+
"java.lang.Float") || subfieldTypeName.equals("java.math.BigDecimal") || subfieldTypeName
531+
.equals("java.lang.Boolean") || subfieldTypeName.equals(
532+
"java.time.LocalDateTime") || subfieldTypeName.equals(
533+
"java.time.LocalDate") || subfieldTypeName.equals(
534+
"java.util.Date") || subfieldTypeName.equals(
535+
"java.time.Instant") || subfieldTypeName.equals(
536+
"java.time.OffsetDateTime") || subfieldTypeName.equals(
537+
"int") || subfieldTypeName.equals("long") || subfieldTypeName
538+
.equals("double") || subfieldTypeName.equals(
539+
"float") || subfieldTypeName.equals("boolean")) {
540+
nestedInterceptor = NumericField.class;
541+
} else if (subfieldTypeName.equals("org.springframework.data.geo.Point")) {
542+
nestedInterceptor = GeoField.class;
543+
}
544+
545+
if (nestedInterceptor != null) {
546+
// Generate a synthetic field for this nested indexed field
547+
// Use unique field name: positions_cusip, positions_manager, etc.
548+
String uniqueFieldName = chainedFieldName + "_" + subfieldName;
549+
Triple<ObjectGraphFieldSpec, FieldSpec, CodeBlock> nestedField = generateMapNestedFieldMetamodel(
550+
entity, chain, uniqueFieldName, nestedFieldName, nestedInterceptor, subfieldTypeName, field
551+
.getSimpleName().toString(), subfieldName);
552+
fieldMetamodelSpec.add(nestedField);
553+
554+
messager.printMessage(Diagnostic.Kind.NOTE,
555+
"Generated nested Map field: " + nestedFieldName + " for " + subfieldName + " (" + subfieldTypeName + ")");
556+
}
557+
}
558+
}
559+
}
560+
} else {
561+
messager.printMessage(Diagnostic.Kind.WARNING,
562+
"Processing class " + entityName + " could not resolve map value type " + mapValueTypeName);
563+
targetInterceptor = null; // Don't generate field if we can't resolve the type
564+
}
507565
}
508566
}
509567
//
@@ -988,6 +1046,41 @@ private Triple<ObjectGraphFieldSpec, FieldSpec, CodeBlock> generateFieldMetamode
9881046
return Tuples.of(ogf, aField, aFieldInit);
9891047
}
9901048

1049+
private Triple<ObjectGraphFieldSpec, FieldSpec, CodeBlock> generateMapNestedFieldMetamodel(TypeName entity,
1050+
List<Element> chain, String chainFieldName, String nestedFieldName, Class<?> interceptorClass,
1051+
String subfieldTypeName, String mapFieldName, String subfieldName) {
1052+
String fieldAccessor = ObjectUtils.staticField(nestedFieldName);
1053+
1054+
FieldSpec objectField = FieldSpec.builder(Field.class, chainFieldName).addModifiers(Modifier.PUBLIC,
1055+
Modifier.STATIC).build();
1056+
1057+
ObjectGraphFieldSpec ogf = new ObjectGraphFieldSpec(objectField, chain);
1058+
1059+
// Get the subfield type for the parametrized type
1060+
TypeName subfieldType;
1061+
try {
1062+
subfieldType = TypeName.get(ClassUtils.forName(subfieldTypeName, MetamodelGenerator.class.getClassLoader()));
1063+
} catch (Exception e) {
1064+
// Fallback to String if we can't resolve the type
1065+
subfieldType = ClassName.get(String.class);
1066+
}
1067+
1068+
TypeName interceptor = ParameterizedTypeName.get(ClassName.get(interceptorClass), entity, subfieldType);
1069+
1070+
FieldSpec aField = FieldSpec.builder(interceptor, fieldAccessor).addModifiers(Modifier.PUBLIC, Modifier.STATIC)
1071+
.build();
1072+
1073+
// Create the JSONPath for nested Map field: $.mapField.*.subfieldName
1074+
String alias = mapFieldName + "_" + subfieldName;
1075+
String jsonPath = "$." + mapFieldName + ".*." + subfieldName;
1076+
1077+
CodeBlock aFieldInit = CodeBlock.builder().addStatement(
1078+
"$L = new $T(new $T(\"$L\", \"$L\", $T.class, $T.class), true)", fieldAccessor, interceptor,
1079+
SearchFieldAccessor.class, alias, jsonPath, subfieldType, entity).build();
1080+
1081+
return Tuples.of(ogf, aField, aFieldInit);
1082+
}
1083+
9911084
private Pair<FieldSpec, CodeBlock> generateUnboundMetamodelField(TypeName entity, String name, String alias,
9921085
Class<?> type) {
9931086
TypeName interceptor = ParameterizedTypeName.get(ClassName.get(MetamodelField.class), entity, TypeName.get(type));

0 commit comments

Comments
 (0)