Handling Complex API Filter Queries in ASP.NET Core

Introduction

A generic composite filter functionality to handle the client-side complex API filter queries is always a necessary component in API development. In this article, I’m going to explain the generic composite filter which I have developed for the ASP.NET Core web API application and published on GitHub.

Composite Filter

If you check the Utility folder from the GitHub repository, you will find two cs files.

  1. CompositeFilter.cs
  2. RootFilter.cs

RootFilter.cs

It is a model class that we used to deserialize the filter query coming from the client.

private static Expression public class RootFilter
{
    public List<Filter> Filters { get; set; }
    public string Logic { get; set; }
}

public class Filter
{
    public string Field { get; set; }
    public string Operator { get; set; }
    public object Value { get; set; }
    public string Logic { get; set; } // This is the nested "logic" property for the inner filters array
    public List<Filter> Filters { get; set; } // Nested filters array
}

Assume we have the following filter query for the API from the client.

https://localhost:44360/api/values?filter={"filters":[{"field":"Name","operator":"contains","value":"0"},{"operator":"contains","value":"0","field":"description"}],"logic":"and"}

The above RootFilter model will be used to deserialize the filter query by using the Newtonsoft.Json library.

string filter = HttpContext.Request.Query["filter"];
if (!string.IsNullOrEmpty(filter))
{
    filterResult = JsonConvert.DeserializeObject<RootFilter>(filter);
}

The above code will deserialize the filter query from the client request.

CompositeFilter.cs

The composite filter file consists of four methods.

1. BuildFilterExpression

private static Expression BuildFilterExpression(Filter filter, ParameterExpression parameter)
{
    if (filter.Filters != null && filter.Filters.Any())
    {
        if (filter.Logic?.ToLower() == "and")
        {
            var andFilters = filter.Filters.Select(f => BuildFilterExpression(f, parameter));
            return andFilters.Aggregate(Expression.AndAlso);
        }
        else if (filter.Logic?.ToLower() == "or")
        {
            var orFilters = filter.Filters.Select(f => BuildFilterExpression(f, parameter));
            return orFilters.Aggregate(Expression.OrElse);
        }
    }

    if (filter.Value == null || string.IsNullOrWhiteSpace(filter.Value.ToString()))
        return null;

    var property = Expression.Property(parameter, filter.Field);
    var constant = Expression.Constant(filter.Value);

    switch (filter.Operator.ToLower())
    {
        case "eq":
            return Expression.Equal(property, constant);
        case "neq":
            return Expression.NotEqual(property, constant);
        case "lt":
            return Expression.LessThan(property, constant);
        case "lte":
            return Expression.LessThanOrEqual(property, constant);
        case "gt":
            return Expression.GreaterThan(property, constant);
        case "gte":
            return Expression.GreaterThanOrEqual(property, constant);
        case "contains":
            var containsMethod = typeof(string).GetMethod("Contains", new[] { typeof(string) });
            return Expression.Call(property, containsMethod, constant);
        case "startswith":
            var startsWithMethod = typeof(string).GetMethod("StartsWith", new[] { typeof(string), typeof(StringComparison) });

            // Convert the constant value to lowercase for case-insensitive comparison
            var constantLower = Expression.Call(constant, typeof(string).GetMethod("ToLower", Type.EmptyTypes));

            return Expression.Call(property, startsWithMethod, constantLower, Expression.Constant(StringComparison.OrdinalIgnoreCase));

        // Add more operators as needed...
        default:
            throw new ArgumentException($"Unsupported operator: {filter.Operator}");
    }
}

This function will build the fundamental filter expression with different operators like eq, neq,lt, lte, gt, gte, contains, and startswith.

2. GetAndFilterExpression

