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:

shell
dotnet add package NetQueryBuilder
Note

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.

C#
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.

C#
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).

C#
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.

C#
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).

C#
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.

C#
// 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:

C#
// 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);
}
Tip

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

C#
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.

C#
// 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.

C#
// 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.

C#
// 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

C#
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:

C#
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:

C#
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:

C#
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:

C#
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:

C#
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:

C#
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.

C#
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:

C#
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.

Note

The built-in NetQueryBuilder.EntityFramework package is one such implementation. Use it as a reference when building your own.

Implementing a custom configurator

C#
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

C#
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...
}
Tip

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
Note

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

text
ExpressionOperator (abstract)
  +-- BinaryOperator (abstract)
  |     +-- EqualsOperator
  |     +-- NotEqualsOperator
  |     +-- GreaterThanOperator
  |     +-- GreaterThanOrEqualOperator
  |     +-- LessThanOperator
  |     +-- LessThanOrEqualOperator
  +-- MethodCallOperator (abstract)
        +-- InListOperator<T>