Skip to content

Commit 9c63e8e

Browse files
committed
feat: add projection and map support for aggregations (#539)
Implements Spring Data-style projections and map-based results for aggregations, providing flexible alternatives to tuple-based results. Follows Spring Data conventions where IDs are not automatically included in projections. - Add toProjection(Class<P>) method for interface-based projections - Add toMaps() and toMaps(boolean includeId) for map-based results - Projections follow Spring Data JPA pattern (IDs not auto-included) - Maps include IDs by default with option to exclude - Dynamic proxy implementation for projection interfaces - Type conversion support for common types - Comprehensive tests for both document and hash entities - Documentation with examples and comparison of approaches
1 parent 3b31693 commit 9c63e8e

File tree

9 files changed

+1045
-9
lines changed

9 files changed

+1045
-9
lines changed

docs/content/modules/ROOT/pages/entity-streams-aggregations.adoc

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,154 @@ entityStream.of(Person.class)
299299
});
300300
----
301301

302+
== Projections and Maps
303+
304+
Redis OM Spring supports projections and map-based results for aggregations, providing flexible ways to work with aggregated data.
305+
306+
=== Interface-Based Projections
307+
308+
You can define projection interfaces to shape your aggregation results. IDs are not automatically included - add `getId()` to your projection if needed.
309+
310+
[source,java]
311+
----
312+
import static com.redis.om.spring.annotations.ReducerFunction.*;
313+
314+
// Define projection interface
315+
public interface DepartmentStatsProjection {
316+
Integer getDepartmentNumber();
317+
Long getHeadcount();
318+
Double getTotalSales();
319+
// String getId(); // Uncomment only if ID is needed
320+
}
321+
322+
// Use projection with aggregation
323+
List<DepartmentStatsProjection> stats = entityStream.of(Person.class)
324+
.groupBy(Person$.DEPARTMENT_NUMBER)
325+
.reduce(COUNT).as("headcount")
326+
.reduce(SUM, Person$.SALES).as("totalSales")
327+
.sorted(Order.desc("@totalSales"))
328+
.toProjection(DepartmentStatsProjection.class);
329+
330+
// Access data through interface methods
331+
stats.forEach(stat -> {
332+
System.out.printf("Dept %d: %d employees, $%.2f total sales%n",
333+
stat.getDepartmentNumber(),
334+
stat.getHeadcount(),
335+
stat.getTotalSales());
336+
});
337+
----
338+
339+
=== Simple Field Projections
340+
341+
For simpler use cases, create projections for specific fields:
342+
343+
[source,java]
344+
----
345+
import static com.redis.om.spring.annotations.ReducerFunction.*;
346+
347+
public interface NameProjection {
348+
String getName();
349+
Long getCount();
350+
}
351+
352+
// Get just names from aggregation with count
353+
List<NameProjection> names = entityStream.of(Person.class)
354+
.groupBy(Person$.NAME)
355+
.reduce(COUNT).as("count")
356+
.toProjection(NameProjection.class);
357+
----
358+
359+
=== Map-Based Results
360+
361+
For maximum flexibility, convert aggregation results to Maps. By default, IDs are included.
362+
363+
[source,java]
364+
----
365+
import static com.redis.om.spring.annotations.ReducerFunction.*;
366+
import java.util.Map;
367+
368+
// Get results as maps with IDs included
369+
List<Map<String, Object>> results = entityStream.of(Person.class)
370+
.groupBy(Person$.DEPARTMENT_NUMBER)
371+
.reduce(COUNT).as("count")
372+
.reduce(AVG, Person$.SALES).as("avgSales")
373+
.toMaps();
374+
375+
// Each map contains the aggregated data
376+
results.forEach(map -> {
377+
System.out.printf("Dept %s: %d employees, avg sales $%.2f%n",
378+
map.get("departmentNumber"),
379+
map.get("count"),
380+
map.get("avgSales"));
381+
});
382+
383+
// Exclude IDs if not needed
384+
List<Map<String, Object>> resultsNoId = entityStream.of(Person.class)
385+
.groupBy(Person$.NAME)
386+
.reduce(SUM, Person$.SALES)
387+
.toMaps(false); // IDs excluded
388+
----
389+
390+
=== Projection vs Maps vs Tuples
391+
392+
Choose the right approach for your use case:
393+
394+
[source,java]
395+
----
396+
import static com.redis.om.spring.annotations.ReducerFunction.*;
397+
import java.util.Map;
398+
399+
// 1. Projections - Type-safe, IDE support, best for defined structures
400+
public interface SalesProjection {
401+
String getName();
402+
Double getTotalSales();
403+
}
404+
405+
List<SalesProjection> projections = entityStream.of(Person.class)
406+
.groupBy(Person$.NAME)
407+
.reduce(SUM, Person$.SALES).as("totalSales")
408+
.toProjection(SalesProjection.class);
409+
410+
// 2. Maps - Flexible, dynamic fields, good for variable structures
411+
List<Map<String, Object>> maps = entityStream.of(Person.class)
412+
.groupBy(Person$.NAME)
413+
.reduce(SUM, Person$.SALES).as("totalSales")
414+
.toMaps();
415+
416+
// 3. Tuples - Lightweight, positional access, existing approach
417+
List<Pair<String, Double>> tuples = entityStream.of(Person.class)
418+
.groupBy(Person$.NAME)
419+
.reduce(SUM, Person$.SALES)
420+
.toList(String.class, Double.class);
421+
----
422+
423+
=== Complex Projections
424+
425+
Projections can handle complex aggregation results:
426+
427+
[source,java]
428+
----
429+
import static com.redis.om.spring.annotations.ReducerFunction.*;
430+
431+
public interface ComprehensiveStats {
432+
Integer getDepartmentNumber();
433+
Long getEmployeeCount();
434+
Double getMinSales();
435+
Double getMaxSales();
436+
Double getAvgSales();
437+
Double getTotalSales();
438+
}
439+
440+
List<ComprehensiveStats> stats = entityStream.of(Person.class)
441+
.groupBy(Person$.DEPARTMENT_NUMBER)
442+
.reduce(COUNT).as("employeeCount")
443+
.reduce(MIN, Person$.SALES).as("minSales")
444+
.reduce(MAX, Person$.SALES).as("maxSales")
445+
.reduce(AVG, Person$.SALES).as("avgSales")
446+
.reduce(SUM, Person$.SALES).as("totalSales")
447+
.toProjection(ComprehensiveStats.class);
448+
----
449+
302450
== Performance Considerations
303451

