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

12 KiB

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

{
  "aggregates": [
    {
      "path": "*",
      "type": "Count"
    }
  ]
}

SQL Equivalent: SELECT COUNT(*)

Response:

{
  "aggregates": [
    {
      "path": "*",
      "type": "Count",
      "value": 150
    }
  ]
}

Sum

{
  "aggregates": [
    {
      "path": "totalAmount",
      "type": "Sum"
    }
  ]
}

SQL Equivalent: SELECT SUM(totalAmount)

Response:

{
  "aggregates": [
    {
      "path": "totalAmount",
      "type": "Sum",
      "value": 125430.50
    }
  ]
}

Average

{
  "aggregates": [
    {
      "path": "price",
      "type": "Average"
    }
  ]
}

SQL Equivalent: SELECT AVG(price)

Min

{
  "aggregates": [
    {
      "path": "price",
      "type": "Min"
    }
  ]
}

SQL Equivalent: SELECT MIN(price)

Max

{
  "aggregates": [
    {
      "path": "price",
      "type": "Max"
    }
  ]
}

SQL Equivalent: SELECT MAX(price)

Multiple Aggregates

{
  "aggregates": [
    {
      "path": "*",
      "type": "Count"
    },
    {
      "path": "totalAmount",
      "type": "Sum"
    },
    {
      "path": "totalAmount",
      "type": "Average"
    },
    {
      "path": "totalAmount",
      "type": "Min"
    },
    {
      "path": "totalAmount",
      "type": "Max"
    }
  ]
}

SQL Equivalent:

SELECT COUNT(*),
       SUM(totalAmount),
       AVG(totalAmount),
       MIN(totalAmount),
       MAX(totalAmount)

Response:

{
  "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

{
  "groups": [
    {
      "path": "category"
    }
  ],
  "aggregates": [
    {
      "path": "*",
      "type": "Count"
    }
  ]
}

SQL Equivalent:

SELECT category, COUNT(*)
FROM products
GROUP BY category

Response:

{
  "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

{
  "groups": [
    {
      "path": "category"
    },
    {
      "path": "status"
    }
  ],
  "aggregates": [
    {
      "path": "*",
      "type": "Count"
    }
  ]
}

SQL Equivalent:

SELECT category, status, COUNT(*)
FROM orders
GROUP BY category, status

Response:

{
  "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)

{
  "filters": [
    {
      "path": "orderDate",
      "operator": "GreaterThanOrEqual",
      "value": "2024-01-01T00:00:00Z"
    }
  ],
  "groups": [
    {
      "path": "category"
    }
  ],
  "aggregates": [
    {
      "path": "totalAmount",
      "type": "Sum"
    }
  ]
}

SQL Equivalent:

SELECT category, SUM(totalAmount)
FROM orders
WHERE orderDate >= '2024-01-01'
GROUP BY category

Common Scenarios

Scenario 1: Sales by Category

{
  "groups": [
    {
      "path": "category"
    }
  ],
  "aggregates": [
    {
      "path": "*",
      "type": "Count"
    },
    {
      "path": "totalAmount",
      "type": "Sum"
    },
    {
      "path": "totalAmount",
      "type": "Average"
    }
  ]
}

Response:

{
  "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

{
  "groups": [
    {
      "path": "customerId"
    }
  ],
  "aggregates": [
    {
      "path": "*",
      "type": "Count"
    },
    {
      "path": "totalAmount",
      "type": "Sum"
    }
  ],
  "sorts": [
    {
      "path": "totalAmount",
      "descending": true
    }
  ]
}

Scenario 3: Monthly Revenue

{
  "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

{
  "filters": [
    {
      "path": "rating",
      "operator": "GreaterThan",
      "value": 0
    }
  ],
  "groups": [
    {
      "path": "productId"
    }
  ],
  "aggregates": [
    {
      "path": "rating",
      "type": "Average"
    },
    {
      "path": "*",
      "type": "Count"
    }
  ]
}

Scenario 5: Inventory by Warehouse

{
  "groups": [
    {
      "path": "warehouseId"
    },
    {
      "path": "category"
    }
  ],
  "aggregates": [
    {
      "path": "quantity",
      "type": "Sum"
    }
  ]
}

Advanced Examples

Top Customers by Revenue

{
  "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

{
  "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:

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:

{
  "groups": [
    {
      "path": "yearMonth"
    }
  ],
  "aggregates": [
    {
      "path": "totalAmount",
      "type": "Sum"
    }
  ]
}

Client-Side Processing

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

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

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:

{
  "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