Skip to content

Commit b2f1f7a

Browse files
committed
feat(metamodel): generate indexed subfields for @reference fields (#677)
When a document has a @reference @indexed field pointing to another entity, the metamodel now generates field accessors for the referenced entity's @indexed and @searchable fields. This enables queries like: entityStream.of(RefVehicle.class) .filter(RefVehicle$.OWNER_NAME.eq("John")) Changes: - MetamodelGenerator: Add processReferencedEntityIndexableFields() to traverse referenced entities and generate subfield accessors - RediSearchIndexer: Add createIndexedFieldsForReferencedEntity() to create search index fields for referenced entity properties Supported annotation attributes for referenced entity fields: - @searchable: weight, sortable, nostem, noindex, phonetic, indexMissing, indexEmpty - @TextIndexed: weight, sortable, nostem, noindex, phonetic, indexMissing, indexEmpty - @indexed: sortable, separator, indexMissing, indexEmpty - @TagIndexed: separator, indexMissing, indexEmpty - @NumericIndexed: sortable, noindex, indexMissing - @indexed (Boolean): sortable, indexMissing, indexEmpty - @indexed (Enum): sortable, separator, indexMissing, indexEmpty For example, given: @document class Owner { @searchable String name; @indexed String email; @TagIndexed String category; @NumericIndexed Integer age; @indexed Boolean active; } @document class RefVehicle { @reference @indexed Owner owner; } The RefVehicle$ metamodel now includes: - OWNER_NAME (TextField) - OWNER_EMAIL (TextTagField) - OWNER_CATEGORY (TextTagField) - OWNER_AGE (NumericField) - OWNER_ACTIVE (TextTagField) Note: @reference fields store only the entity ID, not the full embedded object. To actually search by referenced entity properties, the data must be denormalized or the query should use ReferenceField.eq(entity). Closes #677
1 parent 3c96ba0 commit b2f1f7a

File tree

7 files changed

+615
-0
lines changed

7 files changed

+615
-0
lines changed

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

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import org.apache.commons.logging.LogFactory;
1818
import org.springframework.beans.factory.config.BeanDefinition;
1919
import org.springframework.context.ApplicationContext;
20+
import org.springframework.data.annotation.Id;
2021
import org.springframework.data.annotation.Reference;
2122
import org.springframework.data.geo.Point;
2223
import org.springframework.data.redis.core.RedisHash;
@@ -481,6 +482,10 @@ private List<SearchField> findIndexFields(java.lang.reflect.Field field, String
481482
logger.debug("🪲Found @Reference field " + field.getName() + " in " + field.getDeclaringClass()
482483
.getSimpleName());
483484
createIndexedFieldForReferenceIdField(field, isDocument).ifPresent(fields::add);
485+
486+
// Also create index fields for the referenced entity's indexed/searchable fields
487+
// This enables searching like RefVehicle$.OWNER_NAME.eq("John")
488+
createIndexedFieldsForReferencedEntity(field, isDocument, prefix).forEach(fields::add);
484489
} else if (indexed.schemaFieldType() == SchemaFieldType.AUTODETECT) {
485490
//
486491
// Any Character class, Boolean or Enum with AUTODETECT -> Tag Search Field
@@ -1281,6 +1286,208 @@ private Optional<SearchField> createIndexedFieldForReferenceIdField( //
12811286
TagField.of(fieldName).separator('|').sortable()));
12821287
}
12831288