304452
=== Efficient Aggregations

docs/content/modules/ROOT/pages/entity-streams.adoc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,8 @@ List<Integer> foundingYears = entityStream.of(Company.class)
271271
.collect(Collectors.toList());
272272
----
273273

274+
NOTE: For more advanced projection capabilities, including interface-based projections and map results, see xref:entity-streams-aggregations.adoc#_projections_and_maps[Projections and Maps in Aggregations].
275+
274276
=== Multiple Field Projections
275277

276278
Create tuples for multiple field projections:

docs/content/modules/ROOT/pages/overview.adoc

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,25 @@
22
:page-toclevels: 3
33
:page-pagination:
44

5-
Redis OM Spring is a comprehensive advanced object-mapping, querying and repositories framework for Spring applications using Redis, it extends and enhances Spring Data Redis. While Spring Data Redis provides basic Redis functionality, Redis OM Spring delivers a complete solution designed specifically to leverage the advanced features of Redis 8+, Redis Enterprise, and Redis Cloud.
5+
Redis OM Spring is a comprehensive advanced object-mapping, querying and repositories framework for Spring applications using Redis, it extends and enhances Spring Data Redis. While Spring Data Redis provides basic Redis functionality, Redis OM Spring delivers a complete solution designed specifically to leverage the advanced features of Redis 8+ (and a few older Redis Stack distros), Redis Enterprise, and Redis Cloud.
66

77
== What is Redis OM Spring?
88

