ZibStack.NET.Query
Filter/sort DSL for REST APIs. Parses query strings into LINQ expression trees that translate to SQL via EF Core. Source generation provides compile-time field allowlists — zero reflection, no unsafe field access.
Install
Section titled “Install”dotnet add package ZibStack.NET.QueryWhen used with ZibStack.NET.Dto, the Dto source generator auto-detects ZibStack.NET.Query and adds filter/sort string parameters to all CRUD list endpoints.
Quick Start
Section titled “Quick Start”The standalone way to get a query DSL on any model is [QueryDto] from ZibStack.NET.Dto. As long as ZibStack.NET.Query is referenced in the same project, the Dto source generator automatically wires filter/sort string parsing into the generated query record:
[QueryDto(DefaultSort = "Name")]public partial class Player{ public int Id { get; set; } public string Name { get; set; } = ""; public int Level { get; set; } public string? Email { get; set; } public int? TeamId { get; set; }
[OneToOne] public Team? Team { get; set; }}Generates PlayerQuery with ApplyFilter(query, filter?), ApplySort(query, sort?), Apply(query, filter?, sort?), and ProjectFields(). Point it at an IQueryable<Player> in any endpoint:
app.MapGet("/api/players", (string? filter, string? sort, AppDbContext db) =>{ var q = new PlayerQuery(); return q.Apply(db.Players, filter, sort).ToList();});And the endpoint now accepts filter/sort strings:
GET /api/players?filter=Level>25,Name=*ski&sort=-LevelGET /api/players?filter=Team.Name=LakersGET /api/players?filter=(Level>50|Level<10),Team.City=LAGET /api/players?filter=Name=in=Jan;Anna;KasiaAbout
Team.Namefiltering — dot notation across navigation properties only works becauseTeamis marked with[OneToOne]fromZibStack.NET.Core. The Dto generator reads the relationship attribute and expands the navigation into the filter allowlist (Team.Name,Team.City, etc.) so the query DSL can reach into the related entity. Collection navigations use[OneToMany]to enablefilter=Players.Name=*ski/filter=Players.Count>5. See Core → Relationship Attributes for the full contract.
If you’re using
[CrudApi](or the full[ImTiredOfCrud]fromZibStack.NET.UI), the generator produces the query record, the list endpoint, and wiresfilter/sort/page/pageSizequery string parameters automatically — you don’t have to write the endpoint yourself.[QueryDto]is what you reach for when you only want the DSL on a plain model without the rest of the CRUD scaffolding.
Filter Operators
Section titled “Filter Operators”| Operator | Token | Example | SQL |
|---|---|---|---|
| Equals | = | Name=Jan | WHERE Name = 'Jan' |
| NotEquals | != | Role!=Admin | WHERE Role != 'Admin' |
| GreaterThan | > | Level>30 | WHERE Level > 30 |
| GreaterThanOrEqual | >= | Level>=30 | WHERE Level >= 30 |
| LessThan | < | Level<50 | WHERE Level < 50 |
| LessThanOrEqual | <= | Level<=50 | WHERE Level <= 50 |
| Contains | =* | Name=*ski | WHERE Name LIKE '%ski%' |
| NotContains | !* | Name!*test | WHERE Name NOT LIKE '%test%' |
| StartsWith | ^ | Name^Jan | WHERE Name LIKE 'Jan%' |
| NotStartsWith | !^ | Name!^Jan | WHERE Name NOT LIKE 'Jan%' |
| EndsWith | $ | Name$ski | WHERE Name LIKE '%ski' |
| NotEndsWith | !$ | Name!$ski | WHERE Name NOT LIKE '%ski' |
| In | =in= | Name=in=Jan;Anna | WHERE Name IN ('Jan','Anna') |
| NotIn | =out= | Level=out=10;20 | WHERE Level NOT IN (10,20) |
Logic & Modifiers
Section titled “Logic & Modifiers”| Feature | Syntax | Example |
|---|---|---|
| AND | , | Level>20,Level<50 |
| OR | | | Level>50|Level<10 |
| Grouping | () | (Level>50|Level<10),Name=*ski |
| Case insensitive | /i | Name=jan/i |
| Dot notation | Nav.Field | Team.Name=Lakers |
Precedence: () > , (AND) > | (OR)
Sorting
Section titled “Sorting”GET /api/players?sort=-Level # descendingGET /api/players?sort=Name # ascendingGET /api/players?sort=Name desc # explicit directionGET /api/players?sort=-Level,Name # multi-fieldGET /api/players?sort=Team.Name # sort by relationCollection Filtering (OneToMany)
Section titled “Collection Filtering (OneToMany)”Filter by child collection properties using Any, All, or Count:
GET /api/teams?filter=Players.Name=*ski # Any player name contains "ski" (default)GET /api/teams?filter=Players.Any.Name=*ski # Same — explicit AnyGET /api/teams?filter=Players.All.Level>50 # ALL players have Level > 50GET /api/teams?filter=Players.Count>5 # Team has more than 5 playersGET /api/teams?filter=Players.Count=0 # Teams with no playersSyntax: Collection.Property (implicit Any), Collection.Any.Property, Collection.All.Property, Collection.Count.
EF Core translates to EXISTS/NOT EXISTS/COUNT subqueries.
Relation Filtering (Dot Notation)
Section titled “Relation Filtering (Dot Notation)”When your model has navigation properties with [OneToOne] (from ZibStack.NET.Core), the generator automatically adds dot-notation paths to the filter allowlist:
public class Player{ public int? TeamId { get; set; }
[OneToOne] public Team? Team { get; set; } // enables Team.Name, Team.City, etc.}GET /api/players?filter=Team.Name=Lakers → LEFT JOIN Teams ... WHERE t.Name = 'Lakers'GET /api/players?filter=Team.City=Boston,Level>30 → JOIN + compound WHEREGET /api/players?sort=-Team.Name → JOIN + ORDER BYEF Core translates x => x.Team.Name into a SQL JOIN automatically.
Field Projection (select=)
Section titled “Field Projection (select=)”Return only specific fields to reduce payload:
GET /api/players?select=Name,Level # flat fields onlyGET /api/players?select=Name,Level,Team.Name # include relation fieldsGET /api/players?select=Name,Level,Team.Name,Team.City # multiple relation fieldsResponse: { "name": "Jan", "level": 42, "team": { "name": "Lakers", "city": "LA" } }
Standalone Count
Section titled “Standalone Count”Get count without fetching data:
GET /api/players?filter=Level>25&count=true # → { "count": 42 }GET /api/players?count=true # → { "count": 150 }How Source Generation Helps
Section titled “How Source Generation Helps”The Dto generator produces a compile-time field allowlist per entity:
// Auto-generated (you never write this):clause.Field.ToLowerInvariant() switch{ "name" => FilterApplier.BuildPredicate<Player, string>(x => x.Name, clause), "level" => FilterApplier.BuildPredicate<Player, int>(x => x.Level, clause), "team.name" => FilterApplier.BuildPredicate<Player, string>(x => x.Team.Name, clause), _ => null, // unknown fields silently ignored};This means:
- No reflection — static switch, not runtime property lookup
- Security —
[DtoIgnore],[DtoIgnore(DtoTarget.Query)],[UiTableColumn(Filterable=false)]exclude fields from the allowlist. Sensitive fields likePasswordnever appear. - Type safety — each field has its correct C# type at compile time
- AOT compatible — no
Type.GetProperty()or expression compilation at runtime
[QueryDto] Attribute
Section titled “[QueryDto] Attribute”Standalone query DSL without CRUD endpoints — use on any model:
[QueryDto(DefaultSort = "Name")]public partial class Product{ public int Id { get; set; } public string Name { get; set; } = ""; public decimal Price { get; set; }
[OneToOne] public Category? Category { get; set; }
[OneToMany] public ICollection<Tag> Tags { get; set; }}Generates ProductQuery with ApplyFilter(query, filter?), ApplySort(query, sort?), Apply(query, filter?, sort?), ProjectFields().
Sortable defaults to true — set [QueryDto(Sortable = false)] for endpoints with a fixed result order (analytics, exports).
Standalone Usage
Section titled “Standalone Usage”You can use the parser and applier directly without the Dto source generator:
using ZibStack.NET.Query;
var q = new ProductQuery();var query = dbContext.Products.AsQueryable();
// DSL approach:query = q.Apply(query, "Price>100,Category.Name=Electronics", "-Price");
// Typed approach (when no DSL string):var q2 = new ProductQuery { Name = "laptop", SortBy = "Price" };query = q2.Apply(query);Supported Types
Section titled “Supported Types”The filter applier handles: string, int, long, decimal, double, float, bool, DateTime, DateTimeOffset, Guid, enums, and their nullable variants.
Requirements
Section titled “Requirements”- .NET 8+ (runtime library targets netstandard2.0 and net8.0)
ZibStack.NET.Dtofor auto-generated endpoint integration (optional — standalone usage works without it)
License
Section titled “License”MIT