C# Expression Tree Ve Dinamik Filtreleme Örneği
Expression Tree nedir? Nasıl kullanabiliriz? Bu konuya giriş yapıp dinamik sorgu kodu üretimi örneğiyle inceleyeceğiz.
Expression Tree konusuna girmeden önce bazı temel kavramların üzerinden geçmemiz faydalı olacak. Yazdığımız kodların yapı taşları olan bu kavramlar expression, statement ve block olarak sıralanabilir.
Expression, bir değer döndüren kod parçasıdır. Değişkenler, değişmezler, fonksiyon çağrıları ve operatörlerden veya bunların kombinasyonundan oluşabilir. Bir expression başka bir expression’ı içerebilir birazdan değineceğiz.
Statement, uygulamanın çalıştırılması için gereken temel kod birimleridir. Örneğin C# için “;” arasındaki ifadeler birer statement örneğidir veya if/else, for/do/while, break/continue, throw/try/catch gibi yapılar statement örnekleridir.
Block ise “{}” arasındaki bölümlerdir. İçerisinde hiç statement olmayabilir veya birden çok statement içerebilir.
Asıl konumuz olan Expression Tree ile ilgili bilgiler vermeden önce Expression konusunu daha derinden incelemeliyiz. Expression ve örnekleriyle devam edelim.
Expression
.Net expression yapılarının karşılığı olarak System.Linq.Expressions namespace altında Expression base class bulunmaktadır. Bu kütüphaneyi kullanarak expression oluşturabilir veya mevcut bir expression’ı inceleyebiliriz. Bu class içerisinde NodeType ve Type property’leri bulunmaktadır.
NodeType, kullanılan expression’ın hangi tipte olduğunu verir. Expression kütüphanesi oldukça geniş bir tipte expression oluşturmaya imkan tanımakta. Oluşturulan expression’ın tipine ulaşmak için bu property kullanılır. Neler var denilirse örnek olarak BinaryExpression, ConstantExpression, ConditionalExpression, LambdaExpression ve daha bir çoğu sayılabilir. Bu özel tipler kullanılarak amaca yönelik özelleştirilmiş expression’lar oluşturulabilir.
Type, expression’ın taşıdığı tipi döndürür. Örneğin expression içinde int tipinde bir değer varsa Type dönüşü int olacaktır. Aşağıdaki örneğimizle bir expression oluşturarak özelliklerini inceleyeceğiz.
Örnek : İki sayıyı toplayalım.
int a = 1 + 2;
1 + 2 işleminde 1 ve 2 değerleri sabit değer expression, toplama işlemi ise ikili işlem(binary) yapan bir expression olarak tanımlanır.
ConstantExpression exp1 = Expression.Constant(1, typeof(int));
ConstantExpression exp2 = Expression.Constant(2, typeof(int));
BinaryExpression expA = Expression.Add(exp1, exp2);
Yukarıdaki üç satırlık kodda 1 ve 2 değerlerini taşıyan exp1 ve exp2 expressionlarını aldık, toplama işlemi(Expression.Add) uyguladık ve bu işlemin sonucunda BinaryExpression tipinde expA ismini verdiğimiz expression’ımızı ürettik.
BinaryExpression’lar parametre olarak iki expression alır(left, right) ve bunlara uygulanacak işlemin operatör bilgisini(NodeType) tutar. Aritmetik işlemler (toplama, çarpma, bölme, üs alma vs.), kıyaslama işlemleri(eşit, eşit değil, büyük, küçük), bit seviyesi işlemleri(and, or, exor) gibi işlemler gerçekleştirilebilir.
Örneğin yukarıdaki kodlamadaki expA.NodeType değerine bakıldığında Add görülecektir. Expression.Add yerine Expression.Multipy olsaydi yani toplama yerine çarpma yapsaydık NodeType değeri Multiply olarak gelecekti.
Örnek : Diğer örnekle devam edelim, “merhaba” kelimesini büyük harfe çevireceğiz(string.ToUpper).
MethodCallExpression toUpperExp = Expression.Call(
Expression.Constant("merhaba"),
typeof(String).GetMethod(nameof(String.ToUpper), new Type[] { }));
MethodCallExpression kullandık, bunun nedeni String sınıfı içindeki ToUpper metodunu çağırmaktır. Expression içerisinden başka bir sınıftaki veya nesnedeki metodların çağrılması ihtiyacı olduğunda MethodCallExpression kullanılmaktadır.
Şimdi “merhaba” string ifadesinin uzunluğunu nasıl alabiliriz onu görelim. Normalde kodlarken String.Length kullanarak alabiliyoruz, expression kullandığımızda da String.Length property’sini çağıracağız. Fakat bu bir method değil bir property. MethodCall ile yapamayız. Buna çözüm olarak Property ismiyle çağıracağımız factory metodumuz bize MemberExpression tipinde yeni bir expression üretecek.
MemberExpression lenExp = Expression.Property(
Expression.Constant("merhaba"),
typeof(String).GetProperty(nameof(String.Length)));
Örnek : Console.WriteLine(“merhaba”) işlemini Expression api ile yapalım.
ConstantExpression constExp = Expression.Constant("merhaba", typeof(string));
MethodCallExpression methodCallExp = Expression.Call(
typeof(Console).GetMethod("WriteLine", new Type[] { typeof(string) }),
constExp);
//methodCallExp System.Console.WriteLine("merhaba") üretir
MethodCallExpression içinde Console class altındaki WriteLine metodunu constExp ile hazırladığımız parametreyi vererek çağırdık.
Bu örnekler expression konusunda bir miktar fikir vermiştir. Expression Tree ile devam edeceğiz.
Expression Tree
Buraya kadar expression yapılarını ve Api ile expression üretme yöntemini gördük. Aslında iç içe expression üretirken expression tree konusuna giriş yaptık. Expression’ların birbiri ile bağlantıları expression tree ile sağlanır. Expression tree içindeki node’lar yani expression’lar ağacın en alt dalından başlanarak hesaplanır ve en üst dalında(root) işlem sona erer. Örnekler üzerinden daha net görelim.
Örnek : 3 > 2 işlemini yapan kod parçamız olsun.
bool res = 3 > 2;
Bunun expresion Api kodlaması:
ConstantExpression exp1 = Expression.Constant(3);
ConstantExpression exp2 = Expression.Constant(2);
BinaryExpression expRes = Expression.GreaterThan(exp1, exp2);
Expression tree yapısı:
Örnek : Şimdi ağacı biraz daha büyütüp (5 > 4) && (3 > 2) ifadesi için yapalım.
bool res = (5 > 4) && (3 > 2);
Expression Api kodlaması:
ConstantExpression exp1 = Expression.Constant(5);
ConstantExpression exp2 = Expression.Constant(4);
BinaryExpression exp12 = Expression.GreaterThan(exp1, exp2);
ConstantExpression exp3 = Expression.Constant(3);
ConstantExpression exp4 = Expression.Constant(2);
BinaryExpression exp34 = Expression.GreaterThan(exp3, exp4);
BinaryExpression exp1234 = Expression.AndAlso(exp12, exp34);
Expression tree yapısı:
Buraya kadar olan kısımda expression tanımlama konusunu gördük fakat parametre geçmek gerektiğinde nasıl yapacağız? Bu expression’ları nasıl çalıştırıyoruz? Şimdi bu soruların cevaplarıyla devam edelim.
Bir expression’a dışarıdan parametre göndermek isteyebiliriz. Bunun için ParameterExpression sınıfını kullanacağız. Örneğin aşağıdaki gibi bir lambda expression kodlamış olalım. Lambda expression konusuna bu yazıda girmeyeceğim, bildiğinizi varsayarak devam edeceğim.
Func<int, bool> func1 = (i) => i > 2;
//Expression karşılığı
ParameterExpression expParam = Expression.Parameter(typeof(int), "i");
ConstantExpression expConst = Expression.Constant(2);
BinaryExpression expRes = Expression.GreaterThan(expParam, expConst);
ParameterExpression oluştururken parametrenin taşıdığı tipi ve opsiyonel olarak parametre adını veriyoruz. Bu şekilde oluşturduğumuz expression nesnesine dışarıdan parametre geçebiliyoruz. Örneğimizde func1 lambda expression’ı bulunmakta ve girdi olarak int tipinde parametre alırken çıkı olarak bool tipinde dönüş yapıyor.
Lambda expression’ın sağ tarafındaki ifadeyi üreterek başladık ve int tipinde parameter expression oluşturduk buna “i” ismini verdik, 2 değeriyle ConstantExpression oluşturduk ve bunları büyüktür işlemini yapacak BinaryExpression içine ekledik.
Oluşturduğumuz expression’ın çalıştırılması nasıl yapılıyor? Öncelikle func1 ismiyle yazdığımız lambda expression bildiğiniz üzere doğrudan çalıştırılabiliyor.
Func<int, bool> func1 = (i) => i > 2;
//func1 lambda expression çağırımı
Console.WriteLine(func1(3)); // true
Console.WriteLine(func1(1)); // false
Expression için de durum farklı değil. Üretilen expression nesnesini çalıştırılabilir yapabilmek için öncelikle Lambda expression’a çevirmemiz gerekiyor. Bunu aşağıdaki gibi yapıyoruz.
Expression<Func<int, bool>> lambdaExp = Expression.Lambda<Func<int, bool>>(expRes, expParam);
Func<int, bool> func1FromExp = lambdaExp.Compile();
Console.WriteLine(func1FromExp(3)); // true
Console.WriteLine(func1FromExp(1)); // false
Örneğimizde ürettiğimiz expRes’i önce Lambda Expression’a çeviriyoruz.
Expression<Func<int, bool>> lambdaExp =
Expression.Lambda<Func<int, bool>>(expRes, expParam);
Expression.Lambda metoduyla int girdi alan ve bool dönen bir fonksiyon oluşturacağımızı bildiriyoruz. Parametre olarak alınacak expParam bilgisini de veriyoruz. Yani lambda expression oluştururken expression body ve parametre bilgisini kullandık. Bunun sonucunda elimizde lambdaExp isminde bir nesnemiz var bunu çalıştırmak için derlememiz gerekiyor. Bunun için ise Compile metodunu kullanarak derleme yapıyoruz ve çalıştırılabilir fonksiyonumuz hazır oluyor.
Func<int, bool> func1FromExp = lambdaExp.Compile();
Console.WriteLine(func1FromExp(3)); // true
Console.WriteLine(func1FromExp(1)); // false
Özetle, oluşturduğumuz expression’ları çalıştırılabilir yapabilmek için Lambda Expression’a çevirmemiz ve derlememiz gerekli bunun için de Expression kütüphanesinin Expression.Lambda metodunu ve sonrasında derleme için Expression.Compile metodunu kullanıyoruz.
Microsoft dokümanlarındaki açıklama:
Only expression trees that represent lambda expressions can be executed. Expression trees that represent lambda expressions are of type LambdaExpression or Expression<TDelegate>. To execute these expression trees, call the Compile method to create an executable delegate, and then invoke the delegate.
Yukarıdaki örnekte bir expression ürettik ve bunu lambda expression’a çevirip çalıştırdık. Tersi de geçerlidir. Lambda expression’dan expression tree oluşturmak mümkündür. Örnek:
Expression<Func<int, bool>> expFunc1 = (i) => i > 2;
Derleyici Expression<TDelegate> ifadesi ile başlayan lambda expression’ları expression tree olarak işleyecektir. Böylece üretilen expression içerisi görülebilir veya bu kullanılarak yeni expression tree’ler üretilebilir.
Expression<Func<int, bool>> expTree = i => i > 2;
Func<int, bool> func1 = expTree.Compile();
func1(3); // true
// expTree içeriği incelenebilir veya bunlar kullanılarak
// yeni bir expression tree üretilebilir ve bununla değiştirilebilir.
ParameterExpression param = (ParameterExpression)expTree.Parameters[0];
BinaryExpression operation = (BinaryExpression)expTree.Body;
ParameterExpression left = (ParameterExpression)operation.Left;
ConstantExpression right = (ConstantExpression)operation.Right;
Expression tree node’ları değiştirilemezdir, eğer değiştirmek gerekiyorsa yeni expression node oluşturulur. Expression tree düzenleme işlemleri ExpressionVisitor class yardımıyla yapılır. Dinamik filtreleme konusuna artık geçebiliriz. Oradaki örnek kodlarda kullanım alanları hakkında daha net bilgi sahibi olabileceğiz.
Dinamik Filtre Üretimi
Veritabanı sorgulamalarında LINQ kullanıyorsanız mutlaka expression tree yapılarını kullanmak durumunda kaldınız demektir. Örneğin IQueryable interface’ini miras alan bir nesnenin Where sorgusunda expression kullanmışsınızdır. Bu noktada IEnumarable ve IQueryable farkını da incelemenizi tavsiye ederim. Kısaca değinmek gerekirse, IEnumarable kullanıldığında Entity Framework(EF) tarafından veritabanından tüm kayıtlar çekilir ve bunlar üzerinde sorgu çalıştırılır yani filtreleme işlemini kod üstlenir. IQueryable kullanıldığında ise filtre sorgusu veritabanına gönderilir ve sonucu çekilir. Bu iki yöntemin de kullanım alanları vardır. Expression tree yapılarının temel kullanımı bu noktada olmaktadır. Yazılan LINQ kodları epxression tree’ye çevrilir.
//Aşağıdaki gibi bir sorgu ifademiz olsun.
Expression<Func<int, bool>> func1 = i => i > 2;
//Derleyici bu ifadeyi okur ve aşağıdaki gibi bir expression tree üretir
ParameterExpression exp1 = Expression.Parameter(typeof(int), "i");
ConstantExpression exp2 = Expression.Constant(2);
BinaryExpression expRes = Expression.GreaterThan(exp1, exp2);
Expression<Func<int, bool>> expLambda = Expression.Lambda<Func<int, bool>>(expRes, exp1);
Bu sorgu nasıl işlenir? EF, gelen sorguyu database provider sınıfına gönderir ve database provider bu sorguyu alarak veritabanına gönderilecek örneğin SQL sorgusuna(SELECT Id,Name FROM Users..) çevirir. Bu işleme query translation denir. Translation işlemine başlanmadan araya girmek istenirse IQueryExpressionInterceptor gibi bir interceptor tanımı yapabilirsiniz veya IQueryTranslationPreprocessorFactory ‘den türeyen bir sınıf yazarak doğrudan müdahale edebilirsiniz. Yukarıdaki kısım burada not olarak bulunsun. Expression tree’nin işlenmesi ve sonra veritabanının anlayacağı sorgulara çevrilme kısmına şu aşamada ihtiyacımız olmayacak. Bize lazım olan bilgi: Entity Framework, LINQ sorgusunu expression tree’ye dönüştürür.
Dolayısıyla çalışma zamanında expression tree üretebiliyorsak dinamik sorgulama kapısını açabiliyoruz demektir. Temel çalışma mekanizması hakkında bazı bilgiler edindik şimdi bu bilgileri kullanarak dinamik filtreleme yöntemine geçebiliriz. Örneğimizin tanımını yapalım ve veri yapısını kodlayalım.
Örnek : Örneğimizde araç bilgilerini tutacak Car isminde bir sınıfımız var Id, Year, Make alanlarını içeriyor. Veritabanı ile ilgili tanım kodları kalabalık yapmasın diye sanki veritabanı varmış gibi bir class yapacağız adı DemoDb olacak. DemoDb içinde tüm araçları içeren _cars listemiz olacak. Bu listeyi sorgulayabilmek için Cars metoduyla IQueryable erişimini döndüreceğiz. Kısacası DemoDb veritabanında Cars isminde bir tablo olduğunu düşünelim.
public class Car
{
public int Id { get; set; }
public int Year { get; set; }
public string Make { get; set; }
public Car(int id, int year, string make)
{
Id = id;
Year = year;
Make = make;
}
public override string ToString()
{
return Id + " " + Year + " " + Make;
}
}
public class DemoDb
{
List<Car> _cars = new List<Car>();
public DemoDb()
{
//seed
_cars.Add(new Car(1, 2023, "Volvo"));
_cars.Add(new Car(2, 2022, "Mercedes"));
_cars.Add(new Car(3, 2021, "Tesla"));
_cars.Add(new Car(4, 2023, "Toyota"));
_cars.Add(new Car(5, 2022, "Audi"));
}
public IQueryable<Car> Cars()
{
return _cars.AsQueryable();
}
}
Cars tablosuna sorgu atalım, örneğin 2023 yılına ait araçları çekelim.
var cars2023 = context.Cars().Where(x => x.Year == 2023).ToList();
cars2023.ForEach(x => Console.WriteLine(x.ToString()));
// Sonuçlar:
// 1 2023 Volvo
// 4 2023 Toyota
Bunu daha taşınabilir yapabiliriz. Örneğin sorgu expression parametresini Where içinde değil dışarıda hazırlayıp parametre olarak geçelim.
// Expression'ı önceden üretip kullanabiliriz
Expression<Func<Car, bool>> predicateYear2023 = car => car.Year == 2023;
cars2023 = context.Cars().Where(predicateYear2023).ToList();
cars2023.ForEach(x => Console.WriteLine(x.ToString()));
// Sonuçlar:
// 1 2023 Volvo
// 4 2023 Toyota
Taşınabilir olmasına çalıştık ama Year kolon adını koda gömmek gerekti. Bundan nasıl kurtulabiliriz? Dışarıdan kolon isimleri ve değerler gelsin biz içeride dinamik olarak Where içerisinde kullanacağımız expression’ı üretelim. Şöyle yapabiliriz:
Expression<Func<Car, bool>> predicateDynamic = ExpressionGenerator("Year", "==", 2023);
cars2023 = context.Cars().Where(predicateDynamic).ToList();
cars2023.ForEach(x => Console.WriteLine(x.ToString()));
Bir tane ExpressionGenerator yazalım ve bu tablomuzdaki kolon ve değerlerden bağımsız olarak expression üretsin böylece sorgulama katmanında kolon bağımlılığından kurtulmuş olur ve daha az kodla daha çok iş çıkarabiliriz.
Expression<Func<Car, bool>> ExpressionGenerator(string col, string opr, object val)
{
}
ExpressionGenerator fonksiyonu col, opr, val parametrelerini yani kolon adı, karşılaştırma operatörü ve değer parametrelerini alarak expression tree üretecek. Böylece amacımız olan Where sorgusundaki LINQ ifadesini dinamik olarak üretebileceğiz. Aşağıda ExpressionGenerator’ın çalışan kodunu inceleyebilirsiniz. Kısa ve anlaşılabilir olması nedeniyle içinde herhangi bir kontrol vs. rutinler yok. Sadece örnek için yeterli olacak şekilde yazdım.
Expression<Func<Car, bool>> ExpressionGenerator(string col, string opr, object val)
{
//x => x.Year == 2023, buradaki x parametre olarak geliyor, parameter expression olarak tanımlıyoruz.
ParameterExpression paramExp = Expression.Parameter(typeof(Car), "x");
//Year,Make alanlarının tipini alan adından alıyoruz.
Type propType = typeof(Car).GetProperty(col).PropertyType;
//x.Year diyebilmek için x'i tanımladık bu da alanın expression tanımı.
MemberExpression propExp = Expression.Property(paramExp, col);
//x.Year == 2023 sorgusundaki 2023 değeri yani filtre değeri(val).
ConstantExpression valExp = Expression.Constant(val, propType);
Expression expRes;
//Burada gelen operatör değerine göre farklı expression'lar üretmek gerekiyor.
//==, contains, startswith gibi bunlar çoğaltılabilir.
//Örneğin contains gelirse methodcall expression kullanılırken, == geldiğinden binary expression kullanılır.
switch (opr)
{
case "StartsWith":
expRes = Expression.Call(propExp, nameof(string.StartsWith), Type.EmptyTypes, valExp);
break;
case "Contains":
expRes = Expression.Call(propExp, nameof(string.Contains), Type.EmptyTypes, valExp);
break;
case "==":
expRes = Expression.Equal(propExp, valExp);
break;
case "!=":
expRes = Expression.NotEqual(propExp, valExp);
break;
default:
expRes = Expression.Equal(propExp, valExp);
break;
}
Expression<Func<Car,bool>> lambdaExp = Expression.Lambda<Func<Car, bool>>(expRes, paramExp);
return lambdaExp;
}
Fonksiyon içerisinde neler yapıldı? Öncelikle ParameterExpression oluşturduk. Bunun nedeni x => x.Year == 2023 gibi bir sorgu oluşturabilmek için buradaki x’in Car tipinde olması gerekliydi ve x.Year ifadesi için ise Car sınıfının Year property’sine erişmek gerekliydi bunun için ise MemberExpression tanımladık. Bu noktaya kadar ifademizin sol tarafı hazır oldu. Yani x.Year veya x.Make tamam. Sağ tarafı için yani val değeri için ise ConstantExpression tanımladık. Bu noktada alanın tip bilgisi de gerekliydi ve alan adından tipini alarak ConstantExpression’a geçtik. Sağ ve sol taraflar hazır sadece kıyaslama veya işlem tanımı kaldı yani x.Year == 2023 ifadesindeki == işlemi. Bunun için ise gelen opr parametresine göre expression ürettik. Eğer contains işlemi olacaksa MethodCallExpression ile String.Contains metodunu çağırdık veya == işlemi olacaksa BinaryExpression ürettik ve Equals kullandık.
Son olarak lambda expression üretiyoruz Expression.Lambda’ya expRes ve paramExp parametrelerini geçiyoruz. Bunlar body ve parameter olarak adlandırılır. x => (x.Year == 2023)
örneği üzerinden gidelim buradaki body, x.Year == 2023 olan bölüm iken parameter ise x’i temsil eder.
Şimdi deneyelim:
DemoDb context = new DemoDb();
List<Car> carsRes = new List<Car>();
Console.WriteLine("Filter: Year == 2023");
Expression<Func<Car, bool>> predicateDynamicYear2023 = ExpressionGenerator("Year", "==", 2023);
carsRes = context.Cars().Where(predicateDynamicYear2023).ToList();
carsRes.ForEach(x => Console.WriteLine(x.ToString()));
Console.WriteLine("Filter: Make StartsWith 'T'");
Expression<Func<Car, bool>> predicateDynamicStartsWithT = ExpressionGenerator("Make", "StartsWith", "T");
carsRes = context.Cars().Where(predicateDynamicStartsWithT).ToList();
carsRes.ForEach(x => Console.WriteLine(x.ToString()));
//Çıktıları:
//Filter: Year == 2023
//1 2023 Volvo
//4 2023 Toyota
//Filter: Make StartsWith 'T'
//3 2021 Tesla
//4 2023 Toyota
Buraya kadar expression tree oluşturma ve çalıştırma konusunda bazı bilgiler verdim. Expression tree içeriğini okumak veya değiştirmek gerekebilir bu gibi işlemler için ExpressionVisitor sınıfı kullanılır.
Tahmin edileceği gibi Expression Tree kütüphanesi, kullanım alanları ve kısıtları konularında pek çok detay bulunmakta konunun daha iyi anlaşılabilmesi için somut örnekler yaparak ilerlemek faydalı olacaktır.
Teşekkürler.
Engin Ünal