Skip to content

Instantly share code, notes, and snippets.

@stbraley
Last active February 15, 2024 14:13
Show Gist options
  • Save stbraley/eed040bf0b1dab0f6044 to your computer and use it in GitHub Desktop.
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.
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);
}
}
}
@Loppone
Copy link

Loppone commented Feb 21, 2023

Hello, nice work.
I seem you are facing my same problem.
It doesn't work with Nullable

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment