From 3f2e6018d672fe7f2c81b9b26c197b7df2674b6b Mon Sep 17 00:00:00 2001 From: Peter John Casasola Date: Fri, 15 May 2026 09:55:18 +0800 Subject: [PATCH 1/9] feat(dapper): establish provider-agnostic SQL engine and relationship semantics - Implemented polymorphic ISqlDialect system supporting SQL Server, PostgreSQL, MySQL, MariaDB, SQLite, and Oracle. - Introduced ISqlDialectResolver for automatic dialect detection based on DbConnection types. - Refactored SqlTranslator to use dialect abstraction and specialized relationship translators. - Implemented relationship query semantics (Any, All, Count, Filtered Includes) with EF Core parity. - Enhanced mapping infrastructure (JoinInfo/EntityMapping) with TargetType support. - Updated project to multi-target .NET 6.0/7.0/8.0 and added NuGet metadata. - Integrated global dialect configuration via DapperQueryOptions. - Expanded test suite with 340+ passing tests covering all dialects and relationship scenarios. - Updated README with comprehensive Dapper integration and dialect configuration guides. --- CHANGELOG.md | 22 + README.md | 58 + .../DapperQueryOptions.cs | 79 ++ .../Dialects/DefaultSqlDialectResolver.cs | 37 + .../Dialects/ISqlDialect.cs | 41 + .../Dialects/ISqlDialectResolver.cs | 14 + .../Dialects/MariaDbDialect.cs | 48 + .../Dialects/MySqlDialect.cs | 29 + .../Dialects/OracleDialect.cs | 49 + .../Dialects/PostgreSqlDialect.cs | 29 + .../Dialects/SqlServerDialect.cs | 30 + .../Dialects/SqliteDialect.cs | 49 + .../FlexQuery.NET.Dapper.csproj | 60 + .../FlexQueryDapperExtensions.cs | 127 ++ .../Mapping/EntityMapping.cs | 49 + .../Mapping/EntityMappingBuilder.cs | 18 + .../Mapping/EntityTypeBuilder.cs | 38 + .../Mapping/IEntityMapping.cs | 28 + .../Mapping/IMappingRegistry.cs | 13 + src/FlexQuery.NET.Dapper/Mapping/JoinInfo.cs | 11 + .../Mapping/MappingRegistry.cs | 30 + .../Mapping/PropertyBuilder.cs | 24 + .../SQL/Ast/AllExpressionNode.cs | 12 + .../SQL/Ast/AnyExpressionNode.cs | 12 + .../SQL/Ast/CountExpressionNode.cs | 14 + .../SQL/Ast/IncludeNode.cs | 12 + src/FlexQuery.NET.Dapper/SQL/FieldInfo.cs | 19 + src/FlexQuery.NET.Dapper/SQL/SqlCommand.cs | 16 + .../SQL/Translators/SqlCountTranslator.cs | 49 + .../SQL/Translators/SqlExistsTranslator.cs | 52 + .../SQL/Translators/SqlIncludeTranslator.cs | 38 + .../SQL/Translators/SqlTranslator.cs | 360 ++++++ .../FlexQuery.NET.EFCore.csproj | 1 + .../Models/BaseQueryExecutionOptions.cs | 120 ++ .../Models/QueryExecutionOptions.cs | 100 +- .../Dapper/Dialects/DialectTests.cs | 1082 +++++++++++++++++ .../Dapper/Security/SqlInjectionTests.cs | 517 ++++++++ .../Dapper/Translation/SqlTranslatorTests.cs | 433 +++++++ .../FlexQuery.NET.Tests.csproj | 10 +- 39 files changed, 3627 insertions(+), 103 deletions(-) create mode 100644 src/FlexQuery.NET.Dapper/DapperQueryOptions.cs create mode 100644 src/FlexQuery.NET.Dapper/Dialects/DefaultSqlDialectResolver.cs create mode 100644 src/FlexQuery.NET.Dapper/Dialects/ISqlDialect.cs create mode 100644 src/FlexQuery.NET.Dapper/Dialects/ISqlDialectResolver.cs create mode 100644 src/FlexQuery.NET.Dapper/Dialects/MariaDbDialect.cs create mode 100644 src/FlexQuery.NET.Dapper/Dialects/MySqlDialect.cs create mode 100644 src/FlexQuery.NET.Dapper/Dialects/OracleDialect.cs create mode 100644 src/FlexQuery.NET.Dapper/Dialects/PostgreSqlDialect.cs create mode 100644 src/FlexQuery.NET.Dapper/Dialects/SqlServerDialect.cs create mode 100644 src/FlexQuery.NET.Dapper/Dialects/SqliteDialect.cs create mode 100644 src/FlexQuery.NET.Dapper/FlexQuery.NET.Dapper.csproj create mode 100644 src/FlexQuery.NET.Dapper/FlexQueryDapperExtensions.cs create mode 100644 src/FlexQuery.NET.Dapper/Mapping/EntityMapping.cs create mode 100644 src/FlexQuery.NET.Dapper/Mapping/EntityMappingBuilder.cs create mode 100644 src/FlexQuery.NET.Dapper/Mapping/EntityTypeBuilder.cs create mode 100644 src/FlexQuery.NET.Dapper/Mapping/IEntityMapping.cs create mode 100644 src/FlexQuery.NET.Dapper/Mapping/IMappingRegistry.cs create mode 100644 src/FlexQuery.NET.Dapper/Mapping/JoinInfo.cs create mode 100644 src/FlexQuery.NET.Dapper/Mapping/MappingRegistry.cs create mode 100644 src/FlexQuery.NET.Dapper/Mapping/PropertyBuilder.cs create mode 100644 src/FlexQuery.NET.Dapper/SQL/Ast/AllExpressionNode.cs create mode 100644 src/FlexQuery.NET.Dapper/SQL/Ast/AnyExpressionNode.cs create mode 100644 src/FlexQuery.NET.Dapper/SQL/Ast/CountExpressionNode.cs create mode 100644 src/FlexQuery.NET.Dapper/SQL/Ast/IncludeNode.cs create mode 100644 src/FlexQuery.NET.Dapper/SQL/FieldInfo.cs create mode 100644 src/FlexQuery.NET.Dapper/SQL/SqlCommand.cs create mode 100644 src/FlexQuery.NET.Dapper/SQL/Translators/SqlCountTranslator.cs create mode 100644 src/FlexQuery.NET.Dapper/SQL/Translators/SqlExistsTranslator.cs create mode 100644 src/FlexQuery.NET.Dapper/SQL/Translators/SqlIncludeTranslator.cs create mode 100644 src/FlexQuery.NET.Dapper/SQL/Translators/SqlTranslator.cs create mode 100644 src/FlexQuery.NET/Models/BaseQueryExecutionOptions.cs create mode 100644 tests/FlexQuery.NET.Tests/Dapper/Dialects/DialectTests.cs create mode 100644 tests/FlexQuery.NET.Tests/Dapper/Security/SqlInjectionTests.cs create mode 100644 tests/FlexQuery.NET.Tests/Dapper/Translation/SqlTranslatorTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index beae72a..6ae520f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ All notable changes to this project will be documented in this file. +--- +## [3.0.0] - 2026-05-15 + +### Added +- **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. + +### Changed +- **Mapping Registry Evolution**: Updated `JoinInfo` to support `TargetType`, enabling deep property resolution for related entity filters in Dapper. +- **Dapper Multi-Targeting**: Added support for `.net6.0`, `.net7.0`, and `.net8.0` in the Dapper package. +- **Internal Reorganization**: Moved SQL translators to a dedicated `Translators` folder and namespace for better maintainability. + --- ## [2.5.0] - 2026-05-10 diff --git a/README.md b/README.md index a8a6924..83de780 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,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,6 +63,63 @@ 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 ``` +### 4. Dapper Integration & Database Dialects + +FlexQuery.NET provides a robust Dapper extension (`FlexQuery.NET.Dapper`) that compiles queries into secure, parameterized, and database-specific SQL. + +#### Automatic Dialect Resolution + +By default, the SQL dialect is automatically resolved from your database connection (e.g., `SqlConnection` -> `SqlServerDialect`, `NpgsqlConnection` -> `PostgreSqlDialect`). + +```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; + }); + + return Ok(result); +} +``` + +#### Explicit Dialect Configuration + +If you need to force a specific SQL dialect for a single query, you can configure it directly: + +```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 +}); +``` + +#### Global Dialect Configuration (Optional) + +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: + +```csharp +// Program.cs or Startup.cs +using FlexQuery.NET.Dapper; +using FlexQuery.NET.Dapper.Dialects; + +// Set the global dialect once for the entire application +DapperQueryOptions.GlobalDefaultDialect = new PostgreSqlDialect(); + +// Or, provide your own custom resolver logic: +// DapperQueryOptions.GlobalDialectResolver = new MyCustomResolver(); +``` + --- ## 🏎️ Performance Benchmarks diff --git a/src/FlexQuery.NET.Dapper/DapperQueryOptions.cs b/src/FlexQuery.NET.Dapper/DapperQueryOptions.cs new file mode 100644 index 0000000..6dce3b3 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/DapperQueryOptions.cs @@ -0,0 +1,79 @@ +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 - override base IncludeTotalCount to false (Dapper behavior) + IncludeTotalCount = false; + } + + /// + /// 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 = false; // Override to match Dapper behavior + } + + 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; } + + /// Command timeout in seconds. + public int CommandTimeoutSeconds { get; set; } = 30; +} 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..4744316 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/FlexQuery.NET.Dapper.csproj @@ -0,0 +1,60 @@ + + + + net6.0;net7.0;net8.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..fe12859 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/FlexQueryDapperExtensions.cs @@ -0,0 +1,127 @@ +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 parsedOptions = QueryOptionsParser.Parse(parameters); + parsedOptions.Items["EntityType"] = typeof(T); + + var dapperOptions = new DapperQueryOptions(); + configureDapper?.Invoke(dapperOptions); + + var execOptions = dapperOptions.ToQueryExecutionOptions(); + + parsedOptions.ValidateOrThrow(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 parsedOptions = QueryOptionsParser.Parse(parameters); + parsedOptions.Items["EntityType"] = typeof(T); + + var dapperOptions = dapperQueryOptions ?? new DapperQueryOptions(); + + var execOptions = dapperOptions.ToQueryExecutionOptions(); + + parsedOptions.ValidateOrThrow(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 flexParams = new FlexQueryParameters + { + Filter = parameters.TryGetValue("filter", out var filter) ? filter.ToString() : null, + Sort = parameters.TryGetValue("sort", out var sort) ? sort.ToString() : null, + Select = parameters.TryGetValue("select", out var select) ? select.ToString() : null, + Page = parameters.TryGetValue("page", out var p) && int.TryParse(p, out var page) ? page : null, + PageSize = parameters.TryGetValue("pageSize", out var ps) && int.TryParse(ps, out var pageSize) ? pageSize : null + }; + + 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 translator = new SqlTranslator(new Mapping.MappingRegistry(), dialect); + var command = translator.Translate(options); + + var parameters = new DynamicParameters(); + foreach (var param in command.Parameters) + { + // Strip dialect-specific parameter prefixes (@, :, ?) for Dapper's DynamicParameters + var cleanName = param.Key.TrimStart('@', ':', '?'); + parameters.Add(cleanName, param.Value); + } + + var items = (await connection.QueryAsync( + command.Sql, + parameters, + commandTimeout: execOptions.CommandTimeoutSeconds, + commandType: CommandType.Text)).AsList(); + + var totalCount = items.Count; + if (execOptions.IncludeTotalCount && options.Paging.Page > 1) + { + var countSql = ExtractCountSql(command.Sql); + totalCount = (int)await connection.QuerySingleAsync(countSql, parameters, commandTimeout: execOptions.CommandTimeoutSeconds, commandType: CommandType.Text); + } + + return new QueryResult + { + Data = items, + TotalCount = totalCount, + Page = options.Paging.Page, + PageSize = options.Paging.PageSize + }; + } + + private static string ExtractCountSql(string sql) + { + var idx = sql.IndexOf("ORDER BY", StringComparison.OrdinalIgnoreCase); + var baseSql = idx >= 0 ? sql[..idx] : sql; + return $"SELECT COUNT(1) FROM ({baseSql.Trim()}) AS CountTable"; + } +} diff --git a/src/FlexQuery.NET.Dapper/Mapping/EntityMapping.cs b/src/FlexQuery.NET.Dapper/Mapping/EntityMapping.cs new file mode 100644 index 0000000..e354c87 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Mapping/EntityMapping.cs @@ -0,0 +1,49 @@ +namespace FlexQuery.NET.Dapper.Mapping; + +/// +/// Configuration for a database entity. +/// +public sealed class EntityMapping : IEntityMapping +{ + private readonly Dictionary _propertyToColumn = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _columnToProperty = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _joins = new(StringComparer.OrdinalIgnoreCase); + + public Type Type { get; } + public string TableName { get; } + public string? TableAlias { get; } + + public EntityMapping(Type type, string tableName, string? tableAlias = null) + { + Type = type; + TableName = tableName; + TableAlias = tableAlias; + } + + public void MapProperty(string property, string column) + { + _propertyToColumn[property] = column; + _columnToProperty[column] = property; + } + + public void MapJoin(string navigationProperty, Type targetType, string tableName, string joinCondition) + { + _joins[navigationProperty] = new JoinInfo + { + TargetType = targetType, + TableName = tableName, + JoinCondition = joinCondition + }; + } + + public string GetColumnName(string propertyName) + => _propertyToColumn.TryGetValue(propertyName, out var column) ? column : propertyName; + + public string? GetPropertyName(string columnName) + => _columnToProperty.TryGetValue(columnName, out var property) ? property : null; + + public IEnumerable GetProperties() => _propertyToColumn.Keys; + + public JoinInfo? GetJoinInfo(string navigationProperty) + => _joins.TryGetValue(navigationProperty, out var joinInfo) ? joinInfo : null; +} diff --git a/src/FlexQuery.NET.Dapper/Mapping/EntityMappingBuilder.cs b/src/FlexQuery.NET.Dapper/Mapping/EntityMappingBuilder.cs new file mode 100644 index 0000000..85d14cb --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Mapping/EntityMappingBuilder.cs @@ -0,0 +1,18 @@ +namespace FlexQuery.NET.Dapper.Mapping; + +/// +/// Entry point for configuring entity mappings. +/// +public sealed class EntityMappingBuilder +{ + private readonly MappingRegistry _registry; + + internal EntityMappingBuilder(MappingRegistry registry) + { + _registry = registry; + } + + /// Configures an entity type. + public EntityTypeBuilder Entity() where T : class + => new(typeof(T), _registry); +} diff --git a/src/FlexQuery.NET.Dapper/Mapping/EntityTypeBuilder.cs b/src/FlexQuery.NET.Dapper/Mapping/EntityTypeBuilder.cs new file mode 100644 index 0000000..5599818 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Mapping/EntityTypeBuilder.cs @@ -0,0 +1,38 @@ +namespace FlexQuery.NET.Dapper.Mapping; + +/// +/// Builder for configuring an entity type. +/// +/// The entity type. +public sealed class EntityTypeBuilder where T : class +{ + private readonly Type _type; + private readonly MappingRegistry _registry; + private readonly EntityMapping _mapping; + + internal EntityTypeBuilder(Type type, MappingRegistry registry) + { + _type = type; + _registry = registry; + _mapping = new EntityMapping(type, type.Name.ToLowerInvariant() + "s"); + } + + /// Configures the table name and optional alias. + public EntityTypeBuilder Table(string tableName, string? alias = null) + { + var field = typeof(EntityMapping).GetField("_tableName", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + // Direct field copy not supported, create new mapping + return this; + } + + /// Configures a property-to-column mapping. + public PropertyBuilder Property(System.Linq.Expressions.Expression> propertyExpression) + { + var memberExpression = (System.Linq.Expressions.MemberExpression)propertyExpression.Body; + var propertyName = memberExpression.Member.Name; + return new PropertyBuilder(_mapping, propertyName); + } + + /// Finishes configuration and registers the mapping. + public void Register() => _registry.Register(_mapping); +} diff --git a/src/FlexQuery.NET.Dapper/Mapping/IEntityMapping.cs b/src/FlexQuery.NET.Dapper/Mapping/IEntityMapping.cs new file mode 100644 index 0000000..9699894 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Mapping/IEntityMapping.cs @@ -0,0 +1,28 @@ +namespace FlexQuery.NET.Dapper.Mapping; + +/// +/// Configuration for a database entity. +/// +public interface IEntityMapping +{ + /// Entity type. + Type Type { get; } + + /// Table name. + string TableName { get; } + + /// Table alias. + string? TableAlias { get; } + + /// Get the column name for a property. + string GetColumnName(string propertyName); + + /// Get the property name for a column. + string? GetPropertyName(string columnName); + + /// Get all mapped property names. + IEnumerable GetProperties(); + + /// Get join information for an include relationship. + JoinInfo? GetJoinInfo(string navigationProperty); +} diff --git a/src/FlexQuery.NET.Dapper/Mapping/IMappingRegistry.cs b/src/FlexQuery.NET.Dapper/Mapping/IMappingRegistry.cs new file mode 100644 index 0000000..b96ea8f --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Mapping/IMappingRegistry.cs @@ -0,0 +1,13 @@ +namespace FlexQuery.NET.Dapper.Mapping; + +/// +/// Registry for entity mappings. +/// +public interface IMappingRegistry +{ + /// Gets the mapping for an entity type. + IEntityMapping GetMapping(Type entityType); + + /// Gets the mapping for an entity type. + IEntityMapping GetMapping(); +} diff --git a/src/FlexQuery.NET.Dapper/Mapping/JoinInfo.cs b/src/FlexQuery.NET.Dapper/Mapping/JoinInfo.cs new file mode 100644 index 0000000..68e1f56 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Mapping/JoinInfo.cs @@ -0,0 +1,11 @@ +namespace FlexQuery.NET.Dapper.Mapping; + +/// +/// Information about a join relationship. +/// +public sealed class JoinInfo +{ + public string TableName { get; set; } = string.Empty; + public string JoinCondition { get; set; } = string.Empty; + public Type? TargetType { get; set; } +} diff --git a/src/FlexQuery.NET.Dapper/Mapping/MappingRegistry.cs b/src/FlexQuery.NET.Dapper/Mapping/MappingRegistry.cs new file mode 100644 index 0000000..f36ef24 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Mapping/MappingRegistry.cs @@ -0,0 +1,30 @@ +using System.Collections.Concurrent; + +namespace FlexQuery.NET.Dapper.Mapping; + +/// +/// Registry for entity mappings with caching. +/// +public sealed class MappingRegistry : IMappingRegistry +{ + private readonly ConcurrentDictionary _mappings = new(); + + public IEntityMapping GetMapping(Type entityType) + => _mappings.GetOrAdd(entityType, _ => CreateDefaultMapping(entityType)); + + public IEntityMapping GetMapping() => GetMapping(typeof(T)); + + public void Register(IEntityMapping mapping) => _mappings[mapping.Type] = mapping; + + private IEntityMapping CreateDefaultMapping(Type entityType) + { + var tableName = entityType.Name; + if (tableName.EndsWith("Entity", StringComparison.OrdinalIgnoreCase)) + tableName = tableName.Substring(0, tableName.Length - 6); + if (tableName.EndsWith("Dto", StringComparison.OrdinalIgnoreCase)) + tableName = tableName.Substring(0, tableName.Length - 3); + tableName = tableName.ToLowerInvariant() + "s"; + + return new EntityMapping(entityType, tableName); + } +} diff --git a/src/FlexQuery.NET.Dapper/Mapping/PropertyBuilder.cs b/src/FlexQuery.NET.Dapper/Mapping/PropertyBuilder.cs new file mode 100644 index 0000000..bfadb72 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Mapping/PropertyBuilder.cs @@ -0,0 +1,24 @@ +namespace FlexQuery.NET.Dapper.Mapping; + +/// +/// Builder for configuring a property mapping. +/// +/// The entity type. +public sealed class PropertyBuilder +{ + private readonly EntityMapping _mapping; + private readonly string _propertyName; + + internal PropertyBuilder(EntityMapping mapping, string propertyName) + { + _mapping = mapping; + _propertyName = propertyName; + } + + /// Specifies the database column name. + public PropertyBuilder HasColumn(string columnName) + { + _mapping.MapProperty(_propertyName, columnName); + return this; + } +} diff --git a/src/FlexQuery.NET.Dapper/SQL/Ast/AllExpressionNode.cs b/src/FlexQuery.NET.Dapper/SQL/Ast/AllExpressionNode.cs new file mode 100644 index 0000000..d0581c0 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/SQL/Ast/AllExpressionNode.cs @@ -0,0 +1,12 @@ +using FlexQuery.NET.Models; + +namespace FlexQuery.NET.Dapper.Sql.Ast; + +/// +/// AST Node representing an ALL() condition (NOT EXISTS semantics). +/// +public class AllExpressionNode +{ + public string NavigationProperty { get; set; } = string.Empty; + public FilterGroup ScopedFilter { get; set; } = new(); +} diff --git a/src/FlexQuery.NET.Dapper/SQL/Ast/AnyExpressionNode.cs b/src/FlexQuery.NET.Dapper/SQL/Ast/AnyExpressionNode.cs new file mode 100644 index 0000000..595bf98 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/SQL/Ast/AnyExpressionNode.cs @@ -0,0 +1,12 @@ +using FlexQuery.NET.Models; + +namespace FlexQuery.NET.Dapper.Sql.Ast; + +/// +/// AST Node representing an ANY() condition (EXISTS semantics). +/// +public class AnyExpressionNode +{ + public string NavigationProperty { get; set; } = string.Empty; + public FilterGroup ScopedFilter { get; set; } = new(); +} diff --git a/src/FlexQuery.NET.Dapper/SQL/Ast/CountExpressionNode.cs b/src/FlexQuery.NET.Dapper/SQL/Ast/CountExpressionNode.cs new file mode 100644 index 0000000..4b9e064 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/SQL/Ast/CountExpressionNode.cs @@ -0,0 +1,14 @@ +using FlexQuery.NET.Models; + +namespace FlexQuery.NET.Dapper.Sql.Ast; + +/// +/// AST Node representing a COUNT() condition (correlated COUNT semantics). +/// +public class CountExpressionNode +{ + public string NavigationProperty { get; set; } = string.Empty; + public FilterGroup ScopedFilter { get; set; } = new(); + public string Operator { get; set; } = string.Empty; + public string? Value { get; set; } +} diff --git a/src/FlexQuery.NET.Dapper/SQL/Ast/IncludeNode.cs b/src/FlexQuery.NET.Dapper/SQL/Ast/IncludeNode.cs new file mode 100644 index 0000000..bf2f27f --- /dev/null +++ b/src/FlexQuery.NET.Dapper/SQL/Ast/IncludeNode.cs @@ -0,0 +1,12 @@ +using FlexQuery.NET.Models; + +namespace FlexQuery.NET.Dapper.Sql.Ast; + +/// +/// AST Node representing an include (LEFT JOIN) semantics. +/// +public class IncludeNode +{ + public string NavigationProperty { get; set; } = string.Empty; + public FilterGroup? Filter { get; set; } +} diff --git a/src/FlexQuery.NET.Dapper/SQL/FieldInfo.cs b/src/FlexQuery.NET.Dapper/SQL/FieldInfo.cs new file mode 100644 index 0000000..0596545 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/SQL/FieldInfo.cs @@ -0,0 +1,19 @@ +namespace FlexQuery.NET.Dapper.Sql; + +/// +/// Information about a translated field. +/// +public sealed class FieldInfo +{ + /// Property name in the entity. + public string PropertyName { get; init; } = string.Empty; + + /// Column name in the database. + public string ColumnName { get; init; } = string.Empty; + + /// Table alias. + public string? TableAlias { get; init; } + + /// Full qualified column name (alias.column). + public string QualifiedName => string.IsNullOrEmpty(TableAlias) ? ColumnName : $"{TableAlias}.{ColumnName}"; +} diff --git a/src/FlexQuery.NET.Dapper/SQL/SqlCommand.cs b/src/FlexQuery.NET.Dapper/SQL/SqlCommand.cs new file mode 100644 index 0000000..0915a11 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/SQL/SqlCommand.cs @@ -0,0 +1,16 @@ +namespace FlexQuery.NET.Dapper.Sql; + +/// +/// Result of SQL translation containing the SQL string and parameters. +/// +public sealed class SqlCommand +{ + /// The generated SQL string. + public string Sql { get; init; } = string.Empty; + + /// The parameters for the SQL command. + public IReadOnlyDictionary Parameters { get; init; } = new Dictionary(); + + /// Creates an empty SQL command. + public static SqlCommand Empty { get; } = new() { Sql = string.Empty }; +} diff --git a/src/FlexQuery.NET.Dapper/SQL/Translators/SqlCountTranslator.cs b/src/FlexQuery.NET.Dapper/SQL/Translators/SqlCountTranslator.cs new file mode 100644 index 0000000..e26eb78 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/SQL/Translators/SqlCountTranslator.cs @@ -0,0 +1,49 @@ +using FlexQuery.NET.Dapper.Sql.Ast; +using FlexQuery.NET.Dapper.Dialects; +using FlexQuery.NET.Dapper.Mapping; + +namespace FlexQuery.NET.Dapper.Sql.Translators; + +/// +/// Translator for relationship count queries. +/// +public class SqlCountTranslator +{ + private readonly ISqlDialect _dialect; + + public SqlCountTranslator(ISqlDialect dialect) + { + _dialect = dialect; + } + + /// + /// Translates a count condition into a correlated COUNT subquery. + /// + public string Translate(CountExpressionNode node, IEntityMapping mapping, Func filterBuilder, Dictionary parameters, Func paramNameGenerator) + { + var joinInfo = mapping.GetJoinInfo(node.NavigationProperty); + if (joinInfo == null) return string.Empty; + + var subqueryFilter = filterBuilder(node.ScopedFilter); + var subqueryWhere = string.IsNullOrEmpty(subqueryFilter) + ? joinInfo.JoinCondition + : $"{joinInfo.JoinCondition} AND ({subqueryFilter})"; + + var paramName = paramNameGenerator(); + parameters[paramName] = node.Value; + + var sqlOp = FlexQuery.NET.Constants.FilterOperators.Normalize(node.Operator) switch + { + FlexQuery.NET.Constants.FilterOperators.Equal => "=", + FlexQuery.NET.Constants.FilterOperators.NotEqual => "<>", + FlexQuery.NET.Constants.FilterOperators.GreaterThan => ">", + FlexQuery.NET.Constants.FilterOperators.GreaterThanOrEq => ">=", + FlexQuery.NET.Constants.FilterOperators.LessThan => "<", + FlexQuery.NET.Constants.FilterOperators.LessThanOrEq => "<=", + _ => "=" + }; + + // e.g. (SELECT COUNT(*) FROM orders WHERE users.Id = orders.UserId AND status = @p0) > @p1 + return $"(SELECT COUNT(*) FROM {_dialect.QuoteIdentifier(joinInfo.TableName)} WHERE {subqueryWhere}) {sqlOp} {paramName}"; + } +} diff --git a/src/FlexQuery.NET.Dapper/SQL/Translators/SqlExistsTranslator.cs b/src/FlexQuery.NET.Dapper/SQL/Translators/SqlExistsTranslator.cs new file mode 100644 index 0000000..ee32547 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/SQL/Translators/SqlExistsTranslator.cs @@ -0,0 +1,52 @@ +using FlexQuery.NET.Dapper.Sql.Ast; +using FlexQuery.NET.Dapper.Dialects; +using FlexQuery.NET.Dapper.Mapping; + +namespace FlexQuery.NET.Dapper.Sql.Translators; + +/// +/// Translator for relationship existence queries (Any/All). +/// +public class SqlExistsTranslator +{ + private readonly ISqlDialect _dialect; + + public SqlExistsTranslator(ISqlDialect dialect) + { + _dialect = dialect; + } + + /// + /// Translates an ANY condition into an EXISTS subquery. + /// + public string TranslateAny(AnyExpressionNode node, IEntityMapping mapping, Func filterBuilder) + { + var joinInfo = mapping.GetJoinInfo(node.NavigationProperty); + if (joinInfo == null) return string.Empty; + + var subqueryFilter = filterBuilder(node.ScopedFilter); + var subqueryWhere = string.IsNullOrEmpty(subqueryFilter) + ? joinInfo.JoinCondition + : $"{joinInfo.JoinCondition} AND ({subqueryFilter})"; + + // e.g. EXISTS (SELECT 1 FROM orders WHERE users.Id = orders.UserId AND orders.id = @p0) + return $"EXISTS (SELECT 1 FROM {_dialect.QuoteIdentifier(joinInfo.TableName)} WHERE {subqueryWhere})"; + } + + /// + /// Translates an ALL condition into a NOT EXISTS subquery. + /// + public string TranslateAll(AllExpressionNode node, IEntityMapping mapping, Func filterBuilder) + { + var joinInfo = mapping.GetJoinInfo(node.NavigationProperty); + if (joinInfo == null) return string.Empty; + + var subqueryFilter = filterBuilder(node.ScopedFilter); + var subqueryWhere = string.IsNullOrEmpty(subqueryFilter) + ? $"{joinInfo.JoinCondition} AND NOT (1=1)" + : $"{joinInfo.JoinCondition} AND NOT ({subqueryFilter})"; + + // e.g. NOT EXISTS (SELECT 1 FROM orders WHERE users.Id = orders.UserId AND NOT (orders.status = @p0)) + return $"NOT EXISTS (SELECT 1 FROM {_dialect.QuoteIdentifier(joinInfo.TableName)} WHERE {subqueryWhere})"; + } +} diff --git a/src/FlexQuery.NET.Dapper/SQL/Translators/SqlIncludeTranslator.cs b/src/FlexQuery.NET.Dapper/SQL/Translators/SqlIncludeTranslator.cs new file mode 100644 index 0000000..5a24530 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/SQL/Translators/SqlIncludeTranslator.cs @@ -0,0 +1,38 @@ +using FlexQuery.NET.Dapper.Sql.Ast; +using FlexQuery.NET.Dapper.Dialects; +using FlexQuery.NET.Dapper.Mapping; + +namespace FlexQuery.NET.Dapper.Sql.Translators; + +/// +/// Translator for relationship inclusion (LEFT JOIN). +/// +public class SqlIncludeTranslator +{ + private readonly ISqlDialect _dialect; + + public SqlIncludeTranslator(ISqlDialect dialect) + { + _dialect = dialect; + } + + /// + /// Translates an include node into a LEFT JOIN clause with optional filter. + /// + public string Translate(IncludeNode node, IEntityMapping mapping, Func filterBuilder) + { + var joinInfo = mapping.GetJoinInfo(node.NavigationProperty); + if (joinInfo == null) return string.Empty; + + var sql = $"LEFT JOIN {_dialect.QuoteIdentifier(joinInfo.TableName)} ON {joinInfo.JoinCondition}"; + + if (node.Filter != null) + { + var filterSql = filterBuilder(node.Filter); + if (!string.IsNullOrEmpty(filterSql)) + sql += $" AND ({filterSql})"; + } + + return sql; + } +} diff --git a/src/FlexQuery.NET.Dapper/SQL/Translators/SqlTranslator.cs b/src/FlexQuery.NET.Dapper/SQL/Translators/SqlTranslator.cs new file mode 100644 index 0000000..51634d8 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/SQL/Translators/SqlTranslator.cs @@ -0,0 +1,360 @@ +using FlexQuery.NET.Models; +using FlexQuery.NET.Constants; +using FlexQuery.NET.Dapper.Mapping; +using FlexQuery.NET.Dapper.Dialects; +using FlexQuery.NET.Dapper.Sql.Ast; + +namespace FlexQuery.NET.Dapper.Sql.Translators; + +/// +/// Translates Core QueryOptions into SQL commands for Dapper execution. +/// +public interface ISqlTranslator +{ + /// Translates QueryOptions into a SQL command. + SqlCommand Translate(QueryOptions options); +} + +/// +/// SQL translator implementation that generates parameterized queries from QueryOptions. +/// All parameter naming and SQL generation is delegated to the ISqlDialect abstraction. +/// +public sealed class SqlTranslator : ISqlTranslator +{ + private readonly IMappingRegistry _mappingRegistry; + private readonly ISqlDialect _dialect; + private readonly SqlIncludeTranslator _includeTranslator; + private readonly SqlExistsTranslator _existsTranslator; + private readonly SqlCountTranslator _countTranslator; + private int _parameterIndex; + + public SqlTranslator(IMappingRegistry mappingRegistry, ISqlDialect dialect) + { + _mappingRegistry = mappingRegistry; + _dialect = dialect; + _includeTranslator = new SqlIncludeTranslator(dialect); + _existsTranslator = new SqlExistsTranslator(dialect); + _countTranslator = new SqlCountTranslator(dialect); + } + + public SqlCommand Translate(QueryOptions options) + { + _parameterIndex = 0; + var parameters = new Dictionary(); + + var entityType = options.Items.TryGetValue("EntityType", out var type) ? (Type)type : typeof(object); + var mapping = _mappingRegistry.GetMapping(entityType); + + var distinctClause = options.Distinct == true ? "DISTINCT" : string.Empty; + var selectClause = BuildSelectClause(options, mapping, distinctClause); + var fromClause = $"FROM {_dialect.QuoteIdentifier(mapping.TableName)}"; + var joinClause = BuildJoinClause(options, mapping, parameters); + var whereClause = BuildWhereClause(options.Filter, mapping, parameters); + var groupByClause = BuildGroupByClause(options.GroupBy, mapping); + var havingClause = BuildHavingClause(options.Having, mapping, parameters); + var orderByClause = BuildOrderByClause(options.Sort, mapping); + var pagingClause = BuildPagingClause(options.Paging, parameters); + + var clauses = new List { selectClause, fromClause, joinClause, whereClause, groupByClause, havingClause, orderByClause, pagingClause }; + var sql = string.Join(" ", clauses.Where(c => !string.IsNullOrEmpty(c))); + sql = System.Text.RegularExpressions.Regex.Replace(sql, @"\s+", " "); + + return new SqlCommand + { + Sql = sql, + Parameters = parameters + }; + } + + private string NextParam() => _dialect.CreateParameterName($"p{_parameterIndex++}"); + + private string BuildSelectClause(QueryOptions options, IEntityMapping mapping, string distinctClause) + { + var distinctPrefix = !string.IsNullOrEmpty(distinctClause) ? $"{distinctClause} " : string.Empty; + + if (options.Aggregates?.Count > 0) + { + var selectParts = new List(); + foreach (var agg in options.Aggregates) + { + var column = mapping.GetColumnName(agg.Field ?? "*"); + var quoted = string.IsNullOrEmpty(mapping.TableAlias) + ? _dialect.QuoteIdentifier(column) + : $"{mapping.TableAlias}.{_dialect.QuoteIdentifier(column)}"; + + if (agg.Function.Equals("count", StringComparison.OrdinalIgnoreCase) && string.IsNullOrEmpty(agg.Field)) + { + selectParts.Add($"COUNT(1) AS {_dialect.QuoteIdentifier(agg.Alias)}"); + } + else + { + selectParts.Add($"{agg.Function.ToUpperInvariant()}({quoted}) AS {_dialect.QuoteIdentifier(agg.Alias)}"); + } + } + return $"SELECT {distinctPrefix}{string.Join(", ", selectParts)}"; + } + + if (options.Select?.Count > 0) + { + var columns = options.Select.Select(s => + { + var column = mapping.GetColumnName(s); + var alias = mapping.TableAlias; + return string.IsNullOrEmpty(alias) + ? _dialect.QuoteIdentifier(column) + : $"{alias}.{_dialect.QuoteIdentifier(column)}"; + }); + return $"SELECT {distinctPrefix}{string.Join(", ", columns)}"; + } + + var allColumns = mapping.GetProperties().Select(p => + { + var column = mapping.GetColumnName(p); + var alias = mapping.TableAlias; + return string.IsNullOrEmpty(alias) + ? _dialect.QuoteIdentifier(column) + : $"{alias}.{_dialect.QuoteIdentifier(column)}"; + }); + return $"SELECT {distinctPrefix}{string.Join(", ", allColumns)}"; + } + + private string BuildJoinClause(QueryOptions options, IEntityMapping mapping, Dictionary parameters) + { + var joins = new List(); + + // Handle regular Includes + if (options.Includes != null) + { + foreach (var include in options.Includes) + { + var node = new FlexQuery.NET.Dapper.Sql.Ast.IncludeNode { NavigationProperty = include }; + var sql = _includeTranslator.Translate(node, mapping, _ => string.Empty); + if (!string.IsNullOrEmpty(sql)) joins.Add(sql); + } + } + + // Handle Filtered Includes + if (options.FilteredIncludes != null) + { + foreach (var filteredInclude in options.FilteredIncludes) + { + var node = new FlexQuery.NET.Dapper.Sql.Ast.IncludeNode + { + NavigationProperty = filteredInclude.Path, + Filter = filteredInclude.Filter + }; + + var sql = _includeTranslator.Translate(node, mapping, filterGroup => + { + var joinInfo = mapping.GetJoinInfo(filteredInclude.Path); + if (joinInfo?.TargetType == null) return string.Empty; + var targetMapping = _mappingRegistry.GetMapping(joinInfo.TargetType); + return BuildFilterGroupExpression(filterGroup, targetMapping, parameters); + }); + + if (!string.IsNullOrEmpty(sql)) joins.Add(sql); + } + } + + return string.Join(" ", joins); + } + + private string BuildWhereClause(FilterGroup? filter, IEntityMapping mapping, Dictionary parameters) + { + if (filter == null) return string.Empty; + + var where = BuildFilterGroupExpression(filter, mapping, parameters); + return string.IsNullOrEmpty(where) ? string.Empty : $"WHERE {where}"; + } + + private string BuildFilterGroupExpression(FilterGroup group, IEntityMapping mapping, Dictionary parameters) + { + var parts = new List(); + + foreach (var filter in group.Filters) + { + var expr = BuildConditionExpression(filter, mapping, parameters); + if (!string.IsNullOrEmpty(expr)) + parts.Add(expr); + } + + foreach (var subGroup in group.Groups) + { + var expr = BuildFilterGroupExpression(subGroup, mapping, parameters); + if (!string.IsNullOrEmpty(expr)) + { + if (group.Logic == LogicOperator.Or || group.IsNegated) + parts.Add($"({expr})"); + else + parts.Add(expr); + } + } + + if (parts.Count == 0) return string.Empty; + var result = string.Join($" {(group.Logic == LogicOperator.And ? "AND" : "OR")} ", parts); + if (group.Logic == LogicOperator.Or || group.IsNegated) + return $"({result})"; + return result; + } + + private string BuildConditionExpression(FilterCondition condition, IEntityMapping mapping, Dictionary parameters) + { + var op = FilterOperators.Normalize(condition.Operator); + + // Handle Relationship Operators + if (op == FilterOperators.Any && condition.ScopedFilter != null) + { + var node = new AnyExpressionNode { NavigationProperty = condition.Field, ScopedFilter = condition.ScopedFilter }; + return _existsTranslator.TranslateAny(node, mapping, group => + { + var joinInfo = mapping.GetJoinInfo(condition.Field); + var targetMapping = joinInfo?.TargetType != null ? _mappingRegistry.GetMapping(joinInfo.TargetType) : mapping; + return BuildFilterGroupExpression(group, targetMapping, parameters); + }); + } + + if (op == FilterOperators.All && condition.ScopedFilter != null) + { + var node = new AllExpressionNode { NavigationProperty = condition.Field, ScopedFilter = condition.ScopedFilter }; + return _existsTranslator.TranslateAll(node, mapping, group => + { + var joinInfo = mapping.GetJoinInfo(condition.Field); + var targetMapping = joinInfo?.TargetType != null ? _mappingRegistry.GetMapping(joinInfo.TargetType) : mapping; + return BuildFilterGroupExpression(group, targetMapping, parameters); + }); + } + + if (op == FilterOperators.Count && condition.ScopedFilter != null) + { + if (string.IsNullOrWhiteSpace(condition.Value)) return "1=0"; + + var segments = condition.Value.Split(':', 2, StringSplitOptions.TrimEntries); + if (segments.Length != 2) return "1=0"; + + var node = new CountExpressionNode + { + NavigationProperty = condition.Field, + ScopedFilter = condition.ScopedFilter, + Operator = segments[0], + Value = segments[1] + }; + + return _countTranslator.Translate(node, mapping, group => + { + var joinInfo = mapping.GetJoinInfo(condition.Field); + var targetMapping = joinInfo?.TargetType != null ? _mappingRegistry.GetMapping(joinInfo.TargetType) : mapping; + return BuildFilterGroupExpression(group, targetMapping, parameters); + }, parameters, NextParam); + } + + var column = mapping.GetColumnName(condition.Field); + var quotedColumn = QuoteColumn(column, mapping); + + return op switch + { + FilterOperators.IsNull or "isnull" => $"{quotedColumn} IS NULL", + FilterOperators.IsNotNull or "isnotnull" => $"{quotedColumn} IS NOT NULL", + FilterOperators.In => BuildInExpression(quotedColumn, condition.Value, parameters), + FilterOperators.Between => BuildBetweenExpression(quotedColumn, condition.Value, parameters), + FilterOperators.Contains => BuildLikeExpression(quotedColumn, condition.Value, parameters, "%", "%"), + FilterOperators.StartsWith => BuildLikeExpression(quotedColumn, condition.Value, parameters, "", "%"), + FilterOperators.EndsWith => BuildLikeExpression(quotedColumn, condition.Value, parameters, "%", ""), + _ => BuildComparisonExpression(quotedColumn, condition.Value, op, parameters) + }; + } + + private string BuildComparisonExpression(string quotedColumn, string? value, string op, Dictionary parameters) + { + var paramName = NextParam(); + parameters[paramName] = value; + var sqlOp = op switch + { + FilterOperators.Equal => "=", + FilterOperators.NotEqual => "<>", + FilterOperators.GreaterThan => ">", + FilterOperators.GreaterThanOrEq => ">=", + FilterOperators.LessThan => "<", + FilterOperators.LessThanOrEq => "<=", + _ => "=" + }; + return $"{quotedColumn} {sqlOp} {paramName}"; + } + + private string BuildInExpression(string quotedColumn, string? value, Dictionary parameters) + { + if (string.IsNullOrEmpty(value)) return "1 = 1"; + var values = value.Split(',').Select(v => v.Trim()).ToArray(); + var paramNames = values.Select((_, i) => NextParam()).ToArray(); + for (int i = 0; i < values.Length; i++) + { + parameters[paramNames[i]] = values[i]; + } + return $"{quotedColumn} IN ({string.Join(", ", paramNames)})"; + } + + private string BuildBetweenExpression(string quotedColumn, string? value, Dictionary parameters) + { + if (string.IsNullOrEmpty(value)) return "1 = 1"; + var values = value.Split(',').Select(v => v.Trim()).ToArray(); + if (values.Length != 2) return "1 = 1"; + var fromParam = NextParam(); + var toParam = NextParam(); + parameters[fromParam] = values[0]; + parameters[toParam] = values[1]; + return $"{quotedColumn} BETWEEN {fromParam} AND {toParam}"; + } + + private string BuildLikeExpression(string quotedColumn, string? value, Dictionary parameters, string prefix, string suffix) + { + var paramName = NextParam(); + parameters[paramName] = $"{prefix}{value}{suffix}"; + return $"{quotedColumn} LIKE {paramName}"; + } + + private string QuoteColumn(string column, IEntityMapping mapping) + { + if (string.IsNullOrEmpty(mapping.TableAlias)) + return _dialect.QuoteIdentifier(column); + return $"{mapping.TableAlias}.{_dialect.QuoteIdentifier(column)}"; + } + + private string BuildGroupByClause(IReadOnlyList? groupBys, IEntityMapping mapping) + { + if (groupBys == null || groupBys.Count == 0) return string.Empty; + var columns = groupBys.Select(g => QuoteColumn(mapping.GetColumnName(g), mapping)); + return $"GROUP BY {string.Join(", ", columns)}"; + } + + private string BuildHavingClause(HavingCondition? having, IEntityMapping mapping, Dictionary parameters) + { + if (having == null) return string.Empty; + var column = QuoteColumn(mapping.GetColumnName(having.Field ?? "*"), mapping); + var paramName = NextParam(); + parameters[paramName] = having.Value?.ToString()?.Trim('"'); + return $"HAVING {having.Function.ToUpperInvariant()}({column}) {having.Operator} {paramName}"; + } + + private string BuildOrderByClause(IReadOnlyList? sorts, IEntityMapping mapping) + { + if (sorts == null || sorts.Count == 0) return string.Empty; + var columns = sorts.Select(s => + { + var column = QuoteColumn(mapping.GetColumnName(s.Field), mapping); + return s.Descending ? $"{column} DESC" : column; + }); + return $"ORDER BY {string.Join(", ", columns)}"; + } + + private string BuildPagingClause(PagingOptions paging, Dictionary parameters) + { + if (paging.Disabled) return string.Empty; + + var offset = (paging.Page - 1) * paging.PageSize; + var offsetParam = _dialect.CreateParameterName("Offset"); + var limitParam = _dialect.CreateParameterName("PageSize"); + parameters[offsetParam] = offset; + parameters[limitParam] = paging.PageSize; + + return _dialect.GetPagingClause(offsetParam, limitParam); + } +} diff --git a/src/FlexQuery.NET.EFCore/FlexQuery.NET.EFCore.csproj b/src/FlexQuery.NET.EFCore/FlexQuery.NET.EFCore.csproj index b127ac3..7c83dbe 100644 --- a/src/FlexQuery.NET.EFCore/FlexQuery.NET.EFCore.csproj +++ b/src/FlexQuery.NET.EFCore/FlexQuery.NET.EFCore.csproj @@ -27,6 +27,7 @@ true false true + 3.0.0 diff --git a/src/FlexQuery.NET/Models/BaseQueryExecutionOptions.cs b/src/FlexQuery.NET/Models/BaseQueryExecutionOptions.cs new file mode 100644 index 0000000..333f4c3 --- /dev/null +++ b/src/FlexQuery.NET/Models/BaseQueryExecutionOptions.cs @@ -0,0 +1,120 @@ +using FlexQuery.NET.Security; + +namespace FlexQuery.NET.Models; + +/// +/// Defines server-side execution rules, validation constraints, and security policies. +/// This model separates server-side requirements from client-side query parameters. +/// +public class BaseQueryOptions +{ + + /// + /// Creates a new instance with default security settings. + /// + public BaseQueryOptions() + { + // Set default values for execution options + IncludeTotalCount = true; + DefaultPageSize = 20; + } + + // --- Security Lists --- + + /// Global list of allowed fields (whitelist). + public HashSet? AllowedFields { get; set; } + + /// Global list of blocked fields (blacklist). + public HashSet? BlockedFields { get; set; } + + /// Global list of allowed includes (whitelist for navigation properties). + public HashSet? AllowedIncludes { get; set; } + + /// + /// Maps a DTO field name to an entity expression for full DTO querying. + /// + public Dictionary? ExpressionMappings { get; set; } + + /// + /// Maps an exposed DTO field to an entity expression for server-side evaluation. + /// + public void MapField(string alias, System.Linq.Expressions.Expression> expression) + { + ExpressionMappings ??= new Dictionary(StringComparer.OrdinalIgnoreCase); + ExpressionMappings[alias] = expression; + } + + /// + /// Governance: Map of fields to their explicitly allowed operators (canonical strings). + /// If a field is not present, all operators are allowed. + /// Use for valid keys. + /// + public Dictionary>? AllowedOperators { get; set; } + + /// + /// Ergonomic helper to configure allowed operators for a specific field. + /// Use constants for the operator arguments. + /// + public void AllowOperators(string field, params string[] operators) + { + AllowedOperators ??= new Dictionary>(StringComparer.OrdinalIgnoreCase); + if (!AllowedOperators.TryGetValue(field, out var set)) + { + set = new HashSet(StringComparer.OrdinalIgnoreCase); + AllowedOperators[field] = set; + } + foreach (var op in operators) + { + set.Add(Constants.FilterOperators.Normalize(op)); + } + } + + /// Fields allowed specifically for filtering operations. + public HashSet? FilterableFields { get; set; } + + /// Fields allowed specifically for sorting operations. + public HashSet? SortableFields { get; set; } + + /// Fields allowed specifically for selection/projection operations. + public HashSet? SelectableFields { get; set; } + + // --- Validation Rules --- + + /// Limits the depth of nested field paths (e.g. "Customer.Orders.Items"). + public int? MaxFieldDepth { get; set; } + + /// + /// If true, unauthorized field access throws a validation exception. + /// If false, unauthorized fields are silently removed from the query. + /// + public bool StrictFieldValidation { get; set; } + + /// Whether to include the total count in the result by default. + public bool IncludeTotalCount { get; set; } + + /// The default page size to use if not provided by the user. + public int DefaultPageSize { get; set; } = 20; + + /// The maximum page size a user is allowed to request. + public int? MaxPageSize { get; set; } + + /// If true, field name matching during validation is case-insensitive. + public bool CaseInsensitiveFields { get; set; } = true; + + /// Maps external field aliases to internal property names. + public Dictionary? FieldMappings { get; set; } + + // --- Advanced Security --- + + /// Optional custom resolver for dynamic field-level access control. + public IFieldAccessResolver? FieldAccessResolver { get; set; } + + /// Role-based field permissions. Maps roles to sets of allowed fields. + public Dictionary>? RoleAllowedFields { get; set; } + + /// The active role to use when evaluating RoleAllowedFields. + public string? CurrentRole { get; set; } + + /// Optional resolver to dynamically determine allowed fields based on the entity type. + public Func>? AllowedFieldsResolver { get; set; } +} diff --git a/src/FlexQuery.NET/Models/QueryExecutionOptions.cs b/src/FlexQuery.NET/Models/QueryExecutionOptions.cs index a47f389..4c1568f 100644 --- a/src/FlexQuery.NET/Models/QueryExecutionOptions.cs +++ b/src/FlexQuery.NET/Models/QueryExecutionOptions.cs @@ -6,7 +6,7 @@ namespace FlexQuery.NET.Models; /// Defines server-side execution rules, validation constraints, and security policies. /// This model separates server-side requirements from client-side query parameters. /// -public class QueryExecutionOptions +public class QueryExecutionOptions : BaseQueryOptions { /// @@ -33,102 +33,4 @@ public QueryExecutionOptions() /// public bool UseNoTracking { get; set; } = true; - // --- Security Lists --- - - /// Global list of allowed fields (whitelist). - public HashSet? AllowedFields { get; set; } - - /// Global list of blocked fields (blacklist). - public HashSet? BlockedFields { get; set; } - - /// Global list of allowed includes (whitelist for navigation properties). - public HashSet? AllowedIncludes { get; set; } - - /// - /// Maps a DTO field name to an entity expression for full DTO querying. - /// - public Dictionary? ExpressionMappings { get; set; } - - /// - /// Maps an exposed DTO field to an entity expression for server-side evaluation. - /// - public void MapField(string alias, System.Linq.Expressions.Expression> expression) - { - ExpressionMappings ??= new Dictionary(StringComparer.OrdinalIgnoreCase); - ExpressionMappings[alias] = expression; - } - - /// - /// Governance: Map of fields to their explicitly allowed operators (canonical strings). - /// If a field is not present, all operators are allowed. - /// Use for valid keys. - /// - public Dictionary>? AllowedOperators { get; set; } - - /// - /// Ergonomic helper to configure allowed operators for a specific field. - /// Use constants for the operator arguments. - /// - public void AllowOperators(string field, params string[] operators) - { - AllowedOperators ??= new Dictionary>(StringComparer.OrdinalIgnoreCase); - if (!AllowedOperators.TryGetValue(field, out var set)) - { - set = new HashSet(StringComparer.OrdinalIgnoreCase); - AllowedOperators[field] = set; - } - foreach (var op in operators) - { - set.Add(Constants.FilterOperators.Normalize(op)); - } - } - - /// Fields allowed specifically for filtering operations. - public HashSet? FilterableFields { get; set; } - - /// Fields allowed specifically for sorting operations. - public HashSet? SortableFields { get; set; } - - /// Fields allowed specifically for selection/projection operations. - public HashSet? SelectableFields { get; set; } - - // --- Validation Rules --- - - /// Limits the depth of nested field paths (e.g. "Customer.Orders.Items"). - public int? MaxFieldDepth { get; set; } - - /// - /// If true, unauthorized field access throws a validation exception. - /// If false, unauthorized fields are silently removed from the query. - /// - public bool StrictFieldValidation { get; set; } - - /// Whether to include the total count in the result by default. - public bool IncludeTotalCount { get; set; } - - /// The default page size to use if not provided by the user. - public int DefaultPageSize { get; set; } = 20; - - /// The maximum page size a user is allowed to request. - public int? MaxPageSize { get; set; } - - /// If true, field name matching during validation is case-insensitive. - public bool CaseInsensitiveFields { get; set; } = true; - - /// Maps external field aliases to internal property names. - public Dictionary? FieldMappings { get; set; } - - // --- Advanced Security --- - - /// Optional custom resolver for dynamic field-level access control. - public IFieldAccessResolver? FieldAccessResolver { get; set; } - - /// Role-based field permissions. Maps roles to sets of allowed fields. - public Dictionary>? RoleAllowedFields { get; set; } - - /// The active role to use when evaluating RoleAllowedFields. - public string? CurrentRole { get; set; } - - /// Optional resolver to dynamically determine allowed fields based on the entity type. - public Func>? AllowedFieldsResolver { get; set; } } diff --git a/tests/FlexQuery.NET.Tests/Dapper/Dialects/DialectTests.cs b/tests/FlexQuery.NET.Tests/Dapper/Dialects/DialectTests.cs new file mode 100644 index 0000000..1267cd8 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Dapper/Dialects/DialectTests.cs @@ -0,0 +1,1082 @@ +using FlexQuery.NET.Models; +using FlexQuery.NET.Dapper.Mapping; +using FlexQuery.NET.Dapper.Sql; +using FlexQuery.NET.Dapper.Sql.Translators; +using FlexQuery.NET.Dapper.Dialects; +using FluentAssertions; + +namespace FlexQuery.NET.Tests.Dapper.Dialects; + +public class DialectTests +{ + private readonly IMappingRegistry _registry = new MappingRegistry(); + + + // ======================== + // Pagination Tests + // ======================== + + [Fact] + public void SqlServer_Pagination_Uses_Offset_Fetch() + { + var options = CreatePagedOptions(); + var command = new SqlTranslator(_registry, new SqlServerDialect()).Translate(options); + + command.Sql.Should().Contain("OFFSET"); + command.Sql.Should().Contain("FETCH NEXT"); + command.Sql.Should().Contain("ROWS ONLY"); + } + + [Fact] + public void PostgreSQL_Pagination_Uses_Offset_Limit() + { + var options = CreatePagedOptions(); + var command = new SqlTranslator(_registry, new PostgreSqlDialect()).Translate(options); + + command.Sql.Should().Contain("OFFSET"); + command.Sql.Should().Contain("LIMIT"); + // PostgreSQL uses: LIMIT y OFFSET x + var limitIndex = command.Sql.IndexOf("LIMIT"); + var offsetIndex = command.Sql.IndexOf("OFFSET"); + limitIndex.Should().BeLessThan(offsetIndex); + } + + [Fact] + public void MySQL_Pagination_Uses_Limit_Offset() + { + var options = CreatePagedOptions(); + var command = new SqlTranslator(_registry, new MySqlDialect()).Translate(options); + + command.Sql.Should().Contain("LIMIT"); + command.Sql.Should().Contain("OFFSET"); + // MySQL uses: LIMIT y OFFSET x + var limitIndex = command.Sql.IndexOf("LIMIT"); + var offsetIndex = command.Sql.IndexOf("OFFSET"); + limitIndex.Should().BeLessThan(offsetIndex); + } + + [Fact] + public void MariaDb_Pagination_Uses_Limit_Offset() + { + var options = CreatePagedOptions(); + var command = new SqlTranslator(_registry, new MariaDbDialect()).Translate(options); + + command.Sql.Should().Contain("LIMIT"); + command.Sql.Should().Contain("OFFSET"); + // MariaDB uses: LIMIT y OFFSET x + var limitIndex = command.Sql.IndexOf("LIMIT"); + var offsetIndex = command.Sql.IndexOf("OFFSET"); + limitIndex.Should().BeLessThan(offsetIndex); + } + + [Fact] + public void Sqlite_Pagination_Uses_Limit_Offset() + { + var options = CreatePagedOptions(); + var command = new SqlTranslator(_registry, new SqliteDialect()).Translate(options); + + command.Sql.Should().Contain("LIMIT"); + command.Sql.Should().Contain("OFFSET"); + // SQLite uses: LIMIT y OFFSET x + var limitIndex = command.Sql.IndexOf("LIMIT"); + var offsetIndex = command.Sql.IndexOf("OFFSET"); + limitIndex.Should().BeLessThan(offsetIndex); + } + + [Fact] + public void Oracle_Pagination_Uses_Offset_Fetch() + { + var options = CreatePagedOptions(); + var command = new SqlTranslator(_registry, new OracleDialect()).Translate(options); + + command.Sql.Should().Contain("OFFSET"); + command.Sql.Should().Contain("FETCH NEXT"); + command.Sql.Should().Contain("ROWS ONLY"); + } + + // ======================== + // Identifier Escaping Tests + // ======================== + + [Fact] + public void SqlServer_QuoteIdentifier_Uses_Brackets() + { + var dialect = new SqlServerDialect(); + dialect.QuoteIdentifier("ColumnName").Should().Be("[ColumnName]"); + } + + [Fact] + public void PostgreSQL_QuoteIdentifier_Uses_DoubleQuotes() + { + var dialect = new PostgreSqlDialect(); + dialect.QuoteIdentifier("ColumnName").Should().Be("\"ColumnName\""); + } + + [Fact] + public void MySQL_QuoteIdentifier_Uses_Backticks() + { + var dialect = new MySqlDialect(); + dialect.QuoteIdentifier("ColumnName").Should().Be("`ColumnName`"); + } + + [Fact] + public void MariaDb_QuoteIdentifier_Uses_Backticks() + { + var dialect = new MariaDbDialect(); + dialect.QuoteIdentifier("ColumnName").Should().Be("`ColumnName`"); + } + + [Fact] + public void Sqlite_QuoteIdentifier_Uses_DoubleQuotes() + { + var dialect = new SqliteDialect(); + dialect.QuoteIdentifier("ColumnName").Should().Be("\"ColumnName\""); + } + + [Fact] + public void Oracle_QuoteIdentifier_Uses_DoubleQuotes() + { + var dialect = new OracleDialect(); + dialect.QuoteIdentifier("ColumnName").Should().Be("\"ColumnName\""); + } + + // ======================== + // Quote Character Tests + // ======================== + + [Fact] + public void SqlServer_QuoteChars_Are_Brackets() + { + var dialect = new SqlServerDialect(); + dialect.QuotePrefix.Should().Be('['); + dialect.QuoteSuffix.Should().Be(']'); + } + + [Fact] + public void PostgreSQL_QuoteChars_Are_DoubleQuotes() + { + var dialect = new PostgreSqlDialect(); + dialect.QuotePrefix.Should().Be('"'); + dialect.QuoteSuffix.Should().Be('"'); + } + + [Fact] + public void MySQL_QuoteChars_Are_Backticks() + { + var dialect = new MySqlDialect(); + dialect.QuotePrefix.Should().Be('`'); + dialect.QuoteSuffix.Should().Be('`'); + } + + [Fact] + public void MariaDb_QuoteChars_Are_Backticks() + { + var dialect = new MariaDbDialect(); + dialect.QuotePrefix.Should().Be('`'); + dialect.QuoteSuffix.Should().Be('`'); + } + + [Fact] + public void Sqlite_QuoteChars_Are_DoubleQuotes() + { + var dialect = new SqliteDialect(); + dialect.QuotePrefix.Should().Be('"'); + dialect.QuoteSuffix.Should().Be('"'); + } + + [Fact] + public void Oracle_QuoteChars_Are_DoubleQuotes() + { + var dialect = new OracleDialect(); + dialect.QuotePrefix.Should().Be('"'); + dialect.QuoteSuffix.Should().Be('"'); + } + + // ======================== + // Parameter Prefix Tests + // ======================== + + [Fact] + public void SqlServer_ParameterPrefix_IsAtSign() + { + new SqlServerDialect().ParameterPrefix.Should().Be("@"); + } + + [Fact] + public void PostgreSQL_ParameterPrefix_IsColon() + { + new PostgreSqlDialect().ParameterPrefix.Should().Be(":"); + } + + [Fact] + public void MySQL_ParameterPrefix_IsQuestionMark() + { + new MySqlDialect().ParameterPrefix.Should().Be("?"); + } + + [Fact] + public void MariaDb_ParameterPrefix_IsQuestionMark() + { + new MariaDbDialect().ParameterPrefix.Should().Be("?"); + } + + [Fact] + public void Sqlite_ParameterPrefix_IsAtSign() + { + new SqliteDialect().ParameterPrefix.Should().Be("@"); + } + + [Fact] + public void Oracle_ParameterPrefix_IsColon() + { + new OracleDialect().ParameterPrefix.Should().Be(":"); + } + + // ======================== + // Parameter Name Generation Tests + // ======================== + + [Fact] + public void SqlServer_CreateParameterName_IncludesAtSign() + { + new SqlServerDialect().CreateParameterName("Offset").Should().Be("@Offset"); + } + + [Fact] + public void PostgreSQL_CreateParameterName_IncludesColon() + { + new PostgreSqlDialect().CreateParameterName("Offset").Should().Be(":Offset"); + } + + [Fact] + public void MySQL_CreateParameterName_IncludesQuestionMark() + { + new MySqlDialect().CreateParameterName("Offset").Should().Be("?Offset"); + } + + [Fact] + public void MariaDb_CreateParameterName_IncludesQuestionMark() + { + new MariaDbDialect().CreateParameterName("Offset").Should().Be("?Offset"); + } + + [Fact] + public void Sqlite_CreateParameterName_IncludesAtSign() + { + new SqliteDialect().CreateParameterName("Offset").Should().Be("@Offset"); + } + + [Fact] + public void Oracle_CreateParameterName_IncludesColon() + { + new OracleDialect().CreateParameterName("Offset").Should().Be(":Offset"); + } + + // ======================== + // COUNT Expression Tests + // ======================== + + [Fact] + public void All_Dialects_Use_Count1_Expression() + { + new SqlServerDialect().GetCountExpression.Should().Be("COUNT(1)"); + new PostgreSqlDialect().GetCountExpression.Should().Be("COUNT(1)"); + new MySqlDialect().GetCountExpression.Should().Be("COUNT(1)"); + new MariaDbDialect().GetCountExpression.Should().Be("COUNT(1)"); + new SqliteDialect().GetCountExpression.Should().Be("COUNT(1)"); + new OracleDialect().GetCountExpression.Should().Be("COUNT(1)"); + } + + // ======================== + // Boolean Literal Tests + // ======================== + + [Fact] + public void SqlServer_BooleanLiterals_Are_1_And_0() + { + var dialect = new SqlServerDialect(); + dialect.BooleanTrue.Should().Be("1"); + dialect.BooleanFalse.Should().Be("0"); + } + + [Fact] + public void PostgreSQL_BooleanLiterals_Are_True_False() + { + var dialect = new PostgreSqlDialect(); + dialect.BooleanTrue.Should().Be("TRUE"); + dialect.BooleanFalse.Should().Be("FALSE"); + } + + [Fact] + public void MySQL_BooleanLiterals_Are_True_False() + { + var dialect = new MySqlDialect(); + dialect.BooleanTrue.Should().Be("TRUE"); + dialect.BooleanFalse.Should().Be("FALSE"); + } + + [Fact] + public void MariaDb_BooleanLiterals_Are_True_False() + { + var dialect = new MariaDbDialect(); + dialect.BooleanTrue.Should().Be("TRUE"); + dialect.BooleanFalse.Should().Be("FALSE"); + } + + [Fact] + public void Sqlite_BooleanLiterals_Are_1_And_0() + { + var dialect = new SqliteDialect(); + dialect.BooleanTrue.Should().Be("1"); + dialect.BooleanFalse.Should().Be("0"); + } + + [Fact] + public void Oracle_BooleanLiterals_Are_1_And_0() + { + var dialect = new OracleDialect(); + dialect.BooleanTrue.Should().Be("1"); + dialect.BooleanFalse.Should().Be("0"); + } + + // ======================== + // String Concatenation Tests + // ======================== + + [Fact] + public void SqlServer_Uses_Plus_For_Concatenation() + { + var dialect = new SqlServerDialect(); + dialect.Concatenate("a", "b").Should().Be("a + b"); + } + + [Fact] + public void PostgreSQL_Uses_PipePipe_For_Concatenation() + { + var dialect = new PostgreSqlDialect(); + dialect.Concatenate("a", "b").Should().Be("a || b"); + } + + [Fact] + public void MySQL_Uses_Concat_For_Concatenation() + { + var dialect = new MySqlDialect(); + dialect.Concatenate("a", "b").Should().Be("CONCAT(a, b)"); + } + + [Fact] + public void MariaDb_Uses_Concat_For_Concatenation() + { + var dialect = new MariaDbDialect(); + dialect.Concatenate("a", "b").Should().Be("CONCAT(a, b)"); + } + + [Fact] + public void Sqlite_Uses_PipePipe_For_Concatenation() + { + var dialect = new SqliteDialect(); + dialect.Concatenate("a", "b").Should().Be("a || b"); + } + + [Fact] + public void Oracle_Uses_PipePipe_For_Concatenation() + { + var dialect = new OracleDialect(); + dialect.Concatenate("a", "b").Should().Be("a || b"); + } + + // ======================== + // Limit Expression (Top-N) Tests + // ======================== + + [Fact] + public void SqlServer_LimitExpression_Uses_Top() + { + var dialect = new SqlServerDialect(); + dialect.GetLimitExpression("@p0").Should().Be("TOP (@p0)"); + } + + [Fact] + public void PostgreSQL_LimitExpression_Uses_Limit() + { + var dialect = new PostgreSqlDialect(); + dialect.GetLimitExpression("?p0").Should().Be("LIMIT ?p0"); + } + + [Fact] + public void MySQL_LimitExpression_Uses_Limit() + { + var dialect = new MySqlDialect(); + dialect.GetLimitExpression("?p0").Should().Be("LIMIT ?p0"); + } + + [Fact] + public void MariaDb_LimitExpression_Uses_Limit() + { + var dialect = new MariaDbDialect(); + dialect.GetLimitExpression("?p0").Should().Be("LIMIT ?p0"); + } + + [Fact] + public void Sqlite_LimitExpression_Uses_Limit() + { + var dialect = new SqliteDialect(); + dialect.GetLimitExpression("@p0").Should().Be("LIMIT @p0"); + } + + [Fact] + public void Oracle_LimitExpression_Uses_FetchFirst() + { + var dialect = new OracleDialect(); + dialect.GetLimitExpression(":p0").Should().Be("FETCH FIRST :p0 ROWS ONLY"); + } + + // ======================== + // Full SQL Generation Tests + // ======================== + + [Fact] + public void SqlServer_Generates_Correct_Select_SQL() + { + var options = CreatePagedOptions(); + var command = new SqlTranslator(_registry, new SqlServerDialect()).Translate(options); + + command.Sql.Should().Contain("SELECT"); + command.Sql.Should().Contain("FROM"); + command.Sql.Should().Contain("OFFSET"); + command.Sql.Should().Contain("FETCH NEXT"); + command.Parameters.Should().ContainKey("@Offset"); + command.Parameters.Should().ContainKey("@PageSize"); + } + + [Fact] + public void PostgreSQL_Generates_Correct_Select_SQL() + { + var options = CreatePagedOptions(); + var command = new SqlTranslator(_registry, new PostgreSqlDialect()).Translate(options); + + command.Sql.Should().Contain("SELECT"); + command.Sql.Should().Contain("FROM"); + command.Sql.Should().Contain("OFFSET"); + command.Sql.Should().Contain("LIMIT"); + command.Parameters.Should().ContainKey(":Offset"); + command.Parameters.Should().ContainKey(":PageSize"); + } + + [Fact] + public void MySQL_Generates_Correct_Select_SQL() + { + var options = CreatePagedOptions(); + var command = new SqlTranslator(_registry, new MySqlDialect()).Translate(options); + + command.Sql.Should().Contain("SELECT"); + command.Sql.Should().Contain("FROM"); + command.Sql.Should().Contain("LIMIT"); + command.Sql.Should().Contain("OFFSET"); + command.Parameters.Should().ContainKey("?Offset"); + command.Parameters.Should().ContainKey("?PageSize"); + } + + [Fact] + public void MariaDb_Generates_Correct_Select_SQL() + { + var options = CreatePagedOptions(); + var command = new SqlTranslator(_registry, new MariaDbDialect()).Translate(options); + + command.Sql.Should().Contain("SELECT"); + command.Sql.Should().Contain("FROM"); + command.Sql.Should().Contain("LIMIT"); + command.Sql.Should().Contain("OFFSET"); + command.Parameters.Should().ContainKey("?Offset"); + command.Parameters.Should().ContainKey("?PageSize"); + } + + [Fact] + public void Sqlite_Generates_Correct_Select_SQL() + { + var options = CreatePagedOptions(); + var command = new SqlTranslator(_registry, new SqliteDialect()).Translate(options); + + command.Sql.Should().Contain("SELECT"); + command.Sql.Should().Contain("FROM"); + command.Sql.Should().Contain("LIMIT"); + command.Sql.Should().Contain("OFFSET"); + command.Parameters.Should().ContainKey("@Offset"); + command.Parameters.Should().ContainKey("@PageSize"); + } + + [Fact] + public void Oracle_Generates_Correct_Select_SQL() + { + var options = CreatePagedOptions(); + var command = new SqlTranslator(_registry, new OracleDialect()).Translate(options); + + command.Sql.Should().Contain("SELECT"); + command.Sql.Should().Contain("FROM"); + command.Sql.Should().Contain("OFFSET"); + command.Sql.Should().Contain("FETCH NEXT"); + command.Parameters.Should().ContainKey(":Offset"); + command.Parameters.Should().ContainKey(":PageSize"); + } + + // ======================== + // Filter SQL Generation Tests + // ======================== + + [Fact] + public void All_Dialects_Generate_Where_Clause_For_Equal_Filter() + { + var options = CreateFilteredOptions(); + + var sqlServerCmd = new SqlTranslator(_registry, new SqlServerDialect()).Translate(options); + var pgCmd = new SqlTranslator(_registry, new PostgreSqlDialect()).Translate(options); + var mySqlCmd = new SqlTranslator(_registry, new MySqlDialect()).Translate(options); + var mariadbCmd = new SqlTranslator(_registry, new MariaDbDialect()).Translate(options); + var sqliteCmd = new SqlTranslator(_registry, new SqliteDialect()).Translate(options); + var oracleCmd = new SqlTranslator(_registry, new OracleDialect()).Translate(options); + + sqlServerCmd.Sql.Should().Contain("WHERE"); + pgCmd.Sql.Should().Contain("WHERE"); + mySqlCmd.Sql.Should().Contain("WHERE"); + mariadbCmd.Sql.Should().Contain("WHERE"); + sqliteCmd.Sql.Should().Contain("WHERE"); + oracleCmd.Sql.Should().Contain("WHERE"); + } + + [Fact] + public void All_Dialects_Generate_Like_Clause_For_Contains_Filter() + { + var options = CreateContainsFilterOptions(); + + var sqlServerCmd = new SqlTranslator(_registry, new SqlServerDialect()).Translate(options); + var pgCmd = new SqlTranslator(_registry, new PostgreSqlDialect()).Translate(options); + var mySqlCmd = new SqlTranslator(_registry, new MySqlDialect()).Translate(options); + var mariadbCmd = new SqlTranslator(_registry, new MariaDbDialect()).Translate(options); + var sqliteCmd = new SqlTranslator(_registry, new SqliteDialect()).Translate(options); + var oracleCmd = new SqlTranslator(_registry, new OracleDialect()).Translate(options); + + sqlServerCmd.Sql.Should().Contain("LIKE"); + pgCmd.Sql.Should().Contain("LIKE"); + mySqlCmd.Sql.Should().Contain("LIKE"); + mariadbCmd.Sql.Should().Contain("LIKE"); + sqliteCmd.Sql.Should().Contain("LIKE"); + oracleCmd.Sql.Should().Contain("LIKE"); + } + + [Fact] + public void All_Dialects_Generate_In_Clause() + { + var options = CreateInFilterOptions(); + + var sqlServerCmd = new SqlTranslator(_registry, new SqlServerDialect()).Translate(options); + var pgCmd = new SqlTranslator(_registry, new PostgreSqlDialect()).Translate(options); + var mySqlCmd = new SqlTranslator(_registry, new MySqlDialect()).Translate(options); + var mariadbCmd = new SqlTranslator(_registry, new MariaDbDialect()).Translate(options); + var sqliteCmd = new SqlTranslator(_registry, new SqliteDialect()).Translate(options); + var oracleCmd = new SqlTranslator(_registry, new OracleDialect()).Translate(options); + + sqlServerCmd.Sql.Should().Contain("IN"); + pgCmd.Sql.Should().Contain("IN"); + mySqlCmd.Sql.Should().Contain("IN"); + mariadbCmd.Sql.Should().Contain("IN"); + sqliteCmd.Sql.Should().Contain("IN"); + oracleCmd.Sql.Should().Contain("IN"); + } + + [Fact] + public void All_Dialects_Generate_Between_Clause() + { + var options = CreateBetweenFilterOptions(); + + var sqlServerCmd = new SqlTranslator(_registry, new SqlServerDialect()).Translate(options); + var pgCmd = new SqlTranslator(_registry, new PostgreSqlDialect()).Translate(options); + var mySqlCmd = new SqlTranslator(_registry, new MySqlDialect()).Translate(options); + var mariadbCmd = new SqlTranslator(_registry, new MariaDbDialect()).Translate(options); + var sqliteCmd = new SqlTranslator(_registry, new SqliteDialect()).Translate(options); + var oracleCmd = new SqlTranslator(_registry, new OracleDialect()).Translate(options); + + sqlServerCmd.Sql.Should().Contain("BETWEEN"); + pgCmd.Sql.Should().Contain("BETWEEN"); + mySqlCmd.Sql.Should().Contain("BETWEEN"); + mariadbCmd.Sql.Should().Contain("BETWEEN"); + sqliteCmd.Sql.Should().Contain("BETWEEN"); + oracleCmd.Sql.Should().Contain("BETWEEN"); + } + + // ======================== + // Aggregate SQL Generation Tests + // ======================== + + [Fact] + public void All_Dialects_Generate_Aggregate_Select() + { + var options = CreateAggregateOptions(); + + var sqlServerCmd = new SqlTranslator(_registry, new SqlServerDialect()).Translate(options); + var pgCmd = new SqlTranslator(_registry, new PostgreSqlDialect()).Translate(options); + var mySqlCmd = new SqlTranslator(_registry, new MySqlDialect()).Translate(options); + var mariadbCmd = new SqlTranslator(_registry, new MariaDbDialect()).Translate(options); + var sqliteCmd = new SqlTranslator(_registry, new SqliteDialect()).Translate(options); + var oracleCmd = new SqlTranslator(_registry, new OracleDialect()).Translate(options); + + sqlServerCmd.Sql.Should().Contain("COUNT"); + pgCmd.Sql.Should().Contain("COUNT"); + mySqlCmd.Sql.Should().Contain("COUNT"); + mariadbCmd.Sql.Should().Contain("COUNT"); + sqliteCmd.Sql.Should().Contain("COUNT"); + oracleCmd.Sql.Should().Contain("COUNT"); + } + + [Fact] + public void All_Dialects_Generate_Quoted_Aggregate_Alias() + { + var options = CreateAggregateOptions(); + + var sqlServerCmd = new SqlTranslator(_registry, new SqlServerDialect()).Translate(options); + var pgCmd = new SqlTranslator(_registry, new PostgreSqlDialect()).Translate(options); + var mySqlCmd = new SqlTranslator(_registry, new MySqlDialect()).Translate(options); + var mariadbCmd = new SqlTranslator(_registry, new MariaDbDialect()).Translate(options); + var sqliteCmd = new SqlTranslator(_registry, new SqliteDialect()).Translate(options); + var oracleCmd = new SqlTranslator(_registry, new OracleDialect()).Translate(options); + + // Each dialect should quote the alias using its own identifier escaping + sqlServerCmd.Sql.Should().Contain("[TotalCount]"); + pgCmd.Sql.Should().Contain("\"TotalCount\""); + mySqlCmd.Sql.Should().Contain("`TotalCount`"); + mariadbCmd.Sql.Should().Contain("`TotalCount`"); + sqliteCmd.Sql.Should().Contain("\"TotalCount\""); + oracleCmd.Sql.Should().Contain("\"TotalCount\""); + } + + // ======================== + // OrderBy SQL Generation Tests + // ======================== + + [Fact] + public void All_Dialects_Generate_OrderBy_Clause() + { + var options = CreateSortedOptions(); + + var sqlServerCmd = new SqlTranslator(_registry, new SqlServerDialect()).Translate(options); + var pgCmd = new SqlTranslator(_registry, new PostgreSqlDialect()).Translate(options); + var mySqlCmd = new SqlTranslator(_registry, new MySqlDialect()).Translate(options); + var mariadbCmd = new SqlTranslator(_registry, new MariaDbDialect()).Translate(options); + var sqliteCmd = new SqlTranslator(_registry, new SqliteDialect()).Translate(options); + var oracleCmd = new SqlTranslator(_registry, new OracleDialect()).Translate(options); + + sqlServerCmd.Sql.Should().Contain("ORDER BY"); + pgCmd.Sql.Should().Contain("ORDER BY"); + mySqlCmd.Sql.Should().Contain("ORDER BY"); + mariadbCmd.Sql.Should().Contain("ORDER BY"); + sqliteCmd.Sql.Should().Contain("ORDER BY"); + oracleCmd.Sql.Should().Contain("ORDER BY"); + } + + [Fact] + public void All_Dialects_Generate_Descending_OrderBy() + { + var options = CreateSortedOptions(descending: true); + + var sqlServerCmd = new SqlTranslator(_registry, new SqlServerDialect()).Translate(options); + var pgCmd = new SqlTranslator(_registry, new PostgreSqlDialect()).Translate(options); + + sqlServerCmd.Sql.Should().Contain("DESC"); + pgCmd.Sql.Should().Contain("DESC"); + } + + // ======================== + // Distinct SQL Generation Tests + // ======================== + + [Fact] + public void All_Dialects_Generate_Distinct_Clause() + { + var options = CreateDistinctOptions(); + + var sqlServerCmd = new SqlTranslator(_registry, new SqlServerDialect()).Translate(options); + var pgCmd = new SqlTranslator(_registry, new PostgreSqlDialect()).Translate(options); + var mySqlCmd = new SqlTranslator(_registry, new MySqlDialect()).Translate(options); + var mariadbCmd = new SqlTranslator(_registry, new MariaDbDialect()).Translate(options); + var sqliteCmd = new SqlTranslator(_registry, new SqliteDialect()).Translate(options); + var oracleCmd = new SqlTranslator(_registry, new OracleDialect()).Translate(options); + + sqlServerCmd.Sql.Should().Contain("SELECT DISTINCT"); + pgCmd.Sql.Should().Contain("SELECT DISTINCT"); + mySqlCmd.Sql.Should().Contain("SELECT DISTINCT"); + mariadbCmd.Sql.Should().Contain("SELECT DISTINCT"); + sqliteCmd.Sql.Should().Contain("SELECT DISTINCT"); + oracleCmd.Sql.Should().Contain("SELECT DISTINCT"); + } + + // ======================== + // GroupBy SQL Generation Tests + // ======================== + + [Fact] + public void All_Dialects_Generate_GroupBy_Clause() + { + var options = CreateGroupByOptions(); + + var sqlServerCmd = new SqlTranslator(_registry, new SqlServerDialect()).Translate(options); + var pgCmd = new SqlTranslator(_registry, new PostgreSqlDialect()).Translate(options); + var mySqlCmd = new SqlTranslator(_registry, new MySqlDialect()).Translate(options); + var mariadbCmd = new SqlTranslator(_registry, new MariaDbDialect()).Translate(options); + var sqliteCmd = new SqlTranslator(_registry, new SqliteDialect()).Translate(options); + var oracleCmd = new SqlTranslator(_registry, new OracleDialect()).Translate(options); + + sqlServerCmd.Sql.Should().Contain("GROUP BY"); + pgCmd.Sql.Should().Contain("GROUP BY"); + mySqlCmd.Sql.Should().Contain("GROUP BY"); + mariadbCmd.Sql.Should().Contain("GROUP BY"); + sqliteCmd.Sql.Should().Contain("GROUP BY"); + oracleCmd.Sql.Should().Contain("GROUP BY"); + } + + // ======================== + // Having SQL Generation Tests + // ======================== + + [Fact] + public void All_Dialects_Generate_Having_Clause() + { + var options = CreateHavingOptions(); + + var sqlServerCmd = new SqlTranslator(_registry, new SqlServerDialect()).Translate(options); + var pgCmd = new SqlTranslator(_registry, new PostgreSqlDialect()).Translate(options); + var mySqlCmd = new SqlTranslator(_registry, new MySqlDialect()).Translate(options); + var mariadbCmd = new SqlTranslator(_registry, new MariaDbDialect()).Translate(options); + var sqliteCmd = new SqlTranslator(_registry, new SqliteDialect()).Translate(options); + var oracleCmd = new SqlTranslator(_registry, new OracleDialect()).Translate(options); + + sqlServerCmd.Sql.Should().Contain("HAVING"); + pgCmd.Sql.Should().Contain("HAVING"); + mySqlCmd.Sql.Should().Contain("HAVING"); + mariadbCmd.Sql.Should().Contain("HAVING"); + sqliteCmd.Sql.Should().Contain("HAVING"); + oracleCmd.Sql.Should().Contain("HAVING"); + } + + // ======================== + // Join SQL Generation Tests + // ======================== + + [Fact] + public void All_Dialects_Generate_Join_Clause() + { + var entityWithJoin = new EntityMapping(typeof(TestEntityWithJoin), "users", null); + entityWithJoin.MapJoin("Roles", typeof(object), "roles", "users.Id = roles.UserId"); + ((MappingRegistry)_registry).Register(entityWithJoin); + + var options = new QueryOptions + { + Includes = new List { "Roles" } + }; + options.Items["EntityType"] = typeof(TestEntityWithJoin); + + var sqlServerCmd = new SqlTranslator(_registry, new SqlServerDialect()).Translate(options); + var pgCmd = new SqlTranslator(_registry, new PostgreSqlDialect()).Translate(options); + var mySqlCmd = new SqlTranslator(_registry, new MySqlDialect()).Translate(options); + var mariadbCmd = new SqlTranslator(_registry, new MariaDbDialect()).Translate(options); + var sqliteCmd = new SqlTranslator(_registry, new SqliteDialect()).Translate(options); + var oracleCmd = new SqlTranslator(_registry, new OracleDialect()).Translate(options); + + sqlServerCmd.Sql.Should().Contain("LEFT JOIN"); + pgCmd.Sql.Should().Contain("LEFT JOIN"); + mySqlCmd.Sql.Should().Contain("LEFT JOIN"); + mariadbCmd.Sql.Should().Contain("LEFT JOIN"); + sqliteCmd.Sql.Should().Contain("LEFT JOIN"); + oracleCmd.Sql.Should().Contain("LEFT JOIN"); + } + + // ======================== + // ISqlDialect Polymorphism Test + // ======================== + + [Fact] + public void All_Dialects_Implement_ISqlDialect() + { + ISqlDialect sqlServer = new SqlServerDialect(); + ISqlDialect postgres = new PostgreSqlDialect(); + ISqlDialect mysql = new MySqlDialect(); + ISqlDialect mariaDb = new MariaDbDialect(); + ISqlDialect sqlite = new SqliteDialect(); + ISqlDialect oracle = new OracleDialect(); + + // Verify they all implement the interface and return non-empty values + sqlServer.ParameterPrefix.Should().NotBeNullOrEmpty(); + postgres.ParameterPrefix.Should().NotBeNullOrEmpty(); + mysql.ParameterPrefix.Should().NotBeNullOrEmpty(); + mariaDb.ParameterPrefix.Should().NotBeNullOrEmpty(); + sqlite.ParameterPrefix.Should().NotBeNullOrEmpty(); + oracle.ParameterPrefix.Should().NotBeNullOrEmpty(); + + sqlServer.QuoteIdentifier("test").Should().NotBeNullOrEmpty(); + postgres.QuoteIdentifier("test").Should().NotBeNullOrEmpty(); + mysql.QuoteIdentifier("test").Should().NotBeNullOrEmpty(); + mariaDb.QuoteIdentifier("test").Should().NotBeNullOrEmpty(); + sqlite.QuoteIdentifier("test").Should().NotBeNullOrEmpty(); + oracle.QuoteIdentifier("test").Should().NotBeNullOrEmpty(); + + sqlServer.GetCountExpression.Should().NotBeNullOrEmpty(); + postgres.GetCountExpression.Should().NotBeNullOrEmpty(); + mysql.GetCountExpression.Should().NotBeNullOrEmpty(); + mariaDb.GetCountExpression.Should().NotBeNullOrEmpty(); + sqlite.GetCountExpression.Should().NotBeNullOrEmpty(); + oracle.GetCountExpression.Should().NotBeNullOrEmpty(); + } + + // ======================== + // MySQL vs MariaDB Distinction Test + // ======================== + + [Fact] + public void MySQL_And_MariaDb_Are_Separate_Implementations() + { + var mySql = new MySqlDialect(); + var mariaDb = new MariaDbDialect(); + + // Both should be separate types + mySql.Should().NotBeSameAs(mariaDb); + mySql.GetType().Should().NotBe(mariaDb.GetType()); + + // They should have the same parameter prefix and quoting style + // but are independently replaceable + mySql.ParameterPrefix.Should().Be(mariaDb.ParameterPrefix); + mySql.QuotePrefix.Should().Be(mariaDb.QuotePrefix); + mySql.QuoteSuffix.Should().Be(mariaDb.QuoteSuffix); + + // Both implement ISqlDialect + ((ISqlDialect)mySql).GetCountExpression.Should().Be("COUNT(1)"); + ((ISqlDialect)mariaDb).GetCountExpression.Should().Be("COUNT(1)"); + } + + // ======================== + // Oracle-Specific Tests + // ======================== + + [Fact] + public void Oracle_Has_Dedicated_Implementation() + { + var oracle = new OracleDialect(); + + // Oracle should NOT be lumped with PostgreSQL even though both use : prefix + oracle.ParameterPrefix.Should().Be(":"); + oracle.QuotePrefix.Should().Be('"'); + + // Oracle uses 1/0 for booleans, not TRUE/FALSE keywords in SQL + oracle.BooleanTrue.Should().Be("1"); + oracle.BooleanFalse.Should().Be("0"); + + // Oracle-specific limit syntax + oracle.GetLimitExpression(":p0").Should().Be("FETCH FIRST :p0 ROWS ONLY"); + } + + // ======================== + // SQLite-Specific Tests + // ======================== + + [Fact] + public void Sqlite_Has_Dedicated_Implementation() + { + var sqlite = new SqliteDialect(); + + // SQLite uses @ prefix (Microsoft.Data.Sqlite convention) + sqlite.ParameterPrefix.Should().Be("@"); + + // SQLite uses double-quote for identifiers + sqlite.QuotePrefix.Should().Be('"'); + sqlite.QuoteSuffix.Should().Be('"'); + + // SQLite uses 1/0 for booleans + sqlite.BooleanTrue.Should().Be("1"); + sqlite.BooleanFalse.Should().Be("0"); + + // SQLite uses standard LIMIT/OFFSET + var paging = sqlite.GetPagingClause("@Offset", "@PageSize"); + paging.Should().Contain("LIMIT"); + paging.Should().Contain("OFFSET"); + } + + // ======================== + // MariaDB-Specific Tests + // ======================== + + [Fact] + public void MariaDb_Has_Dedicated_Implementation() + { + var mariaDb = new MariaDbDialect(); + + // MariaDB uses ? prefix + mariaDb.ParameterPrefix.Should().Be("?"); + + // MariaDB uses backtick quoting + mariaDb.QuotePrefix.Should().Be('`'); + mariaDb.QuoteSuffix.Should().Be('`'); + + // MariaDB supports TRUE/FALSE keywords + mariaDb.BooleanTrue.Should().Be("TRUE"); + mariaDb.BooleanFalse.Should().Be("FALSE"); + + // MariaDB uses standard MySQL-style LIMIT/OFFSET + var paging = mariaDb.GetPagingClause("?Offset", "?PageSize"); + paging.Should().Contain("LIMIT"); + paging.Should().Contain("OFFSET"); + } + + // ======================== + // Helper Methods + // ======================== + + private static QueryOptions CreatePagedOptions() + { + var options = new QueryOptions + { + Paging = { Page = 2, PageSize = 10 } + }; + options.Items["EntityType"] = typeof(TestEntity); + return options; + } + + private static QueryOptions CreateFilteredOptions() + { + var options = new QueryOptions + { + Filter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Name", Operator = "eq", Value = "Test" }] + } + }; + options.Items["EntityType"] = typeof(TestEntity); + return options; + } + + private static QueryOptions CreateContainsFilterOptions() + { + var options = new QueryOptions + { + Filter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Name", Operator = "contains", Value = "test" }] + } + }; + options.Items["EntityType"] = typeof(TestEntity); + return options; + } + + private static QueryOptions CreateInFilterOptions() + { + var options = new QueryOptions + { + Filter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Status", Operator = "in", Value = "Active,Pending" }] + } + }; + options.Items["EntityType"] = typeof(TestEntity); + return options; + } + + private static QueryOptions CreateBetweenFilterOptions() + { + var options = new QueryOptions + { + Filter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Age", Operator = "between", Value = "20,30" }] + } + }; + options.Items["EntityType"] = typeof(TestEntity); + return options; + } + + private static QueryOptions CreateAggregateOptions() + { + var options = new QueryOptions + { + Aggregates = [new AggregateModel { Function = "count", Alias = "TotalCount", Field = "*" }] + }; + options.Items["EntityType"] = typeof(TestEntity); + return options; + } + + private static QueryOptions CreateSortedOptions(bool descending = false) + { + var options = new QueryOptions + { + Sort = [new SortNode { Field = "Name", Descending = descending }] + }; + options.Items["EntityType"] = typeof(TestEntity); + return options; + } + + private static QueryOptions CreateDistinctOptions() + { + var options = new QueryOptions + { + Distinct = true + }; + options.Items["EntityType"] = typeof(TestEntity); + return options; + } + + private static QueryOptions CreateGroupByOptions() + { + var options = new QueryOptions + { + GroupBy = ["Status"] + }; + options.Items["EntityType"] = typeof(TestEntity); + return options; + } + + private static QueryOptions CreateHavingOptions() + { + var options = new QueryOptions + { + GroupBy = ["Status"], + Having = new HavingCondition + { + Field = "Amount", + Operator = "gt", + Value = "100", + Function = "sum" + } + }; + options.Items["EntityType"] = typeof(TestEntity); + return options; + } + + private static QueryOptions CreateJoinOptions() + { + var registry = new MappingRegistry(); + var entityWithJoin = new EntityMapping(typeof(TestEntityWithJoin), "users", null); + entityWithJoin.MapJoin("Roles", typeof(object), "roles", "users.Id = roles.UserId"); + ((MappingRegistry)registry).Register(entityWithJoin); + + var options = new QueryOptions + { + Includes = new List { "Roles" } + }; + options.Items["EntityType"] = typeof(TestEntityWithJoin); + + var translator = new SqlTranslator(registry, new SqlServerDialect()); + var _ = translator.Translate(options); // warm-up + + return options; + } + + private class TestEntity + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public int Age { get; set; } + public string City { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + } + + private class TestEntityWithJoin + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } +} diff --git a/tests/FlexQuery.NET.Tests/Dapper/Security/SqlInjectionTests.cs b/tests/FlexQuery.NET.Tests/Dapper/Security/SqlInjectionTests.cs new file mode 100644 index 0000000..8228f80 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Dapper/Security/SqlInjectionTests.cs @@ -0,0 +1,517 @@ +using FlexQuery.NET.Models; +using FlexQuery.NET.Dapper.Mapping; +using FlexQuery.NET.Dapper.Sql; +using FlexQuery.NET.Dapper.Sql.Translators; +using FlexQuery.NET.Dapper.Dialects; +using FlexQuery.NET.Parsers; +using FlexQuery.NET.Parsers.Dsl; +using FlexQuery.NET.Validation; +using FlexQuery.NET.Exceptions; +using FlexQuery.NET.Extensions; +using FluentAssertions; +using Microsoft.Extensions.Primitives; +using Xunit; + +namespace FlexQuery.NET.Tests.Dapper.Security; + +/// +/// Comprehensive SQL injection prevention validation tests. +/// Covers: injection in filters, sorts, selects, includes, group by, having, field names, values. +/// Also validates parameterization and identifier quoting. +/// +public class SqlInjectionTests +{ + private readonly IMappingRegistry _registry = new MappingRegistry(); + private readonly SqlTranslator _translator = new SqlTranslator(new MappingRegistry(), new SqlServerDialect()); + + private class TestEntity + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public int Age { get; set; } + public decimal Price { get; set; } + public string SecretField { get; set; } = string.Empty; + public List Orders { get; set; } = new(); + } + + private class Order + { + public int Id { get; set; } + public decimal Total { get; set; } + } + + // ==================== FILTER VALUE INJECTION ==================== + + [Fact] + public void Should_Generate_Parameterized_SQL_For_Filter_With_Special_Characters() + { + var options = new QueryOptions + { + Filter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Name", Operator = "eq", Value = "'; DROP TABLE Users;--" }] + } + }; + options.Items["EntityType"] = typeof(TestEntity); + options.Paging.Disabled = true; + + var command = _translator.Translate(options); + + command.Sql.Should().Contain("@p0"); + command.Sql.Should().NotContain("DROP TABLE"); + command.Parameters.Should().ContainKey("@p0"); + command.Parameters["@p0"].Should().Be("'; DROP TABLE Users;--"); + } + + [Fact] + public void Should_Reject_Filter_Field_With_SQL_Injection_Pattern() + { + var options = new QueryOptions + { + Filter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Name'); DROP TABLE Users;--", Operator = "eq", Value = "test" }] + } + }; + options.Items["EntityType"] = typeof(TestEntity); + options.Paging.Disabled = true; + + // The field name gets quoted as identifier; injection within field name is neutralized + // But if field doesn't exist, Name');DROP... would be rejected as FIELD_NOT_FOUND + Action validate = () => options.ValidateOrThrow(new QueryExecutionOptions()); + + // Injection pattern in field name fails validation as unknown field + validate.Should().Throw() + .Which.Result.Errors.Should().Contain(e => e.Code == "FIELD_NOT_FOUND" || e.Code == "FIELD_ACCESS_DENIED"); + } + + [Fact] + public void Should_Parameterize_Between_Values() + { + var options = new QueryOptions + { + Filter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Age", Operator = "between", Value = "18;DROP TABLE Users;,65" }] + } + }; + options.Items["EntityType"] = typeof(TestEntity); + options.Paging.Disabled = true; + + var command = _translator.Translate(options); + + command.Sql.Should().Contain("BETWEEN @p0 AND @p1"); + command.Sql.Should().NotContain("DROP TABLE"); + } + + [Fact] + public void Should_Parameterize_In_Clause_Values() + { + var options = new QueryOptions + { + Filter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Name", Operator = "in", Value = "a', OR '1'='1" }] + } + }; + options.Items["EntityType"] = typeof(TestEntity); + options.Paging.Disabled = true; + + var command = _translator.Translate(options); + + command.Sql.Should().Contain("IN ("); + command.Sql.Should().NotContain("OR '1'='1"); + } + + // ==================== SORT INJECTION ==================== + + [Fact] + public void Should_Quote_Sort_Fields_Preventing_Injection() + { + var options = new QueryOptions + { + Filter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Name", Operator = "eq", Value = "test" }] + }, + Sort = [new SortNode { Field = "CreatedAt", Descending = false }], + Paging = { Disabled = true } + }; + options.Items["EntityType"] = typeof(TestEntity); + + var command = _translator.Translate(options); + + command.Sql.Should().Contain("[CreatedAt]"); + command.Sql.Should().NotContain(";"); // no trailing semicolons + } + + [Fact] + public void Should_Quote_Sort_Field_With_Malicious_Name() + { + var options = new QueryOptions + { + Sort = [new SortNode { Field = "Name; DROP TABLE Users;--", Descending = false }], + Paging = { Disabled = true } + }; + options.Items["EntityType"] = typeof(TestEntity); + + var command = _translator.Translate(options); + + // Field name is bracketed, so injection is neutralized + command.Sql.Should().Contain("[Name; DROP TABLE Users;--]"); + // The quoted identifier [Name; DROP TABLE Users;--] is safe - entire string is literal column name + } + + // ==================== SELECT/PROJECTION INJECTION ==================== + + [Fact] + public void Should_Quote_Select_Fields_Preventing_Injection() + { + var options = new QueryOptions + { + Select = ["Id", "Name", "(SELECT * FROM Users)"], + Paging = { Disabled = true } + }; + options.Items["EntityType"] = typeof(TestEntity); + + var command = _translator.Translate(options); + + command.Sql.Should().Contain("[Id]"); + command.Sql.Should().Contain("[Name]"); + command.Sql.Should().Contain("[(SELECT * FROM Users)]"); // quoted as identifier + } + + [Fact] + public void Should_Quote_Select_Field_With_SQL_Injection_Payload() + { + var options = new QueryOptions + { + Select = ["Id); DROP TABLE Users; --"], + Paging = { Disabled = true } + }; + options.Items["EntityType"] = typeof(TestEntity); + + var command = _translator.Translate(options); + + // The quoted identifier is safe - entire thing is treated as column name, not SQL + command.Sql.Should().Contain("[Id); DROP TABLE Users; --]"); + } + + // ==================== GROUP BY INJECTION ==================== + + [Fact] + public void Should_Quote_GroupBy_Fields_Preventing_Injection() + { + var options = new QueryOptions + { + GroupBy = ["Status; DROP TABLE Orders"], + Paging = { Disabled = true } + }; + options.Items["EntityType"] = typeof(TestEntity); + + var command = _translator.Translate(options); + + command.Sql.Should().Contain("GROUP BY"); + command.Sql.Should().Contain("[Status; DROP TABLE Orders]"); + // Properly quoted - the entire string is treated as a column name, not SQL + } + + [Fact] + public void Should_Quote_Multiple_GroupBy_Fields() + { + var options = new QueryOptions + { + GroupBy = ["Name', 'Value') SELECT * FROM Users--"], + Paging = { Disabled = true } + }; + options.Items["EntityType"] = typeof(TestEntity); + + var command = _translator.Translate(options); + + command.Sql.Should().Contain("GROUP BY"); + // Field should be quoted to neutralize injection + command.Sql.Should().Contain("[Name', 'Value') SELECT * FROM Users--]"); + } + + // ==================== HAVING INJECTION ==================== + + [Fact] + public void Should_Parameterize_Having_Values() + { + var options = new QueryOptions + { + GroupBy = new List { "Name" }, + Having = new HavingCondition + { + Function = "count", + Field = "Id", + Operator = "gt", + Value = "5; DROP TABLE Users;--" + } + }; + options.Items["EntityType"] = typeof(TestEntity); + options.Paging.Disabled = true; + + var command = _translator.Translate(options); + + command.Sql.Should().Contain("@p0"); + command.Sql.Should().NotContain("DROP TABLE"); + } + + // ==================== AGGREGATE INJECTION ==================== + + [Fact] + public void Should_Quote_Aggregate_Fields() + { + var options = new QueryOptions + { + Aggregates = { new AggregateModel { Function = "sum", Field = "Price", Alias = "Total" } }, + Paging = { Disabled = true } + }; + options.Items["EntityType"] = typeof(TestEntity); + + var command = _translator.Translate(options); + + command.Sql.Should().Contain("SUM([Price]) AS [Total]"); + // Alias is properly quoted - the malicious content is neutralized + } + + [Fact] + public void Should_Quote_Aggregate_Alias_To_Prevent_Injection() + { + var options = new QueryOptions + { + Aggregates = { new AggregateModel { Function = "count", Field = "Id", Alias = "Cnt); DROP TABLE Users;--" } }, + Paging = { Disabled = true } + }; + options.Items["EntityType"] = typeof(TestEntity); + + var command = _translator.Translate(options); + + command.Sql.Should().Contain("AS [Cnt); DROP TABLE Users;--]"); + // Properly quoted - the entire alias is treated as identifier, not SQL + } + + // ==================== NAVIGATION/INCLUDE INJECTION ==================== + + [Fact] + public void Should_Quote_Navigation_Property_Names() + { + var options = new QueryOptions + { + Includes = ["Orders"], + Paging = { Disabled = true } + }; + options.Items["EntityType"] = typeof(TestEntity); + + var command = _translator.Translate(options); + + command.Sql.Should().Contain("[tests]"); // Main table (default name from TestEntity) + } + + [Fact] + public void Should_Neutralize_Malicious_Navigation_Name() + { + var options = new QueryOptions + { + Includes = ["Orders; DROP TABLE Users;--"], + Paging = { Disabled = true } + }; + options.Items["EntityType"] = typeof(TestEntity); + + // The include name will be treated as a literal navigation name that likely doesn't exist + // But translation should still quote it as an identifier to prevent injection + var command = _translator.Translate(options); + + // Since the navigation isn't mapped, it won't appear in SQL, but the quoting is still applied + } + + // ==================== FIELD NAME AS SQL KEYWORD ==================== + + [Fact] + public void Should_Quote_Field_Named_With_SQL_Keyword() + { + var options = new QueryOptions + { + Select = ["Order"], // "Order" is a SQL keyword + Paging = { Disabled = true } + }; + options.Items["EntityType"] = typeof(TestEntity); + + var command = _translator.Translate(options); + + command.Sql.Should().Contain("[Order]"); // always quoted + } + + [Fact] + public void Should_Handle_Field_Named_With_Special_Chars() + { + var options = new QueryOptions + { + Filter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Field'WithQuotes", Operator = "eq", Value = "test" }] + }, + Paging = { Disabled = true } + }; + options.Items["EntityType"] = typeof(TestEntity); + + // Field with quote in name - should be quoted as [Field'WithQuotes] + var command = _translator.Translate(options); + + command.Sql.Should().Contain("[Field'WithQuotes]"); + } + + // ==================== UNION/SUBQUERY INJECTION ==================== + + [Fact] + public void Should_Not_Allow_Union_Injection_Through_Select() + { + var options = new QueryOptions + { + Select = ["Id", "UNION SELECT * FROM Users"], + Paging = { Disabled = true } + }; + options.Items["EntityType"] = typeof(TestEntity); + + var command = _translator.Translate(options); + + // UNION would be treated as a literal field name and quoted + command.Sql.Should().Contain("[UNION SELECT * FROM Users]"); + // Properly quoted - entire string is identifier, not SQL + } + + [Fact] + public void Should_Not_Allow_Subquery_Injection_Through_Select() + { + var options = new QueryOptions + { + Select = ["(SELECT @@VERSION)"], + Paging = { Disabled = true } + }; + options.Items["EntityType"] = typeof(TestEntity); + + var command = _translator.Translate(options); + + // Subquery would be quoted as identifier + command.Sql.Should().Contain("[(SELECT @@VERSION)]"); + // Properly quoted - treated as identifier, not executed as subquery + } + + // ==================== COMMA/LOGIC SEPARATOR INJECTION ==================== + + [Fact] + public void Should_Quote_Field_Names_With_Commas() + { + var options = new QueryOptions + { + Filter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Name,AnotherField", Operator = "eq", Value = "test" }] + }, + Paging = { Disabled = true } + }; + options.Items["EntityType"] = typeof(TestEntity); + + var command = _translator.Translate(options); + + command.Sql.Should().Contain("[Name,AnotherField]"); + } + + [Fact] + public void Should_Quote_Field_Names_With_Logical_Operators() + { + var options = new QueryOptions + { + Filter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Name OR 1=1", Operator = "eq", Value = "test" }] + }, + Paging = { Disabled = true } + }; + options.Items["EntityType"] = typeof(TestEntity); + + var command = _translator.Translate(options); + + command.Sql.Should().Contain("[Name OR 1=1]"); + // Properly quoted - entire string is identifier, not SQL logic + } + + // ==================== STRING ESCAPING IN VALUES ==================== + + [Fact] + public void Should_Contain_Single_Quotes_In_Value_Without_Breaking() + { + var options = new QueryOptions + { + Filter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Name", Operator = "eq", Value = "O'Reilly" }] + }, + Paging = { Disabled = true } + }; + options.Items["EntityType"] = typeof(TestEntity); + + var command = _translator.Translate(options); + + // Parameterized value retains the single quote but doesn't break SQL + command.Parameters["@p0"].Should().Be("O'Reilly"); + // Should not have string concatenation + command.Sql.Should().NotContain("'O'Reilly'"); + command.Sql.Should().Contain("@p0"); + } + + [Fact] + public void Should_Contain_Backslash_Without_Escape_Breakage() + { + var options = new QueryOptions + { + Filter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Path", Operator = "eq", Value = @"C:\Windows\System32" }] + }, + Paging = { Disabled = true } + }; + options.Items["EntityType"] = typeof(TestEntity); + + var command = _translator.Translate(options); + + command.Parameters["@p0"].Should().Be(@"C:\Windows\System32"); + } + + // ==================== PARSER-LEVEL INJECTION PREVENTION ==================== + + [Fact] + public void Should_Reject_DSL_With_Semicolon_Separated_Injection() + { + // DSL parser validates characters; semicolons are not explicitly forbidden but field name pattern rejects them + // if embedded within field name. But semicolon as value is fine (parameterized) + Action act = () => DslParser.Parse("name:eq:test;DROP TABLE Users"); + // Value parsing stops after "test", extra "DROP..." becomes extra token causing error + act.Should().Throw(); + } + + // ==================== CROSS-SITE SCRIPTING (XSS) VIA DATA ==================== + + [Fact] + public void Should_Not_Reflect_User_Input_In_SQL_As_Code() + { + var malicious = ""; + var options = new QueryOptions + { + Filter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Name", Operator = "contains", Value = malicious }] + }, + Paging = { Disabled = true } + }; + options.Items["EntityType"] = typeof(TestEntity); + + var command = _translator.Translate(options); + + // XSS payload treated as parameter value, not executed + command.Sql.Should().Contain("@p0"); + command.Parameters["@p0"].Should().Be($"%{malicious}%"); + } +} diff --git a/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlTranslatorTests.cs b/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlTranslatorTests.cs new file mode 100644 index 0000000..4138416 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlTranslatorTests.cs @@ -0,0 +1,433 @@ +using FlexQuery.NET.Models; +using FlexQuery.NET.Dapper.Mapping; +using FlexQuery.NET.Dapper.Sql; +using FlexQuery.NET.Dapper.Sql.Translators; +using FlexQuery.NET.Dapper.Dialects; +using FluentAssertions; + +namespace FlexQuery.NET.Tests.Dapper.Translation; + +public class SqlTranslatorTests +{ + private readonly IMappingRegistry _registry = new MappingRegistry(); + + public SqlTranslatorTests() + { + var entityWithJoin = new EntityMapping(typeof(TestEntityWithJoin), "users", null); + entityWithJoin.MapJoin("Roles", typeof(object), "roles", "users.Id = roles.UserId"); + ((MappingRegistry)_registry).Register(entityWithJoin); + } + + private static QueryOptions NoPaging(QueryOptions options) + { + options.Paging.Disabled = true; + return options; + } + + [Fact] + public void Translate_EmptyFilter_GeneratesSelectAll() + { + var options = NoPaging(new QueryOptions()); + options.Items["EntityType"] = typeof(TestEntity); + + var translator = new SqlTranslator(_registry, new SqlServerDialect()); + var command = translator.Translate(options); + + command.Sql.Should().Contain("SELECT"); + command.Sql.Should().Contain("FROM"); + command.Parameters.Should().BeEmpty(); + } + + [Fact] + public void Translate_SimpleEqFilter_GeneratesParameterizedWhere() + { + var options = NoPaging(new QueryOptions + { + Filter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Name", Operator = "eq", Value = "Alice" }] + } + }); + options.Items["EntityType"] = typeof(TestEntity); + + var translator = new SqlTranslator(_registry, new SqlServerDialect()); + var command = translator.Translate(options); + + command.Sql.Should().Contain("WHERE"); + command.Parameters.Should().ContainKey("@p0"); + command.Parameters["@p0"].Should().Be("Alice"); + } + + [Fact] + public void Translate_InOperator_GeneratesInClause() + { + var options = NoPaging(new QueryOptions + { + Filter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Status", Operator = "in", Value = "Active,Pending" }] + } + }); + options.Items["EntityType"] = typeof(TestEntity); + + var translator = new SqlTranslator(_registry, new SqlServerDialect()); + var command = translator.Translate(options); + + command.Sql.Should().Contain("IN"); + command.Parameters.Should().HaveCount(2); + } + + [Fact] + public void Translate_BetweenOperator_GeneratesBetweenClause() + { + var options = NoPaging(new QueryOptions + { + Filter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Age", Operator = "between", Value = "20,30" }] + } + }); + options.Items["EntityType"] = typeof(TestEntity); + + var translator = new SqlTranslator(_registry, new SqlServerDialect()); + var command = translator.Translate(options); + + command.Sql.Should().Contain("BETWEEN"); + command.Parameters.Should().HaveCount(2); + command.Parameters["@p0"].Should().Be("20"); + command.Parameters["@p1"].Should().Be("30"); + } + + [Fact] + public void Translate_ContainsOperator_GeneratesLikeClause() + { + var options = NoPaging(new QueryOptions + { + Filter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Name", Operator = "contains", Value = "John" }] + } + }); + options.Items["EntityType"] = typeof(TestEntity); + + var translator = new SqlTranslator(_registry, new SqlServerDialect()); + var command = translator.Translate(options); + + command.Sql.Should().Contain("LIKE"); + command.Parameters.Should().ContainKey("@p0"); + command.Parameters["@p0"].Should().Be("%John%"); + } + + [Fact] + public void Translate_Sorts_GeneratesOrderBy() + { + var options = NoPaging(new QueryOptions + { + Sort = [new SortNode { Field = "Name", Descending = true }] + }); + options.Items["EntityType"] = typeof(TestEntity); + + var translator = new SqlTranslator(_registry, new SqlServerDialect()); + var command = translator.Translate(options); + + command.Sql.Should().Contain("ORDER BY"); + command.Sql.Should().Contain("DESC"); + } + + [Fact] + public void Translate_GroupBy_GeneratesGroupByClause() + { + var options = NoPaging(new QueryOptions + { + GroupBy = ["City"] + }); + options.Items["EntityType"] = typeof(TestEntity); + + var translator = new SqlTranslator(_registry, new SqlServerDialect()); + var command = translator.Translate(options); + + command.Sql.Should().Contain("GROUP BY"); + } + + [Fact] + public void Translate_Aggregates_GeneratesAggregateSelect() + { + var options = NoPaging(new QueryOptions + { + Aggregates = [new AggregateModel { Function = "count", Alias = "TotalCount" }] + }); + options.Items["EntityType"] = typeof(TestEntity); + + var translator = new SqlTranslator(_registry, new SqlServerDialect()); + var command = translator.Translate(options); + + command.Sql.Should().Contain("COUNT(1) AS [TotalCount]"); + } + + [Fact] + public void Translate_Paging_GeneratesOffsetFetch() + { + var options = new QueryOptions + { + Paging = { Page = 2, PageSize = 10 } + }; + options.Items["EntityType"] = typeof(TestEntity); + + var translator = new SqlTranslator(_registry, new SqlServerDialect()); + var command = translator.Translate(options); + + command.Sql.Should().Contain("OFFSET"); + command.Sql.Should().Contain("FETCH NEXT"); + command.Parameters.Should().ContainKey("@Offset"); + command.Parameters.Should().ContainKey("@PageSize"); + } + + [Fact] + public void Translate_AndLogic_CombinesWithAnd() + { + var options = NoPaging(new QueryOptions + { + Filter = new FilterGroup + { + Logic = LogicOperator.And, + Filters = + [ + new FilterCondition { Field = "City", Operator = "eq", Value = "NYC" }, + new FilterCondition { Field = "Age", Operator = "gt", Value = "25" } + ] + } + }); + options.Items["EntityType"] = typeof(TestEntity); + + var translator = new SqlTranslator(_registry, new SqlServerDialect()); + var command = translator.Translate(options); + + command.Sql.Should().Contain("AND"); + command.Sql.Should().NotContain("OR"); + } + + [Fact] + public void Translate_OrLogic_CombinesWithOr() + { + var options = NoPaging(new QueryOptions + { + Filter = new FilterGroup + { + Logic = LogicOperator.Or, + Filters = + [ + new FilterCondition { Field = "City", Operator = "eq", Value = "NYC" }, + new FilterCondition { Field = "City", Operator = "eq", Value = "LA" } + ] + } + }); + options.Items["EntityType"] = typeof(TestEntity); + + var translator = new SqlTranslator(_registry, new SqlServerDialect()); + var command = translator.Translate(options); + + command.Sql.Should().Contain("OR"); + command.Sql.Should().Contain("("); + } + + [Fact] + public void Translate_SelectFields_GeneratesColumnList() + { + var options = NoPaging(new QueryOptions + { + Select = ["Id", "Name", "Age"] + }); + options.Items["EntityType"] = typeof(TestEntity); + + var translator = new SqlTranslator(_registry, new SqlServerDialect()); + var command = translator.Translate(options); + + command.Sql.Should().Contain("[Id]"); + command.Sql.Should().Contain("[Name]"); + command.Sql.Should().Contain("[Age]"); + } + + [Fact] + public void Translate_Distinct_GeneratesDistinctClause() + { + var options = NoPaging(new QueryOptions + { + Distinct = true + }); + options.Items["EntityType"] = typeof(TestEntity); + + var translator = new SqlTranslator(_registry, new SqlServerDialect()); + var command = translator.Translate(options); + + command.Sql.Should().Contain("SELECT DISTINCT"); + } + + [Fact] + public void Translate_Having_GeneratesHavingClause() + { + var options = NoPaging(new QueryOptions + { + GroupBy = ["Status"], + Having = new HavingCondition + { + Field = "Amount", + Operator = "gt", + Value = "100", + Function = "sum" + } + }); + options.Items["EntityType"] = typeof(TestEntity); + + var translator = new SqlTranslator(_registry, new SqlServerDialect()); + var command = translator.Translate(options); + + command.Sql.Should().Contain("GROUP BY"); + command.Sql.Should().Contain("HAVING"); + command.Sql.Should().Contain("SUM"); + } + + [Fact] + public void Translate_Includes_GeneratesJoinClause() + { + var options = NoPaging(new QueryOptions + { + Includes = new List { "Roles" } + }); + options.Items["EntityType"] = typeof(TestEntityWithJoin); + + var translator = new SqlTranslator(_registry, new SqlServerDialect()); + var command = translator.Translate(options); + + command.Sql.Should().Contain("LEFT JOIN"); + } + + [Fact] + public void Translate_AnyOperator_GeneratesExistsSubquery() + { + var options = NoPaging(new QueryOptions + { + Filter = new FilterGroup + { + Filters = [new FilterCondition + { + Field = "Roles", + Operator = "any", + ScopedFilter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Name", Operator = "eq", Value = "Admin" }] + } + }] + } + }); + options.Items["EntityType"] = typeof(TestEntityWithJoin); + + var translator = new SqlTranslator(_registry, new SqlServerDialect()); + var command = translator.Translate(options); + + command.Sql.Should().Contain("EXISTS"); + command.Sql.Should().Contain("SELECT 1 FROM [roles]"); + command.Sql.Should().Contain("users.Id = roles.UserId"); + command.Sql.Should().Contain("[Name] = @p0"); + command.Parameters["@p0"].Should().Be("Admin"); + } + + [Fact] + public void Translate_AllOperator_GeneratesNotExistsSubquery() + { + var options = NoPaging(new QueryOptions + { + Filter = new FilterGroup + { + Filters = [new FilterCondition + { + Field = "Roles", + Operator = "all", + ScopedFilter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Name", Operator = "eq", Value = "User" }] + } + }] + } + }); + options.Items["EntityType"] = typeof(TestEntityWithJoin); + + var translator = new SqlTranslator(_registry, new SqlServerDialect()); + var command = translator.Translate(options); + + command.Sql.Should().Contain("NOT EXISTS"); + command.Sql.Should().Contain("SELECT 1 FROM [roles]"); + command.Sql.Should().Contain("NOT ([Name] = @p0)"); + } + + [Fact] + public void Translate_CountOperator_GeneratesCorrelatedCountSubquery() + { + var options = NoPaging(new QueryOptions + { + Filter = new FilterGroup + { + Filters = [new FilterCondition + { + Field = "Roles", + Operator = "count", + Value = "gt:5", + ScopedFilter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Name", Operator = "eq", Value = "Guest" }] + } + }] + } + }); + options.Items["EntityType"] = typeof(TestEntityWithJoin); + + var translator = new SqlTranslator(_registry, new SqlServerDialect()); + var command = translator.Translate(options); + + command.Sql.Should().Contain("(SELECT COUNT(*) FROM [roles]"); + command.Sql.Should().Contain("users.Id = roles.UserId"); + command.Sql.Should().Contain("> @p1"); + command.Parameters["@p1"].Should().Be("5"); + } + + [Fact] + public void Translate_FilteredInclude_GeneratesJoinWithFilter() + { + var options = NoPaging(new QueryOptions + { + FilteredIncludes = + [ + new IncludeNode + { + Path = "Roles", + Filter = new FilterGroup + { + Filters = [new FilterCondition { Field = "IsActive", Operator = "eq", Value = "true" }] + } + } + ] + }); + options.Items["EntityType"] = typeof(TestEntityWithJoin); + + var translator = new SqlTranslator(_registry, new SqlServerDialect()); + var command = translator.Translate(options); + + command.Sql.Should().Contain("LEFT JOIN [roles]"); + command.Sql.Should().Contain("users.Id = roles.UserId"); + command.Sql.Should().Contain("AND ([IsActive] = @p0)"); + } + + private class TestEntity + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public int Age { get; set; } + public string City { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + } + + private class TestEntityWithJoin + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } +} diff --git a/tests/FlexQuery.NET.Tests/FlexQuery.NET.Tests.csproj b/tests/FlexQuery.NET.Tests/FlexQuery.NET.Tests.csproj index 53190ef..866e60a 100644 --- a/tests/FlexQuery.NET.Tests/FlexQuery.NET.Tests.csproj +++ b/tests/FlexQuery.NET.Tests/FlexQuery.NET.Tests.csproj @@ -8,7 +8,8 @@ false - + + @@ -25,8 +26,9 @@ - - - + + + + From 35f2332734b8b67724035900e1a86319cca567cc Mon Sep 17 00:00:00 2001 From: Peter John Casasola Date: Fri, 15 May 2026 12:18:46 +0800 Subject: [PATCH 2/9] fix(dapper): finalize 3.0.0 release with stabilized query engine - Fix `SqlTranslator` to explicitly project GroupBy columns for correct SQL generation. - Synchronize unit test assertions with improved typed parameter conversion logic. - Resolve syntax errors and model alignment issues in the integration test suite. - Achieve 100% pass rate (365/365) across the full solution. - Consolidate all Dapper-related fixes and features into the v3.0.0 release notes. --- CHANGELOG.md | 10 + .../DapperQueryOptions.cs | 12 +- .../FlexQueryDapperExtensions.cs | 179 +++++++++++++++-- .../Mapping/EntityMapping.cs | 3 +- .../Mapping/IEntityMapping.cs | 2 +- src/FlexQuery.NET.Dapper/Mapping/JoinInfo.cs | 1 + .../SQL/Translators/SqlCountTranslator.cs | 5 +- .../SQL/Translators/SqlIncludeTranslator.cs | 3 +- .../SQL/Translators/SqlTranslator.cs | 188 +++++++++++++----- src/FlexQuery.NET/Caching/ParserCache.cs | 2 +- .../Exceptions/QueryValidationException.cs | 6 + .../Extensions/QueryOptionsExtensions.cs | 29 ++- src/FlexQuery.NET/FlexQuery.NET.csproj | 1 + .../Models/FlexQueryParameters.cs | 6 +- src/FlexQuery.NET/Models/QueryRequest.cs | 2 +- .../Parsers/Dsl/ConditionNode.cs | 22 ++ src/FlexQuery.NET/Parsers/Dsl/DslAstNode.cs | 51 ----- .../Parsers/Dsl/DslFilterConverter.cs | 32 +++ src/FlexQuery.NET/Parsers/Dsl/DslParser.cs | 35 ++++ src/FlexQuery.NET/Parsers/Dsl/LogicalNode.cs | 18 ++ src/FlexQuery.NET/Parsers/Dsl/NotNode.cs | 14 ++ .../Parsers/Dsl/RelationshipNode.cs | 20 ++ .../Parsers/QueryOptionsParser.cs | 20 +- .../Validation/Rules/TypeCompatibilityRule.cs | 2 +- .../Api/Dapper/DapperApiTestBase.cs | 51 +++++ .../Api/Dapper/IncludeTests.cs | 48 +++++ .../Api/Dapper/OrderAggregationTests.cs | 60 ++++++ .../Api/Dapper/RelationshipTests.cs | 60 ++++++ .../Api/Dapper/SecurityValidationTests.cs | 73 +++++++ .../Api/Dapper/UsersTests.cs | 81 ++++++++ .../Dapper/Translation/SqlTranslatorTests.cs | 6 +- tests/FlexQuery.NET.Tests/Fixtures/DemoApi.cs | 169 ++++++++++++++++ .../Fixtures/SqlProjectionDbContext.cs | 46 +---- .../FlexQuery.NET.Tests.csproj | 12 +- tests/FlexQuery.NET.Tests/GlobalUsings.cs | 2 + .../FlexQuery.NET.Tests/Models/SqlAddress.cs | 9 + .../FlexQuery.NET.Tests/Models/SqlCustomer.cs | 10 + tests/FlexQuery.NET.Tests/Models/SqlOrder.cs | 12 ++ .../Models/SqlOrderItem.cs | 9 + .../Tests/WildcardProjectionTests.cs | 2 +- 40 files changed, 1121 insertions(+), 192 deletions(-) create mode 100644 src/FlexQuery.NET/Parsers/Dsl/ConditionNode.cs create mode 100644 src/FlexQuery.NET/Parsers/Dsl/LogicalNode.cs create mode 100644 src/FlexQuery.NET/Parsers/Dsl/NotNode.cs create mode 100644 src/FlexQuery.NET/Parsers/Dsl/RelationshipNode.cs create mode 100644 tests/FlexQuery.NET.Tests/Api/Dapper/DapperApiTestBase.cs create mode 100644 tests/FlexQuery.NET.Tests/Api/Dapper/IncludeTests.cs create mode 100644 tests/FlexQuery.NET.Tests/Api/Dapper/OrderAggregationTests.cs create mode 100644 tests/FlexQuery.NET.Tests/Api/Dapper/RelationshipTests.cs create mode 100644 tests/FlexQuery.NET.Tests/Api/Dapper/SecurityValidationTests.cs create mode 100644 tests/FlexQuery.NET.Tests/Api/Dapper/UsersTests.cs create mode 100644 tests/FlexQuery.NET.Tests/Fixtures/DemoApi.cs create mode 100644 tests/FlexQuery.NET.Tests/Models/SqlAddress.cs create mode 100644 tests/FlexQuery.NET.Tests/Models/SqlCustomer.cs create mode 100644 tests/FlexQuery.NET.Tests/Models/SqlOrder.cs create mode 100644 tests/FlexQuery.NET.Tests/Models/SqlOrderItem.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ae520f..632bc2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. --- ## [3.0.0] - 2026-05-15 +### Fixed +- **Dapper Query Engine Stabilization**: + - Improved `SqlTranslator` projection logic to explicitly include GroupBy columns when no explicit select list is provided, ensuring valid SQL generation for grouped queries. + - Refined parameter binding in `HAVING` clauses to use correct numeric types (ints/decimals) instead of string literals. +- **Test Suite Integrity**: + - Synchronized `SqlTranslatorTests` assertions with the provider's improved typed parameter conversion logic. + - Resolved `CS1022` syntax errors in `SecurityValidationTests.cs` caused by illegal namespace nesting. + - Updated `WildcardProjectionTests.cs` and `OrderAggregationTests.cs` to align with recent entity model refactorings and seed data. + - Achieved 100% pass rate across 365 tests in the solution. + ### Added - **FlexQuery.NET.Dapper Package**: - Full support for Dapper as a high-performance query engine. diff --git a/src/FlexQuery.NET.Dapper/DapperQueryOptions.cs b/src/FlexQuery.NET.Dapper/DapperQueryOptions.cs index 6dce3b3..5bce14f 100644 --- a/src/FlexQuery.NET.Dapper/DapperQueryOptions.cs +++ b/src/FlexQuery.NET.Dapper/DapperQueryOptions.cs @@ -16,8 +16,8 @@ public sealed class DapperQueryOptions : BaseQueryOptions /// public DapperQueryOptions() { - // Dapper defaults - override base IncludeTotalCount to false (Dapper behavior) - IncludeTotalCount = false; + // Dapper defaults + IncludeTotalCount = true; } /// @@ -42,7 +42,7 @@ public DapperQueryOptions(QueryExecutionOptions source) SelectableFields = source.SelectableFields; // Dapper-specific defaults - IncludeTotalCount = false; // Override to match Dapper behavior + IncludeTotalCount = true; } public QueryExecutionOptions ToQueryExecutionOptions() @@ -73,7 +73,13 @@ public QueryExecutionOptions ToQueryExecutionOptions() /// 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; } /// 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; } } diff --git a/src/FlexQuery.NET.Dapper/FlexQueryDapperExtensions.cs b/src/FlexQuery.NET.Dapper/FlexQueryDapperExtensions.cs index fe12859..38d2748 100644 --- a/src/FlexQuery.NET.Dapper/FlexQueryDapperExtensions.cs +++ b/src/FlexQuery.NET.Dapper/FlexQueryDapperExtensions.cs @@ -24,15 +24,15 @@ public static async Task> FlexQueryAsync( FlexQueryParameters parameters, Action? configureDapper = null) where T : class { - var parsedOptions = QueryOptionsParser.Parse(parameters); - parsedOptions.Items["EntityType"] = typeof(T); - 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(execOptions); + parsedOptions.ValidateOrThrow(dapperOptions.EntityType ?? typeof(T), execOptions); return await ExecuteQueryAsync(connection, parsedOptions, dapperOptions); } @@ -45,14 +45,13 @@ public static async Task> FlexQueryAsync( FlexQueryParameters parameters, DapperQueryOptions? dapperQueryOptions = null) where T : class { - var parsedOptions = QueryOptionsParser.Parse(parameters); - parsedOptions.Items["EntityType"] = typeof(T); - var dapperOptions = dapperQueryOptions ?? new DapperQueryOptions(); + var parsedOptions = QueryOptionsParser.Parse(parameters); + parsedOptions.Items["EntityType"] = dapperOptions.EntityType ?? typeof(T); var execOptions = dapperOptions.ToQueryExecutionOptions(); - parsedOptions.ValidateOrThrow(execOptions); + parsedOptions.ValidateOrThrow(dapperOptions.EntityType ?? typeof(T), execOptions); return await ExecuteQueryAsync(connection, parsedOptions, dapperOptions); } @@ -85,25 +84,93 @@ private static async Task> ExecuteQueryAsync( var dialect = execOptions.Dialect ?? DapperQueryOptions.GlobalDefaultDialect ?? DapperQueryOptions.GlobalDialectResolver.Resolve(connection); - var translator = new SqlTranslator(new Mapping.MappingRegistry(), dialect); + + 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) { - // Strip dialect-specific parameter prefixes (@, :, ?) for Dapper's DynamicParameters var cleanName = param.Key.TrimStart('@', ':', '?'); parameters.Add(cleanName, param.Value); } - var items = (await connection.QueryAsync( - command.Sql, - parameters, - commandTimeout: execOptions.CommandTimeoutSeconds, - commandType: CommandType.Text)).AsList(); + 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(); + var pkProperty = mapping.GetProperties().FirstOrDefault(p => p.Equals("Id", StringComparison.OrdinalIgnoreCase)) ?? mapping.GetProperties().First(); + var pkColumn = mapping.GetColumnName(pkProperty); + + foreach (var row in dynamicItems) + { + var rowDict = (IDictionary)row; + System.IO.File.AppendAllText("hydration_log.txt", $"ROW KEYS: {string.Join(", ", rowDict.Keys)}\n"); + var rowKeys = rowDict.Keys.ToDictionary(k => k, k => k, StringComparer.OrdinalIgnoreCase); + + if (!rowKeys.TryGetValue(pkColumn, out var actualPkCol) || rowDict[actualPkCol] == null || rowDict[actualPkCol] == DBNull.Value) continue; + var pkValue = rowDict[actualPkCol]; + + if (!parentMap.TryGetValue(pkValue, out var parent)) + { + parent = MapRowToEntity(rowDict, mapping, string.Empty); + parentMap[pkValue] = parent; + } + + foreach (var include in options.Includes) + { + var joinInfo = mapping.GetJoinInfo(include); + if (joinInfo == null) continue; + + var targetMapping = registry.GetMapping(joinInfo.TargetType); + var childPkProperty = targetMapping.GetProperties().FirstOrDefault(p => p.Equals("Id", StringComparison.OrdinalIgnoreCase)) ?? targetMapping.GetProperties().First(); + var childPkColumn = joinInfo.NavigationProperty + "_" + targetMapping.GetColumnName(childPkProperty); + + if (rowKeys.TryGetValue(childPkColumn, out var actualChildPkCol) && rowDict[actualChildPkCol] != null && rowDict[actualChildPkCol] != DBNull.Value) + { + var child = MapRowToEntity(rowDict, targetMapping, joinInfo.NavigationProperty + "_"); + AddChildToParent(parent, joinInfo.NavigationProperty, child); + } + } + } + items = parentMap.Values.ToList(); + } + else if (typeof(T) == typeof(object) || options.Aggregates.Count > 0) + { + var dynamicItems = await connection.QueryAsync( + command.Sql, + parameters, + commandTimeout: execOptions.CommandTimeoutSeconds, + commandType: CommandType.Text); + + items = dynamicItems + .Select(d => (T)(object)new Dictionary((IDictionary)d, StringComparer.OrdinalIgnoreCase)) + .AsList(); + } + else + { + items = (await connection.QueryAsync( + command.Sql, + parameters, + commandTimeout: execOptions.CommandTimeoutSeconds, + commandType: CommandType.Text)).AsList(); + } var totalCount = items.Count; - if (execOptions.IncludeTotalCount && options.Paging.Page > 1) + if (execOptions.IncludeTotalCount && (options.Paging.Page > 1 || (options.Paging.PageSize > 0 && items.Count == options.Paging.PageSize))) { var countSql = ExtractCountSql(command.Sql); totalCount = (int)await connection.QuerySingleAsync(countSql, parameters, commandTimeout: execOptions.CommandTimeoutSeconds, commandType: CommandType.Text); @@ -118,10 +185,86 @@ private static async Task> ExecuteQueryAsync( }; } + private static T MapRowToEntity(IDictionary row, Mapping.IEntityMapping mapping, string prefix) where T : class + { + return (T)MapRowToEntity(row, mapping, prefix); + } + + private static object MapRowToEntity(IDictionary row, Mapping.IEntityMapping mapping, string prefix) + { + var entity = Activator.CreateInstance(mapping.Type)!; + var rowKeys = row.Keys.ToDictionary(k => k, k => k, StringComparer.OrdinalIgnoreCase); + + foreach (var propName in mapping.GetProperties()) + { + var colName = prefix + mapping.GetColumnName(propName); + if (rowKeys.TryGetValue(colName, out var actualKey) && row.TryGetValue(actualKey, out var val) && val != DBNull.Value) + { + var prop = mapping.Type.GetProperty(propName); + if (prop != null && prop.CanWrite) + { + try + { + var targetType = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType; + prop.SetValue(entity, Convert.ChangeType(val, targetType)); + } + catch { /* skip incompatible */ } + } + } + } + return entity; + } + + private static void AddChildToParent(object parent, string navigationProperty, object child) + { + var prop = parent.GetType().GetProperty(navigationProperty, System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.IgnoreCase); + if (prop == null) return; + + var value = prop.GetValue(parent); + if (value == null) + { + var propType = prop.PropertyType; + if (propType.IsGenericType && (propType.GetGenericTypeDefinition() == typeof(List<>) || propType.GetGenericTypeDefinition() == typeof(ICollection<>) || propType.GetGenericTypeDefinition() == typeof(IEnumerable<>))) + { + var itemType = propType.GetGenericArguments()[0]; + value = Activator.CreateInstance(typeof(List<>).MakeGenericType(itemType)); + prop.SetValue(parent, value); + } + else + { + prop.SetValue(parent, child); + return; + } + } + + if (value is System.Collections.IList list) + { + var childPkProp = child.GetType().GetProperties().FirstOrDefault(p => p.Name.Equals("Id", StringComparison.OrdinalIgnoreCase)); + var childPk = childPkProp?.GetValue(child); + + if (childPk != null) + { + foreach (var item in list) + { + var itemPk = item.GetType().GetProperty(childPkProp.Name)?.GetValue(item); + if (childPk.Equals(itemPk)) + return; + } + } + list.Add(child); + } + } + private static string ExtractCountSql(string sql) { - var idx = sql.IndexOf("ORDER BY", StringComparison.OrdinalIgnoreCase); - var baseSql = idx >= 0 ? sql[..idx] : sql; + var keywords = new[] { "ORDER BY", "LIMIT", "OFFSET" }; + var minIdx = sql.Length; + foreach (var kw in keywords) + { + var idx = sql.IndexOf(kw, StringComparison.OrdinalIgnoreCase); + if (idx >= 0 && idx < minIdx) minIdx = idx; + } + var baseSql = sql[..minIdx]; return $"SELECT COUNT(1) FROM ({baseSql.Trim()}) AS CountTable"; } } diff --git a/src/FlexQuery.NET.Dapper/Mapping/EntityMapping.cs b/src/FlexQuery.NET.Dapper/Mapping/EntityMapping.cs index e354c87..690de99 100644 --- a/src/FlexQuery.NET.Dapper/Mapping/EntityMapping.cs +++ b/src/FlexQuery.NET.Dapper/Mapping/EntityMapping.cs @@ -11,7 +11,7 @@ public sealed class EntityMapping : IEntityMapping public Type Type { get; } public string TableName { get; } - public string? TableAlias { get; } + public string? TableAlias { get; set; } public EntityMapping(Type type, string tableName, string? tableAlias = null) { @@ -30,6 +30,7 @@ public void MapJoin(string navigationProperty, Type targetType, string tableName { _joins[navigationProperty] = new JoinInfo { + NavigationProperty = navigationProperty, TargetType = targetType, TableName = tableName, JoinCondition = joinCondition diff --git a/src/FlexQuery.NET.Dapper/Mapping/IEntityMapping.cs b/src/FlexQuery.NET.Dapper/Mapping/IEntityMapping.cs index 9699894..bb5b527 100644 --- a/src/FlexQuery.NET.Dapper/Mapping/IEntityMapping.cs +++ b/src/FlexQuery.NET.Dapper/Mapping/IEntityMapping.cs @@ -12,7 +12,7 @@ public interface IEntityMapping string TableName { get; } /// Table alias. - string? TableAlias { get; } + string? TableAlias { get; set; } /// Get the column name for a property. string GetColumnName(string propertyName); diff --git a/src/FlexQuery.NET.Dapper/Mapping/JoinInfo.cs b/src/FlexQuery.NET.Dapper/Mapping/JoinInfo.cs index 68e1f56..ab79fbe 100644 --- a/src/FlexQuery.NET.Dapper/Mapping/JoinInfo.cs +++ b/src/FlexQuery.NET.Dapper/Mapping/JoinInfo.cs @@ -5,6 +5,7 @@ namespace FlexQuery.NET.Dapper.Mapping; /// public sealed class JoinInfo { + public string NavigationProperty { get; set; } = string.Empty; public string TableName { get; set; } = string.Empty; public string JoinCondition { get; set; } = string.Empty; public Type? TargetType { get; set; } diff --git a/src/FlexQuery.NET.Dapper/SQL/Translators/SqlCountTranslator.cs b/src/FlexQuery.NET.Dapper/SQL/Translators/SqlCountTranslator.cs index e26eb78..4028394 100644 --- a/src/FlexQuery.NET.Dapper/SQL/Translators/SqlCountTranslator.cs +++ b/src/FlexQuery.NET.Dapper/SQL/Translators/SqlCountTranslator.cs @@ -30,7 +30,10 @@ public string Translate(CountExpressionNode node, IEntityMapping mapping, Func 0 || options.FilteredIncludes?.Count > 0) + { + mapping.TableAlias = mapping.TableName; + } + var distinctClause = options.Distinct == true ? "DISTINCT" : string.Empty; var selectClause = BuildSelectClause(options, mapping, distinctClause); - var fromClause = $"FROM {_dialect.QuoteIdentifier(mapping.TableName)}"; + var fromClause = string.IsNullOrEmpty(mapping.TableAlias) + ? $"FROM {_dialect.QuoteIdentifier(mapping.TableName)}" + : $"FROM {_dialect.QuoteIdentifier(mapping.TableName)} AS {_dialect.QuoteIdentifier(mapping.TableAlias)}"; var joinClause = BuildJoinClause(options, mapping, parameters); var whereClause = BuildWhereClause(options.Filter, mapping, parameters); var groupByClause = BuildGroupByClause(options.GroupBy, mapping); @@ -71,16 +80,21 @@ public SqlCommand Translate(QueryOptions options) private string BuildSelectClause(QueryOptions options, IEntityMapping mapping, string distinctClause) { var distinctPrefix = !string.IsNullOrEmpty(distinctClause) ? $"{distinctClause} " : string.Empty; - + var selectParts = new List(); if (options.Aggregates?.Count > 0) { - var selectParts = new List(); + if (options.GroupBy?.Count > 0) + { + foreach (var g in options.GroupBy) + { + selectParts.Add(QuoteColumn(mapping.GetColumnName(g), mapping)); + } + } + foreach (var agg in options.Aggregates) { var column = mapping.GetColumnName(agg.Field ?? "*"); - var quoted = string.IsNullOrEmpty(mapping.TableAlias) - ? _dialect.QuoteIdentifier(column) - : $"{mapping.TableAlias}.{_dialect.QuoteIdentifier(column)}"; + var quoted = QuoteColumn(column, mapping); if (agg.Function.Equals("count", StringComparison.OrdinalIgnoreCase) && string.IsNullOrEmpty(agg.Field)) { @@ -94,61 +108,79 @@ private string BuildSelectClause(QueryOptions options, IEntityMapping mapping, s return $"SELECT {distinctPrefix}{string.Join(", ", selectParts)}"; } + // 1. Add Main Entity Columns if (options.Select?.Count > 0) { - var columns = options.Select.Select(s => + foreach (var s in options.Select) { - var column = mapping.GetColumnName(s); - var alias = mapping.TableAlias; - return string.IsNullOrEmpty(alias) - ? _dialect.QuoteIdentifier(column) - : $"{alias}.{_dialect.QuoteIdentifier(column)}"; - }); - return $"SELECT {distinctPrefix}{string.Join(", ", columns)}"; + selectParts.Add(QuoteColumn(mapping.GetColumnName(s), mapping)); + } } - - var allColumns = mapping.GetProperties().Select(p => + else if (options.GroupBy?.Count > 0) { - var column = mapping.GetColumnName(p); - var alias = mapping.TableAlias; - return string.IsNullOrEmpty(alias) - ? _dialect.QuoteIdentifier(column) - : $"{alias}.{_dialect.QuoteIdentifier(column)}"; - }); - return $"SELECT {distinctPrefix}{string.Join(", ", allColumns)}"; - } - - private string BuildJoinClause(QueryOptions options, IEntityMapping mapping, Dictionary parameters) - { - var joins = new List(); + foreach (var g in options.GroupBy) + { + selectParts.Add(QuoteColumn(mapping.GetColumnName(g), mapping)); + } + } + else + { + foreach (var p in mapping.GetProperties()) + { + selectParts.Add(QuoteColumn(mapping.GetColumnName(p), mapping)); + } + } - // Handle regular Includes + // 2. Add Included Entity Columns (Flat mapping for Dapper) if (options.Includes != null) { foreach (var include in options.Includes) { - var node = new FlexQuery.NET.Dapper.Sql.Ast.IncludeNode { NavigationProperty = include }; - var sql = _includeTranslator.Translate(node, mapping, _ => string.Empty); - if (!string.IsNullOrEmpty(sql)) joins.Add(sql); + var joinInfo = mapping.GetJoinInfo(include); + if (joinInfo != null) + { + var targetMapping = _mappingRegistry.GetMapping(joinInfo.TargetType); + var targetAlias = joinInfo.NavigationProperty; // Use mapped property name for alias + foreach (var prop in targetMapping.GetProperties()) + { + var col = targetMapping.GetColumnName(prop); + // Prefix with mapped navigation property name for hydration + var quotedAlias = _dialect.QuoteIdentifier(targetAlias); + var quotedCol = _dialect.QuoteIdentifier(col); + var aliasForHydration = _dialect.QuoteIdentifier(joinInfo.NavigationProperty + "_" + col); + selectParts.Add($"{quotedAlias}.{quotedCol} AS {aliasForHydration}"); + } + } } } - // Handle Filtered Includes + return $"SELECT {distinctPrefix}{string.Join(", ", selectParts)}"; + } + + private string BuildJoinClause(QueryOptions options, IEntityMapping mapping, Dictionary parameters) + { + var joins = new List(); + var joinedPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Handle Filtered Includes (more specific) if (options.FilteredIncludes != null) { foreach (var filteredInclude in options.FilteredIncludes) { + if (!joinedPaths.Add(filteredInclude.Path)) continue; + + var joinInfo = mapping.GetJoinInfo(filteredInclude.Path); + if (joinInfo == null) continue; + var node = new FlexQuery.NET.Dapper.Sql.Ast.IncludeNode { - NavigationProperty = filteredInclude.Path, + NavigationProperty = joinInfo.NavigationProperty, Filter = filteredInclude.Filter }; var sql = _includeTranslator.Translate(node, mapping, filterGroup => { - var joinInfo = mapping.GetJoinInfo(filteredInclude.Path); - if (joinInfo?.TargetType == null) return string.Empty; - var targetMapping = _mappingRegistry.GetMapping(joinInfo.TargetType); + var targetMapping = _mappingRegistry.GetMapping(joinInfo.TargetType!); return BuildFilterGroupExpression(filterGroup, targetMapping, parameters); }); @@ -156,6 +188,19 @@ private string BuildJoinClause(QueryOptions options, IEntityMapping mapping, Dic } } + // Handle regular Includes + if (options.Includes != null) + { + foreach (var include in options.Includes) + { + if (!joinedPaths.Add(include)) continue; + + var node = new FlexQuery.NET.Dapper.Sql.Ast.IncludeNode { NavigationProperty = include }; + var sql = _includeTranslator.Translate(node, mapping, _ => string.Empty); + if (!string.IsNullOrEmpty(sql)) joins.Add(sql); + } + } + return string.Join(" ", joins); } @@ -167,8 +212,9 @@ private string BuildWhereClause(FilterGroup? filter, IEntityMapping mapping, Dic return string.IsNullOrEmpty(where) ? string.Empty : $"WHERE {where}"; } - private string BuildFilterGroupExpression(FilterGroup group, IEntityMapping mapping, Dictionary parameters) + private string BuildFilterGroupExpression(FilterGroup? group, IEntityMapping mapping, Dictionary parameters) { + if (group == null) return string.Empty; var parts = new List(); foreach (var filter in group.Filters) @@ -224,7 +270,7 @@ private string BuildConditionExpression(FilterCondition condition, IEntityMappin }); } - if (op == FilterOperators.Count && condition.ScopedFilter != null) + if (op == FilterOperators.Count) { if (string.IsNullOrWhiteSpace(condition.Value)) return "1=0"; @@ -254,19 +300,19 @@ private string BuildConditionExpression(FilterCondition condition, IEntityMappin { FilterOperators.IsNull or "isnull" => $"{quotedColumn} IS NULL", FilterOperators.IsNotNull or "isnotnull" => $"{quotedColumn} IS NOT NULL", - FilterOperators.In => BuildInExpression(quotedColumn, condition.Value, parameters), - FilterOperators.Between => BuildBetweenExpression(quotedColumn, condition.Value, parameters), + FilterOperators.In => BuildInExpression(quotedColumn, condition.Field, condition.Value, mapping, parameters), + FilterOperators.Between => BuildBetweenExpression(quotedColumn, condition.Field, condition.Value, mapping, parameters), FilterOperators.Contains => BuildLikeExpression(quotedColumn, condition.Value, parameters, "%", "%"), FilterOperators.StartsWith => BuildLikeExpression(quotedColumn, condition.Value, parameters, "", "%"), FilterOperators.EndsWith => BuildLikeExpression(quotedColumn, condition.Value, parameters, "%", ""), - _ => BuildComparisonExpression(quotedColumn, condition.Value, op, parameters) + _ => BuildComparisonExpression(quotedColumn, condition.Field, condition.Value, op, mapping, parameters) }; } - private string BuildComparisonExpression(string quotedColumn, string? value, string op, Dictionary parameters) + private string BuildComparisonExpression(string quotedColumn, string field, string? value, string op, IEntityMapping mapping, Dictionary parameters) { var paramName = NextParam(); - parameters[paramName] = value; + parameters[paramName] = ConvertValue(field, value, mapping); var sqlOp = op switch { FilterOperators.Equal => "=", @@ -280,30 +326,53 @@ private string BuildComparisonExpression(string quotedColumn, string? value, str return $"{quotedColumn} {sqlOp} {paramName}"; } - private string BuildInExpression(string quotedColumn, string? value, Dictionary parameters) + private string BuildInExpression(string quotedColumn, string field, string? value, IEntityMapping mapping, Dictionary parameters) { if (string.IsNullOrEmpty(value)) return "1 = 1"; var values = value.Split(',').Select(v => v.Trim()).ToArray(); var paramNames = values.Select((_, i) => NextParam()).ToArray(); for (int i = 0; i < values.Length; i++) { - parameters[paramNames[i]] = values[i]; + parameters[paramNames[i]] = ConvertValue(field, values[i], mapping); } return $"{quotedColumn} IN ({string.Join(", ", paramNames)})"; } - private string BuildBetweenExpression(string quotedColumn, string? value, Dictionary parameters) + private string BuildBetweenExpression(string quotedColumn, string field, string? value, IEntityMapping mapping, Dictionary parameters) { if (string.IsNullOrEmpty(value)) return "1 = 1"; var values = value.Split(',').Select(v => v.Trim()).ToArray(); if (values.Length != 2) return "1 = 1"; var fromParam = NextParam(); var toParam = NextParam(); - parameters[fromParam] = values[0]; - parameters[toParam] = values[1]; + parameters[fromParam] = ConvertValue(field, values[0], mapping); + parameters[toParam] = ConvertValue(field, values[1], mapping); return $"{quotedColumn} BETWEEN {fromParam} AND {toParam}"; } + private object? ConvertValue(string field, string? value, IEntityMapping mapping) + { + if (value == null) return null; + + if (SafePropertyResolver.TryResolveChain(mapping.Type, field, out var chain) && chain.Count > 0) + { + var targetType = chain.Last().PropertyType; + var underlyingType = Nullable.GetUnderlyingType(targetType) ?? targetType; + + try + { + var converter = TypeDescriptor.GetConverter(underlyingType); + if (converter != null && converter.CanConvertFrom(typeof(string))) + { + return converter.ConvertFromInvariantString(value); + } + } + catch { /* fallback to original string */ } + } + + return value; + } + private string BuildLikeExpression(string quotedColumn, string? value, Dictionary parameters, string prefix, string suffix) { var paramName = NextParam(); @@ -315,7 +384,8 @@ private string QuoteColumn(string column, IEntityMapping mapping) { if (string.IsNullOrEmpty(mapping.TableAlias)) return _dialect.QuoteIdentifier(column); - return $"{mapping.TableAlias}.{_dialect.QuoteIdentifier(column)}"; + + return $"{_dialect.QuoteIdentifier(mapping.TableAlias)}.{_dialect.QuoteIdentifier(column)}"; } private string BuildGroupByClause(IReadOnlyList? groupBys, IEntityMapping mapping) @@ -330,8 +400,22 @@ private string BuildHavingClause(HavingCondition? having, IEntityMapping mapping if (having == null) return string.Empty; var column = QuoteColumn(mapping.GetColumnName(having.Field ?? "*"), mapping); var paramName = NextParam(); - parameters[paramName] = having.Value?.ToString()?.Trim('"'); - return $"HAVING {having.Function.ToUpperInvariant()}({column}) {having.Operator} {paramName}"; + + var valStr = having.Value?.ToString()?.Trim('"'); + parameters[paramName] = ConvertValue(having.Field ?? string.Empty, valStr, mapping); + + var sqlOp = having.Operator.ToLowerInvariant() switch + { + "eq" or "equal" or "equals" or "=" => "=", + "neq" or "ne" or "notequal" or "<>" or "!=" => "<>", + "gt" or "greaterthan" or ">" => ">", + "gte" or "ge" or "greaterthanorequal" or ">=" => ">=", + "lt" or "lessthan" or "<" => "<", + "lte" or "le" or "lessthanorequal" or "<=" => "<=", + _ => having.Operator + }; + + return $"HAVING {having.Function.ToUpperInvariant()}({column}) {sqlOp} {paramName}"; } private string BuildOrderByClause(IReadOnlyList? sorts, IEntityMapping mapping) diff --git a/src/FlexQuery.NET/Caching/ParserCache.cs b/src/FlexQuery.NET/Caching/ParserCache.cs index ee60d72..f884e5d 100644 --- a/src/FlexQuery.NET/Caching/ParserCache.cs +++ b/src/FlexQuery.NET/Caching/ParserCache.cs @@ -58,7 +58,7 @@ public sealed record ParsedQueryCacheKey( string? Filter, string? Sort, string? Select, - string? Includes, + string? Include, string? GroupBy, string? Having, int? Page, diff --git a/src/FlexQuery.NET/Exceptions/QueryValidationException.cs b/src/FlexQuery.NET/Exceptions/QueryValidationException.cs index 2cac73d..63054ee 100644 --- a/src/FlexQuery.NET/Exceptions/QueryValidationException.cs +++ b/src/FlexQuery.NET/Exceptions/QueryValidationException.cs @@ -30,4 +30,10 @@ public QueryValidationException(string message) Result = new ValidationResult(); Result.Errors.Add(new ValidationError(message, "VALIDATION_ERROR")); } + + public QueryValidationException(string message, ValidationResult result) + : base(message) + { + Result = result; + } } diff --git a/src/FlexQuery.NET/Extensions/QueryOptionsExtensions.cs b/src/FlexQuery.NET/Extensions/QueryOptionsExtensions.cs index 200d192..bda5692 100644 --- a/src/FlexQuery.NET/Extensions/QueryOptionsExtensions.cs +++ b/src/FlexQuery.NET/Extensions/QueryOptionsExtensions.cs @@ -23,10 +23,35 @@ public static void ValidateOrThrow( this QueryOptions options, QueryExecutionOptions? execOptions = null) { - var result = ValidateInternal(options, execOptions); + options.ValidateOrThrow(typeof(T), execOptions); + } + + /// + /// Validates the query options or throws a . + /// + /// The query options to validate. + /// The entity type that the query targets. + /// Optional execution options that define server-side constraints. + /// Thrown when validation fails. + public static void ValidateOrThrow( + this QueryOptions options, + Type entityType, + QueryExecutionOptions? execOptions = null) + { + execOptions ??= new QueryExecutionOptions(); + + if (execOptions.ExpressionMappings != null) + { + options.Items["ExpressionMappings"] = execOptions.ExpressionMappings; + } + + var result = options.Validate(entityType, execOptions); if (!result.IsValid) - throw new QueryValidationException(result); + { + var errors = string.Join("; ", result.Errors.Select(e => e.Message)); + throw new QueryValidationException($"Query validation failed: {errors}", result); + } } /// diff --git a/src/FlexQuery.NET/FlexQuery.NET.csproj b/src/FlexQuery.NET/FlexQuery.NET.csproj index f387db5..4030740 100644 --- a/src/FlexQuery.NET/FlexQuery.NET.csproj +++ b/src/FlexQuery.NET/FlexQuery.NET.csproj @@ -52,6 +52,7 @@ + diff --git a/src/FlexQuery.NET/Models/FlexQueryParameters.cs b/src/FlexQuery.NET/Models/FlexQueryParameters.cs index f5b5a21..1b14c54 100644 --- a/src/FlexQuery.NET/Models/FlexQueryParameters.cs +++ b/src/FlexQuery.NET/Models/FlexQueryParameters.cs @@ -19,7 +19,11 @@ public sealed class FlexQueryParameters public string? Select { get; set; } /// The comma-separated list of fields to include. - public string? Includes { get; set; } + 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/src/FlexQuery.NET/Models/QueryRequest.cs b/src/FlexQuery.NET/Models/QueryRequest.cs index 233f462..1d39d06 100644 --- a/src/FlexQuery.NET/Models/QueryRequest.cs +++ b/src/FlexQuery.NET/Models/QueryRequest.cs @@ -37,7 +37,7 @@ public class QueryRequest /// For complex filtered includes, use the 'include=' syntax in the filter or query parameters. /// /// Orders,Address - public string? Includes { get; set; } + public string? Include { get; set; } /// /// Comma-separated list of fields to group by for aggregation. diff --git a/src/FlexQuery.NET/Parsers/Dsl/ConditionNode.cs b/src/FlexQuery.NET/Parsers/Dsl/ConditionNode.cs new file mode 100644 index 0000000..c8aec19 --- /dev/null +++ b/src/FlexQuery.NET/Parsers/Dsl/ConditionNode.cs @@ -0,0 +1,22 @@ +namespace FlexQuery.NET.Parsers.Dsl; + +/// A single field/operator/value condition. +public sealed class ConditionNode : DslAstNode +{ + /// Creates a condition AST node. + public ConditionNode(string field, string @operator, string? value) + { + Field = field; + Operator = @operator; + Value = value; + } + + /// Field or nested property path. + public string Field { get; } + + /// Filter operator. + public string Operator { get; } + + /// Raw string value, when the operator requires one. + public string? Value { get; } +} diff --git a/src/FlexQuery.NET/Parsers/Dsl/DslAstNode.cs b/src/FlexQuery.NET/Parsers/Dsl/DslAstNode.cs index 97de484..7700d96 100644 --- a/src/FlexQuery.NET/Parsers/Dsl/DslAstNode.cs +++ b/src/FlexQuery.NET/Parsers/Dsl/DslAstNode.cs @@ -4,54 +4,3 @@ namespace FlexQuery.NET.Parsers.Dsl; public abstract class DslAstNode { } - -/// A single field/operator/value condition. -public sealed class ConditionNode : DslAstNode -{ - /// Creates a condition AST node. - public ConditionNode(string field, string @operator, string? value) - { - Field = field; - Operator = @operator; - Value = value; - } - - /// Field or nested property path. - public string Field { get; } - - /// Filter operator. - public string Operator { get; } - - /// Raw string value, when the operator requires one. - public string? Value { get; } -} - -/// A logical AND/OR node with child expressions. -public sealed class LogicalNode : DslAstNode -{ - /// Creates a logical AST node. - public LogicalNode(string logic, IReadOnlyList children) - { - Logic = logic; - Children = children; - } - - /// Logical operator: "and" or "or". - public string Logic { get; } - - /// Child AST nodes. - public IReadOnlyList Children { get; } -} - -/// A unary NOT node wrapping a child expression. -public sealed class NotNode : DslAstNode -{ - /// Creates a NOT AST node. - public NotNode(DslAstNode child) - { - Child = child; - } - - /// Child AST node to negate. - public DslAstNode Child { get; } -} diff --git a/src/FlexQuery.NET/Parsers/Dsl/DslFilterConverter.cs b/src/FlexQuery.NET/Parsers/Dsl/DslFilterConverter.cs index 9ff57cf..eacc13d 100644 --- a/src/FlexQuery.NET/Parsers/Dsl/DslFilterConverter.cs +++ b/src/FlexQuery.NET/Parsers/Dsl/DslFilterConverter.cs @@ -19,6 +19,15 @@ public static FilterGroup ToFilterGroup(DslAstNode node) if (node is LogicalNode logical) return ConvertLogical(logical); + if (node is RelationshipNode rel) + { + return new FilterGroup + { + Logic = LogicOperator.And, + Filters = [ConvertRelationship(rel)] + }; + } + return new FilterGroup { Logic = LogicOperator.And, @@ -41,12 +50,35 @@ private static FilterGroup ConvertLogical(LogicalNode node) continue; } + if (child is RelationshipNode rel) + { + group.Filters.Add(ConvertRelationship(rel)); + continue; + } + group.Groups.Add(ToFilterGroup(child)); } return group; } + private static FilterCondition ConvertRelationship(RelationshipNode node) + { + var cond = new FilterCondition + { + Field = node.Property, + Operator = node.Quantifier.ToLowerInvariant(), + ScopedFilter = node.ScopedFilter != null ? ToFilterGroup(node.ScopedFilter) : null + }; + + if (node.Operator != null) + { + cond.Value = $"{node.Operator}:{node.Value}"; + } + + return cond; + } + private static FilterCondition ConvertCondition(ConditionNode node) => new() { diff --git a/src/FlexQuery.NET/Parsers/Dsl/DslParser.cs b/src/FlexQuery.NET/Parsers/Dsl/DslParser.cs index 67d9a76..3ffe913 100644 --- a/src/FlexQuery.NET/Parsers/Dsl/DslParser.cs +++ b/src/FlexQuery.NET/Parsers/Dsl/DslParser.cs @@ -76,6 +76,41 @@ private DslAstNode ParsePrimary() return new NotNode(inner); } + if (Current.Kind == DslTokenKind.Identifier && (Current.Value.Contains(".any", StringComparison.OrdinalIgnoreCase) + || Current.Value.Contains(".all", StringComparison.OrdinalIgnoreCase) + || Current.Value.Contains(".count", StringComparison.OrdinalIgnoreCase))) + { + var val = Current.Value; + var dotIndex = val.LastIndexOf('.'); + var property = val.Substring(0, dotIndex); + var quantifier = val.Substring(dotIndex + 1); + + _position++; // consume identifier + + if (Match(DslTokenKind.OpenParen)) + { + DslAstNode? inner = null; + if (Current.Kind != DslTokenKind.CloseParen) + { + inner = ParseOr(); + } + Expect(DslTokenKind.CloseParen); + + if (quantifier.Equals("count", StringComparison.OrdinalIgnoreCase) && Match(DslTokenKind.Colon)) + { + var op = Expect(DslTokenKind.Identifier).Value; + Expect(DslTokenKind.Colon); + var value = Expect(DslTokenKind.Identifier).Value; + return new RelationshipNode(property, quantifier, inner, op, value); + } + + return new RelationshipNode(property, quantifier, inner); + } + + // Revert if no paren (fallback to plain condition) + _position--; + } + if (Match(DslTokenKind.OpenParen)) { var node = ParseOr(); diff --git a/src/FlexQuery.NET/Parsers/Dsl/LogicalNode.cs b/src/FlexQuery.NET/Parsers/Dsl/LogicalNode.cs new file mode 100644 index 0000000..1403600 --- /dev/null +++ b/src/FlexQuery.NET/Parsers/Dsl/LogicalNode.cs @@ -0,0 +1,18 @@ +namespace FlexQuery.NET.Parsers.Dsl; + +/// A logical AND/OR node with child expressions. +public sealed class LogicalNode : DslAstNode +{ + /// Creates a logical AST node. + public LogicalNode(string logic, IReadOnlyList children) + { + Logic = logic; + Children = children; + } + + /// Logical operator: "and" or "or". + public string Logic { get; } + + /// Child AST nodes. + public IReadOnlyList Children { get; } +} diff --git a/src/FlexQuery.NET/Parsers/Dsl/NotNode.cs b/src/FlexQuery.NET/Parsers/Dsl/NotNode.cs new file mode 100644 index 0000000..ff05eaf --- /dev/null +++ b/src/FlexQuery.NET/Parsers/Dsl/NotNode.cs @@ -0,0 +1,14 @@ +namespace FlexQuery.NET.Parsers.Dsl; + +/// A unary NOT node wrapping a child expression. +public sealed class NotNode : DslAstNode +{ + /// Creates a NOT AST node. + public NotNode(DslAstNode child) + { + Child = child; + } + + /// Child AST node to negate. + public DslAstNode Child { get; } +} diff --git a/src/FlexQuery.NET/Parsers/Dsl/RelationshipNode.cs b/src/FlexQuery.NET/Parsers/Dsl/RelationshipNode.cs new file mode 100644 index 0000000..012aae6 --- /dev/null +++ b/src/FlexQuery.NET/Parsers/Dsl/RelationshipNode.cs @@ -0,0 +1,20 @@ +namespace FlexQuery.NET.Parsers.Dsl; + +/// A relationship filter node (any/all/count) with a scoped filter. +public sealed class RelationshipNode : DslAstNode +{ + public RelationshipNode(string property, string quantifier, DslAstNode? scopedFilter, string? op = null, string? value = null) + { + Property = property; + Quantifier = quantifier; + ScopedFilter = scopedFilter; + Operator = op; + Value = value; + } + + public string Property { get; } + public string Quantifier { get; } + public DslAstNode? ScopedFilter { get; } + public string? Operator { get; } + public string? Value { get; } +} diff --git a/src/FlexQuery.NET/Parsers/QueryOptionsParser.cs b/src/FlexQuery.NET/Parsers/QueryOptionsParser.cs index 208268a..a748d57 100644 --- a/src/FlexQuery.NET/Parsers/QueryOptionsParser.cs +++ b/src/FlexQuery.NET/Parsers/QueryOptionsParser.cs @@ -29,7 +29,7 @@ public static class QueryOptionsParser RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex HavingPattern = new( - @"^(?sum|count|avg)\((?[A-Za-z_][A-Za-z0-9_\.]*)?\):(?[A-Za-z_][A-Za-z0-9_]*):(?.+)$", + @"^(?sum|count|avg)(?:\((?[A-Za-z_][A-Za-z0-9_\.]*)?\)|:(?[A-Za-z_][A-Za-z0-9_\.]*)):(?[A-Za-z_][A-Za-z0-9_]*):(?.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex AggregateSortPattern = new( @@ -53,7 +53,7 @@ public static QueryOptions Parse(QueryRequest request) if (!string.IsNullOrWhiteSpace(request.Filter)) dict["filter"] = request.Filter; if (!string.IsNullOrWhiteSpace(request.Sort)) dict["sort"] = request.Sort; if (!string.IsNullOrWhiteSpace(request.Select)) dict["select"] = request.Select; - if (!string.IsNullOrWhiteSpace(request.Includes)) dict["include"] = request.Includes; + if (!string.IsNullOrWhiteSpace(request.Include)) dict["include"] = request.Include; if (!string.IsNullOrWhiteSpace(request.GroupBy)) dict["group"] = request.GroupBy; if (!string.IsNullOrWhiteSpace(request.Having)) dict["having"] = request.Having; if (!string.IsNullOrWhiteSpace(request.Mode)) dict["mode"] = request.Mode; @@ -75,7 +75,7 @@ public static QueryOptions Parse(FlexQueryParameters parameters) // Try Cache first var cacheKey = new ParsedQueryCacheKey( parameters.Query, parameters.Filter, parameters.Sort, parameters.Select, - parameters.Includes, parameters.GroupBy, parameters.Having, + parameters.Include, parameters.GroupBy, parameters.Having, parameters.Page, parameters.PageSize, parameters.IncludeCount, parameters.Distinct, parameters.Mode); @@ -120,10 +120,10 @@ public static QueryOptions Parse(FlexQueryParameters parameters) } // Includes - if (!string.IsNullOrWhiteSpace(parameters.Includes)) + if (!string.IsNullOrWhiteSpace(parameters.Include)) { - options.Includes = SplitCsv(parameters.Includes.Split('(')[0]); - options.FilteredIncludes = FilteredIncludeParser.Parse(parameters.Includes); + options.Includes = SplitCsv(parameters.Include.Split('(')[0]); + options.FilteredIncludes = FilteredIncludeParser.Parse(parameters.Include); } // Metadata @@ -351,10 +351,12 @@ private static void ParseSelectWithAggregates(QueryOptions options, string? rawS if (string.IsNullOrWhiteSpace(rawHaving)) return null; var match = HavingPattern.Match(rawHaving.Trim()); if (!match.Success) return null; - + var fn = match.Groups["fn"].Value.ToLowerInvariant(); - var field = match.Groups["field"].Success ? match.Groups["field"].Value : null; - + var field = match.Groups["field"].Success + ? match.Groups["field"].Value + : (match.Groups["field2"].Success ? match.Groups["field2"].Value : null); + return new HavingCondition { Function = fn, diff --git a/src/FlexQuery.NET/Validation/Rules/TypeCompatibilityRule.cs b/src/FlexQuery.NET/Validation/Rules/TypeCompatibilityRule.cs index 6e86be2..844eaf0 100644 --- a/src/FlexQuery.NET/Validation/Rules/TypeCompatibilityRule.cs +++ b/src/FlexQuery.NET/Validation/Rules/TypeCompatibilityRule.cs @@ -43,7 +43,7 @@ private void ValidateFilterGroup(FilterGroup group, Type entityType, QueryExecut } // 2. Check Value Compatibility (Simple types) - if (filter.Value != null) + if (filter.Value != null && op != FilterOperators.Any && op != FilterOperators.All && op != FilterOperators.Count) { if (!CanConvert(filter.Value, propertyType)) { diff --git a/tests/FlexQuery.NET.Tests/Api/Dapper/DapperApiTestBase.cs b/tests/FlexQuery.NET.Tests/Api/Dapper/DapperApiTestBase.cs new file mode 100644 index 0000000..3cebc91 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Api/Dapper/DapperApiTestBase.cs @@ -0,0 +1,51 @@ +using System.Data; +using System.Data.Common; +using FlexQuery.NET.Dapper.Dialects; +using FlexQuery.NET.Tests.Fixtures; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.EntityFrameworkCore; + +namespace FlexQuery.NET.Tests.Api.Dapper; + +public abstract class DapperApiTestBase : IDisposable +{ + protected readonly IHost Host; + protected readonly HttpClient Client; + protected readonly IDbConnection Connection; + + protected abstract ISqlDialect Dialect { get; } + + protected DapperApiTestBase() + { + // Setup SQLite in-memory connection and seed it + var db = SqlProjectionDbContext.CreateSeeded(); + Connection = db.Database.GetDbConnection(); + if (Connection.State != ConnectionState.Open) Connection.Open(); + + Host = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder() + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseTestServer(); + webBuilder.UseStartup(); + webBuilder.ConfigureTestServices(services => + { + services.AddSingleton(Dialect); + services.AddSingleton(Connection); + }); + }) + .Start(); + + Client = Host.GetTestClient(); + } + + public void Dispose() + { + Connection.Close(); + Connection.Dispose(); + Client.Dispose(); + Host.Dispose(); + } +} diff --git a/tests/FlexQuery.NET.Tests/Api/Dapper/IncludeTests.cs b/tests/FlexQuery.NET.Tests/Api/Dapper/IncludeTests.cs new file mode 100644 index 0000000..461884f --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Api/Dapper/IncludeTests.cs @@ -0,0 +1,48 @@ +using FlexQuery.NET.Dapper.Dialects; +using FlexQuery.NET.Tests.Fixtures; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using System.Net.Http.Json; +using System.Text.Json; + +namespace FlexQuery.NET.Tests.Api.Dapper; + +public class IncludeTests : DapperApiTestBase +{ + protected override ISqlDialect Dialect => new SqliteDialect(); + + public IncludeTests() { } + + [Fact] + public async Task Should_Apply_LeftJoin_For_Include() + { + // Act + var response = await Client.GetAsync("/api/users?include=orders"); + + // Assert + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + var alice = json.GetProperty("Data").EnumerateArray() + .First(x => x.GetProperty("Name").GetString() == "Alice"); + + alice.TryGetProperty("Orders", out var orders).Should().BeTrue(); + orders.EnumerateArray().Should().NotBeEmpty(); + } + + [Fact] + public async Task Should_Apply_Filtered_Include() + { + // Act - Only include orders with total > 100 + var response = await Client.GetAsync("/api/users?include=orders(total:gt:100)"); + + // Assert + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + var alice = json.GetProperty("Data").EnumerateArray() + .First(x => x.GetProperty("Name").GetString() == "Alice"); + + var orders = alice.GetProperty("Orders").EnumerateArray().ToList(); + orders.Should().HaveCount(1); + orders[0].GetProperty("Total").GetDecimal().Should().BeGreaterThan(100); + } +} diff --git a/tests/FlexQuery.NET.Tests/Api/Dapper/OrderAggregationTests.cs b/tests/FlexQuery.NET.Tests/Api/Dapper/OrderAggregationTests.cs new file mode 100644 index 0000000..8317a12 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Api/Dapper/OrderAggregationTests.cs @@ -0,0 +1,60 @@ +using FlexQuery.NET.Dapper.Dialects; +using FlexQuery.NET.Tests.Fixtures; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using System.Net.Http.Json; +using System.Text.Json; + +namespace FlexQuery.NET.Tests.Api.Dapper; + +public class OrderAggregationTests : DapperApiTestBase +{ + protected override ISqlDialect Dialect => new SqliteDialect(); + + public OrderAggregationTests() { } + + [Fact] + public async Task Should_Group_Orders_By_Customer() + { + // Act + var response = await Client.GetAsync("/api/orders?groupBy=customerId"); + + // Assert + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + var items = json.GetProperty("Data").EnumerateArray().ToList(); + items.Should().HaveCount(3); // Alice, Bob, BobTwo all have orders + } + + [Fact] + public async Task Should_Apply_Aggregates() + { + // Act + var response = await Client.GetAsync("/api/orders?select=sum(total),count(id)"); + + // Assert + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + var items = json.GetProperty("Data").EnumerateArray().ToList(); + items.Should().HaveCount(1); + + var first = items[0]; + var keys = string.Join(", ", first.EnumerateObject().Select(p => p.Name)); + first.TryGetProperty("SUM_total", out _).Should().BeTrue($"Keys found: {keys}"); + first.GetProperty("SUM_total").GetDecimal().Should().BeGreaterThan(0); + first.GetProperty("COUNT_id").GetInt32().Should().BeGreaterThan(0); + } + + [Fact] + public async Task Should_Apply_Having_Clause() + { + // Act - Group by customer and only return those with total sum > 100 + var response = await Client.GetAsync("/api/orders?groupBy=customerId&having=count(id):gt:1&select=customerId,count(id)"); + + // Assert + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + var items = json.GetProperty("Data").EnumerateArray().ToList(); + items.Should().HaveCount(1); // Only Alice has > 100 total + } +} diff --git a/tests/FlexQuery.NET.Tests/Api/Dapper/RelationshipTests.cs b/tests/FlexQuery.NET.Tests/Api/Dapper/RelationshipTests.cs new file mode 100644 index 0000000..0e1027c --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Api/Dapper/RelationshipTests.cs @@ -0,0 +1,60 @@ +using FlexQuery.NET.Dapper.Dialects; +using FlexQuery.NET.Tests.Fixtures; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using System.Net.Http.Json; +using System.Text.Json; + +namespace FlexQuery.NET.Tests.Api.Dapper; + +public class RelationshipTests : DapperApiTestBase +{ + protected override ISqlDialect Dialect => new SqliteDialect(); + + public RelationshipTests() { } + + [Fact] + public async Task Should_Use_Exists_For_Any_Filter() + { + // Act - Users who have any order with total > 100 + var response = await Client.GetAsync("/api/users?filter=orders.any(total:gt:100)"); + + // Assert + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + var items = json.GetProperty("Data").EnumerateArray().ToList(); + items.Should().HaveCount(1); + items[0].GetProperty("Name").GetString().Should().Be("Alice"); + } + + [Fact] + public async Task Should_Use_NotExists_For_All_Filter() + { + // Act - Users where all orders have total > 10 + // (Bob has one order with total 99, so he matches. + // Alice has one order with 125 and one with 45, so she matches. + // Carol has no orders, so she technically matches (vacuously true) + var response = await Client.GetAsync("/api/users?filter=orders.all(total:gt:5)"); + + // Assert + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + var items = json.GetProperty("Data").EnumerateArray().ToList(); + items.Should().Contain(x => x.GetProperty("Name").GetString() == "Alice"); + items.Should().Contain(x => x.GetProperty("Name").GetString() == "Bob"); + } + + [Fact] + public async Task Should_Use_Subquery_For_Count_Filter() + { + // Act - Users with more than 1 order + var response = await Client.GetAsync("/api/users?filter=orders.count():gt:1"); + + // Assert + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + var items = json.GetProperty("Data").EnumerateArray().ToList(); + items.Should().HaveCount(1); + items[0].GetProperty("Name").GetString().Should().Be("Alice"); + } +} diff --git a/tests/FlexQuery.NET.Tests/Api/Dapper/SecurityValidationTests.cs b/tests/FlexQuery.NET.Tests/Api/Dapper/SecurityValidationTests.cs new file mode 100644 index 0000000..ef386c2 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Api/Dapper/SecurityValidationTests.cs @@ -0,0 +1,73 @@ +using FlexQuery.NET.Dapper.Dialects; +using FlexQuery.NET.Tests.Fixtures; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; + +namespace FlexQuery.NET.Tests.Api.Dapper; + +public class SecurityTests : DapperApiTestBase +{ + protected override ISqlDialect Dialect => new SqliteDialect(); + + public SecurityTests() { } + + [Fact] + public async Task Should_Block_SQL_Injection_In_Filter() + { + // Act + var response = await Client.GetAsync("/api/users?filter=name:contains:'; DROP TABLE Customers;--"); + + // Assert + // It should either return 400 (if caught by validator) or 200 with no results (if safely parameterized) + // In our case, the parser should handle it as a string value and parameterize it. + response.StatusCode.Should().Be(HttpStatusCode.OK); + var json = await response.Content.ReadAsStringAsync(); + json.Should().NotContain("DROP TABLE"); + } + + [Fact] + public async Task Should_Block_SQL_Injection_In_Sort() + { + // Act + var response = await Client.GetAsync("/api/users?sort=Name;DROP TABLE Customers"); + + // Assert + // Sort field validation should reject this because "Name;DROP TABLE Customers" is not a valid field. + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } +} + +public class ValidationTests : DapperApiTestBase +{ + protected override ISqlDialect Dialect => new SqliteDialect(); + + public ValidationTests() { } + + [Fact] + public async Task Should_Reject_Disallowed_Field() + { + // Act - Assume "SecretField" is not in the model or blocked + var response = await Client.GetAsync("/api/users?filter=secretField:eq:value"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Should_Enforce_MaxPageSize() + { + // Act + var response = await Client.GetAsync("/api/users?pageSize=1000000"); + + // Assert + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + var items = json.GetProperty("Data").EnumerateArray().ToList(); + + // Default max page size is usually 100 or 1000. + items.Count.Should().BeLessThan(1000000); + } +} diff --git a/tests/FlexQuery.NET.Tests/Api/Dapper/UsersTests.cs b/tests/FlexQuery.NET.Tests/Api/Dapper/UsersTests.cs new file mode 100644 index 0000000..eeb957b --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Api/Dapper/UsersTests.cs @@ -0,0 +1,81 @@ +using FlexQuery.NET.Dapper.Dialects; +using FlexQuery.NET.Tests.Fixtures; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using System.Net.Http.Json; +using System.Text.Json; + +namespace FlexQuery.NET.Tests.Api.Dapper; + +public class UsersTests : DapperApiTestBase +{ + protected override ISqlDialect Dialect => new SqliteDialect(); + + public UsersTests() { } + + [Fact] + public async Task Should_Return_Healthy() + { + var response = await Client.GetAsync("/api/users/health"); + response.EnsureSuccessStatusCode(); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Be("Healthy"); + } + + [Fact] + public async Task Should_Filter_Users_By_Name() + { + // Act + var response = await Client.GetAsync("/api/users?filter=name:eq:Alice"); + + // Assert + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + var items = json.GetProperty("Data").EnumerateArray(); + items.Should().HaveCount(1); + items.First().GetProperty("Name").GetString().Should().Be("Alice"); + } + + [Fact] + public async Task Should_Sort_Users_By_Name_Descending() + { + // Act + var response = await Client.GetAsync("/api/users?sort=name:desc"); + + // Assert + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + var items = json.GetProperty("Data").EnumerateArray().ToList(); + items.Should().HaveCountGreaterThan(1); + items[0].GetProperty("Name").GetString().Should().Be("Bob"); // "Bob" comes after "Alice" + } + + [Fact] + public async Task Should_Apply_Pagination() + { + // Act + var response = await Client.GetAsync("/api/users?page=1&pageSize=1"); + + // Assert + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + json.GetProperty("Data").EnumerateArray().Should().HaveCount(1); + json.GetProperty("TotalCount").GetInt32().Should().BeGreaterThan(1); + } + + [Fact] + public async Task Should_Project_Selected_Fields() + { + // Act + var response = await Client.GetAsync("/api/users?select=id,name"); + + // Assert + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + var firstItem = json.GetProperty("Data").EnumerateArray().First(); + + firstItem.TryGetProperty("Id", out _).Should().BeTrue(); + firstItem.TryGetProperty("Name", out _).Should().BeTrue(); + firstItem.TryGetProperty("Email", out _).Should().BeFalse(); + } +} diff --git a/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlTranslatorTests.cs b/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlTranslatorTests.cs index 4138416..abff7c2 100644 --- a/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlTranslatorTests.cs +++ b/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlTranslatorTests.cs @@ -94,8 +94,8 @@ public void Translate_BetweenOperator_GeneratesBetweenClause() command.Sql.Should().Contain("BETWEEN"); command.Parameters.Should().HaveCount(2); - command.Parameters["@p0"].Should().Be("20"); - command.Parameters["@p1"].Should().Be("30"); + command.Parameters["@p0"].Should().Be(20); + command.Parameters["@p1"].Should().Be(30); } [Fact] @@ -386,7 +386,7 @@ public void Translate_CountOperator_GeneratesCorrelatedCountSubquery() command.Sql.Should().Contain("(SELECT COUNT(*) FROM [roles]"); command.Sql.Should().Contain("users.Id = roles.UserId"); command.Sql.Should().Contain("> @p1"); - command.Parameters["@p1"].Should().Be("5"); + command.Parameters["@p1"].Should().Be(5); } [Fact] diff --git a/tests/FlexQuery.NET.Tests/Fixtures/DemoApi.cs b/tests/FlexQuery.NET.Tests/Fixtures/DemoApi.cs new file mode 100644 index 0000000..4680c58 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Fixtures/DemoApi.cs @@ -0,0 +1,169 @@ +using FlexQuery.NET.AspNetCore.Extensions; +using FlexQuery.NET.Dapper; +using FlexQuery.NET.Dapper.Dialects; +using FlexQuery.NET.Models; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using System.Data.Common; +using FlexQuery.NET.Dapper.Mapping; +using Microsoft.Extensions.DependencyInjection; +using System.Data; + +using System.Text.Json.Serialization; +using FlexQuery.NET.Tests.Models; + +namespace FlexQuery.NET.Tests.Fixtures; + +public class DemoApiStartup +{ + public void ConfigureServices(IServiceCollection services) + { + services.AddControllers() + .AddApplicationPart(typeof(DemoApiStartup).Assembly) + .AddJsonOptions(options => + { + options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; + options.JsonSerializerOptions.PropertyNamingPolicy = null; + }) + .AddFlexQuerySecurity(); + + var registry = new MappingRegistry(); + + var customerMapping = new EntityMapping(typeof(SqlCustomer), "Customers"); + customerMapping.MapProperty("Id", "Id"); + customerMapping.MapProperty("Name", "Name"); + customerMapping.MapProperty("Email", "Email"); + customerMapping.MapJoin("Address", typeof(SqlAddress), "Addresses", "\"Customers\".\"Id\" = \"Address\".\"CustomerId\""); + customerMapping.MapJoin("Orders", typeof(SqlOrder), "Orders", "\"Customers\".\"Id\" = \"Orders\".\"CustomerId\""); + registry.Register(customerMapping); + + var orderMapping = new EntityMapping(typeof(SqlOrder), "Orders"); + orderMapping.MapProperty("Id", "Id"); + orderMapping.MapProperty("CustomerId", "CustomerId"); + orderMapping.MapProperty("OrderDate", "OrderDate"); + orderMapping.MapProperty("Total", "Total"); + orderMapping.MapJoin("Items", typeof(SqlOrderItem), "OrderItems", "\"Orders\".\"Id\" = \"OrderItems\".\"OrderId\""); + registry.Register(orderMapping); + + var orderItemMapping = new EntityMapping(typeof(SqlOrderItem), "OrderItems"); + orderItemMapping.MapProperty("Id", "Id"); + orderItemMapping.MapProperty("OrderId", "OrderId"); + orderItemMapping.MapProperty("ProductName", "ProductName"); + orderItemMapping.MapProperty("Quantity", "Quantity"); + orderItemMapping.MapProperty("UnitPrice", "UnitPrice"); + registry.Register(orderItemMapping); + + services.AddSingleton(registry); + } + + public void Configure(IApplicationBuilder app) + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }); + } +} + +[ApiController] +[Route("api/users")] +public class UsersController : ControllerBase +{ + private readonly IDbConnection _connection; + private readonly ISqlDialect _dialect; + private readonly IMappingRegistry _registry; + + public UsersController(IDbConnection connection, ISqlDialect dialect, IMappingRegistry registry) + { + _connection = connection; + _dialect = dialect; + _registry = registry; + } + + [HttpGet("health")] + public IActionResult Health() => Ok("Healthy"); + + [HttpGet] + public async Task Get([FromQuery] FlexQueryParameters parameters) + { + try + { + var result = await ((System.Data.Common.DbConnection)_connection).FlexQueryAsync(parameters, opt => + { + opt.Dialect = _dialect; + opt.MappingRegistry = _registry; + opt.EntityType = typeof(SqlCustomer); + }); + return Ok(result); + } + catch (FlexQuery.NET.Exceptions.QueryValidationException ex) + { + return BadRequest(ex.Message); + } + } +} + +[ApiController] +[Route("api/orders")] +public class OrdersController : ControllerBase +{ + private readonly IDbConnection _connection; + private readonly ISqlDialect _dialect; + private readonly IMappingRegistry _registry; + + public OrdersController(IDbConnection connection, ISqlDialect dialect, IMappingRegistry registry) + { + _connection = connection; + _dialect = dialect; + _registry = registry; + } + + [HttpGet] + public async Task Get([FromQuery] FlexQueryParameters parameters) + { + try + { + var result = await ((System.Data.Common.DbConnection)_connection).FlexQueryAsync(parameters, opt => + { + opt.Dialect = _dialect; + opt.MappingRegistry = _registry; + opt.EntityType = typeof(SqlOrder); + }); + return Ok(result); + } + catch (FlexQuery.NET.Exceptions.QueryValidationException ex) + { + return BadRequest(ex.Message); + } + } +} + +[ApiController] +[Route("api/products")] +public class ProductsController : ControllerBase +{ + private readonly IDbConnection _connection; + private readonly ISqlDialect _dialect; + private readonly IMappingRegistry _registry; + + public ProductsController(IDbConnection connection, ISqlDialect dialect, IMappingRegistry registry) + { + _connection = connection; + _dialect = dialect; + _registry = registry; + } + + [HttpGet] + public async Task Get([FromQuery] FlexQueryParameters parameters) + { + // Using SqlOrderItem as "Product" for demo + var result = await ((DbConnection)_connection).FlexQueryAsync(parameters, opt => + { + opt.Dialect = _dialect; + opt.MappingRegistry = _registry; + }); + return Ok(result); + } +} diff --git a/tests/FlexQuery.NET.Tests/Fixtures/SqlProjectionDbContext.cs b/tests/FlexQuery.NET.Tests/Fixtures/SqlProjectionDbContext.cs index 6c8bb77..36fc91d 100644 --- a/tests/FlexQuery.NET.Tests/Fixtures/SqlProjectionDbContext.cs +++ b/tests/FlexQuery.NET.Tests/Fixtures/SqlProjectionDbContext.cs @@ -1,5 +1,6 @@ using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using FlexQuery.NET.Tests.Models; namespace FlexQuery.NET.Tests.Fixtures; @@ -43,6 +44,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { entity.HasKey(x => x.Id); entity.Property(x => x.Number).IsRequired(); + entity.Property(x => x.Total).HasColumnType("NUMERIC"); entity.HasMany(x => x.Items) .WithOne(x => x.Order) .HasForeignKey(x => x.OrderId); @@ -95,7 +97,7 @@ public static SqlProjectionDbContext CreateSeeded() Id = 10, Number = "SO-001", Total = 125.50m, - CreatedAtUtc = new DateTime(2025, 1, 1, 8, 0, 0, DateTimeKind.Utc), + OrderDate = new DateTime(2025, 1, 1, 8, 0, 0, DateTimeKind.Utc), Items = [ new SqlOrderItem { Id = 1000, Sku = "SKU-AAA" }, @@ -107,7 +109,7 @@ public static SqlProjectionDbContext CreateSeeded() Id = 11, Number = "SO-002", Total = 45.00m, - CreatedAtUtc = new DateTime(2025, 1, 2, 8, 0, 0, DateTimeKind.Utc), + OrderDate = new DateTime(2025, 1, 2, 8, 0, 0, DateTimeKind.Utc), Items = [ new SqlOrderItem { Id = 1002, Sku = "SKU-CCC" } @@ -129,7 +131,7 @@ public static SqlProjectionDbContext CreateSeeded() Id = 12, Number = "SO-003", Total = 99.00m, - CreatedAtUtc = new DateTime(2025, 1, 3, 8, 0, 0, DateTimeKind.Utc) + OrderDate = new DateTime(2025, 1, 3, 8, 0, 0, DateTimeKind.Utc) } ] }; @@ -147,7 +149,7 @@ public static SqlProjectionDbContext CreateSeeded() Id = 13, Number = "SO-004", Total = 10.00m, - CreatedAtUtc = new DateTime(2025, 1, 4, 8, 0, 0, DateTimeKind.Utc) + OrderDate = new DateTime(2025, 1, 4, 8, 0, 0, DateTimeKind.Utc) } ] }; @@ -159,39 +161,3 @@ public static SqlProjectionDbContext CreateSeeded() return context; } } - -public sealed class SqlCustomer -{ - public int Id { get; set; } - public string Name { get; set; } = string.Empty; - public string Email { get; set; } = string.Empty; - public SqlAddress? Address { get; set; } - public List Orders { get; set; } = []; -} - -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!; -} - -public sealed class SqlOrder -{ - public int Id { get; set; } - public string Number { get; set; } = string.Empty; - public decimal Total { get; set; } - public DateTime CreatedAtUtc { get; set; } - public int CustomerId { get; set; } - public SqlCustomer Customer { get; set; } = null!; - public List Items { get; set; } = []; -} - -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/FlexQuery.NET.Tests.csproj b/tests/FlexQuery.NET.Tests/FlexQuery.NET.Tests.csproj index 866e60a..1f0846f 100644 --- a/tests/FlexQuery.NET.Tests/FlexQuery.NET.Tests.csproj +++ b/tests/FlexQuery.NET.Tests/FlexQuery.NET.Tests.csproj @@ -10,25 +10,27 @@ + - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + + 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/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/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(); From 7fb07983951d2da39afb0d9c760e09c412bfdb00 Mon Sep 17 00:00:00 2001 From: Peter John Casasola Date: Fri, 15 May 2026 13:08:42 +0800 Subject: [PATCH 3/9] =?UTF-8?q?feat(dapper):=20complete=20convention?= =?UTF-8?q?=E2=80=91over=E2=80=91configuration=20refactor=20&=20fix=20test?= =?UTF-8?q?=20suite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrote SqlCountTranslator with clean source, using GetRelationship and IMappingRegistry. - Added missing Mapping.Metadata using directives to SqlExistsTranslator and SqlIncludeTranslator. - Cleaned up redundant Metadata. prefixes in SqlIncludeTranslator and SqlExistsTranslator. - Updated SqlTranslatorTests: * Registered TestRole entity (Table "roles") and corrected assertions to match new quoted SQL (e.g., "[roles].[UserId] = [users].[Id]"). - Fixed SqlInjectionTests navigation‑property assertion to use "[TestEntities]" (convention‑based table name). - Adjusted BuildJoinCondition string interpolation for proper `$` syntax. - All 365 tests now pass. --- CHANGELOG.md | 19 ++-- .../FlexQueryDapperExtensions.cs | 53 ++++++++++ .../Conventions/DefaultEntityConvention.cs | 79 +++++++++++++++ .../DefaultForeignKeyConvention.cs | 28 ++++++ .../Conventions/DefaultPluralizer.cs | 35 +++++++ .../DefaultRelationshipConvention.cs | 89 +++++++++++++++++ .../Conventions/IEntityConvention.cs | 11 +++ .../Conventions/IForeignKeyConvention.cs | 12 +++ .../Conventions/IPluralizer.cs | 12 +++ .../Conventions/IRelationshipConvention.cs | 12 +++ .../DapperQueryOptions.cs | 36 ++++++- .../FlexQuery.NET.Dapper.csproj | 1 + .../Mapping/Builders/EntityTypeBuilder.cs | 59 +++++++++++ .../Mapping/Builders/PropertyBuilder.cs | 25 +++++ .../Mapping/Builders/RelationshipBuilder.cs | 34 +++++++ .../Mapping/EntityMapping.cs | 50 ---------- .../Mapping/EntityMappingBuilder.cs | 18 ---- .../Mapping/EntityTypeBuilder.cs | 38 -------- .../Mapping/IEntityMapping.cs | 3 + .../Mapping/IMappingRegistry.cs | 5 + .../Mapping/MappingRegistry.cs | 54 ++++++++--- .../Mapping/Metadata/EntityMapping.cs | 97 +++++++++++++++++++ .../Mapping/Metadata/PropertyMapping.cs | 20 ++++ .../Mapping/Metadata/RelationshipMapping.cs | 49 ++++++++++ .../Mapping/Metadata/RelationshipType.cs | 12 +++ .../Mapping/PropertyBuilder.cs | 24 ----- .../SQL/Translators/SqlCountTranslator.cs | 39 ++++++-- .../SQL/Translators/SqlExistsTranslator.cs | 44 ++++++--- .../SQL/Translators/SqlIncludeTranslator.cs | 20 +++- .../SQL/Translators/SqlTranslator.cs | 40 ++++---- .../Dapper/Dialects/DialectTests.cs | 18 ++-- .../Dapper/Security/SqlInjectionTests.cs | 2 +- .../Dapper/Translation/SqlTranslatorTests.cs | 15 +-- tests/FlexQuery.NET.Tests/Fixtures/DemoApi.cs | 35 +++---- 34 files changed, 849 insertions(+), 239 deletions(-) create mode 100644 src/FlexQuery.NET.Dapper/Configuration/FlexQueryDapperExtensions.cs create mode 100644 src/FlexQuery.NET.Dapper/Conventions/DefaultEntityConvention.cs create mode 100644 src/FlexQuery.NET.Dapper/Conventions/DefaultForeignKeyConvention.cs create mode 100644 src/FlexQuery.NET.Dapper/Conventions/DefaultPluralizer.cs create mode 100644 src/FlexQuery.NET.Dapper/Conventions/DefaultRelationshipConvention.cs create mode 100644 src/FlexQuery.NET.Dapper/Conventions/IEntityConvention.cs create mode 100644 src/FlexQuery.NET.Dapper/Conventions/IForeignKeyConvention.cs create mode 100644 src/FlexQuery.NET.Dapper/Conventions/IPluralizer.cs create mode 100644 src/FlexQuery.NET.Dapper/Conventions/IRelationshipConvention.cs create mode 100644 src/FlexQuery.NET.Dapper/Mapping/Builders/EntityTypeBuilder.cs create mode 100644 src/FlexQuery.NET.Dapper/Mapping/Builders/PropertyBuilder.cs create mode 100644 src/FlexQuery.NET.Dapper/Mapping/Builders/RelationshipBuilder.cs delete mode 100644 src/FlexQuery.NET.Dapper/Mapping/EntityMapping.cs delete mode 100644 src/FlexQuery.NET.Dapper/Mapping/EntityMappingBuilder.cs delete mode 100644 src/FlexQuery.NET.Dapper/Mapping/EntityTypeBuilder.cs create mode 100644 src/FlexQuery.NET.Dapper/Mapping/Metadata/EntityMapping.cs create mode 100644 src/FlexQuery.NET.Dapper/Mapping/Metadata/PropertyMapping.cs create mode 100644 src/FlexQuery.NET.Dapper/Mapping/Metadata/RelationshipMapping.cs create mode 100644 src/FlexQuery.NET.Dapper/Mapping/Metadata/RelationshipType.cs delete mode 100644 src/FlexQuery.NET.Dapper/Mapping/PropertyBuilder.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 632bc2b..b88cb35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,16 +5,6 @@ All notable changes to this project will be documented in this file. --- ## [3.0.0] - 2026-05-15 -### Fixed -- **Dapper Query Engine Stabilization**: - - Improved `SqlTranslator` projection logic to explicitly include GroupBy columns when no explicit select list is provided, ensuring valid SQL generation for grouped queries. - - Refined parameter binding in `HAVING` clauses to use correct numeric types (ints/decimals) instead of string literals. -- **Test Suite Integrity**: - - Synchronized `SqlTranslatorTests` assertions with the provider's improved typed parameter conversion logic. - - Resolved `CS1022` syntax errors in `SecurityValidationTests.cs` caused by illegal namespace nesting. - - Updated `WildcardProjectionTests.cs` and `OrderAggregationTests.cs` to align with recent entity model refactorings and seed data. - - Achieved 100% pass rate across 365 tests in the solution. - ### Added - **FlexQuery.NET.Dapper Package**: - Full support for Dapper as a high-performance query engine. @@ -28,6 +18,15 @@ All notable changes to this project will be documented in this file. - **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. ### Changed - **Mapping Registry Evolution**: Updated `JoinInfo` to support `TargetType`, enabling deep property resolution for related entity filters in Dapper. 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 index 5bce14f..4b99a30 100644 --- a/src/FlexQuery.NET.Dapper/DapperQueryOptions.cs +++ b/src/FlexQuery.NET.Dapper/DapperQueryOptions.cs @@ -75,11 +75,45 @@ public QueryExecutionOptions ToQueryExecutionOptions() 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; } + 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/FlexQuery.NET.Dapper.csproj b/src/FlexQuery.NET.Dapper/FlexQuery.NET.Dapper.csproj index 4744316..91a0bc4 100644 --- a/src/FlexQuery.NET.Dapper/FlexQuery.NET.Dapper.csproj +++ b/src/FlexQuery.NET.Dapper/FlexQuery.NET.Dapper.csproj @@ -54,6 +54,7 @@ + diff --git a/src/FlexQuery.NET.Dapper/Mapping/Builders/EntityTypeBuilder.cs b/src/FlexQuery.NET.Dapper/Mapping/Builders/EntityTypeBuilder.cs new file mode 100644 index 0000000..e166e77 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Mapping/Builders/EntityTypeBuilder.cs @@ -0,0 +1,59 @@ +using System.Linq.Expressions; +using System.Reflection; +using FlexQuery.NET.Dapper.Mapping.Metadata; + +namespace FlexQuery.NET.Dapper.Mapping.Builders; + +public class EntityTypeBuilder where TEntity : class +{ + private readonly EntityMapping _mapping; + + public EntityTypeBuilder(EntityMapping mapping) + { + _mapping = mapping; + } + + public EntityTypeBuilder ToTable(string tableName) + { + _mapping.TableName = tableName; + return this; + } + + public EntityTypeBuilder HasAlias(string tableAlias) + { + _mapping.TableAlias = tableAlias; + return this; + } + + public PropertyBuilder Property(Expression> propertyExpression) + { + var propertyInfo = GetPropertyInfo(propertyExpression); + var propMapping = _mapping.GetOrAddProperty(propertyInfo); + return new PropertyBuilder(propMapping); + } + + public RelationshipBuilder HasMany(Expression>> navigationExpression) + where TRelatedEntity : class + { + var propertyInfo = GetPropertyInfo(navigationExpression); + var relMapping = _mapping.GetOrAddRelationship(propertyInfo, typeof(TRelatedEntity), RelationshipType.OneToMany); + return new RelationshipBuilder(relMapping); + } + + public RelationshipBuilder HasOne(Expression> navigationExpression) + where TRelatedEntity : class + { + var propertyInfo = GetPropertyInfo(navigationExpression); + var relMapping = _mapping.GetOrAddRelationship(propertyInfo, typeof(TRelatedEntity), RelationshipType.ManyToOne); + return new RelationshipBuilder(relMapping); + } + + private PropertyInfo GetPropertyInfo(Expression> expression) + { + if (expression.Body is MemberExpression memberExpression && memberExpression.Member is PropertyInfo propertyInfo) + { + return propertyInfo; + } + throw new ArgumentException("Expression must be a property access.", nameof(expression)); + } +} diff --git a/src/FlexQuery.NET.Dapper/Mapping/Builders/PropertyBuilder.cs b/src/FlexQuery.NET.Dapper/Mapping/Builders/PropertyBuilder.cs new file mode 100644 index 0000000..701a060 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Mapping/Builders/PropertyBuilder.cs @@ -0,0 +1,25 @@ +using FlexQuery.NET.Dapper.Mapping.Metadata; + +namespace FlexQuery.NET.Dapper.Mapping.Builders; + +public class PropertyBuilder +{ + private readonly PropertyMapping _mapping; + + public PropertyBuilder(PropertyMapping mapping) + { + _mapping = mapping; + } + + public PropertyBuilder HasColumn(string columnName) + { + _mapping.ColumnName = columnName; + return this; + } + + public PropertyBuilder IsPrimaryKey() + { + _mapping.IsPrimaryKey = true; + return this; + } +} diff --git a/src/FlexQuery.NET.Dapper/Mapping/Builders/RelationshipBuilder.cs b/src/FlexQuery.NET.Dapper/Mapping/Builders/RelationshipBuilder.cs new file mode 100644 index 0000000..0dadf9d --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Mapping/Builders/RelationshipBuilder.cs @@ -0,0 +1,34 @@ +using FlexQuery.NET.Dapper.Mapping.Metadata; + +namespace FlexQuery.NET.Dapper.Mapping.Builders; + +public class RelationshipBuilder +{ + private readonly RelationshipMapping _mapping; + + public RelationshipBuilder(RelationshipMapping mapping) + { + _mapping = mapping; + } + + public RelationshipBuilder WithForeignKey(string foreignKey) + { + _mapping.ForeignKey = foreignKey; + return this; + } + + public RelationshipBuilder WithPrincipalKey(string principalKey) + { + _mapping.PrincipalKey = principalKey; + return this; + } + + public RelationshipBuilder UsingJoinTable(string joinTableName, string joinTableForeignKey, string joinTableTargetKey) + { + _mapping.RelationshipType = RelationshipType.ManyToMany; + _mapping.JoinTable = joinTableName; + _mapping.JoinTableForeignKey = joinTableForeignKey; + _mapping.JoinTableTargetKey = joinTableTargetKey; + return this; + } +} diff --git a/src/FlexQuery.NET.Dapper/Mapping/EntityMapping.cs b/src/FlexQuery.NET.Dapper/Mapping/EntityMapping.cs deleted file mode 100644 index 690de99..0000000 --- a/src/FlexQuery.NET.Dapper/Mapping/EntityMapping.cs +++ /dev/null @@ -1,50 +0,0 @@ -namespace FlexQuery.NET.Dapper.Mapping; - -/// -/// Configuration for a database entity. -/// -public sealed class EntityMapping : IEntityMapping -{ - private readonly Dictionary _propertyToColumn = new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary _columnToProperty = new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary _joins = new(StringComparer.OrdinalIgnoreCase); - - public Type Type { get; } - public string TableName { get; } - public string? TableAlias { get; set; } - - public EntityMapping(Type type, string tableName, string? tableAlias = null) - { - Type = type; - TableName = tableName; - TableAlias = tableAlias; - } - - public void MapProperty(string property, string column) - { - _propertyToColumn[property] = column; - _columnToProperty[column] = property; - } - - public void MapJoin(string navigationProperty, Type targetType, string tableName, string joinCondition) - { - _joins[navigationProperty] = new JoinInfo - { - NavigationProperty = navigationProperty, - TargetType = targetType, - TableName = tableName, - JoinCondition = joinCondition - }; - } - - public string GetColumnName(string propertyName) - => _propertyToColumn.TryGetValue(propertyName, out var column) ? column : propertyName; - - public string? GetPropertyName(string columnName) - => _columnToProperty.TryGetValue(columnName, out var property) ? property : null; - - public IEnumerable GetProperties() => _propertyToColumn.Keys; - - public JoinInfo? GetJoinInfo(string navigationProperty) - => _joins.TryGetValue(navigationProperty, out var joinInfo) ? joinInfo : null; -} diff --git a/src/FlexQuery.NET.Dapper/Mapping/EntityMappingBuilder.cs b/src/FlexQuery.NET.Dapper/Mapping/EntityMappingBuilder.cs deleted file mode 100644 index 85d14cb..0000000 --- a/src/FlexQuery.NET.Dapper/Mapping/EntityMappingBuilder.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace FlexQuery.NET.Dapper.Mapping; - -/// -/// Entry point for configuring entity mappings. -/// -public sealed class EntityMappingBuilder -{ - private readonly MappingRegistry _registry; - - internal EntityMappingBuilder(MappingRegistry registry) - { - _registry = registry; - } - - /// Configures an entity type. - public EntityTypeBuilder Entity() where T : class - => new(typeof(T), _registry); -} diff --git a/src/FlexQuery.NET.Dapper/Mapping/EntityTypeBuilder.cs b/src/FlexQuery.NET.Dapper/Mapping/EntityTypeBuilder.cs deleted file mode 100644 index 5599818..0000000 --- a/src/FlexQuery.NET.Dapper/Mapping/EntityTypeBuilder.cs +++ /dev/null @@ -1,38 +0,0 @@ -namespace FlexQuery.NET.Dapper.Mapping; - -/// -/// Builder for configuring an entity type. -/// -/// The entity type. -public sealed class EntityTypeBuilder where T : class -{ - private readonly Type _type; - private readonly MappingRegistry _registry; - private readonly EntityMapping _mapping; - - internal EntityTypeBuilder(Type type, MappingRegistry registry) - { - _type = type; - _registry = registry; - _mapping = new EntityMapping(type, type.Name.ToLowerInvariant() + "s"); - } - - /// Configures the table name and optional alias. - public EntityTypeBuilder Table(string tableName, string? alias = null) - { - var field = typeof(EntityMapping).GetField("_tableName", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - // Direct field copy not supported, create new mapping - return this; - } - - /// Configures a property-to-column mapping. - public PropertyBuilder Property(System.Linq.Expressions.Expression> propertyExpression) - { - var memberExpression = (System.Linq.Expressions.MemberExpression)propertyExpression.Body; - var propertyName = memberExpression.Member.Name; - return new PropertyBuilder(_mapping, propertyName); - } - - /// Finishes configuration and registers the mapping. - public void Register() => _registry.Register(_mapping); -} diff --git a/src/FlexQuery.NET.Dapper/Mapping/IEntityMapping.cs b/src/FlexQuery.NET.Dapper/Mapping/IEntityMapping.cs index bb5b527..315cbf7 100644 --- a/src/FlexQuery.NET.Dapper/Mapping/IEntityMapping.cs +++ b/src/FlexQuery.NET.Dapper/Mapping/IEntityMapping.cs @@ -23,6 +23,9 @@ public interface IEntityMapping /// Get all mapped property names. IEnumerable GetProperties(); + /// Get relationship mapping metadata. + Metadata.RelationshipMapping? GetRelationship(string navigationProperty); + /// Get join information for an include relationship. JoinInfo? GetJoinInfo(string navigationProperty); } diff --git a/src/FlexQuery.NET.Dapper/Mapping/IMappingRegistry.cs b/src/FlexQuery.NET.Dapper/Mapping/IMappingRegistry.cs index b96ea8f..d10e28e 100644 --- a/src/FlexQuery.NET.Dapper/Mapping/IMappingRegistry.cs +++ b/src/FlexQuery.NET.Dapper/Mapping/IMappingRegistry.cs @@ -1,3 +1,5 @@ +using FlexQuery.NET.Dapper.Mapping.Builders; + namespace FlexQuery.NET.Dapper.Mapping; /// @@ -10,4 +12,7 @@ public interface IMappingRegistry /// Gets the mapping for an entity type. IEntityMapping GetMapping(); + + /// Configures an entity mapping using the fluent builder API. + EntityTypeBuilder Entity() where TEntity : class; } diff --git a/src/FlexQuery.NET.Dapper/Mapping/MappingRegistry.cs b/src/FlexQuery.NET.Dapper/Mapping/MappingRegistry.cs index f36ef24..cefb246 100644 --- a/src/FlexQuery.NET.Dapper/Mapping/MappingRegistry.cs +++ b/src/FlexQuery.NET.Dapper/Mapping/MappingRegistry.cs @@ -1,30 +1,58 @@ using System.Collections.Concurrent; +using FlexQuery.NET.Dapper.Conventions; +using FlexQuery.NET.Dapper.Mapping.Builders; +using FlexQuery.NET.Dapper.Mapping.Metadata; namespace FlexQuery.NET.Dapper.Mapping; /// -/// Registry for entity mappings with caching. +/// Registry for entity mappings with caching and convention-based discovery. /// public sealed class MappingRegistry : IMappingRegistry { - private readonly ConcurrentDictionary _mappings = new(); + private readonly ConcurrentDictionary _mappings = new(); + + // Conventions + private readonly IPluralizer _pluralizer; + private readonly IEntityConvention _entityConvention; + private readonly IRelationshipConvention _relationshipConvention; + + public MappingRegistry() : this( + new DefaultPluralizer(), + new DefaultForeignKeyConvention()) + { + } + + public MappingRegistry(IPluralizer pluralizer, IForeignKeyConvention foreignKeyConvention) + { + _pluralizer = pluralizer; + _entityConvention = new DefaultEntityConvention(_pluralizer); + _relationshipConvention = new DefaultRelationshipConvention(foreignKeyConvention); + } public IEntityMapping GetMapping(Type entityType) - => _mappings.GetOrAdd(entityType, _ => CreateDefaultMapping(entityType)); + => _mappings.GetOrAdd(entityType, CreateAndApplyConventions); public IEntityMapping GetMapping() => GetMapping(typeof(T)); - public void Register(IEntityMapping mapping) => _mappings[mapping.Type] = mapping; + public void Register(EntityMapping mapping) => _mappings[mapping.Type] = mapping; - private IEntityMapping CreateDefaultMapping(Type entityType) + public EntityTypeBuilder Entity() where TEntity : class { - var tableName = entityType.Name; - if (tableName.EndsWith("Entity", StringComparison.OrdinalIgnoreCase)) - tableName = tableName.Substring(0, tableName.Length - 6); - if (tableName.EndsWith("Dto", StringComparison.OrdinalIgnoreCase)) - tableName = tableName.Substring(0, tableName.Length - 3); - tableName = tableName.ToLowerInvariant() + "s"; - - return new EntityMapping(entityType, tableName); + var mapping = _mappings.GetOrAdd(typeof(TEntity), CreateAndApplyConventions); + return new EntityTypeBuilder(mapping); } + + private EntityMapping CreateAndApplyConventions(Type entityType) + { + var mapping = new EntityMapping(entityType, entityType.Name); + + _entityConvention.Apply(mapping); + _relationshipConvention.Apply(mapping, this); + + return mapping; + } + + // For testing/internal configuration + public IEnumerable GetAllMappings() => _mappings.Values; } diff --git a/src/FlexQuery.NET.Dapper/Mapping/Metadata/EntityMapping.cs b/src/FlexQuery.NET.Dapper/Mapping/Metadata/EntityMapping.cs new file mode 100644 index 0000000..9d61e2f --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Mapping/Metadata/EntityMapping.cs @@ -0,0 +1,97 @@ +using System.Reflection; + +namespace FlexQuery.NET.Dapper.Mapping.Metadata; + +/// +/// Configuration metadata for a database entity. +/// +public sealed class EntityMapping : IEntityMapping +{ + private readonly Dictionary _properties = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _relationships = new(StringComparer.OrdinalIgnoreCase); + + public Type Type { get; } + public string TableName { get; set; } + public string? TableAlias { get; set; } + + public EntityMapping(Type type, string tableName, string? tableAlias = null) + { + Type = type; + TableName = tableName; + TableAlias = tableAlias; + } + + public PropertyMapping GetOrAddProperty(PropertyInfo propertyInfo) + { + if (!_properties.TryGetValue(propertyInfo.Name, out var mapping)) + { + mapping = new PropertyMapping(propertyInfo, propertyInfo.Name); + _properties[propertyInfo.Name] = mapping; + } + return mapping; + } + + public RelationshipMapping GetOrAddRelationship(PropertyInfo propertyInfo, Type targetType, RelationshipType type) + { + if (!_relationships.TryGetValue(propertyInfo.Name, out var mapping)) + { + mapping = new RelationshipMapping(propertyInfo, targetType, type); + _relationships[propertyInfo.Name] = mapping; + } + return mapping; + } + + public PropertyMapping? GetProperty(string propertyName) + => _properties.TryGetValue(propertyName, out var p) ? p : null; + + public RelationshipMapping? GetRelationship(string navigationProperty) + => _relationships.TryGetValue(navigationProperty, out var r) ? r : null; + + public IEnumerable Properties => _properties.Values; + public IEnumerable Relationships => _relationships.Values; + + // --- Backward compatibility with existing IEntityMapping interface --- + + public string GetColumnName(string propertyName) + { + if (_properties.TryGetValue(propertyName, out var p)) + return p.ColumnName; + return propertyName; + } + + public string? GetPropertyName(string columnName) + { + var prop = _properties.Values.FirstOrDefault(p => p.ColumnName.Equals(columnName, StringComparison.OrdinalIgnoreCase)); + return prop?.PropertyName; + } + + public IEnumerable GetProperties() => _properties.Keys; + + public JoinInfo? GetJoinInfo(string navigationProperty) + { + // JoinInfo is a legacy structure, we construct it dynamically if needed by the translators. + if (_relationships.TryGetValue(navigationProperty, out var rel)) + { + // The actual join condition is usually built during translation based on the relationship type. + // But for backward compatibility with existing tests/translators, we can generate a basic condition. + string joinCondition = rel.RelationshipType switch + { + RelationshipType.OneToMany => $"{TableName}.Id = {rel.TargetType.Name}s.{rel.ForeignKey}", + RelationshipType.ManyToOne => $"{TableName}.{rel.ForeignKey} = {rel.TargetType.Name}s.{rel.PrincipalKey}", + _ => string.Empty + }; + + return new JoinInfo + { + NavigationProperty = rel.NavigationPropertyName, + TargetType = rel.TargetType, + // Ideally, TableName for target should be retrieved from the MappingRegistry, + // but we might not have it here. The translator will need to fetch it. + // We provide a fallback for now. + TableName = rel.TargetType.Name + "s", + JoinCondition = joinCondition + }; + } + return null; + } +} diff --git a/src/FlexQuery.NET.Dapper/Mapping/Metadata/PropertyMapping.cs b/src/FlexQuery.NET.Dapper/Mapping/Metadata/PropertyMapping.cs new file mode 100644 index 0000000..f40676e --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Mapping/Metadata/PropertyMapping.cs @@ -0,0 +1,20 @@ +using System.Reflection; + +namespace FlexQuery.NET.Dapper.Mapping.Metadata; + +/// +/// Configuration metadata for an entity property. +/// +public sealed class PropertyMapping +{ + public PropertyInfo PropertyInfo { get; } + public string PropertyName => PropertyInfo.Name; + public string ColumnName { get; set; } + public bool IsPrimaryKey { get; set; } + + public PropertyMapping(PropertyInfo propertyInfo, string columnName) + { + PropertyInfo = propertyInfo; + ColumnName = columnName; + } +} diff --git a/src/FlexQuery.NET.Dapper/Mapping/Metadata/RelationshipMapping.cs b/src/FlexQuery.NET.Dapper/Mapping/Metadata/RelationshipMapping.cs new file mode 100644 index 0000000..c9d5b6a --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Mapping/Metadata/RelationshipMapping.cs @@ -0,0 +1,49 @@ +using System.Reflection; + +namespace FlexQuery.NET.Dapper.Mapping.Metadata; + +/// +/// Configuration metadata for a relationship/navigation property. +/// +public sealed class RelationshipMapping +{ + public PropertyInfo NavigationProperty { get; } + public string NavigationPropertyName => NavigationProperty.Name; + + public Type TargetType { get; set; } + + public RelationshipType RelationshipType { get; set; } + + /// + /// The foreign key column or property name. + /// + public string ForeignKey { get; set; } + + /// + /// The principal key column or property name on the target/principal entity. + /// + public string PrincipalKey { get; set; } = "Id"; + + /// + /// For many-to-many relationships, the join table name. + /// + public string? JoinTable { get; set; } + + /// + /// For many-to-many relationships, the FK to the current entity. + /// + public string? JoinTableForeignKey { get; set; } + + /// + /// For many-to-many relationships, the FK to the target entity. + /// + public string? JoinTableTargetKey { get; set; } + + public RelationshipMapping(PropertyInfo navigationProperty, Type targetType, RelationshipType type) + { + NavigationProperty = navigationProperty; + TargetType = targetType; + RelationshipType = type; + ForeignKey = string.Empty; // To be resolved by conventions + } +} diff --git a/src/FlexQuery.NET.Dapper/Mapping/Metadata/RelationshipType.cs b/src/FlexQuery.NET.Dapper/Mapping/Metadata/RelationshipType.cs new file mode 100644 index 0000000..559786b --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Mapping/Metadata/RelationshipType.cs @@ -0,0 +1,12 @@ +namespace FlexQuery.NET.Dapper.Mapping.Metadata; + +/// +/// Defines the type of relationship between two entities. +/// +public enum RelationshipType +{ + OneToOne, + OneToMany, + ManyToOne, + ManyToMany +} diff --git a/src/FlexQuery.NET.Dapper/Mapping/PropertyBuilder.cs b/src/FlexQuery.NET.Dapper/Mapping/PropertyBuilder.cs deleted file mode 100644 index bfadb72..0000000 --- a/src/FlexQuery.NET.Dapper/Mapping/PropertyBuilder.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace FlexQuery.NET.Dapper.Mapping; - -/// -/// Builder for configuring a property mapping. -/// -/// The entity type. -public sealed class PropertyBuilder -{ - private readonly EntityMapping _mapping; - private readonly string _propertyName; - - internal PropertyBuilder(EntityMapping mapping, string propertyName) - { - _mapping = mapping; - _propertyName = propertyName; - } - - /// Specifies the database column name. - public PropertyBuilder HasColumn(string columnName) - { - _mapping.MapProperty(_propertyName, columnName); - return this; - } -} diff --git a/src/FlexQuery.NET.Dapper/SQL/Translators/SqlCountTranslator.cs b/src/FlexQuery.NET.Dapper/SQL/Translators/SqlCountTranslator.cs index 4028394..2721c2d 100644 --- a/src/FlexQuery.NET.Dapper/SQL/Translators/SqlCountTranslator.cs +++ b/src/FlexQuery.NET.Dapper/SQL/Translators/SqlCountTranslator.cs @@ -19,22 +19,31 @@ public SqlCountTranslator(ISqlDialect dialect) /// /// Translates a count condition into a correlated COUNT subquery. /// - public string Translate(CountExpressionNode node, IEntityMapping mapping, Func filterBuilder, Dictionary parameters, Func paramNameGenerator) + public string Translate( + CountExpressionNode node, + IEntityMapping mapping, + Func filterBuilder, + Dictionary parameters, + Func paramNameGenerator, + IMappingRegistry registry) { - var joinInfo = mapping.GetJoinInfo(node.NavigationProperty); - if (joinInfo == null) return string.Empty; + var rel = mapping.GetRelationship(node.NavigationProperty); + if (rel == null) return string.Empty; + + var targetMapping = registry.GetMapping(rel.TargetType); + string joinCondition = BuildJoinCondition(mapping, targetMapping, rel, targetMapping.TableName); var subqueryFilter = filterBuilder(node.ScopedFilter); - var subqueryWhere = string.IsNullOrEmpty(subqueryFilter) - ? joinInfo.JoinCondition - : $"{joinInfo.JoinCondition} AND ({subqueryFilter})"; + var subqueryWhere = string.IsNullOrEmpty(subqueryFilter) + ? joinCondition + : $"{joinCondition} AND ({subqueryFilter})"; var paramName = paramNameGenerator(); if (int.TryParse(node.Value, out var countValue)) parameters[paramName] = countValue; else parameters[paramName] = node.Value; - + var sqlOp = FlexQuery.NET.Constants.FilterOperators.Normalize(node.Operator) switch { FlexQuery.NET.Constants.FilterOperators.Equal => "=", @@ -45,8 +54,18 @@ public string Translate(CountExpressionNode node, IEntityMapping mapping, Func "<=", _ => "=" }; - - // e.g. (SELECT COUNT(*) FROM orders WHERE users.Id = orders.UserId AND status = @p0) > @p1 - return $"(SELECT COUNT(*) FROM {_dialect.QuoteIdentifier(joinInfo.TableName)} WHERE {subqueryWhere}) {sqlOp} {paramName}"; + + return $"(SELECT COUNT(*) FROM {_dialect.QuoteIdentifier(targetMapping.TableName)} WHERE {subqueryWhere}) {sqlOp} {paramName}"; + } + + private string BuildJoinCondition(IEntityMapping source, IEntityMapping target, Mapping.Metadata.RelationshipMapping rel, string targetAlias) + { + string alias = _dialect.QuoteIdentifier(targetAlias); + return rel.RelationshipType switch + { + Mapping.Metadata.RelationshipType.OneToMany => $"{alias}.{_dialect.QuoteIdentifier(rel.ForeignKey)} = {_dialect.QuoteIdentifier(source.TableAlias ?? source.TableName)}.{_dialect.QuoteIdentifier(source.GetColumnName(rel.PrincipalKey ?? "Id"))}", + Mapping.Metadata.RelationshipType.ManyToOne => $"{_dialect.QuoteIdentifier(source.TableAlias ?? source.TableName)}.{_dialect.QuoteIdentifier(rel.ForeignKey)} = {alias}.{_dialect.QuoteIdentifier(target.GetColumnName(rel.PrincipalKey ?? "Id"))}", + _ => "1=0" + }; } } diff --git a/src/FlexQuery.NET.Dapper/SQL/Translators/SqlExistsTranslator.cs b/src/FlexQuery.NET.Dapper/SQL/Translators/SqlExistsTranslator.cs index ee32547..629ff4d 100644 --- a/src/FlexQuery.NET.Dapper/SQL/Translators/SqlExistsTranslator.cs +++ b/src/FlexQuery.NET.Dapper/SQL/Translators/SqlExistsTranslator.cs @@ -1,6 +1,7 @@ using FlexQuery.NET.Dapper.Sql.Ast; using FlexQuery.NET.Dapper.Dialects; using FlexQuery.NET.Dapper.Mapping; +using FlexQuery.NET.Dapper.Mapping.Metadata; namespace FlexQuery.NET.Dapper.Sql.Translators; @@ -19,34 +20,49 @@ public SqlExistsTranslator(ISqlDialect dialect) /// /// Translates an ANY condition into an EXISTS subquery. /// - public string TranslateAny(AnyExpressionNode node, IEntityMapping mapping, Func filterBuilder) + public string TranslateAny(AnyExpressionNode node, IEntityMapping mapping, Func filterBuilder, IMappingRegistry registry) { - var joinInfo = mapping.GetJoinInfo(node.NavigationProperty); - if (joinInfo == null) return string.Empty; + var rel = mapping.GetRelationship(node.NavigationProperty); + if (rel == null) return string.Empty; + + var targetMapping = registry.GetMapping(rel.TargetType); + string joinCondition = BuildJoinCondition(mapping, targetMapping, rel, targetMapping.TableName); var subqueryFilter = filterBuilder(node.ScopedFilter); var subqueryWhere = string.IsNullOrEmpty(subqueryFilter) - ? joinInfo.JoinCondition - : $"{joinInfo.JoinCondition} AND ({subqueryFilter})"; + ? joinCondition + : $"{joinCondition} AND ({subqueryFilter})"; - // e.g. EXISTS (SELECT 1 FROM orders WHERE users.Id = orders.UserId AND orders.id = @p0) - return $"EXISTS (SELECT 1 FROM {_dialect.QuoteIdentifier(joinInfo.TableName)} WHERE {subqueryWhere})"; + return $"EXISTS (SELECT 1 FROM {_dialect.QuoteIdentifier(targetMapping.TableName)} WHERE {subqueryWhere})"; } /// /// Translates an ALL condition into a NOT EXISTS subquery. /// - public string TranslateAll(AllExpressionNode node, IEntityMapping mapping, Func filterBuilder) + public string TranslateAll(AllExpressionNode node, IEntityMapping mapping, Func filterBuilder, IMappingRegistry registry) { - var joinInfo = mapping.GetJoinInfo(node.NavigationProperty); - if (joinInfo == null) return string.Empty; + var rel = mapping.GetRelationship(node.NavigationProperty); + if (rel == null) return string.Empty; + + var targetMapping = registry.GetMapping(rel.TargetType); + string joinCondition = BuildJoinCondition(mapping, targetMapping, rel, targetMapping.TableName); var subqueryFilter = filterBuilder(node.ScopedFilter); var subqueryWhere = string.IsNullOrEmpty(subqueryFilter) - ? $"{joinInfo.JoinCondition} AND NOT (1=1)" - : $"{joinInfo.JoinCondition} AND NOT ({subqueryFilter})"; + ? $"{joinCondition} AND NOT (1=1)" + : $"{joinCondition} AND NOT ({subqueryFilter})"; - // e.g. NOT EXISTS (SELECT 1 FROM orders WHERE users.Id = orders.UserId AND NOT (orders.status = @p0)) - return $"NOT EXISTS (SELECT 1 FROM {_dialect.QuoteIdentifier(joinInfo.TableName)} WHERE {subqueryWhere})"; + return $"NOT EXISTS (SELECT 1 FROM {_dialect.QuoteIdentifier(targetMapping.TableName)} WHERE {subqueryWhere})"; + } + + private string BuildJoinCondition(IEntityMapping source, IEntityMapping target, RelationshipMapping rel, string targetAlias) + { + string alias = _dialect.QuoteIdentifier(targetAlias); + return rel.RelationshipType switch + { + RelationshipType.OneToMany => $"{alias}.{_dialect.QuoteIdentifier(rel.ForeignKey)} = {_dialect.QuoteIdentifier(source.TableAlias ?? source.TableName)}.{_dialect.QuoteIdentifier(source.GetColumnName(rel.PrincipalKey ?? "Id"))}", + RelationshipType.ManyToOne => $"{_dialect.QuoteIdentifier(source.TableAlias ?? source.TableName)}.{_dialect.QuoteIdentifier(rel.ForeignKey)} = {alias}.{_dialect.QuoteIdentifier(target.GetColumnName(rel.PrincipalKey ?? "Id"))}", + _ => "1=0" + }; } } diff --git a/src/FlexQuery.NET.Dapper/SQL/Translators/SqlIncludeTranslator.cs b/src/FlexQuery.NET.Dapper/SQL/Translators/SqlIncludeTranslator.cs index 37c2eba..f1ebb05 100644 --- a/src/FlexQuery.NET.Dapper/SQL/Translators/SqlIncludeTranslator.cs +++ b/src/FlexQuery.NET.Dapper/SQL/Translators/SqlIncludeTranslator.cs @@ -1,6 +1,7 @@ using FlexQuery.NET.Dapper.Sql.Ast; using FlexQuery.NET.Dapper.Dialects; using FlexQuery.NET.Dapper.Mapping; +using FlexQuery.NET.Dapper.Mapping.Metadata; namespace FlexQuery.NET.Dapper.Sql.Translators; @@ -19,13 +20,22 @@ public SqlIncludeTranslator(ISqlDialect dialect) /// /// Translates an include node into a LEFT JOIN clause with optional filter. /// - public string Translate(IncludeNode node, IEntityMapping mapping, Func filterBuilder) + public string Translate(IncludeNode node, IEntityMapping mapping, Func filterBuilder, IMappingRegistry registry) { - var joinInfo = mapping.GetJoinInfo(node.NavigationProperty); - if (joinInfo == null) return string.Empty; + var rel = mapping.GetRelationship(node.NavigationProperty); + if (rel == null) return string.Empty; - var alias = _dialect.QuoteIdentifier(joinInfo.NavigationProperty); - var sql = $"LEFT JOIN {_dialect.QuoteIdentifier(joinInfo.TableName)} AS {alias} ON {joinInfo.JoinCondition}"; + var targetMapping = registry.GetMapping(rel.TargetType); + var alias = _dialect.QuoteIdentifier(rel.NavigationPropertyName); + + string joinCondition = rel.RelationshipType switch + { + RelationshipType.OneToMany => $"{alias}.{_dialect.QuoteIdentifier(rel.ForeignKey)} = {_dialect.QuoteIdentifier(mapping.TableAlias ?? mapping.TableName)}.{_dialect.QuoteIdentifier(mapping.GetColumnName(rel.PrincipalKey ?? "Id"))}", + RelationshipType.ManyToOne => $"{_dialect.QuoteIdentifier(mapping.TableAlias ?? mapping.TableName)}.{_dialect.QuoteIdentifier(rel.ForeignKey)} = {alias}.{_dialect.QuoteIdentifier(targetMapping.GetColumnName(rel.PrincipalKey ?? "Id"))}", + _ => "1=0" + }; + + var sql = $"LEFT JOIN {_dialect.QuoteIdentifier(targetMapping.TableName)} AS {alias} ON {joinCondition}"; if (node.Filter != null) { diff --git a/src/FlexQuery.NET.Dapper/SQL/Translators/SqlTranslator.cs b/src/FlexQuery.NET.Dapper/SQL/Translators/SqlTranslator.cs index 221cb08..37c62f4 100644 --- a/src/FlexQuery.NET.Dapper/SQL/Translators/SqlTranslator.cs +++ b/src/FlexQuery.NET.Dapper/SQL/Translators/SqlTranslator.cs @@ -136,18 +136,18 @@ private string BuildSelectClause(QueryOptions options, IEntityMapping mapping, s { foreach (var include in options.Includes) { - var joinInfo = mapping.GetJoinInfo(include); - if (joinInfo != null) + var rel = mapping.GetRelationship(include); + if (rel != null) { - var targetMapping = _mappingRegistry.GetMapping(joinInfo.TargetType); - var targetAlias = joinInfo.NavigationProperty; // Use mapped property name for alias + var targetMapping = _mappingRegistry.GetMapping(rel.TargetType); + var targetAlias = rel.NavigationPropertyName; // Use mapped property name for alias foreach (var prop in targetMapping.GetProperties()) { var col = targetMapping.GetColumnName(prop); // Prefix with mapped navigation property name for hydration var quotedAlias = _dialect.QuoteIdentifier(targetAlias); var quotedCol = _dialect.QuoteIdentifier(col); - var aliasForHydration = _dialect.QuoteIdentifier(joinInfo.NavigationProperty + "_" + col); + var aliasForHydration = _dialect.QuoteIdentifier(rel.NavigationPropertyName + "_" + col); selectParts.Add($"{quotedAlias}.{quotedCol} AS {aliasForHydration}"); } } @@ -169,20 +169,20 @@ private string BuildJoinClause(QueryOptions options, IEntityMapping mapping, Dic { if (!joinedPaths.Add(filteredInclude.Path)) continue; - var joinInfo = mapping.GetJoinInfo(filteredInclude.Path); - if (joinInfo == null) continue; + var rel = mapping.GetRelationship(filteredInclude.Path); + if (rel == null) continue; var node = new FlexQuery.NET.Dapper.Sql.Ast.IncludeNode { - NavigationProperty = joinInfo.NavigationProperty, + NavigationProperty = rel.NavigationPropertyName, Filter = filteredInclude.Filter }; var sql = _includeTranslator.Translate(node, mapping, filterGroup => { - var targetMapping = _mappingRegistry.GetMapping(joinInfo.TargetType!); + var targetMapping = _mappingRegistry.GetMapping(rel.TargetType!); return BuildFilterGroupExpression(filterGroup, targetMapping, parameters); - }); + }, _mappingRegistry); if (!string.IsNullOrEmpty(sql)) joins.Add(sql); } @@ -196,7 +196,7 @@ private string BuildJoinClause(QueryOptions options, IEntityMapping mapping, Dic if (!joinedPaths.Add(include)) continue; var node = new FlexQuery.NET.Dapper.Sql.Ast.IncludeNode { NavigationProperty = include }; - var sql = _includeTranslator.Translate(node, mapping, _ => string.Empty); + var sql = _includeTranslator.Translate(node, mapping, _ => string.Empty, _mappingRegistry); if (!string.IsNullOrEmpty(sql)) joins.Add(sql); } } @@ -253,10 +253,10 @@ private string BuildConditionExpression(FilterCondition condition, IEntityMappin var node = new AnyExpressionNode { NavigationProperty = condition.Field, ScopedFilter = condition.ScopedFilter }; return _existsTranslator.TranslateAny(node, mapping, group => { - var joinInfo = mapping.GetJoinInfo(condition.Field); - var targetMapping = joinInfo?.TargetType != null ? _mappingRegistry.GetMapping(joinInfo.TargetType) : mapping; + var rel = mapping.GetRelationship(condition.Field); + var targetMapping = rel?.TargetType != null ? _mappingRegistry.GetMapping(rel.TargetType) : mapping; return BuildFilterGroupExpression(group, targetMapping, parameters); - }); + }, _mappingRegistry); } if (op == FilterOperators.All && condition.ScopedFilter != null) @@ -264,10 +264,10 @@ private string BuildConditionExpression(FilterCondition condition, IEntityMappin var node = new AllExpressionNode { NavigationProperty = condition.Field, ScopedFilter = condition.ScopedFilter }; return _existsTranslator.TranslateAll(node, mapping, group => { - var joinInfo = mapping.GetJoinInfo(condition.Field); - var targetMapping = joinInfo?.TargetType != null ? _mappingRegistry.GetMapping(joinInfo.TargetType) : mapping; + var rel = mapping.GetRelationship(condition.Field); + var targetMapping = rel?.TargetType != null ? _mappingRegistry.GetMapping(rel.TargetType) : mapping; return BuildFilterGroupExpression(group, targetMapping, parameters); - }); + }, _mappingRegistry); } if (op == FilterOperators.Count) @@ -287,10 +287,10 @@ private string BuildConditionExpression(FilterCondition condition, IEntityMappin return _countTranslator.Translate(node, mapping, group => { - var joinInfo = mapping.GetJoinInfo(condition.Field); - var targetMapping = joinInfo?.TargetType != null ? _mappingRegistry.GetMapping(joinInfo.TargetType) : mapping; + var rel = mapping.GetRelationship(condition.Field); + var targetMapping = rel?.TargetType != null ? _mappingRegistry.GetMapping(rel.TargetType) : mapping; return BuildFilterGroupExpression(group, targetMapping, parameters); - }, parameters, NextParam); + }, parameters, NextParam, _mappingRegistry); } var column = mapping.GetColumnName(condition.Field); diff --git a/tests/FlexQuery.NET.Tests/Dapper/Dialects/DialectTests.cs b/tests/FlexQuery.NET.Tests/Dapper/Dialects/DialectTests.cs index 1267cd8..ee3744d 100644 --- a/tests/FlexQuery.NET.Tests/Dapper/Dialects/DialectTests.cs +++ b/tests/FlexQuery.NET.Tests/Dapper/Dialects/DialectTests.cs @@ -3,6 +3,7 @@ using FlexQuery.NET.Dapper.Sql; using FlexQuery.NET.Dapper.Sql.Translators; using FlexQuery.NET.Dapper.Dialects; +using FlexQuery.NET.Dapper.Mapping.Metadata; using FluentAssertions; namespace FlexQuery.NET.Tests.Dapper.Dialects; @@ -763,9 +764,10 @@ public void All_Dialects_Generate_Having_Clause() [Fact] public void All_Dialects_Generate_Join_Clause() { - var entityWithJoin = new EntityMapping(typeof(TestEntityWithJoin), "users", null); - entityWithJoin.MapJoin("Roles", typeof(object), "roles", "users.Id = roles.UserId"); - ((MappingRegistry)_registry).Register(entityWithJoin); + _registry.Entity() + .ToTable("users") + .HasMany(e => e.Roles) + .WithForeignKey("UserId"); var options = new QueryOptions { @@ -1049,9 +1051,10 @@ private static QueryOptions CreateHavingOptions() private static QueryOptions CreateJoinOptions() { var registry = new MappingRegistry(); - var entityWithJoin = new EntityMapping(typeof(TestEntityWithJoin), "users", null); - entityWithJoin.MapJoin("Roles", typeof(object), "roles", "users.Id = roles.UserId"); - ((MappingRegistry)registry).Register(entityWithJoin); + registry.Entity() + .ToTable("users") + .HasMany(e => e.Roles) + .WithForeignKey("UserId"); var options = new QueryOptions { @@ -1074,9 +1077,12 @@ private class TestEntity public string Status { get; set; } = string.Empty; } + private class TestRole { public int Id { get; set; } } + private class TestEntityWithJoin { public int Id { get; set; } public string Name { get; set; } = string.Empty; + public ICollection Roles { get; set; } = new List(); } } diff --git a/tests/FlexQuery.NET.Tests/Dapper/Security/SqlInjectionTests.cs b/tests/FlexQuery.NET.Tests/Dapper/Security/SqlInjectionTests.cs index 8228f80..1d973c1 100644 --- a/tests/FlexQuery.NET.Tests/Dapper/Security/SqlInjectionTests.cs +++ b/tests/FlexQuery.NET.Tests/Dapper/Security/SqlInjectionTests.cs @@ -307,7 +307,7 @@ public void Should_Quote_Navigation_Property_Names() var command = _translator.Translate(options); - command.Sql.Should().Contain("[tests]"); // Main table (default name from TestEntity) + command.Sql.Should().Contain("[TestEntities]"); // Convention-based table name from TestEntity } [Fact] diff --git a/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlTranslatorTests.cs b/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlTranslatorTests.cs index abff7c2..ca822c8 100644 --- a/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlTranslatorTests.cs +++ b/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlTranslatorTests.cs @@ -3,6 +3,7 @@ using FlexQuery.NET.Dapper.Sql; using FlexQuery.NET.Dapper.Sql.Translators; using FlexQuery.NET.Dapper.Dialects; +using FlexQuery.NET.Dapper.Mapping.Metadata; using FluentAssertions; namespace FlexQuery.NET.Tests.Dapper.Translation; @@ -13,9 +14,8 @@ public class SqlTranslatorTests public SqlTranslatorTests() { - var entityWithJoin = new EntityMapping(typeof(TestEntityWithJoin), "users", null); - entityWithJoin.MapJoin("Roles", typeof(object), "roles", "users.Id = roles.UserId"); - ((MappingRegistry)_registry).Register(entityWithJoin); + _registry.Entity().ToTable("roles"); + _registry.Entity().ToTable("users").HasMany(e => e.Roles).WithForeignKey("UserId"); } private static QueryOptions NoPaging(QueryOptions options) @@ -326,7 +326,7 @@ public void Translate_AnyOperator_GeneratesExistsSubquery() command.Sql.Should().Contain("EXISTS"); command.Sql.Should().Contain("SELECT 1 FROM [roles]"); - command.Sql.Should().Contain("users.Id = roles.UserId"); + command.Sql.Should().Contain("[roles].[UserId] = [users].[Id]"); command.Sql.Should().Contain("[Name] = @p0"); command.Parameters["@p0"].Should().Be("Admin"); } @@ -384,7 +384,7 @@ public void Translate_CountOperator_GeneratesCorrelatedCountSubquery() var command = translator.Translate(options); command.Sql.Should().Contain("(SELECT COUNT(*) FROM [roles]"); - command.Sql.Should().Contain("users.Id = roles.UserId"); + command.Sql.Should().Contain("[roles].[UserId] = [users].[Id]"); command.Sql.Should().Contain("> @p1"); command.Parameters["@p1"].Should().Be(5); } @@ -412,7 +412,7 @@ public void Translate_FilteredInclude_GeneratesJoinWithFilter() var command = translator.Translate(options); command.Sql.Should().Contain("LEFT JOIN [roles]"); - command.Sql.Should().Contain("users.Id = roles.UserId"); + command.Sql.Should().Contain("[Roles].[UserId] = [users].[Id]"); command.Sql.Should().Contain("AND ([IsActive] = @p0)"); } @@ -425,9 +425,12 @@ private class TestEntity public string Status { get; set; } = string.Empty; } + private class TestRole { public int Id { get; set; } } + private class TestEntityWithJoin { public int Id { get; set; } public string Name { get; set; } = string.Empty; + public ICollection Roles { get; set; } = new List(); } } diff --git a/tests/FlexQuery.NET.Tests/Fixtures/DemoApi.cs b/tests/FlexQuery.NET.Tests/Fixtures/DemoApi.cs index 4680c58..79484a1 100644 --- a/tests/FlexQuery.NET.Tests/Fixtures/DemoApi.cs +++ b/tests/FlexQuery.NET.Tests/Fixtures/DemoApi.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Mvc; using System.Data.Common; using FlexQuery.NET.Dapper.Mapping; +using FlexQuery.NET.Dapper.Mapping.Metadata; using Microsoft.Extensions.DependencyInjection; using System.Data; @@ -30,29 +31,17 @@ public void ConfigureServices(IServiceCollection services) var registry = new MappingRegistry(); - var customerMapping = new EntityMapping(typeof(SqlCustomer), "Customers"); - customerMapping.MapProperty("Id", "Id"); - customerMapping.MapProperty("Name", "Name"); - customerMapping.MapProperty("Email", "Email"); - customerMapping.MapJoin("Address", typeof(SqlAddress), "Addresses", "\"Customers\".\"Id\" = \"Address\".\"CustomerId\""); - customerMapping.MapJoin("Orders", typeof(SqlOrder), "Orders", "\"Customers\".\"Id\" = \"Orders\".\"CustomerId\""); - registry.Register(customerMapping); - - var orderMapping = new EntityMapping(typeof(SqlOrder), "Orders"); - orderMapping.MapProperty("Id", "Id"); - orderMapping.MapProperty("CustomerId", "CustomerId"); - orderMapping.MapProperty("OrderDate", "OrderDate"); - orderMapping.MapProperty("Total", "Total"); - orderMapping.MapJoin("Items", typeof(SqlOrderItem), "OrderItems", "\"Orders\".\"Id\" = \"OrderItems\".\"OrderId\""); - registry.Register(orderMapping); - - var orderItemMapping = new EntityMapping(typeof(SqlOrderItem), "OrderItems"); - orderItemMapping.MapProperty("Id", "Id"); - orderItemMapping.MapProperty("OrderId", "OrderId"); - orderItemMapping.MapProperty("ProductName", "ProductName"); - orderItemMapping.MapProperty("Quantity", "Quantity"); - orderItemMapping.MapProperty("UnitPrice", "UnitPrice"); - registry.Register(orderItemMapping); + registry.Entity() + .ToTable("Customers") + .HasOne(c => c.Address).WithForeignKey("CustomerId"); + registry.Entity().HasMany(c => c.Orders).WithForeignKey("CustomerId"); + + registry.Entity() + .ToTable("Orders") + .HasMany(o => o.Items).WithForeignKey("OrderId"); + + registry.Entity() + .ToTable("OrderItems"); services.AddSingleton(registry); } From 162555d0161e2d262d1cd6c5f29be64f7469bf6e Mon Sep 17 00:00:00 2001 From: Peter John Casasola Date: Fri, 15 May 2026 15:09:12 +0800 Subject: [PATCH 4/9] feat(mini-odata): add FlexQuery.NET.MiniOData optional compatibility package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create FlexQuery.NET.MiniOData as a separate, optional package with zero provider dependencies (references Core only). - Implement ODataTokenizer for OData filter expression lexing with support for string literals, number literals, identifiers, and structural characters. - Implement ODataFilterParser converting OData $filter syntax into the unified FlexQuery FilterGroup AST: 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). - Implement MiniODataQueryParser parsing all OData query parameters ($filter, $orderby, $select, $top, $skip, $expand, $count) into QueryOptions with automatic $-prefix stripping and slash-to-dot path conversion. - Add DI registration via services.AddFlexQueryMiniOData(). - Add 63 comprehensive tests: filter parser, query parser, and DSL↔OData AST equivalence tests proving semantic parity. - Update solution file to include MiniOData and Dapper projects. - Update CHANGELOG.md with MiniOData package details under v3.0.0. - All 428 tests passing (365 existing + 63 new). --- CHANGELOG.md | 13 + FlexQuery.NET.slnx | 2 + .../Extensions/ServiceCollectionExtensions.cs | 41 ++ .../FlexQuery.NET.MiniOData.csproj | 55 ++ .../Parsers/MiniODataParseException.cs | 13 + .../Parsers/MiniODataQueryParser.cs | 177 ++++++ .../Parsers/ODataFilterParser.cs | 509 ++++++++++++++++++ .../Parsers/ODataToken.cs | 48 ++ .../Parsers/ODataTokenizer.cs | 156 ++++++ .../FlexQuery.NET.Tests.csproj | 1 + .../MiniOData/MiniODataQueryParserTests.cs | 328 +++++++++++ .../MiniOData/ODataDslEquivalenceTests.cs | 295 ++++++++++ .../MiniOData/ODataFilterParserTests.cs | 337 ++++++++++++ 13 files changed, 1975 insertions(+) create mode 100644 src/FlexQuery.NET.MiniOData/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/FlexQuery.NET.MiniOData/FlexQuery.NET.MiniOData.csproj create mode 100644 src/FlexQuery.NET.MiniOData/Parsers/MiniODataParseException.cs create mode 100644 src/FlexQuery.NET.MiniOData/Parsers/MiniODataQueryParser.cs create mode 100644 src/FlexQuery.NET.MiniOData/Parsers/ODataFilterParser.cs create mode 100644 src/FlexQuery.NET.MiniOData/Parsers/ODataToken.cs create mode 100644 src/FlexQuery.NET.MiniOData/Parsers/ODataTokenizer.cs create mode 100644 tests/FlexQuery.NET.Tests/MiniOData/MiniODataQueryParserTests.cs create mode 100644 tests/FlexQuery.NET.Tests/MiniOData/ODataDslEquivalenceTests.cs create mode 100644 tests/FlexQuery.NET.Tests/MiniOData/ODataFilterParserTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index b88cb35..4341e9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,19 @@ All notable changes to this project will be documented in this file. - 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, 7, and 8. +- **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: 428 tests passing (365 existing + 63 new). ### Changed - **Mapping Registry Evolution**: Updated `JoinInfo` to support `TargetType`, enabling deep property resolution for related entity filters in Dapper. 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/src/FlexQuery.NET.MiniOData/Extensions/ServiceCollectionExtensions.cs b/src/FlexQuery.NET.MiniOData/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..8025d9c --- /dev/null +++ b/src/FlexQuery.NET.MiniOData/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,41 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace FlexQuery.NET.MiniOData.Extensions; + +/// +/// Extension methods for registering FlexQuery.NET Mini OData services. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Adds Mini OData query parsing support to the service collection. + /// + /// This registers the Mini OData query parser as an optional compatibility layer. + /// It does NOT replace the native FlexQuery DSL parser — both can coexist. + /// + /// + /// + /// services.AddFlexQuery() + /// .AddMiniOData(); + /// + /// + /// + /// The service collection to add Mini OData support to. + /// The same service collection for chaining. + public static IServiceCollection AddFlexQueryMiniOData(this IServiceCollection services) + { + // Register the Mini OData feature flag so middleware/controllers can detect it + services.AddSingleton(); + return services; + } +} + +/// +/// Marker service indicating Mini OData compatibility is enabled. +/// Injected by . +/// +public sealed class MiniODataFeature +{ + /// Whether Mini OData parsing is enabled. Always true when registered. + public bool IsEnabled => true; +} diff --git a/src/FlexQuery.NET.MiniOData/FlexQuery.NET.MiniOData.csproj b/src/FlexQuery.NET.MiniOData/FlexQuery.NET.MiniOData.csproj new file mode 100644 index 0000000..c17c943 --- /dev/null +++ b/src/FlexQuery.NET.MiniOData/FlexQuery.NET.MiniOData.csproj @@ -0,0 +1,55 @@ + + + + net6.0;net7.0;net8.0 + false + enable + enable + latest + true + + + FlexQuery.NET.MiniOData + Peter John Casasola + Peter John Casasola + FlexQuery.NET.MiniOData + + Lightweight OData-compatible query syntax adapter for FlexQuery.NET — translates $filter, $orderby, $select, $top, $skip, and $expand into the unified FlexQuery AST. + flexquery;odata;mini-odata;query;filter;adapter;compatibility + + 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.MiniOData/Parsers/MiniODataParseException.cs b/src/FlexQuery.NET.MiniOData/Parsers/MiniODataParseException.cs new file mode 100644 index 0000000..1ce06c8 --- /dev/null +++ b/src/FlexQuery.NET.MiniOData/Parsers/MiniODataParseException.cs @@ -0,0 +1,13 @@ +namespace FlexQuery.NET.MiniOData.Parsers; + +/// +/// Exception thrown when the Mini OData parser encounters invalid syntax. +/// +public sealed class MiniODataParseException : Exception +{ + /// Creates a new parse exception with the specified message. + public MiniODataParseException(string message) : base(message) { } + + /// Creates a new parse exception with the specified message and inner exception. + public MiniODataParseException(string message, Exception innerException) : base(message, innerException) { } +} diff --git a/src/FlexQuery.NET.MiniOData/Parsers/MiniODataQueryParser.cs b/src/FlexQuery.NET.MiniOData/Parsers/MiniODataQueryParser.cs new file mode 100644 index 0000000..36d72a3 --- /dev/null +++ b/src/FlexQuery.NET.MiniOData/Parsers/MiniODataQueryParser.cs @@ -0,0 +1,177 @@ +using FlexQuery.NET.Models; +using Microsoft.Extensions.Primitives; + +namespace FlexQuery.NET.MiniOData.Parsers; + +/// +/// Parses OData-compatible query parameters into a unified object. +/// +/// Supported OData query parameters: +/// +/// $filter — Filter expression (e.g., name eq 'john') +/// $orderby — Sort expression (e.g., createdAt desc) +/// $select — Projection fields (e.g., id,name,email) +/// $top — Page size (e.g., 10) +/// $skip — Skip count (e.g., 20) +/// $expand — Navigation includes (e.g., orders) +/// $count — Include total count (e.g., true) +/// +/// +/// +/// This is a lightweight OData-inspired parser. It does NOT implement the full OData protocol, +/// EDM metadata, batch requests, or delta tracking. +/// +/// +public static class MiniODataQueryParser +{ + /// + /// Parses OData-style query string parameters into a . + /// Accepts both $filter and filter key formats. + /// + /// Dictionary of query string parameter key-value pairs. + /// A populated from the OData-style parameters. + public static QueryOptions Parse(IDictionary queryParams) + { + ArgumentNullException.ThrowIfNull(queryParams); + + var options = new QueryOptions(); + var normalized = NormalizeKeys(queryParams); + + // $filter + if (normalized.TryGetValue("filter", out var filterValue) && !string.IsNullOrWhiteSpace(filterValue)) + { + options.Filter = ODataFilterParser.Parse(filterValue); + } + + // $orderby + if (normalized.TryGetValue("orderby", out var orderByValue) && !string.IsNullOrWhiteSpace(orderByValue)) + { + options.Sort = ParseOrderBy(orderByValue); + } + + // $select + if (normalized.TryGetValue("select", out var selectValue) && !string.IsNullOrWhiteSpace(selectValue)) + { + options.Select = ParseSelect(selectValue); + } + + // $top + if (normalized.TryGetValue("top", out var topValue) && int.TryParse(topValue, out var top)) + { + options.Paging.PageSize = top; + options.Top = top; + } + + // $skip + if (normalized.TryGetValue("skip", out var skipValue) && int.TryParse(skipValue, out var skip)) + { + options.Skip = skip; + // Convert skip + top to page number if top is available + if (options.Top.HasValue && options.Top.Value > 0) + { + options.Paging.Page = (skip / options.Top.Value) + 1; + } + } + + // $expand + if (normalized.TryGetValue("expand", out var expandValue) && !string.IsNullOrWhiteSpace(expandValue)) + { + options.Includes = ParseExpand(expandValue); + } + + // $count + if (normalized.TryGetValue("count", out var countValue)) + { + if (bool.TryParse(countValue, out var includeCount)) + { + options.IncludeCount = includeCount; + } + } + + return options; + } + + /// + /// Parses OData-style query string from (ASP.NET Core compatible). + /// + public static QueryOptions Parse(IDictionary queryParams) + { + ArgumentNullException.ThrowIfNull(queryParams); + + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var kv in queryParams) + { + var value = kv.Value.ToString(); + if (!string.IsNullOrEmpty(value)) + dict[kv.Key] = value; + } + + return Parse(dict); + } + + // ── OrderBy Parsing ───────────────────────────────────────────────── + + private static List ParseOrderBy(string orderBy) + { + var sorts = new List(); + + foreach (var segment in orderBy.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var parts = segment.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0) continue; + + var field = parts[0].Replace('/', '.'); // OData uses / for path separators + + var descending = parts.Length > 1 + && parts[1].Equals("desc", StringComparison.OrdinalIgnoreCase); + + sorts.Add(new SortNode + { + Field = field, + Descending = descending + }); + } + + return sorts; + } + + // ── Select Parsing ────────────────────────────────────────────────── + + private static List ParseSelect(string select) + { + return select + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(f => f.Replace('/', '.')) + .ToList(); + } + + // ── Expand Parsing ────────────────────────────────────────────────── + + private static List ParseExpand(string expand) + { + return expand + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(e => e.Replace('/', '.')) + .ToList(); + } + + // ── Key Normalization ─────────────────────────────────────────────── + + /// + /// Normalizes query parameter keys by stripping the $ prefix + /// and converting to lowercase for consistent lookup. + /// + private static Dictionary NormalizeKeys(IDictionary source) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var kv in source) + { + var key = kv.Key.TrimStart('$').Trim(); + if (!string.IsNullOrEmpty(key)) + result[key] = kv.Value; + } + + return result; + } +} diff --git a/src/FlexQuery.NET.MiniOData/Parsers/ODataFilterParser.cs b/src/FlexQuery.NET.MiniOData/Parsers/ODataFilterParser.cs new file mode 100644 index 0000000..a4491ad --- /dev/null +++ b/src/FlexQuery.NET.MiniOData/Parsers/ODataFilterParser.cs @@ -0,0 +1,509 @@ +using FlexQuery.NET.Constants; +using FlexQuery.NET.Models; + +namespace FlexQuery.NET.MiniOData.Parsers; + +/// +/// Parses OData-style $filter expressions into the unified AST. +/// +/// Supported syntax: +/// +/// Binary comparisons: name eq 'john', age gt 18 +/// Function calls: contains(name,'john'), startswith(name,'jo'), endswith(name,'hn') +/// Logical: and, or, not +/// Grouping: (status eq 'active' or status eq 'pending') +/// Lambda navigation: orders/any(o: o/status eq 'Cancelled') +/// +/// +/// +public sealed class ODataFilterParser +{ + private readonly IReadOnlyList _tokens; + private int _position; + + // OData comparison operators → FlexQuery canonical operators + private static readonly Dictionary ComparisonOperators = new(StringComparer.OrdinalIgnoreCase) + { + ["eq"] = FilterOperators.Equal, + ["ne"] = FilterOperators.NotEqual, + ["gt"] = FilterOperators.GreaterThan, + ["ge"] = FilterOperators.GreaterThanOrEq, + ["lt"] = FilterOperators.LessThan, + ["le"] = FilterOperators.LessThanOrEq, + }; + + // OData function names → FlexQuery operators + private static readonly HashSet FilterFunctions = new(StringComparer.OrdinalIgnoreCase) + { + "contains", "startswith", "endswith" + }; + + // OData lambda quantifiers + private static readonly HashSet LambdaQuantifiers = new(StringComparer.OrdinalIgnoreCase) + { + "any", "all" + }; + + /// Creates a parser over pre-tokenized OData filter input. + public ODataFilterParser(IReadOnlyList tokens) + { + _tokens = tokens; + } + + /// Tokenizes and parses an OData $filter string into a . + public static FilterGroup Parse(string filter) + { + if (string.IsNullOrWhiteSpace(filter)) + return new FilterGroup(); + + var tokens = new ODataTokenizer(filter).Tokenize(); + return new ODataFilterParser(tokens).ParseExpression(); + } + + /// Parses the token stream into a . + public FilterGroup ParseExpression() + { + var group = ParseOr(); + if (Current.Kind != ODataTokenKind.End && Current.Kind != ODataTokenKind.CloseParen) + { + throw new MiniODataParseException( + $"Unexpected token '{Current.Value}' at position {Current.Position}. Expected end of expression."); + } + return group; + } + + private FilterGroup ParseOr() + { + var left = ParseAnd(); + + while (IsKeyword("or")) + { + _position++; // consume 'or' + var right = ParseAnd(); + left = MergeGroups(LogicOperator.Or, left, right); + } + + return left; + } + + private FilterGroup ParseAnd() + { + var left = ParsePrimary(); + + while (IsKeyword("and")) + { + _position++; // consume 'and' + var right = ParsePrimary(); + left = MergeGroups(LogicOperator.And, left, right); + } + + return left; + } + + private FilterGroup ParsePrimary() + { + // NOT expression + if (IsKeyword("not")) + { + _position++; // consume 'not' + var inner = ParsePrimary(); + inner.IsNegated = !inner.IsNegated; + return inner; + } + + // Parenthesized expression + if (Current.Kind == ODataTokenKind.OpenParen) + { + _position++; // consume '(' + var inner = ParseOr(); + Expect(ODataTokenKind.CloseParen); + return inner; + } + + // Function call: contains(...), startswith(...), endswith(...) + if (Current.Kind == ODataTokenKind.Identifier && FilterFunctions.Contains(Current.Value)) + { + return ParseFunctionCall(); + } + + // Identifier — could be comparison, lambda navigation, or in/null check + if (Current.Kind == ODataTokenKind.Identifier) + { + return ParseComparisonOrLambda(); + } + + throw new MiniODataParseException( + $"Unexpected token '{Current.Value}' at position {Current.Position}."); + } + + private FilterGroup ParseFunctionCall() + { + var functionName = Current.Value.ToLowerInvariant(); + _position++; // consume function name + Expect(ODataTokenKind.OpenParen); + + var field = ParseFieldPath(); + Expect(ODataTokenKind.Comma); + var value = ParseLiteralValue(); + Expect(ODataTokenKind.CloseParen); + + var op = functionName switch + { + "contains" => FilterOperators.Contains, + "startswith" => FilterOperators.StartsWith, + "endswith" => FilterOperators.EndsWith, + _ => throw new MiniODataParseException($"Unsupported function '{functionName}'.") + }; + + return WrapCondition(new FilterCondition + { + Field = field, + Operator = op, + Value = value + }); + } + + private FilterGroup ParseComparisonOrLambda() + { + var fieldPath = ParseFieldPath(); + + // Check for lambda navigation: field/any(x: ...) or field/all(x: ...) + if (Current.Kind == ODataTokenKind.Slash) + { + _position++; // consume '/' + if (Current.Kind == ODataTokenKind.Identifier && LambdaQuantifiers.Contains(Current.Value)) + { + return ParseLambda(fieldPath); + } + + // It's a deeper path segment — append and continue + fieldPath += "." + ParseFieldPath(); + + if (Current.Kind == ODataTokenKind.Slash) + { + _position++; + if (Current.Kind == ODataTokenKind.Identifier && LambdaQuantifiers.Contains(Current.Value)) + { + return ParseLambda(fieldPath); + } + fieldPath += "." + ParseFieldPath(); + } + } + + // Null check operators + if (IsKeyword("eq") && PeekNextIsKeyword("null")) + { + _position++; // consume 'eq' + _position++; // consume 'null' + return WrapCondition(new FilterCondition + { + Field = fieldPath, + Operator = FilterOperators.IsNull + }); + } + + if (IsKeyword("ne") && PeekNextIsKeyword("null")) + { + _position++; // consume 'ne' + _position++; // consume 'null' + return WrapCondition(new FilterCondition + { + Field = fieldPath, + Operator = FilterOperators.IsNotNull + }); + } + + // IN operator: field in ('a','b','c') + if (IsKeyword("in")) + { + _position++; // consume 'in' + var values = ParseInList(); + return WrapCondition(new FilterCondition + { + Field = fieldPath, + Operator = FilterOperators.In, + Value = values + }); + } + + // Standard binary comparison: field op value + if (Current.Kind != ODataTokenKind.Identifier || !ComparisonOperators.ContainsKey(Current.Value)) + { + throw new MiniODataParseException( + $"Expected comparison operator at position {Current.Position}, but found '{Current.Value}'."); + } + + var flexOp = ComparisonOperators[Current.Value]; + _position++; // consume operator + var val = ParseLiteralValue(); + + return WrapCondition(new FilterCondition + { + Field = fieldPath, + Operator = flexOp, + Value = val + }); + } + + private FilterGroup ParseLambda(string navigationPath) + { + var quantifier = Current.Value.ToLowerInvariant(); + _position++; // consume 'any'/'all' + Expect(ODataTokenKind.OpenParen); + + FilterGroup? scopedFilter = null; + + // Check for empty lambda: any() / all() + if (Current.Kind != ODataTokenKind.CloseParen) + { + // Parse lambda variable: x: + string? lambdaVar = null; + if (Current.Kind == ODataTokenKind.Identifier) + { + var savedPos = _position; + var candidateVar = Current.Value; + _position++; + + if (Current.Kind == ODataTokenKind.Colon) + { + lambdaVar = candidateVar; + _position++; // consume ':' + } + else + { + // Not a lambda variable, revert + _position = savedPos; + } + } + + // Parse the inner expression + var innerTokens = CollectInnerTokens(); + + // Strip lambda variable prefix from field references + if (lambdaVar != null) + { + innerTokens = StripLambdaPrefix(innerTokens, lambdaVar); + } + + var innerParser = new ODataFilterParser(innerTokens); + scopedFilter = innerParser.ParseExpression(); + } + + Expect(ODataTokenKind.CloseParen); + + // Convert path separators: already using dots from ParseFieldPath + return WrapCondition(new FilterCondition + { + Field = navigationPath, + Operator = quantifier, + ScopedFilter = scopedFilter + }); + } + + private IReadOnlyList CollectInnerTokens() + { + var tokens = new List(); + int depth = 0; + + while (_position < _tokens.Count) + { + var token = _tokens[_position]; + + if (token.Kind == ODataTokenKind.OpenParen) + depth++; + else if (token.Kind == ODataTokenKind.CloseParen) + { + if (depth == 0) break; + depth--; + } + else if (token.Kind == ODataTokenKind.End) + break; + + tokens.Add(token); + _position++; + } + + tokens.Add(new ODataToken(ODataTokenKind.End, string.Empty, _position)); + return tokens; + } + + private static IReadOnlyList StripLambdaPrefix(IReadOnlyList tokens, string lambdaVar) + { + var result = new List(); + var prefix = lambdaVar + "/"; + + for (int i = 0; i < tokens.Count; i++) + { + var token = tokens[i]; + + // Check for pattern: lambdaVar / fieldName + if (token.Kind == ODataTokenKind.Identifier + && token.Value.Equals(lambdaVar, StringComparison.OrdinalIgnoreCase) + && i + 2 < tokens.Count + && tokens[i + 1].Kind == ODataTokenKind.Slash + && tokens[i + 2].Kind == ODataTokenKind.Identifier) + { + // Skip lambdaVar and slash, keep field + i++; // skip slash on next iteration + continue; + } + + // Skip the slash that follows a stripped lambda var + if (token.Kind == ODataTokenKind.Slash + && i > 0 + && result.Count > 0) + { + var prev = tokens[i - 1]; + if (prev.Kind == ODataTokenKind.Identifier + && prev.Value.Equals(lambdaVar, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + } + + result.Add(token); + } + + return result; + } + + private string ParseFieldPath() + { + var token = Expect(ODataTokenKind.Identifier); + var path = token.Value; + + // Handle slash-separated paths: convert to dot-notation + while (Current.Kind == ODataTokenKind.Slash + && _position + 1 < _tokens.Count + && _tokens[_position + 1].Kind == ODataTokenKind.Identifier + && !LambdaQuantifiers.Contains(_tokens[_position + 1].Value)) + { + _position++; // consume '/' + var next = Expect(ODataTokenKind.Identifier); + path += "." + next.Value; + } + + return path; + } + + private string ParseLiteralValue() + { + var token = Current; + + switch (token.Kind) + { + case ODataTokenKind.StringLiteral: + _position++; + return token.Value; + + case ODataTokenKind.NumberLiteral: + _position++; + return token.Value; + + case ODataTokenKind.Identifier when token.Value.Equals("true", StringComparison.OrdinalIgnoreCase): + _position++; + return "true"; + + case ODataTokenKind.Identifier when token.Value.Equals("false", StringComparison.OrdinalIgnoreCase): + _position++; + return "false"; + + case ODataTokenKind.Identifier when token.Value.Equals("null", StringComparison.OrdinalIgnoreCase): + _position++; + return "null"; + + default: + throw new MiniODataParseException( + $"Expected literal value at position {token.Position}, but found '{token.Value}'."); + } + } + + private string ParseInList() + { + Expect(ODataTokenKind.OpenParen); + var values = new List(); + + while (Current.Kind != ODataTokenKind.CloseParen && Current.Kind != ODataTokenKind.End) + { + values.Add(ParseLiteralValue()); + if (Current.Kind == ODataTokenKind.Comma) + _position++; // consume ',' + } + + Expect(ODataTokenKind.CloseParen); + return string.Join(",", values); + } + + // ── Helpers ────────────────────────────────────────────────────────── + + private static FilterGroup WrapCondition(FilterCondition condition) + { + return new FilterGroup + { + Logic = LogicOperator.And, + Filters = { condition } + }; + } + + private static FilterGroup MergeGroups(LogicOperator logic, FilterGroup left, FilterGroup right) + { + // Helper: check if a group is a simple single-condition wrapper + static bool IsSimpleWrapper(FilterGroup g) => + !g.IsNegated && g.Filters.Count <= 1 && g.Groups.Count == 0; + + // If left already uses the target logic and isn't negated, absorb right into it + if (left.Logic == logic && !left.IsNegated) + { + if (IsSimpleWrapper(right)) + { + left.Filters.AddRange(right.Filters); + } + else + { + left.Groups.Add(right); + } + return left; + } + + // Both are simple wrappers — create a new group at the target logic + if (IsSimpleWrapper(left) && IsSimpleWrapper(right)) + { + var merged = new FilterGroup { Logic = logic }; + merged.Filters.AddRange(left.Filters); + merged.Filters.AddRange(right.Filters); + return merged; + } + + // General case: wrap both as sub-groups + return new FilterGroup + { + Logic = logic, + Groups = { left, right } + }; + } + + private bool IsKeyword(string keyword) + { + return Current.Kind == ODataTokenKind.Identifier + && Current.Value.Equals(keyword, StringComparison.OrdinalIgnoreCase); + } + + private bool PeekNextIsKeyword(string keyword) + { + if (_position + 1 >= _tokens.Count) return false; + var next = _tokens[_position + 1]; + return next.Kind == ODataTokenKind.Identifier + && next.Value.Equals(keyword, StringComparison.OrdinalIgnoreCase); + } + + private ODataToken Expect(ODataTokenKind kind) + { + if (Current.Kind == kind) + return _tokens[_position++]; + + throw new MiniODataParseException( + $"Expected {kind} at position {Current.Position}, but found {Current.Kind} ('{Current.Value}')."); + } + + private ODataToken Current => _tokens[Math.Min(_position, _tokens.Count - 1)]; +} diff --git a/src/FlexQuery.NET.MiniOData/Parsers/ODataToken.cs b/src/FlexQuery.NET.MiniOData/Parsers/ODataToken.cs new file mode 100644 index 0000000..d739b5c --- /dev/null +++ b/src/FlexQuery.NET.MiniOData/Parsers/ODataToken.cs @@ -0,0 +1,48 @@ +namespace FlexQuery.NET.MiniOData.Parsers; + +/// Token kinds produced by the OData tokenizer. +public enum ODataTokenKind +{ + /// Alphanumeric identifier or keyword. + Identifier, + /// Single-quoted string literal. + StringLiteral, + /// Numeric literal (integer or decimal). + NumberLiteral, + /// Opening parenthesis. + OpenParen, + /// Closing parenthesis. + CloseParen, + /// Comma separator. + Comma, + /// Forward slash (path separator). + Slash, + /// Colon (lambda variable separator). + Colon, + /// End of input. + End +} + +/// A single token from OData filter expression tokenization. +public sealed class ODataToken +{ + /// Creates a new OData token. + public ODataToken(ODataTokenKind kind, string value, int position) + { + Kind = kind; + Value = value; + Position = position; + } + + /// Token classification. + public ODataTokenKind Kind { get; } + + /// Raw string value of the token. + public string Value { get; } + + /// Character position in the source string. + public int Position { get; } + + /// + public override string ToString() => $"[{Kind}] '{Value}' @{Position}"; +} diff --git a/src/FlexQuery.NET.MiniOData/Parsers/ODataTokenizer.cs b/src/FlexQuery.NET.MiniOData/Parsers/ODataTokenizer.cs new file mode 100644 index 0000000..2933385 --- /dev/null +++ b/src/FlexQuery.NET.MiniOData/Parsers/ODataTokenizer.cs @@ -0,0 +1,156 @@ +using System.Text; + +namespace FlexQuery.NET.MiniOData.Parsers; + +/// +/// Tokenizes OData filter expressions into a stream of instances. +/// Handles identifiers, string literals, number literals, parentheses, commas, slashes, and colons. +/// +public sealed class ODataTokenizer +{ + private readonly string _source; + private int _position; + + /// Creates a tokenizer for the supplied OData filter string. + public ODataTokenizer(string source) + { + _source = source ?? string.Empty; + } + + /// Tokenizes the full OData filter expression. + public IReadOnlyList Tokenize() + { + var tokens = new List(); + + while (_position < _source.Length) + { + SkipWhitespace(); + if (_position >= _source.Length) break; + + var current = _source[_position]; + var start = _position; + + switch (current) + { + case '(': + tokens.Add(new ODataToken(ODataTokenKind.OpenParen, "(", start)); + _position++; + break; + case ')': + tokens.Add(new ODataToken(ODataTokenKind.CloseParen, ")", start)); + _position++; + break; + case ',': + tokens.Add(new ODataToken(ODataTokenKind.Comma, ",", start)); + _position++; + break; + case '/': + tokens.Add(new ODataToken(ODataTokenKind.Slash, "/", start)); + _position++; + break; + case ':': + tokens.Add(new ODataToken(ODataTokenKind.Colon, ":", start)); + _position++; + break; + case '\'': + tokens.Add(ReadStringLiteral()); + break; + default: + if (char.IsDigit(current) || (current == '-' && _position + 1 < _source.Length && char.IsDigit(_source[_position + 1]))) + { + tokens.Add(ReadNumberLiteral()); + } + else if (char.IsLetter(current) || current == '_' || current == '$') + { + tokens.Add(ReadIdentifier()); + } + else + { + throw new MiniODataParseException( + $"Unexpected character '{current}' at position {_position}."); + } + break; + } + } + + tokens.Add(new ODataToken(ODataTokenKind.End, string.Empty, _position)); + return tokens; + } + + private void SkipWhitespace() + { + while (_position < _source.Length && char.IsWhiteSpace(_source[_position])) + _position++; + } + + private ODataToken ReadStringLiteral() + { + var start = _position; + _position++; // skip opening quote + var sb = new StringBuilder(); + + while (_position < _source.Length) + { + var current = _source[_position]; + + // OData escapes single quotes by doubling them: '' + if (current == '\'') + { + if (_position + 1 < _source.Length && _source[_position + 1] == '\'') + { + sb.Append('\''); + _position += 2; + continue; + } + + _position++; // skip closing quote + return new ODataToken(ODataTokenKind.StringLiteral, sb.ToString(), start); + } + + sb.Append(current); + _position++; + } + + throw new MiniODataParseException($"Unterminated string literal at position {start}."); + } + + private ODataToken ReadNumberLiteral() + { + var start = _position; + + if (_source[_position] == '-') _position++; + + while (_position < _source.Length && char.IsDigit(_source[_position])) + _position++; + + // Decimal part + if (_position < _source.Length && _source[_position] == '.') + { + _position++; + while (_position < _source.Length && char.IsDigit(_source[_position])) + _position++; + } + + return new ODataToken(ODataTokenKind.NumberLiteral, _source[start.._position], start); + } + + private ODataToken ReadIdentifier() + { + var start = _position; + + while (_position < _source.Length) + { + var c = _source[_position]; + if (char.IsLetterOrDigit(c) || c == '_' || c == '.' || c == '$') + { + _position++; + } + else + { + break; + } + } + + return new ODataToken(ODataTokenKind.Identifier, _source[start.._position], start); + } +} diff --git a/tests/FlexQuery.NET.Tests/FlexQuery.NET.Tests.csproj b/tests/FlexQuery.NET.Tests/FlexQuery.NET.Tests.csproj index 1f0846f..23d95b5 100644 --- a/tests/FlexQuery.NET.Tests/FlexQuery.NET.Tests.csproj +++ b/tests/FlexQuery.NET.Tests/FlexQuery.NET.Tests.csproj @@ -31,6 +31,7 @@ + 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); + } +} From 43e5e20e29176de145d33c788064020ffc534c97 Mon Sep 17 00:00:00 2001 From: Peter John Casasola Date: Mon, 18 May 2026 15:40:42 +0800 Subject: [PATCH 5/9] feat: implement mini odata parser and separate the logic of jql parser --- .../FlexQueryDapperExtensions.cs | 14 +- .../Extensions/ServiceCollectionExtensions.cs | 11 + .../Parsers/MiniODataParser.cs | 70 ++++ src/FlexQuery.NET/Caching/ParserCache.cs | 3 +- .../Models/FlexQueryParameters.cs | 6 + .../Parsers/FilteredIncludeParser.cs | 2 +- .../Parsers/FlexQueryDslParser.cs | 116 ++++++ src/FlexQuery.NET/Parsers/IQueryParser.cs | 52 +++ src/FlexQuery.NET/Parsers/JqlParser.cs | 70 ++++ .../Parsers/QueryOptionsParser.cs | 346 ++++++++++-------- .../MiniOData/MiniODataIntegrationTests.cs | 89 +++++ .../FlexQuery.NET.Tests/Tests/ParserTests.cs | 1 + 12 files changed, 616 insertions(+), 164 deletions(-) create mode 100644 src/FlexQuery.NET.MiniOData/Parsers/MiniODataParser.cs create mode 100644 src/FlexQuery.NET/Parsers/FlexQueryDslParser.cs create mode 100644 src/FlexQuery.NET/Parsers/IQueryParser.cs create mode 100644 src/FlexQuery.NET/Parsers/JqlParser.cs create mode 100644 tests/FlexQuery.NET.Tests/MiniOData/MiniODataIntegrationTests.cs diff --git a/src/FlexQuery.NET.Dapper/FlexQueryDapperExtensions.cs b/src/FlexQuery.NET.Dapper/FlexQueryDapperExtensions.cs index 38d2748..0c31538 100644 --- a/src/FlexQuery.NET.Dapper/FlexQueryDapperExtensions.cs +++ b/src/FlexQuery.NET.Dapper/FlexQueryDapperExtensions.cs @@ -64,13 +64,17 @@ public static async Task> FlexQueryAsync( 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 = parameters.TryGetValue("filter", out var filter) ? filter.ToString() : null, - Sort = parameters.TryGetValue("sort", out var sort) ? sort.ToString() : null, - Select = parameters.TryGetValue("select", out var select) ? select.ToString() : null, - Page = parameters.TryGetValue("page", out var p) && int.TryParse(p, out var page) ? page : null, - PageSize = parameters.TryGetValue("pageSize", out var ps) && int.TryParse(ps, out var pageSize) ? pageSize : null + 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); diff --git a/src/FlexQuery.NET.MiniOData/Extensions/ServiceCollectionExtensions.cs b/src/FlexQuery.NET.MiniOData/Extensions/ServiceCollectionExtensions.cs index 8025d9c..a1c1d9d 100644 --- a/src/FlexQuery.NET.MiniOData/Extensions/ServiceCollectionExtensions.cs +++ b/src/FlexQuery.NET.MiniOData/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using FlexQuery.NET.Parsers; namespace FlexQuery.NET.MiniOData.Extensions; @@ -26,8 +27,18 @@ public static IServiceCollection AddFlexQueryMiniOData(this IServiceCollection s { // Register the Mini OData feature flag so middleware/controllers can detect it services.AddSingleton(); + + // Register the parser in the central coordinator + QueryOptionsParser.RegisterParser(new Parsers.MiniODataParser()); + return services; } + + /// + /// Alias for for cleaner chaining. + /// + public static IServiceCollection AddMiniOData(this IServiceCollection services) + => services.AddFlexQueryMiniOData(); } /// diff --git a/src/FlexQuery.NET.MiniOData/Parsers/MiniODataParser.cs b/src/FlexQuery.NET.MiniOData/Parsers/MiniODataParser.cs new file mode 100644 index 0000000..504579b --- /dev/null +++ b/src/FlexQuery.NET.MiniOData/Parsers/MiniODataParser.cs @@ -0,0 +1,70 @@ +using FlexQuery.NET.Models; +using FlexQuery.NET.Parsers; + +namespace FlexQuery.NET.MiniOData.Parsers; + +/// +/// Implementation of for OData-compatible syntax. +/// +public sealed class MiniODataParser : IQueryParser +{ + /// + public QuerySyntax Syntax => QuerySyntax.MiniOData; + + /// + public bool CanParse(FlexQueryParameters parameters) + { + // Detect OData by checking for $ prefix in any raw parameter keys. + if (parameters.RawParameters != null) + { + foreach (var key in parameters.RawParameters.Keys) + { + if (key.StartsWith("$")) return true; + } + } + + // Also check if Filter string looks like OData (e.g., contains ' eq ') + // though this is less reliable than key detection. + if (!string.IsNullOrWhiteSpace(parameters.Filter)) + { + var f = parameters.Filter; + if (f.Contains(" eq ", StringComparison.OrdinalIgnoreCase) || + f.Contains(" ne ", StringComparison.OrdinalIgnoreCase) || + f.Contains("contains(", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + /// + public QueryOptions Parse(FlexQueryParameters parameters) + { + // If we have raw parameters (ideal for OData), use them. + if (parameters.RawParameters != null && parameters.RawParameters.Count > 0) + { + return MiniODataQueryParser.Parse(parameters.RawParameters); + } + + // Otherwise, map from FlexQueryParameters properties. + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (!string.IsNullOrWhiteSpace(parameters.Filter)) dict["filter"] = parameters.Filter; + if (!string.IsNullOrWhiteSpace(parameters.Sort)) dict["orderby"] = parameters.Sort; + if (!string.IsNullOrWhiteSpace(parameters.Select)) dict["select"] = parameters.Select; + if (!string.IsNullOrWhiteSpace(parameters.Include)) dict["expand"] = parameters.Include; + + if (parameters.PageSize.HasValue) dict["top"] = parameters.PageSize.Value.ToString(); + if (parameters.Page.HasValue && parameters.PageSize.HasValue) + { + var skip = (parameters.Page.Value - 1) * parameters.PageSize.Value; + dict["skip"] = skip.ToString(); + } + + if (parameters.IncludeCount.HasValue) dict["count"] = parameters.IncludeCount.Value.ToString().ToLowerInvariant(); + + return MiniODataQueryParser.Parse(dict); + } +} diff --git a/src/FlexQuery.NET/Caching/ParserCache.cs b/src/FlexQuery.NET/Caching/ParserCache.cs index f884e5d..7ad4b1a 100644 --- a/src/FlexQuery.NET/Caching/ParserCache.cs +++ b/src/FlexQuery.NET/Caching/ParserCache.cs @@ -66,5 +66,6 @@ public sealed record ParsedQueryCacheKey( bool? IncludeCount, bool? Distinct, string? Mode, - string Version = "v1" + string? RawKey = null, + string Version = "v2" ); diff --git a/src/FlexQuery.NET/Models/FlexQueryParameters.cs b/src/FlexQuery.NET/Models/FlexQueryParameters.cs index 1b14c54..88e25a1 100644 --- a/src/FlexQuery.NET/Models/FlexQueryParameters.cs +++ b/src/FlexQuery.NET/Models/FlexQueryParameters.cs @@ -45,4 +45,10 @@ public sealed class FlexQueryParameters /// The projection mode (Flat, FlatMixed, Nested). public string? Mode { get; set; } + + /// + /// Optional raw dictionary of query parameters. + /// Used by parsers for syntax auto-detection (e.g., detecting OData $ prefix). + /// + public IDictionary? RawParameters { get; set; } } diff --git a/src/FlexQuery.NET/Parsers/FilteredIncludeParser.cs b/src/FlexQuery.NET/Parsers/FilteredIncludeParser.cs index e2cf0da..ad530aa 100644 --- a/src/FlexQuery.NET/Parsers/FilteredIncludeParser.cs +++ b/src/FlexQuery.NET/Parsers/FilteredIncludeParser.cs @@ -136,7 +136,7 @@ public static List Parse(string? raw) catch { /* fallback to JQL */ } } - var jqlAst = JqlParser.Parse(raw); + var jqlAst = Jql.JqlParser.Parse(raw); return JqlFilterConverter.ToFilterGroup(jqlAst); } diff --git a/src/FlexQuery.NET/Parsers/FlexQueryDslParser.cs b/src/FlexQuery.NET/Parsers/FlexQueryDslParser.cs new file mode 100644 index 0000000..8af6d32 --- /dev/null +++ b/src/FlexQuery.NET/Parsers/FlexQueryDslParser.cs @@ -0,0 +1,116 @@ +using FlexQuery.NET.Models; +using FlexQuery.NET.Parsers.Dsl; +using FlexQuery.NET.Parsers.Jql; +using FlexQuery.NET.Builders; + +namespace FlexQuery.NET.Parsers; + +/// +/// Default implementation of that handles the native FlexQuery DSL. +/// +public sealed class FlexQueryDslParser : IQueryParser +{ + /// + public QuerySyntax Syntax => QuerySyntax.NativeDsl; + + /// + public bool CanParse(FlexQueryParameters parameters) + { + // Native DSL is the fallback, but we can specifically check for non-OData keys + // or just return true if it's not obviously OData. + if (parameters.RawParameters != null) + { + foreach (var key in parameters.RawParameters.Keys) + { + if (key.StartsWith("$")) return false; + } + } + return true; + } + + /// + public QueryOptions Parse(FlexQueryParameters parameters) + { + if (parameters.RawParameters != null && parameters.RawParameters.Count > 0) + { + return QueryOptionsParser.InternalParseDictionary(parameters.RawParameters); + } + + var options = new QueryOptions(); + + // Paging + options.Paging.Page = parameters.Page ?? 1; + options.Paging.PageSize = parameters.PageSize ?? 20; + + // Mode + if (!string.IsNullOrWhiteSpace(parameters.Mode)) + { + options.ProjectionMode = parameters.Mode.Trim().ToLowerInvariant() switch + { + "flat" => ProjectionMode.Flat, + "flat-mixed" => ProjectionMode.FlatMixed, + _ => ProjectionMode.Nested + }; + } + + // Select + if (!string.IsNullOrWhiteSpace(parameters.Select)) + { + // Note: We use the helper from QueryOptionsParser which we'll make internal/accessible + QueryOptionsParser.InternalParseSelectWithAggregates(options, parameters.Select); + } + + // Grouping + if (!string.IsNullOrWhiteSpace(parameters.GroupBy)) + { + options.GroupBy = QueryOptionsParser.InternalSplitCsv(parameters.GroupBy); + } + + // Having + if (!string.IsNullOrWhiteSpace(parameters.Having)) + { + options.Having = QueryOptionsParser.InternalParseHaving(parameters.Having); + } + + // Includes + if (!string.IsNullOrWhiteSpace(parameters.Include)) + { + options.Includes = QueryOptionsParser.InternalSplitCsv(parameters.Include.Split('(')[0]); + options.FilteredIncludes = FilteredIncludeParser.Parse(parameters.Include); + } + + // Metadata + options.IncludeCount = parameters.IncludeCount ?? true; + options.Distinct = parameters.Distinct ?? false; + + // Sorting + if (!string.IsNullOrWhiteSpace(parameters.Sort)) + { + options.Sort.AddRange(QueryOptionsParser.InternalParseSort(parameters.Sort)); + } + + // Filters + if (!string.IsNullOrWhiteSpace(parameters.Filter)) + { + var filterVal = parameters.Filter.TrimStart(); + if (filterVal.StartsWith('{')) + { + // JSON parsing logic remains in QueryOptionsParser for now or moved here + QueryOptionsParser.InternalParseJsonFilter(options, filterVal); + } + else + { + try + { + var ast = DslParser.Parse(filterVal); + options.Filter = DslFilterConverter.ToFilterGroup(ast); + options.Ast = ast; + options.Filter = FilterNormalizer.NormalizeOrder(options.Filter); + } + catch (DslParseException) { /* ignore invalid DSL */ } + } + } + + return options; + } +} diff --git a/src/FlexQuery.NET/Parsers/IQueryParser.cs b/src/FlexQuery.NET/Parsers/IQueryParser.cs new file mode 100644 index 0000000..a8c901b --- /dev/null +++ b/src/FlexQuery.NET/Parsers/IQueryParser.cs @@ -0,0 +1,52 @@ +using FlexQuery.NET.Models; + +namespace FlexQuery.NET.Parsers; + +/// +/// Defines the contract for parsing raw query parameters into a unified AST. +/// +public interface IQueryParser +{ + /// + /// The syntax type this parser handles. + /// + QuerySyntax Syntax { get; } + + /// + /// Determines if the provided parameters can be parsed by this parser. + /// Used for auto-detection. + /// + bool CanParse(FlexQueryParameters parameters); + + /// + /// Parses the raw parameters into a unified object. + /// + QueryOptions Parse(FlexQueryParameters parameters); +} + +/// +/// Specifies the query syntax to use when parsing requests. +/// +public enum QuerySyntax +{ + /// + /// Automatically detects the syntax based on the presence of specific query parameters + /// (e.g., OData parameters like $filter, $orderby). + /// + AutoDetect, + + /// + /// Uses the native FlexQuery DSL (e.g., filter=name:eq:john). + /// + NativeDsl, + + /// + /// Uses the Mini OData compatibility syntax (e.g., $filter=name eq 'john'). + /// + MiniOData, + + /// + /// Uses the legacy JQL syntax (e.g., query=name = "john"). + /// + Jql +} diff --git a/src/FlexQuery.NET/Parsers/JqlParser.cs b/src/FlexQuery.NET/Parsers/JqlParser.cs new file mode 100644 index 0000000..1b7ec2d --- /dev/null +++ b/src/FlexQuery.NET/Parsers/JqlParser.cs @@ -0,0 +1,70 @@ +using FlexQuery.NET.Models; +using FlexQuery.NET.Parsers.Jql; +using FlexQuery.NET.Builders; + +namespace FlexQuery.NET.Parsers; + +/// +/// Legacy implementation of that handles JQL-lite syntax. +/// +public sealed class JqlParser : IQueryParser +{ + /// + public QuerySyntax Syntax => QuerySyntax.Jql; + + /// + public bool CanParse(FlexQueryParameters parameters) + { + // JQL is detected by the presence of the 'Query' property (query=...) + return !string.IsNullOrWhiteSpace(parameters.Query); + } + + /// + public QueryOptions Parse(FlexQueryParameters parameters) + { + // Use the internal static helper from QueryOptionsParser for consistency. + // If RawParameters exist, we use the dictionary-based JQL parser. + if (parameters.RawParameters != null && parameters.RawParameters.Count > 0) + { + return QueryOptionsParser.InternalParseJql(parameters.RawParameters, parameters.Query!); + } + + // Fallback for manual DTO instantiation without RawParameters. + var options = new QueryOptions(); + + // Populate standard options from properties + options.Paging.Page = parameters.Page ?? 1; + options.Paging.PageSize = parameters.PageSize ?? 20; + options.IncludeCount = parameters.IncludeCount ?? true; + options.Distinct = parameters.Distinct ?? false; + + if (!string.IsNullOrWhiteSpace(parameters.Select)) + QueryOptionsParser.InternalParseSelectWithAggregates(options, parameters.Select); + + if (!string.IsNullOrWhiteSpace(parameters.Sort)) + options.Sort.AddRange(QueryOptionsParser.InternalParseSort(parameters.Sort)); + + if (!string.IsNullOrWhiteSpace(parameters.GroupBy)) + options.GroupBy = QueryOptionsParser.InternalSplitCsv(parameters.GroupBy); + + if (!string.IsNullOrWhiteSpace(parameters.Having)) + options.Having = QueryOptionsParser.InternalParseHaving(parameters.Having); + + if (!string.IsNullOrWhiteSpace(parameters.Include)) + { + options.Includes = QueryOptionsParser.InternalSplitCsv(parameters.Include.Split('(')[0]); + options.FilteredIncludes = FilteredIncludeParser.Parse(parameters.Include); + } + + // Parse JQL Query + if (!string.IsNullOrWhiteSpace(parameters.Query)) + { + var ast = Jql.JqlParser.Parse(parameters.Query); + options.Filter = JqlFilterConverter.ToFilterGroup(ast); + options.Ast = ast; + options.Filter = FilterNormalizer.NormalizeOrder(options.Filter); + } + + return options; + } +} diff --git a/src/FlexQuery.NET/Parsers/QueryOptionsParser.cs b/src/FlexQuery.NET/Parsers/QueryOptionsParser.cs index a748d57..4738479 100644 --- a/src/FlexQuery.NET/Parsers/QueryOptionsParser.cs +++ b/src/FlexQuery.NET/Parsers/QueryOptionsParser.cs @@ -18,8 +18,9 @@ namespace FlexQuery.NET.Parsers; /// /// Generic — filter[0].field / sort[0].field / page / pageSize / select /// JSON — filter={...json...} -/// DSL — filter=name:eq:john -/// JQL — query=name = "john" +/// DSL — filter=name:eq:john (Primary Syntax) +/// OData — $filter=name eq 'john' (Compatibility Syntax) +/// JQL — query=name = "john" (Legacy/Deprecated Syntax) /// /// public static class QueryOptionsParser @@ -36,6 +37,37 @@ public static class QueryOptionsParser @"^(?[A-Za-z_][A-Za-z0-9_\.]*)\.(?sum|count|max|min|avg)\((?[A-Za-z_][A-Za-z0-9_\.]*)?\)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly List _parsers = new() + { + new JqlParser(), + new FlexQueryDslParser() + }; + + static QueryOptionsParser() + { + try + { + // Try to dynamically discover and register MiniODataParser if its assembly is present in the AppDomain + var odataParserType = Type.GetType("FlexQuery.NET.MiniOData.Parsers.MiniODataParser, FlexQuery.NET.MiniOData"); + if (odataParserType != null) + { + var parser = (IQueryParser)Activator.CreateInstance(odataParserType)!; + RegisterParser(parser); + } + } + catch + { + // Ignore if the assembly is not loaded or available + } + } + + /// + /// Registers a new query parser implementation. + /// New parsers are given priority over existing ones. + /// + /// The parser to register. + public static void RegisterParser(IQueryParser parser) => _parsers.Insert(0, parser); + // ── Public entry point ─────────────────────────────────────────────── /// @@ -63,116 +95,69 @@ public static QueryOptions Parse(QueryRequest request) if (request.IncludeCount.HasValue) dict["includeCount"] = request.IncludeCount.Value.ToString(); if (request.Distinct.HasValue) dict["distinct"] = request.Distinct.Value.ToString(); - return ParseDictionary(dict); + var parameters = new FlexQueryParameters + { + Query = request.Query, + Filter = request.Filter, + Sort = request.Sort, + Select = request.Select, + Include = request.Include, + GroupBy = request.GroupBy, + Having = request.Having, + Page = request.Page, + PageSize = request.PageSize, + IncludeCount = request.IncludeCount, + Distinct = request.Distinct, + Mode = request.Mode, + RawParameters = dict + }; + + return Parse(parameters); } /// /// Parses a strongly typed into . /// - public static QueryOptions Parse(FlexQueryParameters parameters) + /// The raw query parameters from the client. + /// The expected query syntax. Defaults to . + /// A unified object. + public static QueryOptions Parse(FlexQueryParameters parameters, QuerySyntax syntax = QuerySyntax.AutoDetect) { ArgumentNullException.ThrowIfNull(parameters); - // Try Cache first + // Try Cache first (cache key includes syntax for safety) + string? rawKey = null; + if (parameters.RawParameters != null && parameters.RawParameters.Count > 0) + { + rawKey = string.Join("&", parameters.RawParameters + .OrderBy(x => x.Key) + .Select(x => $"{x.Key}={x.Value}")); + } + var cacheKey = new ParsedQueryCacheKey( parameters.Query, parameters.Filter, parameters.Sort, parameters.Select, parameters.Include, parameters.GroupBy, parameters.Having, parameters.Page, parameters.PageSize, parameters.IncludeCount, - parameters.Distinct, parameters.Mode); + parameters.Distinct, parameters.Mode, rawKey, syntax.ToString()); if (ParserCache.TryGet(cacheKey, out var cached)) { return cached!; } - var options = new QueryOptions(); - - // Paging - options.Paging.Page = parameters.Page ?? 1; - options.Paging.PageSize = parameters.PageSize ?? 20; - - // Mode - if (!string.IsNullOrWhiteSpace(parameters.Mode)) - { - options.ProjectionMode = parameters.Mode.Trim().ToLowerInvariant() switch - { - "flat" => ProjectionMode.Flat, - "flat-mixed" => ProjectionMode.FlatMixed, - _ => ProjectionMode.Nested - }; - } - - // Select - if (!string.IsNullOrWhiteSpace(parameters.Select)) - { - ParseSelectWithAggregates(options, parameters.Select); - } - - // Grouping - if (!string.IsNullOrWhiteSpace(parameters.GroupBy)) - { - options.GroupBy = SplitCsv(parameters.GroupBy); - } + IQueryParser? parser = null; - // Having - if (!string.IsNullOrWhiteSpace(parameters.Having)) + if (syntax != QuerySyntax.AutoDetect) { - options.Having = ParseHaving(parameters.Having); + parser = _parsers.FirstOrDefault(p => p.Syntax == syntax); } - // Includes - if (!string.IsNullOrWhiteSpace(parameters.Include)) + if (parser == null) { - options.Includes = SplitCsv(parameters.Include.Split('(')[0]); - options.FilteredIncludes = FilteredIncludeParser.Parse(parameters.Include); + // Auto-detect or fallback + parser = _parsers.FirstOrDefault(p => p.CanParse(parameters)) ?? _parsers.Last(); } - // Metadata - options.IncludeCount = parameters.IncludeCount ?? true; - options.Distinct = parameters.Distinct ?? false; - - // Sorting - if (!string.IsNullOrWhiteSpace(parameters.Sort)) - { - options.Sort.AddRange(ParseSort(parameters.Sort)); - } - - // Filters - prioritize JQL 'Query' if present, then 'Filter' string - if (!string.IsNullOrWhiteSpace(parameters.Query)) - { - var ast = JqlParser.Parse(parameters.Query); - options.Filter = JqlFilterConverter.ToFilterGroup(ast); - options.Ast = ast; - options.Filter = Builders.FilterNormalizer.NormalizeOrder(options.Filter); - } - else if (!string.IsNullOrWhiteSpace(parameters.Filter)) - { - var filterVal = parameters.Filter.TrimStart(); - if (filterVal.StartsWith('{')) - { - try - { - using var doc = JsonDocument.Parse(parameters.Filter); - if (doc.RootElement.TryGetProperty("select", out var selectEl)) - options.SelectTree = Helpers.SelectTreeBuilder.ParseJsonSelect(selectEl); - - if (doc.RootElement.TryGetProperty("filters", out _) || doc.RootElement.TryGetProperty("logic", out _)) - options.Filter = ParseJsonGroup(doc.RootElement); - else if (doc.RootElement.TryGetProperty("filter", out var filterEl)) - options.Filter = ParseJsonGroup(filterEl); - } - catch { /* ignore malformed */ } - } - else - { - try - { - var ast = DslParser.Parse(parameters.Filter); - options.Filter = DslFilterConverter.ToFilterGroup(ast); - options.Ast = ast; - } - catch (DslParseException) { } - } - } + var options = parser.Parse(parameters); // Store in Cache ParserCache.Set(cacheKey, options); @@ -193,28 +178,69 @@ public static QueryOptions Parse(IEnumerable> g => g.Last().Value.ToString(), StringComparer.OrdinalIgnoreCase); - return ParseDictionary(dict); + var parameters = new FlexQueryParameters + { + Query = dict.GetValueOrDefault("query"), + 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"), + GroupBy = dict.GetValueOrDefault("group"), + Having = dict.GetValueOrDefault("having"), + 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, + IncludeCount = dict.TryGetValue("includeCount", out var ic) ? ic.Equals("true", StringComparison.OrdinalIgnoreCase) : null, + Distinct = dict.TryGetValue("distinct", out var d) ? d.Equals("true", StringComparison.OrdinalIgnoreCase) : null, + Mode = dict.GetValueOrDefault("mode"), + RawParameters = dict + }; + + return Parse(parameters); } - private static QueryOptions ParseDictionary(Dictionary dict) + internal static QueryOptions InternalParseDictionary(IDictionary dict) { if (dict.Count == 0) return new QueryOptions(); if (dict.TryGetValue("query", out var jql) && !string.IsNullOrWhiteSpace(jql)) - return ParseJql(dict, jql); + return InternalParseJql(dict, jql); if (dict.Keys.Any(k => k.StartsWith("filter[0]", StringComparison.OrdinalIgnoreCase))) - return ParseGeneric(dict); + return InternalParseGeneric(dict); if (dict.TryGetValue("filter", out var filterVal) && !string.IsNullOrWhiteSpace(filterVal)) { if (filterVal.TrimStart().StartsWith('{')) - return ParseJsonFilter(dict); + return InternalParseJsonFilter(dict); - return ParseDslFilter(dict); + return InternalParseDslFilter(dict); } - return ParseGeneric(dict); + return InternalParseGeneric(dict); + } + + internal static LogicOperator InternalParseLogic(string? raw) + => string.Equals(raw?.Trim(), "or", StringComparison.OrdinalIgnoreCase) + ? LogicOperator.Or + : LogicOperator.And; + + internal static SortedDictionary> InternalCollectIndexed( + IDictionary d, string prefix) + { + var result = new SortedDictionary>(); + var prefixSpan = prefix.AsSpan(); + + foreach (var kv in d) + { + if (TryParseIndexedKey(kv.Key.AsSpan(), prefixSpan, out var idx, out var subkey)) + { + if (!result.TryGetValue(idx, out var inner)) + result[idx] = inner = new Dictionary(StringComparer.OrdinalIgnoreCase); + inner[subkey] = kv.Value; + } + } + + return result; } // ── Generic Format ─────────────────────────────────────────────────── @@ -222,13 +248,13 @@ private static QueryOptions ParseDictionary(Dictionary dict) // &sort[0].field=Age&sort[0].desc=true&page=1&pageSize=10&select=Name,Email // &logic=and (optional top-level logic) - private static QueryOptions ParseGeneric(Dictionary d) + internal static QueryOptions InternalParseGeneric(IDictionary d) { var options = new QueryOptions(); // Paging - options.Paging.Page = ParseInt(d, "page", 1); - options.Paging.PageSize = ParseInt(d, "pageSize", 20); + options.Paging.Page = InternalParseInt(d, "page", 1); + options.Paging.PageSize = InternalParseInt(d, "pageSize", 20); // Mode if (d.TryGetValue("mode", out var modeStr)) @@ -244,38 +270,39 @@ private static QueryOptions ParseGeneric(Dictionary d) // Select if (d.TryGetValue("select", out var sel)) { - ParseSelectWithAggregates(options, sel); + InternalParseSelectWithAggregates(options, sel); } if (d.TryGetValue("group", out var groupRaw)) - options.GroupBy = SplitCsv(groupRaw); + options.GroupBy = InternalSplitCsv(groupRaw); if (d.TryGetValue("having", out var havingRaw)) - options.Having = ParseHaving(havingRaw); + options.Having = InternalParseHaving(havingRaw); // Includes — parse both as plain strings (backward-compat) and as // structured IncludeNode trees that support inline JQL filters. if (d.TryGetValue("include", out var inc)) { - options.Includes = SplitCsv(inc.Split('(')[0]); // plain names only + options.Includes = InternalSplitCsv(inc.Split('(')[0]); // plain names only options.FilteredIncludes = FilteredIncludeParser.Parse(inc); } // Top-level logic - var logic = ParseLogic(d.GetValueOrDefault("logic", "and")); + var logicValue = d.TryGetValue("logic", out var l) ? l : "and"; + var logic = InternalParseLogic(logicValue); // Collect indexed filters: filter[0].field, filter[0].operator, filter[0].value - var filterMap = CollectIndexed(d, "filter"); + var filterMap = InternalCollectIndexed(d, "filter"); var children = new List(); foreach (var (_, fields) in filterMap.OrderBy(x => x.Key)) { - var field = fields.GetValueOrDefault("field"); + var field = fields.TryGetValue("field", out var f) ? f : null; if (string.IsNullOrWhiteSpace(field)) continue; children.Add(new FilterConditionNode { Field = field, - Operator = FilterOperators.Normalize(fields.GetValueOrDefault("operator", "eq")), - Value = fields.GetValueOrDefault("value") + Operator = FilterOperators.Normalize(fields.TryGetValue("operator", out var o) ? o : "eq"), + Value = fields.TryGetValue("value", out var v) ? v : null }); } @@ -283,31 +310,34 @@ private static QueryOptions ParseGeneric(Dictionary d) options.Filter = new FilterGroupNode { Logic = logic, Children = children }; // Collect indexed sorts: sort[0].field, sort[0].desc - var sortMap = CollectIndexed(d, "sort"); + var sortMap = InternalCollectIndexed(d, "sort"); foreach (var (_, fields) in sortMap.OrderBy(x => x.Key)) { - var field = fields.GetValueOrDefault("field"); + var field = fields.TryGetValue("field", out var f) ? f : null; if (string.IsNullOrWhiteSpace(field)) continue; options.Sort.Add(new SortNode { Field = field, - Descending = ParseBool(fields.GetValueOrDefault("desc")) + Descending = InternalParseBool(fields.TryGetValue("desc", out var dsc) ? dsc : null) }); } if (d.TryGetValue("sort", out var sortRaw)) - options.Sort.AddRange(ParseSort(sortRaw)); + options.Sort.AddRange(InternalParseSort(sortRaw)); // Metadata - options.IncludeCount = ParseBool(d.GetValueOrDefault("includeCount"), true); - options.Distinct = ParseBool(d.GetValueOrDefault("distinct")); + var incCountStr = d.TryGetValue("includeCount", out var ic) ? ic : null; + options.IncludeCount = InternalParseBool(incCountStr, true); + + var distinctStr = d.TryGetValue("distinct", out var dist) ? dist : null; + options.Distinct = InternalParseBool(distinctStr); return options; } - private static void ParseSelectWithAggregates(QueryOptions options, string? rawSelect) + internal static void InternalParseSelectWithAggregates(QueryOptions options, string? rawSelect) { - var fields = SplitCsv(rawSelect); + var fields = InternalSplitCsv(rawSelect); if (fields.Count == 0) { options.Select = []; @@ -346,7 +376,7 @@ private static void ParseSelectWithAggregates(QueryOptions options, string? rawS options.Select = scalars; } - private static HavingCondition? ParseHaving(string? rawHaving) + internal static HavingCondition? InternalParseHaving(string? rawHaving) { if (string.IsNullOrWhiteSpace(rawHaving)) return null; var match = HavingPattern.Match(rawHaving.Trim()); @@ -378,14 +408,39 @@ internal static string BuildAggregateAlias(string function, string? field) // ── JSON Filter Format ─────────────────────────────────────────────── // ?filter={"logic":"and","filters":[{"field":"Name","operator":"contains","value":"john"}]} - private static QueryOptions ParseJsonFilter(Dictionary d) + internal static QueryOptions InternalParseJsonFilter(QueryOptions options, string json) + { + try + { + using var doc = JsonDocument.Parse(json); + + if (doc.RootElement.TryGetProperty("select", out var selectEl)) + { + options.SelectTree = Helpers.SelectTreeBuilder.ParseJsonSelect(selectEl); + } + + if (doc.RootElement.TryGetProperty("filters", out _) || doc.RootElement.TryGetProperty("logic", out _)) + { + options.Filter = ParseJsonGroup(doc.RootElement); + } + else if (doc.RootElement.TryGetProperty("filter", out var filterEl)) + { + options.Filter = ParseJsonGroup(filterEl); + } + } + catch { /* malformed JSON — ignore */ } + + return options; + } + + internal static QueryOptions InternalParseJsonFilter(IDictionary d) { var options = new QueryOptions(); // Paging & select same as generic - options.Paging.Page = ParseInt(d, "page", 1); - options.Paging.PageSize = ParseInt(d, "pageSize", 20); - if (d.TryGetValue("select", out var sel)) options.Select = SplitCsv(sel); + options.Paging.Page = InternalParseInt(d, "page", 1); + options.Paging.PageSize = InternalParseInt(d, "pageSize", 20); + if (d.TryGetValue("select", out var sel)) options.Select = InternalSplitCsv(sel); // Sort (generic format + sort string) options.Sort.AddRange(ParseGenericSorts(d)); @@ -456,9 +511,9 @@ private static FilterGroupNode ParseJsonGroup(JsonElement root) // DSL Filter Format // ?filter=(name:eq:john|name:eq:doe)&age:gt:20 - private static QueryOptions ParseDslFilter(Dictionary d) + internal static QueryOptions InternalParseDslFilter(IDictionary d) { - var options = ParseGeneric(d); + var options = InternalParseGeneric(d); if (!d.TryGetValue("filter", out var filter)) return options; try @@ -479,11 +534,11 @@ private static QueryOptions ParseDslFilter(Dictionary d) // ?query=(name = "john" OR name = "doe") AND age >= 20 // // JQL parsing errors are NOT swallowed: invalid syntax should be surfaced to callers. - private static QueryOptions ParseJql(Dictionary d, string query) + internal static QueryOptions InternalParseJql(IDictionary d, string query) { - var options = ParseGeneric(d); + var options = InternalParseGeneric(d); - var ast = JqlParser.Parse(query); + var ast = Jql.JqlParser.Parse(query); options.Filter = JqlFilterConverter.ToFilterGroup(ast); options.Ast = ast; options.Filter = Builders.FilterNormalizer.NormalizeOrder(options.Filter); @@ -495,29 +550,6 @@ private static QueryOptions ParseJql(Dictionary d, string query) // ── Shared helpers ──────────────────────────────────────────────────── - /// - /// Collects keys like prefix[index].subkey into a nested dictionary - /// indexed by the integer index, then sub-keyed by the sub-key name. - /// - private static SortedDictionary> CollectIndexed( - Dictionary d, string prefix) - { - var result = new SortedDictionary>(); - var prefixSpan = prefix.AsSpan(); - - foreach (var kv in d) - { - if (TryParseIndexedKey(kv.Key.AsSpan(), prefixSpan, out var idx, out var subkey)) - { - if (!result.TryGetValue(idx, out var inner)) - result[idx] = inner = new Dictionary(StringComparer.OrdinalIgnoreCase); - inner[subkey] = kv.Value; - } - } - - return result; - } - private static bool TryParseIndexedKey( ReadOnlySpan key, ReadOnlySpan prefix, @@ -554,10 +586,10 @@ private static bool TryParseIndexedKey( - private static int ParseInt(Dictionary d, string key, int defaultValue) + internal static int InternalParseInt(IDictionary d, string key, int defaultValue) => d.TryGetValue(key, out var raw) && int.TryParse(raw, out var val) ? val : defaultValue; - private static bool ParseBool(string? raw, bool defaultValue = false) + internal static bool InternalParseBool(string? raw, bool defaultValue = false) => raw is not null ? (raw.Equals("true", StringComparison.OrdinalIgnoreCase) || raw == "1") : defaultValue; @@ -567,7 +599,7 @@ private static LogicOperator ParseLogic(string? raw) ? LogicOperator.Or : LogicOperator.And; - private static List SplitCsv(string? raw) + internal static List InternalSplitCsv(string? raw) { if (string.IsNullOrWhiteSpace(raw)) return []; @@ -590,29 +622,29 @@ private static List SplitCsv(string? raw) return result; } - private static List ParseGenericSorts(Dictionary d) + private static List ParseGenericSorts(IDictionary d) { var result = new List(); - var sortMap = CollectIndexed(d, "sort"); + var sortMap = InternalCollectIndexed(d, "sort"); foreach (var (_, fields) in sortMap.OrderBy(x => x.Key)) { - var field = fields.GetValueOrDefault("field"); + var field = fields.TryGetValue("field", out var f) ? f : null; if (string.IsNullOrWhiteSpace(field)) continue; result.Add(new SortNode { Field = field, - Descending = ParseBool(fields.GetValueOrDefault("desc")) + Descending = InternalParseBool(fields.TryGetValue("desc", out var dsc) ? dsc : null) }); } if (d.TryGetValue("sort", out var sortRaw)) - result.AddRange(ParseSort(sortRaw)); + result.AddRange(InternalParseSort(sortRaw)); return result; } - internal static List ParseSort(string? sortRaw) + internal static List InternalParseSort(string? sortRaw) { var result = new List(); if (string.IsNullOrWhiteSpace(sortRaw)) return result; 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/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; From ebbe3e8942baac6dafed2d437dad3319038621d8 Mon Sep 17 00:00:00 2001 From: Peter John Casasola Date: Mon, 18 May 2026 21:10:57 +0800 Subject: [PATCH 6/9] refactor!: remove obsolete APIs, deprecated extensions, and legacy query components --- .../Extensions/QueryableEfCoreExtensions.cs | 52 ----------- .../Extensions/QueryableExtensions.cs | 52 ----------- .../Extensions/ValidationExtensions.cs | 36 -------- src/FlexQuery.NET/Models/FlexQueryRequest.cs | 44 ---------- src/FlexQuery.NET/Models/QueryRequest.cs | 86 ------------------- src/FlexQuery.NET/Models/SortOption.cs | 7 -- .../Parsers/QueryOptionsParser.cs | 45 ---------- 7 files changed, 322 deletions(-) delete mode 100644 src/FlexQuery.NET/Models/FlexQueryRequest.cs delete mode 100644 src/FlexQuery.NET/Models/QueryRequest.cs diff --git a/src/FlexQuery.NET.EFCore/Extensions/QueryableEfCoreExtensions.cs b/src/FlexQuery.NET.EFCore/Extensions/QueryableEfCoreExtensions.cs index 5a25156..5d52e17 100644 --- a/src/FlexQuery.NET.EFCore/Extensions/QueryableEfCoreExtensions.cs +++ b/src/FlexQuery.NET.EFCore/Extensions/QueryableEfCoreExtensions.cs @@ -13,58 +13,6 @@ namespace FlexQuery.NET.EFCore; /// public static class QueryableEfCoreExtensions { - /// - /// Async variant of ToQueryResult for EF Core query providers. - /// - [Obsolete("ToQueryResultAsync is deprecated and will be removed in v3. " + - "Use FlexQuery(...) for the unified query pipeline (filtering, sorting,paging and filterincludes).")] - [EditorBrowsable(EditorBrowsableState.Never)] - public static async Task> ToQueryResultAsync( - this IQueryable query, - QueryOptions options, - CancellationToken cancellationToken = default) - where T : class - { - QueryOptionsEfCoreExtensions.EnsureEfCoreOperatorsRegistered(); - - var filtered = QueryBuilder.ApplyFilter(query, options); - filtered = QueryBuilder.ApplySort(filtered, options); - - var total = options.IncludeCount == true ? await filtered.CountAsync(cancellationToken) : (int?)null; - - var paged = QueryBuilder.ApplyPaging(filtered, options); - var data = await paged.ApplyFilteredIncludes(options).ToListAsync(cancellationToken); - - return options.BuildQueryResult(data, total); - } - - /// - /// Async projected result variant using options-driven selection. - /// - [Obsolete("ToProjectedQueryResultAsync is deprecated and will be removed in v3. " + - "Use FlexQuery(...) for the unified query pipeline (filtering, sorting, projection, paging and filterincludes).")] - [EditorBrowsable(EditorBrowsableState.Never)] - public static async Task> ToProjectedQueryResultAsync( - this IQueryable query, - QueryOptions options, - CancellationToken cancellationToken = default) - where T : class - { - QueryOptionsEfCoreExtensions.EnsureEfCoreOperatorsRegistered(); - - var filtered = QueryBuilder.ApplyFilter(query, options); - filtered = QueryBuilder.ApplySort(filtered, options); - - var total = options.IncludeCount == true ? await filtered.CountAsync(cancellationToken) : (int?)null; - - var paged = QueryBuilder.ApplyPaging(filtered, options); - // Note: ApplySelect already incorporates FilteredIncludes filters into the projection tree, - // so calling ApplyFilteredIncludes here is technically redundant but ensures consistency - // if the projection engine behavior changes. - var data = await paged.ApplyFilteredIncludes(options).ApplySelect(options).ToListAsync(cancellationToken); - - return options.BuildQueryResult(data, total); - } // ── Include Pipeline ───────────────────────────────────────────────── diff --git a/src/FlexQuery.NET/Extensions/QueryableExtensions.cs b/src/FlexQuery.NET/Extensions/QueryableExtensions.cs index 87ea804..e1f5804 100644 --- a/src/FlexQuery.NET/Extensions/QueryableExtensions.cs +++ b/src/FlexQuery.NET/Extensions/QueryableExtensions.cs @@ -20,18 +20,6 @@ public static IQueryable Apply( this IQueryable query, QueryOptions options) => QueryBuilder.Apply(query, options); - /// - /// Applies all query options (filter, sort, paging) and returns the shaped queryable. - /// - /// - /// If is provided, use on the result. - /// - [Obsolete("ApplyQueryOptions is deprecated and will be removed in v3. Use Apply(...) or FlexQuery(...).")] - [EditorBrowsable(EditorBrowsableState.Never)] - public static IQueryable ApplyQueryOptions( - this IQueryable query, QueryOptions options) - => Apply(query, options); - /// Applies only the filter predicate. public static IQueryable ApplyFilter( this IQueryable query, QueryOptions options) @@ -55,46 +43,6 @@ public static IQueryable ApplySelect( this IQueryable query, QueryOptions options) => QueryBuilder.ApplySelect(query, options); - /// - /// Executes a query like , but returns projected rows. - /// Uses .Select (and includes/JSON select if present) to shape the result. - /// - [Obsolete("ToProjectedQueryResult is deprecated and will be removed in v3. " + - "Use FlexQuery(...) for the unified query pipeline (filtering, sorting, projection, and paging).")] - [EditorBrowsable(EditorBrowsableState.Never)] - public static QueryResult ToProjectedQueryResult( - this IQueryable query, - QueryOptions options) - { - var filtered = ApplyFilterAndSort(query, options); - - var total = filtered.TryGetTotalCount(options); - - var paged = QueryBuilder.ApplyPaging(filtered, options); - - var data = QueryBuilder.ApplySelect(paged, options); - - return options.BuildQueryResult(data, total); - } - - /// - /// Convenience: executes the query and wraps it in a - /// with total count, page metadata, and the paged data. - /// - [Obsolete("ToQueryResult is deprecated and will be removed in v3. " + - "Use FlexQuery(...) for the unified query pipeline (filtering, sorting, and paging).")] - [EditorBrowsable(EditorBrowsableState.Never)] - public static QueryResult ToQueryResult( - this IQueryable query, QueryOptions options) - { - var filtered = ApplyFilterAndSort(query, options); - - var total = filtered.TryGetTotalCount(options); - - var data = QueryBuilder.ApplyPaging(filtered, options); - - return options.BuildQueryResult(data, total); - } /// /// Conditionally computes the total count if requested. diff --git a/src/FlexQuery.NET/Extensions/ValidationExtensions.cs b/src/FlexQuery.NET/Extensions/ValidationExtensions.cs index ff29603..b6b6e24 100644 --- a/src/FlexQuery.NET/Extensions/ValidationExtensions.cs +++ b/src/FlexQuery.NET/Extensions/ValidationExtensions.cs @@ -44,40 +44,4 @@ public static ValidationResult Validate(this IQueryable query, QueryOption return validator.Validate(options, context); } - /// - /// Validates the query options using the default validation pipeline. - /// - [Obsolete("Validate with default execution rules is deprecated. Use Validate with QueryExecutionOptions instead.")] - [EditorBrowsable(EditorBrowsableState.Never)] - public static ValidationResult Validate(this IQueryable query, QueryOptions options) - => query.Validate(options, _defaultValidator); - - /// - /// Validates the query options using a specific validator. - /// - [Obsolete("Validate with IQueryValidator is deprecated. Use Validate with QueryExecutionOptions instead.")] - [EditorBrowsable(EditorBrowsableState.Never)] - public static ValidationResult Validate(this IQueryable query, QueryOptions options, IQueryValidator validator) - { - ArgumentNullException.ThrowIfNull(validator); - var context = new QueryContext { TargetType = typeof(T) }; - return validator.Validate(options, context); - } - - /// - /// Validates and applies the query options in a single step. - /// Throws if validation fails. - /// - - [Obsolete("ApplyValidatedQueryOptions is deprecated. Use FlexQueryParameters with FlexQuery(...) instead.")] - [EditorBrowsable(EditorBrowsableState.Never)] - public static IQueryable ApplyValidatedQueryOptions(this IQueryable query, QueryOptions options) - { - var result = query.Validate(options); - if (!result.IsValid) - { - throw new QueryValidationException(result); - } - return query.ApplyQueryOptions(options); - } } diff --git a/src/FlexQuery.NET/Models/FlexQueryRequest.cs b/src/FlexQuery.NET/Models/FlexQueryRequest.cs deleted file mode 100644 index faf967b..0000000 --- a/src/FlexQuery.NET/Models/FlexQueryRequest.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.ComponentModel; - -namespace FlexQuery.NET.Models; - -/// -/// A framework-agnostic DTO for dynamic queries. -/// Automatically documented in Swagger UI when XML comments are enabled. -/// -[Obsolete("FlexQueryRequest is deprecated. Use FlexQueryParameters and bind it via [FromQuery].")] -[EditorBrowsable(EditorBrowsableState.Never)] -public class FlexQueryRequest -{ - /// - /// Filter expression using DSL (Field:Operator:Value) or JQL. - /// Supported Operators: eq, neq, gt, lt, ge, le, contains, startswith, endswith. - /// - /// Name:contains:John,Age:gt:18 - public string? Filter { get; set; } - - /// - /// Sorting instructions (e.g. 'FieldName:asc' or 'FieldName:desc'). - /// Supports multiple fields separated by commas. - /// - /// CreatedDate:desc,Name:asc - public string? Sort { get; set; } - - /// - /// Comma-separated list of fields to include in the result. - /// - /// Id,Name,Email - public string? Select { get; set; } - - /// - /// The page number to retrieve (1-indexed). - /// - /// 1 - public int? Page { get; set; } = 1; - - /// - /// The number of items to return per page. - /// - /// 20 - public int? PageSize { get; set; } = 20; -} diff --git a/src/FlexQuery.NET/Models/QueryRequest.cs b/src/FlexQuery.NET/Models/QueryRequest.cs deleted file mode 100644 index 1d39d06..0000000 --- a/src/FlexQuery.NET/Models/QueryRequest.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System.ComponentModel; - -namespace FlexQuery.NET.Models; - -/// -/// A standardized Data Transfer Object representing a dynamic query request from a client. -/// This model separates the untrusted user input from the internal execution model (QueryOptions). -/// -[Obsolete("QueryRequest is deprecated. Use FlexQueryParameters and bind it via [FromQuery].")] -[EditorBrowsable(EditorBrowsableState.Never)] -public class QueryRequest -{ - /// - /// Filter expression using DSL (Field:Operator:Value), JQL, or JSON format. - /// DSL Examples: Name:contains:John, Age:gt:18 - /// JQL Example: (Name = "John" OR Name = "Doe") AND Age >= 20 - /// - /// Name:contains:John,Age:gt:18 - public string? Filter { get; set; } - - /// - /// Sorting instructions (e.g. 'FieldName:asc' or 'FieldName:desc'). - /// Supports multiple fields separated by commas. - /// - /// CreatedDate:desc,Name:asc - public string? Sort { get; set; } - - /// - /// Comma-separated list of fields to select or project. - /// Supports nested paths and aliases (e.g. "Id, Name, Profile.Bio as Bio"). - /// - /// Id,Name,Email - public string? Select { get; set; } - - /// - /// Comma-separated list of navigation properties to eagerly load with all scalars. - /// For complex filtered includes, use the 'include=' syntax in the filter or query parameters. - /// - /// Orders,Address - public string? Include { get; set; } - - /// - /// Comma-separated list of fields to group by for aggregation. - /// - /// Category,Status - public string? GroupBy { get; set; } - - /// - /// Having condition applied after aggregation (e.g., "sum(Total):gt:1000"). - /// - /// sum(Total):gt:1000 - public string? Having { get; set; } - - /// - /// A full JQL (Jira-like Query Language) string. - /// If provided, this may override or be merged with the 'Filter' parameter. - /// - public string? Query { get; set; } - - /// - /// The current page number to retrieve (1-indexed). - /// - /// 1 - public int? Page { get; set; } = 1; - - /// - /// The number of items to return per page. - /// - /// 20 - public int? PageSize { get; set; } = 20; - - /// - /// Whether to include the total count in the result metadata. - /// - public bool? IncludeCount { get; set; } = true; - - /// - /// Whether to apply a distinct operation to the result set. - /// - public bool? Distinct { get; set; } - - /// - /// The projection mode determining how nested data is flattened (e.g., "nested", "flat", "flat-mixed"). - /// - public string? Mode { get; set; } -} diff --git a/src/FlexQuery.NET/Models/SortOption.cs b/src/FlexQuery.NET/Models/SortOption.cs index 9d0ac1d..bef6f31 100644 --- a/src/FlexQuery.NET/Models/SortOption.cs +++ b/src/FlexQuery.NET/Models/SortOption.cs @@ -18,10 +18,3 @@ public class SortNode public bool Descending { get; set; } } -/// -/// Backwards-compatible sort option alias used by older test and API code. -/// -[Obsolete("SortOption is deprecated. Use SortNode instead.")] -public sealed class SortOption : SortNode -{ -} diff --git a/src/FlexQuery.NET/Parsers/QueryOptionsParser.cs b/src/FlexQuery.NET/Parsers/QueryOptionsParser.cs index 4738479..ac98299 100644 --- a/src/FlexQuery.NET/Parsers/QueryOptionsParser.cs +++ b/src/FlexQuery.NET/Parsers/QueryOptionsParser.cs @@ -69,51 +69,6 @@ static QueryOptionsParser() public static void RegisterParser(IQueryParser parser) => _parsers.Insert(0, parser); // ── Public entry point ─────────────────────────────────────────────── - - /// - /// Parses a strongly typed into . - /// - [Obsolete("Use Parse(FlexQueryParameters) instead for better separation of concerns and flexibility.")] - [EditorBrowsable(EditorBrowsableState.Never)] - public static QueryOptions Parse(QueryRequest request) - { - ArgumentNullException.ThrowIfNull(request); - - var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); - - if (!string.IsNullOrWhiteSpace(request.Query)) dict["query"] = request.Query; - if (!string.IsNullOrWhiteSpace(request.Filter)) dict["filter"] = request.Filter; - if (!string.IsNullOrWhiteSpace(request.Sort)) dict["sort"] = request.Sort; - if (!string.IsNullOrWhiteSpace(request.Select)) dict["select"] = request.Select; - if (!string.IsNullOrWhiteSpace(request.Include)) dict["include"] = request.Include; - if (!string.IsNullOrWhiteSpace(request.GroupBy)) dict["group"] = request.GroupBy; - if (!string.IsNullOrWhiteSpace(request.Having)) dict["having"] = request.Having; - if (!string.IsNullOrWhiteSpace(request.Mode)) dict["mode"] = request.Mode; - - if (request.Page.HasValue) dict["page"] = request.Page.Value.ToString(); - if (request.PageSize.HasValue) dict["pageSize"] = request.PageSize.Value.ToString(); - if (request.IncludeCount.HasValue) dict["includeCount"] = request.IncludeCount.Value.ToString(); - if (request.Distinct.HasValue) dict["distinct"] = request.Distinct.Value.ToString(); - - var parameters = new FlexQueryParameters - { - Query = request.Query, - Filter = request.Filter, - Sort = request.Sort, - Select = request.Select, - Include = request.Include, - GroupBy = request.GroupBy, - Having = request.Having, - Page = request.Page, - PageSize = request.PageSize, - IncludeCount = request.IncludeCount, - Distinct = request.Distinct, - Mode = request.Mode, - RawParameters = dict - }; - - return Parse(parameters); - } /// /// Parses a strongly typed into . /// From ceeb823a5c8db77db9b16001e0d15990f09bd01e Mon Sep 17 00:00:00 2001 From: Peter John Casasola Date: Mon, 18 May 2026 21:55:17 +0800 Subject: [PATCH 7/9] build(v3): update target frameworks and remove net7 support --- .../FlexQuery.NET.AspNetCore.csproj | 2 +- .../FlexQuery.NET.Dapper.csproj | 17 +++++++++-------- .../FlexQuery.NET.EFCore.csproj | 10 +++++----- .../FlexQuery.NET.MiniOData.csproj | 10 +++++----- src/FlexQuery.NET/FlexQuery.NET.csproj | 10 +++++----- 5 files changed, 25 insertions(+), 24 deletions(-) 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.0 false enable enable diff --git a/src/FlexQuery.NET.Dapper/FlexQuery.NET.Dapper.csproj b/src/FlexQuery.NET.Dapper/FlexQuery.NET.Dapper.csproj index 91a0bc4..8104b8b 100644 --- a/src/FlexQuery.NET.Dapper/FlexQuery.NET.Dapper.csproj +++ b/src/FlexQuery.NET.Dapper/FlexQuery.NET.Dapper.csproj @@ -1,7 +1,7 @@ - net6.0;net7.0;net8.0 + net6.0;net8.0;net10.0 false enable enable @@ -32,15 +32,18 @@ - + + - - + + + - - + + + @@ -54,8 +57,6 @@ - - diff --git a/src/FlexQuery.NET.EFCore/FlexQuery.NET.EFCore.csproj b/src/FlexQuery.NET.EFCore/FlexQuery.NET.EFCore.csproj index 7c83dbe..53760fa 100644 --- a/src/FlexQuery.NET.EFCore/FlexQuery.NET.EFCore.csproj +++ b/src/FlexQuery.NET.EFCore/FlexQuery.NET.EFCore.csproj @@ -1,7 +1,7 @@ - net6.0;net7.0;net8.0 + net6.0;net8.0;net10.0 false enable enable @@ -35,14 +35,14 @@ - - - - + + + + diff --git a/src/FlexQuery.NET.MiniOData/FlexQuery.NET.MiniOData.csproj b/src/FlexQuery.NET.MiniOData/FlexQuery.NET.MiniOData.csproj index c17c943..c6fb2b3 100644 --- a/src/FlexQuery.NET.MiniOData/FlexQuery.NET.MiniOData.csproj +++ b/src/FlexQuery.NET.MiniOData/FlexQuery.NET.MiniOData.csproj @@ -1,7 +1,7 @@ - net6.0;net7.0;net8.0 + net6.0;net8.0;net10.0 false enable enable @@ -39,14 +39,14 @@ - - - - + + + + diff --git a/src/FlexQuery.NET/FlexQuery.NET.csproj b/src/FlexQuery.NET/FlexQuery.NET.csproj index 4030740..c5ac5f4 100644 --- a/src/FlexQuery.NET/FlexQuery.NET.csproj +++ b/src/FlexQuery.NET/FlexQuery.NET.csproj @@ -1,7 +1,7 @@ - net6.0;net7.0;net8.0 + net6.0;net8.0;net10.0 false @@ -33,14 +33,14 @@ - - - - + + + + From ac4b9acd9e77dae61f074658257eb3f8d1367a0a Mon Sep 17 00:00:00 2001 From: Peter John Casasola Date: Mon, 18 May 2026 22:11:32 +0800 Subject: [PATCH 8/9] docs: update readme and changelog --- CHANGELOG.md | 22 +++++++++++++-- README.md | 80 ++-------------------------------------------------- 2 files changed, 21 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4341e9f..5ce5115 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ 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. @@ -35,17 +38,30 @@ All notable changes to this project will be documented in this file. - `$` 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, 7, and 8. + - 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: 428 tests passing (365 existing + 63 new). + - 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**: Added support for `.net6.0`, `.net7.0`, and `.net8.0` in the Dapper package. +- **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/README.md b/README.md index 83de780..84db4ae 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- FlexQuery.NET Logo + FlexQuery.NET Logo

# FlexQuery.NET @@ -8,7 +8,7 @@ [![NuGet Version](https://img.shields.io/nuget/v/FlexQuery.NET.svg)](https://www.nuget.org/packages/FlexQuery.NET) [![NuGet Downloads](https://img.shields.io/nuget/dt/FlexQuery.NET.svg)](https://www.nuget.org/packages/FlexQuery.NET) -[![Dotnet Support](https://img.shields.io/badge/.NET-6.0%20%7C%207.0%20%7C%208.0-blueviolet)](https://dotnet.microsoft.com/download) +[![Dotnet Support](https://img.shields.io/badge/.NET-6.0%20%7C%208.0%20%7C%2010.0-blueviolet)](https://dotnet.microsoft.com/download) [![Documentation](https://img.shields.io/badge/docs-vercel-blue.svg)](https://flexquery.vercel.app) [![License](https://img.shields.io/badge/License-MIT-yellow.svg)](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. --- @@ -120,80 +119,6 @@ DapperQueryOptions.GlobalDefaultDialect = new PostgreSqlDialect(); // DapperQueryOptions.GlobalDialectResolver = new MyCustomResolver(); ``` ---- - -## 🏎️ 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 | - -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). - ---- - -### 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). - -| 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 | - -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). - ---- - -### API End-to-End (Full ASP.NET Core Pipeline, 100,000 records) - -*Scenario: HTTP request with filter + sort + paging + projection, including JSON serialization.* - -| 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 | - -Full results with fairness notes: [API Benchmarks](docs/guide/performance/api-benchmarks.md). - ---- - -## 📚 Full Documentation - -For detailed methodology, dataset description, reproducibility instructions, and fairness disclaimers: - -👉 **[Performance Documentation Index](docs/guide/performance/)** - -- [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) - ---- - ## 📚 Documentation For detailed guides, API references, and advanced scenarios, visit our documentation site: @@ -203,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) From f47754d8e4cd5dbee43d82fe7e912cb49fc66866 Mon Sep 17 00:00:00 2001 From: Peter John Casasola Date: Mon, 18 May 2026 22:15:37 +0800 Subject: [PATCH 9/9] added missing log --- assets/logo-dark.png | Bin 0 -> 257720 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 assets/logo-dark.png diff --git a/assets/logo-dark.png b/assets/logo-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..e9c371df8c4aedc088014f07d0caca297a960635 GIT binary patch literal 257720 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 #~JfFsKhK`7NUbj4?`%AfqRYcI60){RU> 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Ð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 z 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
w~Y*} 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