Skip to content

Commit a0c8d93

Browse files
committed
feat: handling of non-standard JSON scenarios
1 parent d79fb99 commit a0c8d93

File tree

9 files changed

+472
-23
lines changed

9 files changed

+472
-23
lines changed

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

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -562,10 +562,16 @@ else if (Map.class.isAssignableFrom(fieldType) && isDocument) {
562562
if (maybeValueType.isPresent()) {
563563
Class<?> valueType = maybeValueType.get();
564564
logger.info(String.format("Map field %s has value type: %s", field.getName(), valueType));
565+
566+
// Use the Map field's alias if specified, otherwise use the field name
567+
String mapFieldNameForIndex = (indexed.alias() != null && !indexed.alias().isEmpty()) ?
568+
indexed.alias() :
569+
field.getName();
570+
565571
String mapJsonPath = (prefix == null || prefix.isBlank()) ?
566572
"$." + field.getName() + ".*" :
567573
"$." + prefix + "." + field.getName() + ".*";
568-
String mapFieldAlias = field.getName() + "_values";
574+
String mapFieldAlias = mapFieldNameForIndex + "_values";
569575

570576
// Support all value types that we support for regular fields
571577
if (CharSequence.class.isAssignableFrom(
@@ -610,14 +616,17 @@ else if (Map.class.isAssignableFrom(fieldType) && isDocument) {
610616
for (java.lang.reflect.Field subfield : getDeclaredFieldsTransitively(valueType)) {
611617
if (subfield.isAnnotationPresent(Indexed.class)) {
612618
Indexed subfieldIndexed = subfield.getAnnotation(Indexed.class);
619+
// Get the actual JSON field name (check for @JsonProperty or @SerializedName)
620+
String jsonFieldName = getJsonFieldName(subfield);
613621
String nestedJsonPath = (prefix == null || prefix.isBlank()) ?
614-
"$." + field.getName() + ".*." + subfield.getName() :
615-
"$." + prefix + "." + field.getName() + ".*." + subfield.getName();
622+
"$." + field.getName() + ".*." + jsonFieldName :
623+
"$." + prefix + "." + field.getName() + ".*." + jsonFieldName;
616624
// Respect the alias annotation on the nested field
617625
String subfieldAlias = (subfieldIndexed.alias() != null && !subfieldIndexed.alias().isEmpty()) ?
618626
subfieldIndexed.alias() :
619627
subfield.getName();
620-
String nestedFieldAlias = field.getName() + "_" + subfieldAlias;
628+
// Use the Map field's alias (if present) for the nested field alias prefix
629+
String nestedFieldAlias = mapFieldNameForIndex + "_" + subfieldAlias;
621630

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

1330+
private String getJsonFieldName(java.lang.reflect.Field field) {
1331+
// Check for @JsonProperty annotation first
1332+
if (field.isAnnotationPresent(com.fasterxml.jackson.annotation.JsonProperty.class)) {
1333+
com.fasterxml.jackson.annotation.JsonProperty jsonProperty = field.getAnnotation(
1334+
com.fasterxml.jackson.annotation.JsonProperty.class);
1335+
if (jsonProperty.value() != null && !jsonProperty.value().isEmpty()) {
1336+
return jsonProperty.value();
1337+
}
1338+
}
1339+
1340+
// Check for @SerializedName annotation (Gson)
1341+
if (field.isAnnotationPresent(com.google.gson.annotations.SerializedName.class)) {
1342+
com.google.gson.annotations.SerializedName serializedName = field.getAnnotation(
1343+
com.google.gson.annotations.SerializedName.class);
1344+
if (serializedName.value() != null && !serializedName.value().isEmpty()) {
1345+
return serializedName.value();
1346+
}
1347+
}
1348+
1349+
// Default to field name
1350+
return field.getName();
1351+
}
1352+
13211353
private void registerAlias(Class<?> cl, String fieldName, String alias) {
13221354
entityClassFieldToAlias.put(Tuples.of(cl, fieldName), alias);
13231355
}

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

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,7 @@ else if (Map.class.isAssignableFrom(targetCls)) {
513513
Element subfieldElement = enclosedElement;
514514
if (subfieldElement.getAnnotation(com.redis.om.spring.annotations.Indexed.class) != null) {
515515
String subfieldName = subfieldElement.getSimpleName().toString();
516+
String jsonFieldName = getJsonFieldName(subfieldElement);
516517
String nestedFieldName = field.getSimpleName().toString().toUpperCase().replace("_",
517518
"") + "_" + subfieldName.toUpperCase().replace("_", "");
518519

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

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

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

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

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

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

1098+
/**
1099+
* Get the JSON field name for a field element, checking for @JsonProperty and @SerializedName annotations.
1100+
* Falls back to the Java field name if no JSON annotation is found.
1101+
*/
1102+
private String getJsonFieldName(Element fieldElement) {
1103+
// Check for @JsonProperty annotation first
1104+
for (AnnotationMirror mirror : fieldElement.getAnnotationMirrors()) {
1105+
String annotationType = mirror.getAnnotationType().toString();
1106+
if ("com.fasterxml.jackson.annotation.JsonProperty".equals(annotationType)) {
1107+
for (Map.Entry<? extends ExecutableElement, ? extends AnnotationValue> entry : mirror.getElementValues()
1108+
.entrySet()) {
1109+
if ("value".equals(entry.getKey().getSimpleName().toString())) {
1110+
String value = entry.getValue().getValue().toString();
1111+
if (value != null && !value.isEmpty() && !value.equals("\"\"")) {
1112+
// Remove quotes from the annotation value
1113+
return value.replaceAll("^\"|\"$", "");
1114+
}
1115+
}
1116+
}
1117+
}
1118+
// Check for @SerializedName annotation (Gson)
1119+
else if ("com.google.gson.annotations.SerializedName".equals(annotationType)) {
1120+
for (Map.Entry<? extends ExecutableElement, ? extends AnnotationValue> entry : mirror.getElementValues()
1121+
.entrySet()) {
1122+
if ("value".equals(entry.getKey().getSimpleName().toString())) {
1123+
String value = entry.getValue().getValue().toString();
1124+
if (value != null && !value.isEmpty() && !value.equals("\"\"")) {
1125+
// Remove quotes from the annotation value
1126+
return value.replaceAll("^\"|\"$", "");
1127+
}
1128+
}
1129+
}
1130+
}
1131+
}
1132+
1133+
// Default to field name
1134+
return fieldElement.getSimpleName().toString();
1135+
}
1136+
10961137
private Pair<FieldSpec, CodeBlock> generateThisMetamodelField(TypeName entity) {
10971138
String name = "_THIS";
10981139
String alias = "__this";

redis-om-spring/src/main/java/com/redis/om/spring/repository/query/RediSearchQuery.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,14 @@ private void processMapContainsQuery(String methodName) {
444444
logger.debug(String.format("Looking for Map field '%s' (or '%s') in %s: %s", mapFieldName,
445445
originalMapFieldName, domainType.getSimpleName(), mapField != null ? "FOUND" : "NOT FOUND"));
446446
if (mapField != null && Map.class.isAssignableFrom(mapField.getType())) {
447+
// Check if the Map field has an @Indexed alias
448+
String mapFieldNameForIndex = mapFieldName;
449+
if (mapField.isAnnotationPresent(Indexed.class)) {
450+
Indexed mapIndexed = mapField.getAnnotation(Indexed.class);
451+
if (mapIndexed.alias() != null && !mapIndexed.alias().isEmpty()) {
452+
mapFieldNameForIndex = mapIndexed.alias();
453+
}
454+
}
447455
// Get the Map's value type
448456
Optional<Class<?>> maybeValueType = ObjectUtils.getMapValueClass(mapField);
449457
if (maybeValueType.isPresent()) {
@@ -460,7 +468,7 @@ private void processMapContainsQuery(String methodName) {
460468
actualNestedFieldName = indexed.alias();
461469
}
462470
}
463-
String indexFieldName = mapFieldName + "_" + actualNestedFieldName;
471+
String indexFieldName = mapFieldNameForIndex + "_" + actualNestedFieldName;
464472

465473
// Determine the field type and part type
466474
Class<?> nestedFieldType = ClassUtils.resolvePrimitiveIfNecessary(nestedField.getType());

tests/src/test/java/com/redis/om/spring/annotations/document/MapComplexObjectUpperCaseTest.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,6 @@ void loadTestData() throws IOException {
9898
position.setManager("DEFAULT_MANAGER");
9999
position.setDescription("DEFAULT_DESCRIPTION");
100100
position.setPrice(new BigDecimal("100.00"));
101-
position.setAsOfDate(LocalDate.now());
102101

103102
positions.put(posEntry.getKey(), position);
104103
}
@@ -115,9 +114,9 @@ void loadTestData() throws IOException {
115114
@Test
116115
void testFindByManager() {
117116
// This should work because manager field uses @Indexed(alias = "MANAGER")
118-
List<AccountUC> accounts = repository.findByManager("Emma Jones");
117+
List<AccountUC> accounts = repository.findByManager("Manager Gamma");
119118
assertThat(accounts).isNotEmpty();
120-
assertThat(accounts.get(0).getManager()).isEqualTo("Emma Jones");
119+
assertThat(accounts.get(0).getManager()).isEqualTo("Manager Gamma");
121120
}
122121

123122
@Test
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package com.redis.om.spring.annotations.document;
2+
3+
import com.google.gson.Gson;
4+
import com.redis.om.spring.AbstractBaseDocumentTest;
5+
import com.redis.om.spring.fixtures.document.model.AccountUC;
6+
import com.redis.om.spring.fixtures.document.repository.AccountUCRepository;
7+
import org.junit.jupiter.api.BeforeEach;
8+
import org.junit.jupiter.api.Test;
9+
import org.springframework.beans.factory.annotation.Autowired;
10+
import org.springframework.core.io.ClassPathResource;
11+
12+
import java.io.IOException;
13+
import java.io.InputStreamReader;
14+
import java.util.Arrays;
15+
import java.util.List;
16+
17+
import static org.assertj.core.api.Assertions.assertThat;
18+
19+
class MapContainsNonStandardJsonFieldsTest extends AbstractBaseDocumentTest {
20+
21+
@Autowired
22+
AccountUCRepository repository;
23+
24+
@BeforeEach
25+
void loadTestData() throws IOException, InterruptedException {
26+
// Clear any existing data
27+
repository.deleteAll();
28+
29+
// Wait for index to be recreated
30+
Thread.sleep(2000);
31+
32+
// Load test data from JSON file with non-standard (uppercase) field names
33+
Gson gson = new Gson();
34+
ClassPathResource resource = new ClassPathResource("data/uppercase-json-fields-subset.json");
35+
36+
try (InputStreamReader reader = new InputStreamReader(resource.getInputStream())) {
37+
AccountUC[] accounts = gson.fromJson(reader, AccountUC[].class);
38+
39+
// Save all accounts to Redis
40+
List<AccountUC> savedAccounts = repository.saveAll(Arrays.asList(accounts));
41+
42+
// Verify data was saved
43+
System.out.println("Saved " + savedAccounts.size() + " accounts to Redis");
44+
for (AccountUC account : savedAccounts) {
45+
System.out.println("Account " + account.getAccountId() + " has " + account.getPositions().size() + " positions");
46+
}
47+
}
48+
}
49+
50+
@Test
51+
void testMapContainsWithUppercaseJsonFields() {
52+
// Query for accounts with TSLA positions
53+
List<AccountUC> accounts = repository.findByPositionsMapContainsCusip("TSLA");
54+
55+
// Verify we found the expected accounts (5 out of 7 have TSLA)
56+
assertThat(accounts).hasSize(5);
57+
58+
// Verify the account IDs match expected (5 accounts have TSLA positions)
59+
List<String> expectedIds = List.of("ACC-001", "ACC-002", "ACC-003", "ACC-004", "ACC-005");
60+
List<String> actualIds = accounts.stream()
61+
.map(AccountUC::getAccountId)
62+
.sorted()
63+
.toList();
64+
assertThat(actualIds).containsExactlyInAnyOrderElementsOf(expectedIds);
65+
66+
// Verify each account actually has TSLA positions
67+
for (AccountUC account : accounts) {
68+
boolean hasTSLA = account.getPositions().values().stream()
69+
.anyMatch(position -> "TSLA".equals(position.getCusip()));
70+
assertThat(hasTSLA)
71+
.as("Account %s should have TSLA position", account.getAccountId())
72+
.isTrue();
73+
}
74+
}
75+
76+
@Test
77+
void testMapContainsWithNonMatchingCusip() {
78+
// Query for accounts with a CUSIP that doesn't exist in our subset
79+
List<AccountUC> accounts = repository.findByPositionsMapContainsCusip("GOOGL");
80+
81+
// Should return empty list
82+
assertThat(accounts).isEmpty();
83+
}
84+
85+
@Test
86+
void testMapContainsWithOtherCusips() {
87+
// Query for accounts with AAPL positions
88+
List<AccountUC> accounts = repository.findByPositionsMapContainsCusip("AAPL");
89+
90+
// 6 out of 7 accounts have AAPL
91+
assertThat(accounts).hasSize(6);
92+
93+
// Verify each account actually has AAPL positions
94+
for (AccountUC account : accounts) {
95+
boolean hasAAPL = account.getPositions().values().stream()
96+
.anyMatch(position -> "AAPL".equals(position.getCusip()));
97+
assertThat(hasAAPL)
98+
.as("Account %s should have AAPL position", account.getAccountId())
99+
.isTrue();
100+
}
101+
}
102+
}

tests/src/test/java/com/redis/om/spring/fixtures/document/model/AccountUC.java

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
package com.redis.om.spring.fixtures.document.model;
22

33
import com.fasterxml.jackson.annotation.JsonProperty;
4+
import com.google.gson.annotations.SerializedName;
45
import com.redis.om.spring.annotations.Document;
56
import com.redis.om.spring.annotations.Indexed;
6-
import com.redis.om.spring.annotations.IndexingOptions;
77
import lombok.Data;
88
import lombok.NoArgsConstructor;
99
import org.springframework.data.annotation.Id;
@@ -19,52 +19,62 @@
1919
*/
2020
@Data
2121
@NoArgsConstructor
22-
@Document
23-
@IndexingOptions(indexName = "AccountUCIdx")
22+
@Document(indexName = "idx:om:accounts", prefixes = {"accounts:ACCOUNTID:"})
2423
public class AccountUC {
2524

2625
@Id
2726
@JsonProperty("ACCOUNTID")
27+
@SerializedName("ACCOUNTID")
2828
private String accountId;
2929

3030
@Indexed(alias = "ACC_NAME")
3131
@JsonProperty("ACC_NAME")
32+
@SerializedName("ACC_NAME")
3233
private String accountName;
3334

3435
@Indexed(alias = "MANAGER")
3536
@JsonProperty("MANAGER")
37+
@SerializedName("MANAGER")
3638
private String manager;
3739

3840
@Indexed(alias = "ACC_VALUE")
3941
@JsonProperty("ACC_VALUE")
42+
@SerializedName("ACC_VALUE")
4043
private BigDecimal accountValue;
4144

4245
// Additional fields from VOYA data
4346
@Indexed
4447
@JsonProperty("COMMISSION_RATE")
48+
@SerializedName("COMMISSION_RATE")
4549
private Integer commissionRate;
4650

4751
@Indexed
4852
@JsonProperty("CASH_BALANCE")
53+
@SerializedName("CASH_BALANCE")
4954
private BigDecimal cashBalance;
5055

5156
@JsonProperty("DAY_CHANGE")
57+
@SerializedName("DAY_CHANGE")
5258
private BigDecimal dayChange;
5359

5460
@JsonProperty("UNREALIZED_GAIN_LOSS")
61+
@SerializedName("UNREALIZED_GAIN_LOSS")
5562
private BigDecimal unrealizedGainLoss;
5663

5764
@JsonProperty("MANAGER_FNAME")
65+
@SerializedName("MANAGER_FNAME")
5866
private String managerFirstName;
5967

6068
@JsonProperty("MANAGER_LNAME")
69+
@SerializedName("MANAGER_LNAME")
6170
private String managerLastName;
6271

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

7080
// Alternative for testing: lowercase field name with uppercase JSON property

0 commit comments

Comments
 (0)