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
Original file line number Diff line number Diff line change
Expand Up @@ -198,17 +198,42 @@ private <R> R doReadInternal(Class<?> entityClass, String path, Class<R> type, R
if (type.isInterface()) {
Map<String, Object> map = new HashMap<>();
RedisPersistentEntity<?> persistentEntity = mappingContext.getRequiredPersistentEntity(readType);

// Build a map of property names to their types from the projection interface
Map<String, Class<?>> 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<String, byte[]> 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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> fieldMap = new HashMap<>();
for (Entry<String, Object> 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<String, Object> 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());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
@@ -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<DocumentWithMixedTypes, String> {

// Projection without @Value annotations - following working test pattern
Optional<DocumentMixedTypesProjection> findByName(String name);

// Projection with @Value annotations
Collection<DocumentMixedTypesProjectionFixed> findAllByName(String name);
}
Original file line number Diff line number Diff line change
@@ -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<DocumentWithMixedTypes> 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<DocumentMixedTypesProjection> 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<DocumentMixedTypesProjectionFixed> 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<DocumentWithMixedTypes> 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();
}
}