UniversalQueryBuilder.Core 10.0.13-beta

This is a prerelease version of UniversalQueryBuilder.Core.
dotnet add package UniversalQueryBuilder.Core --version 10.0.13-beta
                    
NuGet\Install-Package UniversalQueryBuilder.Core -Version 10.0.13-beta
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="UniversalQueryBuilder.Core" Version="10.0.13-beta" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="UniversalQueryBuilder.Core" Version="10.0.13-beta" />
                    
Directory.Packages.props
<PackageReference Include="UniversalQueryBuilder.Core" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add UniversalQueryBuilder.Core --version 10.0.13-beta
                    
#r "nuget: UniversalQueryBuilder.Core, 10.0.13-beta"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package UniversalQueryBuilder.Core@10.0.13-beta
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=UniversalQueryBuilder.Core&version=10.0.13-beta&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=UniversalQueryBuilder.Core&version=10.0.13-beta&prerelease
                    
Install as a Cake Tool

QueryBuilder.Core

Foundation library for the Universal Query Builder system

QueryBuilder.Core provides the core abstractions, models, and patterns that enable unified querying across multiple data sources. It defines the contracts that all execution strategies must implement and provides the foundational building blocks for the entire Universal Query Builder ecosystem.

// Define a query once
var query = new QueryDefinition
{
    From = "users",
    Where = FilterDefinitionExtensions.And(
        FilterDefinitionExtensions.Equal("IsActive", true),
        FilterDefinitionExtensions.GreaterThan("Age", 18)
    )
};

// Execute against any data source (EntityFramework, In-Memory, etc.)
var results = await executor.ExecuteAsync<User>(query, context);

Table of Contents


Overview

QueryBuilder.Core is the foundational library that:

Defines Core Models - QueryDefinition, FilterDefinition, Specification, etc. ✅ Provides Abstractions - Strategy pattern for pluggable execution engines ✅ Implements Patterns - Specification pattern for reusable business rules ✅ Offers Extension Methods - Fluent API for building queries and filters ✅ Enables Function Resolution - Runtime @function evaluation (@today, @me, etc.) ✅ Establishes Contracts - Interfaces that execution strategies must implement

Role in the Universal Query Builder System

QueryBuilder.Core is the foundation that all other projects depend on:

┌─────────────────────────────────────────────────────┐
│              QueryBuilder.Core                      │
│  (Models, Abstractions, Patterns, Extensions)       │
└─────────────────────────────────────────────────────┘
                        ▲
                        │
            ┌───────────┼───────────┐
            │           │           │
    ┌───────┴──────┐ ┌──┴────┐ ┌───┴─────────┐
    │ EntityFrwk   │ │ InMem │ │ SchemaReg.  │
    │ (Strategy)   │ │(Strat)│ │ (Metadata)  │
    └──────────────┘ └───────┘ └─────────────┘
            │           │           │
            └───────────┼───────────┘
                        │
                ┌───────┴────────┐
                │  Application   │
                └────────────────┘

Dependencies:

  • QueryBuilder.EntityFramework - EF Core execution strategy
  • QueryBuilder.InMemory - In-memory LINQ execution
  • QueryBuilder.Json - JSON deserialization to QueryDefinition
  • QueryBuilder.Shorthand - Human-readable query syntax
  • QueryBuilder.SchemaRegistry - Metadata and validation
  • QueryBuilder.Expressions - Expression compilation and caching

Unified Type Coercion

All runtime value-to-type conversion is provided by the TypeCoercion NuGet package (v1.1.0), referenced transitively through Core.

Canonical API

  • TypeCoercer.TryCoerce(object? value, Type targetType)
  • TypeCoercer.Coerce(object? value, Type targetType)
  • CoercionResult
  • TypeCoercionException
  • CoercionErrorCode

Behavior contract

  • Null to non-nullable targets fails.
  • Enum coercion accepts string member names only (case-insensitive).
  • Numeric and numeric-string enum values are rejected.
  • Failed conversions return an explicit error; they do not silently fall back to returning a ToString() representation.
  • JsonElement scalar values are normalized and coerced.
  • JsonElement object/array values deserialize only when the target is a complex CLR type; otherwise coercion fails explicitly.
  • Conversion uses CultureInfo.InvariantCulture.

Core Concepts

1. QueryDefinition as Single Source of Truth

All query operations flow through QueryDefinition:

JSON Query → QueryDefinition → Execution Strategy → Results
Shorthand  → QueryDefinition → Execution Strategy → Results
Code       → QueryDefinition → Execution Strategy → Results

No reverse translation - Strategies don't convert back to QueryDefinition. Data flows one direction only.

2. Strategy Pattern for Execution

Different data sources require different execution approaches:

  • Entity Framework - Translate to EF Core IQueryable, execute via any EF provider (SQL Server, PostgreSQL, MySQL, SQLite)
  • In-Memory - Compile to LINQ expressions, execute against IEnumerable
  • Custom - Implement IExecutionStrategy for your data source

The ExecutionStrategyFactory selects the appropriate strategy based on the data source.

3. Specification Pattern for Business Rules

Store business logic as data (POCO), not code:

// Define once, reuse everywhere
var spec = new Specification
{
    Name = "ActiveAdultUsers",
    EntityType = "MyApp.Domain.User",
    Filter = FilterDefinitionExtensions.And(
        FilterDefinitionExtensions.Equal("IsActive", true),
        FilterDefinitionExtensions.GreaterThanOrEqual("Age", 18)
    )
};

// Use in database queries
var expression = SpecificationExpressionBuilder.BuildExpression<User>(spec);
var users = dbContext.Users.Where(expression).ToList();

// Use in API queries
var results = await executor.ExecuteSpecificationAsync<User>(spec, context);

4. Runtime Function Resolution

@functions enable dynamic, context-aware queries:

// Store the query with @function reference
var query = new QueryDefinition
{
    From = "orders",
    Where = new FilterDefinition
    {
        Field = "Created",
        Operator = FilterOperator.Equal,
        Value = "@last_7_days"  // ← Resolved at execution time
    }
};

// Execute - @last_7_days becomes BETWEEN (now - 7 days) AND now
var results = await executor.ExecuteAsync<Order>(query, context);

Benefits:

  • Queries don't hard-code dates
  • Same query works across time periods
  • Context-aware (e.g., @me resolves to current user)

Installation

NuGet Package (when published):

dotnet add package UniversalQueryBuilder.Core

Service Registration:

using Microsoft.Extensions.DependencyInjection;
using QueryBuilder.Core.Extensions;
using QueryBuilder.EntityFramework.Extensions; // When using EF
using QueryBuilder.SchemaRegistry.Extensions;

services.AddUniversalQueryBuilder(options =>
{
    // 1. Core Options
    options.DefaultPageSize = 50;
    options.MaxPageSize = 1000;
    options.EnableQueryValidation = true;

    // 2. Enable Entity Framework (automatically handles DbContext aliasing)
    // Requires QueryBuilder.EntityFramework package
    options.UseEntityFramework<AppDbContext>();

    // 3. Register Data Sources (automatically discovered in entry assembly)
    options.AddCodeFirstSchemaRegistry();
    
    // 4. Register In-Memory Providers (optional)
    options.AddDataProvider<CachedUsersProvider>(ServiceLifetime.Scoped);
});

Dependencies:

  • .NET 10 or later
  • System.Text.Json - JSON serialization
  • Microsoft.Extensions.DependencyInjection - DI support
  • Microsoft.Extensions.Logging - Logging abstractions

Core Models

QueryDefinition

Purpose: Root definition of a query - the single source of truth for all query operations.

Location: /src/QueryBuilder.Core/Models/QueryDefinition.cs

Key Properties:

public class QueryDefinition
{
    /// <summary>Schema version (e.g., "1.0.0")</summary>
    public string Version { get; set; } = "1.0.0";

    /// <summary>FROM clause - data source name (e.g., "users", "productCategories")</summary>
    public string From { get; set; }

    /// <summary>Hierarchical field selection (Unified Dictionary syntax)</summary>
    public SelectionDictionary? Select { get; set; }

    /// <summary>WHERE clause - filter conditions</summary>
    public FilterDefinition? Where { get; set; }

    /// <summary>Explicit grouping fields (GROUP BY clause)</summary>
    public List<string>? GroupBy { get; set; }

    /// <summary>HAVING clause - post-aggregation filters</summary>
    public FilterDefinition? Having { get; set; }

    /// <summary>ORDER BY clause - sorting</summary>
    public List<OrderingDefinition>? OrderBy { get; set; }

    /// <summary>LIMIT/TOP - max records to return</summary>
    public int? Limit { get; set; }

    /// <summary>OFFSET/SKIP - records to skip</summary>
    public int? Offset { get; set; }

    /// <summary>DISTINCT - eliminate duplicates</summary>
    public bool? Distinct { get; set; }

    /// <summary>JOIN clauses - table relationships</summary>
    public List<JoinDefinition>? Joins { get; set; }

    /// <summary>Execution hints (performance optimization)</summary>
    public Dictionary<string, object>? Hints { get; set; }
}

Key Methods:

// Structural validation
ValidationResult Validate();

// Deep copy — cycle-safe (a cyclical subquery graph clones each QueryDefinition once via a
// reference-equality memo; shared subqueries stay shared in the clone)
QueryDefinition Clone();

// Hash for caching
int GetHashCode();

JSON Example:

{
  "version": "1.0.0",
  "from": "users",
  "select": {
    "Id": true,
    "Username": true,
    "Department": {
      "select": { "Name": true }
    },
    "Orders": {
      "where": { "field": "Total", "operator": "gt", "value": 100 },
      "limit": 5,
      "select": { "OrderNumber": true, "Total": true }
    }
  },
  "where": {
    "field": "IsActive", "operator": "eq", "value": true
  },
  "limit": 50
}

FilterDefinition

Purpose: Represents filter conditions - supports both simple comparisons and complex nested logical operations.

Location: /src/QueryBuilder.Core/Models/FilterDefinition.cs

Key Properties:

public class FilterDefinition
{
    /// <summary>Field name (e.g., "IsActive", "User.Email")</summary>
    public string? Field { get; set; }

    /// <summary>Comparison operator (eq, gt, like, etc.)</summary>
    public FilterOperator? Operator { get; set; }

    /// <summary>Logical operator for combining expressions (and, or, not)</summary>
    public LogicalOperator? LogicalOperator { get; set; }

    /// <summary>Single value for comparison</summary>
    public object? Value { get; set; }

    /// <summary>Multiple values (for IN operator)</summary>
    public List<object>? Values { get; set; }

    /// <summary>Range bounds (for BETWEEN operator)</summary>
    public RangeDefinition? Range { get; set; }

    /// <summary>Nested filters (for AND/OR/NOT)</summary>
    public List<FilterDefinition>? Expressions { get; set; }

    /// <summary>Field-to-field comparisons</summary>
    public ExpressionDefinition? ComparisonExpression { get; set; }

    /// <summary>Function-based filters</summary>
    public FunctionDefinition? Function { get; set; }

    /// <summary>Subquery filters</summary>
    public QueryDefinition? Subquery { get; set; }

    /// <summary>String comparison mode</summary>
    public bool? CaseSensitive { get; set; }
}

Filter Operators:

