UniversalQueryBuilder.Core
10.0.13-beta
dotnet add package UniversalQueryBuilder.Core --version 10.0.13-beta
NuGet\Install-Package UniversalQueryBuilder.Core -Version 10.0.13-beta
<PackageReference Include="UniversalQueryBuilder.Core" Version="10.0.13-beta" />
<PackageVersion Include="UniversalQueryBuilder.Core" Version="10.0.13-beta" />
<PackageReference Include="UniversalQueryBuilder.Core" />
paket add UniversalQueryBuilder.Core --version 10.0.13-beta
#r "nuget: UniversalQueryBuilder.Core, 10.0.13-beta"
#:package UniversalQueryBuilder.Core@10.0.13-beta
#addin nuget:?package=UniversalQueryBuilder.Core&version=10.0.13-beta&prerelease
#tool nuget:?package=UniversalQueryBuilder.Core&version=10.0.13-beta&prerelease
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
- Unified Type Coercion
- Core Concepts
- Installation
- Core Models
- Abstractions
- Execution System
- Extension Methods
- Specification Pattern
- Function Resolution
- Usage Examples
- Architecture
- Error Handling
- Testing
- Contributing
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)CoercionResultTypeCoercionExceptionCoercionErrorCode
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. JsonElementscalar values are normalized and coerced.JsonElementobject/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
IExecutionStrategyfor 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.,
@meresolves 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 serializationMicrosoft.Extensions.DependencyInjection- DI supportMicrosoft.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 trueor- At least one condition must be truenot- 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 collectionsum(collection.field)- Sums numeric values (returnsdecimal)avg(collection.field)- Averages numeric values (returnsdecimal?,nullfor empty collections)max(collection.field)- Finds maximum value (returns field type,nullfor empty collections)min(collection.field)- Finds minimum value (returns field type,nullfor 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 fromtrue/false(a scalar leaf inclusion) or aSelectionConfig(a nested selection scope).
SelectionConfig Properties:
Select: NestedSelectionDictionaryfor 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<string, object> 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:
- Check for preferred strategy hint (from ExecutionContext)
- Filter strategies where
CanExecute()returns true - Sort by Priority (highest first)
- 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/IsNotNullcheck CLR nullability only. For non-nullable types, they produce constantfalse/true(vacuous but not an error).@empty/@notEmptycheck for absence/presence of flags: zero means "empty" (no flags set). These are name-independent — they always compare against numeric0. For nullable types,@emptyalso 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:
- BuildExpressionRecursive() - Recursively traverse FilterDefinition tree
- BuildLogicalExpression() - Handle AND/OR/NOT operators
- BuildFilterExpression() - Handle field comparisons (eq, gt, like, etc.)
- BuildPropertyAccess() - Navigate property paths (supports nested:
"Customer.Address.City") - 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:
IValueFunction - Returns values or ranges
@today,@yesterday,@now@last_7_days,@last_30_days,@this_week,@this_month,@this_year@me(current user ID)
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.MinandRangeValidationRule.MaxRegexValidationRule.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
UnexpectedErrorInvalidConfigurationResourceNotFoundOperationCancelled
2. ValidationErrorCode - Validation failures
RequiredFieldMissingInvalidFormatValueOutOfRangeFieldNotFoundOperatorNotSupported
3. ExecutionErrorCode - Execution failures
InvalidInputInvalidFunctionResultTimeoutDatabaseError
4. FeatureErrorCode - Feature availability
NotSupportedNotImplemented
Exception Types
Base Exceptions:
QueryBuilderException- Base for all exceptionsCoreException- Core-specific exceptions
Specific Exceptions:
QueryValidationException- Validation errorsQueryExecutionException- Execution errorsQueryParseException- Parsing errorsQueryTimeoutException- Timeout errorsFeatureNotSupportedException- 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
- Strict Dictionary Output: Hierarchical projections (
Select) always return aDictionary<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). - Maximum Depth: Nested selections are limited to 10 levels deep to prevent stack overflow and performance degradation.
- 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.
- 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 | Versions 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. |
-
net10.0
- Ardalis.GuardClauses (>= 5.0.0)
- Microsoft.Extensions.Caching.Memory (>= 10.0.3)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.3)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.3)
- Microsoft.Extensions.ObjectPool (>= 10.0.0)
- TypeCoercion (>= 1.1.0)
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 |