Note: see the link below for the English version of this article.

https://duongnt.com/expression-tree-linq

Expression Tree cho IQueryable<T> trong LINQ

LINQ cho phép ta thực hiện query để đọc dữ liệu thông qua interface IQueryable<T>. Chúng ta truyền vào một Expression Tree, rồi provider sẽ chuyển Expression Tree đó thành query tương ứng với data source. Thông thường, ta dùng lambda expression để tạo Expression Tree. Nhưng khi cần thiết, ta cũng có thể tự mình tạo Expression Tree.

Các bạn có thể download code ví dụ từ đường link dưới đây.

https://github.com/duongntbk/ExpressionTreeDemo

Dữ liệu để test

Các lớp DTO

Trong code production, data source của ta sẽ là database. Nhưng trong bài này ta đọc dữ liệu từ memory. Dưới đây là các lớp DTO.

public class Person
{
    public string Name { get; set; }
    public int Age { get; set; }
    public DateTime Dob { get; set; }
    public override string ToString()
    {
        return $"Name: {Name}, Age: {Age}, Dob: {Dob}";
    }
}

public class Document
{
    public string Title { get; set; }
    public DateTime IssuedBy { get; set; }
    public override string ToString()
    {
        return $"Title: {Title}, IssuedBy: {IssuedBy}";
    }
}

public class Recipe
{
    public string Name { get; set; }

    public IList<string> Ingredients { get; set; } = new List<string>();

    public override string ToString()
    {
        return $"{Name}: {{ {string.Join(", ", Ingredients)} }}";
    }
}

Dữ liệu test

Ta tạo một số dữ liệu như dưới đây.