Category Operators Description
Comparison eq, ne, gt, gte, lt, lte Equality and ordering
String like, ilike, contains, startsWith, endsWith, regex, fuzzy Text matching
List in, notIn Membership
Range between, notBetween Range checks
Null isNull, isNotNull Null checks
Subquery exists, notExists Subquery existence
Function function Custom function-based filters

Logical Operators:

  • and - All conditions must be true
  • or - At least one condition must be true
  • not - Negate the condition

Examples:

Simple Filter:

var filter = new FilterDefinition
{
    Field = "IsActive",
    Operator = FilterOperator.Equal,
    Value = true
};

Nested Logical Filter:

var filter = new FilterDefinition
{
    LogicalOperator = LogicalOperator.And,
    Expressions = new List<FilterDefinition>
    {
        new FilterDefinition { Field = "IsActive", Operator = FilterOperator.Equal, Value = true },
        new FilterDefinition
        {
            LogicalOperator = LogicalOperator.Or,
            Expressions = new List<FilterDefinition>
            {
                new FilterDefinition { Field = "Role", Operator = FilterOperator.Equal, Value = "Admin" },
                new FilterDefinition { Field = "TotalPurchases", Operator = FilterOperator.GreaterThan, Value = 1000 }
            }
        }
    }
};
// Result: IsActive = true AND (Role = 'Admin' OR TotalPurchases > 1000)

Key Methods:

// Validation
Task<ValidationResult> ValidateAsync(CancellationToken ct = default);
ValidationResult Validate();

// Extract all referenced fields (recursive)
IEnumerable<string> GetReferencedFields();

// Deep copy
FilterDefinition Clone();

// Human-readable representation
string ToString();

Field Functions:

Field functions transform field values before comparison using the FieldFunction property:

// @length(name):10 → WHERE LEN([Name]) = 10
var filter = new FilterDefinition
{
    Field = "Name",
    Operator = FilterOperator.Equal,
    Value = 10,
    FieldFunction = new FunctionDefinition
    {
        Name = "length"
    }
};
// @year(createdAt):2024 → WHERE YEAR([CreatedAt]) = 2024
var filter = new FilterDefinition
{
    Field = "CreatedAt",
    Operator = FilterOperator.Equal,
    Value = 2024,
    FieldFunction = new FunctionDefinition
    {
        Name = "year"
    }
};

Built-in Field Functions:

  • String: length, upper, lower, trim
  • Date: year, month, day, date
  • Math: abs, round
  • Collection Aggregates: count, sum, max, min, avg

Collection Aggregate Functions:

Collection aggregates operate on navigation properties using the FieldFunction property with a collection path argument:

// @count(orders)>10 → customers with more than 10 orders
new FilterDefinition
{
    FieldFunction = new FunctionDefinition
    {
        Name = "count",
        Arguments = new List<FunctionArgument>
        {
            new() { Field = "Orders" }
        }
    },
    Operator = FilterOperator.GreaterThan,
    Value = 10
};

// @sum(orders.totalAmount)>1000 → customers with total order value > $1000
new FilterDefinition
{
    FieldFunction = new FunctionDefinition
    {
        Name = "sum",
        Arguments = new List<FunctionArgument>
        {
            new() { Field = "Orders.TotalAmount" }
        }
    },
    Operator = FilterOperator.GreaterThan,
    Value = 1000m
};

// @avg(orders.totalAmount)<500 → customers with average order value < $500
new FilterDefinition
{
    FieldFunction = new FunctionDefinition
    {
        Name = "avg",
        Arguments = new List<FunctionArgument>
        {
            new() { Field = "Orders.TotalAmount" }
        }
    },
    Operator = FilterOperator.LessThan,
    Value = 500m
};

Supported Aggregates:

  • count(collectionPath) - Counts items in a collection
  • sum(collection.field) - Sums numeric values (returns decimal)
  • avg(collection.field) - Averages numeric values (returns decimal?, null for empty collections)
  • max(collection.field) - Finds maximum value (returns field type, null for empty collections)
  • min(collection.field) - Finds minimum value (returns field type, null for empty collections)

Nested Collections:

For nested collections (e.g., orders.items), the query uses .Any() semantics:

// @count(orders.items)>5 → customers with ANY order having more than 5 items
new FilterDefinition
{
    FieldFunction = new FunctionDefinition
    {
        Name = "count",
        Arguments = new List<FunctionArgument>
        {
            new() { Field = "Orders.Items" }
        }
    },
    Operator = FilterOperator.GreaterThan,
    Value = 5
};

See IFieldTransformationRegistry for registering custom field transformations.


Hierarchical Selection

Purpose: Defines the shape of the result set using a unified dictionary syntax. Supports selecting fields from nested objects and child collections.

A select clause is a SelectionDictionary — a case-insensitive map keyed by field name. The type carries its own [JsonConverter(typeof(SelectionDictionaryConverter))] attribute, so the converter applies wherever a SelectionDictionary is serialized without a per-property attribute. (A query result row is a Dictionary<string, object> and is a separate concern from a select clause.)

Structure:

  • Keys: Field names targeting the data source.
  • Values: SelectValue — a readonly struct implicitly constructed from true/false (a scalar leaf inclusion) or a SelectionConfig (a nested selection scope).

SelectionConfig Properties:

  • Select: Nested SelectionDictionary for hierarchical selection.
  • Where: Filter collection elements.
  • OrderBy: Sort collection elements.
  • Limit: Max elements to return from a collection.
  • Offset: Elements to skip in a collection.

Null optional members (where, orderBy, limit, offset, select) are omitted from the serialized output.

Example: Multi-level Selection

{
  "Name": true,
  "Address": {
    "select": {
      "City": true,
      "ZipCode": true
    }
  }
}

Example: Collection Selection with Rules

{
  "Orders": {
    "where": { "field": "Status", "operator": "eq", "value": "Shipped" },
    "orderBy": [{ "field": "Date", "direction": "desc" }],
    "limit": 10,
    "select": {
      "OrderNumber": true,
      "Items": {
        "select": { "ProductName": true }
      }
    }
  }
}

FunctionDefinition

Purpose: Represents a function that transforms field values before comparison.

Location: /src/QueryBuilder.Core/Models/FunctionDefinition.cs

Key Properties:

public record FunctionDefinition
{
    /// <summary>Function name (e.g., "length", "year", "upper")</summary>
    [JsonPropertyName("name")]
    public required string Name { get; init; }

    /// <summary>Function arguments (optional)</summary>
    [JsonPropertyName("arguments")]
    public List<FunctionArgument>? Arguments { get; init; }
}

public record FunctionArgument
{
    /// <summary>Argument value</summary>
    [JsonPropertyName("value")]
    public required object Value { get; init; }

    /// <summary>Argument type (for validation)</summary>
    [JsonPropertyName("type")]
    public string? Type { get; init; }
}

Usage in FilterDefinition:

// String length transformation
new FilterDefinition
{
    Field = "Description",
    Operator = FilterOperator.GreaterThan,
    Value = 100,
    FieldFunction = new FunctionDefinition { Name = "length" }
};

// Date extraction with argument
new FilterDefinition
{
    Field = "Timestamp",
    Operator = FilterOperator.Equal,
    Value = "2024-01-15",
    FieldFunction = new FunctionDefinition
    {
        Name = "date",  // Extract date part from datetime
        Arguments = new List<FunctionArgument>()
    }
};

Note: FunctionDefinition is for field transformations. For runtime function resolution (like @today, @me), see IFilterFunctionResolver.

Specification

Purpose: POCO model for database-storable business rules - enables runtime evaluation of reusable specifications.

Location: /src/QueryBuilder.Core/Models/Specification.cs

Why POCO instead of Specification<T> abstract class?

  • ✅ Database-friendly (no EF complexities with generics)
  • ✅ JSON-serializable
  • ✅ Runtime flexibility (filter defined as data, not code)
  • ✅ Built-in versioning and audit trail
  • ✅ No tight coupling to entity types at design time

Key Properties:

public class Specification
{
    /// <summary>Database primary key</summary>
    public int Id { get; set; }

    /// <summary>Unique identifier (e.g., "ActiveAdultUsers")</summary>
    public string Name { get; set; }

    /// <summary>Human-readable description</summary>
    public string? Description { get; set; }

    /// <summary>Fully qualified CLR type (e.g., "MyApp.Domain.User")</summary>
    public string EntityType { get; set; }

    /// <summary>Data source reference (Schema Registry key)</summary>
    public string SourceName { get; set; }

    /// <summary>The actual business rule</summary>
    public FilterDefinition Filter { get; set; }

    /// <summary>Creation timestamp</summary>
    public DateTimeOffset CreatedAt { get; set; }

    /// <summary>Creator user ID</summary>
    public string? CreatedBy { get; set; }

    /// <summary>Last modification timestamp</summary>
    public DateTimeOffset UpdatedAt { get; set; }

    /// <summary>Last modifier user ID</summary>
    public string? UpdatedBy { get; set; }

    /// <summary>Version number (for tracking changes)</summary>
    public int Version { get; set; }

    /// <summary>Enable/disable flag</summary>
    public bool IsActive { get; set; }

    /// <summary>Custom metadata (extensibility)</summary>
    public Dictionary<string, object>? Metadata { get; set; }
}

Key Methods:

// Validation
Task<ValidationResult> ValidateAsync(CancellationToken ct = default);
ValidationResult Validate();

// Deep copy
Specification Clone();

// Versioning
Specification CreateNewVersion();

Example:

var spec = new Specification
{
    Name = "HighValueCustomers",
    Description = "Active customers with lifetime value > $10,000",
    EntityType = "MyApp.Domain.Customer",
    SourceName = "customers",
    Filter = FilterDefinitionExtensions.And(
        FilterDefinitionExtensions.Equal("IsActive", true),
        FilterDefinitionExtensions.GreaterThan("LifetimeValue", 10000)
    ),
    CreatedBy = "admin",
    Version = 1,
    IsActive = true
};

// Save to database
await specRepository.SaveAsync(spec);

// Use in queries
var expression = SpecificationExpressionBuilder.BuildExpression<Customer>(spec);
var customers = dbContext.Customers.Where(expression).ToList();

Abstractions

IQueryParser

Purpose: Contract for parsing query strings from various formats (JSON, shorthand, etc.) into QueryParseResult.

Location: /src/QueryBuilder.Core/Abstractions/IQueryParser.cs

Key Members:

public interface IQueryParser
{
    /// <summary>Parse a query string into a QueryParseResult</summary>
    Task<QueryParseResult> ParseAsync(string input, CancellationToken cancellationToken = default);

    /// <summary>Validate a query string without fully parsing it</summary>
    Task<ValidationResult> ValidateAsync(string input, CancellationToken cancellationToken = default);
}

ParseAsync returns a QueryParseResult containing success/failure/empty state, the parsed QueryDefinition, and any errors or warnings. Callers inspect the result using the consumer pattern or use the GetQueryOrThrow() convenience extension:

// Convenience pattern (throws CoreException on failure, InvalidOperationException on empty)
var query = (await parser.ParseAsync("status:active AND created:>2024-01-01")).GetQueryOrThrow();

QueryParseResult

Purpose: Structured result of a query parsing operation, carrying success/failure state, the parsed query, and diagnostics.

Location: /src/QueryBuilder.Core/Abstractions/IQueryParser.cs

Properties:

