Using AutoFilterer with ABP

This article is about filtering data automatically without writing any LINQ via using AutoFilterer.

AutoFilterer is a mini filtering framework library for dotnet. The main purpose of the library is to generate LINQ expressions for Entities over DTOs automatically. Creating queries without writing any expression code is the most powerful feature that is provided. The first aim of AutoFilterer is to be compatible with Open API 3.0 Specifications, unlike oData & GraphQL.

Disclaimer: AutoFilterer is the one of my personal projects. It's not supported by ABP Framework or ABP Team officially. This article can be shown as self-promotion, so I needed to explain that.

Initializing a New Project

If you are familiar with application development with ABP Framework, you can skip to the next step "Designing the Application.Contracts Layer".

  • Create a new project:
    (I prefer mongodb as database provider to get rid of Ef migrations. You can go with Ef on your own.)
abp new Acme.BookStore -t app -d mongodb
  • Create an entity named Book
using System;
using Volo.Abp.Domain.Entities.Auditing;

namespace Acme.BookStore.Books;

public class Book : FullAuditedAggregateRoot<Guid>
{
    public string Title { get; set; }
    public string Language { get; set; }
    public string Country { get; set; }
    public string Author { get; set; }
    public int TotalPage { get; set; }
    public int Year { get; set; }
    public string Link { get; set; }
}
  • Add following property to BookStoreMongoDbContext
public IMongoCollection<Book> Books { get; set; }
  • Create a DataSeedContributor
    • Add this initial-books.json file to Acme.BookStore.Domain/Books/ path and make build action as Embedded Resource.
using Newtonsoft.Json;
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using Volo.Abp.Data;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Domain.Repositories;

namespace Acme.BookStore.Books;

public class BookstoreDataSeederContributor : IDataSeedContributor, ITransientDependency
{
    protected readonly IRepository<Book, Guid> _repository;

    public BookstoreDataSeederContributor(IRepository<Book, Guid> repository)
    {
        _repository = repository;
    }

    public async Task SeedAsync(DataSeedContext context)
    {
        if (!await _repository.AnyAsync())
        {
            await _repository.InsertManyAsync(GetInitialBooks());
        }
    }

    private Book[] GetInitialBooks()
    {
        var json = GetEmbeddedResourceAsText("Acme.Bookstore.Books.initial-books.json");

        return JsonConvert.DeserializeObject<Book[]>(json);
    }

    private string GetEmbeddedResourceAsText(string nameWithNamespace)
    {
        using var stream = GetType().Assembly.GetManifestResourceStream(nameWithNamespace);

        return Encoding.UTF8.GetString(stream.GetAllBytes());
    }
}
  • Run the DbMigrator and database with existing data is ready!

Designing the Application.Contracts Layer

In this section, We'll implement AutoFilterer package and use it for only filtering data. We'll leave Sorting and Paging to ABP Framework, because it already does it well and works with more than one UI compatible.

  • Add AutoFilterer package to your Application.Contracts project.
dotnet add package AutoFilterer
  • Let's start coding with creating DTOs.

    • BookDto
    using System;
    using Volo.Abp.Application.Dtos;
    
    namespace Acme.BookStore.Books;
    
    [Serializable]
    public class BookDto : AuditedEntityDto<Guid>
    {
        public string Title { get; set; }
        public string Language { get; set; }
        public string Country { get; set; }
        public string Author { get; set; }
        public int TotalPage { get; set; }
        public int Year { get; set; }
        public string Link { get; set; }
    }
    
    • BookGetListInput
    using AutoFilterer.Attributes;
    using AutoFilterer.Enums;
    using AutoFilterer.Types;
    using System;
    using Volo.Abp.Application.Dtos;
    
    namespace Acme.BookStore.Books;
    
    [Serializable]
    // We'll leave Paging and Sorting to ABP, we'll use only filtering feature of AutoFilterer.
    // So using FilterBase as a base class is enough.
    public class BookGetListInput : FilterBase, IPagedAndSortedResultRequest
    {
        // Configure 'Filter' property for built-in search boxes.
        [CompareTo(
            nameof(BookDto.Title),
            nameof(BookDto.Language),
            nameof(BookDto.Author),
            nameof(BookDto.Country)
            )]
        [StringFilterOptions(StringFilterOption.Contains)]
        public string Filter { get; set; }
    
        // IPagedAndSortedResultRequest implementation below.
        public int SkipCount { get; set; }
    
        public int MaxResultCount { get; set; }
    
        public string Sorting { get; set; }
    }
    
    • IBookAppService
    using System;
    using Volo.Abp.Application.Services;
    
    namespace Acme.BookStore.Books;
    
    public interface IBookAppService : ICrudAppService<BookDto, Guid, BookGetListInput>
    {
    }
    

