Table of Contents

Data - Filtering, Sorting and Pagination

Ink.Data provides a platform-agnostic query model that is shared between UI components (DataGrid, Charts) and the server or data source that fulfils those queries. Ink.Data.EFCore translates that model directly into EF Core IQueryable<T> operations on the server.

Installation

Shared query model - reference from both client and server projects:

dotnet add package InventingAnimals.Ink.Data

EF Core server translator - reference from your API host or server project only:

dotnet add package InventingAnimals.Ink.Data.EFCore

Architecture

Ink.DataGrid / Ink.Charts  (UI layer, future)
        |
  Ink.Data            <- shared query model, no database or UI dependency
        |
  Ink.Data.EFCore     <- server side, translates queries to IQueryable<T>
  Ink.Data.Http       <- client side, sends queries over HTTP (future)

Ink.Data has no dependencies beyond the .NET base class library and System.Text.Json. It is safe to reference from WASM projects.

Ink.Data.EFCore depends on Microsoft.EntityFrameworkCore and is intended for server use only.


DataQuery

DataQuery is the wire format sent from a DataGrid component to its data source.

var query = new DataQuery(
    Columns: ["name", "email", "createdAt"],   // null means "all columns"
    Sort:    [new SortDescriptor("createdAt", SortDirection.Descending)],
    Filter:  new FilterCondition("active", FilterOp.Equal, [true]),
    Page:    1,
    PageSize: 25);

DataQuery.Default is a sensible starting point with no filter, no sort, page 1, page size 25:

var query = DataQuery.Default with
{
    Sort = [new SortDescriptor("name", SortDirection.Ascending)],
};

Filter trees

Filters are expressed as a composable tree of FilterNode values. Leaf nodes are FilterCondition; branch nodes are FilterAnd, FilterOr, and FilterNot.

// Simple leaf
var filter = new FilterCondition("age", FilterOp.GreaterThan, [18]);

// Compound: active AND (age > 18 OR role = "Admin")
var filter = new FilterAnd([
    new FilterCondition("active", FilterOp.Equal, [true]),
    new FilterOr([
        new FilterCondition("age", FilterOp.GreaterThan, [18]),
        new FilterCondition("role", FilterOp.Equal, ["Admin"]),
    ]),
]);

// Negation
var filter = new FilterNot(new FilterCondition("archived", FilterOp.Equal, [true]));

Combining conditions

When building filters from dynamic UI state the number of active conditions is not known upfront. The .And() and .Or() extension methods handle the empty and single-item cases for you:

FilterNode? filter = conditions.And();
// empty list  -> null  (no filter applied)
// one item    -> that FilterCondition directly
// two or more -> FilterAnd wrapping the list

Built-in operators

FilterOp Value Notes
FilterOp.Equal "eq"
FilterOp.NotEqual "neq"
FilterOp.Contains "contains" string only
FilterOp.StartsWith "startsWith" string only
FilterOp.EndsWith "endsWith" string only
FilterOp.LessThan "lt"
FilterOp.LessThanOrEqual "lte"
FilterOp.GreaterThan "gt"
FilterOp.GreaterThanOrEqual "gte"
FilterOp.In "in" one or more values
FilterOp.NotIn "notIn"
FilterOp.Between "between" two values: min and max
FilterOp.IsNull "isNull" no values
FilterOp.IsNotNull "isNotNull" no values

Custom operators

FilterOp is a readonly record struct wrapping a string, so custom operators are just values:

var fts = new FilterOp("fts");  // full-text search
var filter = new FilterCondition("description", fts, ["invoice"]);

Custom operators must be registered on the translator (see below) before the query is executed.


JSON

FilterNode and DataQuery serialise to and from JSON out of the box using System.Text.Json. The $type discriminator identifies the node kind; FilterOp and SortDirection serialise as human-readable strings.

{
  "Columns": ["name", "age"],
  "Sort": [{ "Field": "name", "Direction": "Ascending" }],
  "Filter": {
    "$type": "and",
    "Children": [
      { "$type": "condition", "Field": "active", "Op": "eq", "Values": [true] },
      { "$type": "condition", "Field": "age",    "Op": "gt", "Values": [18]   }
    ]
  },
  "Page": 1,
  "PageSize": 25
}

Debug string

FilterNodeExtensions.ToDebugString() produces an indented human-readable representation useful for logging:

Console.WriteLine(filter.ToDebugString());
// AND [
//   active eq true
//   age gt 18
// ]

Examples

Building queries from UI state

A typical scenario: a view model that maintains current filter and sort state and builds a DataQuery to fetch the next page.

public class UserListViewModel
{
    private string _nameFilter = string.Empty;
    private bool? _activeFilter = null;
    private string _sortField = "name";
    private SortDirection _sortDirection = SortDirection.Ascending;
    private int _page = 1;

    public DataQuery BuildQuery()
    {
        var conditions = new List<FilterNode>();

        if (!string.IsNullOrWhiteSpace(_nameFilter))
            conditions.Add(new FilterCondition("name", FilterOp.Contains, [_nameFilter]));

        if (_activeFilter.HasValue)
            conditions.Add(new FilterCondition("active", FilterOp.Equal, [_activeFilter.Value]));

        return new DataQuery(
            Columns: null,
            Sort: [new SortDescriptor(_sortField, _sortDirection)],
            Filter: conditions.And(),
            Page: _page,
            PageSize: 25);
    }
}

Common filter patterns

// Exact match
new FilterCondition("status", FilterOp.Equal, ["Active"])

// Substring search
new FilterCondition("name", FilterOp.Contains, ["smith"])

// Date range
new FilterCondition("createdAt", FilterOp.Between, [
    new DateTime(2024, 1, 1),
    new DateTime(2024, 12, 31),
])