Property Type Description
Kind ParseResultKind Primary discriminator: Success, Empty, or Failure
IsValid bool Computed from Kind — true when Kind is Success or Empty
IsEmpty bool Computed from Kind — true when Kind is Empty
Query QueryDefinition? Parsed query (null on failure or when IsEmpty is true)
Errors IReadOnlyList<string> Validation errors encountered during parsing
Warnings IReadOnlyList<string> Non-fatal warnings
ErrorLine int? Line number where error occurred (if applicable)
ErrorColumn int? Column number where error occurred (if applicable)

Kind is the primary state discriminator. IsValid and IsEmpty are computed convenience properties:

State Kind IsValid IsEmpty Query
Success Success true false non-null QueryDefinition
Empty Empty true true null
Failure Failure false false null

Factory Methods:

// Successful result
QueryParseResult.Success(query, warnings);

// Empty result — parsing succeeded but the query would return zero results
QueryParseResult.Empty("Bare term 'xyz' did not match any searchable field");

// Failed result
QueryParseResult.Failure("Unexpected token at position 5");
QueryParseResult.Failure("Syntax error", line: 1, column: 12);

Instance Methods:

// Formatted error message with position info
string message = result.GetErrorMessage();

Extension Methods (QueryParseResultExtensions):

// Unwrap or throw — returns QueryDefinition on success,
// throws CoreException (ParseFailure) on failure,
// throws InvalidOperationException on empty results
QueryDefinition query = result.GetQueryOrThrow();

// Convert typed result to non-generic (used by parser implementations)
QueryParseResult flat = typedResult.ToQueryParseResult();

GetQueryOrThrow() throws InvalidOperationException on empty results with a message referencing IsEmpty. Callers that may receive empty results must check IsEmpty before calling this method.

Consumer Pattern:

Check Kind (or the computed IsEmpty/IsValid properties) before accessing Query:

var result = await parser.ParseAsync(userInput);

switch (result.Kind)
{
    case ParseResultKind.Empty:
        // Query would return zero results — return empty collection without executing
        return Array.Empty<User>();

    case ParseResultKind.Failure:
        // Parsing failed — surface errors to the caller
        return BadRequest(result.GetErrorMessage());
}

// Kind == Success — safe to access Query
var query = result.Query!;
var users = await executor.ExecuteAsync<User>(query, context);

Alternatively, use the computed convenience properties (IsEmpty, IsValid) which derive from Kind:

var result = await parser.ParseAsync(userInput);

if (result.IsEmpty)
    return Array.Empty<User>();

if (!result.IsValid)
    return BadRequest(result.GetErrorMessage());

var query = result.Query!;

Endpoint layer integration: QueryBuilder.Endpoints handles IsEmpty automatically — when the parser returns an empty result, the execution service short-circuits with an empty PagedResult (zero items, TotalCount = 0) without executing a database query. The consumer pattern above is for callers using IQueryParser directly.

Generic Variant: QueryParseResult<TErrorCode> carries QueryParseError<TErrorCode> instances with structured error codes and location information. Parser implementations (e.g., JsonQueryParser, ShorthandQueryParser) use this internally, then convert to the non-generic QueryParseResult via ToQueryParseResult() for the IQueryParser contract. The generic variant has the same Kind, IsValid, and IsEmpty properties, and the same Success(), Empty(), and Failure() factory methods, accepting QueryParseError<TErrorCode> items instead of strings.


IExecutionStrategy

Purpose: Strategy pattern interface for executing queries across different data sources.

Location: /src/QueryBuilder.Core/Abstractions/IExecutionStrategy.cs

Key Members:

public interface IExecutionStrategy
{
    /// <summary>Strategy name (e.g., "EntityFramework", "InMemory")</summary>
    string Name { get; }

    /// <summary>Priority for strategy selection (higher = preferred)</summary>
    int Priority { get; }

    /// <summary>Supported data source type</summary>
    DataSourceType SupportedDataSourceType { get; }

    /// <summary>Check if this strategy can execute the query</summary>
    bool CanExecute(QueryDefinition query, IExecutionContext? context = null);

    /// <summary>
    /// Execute the query and return results as dynamic dictionaries.
    /// All queries return Dictionary&lt;string, object&gt; for dynamic field access.
    /// </summary>
    Task<IQueryResult<Dictionary<string, object>>> ExecuteAsync(
        QueryDefinition query,
        IExecutionContext? context = null);

    /// <summary>Estimate query cost/performance</summary>
    Task<IEstimatedMetrics> EstimateAsync(
        QueryDefinition query,
        IExecutionContext? context = null);

    /// <summary>Validate query against this strategy</summary>
    ValidationResult Validate(QueryDefinition query);
}

Implementations:

  • EntityFrameworkExecutionStrategy (QueryBuilder.EntityFramework) - Translates to EF Core IQueryable
  • InMemoryExecutionStrategy (QueryBuilder.InMemory) - Compiles to LINQ expressions

Example Implementation:

public class CustomExecutionStrategy : IExecutionStrategy
{
    public string Name => "CustomDataSource";
    public int Priority => 100;
    public DataSourceType SupportedDataSourceType => DataSourceType.Custom;

    public bool CanExecute(QueryDefinition query, IExecutionContext? context = null)
    {
        // Check if we can handle this query
        return query.From?.StartsWith("custom:") == true;
    }

    public async Task<IQueryResult<Dictionary<string, object>>> ExecuteAsync(
        QueryDefinition query,
        IExecutionContext? context = null)
    {
        // Execute query against custom data source
        var data = await FetchDataAsync(query);

        return new QueryResult<Dictionary<string, object>>
        {
            Data = data,
            Success = true,
            TotalCount = data.Count(),
            ReturnedCount = data.Count()
        };
    }

    // Implement other methods...
}

// Register in DI
services.AddSingleton<IExecutionStrategy, CustomExecutionStrategy>();

IDataSourceProvider

Purpose: Abstraction for in-memory data source providers.

Location: /src/QueryBuilder.Core/Abstractions/IDataSourceProvider.cs

Key Interfaces:

/// <summary>Base provider interface</summary>
public interface IDataSourceProvider
{
    /// <summary>Unique identifier (e.g., "cachedUsers")</summary>
    string SourceName { get; }

    /// <summary>UI-friendly display name</summary>
    string DisplayName { get; }

    /// <summary>Optional description</summary>
    string? Description { get; }

    /// <summary>Data source type</summary>
    DataSourceType Type { get; }
}

/// <summary>Typed provider for specific entity type</summary>
public interface IDataSourceProvider<T> : IDataSourceProvider
    where T : class
{
    /// <summary>Retrieve data for query execution</summary>
    Task<IEnumerable<T>> GetDataAsync(CancellationToken cancellationToken = default);
}

Example Implementation:

public sealed class CachedUsersProvider : IDataSourceProvider<User>
{
    private readonly IMemoryCache _cache;
    private readonly IUserRepository _userRepository;

    public string SourceName => "cachedUsers";
    public string DisplayName => "Cached Users";
    public string? Description => "Users from in-memory cache with 5-minute expiration";
    public DataSourceType Type => DataSourceType.InMemory;

    public CachedUsersProvider(IMemoryCache cache, IUserRepository userRepository)
    {
        _cache = cache;
        _userRepository = userRepository;
    }

    public async Task<IEnumerable<User>> GetDataAsync(CancellationToken ct = default)
    {
        return await _cache.GetOrCreateAsync("users", async entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
            return await _userRepository.GetAllAsync(ct);
        }) ?? Enumerable.Empty<User>();
    }
}

// Register in DI
services.AddUniversalQueryBuilder(options =>
{
    options.AddDataProvider<CachedUsersProvider>(ServiceLifetime.Scoped);
});

IFieldTransformationRegistry

Purpose: Registry for field transformation functions that convert field expressions to SQL/LINQ equivalents.

Location: /src/QueryBuilder.Core/Registry/IFieldTransformationRegistry.cs

Key Members:

public interface IFieldTransformationRegistry
{
    /// <summary>Register a field transformation</summary>
    void Register(string name, IFieldTransformation transformation);

    /// <summary>Get transformation by name</summary>
    IFieldTransformation? Get(string name);

    /// <summary>Check if transformation exists</summary>
    bool Contains(string name);

    /// <summary>Get all registered transformations</summary>
    IReadOnlyDictionary<string, FieldTransformationMetadata> GetAll();
}

public interface IFieldTransformation
{
    /// <summary>Transform field expression to SQL</summary>
    string TransformToSql(string fieldExpression, ISqlDialect dialect);

    /// <summary>Transform field expression to LINQ</summary>
    Expression<Func<T, object>> TransformToExpression<T>(
        Expression<Func<T, object>> fieldExpression);
}

Built-in Transformations:

// Automatically registered during DI setup
BuiltInFieldTransformations.RegisterAll(registry);

String Functions:

  • length - String length (SQL: LEN(), LINQ: .Length)
  • upper - Uppercase (SQL: UPPER(), LINQ: .ToUpper())
  • lower - Lowercase (SQL: LOWER(), LINQ: .ToLower())
  • trim - Trim whitespace (SQL: TRIM(), LINQ: .Trim())

Date Functions:

  • year - Extract year (SQL: YEAR(), LINQ: .Year)
  • month - Extract month (SQL: MONTH(), LINQ: .Month)
  • day - Extract day (SQL: DAY(), LINQ: .Day)
  • date - Date-only (SQL: CAST(... AS DATE), LINQ: .Date)

Math Functions:

  • abs - Absolute value (SQL: ABS(), LINQ: Math.Abs())
  • round - Round (SQL: ROUND(), LINQ: Math.Round())

Custom Transformation Example:

public class SubstringTransformation : IFieldTransformation
{
    public string TransformToSql(string fieldExpression, ISqlDialect dialect)
    {
        // SQL: SUBSTRING(fieldExpression, 1, 10)
        return $"SUBSTRING({fieldExpression}, 1, 10)";
    }

    public Expression<Func<T, object>> TransformToExpression<T>(
        Expression<Func<T, object>> fieldExpression)
    {
        // LINQ: entity => entity.Field.Substring(0, 10)
        var memberExpr = (MemberExpression)fieldExpression.Body;
        var substringMethod = typeof(string).GetMethod("Substring", new[] { typeof(int), typeof(int) });
        var substringCall = Expression.Call(memberExpr, substringMethod,
            Expression.Constant(0), Expression.Constant(10));

        return Expression.Lambda<Func<T, object>>(
            Expression.Convert(substringCall, typeof(object)),
            fieldExpression.Parameters);
    }
}

// Register custom transformation
var registry = serviceProvider.GetRequiredService<IFieldTransformationRegistry>();
registry.Register("substring", new SubstringTransformation());

Usage in Queries:

// Shorthand: @length(name):10
var query = (await parser.ParseAsync("@length(name):10")).GetQueryOrThrow();

// Programmatic
var filter = new FilterDefinition
{
    Field = "Name",
    Operator = FilterOperator.Equal,
    Value = 10,
    FieldFunction = new FunctionDefinition { Name = "length" }
};

IFilterFunctionResolver

Purpose: Resolves runtime @function references in a query to concrete values across the whole query graph — WHERE, HAVING, nested projection filters (SelectionConfig.Where), ordering function filters, CASE-WHEN conditions, and subqueries.

Location: /src/QueryBuilder.Core/Abstractions/IFilterFunctionResolver.cs

Key Members:

