Calais 1.6.1

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

Calais

A flexible C# query building library for Entity Framework Core with PostgreSQL/Npgsql support. Inspired by Sieve, Calais transforms user input into database queries using expression trees.

Features

  • Expression tree-based filtering and sorting - Efficient query building that translates to SQL
  • Full-text search support - Native support for NpgsqlTsVector with configurable language
  • JSONB document querying - Filter and sort on JsonDocument properties
  • Nested property navigation - Support for paths like comments.text or posts.title
  • Opt-in entities, opt-out fields - Entity-level configuration with field-level overrides
  • Scoped custom sorts and filters - Add custom query methods with DI access
  • Default entity sorts - Configure stable fallback sort chains per entity
  • Separate pagination - Pagination can be applied independently from filtering/sorting
  • OR group support - Complex nested conditions with OR logic
  • Length operators - Filter by collection size with len>=, len==, etc.

Installation

Add the project reference or package to your project.

Quick Start

1. Configure the processor

var processor = new CalaisBuilder()
    .ConfigureEntity<User>(e =>
    {
        // Ignore sensitive fields
        e.Ignore(u => u.PasswordHash, sorts: true, filter: true);
        
        // Add stable default ordering
        e.AddDefaultSort(u => u.Name)
            .ThenBy(u => u.Email, SortDirection.Desc);
        
        // Configure vector fields
        e.AsVector(u => u.SearchVector, language: "english");
    })
    .WithDefaultPageSize(10)
    .WithMaxPageSize(100)
    .WithDefaultVectorLanguage("english")
    .Build();

If you want to use the processor with dependency injection, see the Dependency Injection section below.

Note: Default page size and max page size are set to 10 and 100 respectively. If you want to remove pagination limits, call WithMaxPageSize(int.MaxValue)/WithDefaultPageSize(int.MaxValue).

2. Create a query

var query = new CalaisQuery
{
    Page = 1,
    PageSize = 10,
    Sorts = new List<SortDescriptor>
    {
        new SortDescriptor { Field = "name", Direction = "asc" },
        new SortDescriptor { Field = "age", Direction = "desc" }
    },
    Filters = new List<FilterDescriptor>
    {
        new FilterDescriptor
        {
            Field = "name",
            Operator = "==",
            Values = new List<object> { "alice", "bob" }
        },
        new FilterDescriptor
        {
            Field = "age",
            Operator = ">=",
            Values = new List<object> { 20 }
        }
    }
};

3. Apply the query

// Apply all (filters, sorting, pagination)
var result = await processor.ApplyAsync(dbContext.Users, query);

// Or apply separately
var filtered = processor.ApplyFilters(dbContext.Users, query);
var sorted = processor.ApplySorting(filtered, query);
var paged = processor.ApplyPagination(sorted, query);

Supported Operators

Operator Description
== Equals
!= Not equals
> Greater than
< Less than
>= Greater than or equal
<= Less than or equal
@= Contains (string)
_= Starts with
_-= Ends with
!@= Does not contain
!_= Does not start with
!_-= Does not end with
==* Equals (case insensitive)
!=* Not equals (case insensitive)
@=* Contains (case insensitive)
~= Matches regular expression
~=* Matches regular expression (case insensitive)
_=* Starts with (case insensitive)
_-=* Ends with (case insensitive)
len== Length equals
len!= Length not equals
len> Length greater than
len< Length less than
len>= Length greater than or equal
len<= Length less than or equal

Multiple Values

When multiple values are provided for a filter:

  • For == and similar operators: treated as OR (matches any)
  • For negated operators (!=, !@=, !_=, !_-=, len!=, and case-insensitive variants): treated as AND (must not match any)
{
  "field": "name",
  "operator": "==",
  "values": ["alice", "bob"]
}

This generates: name == "alice" OR name == "bob"

OR Groups

Use the or property to create OR conditions:

{
  "filters": [
    {
      "or": [
        { "field": "name", "operator": "@=", "values": ["admin"] },
        { "field": "age", "operator": ">=", "values": [21] }
      ]
    }
  ]
}

Nested Properties

Filter through navigation properties:

{
  "field": "comments.text",
  "operator": "@=",
  "values": ["good"]
}

This generates a query that finds users who have at least one comment containing "good".

JSONB Support

Mark filters as JSON to query JsonDocument properties:

{
  "field": "jsonbColumn.randomData",
  "json": true,
  "operator": "@=",
  "values": ["tagged"]
}

Mark filters as vector for tsquery matching:

{
  "field": "contentVector",
  "vector": true,
  "values": ["test & example"]
}

Multiple vector values are combined with OR.

Separate Pagination

Pagination can be applied independently, addressing Sieve issue #34:

// Get filtered count first
var filteredQuery = processor.ApplyWithoutPagination(dbContext.Users, query);
var totalCount = await filteredQuery.CountAsync();

// Then apply pagination
var page1 = await processor.ApplyPagination(filteredQuery, 1, 10).ToListAsync();
var page2 = await processor.ApplyPagination(filteredQuery, 2, 10).ToListAsync();

Dependency Injection

services.AddCalais(builder =>
{
    builder.ConfigureEntity<User>(e =>
    {
        e.Ignore(u => u.PasswordHash);
        e.AddDefaultSort(u => u.Name);
    });
    builder.WithDefaultPageSize(20);
});

