diff --git a/CHANGELOG.md b/CHANGELOG.md
index beae72a..5ce5115 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,66 @@
All notable changes to this project will be documented in this file.
+---
+## [3.0.0] - 2026-05-15
+
+### Added
+- **.NET 10.0 Support**: Added support for `.NET 10.0` (`net10.0`) across all packages (`FlexQuery.NET`, `FlexQuery.NET.EFCore`, `FlexQuery.NET.Dapper`, `FlexQuery.NET.AspNetCore`, `FlexQuery.NET.MiniOData`).
+- **Internal Options Base Class**: Introduced `BaseQueryExecutionOptions` to clean up and consolidate core options and security configuration fields.
+- **Automatic OData Parser Discovery**: Implemented automatic reflection-based detection and registration of `MiniODataParser` within `QueryOptionsParser` to enable zero-configuration OData query parsing.
+- **FlexQuery.NET.Dapper Package**:
+ - Full support for Dapper as a high-performance query engine.
+ - Polymorphic `ISqlDialect` system supporting SQL Server, PostgreSQL, MySQL, MariaDB, SQLite, and Oracle.
+ - Automatic dialect resolution via `ISqlDialectResolver` based on `DbConnection` types.
+ - Secure SQL translation engine with parameterization and identifier quoting.
+- **Relationship Query Semantics for Dapper**:
+ - Implemented `any()`, `all()`, and `count()` semantics using efficient `EXISTS` and correlated subqueries.
+ - Support for `include` and Filtered Includes using `LEFT JOIN` syntax.
+ - Semantic parity with EF Core relationship queries.
+- **Dapper AST & Translators**:
+ - Dedicated AST nodes for relationship queries, decoupled from core models.
+ - Specialized translators for Includes, Existence checks, and Counts.
+- **Dapper Query Engine Stabilization**:
+ - Reimplemented `SqlCountTranslator` to use convention‑based `RelationshipMapping` via `IMappingRegistry`.
+ - Added missing `using FlexQuery.NET.Dapper.Mapping.Metadata` to `SqlExistsTranslator` and `SqlIncludeTranslator`.
+ - Fixed string interpolation issues and removed redundant `Metadata.` prefixes.
+ - Refactored Dapper mapping system to convention‑over‑configuration using `IMappingRegistry`.
+- **Test Suite Integrity**:
+ - Updated `SqlTranslatorTests` to register `TestRole` (`ToTable("roles")`) and adjusted assertions for proper quoted SQL (`[roles].[UserId] = [users].[Id]`).
+ - Fixed navigation‑property test to expect table name `[TestEntities]`.
+ - Resolved CS1022 / CS1519 syntax errors in translation files.
+- **FlexQuery.NET.MiniOData Package**:
+ - Lightweight OData-compatible query syntax adapter — completely optional, zero core dependencies.
+ - Parses `$filter`, `$orderby`, `$select`, `$top`, `$skip`, `$expand`, and `$count` into the unified FlexQuery AST.
+ - OData filter parser supporting binary comparisons (`eq`, `ne`, `gt`, `ge`, `lt`, `le`), function calls (`contains`, `startswith`, `endswith`), logical operators (`and`, `or`, `not`), grouping, null checks, `in` lists, and lambda navigation (`any`/`all`).
+ - Automatic OData path separator (`/`) to dot-notation conversion.
+ - `$` prefix stripping for seamless compatibility with both `$filter` and `filter` key formats.
+ - Lambda variable stripping for `any(o: o/status eq 'active')` expressions.
+ - DI registration via `services.AddFlexQueryMiniOData()`.
+ - Multi-targeting support for .NET 6, 8, and 10.
+- **Mini OData ↔ Native DSL Equivalence**:
+ - 63 comprehensive tests verifying AST equivalence between Native DSL and Mini OData syntaxes.
+ - Proven semantic parity: both parsers produce identical `FilterGroup`, `SortNode`, and `QueryOptions` structures.
+ - Full solution test suite: 431 tests passing.
+
+### Changed
+- **JQL Status Promotion**: Removed obsolete/deprecation attributes from JQL parser components (`JqlParser`, `FlexQueryParameters.Query`, and `QuerySyntax.Jql`), reinstating JQL as a first-class supported query syntax option alongside DSL and OData.
+- **Mini OData Cleanups**: Streamlined the dynamic registration calls and brought in namespaces natively in `ServiceCollectionExtensions`.
+- **Dapper Parameter Binding**: Simplified and optimized clean parameter naming iteration in `FlexQueryDapperExtensions`.
+- **Documentation Refactoring**: Reorganized the main `README.md` with new .NET 10.0 badges, dark-mode logo assets, and streamlined links.
+- **Mapping Registry Evolution**: Updated `JoinInfo` to support `TargetType`, enabling deep property resolution for related entity filters in Dapper.
+- **Dapper Multi-Targeting**: Updated support to target `.net6.0`, `.net8.0`, and `.net10.0` in the Dapper package (dropping EOL `.net7.0`).
+- **Internal Reorganization**: Moved SQL translators to a dedicated `Translators` folder and namespace for better maintainability.
+
+### Removed (Breaking Changes)
+- **.NET 7.0 EOL Drop**: Dropped support for `.NET 7.0` (reached end-of-life on May 14, 2024) across all projects.
+- **Obsolete APIs Cleanup**: Fully removed deprecated and legacy methods that were scheduled for removal:
+ - `ToQueryResultAsync` and `ToProjectedQueryResultAsync` from `QueryableEfCoreExtensions`.
+ - `ToQueryResult`, `ToProjectedQueryResult`, and `ApplyQueryOptions` from `QueryableExtensions`.
+ - `Validate` and `ApplyValidatedQueryOptions` overloads from `ValidationExtensions`.
+ - `QueryOptionsParser.Parse(QueryRequest)` helper from `QueryOptionsParser`.
+ - `SortOption` alias in favor of the unified `SortNode` class.
+
---
## [2.5.0] - 2026-05-10
diff --git a/FlexQuery.NET.slnx b/FlexQuery.NET.slnx
index 5ccbe1e..12d7ed3 100644
--- a/FlexQuery.NET.slnx
+++ b/FlexQuery.NET.slnx
@@ -3,6 +3,8 @@
+
+
diff --git a/README.md b/README.md
index a8a6924..84db4ae 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-
+
# FlexQuery.NET
@@ -8,7 +8,7 @@
[](https://www.nuget.org/packages/FlexQuery.NET)
[](https://www.nuget.org/packages/FlexQuery.NET)
-[](https://dotnet.microsoft.com/download)
+[](https://dotnet.microsoft.com/download)
[](https://flexquery.vercel.app)
[](LICENSE)
@@ -23,7 +23,6 @@ FlexQuery.NET is a lightweight and powerful dynamic query engine for .NET. It al
- **Advanced Projection**: Automatic SQL `SELECT` optimization including nested includes.
- **Governance & Security**: Built-in field-level validation and operator restrictions.
- **High Performance**: Thread-safe expression caching for ultra-low latency.
-- **Explicit Joins**: SQL-like join support with alias-aware field resolution.
---
@@ -34,6 +33,7 @@ FlexQuery.NET is a lightweight and powerful dynamic query engine for .NET. It al
```bash
dotnet add package FlexQuery.NET
dotnet add package FlexQuery.NET.EFCore
+dotnet add package FlexQuery.NET.Dapper
dotnet add package FlexQuery.NET.AspNetCore
```
@@ -62,79 +62,62 @@ public async Task GetUsers([FromQuery] FlexQueryParameters parame
GET /api/users?filter=age:gt:18&sort=createdAt:desc&page=1&pageSize=20&select=id,name,email
```
----
-
-## 🏎️ Performance Benchmarks
-
-FlexQuery.NET is engineered for performance with transparent, reproducible benchmarks. We measure parsing, expression generation, full end-to-end execution, and API-level latency against Gridify, Sieve, OData, and GraphQL.
-
-> For the complete benchmark suite with methodology, fairness disclaimers, and full analysis, see **[docs/guide/performance/](docs/guide/performance/)**.
-
-### End-to-End Execution (EF Core InMemory, 1,000 records)
-
-*Scenario: Filter (2 conditions) + Sort + Paging (100 items) on 1,000 Users with related Orders and OrderItems.*
-
-| Library | Mean | Relative | Allocated |
-|:---------|-----:|---------:|----------:|
-| **FlexQuery.NET** | **17.67 ms** | **0.44×** | 21.42 KB |
-| **Handwritten LINQ** | 40.21 ms | 1.00× | 97.11 KB |
-| Gridify | 40.33 ms | 1.00× | 107.76 KB |
-| System.Linq.Dynamic.Core | 40.95 ms | 1.02× | 110.79 KB |
-| Sieve | 41.37 ms | 1.03× | 117.67 KB |
+### 4. Dapper Integration & Database Dialects
-FlexQuery.NET is **2.25× faster than handwritten LINQ** in this InMemory scenario, likely due to expression tree optimization and reduced closure allocation. Full analysis: [Execution Benchmarks](docs/guide/performance/execution.md).
+FlexQuery.NET provides a robust Dapper extension (`FlexQuery.NET.Dapper`) that compiles queries into secure, parameterized, and database-specific SQL.
----
-
-### Database Execution (SQL Server LocalDB, 100,000 records)
-
-*Scenario: Simple filter (`status:eq:active`) + page (100 items) against SQL Server with no index.*
-
-⚠️ **Important:** FlexQuery.NET's default configuration (`IncludeCount=true`) executes an additional COUNT query to return total record count, while the handwritten baseline retrieves data only. This benchmark therefore measures **two roundtrips vs one**. When configured fairly, FlexQuery.NET's filtering overhead is ~3–10% (see details).
+#### Automatic Dialect Resolution
-| Library | Mean | Relative | Allocated | Queries |
-|:---------|-----:|---------:|----------:|---------|
-| **Handwritten LINQ** (data only) | 336 µs | 1.00× | 111 KB | 1 SELECT |
-| **FlexQuery.NET (with count)** | 20,798 µs | 61.8× | 129 KB | SELECT + COUNT |
+By default, the SQL dialect is automatically resolved from your database connection (e.g., `SqlConnection` -> `SqlServerDialect`, `NpgsqlConnection` -> `PostgreSqlDialect`).
-The apparent 62× overhead is the cost of the extra COUNT query. Full analysis, fair comparison methodology, and configuration options: [Database Execution](docs/guide/performance/database-execution.md).
-
----
+```csharp
+[HttpGet]
+public async Task GetUsersDapper([FromQuery] FlexQueryParameters parameters)
+{
+ // The dialect is automatically resolved based on the provided NpgsqlConnection
+ using var connection = new NpgsqlConnection("Host=localhost;Database=mydb;");
+
+ var result = await connection.FlexQueryAsync(parameters, options =>
+ {
+ options.AllowedFields = ["Id", "Name", "Email"];
+ // Dapper specific options
+ options.CommandTimeoutSeconds = 60;
+ });
-### API End-to-End (Full ASP.NET Core Pipeline, 100,000 records)
+ return Ok(result);
+}
+```
-*Scenario: HTTP request with filter + sort + paging + projection, including JSON serialization.*
+#### Explicit Dialect Configuration
-| Library | PageSize=20 | PageSize=100 | PageSize=100K |
-|:---------|------------:|-------------:|--------------:|
-| **FlexQuery.NET** | 1.49 ms | 1.64 ms | 2.26 ms |
-| GraphQL | 0.90 ms | 0.90 ms | FAILED |
-| OData | 1.64 ms | 1.72 ms | 2.24 ms |
-| Gridify | 1.56 ms | 1.90 ms | 1.90 ms |
-| Sieve | 1.59 ms | 1.97 ms | 1.86 ms |
-| Manual LINQ | 1.63 ms | 1.97 ms | 1.89 ms |
+If you need to force a specific SQL dialect for a single query, you can configure it directly:
-Full results with fairness notes: [API Benchmarks](docs/guide/performance/api-benchmarks.md).
+```csharp
+using FlexQuery.NET.Dapper.Dialects;
----
+var result = await connection.FlexQueryAsync(parameters, options =>
+{
+ // Explicitly configure the dialect for this specific query
+ options.Dialect = new MySqlDialect();
+ // Supported dialects: SqlServerDialect, PostgreSqlDialect, MySqlDialect, MariaDbDialect, SqliteDialect, OracleDialect
+});
+```
-## 📚 Full Documentation
+#### Global Dialect Configuration (Optional)
-For detailed methodology, dataset description, reproducibility instructions, and fairness disclaimers:
+If your entire application uses a single database type and you want to bypass the automatic resolution entirely, you can configure a global default dialect once at startup:
-👉 **[Performance Documentation Index](docs/guide/performance/)**
+```csharp
+// Program.cs or Startup.cs
+using FlexQuery.NET.Dapper;
+using FlexQuery.NET.Dapper.Dialects;
-- [Methodology & Reproducibility](docs/guide/performance/methodology.md)
-- [Parsing Benchmarks](docs/guide/performance/parsing.md)
-- [Expression Generation](docs/guide/performance/expression-generation.md)
-- [End-to-End Execution](docs/guide/performance/execution.md)
-- [Database Execution (SQL Server)](docs/guide/performance/database-execution.md)
-- [API Benchmarks (vs OData/GraphQL)](docs/guide/performance/api-benchmarks.md)
-- [Scalability Analysis](docs/guide/performance/scalability.md)
-- [Fairness Disclaimers](docs/guide/performance/fairness-disclaimers.md)
-- [Interpretation Guide](docs/guide/performance/interpretation-guide.md)
+// Set the global dialect once for the entire application
+DapperQueryOptions.GlobalDefaultDialect = new PostgreSqlDialect();
----
+// Or, provide your own custom resolver logic:
+// DapperQueryOptions.GlobalDialectResolver = new MyCustomResolver();
+```
## 📚 Documentation
@@ -145,7 +128,6 @@ For detailed guides, API references, and advanced scenarios, visit our documenta
### Quick Links
- [Getting Started](https://flexquery.vercel.app/guide/getting-started)
- [Query Composition](https://flexquery.vercel.app/guide/composition)
-- [Explicit Joins](https://flexquery.vercel.app/guide/joins)
- [Governance & Security](https://flexquery.vercel.app/guide/security)
- [Performance Optimization](https://flexquery.vercel.app/guide/performance-tuning)
- [Migration Guide (v1 → v2)](https://flexquery.vercel.app/migration/v1-to-v2)
diff --git a/assets/logo-dark.png b/assets/logo-dark.png
new file mode 100644
index 0000000..e9c371d
Binary files /dev/null and b/assets/logo-dark.png differ
diff --git a/src/FlexQuery.NET.AspNetCore/FlexQuery.NET.AspNetCore.csproj b/src/FlexQuery.NET.AspNetCore/FlexQuery.NET.AspNetCore.csproj
index ed1fd59..ab32781 100644
--- a/src/FlexQuery.NET.AspNetCore/FlexQuery.NET.AspNetCore.csproj
+++ b/src/FlexQuery.NET.AspNetCore/FlexQuery.NET.AspNetCore.csproj
@@ -1,7 +1,7 @@
- net6.0;net7.0;net8.0
+ net6.0;net8.0;net10.0falseenableenable
diff --git a/src/FlexQuery.NET.Dapper/Configuration/FlexQueryDapperExtensions.cs b/src/FlexQuery.NET.Dapper/Configuration/FlexQueryDapperExtensions.cs
new file mode 100644
index 0000000..d654f86
--- /dev/null
+++ b/src/FlexQuery.NET.Dapper/Configuration/FlexQueryDapperExtensions.cs
@@ -0,0 +1,53 @@
+using Microsoft.Extensions.DependencyInjection;
+using FlexQuery.NET.Dapper.Sql;
+
+namespace FlexQuery.NET.Dapper.Configuration;
+
+public static class FlexQueryDapperExtensions
+{
+ ///
+ /// Configures FlexQuery.NET Dapper globally.
+ ///
+ public static IServiceCollection AddFlexQueryDapper(this IServiceCollection services, Action configure)
+ {
+ var options = new DapperQueryOptions();
+ configure(options);
+
+ // Optionally, register the options as a singleton or configured options
+ services.AddSingleton(options);
+
+ if (options.Dialect != null)
+ {
+ DapperQueryOptions.GlobalDefaultDialect = options.Dialect;
+ }
+
+ return services;
+ }
+
+ ///
+ /// Configures the SQL Server dialect.
+ ///
+ public static DapperQueryOptions UseSqlServer(this DapperQueryOptions options)
+ {
+ options.Dialect = new Dialects.SqlServerDialect();
+ return options;
+ }
+
+ ///
+ /// Configures the PostgreSQL dialect.
+ ///
+ public static DapperQueryOptions UsePostgreSql(this DapperQueryOptions options)
+ {
+ options.Dialect = new Dialects.PostgreSqlDialect();
+ return options;
+ }
+
+ ///
+ /// Configures the SQLite dialect.
+ ///
+ public static DapperQueryOptions UseSqlite(this DapperQueryOptions options)
+ {
+ options.Dialect = new Dialects.SqliteDialect();
+ return options;
+ }
+}
diff --git a/src/FlexQuery.NET.Dapper/Conventions/DefaultEntityConvention.cs b/src/FlexQuery.NET.Dapper/Conventions/DefaultEntityConvention.cs
new file mode 100644
index 0000000..1100d31
--- /dev/null
+++ b/src/FlexQuery.NET.Dapper/Conventions/DefaultEntityConvention.cs
@@ -0,0 +1,79 @@
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+using System.Reflection;
+using FlexQuery.NET.Dapper.Mapping.Metadata;
+
+namespace FlexQuery.NET.Dapper.Conventions;
+
+///
+/// Default entity convention. Infers table name, maps properties, detects primary key.
+///
+public class DefaultEntityConvention : IEntityConvention
+{
+ private readonly IPluralizer _pluralizer;
+
+ public DefaultEntityConvention(IPluralizer pluralizer)
+ {
+ _pluralizer = pluralizer;
+ }
+
+ public void Apply(EntityMapping mapping)
+ {
+ var type = mapping.Type;
+
+ // 1. Table Name Convention
+ var tableAttr = type.GetCustomAttribute();
+ if (tableAttr != null)
+ {
+ mapping.TableName = tableAttr.Name;
+ }
+ else if (string.IsNullOrEmpty(mapping.TableName) || mapping.TableName == type.Name)
+ {
+ mapping.TableName = _pluralizer.Pluralize(type.Name);
+ }
+
+ // 2. Property Conventions
+ foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
+ {
+ // Skip unmapped / ignored properties (can add NotMappedAttribute support here)
+ if (property.GetCustomAttribute() != null)
+ continue;
+
+ // Skip navigation properties (complex types and collections are handled by relationship convention)
+ if (IsNavigationProperty(property.PropertyType))
+ continue;
+
+ var propMapping = mapping.GetOrAddProperty(property);
+
+ // Column Name
+ var columnAttr = property.GetCustomAttribute();
+ if (columnAttr != null)
+ {
+ propMapping.ColumnName = columnAttr.Name ?? property.Name;
+ }
+
+ // Primary Key
+ if (property.GetCustomAttribute() != null)
+ {
+ propMapping.IsPrimaryKey = true;
+ }
+ else if (string.Equals(property.Name, "Id", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(property.Name, type.Name + "Id", StringComparison.OrdinalIgnoreCase))
+ {
+ propMapping.IsPrimaryKey = true;
+ }
+ }
+ }
+
+ private bool IsNavigationProperty(Type type)
+ {
+ if (type == typeof(string) || type == typeof(byte[]) || type.IsValueType || type.IsPrimitive)
+ return false;
+
+ // Nullable where T is a value type is not a navigation property
+ if (Nullable.GetUnderlyingType(type) != null)
+ return false;
+
+ return true;
+ }
+}
diff --git a/src/FlexQuery.NET.Dapper/Conventions/DefaultForeignKeyConvention.cs b/src/FlexQuery.NET.Dapper/Conventions/DefaultForeignKeyConvention.cs
new file mode 100644
index 0000000..c74517a
--- /dev/null
+++ b/src/FlexQuery.NET.Dapper/Conventions/DefaultForeignKeyConvention.cs
@@ -0,0 +1,28 @@
+using System.Reflection;
+using FlexQuery.NET.Dapper.Mapping.Metadata;
+
+namespace FlexQuery.NET.Dapper.Conventions;
+
+///
+/// Default convention for inferring foreign key column names.
+///
+public class DefaultForeignKeyConvention : IForeignKeyConvention
+{
+ public string GetForeignKeyName(PropertyInfo navigationProperty, Type targetType, RelationshipType relationshipType, Type entityType)
+ {
+ if (relationshipType == RelationshipType.OneToMany)
+ {
+ // For Customer.Orders, the FK is on Order, pointing to Customer.
+ // FK is usually CustomerId.
+ return entityType.Name + "Id";
+ }
+ else if (relationshipType == RelationshipType.ManyToOne)
+ {
+ // For Order.Customer, the FK is on Order, pointing to Customer.
+ // FK is usually CustomerId.
+ return navigationProperty.Name + "Id";
+ }
+
+ return navigationProperty.Name + "Id";
+ }
+}
diff --git a/src/FlexQuery.NET.Dapper/Conventions/DefaultPluralizer.cs b/src/FlexQuery.NET.Dapper/Conventions/DefaultPluralizer.cs
new file mode 100644
index 0000000..73db32e
--- /dev/null
+++ b/src/FlexQuery.NET.Dapper/Conventions/DefaultPluralizer.cs
@@ -0,0 +1,35 @@
+namespace FlexQuery.NET.Dapper.Conventions;
+
+///
+/// Default English pluralizer implementation.
+///
+public class DefaultPluralizer : IPluralizer
+{
+ public string Pluralize(string name)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ return name;
+
+ // Simple english pluralization rules
+ if (name.EndsWith("y", StringComparison.OrdinalIgnoreCase) &&
+ !name.EndsWith("ay", StringComparison.OrdinalIgnoreCase) &&
+ !name.EndsWith("ey", StringComparison.OrdinalIgnoreCase) &&
+ !name.EndsWith("iy", StringComparison.OrdinalIgnoreCase) &&
+ !name.EndsWith("oy", StringComparison.OrdinalIgnoreCase) &&
+ !name.EndsWith("uy", StringComparison.OrdinalIgnoreCase))
+ {
+ return name[..^1] + "ies";
+ }
+
+ if (name.EndsWith("s", StringComparison.OrdinalIgnoreCase) ||
+ name.EndsWith("sh", StringComparison.OrdinalIgnoreCase) ||
+ name.EndsWith("ch", StringComparison.OrdinalIgnoreCase) ||
+ name.EndsWith("x", StringComparison.OrdinalIgnoreCase) ||
+ name.EndsWith("z", StringComparison.OrdinalIgnoreCase))
+ {
+ return name + "es";
+ }
+
+ return name + "s";
+ }
+}
diff --git a/src/FlexQuery.NET.Dapper/Conventions/DefaultRelationshipConvention.cs b/src/FlexQuery.NET.Dapper/Conventions/DefaultRelationshipConvention.cs
new file mode 100644
index 0000000..838b3ff
--- /dev/null
+++ b/src/FlexQuery.NET.Dapper/Conventions/DefaultRelationshipConvention.cs
@@ -0,0 +1,89 @@
+using System.Collections;
+using System.ComponentModel.DataAnnotations.Schema;
+using System.Reflection;
+using FlexQuery.NET.Dapper.Mapping;
+using FlexQuery.NET.Dapper.Mapping.Metadata;
+
+namespace FlexQuery.NET.Dapper.Conventions;
+
+///
+/// Default relationship convention. Discovers relationships, infers foreign keys and target types.
+///
+public class DefaultRelationshipConvention : IRelationshipConvention
+{
+ private readonly IForeignKeyConvention _foreignKeyConvention;
+
+ public DefaultRelationshipConvention(IForeignKeyConvention foreignKeyConvention)
+ {
+ _foreignKeyConvention = foreignKeyConvention;
+ }
+
+ public void Apply(EntityMapping mapping, IMappingRegistry registry)
+ {
+ var type = mapping.Type;
+
+ foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
+ {
+ if (property.GetCustomAttribute() != null)
+ continue;
+
+ if (!IsNavigationProperty(property.PropertyType))
+ continue;
+
+ Type targetType;
+ RelationshipType relType;
+
+ if (typeof(IEnumerable).IsAssignableFrom(property.PropertyType) && property.PropertyType != typeof(string))
+ {
+ // One-to-Many or Many-to-Many
+ targetType = GetElementType(property.PropertyType);
+ relType = RelationshipType.OneToMany; // We default to OneToMany, ManyToMany requires advanced detection
+ }
+ else
+ {
+ // Many-to-One or One-to-One
+ targetType = property.PropertyType;
+ relType = RelationshipType.ManyToOne;
+ }
+
+ var relMapping = mapping.GetOrAddRelationship(property, targetType, relType);
+
+ // Infer Foreign Key
+ var fkAttr = property.GetCustomAttribute();
+ if (fkAttr != null)
+ {
+ relMapping.ForeignKey = fkAttr.Name;
+ }
+ else if (string.IsNullOrEmpty(relMapping.ForeignKey))
+ {
+ relMapping.ForeignKey = _foreignKeyConvention.GetForeignKeyName(property, targetType, relType, type);
+ }
+ }
+ }
+
+ private Type GetElementType(Type type)
+ {
+ if (type.IsArray)
+ return type.GetElementType()!;
+
+ if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>))
+ return type.GetGenericArguments()[0];
+
+ var enumerableInterface = type.GetInterfaces().FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>));
+ if (enumerableInterface != null)
+ return enumerableInterface.GetGenericArguments()[0];
+
+ return typeof(object);
+ }
+
+ private bool IsNavigationProperty(Type type)
+ {
+ if (type == typeof(string) || type == typeof(byte[]) || type.IsValueType || type.IsPrimitive)
+ return false;
+
+ if (Nullable.GetUnderlyingType(type) != null)
+ return false;
+
+ return true;
+ }
+}
diff --git a/src/FlexQuery.NET.Dapper/Conventions/IEntityConvention.cs b/src/FlexQuery.NET.Dapper/Conventions/IEntityConvention.cs
new file mode 100644
index 0000000..2691cf0
--- /dev/null
+++ b/src/FlexQuery.NET.Dapper/Conventions/IEntityConvention.cs
@@ -0,0 +1,11 @@
+using FlexQuery.NET.Dapper.Mapping.Metadata;
+
+namespace FlexQuery.NET.Dapper.Conventions;
+
+///
+/// Convention applied to entity mappings.
+///
+public interface IEntityConvention
+{
+ void Apply(EntityMapping mapping);
+}
diff --git a/src/FlexQuery.NET.Dapper/Conventions/IForeignKeyConvention.cs b/src/FlexQuery.NET.Dapper/Conventions/IForeignKeyConvention.cs
new file mode 100644
index 0000000..b267cee
--- /dev/null
+++ b/src/FlexQuery.NET.Dapper/Conventions/IForeignKeyConvention.cs
@@ -0,0 +1,12 @@
+using System.Reflection;
+using FlexQuery.NET.Dapper.Mapping.Metadata;
+
+namespace FlexQuery.NET.Dapper.Conventions;
+
+///
+/// Convention for inferring foreign key column names.
+///
+public interface IForeignKeyConvention
+{
+ string GetForeignKeyName(PropertyInfo navigationProperty, Type targetType, RelationshipType relationshipType, Type entityType);
+}
diff --git a/src/FlexQuery.NET.Dapper/Conventions/IPluralizer.cs b/src/FlexQuery.NET.Dapper/Conventions/IPluralizer.cs
new file mode 100644
index 0000000..af8677a
--- /dev/null
+++ b/src/FlexQuery.NET.Dapper/Conventions/IPluralizer.cs
@@ -0,0 +1,12 @@
+namespace FlexQuery.NET.Dapper.Conventions;
+
+///
+/// Service responsible for pluralizing entity names into table names.
+///
+public interface IPluralizer
+{
+ ///
+ /// Pluralizes a singular name.
+ ///
+ string Pluralize(string name);
+}
diff --git a/src/FlexQuery.NET.Dapper/Conventions/IRelationshipConvention.cs b/src/FlexQuery.NET.Dapper/Conventions/IRelationshipConvention.cs
new file mode 100644
index 0000000..3f1e06e
--- /dev/null
+++ b/src/FlexQuery.NET.Dapper/Conventions/IRelationshipConvention.cs
@@ -0,0 +1,12 @@
+using FlexQuery.NET.Dapper.Mapping;
+using FlexQuery.NET.Dapper.Mapping.Metadata;
+
+namespace FlexQuery.NET.Dapper.Conventions;
+
+///
+/// Convention applied to relationship mappings.
+///
+public interface IRelationshipConvention
+{
+ void Apply(EntityMapping mapping, IMappingRegistry registry);
+}
diff --git a/src/FlexQuery.NET.Dapper/DapperQueryOptions.cs b/src/FlexQuery.NET.Dapper/DapperQueryOptions.cs
new file mode 100644
index 0000000..4b99a30
--- /dev/null
+++ b/src/FlexQuery.NET.Dapper/DapperQueryOptions.cs
@@ -0,0 +1,119 @@
+using FlexQuery.NET.Models;
+using FlexQuery.NET.Dapper.Sql;
+using FlexQuery.NET.Dapper.Dialects;
+using FlexQuery.NET.Security;
+
+
+namespace FlexQuery.NET.Dapper;
+
+///
+/// Dapper-specific execution options that extends QueryExecutionOptions with SQL dialect configuration.
+///
+public sealed class DapperQueryOptions : BaseQueryOptions
+{
+ ///
+ /// Default constructor with Dapper-specific defaults.
+ ///
+ public DapperQueryOptions()
+ {
+ // Dapper defaults
+ IncludeTotalCount = true;
+ }
+
+ ///
+ /// Copy constructor - creates a new instance by copying all properties from source.
+ ///
+ /// The source options to copy.
+ public DapperQueryOptions(QueryExecutionOptions source)
+ {
+ // Copy all properties from the base options
+ MaxPageSize = source.MaxPageSize;
+ DefaultPageSize = source.DefaultPageSize;
+ CaseInsensitiveFields = source.CaseInsensitiveFields;
+ IncludeTotalCount = source.IncludeTotalCount;
+ StrictFieldValidation = source.StrictFieldValidation;
+ MaxFieldDepth = source.MaxFieldDepth;
+ AllowedFields = source.AllowedFields;
+ BlockedFields = source.BlockedFields;
+ AllowedIncludes = source.AllowedIncludes;
+ ExpressionMappings = source.ExpressionMappings;
+ FilterableFields = source.FilterableFields;
+ SortableFields = source.SortableFields;
+ SelectableFields = source.SelectableFields;
+
+ // Dapper-specific defaults
+ IncludeTotalCount = true;
+ }
+
+ public QueryExecutionOptions ToQueryExecutionOptions()
+ {
+ return new QueryExecutionOptions
+ {
+ MaxPageSize = this.MaxPageSize,
+ DefaultPageSize = this.DefaultPageSize,
+ CaseInsensitiveFields = this.CaseInsensitiveFields,
+ IncludeTotalCount = this.IncludeTotalCount,
+ StrictFieldValidation = this.StrictFieldValidation,
+ MaxFieldDepth = this.MaxFieldDepth,
+ AllowedFields = this.AllowedFields,
+ BlockedFields = this.BlockedFields,
+ AllowedIncludes = this.AllowedIncludes,
+ ExpressionMappings = this.ExpressionMappings,
+ FilterableFields = this.FilterableFields,
+ SortableFields = this.SortableFields,
+ SelectableFields = this.SelectableFields
+ };
+ }
+
+ /// Global default SQL dialect. If set, overrides the automatic connection-based resolution for all queries unless a specific query provides its own dialect.
+ public static ISqlDialect? GlobalDefaultDialect { get; set; }
+
+ /// Global default resolver for SQL dialects. Defaults to DefaultSqlDialectResolver.
+ public static ISqlDialectResolver GlobalDialectResolver { get; set; } = new DefaultSqlDialectResolver();
+
+ /// SQL dialect to use for query generation. If null, resolves via GlobalDefaultDialect, then GlobalDialectResolver.
+ public ISqlDialect? Dialect { get; set; }
+
+ /// Entity mapping registry. If null, a new empty registry is used by the translator.
+ public Mapping.IMappingRegistry MappingRegistry { get; set; } = new Mapping.MappingRegistry();
+
+ /// Command timeout in seconds.
+ public int CommandTimeoutSeconds { get; set; } = 30;
+
+ /// Explicitly set the entity type for mapping resolution. If null, use the generic type T from FlexQueryAsync.
+ public Type? EntityType { get; set; }
+
+ ///
+ /// Configures the mapping for a specific entity type using fluent builder API.
+ ///
+ public Mapping.Builders.EntityTypeBuilder Entity() where TEntity : class
+ {
+ return MappingRegistry.Entity();
+ }
+
+ ///
+ /// Scans the given assembly for types that match typical entity conventions
+ /// (e.g., classes that aren't abstract, are public, and perhaps have key properties).
+ ///
+ public void ScanEntitiesFromAssembly(System.Reflection.Assembly assembly)
+ {
+ var types = assembly.GetTypes()
+ .Where(t => t.IsClass && !t.IsAbstract && t.IsPublic);
+
+ foreach (var type in types)
+ {
+ // Only scan types that have an Id or Key property, or a Table attribute
+ var hasKey = type.GetProperties().Any(p =>
+ p.Name.Equals("Id", StringComparison.OrdinalIgnoreCase) ||
+ p.Name.Equals(type.Name + "Id", StringComparison.OrdinalIgnoreCase) ||
+ p.GetCustomAttributes(typeof(System.ComponentModel.DataAnnotations.KeyAttribute), true).Any());
+
+ var hasTable = type.GetCustomAttributes(typeof(System.ComponentModel.DataAnnotations.Schema.TableAttribute), true).Any();
+
+ if (hasKey || hasTable)
+ {
+ MappingRegistry.GetMapping(type);
+ }
+ }
+ }
+}
diff --git a/src/FlexQuery.NET.Dapper/Dialects/DefaultSqlDialectResolver.cs b/src/FlexQuery.NET.Dapper/Dialects/DefaultSqlDialectResolver.cs
new file mode 100644
index 0000000..ba6eaea
--- /dev/null
+++ b/src/FlexQuery.NET.Dapper/Dialects/DefaultSqlDialectResolver.cs
@@ -0,0 +1,37 @@
+using System.Data.Common;
+
+namespace FlexQuery.NET.Dapper.Dialects;
+
+///
+/// Default implementation of ISqlDialectResolver that inspects the connection type name.
+///
+public class DefaultSqlDialectResolver : ISqlDialectResolver
+{
+ public ISqlDialect Resolve(DbConnection connection)
+ {
+ var typeName = connection.GetType().Name;
+
+ if (typeName.Contains("NpgsqlConnection", StringComparison.OrdinalIgnoreCase))
+ return new PostgreSqlDialect();
+
+ if (typeName.Contains("SqliteConnection", StringComparison.OrdinalIgnoreCase))
+ return new SqliteDialect();
+
+ if (typeName.Contains("OracleConnection", StringComparison.OrdinalIgnoreCase))
+ return new OracleDialect();
+
+ // MariaDB Connector/NET uses MySqlConnection or sometimes MariaDbConnection depending on the library
+ if (typeName.Contains("MariaDbConnection", StringComparison.OrdinalIgnoreCase))
+ 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();
+ }
+}
diff --git a/src/FlexQuery.NET.Dapper/Dialects/ISqlDialect.cs b/src/FlexQuery.NET.Dapper/Dialects/ISqlDialect.cs
new file mode 100644
index 0000000..2832aa2
--- /dev/null
+++ b/src/FlexQuery.NET.Dapper/Dialects/ISqlDialect.cs
@@ -0,0 +1,41 @@
+namespace FlexQuery.NET.Dapper.Dialects;
+
+///
+/// Abstraction for SQL dialect-specific behavior.
+/// Each dialect encapsulates all provider-specific SQL generation concerns.
+///
+public interface ISqlDialect
+{
+ /// SQL parameter prefix (e.g., @ for SQL Server, : for PostgreSQL, ? for MySQL).
+ string ParameterPrefix { get; }
+
+ /// Wraps an identifier in quotes for the dialect (e.g., [Column], "Column", `Column`).
+ string QuoteIdentifier(string identifier);
+
+ /// Gets the COUNT expression for count queries.
+ string GetCountExpression { get; }
+
+ /// Gets the pagination clause (OFFSET/FETCH or LIMIT/OFFSET) for the dialect.
+ string GetPagingClause(string offsetParam, string limitParam);
+
+ /// Gets the SQL boolean literal for TRUE.
+ string BooleanTrue { get; }
+
+ /// Gets the SQL boolean literal for FALSE.
+ string BooleanFalse { get; }
+
+ /// Generates a string concatenation expression for the dialect.
+ string Concatenate(params string[] parts);
+
+ /// Generates a TOP/N limit expression for the dialect (used when only limit is needed without offset).
+ string GetLimitExpression(string limitParam);
+
+ /// Quote prefix for identifiers.
+ char QuotePrefix { get; }
+
+ /// Quote suffix for identifiers.
+ char QuoteSuffix { get; }
+
+ /// Creates a parameter name with the dialect's parameter prefix.
+ string CreateParameterName(string name);
+}
diff --git a/src/FlexQuery.NET.Dapper/Dialects/ISqlDialectResolver.cs b/src/FlexQuery.NET.Dapper/Dialects/ISqlDialectResolver.cs
new file mode 100644
index 0000000..50ec287
--- /dev/null
+++ b/src/FlexQuery.NET.Dapper/Dialects/ISqlDialectResolver.cs
@@ -0,0 +1,14 @@
+using System.Data.Common;
+
+namespace FlexQuery.NET.Dapper.Dialects;
+
+///
+/// Service responsible for automatically resolving the correct SQL dialect from a database connection.
+///
+public interface ISqlDialectResolver
+{
+ ///
+ /// Resolves the SQL dialect for the given database connection.
+ ///
+ ISqlDialect Resolve(DbConnection connection);
+}
diff --git a/src/FlexQuery.NET.Dapper/Dialects/MariaDbDialect.cs b/src/FlexQuery.NET.Dapper/Dialects/MariaDbDialect.cs
new file mode 100644
index 0000000..c4e26e7
--- /dev/null
+++ b/src/FlexQuery.NET.Dapper/Dialects/MariaDbDialect.cs
@@ -0,0 +1,48 @@
+namespace FlexQuery.NET.Dapper.Dialects;
+
+///
+/// MariaDB dialect implementation.
+///
+/// MariaDB is NOT a drop-in replacement for MySQL in all scenarios.
+/// This dedicated dialect handles MariaDB-specific behavior including:
+/// - Identifier escaping with backticks (same as MySQL)
+/// - Parameter prefix using ? (same as MySQL Connector/NET)
+/// - LIMIT/OFFSET pagination (same as MySQL)
+/// - String concatenation with CONCAT()
+/// - Boolean literal handling specific to MariaDB
+///
+/// NOTE: MariaDB has its own versioning, features, and behaviors that may
+/// diverge from MySQL. Use this dialect when connecting to MariaDB instances
+/// to ensure correct SQL generation for MariaDB-specific edge cases.
+///
+public sealed class MariaDbDialect : ISqlDialect
+{
+ /// MariaDB uses ? parameter prefix with MariaDB Connector/NET and MySqlConnector.
+ public string ParameterPrefix => "?";
+
+ public string GetCountExpression => "COUNT(1)";
+
+ /// MariaDB treats TRUE as 1 and FALSE as 0, but supports TRUE/FALSE keywords in SQL mode.
+ public string BooleanTrue => "TRUE";
+ public string BooleanFalse => "FALSE";
+
+ /// MariaDB uses backtick quoting for identifiers (same as MySQL).
+ public char QuotePrefix => '`';
+ public char QuoteSuffix => '`';
+
+ public string QuoteIdentifier(string identifier) => $"`{identifier}`";
+
+ /// MariaDB uses the same LIMIT/OFFSET pagination syntax as MySQL.
+ public string GetPagingClause(string offsetParam, string limitParam)
+ => $"LIMIT {limitParam} OFFSET {offsetParam}";
+
+ /// MariaDB supports LIMIT without OFFSET for top-N queries.
+ public string GetLimitExpression(string limitParam)
+ => $"LIMIT {limitParam}";
+
+ /// MariaDB uses CONCAT() function for string concatenation.
+ public string Concatenate(params string[] parts)
+ => "CONCAT(" + string.Join(", ", parts) + ")";
+
+ public string CreateParameterName(string name) => $"?{name}";
+}
\ No newline at end of file
diff --git a/src/FlexQuery.NET.Dapper/Dialects/MySqlDialect.cs b/src/FlexQuery.NET.Dapper/Dialects/MySqlDialect.cs
new file mode 100644
index 0000000..2050b10
--- /dev/null
+++ b/src/FlexQuery.NET.Dapper/Dialects/MySqlDialect.cs
@@ -0,0 +1,29 @@
+namespace FlexQuery.NET.Dapper.Dialects;
+
+///
+/// MySQL dialect implementation.
+/// Identifier escaping: `Column`
+/// Parameter prefix: ?
+///
+public sealed class MySqlDialect : ISqlDialect
+{
+ public string ParameterPrefix => "?";
+ public string GetCountExpression => "COUNT(1)";
+ public string BooleanTrue => "TRUE";
+ public string BooleanFalse => "FALSE";
+ public char QuotePrefix => '`';
+ public char QuoteSuffix => '`';
+
+ public string QuoteIdentifier(string identifier) => $"`{identifier}`";
+
+ public string GetPagingClause(string offsetParam, string limitParam)
+ => $"LIMIT {limitParam} OFFSET {offsetParam}";
+
+ public string GetLimitExpression(string limitParam)
+ => $"LIMIT {limitParam}";
+
+ public string Concatenate(params string[] parts)
+ => "CONCAT(" + string.Join(", ", parts) + ")";
+
+ public string CreateParameterName(string name) => $"?{name}";
+}
diff --git a/src/FlexQuery.NET.Dapper/Dialects/OracleDialect.cs b/src/FlexQuery.NET.Dapper/Dialects/OracleDialect.cs
new file mode 100644
index 0000000..78e40c5
--- /dev/null
+++ b/src/FlexQuery.NET.Dapper/Dialects/OracleDialect.cs
@@ -0,0 +1,49 @@
+namespace FlexQuery.NET.Dapper.Dialects;
+
+///
+/// Oracle dialect implementation.
+///
+/// Oracle Database uses specific SQL syntax:
+/// - Identifier escaping with double quotes (uppercased by default)
+/// - Parameter prefix: : (named parameters via OracleCommand)
+/// - Pagination: OFFSET/FETCH (Oracle 12c+)
+/// - String concatenation with || operator
+/// - Boolean handling: Oracle has no native BOOLEAN type in SQL;
+/// uses 1/0 or 'Y'/'N' patterns. Oracle does not support TRUE/FALSE
+/// keywords in SQL statements.
+///
+/// NOTE: For Oracle versions prior to 12c, OFFSET/FETCH is not supported.
+/// A ROW_NUMBER() based fallback may be needed for legacy Oracle versions.
+/// This implementation targets Oracle 12c and later.
+///
+public sealed class OracleDialect : ISqlDialect
+{
+ /// Oracle uses : parameter prefix for named parameters.
+ public string ParameterPrefix => ":";
+
+ public string GetCountExpression => "COUNT(1)";
+
+ /// Oracle does not have native TRUE/FALSE in SQL; uses 1 and 0.
+ public string BooleanTrue => "1";
+ public string BooleanFalse => "0";
+
+ /// Oracle uses double-quote identifier escaping; identifiers are case-sensitive when quoted.
+ public char QuotePrefix => '"';
+ public char QuoteSuffix => '"';
+
+ public string QuoteIdentifier(string identifier) => $"\"{identifier}\"";
+
+ /// Oracle 12c+ supports OFFSET/FETCH pagination syntax.
+ public string GetPagingClause(string offsetParam, string limitParam)
+ => $"OFFSET {offsetParam} ROWS FETCH NEXT {limitParam} ROWS ONLY";
+
+ /// Oracle 12c+ supports FETCH FIRST for top-N queries.
+ public string GetLimitExpression(string limitParam)
+ => $"FETCH FIRST {limitParam} ROWS ONLY";
+
+ /// Oracle uses || operator for string concatenation.
+ public string Concatenate(params string[] parts)
+ => string.Join(" || ", parts);
+
+ public string CreateParameterName(string name) => $":{name}";
+}
\ No newline at end of file
diff --git a/src/FlexQuery.NET.Dapper/Dialects/PostgreSqlDialect.cs b/src/FlexQuery.NET.Dapper/Dialects/PostgreSqlDialect.cs
new file mode 100644
index 0000000..a7abbc0
--- /dev/null
+++ b/src/FlexQuery.NET.Dapper/Dialects/PostgreSqlDialect.cs
@@ -0,0 +1,29 @@
+namespace FlexQuery.NET.Dapper.Dialects;
+
+///
+/// PostgreSQL dialect implementation.
+/// Identifier escaping: "Column"
+/// Parameter prefix: :
+///
+public sealed class PostgreSqlDialect : ISqlDialect
+{
+ public string ParameterPrefix => ":";
+ public string GetCountExpression => "COUNT(1)";
+ public string BooleanTrue => "TRUE";
+ public string BooleanFalse => "FALSE";
+ public char QuotePrefix => '"';
+ public char QuoteSuffix => '"';
+
+ public string QuoteIdentifier(string identifier) => $"\"{identifier}\"";
+
+ public string GetPagingClause(string offsetParam, string limitParam)
+ => $"LIMIT {limitParam} OFFSET {offsetParam}";
+
+ public string GetLimitExpression(string limitParam)
+ => $"LIMIT {limitParam}";
+
+ public string Concatenate(params string[] parts)
+ => string.Join(" || ", parts);
+
+ public string CreateParameterName(string name) => $":{name}";
+}
diff --git a/src/FlexQuery.NET.Dapper/Dialects/SqlServerDialect.cs b/src/FlexQuery.NET.Dapper/Dialects/SqlServerDialect.cs
new file mode 100644
index 0000000..556a1a8
--- /dev/null
+++ b/src/FlexQuery.NET.Dapper/Dialects/SqlServerDialect.cs
@@ -0,0 +1,30 @@
+namespace FlexQuery.NET.Dapper.Dialects;
+
+///
+/// SQL Server dialect implementation.
+/// Supports SQL Server 2012+ with OFFSET/FETCH pagination.
+/// Identifier escaping: [Column]
+/// Parameter prefix: @
+///
+public sealed class SqlServerDialect : ISqlDialect
+{
+ public string ParameterPrefix => "@";
+ public string GetCountExpression => "COUNT(1)";
+ public string BooleanTrue => "1";
+ public string BooleanFalse => "0";
+ public char QuotePrefix => '[';
+ public char QuoteSuffix => ']';
+
+ public string QuoteIdentifier(string identifier) => $"[{identifier}]";
+
+ public string GetPagingClause(string offsetParam, string limitParam)
+ => $"OFFSET {offsetParam} ROWS FETCH NEXT {limitParam} ROWS ONLY";
+
+ public string GetLimitExpression(string limitParam)
+ => $"TOP ({limitParam})";
+
+ public string Concatenate(params string[] parts)
+ => string.Join(" + ", parts);
+
+ public string CreateParameterName(string name) => $"@{name}";
+}
diff --git a/src/FlexQuery.NET.Dapper/Dialects/SqliteDialect.cs b/src/FlexQuery.NET.Dapper/Dialects/SqliteDialect.cs
new file mode 100644
index 0000000..bdb8244
--- /dev/null
+++ b/src/FlexQuery.NET.Dapper/Dialects/SqliteDialect.cs
@@ -0,0 +1,49 @@
+namespace FlexQuery.NET.Dapper.Dialects;
+
+///
+/// SQLite dialect implementation.
+///
+/// SQLite is commonly used for:
+/// - Integration testing
+/// - Demo APIs
+/// - Local development
+/// - In-memory testing
+///
+/// SQLite has specific behaviors:
+/// - Identifier escaping uses double quotes (ANSI SQL style)
+/// - Parameter prefix uses @ (consistent with Microsoft.Data.Sqlite)
+/// - LIMIT/OFFSET pagination (same as MySQL/PostgreSQL)
+/// - String concatenation uses the || operator
+/// - Boolean literals: 1 for TRUE, 0 for FALSE
+///
+public sealed class SqliteDialect : ISqlDialect
+{
+ /// SQLite uses @ parameter prefix with Microsoft.Data.Sqlite.
+ public string ParameterPrefix => "@";
+
+ public string GetCountExpression => "COUNT(1)";
+
+ /// SQLite does not have native TRUE/FALSE keywords; uses 1 and 0.
+ public string BooleanTrue => "1";
+ public string BooleanFalse => "0";
+
+ /// SQLite uses double-quote identifier escaping (ANSI SQL).
+ public char QuotePrefix => '"';
+ public char QuoteSuffix => '"';
+
+ public string QuoteIdentifier(string identifier) => $"\"{identifier}\"";
+
+ /// SQLite uses LIMIT/OFFSET for pagination.
+ public string GetPagingClause(string offsetParam, string limitParam)
+ => $"LIMIT {limitParam} OFFSET {offsetParam}";
+
+ /// SQLite supports LIMIT for top-N queries without OFFSET.
+ public string GetLimitExpression(string limitParam)
+ => $"LIMIT {limitParam}";
+
+ /// SQLite uses || operator for string concatenation (same as PostgreSQL).
+ public string Concatenate(params string[] parts)
+ => string.Join(" || ", parts);
+
+ public string CreateParameterName(string name) => $"@{name}";
+}
\ No newline at end of file
diff --git a/src/FlexQuery.NET.Dapper/FlexQuery.NET.Dapper.csproj b/src/FlexQuery.NET.Dapper/FlexQuery.NET.Dapper.csproj
new file mode 100644
index 0000000..8104b8b
--- /dev/null
+++ b/src/FlexQuery.NET.Dapper/FlexQuery.NET.Dapper.csproj
@@ -0,0 +1,62 @@
+
+
+
+ net6.0;net8.0;net10.0
+ false
+ enable
+ enable
+ latest
+ true
+
+
+ FlexQuery.NET.Dapper
+ Peter John Casasola
+ Peter John Casasola
+ FlexQuery.NET.Dapper
+
+ Dapper integration for FlexQuery.NET — async execution, filtered includes, projection, and pagination helpers
+ flexquery;dynamic;linq;iqueryable;;filtering;projection;pagination
+
+ MIT
+ https://github.com/peterjohncasasola/FlexQuery.NET
+ git
+ https://github.com/peterjohncasasola/FlexQuery.NET
+
+ README.md
+ logo.png
+
+ true
+ false
+ true
+ 3.0.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/FlexQuery.NET.Dapper/FlexQueryDapperExtensions.cs b/src/FlexQuery.NET.Dapper/FlexQueryDapperExtensions.cs
new file mode 100644
index 0000000..0c31538
--- /dev/null
+++ b/src/FlexQuery.NET.Dapper/FlexQueryDapperExtensions.cs
@@ -0,0 +1,274 @@
+using System.Data;
+using System.Data.Common;
+using Dapper;
+using FlexQuery.NET.Models;
+using FlexQuery.NET.Parsers;
+using FlexQuery.NET.Dapper.Sql;
+using FlexQuery.NET.Dapper.Sql.Translators;
+using FlexQuery.NET.Dapper.Dialects;
+using FlexQuery.NET.Extensions;
+using Microsoft.Extensions.Primitives;
+
+namespace FlexQuery.NET.Dapper;
+
+///
+/// Extension methods for executing FlexQuery requests with Dapper.
+///
+public static class FlexQueryDapperExtensions
+{
+ ///
+ /// Executes a FlexQuery using FlexQueryParameters with validation.
+ ///
+ public static async Task> FlexQueryAsync(
+ this DbConnection connection,
+ FlexQueryParameters parameters,
+ Action? configureDapper = null) where T : class
+ {
+ var dapperOptions = new DapperQueryOptions();
+ configureDapper?.Invoke(dapperOptions);
+
+ var parsedOptions = QueryOptionsParser.Parse(parameters);
+ parsedOptions.Items["EntityType"] = dapperOptions.EntityType ?? typeof(T);
+
+ var execOptions = dapperOptions.ToQueryExecutionOptions();
+
+ parsedOptions.ValidateOrThrow(dapperOptions.EntityType ?? typeof(T), execOptions);
+
+ return await ExecuteQueryAsync(connection, parsedOptions, dapperOptions);
+ }
+
+ ///
+ /// Executes a FlexQuery using FlexQueryParameters with full options.
+ ///
+ public static async Task> FlexQueryAsync(
+ this DbConnection connection,
+ FlexQueryParameters parameters,
+ DapperQueryOptions? dapperQueryOptions = null) where T : class
+ {
+ var dapperOptions = dapperQueryOptions ?? new DapperQueryOptions();
+ var parsedOptions = QueryOptionsParser.Parse(parameters);
+ parsedOptions.Items["EntityType"] = dapperOptions.EntityType ?? typeof(T);
+
+ var execOptions = dapperOptions.ToQueryExecutionOptions();
+
+ parsedOptions.ValidateOrThrow(dapperOptions.EntityType ?? typeof(T), execOptions);
+
+ return await ExecuteQueryAsync(connection, parsedOptions, dapperOptions);
+ }
+
+ ///
+ /// Executes a FlexQuery using raw query string parameters.
+ ///
+ public static async Task> FlexQueryAsync(
+ this DbConnection connection,
+ IDictionary parameters,
+ Action? configureDapper = null) where T : class
+ {
+ var dict = parameters.ToDictionary(k => k.Key, v => v.Value.ToString(), StringComparer.OrdinalIgnoreCase);
+
+ var flexParams = new FlexQueryParameters
+ {
+ Filter = dict.GetValueOrDefault("filter") ?? dict.GetValueOrDefault("$filter"),
+ Sort = dict.GetValueOrDefault("sort") ?? dict.GetValueOrDefault("orderby") ?? dict.GetValueOrDefault("$orderby"),
+ Select = dict.GetValueOrDefault("select") ?? dict.GetValueOrDefault("$select"),
+ Include = dict.GetValueOrDefault("include") ?? dict.GetValueOrDefault("expand") ?? dict.GetValueOrDefault("$expand"),
+ Page = dict.TryGetValue("page", out var p) && int.TryParse(p, out var page) ? page : null,
+ PageSize = dict.TryGetValue("pageSize", out var ps) && int.TryParse(ps, out var pageSize) ? pageSize : null,
+ RawParameters = dict
+ };
+
+ return await FlexQueryAsync(connection, flexParams, configureDapper);
+ }
+
+ private static async Task> ExecuteQueryAsync(
+ DbConnection connection,
+ QueryOptions options,
+ DapperQueryOptions execOptions) where T : class
+ {
+ var dialect = execOptions.Dialect
+ ?? DapperQueryOptions.GlobalDefaultDialect
+ ?? DapperQueryOptions.GlobalDialectResolver.Resolve(connection);
+
+ var registry = execOptions.MappingRegistry ?? new Mapping.MappingRegistry();
+
+ // Propagate EntityType to options for translator
+ if (execOptions.EntityType != null)
+ options.Items["EntityType"] = execOptions.EntityType;
+
+ var translator = new SqlTranslator(registry, dialect);
+ var command = translator.Translate(options);
+ var mapping = registry.GetMapping(execOptions.EntityType ?? typeof(T));
+
+ var parameters = new DynamicParameters();
+ foreach (var param in command.Parameters)
+ {
+ var cleanName = param.Key.TrimStart('@', ':', '?');
+ parameters.Add(cleanName, param.Value);
+ }
+
+ IReadOnlyList items;
+ if (options.Includes?.Count > 0)
+ {
+ var dynamicItems = await connection.QueryAsync(
+ command.Sql,
+ parameters,
+ commandTimeout: execOptions.CommandTimeoutSeconds,
+ commandType: CommandType.Text);
+
+ var parentMap = new Dictionary
-
+
+
+
-
-
-
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitiveall
-
+ runtime; build; native; contentfiles; analyzers; buildtransitiveall
-
+
-
-
-
+
+
+
+
+
+
diff --git a/tests/FlexQuery.NET.Tests/GlobalUsings.cs b/tests/FlexQuery.NET.Tests/GlobalUsings.cs
index c802f44..ad7ea98 100644
--- a/tests/FlexQuery.NET.Tests/GlobalUsings.cs
+++ b/tests/FlexQuery.NET.Tests/GlobalUsings.cs
@@ -1 +1,3 @@
global using Xunit;
+global using FlexQuery.NET.Tests.Models;
+global using FlexQuery.NET.Tests.Fixtures;
diff --git a/tests/FlexQuery.NET.Tests/MiniOData/MiniODataIntegrationTests.cs b/tests/FlexQuery.NET.Tests/MiniOData/MiniODataIntegrationTests.cs
new file mode 100644
index 0000000..8d4bf80
--- /dev/null
+++ b/tests/FlexQuery.NET.Tests/MiniOData/MiniODataIntegrationTests.cs
@@ -0,0 +1,89 @@
+using FlexQuery.NET.Models;
+using FlexQuery.NET.Parsers;
+using FlexQuery.NET.MiniOData.Parsers;
+using FluentAssertions;
+using Microsoft.Extensions.DependencyInjection;
+using FlexQuery.NET.MiniOData.Extensions;
+
+namespace FlexQuery.NET.Tests.MiniOData;
+
+public class MiniODataIntegrationTests
+{
+ public MiniODataIntegrationTests()
+ {
+ // Ensure MiniOData parser is registered for integration tests.
+ // This is normally handled by services.AddMiniOData() in a real app.
+ QueryOptionsParser.RegisterParser(new MiniODataParser());
+ }
+
+ [Fact]
+ public void AutoDetect_WithODataParameters_UsesMiniODataParser()
+ {
+ // Arrange
+ var parameters = new FlexQueryParameters
+ {
+ RawParameters = new Dictionary
+ {
+ ["$filter"] = "name eq 'john'",
+ ["$orderby"] = "age desc"
+ }
+ };
+
+ // Act
+ var options = QueryOptionsParser.Parse(parameters);
+
+ // Assert
+ options.Filter.Should().NotBeNull();
+ options.Filter!.Filters.Should().HaveCount(1);
+ options.Filter!.Filters[0].Field.Should().Be("name");
+ options.Filter!.Filters[0].Value.Should().Be("john");
+
+ options.Sort.Should().HaveCount(1);
+ options.Sort[0].Field.Should().Be("age");
+ options.Sort[0].Descending.Should().BeTrue();
+ }
+
+ [Fact]
+ public void AutoDetect_WithNativeParameters_UsesDslParser()
+ {
+ // Arrange
+ var parameters = new FlexQueryParameters
+ {
+ Filter = "name:eq:john",
+ Sort = "age:desc"
+ };
+
+ // Act
+ var options = QueryOptionsParser.Parse(parameters);
+
+ // Assert
+ options.Filter.Should().NotBeNull();
+ options.Filter!.Filters.Should().HaveCount(1);
+ options.Filter!.Filters[0].Field.Should().Be("name");
+ options.Filter!.Filters[0].Value.Should().Be("john");
+
+ options.Sort.Should().HaveCount(1);
+ options.Sort[0].Field.Should().Be("age");
+ options.Sort[0].Descending.Should().BeTrue();
+ }
+
+ [Fact]
+ public void ExplicitSyntax_OverridesAutoDetect()
+ {
+ // Arrange
+ var parameters = new FlexQueryParameters
+ {
+ Filter = "name eq 'john'" // OData syntax in Native DSL property
+ };
+
+ // Act & Assert
+ // This should fail or produce weird AST if parsed as DSL
+ var optionsDsl = QueryOptionsParser.Parse(parameters, QuerySyntax.NativeDsl);
+ // (In reality, DslParser might throw or ignore the 'eq')
+
+ // This should work if forced to OData
+ var optionsOData = QueryOptionsParser.Parse(parameters, QuerySyntax.MiniOData);
+ optionsOData.Filter.Should().NotBeNull();
+ optionsOData.Filter!.Filters[0].Value.Should().Be("john");
+ }
+}
diff --git a/tests/FlexQuery.NET.Tests/MiniOData/MiniODataQueryParserTests.cs b/tests/FlexQuery.NET.Tests/MiniOData/MiniODataQueryParserTests.cs
new file mode 100644
index 0000000..7456f57
--- /dev/null
+++ b/tests/FlexQuery.NET.Tests/MiniOData/MiniODataQueryParserTests.cs
@@ -0,0 +1,328 @@
+using FlexQuery.NET.MiniOData.Parsers;
+using FlexQuery.NET.Models;
+using FluentAssertions;
+
+namespace FlexQuery.NET.Tests.MiniOData;
+
+///
+/// Tests for the MiniODataQueryParser — the main entry point that parses
+/// all OData query parameters ($filter, $orderby, $select, $top, $skip, $expand, $count).
+///
+public class MiniODataQueryParserTests
+{
+ // ========================
+ // $filter
+ // ========================
+
+ [Fact]
+ public void Parse_Filter_TranslatesFilterExpression()
+ {
+ var queryParams = new Dictionary
+ {
+ ["$filter"] = "name eq 'john'"
+ };
+
+ var result = MiniODataQueryParser.Parse(queryParams);
+
+ result.Filter.Should().NotBeNull();
+ result.Filter!.Filters.Should().HaveCount(1);
+ result.Filter!.Filters[0].Field.Should().Be("name");
+ }
+
+ [Fact]
+ public void Parse_Filter_WithoutDollarPrefix_StillWorks()
+ {
+ var queryParams = new Dictionary
+ {
+ ["filter"] = "status eq 'active'"
+ };
+
+ var result = MiniODataQueryParser.Parse(queryParams);
+
+ result.Filter.Should().NotBeNull();
+ result.Filter!.Filters[0].Field.Should().Be("status");
+ }
+
+ // ========================
+ // $orderby
+ // ========================
+
+ [Fact]
+ public void Parse_OrderBy_SingleAsc_ProducesSortNode()
+ {
+ var queryParams = new Dictionary
+ {
+ ["$orderby"] = "name"
+ };
+
+ var result = MiniODataQueryParser.Parse(queryParams);
+
+ result.Sort.Should().HaveCount(1);
+ result.Sort[0].Field.Should().Be("name");
+ result.Sort[0].Descending.Should().BeFalse();
+ }
+
+ [Fact]
+ public void Parse_OrderBy_SingleDesc_ProducesDescendingSort()
+ {
+ var queryParams = new Dictionary
+ {
+ ["$orderby"] = "createdAt desc"
+ };
+
+ var result = MiniODataQueryParser.Parse(queryParams);
+
+ result.Sort.Should().HaveCount(1);
+ result.Sort[0].Field.Should().Be("createdAt");
+ result.Sort[0].Descending.Should().BeTrue();
+ }
+
+ [Fact]
+ public void Parse_OrderBy_Multiple_ProducesMultipleSortNodes()
+ {
+ var queryParams = new Dictionary
+ {
+ ["$orderby"] = "lastName asc, createdAt desc"
+ };
+
+ var result = MiniODataQueryParser.Parse(queryParams);
+
+ result.Sort.Should().HaveCount(2);
+ result.Sort[0].Field.Should().Be("lastName");
+ result.Sort[0].Descending.Should().BeFalse();
+ result.Sort[1].Field.Should().Be("createdAt");
+ result.Sort[1].Descending.Should().BeTrue();
+ }
+
+ [Fact]
+ public void Parse_OrderBy_SlashPath_ConvertsToDotNotation()
+ {
+ var queryParams = new Dictionary
+ {
+ ["$orderby"] = "address/city desc"
+ };
+
+ var result = MiniODataQueryParser.Parse(queryParams);
+
+ result.Sort[0].Field.Should().Be("address.city");
+ }
+
+ // ========================
+ // $select
+ // ========================
+
+ [Fact]
+ public void Parse_Select_CommaSeparatedFields()
+ {
+ var queryParams = new Dictionary
+ {
+ ["$select"] = "id,name,email"
+ };
+
+ var result = MiniODataQueryParser.Parse(queryParams);
+
+ result.Select.Should().BeEquivalentTo(new[] { "id", "name", "email" });
+ }
+
+ [Fact]
+ public void Parse_Select_SlashPaths_ConvertToDotNotation()
+ {
+ var queryParams = new Dictionary
+ {
+ ["$select"] = "id,profile/name,address/city"
+ };
+
+ var result = MiniODataQueryParser.Parse(queryParams);
+
+ result.Select.Should().Contain("profile.name");
+ result.Select.Should().Contain("address.city");
+ }
+
+ // ========================
+ // $top
+ // ========================
+
+ [Fact]
+ public void Parse_Top_SetsPageSize()
+ {
+ var queryParams = new Dictionary
+ {
+ ["$top"] = "10"
+ };
+
+ var result = MiniODataQueryParser.Parse(queryParams);
+
+ result.Paging.PageSize.Should().Be(10);
+ result.Top.Should().Be(10);
+ }
+
+ // ========================
+ // $skip
+ // ========================
+
+ [Fact]
+ public void Parse_Skip_SetsSkipCount()
+ {
+ var queryParams = new Dictionary
+ {
+ ["$skip"] = "20"
+ };
+
+ var result = MiniODataQueryParser.Parse(queryParams);
+
+ result.Skip.Should().Be(20);
+ }
+
+ [Fact]
+ public void Parse_SkipAndTop_CalculatesPage()
+ {
+ var queryParams = new Dictionary
+ {
+ ["$top"] = "10",
+ ["$skip"] = "20"
+ };
+
+ var result = MiniODataQueryParser.Parse(queryParams);
+
+ result.Paging.Page.Should().Be(3); // skip 20 / top 10 + 1 = page 3
+ result.Paging.PageSize.Should().Be(10);
+ }
+
+ // ========================
+ // $expand
+ // ========================
+
+ [Fact]
+ public void Parse_Expand_SingleNavigation()
+ {
+ var queryParams = new Dictionary
+ {
+ ["$expand"] = "orders"
+ };
+
+ var result = MiniODataQueryParser.Parse(queryParams);
+
+ result.Includes.Should().BeEquivalentTo(new[] { "orders" });
+ }
+
+ [Fact]
+ public void Parse_Expand_MultipleNavigations()
+ {
+ var queryParams = new Dictionary
+ {
+ ["$expand"] = "orders,profile,addresses"
+ };
+
+ var result = MiniODataQueryParser.Parse(queryParams);
+
+ result.Includes.Should().HaveCount(3);
+ result.Includes.Should().Contain("orders");
+ result.Includes.Should().Contain("profile");
+ result.Includes.Should().Contain("addresses");
+ }
+
+ // ========================
+ // $count
+ // ========================
+
+ [Fact]
+ public void Parse_Count_True()
+ {
+ var queryParams = new Dictionary
+ {
+ ["$count"] = "true"
+ };
+
+ var result = MiniODataQueryParser.Parse(queryParams);
+
+ result.IncludeCount.Should().BeTrue();
+ }
+
+ [Fact]
+ public void Parse_Count_False()
+ {
+ var queryParams = new Dictionary
+ {
+ ["$count"] = "false"
+ };
+
+ var result = MiniODataQueryParser.Parse(queryParams);
+
+ result.IncludeCount.Should().BeFalse();
+ }
+
+ // ========================
+ // Combined Parameters
+ // ========================
+
+ [Fact]
+ public void Parse_AllParameters_ProducesCompleteQueryOptions()
+ {
+ var queryParams = new Dictionary
+ {
+ ["$filter"] = "age gt 18 and status eq 'active'",
+ ["$orderby"] = "name asc, createdAt desc",
+ ["$select"] = "id,name,email",
+ ["$top"] = "25",
+ ["$skip"] = "50",
+ ["$expand"] = "orders,profile",
+ ["$count"] = "true"
+ };
+
+ var result = MiniODataQueryParser.Parse(queryParams);
+
+ result.Filter.Should().NotBeNull();
+ result.Filter!.Filters.Should().HaveCount(2);
+ result.Sort.Should().HaveCount(2);
+ result.Select.Should().HaveCount(3);
+ result.Paging.PageSize.Should().Be(25);
+ result.Skip.Should().Be(50);
+ result.Includes.Should().HaveCount(2);
+ result.IncludeCount.Should().BeTrue();
+ }
+
+ // ========================
+ // Edge Cases
+ // ========================
+
+ [Fact]
+ public void Parse_EmptyParams_ReturnsDefaultQueryOptions()
+ {
+ var queryParams = new Dictionary();
+
+ var result = MiniODataQueryParser.Parse(queryParams);
+
+ result.Filter.Should().BeNull();
+ result.Sort.Should().BeEmpty();
+ result.Select.Should().BeNull();
+ result.Includes.Should().BeNull();
+ }
+
+ [Fact]
+ public void Parse_CaseInsensitiveKeys()
+ {
+ var queryParams = new Dictionary
+ {
+ ["$Filter"] = "name eq 'test'",
+ ["$OrderBy"] = "id desc"
+ };
+
+ var result = MiniODataQueryParser.Parse(queryParams);
+
+ result.Filter.Should().NotBeNull();
+ result.Sort.Should().HaveCount(1);
+ }
+
+ [Fact]
+ public void Parse_InvalidTop_IsIgnored()
+ {
+ var queryParams = new Dictionary
+ {
+ ["$top"] = "not_a_number"
+ };
+
+ var result = MiniODataQueryParser.Parse(queryParams);
+
+ result.Top.Should().BeNull();
+ }
+}
diff --git a/tests/FlexQuery.NET.Tests/MiniOData/ODataDslEquivalenceTests.cs b/tests/FlexQuery.NET.Tests/MiniOData/ODataDslEquivalenceTests.cs
new file mode 100644
index 0000000..ba7d8ce
--- /dev/null
+++ b/tests/FlexQuery.NET.Tests/MiniOData/ODataDslEquivalenceTests.cs
@@ -0,0 +1,295 @@
+using FlexQuery.NET.Constants;
+using FlexQuery.NET.MiniOData.Parsers;
+using FlexQuery.NET.Models;
+using FlexQuery.NET.Parsers;
+using FlexQuery.NET.Parsers.Dsl;
+using FluentAssertions;
+
+namespace FlexQuery.NET.Tests.MiniOData;
+
+///
+/// Equivalence tests verifying that Native DSL and Mini OData syntaxes
+/// produce semantically identical AST structures.
+/// This is the core contract: same query semantics regardless of input syntax.
+///
+public class ODataDslEquivalenceTests
+{
+ // ========================
+ // Simple Equality
+ // ========================
+
+ [Fact]
+ public void Equality_NativeDsl_And_OData_ProduceEquivalentAst()
+ {
+ // Native DSL
+ var dslFilter = ParseDsl("name:eq:john");
+
+ // Mini OData
+ var odataFilter = ODataFilterParser.Parse("name eq 'john'");
+
+ AssertEquivalentFilter(dslFilter, odataFilter, "name", FilterOperators.Equal, "john");
+ }
+
+ // ========================
+ // Contains
+ // ========================
+
+ [Fact]
+ public void Contains_NativeDsl_And_OData_ProduceEquivalentAst()
+ {
+ var dslFilter = ParseDsl("name:contains:john");
+ var odataFilter = ODataFilterParser.Parse("contains(name,'john')");
+
+ AssertEquivalentFilter(dslFilter, odataFilter, "name", FilterOperators.Contains, "john");
+ }
+
+ // ========================
+ // StartsWith
+ // ========================
+
+ [Fact]
+ public void StartsWith_NativeDsl_And_OData_ProduceEquivalentAst()
+ {
+ var dslFilter = ParseDsl("name:startswith:jo");
+ var odataFilter = ODataFilterParser.Parse("startswith(name,'jo')");
+
+ AssertEquivalentFilter(dslFilter, odataFilter, "name", FilterOperators.StartsWith, "jo");
+ }
+
+ // ========================
+ // EndsWith
+ // ========================
+
+ [Fact]
+ public void EndsWith_NativeDsl_And_OData_ProduceEquivalentAst()
+ {
+ var dslFilter = ParseDsl("email:endswith:.com");
+ var odataFilter = ODataFilterParser.Parse("endswith(email,'.com')");
+
+ AssertEquivalentFilter(dslFilter, odataFilter, "email", FilterOperators.EndsWith, ".com");
+ }
+
+ // ========================
+ // Greater Than
+ // ========================
+
+ [Fact]
+ public void GreaterThan_NativeDsl_And_OData_ProduceEquivalentAst()
+ {
+ var dslFilter = ParseDsl("age:gt:18");
+ var odataFilter = ODataFilterParser.Parse("age gt 18");
+
+ AssertEquivalentFilter(dslFilter, odataFilter, "age", FilterOperators.GreaterThan, "18");
+ }
+
+ // ========================
+ // Compound AND
+ // ========================
+
+ [Fact]
+ public void CompoundAnd_NativeDsl_And_OData_ProduceEquivalentAst()
+ {
+ var dslFilter = ParseDsl("age:gt:18&status:eq:active");
+ var odataFilter = ODataFilterParser.Parse("age gt 18 and status eq 'active'");
+
+ // Both should produce AND groups with 2 conditions
+ dslFilter.Logic.Should().Be(LogicOperator.And);
+ odataFilter.Logic.Should().Be(LogicOperator.And);
+
+ dslFilter.Filters.Should().HaveCount(2);
+ odataFilter.Filters.Should().HaveCount(2);
+
+ // Verify field names match
+ dslFilter.Filters[0].Field.Should().Be(odataFilter.Filters[0].Field);
+ dslFilter.Filters[1].Field.Should().Be(odataFilter.Filters[1].Field);
+
+ // Verify operators match
+ dslFilter.Filters[0].Operator.Should().Be(odataFilter.Filters[0].Operator);
+ dslFilter.Filters[1].Operator.Should().Be(odataFilter.Filters[1].Operator);
+ }
+
+ // ========================
+ // Compound OR
+ // ========================
+
+ [Fact]
+ public void CompoundOr_NativeDsl_And_OData_ProduceEquivalentAst()
+ {
+ var dslFilter = ParseDsl("status:eq:active|status:eq:pending");
+ var odataFilter = ODataFilterParser.Parse("status eq 'active' or status eq 'pending'");
+
+ dslFilter.Logic.Should().Be(LogicOperator.Or);
+ odataFilter.Logic.Should().Be(LogicOperator.Or);
+
+ dslFilter.Filters.Should().HaveCount(2);
+ odataFilter.Filters.Should().HaveCount(2);
+ }
+
+ // ========================
+ // Null Check
+ // ========================
+
+ [Fact]
+ public void NullCheck_NativeDsl_And_OData_ProduceEquivalentAst()
+ {
+ var dslFilter = ParseDsl("deletedAt:isnull");
+ var odataFilter = ODataFilterParser.Parse("deletedAt eq null");
+
+ dslFilter.Filters[0].Field.Should().Be(odataFilter.Filters[0].Field);
+ dslFilter.Filters[0].Operator.Should().Be(FilterOperators.IsNull);
+ odataFilter.Filters[0].Operator.Should().Be(FilterOperators.IsNull);
+ }
+
+ // ========================
+ // Any (Relationship)
+ // ========================
+
+ [Fact]
+ public void AnyRelationship_NativeDsl_And_OData_ProduceEquivalentAst()
+ {
+ var dslFilter = ParseDsl("orders.any(status:eq:Cancelled)");
+ var odataFilter = ODataFilterParser.Parse("orders/any(o: o/status eq 'Cancelled')");
+
+ // Both should produce a FilterCondition with operator = "any"
+ dslFilter.Filters[0].Field.Should().Be("orders");
+ odataFilter.Filters[0].Field.Should().Be("orders");
+
+ dslFilter.Filters[0].Operator.Should().Be("any");
+ odataFilter.Filters[0].Operator.Should().Be("any");
+
+ // Both should have a scoped filter
+ dslFilter.Filters[0].ScopedFilter.Should().NotBeNull();
+ odataFilter.Filters[0].ScopedFilter.Should().NotBeNull();
+
+ // Inner scoped filter: status eq 'Cancelled'
+ dslFilter.Filters[0].ScopedFilter!.Filters[0].Field.Should().Be("status");
+ odataFilter.Filters[0].ScopedFilter!.Filters[0].Field.Should().Be("status");
+
+ dslFilter.Filters[0].ScopedFilter!.Filters[0].Value.Should().Be("Cancelled");
+ odataFilter.Filters[0].ScopedFilter!.Filters[0].Value.Should().Be("Cancelled");
+ }
+
+ // ========================
+ // Negation
+ // ========================
+
+ [Fact]
+ public void Negation_NativeDsl_And_OData_ProduceEquivalentAst()
+ {
+ var dslFilter = ParseDsl("!(status:eq:deleted)");
+ var odataFilter = ODataFilterParser.Parse("not (status eq 'deleted')");
+
+ dslFilter.IsNegated.Should().BeTrue();
+ odataFilter.IsNegated.Should().BeTrue();
+
+ dslFilter.Filters[0].Field.Should().Be("status");
+ odataFilter.Filters[0].Field.Should().Be("status");
+ }
+
+ // ========================
+ // OrderBy Equivalence
+ // ========================
+
+ [Fact]
+ public void OrderBy_NativeDsl_And_OData_ProduceEquivalentSortNodes()
+ {
+ // Native DSL: sort=createdAt:desc
+ var nativeParams = new FlexQueryParameters { Sort = "createdAt:desc" };
+ var nativeOptions = QueryOptionsParser.Parse(nativeParams);
+
+ // Mini OData: $orderby=createdAt desc
+ var odataParams = new Dictionary { ["$orderby"] = "createdAt desc" };
+ var odataOptions = MiniODataQueryParser.Parse(odataParams);
+
+ nativeOptions.Sort.Should().HaveCount(1);
+ odataOptions.Sort.Should().HaveCount(1);
+
+ nativeOptions.Sort[0].Field.Should().Be(odataOptions.Sort[0].Field);
+ nativeOptions.Sort[0].Descending.Should().Be(odataOptions.Sort[0].Descending);
+ }
+
+ // ========================
+ // Select Equivalence
+ // ========================
+
+ [Fact]
+ public void Select_NativeDsl_And_OData_ProduceEquivalentProjection()
+ {
+ // Native DSL: select=id,name,email
+ var nativeParams = new FlexQueryParameters { Select = "id,name,email" };
+ var nativeOptions = QueryOptionsParser.Parse(nativeParams);
+
+ // Mini OData: $select=id,name,email
+ var odataParams = new Dictionary { ["$select"] = "id,name,email" };
+ var odataOptions = MiniODataQueryParser.Parse(odataParams);
+
+ nativeOptions.Select.Should().BeEquivalentTo(odataOptions.Select);
+ }
+
+ // ========================
+ // Pagination Equivalence
+ // ========================
+
+ [Fact]
+ public void Pagination_NativeDsl_And_OData_ProduceEquivalentPaging()
+ {
+ // Native DSL: page=3&pageSize=10
+ var nativeParams = new FlexQueryParameters { Page = 3, PageSize = 10 };
+ var nativeOptions = QueryOptionsParser.Parse(nativeParams);
+
+ // Mini OData: $top=10&$skip=20 (page 3 with size 10 = skip 20)
+ var odataParams = new Dictionary
+ {
+ ["$top"] = "10",
+ ["$skip"] = "20"
+ };
+ var odataOptions = MiniODataQueryParser.Parse(odataParams);
+
+ nativeOptions.Paging.PageSize.Should().Be(odataOptions.Paging.PageSize);
+ nativeOptions.Paging.Page.Should().Be(odataOptions.Paging.Page);
+ }
+
+ // ========================
+ // Include / Expand Equivalence
+ // ========================
+
+ [Fact]
+ public void Include_NativeDsl_And_OData_ProduceEquivalentIncludes()
+ {
+ // Native DSL: include=orders,profile
+ var nativeParams = new FlexQueryParameters { Include = "orders,profile" };
+ var nativeOptions = QueryOptionsParser.Parse(nativeParams);
+
+ // Mini OData: $expand=orders,profile
+ var odataParams = new Dictionary { ["$expand"] = "orders,profile" };
+ var odataOptions = MiniODataQueryParser.Parse(odataParams);
+
+ nativeOptions.Includes.Should().BeEquivalentTo(odataOptions.Includes);
+ }
+
+ // ========================
+ // Helpers
+ // ========================
+
+ private static FilterGroup ParseDsl(string dsl)
+ {
+ var ast = DslParser.Parse(dsl);
+ return DslFilterConverter.ToFilterGroup(ast);
+ }
+
+ private static void AssertEquivalentFilter(FilterGroup dsl, FilterGroup odata,
+ string expectedField, string expectedOp, string expectedValue)
+ {
+ dsl.Filters.Should().HaveCount(1);
+ odata.Filters.Should().HaveCount(1);
+
+ dsl.Filters[0].Field.Should().Be(expectedField);
+ odata.Filters[0].Field.Should().Be(expectedField);
+
+ dsl.Filters[0].Operator.Should().Be(expectedOp);
+ odata.Filters[0].Operator.Should().Be(expectedOp);
+
+ dsl.Filters[0].Value.Should().Be(expectedValue);
+ odata.Filters[0].Value.Should().Be(expectedValue);
+ }
+}
diff --git a/tests/FlexQuery.NET.Tests/MiniOData/ODataFilterParserTests.cs b/tests/FlexQuery.NET.Tests/MiniOData/ODataFilterParserTests.cs
new file mode 100644
index 0000000..4dc50e2
--- /dev/null
+++ b/tests/FlexQuery.NET.Tests/MiniOData/ODataFilterParserTests.cs
@@ -0,0 +1,337 @@
+using FlexQuery.NET.Constants;
+using FlexQuery.NET.MiniOData.Parsers;
+using FluentAssertions;
+
+namespace FlexQuery.NET.Tests.MiniOData;
+
+///
+/// Tests for the OData filter expression parser.
+/// Validates that all supported OData $filter syntax correctly produces
+/// the unified FlexQuery FilterGroup AST.
+///
+public class ODataFilterParserTests
+{
+ // ========================
+ // Simple Comparisons
+ // ========================
+
+ [Fact]
+ public void Parse_EqualString_ProducesCorrectFilterGroup()
+ {
+ var result = ODataFilterParser.Parse("name eq 'john'");
+
+ result.Filters.Should().HaveCount(1);
+ result.Filters[0].Field.Should().Be("name");
+ result.Filters[0].Operator.Should().Be(FilterOperators.Equal);
+ result.Filters[0].Value.Should().Be("john");
+ }
+
+ [Fact]
+ public void Parse_NotEqual_ProducesNeqOperator()
+ {
+ var result = ODataFilterParser.Parse("status ne 'inactive'");
+
+ result.Filters.Should().HaveCount(1);
+ result.Filters[0].Field.Should().Be("status");
+ result.Filters[0].Operator.Should().Be(FilterOperators.NotEqual);
+ result.Filters[0].Value.Should().Be("inactive");
+ }
+
+ [Fact]
+ public void Parse_GreaterThan_ProducesGtOperator()
+ {
+ var result = ODataFilterParser.Parse("age gt 18");
+
+ result.Filters.Should().HaveCount(1);
+ result.Filters[0].Field.Should().Be("age");
+ result.Filters[0].Operator.Should().Be(FilterOperators.GreaterThan);
+ result.Filters[0].Value.Should().Be("18");
+ }
+
+ [Fact]
+ public void Parse_GreaterThanOrEqual_ProducesGteOperator()
+ {
+ var result = ODataFilterParser.Parse("price ge 9.99");
+
+ result.Filters.Should().HaveCount(1);
+ result.Filters[0].Operator.Should().Be(FilterOperators.GreaterThanOrEq);
+ result.Filters[0].Value.Should().Be("9.99");
+ }
+
+ [Fact]
+ public void Parse_LessThan_ProducesLtOperator()
+ {
+ var result = ODataFilterParser.Parse("quantity lt 100");
+
+ result.Filters.Should().HaveCount(1);
+ result.Filters[0].Operator.Should().Be(FilterOperators.LessThan);
+ result.Filters[0].Value.Should().Be("100");
+ }
+
+ [Fact]
+ public void Parse_LessThanOrEqual_ProducesLteOperator()
+ {
+ var result = ODataFilterParser.Parse("score le 50");
+
+ result.Filters.Should().HaveCount(1);
+ result.Filters[0].Operator.Should().Be(FilterOperators.LessThanOrEq);
+ }
+
+ // ========================
+ // Boolean & Null Values
+ // ========================
+
+ [Fact]
+ public void Parse_BooleanTrue_ProducesTrueValue()
+ {
+ var result = ODataFilterParser.Parse("isActive eq true");
+
+ result.Filters.Should().HaveCount(1);
+ result.Filters[0].Value.Should().Be("true");
+ }
+
+ [Fact]
+ public void Parse_BooleanFalse_ProducesFalseValue()
+ {
+ var result = ODataFilterParser.Parse("isDeleted eq false");
+
+ result.Filters.Should().HaveCount(1);
+ result.Filters[0].Value.Should().Be("false");
+ }
+
+ [Fact]
+ public void Parse_NullCheck_ProducesIsNullOperator()
+ {
+ var result = ODataFilterParser.Parse("deletedAt eq null");
+
+ result.Filters.Should().HaveCount(1);
+ result.Filters[0].Operator.Should().Be(FilterOperators.IsNull);
+ }
+
+ [Fact]
+ public void Parse_NotNullCheck_ProducesIsNotNullOperator()
+ {
+ var result = ODataFilterParser.Parse("email ne null");
+
+ result.Filters.Should().HaveCount(1);
+ result.Filters[0].Operator.Should().Be(FilterOperators.IsNotNull);
+ }
+
+ // ========================
+ // Function Calls
+ // ========================
+
+ [Fact]
+ public void Parse_Contains_ProducesContainsOperator()
+ {
+ var result = ODataFilterParser.Parse("contains(name,'john')");
+
+ result.Filters.Should().HaveCount(1);
+ result.Filters[0].Field.Should().Be("name");
+ result.Filters[0].Operator.Should().Be(FilterOperators.Contains);
+ result.Filters[0].Value.Should().Be("john");
+ }
+
+ [Fact]
+ public void Parse_StartsWith_ProducesStartsWithOperator()
+ {
+ var result = ODataFilterParser.Parse("startswith(name,'jo')");
+
+ result.Filters.Should().HaveCount(1);
+ result.Filters[0].Operator.Should().Be(FilterOperators.StartsWith);
+ result.Filters[0].Value.Should().Be("jo");
+ }
+
+ [Fact]
+ public void Parse_EndsWith_ProducesEndsWithOperator()
+ {
+ var result = ODataFilterParser.Parse("endswith(email,'.com')");
+
+ result.Filters.Should().HaveCount(1);
+ result.Filters[0].Operator.Should().Be(FilterOperators.EndsWith);
+ result.Filters[0].Value.Should().Be(".com");
+ }
+
+ // ========================
+ // Logical Operators
+ // ========================
+
+ [Fact]
+ public void Parse_And_CombinesFiltersWithAndLogic()
+ {
+ var result = ODataFilterParser.Parse("age gt 18 and status eq 'active'");
+
+ result.Logic.Should().Be(FlexQuery.NET.Models.LogicOperator.And);
+ result.Filters.Should().HaveCount(2);
+ result.Filters[0].Field.Should().Be("age");
+ result.Filters[1].Field.Should().Be("status");
+ }
+
+ [Fact]
+ public void Parse_Or_CombinesFiltersWithOrLogic()
+ {
+ var result = ODataFilterParser.Parse("status eq 'active' or status eq 'pending'");
+
+ result.Logic.Should().Be(FlexQuery.NET.Models.LogicOperator.Or);
+ result.Filters.Should().HaveCount(2);
+ }
+
+ [Fact]
+ public void Parse_Not_NegatesInnerGroup()
+ {
+ var result = ODataFilterParser.Parse("not (status eq 'deleted')");
+
+ result.IsNegated.Should().BeTrue();
+ result.Filters.Should().HaveCount(1);
+ result.Filters[0].Field.Should().Be("status");
+ }
+
+ [Fact]
+ public void Parse_ComplexLogic_MixedAndOr()
+ {
+ var result = ODataFilterParser.Parse("name eq 'john' and (age gt 18 or status eq 'vip')");
+
+ // Should have AND at top level
+ result.Logic.Should().Be(FlexQuery.NET.Models.LogicOperator.And);
+ }
+
+ // ========================
+ // Grouping (Parentheses)
+ // ========================
+
+ [Fact]
+ public void Parse_Parentheses_RespectsGrouping()
+ {
+ var result = ODataFilterParser.Parse("(status eq 'active' or status eq 'pending') and age gt 18");
+
+ result.Logic.Should().Be(FlexQuery.NET.Models.LogicOperator.And);
+ }
+
+ // ========================
+ // Nested Property Paths
+ // ========================
+
+ [Fact]
+ public void Parse_SlashPath_ConvertsToDotNotation()
+ {
+ var result = ODataFilterParser.Parse("address/city eq 'NYC'");
+
+ result.Filters.Should().HaveCount(1);
+ result.Filters[0].Field.Should().Be("address.city");
+ }
+
+ [Fact]
+ public void Parse_DeepPath_ConvertsMultiLevelPath()
+ {
+ var result = ODataFilterParser.Parse("contains(profile/address/city,'York')");
+
+ result.Filters.Should().HaveCount(1);
+ result.Filters[0].Field.Should().Be("profile.address.city");
+ }
+
+ // ========================
+ // Lambda Navigation (any/all)
+ // ========================
+
+ [Fact]
+ public void Parse_AnyLambda_ProducesAnyOperator()
+ {
+ var result = ODataFilterParser.Parse("orders/any(o: o/status eq 'Cancelled')");
+
+ result.Filters.Should().HaveCount(1);
+ result.Filters[0].Field.Should().Be("orders");
+ result.Filters[0].Operator.Should().Be("any");
+ result.Filters[0].ScopedFilter.Should().NotBeNull();
+ result.Filters[0].ScopedFilter!.Filters.Should().HaveCount(1);
+ result.Filters[0].ScopedFilter!.Filters[0].Field.Should().Be("status");
+ result.Filters[0].ScopedFilter!.Filters[0].Value.Should().Be("Cancelled");
+ }
+
+ [Fact]
+ public void Parse_AllLambda_ProducesAllOperator()
+ {
+ var result = ODataFilterParser.Parse("items/all(i: i/price gt 10)");
+
+ result.Filters.Should().HaveCount(1);
+ result.Filters[0].Field.Should().Be("items");
+ result.Filters[0].Operator.Should().Be("all");
+ result.Filters[0].ScopedFilter.Should().NotBeNull();
+ }
+
+ [Fact]
+ public void Parse_EmptyAny_ProducesAnyWithNoFilter()
+ {
+ var result = ODataFilterParser.Parse("orders/any()");
+
+ result.Filters.Should().HaveCount(1);
+ result.Filters[0].Operator.Should().Be("any");
+ result.Filters[0].ScopedFilter.Should().BeNull();
+ }
+
+ // ========================
+ // IN operator
+ // ========================
+
+ [Fact]
+ public void Parse_InOperator_ProducesInWithCommaSeparatedValues()
+ {
+ var result = ODataFilterParser.Parse("status in ('active','pending','review')");
+
+ result.Filters.Should().HaveCount(1);
+ result.Filters[0].Operator.Should().Be(FilterOperators.In);
+ result.Filters[0].Value.Should().Be("active,pending,review");
+ }
+
+ // ========================
+ // Edge Cases
+ // ========================
+
+ [Fact]
+ public void Parse_EmptyString_ReturnsEmptyFilterGroup()
+ {
+ var result = ODataFilterParser.Parse("");
+ result.Filters.Should().BeEmpty();
+ result.Groups.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void Parse_WhitespaceOnly_ReturnsEmptyFilterGroup()
+ {
+ var result = ODataFilterParser.Parse(" ");
+ result.Filters.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void Parse_EscapedQuote_PreservesQuoteInValue()
+ {
+ var result = ODataFilterParser.Parse("name eq 'O''Brien'");
+
+ result.Filters.Should().HaveCount(1);
+ result.Filters[0].Value.Should().Be("O'Brien");
+ }
+
+ [Fact]
+ public void Parse_NegativeNumber_ParsesCorrectly()
+ {
+ var result = ODataFilterParser.Parse("balance lt -100");
+
+ result.Filters.Should().HaveCount(1);
+ result.Filters[0].Value.Should().Be("-100");
+ }
+
+ [Fact]
+ public void Parse_InvalidExpression_ThrowsParseException()
+ {
+ Action act = () => ODataFilterParser.Parse("name INVALID 'test'");
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void Parse_MultipleAndsFlattened_ProducesAllConditions()
+ {
+ var result = ODataFilterParser.Parse("a eq '1' and b eq '2' and c eq '3'");
+
+ result.Logic.Should().Be(FlexQuery.NET.Models.LogicOperator.And);
+ result.Filters.Should().HaveCount(3);
+ }
+}
diff --git a/tests/FlexQuery.NET.Tests/Models/SqlAddress.cs b/tests/FlexQuery.NET.Tests/Models/SqlAddress.cs
new file mode 100644
index 0000000..c388070
--- /dev/null
+++ b/tests/FlexQuery.NET.Tests/Models/SqlAddress.cs
@@ -0,0 +1,9 @@
+namespace FlexQuery.NET.Tests.Models;
+
+public sealed class SqlAddress
+{
+ public int Id { get; set; }
+ public string City { get; set; } = string.Empty;
+ public int CustomerId { get; set; }
+ public SqlCustomer Customer { get; set; } = null!;
+}
diff --git a/tests/FlexQuery.NET.Tests/Models/SqlCustomer.cs b/tests/FlexQuery.NET.Tests/Models/SqlCustomer.cs
new file mode 100644
index 0000000..fc5f0f7
--- /dev/null
+++ b/tests/FlexQuery.NET.Tests/Models/SqlCustomer.cs
@@ -0,0 +1,10 @@
+namespace FlexQuery.NET.Tests.Models;
+
+public sealed class SqlCustomer
+{
+ public int Id { get; set; }
+ public string? Name { get; set; }
+ public string? Email { get; set; }
+ public SqlAddress? Address { get; set; }
+ public List Orders { get; set; } = [];
+}
diff --git a/tests/FlexQuery.NET.Tests/Models/SqlOrder.cs b/tests/FlexQuery.NET.Tests/Models/SqlOrder.cs
new file mode 100644
index 0000000..361e029
--- /dev/null
+++ b/tests/FlexQuery.NET.Tests/Models/SqlOrder.cs
@@ -0,0 +1,12 @@
+namespace FlexQuery.NET.Tests.Models;
+
+public sealed class SqlOrder
+{
+ public int Id { get; set; }
+ public string Number { get; set; } = string.Empty;
+ public decimal Total { get; set; }
+ public DateTime OrderDate { get; set; }
+ public int CustomerId { get; set; }
+ public SqlCustomer Customer { get; set; } = null!;
+ public List Items { get; set; } = [];
+}
diff --git a/tests/FlexQuery.NET.Tests/Models/SqlOrderItem.cs b/tests/FlexQuery.NET.Tests/Models/SqlOrderItem.cs
new file mode 100644
index 0000000..07a47f8
--- /dev/null
+++ b/tests/FlexQuery.NET.Tests/Models/SqlOrderItem.cs
@@ -0,0 +1,9 @@
+namespace FlexQuery.NET.Tests.Models;
+
+public sealed class SqlOrderItem
+{
+ public int Id { get; set; }
+ public string Sku { get; set; } = string.Empty;
+ public int OrderId { get; set; }
+ public SqlOrder Order { get; set; } = null!;
+}
diff --git a/tests/FlexQuery.NET.Tests/Tests/ParserTests.cs b/tests/FlexQuery.NET.Tests/Tests/ParserTests.cs
index 64f3971..dc897d2 100644
--- a/tests/FlexQuery.NET.Tests/Tests/ParserTests.cs
+++ b/tests/FlexQuery.NET.Tests/Tests/ParserTests.cs
@@ -2,6 +2,7 @@
using FlexQuery.NET.Models;
using FlexQuery.NET.Parsers;
using FlexQuery.NET.Parsers.Jql;
+using JqlParser = FlexQuery.NET.Parsers.Jql.JqlParser;
using FluentAssertions;
using Microsoft.Extensions.Primitives;
diff --git a/tests/FlexQuery.NET.Tests/Tests/WildcardProjectionTests.cs b/tests/FlexQuery.NET.Tests/Tests/WildcardProjectionTests.cs
index b1e92fd..39720ef 100644
--- a/tests/FlexQuery.NET.Tests/Tests/WildcardProjectionTests.cs
+++ b/tests/FlexQuery.NET.Tests/Tests/WildcardProjectionTests.cs
@@ -43,7 +43,7 @@ public async Task ApplySelect_WithWildcard_IncludesAllScalars()
// Should have all scalars of SqlOrder
order.GetType().GetProperty("Number").Should().NotBeNull();
order.GetType().GetProperty("Total").Should().NotBeNull();
- order.GetType().GetProperty("CreatedAtUtc").Should().NotBeNull();
+ order.GetType().GetProperty("OrderDate").Should().NotBeNull();
// Should NOT have navigations (unless specified)
order.GetType().GetProperty("Items").Should().BeNull();