Last active
February 15, 2024 14:13
-
-
Save stbraley/eed040bf0b1dab0f6044 to your computer and use it in GitHub Desktop.
Uses Expression Trees to create a LINQ query that allows you to sort and filter a collection of object or entity framework models using a data struct that can easily be sent to the client.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Collections.Generic; | |
using System.Globalization; | |
using System.Linq; | |
using System.Linq.Expressions; | |
using System.Reflection; | |
using System.Security.Cryptography.X509Certificates; | |
namespace Query | |
{ | |
[AttributeUsage(AttributeTargets.Property)] | |
public class DefaultSortProperty : Attribute | |
{ | |
public int Priority { get; set; } | |
public DefaultSortProperty(int priority) | |
{ | |
Priority = priority; | |
} | |
} | |
public class Model | |
{ | |
[DefaultSortProperty(1)] | |
public int Id { get; set; } | |
} | |
public enum SortDirection { Ascending, Descending, NoSort } | |
public enum CriteriaOperator | |
{ | |
NoOp, | |
And, | |
Or, | |
Contains, | |
Equal, | |
NotEqual, | |
GreaterThan, | |
GreaterThanEqual, | |
LessThan, | |
LessThanEqual | |
} | |
public class Criteria | |
{ | |
/// <summary> | |
/// The operation to perform used to create the criteria predicate. | |
/// </summary> | |
public CriteriaOperator Operator { get; set; } | |
/// <summary> | |
/// The string representation of the model property to operate on. | |
/// Property chaining is supported | |
/// </summary> | |
public string ModelProperty { get; set; } | |
/// <summary> | |
/// The priority of the criteria used when building the query. Lower priority first. | |
/// </summary> | |
public int Priority { get; set; } | |
public string Data { get; set; } | |
} | |
public class SortClause | |
{ | |
public List<SortProperty> SortProperties { get; set; } | |
public int OrderPriority { get; set; } | |
} | |
public class SortProperty | |
{ | |
public string ModelProperty { get; set; } | |
public SortDirection Direction { get; set; } | |
public int OrderPriority { get; set; } | |
} | |
public class Query | |
{ | |
public Query() | |
{ | |
Criteria = new List<Criteria>(); | |
OrderBy = new List<SortClause>(); | |
} | |
/// <summary> | |
/// The where criteria | |
/// </summary> | |
public List<Criteria> Criteria { get; set; } | |
/// <summary> | |
/// The order clause | |
/// </summary> | |
public List<SortClause> OrderBy { get; set; } | |
/// <summary> | |
/// The total number of records that match the criteria | |
/// </summary> | |
public int TotalRecords { get; set; } | |
/// <summary> | |
/// The number of records for a page. | |
/// </summary> | |
public int PageSize { get; set; } | |
/// <summary> | |
/// The page number to start at. | |
/// </summary> | |
public int Page { get; set; } | |
} | |
public static class PropertyParser | |
{ | |
/// <summary> | |
/// The get property by name. | |
/// </summary> | |
/// <param name="declaringType"> | |
/// The declaring type. | |
/// </param> | |
/// <param name="propertyName"> | |
/// The property name. | |
/// </param> | |
/// <returns> | |
/// The <see cref="PropertyInfo" />. | |
/// </returns> | |
/// <exception cref="InvalidOperationException"> | |
/// </exception> | |
private static PropertyInfo GetPropertyByName(Type declaringType, string propertyName) | |
{ | |
const BindingFlags flags = BindingFlags.IgnoreCase | BindingFlags.Instance | BindingFlags.Public; | |
PropertyInfo property = declaringType.GetProperty(propertyName, flags); | |
if (property == null) | |
{ | |
string exceptionMessage = string.Format( | |
CultureInfo.InvariantCulture, | |
"{0} does not contain a property named '{1}'.", | |
declaringType, | |
propertyName); | |
throw new InvalidOperationException(exceptionMessage); | |
} | |
return property; | |
} | |
// Throws an InvalidOperationException when property with name does not exist or doens't have a getter. | |
/// <summary> | |
/// The get property accessor. | |
/// </summary> | |
/// <param name="declaringType"> | |
/// The declaring type. | |
/// </param> | |
/// <param name="propertyName"> | |
/// The property name. | |
/// </param> | |
/// <returns> | |
/// The <see cref="MethodInfo" />. | |
/// </returns> | |
private static PropertyInfo GetProperty(Type declaringType, string propertyName) | |
{ | |
PropertyInfo property = GetPropertyByName(declaringType, propertyName); | |
return property; | |
} | |
/// <summary> | |
/// The get property accessors from property name chain. | |
/// </summary> | |
/// <param name="propertyNameChain"> | |
/// The property name chain. | |
/// </param> | |
/// <returns> | |
/// The <see cref="List{T}" />. | |
/// </returns> | |
private static List<PropertyInfo> GetPropertiesFromPropertyNameChain<T>(string propertyNameChain) where T : Model | |
{ | |
var properties = new List<PropertyInfo>(); | |
Type declaringTypeForProperty = typeof(T); | |
string[] propertyNames = propertyNameChain.Split('.'); | |
foreach (string propertyName in propertyNames) | |
{ | |
PropertyInfo property = GetProperty(declaringTypeForProperty, propertyName); | |
properties.Add(property); | |
declaringTypeForProperty = property.PropertyType; | |
} | |
return properties; | |
} | |
// Throws an ArgumentException when the propertyNameChain is invalid. | |
/// <summary> | |
/// The get property accessors. | |
/// </summary> | |
/// <param name="propertyName"> | |
/// The property name. | |
/// </param> | |
/// <returns> | |
/// The <see cref="List{T}" />. | |
/// </returns> | |
/// <exception cref="ArgumentException"> | |
/// </exception> | |
private static List<PropertyInfo> GetProperties<T>(string propertyName) where T : Model | |
{ | |
try | |
{ | |
var propertyNameChain = propertyName; | |
return GetPropertiesFromPropertyNameChain<T>(propertyNameChain); | |
} | |
catch (InvalidOperationException ex) | |
{ | |
var exceptionMessage = string.Format( | |
CultureInfo.InvariantCulture, "'{0}' could not be parsed. ", propertyName); | |
// We throw a more expressive exception at this level. | |
// ReSharper disable once UseNameofExpression | |
throw new ArgumentException(exceptionMessage + ex.Message, "propertyName"); | |
} | |
} | |
/// <summary> | |
/// Gets the PropertyInfo for a given property string. Property chaining is supported. | |
/// </summary> | |
/// <typeparam name="T">The type of object that contains the property.</typeparam> | |
/// <param name="propertyName"></param> | |
/// <returns>The <see cref="PropertyInfo" /> for the given property string.</returns> | |
public static PropertyInfo ParseModelProperty<T>(string propertyName) where T : Model | |
{ | |
var properties = GetProperties<T>(propertyName); | |
return properties.Last(); | |
} | |
} | |
public class QueryExecutioner<T> where T : Model | |
{ | |
private static ILambdaBuilder CreateGenericLambdaBuilder(Type keyType) | |
{ | |
Type[] typeArguments = new[] { typeof(T), keyType }; | |
Type lambdaBuilderType = typeof(LambdaBuilder<>).MakeGenericType(typeArguments); | |
return (ILambdaBuilder)Activator.CreateInstance(lambdaBuilderType); | |
} | |
private static Expression ConvertToType(string value, PropertyInfo info) | |
{ | |
if (info.PropertyType == typeof (DateTime)) | |
return Expression.Constant(Convert.ToDateTime(value), typeof (DateTime)); | |
if (info.PropertyType == typeof (decimal)) | |
return Expression.Constant(decimal.Parse(value), typeof (decimal)); | |
if (info.PropertyType == typeof (int)) | |
return Expression.Constant(int.Parse(value), typeof (int)); | |
return Expression.Constant(value, typeof (string)); | |
} | |
private static Expression BuildWhereCallExpression(Query query, Expression parameterExpression) | |
{ | |
var propertyExpression = parameterExpression; | |
Expression whereExpression = null; | |
if( query.Criteria.Count <= 0 ) | |
query.Criteria.Add( new Criteria { Operator = CriteriaOperator.NoOp }); | |
CriteriaOperator last = CriteriaOperator.And; | |
foreach (var criteria in query.Criteria.OrderBy( i => i.Priority) ) | |
{ | |
PropertyInfo info = null; | |
if (criteria.Operator != CriteriaOperator.NoOp && | |
criteria.Operator != CriteriaOperator.And && | |
criteria.Operator != CriteriaOperator.Or ) | |
{ | |
info = PropertyParser.ParseModelProperty<T>(criteria.ModelProperty); | |
propertyExpression = criteria.ModelProperty.Split('.') | |
.Aggregate(parameterExpression, Expression.PropertyOrField); | |
} | |
Expression local = null; | |
switch (criteria.Operator) | |
{ | |
case CriteriaOperator.Contains: | |
MethodInfo containsMethod = typeof(string).GetMethod("Contains", new[] { typeof(string) }); | |
var containsValue = Expression.Constant(criteria.Data); | |
local = Expression.Call(propertyExpression, containsMethod, containsValue); | |
break; | |
case CriteriaOperator.Equal: | |
local = Expression.Equal(propertyExpression, ConvertToType(criteria.Data, info)); | |
break; | |
case CriteriaOperator.NotEqual: | |
local = Expression.NotEqual(propertyExpression, ConvertToType(criteria.Data, info)); | |
break; | |
case CriteriaOperator.GreaterThan: | |
local = Expression.GreaterThan(propertyExpression, ConvertToType(criteria.Data, info)); | |
break; | |
case CriteriaOperator.GreaterThanEqual: | |
local = Expression.GreaterThanOrEqual(propertyExpression, ConvertToType(criteria.Data, info)); | |
break; | |
case CriteriaOperator.LessThan: | |
local = Expression.LessThan(propertyExpression, ConvertToType(criteria.Data, info)); | |
break; | |
case CriteriaOperator.LessThanEqual: | |
local = Expression.LessThanOrEqual(propertyExpression, ConvertToType(criteria.Data, info)); | |
break; | |
case CriteriaOperator.Or: | |
last = CriteriaOperator.Or; | |
break; | |
default: | |
local = Expression.Constant(true, typeof(bool)); // just return true if we don't have an op... where(i => true ) | |
break; | |
} | |
if (local != null) | |
{ | |
whereExpression = whereExpression == null | |
? local | |
: last == CriteriaOperator.And | |
? Expression.AndAlso(whereExpression, local) | |
: Expression.OrElse(whereExpression, local); | |
last = CriteriaOperator.And; // default to and... | |
} | |
} | |
return whereExpression; | |
} | |
private static Expression BuildSortCallExpressionChain(Query query, ParameterExpression parameterExpression, Expression callExpression) | |
{ | |
Expression propertyExpression = parameterExpression; | |
string orderBy = null; | |
// Entity Framework requires the query to be sorted in order to page the result set aka... (Skip Records). Therefore; if the | |
// caller did not provide a sort, we will look on the entity for a default sort attribue and use that. | |
if (query.OrderBy.Count <= 0) | |
{ | |
var props = typeof(T).GetProperties() | |
.Where(i => i.GetCustomAttributes<DefaultSortProperty>().Any()) | |
.Select(i => new { Attribute = i.GetCustomAttribute<DefaultSortProperty>(), Info = i }) | |
.OrderBy(i => i.Attribute.Priority) | |
.Select( | |
i => | |
new SortProperty | |
{ | |
OrderPriority = i.Attribute.Priority, | |
Direction = SortDirection.Ascending, | |
ModelProperty = i.Info.Name | |
}); | |
query.OrderBy.Add(new SortClause | |
{ | |
OrderPriority = 1, | |
SortProperties = new List<SortProperty>(props) | |
}); | |
} | |
foreach (var group in query.OrderBy) | |
{ | |
foreach (var property in group.SortProperties) | |
{ | |
var info = PropertyParser.ParseModelProperty<T>(property.ModelProperty); | |
propertyExpression = property.ModelProperty.Split('.').Aggregate(propertyExpression, Expression.PropertyOrField); | |
if (orderBy == null) | |
orderBy = property.Direction == SortDirection.Ascending ? "OrderBy" : "OrderByDescending"; | |
else | |
orderBy = property.Direction == SortDirection.Ascending ? "ThenBy" : "ThenByDescending"; | |
var lambdaBuilder = CreateGenericLambdaBuilder(info.PropertyType); | |
var methodCallExpression = Expression.Call( | |
typeof(Queryable), | |
orderBy, | |
new[] { typeof(T), info.PropertyType }, | |
callExpression, | |
lambdaBuilder.BuildLambda(parameterExpression, propertyExpression)); | |
callExpression = methodCallExpression; | |
} | |
} | |
return callExpression; | |
} | |
/// <summary> | |
/// Execute the query on the given datasource. | |
/// </summary> | |
/// <param name="query">The query to execute</param> | |
/// <param name="collection">The datasource to query</param> | |
/// <returns></returns> | |
public static IQueryable<T> ExecuteQuery(Query query, IEnumerable<T> collection) | |
{ | |
var parameter = Expression.Parameter(typeof(T)); | |
var body = BuildWhereCallExpression(query, parameter); | |
var queryable = collection.AsQueryable<T>(); | |
var whereMethodCall = Expression.Call( | |
typeof(Queryable), | |
"Where", | |
new[] { typeof(T) }, | |
queryable.Expression, | |
Expression.Lambda<Func<T, bool>>(body, parameter)); | |
body = BuildSortCallExpressionChain(query, parameter, whereMethodCall); | |
var skip = query.Page * query.PageSize; | |
var containsValue = Expression.Constant(skip, typeof(int)); | |
//body = Expression.Call( | |
// body, | |
// "Skip", | |
// new[] {typeof (T)}, | |
// containsValue); | |
//body = Expression.Call( | |
// body, | |
// "Take", | |
// new[] {typeof (T)}, | |
// Expression.Constant(query.PageSize, typeof (int))); | |
return queryable.Provider.CreateQuery<T>(body); | |
} | |
#region [ Lambda Builder ] | |
private interface ILambdaBuilder | |
{ | |
LambdaExpression BuildLambda(ParameterExpression parameterExpression, Expression propertyExpression); | |
} | |
private class LambdaBuilder<TProperyType> : ILambdaBuilder | |
{ | |
public LambdaExpression BuildLambda(ParameterExpression parameterExpression, Expression propertyExpression) | |
{ | |
return Expression.Lambda<Func<T, TProperyType>>(propertyExpression, parameterExpression); | |
} | |
} | |
#endregion | |
} | |
class Program | |
{ | |
class Person : Model | |
{ | |
public string First { get; set; } | |
public string Last { get; set; } | |
public int Age { get; set; } | |
} | |
static void Main(string[] args) | |
{ | |
var people = new List<Person> | |
{ | |
new Person {First = "Jim", Last = "Anderson", Age = 27, Id = 22}, | |
new Person {First = "Wes", Last = "Easton", Age = 34, Id = 33}, | |
new Person {First = "Al", Last = "Munster", Age = 55, Id = 2}, | |
new Person {First = "Bob", Last = "Mclean", Age = 22, Id = 7}, | |
new Person {First = "Jen", Last = "Johnson", Age = 10, Id = 1}, | |
}; | |
var query = new Query { Page = 0, PageSize = 2 }; | |
query.Criteria.Add( | |
new Criteria | |
{ | |
Data = "on", | |
ModelProperty = "Last", | |
Operator = CriteriaOperator.Contains | |
}); | |
query.OrderBy.Add( | |
new SortClause | |
{ | |
OrderPriority = 1, | |
SortProperties = new List<SortProperty> | |
{ | |
new SortProperty | |
{ | |
Direction = SortDirection.Ascending, | |
ModelProperty = "Age", | |
OrderPriority = 1 | |
} | |
} | |
}); | |
var selected = QueryExecutioner<Person>.ExecuteQuery(query, people); | |
foreach( var p in selected ) | |
Console.WriteLine("First: {0}, Last: {1}, Age: {2}", p.First, p.Last, p.Age); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Hello, nice work.
I seem you are facing my same problem.
It doesn't work with Nullable