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.
- Add this initial-books.json file to
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!
Filtering Specific Properties
AutoFilterer supports some different features like Filtering with Range. Let's filter TotalPage and Year properties with range.
- Add following
TotalPage
andYear
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
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.
Comments
Engincan Veske 116 weeks ago
Great library, thanks for sharing!
Adrian Inman 77 weeks ago
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 77 weeks ago
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 77 weeks ago
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
`
Enis Necipoğlu 77 weeks ago
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 77 weeks ago
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
then you can apply your filter to this query according to MyClass properties
You can ask more detailed questions on ABP Discord Server