public interface IFilterFunctionResolver
{
    /// <summary>
    /// Resolve every @function reference in the query by deep-cloning the input and resolving the
    /// clone, which it returns. The input is never mutated.
    /// </summary>
    Task<QueryDefinition> ResolveAsync(
        QueryDefinition query,
        IFilterResolutionContext context,
        CancellationToken cancellationToken = default);

    /// <summary>
    /// Check whether any @function reference requires resolution anywhere in the query graph.
    /// </summary>
    bool RequiresResolution(QueryDefinition query);
}

Clone-then-resolve: ResolveAsync deep-clones the input (cycle-safe QueryDefinition.Clone()) and resolves @-references in the clone, leaving the caller's graph pristine. Because the input is never mutated, a query template may be cached and reused across users/requests — each call resolves an independent clone, so one caller's resolved values can never leak into another's. RequiresResolution(query) scans the same whole-graph clause set as the resolver; use it to gate before calling ResolveAsync.

Resolved-value guard: a value function must resolve to a concrete, non-null value that is not @-leading. The resolver throws QueryExecutionException when a function resolves to null (which would otherwise become a silent field = NULL filter) or to a string beginning with '@' (a reserved sentinel for function references — rejecting it keeps resolution idempotent, so a resolved value can never be re-detected as a reference). The guard applies uniformly to single values, IN value lists, and BETWEEN range bounds.

Example Resolution Flow:

// Before resolution
var query = new QueryDefinition
{
    From = "orders",
    Where = new FilterDefinition
    {
        Field = "Created",
        Operator = FilterOperator.Equal,
        Value = "@last_7_days"  // ← String reference to function
    }
};

// Resolve
var resolver = serviceProvider.GetRequiredService<IFilterFunctionResolver>();
var context = new FilterResolutionContext
{
    ReferenceTime = DateTimeOffset.UtcNow,
    Features = new[] { new UserContextFeature(currentUser.Id) }
};

var resolvedQuery = await resolver.ResolveAsync(query, context);

// After resolution
// where.Operator = FilterOperator.Between
// where.Range = { Min = "2025-10-18T00:00:00Z", Max = "2025-10-25T00:00:00Z" }

IFunctionExecutionFeatureProvider

Purpose: Host-supplied seam that contributes a single IFunctionExecutionFeature for the current request. Providers populate FilterResolutionContext.Features so value functions like @me (which require UserContextFeature) resolve correctly.

Location: /src/QueryBuilder.Core/Abstractions/IFunctionExecutionFeatureProvider.cs

Key Members:

public interface IFunctionExecutionFeatureProvider
{
    ValueTask<IFunctionExecutionFeature?> GetFeatureAsync(CancellationToken cancellationToken);
}

Hosts register one provider per feature type. The shared IFunctionExecutionFeatureCollector invokes every registered provider once per request and concatenates the non-null results. Duplicate feature types follow last-write-wins semantics per ValueResolutionContext.

public sealed class CurrentActorUserContextProvider(ICurrentActorService actor)
    : IFunctionExecutionFeatureProvider
{
    public async ValueTask<IFunctionExecutionFeature?> GetFeatureAsync(CancellationToken ct)
    {
        var id = await actor.GetIdAsync(ct);
        return id is null ? null : new UserContextFeature(id);
    }
}

services.AddScoped<IFunctionExecutionFeatureProvider, CurrentActorUserContextProvider>();

Lifetime: Register as Scoped or Transient. Singleton registration is unsupported — providers that take scoped dependencies (IHttpContextAccessor, request-scoped actor services) fail DI validation with a captive-dependency error. The collector itself is scoped.

Idempotency: GetFeatureAsync is invoked at most once per request per provider. FunctionExecutionFeatureCollector caches the materialized list on its scoped instance, so the validator, the execution service, the EF QueryableBuilder, BulkLookupService, and DistinctValuesService all share a single materialization. Side-effecting providers (audit log, metrics, remote lookups) therefore run exactly once per request without needing their own request-scoped cache.

The collector is consumed by QueryRequestValidator, QueryExecutionService, and QueryableBuilder (EF path), so all three execution paths see the same features.


IUserAccessor

Purpose: Resolves the ClaimsPrincipal for the current request without coupling consumers to IHttpContextAccessor. Used by column authorization, row-level filters, and any service that needs the current principal.

Location: /src/QueryBuilder.Core/Abstractions/IUserAccessor.cs

Key Members:

public interface IUserAccessor
{
    ClaimsPrincipal User { get; }
}

Implementations return an unauthenticated ClaimsPrincipal when no request is in flight (background work, unit-test contexts) so consumers never need to null-coalesce.

The Endpoints layer registers HttpContextUserAccessor over IHttpContextAccessor. Non-HTTP consumers can register their own implementation:

services.AddScoped<IUserAccessor, BackgroundJobUserAccessor>();

AnonymousUserAccessor (in QueryBuilder.Core.Services) returns an unauthenticated principal for every call. AddQueryBuilderEntityFramework() registers it via TryAdd so non-HTTP hosts (background workers, integration tests, console tools) get a working IUserAccessor automatically. AddQueryBuilderEndpoints() registers HttpContextUserAccessor and removes the anonymous fallback if it was registered first, so HTTP hosts that call both helpers end up with HttpContextUserAccessor regardless of order. A host that registers its own IUserAccessor (anything other than AnonymousUserAccessor) keeps it — neither helper overrides an explicit custom registration:

services.AddScoped<IUserAccessor, MyBackgroundJobAccessor>();  // explicit — survives both helpers
services.AddQueryBuilderEntityFramework();
services.AddQueryBuilderEndpoints();

AnonymousUserAccessor is a deliberate "fail-open to unauthenticated" choice for non-HTTP scenarios: a missing accessor would break every row-level filter at runtime, so the EF helper provides a working default rather than a hard failure. Row-level filters that need to deny anonymous callers should branch on ctx.User.Identity?.IsAuthenticated.


Execution System

QueryExecutor

Purpose: Simplified query execution API for dynamic projections and aggregations. Orchestrates query execution by coordinating validation, function resolution, and strategy execution.

Location: /src/QueryBuilder.Core/Services/QueryExecutor.cs

⚠️ Important Requirement: QueryExecutor requires explicit SELECT clause. For entity queries without SELECT, use EF Core directly.

When to Use QueryExecutor vs EF Core

✅ Use QueryExecutor For:

  • Dynamic field selection (runtime SELECT with ProjectionDefinition[])
  • Aggregation queries (GROUP BY, HAVING, aggregate functions)
  • Queries with @function resolution (@today, @last_7_days, @me)
  • Cross-strategy queries (works with any IExecutionStrategy)

✅ Use EF Core Directly For:

  • Entity queries with known shape (no explicit SELECT needed)
  • Simple WHERE/ORDER BY/LIMIT queries
  • Typed entity results (List<Order>, List<User>)
  • Navigation property includes
Execution Pipeline
1. Validate SELECT Requirement (throws CoreException if missing)
       ↓
2. Validate Query Structure (optional, via delegate - typically Schema Registry)
       ↓
3. Resolve @ Functions (temporal/contextual expressions → concrete values)
       ↓
4. Select Strategy (via ExecutionStrategyFactory)
       ↓
5. Execute Query
       ↓
6. Return Results (Dictionary<string, object>)
Constructor
public QueryExecutor(
    IFilterFunctionResolver filterResolver,
    IFilterFunctionRegistry functionRegistry,
    IExecutionStrategyFactory strategyFactory,
    ILogger<QueryExecutor> logger,
    Func<QueryDefinition, CancellationToken, Task<ValidationResult>>? validateQuery = null)
Key Methods
/// <summary>
/// Execute a query with dynamic projection or aggregation.
/// Requires explicit SELECT clause.
/// </summary>
/// <param name="query">Query definition with SELECT</param>
/// <param name="context">Execution context (optional, defaults to new instance)</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Query results as Dictionary<string, object></returns>
/// <exception cref="CoreException">Thrown when SELECT is missing</exception>
Task<IQueryResult<Dictionary<string, object>>> ExecuteAsync(
    QueryDefinition query,
    IExecutionContext? context = null,
    CancellationToken cancellationToken = default);

/// <summary>
/// Execute a specification.
/// Note: Specifications without SELECT will fail validation.
/// </summary>
Task<IQueryResult<Dictionary<string, object>>> ExecuteSpecificationAsync(
    Specification specification,
    IExecutionContext? context = null,
    CancellationToken cancellationToken = default);
Usage Examples

Example 1: Dynamic Projection (Use QueryExecutor)

// Dynamic field selection at runtime
var query = new QueryDefinition
{
    From = "users",
    Select =
    [
        new ProjectionDefinition { Field = "id" },
        new ProjectionDefinition { Field = "firstName" },
        new ProjectionDefinition { Field = "email" }
    ],
    Where = FilterDefinitionExtensions.Equal("isActive", true),
    OrderBy = [new OrderingDefinition { Field = "lastName", Direction = "asc" }],
    Limit = 50
};

// ExecutionContext is optional (defaults to new instance)
var results = await executor.ExecuteAsync(query);

// Results are Dictionary<string, object> for runtime flexibility
foreach (var user in results.Data)
{
    Console.WriteLine($"User: {user["firstName"]} {user["email"]}");
}

Example 2: Aggregation Query (Use QueryExecutor)

// Aggregation with GROUP BY
var query = new QueryDefinition
{
    From = "employees",
    Select =
    [
        new ProjectionDefinition { Field = "department" },
        new ProjectionDefinition
        {
            Function = new FunctionDefinition { Name = "count" },
            Alias = "EmployeeCount"
        },
        new ProjectionDefinition
        {
            Function = new FunctionDefinition
            {
                Name = "avg",
                Arguments = [new FunctionArgument { Field = "salary" }]
            },
            Alias = "AvgSalary"
        }
    ],
    GroupBy = [new ProjectionDefinition { Field = "department" }],
    Having = FilterDefinitionExtensions.GreaterThan("EmployeeCount", 10)
};

var results = await executor.ExecuteAsync(query);
// Returns: [{department: "IT", EmployeeCount: 25, AvgSalary: 85000}, ...]

Example 3: Entity Query (Use EF Core Directly)

// For entity queries without explicit SELECT, use EF Core directly
// Do NOT use QueryExecutor for this - it will throw CoreException

// Good: EF Core for entity queries
var orders = await dbContext.Orders
    .Where(o => o.TotalAmount > 1000)
    .OrderByDescending(o => o.OrderDate)
    .Include(o => o.Customer)
    .Take(100)
    .ToListAsync();

// Bad: QueryExecutor without SELECT throws CoreException
var query = new QueryDefinition
{
    From = "orders",
    // No SELECT - this throws!
    Where = FilterDefinitionExtensions.GreaterThan("totalAmount", 1000)
};
// Throws: "QueryDefinition.Select is required for QueryExecutor.
//          For entity queries without explicit SELECT, use EF Core directly..."

Example 4: Custom ExecutionContext

// Advanced: Custom execution context for specific scenarios
var context = ExecutionContext.Create()
    .WithSecurityContext(SecurityContext.ForUser("user123", "read:orders"))
    .WithCaching(false)  // Disable caching for this query
    .WithTimeout(TimeSpan.FromSeconds(30))
    .WithProperty("ReferenceTime", DateTimeOffset.UtcNow)  // For @function resolution
    .Build();