9-
Redis OM Spring is an Object Mapping framework designed specifically for Redis, allowing Java developers to work with Redis data using familiar annotations and repository patterns. Redis OM Spring reimagines how Redis should integrate with Spring applications to provide a more intuitive and powerful developer experience.
9+
Redis OM Spring is an Object Mapping framework designed specifically for Redis, allowing Java developers to work with Redis data using annotations, repository patterns, fluid Streams-like APIs and more. Redis OM Spring reimagines how Redis should integrate with Spring applications to provide a more intuitive and powerful developer experience.
1010

1111
image::redis-om-spring-architecture.png[Redis OM Spring Architecture,width=100%]
1212

1313
[.lead]
1414
Redis OM Spring consists of two modules:
1515

1616
* *redis-om-spring* - Core module providing modeling, indexing, search, and repository capabilities
17-
* *redis-om-spring-ai* - AI module offering vector embedding generation through Spring AI integration
17+
* *redis-om-spring-ai* - AI module offering AI-focused features like vector embedding generation - it leverages Spring AI
1818

1919
== Redis Integration
2020

2121
Redis OM Spring is built to work with Redis 8.0.0+, which includes these essential capabilities:
2222

23-
* *Query Engine* - Powerful search and query engine (formerly RediSearch)
23+
* *Query Engine* - Powerful search / query engine with Full-text and Vector capabilities (formerly RediSearch)
2424
* *JSON* - Native JSON document storage
2525
* *Probabilistic Data Structures* - Bloom filters, Cuckoo filters, and more
2626
* *Auto-Complete* - Fast auto-complete server-side functionality
@@ -153,9 +153,12 @@ public class MyDoc {
153153
* Type-safe query construction with generated metamodels
154154
* Fluent filtering, sorting, and projection
155155
* Powerful aggregation capabilities with grouping and reduction
156+
* Interface-based projections and map results for aggregations
156157

157158
[source,java]
158159
----
160+
import static com.redis.om.spring.annotations.ReducerFunction.*;
161+
159162
// Find companies founded between 1980 and 2020, sorted by name
160163
List<Company> companies = entityStream
161164
.of(Company.class)
@@ -167,7 +170,7 @@ List<Company> companies = entityStream
167170
List<Pair<String, Long>> topBrands = entityStream
168171
.of(Game.class)
169172
.groupBy(Game$.BRAND)
170-
.reduce(ReducerFunction.COUNT).as("count")
173+
.reduce(COUNT).as("count")
171174
.sorted(Order.desc("@count"))
172175
.limit(5)
173176
.toList(String.class, Long.class);
@@ -176,12 +179,26 @@ List<Pair<String, Long>> topBrands = entityStream
176179
List<Quintuple<String, Double, Double, Double, Long>> priceStats = entityStream
177180
.of(Game.class)
178181
.groupBy(Game$.BRAND)
179-
.reduce(ReducerFunction.AVG, Game$.PRICE).as("avgPrice")
180-
.reduce(ReducerFunction.MIN, Game$.PRICE).as("minPrice")
181-
.reduce(ReducerFunction.MAX, Game$.PRICE).as("maxPrice")
182-
.reduce(ReducerFunction.COUNT).as("count")
182+
.reduce(AVG, Game$.PRICE).as("avgPrice")
183+
.reduce(MIN, Game$.PRICE).as("minPrice")
184+
.reduce(MAX, Game$.PRICE).as("maxPrice")
185+
.reduce(COUNT).as("count")
183186
.sorted(Order.desc("@count"))
184187
.toList(String.class, Double.class, Double.class, Double.class, Long.class);
188+
189+
// Interface-based projections for aggregations
190+
public interface BrandStats {
191+
String getBrand();
192+
Double getAvgPrice();
193+
Long getCount();
194+
}
195+
196+
List<BrandStats> brandStatistics = entityStream
197+
.of(Game.class)
198+
.groupBy(Game$.BRAND)
199+
.reduce(AVG, Game$.PRICE).as("avgPrice")
200+
.reduce(COUNT).as("count")
201+
.toProjection(BrandStats.class);
185202
----
186203

187204
=== Aggregation Support

docs/content/modules/ROOT/pages/query-annotation.adoc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,8 @@ SearchResult getFirstTag();
192192
SearchResult customFindAllByTitleStartingWithReturnFieldsAndLimit(@Param("prefix") String prefix);
193193
----
194194

195+
NOTE: For more advanced projection capabilities, including interface-based projections with type safety, see xref:entity-streams-aggregations.adoc#_projections_and_maps[Projections and Maps in Aggregations].
196+
195197
=== Pagination
196198

197199
Control the number of results with Spring Data Pageable or annotation attributes:

docs/content/modules/ROOT/pages/repository-queries.adoc

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,31 @@ public interface CompanyRepository extends RedisDocumentRepository<Company, Stri
217217
}
218218
----
219219

