dotnet-cqrs/docs/core-features/dynamic-queries/groups-and-aggregates.md

744 lines
12 KiB
Markdown

# Groups and Aggregates
Grouping and aggregation for analytics and reporting.
## Overview
Grouping and aggregation enable SQL-like `GROUP BY` operations with aggregate functions (COUNT, SUM, AVG, MIN, MAX) for analytics and reporting queries.
**Common Use Cases:**
- Sales totals by category
- Order counts by customer
- Average rating by product
- Revenue by month
- Inventory by warehouse
## Aggregate Functions
### Count
```json
{
"aggregates": [
{
"path": "*",
"type": "Count"
}
]
}
```
**SQL Equivalent:** `SELECT COUNT(*)`
**Response:**
```json
{
"aggregates": [
{
"path": "*",
"type": "Count",
"value": 150
}
]
}
```
### Sum
```json
{
"aggregates": [
{
"path": "totalAmount",
"type": "Sum"
}
]
}
```
**SQL Equivalent:** `SELECT SUM(totalAmount)`
**Response:**
```json
{
"aggregates": [
{
"path": "totalAmount",
"type": "Sum",
"value": 125430.50
}
]
}
```
### Average
```json
{
"aggregates": [
{
"path": "price",
"type": "Average"
}
]
}
```
**SQL Equivalent:** `SELECT AVG(price)`
### Min
```json
{
"aggregates": [
{
"path": "price",
"type": "Min"
}
]
}
```
**SQL Equivalent:** `SELECT MIN(price)`
### Max
```json
{
"aggregates": [
{
"path": "price",
"type": "Max"
}
]
}
```
**SQL Equivalent:** `SELECT MAX(price)`
## Multiple Aggregates
```json
{
"aggregates": [
{
"path": "*",
"type": "Count"
},
{
"path": "totalAmount",
"type": "Sum"
},
{
"path": "totalAmount",
"type": "Average"
},
{
"path": "totalAmount",
"type": "Min"
},
{
"path": "totalAmount",
"type": "Max"
}
]
}
```
**SQL Equivalent:**
```sql
SELECT COUNT(*),
SUM(totalAmount),
AVG(totalAmount),
MIN(totalAmount),
MAX(totalAmount)
```
**Response:**
```json
{
"aggregates": [
{ "path": "*", "type": "Count", "value": 450 },
{ "path": "totalAmount", "type": "Sum", "value": 547820.75 },
{ "path": "totalAmount", "type": "Average", "value": 1217.38 },
{ "path": "totalAmount", "type": "Min", "value": 12.50 },
{ "path": "totalAmount", "type": "Max", "value": 9999.99 }
]
}
```
## Grouping
### Single Group
```json
{
"groups": [
{
"path": "category"
}
],
"aggregates": [
{
"path": "*",
"type": "Count"
}
]
}
```
**SQL Equivalent:**
```sql
SELECT category, COUNT(*)
FROM products
GROUP BY category
```
**Response:**
```json
{
"groupedData": [
{
"key": { "category": "Electronics" },
"count": 125,
"aggregates": [
{ "path": "*", "type": "Count", "value": 125 }
]
},
{
"key": { "category": "Books" },
"count": 200,
"aggregates": [
{ "path": "*", "type": "Count", "value": 200 }
]
},
{
"key": { "category": "Toys" },
"count": 75,
"aggregates": [
{ "path": "*", "type": "Count", "value": 75 }
]
}
]
}
```
### Multiple Groups
```json
{
"groups": [
{
"path": "category"
},
{
"path": "status"
}
],
"aggregates": [
{
"path": "*",
"type": "Count"
}
]
}
```
**SQL Equivalent:**
```sql
SELECT category, status, COUNT(*)
FROM orders
GROUP BY category, status
```
**Response:**
```json
{
"groupedData": [
{
"key": { "category": "Electronics", "status": "Pending" },
"count": 25,
"aggregates": [{ "path": "*", "type": "Count", "value": 25 }]
},
{
"key": { "category": "Electronics", "status": "Completed" },
"count": 100,
"aggregates": [{ "path": "*", "type": "Count", "value": 100 }]
},
{
"key": { "category": "Books", "status": "Pending" },
"count": 40,
"aggregates": [{ "path": "*", "type": "Count", "value": 40 }]
}
]
}
```
## Filtering with Grouping
### Filter Before Grouping (WHERE)
```json
{
"filters": [
{
"path": "orderDate",
"operator": "GreaterThanOrEqual",
"value": "2024-01-01T00:00:00Z"
}
],
"groups": [
{
"path": "category"
}
],
"aggregates": [
{
"path": "totalAmount",
"type": "Sum"
}
]
}
```
**SQL Equivalent:**
```sql
SELECT category, SUM(totalAmount)
FROM orders
WHERE orderDate >= '2024-01-01'
GROUP BY category
```
## Common Scenarios
### Scenario 1: Sales by Category
```json
{
"groups": [
{
"path": "category"
}
],
"aggregates": [
{
"path": "*",
"type": "Count"
},
{
"path": "totalAmount",
"type": "Sum"
},
{
"path": "totalAmount",
"type": "Average"
}
]
}
```
**Response:**
```json
{
"groupedData": [
{
"key": { "category": "Electronics" },
"count": 150,
"aggregates": [
{ "path": "*", "type": "Count", "value": 150 },
{ "path": "totalAmount", "type": "Sum", "value": 247350.00 },
{ "path": "totalAmount", "type": "Average", "value": 1649.00 }
]
}
]
}
```
### Scenario 2: Orders by Customer
```json
{
"groups": [
{
"path": "customerId"
}
],
"aggregates": [
{
"path": "*",
"type": "Count"
},
{
"path": "totalAmount",
"type": "Sum"
}
],
"sorts": [
{
"path": "totalAmount",
"descending": true
}
]
}
```
### Scenario 3: Monthly Revenue
```json
{
"filters": [
{
"path": "orderDate",
"operator": "GreaterThanOrEqual",
"value": "2024-01-01T00:00:00Z"
}
],
"groups": [
{
"path": "month"
}
],
"aggregates": [
{
"path": "totalAmount",
"type": "Sum"
},
{
"path": "*",
"type": "Count"
}
]
}
```
### Scenario 4: Product Ratings
```json
{
"filters": [
{
"path": "rating",
"operator": "GreaterThan",
"value": 0
}
],
"groups": [
{
"path": "productId"
}
],
"aggregates": [
{
"path": "rating",
"type": "Average"
},
{
"path": "*",
"type": "Count"
}
]
}
```
### Scenario 5: Inventory by Warehouse
```json
{
"groups": [
{
"path": "warehouseId"
},
{
"path": "category"
}
],
"aggregates": [
{
"path": "quantity",
"type": "Sum"
}
]
}
```
## Advanced Examples
### Top Customers by Revenue
```json
{
"groups": [
{
"path": "customerId"
}
],
"aggregates": [
{
"path": "totalAmount",
"type": "Sum"
},
{
"path": "*",
"type": "Count"
}
],
"sorts": [
{
"path": "totalAmount",
"descending": true
}
],
"page": 1,
"pageSize": 10
}
```
### Sales Summary by Region and Category
```json
{
"filters": [
{
"path": "orderDate",
"operator": "GreaterThanOrEqual",
"value": "2024-01-01T00:00:00Z"
}
],
"groups": [
{
"path": "region"
},
{
"path": "category"
}
],
"aggregates": [
{
"path": "*",
"type": "Count"
},
{
"path": "totalAmount",
"type": "Sum"
},
{
"path": "totalAmount",
"type": "Average"
}
],
"sorts": [
{
"path": "region",
"descending": false
},
{
"path": "totalAmount",
"descending": true
}
]
}
```
## Preparing Data for Grouping
### Add Computed Properties
For grouping by month, year, etc., add computed properties to your DTO:
```csharp
public class Order
{
public int Id { get; set; }
public DateTime OrderDate { get; set; }
public decimal TotalAmount { get; set; }
}
public record OrderDto
{
public int Id { get; init; }
public DateTime OrderDate { get; init; }
public decimal TotalAmount { get; init; }
// Computed properties for grouping
public int Year => OrderDate.Year;
public int Month => OrderDate.Month;
public string YearMonth => $"{OrderDate.Year}-{OrderDate.Month:D2}";
}
```
**Group by Month:**
```json
{
"groups": [
{
"path": "yearMonth"
}
],
"aggregates": [
{
"path": "totalAmount",
"type": "Sum"
}
]
}
```
## Client-Side Processing
### TypeScript
```typescript
interface GroupedResult<T> {
groupedData: Array<{
key: Record<string, any>;
count: number;
aggregates: Array<{
path: string;
type: string;
value: number;
}>;
}>;
}
async function getSalesByCategory(): Promise<GroupedResult<OrderDto>> {
const request = {
groups: [{ path: "category" }],
aggregates: [
{ path: "*", type: "Count" },
{ path: "totalAmount", type: "Sum" }
]
};
const response = await fetch('/api/query/orderDynamicQuery', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request)
});
return await response.json();
}
// Process results
const result = await getSalesByCategory();
result.groupedData.forEach(group => {
const category = group.key.category;
const count = group.aggregates.find(a => a.type === 'Count')?.value;
const total = group.aggregates.find(a => a.type === 'Sum')?.value;
console.log(`${category}: ${count} orders, $${total} total`);
});
```
### C# HttpClient
```csharp
public class OrderAnalyticsClient
{
private readonly HttpClient _httpClient;
public async Task<GroupedResult<OrderDto>> GetSalesByCategoryAsync()
{
var request = new
{
groups = new[] { new { path = "category" } },
aggregates = new[]
{
new { path = "*", type = "Count" },
new { path = "totalAmount", type = "Sum" }
}
};
var response = await _httpClient.PostAsJsonAsync(
"/api/query/orderDynamicQuery",
request);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<GroupedResult<OrderDto>>();
}
}
public class GroupedResult<T>
{
public List<GroupedItem> GroupedData { get; set; } = new();
}
public class GroupedItem
{
public Dictionary<string, object> Key { get; set; } = new();
public int Count { get; set; }
public List<AggregateResult> Aggregates { get; set; } = new();
}
public class AggregateResult
{
public string Path { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
public decimal Value { get; set; }
}
```
## Performance Considerations
### Add Indexes for Grouped Columns
```csharp
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>(entity =>
{
// Index columns used in GROUP BY
entity.HasIndex(e => e.Category);
entity.HasIndex(e => e.CustomerId);
entity.HasIndex(e => e.Status);
// Composite index for common groupings
entity.HasIndex(e => new { e.Category, e.Status });
});
}
```
### Filter Before Grouping
Always apply filters before grouping to reduce the dataset:
```json
{
"filters": [
{
"path": "orderDate",
"operator": "GreaterThanOrEqual",
"value": "2024-01-01T00:00:00Z"
}
],
"groups": [{ "path": "category" }],
"aggregates": [{ "path": "totalAmount", "type": "Sum" }]
}
```
## Best Practices
### ✅ DO
- Filter data before grouping to reduce dataset
- Add indexes on grouped columns
- Use pagination with grouped results
- Group by indexed columns when possible
- Use multiple aggregates to get comprehensive statistics
- Sort grouped results for better presentation
### ❌ DON'T
- Don't group by high-cardinality columns (like IDs) without pagination
- Don't group without aggregates (just use distinct filtering)
- Don't skip filtering when working with large datasets
- Don't group by computed columns that can't use indexes
## See Also
- [Dynamic Queries Overview](README.md)
- [Getting Started](getting-started.md)
- [Filters and Sorts](filters-and-sorts.md)
- [Queryable Providers](queryable-providers.md)