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 @@ -562,10 +562,16 @@ else if (Map.class.isAssignableFrom(fieldType) && isDocument) {
if (maybeValueType.isPresent()) {
Class<?> valueType = maybeValueType.get();
logger.info(String.format("Map field %s has value type: %s", field.getName(), valueType));

// Use the Map field's alias if specified, otherwise use the field name
String mapFieldNameForIndex = (indexed.alias() != null && !indexed.alias().isEmpty()) ?
indexed.alias() :
field.getName();

String mapJsonPath = (prefix == null || prefix.isBlank()) ?
"$." + field.getName() + ".*" :
"$." + prefix + "." + field.getName() + ".*";
String mapFieldAlias = field.getName() + "_values";
String mapFieldAlias = mapFieldNameForIndex + "_values";

// Support all value types that we support for regular fields
if (CharSequence.class.isAssignableFrom(
Expand Down Expand Up @@ -610,14 +616,17 @@ else if (Map.class.isAssignableFrom(fieldType) && isDocument) {
for (java.lang.reflect.Field subfield : getDeclaredFieldsTransitively(valueType)) {
if (subfield.isAnnotationPresent(Indexed.class)) {
Indexed subfieldIndexed = subfield.getAnnotation(Indexed.class);
// Get the actual JSON field name (check for @JsonProperty or @SerializedName)
String jsonFieldName = getJsonFieldName(subfield);
String nestedJsonPath = (prefix == null || prefix.isBlank()) ?
"$." + field.getName() + ".*." + subfield.getName() :
"$." + prefix + "." + field.getName() + ".*." + subfield.getName();
"$." + field.getName() + ".*." + jsonFieldName :
"$." + prefix + "." + field.getName() + ".*." + jsonFieldName;
// Respect the alias annotation on the nested field
String subfieldAlias = (subfieldIndexed.alias() != null && !subfieldIndexed.alias().isEmpty()) ?
subfieldIndexed.alias() :
subfield.getName();
String nestedFieldAlias = field.getName() + "_" + subfieldAlias;
// Use the Map field's alias (if present) for the nested field alias prefix
String nestedFieldAlias = mapFieldNameForIndex + "_" + subfieldAlias;

logger.info(String.format("Processing nested field %s in Map value type, path: %s, alias: %s",
subfield.getName(), nestedJsonPath, nestedFieldAlias));
Expand Down Expand Up @@ -1318,6 +1327,29 @@ private String getFieldPrefix(String prefix, boolean isDocument) {
return isDocument ? "$." + chain : chain;
}

private String getJsonFieldName(java.lang.reflect.Field field) {
// Check for @JsonProperty annotation first
if (field.isAnnotationPresent(com.fasterxml.jackson.annotation.JsonProperty.class)) {
com.fasterxml.jackson.annotation.JsonProperty jsonProperty = field.getAnnotation(
com.fasterxml.jackson.annotation.JsonProperty.class);
if (jsonProperty.value() != null && !jsonProperty.value().isEmpty()) {
return jsonProperty.value();
}
}

// Check for @SerializedName annotation (Gson)
if (field.isAnnotationPresent(com.google.gson.annotations.SerializedName.class)) {
com.google.gson.annotations.SerializedName serializedName = field.getAnnotation(
com.google.gson.annotations.SerializedName.class);
if (serializedName.value() != null && !serializedName.value().isEmpty()) {
return serializedName.value();
}
}

// Default to field name
return field.getName();
}

private void registerAlias(Class<?> cl, String fieldName, String alias) {
entityClassFieldToAlias.put(Tuples.of(cl, fieldName), alias);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,7 @@ else if (Map.class.isAssignableFrom(targetCls)) {
Element subfieldElement = enclosedElement;
if (subfieldElement.getAnnotation(com.redis.om.spring.annotations.Indexed.class) != null) {
String subfieldName = subfieldElement.getSimpleName().toString();
String jsonFieldName = getJsonFieldName(subfieldElement);
String nestedFieldName = field.getSimpleName().toString().toUpperCase().replace("_",
"") + "_" + subfieldName.toUpperCase().replace("_", "");

Expand Down Expand Up @@ -548,7 +549,7 @@ else if (Map.class.isAssignableFrom(targetCls)) {
String uniqueFieldName = chainedFieldName + "_" + subfieldName;
Triple<ObjectGraphFieldSpec, FieldSpec, CodeBlock> nestedField = generateMapNestedFieldMetamodel(
entity, chain, uniqueFieldName, nestedFieldName, nestedInterceptor, subfieldTypeName, field
.getSimpleName().toString(), subfieldName);
.getSimpleName().toString(), subfieldName, jsonFieldName);
fieldMetamodelSpec.add(nestedField);

messager.printMessage(Diagnostic.Kind.NOTE,
Expand Down Expand Up @@ -1048,7 +1049,7 @@ private Triple<ObjectGraphFieldSpec, FieldSpec, CodeBlock> generateFieldMetamode

private Triple<ObjectGraphFieldSpec, FieldSpec, CodeBlock> generateMapNestedFieldMetamodel(TypeName entity,
List<Element> chain, String chainFieldName, String nestedFieldName, Class<?> interceptorClass,
String subfieldTypeName, String mapFieldName, String subfieldName) {
String subfieldTypeName, String mapFieldName, String subfieldName, String jsonFieldName) {
String fieldAccessor = ObjectUtils.staticField(nestedFieldName);

FieldSpec objectField = FieldSpec.builder(Field.class, chainFieldName).addModifiers(Modifier.PUBLIC,
Expand All @@ -1070,9 +1071,10 @@ private Triple<ObjectGraphFieldSpec, FieldSpec, CodeBlock> generateMapNestedFiel
FieldSpec aField = FieldSpec.builder(interceptor, fieldAccessor).addModifiers(Modifier.PUBLIC, Modifier.STATIC)
.build();

// Create the JSONPath for nested Map field: $.mapField.*.subfieldName
String alias = mapFieldName + "_" + subfieldName;
String jsonPath = "$." + mapFieldName + ".*." + subfieldName;
// Create the JSONPath for nested Map field: $.mapField.*.jsonFieldName
// Use JSON field name for both alias and path to match what the indexer creates
String alias = mapFieldName + "_" + jsonFieldName;
String jsonPath = "$." + mapFieldName + ".*." + jsonFieldName;

CodeBlock aFieldInit = CodeBlock.builder().addStatement(
"$L = new $T(new $T(\"$L\", \"$L\", $T.class, $T.class), true)", fieldAccessor, interceptor,
Expand All @@ -1093,6 +1095,45 @@ private Pair<FieldSpec, CodeBlock> generateUnboundMetamodelField(TypeName entity
return Tuples.of(aField, aFieldInit);
}

/**
* Get the JSON field name for a field element, checking for @JsonProperty and @SerializedName annotations.
* Falls back to the Java field name if no JSON annotation is found.
*/
private String getJsonFieldName(Element fieldElement) {
// Check for @JsonProperty annotation first
for (AnnotationMirror mirror : fieldElement.getAnnotationMirrors()) {
String annotationType = mirror.getAnnotationType().toString();
if ("com.fasterxml.jackson.annotation.JsonProperty".equals(annotationType)) {
for (Map.Entry<? extends ExecutableElement, ? extends AnnotationValue> entry : mirror.getElementValues()
.entrySet()) {
if ("value".equals(entry.getKey().getSimpleName().toString())) {
String value = entry.getValue().getValue().toString();
if (value != null && !value.isEmpty() && !value.equals("\"\"")) {
// Remove quotes from the annotation value
return value.replaceAll("^\"|\"$", "");
}
}
}
}
// Check for @SerializedName annotation (Gson)
else if ("com.google.gson.annotations.SerializedName".equals(annotationType)) {
for (Map.Entry<? extends ExecutableElement, ? extends AnnotationValue> entry : mirror.getElementValues()
.entrySet()) {
if ("value".equals(entry.getKey().getSimpleName().toString())) {
String value = entry.getValue().getValue().toString();
if (value != null && !value.isEmpty() && !value.equals("\"\"")) {
// Remove quotes from the annotation value
return value.replaceAll("^\"|\"$", "");
}
}
}
}
}

// Default to field name
return fieldElement.getSimpleName().toString();
}

private Pair<FieldSpec, CodeBlock> generateThisMetamodelField(TypeName entity) {
String name = "_THIS";
String alias = "__this";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,14 @@ private void processMapContainsQuery(String methodName) {
logger.debug(String.format("Looking for Map field '%s' (or '%s') in %s: %s", mapFieldName,
originalMapFieldName, domainType.getSimpleName(), mapField != null ? "FOUND" : "NOT FOUND"));
if (mapField != null && Map.class.isAssignableFrom(mapField.getType())) {
// Check if the Map field has an @Indexed alias
String mapFieldNameForIndex = mapFieldName;
if (mapField.isAnnotationPresent(Indexed.class)) {
Indexed mapIndexed = mapField.getAnnotation(Indexed.class);
if (mapIndexed.alias() != null && !mapIndexed.alias().isEmpty()) {
mapFieldNameForIndex = mapIndexed.alias();
}
}
// Get the Map's value type
Optional<Class<?>> maybeValueType = ObjectUtils.getMapValueClass(mapField);
if (maybeValueType.isPresent()) {
Expand All @@ -460,7 +468,7 @@ private void processMapContainsQuery(String methodName) {
actualNestedFieldName = indexed.alias();
}
}
String indexFieldName = mapFieldName + "_" + actualNestedFieldName;
String indexFieldName = mapFieldNameForIndex + "_" + actualNestedFieldName;

// Determine the field type and part type
Class<?> nestedFieldType = ClassUtils.resolvePrimitiveIfNecessary(nestedField.getType());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,6 @@ void loadTestData() throws IOException {
position.setManager("DEFAULT_MANAGER");
position.setDescription("DEFAULT_DESCRIPTION");
position.setPrice(new BigDecimal("100.00"));
position.setAsOfDate(LocalDate.now());

positions.put(posEntry.getKey(), position);
}
Expand All @@ -115,9 +114,9 @@ void loadTestData() throws IOException {
@Test
void testFindByManager() {
// This should work because manager field uses @Indexed(alias = "MANAGER")
List<AccountUC> accounts = repository.findByManager("Emma Jones");
List<AccountUC> accounts = repository.findByManager("Manager Gamma");
assertThat(accounts).isNotEmpty();
assertThat(accounts.get(0).getManager()).isEqualTo("Emma Jones");
assertThat(accounts.get(0).getManager()).isEqualTo("Manager Gamma");
}

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package com.redis.om.spring.annotations.document;

import com.google.gson.Gson;
import com.redis.om.spring.AbstractBaseDocumentTest;
import com.redis.om.spring.fixtures.document.model.AccountUC;
import com.redis.om.spring.fixtures.document.repository.AccountUCRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;

import java.io.IOException;
import java.io.InputStreamReader;
import java.util.Arrays;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

class MapContainsNonStandardJsonFieldsTest extends AbstractBaseDocumentTest {

@Autowired
AccountUCRepository repository;

@BeforeEach
void loadTestData() throws IOException, InterruptedException {
// Clear any existing data
repository.deleteAll();

// Wait for index to be recreated
Thread.sleep(2000);

// Load test data from JSON file with non-standard (uppercase) field names
Gson gson = new Gson();
ClassPathResource resource = new ClassPathResource("data/uppercase-json-fields-subset.json");

try (InputStreamReader reader = new InputStreamReader(resource.getInputStream())) {
AccountUC[] accounts = gson.fromJson(reader, AccountUC[].class);

// Save all accounts to Redis
List<AccountUC> savedAccounts = repository.saveAll(Arrays.asList(accounts));

// Verify data was saved
System.out.println("Saved " + savedAccounts.size() + " accounts to Redis");
for (AccountUC account : savedAccounts) {
System.out.println("Account " + account.getAccountId() + " has " + account.getPositions().size() + " positions");
}
}
}

@Test
void testMapContainsWithUppercaseJsonFields() {
// Query for accounts with TSLA positions
List<AccountUC> accounts = repository.findByPositionsMapContainsCusip("TSLA");

// Verify we found the expected accounts (5 out of 7 have TSLA)
assertThat(accounts).hasSize(5);

// Verify the account IDs match expected (5 accounts have TSLA positions)
List<String> expectedIds = List.of("ACC-001", "ACC-002", "ACC-003", "ACC-004", "ACC-005");
List<String> actualIds = accounts.stream()
.map(AccountUC::getAccountId)
.sorted()
.toList();
assertThat(actualIds).containsExactlyInAnyOrderElementsOf(expectedIds);

// Verify each account actually has TSLA positions
for (AccountUC account : accounts) {
boolean hasTSLA = account.getPositions().values().stream()
.anyMatch(position -> "TSLA".equals(position.getCusip()));
assertThat(hasTSLA)
.as("Account %s should have TSLA position", account.getAccountId())
.isTrue();
}
}

@Test
void testMapContainsWithNonMatchingCusip() {
// Query for accounts with a CUSIP that doesn't exist in our subset
List<AccountUC> accounts = repository.findByPositionsMapContainsCusip("GOOGL");

// Should return empty list
assertThat(accounts).isEmpty();
}

@Test
void testMapContainsWithOtherCusips() {
// Query for accounts with AAPL positions
List<AccountUC> accounts = repository.findByPositionsMapContainsCusip("AAPL");

// 6 out of 7 accounts have AAPL
assertThat(accounts).hasSize(6);

// Verify each account actually has AAPL positions
for (AccountUC account : accounts) {
boolean hasAAPL = account.getPositions().values().stream()
.anyMatch(position -> "AAPL".equals(position.getCusip()));
assertThat(hasAAPL)
.as("Account %s should have AAPL position", account.getAccountId())
.isTrue();
}
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package com.redis.om.spring.fixtures.document.model;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.gson.annotations.SerializedName;
import com.redis.om.spring.annotations.Document;
import com.redis.om.spring.annotations.Indexed;
import com.redis.om.spring.annotations.IndexingOptions;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
Expand All @@ -19,52 +19,62 @@
*/
@Data
@NoArgsConstructor
@Document
@IndexingOptions(indexName = "AccountUCIdx")
@Document(indexName = "idx:om:accounts", prefixes = {"accounts:ACCOUNTID:"})
public class AccountUC {

@Id
@JsonProperty("ACCOUNTID")
@SerializedName("ACCOUNTID")
private String accountId;

@Indexed(alias = "ACC_NAME")
@JsonProperty("ACC_NAME")
@SerializedName("ACC_NAME")
private String accountName;

@Indexed(alias = "MANAGER")
@JsonProperty("MANAGER")
@SerializedName("MANAGER")
private String manager;

@Indexed(alias = "ACC_VALUE")
@JsonProperty("ACC_VALUE")
@SerializedName("ACC_VALUE")
private BigDecimal accountValue;

// Additional fields from VOYA data
@Indexed
@JsonProperty("COMMISSION_RATE")
@SerializedName("COMMISSION_RATE")
private Integer commissionRate;

@Indexed
@JsonProperty("CASH_BALANCE")
@SerializedName("CASH_BALANCE")
private BigDecimal cashBalance;

@JsonProperty("DAY_CHANGE")
@SerializedName("DAY_CHANGE")
private BigDecimal dayChange;

@JsonProperty("UNREALIZED_GAIN_LOSS")
@SerializedName("UNREALIZED_GAIN_LOSS")
private BigDecimal unrealizedGainLoss;

@JsonProperty("MANAGER_FNAME")
@SerializedName("MANAGER_FNAME")
private String managerFirstName;

@JsonProperty("MANAGER_LNAME")
@SerializedName("MANAGER_LNAME")
private String managerLastName;

// Map with complex object values containing indexed fields
// Note: The field name is "Positions" with capital P to match VOYA JSON
// WITHOUT the alias, the repository method findByPositionsMapContainsCusip SHOULD FAIL
@Indexed
@JsonProperty("Positions")
@SerializedName("Positions")
private Map<String, PositionUC> Positions = new HashMap<>();

// Alternative for testing: lowercase field name with uppercase JSON property
Expand Down
Loading