Search code examples
linqentity-framework-coredynamic-linq

Grouping IQueryable by Time Period with Additional Columns in C#


I'm currently working on a C# project where I need to group an IQueryable by different time periods (e.g., annually, quarterly, monthly, weekly, daily) based on a date column. To accomplish this, I've created a helper function GroupQueryByTimePeriod. However, I also need to include additional columns in the groupings, such as ClientId in this particular query or AssignedTo, and in other queries based on their respective columns.

I attempted to modify the function to include these additional columns in the grouping, but I encountered difficulties. Dynamic LINQ seemed like a potential solution, but unfortunately, it can't call SQL User Defined Functions (UDFs), and I need to calculate accurate week numbers using a UDF (GetWeekNumber). Simply dividing days by 7 won't provide the precision I require.

Here's a simplified version of my current code:

public static IQueryable<IGrouping<object, T>> GroupQueryByTimePeriod<T>(this IQueryable<T> query,
    TimePeriodGroupingType groupingType,
    ApplicationDbContext context
) where T : IDate
{
    IQueryable<IGrouping<object, T>> groupedQuery = null;
    switch (groupingType)
    {
        case TimePeriodGroupingType.Annually:
            groupedQuery = query.GroupBy(x => new
            {
                x.Date.Year,
            }).AsQueryable();
            break;
        case TimePeriodGroupingType.Quarterly:
            groupedQuery = query.GroupBy(x => new
            {
                x.Date.Year,
                Quarter = ((x.Date.Month - 1) / 3)
            }).AsQueryable();
            break;
        case TimePeriodGroupingType.Monthly:
            groupedQuery = query.GroupBy(x => new
            {
                x.Date.Year,
                //Quarter = ((x.Date.Month - 1) / 3),
                x.Date.Month
            }).AsQueryable();
            break;
        case TimePeriodGroupingType.Weekly:
            // Assuming weeks start on Mondays
            groupedQuery = query.GroupBy(x => new
            {
                x.Date.Year,
                //Quarter = ((x.Date.Month - 1) / 3),
                //x.Date.Month,
                Week = context.GetWeekNumber(x.Date)
            }).AsQueryable();
            break;
        case TimePeriodGroupingType.Daily:
            groupedQuery = query.GroupBy(x => new
            {
                x.Date.Year,
                //Quarter = ((x.Date.Month - 1) / 3),
                //x.Date.Month,
                //Week = context.GetWeekNumber(x.Date),
                Date = x.Date.Date,
            }).AsQueryable();
            break;
    }
    return groupedQuery;
}

Interface for date columns

public interface IDate
{
    DateTime Date { get; }
}

Usage example:


private async Task<List<T>> GetPaymentChartReportData<T>(PaymentChartSearchViewModel search) where T : PaymentReportViewModel, IDate, ILabel, new()
{
    var queryablePayments = (from p in _db.Payments
                             join c in _db.Clients on p.ClientId equals c.Id
                             join cu in _db.Users on c.UserId equals cu.Id
                             join au in _db.Users on c.AssignedToUserId equals au.Id
                             where p.Type == (int)PaymentTypeCatalog.Payment
                             && (search.FromDate == null || search.FromDate <= p.Date)
                             && (search.ToDate == null || search.ToDate >= p.Date)
                             select new T
                             {
                                 Client = new ClientBriefViewModel
                                 {
                                     Id = c.Id,
                                     FirstName = cu.FirstName,
                                     LastName = cu.LastName,
                                     Username = cu.UserName
                                 },
                                 AssignedTo = new EmployeeBriefViewModel()
                                 {
                                     Id = au.Id,
                                     FirstName = au.FirstName,
                                     LastName = au.LastName,
                                 },
                                 Amount = p.Amount,
                                 Date = p.Date
                             }).AsNoTracking();
    var queryableTimePeriodGroupedPaymentsQueryable = queryablePayments
    .GroupQueryByTimePeriod(search.TimePeriodGroupingType, _db);
    var queryableTimePeriodGroupedPayments = queryableTimePeriodGroupedPaymentsQueryable.Select(x => new T
    {
        Client = new ClientBriefViewModel
        {
            Id = x.Max(y => y.Client.Id),
            FirstName = x.Max(y => y.Client.FirstName),
            LastName = x.Max(y => y.Client.LastName),
            Username = x.Max(y => y.Client.Username)
        },
        AssignedTo = new EmployeeBriefViewModel()
        {
            Id = x.Max(y => y.AssignedTo.Id),
            FirstName = x.Max(y => y.AssignedTo.FirstName),
            LastName = x.Max(y => y.AssignedTo.LastName),
        },
        Amount = x.Sum(y => y.Amount),
        Date = x.Max(y => y.Date),
    });





    var query = queryableTimePeriodGroupedPayments.ToQueryString();
    var payments = await queryableTimePeriodGroupedPayments.ToListAsync();
    var paymentsReportData = payments.SetLabelAndFillMissingData(search.FromDate, search.ToDate, search.TimePeriodGroupingType);
    return paymentsReportData;
}

