UniversalQueryBuilder.SchemaRegistry 10.0.13-beta

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

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

What is QueryBuilder.SchemaRegistry?

QueryBuilder.SchemaRegistry is the metadata layer for the Universal Query Builder system. It provides:

  1. Code-First Configuration - Define queryable data sources using type-safe fluent API
  2. Automatic Discovery - Reflection-based scanning finds all configurations at startup
  3. In-Memory Registry - Fast, cached metadata lookups with zero database overhead
  4. Validation - Query validation with "Did you mean?" suggestions
  5. Field Aliases - Flexible field naming for user-friendly queries
  6. 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:

  1. IDataSourceConfiguration<TEntity> - Marker interface for auto-discovery
  2. DataSourceBuilder<TEntity> - Fluent API for configuration
  3. ConfigurationDiscoveryService - Reflection-based scanner
  4. 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:

  1. Startup - SchemaRegistryBootstrapper (IHostedService) runs during app startup
  2. Assembly Scan - Reflects over assemblies looking for IDataSourceConfiguration<T>
  3. Generic Type Extraction - Extracts TEntity from IDataSourceConfiguration<TEntity>
  4. Builder Creation - Creates DataSourceBuilder<TEntity> dynamically via Activator.CreateInstance
  5. Configuration Invocation - Calls Configure(builder) method
  6. Registration - Calls Build() and registers result in InMemorySchemaProvider

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 DataSourceDefinition objects
  • ConcurrentDictionary for thread-safe lookups (case-insensitive)
  • Zero database overhead
  • Populated once at startup, read-only during operation

InMemorySchemaRegistry:

  • Implements ISchemaRegistry for query validation
  • Uses IMemoryCache for 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()):

  1. SourceName required (non-null, non-empty, non-whitespace)
  2. Execution mode required — either UseDbContext<T>() or InMemoryOnly()
  3. At least one property exposed
  4. No duplicate property names (case-insensitive)
  5. Max navigation depth: 4 levels
  6. Valid formatting strategy names
  7. Property expressions must reference TEntity
  8. 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.city are rejected; use aliases like city when you want user-friendly query terms.
  • Value types must be nullable (int?, decimal?, DateTime?, etc.) because JSON_VALUE returns NULL for missing paths and conversion failures.
  • TRY_CONVERT returns NULL silently when a JSON value cannot be converted to the target type. Callers cannot distinguish "missing path" from "conversion failure" at the SQL level. JSON_VALUE also returns NULL if the extracted value exceeds 4000 characters.
  • v1 is scalar-only. Arrays, objects, JSON_QUERY, and OPENJSON are 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

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 custom IEnumerable<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:

  1. [Display(Name = "...")] from System.ComponentModel.DataAnnotations
  2. [DisplayName("...")] from System.ComponentModel
  3. Humanize() — 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):

  1. HasDisplayName(prop, name) — explicit per-property override
  2. Builder-level convention (builder.SetDisplayNameConvention(...))
  3. Global convention (options.SetDisplayNameConvention(...))
  4. 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 explicit orderBy specification
  • 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. Use WithDefaultOrdering() 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:

  • Properties projects the property names from Parts in order
  • KeyPropertyType returns the CLR type when there is exactly one part, null otherwise

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:

  1. ExecutionContext.QuerySplitting (runtime override) - highest priority
  2. DataSourceDefinition.EnableQuerySplitting (schema registry configuration)
  3. No change (EF Core default - single query mode)

