Skip to content
IzzyBlazor 0.1.0-alpha.20

Enter all results ↑ ↓ move Esc close

Data and filters

A Table with an ItemsProvider asks for its rows with a DataRequest: the range, the sort and the filter. You answer with a DataResult. The request applies itself to an EF Core query, travels to an API as a query string or JSON, and never turns a value into code.

The request and the result

The request says which rows the component wants:

MemberWhat it is
Skip, TakeThe range: rows to skip, and how many to take (null for every row). Page gives the range as a page number, from 1.
SortA list of SortFields, a key and whether it's descending. A Table sorts by one column.
FilterThe filter tree, or null. A Table's is Filter.All of one condition per filtered column, and any other conditions of a bound Filter.
SearchFree text to look for in the searchable fields. A Table doesn't set it; a provider can, from your own search box: request with { Search = query }.
CursorWhen the component pages by cursor, the Next or Previous of an earlier result.
NeedsTotalWhether to count. False when the component pages by cursor, where the total is optional.
CancellationTokenCanceled when a newer request replaces this one. Pass it on.

The result is the rows, with the Total that numbered pages need, or the Next and Previous cursors:

csharp
new DataResult<Order>(rows, total)                 // numbered pages
new DataResult<Order>(rows) { Next = cursor }      // by cursor, no total needed

From a database

The simplest way needs no provider: pass an EF Core query as a Table's Items, and the database sorts, filters and pages it. Write an ItemsProvider when you need more, such as scoping by the signed-in user per request, an API in between, or your own search.

In a provider, ToResultAsync filters, searches and sorts a query, counts what the filter keeps (only when NeedsTotal), and lists the range. It takes EF Core's async methods as arguments, so IzzyBlazor doesn't depend on EF Core:

razor
<Table TItem="Order" ItemsProvider="LoadOrders" PageSize="20">...</Table>

@code {
    async ValueTask<DataResult<Order>> LoadOrders(DataRequest<Order> request)
    {
        var query = db.Orders.AsNoTracking().Where(o => o.ShopId == shopId);
        return await request.ToResultAsync(query,
            count: (q, ct) => q.CountAsync(ct),
            list: (q, ct) => q.ToListAsync(ct));
    }
}

The request carries the component's columns, which say how each key reads an item, so the provider never maps keys to properties. Each condition becomes a LINQ expression on the column's own property, with its value as a query parameter, and the database does the work. For a list in memory, request.ToResult(list.AsQueryable()) does the same. request.Apply(query) filters, sorts and takes the range without counting; ApplyFiltering and ApplySorting do one part each.

If the provider throws, the Table logs the exception and shows "Couldn't load the rows." with Retry; see When loading fails. A request it canceled isn't an error.

Filters in code

A filter is a tree: a condition on one field (FieldFilter), conditions joined by and or or (FilterGroup), or the opposite of one (NotFilter). Fields are named by their key, the same key the address uses (?f-status= filters the field status): a column's property name in lower case, or its SortKey (see Fields).

csharp
Filter.Where("status", FilterOperator.Equals, "Paid")
Filter.Between("total", 100, 500)                    // either end may be null
Filter.In("status", ["Paid", "Pending"])
Filter.All(                                          // and; Filter.Any is or
    Filter.Where("placed", FilterOperator.OnDay, DateOnly.FromDateTime(DateTime.Today)),
    Filter.Not(Filter.Where("customer", FilterOperator.StartsWith, "test")))
new FieldFilter("sku", FilterOperator.Equals, "AB-1", MatchCase: true)

Bind a Table's Filter to set it from code on an interactive page. Each condition a column's panel can show (contains for text, between, at least, at most or equals for a number, on day for a date, in for a choice) shows there; any other, such as an or group, still applies and shows as a tag that removes it. With an ItemsProvider, the whole filter reaches it as request.Filter:

razor
<Table Items="orders" @bind-Filter="filter">...</Table>

@code {
    Filter? filter = Filter.Any(
        Filter.Where("status", FilterOperator.Equals, "Failed"),
        Filter.Where("total", FilterOperator.GreaterThan, 1000));   // a tag, not a panel
}

A provider that passes conditions on to an API reads one with Find: the condition on that field that every match must meet, or null.

csharp
var who = request.Filter?.Find("who")?.Value as string;   // the Who column's text, or null
var (items, next) = await api.ListEventsAsync(request.Cursor, request.Take ?? 50, who);
return new DataResult<AuditEvent>(items) { Next = next };