Grouping TimePeriod Function Call

var queryableTimePeriodGroupedPaymentsQueryable = queryablePayments
    .GroupQueryByTimePeriod(search.TimePeriodGroupingType, _db);

I'm seeking guidance on how to modify the GroupQueryByTimePeriod function to accommodate grouping by additional columns (e.g., client information or membership information) alongside the time periods. Is there an alternative approach or workaround that would allow me to achieve this requirement without relying on dynamic LINQ? Any insights or suggestions would be highly appreciated.


Solution

  • At first define common grouping key class. Additional property should contain additional grouping key details:

    public class PeriodGroupingKey<TAdditional>
    {
        public int Year { get; set; }
        public int Month { get; set; }
        public int Quarter { get; set; }
        public int Week { get; set; }
        public DateTime Date { get; set; }
    
        public TAdditional Additional { get; set; }
    }
    
    

    Reuse this class in your grouping query:

    public static IQueryable<IGrouping<PeriodGroupingKey<TAdditional>, T>> GroupQueryByTimePeriod<T, TAdditional>(this IQueryable<T> query, 
        TimePeriodGroupingType groupingType, 
        Expression<Func<T, TAdditional>> additionalKey,
        ApplicationDbContext context
    ) where T : IDate
    {
        // we inject additionalKey into groupingKey as a parameter
        Expression<Func<T, TAdditional, PeriodGroupingKey<TAdditional>>> groupingKey = groupingType switch
        {
            TimePeriodGroupingType.Annually  => (x, a) => new PeriodGroupingKey<TAdditional> { Year = x.Date.Year, Additional = a },
            TimePeriodGroupingType.Quarterly => (x, a) => new PeriodGroupingKey<TAdditional> { Year = x.Date.Year, Quarter = (x.Date.Month - 1) / 3, Additional = a },
            TimePeriodGroupingType.Monthly   => (x, a) => new PeriodGroupingKey<TAdditional> { Year = x.Date.Year, Month = x.Date.Month, Additional = a },
            TimePeriodGroupingType.Weekly    => (x, a) => new PeriodGroupingKey<TAdditional> { Year = x.Date.Year, Week = context.GetWeekNumber(x.Date), Additional = a },
            TimePeriodGroupingType.Daily     => (x, a) => new PeriodGroupingKey<TAdditional> { Year = x.Date.Year, Month = x.Date.Month, Additional = a },
            _ => throw new ArgumentOutOfRangeException(nameof(groupingType), groupingType, null)
        };
    
        var newGroupingBody = groupingKey.Body;
    
        // replace original groupingKey parameter with additionalKey parameter
        newGroupingBody = ReplacingExpressionVisitor.Replace(groupingKey.Parameters[0], additionalKey.Parameters[0], newGroupingBody);
    
        // inject additionalKey body into groupingKey body
        newGroupingBody = ReplacingExpressionVisitor.Replace(groupingKey.Parameters[1], additionalKey.Body, newGroupingBody);
    
        // combine new grouping lambda
        var groupingLambda = Expression.Lambda<Func<T, PeriodGroupingKey<TAdditional>>>(newGroupingBody, additionalKey.Parameters);
    
        return query.GroupBy(groupingLambda);
    }
    

    Then you can use this method with additional grouping key:

    var query = queryablePayments
        .GroupQueryByTimePeriod(TimePeriodGroupingType.Daily, e => new { e.ClientId });
    

    If additional key is not needed just use constant:

    var query = queryablePayments
        .GroupQueryByTimePeriod(TimePeriodGroupingType.Daily, e => 1);