var results = await executor.ExecuteAsync(query, context);
Service Registration
// Automatic registration via QueryBuilder.Extensions
builder.Services.AddQueryBuilder(options =>
{
    // QueryExecutor is automatically registered with all dependencies
});

// Manual registration (if needed)
services.AddScoped<QueryExecutor>();
services.AddSingleton<IFilterFunctionResolver, FilterFunctionResolver>();
services.AddSingleton<IFilterFunctionRegistry, FilterFunctionRegistry>();
services.AddScoped<IExecutionStrategyFactory, ExecutionStrategyFactory>();
Error Handling
try
{
    var results = await executor.ExecuteAsync(query);
}
catch (CoreException ex) when (ex.ErrorCode == CoreErrorCode.InvalidConfiguration)
{
    // Missing SELECT clause or invalid query structure
    Console.WriteLine($"Query validation failed: {ex.Message}");
    // Message includes guidance: "use EF Core directly..."
}
catch (InvalidOperationException ex)
{
    // No execution strategy available for data source
    Console.WriteLine($"Strategy selection failed: {ex.Message}");
}
catch (QueryValidationException ex)
{
    // Schema validation failed (if validation delegate provided)
    Console.WriteLine($"Schema validation failed: {ex.Message}");
}

ExecutionStrategyFactory

Purpose: Factory for managing and selecting execution strategies based on query requirements.

Location: /src/QueryBuilder.Core/Factories/ExecutionStrategyFactory.cs

Selection Algorithm:

  1. Check for preferred strategy hint (from ExecutionContext)
  2. Filter strategies where CanExecute() returns true
  3. Sort by Priority (highest first)
  4. Return highest priority strategy

Key Methods:

public interface IExecutionStrategyFactory
{
    /// <summary>Get all registered strategies</summary>
    IEnumerable<IExecutionStrategy> GetStrategies();

    /// <summary>Select best strategy for query</summary>
    IExecutionStrategy? SelectStrategy(
        QueryDefinition query,
        IExecutionContext? context = null);

    /// <summary>Register a new strategy</summary>
    void RegisterStrategy(IExecutionStrategy strategy);

    /// <summary>Get strategy by name</summary>
    IExecutionStrategy? GetStrategy(string name);
}

Example:

// Register strategies
var factory = serviceProvider.GetRequiredService<IExecutionStrategyFactory>();
factory.RegisterStrategy(new EntityFrameworkExecutionStrategy());
factory.RegisterStrategy(new InMemoryExecutionStrategy());

// Select strategy
var query = new QueryDefinition { From = "users" };
var strategy = factory.SelectStrategy(query, context);
// Returns EntityFrameworkExecutionStrategy if "users" is EF source, InMemoryExecutionStrategy if in-memory

ExecutionContext

Purpose: Context information passed through query execution pipeline.

Location: /src/QueryBuilder.Core/Execution/ExecutionContext.cs

Key Properties:

public interface IExecutionContext
{
    /// <summary>Security context (user, permissions)</summary>
    ISecurityContext? SecurityContext { get; }

    /// <summary>Preferred strategy hint</summary>
    string? PreferredStrategy { get; }

    /// <summary>Include execution metadata in result</summary>
    bool IncludeMetadata { get; }

    /// <summary>Enable query result caching</summary>
    bool UseCaching { get; }

    /// <summary>Query timeout</summary>
    TimeSpan? Timeout { get; }

    /// <summary>Cancellation token</summary>
    CancellationToken CancellationToken { get; }

    /// <summary>Extensibility properties</summary>
    IDictionary<string, object?> Properties { get; }

    /// <summary>Data source identifier</summary>
    string? DataSource { get; }

    /// <summary>Track performance metrics</summary>
    bool TrackPerformance { get; }

    /// <summary>Custom resilience policy (e.g., Polly pipeline)</summary>
    object? CustomResiliencePolicy { get; }
}

Builder Pattern:

var context = ExecutionContext.Create()
    .WithSecurityContext(SecurityContext.ForUser("user123", "read:orders"))
    .WithPreferredStrategy("EntityFramework")
    .WithMetadata(includeMetadata: true)
    .WithCaching(useCaching: true)
    .WithTimeout(TimeSpan.FromSeconds(30))
    .WithCancellationToken(cancellationToken)
    .WithProperty("ReferenceTime", DateTimeOffset.UtcNow)
    .WithDataSource("DefaultConnection")
    .WithPerformanceTracking(trackPerformance: true)
    .Build();

Static Helpers:

// Minimal context for testing
var context = ExecutionContext.ForTesting();

Extension Methods

FilterDefinitionExtensions

Purpose: Fluent API for building filter expressions.

Location: /src/QueryBuilder.Core/Extensions/FilterDefinitionExtensions.cs

Static Logical Operators

Combine multiple filters:

// AND - All conditions must be true
var filter = FilterDefinitionExtensions.And(
    FilterDefinitionExtensions.Equal("IsActive", true),
    FilterDefinitionExtensions.GreaterThan("Age", 18),
    FilterDefinitionExtensions.Equal("Role", "User")
);

// OR - At least one condition must be true
var filter = FilterDefinitionExtensions.Or(
    FilterDefinitionExtensions.Equal("Role", "Admin"),
    FilterDefinitionExtensions.GreaterThan("TotalPurchases", 1000)
);

// NOT - Negate condition
var filter = FilterDefinitionExtensions.Not(
    FilterDefinitionExtensions.Equal("IsDeleted", true)
);
Instance Logical Operators (Chaining)

Build filters fluently:

var filter = FilterDefinitionExtensions.Equal("IsActive", true)
    .And(FilterDefinitionExtensions.GreaterThan("Age", 18))
    .Or(FilterDefinitionExtensions.Equal("Role", "Admin"));

// Negate entire filter
var negated = filter.AsNegated();
Comparison Operators
// Equality
FilterDefinitionExtensions.Equal("Status", "Active")
FilterDefinitionExtensions.NotEqual("IsDeleted", true)

// Ordering
FilterDefinitionExtensions.GreaterThan("Age", 18)
FilterDefinitionExtensions.GreaterThanOrEqual("Price", 100)
FilterDefinitionExtensions.LessThan("Quantity", 10)
FilterDefinitionExtensions.LessThanOrEqual("Stock", 5)
String Operators
// Substring matching
FilterDefinitionExtensions.Contains("Name", "John")

// Prefix matching
FilterDefinitionExtensions.StartsWith("Email", "admin")

// Suffix matching
FilterDefinitionExtensions.EndsWith("Email", "@company.com")
List Operators
// IN operator - value in list
FilterDefinitionExtensions.In("Status", "Active", "Pending", "InProgress")
FilterDefinitionExtensions.In("Priority", 1, 2, 3)

Empty values: IN and NOT IN require at least one element in Values. Passing Values: [] or omitting the property returns a validation error with an actionable message. To match all values, remove the filter entirely instead of passing an empty array.

Range Operators
// BETWEEN operator
FilterDefinitionExtensions.Between("Price", 10, 100)
FilterDefinitionExtensions.Between("Age", 18, 65)
Flags Enum Operators

For CLR enums marked with [Flags], Equal, NotEqual, In, and NotIn use bitwise membership semantics instead of exact equality:

// Equal → membership check: (Access & Read) == Read
FilterDefinitionExtensions.Equal("Access", "Read")
// Matches records with Read flag set (including Read|Write, Read|Execute, etc.)

// In → any-of membership: Has(Read) || Has(Write)
FilterDefinitionExtensions.In("Access", "Read", "Write")
// Matches records with Read OR Write flag set

// NotEqual → negated membership: NOT((Access & Read) == Read)
FilterDefinitionExtensions.NotEqual("Access", "Read")
// Matches records WITHOUT the Read flag

Zero-value flags use direct equality instead of bitwise membership: field == 0 / field != 0. Zero detection is value-based — any member with numeric value 0 triggers this behavior, regardless of its name (None, Unknown, Default, etc.). Zero-value flags are rejected in multi-value (In/NotIn) contexts.

Null and empty semantics for flags enum fields:

  • IsNull / IsNotNull check CLR nullability only. For non-nullable types, they produce constant false / true (vacuous but not an error).
  • @empty / @notEmpty check for absence/presence of flags: zero means "empty" (no flags set). These are name-independent — they always compare against numeric 0. For nullable types, @empty also matches null.

Non-flags enums continue to use exact equality and exact IN. The semantic switch from exact equality to bitwise membership is determined at expression build time based on the target entity's CLR property type.

Null Operators
// IS NULL
FilterDefinitionExtensions.IsNull("DeletedAt")

// IS NOT NULL
FilterDefinitionExtensions.IsNotNull("UpdatedAt")
Complex Example
var filter = FilterDefinitionExtensions.And(
    // Active users
    FilterDefinitionExtensions.Equal("IsActive", true),

    // VIP or high spenders
    FilterDefinitionExtensions.Or(
        FilterDefinitionExtensions.Equal("Status", "VIP"),
        FilterDefinitionExtensions.GreaterThan("TotalSpent", 1000)
    ),

    // Not deleted
    FilterDefinitionExtensions.Not(
        FilterDefinitionExtensions.Equal("IsDeleted", true)
    ),

    // Recent activity
    FilterDefinitionExtensions.IsNotNull("LastLoginAt")
);

// Result: IsActive = true
//     AND (Status = 'VIP' OR TotalSpent > 1000)
//     AND NOT (IsDeleted = true)
//     AND LastLoginAt IS NOT NULL

QueryDefinitionExtensions

Purpose: Extension methods for manipulating QueryDefinition objects.

Location: /src/QueryBuilder.Core/Extensions/QueryDefinitionExtensions.cs

Key Methods:

// Serialization
string ToJson(this QueryDefinition query, bool indented = false);
QueryDefinition FromJson(string json);

// Cloning
QueryDefinition Clone(this QueryDefinition query);

// Fluent manipulation
QueryDefinition WithFilter(this QueryDefinition query, FilterDefinition filter);
QueryDefinition WithPagination(this QueryDefinition query, int pageNumber, int pageSize);
QueryDefinition WithOrdering(this QueryDefinition query, OrderingDefinition ordering);

Example:

var query = new QueryDefinition
{
    From = "users"
}
.WithFilter(FilterDefinitionExtensions.Equal("IsActive", true))
.WithPagination(pageNumber: 1, pageSize: 50)
.WithOrdering(new OrderingDefinition
{
    Field = "LastName",
    Direction = SortDirection.Ascending
});

Specification Pattern

SpecificationExpressionBuilder

Purpose: Builds runtime LINQ expressions from POCO Specification models.

Location: /src/QueryBuilder.Core/Specifications/Builders/SpecificationExpressionBuilder.cs

Key Methods

1. BuildExpression<T>() - Compile-time type safety

public static Expression<Func<T, bool>> BuildExpression<T>(Specification specification)
    where T : class

Example:

var spec = new Specification
{
    Name = "ActiveAdultUsers",
    EntityType = "MyApp.Domain.User",
    Filter = FilterDefinitionExtensions.And(
        FilterDefinitionExtensions.Equal("IsActive", true),
        FilterDefinitionExtensions.GreaterThanOrEqual("Age", 18)
    )
};

var expression = SpecificationExpressionBuilder.BuildExpression<User>(spec);
// Result: user => user.IsActive == true && user.Age >= 18

