diff --git a/src/FlexQuery.NET.Dapper/Configuration/FlexQueryDapper.cs b/src/FlexQuery.NET.Dapper/Configuration/FlexQueryDapper.cs index 54479bc..ac138a3 100644 --- a/src/FlexQuery.NET.Dapper/Configuration/FlexQueryDapper.cs +++ b/src/FlexQuery.NET.Dapper/Configuration/FlexQueryDapper.cs @@ -9,29 +9,77 @@ namespace FlexQuery.NET.Dapper.Configuration; /// public static class FlexQueryDapper { + private static FlexQueryModel? _defaultModel; + private static DapperQueryOptions _defaultOptions = new(); + private static bool _configured; + private static readonly object _lock = new(); + /// /// Gets the global entity-mapping model configured during application startup. /// Used internally by FlexQueryAsync when a per-request model is not supplied. + /// Returns null if has not been called. /// - internal static FlexQueryModel? DefaultModel { get; set; } + internal static FlexQueryModel? DefaultModel + { + get + { + lock (_lock) + { + return _defaultModel; + } + } + } - internal static DapperQueryOptions DefaultOptions { get; set; } = new(); + /// + /// Gets the global Dapper query options configured during application startup. + /// + internal static DapperQueryOptions DefaultOptions + { + get + { + lock (_lock) + { + return _defaultOptions; + } + } + } /// /// Configures the Dapper entity-mapping model used by FlexQuery at runtime. /// Must be called once during application startup, before executing any queries. + /// After the first call, the configuration becomes immutable. /// /// /// An optional delegate used to configure FlexQuery Dapper entity mappings. /// + /// Thrown when configuration is attempted after queries have already been executed. public static void Configure(Action? configure = null) { + lock (_lock) + { + if (_configured) + throw new InvalidOperationException("FlexQueryDapper has already been configured and is now immutable. Configure must be called before any queries are executed."); + + var options = new FlexQueryDapperOptions(); + configure?.Invoke(options); - var options = new FlexQueryDapperOptions(); - configure?.Invoke(options); + _defaultOptions.UseModel(options.Model.Build()); + _defaultOptions.CommandTimeout = options.CommandTimeout; + _defaultModel = options.Model.Build(); + _configured = true; + } + } - DefaultOptions.UseModel(options.Model.Build()); - DefaultOptions.CommandTimeout = options.CommandTimeout; - DefaultModel = options.Model.Build(); + /// + /// Resets the configuration state. For testing only. + /// + internal static void Reset() + { + lock (_lock) + { + _defaultModel = null; + _defaultOptions = new DapperQueryOptions(); + _configured = false; + } } } diff --git a/src/FlexQuery.NET.EntityFrameworkCore/FlexQueryEFCore.cs b/src/FlexQuery.NET.EntityFrameworkCore/FlexQueryEFCore.cs index 259f7a3..13c7c5e 100644 --- a/src/FlexQuery.NET.EntityFrameworkCore/FlexQueryEFCore.cs +++ b/src/FlexQuery.NET.EntityFrameworkCore/FlexQueryEFCore.cs @@ -8,11 +8,25 @@ namespace FlexQuery.NET.EntityFrameworkCore; /// public static class FlexQueryEFCore { + private static FlexQueryEfCoreOptions? _defaultOptions; + private static bool _configured; + private static readonly object _lock = new(); + /// /// Gets the global EF Core execution options configured during application startup. /// Used by FlexQueryAsync when no per-execution options are supplied. + /// Returns null if has not been called. /// - internal static FlexQueryEfCoreOptions? DefaultOptions { get; private set; } + internal static FlexQueryEfCoreOptions? DefaultOptions + { + get + { + lock (_lock) + { + return _defaultOptions; + } + } + } /// /// Ensures EF Core-specific query operators are registered. @@ -27,15 +41,36 @@ public static void Setup() /// Configures the global EF Core execution options used by FlexQuery at runtime and /// ensures EF Core-specific query operators are registered. Must be called once during /// application startup, before executing any queries. + /// After the first call, the configuration becomes immutable. /// /// /// A delegate used to configure the global . /// + /// Thrown when configuration is attempted after queries have already been executed. public static void Configure(Action? configure = null) { - var options = new FlexQueryEfCoreOptions(); - configure?.Invoke(options); - DefaultOptions = options; - Setup(); + lock (_lock) + { + if (_configured) + throw new InvalidOperationException("FlexQueryEFCore has already been configured and is now immutable. Configure must be called before any queries are executed."); + + var options = new FlexQueryEfCoreOptions(); + configure?.Invoke(options); + _defaultOptions = options; + _configured = true; + Setup(); + } + } + + /// + /// Resets the configuration state. For testing only. + /// + internal static void Reset() + { + lock (_lock) + { + _defaultOptions = null; + _configured = false; + } } } diff --git a/src/FlexQuery.NET.OpenApi/FlexQuery.NET.OpenApi.csproj b/src/FlexQuery.NET.OpenApi/FlexQuery.NET.OpenApi.csproj index d49373a..d1d43bb 100644 --- a/src/FlexQuery.NET.OpenApi/FlexQuery.NET.OpenApi.csproj +++ b/src/FlexQuery.NET.OpenApi/FlexQuery.NET.OpenApi.csproj @@ -39,6 +39,7 @@ + diff --git a/src/FlexQuery.NET.Parsers.Fql/Parsing/FqlParseException.cs b/src/FlexQuery.NET.Parsers.Fql/Parsing/FqlParseException.cs index 95ff8c5..fe495eb 100644 --- a/src/FlexQuery.NET.Parsers.Fql/Parsing/FqlParseException.cs +++ b/src/FlexQuery.NET.Parsers.Fql/Parsing/FqlParseException.cs @@ -1,3 +1,5 @@ +using FlexQuery.NET.Exceptions; + namespace FlexQuery.NET.Parsers.Fql; /// @@ -13,7 +15,7 @@ namespace FlexQuery.NET.Parsers.Fql; /// /// Consumers should catch rather than this type. /// -public sealed class FqlParseException : Exception +public sealed class FqlParseException : FlexQueryException { /// Creates a with the specified error message. /// A description of the Fql grammar violation. diff --git a/src/FlexQuery.NET.Parsers.Fql/Parsing/FqlQueryParser.cs b/src/FlexQuery.NET.Parsers.Fql/Parsing/FqlQueryParser.cs index 490f419..cff1f6a 100644 --- a/src/FlexQuery.NET.Parsers.Fql/Parsing/FqlQueryParser.cs +++ b/src/FlexQuery.NET.Parsers.Fql/Parsing/FqlQueryParser.cs @@ -33,6 +33,18 @@ public QueryOptions Parse(FlexQueryParameters parameters) { var options = QueryOptionsFactory.Create(parameters); + if (!string.IsNullOrWhiteSpace(parameters.Select)) + { + try + { + FqlSelectParser.Parse(options, parameters.Select); + } + catch (FqlParseException ex) + { + throw new QueryParseException(QueryOptionKeys.Select, QuerySyntax.Fql, parameters.Select, ex); + } + } + if (!string.IsNullOrWhiteSpace(parameters.Filter)) { try diff --git a/src/FlexQuery.NET.Parsers.Fql/Parsing/FqlSelectParser.cs b/src/FlexQuery.NET.Parsers.Fql/Parsing/FqlSelectParser.cs new file mode 100644 index 0000000..0610eb6 --- /dev/null +++ b/src/FlexQuery.NET.Parsers.Fql/Parsing/FqlSelectParser.cs @@ -0,0 +1,59 @@ +using FlexQuery.NET.Models; +using FlexQuery.NET.Parsers; + +namespace FlexQuery.NET.Parsers.Fql; + +/// +/// Parses FQL SELECT expressions into a list of scalar field paths and expressions. +/// +internal static class FqlSelectParser +{ + /// + /// Parses an FQL select string into field paths on options.Select. + /// Accepts property paths, "AS" aliases, and expressions with parentheses. + /// + public static void Parse(QueryOptions options, string? rawSelect) + { + if (rawSelect is not null && string.IsNullOrWhiteSpace(rawSelect)) + throw new FqlParseException( + "The 'select' parameter value is empty. Expected comma-separated field paths or expressions."); + + var fields = ParserUtilities.SplitCsv(rawSelect); + var selectedFields = new List(fields.Count); + foreach (var field in fields) + { + if (field.Contains('(') || field.Contains(')')) + { + selectedFields.Add(field); + continue; + } + + var asIndex = field.IndexOf(" AS ", StringComparison.OrdinalIgnoreCase); + if (asIndex >= 0) + { + var pathPart = field[..asIndex].Trim(); + if (pathPart.Length > 0 && !ParserUtilities.IsValidPropertyPath(pathPart.AsSpan())) + throw new FqlParseException( + $"Invalid property path '{pathPart}' in 'select' parameter. " + + "Property paths must be dot-separated identifiers (e.g. 'Id' or 'Customer.Name')."); + + selectedFields.Add(field); + continue; + } + + if (field.Contains(':')) + throw new FqlParseException( + $"Invalid alias in 'select' parameter. " + + "FQL does not support colon-separated aliases. Use 'AS' syntax instead (e.g. 'Name AS FullName')."); + + if (!ParserUtilities.IsValidPropertyPath(field.AsSpan())) + throw new FqlParseException( + $"Invalid property path '{field}' in 'select' parameter. " + + "Property paths must be dot-separated identifiers (e.g. 'Id' or 'Customer.Name')."); + + selectedFields.Add(field); + } + + options.Select = selectedFields; + } +} diff --git a/src/FlexQuery.NET.Parsers.MiniOData/Parsers/MiniODataParseException.cs b/src/FlexQuery.NET.Parsers.MiniOData/Parsers/MiniODataParseException.cs index a09cbee..9a343d6 100644 --- a/src/FlexQuery.NET.Parsers.MiniOData/Parsers/MiniODataParseException.cs +++ b/src/FlexQuery.NET.Parsers.MiniOData/Parsers/MiniODataParseException.cs @@ -1,9 +1,11 @@ +using FlexQuery.NET.Exceptions; + namespace FlexQuery.NET.Parsers.MiniOData; /// /// Exception thrown when the Mini OData parser encounters invalid syntax. /// -public sealed class MiniODataParseException : Exception +public sealed class MiniODataParseException : FlexQueryException { /// Creates a new parse exception with the specified message. public MiniODataParseException(string message) : base(message) { } diff --git a/src/FlexQuery.NET/FlexQueryCore.cs b/src/FlexQuery.NET/FlexQueryCore.cs index d1eb92f..0432891 100644 --- a/src/FlexQuery.NET/FlexQueryCore.cs +++ b/src/FlexQuery.NET/FlexQueryCore.cs @@ -1,28 +1,70 @@ +using System.Threading; +using System.Threading; using FlexQuery.NET.Configuration; using FlexQuery.NET.Parsers; namespace FlexQuery.NET; /// -/// +/// Global FlexQuery configuration entry point. /// public static class FlexQueryCore { - internal static FlexQueryOptions DefaultOptions { get; set; } = new(); + private static FlexQueryOptions? _defaultOptions; + private static bool _configured; + private static readonly object _lock = new(); + + /// + /// Gets the global FlexQuery options configured during application startup. + /// Returns an empty options instance if has not been called. + /// + internal static FlexQueryOptions DefaultOptions + { + get + { + lock (_lock) + { + if (_defaultOptions is null) + _defaultOptions = new FlexQueryOptions(); + + return _defaultOptions; + } + } + } /// /// Configures the global FlexQuery options that serve as defaults for all queries. - /// Must be called once during application startup, before any queries are executed. + /// Must be called once during application startup, before executing any queries. + /// After the first call, the configuration becomes immutable. /// /// An optional delegate used to configure the global . - /// The configured instance. + /// Thrown when configuration is attempted after queries have already been executed. public static void Configure(Action? configure = null) { - var options = new FlexQueryOptions(); - configure?.Invoke(options); + lock (_lock) + { + if (_configured) + throw new InvalidOperationException("FlexQueryCore has already been configured and is now immutable. Configure must be called before any queries are executed."); - QueryOptionsParser.SetGlobalSyntax(options.QuerySyntax); + var options = new FlexQueryOptions(); + configure?.Invoke(options); - DefaultOptions = options; + QueryOptionsParser.SetGlobalSyntax(options.QuerySyntax); + + _defaultOptions = options; + _configured = true; + } + } + + /// + /// Resets the configuration state. For testing only. + /// + internal static void Reset() + { + lock (_lock) + { + _defaultOptions = null; + _configured = false; + } } } \ No newline at end of file diff --git a/src/FlexQuery.NET/Parsers/Dsl/Ast/DslParseException.cs b/src/FlexQuery.NET/Parsers/Dsl/Ast/DslParseException.cs index aa0fa4e..e49126e 100644 --- a/src/FlexQuery.NET/Parsers/Dsl/Ast/DslParseException.cs +++ b/src/FlexQuery.NET/Parsers/Dsl/Ast/DslParseException.cs @@ -1,3 +1,5 @@ +using FlexQuery.NET.Exceptions; + namespace FlexQuery.NET.Parsers.Dsl; /// @@ -10,7 +12,7 @@ namespace FlexQuery.NET.Parsers.Dsl; /// /// Consumers should catch rather than this type. /// -public sealed class DslParseException : Exception +public sealed class DslParseException : FlexQueryException { /// Creates a with the specified error message. /// A description of the DSL grammar violation. diff --git a/src/FlexQuery.NET/Parsers/Dsl/DslSelectParser.cs b/src/FlexQuery.NET/Parsers/Dsl/DslSelectParser.cs new file mode 100644 index 0000000..43b82dd --- /dev/null +++ b/src/FlexQuery.NET/Parsers/Dsl/DslSelectParser.cs @@ -0,0 +1,81 @@ +using FlexQuery.NET.Models; + +namespace FlexQuery.NET.Parsers.Dsl; + +/// +/// Parses DSL SELECT expressions into a list of scalar field paths. +/// +internal static class DslSelectParser +{ + /// + /// Parses a DSL select string into scalar field paths on options.Select. + /// + public static void Parse(QueryOptions options, string? rawSelect) + { + if (rawSelect is not null && string.IsNullOrWhiteSpace(rawSelect)) + throw new DslParseException( + $"The 'select' parameter value is empty. Expected comma-separated field paths."); + + var fields = ParserUtilities.SplitCsv(rawSelect); + var validated = new List(fields.Count); + foreach (var field in fields) + { + var asIndex = field.IndexOf(" AS ", StringComparison.OrdinalIgnoreCase); + if (asIndex >= 0) + { + var pathPart = field[..asIndex].Trim(); + if (pathPart.Length > 0 && !ParserUtilities.IsValidPropertyPath(pathPart.AsSpan())) + throw new DslParseException( + $"Invalid property path '{pathPart}' in 'select' parameter. " + + "Property paths must be dot-separated identifiers (e.g. 'Id' or 'Customer.Name')."); + + validated.Add(field); + continue; + } + + var colonIndex = field.IndexOf(':'); + if (colonIndex >= 0) + { + if (field.IndexOf(':', colonIndex + 1) >= 0) + throw new DslParseException( + $"Invalid alias in 'select' parameter. " + + "Colon-separated aliases must contain exactly one colon (e.g. 'Name:FullName')."); + + var rawPath = field[..colonIndex].Trim(); + var rawAlias = field[(colonIndex + 1)..].Trim(); + + if (rawAlias.Length == 0) + throw new DslParseException( + $"Invalid alias in 'select' parameter. " + + "Alias must be a non-empty identifier (e.g. 'Name:FullName')."); + + if (string.Equals(rawAlias, "AS", StringComparison.OrdinalIgnoreCase)) + throw new DslParseException( + $"Invalid alias in 'select' parameter. " + + "The identifier 'AS' is a reserved keyword and cannot be used as an alias."); + + if (!ParserUtilities.IsValidPropertyPath(rawPath.AsSpan())) + throw new DslParseException( + $"Invalid property path '{rawPath}' in 'select' parameter. " + + "Property paths must be dot-separated identifiers (e.g. 'Id' or 'Customer.Name')."); + + if (!ParserUtilities.IsValidPropertyPath(rawAlias.AsSpan())) + throw new DslParseException( + $"Invalid alias '{rawAlias}' in 'select' parameter. " + + "Aliases must be valid identifiers (e.g. 'FullName')."); + + validated.Add($"{rawPath} AS {rawAlias}"); + continue; + } + + if (!ParserUtilities.IsValidPropertyPath(field.AsSpan())) + throw new DslParseException( + $"Invalid property path '{field}' in 'select' parameter. " + + "Property paths must be dot-separated identifiers (e.g. 'Id' or 'Customer.Name')."); + + validated.Add(field); + } + + options.Select = validated; + } +} diff --git a/src/FlexQuery.NET/Parsers/DslAggregateParser.cs b/src/FlexQuery.NET/Parsers/DslAggregateParser.cs index 51a83dd..2f20832 100644 --- a/src/FlexQuery.NET/Parsers/DslAggregateParser.cs +++ b/src/FlexQuery.NET/Parsers/DslAggregateParser.cs @@ -25,20 +25,15 @@ public static List Parse(string? rawAggregates) if (parts.Length < 2) throw new DslParseException( $"Unable to parse aggregate expression '{rawAggregates}'. " + - $"Expected format: Field:Function[:Alias]. Invalid item '{trimmed}'."); - - var field = parts[0]; - if (field != "*" && !ParserUtilities.IsValidPropertyPath(field.AsSpan())) - throw new DslParseException( - $"Invalid field '{field}' in aggregate expression '{rawAggregates}'. " + - "Field must be a valid property path."); + $"Expected format: Function:Field[:Alias]. Invalid item '{trimmed}'."); + string? aggregateField; AggregateFunction function; string functionName; try { - functionName = parts[1].ToLowerInvariant(); + functionName = parts[0].ToLowerInvariant(); if (functionName == "average") functionName = "avg"; function = AggregateFunctionConverter.Parse(functionName); } @@ -46,11 +41,17 @@ public static List Parse(string? rawAggregates) { throw new DslParseException( $"Unable to parse aggregate expression '{rawAggregates}'. " + - $"Unrecognized aggregate function '{parts[1]}' at '{trimmed}'."); + $"Unrecognized aggregate function '{parts[0]}' at '{trimmed}'."); } + var field = parts[1]; + if (field != "*" && !ParserUtilities.IsValidPropertyPath(field.AsSpan())) + throw new DslParseException( + $"Invalid field '{field}' in aggregate expression '{rawAggregates}'. " + + "Field must be a valid property path."); + + aggregateField = field == "*" ? null : field; string? alias = parts.Length >= 3 ? parts[2] : null; - string? aggregateField = field == "*" ? null : field; result.Add(new AggregateModel { @@ -64,7 +65,7 @@ public static List Parse(string? rawAggregates) { throw new DslParseException( $"Unable to parse aggregate expression '{rawAggregates}'. " + - $"Expected format: Field:Function[:Alias]. No valid aggregate expressions found."); + $"Expected format: Function:Field[:Alias]. No valid aggregate expressions found."); } return result; diff --git a/src/FlexQuery.NET/Parsers/DslQueryParser.cs b/src/FlexQuery.NET/Parsers/DslQueryParser.cs index 2aaea5b..1fe0530 100644 --- a/src/FlexQuery.NET/Parsers/DslQueryParser.cs +++ b/src/FlexQuery.NET/Parsers/DslQueryParser.cs @@ -32,6 +32,18 @@ public QueryOptions Parse(FlexQueryParameters parameters) ex); } + if (!string.IsNullOrWhiteSpace(parameters.Select)) + { + try + { + DslSelectParser.Parse(options, parameters.Select); + } + catch (DslParseException ex) + { + throw new QueryParseException(QueryOptionKeys.Select, QuerySyntax.NativeDsl, parameters.Select, ex); + } + } + if (!string.IsNullOrWhiteSpace(parameters.Filter)) { try diff --git a/src/FlexQuery.NET/Parsers/QueryOptionsFactory.cs b/src/FlexQuery.NET/Parsers/QueryOptionsFactory.cs index af2b2fa..bb1e29e 100644 --- a/src/FlexQuery.NET/Parsers/QueryOptionsFactory.cs +++ b/src/FlexQuery.NET/Parsers/QueryOptionsFactory.cs @@ -23,9 +23,6 @@ public static QueryOptions Create(FlexQueryParameters parameters) Distinct = parameters.Distinct ?? false }; - if (!string.IsNullOrWhiteSpace(parameters.Select)) - SelectParser.Parse(options, parameters.Select); - if (!string.IsNullOrWhiteSpace(parameters.GroupBy)) { var groups = ParserUtilities.SplitCsv(parameters.GroupBy); diff --git a/src/FlexQuery.NET/Parsers/QueryParserRegistry.cs b/src/FlexQuery.NET/Parsers/QueryParserRegistry.cs index c863314..1f15717 100644 --- a/src/FlexQuery.NET/Parsers/QueryParserRegistry.cs +++ b/src/FlexQuery.NET/Parsers/QueryParserRegistry.cs @@ -1,12 +1,9 @@ using FlexQuery.NET.Exceptions; using FlexQuery.NET.Models; - namespace FlexQuery.NET.Parsers; /// /// Tracks which query parsers are available, keyed by . -/// Optional parser packages self-register during DI; the active parser for a request is -/// resolved through . /// /// /// This component owns parser availability only. Parsing orchestration (request @@ -20,24 +17,40 @@ internal static class QueryParserRegistry [QuerySyntax.NativeDsl] = new DslQueryParser() }; + private static readonly object _lock = new(); + /// - /// Registers an available parser for the given syntax. Called by optional parser packages. + /// Registers an available parser for the given syntax. /// - public static void Register(QuerySyntax syntax, IQueryParser parser) => _available[syntax] = parser; + public static void Register(QuerySyntax syntax, IQueryParser parser) + { + lock (_lock) + { + _available[syntax] = parser; + } + } /// - /// Resolves the parser registered for . + /// Resolves the parser registered for the specified syntax. /// /// /// Thrown when no parser has been registered for . /// public static IQueryParser Resolve(QuerySyntax syntax) { - _available.TryGetValue(syntax, out var parser); - - return parser ?? throw new ParserNotRegisteredException(syntax); + lock (_lock) + { + _available.TryGetValue(syntax, out var parser); + return parser ?? throw new ParserNotRegisteredException(syntax); + } } /// Returns true if a parser has been registered for the given syntax. - public static bool IsRegistered(QuerySyntax syntax) => _available.ContainsKey(syntax); -} \ No newline at end of file + public static bool IsRegistered(QuerySyntax syntax) + { + lock (_lock) + { + return _available.ContainsKey(syntax); + } + } +} diff --git a/src/FlexQuery.NET/Parsers/SelectParser.cs b/src/FlexQuery.NET/Parsers/SelectParser.cs deleted file mode 100644 index 75830c4..0000000 --- a/src/FlexQuery.NET/Parsers/SelectParser.cs +++ /dev/null @@ -1,51 +0,0 @@ -using FlexQuery.NET.Models; -using FlexQuery.NET.Parsers.Dsl; - -namespace FlexQuery.NET.Parsers; - -/// -/// Parses SELECT expressions into a list of scalar field paths. -/// Aggregate expressions are no longer parsed from select; -/// they are handled by instead. -/// -internal static class SelectParser -{ - /// - /// Parses a select string into scalar field paths on options.Select. - /// Items that look like aggregate function calls (containing parentheses) - /// are included without path validation. - /// - public static void Parse(QueryOptions options, string? rawSelect) - { - if (rawSelect is not null && string.IsNullOrWhiteSpace(rawSelect)) - throw new DslParseException( - $"The 'select' parameter value is empty. Expected comma-separated field paths."); - - var fields = ParserUtilities.SplitCsv(rawSelect); - var validated = new List(fields.Count); - foreach (var field in fields) - { - // Skip property path validation for items that look like aggregate expressions (contain parentheses) - if (field.Contains('(') || field.Contains(')')) - { - validated.Add(field); - continue; - } - - // Extract the property path portion before " AS " for alias expressions - var pathPart = field; - var asIndex = field.IndexOf(" AS ", StringComparison.OrdinalIgnoreCase); - if (asIndex >= 0) - pathPart = field[..asIndex].Trim(); - - if (pathPart.Length > 0 && !ParserUtilities.IsValidPropertyPath(pathPart.AsSpan())) - throw new DslParseException( - $"Invalid property path '{pathPart}' in 'select' parameter. " + - "Property paths must be dot-separated identifiers (e.g. 'Id' or 'Customer.Name')."); - - validated.Add(field); - } - - options.Select = validated; - } -} \ No newline at end of file diff --git a/tests/FlexQuery.NET.OpenApi.Tests/Documentation/DocumentationContentTests.cs b/tests/FlexQuery.NET.OpenApi.Tests/Documentation/DocumentationContentTests.cs new file mode 100644 index 0000000..9e198b7 --- /dev/null +++ b/tests/FlexQuery.NET.OpenApi.Tests/Documentation/DocumentationContentTests.cs @@ -0,0 +1,36 @@ +using FlexQuery.NET.OpenApi.Documentation; +using Xunit; + +namespace FlexQuery.NET.OpenApi.Tests.Documentation; + +public class DocumentationContentTests +{ + [Fact] + public void ParameterDocumentation_ValuesAreNonEmpty() + { + ParameterDocumentation.Filter.Should().NotBeNullOrWhiteSpace(); + ParameterDocumentation.Sort.Should().NotBeNullOrWhiteSpace(); + ParameterDocumentation.Page.Should().NotBeNullOrWhiteSpace(); + ParameterDocumentation.PageSize.Should().NotBeNullOrWhiteSpace(); + ParameterDocumentation.Select.Should().NotBeNullOrWhiteSpace(); + ParameterDocumentation.Include.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public void SchemaDocumentation_ValuesAreNonEmpty() + { + SchemaDocumentation.FlexQueryRequest.Should().NotBeNullOrWhiteSpace(); + SchemaDocumentation.FlexQueryParameters.Should().NotBeNullOrWhiteSpace(); + SchemaDocumentation.QueryResult.Should().NotBeNullOrWhiteSpace(); + SchemaDocumentation.FilterGroup.Should().NotBeNullOrWhiteSpace(); + SchemaDocumentation.FilterCondition.Should().NotBeNullOrWhiteSpace(); + SchemaDocumentation.SortNode.Should().NotBeNullOrWhiteSpace(); + SchemaDocumentation.PagingOptions.Should().NotBeNullOrWhiteSpace(); + SchemaDocumentation.AggregateModel.Should().NotBeNullOrWhiteSpace(); + SchemaDocumentation.HavingCondition.Should().NotBeNullOrWhiteSpace(); + SchemaDocumentation.IncludeNode.Should().NotBeNullOrWhiteSpace(); + SchemaDocumentation.ProjectionMode.Should().NotBeNullOrWhiteSpace(); + SchemaDocumentation.LogicOperator.Should().NotBeNullOrWhiteSpace(); + SchemaDocumentation.AggregateFunction.Should().NotBeNullOrWhiteSpace(); + } +} diff --git a/tests/FlexQuery.NET.OpenApi.Tests/Documentation/ExampleProviderTests.cs b/tests/FlexQuery.NET.OpenApi.Tests/Documentation/ExampleProviderTests.cs new file mode 100644 index 0000000..66b30fb --- /dev/null +++ b/tests/FlexQuery.NET.OpenApi.Tests/Documentation/ExampleProviderTests.cs @@ -0,0 +1,43 @@ +using FlexQuery.NET.Models; +using FlexQuery.NET.OpenApi.Documentation; +using Xunit; + +namespace FlexQuery.NET.OpenApi.Tests.Documentation; + +public class ExampleProviderTests +{ + [Fact] + public void CreateRequestExample_IsConsistent() + { + var example = ExampleProvider.CreateRequestExample(); + + example.Should().NotBeNull(); + example.Filter.Should().NotBeNull(); + example.Sort.Should().NotBeEmpty(); + example.Aggregate.Should().NotBeEmpty(); + example.Paging.Should().NotBeNull(); + } + + [Fact] + public void CreateParametersExample_AliasesMatchAggregates() + { + var example = ExampleProvider.CreateParametersExample(); + + example.Should().NotBeNull(); + example.Aggregate.Should().Contain("TotalRevenue"); + example.Aggregate.Should().Contain("OrderCount"); + example.Aggregate.Should().Contain("AvgRating"); + example.Having.Should().Contain("TotalAmount"); + } + + [Fact] + public void CreateQueryResultExample_HasDataAndAggregates() + { + var example = ExampleProvider.CreateQueryResultExample(); + + example.Should().NotBeNull(); + example.Data.Should().NotBeEmpty(); + example.Aggregates.Should().NotBeEmpty(); + example.TotalCount.Should().Be(125); + } +} diff --git a/tests/FlexQuery.NET.OpenApi.Tests/Documentation/FlexQueryDocumentationRegistryTests.cs b/tests/FlexQuery.NET.OpenApi.Tests/Documentation/FlexQueryDocumentationRegistryTests.cs new file mode 100644 index 0000000..24b3a2d --- /dev/null +++ b/tests/FlexQuery.NET.OpenApi.Tests/Documentation/FlexQueryDocumentationRegistryTests.cs @@ -0,0 +1,48 @@ +using FlexQuery.NET.OpenApi.Documentation; +using FlexQuery.NET.Models; +using Xunit; + +namespace FlexQuery.NET.OpenApi.Tests.Documentation; + +public class FlexQueryDocumentationRegistryTests +{ + [Theory] + [InlineData(typeof(FlexQueryRequest))] + [InlineData(typeof(FlexQueryParameters))] + [InlineData(typeof(FilterGroup))] + [InlineData(typeof(FilterCondition))] + [InlineData(typeof(SortNode))] + [InlineData(typeof(PagingOptions))] + [InlineData(typeof(AggregateModel))] + [InlineData(typeof(HavingCondition))] + [InlineData(typeof(IncludeNode))] + [InlineData(typeof(ProjectionMode))] + [InlineData(typeof(LogicOperator))] + [InlineData(typeof(AggregateFunction))] + public void TryGet_RegisteredModelType_ReturnsTrue(Type type) + { + var found = FlexQueryDocumentationRegistry.TryGet(type, out var doc); + + found.Should().BeTrue(); + doc.Description.Should().NotBeNull(); + } + + [Fact] + public void TryGet_QueryResultGeneric_ReturnsTrue() + { + var found = FlexQueryDocumentationRegistry.TryGet(typeof(QueryResult>), out var doc); + + found.Should().BeTrue(); + doc.Description.Should().NotBeNull(); + doc.Example.Should().NotBeNull(); + } + + [Fact] + public void TryGet_UnknownType_ReturnsFalse() + { + var found = FlexQueryDocumentationRegistry.TryGet(typeof(string), out var doc); + + found.Should().BeFalse(); + doc.Should().Be(default(FlexQueryDocumentation)); + } +} diff --git a/tests/FlexQuery.NET.OpenApi.Tests/FlexQuery.NET.OpenApi.Tests.csproj b/tests/FlexQuery.NET.OpenApi.Tests/FlexQuery.NET.OpenApi.Tests.csproj new file mode 100644 index 0000000..98f9725 --- /dev/null +++ b/tests/FlexQuery.NET.OpenApi.Tests/FlexQuery.NET.OpenApi.Tests.csproj @@ -0,0 +1,25 @@ + + + + net9.0 + enable + enable + latest + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/tests/FlexQuery.NET.OpenApi.Tests/GlobalUsings.cs b/tests/FlexQuery.NET.OpenApi.Tests/GlobalUsings.cs new file mode 100644 index 0000000..6352ce5 --- /dev/null +++ b/tests/FlexQuery.NET.OpenApi.Tests/GlobalUsings.cs @@ -0,0 +1,8 @@ +global using Xunit; +global using FluentAssertions; +global using Microsoft.Extensions.DependencyInjection; +global using FlexQuery.NET.Models; +global using FlexQuery.NET.Models.Filters; +global using FlexQuery.NET.Models.Paging; +global using FlexQuery.NET.Models.Aggregates; +global using FlexQuery.NET.Models.Projection; diff --git a/tests/FlexQuery.NET.OpenApi.Tests/OpenApiOptionsExtensionsTests.cs b/tests/FlexQuery.NET.OpenApi.Tests/OpenApiOptionsExtensionsTests.cs new file mode 100644 index 0000000..8683dc2 --- /dev/null +++ b/tests/FlexQuery.NET.OpenApi.Tests/OpenApiOptionsExtensionsTests.cs @@ -0,0 +1,38 @@ +using FlexQuery.NET.OpenApi; +using Microsoft.AspNetCore.OpenApi; +using Xunit; + +namespace FlexQuery.NET.OpenApi.Tests; + +public class OpenApiOptionsExtensionsTests +{ + [Fact] + public void AddFlexQuery_RegistersTransformersWithoutThrowing() + { + // In Microsoft.AspNetCore.OpenApi 9.0.0 the transformer collections are not exposed + // publicly, so we verify the fluent registration completes and returns the same options. + // The actual transformer behavior is covered by FlexQuerySchemaTransformerTests and + // FlexQueryOperationTransformerTests. + var options = new OpenApiOptions(); + + var result = options.AddFlexQuery(); + + result.Should().BeSameAs(options); + } +} + +public class ServiceCollectionExtensionsTests +{ + [Fact] + public void AddFlexQueryOpenApi_RegistersWithoutThrowing() + { + var services = new ServiceCollection(); + + var result = services.AddFlexQueryOpenApi(); + + result.Should().BeSameAs(services); + + var act = () => services.BuildServiceProvider(); + act.Should().NotThrow(); + } +} diff --git a/tests/FlexQuery.NET.OpenApi.Tests/Transformers/TransformerTests.cs b/tests/FlexQuery.NET.OpenApi.Tests/Transformers/TransformerTests.cs new file mode 100644 index 0000000..cd6f78d --- /dev/null +++ b/tests/FlexQuery.NET.OpenApi.Tests/Transformers/TransformerTests.cs @@ -0,0 +1,91 @@ +using System.Text.Json; +using FlexQuery.NET.Models; +using FlexQuery.NET.OpenApi.Transformers; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi.Models; +using Xunit; + +namespace FlexQuery.NET.OpenApi.Tests.Transformers; + +public class FlexQuerySchemaTransformerTests +{ + private static OpenApiSchemaTransformerContext MakeContext(Type type) + { + return new OpenApiSchemaTransformerContext + { + DocumentName = "v1", + JsonTypeInfo = JsonSerializerOptions.Default.GetTypeInfo(type), + ParameterDescription = null, + JsonPropertyInfo = null, + ApplicationServices = null + }; + } + + [Fact] + public async Task TransformAsync_RegisteredType_SetsDescriptionAndExample() + { + var transformer = new FlexQuerySchemaTransformer(); + var schema = new OpenApiSchema(); + var context = MakeContext(typeof(FlexQueryRequest)); + + await transformer.TransformAsync(schema, context, CancellationToken.None); + + schema.Description.Should().NotBeNullOrWhiteSpace(); + schema.Example.Should().NotBeNull(); + } + + [Fact] + public async Task TransformAsync_UnregisteredType_IsNoOp() + { + var transformer = new FlexQuerySchemaTransformer(); + var schema = new OpenApiSchema { Description = "untouched" }; + var context = MakeContext(typeof(string)); + + await transformer.TransformAsync(schema, context, CancellationToken.None); + + schema.Description.Should().Be("untouched"); + schema.Example.Should().BeNull(); + } +} + +public class FlexQueryOperationTransformerTests +{ + [Fact] + public async Task TransformAsync_SetsDescriptionForKnownParameters() + { + var transformer = new FlexQueryOperationTransformer(); + var operation = new OpenApiOperation + { + Parameters = + { + new OpenApiParameter { Name = "filter" }, + new OpenApiParameter { Name = "sort" }, + new OpenApiParameter { Name = "page" }, + new OpenApiParameter { Name = "pageSize" }, + new OpenApiParameter { Name = "select" }, + new OpenApiParameter { Name = "include" }, + new OpenApiParameter { Name = "other" } + } + }; + + await transformer.TransformAsync(operation, default, CancellationToken.None); + + operation.Parameters.Should().Contain(p => p.Name == "filter" && !string.IsNullOrWhiteSpace(p.Description)); + operation.Parameters.Should().Contain(p => p.Name == "sort" && !string.IsNullOrWhiteSpace(p.Description)); + operation.Parameters.Should().Contain(p => p.Name == "page" && !string.IsNullOrWhiteSpace(p.Description)); + operation.Parameters.Should().Contain(p => p.Name == "pageSize" && !string.IsNullOrWhiteSpace(p.Description)); + operation.Parameters.Should().Contain(p => p.Name == "select" && !string.IsNullOrWhiteSpace(p.Description)); + operation.Parameters.Should().Contain(p => p.Name == "include" && !string.IsNullOrWhiteSpace(p.Description)); + operation.Parameters.Should().Contain(p => p.Name == "other" && p.Description == null); + } + + [Fact] + public async Task TransformAsync_NullParameters_IsNoOp() + { + var transformer = new FlexQueryOperationTransformer(); + var operation = new OpenApiOperation { Parameters = null }; + + var act = async () => await transformer.TransformAsync(operation, default, CancellationToken.None); + await act.Should().NotThrowAsync(); + } +} diff --git a/tests/FlexQuery.NET.Tests/Tests/AgGridQueryParserTests.cs b/tests/FlexQuery.NET.Tests/Adapters/AgGridQueryParserTests.cs similarity index 99% rename from tests/FlexQuery.NET.Tests/Tests/AgGridQueryParserTests.cs rename to tests/FlexQuery.NET.Tests/Adapters/AgGridQueryParserTests.cs index b722ad2..38c3650 100644 --- a/tests/FlexQuery.NET.Tests/Tests/AgGridQueryParserTests.cs +++ b/tests/FlexQuery.NET.Tests/Adapters/AgGridQueryParserTests.cs @@ -6,7 +6,7 @@ using FlexQuery.NET.Models.Aggregates; using FlexQuery.NET.Models.Filters; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Adapters; public class AgGridQueryParserTests { diff --git a/tests/FlexQuery.NET.Tests/Tests/AgGridQueryableExtensionsTests.cs b/tests/FlexQuery.NET.Tests/Adapters/AgGridQueryableExtensionsTests.cs similarity index 98% rename from tests/FlexQuery.NET.Tests/Tests/AgGridQueryableExtensionsTests.cs rename to tests/FlexQuery.NET.Tests/Adapters/AgGridQueryableExtensionsTests.cs index 1e25f8e..85da6e8 100644 --- a/tests/FlexQuery.NET.Tests/Tests/AgGridQueryableExtensionsTests.cs +++ b/tests/FlexQuery.NET.Tests/Adapters/AgGridQueryableExtensionsTests.cs @@ -4,7 +4,7 @@ using FlexQuery.NET.EntityFrameworkCore; using FlexQuery.NET.Exceptions; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Adapters; public class AgGridQueryableExtensionsTests { diff --git a/tests/FlexQuery.NET.Tests/Tests/AgGridResponseConverterTests.cs b/tests/FlexQuery.NET.Tests/Adapters/AgGridResponseConverterTests.cs similarity index 99% rename from tests/FlexQuery.NET.Tests/Tests/AgGridResponseConverterTests.cs rename to tests/FlexQuery.NET.Tests/Adapters/AgGridResponseConverterTests.cs index 65199ab..520f3ff 100644 --- a/tests/FlexQuery.NET.Tests/Tests/AgGridResponseConverterTests.cs +++ b/tests/FlexQuery.NET.Tests/Adapters/AgGridResponseConverterTests.cs @@ -3,7 +3,7 @@ using FlexQuery.NET.Adapters.AgGrid.Models; using FlexQuery.NET.Models; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Adapters; public class AgGridResponseConverterTests { diff --git a/tests/FlexQuery.NET.Tests/Tests/KendoQueryParserTests.cs b/tests/FlexQuery.NET.Tests/Adapters/KendoQueryParserTests.cs similarity index 99% rename from tests/FlexQuery.NET.Tests/Tests/KendoQueryParserTests.cs rename to tests/FlexQuery.NET.Tests/Adapters/KendoQueryParserTests.cs index 0c7e472..ad6fcd4 100644 --- a/tests/FlexQuery.NET.Tests/Tests/KendoQueryParserTests.cs +++ b/tests/FlexQuery.NET.Tests/Adapters/KendoQueryParserTests.cs @@ -6,7 +6,7 @@ using FlexQuery.NET.Models.Aggregates; using FlexQuery.NET.Models.Filters; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Adapters; public class KendoQueryParserTests { diff --git a/tests/FlexQuery.NET.Tests/AspNetCore/FieldAccessFilterFunctionalTests.cs b/tests/FlexQuery.NET.Tests/AspNetCore/FieldAccessFilterFunctionalTests.cs new file mode 100644 index 0000000..8f057be --- /dev/null +++ b/tests/FlexQuery.NET.Tests/AspNetCore/FieldAccessFilterFunctionalTests.cs @@ -0,0 +1,129 @@ +using System.Reflection; +using FlexQuery.NET.AspNetCore.Attributes; +using FlexQuery.NET.AspNetCore.Filters; +using FlexQuery.NET.Constants; +using FlexQuery.NET.Options; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; +using Xunit; + +namespace FlexQuery.NET.Tests.AspNetCore; + +public class FieldAccessFilterFunctionalTests +{ + [FieldAccess(Allowed = new[] { "Id" }, Filterable = new[] { "Name" })] + private class AttributedController + { + [FieldAccess(Allowed = new[] { "Name" }, Blocked = new[] { "Secret" }, DefaultSortField = "Name", DefaultSortDirection = "desc", MaxDepth = 3)] + public void ActionWithAttribute() { } + + public void ActionWithoutAttribute() { } + } + + private class PlainController + { + public void Action() { } + } + + private static ActionExecutingContext BuildContext(System.Reflection.MemberInfo? method, System.Reflection.MemberInfo? controller, HttpContext? httpContext = null) + { + httpContext ??= new DefaultHttpContext(); + var descriptor = new ControllerActionDescriptor + { + ControllerTypeInfo = (controller as System.Type)?.GetTypeInfo() ?? typeof(AttributedController).GetTypeInfo(), + MethodInfo = (method as System.Reflection.MethodInfo) ?? typeof(AttributedController).GetMethod("ActionWithoutAttribute")!, + ControllerName = "Attributed", + ActionName = "Action" + }; + + var actionContext = new ActionContext(httpContext, new RouteData(), descriptor); + return new ActionExecutingContext(actionContext, new List(), new Dictionary(), new object()); + } + + private static QueryExecutionOptions? OptionsFrom(ActionExecutingContext context) + => context.HttpContext.Items[ContextKeys.ExecutionOptions] as QueryExecutionOptions; + + [Fact] + public void OnActionExecuting_AppliesActionAttributeToExecutionOptions() + { + var method = typeof(AttributedController).GetMethod("ActionWithAttribute")!; + var ctx = BuildContext(method, typeof(AttributedController)); + + new FieldAccessFilter().OnActionExecuting(ctx); + + var options = OptionsFrom(ctx); + options.Should().NotBeNull(); + options!.AllowedFields.Should().Contain(new[] { "Name" }); + options.BlockedFields.Should().Contain(new[] { "Secret" }); + options.DefaultSortField.Should().Be("Name"); + options.DefaultSortDescending.Should().BeTrue(); + options.MaxFieldDepth.Should().Be(3); + } + + [Fact] + public void OnActionExecuting_NoAttribute_StoresNothing() + { + var ctx = BuildContext(typeof(PlainController).GetMethod("Action")!, typeof(PlainController)); + + new FieldAccessFilter().OnActionExecuting(ctx); + + ctx.HttpContext.Items.Keys.Should().NotContain(ContextKeys.ExecutionOptions); + } + + [Fact] + public void OnActionExecuting_ActionAttributeOverridesControllerAttribute() + { + var method = typeof(AttributedController).GetMethod("ActionWithAttribute")!; + var ctx = BuildContext(method, typeof(AttributedController)); + + new FieldAccessFilter().OnActionExecuting(ctx); + + var options = OptionsFrom(ctx); + // Action attribute Allowed=["Name"] wins over controller Allowed=["Id"]. + options!.AllowedFields.Should().BeEquivalentTo(new[] { "Name" }); + } + + [Fact] + public void OnActionExecuting_MergesWithExistingOptions() + { + var method = typeof(AttributedController).GetMethod("ActionWithAttribute")!; + var httpContext = new DefaultHttpContext(); + var existing = new QueryExecutionOptions { AllowedFields = new() { "X" } }; + httpContext.Items[ContextKeys.ExecutionOptions] = existing; + + var ctx = BuildContext(method, typeof(AttributedController), httpContext); + + new FieldAccessFilter().OnActionExecuting(ctx); + + var options = OptionsFrom(ctx); + options!.AllowedFields.Should().Contain("X"); + options.AllowedFields.Should().Contain("Name"); + } + + [Fact] + public void OnActionExecuting_DefaultSortDirectionCaseInsensitive() + { + var method = typeof(AttributedController).GetMethod("ActionWithAttribute")!; + var ctx = BuildContext(method, typeof(AttributedController)); + + new FieldAccessFilter().OnActionExecuting(ctx); + + OptionsFrom(ctx)!.DefaultSortDescending.Should().BeTrue(); + } + + [Fact] + public void FieldAccessAttribute_Defaults() + { + var attribute = new FieldAccessAttribute(); + + attribute.MaxDepth.Should().Be(-1); + attribute.Allowed.Should().BeNull(); + attribute.Blocked.Should().BeNull(); + attribute.Filterable.Should().BeNull(); + attribute.Sortable.Should().BeNull(); + } +} diff --git a/tests/FlexQuery.NET.Tests/AspNetCore/FieldAccessFilterTests.cs b/tests/FlexQuery.NET.Tests/AspNetCore/FieldAccessFilterTests.cs new file mode 100644 index 0000000..3dede3e --- /dev/null +++ b/tests/FlexQuery.NET.Tests/AspNetCore/FieldAccessFilterTests.cs @@ -0,0 +1,38 @@ +using FlexQuery.NET.AspNetCore.Filters; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace FlexQuery.NET.Tests.AspNetCore; + +public class FieldAccessFilterTests +{ + [Fact] + public void OnActionExecuting_DoesNotThrow_WithValidContext() + { + var services = new ServiceCollection(); + services.AddLogging(); + var sp = services.BuildServiceProvider(); + + var filter = new FieldAccessFilter(); + var httpContext = new DefaultHttpContext { RequestServices = sp }; + + var routeData = new RouteData(); + routeData.Values["controller"] = "Test"; + + var actionDescriptor = new ActionDescriptor(); + var actionContext = new ActionContext(httpContext, routeData, actionDescriptor); + var context = new ActionExecutingContext( + actionContext, + new List(), + new Dictionary(), + new object()); + + Action act = () => filter.OnActionExecuting(context); + act.Should().NotThrow(); + } +} diff --git a/tests/FlexQuery.NET.Tests/AspNetCore/HttpContextExtensionsTests.cs b/tests/FlexQuery.NET.Tests/AspNetCore/HttpContextExtensionsTests.cs new file mode 100644 index 0000000..bba067e --- /dev/null +++ b/tests/FlexQuery.NET.Tests/AspNetCore/HttpContextExtensionsTests.cs @@ -0,0 +1,67 @@ +using FlexQuery.NET.AspNetCore; +using FlexQuery.NET.Options; +using Microsoft.AspNetCore.Http; +using Xunit; + +namespace FlexQuery.NET.Tests.AspNetCore; + +public class HttpContextExtensionsTests +{ + [Fact] + public void GetFlexQueryExecutionOptions_NullContext_ReturnsDefault() + { + var options = ((HttpContext?)null).GetFlexQueryExecutionOptions(); + + options.Should().NotBeNull(); + options.Should().BeOfType(); + } + + [Fact] + public void GetFlexQueryExecutionOptions_NoItems_ReturnsDefault() + { + var context = new DefaultHttpContext(); + + var options = context.GetFlexQueryExecutionOptions(); + + options.Should().NotBeNull(); + options.Should().BeOfType(); + } + + [Fact] + public void GetFlexQueryExecutionOptions_StoredOptions_ReturnsStored() + { + var context = new DefaultHttpContext(); + var stored = new QueryExecutionOptions { DefaultSortField = "Name" }; + context.Items["ExecutionOptions"] = stored; + + var options = context.GetFlexQueryExecutionOptions(); + + options.Should().BeSameAs(stored); + } + + [Fact] + public void GetFlexQueryExecutionOptions_LegacyKey_ReturnsLegacy() + { + var context = new DefaultHttpContext(); + var legacy = new QueryExecutionOptions { DefaultSortField = "Id" }; + context.Items["FlexQueryExecutionOptions"] = legacy; + + var options = context.GetFlexQueryExecutionOptions(); + + options.Should().BeSameAs(legacy); + } + + [Fact] + public void GetFlexQueryExecutionOptions_NewerKeyPreferredOverLegacy() + { + var context = new DefaultHttpContext(); + var legacy = new QueryExecutionOptions { DefaultSortField = "Id" }; + var current = new QueryExecutionOptions { DefaultSortField = "Name" }; + context.Items["FlexQueryExecutionOptions"] = legacy; + context.Items["ExecutionOptions"] = current; + + var options = context.GetFlexQueryExecutionOptions(); + + options.Should().BeSameAs(current); + } +} diff --git a/tests/FlexQuery.NET.Tests/Tests/AggregateBuilderTests.cs b/tests/FlexQuery.NET.Tests/Builders/AggregateBuilderTests.cs similarity index 94% rename from tests/FlexQuery.NET.Tests/Tests/AggregateBuilderTests.cs rename to tests/FlexQuery.NET.Tests/Builders/AggregateBuilderTests.cs index fa0bdbb..f1cb7db 100644 --- a/tests/FlexQuery.NET.Tests/Tests/AggregateBuilderTests.cs +++ b/tests/FlexQuery.NET.Tests/Builders/AggregateBuilderTests.cs @@ -1,7 +1,7 @@ using FlexQuery.NET.Builders.Fluent; using FlexQuery.NET.Models.Aggregates; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Builders; public class AggregateBuilderTests { diff --git a/tests/FlexQuery.NET.Tests/Builders/AggregateResultBuilderTests.cs b/tests/FlexQuery.NET.Tests/Builders/AggregateResultBuilderTests.cs new file mode 100644 index 0000000..1d7aab3 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Builders/AggregateResultBuilderTests.cs @@ -0,0 +1,81 @@ +using FlexQuery.NET.Builders; +using FlexQuery.NET.Models.Aggregates; + +namespace FlexQuery.NET.Tests.Builders; + +public class AggregateResultBuilderTests +{ + private sealed class AggregateRow + { + public double TotalSum { get; set; } + public int IdCount { get; set; } + } + + [Fact] + public void Build_NullRow_ReturnsNull() + { + var result = AggregateResultBuilder.Build(null, []); + result.Should().BeNull(); + } + + [Fact] + public void Build_EmptyAggregates_ReturnsNull() + { + var row = new AggregateRow { TotalSum = 100, IdCount = 5 }; + + var result = AggregateResultBuilder.Build(row, []); + + result.Should().BeNull(); + } + + [Fact] + public void Build_MatchingAggregates_ReturnsGrandTotals() + { + var row = new AggregateRow { TotalSum = 250.0, IdCount = 10 }; + var aggregates = new List + { + new() { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }, + new() { Function = AggregateFunction.Count, Field = "Id", Alias = "IdCount" } + }; + + var result = AggregateResultBuilder.Build(row, aggregates); + + result.Should().NotBeNull(); + result!.Should().ContainKey("Total"); + result["Total"].Should().ContainKey("sum"); + result["Total"]["sum"].Should().Be(250.0); + result.Should().ContainKey("Id"); + result["Id"].Should().ContainKey("count"); + result["Id"]["count"].Should().Be(10); + } + + [Fact] + public void Build_PartialMatch_OnlyReturnsMatching() + { + var row = new AggregateRow { TotalSum = 100, IdCount = 5 }; + var aggregates = new List + { + new() { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" } + }; + + var result = AggregateResultBuilder.Build(row, aggregates); + + result.Should().NotBeNull(); + result!.Should().ContainKey("Total"); + result.Should().NotContainKey("Id"); + } + + [Fact] + public void Build_MissingAlias_ReturnsNullForUnmatchedProperty() + { + var row = new { OtherField = 42 }; + var aggregates = new List + { + new() { Function = AggregateFunction.Count, Field = "Id", Alias = "NonExistentProperty" } + }; + + var result = AggregateResultBuilder.Build(row, aggregates); + + result.Should().BeNull(); + } +} diff --git a/tests/FlexQuery.NET.Tests/Tests/DynamicTypeBuilderTests.cs b/tests/FlexQuery.NET.Tests/Builders/DynamicTypeBuilderTests.cs similarity index 99% rename from tests/FlexQuery.NET.Tests/Tests/DynamicTypeBuilderTests.cs rename to tests/FlexQuery.NET.Tests/Builders/DynamicTypeBuilderTests.cs index cf8a4af..90e2537 100644 --- a/tests/FlexQuery.NET.Tests/Tests/DynamicTypeBuilderTests.cs +++ b/tests/FlexQuery.NET.Tests/Builders/DynamicTypeBuilderTests.cs @@ -2,7 +2,7 @@ using System.Collections.Concurrent; using FlexQuery.NET.Builders; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Builders; public class DynamicTypeBuilderTests { diff --git a/tests/FlexQuery.NET.Tests/Tests/ExpandBuilderTests.cs b/tests/FlexQuery.NET.Tests/Builders/ExpandBuilderTests.cs similarity index 96% rename from tests/FlexQuery.NET.Tests/Tests/ExpandBuilderTests.cs rename to tests/FlexQuery.NET.Tests/Builders/ExpandBuilderTests.cs index 958c974..fa52e78 100644 --- a/tests/FlexQuery.NET.Tests/Tests/ExpandBuilderTests.cs +++ b/tests/FlexQuery.NET.Tests/Builders/ExpandBuilderTests.cs @@ -1,6 +1,6 @@ using FlexQuery.NET.Builders.Fluent; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Builders; public class ExpandBuilderTests { diff --git a/tests/FlexQuery.NET.Tests/Tests/FluentFilterBuilderTests.cs b/tests/FlexQuery.NET.Tests/Builders/FluentFilterBuilderTests.cs similarity index 99% rename from tests/FlexQuery.NET.Tests/Tests/FluentFilterBuilderTests.cs rename to tests/FlexQuery.NET.Tests/Builders/FluentFilterBuilderTests.cs index 098389d..12e0e13 100644 --- a/tests/FlexQuery.NET.Tests/Tests/FluentFilterBuilderTests.cs +++ b/tests/FlexQuery.NET.Tests/Builders/FluentFilterBuilderTests.cs @@ -1,7 +1,7 @@ using FlexQuery.NET.Models; using FlexQuery.NET.Models.Filters; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Builders; public class FluentFilterBuilderTests { diff --git a/tests/FlexQuery.NET.Tests/Tests/FluentQueryBuilderTests.cs b/tests/FlexQuery.NET.Tests/Builders/FluentQueryBuilderTests.cs similarity index 99% rename from tests/FlexQuery.NET.Tests/Tests/FluentQueryBuilderTests.cs rename to tests/FlexQuery.NET.Tests/Builders/FluentQueryBuilderTests.cs index 9f37c72..3c051e5 100644 --- a/tests/FlexQuery.NET.Tests/Tests/FluentQueryBuilderTests.cs +++ b/tests/FlexQuery.NET.Tests/Builders/FluentQueryBuilderTests.cs @@ -4,7 +4,7 @@ using FlexQuery.NET.Models.Filters; using FlexQuery.NET.Models.Projection; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Builders; public class FluentQueryBuilderTests { diff --git a/tests/FlexQuery.NET.Tests/Builders/GroupByBuilderTests.cs b/tests/FlexQuery.NET.Tests/Builders/GroupByBuilderTests.cs new file mode 100644 index 0000000..bfe01a8 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Builders/GroupByBuilderTests.cs @@ -0,0 +1,34 @@ +using FlexQuery.NET.Builders; +using FlexQuery.NET.Models; +using FlexQuery.NET.Models.Aggregates; + +namespace FlexQuery.NET.Tests.Builders; + +public class GroupByBuilderTests +{ + private sealed class TestItem + { + public int Id { get; set; } + public string Category { get; set; } = string.Empty; + public decimal Amount { get; set; } + } + + [Fact] + public void GetProjectionName_SimpleField_ReturnsField() + { + GroupByBuilder.GetProjectionName("Name").Should().Be("Name"); + } + + [Fact] + public void GetProjectionName_NestedField_ReturnsLastSegment() + { + GroupByBuilder.GetProjectionName("Profile.Name").Should().Be("Name"); + } + + [Fact] + public void GetProjectionName_RemovesUnderscores() + { + var result = GroupByBuilder.GetProjectionName("my_field"); + result.Should().NotContain("_"); + } +} diff --git a/tests/FlexQuery.NET.Tests/Tests/GroupByTests.cs b/tests/FlexQuery.NET.Tests/Builders/GroupByTests.cs similarity index 99% rename from tests/FlexQuery.NET.Tests/Tests/GroupByTests.cs rename to tests/FlexQuery.NET.Tests/Builders/GroupByTests.cs index 0a51eb7..e3d79ba 100644 --- a/tests/FlexQuery.NET.Tests/Tests/GroupByTests.cs +++ b/tests/FlexQuery.NET.Tests/Builders/GroupByTests.cs @@ -7,7 +7,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Primitives; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Builders; public class GroupByTests : IDisposable { @@ -31,7 +31,7 @@ public async Task GroupBy_SelectAggregates_Having_AppliesServerTranslatableShape { ["groupBy"] = "CustomerId", ["select"] = "CustomerId", - ["aggregate"] = "Total:sum,Id:count", + ["aggregate"] = "sum:Total,count:Id", ["having"] = "sum(Total):gt:100" }); @@ -51,7 +51,7 @@ public async Task GroupBy_LinqStyleAggregates_Works() { ["groupBy"] = "CustomerId", ["select"] = "CustomerId", - ["aggregate"] = "Total:sum,Id:count", + ["aggregate"] = "sum:Total,count:Id", }); options.Aggregates.Should().HaveCount(2, "it should have parsed two aggregates from the aggregate parameter"); diff --git a/tests/FlexQuery.NET.Tests/Builders/KeysetPaginationBuilderTests.cs b/tests/FlexQuery.NET.Tests/Builders/KeysetPaginationBuilderTests.cs new file mode 100644 index 0000000..413be44 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Builders/KeysetPaginationBuilderTests.cs @@ -0,0 +1,87 @@ +using System.Linq.Expressions; +using FlexQuery.NET.Builders; +using FlexQuery.NET.Internal; +using FlexQuery.NET.Models.Paging; + +namespace FlexQuery.NET.Tests.Builders; + +public class KeysetPaginationBuilderTests +{ + private sealed class TestEntity + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + } + + [Fact] + public void BuildOrderingInfos_EmptySorts_Throws() + { + var sorts = new List(); + + Action act = () => KeysetPaginationBuilder.BuildOrderingInfos(sorts); + + act.Should().Throw() + .WithMessage("*Keyset pagination requires at least one sort field*"); + } + + [Fact] + public void BuildOrderingInfos_WithSorts_ReturnsOrderings() + { + var sorts = new List + { + new() { Field = "Id", Descending = false }, + new() { Field = "Name", Descending = true } + }; + + var orderings = KeysetPaginationBuilder.BuildOrderingInfos(sorts); + + orderings.Should().HaveCount(2); + orderings[0].Descending.Should().BeFalse(); + orderings[1].Descending.Should().BeTrue(); + } + + [Fact] + public void BuildSeekPredicate_WithValues_ProducesValidExpression() + { + var sorts = new List { new() { Field = "Id", Descending = false } }; + var orderings = KeysetPaginationBuilder.BuildOrderingInfos(sorts); + var cursor = new KeysetCursor(5); + + var predicate = KeysetPaginationBuilder.BuildSeekPredicate(orderings, cursor.Values); + + predicate.Should().NotBeNull(); + predicate.Parameters.Should().ContainSingle(p => p.Type == typeof(TestEntity)); + } + + [Fact] + public void BuildSeekPredicate_EmptyOrderings_Throws() + { + var orderings = new List<(LambdaExpression, bool)>(); + var cursor = new KeysetCursor(1); + + Action act = () => KeysetPaginationBuilder.BuildSeekPredicate(orderings, cursor.Values); + + act.Should().Throw() + .WithMessage("*Keyset pagination requires at least one sort field*"); + } + + [Fact] + public void KeysetCursor_ConstructsWithValues() + { + var cursor = new KeysetCursor(1, "test", null); + + cursor.Values.Should().HaveCount(3); + cursor.Values[0].Should().Be(1); + cursor.Values[1].Should().Be("test"); + cursor.Values[2].Should().BeNull(); + } + + [Fact] + public void KeysetCursor_Empty_Constructs() + { + var cursor = new KeysetCursor(); + + cursor.Values.Should().BeEmpty(); + } +} diff --git a/tests/FlexQuery.NET.Tests/Tests/SortBuilderTests.cs b/tests/FlexQuery.NET.Tests/Builders/SortBuilderTests.cs similarity index 94% rename from tests/FlexQuery.NET.Tests/Tests/SortBuilderTests.cs rename to tests/FlexQuery.NET.Tests/Builders/SortBuilderTests.cs index 2f16363..5e2f7d0 100644 --- a/tests/FlexQuery.NET.Tests/Tests/SortBuilderTests.cs +++ b/tests/FlexQuery.NET.Tests/Builders/SortBuilderTests.cs @@ -1,7 +1,7 @@ using FlexQuery.NET.Builders.Fluent; using FlexQuery.NET.Models.Paging; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Builders; public class SortBuilderTests { diff --git a/tests/FlexQuery.NET.Tests/Caching/BoundedConcurrentCacheTests.cs b/tests/FlexQuery.NET.Tests/Caching/BoundedConcurrentCacheTests.cs new file mode 100644 index 0000000..01fba3b --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Caching/BoundedConcurrentCacheTests.cs @@ -0,0 +1,103 @@ +using FlexQuery.NET.Caching; + +namespace FlexQuery.NET.Tests.Caching; + +public class BoundedConcurrentCacheTests +{ + [Fact] + public void GetOrAdd_NewKey_AddsAndReturns() + { + var cache = new BoundedConcurrentCache(); + + var value = cache.GetOrAdd("key", _ => 42); + + value.Should().Be(42); + cache.Count.Should().Be(1); + } + + [Fact] + public void GetOrAdd_ExistingKey_ReturnsExisting() + { + var cache = new BoundedConcurrentCache(); + cache.GetOrAdd("key", _ => 42); + + var value = cache.GetOrAdd("key", _ => 99); + + value.Should().Be(42); + } + + [Fact] + public void TryGetValue_ExistingKey_ReturnsTrue() + { + var cache = new BoundedConcurrentCache(); + cache.GetOrAdd("key", _ => 42); + + var found = cache.TryGetValue("key", out var value); + + found.Should().BeTrue(); + value.Should().Be(42); + } + + [Fact] + public void TryGetValue_MissingKey_ReturnsFalse() + { + var cache = new BoundedConcurrentCache(); + + var found = cache.TryGetValue("nonexistent", out _); + + found.Should().BeFalse(); + } + + [Fact] + public void Set_AddsNewValue() + { + var cache = new BoundedConcurrentCache(); + + cache.Set("key", 42); + + cache.TryGetValue("key", out var value).Should().BeTrue(); + value.Should().Be(42); + } + + [Fact] + public void Set_OverwritesExistingValue() + { + var cache = new BoundedConcurrentCache(); + cache.Set("key", 42); + + cache.Set("key", 99); + + cache.TryGetValue("key", out var value).Should().BeTrue(); + value.Should().Be(99); + } + + [Fact] + public void Clear_RemovesAllEntries() + { + var cache = new BoundedConcurrentCache(); + cache.GetOrAdd("key1", _ => 1); + cache.GetOrAdd("key2", _ => 2); + + cache.Clear(); + + cache.Count.Should().Be(0); + } + + [Fact] + public void Clear_AfterClear_Reusable() + { + var cache = new BoundedConcurrentCache(); + cache.GetOrAdd("key", _ => 42); + cache.Clear(); + + cache.GetOrAdd("key", _ => 99).Should().Be(99); + } + + [Fact] + public void Count_StartsAtZero() + { + var cache = new BoundedConcurrentCache(); + + cache.Count.Should().Be(0); + } +} diff --git a/tests/FlexQuery.NET.Tests/Tests/CacheIsolationTests.cs b/tests/FlexQuery.NET.Tests/Caching/CacheIsolationTests.cs similarity index 98% rename from tests/FlexQuery.NET.Tests/Tests/CacheIsolationTests.cs rename to tests/FlexQuery.NET.Tests/Caching/CacheIsolationTests.cs index 1721e36..bdf5d83 100644 --- a/tests/FlexQuery.NET.Tests/Tests/CacheIsolationTests.cs +++ b/tests/FlexQuery.NET.Tests/Caching/CacheIsolationTests.cs @@ -1,7 +1,7 @@ using FlexQuery.NET.Parsers; using Microsoft.Extensions.Primitives; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Caching; public class CacheIsolationTests { diff --git a/tests/FlexQuery.NET.Tests/Tests/CacheKeyCorrectnessTests.cs b/tests/FlexQuery.NET.Tests/Caching/CacheKeyCorrectnessTests.cs similarity index 99% rename from tests/FlexQuery.NET.Tests/Tests/CacheKeyCorrectnessTests.cs rename to tests/FlexQuery.NET.Tests/Caching/CacheKeyCorrectnessTests.cs index 8301a09..29cfcef 100644 --- a/tests/FlexQuery.NET.Tests/Tests/CacheKeyCorrectnessTests.cs +++ b/tests/FlexQuery.NET.Tests/Caching/CacheKeyCorrectnessTests.cs @@ -6,7 +6,7 @@ using FlexQuery.NET.Models.Projection; using Microsoft.Extensions.Primitives; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Caching; public class CacheKeyCorrectnessTests { diff --git a/tests/FlexQuery.NET.Tests/Caching/QueryCacheKeyBuilderTests.cs b/tests/FlexQuery.NET.Tests/Caching/QueryCacheKeyBuilderTests.cs new file mode 100644 index 0000000..1c3c49b --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Caching/QueryCacheKeyBuilderTests.cs @@ -0,0 +1,158 @@ +using FlexQuery.NET.Caching; +using FlexQuery.NET.Models; +using FlexQuery.NET.Models.Aggregates; +using FlexQuery.NET.Models.Filters; +using FlexQuery.NET.Models.Paging; +using FlexQuery.NET.Models.Projection; + +namespace FlexQuery.NET.Tests.Caching; + +public class QueryCacheKeyBuilderTests +{ + private sealed class TestEntity + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + [Fact] + public void Build_EmptyOptions_ReturnsKey() + { + var options = new QueryOptions(); + var key = QueryCacheKeyBuilder.Build(options, typeof(TestEntity), "query"); + + key.Should().NotBeNullOrEmpty(); + key.Should().Contain("query"); + key.Should().Contain(typeof(TestEntity).FullName); + } + + [Fact] + public void Build_DifferentFilters_DifferentKeys() + { + var opts1 = new QueryOptions + { + Filter = new FilterGroup { Filters = [new FilterCondition { Field = "Name", Operator = "eq", Value = "Alice" }] } + }; + var opts2 = new QueryOptions + { + Filter = new FilterGroup { Filters = [new FilterCondition { Field = "Name", Operator = "eq", Value = "Bob" }] } + }; + + var key1 = QueryCacheKeyBuilder.Build(opts1, typeof(TestEntity), "query"); + var key2 = QueryCacheKeyBuilder.Build(opts2, typeof(TestEntity), "query"); + + key1.Should().NotBe(key2); + } + + [Fact] + public void Build_SameOptions_SameKey() + { + var opts1 = new QueryOptions + { + Filter = new FilterGroup { Filters = [new FilterCondition { Field = "Name", Operator = "eq", Value = "Alice" }] } + }; + var opts2 = new QueryOptions + { + Filter = new FilterGroup { Filters = [new FilterCondition { Field = "Name", Operator = "eq", Value = "Alice" }] } + }; + + var key1 = QueryCacheKeyBuilder.Build(opts1, typeof(TestEntity), "query"); + var key2 = QueryCacheKeyBuilder.Build(opts2, typeof(TestEntity), "query"); + + key1.Should().Be(key2); + } + + [Fact] + public void Build_DifferentEntityTypes_DifferentKeys() + { + var opts = new QueryOptions(); + + var key1 = QueryCacheKeyBuilder.Build(opts, typeof(TestEntity), "query"); + var key2 = QueryCacheKeyBuilder.Build(opts, typeof(string), "query"); + + key1.Should().NotBe(key2); + } + + [Fact] + public void Build_SortIncluded() + { + var options = new QueryOptions + { + Sort = [new SortNode { Field = "Name", Descending = false }] + }; + + var key = QueryCacheKeyBuilder.Build(options, typeof(TestEntity), "query"); + + key.Should().Contain("sort="); + } + + [Fact] + public void Build_SelectIncluded() + { + var options = new QueryOptions + { + Select = ["Id", "Name"] + }; + + var key = QueryCacheKeyBuilder.Build(options, typeof(TestEntity), "query"); + + key.Should().Contain("select="); + } + + [Fact] + public void Build_IncludesIncluded() + { + var options = new QueryOptions + { + Includes = ["Orders"] + }; + + var key = QueryCacheKeyBuilder.Build(options, typeof(TestEntity), "query"); + + key.Should().Contain("includes="); + } + + [Fact] + public void Build_GroupByAndAggregatesIncluded() + { + var options = new QueryOptions + { + GroupBy = ["Category"], + Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = "Id", Alias = "cnt" }] + }; + + var key = QueryCacheKeyBuilder.Build(options, typeof(TestEntity), "query"); + + key.Should().Contain("groupBy="); + key.Should().Contain("aggregates="); + } + + [Fact] + public void Build_DistinctIncluded() + { + var options = new QueryOptions { Distinct = true }; + + var key = QueryCacheKeyBuilder.Build(options, typeof(TestEntity), "query"); + + key.Should().Contain("distinct=True"); + } + + [Fact] + public void CanCache_AlwaysReturnsTrue() + { + QueryCacheKeyBuilder.CanCache(new QueryOptions()).Should().BeTrue(); + } + + [Fact] + public void Build_ExpandIncluded() + { + var options = new QueryOptions + { + Expand = [new IncludeNode { Path = "Orders" }] + }; + + var key = QueryCacheKeyBuilder.Build(options, typeof(TestEntity), "query"); + + key.Should().Contain("filteredIncludes="); + } +} diff --git a/tests/FlexQuery.NET.Tests/Caching/QueryCacheManagerTests.cs b/tests/FlexQuery.NET.Tests/Caching/QueryCacheManagerTests.cs new file mode 100644 index 0000000..08700cd --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Caching/QueryCacheManagerTests.cs @@ -0,0 +1,62 @@ +using System.Linq.Expressions; +using FlexQuery.NET.Caching; + +namespace FlexQuery.NET.Tests.Caching; + +public class QueryCacheManagerTests +{ + [Fact] + public void ShouldCache_NullOverride_UsesDefault() + { + var result = QueryCacheManager.ShouldCache(null); + result.Should().Be(FlexQueryCacheSettings.EnableCache); + } + + [Fact] + public void ShouldCache_TrueOverride_ReturnsTrue() + { + QueryCacheManager.ShouldCache(true).Should().BeTrue(); + } + + [Fact] + public void ShouldCache_FalseOverride_ReturnsFalse() + { + QueryCacheManager.ShouldCache(false).Should().BeFalse(); + } + + [Fact] + public void GetOrAddExpression_AddsAndReturns() + { + Expression> expected = x => x + 1; + + var result = QueryCacheManager.GetOrAddExpression("expr_key", () => expected); + + result.Should().BeSameAs(expected); + } + + [Fact] + public void GetOrAddExpression_ReturnsCached() + { + Expression> first = x => x + 1; + Expression> second = x => x + 2; + + var cached = QueryCacheManager.GetOrAddExpression("same_key", () => first); + var result = QueryCacheManager.GetOrAddExpression("same_key", () => second); + + result.Should().BeSameAs(first); + cached.Should().BeSameAs(first); + } + + [Fact] + public void Clear_RemovesCachedExpressions() + { + Expression> expr = x => x + 1; + QueryCacheManager.GetOrAddExpression("clear_key", () => expr); + + QueryCacheManager.Clear(); + + // After clear, factory should be invoked again + var result = QueryCacheManager.GetOrAddExpression("clear_key", () => expr); + result.Should().BeSameAs(expr); + } +} diff --git a/tests/FlexQuery.NET.Tests/Tests/ReflectionCacheTests.cs b/tests/FlexQuery.NET.Tests/Caching/ReflectionCacheTests.cs similarity index 99% rename from tests/FlexQuery.NET.Tests/Tests/ReflectionCacheTests.cs rename to tests/FlexQuery.NET.Tests/Caching/ReflectionCacheTests.cs index 0651a12..522af0b 100644 --- a/tests/FlexQuery.NET.Tests/Tests/ReflectionCacheTests.cs +++ b/tests/FlexQuery.NET.Tests/Caching/ReflectionCacheTests.cs @@ -1,7 +1,7 @@ using FlexQuery.NET.Caching; using System.Collections.Concurrent; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Caching; public class ReflectionCacheTests { diff --git a/tests/FlexQuery.NET.Tests/Dapper/Dialects/DialectTests.cs b/tests/FlexQuery.NET.Tests/Dapper/Dialects/DialectTests.cs index 0d9ef45..4f6427e 100644 --- a/tests/FlexQuery.NET.Tests/Dapper/Dialects/DialectTests.cs +++ b/tests/FlexQuery.NET.Tests/Dapper/Dialects/DialectTests.cs @@ -1,5 +1,6 @@ using FlexQuery.NET.Models; using FlexQuery.NET.Dapper.Mapping; +using FlexQuery.NET.Dapper.Sql.Models; using FlexQuery.NET.Dapper.Sql.Translators; using FlexQuery.NET.Dapper.Dialects; using FlexQuery.NET.Models.Aggregates; @@ -612,7 +613,18 @@ public void All_Dialects_Generate_Like_Clause_For_Contains_Filter() var sqliteCmd = new SqlTranslator(_registry, new SqliteDialect()).Translate(options); var oracleCmd = new SqlTranslator(_registry, new OracleDialect()).Translate(options); - //throw new System.Exception("SQLSERVER: " + sqlServerCmd.Sql + "\nPG: " + pgCmd.Sql + "\nMYSQL: " + mySqlCmd.Sql + "\nSQLITE: " + sqliteCmd.Sql + "\nORACLE: " + oracleCmd.Sql); + AssertLike(sqlServerCmd); + AssertLike(pgCmd); + AssertLike(mySqlCmd); + AssertLike(mariadbCmd); + AssertLike(sqliteCmd); + AssertLike(oracleCmd); + } + + private static void AssertLike(SqlCommand command) + { + command.Sql.Should().Contain("LIKE"); + command.Parameters.Values.Should().Contain("%test%"); } [Fact] diff --git a/tests/FlexQuery.NET.Tests/Api/Dapper/DapperApiTestBase.cs b/tests/FlexQuery.NET.Tests/Dapper/Integration/DapperApiTestBase.cs similarity index 96% rename from tests/FlexQuery.NET.Tests/Api/Dapper/DapperApiTestBase.cs rename to tests/FlexQuery.NET.Tests/Dapper/Integration/DapperApiTestBase.cs index e9eb48e..6343184 100644 --- a/tests/FlexQuery.NET.Tests/Api/Dapper/DapperApiTestBase.cs +++ b/tests/FlexQuery.NET.Tests/Dapper/Integration/DapperApiTestBase.cs @@ -5,7 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.EntityFrameworkCore; -namespace FlexQuery.NET.Tests.Api.Dapper; +namespace FlexQuery.NET.Tests.Dapper.Integration; public abstract class DapperApiTestBase : IDisposable { diff --git a/tests/FlexQuery.NET.Tests/Api/Dapper/FlatProjectionTests.cs b/tests/FlexQuery.NET.Tests/Dapper/Integration/FlatProjectionTests.cs similarity index 98% rename from tests/FlexQuery.NET.Tests/Api/Dapper/FlatProjectionTests.cs rename to tests/FlexQuery.NET.Tests/Dapper/Integration/FlatProjectionTests.cs index ef29463..226177a 100644 --- a/tests/FlexQuery.NET.Tests/Api/Dapper/FlatProjectionTests.cs +++ b/tests/FlexQuery.NET.Tests/Dapper/Integration/FlatProjectionTests.cs @@ -1,7 +1,7 @@ using System.Net.Http.Json; using System.Text.Json; -namespace FlexQuery.NET.Tests.Api.Dapper; +namespace FlexQuery.NET.Tests.Dapper.Integration; public class FlatProjectionTests : DapperApiTestBase { diff --git a/tests/FlexQuery.NET.Tests/Api/Dapper/GroupedQueryExecutionTests.cs b/tests/FlexQuery.NET.Tests/Dapper/Integration/GroupedQueryExecutionTests.cs similarity index 99% rename from tests/FlexQuery.NET.Tests/Api/Dapper/GroupedQueryExecutionTests.cs rename to tests/FlexQuery.NET.Tests/Dapper/Integration/GroupedQueryExecutionTests.cs index 78181a4..263ac46 100644 --- a/tests/FlexQuery.NET.Tests/Api/Dapper/GroupedQueryExecutionTests.cs +++ b/tests/FlexQuery.NET.Tests/Dapper/Integration/GroupedQueryExecutionTests.cs @@ -10,7 +10,7 @@ using FlexQuery.NET.Models.Paging; using Microsoft.EntityFrameworkCore; -namespace FlexQuery.NET.Tests.Api.Dapper; +namespace FlexQuery.NET.Tests.Dapper.Integration; public class GroupedQueryExecutionTests : IDisposable { diff --git a/tests/FlexQuery.NET.Tests/Api/Dapper/IncludeTests.cs b/tests/FlexQuery.NET.Tests/Dapper/Integration/IncludeTests.cs similarity index 96% rename from tests/FlexQuery.NET.Tests/Api/Dapper/IncludeTests.cs rename to tests/FlexQuery.NET.Tests/Dapper/Integration/IncludeTests.cs index a2db1a2..aa51e14 100644 --- a/tests/FlexQuery.NET.Tests/Api/Dapper/IncludeTests.cs +++ b/tests/FlexQuery.NET.Tests/Dapper/Integration/IncludeTests.cs @@ -1,7 +1,7 @@ using System.Net.Http.Json; using System.Text.Json; -namespace FlexQuery.NET.Tests.Api.Dapper; +namespace FlexQuery.NET.Tests.Dapper.Integration; public class IncludeTests : DapperApiTestBase { diff --git a/tests/FlexQuery.NET.Tests/Api/Dapper/OrderAggregationTests.cs b/tests/FlexQuery.NET.Tests/Dapper/Integration/OrderAggregationTests.cs similarity index 92% rename from tests/FlexQuery.NET.Tests/Api/Dapper/OrderAggregationTests.cs rename to tests/FlexQuery.NET.Tests/Dapper/Integration/OrderAggregationTests.cs index 189dbde..2153194 100644 --- a/tests/FlexQuery.NET.Tests/Api/Dapper/OrderAggregationTests.cs +++ b/tests/FlexQuery.NET.Tests/Dapper/Integration/OrderAggregationTests.cs @@ -1,7 +1,7 @@ using System.Net.Http.Json; using System.Text.Json; -namespace FlexQuery.NET.Tests.Api.Dapper; +namespace FlexQuery.NET.Tests.Dapper.Integration; public class OrderAggregationTests : DapperApiTestBase { @@ -24,7 +24,7 @@ public async Task Should_Group_Orders_By_Customer() public async Task Should_Apply_Aggregates() { // Act - var response = await Client.GetAsync("/api/orders?aggregate=total:sum,id:count"); + var response = await Client.GetAsync("/api/orders?aggregate=sum:total,count:id"); // Assert response.EnsureSuccessStatusCode(); @@ -41,7 +41,7 @@ public async Task Should_Apply_Aggregates() 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)"); + var response = await Client.GetAsync("/api/orders?groupBy=customerId&having=count(id):gt:1&select=customerId&aggregate=count:id"); // Assert response.EnsureSuccessStatusCode(); diff --git a/tests/FlexQuery.NET.Tests/Api/Dapper/RelationshipTests.cs b/tests/FlexQuery.NET.Tests/Dapper/Integration/RelationshipTests.cs similarity index 97% rename from tests/FlexQuery.NET.Tests/Api/Dapper/RelationshipTests.cs rename to tests/FlexQuery.NET.Tests/Dapper/Integration/RelationshipTests.cs index 41f485c..c38d579 100644 --- a/tests/FlexQuery.NET.Tests/Api/Dapper/RelationshipTests.cs +++ b/tests/FlexQuery.NET.Tests/Dapper/Integration/RelationshipTests.cs @@ -1,7 +1,7 @@ using System.Net.Http.Json; using System.Text.Json; -namespace FlexQuery.NET.Tests.Api.Dapper; +namespace FlexQuery.NET.Tests.Dapper.Integration; public class RelationshipTests : DapperApiTestBase { diff --git a/tests/FlexQuery.NET.Tests/Api/Dapper/SecurityValidationTests.cs b/tests/FlexQuery.NET.Tests/Dapper/Integration/SecurityValidationTests.cs similarity index 97% rename from tests/FlexQuery.NET.Tests/Api/Dapper/SecurityValidationTests.cs rename to tests/FlexQuery.NET.Tests/Dapper/Integration/SecurityValidationTests.cs index 9ef0a20..e0d27fe 100644 --- a/tests/FlexQuery.NET.Tests/Api/Dapper/SecurityValidationTests.cs +++ b/tests/FlexQuery.NET.Tests/Dapper/Integration/SecurityValidationTests.cs @@ -3,7 +3,7 @@ using System.Net.Http.Json; using System.Text.Json; -namespace FlexQuery.NET.Tests.Api.Dapper; +namespace FlexQuery.NET.Tests.Dapper.Integration; public class SecurityTests : DapperApiTestBase { diff --git a/tests/FlexQuery.NET.Tests/Api/Dapper/UsersTests.cs b/tests/FlexQuery.NET.Tests/Dapper/Integration/UsersTests.cs similarity index 98% rename from tests/FlexQuery.NET.Tests/Api/Dapper/UsersTests.cs rename to tests/FlexQuery.NET.Tests/Dapper/Integration/UsersTests.cs index 22d141b..dbf8627 100644 --- a/tests/FlexQuery.NET.Tests/Api/Dapper/UsersTests.cs +++ b/tests/FlexQuery.NET.Tests/Dapper/Integration/UsersTests.cs @@ -1,7 +1,7 @@ using System.Net.Http.Json; using System.Text.Json; -namespace FlexQuery.NET.Tests.Api.Dapper; +namespace FlexQuery.NET.Tests.Dapper.Integration; public class UsersTests : DapperApiTestBase { diff --git a/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlCountBuilderTests.cs b/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlCountBuilderTests.cs new file mode 100644 index 0000000..281c332 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlCountBuilderTests.cs @@ -0,0 +1,142 @@ +using FlexQuery.NET.Dapper.Sql.Builders; + +namespace FlexQuery.NET.Tests.Dapper.Translation; + +public class SqlCountBuilderTests +{ + [Fact] + public void ExtractCountSql_SimpleSelect_WrapsInCount() + { + var sql = "SELECT Id, Name FROM Users WHERE Age > 18"; + var result = SqlCountBuilder.ExtractCountSql(sql); + result.Should().Be("SELECT COUNT(1) FROM (SELECT Id, Name FROM Users WHERE Age > 18) AS CountTable"); + } + + [Fact] + public void ExtractCountSql_WithOrderBy_StripsOrderBy() + { + var sql = "SELECT Id, Name FROM Users ORDER BY Name"; + var result = SqlCountBuilder.ExtractCountSql(sql); + result.Should().Be("SELECT COUNT(1) FROM (SELECT Id, Name FROM Users) AS CountTable"); + } + + [Fact] + public void ExtractCountSql_WithOrderByAndLimit_StripsBoth() + { + var sql = "SELECT Id, Name FROM Users ORDER BY Name LIMIT 10"; + var result = SqlCountBuilder.ExtractCountSql(sql); + result.Should().Be("SELECT COUNT(1) FROM (SELECT Id, Name FROM Users) AS CountTable"); + } + + [Fact] + public void ExtractCountSql_WithOrderByAndOffset_StripsAll() + { + var sql = "SELECT Id, Name FROM Users ORDER BY Name OFFSET 5 ROWS FETCH NEXT 10 ROWS ONLY"; + var result = SqlCountBuilder.ExtractCountSql(sql); + result.Should().Be("SELECT COUNT(1) FROM (SELECT Id, Name FROM Users) AS CountTable"); + } + + [Fact] + public void ExtractCountSql_WithLimitAndOffset_StripsBoth() + { + var sql = "SELECT Id, Name FROM Users ORDER BY Id LIMIT 10 OFFSET 20"; + var result = SqlCountBuilder.ExtractCountSql(sql); + result.Should().Be("SELECT COUNT(1) FROM (SELECT Id, Name FROM Users) AS CountTable"); + } + + [Fact] + public void ExtractCountSql_WithSubqueryOrderBy_KeepsInnerOrderBy() + { + var sql = "SELECT * FROM (SELECT Id, ROW_NUMBER() OVER (ORDER BY Name) AS rn FROM Users) AS sub WHERE rn > 0 ORDER BY rn"; + var result = SqlCountBuilder.ExtractCountSql(sql); + result.Should().Be("SELECT COUNT(1) FROM (SELECT * FROM (SELECT Id, ROW_NUMBER() OVER (ORDER BY Name) AS rn FROM Users) AS sub WHERE rn > 0) AS CountTable"); + } + + [Fact] + public void ExtractCountSql_WithSubqueryLimit_KeepsInnerLimit() + { + var sql = "SELECT * FROM (SELECT Id, Name FROM Users LIMIT 10) AS sub ORDER BY Name"; + var result = SqlCountBuilder.ExtractCountSql(sql); + result.Should().Be("SELECT COUNT(1) FROM (SELECT * FROM (SELECT Id, Name FROM Users LIMIT 10) AS sub) AS CountTable"); + } + + [Fact] + public void ExtractCountSql_EmptySql_ReturnsWrappedEmpty() + { + var sql = ""; + var result = SqlCountBuilder.ExtractCountSql(sql); + result.Should().Be("SELECT COUNT(1) FROM () AS CountTable"); + } + + [Fact] + public void ExtractCountSql_NoOrderByOrLimit_ReturnsWrapped() + { + var sql = "SELECT * FROM Products WHERE Category = 'Books'"; + var result = SqlCountBuilder.ExtractCountSql(sql); + result.Should().Be("SELECT COUNT(1) FROM (SELECT * FROM Products WHERE Category = 'Books') AS CountTable"); + } + + [Fact] + public void ExtractCountSql_OrderByInsideSubquery_NotStripped() + { + var sql = "SELECT Id, (SELECT COUNT(*) FROM Items WHERE Items.ParentId = Parents.Id ORDER BY Items.Name) AS ItemCount FROM Parents"; + var result = SqlCountBuilder.ExtractCountSql(sql); + result.Should().Be("SELECT COUNT(1) FROM (SELECT Id, (SELECT COUNT(*) FROM Items WHERE Items.ParentId = Parents.Id ORDER BY Items.Name) AS ItemCount FROM Parents) AS CountTable"); + } + + [Fact] + public void ExtractCountSql_LimitInsideSubquery_NotStripped() + { + var sql = "SELECT * FROM (SELECT Id, Name FROM Users ORDER BY Name LIMIT 5) AS top5 ORDER BY Id"; + var result = SqlCountBuilder.ExtractCountSql(sql); + result.Should().Be("SELECT COUNT(1) FROM (SELECT * FROM (SELECT Id, Name FROM Users ORDER BY Name LIMIT 5) AS top5) AS CountTable"); + } + + [Fact] + public void ExtractCountSql_AllClauses_StripsAll() + { + var sql = "SELECT Id, Name FROM Users WHERE Active = 1 ORDER BY Name LIMIT 10 OFFSET 20"; + var result = SqlCountBuilder.ExtractCountSql(sql); + result.Should().Be("SELECT COUNT(1) FROM (SELECT Id, Name FROM Users WHERE Active = 1) AS CountTable"); + } + + [Fact] + public void ExtractCountSql_OnlyOrderBy_StripsOrderBy() + { + var sql = "SELECT * FROM Products ORDER BY Price DESC"; + var result = SqlCountBuilder.ExtractCountSql(sql); + result.Should().Be("SELECT COUNT(1) FROM (SELECT * FROM Products) AS CountTable"); + } + + [Fact] + public void ExtractCountSql_OnlyLimit_StripsLimit() + { + var sql = "SELECT * FROM Products LIMIT 5"; + var result = SqlCountBuilder.ExtractCountSql(sql); + result.Should().Be("SELECT COUNT(1) FROM (SELECT * FROM Products) AS CountTable"); + } + + [Fact] + public void ExtractCountSql_OnlyOffset_StripsOffset() + { + var sql = "SELECT * FROM Products OFFSET 10"; + var result = SqlCountBuilder.ExtractCountSql(sql); + result.Should().Be("SELECT COUNT(1) FROM (SELECT * FROM Products) AS CountTable"); + } + + [Fact] + public void ExtractCountSql_OrderByInSubquery_NotStripped() + { + var sql = "SELECT * FROM (SELECT * FROM Items ORDER BY Name) AS sorted WHERE sorted.Price > 100 ORDER BY sorted.Price"; + var result = SqlCountBuilder.ExtractCountSql(sql); + result.Should().Be("SELECT COUNT(1) FROM (SELECT * FROM (SELECT * FROM Items ORDER BY Name) AS sorted WHERE sorted.Price > 100) AS CountTable"); + } + + [Fact] + public void ExtractCountSql_MultipleOrderByKeywords_StripsOutermostOnly() + { + var sql = "SELECT * FROM (SELECT * FROM Items ORDER BY Name) AS sub ORDER BY sub.Price"; + var result = SqlCountBuilder.ExtractCountSql(sql); + result.Should().Be("SELECT COUNT(1) FROM (SELECT * FROM (SELECT * FROM Items ORDER BY Name) AS sub) AS CountTable"); + } +} diff --git a/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlHavingBuilderTests.cs b/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlHavingBuilderTests.cs new file mode 100644 index 0000000..2786e14 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlHavingBuilderTests.cs @@ -0,0 +1,485 @@ +using FlexQuery.NET.Dapper.Dialects; +using FlexQuery.NET.Dapper.Mapping; +using FlexQuery.NET.Dapper.Sql.Builders; +using FlexQuery.NET.Dapper.Sql.Models; +using FlexQuery.NET.Models.Aggregates; + +namespace FlexQuery.NET.Tests.Dapper.Translation; + +public class SqlHavingBuilderTests +{ + private readonly IMappingRegistry _registry = new MappingRegistry(); + private static readonly ISqlDialect Dialect = new SqlServerDialect(); + + public SqlHavingBuilderTests() + { + _registry.Entity().ToTable("entities"); + } + + [Fact] + public void Build_NullHaving_ReturnsEmpty() + { + var mapping = _registry.GetMapping(typeof(HavingTestEntity)); + var parameters = new SqlParameterContext(Dialect); + var result = SqlHavingBuilder.Build(Dialect, null, mapping, parameters); + result.Should().BeEmpty(); + } + + [Fact] + public void Build_CountStarWithGt_GeneratesHavingCountGt() + { + var mapping = _registry.GetMapping(typeof(HavingTestEntity)); + var parameters = new SqlParameterContext(Dialect); + var having = new HavingCondition + { + Function = AggregateFunction.Count, + Field = null, + Operator = "gt", + Value = "5" + }; + + var result = SqlHavingBuilder.Build(Dialect, having, mapping, parameters); + + result.Should().Be("HAVING COUNT(*) > @p0"); + parameters.Parameters.Should().ContainKey("@p0"); + parameters.Parameters["@p0"].Should().Be(5L); + } + + [Fact] + public void Build_CountStarWithEq_GeneratesHavingCountEq() + { + var mapping = _registry.GetMapping(typeof(HavingTestEntity)); + var parameters = new SqlParameterContext(Dialect); + var having = new HavingCondition + { + Function = AggregateFunction.Count, + Field = null, + Operator = "eq", + Value = "10" + }; + + var result = SqlHavingBuilder.Build(Dialect, having, mapping, parameters); + + result.Should().Be("HAVING COUNT(*) = @p0"); + parameters.Parameters["@p0"].Should().Be(10L); + } + + [Fact] + public void Build_CountStarWithLt_GeneratesHavingCountLt() + { + var mapping = _registry.GetMapping(typeof(HavingTestEntity)); + var parameters = new SqlParameterContext(Dialect); + var having = new HavingCondition + { + Function = AggregateFunction.Count, + Field = null, + Operator = "lt", + Value = "100" + }; + + var result = SqlHavingBuilder.Build(Dialect, having, mapping, parameters); + + result.Should().Be("HAVING COUNT(*) < @p0"); + parameters.Parameters["@p0"].Should().Be(100L); + } + + [Fact] + public void Build_CountStarWithGte_GeneratesHavingCountGte() + { + var mapping = _registry.GetMapping(typeof(HavingTestEntity)); + var parameters = new SqlParameterContext(Dialect); + var having = new HavingCondition + { + Function = AggregateFunction.Count, + Field = null, + Operator = "gte", + Value = "0" + }; + + var result = SqlHavingBuilder.Build(Dialect, having, mapping, parameters); + + result.Should().Be("HAVING COUNT(*) >= @p0"); + parameters.Parameters["@p0"].Should().Be(0L); + } + + [Fact] + public void Build_CountStarWithLte_GeneratesHavingCountLte() + { + var mapping = _registry.GetMapping(typeof(HavingTestEntity)); + var parameters = new SqlParameterContext(Dialect); + var having = new HavingCondition + { + Function = AggregateFunction.Count, + Field = null, + Operator = "lte", + Value = "50" + }; + + var result = SqlHavingBuilder.Build(Dialect, having, mapping, parameters); + + result.Should().Be("HAVING COUNT(*) <= @p0"); + parameters.Parameters["@p0"].Should().Be(50L); + } + + [Fact] + public void Build_CountStarWithNeq_GeneratesHavingCountNeq() + { + var mapping = _registry.GetMapping(typeof(HavingTestEntity)); + var parameters = new SqlParameterContext(Dialect); + var having = new HavingCondition + { + Function = AggregateFunction.Count, + Field = null, + Operator = "neq", + Value = "0" + }; + + var result = SqlHavingBuilder.Build(Dialect, having, mapping, parameters); + + result.Should().Be("HAVING COUNT(*) <> @p0"); + parameters.Parameters["@p0"].Should().Be(0L); + } + + [Fact] + public void Build_CountField_GeneratesHavingCountField() + { + var mapping = _registry.GetMapping(typeof(HavingTestEntity)); + var parameters = new SqlParameterContext(Dialect); + var having = new HavingCondition + { + Function = AggregateFunction.Count, + Field = "Status", + Operator = "gt", + Value = "3" + }; + + var result = SqlHavingBuilder.Build(Dialect, having, mapping, parameters); + + result.Should().Be("HAVING COUNT([Status]) > @p0"); + parameters.Parameters["@p0"].Should().Be("3"); + } + + [Fact] + public void Build_SumField_GeneratesHavingSum() + { + var mapping = _registry.GetMapping(typeof(HavingTestEntity)); + var parameters = new SqlParameterContext(Dialect); + var having = new HavingCondition + { + Function = AggregateFunction.Sum, + Field = "Score", + Operator = "gt", + Value = "100" + }; + + var result = SqlHavingBuilder.Build(Dialect, having, mapping, parameters); + + result.Should().Be("HAVING SUM([Score]) > @p0"); + parameters.Parameters["@p0"].Should().Be(100L); + } + + [Fact] + public void Build_AvgField_GeneratesHavingAvg() + { + var mapping = _registry.GetMapping(typeof(HavingTestEntity)); + var parameters = new SqlParameterContext(Dialect); + var having = new HavingCondition + { + Function = AggregateFunction.Avg, + Field = "Score", + Operator = "gte", + Value = "75.5" + }; + + var result = SqlHavingBuilder.Build(Dialect, having, mapping, parameters); + + result.Should().Be("HAVING AVG([Score]) >= @p0"); + parameters.Parameters["@p0"].Should().Be(75.5); + } + + [Fact] + public void Build_MinField_GeneratesHavingMin() + { + var mapping = _registry.GetMapping(typeof(HavingTestEntity)); + var parameters = new SqlParameterContext(Dialect); + var having = new HavingCondition + { + Function = AggregateFunction.Min, + Field = "Score", + Operator = "lt", + Value = "10" + }; + + var result = SqlHavingBuilder.Build(Dialect, having, mapping, parameters); + + result.Should().Be("HAVING MIN([Score]) < @p0"); + parameters.Parameters["@p0"].Should().Be(10L); + } + + [Fact] + public void Build_MaxField_GeneratesHavingMax() + { + var mapping = _registry.GetMapping(typeof(HavingTestEntity)); + var parameters = new SqlParameterContext(Dialect); + var having = new HavingCondition + { + Function = AggregateFunction.Max, + Field = "Score", + Operator = "lte", + Value = "200" + }; + + var result = SqlHavingBuilder.Build(Dialect, having, mapping, parameters); + + result.Should().Be("HAVING MAX([Score]) <= @p0"); + parameters.Parameters["@p0"].Should().Be(200L); + } + + [Fact] + public void Build_OperatorAliases_NormalizeCorrectly() + { + var mapping = _registry.GetMapping(typeof(HavingTestEntity)); + var parameters = new SqlParameterContext(Dialect); + + var having = new HavingCondition + { + Function = AggregateFunction.Count, + Field = null, + Operator = "=", + Value = "5" + }; + + var result = SqlHavingBuilder.Build(Dialect, having, mapping, parameters); + result.Should().Be("HAVING COUNT(*) = @p0"); + } + + [Fact] + public void Build_OperatorNeqAliases_NormalizeCorrectly() + { + var mapping = _registry.GetMapping(typeof(HavingTestEntity)); + var parameters = new SqlParameterContext(Dialect); + + var having = new HavingCondition + { + Function = AggregateFunction.Count, + Field = null, + Operator = "!=", + Value = "5" + }; + + var result = SqlHavingBuilder.Build(Dialect, having, mapping, parameters); + result.Should().Be("HAVING COUNT(*) <> @p0"); + } + + [Fact] + public void Build_OperatorGtAliases_NormalizeCorrectly() + { + var mapping = _registry.GetMapping(typeof(HavingTestEntity)); + var parameters = new SqlParameterContext(Dialect); + + var having = new HavingCondition + { + Function = AggregateFunction.Count, + Field = null, + Operator = "greaterthan", + Value = "5" + }; + + var result = SqlHavingBuilder.Build(Dialect, having, mapping, parameters); + result.Should().Be("HAVING COUNT(*) > @p0"); + } + + [Fact] + public void Build_OperatorLtAliases_NormalizeCorrectly() + { + var mapping = _registry.GetMapping(typeof(HavingTestEntity)); + var parameters = new SqlParameterContext(Dialect); + + var having = new HavingCondition + { + Function = AggregateFunction.Count, + Field = null, + Operator = "lessthan", + Value = "5" + }; + + var result = SqlHavingBuilder.Build(Dialect, having, mapping, parameters); + result.Should().Be("HAVING COUNT(*) < @p0"); + } + + [Fact] + public void Build_OperatorGteAliases_NormalizeCorrectly() + { + var mapping = _registry.GetMapping(typeof(HavingTestEntity)); + var parameters = new SqlParameterContext(Dialect); + + var having = new HavingCondition + { + Function = AggregateFunction.Count, + Field = null, + Operator = ">=", + Value = "5" + }; + + var result = SqlHavingBuilder.Build(Dialect, having, mapping, parameters); + result.Should().Be("HAVING COUNT(*) >= @p0"); + } + + [Fact] + public void Build_OperatorLteAliases_NormalizeCorrectly() + { + var mapping = _registry.GetMapping(typeof(HavingTestEntity)); + var parameters = new SqlParameterContext(Dialect); + + var having = new HavingCondition + { + Function = AggregateFunction.Count, + Field = null, + Operator = "<=", + Value = "5" + }; + + var result = SqlHavingBuilder.Build(Dialect, having, mapping, parameters); + result.Should().Be("HAVING COUNT(*) <= @p0"); + } + + [Fact] + public void Build_SumWithDecimalValue_ConvertsCorrectly() + { + var mapping = _registry.GetMapping(typeof(HavingTestEntity)); + var parameters = new SqlParameterContext(Dialect); + var having = new HavingCondition + { + Function = AggregateFunction.Sum, + Field = "Score", + Operator = "gt", + Value = "99.99" + }; + + var result = SqlHavingBuilder.Build(Dialect, having, mapping, parameters); + + result.Should().Be("HAVING SUM([Score]) > @p0"); + parameters.Parameters["@p0"].Should().Be(99.99); + } + + [Fact] + public void Build_CountStarWithSqlite_ConvertsDecimalToDouble() + { + var mapping = _registry.GetMapping(typeof(HavingTestEntity)); + var parameters = new SqlParameterContext(new SqliteDialect()); + var having = new HavingCondition + { + Function = AggregateFunction.Count, + Field = null, + Operator = "gt", + Value = "5" + }; + + var result = SqlHavingBuilder.Build(new SqliteDialect(), having, mapping, parameters); + + result.Should().Be("HAVING COUNT(*) > @p0"); + parameters.Parameters["@p0"].Should().Be(5L); + } + + [Fact] + public void Build_SumWithSqlite_ConvertsDecimalToDouble() + { + var mapping = _registry.GetMapping(typeof(HavingTestEntity)); + var parameters = new SqlParameterContext(new SqliteDialect()); + var having = new HavingCondition + { + Function = AggregateFunction.Sum, + Field = "Score", + Operator = "gt", + Value = "99.99" + }; + + var result = SqlHavingBuilder.Build(new SqliteDialect(), having, mapping, parameters); + + result.Should().Be("HAVING SUM(\"Score\") > @p0"); + parameters.Parameters["@p0"].Should().Be(99.99); + } + + [Fact] + public void Build_WithPostgreSql_UsesCorrectQuoting() + { + var mapping = _registry.GetMapping(typeof(HavingTestEntity)); + var parameters = new SqlParameterContext(new PostgreSqlDialect()); + var having = new HavingCondition + { + Function = AggregateFunction.Sum, + Field = "Score", + Operator = "gt", + Value = "100" + }; + + var result = SqlHavingBuilder.Build(new PostgreSqlDialect(), having, mapping, parameters); + + result.Should().Be("HAVING SUM(\"Score\") > :p0"); + } + + [Fact] + public void Build_WithMySql_UsesCorrectQuoting() + { + var mapping = _registry.GetMapping(typeof(HavingTestEntity)); + var parameters = new SqlParameterContext(new MySqlDialect()); + var having = new HavingCondition + { + Function = AggregateFunction.Sum, + Field = "Score", + Operator = "gt", + Value = "100" + }; + + var result = SqlHavingBuilder.Build(new MySqlDialect(), having, mapping, parameters); + + result.Should().Be("HAVING SUM(`Score`) > ?p0"); + } + + [Fact] + public void Build_WithTableAlias_UsesAliasInColumn() + { + var registry = new MappingRegistry(); + registry.Entity().ToTable("entities").HasAlias("e"); + var mapping = registry.GetMapping(typeof(HavingTestEntity)); + var parameters = new SqlParameterContext(Dialect); + var having = new HavingCondition + { + Function = AggregateFunction.Sum, + Field = "Score", + Operator = "gt", + Value = "100" + }; + + var result = SqlHavingBuilder.Build(Dialect, having, mapping, parameters); + + result.Should().Be("HAVING SUM([e].[Score]) > @p0"); + } + + [Fact] + public void Build_UnknownOperator_ReturnsRawOperator() + { + var mapping = _registry.GetMapping(typeof(HavingTestEntity)); + var parameters = new SqlParameterContext(Dialect); + var having = new HavingCondition + { + Function = AggregateFunction.Count, + Field = null, + Operator = "custom_op", + Value = "5" + }; + + var result = SqlHavingBuilder.Build(Dialect, having, mapping, parameters); + + result.Should().Be("HAVING COUNT(*) custom_op @p0"); + } + + private sealed class HavingTestEntity + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public decimal Score { get; set; } + } +} + diff --git a/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlJoinBuilderTests.cs b/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlJoinBuilderTests.cs new file mode 100644 index 0000000..d5c1021 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlJoinBuilderTests.cs @@ -0,0 +1,339 @@ +using FlexQuery.NET.Dapper.Dialects; +using FlexQuery.NET.Dapper.Mapping; +using FlexQuery.NET.Dapper.Sql.Builders; +using FlexQuery.NET.Dapper.Sql.Models; +using FlexQuery.NET.Dapper.Sql.Translators; +using FlexQuery.NET.Internal; +using FlexQuery.NET.Models; +using FlexQuery.NET.Models.Projection; +using FlexQuery.NET.Models.Filters; + +namespace FlexQuery.NET.Tests.Dapper.Translation; + +public class SqlJoinBuilderTests +{ + private readonly IMappingRegistry _registry = new MappingRegistry(); + private static readonly ISqlDialect Dialect = new SqlServerDialect(); + + public SqlJoinBuilderTests() + { + _registry.Entity() + .ToTable("Customers") + .HasMany(c => c.Orders).WithForeignKey("CustomerId"); + _registry.Entity() + .ToTable("Orders") + .HasMany(o => o.Items).WithForeignKey("OrderId"); + _registry.Entity() + .ToTable("OrderItems"); + } + + private SqlJoinBuilder CreateBuilder(ISqlDialect? dialect = null) + { + var d = dialect ?? Dialect; + var includeTranslator = new SqlIncludeTranslator(d); + var existsTranslator = new SqlExistsTranslator(d); + var countTranslator = new SqlCountTranslator(d); + var whereBuilder = new SqlWhereBuilder(_registry, d, existsTranslator, countTranslator); + return new SqlJoinBuilder(_registry, d, includeTranslator, whereBuilder); + } + + [Fact] + public void BuildJoinClause_NoSelectTreeNoIncludes_ReturnsEmpty() + { + var mapping = _registry.GetMapping(typeof(SqlCustomer)); + var builder = CreateBuilder(); + var options = new QueryOptions(); + var parameters = new SqlParameterContext(Dialect); + var tree = new SelectionNode(); + + var result = builder.BuildJoinClause(options, mapping, parameters, tree); + + result.Should().BeEmpty(); + } + + [Fact] + public void BuildJoinClause_SelectTreeWithNavigation_GeneratesJoin() + { + var mapping = _registry.GetMapping(typeof(SqlCustomer)); + var builder = CreateBuilder(); + var options = new QueryOptions(); + var parameters = new SqlParameterContext(Dialect); + var tree = new SelectionNode(); + tree.GetOrAddChild("Orders").GetOrAddChild("Total"); + + var result = builder.BuildJoinClause(options, mapping, parameters, tree); + + result.Should().Contain("LEFT JOIN"); + result.Should().Contain("[Orders]"); + result.Should().Contain("[CustomerId]"); + result.Should().Contain("[Customers]"); + } + + [Fact] + public void BuildJoinClause_MultiLevelNavigation_GeneratesMultipleJoins() + { + var mapping = _registry.GetMapping(typeof(SqlCustomer)); + var builder = CreateBuilder(); + var options = new QueryOptions(); + var parameters = new SqlParameterContext(Dialect); + var tree = new SelectionNode(); + tree.GetOrAddChild("Orders").GetOrAddChild("Items").GetOrAddChild("Sku"); + + var result = builder.BuildJoinClause(options, mapping, parameters, tree); + + result.Should().Contain("LEFT JOIN [Orders]"); + result.Should().Contain("LEFT JOIN [OrderItems]"); + } + + [Fact] + public void BuildJoinClause_WithIncludes_GeneratesJoin() + { + var mapping = _registry.GetMapping(typeof(SqlCustomer)); + var builder = CreateBuilder(); + var options = new QueryOptions + { + Includes = ["Orders"] + }; + var parameters = new SqlParameterContext(Dialect); + var tree = new SelectionNode(); + + var result = builder.BuildJoinClause(options, mapping, parameters, tree); + + result.Should().Contain("LEFT JOIN"); + result.Should().Contain("[Orders]"); + } + + [Fact] + public void BuildJoinClause_WithFilteredInclude_GeneratesJoinWithFilter() + { + var mapping = _registry.GetMapping(typeof(SqlCustomer)); + var builder = CreateBuilder(); + var options = new QueryOptions + { + Expand = + [ + new IncludeNode + { + Path = "Orders", + Filter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Total", Operator = "gt", Value = "100" }] + } + } + ] + }; + var parameters = new SqlParameterContext(Dialect); + var tree = new SelectionNode(); + + var result = builder.BuildJoinClause(options, mapping, parameters, tree); + + result.Should().Contain("LEFT JOIN"); + result.Should().Contain("[Orders]"); + result.Should().Contain("[Total] > @p0"); + parameters.Parameters["@p0"].Should().Be(100M); + } + + [Fact] + public void BuildJoinClause_DuplicatePaths_Deduplicates() + { + var mapping = _registry.GetMapping(typeof(SqlCustomer)); + var builder = CreateBuilder(); + var options = new QueryOptions + { + Includes = ["Orders", "Orders"] + }; + var parameters = new SqlParameterContext(Dialect); + var tree = new SelectionNode(); + + var result = builder.BuildJoinClause(options, mapping, parameters, tree); + + result.Should().Contain("LEFT JOIN [Orders]"); + var count = System.Text.RegularExpressions.Regex.Matches(result, "LEFT JOIN").Count; + count.Should().Be(1); + } + + [Fact] + public void BuildJoinClause_SelectTreeAndIncludes_Deduplicates() + { + var mapping = _registry.GetMapping(typeof(SqlCustomer)); + var builder = CreateBuilder(); + var options = new QueryOptions + { + Includes = ["Orders"] + }; + var parameters = new SqlParameterContext(Dialect); + var tree = new SelectionNode(); + tree.GetOrAddChild("Orders").GetOrAddChild("Total"); + + var result = builder.BuildJoinClause(options, mapping, parameters, tree); + + var count = System.Text.RegularExpressions.Regex.Matches(result, "LEFT JOIN").Count; + count.Should().Be(1); + } + + [Fact] + public void BuildJoinClause_UnknownPath_IsSkipped() + { + var mapping = _registry.GetMapping(typeof(SqlCustomer)); + var builder = CreateBuilder(); + var options = new QueryOptions + { + Includes = ["NonExistent"] + }; + var parameters = new SqlParameterContext(Dialect); + var tree = new SelectionNode(); + + var result = builder.BuildJoinClause(options, mapping, parameters, tree); + + result.Should().BeEmpty(); + } + + [Fact] + public void BuildJoinClause_WithPostgreSql_UsesCorrectQuoting() + { + var dialect = new PostgreSqlDialect(); + var mapping = _registry.GetMapping(typeof(SqlCustomer)); + var builder = CreateBuilder(dialect); + var options = new QueryOptions + { + Includes = ["Orders"] + }; + var parameters = new SqlParameterContext(dialect); + var tree = new SelectionNode(); + + var result = builder.BuildJoinClause(options, mapping, parameters, tree); + + result.Should().Contain("\"Orders\""); + result.Should().Contain("\"Customers\""); + } + + [Fact] + public void BuildJoinClause_WithMySql_UsesCorrectQuoting() + { + var dialect = new MySqlDialect(); + var mapping = _registry.GetMapping(typeof(SqlCustomer)); + var builder = CreateBuilder(dialect); + var options = new QueryOptions + { + Includes = ["Orders"] + }; + var parameters = new SqlParameterContext(dialect); + var tree = new SelectionNode(); + + var result = builder.BuildJoinClause(options, mapping, parameters, tree); + + result.Should().Contain("`Orders`"); + result.Should().Contain("`Customers`"); + } + + [Fact] + public void BuildJoinClause_CaseInsensitiveFilter_AppliesCorrectly() + { + var mapping = _registry.GetMapping(typeof(SqlCustomer)); + var builder = CreateBuilder(); + var options = new QueryOptions + { + CaseInsensitive = true, + Expand = + [ + new IncludeNode + { + Path = "Orders", + Filter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Number", Operator = "eq", Value = "ORD-001" }] + } + } + ] + }; + var parameters = new SqlParameterContext(Dialect); + var tree = new SelectionNode(); + + var result = builder.BuildJoinClause(options, mapping, parameters, tree); + + result.Should().Contain("LOWER("); + result.Should().Contain("LEFT JOIN"); + } + + [Fact] + public void BuildJoinClause_SelectTreeWithFilteredNavigation_GeneratesFilteredJoin() + { + var mapping = _registry.GetMapping(typeof(SqlCustomer)); + var builder = CreateBuilder(); + var options = new QueryOptions(); + var parameters = new SqlParameterContext(Dialect); + var tree = new SelectionNode(); + var ordersNode = tree.GetOrAddChild("Orders"); + ordersNode.GetOrAddChild("Total"); + ordersNode.Filter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Total", Operator = "gt", Value = "50" }] + }; + + var result = builder.BuildJoinClause(options, mapping, parameters, tree); + + result.Should().Contain("LEFT JOIN"); + result.Should().Contain("[Total] > @p0"); + } + + [Fact] + public void BuildJoinClause_WithTableAlias_UsesAliasInJoinCondition() + { + var registry = new MappingRegistry(); + registry.Entity() + .ToTable("Customers").HasAlias("c") + .HasMany(c => c.Orders).WithForeignKey("CustomerId"); + registry.Entity() + .ToTable("Orders"); + + var mapping = registry.GetMapping(typeof(SqlCustomer)); + var includeTranslator = new SqlIncludeTranslator(Dialect); + var existsTranslator = new SqlExistsTranslator(Dialect); + var countTranslator = new SqlCountTranslator(Dialect); + var whereBuilder = new SqlWhereBuilder(registry, Dialect, existsTranslator, countTranslator); + var builder = new SqlJoinBuilder(registry, Dialect, includeTranslator, whereBuilder); + var options = new QueryOptions + { + Includes = ["Orders"] + }; + var parameters = new SqlParameterContext(Dialect); + var tree = new SelectionNode(); + + var result = builder.BuildJoinClause(options, mapping, parameters, tree); + + result.Should().Contain("[c]"); + } + + [Fact] + public void BuildJoinClause_MultipleFilteredIncludes_GeneratesMultipleJoins() + { + var registry = new MappingRegistry(); + registry.Entity() + .ToTable("Customers") + .HasMany(c => c.Orders).WithForeignKey("CustomerId"); + registry.Entity() + .HasOne(c => c.Address).WithForeignKey("CustomerId"); + registry.Entity() + .ToTable("Orders"); + registry.Entity() + .ToTable("Addresses"); + + var mapping = registry.GetMapping(typeof(SqlCustomer)); + var includeTranslator = new SqlIncludeTranslator(Dialect); + var existsTranslator = new SqlExistsTranslator(Dialect); + var countTranslator = new SqlCountTranslator(Dialect); + var whereBuilder = new SqlWhereBuilder(registry, Dialect, existsTranslator, countTranslator); + var builder = new SqlJoinBuilder(registry, Dialect, includeTranslator, whereBuilder); + var options = new QueryOptions + { + Includes = ["Orders", "Address"] + }; + var parameters = new SqlParameterContext(Dialect); + var tree = new SelectionNode(); + + var result = builder.BuildJoinClause(options, mapping, parameters, tree); + + result.Should().Contain("[Orders]"); + result.Should().Contain("[Addresses]"); + } +} diff --git a/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlSelectBuilderTests.cs b/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlSelectBuilderTests.cs new file mode 100644 index 0000000..0c04c91 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlSelectBuilderTests.cs @@ -0,0 +1,595 @@ +using FlexQuery.NET.Dapper.Dialects; +using FlexQuery.NET.Dapper.Mapping; +using FlexQuery.NET.Dapper.Sql.Builders; +using FlexQuery.NET.Internal; +using FlexQuery.NET.Models; +using FlexQuery.NET.Models.Aggregates; +using FlexQuery.NET.Models.Projection; + +namespace FlexQuery.NET.Tests.Dapper.Translation; + +public class SqlSelectBuilderTests +{ + private readonly IMappingRegistry _registry = new MappingRegistry(); + private static readonly ISqlDialect Dialect = new SqlServerDialect(); + + public SqlSelectBuilderTests() + { + _registry.Entity().ToTable("entities"); + } + + // ── BuildAggregateSelectParts ──────────────────────────────────────── + + [Fact] + public void BuildAggregateSelectParts_CountStarNullField_ReturnsCount1() + { + var mapping = _registry.GetMapping(typeof(SelectTestEntity)); + var builder = new SqlSelectBuilder(_registry, Dialect); + var options = new QueryOptions + { + Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = null, Alias = "total" }] + }; + + var parts = builder.BuildAggregateSelectParts(options, mapping); + + parts.Should().ContainSingle().Which.Should().Be("COUNT(1) AS [total]"); + } + + [Fact] + public void BuildAggregateSelectParts_CountStarWildcard_ReturnsCount1() + { + var mapping = _registry.GetMapping(typeof(SelectTestEntity)); + var builder = new SqlSelectBuilder(_registry, Dialect); + var options = new QueryOptions + { + Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = "*", Alias = "total" }] + }; + + var parts = builder.BuildAggregateSelectParts(options, mapping); + + parts.Should().ContainSingle().Which.Should().Be("COUNT(1) AS [total]"); + } + + [Fact] + public void BuildAggregateSelectParts_CountField_GeneratesCountField() + { + var mapping = _registry.GetMapping(typeof(SelectTestEntity)); + var builder = new SqlSelectBuilder(_registry, Dialect); + var options = new QueryOptions + { + Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = "Status", Alias = "statusCount" }] + }; + + var parts = builder.BuildAggregateSelectParts(options, mapping); + + parts.Should().ContainSingle().Which.Should().Be("COUNT([Status]) AS [statusCount]"); + } + + [Fact] + public void BuildAggregateSelectParts_Sum_GeneratesSum() + { + var mapping = _registry.GetMapping(typeof(SelectTestEntity)); + var builder = new SqlSelectBuilder(_registry, Dialect); + var options = new QueryOptions + { + Aggregates = [new AggregateModel { Function = AggregateFunction.Sum, Field = "Score", Alias = "totalScore" }] + }; + + var parts = builder.BuildAggregateSelectParts(options, mapping); + + parts.Should().ContainSingle().Which.Should().Be("SUM([Score]) AS [totalScore]"); + } + + [Fact] + public void BuildAggregateSelectParts_Avg_GeneratesAvg() + { + var mapping = _registry.GetMapping(typeof(SelectTestEntity)); + var builder = new SqlSelectBuilder(_registry, Dialect); + var options = new QueryOptions + { + Aggregates = [new AggregateModel { Function = AggregateFunction.Avg, Field = "Score", Alias = "avgScore" }] + }; + + var parts = builder.BuildAggregateSelectParts(options, mapping); + + parts.Should().ContainSingle().Which.Should().Be("AVG([Score]) AS [avgScore]"); + } + + [Fact] + public void BuildAggregateSelectParts_Min_GeneratesMin() + { + var mapping = _registry.GetMapping(typeof(SelectTestEntity)); + var builder = new SqlSelectBuilder(_registry, Dialect); + var options = new QueryOptions + { + Aggregates = [new AggregateModel { Function = AggregateFunction.Min, Field = "Score", Alias = "minScore" }] + }; + + var parts = builder.BuildAggregateSelectParts(options, mapping); + + parts.Should().ContainSingle().Which.Should().Be("MIN([Score]) AS [minScore]"); + } + + [Fact] + public void BuildAggregateSelectParts_Max_GeneratesMax() + { + var mapping = _registry.GetMapping(typeof(SelectTestEntity)); + var builder = new SqlSelectBuilder(_registry, Dialect); + var options = new QueryOptions + { + Aggregates = [new AggregateModel { Function = AggregateFunction.Max, Field = "Score", Alias = "maxScore" }] + }; + + var parts = builder.BuildAggregateSelectParts(options, mapping); + + parts.Should().ContainSingle().Which.Should().Be("MAX([Score]) AS [maxScore]"); + } + + [Fact] + public void BuildAggregateSelectParts_MultipleAggregates_ReturnsAll() + { + var mapping = _registry.GetMapping(typeof(SelectTestEntity)); + var builder = new SqlSelectBuilder(_registry, Dialect); + var options = new QueryOptions + { + Aggregates = + [ + new AggregateModel { Function = AggregateFunction.Count, Field = null, Alias = "total" }, + new AggregateModel { Function = AggregateFunction.Sum, Field = "Score", Alias = "sumScore" }, + new AggregateModel { Function = AggregateFunction.Avg, Field = "Score", Alias = "avgScore" } + ] + }; + + var parts = builder.BuildAggregateSelectParts(options, mapping); + + parts.Should().HaveCount(3); + parts[0].Should().Be("COUNT(1) AS [total]"); + parts[1].Should().Be("SUM([Score]) AS [sumScore]"); + parts[2].Should().Be("AVG([Score]) AS [avgScore]"); + } + + [Fact] + public void BuildAggregateSelectParts_WithTableAlias_UsesAlias() + { + var registry = new MappingRegistry(); + registry.Entity().ToTable("entities").HasAlias("e"); + var mapping = registry.GetMapping(typeof(SelectTestEntity)); + var builder = new SqlSelectBuilder(registry, Dialect); + var options = new QueryOptions + { + Aggregates = [new AggregateModel { Function = AggregateFunction.Sum, Field = "Score", Alias = "total" }] + }; + + var parts = builder.BuildAggregateSelectParts(options, mapping); + + parts.Should().ContainSingle().Which.Should().Be("SUM([e].[Score]) AS [total]"); + } + + // ── BuildSelectClause ──────────────────────────────────────────────── + + [Fact] + public void BuildSelectClause_NoSelect_FallsBackToAllColumns() + { + var mapping = _registry.GetMapping(typeof(SelectTestEntity)); + var builder = new SqlSelectBuilder(_registry, Dialect); + var options = new QueryOptions(); + var tree = new SelectionNode(); + + var result = builder.BuildSelectClause(options, mapping, string.Empty, tree); + + result.Should().Be("SELECT [Id] AS [Id], [Name] AS [Name], [Status] AS [Status], [Score] AS [Score]"); + } + + [Fact] + public void BuildSelectClause_WithSelect_IncludesOnlySelectedFields() + { + var mapping = _registry.GetMapping(typeof(SelectTestEntity)); + var builder = new SqlSelectBuilder(_registry, Dialect); + var options = new QueryOptions + { + Select = ["Name", "Score"] + }; + var tree = new SelectionNode(); + tree.GetOrAddChild("Name"); + tree.GetOrAddChild("Score"); + + var result = builder.BuildSelectClause(options, mapping, string.Empty, tree); + + result.Should().Be("SELECT [Name] AS [Name], [Score] AS [Score]"); + } + + [Fact] + public void BuildSelectClause_WithDistinct_IncludesDistinct() + { + var mapping = _registry.GetMapping(typeof(SelectTestEntity)); + var builder = new SqlSelectBuilder(_registry, Dialect); + var options = new QueryOptions + { + Select = ["Name"] + }; + var tree = new SelectionNode(); + tree.GetOrAddChild("Name"); + + var result = builder.BuildSelectClause(options, mapping, "DISTINCT", tree); + + result.Should().Be("SELECT DISTINCT [Name] AS [Name]"); + } + + [Fact] + public void BuildSelectClause_WithGroupByAndAggregates_IncludesBoth() + { + var mapping = _registry.GetMapping(typeof(SelectTestEntity)); + var builder = new SqlSelectBuilder(_registry, Dialect); + var options = new QueryOptions + { + GroupBy = ["Status"], + Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = null, Alias = "cnt" }] + }; + var tree = new SelectionNode(); + + var result = builder.BuildSelectClause(options, mapping, string.Empty, tree); + + result.Should().Be("SELECT [Status], COUNT(1) AS [cnt]"); + } + + [Fact] + public void BuildSelectClause_WithGroupByOnly_ReturnsGroupByColumns() + { + var mapping = _registry.GetMapping(typeof(SelectTestEntity)); + var builder = new SqlSelectBuilder(_registry, Dialect); + var options = new QueryOptions + { + GroupBy = ["Status", "Name"] + }; + var tree = new SelectionNode(); + + var result = builder.BuildSelectClause(options, mapping, string.Empty, tree); + + result.Should().Be("SELECT [Status], [Name]"); + } + + [Fact] + public void BuildSelectClause_WithAlias_UsesAlias() + { + var mapping = _registry.GetMapping(typeof(SelectTestEntity)); + var builder = new SqlSelectBuilder(_registry, Dialect); + var options = new QueryOptions(); + var tree = new SelectionNode(); + var nameNode = tree.GetOrAddChild("Name"); + nameNode.Alias = "UserName"; + + var result = builder.BuildSelectClause(options, mapping, string.Empty, tree); + + result.Should().Be("SELECT [Name] AS [UserName]"); + } + + [Fact] + public void BuildSelectClause_WithNavigation_IncludesNavigationColumns() + { + var registry = new MappingRegistry(); + registry.Entity() + .ToTable("Customers") + .HasMany(c => c.Orders).WithForeignKey("CustomerId"); + registry.Entity() + .ToTable("Orders"); + + var mapping = registry.GetMapping(typeof(SqlCustomer)); + var builder = new SqlSelectBuilder(registry, Dialect); + var options = new QueryOptions(); + var tree = new SelectionNode(); + var ordersNode = tree.GetOrAddChild("Orders"); + ordersNode.MarkIncludeAllScalars(); + + var result = builder.BuildSelectClause(options, mapping, string.Empty, tree); + + result.Should().Contain("SELECT"); + result.Should().Contain("[Orders].[Id] AS [Orders_Id]"); + result.Should().Contain("[Orders].[Number] AS [Orders_Number]"); + result.Should().Contain("[Orders].[Total] AS [Orders_Total]"); + } + + [Fact] + public void BuildSelectClause_WithNavigationAndSpecificFields_IncludesOnlySelected() + { + var registry = new MappingRegistry(); + registry.Entity() + .ToTable("Customers") + .HasMany(c => c.Orders).WithForeignKey("CustomerId"); + registry.Entity() + .ToTable("Orders"); + + var mapping = registry.GetMapping(typeof(SqlCustomer)); + var builder = new SqlSelectBuilder(registry, Dialect); + var options = new QueryOptions(); + var tree = new SelectionNode(); + var ordersNode = tree.GetOrAddChild("Orders"); + ordersNode.GetOrAddChild("Total"); + ordersNode.GetOrAddChild("Number"); + + var result = builder.BuildSelectClause(options, mapping, string.Empty, tree); + + result.Should().Be("SELECT [Orders].[Total] AS [Orders_Total], [Orders].[Number] AS [Orders_Number]"); + } + + [Fact] + public void BuildSelectClause_WithPostgreSql_UsesCorrectQuoting() + { + var mapping = _registry.GetMapping(typeof(SelectTestEntity)); + var builder = new SqlSelectBuilder(_registry, new PostgreSqlDialect()); + var options = new QueryOptions(); + var tree = new SelectionNode(); + tree.GetOrAddChild("Name"); + + var result = builder.BuildSelectClause(options, mapping, string.Empty, tree); + + result.Should().Be("SELECT \"Name\" AS \"Name\""); + } + + [Fact] + public void BuildSelectClause_WithMySql_UsesCorrectQuoting() + { + var mapping = _registry.GetMapping(typeof(SelectTestEntity)); + var builder = new SqlSelectBuilder(_registry, new MySqlDialect()); + var options = new QueryOptions(); + var tree = new SelectionNode(); + tree.GetOrAddChild("Name"); + + var result = builder.BuildSelectClause(options, mapping, string.Empty, tree); + + result.Should().Be("SELECT `Name` AS `Name`"); + } + + [Fact] + public void BuildSelectClause_WithTableAlias_UsesAlias() + { + var registry = new MappingRegistry(); + registry.Entity().ToTable("entities").HasAlias("e"); + var mapping = registry.GetMapping(typeof(SelectTestEntity)); + var builder = new SqlSelectBuilder(registry, Dialect); + var options = new QueryOptions(); + var tree = new SelectionNode(); + tree.GetOrAddChild("Name"); + + var result = builder.BuildSelectClause(options, mapping, string.Empty, tree); + + result.Should().Be("SELECT [e].[Name] AS [Name]"); + } + + // ── BuildFlatSelectClause ──────────────────────────────────────────── + + [Fact] + public void BuildFlatSelectClause_NoNavPath_ReturnsRootColumns() + { + var registry = new MappingRegistry(); + registry.Entity().ToTable("Customers"); + + var mapping = registry.GetMapping(typeof(SqlCustomer)); + var builder = new SqlSelectBuilder(registry, Dialect); + var options = new QueryOptions + { + ProjectionMode = ProjectionMode.Flat, + Select = ["Name", "Email"] + }; + var tree = new SelectionNode(); + tree.GetOrAddChild("Name"); + tree.GetOrAddChild("Email"); + + var (selectClause, joinClause, flatJoins) = builder.BuildFlatSelectClause(options, mapping, string.Empty, tree); + + selectClause.Should().Be("SELECT [Customers].[Name] AS [Name], [Customers].[Email] AS [Email]"); + joinClause.Should().BeEmpty(); + flatJoins.Should().BeEmpty(); + } + + [Fact] + public void BuildFlatSelectClause_WithNavigation_GeneratesJoins() + { + var registry = new MappingRegistry(); + registry.Entity() + .ToTable("Customers") + .HasMany(c => c.Orders).WithForeignKey("CustomerId"); + registry.Entity() + .ToTable("Orders"); + + var mapping = registry.GetMapping(typeof(SqlCustomer)); + var builder = new SqlSelectBuilder(registry, Dialect); + var options = new QueryOptions + { + ProjectionMode = ProjectionMode.Flat, + Select = ["Orders.Total"] + }; + var tree = new SelectionNode(); + var ordersNode = tree.GetOrAddChild("Orders"); + ordersNode.GetOrAddChild("Total"); + + var (selectClause, joinClause, flatJoins) = builder.BuildFlatSelectClause(options, mapping, string.Empty, tree); + + selectClause.Should().Be("SELECT [Orders].[Total] AS [Total]"); + joinClause.Should().Contain("LEFT JOIN"); + joinClause.Should().Contain("[Orders]"); + flatJoins.Should().Contain("Orders"); + } + + [Fact] + public void BuildFlatSelectClause_MultiLevel_GeneratesMultipleJoins() + { + var registry = new MappingRegistry(); + registry.Entity() + .ToTable("Customers") + .HasMany(c => c.Orders).WithForeignKey("CustomerId"); + registry.Entity() + .ToTable("Orders") + .HasMany(o => o.Items).WithForeignKey("OrderId"); + registry.Entity() + .ToTable("OrderItems"); + + var mapping = registry.GetMapping(typeof(SqlCustomer)); + var builder = new SqlSelectBuilder(registry, Dialect); + var options = new QueryOptions + { + ProjectionMode = ProjectionMode.Flat, + Select = ["Orders.Items.Sku"] + }; + var tree = new SelectionNode(); + var ordersNode = tree.GetOrAddChild("Orders"); + var itemsNode = ordersNode.GetOrAddChild("Items"); + itemsNode.GetOrAddChild("Sku"); + + var (selectClause, joinClause, flatJoins) = builder.BuildFlatSelectClause(options, mapping, string.Empty, tree); + + selectClause.Should().Be("SELECT [Items].[Sku] AS [Sku]"); + joinClause.Should().Contain("LEFT JOIN [Orders]"); + joinClause.Should().Contain("LEFT JOIN [OrderItems]"); + flatJoins.Should().Contain("Orders"); + flatJoins.Should().Contain("Items"); + } + + [Fact] + public void BuildFlatSelectClause_FlatMixed_IncludesRootScalars() + { + var registry = new MappingRegistry(); + registry.Entity() + .ToTable("Customers") + .HasMany(c => c.Orders).WithForeignKey("CustomerId"); + registry.Entity() + .ToTable("Orders"); + + var mapping = registry.GetMapping(typeof(SqlCustomer)); + var builder = new SqlSelectBuilder(registry, Dialect); + var options = new QueryOptions + { + ProjectionMode = ProjectionMode.FlatMixed, + Select = ["Name", "Orders.Total"] + }; + var tree = new SelectionNode(); + tree.GetOrAddChild("Name"); + var ordersNode = tree.GetOrAddChild("Orders"); + ordersNode.GetOrAddChild("Total"); + + var (selectClause, joinClause, flatJoins) = builder.BuildFlatSelectClause(options, mapping, string.Empty, tree); + + selectClause.Should().Contain("[Customers].[Name] AS [Name]"); + selectClause.Should().Contain("[Orders].[Total] AS [Total]"); + joinClause.Should().Contain("LEFT JOIN"); + flatJoins.Should().Contain("Orders"); + } + + [Fact] + public void BuildFlatSelectClause_NoSelect_FallsBackToAllColumns() + { + var registry = new MappingRegistry(); + registry.Entity() + .ToTable("Customers") + .HasMany(c => c.Orders).WithForeignKey("CustomerId"); + registry.Entity() + .ToTable("Orders"); + + var mapping = registry.GetMapping(typeof(SqlCustomer)); + var builder = new SqlSelectBuilder(registry, Dialect); + var options = new QueryOptions + { + ProjectionMode = ProjectionMode.Flat, + Select = ["Orders.Total"] + }; + var tree = new SelectionNode(); + var ordersNode = tree.GetOrAddChild("Orders"); + ordersNode.GetOrAddChild("Total"); + + var (selectClause, joinClause, flatJoins) = builder.BuildFlatSelectClause(options, mapping, string.Empty, tree); + + selectClause.Should().Be("SELECT [Orders].[Total] AS [Total]"); + joinClause.Should().Contain("LEFT JOIN"); + flatJoins.Should().Contain("Orders"); + } + + [Fact] + public void BuildFlatSelectClause_BranchingNavPath_Throws() + { + var registry = new MappingRegistry(); + registry.Entity() + .ToTable("Customers") + .HasMany(c => c.Orders).WithForeignKey("CustomerId"); + registry.Entity() + .HasOne(c => c.Address).WithForeignKey("CustomerId"); + registry.Entity() + .ToTable("Orders"); + registry.Entity() + .ToTable("Addresses"); + + var mapping = registry.GetMapping(typeof(SqlCustomer)); + var builder = new SqlSelectBuilder(registry, Dialect); + var options = new QueryOptions + { + ProjectionMode = ProjectionMode.Flat + }; + var tree = new SelectionNode(); + tree.GetOrAddChild("Orders").GetOrAddChild("Total"); + tree.GetOrAddChild("Address").GetOrAddChild("City"); + + var act = () => builder.BuildFlatSelectClause(options, mapping, string.Empty, tree); + + act.Should().Throw(); + } + + [Fact] + public void BuildFlatSelectClause_WithPostgreSql_UsesCorrectQuoting() + { + var registry = new MappingRegistry(); + registry.Entity() + .ToTable("Customers") + .HasMany(c => c.Orders).WithForeignKey("CustomerId"); + registry.Entity() + .ToTable("Orders"); + + var mapping = registry.GetMapping(typeof(SqlCustomer)); + var builder = new SqlSelectBuilder(registry, new PostgreSqlDialect()); + var options = new QueryOptions + { + ProjectionMode = ProjectionMode.Flat, + Select = ["Orders.Total"] + }; + var tree = new SelectionNode(); + var ordersNode = tree.GetOrAddChild("Orders"); + ordersNode.GetOrAddChild("Total"); + + var (selectClause, joinClause, flatJoins) = builder.BuildFlatSelectClause(options, mapping, string.Empty, tree); + + selectClause.Should().Contain("\"Orders\".\"Total\""); + joinClause.Should().Contain("LEFT JOIN"); + } + + [Fact] + public void BuildFlatSelectClause_WithMySql_UsesCorrectQuoting() + { + var registry = new MappingRegistry(); + registry.Entity() + .ToTable("Customers") + .HasMany(c => c.Orders).WithForeignKey("CustomerId"); + registry.Entity() + .ToTable("Orders"); + + var mapping = registry.GetMapping(typeof(SqlCustomer)); + var builder = new SqlSelectBuilder(registry, new MySqlDialect()); + var options = new QueryOptions + { + ProjectionMode = ProjectionMode.Flat, + Select = ["Orders.Total"] + }; + var tree = new SelectionNode(); + var ordersNode = tree.GetOrAddChild("Orders"); + ordersNode.GetOrAddChild("Total"); + + var (selectClause, joinClause, flatJoins) = builder.BuildFlatSelectClause(options, mapping, string.Empty, tree); + + selectClause.Should().Contain("`Orders`.`Total`"); + joinClause.Should().Contain("LEFT JOIN"); + } + + private sealed class SelectTestEntity + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public decimal Score { get; set; } + } +} diff --git a/tests/FlexQuery.NET.Tests/DependencyInjection/AspNetCoreServiceCollectionExtensionsTests.cs b/tests/FlexQuery.NET.Tests/DependencyInjection/AspNetCoreServiceCollectionExtensionsTests.cs index bac3492..0581f14 100644 --- a/tests/FlexQuery.NET.Tests/DependencyInjection/AspNetCoreServiceCollectionExtensionsTests.cs +++ b/tests/FlexQuery.NET.Tests/DependencyInjection/AspNetCoreServiceCollectionExtensionsTests.cs @@ -1,3 +1,4 @@ +using FlexQuery.NET.AspNetCore.Filters; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -16,8 +17,10 @@ public void AddFlexQuerySecurity_registers_FieldAccessFilter() mvcBuilder.AddFlexQuerySecurity(); var serviceProvider = services.BuildServiceProvider(); - var filters = serviceProvider.GetRequiredService>(); + var mvcOptions = serviceProvider.GetRequiredService>().Value; - filters.Should().NotBeNull(); + mvcOptions.Filters + .Any(f => f is TypeFilterAttribute t && t.ImplementationType == typeof(FieldAccessFilter)) + .Should().BeTrue(); } } diff --git a/tests/FlexQuery.NET.Tests/DependencyInjection/FlexQueryAspNetCoreConfigurationTests.cs b/tests/FlexQuery.NET.Tests/DependencyInjection/FlexQueryAspNetCoreConfigurationTests.cs index da76a87..43a05af 100644 --- a/tests/FlexQuery.NET.Tests/DependencyInjection/FlexQueryAspNetCoreConfigurationTests.cs +++ b/tests/FlexQuery.NET.Tests/DependencyInjection/FlexQueryAspNetCoreConfigurationTests.cs @@ -4,6 +4,11 @@ namespace FlexQuery.NET.Tests.DependencyInjection; public class FlexQueryAspNetCoreConfigurationTests { + public FlexQueryAspNetCoreConfigurationTests() + { + FlexQueryCore.Reset(); + } + [Fact] public void Configure_returns_FlexQueryOptions() { diff --git a/tests/FlexQuery.NET.Tests/DependencyInjection/FlexQueryDapperConfigurationTests.cs b/tests/FlexQuery.NET.Tests/DependencyInjection/FlexQueryDapperConfigurationTests.cs index b036b04..710a59c 100644 --- a/tests/FlexQuery.NET.Tests/DependencyInjection/FlexQueryDapperConfigurationTests.cs +++ b/tests/FlexQuery.NET.Tests/DependencyInjection/FlexQueryDapperConfigurationTests.cs @@ -6,6 +6,11 @@ namespace FlexQuery.NET.Tests.DependencyInjection; public class FlexQueryDapperConfigurationTests { + public FlexQueryDapperConfigurationTests() + { + FlexQueryDapper.Reset(); + } + [Fact] public void Configure_stores_global_model() { diff --git a/tests/FlexQuery.NET.Tests/DependencyInjection/FlexQueryEFCoreConfigurationTests.cs b/tests/FlexQuery.NET.Tests/DependencyInjection/FlexQueryEFCoreConfigurationTests.cs index e950435..54e0fb8 100644 --- a/tests/FlexQuery.NET.Tests/DependencyInjection/FlexQueryEFCoreConfigurationTests.cs +++ b/tests/FlexQuery.NET.Tests/DependencyInjection/FlexQueryEFCoreConfigurationTests.cs @@ -5,6 +5,11 @@ namespace FlexQuery.NET.Tests.DependencyInjection; public class FlexQueryEFCoreConfigurationTests { + public FlexQueryEFCoreConfigurationTests() + { + FlexQueryEFCore.Reset(); + } + [Fact] public void Setup_Does_Not_Throw() { diff --git a/tests/FlexQuery.NET.Tests/DependencyInjection/FlexQueryNonDiSmokeTests.cs b/tests/FlexQuery.NET.Tests/DependencyInjection/FlexQueryNonDiSmokeTests.cs index 982ddbc..148403a 100644 --- a/tests/FlexQuery.NET.Tests/DependencyInjection/FlexQueryNonDiSmokeTests.cs +++ b/tests/FlexQuery.NET.Tests/DependencyInjection/FlexQueryNonDiSmokeTests.cs @@ -7,6 +7,13 @@ namespace FlexQuery.NET.Tests.DependencyInjection; public class FlexQueryNonDiSmokeTests { + public FlexQueryNonDiSmokeTests() + { + FlexQueryCore.Reset(); + FlexQueryEFCore.Reset(); + FlexQueryDapper.Reset(); + } + [Fact] public void Full_setup_pipeline_does_not_throw() { diff --git a/tests/FlexQuery.NET.Tests/Diagnostics/ConsoleExecutionListenerTests.cs b/tests/FlexQuery.NET.Tests/Diagnostics/ConsoleExecutionListenerTests.cs new file mode 100644 index 0000000..e7d285b --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Diagnostics/ConsoleExecutionListenerTests.cs @@ -0,0 +1,90 @@ +using System.Diagnostics; +using FlexQuery.NET.Diagnostics; +using FlexQuery.NET.Models; +using Xunit; + +namespace FlexQuery.NET.Tests.Diagnostics; + +/// +/// Verifies writes every lifecycle event to the +/// console without throwing, including the exception paths. +/// +public class ConsoleExecutionListenerTests +{ + [Fact] + public void AllEvents_WriteToConsole_WithoutThrowing() + { + var listener = new ConsoleExecutionListener(); + var original = Console.Out; + var buffer = new StringWriter(); + Console.SetOut(buffer); + try + { + var id = Guid.NewGuid(); + var act = () => + { + listener.QueryParsedAsync(new QueryParsedEvent(id, new QueryOptions(), TimeSpan.FromMilliseconds(1), DateTimeOffset.UtcNow), CancellationToken.None).GetAwaiter().GetResult(); + listener.QueryTranslatedAsync(new QueryTranslatedEvent(id, "SELECT 1", [new QueryParameter("p0", 42)], TimeSpan.FromMilliseconds(2), DateTimeOffset.UtcNow), CancellationToken.None).GetAwaiter().GetResult(); + listener.QueryExecutedAsync(new QueryExecutedEvent(id, 5, null, TimeSpan.FromMilliseconds(3), DateTimeOffset.UtcNow), CancellationToken.None).GetAwaiter().GetResult(); + listener.QueryMaterializedAsync(new QueryMaterializedEvent(id, new QueryResult(), null, TimeSpan.FromMilliseconds(4), DateTimeOffset.UtcNow), CancellationToken.None).GetAwaiter().GetResult(); + }; + act.Should().NotThrow(); + } + finally + { + Console.SetOut(original); + } + + var output = buffer.ToString(); + output.Should().Contain("Parsed"); + output.Should().Contain("Translated"); + output.Should().Contain("SQL: SELECT 1"); + output.Should().Contain("p0 = 42"); + output.Should().Contain("rows=5"); + output.Should().Contain("Materialized"); + } + + [Fact] + public void ExecutedWithException_WritesErrorMessage() + { + var listener = new ConsoleExecutionListener(); + var original = Console.Out; + var buffer = new StringWriter(); + Console.SetOut(buffer); + try + { + var id = Guid.NewGuid(); + listener.QueryExecutedAsync( + new QueryExecutedEvent(id, null, new InvalidOperationException("kaboom"), TimeSpan.FromMilliseconds(1), DateTimeOffset.UtcNow), + CancellationToken.None).GetAwaiter().GetResult(); + } + finally + { + Console.SetOut(original); + } + + buffer.ToString().Should().Contain("kaboom"); + } + + [Fact] + public void MaterializedWithException_WritesErrorMessage() + { + var listener = new ConsoleExecutionListener(); + var original = Console.Out; + var buffer = new StringWriter(); + Console.SetOut(buffer); + try + { + var id = Guid.NewGuid(); + listener.QueryMaterializedAsync( + new QueryMaterializedEvent(id, null, new Exception("materialize-fail"), TimeSpan.FromMilliseconds(1), DateTimeOffset.UtcNow), + CancellationToken.None).GetAwaiter().GetResult(); + } + finally + { + Console.SetOut(original); + } + + buffer.ToString().Should().Contain("materialize-fail"); + } +} diff --git a/tests/FlexQuery.NET.Tests/Tests/DebugTests.cs b/tests/FlexQuery.NET.Tests/Diagnostics/DebugTests.cs similarity index 97% rename from tests/FlexQuery.NET.Tests/Tests/DebugTests.cs rename to tests/FlexQuery.NET.Tests/Diagnostics/DebugTests.cs index 8330342..b0bda5a 100644 --- a/tests/FlexQuery.NET.Tests/Tests/DebugTests.cs +++ b/tests/FlexQuery.NET.Tests/Diagnostics/DebugTests.cs @@ -1,6 +1,6 @@ using FlexQuery.NET.Parsers; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Diagnostics; public class DebugTests { diff --git a/tests/FlexQuery.NET.Tests/Diagnostics/FlexQueryDiagnosticsBuildReportTests.cs b/tests/FlexQuery.NET.Tests/Diagnostics/FlexQueryDiagnosticsBuildReportTests.cs new file mode 100644 index 0000000..a2c5941 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Diagnostics/FlexQueryDiagnosticsBuildReportTests.cs @@ -0,0 +1,225 @@ +using System.Diagnostics; +using FlexQuery.NET.Diagnostics; +using FlexQuery.NET.Models; +using Xunit; + +namespace FlexQuery.NET.Tests.Diagnostics; + +/// +/// Covers timeline/duration math, +/// partial-stage chains, ordering, and the QueryId fallback resolution across stages. +/// +public class FlexQueryDiagnosticsBuildReportTests +{ + private static readonly Guid QueryId = Guid.NewGuid(); + + [Fact] + public void BuildReport_FullChain_ComputesPerStageDurationsAndTotal() + { + var collector = new FlexQueryDiagnosticsCollector(); + var baseTime = DateTimeOffset.UtcNow; + + // Cumulative durations: parsed=10, translated=30, executed=50, materialized=60 (ms). + var parsed = new QueryParsedEvent(QueryId, new QueryOptions(), TimeSpan.FromMilliseconds(10), baseTime.AddMilliseconds(10)); + var translated = new QueryTranslatedEvent(QueryId, "SELECT 1", null, TimeSpan.FromMilliseconds(30), baseTime.AddMilliseconds(40)); + var executed = new QueryExecutedEvent(QueryId, 7, null, TimeSpan.FromMilliseconds(50), baseTime.AddMilliseconds(90)); + var materialized = new QueryMaterializedEvent(QueryId, MakeResult(2), null, TimeSpan.FromMilliseconds(60), baseTime.AddMilliseconds(150)); + + collector.QueryParsedAsync(parsed, CancellationToken.None); + collector.QueryTranslatedAsync(translated, CancellationToken.None); + collector.QueryExecutedAsync(executed, CancellationToken.None); + collector.QueryMaterializedAsync(materialized, CancellationToken.None); + + var report = collector.BuildReport(provider: "EFCore", translator: "EfCoreTranslator"); + + report.QueryId.Should().Be(QueryId); + report.Provider.Should().Be("EFCore"); + report.Translator.Should().Be("EfCoreTranslator"); + report.Sql.Should().Be("SELECT 1"); + report.Rows.Should().Be(2); + + // Total = last.End - first.Start = 150ms - 0ms + report.Duration.TotalMs.Should().BeApproximately(150, 0.001); + report.Duration.ParseMs.Should().BeApproximately(10, 0.001); + report.Duration.TranslateMs.Should().BeApproximately(20, 0.001); // 30 - 10 + report.Duration.DatabaseMs.Should().BeApproximately(20, 0.001); // 50 - 30 + report.Duration.MaterializeMs.Should().BeApproximately(10, 0.001); // 60 - 50 + + report.Timeline.Should().HaveCount(4); + report.Timeline[0].Stage.Should().Be("Parsing"); + report.Timeline[1].Stage.Should().Be("Translation"); + report.Timeline[2].Stage.Should().Be("DatabaseExecution"); + report.Timeline[3].Stage.Should().Be("Materialization"); + } + + [Fact] + public void BuildReport_OnlyParsed_HasSingleEntry() + { + var collector = new FlexQueryDiagnosticsCollector(); + collector.QueryParsedAsync( + new QueryParsedEvent(QueryId, new QueryOptions(), TimeSpan.FromMilliseconds(5), DateTimeOffset.UtcNow), + CancellationToken.None); + + var report = collector.BuildReport(); + + report.QueryId.Should().Be(QueryId); + report.Rows.Should().BeNull(); + report.Duration.ParseMs.Should().BeApproximately(5, 0.001); + report.Duration.TranslateMs.Should().BeNull(); + report.Duration.DatabaseMs.Should().BeNull(); + report.Duration.MaterializeMs.Should().BeNull(); + report.Timeline.Should().ContainSingle().Which.Stage.Should().Be("Parsing"); + } + + [Fact] + public void BuildReport_OnlyExecuted_UsesExecutedAsParseDuration() + { + var collector = new FlexQueryDiagnosticsCollector(); + collector.QueryExecutedAsync( + new QueryExecutedEvent(QueryId, 3, null, TimeSpan.FromMilliseconds(12), DateTimeOffset.UtcNow), + CancellationToken.None); + + var report = collector.BuildReport(); + + report.QueryId.Should().Be(QueryId); + report.Rows.Should().Be(3); + report.Duration.DatabaseMs.Should().BeApproximately(12, 0.001); + report.Duration.ParseMs.Should().BeNull(); + report.Timeline.Should().ContainSingle().Which.Stage.Should().Be("DatabaseExecution"); + } + + [Fact] + public void BuildReport_ParsedAndExecuted_MaterializeIsNull() + { + var collector = new FlexQueryDiagnosticsCollector(); + collector.QueryParsedAsync( + new QueryParsedEvent(QueryId, new QueryOptions(), TimeSpan.FromMilliseconds(8), DateTimeOffset.UtcNow), + CancellationToken.None); + collector.QueryExecutedAsync( + new QueryExecutedEvent(QueryId, 4, null, TimeSpan.FromMilliseconds(20), DateTimeOffset.UtcNow), + CancellationToken.None); + + var report = collector.BuildReport(); + + report.Duration.ParseMs.Should().BeApproximately(8, 0.001); + report.Duration.DatabaseMs.Should().BeApproximately(12, 0.001); // 20 - 8 + report.Duration.MaterializeMs.Should().BeNull(); + report.Timeline.Should().HaveCount(2); + } + + [Fact] + public void BuildReport_WithExecutedException_SurfacesException() + { + var collector = new FlexQueryDiagnosticsCollector(); + var ex = new InvalidOperationException("boom"); + collector.QueryExecutedAsync( + new QueryExecutedEvent(QueryId, null, ex, TimeSpan.FromMilliseconds(5), DateTimeOffset.UtcNow), + CancellationToken.None); + + var report = collector.BuildReport(); + + report.Exception.Should().BeSameAs(ex); + } + + [Fact] + public void BuildReport_WithMaterializedException_PrefersMaterializedException() + { + var collector = new FlexQueryDiagnosticsCollector(); + var execEx = new Exception("exec"); + var matEx = new Exception("mat"); + collector.QueryExecutedAsync( + new QueryExecutedEvent(QueryId, null, execEx, TimeSpan.FromMilliseconds(5), DateTimeOffset.UtcNow), + CancellationToken.None); + collector.QueryMaterializedAsync( + new QueryMaterializedEvent(QueryId, null, matEx, TimeSpan.FromMilliseconds(8), DateTimeOffset.UtcNow), + CancellationToken.None); + + var report = collector.BuildReport(); + + report.Exception.Should().BeSameAs(matEx); + } + + [Fact] + public void BuildReport_QueryIdFallsBackAcrossStages() + { + var collector = new FlexQueryDiagnosticsCollector(); + // Only an executed event provides the id. + collector.QueryExecutedAsync( + new QueryExecutedEvent(QueryId, 1, null, TimeSpan.FromMilliseconds(1), DateTimeOffset.UtcNow), + CancellationToken.None); + + collector.BuildReport().QueryId.Should().Be(QueryId); + } + + [Fact] + public void BuildReport_NoEvents_ReturnsEmptyTimelineAndZeroDuration() + { + var collector = new FlexQueryDiagnosticsCollector(); + + var report = collector.BuildReport(); + + report.QueryId.Should().Be(Guid.Empty); + report.Timeline.Should().BeEmpty(); + report.Duration.TotalMs.Should().Be(0); + } + + [Fact] + public void Clear_RemovesEventsSoBuildReportIsEmpty() + { + var collector = new FlexQueryDiagnosticsCollector(); + collector.QueryParsedAsync( + new QueryParsedEvent(QueryId, new QueryOptions(), TimeSpan.Zero, DateTimeOffset.UtcNow), + CancellationToken.None); + collector.QueryExecutedAsync( + new QueryExecutedEvent(QueryId, 1, null, TimeSpan.FromMilliseconds(1), DateTimeOffset.UtcNow), + CancellationToken.None); + + collector.Clear(); + + collector.ParsedEvents.Should().BeEmpty(); + collector.ExecutedEvents.Should().BeEmpty(); + collector.BuildReport().Timeline.Should().BeEmpty(); + } + + [Fact] + public async Task ConcurrentRecording_AllEventsPreserved() + { + var collector = new FlexQueryDiagnosticsCollector(); + const int count = 50; + + var tasks = Enumerable.Range(0, count).Select(i => + { + var id = Guid.NewGuid(); + return Task.Run(async () => + { + await collector.QueryParsedAsync(new QueryParsedEvent(id, new QueryOptions(), TimeSpan.FromMilliseconds(1), DateTimeOffset.UtcNow), CancellationToken.None); + await collector.QueryExecutedAsync(new QueryExecutedEvent(id, 1, null, TimeSpan.FromMilliseconds(1), DateTimeOffset.UtcNow), CancellationToken.None); + }); + }).ToArray(); + + await Task.WhenAll(tasks); + + collector.ParsedEvents.Should().HaveCount(count); + collector.ExecutedEvents.Should().HaveCount(count); + } + + [Fact] + public void Snapshots_AreCopies_NotLiveReferences() + { + var collector = new FlexQueryDiagnosticsCollector(); + collector.QueryParsedAsync( + new QueryParsedEvent(QueryId, new QueryOptions(), TimeSpan.Zero, DateTimeOffset.UtcNow), + CancellationToken.None); + + var snapshot1 = collector.ParsedEvents; + collector.QueryParsedAsync( + new QueryParsedEvent(Guid.NewGuid(), new QueryOptions(), TimeSpan.Zero, DateTimeOffset.UtcNow), + CancellationToken.None); + + snapshot1.Should().HaveCount(1); + collector.ParsedEvents.Should().HaveCount(2); + } + + private static object MakeResult(int rows) => + new QueryResult { Data = Enumerable.Range(0, rows).Select(_ => new object()).ToList() }; +} diff --git a/tests/FlexQuery.NET.Tests/Diagnostics/FlexQueryDiagnosticsCollectorTests.cs b/tests/FlexQuery.NET.Tests/Diagnostics/FlexQueryDiagnosticsCollectorTests.cs new file mode 100644 index 0000000..3fc0a36 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Diagnostics/FlexQueryDiagnosticsCollectorTests.cs @@ -0,0 +1,45 @@ +using FlexQuery.NET.Diagnostics; +using FlexQuery.NET.Models; +using Xunit; + +namespace FlexQuery.NET.Tests.Diagnostics; + +public class FlexQueryDiagnosticsCollectorTests +{ + [Fact] + public async Task QueryParsedAsync_AndBuildReport_RecordsEvent() + { + var collector = new FlexQueryDiagnosticsCollector(); + var timestamp = DateTimeOffset.UtcNow; + + await collector.QueryParsedAsync(new QueryParsedEvent( + Guid.NewGuid(), + new QueryOptions(), + TimeSpan.FromMilliseconds(10), + timestamp), CancellationToken.None); + + collector.ParsedEvents.Should().HaveCount(1); + collector.ParsedEvents[0].Duration.TotalMilliseconds.Should().Be(10); + } + + [Fact] + public async Task BuildReport_WithNoEvents_ReturnsEmptyTimeline() + { + var collector = new FlexQueryDiagnosticsCollector(); + var report = collector.BuildReport(); + + report.Timeline.Should().BeEmpty(); + report.Duration.TotalMs.Should().Be(0); + } + + [Fact] + public async Task Clear_RemovesAllRecordedEvents() + { + var collector = new FlexQueryDiagnosticsCollector(); + await collector.QueryParsedAsync(new QueryParsedEvent(Guid.NewGuid(), new QueryOptions(), TimeSpan.Zero, DateTimeOffset.UtcNow), CancellationToken.None); + + collector.Clear(); + + collector.ParsedEvents.Should().BeEmpty(); + } +} diff --git a/tests/FlexQuery.NET.Tests/Diagnostics/FlexQueryDiagnosticsModelsTests.cs b/tests/FlexQuery.NET.Tests/Diagnostics/FlexQueryDiagnosticsModelsTests.cs new file mode 100644 index 0000000..236d531 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Diagnostics/FlexQueryDiagnosticsModelsTests.cs @@ -0,0 +1,73 @@ +using FlexQuery.NET.Diagnostics; +using Xunit; + +namespace FlexQuery.NET.Tests.Diagnostics; + +/// +/// Verifies the diagnostics report model types round-trip their init-only properties. +/// +public class FlexQueryDiagnosticsModelsTests +{ + [Fact] + public void FlexQueryDiagnosticsReport_PropertiesRoundTrip() + { + var entry = new TimelineEntry + { + Stage = "Parsing", + StartUtc = DateTimeOffset.UnixEpoch, + EndUtc = DateTimeOffset.UnixEpoch.AddMilliseconds(5), + DurationMs = 5 + }; + + var duration = new DiagnosticsDuration + { + TotalMs = 5, + ParseMs = 5, + TranslateMs = null, + DatabaseMs = null, + MaterializeMs = null + }; + + var report = new FlexQueryDiagnosticsReport + { + QueryId = Guid.NewGuid(), + Provider = "EFCore", + Translator = "EfCoreTranslator", + Sql = "SELECT 1", + Rows = 3, + Exception = new InvalidOperationException("x"), + Duration = duration, + Timeline = [entry] + }; + + report.QueryId.Should().NotBe(Guid.Empty); + report.Provider.Should().Be("EFCore"); + report.Translator.Should().Be("EfCoreTranslator"); + report.Sql.Should().Be("SELECT 1"); + report.Rows.Should().Be(3); + report.Exception.Should().NotBeNull(); + report.Duration.TotalMs.Should().Be(5); + report.Timeline.Should().ContainSingle().Which.Stage.Should().Be("Parsing"); + } + + [Fact] + public void TimelineEntry_DefaultsAreSafe() + { + var entry = new TimelineEntry(); + + entry.Stage.Should().Be(string.Empty); + entry.DurationMs.Should().Be(0); + } + + [Fact] + public void DiagnosticsDuration_DefaultsAreSafe() + { + var duration = new DiagnosticsDuration(); + + duration.TotalMs.Should().Be(0); + duration.ParseMs.Should().BeNull(); + duration.TranslateMs.Should().BeNull(); + duration.DatabaseMs.Should().BeNull(); + duration.MaterializeMs.Should().BeNull(); + } +} diff --git a/tests/FlexQuery.NET.Tests/Expressions/ExpressionMethodCacheTests.cs b/tests/FlexQuery.NET.Tests/Expressions/ExpressionMethodCacheTests.cs new file mode 100644 index 0000000..f7c489d --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Expressions/ExpressionMethodCacheTests.cs @@ -0,0 +1,206 @@ +using System.Reflection; +using FlexQuery.NET.Expressions; + +namespace FlexQuery.NET.Tests.Expressions; + +public class ExpressionMethodCacheTests +{ + private sealed class TestItem + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + [Fact] + public void QueryableGroupBy_ReturnsMethodInfo() + { + var method = ExpressionMethodCache.QueryableGroupBy(); + method.Should().NotBeNull(); + method.Name.Should().Be(nameof(Queryable.GroupBy)); + } + + [Fact] + public void QueryableSelect_ReturnsMethodInfo() + { + var method = ExpressionMethodCache.QueryableSelect(); + method.Should().NotBeNull(); + method.Name.Should().Be(nameof(Queryable.Select)); + } + + [Fact] + public void QueryableWhere_ReturnsMethodInfo() + { + var method = ExpressionMethodCache.QueryableWhere(); + method.Should().NotBeNull(); + method.Name.Should().Be(nameof(Queryable.Where)); + } + + [Fact] + public void QueryableOrderBy_ReturnsMethodInfo() + { + var method = ExpressionMethodCache.QueryableOrderBy(); + method.Should().NotBeNull(); + method.Name.Should().Be(nameof(Queryable.OrderBy)); + } + + [Fact] + public void QueryableOrderByDescending_ReturnsMethodInfo() + { + var method = ExpressionMethodCache.QueryableOrderByDescending(); + method.Should().NotBeNull(); + method.Name.Should().Be(nameof(Queryable.OrderByDescending)); + } + + [Fact] + public void QueryableThenBy_ReturnsMethodInfo() + { + var method = ExpressionMethodCache.QueryableThenBy(); + method.Should().NotBeNull(); + method.Name.Should().Be(nameof(Queryable.ThenBy)); + } + + [Fact] + public void QueryableThenByDescending_ReturnsMethodInfo() + { + var method = ExpressionMethodCache.QueryableThenByDescending(); + method.Should().NotBeNull(); + method.Name.Should().Be(nameof(Queryable.ThenByDescending)); + } + + [Fact] + public void QueryableSelectMany_ReturnsMethodInfo() + { + var method = ExpressionMethodCache.QueryableSelectMany(); + method.Should().NotBeNull(); + method.Name.Should().Be(nameof(Queryable.SelectMany)); + } + + [Fact] + public void EnumerableAnyWithPredicate_BindsCorrectType() + { + var method = ExpressionMethodCache.EnumerableAnyWithPredicate(typeof(TestItem)); + + method.Should().NotBeNull(); + method.Name.Should().Be(nameof(Enumerable.Any)); + method.GetGenericArguments().Should().ContainSingle().Which.Should().Be(typeof(TestItem)); + } + + [Fact] + public void EnumerableAll_BindsCorrectType() + { + var method = ExpressionMethodCache.EnumerableAll(typeof(TestItem)); + + method.Should().NotBeNull(); + method.Name.Should().Be(nameof(Enumerable.All)); + method.GetGenericArguments().Should().ContainSingle().Which.Should().Be(typeof(TestItem)); + } + + [Fact] + public void EnumerableCount_BindsCorrectType() + { + var method = ExpressionMethodCache.EnumerableCount(typeof(TestItem)); + + method.Should().NotBeNull(); + method.Name.Should().Be(nameof(Enumerable.Count)); + method.GetGenericArguments().Should().ContainSingle().Which.Should().Be(typeof(TestItem)); + } + + [Fact] + public void EnumerableToList_ReturnsOpenGeneric() + { + var method = ExpressionMethodCache.EnumerableToList(); + + method.Should().NotBeNull(); + method.Name.Should().Be(nameof(Enumerable.ToList)); + method.IsGenericMethodDefinition.Should().BeTrue(); + } + + [Fact] + public void EnumerableMinWithSelector_BindsCorrectTypes() + { + var method = ExpressionMethodCache.EnumerableMinWithSelector(typeof(TestItem), typeof(int)); + + method.Should().NotBeNull(); + method.Name.Should().Be(nameof(Enumerable.Min)); + } + + [Fact] + public void EnumerableMaxWithSelector_BindsCorrectTypes() + { + var method = ExpressionMethodCache.EnumerableMaxWithSelector(typeof(TestItem), typeof(string)); + + method.Should().NotBeNull(); + method.Name.Should().Be(nameof(Enumerable.Max)); + } + + [Fact] + public void EnumerableSumWithSelector_BindsCorrectTypes() + { + var method = ExpressionMethodCache.EnumerableSumWithSelector(typeof(TestItem), typeof(decimal)); + + method.Should().NotBeNull(); + method.Name.Should().Be(nameof(Enumerable.Sum)); + } + + [Fact] + public void EnumerableAverageWithSelector_BindsCorrectTypes() + { + var method = ExpressionMethodCache.EnumerableAverageWithSelector(typeof(TestItem), typeof(decimal)); + + method.Should().NotBeNull(); + method.Name.Should().Be(nameof(Enumerable.Average)); + } + + [Fact] + public void QueryableWhereSimple_ReturnsMethodInfo() + { + var method = ExpressionMethodCache.QueryableWhereSimple(); + + method.Should().NotBeNull(); + method.Name.Should().Be(nameof(Queryable.Where)); + } + + [Fact] + public void QueryableSelectSimple_ReturnsMethodInfo() + { + var method = ExpressionMethodCache.QueryableSelectSimple(); + + method.Should().NotBeNull(); + method.Name.Should().Be(nameof(Queryable.Select)); + } + + [Fact] + public void QueryableAsQueryable_ReturnsMethodInfo() + { + var method = ExpressionMethodCache.QueryableAsQueryable(); + method.Should().NotBeNull(); + method.Name.Should().Be(nameof(Queryable.AsQueryable)); + } + + [Fact] + public void CachedMethods_AreStable() + { + var method1 = ExpressionMethodCache.QueryableGroupBy(); + var method2 = ExpressionMethodCache.QueryableGroupBy(); + + method1.Should().BeSameAs(method2); + } + + [Fact] + public void EnumerableAnyWithPredicate_CachesPerType() + { + var method1 = ExpressionMethodCache.EnumerableAnyWithPredicate(typeof(TestItem)); + var method2 = ExpressionMethodCache.EnumerableAnyWithPredicate(typeof(TestItem)); + + method1.Should().BeSameAs(method2); + } + + [Fact] + public void EnumerableAnyWithPredicate_DifferentTypes_DifferentMethods() + { + var method1 = ExpressionMethodCache.EnumerableAnyWithPredicate(typeof(TestItem)); + var method2 = ExpressionMethodCache.EnumerableAnyWithPredicate(typeof(string)); + + method1.Should().NotBeSameAs(method2); + } +} diff --git a/tests/FlexQuery.NET.Tests/Expressions/FilterExpressionBuilderTests.cs b/tests/FlexQuery.NET.Tests/Expressions/FilterExpressionBuilderTests.cs new file mode 100644 index 0000000..d250e15 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Expressions/FilterExpressionBuilderTests.cs @@ -0,0 +1,216 @@ +using System.Linq.Expressions; +using FlexQuery.NET.Constants; +using FlexQuery.NET.Expressions; +using Xunit; + +namespace FlexQuery.NET.Tests.Expressions; + +public class FilterExpressionBuilderTests +{ + private class NullableModel + { + public int? Score { get; set; } + } + + private static Func Compile(Expression member, string op, string? value) + { + var expr = FilterExpressionBuilder.Build(member, op, value) + ?? throw new InvalidOperationException($"Build returned null for op={op} value={value}"); + var param = (ParameterExpression)((MemberExpression)member).Expression!; + return Expression.Lambda>(expr, param).Compile(); + } + + [Theory] + [InlineData("contains", "oh", "John", true)] + [InlineData("contains", "xy", "John", false)] + [InlineData("startswith", "Jo", "John", true)] + [InlineData("startswith", "oh", "John", false)] + [InlineData("endswith", "hn", "John", true)] + [InlineData("endswith", "Jo", "John", false)] + public void StringMethods_MatchCorrectly(string op, string value, string name, bool expected) + { + var param = Expression.Parameter(typeof(TestEntity)); + var member = Expression.Property(param, nameof(TestEntity.Name)); + var predicate = Compile(member, op, value); + + predicate(new TestEntity { Name = name }).Should().Be(expected); + } + + [Fact] + public void Contains_WithNullValue_UsesEmptyString() + { + var param = Expression.Parameter(typeof(TestEntity)); + var member = Expression.Property(param, nameof(TestEntity.Name)); + var expr = FilterExpressionBuilder.Build(member, FilterOperators.Contains, null); + + expr.Should().NotBeNull(); + expr!.NodeType.Should().Be(ExpressionType.Call); + } + + [Theory] + [InlineData("eq", "John", "John", true)] + [InlineData("eq", "John", "Jane", false)] + [InlineData("neq", "John", "Jane", true)] + [InlineData("neq", "John", "John", false)] + public void StringEqual_ComparesDirectly(string op, string value, string name, bool expected) + { + var param = Expression.Parameter(typeof(TestEntity)); + var member = Expression.Property(param, nameof(TestEntity.Name)); + var predicate = Compile(member, op, value); + + predicate(new TestEntity { Name = name }).Should().Be(expected); + } + + [Theory] + [InlineData("eq", "30", 30, true)] + [InlineData("eq", "30", 31, false)] + [InlineData("gt", "25", 30, true)] + [InlineData("gt", "25", 20, false)] + [InlineData("gte", "30", 30, true)] + [InlineData("lt", "30", 20, true)] + [InlineData("lte", "30", 30, true)] + public void NumericComparison_Evaluates(string op, string value, int age, bool expected) + { + var param = Expression.Parameter(typeof(TestEntity)); + var member = Expression.Property(param, nameof(TestEntity.Age)); + var predicate = Compile(member, op, value); + + predicate(new TestEntity { Age = age }).Should().Be(expected); + } + + [Fact] + public void In_WithCommaSeparatedValues_MatchesAny() + { + var param = Expression.Parameter(typeof(TestEntity)); + var member = Expression.Property(param, nameof(TestEntity.Age)); + var predicate = Compile(member, FilterOperators.In, "20,30,40"); + + predicate(new TestEntity { Age = 30 }).Should().BeTrue(); + predicate(new TestEntity { Age = 25 }).Should().BeFalse(); + } + + [Fact] + public void In_WithPartiallyUnconvertibleValues_SkipsBadValues() + { + var param = Expression.Parameter(typeof(TestEntity)); + var member = Expression.Property(param, nameof(TestEntity.Age)); + var predicate = Compile(member, FilterOperators.In, "20,abc,30"); + + predicate(new TestEntity { Age = 20 }).Should().BeTrue(); + predicate(new TestEntity { Age = 30 }).Should().BeTrue(); + predicate(new TestEntity { Age = 25 }).Should().BeFalse(); + } + + [Fact] + public void In_WithEmptyValue_ReturnsConstantFalse() + { + var param = Expression.Parameter(typeof(TestEntity)); + var member = Expression.Property(param, nameof(TestEntity.Age)); + var expr = FilterExpressionBuilder.Build(member, FilterOperators.In, " "); + + expr.Should().BeOfType() + .Which.Value.Should().Be(false); + } + + [Fact] + public void NotIn_NegatesInResult() + { + var param = Expression.Parameter(typeof(TestEntity)); + var member = Expression.Property(param, nameof(TestEntity.Age)); + var predicate = Compile(member, FilterOperators.NotIn, "20,30,40"); + + predicate(new TestEntity { Age = 25 }).Should().BeTrue(); + predicate(new TestEntity { Age = 30 }).Should().BeFalse(); + } + + [Fact] + public void Between_WithinBounds_Matches() + { + var param = Expression.Parameter(typeof(TestEntity)); + var member = Expression.Property(param, nameof(TestEntity.Age)); + var predicate = Compile(member, FilterOperators.Between, "20,40"); + + predicate(new TestEntity { Age = 30 }).Should().BeTrue(); + predicate(new TestEntity { Age = 50 }).Should().BeFalse(); + } + + [Fact] + public void Between_WithSingleBound_ReturnsNull() + { + var param = Expression.Parameter(typeof(TestEntity)); + var member = Expression.Property(param, nameof(TestEntity.Age)); + + FilterExpressionBuilder.Build(member, FilterOperators.Between, "20").Should().BeNull(); + } + + [Fact] + public void Between_OnNonComparableType_ReturnsNull() + { + var param = Expression.Parameter(typeof(TestEntity)); + var member = Expression.Property(param, nameof(TestEntity.Name)); + + FilterExpressionBuilder.Build(member, FilterOperators.Between, "a,z").Should().BeNull(); + } + + [Fact] + public void IsNull_OnNonNullableValueType_ReturnsConstantFalse() + { + var param = Expression.Parameter(typeof(TestEntity)); + var member = Expression.Property(param, nameof(TestEntity.Age)); + var expr = FilterExpressionBuilder.Build(member, FilterOperators.IsNull, null); + + expr.Should().BeOfType() + .Which.Value.Should().Be(false); + } + + [Fact] + public void IsNotNull_OnNonNullableValueType_ReturnsConstantTrue() + { + var param = Expression.Parameter(typeof(TestEntity)); + var member = Expression.Property(param, nameof(TestEntity.Age)); + var expr = FilterExpressionBuilder.Build(member, FilterOperators.IsNotNull, null); + + expr.Should().BeOfType() + .Which.Value.Should().Be(true); + } + + [Fact] + public void IsNull_OnNullableValueType_EvaluatesNullCheck() + { + var param = Expression.Parameter(typeof(NullableModel)); + var member = Expression.Property(param, nameof(NullableModel.Score)); + var predicate = Compile(member, FilterOperators.IsNull, null); + + predicate(new NullableModel { Score = null }).Should().BeTrue(); + predicate(new NullableModel { Score = 5 }).Should().BeFalse(); + } + + [Fact] + public void IsNotNull_OnNullableValueType_EvaluatesNullCheck() + { + var param = Expression.Parameter(typeof(NullableModel)); + var member = Expression.Property(param, nameof(NullableModel.Score)); + var predicate = Compile(member, FilterOperators.IsNotNull, null); + + predicate(new NullableModel { Score = 5 }).Should().BeTrue(); + predicate(new NullableModel { Score = null }).Should().BeFalse(); + } + + [Fact] + public void Comparison_OnNonComparableType_ReturnsNull() + { + var param = Expression.Parameter(typeof(TestEntity)); + var member = Expression.Property(param, nameof(TestEntity.Name)); + + FilterExpressionBuilder.Build(member, FilterOperators.GreaterThan, "x").Should().BeNull(); + } + + [Fact] + public void UnknownOperator_ReturnsNull() + { + var param = Expression.Parameter(typeof(TestEntity)); + var member = Expression.Property(param, nameof(TestEntity.Age)); + + FilterExpressionBuilder.Build(member, "bogus", "1").Should().BeNull(); + } +} diff --git a/tests/FlexQuery.NET.Tests/Filters/FilterAnalyzerTests.cs b/tests/FlexQuery.NET.Tests/Filters/FilterAnalyzerTests.cs new file mode 100644 index 0000000..3765156 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Filters/FilterAnalyzerTests.cs @@ -0,0 +1,189 @@ +using FlexQuery.NET.Filters; +using FlexQuery.NET.Models.Filters; + +namespace FlexQuery.NET.Tests.Filters; + +public class FilterAnalyzerTests +{ + [Fact] + public void ExtractForNavigation_EmptyGroup_ReturnsNull() + { + var group = new FilterGroupNode { Logic = LogicOperator.And }; + + var result = FilterAnalyzer.ExtractForNavigation(group, "Orders"); + + result.Should().BeNull(); + } + + [Fact] + public void ExtractForNavigation_NullNavigation_ReturnsNull() + { + var group = new FilterGroupNode + { + Logic = LogicOperator.And, + Children = [new FilterConditionNode { Field = "Orders.Total", Operator = "gt", Value = "100" }] + }; + + var result = FilterAnalyzer.ExtractForNavigation(group, ""); + + result.Should().BeNull(); + } + + [Fact] + public void ExtractForNavigation_AndGroup_ExtractsMatchingConditions() + { + var group = new FilterGroupNode + { + Logic = LogicOperator.And, + Children = + [ + new FilterConditionNode { Field = "Orders.Total", Operator = "gt", Value = "100" }, + new FilterConditionNode { Field = "Name", Operator = "eq", Value = "Alice" }, + new FilterConditionNode { Field = "Orders.Status", Operator = "eq", Value = "Shipped" } + ] + }; + + var result = FilterAnalyzer.ExtractForNavigation(group, "Orders"); + + result.Should().NotBeNull(); + result!.Logic.Should().Be(LogicOperator.And); + result.Children.Should().HaveCount(2); + result.Children.OfType().Should().Contain(c => c.Field == "Total" && c.Value == "100"); + result.Children.OfType().Should().Contain(c => c.Field == "Status" && c.Value == "Shipped"); + } + + [Fact] + public void ExtractForNavigation_OrGroup_WhenAllMatch_ReturnsExtracted() + { + var group = new FilterGroupNode + { + Logic = LogicOperator.Or, + Children = + [ + new FilterConditionNode { Field = "Orders.Total", Operator = "gt", Value = "100" }, + new FilterConditionNode { Field = "Orders.Total", Operator = "lt", Value = "200" } + ] + }; + + var result = FilterAnalyzer.ExtractForNavigation(group, "Orders"); + + result.Should().NotBeNull(); + result!.Children.Should().HaveCount(2); + } + + [Fact] + public void ExtractForNavigation_OrGroup_WhenNotAllMatch_ReturnsNull() + { + var group = new FilterGroupNode + { + Logic = LogicOperator.Or, + Children = + [ + new FilterConditionNode { Field = "Orders.Total", Operator = "gt", Value = "100" }, + new FilterConditionNode { Field = "Name", Operator = "eq", Value = "Alice" } + ] + }; + + var result = FilterAnalyzer.ExtractForNavigation(group, "Orders"); + + result.Should().BeNull(); + } + + [Fact] + public void ExtractForNavigation_NoMatchingConditions_ReturnsNull() + { + var group = new FilterGroupNode + { + Logic = LogicOperator.And, + Children = [new FilterConditionNode { Field = "Name", Operator = "eq", Value = "Alice" }] + }; + + var result = FilterAnalyzer.ExtractForNavigation(group, "Orders"); + + result.Should().BeNull(); + } + + [Fact] + public void ExtractForNavigation_StripsNavigationPrefix() + { + var group = new FilterGroupNode + { + Logic = LogicOperator.And, + Children = [new FilterConditionNode { Field = "Orders.Total", Operator = "gt", Value = "100" }] + }; + + var result = FilterAnalyzer.ExtractForNavigation(group, "Orders"); + + result!.Children.Should().ContainSingle() + .Which.Should().BeOfType() + .Which.Field.Should().Be("Total"); + } + + [Fact] + public void ExtractForNavigation_NestedGroup_ExtractsRecursively() + { + var group = new FilterGroupNode + { + Logic = LogicOperator.And, + Children = + [ + new FilterConditionNode { Field = "Name", Operator = "eq", Value = "Alice" }, + new FilterGroupNode + { + Logic = LogicOperator.And, + Children = + [ + new FilterConditionNode { Field = "Orders.Total", Operator = "gt", Value = "100" } + ] + } + ] + }; + + var result = FilterAnalyzer.ExtractForNavigation(group, "Orders"); + + result.Should().NotBeNull(); + result!.Children.Should().ContainSingle() + .Which.Should().BeOfType() + .Which.Children.Should().ContainSingle() + .Which.Should().BeOfType() + .Which.Field.Should().Be("Total"); + } + + [Fact] + public void CacheKey_Null_ReturnsEmpty() + { + FilterAnalyzer.CacheKey(null).Should().BeEmpty(); + } + + [Fact] + public void CacheKey_Deterministic() + { + var group = new FilterGroupNode + { + Logic = LogicOperator.And, + Children = [new FilterConditionNode { Field = "Name", Operator = "eq", Value = "Alice" }] + }; + + var key1 = FilterAnalyzer.CacheKey(group); + var key2 = FilterAnalyzer.CacheKey(group); + + key1.Should().Be(key2); + } + + [Fact] + public void ExtractForNavigation_ExactNavigationMatch_RebasesCorrectly() + { + var group = new FilterGroupNode + { + Logic = LogicOperator.And, + Children = [new FilterConditionNode { Field = "Orders.Total", Operator = "gt", Value = "100" }] + }; + + var result = FilterAnalyzer.ExtractForNavigation(group, "Orders"); + + result.Should().NotBeNull(); + result!.Children.Should().ContainSingle() + .Which.Should().BeOfType() + .Which.Field.Should().Be("Total"); + } +} diff --git a/tests/FlexQuery.NET.Tests/Filters/FilterComposerTests.cs b/tests/FlexQuery.NET.Tests/Filters/FilterComposerTests.cs new file mode 100644 index 0000000..f93dbbd --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Filters/FilterComposerTests.cs @@ -0,0 +1,76 @@ +using FlexQuery.NET.Filters; +using FlexQuery.NET.Models.Filters; + +namespace FlexQuery.NET.Tests.Filters; + +public class FilterComposerTests +{ + [Fact] + public void MergeFilters_NullLeft_ReturnsRight() + { + var right = new FilterGroup { Filters = [new FilterCondition { Field = "Name", Operator = "eq", Value = "test" }] }; + + var result = FilterComposer.MergeFilters(null, right); + + result.Should().BeSameAs(right); + } + + [Fact] + public void MergeFilters_NullRight_ReturnsLeft() + { + var left = new FilterGroup { Filters = [new FilterCondition { Field = "Name", Operator = "eq", Value = "test" }] }; + + var result = FilterComposer.MergeFilters(left, null); + + result.Should().BeSameAs(left); + } + + [Fact] + public void MergeFilters_BothNull_ReturnsNull() + { + var result = FilterComposer.MergeFilters(null, null); + + result.Should().BeNull(); + } + + [Fact] + public void MergeFilters_CombinesWithAndLogic() + { + var left = new FilterGroup { Filters = [new FilterCondition { Field = "Name", Operator = "eq", Value = "Alice" }] }; + var right = new FilterGroup { Filters = [new FilterCondition { Field = "Age", Operator = "gt", Value = "25" }] }; + + var result = FilterComposer.MergeFilters(left, right); + + result.Should().NotBeNull(); + result!.Logic.Should().Be(LogicOperator.And); + result.Groups.Should().HaveCount(2); + result.Groups[0].Should().BeSameAs(left); + result.Groups[1].Should().BeSameAs(right); + result.Filters.Should().BeEmpty(); + } + + [Fact] + public void MergeFilters_PreservesBothGroupsStructure() + { + var left = new FilterGroup + { + Logic = LogicOperator.Or, + Filters = + [ + new FilterCondition { Field = "Status", Operator = "eq", Value = "Active" }, + new FilterCondition { Field = "Status", Operator = "eq", Value = "Pending" } + ] + }; + var right = new FilterGroup + { + Filters = [new FilterCondition { Field = "Age", Operator = "gte", Value = "18" }] + }; + + var result = FilterComposer.MergeFilters(left, right); + + result!.Logic.Should().Be(LogicOperator.And); + result.Groups.Should().HaveCount(2); + result.Groups[0].Should().BeSameAs(left); + result.Groups[1].Should().BeSameAs(right); + } +} diff --git a/tests/FlexQuery.NET.Tests/Filters/FilterNormalizerTests.cs b/tests/FlexQuery.NET.Tests/Filters/FilterNormalizerTests.cs new file mode 100644 index 0000000..3d84482 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Filters/FilterNormalizerTests.cs @@ -0,0 +1,250 @@ +using FlexQuery.NET.Filters; +using FlexQuery.NET.Models.Filters; + +namespace FlexQuery.NET.Tests.Filters; + +public class FilterNormalizerTests +{ + [Fact] + public void Normalize_Null_ReturnsNull() + { + FilterNormalizer.Normalize(null).Should().BeNull(); + } + + [Fact] + public void Normalize_EmptyGroup_ReturnsEmptyGroup() + { + var group = new FilterGroupNode { Logic = LogicOperator.And }; + + var result = FilterNormalizer.Normalize(group); + + result.Should().NotBeNull(); + result!.Logic.Should().Be(LogicOperator.And); + result.Children.Should().BeEmpty(); + } + + [Fact] + public void Normalize_NormalizesFieldNamesToLower() + { + var group = new FilterGroupNode + { + Logic = LogicOperator.And, + Children = + [ + new FilterConditionNode { Field = "NAME", Operator = "EQ", Value = "Alice" } + ] + }; + + var result = FilterNormalizer.Normalize(group); + + var condition = result!.Children.Should().ContainSingle().Which.Should().BeOfType().Subject; + condition.Field.Should().Be("name"); + condition.Operator.Should().Be("eq"); + } + + [Fact] + public void Normalize_FlattensMatchingChildGroups() + { + var inner = new FilterGroupNode + { + Logic = LogicOperator.And, + Children = + [ + new FilterConditionNode { Field = "Age", Operator = "gt", Value = "25" } + ] + }; + var outer = new FilterGroupNode + { + Logic = LogicOperator.And, + Children = + [ + new FilterConditionNode { Field = "Name", Operator = "eq", Value = "Alice" }, + inner + ] + }; + + var result = FilterNormalizer.Normalize(outer); + + // Inner AND group should be flattened into outer + result!.Children.Should().HaveCount(2); + } + + [Fact] + public void Normalize_DoesNotFlattenNonMatchingLogicGroups() + { + var inner = new FilterGroupNode + { + Logic = LogicOperator.Or, + Children = + [ + new FilterConditionNode { Field = "Age", Operator = "gt", Value = "25" } + ] + }; + var outer = new FilterGroupNode + { + Logic = LogicOperator.And, + Children = + [ + new FilterConditionNode { Field = "Name", Operator = "eq", Value = "Alice" }, + inner + ] + }; + + var result = FilterNormalizer.Normalize(outer); + + // Inner OR group should NOT be flattened + result!.Children.Should().ContainSingle(c => c is FilterGroupNode); + } + + [Fact] + public void Normalize_DoesNotFlattenNegatedGroups() + { + var inner = new FilterGroupNode + { + Logic = LogicOperator.And, + IsNegated = true, + Children = + [ + new FilterConditionNode { Field = "Age", Operator = "gt", Value = "25" } + ] + }; + var outer = new FilterGroupNode + { + Logic = LogicOperator.And, + Children = + [ + new FilterConditionNode { Field = "Name", Operator = "eq", Value = "Alice" }, + inner + ] + }; + + var result = FilterNormalizer.Normalize(outer); + + result!.Children.Should().ContainSingle(c => c is FilterGroupNode); + } + + [Fact] + public void Normalize_RemovesEmptyFilters() + { + var group = new FilterGroupNode + { + Logic = LogicOperator.And, + Children = + [ + new FilterConditionNode { Field = "", Operator = "eq", Value = "test" }, + new FilterConditionNode { Field = " ", Operator = "eq", Value = "test" }, + new FilterConditionNode { Field = "Name", Operator = "eq", Value = "Alice" } + ] + }; + + var result = FilterNormalizer.Normalize(group); + + result!.Children.Should().ContainSingle().Which.Should().BeOfType(); + } + + [Fact] + public void Normalize_DeduplicatesIdenticalConditions() + { + var group = new FilterGroupNode + { + Logic = LogicOperator.And, + Children = + [ + new FilterConditionNode { Field = "Name", Operator = "eq", Value = "Alice" }, + new FilterConditionNode { Field = "Name", Operator = "eq", Value = "Alice" } + ] + }; + + var result = FilterNormalizer.Normalize(group); + + result!.Children.Should().ContainSingle(); + } + + [Fact] + public void GenerateCacheKey_Null_ReturnsEmpty() + { + FilterNormalizer.GenerateCacheKey(null).Should().BeEmpty(); + } + + [Fact] + public void GenerateCacheKey_DeterministicAcrossDifferentOrder() + { + var group1 = new FilterGroupNode + { + Logic = LogicOperator.And, + Children = + [ + new FilterConditionNode { Field = "Name", Operator = "eq", Value = "Alice" }, + new FilterConditionNode { Field = "Age", Operator = "gt", Value = "25" } + ] + }; + var group2 = new FilterGroupNode + { + Logic = LogicOperator.And, + Children = + [ + new FilterConditionNode { Field = "Age", Operator = "gt", Value = "25" }, + new FilterConditionNode { Field = "Name", Operator = "eq", Value = "Alice" } + ] + }; + + var key1 = FilterNormalizer.GenerateCacheKey(group1); + var key2 = FilterNormalizer.GenerateCacheKey(group2); + + key1.Should().Be(key2); + } + + [Fact] + public void GenerateHash_ProducesConsistentHash() + { + var group = new FilterGroupNode + { + Logic = LogicOperator.And, + Children = + [ + new FilterConditionNode { Field = "Name", Operator = "eq", Value = "Alice" } + ] + }; + + var hash1 = FilterNormalizer.GenerateHash(group); + var hash2 = FilterNormalizer.GenerateHash(group); + + hash1.Should().Be(hash2); + } + + [Fact] + public void GenerateHash_DifferentFilters_DifferentHashes() + { + var group1 = new FilterGroupNode + { + Logic = LogicOperator.And, + Children = [new FilterConditionNode { Field = "Name", Operator = "eq", Value = "Alice" }] + }; + var group2 = new FilterGroupNode + { + Logic = LogicOperator.And, + Children = [new FilterConditionNode { Field = "Name", Operator = "eq", Value = "Bob" }] + }; + + FilterNormalizer.GenerateHash(group1).Should().NotBe(FilterNormalizer.GenerateHash(group2)); + } + + [Fact] + public void NormalizeOrder_PreservesCase() + { + var group = new FilterGroupNode + { + Logic = LogicOperator.And, + Children = + [ + new FilterConditionNode { Field = "NAME", Operator = "EQ", Value = "Alice" } + ] + }; + + var result = FilterNormalizer.NormalizeOrder(group); + + var condition = result!.Children.Should().ContainSingle().Which.Should().BeOfType().Subject; + condition.Field.Should().Be("NAME"); + condition.Operator.Should().Be("EQ"); + } +} diff --git a/tests/FlexQuery.NET.Tests/Tests/FilterTests.cs b/tests/FlexQuery.NET.Tests/Filters/FilterTests.cs similarity index 99% rename from tests/FlexQuery.NET.Tests/Tests/FilterTests.cs rename to tests/FlexQuery.NET.Tests/Filters/FilterTests.cs index a645767..dda062e 100644 --- a/tests/FlexQuery.NET.Tests/Tests/FilterTests.cs +++ b/tests/FlexQuery.NET.Tests/Filters/FilterTests.cs @@ -3,7 +3,7 @@ using FlexQuery.NET.Parsers; using Microsoft.Extensions.Primitives; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Filters; public class FilterTests : IDisposable { diff --git a/tests/FlexQuery.NET.Tests/Tests/FilterValueFormatterTests.cs b/tests/FlexQuery.NET.Tests/Filters/FilterValueFormatterTests.cs similarity index 96% rename from tests/FlexQuery.NET.Tests/Tests/FilterValueFormatterTests.cs rename to tests/FlexQuery.NET.Tests/Filters/FilterValueFormatterTests.cs index 1d4ee95..55e21c0 100644 --- a/tests/FlexQuery.NET.Tests/Tests/FilterValueFormatterTests.cs +++ b/tests/FlexQuery.NET.Tests/Filters/FilterValueFormatterTests.cs @@ -1,6 +1,6 @@ using FlexQuery.NET.Internal; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Filters; public class FilterValueFormatterTests { diff --git a/tests/FlexQuery.NET.Tests/Tests/OperatorHandlerTests.cs b/tests/FlexQuery.NET.Tests/Filters/OperatorHandlerTests.cs similarity index 97% rename from tests/FlexQuery.NET.Tests/Tests/OperatorHandlerTests.cs rename to tests/FlexQuery.NET.Tests/Filters/OperatorHandlerTests.cs index 0bad042..16089d6 100644 --- a/tests/FlexQuery.NET.Tests/Tests/OperatorHandlerTests.cs +++ b/tests/FlexQuery.NET.Tests/Filters/OperatorHandlerTests.cs @@ -4,7 +4,7 @@ using FlexQuery.NET.Operators; using Microsoft.EntityFrameworkCore; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Filters; public class OperatorHandlerTests : IDisposable { diff --git a/tests/FlexQuery.NET.Tests/Models/SqlAddress.cs b/tests/FlexQuery.NET.Tests/Fixtures/SqlAddress.cs similarity index 100% rename from tests/FlexQuery.NET.Tests/Models/SqlAddress.cs rename to tests/FlexQuery.NET.Tests/Fixtures/SqlAddress.cs diff --git a/tests/FlexQuery.NET.Tests/Models/SqlCustomer.cs b/tests/FlexQuery.NET.Tests/Fixtures/SqlCustomer.cs similarity index 100% rename from tests/FlexQuery.NET.Tests/Models/SqlCustomer.cs rename to tests/FlexQuery.NET.Tests/Fixtures/SqlCustomer.cs diff --git a/tests/FlexQuery.NET.Tests/Models/SqlOrder.cs b/tests/FlexQuery.NET.Tests/Fixtures/SqlOrder.cs similarity index 89% rename from tests/FlexQuery.NET.Tests/Models/SqlOrder.cs rename to tests/FlexQuery.NET.Tests/Fixtures/SqlOrder.cs index 361e029..3ccdff8 100644 --- a/tests/FlexQuery.NET.Tests/Models/SqlOrder.cs +++ b/tests/FlexQuery.NET.Tests/Fixtures/SqlOrder.cs @@ -1,4 +1,4 @@ -namespace FlexQuery.NET.Tests.Models; +namespace FlexQuery.NET.Tests.Fixtures; public sealed class SqlOrder { diff --git a/tests/FlexQuery.NET.Tests/Models/SqlOrderItem.cs b/tests/FlexQuery.NET.Tests/Fixtures/SqlOrderItem.cs similarity index 100% rename from tests/FlexQuery.NET.Tests/Models/SqlOrderItem.cs rename to tests/FlexQuery.NET.Tests/Fixtures/SqlOrderItem.cs diff --git a/tests/FlexQuery.NET.Tests/Models/TestEntity.cs b/tests/FlexQuery.NET.Tests/Fixtures/TestEntity.cs similarity index 100% rename from tests/FlexQuery.NET.Tests/Models/TestEntity.cs rename to tests/FlexQuery.NET.Tests/Fixtures/TestEntity.cs diff --git a/tests/FlexQuery.NET.Tests/FlexQuery.NET.Tests.csproj b/tests/FlexQuery.NET.Tests/FlexQuery.NET.Tests.csproj index b84cdda..f97484b 100644 --- a/tests/FlexQuery.NET.Tests/FlexQuery.NET.Tests.csproj +++ b/tests/FlexQuery.NET.Tests/FlexQuery.NET.Tests.csproj @@ -35,6 +35,7 @@ + diff --git a/tests/FlexQuery.NET.Tests/FlexQueryCoreTests.cs b/tests/FlexQuery.NET.Tests/FlexQueryCoreTests.cs new file mode 100644 index 0000000..a9d9a79 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/FlexQueryCoreTests.cs @@ -0,0 +1,66 @@ +using FlexQuery.NET.Configuration; +using FlexQuery.NET.Parsers; + +namespace FlexQuery.NET.Tests; + +public class FlexQueryCoreTests +{ + public FlexQueryCoreTests() + { + FlexQueryCore.Reset(); + } + + [Fact] + public void DefaultOptions_WhenNotConfigured_ReturnsEmptyOptions() + { + var options = FlexQueryCore.DefaultOptions; + + options.Should().NotBeNull(); + } + + [Fact] + public void Configure_SetsDefaultOptions() + { + FlexQueryCore.Configure(opts => opts.DefaultPageSize = 50); + + FlexQueryCore.DefaultOptions.DefaultPageSize.Should().Be(50); + } + + [Fact] + public void Configure_WithoutAction_UsesDefaults() + { + FlexQueryCore.Configure(); + + FlexQueryCore.DefaultOptions.Should().NotBeNull(); + } + + [Fact] + public void Configure_Twice_ThrowsInvalidOperationException() + { + FlexQueryCore.Configure(); + + Action act = () => FlexQueryCore.Configure(); + + act.Should().Throw() + .WithMessage("*already been configured*"); + } + + [Fact] + public void Reset_AllowsReconfiguration() + { + FlexQueryCore.Configure(opts => opts.DefaultPageSize = 10); + FlexQueryCore.Reset(); + + FlexQueryCore.Configure(opts => opts.DefaultPageSize = 25); + + FlexQueryCore.DefaultOptions.DefaultPageSize.Should().Be(25); + } + + [Fact] + public void Configure_SetsQuerySyntaxGlobally() + { + FlexQueryCore.Configure(opts => opts.QuerySyntax = QuerySyntax.NativeDsl); + + FlexQueryCore.DefaultOptions.QuerySyntax.Should().Be(QuerySyntax.NativeDsl); + } +} diff --git a/tests/FlexQuery.NET.Tests/Helpers/TypeHelperTests.cs b/tests/FlexQuery.NET.Tests/Helpers/TypeHelperTests.cs new file mode 100644 index 0000000..b5c6dd0 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Helpers/TypeHelperTests.cs @@ -0,0 +1,144 @@ +using FlexQuery.NET.Helpers; +using Xunit; + +namespace FlexQuery.NET.Tests.Helpers; + +public class TypeHelperTests +{ + private enum Color { Red, Green, Blue } + + [Theory] + [InlineData("5", typeof(int), 5)] + [InlineData("3.14", typeof(decimal), 3.14)] + [InlineData("true", typeof(bool), true)] + [InlineData("10.5", typeof(double), 10.5)] + [InlineData("7", typeof(long), 7L)] + public void ConvertValue_Primitives_ReturnsConvertedValue(string raw, Type target, object expected) + { + var result = TypeHelper.ConvertValue(raw, target); + + result.Should().Be(expected); + result!.GetType().Should().Be(Nullable.GetUnderlyingType(target) ?? target); + } + + [Fact] + public void ConvertValue_String_ReturnsRawString() + { + TypeHelper.ConvertValue("hello", typeof(string)).Should().Be("hello"); + } + + [Fact] + public void ConvertValue_NullableInt_UnwrapsAndConverts() + { + TypeHelper.ConvertValue("42", typeof(int?)).Should().Be(42); + } + + [Fact] + public void ConvertValue_NullRaw_ReturnsNull() + { + TypeHelper.ConvertValue(null, typeof(int)).Should().BeNull(); + } + + [Fact] + public void ConvertValue_InvalidInt_ReturnsNull() + { + TypeHelper.ConvertValue("abc", typeof(int)).Should().BeNull(); + } + + [Fact] + public void ConvertValue_NumericStringToBool_ReturnsNull() + { + // "1" is not a valid bool via Convert.ChangeType. + TypeHelper.ConvertValue("1", typeof(bool)).Should().BeNull(); + } + + [Fact] + public void ConvertValue_Enum_CaseInsensitive() + { + TypeHelper.ConvertValue("green", typeof(Color)).Should().Be(Color.Green); + TypeHelper.ConvertValue("RED", typeof(Color)).Should().Be(Color.Red); + } + + [Fact] + public void ConvertValue_InvalidEnum_ReturnsNull() + { + TypeHelper.ConvertValue("purple", typeof(Color)).Should().BeNull(); + } + + [Fact] + public void ConvertValue_Guid_IsNotConvertibleViaChangeType() + { + // Convert.ChangeType does not support string -> Guid, so conversion degrades to null. + // See "Future Improvements" in the testing plan for adding first-class Guid support. + TypeHelper.ConvertValue(Guid.NewGuid().ToString(), typeof(Guid)).Should().BeNull(); + } + + [Fact] + public void ConvertValue_DateTime_ReturnsDateTime() + { + TypeHelper.ConvertValue("2020-01-01", typeof(DateTime)).Should().Be(new DateTime(2020, 1, 1)); + } + + [Fact] + public void ConvertValue_InvalidDateTime_ReturnsNull() + { + TypeHelper.ConvertValue("not-a-date", typeof(DateTime)).Should().BeNull(); + } + + [Fact] + public void ConvertValue_DateOnly_IsNotConvertibleViaChangeType() + { + // Convert.ChangeType does not support string -> DateOnly, so conversion degrades to null. + // See "Future Improvements" in the testing plan for adding first-class DateOnly support. + TypeHelper.ConvertValue("2020-01-01", typeof(DateOnly)).Should().BeNull(); + } + + [Fact] + public void ConvertValue_TimeOnly_IsNotConvertibleViaChangeType() + { + // Convert.ChangeType does not support string -> TimeOnly, so conversion degrades to null. + // See "Future Improvements" in the testing plan for adding first-class TimeOnly support. + TypeHelper.ConvertValue("10:30:00", typeof(TimeOnly)).Should().BeNull(); + } + + [Theory] + [InlineData(typeof(int), true)] + [InlineData(typeof(decimal), true)] + [InlineData(typeof(double), true)] + [InlineData(typeof(long), true)] + [InlineData(typeof(string), false)] + [InlineData(typeof(DateTime), false)] + [InlineData(typeof(Color), false)] + public void IsNumeric_ReturnsExpected(Type type, bool expected) + { + TypeHelper.IsNumeric(type).Should().Be(expected); + } + + [Fact] + public void IsNavigationProperty_ReferenceType_ReturnsTrue() + { + TypeHelper.IsNavigationProperty(typeof(TestEntity)).Should().BeTrue(); + } + + [Fact] + public void IsNavigationProperty_StringAndValueType_ReturnFalse() + { + TypeHelper.IsNavigationProperty(typeof(string)).Should().BeFalse(); + TypeHelper.IsNavigationProperty(typeof(int)).Should().BeFalse(); + TypeHelper.IsNavigationProperty(typeof(int?)).Should().BeFalse(); + } + + [Fact] + public void TryGetCollectionElementType_List_ReturnsElement() + { + TypeHelper.TryGetCollectionElementType(typeof(List), out var element) + .Should().BeTrue(); + element.Should().Be(typeof(TestEntity)); + } + + [Fact] + public void TryGetCollectionElementType_String_ReturnsFalse() + { + TypeHelper.TryGetCollectionElementType(typeof(string), out _).Should().BeFalse(); + } +} diff --git a/tests/FlexQuery.NET.Tests/Tests/CollectionAnySqliteTests.cs b/tests/FlexQuery.NET.Tests/Integration/CollectionAnySqliteTests.cs similarity index 98% rename from tests/FlexQuery.NET.Tests/Tests/CollectionAnySqliteTests.cs rename to tests/FlexQuery.NET.Tests/Integration/CollectionAnySqliteTests.cs index f5b0d26..315f316 100644 --- a/tests/FlexQuery.NET.Tests/Tests/CollectionAnySqliteTests.cs +++ b/tests/FlexQuery.NET.Tests/Integration/CollectionAnySqliteTests.cs @@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Primitives; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Integration; public class CollectionAnySqliteTests { diff --git a/tests/FlexQuery.NET.Tests/Tests/DtoMappingTests.cs b/tests/FlexQuery.NET.Tests/Integration/DtoMappingTests.cs similarity index 96% rename from tests/FlexQuery.NET.Tests/Tests/DtoMappingTests.cs rename to tests/FlexQuery.NET.Tests/Integration/DtoMappingTests.cs index 1f7dc5c..241ea39 100644 --- a/tests/FlexQuery.NET.Tests/Tests/DtoMappingTests.cs +++ b/tests/FlexQuery.NET.Tests/Integration/DtoMappingTests.cs @@ -1,7 +1,7 @@ using FlexQuery.NET.Models; using System.Text.Json; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Integration; public class DtoMappingTests : IDisposable { @@ -75,7 +75,8 @@ public void FlexQuery_MappedField_Aggregate_AppliesMappedExpression() { var parameters = new FlexQueryParameters { - Select = "count(dtoAge) as countAge", + Select = "City", + Aggregate = "count:dtoAge:countAge", GroupBy = "City", PageSize = 100 }; diff --git a/tests/FlexQuery.NET.Tests/Tests/FilteredIncludeTests.cs b/tests/FlexQuery.NET.Tests/Integration/FilteredIncludeTests.cs similarity index 99% rename from tests/FlexQuery.NET.Tests/Tests/FilteredIncludeTests.cs rename to tests/FlexQuery.NET.Tests/Integration/FilteredIncludeTests.cs index 86ef03a..1da4bf4 100644 --- a/tests/FlexQuery.NET.Tests/Tests/FilteredIncludeTests.cs +++ b/tests/FlexQuery.NET.Tests/Integration/FilteredIncludeTests.cs @@ -3,7 +3,7 @@ using FlexQuery.NET.Parsers; using Microsoft.EntityFrameworkCore; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Integration; public class FilteredIncludeTests : IDisposable { diff --git a/tests/FlexQuery.NET.Tests/Tests/FlexQueryBaseTests.cs b/tests/FlexQuery.NET.Tests/Integration/FlexQueryBaseTests.cs similarity index 98% rename from tests/FlexQuery.NET.Tests/Tests/FlexQueryBaseTests.cs rename to tests/FlexQuery.NET.Tests/Integration/FlexQueryBaseTests.cs index f08908a..63c59b3 100644 --- a/tests/FlexQuery.NET.Tests/Tests/FlexQueryBaseTests.cs +++ b/tests/FlexQuery.NET.Tests/Integration/FlexQueryBaseTests.cs @@ -4,7 +4,7 @@ using FlexQuery.NET.Models.Paging; using FlexQuery.NET.Models.Projection; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Integration; public class FlexQueryBaseTests { diff --git a/tests/FlexQuery.NET.Tests/Tests/GrandTotalAggregationTests.cs b/tests/FlexQuery.NET.Tests/Integration/GrandTotalAggregationTests.cs similarity index 93% rename from tests/FlexQuery.NET.Tests/Tests/GrandTotalAggregationTests.cs rename to tests/FlexQuery.NET.Tests/Integration/GrandTotalAggregationTests.cs index b801513..11e2594 100644 --- a/tests/FlexQuery.NET.Tests/Tests/GrandTotalAggregationTests.cs +++ b/tests/FlexQuery.NET.Tests/Integration/GrandTotalAggregationTests.cs @@ -2,7 +2,7 @@ using FlexQuery.NET.EntityFrameworkCore; using Microsoft.Extensions.Primitives; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Integration; public class GrandTotalAggregationTests : IDisposable { @@ -24,7 +24,7 @@ public async Task GrandTotals_CountSumMinMaxAvg_WorksForEFCore() { var options = Parse(new() { - ["aggregate"] = "Total:sum,Id:count,Total:min,Total:max,Total:avg" + ["aggregate"] = "sum:Total,count:Id,min:Total,max:Total,avg:Total" }); // Act @@ -58,7 +58,7 @@ public void GrandTotals_CountSumMinMaxAvg_WorksForQueryable() { var options = Parse(new() { - ["aggregate"] = "Total:sum,Id:count,Total:min,Total:max,Total:avg" + ["aggregate"] = "sum:Total,count:Id,min:Total,max:Total,avg:Total" }); // Act diff --git a/tests/FlexQuery.NET.Tests/Tests/GroupedQueryBehaviorTests.cs b/tests/FlexQuery.NET.Tests/Integration/GroupedQueryBehaviorTests.cs similarity index 99% rename from tests/FlexQuery.NET.Tests/Tests/GroupedQueryBehaviorTests.cs rename to tests/FlexQuery.NET.Tests/Integration/GroupedQueryBehaviorTests.cs index 694c8f8..a9a1bf3 100644 --- a/tests/FlexQuery.NET.Tests/Tests/GroupedQueryBehaviorTests.cs +++ b/tests/FlexQuery.NET.Tests/Integration/GroupedQueryBehaviorTests.cs @@ -14,7 +14,7 @@ using FlexQuery.NET.Models.Paging; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Integration; diff --git a/tests/FlexQuery.NET.Tests/Tests/KeysetPaginationTests.cs b/tests/FlexQuery.NET.Tests/Integration/KeysetPaginationTests.cs similarity index 96% rename from tests/FlexQuery.NET.Tests/Tests/KeysetPaginationTests.cs rename to tests/FlexQuery.NET.Tests/Integration/KeysetPaginationTests.cs index fb114e4..d6703f0 100644 --- a/tests/FlexQuery.NET.Tests/Tests/KeysetPaginationTests.cs +++ b/tests/FlexQuery.NET.Tests/Integration/KeysetPaginationTests.cs @@ -5,7 +5,7 @@ using FlexQuery.NET.Models.Paging; using FlexQuery.NET.Serialization; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Integration; public class KeysetPaginationTests : IDisposable { @@ -167,7 +167,7 @@ public void SeekAfter_Descending_PagesCorrectly() } [Fact] - public void SeekAfter_WithoutOrderBy_Throws() + public void SeekAfter_WithOrderBy_DoesNotThrow() { var act = () => _db.Entities .OrderBy(e => e.Id) // becomes IOrderedQueryable @@ -175,10 +175,23 @@ public void SeekAfter_WithoutOrderBy_Throws() .Take(3) .ToList(); - // Should not throw - valid + // Valid: an ordered query supports keyset pagination. act.Should().NotThrow(); } + [Fact] + public void SeekAfter_WithoutOrderBy_Throws() + { + IQueryable unordered = new List().AsQueryable(); + var act = () => ((IOrderedQueryable)unordered) + .SeekAfter(1) + .Take(3) + .ToList(); + + act.Should().Throw() + .WithMessage("*at least one OrderBy*"); + } + [Fact] public void SeekAfter_NullCursor_Throws() { diff --git a/tests/FlexQuery.NET.Tests/Tests/PagingTests.cs b/tests/FlexQuery.NET.Tests/Integration/PagingTests.cs similarity index 99% rename from tests/FlexQuery.NET.Tests/Tests/PagingTests.cs rename to tests/FlexQuery.NET.Tests/Integration/PagingTests.cs index 1510606..265aac8 100644 --- a/tests/FlexQuery.NET.Tests/Tests/PagingTests.cs +++ b/tests/FlexQuery.NET.Tests/Integration/PagingTests.cs @@ -1,7 +1,7 @@ using FlexQuery.NET.Models; using FlexQuery.NET.Models.Paging; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Integration; public class PagingTests : IDisposable { diff --git a/tests/FlexQuery.NET.Tests/Tests/QueryNormalizationTests.cs b/tests/FlexQuery.NET.Tests/Integration/QueryNormalizationTests.cs similarity index 99% rename from tests/FlexQuery.NET.Tests/Tests/QueryNormalizationTests.cs rename to tests/FlexQuery.NET.Tests/Integration/QueryNormalizationTests.cs index a5a04b5..9851121 100644 --- a/tests/FlexQuery.NET.Tests/Tests/QueryNormalizationTests.cs +++ b/tests/FlexQuery.NET.Tests/Integration/QueryNormalizationTests.cs @@ -6,7 +6,7 @@ using FlexQuery.NET.Parsers; using Microsoft.Extensions.Primitives; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Integration; public class QueryNormalizationTests { diff --git a/tests/FlexQuery.NET.Tests/Tests/ResultCountTests.cs b/tests/FlexQuery.NET.Tests/Integration/ResultCountTests.cs similarity index 99% rename from tests/FlexQuery.NET.Tests/Tests/ResultCountTests.cs rename to tests/FlexQuery.NET.Tests/Integration/ResultCountTests.cs index af14711..b5ad44a 100644 --- a/tests/FlexQuery.NET.Tests/Tests/ResultCountTests.cs +++ b/tests/FlexQuery.NET.Tests/Integration/ResultCountTests.cs @@ -8,7 +8,7 @@ using FlexQuery.NET.Models.Paging; using Microsoft.EntityFrameworkCore; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Integration; public class ResultCountTests { diff --git a/tests/FlexQuery.NET.Tests/Tests/SecurityGovernanceEfCoreIntegrationTests.cs b/tests/FlexQuery.NET.Tests/Integration/SecurityGovernanceEfCoreIntegrationTests.cs similarity index 99% rename from tests/FlexQuery.NET.Tests/Tests/SecurityGovernanceEfCoreIntegrationTests.cs rename to tests/FlexQuery.NET.Tests/Integration/SecurityGovernanceEfCoreIntegrationTests.cs index 1978f1c..dad1cdf 100644 --- a/tests/FlexQuery.NET.Tests/Tests/SecurityGovernanceEfCoreIntegrationTests.cs +++ b/tests/FlexQuery.NET.Tests/Integration/SecurityGovernanceEfCoreIntegrationTests.cs @@ -10,7 +10,7 @@ using FlexQuery.NET.Models.Aggregates; using FlexQuery.NET.Options; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Integration; public sealed class SecurityGovernanceEfCoreIntegrationTests : IDisposable { diff --git a/tests/FlexQuery.NET.Tests/Tests/SortingNestedSqliteTests.cs b/tests/FlexQuery.NET.Tests/Integration/SortingNestedSqliteTests.cs similarity index 99% rename from tests/FlexQuery.NET.Tests/Tests/SortingNestedSqliteTests.cs rename to tests/FlexQuery.NET.Tests/Integration/SortingNestedSqliteTests.cs index 38a8bd6..e71564f 100644 --- a/tests/FlexQuery.NET.Tests/Tests/SortingNestedSqliteTests.cs +++ b/tests/FlexQuery.NET.Tests/Integration/SortingNestedSqliteTests.cs @@ -3,7 +3,7 @@ using FlexQuery.NET.Parsers; using Microsoft.Extensions.Primitives; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Integration; public sealed class SortingNestedSqliteTests : IDisposable { diff --git a/tests/FlexQuery.NET.Tests/Tests/SortingTests.cs b/tests/FlexQuery.NET.Tests/Integration/SortingTests.cs similarity index 99% rename from tests/FlexQuery.NET.Tests/Tests/SortingTests.cs rename to tests/FlexQuery.NET.Tests/Integration/SortingTests.cs index d168052..aa96f8b 100644 --- a/tests/FlexQuery.NET.Tests/Tests/SortingTests.cs +++ b/tests/FlexQuery.NET.Tests/Integration/SortingTests.cs @@ -1,7 +1,7 @@ using FlexQuery.NET.Models; using FlexQuery.NET.Models.Paging; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Integration; public class SortingTests : IDisposable { diff --git a/tests/FlexQuery.NET.Tests/Tests/StressTests.cs b/tests/FlexQuery.NET.Tests/Integration/StressTests.cs similarity index 98% rename from tests/FlexQuery.NET.Tests/Tests/StressTests.cs rename to tests/FlexQuery.NET.Tests/Integration/StressTests.cs index 5c2e62c..5fb2ea2 100644 --- a/tests/FlexQuery.NET.Tests/Tests/StressTests.cs +++ b/tests/FlexQuery.NET.Tests/Integration/StressTests.cs @@ -2,7 +2,7 @@ using FlexQuery.NET.Parsers; using Microsoft.EntityFrameworkCore; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Integration; public class StressTests : IDisposable { diff --git a/tests/FlexQuery.NET.Tests/Models/PagingOptionsTests.cs b/tests/FlexQuery.NET.Tests/Models/PagingOptionsTests.cs new file mode 100644 index 0000000..410fdeb --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Models/PagingOptionsTests.cs @@ -0,0 +1,78 @@ +using FlexQuery.NET.Models.Paging; + +namespace FlexQuery.NET.Tests.Models; + +public class PagingOptionsTests +{ + [Fact] + public void DefaultConstructor_SetsPageToOne() + { + var paging = new PagingOptions(); + paging.Page.Should().Be(1); + } + + [Fact] + public void DefaultConstructor_SetsPageSizeToTwenty() + { + var paging = new PagingOptions(); + paging.PageSize.Should().Be(20); + } + + [Fact] + public void DefaultConstructor_DisabledIsFalse() + { + var paging = new PagingOptions(); + paging.Disabled.Should().BeFalse(); + } + + [Fact] + public void Page_ClampsToMinimumOfOne() + { + var paging = new PagingOptions { Page = 0 }; + paging.Page.Should().Be(1); + + paging.Page = -5; + paging.Page.Should().Be(1); + } + + [Fact] + public void PageSize_ClampsToMinimumOfOne() + { + var paging = new PagingOptions { PageSize = 0 }; + paging.PageSize.Should().Be(1); + + paging.PageSize = -1; + paging.PageSize.Should().Be(1); + } + + [Fact] + public void PageSize_ClampsToMaximumOfOneThousand() + { + var paging = new PagingOptions { PageSize = 1001 }; + paging.PageSize.Should().Be(1000); + + paging.PageSize = 5000; + paging.PageSize.Should().Be(1000); + } + + [Fact] + public void Skip_ComputesCorrectly() + { + var paging = new PagingOptions { Page = 3, PageSize = 10 }; + paging.Skip.Should().Be(20); + } + + [Fact] + public void Skip_WhenPageIsOne_ReturnsZero() + { + var paging = new PagingOptions { Page = 1, PageSize = 20 }; + paging.Skip.Should().Be(0); + } + + [Fact] + public void Disabled_WhenTrue_SkipIsStillValid() + { + var paging = new PagingOptions { Page = 2, PageSize = 10, Disabled = true }; + paging.Skip.Should().Be(10); + } +} diff --git a/tests/FlexQuery.NET.Tests/Models/QueryOptionsTests.cs b/tests/FlexQuery.NET.Tests/Models/QueryOptionsTests.cs new file mode 100644 index 0000000..ce88e3b --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Models/QueryOptionsTests.cs @@ -0,0 +1,69 @@ +using FlexQuery.NET.Models; +using FlexQuery.NET.Models.Filters; +using FlexQuery.NET.Models.Projection; + +namespace FlexQuery.NET.Tests.Models; + +public class QueryOptionsTests +{ + [Fact] + public void DefaultConstructor_SetsDefaultValues() + { + var options = new QueryOptions(); + + options.Sort.Should().BeEmpty(); + options.Aggregates.Should().BeEmpty(); + options.Paging.Should().NotBeNull(); + options.IncludeCount.Should().BeTrue(); + options.ProjectionMode.Should().Be(ProjectionMode.Nested); + } + + [Fact] + public void Filter_NullByDefault() + { + var options = new QueryOptions(); + options.Filter.Should().BeNull(); + } + + [Fact] + public void Select_NullByDefault() + { + var options = new QueryOptions(); + options.Select.Should().BeNull(); + } + + [Fact] + public void Includes_NullByDefault() + { + var options = new QueryOptions(); + options.Includes.Should().BeNull(); + } + + [Fact] + public void Expand_NullByDefault() + { + var options = new QueryOptions(); + options.Expand.Should().BeNull(); + } + + [Fact] + public void GroupBy_NullByDefault() + { + var options = new QueryOptions(); + options.GroupBy.Should().BeNull(); + } + + [Fact] + public void Having_NullByDefault() + { + var options = new QueryOptions(); + options.Having.Should().BeNull(); + } + + [Fact] + public void Distinct_NullByDefault() + { + var options = new QueryOptions(); + options.Distinct.Should().BeNull(); + } +} diff --git a/tests/FlexQuery.NET.Tests/Parsers/Dsl/DslSelectParserTests.cs b/tests/FlexQuery.NET.Tests/Parsers/Dsl/DslSelectParserTests.cs new file mode 100644 index 0000000..0b5f016 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Parsers/Dsl/DslSelectParserTests.cs @@ -0,0 +1,189 @@ +using FlexQuery.NET.Models; +using FlexQuery.NET.Parsers.Dsl; +using Xunit; + +namespace FlexQuery.NET.Tests.Parsers.Dsl; + +public class DslSelectParserTests +{ + [Fact] + public void Parse_EmptyString_Throws() + { + var options = new QueryOptions(); + + var act = () => DslSelectParser.Parse(options, " "); + + act.Should().Throw(); + } + + [Fact] + public void Parse_ValidPaths_PopulatesSelect() + { + var options = new QueryOptions(); + + DslSelectParser.Parse(options, "Id,Name,Profile.AvatarUrl"); + + options.Select.Should().BeEquivalentTo(new[] { "Id", "Name", "Profile.AvatarUrl" }); + } + + [Fact] + public void Parse_AliasExpression_ExtractsPathBeforeAs() + { + var options = new QueryOptions(); + + DslSelectParser.Parse(options, "Name AS FullName, Age"); + + options.Select.Should().Contain("Name AS FullName"); + options.Select.Should().Contain("Age"); + } + + [Fact] + public void Parse_InvalidPropertyPath_Throws() + { + var options = new QueryOptions(); + + var act = () => DslSelectParser.Parse(options, "Name."); + + act.Should().Throw(); + } + + [Fact] + public void Parse_WhitespaceItems_AreSkipped() + { + var options = new QueryOptions(); + + DslSelectParser.Parse(options, "Id, , Name"); + + options.Select.Should().BeEquivalentTo(new[] { "Id", "Name" }); + } + + [Fact] + public void Parse_ColonAlias_Simple() + { + var options = new QueryOptions(); + + DslSelectParser.Parse(options, "Name:FullName"); + + options.Select.Should().Contain("Name AS FullName"); + } + + [Fact] + public void Parse_ColonAlias_NestedPath() + { + var options = new QueryOptions(); + + DslSelectParser.Parse(options, "Customer.Name:CustomerName"); + + options.Select.Should().Contain("Customer.Name AS CustomerName"); + } + + [Fact] + public void Parse_ColonAlias_EmptyAlias_Throws() + { + var options = new QueryOptions(); + + var act = () => DslSelectParser.Parse(options, "Name:"); + + act.Should().Throw(); + } + + [Fact] + public void Parse_ColonAlias_EmptyPath_Throws() + { + var options = new QueryOptions(); + + var act = () => DslSelectParser.Parse(options, ":FullName"); + + act.Should().Throw(); + } + + [Fact] + public void Parse_ColonAlias_AliasWithSpace_Throws() + { + var options = new QueryOptions(); + + var act = () => DslSelectParser.Parse(options, "Name:Full Name"); + + act.Should().Throw(); + } + + [Fact] + public void Parse_ColonAlias_MultipleColons_Throws() + { + var options = new QueryOptions(); + + var act = () => DslSelectParser.Parse(options, "Name:First:Last"); + + act.Should().Throw(); + } + + [Fact] + public void Parse_ColonAlias_ReservedKeywordAs_Throws() + { + var options = new QueryOptions(); + + var act = () => DslSelectParser.Parse(options, "Name:AS"); + + act.Should().Throw(); + } + + [Fact] + public void Parse_ColonAlias_ReservedKeywordAsCaseInsensitive_Throws() + { + var options = new QueryOptions(); + + var act = () => DslSelectParser.Parse(options, "Name:as"); + + act.Should().Throw(); + } + + [Fact] + public void Parse_ColonAlias_MixedWithPlain() + { + var options = new QueryOptions(); + + DslSelectParser.Parse(options, "Id,Name:FullName,Age"); + + options.Select.Should().BeEquivalentTo(new[] { "Id", "Name AS FullName", "Age" }); + } + + [Fact] + public void Parse_ColonAlias_WhitespaceAroundColon() + { + var options = new QueryOptions(); + + DslSelectParser.Parse(options, "Name : FullName"); + + options.Select.Should().Contain("Name AS FullName"); + } + + [Fact] + public void Parse_BackwardCompat_AsSyntax_StillWorks() + { + var options = new QueryOptions(); + + DslSelectParser.Parse(options, "Name AS FullName"); + + options.Select.Should().Contain("Name AS FullName"); + } + + [Fact] + public void Parse_AggregateExpression_Throws() + { + var options = new QueryOptions(); + + var act = () => DslSelectParser.Parse(options, "SUM(Total):Revenue"); + + act.Should().Throw(); + } + + [Fact] + public void Parse_ColonAlias_InvalidIdentifier_Throws() + { + var options = new QueryOptions(); + + var act = () => DslSelectParser.Parse(options, "Name:Full-Name"); + + act.Should().Throw(); + } +} diff --git a/tests/FlexQuery.NET.Tests/Parsers/DslQueryParserTests.cs b/tests/FlexQuery.NET.Tests/Parsers/DslQueryParserTests.cs index efc00ec..49dcbb7 100644 --- a/tests/FlexQuery.NET.Tests/Parsers/DslQueryParserTests.cs +++ b/tests/FlexQuery.NET.Tests/Parsers/DslQueryParserTests.cs @@ -482,7 +482,7 @@ public void GroupAndAggregateSelect_ParsedCorrectly() { ["groupBy"] = "category,status", ["select"] = "category", - ["aggregate"] = "total:sum,id:count", + ["aggregate"] = "sum:total,count:id", ["having"] = "sum(total):gt:10000" }); @@ -525,7 +525,8 @@ public void Having_Count_GreaterThan() var opts = Parse(new() { ["groupBy"] = "status", - ["select"] = "status,count()", + ["select"] = "status", + ["aggregate"] = "count:*", ["having"] = "count:gt:20" }); @@ -542,7 +543,8 @@ public void Having_Sum_WithField() var opts = Parse(new() { ["groupBy"] = "status", - ["select"] = "status,sum(total)", + ["select"] = "status", + ["aggregate"] = "sum:total", ["having"] = "sum:total:gt:100" }); @@ -559,7 +561,8 @@ public void Having_ParenthesisFormat_ParsedCorrectly() var opts = Parse(new() { ["group"] = "status", - ["select"] = "status,sum(total)", + ["select"] = "status", + ["aggregate"] = "sum:total", ["having"] = "sum(total):gt:100" }); @@ -588,7 +591,7 @@ public void DslHaving_NestedField_ParsedCorrectly() { ["group"] = "status", ["select"] = "status", - ["aggregates"] = "Orders.Total:sum", + ["aggregates"] = "sum:Orders.Total", ["having"] = "sum(Orders.Total):gt:500" }); @@ -608,7 +611,7 @@ public void DslAggregate_SingleField_ParsedCorrectly() { var opts = Parse(new() { - ["aggregate"] = "Amount:sum" + ["aggregate"] = "sum:Amount" }); opts.Aggregates.Should().ContainSingle(); @@ -622,7 +625,7 @@ public void DslAggregate_WithAlias_ParsedCorrectly() { var opts = Parse(new() { - ["aggregate"] = "Amount:sum:TotalSales" + ["aggregate"] = "sum:Amount:TotalSales" }); opts.Aggregates.Should().ContainSingle(); @@ -636,7 +639,7 @@ public void DslAggregate_Multiple_ParsedCorrectly() { var opts = Parse(new() { - ["aggregate"] = "Amount:sum,Price:avg,Id:count" + ["aggregate"] = "sum:Amount,avg:Price,count:Id" }); opts.Aggregates.Should().HaveCount(3); @@ -653,7 +656,7 @@ public void DslAggregate_DefaultAlias_GeneratedCorrectly() { var opts = Parse(new() { - ["aggregate"] = "Price:avg" + ["aggregate"] = "avg:Price" }); opts.Aggregates.Should().ContainSingle(); @@ -667,7 +670,7 @@ public void DslAggregate_NestedField_AliasGeneratedCorrectly() { var opts = Parse(new() { - ["aggregate"] = "Orders.Total:sum" + ["aggregate"] = "sum:Orders.Total" }); opts.Aggregates.Should().ContainSingle(); @@ -681,7 +684,7 @@ public void DslAggregate_InvalidFunction_IsRejected() { var act = () => Parse(new() { - ["aggregate"] = "Amount:invalid,Price:sum" + ["aggregate"] = "invalid:Amount,sum:Price" }); act.Should().Throw() @@ -712,7 +715,7 @@ public void DslAggregate_CountStar_ParsedCorrectly() { var opts = Parse(new() { - ["aggregate"] = "*:count" + ["aggregate"] = "count:*" }); opts.Aggregates.Should().ContainSingle(); @@ -726,7 +729,7 @@ public void DslAggregate_CaseInsensitiveFunctions_ParsedCorrectly() { var opts = Parse(new() { - ["aggregate"] = "Amount:SUM,Price:Avg,Id:count" + ["aggregate"] = "SUM:Amount,Avg:Price,count:Id" }); opts.Aggregates.Should().HaveCount(3); @@ -740,7 +743,7 @@ public void DslAggregate_MinMax_ParsedCorrectly() { var opts = Parse(new() { - ["aggregate"] = "Date:min,Date:max" + ["aggregate"] = "min:Date,max:Date" }); opts.Aggregates.Should().HaveCount(2); @@ -807,7 +810,7 @@ public void DslIntegration_AllParameters_ParsedCorrectly() ["select"] = "Id,Name,CustomerName", ["include"] = "Orders,Profile", ["groupBy"] = "customerId,category", - ["aggregate"] = "Amount:sum:TotalSales,Id:count,Price:avg", + ["aggregate"] = "sum:Amount:TotalSales,count:Id,avg:Price", ["having"] = "sum(Amount):gt:1000", ["distinct"] = "true", ["page"] = "2", diff --git a/tests/FlexQuery.NET.Tests/Parsers/Fql/FqlSelectParserTests.cs b/tests/FlexQuery.NET.Tests/Parsers/Fql/FqlSelectParserTests.cs new file mode 100644 index 0000000..af92d3b --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Parsers/Fql/FqlSelectParserTests.cs @@ -0,0 +1,68 @@ +using FlexQuery.NET.Models; +using FlexQuery.NET.Parsers.Fql; +using Xunit; + +namespace FlexQuery.NET.Tests.Parsers.Fql; + +public class FqlSelectParserTests +{ + [Fact] + public void Parse_PropertyPath_Valid() + { + var options = new QueryOptions(); + + FqlSelectParser.Parse(options, "Id,Name,Profile.AvatarUrl"); + + options.Select.Should().BeEquivalentTo(new[] { "Id", "Name", "Profile.AvatarUrl" }); + } + + [Fact] + public void Parse_AsAlias_Valid() + { + var options = new QueryOptions(); + + FqlSelectParser.Parse(options, "Name AS FullName"); + + options.Select.Should().Contain("Name AS FullName"); + } + + [Fact] + public void Parse_AggregateExpression_WithParentheses_Valid() + { + var options = new QueryOptions(); + + FqlSelectParser.Parse(options, "sum(Total) AS TotalSum"); + + options.Select.Should().Contain("sum(Total) AS TotalSum"); + } + + [Fact] + public void Parse_ColonAlias_Throws() + { + var options = new QueryOptions(); + + var act = () => FqlSelectParser.Parse(options, "Name:FullName"); + + act.Should().Throw(); + } + + [Fact] + public void Parse_InvalidPropertyPath_Throws() + { + var options = new QueryOptions(); + + var act = () => FqlSelectParser.Parse(options, "Name."); + + act.Should().Throw(); + } + + [Fact] + public void Parse_EmptyString_Throws() + { + var options = new QueryOptions(); + + var act = () => FqlSelectParser.Parse(options, " "); + + act.Should().Throw(); + } +} diff --git a/tests/FlexQuery.NET.Tests/Tests/IncludeParserTests.cs b/tests/FlexQuery.NET.Tests/Parsers/IncludeParserTests.cs similarity index 99% rename from tests/FlexQuery.NET.Tests/Tests/IncludeParserTests.cs rename to tests/FlexQuery.NET.Tests/Parsers/IncludeParserTests.cs index 2f205b0..a0af08b 100644 --- a/tests/FlexQuery.NET.Tests/Tests/IncludeParserTests.cs +++ b/tests/FlexQuery.NET.Tests/Parsers/IncludeParserTests.cs @@ -2,7 +2,7 @@ using FlexQuery.NET.Parsers; using FlexQuery.NET.Parsers.Fql; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Parsers; public class IncludeParserTests { diff --git a/tests/FlexQuery.NET.Tests/Tests/ParserTests.cs b/tests/FlexQuery.NET.Tests/Parsers/ParserTests.cs similarity index 96% rename from tests/FlexQuery.NET.Tests/Tests/ParserTests.cs rename to tests/FlexQuery.NET.Tests/Parsers/ParserTests.cs index 45add23..002dcb1 100644 --- a/tests/FlexQuery.NET.Tests/Tests/ParserTests.cs +++ b/tests/FlexQuery.NET.Tests/Parsers/ParserTests.cs @@ -1,6 +1,6 @@ using FlexQuery.NET.Parsers; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Parsers; /// /// Tests for ParserUtilities.BuildAggregateAlias. diff --git a/tests/FlexQuery.NET.Tests/Parsers/QueryParserRegistryTests.cs b/tests/FlexQuery.NET.Tests/Parsers/QueryParserRegistryTests.cs new file mode 100644 index 0000000..13276b8 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Parsers/QueryParserRegistryTests.cs @@ -0,0 +1,58 @@ +using FlexQuery.NET.Exceptions; +using FlexQuery.NET.Models; +using FlexQuery.NET.Parsers; +using Xunit; + +namespace FlexQuery.NET.Tests.Parsers; + +public class QueryParserRegistryTests +{ + [Fact] + public void IsRegistered_NativeDsl_IsTrue() + { + QueryParserRegistry.IsRegistered(QuerySyntax.NativeDsl).Should().BeTrue(); + } + + [Fact] + public void Resolve_NativeDsl_ReturnsParser() + { + var parser = QueryParserRegistry.Resolve(QuerySyntax.NativeDsl); + + parser.Should().NotBeNull(); + } + + [Fact] + public void Resolve_UnregisteredSyntax_Throws() + { + var unregistered = Enum.GetValues() + .Where(s => s != QuerySyntax.NativeDsl && !QueryParserRegistry.IsRegistered(s)) + .ToList(); + + foreach (var syntax in unregistered) + { + var act = () => QueryParserRegistry.Resolve(syntax); + act.Should().Throw(); + } + } + + [Fact] + public void Register_AddsParser_AndResolveReturnsIt() + { + var dummy = new DslQueryParser(); + QueryParserRegistry.Register(QuerySyntax.MiniOData, dummy); + + QueryParserRegistry.IsRegistered(QuerySyntax.MiniOData).Should().BeTrue(); + QueryParserRegistry.Resolve(QuerySyntax.MiniOData).Should().BeSameAs(dummy); + } + + [Fact] + public void Register_Duplicate_OverwritesPrevious() + { + var first = new DslQueryParser(); + var second = new DslQueryParser(); + QueryParserRegistry.Register(QuerySyntax.MiniOData, first); + QueryParserRegistry.Register(QuerySyntax.MiniOData, second); + + QueryParserRegistry.Resolve(QuerySyntax.MiniOData).Should().BeSameAs(second); + } +} diff --git a/tests/FlexQuery.NET.Tests/Parsers/SortParserTests.cs b/tests/FlexQuery.NET.Tests/Parsers/SortParserTests.cs new file mode 100644 index 0000000..39f27e3 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Parsers/SortParserTests.cs @@ -0,0 +1,87 @@ +using FlexQuery.NET.Models.Paging; +using FlexQuery.NET.Parsers; +using FlexQuery.NET.Parsers.Dsl; +using Xunit; + +namespace FlexQuery.NET.Tests.Parsers; + +public class SortParserTests +{ + [Fact] + public void Parse_SingleFieldAsc_DefaultsToAscending() + { + var result = SortParser.Parse("LastName"); + + result.Should().ContainSingle(); + result[0].Field.Should().Be("LastName"); + result[0].Descending.Should().BeFalse(); + } + + [Fact] + public void Parse_FieldWithDirection() + { + var asc = SortParser.Parse("LastName:asc"); + var desc = SortParser.Parse("LastName:desc"); + + asc[0].Descending.Should().BeFalse(); + desc[0].Descending.Should().BeTrue(); + } + + [Fact] + public void Parse_MultipleFields() + { + var result = SortParser.Parse("LastName:asc,FirstName:desc"); + + result.Should().HaveCount(2); + result[0].Field.Should().Be("LastName"); + result[0].Descending.Should().BeFalse(); + result[1].Field.Should().Be("FirstName"); + result[1].Descending.Should().BeTrue(); + } + + [Fact] + public void Parse_InvalidDirection_Throws() + { + var act = () => SortParser.Parse("LastName:sideways"); + + act.Should().Throw(); + } + + [Fact] + public void Parse_EmptyItem_Throws() + { + var act = () => SortParser.Parse("LastName:asc,,"); + + act.Should().Throw(); + } + + [Fact] + public void Parse_InvalidFieldPath_Throws() + { + var act = () => SortParser.Parse("Name.:asc"); + + act.Should().Throw(); + } + + [Fact] + public void Parse_AggregateSort_SetsAggregateFields() + { + var result = SortParser.Parse("Orders.sum(total):desc"); + + result.Should().ContainSingle(); + result[0].Field.Should().Be("Orders"); + result[0].Aggregate.Should().Be("sum"); + result[0].AggregateField.Should().Be("total"); + result[0].Descending.Should().BeTrue(); + } + + [Fact] + public void Parse_AggregateSort_WithoutField() + { + var result = SortParser.Parse("Orders.count():asc"); + + result[0].Aggregate.Should().Be("count"); + result[0].AggregateField.Should().BeNull(); + result[0].Descending.Should().BeFalse(); + } +} diff --git a/tests/FlexQuery.NET.Tests/Tests/FilteredProjectionTests.cs b/tests/FlexQuery.NET.Tests/Projection/FilteredProjectionTests.cs similarity index 99% rename from tests/FlexQuery.NET.Tests/Tests/FilteredProjectionTests.cs rename to tests/FlexQuery.NET.Tests/Projection/FilteredProjectionTests.cs index 653c65a..acef3f5 100644 --- a/tests/FlexQuery.NET.Tests/Tests/FilteredProjectionTests.cs +++ b/tests/FlexQuery.NET.Tests/Projection/FilteredProjectionTests.cs @@ -3,7 +3,7 @@ using System.Text.RegularExpressions; using FlexQuery.NET.Models.Filters; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Projection; public sealed class FilteredProjectionTests : IDisposable { diff --git a/tests/FlexQuery.NET.Tests/Tests/FlatProjectionTests.cs b/tests/FlexQuery.NET.Tests/Projection/FlatProjectionTests.cs similarity index 99% rename from tests/FlexQuery.NET.Tests/Tests/FlatProjectionTests.cs rename to tests/FlexQuery.NET.Tests/Projection/FlatProjectionTests.cs index dd43f82..c921204 100644 --- a/tests/FlexQuery.NET.Tests/Tests/FlatProjectionTests.cs +++ b/tests/FlexQuery.NET.Tests/Projection/FlatProjectionTests.cs @@ -2,7 +2,7 @@ using FlexQuery.NET.Models.Projection; using Microsoft.EntityFrameworkCore; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Projection; public class FlatProjectionTests : IDisposable { diff --git a/tests/FlexQuery.NET.Tests/Projection/ProjectionEnhancerTests.cs b/tests/FlexQuery.NET.Tests/Projection/ProjectionEnhancerTests.cs new file mode 100644 index 0000000..932dd03 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Projection/ProjectionEnhancerTests.cs @@ -0,0 +1,38 @@ +using FlexQuery.NET.Models; +using FlexQuery.NET.Models.Filters; +using FlexQuery.NET.Projection; + +namespace FlexQuery.NET.Tests.Projection; + +public class ProjectionEnhancerTests +{ + private sealed class TestItem + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + [Fact] + public void ApplyCollectionWhereIfNeeded_NullFilter_ReturnsSameExpression() + { + var expr = System.Linq.Expressions.Expression.Constant(new List().AsQueryable()); + + var result = ProjectionEnhancer.ApplyCollectionWhereIfNeeded(expr, typeof(TestItem), null, new QueryOptions()); + + result.Should().BeSameAs(expr); + } + + [Fact] + public void ApplyCollectionWhereIfNeeded_WithFilter_ReturnsModifiedExpression() + { + var expr = System.Linq.Expressions.Expression.Constant(new List().AsQueryable()); + var filter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Name", Operator = "eq", Value = "test" }] + }; + + var result = ProjectionEnhancer.ApplyCollectionWhereIfNeeded(expr, typeof(TestItem), filter, new QueryOptions()); + + result.Should().NotBeSameAs(expr); + } +} diff --git a/tests/FlexQuery.NET.Tests/Projection/ProjectionMetadataBuilderTests.cs b/tests/FlexQuery.NET.Tests/Projection/ProjectionMetadataBuilderTests.cs new file mode 100644 index 0000000..b153f68 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Projection/ProjectionMetadataBuilderTests.cs @@ -0,0 +1,57 @@ +using FlexQuery.NET.Models; +using FlexQuery.NET.Projection; + +namespace FlexQuery.NET.Tests.Projection; + +public class ProjectionMetadataBuilderTests +{ + private sealed class TestEntity + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + [Fact] + public void Build_ReturnsProjectionMetadata() + { + var options = new QueryOptions { Select = ["Id", "Name"] }; + + var result = ProjectionMetadataBuilder.Build(typeof(TestEntity), options); + + result.Should().NotBeNull(); + result.EntityType.Should().Be(typeof(TestEntity)); + } + + [Fact] + public void Build_EmptyOptions_ReturnsMetadata() + { + var result = ProjectionMetadataBuilder.Build(typeof(TestEntity), new QueryOptions()); + + result.Should().NotBeNull(); + } + + [Fact] + public void IsIEnumerable_CollectionType_ReturnsTrue() + { + var result = ProjectionMetadataBuilder.IsIEnumerable(typeof(List), out var itemType); + + result.Should().BeTrue(); + itemType.Should().Be(typeof(int)); + } + + [Fact] + public void IsIEnumerable_String_ReturnsFalse() + { + var result = ProjectionMetadataBuilder.IsIEnumerable(typeof(string), out _); + + result.Should().BeFalse(); + } + + [Fact] + public void IsIEnumerable_Int_ReturnsFalse() + { + var result = ProjectionMetadataBuilder.IsIEnumerable(typeof(int), out _); + + result.Should().BeFalse(); + } +} diff --git a/tests/FlexQuery.NET.Tests/Tests/SelectTests.cs b/tests/FlexQuery.NET.Tests/Projection/SelectTests.cs similarity index 99% rename from tests/FlexQuery.NET.Tests/Tests/SelectTests.cs rename to tests/FlexQuery.NET.Tests/Projection/SelectTests.cs index 2a7daaf..0e9ed47 100644 --- a/tests/FlexQuery.NET.Tests/Tests/SelectTests.cs +++ b/tests/FlexQuery.NET.Tests/Projection/SelectTests.cs @@ -5,7 +5,7 @@ using System.Text.RegularExpressions; using FlexQuery.NET.Builders; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Projection; public class SelectTests : IDisposable { diff --git a/tests/FlexQuery.NET.Tests/Tests/WildcardProjectionTests.cs b/tests/FlexQuery.NET.Tests/Projection/WildcardProjectionTests.cs similarity index 99% rename from tests/FlexQuery.NET.Tests/Tests/WildcardProjectionTests.cs rename to tests/FlexQuery.NET.Tests/Projection/WildcardProjectionTests.cs index 4ac391f..2d406bc 100644 --- a/tests/FlexQuery.NET.Tests/Tests/WildcardProjectionTests.cs +++ b/tests/FlexQuery.NET.Tests/Projection/WildcardProjectionTests.cs @@ -6,7 +6,7 @@ using FlexQuery.NET.Options; using Microsoft.EntityFrameworkCore; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Projection; public class WildcardProjectionTests : IDisposable { diff --git a/tests/FlexQuery.NET.Tests/Resolvers/FieldResolverTests.cs b/tests/FlexQuery.NET.Tests/Resolvers/FieldResolverTests.cs new file mode 100644 index 0000000..0cd2bb2 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Resolvers/FieldResolverTests.cs @@ -0,0 +1,61 @@ +using FlexQuery.NET.Resolvers; + +namespace FlexQuery.NET.Tests.Resolvers; + +public class FieldResolverTests +{ + private sealed class TestEntity + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public Address? Address { get; set; } + } + + private sealed class Address + { + public string City { get; set; } = string.Empty; + public string Country { get; set; } = string.Empty; + } + + [Fact] + public void TryResolveType_SimpleField_ReturnsType() + { + var found = FieldResolver.TryResolveType(typeof(TestEntity), "Name", null, out var resolvedType); + + found.Should().BeTrue(); + resolvedType.Should().Be(typeof(string)); + } + + [Fact] + public void TryResolveType_NestedField_ReturnsType() + { + var found = FieldResolver.TryResolveType(typeof(TestEntity), "Address.City", null, out var resolvedType); + + found.Should().BeTrue(); + resolvedType.Should().Be(typeof(string)); + } + + [Fact] + public void TryResolveType_NonExistentField_ReturnsFalse() + { + var found = FieldResolver.TryResolveType(typeof(TestEntity), "NonExistent", null, out _); + + found.Should().BeFalse(); + } + + [Fact] + public void TryResolveType_EmptyPath_ReturnsFalse() + { + var found = FieldResolver.TryResolveType(typeof(TestEntity), "", null, out _); + + found.Should().BeFalse(); + } + + [Fact] + public void TryResolveType_NullPath_ReturnsFalse() + { + var found = FieldResolver.TryResolveType(typeof(TestEntity), null!, null, out _); + + found.Should().BeFalse(); + } +} diff --git a/tests/FlexQuery.NET.Tests/Security/FieldRegistryTests.cs b/tests/FlexQuery.NET.Tests/Security/FieldRegistryTests.cs new file mode 100644 index 0000000..804be0c --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Security/FieldRegistryTests.cs @@ -0,0 +1,74 @@ +using FlexQuery.NET.Security; + +namespace FlexQuery.NET.Tests.Security; + +public class FieldRegistryTests +{ + private sealed class TestEntity + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + [Fact] + public void IsAllowed_NoRegistration_ReturnsTrue() + { + FieldRegistry.IsAllowed(typeof(TestEntity), "AnyField").Should().BeTrue(); + } + + [Fact] + public void RegisterAndIsAllowed_RegisteredField_ReturnsTrue() + { + FieldRegistry.Register(["Id", "Name"]); + + try + { + FieldRegistry.IsAllowed(typeof(TestEntity), "Id").Should().BeTrue(); + FieldRegistry.IsAllowed(typeof(TestEntity), "Name").Should().BeTrue(); + } + finally + { + FieldRegistry.Clear(); + } + } + + [Fact] + public void RegisterAndIsAllowed_UnregisteredField_ReturnsFalse() + { + FieldRegistry.Register(["Id"]); + + try + { + FieldRegistry.IsAllowed(typeof(TestEntity), "Name").Should().BeFalse(); + } + finally + { + FieldRegistry.Clear(); + } + } + + [Fact] + public void Clear_RemovesRegistration() + { + FieldRegistry.Register(["Id"]); + FieldRegistry.Clear(); + + FieldRegistry.IsAllowed(typeof(TestEntity), "Id").Should().BeTrue(); + } + + [Fact] + public void Register_CaseInsensitive() + { + FieldRegistry.Register(["id"]); + + try + { + FieldRegistry.IsAllowed(typeof(TestEntity), "ID").Should().BeTrue(); + FieldRegistry.IsAllowed(typeof(TestEntity), "Id").Should().BeTrue(); + } + finally + { + FieldRegistry.Clear(); + } + } +} diff --git a/tests/FlexQuery.NET.Tests/Tests/FieldSecurityTests.cs b/tests/FlexQuery.NET.Tests/Security/FieldSecurityTests.cs similarity index 99% rename from tests/FlexQuery.NET.Tests/Tests/FieldSecurityTests.cs rename to tests/FlexQuery.NET.Tests/Security/FieldSecurityTests.cs index f68f18b..381ac48 100644 --- a/tests/FlexQuery.NET.Tests/Tests/FieldSecurityTests.cs +++ b/tests/FlexQuery.NET.Tests/Security/FieldSecurityTests.cs @@ -11,7 +11,7 @@ using FlexQuery.NET.Validation.Rules; using Microsoft.Extensions.Primitives; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Security; public class FieldSecurityTests { diff --git a/tests/FlexQuery.NET.Tests/Tests/IncludeSecurityTests.cs b/tests/FlexQuery.NET.Tests/Security/IncludeSecurityTests.cs similarity index 99% rename from tests/FlexQuery.NET.Tests/Tests/IncludeSecurityTests.cs rename to tests/FlexQuery.NET.Tests/Security/IncludeSecurityTests.cs index 11c1328..36a6163 100644 --- a/tests/FlexQuery.NET.Tests/Tests/IncludeSecurityTests.cs +++ b/tests/FlexQuery.NET.Tests/Security/IncludeSecurityTests.cs @@ -5,7 +5,7 @@ using FlexQuery.NET.Validation; using FlexQuery.NET.Validation.Rules; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Security; public class IncludeSecurityTests { diff --git a/tests/FlexQuery.NET.Tests/Security/OperatorRegistryTests.cs b/tests/FlexQuery.NET.Tests/Security/OperatorRegistryTests.cs new file mode 100644 index 0000000..e3a293e --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Security/OperatorRegistryTests.cs @@ -0,0 +1,55 @@ +using System.Linq.Expressions; +using FlexQuery.NET.Security; + +namespace FlexQuery.NET.Tests.Security; + +public class OperatorRegistryTests +{ + [Fact] + public void IsAllowed_SupportedOperators_ReturnsTrue() + { + var supported = new[] { "eq", "neq", "gt", "gte", "lt", "lte", "contains", "startswith", "endswith", + "like", "in", "notin", "between", "isnull", "isnotnull", "any", "all", "count" }; + + foreach (var op in supported) + { + OperatorRegistry.IsAllowed(op).Should().BeTrue($"operator '{op}' should be allowed"); + } + } + + [Fact] + public void IsAllowed_UnsupportedOperator_ReturnsFalse() + { + OperatorRegistry.IsAllowed("unsupported_op").Should().BeFalse(); + } + + [Fact] + public void IsAllowed_NullOrEmpty_ReturnsFalse() + { + OperatorRegistry.IsAllowed(null!).Should().BeFalse(); + OperatorRegistry.IsAllowed("").Should().BeFalse(); + } + + [Fact] + public void BinaryFactories_ContainsComparisonOperators() + { + OperatorRegistry.BinaryFactories.Should().ContainKey("eq"); + OperatorRegistry.BinaryFactories.Should().ContainKey("neq"); + OperatorRegistry.BinaryFactories.Should().ContainKey("gt"); + OperatorRegistry.BinaryFactories.Should().ContainKey("gte"); + OperatorRegistry.BinaryFactories.Should().ContainKey("lt"); + OperatorRegistry.BinaryFactories.Should().ContainKey("lte"); + } + + [Fact] + public void BinaryFactories_ProduceCorrectExpressions() + { + var left = Expression.Constant(1); + var right = Expression.Constant(2); + + OperatorRegistry.BinaryFactories["eq"](left, right).Should().BeAssignableTo() + .Which.NodeType.Should().Be(ExpressionType.Equal); + OperatorRegistry.BinaryFactories["gt"](left, right).Should().BeAssignableTo() + .Which.NodeType.Should().Be(ExpressionType.GreaterThan); + } +} diff --git a/tests/FlexQuery.NET.Tests/Security/SafePropertyResolverTests.cs b/tests/FlexQuery.NET.Tests/Security/SafePropertyResolverTests.cs new file mode 100644 index 0000000..5de5b75 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Security/SafePropertyResolverTests.cs @@ -0,0 +1,96 @@ +using FlexQuery.NET.Security; + +namespace FlexQuery.NET.Tests.Security; + +public class SafePropertyResolverTests +{ + private sealed class TestEntity + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public Address? Address { get; set; } + public List Orders { get; set; } = []; + } + + private sealed class Address + { + public string City { get; set; } = string.Empty; + } + + private sealed class Order + { + public int Number { get; set; } + } + + [Fact] + public void TryResolveChain_SimplePath_ReturnsChain() + { + var found = SafePropertyResolver.TryResolveChain(typeof(TestEntity), "Name", out var chain); + + found.Should().BeTrue(); + chain.Should().HaveCount(1); + chain[0].Name.Should().Be("Name"); + } + + [Fact] + public void TryResolveChain_NestedPath_ReturnsChain() + { + var found = SafePropertyResolver.TryResolveChain(typeof(TestEntity), "Address.City", out var chain); + + found.Should().BeTrue(); + chain.Should().HaveCount(2); + chain[0].Name.Should().Be("Address"); + chain[1].Name.Should().Be("City"); + } + + [Fact] + public void TryResolveChain_NonExistentPath_ReturnsFalse() + { + var found = SafePropertyResolver.TryResolveChain(typeof(TestEntity), "NonExistent", out _); + + found.Should().BeFalse(); + } + + [Fact] + public void TryResolveChain_EmptyPath_ReturnsFalse() + { + var found = SafePropertyResolver.TryResolveChain(typeof(TestEntity), "", out _); + + found.Should().BeFalse(); + } + + [Fact] + public void TryGetCollectionElementType_ListType_ReturnsElement() + { + var found = SafePropertyResolver.TryGetCollectionElementType(typeof(List), out var elementType); + + found.Should().BeTrue(); + elementType.Should().Be(typeof(Order)); + } + + [Fact] + public void TryGetCollectionElementType_NonCollection_ReturnsFalse() + { + var found = SafePropertyResolver.TryGetCollectionElementType(typeof(string), out _); + + found.Should().BeFalse(); + } + + [Fact] + public void TryGetCollectionElementType_ArrayType_ReturnsElement() + { + var found = SafePropertyResolver.TryGetCollectionElementType(typeof(Order[]), out var elementType); + + found.Should().BeTrue(); + elementType.Should().Be(typeof(Order)); + } + + [Fact] + public void TryGetCollectionElementType_IEnumerable_ReturnsElement() + { + var found = SafePropertyResolver.TryGetCollectionElementType(typeof(IEnumerable), out var elementType); + + found.Should().BeTrue(); + elementType.Should().Be(typeof(Order)); + } +} diff --git a/tests/FlexQuery.NET.Tests/Tests/SecurityTests.cs b/tests/FlexQuery.NET.Tests/Security/SecurityTests.cs similarity index 92% rename from tests/FlexQuery.NET.Tests/Tests/SecurityTests.cs rename to tests/FlexQuery.NET.Tests/Security/SecurityTests.cs index d3abbf3..e3f0180 100644 --- a/tests/FlexQuery.NET.Tests/Tests/SecurityTests.cs +++ b/tests/FlexQuery.NET.Tests/Security/SecurityTests.cs @@ -1,9 +1,10 @@ using FlexQuery.NET.Builders; +using FlexQuery.NET.Exceptions; using FlexQuery.NET.Models; using FlexQuery.NET.Parsers; using FlexQuery.NET.Parsers.Fql; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Security; public class SecurityTests : IDisposable { @@ -63,8 +64,6 @@ public void SelectInjection_InvalidAlias_ThrowsArgumentException() var maliciousSelect = "id,(SELECT * FROM Users) as bad_alias"; var dict = new Dictionary { { "select", maliciousSelect } }; var act = () => QueryOptionsParser.Parse(dict); - var options = act(); - var actBuild = () => SelectTreeBuilder.Build(options); - actBuild.Should().NotThrow(); + act.Should().Throw(); } } diff --git a/tests/FlexQuery.NET.Tests/Security/WildcardMatcherTests.cs b/tests/FlexQuery.NET.Tests/Security/WildcardMatcherTests.cs new file mode 100644 index 0000000..5114792 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Security/WildcardMatcherTests.cs @@ -0,0 +1,78 @@ +using FlexQuery.NET.Security; + +namespace FlexQuery.NET.Tests.Security; + +public class WildcardMatcherTests +{ + [Fact] + public void IsMatch_ExactMatch_ReturnsTrue() + { + WildcardMatcher.IsMatch("Name", ["Name"]).Should().BeTrue(); + } + + [Fact] + public void IsMatch_CaseInsensitiveMatch_ReturnsTrue() + { + WildcardMatcher.IsMatch("name", ["Name"]).Should().BeTrue(); + } + + [Fact] + public void IsMatch_StarWildcard_ReturnsTrue() + { + WildcardMatcher.IsMatch("AnyField", ["*"]).Should().BeTrue(); + } + + [Fact] + public void IsMatch_PrefixWildcard_ReturnsTrue() + { + WildcardMatcher.IsMatch("UserName", ["User*"]).Should().BeTrue(); + } + + [Fact] + public void IsMatch_SuffixWildcard_ReturnsTrue() + { + WildcardMatcher.IsMatch("FullName", ["*Name"]).Should().BeTrue(); + } + + [Fact] + public void IsMatch_MiddleWildcard_ReturnsTrue() + { + WildcardMatcher.IsMatch("StartMiddleEnd", ["Start*End"]).Should().BeTrue(); + } + + [Fact] + public void IsMatch_NoMatch_ReturnsFalse() + { + WildcardMatcher.IsMatch("FirstName", ["LastName"]).Should().BeFalse(); + } + + [Fact] + public void IsMatch_EmptyPatterns_ReturnsFalse() + { + WildcardMatcher.IsMatch("Name", []).Should().BeFalse(); + } + + [Fact] + public void IsMatch_MultiplePatterns_OneMatches_ReturnsTrue() + { + WildcardMatcher.IsMatch("Name", ["Age", "Name", "City"]).Should().BeTrue(); + } + + [Fact] + public void IsMatch_WildcardMatchesLiteralStarSuffix() + { + WildcardMatcher.IsMatch("Name*", ["Name*"]).Should().BeTrue(); + } + + [Fact] + public void IsMatch_MultipleAsterisks_MatchesCorrectly() + { + WildcardMatcher.IsMatch("A.B.C", ["A.*.C"]).Should().BeTrue(); + } + + [Fact] + public void IsMatch_DotNotation_MatchesCorrectly() + { + WildcardMatcher.IsMatch("Profile.Bio", ["Profile.*"]).Should().BeTrue(); + } +} diff --git a/tests/FlexQuery.NET.Tests/Validation/DefaultProjectionRuleTests.cs b/tests/FlexQuery.NET.Tests/Validation/DefaultProjectionRuleTests.cs new file mode 100644 index 0000000..b524cd0 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Validation/DefaultProjectionRuleTests.cs @@ -0,0 +1,193 @@ +using FlexQuery.NET.Execution; +using FlexQuery.NET.Internal; +using FlexQuery.NET.Models; +using FlexQuery.NET.Options; +using FlexQuery.NET.Validation; +using FlexQuery.NET.Validation.Rules; + +namespace FlexQuery.NET.Tests.Validation; + +public class DefaultProjectionRuleTests +{ + private sealed class TestEntity + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public int Age { get; set; } + public string? Description { get; set; } + public List Children { get; set; } = []; + } + + private sealed class Child + { + public int Id { get; set; } + public string Label { get; set; } = string.Empty; + } + + private sealed class TestGovernanceOptions : QueryGovernanceOptions { } + + private static QueryContext Context(Type? targetType = null, QueryGovernanceOptions? execOptions = null) => + new() { TargetType = targetType ?? typeof(TestEntity), ExecutionOptions = execOptions }; + + [Fact] + public void NullExecOptions_Passes() + { + var options = new QueryOptions(); + var rule = new DefaultProjectionRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(targetType: null, execOptions: null), result); + + result.IsValid.Should().BeTrue(); + options.Select.Should().BeNull(); + } + + [Fact] + public void SelectAlreadySet_SkipsInjection() + { + var execOptions = new TestGovernanceOptions { SelectableFields = ["Id", "Name"] }; + var options = new QueryOptions { Select = ["Id"] }; + var rule = new DefaultProjectionRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + options.Select.Should().BeEquivalentTo(["Id"]); + } + + [Fact] + public void SelectTreeSet_SkipsInjection() + { + var execOptions = new TestGovernanceOptions { SelectableFields = ["Id", "Name"] }; + var options = new QueryOptions { SelectTree = new SelectionNode() }; + options.SelectTree.MarkIncludeAllScalars(); + var rule = new DefaultProjectionRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + options.Select.Should().BeNullOrEmpty(); + } + + [Fact] + public void HasProjectionThroughIncludes_SkipsInjection() + { + var execOptions = new TestGovernanceOptions { SelectableFields = ["Id", "Name"] }; + var options = new QueryOptions { Includes = ["Children"] }; + var rule = new DefaultProjectionRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + options.Select.Should().BeNull(); + } + + [Fact] + public void InjectsFromSelectableFields() + { + var execOptions = new TestGovernanceOptions { SelectableFields = ["Id", "Name"] }; + var options = new QueryOptions(); + var rule = new DefaultProjectionRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + options.Select.Should().BeEquivalentTo(["Id", "Name"]); + } + + [Fact] + public void InjectsFromRoleAllowedFields() + { + var execOptions = new TestGovernanceOptions + { + RoleAllowedFields = new() { ["admin"] = ["Id", "Name", "Age"] }, + CurrentRole = "admin" + }; + var options = new QueryOptions(); + var rule = new DefaultProjectionRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + options.Select.Should().BeEquivalentTo(["Id", "Name", "Age"]); + } + + [Fact] + public void RoleAllowedFields_NoCurrentRole_FallsThrough() + { + var execOptions = new TestGovernanceOptions + { + RoleAllowedFields = new() { ["admin"] = ["Id", "Name"] }, + AllowedFields = ["Id"] + }; + var options = new QueryOptions(); + var rule = new DefaultProjectionRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + options.Select.Should().BeEquivalentTo(["Id"]); + } + + [Fact] + public void InjectsFromAllowedFields() + { + var execOptions = new TestGovernanceOptions { AllowedFields = ["Id", "Name"] }; + var options = new QueryOptions(); + var rule = new DefaultProjectionRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + options.Select.Should().BeEquivalentTo(["Id", "Name"]); + } + + [Fact] + public void ExcludesBlockedFields() + { + var execOptions = new TestGovernanceOptions { BlockedFields = ["Description"] }; + var options = new QueryOptions(); + var rule = new DefaultProjectionRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + options.Select.Should().Contain("Id"); + options.Select.Should().Contain("Name"); + options.Select.Should().Contain("Age"); + options.Select.Should().NotContain("Description"); + } + + [Fact] + public void WildcardExpansion_StarOnly() + { + var execOptions = new TestGovernanceOptions { SelectableFields = ["*"] }; + var options = new QueryOptions(); + var rule = new DefaultProjectionRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + options.Select.Should().Contain("Id"); + options.Select.Should().Contain("Name"); + options.Select.Should().Contain("Age"); + options.Select.Should().Contain("Description"); + } + + [Fact] + public void Priority_SelectableFieldsOverAllowedFields() + { + var execOptions = new TestGovernanceOptions + { + SelectableFields = ["Id"], + AllowedFields = ["Id", "Name", "Age"] + }; + var options = new QueryOptions(); + var rule = new DefaultProjectionRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + options.Select.Should().BeEquivalentTo(["Id"]); + } +} diff --git a/tests/FlexQuery.NET.Tests/Validation/FieldAccessValidationRuleTests.cs b/tests/FlexQuery.NET.Tests/Validation/FieldAccessValidationRuleTests.cs new file mode 100644 index 0000000..9be26c7 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Validation/FieldAccessValidationRuleTests.cs @@ -0,0 +1,807 @@ +using FlexQuery.NET.Exceptions; +using FlexQuery.NET.Execution; +using FlexQuery.NET.Internal; +using FlexQuery.NET.Models; +using FlexQuery.NET.Models.Aggregates; +using FlexQuery.NET.Models.Filters; +using FlexQuery.NET.Models.Paging; +using FlexQuery.NET.Options; +using FlexQuery.NET.Security; +using FlexQuery.NET.Validation; +using FlexQuery.NET.Validation.Rules; + +namespace FlexQuery.NET.Tests.Validation; + +public class FieldAccessValidationRuleTests +{ + private sealed class TestEntity + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public int Age { get; set; } + public string? Description { get; set; } + public List Children { get; set; } = []; + } + + private sealed class Child + { + public int Id { get; set; } + public string Label { get; set; } = string.Empty; + } + + private sealed class TestGovernanceOptions : QueryGovernanceOptions { } + + private static QueryContext Context(Type? targetType = null, QueryGovernanceOptions? execOptions = null) => + new() { TargetType = targetType ?? typeof(TestEntity), ExecutionOptions = execOptions }; + + private sealed class DenyAllResolver : IFieldAccessResolver + { + public bool IsAllowed(string field, QueryOperation operation, QueryContext context) => false; + } + + // --- Null / No-op --- + + [Fact] + public void NullExecOptions_Passes() + { + var options = new QueryOptions { Filter = new FilterGroup { Filters = [new FilterCondition { Field = "Id", Operator = "eq", Value = "1" }] } }; + var rule = new FieldAccessValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: null), result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void NoSecurityActive_Passes() + { + var options = new QueryOptions { Filter = new FilterGroup { Filters = [new FilterCondition { Field = "Id", Operator = "eq", Value = "1" }] } }; + var rule = new FieldAccessValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: new TestGovernanceOptions()), result); + + result.IsValid.Should().BeTrue(); + } + + // --- Default Sort --- + + [Fact] + public void DefaultSort_InjectedWhenNoSortSpecified() + { + var execOptions = new TestGovernanceOptions { DefaultSortField = "Name" }; + var options = new QueryOptions(); + var rule = new FieldAccessValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + options.Sort.Should().ContainSingle(s => s.Field == "Name" && s.Descending == false); + } + + [Fact] + public void DefaultSortDescending() + { + var execOptions = new TestGovernanceOptions { DefaultSortField = "Name", DefaultSortDescending = true }; + var options = new QueryOptions(); + var rule = new FieldAccessValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + options.Sort.Should().ContainSingle(s => s.Field == "Name" && s.Descending); + } + + [Fact] + public void DefaultSortNotInjectedWhenSortExists() + { + var execOptions = new TestGovernanceOptions { DefaultSortField = "Name" }; + var options = new QueryOptions { Sort = [new SortNode { Field = "Age" }] }; + var rule = new FieldAccessValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + options.Sort.Should().ContainSingle(s => s.Field == "Age"); + } + + // --- Blocked Fields per Operation --- + + [Fact] + public void BlockedFieldInFilter_StrictMode_Throws() + { + var execOptions = new TestGovernanceOptions + { + StrictFieldValidation = true, + BlockedFields = ["Name"] + }; + var options = new QueryOptions + { + Filter = new FilterGroup { Filters = [new FilterCondition { Field = "Name", Operator = "eq", Value = "test" }] } + }; + var rule = new FieldAccessValidationRule(); + + var act = () => rule.Validate(options, Context(execOptions: execOptions), ValidationResult.Success()); + + act.Should().Throw() + .Which.Result.Errors.Should().Contain(e => e.Code == ValidationErrorCodes.FieldAccessDenied); + } + + [Fact] + public void BlockedFieldInFilter_NonStrict_Removes() + { + var execOptions = new TestGovernanceOptions + { + StrictFieldValidation = false, + BlockedFields = ["Name"] + }; + var options = new QueryOptions + { + Filter = new FilterGroup { Filters = [new FilterCondition { Field = "Name", Operator = "eq", Value = "test" }] } + }; + var rule = new FieldAccessValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + result.Errors.Should().Contain(e => e.Code == ValidationErrorCodes.FieldAccessDenied); + options.Filter.Filters.Should().BeEmpty(); + } + + [Fact] + public void BlockedFieldInSort_Strict_Throws() + { + var execOptions = new TestGovernanceOptions + { + StrictFieldValidation = true, + BlockedFields = ["Name"] + }; + var options = new QueryOptions { Sort = [new SortNode { Field = "Name" }] }; + var rule = new FieldAccessValidationRule(); + + var act = () => rule.Validate(options, Context(execOptions: execOptions), ValidationResult.Success()); + + act.Should().Throw(); + } + + [Fact] + public void BlockedFieldInSort_NonStrict_Removes() + { + var execOptions = new TestGovernanceOptions + { + StrictFieldValidation = false, + BlockedFields = ["Name"] + }; + var options = new QueryOptions { Sort = [new SortNode { Field = "Name" }] }; + var rule = new FieldAccessValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + options.Sort.Should().BeEmpty(); + } + + [Fact] + public void BlockedFieldInSelect_Strict_Throws() + { + var execOptions = new TestGovernanceOptions + { + StrictFieldValidation = true, + BlockedFields = ["Name"] + }; + var options = new QueryOptions { Select = ["Name"] }; + var rule = new FieldAccessValidationRule(); + + var act = () => rule.Validate(options, Context(execOptions: execOptions), ValidationResult.Success()); + + act.Should().Throw(); + } + + [Fact] + public void BlockedFieldInSelect_NonStrict_RemovesAndReinjectsDefault() + { + var execOptions = new TestGovernanceOptions + { + StrictFieldValidation = false, + BlockedFields = ["Name"], + SelectableFields = ["Id", "Age"] + }; + var options = new QueryOptions { Select = ["Name"] }; + var rule = new FieldAccessValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + options.Select.Should().NotBeEmpty(); + options.Select.Should().NotContain("Name"); + } + + [Fact] + public void BlockedFieldInGroupBy_Strict_Throws() + { + var execOptions = new TestGovernanceOptions + { + StrictFieldValidation = true, + BlockedFields = ["Name"] + }; + var options = new QueryOptions { GroupBy = ["Name"] }; + var rule = new FieldAccessValidationRule(); + + var act = () => rule.Validate(options, Context(execOptions: execOptions), ValidationResult.Success()); + + act.Should().Throw(); + } + + [Fact] + public void BlockedFieldInGroupBy_NonStrict_Removes() + { + var execOptions = new TestGovernanceOptions + { + StrictFieldValidation = false, + BlockedFields = ["Name"] + }; + var options = new QueryOptions { GroupBy = ["Name"] }; + var rule = new FieldAccessValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + options.GroupBy.Should().BeEmpty(); + } + + [Fact] + public void BlockedFieldInAggregate_Strict_Throws() + { + var execOptions = new TestGovernanceOptions + { + StrictFieldValidation = true, + BlockedFields = ["Name"] + }; + var options = new QueryOptions { Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = "Name", Alias = "cnt" }] }; + var rule = new FieldAccessValidationRule(); + + var act = () => rule.Validate(options, Context(execOptions: execOptions), ValidationResult.Success()); + + act.Should().Throw(); + } + + [Fact] + public void BlockedFieldInAggregate_NonStrict_Removes() + { + var execOptions = new TestGovernanceOptions + { + StrictFieldValidation = false, + BlockedFields = ["Name"] + }; + var options = new QueryOptions { Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = "Name", Alias = "cnt" }] }; + var rule = new FieldAccessValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + options.Aggregates.Should().BeEmpty(); + } + + [Fact] + public void BlockedFieldInHaving_Strict_Throws() + { + var execOptions = new TestGovernanceOptions + { + StrictFieldValidation = true, + BlockedFields = ["Name"] + }; + var options = new QueryOptions + { + Having = new HavingCondition { Function = AggregateFunction.Count, Field = "Name", Operator = "gt", Value = "5" } + }; + var rule = new FieldAccessValidationRule(); + + var act = () => rule.Validate(options, Context(execOptions: execOptions), ValidationResult.Success()); + + act.Should().Throw(); + } + + // --- Operation-Level Fields --- + + [Fact] + public void OperationLevel_FilterableFields_Strict_Throws() + { + var execOptions = new TestGovernanceOptions + { + StrictFieldValidation = true, + FilterableFields = ["Id"] + }; + var options = new QueryOptions + { + Filter = new FilterGroup { Filters = [new FilterCondition { Field = "Name", Operator = "eq", Value = "test" }] } + }; + var rule = new FieldAccessValidationRule(); + + var act = () => rule.Validate(options, Context(execOptions: execOptions), ValidationResult.Success()); + + act.Should().Throw(); + } + + [Fact] + public void OperationLevel_SortableFields_Strict_Throws() + { + var execOptions = new TestGovernanceOptions + { + StrictFieldValidation = true, + SortableFields = ["Id"] + }; + var options = new QueryOptions { Sort = [new SortNode { Field = "Name" }] }; + var rule = new FieldAccessValidationRule(); + + var act = () => rule.Validate(options, Context(execOptions: execOptions), ValidationResult.Success()); + + act.Should().Throw(); + } + + [Fact] + public void OperationLevel_SelectableFields_Strict_Throws() + { + var execOptions = new TestGovernanceOptions + { + StrictFieldValidation = true, + SelectableFields = ["Id"] + }; + var options = new QueryOptions { Select = ["Name"] }; + var rule = new FieldAccessValidationRule(); + + var act = () => rule.Validate(options, Context(execOptions: execOptions), ValidationResult.Success()); + + act.Should().Throw(); + } + + [Fact] + public void OperationLevel_GroupableFields_Strict_Throws() + { + var execOptions = new TestGovernanceOptions + { + StrictFieldValidation = true, + GroupableFields = ["Id"] + }; + var options = new QueryOptions { GroupBy = ["Name"] }; + var rule = new FieldAccessValidationRule(); + + var act = () => rule.Validate(options, Context(execOptions: execOptions), ValidationResult.Success()); + + act.Should().Throw(); + } + + [Fact] + public void OperationLevel_AggregatableFields_Strict_Throws() + { + var execOptions = new TestGovernanceOptions + { + StrictFieldValidation = true, + AggregatableFields = ["Id"] + }; + var options = new QueryOptions { Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = "Name", Alias = "cnt" }] }; + var rule = new FieldAccessValidationRule(); + + var act = () => rule.Validate(options, Context(execOptions: execOptions), ValidationResult.Success()); + + act.Should().Throw(); + } + + // --- Global AllowedFields --- + + [Fact] + public void GlobalAllowedFields_Strict_Throws() + { + var execOptions = new TestGovernanceOptions + { + StrictFieldValidation = true, + AllowedFields = ["Id", "Age"] + }; + var options = new QueryOptions { Select = ["Name"] }; + var rule = new FieldAccessValidationRule(); + + var act = () => rule.Validate(options, Context(execOptions: execOptions), ValidationResult.Success()); + + act.Should().Throw(); + } + + // --- Depth Validation --- + + [Fact] + public void DepthValidation_Strict_Throws() + { + var execOptions = new TestGovernanceOptions + { + StrictFieldValidation = true, + MaxFieldDepth = 1 + }; + var options = new QueryOptions { Select = ["Children.Label"] }; + var rule = new FieldAccessValidationRule(); + + var act = () => rule.Validate(options, Context(execOptions: execOptions), ValidationResult.Success()); + + act.Should().Throw(); + } + + [Fact] + public void DepthValidation_NonStrict_Removes() + { + var execOptions = new TestGovernanceOptions + { + StrictFieldValidation = false, + MaxFieldDepth = 1 + }; + var options = new QueryOptions { Select = ["Children.Label", "Id"] }; + var rule = new FieldAccessValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + options.Select.Should().BeEquivalentTo(["Id"]); + } + + // --- Custom Resolver --- + + [Fact] + public void CustomResolver_Denies_Strict_Throws() + { + var execOptions = new TestGovernanceOptions + { + StrictFieldValidation = true, + FieldAccessResolver = new DenyAllResolver() + }; + var options = new QueryOptions { Select = ["Id"] }; + var rule = new FieldAccessValidationRule(); + + var act = () => rule.Validate(options, Context(execOptions: execOptions), ValidationResult.Success()); + + act.Should().Throw(); + } + + [Fact] + public void CustomResolver_Denies_NonStrict_Removes() + { + var execOptions = new TestGovernanceOptions + { + StrictFieldValidation = false, + FieldAccessResolver = new DenyAllResolver() + }; + var options = new QueryOptions { Select = ["Id", "Name"] }; + var rule = new FieldAccessValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + options.Select.Should().BeEmpty(); + } + + // --- Role-Based --- + + [Fact] + public void RoleAllowedFields_Strict_Throws() + { + var execOptions = new TestGovernanceOptions + { + StrictFieldValidation = true, + RoleAllowedFields = new() { ["admin"] = ["Id"] }, + CurrentRole = "admin" + }; + var options = new QueryOptions { Select = ["Name"] }; + var rule = new FieldAccessValidationRule(); + + var act = () => rule.Validate(options, Context(execOptions: execOptions), ValidationResult.Success()); + + act.Should().Throw(); + } + + [Fact] + public void RoleAllowedFields_NonStrict_Removes() + { + var execOptions = new TestGovernanceOptions + { + StrictFieldValidation = false, + RoleAllowedFields = new() { ["admin"] = ["Id"] }, + CurrentRole = "admin" + }; + var options = new QueryOptions { Select = ["Id", "Name"] }; + var rule = new FieldAccessValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + options.Select.Should().BeEquivalentTo(["Id"]); + } + + // --- Navigation Traversal by Includes --- + + [Fact] + public void NavigationTraversalByIncludes_AllowsFilteringOnNavProperty() + { + var execOptions = new TestGovernanceOptions + { + StrictFieldValidation = true, + AllowedFields = ["Id"], + AllowedIncludes = ["Children"] + }; + var options = new QueryOptions + { + Filter = new FilterGroup + { + Filters = + [ + new FilterCondition { Field = "Children.Label", Operator = "eq", Value = "test" } + ] + } + }; + var rule = new FieldAccessValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void NavigationTraversalByIncludes_RejectsFieldOutsideAllowed() + { + var execOptions = new TestGovernanceOptions + { + StrictFieldValidation = true, + AllowedFields = ["Id"], + AllowedIncludes = ["Children"] + }; + var options = new QueryOptions { Select = ["Name"] }; + var rule = new FieldAccessValidationRule(); + + var act = () => rule.Validate(options, Context(execOptions: execOptions), ValidationResult.Success()); + + act.Should().Throw(); + } + + // --- SelectTree --- + + [Fact] + public void SelectTree_BlockedField_RemovesChild() + { + var execOptions = new TestGovernanceOptions + { + StrictFieldValidation = false, + BlockedFields = ["Name"] + }; + var selectTree = new SelectionNode(); + selectTree.GetOrAddChild("Id"); + selectTree.GetOrAddChild("Name"); + var options = new QueryOptions { SelectTree = selectTree }; + var rule = new FieldAccessValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + options.SelectTree.Should().NotBeNull(); + options.SelectTree.HasChildren.Should().BeTrue(); + options.SelectTree.Children.Should().ContainKey("Id"); + options.SelectTree.Children.Should().NotContainKey("Name"); + } + + [Fact] + public void SelectTree_IncludeAllScalars_ValidatesEachScalar() + { + var execOptions = new TestGovernanceOptions + { + StrictFieldValidation = false, + BlockedFields = ["Description"] + }; + var selectTree = new SelectionNode(); + selectTree.MarkIncludeAllScalars(); + var options = new QueryOptions { SelectTree = selectTree }; + var rule = new FieldAccessValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + options.SelectTree.Should().NotBeNull(); + options.SelectTree.Children.Should().ContainKey("Id"); + options.SelectTree.Children.Should().ContainKey("Name"); + options.SelectTree.Children.Should().ContainKey("Age"); + options.SelectTree.Children.Should().NotContainKey("Description"); + } + + // --- Filter Group Recursion --- + + [Fact] + public void AllowedFieldFilter_NonStrict_KeepsAllowedFields() + { + var execOptions = new TestGovernanceOptions + { + StrictFieldValidation = false, + FilterableFields = ["Id"] + }; + var options = new QueryOptions + { + Filter = new FilterGroup + { + Filters = + [ + new FilterCondition { Field = "Id", Operator = "eq", Value = "1" }, + new FilterCondition { Field = "Name", Operator = "eq", Value = "test" } + ] + } + }; + var rule = new FieldAccessValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + options.Filter.Filters.Should().ContainSingle(f => f.Field == "Id"); + } + + [Fact] + public void NestedFilterGroup_ValidatesRecursively() + { + var execOptions = new TestGovernanceOptions + { + StrictFieldValidation = false, + FilterableFields = ["Id"] + }; + var options = new QueryOptions + { + Filter = new FilterGroup + { + Groups = + [ + new FilterGroup + { + Filters = [new FilterCondition { Field = "Name", Operator = "eq", Value = "test" }] + } + ] + } + }; + var rule = new FieldAccessValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + options.Filter.Groups.Should().ContainSingle(); + options.Filter.Groups[0].Filters.Should().BeEmpty(); + } + + [Fact] + public void ScopedFilter_ValidatesRecursively() + { + var execOptions = new TestGovernanceOptions + { + StrictFieldValidation = false, + FilterableFields = ["Children", "Children.Label"] + }; + var options = new QueryOptions + { + Filter = new FilterGroup + { + Filters = + [ + new FilterCondition + { + Field = "Children", + Operator = "any", + ScopedFilter = new FilterGroup + { + Filters = + [ + new FilterCondition { Field = "Label", Operator = "eq", Value = "x" } + ] + } + } + ] + } + }; + var rule = new FieldAccessValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + options.Filter.Filters.Should().ContainSingle(); + options.Filter.Filters[0].ScopedFilter.Filters.Should().ContainSingle(f => f.Field == "Label"); + } + + // --- Field Mappings --- + + [Fact] + public void FieldMappings_NormalizesField() + { + var execOptions = new TestGovernanceOptions + { + StrictFieldValidation = true, + FieldMappings = new() { ["alias"] = "Name" }, + BlockedFields = ["Name"] + }; + var options = new QueryOptions + { + Filter = new FilterGroup { Filters = [new FilterCondition { Field = "alias", Operator = "eq", Value = "test" }] } + }; + var rule = new FieldAccessValidationRule(); + + var act = () => rule.Validate(options, Context(execOptions: execOptions), ValidationResult.Success()); + + act.Should().Throw() + .Which.Result.Errors.Should().Contain(e => e.Code == ValidationErrorCodes.FieldAccessDenied); + } + + // --- Sort with GroupBy (Aggregate Alias Resolution) --- + + [Fact] + public void SortWithGroupBy_ResolvesAggregateAlias() + { + var execOptions = new TestGovernanceOptions + { + StrictFieldValidation = true, + AllowedFields = ["Age"] + }; + var options = new QueryOptions + { + GroupBy = ["Age"], + Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = "Age", Alias = "cnt" }], + Sort = [new SortNode { Field = "cnt" }] + }; + var rule = new FieldAccessValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + result.IsValid.Should().BeTrue(); + } + + // --- Startup Config Validation --- + + [Fact] + public void ValidateDefaultSortFieldConfiguration_BlockedField_Throws() + { + var execOptions = new TestGovernanceOptions + { + DefaultSortField = "Name", + BlockedFields = ["Name"] + }; + + var act = () => FieldAccessValidationRule.ValidateDefaultSortFieldConfiguration(execOptions); + + act.Should().Throw() + .Which.Message.Should().Contain("DefaultSortField"); + } + + [Fact] + public void ValidateDefaultSortFieldConfiguration_NotInSortableFields_Throws() + { + var execOptions = new TestGovernanceOptions + { + DefaultSortField = "Name", + SortableFields = ["Id"] + }; + + var act = () => FieldAccessValidationRule.ValidateDefaultSortFieldConfiguration(execOptions); + + act.Should().Throw() + .Which.Message.Should().Contain("DefaultSortField"); + } + + [Fact] + public void ValidateDefaultSortFieldConfiguration_NotInAllowedFields_Throws() + { + var execOptions = new TestGovernanceOptions + { + DefaultSortField = "Name", + AllowedFields = ["Id"] + }; + + var act = () => FieldAccessValidationRule.ValidateDefaultSortFieldConfiguration(execOptions); + + act.Should().Throw() + .Which.Message.Should().Contain("DefaultSortField"); + } + + [Fact] + public void ValidateDefaultSortFieldConfiguration_Empty_Passes() + { + var act = () => FieldAccessValidationRule.ValidateDefaultSortFieldConfiguration(new TestGovernanceOptions()); + + act.Should().NotThrow(); + } +} diff --git a/tests/FlexQuery.NET.Tests/Validation/GovernanceConfigValidationRuleTests.cs b/tests/FlexQuery.NET.Tests/Validation/GovernanceConfigValidationRuleTests.cs new file mode 100644 index 0000000..7b7bf86 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Validation/GovernanceConfigValidationRuleTests.cs @@ -0,0 +1,242 @@ +using FlexQuery.NET.Execution; +using FlexQuery.NET.Models; +using FlexQuery.NET.Options; +using FlexQuery.NET.Validation; +using FlexQuery.NET.Validation.Rules; + +namespace FlexQuery.NET.Tests.Validation; + +public class GovernanceConfigValidationRuleTests +{ + private sealed class TestEntity + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public int Age { get; set; } + public string? Description { get; set; } + public List Children { get; set; } = []; + } + + private sealed class Child + { + public int Id { get; set; } + public string Label { get; set; } = string.Empty; + } + + private sealed class TestGovernanceOptions : QueryGovernanceOptions { } + + private static QueryContext Context(Type? targetType = null, QueryGovernanceOptions? execOptions = null) => + new() { TargetType = targetType ?? typeof(TestEntity), ExecutionOptions = execOptions }; + + [Fact] + public void NullExecOptions_Passes() + { + var rule = new GovernanceConfigValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(new QueryOptions(), Context(targetType: typeof(TestEntity), execOptions: null), result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void NullTargetType_Passes() + { + var rule = new GovernanceConfigValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(new QueryOptions(), Context(targetType: null, execOptions: new TestGovernanceOptions()), result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void NoGovernanceLists_Passes() + { + var execOptions = new TestGovernanceOptions(); + var rule = new GovernanceConfigValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(new QueryOptions(), Context(execOptions: execOptions), result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void ValidFieldLists_Passes() + { + var execOptions = new TestGovernanceOptions + { + AllowedFields = ["Id", "Name", "Age"], + BlockedFields = ["Description"], + SelectableFields = ["Id", "Name"], + FilterableFields = ["Age"], + SortableFields = ["Name"], + GroupableFields = ["Age"], + AggregatableFields = ["Age"] + }; + var rule = new GovernanceConfigValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(new QueryOptions(), Context(execOptions: execOptions), result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void InvalidFieldInAllowedFields_Fails() + { + var execOptions = new TestGovernanceOptions { AllowedFields = ["Id", "NonExistentField"] }; + var rule = new GovernanceConfigValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(new QueryOptions(), Context(execOptions: execOptions), result); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.Code == ValidationErrorCodes.GovernanceFieldNotFound && e.Field == "NonExistentField"); + } + + [Fact] + public void InvalidFieldInBlockedFields_Fails() + { + var execOptions = new TestGovernanceOptions { BlockedFields = ["FakeField"] }; + var rule = new GovernanceConfigValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(new QueryOptions(), Context(execOptions: execOptions), result); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.Code == ValidationErrorCodes.GovernanceFieldNotFound && e.Field == "FakeField"); + } + + [Fact] + public void InvalidFieldInSelectableFields_Fails() + { + var execOptions = new TestGovernanceOptions { SelectableFields = ["BadField"] }; + var rule = new GovernanceConfigValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(new QueryOptions(), Context(execOptions: execOptions), result); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.Code == ValidationErrorCodes.GovernanceFieldNotFound && e.Field == "BadField"); + } + + [Fact] + public void InvalidFieldInFilterableFields_Fails() + { + var execOptions = new TestGovernanceOptions { FilterableFields = ["Nope"] }; + var rule = new GovernanceConfigValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(new QueryOptions(), Context(execOptions: execOptions), result); + + result.IsValid.Should().BeFalse(); + } + + [Fact] + public void InvalidFieldInSortableFields_Fails() + { + var execOptions = new TestGovernanceOptions { SortableFields = ["Nope"] }; + var rule = new GovernanceConfigValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(new QueryOptions(), Context(execOptions: execOptions), result); + + result.IsValid.Should().BeFalse(); + } + + [Fact] + public void InvalidFieldInGroupableFields_Fails() + { + var execOptions = new TestGovernanceOptions { GroupableFields = ["Nope"] }; + var rule = new GovernanceConfigValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(new QueryOptions(), Context(execOptions: execOptions), result); + + result.IsValid.Should().BeFalse(); + } + + [Fact] + public void InvalidFieldInAggregatableFields_Fails() + { + var execOptions = new TestGovernanceOptions { AggregatableFields = ["Nope"] }; + var rule = new GovernanceConfigValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(new QueryOptions(), Context(execOptions: execOptions), result); + + result.IsValid.Should().BeFalse(); + } + + [Fact] + public void WildcardFieldEntries_Skipped() + { + var execOptions = new TestGovernanceOptions + { + AllowedFields = ["Id", "Name", "Address.*"], + BlockedFields = ["*Internal*"] + }; + var rule = new GovernanceConfigValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(new QueryOptions(), Context(execOptions: execOptions), result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void EmptyOrWhitespaceField_Skipped() + { + var execOptions = new TestGovernanceOptions + { + AllowedFields = ["Id", ""], + BlockedFields = [" "] + }; + var rule = new GovernanceConfigValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(new QueryOptions(), Context(execOptions: execOptions), result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void ValidInclude_Passes() + { + var execOptions = new TestGovernanceOptions { AllowedIncludes = ["Children"] }; + var rule = new GovernanceConfigValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(new QueryOptions(), Context(execOptions: execOptions), result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void InvalidInclude_Fails() + { + var execOptions = new TestGovernanceOptions { AllowedIncludes = ["NonExistentNav"] }; + var rule = new GovernanceConfigValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(new QueryOptions(), Context(execOptions: execOptions), result); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.Code == ValidationErrorCodes.GovernanceFieldNotFound); + } + + [Fact] + public void NonNavigationInclude_Fails() + { + var execOptions = new TestGovernanceOptions { AllowedIncludes = ["Name"] }; + var rule = new GovernanceConfigValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(new QueryOptions(), Context(execOptions: execOptions), result); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.Code == ValidationErrorCodes.GovernanceFieldNotFound); + } +} diff --git a/tests/FlexQuery.NET.Tests/Tests/GovernanceReviewChangesTests.cs b/tests/FlexQuery.NET.Tests/Validation/GovernanceReviewChangesTests.cs similarity index 99% rename from tests/FlexQuery.NET.Tests/Tests/GovernanceReviewChangesTests.cs rename to tests/FlexQuery.NET.Tests/Validation/GovernanceReviewChangesTests.cs index 8a8e84e..a9f5a07 100644 --- a/tests/FlexQuery.NET.Tests/Tests/GovernanceReviewChangesTests.cs +++ b/tests/FlexQuery.NET.Tests/Validation/GovernanceReviewChangesTests.cs @@ -5,7 +5,7 @@ using FlexQuery.NET.Models.Paging; using FlexQuery.NET.Options; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Validation; /// /// Locks in the governance model changes from the security review: diff --git a/tests/FlexQuery.NET.Tests/Validation/HavingAliasIntegrityRuleTests.cs b/tests/FlexQuery.NET.Tests/Validation/HavingAliasIntegrityRuleTests.cs new file mode 100644 index 0000000..78cb027 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Validation/HavingAliasIntegrityRuleTests.cs @@ -0,0 +1,98 @@ +using FlexQuery.NET.Execution; +using FlexQuery.NET.Models; +using FlexQuery.NET.Models.Aggregates; +using FlexQuery.NET.Options; +using FlexQuery.NET.Validation; +using FlexQuery.NET.Validation.Rules; + +namespace FlexQuery.NET.Tests.Validation; + +public class HavingAliasIntegrityRuleTests +{ + private sealed class TestEntity + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public int Age { get; set; } + } + + private static QueryContext Context(Type? targetType = null, QueryGovernanceOptions? execOptions = null) => + new() { TargetType = targetType ?? typeof(TestEntity), ExecutionOptions = execOptions }; + + [Fact] + public void NullHaving_Passes() + { + var options = new QueryOptions { Having = null }; + var rule = new HavingAliasIntegrityRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(), result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void EmptyAggregates_Passes() + { + var options = new QueryOptions + { + Having = new HavingCondition { Function = AggregateFunction.Count, Field = "Id", Operator = "gt", Value = "5" }, + Aggregates = [] + }; + var rule = new HavingAliasIntegrityRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(), result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void MatchingAggregate_Passes() + { + var options = new QueryOptions + { + Aggregates = [new AggregateModel { Function = AggregateFunction.Sum, Field = "Age", Alias = "totalAge" }], + Having = new HavingCondition { Function = AggregateFunction.Sum, Field = "Age", Operator = "gt", Value = "100" } + }; + var rule = new HavingAliasIntegrityRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(), result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void MismatchedAggregate_Fails() + { + var options = new QueryOptions + { + Aggregates = [new AggregateModel { Function = AggregateFunction.Sum, Field = "Age", Alias = "totalAge" }], + Having = new HavingCondition { Function = AggregateFunction.Count, Field = "Id", Operator = "gt", Value = "5" } + }; + var rule = new HavingAliasIntegrityRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(), result); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainSingle(e => e.Code == ValidationErrorCodes.HavingAliasMismatch); + } + + [Fact] + public void CaseInsensitiveFieldMatch_Passes() + { + var options = new QueryOptions + { + Aggregates = [new AggregateModel { Function = AggregateFunction.Sum, Field = "Age", Alias = "totalAge" }], + Having = new HavingCondition { Function = AggregateFunction.Sum, Field = "age", Operator = "gt", Value = "100" } + }; + var rule = new HavingAliasIntegrityRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(), result); + + result.IsValid.Should().BeTrue(); + } +} diff --git a/tests/FlexQuery.NET.Tests/Validation/IncludeAccessValidationRuleTests.cs b/tests/FlexQuery.NET.Tests/Validation/IncludeAccessValidationRuleTests.cs new file mode 100644 index 0000000..5e66ab6 --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Validation/IncludeAccessValidationRuleTests.cs @@ -0,0 +1,152 @@ +using FlexQuery.NET.Exceptions; +using FlexQuery.NET.Execution; +using FlexQuery.NET.Models; +using FlexQuery.NET.Models.Projection; +using FlexQuery.NET.Options; +using FlexQuery.NET.Validation; +using FlexQuery.NET.Validation.Rules; + +namespace FlexQuery.NET.Tests.Validation; + +public class IncludeAccessValidationRuleTests +{ + private sealed class TestEntity + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public List Children { get; set; } = []; + } + + private sealed class Child + { + public int Id { get; set; } + public string Label { get; set; } = string.Empty; + } + + private sealed class TestGovernanceOptions : QueryGovernanceOptions { } + + private static QueryContext Context(Type? targetType = null, QueryGovernanceOptions? execOptions = null) => + new() { TargetType = targetType ?? typeof(TestEntity), ExecutionOptions = execOptions }; + + [Fact] + public void NoAllowedIncludes_Passes() + { + var options = new QueryOptions { Includes = ["Children"] }; + var rule = new IncludeAccessValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: new TestGovernanceOptions()), result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void AllowedInclude_Passes() + { + var execOptions = new TestGovernanceOptions { AllowedIncludes = ["Children"] }; + var options = new QueryOptions { Includes = ["Children"] }; + var rule = new IncludeAccessValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void DisallowedIncludeStrict_Throws() + { + var execOptions = new TestGovernanceOptions { StrictFieldValidation = true, AllowedIncludes = ["Children"] }; + var options = new QueryOptions { Includes = ["NonExistentNav"] }; + var rule = new IncludeAccessValidationRule(); + + var act = () => rule.Validate(options, Context(execOptions: execOptions), ValidationResult.Success()); + + act.Should().Throw() + .Which.Message.Should().Contain("NonExistentNav"); + } + + [Fact] + public void DisallowedIncludeNonStrict_RemovesAndAddsError() + { + var execOptions = new TestGovernanceOptions { StrictFieldValidation = false, AllowedIncludes = ["Children"] }; + var options = new QueryOptions { Includes = ["NonExistentNav"] }; + var rule = new IncludeAccessValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainSingle(e => e.Code == ValidationErrorCodes.IncludeAccessDenied); + options.Includes.Should().BeEmpty(); + } + + [Fact] + public void MixedIncludes_RemovesOnlyDisallowed() + { + var execOptions = new TestGovernanceOptions { StrictFieldValidation = false, AllowedIncludes = ["Children"] }; + var options = new QueryOptions { Includes = ["Children", "NonExistentNav"] }; + var rule = new IncludeAccessValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + options.Includes.Should().BeEquivalentTo(["Children"]); + result.Errors.Should().ContainSingle(e => e.Code == ValidationErrorCodes.IncludeAccessDenied); + } + + [Fact] + public void DisallowedExpandNodeStrict_Throws() + { + var execOptions = new TestGovernanceOptions { StrictFieldValidation = true, AllowedIncludes = ["Children"] }; + var options = new QueryOptions { Expand = [new IncludeNode { Path = "NonExistentNav" }] }; + var rule = new IncludeAccessValidationRule(); + + var act = () => rule.Validate(options, Context(execOptions: execOptions), ValidationResult.Success()); + + act.Should().Throw(); + } + + [Fact] + public void DisallowedExpandNodeNonStrict_Removes() + { + var execOptions = new TestGovernanceOptions { StrictFieldValidation = false, AllowedIncludes = ["Children"] }; + var options = new QueryOptions { Expand = [new IncludeNode { Path = "NonExistentNav" }] }; + var rule = new IncludeAccessValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + options.Expand.Should().BeNullOrEmpty(); + result.Errors.Should().ContainSingle(e => e.Code == ValidationErrorCodes.IncludeAccessDenied); + } + + [Fact] + public void NestedExpandNode_DisallowedChildRemoved() + { + var execOptions = new TestGovernanceOptions + { + StrictFieldValidation = false, + AllowedIncludes = ["Children"] + }; + var options = new QueryOptions + { + Expand = + [ + new IncludeNode + { + Path = "Children", + Children = [new IncludeNode { Path = "NonExistentChild" }] + } + ] + }; + var rule = new IncludeAccessValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + options.Expand.Should().ContainSingle(); + options.Expand[0].Children.Should().BeEmpty(); + result.Errors.Should().ContainSingle(e => e.Code == ValidationErrorCodes.IncludeAccessDenied); + } +} diff --git a/tests/FlexQuery.NET.Tests/Validation/ValidationEdgeCaseTests.cs b/tests/FlexQuery.NET.Tests/Validation/ValidationEdgeCaseTests.cs new file mode 100644 index 0000000..806db3f --- /dev/null +++ b/tests/FlexQuery.NET.Tests/Validation/ValidationEdgeCaseTests.cs @@ -0,0 +1,493 @@ +using FlexQuery.NET.Execution; +using FlexQuery.NET.Models; +using FlexQuery.NET.Models.Aggregates; +using FlexQuery.NET.Models.Filters; +using FlexQuery.NET.Models.Paging; +using FlexQuery.NET.Models.Projection; +using FlexQuery.NET.Options; +using FlexQuery.NET.Validation; +using FlexQuery.NET.Validation.Rules; + +namespace FlexQuery.NET.Tests.Validation; + +public class ValidationEdgeCaseTests +{ + private sealed class TestEntity + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public int Age { get; set; } + public string? Description { get; set; } + public List Children { get; set; } = []; + } + + private sealed class Child + { + public int Id { get; set; } + public string Label { get; set; } = string.Empty; + } + + private static QueryContext Context(Type? targetType = null, QueryGovernanceOptions? execOptions = null) => + new() { TargetType = targetType ?? typeof(TestEntity), ExecutionOptions = execOptions }; + + [Fact] + public void PaginationModeValidation_RejectsKeysetWithOffset() + { + var options = new QueryOptions + { + IsKeysetMode = true, + OffsetExplicitlyRequested = true + }; + var rule = new PaginationModeValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(), result); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainSingle(e => e.Code == ValidationErrorCodes.PaginationModeConflict); + } + + [Fact] + public void PaginationModeValidation_AllowsKeysetWithoutOffset() + { + var options = new QueryOptions + { + IsKeysetMode = true, + OffsetExplicitlyRequested = false + }; + var rule = new PaginationModeValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(), result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void PaginationModeValidation_AllowsOffsetWithoutKeyset() + { + var options = new QueryOptions + { + IsKeysetMode = false, + OffsetExplicitlyRequested = true + }; + var rule = new PaginationModeValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(), result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void PaginationModeValidation_AllowsNeither() + { + var options = new QueryOptions + { + IsKeysetMode = false, + OffsetExplicitlyRequested = false + }; + var rule = new PaginationModeValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(), result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void OperatorValidity_RejectsUnsupportedOperator() + { + var options = new QueryOptions + { + Filter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Name", Operator = "unsupported_op", Value = "test" }] + } + }; + var rule = new OperatorValidityRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(), result); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.Code == ValidationErrorCodes.InvalidOperator); + } + + [Fact] + public void OperatorValidity_RejectsOperatorNotAllowedPerField() + { + var execOptions = new TestGovernanceOptions(); + execOptions.AllowOperators("Name", "eq"); + var options = new QueryOptions + { + Filter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Name", Operator = "contains", Value = "test" }] + } + }; + var rule = new OperatorValidityRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.Code == ValidationErrorCodes.OperatorNotAllowed); + } + + [Fact] + public void OperatorValidity_AllowsOperatorAllowedPerField() + { + var execOptions = new TestGovernanceOptions(); + execOptions.AllowOperators("Name", "eq", "contains"); + var options = new QueryOptions + { + Filter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Name", Operator = "contains", Value = "test" }] + } + }; + var rule = new OperatorValidityRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(execOptions: execOptions), result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void OperatorValidity_ValidatesScopedFilterOperators() + { + var options = new QueryOptions + { + Filter = new FilterGroup + { + Filters = + [ + new FilterCondition + { + Field = "Children", + Operator = "any", + ScopedFilter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Label", Operator = "unsupported_op", Value = "x" }] + } + } + ] + } + }; + var rule = new OperatorValidityRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(), result); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.Code == ValidationErrorCodes.InvalidOperator); + } + + [Fact] + public void TypeCompatibility_RejectsStringOperatorOnNonStringField() + { + var options = new QueryOptions + { + Filter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Id", Operator = "contains", Value = "test" }] + } + }; + var rule = new TypeCompatibilityRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(), result); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.Code == ValidationErrorCodes.TypeMismatch); + } + + [Fact] + public void TypeCompatibility_AllowsStringOperatorOnStringField() + { + var options = new QueryOptions + { + Filter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Name", Operator = "contains", Value = "test" }] + } + }; + var rule = new TypeCompatibilityRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(), result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void TypeCompatibility_AllowsStartsWithEndsWithLikeOnStringField() + { + var rule = new TypeCompatibilityRule(); + + foreach (var op in new[] { "startswith", "endswith", "like" }) + { + var options = new QueryOptions + { + Filter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Name", Operator = op, Value = "test" }] + } + }; + var result = ValidationResult.Success(); + rule.Validate(options, Context(), result); + result.IsValid.Should().BeTrue($"operator '{op}' should be valid on string fields"); + } + } + + [Fact] + public void TypeCompatibility_RejectsNonConvertibleValue() + { + var options = new QueryOptions + { + Filter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Id", Operator = "eq", Value = "not_a_number" }] + } + }; + var rule = new TypeCompatibilityRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(), result); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.Code == ValidationErrorCodes.TypeMismatch); + } + + [Fact] + public void TypeCompatibility_AllowsCollectionOperatorsWithoutValueCheck() + { + var options = new QueryOptions + { + Filter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Children", Operator = "any", Value = null }] + } + }; + var rule = new TypeCompatibilityRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(), result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void TypeCompatibility_ReturnsTrueForNullTargetType() + { + var options = new QueryOptions + { + Filter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Name", Operator = "eq", Value = "test" }] + } + }; + var rule = new TypeCompatibilityRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(targetType: null), result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void FieldExistence_RejectsNonExistentSortField() + { + var options = new QueryOptions + { + Sort = [new SortNode { Field = "NonExistentField" }] + }; + var rule = new FieldExistenceRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(), result); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.Code == ValidationErrorCodes.FieldNotFound); + } + + [Fact] + public void FieldExistence_AllowsValidSortField() + { + var options = new QueryOptions + { + Sort = [new SortNode { Field = "Name" }] + }; + var rule = new FieldExistenceRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(), result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void FieldExistence_SkipsGroupedAggregateAliasInSort() + { + var options = new QueryOptions + { + GroupBy = ["City"], + Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = "Id", Alias = "count" }], + Sort = [new SortNode { Field = "count" }] + }; + var rule = new FieldExistenceRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(), result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void FieldExistence_RejectsScopedFilterOnNonCollection() + { + var options = new QueryOptions + { + Filter = new FilterGroup + { + Filters = + [ + new FilterCondition + { + Field = "Name", + Operator = "any", + Value = null, + ScopedFilter = new FilterGroup + { + Filters = [new FilterCondition { Field = "Id", Operator = "eq", Value = "1" }] + } + } + ] + } + }; + var rule = new FieldExistenceRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(), result); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.Code == ValidationErrorCodes.NotACollection); + } + + [Fact] + public void FieldExistence_ReturnsTrueForNullTargetType() + { + var options = new QueryOptions + { + Sort = [new SortNode { Field = "Name" }] + }; + var rule = new FieldExistenceRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(targetType: null), result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void HavingWithoutGroupBy_AllowsHavingWhenGroupByIsPresent() + { + var options = new QueryOptions + { + GroupBy = ["City"], + Having = new HavingCondition { Function = AggregateFunction.Count, Field = null, Operator = "gt", Value = "5" } + }; + var rule = new HavingWithoutGroupByRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(), result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void HavingWithoutGroupBy_AllowsHavingWhenAggregatesArePresent() + { + var options = new QueryOptions + { + Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = "Id", Alias = "cnt" }], + Having = new HavingCondition { Function = AggregateFunction.Count, Field = "Id", Operator = "gt", Value = "5" } + }; + var rule = new HavingWithoutGroupByRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(), result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void HavingWithoutGroupBy_NullHaving_Passes() + { + var options = new QueryOptions { Having = null }; + var rule = new HavingWithoutGroupByRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(), result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void GroupByIncludeConflict_NullGroupBy_Passes() + { + var options = new QueryOptions + { + GroupBy = null, + Includes = ["Children"] + }; + var rule = new GroupByIncludeConflictRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(), result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void ExpandPathValidation_NullTargetType_Passes() + { + var options = new QueryOptions + { + Includes = ["Children"] + }; + var rule = new ExpandPathValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(targetType: null), result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void ExpandPathValidation_ValidatesNestedIncludePath() + { + var options = new QueryOptions + { + Expand = + [ + new IncludeNode + { + Path = "Children", + Children = [new IncludeNode { Path = "NonExistentChild" }] + } + ] + }; + var rule = new ExpandPathValidationRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, Context(), result); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.Code == ValidationErrorCodes.IncludePathNotFound); + } + + private sealed class TestGovernanceOptions : QueryGovernanceOptions { } +} diff --git a/tests/FlexQuery.NET.Tests/Tests/ValidationTests.cs b/tests/FlexQuery.NET.Tests/Validation/ValidationTests.cs similarity index 99% rename from tests/FlexQuery.NET.Tests/Tests/ValidationTests.cs rename to tests/FlexQuery.NET.Tests/Validation/ValidationTests.cs index 8f1b98e..db834d4 100644 --- a/tests/FlexQuery.NET.Tests/Tests/ValidationTests.cs +++ b/tests/FlexQuery.NET.Tests/Validation/ValidationTests.cs @@ -7,7 +7,7 @@ using FlexQuery.NET.Models.Projection; using Microsoft.Extensions.Primitives; -namespace FlexQuery.NET.Tests.Tests; +namespace FlexQuery.NET.Tests.Validation; public class ValidationTests {