// Multiple allowed values
new FilterCondition("role", FilterOp.In, ["Admin", "Manager"])

// Null check
new FilterCondition("deletedAt", FilterOp.IsNull, [])

// Combined: active users created this year
new FilterAnd([
    new FilterCondition("active", FilterOp.Equal, [true]),
    new FilterCondition("createdAt", FilterOp.GreaterThanOrEqual, [new DateTime(2024, 1, 1)]),
])

// NOT archived, OR role is admin
new FilterOr([
    new FilterNot(new FilterCondition("archived", FilterOp.Equal, [true])),
    new FilterCondition("role", FilterOp.Equal, ["Admin"]),
])

Multi-column sort

var query = DataQuery.Default with
{
    Sort =
    [
        new SortDescriptor("department", SortDirection.Ascending),
        new SortDescriptor("name", SortDirection.Ascending),
    ],
};

Column projection

When the server and client communicate over a narrow connection, request only the columns needed for the current view:

var query = DataQuery.Default with
{
    Columns = ["name", "email", "createdAt"],
};

The translator enforces that requested columns are registered; unregistered column names throw UnauthorizedFieldException.


EFCoreQueryTranslator

EFCoreQueryTranslator<T> is a configured, reusable object that applies a DataQuery to an IQueryable<T>. Configure it once as a singleton or static field.

Setup

Configure the translator once - as a static field or a singleton registered in your DI container.

// Registered as a singleton in Program.cs
builder.Services.AddSingleton(new EFCoreQueryTranslator<User>()
    .Column(x => x.Name)
    .Column(x => x.Email)
    .Column(x => x.Age)
    .Column(x => x.CreatedAt)
    .Column(x => x.Active));

Column field names are derived automatically from the expression in camelCase - x => x.CreatedAt registers the field "createdAt". Matching is case-insensitive.

Minimal API

The simplest wiring: a single MapPost endpoint that accepts a DataQuery and returns a DataPage<T>.

app.MapPost("/api/users/query", async (
    DataQuery query,
    AppDbContext db,
    EFCoreQueryTranslator<User> translator) =>
{
    var result = await translator.ExecuteAsync(
        db.Users.Where(u => u.TenantId == currentTenantId),  // tenant isolation here
        query);
    return Results.Ok(result);
});

ApiController

If you prefer a controller-based structure:

[ApiController]
[Route("api/users")]
public class UsersController : ControllerBase
{
    private readonly AppDbContext _db;
    private readonly EFCoreQueryTranslator<User> _translator;

    public UsersController(AppDbContext db, EFCoreQueryTranslator<User> translator)
    {
        _db = db;
        _translator = translator;
    }

    [HttpPost("query")]
    public async Task<ActionResult<DataPage<User>>> Query(
        [FromBody] DataQuery query,
        CancellationToken ct)
    {
        var baseQuery = _db.Users
            .Where(u => u.TenantId == User.GetTenantId());

        var result = await _translator.ExecuteAsync(baseQuery, query, ct);
        return Ok(result);
    }
}

ExecuteAsync runs two database queries - one for the total count, one for the page - and returns DataPage<T>:

public record DataPage<T>(IReadOnlyList<T> Items, int TotalCount);

Apply without pagination

Use Apply when you need the filtered and sorted IQueryable<T> but want to control pagination yourself or add further clauses:

var q = translator.Apply(db.Users, query);
var count = await q.CountAsync(ct);
var items = await q
    .Skip(skip).Take(take)
    .Include(u => u.Department)
    .ToListAsync(ct);

Security

Only columns explicitly registered via .Column(...) can be referenced in filters or sort descriptors. Any other field name throws UnauthorizedFieldException. This prevents clients from probing unintended fields such as password hashes.

// UnauthorizedFieldException: 'passwordHash' is not registered
var query = DataQuery.Default with
{
    Filter = new FilterCondition("passwordHash", FilterOp.Equal, ["secret"]),
};
translator.Apply(db.Users, query);  // throws

Catch it in a global exception handler and return 400:

app.UseExceptionHandler(err => err.Run(async ctx =>
{
    var ex = ctx.Features.Get<IExceptionHandlerFeature>()?.Error;
    if (ex is UnauthorizedFieldException ufe)
    {
        ctx.Response.StatusCode = 400;
        await ctx.Response.WriteAsJsonAsync(new { error = ufe.Message });
    }
}));

Custom operators

Register a handler for any FilterOp value. The handler receives the member access expression, the member CLR type, and the condition values; it returns an EF Core-compatible predicate expression.

// PostgreSQL full-text search via Npgsql
builder.Services.AddSingleton(new EFCoreQueryTranslator<Article>()
    .Column(x => x.Title)
    .Column(x => x.Body)
    .HandleOp(new FilterOp("fts"), (member, _, values) =>
        Expression.Call(
            typeof(NpgsqlDbFunctionsExtensions),
            nameof(NpgsqlDbFunctionsExtensions.ToTsQuery),
            null,
            Expression.Property(null, typeof(EF), nameof(EF.Functions)),
            member,
            Expression.Constant(values[0]?.ToString()))));

Custom handlers take precedence over built-in operators, so they can also override built-in behaviour for specific columns.


Supported filter operations per column type

When ops is not specified, EFCoreQueryTranslator<T> infers the supported operations from the column value type:

Column type Default operators
string eq, neq, contains, startsWith, endsWith, in, notIn, isNull, isNotNull
Numeric / IComparable<T> eq, neq, lt, lte, gt, gte, between, in, notIn
bool eq, neq
enum in, notIn

Override for a specific column by passing an explicit list:

translator.Column(x => x.Status, ops: [FilterOp.In, FilterOp.NotIn]);