Implementing Application Layer

I prefer using CrudAppService to skip unrelated CRUD operations.

  • Create BookAppService and apply AutoFilterer filtering to queryable via overriding CreateFilteredQueryAsync.
using AutoFilterer.Extensions;
using System;
using System.Linq;
using System.Threading.Tasks;
using Volo.Abp.Application.Services;
using Volo.Abp.Domain.Repositories;

namespace Acme.BookStore.Books;

public class BookAppService : CrudAppService<Book, BookDto, Guid, BookGetListInput>
{
    public BookAppService(IRepository<Book, Guid> repository) : base(repository)
    {
    }

    protected override async Task<IQueryable<Book>> CreateFilteredQueryAsync(BookGetListInput input)
    {
        return (await base.CreateFilteredQueryAsync(input))
            .ApplyFilter(input);
    }
}
  • Add following mapping in BookStoreApplicationAutoMapperProfile
CreateMap<Book, BookDto>().ReverseMap();

Displaying on UI

Let's start with creating a page to show data list and filter it with a textbox.

  • Create Books/Index.cshtml / Books/Index.cshtml.cs
namespace Acme.BookStore.Web.Pages.Books;

public class IndexModel : BookStorePageModel
{
}
@page
@using Acme.BookStore.Localization
@using Acme.BookStore.Web.Pages.Books
@using Microsoft.Extensions.Localization

@model IndexModel

@inject IStringLocalizer<BookStoreResource> L

<h2>Books</h2>

@section scripts
{
	<abp-script src="/Pages/Books/index.js" />
}

<abp-card>
	<abp-card-header>
		<h2>@L["Books"]</h2>
	</abp-card-header>
	<abp-card-body>
		<abp-table striped-rows="true" id="BooksTable"></abp-table>
	</abp-card-body>
</abp-card>
  • Create index.js in the same folder
$(function () {
    var l = abp.localization.getResource('BookStore');

    var dataTable = $('#BooksTable').DataTable(
        abp.libs.datatables.normalizeConfiguration({
            serverSide: true,
            paging: true,
            order: [[1, "asc"]],
            searching: true,
            scrollX: true,
            ajax: abp.libs.datatables.createAjax(acme.bookStore.books.book.getList),
            columnDefs: [
                {
                    title: l('Title'),
                    data: "title"
                },
                {
                    title: l('Language'),
                    data: "language",
                },
                {
                    title: l('Country'),
                    data: "country",
                },
                {
                    title: l('Author'),
                    data: "author"
                },
                {
                    title: l('TotalPage'),
                    data: "totalPage",
                    render: function (data) {
                        return data + ' pages'
                    }
                },
                {
                    title: l('Year'),
                    data: "year"
                },
                {
                    title: l('Link'),
                    data: "link",
                    render: function (data) {
                        return '<a href="' + data + '" target="_blank">Link</a>';
                    }
                },
            ]
        })
    );
});
  • Run the project and see how it's working!

autofilterer-preview-with-abp

Filtering Specific Properties

AutoFilterer supports some different features like Filtering with Range. Let's filter TotalPage and Year properties with range.

  • Add following TotalPage and Year properties to BookGetListInput.