{ // People
    { "Name": "John Doe", Age: 42, Dob: "1980-01-01 00:00:00" },
    { "Name": "Jane Doe", Age: 41, Dob: "1981-01-01 00:00:00" },
    { "Name": "Baby Doe", Age: 12, Dob: "2010-01-01 00:00:00" }
},
{ // Documents
    { "Title": "Birth Certificate", IssuedBy: "1980-01-01 00:00:00" },
    { "Title": "College Degree", IssuedBy: "2003-08-01 00:00:00" },
    { "Title": "Marriage Certificate", IssuedBy: "2008-01-01 00:00:00" }
}
{ // Recipes
    { Fried Rice: { eggs, rice, oil, vegetables } },
    { Omelette: { eggs, butter, oil } },
    { Pho: { pho, chicken, spice } },
    { sandwich: { bread", ham, vegetables } }
}

Tạo query với lambda expression

Khác biệt giữa LINQ cho IEnumerable<T> và cho IQueryable<T>

Thoạt nhìn qua, LINQ predicate để đọc dữ liệu từ IEnumerable<T> và từ IQueryable<T> trông giống hệt nhau. Nhưng chúng có một điểm khác biệt căn bản.

  • Với IEnumerable<T>, ta dùng delegate làm predicate.
  • Với IQueryable<T>, ta dùng Expression Tree làm predicate.

Nếu ta sử dụng delegate với IQueryable<T> thì runtime sẽ chuyển nó thành Expression Tree.

Và ta cũng có thể chuyển IEnumerable<T> thành IQueryable<T>.

var peopleList = <code để tạo List<Person>>;
var people = peopleList.AsQueryable();
var documentsList = <code để tạo List<Document>>;
var documents = documentsList.AsQueryable();

Tạo Expression Tree với lambda expression

Dưới đây là cách dùng lambda expression để đọc tất cả tên và tiêu đề từ collection.

var names = people.Select(p => p.Name);
var titles = documents.Select(p => p.Title);

Còn dưới đây là cách lấy thông tin tất cả mọi người sinh sau ngày 1980/12/31, và tất cả tài liệu phát hành sau ngày 2000/01/01.

var filteredPeople = people.Where(p.Dob > new DateTime(1980, 12, 31));
var filteredDocuments = documents.Where(p.IssuedBy > new DateTime(2000, 1, 1));

Có thể thấy rằng ta phải hard-code tên của attribute trong lambda expression. Việc tạo một hàm chung cho cả 2 collection ở trên là tương đối khó. Dưới đây là một số trường hợp mà hàm chung là có ích.

  • Một hàm chung nhận vào tên attribute làm tham số, rồi lấy tất cả giá trị của attribute đó.
  • Một hàm chung để lọc dữ liệu dựa trên thời điểm khởi tạo (Dob hoặc IssuedBy) mà không cần hard-code tên attribute.
  • .etc

Tự tạo Expression Tree

Predicate cho IQueryable<T> có dạng Expression<Func<TSource, TReturn>>. Ta có thể chia LINQ predicate làm 2 kiểu chính.

  • Expression<Func<TSource, TResult>>: được dùng trong các hàm đọc dữ liệu. Ví dụ: Select/SelectMany/Max/....
  • Expression<Func<TSource, bool>>: được dùng trong các hàm lọc dữ liệu. Ví dụ: First/Where/Single/....

Expression Tree để đọc dữ liệu

Ta sẽ bắt đầu từ trường hợp đơn giản nhất: đọc dữ liệu của một attribute trong collection. Code của ta sẽ tương ứng với lambda expression dưới đây, nhưng ta không cần hard-code tên attribute.

var values = collection.Select(c => c.<AttributeName>);
// với collection people: var names = people.Select(p => p.Name);

Các bạn có thể tham khảo code hoàn chỉnh tại đây.

private static IQueryable<TColumn> GetField<TSource, TColumn>(IQueryable<TSource> collection, string columnName)
{
    var collectionTypeExpr = Expression.Parameter(typeof(TSource)); // ParameterExpression
    var columnPropertyExpr = Expression.Property(collectionTypeExpr, columnName); // MemberExpression
    var predicate = Expression.Lambda<Func<TSource, TColumn>>(columnPropertyExpr, collectionTypeExpr);

    return collection.Select(predicate);
}

Collection của ta cần có dạng IQueryable<TSource>. Còn tên attribute được truyền dưới dạng string. Để ý là kiểu dữ liệu trả về cũng là generic. Sau đó ta tạo 2 expression tương ứng với collection và với attribute mà ta muốn đọc. Cuối cùng, ta dùng hàm Expression.Lambda để tạo Expression Tree. Lúc này, predicate vừa tạo có thể được dùng để đọc giá trị của attribute từ collection.

Như đã nói ở phần trước, predicate của ta có kiểu là Expression<Func<TSource, TColumn>>.

Dưới đây là kết quả test.

var names = GetField<Person, string>(_people, nameof(Person.Name));

// John Doe
// Jane Doe
// Baby Doe

Expression Tree để lọc dữ liệu

Trong ví dụ tiếp theo, ta tạo một hàm chung để lọc dữ liệu dựa trên ngày khởi tạo. Với Person, ta sử dụng Dob. Còn với Document, ta dùng IssuedBy. Hàm của ta sẽ tương ứng với code dưới đây.

var values = collection.Where(c => c.<AttributeName> > lowerBound && c.<AttributeName> < upperBound);
// Với collection people: var filterData = people.Where(p => p.Dob > lowerBound && p.IssuedBy < upperBound);

Các bạn có thể tham khảo code hoàn chỉnh tại đây. Dưới đây là một số dòng đáng chú ý.

var olderThanExpr = Expression.GreaterThan(timePropertyExpr, Expression.Constant(lower));

BinaryExpression này chính là cận dưới của thời điểm khởi tạo. Vì thế ta dùng hàm GreaterThan.

var newerThanExpr = Expression.LessThan(timePropertyExpr, Expression.Constant(upper));

BinaryExpression này chính là cận trên của thời điểm khởi tạo. Vì thế ta dùng hàm LessThan.

var timeRangeExpr = Expression.And(newerThanExpr, olderThanExpr);

Sau khi tạo 2 expression trên, ta có thể tạo được khoảng thời gian để lọc dữ liệu. Khoảng thời gian này cũng là một BinaryExpression. Ta chỉ cần kết hợp cận trên và cận dưới vừa tạo.

var predicate = Expression.Lambda<Func<TSource, bool>>(timeRangeExpr, parameterExpr);

Như đã nói ở phần trước, predicate của ta có kiểu là Expression<Func<TSource, bool>>.

Dưới đây là kết quả test.

var peopleInRange = GetInRange(
    _people, nameof(Person.Dob), new DateTime(1980, 12, 31), new DateTime(1995, 1, 1));

// Name: Jane Doe, Age: 41, Dob: 1981/01/01 0:00:00

Cách gọi hàm instance trong Expression Tree

Ở ví dụ trước, ta chỉ so sánh giá trị các attribute với một hằng số. Nhưng đôi khi ta muốn gọi hàm của attribute để lọc dữ liệu. Ví dụ: ta muốn tìm tất cả những người tên bắt đầu bằng Jo như trong đoạn code dưới.

var peopleStartsWithJo = people.Where(p => p.Name.StartsWith("Jo"));

Đây là code hoàn chỉnh. Ta sẽ xem từng dòng.

var methodInfo = typeof(string).GetMethods()
    .Single(m => m.Name == nameof(string.StartsWith) &&
        m.GetParameters().Length == 1 &&
        m.GetParameters().Single().ParameterType == typeof(string));

Để gọi hàm trong Expression Tree, ta cần lấy được method info của hàm đó. Vì hàm string.StartsWith có nhiều overload nên ta phải tìm đúng hàm có một parameter với kiểu string. Ta có thể cache giá trị method info này để cải thiện hiệu năng của code.

var startsWithExpr = Expression.Call(columnProperty, methodInfo, Expression.Constant(prefix));

Ta dùng hàm Expression.Call để tạo một MethodCallExpression. Hàm Call cũng có nhiều overload, nhưng ta dùng overload để gọi hàm instance.

Kết quả thu được là đúng như ta mong muốn.

var peopleStartWithJo = GetTextFieldStartsWith(_people, nameof(Person.Name), "Jo");

// Name: John Doe, Age: 42, Dob: 1980/01/01 0:00:00

Cách gọi hàm generic static trong Expression Tree

Ở ví dụ cuối, ta sẽ gọi hàm generic static trong Expression Tree. Code của ta sẽ tương ứng với đoạn code lọc công thức nấu ăn dựa trên nguyên liệu ở dưới.

var recipesWithEggs = recipes.Where(r => r.Ingredients.Contains("eggs"));

Xin tham khảo code hoàn chỉnh tại đây. Ta đã biết cách gọi hàm trong Expression Tree. Và dưới đây là một số khác biệt khi gọi hàm generic static thay vì hàm instance.

var methodInfo = typeof(Enumerable).GetMethods()
    .Single(m => m.Name == nameof(Enumerable.Contains) && m.GetParameters().Length == 2);
var containsMethod = methodInfo.MakeGenericMethod(typeof(TField));

Code để tìm overload thích hợp là gần tương tự phần trước, nhưng ta còn cần phải cung cấp kiểu dữ liệu cho hàm generic. Method info này cũng có thể được cache khi cần.

var containsExpr = Expression.Call(containsMethod, columnProperty, Expression.Constant(value));

Mặc dù ta vẫn dùng hàm Expression.Call, lần này ta sẽ dùng overload có hỗ trợ hàm static.

Code của ta có thể lọc được công thức nấu ăn dựa theo nguyên liệu một cách chính xác.

var recipeWithEggs = GetWithFieldContainValue(_recipes, nameof(Recipe.Ingredients), "eggs");

// Fried Rice: { eggs, rice, oil, vegetables }
// Omelette: { eggs, butter, oil }

Kết thúc

Đôi khi trong lúc sử dụng LINQ, tôi phải viết nhiều lambda expression với cùng logic mà chỉ khác tên attribute. Lúc đó tôi sẽ có 2 lựa chọn.

  • Sử dụng thư viện Dynamic LINQ.
  • Tạo một Expression Tree có thể xử lý được tất cả các data source và qua đó loại bỏ trùng lặp code.
A software developer from Vietnam and is currently living in Japan.

One Thought on “Expression Tree cho IQueryable trong LINQ”

Leave a Reply