Skip to content

Commit 2cbc90d

Browse files
committed
feat: add support for indexing nested fields in arrays (#519)
Enables automatic indexing of fields within List<Model> properties when annotated with @indexed(schemaFieldType = SchemaFieldType.NESTED). - Modify RediSearchIndexer to automatically detect and index nested fields within collections marked with SchemaFieldType.NESTED - Update RediSearchQuery to handle nested field paths in repository query methods (e.g., findByPhonesListNumber) - Create comprehensive test suite demonstrating nested array indexing capabilities including edge cases and performance tests - Support automatic field type detection for nested properties (String, Number, Boolean, Point, etc.)
1 parent 678f955 commit 2cbc90d

File tree

6 files changed

+511
-3
lines changed

6 files changed

+511
-3
lines changed

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

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -569,12 +569,30 @@ else if (fieldType == Point.class) {
569569
case GEO -> fields.add(SearchField.of(field, indexAsGeoFieldFor(field, true, prefix, indexed.alias())));
570570
case VECTOR -> fields.add(SearchField.of(field, indexAsVectorFieldFor(field, isDocument, prefix, indexed)));
571571
case NESTED -> {
572+
Class<?> nestedType = field.getType();
573+
574+
// Handle List<Model> fields by extracting the element type
575+
if (List.class.isAssignableFrom(nestedType) || Set.class.isAssignableFrom(nestedType)) {
576+
Optional<Class<?>> maybeCollectionType = getCollectionElementClass(field);
577+
if (maybeCollectionType.isPresent()) {
578+
nestedType = maybeCollectionType.get();
579+
logger.info(String.format("Processing nested array field %s with element type %s", field.getName(),
580+
nestedType.getSimpleName()));
581+
} else {
582+
logger.warn(String.format("Could not determine element type for nested field %s", field.getName()));
583+
break;
584+
}
585+
}
586+
587+
// Process all fields of the nested type automatically
572588
for (java.lang.reflect.Field subfield : com.redis.om.spring.util.ObjectUtils.getDeclaredFieldsTransitively(
573-
field.getType())) {
589+
nestedType)) {
574590
String subfieldPrefix = (prefix == null || prefix.isBlank()) ?
575591
field.getName() :
576592
String.join(".", prefix, field.getName());
577-
fields.addAll(findIndexFields(subfield, subfieldPrefix, isDocument));
593+
594+
// For nested fields, automatically create index fields even without explicit annotations
595+
fields.addAll(createNestedIndexFields(field, subfield, subfieldPrefix, isDocument));
578596
}
579597
}
580598
default -> {
@@ -869,6 +887,80 @@ private List<SearchField> getNestedField(String fieldPrefix, java.lang.reflect.F
869887
return fieldList;
870888
}
871889

890+
/**
891+
* Creates index fields for nested array elements automatically.
892+
* This method handles automatic indexing of all fields within nested objects
893+
* when @Indexed(schemaFieldType = SchemaFieldType.NESTED) is used.
894+
*/
895+
private List<SearchField> createNestedIndexFields(java.lang.reflect.Field arrayField,
896+
java.lang.reflect.Field nestedField, String prefix, boolean isDocument) {
897+
List<SearchField> fields = new ArrayList<>();
898+
899+
Class<?> nestedFieldType = ClassUtils.resolvePrimitiveIfNecessary(nestedField.getType());
900+
901+
// For nested arrays, the path should be: $.arrayField[*].nestedField
902+
// The prefix already contains the array field name, so we just need [*].nestedField
903+
String fullFieldPath = isDocument ?
904+
"$." + arrayField.getName() + "[*]." + nestedField.getName() :
905+
arrayField.getName() + "[*]." + nestedField.getName();
906+
907+
logger.info(String.format("Creating automatic nested field index: %s -> %s", arrayField.getName(), fullFieldPath));
908+
909+
// Determine field type and create appropriate index field
910+
if (CharSequence.class.isAssignableFrom(
911+
nestedFieldType) || nestedFieldType == Boolean.class || nestedFieldType == UUID.class || nestedFieldType == Ulid.class) {
912+
913+
// Create TAG field for strings, booleans, UUIDs, and ULIDs
914+
FieldName fieldName = FieldName.of(fullFieldPath);
915+
String alias = QueryUtils.searchIndexFieldAliasFor(nestedField, prefix);
916+
if (alias != null && !alias.isEmpty()) {
917+
fieldName = fieldName.as(alias);
918+
}
919+
920+
fields.add(SearchField.of(arrayField, getTagField(fieldName, "|", false)));
921+
922+
} else if (Number.class.isAssignableFrom(
923+
nestedFieldType) || nestedFieldType == LocalDateTime.class || nestedFieldType == LocalDate.class || nestedFieldType == Date.class || nestedFieldType == Instant.class || nestedFieldType == OffsetDateTime.class) {
924+
925+
// Create NUMERIC field for numbers and dates
926+
FieldName fieldName = FieldName.of(fullFieldPath);
927+
String alias = QueryUtils.searchIndexFieldAliasFor(nestedField, prefix);
928+
if (alias != null && !alias.isEmpty()) {
929+
fieldName = fieldName.as(alias);
930+
}
931+
932+
fields.add(SearchField.of(arrayField, NumericField.of(fieldName)));
933+
934+
} else if (nestedFieldType == Point.class) {
935+
936+
// Create GEO field for Point objects
937+
FieldName fieldName = FieldName.of(fullFieldPath);
938+
String alias = QueryUtils.searchIndexFieldAliasFor(nestedField, prefix);
939+
if (alias != null && !alias.isEmpty()) {
940+
fieldName = fieldName.as(alias);
941+
}
942+
943+
fields.add(SearchField.of(arrayField, GeoField.of(fieldName)));
944+
945+
} else if (nestedFieldType.isEnum()) {
946+
947+
// Create TAG field for enums
948+
FieldName fieldName = FieldName.of(fullFieldPath);
949+
String alias = QueryUtils.searchIndexFieldAliasFor(nestedField, prefix);
950+
if (alias != null && !alias.isEmpty()) {
951+
fieldName = fieldName.as(alias);
952+
}
953+
954+
fields.add(SearchField.of(arrayField, getTagField(fieldName, "|", false)));
955+
956+
} else {
957+
logger.debug(String.format("Skipping nested field %s of unsupported type %s", nestedField.getName(),
958+
nestedFieldType.getSimpleName()));
959+
}
960+
961+
return fields;
962+
}
963+
872964
private TagField getTagField(FieldName fieldName, String separator, boolean sortable) {
873965
return getTagField(fieldName, separator, sortable, false, false);
874966
}

redis-om-spring/src/main/java/com/redis/om/spring/repository/query/RediSearchQuery.java

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,38 @@ else if (Set.class.isAssignableFrom(fieldType) || List.class.isAssignableFrom(fi
438438
Optional<Class<?>> maybeCollectionType = ObjectUtils.getCollectionElementClass(field);
439439
if (maybeCollectionType.isPresent()) {
440440
Class<?> collectionType = maybeCollectionType.get();
441-
if (Number.class.isAssignableFrom(collectionType)) {
441+
442+
// Check if this is a nested array field with @Indexed(schemaFieldType = SchemaFieldType.NESTED)
443+
if (indexAnnotation.schemaFieldType() == SchemaFieldType.NESTED) {
444+
// For nested arrays, we need to create the proper field path
445+
String nestedFieldName = path.size() > level + 1 ? path.get(level + 1).getSegment() : "";
446+
if (!nestedFieldName.isEmpty()) {
447+
// Create the nested field path: arrayField_nestedField
448+
String nestedKey = field.getName() + "_" + nestedFieldName;
449+
450+
logger.debug(String.format("Processing nested array field query: %s -> %s", key, nestedKey));
451+
452+
// Determine the field type for the nested field
453+
Field nestedField = ReflectionUtils.findField(collectionType, nestedFieldName);
454+
if (nestedField != null) {
455+
// Get the alias matching the indexer logic
456+
String alias = QueryUtils.searchIndexFieldAliasFor(nestedField, field.getName());
457+
String actualNestedKey = (alias != null && !alias.isEmpty()) ? alias : nestedKey;
458+
Class<?> nestedFieldType = ClassUtils.resolvePrimitiveIfNecessary(nestedField.getType());
459+
460+
if (CharSequence.class.isAssignableFrom(
461+
nestedFieldType) || nestedFieldType == Boolean.class || nestedFieldType == UUID.class || nestedFieldType == Ulid.class || nestedFieldType
462+
.isEnum()) {
463+
qf.add(Pair.of(actualNestedKey, QueryClause.get(FieldType.TAG, part.getType())));
464+
} else if (Number.class.isAssignableFrom(
465+
nestedFieldType) || nestedFieldType == LocalDateTime.class || nestedFieldType == LocalDate.class || nestedFieldType == Date.class) {
466+
qf.add(Pair.of(actualNestedKey, QueryClause.get(FieldType.NUMERIC, part.getType())));
467+
} else if (nestedFieldType == Point.class) {
468+
qf.add(Pair.of(actualNestedKey, QueryClause.get(FieldType.GEO, part.getType())));
469+
}
470+
}
471+
}
472+
} else if (Number.class.isAssignableFrom(collectionType)) {
442473
if (isANDQuery) {
443474
qf.add(Pair.of(actualKey, QueryClause.NUMERIC_CONTAINING_ALL));
444475
} else {

0 commit comments

Comments
 (0)