From e51edcee02621de5c829831b44299c527d841ebd Mon Sep 17 00:00:00 2001 From: Peter John Casasola Date: Thu, 9 Jul 2026 08:44:28 +0800 Subject: [PATCH 1/7] docs: update --- docs/examples/advanced.md | 13 +- docs/examples/basic.md | 55 +++-- docs/examples/real-world.md | 31 ++- docs/guide/aspnet-integration.md | 98 ++++---- docs/guide/basic-usage.md | 81 +++++-- docs/guide/comparison-libraries.md | 199 +++++++---------- docs/guide/comparison.md | 301 +++++++++++-------------- docs/guide/core-concepts.md | 194 ++++++++-------- docs/guide/debugging.md | 165 +++++--------- docs/guide/dotnet-comparison.md | 249 +++++++-------------- docs/guide/dto-mapping.md | 68 ++++-- docs/guide/end-to-end.md | 68 ++++-- docs/guide/execution-pipeline.md | 236 ++++++------------- docs/guide/execution.md | 348 ----------------------------- docs/guide/extension-methods.md | 87 ++++---- docs/guide/filtering.md | 20 +- docs/guide/flattening.md | 8 +- docs/guide/getting-started.md | 263 ++++++++++------------ docs/guide/grouping.md | 15 +- docs/guide/how-it-works.md | 12 +- docs/guide/include-filtering.md | 24 +- docs/guide/include.md | 85 ++++--- docs/guide/introduction.md | 115 ++++++++-- docs/guide/paging.md | 29 ++- docs/guide/performance-tuning.md | 2 +- docs/guide/projection.md | 20 +- docs/guide/sorting.md | 26 ++- docs/guide/validation.md | 6 + docs/providers/ef-core.md | 24 +- 29 files changed, 1269 insertions(+), 1573 deletions(-) delete mode 100644 docs/guide/execution.md diff --git a/docs/examples/advanced.md b/docs/examples/advanced.md index 1e8b75d..73c5aac 100644 --- a/docs/examples/advanced.md +++ b/docs/examples/advanced.md @@ -47,13 +47,16 @@ OFFSET 0 ROWS FETCH NEXT 20 ROWS ONLY **Response:** ```json { + "totalCount": 2, + "resultCount": 2, + "page": 1, + "pageSize": 20, + "aggregates": null, "data": [ { "id": 1, "name": "Alice Chen" }, { "id": 5, "name": "Carol White" } ], - "totalCount": 2, - "page": 1, - "pageSize": 20 + "nextCursorToken": null } ``` @@ -274,7 +277,7 @@ GET /api/users?select=id,name,salary public async Task GetOrders([FromQuery] FlexQueryParameters parameters, CancellationToken ct) { // Parse - var options = QueryOptionsParser.Parse(parameters); + var options = parameters.ToQueryOptions(); // Validate var execOptions = new QueryExecutionOptions @@ -296,7 +299,7 @@ public async Task GetOrders([FromQuery] FlexQueryParameters param var total = await query.CountAsync(ct); query = query.ApplyPaging(options); - query = query.ApplyFilteredIncludes(options); + query = query.ApplyExpand(options); var data = await query.ApplySelect(options).ToListAsync(ct); diff --git a/docs/examples/basic.md b/docs/examples/basic.md index 5a83ad2..d77e4ab 100644 --- a/docs/examples/basic.md +++ b/docs/examples/basic.md @@ -32,14 +32,20 @@ public async Task GetUsers([FromQuery] FlexQueryParameters parame **Response:** ```json { + "totalCount": 42, + "resultCount": 42, + "page": 1, + "pageSize": 20, + "totalPages": 3, + "hasNextPage": true, + "hasPreviousPage": false, + "aggregates": null, "data": [ { "id": 1, "name": "Alice Chen", "email": "alice@example.com", "status": "active", "createdAt": "2024-03-15T10:00:00Z" }, { "id": 2, "name": "Bob Smith", "email": "bob@example.com", "status": "active", "createdAt": "2024-04-01T09:30:00Z" }, { "id": 5, "name": "Carol White", "email": "carol@example.com", "status": "active", "createdAt": "2024-04-20T14:00:00Z" } ], - "totalCount": 42, - "page": 1, - "pageSize": 20 + "nextCursorToken": null } ``` @@ -64,14 +70,20 @@ OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY **Response:** ```json { + "totalCount": 3, + "resultCount": 3, + "page": 1, + "pageSize": 10, + "totalPages": 1, + "hasNextPage": false, + "hasPreviousPage": false, + "aggregates": null, "data": [ { "id": 1, "name": "Alice Chen", "status": "active" }, { "id": 8, "name": "Ali Hassan", "status": "active" }, { "id": 12, "name": "Alicia Park", "status": "inactive" } ], - "totalCount": 3, - "page": 1, - "pageSize": 10 + "nextCursorToken": null } ``` @@ -98,13 +110,19 @@ OFFSET 0 ROWS FETCH NEXT 50 ROWS ONLY **Response:** ```json { + "totalCount": 42, + "resultCount": 42, + "page": 1, + "pageSize": 50, + "totalPages": 1, + "hasNextPage": false, + "hasPreviousPage": false, + "aggregates": null, "data": [ { "id": 1, "name": "Alice Chen", "email": "alice@example.com" }, { "id": 2, "name": "Bob Smith", "email": "bob@example.com" } ], - "totalCount": 42, - "page": 1, - "pageSize": 50 + "nextCursorToken": null } ``` @@ -134,13 +152,19 @@ GET /api/users?query=(name = "alice" OR name = "bob") AND status = "active"&page **Response:** ```json { + "totalCount": 2, + "resultCount": 2, + "page": 1, + "pageSize": 10, + "totalPages": 1, + "hasNextPage": false, + "hasPreviousPage": false, + "aggregates": null, "data": [ { "id": 1, "name": "Alice Chen", "status": "active" }, { "id": 2, "name": "Bob Smith", "status": "active" } ], - "totalCount": 2, - "page": 1, - "pageSize": 10 + "nextCursorToken": null } ``` @@ -203,10 +227,13 @@ GET /api/users?filter=status:eq:active&page=1&pageSize=20&includeCount=false **Response:** ```json { - "data": [ ... ], "totalCount": null, + "resultCount": null, "page": 1, - "pageSize": 20 + "pageSize": 20, + "aggregates": null, + "data": [ "..." ], + "nextCursorToken": null } ``` diff --git a/docs/examples/real-world.md b/docs/examples/real-world.md index 8f2cc10..6636f04 100644 --- a/docs/examples/real-world.md +++ b/docs/examples/real-world.md @@ -56,6 +56,14 @@ GET /api/orders?filter=createdAt:between:2024-01-01,2024-12-31,status:in:pending **Response:** ```json { + "totalCount": 143, + "resultCount": 143, + "page": 1, + "pageSize": 25, + "totalPages": 6, + "hasNextPage": true, + "hasPreviousPage": false, + "aggregates": null, "data": [ { "id": 1001, @@ -72,9 +80,7 @@ GET /api/orders?filter=createdAt:between:2024-01-01,2024-12-31,status:in:pending "customer": { "name": "Bob Smith" } } ], - "totalCount": 143, - "page": 1, - "pageSize": 25 + "nextCursorToken": null } ``` @@ -177,6 +183,14 @@ GET /api/products?filter=category:eq:electronics,price:lte:500 **Response:** ```json { + "totalCount": 28, + "resultCount": 28, + "page": 1, + "pageSize": 10, + "totalPages": 3, + "hasNextPage": true, + "hasPreviousPage": false, + "aggregates": null, "data": [ { "id": 5, @@ -196,9 +210,7 @@ GET /api/products?filter=category:eq:electronics,price:lte:500 "reviews": [] } ], - "totalCount": 28, - "page": 1, - "pageSize": 10 + "nextCursorToken": null } ``` @@ -239,12 +251,17 @@ GET /api/reports/revenue?filter=createdAt:between:2024-01-01,2024-12-31,status:e **Response:** ```json { + "totalCount": 3, + "resultCount": 3, + "page": 1, + "pageSize": 20, + "aggregates": null, "data": [ { "region": "North America", "allCount": 512, "amountSum": 128000.00, "amountAvg": 250.00 }, { "region": "Europe", "allCount": 380, "amountSum": 95000.00, "amountAvg": 250.00 }, { "region": "Asia Pacific", "allCount": 210, "amountSum": 52500.00, "amountAvg": 250.00 } ], - "totalCount": 3 + "nextCursorToken": null } ``` diff --git a/docs/guide/aspnet-integration.md b/docs/guide/aspnet-integration.md index c269c5d..433c69a 100644 --- a/docs/guide/aspnet-integration.md +++ b/docs/guide/aspnet-integration.md @@ -1,6 +1,18 @@ # ASP.NET Core Integration -The `FlexQuery.NET.AspNetCore` package provides optional integration helpers for ASP.NET Core applications. These are primarily focused on declarative security and automated validation. +## Overview + +The `FlexQuery.NET.AspNetCore` package provides optional integration helpers for ASP.NET Core applications. It bridges the gap between raw HTTP requests and the FlexQuery.NET execution engine, primarily focusing on declarative security, global error handling, and `HttpContext` binding. + +## Why this feature exists + +While you can manually instantiate `FlexQueryParameters` and pass lambdas to `FlexQueryAsync` everywhere, large MVC applications often prefer convention over configuration. This package provides the `[FieldAccess]` attribute, allowing you to define your security rules directly on your controller methods alongside your HTTP verb attributes, keeping your actions clean and standardized. + +## When to use + +- You are building an ASP.NET Core MVC or Web API application. +- You want to use declarative `[FieldAccess]` attributes on your controllers instead of configuring security inside lambda expressions in every endpoint. +- You want to globally catch and format query validation errors. --- @@ -12,59 +24,52 @@ dotnet add package FlexQuery.NET.AspNetCore --- -## Overview - -FlexQuery.NET is designed to be **dependency-injection free** by default. You do not need to register any services to use the core library. - -The ASP.NET Core integration package provides two primary features: -1. **`FieldAccessFilter`**: An action filter that automatically applies security rules from attributes. -2. **`FlexQueryParameters`**: A unified DTO for query-string binding. - ---- - ## Service Registration -To enable the declarative security attributes, register the security filters in `Program.cs`: +Unlike v3, FlexQuery.NET v4 relies on a robust dependency injection container. To enable the declarative security attributes, register the core engine, your execution provider, and the ASP.NET Core security filters in `Program.cs`: ```csharp -using FlexQuery.NET.AspNetCore.Extensions; +using FlexQuery.NET.DependencyInjection; +using FlexQuery.NET.EntityFrameworkCore.DependencyInjection; +using FlexQuery.NET.AspNetCore.DependencyInjection; var builder = WebApplication.CreateBuilder(args); -// For MVC/Web API Controllers +// 1. Register Core Engine +builder.Services.AddFlexQuery(); + +// 2. Register Execution Provider (e.g. EF Core) +builder.Services.AddFlexQueryEntityFrameworkCore(); + +// 3. Register MVC with FlexQuery Security builder.Services.AddControllers() .AddFlexQuerySecurity(); -// OR manual filter registration -builder.Services.AddControllers(options => -{ - options.Filters.Add(); -}); +var app = builder.Build(); ``` > [!NOTE] -> `AddFlexQuerySecurity()` only registers the `FieldAccessFilter`. It does not provide a full DI framework for the core library, which remains intentionally decoupled from the ASP.NET Core container. +> `AddFlexQuerySecurity()` registers the `FieldAccessFilter` into the ASP.NET Core MVC pipeline. It requires the core `AddFlexQuery()` engine to be registered first. --- ## Declarative Security: `[FieldAccess]` -The `[FieldAccess]` attribute allows you to define field security rules directly on your controller actions. +The `[FieldAccess]` attribute allows you to define field security rules directly on your controller actions. It accepts the same properties found on `BaseQueryOptions`. ```csharp [HttpGet] [FieldAccess( - Allowed = ["id", "name", "email", "status"], - Filterable = ["name", "status"], - Sortable = ["name", "createdAt"], - MaxDepth = 2 + AllowedFields = new[] { "Id", "Name", "Email", "Status" }, + FilterableFields = new[] { "Name", "Status" }, + SortableFields = new[] { "Name", "CreatedAt" }, + MaxFieldDepth = 2 )] -public async Task GetUsers( - [FromQuery] FlexQueryParameters parameters) +public async Task GetUsers([FromQuery] FlexQueryParameters parameters) { - // The FieldAccessFilter automatically populates execution options - // into the HttpContext. The FlexQueryAsync overload picks it up. - var result = await _context.Users.FlexQueryAsync(parameters, HttpContext); + // The FlexQueryAsync overload natively extracts the security rules + // from the HttpContext metadata populated by the [FieldAccess] filter. + var result = await _context.Users.FlexQueryAsync(parameters, HttpContext); return Ok(result); } @@ -72,17 +77,21 @@ public async Task GetUsers( ### How it Works 1. The **`FieldAccessFilter`** intercepts the request. -2. It looks for a **`[FieldAccess]`** attribute on the action or controller. +2. It looks for a **`[FieldAccess]`** attribute on the action or controller metadata. 3. If found, it populates a **`QueryExecutionOptions`** object and stores it in **`HttpContext.Items`**. -4. The **`FlexQueryAsync(..., HttpContext)`** extension method retrieves it and enforces the server-owned policy. +4. The **`FlexQueryAsync(..., HttpContext)`** extension method retrieves the options and enforces the declarative policy during query compilation. --- ## Global Exception Handling -You can handle query validation errors globally using an Exception Filter or Middleware. +FlexQuery.NET halts execution and throws a `QueryValidationException` when a client requests an unauthorized field or violates max depth constraints. You can handle this globally using an Exception Filter or ASP.NET Core Middleware. ```csharp +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using FlexQuery.NET.Exceptions; + public class FlexQueryExceptionFilter : IExceptionFilter { public void OnException(ExceptionContext context) @@ -100,49 +109,58 @@ public class FlexQueryExceptionFilter : IExceptionFilter } // Register globally -builder.Services.AddControllers(o => o.Filters.Add()); +builder.Services.AddControllers(o => +{ + o.Filters.Add(); +}); ``` --- ## OpenAPI / Swagger Integration -`FlexQueryParameters` is a standard POCO that maps naturally to OpenAPI/Swagger. +`FlexQueryParameters` is a standard POCO that maps naturally to OpenAPI/Swagger documentation generators like Swashbuckle or NSwag. ```csharp [HttpGet] public async Task>> GetUsers( [FromQuery] FlexQueryParameters parameters) { - return await _context.Users.FlexQueryAsync(parameters); + return await _context.Users.FlexQueryAsync(parameters, HttpContext); } ``` -Swagger UI will automatically display the query parameters: +Swagger UI will automatically display the query string parameters bound to the object: - `filter` - `sort` - `select` - `page` - `pageSize` - `includeCount` +- `mode` --- ## Minimal APIs -The security attributes are currently designed for MVC/Web API Controllers. For Minimal APIs, we recommend manual configuration: +The `[FieldAccess]` attribute and `AddFlexQuerySecurity()` pipeline are designed specifically for the MVC/Web API Controller pipeline. For Minimal APIs, we strongly recommend manual configuration using the inline lambda, which is highly performant and explicit: ```csharp app.MapGet("/api/users", async ( [AsParameters] FlexQueryParameters parameters, AppDbContext db) => { - var result = await db.Users.FlexQueryAsync(parameters, exec => + var result = await db.Users.FlexQueryAsync(parameters, exec => { - exec.AllowedFields = ["id", "name", "email"]; + exec.AllowedFields = ["Id", "Name", "Email"]; exec.MaxFieldDepth = 2; }); return Results.Ok(result); }); ``` + +## Best Practices + +- **Mix and Match:** You can apply `[FieldAccess]` at the class level to secure the entire controller, and then apply tighter `[FieldAccess]` bounds on specific `[HttpGet]` actions. +- **Always Catch Validation Exceptions:** Ensure you have registered the `FlexQueryExceptionFilter` (or a similar middleware) so that malicious query probes return `400 Bad Request` instead of crashing the request with an unhandled `500 Internal Server Error`. diff --git a/docs/guide/basic-usage.md b/docs/guide/basic-usage.md index 5ab98d1..3a7397a 100644 --- a/docs/guide/basic-usage.md +++ b/docs/guide/basic-usage.md @@ -1,10 +1,23 @@ # Basic Usage Guide -FlexQuery.NET uses a consistent, human-readable DSL (Domain Specific Language) for dynamic querying. The standard format for any operation is `Field:Operator:Value`. +## Overview + +FlexQuery.NET uses a consistent, human-readable DSL (Domain Specific Language) for dynamic querying. This guide provides a rapid introduction to the standard formats for filtering, sorting, paging, and projection. + +## Why this feature exists + +When building APIs, there is a constant tension between backend rigidity and frontend flexibility. The FlexQuery DSL exists to provide a standardized, secure, and easily-parsable syntax that frontends can use to request exact data shapes without forcing backend developers to write custom SQL or LINQ for every view. + +## When to use + +- Read this guide to understand the fundamental URL syntax for FlexQuery GET requests. +- Share this guide with frontend engineers so they understand how to construct query strings. + +--- ## Filtering -Filtering allows you to restrict the results based on property values. +Filtering allows you to restrict the results based on property values. The standard format for any operation is `Field:Operator:Value`. ### Simple Filters - **Equals**: `Name:eq:John` @@ -13,41 +26,52 @@ Filtering allows you to restrict the results based on property values. - **In Collection**: `Status:in:Active,Pending` ### Multiple Filters -By default, multiple filters are combined using **AND**. -`?filter=Status:eq:Active,Price:gt:100` +Multiple filters are combined using the **AND** operator (`&`), which must be URL-encoded as `%26` in HTTP requests. + +`?filter=Status:eq:Active%26Price:gt:100` ### Nested Properties -You can filter on nested navigation properties using dot notation. +You can filter on nested navigation properties using dot notation. FlexQuery automatically handles generating the underlying SQL `JOIN` or EF Core `Include` logic. + `?filter=Category.Name:eq:Electronics` --- ## Sorting -Sorting controls the order of the returned items. +Sorting controls the order of the returned items. You can specify ascending (`asc`) or descending (`desc`). - **Ascending**: `?sort=Name:asc` - **Descending**: `?sort=Price:desc` -- **Multiple**: `?sort=Category.Name:asc,Price:desc` +- **Multiple Columns**: Use a comma to separate multiple sort directives. + `?sort=Category.Name:asc,Price:desc` --- ## Paging -FlexQuery supports standard paging parameters. +FlexQuery supports standard offset paging parameters as well as high-performance keyset paging. - **Page**: The current page number (1-based). `?page=1` - **PageSize**: Number of items per page. `?pageSize=20` -**Result Shape:** -When paging is used, the result includes metadata: +### Result Shape + +When paging is used, the result includes a standardized pagination envelope (the `QueryResult` contract): + ```json { - "items": [...], "totalCount": 150, + "resultCount": 20, + "page": 1, + "pageSize": 20, "totalPages": 8, - "currentPage": 1, - "pageSize": 20 + "hasNextPage": true, + "hasPreviousPage": false, + "data": [ + // ... 20 items ... + ], + "nextCursorToken": null } ``` @@ -55,7 +79,7 @@ When paging is used, the result includes metadata: ## Projection (Select) -Projection allows you to specify exactly which fields should be returned. This reduces database I/O and network bandwidth. +Projection allows you to specify exactly which fields should be returned. This reduces database I/O, network bandwidth, and memory allocation. **Basic Select:** `?select=Id,Name,Price` @@ -64,20 +88,37 @@ Projection allows you to specify exactly which fields should be returned. This r `?select=Id,Name,Category.Name` > [!IMPORTANT] -> When using `select`, only the requested fields will be populated in the result object. All other fields will be null or default. +> When using `select`, only the requested fields will be populated in the result object. All other fields will be null or default values in the JSON response. --- ## Unified Execution -In FlexQuery v2, all these features are applied in a single unified pipeline. You don't need to call `.Where()`, `.OrderBy()`, or `.Select()` manually. +In FlexQuery v4, all these features are applied in a single unified pipeline. You don't need to manually string together `.Where()`, `.OrderBy()`, or `.Select()` clauses. ```csharp -var result = await _context.Users.FlexQueryAsync(parameters); +[HttpGet] +public async Task GetUsers([FromQuery] FlexQueryParameters parameters) +{ + // Execute everything in one pass + var result = await _context.Users.FlexQueryAsync(parameters, options => + { + // Enforce your security rules + options.AllowedFields = ["Id", "Name", "Price", "Category.Name", "Status"]; + }); + + return Ok(result); +} ``` This single call handles: 1. Parsing the query string. -2. Building the Expression Tree. -3. Applying security rules. -4. Executing the query against the database. +2. Validating the requested fields against your `AllowedFields` policy. +3. Building the Expression Tree or ADO.NET SQL Command. +4. Counting the total records (if requested). +5. Executing the query against the database and paginating. + +## Best Practices + +- **URL Encode:** Always remind frontend developers to use `encodeURIComponent()` (in JavaScript/TypeScript) on their filter strings. The `&` character will break HTTP routing if it is not encoded as `%26`. +- **Use Paging Defaults:** Always specify a `DefaultPageSize` and `MaxPageSize` in your execution options to prevent accidental `SELECT * FROM Table` scenarios. diff --git a/docs/guide/comparison-libraries.md b/docs/guide/comparison-libraries.md index 9c3fe59..1941644 100644 --- a/docs/guide/comparison-libraries.md +++ b/docs/guide/comparison-libraries.md @@ -1,22 +1,22 @@ -# FlexQuery.NET vs Gridify vs Sieve: A Practical Comparison +# FlexQuery.NET vs Gridify vs Sieve -## πŸ’‘ Introduction +## Overview -Modern .NET applications often require dynamic querying capabilities -where clients can define filtering, sorting, projection, and pagination -via URL parameters. +Modern .NET applications often require dynamic querying capabilities where clients can define filtering, sorting, projection, and pagination via URL parameters. Manually mapping query strings into LINQ expressions is repetitive, error-prone, and difficult to maintain. -Manually mapping query strings into LINQ expressions is repetitive, -error-prone, and difficult to maintain. +Libraries like **Gridify**, **Sieve**, and **FlexQuery.NET** aim to solve this problem by translating string-based input into `IQueryable` expressions. While they share a similar goal, they differ significantly in scope, flexibility, and architecture. -Libraries like **Gridify**, **Sieve**, and **FlexQuery.NET** aim to -solve this problem by translating string-based input into `IQueryable` -expressions. +## Why this comparison exists -While they share a similar goal, they differ significantly in **scope, -flexibility, and architecture**. +When evaluating .NET packages for dynamic querying, developers frequently encounter Gridify and Sieve. Both are excellent, popular libraries. This comparison exists to help architects understand the technical differences between a "lightweight mapping tool" (Gridify), an "attribute-driven tool" (Sieve), and a "full query pipeline engine" (FlexQuery.NET), ensuring you pick the right tool for your specific bounded context. ------------------------------------------------------------------------- +## When to use each + +- Use **Gridify** when you need simple, fast filtering and sorting without projection or complex relationships. +- Use **Sieve** when you want strict, attribute-based control over a few specific DTOs. +- Use **FlexQuery.NET** when you are building enterprise data grids, need dynamic runtime projection (SELECT), deep relational filtering (JOINs), or granular role-based field security. + +--- ## ⚑ Quick Comparison @@ -31,7 +31,7 @@ flexibility, and architecture**. | Configuration | None required | Mapper required | Attributes required | | Pipeline | βœ… Unified | ❌ Split | ❌ Split | ------------------------------------------------------------------------- +--- ## 🧠 Core Philosophy @@ -41,135 +41,133 @@ flexibility, and architecture**. | Sieve | Attribute-based query control | | FlexQuery.NET | Full query pipeline engine | ------------------------------------------------------------------------- +--- ## πŸ” What is Gridify? -Gridify is a lightweight library focused on converting string -expressions into LINQ `Where` and `OrderBy` clauses. +Gridify is a lightweight library focused on converting string expressions into LINQ `Where` and `OrderBy` clauses. -- Uses expression-based filtering -- Supports sorting and paging -- Requires a **mapper** for advanced scenarios +- Uses expression-based filtering +- Supports sorting and paging +- Requires a **mapper** for advanced scenarios -**Best for:** Simple filtering + sorting use cases. +**Best for:** Simple filtering and sorting use cases where the full entity is returned. ------------------------------------------------------------------------- +--- ## πŸ” What is Sieve? Sieve is an attribute-driven filtering, sorting, and paging library. -- Uses `[Sieve]` attributes on models -- Enforces opt-in queryability -- Requires decorating domain or DTO models +- Uses `[Sieve]` attributes on models +- Enforces opt-in queryability at the class level +- Requires decorating domain or DTO models -**Best for:** Controlled environments with strict field exposure. +**Best for:** Controlled environments with strict field exposure where you don't mind coupling your models to a third-party attribute. ------------------------------------------------------------------------- +--- ## πŸ” What is FlexQuery.NET? -FlexQuery.NET is a **unified query pipeline** built on top of -`IQueryable`. +FlexQuery.NET is a **unified query pipeline** built on top of `IQueryable` (and ADO.NET via Dapper). It supports: - -- Filtering (DSL, JQL, JSON) -- Sorting -- Projection (dynamic select) -- Includes / joins -- Grouping -- Pagination +- Filtering (DSL, JQL, JSON) +- Sorting +- Projection (dynamic `SELECT` at runtime) +- Includes / joins with nested filtering +- Grouping & Aggregates +- Pagination (Offset and Keyset) All in a **single method call**. -``` http +```http GET /api/users?filter=Name:contains:John&select=Name,Orders.Status&include=Orders ``` ------------------------------------------------------------------------- +--- ## πŸ”₯ Key Differences ### 1. Projection (Major Differentiator) -**Gridify / Sieve** - Do not support dynamic projection - Require manual -`.Select(...)` - Load full entity before projection +**Gridify / Sieve** +- Do not support dynamic projection +- Require manual `.Select(...)` after the library executes +- Load full entities into memory before projection can occur -**FlexQuery.NET** - Supports dynamic `select` - Builds expression tree -automatically - Fetches only required columns +**FlexQuery.NET** +- Supports dynamic `select` directly from the HTTP request +- Builds the `Select` expression tree automatically +- Fetches only required columns from the SQL database -πŸ‘‰ Less data = better performance +πŸ‘‰ **Less data over the wire = better performance.** ------------------------------------------------------------------------- +--- ### 2. Includes & Navigation -**Sieve** - No support for includes +**Sieve** - No support for includes. -**Gridify** - Limited support via mapping +**Gridify** - Limited support via custom mapping. -**FlexQuery.NET** - Explicit includes - Scoped filtering (Joins with filters) - Nested relationships +**FlexQuery.NET** - Explicit includes, scoped filtering (filtering the child collection of an include), and nested relationship traversal. FlexQuery.NET automatically generates SQL **joins** when filters are applied within an `include` or `select` parameter. -πŸ‘‰ FlexQuery.NET automatically generates SQL **joins** when filters are applied within an `include` or `select` parameter. - - ------------------------------------------------------------------------- +--- ### 3. Configuration Overhead | Library | Configuration | | :--- | :--- | -| Sieve | High (attributes) | -| Gridify | Medium (mapper) | -| FlexQuery.NET | Minimal | +| Sieve | High (attributes on every property) | +| Gridify | Medium (mapper classes) | +| FlexQuery.NET | Minimal (inline lambda policy) | ------------------------------------------------------------------------- +--- ### 4. Pipeline Design **Gridify / Sieve** - -``` text +```text Parse β†’ Apply β†’ Manual Projection ``` **FlexQuery.NET** - -``` text +```text Parse β†’ Validate β†’ Execute (single pipeline) ``` ------------------------------------------------------------------------- +--- ## πŸ“Š Side-by-Side Example ### Scenario +- Filter users where name contains "John" +- Include their orders +- Select only the user's name and the order's status +- Sort by CreatedAt descending -- Filter users where name contains "John" -- Include orders -- Select name + order status -- Sort by CreatedAt - ------------------------------------------------------------------------- +--- ### 🟦 FlexQuery.NET -``` http +```http GET /api/users?filter=Name:contains:John&include=Orders&select=Name,Orders.Status&sort=CreatedAt:desc ``` -``` csharp -var result = await _context.Users - .FlexQueryAsync(parameters); +```csharp +var result = await _context.Users.FlexQueryAsync(parameters, options => +{ + // Security policy enforced inline + options.AllowedFields = ["Name", "Orders.Status", "CreatedAt"]; +}); ``` ------------------------------------------------------------------------- +--- ### 🟨 Gridify -``` csharp +```csharp var mapper = new GridifyMapper().GenerateDefaultMap(); var query = _context.Users @@ -183,13 +181,14 @@ var result = query.Data.Select(u => new }); ``` ------------------------------------------------------------------------- +--- ### πŸŸ₯ Sieve -``` csharp +```csharp var query = _context.Users.Include(u => u.Orders); +// Sieve applies the filters/sorts based on attributes query = _sieveProcessor.Apply(sieveModel, query); var result = await query.Select(u => new @@ -199,7 +198,7 @@ var result = await query.Select(u => new }).ToListAsync(); ``` ------------------------------------------------------------------------- +--- ## πŸ’‘ Developer Experience @@ -210,57 +209,27 @@ var result = await query.Select(u => new | API Cleanliness | Unified | Mixed | Split | | Learning Curve | Low | Low | Medium | ------------------------------------------------------------------------- +--- ## ⚑ Performance -All three libraries use **expression trees**, meaning EF Core can -translate queries into SQL. - -However, FlexQuery.NET provides advantages: - -- βœ… Fetches only selected fields -- βœ… Avoids unnecessary data loading -- βœ… Optional total count (skip expensive queries) - ------------------------------------------------------------------------- - -## 🎯 When to Choose Each - -### Use Gridify when: - -- You need simple filtering/sorting -- You prefer lightweight tools -- No projection required - ------------------------------------------------------------------------- - -### Use Sieve when: - -- You want strict attribute control -- You prefer explicit opt-in fields - ------------------------------------------------------------------------- - -### Use FlexQuery.NET when: +All three libraries use **expression trees**, meaning EF Core can translate the final `IQueryable` into SQL. -- You need projection, includes, grouping -- You want a unified pipeline -- You want minimal setup -- You want dynamic APIs without DTO explosion +However, FlexQuery.NET provides significant mechanical advantages: +- βœ… **Fetches only selected fields:** Because projection is part of the AST, the `SELECT` clause in SQL is narrowed down. +- βœ… **Avoids Cartesian Explosions:** By explicitly shaping data, you avoid pulling massive object graphs into memory. +- βœ… **Keyset Pagination:** FlexQuery supports `cursor` paging for massive datasets, which avoids the `OFFSET` scanning penalties inherent to the other libraries. ------------------------------------------------------------------------- +--- ## 🧨 Final Takeaway -All three libraries solve similar problems --- but at different levels. +All three libraries solve similar problemsβ€”but at different levels of abstraction. | Library | Scope | | :--- | :--- | | Gridify | Basic querying | | Sieve | Controlled querying | -| FlexQuery.NET | Full query engine | +| FlexQuery.NET | Full enterprise query engine | -πŸ‘‰ FlexQuery.NET provides the most complete solution for modern APIs by -combining filtering, projection, and relational querying into a single -pipeline. +πŸ‘‰ FlexQuery.NET provides the most complete solution for modern REST APIs by combining filtering, validation, projection, and relational querying into a single, unified pipeline. diff --git a/docs/guide/comparison.md b/docs/guide/comparison.md index 957f5a8..ca262d4 100644 --- a/docs/guide/comparison.md +++ b/docs/guide/comparison.md @@ -1,102 +1,106 @@ - # Comparison: FlexQuery.NET vs GraphQL vs OData -This page compares FlexQuery.NET, GraphQL, and OData for dynamic querying in APIs. +## Overview + +This page provides an architectural comparison between FlexQuery.NET, GraphQL, and OData for dynamic querying in APIs. The goal is not to declare a universal "best" solution, but to highlight the different philosophies, strengths, and tradeoffs of each approach. -The goal is not to declare a universal β€œbest” solution, but to highlight the different philosophies, strengths, and tradeoffs of each approach. +## Why this comparison exists -Each technology is optimized for different scenarios. +When engineering teams decide they need to provide frontends with dynamic filtering or projection, GraphQL and OData are typically the first two technologies considered. However, both represent massive architectural shifts. This guide helps architects evaluate if they truly need a graph-based paradigm (GraphQL), a heavy metadata standard (OData), or simply a robust query abstraction layer over standard REST (FlexQuery.NET). --- -# Philosophy +## Philosophy | | FlexQuery.NET | GraphQL | OData | | :--- | :--- | :--- | :--- | -| Protocol style | REST | Graph-based API layer | REST + OASIS standard | -| Client contract | Query string / JSON params | Typed schema | OData metadata conventions | -| Server complexity | Low-Medium | High | Medium-High | -| Client complexity | Low | Medium | Medium | -| Learning curve | Low | High | Medium | -| Payload style | Minimal REST envelope | Nested graph responses | Metadata-driven REST | +| **Protocol style** | REST | Graph-based API layer | REST + OASIS standard | +| **Client contract** | Query string / JSON params | Typed schema | OData metadata conventions | +| **Server complexity** | Low-Medium | High | Medium-High | +| **Client complexity** | Low | Medium | Medium | +| **Learning curve** | Low | High | Medium | +| **Payload style** | Minimal REST envelope | Nested graph responses | Metadata-driven REST | --- -# Different Design Goals +## Different Design Goals -## FlexQuery.NET +### FlexQuery.NET -FlexQuery.NET focuses on providing a flexible query layer on top of traditional REST APIs. +FlexQuery.NET focuses on providing a flexible query layer on top of traditional REST APIs. It is designed for: - dynamic filtering -- projection -- aggregates +- projection (SELECT) +- aggregates (SUM, MIN, MAX) - field-level restrictions - reusable query pipelines -Typical use cases: -- REST APIs -- admin dashboards -- reporting endpoints -- multi-tenant systems -- advanced search endpoints +**Typical use cases:** +- Traditional REST APIs +- Admin dashboards +- Reporting endpoints +- Multi-tenant systems +- Advanced search endpoints --- -## GraphQL - -GraphQL focuses on client-driven data shaping through a strongly typed schema. +### GraphQL -Clients request exactly the fields they need through graph-based queries. +GraphQL focuses on client-driven data shaping through a strongly typed schema. Clients request exactly the fields they need through graph-based queries. -Typical use cases: -- frontend-heavy applications -- multi-client ecosystems -- mobile + web applications -- real-time subscription systems +**Typical use cases:** +- Frontend-heavy applications (React/Apollo) +- Multi-client ecosystems (Mobile + Web pulling different shapes) +- Real-time subscription systems --- -## OData +### OData -OData focuses on standardized REST querying and interoperability. +OData focuses on standardized REST querying and interoperability. It is commonly used in Microsoft-centric ecosystems and enterprise tooling scenarios. -It is commonly used in Microsoft-centric ecosystems and enterprise tooling scenarios. - -Typical use cases: +**Typical use cases:** - Power BI integrations - Excel integrations - Azure Data Factory -- enterprise interoperability +- Enterprise interoperability --- -# The Same Query β€” Three Approaches +## The Same Query β€” Three Approaches -Goal: -- users named "Alice" -- active status -- sorted by creation date -- first 10 records -- return id, name, email +**Goal:** +- Users named "Alice" AND active status +- Sorted by creation date descending +- First 10 records +- Return exactly `id`, `name`, and `email` --- -## FlexQuery.NET +### FlexQuery.NET ```http -GET /api/users?filter=name:contains:alice,status:eq:active - &sort=createdAt:desc - &page=1 - &pageSize=10 - &select=id,name,email +GET /api/users + ?filter=name:contains:alice%26status:eq:active + &sort=createdAt:desc + &page=1 + &pageSize=10 + &select=id,name,email ``` -### Response +**Response** ```json { + "totalCount": 3, + "resultCount": 3, + "page": 1, + "pageSize": 10, + "totalPages": 1, + "hasNextPage": false, + "hasPreviousPage": false, + "aggregates": null, "data": [ { "id": 1, @@ -104,17 +108,15 @@ GET /api/users?filter=name:contains:alice,status:eq:active "email": "alice@example.com" } ], - "totalCount": 3, - "page": 1, - "pageSize": 10 + "nextCursorToken": null } ``` -FlexQuery.NET keeps the API fully REST-compatible while adding dynamic querying capabilities. +FlexQuery.NET keeps the API fully REST-compatible while adding dynamic querying capabilities with a standardized pagination envelope. --- -## GraphQL +### GraphQL ```graphql POST /graphql @@ -141,7 +143,7 @@ query { } ``` -### Response +**Response** ```json { @@ -160,22 +162,23 @@ query { } ``` -GraphQL provides highly flexible client-driven data selection through a typed schema. +GraphQL provides highly flexible client-driven data selection, but forces all operations (even reads) to go through `POST /graphql`, bypassing HTTP-level caching. --- -## OData +### OData ```http -GET /api/users?$filter=contains(Name,'alice') and Status eq 'active' - &$orderby=CreatedAt desc - &$top=10 - &$skip=0 - &$select=Id,Name,Email - &$count=true +GET /api/users + ?$filter=contains(Name,'alice') and Status eq 'active' + &$orderby=CreatedAt desc + &$top=10 + &$skip=0 + &$select=Id,Name,Email + &$count=true ``` -### Response +**Response** ```json { @@ -191,34 +194,30 @@ GET /api/users?$filter=contains(Name,'alice') and Status eq 'active' } ``` -OData emphasizes standardized metadata-driven REST interoperability. +OData emphasizes standardized metadata-driven REST interoperability, but brings heavy URL syntax and metadata properties (`@odata.*`) into the payload. --- -# Nested Collection Query +## Nested Collection Query -Goal: -- users with at least one shipped order +**Goal:** +- Return users that have at least one order with the status "shipped" --- -## FlexQuery.NET (DSL) +### FlexQuery.NET (DSL) ```http GET /api/users?filter=orders:any:status:eq:shipped ``` ---- - -## FlexQuery.NET (JQL) +### FlexQuery.NET (JQL) ```http GET /api/users?query=Orders.any(Status = "shipped") ``` ---- - -## GraphQL +### GraphQL ```graphql query { @@ -241,9 +240,7 @@ query { } ``` ---- - -## OData +### OData ```http GET /api/users?$filter=orders/any(o: o/Status eq 'shipped') @@ -253,46 +250,40 @@ All three approaches support nested collection filtering, but with different que --- -# Response Payload Comparison +## Response Payload Comparison | | FlexQuery.NET | GraphQL | OData | | :--- | :--- | :--- | :--- | -| Primary data field | `data` | nested graph structure | `value` | -| Count field | `totalCount` | schema-defined | `@odata.count` | -| Metadata payload | Minimal | Minimal | Includes metadata | -| Schema exposure | Optional | Built-in introspection | `$metadata` endpoint | +| **Primary data field** | `data` | nested graph structure | `value` | +| **Count field** | `totalCount` | schema-defined | `@odata.count` | +| **Metadata payload** | Minimal (Paging envelope) | Minimal | Heavy (Includes EDM context) | +| **Schema exposure** | None (Hidden) | Built-in Introspection | `$metadata` XML endpoint | --- -# Server Setup Comparison +## Server Setup Comparison -## FlexQuery.NET +### FlexQuery.NET ```csharp [HttpGet] -public async Task GetUsers( - [FromQuery] FlexQueryParameters parameters) +public async Task GetUsers([FromQuery] FlexQueryParameters parameters) { var result = await _context.Users.FlexQueryAsync(parameters, exec => { - exec.AllowedFields = - [ - "id", - "name", - "email", - "status" - ]; + // Security policy enforced immediately inline + exec.AllowedFields = ["id", "name", "email", "status"]; }); return Ok(result); } ``` -FlexQuery.NET is designed to integrate directly into existing ASP.NET Core REST APIs. +FlexQuery.NET is designed to integrate directly into existing ASP.NET Core REST APIs without altering your application's architecture. --- -## GraphQL (Hot Chocolate) +### GraphQL (Hot Chocolate) ```csharp builder.Services @@ -309,17 +300,15 @@ public class Query [UseProjection] [UseFiltering] [UseSorting] - public IQueryable GetUsers( - [ScopedService] AppDbContext db) - => db.Users; + public IQueryable GetUsers([ScopedService] AppDbContext db) => db.Users; } ``` -GraphQL typically requires schema configuration and GraphQL-aware clients. +GraphQL typically requires schema configuration, specialized resolvers, and GraphQL-aware clients (like Apollo). --- -## OData +### OData ```csharp builder.Services @@ -340,11 +329,11 @@ public IQueryable GetUsers() } ``` -OData requires EDM model configuration and OData-aware query conventions. +OData requires Entity Data Model (EDM) configuration and heavily modifies the global MVC output formatters. --- -# Feature Comparison +## Feature Comparison | Feature | FlexQuery.NET | GraphQL | OData | | :--- | :---: | :---: | :---: | @@ -365,86 +354,54 @@ OData requires EDM model configuration and OData-aware query conventions. --- -# Tradeoffs - -## FlexQuery.NET +## Tradeoffs -### Strengths -- REST-native querying -- Unified query pipeline -- Built-in validation and field restrictions -- Projection and aggregate support -- Minimal setup overhead +### FlexQuery.NET +**Strengths** +- REST-native querying (Cachable GET requests). +- Unified, secure validation pipeline. +- Aggregates and projection work out-of-the-box. +- Minimal setup overhead. -### Tradeoffs -- Smaller ecosystem than GraphQL/OData -- More query concepts than lightweight filtering libraries -- REST-oriented rather than graph-oriented +**Tradeoffs** +- Smaller frontend tooling ecosystem compared to GraphQL/Apollo. +- Lacks a typed schema discovery mechanism. --- -## GraphQL +### GraphQL +**Strengths** +- Highly flexible client-driven queries. +- Strong typed schema system with built-in introspection. +- Excellent frontend tooling ecosystem. -### Strengths -- Highly flexible client-driven queries -- Strong typed schema system -- Excellent frontend tooling ecosystem -- Real-time subscription support - -### Tradeoffs -- Higher setup complexity -- Additional schema layer -- Requires GraphQL-aware tooling and clients +**Tradeoffs** +- Higher setup complexity and operational overhead. +- "N+1" loading problems require specific DataLoader strategies. +- Breaks standard HTTP caching semantics. --- -## OData - -### Strengths -- Standardized REST querying -- Strong Microsoft ecosystem integration -- Rich interoperability tooling -- Metadata-driven clients +### OData +**Strengths** +- Standardized REST querying. +- Strong Microsoft ecosystem integration. +- Rich interoperability tooling (Excel, Power BI). -### Tradeoffs -- Verbose query conventions -- Additional metadata complexity -- Steeper learning curve than traditional REST APIs +**Tradeoffs** +- Verbose query conventions. +- Extremely heavy XML/JSON metadata payloads. +- Steeper learning curve than traditional REST APIs. --- -# Choosing the Right Tool +## Choosing the Right Tool | Scenario | Recommended Approach | | :--- | :--- | -| Traditional REST APIs with advanced querying | FlexQuery.NET | -| Frontend-heavy graph-driven applications | GraphQL | -| Enterprise Microsoft ecosystem integrations | OData | -| Reporting APIs with aggregates and projections | FlexQuery.NET | -| Real-time subscription systems | GraphQL | -| Power BI / Excel interoperability | OData | - ---- - -# Final Thoughts - -Each technology solves a different category of problem: - -| Technology | Primary Focus | -| :--- | :--- | -| FlexQuery.NET | REST query abstraction | -| GraphQL | Client-driven graph queries | -| OData | Standardized REST interoperability | - -FlexQuery.NET is designed for teams that want: -- dynamic querying -- projection -- aggregates -- validation -- field-level restrictions - -while keeping a traditional REST API architecture. - -GraphQL excels in highly dynamic frontend ecosystems with diverse client needs. - -OData excels in interoperability-focused enterprise environments and Microsoft ecosystem tooling. +| Traditional REST APIs with advanced querying | **FlexQuery.NET** | +| Reporting APIs with dynamic grouping and aggregates | **FlexQuery.NET** | +| Frontend-heavy graph-driven applications | **GraphQL** | +| Real-time subscription systems | **GraphQL** | +| Enterprise Microsoft ecosystem integrations | **OData** | +| Power BI / Excel interoperability | **OData** | diff --git a/docs/guide/core-concepts.md b/docs/guide/core-concepts.md index 97fe06c..c52631b 100644 --- a/docs/guide/core-concepts.md +++ b/docs/guide/core-concepts.md @@ -1,6 +1,19 @@ # Core Concepts +## Overview +This guide explains the foundational building blocks of FlexQuery.NET. It covers the abstract syntax tree (AST) lifecycle, the public HTTP contracts, the internal configuration boundaries, and the pipeline that translates a string request into a database result. + +## Why this feature exists + +FlexQuery is a pipeline engine. To effectively debug complex projection queries, use keyset pagination, or configure strict security boundaries, developers must understand the difference between the client's intent (`FlexQueryParameters`), the parsed model (`QueryOptions`), and the server's policy (`BaseQueryOptions`). + +## When to use + +- Read this page when you want to move beyond basic `FlexQueryAsync` usage and understand how to manually orchestrate the pipeline. +- Refer to this page to understand the exact JSON envelopes and projection modes supported out-of-the-box. + +--- ## API Levels @@ -8,57 +21,50 @@ FlexQuery.NET exposes two complementary API layers: | API Level | Recommended For | Entry Point | |---|---|---| -| High-Level API | Controllers, APIs, frontend-driven filtering | `FlexQueryParameters + FlexQuery()` | -| Advanced API | Server-side query composition, strongly-typed filters | `QueryOptions + ApplyQueryOptions()` | +| **High-Level API** | Controllers, APIs, frontend-driven filtering | `FlexQueryParameters` + `FlexQueryAsync()` | +| **Advanced API** | Server-side query composition, strongly-typed filters | `QueryOptions` + `ApplyFilter()`, `ApplySort()` | Most applications should use the high-level API. The advanced API exists for scenarios requiring: -- programmatic query construction -- dynamic filter composition +- programmatic query construction (e.g., hardcoding a multi-tenant tenant ID filter) +- dynamic filter composition outside of HTTP - nested logical query trees - reusable server-side query templates --- -## Understanding the Core Model - -Understanding the core model helps you use FlexQuery.NET correctly and avoid common mistakes. - ---- - ## The Execution Pipeline -Every query in FlexQuery.NET flows through the same pipeline: +Every query in FlexQuery.NET flows through a strict lifecycle: -``` +```text HTTP Query String β”‚ β–Ό FlexQueryParameters ← Public DTO, bound from [FromQuery] β”‚ β–Ό - QueryOptionsParser.Parse() ← Detects format, builds AST + ToQueryOptions() ← Detects format, builds AST β”‚ β–Ό - QueryOptions ← The internal parsed model + QueryOptions ← The internal parsed model β”‚ - β”œβ”€β”€ ValidateOrThrow() ← Field access, operator, depth checks + β”œβ”€β”€ ValidateOrThrow() ← Enforces Server Policy (Field access, depth) β”‚ - β”œβ”€β”€ ApplyFilter() ← Expression tree β†’ SQL WHERE - β”œβ”€β”€ ApplySort() ← Expression tree β†’ SQL ORDER BY - β”œβ”€β”€ ApplyPaging() ← SKIP / TAKE - β”œβ”€β”€ ApplyFilteredIncludes() ← Include pipeline - └── ApplySelect() ← Dynamic projection + β”œβ”€β”€ ApplyFilter() ← AST β†’ SQL WHERE / Expression Tree + β”œβ”€β”€ ApplySort() ← AST β†’ SQL ORDER BY + β”œβ”€β”€ ApplyPaging() ← SKIP / TAKE or Keyset Cursor + β”œβ”€β”€ ApplyExpand() ← AST β†’ SQL JOINs / EF Includes + └── ApplySelect() ← Dynamic projection β”‚ β–Ό - QueryResult - { data, totalCount, page, pageSize } + QueryResult ``` --- -## FlexQueryParameters +## `FlexQueryParameters` `FlexQueryParameters` is the **public API contract** β€” the DTO your clients interact with. @@ -71,30 +77,29 @@ public async Task Get([FromQuery] FlexQueryParameters parameters) | Property | Type | Purpose | | :--- | :--- | :--- | -| `Filter` | `string?` | DSL or JSON filter expression | -| `Query` | `string?` | JQL-style filter (`query=name = "alice"`) | -| `Sort` | `string?` | Sort expression (`name:asc,age:desc`) | +| `Filter` | `string?` | DSL filter expression (`Name:eq:Alice`) | +| `Query` | `string?` | Alternative JQL/OData-style string parser | +| `Sort` | `string?` | Sort expression (`Name:asc,Age:desc`) | | `Select` | `string?` | Comma-separated fields to project | -| `Includes` | `string?` | Navigation properties to include | +| `Include` | `string?` | Navigation properties to include / expand | | `GroupBy` | `string?` | Fields to group by | | `Having` | `string?` | Aggregate condition on groups | | `Page` | `int?` | Page number (1-indexed) | | `PageSize` | `int?` | Items per page | | `IncludeCount` | `bool?` | Whether to return total count | | `Distinct` | `bool?` | Apply DISTINCT | -| `Mode` | `string?` | Projection mode: `nested`, `flat`, `flat-mixed` | +| `Mode` | `string?` | Projection mode: `Nested`, `Flat`, `FlatMixed` | +| `UseKeysetPagination` | `bool?` | Force keyset pagination engine | +| `Cursor` | `string?` | Keyset pagination token from a previous request | --- -## QueryOptions +## `QueryOptions` -`QueryOptions` is the **internal parsed representation** of a client's request. - -`QueryOptions` is primarily produced by `QueryOptionsParser.Parse()`, -but advanced users may also construct it manually for programmatic query composition. +`QueryOptions` is the **internal parsed representation** of a client's request. It represents the AST. ```csharp -var options = QueryOptionsParser.Parse(parameters); +QueryOptions options = parameters.ToQueryOptions(); ``` Key properties: @@ -104,107 +109,76 @@ Key properties: | `Filter` | `FilterGroup?` | Parsed filter AST (nested AND/OR tree) | | `Sort` | `List` | Ordered list of sort fields and directions | | `Select` | `List?` | Projected field paths | -| `Includes` | `List?` | Navigation properties to include | -| `FilteredIncludes` | `List?` | Structured include tree with inline filters | -| `Paging` | `PagingOptions` | Page number, page size, skip offset | +| `Expand` | `List?` | Structured include tree with inline filters | +| `Paging` | `PagingOptions` | Page number, page size, or cursor data | | `GroupBy` | `List?` | Group-by field paths | | `Aggregates` | `List` | Aggregate expressions (sum, count, avg) | -| `Having` | `HavingCondition?` | HAVING clause for aggregate filtering | | `ProjectionMode` | `ProjectionMode` | Nested / Flat / FlatMixed | -| `Distinct` | `bool?` | Apply DISTINCT | -| `CaseInsensitive` | `bool` | Whether string comparisons are case-insensitive | -| `IncludeCount` | `bool?` | Whether to run a COUNT query | --- -## QueryExecutionOptions +## `BaseQueryOptions` (Server Policy) -`QueryExecutionOptions` contains **server-side constraints** β€” not client-provided. +While `QueryOptions` represents what the *client wants*, `BaseQueryOptions` (and its derivatives like `EfCoreQueryOptions` and `DapperQueryOptions`) represents what the *server allows*. -You create it in your controller and pass it to `ValidateOrThrow()` or `FlexQueryAsync`. +You configure this via the lambda in `FlexQueryAsync`: ```csharp -var execOptions = new QueryExecutionOptions +var result = await _db.Users.FlexQueryAsync(parameters, exec => { - AllowedFields = new HashSet { "id", "name", "email" }, - BlockedFields = new HashSet { "passwordHash" }, - FilterableFields = new HashSet { "name", "status" }, - SortableFields = new HashSet { "name", "createdAt" }, - SelectableFields = new HashSet { "id", "name", "email" }, - MaxFieldDepth = 2, - StrictFieldValidation = true -}; + exec.AllowedFields = new HashSet { "Id", "Name", "Email" }; + exec.MaxFieldDepth = 2; + exec.StrictFieldValidation = true; +}); ``` -| Property | Type | Description | -| :--- | :--- | :--- | -| `AllowedFields` | `HashSet?` | Global allow-list (all operations) | -| `BlockedFields` | `HashSet?` | Explicitly blocked fields | -| `FilterableFields` | `HashSet?` | Fields allowed in filter expressions | -| `SortableFields` | `HashSet?` | Fields allowed in sort expressions | -| `SelectableFields` | `HashSet?` | Fields allowed in select/projection | -| `MaxFieldDepth` | `int?` | Maximum dot-notation path depth | -| `FieldMappings` | `Dictionary?` | Field alias β†’ real field mapping | -| `FieldAccessResolver` | `IFieldAccessResolver?` | Custom programmatic resolver | -| `StrictFieldValidation` | `bool` | Throw on access violation (vs. collect errors) | +| Property | Description | +| :--- | :--- | +| `AllowedFields` | Global allow-list. If a client requests a field not on this list, it is rejected. | +| `BlockedFields` | Explicitly blocked fields (e.g. `PasswordHash`). Overrides `AllowedFields`. | +| `MaxFieldDepth` | Maximum dot-notation path depth. `2` allows `Category.Name` but blocks `Category.Company.Name`. | +| `MaxPageSize` | Hard cap on `pageSize` to prevent memory exhaustion. | +| `StrictFieldValidation` | `true` = Throw exception on violation. `false` = Silently strip unauthorized fields from the query. | --- - ## Parsing Formats -`QueryOptionsParser` auto-detects the input format: +FlexQuery auto-detects the input format based on the property used in `FlexQueryParameters`: -### DSL Format -``` -GET /api/users?filter=status:eq:active&sort=name:asc&page=1&pageSize=10 +### DSL Format (Standard) +```http +GET /api/users?filter=status:eq:active%26name:contains:alice&sort=name:asc ``` ### JQL Format -``` +```http GET /api/users?query=status = "active" AND age >= 18&sort=name:asc ``` ### JSON Format -``` +```http GET /api/users?filter={"logic":"and","filters":[{"field":"status","operator":"eq","value":"active"}]} ``` -### Indexed Format -``` -GET /api/users?filter[0].field=status&filter[0].operator=eq&filter[0].value=active -``` - --- ## Validation -Validation runs against the `QueryOptions` AST, not the raw string. It checks: - -1. **Field existence** β€” Does the field exist on the entity type? -2. **Field access** β€” Is the field in the `AllowedFields` / `FilterableFields` lists? -3. **Operator validity** β€” Is the operator compatible with the field type? -4. **Depth** β€” Does the dot-notation path exceed `MaxFieldDepth`? -5. **Blocked fields** β€” Is the field explicitly in `BlockedFields`? +Validation runs against the `QueryOptions` AST before any database interaction occurs. It checks: -```csharp -// Option 1: Throw on first failure -options.ValidateOrThrow(execOptions); +1. **Field access** β€” Is the field in the `AllowedFields` list? +2. **Operator validity** β€” Is the operator compatible with the CLR/SQL field type? +3. **Depth** β€” Does the dot-notation path exceed `MaxFieldDepth`? +4. **Blocked fields** β€” Is the field explicitly in `BlockedFields`? -// Option 2: Collect all errors -var result = options.ValidateSafe(execOptions); -if (!result.IsValid) -{ - // result.Errors is a List - return BadRequest(result.Errors); -} -``` +If validation fails and `StrictFieldValidation` is true, a `QueryValidationException` is thrown. --- ## Projection -FlexQuery.NET supports three projection modes: +FlexQuery.NET supports three projection modes when mapping relational data to JSON: ### Nested (Default) @@ -248,20 +222,36 @@ Scalars at the top level, collections remain nested: ## QueryResult -Every high-level method returns a `QueryResult`: +Every high-level method returns a standardized envelope `QueryResult`: ```json { - "data": [ ... ], "totalCount": 150, + "resultCount": 20, "page": 1, - "pageSize": 20 + "pageSize": 20, + "totalPages": 8, + "hasNextPage": true, + "hasPreviousPage": false, + "aggregates": null, + "data": [ + { "id": 1, "name": "Alice" }, + { "id": 2, "name": "Bob" } + ], + "nextCursorToken": null } ``` | Property | Type | Description | | :--- | :--- | :--- | -| `data` | `List` | The current page of results | -| `totalCount` | `int?` | Total records before paging (null if `IncludeCount=false`) | -| `page` | `int` | Current page number | -| `pageSize` | `int` | Items per page | +| `data` | `List` | The current page of results. | +| `totalCount` | `int?` | Total records before paging (null if `IncludeCount=false` or keyset pagination is used). | +| `resultCount` | `int?` | Count of records after grouping (often equals `totalCount`). | +| `page` | `int` | Current page number. | +| `pageSize` | `int` | Items per page. | +| `totalPages` | `int` | Computed `Ceiling(resultCount / pageSize)`. | +| `nextCursorToken` | `string?` | Keyset cursor for the next page, used in high-performance paging. | + +## Best Practices +- **Never skip validation:** Whether you use `FlexQueryAsync` or the manual pipeline, never trust client query definitions blindly. Always configure an execution policy. +- **Understand Projection:** Only use `Flat` or `FlatMixed` if your frontend data-grid natively supports or requires flat dictionary data (e.g. some versions of Kendo or older AG Grid instances). `Nested` is standard REST. diff --git a/docs/guide/debugging.md b/docs/guide/debugging.md index 2fed87a..9e17b14 100644 --- a/docs/guide/debugging.md +++ b/docs/guide/debugging.md @@ -1,49 +1,37 @@ # Debugging -FlexQuery.NET includes built-in debugging tools that let you inspect the parsed query AST, generated expression trees, and execution pipeline state β€” without touching a database. +## Overview ---- - -## What You Can Debug - -- The parsed filter AST (Abstract Syntax Tree) -- The normalized filter form (used for caching) -- The generated LINQ expression as a string -- Paging, sort, and projection state - ---- +FlexQuery.NET includes built-in debugging tools that allow you to inspect the parsed query Abstract Syntax Tree (AST), the generated expression trees, and the execution pipeline state β€” without necessarily touching a database. -## Inspecting the Parsed AST +## Why this feature exists -After parsing, the `QueryOptions.Ast` property holds the raw parsed AST (if using DSL or JQL format): +When building dynamic, client-driven querying APIs, it can sometimes be difficult to determine *why* a query failed or why the generated SQL looks a certain way. Is the frontend sending malformed URL encoded strings? Is the security validation stripping out a valid field? FlexQuery provides deep introspection capabilities so you can trace the exact lifecycle of a request from the HTTP boundary down to the ADO.NET command. -```csharp -var options = QueryOptionsParser.Parse(parameters); +## When to use -// The raw parser output (JQL or DSL AST node) -Console.WriteLine(options.Ast); - -// The resolved FilterGroup tree -Console.WriteLine(options.Filter?.ToString()); -``` +- Read this guide when you are encountering unexpected `QueryValidationExceptions`. +- Read this guide when you want to intercept and log the generated SQL strings in production. +- Use the `IFlexQueryExecutionListener` when building global APM telemetry integrations. --- -## Inspecting QueryOptions in a Controller +## Inspecting the Parsed AST -Add a debug endpoint to inspect what FlexQuery.NET parsed from a request: +Before execution, you can inspect exactly what FlexQuery.NET parsed from the HTTP request by calling `.ToQueryOptions()`. ```csharp [HttpGet("debug")] public IActionResult DebugQuery([FromQuery] FlexQueryParameters parameters) { - var options = QueryOptionsParser.Parse(parameters); + // The internal parsed model + QueryOptions options = parameters.ToQueryOptions(); return Ok(new { - filter = options.Filter, - sort = options.Sort, - select = options.Select, + filter = options.Filter, // The nested AND/OR tree + sort = options.Sort, // Ordered list of sorts + select = options.Select, // Projected field paths paging = new { page = options.Paging.Page, @@ -53,13 +41,12 @@ public IActionResult DebugQuery([FromQuery] FlexQueryParameters parameters) projectionMode = options.ProjectionMode.ToString(), groupBy = options.GroupBy, aggregates = options.Aggregates, - includes = options.Includes, - caseInsensitive = options.CaseInsensitive + includes = options.Expand, // v4 uses Expand }); } ``` -**Sample output for `?filter=status:eq:active&sort=name:asc&page=2&pageSize=10`:** +**Sample output for `?filter=Status:eq:active&sort=Name:asc&page=2&pageSize=10`:** ```json { @@ -67,14 +54,14 @@ public IActionResult DebugQuery([FromQuery] FlexQueryParameters parameters) "logic": "And", "children": [ { - "field": "status", + "field": "Status", "operator": "eq", "value": "active" } ] }, "sort": [ - { "field": "name", "descending": false } + { "field": "Name", "descending": false } ], "select": null, "paging": { @@ -85,8 +72,7 @@ public IActionResult DebugQuery([FromQuery] FlexQueryParameters parameters) "projectionMode": "Nested", "groupBy": null, "aggregates": [], - "includes": null, - "caseInsensitive": true + "includes": null } ``` @@ -94,13 +80,14 @@ public IActionResult DebugQuery([FromQuery] FlexQueryParameters parameters) ## Inspecting the Validation Result -Capture the validation result before throwing: +If a query is failing and you want to capture the validation result before throwing an exception, you can run validation manually: ```csharp -var options = QueryOptionsParser.Parse(parameters); +var options = parameters.ToQueryOptions(); + var execOptions = new QueryExecutionOptions { - AllowedFields = new HashSet { "id", "name", "status" } + AllowedFields = new HashSet { "Id", "Name", "Status" } }; var validation = options.ValidateSafe(execOptions); @@ -114,73 +101,42 @@ foreach (var error in validation.Errors) **Sample output:** -``` +```text IsValid: False - [FIELD_ACCESS_DENIED] salary: Field 'salary' is not in the global allowed list. - [FIELD_ACCESS_DENIED] internalNotes: Field 'internalNotes' is explicitly blocked. + [FIELD_ACCESS_DENIED] Salary: Field 'Salary' is not in the global allowed list. + [FIELD_ACCESS_DENIED] InternalNotes: Field 'InternalNotes' is explicitly blocked. ``` --- -## Inspecting the Filter Normalizer +## Diagnostic Logging (`IFlexQueryExecutionListener`) -The `FilterNormalizer` canonicalizes a filter AST. Use it to verify cache key stability: +In v4, FlexQuery introduces a formal diagnostic telemetry hook: the `IFlexQueryExecutionListener`. -```csharp -using FlexQuery.NET.Builders; +You can pass a custom listener into your `options` lambda to receive real-time execution metrics. -var options1 = QueryOptionsParser.Parse(new FlexQueryParameters +```csharp +var result = await _db.Products.FlexQueryAsync(parameters, options => { - Filter = "status:eq:active,age:gte:18" + options.AllowedFields = ["Id", "Name", "Price"]; + + // Attach a listener for debugging + options.Listener = new ConsoleDiagnosticsListener(); }); -var options2 = QueryOptionsParser.Parse(new FlexQueryParameters +// Custom Listener Implementation +public class ConsoleDiagnosticsListener : IFlexQueryExecutionListener { - Filter = "age:gte:18,status:eq:active" -}); - -var key1 = options1.GetCacheKey(typeof(User), "predicate"); -var key2 = options2.GetCacheKey(typeof(User), "predicate"); - -Console.WriteLine(key1 == key2); // true β€” normalized form is identical -``` - ---- - -## Viewing the Cache Key - -```csharp -var cacheKey = options.GetCacheKey(typeof(User), "predicate"); -Console.WriteLine(cacheKey); -// e.g., "predicate:MyApp.Models.User:ci:a3f8c2d1|1|20|name_asc|id,name" - -var hash = options.GetQueryHash(); -Console.WriteLine(hash); -// e.g., "SHA256: 3a7f2c..." -``` - ---- - -## Logging Integration - -For production debugging, log the parsed options: - -```csharp -[HttpGet] -public async Task GetUsers( - [FromQuery] FlexQueryParameters parameters, - ILogger logger) -{ - var options = QueryOptionsParser.Parse(parameters); - - logger.LogDebug( - "FlexQuery parsed: filter={Filter}, sort={Sort}, page={Page}, pageSize={PageSize}", - options.Filter != null ? "present" : "none", - options.Sort.Count, - options.Paging.Page, - options.Paging.PageSize); - - // ... rest of pipeline + public void OnQueryExecuted(FlexQueryExecutionEvent executionEvent) + { + Console.WriteLine($"Query executed in {executionEvent.ElapsedMilliseconds}ms"); + Console.WriteLine($"Total Records Found: {executionEvent.TotalCount}"); + + if (executionEvent.Exception != null) + { + Console.WriteLine($"Failed with: {executionEvent.Exception.Message}"); + } + } } ``` @@ -188,18 +144,19 @@ public async Task GetUsers( ## Viewing Generated SQL (EF Core) -Use EF Core's built-in logging to see the SQL FlexQuery.NET generates: +If you are using the EF Core provider, you can use EF Core's built-in logging to see the SQL FlexQuery.NET generates: ```csharp // In Program.cs or DbContext OnConfiguring optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information); ``` -Or use `ToQueryString()` to inspect the SQL without executing: +Or you can use `ToQueryString()` in the manual pipeline to inspect the SQL without executing: ```csharp -var options = QueryOptionsParser.Parse(parameters); +var options = parameters.ToQueryOptions(); var query = _context.Users.AsQueryable(); + query = query.ApplyFilter(options); query = query.ApplySort(options); @@ -223,20 +180,16 @@ ORDER BY [u].[Name] ### "My filter isn't working" -1. Check `options.Filter` β€” is it null? The format might not have been recognized. -2. Try JSON format as the most explicit: `filter={"logic":"and","filters":[...]}` -3. Check the operator β€” typos are silently ignored. Use `FilterOperators.Normalize("myop")` to verify. +1. Check `options.Filter` in the parsed AST β€” is it null? The format might not have been recognized, or a URL decoding issue occurred (e.g., using `&` instead of `%26` for multiple filters). +2. Check the operator β€” typos in operator strings are silently ignored during parsing but caught during validation. ### "Results are empty but shouldn't be" -1. Verify case sensitivity: `options.CaseInsensitive` is `true` by default. -2. Check if a server-side pre-filter is excluding results before FlexQuery runs. -3. Use `query.ToQueryString()` to see the exact SQL. - -### "I'm getting a double-filter in SQL" - -You are using a deprecated v1 method pattern. See [Execution Pipeline](/guide/execution) for the correct approach. +1. Verify case sensitivity: `options.CaseInsensitive` is `true` by default, but if you turned it off, "Alice" and "alice" will not match. +2. Check if a server-side pre-filter is excluding results before FlexQuery runs (e.g., `_context.Users.Where(u => u.TenantId == 1).FlexQueryAsync(...)`). +3. Use `query.ToQueryString()` or SQL Profiler to see the exact SQL generated. ### "Validation is rejecting a valid field" -Check `AllowedFields`. Field matching is **case-insensitive** by default. If you set `AllowedFields = { "Name" }` and the client sends `filter=name:eq:alice`, it will still pass. +1. Check `AllowedFields`. +2. Remember that if `StrictFieldValidation` is true, the query will completely abort on the first infraction. Check your frontend network tab to see if the UI is requesting a deeply nested relationship field that exceeds your `MaxFieldDepth` setting. diff --git a/docs/guide/dotnet-comparison.md b/docs/guide/dotnet-comparison.md index 2666e54..d065140 100644 --- a/docs/guide/dotnet-comparison.md +++ b/docs/guide/dotnet-comparison.md @@ -1,121 +1,99 @@ - # Comparison: FlexQuery.NET vs .NET Query Libraries -This page compares FlexQuery.NET with several popular .NET query libraries including: +## Overview +This page compares FlexQuery.NET with several popular .NET query libraries including: - Gridify - Sieve - System.Linq.Dynamic.Core -The goal is not to declare a β€œwinner”, but to clarify the different design philosophies, strengths, and tradeoffs of each approach. +The goal is not to declare a universal "winner", but to clarify the different design philosophies, strengths, and tradeoffs of each approach. Different libraries solve different problems. + +## Why this comparison exists + +When building dynamic APIs in .NET, teams often evaluate a spectrum of tools ranging from simple string-to-LINQ mappers to full execution engines. This guide exists to help architects understand where FlexQuery.NET sits on that spectrum, particularly compared to heavily utilized libraries like `System.Linq.Dynamic.Core` and `Gridify`. -Different libraries solve different problems. +## When to choose which + +- **Simple internal CRUD filtering**: Gridify +- **Attribute-driven filtering**: Sieve +- **Runtime-generated LINQ expressions**: Dynamic.Core +- **Public APIs with validation/projection**: FlexQuery.NET +- **Reporting endpoints with grouping/aggregates**: FlexQuery.NET --- -# Overview +## High-Level Matrix | | FlexQuery.NET | Gridify | Sieve | System.Linq.Dynamic.Core | | :--- | :--- | :--- | :--- | :--- | -| Primary focus | Unified query pipeline | Lightweight filtering | Attribute-based filtering | Dynamic LINQ expressions | -| Input style | DSL, JQL, JSON, Indexed | Custom DSL | Query model | LINQ expression strings | -| Projection (`select`) | βœ… | ❌ | ❌ | βœ… | -| Grouping / Aggregates | βœ… | ❌ | ❌ | βœ… | -| Filtered includes | βœ… | ❌ | ❌ | ❌ | -| Validation pipeline | βœ… Built-in | ❌ External | ⚠️ Attribute-based | ❌ External | -| Field-level restrictions | βœ… | ❌ | ⚠️ Attribute-based | ❌ | -| Multiple query formats | βœ… | ❌ | ❌ | ❌ | -| Async EF Core pipeline | βœ… | βœ… | βœ… | ⚠️ Manual composition | -| OpenAPI-friendly DTO | βœ… | βœ… | βœ… | ❌ | +| **Primary focus** | Unified query pipeline | Lightweight filtering | Attribute-based filtering | Dynamic LINQ expressions | +| **Input style** | DSL, JQL, JSON, Indexed | Custom DSL | Query model | LINQ expression strings | +| **Projection (`select`)** | βœ… | ❌ | ❌ | βœ… | +| **Grouping / Aggregates**| βœ… | ❌ | ❌ | βœ… | +| **Filtered includes** | βœ… | ❌ | ❌ | ❌ | +| **Validation pipeline** | βœ… Built-in | ❌ External | ⚠️ Attribute-based | ❌ External | +| **Field restrictions** | βœ… | ❌ | ⚠️ Attribute-based | ❌ | +| **Multiple formats** | βœ… | ❌ | ❌ | ❌ | +| **Async EF Core pipeline**| βœ… | βœ… | βœ… | ⚠️ Manual composition | +| **OpenAPI-friendly DTO** | βœ… | βœ… | βœ… | ❌ | --- -# Different Philosophies +## Different Philosophies -## FlexQuery.NET +### FlexQuery.NET FlexQuery.NET is designed as a higher-level query framework focused on: - - API-driven querying - validation -- projection -- grouping -- aggregates +- projection (SELECT) +- grouping and aggregates - field-level access control - reusable query pipelines -It is intended for scenarios where query safety, flexibility, and composability are important. - -Typical use cases: -- public APIs -- multi-tenant systems -- reporting endpoints -- admin dashboards -- advanced search systems +It is intended for scenarios where query safety, flexibility, and composability are paramount, such as public APIs, multi-tenant systems, and reporting endpoints. --- -## Gridify - -Gridify focuses on simplicity and minimal setup. - -It provides lightweight filtering, sorting, and paging with a small API surface and quick onboarding experience. +### Gridify -Typical use cases: -- internal CRUD APIs -- admin tools -- lightweight filtering scenarios -- rapid prototyping +Gridify focuses on simplicity and minimal setup. It provides lightweight filtering, sorting, and paging with a small API surface and quick onboarding experience. -Applications requiring projection, aggregates, or field-level validation may require additional infrastructure. +It is ideal for internal CRUD APIs and rapid prototyping. Applications requiring projection, aggregates, or field-level validation may require additional custom infrastructure on top of Gridify. --- -## Sieve +### Sieve -Sieve uses attribute-based configuration to enable filtering and sorting. +Sieve uses attribute-based configuration to enable filtering and sorting. It integrates naturally with ASP.NET-style conventions and works well for teams preferring declarative entity configuration. -It integrates naturally with ASP.NET-style conventions and works well for teams preferring declarative entity configuration. - -Typical use cases: -- attribute-driven APIs -- simple filtering/sorting requirements -- convention-based applications +It is best suited for attribute-driven APIs and simple filtering/sorting requirements. --- -## System.Linq.Dynamic.Core - -System.Linq.Dynamic.Core provides highly flexible runtime LINQ expression execution using string-based expressions. +### System.Linq.Dynamic.Core -It is extremely powerful for advanced dynamic query generation scenarios. +System.Linq.Dynamic.Core provides highly flexible runtime LINQ expression execution using string-based expressions. It is extremely powerful for advanced dynamic query generation scenarios (like runtime report builders). -Typical use cases: -- runtime-generated LINQ -- advanced admin tooling -- dynamic report builders -- expression-driven systems - -Because expressions are string-based, applications typically need their own validation and restriction layers for public-facing APIs. +Because expressions are string-based and parsed blindly, applications typically need to build their own extensive validation and restriction layers to expose it safely to public-facing APIs. --- -# The Same Query Across Libraries - -Goal: +## The Same Query Across Libraries -- status == "active" -- age >= 18 -- sort by name ascending -- page 2 -- page size 10 +**Goal:** +- `status == "active"` +- `age >= 18` +- Sort by `name` ascending +- Page 2, Page size 10 --- -## FlexQuery.NET +### FlexQuery.NET ```http -GET /api/users?filter=status:eq:active&age:gte:18&sort=name:asc&page=2&pageSize=10 +GET /api/users?filter=status:eq:active%26age:gte:18&sort=name:asc&page=2&pageSize=10 ``` ```csharp @@ -124,6 +102,7 @@ public async Task GetUsers([FromQuery] FlexQueryParameters parame { var result = await _context.Users.FlexQueryAsync(parameters, exec => { + // Enforce server policy exec.AllowedFields = new HashSet { "name", @@ -138,7 +117,7 @@ public async Task GetUsers([FromQuery] FlexQueryParameters parame --- -## Gridify +### Gridify ```http GET /api/users?filter=status=active,age>=18&orderBy=name&page=2&pageSize=10 @@ -153,11 +132,11 @@ public async Task GetUsers([FromQuery] GridifyQuery query) } ``` -Gridify intentionally keeps configuration lightweight and focused on filtering/sorting/paging. +Gridify intentionally keeps configuration lightweight and focused purely on filtering/sorting/paging. --- -## Sieve +### Sieve ```http GET /api/users?filters=Status==active,Age>=18&sorts=Name&page=2&pageSize=10 @@ -166,10 +145,7 @@ GET /api/users?filters=Status==active,Age>=18&sorts=Name&page=2&pageSize=10 ```csharp public class UserSieveProcessor : SieveProcessor { - public UserSieveProcessor(IOptions options) - : base(options) - { - } + public UserSieveProcessor(IOptions options) : base(options) { } } public class User @@ -196,7 +172,7 @@ Sieve emphasizes declarative configuration through attributes. --- -## System.Linq.Dynamic.Core +### System.Linq.Dynamic.Core ```csharp [HttpGet] @@ -231,11 +207,11 @@ public async Task GetUsers( } ``` -Dynamic.Core provides maximum flexibility, but applications typically implement their own validation, paging, and restriction layers. +Dynamic.Core provides maximum flexibility, but applications typically implement their own validation, paging, and projection layers. --- -# Feature Matrix +## Feature Matrix | Feature | FlexQuery.NET | Gridify | Sieve | Dynamic.Core | | :--- | :---: | :---: | :---: | :---: | @@ -252,104 +228,47 @@ Dynamic.Core provides maximum flexibility, but applications typically implement | OpenAPI DTO | βœ… | βœ… | βœ… | ❌ | | Query result envelope | βœ… | ⚠️ Partial | ❌ | ❌ | | Async EF Core support | βœ… | βœ… | βœ… | ⚠️ Manual | -| Strongly-typed query model | βœ… | ❌ | ❌ | ❌ | - ---- - -# Tradeoffs - -## FlexQuery.NET - -### Strengths -- Unified query pipeline -- Projection support -- Aggregates and grouping -- Validation and field-level restrictions -- Multiple query formats -- Strongly-typed query model - -### Tradeoffs -- More concepts to learn initially -- Heavier than lightweight filtering libraries -- More configuration surface area - ---- - -## Gridify - -### Strengths -- Extremely simple setup -- Minimal configuration -- Lightweight API surface -- Fast onboarding experience - -### Tradeoffs -- Focused primarily on filtering/sorting/paging -- Advanced query scenarios may require additional infrastructure - ---- - -## Sieve - -### Strengths -- Declarative attribute-based configuration -- Familiar ASP.NET-style conventions -- Clean integration with entity models - -### Tradeoffs -- Requires entity annotations -- Limited advanced query features -- Primarily focused on filtering/sorting +| Strongly-typed AST | βœ… | ❌ | ❌ | ❌ | --- -## System.Linq.Dynamic.Core - -### Strengths -- Extremely flexible -- Full runtime LINQ expression support -- Powerful dynamic query generation +## Tradeoffs -### Tradeoffs -- String-based expressions can become difficult to validate -- Public APIs often require additional restriction layers -- Paging/projection pipelines are typically composed manually +### FlexQuery.NET ---- +**Strengths** +- Unified query pipeline that handles everything from parsing to SQL execution. +- Projection support (dynamic SELECT) minimizes database I/O. +- Built-in validation and field-level restrictions. +- Standardized REST envelope (`QueryResult`). -# Choosing the Right Tool +**Tradeoffs** +- More concepts to learn initially compared to minimal libraries. -| Scenario | Recommended Approach | -| :--- | :--- | -| Simple internal CRUD filtering | Gridify | -| Attribute-driven filtering | Sieve | -| Runtime-generated LINQ expressions | Dynamic.Core | -| Public APIs with validation/projection | FlexQuery.NET | -| Multi-tenant field-restricted APIs | FlexQuery.NET | -| Reporting endpoints with grouping/aggregates | FlexQuery.NET | +### Gridify ---- +**Strengths** +- Extremely simple setup with minimal configuration. +- Lightweight API surface and fast onboarding experience. -# Final Thoughts +**Tradeoffs** +- Advanced query scenarios (Projection, Aggregates, Included filtering) require additional infrastructure. -Each library optimizes for different priorities: +### Sieve -| Library | Primary Priority | -| :--- | :--- | -| Gridify | Simplicity | -| Sieve | Declarative conventions | -| Dynamic.Core | Flexibility | -| FlexQuery.NET | Unified query pipeline | +**Strengths** +- Declarative attribute-based configuration fits nicely with Entity configuration. +- Clean integration with ASP.NET conventions. -FlexQuery.NET is designed for applications requiring more than simple filtering β€” particularly scenarios involving: +**Tradeoffs** +- Requires heavy entity annotations. +- Limited advanced query features. -- projection -- grouping -- aggregates -- validation -- field-level access control -- reusable query pipelines +### System.Linq.Dynamic.Core -For lightweight CRUD filtering, smaller libraries may provide a simpler experience. +**Strengths** +- Full runtime LINQ expression support makes it exceptionally powerful. -For advanced API querying scenarios, FlexQuery.NET aims to provide a more complete query abstraction layer. +**Tradeoffs** +- Public APIs often require additional restriction layers to prevent malicious strings. +- Paging/projection pipelines are typically composed manually. diff --git a/docs/guide/dto-mapping.md b/docs/guide/dto-mapping.md index b53c7bd..9a0317f 100644 --- a/docs/guide/dto-mapping.md +++ b/docs/guide/dto-mapping.md @@ -1,30 +1,68 @@ # DTO Field Mapping -When exposing an API, it's best practice to separate your external Data Transfer Objects (DTOs) from your internal database entities. `FlexQuery.NET` provides a seamless way to map public DTO fields to internal entity expressions using `MapField`. +## Overview -## Why Map Fields? +When exposing an API, it is an industry best practice to separate your external Data Transfer Objects (DTOs) from your internal database entities. `FlexQuery.NET` provides a seamless way to map public DTO fields to internal entity expressions using the `MapField` method on your execution options. -If your API exposes a `CustomerDto`, but queries the database against a `Customer` entity, clients might try to filter or sort by fields that exist on the DTO but not on the entity, or they might try to access sensitive database columns. +## Why this feature exists -By using `MapField`, you define exactly how an external field string should be translated into an internal `IQueryable` LINQ expression. +If your API exposes a `CustomerDto`, but you execute `FlexQueryAsync` against a `Customer` database entity, clients might try to filter or sort by fields that exist on the DTO but *not* on the entity (like a computed property). Conversely, if you rename a database column from `strFullName` to `FirstName`, you don't want to break existing API clients that are still filtering on `?filter=fullName:eq:Alice`. -## Example Usage +By using `MapField`, you define exactly how an external field string should be translated into an internal `IQueryable` LINQ expression before it hits the database. + +## When to use + +- When your API contract differs from your database schema (e.g., camelCase vs PascalCase, or different property names entirely). +- When you want to expose a "computed" field to the client that can be filtered and sorted on the server. +- When you need to provide an alias for a deeply nested relationship to simplify client queries. + +--- + +## Complete Runnable Example + +You configure mappings directly inside the options lambda passed to `FlexQueryAsync`. ```csharp -var options = new BaseQueryOptions(); +[HttpGet] +public async Task GetCustomers([FromQuery] FlexQueryParameters parameters) +{ + var result = await _context.Customers.FlexQueryAsync(parameters, options => + { + // 1. Basic mapping: The external field "name" maps to the internal "FullName" + options.MapField("name", c => c.FullName); + + // 2. Computed mapping: The external field "displayName" maps to a computed concatenation + options.MapField("displayName", c => c.FirstName + " " + c.LastName); -// Basic mapping: The external field "Name" maps to the internal "FullName" -options.MapField("Name", c => c.FullName); + // 3. Navigation mapping: The external field "company" maps to a nested property + options.MapField("company", c => c.Company.Name); -// Complex mapping: The external field "FullName" maps to a computed expression -options.MapField("FullName", c => c.FirstName + " " + c.LastName); + // Security: Don't forget to explicitly allow the mapped field aliases! + options.AllowedFields = ["name", "displayName", "company", "Id"]; + }); -// Navigation mapping: The external field "CompanyName" maps to a nested property -options.MapField("CompanyName", c => c.Company.Name); + return Ok(result); +} ``` -### How it Works +### The Client Request + +```http +GET /api/customers?filter=displayName:contains:John%26company:eq:AcmeCorp&sort=displayName:asc +``` + +### How it Works (Under the Hood) + +When the query is parsed, FlexQuery checks the client's requested fields against your mapping dictionary. + +If a client requests filtering on `displayName`, the query engine intercepts `displayName` and substitutes the LINQ expression `c => c.FirstName + " " + c.LastName`. + +Entity Framework Core then translates that expression into the appropriate SQL (e.g. `WHERE [FirstName] + ' ' + [LastName] LIKE '%John%'`). This completely abstracts your database schema from the API contract, and works natively across both EF Core and the Dapper provider. + +--- -When a query is parsed, if a client requests sorting or filtering on `FullName` (e.g. `?$filter=FullName eq 'John Doe'`), the query engine will intercept `FullName` and substitute the LINQ expression `c => c.FirstName + " " + c.LastName`. +## Best Practices -This completely abstracts your database schema from the API contract, and works natively across EF Core and Dapper. +- **Security First:** Mapped fields are subject to the same validation pipeline as normal fields. If you map `"displayName"`, you must still include `"displayName"` in your `AllowedFields` whitelist, or the request will be rejected. +- **Dapper Limitations:** While EF Core can translate complex inline C# expressions, the Dapper `SqlTranslator` can only translate standard SQL-compatible binary expressions (like string concatenation `+`, math operators, and coalescing). Avoid complex C# method calls (like `.ToString()`) inside a `MapField` expression if you are using the Dapper provider. +- **Alias Collisions:** Ensure your mapped aliases do not collide with actual physical properties on the entity, unless your explicit intent is to shadow and override the default behavior. diff --git a/docs/guide/end-to-end.md b/docs/guide/end-to-end.md index e98daef..dfbc466 100644 --- a/docs/guide/end-to-end.md +++ b/docs/guide/end-to-end.md @@ -1,18 +1,34 @@ # End-to-End Example -This example demonstrates the full lifecycle of a FlexQuery request: from the HTTP query string to the final SQL execution and JSON response. +## Overview + +This example demonstrates the full lifecycle of a FlexQuery.NET request: tracing the execution path from the HTTP query string, through the AST parser and validation pipeline, into the final SQL generation, and ultimately returning the standardized JSON response. + +## Why this feature exists + +When introducing a new query engine to a team, developers often wonder "Where is the magic happening?". By tracing a single request end-to-end, you can clearly see that FlexQuery.NET does not perform in-memory evaluation. It is purely an AST-to-SQL compiler, ensuring your database does all the heavy lifting. + +## When to use + +- Share this page with your database administrators (DBAs) or backend engineers to prove that FlexQuery generates highly optimized, standard SQL queries with native pagination and parameterization. + +--- ## 1. The HTTP Request -A client wants to find all **Active** products in the **Electronics** category with a price greater than **$500**, sorted by the latest arrival. +A client wants to find all **Active** products in the **Electronics** category with a price greater than **$500**, sorted by the latest arrival. + +Notice how the `&` symbol combining filters is URL-encoded as `%26` so it doesn't collide with the standard HTTP query parameter separator. ```http -GET /api/products?filter=Status:eq:Active & Category:eq:Electronics & Price:gt:500 & sort=CreatedAt:desc +GET /api/products?filter=Status:eq:Active%26Category:eq:Electronics%26Price:gt:500&sort=CreatedAt:desc ``` +--- + ## 2. The Controller Action -The request is bound to a `FlexQueryParameters` DTO and processed through the validated pipeline. +The request is automatically bound to a `FlexQueryParameters` DTO by ASP.NET Core and passed into the unified `FlexQueryAsync` pipeline. ```csharp [HttpGet] @@ -21,34 +37,50 @@ public async Task Get([FromQuery] FlexQueryParameters request) // 1. Parsing & Validation happens here // 2. IQueryable is extended with Expression Trees // 3. Query is executed against the DB - var result = await _context.Products - .ApplyValidatedQueryOptions(request) //Deprecated in v2.0 - .ToQueryResultAsync(); + var result = await _context.Products.FlexQueryAsync(request, options => + { + // Enforce strict security: the client can only filter/sort these specific fields + options.AllowedFields = ["Status", "Category", "Price", "CreatedAt", "Id", "Name"]; + }); return Ok(result); } ``` +--- + ## 3. Generated SQL (EF Core) -FlexQuery translates the request into a single, optimized SQL query. No in-memory filtering occurs. +FlexQuery translates the request AST into a single, optimized SQL query via Entity Framework Core. **No in-memory filtering occurs.** ```sql SELECT [p].[Id], [p].[Name], [p].[Price], [p].[Status], [p].[Category], [p].[CreatedAt] FROM [Products] AS [p] -WHERE ([p].[Status] = N'Active') - AND ([p].[Category] = N'Electronics') +WHERE (([p].[Status] = N'Active') + AND ([p].[Category] = N'Electronics')) AND ([p].[Price] > 500.0) ORDER BY [p].[CreatedAt] DESC OFFSET 0 ROWS FETCH NEXT 20 ROWS ONLY ``` +*(Note: In production, values like `N'Active'` and `500.0` are passed as DbParameters (`@p0`, `@p1`) to prevent SQL injection. They are shown as literals here for readability).* + +--- + ## 4. The JSON Response -The client receives a structured response containing the requested data and pagination metadata. +The client receives a structured response containing the requested data and the standard v4 pagination metadata envelope (`QueryResult`). ```json { + "totalCount": 2, + "resultCount": 2, + "page": 1, + "pageSize": 20, + "totalPages": 1, + "hasNextPage": false, + "hasPreviousPage": false, + "aggregates": null, "data": [ { "id": 101, @@ -67,16 +99,14 @@ The client receives a structured response containing the requested data and pagi "createdAt": "2026-04-28T14:30:00Z" } ], - "totalCount": 2, - "page": 1, - "pageSize": 20 + "nextCursorToken": null } ``` -## Why this is Powerful - -- **Client Flexibility**: The client can change the price threshold or category without any backend changes. -- **Server Security**: The server enforces that only `Active` products are visible (if you added a hardcoded filter) and only allows filtering on valid fields. -- **Database Efficiency**: The query uses standard SQL indexes and performs pagination at the database level. +--- +## Best Practices +- **Client Flexibility**: The client can change the price threshold or category without requiring any backend code changes, redeployments, or new DTOs. +- **Server Security**: The server enforces a strict `AllowedFields` whitelist. If a malicious user tries to probe for `?filter=InternalCost:gt:100`, the server immediately returns a `400 Bad Request` validation error, and the database is never touched. +- **Database Efficiency**: The generated query uses standard `OFFSET/FETCH` pagination (or `WHERE Id > cursor` if Keyset pagination is enabled) at the database level, meaning bandwidth and memory are preserved. diff --git a/docs/guide/execution-pipeline.md b/docs/guide/execution-pipeline.md index 310ac2c..db1bf64 100644 --- a/docs/guide/execution-pipeline.md +++ b/docs/guide/execution-pipeline.md @@ -1,38 +1,41 @@ # Execution Pipeline -The execution pipeline is the heart of FlexQuery.NET. Understanding which method to call β€” and why β€” is critical for correctness, security, and performance. +## Overview + +The execution pipeline is the heart of FlexQuery.NET. It dictates the exact order of operations used to translate an incoming HTTP query into a database result. Understanding which pipeline method to call β€” and why β€” is critical for correctness, security, and performance. + +## Why this feature exists + +While `FlexQueryAsync` wraps the entire execution into a single, convenient call, enterprise applications often need to inject custom logic into the middle of the execution phase. For example, you might need to count the total rows in a multi-tenant system *after* applying the client's `WHERE` filter, but *before* you run secondary authorization checks on the data. The modular pipeline design exists so you can decouple the AST from execution. + +## When to use + +- Read this guide when you want to understand the difference between the `FlexQueryAsync` unified wrapper and the low-level `ApplyFilter` / `ApplySort` extension methods. +- Consult this guide if you are writing custom Database Providers (e.g., implementing an NHibernate or CosmosDB provider). --- ## API Design & Positioning -FlexQuery.NET exposes `IQueryable` extension methods as the primary public API surface. +FlexQuery.NET exposes `IQueryable` extension methods as the primary public API surface for Entity Framework Core. 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. - --- ## Overview Table -| Method | Filter | Sort | Page | Project | Validate | Async | Returns | +| Method | Filter | Sort | Page | Expand | Project | Validate | Returns | | :--- | :---: | :---: | :---: | :---: | :---: | :---: | :--- | | `ApplyFilter` | βœ… | ❌ | ❌ | ❌ | ❌ | ❌ | `IQueryable` | | `ApplySort` | ❌ | βœ… | ❌ | ❌ | ❌ | ❌ | `IQueryable` | | `ApplyPaging` | ❌ | ❌ | βœ… | ❌ | ❌ | ❌ | `IQueryable` | -| `ApplySelect` | ❌ | ❌ | ❌ | βœ… | ❌ | ❌ | `IQueryable` | -| `ApplyFilteredIncludes` | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | `IQueryable` | -| `FlexQuery` | βœ… | βœ… | βœ… | βœ… | βœ… | ❌ | `QueryResult` | -| `FlexQueryAsync` | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… | `Task>` | +| `ApplyExpand` | ❌ | ❌ | ❌ | βœ… | ❌ | ❌ | `IQueryable` | +| `ApplySelect` | ❌ | ❌ | ❌ | ❌ | βœ… | ❌ | `IQueryable` | +| `FlexQueryAsync` | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… | `Task>` | --- @@ -46,9 +49,9 @@ The lower-level `QueryBuilder` APIs are considered **advanced/internal infrastru [HttpGet] public async Task GetUsers([FromQuery] FlexQueryParameters parameters) { - var result = await _context.Users.FlexQueryAsync(parameters, exec => + var result = await _context.Users.FlexQueryAsync(parameters, exec => { - exec.AllowedFields = new HashSet { "id", "name", "email", "status" }; + exec.AllowedFields = ["Id", "Name", "Email", "Status"]; exec.MaxFieldDepth = 2; }); @@ -58,32 +61,17 @@ public async Task GetUsers([FromQuery] FlexQueryParameters parame **What it does internally:** -``` -Parse(parameters) - β†’ ValidateOrThrow(execOptions) +```text +ToQueryOptions(parameters) + β†’ ValidateOrThrow(execOptions) β†’ ApplyFilter β†’ ApplySort - β†’ CountAsync (if IncludeCount = true) + β†’ CountAsync (if IncludeCount = true and not keyset) β†’ ApplyPaging - β†’ ApplyFilteredIncludes + β†’ ApplyExpand (previously FilteredIncludes) β†’ ApplySelect (if projection requested) β†’ ToListAsync - β†’ QueryResult -``` - -**Configuration:** - -```csharp -await query.FlexQueryAsync(parameters, exec => -{ - exec.AllowedFields = new HashSet { "id", "name", "email" }; - exec.BlockedFields = new HashSet { "passwordHash" }; - exec.FilterableFields = new HashSet { "name", "status" }; - exec.SortableFields = new HashSet { "name", "createdAt" }; - exec.SelectableFields = new HashSet { "id", "name", "email" }; - exec.MaxFieldDepth = 2; - exec.StrictFieldValidation = true; -}); + β†’ QueryResult ``` --- @@ -94,7 +82,7 @@ Use these when you need granular control over individual pipeline steps. ### ApplyFilter -Applies the `WHERE` predicate from `QueryOptions.Filter` to the query. +Applies the `WHERE` predicate from `QueryOptions.Filter` to the `IQueryable`. ```csharp var filtered = query.ApplyFilter(options); @@ -104,16 +92,9 @@ var filtered = query.ApplyFilter(options); - No-op if `options.Filter` is 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 -``` - +`GET /api/users?filter=Status:eq:active` ```sql --- Generated SQL SELECT * FROM Users WHERE Status = 'active' ``` @@ -128,15 +109,10 @@ var sorted = query.ApplySort(options); ``` - Supports multiple sort fields (uses `ThenBy` internally). -- Supports aggregate sorts (e.g., sort by `Orders.count()`). - No-op if `options.Sort` is empty. **Example:** - -``` -GET /api/users?sort=name:asc,createdAt:desc -``` - +`GET /api/users?sort=Name:asc,CreatedAt:desc` ```sql ORDER BY Name ASC, CreatedAt DESC ``` @@ -145,204 +121,140 @@ ORDER BY Name ASC, CreatedAt DESC ### ApplyPaging -Applies `SKIP` / `TAKE` from `QueryOptions.Paging`. +Applies `SKIP` / `TAKE` (Offset pagination) or a `WHERE Cursor > X` (Keyset pagination) from `QueryOptions.Paging`. ```csharp var paged = query.ApplyPaging(options); ``` -- Automatically adds a default `ORDER BY Id` if the query is unordered and `Skip > 0` (prevents EF Core errors). -- No-op if `options.Paging.Disabled = true`. - -**Example:** - -``` -GET /api/users?page=2&pageSize=10 -``` - -```sql -ORDER BY Id OFFSET 10 ROWS FETCH NEXT 10 ROWS ONLY -``` - ---- - -### ApplySelect - -Applies dynamic projection. Returns `IQueryable`. - -```csharp -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 `GroupByBuilder` when `GroupBy` or `Aggregates` are set. -- Returns `query.Cast()` if no projection is requested. - -**Example:** - -``` -GET /api/users?select=id,name,email -``` - -```json -[ - { "id": 1, "name": "Alice", "email": "alice@example.com" } -] -``` +- Automatically adds a default `ORDER BY Id` if the query is unordered and `Skip > 0` (prevents EF Core errors in SQL Server). --- -### ApplyFilteredIncludes +### ApplyExpand (Formerly Includes) Applies the **Include pipeline** β€” EF Core `Include`/`ThenInclude` with optional inline filters. ```csharp -var withIncludes = query.ApplyFilteredIncludes(options); +var withIncludes = query.ApplyExpand(options); ``` -- **Independent** from the WHERE pipeline β€” does not affect root result count. - Must be called **before** `ToListAsync`. -- No-op if `options.FilteredIncludes` is null or empty. +- No-op if `options.Expand` is null or empty. **Example:** - -``` -GET /api/users?include=Orders(status:eq:shipped) -``` - +`GET /api/users?include=Orders(Status:eq:shipped)` ```csharp -// Translates to: +// Translates internally to: query.Include(u => u.Orders.Where(o => o.Status == "shipped")) ``` - --- -## Async Execution +### ApplySelect -All database trips should use the EF Core async extensions. +Applies dynamic projection. Returns `IQueryable`. ```csharp -// Count before paging -var total = await filteredQuery.CountAsync(cancellationToken); - -// Execute after paging + projection -var data = await projectedQuery.ToListAsync(cancellationToken); +var projected = query.ApplySelect(options); +var data = await projected.ToListAsync(); ``` -`FlexQueryAsync` handles all of this for you internally. +- Uses expression trees β€” no reflection at runtime. +- Handles Nested, Flat, and FlatMixed modes. +- Returns `query.Cast()` if no projection is requested. --- ## ⚠️ Critical Warning: Double Filtering > [!CAUTION] -> The most common mistake in FlexQuery.NET is applying filters twice. +> The most common mistake in FlexQuery.NET manual pipeline orchestration is applying filters twice. **WRONG β€” This filters twice:** ```csharp // ❌ DO NOT DO THIS -var options = QueryOptionsParser.Parse(parameters); +var options = parameters.ToQueryOptions(); -// Step 1: ApplyValidatedQueryOptions applies filter internally var query = _context.Users.AsQueryable(); -var query = query.ApplyValidatedQueryOptions(options); -// Step 2: ToProjectedQueryResultAsync ALSO applies filter internally +// 1st Filter: Applied here manually +query = query.ApplyFilter(options); + +// 2nd Filter: FlexQueryAsync re-applies the options! // The WHERE clause is duplicated in SQL! -var result = await query.ToProjectedQueryResultAsync(options); +var result = await query.FlexQueryAsync(options); ``` -**CORRECT β€” Use FlexQueryAsync:** +**CORRECT β€” Use the Unified Pipeline:** ```csharp // βœ… CORRECT: Everything in one call, filter applied once -var result = await _context.Users.FlexQueryAsync(parameters, exec => +var result = await _context.Users.FlexQueryAsync(parameters, exec => { - exec.AllowedFields = new HashSet { "id", "name", "email" }; + exec.AllowedFields = ["Id", "Name", "Email"]; }); ``` -**CORRECT β€” Manual pipeline, filter applied once:** - -```csharp -// βœ… CORRECT: Manual pipeline β€” each step called exactly once -var options = QueryOptionsParser.Parse(parameters); -options.ValidateOrThrow(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)); -``` - --- ## Complete Manual Pipeline Example -For when you need full control β€” e.g., injecting custom tenant filter between steps: +For when you need full control β€” e.g., injecting custom tenant filter between steps and logging the SQL: ```csharp [HttpGet] -public async Task GetUsers([FromQuery] FlexQueryParameters parameters, CancellationToken ct) +public async Task GetUsersManual([FromQuery] FlexQueryParameters parameters, CancellationToken ct) { // 1. Parse - var options = QueryOptionsParser.Parse(parameters); + var options = parameters.ToQueryOptions(); - // 2. Validate - var execOptions = new QueryExecutionOptions + // 2. Validate against Server Policy + var execOptions = new EfCoreQueryOptions { - AllowedFields = new HashSet { "id", "name", "email", "status", "createdAt" }, + AllowedFields = ["Id", "Name", "Email", "Status", "CreatedAt"], MaxFieldDepth = 2 }; - options.ValidateOrThrow(execOptions); + options.ValidateOrThrow(execOptions); - // 3. Start query + // 3. Start query with strict Tenancy limits var query = _context.Users - .Where(u => u.TenantId == CurrentTenantId) // custom pre-filter + .Where(u => u.TenantId == CurrentTenantId) .AsQueryable(); - // 4. Apply FlexQuery filter + // 4. Apply FlexQuery filter and sort query = query.ApplyFilter(options); query = query.ApplySort(options); - // 5. Count BEFORE paging + // 5. Manual intervention: Count BEFORE paging var total = await query.CountAsync(ct); - // 6. Page + includes + // 6. Page + Includes query = query.ApplyPaging(options); - query = query.ApplyFilteredIncludes(options); + query = query.ApplyExpand(options); - // 7. Project + execute + // 7. Project + Execute var data = await query.ApplySelect(options).ToListAsync(ct); - // 8. Return + // 8. Return standardized envelope return Ok(options.BuildQueryResult(data, total)); } ``` --- -## Deprecated Methods (v1 β†’ v2) +## Deprecated Methods -The following methods are deprecated in v2 and will be removed in v3. +The following methods were heavily used in v1/v2 but are removed or completely deprecated in v4: -| Deprecated | Replacement | +| Deprecated Method | Replacement | | :--- | :--- | | `ToQueryResultAsync` | `FlexQueryAsync` | | `ToProjectedQueryResultAsync` | `FlexQueryAsync` | -| `ApplyValidatedQueryOptions` | Manual pipeline + `ValidateOrThrow` | -| `QueryOptionsParser.Parse(QueryRequest)` | `QueryOptionsParser.Parse(FlexQueryParameters)` | +| `ApplyValidatedQueryOptions` | `FlexQueryAsync` or Manual pipeline | +| `QueryOptionsParser.Parse` | `parameters.ToQueryOptions()` | +| `ApplyFilteredIncludes` | `ApplyExpand` | > [!WARNING] -> Deprecated methods are marked with `[Obsolete]` and hidden from IntelliSense. They will be removed in v3. +> Deprecated methods have been formally removed from the `v4.0.0` distribution to streamline the API. diff --git a/docs/guide/execution.md b/docs/guide/execution.md deleted file mode 100644 index 310ac2c..0000000 --- a/docs/guide/execution.md +++ /dev/null @@ -1,348 +0,0 @@ -# Execution Pipeline - -The execution pipeline is the heart of FlexQuery.NET. Understanding which method to call β€” and why β€” is critical for correctness, security, and performance. - ---- - -## API Design & Positioning - -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. - - ---- - -## Overview Table - -| Method | Filter | Sort | Page | Project | Validate | Async | Returns | -| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :--- | -| `ApplyFilter` | βœ… | ❌ | ❌ | ❌ | ❌ | ❌ | `IQueryable` | -| `ApplySort` | ❌ | βœ… | ❌ | ❌ | ❌ | ❌ | `IQueryable` | -| `ApplyPaging` | ❌ | ❌ | βœ… | ❌ | ❌ | ❌ | `IQueryable` | -| `ApplySelect` | ❌ | ❌ | ❌ | βœ… | ❌ | ❌ | `IQueryable` | -| `ApplyFilteredIncludes` | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | `IQueryable` | -| `FlexQuery` | βœ… | βœ… | βœ… | βœ… | βœ… | ❌ | `QueryResult` | -| `FlexQueryAsync` | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… | `Task>` | - ---- - -## High-Level: FlexQueryAsync ⭐ Recommended - -`FlexQueryAsync` is the **unified pipeline method**. It parses, validates, and executes in a single call. - -**When to use:** Any standard public API endpoint. - -```csharp -[HttpGet] -public async Task GetUsers([FromQuery] FlexQueryParameters parameters) -{ - var result = await _context.Users.FlexQueryAsync(parameters, exec => - { - exec.AllowedFields = new HashSet { "id", "name", "email", "status" }; - exec.MaxFieldDepth = 2; - }); - - return Ok(result); -} -``` - -**What it does internally:** - -``` -Parse(parameters) - β†’ ValidateOrThrow(execOptions) - β†’ ApplyFilter - β†’ ApplySort - β†’ CountAsync (if IncludeCount = true) - β†’ ApplyPaging - β†’ ApplyFilteredIncludes - β†’ ApplySelect (if projection requested) - β†’ ToListAsync - β†’ QueryResult -``` - -**Configuration:** - -```csharp -await query.FlexQueryAsync(parameters, exec => -{ - exec.AllowedFields = new HashSet { "id", "name", "email" }; - exec.BlockedFields = new HashSet { "passwordHash" }; - exec.FilterableFields = new HashSet { "name", "status" }; - exec.SortableFields = new HashSet { "name", "createdAt" }; - exec.SelectableFields = new HashSet { "id", "name", "email" }; - exec.MaxFieldDepth = 2; - exec.StrictFieldValidation = true; -}); -``` - ---- - -## Low-Level Methods - -Use these when you need granular control over individual pipeline steps. - -### ApplyFilter - -Applies the `WHERE` predicate from `QueryOptions.Filter` to the query. - -```csharp -var filtered = query.ApplyFilter(options); -``` - -- Returns `IQueryable` β€” no database trip yet. -- No-op if `options.Filter` is 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 -``` - -```sql --- Generated SQL -SELECT * FROM Users WHERE Status = 'active' -``` - ---- - -### ApplySort - -Applies `ORDER BY` from `QueryOptions.Sort`. - -```csharp -var sorted = query.ApplySort(options); -``` - -- Supports multiple sort fields (uses `ThenBy` internally). -- Supports aggregate sorts (e.g., sort by `Orders.count()`). -- No-op if `options.Sort` is empty. - -**Example:** - -``` -GET /api/users?sort=name:asc,createdAt:desc -``` - -```sql -ORDER BY Name ASC, CreatedAt DESC -``` - ---- - -### ApplyPaging - -Applies `SKIP` / `TAKE` from `QueryOptions.Paging`. - -```csharp -var paged = query.ApplyPaging(options); -``` - -- Automatically adds a default `ORDER BY Id` if the query is unordered and `Skip > 0` (prevents EF Core errors). -- No-op if `options.Paging.Disabled = true`. - -**Example:** - -``` -GET /api/users?page=2&pageSize=10 -``` - -```sql -ORDER BY Id OFFSET 10 ROWS FETCH NEXT 10 ROWS ONLY -``` - ---- - -### ApplySelect - -Applies dynamic projection. Returns `IQueryable`. - -```csharp -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 `GroupByBuilder` when `GroupBy` or `Aggregates` are set. -- Returns `query.Cast()` if no projection is requested. - -**Example:** - -``` -GET /api/users?select=id,name,email -``` - -```json -[ - { "id": 1, "name": "Alice", "email": "alice@example.com" } -] -``` - ---- - -### ApplyFilteredIncludes - -Applies the **Include pipeline** β€” EF Core `Include`/`ThenInclude` with optional inline filters. - -```csharp -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.FilteredIncludes` is null or empty. - -**Example:** - -``` -GET /api/users?include=Orders(status:eq:shipped) -``` - -```csharp -// Translates to: -query.Include(u => u.Orders.Where(o => o.Status == "shipped")) -``` - - ---- - -## Async Execution - -All database trips should use the EF Core async extensions. - -```csharp -// 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. - ---- - -## ⚠️ Critical Warning: Double Filtering - -> [!CAUTION] -> The most common mistake in FlexQuery.NET is applying filters twice. - -**WRONG β€” This filters twice:** - -```csharp -// ❌ 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:** - -```csharp -// βœ… CORRECT: Everything in one call, filter applied once -var result = await _context.Users.FlexQueryAsync(parameters, exec => -{ - exec.AllowedFields = new HashSet { "id", "name", "email" }; -}); -``` - -**CORRECT β€” Manual pipeline, filter applied once:** - -```csharp -// βœ… CORRECT: Manual pipeline β€” each step called exactly once -var options = QueryOptionsParser.Parse(parameters); -options.ValidateOrThrow(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)); -``` - ---- - -## Complete Manual Pipeline Example - -For when you need full control β€” e.g., injecting custom tenant filter between steps: - -```csharp -[HttpGet] -public async Task GetUsers([FromQuery] FlexQueryParameters parameters, CancellationToken ct) -{ - // 1. Parse - var options = QueryOptionsParser.Parse(parameters); - - // 2. Validate - var execOptions = new QueryExecutionOptions - { - AllowedFields = new HashSet { "id", "name", "email", "status", "createdAt" }, - MaxFieldDepth = 2 - }; - options.ValidateOrThrow(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)); -} -``` - ---- - -## Deprecated Methods (v1 β†’ v2) - -The following methods are deprecated in v2 and will be removed in v3. - -| Deprecated | Replacement | -| :--- | :--- | -| `ToQueryResultAsync` | `FlexQueryAsync` | -| `ToProjectedQueryResultAsync` | `FlexQueryAsync` | -| `ApplyValidatedQueryOptions` | Manual pipeline + `ValidateOrThrow` | -| `QueryOptionsParser.Parse(QueryRequest)` | `QueryOptionsParser.Parse(FlexQueryParameters)` | - -> [!WARNING] -> Deprecated methods are marked with `[Obsolete]` and hidden from IntelliSense. They will be removed in v3. diff --git a/docs/guide/extension-methods.md b/docs/guide/extension-methods.md index 63b22d9..3a57de5 100644 --- a/docs/guide/extension-methods.md +++ b/docs/guide/extension-methods.md @@ -1,49 +1,41 @@ # Extension Methods -FlexQuery.NET is built entirely around `IQueryable` extension methods. This ensures it plays nicely with Entity Framework Core (or any other LINQ provider) without forcing you to inherit from base controllers or rewrite your data access layer. +## Overview -This page documents the core extension methods and when to use each one. +FlexQuery.NET is built entirely around `IQueryable` extension methods. This ensures it integrates seamlessly with Entity Framework Core (or any LINQ provider) without forcing you to inherit from base controllers or rewrite your data access layer. -## 1. `FlexQueryAsync` (Recommended) +## Why this feature exists -This is the all-in-one unified pipeline method. It handles parsing, validation, filtering, sorting, paging, includes, and projection in a single secure call. +Extension methods preserve the idiomatic .NET style. Your controller logic stays clean and declarative, and the query composition remains fully compatible with any pre-existing `IQueryable` chain you have already constructed (e.g., `_context.Products.Where(p => p.TenantId == tenantId).FlexQueryAsync(...)`). + +--- + +## `FlexQueryAsync` ⭐ Recommended + +The all-in-one unified pipeline method. Handles parsing, validation, filtering, sorting, paging, includes, and projection in a single secure call. -**Example:** ```csharp [HttpGet] public async Task Get([FromQuery] FlexQueryParameters parameters) { - // Unified pipeline execution - var result = await _context.Users.FlexQueryAsync(parameters, exec => + var result = await _context.Users.FlexQueryAsync(parameters, exec => { - exec.AllowedFields = new HashSet { "id", "name", "email" }; + exec.AllowedFields = ["Id", "Name", "Email"]; + exec.MaxFieldDepth = 2; }); return Ok(result); } ``` -## 2. `Apply` (Low-Level All-in-One) - -Applies Filter, Sort, and Paging in sequence. It returns the modified `IQueryable`. It does **not** apply projection or validation. - -**Example:** -```csharp -var options = QueryOptionsParser.Parse(parameters); - -// Returns IQueryable (Filtered, Sorted, and Paged) -var query = _context.Users.AsQueryable(); -var query = query.Apply(options); - -var data = await query.ToListAsync(); -``` +--- -## 3. Atomic Pipeline Methods +## Atomic Pipeline Methods -Use these when you need to apply only specific parts of the FlexQuery logic. +Use these when you need to apply only specific parts of the FlexQuery logic in a custom orchestration. ### `.ApplyFilter(options)` -Applies the `WHERE` clause. +Applies the `WHERE` clause from the parsed AST. ```csharp query = query.ApplyFilter(options); ``` @@ -55,37 +47,38 @@ query = query.ApplySort(options); ``` ### `.ApplyPaging(options)` -Applies `SKIP` and `TAKE`. +Applies `SKIP` / `TAKE` (offset) or a keyset cursor `WHERE`. ```csharp query = query.ApplyPaging(options); ``` -### `.ApplySelect(options)` -Applies the dynamic projection (`select`). Returns `IQueryable`. +### `.ApplyExpand(options)` +Applies EF Core filtered includes (e.g., `Include(x => x.Orders.Where(...))`). ```csharp -var projected = query.ApplySelect(options); +query = query.ApplyExpand(options); ``` -### `.ApplyFilteredIncludes(options)` -Applies EF Core filtered includes (e.g., `Include(x => x.Orders.Where(...))`). +### `.ApplySelect(options)` +Applies dynamic projection. Returns `IQueryable`. ```csharp -query = query.ApplyFilteredIncludes(options); +var projected = query.ApplySelect(options); +var data = await projected.ToListAsync(); ``` --- -## 4. Security & Validation +## Security & Validation -If you are not using `FlexQueryAsync`, you should manually validate the options before executing them. +If you are using the manual pipeline instead of `FlexQueryAsync`, you **must** manually validate the options before executing them. -### `.ValidateOrThrow(execOptions)` -Throws a `QueryValidationException` if validation fails. +### `.ValidateOrThrow(execOptions)` +Throws a `QueryValidationException` if any field access violation, depth violation, or operator mismatch is found. ```csharp options.ValidateOrThrow(execOptions); ``` -### `.ValidateSafe(execOptions)` -Returns a `ValidationResult`. Non-throwing. +### `.ValidateSafe(execOptions)` +Returns a `ValidationResult`. Non-throwing β€” use this when you prefer structured error returns over exceptions. ```csharp var result = options.ValidateSafe(execOptions); if (!result.IsValid) @@ -93,3 +86,21 @@ if (!result.IsValid) return BadRequest(result.Errors); } ``` + +--- + +## `BuildQueryResult` + +After a manual pipeline execution, use this helper to construct the standardized `QueryResult` envelope. + +```csharp +var total = await filteredQuery.CountAsync(); +var data = await pagedQuery.ApplySelect(options).ToListAsync(); + +return Ok(options.BuildQueryResult(data, total)); +``` + +## Related Topics + +- [Execution Pipeline](/guide/execution-pipeline) +- [Security Governance](/guide/security-governance) diff --git a/docs/guide/filtering.md b/docs/guide/filtering.md index 25101a8..56a291e 100644 --- a/docs/guide/filtering.md +++ b/docs/guide/filtering.md @@ -44,10 +44,10 @@ GET /api/users?filter=age:gte:18 GET /api/users?filter=name:contains:alice ``` -**Compound (AND by default):** +**Compound (AND β€” URL-encode `&` as `%26`):** -``` -GET /api/users?filter=status:eq:active,age:gte:18 +```http +GET /api/users?filter=status:eq:active%26age:gte:18 ``` ### JQL Format (SQL-like) @@ -133,7 +133,7 @@ GET /api/users?filter[0].field=status&filter[0].operator=eq&filter[0].value=acti ### Manual Filter Application ```csharp -var options = QueryOptionsParser.Parse(parameters); +var options = parameters.ToQueryOptions(); var query = _context.Users.AsQueryable(); var filtered = query.ApplyFilter(options); var users = await filtered.ToListAsync(); @@ -201,14 +201,20 @@ GET /api/users?filter=status:eq:active&page=1&pageSize=3 **Response:** ```json { + "totalCount": 48, + "resultCount": 48, + "page": 1, + "pageSize": 3, + "totalPages": 16, + "hasNextPage": true, + "hasPreviousPage": false, + "aggregates": null, "data": [ { "id": 1, "name": "Alice Chen", "status": "active" }, { "id": 2, "name": "Bob Smith", "status": "active" }, { "id": 5, "name": "Carol White", "status": "active" } ], - "totalCount": 48, - "page": 1, - "pageSize": 3 + "nextCursorToken": null } ``` diff --git a/docs/guide/flattening.md b/docs/guide/flattening.md index 40dd8a5..c2e0b2d 100644 --- a/docs/guide/flattening.md +++ b/docs/guide/flattening.md @@ -1,7 +1,13 @@ # Flattening +## Overview + FlexQuery.NET supports three projection modes. The **Flat** and **FlatMixed** modes reshape nested objects into flat key-value structures β€” useful for grid views, CSV exports, and analytics tools. +## Why this feature exists + +Different consumers need different data shapes. A mobile application prefers clean, nested JSON objects. An older Kendo UI grid might expect a flat row dictionary where all fields are at the top level. A CSV export pipeline needs a completely flat structure. The `mode` parameter lets you serve all these consumers from a single endpoint without writing format-specific controllers. + --- ## What It Does @@ -113,7 +119,7 @@ The `mode` query parameter is automatically parsed: GET /api/users?select=id,name&mode=flat ``` -Valid values: `nested`, `flat`, `flat-mixed` +Valid values: `Nested`, `Flat`, `FlatMixed` (case-insensitive when parsing from URL). --- diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 253cde6..fdd4f56 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -1,70 +1,73 @@ # Getting Started -This guide will get you from zero to a working, secure API endpoint in under 10 minutes. +## Overview ---- - -## Installation +This guide provides a comprehensive walkthrough for installing and configuring FlexQuery.NET. It takes you from an empty ASP.NET Core project to a fully secured, dynamic API endpoint in under 10 minutes. -Install the packages that match your stack: +## Why this feature exists -```bash -# Core library (filtering, sorting, paging, projection, validation) -dotnet add package FlexQuery.NET +Setting up dynamic querying often requires piecing together multiple libraries, writing custom MVC binders, or overriding default Entity Framework behaviors. FlexQuery.NET is designed to be plug-and-play. The built-in integration packages abstract away the complexity of model binding and dependency injection, so you can focus on writing your security policies. -# EF Core async execution (FlexQueryAsync, ApplyFilteredIncludes) -dotnet add package FlexQuery.NET.EntityFrameworkCore +## When to use -# ASP.NET Core integration ([FieldAccess] attribute, FieldAccessFilter) -dotnet add package FlexQuery.NET.AspNetCore -``` +- Read this guide when you are setting up FlexQuery.NET in a new project. +- Use the **Manual Pipeline** section when you need fine-grained control over exactly when the query is executed (e.g., if you need to run secondary database checks midway through the execution pipeline). --- -## Basic Setup (ASP.NET Core + EF Core) +## Installation -### Step 1: Install Packages +FlexQuery.NET is modular. Install only the packages that match your stack: ```bash -# Core library (filtering, sorting, paging, projection, validation) +# Core library (parsers, AST, validation, FlexQueryParameters) dotnet add package FlexQuery.NET -# EF Core async execution (FlexQueryAsync, ApplyFilteredIncludes) +# EF Core async execution provider dotnet add package FlexQuery.NET.EntityFrameworkCore -# ASP.NET Core integration ([FieldAccess] attribute, FieldAccessFilter) +# ASP.NET Core integration ([FieldAccess] attribute, global exceptions) dotnet add package FlexQuery.NET.AspNetCore ``` -### Step 2: Configure Services +--- -In `Program.cs`: +## Basic Setup (ASP.NET Core + EF Core) + +### Step 1: Configure Services + +In `Program.cs`, you must register the core engine and your execution provider. ```csharp -using FlexQuery.NET.AspNetCore.Extensions; +using FlexQuery.NET.DependencyInjection; +using FlexQuery.NET.EntityFrameworkCore.DependencyInjection; +using FlexQuery.NET.AspNetCore.DependencyInjection; +using Microsoft.AspNetCore.Builder; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; var builder = WebApplication.CreateBuilder(args); // Register EF Core DbContext builder.Services.AddDbContext(opt => - opt.UseSqlServer( - builder.Configuration.GetConnectionString("Default") - )); + opt.UseSqlServer(builder.Configuration.GetConnectionString("Default"))); -// Configure FlexQuery.NET globally (recommended) +// 1. Register FlexQuery Core globally builder.Services.AddFlexQuery(options => { options.MaxPageSize = 1000; options.DefaultPageSize = 50; options.CaseInsensitive = true; options.IncludeTotalCount = true; - options.StrictFieldValidation = true; + options.StrictFieldValidation = true; // Security: Throws on unauthorized access options.MaxFieldDepth = 5; - options.UseNoTracking = true; }); -// Register MVC + optional FlexQuery security integration +// 2. Register the EF Core Provider +builder.Services.AddFlexQueryEntityFrameworkCore(); + +// 3. Register MVC and optional declarative Security ([FieldAccess]) builder.Services .AddControllers() .AddFlexQuerySecurity(); @@ -72,41 +75,20 @@ builder.Services var app = builder.Build(); app.MapControllers(); - app.Run(); ``` ---- - -## What does `AddFlexQuerySecurity()` do? - -This optional integration automatically registers: +### What does `AddFlexQuerySecurity()` do? -- `FieldAccessFilter` -- attribute-based field-level security -- MVC filter pipeline integration - -This enables features such as: +This optional integration automatically registers the `FieldAccessFilter` into the ASP.NET Core MVC pipeline. This enables you to use declarative security attributes directly on your controllers: ```csharp [FieldAccess(AllowedFields = new[] { "Id", "Name", "Email" })] +[HttpGet] +public async Task GetUsers() { ... } ``` -on controllers and actions. - ---- - -## When do I need `AddFlexQuerySecurity()`? - -You only need it if you use: - -- `[FieldAccess]` -- automatic MVC field-level security -- global `FieldAccessFilter` behavior - -If you only use inline configuration with `FlexQueryAsync(parameters, exec => ...)`, you can skip this registration. - -### 2. Your Entity +### Step 2: Define Your Entity ```csharp public class User @@ -123,13 +105,17 @@ public class User --- -## Your First Endpoint +## Your First Endpoint (Complete Runnable Example) -This is the recommended production pattern using `FlexQueryAsync`: +This is the recommended production pattern using `FlexQueryAsync`, which automatically handles parsing, validation, and execution in a single line. ```csharp using FlexQuery.NET.EntityFrameworkCore; using FlexQuery.NET.Models; +using FlexQuery.NET.Exceptions; +using Microsoft.AspNetCore.Mvc; +using System.Threading.Tasks; +using System.Collections.Generic; [ApiController] [Route("api/[controller]")] @@ -142,40 +128,57 @@ public class UsersController : ControllerBase [HttpGet] public async Task GetUsers([FromQuery] FlexQueryParameters parameters) { - var result = await _context.Users.FlexQueryAsync(parameters, exec => + try { - // Declare which fields clients are allowed to use - exec.AllowedFields = new HashSet + var result = await _context.Users.FlexQueryAsync(parameters, exec => { - "id", "name", "email", "status", "age", "createdAt" - }; - - // Limit nesting depth (prevents deep path traversal) - exec.MaxFieldDepth = 2; - }); - - return Ok(result); + // Security: Declare which fields clients are allowed to view/filter/sort + exec.AllowedFields = new HashSet + { + "Id", "Name", "Email", "Status", "Age", "CreatedAt" + }; + + // Block highly sensitive fields absolutely + exec.BlockedFields = new HashSet { "PasswordHash" }; + + // Limit nesting depth (prevents infinite traversal via includes) + exec.MaxFieldDepth = 2; + }); + + return Ok(result); + } + catch (QueryValidationException ex) + { + // Always return 400 Bad Request if the client violates the AllowedFields policy + return BadRequest(new { errors = ex.ValidationResult.Errors }); + } } } ``` ### Sample Request -``` -GET /api/users?filter=status:eq:active&sort=name:asc&page=1&pageSize=10&select=id,name,email +```http +GET /api/users?filter=Status:eq:active&sort=Name:asc&page=1&pageSize=10&select=Id,Name,Email ``` ### Sample Response ```json { + "totalCount": 48, + "resultCount": 48, + "page": 1, + "pageSize": 10, + "totalPages": 5, + "hasNextPage": true, + "hasPreviousPage": false, + "aggregates": null, "data": [ { "id": 1, "name": "Alice Chen", "email": "alice@example.com" }, { "id": 2, "name": "Bob Smith", "email": "bob@example.com" } ], - "totalCount": 48, - "page": 1, - "pageSize": 10 + "nextCursorToken": null } ``` @@ -183,43 +186,44 @@ GET /api/users?filter=status:eq:active&sort=name:asc&page=1&pageSize=10&select=i ## Understanding FlexQueryParameters -`FlexQueryParameters` is the public-facing DTO. Bind it directly from the query string. +`FlexQueryParameters` is the public-facing DTO. Bind it directly from the query string in GET requests. ```csharp -public sealed class FlexQueryParameters +public class FlexQueryParameters { - public string? Query { get; set; } // JQL: query=status = "active" + public string? Query { get; set; } // JQL: query=status="active" public string? Filter { get; set; } // DSL: filter=status:eq:active public string? Sort { get; set; } // sort=name:asc,createdAt:desc public string? Select { get; set; } // select=id,name,email - public string? Includes { get; set; } // includes=Orders,Profile + public string? Include { get; set; } // include=Orders,Profile public string? GroupBy { get; set; } // groupBy=status - public string? Having { get; set; } // having=count():gt:5 + public string? Having { get; set; } // having=count:gt:5 public int? Page { get; set; } // page=1 public int? PageSize { get; set; } // pageSize=20 public bool? IncludeCount { get; set; } // includeCount=true public bool? Distinct { get; set; } // distinct=true - public string? Mode { get; set; } // mode=flat + public string? Mode { get; set; } // mode=Flat + public bool? UseKeysetPagination { get; set; } + public string? Cursor { get; set; } } ``` **Why `FlexQueryParameters` and not `Request.Query` directly?** -- It is an **OpenAPI-compatible DTO** β€” Swagger generates proper documentation. -- It is **type-safe** β€” all values are strings, ints, or bools; no injection risk. -- It is **easier to test** β€” create instances directly in unit tests. -- It is **explicit** β€” all supported parameters are visible in the class definition. +- It is an **OpenAPI-compatible DTO** β€” Swagger generates proper documentation automatically. +- It is **type-safe** β€” all values are bound strongly; mitigating generic string injection risks. +- It is **testable** β€” you can instantiate it directly in unit tests without mocking an `HttpContext`. --- -## How FlexQueryAsync Works +## How FlexQueryAsync Works Under the Hood -`FlexQueryAsync` is the unified high-level method. It does everything in one call: +`FlexQueryAsync` is the unified high-level method. It internally manages the entire query lifecycle: -``` +```text FlexQueryParameters β†’ Parse (QueryOptionsParser) - β†’ Validate (field access, operators, depth) + β†’ Validate (field access, operators, depth against Server Policy) β†’ ApplyFilter β†’ ApplySort β†’ CountAsync (for totalCount) @@ -230,45 +234,42 @@ FlexQueryParameters β†’ QueryResult ``` -```csharp -var result = await _context.Users.FlexQueryAsync(parameters, exec => -{ - exec.AllowedFields = new HashSet { "id", "name", "email" }; -}); -``` - --- ## Manual Pipeline (Mid-Level Control) -When you need custom logic between steps, use the manual pipeline: +When you need custom logic between steps (e.g., executing business rules before pagination), you can manually orchestrate the pipeline instead of using `FlexQueryAsync`: ```csharp -[HttpGet] -public async Task GetUsers([FromQuery] FlexQueryParameters parameters) +using FlexQuery.NET; +using FlexQuery.NET.EntityFrameworkCore; + +[HttpGet("manual")] +public async Task GetUsersManual([FromQuery] FlexQueryParameters parameters) { // 1. Parse - var options = QueryOptionsParser.Parse(parameters); + var options = parameters.ToQueryOptions(); // 2. Validate var execOptions = new QueryExecutionOptions { - AllowedFields = new HashSet { "id", "name", "email", "status" } + AllowedFields = new HashSet { "Id", "Name", "Email", "Status" } }; options.ValidateOrThrow(execOptions); - // 3. Apply pipeline + // 3. Start composing the IQueryable var query = _context.Users.AsQueryable(); query = query.ApplyFilter(options); query = query.ApplySort(options); - // 4. Count before paging + // 4. Manual intervention: Count the filtered rows *before* paging cuts them off var total = await query.CountAsync(); + // 5. Apply pagination and includes query = query.ApplyPaging(options); - query = query.ApplyFilteredIncludes(options); + query = query.ApplyExpand(options); - // 5. Project and execute + // 6. Project and execute var data = await query.ApplySelect(options).ToListAsync(); return Ok(options.BuildQueryResult(data, total)); @@ -284,18 +285,18 @@ public async Task GetUsers([FromQuery] FlexQueryParameters parame ```csharp var execOptions = new QueryExecutionOptions { - AllowedFields = new HashSet { "name", "email", "status" }, - BlockedFields = new HashSet { "passwordHash", "internalNotes" }, - FilterableFields = new HashSet { "name", "status" }, - SortableFields = new HashSet { "name", "createdAt" }, - SelectableFields = new HashSet { "id", "name", "email" }, + AllowedFields = new HashSet { "Name", "Email", "Status" }, + BlockedFields = new HashSet { "PasswordHash", "InternalNotes" }, + FilterableFields = new HashSet { "Name", "Status" }, + SortableFields = new HashSet { "Name", "CreatedAt" }, + SelectableFields = new HashSet { "Id", "Name", "Email" }, MaxFieldDepth = 2 }; options.ValidateOrThrow(execOptions); ``` -To return structured errors instead of throwing: +If you prefer to avoid exceptions for control flow, you can use `ValidateSafe` to return structured errors: ```csharp var result = options.ValidateSafe(execOptions); @@ -306,55 +307,29 @@ if (!result.IsValid) } ``` - --- ## Performance & Optimization -For high-traffic APIs, you can enable **Expression Caching** to skip the overhead of building LINQ trees for repeated query shapes. +For extremely high-traffic APIs using the EF Core provider, you can enable **Expression Caching**. This instructs FlexQuery to cache the compiled LINQ Expression Trees for repeated query shapes, bypassing the CPU overhead of reflection and tree generation on subsequent identical requests. -In `Program.cs`: +In `Program.cs` (Global configuration): ```csharp using FlexQuery.NET.Caching; -// Enable global caching +// Enable global expression caching FlexQueryCacheSettings.EnableCache = true; FlexQueryCacheSettings.MaxCacheSize = 5000; ``` ---- - -## Recommended Production Setup +## Best Practices -```csharp -[HttpGet] -public async Task GetUsers([FromQuery] FlexQueryParameters parameters) -{ - try - { - var result = await _context.Users.FlexQueryAsync(parameters, exec => - { - exec.AllowedFields = new HashSet { "id", "name", "email", "status", "age", "createdAt" }; - exec.BlockedFields = new HashSet { "passwordHash", "twoFactorSecret" }; - exec.SortableFields = new HashSet { "name", "createdAt", "age" }; - exec.SelectableFields = new HashSet { "id", "name", "email" }; - exec.MaxFieldDepth = 2; - }); - - return Ok(result); - } - catch (QueryValidationException ex) - { - return BadRequest(new { errors = ex.ValidationResult.Errors }); - } -} -``` +- **Global Error Handling:** Do not wrap every controller method in a `try/catch`. Instead, register an ASP.NET Core Exception Middleware to globally catch `QueryValidationException` and map it to a `400 Bad Request`. +- **Always Validate:** Even if you use the manual pipeline, never skip the `ValidateOrThrow` step. -This gives you: +## Related Topics -- βœ… Parsing from query string -- βœ… Field-level security validation -- βœ… Safe filter/sort/page execution -- βœ… Optional projection -- βœ… Structured error response on bad input +- [Filtering and Sorting](/guide/filtering) +- [Pagination](/guide/paging) +- [Security Governance](/guide/security-governance) diff --git a/docs/guide/grouping.md b/docs/guide/grouping.md index 37fc00b..2495af6 100644 --- a/docs/guide/grouping.md +++ b/docs/guide/grouping.md @@ -1,6 +1,12 @@ # Grouping & Aggregates -FlexQuery.NET supports server-side GROUP BY with aggregate projections (sum, count, avg) and HAVING conditions β€” all driven by query parameters. +## Overview + +FlexQuery.NET supports server-side GROUP BY with aggregate projections (count, sum, avg) and HAVING conditions β€” all driven by query parameters with no custom backend code required. + +## Why this feature exists + +Reporting and analytics endpoints historically require custom SQL procedures or hardcoded LINQ statements for every aggregate view ("total orders by status", "revenue by region", etc.). By exposing grouping and aggregates as a first-class query feature, FlexQuery.NET allows a single generic endpoint to power an entire report dashboard, where the frontend decides what groupings to apply dynamically. --- @@ -43,12 +49,15 @@ GET /api/users?select=status,count()&groupBy=status **Response:** ```json { + "totalCount": 3, + "resultCount": 3, + "page": 1, + "pageSize": 20, "data": [ { "status": "active", "allCount": 42 }, { "status": "inactive", "allCount": 6 }, { "status": "pending", "allCount": 12 } - ], - "totalCount": 3 + ] } ``` diff --git a/docs/guide/how-it-works.md b/docs/guide/how-it-works.md index ed8b1b4..eff61a4 100644 --- a/docs/guide/how-it-works.md +++ b/docs/guide/how-it-works.md @@ -1,7 +1,13 @@ # How FlexQuery.NET Works +## Overview + FlexQuery.NET sits between your HTTP controller and your `IQueryable`. It translates client-provided query parameters into validated, server-safe LINQ expression trees that EF Core compiles to SQL. +## Why this feature exists + +Understanding the internal pipeline helps you debug unexpected results, audit generated SQL, and make intelligent decisions about where to insert custom logic (e.g., multi-tenant pre-filters). This page describes the exact transformation chain. + --- ## The Pipeline at a Glance @@ -28,8 +34,8 @@ HTTP Query String β”œβ”€β”€ ApplyFilter() β†’ WHERE clause (expression tree) β”œβ”€β”€ ApplySort() β†’ ORDER BY (expression tree) β”œβ”€β”€ CountAsync() β†’ SELECT COUNT(*) (optional) - β”œβ”€β”€ ApplyPaging() β†’ SKIP / TAKE - β”œβ”€β”€ ApplyFilteredIncludes() β†’ Include pipeline (independent) + β”œβ”€β”€ ApplyPaging() β†’ SKIP / TAKE or Keyset Cursor + β”œβ”€β”€ ApplyExpand() β†’ Include pipeline (independent) └── ApplySelect() β†’ Dynamic projection β”‚ β–Ό @@ -170,7 +176,7 @@ query = ApplyPaging(query, options); Handles related collection loading with optional filters. ```csharp -query = query.ApplyFilteredIncludes(options); +query = query.ApplyExpand(options); ``` The include pipeline is **completely independent** from the WHERE pipeline. Filtering a collection inside `include=Orders(status:eq:shipped)` does **not** affect which root entities are returned. diff --git a/docs/guide/include-filtering.md b/docs/guide/include-filtering.md index ddfd790..7cefd7a 100644 --- a/docs/guide/include-filtering.md +++ b/docs/guide/include-filtering.md @@ -1,7 +1,13 @@ # Include Filtering +## Overview + Filtered Includes let you load related entity collections with inline `WHERE` conditions β€” without affecting the root query's results or count. +## Why this feature exists + +When a user views an order details page, they need to see the root Order with all its related Items. But when viewing a list of Users in a data grid, they might only want to see each user's *active* subscriptions, not all of them. The `include` parameter with inline filters solves this β€” you can load related data with discriminating conditions without polluting the root entity's row count. + --- ## What It Does @@ -74,7 +80,7 @@ GET /api/users?include=Orders(status:eq:active).Items ### Applying Filtered Includes ```csharp -var options = QueryOptionsParser.Parse(parameters); +var options = parameters.ToQueryOptions(); var query = _context.Users.AsQueryable(); query = query.ApplyFilter(options); @@ -85,7 +91,7 @@ var total = await query.CountAsync(); query = query.ApplyPaging(options); // Apply include pipeline AFTER paging, BEFORE materialization -query = query.ApplyFilteredIncludes(options); +query = query.ApplyExpand(options); var data = await query.ToListAsync(); ``` @@ -113,6 +119,14 @@ GET /api/users?include=Orders(status:eq:shipped)&select=id,name&page=1&pageSize= **Response:** ```json { + "totalCount": 48, + "resultCount": 48, + "page": 1, + "pageSize": 2, + "totalPages": 24, + "hasNextPage": true, + "hasPreviousPage": false, + "aggregates": null, "data": [ { "id": 1, @@ -128,9 +142,7 @@ GET /api/users?include=Orders(status:eq:shipped)&select=id,name&page=1&pageSize= "orders": [] } ], - "totalCount": 48, - "page": 1, - "pageSize": 2 + "nextCursorToken": null } ``` @@ -183,7 +195,7 @@ query = query.ApplyFilteredIncludes(options); // too late ```csharp // CORRECT β€” apply includes before materialization -query = query.ApplyFilteredIncludes(options); +query = query.ApplyExpand(options); var data = await query.ToListAsync(); ``` diff --git a/docs/guide/include.md b/docs/guide/include.md index 90f00c7..775828d 100644 --- a/docs/guide/include.md +++ b/docs/guide/include.md @@ -1,64 +1,93 @@ # Include (Eager Loading) -FlexQuery.NET allows clients to request related entities to be returned alongside the parent entity, significantly reducing the N+1 problem and eliminating the need for multiple API requests. +## Overview + +FlexQuery.NET allows clients to request related entities to be returned alongside the parent entity, significantly reducing the N+1 query problem and eliminating the need for multiple API round trips. Behind the scenes, this leverages EF Core's `.Include()` and `.ThenInclude()`. +## Why this feature exists + +In REST APIs, it is common to either over-fetch (load everything in a massive graph) or under-fetch (make multiple roundtrips to load related data). FlexQuery's `include` parameter enables precise, client-controlled eager loading with optional inline filters, giving frontends the data they need in a single request. + +## When to use + +- Use `include` when the frontend needs related data alongside the parent entity in a single request. +- Use `include=Orders(Status:eq:Active)` when you want to conditionally load a subset of a collection. +- Use `MaxFieldDepth` to prevent clients from traversing unbounded object graphs. + +--- + ## Basic Include -To load a related collection or navigation property, use the `select` parameter to list both primary fields and the nested navigation fields. Or, depending on your configuration, use the explicit `includes` mapping on `QueryRequest`. +To load a related collection or navigation property, use the `include` parameter: -**Example Request:** -Include the `Orders` collection. ```http -GET /api/customers?select=Id,Name,Orders.Id,Orders.Total +GET /api/customers?include=Orders +``` + +To project specific fields from an included collection, use dot notation in the `select` parameter: + +```http +GET /api/customers?select=Id,Name,Orders.Id,Orders.Total&include=Orders ``` **Backend (C#):** ```csharp [HttpGet] -public async Task Get([FromQuery] QueryRequest request) +public async Task Get([FromQuery] FlexQueryParameters parameters) { - var options = QueryOptionsParser.Parse(request); - - // Applying select automatically figures out which Includes are needed! - var users = await _context.Customers - .ApplyValidatedQueryOptions(options) - .ToListAsync(); - - return Ok(users); + var result = await _context.Customers.FlexQueryAsync(parameters, options => + { + options.AllowedFields = ["Id", "Name", "Orders.Id", "Orders.Total"]; + options.MaxFieldDepth = 2; + }); + + return Ok(result); } ``` +--- + ## Deep Includes -You can navigate through multiple layers of relationships using dot notation. +You can navigate through multiple layers of relationships using dot notation. The `MaxFieldDepth` setting controls how deeply clients can traverse. -**Example Request:** -Fetch Customers, their Orders, and the Order Items. ```http -GET /api/customers?select=Id,Orders.Id,Orders.Items.ProductId +GET /api/customers?select=Id,Orders.Id,Orders.Items.ProductId&include=Orders.Items ``` -## Scoped Filtering (Join with Filter) +--- + +## Scoped Filtering (Filtered Include) -A unique feature of FlexQuery.NET is the ability to apply filters directly to related collections within the `include` or `select` parameters. This allows you to fetch a parent entity and only a subset of its related children (e.g., only "Active" orders). +A powerful feature of FlexQuery.NET is the ability to apply filters directly to included collections. This allows you to fetch a parent entity and only a matching subset of its related children. -**Example Request:** -Fetch Customers and only their "Completed" orders. ```http -GET /api/customers?include=Orders(Status = 'Completed') +GET /api/customers?include=Orders(Status:eq:Completed) ``` -**How it works:** -When a filter is applied to a collection navigation, FlexQuery.NET automatically generates the necessary SQL **JOIN** or **EXISTS** clause. If you are using projection (`select`), it builds a filtered projection on the navigation property, ensuring that only the matching child records are materialized. +When a filter is applied to a collection navigation, FlexQuery.NET generates the EF Core `.Include(x => x.Orders.Where(o => o.Status == "Completed"))` expression. This means only matching child records are materialized. +> [!IMPORTANT] +> Filtered includes do **not** affect which root entities are returned. All customers are returned; only the included orders are filtered. See [Include Filtering](/guide/include-filtering) for a detailed comparison. + +--- ## Security Considerations -To prevent clients from including massive data graphs (which can cause denial of service), you should strongly consider using `MaxFieldDepth` to limit how deeply clients can nest their queries. +To prevent clients from including massive data graphs (which can cause denial of service), always configure `MaxFieldDepth` to limit how deeply clients can nest their queries. ```csharp -// Limit nesting to 2 levels (e.g., Customer -> Orders -> Items) -options.MaxFieldDepth = 2; +var result = await _context.Customers.FlexQueryAsync(parameters, options => +{ + // Limit nesting to 2 levels (e.g., Customer β†’ Orders β†’ Items) + options.MaxFieldDepth = 2; + options.AllowedFields = ["Id", "Name", "Orders.Status"]; +}); ``` + +## Related Topics + +- [Include Filtering](/guide/include-filtering) β€” Detailed guide on filtered includes vs root filter semantics +- [Security Governance](/guide/security-governance) β€” MaxFieldDepth and field access validation diff --git a/docs/guide/introduction.md b/docs/guide/introduction.md index efa95bb..c2aec74 100644 --- a/docs/guide/introduction.md +++ b/docs/guide/introduction.md @@ -1,32 +1,111 @@ # Introduction -FlexQuery.NET is a lightweight, high-performance .NET library that enables **dynamic filtering, sorting, paging, grouping, and projection** over any `IQueryable` (EF Core or any LINQ provider). +## Overview -## What it does +FlexQuery.NET is a lightweight, high-performance .NET library that enables **dynamic filtering, sorting, paging, grouping, and projection** over any `IQueryable` (Entity Framework Core) or `IDbConnection` (Dapper). -Instead of writing dozens of custom API endpoints or complex `switch` statements to handle optional filters, FlexQuery.NET translates incoming HTTP query parameters directly into secure, EF Core-translatable expression trees. +Instead of writing dozens of custom API endpoints or complex `switch` statements to handle optional filters, FlexQuery.NET translates incoming HTTP query parameters directly into secure, EF Core-translatable expression trees or optimized, parameterized raw SQL strings. -**Request:** +## Why this feature exists + +Building a data-rich UI (like an enterprise data grid or dashboard) requires extreme flexibility from the backend. Developers typically encounter a cross-roads where they either: +1. **Hardcode hundreds of filter combinations**: Writing a massive `/api/orders?status=x&minTotal=y&date=z` endpoint with manual `if (request.HasStatus)` branches, which becomes unmaintainable. +2. **Adopt heavy frameworks**: Using [GraphQL](/guide/comparison) or [OData](/guide/comparison), which introduce steep learning curves, entirely new protocol specs, and significant operational overhead. + +**FlexQuery.NET provides the sweet spot:** It offers the deep flexibility of GraphQL and OData, but it stays within the standard REST paradigm without requiring you to radically alter your backend architecture. + +## When to use + +- You are building internal dashboards, admin panels, or data grids (e.g., AG Grid, Syncfusion, Kendo UI, Vue/React tables) where users need to dynamically slice and dice data. +- You need a highly flexible API but don't want the overhead or complexity of [OData](/guide/comparison) or [GraphQL](/guide/comparison). +- You require strong, declarative security (Field-Level Security) to ensure clients cannot probe restricted data (e.g. `?filter=passwordHash:eq:xxx`). +- You are using Entity Framework Core or Dapper as your data access layer. + +## When not to use + +- **Rigid CQRS Systems:** If you strictly adhere to a Command Query Responsibility Segregation architecture where every read has a highly specialized, immutable DTO that clients cannot alter. +- **NoSQL Databases:** FlexQuery.NET translates to LINQ `IQueryable` and ADO.NET dialects. It is fundamentally designed for relational database querying and will not directly translate to Cosmos DB or MongoDB pipelines. + +--- + +## Complete Runnable Example + +FlexQuery.NET bridges the HTTP request directly to your database with an explicit, secure configuration layer. + +**Backend (C#) using Minimal APIs:** +```csharp +app.MapGet("/api/orders", async ( + [AsParameters] FlexQueryParameters parameters, + AppDbContext dbContext) => +{ + // Executes the query directly via EF Core + var result = await dbContext.Orders.FlexQueryAsync(parameters, options => + { + // Security boundary: Only allow access to these specific fields + options.AllowedFields = ["Id", "Status", "Total", "CreatedAt"]; + + // Performance boundary: Hard cap the number of items returned + options.MaxPageSize = 200; + options.DefaultPageSize = 50; + }); + + return Results.Ok(result); +}); +``` + +## HTTP Request and JSON Response + +**The Client Request:** ```http -GET /api/orders?filter=Status:eq:Paid&sort=Total:desc&select=Id,Total +GET /api/orders + ?filter=Status:eq:Paid&Total:gt:500 + &sort=Total:desc + &select=Id,Total + &page=1 ``` -**Backend (C#):** -```csharp -var options = QueryOptionsParser.Parse(Request.Query); -var orders = await _dbContext.Orders.ApplyValidatedQueryOptions(options).ToListAsync(); +**The Server Response (`QueryResult`):** +FlexQuery standardizes the payload into an envelope containing the data and pagination metadata. + +```json +{ + "totalCount": 1500, + "resultCount": 1500, + "page": 1, + "pageSize": 50, + "totalPages": 30, + "hasNextPage": true, + "hasPreviousPage": false, + "aggregates": null, + "data": [ + { "id": 1042, "total": 1250.00 }, + { "id": 1089, "total": 850.50 } + ], + "nextCursorToken": null +} ``` -## Why it exists +--- + +## Performance Notes + +FlexQuery.NET executes on the server; it does **not** evaluate expressions in memory. For EF Core, it dynamically constructs an `IQueryable` expression tree so that Entity Framework generates the optimized SQL query. For Dapper, it generates highly optimized, parameterized raw SQL strings specifically mapped to your target dialect (e.g., PostgreSQL, SQL Server). + +Furthermore, FlexQuery supports **Keyset Pagination**, allowing you to bypass expensive `OFFSET/FETCH` scanning for massive datasets. + +## Security Notes + +Client input is treated as inherently hostile. The `QueryOptions` parser acts only as an AST generator. Before the database is ever touched, the validation phase compares the AST against your server-side `AllowedFields` (the whitelist). If a client requests a field or relationship they are not permitted to see, FlexQuery halts immediately with a `QueryValidationException` (in strict mode). -Building a data-rich UI (like a data grid or dashboard) requires extreme flexibility from the backend. Developers typically either: -1. Hardcode hundreds of filter combinations (unmaintainable). -2. Adopt heavy frameworks like [GraphQL](/guide/comparison) or [OData](/guide/comparison) (overkill, steep learning curve). +## Best Practices -**FlexQuery.NET provides the sweet spot:** The flexibility of GraphQL, with the simplicity of standard REST APIs. +- **Always configure `AllowedFields`**: Never execute an unfiltered query options payload against your database. Always explicitly declare what fields the client is allowed to manipulate. +- **Always set `MaxPageSize`**: Prevent malicious clients from attempting to pull down millions of rows by setting a hard limit on page requests. +- **Use `CaseInsensitive = true`**: When configuring FlexQuery globally, enabling case insensitivity provides a friendlier developer experience for frontend teams passing URL parameters. -## When to use it +## Related Topics -- You are building internal dashboards, admin panels, or data grids (e.g., AG Grid, Syncfusion, Vue/React tables). -- You need a flexible API but don't want the overhead of [OData](/guide/comparison) or [GraphQL](/guide/comparison). -- You need strong security (Field-Level Security) to ensure clients can't probe restricted data. +- [Installation and Setup](/get-started/installation) +- [Filtering and Sorting Syntax](/guide/filtering) +- [Security and Governance](/guide/security) +- [FlexQuery vs GraphQL/OData](/guide/comparison) diff --git a/docs/guide/paging.md b/docs/guide/paging.md index 729823f..b301f29 100644 --- a/docs/guide/paging.md +++ b/docs/guide/paging.md @@ -1,6 +1,12 @@ # Paging -FlexQuery.NET provides server-safe pagination with automatic defaults and consistent response envelopes. +## Overview + +FlexQuery.NET provides server-safe pagination with automatic defaults, configurable page size limits, and consistent response envelopes. + +## Why this feature exists + +Returning an unbounded result set from a public API is both a security risk and a performance trap. FlexQuery enforces server-side pagination contracts (default page size, maximum page size) while providing the client with rich pagination metadata so it can render paging controls accurately. --- @@ -104,7 +110,7 @@ GET /api/users?page=1&pageSize=20&includeCount=false ### Using ApplyPaging Directly ```csharp -var options = QueryOptionsParser.Parse(parameters); +var options = parameters.ToQueryOptions(); var query = _context.Users.AsQueryable(); var paged = query.ApplyPaging(options); var data = await paged.ToListAsync(); @@ -113,7 +119,7 @@ var data = await paged.ToListAsync(); ### Enforcing a Maximum Page Size ```csharp -var options = QueryOptionsParser.Parse(parameters); +var options = parameters.ToQueryOptions(); // Cap pageSize server-side before applying if (options.Paging.PageSize > 100) @@ -126,7 +132,7 @@ var paged = query.ApplyPaging(options); ### Getting Total Count ```csharp -var options = QueryOptionsParser.Parse(parameters); +var options = parameters.ToQueryOptions(); var query = _context.Users.AsQueryable(); var filtered = query.ApplyFilter(options); var filtered2 = filtered.ApplySort(options); @@ -163,6 +169,14 @@ GET /api/users?page=3&pageSize=5 **Response:** ```json { + "totalCount": 48, + "resultCount": 48, + "page": 3, + "pageSize": 5, + "totalPages": 10, + "hasNextPage": true, + "hasPreviousPage": true, + "aggregates": null, "data": [ { "id": 11, "name": "Lena Park" }, { "id": 12, "name": "Mike Rowe" }, @@ -170,10 +184,7 @@ GET /api/users?page=3&pageSize=5 { "id": 14, "name": "Oscar Drew" }, { "id": 15, "name": "Petra Voss" } ], - "totalCount": 48, - "resultCount": 48, - "page": 3, - "pageSize": 5 + "nextCursorToken": null } ``` @@ -213,4 +224,4 @@ if (options.Paging.PageSize > 200) options.Paging.PageSize = 200; - Disable `IncludeCount` (`?includeCount=false`) on high-frequency endpoints where total count is not needed. - Grouped or shaped queries may also calculate `ResultCount` from the shaped query before paging. - Always sort before paging. Without a deterministic `ORDER BY`, results are undefined. -- Use cursor-based pagination for very large datasets β€” FlexQuery.NET handles standard offset paging. +- Use **Keyset Pagination** (`?useKeysetPagination=true&cursor=TOKEN`) for very large datasets to avoid `OFFSET` scanning penalties. FlexQuery.NET generates `WHERE Id > cursor` style queries for keyset navigation. diff --git a/docs/guide/performance-tuning.md b/docs/guide/performance-tuning.md index 412deeb..bafbef3 100644 --- a/docs/guide/performance-tuning.md +++ b/docs/guide/performance-tuning.md @@ -161,7 +161,7 @@ FlexQueryCacheSettings.CacheCompiledLambdas = true; You can override the global setting on individual requests via `QueryOptions`: ```csharp -var options = QueryOptionsParser.Parse(request); +var options = parameters.ToQueryOptions(); // Force cache for this specific heavy query, even if global cache is off options.EnableCache = true; diff --git a/docs/guide/projection.md b/docs/guide/projection.md index 9635269..94fb896 100644 --- a/docs/guide/projection.md +++ b/docs/guide/projection.md @@ -1,7 +1,13 @@ # Projection +## Overview + Projection lets clients control which fields are returned in the response. Instead of always returning the full entity, clients request only what they need. +## Why this feature exists + +Without projection, every API request transfers the entire row for every entity returned. For entities with 30+ columns (including large blobs, internal audit fields, or sensitive data), this is wasteful. Projection enables a single generic endpoint to serve different UI views efficiently without requiring custom DTOs or multiple endpoints per page. + --- ## What It Does @@ -118,7 +124,7 @@ Scalar navigation is flattened; collections remain nested: ### Applying Projection ```csharp -var options = QueryOptionsParser.Parse(parameters); +var options = parameters.ToQueryOptions(); var query = _context.Users.AsQueryable(); query = query.ApplyFilter(options); @@ -169,14 +175,20 @@ GET /api/users?select=id,name,email&page=1&pageSize=3 **Response:** ```json { + "totalCount": 48, + "resultCount": 48, + "page": 1, + "pageSize": 3, + "totalPages": 16, + "hasNextPage": true, + "hasPreviousPage": false, + "aggregates": null, "data": [ { "id": 1, "name": "Alice Chen", "email": "alice@example.com" }, { "id": 2, "name": "Bob Smith", "email": "bob@example.com" }, { "id": 3, "name": "Carol White", "email": "carol@example.com" } ], - "totalCount": 48, - "page": 1, - "pageSize": 3 + "nextCursorToken": null } ``` diff --git a/docs/guide/sorting.md b/docs/guide/sorting.md index 498e95a..fbb6fa3 100644 --- a/docs/guide/sorting.md +++ b/docs/guide/sorting.md @@ -1,7 +1,13 @@ # Sorting +## Overview + FlexQuery.NET supports multi-field sorting with simple syntax, including aggregate-based sorting on related collections. +## Why this feature exists + +Frontend data grids require dynamic sort control β€” clicking a column header should immediately reorder results. Without a library like FlexQuery, each sortable column requires explicit `switch/case` handling on the backend. FlexQuery translates any valid sort expression into an optimized, multi-level `ORDER BY` chain. + --- ## What It Does @@ -77,7 +83,7 @@ GET /api/users?sort=orders.min(amount):asc ### Using QueryBuilder.ApplySort Directly ```csharp -var options = QueryOptionsParser.Parse(parameters); +var options = parameters.ToQueryOptions(); var query = _context.Users.AsQueryable(); var sorted = query.ApplySort(options); var data = await sorted.ToListAsync(); @@ -125,14 +131,20 @@ GET /api/users?sort=createdAt:desc&page=1&pageSize=3 **Response:** ```json { - "data": [ - { "id": 10, "name": "Zara Khan", "createdAt": "2025-11-20T09:00:00Z" }, - { "id": 9, "name": "Yuki Tanaka","createdAt": "2025-10-15T14:30:00Z" }, - { "id": 8, "name": "Xan Torres", "createdAt": "2025-09-01T08:00:00Z" } - ], "totalCount": 48, + "resultCount": 48, "page": 1, - "pageSize": 3 + "pageSize": 3, + "totalPages": 16, + "hasNextPage": true, + "hasPreviousPage": false, + "aggregates": null, + "data": [ + { "id": 10, "name": "Zara Khan", "createdAt": "2025-11-20T09:00:00Z" }, + { "id": 9, "name": "Yuki Tanaka", "createdAt": "2025-10-15T14:30:00Z" }, + { "id": 8, "name": "Xan Torres", "createdAt": "2025-09-01T08:00:00Z" } + ], + "nextCursorToken": null } ``` diff --git a/docs/guide/validation.md b/docs/guide/validation.md index 1fb94fa..2f79118 100644 --- a/docs/guide/validation.md +++ b/docs/guide/validation.md @@ -1,7 +1,13 @@ # Validation +## Overview + FlexQuery.NET validates every query before execution. The validation pipeline checks field paths, operators, access rules, and depth β€” and returns structured errors. +## Why this feature exists + +Client-provided query strings are inherently untrusted input. The validation layer ensures that no unauthorized field names, operators, or navigation path depths can reach the LINQ expression builder or the database. This is the security boundary between the public API and your database schema. + --- ## What the Validation Pipeline Checks diff --git a/docs/providers/ef-core.md b/docs/providers/ef-core.md index 5f64bcd..bef2397 100644 --- a/docs/providers/ef-core.md +++ b/docs/providers/ef-core.md @@ -70,7 +70,7 @@ IQueryable (your DbSet) β”‚ β–Ό ApplyPaging() β€” OFFSET/FETCH via Skip/Take β”‚ - β–Ό ApplyFilteredIncludes() β€” EF Core Include/ThenInclude chain + β–Ό ApplyExpand() β€” EF Core Include/ThenInclude chain β”‚ β”œβ”€β”€ HasProjection? ──Yes──► ApplySelect() β†’ ToListAsync() β†’ Projected results β”‚ @@ -100,11 +100,12 @@ query If you need to apply includes separately from the main query: ```csharp -var options = QueryOptionsParser.Parse(Request.Query); +var options = parameters.ToQueryOptions(); var result = await _context.Customers - .ApplyQueryOptions(options) // WHERE pipeline - .ApplyFilteredIncludes(options) // INCLUDE pipeline + .ApplyFilter(options) // WHERE pipeline + .ApplySort(options) // ORDER BY + .ApplyExpand(options) // INCLUDE pipeline .ToListAsync(); ``` @@ -133,12 +134,7 @@ For nested projections with includes: FlexQuery supports grand total aggregation via LINQ: ```csharp -var parameters = new FlexQueryParameters -{ - // No GroupBy β€” triggers grand total mode -}; - -var options = QueryOptionsParser.Parse(parameters); +var options = parameters.ToQueryOptions(); options.Aggregates = new List { new() { Function = "sum", Field = "Price", Alias = "priceSum" }, @@ -172,15 +168,15 @@ var result = await _context.Products.FlexQueryAsync(parameters, opts => Inspect the SQL that EF Core will generate without executing the query: ```csharp -var options = QueryOptionsParser.Parse(parameters); -var query = _context.Products.ApplyQueryOptions(options); -string sql = query.ToSqlPreview(); +var options = parameters.ToQueryOptions(); +var query = _context.Products.AsQueryable().ApplyFilter(options); +string sql = query.ToQueryString(); // EF Core built-in method // Returns the generated SQL or a diagnostic message Console.WriteLine(sql); ``` -**Note:** `ToSqlPreview()` requires the `IQueryable` to be backed by an EF Core provider. If the queryable is an in-memory collection, it returns `""`. +**Note:** `ToQueryString()` is an EF Core built-in method. It is available after any step in the `IQueryable` pipeline. ## Real-World Example: Multi-Tenant API From 369ab0c9914c1ce5104bf40c240fe9fd07827046 Mon Sep 17 00:00:00 2001 From: Peter John Casasola Date: Thu, 9 Jul 2026 14:43:19 +0800 Subject: [PATCH 2/7] refactor(dapper): remove manual Dialect configuration, use auto-detection from DbConnection Removed the Dialect property from DapperQueryOptions and FlexQueryDapperOptions. Deleted DapperQueryOptionsExtensions.cs (6 Use*() methods). Removed 3 AddFlexQueryDapper*() convenience methods and dialect singleton registration from ServiceCollectionExtensions. DapperQueryExecutor now always resolves dialect via SqlDialectResolver.Resolve(connection). SqlDialectResolver uses EndsWith with Ordinal comparison for accurate type matching, checking more specific providers (Npgsql, Sqlite, Oracle, MariaDb, MySql) before SqlConnection. Unknown connection types throw NotSupportedException instead of falling back to SqlServerDialect. --- .../Configuration/FlexQueryDapperOptions.cs | 70 ++--------------- .../ServiceCollectionExtensions.cs | 68 +--------------- .../Dialects/SqlDialectResolver.cs | 45 ++++++----- .../Execution/DapperQueryExecutor.cs | 2 +- .../DapperQueryOptionsExtensions.cs | 77 ------------------- .../Options/DapperQueryOptions.cs | 9 +-- 6 files changed, 38 insertions(+), 233 deletions(-) diff --git a/src/FlexQuery.NET.Dapper/Configuration/FlexQueryDapperOptions.cs b/src/FlexQuery.NET.Dapper/Configuration/FlexQueryDapperOptions.cs index af7c8fd..f8f0e96 100644 --- a/src/FlexQuery.NET.Dapper/Configuration/FlexQueryDapperOptions.cs +++ b/src/FlexQuery.NET.Dapper/Configuration/FlexQueryDapperOptions.cs @@ -1,4 +1,3 @@ -using FlexQuery.NET.Dapper.Dialects; using FlexQuery.NET.Options; namespace FlexQuery.NET.Dapper.Configuration; @@ -9,8 +8,11 @@ namespace FlexQuery.NET.Dapper.Configuration; /// /// /// is used with -/// AddFlexQueryDapper(...) to configure the default SQL dialect and -/// define the application's entity mapping model. +/// AddFlexQueryDapper(...) to define the application's entity mapping model. +/// +/// +/// The SQL dialect is auto-detected from the supplied +/// at runtime β€” no manual dialect configuration is required. /// /// /// This type is intended for startup configuration only and should not be used @@ -24,8 +26,6 @@ internal FlexQueryDapperOptions() Model = new ModelBuilder(); } - internal ISqlDialect? Dialect { get; private set; } - /// /// Gets the model builder used to configure entity mappings. /// @@ -34,64 +34,4 @@ internal FlexQueryDapperOptions() /// mapping configurations before the model is built for runtime use. /// public ModelBuilder Model { get; } - - /// - /// Configures FlexQuery.NET to generate SQL Server-compatible queries by default. - /// - /// The current instance. - public FlexQueryDapperOptions UseSqlServer() - { - Dialect = new SqlServerDialect(); - return this; - } - - /// - /// Configures FlexQuery.NET to generate SQLite-compatible queries by default. - /// - /// The current instance. - public FlexQueryDapperOptions UseSqlite() - { - Dialect = new SqliteDialect(); - return this; - } - - /// - /// Configures FlexQuery.NET to generate PostgreSQL-compatible queries by default. - /// - /// The current instance. - public FlexQueryDapperOptions UsePostgreSql() - { - Dialect = new PostgreSqlDialect(); - return this; - } - - /// - /// Configures FlexQuery.NET to generate MySQL-compatible queries by default. - /// - /// The current instance. - public FlexQueryDapperOptions UseMySql() - { - Dialect = new MySqlDialect(); - return this; - } - - /// - /// Configures FlexQuery.NET to generate MariaDB-compatible queries by default. - /// - /// The current instance. - public FlexQueryDapperOptions UseMariaDb() - { - Dialect = new MariaDbDialect(); - return this; - } - - /// - /// Configures FlexQuery.NET to generate Oracle-compatible queries by default. - /// - /// The current instance. - public FlexQueryDapperOptions UseOracle() - { - Dialect = new OracleDialect(); - return this; - } } \ No newline at end of file diff --git a/src/FlexQuery.NET.Dapper/DependencyInjection/ServiceCollectionExtensions.cs b/src/FlexQuery.NET.Dapper/DependencyInjection/ServiceCollectionExtensions.cs index d03ad70..ff447df 100644 --- a/src/FlexQuery.NET.Dapper/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/FlexQuery.NET.Dapper/DependencyInjection/ServiceCollectionExtensions.cs @@ -5,16 +5,18 @@ namespace FlexQuery.NET.Dapper.DependencyInjection; /// /// Provides dependency injection registration methods for FlexQuery Dapper. +/// The SQL dialect is auto-detected from the supplied +/// at runtime β€” no manual dialect configuration is required. /// public static class ServiceCollectionExtensions { /// /// Registers the services required for FlexQuery Dapper and configures - /// the metadata model and SQL dialect. + /// the entity mapping model. /// /// The service collection. /// - /// A delegate used to configure FlexQuery Dapper. + /// A delegate used to configure FlexQuery Dapper entity mappings. /// /// The updated service collection. public static IServiceCollection AddFlexQueryDapper( @@ -27,68 +29,6 @@ public static IServiceCollection AddFlexQueryDapper( var model = configurer.Model.Build(); services.AddSingleton(model); - if (configurer.Dialect is not null) - { - services.AddSingleton(configurer.Dialect); - } - return services; } - - /// - /// Registers FlexQuery Dapper configured for SQL Server. - /// - /// The service collection. - /// - /// An optional delegate used to configure additional Dapper options. - /// - /// The updated service collection. - public static IServiceCollection AddFlexQueryDapperSqlServer( - this IServiceCollection services, - Action? configure = null) - { - return services.AddFlexQueryDapper(cfg => - { - cfg.UseSqlServer(); - configure?.Invoke(cfg); - }); - } - - /// - /// Registers FlexQuery Dapper configured for PostgreSQL. - /// - /// The service collection. - /// - /// An optional delegate used to configure additional Dapper options. - /// - /// The updated service collection. - public static IServiceCollection AddFlexQueryDapperPostgreSql( - this IServiceCollection services, - Action? configure = null) - { - return services.AddFlexQueryDapper(cfg => - { - cfg.UsePostgreSql(); - configure?.Invoke(cfg); - }); - } - - /// - /// Registers FlexQuery Dapper configured for SQLite. - /// - /// The service collection. - /// - /// An optional delegate used to configure additional Dapper options. - /// - /// The updated service collection. - public static IServiceCollection AddFlexQueryDapperSqlite( - this IServiceCollection services, - Action? configure = null) - { - return services.AddFlexQueryDapper(cfg => - { - cfg.UseSqlite(); - configure?.Invoke(cfg); - }); - } } diff --git a/src/FlexQuery.NET.Dapper/Dialects/SqlDialectResolver.cs b/src/FlexQuery.NET.Dapper/Dialects/SqlDialectResolver.cs index a21a868..2572d8c 100644 --- a/src/FlexQuery.NET.Dapper/Dialects/SqlDialectResolver.cs +++ b/src/FlexQuery.NET.Dapper/Dialects/SqlDialectResolver.cs @@ -3,36 +3,43 @@ namespace FlexQuery.NET.Dapper.Dialects; /// -/// Default implementation of ISqlDialectResolver that inspects the connection type name. +/// Resolves the appropriate SQL dialect from the supplied . +/// This is the single source of truth for SQL dialect selection. /// internal static class SqlDialectResolver { /// Resolves the appropriate SQL dialect for the given database connection. + /// The database connection to inspect. + /// The matching for the connection type. + /// Thrown when the connection type is not recognized. public static ISqlDialect Resolve(DbConnection connection) { var typeName = connection.GetType().Name; - if (typeName.Contains("NpgsqlConnection", StringComparison.OrdinalIgnoreCase)) + // Check more specific providers first to avoid SqlConnection substring matches. + if (typeName.EndsWith("NpgsqlConnection", StringComparison.Ordinal)) return new PostgreSqlDialect(); - - if (typeName.Contains("SqliteConnection", StringComparison.OrdinalIgnoreCase)) + + if (typeName.EndsWith("SqliteConnection", StringComparison.Ordinal)) return new SqliteDialect(); - - if (typeName.Contains("OracleConnection", StringComparison.OrdinalIgnoreCase)) + + if (typeName.EndsWith("OracleConnection", StringComparison.Ordinal)) return new OracleDialect(); - - // MariaDB Connector/NET uses MySqlConnection or sometimes MariaDbConnection depending on the library - if (typeName.Contains("MariaDbConnection", StringComparison.OrdinalIgnoreCase)) + + if (typeName.EndsWith("MariaDbConnection", StringComparison.Ordinal)) return new MariaDbDialect(); - - if (typeName.Contains("MySqlConnection", StringComparison.OrdinalIgnoreCase)) - { - // Optional: You could inspect connection.ConnectionString for "MariaDB" if needed, - // but returning MySqlDialect is safe as a baseline for MySqlConnection. - return new MySqlDialect(); - } - - // Fallback or explicit SqlConnection - return new SqlServerDialect(); + + if (typeName.EndsWith("MySqlConnection", StringComparison.Ordinal)) + return new MySqlDialect(); + + if (typeName.EndsWith("SqlConnection", StringComparison.Ordinal)) + return new SqlServerDialect(); + + throw new NotSupportedException( + $"The connection type '{typeName}' is not a supported database provider. " + + "Supported providers: SQL Server (SqlConnection), PostgreSQL (NpgsqlConnection), " + + "SQLite (SqliteConnection), MySQL (MySqlConnection), MariaDB (MariaDbConnection), " + + "Oracle (OracleConnection). " + + "To add support for a custom provider, extend SqlDialectResolver."); } } diff --git a/src/FlexQuery.NET.Dapper/Execution/DapperQueryExecutor.cs b/src/FlexQuery.NET.Dapper/Execution/DapperQueryExecutor.cs index 188e8a8..ab4395a 100644 --- a/src/FlexQuery.NET.Dapper/Execution/DapperQueryExecutor.cs +++ b/src/FlexQuery.NET.Dapper/Execution/DapperQueryExecutor.cs @@ -61,7 +61,7 @@ private static async Task> ExecuteAsync( await ConnectionHelper.EnsureOpenAsync(connection, ct); - var dialect = options.Dialect ?? SqlDialectResolver.Resolve(connection); + var dialect = SqlDialectResolver.Resolve(connection); var registry = options.Model?.Registry ?? new MappingRegistry(); // Re-stamped here (in addition to RunAsync, above) because it's read again diff --git a/src/FlexQuery.NET.Dapper/Extensions/DapperQueryOptionsExtensions.cs b/src/FlexQuery.NET.Dapper/Extensions/DapperQueryOptionsExtensions.cs index c45135d..e69de29 100644 --- a/src/FlexQuery.NET.Dapper/Extensions/DapperQueryOptionsExtensions.cs +++ b/src/FlexQuery.NET.Dapper/Extensions/DapperQueryOptionsExtensions.cs @@ -1,77 +0,0 @@ -using FlexQuery.NET.Dapper.Dialects; -using FlexQuery.NET.Dapper.Options; - -namespace FlexQuery.NET.Dapper; - -/// -/// Provides extension methods for configuring the SQL dialect used by -/// . -/// -public static class DapperQueryOptionsExtensions -{ - /// - /// Configures SQL Server as the SQL dialect. - /// - /// The Dapper query options. - /// The same instance. - public static DapperQueryOptions UseSqlServer(this DapperQueryOptions options) - { - options.Dialect = new SqlServerDialect(); - return options; - } - - /// - /// Configures PostgreSQL as the SQL dialect. - /// - /// The Dapper query options. - /// The same instance. - public static DapperQueryOptions UsePostgreSql(this DapperQueryOptions options) - { - options.Dialect = new PostgreSqlDialect(); - return options; - } - - /// - /// Configures SQLite as the SQL dialect. - /// - /// The Dapper query options. - /// The same instance. - public static DapperQueryOptions UseSqlite(this DapperQueryOptions options) - { - options.Dialect = new SqliteDialect(); - return options; - } - - /// - /// Configures MariaDB as the SQL dialect. - /// - /// The Dapper query options. - /// The same instance. - public static DapperQueryOptions UseMariaDb(this DapperQueryOptions options) - { - options.Dialect = new MariaDbDialect(); - return options; - } - - /// - /// Configures MySQL as the SQL dialect. - /// - /// The Dapper query options. - /// The same instance. - public static DapperQueryOptions UseMySql(this DapperQueryOptions options) - { - options.Dialect = new MySqlDialect(); - return options; - } - - /// - /// Configures Oracle Database as the SQL dialect. - /// - /// The Dapper query options. - /// The same instance. - public static DapperQueryOptions UseOracle(this DapperQueryOptions options) - { - options.Dialect = new OracleDialect(); - return options; - } -} \ No newline at end of file diff --git a/src/FlexQuery.NET.Dapper/Options/DapperQueryOptions.cs b/src/FlexQuery.NET.Dapper/Options/DapperQueryOptions.cs index 90ec92c..c716142 100644 --- a/src/FlexQuery.NET.Dapper/Options/DapperQueryOptions.cs +++ b/src/FlexQuery.NET.Dapper/Options/DapperQueryOptions.cs @@ -1,4 +1,3 @@ -using FlexQuery.NET.Dapper.Dialects; using FlexQuery.NET.Dapper.Metadata; using FlexQuery.NET.Options; @@ -6,6 +5,8 @@ namespace FlexQuery.NET.Dapper.Options; /// /// Represents Dapper-specific execution options for a FlexQuery request. +/// The SQL dialect is auto-detected from the supplied +/// at runtime β€” no manual dialect configuration is required. /// /// public sealed class DapperQueryOptions : BaseQueryOptions @@ -36,12 +37,6 @@ public void UseModel(FlexQueryModel model) Model = model ?? throw new ArgumentNullException(nameof(model)); } - /// - /// Gets or sets the SQL dialect used to translate FlexQuery expressions. - /// If not specified, the dialect is resolved automatically from the database connection. - /// - public ISqlDialect? Dialect { get; set; } - /// /// Gets or sets the database command timeout, in seconds. /// From 9471b4e90a1b507e2c41a8b3a7a69ef261140726 Mon Sep 17 00:00:00 2001 From: Peter John Casasola Date: Thu, 9 Jul 2026 14:43:27 +0800 Subject: [PATCH 3/7] test(dapper): remove Dialect property overrides from test fixtures and test classes Removed the protected abstract ISqlDialect Dialect property from DapperApiTestBase and its singleton DI registration. Removed Dialect property overrides from 6 test classes: UsersTests, SecurityValidationTests (2 classes), RelationshipTests, OrderAggregationTests, IncludeTests, FlatProjectionTests. Removed ISqlDialect field and constructor injection from DemoApi controllers (3 controllers). Removed Dialect = new SqliteDialect() assignment from ResultCountTests, GroupedQueryExecutionTests, and all 8 occurrences in SecurityGovernanceDapperIntegrationTests. --- .../Api/Dapper/DapperApiTestBase.cs | 4 ---- .../Api/Dapper/FlatProjectionTests.cs | 3 --- .../Api/Dapper/GroupedQueryExecutionTests.cs | 1 - .../Api/Dapper/IncludeTests.cs | 3 --- .../Api/Dapper/OrderAggregationTests.cs | 3 --- .../Api/Dapper/RelationshipTests.cs | 3 --- .../Api/Dapper/SecurityValidationTests.cs | 5 ----- .../FlexQuery.NET.Tests/Api/Dapper/UsersTests.cs | 3 --- .../SecurityGovernanceDapperIntegrationTests.cs | 8 -------- tests/FlexQuery.NET.Tests/Fixtures/DemoApi.cs | 16 +++------------- .../Tests/ResultCountTests.cs | 2 -- 11 files changed, 3 insertions(+), 48 deletions(-) diff --git a/tests/FlexQuery.NET.Tests/Api/Dapper/DapperApiTestBase.cs b/tests/FlexQuery.NET.Tests/Api/Dapper/DapperApiTestBase.cs index 370cd5a..e9eb48e 100644 --- a/tests/FlexQuery.NET.Tests/Api/Dapper/DapperApiTestBase.cs +++ b/tests/FlexQuery.NET.Tests/Api/Dapper/DapperApiTestBase.cs @@ -1,5 +1,4 @@ using System.Data; -using FlexQuery.NET.Dapper.Dialects; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Hosting; @@ -14,8 +13,6 @@ public abstract class DapperApiTestBase : IDisposable protected readonly HttpClient Client; protected readonly IDbConnection Connection; - protected abstract ISqlDialect Dialect { get; } - protected DapperApiTestBase() { // Setup SQLite in-memory connection and seed it @@ -30,7 +27,6 @@ protected DapperApiTestBase() webBuilder.UseStartup(); webBuilder.ConfigureTestServices(services => { - services.AddSingleton(Dialect); services.AddSingleton(Connection); }); }) diff --git a/tests/FlexQuery.NET.Tests/Api/Dapper/FlatProjectionTests.cs b/tests/FlexQuery.NET.Tests/Api/Dapper/FlatProjectionTests.cs index 4b61789..ef29463 100644 --- a/tests/FlexQuery.NET.Tests/Api/Dapper/FlatProjectionTests.cs +++ b/tests/FlexQuery.NET.Tests/Api/Dapper/FlatProjectionTests.cs @@ -1,4 +1,3 @@ -using FlexQuery.NET.Dapper.Dialects; using System.Net.Http.Json; using System.Text.Json; @@ -6,8 +5,6 @@ namespace FlexQuery.NET.Tests.Api.Dapper; public class FlatProjectionTests : DapperApiTestBase { - protected override ISqlDialect Dialect => new SqliteDialect(); - public FlatProjectionTests() { } [Fact] diff --git a/tests/FlexQuery.NET.Tests/Api/Dapper/GroupedQueryExecutionTests.cs b/tests/FlexQuery.NET.Tests/Api/Dapper/GroupedQueryExecutionTests.cs index f3d9215..2ab2a4a 100644 --- a/tests/FlexQuery.NET.Tests/Api/Dapper/GroupedQueryExecutionTests.cs +++ b/tests/FlexQuery.NET.Tests/Api/Dapper/GroupedQueryExecutionTests.cs @@ -174,7 +174,6 @@ private Task> ExecuteOrdersAsync(QueryOptions options) { var dapperOptions = new DapperQueryOptions { - Dialect = new SqliteDialect(), IncludeTotalCount = true }; ConfigureMappings(dapperOptions); diff --git a/tests/FlexQuery.NET.Tests/Api/Dapper/IncludeTests.cs b/tests/FlexQuery.NET.Tests/Api/Dapper/IncludeTests.cs index 88883fe..a2db1a2 100644 --- a/tests/FlexQuery.NET.Tests/Api/Dapper/IncludeTests.cs +++ b/tests/FlexQuery.NET.Tests/Api/Dapper/IncludeTests.cs @@ -1,4 +1,3 @@ -using FlexQuery.NET.Dapper.Dialects; using System.Net.Http.Json; using System.Text.Json; @@ -6,8 +5,6 @@ namespace FlexQuery.NET.Tests.Api.Dapper; public class IncludeTests : DapperApiTestBase { - protected override ISqlDialect Dialect => new SqliteDialect(); - public IncludeTests() { } [Fact] diff --git a/tests/FlexQuery.NET.Tests/Api/Dapper/OrderAggregationTests.cs b/tests/FlexQuery.NET.Tests/Api/Dapper/OrderAggregationTests.cs index f49584e..ef952b9 100644 --- a/tests/FlexQuery.NET.Tests/Api/Dapper/OrderAggregationTests.cs +++ b/tests/FlexQuery.NET.Tests/Api/Dapper/OrderAggregationTests.cs @@ -1,4 +1,3 @@ -using FlexQuery.NET.Dapper.Dialects; using System.Net.Http.Json; using System.Text.Json; @@ -6,8 +5,6 @@ namespace FlexQuery.NET.Tests.Api.Dapper; public class OrderAggregationTests : DapperApiTestBase { - protected override ISqlDialect Dialect => new SqliteDialect(); - public OrderAggregationTests() { } [Fact] diff --git a/tests/FlexQuery.NET.Tests/Api/Dapper/RelationshipTests.cs b/tests/FlexQuery.NET.Tests/Api/Dapper/RelationshipTests.cs index b3326f2..41f485c 100644 --- a/tests/FlexQuery.NET.Tests/Api/Dapper/RelationshipTests.cs +++ b/tests/FlexQuery.NET.Tests/Api/Dapper/RelationshipTests.cs @@ -1,4 +1,3 @@ -using FlexQuery.NET.Dapper.Dialects; using System.Net.Http.Json; using System.Text.Json; @@ -6,8 +5,6 @@ namespace FlexQuery.NET.Tests.Api.Dapper; public class RelationshipTests : DapperApiTestBase { - protected override ISqlDialect Dialect => new SqliteDialect(); - public RelationshipTests() { } [Fact] diff --git a/tests/FlexQuery.NET.Tests/Api/Dapper/SecurityValidationTests.cs b/tests/FlexQuery.NET.Tests/Api/Dapper/SecurityValidationTests.cs index 97b5fdf..e3a328f 100644 --- a/tests/FlexQuery.NET.Tests/Api/Dapper/SecurityValidationTests.cs +++ b/tests/FlexQuery.NET.Tests/Api/Dapper/SecurityValidationTests.cs @@ -1,4 +1,3 @@ -using FlexQuery.NET.Dapper.Dialects; using System.Net; using System.Net.Http.Json; using System.Text.Json; @@ -7,8 +6,6 @@ namespace FlexQuery.NET.Tests.Api.Dapper; public class SecurityTests : DapperApiTestBase { - protected override ISqlDialect Dialect => new SqliteDialect(); - public SecurityTests() { } [Fact] @@ -39,8 +36,6 @@ public async Task Should_Block_SQL_Injection_In_Sort() public class ValidationTests : DapperApiTestBase { - protected override ISqlDialect Dialect => new SqliteDialect(); - public ValidationTests() { } [Fact] diff --git a/tests/FlexQuery.NET.Tests/Api/Dapper/UsersTests.cs b/tests/FlexQuery.NET.Tests/Api/Dapper/UsersTests.cs index 11abf6d..22d141b 100644 --- a/tests/FlexQuery.NET.Tests/Api/Dapper/UsersTests.cs +++ b/tests/FlexQuery.NET.Tests/Api/Dapper/UsersTests.cs @@ -1,4 +1,3 @@ -using FlexQuery.NET.Dapper.Dialects; using System.Net.Http.Json; using System.Text.Json; @@ -6,8 +5,6 @@ namespace FlexQuery.NET.Tests.Api.Dapper; public class UsersTests : DapperApiTestBase { - protected override ISqlDialect Dialect => new SqliteDialect(); - public UsersTests() { } [Fact] diff --git a/tests/FlexQuery.NET.Tests/Dapper/Security/SecurityGovernanceDapperIntegrationTests.cs b/tests/FlexQuery.NET.Tests/Dapper/Security/SecurityGovernanceDapperIntegrationTests.cs index 9f80d46..b58ef50 100644 --- a/tests/FlexQuery.NET.Tests/Dapper/Security/SecurityGovernanceDapperIntegrationTests.cs +++ b/tests/FlexQuery.NET.Tests/Dapper/Security/SecurityGovernanceDapperIntegrationTests.cs @@ -432,7 +432,6 @@ public async Task Execute_DefaultProjection_WithAllowedFields() var options = NoPaging(new QueryOptions { IncludeCount = true }); var dapperOptions = new DapperQueryOptions { - Dialect = new SqliteDialect(), IncludeTotalCount = true, AllowedFields = new HashSet(StringComparer.OrdinalIgnoreCase) { "Id", "Name" } }; @@ -486,7 +485,6 @@ public async Task Execute_GroupedQuery_ReturnsAggregateAlias() }); var dapperOptions = new DapperQueryOptions { - Dialect = new SqliteDialect(), IncludeTotalCount = true, GroupableFields = new HashSet(StringComparer.OrdinalIgnoreCase) { "CustomerId" }, AggregatableFields = new HashSet(StringComparer.OrdinalIgnoreCase) { "Total" } @@ -522,7 +520,6 @@ public async Task Execute_GroupByGovernanceViolation_ShouldThrow() var dapperOptions = new DapperQueryOptions { - Dialect = new SqliteDialect(), IncludeTotalCount = true, GroupableFields = new HashSet(StringComparer.OrdinalIgnoreCase) { "Id" } }; @@ -546,7 +543,6 @@ public async Task Execute_AggregateGovernanceViolation_ShouldThrow() }); var dapperOptions = new DapperQueryOptions { - Dialect = new SqliteDialect(), IncludeTotalCount = true, AggregatableFields = new HashSet(StringComparer.OrdinalIgnoreCase) { "Id" } }; @@ -572,7 +568,6 @@ public async Task Execute_HavingGovernanceViolation_ShouldThrow() }); var dapperOptions = new DapperQueryOptions { - Dialect = new SqliteDialect(), IncludeTotalCount = true, AggregatableFields = new HashSet(StringComparer.OrdinalIgnoreCase) { "Id" } }; @@ -599,7 +594,6 @@ public async Task Execute_FilterGovernanceViolation_ShouldThrow() }); var dapperOptions = new DapperQueryOptions { - Dialect = new SqliteDialect(), IncludeTotalCount = true, FilterableFields = new HashSet(StringComparer.OrdinalIgnoreCase) { "Id" } }; @@ -624,7 +618,6 @@ public async Task Execute_RoleAllowedFields_ShouldRestrictProjection() var options = NoPaging(new QueryOptions { IncludeCount = true }); var dapperOptions = new DapperQueryOptions { - Dialect = new SqliteDialect(), IncludeTotalCount = true, CurrentRole = "admin", RoleAllowedFields = new Dictionary>(StringComparer.OrdinalIgnoreCase) @@ -669,7 +662,6 @@ public async Task Execute_DefaultSortField_ShouldOrderResults() var dapperOptions = new DapperQueryOptions { - Dialect = new SqliteDialect(), IncludeTotalCount = true, DefaultSortField = "Name", AllowedFields = new HashSet(StringComparer.OrdinalIgnoreCase) { "Id", "Name" } diff --git a/tests/FlexQuery.NET.Tests/Fixtures/DemoApi.cs b/tests/FlexQuery.NET.Tests/Fixtures/DemoApi.cs index d2ea0bc..2974f1d 100644 --- a/tests/FlexQuery.NET.Tests/Fixtures/DemoApi.cs +++ b/tests/FlexQuery.NET.Tests/Fixtures/DemoApi.cs @@ -1,6 +1,5 @@ using FlexQuery.NET.Dapper; using FlexQuery.NET.Dapper.Configuration; -using FlexQuery.NET.Dapper.Dialects; using FlexQuery.NET.Dapper.Metadata; using FlexQuery.NET.Exceptions; using FlexQuery.NET.Models; @@ -51,12 +50,10 @@ public class DiagnosticController : ControllerBase public class UsersController : ControllerBase { private readonly IDbConnection _connection; - private readonly ISqlDialect _dialect; - public UsersController(IDbConnection connection, ISqlDialect dialect) + public UsersController(IDbConnection connection) { _connection = connection; - _dialect = dialect; } [HttpGet("health")] @@ -70,7 +67,6 @@ public async Task Get([FromQuery] FlexQueryParameters parameters) var model = BuildModel(); var result = await ((System.Data.Common.DbConnection)_connection).FlexQueryAsync(parameters, opt => { - opt.Dialect = _dialect; opt.UseModel(model); }); return Ok(result); @@ -102,12 +98,10 @@ private static FlexQueryModel BuildModel() public class OrdersController : ControllerBase { private readonly IDbConnection _connection; - private readonly ISqlDialect _dialect; - public OrdersController(IDbConnection connection, ISqlDialect dialect) + public OrdersController(IDbConnection connection) { _connection = connection; - _dialect = dialect; } [HttpGet] @@ -118,7 +112,6 @@ public async Task Get([FromQuery] FlexQueryParameters parameters) var model = BuildModel(); var result = await ((System.Data.Common.DbConnection)_connection).FlexQueryAsync(parameters, opt => { - opt.Dialect = _dialect; opt.UseModel(model); }); return Ok(result); @@ -150,12 +143,10 @@ private static FlexQueryModel BuildModel() public class ProductsController : ControllerBase { private readonly IDbConnection _connection; - private readonly ISqlDialect _dialect; - public ProductsController(IDbConnection connection, ISqlDialect dialect) + public ProductsController(IDbConnection connection) { _connection = connection; - _dialect = dialect; } [HttpGet] @@ -164,7 +155,6 @@ public async Task Get([FromQuery] FlexQueryParameters parameters) var model = BuildModel(); var result = await ((DbConnection)_connection).FlexQueryAsync(parameters, opt => { - opt.Dialect = _dialect; opt.UseModel(model); }); return Ok(result); diff --git a/tests/FlexQuery.NET.Tests/Tests/ResultCountTests.cs b/tests/FlexQuery.NET.Tests/Tests/ResultCountTests.cs index 4535e6f..d933d89 100644 --- a/tests/FlexQuery.NET.Tests/Tests/ResultCountTests.cs +++ b/tests/FlexQuery.NET.Tests/Tests/ResultCountTests.cs @@ -1,6 +1,5 @@ using FlexQuery.NET.Dapper; using FlexQuery.NET.Dapper.Options; -using FlexQuery.NET.Dapper.Dialects; using DapperModelBuilder = FlexQuery.NET.Dapper.Configuration.ModelBuilder; using FlexQuery.NET.EntityFrameworkCore; using FlexQuery.NET.Models; @@ -251,7 +250,6 @@ private static async Task> ExecuteDapperOrdersAsync( var dapperOptions = new DapperQueryOptions { - Dialect = new SqliteDialect(), IncludeTotalCount = includeTotalCount }; From 10df0e1e31e8214c9d612b382f819f14b02b5632 Mon Sep 17 00:00:00 2001 From: Peter John Casasola Date: Thu, 9 Jul 2026 14:43:33 +0800 Subject: [PATCH 4/7] test(dapper): add SqlDialectResolverTests for all supported providers Covers 7 scenarios: SqlConnection -> SqlServerDialect, NpgsqlConnection -> PostgreSqlDialect, SqliteConnection -> SqliteDialect, MySqlConnection -> MySqlDialect, MariaDbConnection -> MariaDbDialect, OracleConnection -> OracleDialect, and unknown connection type -> NotSupportedException. Uses lightweight mock DbConnection subclasses (no ADO.NET provider packages required). --- .../Dialects/SqlDialectResolverTests.cs | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 tests/FlexQuery.NET.Tests/Dapper/Dialects/SqlDialectResolverTests.cs diff --git a/tests/FlexQuery.NET.Tests/Dapper/Dialects/SqlDialectResolverTests.cs b/tests/FlexQuery.NET.Tests/Dapper/Dialects/SqlDialectResolverTests.cs new file mode 100644 index 0000000..95e8cdb --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Dapper/Dialects/SqlDialectResolverTests.cs @@ -0,0 +1,163 @@ +#pragma warning disable CS8764 + +using System.Data; +using System.Data.Common; +using FlexQuery.NET.Dapper.Dialects; + +namespace FlexQuery.NET.Tests.Dapper.Dialects; + +public class SqlDialectResolverTests +{ + [Fact] + public void SqlConnection_Resolves_SqlServerDialect() + { + var dialect = SqlDialectResolver.Resolve(new FakeSqlConnection()); + dialect.Should().BeOfType(); + } + + [Fact] + public void NpgsqlConnection_Resolves_PostgreSqlDialect() + { + var dialect = SqlDialectResolver.Resolve(new FakeNpgsqlConnection()); + dialect.Should().BeOfType(); + } + + [Fact] + public void SqliteConnection_Resolves_SqliteDialect() + { + var dialect = SqlDialectResolver.Resolve(new FakeSqliteConnection()); + dialect.Should().BeOfType(); + } + + [Fact] + public void MySqlConnection_Resolves_MySqlDialect() + { + var dialect = SqlDialectResolver.Resolve(new FakeMySqlConnection()); + dialect.Should().BeOfType(); + } + + [Fact] + public void MariaDbConnection_Resolves_MariaDbDialect() + { + var dialect = SqlDialectResolver.Resolve(new FakeMariaDbConnection()); + dialect.Should().BeOfType(); + } + + [Fact] + public void OracleConnection_Resolves_OracleDialect() + { + var dialect = SqlDialectResolver.Resolve(new FakeOracleConnection()); + dialect.Should().BeOfType(); + } + + [Fact] + public void UnknownConnectionType_Throws_NotSupportedException() + { + var act = () => SqlDialectResolver.Resolve(new FakeUnknownConnection()); + act.Should().Throw() + .WithMessage("*not a supported database provider*"); + } + + // ────────────────────────────────────────────────────────────── + // Fake DbConnection types β€” only the class name matters for + // SqlDialectResolver.Resolve(), which uses GetType().Name. + // ────────────────────────────────────────────────────────────── + + private sealed class FakeSqlConnection : DbConnection + { + public override string? ConnectionString { get; set; } = string.Empty; + public override string? Database => "Test"; + public override string? DataSource => ":memory:"; + public override string? ServerVersion => "1.0"; + public override ConnectionState State => ConnectionState.Closed; + public override void ChangeDatabase(string databaseName) { } + public override void Close() { } + public override void Open() { } + protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => null!; + protected override DbCommand CreateDbCommand() => null!; + } + + private sealed class FakeNpgsqlConnection : DbConnection + { + public override string? ConnectionString { get; set; } = string.Empty; + public override string? Database => "Test"; + public override string? DataSource => "localhost"; + public override string? ServerVersion => "1.0"; + public override ConnectionState State => ConnectionState.Closed; + public override void ChangeDatabase(string databaseName) { } + public override void Close() { } + public override void Open() { } + protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => null!; + protected override DbCommand CreateDbCommand() => null!; + } + + private sealed class FakeSqliteConnection : DbConnection + { + public override string? ConnectionString { get; set; } = string.Empty; + public override string? Database => "Test"; + public override string? DataSource => ":memory:"; + public override string? ServerVersion => "1.0"; + public override ConnectionState State => ConnectionState.Closed; + public override void ChangeDatabase(string databaseName) { } + public override void Close() { } + public override void Open() { } + protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => null!; + protected override DbCommand CreateDbCommand() => null!; + } + + private sealed class FakeMySqlConnection : DbConnection + { + public override string? ConnectionString { get; set; } = string.Empty; + public override string? Database => "Test"; + public override string? DataSource => "localhost"; + public override string? ServerVersion => "1.0"; + public override ConnectionState State => ConnectionState.Closed; + public override void ChangeDatabase(string databaseName) { } + public override void Close() { } + public override void Open() { } + protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => null!; + protected override DbCommand CreateDbCommand() => null!; + } + + private sealed class FakeMariaDbConnection : DbConnection + { + public override string? ConnectionString { get; set; } = string.Empty; + public override string? Database => "Test"; + public override string? DataSource => "localhost"; + public override string? ServerVersion => "1.0"; + public override ConnectionState State => ConnectionState.Closed; + public override void ChangeDatabase(string databaseName) { } + public override void Close() { } + public override void Open() { } + protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => null!; + protected override DbCommand CreateDbCommand() => null!; + } + + private sealed class FakeOracleConnection : DbConnection + { + public override string? ConnectionString { get; set; } = string.Empty; + public override string? Database => "Test"; + public override string? DataSource => "localhost"; + public override string? ServerVersion => "1.0"; + public override ConnectionState State => ConnectionState.Closed; + public override void ChangeDatabase(string databaseName) { } + public override void Close() { } + public override void Open() { } + protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => null!; + protected override DbCommand CreateDbCommand() => null!; + } + + private sealed class FakeUnknownConnection : DbConnection + { + public override string? ConnectionString { get; set; } = string.Empty; + public override string? Database => "Test"; + public override string? DataSource => "unknown"; + public override string? ServerVersion => "1.0"; + public override ConnectionState State => ConnectionState.Closed; + public override void ChangeDatabase(string databaseName) { } + public override void Close() { } + public override void Open() { } + protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => null!; + protected override DbCommand CreateDbCommand() => null!; + } +} From 8b0b69c7106cb7f1e85602e807f964ab5cbf19d3 Mon Sep 17 00:00:00 2001 From: Peter John Casasola Date: Thu, 9 Jul 2026 14:43:39 +0800 Subject: [PATCH 5/7] chore: inline dialect in benchmarks and remove UseSqlite from sample DapperSqlGenerationBenchmarks: removed _dialect field, inlined new SqlServerDialect() in SqlTranslator constructor call. Sample Program.cs: removed cfg.UseSqlite() call since dialect is now auto-detected from DbConnection. --- .../Benchmarks/Dapper/DapperSqlGenerationBenchmarks.cs | 4 +--- samples/FlexQuery.NET.Samples.WebApi/Program.cs | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/benchmarks/FlexQuery.Benchmarks/Benchmarks/Dapper/DapperSqlGenerationBenchmarks.cs b/benchmarks/FlexQuery.Benchmarks/Benchmarks/Dapper/DapperSqlGenerationBenchmarks.cs index 74695ef..9f28f78 100644 --- a/benchmarks/FlexQuery.Benchmarks/Benchmarks/Dapper/DapperSqlGenerationBenchmarks.cs +++ b/benchmarks/FlexQuery.Benchmarks/Benchmarks/Dapper/DapperSqlGenerationBenchmarks.cs @@ -29,7 +29,6 @@ private class SqlEntity } private IMappingRegistry _registry = null!; - private ISqlDialect _dialect = null!; private SqlTranslator _translator = null!; private QueryOptions _simpleOptions = null!; private QueryOptions _complexFilterOptions = null!; @@ -41,8 +40,7 @@ public void Setup() _registry = new MappingRegistry(); _registry.Entity().ToTable("SqlEntities"); - _dialect = new SqlServerDialect(); - _translator = new SqlTranslator(_registry, _dialect); + _translator = new SqlTranslator(_registry, new SqlServerDialect()); _simpleOptions = BuildSimpleQuery(); _complexFilterOptions = BuildComplexFilterQuery(); diff --git a/samples/FlexQuery.NET.Samples.WebApi/Program.cs b/samples/FlexQuery.NET.Samples.WebApi/Program.cs index 8c436bb..1b8cfbc 100644 --- a/samples/FlexQuery.NET.Samples.WebApi/Program.cs +++ b/samples/FlexQuery.NET.Samples.WebApi/Program.cs @@ -25,7 +25,6 @@ //Global Config builder.Services.AddFlexQueryDapper(cfg => { - cfg.UseSqlite(); cfg.Model.Entity() .ToTable("Customers") .HasMany(c => c.Orders).WithForeignKey("CustomerId"); From a7286f64c24a128e184d47eb1fe8b47435c62022 Mon Sep 17 00:00:00 2001 From: Peter John Casasola Date: Thu, 9 Jul 2026 14:43:46 +0800 Subject: [PATCH 6/7] fix: update FlexQueryBase and test files for incompatible API changes Removed obsolete Includes alias from FlexQueryBase (use Include instead). Updated FilteredIncludeTests to use Include instead of the removed Includes alias on FlexQueryParameters. Rewrote FlexQueryBaseTests.FlexQueryRequest_IsFlexQueryBase to use the new strongly-typed FlexQueryRequest API (FilterGroup, List, HavingCondition, PagingOptions, ProjectionMode). Removed obsolete IncludesAlias_IsObsolete test. --- src/FlexQuery.NET/Models/FlexQueryBase.cs | 4 -- .../Tests/FilteredIncludeTests.cs | 10 ++-- .../Tests/FlexQueryBaseTests.cs | 58 ++++++++++--------- 3 files changed, 35 insertions(+), 37 deletions(-) diff --git a/src/FlexQuery.NET/Models/FlexQueryBase.cs b/src/FlexQuery.NET/Models/FlexQueryBase.cs index c9607d2..a89e132 100644 --- a/src/FlexQuery.NET/Models/FlexQueryBase.cs +++ b/src/FlexQuery.NET/Models/FlexQueryBase.cs @@ -18,10 +18,6 @@ public abstract class FlexQueryBase /// The comma-separated list of fields to include. public string? Include { get; set; } - /// Alias for Include (backward compatibility). - [Obsolete("Use Include instead.")] - public string? Includes { get => Include; set => Include = value; } - /// The comma-separated list of fields to group by. public string? GroupBy { get; set; } diff --git a/tests/FlexQuery.NET.Tests/Tests/FilteredIncludeTests.cs b/tests/FlexQuery.NET.Tests/Tests/FilteredIncludeTests.cs index 78eebda..86ef03a 100644 --- a/tests/FlexQuery.NET.Tests/Tests/FilteredIncludeTests.cs +++ b/tests/FlexQuery.NET.Tests/Tests/FilteredIncludeTests.cs @@ -137,7 +137,7 @@ public async Task ToProjectedQueryResultAsync_AppliesFilteredIncludes() var parameters = new FlexQueryParameters { Filter = "Id:eq:1", - Includes = "Orders(Total:gt:100)", + Include = "Orders(Total:gt:100)", Select = "Id,Name,Orders.Number,Orders.Total" }; @@ -168,7 +168,7 @@ public async Task Select_OnNavigation_OverridesIncludeAllScalars() var parameters = new FlexQueryParameters { Filter = "Id:eq:1", - Includes = "Orders(Total:gt:100)", + Include = "Orders(Total:gt:100)", Select = "Id,Orders.Number" // We ONLY want Number, not Total! }; @@ -199,7 +199,7 @@ public async Task FilteredInclude_SupportsDsl() var parameters = new FlexQueryParameters { Filter = "Id:eq:1", - Includes = "Orders(Total:gt:100)", + Include = "Orders(Total:gt:100)", Select = "Id,Orders.Number,Orders.Total" }; @@ -226,7 +226,7 @@ public async Task FilteredInclude_NestedMixed_WorksCorrectly() var parameters = new FlexQueryParameters { Filter = "Id:eq:1", - Includes = "Orders(Total:gt:100).items(Sku:eq:SKU-AAA)", + Include = "Orders(Total:gt:100).items(Sku:eq:SKU-AAA)", Select = "Id,Orders.Number,Orders.Items.Sku" }; @@ -260,7 +260,7 @@ public async Task FilteredInclude_ComplexChain_MixedFormats() var parameters = new FlexQueryParameters { Filter = "Id:eq:1", - Includes = "Orders(Total:gt:100) . items(Sku:eq:SKU-AAA)", + Include = "Orders(Total:gt:100) . items(Sku:eq:SKU-AAA)", Select = "Id,Orders.Number,Orders.Items.Sku" }; diff --git a/tests/FlexQuery.NET.Tests/Tests/FlexQueryBaseTests.cs b/tests/FlexQuery.NET.Tests/Tests/FlexQueryBaseTests.cs index 74fcbc0..461ef8b 100644 --- a/tests/FlexQuery.NET.Tests/Tests/FlexQueryBaseTests.cs +++ b/tests/FlexQuery.NET.Tests/Tests/FlexQueryBaseTests.cs @@ -1,46 +1,48 @@ using FlexQuery.NET.Models; +using FlexQuery.NET.Models.Aggregates; +using FlexQuery.NET.Models.Filters; +using FlexQuery.NET.Models.Paging; +using FlexQuery.NET.Models.Projection; namespace FlexQuery.NET.Tests.Tests; public class FlexQueryBaseTests { [Fact] - public void FlexQueryRequest_IsFlexQueryBase() + public void FlexQueryRequest_PopulatesAllProperties() { var request = new FlexQueryRequest { - Filter = "Age gt 18", - Sort = "Name:asc", - Select = "Id,Name", - Include = "Orders", - GroupBy = "Category", - Having = "count(Id) gt 5", - Page = 2, - PageSize = 25, + Filter = new FilterGroup + { + Logic = LogicOperator.And, + Filters = [new FilterCondition { Field = "Age", Operator = "gt", Value = "18" }] + }, + Sort = [new SortNode { Field = "Name", Descending = false }], + Select = ["Id", "Name"], + Includes = ["Orders"], + GroupBy = ["Category"], + Having = new HavingCondition { Function = "count", Field = "Id", Operator = "gt", Value = "5" }, + Paging = new PagingOptions { Page = 2, PageSize = 25 }, IncludeCount = false, Distinct = true, - Mode = "Flat" + ProjectionMode = ProjectionMode.Flat }; - request.Filter.Should().Be("Age gt 18"); - request.Sort.Should().Be("Name:asc"); - request.Select.Should().Be("Id,Name"); - request.Include.Should().Be("Orders"); - request.GroupBy.Should().Be("Category"); - request.Having.Should().Be("count(Id) gt 5"); - request.Page.Should().Be(2); - request.PageSize.Should().Be(25); + request.Filter.Should().NotBeNull(); + request.Filter!.Filters.Should().Contain(f => f.Field == "Age" && f.Operator == "gt" && f.Value == "18"); + request.Sort.Should().Contain(s => s.Field == "Name" && !s.Descending); + request.Select.Should().BeEquivalentTo("Id", "Name"); + request.Includes.Should().BeEquivalentTo("Orders"); + request.GroupBy.Should().BeEquivalentTo("Category"); + request.Having.Should().NotBeNull(); + request.Having!.Function.Should().Be("count"); + request.Having.Field.Should().Be("Id"); + request.Paging.Page.Should().Be(2); + request.Paging.PageSize.Should().Be(25); request.IncludeCount.Should().BeFalse(); request.Distinct.Should().BeTrue(); - request.Mode.Should().Be("Flat"); - } - - [Fact] - public void FlexQueryRequest_IncludesAlias_IsObsolete() - { - var request = new FlexQueryRequest(); - request.Includes = "Orders,Profile"; - request.Include.Should().Be("Orders,Profile"); + request.ProjectionMode.Should().Be(ProjectionMode.Flat); } [Fact] @@ -66,4 +68,4 @@ public void FlexQueryParameters_RawParameters_CanBeNull() var parameters = new FlexQueryParameters(); parameters.RawParameters.Should().BeNull(); } -} \ No newline at end of file +} From 169166dac8f78a43892d6fc7aaf1b13ef87c19e0 Mon Sep 17 00:00:00 2001 From: Peter John Casasola Date: Thu, 9 Jul 2026 14:43:53 +0800 Subject: [PATCH 7/7] feat: add FlexQueryRequestExtensions fluent API and strongly-typed FlexQueryRequest Added FlexQueryRequestExtensions providing fluent builder methods for FlexQueryRequest. FlexQueryRequest is now a standalone sealed class with strongly-typed properties (FilterGroup, List, List, HavingCondition, PagingOptions, ProjectionMode) instead of inheriting from FlexQueryBase. --- .../Extensions/FlexQueryRequestExtensions.cs | 66 +++++++++++++++++++ src/FlexQuery.NET/Models/FlexQueryRequest.cs | 47 ++++++++++++- 2 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 src/FlexQuery.NET/Extensions/FlexQueryRequestExtensions.cs diff --git a/src/FlexQuery.NET/Extensions/FlexQueryRequestExtensions.cs b/src/FlexQuery.NET/Extensions/FlexQueryRequestExtensions.cs new file mode 100644 index 0000000..52f3357 --- /dev/null +++ b/src/FlexQuery.NET/Extensions/FlexQueryRequestExtensions.cs @@ -0,0 +1,66 @@ +using FlexQuery.NET.Models; + +namespace FlexQuery.NET; + +/// +/// Provides extension methods for converting +/// instances into objects. +/// +public static class FlexQueryRequestExtensions +{ + /// + /// Creates a instance from the specified + /// . + /// + /// + /// The FlexQuery request containing the query parameters, typically + /// deserialized from an HTTP POST request body. + /// + /// + /// A instance containing the equivalent query + /// configuration represented by the request. + /// + /// + /// This method performs a direct mapping from a + /// to a instance. It copies all supported query + /// components, including: + /// + /// Filter expressions + /// Sorting + /// Projection (Select) + /// Expand / Includes + /// Grouping (Group By) + /// Aggregate functions + /// Having filters + /// Paging options + /// Distinct + /// Projection mode + /// Include count + /// + /// + /// This extension is useful when a request needs to be inspected, modified, + /// validated, or executed through APIs that accept a + /// instance. + /// + /// + public static QueryOptions ToQueryOptions(this FlexQueryRequest request) + { + var queryOptions = new QueryOptions + { + Aggregates = request.Aggregates, + Filter = request.Filter, + Distinct = request.Distinct, + Expand = request.Expand, + Paging = request.Paging, + Select = request.Select, + IncludeCount = request.IncludeCount, + Includes = request.Includes, + GroupBy = request.GroupBy, + Sort = request.Sort, + Having = request.Having, + ProjectionMode = request.ProjectionMode + }; + + return queryOptions; + } +} \ No newline at end of file diff --git a/src/FlexQuery.NET/Models/FlexQueryRequest.cs b/src/FlexQuery.NET/Models/FlexQueryRequest.cs index 50efeee..1e68e71 100644 --- a/src/FlexQuery.NET/Models/FlexQueryRequest.cs +++ b/src/FlexQuery.NET/Models/FlexQueryRequest.cs @@ -1,7 +1,52 @@ +using FlexQuery.NET.Models.Aggregates; +using FlexQuery.NET.Models.Filters; +using FlexQuery.NET.Models.Paging; +using FlexQuery.NET.Models.Projection; namespace FlexQuery.NET.Models; /// /// The standard request model for FlexQuery.NET. /// Represents a query sent in the body of an HTTP POST request. /// -public sealed class FlexQueryRequest : FlexQueryBase; \ No newline at end of file +public sealed class FlexQueryRequest +{ + // --- Data Selection & Projection --- + + /// The filter expression (JQL or DSL). + public FilterGroup? Filter { get; set; } + + /// The sorting expressions. + public List Sort { get; set; } = []; + + /// Flat dot-notation selection paths (e.g. "Id", "Profile.Name"). + public List? Select { get; set; } + + /// Navigation properties to include with all scalars. + public List? Includes { get; set; } + + /// Deep, filtered navigation expansion trees. + public List? Expand { get; set; } + + /// Defines how projected data should be shaped (Nested, Flat, FlatMixed). + public ProjectionMode ProjectionMode { get; set; } = ProjectionMode.Nested; + + /// Fields to group by for aggregation. + public List? GroupBy { get; set; } + + /// Aggregate projection expressions (sum, count, avg). + public List Aggregates { get; set; } = []; + + /// HAVING condition against aggregate projections. + public HavingCondition? Having { get; set; } + + /// If true, applies Distinct() to the query. + public bool? Distinct { get; set; } + + // --- Pagination --- + + /// Pagination parameters (Page, PageSize, Disabled). + public PagingOptions Paging { get; set; } = new(); + + /// Whether to include the total count in the result. + public bool? IncludeCount { get; set; } = true; +} \ No newline at end of file