UniversalQueryBuilder.SchemaRegistry
10.0.13-beta
dotnet add package UniversalQueryBuilder.SchemaRegistry --version 10.0.13-beta
NuGet\Install-Package UniversalQueryBuilder.SchemaRegistry -Version 10.0.13-beta
<PackageReference Include="UniversalQueryBuilder.SchemaRegistry" Version="10.0.13-beta" />
<PackageVersion Include="UniversalQueryBuilder.SchemaRegistry" Version="10.0.13-beta" />
<PackageReference Include="UniversalQueryBuilder.SchemaRegistry" />
paket add UniversalQueryBuilder.SchemaRegistry --version 10.0.13-beta
#r "nuget: UniversalQueryBuilder.SchemaRegistry, 10.0.13-beta"
#:package UniversalQueryBuilder.SchemaRegistry@10.0.13-beta
#addin nuget:?package=UniversalQueryBuilder.SchemaRegistry&version=10.0.13-beta&prerelease
#tool nuget:?package=UniversalQueryBuilder.SchemaRegistry&version=10.0.13-beta&prerelease
QueryBuilder.SchemaRegistry
Code-first schema registry with fluent configuration API
QueryBuilder.SchemaRegistry provides a type-safe, code-first approach to defining queryable data sources with automatic schema discovery, validation, and metadata management. Define your schema once using fluent builders, and get query validation, field resolution, and metadata services.
// Define a data source using fluent configuration
public class UserConfiguration : IDataSourceConfiguration<User>
{
public void Configure(DataSourceBuilder<User> builder)
{
builder
.HasSourceName("users")
.HasDisplayName("Users")
.ExposeProperties(u => new { u.Id, u.FirstName, u.LastName, u.Email })
.HasFormatting(u => u.FirstName, fmt => fmt.AsTitleCase())
.ConfigureNavigation(u => u.Company, company =>
company.ExposeProperties(c => new { c.Name, c.Industry }));
}
}
// 2. Unified registration in Program.cs
builder.Services.AddUniversalQueryBuilder(options =>
{
options.AddCodeFirstSchemaRegistry();
});
// Schema registry provides query validation and metadata
Table of Contents
- Overview
- Quick Start
- Core Concepts
- Configuration API
- DataSourceBuilder
- Property Exposure
- Navigation Properties
- Property Aliases
- Formatting Rules
- Default Ordering
- Primary Key Metadata
- Query Splitting (EF Core)
- Recommended Columns
- Column Metadata
- Access Policies
- Coalesced Columns
- Column-Level Permissions
- Row-Level Filter
- Search Rules
- Composite Search Rules
- Search Groups
- Configuring Templates
- Configuration Composition
- Services
- Validation
- Field Alias Resolution
- Architecture
- Performance
- Testing
Overview
What is QueryBuilder.SchemaRegistry?
QueryBuilder.SchemaRegistry is the metadata layer for the Universal Query Builder system. It provides:
- Code-First Configuration - Define queryable data sources using type-safe fluent API
- Automatic Discovery - Reflection-based scanning finds all configurations at startup
- In-Memory Registry - Fast, cached metadata lookups with zero database overhead
- Validation - Query validation with "Did you mean?" suggestions
- Field Aliases - Flexible field naming for user-friendly queries
- Formatting Metadata - Client-side formatting instructions (dates, currency, percentages)
Why Code-First?
Before (Database-Backed):
// Hundreds of lines of manual registration
await registrationService.RegisterEntityFrameworkSourceAsync(...);
await registrationService.RegisterColumnAsync(...);
// Database migrations, DbContext, repositories, caching...
After (Code-First):
// Single configuration class + one-line registration
public class UserConfiguration : IDataSourceConfiguration<User> { ... }
builder.Services.AddCodeFirstSchemaRegistry();
Benefits:
- Type-safe property selection using expression trees
- Compile-time validation (no invalid property names)
- Zero database overhead (in-memory registry)
- Simpler architecture (no migrations, no DbContext)
- Better developer experience (IntelliSense, refactoring support)
Quick Start
1. Install Package
dotnet add package QueryBuilder.SchemaRegistry
2. Define Configuration
Create a configuration class implementing IDataSourceConfiguration<TEntity>:
using QueryBuilder.SchemaRegistry.Configuration;
public class UserConfiguration : IDataSourceConfiguration<User>
{
public void Configure(DataSourceBuilder<User> builder)
{
builder
.HasSourceName("users")
.HasDisplayName("Users")
.ExposeProperties(u => new {
u.Id,
u.FirstName,
u.LastName,
u.Email,
u.CreatedAt
})
.HasFormatting(u => u.FirstName, fmt => fmt.AsTitleCase())
.HasFormatting(u => u.CreatedAt, fmt => fmt.AsDate("yyyy-MM-dd"));
}
}
3. Register Services
In Program.cs:
// Register Code-First Schema Registry
builder.Services.AddCodeFirstSchemaRegistry(
options =>
{
options.DefaultSourceNameCase = SourceNameCase.CamelCase;
options.DefaultPropertyNameCase = PropertyNameCase.CamelCase;
},
// Explicitly scan specific assemblies if needed (optional)
typeof(Program).Assembly
);
See QueryBuilder.Extensions for advanced configuration options.
That's it! Your data sources are now registered and ready for querying. The schema registry provides query validation, field resolution, and metadata services.
Core Concepts
Code-First Configuration
Definition: Data sources are defined using C# configuration classes with fluent API, discovered automatically via reflection.
Key Components:
- IDataSourceConfiguration<TEntity> - Marker interface for auto-discovery
- DataSourceBuilder<TEntity> - Fluent API for configuration
- ConfigurationDiscoveryService - Reflection-based scanner
- SchemaRegistryBootstrapper - IHostedService that runs at startup
Example:
public class OrderConfiguration : IDataSourceConfiguration<Order>
{
public void Configure(DataSourceBuilder<Order> builder)
{
builder
.HasSourceName("orders")
.ExposeProperties(o => new {
o.Id,
o.OrderNumber,
o.TotalAmount,
o.OrderDate
})
.ConfigureNavigation(o => o.OrderItems, item =>
item.ExposeProperties(i => new { i.Quantity, i.UnitPrice })
.ConfigureNavigation(i => i.Product, p =>
p.ExposeProperties(prod => new { prod.Name, prod.SKU })));
}
}
Automatic Discovery
How It Works:
- Startup -
SchemaRegistryBootstrapper(IHostedService) runs during app startup - Assembly Scan - Reflects over assemblies looking for
IDataSourceConfiguration<T> - Generic Type Extraction - Extracts
TEntityfromIDataSourceConfiguration<TEntity> - Builder Creation - Creates
DataSourceBuilder<TEntity>dynamically viaActivator.CreateInstance - Configuration Invocation - Calls
Configure(builder)method - Registration - Calls
Build()and registers result inInMemorySchemaProvider
Startup Logging:
[SchemaRegistryBootstrapper] Starting schema registry initialization...
[ConfigurationDiscoveryService] Scanning assembly: QueryBuilder.Web
[ConfigurationDiscoveryService] Found configuration: UserConfiguration
[ConfigurationDiscoveryService] Found configuration: OrderConfiguration
[ConfigurationDiscoveryService] Found configuration: AddressConfiguration
[SchemaRegistryBootstrapper] Registered 3 data sources in 245ms: users, orders, addresses
Schema Registry
InMemorySchemaProvider:
- Singleton service holding all
DataSourceDefinitionobjects - ConcurrentDictionary for thread-safe lookups (case-insensitive)
- Zero database overhead
- Populated once at startup, read-only during operation
InMemorySchemaRegistry:
- Implements
ISchemaRegistryfor query validation - Uses
IMemoryCachefor ValidationResult caching (24-hour TTL) - Provides "Did you mean?" suggestions using Levenshtein distance
- Validates both flat and nested property paths
Example:
// Get data source definition
var dataSource = schemaProvider.GetBySourceName("users");
// Validate query
var validation = await schemaRegistry.ValidateQueryAsync(query);
if (!validation.IsValid)
{
foreach (var error in validation.Errors)
{
Console.WriteLine($"{error.Field}: {error.Message}");
if (error.Suggestions.Any())
Console.WriteLine($" Did you mean: {string.Join(", ", error.Suggestions)}?");
}
}
Configuration API
DataSourceBuilder
Generic Constraints:
DataSourceBuilder<TEntity>
where TEntity : class
Methods:
// Required
builder.HasSourceName("users") // Unique identifier (opaque)
builder.HasDisplayName("Users") // Human-readable name
builder.ExposeProperties(u => new { u.Id, u.Name }) // Whitelist properties
// Optional
builder.AddAlias("firstName", "fname", "givenName")
builder.HasFormatting(u => u.Amount, fmt => fmt.AsCurrency("USD"))
builder.Recommended(u => u.Status) // Mark as suggested filter for UI
builder.ConfigureNavigation(u => u.Address, addr => ...)
// Column Metadata
builder.HasMetadata(u => u.LanId, new AdLanId("CORP")) // Per-column metadata
builder.ApplyMetadata<DateTime>(metadata) // By CLR type
builder.ApplyMetadataWhere(predicate, metadata) // By predicate
builder.RemoveMetadata<FormattingRule>(u => u.InternalDate) // Opt out of bulk rule
// Access Policy (optional — defaults to Unconfigured)
builder.RequireAuthenticated() // Any authenticated user
builder.RequirePermission("devices:read") // Specific permission key
builder.DenyAccess() // Block all endpoint access
Validation Rules (enforced in Build()):
- SourceName required (non-null, non-empty, non-whitespace)
- Execution mode required — either
UseDbContext<T>()orInMemoryOnly() - At least one property exposed
- No duplicate property names (case-insensitive)
- Max navigation depth: 4 levels
- Valid formatting strategy names
- Property expressions must reference TEntity
- Navigation expressions must reference valid properties
SQL Server JSON Scalar Fields
Schema Registry can expose flat top-level fields backed by a raw SQL Server JSON string column.
builder
.ExposeJsonValue<string>(x => x.ProfileJson, "profileCity", "$.city")
.ExposeJsonValue<int?>(x => x.ProfileJson, "profileAge", "$.age")
.ExposeJsonValue<string>(
x => x.ProfileJson,
"profileEmail",
"$.email",
json => json.HasDisplayName("Email").AddAlias("email").Searchable());
Rules and limitations:
- SQL Server only. Registered JSON scalar fields require the SQL Server EF provider at execution time.
- EF only. The Entity Framework strategy translates these fields; other strategies do not.
- Explicit registration only. Queries cannot supply arbitrary JSON paths.
- Field names are flat. Dotted names such as
profile.cityare rejected; use aliases likecitywhen you want user-friendly query terms. - Value types must be nullable (
int?,decimal?,DateTime?, etc.) becauseJSON_VALUEreturnsNULLfor missing paths and conversion failures. TRY_CONVERTreturnsNULLsilently when a JSON value cannot be converted to the target type. Callers cannot distinguish "missing path" from "conversion failure" at the SQL level.JSON_VALUEalso returnsNULLif the extracted value exceeds 4000 characters.- v1 is scalar-only. Arrays, objects,
JSON_QUERY, andOPENJSONare intentionally out of scope.
Property Exposure
Expression Tree Parsing:
// Anonymous object initializer
builder.ExposeProperties(u => new { u.Id, u.FirstName, u.LastName });
// Behind the scenes:
// 1. Parse Expression<Func<TEntity, object>>
// 2. Extract NewExpression with member bindings
// 3. Get PropertyInfo for each member
// 4. Create ColumnDefinition for each property
// 5. Store in Columns dictionary (case-insensitive)
Supported Property Types:
- Primitive types (int, string, bool, DateTime, decimal, etc.)
- Nullable types (int?, DateTime?, etc.)
- Enums
- Navigation properties (configured separately)
Not Supported:
- Complex computed expressions (
u => u.FirstName + " " + u.LastName) - Method calls (
u => u.GetFullName()) - Indexers or array access
Navigation Properties
Single Navigation (1:1, N:1):
builder.ConfigureNavigation(u => u.Company, company =>
company.ExposeProperties(c => new { c.Name, c.Industry })
.HasFormatting(c => c.Revenue, fmt => fmt.AsCurrency("USD")));
Collection Navigation (1:N):
// Explicit type parameter required for ICollection<T>
builder.ConfigureNavigation<Address>(u => u.Addresses, addr =>
addr.ExposeProperties(a => new { a.Street, a.City, a.State }));
Deep Navigation (up to 4 levels):
builder.ConfigureNavigation(o => o.OrderItems, item =>
item.ExposeProperties(i => new { i.Quantity, i.UnitPrice })
.ConfigureNavigation(i => i.Product, p =>
p.ExposeProperties(prod => new { prod.Name, prod.SKU })
.ConfigureNavigation(prod => prod.Category, cat =>
cat.ExposeProperties(c => new { c.Name }))));
// Level 1: Order → OrderItems
// Level 2: OrderItem → Product
// Level 3: Product → Category
// Level 4: Maximum depth reached
Generated Paths:
"orderItems.product.name""orderItems.product.category.name"
Read-Only Collection Navigation
Domain models following DDD principles often expose read-only collections to protect aggregate invariants. ConfigureNavigation supports all IEnumerable<T>-compatible collection types:
ICollection<T>,List<T>,IList<T>(mutable)IReadOnlyCollection<T>,IReadOnlyList<T>,ReadOnlyCollection<T>(read-only)HashSet<T>,ISet<T>, and any customIEnumerable<T>implementation
// Domain entity with read-only collection (DDD pattern)
public class Department
{
private readonly List<Employee> _employees = [];
public IReadOnlyCollection<Employee> Employees => _employees.AsReadOnly();
}
// Configuration works directly — no need for mutable ICollection<T>
builder
.ExposeProperties(d => new { d.Id, d.Name, d.Employees })
.ConfigureNavigation(d => d.Employees, employees =>
{
employees.ExposeProperties(e => new { e.Id, e.FullName });
});
Display Name Conventions
Property display names are generated by the DisplayNameConvention. The default convention uses a three-step fallback:
[Display(Name = "...")]fromSystem.ComponentModel.DataAnnotations[DisplayName("...")]fromSystem.ComponentModelHumanize()— splits PascalCase into words (e.g.,"FirstName"→"First name")
public class User
{
[Display(Name = "LAN ID")]
public string? LanId { get; set; } // → "LAN ID"
[DisplayName("ZIP Code")]
public string? ZipCode { get; set; } // → "ZIP Code"
public string? FirstName { get; set; } // → "First name" (Humanize fallback)
}
Priority chain (highest wins):
HasDisplayName(prop, name)— explicit per-property override- Builder-level convention (
builder.SetDisplayNameConvention(...)) - Global convention (
options.SetDisplayNameConvention(...)) - Default convention fallback:
[Display(Name)]>[DisplayName]>Humanize()
Custom conventions:
// String-based — simple name transformation
options.SetDisplayNameConvention(name => name.ToUpperInvariant());
// PropertyInfo-based — full reflection access
options.SetDisplayNameConvention(prop =>
prop.GetCustomAttribute<DisplayAttribute>()?.GetName()
?? prop.Name.ToUpperInvariant());
// Per-builder override
builder.SetDisplayNameConvention(name => name.Replace("_", " "));
Data source display names use a separate convention:
options.SetSourceDisplayNameConvention(name => name.Humanize(LetterCasing.Title));
Property Aliases
Purpose: Allow flexible field names in queries (shorthand syntax, alternative naming)
Configuration:
builder
.AddAlias("firstName", "fname", "givenName", "first")
.AddAlias("lastName", "lname", "surname", "last")
.AddAlias("email", "emailAddress", "mail");
Usage:
// All of these resolve to "FirstName":
"firstName:John" // Canonical name
"fname:John" // Alias
"givenName:John" // Alias
"first:John" // Alias
Nested Aliases:
// Configure parent and child aliases
builder.ConfigureNavigation(u => u.Address, addr =>
addr.AddAlias("city", "town", "municipality"));
// Queries:
"address.city:Seattle" // Canonical
"addr.city:Seattle" // Parent alias
"address.town:Seattle" // Child alias
"addr.municipality:Seattle" // Both aliases
Formatting Rules
FormattingRule implements IColumnMetadata. The HasFormatting() builder method attaches a FormattingRule as column metadata.
Reading formatting:
var formatting = column.Metadata.TryGet<FormattingRule>();
if (formatting is not null)
{
Console.WriteLine($"{formatting.StrategyName}: {formatting.Parameters}");
}
Available Strategies:
Numeric:
.HasFormatting(u => u.Amount, fmt => fmt.AsCurrency("USD"))
.HasFormatting(u => u.Amount, fmt => fmt.AsNumber(decimals: 2))
.HasFormatting(u => u.Completion, fmt => fmt.AsPercentage(decimals: 1))
Date/Time:
.HasFormatting(u => u.CreatedAt, fmt => fmt.AsDate("yyyy-MM-dd"))
.HasFormatting(u => u.LastLogin, fmt => fmt.AsTime("HH:mm:ss"))
Text:
.HasFormatting(u => u.FirstName, fmt => fmt.AsTitleCase())
.HasFormatting(u => u.SKU, fmt => fmt.AsUpperCase())
.HasFormatting(u => u.Email, fmt => fmt.AsLowerCase())
.HasFormatting(u => u.SKU, fmt => fmt.WithTextPrefix("SKU-"))
.HasFormatting(u => u.Extension, fmt => fmt.WithTextSuffix(".txt"))
Boolean:
.HasFormatting(u => u.IsActive, fmt => fmt.AsYesNo())
.HasFormatting(u => u.IsVerified, fmt => fmt.AsTrueFalse())
.HasFormatting(u => u.IsEnabled, fmt => fmt.AsOnOff())
Introspection output (per column):
{
"columnName": "Amount",
"metadata": {
"formattingRule": {
"strategyName": "currency",
"parameters": { "symbol": "USD", "decimals": 2 }
}
}
}
Default Ordering
Purpose: Configure default sort order for paginated queries to ensure deterministic results and eliminate EF Core warnings about Skip/Take without OrderBy.
Auto-Discovery:
Primary keys marked with [Key] attribute are automatically discovered and used as default ordering (ascending). No explicit configuration required for most entities.
Lambda-Based API (Type-Safe, Recommended):
Single Field (Shorthand):
// Simple ascending sort by single property
builder.WithDefaultOrdering(x => x.Id);
Multiple Fields (Nested Builder):
// Type-safe ordering with multiple fields
builder.WithDefaultOrdering(order => order
.Ascending(x => x.LastName)
.Descending(x => x.FirstName));
// Business logic ordering (most recent first, then by PK)
builder.WithDefaultOrdering(order => order
.Descending(x => x.OrderDate)
.Ascending(x => x.Id));
String-Based API:
builder.WithDefaultOrdering("lastName asc, firstName desc");
builder.WithDefaultOrdering("orderDate desc, id asc");
Primary Key Specification:
// Single primary key (for metadata purposes)
builder.WithPrimaryKey(x => x.Id);
// Composite primary key
builder.WithPrimaryKey(x => new { x.OrderId, x.ProductId });
When Default Ordering is Applied:
- Queries with pagination (
limit/offset) but no explicitorderByspecification - Prevents EF Core warning: "The query uses a row limiting operator ('Skip'/'Take') without an 'OrderBy' operator."
Behavior:
- With
[Key]Attribute: Primary key auto-discovered, used as default ordering (ascending) - No
[Key]Attribute: Warning logged at startup, but build continues. UseWithDefaultOrdering()to specify explicit ordering. - Explicit
WithDefaultOrdering(): Overrides auto-discovered primary key ordering
Example with Auto-Discovery:
public class User
{
[Key] // Auto-discovered as default ordering
public int Id { get; set; }
public string Username { get; set; }
public DateTime CreatedAt { get; set; }
}
public class UserConfiguration : IDataSourceConfiguration<User>
{
public void Configure(DataSourceBuilder<User> builder)
{
builder
.HasSourceName("users")
.ExposeProperties(u => new { u.Id, u.Username, u.CreatedAt });
// No WithDefaultOrdering() needed - Id is auto-discovered from [Key]
}
}
Example with Custom Ordering:
public class OrderConfiguration : IDataSourceConfiguration<Order>
{
public void Configure(DataSourceBuilder<Order> builder)
{
builder
.HasSourceName("orders")
.ExposeProperties(o => new { o.Id, o.OrderDate, o.TotalAmount })
.WithDefaultOrdering(order => order
.Descending(o => o.OrderDate) // Most recent first
.Ascending(o => o.Id)); // Then by ID for stability
}
}
Startup Logging:
[DataSourceBuilder] ✅ Auto-discovered primary key ordering for User: Id asc
[DataSourceBuilder] ⚠️ No primary key found for LegacyEntity. Consider using WithDefaultOrdering() or WithPrimaryKey() to ensure deterministic pagination.
Primary Key Metadata
Purpose: Store primary key metadata in DataSourceDefinition for use by features like the Get-By-ID endpoint and schema introspection.
Auto-Discovery from [Key] Attribute:
public class User
{
[Key]
public int Id { get; set; }
public string Username { get; set; } = string.Empty;
}
public class UserConfiguration : IDataSourceConfiguration<User>
{
public void Configure(DataSourceBuilder<User> builder)
{
builder
.HasSourceName("users")
.ExposeProperties(u => new { u.Id, u.Username });
// Primary key auto-discovered from [Key] attribute
}
}
Explicit Configuration:
// Single property key
builder.WithPrimaryKey(u => u.Id);
// Composite key (for entities without [Key] attributes)
builder.WithPrimaryKey(e => new { e.TenantId, e.OrderId });
What Gets Stored:
The DataSourceDefinition.PrimaryKey property contains a KeyDefinition:
public sealed record KeyPropertyPart(string PropertyName, Type PropertyType);
public sealed record KeyDefinition
{
/// Single source of truth for key property names, types, and ordering.
public required IReadOnlyList<KeyPropertyPart> Parts { get; init; }
public required KeyType KeyType { get; init; }
/// Derived from Parts. Property names in key order.
public IReadOnlyList<string> Properties => Parts.Select(p => p.PropertyName).ToList();
public bool IsComposite => Parts.Count > 1;
/// CLR type of the key property (single keys only). Null for composite keys.
/// Computed from Parts.
public Type? KeyPropertyType => Parts.Count == 1 ? Parts[0].PropertyType : null;
public string? DisplayName { get; init; }
}
public enum KeyType { PrimaryKey, UniqueKey, AlternateKey }
Parts is the single source of truth. Properties and KeyPropertyType are both derived from Parts:
Propertiesprojects the property names fromPartsin orderKeyPropertyTypereturns the CLR type when there is exactly one part,nullotherwise
Auto-discovered composite keys (from multiple [Key] attributes) are sorted alphabetically by property name (StringComparer.Ordinal) for deterministic ordering.
Column-Level Metadata:
Each ColumnDefinition includes an IsPrimaryKey flag for schema introspection:
var definition = await schemaRegistry.GetBySourceNameAsync("users");
var idColumn = definition.Columns["Id"];
Console.WriteLine(idColumn.IsPrimaryKey); // true
Use Cases:
- Get-By-ID Endpoint -
POST /{sourceName}/{id}uses primary key metadata to build the filter and convert the ID to the correct CLR type - Schema Introspection - Clients can discover which columns are primary keys
- Pagination - Fallback ordering uses primary key for deterministic results
Query Splitting (EF Core)
Purpose: Enable EF Core query splitting to avoid Cartesian explosion when querying multiple collection navigations.
When to Enable:
- EF Core warning: "Compiling a query which loads related collections for more than one collection navigation"
- Queries with multiple collection navigations (e.g., Orders with OrderItems AND Payments)
Configuration:
builder
.HasSourceName("customers")
.ExposeProperties(c => new { c.Id, c.Name, c.Email })
.WithQuerySplitting(); // Enable split queries
Runtime Override:
var context = ExecutionContext.Create()
.WithQuerySplitting(false) // Disable for this query
.Build();
Precedence:
ExecutionContext.QuerySplitting(runtime override) - highest priorityDataSourceDefinition.EnableQuerySplitting(schema registry configuration)- No change (EF Core default - single query mode)
Trade-offs:
- Pro: Avoids Cartesian explosion and data duplication
- Con: Multiple database round-trips
Recommended Columns
Mark scalar properties as recommended to surface them as suggested filters in UI builders. Recommended() attaches Recommended metadata to the column, which flows through schema introspection to API consumers.
builder
.ExposeProperties(u => new { u.Id, u.Name, u.Status, u.Department })
.Recommended(u => u.Status)
.Recommended(u => u.Department);
Reading recommended status:
var column = dataSource.Columns["Status"];
bool isRecommended = column.Metadata.Has<Recommended>();
Constraints:
- Property must be exposed via
ExposeProperties()first - Only scalar properties can be recommended -- navigation properties throw
InvalidOperationException - For nested scalars, call
Recommended()insideConfigureNavigation
Default: No Recommended metadata. Only explicitly marked columns appear as recommended.
Column Metadata
Each ColumnDefinition carries a MetadataCollection -- an immutable, type-keyed collection of metadata instances. Metadata types implement the IColumnMetadata marker interface and are stored one-per-type per column.
Defining Custom Metadata
Define metadata as a sealed record implementing IColumnMetadata:
// Tag-only (no data)
public sealed record PersonallyIdentifiable : IColumnMetadata;
// With data
public sealed record AdLanId(string Domain) : IColumnMetadata;
For metadata that describes itself in introspection endpoints, implement IDescribableMetadata:
public sealed record AdLanId(string Domain) : IDescribableMetadata
{
public string MetadataKey => "adLanId";
public string Description => "Active Directory LAN identifier";
}
Plain IColumnMetadata types fall back to convention-based key derivation (MetadataKeyConvention):
- Strip
Metadatasuffix (orMarkersuffix) from the type name - Apply camelCase
- Examples:
Recommended→"recommended",HasDistinctValues→"hasDistinctValues"
MetadataCollection API
// Exact-type lookup (throws KeyNotFoundException if absent)
var formatting = column.Metadata.Get<FormattingRule>();
// Safe lookup (returns null if absent)
var formatting = column.Metadata.TryGet<FormattingRule>();
// Presence check
bool isRecommended = column.Metadata.Has<Recommended>();
// Interface-based query (LINQ -- Get/TryGet/Has match exact type only)
var describable = column.Metadata.OfType<IDescribableMetadata>();
// Count and enumeration
int count = column.Metadata.Count;
foreach (var meta in column.Metadata) { /* ... */ }
Builder Methods
HasMetadata() -- attach metadata to a specific column:
builder
.HasMetadata(u => u.LanId, new AdLanId("CORP"))
.HasMetadata(u => u.Email, new PersonallyIdentifiable());
Last-write-wins: calling HasMetadata() with the same metadata type on the same column overwrites the previous instance.
ApplyMetadata<T>() -- attach metadata to all columns matching a CLR type:
// Apply date formatting to all DateTime and DateTime? columns
builder.ApplyMetadata<DateTime>(new FormattingRule
{
StrategyName = "Date",
Parameters = new Dictionary<string, object> { ["format"] = "yyyy-MM-dd" }.AsReadOnly()
});
Matches against the unwrapped ClrType, so ApplyMetadata<DateTime>() matches both DateTime and DateTime? columns.
ApplyMetadataWhere() -- attach metadata to columns matching a predicate:
// Mark all string columns as having distinct values
builder.ApplyMetadataWhere(
col => col.ClrType == typeof(string) && !col.IsNullable,
new HasDistinctValues());
Predicates see structural column state only (FieldName, ClrType, IsNullable, etc.), not metadata state.
RemoveMetadata<T>() -- opt out of bulk-applied metadata for a specific column:
// Apply formatting to all dates, then remove it from one column
builder
.ApplyMetadata<DateTime>(dateFormatting)
.RemoveMetadata<FormattingRule>(u => u.InternalTimestamp);
Resolution order during Build(): bulk rules → per-property HasMetadata() (overwrites) → RemoveMetadata() removals.
Built-In Metadata Types
| Type | Kind | Description |
|---|---|---|
Recommended |
Tag-only | Marks a column as a suggested filter in UI builders |
HasDistinctValues |
Tag-only | Marks a column as having a finite set of values (suitable for dropdown/facet UI) |
FormattingRule |
Data-carrying | Formatting instruction (strategy name + parameters) for API clients |
Convenience Methods
HasDistinctValues() -- convenience for attaching HasDistinctValues metadata:
builder.HasDistinctValues(u => u.Status);
// Equivalent to:
builder.HasMetadata(u => u.Status, new HasDistinctValues());
// String-keyed companion for synthetic columns (coalesced, computed):
builder.HasDistinctValues("DisplayStatus");
Querying Metadata at Runtime
var dataSource = schemaProvider.GetBySourceName("users");
// Find all recommended columns
var recommended = dataSource.Columns.Values
.Where(c => c.Metadata.Has<Recommended>());
// Find all columns with formatting
var formatted = dataSource.Columns.Values
.Where(c => c.Metadata.Has<FormattingRule>())
.Select(c => (c.FieldName, c.Metadata.Get<FormattingRule>()));
// Find all columns with distinct values
var facets = dataSource.Columns.Values
.Where(c => c.Metadata.Has<HasDistinctValues>());
// Find all describable metadata on a column
var describable = column.Metadata.OfType<IDescribableMetadata>()
.Select(m => (m.MetadataKey, m.Description));
Access Policies
Each data source carries an AccessPolicy that the HTTP layer uses for per-source authorization. If no access policy method is called, the source defaults to AccessPolicy.Unconfigured.
public class DeviceConfiguration : IDataSourceConfiguration<Device>
{
public void Configure(DataSourceBuilder<Device> builder)
{
builder
.HasSourceName("devices")
.UseDbContext<AppDbContext>()
.ExposeProperties(d => new { d.Id, d.Name, d.Status })
.RequirePermission("devices:read"); // Enforced at the endpoint layer
}
}
Execution mode — every data source must declare exactly one:
UseDbContext<TContext>()— database-backed source. The entity must be part of that context's EF model, but it does not need its own explicitDbSet<TEntity>property. This allows registering derived entities in TPH/TPT hierarchies even when the context only exposesDbSet<BaseEntity>.InMemoryOnly()— in-memory or schema-only source. Use for composite context types used withPredicateCompiler, or sources backed by anIDataSourceProvider.
Available methods:
| Method | Effect |
|---|---|
| (none) | AccessPolicy.Unconfigured — behavior determined by endpoint-layer fallback option |
RequireAuthenticated() |
Any authenticated user can access |
RequirePermission("key") |
Delegates to IPermissionEvaluator registered in the endpoint layer |
DenyAccess() |
All endpoint access denied (useful for internal-only sources) |
Access policies are stored on DataSourceDefinition.AccessPolicy and evaluated by MetadataSourceAuthorizationFilter in QueryBuilder.Endpoints. See the Endpoints README for registration and runtime behavior.
AccessPolicy model:
AccessPolicy is a sealed record with a private constructor — instances are created via static factory methods only. It composes an AccessRequirement (what is required) with an IsExplicit flag (whether the consumer deliberately configured it vs. it being the builder default).
AccessRequirement is a sealed hierarchy (algebraic data type):
AccessRequirement.Deny— access deniedAccessRequirement.Authenticated— any authenticated userAccessRequirement.PermissionRequired(string permission)— specific permission key
Coalesced Columns
WithCoalesce<T>(alias, first, second, params additional) is a schema-level COALESCE that registers a single queryable column from two or more property selectors. The synthetic column replaces its backing properties in the queryable surface — the registered alias is the one consumers filter, sort, and select on. The signature lifts the "at least two selectors" rule from runtime to the type system; a single-selector call is a compile error.
Binary coalesce. Pick the preferred name when present, fall back to the legal name otherwise.
builder
.ExposeProperties(u => new { u.Id, u.PreferredFirstName, u.FirstName })
.WithCoalesce<string?>("FirstName", u => u.PreferredFirstName, u => u.FirstName);
Use string? because every selector must return T and PreferredFirstName is string?. The synthetic column's nullability is derived independently from the terminal source (u.FirstName).
N-ary fallback. Compose more than two selectors — checked left-to-right, first non-null wins.
builder
.ExposeProperties(u => new { u.Id, u.Nickname, u.PreferredFirstName, u.FirstName })
.WithCoalesce<string?>(
"DisplayName",
u => u.Nickname,
u => u.PreferredFirstName,
u => u.FirstName);
Literal terminal. A constant literal may appear as the final selector to guarantee a non-null result.
builder
.ExposeProperties(u => new { u.Id, u.PreferredFirstName })
.WithCoalesce<string?>("DisplayName", u => u.PreferredFirstName, u => "Unknown");
The resulting column is non-nullable because the literal fallback is non-null. (null literal fallbacks produce a nullable column.)
Rules:
- At least two selectors; the last may be a literal.
- Selectors must reference scalar properties declared directly on the entity (or a base type). Nested/navigation access and collection-typed selectors (e.g.,
List<T>) throwArgumentException. - Non-final selectors must be nullable. The builder throws
InvalidOperationExceptionif a non-final selector targets a non-nullable property, because later fallbacks would be unreachable. - Backing properties become unqueryable — they are added to the restricted-property set and removed from the column map. Exception: when the alias equals a backing property name, the synthetic column replaces the raw one under that name instead of being added as a sibling.
- Alias collision throws
InvalidOperationException(unless the alias matches a backing property — see above). - Coalesced columns cannot be primary keys.
WithPrimaryKeyrejects coalesced aliases, andWithCoalescerejects backings that are already part of the primary key — both directions throwInvalidOperationException. - Backing properties with non-Open
AccessPolicy, registered search rules, or per-property metadata cannot be folded — the builder throwsInvalidOperationExceptionso the configuration is preserved on the alias instead of being silently dropped.
Type-level invariants. ColumnAccessor.Coalesce and LiteralFallback enforce their core invariants in their constructors, so direct construction (new ColumnAccessor.Coalesce(...) or new LiteralFallback(...)) cannot produce malformed instances:
Coalesce(sources, fallback):sourcesis typed asIReadOnlyList<ColumnAccessor.Property>— nestedCoalescesources andJsonScalarsources are both unrepresentable at compile time. The list must be non-null and non-empty; with a null fallback, ≥2 entries are required. The source list is snapshotted defensively at construction so callers cannot mutate it post-hoc.LiteralFallback(value, clrType):clrTypemust be non-null and nottypeof(object)(type erasure would defeat SQL translation); a nullvaluerequires a nullableclrType(reference type orNullable<T>); a non-nullvaluemust be assignable toclrType(after strippingNullable<T>).
SQL emission. EF Core translates the expression tree into a flat SQL COALESCE(...) call regardless of how many sources are chained. For
builder.WithCoalesce<string?>("DisplayName",
u => u.Nickname,
u => u.PreferredFirstName,
u => u.FirstName);
EF Core (SQL Server provider) emits roughly:
SELECT COALESCE([u].[Nickname], [u].[PreferredFirstName], [u].[FirstName]) AS [DisplayName]
FROM [Users] AS [u]
Filter, ORDER BY, and DISTINCT clauses against the alias reuse the same COALESCE(...) expression. When all preceding sources are nullable and a literal fallback is provided, the literal is parameterized inside the COALESCE; when a non-nullable terminal source makes the literal unreachable, EF Core may elide it.
Performance: A COALESCE(...) expression is not indexable by a simple single-column index; if the coalesced column is a frequent filter target, consider a persisted computed column at the database layer.
Column-level permissions on coalesced columns. The selector-based RequireColumnPermission overload cannot target synthetic columns. Use the string-keyed RequireColumnPermission(string fieldName, string permission) overload (see Column-Level Permissions) to gate a coalesced column.
builder
.WithCoalesce<string?>("DisplayName", u => u.PreferredFirstName, u => u.FirstName)
.RequireColumnPermission("DisplayName", "read:pii");
Configuring search and metadata on coalesced columns. Use the string-keyed Searchable(string) / Searchable(string, Action<SearchConfigurationBuilder>) / HasMetadata(string, TMeta) / Recommended(string) overloads — they accept any registered column name including coalesced aliases. The selector-based overloads only accept direct CLR properties.
builder
.WithCoalesce<string?>("DisplayName", u => u.PreferredFirstName, u => u.FirstName)
.Searchable("DisplayName")
.Recommended("DisplayName")
.HasMetadata("DisplayName", new AdLanId("CORP"));
Column-Level Permissions
Individual columns can carry their own permission requirement that is enforced in addition to the source-level policy. Source-level access gates the source as a whole; column-level access gates the specific fields a query references.
public class CoworkerConfiguration : IDataSourceConfiguration<Coworker>
{
public void Configure(DataSourceBuilder<Coworker> builder)
{
builder
.HasSourceName("coworkers")
.UseDbContext<AppDbContext>()
.ExposeProperties(c => new { c.Id, c.Username, c.PersonnelNumber, c.Address })
.RequirePermission("coworkers:read")
.RequireColumnPermission(c => c.PersonnelNumber, "read:pii")
.ConfigureNavigation(c => c.Address, addr => addr
.RequireColumnPermission(a => a.Zip, "read:pii"));
}
}
Builder overloads:
| Method | Effect |
|---|---|
RequireColumnPermission(propertyExpression, "permission") |
Sets ColumnAccessPolicy.RequiresPermission("permission") on the column targeted by the selector |
RequireColumnPermission(string fieldName, "permission") |
String-keyed companion for synthetic columns (coalesced aliases registered via WithCoalesce<T>, JSON scalars registered via ExposeJsonValue<T>) that have no CLR property to point at |
Default policy — every column is ColumnAccessPolicy.Open unless RequireColumnPermission is called for it. Open means no column-level restriction; the source-level policy still applies. Calling RequireColumnPermission twice on the same column throws InvalidOperationException — conflicting permission requirements are rejected explicitly rather than last-write-wins.
The column-level method is named RequireColumnPermission (rather than overloading RequirePermission) so a missing lambda in the call cannot silently flip scope from column to source.
ColumnAccessPolicy model — sealed record exposing Requirement as a nullable AccessRequirement.PermissionRequired. null means open (synonym for ColumnAccessPolicy.Open); any non-null value carries a permission key. Source-level concepts like Deny and Authenticated are not meaningful at column level — once the user has cleared the source-level check, columns gate on capabilities.
Additive cascade across nested paths. A reference to Address.Zip requires every permission encountered along the path:
coworkers (source) → "coworkers:read"
└─ Address (column) → no column policy
└─ Zip (column) → "read:pii"
A user querying Address.Zip must satisfy both coworkers:read (source) and read:pii (column). If the user has coworkers:read but not read:pii, the request is denied with HTTP 403 and errorCode: column_permission_denied. Each permission encountered along the walk is checked exactly once per request — the response reports the canonical path that triggered the denied permission. When a restriction is placed on Address itself (block-cascade), denying access there denies every descendant path under Address regardless of their own column policies.
Discovery surface. Restricted columns appear in GET /sources/{name}/columns with isRestricted: true. A denied ancestor (block-cascade) empties children and aliases and clears metadata so consumer hints and alternate paths are not leaked. See QueryBuilder.Endpoints/README.md for the full discovery and denial response shapes.
Row-Level Filter
Sources can declare a server-controlled visibility filter that is AND-composed into the WHERE of every query targeting the source. The same filter applies when the source is referenced from a subquery, so a caller cannot widen their result set by wrapping the source inside another query.
Filters that need request-scoped DI services implement IRowFilter and are attached with the generic WithRowLevelFilter<TFilter>() overload. The filter receives normal constructor injection — no ambient IServiceProvider, no captured IServiceScopeFactory:
public sealed class ReportRowFilter(IPermissionService perms) : IRowFilter
{
public async ValueTask<RowFilterDecision> BuildAsync(RowFilterContext ctx, CancellationToken ct)
{
if (await perms.HasAsync(ctx.User, "reports.bypass_row_filter", ct))
{
return RowFilterDecision.Bypass.Instance; // admin override — every row visible
}
return new RowFilterDecision.Apply(FilterDefinitionExtensions.Or(
FilterDefinitionExtensions.Equal("OwnerLanId", "@me"),
FilterDefinitionExtensions.IsNotNull("PublishedAt")));
}
}
public class ReportConfiguration : IDataSourceConfiguration<Report>
{
public void Configure(DataSourceBuilder<Report> builder)
{
builder
.HasSourceName("reports")
.UseDbContext<AppDbContext>()
.ExposeProperties(r => new { r.Id, r.OwnerLanId, r.PublishedAt, r.Body })
.WithRowLevelFilter<ReportRowFilter>();
}
}
The filter type is activated fresh per request from the request scope via ActivatorUtilities.CreateInstance. Registering the filter type in DI has no effect — the injector always constructs a new instance, resolving constructor dependencies from the request scope. Only the filter's constructor dependencies need to be registered (Scoped or Transient — never Singleton, since a singleton dependency would capture state across requests). The delegate overload WithRowLevelFilter(RowFilterFactory) remains for inline filters that need only ctx.User. Row filters — typed or delegate — apply only on the scoped execution path; an in-process ExecuteUnscopedAsync caller deliberately skips all row-level filtering.
The filter. Receives a RowFilterContext(SourceName, User) and a CancellationToken, and returns a RowFilterDecision — either RowFilterDecision.Apply(predicate) (the predicate is AND-composed into the WHERE) or RowFilterDecision.Bypass.Instance (admin override — every row visible). The two-state union makes "no decision yet" / null unrepresentable, eliminating the inverted-default footgun where a return null mistakenly widens visibility. Both RowFilterContext and RowFilterDecision live in QueryBuilder.Core.Models so the injector and the EF QueryableBuilder consume them without a SchemaRegistry reverse-dependency.
SourceName— the data source the filter is being requested for. Useful when one shared filter handles multiple sources.User— the currentClaimsPrincipalfromIUserAccessor. Anonymous when no request context is active.
The filter may reference value functions like @me — the validator resolves them after composition so injected references behave the same as caller-supplied ones.
RowFilterDecision.Bypass.Instance bypasses the filter for the current caller. This is the explicit admin-override pattern; the filter is the single source of truth for visibility decisions, so override logic lives inside the filter rather than scattered across consumer code. To deny all rows return RowFilterDecision.Apply with a no-match predicate (e.g., FilterDefinitionExtensions.Equal("Id", "__deny__")) — NOT Bypass (which widens visibility) and NOT a thrown exception (which surfaces as a CoreException to operators and clients).
Builder semantics. A source may declare at most one row filter. WithRowLevelFilter(RowFilterFactory) throws ArgumentNullException on a null factory; both overloads throw InvalidOperationException if a row filter (delegate or typed) is already configured — silently overwriting a security primitive is unsafe.
Per-request caching. The filter is invoked at most once per request per source name. Outer and subquery occurrences of the same source share the same materialized filter, so an expensive permission check runs once even if the query references the source from multiple positions.
Column authorization participates. Field references introduced by the filter are walked by the column-authorization pipeline (defense in depth). A row filter that references a restricted column denies the request for callers who lack that permission, instead of silently bypassing column auth. BulkLookupService and DistinctValuesService compose the row filter into the auth-time query shape before running column authorization, matching the validator's "inject-then-auth" ordering — there is no surface where the row filter applies to data retrieval but bypasses column auth on its own references.
Composition. IRowFilterInjector (in QueryBuilder.SchemaRegistry.Services) drives the composition. Its method signature is:
Task InjectAsync(
QueryDefinition query,
CancellationToken cancellationToken);
Mutation semantics. The injector mutates query in place: callers pass a query they own, and on return query.Where (and the Where of every reachable subquery) may have a new AND-composed predicate. The outer source is resolved internally from query.From via the schema registry — callers can't accidentally pass a mismatched source.
Dependencies and lifetime. The injector owns IUserAccessor, ISchemaRegistry, IServiceProvider (the request scope, used to activate typed filters), and ILogger via constructor injection. It MUST be registered Scoped: it activates request-scoped filters from its injected IServiceProvider, so a Singleton registration would hand every filter the root provider and break scoped resolution (or capture a stale principal).
Traversal. The injector AND-composes the outer source's filter into query.Where, then recursively walks every subquery-bearing clause (Where, Having, Subquery operators reachable through them) and composes the per-source filter at each subquery whose From references a row-filtered source.
Cycle and depth guards. Cyclical configurations terminate via reference-equality visited-set tracking combined with per-source filter caching: a cycle eventually re-enters a previously-visited QueryDefinition and short-circuits. A depth limit (16 levels) is defense-in-depth for pathological linear nesting that the visited set cannot catch — it throws CoreException(InvalidConfiguration) if breached.
Exception wrapping. Filter exceptions (delegate throw or typed activation/execution failure) are wrapped as CoreException(InvalidConfiguration); an unregistered subquery source throws CoreException(ResourceNotFound). OperationCanceledException propagates unwrapped so client cancellation and timeouts surface as the cancellation they are, not as misleading configuration errors.
The Endpoints pipeline, BulkLookupService, DistinctValuesService, and the EF QueryableBuilder all invoke the same injector, so every execution path sees the same composed predicate. See QueryBuilder.Endpoints/README.md for pipeline ordering details and QueryBuilder.EntityFramework/README.md for the standalone QueryableBuilder integration.
Pre-Registered DTO Projections
Purpose: Register a data source that queries one entity type but always projects to a specific DTO type using a pre-compiled expression.
When to Use:
- Fixed projection patterns that never change (e.g.,
OrderSummaryDto,UserProfileDto) - Type-safe DTO definitions with compile-time validation
- Performance-critical scenarios where projection expression is pre-compiled
- When you want strong typing in your API contracts
Configuration:
// Define DTO
public class OrderSummaryDto
{
public int Id { get; set; }
public string OrderNumber { get; set; } = string.Empty;
public decimal TotalAmount { get; set; }
public string CustomerName { get; set; } = string.Empty;
public DateTime OrderDate { get; set; }
}
// Register data source with projection type
public class OrderSummaryConfiguration : IDataSourceConfiguration<Order>
{
public void Configure(DataSourceBuilder<Order> builder)
{
builder
.HasSourceName("orderSummaries")
.HasDisplayName("Order Summaries")
.WithProjectionType<OrderSummaryDto>() // Specify projection type
.WithProjection(o => new OrderSummaryDto // Define projection expression
{
Id = o.Id,
OrderNumber = o.OrderNumber,
TotalAmount = o.TotalAmount,
CustomerName = o.Customer.Name,
OrderDate = o.OrderDate
})
.ExposeProperties(o => new { o.Id, o.OrderNumber, o.TotalAmount, o.OrderDate });
}
}
How It Works:
- EntityType: Specifies the entity to query from DbContext (e.g.,
Order) - ProjectionType: Specifies the result type returned to callers (e.g.,
OrderSummaryDto) - Projection Expression: Pre-compiled
Expression<Func<Order, OrderSummaryDto>>applied to all queries - Execution Strategy: EntityFrameworkExecutionStrategy detects
ProjectionType != nulland applies registered projection
Result:
// Query execution
var result = await strategy.ExecuteAsync<OrderSummaryDto>(new QueryDefinition
{
From = "orderSummaries",
Where = new FilterDefinition { Field = "TotalAmount", Operator = FilterOperator.GreaterThan, Value = 100 }
});
// Returns List<OrderSummaryDto> with pre-compiled projection applied
Comparison with Dynamic Projections:
| Feature | Pre-Registered DTO | Dynamic Projections |
|---|---|---|
| Return Type | Strong typed DTO | Dictionary<string, object> |
| Configuration | Compile-time expression | Runtime field selection |
| Flexibility | Fixed fields only | Select any exposed fields |
| Performance | Pre-compiled (~0ms overhead) | First query compiles expression (~5-10ms), cached after |
| Use Case | Fixed APIs, type safety | Ad-hoc queries, flexible selection |
Note: Most applications should use dynamic projections for flexibility. Use pre-registered projections only when:
- You have a fixed API contract that never changes
- Type safety is critical
- You're building a public API with versioned DTOs
Search Rules
Purpose: Declare which fields participate in bare-term search — when a user types a term without a field qualifier (e.g., john instead of name:john), search rules control the OR expansion across fields.
Default search — fields participate with TryParse guards for non-string types:
builder
.Searchable(u => u.Name) // string — uses default operator (StartsWith)
.Searchable(u => u.Email) // string — uses default operator
.Searchable(u => u.EmployeeId); // int — TryParse guard, Equal operator
Pattern routing — regex patterns auto-route terms to specific fields:
// Exclusive pattern short-circuits all other rules when matched
builder.Searchable(u => u.WorkOrderId, o => o
.WithExclusiveSearchPattern(@"^WO\d{10}$")
.WithSearchPattern(@"^(?:WO)?(?<id>\d{1,10})$",
m => $"WO{m.Groups["id"].Value.PadLeft(10, '0')}")
.WithSearchOperator(FilterOperator.Equal));
Built-in pattern helpers — backed by [GeneratedRegex] for AOT-friendly, source-generated matching:
builder.Searchable(u => u.Email, o => o.WithEmailPattern());
builder.Searchable(u => u.ExternalId, o => o.WithGuidPattern());
builder.Searchable(u => u.IpAddress, o => o.WithIpAddressPattern());
builder.Searchable(u => u.Code, o => o.WithDigitsOnlyPattern());
Each helper has additive and exclusive variants (WithEmailPattern() / WithExclusiveEmailPattern()), plus overloads accepting a Func<Match, string> transform.
Value resolvers — transform search terms into filter conditions during bare term expansion. Resolvers are tried after pattern matching but before default rules:
// Built-in date expression resolver: "april" becomes field >= Apr 1 AND field < May 1
builder.Searchable(u => u.HireDate, o => o.WithDateExpressionSearch());
builder.Searchable(u => u.CreatedDate, o => o.WithDateExpressionSearch());
// Custom resolver via instance
builder.Searchable(u => u.Status, o => o.WithValueResolver(new StatusResolver()));
// Custom resolver via type (parameterless constructor)
builder.Searchable(u => u.Priority, o => o.WithValueResolver<PriorityResolver>());
The DateExpressionResolver handles full month names (january-december), abbreviated month names (jan-dec), and quarter names (q1-q4). All lookups are case-insensitive. It produces half-open interval filters on date-typed fields (DateTime, DateTimeOffset, and their nullable variants).
ISearchValueResolver interface:
public interface ISearchValueResolver
{
/// <summary>
/// Attempts to resolve the given search term into a FilterDefinition.
/// Returns the filter if this resolver can handle the term, or null if it cannot.
/// </summary>
FilterDefinition? TryResolve(SearchValueResolverContext context);
}
Resolvers return FilterDefinition rather than scalar values because transformations like date ranges require compound filters (e.g., field >= start AND field < end). Returning null causes the expansion pipeline to fall through to the default rule. Exceptions are caught by the pipeline, emitting a parse warning without aborting the query.
SearchValueResolverContext record:
public sealed record SearchValueResolverContext(
string SearchTerm, // The bare search term entered by the user
string FieldName, // The field name this rule applies to
Type FieldClrType, // The CLR type of the field
DateTimeOffset ReferenceTime); // Reference time for date calculations
SearchRule.ValueResolver property:
Each SearchRule carries an optional ISearchValueResolver? ValueResolver property. When set, the resolver is tried after pattern matching but before the default TryParse fallback during bare term expansion.
SearchConfigurationBuilder methods:
| Method | Description |
|---|---|
WithValueResolver(ISearchValueResolver resolver) |
Sets a resolver instance |
WithValueResolver<TResolver>() |
Sets a resolver by type (parameterless constructor) |
WithDateExpressionSearch() |
Shorthand for WithValueResolver(DateExpressionResolver.Instance) |
DateExpressionResolver:
Built-in ISearchValueResolver that resolves month and quarter names into date range filter conditions. Stateless singleton — use DateExpressionResolver.Instance. Delegates date arithmetic to DateRangeCalculator in QueryBuilder.Core.
// "april" on a DateTime field → field >= 2026-04-01 AND field < 2026-05-01
// "q3" on a DateTimeOffset field → field >= 2026-07-01 AND field < 2026-10-01
// "november" on an int field → null (resolver only handles date-typed fields)
Supported expressions (case-insensitive):
- Full month names:
januarythroughdecember - Abbreviated month names:
janthroughdec(mayserves as both) - Quarter names:
q1throughq4
Returns null for non-date fields (DateTime, DateTimeOffset, DateOnly, and their nullable variants only).
Multi-registration — register both a pattern rule and default search for the same field:
// Pattern rule: exclusive match for exact ITS-prefixed tickets
builder.Searchable(u => u.TicketId, o => o
.WithExclusiveSearchPattern(@"^ITS-(?<id>\d+)$", m => m.Groups["id"].Value)
.WithSearchOperator(FilterOperator.Equal));
// Default rule: also participates in broad search
builder.Searchable(u => u.TicketId);
Operator and weight configuration:
builder.Searchable(u => u.LanId, o => o
.WithSearchOperator(FilterOperator.Equal) // Override default operator
.WithWeight(1.5)); // Relevance scoring weight
Behavior rules:
| Scenario | Behavior |
|---|---|
| String field, no operator | Falls back to the consumer's default search operator |
| Non-string field, no operator | TryParse guard + Equal |
| Non-string field + Contains/StartsWith | InvalidOperationException at Build() time |
| Non-string field + explicit Equal | Allowed |
| Duplicate default rules (same field) | InvalidOperationException at Build() time |
| Default + pattern rule (same field) | Both preserved — different search behaviors |
| String pattern (not pre-compiled) | Compiled eagerly at configuration time with 100ms ReDoS timeout |
Pre-compiled Regex pattern |
Validated for finite match timeout, preserved as-is (no re-compilation) |
| Invalid regex pattern | ArgumentException at configuration time (immediate) |
| Invalid search operator | ArgumentException — only Equal, Contains, StartsWith, EndsWith, Like, ILike, Regex, Fuzzy are valid |
| Weight ≤ 0 or non-finite | ArgumentOutOfRangeException — must be a positive finite number |
SearchPatterns static helpers:
| Method | Pattern | Use Case |
|---|---|---|
SearchPatterns.DigitsOnly() |
^\d+$ |
Numeric IDs, codes |
SearchPatterns.Email() |
^[^@]+@[^@]+\.[^@]+$ |
Email addresses |
SearchPatterns.GuidFormat() |
Standard UUID format | External IDs, correlation IDs |
SearchPatterns.IpAddress() |
^\d{1,3}(?:\.\d{1,3}){1,3}$ |
IP addresses |
All patterns require a finite match timeout for ReDoS protection. String patterns auto-apply 100ms; pre-compiled Regex instances are validated at configuration time.
Navigation searchable fields:
.Searchable(), .CompositeSearchable(), and search groups defined inside ConfigureNavigation() automatically participate in bare-term search. Field names are prefixed with the navigation path to produce fully qualified dotted paths.
builder
.Searchable(co => co.Title) // → "Title"
.ConfigureNavigation(co => co.Coworker!, nav =>
nav.ExposeProperties(c => new { c.FirstName, c.LastName })
.Searchable(c => c.FirstName) // → "Coworker.FirstName"
.Searchable(c => c.LastName) // → "Coworker.LastName"
.CompositeSearchable(c => c.FirstName, c => c.LastName, cfg => cfg
.WithTemplate(@"^(?<FirstName>.+?)\s+(?<LastName>.+)$")));
Bare-term "John" expands to: Title StartsWith "John" OR Coworker.FirstName StartsWith "John" OR Coworker.LastName StartsWith "John".
Multi-level nesting produces fully qualified paths (Address.City.Name). Search groups defined inside navigations have their names and field references prefixed automatically.
Composite Search Rules
Purpose: Decompose bare search terms into per-field AND conditions — "John Smith" becomes FirstName StartsWith "John" AND LastName StartsWith "Smith" instead of searching each field independently. Produces index-friendly per-field conditions.
Expression-based (shorthand) — infer fields and separators from string concatenation:
// Auto-generates regex from expression: ^(?<FirstName>.+?) (?<LastName>.+)$
builder.CompositeSearchable(e => e.FirstName + " " + e.LastName);
// With optional operator/weight configuration:
builder.CompositeSearchable(e => e.FirstName + " " + e.LastName, cfg => cfg
.WithSearchOperator(FilterOperator.Equal)
.WithWeight(2.0));
Input "John Smith" decomposes to FirstName StartsWith "John" AND LastName StartsWith "Smith".
Input "Mary Jane Smith" decomposes to FirstName StartsWith "Mary" AND LastName StartsWith "Jane Smith" (last group is greedy).
Input "John" (no space) does not match — the composite rule is skipped and other rules handle the term.
Regex-template (explicit) — define templates with named capture groups:
builder.CompositeSearchable(u => u.FirstName, u => u.LastName, cfg => cfg
.WithTemplate(@"^(?<FirstName>[\w'-]+)\s+(?<LastName>[\w'-]+)$")
.WithTemplate(@"^(?<LastName>[\w'-]+),\s*(?<FirstName>[\w'-]+)$"));
[\w'-]+ matches Unicode letters, digits, hyphens, and apostrophes — handling names like "O'Brien" and "Mary-Jane". Consumers should choose capture group patterns appropriate for their data.
Multiple templates allow matching different input formats. Templates are tried in registration order; first match wins.
With operator and weight:
builder.CompositeSearchable(u => u.FirstName, u => u.LastName, cfg => cfg
.WithTemplate(@"^(?<FirstName>[\w'-]+)\s+(?<LastName>[\w'-]+)$")
.WithSearchOperator(FilterOperator.Equal)
.WithWeight(2.0));
Three-field composite:
builder.CompositeSearchable(u => u.FirstName, u => u.MiddleName, u => u.LastName, cfg => cfg
.WithTemplate(@"^(?<FirstName>[\w'-]+)\s+(?<MiddleName>[\w'-]+)\s+(?<LastName>[\w'-]+)$"));
Composite + regular search coexistence:
builder
.ExposeProperties(e => new { e.FirstName, e.LastName, e.Email })
.CompositeSearchable(e => e.FirstName, e => e.LastName, cfg => cfg
.WithTemplate(@"^(?<FirstName>[\w'-]+)\s+(?<LastName>[\w'-]+)$")
.WithTemplate(@"^(?<LastName>[\w'-]+),\s*(?<FirstName>[\w'-]+)$"))
.Searchable(e => e.Email);
// "John Smith" → (FirstName AND LastName) OR Email
// "john@example.com" → composite skipped, Email matches
A field can appear in both regular and composite search rules. Both participate independently — composite match produces AND conditions while regular match produces a single-field condition. Both results join the outer OR.
Behavior rules:
| Scenario | Behavior |
|---|---|
| Composite template matches | Per-field AND conditions join the OR with other rules |
| No composite template matches | Composite rule is skipped (not an error) |
| Exclusive pattern on a regular rule matches | Short-circuits entire expansion — composite rules not evaluated |
| Any captured value is empty/whitespace | Composite match skipped (avoids StartsWith "" matching everything) |
| Template capture group not in declared fields | InvalidOperationException at configuration time |
| Declared field missing from template | InvalidOperationException at configuration time |
| Unnamed positional capture group in template | ArgumentException at configuration time |
| Expression with < 2 properties | ArgumentException at configuration time |
| Expression with no separators | ArgumentException at configuration time |
| Property not exposed via ExposeProperties() | InvalidOperationException at configuration time |
| Regex timeout during search | Warning added to parse result, template skipped |
Search Groups
Purpose: Named subsets of searchable fields that conditionally narrow bare-term expansion based on the shape of the search term, or that consumers explicitly select by name.
Without groups, bare-term expansion fans out to every Searchable() field. When a user searches "September," every field is tried — including string fields where "September" is noise. Search groups let the schema declare that date-typed fields should handle date expressions exclusively, while string fields handle name-like terms.
Activation modes:
| Mode | Trigger | Behavior |
|---|---|---|
| Auto-activation | Group has a When() condition that returns true for the term |
Exclusive groups narrow the field set; inclusive groups have no effect |
| Consumer selection | Caller specifies ?searchGroup=dates |
Group's fields are used exclusively regardless of declared mode |
| Fallback | No group condition matches, no consumer selection | All Searchable() fields participate (existing behavior) |
Exclusive vs. inclusive groups:
- Exclusive — when activated (auto or consumer), suppresses all non-group fields. Only the group's fields participate in expansion.
- Inclusive (default) — exists for consumer selection only. During auto-activation, an inclusive group's condition has no effect on the field set. Inclusive groups let consumers target a named subset without requiring the group to narrow results automatically.
SearchGroupBuilder<TEntity> API:
| Method | Description |
|---|---|
Exclusive() |
Marks the group as exclusive (default is inclusive) |
When(Func<string, bool> condition) |
Sets the auto-activation predicate evaluated against the bare search term |
Fields(params Expression<Func<TEntity, object?>>[] selectors) |
Declares which fields belong to the group |
Registration example:
builder
.HasSourceName("employees")
.UseDbContext<HrDbContext>()
.ExposeProperties(e => new { e.Id, e.FirstName, e.LastName, e.LanId, e.HireDate, e.CreatedDate })
// Declare searchable fields first
.Searchable(e => e.FirstName)
.Searchable(e => e.LastName)
.Searchable(e => e.LanId)
.Searchable(e => e.HireDate, s => s.WithDateExpressionSearch())
.Searchable(e => e.CreatedDate, s => s.WithDateExpressionSearch())
// Exclusive group — auto-activates when the term is a date expression
.SearchGroup("dates", g => g
.Exclusive()
.When(term => DateExpressionResolver.IsDateExpression(term))
.Fields(e => e.HireDate, e => e.CreatedDate))
// Inclusive group — consumer-selectable only, no auto-activation effect
.SearchGroup("names", g => g
.Fields(e => e.FirstName, e => e.LastName));
Searching "September":
- The "dates" group condition matches, and it is exclusive — only HireDate and CreatedDate participate
- Result:
HireDate >= Sept 1 AND HireDate < Oct 1 OR CreatedDate >= Sept 1 AND CreatedDate < Oct 1
Searching "John":
- The "dates" group condition does not match
- The "names" group has no condition (inclusive, consumer-selectable only)
- No exclusive group activated — full default expansion across all five fields
Searching with ?searchGroup=names:
- Consumer explicitly selects the "names" group — only FirstName and LastName participate regardless of the group's inclusive declaration
Multiple exclusive groups activating:
When multiple exclusive groups have conditions that match the same term, their field sets are unioned. This is not a conflict — the resulting field set is the combined fields from all activated exclusive groups.
Composite search rules and search groups:
Composite search rules (CompositeSearchable()) participate in search group filtering automatically. When a group is active (auto-activated or consumer-selected), a composite rule participates only if all of its fields are members of the active group's field set. If any field is outside the group, the composite rule is suppressed.
builder
.Searchable(e => e.FirstName)
.Searchable(e => e.LastName)
.Searchable(e => e.LanId)
.CompositeSearchable(e => e.FirstName + " " + e.LastName)
.SearchGroup("names", g => g.Fields(e => e.FirstName, e => e.LastName));
// With ?searchGroup=names:
// Regular rules: FirstName, LastName (LanId suppressed)
// Composite rule: FirstName+LastName → INCLUDED (all fields in group)
// If the group were "lanids" with only LanId:
// Composite rule: FirstName+LastName → SUPPRESSED (LastName not in group)
Groups can reference fields that only exist in composite rules — a field does not need a separate Searchable() declaration if it appears in a CompositeSearchable() call.
Build-time validation:
| Scenario | Behavior |
|---|---|
| Group name is null, empty, or whitespace | ArgumentException at registration time (when .SearchGroup() is called) |
| Duplicate group name in the same data source | InvalidOperationException at Build() time |
Field referenced in a group has no Searchable() or CompositeSearchable() rule |
InvalidOperationException at Build() time |
| No fields declared in a group | ArgumentException at registration time |
Exclusive group without When() condition |
InvalidOperationException at registration time |
Inclusive group with When() condition |
InvalidOperationException at registration time |
| Consumer specifies a group name that does not exist | QueryParseException listing available group names |
A field may appear in multiple groups. Groups do not affect the existing search pipeline (pattern matching, value resolvers, TryParse guards) — they filter which rules enter the pipeline.
Configuring Templates
Purpose: Define reusable query patterns (shortcuts/macros) scoped to a data source with automatic validation
Basic Template Configuration:
public class UserConfiguration : IDataSourceConfiguration<User>
{
public void Configure(DataSourceBuilder<User> builder)
{
builder
.HasSourceName("users")
.ExposeProperties(u => new { u.Id, u.FirstName, u.LastName, u.IsActive, u.Role })
.WithTemplates(t =>
{
// Simple templates (no parameters)
t.AddTemplate("activeUsers", "isActive:true")
.WithDescription("All active users");
t.AddTemplate("inactiveUsers", "isActive:false")
.WithDescription("All inactive users");
t.AddTemplate("recentUsers", "createdAt:l30d")
.WithDescription("Users created in the last 30 days");
});
}
}
Parameterized Templates with Named Parameters:
builder.WithTemplates(t =>
{
// Named parameter syntax (recommended)
t.AddTemplate("usersByRole", "role:{roleName} AND isActive:{activeStatus}")
.WithDescription("Find users by role and active status")
.WithParameter<string>("roleName", p => p
.WithDescription("User role (e.g., 'admin', 'user')"))
.WithParameter<bool>("activeStatus", p => p
.WithDescription("Active status (true/false)"));
t.AddTemplate("usersByName", "firstName:{firstName} OR lastName:{lastName}")
.WithDescription("Find users by first or last name")
.WithParameter<string>("firstName", p => p
.WithDescription("User's first name"))
.WithParameter<string>("lastName", p => p
.WithDescription("User's last name"));
});
// Usage in queries:
// $usersByRole(admin, true) → role:admin AND isActive:true
// $usersByName(John, Smith) → firstName:John OR lastName:Smith
Parameter Validation Rules:
builder.WithTemplates(t =>
{
// Range validation
t.AddTemplate("usersByAge", "age:>{minAge} AND age:<{maxAge}")
.WithDescription("Find users within age range")
.WithParameter<int>("minAge", p => p
.WithDescription("Minimum age")
.InRange(1, 120))
.WithParameter<int>("maxAge", p => p
.WithDescription("Maximum age")
.InRange(1, 120));
// Regex validation
t.AddTemplate("companiesByIndustry", "industry:{industryName} AND employeeCount:>{minCount}")
.WithDescription("Search companies by industry and size")
.WithParameter<string>("industryName", p => p
.WithDescription("Industry name")
.Matching(@"^[a-zA-Z\s]+$")) // Only letters and spaces
.WithParameter<int>("minCount", p => p
.WithDescription("Minimum employee count")
.InRange(1, 1000000));
// Custom lambda validation (now type-safe!)
t.AddTemplate("evenNumbers", "value:{number}")
.WithDescription("Filter by even numbers")
.WithParameter<int>("number", p => p
.WithDescription("Must be even")
.WithValidation(value => // value is int?, not object!
{
if (value.HasValue && value.Value % 2 == 0)
return ParameterValidationResult.Success();
return ParameterValidationResult.Failure("Value must be even");
}));
});
Template Validation:
Templates configured via .WithTemplates() are automatically validated during application startup:
What Gets Validated:
- ✅ Syntax: Invalid operators, parentheses, malformed expressions
- ✅ Fields: Unknown field names (with "Did you mean?" suggestions)
- ✅ Functions: Function names exist (
@today,@me, etc.) - ✅ Features: Required features are registered
- ✅ Parameters: Parameter names in pattern match configured parameters
What Doesn't Get Validated (Runtime Only):
- ❌ Parameter types and values (validated when template is invoked)
- ❌ Function results (e.g.,
@me→ actual user ID)
Validation Error Example:
❌ Template 'searchUsers' in source 'users' (Provider: ConfiguredQueryTemplateProvider):
Pattern: usernam:~{searchTerm} OR email:~{searchTerm}
Error: Field 'usernam' not found in data source 'users'. Did you mean: username, userName?
Template Scoping:
- Templates are scoped to their data source (SourceName)
- A template named "active" in "users" is separate from "active" in "orders"
- Templates are stored in-memory alongside other configuration metadata
- No database persistence required for built-in templates
- Validated automatically during application startup
Template Priority System: Templates can come from multiple providers with different priorities:
- 10-40: Custom templates (user-scoped, team-scoped, database-backed)
- 50: Built-in templates (configured via
WithTemplates()) - 100+: Fallback templates (system defaults)
Lower priority numbers win. User templates (10) override built-in templates (50).
Named Parameter Syntax:
- Use
{paramName}in template patterns (e.g.,field:{paramName}) - Parameter names are self-documenting and order-independent
- Parameters are automatically extracted from the pattern
- No separate parameter hints needed
Validation Rules:
- Range Validation:
.InRange(min, max)- Validates numeric parameters - Regex Validation:
.Matching(pattern)- Validates string parameters against regex - Custom Validation:
.WithValidation(func)- Custom lambda validation
Requirements:
WithDescription()is REQUIRED for all templates.WithParameter()is OPTIONAL but recommended for parameter metadata- Template names must be unique within a data source
- Parameter validation runs when template is invoked (runtime)
Configuration Composition
Purpose: Reuse base configurations and create specialized variants with property exclusions
Base Configuration Pattern:
// Create reusable base configuration with all properties
public class BaseUserConfiguration : IDataSourceConfiguration<User>
{
public void Configure(DataSourceBuilder<User> builder)
{
builder
.ExposeProperties(u => new { u.Id, u.FirstName, u.LastName, u.Email, u.DateOfBirth, u.LastLoginAt })
.AddAlias(u => u.FirstName, "fname", "givenName")
.AddAlias(u => u.Email, "emailAddress", "mail")
.HasFormatting(u => u.FirstName, fmt => fmt.AsTitleCase());
}
}
Variant Configurations:
// Public user profile - excludes sensitive data
public class PublicUserConfiguration : IDataSourceConfiguration<User>
{
public void Configure(DataSourceBuilder<User> builder)
{
builder
.HasSourceName("public-users")
.HasDisplayName("Public User Profiles")
.UseDbContext<AppDbContext>()
.UseDefault<BaseUserConfiguration>() // Apply base configuration
.ExcludeProperty(u => u.Email) // Exclude sensitive fields
.ExcludeProperty(u => u.DateOfBirth)
.ExcludeProperty(u => u.LastLoginAt);
}
}
// Simplified orders - excludes navigation properties for performance
public class SimpleOrderConfiguration : IDataSourceConfiguration<Order>
{
public void Configure(DataSourceBuilder<Order> builder)
{
builder
.HasSourceName("simple-orders")
.UseDbContext<AppDbContext>()
.UseDefault<BaseOrderConfiguration>()
.ExcludeProperty(o => o.User) // Exclude navigations
.ExcludeProperty(o => o.OrderItems);
}
}
Composition Methods:
UseDefault<TConfiguration>()
- Applies another configuration to the current builder
- TConfiguration must have parameterless constructor
- Inherits all properties, aliases, and formatting rules
- Can be chained with additional configuration
ExcludeProperty(Expression<Func<TEntity, object?>>)
- Removes a previously exposed property
- Property must have been exposed via ExposeProperties()
- Removes property and all its metadata (aliases, formatting)
- Returns builder for method chaining
- Accepts nullable properties (
DateTime?, etc.)
Use Cases:
- Security: Create public-facing variants that exclude sensitive fields (email, SSN, passwords)
- Performance: Create lightweight variants without navigation properties for high-throughput scenarios
- Regional: Create locale-specific variants (domestic addresses without country field)
- API Versioning: Create v1/v2 variants with different field sets
Execution Order:
- Configure source name, display name, DbContext
- Apply
UseDefault<T>()- copies all configuration from base - Apply any additional configuration (ExposeProperties, aliases, formatting)
- Apply
ExcludeProperty()calls - removes specified properties - Validate and build
Services
InMemorySchemaProvider
Purpose: Singleton registry holding all DataSourceDefinition objects.
Interface:
public sealed class InMemorySchemaProvider
{
// Registration (startup only)
void Register(DataSourceDefinition dataSource);
// Lookups (runtime)
DataSourceDefinition? GetBySourceName(string sourceName);
IReadOnlyCollection<DataSourceDefinition> GetAll();
bool IsRegistered(string sourceName);
int Count { get; }
}
Thread Safety: Uses ConcurrentDictionary<string, DataSourceDefinition> with case-insensitive comparer.
Registration:
services.AddSingleton<InMemorySchemaProvider>();
ConfigurationDiscoveryService
Purpose: Scans assemblies for IDataSourceConfiguration<T> implementations and builds DataSourceDefinition objects.
Process:
- Scan - Reflect over assemblies for types implementing
IDataSourceConfiguration<T> - Extract - Get
TEntityfrom generic interface - Create - Instantiate
DataSourceBuilder<TEntity>viaActivator.CreateInstance - Configure - Call
Configure(builder)method - Build - Call
Build()to getDataSourceDefinition
Registration:
services.AddScoped<ConfigurationDiscoveryService>();
SchemaRegistryBootstrapper
Purpose: IHostedService that orchestrates discovery and registration at startup.
Lifecycle:
public async Task StartAsync(CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
// 1. Discover configurations via reflection
var definitions = await _discoveryService.DiscoverFromAssembliesAsync(_assemblies);
// 2. Register in InMemorySchemaProvider
foreach (var definition in definitions)
_schemaProvider.Register(definition);
// 3. Log diagnostics
_logger.LogInformation(
"Registered {Count} data sources in {Elapsed}ms: {SourceNames}",
definitions.Count, stopwatch.ElapsedMilliseconds, string.Join(", ", sourceNames));
}
Registration:
// Factory pattern to pass assemblies dynamically
services.AddHostedService(sp =>
new SchemaRegistryBootstrapper(
sp.GetRequiredService<ConfigurationDiscoveryService>(),
sp.GetRequiredService<InMemorySchemaProvider>(),
sp.GetRequiredService<ILogger<SchemaRegistryBootstrapper>>(),
assemblies));
InMemorySchemaRegistry
Purpose: Implements ISchemaRegistry for query validation with caching.
Methods:
public interface ISchemaRegistry
{
ValueTask<ValidationResult> ValidateQueryAsync(QueryDefinition query);
ValueTask<bool> IsValidSourceAsync(string sourceName);
ValueTask<ColumnMetadata?> GetColumnMetadataAsync(string sourceName, string fieldName);
}
Validation Logic:
- Check data source exists
- Validate all fields in FilterDefinition (recursive for LogicalOperator)
- Check field existence (canonical name or alias)
- Provide "Did you mean?" suggestions using Levenshtein distance
- Validate nested paths ("address.city")
Caching:
// IMemoryCache with 24-hour TTL
var cacheKey = $"validation:{query.From.Source}:{query.GetHashCode()}";
if (_cache.TryGetValue<ValidationResult>(cacheKey, out var cached))
return cached;
// ... validation logic ...
_cache.Set(cacheKey, result, TimeSpan.FromHours(24));
Registration:
services.AddScoped<ISchemaRegistry, InMemorySchemaRegistry>();
ColumnResolver
Purpose: Static utility for resolving field names against column metadata with case-insensitive and alias matching.
Methods:
| Method | Description |
|---|---|
TryResolveTopLevel(columns, fieldName, out column) |
Resolves a single (non-dotted) field name against top-level columns. Matches by dictionary key, FieldName, PropertyInfo.Name, and Aliases. |
TryResolvePath(columns, fieldPath, out leafColumn, out traversesCollection) |
Resolves a dot-notation path (e.g., "address.city") by traversing NestedProperties at each segment using TryResolveTopLevel for alias support. Sets traversesCollection if any segment is a collection navigation. |
CollectAllFieldPaths(columns) |
Enumerates all field paths including nested navigation paths (e.g., ["Id", "Address", "Address.City"]). |
Both TryResolveTopLevel and TryResolvePath have DataSourceDefinition? convenience overloads that return false for null input.
GroupedSelectClassifier
Purpose: Single source of truth for classifying a grouped SELECT (a select clause paired with a groupBy field) against a data source schema. Both the EntityFramework aggregation expression builder and the endpoint grouped-select validator consume it, so execution and validation share one contract.
Method:
public static IReadOnlyList<GroupedSelectTerm> Classify(
SelectionDictionary select, DataSourceDefinition source, string groupByField);
Returns terms in entry order: one per top-level select entry, except a column with per-column aggregates yields a single AggregateColumn for its valid aggregates plus a separate Invalid term for each invalid nested key (e.g. Salary.stddev, carrying a dotted Path) — so a column whose nested keys are all invalid yields only Invalid terms. Keys resolve through ColumnResolver (case-insensitive, alias-aware, dotted); the groupByField is canonicalized to its ColumnDefinition.FieldName so the group-key match is consistent with execution. Both the raw wire form (count:true) and the empty-config form (count:{}) are accepted.
Precedence: a registered column wins over the aggregate-keyword interpretation — a column literally named count classifies as a column, not the COUNT aggregate.
Keyword SSOT: the set of aggregate keywords (count, sum, avg, average, min, max) is owned by AggregateKeywords.IsSupportedAggregate (in QueryBuilder.Core). The classifier delegates keyword recognition to it rather than carrying its own list.
Terms (GroupedSelectTerm): a closed record hierarchy.
| Term | Meaning |
|---|---|
GroupKey(Path) |
The entry is the GROUP BY key. |
Count(Path) |
The entry is a COUNT over the group. |
AggregateColumn(Path, Aggregates) |
A resolved column with one or more supported aggregates kept nested (e.g. Salary:{sum,avg} → one term, Aggregates = ["sum","avg"]). Per-column count is not a supported aggregate. |
Invalid(Path, Reason) |
The entry (or one nested key) is not admissible. |
Invalid reasons (GroupedSelectError): BareAggregateNeedsTarget, UnsupportedAggregate, BareNonGroupedColumn, GroupKeyMustBeLeaf, GroupKeyNotProjected (the select never projects the GROUP BY key), NestedNavigationUnsupported, ExcludedLeaf, NotAColumn. The reasons are neutral domain data (no message text) — the endpoint maps them to validation error codes and prose; the aggregation builder throws on them.
FieldAliasResolver
Purpose: Resolves field aliases to canonical names with nested property support.
Methods:
public interface IFieldAliasResolver
{
ValueTask<FieldResolutionResult?> ResolveFieldAsync(string sourceName, string fieldName, CancellationToken cancellationToken = default);
ValueTask<Dictionary<string, FieldResolutionResult>?> ResolveFieldsAsync(string sourceName, IEnumerable<string> fieldNames, CancellationToken cancellationToken = default);
ValueTask<IReadOnlyList<string>?> GetAliasesAsync(string sourceName, string canonicalName, CancellationToken cancellationToken = default);
ValueTask<ValidationResult> ValidateFieldsAsync(string sourceName, IEnumerable<string> fieldNames, CancellationToken cancellationToken = default);
}
Resolution Algorithm:
- Check if field exists as canonical name (exact match)
- Check if field exists as canonical name (case-insensitive)
- Check if field is an alias (forward lookup)
- Check nested properties ("address.city")
- Split on '.' and traverse NestedProperties dictionary
- Resolve aliases at each level
- Return null if not found
Levenshtein Suggestions:
// If field not found, suggest similar fields
var allFields = GetAllFieldsIncludingNested();
var suggestions = allFields
.Select(f => (Field: f, Distance: LevenshteinDistance(fieldOrAlias, f)))
.Where(x => x.Distance <= 2) // Max 2 character edits
.OrderBy(x => x.Distance)
.Take(3)
.Select(x => x.Field)
.ToArray();
Registration:
services.AddSingleton<IFieldAliasResolver, FieldAliasResolver>();
NestedSelectionValidator
Purpose: Validates select trees (including nested SelectionConfig) against schema metadata before execution strategies build projections.
Behavior:
- Enforces field existence and nested path validity
- Enforces maximum nesting depth (
MaxNestingDepth) - Validates collection limits against schema or global defaults (
DefaultCollectionLimit/MaxCollectionLimit) - Enforces relation-option rules (
where/orderBy/limit/offsetonly on collections) - Uses
IFieldAliasResolverfor"Did you mean ..."suggestions on unknown fields
Registration:
services.AddScoped<INestedSelectionValidator, NestedSelectionValidator>();
Validation
Query Validation
ValidateQueryAsync:
var query = new QueryDefinition
{
From = new SourceDefinition { Source = "users" },
Where = new FilterDefinition
{
Field = "isActiv", // Typo
Operator = FilterOperator.Eq,
Value = true
}
};
var result = await schemaRegistry.ValidateQueryAsync(query);
// Result:
{
IsValid: false,
Errors: [
{
Field: "isActiv",
Message: "Field 'isActiv' does not exist in data source 'users'",
Suggestions: ["isActive", "isVerified"]
}
]
}
Field Validation
ValidateFieldsAsync:
var result = await fieldAliasResolver.ValidateFieldsAsync(
"users",
new[] { "firstName", "lname", "invalidField" });
// Result:
{
IsValid: false,
Errors: [
{
Field: "invalidField",
Message: "Field 'invalidField' not found",
Suggestions: ["firstName", "lastName", "email"]
}
]
}
Nested Property Validation
Dot-Notation Paths:
// Valid nested paths
"address.city" // ✓ Canonical nested path
"addr.city" // ✓ Parent alias
"address.town" // ✓ Child alias
"location.municipality" // ✓ Both aliases
// Invalid paths
"address.invalidField" // ✗ Child field doesn't exist
"invalidParent.city" // ✗ Parent navigation doesn't exist
"address.city.zipCode" // ✗ City is not a navigation property
Field Alias Resolution
Algorithm
ResolveFieldAsync:
public async ValueTask<string?> ResolveFieldAsync(string sourceName, string fieldOrAlias)
{
// 1. Get data source
var dataSource = _schemaProvider.GetBySourceName(sourceName);
if (dataSource == null) return null;
// 2. Check if it's a nested path (contains '.')
if (fieldOrAlias.Contains('.'))
return TraverseNestedPath(fieldOrAlias, dataSource.Columns);
// 3. Check canonical name (case-insensitive)
if (dataSource.Columns.TryGetValue(fieldOrAlias, out var column))
return column.FieldName;
// 4. Check aliases (forward lookup)
foreach (var col in dataSource.Columns.Values)
{
if (col.Aliases.Contains(fieldOrAlias, StringComparer.OrdinalIgnoreCase))
return col.FieldName;
}
return null; // Not found
}
Nested Path Traversal
Example:
// Field: "address.city"
// Configuration:
builder
.ConfigureNavigation(u => u.Address, addr =>
addr.ExposeProperties(a => new { a.Street, a.City, a.State })
.AddAlias("city", "town", "municipality"));
// Traversal:
// 1. Split: ["address", "city"]
// 2. Find "address" in root Columns (navigation property)
// 3. Get NestedProperties dictionary from "address" ColumnDefinition
// 4. Find "city" in NestedProperties (or resolve alias "town", "municipality")
// 5. Return canonical path: "Address.City"
Performance Considerations
Singleton Lifetime:
- FieldAliasResolver registered as singleton (stateless service)
- Zero allocations per request
- Direct dictionary lookups (O(1) average case)
ValueTask Pattern:
- All methods return
ValueTask<T>(notTask<T>) - Synchronous execution without heap allocation
- Flexibility for future async operations if needed
Architecture
Component Diagram
┌─────────────────────────────────────────────────────────────┐
│ Application Startup │
└─────────────────┬───────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ SchemaRegistryBootstrapper │
│ (IHostedService) │
└─────────────────┬───────────────────────────────────────────┘
│
├─► ConfigurationDiscoveryService
│ (Reflect assemblies for IDataSourceConfiguration<T>)
│
├─► Create DataSourceBuilder<TEntity>
│ (Dynamically via Activator.CreateInstance)
│
├─► Call Configure(builder)
│ (Fluent API execution)
│
├─► Build() → DataSourceDefinition
│ (Validation and construction)
│
└─► Register in InMemorySchemaProvider
(Singleton ConcurrentDictionary)
┌─────────────────────────────────────────────────────────────┐
│ Query Execution │
└─────────────────┬───────────────────────────────────────────┘
│
├─► Query Parser (JSON/Shorthand)
│ (Parse query → QueryDefinition)
│
├─► Schema Registry Validation
│ (Validate fields, suggest corrections)
│
├─► Execution Strategy Selection
│ (EntityFramework, InMemory)
│
└─► Query Execution
(Apply filters, sorting, projections)
Data Flow
Startup (Once):
IDataSourceConfiguration<User>
→ DataSourceBuilder<User>.Configure()
→ DataSourceBuilder<User>.Build()
→ DataSourceDefinition
→ InMemorySchemaProvider.Register()
Runtime (Per Request):
Query (JSON/Shorthand)
→ Parser
→ QueryDefinition
→ Schema Registry Validation
→ Execution Strategy
→ Database/InMemory Execution
→ Results
Dependencies
NuGet Packages:
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Humanizer.Core" />
<PackageReference Include="FuzzySharp" />
<PackageReference Include="NJsonSchema" />
Project References:
<ProjectReference Include="..\QueryBuilder.Core\QueryBuilder.Core.csproj" />
Performance
Startup Performance
Optimizations:
- Reflection caching (TypeInfo, MethodInfo) avoids repeated reflection
- Parallel assembly scanning when multiple assemblies are configured
- Fail-fast validation surfaces registration errors immediately
Runtime Performance
Query Validation:
- Validated results are cached via IMemoryCache to avoid repeated field resolution
- Cache misses incur field resolution and Levenshtein distance computation
- Cache entries use a 24-hour TTL
Field Resolution:
- Canonical name lookup uses dictionary-based O(1) access
- Alias traversal performs a linear scan per column
- Nested path resolution chains multiple dictionary lookups (one per path segment)
Memory Footprint
InMemorySchemaProvider:
- Stores one DataSourceDefinition per registered source and one ColumnDefinition per column
- Memory scales linearly with the number of registered sources and columns
Reflection Caching:
- MethodInfo objects are cached per DbContext type, not per data source
Formatting Metadata:
- Pre-computed lists stored in closure variables
- No per-request allocations for formatting rules
Testing
Unit Tests
Test Categories:
Configuration Builder Tests
- Property exposure validation
- Navigation depth limits
- Alias registration
- Formatting rules
Field Alias Resolver Tests (50 tests, 62ms)
- Canonical name resolution
- Alias resolution (single and multiple)
- Nested property paths
- Levenshtein suggestions
- Batch operations
- Edge cases
Schema Registry Tests
- Query validation
- Data source existence checks
- Field validation with suggestions
- Caching behavior
Example Test:
[Fact]
public async Task ResolveFieldAsync_Should_Resolve_Nested_Property_With_Parent_And_Child_Aliases()
{
// Arrange
var dataSource = new DataSourceDefinition
{
SourceName = "users",
Columns = new Dictionary<string, ColumnDefinition>
{
["Address"] = new ColumnDefinition
{
FieldName = "Address",
Aliases = new[] { "addr", "location" },
NestedProperties = new Dictionary<string, ColumnDefinition>
{
["City"] = new ColumnDefinition
{
FieldName = "City",
Aliases = new[] { "town", "municipality" }
}
}
}
}
};
_schemaProvider.Register(dataSource);
// Act
var result = await _resolver.ResolveFieldAsync("users", "location.municipality");
// Assert
result.ShouldBe("Address.City"); // Both aliases resolved
}
Integration Tests
Pending (Requires SQL Server):
- End-to-end query execution
- SQL field projection verification
- Filtering, sorting, pagination validation
- DbContext lifecycle verification
Migration from Database-Backed Registry
Old Approach (Database-Backed):
// Manual registration via service
await registrationService.RegisterEntityFrameworkSourceAsync(
sourceName: "users",
dbContextType: typeof(AppDbContext),
entityType: typeof(User),
propertiesToRegister: new[] { "Id", "Username", "Email" },
createdBy: "admin"
);
// Database schema
CREATE TABLE DataSourceRegistry (...)
CREATE TABLE ColumnMetadata (...)
CREATE TABLE FormattingInstructions (...)
// + Migrations, DbContext, repositories, caching...
New Approach (Code-First):
// Single configuration class
public class UserConfiguration : IDataSourceConfiguration<User>
{
public void Configure(DataSourceBuilder<User> builder)
{
builder
.HasSourceName("users")
.ExposeProperties(u => new { u.Id, u.Username, u.Email });
}
}
// Automatic discovery and registration
builder.Services
.AddQueryBuilder(options => { /* conventions */ })
.RegisterDbContextFactory<AppDbContext>();
Advanced Scenarios
Multi-DbContext Support
Example:
public class AppDbContext : DbContext { }
public class ReportingDbContext : DbContext { }
// Configuration for AppDbContext
public class UserConfiguration : IDataSourceConfiguration<User>
{
public void Configure(DataSourceBuilder<User> builder) { ... }
}
// Configuration for ReportingDbContext
public class SalesReportConfiguration : IDataSourceConfiguration<SalesReport>
{
public void Configure(DataSourceBuilder<SalesReport> builder) { ... }
}
// Resolver selects correct DbContext by type
public class DbContextResolver : IDbContextResolver
{
public DbContext Resolve(Type dbContextType) => dbContextType.Name switch
{
nameof(AppDbContext) => _appDbContext,
nameof(ReportingDbContext) => _reportingDbContext,
_ => throw new InvalidOperationException($"Unknown DbContext: {dbContextType.Name}")
};
}
Global Conventions
Configuration:
builder.Services
.AddQueryBuilder(options =>
{
options.ConfigureSchemaRegistry(schema =>
{
// Naming conventions
schema.DefaultSourceNameCase = SourceNameCase.CamelCase; // "User" → "users"
schema.DefaultPropertyNameCase = PropertyNameCase.CamelCase; // "FirstName" → "firstName"
});
// Assembly scanning (defaults to entry assembly)
options.ScanAssemblies(typeof(Program).Assembly);
})
.RegisterDbContextFactory<AppDbContext>();
Custom Formatting Strategies
Future Enhancement (Not Yet Implemented):
// Custom strategy registration
services.AddFormattingStrategy<PhoneNumberFormattingStrategy>();
// Usage in configuration
builder.HasFormatting(u => u.PhoneNumber, fmt => fmt.Custom("PhoneNumber",
new { format = "(###) ###-####" }));
Troubleshooting
Configuration Not Discovered
Symptom: [SchemaRegistryBootstrapper] Registered 0 data sources
Causes:
- Configuration class doesn't implement
IDataSourceConfiguration<TEntity> - Assembly not passed to
AddCodeFirstSchemaRegistry() - Configuration class is private or internal
- Generic type parameter mismatch
Solution:
// Ensure public class with correct interface
public class UserConfiguration : IDataSourceConfiguration<User> { ... }
// Ensure assembly is scanned
builder.Services
.AddQueryBuilder(options =>
{
// Scan specific assemblies (defaults to entry assembly if not specified)
options.ScanAssemblies(
typeof(Program).Assembly, // ← Include this assembly
typeof(SharedConfig).Assembly // ← Include additional assemblies if needed
);
})
.RegisterDbContextFactory<AppDbContext>();
Validation Errors Not Showing
Symptom: Invalid queries execute without validation errors
Cause: SchemaRegistry not registered or not called
Solution:
// Ensure ISchemaRegistry is registered
builder.Services.AddQueryBuilder(options => { }); // Registers ISchemaRegistry automatically
// Call validation before execution
var validation = await schemaRegistry.ValidateQueryAsync(query);
if (!validation.IsValid)
{
foreach (var error in validation.Errors)
Console.WriteLine($"{error.Field}: {error.Message}");
return;
}
Future Enhancements
Planned Features
- Schema Versioning - Support multiple schema versions for backward compatibility
- Field-Level Security - RBAC for sensitive fields
- Audit Logging - Track schema changes and access patterns
- Schema Drift Detection - Warn when EF Core entities change
- Custom Formatting Strategies - Plugin system for client-specific formatters
- Query Templates - User-defined shorthand macros
Under Consideration
- Admin UI - Web-based schema management
- InMemory Data Sources - Support non-EF data via
IDataSourceProvider<T>(partially implemented) - Multi-Tenant Schema - Tenant-specific data source configurations
License
See LICENSE file in repository root.
Support
- Documentation: Universal Query Builder Docs
- Issues: GitHub Issues
- Discussions: GitHub Discussions
| 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
- FuzzySharp (>= 2.0.2)
- Humanizer.Core (>= 3.0.1)
- Microsoft.EntityFrameworkCore (>= 10.0.3)
- Microsoft.Extensions.Caching.Memory (>= 10.0.3)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.3)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.3)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.3)
- Microsoft.Extensions.Options (>= 10.0.3)
- NJsonSchema (>= 11.5.2)
- UniversalQueryBuilder.Core (>= 10.0.13-beta)
NuGet packages (5)
Showing the top 5 NuGet packages that depend on UniversalQueryBuilder.SchemaRegistry:
| Package | Downloads |
|---|---|
|
UniversalQueryBuilder.Expressions
Expression compilation engine with two-level caching for Universal Query Builder. Provides high-performance filter compilation using FastExpressionCompiler. |
|
|
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. |
|
|
UniversalQueryBuilder.Extensions
Consolidated service registration API for Universal Query Builder. Single fluent extension method replaces 9 scattered registration methods. |
|
|
UniversalQueryBuilder.Endpoints
Minimal API endpoints for Universal Query Builder - provides ready-to-use HTTP endpoints for query execution, schema introspection, and validation |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 10.0.13-beta | 57 | 6/3/2026 |
| 10.0.12-beta | 73 | 6/1/2026 |
| 10.0.11-beta | 75 | 5/31/2026 |
| 10.0.10-beta | 76 | 5/28/2026 |
| 10.0.9-beta | 72 | 5/27/2026 |
| 10.0.8-beta | 80 | 5/18/2026 |
| 10.0.7-beta | 77 | 5/16/2026 |
| 10.0.6-beta | 81 | 5/11/2026 |
| 10.0.5-beta | 73 | 4/30/2026 |
| 10.0.4-beta | 62 | 4/23/2026 |
| 10.0.3-beta | 83 | 4/23/2026 |
| 10.0.2-beta | 71 | 4/10/2026 |
| 10.0.1-beta | 59 | 4/10/2026 |
| 10.0.0-beta | 63 | 4/9/2026 |