Operators

Which operators a field takes depends on its type. An operator its type doesn't take matches nothing.

TypeOperators
TextEquals, NotEquals, Contains, DoesNotContain, StartsWith, EndsWith, In, NotIn, IsEmpty, IsNotEmpty
Numbers, timesEquals, NotEquals, LessThan, LessThanOrEqual, GreaterThan, GreaterThanOrEqual, Between, In, NotIn
DatesThe same, and OnDay
Enums, booleans, GuidsEquals, NotEquals, In, NotIn
Anything that can be nullAlso IsNull and IsNotNull
  • Text ignores case, as a Table's filters do. Set MatchCase on the condition to match it. IsEmpty matches empty text and null; DoesNotContain keeps null.
  • Text ignores case by lowering both sides, which a database index can't serve. FilterCase.Database on a Table or DataGrid, or as a field's Case, compares text as it is and leaves it to the database's collation; FilterCase.MatchCase compares text as it is too, which is exact in memory, though SQLite's LIKE for a starts-with or ends-with still ignores ASCII case. The filter's text doesn't carry this, so an endpoint that reads it, such as an export, sets Case on its own fields.
  • Values are parsed for the field's type, so "10" filters a number and "Paid" an enum. Numbers are read as the person writes them in the current culture ("1.234,5" in German), then in the invariant form ("1234.5"); dates and times in ISO form ("2026-10-28"), then the current culture's. A value that doesn't parse matches nothing, as does a condition on a field that doesn't exist.
  • Number bounds round the way the comparison means. On a whole-number field, "at least 2.5" is 3 or more and "at most 2.5" is 2 or less. A bound beyond what the type holds keeps every row or none. Between includes both ends, and a null end has no limit.
  • A day on a DateTimeOffset doesn't translate on SQLite, as it reads the date on the clock where it was recorded.
  • OnDay matches a day. A DateTime keeps the day's range, so an index can serve it; a DateTimeOffset compares the day on the clock where it was recorded.

Filters as text

Filter.Parse reads a filter written in a subset of OData's $filter, and a filter's ToString() writes one. It's the form the request's query string uses for ?filter=:

text
total ge 100 and (status in ('Paid','Pending') or customer contains 'ada')
WriteFor
eq ne gt ge lt leComparisons; eq null and ne null for the null checks
status in ('Paid','Pending')Any of the values; not (status in (...)) for none
customer contains 'ada'Also contains(customer,'ada'), and startswith, endswith
between(total,100,500)Either end can be null, for no limit
isempty(email)No value or empty text; also isnotempty
date(placed) eq 2026-10-28On that day
and or not ( )Groups; and binds before or
'O''Brien', -1.5, true, 2026-10-28T09:30:00ZValues: text in single quotes (a quote doubled), numbers, true or false, dates and times, Guids

Parsing only builds the tree: names are field keys and values stay values, so name eq 'x'' or 1 eq 1' looks for that exact text. Text that isn't in this form doesn't parse (TryParse returns false), and a request read from a query string with such a filter matches nothing. Text ignores case here too, unlike OData's own contains. MatchCase isn't written, so a condition sent this way ignores case on the server; send the request as JSON to keep it.

Fields

A field is a key, the expression that reads it, and what can be done with it: sorted, filtered, searched, with which operators. A Table's columns are its fields. A PropertyColumn's key is its property path in lower case (customer.name) unless it has a SortKey; a TemplateColumn's is its SortKey, and it filters and searches by its FilterBy and sorts by its SortBy.

Where there are no columns, DataFields.Of<T>() reads them from a type: each public top-level property a filter can compare, keyed by its name in lower case, sortable and filterable, with text searchable and an enum's names as its choices. It reads [Display(Name)] for the caption, [DisplayFormat] and [DataType] for the format, and leaves out [ScaffoldColumn(false)] and [Display(AutoGenerateField = false)] properties. Or declare fields yourself:

csharp
IReadOnlyList<DataField<Order>> fields =
[
    new() { Key = "customer", Value = (Expression<Func<Order, string>>)(o => o.Customer.Name),
            Sortable = true, Filterable = true, Searchable = true },
    new() { Key = "total", Value = (Expression<Func<Order, decimal>>)(o => o.Total),
            Sortable = true, Filterable = true,
            Operators = [FilterOperator.GreaterThanOrEqual,
                         FilterOperator.LessThanOrEqual, FilterOperator.Between] },
];