var users = dbContext.Users.Where(expression).ToList();

2. BuildCompiledFunctionAsync() - Cached compilation (Recommended)

public static async ValueTask<Func<T, bool>> BuildCompiledFunctionAsync<T>(
    Specification specification,
    Func<Expression<Func<T, bool>>, ValueTask<Func<T, bool>>>? compileFunc = null)
    where T : class

Example with Expression Compiler:

var compiler = serviceProvider.GetRequiredService<IExpressionCompiler>();

var func = await SpecificationExpressionBuilder.BuildCompiledFunctionAsync<User>(
    spec,
    expr => compiler.CompileAsync(expr)  // 10-40x faster with caching
);

var isMatch = func(userInstance);

3. ApplySpecification() - Apply to queryable

public static IQueryable<T> ApplySpecification<T>(
    IQueryable<T> source,
    Specification specification)
    where T : class

Example:

var query = SpecificationExpressionBuilder.ApplySpecification(
    dbContext.Users,
    highValueCustomersSpec
);

var customers = await query.ToListAsync();

4. IsSatisfiedBy() - Check single entity

public static bool IsSatisfiedBy<T>(T entity, Specification specification)
    where T : class

Example:

var user = new User { IsActive = true, Age = 25 };
var matches = SpecificationExpressionBuilder.IsSatisfiedBy(user, activeAdultsSpec);
// Returns: true

5. CombineWithAnd() / CombineWithOr() - Compose specifications

public static Expression<Func<T, bool>> CombineWithAnd<T>(params Specification[] specifications)
    where T : class

public static Expression<Func<T, bool>> CombineWithOr<T>(params Specification[] specifications)
    where T : class

Example:

var activeSpec = new Specification { /* IsActive = true */ };
var adultSpec = new Specification { /* Age >= 18 */ };
var vipSpec = new Specification { /* Status = 'VIP' */ };

// Combine: IsActive AND Age >= 18 AND Status = 'VIP'
var expression = SpecificationExpressionBuilder.CombineWithAnd<User>(
    activeSpec,
    adultSpec,
    vipSpec
);

var users = dbContext.Users.Where(expression).ToList();
Expression Building Process

How it works:

  1. BuildExpressionRecursive() - Recursively traverse FilterDefinition tree
  2. BuildLogicalExpression() - Handle AND/OR/NOT operators
  3. BuildFilterExpression() - Handle field comparisons (eq, gt, like, etc.)
  4. BuildPropertyAccess() - Navigate property paths (supports nested: "Customer.Address.City")
  5. ConvertValue() - Type conversion and nullable handling

Supported Operators:

FilterOperator Expression
Equal Expression.Equal(property, constant)
NotEqual Expression.NotEqual(property, constant)
GreaterThan Expression.GreaterThan(property, constant)
GreaterThanOrEqual Expression.GreaterThanOrEqual(property, constant)
LessThan Expression.LessThan(property, constant)
LessThanOrEqual Expression.LessThanOrEqual(property, constant)
Contains Expression.Call(property, "Contains", constant)
StartsWith Expression.Call(property, "StartsWith", constant)
EndsWith Expression.Call(property, "EndsWith", constant)
In Expression.Call(list, "Contains", property)
Between property >= min && property <= max

Function Resolution

FilterFunctionResolver

Purpose: Resolves @function references to concrete values across the whole query graph at query execution time.

Location: /src/QueryBuilder.Core/Services/FilterFunctionResolver.cs

How It Works

Resolution Flow:

1. Deep-clone the input query (cycle-safe Clone); resolve in the CLONE, leaving the input pristine
       ↓
2. Walk the whole graph (WHERE, HAVING, projection filters, ordering function filters,
   CASE conditions, subqueries) for filter values starting with "@"
       ↓
3. Look up function in FilterFunctionRegistry
       ↓
4. Execute function with ValueResolutionContext
       ↓
5. Transform the filter node based on result type:
   - DateRangeResult → BETWEEN operator
   - NumericRangeResult → BETWEEN operator
   - ScalarResult → Keep original operator with resolved value
       ↓
6. Return the resolved clone (input never mutated)

Shared scan/rewrite primitives: the resolver does not own its own traversal. QueryDefinitionWalker.ContainsFilter (used by RequiresResolution) and QueryDefinitionWalker.RewriteFiltersAsync (used by ResolveAsync) are the single graph walk that visits every filter-bearing clause — Where, Having, nested SelectionConfig.Where (recursively), ordering FunctionDefinition.Filter, CASE-WHEN conditions, and subqueries. AtFunctionReference is the single definition of what counts as an @-reference (a value whose string form starts with '@'), so detection and rewrite agree on the same nodes. Because a leading '@' is a reserved sentinel, filter and expression values cannot carry a literal @-prefixed string — there is no escape.

Example:

// Input: Query with @function
var query = new QueryDefinition
{
    From = "orders",
    Where = new FilterDefinition
    {
        Field = "Created",
        Operator = FilterOperator.Equal,
        Value = "@last_7_days"
    }
};

// Resolve
var resolver = serviceProvider.GetRequiredService<IFilterFunctionResolver>();
var context = new FilterResolutionContext
{
    ReferenceTime = DateTimeOffset.UtcNow
};

var resolvedQuery = await resolver.ResolveAsync(query, context);

// Output: Resolved query with BETWEEN
// {
//   "field": "Created",
//   "operator": "between",
//   "range": { "min": "2025-10-18T00:00:00Z", "max": "2025-10-25T00:00:00Z" }
// }
Operator Preservation

When a function returns a range (DateRangeResult or NumericRangeResult), the resolver preserves operator semantics:

Original Operator Range Result Transformation
Equal (:) BETWEEN (entire range)
NotEqual (!=) NOT(BETWEEN)
GreaterThan (>) > range.End (beyond the range)
GreaterThanOrEqual (>=) >= range.Start (start of range or later)
LessThan (<) < range.Start (before the range)
LessThanOrEqual (<=) ⇐ range.End (end of range or earlier)

DateRangeCalculator

Purpose: Shared date range calculation utility used by RelativeTimeParser (which powers date macro value functions) and date expression resolvers in QueryBuilder.SchemaRegistry. Provides half-open [start, end) interval calculations for all supported date macros.

Location: /src/QueryBuilder.Core/Services/DateRangeCalculator.cs

API:

All methods are static, accept a DateTimeOffset now reference time, and return (DateTimeOffset Start, DateTimeOffset End) tuples representing half-open intervals.

Method Interval
TodayRange [start-of-today, start-of-tomorrow)
YesterdayRange [start-of-yesterday, start-of-today)
TomorrowRange [start-of-tomorrow, start-of-day-after)
ThisWeekRange [start-of-week, start-of-next-week)
LastWeekRange [start-of-last-week, start-of-this-week)
NextWeekRange [start-of-next-week, end-of-next-week)
WeekToDateRange [start-of-week, now)
ThisMonthRange [first-of-month, first-of-next-month)
LastMonthRange [first-of-last-month, first-of-this-month)
NextMonthRange [first-of-next-month, first-of-month-after)
MonthToDateRange [first-of-month, now)
ThisQuarterRange [first-of-quarter, first-of-next-quarter)
LastQuarterRange [first-of-last-quarter, first-of-this-quarter)
NextQuarterRange [first-of-next-quarter, first-of-quarter-after)
QuarterToDateRange [first-of-quarter, now)
ThisYearRange [Jan 1, Jan 1 next year)
LastYearRange [Jan 1 last year, Jan 1 this year)
NextYearRange [Jan 1 next year, Jan 1 year after)
YearToDateRange [Jan 1, now)
SpecificMonthRange(now, month) [first-of-month, first-of-next-month) in the reference year
SpecificQuarterRange(now, startMonth) [first-of-quarter, first-of-next-quarter) in the reference year
ThisDayLastWeekRange Same day of week, previous week (24h)
ThisDayNextWeekRange Same day of week, next week (24h)
ThisDayLastMonthRange Same date, previous month (24h)
ThisDayNextMonthRange Same date, next month (24h)
StartOfMonthRange First day of current month (24h)
EndOfMonthRange Last day of current month (24h)
StartOfYearRange January 1st (24h)
EndOfYearRange December 31st (24h)
Last5BusinessDaysRange Last 5 Mon-Fri days
ThisBusinessWeekRange Current Mon-Fri
LastBusinessWeekRange Previous Mon-Fri
NextBusinessWeekRange Next Mon-Fri
LastSpecificDayOfWeekRange(now, dayOfWeek) Most recent occurrence of a specific day (24h)
NextSpecificDayOfWeekRange(now, dayOfWeek) Next upcoming occurrence of a specific day (24h)
AddBusinessDays(start, days) Add/subtract business days, skipping weekends

Business day calculations do NOT account for holidays.


FilterFunctionRegistry

Purpose: DI-driven registry for filter functions (thread-safe via FrozenDictionary).

Location: /src/QueryBuilder.Core/Registry/FilterFunctionRegistry.cs

Key Features:

  • FrozenDictionary - Immutable after construction (thread-safe)
  • Case-insensitive - Function names matched without case sensitivity
  • Duplicate detection - Throws exception if duplicate function names registered

Function Types:

  1. IValueFunction - Returns values or ranges

    • @today, @yesterday, @now
    • @last_7_days, @last_30_days, @this_week, @this_month, @this_year
    • @me (current user ID)
  2. IFilterBuilderFunction - Returns FilterDefinitions

    • @all (always true)
    • @none (always false)

Usage:

// DI registration
services.AddSingleton<IValueFunction, TodayFunction>();
services.AddSingleton<IValueFunction, LastSevenDaysFunction>();
services.AddSingleton<IValueFunction, MeFunction>();
services.AddSingleton<IFilterFunctionRegistry, FilterFunctionRegistry>();

// Lookup
var registry = serviceProvider.GetRequiredService<IFilterFunctionRegistry>();
var function = registry.GetValueFunction("today");
var result = await function.ExecuteAsync(args, context);

Built-in Functions

Date/Time Functions

@now - Current timestamp:

// Usage in query
{ "field": "UpdatedAt", "operator": "gt", "value": "@now" }

// Resolves to
{ "field": "UpdatedAt", "operator": "gt", "value": "2025-10-25T14:30:00Z" }

@today - Today at midnight:

{ "field": "CreatedDate", "operator": "eq", "value": "@today" }
// Resolves to BETWEEN today 00:00:00 AND today 23:59:59

@yesterday - Yesterday at midnight:

{ "field": "LastLogin", "operator": "eq", "value": "@yesterday" }

@last_7_days - Last 7 days range:

{ "field": "Created", "operator": "eq", "value": "@last_7_days" }
// Resolves to BETWEEN (now - 7 days) AND now

@last_30_days - Last 30 days range

@this_week - Current week range (Monday - Sunday)

@this_month - Current month range

@this_year - Current year range

@january through @december - Specific month range (1st to last day) of the current year. Abbreviated forms also supported: @jan, @feb, @mar, @apr, @jun, @jul, @aug, @sep, @oct, @nov, @dec. Note: @may serves as both full and abbreviated form.

@q1 through @q4 - Quarter ranges of the current year:

  • @q1 — January 1 through March 31
  • @q2 — April 1 through June 30
  • @q3 — July 1 through September 30
  • @q4 — October 1 through December 31
User Context Functions

@me - Current user ID:

