Calais 1.6.1
dotnet add package Calais --version 1.6.1
NuGet\Install-Package Calais -Version 1.6.1
<PackageReference Include="Calais" Version="1.6.1" />
<PackageVersion Include="Calais" Version="1.6.1" />
<PackageReference Include="Calais" />
paket add Calais --version 1.6.1
#r "nuget: Calais, 1.6.1"
#:package Calais@1.6.1
#addin nuget:?package=Calais&version=1.6.1
#tool nuget:?package=Calais&version=1.6.1
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
NpgsqlTsVectorwith configurable language - JSONB document querying - Filter and sort on
JsonDocumentproperties - Nested property navigation - Support for paths like
comments.textorposts.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"]
}
Full-Text Search
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 | Versions 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. |
-
net10.0
- Microsoft.EntityFrameworkCore (>= 10.0.2)
- Npgsql.EntityFrameworkCore.PostgreSQL (>= 10.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.