Operators narrows what a field takes. Filter builds its condition yourself, for a computed value or one stored differently from how it's shown: it gets the condition with its values parsed for the field's type, and returns the filter on the item, or null for the usual one. It runs for every operator, so check the one you handle; and it matches case unless you lower both sides. A Table column takes the same Filter parameter.

razor
<PropertyColumn Property="o => o.Tags" Filterable Filter="TagFilter" />

@code {
    // Tags are stored lower case, as "red;green": "Green" in the filter finds it.
    static Expression<Func<Order, bool>>? TagFilter(FieldFilter f) =>
        f.Operator == FilterOperator.Contains && f.Value is string tag
            ? o => (";" + o.Tags + ";").Contains(";" + tag.ToLower() + ";")
            : null;
}

From the browser to an API

In a WebAssembly or Auto app the provider runs in the browser, so it sends the request to an API. ToQueryString() writes it for a GET (sort=-placed&skip=20&take=10&filter=total ge 100, shown unescaped, with total=false when it doesn't need the count), and the request also serializes with System.Text.Json for a POST. On the server, DataRequest<T>.Parse reads it back, and ToResultAsync with the fields you declare applies it:

Both sides use the same row type, here OrderRow in a project the client and the server share:

Orders.razor (client)
@inject HttpClient Api

<Table TItem="OrderRow" ItemsProvider="LoadOrders" PageSize="20">
    <PropertyColumn Property="o => o.Customer" Sortable Filterable />
    <PropertyColumn Property="o => o.Total" Format="C" Sortable Filterable />
</Table>

@code {
    async ValueTask<DataResult<OrderRow>> LoadOrders(DataRequest<OrderRow> request) =>
        (await Api.GetFromJsonAsync<DataResult<OrderRow>>(
            $"api/orders?{request.ToQueryString()}", request.CancellationToken))!;
}
Program.cs (server)
using Microsoft.EntityFrameworkCore;   // CountAsync, ToListAsync

app.MapGet("/api/orders", async (HttpContext context, ShopDb db) =>
{
    var request = DataRequest<OrderRow>.Parse(context.Request.QueryString.Value ?? "")
        with { CancellationToken = context.RequestAborted };
    // At most 100 rows.
    request = request with { Take = Math.Min(request.Take ?? 100, 100) };
    var rows = db.Orders.AsNoTracking()   // an initializer: EF Core filters and sorts on it
        .Select(o => new OrderRow { Id = o.Id, Customer = o.Customer.Name, Total = o.Total });
    return await request.ToResultAsync(rows, DataFields.Of<OrderRow>(),
        count: (q, ct) => q.CountAsync(ct), list: (q, ct) => q.ToListAsync(ct));
});

public sealed record OrderRow
{
    public int Id { get; init; }
    public string Customer { get; init; } = "";
    public decimal Total { get; init; }
}
  • The keys are the client's columns. The server's fields need the same keys. With the same row type they match; a column on a path, o => o.Customer.Name, is customer.name, so give it the SortKey of the server's field.
  • Only declared fields can be reached. A condition on a key that isn't a filterable field matches nothing, and a sort by one is left out. DataFields.Of<T>() declares every public property, so read it from a type made for the API, not an entity with a password hash on it.
  • Cap the range. The request asks for its own Take, or every row without one.
  • Large or deep filters match nothing. A filter of more than 1,000 nodes (conditions, groups and nots), or nested more than 64 deep, matches no rows rather than building an expression that would overflow the server's stack. The text form nests at most 32 deep.

OData endpoints

When the API is an OData endpoint, ToODataQuery() writes the request as its query options: $filter, $orderby, $skip, $top, $count and $search. OData compares text with case, so text that ignores case is written lower-cased on both sides (contains(tolower(customer),'ada')), and paths use / (customer/name). Values are written as their fields' types: a Table's request carries its fields, and for a request you read with Parse, pass them, ToODataQuery(fields). Keys are lower case; if the service matches property names with case, give the columns a SortKey with its names. A cursor isn't part of OData: pass request.Cursor on yourself.

csharp
var url = $"odata/Orders?{request.ToODataQuery()}";
// Unescaped:
// $filter=contains(tolower(customer),'ada') and total ge 100
// &$orderby=placed desc&$skip=20&$top=10&$count=true

Next steps

  • Table: sorting, filters, paging by cursor and Load more, with state in the address.
  • WebAssembly and Auto: setting up an app whose components run in the browser.
  • Render modes: why the Table keeps its state in the address.

Connection lost

Rejoining the server…