diff --git a/CHANGELOG.md b/CHANGELOG.md index beae72a..5ce5115 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,66 @@ All notable changes to this project will be documented in this file. +--- +## [3.0.0] - 2026-05-15 + +### Added +- **.NET 10.0 Support**: Added support for `.NET 10.0` (`net10.0`) across all packages (`FlexQuery.NET`, `FlexQuery.NET.EFCore`, `FlexQuery.NET.Dapper`, `FlexQuery.NET.AspNetCore`, `FlexQuery.NET.MiniOData`). +- **Internal Options Base Class**: Introduced `BaseQueryExecutionOptions` to clean up and consolidate core options and security configuration fields. +- **Automatic OData Parser Discovery**: Implemented automatic reflection-based detection and registration of `MiniODataParser` within `QueryOptionsParser` to enable zero-configuration OData query parsing. +- **FlexQuery.NET.Dapper Package**: + - Full support for Dapper as a high-performance query engine. + - Polymorphic `ISqlDialect` system supporting SQL Server, PostgreSQL, MySQL, MariaDB, SQLite, and Oracle. + - Automatic dialect resolution via `ISqlDialectResolver` based on `DbConnection` types. + - Secure SQL translation engine with parameterization and identifier quoting. +- **Relationship Query Semantics for Dapper**: + - Implemented `any()`, `all()`, and `count()` semantics using efficient `EXISTS` and correlated subqueries. + - Support for `include` and Filtered Includes using `LEFT JOIN` syntax. + - Semantic parity with EF Core relationship queries. +- **Dapper AST & Translators**: + - Dedicated AST nodes for relationship queries, decoupled from core models. + - Specialized translators for Includes, Existence checks, and Counts. +- **Dapper Query Engine Stabilization**: + - Reimplemented `SqlCountTranslator` to use convention‑based `RelationshipMapping` via `IMappingRegistry`. + - Added missing `using FlexQuery.NET.Dapper.Mapping.Metadata` to `SqlExistsTranslator` and `SqlIncludeTranslator`. + - Fixed string interpolation issues and removed redundant `Metadata.` prefixes. + - Refactored Dapper mapping system to convention‑over‑configuration using `IMappingRegistry`. +- **Test Suite Integrity**: + - Updated `SqlTranslatorTests` to register `TestRole` (`ToTable("roles")`) and adjusted assertions for proper quoted SQL (`[roles].[UserId] = [users].[Id]`). + - Fixed navigation‑property test to expect table name `[TestEntities]`. + - Resolved CS1022 / CS1519 syntax errors in translation files. +- **FlexQuery.NET.MiniOData Package**: + - Lightweight OData-compatible query syntax adapter — completely optional, zero core dependencies. + - Parses `$filter`, `$orderby`, `$select`, `$top`, `$skip`, `$expand`, and `$count` into the unified FlexQuery AST. + - OData filter parser supporting binary comparisons (`eq`, `ne`, `gt`, `ge`, `lt`, `le`), function calls (`contains`, `startswith`, `endswith`), logical operators (`and`, `or`, `not`), grouping, null checks, `in` lists, and lambda navigation (`any`/`all`). + - Automatic OData path separator (`/`) to dot-notation conversion. + - `$` prefix stripping for seamless compatibility with both `$filter` and `filter` key formats. + - Lambda variable stripping for `any(o: o/status eq 'active')` expressions. + - DI registration via `services.AddFlexQueryMiniOData()`. + - Multi-targeting support for .NET 6, 8, and 10. +- **Mini OData ↔ Native DSL Equivalence**: + - 63 comprehensive tests verifying AST equivalence between Native DSL and Mini OData syntaxes. + - Proven semantic parity: both parsers produce identical `FilterGroup`, `SortNode`, and `QueryOptions` structures. + - Full solution test suite: 431 tests passing. + +### Changed +- **JQL Status Promotion**: Removed obsolete/deprecation attributes from JQL parser components (`JqlParser`, `FlexQueryParameters.Query`, and `QuerySyntax.Jql`), reinstating JQL as a first-class supported query syntax option alongside DSL and OData. +- **Mini OData Cleanups**: Streamlined the dynamic registration calls and brought in namespaces natively in `ServiceCollectionExtensions`. +- **Dapper Parameter Binding**: Simplified and optimized clean parameter naming iteration in `FlexQueryDapperExtensions`. +- **Documentation Refactoring**: Reorganized the main `README.md` with new .NET 10.0 badges, dark-mode logo assets, and streamlined links. +- **Mapping Registry Evolution**: Updated `JoinInfo` to support `TargetType`, enabling deep property resolution for related entity filters in Dapper. +- **Dapper Multi-Targeting**: Updated support to target `.net6.0`, `.net8.0`, and `.net10.0` in the Dapper package (dropping EOL `.net7.0`). +- **Internal Reorganization**: Moved SQL translators to a dedicated `Translators` folder and namespace for better maintainability. + +### Removed (Breaking Changes) +- **.NET 7.0 EOL Drop**: Dropped support for `.NET 7.0` (reached end-of-life on May 14, 2024) across all projects. +- **Obsolete APIs Cleanup**: Fully removed deprecated and legacy methods that were scheduled for removal: + - `ToQueryResultAsync` and `ToProjectedQueryResultAsync` from `QueryableEfCoreExtensions`. + - `ToQueryResult`, `ToProjectedQueryResult`, and `ApplyQueryOptions` from `QueryableExtensions`. + - `Validate` and `ApplyValidatedQueryOptions` overloads from `ValidationExtensions`. + - `QueryOptionsParser.Parse(QueryRequest)` helper from `QueryOptionsParser`. + - `SortOption` alias in favor of the unified `SortNode` class. + --- ## [2.5.0] - 2026-05-10 diff --git a/FlexQuery.NET.slnx b/FlexQuery.NET.slnx index 5ccbe1e..12d7ed3 100644 --- a/FlexQuery.NET.slnx +++ b/FlexQuery.NET.slnx @@ -3,6 +3,8 @@ + + diff --git a/README.md b/README.md index a8a6924..84db4ae 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- FlexQuery.NET 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. --- @@ -34,6 +33,7 @@ FlexQuery.NET is a lightweight and powerful dynamic query engine for .NET. It al ```bash dotnet add package FlexQuery.NET dotnet add package FlexQuery.NET.EFCore +dotnet add package FlexQuery.NET.Dapper dotnet add package FlexQuery.NET.AspNetCore ``` @@ -62,79 +62,62 @@ public async Task GetUsers([FromQuery] FlexQueryParameters parame GET /api/users?filter=age:gt:18&sort=createdAt:desc&page=1&pageSize=20&select=id,name,email ``` ---- - -## 🏎️ Performance Benchmarks - -FlexQuery.NET is engineered for performance with transparent, reproducible benchmarks. We measure parsing, expression generation, full end-to-end execution, and API-level latency against Gridify, Sieve, OData, and GraphQL. - -> For the complete benchmark suite with methodology, fairness disclaimers, and full analysis, see **[docs/guide/performance/](docs/guide/performance/)**. - -### End-to-End Execution (EF Core InMemory, 1,000 records) - -*Scenario: Filter (2 conditions) + Sort + Paging (100 items) on 1,000 Users with related Orders and OrderItems.* - -| Library | Mean | Relative | Allocated | -|:---------|-----:|---------:|----------:| -| **FlexQuery.NET** | **17.67 ms** | **0.44×** | 21.42 KB | -| **Handwritten LINQ** | 40.21 ms | 1.00× | 97.11 KB | -| Gridify | 40.33 ms | 1.00× | 107.76 KB | -| System.Linq.Dynamic.Core | 40.95 ms | 1.02× | 110.79 KB | -| Sieve | 41.37 ms | 1.03× | 117.67 KB | +### 4. Dapper Integration & Database Dialects -FlexQuery.NET is **2.25× faster than handwritten LINQ** in this InMemory scenario, likely due to expression tree optimization and reduced closure allocation. Full analysis: [Execution Benchmarks](docs/guide/performance/execution.md). +FlexQuery.NET provides a robust Dapper extension (`FlexQuery.NET.Dapper`) that compiles queries into secure, parameterized, and database-specific SQL. ---- - -### Database Execution (SQL Server LocalDB, 100,000 records) - -*Scenario: Simple filter (`status:eq:active`) + page (100 items) against SQL Server with no index.* - -⚠️ **Important:** FlexQuery.NET's default configuration (`IncludeCount=true`) executes an additional COUNT query to return total record count, while the handwritten baseline retrieves data only. This benchmark therefore measures **two roundtrips vs one**. When configured fairly, FlexQuery.NET's filtering overhead is ~3–10% (see details). +#### Automatic Dialect Resolution -| Library | Mean | Relative | Allocated | Queries | -|:---------|-----:|---------:|----------:|---------| -| **Handwritten LINQ** (data only) | 336 µs | 1.00× | 111 KB | 1 SELECT | -| **FlexQuery.NET (with count)** | 20,798 µs | 61.8× | 129 KB | SELECT + COUNT | +By default, the SQL dialect is automatically resolved from your database connection (e.g., `SqlConnection` -> `SqlServerDialect`, `NpgsqlConnection` -> `PostgreSqlDialect`). -The apparent 62× overhead is the cost of the extra COUNT query. Full analysis, fair comparison methodology, and configuration options: [Database Execution](docs/guide/performance/database-execution.md). - ---- +```csharp +[HttpGet] +public async Task GetUsersDapper([FromQuery] FlexQueryParameters parameters) +{ + // The dialect is automatically resolved based on the provided NpgsqlConnection + using var connection = new NpgsqlConnection("Host=localhost;Database=mydb;"); + + var result = await connection.FlexQueryAsync(parameters, options => + { + options.AllowedFields = ["Id", "Name", "Email"]; + // Dapper specific options + options.CommandTimeoutSeconds = 60; + }); -### API End-to-End (Full ASP.NET Core Pipeline, 100,000 records) + return Ok(result); +} +``` -*Scenario: HTTP request with filter + sort + paging + projection, including JSON serialization.* +#### Explicit Dialect Configuration -| Library | PageSize=20 | PageSize=100 | PageSize=100K | -|:---------|------------:|-------------:|--------------:| -| **FlexQuery.NET** | 1.49 ms | 1.64 ms | 2.26 ms | -| GraphQL | 0.90 ms | 0.90 ms | FAILED | -| OData | 1.64 ms | 1.72 ms | 2.24 ms | -| Gridify | 1.56 ms | 1.90 ms | 1.90 ms | -| Sieve | 1.59 ms | 1.97 ms | 1.86 ms | -| Manual LINQ | 1.63 ms | 1.97 ms | 1.89 ms | +If you need to force a specific SQL dialect for a single query, you can configure it directly: -Full results with fairness notes: [API Benchmarks](docs/guide/performance/api-benchmarks.md). +```csharp +using FlexQuery.NET.Dapper.Dialects; ---- +var result = await connection.FlexQueryAsync(parameters, options => +{ + // Explicitly configure the dialect for this specific query + options.Dialect = new MySqlDialect(); + // Supported dialects: SqlServerDialect, PostgreSqlDialect, MySqlDialect, MariaDbDialect, SqliteDialect, OracleDialect +}); +``` -## 📚 Full Documentation +#### Global Dialect Configuration (Optional) -For detailed methodology, dataset description, reproducibility instructions, and fairness disclaimers: +If your entire application uses a single database type and you want to bypass the automatic resolution entirely, you can configure a global default dialect once at startup: -👉 **[Performance Documentation Index](docs/guide/performance/)** +```csharp +// Program.cs or Startup.cs +using FlexQuery.NET.Dapper; +using FlexQuery.NET.Dapper.Dialects; -- [Methodology & Reproducibility](docs/guide/performance/methodology.md) -- [Parsing Benchmarks](docs/guide/performance/parsing.md) -- [Expression Generation](docs/guide/performance/expression-generation.md) -- [End-to-End Execution](docs/guide/performance/execution.md) -- [Database Execution (SQL Server)](docs/guide/performance/database-execution.md) -- [API Benchmarks (vs OData/GraphQL)](docs/guide/performance/api-benchmarks.md) -- [Scalability Analysis](docs/guide/performance/scalability.md) -- [Fairness Disclaimers](docs/guide/performance/fairness-disclaimers.md) -- [Interpretation Guide](docs/guide/performance/interpretation-guide.md) +// Set the global dialect once for the entire application +DapperQueryOptions.GlobalDefaultDialect = new PostgreSqlDialect(); ---- +// Or, provide your own custom resolver logic: +// DapperQueryOptions.GlobalDialectResolver = new MyCustomResolver(); +``` ## 📚 Documentation @@ -145,7 +128,6 @@ For detailed guides, API references, and advanced scenarios, visit our documenta ### Quick Links - [Getting Started](https://flexquery.vercel.app/guide/getting-started) - [Query Composition](https://flexquery.vercel.app/guide/composition) -- [Explicit Joins](https://flexquery.vercel.app/guide/joins) - [Governance & Security](https://flexquery.vercel.app/guide/security) - [Performance Optimization](https://flexquery.vercel.app/guide/performance-tuning) - [Migration Guide (v1 → v2)](https://flexquery.vercel.app/migration/v1-to-v2) diff --git a/assets/logo-dark.png b/assets/logo-dark.png new file mode 100644 index 0000000..e9c371d Binary files /dev/null and b/assets/logo-dark.png differ diff --git a/src/FlexQuery.NET.AspNetCore/FlexQuery.NET.AspNetCore.csproj b/src/FlexQuery.NET.AspNetCore/FlexQuery.NET.AspNetCore.csproj index ed1fd59..ab32781 100644 --- a/src/FlexQuery.NET.AspNetCore/FlexQuery.NET.AspNetCore.csproj +++ b/src/FlexQuery.NET.AspNetCore/FlexQuery.NET.AspNetCore.csproj @@ -1,7 +1,7 @@ - net6.0;net7.0;net8.0 + net6.0;net8.0;net10.0 false enable enable diff --git a/src/FlexQuery.NET.Dapper/Configuration/FlexQueryDapperExtensions.cs b/src/FlexQuery.NET.Dapper/Configuration/FlexQueryDapperExtensions.cs new file mode 100644 index 0000000..d654f86 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Configuration/FlexQueryDapperExtensions.cs @@ -0,0 +1,53 @@ +using Microsoft.Extensions.DependencyInjection; +using FlexQuery.NET.Dapper.Sql; + +namespace FlexQuery.NET.Dapper.Configuration; + +public static class FlexQueryDapperExtensions +{ + /// + /// Configures FlexQuery.NET Dapper globally. + /// + public static IServiceCollection AddFlexQueryDapper(this IServiceCollection services, Action configure) + { + var options = new DapperQueryOptions(); + configure(options); + + // Optionally, register the options as a singleton or configured options + services.AddSingleton(options); + + if (options.Dialect != null) + { + DapperQueryOptions.GlobalDefaultDialect = options.Dialect; + } + + return services; + } + + /// + /// Configures the SQL Server dialect. + /// + public static DapperQueryOptions UseSqlServer(this DapperQueryOptions options) + { + options.Dialect = new Dialects.SqlServerDialect(); + return options; + } + + /// + /// Configures the PostgreSQL dialect. + /// + public static DapperQueryOptions UsePostgreSql(this DapperQueryOptions options) + { + options.Dialect = new Dialects.PostgreSqlDialect(); + return options; + } + + /// + /// Configures the SQLite dialect. + /// + public static DapperQueryOptions UseSqlite(this DapperQueryOptions options) + { + options.Dialect = new Dialects.SqliteDialect(); + return options; + } +} diff --git a/src/FlexQuery.NET.Dapper/Conventions/DefaultEntityConvention.cs b/src/FlexQuery.NET.Dapper/Conventions/DefaultEntityConvention.cs new file mode 100644 index 0000000..1100d31 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Conventions/DefaultEntityConvention.cs @@ -0,0 +1,79 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Reflection; +using FlexQuery.NET.Dapper.Mapping.Metadata; + +namespace FlexQuery.NET.Dapper.Conventions; + +/// +/// Default entity convention. Infers table name, maps properties, detects primary key. +/// +public class DefaultEntityConvention : IEntityConvention +{ + private readonly IPluralizer _pluralizer; + + public DefaultEntityConvention(IPluralizer pluralizer) + { + _pluralizer = pluralizer; + } + + public void Apply(EntityMapping mapping) + { + var type = mapping.Type; + + // 1. Table Name Convention + var tableAttr = type.GetCustomAttribute(); + if (tableAttr != null) + { + mapping.TableName = tableAttr.Name; + } + else if (string.IsNullOrEmpty(mapping.TableName) || mapping.TableName == type.Name) + { + mapping.TableName = _pluralizer.Pluralize(type.Name); + } + + // 2. Property Conventions + foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + // Skip unmapped / ignored properties (can add NotMappedAttribute support here) + if (property.GetCustomAttribute() != null) + continue; + + // Skip navigation properties (complex types and collections are handled by relationship convention) + if (IsNavigationProperty(property.PropertyType)) + continue; + + var propMapping = mapping.GetOrAddProperty(property); + + // Column Name + var columnAttr = property.GetCustomAttribute(); + if (columnAttr != null) + { + propMapping.ColumnName = columnAttr.Name ?? property.Name; + } + + // Primary Key + if (property.GetCustomAttribute() != null) + { + propMapping.IsPrimaryKey = true; + } + else if (string.Equals(property.Name, "Id", StringComparison.OrdinalIgnoreCase) || + string.Equals(property.Name, type.Name + "Id", StringComparison.OrdinalIgnoreCase)) + { + propMapping.IsPrimaryKey = true; + } + } + } + + private bool IsNavigationProperty(Type type) + { + if (type == typeof(string) || type == typeof(byte[]) || type.IsValueType || type.IsPrimitive) + return false; + + // Nullable where T is a value type is not a navigation property + if (Nullable.GetUnderlyingType(type) != null) + return false; + + return true; + } +} diff --git a/src/FlexQuery.NET.Dapper/Conventions/DefaultForeignKeyConvention.cs b/src/FlexQuery.NET.Dapper/Conventions/DefaultForeignKeyConvention.cs new file mode 100644 index 0000000..c74517a --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Conventions/DefaultForeignKeyConvention.cs @@ -0,0 +1,28 @@ +using System.Reflection; +using FlexQuery.NET.Dapper.Mapping.Metadata; + +namespace FlexQuery.NET.Dapper.Conventions; + +/// +/// Default convention for inferring foreign key column names. +/// +public class DefaultForeignKeyConvention : IForeignKeyConvention +{ + public string GetForeignKeyName(PropertyInfo navigationProperty, Type targetType, RelationshipType relationshipType, Type entityType) + { + if (relationshipType == RelationshipType.OneToMany) + { + // For Customer.Orders, the FK is on Order, pointing to Customer. + // FK is usually CustomerId. + return entityType.Name + "Id"; + } + else if (relationshipType == RelationshipType.ManyToOne) + { + // For Order.Customer, the FK is on Order, pointing to Customer. + // FK is usually CustomerId. + return navigationProperty.Name + "Id"; + } + + return navigationProperty.Name + "Id"; + } +} diff --git a/src/FlexQuery.NET.Dapper/Conventions/DefaultPluralizer.cs b/src/FlexQuery.NET.Dapper/Conventions/DefaultPluralizer.cs new file mode 100644 index 0000000..73db32e --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Conventions/DefaultPluralizer.cs @@ -0,0 +1,35 @@ +namespace FlexQuery.NET.Dapper.Conventions; + +/// +/// Default English pluralizer implementation. +/// +public class DefaultPluralizer : IPluralizer +{ + public string Pluralize(string name) + { + if (string.IsNullOrWhiteSpace(name)) + return name; + + // Simple english pluralization rules + if (name.EndsWith("y", StringComparison.OrdinalIgnoreCase) && + !name.EndsWith("ay", StringComparison.OrdinalIgnoreCase) && + !name.EndsWith("ey", StringComparison.OrdinalIgnoreCase) && + !name.EndsWith("iy", StringComparison.OrdinalIgnoreCase) && + !name.EndsWith("oy", StringComparison.OrdinalIgnoreCase) && + !name.EndsWith("uy", StringComparison.OrdinalIgnoreCase)) + { + return name[..^1] + "ies"; + } + + if (name.EndsWith("s", StringComparison.OrdinalIgnoreCase) || + name.EndsWith("sh", StringComparison.OrdinalIgnoreCase) || + name.EndsWith("ch", StringComparison.OrdinalIgnoreCase) || + name.EndsWith("x", StringComparison.OrdinalIgnoreCase) || + name.EndsWith("z", StringComparison.OrdinalIgnoreCase)) + { + return name + "es"; + } + + return name + "s"; + } +} diff --git a/src/FlexQuery.NET.Dapper/Conventions/DefaultRelationshipConvention.cs b/src/FlexQuery.NET.Dapper/Conventions/DefaultRelationshipConvention.cs new file mode 100644 index 0000000..838b3ff --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Conventions/DefaultRelationshipConvention.cs @@ -0,0 +1,89 @@ +using System.Collections; +using System.ComponentModel.DataAnnotations.Schema; +using System.Reflection; +using FlexQuery.NET.Dapper.Mapping; +using FlexQuery.NET.Dapper.Mapping.Metadata; + +namespace FlexQuery.NET.Dapper.Conventions; + +/// +/// Default relationship convention. Discovers relationships, infers foreign keys and target types. +/// +public class DefaultRelationshipConvention : IRelationshipConvention +{ + private readonly IForeignKeyConvention _foreignKeyConvention; + + public DefaultRelationshipConvention(IForeignKeyConvention foreignKeyConvention) + { + _foreignKeyConvention = foreignKeyConvention; + } + + public void Apply(EntityMapping mapping, IMappingRegistry registry) + { + var type = mapping.Type; + + foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (property.GetCustomAttribute() != null) + continue; + + if (!IsNavigationProperty(property.PropertyType)) + continue; + + Type targetType; + RelationshipType relType; + + if (typeof(IEnumerable).IsAssignableFrom(property.PropertyType) && property.PropertyType != typeof(string)) + { + // One-to-Many or Many-to-Many + targetType = GetElementType(property.PropertyType); + relType = RelationshipType.OneToMany; // We default to OneToMany, ManyToMany requires advanced detection + } + else + { + // Many-to-One or One-to-One + targetType = property.PropertyType; + relType = RelationshipType.ManyToOne; + } + + var relMapping = mapping.GetOrAddRelationship(property, targetType, relType); + + // Infer Foreign Key + var fkAttr = property.GetCustomAttribute(); + if (fkAttr != null) + { + relMapping.ForeignKey = fkAttr.Name; + } + else if (string.IsNullOrEmpty(relMapping.ForeignKey)) + { + relMapping.ForeignKey = _foreignKeyConvention.GetForeignKeyName(property, targetType, relType, type); + } + } + } + + private Type GetElementType(Type type) + { + if (type.IsArray) + return type.GetElementType()!; + + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + return type.GetGenericArguments()[0]; + + var enumerableInterface = type.GetInterfaces().FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>)); + if (enumerableInterface != null) + return enumerableInterface.GetGenericArguments()[0]; + + return typeof(object); + } + + private bool IsNavigationProperty(Type type) + { + if (type == typeof(string) || type == typeof(byte[]) || type.IsValueType || type.IsPrimitive) + return false; + + if (Nullable.GetUnderlyingType(type) != null) + return false; + + return true; + } +} diff --git a/src/FlexQuery.NET.Dapper/Conventions/IEntityConvention.cs b/src/FlexQuery.NET.Dapper/Conventions/IEntityConvention.cs new file mode 100644 index 0000000..2691cf0 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Conventions/IEntityConvention.cs @@ -0,0 +1,11 @@ +using FlexQuery.NET.Dapper.Mapping.Metadata; + +namespace FlexQuery.NET.Dapper.Conventions; + +/// +/// Convention applied to entity mappings. +/// +public interface IEntityConvention +{ + void Apply(EntityMapping mapping); +} diff --git a/src/FlexQuery.NET.Dapper/Conventions/IForeignKeyConvention.cs b/src/FlexQuery.NET.Dapper/Conventions/IForeignKeyConvention.cs new file mode 100644 index 0000000..b267cee --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Conventions/IForeignKeyConvention.cs @@ -0,0 +1,12 @@ +using System.Reflection; +using FlexQuery.NET.Dapper.Mapping.Metadata; + +namespace FlexQuery.NET.Dapper.Conventions; + +/// +/// Convention for inferring foreign key column names. +/// +public interface IForeignKeyConvention +{ + string GetForeignKeyName(PropertyInfo navigationProperty, Type targetType, RelationshipType relationshipType, Type entityType); +} diff --git a/src/FlexQuery.NET.Dapper/Conventions/IPluralizer.cs b/src/FlexQuery.NET.Dapper/Conventions/IPluralizer.cs new file mode 100644 index 0000000..af8677a --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Conventions/IPluralizer.cs @@ -0,0 +1,12 @@ +namespace FlexQuery.NET.Dapper.Conventions; + +/// +/// Service responsible for pluralizing entity names into table names. +/// +public interface IPluralizer +{ + /// + /// Pluralizes a singular name. + /// + string Pluralize(string name); +} diff --git a/src/FlexQuery.NET.Dapper/Conventions/IRelationshipConvention.cs b/src/FlexQuery.NET.Dapper/Conventions/IRelationshipConvention.cs new file mode 100644 index 0000000..3f1e06e --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Conventions/IRelationshipConvention.cs @@ -0,0 +1,12 @@ +using FlexQuery.NET.Dapper.Mapping; +using FlexQuery.NET.Dapper.Mapping.Metadata; + +namespace FlexQuery.NET.Dapper.Conventions; + +/// +/// Convention applied to relationship mappings. +/// +public interface IRelationshipConvention +{ + void Apply(EntityMapping mapping, IMappingRegistry registry); +} diff --git a/src/FlexQuery.NET.Dapper/DapperQueryOptions.cs b/src/FlexQuery.NET.Dapper/DapperQueryOptions.cs new file mode 100644 index 0000000..4b99a30 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/DapperQueryOptions.cs @@ -0,0 +1,119 @@ +using FlexQuery.NET.Models; +using FlexQuery.NET.Dapper.Sql; +using FlexQuery.NET.Dapper.Dialects; +using FlexQuery.NET.Security; + + +namespace FlexQuery.NET.Dapper; + +/// +/// Dapper-specific execution options that extends QueryExecutionOptions with SQL dialect configuration. +/// +public sealed class DapperQueryOptions : BaseQueryOptions +{ + /// + /// Default constructor with Dapper-specific defaults. + /// + public DapperQueryOptions() + { + // Dapper defaults + IncludeTotalCount = true; + } + + /// + /// Copy constructor - creates a new instance by copying all properties from source. + /// + /// The source options to copy. + public DapperQueryOptions(QueryExecutionOptions source) + { + // Copy all properties from the base options + MaxPageSize = source.MaxPageSize; + DefaultPageSize = source.DefaultPageSize; + CaseInsensitiveFields = source.CaseInsensitiveFields; + IncludeTotalCount = source.IncludeTotalCount; + StrictFieldValidation = source.StrictFieldValidation; + MaxFieldDepth = source.MaxFieldDepth; + AllowedFields = source.AllowedFields; + BlockedFields = source.BlockedFields; + AllowedIncludes = source.AllowedIncludes; + ExpressionMappings = source.ExpressionMappings; + FilterableFields = source.FilterableFields; + SortableFields = source.SortableFields; + SelectableFields = source.SelectableFields; + + // Dapper-specific defaults + IncludeTotalCount = true; + } + + public QueryExecutionOptions ToQueryExecutionOptions() + { + return new QueryExecutionOptions + { + MaxPageSize = this.MaxPageSize, + DefaultPageSize = this.DefaultPageSize, + CaseInsensitiveFields = this.CaseInsensitiveFields, + IncludeTotalCount = this.IncludeTotalCount, + StrictFieldValidation = this.StrictFieldValidation, + MaxFieldDepth = this.MaxFieldDepth, + AllowedFields = this.AllowedFields, + BlockedFields = this.BlockedFields, + AllowedIncludes = this.AllowedIncludes, + ExpressionMappings = this.ExpressionMappings, + FilterableFields = this.FilterableFields, + SortableFields = this.SortableFields, + SelectableFields = this.SelectableFields + }; + } + + /// Global default SQL dialect. If set, overrides the automatic connection-based resolution for all queries unless a specific query provides its own dialect. + public static ISqlDialect? GlobalDefaultDialect { get; set; } + + /// Global default resolver for SQL dialects. Defaults to DefaultSqlDialectResolver. + public static ISqlDialectResolver GlobalDialectResolver { get; set; } = new DefaultSqlDialectResolver(); + + /// SQL dialect to use for query generation. If null, resolves via GlobalDefaultDialect, then GlobalDialectResolver. + public ISqlDialect? Dialect { get; set; } + + /// Entity mapping registry. If null, a new empty registry is used by the translator. + public Mapping.IMappingRegistry MappingRegistry { get; set; } = new Mapping.MappingRegistry(); + + /// Command timeout in seconds. + public int CommandTimeoutSeconds { get; set; } = 30; + + /// Explicitly set the entity type for mapping resolution. If null, use the generic type T from FlexQueryAsync. + public Type? EntityType { get; set; } + + /// + /// Configures the mapping for a specific entity type using fluent builder API. + /// + public Mapping.Builders.EntityTypeBuilder Entity() where TEntity : class + { + return MappingRegistry.Entity(); + } + + /// + /// Scans the given assembly for types that match typical entity conventions + /// (e.g., classes that aren't abstract, are public, and perhaps have key properties). + /// + public void ScanEntitiesFromAssembly(System.Reflection.Assembly assembly) + { + var types = assembly.GetTypes() + .Where(t => t.IsClass && !t.IsAbstract && t.IsPublic); + + foreach (var type in types) + { + // Only scan types that have an Id or Key property, or a Table attribute + var hasKey = type.GetProperties().Any(p => + p.Name.Equals("Id", StringComparison.OrdinalIgnoreCase) || + p.Name.Equals(type.Name + "Id", StringComparison.OrdinalIgnoreCase) || + p.GetCustomAttributes(typeof(System.ComponentModel.DataAnnotations.KeyAttribute), true).Any()); + + var hasTable = type.GetCustomAttributes(typeof(System.ComponentModel.DataAnnotations.Schema.TableAttribute), true).Any(); + + if (hasKey || hasTable) + { + MappingRegistry.GetMapping(type); + } + } + } +} diff --git a/src/FlexQuery.NET.Dapper/Dialects/DefaultSqlDialectResolver.cs b/src/FlexQuery.NET.Dapper/Dialects/DefaultSqlDialectResolver.cs new file mode 100644 index 0000000..ba6eaea --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Dialects/DefaultSqlDialectResolver.cs @@ -0,0 +1,37 @@ +using System.Data.Common; + +namespace FlexQuery.NET.Dapper.Dialects; + +/// +/// Default implementation of ISqlDialectResolver that inspects the connection type name. +/// +public class DefaultSqlDialectResolver : ISqlDialectResolver +{ + public ISqlDialect Resolve(DbConnection connection) + { + var typeName = connection.GetType().Name; + + if (typeName.Contains("NpgsqlConnection", StringComparison.OrdinalIgnoreCase)) + return new PostgreSqlDialect(); + + if (typeName.Contains("SqliteConnection", StringComparison.OrdinalIgnoreCase)) + return new SqliteDialect(); + + if (typeName.Contains("OracleConnection", StringComparison.OrdinalIgnoreCase)) + return new OracleDialect(); + + // MariaDB Connector/NET uses MySqlConnection or sometimes MariaDbConnection depending on the library + if (typeName.Contains("MariaDbConnection", StringComparison.OrdinalIgnoreCase)) + return new MariaDbDialect(); + + if (typeName.Contains("MySqlConnection", StringComparison.OrdinalIgnoreCase)) + { + // Optional: You could inspect connection.ConnectionString for "MariaDB" if needed, + // but returning MySqlDialect is safe as a baseline for MySqlConnection. + return new MySqlDialect(); + } + + // Fallback or explicit SqlConnection + return new SqlServerDialect(); + } +} diff --git a/src/FlexQuery.NET.Dapper/Dialects/ISqlDialect.cs b/src/FlexQuery.NET.Dapper/Dialects/ISqlDialect.cs new file mode 100644 index 0000000..2832aa2 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Dialects/ISqlDialect.cs @@ -0,0 +1,41 @@ +namespace FlexQuery.NET.Dapper.Dialects; + +/// +/// Abstraction for SQL dialect-specific behavior. +/// Each dialect encapsulates all provider-specific SQL generation concerns. +/// +public interface ISqlDialect +{ + /// SQL parameter prefix (e.g., @ for SQL Server, : for PostgreSQL, ? for MySQL). + string ParameterPrefix { get; } + + /// Wraps an identifier in quotes for the dialect (e.g., [Column], "Column", `Column`). + string QuoteIdentifier(string identifier); + + /// Gets the COUNT expression for count queries. + string GetCountExpression { get; } + + /// Gets the pagination clause (OFFSET/FETCH or LIMIT/OFFSET) for the dialect. + string GetPagingClause(string offsetParam, string limitParam); + + /// Gets the SQL boolean literal for TRUE. + string BooleanTrue { get; } + + /// Gets the SQL boolean literal for FALSE. + string BooleanFalse { get; } + + /// Generates a string concatenation expression for the dialect. + string Concatenate(params string[] parts); + + /// Generates a TOP/N limit expression for the dialect (used when only limit is needed without offset). + string GetLimitExpression(string limitParam); + + /// Quote prefix for identifiers. + char QuotePrefix { get; } + + /// Quote suffix for identifiers. + char QuoteSuffix { get; } + + /// Creates a parameter name with the dialect's parameter prefix. + string CreateParameterName(string name); +} diff --git a/src/FlexQuery.NET.Dapper/Dialects/ISqlDialectResolver.cs b/src/FlexQuery.NET.Dapper/Dialects/ISqlDialectResolver.cs new file mode 100644 index 0000000..50ec287 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Dialects/ISqlDialectResolver.cs @@ -0,0 +1,14 @@ +using System.Data.Common; + +namespace FlexQuery.NET.Dapper.Dialects; + +/// +/// Service responsible for automatically resolving the correct SQL dialect from a database connection. +/// +public interface ISqlDialectResolver +{ + /// + /// Resolves the SQL dialect for the given database connection. + /// + ISqlDialect Resolve(DbConnection connection); +} diff --git a/src/FlexQuery.NET.Dapper/Dialects/MariaDbDialect.cs b/src/FlexQuery.NET.Dapper/Dialects/MariaDbDialect.cs new file mode 100644 index 0000000..c4e26e7 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Dialects/MariaDbDialect.cs @@ -0,0 +1,48 @@ +namespace FlexQuery.NET.Dapper.Dialects; + +/// +/// MariaDB dialect implementation. +/// +/// MariaDB is NOT a drop-in replacement for MySQL in all scenarios. +/// This dedicated dialect handles MariaDB-specific behavior including: +/// - Identifier escaping with backticks (same as MySQL) +/// - Parameter prefix using ? (same as MySQL Connector/NET) +/// - LIMIT/OFFSET pagination (same as MySQL) +/// - String concatenation with CONCAT() +/// - Boolean literal handling specific to MariaDB +/// +/// NOTE: MariaDB has its own versioning, features, and behaviors that may +/// diverge from MySQL. Use this dialect when connecting to MariaDB instances +/// to ensure correct SQL generation for MariaDB-specific edge cases. +/// +public sealed class MariaDbDialect : ISqlDialect +{ + /// MariaDB uses ? parameter prefix with MariaDB Connector/NET and MySqlConnector. + public string ParameterPrefix => "?"; + + public string GetCountExpression => "COUNT(1)"; + + /// MariaDB treats TRUE as 1 and FALSE as 0, but supports TRUE/FALSE keywords in SQL mode. + public string BooleanTrue => "TRUE"; + public string BooleanFalse => "FALSE"; + + /// MariaDB uses backtick quoting for identifiers (same as MySQL). + public char QuotePrefix => '`'; + public char QuoteSuffix => '`'; + + public string QuoteIdentifier(string identifier) => $"`{identifier}`"; + + /// MariaDB uses the same LIMIT/OFFSET pagination syntax as MySQL. + public string GetPagingClause(string offsetParam, string limitParam) + => $"LIMIT {limitParam} OFFSET {offsetParam}"; + + /// MariaDB supports LIMIT without OFFSET for top-N queries. + public string GetLimitExpression(string limitParam) + => $"LIMIT {limitParam}"; + + /// MariaDB uses CONCAT() function for string concatenation. + public string Concatenate(params string[] parts) + => "CONCAT(" + string.Join(", ", parts) + ")"; + + public string CreateParameterName(string name) => $"?{name}"; +} \ No newline at end of file diff --git a/src/FlexQuery.NET.Dapper/Dialects/MySqlDialect.cs b/src/FlexQuery.NET.Dapper/Dialects/MySqlDialect.cs new file mode 100644 index 0000000..2050b10 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Dialects/MySqlDialect.cs @@ -0,0 +1,29 @@ +namespace FlexQuery.NET.Dapper.Dialects; + +/// +/// MySQL dialect implementation. +/// Identifier escaping: `Column` +/// Parameter prefix: ? +/// +public sealed class MySqlDialect : ISqlDialect +{ + public string ParameterPrefix => "?"; + public string GetCountExpression => "COUNT(1)"; + public string BooleanTrue => "TRUE"; + public string BooleanFalse => "FALSE"; + public char QuotePrefix => '`'; + public char QuoteSuffix => '`'; + + public string QuoteIdentifier(string identifier) => $"`{identifier}`"; + + public string GetPagingClause(string offsetParam, string limitParam) + => $"LIMIT {limitParam} OFFSET {offsetParam}"; + + public string GetLimitExpression(string limitParam) + => $"LIMIT {limitParam}"; + + public string Concatenate(params string[] parts) + => "CONCAT(" + string.Join(", ", parts) + ")"; + + public string CreateParameterName(string name) => $"?{name}"; +} diff --git a/src/FlexQuery.NET.Dapper/Dialects/OracleDialect.cs b/src/FlexQuery.NET.Dapper/Dialects/OracleDialect.cs new file mode 100644 index 0000000..78e40c5 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Dialects/OracleDialect.cs @@ -0,0 +1,49 @@ +namespace FlexQuery.NET.Dapper.Dialects; + +/// +/// Oracle dialect implementation. +/// +/// Oracle Database uses specific SQL syntax: +/// - Identifier escaping with double quotes (uppercased by default) +/// - Parameter prefix: : (named parameters via OracleCommand) +/// - Pagination: OFFSET/FETCH (Oracle 12c+) +/// - String concatenation with || operator +/// - Boolean handling: Oracle has no native BOOLEAN type in SQL; +/// uses 1/0 or 'Y'/'N' patterns. Oracle does not support TRUE/FALSE +/// keywords in SQL statements. +/// +/// NOTE: For Oracle versions prior to 12c, OFFSET/FETCH is not supported. +/// A ROW_NUMBER() based fallback may be needed for legacy Oracle versions. +/// This implementation targets Oracle 12c and later. +/// +public sealed class OracleDialect : ISqlDialect +{ + /// Oracle uses : parameter prefix for named parameters. + public string ParameterPrefix => ":"; + + public string GetCountExpression => "COUNT(1)"; + + /// Oracle does not have native TRUE/FALSE in SQL; uses 1 and 0. + public string BooleanTrue => "1"; + public string BooleanFalse => "0"; + + /// Oracle uses double-quote identifier escaping; identifiers are case-sensitive when quoted. + public char QuotePrefix => '"'; + public char QuoteSuffix => '"'; + + public string QuoteIdentifier(string identifier) => $"\"{identifier}\""; + + /// Oracle 12c+ supports OFFSET/FETCH pagination syntax. + public string GetPagingClause(string offsetParam, string limitParam) + => $"OFFSET {offsetParam} ROWS FETCH NEXT {limitParam} ROWS ONLY"; + + /// Oracle 12c+ supports FETCH FIRST for top-N queries. + public string GetLimitExpression(string limitParam) + => $"FETCH FIRST {limitParam} ROWS ONLY"; + + /// Oracle uses || operator for string concatenation. + public string Concatenate(params string[] parts) + => string.Join(" || ", parts); + + public string CreateParameterName(string name) => $":{name}"; +} \ No newline at end of file diff --git a/src/FlexQuery.NET.Dapper/Dialects/PostgreSqlDialect.cs b/src/FlexQuery.NET.Dapper/Dialects/PostgreSqlDialect.cs new file mode 100644 index 0000000..a7abbc0 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Dialects/PostgreSqlDialect.cs @@ -0,0 +1,29 @@ +namespace FlexQuery.NET.Dapper.Dialects; + +/// +/// PostgreSQL dialect implementation. +/// Identifier escaping: "Column" +/// Parameter prefix: : +/// +public sealed class PostgreSqlDialect : ISqlDialect +{ + public string ParameterPrefix => ":"; + public string GetCountExpression => "COUNT(1)"; + public string BooleanTrue => "TRUE"; + public string BooleanFalse => "FALSE"; + public char QuotePrefix => '"'; + public char QuoteSuffix => '"'; + + public string QuoteIdentifier(string identifier) => $"\"{identifier}\""; + + public string GetPagingClause(string offsetParam, string limitParam) + => $"LIMIT {limitParam} OFFSET {offsetParam}"; + + public string GetLimitExpression(string limitParam) + => $"LIMIT {limitParam}"; + + public string Concatenate(params string[] parts) + => string.Join(" || ", parts); + + public string CreateParameterName(string name) => $":{name}"; +} diff --git a/src/FlexQuery.NET.Dapper/Dialects/SqlServerDialect.cs b/src/FlexQuery.NET.Dapper/Dialects/SqlServerDialect.cs new file mode 100644 index 0000000..556a1a8 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Dialects/SqlServerDialect.cs @@ -0,0 +1,30 @@ +namespace FlexQuery.NET.Dapper.Dialects; + +/// +/// SQL Server dialect implementation. +/// Supports SQL Server 2012+ with OFFSET/FETCH pagination. +/// Identifier escaping: [Column] +/// Parameter prefix: @ +/// +public sealed class SqlServerDialect : ISqlDialect +{ + public string ParameterPrefix => "@"; + public string GetCountExpression => "COUNT(1)"; + public string BooleanTrue => "1"; + public string BooleanFalse => "0"; + public char QuotePrefix => '['; + public char QuoteSuffix => ']'; + + public string QuoteIdentifier(string identifier) => $"[{identifier}]"; + + public string GetPagingClause(string offsetParam, string limitParam) + => $"OFFSET {offsetParam} ROWS FETCH NEXT {limitParam} ROWS ONLY"; + + public string GetLimitExpression(string limitParam) + => $"TOP ({limitParam})"; + + public string Concatenate(params string[] parts) + => string.Join(" + ", parts); + + public string CreateParameterName(string name) => $"@{name}"; +} diff --git a/src/FlexQuery.NET.Dapper/Dialects/SqliteDialect.cs b/src/FlexQuery.NET.Dapper/Dialects/SqliteDialect.cs new file mode 100644 index 0000000..bdb8244 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Dialects/SqliteDialect.cs @@ -0,0 +1,49 @@ +namespace FlexQuery.NET.Dapper.Dialects; + +/// +/// SQLite dialect implementation. +/// +/// SQLite is commonly used for: +/// - Integration testing +/// - Demo APIs +/// - Local development +/// - In-memory testing +/// +/// SQLite has specific behaviors: +/// - Identifier escaping uses double quotes (ANSI SQL style) +/// - Parameter prefix uses @ (consistent with Microsoft.Data.Sqlite) +/// - LIMIT/OFFSET pagination (same as MySQL/PostgreSQL) +/// - String concatenation uses the || operator +/// - Boolean literals: 1 for TRUE, 0 for FALSE +/// +public sealed class SqliteDialect : ISqlDialect +{ + /// SQLite uses @ parameter prefix with Microsoft.Data.Sqlite. + public string ParameterPrefix => "@"; + + public string GetCountExpression => "COUNT(1)"; + + /// SQLite does not have native TRUE/FALSE keywords; uses 1 and 0. + public string BooleanTrue => "1"; + public string BooleanFalse => "0"; + + /// SQLite uses double-quote identifier escaping (ANSI SQL). + public char QuotePrefix => '"'; + public char QuoteSuffix => '"'; + + public string QuoteIdentifier(string identifier) => $"\"{identifier}\""; + + /// SQLite uses LIMIT/OFFSET for pagination. + public string GetPagingClause(string offsetParam, string limitParam) + => $"LIMIT {limitParam} OFFSET {offsetParam}"; + + /// SQLite supports LIMIT for top-N queries without OFFSET. + public string GetLimitExpression(string limitParam) + => $"LIMIT {limitParam}"; + + /// SQLite uses || operator for string concatenation (same as PostgreSQL). + public string Concatenate(params string[] parts) + => string.Join(" || ", parts); + + public string CreateParameterName(string name) => $"@{name}"; +} \ No newline at end of file diff --git a/src/FlexQuery.NET.Dapper/FlexQuery.NET.Dapper.csproj b/src/FlexQuery.NET.Dapper/FlexQuery.NET.Dapper.csproj new file mode 100644 index 0000000..8104b8b --- /dev/null +++ b/src/FlexQuery.NET.Dapper/FlexQuery.NET.Dapper.csproj @@ -0,0 +1,62 @@ + + + + net6.0;net8.0;net10.0 + false + enable + enable + latest + true + + + FlexQuery.NET.Dapper + Peter John Casasola + Peter John Casasola + FlexQuery.NET.Dapper + + Dapper integration for FlexQuery.NET — async execution, filtered includes, projection, and pagination helpers + flexquery;dynamic;linq;iqueryable;;filtering;projection;pagination + + MIT + https://github.com/peterjohncasasola/FlexQuery.NET + git + https://github.com/peterjohncasasola/FlexQuery.NET + + README.md + logo.png + + true + false + true + 3.0.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/FlexQuery.NET.Dapper/FlexQueryDapperExtensions.cs b/src/FlexQuery.NET.Dapper/FlexQueryDapperExtensions.cs new file mode 100644 index 0000000..0c31538 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/FlexQueryDapperExtensions.cs @@ -0,0 +1,274 @@ +using System.Data; +using System.Data.Common; +using Dapper; +using FlexQuery.NET.Models; +using FlexQuery.NET.Parsers; +using FlexQuery.NET.Dapper.Sql; +using FlexQuery.NET.Dapper.Sql.Translators; +using FlexQuery.NET.Dapper.Dialects; +using FlexQuery.NET.Extensions; +using Microsoft.Extensions.Primitives; + +namespace FlexQuery.NET.Dapper; + +/// +/// Extension methods for executing FlexQuery requests with Dapper. +/// +public static class FlexQueryDapperExtensions +{ + /// + /// Executes a FlexQuery using FlexQueryParameters with validation. + /// + public static async Task> FlexQueryAsync( + this DbConnection connection, + FlexQueryParameters parameters, + Action? configureDapper = null) where T : class + { + var dapperOptions = new DapperQueryOptions(); + configureDapper?.Invoke(dapperOptions); + + var parsedOptions = QueryOptionsParser.Parse(parameters); + parsedOptions.Items["EntityType"] = dapperOptions.EntityType ?? typeof(T); + + var execOptions = dapperOptions.ToQueryExecutionOptions(); + + parsedOptions.ValidateOrThrow(dapperOptions.EntityType ?? typeof(T), execOptions); + + return await ExecuteQueryAsync(connection, parsedOptions, dapperOptions); + } + + /// + /// Executes a FlexQuery using FlexQueryParameters with full options. + /// + public static async Task> FlexQueryAsync( + this DbConnection connection, + FlexQueryParameters parameters, + DapperQueryOptions? dapperQueryOptions = null) where T : class + { + var dapperOptions = dapperQueryOptions ?? new DapperQueryOptions(); + var parsedOptions = QueryOptionsParser.Parse(parameters); + parsedOptions.Items["EntityType"] = dapperOptions.EntityType ?? typeof(T); + + var execOptions = dapperOptions.ToQueryExecutionOptions(); + + parsedOptions.ValidateOrThrow(dapperOptions.EntityType ?? typeof(T), execOptions); + + return await ExecuteQueryAsync(connection, parsedOptions, dapperOptions); + } + + /// + /// Executes a FlexQuery using raw query string parameters. + /// + public static async Task> FlexQueryAsync( + this DbConnection connection, + IDictionary parameters, + Action? configureDapper = null) where T : class + { + var dict = parameters.ToDictionary(k => k.Key, v => v.Value.ToString(), StringComparer.OrdinalIgnoreCase); + + var flexParams = new FlexQueryParameters + { + Filter = dict.GetValueOrDefault("filter") ?? dict.GetValueOrDefault("$filter"), + Sort = dict.GetValueOrDefault("sort") ?? dict.GetValueOrDefault("orderby") ?? dict.GetValueOrDefault("$orderby"), + Select = dict.GetValueOrDefault("select") ?? dict.GetValueOrDefault("$select"), + Include = dict.GetValueOrDefault("include") ?? dict.GetValueOrDefault("expand") ?? dict.GetValueOrDefault("$expand"), + Page = dict.TryGetValue("page", out var p) && int.TryParse(p, out var page) ? page : null, + PageSize = dict.TryGetValue("pageSize", out var ps) && int.TryParse(ps, out var pageSize) ? pageSize : null, + RawParameters = dict + }; + + return await FlexQueryAsync(connection, flexParams, configureDapper); + } + + private static async Task> ExecuteQueryAsync( + DbConnection connection, + QueryOptions options, + DapperQueryOptions execOptions) where T : class + { + var dialect = execOptions.Dialect + ?? DapperQueryOptions.GlobalDefaultDialect + ?? DapperQueryOptions.GlobalDialectResolver.Resolve(connection); + + var registry = execOptions.MappingRegistry ?? new Mapping.MappingRegistry(); + + // Propagate EntityType to options for translator + if (execOptions.EntityType != null) + options.Items["EntityType"] = execOptions.EntityType; + + var translator = new SqlTranslator(registry, dialect); + var command = translator.Translate(options); + var mapping = registry.GetMapping(execOptions.EntityType ?? typeof(T)); + + var parameters = new DynamicParameters(); + foreach (var param in command.Parameters) + { + var cleanName = param.Key.TrimStart('@', ':', '?'); + parameters.Add(cleanName, param.Value); + } + + IReadOnlyList items; + if (options.Includes?.Count > 0) + { + var dynamicItems = await connection.QueryAsync( + command.Sql, + parameters, + commandTimeout: execOptions.CommandTimeoutSeconds, + commandType: CommandType.Text); + + var parentMap = new Dictionary(); + 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 || (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); + } + + return new QueryResult + { + Data = items, + TotalCount = totalCount, + Page = options.Paging.Page, + PageSize = options.Paging.PageSize + }; + } + + 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 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/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/IEntityMapping.cs b/src/FlexQuery.NET.Dapper/Mapping/IEntityMapping.cs new file mode 100644 index 0000000..315cbf7 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Mapping/IEntityMapping.cs @@ -0,0 +1,31 @@ +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; set; } + + /// 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 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 new file mode 100644 index 0000000..d10e28e --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Mapping/IMappingRegistry.cs @@ -0,0 +1,18 @@ +using FlexQuery.NET.Dapper.Mapping.Builders; + +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(); + + /// Configures an entity mapping using the fluent builder API. + EntityTypeBuilder Entity() where TEntity : class; +} diff --git a/src/FlexQuery.NET.Dapper/Mapping/JoinInfo.cs b/src/FlexQuery.NET.Dapper/Mapping/JoinInfo.cs new file mode 100644 index 0000000..ab79fbe --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Mapping/JoinInfo.cs @@ -0,0 +1,12 @@ +namespace FlexQuery.NET.Dapper.Mapping; + +/// +/// Information about a join relationship. +/// +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/Mapping/MappingRegistry.cs b/src/FlexQuery.NET.Dapper/Mapping/MappingRegistry.cs new file mode 100644 index 0000000..cefb246 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/Mapping/MappingRegistry.cs @@ -0,0 +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 and convention-based discovery. +/// +public sealed class MappingRegistry : IMappingRegistry +{ + 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, CreateAndApplyConventions); + + public IEntityMapping GetMapping() => GetMapping(typeof(T)); + + public void Register(EntityMapping mapping) => _mappings[mapping.Type] = mapping; + + public EntityTypeBuilder Entity() where TEntity : class + { + 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/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..2721c2d --- /dev/null +++ b/src/FlexQuery.NET.Dapper/SQL/Translators/SqlCountTranslator.cs @@ -0,0 +1,71 @@ +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, + IMappingRegistry registry) + { + 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) + ? 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 => "=", + 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 => "<=", + _ => "=" + }; + + 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 new file mode 100644 index 0000000..629ff4d --- /dev/null +++ b/src/FlexQuery.NET.Dapper/SQL/Translators/SqlExistsTranslator.cs @@ -0,0 +1,68 @@ +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; + +/// +/// 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, IMappingRegistry registry) + { + 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) + ? joinCondition + : $"{joinCondition} AND ({subqueryFilter})"; + + 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, IMappingRegistry registry) + { + 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) + ? $"{joinCondition} AND NOT (1=1)" + : $"{joinCondition} AND NOT ({subqueryFilter})"; + + 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 new file mode 100644 index 0000000..f1ebb05 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/SQL/Translators/SqlIncludeTranslator.cs @@ -0,0 +1,49 @@ +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; + +/// +/// 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, IMappingRegistry registry) + { + var rel = mapping.GetRelationship(node.NavigationProperty); + if (rel == null) return string.Empty; + + 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) + { + 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..37c62f4 --- /dev/null +++ b/src/FlexQuery.NET.Dapper/SQL/Translators/SqlTranslator.cs @@ -0,0 +1,444 @@ +using FlexQuery.NET.Models; +using FlexQuery.NET.Constants; +using FlexQuery.NET.Dapper.Mapping; +using FlexQuery.NET.Dapper.Dialects; +using FlexQuery.NET.Dapper.Sql.Ast; +using FlexQuery.NET.Security; +using System.ComponentModel; + +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); + + if (options.Includes?.Count > 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 = 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); + 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; + var selectParts = new List(); + if (options.Aggregates?.Count > 0) + { + 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 = QuoteColumn(column, mapping); + + 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)}"; + } + + // 1. Add Main Entity Columns + if (options.Select?.Count > 0) + { + foreach (var s in options.Select) + { + selectParts.Add(QuoteColumn(mapping.GetColumnName(s), mapping)); + } + } + else if (options.GroupBy?.Count > 0) + { + 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)); + } + } + + // 2. Add Included Entity Columns (Flat mapping for Dapper) + if (options.Includes != null) + { + foreach (var include in options.Includes) + { + var rel = mapping.GetRelationship(include); + if (rel != null) + { + 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(rel.NavigationPropertyName + "_" + col); + selectParts.Add($"{quotedAlias}.{quotedCol} AS {aliasForHydration}"); + } + } + } + } + + 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 rel = mapping.GetRelationship(filteredInclude.Path); + if (rel == null) continue; + + var node = new FlexQuery.NET.Dapper.Sql.Ast.IncludeNode + { + NavigationProperty = rel.NavigationPropertyName, + Filter = filteredInclude.Filter + }; + + var sql = _includeTranslator.Translate(node, mapping, filterGroup => + { + var targetMapping = _mappingRegistry.GetMapping(rel.TargetType!); + return BuildFilterGroupExpression(filterGroup, targetMapping, parameters); + }, _mappingRegistry); + + if (!string.IsNullOrEmpty(sql)) joins.Add(sql); + } + } + + // 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, _mappingRegistry); + 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) + { + if (group == null) return string.Empty; + 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 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) + { + var node = new AllExpressionNode { NavigationProperty = condition.Field, ScopedFilter = condition.ScopedFilter }; + return _existsTranslator.TranslateAll(node, mapping, group => + { + 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) + { + 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 rel = mapping.GetRelationship(condition.Field); + var targetMapping = rel?.TargetType != null ? _mappingRegistry.GetMapping(rel.TargetType) : mapping; + return BuildFilterGroupExpression(group, targetMapping, parameters); + }, parameters, NextParam, _mappingRegistry); + } + + 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.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.Field, condition.Value, op, mapping, parameters) + }; + } + + private string BuildComparisonExpression(string quotedColumn, string field, string? value, string op, IEntityMapping mapping, Dictionary parameters) + { + var paramName = NextParam(); + parameters[paramName] = ConvertValue(field, value, mapping); + 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 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]] = ConvertValue(field, values[i], mapping); + } + return $"{quotedColumn} IN ({string.Join(", ", paramNames)})"; + } + + 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] = 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(); + 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 $"{_dialect.QuoteIdentifier(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(); + + 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) + { + 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/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.EFCore/FlexQuery.NET.EFCore.csproj b/src/FlexQuery.NET.EFCore/FlexQuery.NET.EFCore.csproj index b127ac3..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 @@ -27,6 +27,7 @@ true false true + 3.0.0 @@ -34,14 +35,14 @@ - - - - + + + + diff --git a/src/FlexQuery.NET.MiniOData/Extensions/ServiceCollectionExtensions.cs b/src/FlexQuery.NET.MiniOData/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..a1c1d9d --- /dev/null +++ b/src/FlexQuery.NET.MiniOData/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,52 @@ +using Microsoft.Extensions.DependencyInjection; +using FlexQuery.NET.Parsers; + +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(); + + // 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(); +} + +/// +/// 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..c6fb2b3 --- /dev/null +++ b/src/FlexQuery.NET.MiniOData/FlexQuery.NET.MiniOData.csproj @@ -0,0 +1,55 @@ + + + + net6.0;net8.0;net10.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/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.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/src/FlexQuery.NET/Caching/ParserCache.cs b/src/FlexQuery.NET/Caching/ParserCache.cs index ee60d72..7ad4b1a 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, @@ -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/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/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/FlexQuery.NET.csproj b/src/FlexQuery.NET/FlexQuery.NET.csproj index f387db5..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 @@ - - - - + + + + @@ -52,6 +52,7 @@ + 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/FlexQueryParameters.cs b/src/FlexQuery.NET/Models/FlexQueryParameters.cs index f5b5a21..88e25a1 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; } @@ -41,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/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/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/src/FlexQuery.NET/Models/QueryRequest.cs b/src/FlexQuery.NET/Models/QueryRequest.cs deleted file mode 100644 index 233f462..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? Includes { 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/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/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 208268a..ac98299 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 @@ -29,150 +30,89 @@ 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( @"^(?[A-Za-z_][A-Za-z0-9_\.]*)\.(?sum|count|max|min|avg)\((?[A-Za-z_][A-Za-z0-9_\.]*)?\)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); - // ── Public entry point ─────────────────────────────────────────────── + private static readonly List _parsers = new() + { + new JqlParser(), + new FlexQueryDslParser() + }; - /// - /// 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) + static QueryOptionsParser() { - ArgumentNullException.ThrowIfNull(request); + 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 + } + } - 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.Includes)) dict["include"] = request.Includes; - 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(); + /// + /// 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); - return ParseDictionary(dict); - } + // ── Public entry point ─────────────────────────────────────────────── /// /// 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.Includes, parameters.GroupBy, parameters.Having, + 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; + IQueryParser? parser = null; - // Mode - if (!string.IsNullOrWhiteSpace(parameters.Mode)) + if (syntax != QuerySyntax.AutoDetect) { - options.ProjectionMode = parameters.Mode.Trim().ToLowerInvariant() switch - { - "flat" => ProjectionMode.Flat, - "flat-mixed" => ProjectionMode.FlatMixed, - _ => ProjectionMode.Nested - }; + parser = _parsers.FirstOrDefault(p => p.Syntax == syntax); } - // Select - if (!string.IsNullOrWhiteSpace(parameters.Select)) + if (parser == null) { - ParseSelectWithAggregates(options, parameters.Select); + // Auto-detect or fallback + parser = _parsers.FirstOrDefault(p => p.CanParse(parameters)) ?? _parsers.Last(); } - // Grouping - if (!string.IsNullOrWhiteSpace(parameters.GroupBy)) - { - options.GroupBy = SplitCsv(parameters.GroupBy); - } - - // Having - if (!string.IsNullOrWhiteSpace(parameters.Having)) - { - options.Having = ParseHaving(parameters.Having); - } - - // Includes - if (!string.IsNullOrWhiteSpace(parameters.Includes)) - { - options.Includes = SplitCsv(parameters.Includes.Split('(')[0]); - options.FilteredIncludes = FilteredIncludeParser.Parse(parameters.Includes); - } - - // 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 +133,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 +203,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 +225,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 +265,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,15 +331,17 @@ 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()); 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, @@ -376,14 +363,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)); @@ -454,9 +466,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 @@ -477,11 +489,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); @@ -493,29 +505,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, @@ -552,10 +541,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; @@ -565,7 +554,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 []; @@ -588,29 +577,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/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/Dialects/DialectTests.cs b/tests/FlexQuery.NET.Tests/Dapper/Dialects/DialectTests.cs new file mode 100644 index 0000000..ee3744d --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Dapper/Dialects/DialectTests.cs @@ -0,0 +1,1088 @@ +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.Dapper.Mapping.Metadata; +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() + { + _registry.Entity() + .ToTable("users") + .HasMany(e => e.Roles) + .WithForeignKey("UserId"); + + 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(); + registry.Entity() + .ToTable("users") + .HasMany(e => e.Roles) + .WithForeignKey("UserId"); + + 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 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 new file mode 100644 index 0000000..1d973c1 --- /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("[TestEntities]"); // Convention-based table 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..ca822c8 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlTranslatorTests.cs @@ -0,0 +1,436 @@ +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.Dapper.Mapping.Metadata; +using FluentAssertions; + +namespace FlexQuery.NET.Tests.Dapper.Translation; + +public class SqlTranslatorTests +{ + private readonly IMappingRegistry _registry = new MappingRegistry(); + + public SqlTranslatorTests() + { + _registry.Entity().ToTable("roles"); + _registry.Entity().ToTable("users").HasMany(e => e.Roles).WithForeignKey("UserId"); + } + + 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("[roles].[UserId] = [users].[Id]"); + 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("[roles].[UserId] = [users].[Id]"); + 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("[Roles].[UserId] = [users].[Id]"); + 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 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 new file mode 100644 index 0000000..79484a1 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Fixtures/DemoApi.cs @@ -0,0 +1,158 @@ +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 FlexQuery.NET.Dapper.Mapping.Metadata; +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(); + + 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); + } + + 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 53190ef..23d95b5 100644 --- a/tests/FlexQuery.NET.Tests/FlexQuery.NET.Tests.csproj +++ b/tests/FlexQuery.NET.Tests/FlexQuery.NET.Tests.csproj @@ -8,25 +8,30 @@ false - + + + - - - + + + 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/MiniOData/MiniODataIntegrationTests.cs b/tests/FlexQuery.NET.Tests/MiniOData/MiniODataIntegrationTests.cs new file mode 100644 index 0000000..8d4bf80 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/MiniOData/MiniODataIntegrationTests.cs @@ -0,0 +1,89 @@ +using FlexQuery.NET.Models; +using FlexQuery.NET.Parsers; +using FlexQuery.NET.MiniOData.Parsers; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using FlexQuery.NET.MiniOData.Extensions; + +namespace FlexQuery.NET.Tests.MiniOData; + +public class MiniODataIntegrationTests +{ + public MiniODataIntegrationTests() + { + // Ensure MiniOData parser is registered for integration tests. + // This is normally handled by services.AddMiniOData() in a real app. + QueryOptionsParser.RegisterParser(new MiniODataParser()); + } + + [Fact] + public void AutoDetect_WithODataParameters_UsesMiniODataParser() + { + // Arrange + var parameters = new FlexQueryParameters + { + RawParameters = new Dictionary + { + ["$filter"] = "name eq 'john'", + ["$orderby"] = "age desc" + } + }; + + // Act + var options = QueryOptionsParser.Parse(parameters); + + // Assert + options.Filter.Should().NotBeNull(); + options.Filter!.Filters.Should().HaveCount(1); + options.Filter!.Filters[0].Field.Should().Be("name"); + options.Filter!.Filters[0].Value.Should().Be("john"); + + options.Sort.Should().HaveCount(1); + options.Sort[0].Field.Should().Be("age"); + options.Sort[0].Descending.Should().BeTrue(); + } + + [Fact] + public void AutoDetect_WithNativeParameters_UsesDslParser() + { + // Arrange + var parameters = new FlexQueryParameters + { + Filter = "name:eq:john", + Sort = "age:desc" + }; + + // Act + var options = QueryOptionsParser.Parse(parameters); + + // Assert + options.Filter.Should().NotBeNull(); + options.Filter!.Filters.Should().HaveCount(1); + options.Filter!.Filters[0].Field.Should().Be("name"); + options.Filter!.Filters[0].Value.Should().Be("john"); + + options.Sort.Should().HaveCount(1); + options.Sort[0].Field.Should().Be("age"); + options.Sort[0].Descending.Should().BeTrue(); + } + + [Fact] + public void ExplicitSyntax_OverridesAutoDetect() + { + // Arrange + var parameters = new FlexQueryParameters + { + Filter = "name eq 'john'" // OData syntax in Native DSL property + }; + + // Act & Assert + // This should fail or produce weird AST if parsed as DSL + var optionsDsl = QueryOptionsParser.Parse(parameters, QuerySyntax.NativeDsl); + // (In reality, DslParser might throw or ignore the 'eq') + + // This should work if forced to OData + var optionsOData = QueryOptionsParser.Parse(parameters, QuerySyntax.MiniOData); + optionsOData.Filter.Should().NotBeNull(); + optionsOData.Filter!.Filters[0].Value.Should().Be("john"); + } +} diff --git a/tests/FlexQuery.NET.Tests/MiniOData/MiniODataQueryParserTests.cs b/tests/FlexQuery.NET.Tests/MiniOData/MiniODataQueryParserTests.cs new file mode 100644 index 0000000..7456f57 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/MiniOData/MiniODataQueryParserTests.cs @@ -0,0 +1,328 @@ +using FlexQuery.NET.MiniOData.Parsers; +using FlexQuery.NET.Models; +using FluentAssertions; + +namespace FlexQuery.NET.Tests.MiniOData; + +/// +/// Tests for the MiniODataQueryParser — the main entry point that parses +/// all OData query parameters ($filter, $orderby, $select, $top, $skip, $expand, $count). +/// +public class MiniODataQueryParserTests +{ + // ======================== + // $filter + // ======================== + + [Fact] + public void Parse_Filter_TranslatesFilterExpression() + { + var queryParams = new Dictionary + { + ["$filter"] = "name eq 'john'" + }; + + var result = MiniODataQueryParser.Parse(queryParams); + + result.Filter.Should().NotBeNull(); + result.Filter!.Filters.Should().HaveCount(1); + result.Filter!.Filters[0].Field.Should().Be("name"); + } + + [Fact] + public void Parse_Filter_WithoutDollarPrefix_StillWorks() + { + var queryParams = new Dictionary + { + ["filter"] = "status eq 'active'" + }; + + var result = MiniODataQueryParser.Parse(queryParams); + + result.Filter.Should().NotBeNull(); + result.Filter!.Filters[0].Field.Should().Be("status"); + } + + // ======================== + // $orderby + // ======================== + + [Fact] + public void Parse_OrderBy_SingleAsc_ProducesSortNode() + { + var queryParams = new Dictionary + { + ["$orderby"] = "name" + }; + + var result = MiniODataQueryParser.Parse(queryParams); + + result.Sort.Should().HaveCount(1); + result.Sort[0].Field.Should().Be("name"); + result.Sort[0].Descending.Should().BeFalse(); + } + + [Fact] + public void Parse_OrderBy_SingleDesc_ProducesDescendingSort() + { + var queryParams = new Dictionary + { + ["$orderby"] = "createdAt desc" + }; + + var result = MiniODataQueryParser.Parse(queryParams); + + result.Sort.Should().HaveCount(1); + result.Sort[0].Field.Should().Be("createdAt"); + result.Sort[0].Descending.Should().BeTrue(); + } + + [Fact] + public void Parse_OrderBy_Multiple_ProducesMultipleSortNodes() + { + var queryParams = new Dictionary + { + ["$orderby"] = "lastName asc, createdAt desc" + }; + + var result = MiniODataQueryParser.Parse(queryParams); + + result.Sort.Should().HaveCount(2); + result.Sort[0].Field.Should().Be("lastName"); + result.Sort[0].Descending.Should().BeFalse(); + result.Sort[1].Field.Should().Be("createdAt"); + result.Sort[1].Descending.Should().BeTrue(); + } + + [Fact] + public void Parse_OrderBy_SlashPath_ConvertsToDotNotation() + { + var queryParams = new Dictionary + { + ["$orderby"] = "address/city desc" + }; + + var result = MiniODataQueryParser.Parse(queryParams); + + result.Sort[0].Field.Should().Be("address.city"); + } + + // ======================== + // $select + // ======================== + + [Fact] + public void Parse_Select_CommaSeparatedFields() + { + var queryParams = new Dictionary + { + ["$select"] = "id,name,email" + }; + + var result = MiniODataQueryParser.Parse(queryParams); + + result.Select.Should().BeEquivalentTo(new[] { "id", "name", "email" }); + } + + [Fact] + public void Parse_Select_SlashPaths_ConvertToDotNotation() + { + var queryParams = new Dictionary + { + ["$select"] = "id,profile/name,address/city" + }; + + var result = MiniODataQueryParser.Parse(queryParams); + + result.Select.Should().Contain("profile.name"); + result.Select.Should().Contain("address.city"); + } + + // ======================== + // $top + // ======================== + + [Fact] + public void Parse_Top_SetsPageSize() + { + var queryParams = new Dictionary + { + ["$top"] = "10" + }; + + var result = MiniODataQueryParser.Parse(queryParams); + + result.Paging.PageSize.Should().Be(10); + result.Top.Should().Be(10); + } + + // ======================== + // $skip + // ======================== + + [Fact] + public void Parse_Skip_SetsSkipCount() + { + var queryParams = new Dictionary + { + ["$skip"] = "20" + }; + + var result = MiniODataQueryParser.Parse(queryParams); + + result.Skip.Should().Be(20); + } + + [Fact] + public void Parse_SkipAndTop_CalculatesPage() + { + var queryParams = new Dictionary + { + ["$top"] = "10", + ["$skip"] = "20" + }; + + var result = MiniODataQueryParser.Parse(queryParams); + + result.Paging.Page.Should().Be(3); // skip 20 / top 10 + 1 = page 3 + result.Paging.PageSize.Should().Be(10); + } + + // ======================== + // $expand + // ======================== + + [Fact] + public void Parse_Expand_SingleNavigation() + { + var queryParams = new Dictionary + { + ["$expand"] = "orders" + }; + + var result = MiniODataQueryParser.Parse(queryParams); + + result.Includes.Should().BeEquivalentTo(new[] { "orders" }); + } + + [Fact] + public void Parse_Expand_MultipleNavigations() + { + var queryParams = new Dictionary + { + ["$expand"] = "orders,profile,addresses" + }; + + var result = MiniODataQueryParser.Parse(queryParams); + + result.Includes.Should().HaveCount(3); + result.Includes.Should().Contain("orders"); + result.Includes.Should().Contain("profile"); + result.Includes.Should().Contain("addresses"); + } + + // ======================== + // $count + // ======================== + + [Fact] + public void Parse_Count_True() + { + var queryParams = new Dictionary + { + ["$count"] = "true" + }; + + var result = MiniODataQueryParser.Parse(queryParams); + + result.IncludeCount.Should().BeTrue(); + } + + [Fact] + public void Parse_Count_False() + { + var queryParams = new Dictionary + { + ["$count"] = "false" + }; + + var result = MiniODataQueryParser.Parse(queryParams); + + result.IncludeCount.Should().BeFalse(); + } + + // ======================== + // Combined Parameters + // ======================== + + [Fact] + public void Parse_AllParameters_ProducesCompleteQueryOptions() + { + var queryParams = new Dictionary + { + ["$filter"] = "age gt 18 and status eq 'active'", + ["$orderby"] = "name asc, createdAt desc", + ["$select"] = "id,name,email", + ["$top"] = "25", + ["$skip"] = "50", + ["$expand"] = "orders,profile", + ["$count"] = "true" + }; + + var result = MiniODataQueryParser.Parse(queryParams); + + result.Filter.Should().NotBeNull(); + result.Filter!.Filters.Should().HaveCount(2); + result.Sort.Should().HaveCount(2); + result.Select.Should().HaveCount(3); + result.Paging.PageSize.Should().Be(25); + result.Skip.Should().Be(50); + result.Includes.Should().HaveCount(2); + result.IncludeCount.Should().BeTrue(); + } + + // ======================== + // Edge Cases + // ======================== + + [Fact] + public void Parse_EmptyParams_ReturnsDefaultQueryOptions() + { + var queryParams = new Dictionary(); + + var result = MiniODataQueryParser.Parse(queryParams); + + result.Filter.Should().BeNull(); + result.Sort.Should().BeEmpty(); + result.Select.Should().BeNull(); + result.Includes.Should().BeNull(); + } + + [Fact] + public void Parse_CaseInsensitiveKeys() + { + var queryParams = new Dictionary + { + ["$Filter"] = "name eq 'test'", + ["$OrderBy"] = "id desc" + }; + + var result = MiniODataQueryParser.Parse(queryParams); + + result.Filter.Should().NotBeNull(); + result.Sort.Should().HaveCount(1); + } + + [Fact] + public void Parse_InvalidTop_IsIgnored() + { + var queryParams = new Dictionary + { + ["$top"] = "not_a_number" + }; + + var result = MiniODataQueryParser.Parse(queryParams); + + result.Top.Should().BeNull(); + } +} diff --git a/tests/FlexQuery.NET.Tests/MiniOData/ODataDslEquivalenceTests.cs b/tests/FlexQuery.NET.Tests/MiniOData/ODataDslEquivalenceTests.cs new file mode 100644 index 0000000..ba7d8ce --- /dev/null +++ b/tests/FlexQuery.NET.Tests/MiniOData/ODataDslEquivalenceTests.cs @@ -0,0 +1,295 @@ +using FlexQuery.NET.Constants; +using FlexQuery.NET.MiniOData.Parsers; +using FlexQuery.NET.Models; +using FlexQuery.NET.Parsers; +using FlexQuery.NET.Parsers.Dsl; +using FluentAssertions; + +namespace FlexQuery.NET.Tests.MiniOData; + +/// +/// Equivalence tests verifying that Native DSL and Mini OData syntaxes +/// produce semantically identical AST structures. +/// This is the core contract: same query semantics regardless of input syntax. +/// +public class ODataDslEquivalenceTests +{ + // ======================== + // Simple Equality + // ======================== + + [Fact] + public void Equality_NativeDsl_And_OData_ProduceEquivalentAst() + { + // Native DSL + var dslFilter = ParseDsl("name:eq:john"); + + // Mini OData + var odataFilter = ODataFilterParser.Parse("name eq 'john'"); + + AssertEquivalentFilter(dslFilter, odataFilter, "name", FilterOperators.Equal, "john"); + } + + // ======================== + // Contains + // ======================== + + [Fact] + public void Contains_NativeDsl_And_OData_ProduceEquivalentAst() + { + var dslFilter = ParseDsl("name:contains:john"); + var odataFilter = ODataFilterParser.Parse("contains(name,'john')"); + + AssertEquivalentFilter(dslFilter, odataFilter, "name", FilterOperators.Contains, "john"); + } + + // ======================== + // StartsWith + // ======================== + + [Fact] + public void StartsWith_NativeDsl_And_OData_ProduceEquivalentAst() + { + var dslFilter = ParseDsl("name:startswith:jo"); + var odataFilter = ODataFilterParser.Parse("startswith(name,'jo')"); + + AssertEquivalentFilter(dslFilter, odataFilter, "name", FilterOperators.StartsWith, "jo"); + } + + // ======================== + // EndsWith + // ======================== + + [Fact] + public void EndsWith_NativeDsl_And_OData_ProduceEquivalentAst() + { + var dslFilter = ParseDsl("email:endswith:.com"); + var odataFilter = ODataFilterParser.Parse("endswith(email,'.com')"); + + AssertEquivalentFilter(dslFilter, odataFilter, "email", FilterOperators.EndsWith, ".com"); + } + + // ======================== + // Greater Than + // ======================== + + [Fact] + public void GreaterThan_NativeDsl_And_OData_ProduceEquivalentAst() + { + var dslFilter = ParseDsl("age:gt:18"); + var odataFilter = ODataFilterParser.Parse("age gt 18"); + + AssertEquivalentFilter(dslFilter, odataFilter, "age", FilterOperators.GreaterThan, "18"); + } + + // ======================== + // Compound AND + // ======================== + + [Fact] + public void CompoundAnd_NativeDsl_And_OData_ProduceEquivalentAst() + { + var dslFilter = ParseDsl("age:gt:18&status:eq:active"); + var odataFilter = ODataFilterParser.Parse("age gt 18 and status eq 'active'"); + + // Both should produce AND groups with 2 conditions + dslFilter.Logic.Should().Be(LogicOperator.And); + odataFilter.Logic.Should().Be(LogicOperator.And); + + dslFilter.Filters.Should().HaveCount(2); + odataFilter.Filters.Should().HaveCount(2); + + // Verify field names match + dslFilter.Filters[0].Field.Should().Be(odataFilter.Filters[0].Field); + dslFilter.Filters[1].Field.Should().Be(odataFilter.Filters[1].Field); + + // Verify operators match + dslFilter.Filters[0].Operator.Should().Be(odataFilter.Filters[0].Operator); + dslFilter.Filters[1].Operator.Should().Be(odataFilter.Filters[1].Operator); + } + + // ======================== + // Compound OR + // ======================== + + [Fact] + public void CompoundOr_NativeDsl_And_OData_ProduceEquivalentAst() + { + var dslFilter = ParseDsl("status:eq:active|status:eq:pending"); + var odataFilter = ODataFilterParser.Parse("status eq 'active' or status eq 'pending'"); + + dslFilter.Logic.Should().Be(LogicOperator.Or); + odataFilter.Logic.Should().Be(LogicOperator.Or); + + dslFilter.Filters.Should().HaveCount(2); + odataFilter.Filters.Should().HaveCount(2); + } + + // ======================== + // Null Check + // ======================== + + [Fact] + public void NullCheck_NativeDsl_And_OData_ProduceEquivalentAst() + { + var dslFilter = ParseDsl("deletedAt:isnull"); + var odataFilter = ODataFilterParser.Parse("deletedAt eq null"); + + dslFilter.Filters[0].Field.Should().Be(odataFilter.Filters[0].Field); + dslFilter.Filters[0].Operator.Should().Be(FilterOperators.IsNull); + odataFilter.Filters[0].Operator.Should().Be(FilterOperators.IsNull); + } + + // ======================== + // Any (Relationship) + // ======================== + + [Fact] + public void AnyRelationship_NativeDsl_And_OData_ProduceEquivalentAst() + { + var dslFilter = ParseDsl("orders.any(status:eq:Cancelled)"); + var odataFilter = ODataFilterParser.Parse("orders/any(o: o/status eq 'Cancelled')"); + + // Both should produce a FilterCondition with operator = "any" + dslFilter.Filters[0].Field.Should().Be("orders"); + odataFilter.Filters[0].Field.Should().Be("orders"); + + dslFilter.Filters[0].Operator.Should().Be("any"); + odataFilter.Filters[0].Operator.Should().Be("any"); + + // Both should have a scoped filter + dslFilter.Filters[0].ScopedFilter.Should().NotBeNull(); + odataFilter.Filters[0].ScopedFilter.Should().NotBeNull(); + + // Inner scoped filter: status eq 'Cancelled' + dslFilter.Filters[0].ScopedFilter!.Filters[0].Field.Should().Be("status"); + odataFilter.Filters[0].ScopedFilter!.Filters[0].Field.Should().Be("status"); + + dslFilter.Filters[0].ScopedFilter!.Filters[0].Value.Should().Be("Cancelled"); + odataFilter.Filters[0].ScopedFilter!.Filters[0].Value.Should().Be("Cancelled"); + } + + // ======================== + // Negation + // ======================== + + [Fact] + public void Negation_NativeDsl_And_OData_ProduceEquivalentAst() + { + var dslFilter = ParseDsl("!(status:eq:deleted)"); + var odataFilter = ODataFilterParser.Parse("not (status eq 'deleted')"); + + dslFilter.IsNegated.Should().BeTrue(); + odataFilter.IsNegated.Should().BeTrue(); + + dslFilter.Filters[0].Field.Should().Be("status"); + odataFilter.Filters[0].Field.Should().Be("status"); + } + + // ======================== + // OrderBy Equivalence + // ======================== + + [Fact] + public void OrderBy_NativeDsl_And_OData_ProduceEquivalentSortNodes() + { + // Native DSL: sort=createdAt:desc + var nativeParams = new FlexQueryParameters { Sort = "createdAt:desc" }; + var nativeOptions = QueryOptionsParser.Parse(nativeParams); + + // Mini OData: $orderby=createdAt desc + var odataParams = new Dictionary { ["$orderby"] = "createdAt desc" }; + var odataOptions = MiniODataQueryParser.Parse(odataParams); + + nativeOptions.Sort.Should().HaveCount(1); + odataOptions.Sort.Should().HaveCount(1); + + nativeOptions.Sort[0].Field.Should().Be(odataOptions.Sort[0].Field); + nativeOptions.Sort[0].Descending.Should().Be(odataOptions.Sort[0].Descending); + } + + // ======================== + // Select Equivalence + // ======================== + + [Fact] + public void Select_NativeDsl_And_OData_ProduceEquivalentProjection() + { + // Native DSL: select=id,name,email + var nativeParams = new FlexQueryParameters { Select = "id,name,email" }; + var nativeOptions = QueryOptionsParser.Parse(nativeParams); + + // Mini OData: $select=id,name,email + var odataParams = new Dictionary { ["$select"] = "id,name,email" }; + var odataOptions = MiniODataQueryParser.Parse(odataParams); + + nativeOptions.Select.Should().BeEquivalentTo(odataOptions.Select); + } + + // ======================== + // Pagination Equivalence + // ======================== + + [Fact] + public void Pagination_NativeDsl_And_OData_ProduceEquivalentPaging() + { + // Native DSL: page=3&pageSize=10 + var nativeParams = new FlexQueryParameters { Page = 3, PageSize = 10 }; + var nativeOptions = QueryOptionsParser.Parse(nativeParams); + + // Mini OData: $top=10&$skip=20 (page 3 with size 10 = skip 20) + var odataParams = new Dictionary + { + ["$top"] = "10", + ["$skip"] = "20" + }; + var odataOptions = MiniODataQueryParser.Parse(odataParams); + + nativeOptions.Paging.PageSize.Should().Be(odataOptions.Paging.PageSize); + nativeOptions.Paging.Page.Should().Be(odataOptions.Paging.Page); + } + + // ======================== + // Include / Expand Equivalence + // ======================== + + [Fact] + public void Include_NativeDsl_And_OData_ProduceEquivalentIncludes() + { + // Native DSL: include=orders,profile + var nativeParams = new FlexQueryParameters { Include = "orders,profile" }; + var nativeOptions = QueryOptionsParser.Parse(nativeParams); + + // Mini OData: $expand=orders,profile + var odataParams = new Dictionary { ["$expand"] = "orders,profile" }; + var odataOptions = MiniODataQueryParser.Parse(odataParams); + + nativeOptions.Includes.Should().BeEquivalentTo(odataOptions.Includes); + } + + // ======================== + // Helpers + // ======================== + + private static FilterGroup ParseDsl(string dsl) + { + var ast = DslParser.Parse(dsl); + return DslFilterConverter.ToFilterGroup(ast); + } + + private static void AssertEquivalentFilter(FilterGroup dsl, FilterGroup odata, + string expectedField, string expectedOp, string expectedValue) + { + dsl.Filters.Should().HaveCount(1); + odata.Filters.Should().HaveCount(1); + + dsl.Filters[0].Field.Should().Be(expectedField); + odata.Filters[0].Field.Should().Be(expectedField); + + dsl.Filters[0].Operator.Should().Be(expectedOp); + odata.Filters[0].Operator.Should().Be(expectedOp); + + dsl.Filters[0].Value.Should().Be(expectedValue); + odata.Filters[0].Value.Should().Be(expectedValue); + } +} diff --git a/tests/FlexQuery.NET.Tests/MiniOData/ODataFilterParserTests.cs b/tests/FlexQuery.NET.Tests/MiniOData/ODataFilterParserTests.cs new file mode 100644 index 0000000..4dc50e2 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/MiniOData/ODataFilterParserTests.cs @@ -0,0 +1,337 @@ +using FlexQuery.NET.Constants; +using FlexQuery.NET.MiniOData.Parsers; +using FluentAssertions; + +namespace FlexQuery.NET.Tests.MiniOData; + +/// +/// Tests for the OData filter expression parser. +/// Validates that all supported OData $filter syntax correctly produces +/// the unified FlexQuery FilterGroup AST. +/// +public class ODataFilterParserTests +{ + // ======================== + // Simple Comparisons + // ======================== + + [Fact] + public void Parse_EqualString_ProducesCorrectFilterGroup() + { + var result = ODataFilterParser.Parse("name eq 'john'"); + + result.Filters.Should().HaveCount(1); + result.Filters[0].Field.Should().Be("name"); + result.Filters[0].Operator.Should().Be(FilterOperators.Equal); + result.Filters[0].Value.Should().Be("john"); + } + + [Fact] + public void Parse_NotEqual_ProducesNeqOperator() + { + var result = ODataFilterParser.Parse("status ne 'inactive'"); + + result.Filters.Should().HaveCount(1); + result.Filters[0].Field.Should().Be("status"); + result.Filters[0].Operator.Should().Be(FilterOperators.NotEqual); + result.Filters[0].Value.Should().Be("inactive"); + } + + [Fact] + public void Parse_GreaterThan_ProducesGtOperator() + { + var result = ODataFilterParser.Parse("age gt 18"); + + result.Filters.Should().HaveCount(1); + result.Filters[0].Field.Should().Be("age"); + result.Filters[0].Operator.Should().Be(FilterOperators.GreaterThan); + result.Filters[0].Value.Should().Be("18"); + } + + [Fact] + public void Parse_GreaterThanOrEqual_ProducesGteOperator() + { + var result = ODataFilterParser.Parse("price ge 9.99"); + + result.Filters.Should().HaveCount(1); + result.Filters[0].Operator.Should().Be(FilterOperators.GreaterThanOrEq); + result.Filters[0].Value.Should().Be("9.99"); + } + + [Fact] + public void Parse_LessThan_ProducesLtOperator() + { + var result = ODataFilterParser.Parse("quantity lt 100"); + + result.Filters.Should().HaveCount(1); + result.Filters[0].Operator.Should().Be(FilterOperators.LessThan); + result.Filters[0].Value.Should().Be("100"); + } + + [Fact] + public void Parse_LessThanOrEqual_ProducesLteOperator() + { + var result = ODataFilterParser.Parse("score le 50"); + + result.Filters.Should().HaveCount(1); + result.Filters[0].Operator.Should().Be(FilterOperators.LessThanOrEq); + } + + // ======================== + // Boolean & Null Values + // ======================== + + [Fact] + public void Parse_BooleanTrue_ProducesTrueValue() + { + var result = ODataFilterParser.Parse("isActive eq true"); + + result.Filters.Should().HaveCount(1); + result.Filters[0].Value.Should().Be("true"); + } + + [Fact] + public void Parse_BooleanFalse_ProducesFalseValue() + { + var result = ODataFilterParser.Parse("isDeleted eq false"); + + result.Filters.Should().HaveCount(1); + result.Filters[0].Value.Should().Be("false"); + } + + [Fact] + public void Parse_NullCheck_ProducesIsNullOperator() + { + var result = ODataFilterParser.Parse("deletedAt eq null"); + + result.Filters.Should().HaveCount(1); + result.Filters[0].Operator.Should().Be(FilterOperators.IsNull); + } + + [Fact] + public void Parse_NotNullCheck_ProducesIsNotNullOperator() + { + var result = ODataFilterParser.Parse("email ne null"); + + result.Filters.Should().HaveCount(1); + result.Filters[0].Operator.Should().Be(FilterOperators.IsNotNull); + } + + // ======================== + // Function Calls + // ======================== + + [Fact] + public void Parse_Contains_ProducesContainsOperator() + { + var result = ODataFilterParser.Parse("contains(name,'john')"); + + result.Filters.Should().HaveCount(1); + result.Filters[0].Field.Should().Be("name"); + result.Filters[0].Operator.Should().Be(FilterOperators.Contains); + result.Filters[0].Value.Should().Be("john"); + } + + [Fact] + public void Parse_StartsWith_ProducesStartsWithOperator() + { + var result = ODataFilterParser.Parse("startswith(name,'jo')"); + + result.Filters.Should().HaveCount(1); + result.Filters[0].Operator.Should().Be(FilterOperators.StartsWith); + result.Filters[0].Value.Should().Be("jo"); + } + + [Fact] + public void Parse_EndsWith_ProducesEndsWithOperator() + { + var result = ODataFilterParser.Parse("endswith(email,'.com')"); + + result.Filters.Should().HaveCount(1); + result.Filters[0].Operator.Should().Be(FilterOperators.EndsWith); + result.Filters[0].Value.Should().Be(".com"); + } + + // ======================== + // Logical Operators + // ======================== + + [Fact] + public void Parse_And_CombinesFiltersWithAndLogic() + { + var result = ODataFilterParser.Parse("age gt 18 and status eq 'active'"); + + result.Logic.Should().Be(FlexQuery.NET.Models.LogicOperator.And); + result.Filters.Should().HaveCount(2); + result.Filters[0].Field.Should().Be("age"); + result.Filters[1].Field.Should().Be("status"); + } + + [Fact] + public void Parse_Or_CombinesFiltersWithOrLogic() + { + var result = ODataFilterParser.Parse("status eq 'active' or status eq 'pending'"); + + result.Logic.Should().Be(FlexQuery.NET.Models.LogicOperator.Or); + result.Filters.Should().HaveCount(2); + } + + [Fact] + public void Parse_Not_NegatesInnerGroup() + { + var result = ODataFilterParser.Parse("not (status eq 'deleted')"); + + result.IsNegated.Should().BeTrue(); + result.Filters.Should().HaveCount(1); + result.Filters[0].Field.Should().Be("status"); + } + + [Fact] + public void Parse_ComplexLogic_MixedAndOr() + { + var result = ODataFilterParser.Parse("name eq 'john' and (age gt 18 or status eq 'vip')"); + + // Should have AND at top level + result.Logic.Should().Be(FlexQuery.NET.Models.LogicOperator.And); + } + + // ======================== + // Grouping (Parentheses) + // ======================== + + [Fact] + public void Parse_Parentheses_RespectsGrouping() + { + var result = ODataFilterParser.Parse("(status eq 'active' or status eq 'pending') and age gt 18"); + + result.Logic.Should().Be(FlexQuery.NET.Models.LogicOperator.And); + } + + // ======================== + // Nested Property Paths + // ======================== + + [Fact] + public void Parse_SlashPath_ConvertsToDotNotation() + { + var result = ODataFilterParser.Parse("address/city eq 'NYC'"); + + result.Filters.Should().HaveCount(1); + result.Filters[0].Field.Should().Be("address.city"); + } + + [Fact] + public void Parse_DeepPath_ConvertsMultiLevelPath() + { + var result = ODataFilterParser.Parse("contains(profile/address/city,'York')"); + + result.Filters.Should().HaveCount(1); + result.Filters[0].Field.Should().Be("profile.address.city"); + } + + // ======================== + // Lambda Navigation (any/all) + // ======================== + + [Fact] + public void Parse_AnyLambda_ProducesAnyOperator() + { + var result = ODataFilterParser.Parse("orders/any(o: o/status eq 'Cancelled')"); + + result.Filters.Should().HaveCount(1); + result.Filters[0].Field.Should().Be("orders"); + result.Filters[0].Operator.Should().Be("any"); + result.Filters[0].ScopedFilter.Should().NotBeNull(); + result.Filters[0].ScopedFilter!.Filters.Should().HaveCount(1); + result.Filters[0].ScopedFilter!.Filters[0].Field.Should().Be("status"); + result.Filters[0].ScopedFilter!.Filters[0].Value.Should().Be("Cancelled"); + } + + [Fact] + public void Parse_AllLambda_ProducesAllOperator() + { + var result = ODataFilterParser.Parse("items/all(i: i/price gt 10)"); + + result.Filters.Should().HaveCount(1); + result.Filters[0].Field.Should().Be("items"); + result.Filters[0].Operator.Should().Be("all"); + result.Filters[0].ScopedFilter.Should().NotBeNull(); + } + + [Fact] + public void Parse_EmptyAny_ProducesAnyWithNoFilter() + { + var result = ODataFilterParser.Parse("orders/any()"); + + result.Filters.Should().HaveCount(1); + result.Filters[0].Operator.Should().Be("any"); + result.Filters[0].ScopedFilter.Should().BeNull(); + } + + // ======================== + // IN operator + // ======================== + + [Fact] + public void Parse_InOperator_ProducesInWithCommaSeparatedValues() + { + var result = ODataFilterParser.Parse("status in ('active','pending','review')"); + + result.Filters.Should().HaveCount(1); + result.Filters[0].Operator.Should().Be(FilterOperators.In); + result.Filters[0].Value.Should().Be("active,pending,review"); + } + + // ======================== + // Edge Cases + // ======================== + + [Fact] + public void Parse_EmptyString_ReturnsEmptyFilterGroup() + { + var result = ODataFilterParser.Parse(""); + result.Filters.Should().BeEmpty(); + result.Groups.Should().BeEmpty(); + } + + [Fact] + public void Parse_WhitespaceOnly_ReturnsEmptyFilterGroup() + { + var result = ODataFilterParser.Parse(" "); + result.Filters.Should().BeEmpty(); + } + + [Fact] + public void Parse_EscapedQuote_PreservesQuoteInValue() + { + var result = ODataFilterParser.Parse("name eq 'O''Brien'"); + + result.Filters.Should().HaveCount(1); + result.Filters[0].Value.Should().Be("O'Brien"); + } + + [Fact] + public void Parse_NegativeNumber_ParsesCorrectly() + { + var result = ODataFilterParser.Parse("balance lt -100"); + + result.Filters.Should().HaveCount(1); + result.Filters[0].Value.Should().Be("-100"); + } + + [Fact] + public void Parse_InvalidExpression_ThrowsParseException() + { + Action act = () => ODataFilterParser.Parse("name INVALID 'test'"); + act.Should().Throw(); + } + + [Fact] + public void Parse_MultipleAndsFlattened_ProducesAllConditions() + { + var result = ODataFilterParser.Parse("a eq '1' and b eq '2' and c eq '3'"); + + result.Logic.Should().Be(FlexQuery.NET.Models.LogicOperator.And); + result.Filters.Should().HaveCount(3); + } +} diff --git a/tests/FlexQuery.NET.Tests/Models/SqlAddress.cs b/tests/FlexQuery.NET.Tests/Models/SqlAddress.cs new file mode 100644 index 0000000..c388070 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Models/SqlAddress.cs @@ -0,0 +1,9 @@ +namespace FlexQuery.NET.Tests.Models; + +public sealed class SqlAddress +{ + public int Id { get; set; } + public string City { get; set; } = string.Empty; + public int CustomerId { get; set; } + public SqlCustomer Customer { get; set; } = null!; +} diff --git a/tests/FlexQuery.NET.Tests/Models/SqlCustomer.cs b/tests/FlexQuery.NET.Tests/Models/SqlCustomer.cs new file mode 100644 index 0000000..fc5f0f7 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Models/SqlCustomer.cs @@ -0,0 +1,10 @@ +namespace FlexQuery.NET.Tests.Models; + +public sealed class SqlCustomer +{ + public int Id { get; set; } + public string? Name { get; set; } + public string? Email { get; set; } + public SqlAddress? Address { get; set; } + public List Orders { get; set; } = []; +} diff --git a/tests/FlexQuery.NET.Tests/Models/SqlOrder.cs b/tests/FlexQuery.NET.Tests/Models/SqlOrder.cs new file mode 100644 index 0000000..361e029 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Models/SqlOrder.cs @@ -0,0 +1,12 @@ +namespace FlexQuery.NET.Tests.Models; + +public sealed class SqlOrder +{ + public int Id { get; set; } + public string Number { get; set; } = string.Empty; + public decimal Total { get; set; } + public DateTime OrderDate { get; set; } + public int CustomerId { get; set; } + public SqlCustomer Customer { get; set; } = null!; + public List Items { get; set; } = []; +} diff --git a/tests/FlexQuery.NET.Tests/Models/SqlOrderItem.cs b/tests/FlexQuery.NET.Tests/Models/SqlOrderItem.cs new file mode 100644 index 0000000..07a47f8 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Models/SqlOrderItem.cs @@ -0,0 +1,9 @@ +namespace FlexQuery.NET.Tests.Models; + +public sealed class SqlOrderItem +{ + public int Id { get; set; } + public string Sku { get; set; } = string.Empty; + public int OrderId { get; set; } + public SqlOrder Order { get; set; } = null!; +} diff --git a/tests/FlexQuery.NET.Tests/Tests/ParserTests.cs b/tests/FlexQuery.NET.Tests/Tests/ParserTests.cs index 64f3971..dc897d2 100644 --- a/tests/FlexQuery.NET.Tests/Tests/ParserTests.cs +++ b/tests/FlexQuery.NET.Tests/Tests/ParserTests.cs @@ -2,6 +2,7 @@ using FlexQuery.NET.Models; using FlexQuery.NET.Parsers; using FlexQuery.NET.Parsers.Jql; +using JqlParser = FlexQuery.NET.Parsers.Jql.JqlParser; using FluentAssertions; using Microsoft.Extensions.Primitives; diff --git a/tests/FlexQuery.NET.Tests/Tests/WildcardProjectionTests.cs b/tests/FlexQuery.NET.Tests/Tests/WildcardProjectionTests.cs index b1e92fd..39720ef 100644 --- a/tests/FlexQuery.NET.Tests/Tests/WildcardProjectionTests.cs +++ b/tests/FlexQuery.NET.Tests/Tests/WildcardProjectionTests.cs @@ -43,7 +43,7 @@ public async Task ApplySelect_WithWildcard_IncludesAllScalars() // Should have all scalars of SqlOrder order.GetType().GetProperty("Number").Should().NotBeNull(); order.GetType().GetProperty("Total").Should().NotBeNull(); - order.GetType().GetProperty("CreatedAtUtc").Should().NotBeNull(); + order.GetType().GetProperty("OrderDate").Should().NotBeNull(); // Should NOT have navigations (unless specified) order.GetType().GetProperty("Items").Should().BeNull();