Zift.EntityFrameworkCore 0.11.0

dotnet add package Zift.EntityFrameworkCore --version 0.11.0
                    
NuGet\Install-Package Zift.EntityFrameworkCore -Version 0.11.0
                    
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="Zift.EntityFrameworkCore" Version="0.11.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Zift.EntityFrameworkCore" Version="0.11.0" />
                    
Directory.Packages.props
<PackageReference Include="Zift.EntityFrameworkCore" />
                    
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 Zift.EntityFrameworkCore --version 0.11.0
                    
#r "nuget: Zift.EntityFrameworkCore, 0.11.0"
                    
#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 Zift.EntityFrameworkCore@0.11.0
                    
#: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=Zift.EntityFrameworkCore&version=0.11.0
                    
Install as a Cake Addin
#tool nuget:?package=Zift.EntityFrameworkCore&version=0.11.0
                    
Install as a Cake Tool

Zift Logo

NuGet NuGet CI Build & Publish

Zift adds dynamic filtering and pagination to IQueryable<T> using familiar LINQ-style methods.

  • Dynamic filtering: string expressions with Where(...)
  • Pagination: offset-based and cursor-based pagination

See the complete example for filtering and cursor pagination together.

Installation

dotnet add package Zift

If you use Entity Framework Core, install the EF Core package instead. It includes Zift and adds the async methods (ToPageAsync and ToCursorPageAsync):

dotnet add package Zift.EntityFrameworkCore

Zift targets .NET 8, 9, and 10. The EF Core package uses the EF Core major version that matches your target framework.

Dynamic Filtering

Filter queries using string expressions. Zift translates them into LINQ expression trees.

var products = await context.Products
    .Where("Price < 100 && Category.Name == 'Books'")
    .ToListAsync();

You can also filter across nested collections:

var categories = await context.Categories
    .Where("Products:any(Reviews:any(Rating >= 4))")
    .ToListAsync();

Supported Syntax

  • Property navigation Name, Author.Email

    Property names are limited to ASCII letters, digits, and _, and can't start with a digit. They aren't case-sensitive, so price matches Price.

  • Logical operators &&, ||, !

    Example: Price > 100 && Price < 500

  • Grouping (...) for evaluation order

    Example: (Price > 100 && Price < 500) || IsFeatured == true

  • Comparison operators ==, !=, <, <=, >, >=

    Example: Rating >= 4

  • String operators %= (contains), ^= (starts with), $= (ends with)

    Example: Title %= "C#"

  • in operator with list literals

    Example: Status in ["Active", "Pending"]

  • Quantifiers on collections

    • :any(predicate)
    • :all(predicate)
    • :any() (existence check)

    Example: Reviews:any(Author.Email $= "@example.com")

  • Collection projections

    • :count

    Example: Products:count >= 2

A comparison is always of the form property operator literal, including boolean properties (IsFeatured == true). Comparing two properties (Price > Cost) and arithmetic (Price * 2) aren't supported.

For strings, databases compare using the column's collation. In-memory sources use the current culture for ^=, $=, <, <=, >, and >=, and ordinal comparison for %=, ==, and !=.

Literals

  • Strings use single or double quotes: "Active" or 'Active'. Supported escapes are \\, \", and \'.
  • Numbers include integers, decimals, and exponents: 42, -1.5, 1e3. A leading + or - is optional.
  • Booleans and null use true, false, and null.
  • Dates, times, and GUIDs use quoted strings: "2024-03-05T10:20:30Z", "2024-03-05", or "10:20:30", depending on the property type. All standard GUID formats are supported.
  • Enums use quoted, case-sensitive member names: Status == "Active". Numeric strings aren't supported. For [Flags] enums, separate names with commas.

Numbers must fit the property's type. For an int property, 1.0 and 1e3 are valid; 1.5 and 3000000000 (exceeds int.MaxValue) aren't. Fractional values aren't rounded to integers.

Null and Empty Lists

Use == null or != null to check for null. Other direct comparisons with null aren't supported.

Lists can include null when the property is nullable:

items.Where("Rating in [4, 5, null]");

An empty list matches nothing; negating it matches everything. A list containing only null matches null values.

When EnableNullGuards is on, missing navigations evaluate to null instead of throwing. Comparisons then behave as they do in C#: Author.Name == null matches rows without an author, as does Author.Name != "Ann", while Author.Rating > 3 doesn't.

