744 lines
12 KiB
Markdown
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)
|