private static Expression<Func<T, bool>> GetAndFilterExpression(List<Filter> filters)
{
    if (filters == null || !filters.Any())
        return null;

    var parameter = Expression.Parameter(typeof(T), "x");
    Expression andExpression = null;

    foreach (var filter in filters)
    {
        var filterExpression = BuildFilterExpression(filter, parameter);
        if (filterExpression != null)
        {
            if (andExpression == null)
            {
                andExpression = filterExpression;
            }
            else
            {
                andExpression = Expression.AndAlso(andExpression, filterExpression);
            }
        }
    }

    if (andExpression == null)
    {
        // Return default expression that always evaluates to false
        andExpression = Expression.Constant(false);
    }

    return Expression.Lambda<Func<T, bool>>(andExpression, parameter);
}

This function will help to build the filter expression for AND logic with a nested filter option.

3. GetOrFilterExpression

private static Expression<Func<T, bool>> GetOrFilterExpression(List<Filter> filters)
{
    if (filters == null || !filters.Any())
        return null;

    var parameter = Expression.Parameter(typeof(T), "x");
    Expression orExpression = null;

    foreach (var filter in filters)
    {
        var filterExpression = BuildFilterExpression(filter, parameter);
        if (filterExpression != null)
        {
            if (orExpression == null)
            {
                orExpression = filterExpression;
            }
            else
            {
                orExpression = Expression.OrElse(orExpression, filterExpression);
            }
        }
    }

    if (orExpression == null)
    {
        // Return default expression that always evaluates to false
        orExpression = Expression.Constant(false);
    }

    return Expression.Lambda<Func<T, bool>>(orExpression, parameter);
}

This function will help to build the filter expression for OR logic with a nested filter option.

4. ApplyFilter

public static IQueryable<T> ApplyFilter(IQueryable<T> query, RootFilter filter)
{
    if (filter == null || filter.Filters == null || !filter.Filters.Any())
        return query;

    Expression<Func<T, bool>> compositeFilterExpression = null;

    if (filter.Logic?.ToLower() == "and")
    {
        compositeFilterExpression = GetAndFilterExpression(filter.Filters);
    }
    else if (filter.Logic?.ToLower() == "or")
    {
        compositeFilterExpression = GetOrFilterExpression(filter.Filters);
    }

    return compositeFilterExpression != null
        ? query.Where(compositeFilterExpression)
        : query;
}

This function is exposed for the call from another file,

sessionsQuery = CompositeFilter<Product>.ApplyFilter(sessionsQuery, filterResult); 

Where sessionQuery will be the list of product records.

Here is the complete action written in the API Controller.

public async Task<IActionResult> Get()
{
    dynamic sessions = new ExpandoObject();
    string filter = HttpContext.Request.Query["filter"];
    var filterResult = new RootFilter();

    if (!string.IsNullOrEmpty(filter))
    {
        filterResult = JsonConvert.DeserializeObject<RootFilter>(filter);
    }
    else
    {
        filterResult = new RootFilter();
    }

    var productList = new List<Product>();
    for (int i = 0; i < 100; i++)
    {
        productList.Add(new Product { ProductID = i, Name = "Product " + i, Price = i * 10, Description = "Description " + i });
    }

    var sessionsQuery = productList.AsQueryable();

    if (filterResult.Filters != null)
    {
        sessionsQuery = CompositeFilter<Product>.ApplyFilter(sessionsQuery, filterResult);
    }

    sessions.records = sessionsQuery.ToList();

    return Ok(sessions.records);
}

Let’s test API with some complex filter queries from Postman.

https://localhost:44360/api/values?filter={"filters":[{"field":"Name","operator":"contains","value":"0"},{"operator":"contains","value":"0","field":"description"}],"logic":"and"} 

postman testing

Added more complexity(logic grouping) in the filter query.

https://localhost:44360/api/values?filter={"filters":[{"field":"Name","operator":"contains","value":"0"},{"operator":"contains","value":"0","field":"description"},{"logic":"or","filters":[{"operator":"eq","value":"1","field":"Name"},{"operator":"eq","value":"Description 1","field":"description"}]}],"logic":"or"}

Summary

We have seen how to perform the composite filter in ASP.NET Core API based on the filter queries from the client, and we also see the generic filter functions are also capable of handling complex filter queries.

Happy coding!!!


Similar Articles