{ "field": "AssigneeId", "operator": "eq", "value": "@me" }

// Requires UserContextFeature in context. Hosts supply this via
// IFunctionExecutionFeatureProvider — the shared collector populates
// FilterResolutionContext.Features once per request:
var context = new FilterResolutionContext
{
    Features = new[] { new UserContextFeature("user123") }
};

// Resolves to
{ "field": "AssigneeId", "operator": "eq", "value": "user123" }

See IFunctionExecutionFeatureProvider for the host-registration model.

Template Validation Rule Introspection

Template validation rules expose metadata properties for introspection scenarios (for example, HTTP metadata endpoints):

  • RangeValidationRule.Min and RangeValidationRule.Max
  • RegexValidationRule.Pattern

These accessors are read-only and do not change runtime validation behavior.


Usage Examples

Example 1: Simple Query Execution

using QueryBuilder.Core.Models;
using QueryBuilder.Core.Extensions;

// Build query
var query = new QueryDefinition
{
    From = "users",
    Where = FilterDefinitionExtensions.Equal("IsActive", true),
    OrderBy = new List<OrderingDefinition>
    {
        new OrderingDefinition { Field = "LastName", Direction = SortDirection.Ascending }
    },
    Limit = 50
};

// Execute
var executor = serviceProvider.GetRequiredService<QueryExecutor>();
var context = ExecutionContext.Create().Build();
var results = await executor.ExecuteAsync<User>(query, context);

// Use results
foreach (var user in results.Data)
{
    Console.WriteLine($"{user.LastName}, {user.FirstName}");
}

Example 2: Complex Filter Building

// Build complex filter: Active VIP users OR high spenders
var filter = FilterDefinitionExtensions.And(
    FilterDefinitionExtensions.Equal("IsActive", true),
    FilterDefinitionExtensions.Or(
        FilterDefinitionExtensions.Equal("MembershipLevel", "VIP"),
        FilterDefinitionExtensions.GreaterThan("LifetimeValue", 10000)
    ),
    FilterDefinitionExtensions.Between("Age", 18, 65),
    FilterDefinitionExtensions.IsNotNull("Email")
);

var query = new QueryDefinition
{
    From = "customers",
    Where = filter,
    OrderBy = new List<OrderingDefinition>
    {
        new OrderingDefinition { Field = "LifetimeValue", Direction = SortDirection.Descending }
    }
};

var results = await executor.ExecuteAsync<Customer>(query, context);

Example 3: Using Specifications

// Define specification
var spec = new Specification
{
    Name = "HighValueCustomers",
    Description = "Active customers with lifetime value > $10,000",
    EntityType = "MyApp.Domain.Customer",
    SourceName = "customers",
    Filter = FilterDefinitionExtensions.And(
        FilterDefinitionExtensions.Equal("IsActive", true),
        FilterDefinitionExtensions.GreaterThan("LifetimeValue", 10000)
    ),
    CreatedBy = "admin",
    Version = 1
};

// Save specification
var specRepository = serviceProvider.GetRequiredService<ISpecificationDataRepository>();
await specRepository.SaveAsync(spec);

// Execute via QueryExecutor
var results = await executor.ExecuteSpecificationAsync<Customer>(spec, context);

// Or build expression for EF Core
var expression = SpecificationExpressionBuilder.BuildExpression<Customer>(spec);
var customers = await dbContext.Customers.Where(expression).ToListAsync();

Example 4: Runtime Function Resolution

// Query with @functions - no hard-coded dates!
var query = new QueryDefinition
{
    From = "orders",
    Where = FilterDefinitionExtensions.And(
        new FilterDefinition
        {
            Field = "AssigneeId",
            Operator = FilterOperator.Equal,
            Value = "@me"
        },
        new FilterDefinition
        {
            Field = "CreatedDate",
            Operator = FilterOperator.Equal,
            Value = "@last_30_days"
        },
        FilterDefinitionExtensions.In("Status", "Open", "InProgress")
    )
};

// Set up context with the per-request feature list. Endpoints and the EF
// QueryableBuilder populate this from IFunctionExecutionFeatureCollector;
// direct callers can build the list inline.
var context = ExecutionContext.Create()
    .WithProperty("FunctionFeatures", new IFunctionExecutionFeature[]
    {
        new UserContextFeature(currentUser.Id)
    })
    .Build();

// Execute - @me and @last_30_days resolved automatically
var results = await executor.ExecuteAsync<Order>(query, context);

Example 5: Custom Data Provider

// Implement custom provider
public sealed class RedisUsersProvider : IDataSourceProvider<User>
{
    private readonly IConnectionMultiplexer _redis;
    private readonly ILogger<RedisUsersProvider> _logger;

    public string SourceName => "redis-users";
    public string DisplayName => "Redis Cached Users";
    public string? Description => "Users cached in Redis with 10-minute expiration";
    public DataSourceType Type => DataSourceType.InMemory;

    public RedisUsersProvider(
        IConnectionMultiplexer redis,
        ILogger<RedisUsersProvider> logger)
    {
        _redis = redis;
        _logger = logger;
    }

    public async Task<IEnumerable<User>> GetDataAsync(CancellationToken ct = default)
    {
        try
        {
            var db = _redis.GetDatabase();
            var json = await db.StringGetAsync("users:cache");

            if (json.IsNullOrEmpty)
            {
                _logger.LogWarning("Redis cache miss for users");
                return Enumerable.Empty<User>();
            }

            return JsonSerializer.Deserialize<List<User>>(json!) ?? Enumerable.Empty<User>();
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to retrieve users from Redis");
            return Enumerable.Empty<User>();
        }
    }
}

// Register in DI
services.AddUniversalQueryBuilder(options =>
{
    options.AddDataProvider<RedisUsersProvider>(ServiceLifetime.Singleton);
});

// Query against Redis provider
var query = new QueryDefinition
{
    From = "redis-users",
    Where = FilterDefinitionExtensions.Equal("IsActive", true)
};

var results = await executor.ExecuteAsync<User>(query, context);

Example 6: Pagination and Sorting

// Build paginated query
var query = new QueryDefinition
{
    From = "products",
    Where = FilterDefinitionExtensions.And(
        FilterDefinitionExtensions.Equal("IsAvailable", true),
        FilterDefinitionExtensions.GreaterThan("Price", 0)
    ),
    OrderBy = new List<OrderingDefinition>
    {
        new OrderingDefinition { Field = "Category", Direction = SortDirection.Ascending },
        new OrderingDefinition { Field = "Price", Direction = SortDirection.Descending }
    },
    Limit = 25,  // Page size
    Offset = 50  // Skip first 50 (page 3)
};

var results = await executor.ExecuteAsync<Product>(query, context);

// Access pagination metadata
Console.WriteLine($"Total: {results.TotalCount}");
Console.WriteLine($"Returned: {results.ReturnedCount}");
Console.WriteLine($"Has More: {results.HasMoreResults}");

Architecture

System Architecture

┌─────────────────────────────────────────────────────┐
│              Application Layer                      │
│  (ASP.NET Core, Console App, Azure Function, etc.)  │
└─────────────────────────────────────────────────────┘
                        ↓
┌─────────────────────────────────────────────────────┐
│            QueryBuilder.Core                        │
│  ┌───────────────────────────────────────────────┐  │
│  │  QueryExecutor                                │  │
│  │  ├─ Validate (Schema Registry)                │  │
│  │  ├─ Resolve @ Functions                       │  │
│  │  ├─ Select Strategy                           │  │
│  │  └─ Execute                                    │  │
│  └───────────────────────────────────────────────┘  │
│  ┌───────────────────────────────────────────────┐  │
│  │  ExecutionStrategyFactory                     │  │
│  │  ├─ InMemoryExecutionStrategy     (Priority 100)│ │
│  │  ├─ EntityFrameworkExecutionStrategy (Priority 10)│ │
│  │  └─ CustomExecutionStrategy       (Priority varies)│ │
│  └───────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────┘
                        ↓
            ┌───────────┼───────────┐
            ↓           ↓           ↓
    ┌───────────┐ ┌──────────┐ ┌────────────┐
    │ InMemory  │ │ EF Core  │ │   Custom   │
    │  (LINQ)   │ │(IQueryable)│ │  Strategy  │
    └───────────┘ └──────────┘ └────────────┘
            ↓           ↓           ↓
    ┌───────────┐ ┌──────────┐ ┌────────────┐
    │IEnumerable│ │ Database │ │   Custom   │
    │   <T>     │ │(any EF   │ │    Data    │
    │           │ │provider) │ │            │
    └───────────┘ └──────────┘ └────────────┘

Data Flow

Query Execution Flow:

1. User Input
   ├─ JSON → QueryDefinition (via QueryBuilder.Json)
   ├─ Shorthand → QueryDefinition (via QueryBuilder.Shorthand)
   └─ Code → QueryDefinition (directly)
        ↓
2. QueryExecutor.ExecuteAsync()
        ↓
3. Schema Registry Validation (optional)
   ├─ Data source exists?
   ├─ Fields exist?
   └─ Types compatible?
        ↓
4. Function Resolution (FilterFunctionResolver)
   ├─ @ functions → concrete values
   └─ Returns new QueryDefinition
        ↓
5. Strategy Selection (ExecutionStrategyFactory)
   ├─ Check CanExecute() for each strategy
   ├─ Sort by Priority
   └─ Select highest priority
        ↓
6. Strategy.ExecuteAsync()
   ├─ EntityFramework: Translate to IQueryable + Execute via EF Core
   ├─ InMemory: Compile LINQ + Execute against IEnumerable
   └─ Custom: Your implementation
        ↓
7. Return IQueryResult<T>
   ├─ Data
   ├─ Metadata (execution time, SQL, etc.)
   └─ Performance metrics

Error Handling

Error Architecture

QueryBuilder.Core uses a centralized error definition system with structured error codes and context.

Error Categories

1. CoreErrorCode - General system errors

  • UnexpectedError
  • InvalidConfiguration
  • ResourceNotFound
  • OperationCancelled

2. ValidationErrorCode - Validation failures

  • RequiredFieldMissing
  • InvalidFormat
  • ValueOutOfRange
  • FieldNotFound
  • OperatorNotSupported

3. ExecutionErrorCode - Execution failures

  • InvalidInput
  • InvalidFunctionResult
  • Timeout
  • DatabaseError

4. FeatureErrorCode - Feature availability

  • NotSupported
  • NotImplemented
Exception Types

Base Exceptions:

  • QueryBuilderException - Base for all exceptions
  • CoreException - Core-specific exceptions

Specific Exceptions:

  • QueryValidationException - Validation errors
  • QueryExecutionException - Execution errors
  • QueryParseException - Parsing errors
  • QueryTimeoutException - Timeout errors
  • FeatureNotSupportedException - Unsupported features
Error Definitions

Location: /src/QueryBuilder.Core/ErrorHandling/CoreErrors.cs

public static class CoreErrors
{
    public static readonly ErrorDefinition<CoreErrorCode> UnexpectedError = new()
    {
        Code = CoreErrorCode.UnexpectedError,
        Title = "Unexpected Error",
        Severity = ErrorSeverity.Error,
        MessageFormatter = args =>
            $"An unexpected error occurred: {args.GetValueOrDefault("message")}",
        DocumentationUrl = "https://github.com/.../CORE_UNEXPECTED_ERROR.md"
    };

