The execution pipeline is the heart of FlexQuery.NET. Understanding which method to call — and why — is critical for correctness, security, and performance.
FlexQuery.NET exposes IQueryable extension methods as the primary public API surface.
These extension methods provide:
- Fluent composition: Chain query steps naturally.
- LINQ-style syntax: Feels familiar to any .NET developer.
- Cleaner code: Reduces boilerplate in controllers.
- Better readability: Intent is clear at a glance.
The lower-level QueryBuilder APIs are considered advanced/internal infrastructure and are primarily intended for:
- Custom library integrations.
- Framework extensions (e.g., building a custom query provider).
- Complex execution scenarios where manual expression manipulation is required.
| Method | Filter | Sort | Page | Project | Validate | Async | Returns |
|---|---|---|---|---|---|---|---|
ApplyFilter |
✅ | ❌ | ❌ | ❌ | ❌ | ❌ | IQueryable<T> |
ApplySort |
❌ | ✅ | ❌ | ❌ | ❌ | ❌ | IQueryable<T> |
ApplyPaging |
❌ | ❌ | ✅ | ❌ | ❌ | ❌ | IQueryable<T> |
ApplySelect |
❌ | ❌ | ❌ | ✅ | ❌ | ❌ | IQueryable<object> |
ApplyFilteredIncludes |
❌ | ❌ | ❌ | ❌ | ❌ | ❌ | IQueryable<T> |
FlexQuery |
✅ | ✅ | ✅ | ✅ | ✅ | ❌ | QueryResult<object> |
FlexQueryAsync |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ | Task<QueryResult<object>> |
FlexQueryAsync is the unified pipeline method. It parses, validates, and executes in a single call.
When to use: Any standard public API endpoint.
[HttpGet]
public async Task<IActionResult> GetUsers([FromQuery] FlexQueryParameters parameters)
{
var result = await _context.Users.FlexQueryAsync<User>(parameters, exec =>
{
exec.AllowedFields = new HashSet<string> { "id", "name", "email", "status" };
exec.MaxFieldDepth = 2;
});
return Ok(result);
}What it does internally:
Parse(parameters)
→ ValidateOrThrow<T>(execOptions)
→ ApplyFilter
→ ApplySort
→ CountAsync (if IncludeCount = true)
→ ApplyPaging
→ ApplyFilteredIncludes
→ ApplySelect (if projection requested)
→ ToListAsync
→ QueryResult<object>
Configuration:
await query.FlexQueryAsync<User>(parameters, exec =>
{
exec.AllowedFields = new HashSet<string> { "id", "name", "email" };
exec.BlockedFields = new HashSet<string> { "passwordHash" };
exec.FilterableFields = new HashSet<string> { "name", "status" };
exec.SortableFields = new HashSet<string> { "name", "createdAt" };
exec.SelectableFields = new HashSet<string> { "id", "name", "email" };
exec.MaxFieldDepth = 2;
exec.StrictFieldValidation = true;
});Use these when you need granular control over individual pipeline steps.
Applies the WHERE predicate from QueryOptions.Filter to the query.
var filtered = query.ApplyFilter(options);- Returns
IQueryable<T>— no database trip yet. - No-op if
options.Filteris null or empty. - Builds an expression tree; EF Core translates it to SQL.
Supported operators: eq, neq, gt, gte, lt, lte, contains, startswith, endswith, in, notin, between, isnull, isnotnull, like, any, all, count
Example:
GET /api/users?filter=status:eq:active
-- Generated SQL
SELECT * FROM Users WHERE Status = 'active'Applies ORDER BY from QueryOptions.Sort.
var sorted = query.ApplySort(options);- Supports multiple sort fields (uses
ThenByinternally). - Supports aggregate sorts (e.g., sort by
Orders.count()). - No-op if
options.Sortis empty.
Example:
GET /api/users?sort=name:asc,createdAt:desc
ORDER BY Name ASC, CreatedAt DESCApplies SKIP / TAKE from QueryOptions.Paging.
var paged = query.ApplyPaging(options);- Automatically adds a default
ORDER BY Idif the query is unordered andSkip > 0(prevents EF Core errors). - No-op if
options.Paging.Disabled = true.
Example:
GET /api/users?page=2&pageSize=10
ORDER BY Id OFFSET 10 ROWS FETCH NEXT 10 ROWS ONLYApplies dynamic projection. Returns IQueryable<object>.
var projected = query.ApplySelect(options);
var data = await projected.ToListAsync();- Uses expression trees — no reflection at runtime.
- Handles Nested, Flat, and FlatMixed modes.
- Delegates to
GroupByBuilderwhenGroupByorAggregatesare set. - Returns
query.Cast<object>()if no projection is requested.
Example:
GET /api/users?select=id,name,email
[
{ "id": 1, "name": "Alice", "email": "alice@example.com" }
]Applies the Include pipeline — EF Core Include/ThenInclude with optional inline filters.
var withIncludes = query.ApplyFilteredIncludes(options);- Independent from the WHERE pipeline — does not affect root result count.
- Must be called before
ToListAsync. - No-op if
options.FilteredIncludesis null or empty.
Example:
GET /api/users?include=Orders(status:eq:shipped)
// Translates to:
query.Include(u => u.Orders.Where(o => o.Status == "shipped"))All database trips should use the EF Core async extensions.
// Count before paging
var total = await filteredQuery.CountAsync(cancellationToken);
// Execute after paging + projection
var data = await projectedQuery.ToListAsync(cancellationToken);FlexQueryAsync handles all of this for you internally.
Caution
The most common mistake in FlexQuery.NET is applying filters twice.
WRONG — This filters twice:
// ❌ DO NOT DO THIS
var options = QueryOptionsParser.Parse(parameters);
// Step 1: ApplyValidatedQueryOptions applies filter internally
var query = _context.Users.AsQueryable();
var query = query.ApplyValidatedQueryOptions(options);
// Step 2: ToProjectedQueryResultAsync ALSO applies filter internally
// The WHERE clause is duplicated in SQL!
var result = await query.ToProjectedQueryResultAsync(options);CORRECT — Use FlexQueryAsync:
// ✅ CORRECT: Everything in one call, filter applied once
var result = await _context.Users.FlexQueryAsync<User>(parameters, exec =>
{
exec.AllowedFields = new HashSet<string> { "id", "name", "email" };
});CORRECT — Manual pipeline, filter applied once:
// ✅ CORRECT: Manual pipeline — each step called exactly once
var options = QueryOptionsParser.Parse(parameters);
options.ValidateOrThrow<User>(execOptions);
var query = _context.Users.AsQueryable();
query = query.ApplyFilter(options);
query = query.ApplySort(options);
var total = await query.CountAsync();
query = query.ApplyPaging(options);
query = query.ApplyFilteredIncludes(options);
var data = await query.ApplySelect(options).ToListAsync();
return Ok(options.BuildQueryResult(data, total));For when you need full control — e.g., injecting custom tenant filter between steps:
[HttpGet]
public async Task<IActionResult> GetUsers([FromQuery] FlexQueryParameters parameters, CancellationToken ct)
{
// 1. Parse
var options = QueryOptionsParser.Parse(parameters);
// 2. Validate
var execOptions = new QueryExecutionOptions
{
AllowedFields = new HashSet<string> { "id", "name", "email", "status", "createdAt" },
MaxFieldDepth = 2
};
options.ValidateOrThrow<User>(execOptions);
// 3. Start query
var query = _context.Users
.Where(u => u.TenantId == CurrentTenantId) // custom pre-filter
.AsQueryable();
// 4. Apply FlexQuery filter
query = query.ApplyFilter(options);
query = query.ApplySort(options);
// 5. Count BEFORE paging
var total = await query.CountAsync(ct);
// 6. Page + includes
query = query.ApplyPaging(options);
query = query.ApplyFilteredIncludes(options);
// 7. Project + execute
var data = await query.ApplySelect(options).ToListAsync(ct);
// 8. Return
return Ok(options.BuildQueryResult(data, total));
}The following methods are deprecated in v2 and will be removed in v3.
| Deprecated | Replacement |
|---|---|
ToQueryResultAsync |
FlexQueryAsync |
ToProjectedQueryResultAsync |
FlexQueryAsync |
ApplyValidatedQueryOptions |
Manual pipeline + ValidateOrThrow<T> |
QueryOptionsParser.Parse(QueryRequest) |
QueryOptionsParser.Parse(FlexQueryParameters) |
Warning
Deprecated methods are marked with [Obsolete] and hidden from IntelliSense. They will be removed in v3.