A null collection behaves like an empty one: Reviews:count == 0 matches it, Reviews:any() doesn't, and Reviews:all(...) does. You can only compare a value type behind an optional navigation with null when guards are on (Category.Rank == null).

Options and Limits

Pass ExpressionBuilderOptions to Where(...) to change the defaults:

var options = new ExpressionBuilderOptions
{
    EnableNullGuards = true,
    MaxExpressionLength = 8192
};

var products = items.Where("Category.Name == 'Books'", options);
Option Default Description
EnableNullGuards false Treats a null navigation as null instead of throwing. Useful for in-memory queries.
ParameterizeValues true Captures values so providers such as EF Core can parameterize queries and reuse query plans.
MaxExpressionLength 4096 Filter length limit, in characters.
MaxNestingDepth 64 Nesting limit for groups, !, and quantifier predicates.
MaxListItems 1000 List size limit.

Limits must be at least 1. Keep them bounded when accepting filters from users.

Security

Filters can access any public property, including nested ones. Zift doesn't enforce an allowlist.

When filters come from users:

  • Filter over a projection that only has the properties users are allowed to filter on, not the full entity.
  • Restrict the query to records the user is allowed to access before applying their filter.
  • Filtering can reveal property values indirectly. A user who can't read a property can still probe it with Email ^= "a".

Errors

Invalid filters throw InvalidFilterException, which derives from FormatException. This includes syntax errors, unknown properties, invalid values, unsupported operators, and exceeded limits.

try
{
    var products = items.Where(filter);
}
catch (InvalidFilterException ex)
{
    // ex.Message describes the error; ex.Position gives its location when available.
}

SyntaxErrorException and LexicalErrorException derive from InvalidFilterException and set Position. Other errors leave it null. When available, the underlying .NET exception is in InnerException.

A null filter throws ArgumentNullException. An empty or whitespace-only one throws ArgumentException.

Database provider errors, such as EF Core translation failures, aren't wrapped.

Pagination

Zift supports two ways to paginate IQueryable<T> queries:

  • Offset-based pagination
  • Cursor-based pagination

Offset Pagination

Offset pagination uses 1-based page numbers and LINQ's Skip / Take. It works best for smaller datasets where you need a total count.

var page = await context.Categories
    .OrderBy(c => c.Name)
    .ToPageAsync(pageNumber: 1, pageSize: 25);

Use ToPageAsync with Entity Framework Core. For other sources, such as in-memory collections, use ToPage:

var page = categories
    .AsQueryable()
    .OrderBy(c => c.Name)
    .ToPage(pageNumber: 1, pageSize: 25);

Use a consistent ordering to avoid skipped or repeated rows between pages. pageNumber and pageSize must be at least 1. An offset greater than int.MaxValue throws ArgumentOutOfRangeException.

The count and items are fetched separately, so concurrent changes can leave the count out of sync with the items.

Result Shape
page.Items
page.PageNumber
page.PageSize
page.PageCount
page.HasNextPage
page.HasPreviousPage
page.TotalItemCount

Use page.Map(...) to convert the items while keeping the paging information:

var dtoPage = page.Map(category => new CategoryDto(category));

Cursor Pagination

Cursor (or keyset) pagination uses ordering keys instead of offsets to navigate through results. It performs well even on large datasets.

Start by calling AsCursorQuery() on the IQueryable<T>, then specify at least one ordering key:

var query = context.Products
    .AsCursorQuery()
    .OrderBy(p => p.Name)
    .ThenByDescending(p => p.Price)
    .ThenBy(p => p.Id);

var page = await query.ToCursorPageAsync(pageSize: 25);

For other IQueryable<T> sources, such as in-memory collections, use ToCursorPage.

Note: Ordering for cursor pagination must be applied after calling AsCursorQuery(). Any earlier ordering is ignored.

Ordering

You can also specify ordering using a string:

var query = context.Products
    .AsCursorQuery()
    .OrderBy("Name ASC, Price DESC, Id ASC");

Use ThenBy(string) to append more ordering keys.

String ordering supports nested paths (Category.Title) and case-insensitive property names. Each entry is Property [ASC|DESC]. A malformed entry or unknown property throws InvalidOrderingClauseException.

For in-memory sources, pass new CursorQueryOptions { EnableNullGuards = true } to AsCursorQuery() to guard nested paths against null. This only affects string-based ordering; lambda selectors are used as written.

