Core Library
The foundation package providing expression-tree based query building, extensible operators, and property path navigation.
Installation#
Install the core package via the .NET CLI:
dotnet add package NetQueryBuilder
The core package is ORM-independent. To use it with Entity Framework Core, also install NetQueryBuilder.EntityFramework. See the Entity Framework Core documentation for details.
The core library targets .NET 6, .NET 8, .NET 9, .NET Standard 2.1, and .NET Framework 4.8, so it works across modern and legacy projects alike.
Core Concepts#
NetQueryBuilder is built around a small set of interfaces and abstractions that compose together to form a flexible query-building pipeline. Understanding these key types is essential before diving into usage.
IQueryConfigurator
The main entry point for configuring and building queries. It provides fluent methods for customizing how queries select fields, apply conditions, and display expressions.
public interface IQueryConfigurator { IEnumerable<Type> GetEntities(); IQueryConfigurator UseExpressionStringifier(IExpressionStringifier expressionStringifier); IQueryConfigurator ConfigureSelect(Action<ISelectConfigurator> selectBuilder); IQueryConfigurator ConfigureConditions(Action<IConditionConfigurator> conditionBuilder); IQuery BuildFor<T>() where T : class; IQuery BuildFor(Type type); }
IQuery
Represents a built query. It exposes the available property paths for conditions and selection, the root condition tree, and methods to compile and execute the query with pagination.
public interface IQuery { EventHandler OnChanged { get; set; } IReadOnlyCollection<SelectPropertyPath> SelectPropertyPaths { get; } IReadOnlyCollection<PropertyPath> ConditionPropertyPaths { get; } BlockCondition Condition { get; } LambdaExpression Compile(); Task<QueryResult<dynamic>> Execute(int pageSize); Task<QueryResult<TProjection>> Execute<TProjection>(int pageSize); }
ICondition
Represents a node in the condition tree. Conditions form a hierarchy with parent-child relationships and support change notification through events. The two concrete implementations are SimpleCondition (a leaf with a property, operator, and value) and BlockCondition (a group of child conditions joined by a logical operator).
public interface ICondition { BlockCondition Parent { get; set; } EventHandler ConditionChanged { get; set; } LogicalOperator LogicalOperator { get; set; } ICondition GetRoot(); Expression Compile(); }
PropertyPath
Represents a property in the entity model. Supports navigation through related entities using dot notation (e.g., Address.City). Each property path knows its type, depth, and which operators are compatible with it.
public class PropertyPath { public string PropertyFullName { get; } // e.g. "Address.City" public string PropertyName { get; } // e.g. "City" public Type ParentType { get; } public Type PropertyType { get; } public int Depth { get; } // 0 = direct property, 1+ = navigated public MemberExpression GetExpression(); public object GetDefaultValue(); public IEnumerable<ExpressionOperator> GetCompatibleOperators(); public string DisplayName(); public string DisplayValue(object context); public string GetNavigationPath(); }
ExpressionOperator
The abstract base class for all operators. Operators convert a left expression and a right expression into a combined LINQ expression. Two specializations exist: BinaryOperator for comparison operators (Equals, GreaterThan, etc.) and MethodCallOperator for method-based operators (InList).
public abstract class ExpressionOperator { public abstract Expression ToExpression(Expression left, Expression right); public abstract object GetDefaultValue(Type type, object value); public string DisplayName { get; } }
Query Creation#
Queries are created through the IQueryConfigurator interface. Call BuildFor<T>() with your entity type to get an IQuery instance, then execute it with pagination.
// Create a configurator (or use an EF-backed one from NetQueryBuilder.EntityFramework) IQueryConfigurator configurator = new QueryableQueryConfigurator(); // Build a query for a specific entity type IQuery query = configurator.BuildFor<Person>(); // Execute with a page size of 50 QueryResult<dynamic> result = await query.Execute(50); // Access results IReadOnlyCollection<dynamic> items = result.Items; int totalItems = result.TotalItems; int totalPages = result.TotalPage; int currentPage = result.CurrentPage; // Navigate to another page QueryResult<dynamic> page2 = await result.GoToPage(1);
You can also build queries dynamically using a Type object instead of a generic parameter:
// Build a query from a runtime type Type entityType = typeof(Person); IQuery query = configurator.BuildFor(entityType); // List all available entity types foreach (var type in configurator.GetEntities()) { Console.WriteLine(type.Name); }
The IQuery.OnChanged event fires whenever a condition or selection changes, making it easy to trigger automatic re-execution or UI updates in reactive scenarios.
Condition Building#
Conditions are managed through the BlockCondition root node available on every IQuery. You can create simple conditions, chain them with logical operators, and nest them into groups.
Creating simple conditions
var query = configurator.BuildFor<Person>(); // Find the property path for "Name" var nameProperty = query.ConditionPropertyPaths .First(p => p.PropertyFullName == "Name"); // Create a condition with a specific operator type SimpleCondition condition = query.Condition .CreateNew<EqualsOperator>(nameProperty, "Alice"); // Or create with an explicit operator instance var operators = nameProperty.GetCompatibleOperators(); var notEquals = operators.OfType<NotEqualsOperator>().First(); query.Condition.CreateNew(nameProperty, notEquals, "Bob");
Working with SimpleCondition
Each SimpleCondition holds a PropertyPath, an ExpressionOperator, a Value, and a LogicalOperator (And/Or). Changing any of these properties automatically invalidates the compiled expression cache and fires the ConditionChanged event.
// Change the value condition.Value = "Charlie"; // Change the operator condition.Operator = operators.OfType<EqualsOperator>().First(); // Change the logical operator condition.LogicalOperator = LogicalOperator.Or; // Switch to a different property var ageProperty = query.ConditionPropertyPaths .First(p => p.PropertyFullName == "Age"); condition.PropertyPath = ageProperty; // List available operators for the current property foreach (var op in condition.AvailableOperatorsForCurrentProperty()) { Console.WriteLine(op.DisplayName); }
Grouping and ungrouping
Use Group() to wrap multiple conditions into a nested BlockCondition, and Ungroup() to flatten them back into the parent.
// Add several conditions var c1 = query.Condition.CreateNew<EqualsOperator>(nameProperty, "Alice"); var c2 = query.Condition.CreateNew<EqualsOperator>(nameProperty, "Bob"); var c3 = query.Condition.CreateNew<GreaterThanOperator>(ageProperty, 30); // Group the first two into a nested block BlockCondition group = query.Condition.Group(new[] { c1, c2 }); // Result: (Name == "Alice" OR Name == "Bob") AND Age > 30 group.LogicalOperator = LogicalOperator.Or; // Remove a condition query.Condition.Remove(c3); // Ungroup to flatten back group.Ungroup(new[] { c1, c2 });
Compiling expressions
Conditions compile into LINQ expression trees. The compiled expression is cached and only recompiled when the condition tree changes.
// Compile the full query condition to a LambdaExpression LambdaExpression expression = query.Compile(); // Compile an individual condition node Expression conditionExpr = condition.Compile();
Property Selection#
Control which properties are available and selected in a query using the ConfigureSelect and ConfigureConditions methods on the configurator.
Configuring select fields
var configurator = new QueryableQueryConfigurator(); // Limit which fields appear in the select list configurator.ConfigureSelect(select => { select.LimitToFields("Name", "Age", "Email"); }); // Or remove specific fields configurator.ConfigureSelect(select => { select.RemoveFields("InternalId", "CreatedAt"); }); // Limit navigation depth for related entities configurator.ConfigureSelect(select => { select.LimitDepth(2); }); // Exclude specific relationship types configurator.ConfigureSelect(select => { select.ExcludeRelationships(typeof(AuditLog)); });
Configuring condition fields
The same fluent API is available for condition fields through ConfigureConditions:
configurator.ConfigureConditions(conditions => { conditions .LimitToFields("Name", "Age", "Address.City") .LimitDepth(1) .ExcludeRelationships(typeof(AuditLog)); });
Working with SelectPropertyPath
After building a query, the SelectPropertyPaths collection lets you toggle which properties are included in the query projection:
var query = configurator.BuildFor<Person>(); // Toggle properties on/off foreach (var selectPath in query.SelectPropertyPaths) { Console.WriteLine($"{selectPath.Property.DisplayName()} - Selected: {selectPath.IsSelected}"); selectPath.IsSelected = false; // exclude from projection } // Re-enable a specific property var namePath = query.SelectPropertyPaths .First(s => s.Property.PropertyFullName == "Name"); namePath.IsSelected = true;
Using a property stringifier
Implement IPropertyStringifier to customize how property names and values are displayed in the UI:
public class FriendlyPropertyStringifier : IPropertyStringifier { public string GetName(string propertyName) { return propertyName switch { "Address.City" => "City", "Address.ZipCode" => "Postal Code", _ => propertyName }; } public string FormatValue(string propertyName, Type type, object value) { if (type == typeof(DateTime)) return ((DateTime)value).ToString("yyyy-MM-dd"); return value?.ToString() ?? string.Empty; } } // Apply to the configurator configurator.ConfigureSelect(s => s.UseStringifier(new FriendlyPropertyStringifier())); configurator.ConfigureConditions(c => c.UseStringifier(new FriendlyPropertyStringifier()));
Custom Operators#
The operator system is extensible. You can create your own operators by extending BinaryOperator or MethodCallOperator, depending on the kind of expression you need to build.
Creating a binary operator
Binary operators produce comparison expressions using Expression.MakeBinary. Extend BinaryOperator for straightforward comparisons:
public class ModuloEqualsOperator : BinaryOperator { public ModuloEqualsOperator(IExpressionStringifier stringifier) : base(ExpressionType.Modulo, "ModuloEquals", stringifier) { } public override Expression ToExpression(Expression left, Expression right) { // x % value == 0 var modulo = Expression.MakeBinary(ExpressionType.Modulo, left, right); return Expression.Equal(modulo, Expression.Constant(0)); } }
Creating a method call operator
Method call operators invoke a specific MethodInfo at runtime. Extend MethodCallOperator for string or collection methods:
public class StartsWithOperator : MethodCallOperator { public StartsWithOperator(IExpressionStringifier stringifier) : base( "StartsWith", stringifier, typeof(string).GetMethod("StartsWith", new[] { typeof(string) })) { } public override Expression ToExpression(Expression left, Expression right) { var call = Expression.Call(left, MethodInfo, right); return IsNegated ? Expression.Not(call) : call; } public override object GetDefaultValue(Type type, object value) { return value is string ? value : string.Empty; } }
Registering custom operators
To make your operators available in the query builder, implement IOperatorFactory or extend DefaultOperatorFactory:
public class ExtendedOperatorFactory : DefaultOperatorFactory { public ExtendedOperatorFactory(IExpressionStringifier stringifier) : base(stringifier) { } public override IEnumerable<ExpressionOperator> GetAllForProperty(PropertyPath propertyPath) { var defaults = base.GetAllForProperty(propertyPath).ToList(); if (propertyPath.PropertyType == typeof(string)) { defaults.Add(new StartsWithOperator(_stringifier)); } return defaults; } }
Expression Stringifier#
The IExpressionStringifier interface controls how operators are displayed as text. This affects operator labels in UI components and ToString() output.
public interface IExpressionStringifier { string GetString(ExpressionType expressionType, string name); }
The built-in UpperSeparatorExpressionStringifier converts PascalCase names into space-separated labels (e.g., "GreaterThanOrEqual" becomes "Greater than or equal"). You can replace it with a custom implementation for localization or different formatting:
public class SymbolStringifier : IExpressionStringifier { public string GetString(ExpressionType expressionType, string name) { return expressionType switch { ExpressionType.Equal => "=", ExpressionType.NotEqual => "!=", ExpressionType.GreaterThan => ">", ExpressionType.GreaterThanOrEqual => ">=", ExpressionType.LessThan => "<", ExpressionType.LessThanOrEqual => "<=", _ => name }; } } // Apply to the configurator configurator.UseExpressionStringifier(new SymbolStringifier());
Custom Data Sources#
NetQueryBuilder is not tied to any specific ORM. You can implement IQueryConfigurator and IQuery to target REST APIs, NoSQL databases, in-memory collections, or any other data source.
The built-in NetQueryBuilder.EntityFramework package is one such implementation. Use it as a reference when building your own.
Implementing a custom configurator
public class RestApiQueryConfigurator : IQueryConfigurator { private readonly HttpClient _httpClient; public RestApiQueryConfigurator(HttpClient httpClient) { _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); } public IEnumerable<Type> GetEntities() { // Return the entity types your API supports return new[] { typeof(Product), typeof(Order) }; } public IQuery BuildFor<T>() where T : class { return new RestApiQuery<T>(_httpClient); } public IQuery BuildFor(Type type) { // Use reflection to call BuildFor<T> with runtime type var method = typeof(RestApiQueryConfigurator) .GetMethod(nameof(BuildFor), Type.EmptyTypes) .MakeGenericMethod(type); return (IQuery)method.Invoke(this, null); } // Implement remaining IQueryConfigurator members... }
Implementing a custom query
public class RestApiQuery<T> : IQuery where T : class { private readonly HttpClient _httpClient; public async Task<QueryResult<dynamic>> Execute(int pageSize) { // Translate the compiled expression to API query parameters var expression = Compile(); // Build and send the HTTP request // Parse the response into QueryResult } // Implement remaining IQuery members... }
Common use cases for custom data sources include REST APIs, NoSQL databases (MongoDB, CosmosDB), in-memory collections, CSV/Excel files, and domain-specific query languages.
Built-in Operators#
The DefaultOperatorFactory provides the following operators out of the box. Operator availability depends on the property type.
| Operator | Class | Base | Supported Types |
|---|---|---|---|
| Equals | EqualsOperator |
BinaryOperator | All types |
| Not Equals | NotEqualsOperator |
BinaryOperator | All types |
| Greater Than | GreaterThanOperator |
BinaryOperator | int, DateTime |
| Greater Than or Equal | GreaterThanOrEqualOperator |
BinaryOperator | int, DateTime |
| Less Than | LessThanOperator |
BinaryOperator | int, DateTime |
| Less Than or Equal | LessThanOrEqualOperator |
BinaryOperator | int, DateTime |
| In List | InListOperator<T> |
MethodCallOperator | int, string |
| Not In List | InListOperator<T> (negated) |
MethodCallOperator | int, string |
The DefaultOperatorFactory determines which operators are available based on the PropertyType of each PropertyPath. Boolean properties only get Equals and NotEquals. You can override GetAllForProperty in a custom factory to add more operators for any type.
Operator type hierarchy
ExpressionOperator (abstract)
+-- BinaryOperator (abstract)
| +-- EqualsOperator
| +-- NotEqualsOperator
| +-- GreaterThanOperator
| +-- GreaterThanOrEqualOperator
| +-- LessThanOperator
| +-- LessThanOrEqualOperator
+-- MethodCallOperator (abstract)
+-- InListOperator<T>