public class BookGetListInput : FilterBase, IPagedAndSortedResultRequest
{
    // Configure 'Filter' property for built-in search boxes.
    [CompareTo(
        nameof(BookDto.Title),
        nameof(BookDto.Language),
        nameof(BookDto.Author),
        nameof(BookDto.Country)
        )]
    [StringFilterOptions(StringFilterOption.Contains)]
    public string Filter { get; set; }

    public Range<int> TotalPage { get; set; } // <-- Add this one

    public Range<int> Year { get; set; } // <-- and this

    // IPagedAndSortedResultRequest implementation below.
    public int SkipCount { get; set; }

    public int MaxResultCount { get; set; }

    public string Sorting { get; set; }
}
  • Update Index.cshtml too
@page
@using Acme.BookStore.Localization
@using Acme.BookStore.Web.Pages.Books
@using Microsoft.Extensions.Localization

@model IndexModel

@inject IStringLocalizer<BookStoreResource> L

<h2>Books</h2>

@section scripts
{
	<abp-script src="/Pages/Books/index.js" />
}

<abp-card>
	<abp-card-header>
		<h2>@L["Books"]</h2>
	</abp-card-header>
	<abp-card-body>
		<div id="books-filter-wrapper">
			<div class="row">

				<div class="col-6">
					<label class="form-label"> TotalPage </label>
					<div class="row">
						<div class="col-6">
							<label class="form-label">Min</label>
							<input id="TotalPageMin" type="number" class="form-control" />
						</div>
						<div class="col-6">
							<label class="form-label">Max</label>
							<input id="TotalPageMax" type="number" class="form-control" />
						</div>
					</div>
				</div>

				<div class="col-6">
					<label class="form-label"> Year </label>
					<div class="row">
						<div class="col-6">
							<label class="form-label">Min</label>
							<input id="YearMin" type="number" class="form-control" />
						</div>
						<div class="col-6">
							<label class="form-label">Max</label>
							<input id="YearMax" type="number" class="form-control" />
						</div>
					</div>
				</div>

			</div>
		</div>
		<div class="mt-2">
			<abp-table striped-rows="true" id="BooksTable"></abp-table>
		</div>
	</abp-card-body>
</abp-card>
  • Update index.js file to send those parameters to API
$(function () {
    var l = abp.localization.getResource('BookStore');

    var getFilter = function () {
        return {
            totalPage: {
                min: $('#TotalPageMin').val(),
                max: $('#TotalPageMax').val()
            },
            year: {
                min: $('#YearMin').val(),
                max: $('#YearMax').val()
            }
        };
    };

    $("#books-filter-wrapper :input").on('input', function () {
        dataTable.ajax.reload();
    });

    var dataTable = $('#BooksTable').DataTable(
        abp.libs.datatables.normalizeConfiguration({
            serverSide: true,
            paging: true,
            order: [[1, "asc"]],
            searching: true,
            scrollX: true,
            ajax: abp.libs.datatables.createAjax(acme.bookStore.books.book.getList, getFilter),
            columnDefs: [
                {
                    title: l('Title'),
                    data: "title"
                },
                {
                    title: l('Language'),
                    data: "language",
                },
                {
                    title: l('Country'),
                    data: "country",
                },
                {
                    title: l('Author'),
                    data: "author"
                },
                {
                    title: l('TotalPage'),
                    data: "totalPage",
                    render: function (data) {
                        return data + ' pages'
                    }
                },
                {
                    title: l('Year'),
                    data: "year"
                },
                {
                    title: l('Link'),
                    data: "link",
                    render: function (data) {
                        return '<a href="' + data + '" target="_blank">Link</a>';
                    }
                },
            ]
        })
    );
});
  • Run the Application and see the result

autofilterer-with-abp-range-filter


Source-Code

You can find final version of this example on github.


Discussion

There is a couple of questions that you can think about. In this "Discussion" section I'll try to answer them.

Is Defining Comparison in Dto ok?

As a first impression, I tough, it's not ok because Application.Contracts can be shipped to clients. It's true, definition of filter is in DTO, but the implementation is in Application layer. So if a developer who implements client-side, can see something like below.

public class BookGetListInput : FilterBase, IPagedAndSortedResultRequest
{
    // Configure 'Filter' property for built-in search boxes.
    [CompareTo(
        nameof(BookDto.Title),
        nameof(BookDto.Language),
        nameof(BookDto.Author),
        nameof(BookDto.Country)
        )]
    [StringFilterOptions(StringFilterOption.Contains)]
    public string Filter { get; set; }
}

I think that's ok, because the developer'll understand what filtering does. It's kind of documentation with attributes.

Why didn't use Sorting & Pagination feature of AutoFilterer

ABP does those features well and UI frameworks(Razor Pages, Angular and Blazor) already implemented sorting and pagination with those existin parameters. Chaning cost is high because if you change, you'll need to implement them for each UI framework.

AutoFilterer
Engincan Veske 91 weeks पहले

Great library, thanks for sharing!

Adrian Inman 52 weeks पहले

This is really useful. The example helps a lot. What is missing though is an example like the demo using Books and Authors where there is a join... when I integrated this approach with my project which has related entities, the filter doesn't work on the linked objects even though I have referenced them on the Dto as presumably those fields are empty when the filtering runs... is there a mechanism to populate them first? I've plumbed it in as where I run the join query from however I need to work out a way to run it after I have already joined... help most appreciated.

Enis Necipoğlu 52 weeks पहले

Hello @Adrian This isn't a topic related to ABP Framework but the library supports nested objects filtering, you should place other object's filter into main object filter as a property and it's filtering fields will be accessible with dots like books?title=some&author.name=john

See Nested-Relational-Queries You also check out the example from original repository to see how to filter joined entities: https://github.com/enisn/AutoFilterer/blob/2bc72d279071a144815599d14e6dfbf63899c122/sandbox/WebApplication.API/Controllers/NorthwindController.cs#L38-L40

Adrian Inman 52 weeks पहले

Thanks... I appreciate what you're saying, however, I'm using this with ABP and this is the ABP forum. The tool is really useful and interesting, but I can't see how I can use it with the structures ABP wants you to use... under DDD there is no nesting in the objects... just Guids used to point to others which are loaded at a later stage... your example in your last message is based on Blog and Comment Entities where the Blog entity contains a list of Comments... this is not how ABP encourages you to structure things though.. .as such I would have Blog and then Comments with a foreign key of BlogId.. using the examples I'm using linq to get my data, but his has to run after using your filter as if I apply your filter after my query, no filtering takes place... :/

` public override async Task<PagedResultDto

    var thequery = from product in queryable
                join productgroup in await _productGroupRepository.GetQueryableAsync() on product.ProductGroupId equals productgroup.Id
                join productimage in await _productImageRepository.GetQueryableAsync() on product.HeroProductImageId equals productimage.Id into phi
                from subproductimage in phi.DefaultIfEmpty()
                where product.FreeStockQuantity > 0
                select new { product, productgroup, subproductimage };

`

Enis Necipoğlu 52 weeks पहले

It's possible but I'm not sure if it'll work with anonymous type. If you use a class instead anonymous type, it probably work. Like ``select new MyClass { Product = product, ProductGrup = productGroup, SubProductImage = subproductimage`}

then you can apply your filter to this query according to MyClass properties

You can ask more detailed questions on ABP Discord Server

Enis Necipoğlu 52 weeks पहले

It's possible but I'm not sure if it'll work with anonymous type. If you use a class instead anonymous type, it probably work. Like

select new MyClass { Product = product, ProductGrup = productGroup, SubProductImage = subproductimage }

then you can apply your filter to this query according to MyClass properties

You can ask more detailed questions on ABP Discord Server

Enis Necipoğlu से अधिक