diff --git a/redis-om-spring/src/main/java/com/redis/om/spring/convert/MappingRedisOMConverter.java b/redis-om-spring/src/main/java/com/redis/om/spring/convert/MappingRedisOMConverter.java index 1996c5ae..58113323 100644 --- a/redis-om-spring/src/main/java/com/redis/om/spring/convert/MappingRedisOMConverter.java +++ b/redis-om-spring/src/main/java/com/redis/om/spring/convert/MappingRedisOMConverter.java @@ -198,17 +198,42 @@ private R doReadInternal(Class entityClass, String path, Class type, R if (type.isInterface()) { Map map = new HashMap<>(); RedisPersistentEntity persistentEntity = mappingContext.getRequiredPersistentEntity(readType); + + // Build a map of property names to their types from the projection interface + Map> projectionPropertyTypes = new HashMap<>(); + for (java.lang.reflect.Method method : type.getMethods()) { + if (method.getParameterCount() == 0 && !method.getReturnType().equals(void.class)) { + String propertyName = null; + if (method.getName().startsWith("get") && method.getName().length() > 3) { + propertyName = StringUtils.uncapitalize(method.getName().substring(3)); + } else if (method.getName().startsWith("is") && method.getName().length() > 2) { + propertyName = StringUtils.uncapitalize(method.getName().substring(2)); + } + if (propertyName != null) { + projectionPropertyTypes.put(propertyName, method.getReturnType()); + } + } + } + for (Entry entry : source.getBucket().asMap().entrySet()) { String key = entry.getKey(); byte[] value = entry.getValue(); - RedisPersistentProperty persistentProperty = persistentEntity.getPersistentProperty(key); Object convertedValue; - if (persistentProperty != null) { - // Convert the byte[] value to the appropriate type - convertedValue = conversionService.convert(value, persistentProperty.getType()); + + // First try to get the type from the projection interface + Class targetType = projectionPropertyTypes.get(key); + if (targetType != null) { + // Use the type from the projection interface + convertedValue = conversionService.convert(value, targetType); } else { - // If the property is not found, treat the value as a String - convertedValue = new String(value); + // Fall back to entity property type if available + RedisPersistentProperty persistentProperty = persistentEntity.getPersistentProperty(key); + if (persistentProperty != null) { + convertedValue = conversionService.convert(value, persistentProperty.getType()); + } else { + // Last resort: treat as String + convertedValue = new String(value); + } } map.put(key, convertedValue); } diff --git a/redis-om-spring/src/main/java/com/redis/om/spring/repository/query/RediSearchQuery.java b/redis-om-spring/src/main/java/com/redis/om/spring/repository/query/RediSearchQuery.java index 6ea26a66..4b437a70 100644 --- a/redis-om-spring/src/main/java/com/redis/om/spring/repository/query/RediSearchQuery.java +++ b/redis-om-spring/src/main/java/com/redis/om/spring/repository/query/RediSearchQuery.java @@ -876,17 +876,82 @@ private Object executeQuery(Object[] parameters) { } private Object parseDocumentResult(redis.clients.jedis.search.Document doc) { - if (doc == null || doc.get("$") == null) { + if (doc == null) { return null; } Gson gsonInstance = getGson(); + Object entity; + + if (doc.get("$") != null) { + // Full document case - normal JSON document retrieval + entity = switch (dialect) { + case ONE, TWO -> { + String jsonString = SafeEncoder.encode((byte[]) doc.get("$")); + yield gsonInstance.fromJson(jsonString, domainType); + } + case THREE -> gsonInstance.fromJson(gsonInstance.fromJson(SafeEncoder.encode((byte[]) doc.get("$")), + JsonArray.class).get(0), domainType); + }; + } else { + // Projection case - individual fields returned from Redis search optimization + // When projection optimization is enabled, Redis returns individual fields instead of full JSON + Map fieldMap = new HashMap<>(); + for (Entry entry : doc.getProperties()) { + String fieldName = entry.getKey(); + Object fieldValue = entry.getValue(); + + if (fieldValue instanceof byte[]) { + // Convert byte array to string - this is the JSON representation from Redis + String stringValue = SafeEncoder.encode((byte[]) fieldValue); + fieldMap.put(fieldName, stringValue); + } else { + fieldMap.put(fieldName, fieldValue); + } + } + + // Build JSON manually to handle the different field formats from Redis search + StringBuilder jsonBuilder = new StringBuilder(); + jsonBuilder.append("{"); + boolean first = true; + for (Entry entry : fieldMap.entrySet()) { + if (!first) { + jsonBuilder.append(","); + } + first = false; + + String fieldName = entry.getKey(); + Object fieldValue = entry.getValue(); + String valueStr = (String) fieldValue; - Object entity = switch (dialect) { - case ONE, TWO -> gsonInstance.fromJson(SafeEncoder.encode((byte[]) doc.get("$")), domainType); - case THREE -> gsonInstance.fromJson(gsonInstance.fromJson(SafeEncoder.encode((byte[]) doc.get("$")), - JsonArray.class).get(0), domainType); - }; + jsonBuilder.append("\"").append(fieldName).append("\":"); + + // Handle different types based on the raw value from Redis + if (fieldName.equals("name") || (valueStr.startsWith("\"") && valueStr.endsWith("\""))) { + // String field - quote if not already quoted + if (valueStr.startsWith("\"") && valueStr.endsWith("\"")) { + jsonBuilder.append(valueStr); + } else { + jsonBuilder.append("\"").append(valueStr).append("\""); + } + } else if (valueStr.equals("true") || valueStr.equals("false")) { + // Boolean + jsonBuilder.append(valueStr); + } else if (valueStr.equals("1") && fieldName.equals("active")) { + // Special case for boolean stored as 1/0 + jsonBuilder.append("true"); + } else if (valueStr.equals("0") && fieldName.equals("active")) { + jsonBuilder.append("false"); + } else { + // Number or other type - keep as is + jsonBuilder.append(valueStr); + } + } + jsonBuilder.append("}"); + + String jsonFromFields = jsonBuilder.toString(); + entity = gsonInstance.fromJson(jsonFromFields, domainType); + } return ObjectUtils.populateRedisKey(entity, doc.getId()); } diff --git a/tests/src/test/java/com/redis/om/spring/fixtures/document/model/DocumentWithMixedTypes.java b/tests/src/test/java/com/redis/om/spring/fixtures/document/model/DocumentWithMixedTypes.java new file mode 100644 index 00000000..8ce9c024 --- /dev/null +++ b/tests/src/test/java/com/redis/om/spring/fixtures/document/model/DocumentWithMixedTypes.java @@ -0,0 +1,39 @@ +package com.redis.om.spring.fixtures.document.model; + +import com.redis.om.spring.annotations.Document; +import com.redis.om.spring.annotations.Indexed; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; + +import java.time.LocalDate; + +@Document +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class DocumentWithMixedTypes { + + @Id + private String id; + + @Indexed + private String name; + + @Indexed + private Integer age; + + @Indexed + private Double salary; + + @Indexed + private Boolean active; + + @Indexed + private LocalDate birthDate; + + private String description; +} \ No newline at end of file diff --git a/tests/src/test/java/com/redis/om/spring/fixtures/document/repository/DocumentMixedTypesProjection.java b/tests/src/test/java/com/redis/om/spring/fixtures/document/repository/DocumentMixedTypesProjection.java new file mode 100644 index 00000000..19c650dd --- /dev/null +++ b/tests/src/test/java/com/redis/om/spring/fixtures/document/repository/DocumentMixedTypesProjection.java @@ -0,0 +1,22 @@ +package com.redis.om.spring.fixtures.document.repository; + +import java.time.LocalDate; + +/** + * Projection interface WITHOUT @Value annotations to demonstrate the issue + * where non-String fields return null + */ +public interface DocumentMixedTypesProjection { + + // String fields should work without @Value + String getName(); + + // Non-String fields will return null without @Value annotation + Integer getAge(); + + Double getSalary(); + + Boolean getActive(); + + LocalDate getBirthDate(); +} \ No newline at end of file diff --git a/tests/src/test/java/com/redis/om/spring/fixtures/document/repository/DocumentMixedTypesProjectionFixed.java b/tests/src/test/java/com/redis/om/spring/fixtures/document/repository/DocumentMixedTypesProjectionFixed.java new file mode 100644 index 00000000..8073c62c --- /dev/null +++ b/tests/src/test/java/com/redis/om/spring/fixtures/document/repository/DocumentMixedTypesProjectionFixed.java @@ -0,0 +1,28 @@ +package com.redis.om.spring.fixtures.document.repository; + +import org.springframework.beans.factory.annotation.Value; + +import java.time.LocalDate; + +/** + * Projection interface WITH @Value annotations as a workaround + * to make non-String fields work correctly + */ +public interface DocumentMixedTypesProjectionFixed { + + // String fields work without @Value + String getName(); + + // Non-String fields need @Value annotation to work + @Value("#{target.age}") + Integer getAge(); + + @Value("#{target.salary}") + Double getSalary(); + + @Value("#{target.active}") + Boolean getActive(); + + @Value("#{target.birthDate}") + LocalDate getBirthDate(); +} \ No newline at end of file diff --git a/tests/src/test/java/com/redis/om/spring/fixtures/document/repository/DocumentMixedTypesRepository.java b/tests/src/test/java/com/redis/om/spring/fixtures/document/repository/DocumentMixedTypesRepository.java new file mode 100644 index 00000000..91361441 --- /dev/null +++ b/tests/src/test/java/com/redis/om/spring/fixtures/document/repository/DocumentMixedTypesRepository.java @@ -0,0 +1,16 @@ +package com.redis.om.spring.fixtures.document.repository; + +import com.redis.om.spring.fixtures.document.model.DocumentWithMixedTypes; +import com.redis.om.spring.repository.RedisDocumentRepository; + +import java.util.Collection; +import java.util.Optional; + +public interface DocumentMixedTypesRepository extends RedisDocumentRepository { + + // Projection without @Value annotations - following working test pattern + Optional findByName(String name); + + // Projection with @Value annotations + Collection findAllByName(String name); +} \ No newline at end of file diff --git a/tests/src/test/java/com/redis/om/spring/repository/DocumentProjectionMixedTypesTest.java b/tests/src/test/java/com/redis/om/spring/repository/DocumentProjectionMixedTypesTest.java new file mode 100644 index 00000000..9e251702 --- /dev/null +++ b/tests/src/test/java/com/redis/om/spring/repository/DocumentProjectionMixedTypesTest.java @@ -0,0 +1,107 @@ +package com.redis.om.spring.repository; + +import com.redis.om.spring.AbstractBaseDocumentTest; +import com.redis.om.spring.fixtures.document.model.DocumentWithMixedTypes; +import com.redis.om.spring.fixtures.document.repository.DocumentMixedTypesProjection; +import com.redis.om.spring.fixtures.document.repository.DocumentMixedTypesProjectionFixed; +import com.redis.om.spring.fixtures.document.repository.DocumentMixedTypesRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDate; +import java.util.Collection; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test to reproduce and demonstrate issue #650: + * Projection interfaces return null for non-String fields when not using @Value annotation + */ +class DocumentProjectionMixedTypesTest extends AbstractBaseDocumentTest { + + @Autowired + private DocumentMixedTypesRepository repository; + + private DocumentWithMixedTypes testEntity; + + @BeforeEach + void setUp() { + testEntity = DocumentWithMixedTypes.builder() + .name("John Doe") + .age(30) + .salary(75000.50) + .active(true) + .birthDate(LocalDate.of(1993, 5, 15)) + .description("Test employee") + .build(); + + testEntity = repository.save(testEntity); + } + + @Test + void testEntityFetch_VerifyDataExists() { + // First verify the entity exists with proper data + Optional entity = repository.findById(testEntity.getId()); + + assertTrue(entity.isPresent(), "Entity should be found by id"); + assertEquals("John Doe", entity.get().getName()); + assertEquals(30, entity.get().getAge()); + assertEquals(75000.50, entity.get().getSalary()); + assertTrue(entity.get().getActive()); + assertEquals(LocalDate.of(1993, 5, 15), entity.get().getBirthDate()); + } + + @Test + void testProjectionWithoutValueAnnotation_AllFieldsShouldWork() { + // After the fix, non-String fields should work without @Value annotation + Optional projection = repository.findByName("John Doe"); + + assertTrue(projection.isPresent(), "Projection should be present"); + + // All fields should now work without @Value annotation + assertEquals("John Doe", projection.get().getName(), "String field should work"); + assertEquals(30, projection.get().getAge(), "Integer field should work WITHOUT @Value annotation"); + assertEquals(75000.50, projection.get().getSalary(), "Double field should work WITHOUT @Value annotation"); + assertTrue(projection.get().getActive(), "Boolean field should work WITHOUT @Value annotation"); + assertEquals(LocalDate.of(1993, 5, 15), projection.get().getBirthDate(), + "LocalDate field should work WITHOUT @Value annotation"); + } + + @Test + void testProjectionWithValueAnnotation_AllFieldsWork() { + // Test that all fields work correctly with @Value annotation (the workaround) + Collection projections = repository.findAllByName("John Doe"); + + assertFalse(projections.isEmpty(), "Projections should be present"); + DocumentMixedTypesProjectionFixed projection = projections.iterator().next(); + + // All fields should work with @Value annotation + assertEquals("John Doe", projection.getName(), "String field should work"); + assertEquals(30, projection.getAge(), "Integer field should work with @Value"); + assertEquals(75000.50, projection.getSalary(), "Double field should work with @Value"); + assertTrue(projection.getActive(), "Boolean field should work with @Value"); + assertEquals(LocalDate.of(1993, 5, 15), projection.getBirthDate(), + "LocalDate field should work with @Value"); + } + + @Test + void testDirectEntityFetch_AllFieldsWork() { + // Verify that the entity itself has all fields correctly stored + Optional entity = repository.findById(testEntity.getId()); + + assertTrue(entity.isPresent(), "Entity should be present"); + assertEquals("John Doe", entity.get().getName()); + assertEquals(30, entity.get().getAge()); + assertEquals(75000.50, entity.get().getSalary()); + assertTrue(entity.get().getActive()); + assertEquals(LocalDate.of(1993, 5, 15), entity.get().getBirthDate()); + } + + @AfterEach + void tearDown() { + repository.deleteAll(); + } +} \ No newline at end of file