services.AddScoped<ICalaisCustomFilterMethods, UserFilters>();
services.AddScoped<ICalaisCustomSortMethods, UserSorts>();

Then inject CalaisProcessor where needed.

Custom Sorts and Filters

Custom methods are discovered by matching the request field name to a public method name, case-insensitively. Method services are scoped, so they can use constructor injection or context.Services to access a DbContext or other services.

public sealed class UserFilters(AppDbContext dbContext) : ICalaisCustomFilterMethods
{
    public IQueryable<User> hasRole(IQueryable<User> source, CalaisFilterContext context)
    {
        var role = context.Values.First().ToString();
        var userIds = dbContext.UserRoles
            .Where(r => r.Role.Name == role)
            .Select(r => r.UserId);

        return source.Where(u => userIds.Contains(u.Id));
    }
}

public sealed class UserSorts : ICalaisCustomSortMethods
{
    public IQueryable<User> isBanned(IQueryable<User> source, CalaisSortContext context)
    {
        if (context.UseThenBy && source is IOrderedQueryable<User> ordered)
        {
            return context.Direction == SortDirection.Desc
                ? ordered.ThenByDescending(u => u.LockoutEnd != null)
                : ordered.ThenBy(u => u.LockoutEnd != null);
        }

        return context.Direction == SortDirection.Desc
            ? source.OrderByDescending(u => u.LockoutEnd != null)
            : source.OrderBy(u => u.LockoutEnd != null);
    }
}

Custom filters are query transforms, so they cannot be composed inside or groups. In strict mode Calais throws; otherwise those custom filters are ignored inside the group.

Default Sorts

Default sorts are configured per entity and appended after request sorts when the request did not already sort by that field:

builder.ConfigureEntity<Book>(e => e
    .AddDefaultSort(b => b.Name)
    .ThenBy(b => b.Author.Name, SortDirection.Desc));

If a request has no sorts, Calais applies the full default chain. If a request sorts by author.name, Calais preserves that request sort and appends name asc as the missing default.

Example: Complete Query

{
  "page": 5,
  "pageSize": 10,
  "sorts": [
    { "field": "name", "direction": "asc" },
    { "field": "age", "direction": "desc" },
    { "field": "jsonbColumn.randomData", "json": true, "direction": "desc" }
  ],
  "filters": [
    { "field": "id", "operator": "!=", "values": ["guid1", "guid2"] },
    { "field": "name", "operator": "==", "values": ["alice", "bob"] },
    { "field": "age", "operator": ">=", "values": [20] },
    { "field": "age", "operator": "<=", "values": [35] },
    { "field": "comments.text", "operator": "@=", "values": ["good"] },
    {
      "or": [
        { "field": "posts.title", "operator": "@=", "values": ["abc"] },
        { "field": "posts.contentVector", "vector": true, "values": ["test:* & example:*"] },
        { "field": "jsonbColumn.randomData", "json": true, "operator": "@=", "values": ["tagged"] },
        { "field": "comments", "operator": "len>=", "values": [1] }
      ]
    }
  ]
}

This query:

  • Gets page 5 with 10 items
  • Sorts by name (asc), then age (desc), then JSONB field
  • Excludes specific IDs
  • Matches name equal to alice or bob
  • Age between 20 and 35
  • Has comments containing "good"
  • AND either:
    • Has posts with title containing "abc", OR
    • Has posts matching the tsquery, OR
    • Has JSONB randomData containing "tagged", OR
    • Has at least 1 comment

Error Handling

Calais provides custom exceptions for different error scenarios. By default, invalid fields are silently ignored. Enable strict mode with ThrowOnInvalidFields:

var processor = new CalaisBuilder()
    .ThrowOnInvalidFields(true)
    .Build();

Exception Types

Exception Description
CalaisException Base exception for all Calais errors
PropertyNotFoundException Property not found on entity type
PropertyNotFilterableException Property is configured as not filterable
PropertyNotSortableException Property is configured as not sortable
InvalidJsonPathException JSON path format is invalid (requires column.property)
InvalidFilterOperatorException Filter operator is not recognized
ValueConversionException Value cannot be converted to target property type
ExpressionBuildException Generic expression building failure

Example: Catching Specific Exceptions

try
{
    var result = await processor.ApplyAsync(dbContext.Users, query);
}
catch (PropertyNotFoundException ex)
{
    Console.WriteLine($"Property '{ex.PropertyName}' not found on {ex.EntityType.Name}");
}
catch (PropertyNotFilterableException ex)
{
    Console.WriteLine($"Cannot filter by '{ex.PropertyName}'");
}
catch (CalaisException ex)
{
    // Catch any Calais-related error
    Console.WriteLine($"Query error: {ex.Message}");
}
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

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
1.6.1 86 9/14/2026
1.6.0 84 9/12/2026
1.4.0 117 5/31/2026
1.3.3 111 5/20/2026
1.3.2 115 3/6/2026
1.3.1 114 3/3/2026
1.3.0 118 2/27/2026
1.2.1 115 2/10/2026
1.2.0 109 2/9/2026
1.1.0 118 2/3/2026
1.0.1 116 2/2/2026
1.0.0 119 2/2/2026