Skip to content

Commit a9a9a4f

Browse files
committed
feat: add @RedisKey annotation to populate entity fields with Redis keys during search operations (#538)
Add new @RedisKey annotation that allows entities to capture their Redis key during retrieval operations. This feature enables entities to know their full Redis key, which is useful for operations that need the complete key including custom prefixes. - Add @RedisKey field annotation for marking fields to receive Redis keys - Implement populateRedisKey utility method with class-level caching for performance - Integrate key population into all entity retrieval paths (adapters, repositories, queries) - Add optimized metadata caching in RedisEnhancedPersistentEntity with lazy initialization - Support both Document and Hash entities with default and custom key prefixes - Add comprehensive test coverage for all entity types and key prefix scenarios The implementation uses efficient caching strategies to avoid repeated field scanning for entities that don't use the @RedisKey feature, ensuring minimal performance impact.
1 parent 3570ccf commit a9a9a4f

22 files changed

+857
-19
lines changed

redis-om-spring/src/main/java/com/redis/om/spring/RedisEnhancedKeyValueAdapter.java

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,19 @@ public <T> T get(Object id, String keyspace, Class<T> type) {
231231
data.setId(stringId);
232232
data.setKeyspace(stringKeyspace);
233233

234-
return readTimeToLiveIfSet(binId, converter.read(type, data));
234+
T entity = readTimeToLiveIfSet(binId, converter.read(type, data));
235+
if (entity != null) {
236+
String redisKey = new String(binId);
237+
// Use optimized method if we can get the persistent entity
238+
RedisPersistentEntity<?> persistentEntity = converter.getMappingContext().getPersistentEntity(type);
239+
if (persistentEntity instanceof RedisEnhancedPersistentEntity) {
240+
((RedisEnhancedPersistentEntity<?>) persistentEntity).populateRedisKey(entity, redisKey);
241+
} else {
242+
// Fallback to utility method
243+
com.redis.om.spring.util.ObjectUtils.populateRedisKey(entity, redisKey);
244+
}
245+
}
246+
return entity;
235247
}
236248

237249
/*
@@ -326,8 +338,19 @@ public <T> List<T> getAllOf(String keyspace, Class<T> type, long offset, int row
326338
query.limit(Math.toIntExact(offset), limit);
327339
SearchResult searchResult = searchOps.search(query);
328340

341+
// Get persistent entity once for all results
342+
RedisPersistentEntity<?> persistentEntity = converter.getMappingContext().getPersistentEntity(type);
343+
boolean useOptimized = persistentEntity instanceof RedisEnhancedPersistentEntity;
344+
329345
return (List<T>) searchResult.getDocuments().stream() //
330-
.map(d -> documentToObject(d, type, (MappingRedisOMConverter) converter)) //
346+
.map(d -> {
347+
Object entity = documentToObject(d, type, (MappingRedisOMConverter) converter);
348+
if (useOptimized) {
349+
return ((RedisEnhancedPersistentEntity<?>) persistentEntity).populateRedisKey(entity, d.getId());
350+
} else {
351+
return com.redis.om.spring.util.ObjectUtils.populateRedisKey(entity, d.getId());
352+
}
353+
}) //
331354
.toList();
332355
}
333356

redis-om-spring/src/main/java/com/redis/om/spring/RedisJSONKeyValueAdapter.java

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,18 @@ public <T> T get(String key, Class<T> type) {
197197
@SuppressWarnings(
198198
"unchecked"
199199
) JSONOperations<String> ops = (JSONOperations<String>) redisJSONOperations;
200-
return ops.get(key, type);
200+
T entity = ops.get(key, type);
201+
if (entity != null) {
202+
// Use optimized method if we can get the persistent entity
203+
RedisPersistentEntity<?> persistentEntity = mappingContext.getPersistentEntity(type);
204+
if (persistentEntity instanceof RedisEnhancedPersistentEntity) {
205+
((RedisEnhancedPersistentEntity<?>) persistentEntity).populateRedisKey(entity, key);
206+
} else {
207+
// Fallback to utility method
208+
ObjectUtils.populateRedisKey(entity, key);
209+
}
210+
}
211+
return entity;
201212
}
202213

203214
/**
@@ -222,8 +233,19 @@ public <T> List<T> getAllOf(String keyspace, Class<T> type, long offset, int row
222233
query.limit(Math.toIntExact(offset), limit);
223234
SearchResult searchResult = searchOps.search(query);
224235
Gson gson = gsonBuilder.create();
225-
return searchResult.getDocuments().stream().map(d -> gson.fromJson(SafeEncoder.encode((byte[]) d.get("$")), type)) //
226-
.toList();
236+
237+
// Get persistent entity once for all results
238+
RedisPersistentEntity<?> persistentEntity = mappingContext.getPersistentEntity(type);
239+
boolean useOptimized = persistentEntity instanceof RedisEnhancedPersistentEntity;
240+
241+
return searchResult.getDocuments().stream().map(d -> {
242+
T entity = gson.fromJson(SafeEncoder.encode((byte[]) d.get("$")), type);
243+
if (useOptimized) {
244+
return ((RedisEnhancedPersistentEntity<?>) persistentEntity).populateRedisKey(entity, d.getId());
245+
} else {
246+
return ObjectUtils.populateRedisKey(entity, d.getId());
247+
}
248+
}).toList();
227249
}
228250

229251
/**
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package com.redis.om.spring.annotations;
2+
3+
import java.lang.annotation.*;
4+
5+
/**
6+
* Marks a field to be populated with the Redis key during search operations.
7+
* <p>
8+
* When a field is annotated with @RedisKey, it will automatically be populated
9+
* with the full Redis key (including prefix) when the entity is retrieved
10+
* through search operations.
11+
* </p>
12+
*
13+
* <p>Example usage:</p>
14+
* <pre>
15+
* {@code
16+
* @Document
17+
* public class MyDocument {
18+
*
19+
* @Id
20+
* private String id;
21+
*
22+
* @RedisKey
23+
* private String redisKey;
24+
*
25+
* // other fields...
26+
* }
27+
* }
28+
* </pre>
29+
*
30+
* <p>Note: Only one field per entity should be annotated with @RedisKey.</p>
31+
*
32+
* @author Brian Sam-Bodden
33+
* @since 1.0.0
34+
*/
35+
@Documented
36+
@Retention(
37+
RetentionPolicy.RUNTIME
38+
)
39+
@Target(
40+
{ ElementType.FIELD, ElementType.METHOD }
41+
)
42+
public @interface RedisKey {
43+
}

redis-om-spring/src/main/java/com/redis/om/spring/mapping/RedisEnhancedPersistentEntity.java

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package com.redis.om.spring.mapping;
22

3+
import java.lang.reflect.Field;
34
import java.util.ArrayList;
45
import java.util.List;
6+
import java.util.Optional;
57

68
import org.springframework.data.keyvalue.core.mapping.KeySpaceResolver;
79
import org.springframework.data.mapping.MappingException;
@@ -12,6 +14,9 @@
1214
import org.springframework.data.util.TypeInformation;
1315
import org.springframework.lang.Nullable;
1416

17+
import com.redis.om.spring.annotations.RedisKey;
18+
import com.redis.om.spring.util.ObjectUtils;
19+
1520
import jakarta.persistence.IdClass;
1621

1722
/**
@@ -45,6 +50,7 @@ public class RedisEnhancedPersistentEntity<T> extends BasicRedisPersistentEntity
4550

4651
private final List<RedisPersistentProperty> idProperties = new ArrayList<>();
4752
private final boolean hasIdClass;
53+
private volatile Optional<Field> redisKeyField = null; // Lazy initialized
4854

4955
/**
5056
* Creates a new {@code RedisEnhancedPersistentEntity} for the given type information.
@@ -125,4 +131,62 @@ public List<RedisPersistentProperty> getIdProperties() {
125131
public boolean isIdClassComposite() {
126132
return hasIdClass;
127133
}
134+
135+
/**
136+
* Returns the field annotated with @RedisKey if present.
137+
* This method caches the result for performance.
138+
*
139+
* @return an {@link Optional} containing the @RedisKey field if present,
140+
* or an empty Optional if no field has the annotation
141+
*/
142+
public Optional<Field> getRedisKeyField() {
143+
if (redisKeyField == null) {
144+
synchronized (this) {
145+
if (redisKeyField == null) {
146+
redisKeyField = findRedisKeyField();
147+
}
148+
}
149+
}
150+
return redisKeyField;
151+
}
152+
153+
/**
154+
* Populates the @RedisKey field of the given entity with the provided Redis key.
155+
* This method uses the cached field information for optimal performance.
156+
*
157+
* @param entity the entity to populate
158+
* @param redisKey the Redis key to set
159+
* @param <E> the entity type
160+
* @return the entity with populated @RedisKey field
161+
*/
162+
@SuppressWarnings(
163+
"unchecked"
164+
)
165+
public <E> E populateRedisKey(E entity, String redisKey) {
166+
if (entity == null || redisKey == null) {
167+
return entity;
168+
}
169+
170+
Optional<Field> fieldOpt = getRedisKeyField();
171+
if (fieldOpt.isPresent()) {
172+
try {
173+
fieldOpt.get().set(entity, redisKey);
174+
} catch (IllegalAccessException e) {
175+
throw new RuntimeException("Failed to set @RedisKey field", e);
176+
}
177+
}
178+
179+
return entity;
180+
}
181+
182+
private Optional<Field> findRedisKeyField() {
183+
List<Field> fields = ObjectUtils.getDeclaredFieldsTransitively(getType());
184+
for (Field field : fields) {
185+
if (field.isAnnotationPresent(RedisKey.class)) {
186+
field.setAccessible(true);
187+
return Optional.of(field);
188+
}
189+
}
190+
return Optional.empty();
191+
}
128192
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -597,11 +597,13 @@ private Object parseDocumentResult(redis.clients.jedis.search.Document doc) {
597597

598598
Gson gsonInstance = getGson();
599599

600-
return switch (dialect) {
600+
Object entity = switch (dialect) {
601601
case ONE, TWO -> gsonInstance.fromJson(SafeEncoder.encode((byte[]) doc.get("$")), domainType);
602602
case THREE -> gsonInstance.fromJson(gsonInstance.fromJson(SafeEncoder.encode((byte[]) doc.get("$")),
603603
JsonArray.class).get(0), domainType);
604604
};
605+
606+
return ObjectUtils.populateRedisKey(entity, doc.getId());
605607
}
606608

607609
private Object executeDeleteQuery(Object[] parameters) {

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

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -580,8 +580,10 @@ private Object executeQuery(Object[] parameters) {
580580
if (queryMethod.getReturnedObjectType() == SearchResult.class) {
581581
result = searchResult;
582582
} else if (queryMethod.isPageQuery()) {
583-
List<Object> content = searchResult.getDocuments().stream().map(d -> ObjectUtils.documentToObject(d, queryMethod
584-
.getReturnedObjectType(), mappingConverter)).collect(Collectors.toList());
583+
List<Object> content = searchResult.getDocuments().stream().map(d -> {
584+
Object entity = ObjectUtils.documentToObject(d, queryMethod.getReturnedObjectType(), mappingConverter);
585+
return ObjectUtils.populateRedisKey(entity, d.getId());
586+
}).collect(Collectors.toList());
585587

586588
if (maybePageable.isPresent()) {
587589
Pageable pageable = maybePageable.get();
@@ -591,14 +593,17 @@ private Object executeQuery(Object[] parameters) {
591593
}
592594
} else if (!queryMethod.isCollectionQuery()) {
593595
if (searchResult.getTotalResults() > 0 && !searchResult.getDocuments().isEmpty()) {
594-
result = ObjectUtils.documentToObject(searchResult.getDocuments().get(0), queryMethod.getReturnedObjectType(),
595-
mappingConverter);
596+
redis.clients.jedis.search.Document doc = searchResult.getDocuments().get(0);
597+
Object entity = ObjectUtils.documentToObject(doc, queryMethod.getReturnedObjectType(), mappingConverter);
598+
result = ObjectUtils.populateRedisKey(entity, doc.getId());
596599
} else {
597600
result = null;
598601
}
599602
} else if (queryMethod.isCollectionQuery()) {
600-
result = searchResult.getDocuments().stream().map(d -> ObjectUtils.documentToObject(d, queryMethod
601-
.getReturnedObjectType(), mappingConverter)).collect(Collectors.toList());
603+
result = searchResult.getDocuments().stream().map(d -> {
604+
Object entity = ObjectUtils.documentToObject(d, queryMethod.getReturnedObjectType(), mappingConverter);
605+
return ObjectUtils.populateRedisKey(entity, d.getId());
606+
}).collect(Collectors.toList());
602607
} else {
603608
result = null;
604609
}

redis-om-spring/src/main/java/com/redis/om/spring/repository/support/SimpleRedisDocumentRepository.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -600,8 +600,10 @@ public Page<T> findAll(Pageable pageable) {
600600
Gson gson = gsonBuilder.create();
601601

602602
if (searchResult.getTotalResults() > 0) {
603-
List<T> content = searchResult.getDocuments().stream().map(d -> gson.fromJson(SafeEncoder.encode((byte[]) d.get(
604-
"$")), metadata.getJavaType())).toList();
603+
List<T> content = searchResult.getDocuments().stream().map(d -> {
604+
T entity = gson.fromJson(SafeEncoder.encode((byte[]) d.get("$")), metadata.getJavaType());
605+
return ObjectUtils.populateRedisKey(entity, d.getId());
606+
}).toList();
605607

606608
return new PageImpl<>(content, pageable, searchResult.getTotalResults());
607609
} else {

redis-om-spring/src/main/java/com/redis/om/spring/repository/support/SimpleRedisEnhancedRepository.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,10 @@ public Page<T> findAll(Pageable pageable) {
255255
@SuppressWarnings(
256256
"unchecked"
257257
) List<T> content = (List<T>) searchResult.getDocuments().stream() //
258-
.map(d -> ObjectUtils.documentToObject(d, metadata.getJavaType(), mappingConverter)) //
258+
.map(d -> {
259+
Object entity = ObjectUtils.documentToObject(d, metadata.getJavaType(), mappingConverter);
260+
return ObjectUtils.populateRedisKey(entity, d.getId());
261+
}) //
259262
.toList();
260263
return new PageImpl<>(content, pageable, searchResult.getTotalResults());
261264
} else {

redis-om-spring/src/main/java/com/redis/om/spring/search/stream/SearchStreamImpl.java

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -648,11 +648,15 @@ private List<E> toEntityList(SearchResult searchResult) {
648648
if (projections.isEmpty()) {
649649
if (isDocument) {
650650
Gson g = getGson();
651-
return searchResult.getDocuments().stream().map(d -> g.fromJson(SafeEncoder.encode((byte[]) d.get("$")),
652-
entityClass)).toList();
651+
return searchResult.getDocuments().stream().map(d -> {
652+
E entity = g.fromJson(SafeEncoder.encode((byte[]) d.get("$")), entityClass);
653+
return ObjectUtils.populateRedisKey(entity, d.getId());
654+
}).toList();
653655
} else {
654-
return searchResult.getDocuments().stream().map(d -> (E) ObjectUtils.documentToObject(d, entityClass,
655-
mappingConverter)).toList();
656+
return searchResult.getDocuments().stream().map(d -> {
657+
E entity = (E) ObjectUtils.documentToObject(d, entityClass, mappingConverter);
658+
return ObjectUtils.populateRedisKey(entity, d.getId());
659+
}).toList();
656660
}
657661
} else {
658662
List<E> projectedEntities = new ArrayList<>();

redis-om-spring/src/main/java/com/redis/om/spring/util/ObjectUtils.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import java.nio.ByteOrder;
1515
import java.nio.FloatBuffer;
1616
import java.util.*;
17+
import java.util.concurrent.ConcurrentHashMap;
1718
import java.util.function.Function;
1819
import java.util.stream.Collectors;
1920
import java.util.stream.Stream;
@@ -60,6 +61,7 @@ public class ObjectUtils {
6061
*/
6162
public static final Character REPLACEMENT_CHARACTER = '_';
6263
static final Set<String> JAVA_LITERAL_WORDS = Set.of("true", "false", "null");
64+
private static final ConcurrentHashMap<Class<?>, Boolean> HAS_REDIS_KEY_CACHE = new ConcurrentHashMap<>();
6365
// Java reserved keywords
6466
static final Set<String> JAVA_RESERVED_WORDS = Collections.unmodifiableSet(Stream.of(
6567
// Unused
@@ -1033,4 +1035,48 @@ public static Object getPropertyValue(Object object, String propertyName) {
10331035
throw new RuntimeException("Error getting property value", e);
10341036
}
10351037
}
1038+
1039+
/**
1040+
* Populates fields annotated with @RedisKey with the Redis key value.
1041+
* This method searches for fields annotated with @RedisKey and sets their value
1042+
* to the provided Redis key.
1043+
*
1044+
* @param entity the entity to populate
1045+
* @param redisKey the Redis key to set
1046+
* @param <T> the entity type
1047+
* @return the entity with populated @RedisKey field
1048+
*/
1049+
public static <T> T populateRedisKey(T entity, String redisKey) {
1050+
if (entity == null || redisKey == null) {
1051+
return entity;
1052+
}
1053+
1054+
Class<?> clazz = entity.getClass();
1055+
1056+
// Quick check: if this class doesn't have @RedisKey fields, return early
1057+
Boolean hasRedisKey = HAS_REDIS_KEY_CACHE.computeIfAbsent(clazz, cls -> {
1058+
List<Field> fields = getDeclaredFieldsTransitively(cls);
1059+
return fields.stream().anyMatch(f -> f.isAnnotationPresent(com.redis.om.spring.annotations.RedisKey.class));
1060+
});
1061+
1062+
if (!hasRedisKey) {
1063+
return entity;
1064+
}
1065+
1066+
// If we get here, we know there's at least one @RedisKey field
1067+
List<Field> fields = getDeclaredFieldsTransitively(clazz);
1068+
1069+
for (Field field : fields) {
1070+
if (field.isAnnotationPresent(com.redis.om.spring.annotations.RedisKey.class)) {
1071+
try {
1072+
field.setAccessible(true);
1073+
field.set(entity, redisKey);
1074+
} catch (IllegalAccessException e) {
1075+
throw new RuntimeException("Failed to set @RedisKey field", e);
1076+
}
1077+
}
1078+
}
1079+
1080+
return entity;
1081+
}
10361082
}

0 commit comments

Comments
 (0)