Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,4 @@ build/
.gradle/
compile_debug.log
docs/.cache/*
/test_output.log
156 changes: 155 additions & 1 deletion docs/content/modules/ROOT/pages/json-map-fields.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ Redis OM Spring supports Maps with the following value types:
=== Spatial Types
* `Point` - Indexed as GEO fields for spatial queries

=== Complex Object Types (New in 1.0.0)
* Any custom class with `@Indexed` fields - Enables querying nested properties within map values

== Basic Usage

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

== Complex Object Values in Maps

=== Defining Complex Objects as Map Values

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.

[source,java]
----
// Define the complex object
@Data
public class Position {
@Indexed
private String cusip; // Security identifier

@Indexed
private String description;

@Indexed
private String manager;

@Indexed
private Integer quantity;

@Indexed
private BigDecimal price;

@Indexed
private LocalDate asOfDate;
}

// Use it in a Map field
@Data
@Document
public class Account {
@Id
private String id;

@Indexed
private String accountNumber;

@Indexed
private String accountHolder;

// Map with complex object values
@Indexed(schemaFieldType = SchemaFieldType.NESTED)
private Map<String, Position> positions = new HashMap<>();

@Indexed
private BigDecimal totalValue;
}
----

=== Querying Nested Fields in Complex Map Values

Redis OM Spring provides a special query pattern `MapContains<NestedField>` for querying nested properties within map values:

[source,java]
----
public interface AccountRepository extends RedisDocumentRepository<Account, String> {

// Query by nested CUSIP field
List<Account> findByPositionsMapContainsCusip(String cusip);

// Query by nested Manager field
List<Account> findByPositionsMapContainsManager(String manager);

// Numeric comparisons on nested fields
List<Account> findByPositionsMapContainsQuantityGreaterThan(Integer quantity);
List<Account> findByPositionsMapContainsPriceLessThan(BigDecimal price);

// Temporal queries on nested fields
List<Account> findByPositionsMapContainsAsOfDateAfter(LocalDate date);
List<Account> findByPositionsMapContainsAsOfDateBetween(LocalDate start, LocalDate end);

// Combine with regular field queries
List<Account> findByAccountHolderAndPositionsMapContainsManager(
String accountHolder, String manager
);

// Multiple nested field conditions
List<Account> findByPositionsMapContainsCusipAndPositionsMapContainsQuantityGreaterThan(
String cusip, Integer minQuantity
);
}
----

=== Usage Example

[source,java]
----
// Create account with positions
Account account = new Account();
account.setAccountNumber("10190001");
account.setAccountHolder("John Doe");
account.setTotalValue(new BigDecimal("100000.00"));

// Add positions
Position applePosition = new Position();
applePosition.setCusip("AAPL");
applePosition.setDescription("Apple Inc.");
applePosition.setManager("Jane Smith");
applePosition.setQuantity(100);
applePosition.setPrice(new BigDecimal("150.00"));
applePosition.setAsOfDate(LocalDate.now());
account.getPositions().put("AAPL", applePosition);

Position googlePosition = new Position();
googlePosition.setCusip("GOOGL");
googlePosition.setDescription("Alphabet Inc.");
googlePosition.setManager("Bob Johnson");
googlePosition.setQuantity(50);
googlePosition.setPrice(new BigDecimal("2800.00"));
googlePosition.setAsOfDate(LocalDate.now());
account.getPositions().put("GOOGL", googlePosition);

accountRepository.save(account);

// Query examples
// Find all accounts holding Apple stock
List<Account> appleHolders = repository.findByPositionsMapContainsCusip("AAPL");

// Find accounts with positions managed by Jane Smith
List<Account> janesManagedAccounts = repository.findByPositionsMapContainsManager("Jane Smith");

// Find accounts with any position having quantity > 75
List<Account> largePositions = repository.findByPositionsMapContainsQuantityGreaterThan(75);

// Find accounts with positions priced below $200
List<Account> affordablePositions = repository.findByPositionsMapContainsPriceLessThan(
new BigDecimal("200.00")
);
----

=== Index Structure

When you use complex objects in Maps, Redis OM Spring creates indexes for each nested field using JSONPath expressions:

[source,text]
----
// Generated index fields for Map<String, Position>
$.positions.*.cusip -> TAG field (positions_cusip)
$.positions.*.manager -> TAG field (positions_manager)
$.positions.*.quantity -> NUMERIC field (positions_quantity)
$.positions.*.price -> NUMERIC field (positions_price)
$.positions.*.asOfDate -> NUMERIC field (positions_asOfDate)
$.positions.*.description -> TAG field (positions_description)
----

This structure enables efficient queries across all map values, regardless of their keys.

== Advanced Examples

=== Working with Complex Value Types
=== Working with Other Complex Value Types

[source,java]
----
Expand Down Expand Up @@ -326,6 +479,7 @@ List<Entity> findByRegularFieldAndMapFieldMapContains(
* **No partial matching**: String values in maps use TAG indexing (exact match only)
* **GEO queries**: Point values support equality through proximity search with minimal radius
* **Collection values**: Maps with collection-type values are not supported
* **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

== Best Practices

Expand Down
8 changes: 7 additions & 1 deletion docs/content/modules/ROOT/pages/json_mappings.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ public class Company {

=== Map Field Support

Redis OM Spring provides comprehensive support for `Map<String, T>` fields, enabling dynamic key-value pairs with full indexing and query capabilities:
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:

[source,java]
----
Expand All @@ -227,6 +227,9 @@ public class Product {

@Indexed
private Map<String, LocalDateTime> events; // Temporal data

@Indexed(schemaFieldType = SchemaFieldType.NESTED)
private Map<String, ComplexObject> items; // Complex objects (v1.0.0+)
}
----

Expand All @@ -239,6 +242,9 @@ public interface ProductRepository extends RedisDocumentRepository<Product, Stri
List<Product> findByAttributesMapContains(String value);
List<Product> findBySpecificationsMapContainsGreaterThan(Double value);
List<Product> findByFeaturesMapContains(Boolean hasFeature);

// Query nested fields in complex map values (v1.0.0+)
List<Product> findByItemsMapContainsPropertyName(String propertyValue);
}
----

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -601,9 +601,63 @@ else if (Map.class.isAssignableFrom(fieldType) && isDocument) {
GeoField geoField = GeoField.of(FieldName.of(mapJsonPath).as(mapFieldAlias));
fields.add(SearchField.of(field, geoField));
logger.info(String.format("Added GEO field for Map: %s as %s", field.getName(), mapFieldAlias));
} else {
// Handle complex object values in Map by recursively indexing their @Indexed fields
logger.info(String.format("Processing complex object Map field: %s with value type %s", field.getName(),
valueType.getName()));

// Recursively process @Indexed fields within the Map value type
for (java.lang.reflect.Field subfield : getDeclaredFieldsTransitively(valueType)) {
if (subfield.isAnnotationPresent(Indexed.class)) {
Indexed subfieldIndexed = subfield.getAnnotation(Indexed.class);
String nestedJsonPath = (prefix == null || prefix.isBlank()) ?
"$." + field.getName() + ".*." + subfield.getName() :
"$." + prefix + "." + field.getName() + ".*." + subfield.getName();
String nestedFieldAlias = field.getName() + "_" + subfield.getName();

logger.info(String.format("Processing nested field %s in Map value type, path: %s, alias: %s",
subfield.getName(), nestedJsonPath, nestedFieldAlias));

Class<?> subfieldType = subfield.getType();

// Create appropriate index field based on subfield type
if (CharSequence.class.isAssignableFrom(
subfieldType) || subfieldType == UUID.class || subfieldType == Ulid.class || subfieldType
.isEnum()) {
// Index as TAG field
TagField tagField = TagField.of(FieldName.of(nestedJsonPath).as(nestedFieldAlias));
if (subfieldIndexed.sortable())
tagField.sortable();
if (subfieldIndexed.indexMissing())
tagField.indexMissing();
if (subfieldIndexed.indexEmpty())
tagField.indexEmpty();
if (!subfieldIndexed.separator().isEmpty()) {
tagField.separator(subfieldIndexed.separator().charAt(0));
}
fields.add(SearchField.of(subfield, tagField));
logger.info(String.format("Added nested TAG field for Map value: %s", nestedFieldAlias));
} else if (Number.class.isAssignableFrom(
subfieldType) || subfieldType == Boolean.class || subfieldType == LocalDateTime.class || subfieldType == LocalDate.class || subfieldType == Date.class || subfieldType == Instant.class || subfieldType == OffsetDateTime.class) {
// Index as NUMERIC field
NumericField numField = NumericField.of(FieldName.of(nestedJsonPath).as(nestedFieldAlias));
if (subfieldIndexed.sortable())
numField.sortable();
if (subfieldIndexed.noindex())
numField.noIndex();
if (subfieldIndexed.indexMissing())
numField.indexMissing();
fields.add(SearchField.of(subfield, numField));
logger.info(String.format("Added nested NUMERIC field for Map value: %s", nestedFieldAlias));
} else if (subfieldType == Point.class) {
// Index as GEO field
GeoField geoField = GeoField.of(FieldName.of(nestedJsonPath).as(nestedFieldAlias));
fields.add(SearchField.of(subfield, geoField));
logger.info(String.format("Added nested GEO field for Map value: %s", nestedFieldAlias));
}
}
}
}
// For complex object values, we could recursively index their fields
// but that would require more complex implementation
}
}
//
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -501,9 +501,67 @@ else if (Map.class.isAssignableFrom(targetCls)) {
// We still want the regular Map field for direct access
// targetInterceptor remains set for the Map field itself
} catch (ClassNotFoundException cnfe) {
messager.printMessage(Diagnostic.Kind.WARNING,
"Processing class " + entityName + " could not resolve map value type " + mapValueTypeName);
targetInterceptor = null; // Don't generate field if we can't resolve the type
// Try to find the Map value type using annotation processing API
TypeElement valueTypeElement = processingEnvironment.getElementUtils().getTypeElement(mapValueTypeName);
if (valueTypeElement != null) {
messager.printMessage(Diagnostic.Kind.NOTE,
"Processing complex Map value type: " + mapValueTypeName + " for field: " + field.getSimpleName());

// Process all field elements in the Map value type
for (Element enclosedElement : valueTypeElement.getEnclosedElements()) {
if (enclosedElement.getKind() == ElementKind.FIELD) {
Element subfieldElement = enclosedElement;
if (subfieldElement.getAnnotation(com.redis.om.spring.annotations.Indexed.class) != null) {
String subfieldName = subfieldElement.getSimpleName().toString();
String nestedFieldName = field.getSimpleName().toString().toUpperCase().replace("_",
"") + "_" + subfieldName.toUpperCase().replace("_", "");

TypeMirror subfieldTypeMirror = subfieldElement.asType();
String subfieldTypeName = subfieldTypeMirror.toString();
Class<?> nestedInterceptor = null;

// Determine interceptor type based on type name
if (subfieldTypeName.equals("java.lang.String") || subfieldTypeName.equals(
"java.util.UUID") || subfieldTypeName.contains("Ulid") || isEnum(processingEnvironment,
subfieldTypeMirror)) {
nestedInterceptor = TextTagField.class;
} else if (subfieldTypeName.equals("java.lang.Integer") || subfieldTypeName.equals(
"java.lang.Long") || subfieldTypeName.equals("java.lang.Double") || subfieldTypeName.equals(
"java.lang.Float") || subfieldTypeName.equals("java.math.BigDecimal") || subfieldTypeName
.equals("java.lang.Boolean") || subfieldTypeName.equals(
"java.time.LocalDateTime") || subfieldTypeName.equals(
"java.time.LocalDate") || subfieldTypeName.equals(
"java.util.Date") || subfieldTypeName.equals(
"java.time.Instant") || subfieldTypeName.equals(
"java.time.OffsetDateTime") || subfieldTypeName.equals(
"int") || subfieldTypeName.equals("long") || subfieldTypeName
.equals("double") || subfieldTypeName.equals(
"float") || subfieldTypeName.equals("boolean")) {
nestedInterceptor = NumericField.class;
} else if (subfieldTypeName.equals("org.springframework.data.geo.Point")) {
nestedInterceptor = GeoField.class;
}

if (nestedInterceptor != null) {
// Generate a synthetic field for this nested indexed field
// Use unique field name: positions_cusip, positions_manager, etc.
String uniqueFieldName = chainedFieldName + "_" + subfieldName;
Triple<ObjectGraphFieldSpec, FieldSpec, CodeBlock> nestedField = generateMapNestedFieldMetamodel(
entity, chain, uniqueFieldName, nestedFieldName, nestedInterceptor, subfieldTypeName, field
.getSimpleName().toString(), subfieldName);
fieldMetamodelSpec.add(nestedField);

messager.printMessage(Diagnostic.Kind.NOTE,
"Generated nested Map field: " + nestedFieldName + " for " + subfieldName + " (" + subfieldTypeName + ")");
}
}
}
}
} else {
messager.printMessage(Diagnostic.Kind.WARNING,
"Processing class " + entityName + " could not resolve map value type " + mapValueTypeName);
targetInterceptor = null; // Don't generate field if we can't resolve the type
}
}
}
//
Expand Down Expand Up @@ -988,6 +1046,41 @@ private Triple<ObjectGraphFieldSpec, FieldSpec, CodeBlock> generateFieldMetamode
return Tuples.of(ogf, aField, aFieldInit);
}

private Triple<ObjectGraphFieldSpec, FieldSpec, CodeBlock> generateMapNestedFieldMetamodel(TypeName entity,
List<Element> chain, String chainFieldName, String nestedFieldName, Class<?> interceptorClass,
String subfieldTypeName, String mapFieldName, String subfieldName) {
String fieldAccessor = ObjectUtils.staticField(nestedFieldName);

FieldSpec objectField = FieldSpec.builder(Field.class, chainFieldName).addModifiers(Modifier.PUBLIC,
Modifier.STATIC).build();

ObjectGraphFieldSpec ogf = new ObjectGraphFieldSpec(objectField, chain);

// Get the subfield type for the parametrized type
TypeName subfieldType;
try {
subfieldType = TypeName.get(ClassUtils.forName(subfieldTypeName, MetamodelGenerator.class.getClassLoader()));
} catch (Exception e) {
// Fallback to String if we can't resolve the type
subfieldType = ClassName.get(String.class);
}

TypeName interceptor = ParameterizedTypeName.get(ClassName.get(interceptorClass), entity, subfieldType);

FieldSpec aField = FieldSpec.builder(interceptor, fieldAccessor).addModifiers(Modifier.PUBLIC, Modifier.STATIC)
.build();

// Create the JSONPath for nested Map field: $.mapField.*.subfieldName
String alias = mapFieldName + "_" + subfieldName;
String jsonPath = "$." + mapFieldName + ".*." + subfieldName;

CodeBlock aFieldInit = CodeBlock.builder().addStatement(
"$L = new $T(new $T(\"$L\", \"$L\", $T.class, $T.class), true)", fieldAccessor, interceptor,
SearchFieldAccessor.class, alias, jsonPath, subfieldType, entity).build();

return Tuples.of(ogf, aField, aFieldInit);
}

private Pair<FieldSpec, CodeBlock> generateUnboundMetamodelField(TypeName entity, String name, String alias,
Class<?> type) {
TypeName interceptor = ParameterizedTypeName.get(ClassName.get(MetamodelField.class), entity, TypeName.get(type));
Expand Down
Loading