From b2f1f7ac9c69c049179850ad169e01a76a68f134 Mon Sep 17 00:00:00 2001 From: Brian Sam-Bodden Date: Tue, 25 Nov 2025 09:08:44 -0700 Subject: [PATCH] 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 --- .../om/spring/indexing/RediSearchIndexer.java | 207 +++++++++++++++ .../spring/metamodel/MetamodelGenerator.java | 58 ++++ .../ReferenceIndexedSubfieldsTest.java | 247 ++++++++++++++++++ .../spring/fixtures/document/model/Owner.java | 50 ++++ .../fixtures/document/model/RefVehicle.java | 39 +++ .../document/repository/OwnerRepository.java | 7 + .../repository/RefVehicleRepository.java | 7 + 7 files changed, 615 insertions(+) create mode 100644 tests/src/test/java/com/redis/om/spring/annotations/document/ReferenceIndexedSubfieldsTest.java create mode 100644 tests/src/test/java/com/redis/om/spring/fixtures/document/model/Owner.java create mode 100644 tests/src/test/java/com/redis/om/spring/fixtures/document/model/RefVehicle.java create mode 100644 tests/src/test/java/com/redis/om/spring/fixtures/document/repository/OwnerRepository.java create mode 100644 tests/src/test/java/com/redis/om/spring/fixtures/document/repository/RefVehicleRepository.java diff --git a/redis-om-spring/src/main/java/com/redis/om/spring/indexing/RediSearchIndexer.java b/redis-om-spring/src/main/java/com/redis/om/spring/indexing/RediSearchIndexer.java index 4355bcce..78d55e0f 100644 --- a/redis-om-spring/src/main/java/com/redis/om/spring/indexing/RediSearchIndexer.java +++ b/redis-om-spring/src/main/java/com/redis/om/spring/indexing/RediSearchIndexer.java @@ -17,6 +17,7 @@ import org.apache.commons.logging.LogFactory; import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.context.ApplicationContext; +import org.springframework.data.annotation.Id; import org.springframework.data.annotation.Reference; import org.springframework.data.geo.Point; import org.springframework.data.redis.core.RedisHash; @@ -481,6 +482,10 @@ private List findIndexFields(java.lang.reflect.Field field, String logger.debug("🪲Found @Reference field " + field.getName() + " in " + field.getDeclaringClass() .getSimpleName()); createIndexedFieldForReferenceIdField(field, isDocument).ifPresent(fields::add); + + // Also create index fields for the referenced entity's indexed/searchable fields + // This enables searching like RefVehicle$.OWNER_NAME.eq("John") + createIndexedFieldsForReferencedEntity(field, isDocument, prefix).forEach(fields::add); } else if (indexed.schemaFieldType() == SchemaFieldType.AUTODETECT) { // // Any Character class, Boolean or Enum with AUTODETECT -> Tag Search Field @@ -1281,6 +1286,208 @@ private Optional createIndexedFieldForReferenceIdField( // TagField.of(fieldName).separator('|').sortable())); } + /** + * Creates index fields for the indexed/searchable fields of a referenced entity. + * This enables searching on referenced entity properties, e.g., RefVehicle$.OWNER_NAME.eq("John"). + * + * @param referenceField the @Reference field + * @param isDocument whether this is a JSON document (vs Hash) + * @param prefix the field prefix + * @return list of search fields for the referenced entity's indexed properties + */ + private List createIndexedFieldsForReferencedEntity(java.lang.reflect.Field referenceField, + boolean isDocument, String prefix) { + + List fields = new ArrayList<>(); + Class referencedType = referenceField.getType(); + String referenceFieldName = referenceField.getName(); + + logger.debug( + "Processing indexed subfields for @Reference field " + referenceFieldName + " of type " + referencedType + .getSimpleName()); + + // Get all fields from the referenced entity that have indexing annotations + List referencedFields = new ArrayList<>(); + referencedFields.addAll(com.redis.om.spring.util.ObjectUtils.getFieldsWithAnnotation(referencedType, + Indexed.class)); + referencedFields.addAll(com.redis.om.spring.util.ObjectUtils.getFieldsWithAnnotation(referencedType, + Searchable.class)); + referencedFields.addAll(com.redis.om.spring.util.ObjectUtils.getFieldsWithAnnotation(referencedType, + TagIndexed.class)); + referencedFields.addAll(com.redis.om.spring.util.ObjectUtils.getFieldsWithAnnotation(referencedType, + TextIndexed.class)); + referencedFields.addAll(com.redis.om.spring.util.ObjectUtils.getFieldsWithAnnotation(referencedType, + NumericIndexed.class)); + referencedFields.addAll(com.redis.om.spring.util.ObjectUtils.getFieldsWithAnnotation(referencedType, + GeoIndexed.class)); + // Remove duplicates (a field might have multiple annotations) + referencedFields = referencedFields.stream().distinct().toList(); + + for (java.lang.reflect.Field subField : referencedFields) { + // Skip @Id fields - they're handled separately by createIndexedFieldForReferenceIdField + if (subField.isAnnotationPresent(Id.class)) { + continue; + } + // Skip @Reference fields to avoid infinite recursion + if (subField.isAnnotationPresent(Reference.class)) { + continue; + } + + Class subFieldType = ClassUtils.resolvePrimitiveIfNecessary(subField.getType()); + String subFieldName = subField.getName(); + + // Build the nested field path: referenceField.subField + String fieldPath = isDocument ? + getFieldPrefix(prefix, true) + referenceFieldName + "." + subFieldName : + referenceFieldName + "_" + subFieldName; + + // Build the alias: referenceField_subField + String alias = referenceFieldName + "_" + subFieldName; + + FieldName fieldName = FieldName.of(fieldPath).as(alias); + + logger.debug( + "Creating index field for " + referenceFieldName + "." + subFieldName + " with path " + fieldPath + " and alias " + alias); + + // Handle @Searchable fields (full-text search) + Searchable searchable = subField.getAnnotation(Searchable.class); + if (searchable != null) { + TextField textField = TextField.of(fieldName); + if (searchable.weight() != 1.0) { + textField.weight(searchable.weight()); + } + if (searchable.sortable()) { + textField.sortable(); + } + if (searchable.nostem()) { + textField.noStem(); + } + if (searchable.noindex()) { + textField.noIndex(); + } + String phonetic = searchable.phonetic(); + if (phonetic != null && !phonetic.isEmpty()) { + textField.phonetic(phonetic); + } + if (searchable.indexMissing()) { + textField.indexMissing(); + } + if (searchable.indexEmpty()) { + textField.indexEmpty(); + } + fields.add(SearchField.of(subField, textField)); + continue; + } + + // Handle @TextIndexed fields + TextIndexed textIndexed = subField.getAnnotation(TextIndexed.class); + if (textIndexed != null) { + TextField textField = TextField.of(fieldName); + if (textIndexed.weight() != 1.0) { + textField.weight(textIndexed.weight()); + } + if (textIndexed.sortable()) { + textField.sortable(); + } + if (textIndexed.nostem()) { + textField.noStem(); + } + if (textIndexed.noindex()) { + textField.noIndex(); + } + String phonetic = textIndexed.phonetic(); + if (phonetic != null && !phonetic.isEmpty()) { + textField.phonetic(phonetic); + } + if (textIndexed.indexMissing()) { + textField.indexMissing(); + } + if (textIndexed.indexEmpty()) { + textField.indexEmpty(); + } + fields.add(SearchField.of(subField, textField)); + continue; + } + + // Handle @Indexed or @TagIndexed fields + Indexed indexed = subField.getAnnotation(Indexed.class); + TagIndexed tagIndexed = subField.getAnnotation(TagIndexed.class); + NumericIndexed numericIndexed = subField.getAnnotation(NumericIndexed.class); + GeoIndexed geoIndexed = subField.getAnnotation(GeoIndexed.class); + + if (tagIndexed != null || (indexed != null && CharSequence.class.isAssignableFrom(subFieldType))) { + // Tag field for strings + String separatorStr = tagIndexed != null ? + tagIndexed.separator() : + (indexed != null ? indexed.separator() : "|"); + char separator = separatorStr != null && !separatorStr.isEmpty() ? separatorStr.charAt(0) : '|'; + TagField tagField = TagField.of(fieldName).separator(separator); + if (indexed != null && indexed.sortable()) { + tagField.sortable(); + } + if (tagIndexed != null && tagIndexed.indexMissing()) { + tagField.indexMissing(); + } else if (indexed != null && indexed.indexMissing()) { + tagField.indexMissing(); + } + if (tagIndexed != null && tagIndexed.indexEmpty()) { + tagField.indexEmpty(); + } else if (indexed != null && indexed.indexEmpty()) { + tagField.indexEmpty(); + } + fields.add(SearchField.of(subField, tagField)); + } else if (numericIndexed != null || (indexed != null && Number.class.isAssignableFrom(subFieldType))) { + // Numeric field + NumericField numField = NumericField.of(fieldName); + if ((numericIndexed != null && numericIndexed.sortable()) || (indexed != null && indexed.sortable())) { + numField.sortable(); + } + if ((numericIndexed != null && numericIndexed.noindex()) || (indexed != null && indexed.noindex())) { + numField.noIndex(); + } + if (indexed != null && indexed.indexMissing()) { + numField.indexMissing(); + } + // Note: NumericField doesn't support indexEmpty() in current Jedis version + fields.add(SearchField.of(subField, numField)); + } else if (geoIndexed != null || (indexed != null && Point.class.isAssignableFrom(subFieldType))) { + // Geo field + GeoField geoField = GeoField.of(fieldName); + fields.add(SearchField.of(subField, geoField)); + } else if (indexed != null && subFieldType.isEnum()) { + // Enum as tag field + String separatorStr = indexed.separator(); + char separator = separatorStr != null && !separatorStr.isEmpty() ? separatorStr.charAt(0) : '|'; + TagField tagField = TagField.of(fieldName).separator(separator); + if (indexed.sortable()) { + tagField.sortable(); + } + if (indexed.indexMissing()) { + tagField.indexMissing(); + } + if (indexed.indexEmpty()) { + tagField.indexEmpty(); + } + fields.add(SearchField.of(subField, tagField)); + } else if (indexed != null && (subFieldType == Boolean.class || subFieldType == boolean.class)) { + // Boolean as tag field + TagField tagField = TagField.of(fieldName); + if (indexed.sortable()) { + tagField.sortable(); + } + if (indexed.indexMissing()) { + tagField.indexMissing(); + } + if (indexed.indexEmpty()) { + tagField.indexEmpty(); + } + fields.add(SearchField.of(subField, tagField)); + } + } + + return fields; + } + private FTCreateParams createIndexDefinition(Class cl, IndexDataType idxType) { FTCreateParams params = FTCreateParams.createParams(); params.on(idxType); diff --git a/redis-om-spring/src/main/java/com/redis/om/spring/metamodel/MetamodelGenerator.java b/redis-om-spring/src/main/java/com/redis/om/spring/metamodel/MetamodelGenerator.java index 080032a5..30f42da4 100644 --- a/redis-om-spring/src/main/java/com/redis/om/spring/metamodel/MetamodelGenerator.java +++ b/redis-om-spring/src/main/java/com/redis/om/spring/metamodel/MetamodelGenerator.java @@ -347,6 +347,10 @@ private List> processFieldMet // targetInterceptor = ReferenceField.class; searchSchemaAlias = indexed.alias(); + + // Also process indexed/searchable fields from the referenced entity + // This generates fields like OWNER_NAME, OWNER_EMAIL for a @Reference @Indexed Owner owner field + fieldMetamodelSpec.addAll(processReferencedEntityIndexableFields(entity, chain)); } else if (searchable != null || textIndexed != null) { // // @Searchable/@TextIndexed: Field is a full-text field @@ -814,6 +818,60 @@ private List> processNestedIn return fieldMetamodels; } + /** + * Process indexed and searchable fields from a referenced entity. + * This generates metamodel fields like OWNER_NAME, OWNER_EMAIL when a field + * is annotated with both @Reference and @Indexed. + * + * @param entity the parent entity type + * @param chain the chain of elements from the root entity to the reference field + * @return list of field metamodel specifications for the referenced entity's indexed fields + */ + private List> processReferencedEntityIndexableFields( + TypeName entity, List chain) { + Element referenceField = chain.get(chain.size() - 1); + TypeMirror typeMirror = referenceField.asType(); + + // Get the referenced entity type element + Element referencedEntity; + if (typeMirror instanceof DeclaredType declaredType) { + referencedEntity = declaredType.asElement(); + } else { + return Collections.emptyList(); + } + + List> fieldMetamodels = new ArrayList<>(); + + messager.printMessage(Diagnostic.Kind.NOTE, "Processing @Reference field " + referenceField + .getSimpleName() + " of type " + referencedEntity); + + // Get all instance fields from the referenced entity + Map enclosedFields = getInstanceFields(referencedEntity); + + enclosedFields.forEach((field, getter) -> { + // Check if the field has any indexing annotation + boolean fieldIsIndexed = (field.getAnnotation(Indexed.class) != null) || (field.getAnnotation( + Searchable.class) != null) || (field.getAnnotation(NumericIndexed.class) != null) || (field.getAnnotation( + TagIndexed.class) != null) || (field.getAnnotation(TextIndexed.class) != null) || (field.getAnnotation( + GeoIndexed.class) != null); + + // Skip @Id fields and @Reference fields (to avoid infinite recursion) + boolean isIdField = field.getAnnotation(Id.class) != null; + boolean isReferenceField = field.getAnnotation(Reference.class) != null; + + if (fieldIsIndexed && !isIdField && !isReferenceField) { + // Create a new chain that includes the reference field and the subfield + List newChain = new ArrayList<>(chain); + newChain.add(field); + + // Process the subfield + fieldMetamodels.addAll(processFieldMetamodel(entity, entity.toString(), newChain)); + } + }); + + return fieldMetamodels; + } + private Map getInstanceFields(Element element) { if (objectTypeElement.equals(element)) { return Collections.emptyMap(); diff --git a/tests/src/test/java/com/redis/om/spring/annotations/document/ReferenceIndexedSubfieldsTest.java b/tests/src/test/java/com/redis/om/spring/annotations/document/ReferenceIndexedSubfieldsTest.java new file mode 100644 index 00000000..75f771eb --- /dev/null +++ b/tests/src/test/java/com/redis/om/spring/annotations/document/ReferenceIndexedSubfieldsTest.java @@ -0,0 +1,247 @@ +package com.redis.om.spring.annotations.document; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.redis.om.spring.AbstractBaseDocumentTest; +import com.redis.om.spring.fixtures.document.model.*; +import com.redis.om.spring.fixtures.document.repository.*; +import com.redis.om.spring.search.stream.EntityStream; + +/** + * Tests for Issue #677: Indexed subfields not generated for @Reference fields. + * + * When a document has a @Reference @Indexed field pointing to another document, + * the metamodel should generate field accessors for the referenced document's + * indexed/searchable fields, allowing queries like: + * + * entityStream.of(RefVehicle.class) + * .filter(RefVehicle$.OWNER_NAME.eq("John")) + * .collect(...) + * + * @see Issue #677 + */ +class ReferenceIndexedSubfieldsTest extends AbstractBaseDocumentTest { + + @Autowired + OwnerRepository ownerRepository; + + @Autowired + RefVehicleRepository refVehicleRepository; + + @Autowired + EntityStream entityStream; + + private Owner johnDoe; + private Owner janeDoe; + private RefVehicle teslaModelS; + private RefVehicle fordMustang; + private RefVehicle bmwM3; + + @BeforeEach + void setup() { + refVehicleRepository.deleteAll(); + ownerRepository.deleteAll(); + + // Create owners + johnDoe = ownerRepository.save(Owner.of("John Doe", "john@example.com")); + janeDoe = ownerRepository.save(Owner.of("Jane Doe", "jane@example.com")); + + // Create vehicles with references to owners + teslaModelS = refVehicleRepository.save(RefVehicle.of("Model S", "Tesla", johnDoe)); + fordMustang = refVehicleRepository.save(RefVehicle.of("Mustang", "Ford", johnDoe)); + bmwM3 = refVehicleRepository.save(RefVehicle.of("M3", "BMW", janeDoe)); + } + + /** + * Test that the metamodel generates OWNER_NAME field for searching + * vehicles by their owner's name (a @Searchable field on the referenced entity). + */ + @Test + void testMetamodelGeneratesOwnerNameField() throws Exception { + // This test verifies that RefVehicle$ has a field named OWNER_NAME + Class metamodelClass = Class.forName("com.redis.om.spring.fixtures.document.model.RefVehicle$"); + + // Check that the static field OWNER_NAME exists + Field ownerNameField = metamodelClass.getDeclaredField("OWNER_NAME"); + assertThat(ownerNameField).isNotNull(); + + // Check that the field is accessible as a static field + Object ownerNameValue = ownerNameField.get(null); + assertThat(ownerNameValue).isNotNull(); + } + + /** + * Test that the metamodel generates OWNER_EMAIL field for searching + * vehicles by their owner's email (an @Indexed field on the referenced entity). + */ + @Test + void testMetamodelGeneratesOwnerEmailField() throws Exception { + // This test verifies that RefVehicle$ has a field named OWNER_EMAIL + Class metamodelClass = Class.forName("com.redis.om.spring.fixtures.document.model.RefVehicle$"); + + // Check that the static field OWNER_EMAIL exists + Field ownerEmailField = metamodelClass.getDeclaredField("OWNER_EMAIL"); + assertThat(ownerEmailField).isNotNull(); + + // Check that the field is accessible as a static field + Object ownerEmailValue = ownerEmailField.get(null); + assertThat(ownerEmailValue).isNotNull(); + } + + /** + * Test that OWNER_NAME field can be used in queries. + * + * Note: @Reference fields store only the entity ID, not the full object. + * Therefore, searching by referenced entity properties (like owner.name) requires + * the referenced entity to be embedded, not just referenced by ID. + * + * This test verifies the metamodel field is usable in query construction, + * but won't find results because the actual JSON stores only the reference ID. + */ + @Test + void testOwnerNameFieldIsUsableInQuery() { + // Verify the metamodel field can be used in a query (no compilation errors) + // The query returns empty because @Reference stores only the ID, not the full object + List vehicles = entityStream + .of(RefVehicle.class) + .filter(RefVehicle$.OWNER_NAME.eq("John Doe")) + .collect(Collectors.toList()); + + // With @Reference, the owner field stores only the ID (e.g., "owner:01ARZ3...") + // not the nested object with name/email, so this returns empty + // For searching by referenced entity fields, use the ReferenceField.eq() with the entity + assertThat(vehicles).isEmpty(); + } + + /** + * Test that OWNER_EMAIL field can be used in queries. + * + * Note: See testOwnerNameFieldIsUsableInQuery for explanation of @Reference behavior. + */ + @Test + void testOwnerEmailFieldIsUsableInQuery() { + // Verify the metamodel field can be used in a query (no compilation errors) + List vehicles = entityStream + .of(RefVehicle.class) + .filter(RefVehicle$.OWNER_EMAIL.eq("jane@example.com")) + .collect(Collectors.toList()); + + // Returns empty because @Reference stores only the ID + assertThat(vehicles).isEmpty(); + } + + /** + * Test searching by the reference itself using the existing ReferenceField.eq() method. + * This is the correct way to search by reference in the current implementation. + */ + @Test + void testSearchVehiclesByOwnerReference() { + // Search for vehicles owned by johnDoe using the reference field + List johnsVehicles = entityStream + .of(RefVehicle.class) + .filter(RefVehicle$.OWNER.eq(johnDoe)) + .collect(Collectors.toList()); + + assertThat(johnsVehicles).hasSize(2); + assertThat(johnsVehicles).extracting("model").containsExactlyInAnyOrder("Model S", "Mustang"); + } + + /** + * Test that the feature also works with existing State/City models. + * State has an @Indexed 'name' field, so City$ should have STATE_NAME. + */ + @Test + void testExistingCityModelHasStateNameField() throws Exception { + // This test verifies that City$ has a field named STATE_NAME + Class metamodelClass = Class.forName("com.redis.om.spring.fixtures.document.model.City$"); + + // Check that the static field STATE_NAME exists + Field stateNameField = metamodelClass.getDeclaredField("STATE_NAME"); + assertThat(stateNameField).isNotNull(); + + // Check that the field is accessible as a static field + Object stateNameValue = stateNameField.get(null); + assertThat(stateNameValue).isNotNull(); + } + + // ================================================================================== + // Tests for additional indexed field types (PR review feedback) + // ================================================================================== + + /** + * Test that the metamodel generates OWNER_CATEGORY field for @TagIndexed fields. + */ + @Test + void testMetamodelGeneratesOwnerCategoryField() throws Exception { + Class metamodelClass = Class.forName("com.redis.om.spring.fixtures.document.model.RefVehicle$"); + + // Check that the static field OWNER_CATEGORY exists (from @TagIndexed) + Field ownerCategoryField = metamodelClass.getDeclaredField("OWNER_CATEGORY"); + assertThat(ownerCategoryField).isNotNull(); + + Object ownerCategoryValue = ownerCategoryField.get(null); + assertThat(ownerCategoryValue).isNotNull(); + } + + /** + * Test that the metamodel generates OWNER_AGE field for @NumericIndexed fields. + */ + @Test + void testMetamodelGeneratesOwnerAgeField() throws Exception { + Class metamodelClass = Class.forName("com.redis.om.spring.fixtures.document.model.RefVehicle$"); + + // Check that the static field OWNER_AGE exists (from @NumericIndexed) + Field ownerAgeField = metamodelClass.getDeclaredField("OWNER_AGE"); + assertThat(ownerAgeField).isNotNull(); + + Object ownerAgeValue = ownerAgeField.get(null); + assertThat(ownerAgeValue).isNotNull(); + } + + /** + * Test that the metamodel generates OWNER_ACTIVE field for @Indexed Boolean fields. + */ + @Test + void testMetamodelGeneratesOwnerActiveField() throws Exception { + Class metamodelClass = Class.forName("com.redis.om.spring.fixtures.document.model.RefVehicle$"); + + // Check that the static field OWNER_ACTIVE exists (from @Indexed Boolean) + Field ownerActiveField = metamodelClass.getDeclaredField("OWNER_ACTIVE"); + assertThat(ownerActiveField).isNotNull(); + + Object ownerActiveValue = ownerActiveField.get(null); + assertThat(ownerActiveValue).isNotNull(); + } + + /** + * Test that all expected fields from the Owner entity are generated in RefVehicle$. + */ + @Test + void testAllOwnerIndexedFieldsAreGenerated() throws Exception { + Class metamodelClass = Class.forName("com.redis.om.spring.fixtures.document.model.RefVehicle$"); + + // Verify all expected fields exist + String[] expectedFields = { + "OWNER_NAME", // @Searchable + "OWNER_EMAIL", // @Indexed + "OWNER_CATEGORY", // @TagIndexed + "OWNER_AGE", // @NumericIndexed + "OWNER_ACTIVE" // @Indexed Boolean + }; + + for (String fieldName : expectedFields) { + Field field = metamodelClass.getDeclaredField(fieldName); + assertThat(field).as("Field %s should exist", fieldName).isNotNull(); + Object value = field.get(null); + assertThat(value).as("Field %s should have a non-null value", fieldName).isNotNull(); + } + } +} diff --git a/tests/src/test/java/com/redis/om/spring/fixtures/document/model/Owner.java b/tests/src/test/java/com/redis/om/spring/fixtures/document/model/Owner.java new file mode 100644 index 00000000..a82ff174 --- /dev/null +++ b/tests/src/test/java/com/redis/om/spring/fixtures/document/model/Owner.java @@ -0,0 +1,50 @@ +package com.redis.om.spring.fixtures.document.model; + +import org.springframework.data.annotation.Id; + +import com.redis.om.spring.annotations.Document; +import com.redis.om.spring.annotations.Indexed; +import com.redis.om.spring.annotations.NumericIndexed; +import com.redis.om.spring.annotations.Searchable; +import com.redis.om.spring.annotations.TagIndexed; + +import lombok.*; + +/** + * Model for testing Issue #677: Indexed subfields for @Reference fields. + * The Owner entity has searchable/indexed fields that should be accessible + * from entities that reference it. + * + * This model also tests various index attribute options: + * - @Searchable with indexMissing/indexEmpty + * - @Indexed with sortable + * - @TagIndexed with indexMissing/indexEmpty + * - @NumericIndexed with sortable + * - @Indexed on Boolean + */ +@Data +@RequiredArgsConstructor(staticName = "of") +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(force = true) +@Document("owner") +public class Owner { + @Id + private String id; + + @NonNull + @Searchable(indexMissing = true, indexEmpty = true) + private String name; + + @NonNull + @Indexed(sortable = true) + private String email; + + @TagIndexed(indexMissing = true, indexEmpty = true) + private String category; + + @NumericIndexed(sortable = true) + private Integer age; + + @Indexed(sortable = true, indexMissing = true, indexEmpty = true) + private Boolean active; +} diff --git a/tests/src/test/java/com/redis/om/spring/fixtures/document/model/RefVehicle.java b/tests/src/test/java/com/redis/om/spring/fixtures/document/model/RefVehicle.java new file mode 100644 index 00000000..a623313f --- /dev/null +++ b/tests/src/test/java/com/redis/om/spring/fixtures/document/model/RefVehicle.java @@ -0,0 +1,39 @@ +package com.redis.om.spring.fixtures.document.model; + +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.Reference; + +import com.redis.om.spring.annotations.Document; +import com.redis.om.spring.annotations.Indexed; +import com.redis.om.spring.annotations.Searchable; + +import lombok.*; + +/** + * Model for testing Issue #677: Indexed subfields for @Reference fields. + * This entity references Owner with @Reference @Indexed, and should have + * metamodel fields like OWNER_NAME and OWNER_EMAIL generated to allow + * searching on the referenced entity's indexed/searchable fields. + */ +@Data +@RequiredArgsConstructor(staticName = "of") +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(force = true) +@Document("refvehicle") +public class RefVehicle { + @Id + private String id; + + @NonNull + @Searchable + private String model; + + @NonNull + @Indexed + private String brand; + + @NonNull + @Reference + @Indexed + private Owner owner; +} diff --git a/tests/src/test/java/com/redis/om/spring/fixtures/document/repository/OwnerRepository.java b/tests/src/test/java/com/redis/om/spring/fixtures/document/repository/OwnerRepository.java new file mode 100644 index 00000000..7483de31 --- /dev/null +++ b/tests/src/test/java/com/redis/om/spring/fixtures/document/repository/OwnerRepository.java @@ -0,0 +1,7 @@ +package com.redis.om.spring.fixtures.document.repository; + +import com.redis.om.spring.fixtures.document.model.Owner; +import com.redis.om.spring.repository.RedisDocumentRepository; + +public interface OwnerRepository extends RedisDocumentRepository { +} diff --git a/tests/src/test/java/com/redis/om/spring/fixtures/document/repository/RefVehicleRepository.java b/tests/src/test/java/com/redis/om/spring/fixtures/document/repository/RefVehicleRepository.java new file mode 100644 index 00000000..200441b0 --- /dev/null +++ b/tests/src/test/java/com/redis/om/spring/fixtures/document/repository/RefVehicleRepository.java @@ -0,0 +1,7 @@ +package com.redis.om.spring.fixtures.document.repository; + +import com.redis.om.spring.fixtures.document.model.RefVehicle; +import com.redis.om.spring.repository.RedisDocumentRepository; + +public interface RefVehicleRepository extends RedisDocumentRepository { +}