Ordering keys can be strings, numbers, booleans, enums, or any type with comparison operators or IComparable<T> (Guid, DateTime, DateTimeOffset). Unsupported types throw NotSupportedException when navigating beyond the first page.

Note: The ordering must uniquely identify each row, so end it with a unique key such as Id. Rows that tie on every key can be skipped between pages.

Ordering allows up to 16 keys by default, configurable through CursorQueryOptions.MaxOrderingKeys. Exceeding this limit throws InvalidOrderingClauseException.

By default, Zift assumes nulls sort first in ascending order (SQL Server, SQLite, MySQL). For databases that sort them last (PostgreSQL, Oracle), pass new CursorQueryOptions { NullOrdering = NullOrdering.NullsCompareHigher } to AsCursorQuery().

Note: If an ordering key can be null, set NullOrdering to match your database. With the wrong setting, pages can skip or repeat rows.

Cursors are URL-safe tokens tied to the entity type and ordering that produced them. Using one with a different ordering or entity type throws InvalidCursorException. They aren't encrypted or signed, and each contains the ordering key values of its corresponding row.

Reuse the ordered query and pass the current page's cursors to navigate. A null cursor selects the first page.

// Forward traversal
if (page.HasNextPage)
{
    var nextPage = await query
        .After(page.EndCursor)
        .ToCursorPageAsync(pageSize: 25);
}

// Backward traversal
if (page.HasPreviousPage)
{
    var previousPage = await query
        .Before(page.StartCursor)
        .ToCursorPageAsync(pageSize: 25);
}

For nonempty pages, After(...) assumes HasPreviousPage is true, while Before(...) assumes HasNextPage is true. These values aren't verified and may be inaccurate if the data changes between requests.

Page Size

pageSize must be between 1 and int.MaxValue - 1. Public APIs should enforce a much lower limit.

Result Shape
page.Items
page.StartCursor
page.EndCursor
page.HasNextPage
page.HasPreviousPage

Use page.Map(...) to convert the items and keep the cursors.

Complete Example

This example takes a user-supplied filter and cursor in an ASP.NET Core endpoint. It restricts the data before applying the filter, exposes only the properties users may filter on, caps the page size, and returns a 400 for invalid input.

app.MapGet("/products", async (
    string? filter,
    string? after,
    int? pageSize,
    ShopDbContext db,
    CancellationToken cancellationToken) =>
{
    // Restrict to what the caller may see, then project to the filterable properties.
    IQueryable<ProductListItem> products = db.Products
        .Where(p => !p.IsDeleted && p.IsPublished)
        .Select(p => new ProductListItem
        {
            Id = p.Id,
            Name = p.Name,
            Price = p.Price,
            IsFeatured = p.IsFeatured
        });

    try
    {
        if (!string.IsNullOrWhiteSpace(filter))
        {
            products = products.Where(filter);
        }

        var page = await products
            .AsCursorQuery()
            .OrderBy(p => p.Name)
            .ThenBy(p => p.Id)
            .After(string.IsNullOrWhiteSpace(after) ? null : after)
            .ToCursorPageAsync(Math.Clamp(pageSize ?? 25, 1, 100), cancellationToken);

        return Results.Ok(page);
    }
    catch (InvalidFilterException ex)
    {
        return Results.Problem(
            detail: ex.Message,
            statusCode: StatusCodes.Status400BadRequest,
            extensions: new Dictionary<string, object?> { ["position"] = ex.Position });
    }
    catch (InvalidCursorException)
    {
        return Results.Problem(
            detail: "The cursor is invalid.",
            statusCode: StatusCodes.Status400BadRequest);
    }
});

public sealed record ProductListItem
{
    public int Id { get; init; }
    public string Name { get; init; } = string.Empty;
    public decimal Price { get; init; }
    public bool IsFeatured { get; init; }
}

Keep the filter and ordering consistent when navigating between pages.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  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

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.11.0 93 10/29/2026
0.10.0 97 10/26/2026
0.9.1 99 10/24/2026
0.9.0 93 10/24/2026
0.8.0 177 1/8/2026
0.7.0 153 1/4/2026
0.6.3 218 10/12/2025
0.6.2 214 10/5/2025
0.6.1 298 6/8/2025
0.6.0 223 5/11/2025
0.5.0 176 5/9/2025
0.4.1 226 5/4/2025
0.4.0 184 5/4/2025
0.3.1 200 5/2/2025
0.3.0 219 4/30/2025