220+
== Working with Projections
221+
222+
Redis OM Spring supports Spring Data projections for efficient data retrieval:
223+
224+
=== Interface-Based Projections
225+
226+
Define an interface with getter methods for the fields you need:
227+
228+
[source,java]
229+
----
230+
// Define projection interface
231+
public interface CompanyProjection {
232+
String getName();
233+
Integer getYearFounded();
234+
// Note: ID is not automatically included
235+
}
236+
237+
// Use in repository - requires custom implementation
238+
public interface CompanyRepository extends RedisDocumentRepository<Company, String> {
239+
List<CompanyProjection> findByPubliclyListed(boolean publiclyListed);
240+
}
241+
----
242+
243+
NOTE: Projection support in repositories requires custom implementation. For built-in projection support, use Entity Streams with aggregations. See xref:entity-streams-aggregations.adoc#_projections_and_maps[Projections and Maps in Aggregations].
244+
220245
== Best Practices
221246

222247
* Use the appropriate query method for your needs
@@ -229,4 +254,5 @@ public interface CompanyRepository extends RedisDocumentRepository<Company, Stri
229254

230255
* xref:query-annotation.adoc[Query Annotation]
231256
* xref:entity-streams.adoc[Entity Streams]
257+
* xref:entity-streams-aggregations.adoc[Entity Streams Aggregations]
232258
* xref:qbe.adoc[Query By Example]

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import java.time.Duration;
44
import java.util.List;
5+
import java.util.Map;
56

67
import org.springframework.data.domain.Page;
78
import org.springframework.data.domain.Pageable;
@@ -238,6 +239,38 @@ public interface AggregationStream<T> {
238239
*/
239240
<R extends T> List<R> toList(Class<?>... contentTypes);
240241

242+
/**
243+
* Executes the aggregation and converts the results to a list of projection objects.
244+
* The projection interface should define getter methods for the fields to include.
245+
* IDs are not automatically included - add getId() to the projection interface if needed.
246+
*
247+
* @param <P> the projection type
248+
* @param projectionClass the projection interface class
249+
* @return a list of projection instances with the aggregated data
250+
* @throws RuntimeException if the aggregation execution or projection creation fails
251+
*/
252+
<P> List<P> toProjection(Class<P> projectionClass);
253+
254+
/**
255+
* Executes the aggregation and converts the results to a list of maps.
256+
* Each map contains field names as keys and field values as values.
257+
* By default, entity IDs are included in the results.
258+
*
259+
* @return a list of maps containing the aggregated data with IDs included
260+
* @throws RuntimeException if the aggregation execution fails
261+
*/
262+
List<Map<String, Object>> toMaps();
263+
264+
/**
265+
* Executes the aggregation and converts the results to a list of maps.
266+
* Each map contains field names as keys and field values as values.
267+
*
268+
* @param includeId whether to include entity IDs in the results
269+
* @return a list of maps containing the aggregated data
270+
* @throws RuntimeException if the aggregation execution fails
271+
*/
272+
List<Map<String, Object>> toMaps(boolean includeId);
273+
241274
/**
242275
* Returns the underlying RediSearch query that would be executed.
243276
* This is useful for debugging and understanding the generated query.

0 commit comments

Comments
 (0)