Trade-offs:

  • Pro: Avoids Cartesian explosion and data duplication
  • Con: Multiple database round-trips

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() inside ConfigureNavigation

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 Metadata suffix (or Marker suffix) 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 explicit DbSet<TEntity> property. This allows registering derived entities in TPH/TPT hierarchies even when the context only exposes DbSet<BaseEntity>.
  • InMemoryOnly() — in-memory or schema-only source. Use for composite context types used with PredicateCompiler, or sources backed by an IDataSourceProvider.

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 denied
  • AccessRequirement.Authenticated — any authenticated user
  • AccessRequirement.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>) throw ArgumentException.
  • Non-final selectors must be nullable. The builder throws InvalidOperationException if 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. WithPrimaryKey rejects coalesced aliases, and WithCoalesce rejects backings that are already part of the primary key — both directions throw InvalidOperationException.
  • Backing properties with non-Open AccessPolicy, registered search rules, or per-property metadata cannot be folded — the builder throws InvalidOperationException so 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): sources is typed as IReadOnlyList<ColumnAccessor.Property> — nested Coalesce sources and JsonScalar sources 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): clrType must be non-null and not typeof(object) (type erasure would defeat SQL translation); a null value requires a nullable clrType (reference type or Nullable<T>); a non-null value must be assignable to clrType (after stripping Nullable<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 current ClaimsPrincipal from IUserAccessor. 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:

  1. EntityType: Specifies the entity to query from DbContext (e.g., Order)
  2. ProjectionType: Specifies the result type returned to callers (e.g., OrderSummaryDto)
  3. Projection Expression: Pre-compiled Expression<Func<Order, OrderSummaryDto>> applied to all queries
  4. Execution Strategy: EntityFrameworkExecutionStrategy detects ProjectionType != null and 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: january through december
  • Abbreviated month names: jan through dec (may serves as both)
  • Quarter names: q1 through q4

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:

  1. Configure source name, display name, DbContext
  2. Apply UseDefault<T>() - copies all configuration from base
  3. Apply any additional configuration (ExposeProperties, aliases, formatting)
  4. Apply ExcludeProperty() calls - removes specified properties
  5. 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:

  1. Scan - Reflect over assemblies for types implementing IDataSourceConfiguration<T>
  2. Extract - Get TEntity from generic interface
  3. Create - Instantiate DataSourceBuilder<TEntity> via Activator.CreateInstance
  4. Configure - Call Configure(builder) method
  5. Build - Call Build() to get DataSourceDefinition

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:

  1. Check data source exists
  2. Validate all fields in FilterDefinition (recursive for LogicalOperator)
  3. Check field existence (canonical name or alias)
  4. Provide "Did you mean?" suggestions using Levenshtein distance
  5. 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:

  1. Check if field exists as canonical name (exact match)
  2. Check if field exists as canonical name (case-insensitive)
  3. Check if field is an alias (forward lookup)
  4. Check nested properties ("address.city")
    • Split on '.' and traverse NestedProperties dictionary
    • Resolve aliases at each level
  5. 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:

  1. Enforces field existence and nested path validity
  2. Enforces maximum nesting depth (MaxNestingDepth)
  3. Validates collection limits against schema or global defaults (DefaultCollectionLimit / MaxCollectionLimit)
  4. Enforces relation-option rules (where/orderBy/limit/offset only on collections)
  5. Uses IFieldAliasResolver for "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> (not Task<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:

  1. Configuration Builder Tests

    • Property exposure validation
    • Navigation depth limits
    • Alias registration
    • Formatting rules
  2. Field Alias Resolver Tests (50 tests, 62ms)

    • Canonical name resolution
    • Alias resolution (single and multiple)
    • Nested property paths
    • Levenshtein suggestions
    • Batch operations
    • Edge cases
  3. 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:

  1. Configuration class doesn't implement IDataSourceConfiguration<TEntity>
  2. Assembly not passed to AddCodeFirstSchemaRegistry()
  3. Configuration class is private or internal
  4. 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

  1. Schema Versioning - Support multiple schema versions for backward compatibility
  2. Field-Level Security - RBAC for sensitive fields
  3. Audit Logging - Track schema changes and access patterns
  4. Schema Drift Detection - Warn when EF Core entities change
  5. Custom Formatting Strategies - Plugin system for client-specific formatters
  6. 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

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (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