1289+
/**
1290+
* Creates index fields for the indexed/searchable fields of a referenced entity.
1291+
* This enables searching on referenced entity properties, e.g., RefVehicle$.OWNER_NAME.eq("John").
1292+
*
1293+
* @param referenceField the @Reference field
1294+
* @param isDocument whether this is a JSON document (vs Hash)
1295+
* @param prefix the field prefix
1296+
* @return list of search fields for the referenced entity's indexed properties
1297+
*/
1298+
private List<SearchField> createIndexedFieldsForReferencedEntity(java.lang.reflect.Field referenceField,
1299+
boolean isDocument, String prefix) {
1300+
1301+
List<SearchField> fields = new ArrayList<>();
1302+
Class<?> referencedType = referenceField.getType();
1303+
String referenceFieldName = referenceField.getName();
1304+
1305+
logger.debug(
1306+
"Processing indexed subfields for @Reference field " + referenceFieldName + " of type " + referencedType
1307+
.getSimpleName());
1308+
1309+
// Get all fields from the referenced entity that have indexing annotations
1310+
List<java.lang.reflect.Field> referencedFields = new ArrayList<>();
1311+
referencedFields.addAll(com.redis.om.spring.util.ObjectUtils.getFieldsWithAnnotation(referencedType,
1312+
Indexed.class));
1313+
referencedFields.addAll(com.redis.om.spring.util.ObjectUtils.getFieldsWithAnnotation(referencedType,
1314+
Searchable.class));
1315+
referencedFields.addAll(com.redis.om.spring.util.ObjectUtils.getFieldsWithAnnotation(referencedType,
1316+
TagIndexed.class));
1317+
referencedFields.addAll(com.redis.om.spring.util.ObjectUtils.getFieldsWithAnnotation(referencedType,
1318+
TextIndexed.class));
1319+
referencedFields.addAll(com.redis.om.spring.util.ObjectUtils.getFieldsWithAnnotation(referencedType,
1320+
NumericIndexed.class));
1321+
referencedFields.addAll(com.redis.om.spring.util.ObjectUtils.getFieldsWithAnnotation(referencedType,
1322+
GeoIndexed.class));
1323+
// Remove duplicates (a field might have multiple annotations)
1324+
referencedFields = referencedFields.stream().distinct().toList();
1325+
1326+
for (java.lang.reflect.Field subField : referencedFields) {
1327+
// Skip @Id fields - they're handled separately by createIndexedFieldForReferenceIdField
1328+
if (subField.isAnnotationPresent(Id.class)) {
1329+
continue;
1330+
}
1331+
// Skip @Reference fields to avoid infinite recursion
1332+
if (subField.isAnnotationPresent(Reference.class)) {
1333+
continue;
1334+
}
1335+
1336+
Class<?> subFieldType = ClassUtils.resolvePrimitiveIfNecessary(subField.getType());
1337+
String subFieldName = subField.getName();
1338+
1339+
// Build the nested field path: referenceField.subField
1340+
String fieldPath = isDocument ?
1341+
getFieldPrefix(prefix, true) + referenceFieldName + "." + subFieldName :
1342+
referenceFieldName + "_" + subFieldName;
1343+
1344+
// Build the alias: referenceField_subField
1345+
String alias = referenceFieldName + "_" + subFieldName;
1346+
1347+
FieldName fieldName = FieldName.of(fieldPath).as(alias);
1348+
1349+
logger.debug(
1350+
"Creating index field for " + referenceFieldName + "." + subFieldName + " with path " + fieldPath + " and alias " + alias);
1351+
1352+
// Handle @Searchable fields (full-text search)
1353+
Searchable searchable = subField.getAnnotation(Searchable.class);
1354+
if (searchable != null) {
1355+
TextField textField = TextField.of(fieldName);
1356+
if (searchable.weight() != 1.0) {
1357+
textField.weight(searchable.weight());
1358+
}
1359+
if (searchable.sortable()) {
1360+
textField.sortable();
1361+
}
1362+
if (searchable.nostem()) {
1363+
textField.noStem();
1364+
}
1365+
if (searchable.noindex()) {
1366+
textField.noIndex();
1367+
}
1368+
String phonetic = searchable.phonetic();
1369+
if (phonetic != null && !phonetic.isEmpty()) {
1370+
textField.phonetic(phonetic);
1371+
}
1372+
if (searchable.indexMissing()) {
1373+
textField.indexMissing();
1374+
}
1375+
if (searchable.indexEmpty()) {
1376+
textField.indexEmpty();
1377+
}
1378+
fields.add(SearchField.of(subField, textField));
1379+
continue;
1380+
}
1381+
1382+
// Handle @TextIndexed fields
1383+
TextIndexed textIndexed = subField.getAnnotation(TextIndexed.class);
1384+
if (textIndexed != null) {
1385+
TextField textField = TextField.of(fieldName);
1386+
if (textIndexed.weight() != 1.0) {
1387+
textField.weight(textIndexed.weight());
1388+
}
1389+
if (textIndexed.sortable()) {
1390+
textField.sortable();
1391+
}
1392+
if (textIndexed.nostem()) {
1393+
textField.noStem();
1394+
}
1395+
if (textIndexed.noindex()) {
1396+
textField.noIndex();
1397+
}
1398+
String phonetic = textIndexed.phonetic();
1399+
if (phonetic != null && !phonetic.isEmpty()) {
1400+
textField.phonetic(phonetic);
1401+
}
1402+
if (textIndexed.indexMissing()) {
1403+
textField.indexMissing();
1404+
}
1405+
if (textIndexed.indexEmpty()) {
1406+
textField.indexEmpty();
1407+
}
1408+
fields.add(SearchField.of(subField, textField));
1409+
continue;
1410+
}
1411+
1412+
// Handle @Indexed or @TagIndexed fields
1413+
Indexed indexed = subField.getAnnotation(Indexed.class);
1414+
TagIndexed tagIndexed = subField.getAnnotation(TagIndexed.class);
1415+
NumericIndexed numericIndexed = subField.getAnnotation(NumericIndexed.class);
1416+
GeoIndexed geoIndexed = subField.getAnnotation(GeoIndexed.class);
1417+
1418+
if (tagIndexed != null || (indexed != null && CharSequence.class.isAssignableFrom(subFieldType))) {
1419+
// Tag field for strings
1420+
String separatorStr = tagIndexed != null ?
1421+
tagIndexed.separator() :
1422+
(indexed != null ? indexed.separator() : "|");
1423+
char separator = separatorStr != null && !separatorStr.isEmpty() ? separatorStr.charAt(0) : '|';
1424+
TagField tagField = TagField.of(fieldName).separator(separator);
1425+
if (indexed != null && indexed.sortable()) {
1426+
tagField.sortable();
1427+
}
1428+
if (tagIndexed != null && tagIndexed.indexMissing()) {
1429+
tagField.indexMissing();
1430+
} else if (indexed != null && indexed.indexMissing()) {
1431+
tagField.indexMissing();
1432+
}
1433+
if (tagIndexed != null && tagIndexed.indexEmpty()) {
1434+
tagField.indexEmpty();
1435+
} else if (indexed != null && indexed.indexEmpty()) {
1436+
tagField.indexEmpty();
1437+
}
1438+
fields.add(SearchField.of(subField, tagField));
1439+
} else if (numericIndexed != null || (indexed != null && Number.class.isAssignableFrom(subFieldType))) {
1440+
// Numeric field
1441+
NumericField numField = NumericField.of(fieldName);
1442+
if ((numericIndexed != null && numericIndexed.sortable()) || (indexed != null && indexed.sortable())) {
1443+
numField.sortable();
1444+
}
1445+
if ((numericIndexed != null && numericIndexed.noindex()) || (indexed != null && indexed.noindex())) {
1446+
numField.noIndex();
1447+
}
1448+
if (indexed != null && indexed.indexMissing()) {
1449+
numField.indexMissing();
1450+
}
1451+
// Note: NumericField doesn't support indexEmpty() in current Jedis version
1452+
fields.add(SearchField.of(subField, numField));
1453+
} else if (geoIndexed != null || (indexed != null && Point.class.isAssignableFrom(subFieldType))) {
1454+
// Geo field
1455+
GeoField geoField = GeoField.of(fieldName);
1456+
fields.add(SearchField.of(subField, geoField));
1457+
} else if (indexed != null && subFieldType.isEnum()) {
1458+
// Enum as tag field
1459+
String separatorStr = indexed.separator();
1460+
char separator = separatorStr != null && !separatorStr.isEmpty() ? separatorStr.charAt(0) : '|';
1461+
TagField tagField = TagField.of(fieldName).separator(separator);
1462+
if (indexed.sortable()) {
1463+
tagField.sortable();
1464+
}
1465+
if (indexed.indexMissing()) {
1466+
tagField.indexMissing();
1467+
}
1468+
if (indexed.indexEmpty()) {
1469+
tagField.indexEmpty();
1470+
}
1471+
fields.add(SearchField.of(subField, tagField));
1472+
} else if (indexed != null && (subFieldType == Boolean.class || subFieldType == boolean.class)) {
1473+
// Boolean as tag field
1474+
TagField tagField = TagField.of(fieldName);
1475+
if (indexed.sortable()) {
1476+
tagField.sortable();
1477+
}
1478+
if (indexed.indexMissing()) {
1479+
tagField.indexMissing();
1480+
}
1481+
if (indexed.indexEmpty()) {
1482+
tagField.indexEmpty();
1483+
}
1484+
fields.add(SearchField.of(subField, tagField));
1485+
}
1486+
}
1487+
1488+
return fields;
1489+
}
1490+
12841491
private FTCreateParams createIndexDefinition(Class<?> cl, IndexDataType idxType) {
12851492
FTCreateParams params = FTCreateParams.createParams();
12861493
params.on(idxType);

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,10 @@ private List<Triple<ObjectGraphFieldSpec, FieldSpec, CodeBlock>> processFieldMet
347347
//
348348
targetInterceptor = ReferenceField.class;
349349
searchSchemaAlias = indexed.alias();
350+
351+
// Also process indexed/searchable fields from the referenced entity
352+
// This generates fields like OWNER_NAME, OWNER_EMAIL for a @Reference @Indexed Owner owner field
353+
fieldMetamodelSpec.addAll(processReferencedEntityIndexableFields(entity, chain));
350354
} else if (searchable != null || textIndexed != null) {
351355
//
352356
// @Searchable/@TextIndexed: Field is a full-text field
@@ -814,6 +818,60 @@ private List<Triple<ObjectGraphFieldSpec, FieldSpec, CodeBlock>> processNestedIn
814818
return fieldMetamodels;
815819
}
816820