    // Additional error definitions...
}
Exception Context

All exceptions support structured context data:

try
{
    var expression = SpecificationExpressionBuilder.BuildExpression<User>(spec);
}
catch (QueryValidationException ex)
{
    // Access structured error data
    Console.WriteLine($"Error Code: {ex.ErrorCode}");
    Console.WriteLine($"Message: {ex.Message}");
    Console.WriteLine($"Field: {ex.Context["fieldName"]}");
    Console.WriteLine($"Help: {ex.DocumentationUrl}");

    // Log with full context
    _logger.LogError(ex, "Validation failed: {ErrorCode}", ex.ErrorCode);
}
Creating Exceptions with Context
throw new QueryValidationException(
    ValidationErrorCode.FieldNotFound,
    $"Field '{fieldName}' not found in entity type '{entityType}'",
    documentationUrl: ValidationErrors.FieldNotFound.DocumentationUrl,
    context: new Dictionary<string, object?>
    {
        ["fieldName"] = fieldName,
        ["entityType"] = entityType,
        ["availableFields"] = string.Join(", ", availableFields),
        ["suggestions"] = FindSimilarFieldNames(fieldName, availableFields)
    }
);

Testing

Test Project

Location: /tests/QueryBuilder.Core.Tests/

Test Coverage Areas

1. Model Validation

  • QueryDefinition structural validation
  • FilterDefinition validation
  • Specification validation
  • Value ranges and required fields

2. Extension Methods

  • FilterDefinitionExtensions (AND, OR, NOT, comparison, string, list, range, null)
  • QueryDefinitionExtensions (serialization, cloning, fluent manipulation)
  • SpecificationExtensions

3. Expression Building

  • SpecificationExpressionBuilder
  • Logical operators (AND, OR, NOT)
  • Filter operators (eq, gt, like, in, between, etc.)
  • Nested property access (Customer.Address.City)
  • Type conversion and nullable handling

4. Function Resolution

  • FilterFunctionResolver
  • FilterFunctionRegistry
  • Built-in functions (@today, @me, @last_7_days, etc.)
  • Operator preservation (range results)

5. Execution System

  • ExecutionStrategyFactory (strategy selection, priority ordering)
  • QueryExecutor (validation, resolution, execution)
  • ExecutionContext (builder pattern, properties)

6. Error Handling

  • Exception types and inheritance
  • Error definitions and formatting
  • Structured context data

Running Tests

# All Core tests
dotnet test tests/QueryBuilder.Core.Tests/

# Specific category
dotnet test --filter "FullyQualifiedName~SpecificationExpressionBuilderTests"

# With coverage
dotnet test --collect:"XPlat Code Coverage"

Example Tests

Model Validation:

[Fact]
public void QueryDefinition_Validate_RequiresFrom()
{
    var query = new QueryDefinition { From = null };
    var result = query.Validate();

    result.IsValid.Should().BeFalse();
    result.Errors.Should().Contain(e => e.Contains("From"));
}

Extension Methods:

[Fact]
public void FilterDefinitionExtensions_And_CombinesFilters()
{
    var filter1 = FilterDefinitionExtensions.Equal("IsActive", true);
    var filter2 = FilterDefinitionExtensions.GreaterThan("Age", 18);

    var combined = FilterDefinitionExtensions.And(filter1, filter2);

    combined.LogicalOperator.Should().Be(LogicalOperator.And);
    combined.Expressions.Should().HaveCount(2);
}

Expression Building:

[Fact]
public void SpecificationExpressionBuilder_BuildExpression_CreatesValidExpression()
{
    var spec = new Specification
    {
        EntityType = "MyApp.Domain.User",
        Filter = FilterDefinitionExtensions.Equal("IsActive", true)
    };

    var expression = SpecificationExpressionBuilder.BuildExpression<User>(spec);

    var user = new User { IsActive = true };
    var func = expression.Compile();
    func(user).Should().BeTrue();
}

Contributing

Development Setup

Prerequisites:

  • .NET 10 SDK
  • Your favorite IDE (Visual Studio, Rider, VS Code)

Build:

dotnet build src/QueryBuilder.Core/QueryBuilder.Core.csproj

Run Tests:

dotnet test tests/QueryBuilder.Core.Tests/

Adding New Features

Adding a New Filter Operator

1. Add enum value:

// File: src/QueryBuilder.Core/Enums/FilterOperator.cs
public enum FilterOperator
{
    // ... existing operators

    [EnumMember(Value = "customOp")]
    CustomOperator
}

2. Add extension method:

// File: src/QueryBuilder.Core/Extensions/FilterDefinitionExtensions.cs
public static FilterDefinition CustomOperator(string field, object value)
{
    return new FilterDefinition
    {
        Field = field,
        Operator = FilterOperator.CustomOperator,
        Value = value
    };
}

3. Add expression builder support:

// File: src/QueryBuilder.Core/Specifications/Builders/SpecificationExpressionBuilder.cs
private static Expression BuildFilterExpression(/* params */)
{
    switch (filter.Operator)
    {
        // ... existing cases

        case FilterOperator.CustomOperator:
            return BuildCustomOperatorExpression(parameter, filter);
    }
}

4. Add tests:

[Fact]
public void CustomOperator_WorksCorrectly()
{
    var filter = FilterDefinitionExtensions.CustomOperator("Field", "value");
    // Assert...
}
Adding a New @ Function

1. Implement IValueFunction:

public class CustomDateFunction : IValueFunction
{
    public string Name => "customDate";

    public Task<FunctionResult> ExecuteAsync(
        FilterFunctionArgument[] args,
        ValueResolutionContext context,
        CancellationToken cancellationToken = default)
    {
        // Your logic here
        var result = CalculateCustomDate(context.ReferenceTime);
        return Task.FromResult(new ScalarResult(result));
    }
}

2. Register in DI:

services.AddSingleton<IValueFunction, CustomDateFunction>();

3. Use in queries:

var query = new QueryDefinition
{
    Where = new FilterDefinition
    {
        Field = "Date",
        Operator = FilterOperator.Equal,
        Value = "@customDate"
    }
};

Code Style

  • C# 12 features preferred
  • Nullable reference types enabled
  • XML documentation required for public APIs
  • Unit tests required for new features
  • Follow existing patterns (builder, extension methods, etc.)

Documentation

Project Documentation

  • Main README: /README.md - Universal Query Builder overview
  • Architecture: /docs/architecture-refactor-plan.md - System architecture
  • CLAUDE.md: /CLAUDE.md - Development guide and project context

Feature Documentation

  • Runtime Function Resolution: /docs/features/RUNTIME-FUNCTION-RESOLUTION.md
  • Relative Range Syntax: /docs/features/RELATIVE-RANGE-SYNTAX.md

API Documentation

All public APIs include XML documentation:

/// <summary>
/// Executes a query and returns strongly-typed results.
/// </summary>
/// <typeparam name="T">The entity type to return.</typeparam>
/// <param name="query">The query definition to execute.</param>
/// <param name="context">Execution context with security and configuration.</param>
/// <param name="cancellationToken">Optional cancellation token.</param>
/// <returns>Query results with metadata.</returns>
/// <exception cref="QueryValidationException">Thrown when query validation fails.</exception>
/// <exception cref="QueryExecutionException">Thrown when execution fails.</exception>
Task<IQueryResult<T>> ExecuteAsync<T>(
    QueryDefinition query,
    IExecutionContext context,
    CancellationToken cancellationToken = default)
    where T : class;

Performance

Optimizations

1. Expression Compilation Caching

  • Use IExpressionCompiler (QueryBuilder.Expressions) for 10-40x performance improvement
  • Expressions cached by hash (filter + entity type)
  • Avoids repeated compilation of identical specifications

2. Immutable Collections

  • FrozenDictionary for registries (thread-safe, faster lookups)
  • Read-only collections where appropriate

3. Async Throughout

  • All I/O operations are async
  • CancellationToken support everywhere
  • ValueTask for potentially synchronous operations

4. Minimal Allocations

  • Value types where appropriate (enums, structs)
  • Object pooling for common operations
  • Avoid unnecessary LINQ allocations

5. Lazy Metadata Loading

  • IQueryMetadata built on-demand
  • Avoids allocating metadata objects when not needed

Benchmarks

Typical Performance (Intel i7, .NET 10):

Operation Time Notes
QueryDefinition validation ~0.1ms Structural only
FilterDefinition validation ~0.2ms Recursive tree
Expression compilation (cold) ~5-10ms First time
Expression compilation (cached) ~0.1ms From cache
Function resolution ~0.5-1ms Per function
Strategy selection ~0.05ms 3 strategies

Memory:

  • QueryDefinition: ~1-5KB (depending on complexity)
  • FilterDefinition tree: ~2-10KB
  • Compiled expression: ~10-50KB (cached)

License

Part of the Universal Query Builder project.

See /LICENSE for license information.


Support

  • Issues: GitHub Issues
  • Documentation: /docs/
  • Examples: /examples/

Built with ❤️ for .NET developers

Limitations

Hierarchical Selection

  1. Strict Dictionary Output: Hierarchical projections (Select) always return a Dictionary<string, object> (or list thereof). They cannot be projected directly into strongly-typed DTOs by the execution strategy itself (DTO projection must be handled separately).
  2. Maximum Depth: Nested selections are limited to 10 levels deep to prevent stack overflow and performance degradation.
  3. Split Queries (Deferred): Currently, selecting multiple collections at the same level in EF Core execution may fail or produce inefficient SQL ("Cartesian Explosion") until Split Query detection is implemented in a future phase.
  4. Recursion Safety: Recursive data structures must be handled carefully. The depth limit acts as a safeguard, but cycles in data should be avoided in selection paths.
Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (8)

Showing the top 5 NuGet packages that depend on UniversalQueryBuilder.Core:

Package Downloads
UniversalQueryBuilder.SchemaRegistry

Central metadata authority and schema registry for Universal Query Builder. Manages data source discovery, registration, validation, and column metadata with EF Core storage.

UniversalQueryBuilder.Expressions

Expression compilation engine with two-level caching for Universal Query Builder. Provides high-performance filter compilation using FastExpressionCompiler.

UniversalQueryBuilder.InMemory

In-memory LINQ execution strategy for Universal Query Builder. Provides regex and fuzzy matching support with custom data provider integration for non-database collections.

UniversalQueryBuilder.EntityFramework

Entity Framework Core execution strategy for Universal Query Builder. Provides SQL query generation with automatic navigation property includes, DTO projections, and multi-DbContext support.

UniversalQueryBuilder.Shorthand

ANTLR4-based human-readable query syntax parser for Universal Query Builder. Supports shorthand syntax like 'is:active AND age>18' with query templates and relative date ranges.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
10.0.13-beta 59 6/3/2026
10.0.12-beta 81 6/1/2026
10.0.11-beta 80 5/31/2026
10.0.10-beta 86 5/28/2026
10.0.9-beta 92 5/27/2026
10.0.8-beta 88 5/18/2026
10.0.7-beta 87 5/16/2026
10.0.6-beta 87 5/11/2026
10.0.5-beta 77 4/30/2026
10.0.4-beta 67 4/23/2026
10.0.3-beta 82 4/23/2026
10.0.2-beta 70 4/10/2026
10.0.1-beta 59 4/10/2026
10.0.0-beta 69 4/9/2026