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
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
{