821+
/**
822+
* Process indexed and searchable fields from a referenced entity.
823+
* This generates metamodel fields like OWNER_NAME, OWNER_EMAIL when a field
824+
* is annotated with both @Reference and @Indexed.
825+
*
826+
* @param entity the parent entity type
827+
* @param chain the chain of elements from the root entity to the reference field
828+
* @return list of field metamodel specifications for the referenced entity's indexed fields
829+
*/
830+
private List<Triple<ObjectGraphFieldSpec, FieldSpec, CodeBlock>> processReferencedEntityIndexableFields(
831+
TypeName entity, List<Element> chain) {
832+
Element referenceField = chain.get(chain.size() - 1);
833+
TypeMirror typeMirror = referenceField.asType();
834+
835+
// Get the referenced entity type element
836+
Element referencedEntity;
837+
if (typeMirror instanceof DeclaredType declaredType) {
838+
referencedEntity = declaredType.asElement();
839+
} else {
840+
return Collections.emptyList();
841+
}
842+
843+
List<Triple<ObjectGraphFieldSpec, FieldSpec, CodeBlock>> fieldMetamodels = new ArrayList<>();
844+
845+
messager.printMessage(Diagnostic.Kind.NOTE, "Processing @Reference field " + referenceField
846+
.getSimpleName() + " of type " + referencedEntity);
847+
848+
// Get all instance fields from the referenced entity
849+
Map<? extends Element, String> enclosedFields = getInstanceFields(referencedEntity);
850+
851+
enclosedFields.forEach((field, getter) -> {
852+
// Check if the field has any indexing annotation
853+
boolean fieldIsIndexed = (field.getAnnotation(Indexed.class) != null) || (field.getAnnotation(
854+
Searchable.class) != null) || (field.getAnnotation(NumericIndexed.class) != null) || (field.getAnnotation(
855+
TagIndexed.class) != null) || (field.getAnnotation(TextIndexed.class) != null) || (field.getAnnotation(
856+
GeoIndexed.class) != null);
857+
858+
// Skip @Id fields and @Reference fields (to avoid infinite recursion)
859+
boolean isIdField = field.getAnnotation(Id.class) != null;
860+
boolean isReferenceField = field.getAnnotation(Reference.class) != null;
861+
862+
if (fieldIsIndexed && !isIdField && !isReferenceField) {
863+
// Create a new chain that includes the reference field and the subfield
864+
List<Element> newChain = new ArrayList<>(chain);
865+
newChain.add(field);
866+
867+
// Process the subfield
868+
fieldMetamodels.addAll(processFieldMetamodel(entity, entity.toString(), newChain));
869+
}
870+
});
871+
872+
return fieldMetamodels;
873+
}
874+
817875
private Map<? extends Element, String> getInstanceFields(Element element) {
818876
if (objectTypeElement.equals(element)) {
819877
return Collections.emptyMap();

0 commit comments

Comments
 (0)