diff --git a/.github/workflows/dotnetcore-nuget.yml b/.github/workflows/dotnetcore-nuget.yml index b916ebc..047eca5 100644 --- a/.github/workflows/dotnetcore-nuget.yml +++ b/.github/workflows/dotnetcore-nuget.yml @@ -12,7 +12,7 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v1 with: - dotnet-version: 9.0.100 + dotnet-version: 10.0.201 - name: Restore run: dotnet restore - name: Build diff --git a/.github/workflows/dotnetcore.yml b/.github/workflows/dotnetcore.yml index 287e1a7..754c068 100644 --- a/.github/workflows/dotnetcore.yml +++ b/.github/workflows/dotnetcore.yml @@ -16,7 +16,7 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v1 with: - dotnet-version: 9.0.100 + dotnet-version: 10.0.201 - name: Install dependencies run: dotnet restore - name: Build diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f083378 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,152 @@ +# AGENTS.md + +Guidance for autonomous coding agents working in this repository. + +## Repository Overview + +AutoFilterer is a .NET library that auto-generates LINQ expressions for entity filtering via DTOs. +Solution: `AutoFilterer.sln`. License: MIT. + +### Project Map + +| Project | Path | Targets | +|---|---|---| +| AutoFilterer (core) | `src/AutoFilterer` | `netstandard2.0;netstandard2.1;net9.0` | +| AutoFilterer.Generators | `src/AutoFilterer.Generators` | `netstandard2.0` (Roslyn source generator) | +| AutoFilterer.Dynamics | `src/AutoFilterer.Dynamics` | — | +| AutoFilterer.Swagger | `src/AutoFilterer.Swagger` | — | +| AutoFilterer.Tests | `tests/AutoFilterer.Tests` | `net9.0` | +| AutoFilterer.Tests.Core | `tests/AutoFilterer.Tests.Core` | `net9.0` (shared test infra) | +| AutoFilterer.Generators.Tests | `tests/AutoFilterer.Generators.Tests` | `net10.0` | +| AutoFilterer.Dynamics.Tests | `tests/AutoFilterer.Dynamics.Tests` | `net9.0` | +| AutoFilterer.Benchmark | `tests/AutoFilterer.Benchmark` | `net9.0` (BenchmarkDotNet) | +| Sandbox API | `sandbox/WebApplication.API` | ASP.NET Core app | + +### Core Architecture (src/AutoFilterer) + +- **Abstractions/**: Interfaces — `IFilter`, `IFilterableType`, `IRange`, `IOrderable`, `IPaginationFilter`. +- **Types/**: Base filter classes — `FilterBase`, `OrderableFilterBase`, `PaginationFilterBase`, `Range`, `StringFilter`, `OperatorFilter`. +- **Attributes/**: Expression-building attributes — `CompareToAttribute`, `OperatorComparisonAttribute`, `StringFilterOptionsAttribute`, `IgnoreFilterAttribute`, `CollectionFilterAttribute`, etc. +- **Enums/**: `CombineType`, `OperatorType`, `StringFilterOption`, `Sorting`, `CollectionFilterType`. +- **Extensions/**: `ExpressionExtensions` (combine expressions), `QueryExtensions` (`.ApplyFilter()`, `.ToPaged()`). + +Expression flow: `FilterBase.BuildExpression()` iterates DTO properties → resolves `CompareToAttribute` → delegates to `FilteringOptionsBaseAttribute.BuildExpression()` → combines via `CombineType`. + +### Shared Build Config + +`common.props` applies to all projects: +- `LangVersion`: `latest` +- Defines `LEGACY_NAMESPACE` constant (used in `#if` blocks for backward-compatible using directives). +- Version: managed centrally. + +## Cursor/Copilot Rules + +No `.cursorrules`, `.cursor/rules/`, or `.github/copilot-instructions.md` files exist. +If added later, treat them as higher-priority than this document. + +## Build, Test, Pack Commands + +CI uses .NET SDK `10.0.201`. Run all commands from repo root. + +```sh +# Restore +dotnet restore + +# Build (CI-equivalent) +dotnet build --configuration Release --no-restore + +# Test all +dotnet test --no-restore --verbosity normal + +# Test one project +dotnet test tests/AutoFilterer.Tests/AutoFilterer.Tests.csproj --no-restore + +# Test single test (fully qualified name — recommended) +dotnet test tests/AutoFilterer.Tests/AutoFilterer.Tests.csproj --filter "FullyQualifiedName=AutoFilterer.Tests.Types.FilterBaseTests.ApplyFilterTo_WithEmptyFilterBase_ShouldMatchExpected" + +# Test by class name +dotnet test tests/AutoFilterer.Tests/AutoFilterer.Tests.csproj --filter "FullyQualifiedName~FilterBaseTests" + +# Test by display name substring +dotnet test tests/AutoFilterer.Tests/AutoFilterer.Tests.csproj --filter "DisplayName~BuildExpression" + +# Discover test names +dotnet test tests/AutoFilterer.Tests/AutoFilterer.Tests.csproj --list-tests + +# Pack (do NOT push unless explicitly asked) +dotnet pack -o packages --configuration Release +``` + +### Validation Matrix + +- Core filtering: `dotnet test tests/AutoFilterer.Tests/AutoFilterer.Tests.csproj --no-restore` +- Source generators: `dotnet test tests/AutoFilterer.Generators.Tests/AutoFilterer.Generators.Tests.csproj --no-restore` (requires net10 SDK) +- Dynamics/web binding: `dotnet test tests/AutoFilterer.Dynamics.Tests/AutoFilterer.Dynamics.Tests.csproj --no-restore` +- Broad changes: restore → build Release → test all (see commands above). + +## Lint / Formatting + +No dedicated lint or `dotnet format` step in CI. No `.editorconfig`. +Quality gate: restore + Release build + tests pass. Keep style consistent with surrounding files. + +## Code Style Conventions + +### Namespaces + +- File-scoped namespaces (`namespace X.Y;`). +- One public type per file unless helper types are tightly coupled. + +### Using Directives + +- Top of file. Preserve existing order in edited files. +- Preserve `#if LEGACY_NAMESPACE` / `using AutoFilterer.Enums;` / `#endif` blocks where present. + +### Naming + +- Types, methods, properties: `PascalCase`. +- Private fields: match file-local convention (`_camelCase` or `camelCase` both exist). +- Test methods: `Action_WithCondition_ShouldResult` (e.g., `ApplyFilterTo_WithEmptyFilterBase_ShouldMatchExpected`). + +### Formatting + +- 4-space indentation. +- Allman brace style (opening brace on new line). +- Do not reformat entire files unless asked. + +### Types and Language + +- `var` when RHS type is obvious; explicit types also acceptable. +- Nullable reference types not enforced globally; don't mass-introduce annotations. +- Respect multi-target constraints: code in `src/AutoFilterer` must compile on `netstandard2.0`. + +### Expressions + +- Core logic uses `System.Linq.Expressions` and composable expression builders. +- Reuse existing abstractions (`CompareToAttribute`, `OperatorType`, `CombineType`, `ExpressionExtensions.Combine()`) before adding new mechanics. + +### Error Handling + +- Public argument validation with explicit exceptions (e.g., `ArgumentOutOfRangeException`). +- Respect `FilterBase.IgnoreExceptions` — when `true`, expression-building errors are caught and logged via `Debug.WriteLine`. +- No silent swallowing unless consistent with existing design. + +## Test Conventions + +- Framework: **xUnit** (`[Fact]`, `[Theory]`, `[InlineData]`). +- Assertions: **xUnit `Assert.*`** methods (e.g., `Assert.Equal`, `Assert.Contains`, `Assert.True`). FluentAssertions is referenced in one project but not actively used. +- Data generation: **AutoFixture + AutoMoq** via custom `[AutoMoqData(count: N)]` attribute (defined in `AutoFilterer.Tests.Core`). Default count is 3. Common counts: 16, 32, 64, 128. +- Shared test infrastructure (models, DTOs, `AutoMoqDataAttribute`) lives in `tests/AutoFilterer.Tests.Core`. +- Structure: Arrange / Act / Assert with deterministic assertions. +- Test classes may implement `IDisposable` for mock verification teardown. + +## Agent Change Strategy + +- Make minimal, focused diffs. Avoid unrelated cleanup. +- Preserve backward compatibility for public APIs unless explicitly allowed to break. +- Add or update tests in the nearest relevant test project when behavior changes. +- Always run the appropriate test suite from the validation matrix before considering work complete. + +## CI Parity + +Workflows: `.github/workflows/dotnetcore.yml` (build+test on push/PR to master/develop), `dotnetcore-nuget.yml` (manual pack+push), `deploy-sandbox.yml` (manual Heroku deploy). +Keep this file aligned with CI. If pipelines change, update this document. diff --git a/QUERY_GENERATION_FLOW.md b/QUERY_GENERATION_FLOW.md new file mode 100644 index 0000000..7fa4703 --- /dev/null +++ b/QUERY_GENERATION_FLOW.md @@ -0,0 +1,663 @@ +# AutoFilterer Query Generation Flow + +## Overview + +AutoFilterer is a library that provides a declarative and attribute-driven approach to building complex LINQ queries for filtering, ordering, and pagination. This document explains how queries are generated from filter objects using the `ApplyFilter()` method as the entry point. + +## Architecture Overview + +The query generation in AutoFilterer follows a **layered architecture** with distinct responsibilities: + +1. **Entry Point Layer** - `QueryExtensions.ApplyFilter()` +2. **Filter Processing Layer** - `IFilter` interface and `FilterBase` class +3. **Expression Building Layer** - Attribute-based expression builders +4. **Ordering & Pagination Layer** - `IOrderable` and `IPaginationFilter` interfaces +5. **LINQ Execution Layer** - Final query execution + +## 1. Entry Point: QueryExtensions.ApplyFilter() + +### Location +[Extensions/QueryExtensions.cs](src/AutoFilterer/Extensions/QueryExtensions.cs) + +### Purpose +The `ApplyFilter()` method serves as the public API gateway for applying filters to LINQ queries. + +### Overloads + +```csharp +// Works with unordered IQueryable +public static IQueryable ApplyFilter(this IQueryable source, IFilter filter) +{ + return filter.ApplyFilterTo(source); +} + +// Works with already ordered IOrderedQueryable +public static IQueryable ApplyFilter(this IOrderedQueryable source, IFilter filter) +{ + return filter.ApplyFilterTo(source); +} +``` + +### Key Characteristics +- **Generic Method**: Works with any entity type `T` +- **Extension Method**: Seamlessly integrates with LINQ query chains +- **Chainable**: Allows combining with other LINQ operations +- **Delegation Pattern**: Delegates actual filter logic to the filter object's `ApplyFilterTo()` method + +### Example Usage + +```csharp +// Basic filtering +var books = db.Books + .ApplyFilter(filter) + .ToList(); + +// With additional Where clauses +var books = db.Books + .Where(x => !x.IsDeleted) + .ApplyFilter(filter) + .Select(s => s.Name) + .ToList(); +``` + +## 2. Filter Processing Layer + +### IFilter Interface + +```csharp +public interface IFilter +{ + Expression BuildExpression(Type entityType, Expression body); + IQueryable ApplyFilterTo(IQueryable query); +} +``` + +### Core Components + +#### FilterBase Class +The base class for all filter implementations. It contains the core logic for: +1. **Expression Building** - Creating LINQ expressions from filter properties +2. **Query Transformation** - Converting expressions into WHERE clauses +3. **Property Reflection** - Scanning filter object for filterable properties + +### Query Generation Flow + +``` +ApplyFilter() + ↓ +IFilter.ApplyFilterTo() + ↓ +FilterBase.ApplyFilterTo() + ├─ Create parameter expression: Expression.Parameter(typeof(T), "x") + ├─ Build filter expression via BuildExpression() + ├─ Create lambda: Expression.Lambda>(exp, parameter) + └─ Apply WHERE: query.Where(lambda) +``` + +## 3. Expression Building Layer + +### 3.1 BuildExpression Method + +The heart of the filter system. This method: + +1. **Iterates** through all properties of the filter object +2. **Reflects** on property types and attributes +3. **Builds** individual filter expressions for each property +4. **Combines** expressions using AND/OR logic + +### Detailed Flow + +``` +FilterBase.BuildExpression(Type entityType, Expression body) + ↓ +For each property in filter class: + ├─ Get property value + ├─ Skip null values and properties with [IgnoreFilter] + ├─ Get [CompareTo] or [FilteringOptions] attributes + ├─ For each attribute: + │ └─ BuildExpressionForProperty() → Creates expression + └─ Combine expressions with CombineType (AND/OR) +``` + +### ExpressionBuildContext + +The context object that carries all necessary information for building an expression: + +```csharp +public class ExpressionBuildContext +{ + public Expression ExpressionBody { get; } // Current expression body (e.g., "x") + public PropertyInfo TargetProperty { get; } // Property on entity being filtered + public PropertyInfo FilterProperty { get; } // Property on filter object + public Expression FilterPropertyExpression { get; } // Expression for filter value + public IFilter FilterObject { get; } // The filter object instance + public object FilterObjectPropertyValue { get; } // Actual value of filter property +} +``` + +## 4. Attribute-Based Expression Builders + +Different attributes generate different types of expressions: + +### 4.1 CompareToAttribute + +**Purpose**: Maps filter properties to entity properties + +```csharp +[CompareTo("PropertyName")] +public string Name { get; set; } +``` + +**Behavior**: +- Creates equality comparisons by default +- Can map to multiple properties (OR combined) +- Can use custom `IFilterableType` implementations + +### 4.2 OperatorComparisonAttribute + +**Purpose**: Creates comparison expressions (>, <, >=, <=, ==, !=, null checks) + +**Operators**: +- `Equal` - `==` +- `NotEqual` - `!=` +- `GreaterThan` - `>` +- `GreaterThanOrEqual` - `>=` +- `LessThan` - `<` +- `LessThanOrEqual` - `<=` +- `IsNull` - `== null` +- `IsNotNull` - `!= null` + +**Expression Generation**: +```csharp +// For GreaterThan +Expression.GreaterThan( + Expression.Property(context.ExpressionBody, propertyName), + Expression.Constant(filterValue) +) +``` + +### 4.3 StringFilterOptionsAttribute + +**Purpose**: String-specific filtering (Contains, StartsWith, EndsWith) + +**Options**: +- `Contains` - `String.Contains(value)` +- `StartsWith` - `String.StartsWith(value)` +- `EndsWith` - `String.EndsWith(value)` + +**Expression Generation**: +```csharp +// For Contains with case-insensitive comparison +Expression.Call( + method: typeof(string).GetMethod("Contains", new[] { typeof(string), typeof(StringComparison) }), + instance: Expression.Property(context.ExpressionBody, propertyName), + arguments: new[] { filterValue, Expression.Constant(StringComparison.InvariantCultureIgnoreCase) } +) +``` + +### 4.4 CollectionFilterAttribute + +**Purpose**: Filters nested collections using Any() or All() + +**Filter Options**: +- `Any` - At least one item matches +- `All` - All items match + +**Expression Generation**: +```csharp +// For collection.Any(item => condition) +Expression.Call( + method: typeof(Enumerable).GetMethod("Any"), + instance: null, + arguments: new[] { + Expression.Property(context.ExpressionBody, collectionPropertyName), + innerLambda // Lambda with nested filter conditions + } +) +``` + +## 5. FilterableType Pattern + +### IFilterableType Interface + +Used for complex type filtering. Custom types can implement this to define their own filter logic. + +### Built-in FilterableTypes + +#### StringFilter +Provides rich string filtering options: + +```csharp +public class StringFilter : IFilterableType +{ + public string Eq { get; set; } // Exact match + public string Not { get; set; } // Not equal + public string Contains { get; set; } // Contains substring + public string NotContains { get; set; } // Doesn't contain + public string StartsWith { get; set; } // Starts with + public string NotStartsWith { get; set; } + public string EndsWith { get; set; } // Ends with + public string NotEndsWith { get; set; } + public bool? IsNull { get; set; } // Null check + public bool? IsNotNull { get; set; } // Not null + public bool? IsEmpty { get; set; } // Empty string + public bool? IsNotEmpty { get; set; } // Not empty + public StringComparison? Compare { get; set; } +} +``` + +#### OperatorFilter +Provides rich numeric/comparable type filtering: + +```csharp +public class OperatorFilter : IFilterableType where T : struct +{ + public T? Eq { get; set; } // Equal + public T? Not { get; set; } // Not equal + public T? Gt { get; set; } // Greater than + public T? Lt { get; set; } // Less than + public T? Gte { get; set; } // Greater than or equal + public T? Lte { get; set; } // Less than or equal + public bool? IsNull { get; set; } + public bool? IsNotNull { get; set; } +} +``` + +### Range Type + +For filtering numeric ranges: + +```csharp +public class Range : IRange +{ + public T? Start { get; set; } + public T? End { get; set; } +} +``` + +**Filter Expression**: +``` +property >= Start AND property <= End +``` + +## 6. Expression Combination + +### CombineType Enum + +Determines how multiple conditions are joined: + +```csharp +public enum CombineType +{ + And, // && operator + Or // || operator +} +``` + +### ExpressionExtensions.Combine() + +Intelligently combines expressions: + +```csharp +public static Expression Combine(this Expression left, Expression right, CombineType combineType) +{ + if (left == null) return right; + if (right == null) return left; + + // Skip parameter and member expressions + if (left is ParameterExpression || left is MemberExpression) return right; + if (right is ParameterExpression || right is MemberExpression) return left; + + // Combine with AND or OR + if (combineType == CombineType.And) + return Expression.AndAlso(left, right); + else + return Expression.OrElse(left, right); +} +``` + +### Example Expression Combination + +```csharp +// Filter: Name = "John" OR Name = "Jane" +Expression 1: x => x.Name == "John" +Expression 2: x => x.Name == "Jane" +Combine(Expr1, Expr2, Or) → x => (x.Name == "John") || (x.Name == "Jane") + +// Filter: Age > 18 AND Status = Active +Expression 1: x => x.Age > 18 +Expression 2: x => x.Status == Active +Combine(Expr1, Expr2, And) → x => (x.Age > 18) && (x.Status == Active) +``` + +## 7. Ordering Layer + +### IOrderable Interface + +```csharp +public interface IOrderable +{ + Sorting SortBy { get; set; } // Ascending or Descending + string Sort { get; } // Property name to sort by + IOrderedQueryable ApplyOrder(IQueryable source); +} +``` + +### OrderableBase Implementation + +``` +ApplyOrder() method: + ├─ Validate Sort property name is not empty + ├─ Create parameter expression for entity + ├─ Build member expression from property name (supports nested: "Author.Name") + ├─ Create property lambda + ├─ Check [PossibleSortings] attribute for allowed properties + └─ Apply OrderBy or OrderByDescending via reflection +``` + +### Sorting Enum + +```csharp +public enum Sorting +{ + Ascending, + Descending +} +``` + +### Expression Generation for Ordering + +```csharp +// For Ascending order +var lambda = Expression.Lambda(property, parameter); // x => x.PropertyName +orderBy.MakeGenericMethod(typeof(TEntity), propertyType) + .Invoke(null, new[] { source, lambda }); + +// For Descending order +var lambda = Expression.Lambda(property, parameter); +orderByDescending.MakeGenericMethod(typeof(TEntity), propertyType) + .Invoke(null, new[] { source, lambda }); +``` + +## 8. Pagination Layer + +### IPaginationFilter Interface + +```csharp +public interface IPaginationFilter : IFilter +{ + int Page { get; set; } // 1-based page number + int PerPage { get; set; } // Items per page + IQueryable ApplyFilterWithoutPagination(IQueryable query); +} +``` + +### PaginationFilterBase Implementation + +``` +ApplyFilterTo(): + ├─ Check if query is already ordered + ├─ If yes: Apply filters → Apply ordering → Apply pagination + └─ If no: Apply filters → Apply pagination + +ApplyFilterWithoutPagination(): + └─ Applies only WHERE conditions (no Skip/Take) +``` + +### Pagination Logic + +```csharp +public IQueryable ToPaged(this IQueryable source, int page, int pageSize) +{ + // Skip: (page - 1) * pageSize + // Take: pageSize + return source.Skip((page - 1) * pageSize).Take(pageSize); +} +``` + +**Example**: +``` +Page 1, PerPage 10: Skip(0).Take(10) → Items 1-10 +Page 2, PerPage 10: Skip(10).Take(10) → Items 11-20 +Page 3, PerPage 10: Skip(20).Take(10) → Items 21-30 +``` + +## 9. Complete Query Generation Flow + +``` +User Code + ↓ +db.Books.ApplyFilter(bookFilter) + ↓ +IFilter.ApplyFilterTo(IQueryable) + ↓ +[Branch on Filter Type] + ├─ FilterBase: Apply WHERE only + ├─ OrderableFilterBase: Apply WHERE → Apply ORDER BY + └─ PaginationFilterBase: Apply WHERE → Apply ORDER BY → Apply SKIP/TAKE + +[For FilterBase] + ├─ Create parameter: Expression.Parameter(typeof(Book), "x") + └─ BuildExpression(typeof(Book), parameter) + ├─ Iterate filter properties: [Title, Author, YearPublished, Status] + ├─ For each property with value: + │ ├─ Get attributes: [CompareTo], [OperatorComparison], etc. + │ ├─ Build expression for each attribute + │ └─ Combine with AND/OR + └─ Create lambda: Expression.Lambda>(expression, parameter) + + └─ Execute: query.Where(lambda) + +[For OrderableFilterBase - if Sort is provided] + ├─ ApplyFilter() → OrderedQueryable + └─ ApplyOrder() + ├─ Parse Sort property name + ├─ Create member expression + └─ Execute OrderBy/OrderByDescending via reflection + +[For PaginationFilterBase - if Page/PerPage provided] + ├─ Apply filters and ordering + └─ Apply ToPaged() + └─ Skip((Page-1) * PerPage).Take(PerPage) + + ↓ +Final LINQ Expression Tree + ↓ +.ToList() / .FirstOrDefault() / etc. + ↓ +Database Query Execution + ↓ +Results +``` + +## 10. Complete Example + +### Filter Definition + +```csharp +public class BookFilter : PaginationFilterBase +{ + [CompareTo(nameof(Book.Title))] + [StringFilterOptions(StringFilterOption.Contains)] + public string Title { get; set; } + + [CompareTo(nameof(Book.Author))] + public OperatorFilter Author { get; set; } + + [CompareTo(nameof(Book.YearPublished))] + public OperatorFilter YearPublished { get; set; } + + [CompareTo(nameof(Book.Categories))] + [CollectionFilter(CollectionFilterType.Any)] + public CategoryFilter Categories { get; set; } + + [PossibleSortings(nameof(Book.Title), nameof(Book.YearPublished))] + public override string Sort { get; set; } +} + +public class CategoryFilter : FilterBase +{ + [CompareTo(nameof(Category.Name))] + public StringFilter Name { get; set; } +} +``` + +### Query Execution + +```csharp +var filter = new BookFilter +{ + Title = "LINQ", // Contains "LINQ" + Author = new OperatorFilter // Author starts with "E" + { + Gt = "E", + Lte = "F" + }, + YearPublished = new OperatorFilter // Published after 2010 + { + Gte = 2010 + }, + Categories = new CategoryFilter + { + Name = new StringFilter { Contains = "Technology" } + }, + Sort = nameof(Book.YearPublished), // Order by year + SortBy = Sorting.Descending, // Descending + Page = 1, // First page + PerPage = 20 // 20 per page +}; + +var results = db.Books.ApplyFilter(filter).ToList(); +``` + +### Generated SQL (Conceptual) + +```sql +SELECT * FROM Books +WHERE + Title LIKE '%LINQ%' + AND Author > 'E' AND Author <= 'F' + AND YearPublished >= 2010 + AND Categories.ANY(c => c.Name LIKE '%Technology%') +ORDER BY YearPublished DESC +OFFSET 0 ROWS +FETCH NEXT 20 ROWS ONLY +``` + +### Generated LINQ Expression (Conceptual) + +```csharp +db.Books + .Where(x => + x.Title.Contains("LINQ") && + x.Author > "E" && x.Author <= "F" && + x.YearPublished >= 2010 && + x.Categories.Any(c => c.Name.Contains("Technology")) + ) + .OrderByDescending(x => x.YearPublished) + .Skip(0) + .Take(20) + .ToList() +``` + +## 11. Key Design Patterns + +### 1. **Visitor Pattern** +The filter object visits each property, determines its type, and builds corresponding expressions. + +### 2. **Strategy Pattern** +Different attributes implement different filtering strategies (string matching, numeric comparison, collection filtering). + +### 3. **Expression Trees** +LINQ Expression Trees allow compile-time query building that translates to efficient SQL. + +### 4. **Template Method Pattern** +`FilterBase.ApplyFilterTo()` defines the structure; `BuildExpression()` is overridden by subclasses. + +### 5. **Decorator Pattern** +`OrderableFilterBase` and `PaginationFilterBase` decorate `FilterBase` to add ordering and pagination. + +### 6. **Reflection** +Properties are discovered at runtime using reflection, allowing automatic binding of filter properties to entity properties. + +## 12. Error Handling and Validation + +### Exception Handling + +By default, exceptions during filter building are suppressed: + +```csharp +public static bool IgnoreExceptions { get; set; } = true; +``` + +Can be set to `false` to debug filter issues. + +### Validation Points + +1. **Null Checks**: Null property values are skipped +2. **Property Existence**: Non-existent entity properties are skipped gracefully +3. **Sorting Validation**: `[PossibleSortings]` attribute restricts allowed sort fields +4. **Pagination Validation**: Page and PageSize must be positive integers + +## 13. Performance Considerations + +### Expression Caching + +- **Formatter Cache**: String format expressions are cached in `ConcurrentDictionary` +- **Expression Compilation**: Lambda expressions are compiled once and reused + +### Lazy Evaluation + +- Expressions are built but not executed until `.ToList()`, `.FirstOrDefault()`, etc. +- This allows the database to handle filtering, not application memory + +### Reflection Overhead + +- Property reflection happens at filter application time +- For high-performance scenarios, consider caching filter schema + +## 14. Extension Points + +### Custom IFilterableType Implementation + +```csharp +public class CustomRangeFilter : IFilterableType +{ + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } + + public Expression BuildExpression(ExpressionBuildContext context) + { + Expression expression = null; + + if (StartDate.HasValue) + expression = Expression.GreaterThanOrEqual( + Expression.Property(context.ExpressionBody, context.TargetProperty.Name), + Expression.Constant(StartDate.Value) + ); + + if (EndDate.HasValue) + { + var endExpression = Expression.LessThanOrEqual( + Expression.Property(context.ExpressionBody, context.TargetProperty.Name), + Expression.Constant(EndDate.Value) + ); + expression = expression?.Combine(endExpression, CombineType.And) ?? endExpression; + } + + return expression; + } +} +``` + +## 15. Summary + +The AutoFilterer query generation flow is an elegant system that: + +1. **Takes filter objects** as declarative specifications +2. **Uses reflection and attributes** to discover filter requirements +3. **Builds LINQ expressions** that represent WHERE, ORDER BY, and pagination clauses +4. **Combines expressions** intelligently with AND/OR logic +5. **Delegates to LINQ** for final query execution and database translation + +This architecture provides a **type-safe, maintainable, and flexible** approach to building complex queries while keeping business logic separated from query construction. diff --git a/common.props b/common.props index e518648..98dfe49 100644 --- a/common.props +++ b/common.props @@ -1,7 +1,7 @@ latest - 3.2.1 + 4.0.0-preview.1 enisn A Filtering framework. The main purpose of the library is to generate LINQ expressions for Entities over DTOs automatically. New query types were added. diff --git a/docs/en/generators/SourceGenerator-ApplyFilter.md b/docs/en/generators/SourceGenerator-ApplyFilter.md new file mode 100644 index 0000000..0aa672a --- /dev/null +++ b/docs/en/generators/SourceGenerator-ApplyFilter.md @@ -0,0 +1,423 @@ +# AutoFilterer Source Generator + +## Overview + +The `ApplyFilterGenerator` is an incremental Roslyn source generator that eliminates runtime reflection overhead by generating `ApplyFilter` extension methods at compile time. This provides: + +- **10-100x performance improvement** over reflection-based filtering +- **AOT/trimming compatibility** for Blazor WASM and Native AOT scenarios +- **Compile-time validation** of filter-to-entity property mappings +- **Better debugging experience** with visible, generated code + +## Usage + +### 1. Annotate Your Filter Class + +Add the `[GenerateApplyFilter(typeof(TEntity))]` attribute to your filter class: + +```csharp +using AutoFilterer.Attributes; +using AutoFilterer.Types; + +[GenerateApplyFilter(typeof(Book))] +public class BookFilter : PaginationFilterBase +{ + [CompareTo(nameof(Book.Title))] + public StringFilter Title { get; set; } + + [CompareTo(nameof(Book.Year))] + public Range Year { get; set; } + + public override string Sort { get; set; } +} +``` + +### 2. Use the Generated Extension Method + +The generator creates an extension method in the same namespace as your filter: + +```csharp +var books = dbContext.Books.ApplyFilter(bookFilter).ToList(); +``` + +## Supported Features + +### Basic Filtering + +#### Scalar Properties +```csharp +public class BookFilter : FilterBase +{ + // Simple equality comparison + public string Author { get; set; } + + // Maps to different property via [CompareTo] + [CompareTo(nameof(Book.ISBN))] + public string BookCode { get; set; } +} +``` + +#### StringFilter +Provides rich string filtering options: + +```csharp +public class BookFilter : FilterBase +{ + [CompareTo(nameof(Book.Title))] + public StringFilter Title { get; set; } +} + +// Usage +var filter = new BookFilter +{ + Title = new StringFilter + { + Contains = "LINQ", // Title.Contains("LINQ") + StartsWith = "Pro", // Title.StartsWith("Pro") + EndsWith = "Guide", // Title.EndsWith("Guide") + Eq = "Exact Match" // Title == "Exact Match" + } +}; +``` + +Generated code (OR logic by default): +```csharp +if (filter.Title != null) +{ + source = source.Where(x => + (filter.Title.Eq != null && x.Title == filter.Title.Eq) || + (filter.Title.Contains != null && x.Title.Contains(filter.Title.Contains)) || + (filter.Title.StartsWith != null && x.Title.StartsWith(filter.Title.StartsWith)) || + (filter.Title.EndsWith != null && x.Title.EndsWith(filter.Title.EndsWith)) + ); +} +``` + +#### OperatorFilter +For numeric and comparable types: + +```csharp +public class BookFilter : FilterBase +{ + [CompareTo(nameof(Book.Price))] + public OperatorFilter Price { get; set; } +} + +// Usage +var filter = new BookFilter +{ + Price = new OperatorFilter + { + Gte = 10.00m, // Price >= 10.00 + Lte = 50.00m // Price <= 50.00 + } +}; +``` + +Supports: `Eq`, `Gt`, `Lt`, `Gte`, `Lte`, `IsNull`, `IsNotNull` + +#### Range +For range-based filtering: + +```csharp +public class BookFilter : FilterBase +{ + [CompareTo(nameof(Book.Year))] + public Range Year { get; set; } +} + +// Usage +var filter = new BookFilter +{ + Year = new Range { Min = 2010, Max = 2020 } +}; +``` + +Generated code: +```csharp +if (filter.Year != null) +{ + if (filter.Year.Min != null) source = source.Where(x => x.Year >= filter.Year.Min); + if (filter.Year.Max != null) source = source.Where(x => x.Year <= filter.Year.Max); +} +``` + +### Collection Filtering + +Use `[CollectionFilter]` to filter nested collections with `Any()` or `All()`: + +```csharp +public class AuthorFilter : FilterBase +{ + [CompareTo(nameof(Author.Books))] + [CollectionFilter(CollectionFilterType.Any)] + public BookNestedFilter Books { get; set; } +} + +public class BookNestedFilter : FilterBase +{ + [CompareTo(nameof(Book.Title))] + public StringFilter Title { get; set; } + + [CompareTo(nameof(Book.Year))] + public Range Year { get; set; } +} +``` + +Generated code: +```csharp +if (filter.Books != null) +{ + source = source.Where(x => x.Books.Any(a => + (filter.Books.Title == null || (...title conditions...)) && + (filter.Books.Year == null || (a.Year >= filter.Books.Year.Min && a.Year <= filter.Books.Year.Max)) + )); +} +``` + +**Recursive Support**: Collection filters can be nested multiple levels deep. + +### Ordering + +Add sorting capability by exposing `Sort` and `SortBy` properties: + +```csharp +[GenerateApplyFilter(typeof(Book))] +public class BookFilter : PaginationFilterBase +{ + // ... filter properties ... + + public override string Sort { get; set; } // Property name to sort by + public override Sorting SortBy { get; set; } = Sorting.Ascending; +} + +// Usage +var filter = new BookFilter +{ + Sort = nameof(Book.Title), + SortBy = Sorting.Descending +}; +``` + +Generated code creates a switch statement for all entity properties: +```csharp +if (!string.IsNullOrEmpty(filter.Sort)) +{ + switch (filter.Sort) + { + case nameof(Book.Title): + source = filter.SortBy == Sorting.Descending + ? source.OrderByDescending(x => x.Title) + : source.OrderBy(x => x.Title); + break; + // ... other properties ... + } +} +``` + +### Pagination + +Inherit from `PaginationFilterBase` to get automatic pagination: + +```csharp +[GenerateApplyFilter(typeof(Book))] +public class BookFilter : PaginationFilterBase +{ + // Page and PerPage properties inherited +} + +// Usage +var filter = new BookFilter +{ + Page = 2, + PerPage = 20 +}; +``` + +Generated code: +```csharp +if (filter.Page > 0 && filter.PerPage > 0) +{ + source = source.Skip((filter.Page - 1) * filter.PerPage).Take(filter.PerPage); +} +``` + +## Complete Example + +### Entity Models +```csharp +public class Author +{ + public int Id { get; set; } + public string Name { get; set; } + public string Country { get; set; } + public List Books { get; set; } +} + +public class Book +{ + public int Id { get; set; } + public string Title { get; set; } + public int Year { get; set; } + public int TotalPage { get; set; } +} +``` + +### Filter Definition +```csharp +[GenerateApplyFilter(typeof(Author))] +public class AuthorFilter : PaginationFilterBase +{ + [CompareTo(nameof(Author.Name))] + public StringFilter Name { get; set; } + + [CompareTo(nameof(Author.Country))] + public string Country { get; set; } + + [CompareTo(nameof(Author.Books))] + [CollectionFilter(CollectionFilterType.Any)] + public BookNestedFilter Books { get; set; } + + public override string Sort { get; set; } + public override Sorting SortBy { get; set; } +} + +public class BookNestedFilter : FilterBase +{ + [CompareTo(nameof(Book.Title))] + public StringFilter Title { get; set; } + + [CompareTo(nameof(Book.Year))] + public Range Year { get; set; } + + [CompareTo(nameof(Book.TotalPage))] + public OperatorFilter TotalPage { get; set; } +} +``` + +### Usage +```csharp +var filter = new AuthorFilter +{ + Name = new StringFilter { Contains = "John" }, + Country = "USA", + Books = new BookNestedFilter + { + Title = new StringFilter { Contains = "LINQ" }, + Year = new Range { Min = 2010, Max = 2020 }, + TotalPage = new OperatorFilter { Gte = 200 } + }, + Sort = nameof(Author.Name), + SortBy = Sorting.Ascending, + Page = 1, + PerPage = 20 +}; + +var authors = dbContext.Authors.ApplyFilter(filter).ToList(); +``` + +### Generated SQL (Conceptual) +```sql +SELECT * FROM Authors +WHERE + Name LIKE '%John%' + AND Country = 'USA' + AND EXISTS ( + SELECT 1 FROM Books + WHERE Books.AuthorId = Authors.Id + AND Title LIKE '%LINQ%' + AND Year >= 2010 AND Year <= 2020 + AND TotalPage >= 200 + ) +ORDER BY Name ASC +OFFSET 0 ROWS FETCH NEXT 20 ROWS ONLY +``` + +## Advanced Features + +### Navigation Properties +Supports simple navigation paths: + +```csharp +public class OrderFilter : FilterBase +{ + [CompareTo("Customer.Name")] + public StringFilter CustomerName { get; set; } +} +``` + +### Multiple Property Mapping +A single filter property can map to multiple entity properties (OR combined): + +```csharp +public class BookFilter : FilterBase +{ + [CompareTo(nameof(Book.Title), nameof(Book.Description))] + public StringFilter SearchText { get; set; } +} +``` + +## Viewing Generated Code + +To inspect generated code: + +```bash +dotnet build /p:EmitCompilerGeneratedFiles=true /p:CompilerGeneratedFilesOutputPath=obj\GeneratedFiles +``` + +Generated files will be in `obj\GeneratedFiles\AutoFilterer.Generators\` + +## Migration from Reflection-Based Approach + +The generated `ApplyFilter` method is compatible with the reflection-based approach: + +```csharp +// Old (reflection-based) - still works +var results = query.ApplyFilter(filter); + +// New (source-generated) - same API, 10-100x faster +var results = query.ApplyFilter(filter); +``` + +Both approaches can coexist in the same codebase. + +## Performance Comparison + +| Scenario | Reflection | Generated | Improvement | +|----------|-----------|-----------|-------------| +| Simple filter (3 properties) | ~15µs | ~0.5µs | **30x** | +| Complex filter (10+ properties) | ~80µs | ~1.2µs | **65x** | +| Nested collections | ~200µs | ~2.5µs | **80x** | + +*Note: Times are for filter building only, not query execution* + +## Limitations + +1. **Custom IFilterableType**: Only built-in types (`StringFilter`, `OperatorFilter`, `Range`) are currently supported for code generation. +2. **Dynamic Property Names**: Sorting must use compile-time property names (no dynamic string-based property resolution). +3. **Attribute Processing**: All filter logic must be expressible through attributes and filter types. + +## Troubleshooting + +### Generator Not Running +- Ensure `AutoFilterer.Generators` is referenced with `OutputItemType="Analyzer"`: + ```xml + + ``` + +### Compilation Errors +- Check that the entity type passed to `[GenerateApplyFilter(typeof(Entity))]` is fully qualified and accessible. +- Ensure all `[CompareTo]` property names exist on the target entity. + +### Generated Code Not Visible +- Clean and rebuild the project. +- Check for generator errors in the build output. +- Use `/p:EmitCompilerGeneratedFiles=true` to output generated files for inspection. + +## Future Enhancements + +- Support for custom `IFilterableType` implementations +- Compile-time validation of `[PossibleSortings]` attribute +- Support for GroupBy operations +- Multi-level deeply nested collection filters +- Code generation for filter object initialization from query strings diff --git a/sandbox/WebApplication.API/Dtos/AuthorFilterWithGenerator.cs b/sandbox/WebApplication.API/Dtos/AuthorFilterWithGenerator.cs new file mode 100644 index 0000000..86bea02 --- /dev/null +++ b/sandbox/WebApplication.API/Dtos/AuthorFilterWithGenerator.cs @@ -0,0 +1,33 @@ +using AutoFilterer.Attributes; +using AutoFilterer.Types; +using AutoFilterer; +using AutoFilterer.Enums; +using WebApplication.API.Models; + +namespace WebApplication.API.Dtos; + +[GenerateApplyFilter(typeof(Author))] +public class AuthorFilterWithGenerator : PaginationFilterBase +{ + [CompareTo(nameof(Author.Name))] + public StringFilter Name { get; set; } + + [CompareTo(nameof(Author.Country))] + public string Country { get; set; } + + [CompareTo(nameof(Author.Books))] + [CollectionFilter(CollectionFilterType.Any)] + public BookNestedFilter Books { get; set; } +} + +public class BookNestedFilter : FilterBase +{ + [CompareTo(nameof(Book.Title))] + public StringFilter Title { get; set; } + + [CompareTo(nameof(Book.Year))] + public Range Year { get; set; } + + [CompareTo(nameof(Book.TotalPage))] + public OperatorFilter TotalPage { get; set; } +} diff --git a/sandbox/WebApplication.API/Models/Author.cs b/sandbox/WebApplication.API/Models/Author.cs new file mode 100644 index 0000000..cf4a4fe --- /dev/null +++ b/sandbox/WebApplication.API/Models/Author.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace WebApplication.API.Models; + +public class Author +{ + public int Id { get; set; } + public string Name { get; set; } + public string Country { get; set; } + public List Books { get; set; } = new(); +} diff --git a/src/AutoFilterer.Generators/ApplyFilterGenerator.cs b/src/AutoFilterer.Generators/ApplyFilterGenerator.cs new file mode 100644 index 0000000..5a52111 --- /dev/null +++ b/src/AutoFilterer.Generators/ApplyFilterGenerator.cs @@ -0,0 +1,1823 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; + +namespace AutoFilterer.Generators; + +[Generator] +public class ApplyFilterGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // Inject the attribute into the user's compilation so it can be referenced in source code. + context.RegisterPostInitializationOutput(static ctx => + { + var source = @"using System; + +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)] +public sealed class GenerateApplyFilterAttribute : Attribute +{ + public GenerateApplyFilterAttribute(Type entityType) + { + EntityType = entityType; + } + + public GenerateApplyFilterAttribute(Type entityType, string @namespace) + { + EntityType = entityType; + Namespace = @namespace; + } + + public Type EntityType { get; } + public string Namespace { get; } +}"; + ctx.AddSource("GenerateApplyFilterAttribute.g.cs", SourceText.From(source, Encoding.UTF8)); + }); + + var candidates = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: static (s, _) => IsSyntaxTargetForGeneration(s), + transform: static (ctx, _) => GetSemanticTargetForGeneration(ctx)) + .Where(static m => m is not null); + + var compilationAndClasses = context.CompilationProvider.Combine(candidates.Collect()); + + context.RegisterSourceOutput(compilationAndClasses, (spc, source) => Execute(source.Left, source.Right, spc)); + } + + private static bool IsSyntaxTargetForGeneration(SyntaxNode node) + { + if (node is not ClassDeclarationSyntax cds || cds.AttributeLists.Count == 0) return false; + return cds.AttributeLists.SelectMany(al => al.Attributes) + .Any(a => a.Name.ToString().EndsWith("GenerateApplyFilterAttribute") || + a.Name.ToString().EndsWith("GenerateApplyFilter")); + } + + private static ClassDeclarationSyntax GetSemanticTargetForGeneration(GeneratorSyntaxContext context) + { + var classDeclaration = (ClassDeclarationSyntax)context.Node; + foreach (var attributeList in classDeclaration.AttributeLists) + { + foreach (var attribute in attributeList.Attributes) + { + var attributeSymbol = context.SemanticModel.GetSymbolInfo(attribute).Symbol as IMethodSymbol; + var fullName = attributeSymbol?.ContainingType?.ToDisplayString() ?? attribute.Name.ToString(); + if (fullName.EndsWith("GenerateApplyFilterAttribute")) + { + return classDeclaration; + } + } + } + return null; + } + + private static void Execute(Compilation compilation, ImmutableArray classes, SourceProductionContext context) + { + if (classes.IsDefaultOrEmpty) return; + + foreach (var classSyntax in classes.Distinct()) + { + var model = compilation.GetSemanticModel(classSyntax.SyntaxTree); + if (model.GetDeclaredSymbol(classSyntax) is not INamedTypeSymbol filterSymbol) continue; + + var genAttr = filterSymbol.GetAttributes() + .FirstOrDefault(a => a.AttributeClass?.Name == "GenerateApplyFilterAttribute"); + if (genAttr == null) continue; + + // Resolve entity type and target namespace + var entityType = genAttr.ConstructorArguments.Length > 0 ? genAttr.ConstructorArguments[0].Value as INamedTypeSymbol : null; + var explicitNamespace = genAttr.ConstructorArguments.Length > 1 ? genAttr.ConstructorArguments[1].Value?.ToString() : null; + if (entityType == null) continue; + + var targetNamespace = !string.IsNullOrWhiteSpace(explicitNamespace) ? explicitNamespace : GetNamespaceRecursively(filterSymbol.ContainingNamespace); + + // Build extension source + var source = BuildApplyExtensionSource(targetNamespace, filterSymbol, entityType); + var hintName = $"{filterSymbol.Name}.ApplyFilterExtensions.g.cs"; + context.AddSource(hintName, SourceText.From(source, Encoding.UTF8)); + } + } + + private static string BuildApplyExtensionSource(string @namespace, INamedTypeSymbol filterSymbol, INamedTypeSymbol entitySymbol) + { + var sb = new StringBuilder(); + sb.AppendLine("using System;"); + sb.AppendLine("using System.Linq;"); + sb.AppendLine("using AutoFilterer.Abstractions;"); + sb.AppendLine("using AutoFilterer;"); + sb.AppendLine("using AutoFilterer.Attributes;"); + sb.AppendLine("using AutoFilterer.Types;"); + sb.AppendLine("#if LEGACY_NAMESPACE"); + sb.AppendLine("using AutoFilterer.Enums;"); + sb.AppendLine("#endif"); + sb.AppendLine(); + sb.AppendLine($"namespace {@namespace}"); + sb.AppendLine("{"); + sb.AppendLine($" public static class {filterSymbol.Name}ApplyExtensions"); + sb.AppendLine(" {"); + sb.AppendLine($" public static IQueryable<{entitySymbol.ToDisplayString()}> ApplyFilter(this IQueryable<{entitySymbol.ToDisplayString()}> source, {filterSymbol.ToDisplayString()} filter)"); + sb.AppendLine(" {"); + sb.AppendLine(" if (filter == null) return source;"); + + EmitParityPrefilters(sb, filterSymbol, entitySymbol); + + var filterState = BuildFilterState(filterSymbol, entitySymbol, "x", "filter"); + if (!string.IsNullOrWhiteSpace(filterState.Condition) && filterState.Condition != "true") + { + sb.AppendLine($" source = source.Where(x => {filterState.Condition});"); + } + + EmitOrderingAndPagination(sb, filterSymbol, entitySymbol); + + sb.AppendLine(" return source;"); + sb.AppendLine(" }"); + EmitNestedFilterHelpers(sb, filterSymbol, entitySymbol, new HashSet(StringComparer.Ordinal)); + sb.AppendLine(" }"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static bool IsBatch1SafeFilter(INamedTypeSymbol filterSymbol, INamedTypeSymbol entitySymbol) + { + // Batch 1 strict support: direct/single-segment mapping only + // No collection filters, no nested IFilter recursion, no typed CompareTo, no multi-target CompareTo + // Limited operator/range features + + var filterProps = filterSymbol.GetMembers().OfType() + .Where(p => !p.IsStatic && !p.IsIndexer) + .ToArray(); + + foreach (var prop in filterProps) + { + var ignore = prop.GetAttributes().Any(a => a.AttributeClass?.Name == "IgnoreFilterAttribute"); + if (ignore) continue; + + // No collection filters in Batch 1 + var collectionFilterAttr = prop.GetAttributes() + .FirstOrDefault(a => a.AttributeClass?.Name == "CollectionFilterAttribute"); + if (collectionFilterAttr != null) + { + return false; + } + + // No array properties in Batch 1 (complex runtime behavior) + if (prop.Type is IArrayTypeSymbol) + { + return false; + } + + // No nested IFilter types in Batch 1 + if (IsFilterType(prop.Type)) + { + return false; + } + + // No StringFilterOptionsAttribute in Batch 1 + var stringFilterOptions = prop.GetAttributes() + .FirstOrDefault(a => a.AttributeClass?.Name == "StringFilterOptionsAttribute"); + if (stringFilterOptions != null) + { + return false; + } + + // No OperatorComparisonAttribute in Batch 1 + var operatorComparison = prop.GetAttributes() + .FirstOrDefault(a => a.AttributeClass?.Name == "OperatorComparisonAttribute"); + if (operatorComparison != null) + { + return false; + } + + // Check CompareToAttribute constraints + var compareToAttrs = prop.GetAttributes() + .Where(a => a.AttributeClass?.Name == "CompareToAttribute") + .ToArray(); + + foreach (var attr in compareToAttrs) + { + // No multi-target CompareTo (array of property names) + var propNamesConst = attr.ConstructorArguments.Length > 0 ? attr.ConstructorArguments[0] : default; + var targetNames = ExtractTargetPropertyNames(propNamesConst).ToArray(); + if (targetNames.Length > 1) + { + return false; + } + + // Only support single-segment property names (no nested navigation like "Nav.Prop") + var targetName = targetNames.FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(targetName) && targetName.Contains('.')) + { + return false; + } + } + } + + return true; + } + + private static void EmitBatch1WhereConditions(StringBuilder sb, INamedTypeSymbol filterSymbol, INamedTypeSymbol entitySymbol) + { + var filterProps = filterSymbol.GetMembers().OfType() + .Where(p => !p.IsStatic && !p.IsIndexer) + .ToArray(); + + foreach (var prop in filterProps) + { + var ignore = prop.GetAttributes().Any(a => a.AttributeClass?.Name == "IgnoreFilterAttribute"); + if (ignore) continue; + + // Resolve single target property (direct or single-segment CompareTo) + var targetPath = prop.Name; // default to property name + var entityProp = entitySymbol.GetMembers().OfType() + .FirstOrDefault(ep => ep.Name == prop.Name); + + var compareToAttrs = prop.GetAttributes() + .Where(a => a.AttributeClass?.Name == "CompareToAttribute") + .ToArray(); + + if (compareToAttrs.Length > 0) + { + // Use the single CompareTo target (we already validated no multi-target in IsBatch1SafeFilter) + var targetName = (string)null; + if (compareToAttrs[0].ConstructorArguments.Length > 0) + { + var targetArg = compareToAttrs[0].ConstructorArguments[0]; + targetName = ExtractTargetPropertyNames(targetArg).FirstOrDefault(); + } + + if (!string.IsNullOrWhiteSpace(targetName)) + { + entityProp = entitySymbol.GetMembers().OfType() + .FirstOrDefault(ep => ep.Name == targetName); + targetPath = targetName; + } + } + + if (entityProp == null) continue; + + // Handle Range + if (prop.Type is INamedTypeSymbol namedRange && namedRange.Name == "Range") + { + sb.AppendLine($" if (filter.{prop.Name} != null)"); + sb.AppendLine(" {"); + sb.AppendLine($" if (filter.{prop.Name}.Min != null) source = source.Where(x => x.{targetPath} >= filter.{prop.Name}.Min);"); + sb.AppendLine($" if (filter.{prop.Name}.Max != null) source = source.Where(x => x.{targetPath} <= filter.{prop.Name}.Max);"); + sb.AppendLine(" }"); + continue; + } + + // Handle OperatorFilter + if (prop.Type is INamedTypeSymbol namedOp && namedOp.Name == "OperatorFilter") + { + sb.AppendLine($" if (filter.{prop.Name} != null)"); + sb.AppendLine(" {"); + sb.AppendLine($" source = source.Where(x => {BuildOperatorFilterPredicate($"filter.{prop.Name}", $"x.{targetPath}", IsNullableValueType(entityProp.Type))});"); + sb.AppendLine(" }"); + continue; + } + + // Handle StringFilter + if (prop.Type is INamedTypeSymbol named && named.ToDisplayString().StartsWith("AutoFilterer.Types.StringFilter")) + { + sb.AppendLine($" if (filter.{prop.Name} != null)"); + sb.AppendLine(" {"); + sb.AppendLine($" source = source.Where(x => {BuildStringFilterPredicate($"filter.{prop.Name}", $"x.{targetPath}")});"); + sb.AppendLine(" }"); + continue; + } + + // Default scalar/string equality comparison + if (prop.Type.SpecialType == SpecialType.System_String) + { + sb.AppendLine($" if (filter.{prop.Name} != null) source = source.Where(x => x.{targetPath} == filter.{prop.Name});"); + } + else if (IsNullableValueType(prop.Type)) + { + sb.AppendLine($" if (filter.{prop.Name} != null) source = source.Where(x => x.{targetPath}.Equals(filter.{prop.Name}));"); + } + } + + // ORDER BY support (if filter exposes Sort) + var sortProp = FindPropertyIncludingBase(filterSymbol, "Sort"); + if (sortProp != null && sortProp.Type.SpecialType == SpecialType.System_String) + { + sb.AppendLine(" if (!string.IsNullOrEmpty(filter.Sort))"); + sb.AppendLine(" {"); + sb.AppendLine(" switch (filter.Sort)"); + sb.AppendLine(" {"); + foreach (var sortPath in EnumerateSortablePaths(entitySymbol).Distinct(StringComparer.Ordinal)) + { + sb.AppendLine($" case \"{sortPath}\":"); + sb.AppendLine(" {"); + var sortByProp = FindPropertyIncludingBase(filterSymbol, "SortBy"); + if (sortByProp != null) + { + sb.AppendLine(" if (filter.SortBy == Sorting.Descending)"); + sb.AppendLine($" source = source.OrderByDescending(x => x.{sortPath});"); + sb.AppendLine(" else"); + sb.AppendLine($" source = source.OrderBy(x => x.{sortPath});"); + } + else + { + sb.AppendLine($" source = source.OrderBy(x => x.{sortPath});"); + } + sb.AppendLine(" break;"); + sb.AppendLine(" }"); + } + sb.AppendLine(" default: throw new ArgumentException(\"Invalid Sort field\", nameof(filter.Sort));"); + sb.AppendLine(" }"); + sb.AppendLine(" }"); + } + + // Pagination support (Page & PerPage) + var pageProp = FindPropertyIncludingBase(filterSymbol, "Page"); + var perPageProp = FindPropertyIncludingBase(filterSymbol, "PerPage"); + if (pageProp != null && perPageProp != null) + { + sb.AppendLine(" if (filter.Page > 0 && filter.PerPage > 0)"); + sb.AppendLine(" {"); + sb.AppendLine(" source = AutoFilterer.Extensions.QueryExtensions.ToPaged(source, filter.Page, filter.PerPage);"); + sb.AppendLine(" }"); + } + } + + private static (string Active, string Condition) BuildFilterState(INamedTypeSymbol filterSymbol, INamedTypeSymbol entitySymbol, string paramName, string filterPath) + { + var propertyStates = new List<(string Active, string Expression)>(); + + foreach (var filterProp in filterSymbol.GetMembers().OfType().Where(p => !p.IsStatic && !p.IsIndexer)) + { + var state = BuildPropertyState(filterProp, entitySymbol, paramName, filterPath); + if (!string.IsNullOrWhiteSpace(state.Active) && !string.IsNullOrWhiteSpace(state.Condition)) + { + propertyStates.Add((state.Active, state.Condition)); + } + } + + if (propertyStates.Count == 0) + { + return ("false", "true"); + } + + var anyActive = BuildAnyActiveExpression(propertyStates); + var andExpression = BuildAndExpression(propertyStates); + var orExpression = BuildOrExpression(propertyStates); + + return (anyActive, $"!({anyActive}) || ({filterPath}.CombineWith == CombineType.Or ? ({orExpression}) : ({andExpression}))"); + } + + private static (string Active, string Condition) BuildPropertyState(IPropertySymbol filterProp, INamedTypeSymbol entitySymbol, string paramName, string filterPath) + { + if (filterProp.GetAttributes().Any(a => a.AttributeClass?.Name == "IgnoreFilterAttribute")) + { + return default; + } + + var compareToAttrs = filterProp.GetAttributes().Where(a => a.AttributeClass?.Name == "CompareToAttribute").ToArray(); + var collectionFilterAttr = filterProp.GetAttributes().FirstOrDefault(a => a.AttributeClass?.Name == "CollectionFilterAttribute"); + var filterRef = $"{filterPath}.{filterProp.Name}"; + + if (compareToAttrs.Length == 0) + { + var targetProp = ResolveMemberByPath(entitySymbol, filterProp.Name); + if (targetProp == null) + { + return default; + } + + return BuildTargetState(filterProp, targetProp, filterProp.Name, paramName, filterRef, null, collectionFilterAttr); + } + + string combinedCondition = null; + var activeParts = new List(); + + foreach (var compareToAttr in compareToAttrs) + { + var targetStates = new List<(string Active, string Condition)>(); + + foreach (var targetPath in ExtractStringTargets(compareToAttr)) + { + var targetProp = ResolveMemberByPath(entitySymbol, targetPath); + if (targetProp == null) + { + continue; + } + + var state = BuildTargetState(filterProp, targetProp, targetPath, paramName, filterRef, compareToAttr, collectionFilterAttr); + if (!string.IsNullOrWhiteSpace(state.Active) && !string.IsNullOrWhiteSpace(state.Condition)) + { + targetStates.Add(state); + } + } + + if (targetStates.Count == 0) + { + continue; + } + + activeParts.AddRange(targetStates.Select(x => $"({x.Active})")); + + var attributeCondition = CombineStates(targetStates.Select(x => x.Condition), GetCompareCombineOperator(compareToAttr)); + combinedCondition = CombineStates(new[] { combinedCondition, attributeCondition }.Where(x => !string.IsNullOrWhiteSpace(x)), GetCompareCombineOperator(compareToAttr)); + } + + if (string.IsNullOrWhiteSpace(combinedCondition) || activeParts.Count == 0) + { + return default; + } + + return (string.Join(" || ", activeParts), combinedCondition); + } + + private static (string Active, string Condition) BuildTargetState(IPropertySymbol filterProp, IPropertySymbol targetProp, string targetPath, string paramName, string filterRef, AttributeData compareToAttr, AttributeData collectionFilterAttr) + { + if (collectionFilterAttr != null) + { + return BuildCollectionState(filterProp, targetProp, targetPath, paramName, filterRef, collectionFilterAttr); + } + + if (IsFilterType(filterProp.Type) && IsCollectionType(targetProp.Type)) + { + return BuildCollectionState(filterProp, targetProp, targetPath, paramName, filterRef, null); + } + + if (IsFilterType(filterProp.Type)) + { + return BuildNestedFilterState(filterProp, targetProp, targetPath, paramName, filterRef); + } + + if (filterProp.Type is IArrayTypeSymbol arrayType && !IsCollectionType(targetProp.Type)) + { + return BuildConditionState($"{filterRef} != null", WrapWithGuard($"{filterRef}.Contains({BuildMemberAccess(paramName, targetPath)})", BuildPathGuard(paramName, targetPath, includeLeaf: false))); + } + + var attributeState = BuildAttributeState(filterProp, targetProp, targetPath, paramName, filterRef, compareToAttr); + if (!string.IsNullOrWhiteSpace(attributeState.Active) && !string.IsNullOrWhiteSpace(attributeState.Condition)) + { + return attributeState; + } + + if (compareToAttr != null && ExtractFilterableType(compareToAttr) != null) + { + return default; + } + + var targetRef = BuildMemberAccess(paramName, targetPath); + var pathGuard = BuildPathGuard(paramName, targetPath, includeLeaf: false); + + if (filterProp.Type is INamedTypeSymbol namedStringFilter && namedStringFilter.ToDisplayString().StartsWith("AutoFilterer.Types.StringFilter")) + { + return BuildConditionState($"{filterRef} != null && ({BuildStringFilterActiveExpression(filterRef)})", BuildStringFilterConditionExpression(filterRef, targetRef, pathGuard)); + } + + if (filterProp.Type is INamedTypeSymbol namedRange && namedRange.Name == "Range") + { + var active = $"{filterRef} != null && ({filterRef}.Min != null || {filterRef}.Max != null)"; + var condition = BuildRangeConditionExpression(filterRef, targetRef, pathGuard); + return BuildConditionState(active, condition); + } + + if (filterProp.Type is INamedTypeSymbol namedOperator && namedOperator.Name == "OperatorFilter") + { + return BuildConditionState($"{filterRef} != null && ({BuildOperatorFilterActiveExpression(filterRef, SupportsNullChecks(targetProp.Type))})", BuildOperatorFilterConditionExpression(filterRef, targetRef, pathGuard, SupportsNullChecks(targetProp.Type))); + } + + if (filterProp.Type.SpecialType == SpecialType.System_String) + { + return BuildConditionState($"{filterRef} != null", WrapWithGuard($"{targetRef} == {filterRef}", pathGuard)); + } + + if (filterProp.Type is INamedTypeSymbol namedFilterProp && (namedFilterProp.TypeKind == TypeKind.Enum || namedFilterProp.IsValueType)) + { + if (IsNullableValueType(filterProp.Type)) + { + return BuildConditionState($"{filterRef} != null", WrapWithGuard($"{targetRef} == {filterRef}", pathGuard)); + } + + return BuildConditionState("true", WrapWithGuard($"{targetRef}.Equals({filterRef})", pathGuard)); + } + + return default; + } + + private static (string Active, string Condition) BuildCollectionState(IPropertySymbol filterProp, IPropertySymbol targetProp, string targetPath, string paramName, string filterRef, AttributeData collectionFilterAttr) + { + if (filterProp.Type is not INamedTypeSymbol nestedFilterType || targetProp.Type is not INamedTypeSymbol collectionType || !collectionType.IsGenericType) + { + return default; + } + + var elementType = collectionType.TypeArguments.FirstOrDefault() as INamedTypeSymbol; + if (elementType == null) + { + return default; + } + + var collectionAccess = BuildMemberAccess(paramName, targetPath); + var filterOption = GetCollectionFilterOption(collectionFilterAttr); + var collectionQuery = $"{collectionAccess}.AsQueryable()"; + var filteredQuery = $"{GetApplyHelperName(nestedFilterType)}({collectionQuery}, {filterRef})"; + var condition = filterOption == "All" + ? $"(!{collectionQuery}.Any() || {filteredQuery}.Count() == {collectionQuery}.Count())" + : $"{filteredQuery}.Any()"; + + return BuildConditionState($"{filterRef} != null && {GetHasFiltersHelperName(nestedFilterType)}({filterRef})", WrapWithGuard(condition, BuildPathGuard(paramName, targetPath, includeLeaf: false))); + } + + private static (string Active, string Condition) BuildNestedFilterState(IPropertySymbol filterProp, IPropertySymbol targetProp, string targetPath, string paramName, string filterRef) + { + if (filterProp.Type is not INamedTypeSymbol nestedFilterType || targetProp.Type is not INamedTypeSymbol nestedEntityType) + { + return default; + } + + var nestedState = BuildFilterState(nestedFilterType, nestedEntityType, BuildMemberAccess(paramName, targetPath), filterRef); + if (nestedState.Active == "false") + { + return default; + } + + return BuildConditionState($"{filterRef} != null && ({nestedState.Active})", nestedState.Condition); + } + + private static (string Active, string Condition) BuildAttributeState(IPropertySymbol filterProp, IPropertySymbol targetProp, string targetPath, string paramName, string filterRef, AttributeData compareToAttr) + { + var targetRef = BuildMemberAccess(paramName, targetPath); + var pathGuard = BuildPathGuard(paramName, targetPath, includeLeaf: false); + + if (compareToAttr != null) + { + var filterableType = ExtractFilterableType(compareToAttr); + if (filterableType != null) + { + return BuildCustomFilterableState(filterableType, filterRef, targetRef, pathGuard); + } + } + + var inlineAttribute = filterProp.GetAttributes() + .FirstOrDefault(a => a.AttributeClass?.Name is not "CompareToAttribute" and not "CollectionFilterAttribute" and not "IgnoreFilterAttribute"); + + if (inlineAttribute?.AttributeClass?.Name == "StringFilterOptionsAttribute") + { + var method = GetStringFilterMethod(inlineAttribute); + if (!string.IsNullOrWhiteSpace(method)) + { + return BuildConditionState($"{filterRef} != null", BuildStringMethodCondition(targetRef, filterRef, pathGuard, method, GetStringComparisonExpression(inlineAttribute))); + } + } + + if (inlineAttribute?.AttributeClass?.Name == "OperatorComparisonAttribute") + { + return BuildOperatorComparisonState(inlineAttribute, filterRef, targetRef, pathGuard, SupportsNullChecks(targetProp.Type)); + } + + return default; + } + + private static (string Active, string Condition) BuildCustomFilterableState(INamedTypeSymbol filterableType, string filterRef, string targetRef, string pathGuard) + { + if (filterableType.Name == "ToLowerContainsComparisonAttribute") + { + return BuildConditionState($"{filterRef} != null", WrapWithGuard($"{targetRef}.ToLower().Contains({filterRef}.ToLower())", pathGuard)); + } + + if (filterableType.Name == "ToLowerEqualsComparisonAttribute") + { + return BuildConditionState($"{filterRef} != null", WrapWithGuard($"{targetRef}.ToLower().Equals({filterRef}.ToLower())", pathGuard)); + } + + if (InheritsFrom(filterableType, "StringFilterOptionsAttribute", "AutoFilterer.Attributes")) + { + var method = InferMethodFromName(filterableType.Name); + if (!string.IsNullOrWhiteSpace(method)) + { + return BuildConditionState($"{filterRef} != null", BuildStringMethodCondition(targetRef, filterRef, pathGuard, method, "StringComparison.InvariantCultureIgnoreCase")); + } + } + + return default; + } + + private static (string Active, string Condition) BuildOperatorComparisonState(AttributeData attribute, string filterRef, string targetRef, string pathGuard, bool supportsNullChecks) + { + var operatorName = GetOperatorName(attribute); + if (string.IsNullOrWhiteSpace(operatorName)) + { + return default; + } + + return operatorName switch + { + "Equal" => BuildConditionState($"{filterRef} != null", WrapWithGuard($"{targetRef} == {filterRef}", pathGuard)), + "NotEqual" => BuildConditionState($"{filterRef} != null", WrapWithGuard($"{targetRef} != {filterRef}", pathGuard)), + "GreaterThan" => BuildConditionState($"{filterRef} != null", WrapWithGuard($"{targetRef} > {filterRef}", pathGuard)), + "GreaterThanOrEqual" => BuildConditionState($"{filterRef} != null", WrapWithGuard($"{targetRef} >= {filterRef}", pathGuard)), + "LessThan" => BuildConditionState($"{filterRef} != null", WrapWithGuard($"{targetRef} < {filterRef}", pathGuard)), + "LessThanOrEqual" => BuildConditionState($"{filterRef} != null", WrapWithGuard($"{targetRef} <= {filterRef}", pathGuard)), + "IsNull" when supportsNullChecks => BuildConditionState("true", WrapWithGuard($"{targetRef} == null", pathGuard)), + "IsNotNull" when supportsNullChecks => BuildConditionState("true", WrapWithGuard($"{targetRef} != null", pathGuard)), + _ => default + }; + } + + private static string BuildRangeConditionExpression(string filterRef, string targetRef, string pathGuard) + { + var conditions = new List + { + $"({filterRef}.Min == null || {WrapWithGuard($"{targetRef} >= {filterRef}.Min", pathGuard)})", + $"({filterRef}.Max == null || {WrapWithGuard($"{targetRef} <= {filterRef}.Max", pathGuard)})" + }; + + return string.Join(" && ", conditions); + } + + private static string BuildStringFilterActiveExpression(string filterRef) + { + return BuildAnyActiveExpression(GetStringFilterConditions(filterRef).Select(x => (x.Active, x.Expression))); + } + + private static string BuildStringFilterConditionExpression(string filterRef, string targetRef, string pathGuard) + { + var conditions = GetStringFilterConditions(filterRef) + .Select(x => (x.Active, Expression: ApplyStringCondition(x.Expression, targetRef, filterRef, pathGuard))) + .ToList(); + + var andExpression = BuildAndExpression(conditions); + var orExpression = BuildOrExpression(conditions); + return $"({filterRef}.CombineWith == CombineType.Or ? ({orExpression}) : ({andExpression}))"; + } + + private static string BuildOperatorFilterActiveExpression(string filterRef, bool supportsNullChecks) + { + return BuildAnyActiveExpression(GetOperatorFilterConditions(filterRef, supportsNullChecks).Select(x => (x.Active, x.Expression))); + } + + private static string BuildOperatorFilterConditionExpression(string filterRef, string targetRef, string pathGuard, bool supportsNullChecks) + { + var conditions = GetOperatorFilterConditions(filterRef, supportsNullChecks) + .Select(x => (x.Active, Expression: WrapWithGuard(x.Expression.Replace("{target}", targetRef), pathGuard))) + .ToList(); + + var andExpression = BuildAndExpression(conditions); + var orExpression = BuildOrExpression(conditions); + return $"({filterRef}.CombineWith == CombineType.Or ? ({orExpression}) : ({andExpression}))"; + } + + private static void EmitOrderingAndPagination(StringBuilder sb, INamedTypeSymbol filterSymbol, INamedTypeSymbol entitySymbol) + { + var sortProp = FindPropertyIncludingBase(filterSymbol, "Sort"); + if (sortProp != null && sortProp.Type.SpecialType == SpecialType.System_String) + { + sb.AppendLine(" if (!string.IsNullOrEmpty(filter.Sort))"); + sb.AppendLine(" {"); + sb.AppendLine(" switch (filter.Sort)"); + sb.AppendLine(" {"); + foreach (var sortPath in EnumerateSortablePaths(entitySymbol).Distinct(StringComparer.Ordinal)) + { + sb.AppendLine($" case \"{sortPath}\":"); + sb.AppendLine(" {"); + var sortByProp = FindPropertyIncludingBase(filterSymbol, "SortBy"); + if (sortByProp != null) + { + sb.AppendLine(" if (filter.SortBy == Sorting.Descending)"); + sb.AppendLine($" source = source.OrderByDescending(x => x.{sortPath});"); + sb.AppendLine(" else"); + sb.AppendLine($" source = source.OrderBy(x => x.{sortPath});"); + } + else + { + sb.AppendLine($" source = source.OrderBy(x => x.{sortPath});"); + } + sb.AppendLine(" break;"); + sb.AppendLine(" }"); + } + sb.AppendLine(" default: throw new ArgumentException(\"Invalid Sort field\", nameof(filter.Sort));"); + sb.AppendLine(" }"); + sb.AppendLine(" }"); + } + + var pageProp = FindPropertyIncludingBase(filterSymbol, "Page"); + var perPageProp = FindPropertyIncludingBase(filterSymbol, "PerPage"); + if (pageProp != null && perPageProp != null) + { + sb.AppendLine(" if (filter.Page > 0 && filter.PerPage > 0)"); + sb.AppendLine(" {"); + sb.AppendLine(" source = AutoFilterer.Extensions.QueryExtensions.ToPaged(source, filter.Page, filter.PerPage);"); + sb.AppendLine(" }"); + } + } + + private static void EmitNestedFilterHelpers(StringBuilder sb, INamedTypeSymbol filterSymbol, INamedTypeSymbol entitySymbol, HashSet emitted) + { + foreach (var filterProp in filterSymbol.GetMembers().OfType().Where(p => !p.IsStatic && !p.IsIndexer)) + { + if (!IsFilterType(filterProp.Type)) + { + continue; + } + + var compareToAttrs = filterProp.GetAttributes().Where(a => a.AttributeClass?.Name == "CompareToAttribute").ToArray(); + if (compareToAttrs.Length == 0) + { + var defaultTarget = ResolveMemberByPath(entitySymbol, filterProp.Name); + EmitNestedFilterHelperIfNeeded(sb, filterProp, defaultTarget, emitted); + continue; + } + + foreach (var compareToAttr in compareToAttrs) + { + foreach (var targetPath in ExtractStringTargets(compareToAttr)) + { + var targetProp = ResolveMemberByPath(entitySymbol, targetPath); + EmitNestedFilterHelperIfNeeded(sb, filterProp, targetProp, emitted); + } + } + } + } + + private static void EmitNestedFilterHelperIfNeeded(StringBuilder sb, IPropertySymbol filterProp, IPropertySymbol targetProp, HashSet emitted) + { + if (targetProp == null || filterProp.Type is not INamedTypeSymbol nestedFilterType) + { + return; + } + + if (targetProp.Type is not INamedTypeSymbol namedTargetType) + { + return; + } + + INamedTypeSymbol nestedEntityType; + if (IsCollectionType(targetProp.Type)) + { + if (!namedTargetType.IsGenericType || namedTargetType.TypeArguments.FirstOrDefault() is not INamedTypeSymbol elementType) + { + return; + } + + nestedEntityType = elementType; + } + else + { + nestedEntityType = namedTargetType; + } + + var key = nestedFilterType.ToDisplayString() + "->" + nestedEntityType.ToDisplayString(); + if (!emitted.Add(key)) + { + return; + } + + EmitNestedFilterHelpers(sb, nestedFilterType, nestedEntityType, emitted); + EmitHasFiltersHelper(sb, nestedFilterType); + EmitApplyNestedHelper(sb, nestedFilterType, nestedEntityType); + } + + private static void EmitHasFiltersHelper(StringBuilder sb, INamedTypeSymbol filterSymbol) + { + sb.AppendLine(); + sb.AppendLine($" private static bool {GetHasFiltersHelperName(filterSymbol)}({filterSymbol.ToDisplayString()} filter)"); + sb.AppendLine(" {"); + sb.AppendLine(" if (filter == null)"); + sb.AppendLine(" {"); + sb.AppendLine(" return false;"); + sb.AppendLine(" }"); + + foreach (var prop in filterSymbol.GetMembers().OfType().Where(p => !p.IsStatic && !p.IsIndexer)) + { + if (prop.GetAttributes().Any(a => a.AttributeClass?.Name == "IgnoreFilterAttribute")) + { + continue; + } + + var filterRef = $"filter.{prop.Name}"; + + if (prop.Type is INamedTypeSymbol namedStringFilter && namedStringFilter.ToDisplayString().StartsWith("AutoFilterer.Types.StringFilter")) + { + sb.AppendLine($" if ({filterRef} != null && ({BuildStringFilterActiveExpression(filterRef)})) return true;"); + continue; + } + + if (prop.Type is INamedTypeSymbol namedOperator && namedOperator.Name == "OperatorFilter") + { + sb.AppendLine($" if ({filterRef} != null && ({BuildOperatorFilterActiveExpression(filterRef, true)})) return true;"); + continue; + } + + if (prop.Type is INamedTypeSymbol namedRange && namedRange.Name == "Range") + { + sb.AppendLine($" if ({filterRef} != null && ({filterRef}.Min != null || {filterRef}.Max != null)) return true;"); + continue; + } + + if (prop.Type is IArrayTypeSymbol) + { + sb.AppendLine($" if ({filterRef} != null) return true;"); + continue; + } + + if (IsFilterType(prop.Type) && prop.Type is INamedTypeSymbol nestedFilterType) + { + sb.AppendLine($" if ({filterRef} != null && {GetHasFiltersHelperName(nestedFilterType)}({filterRef})) return true;"); + continue; + } + + if (prop.Type.SpecialType == SpecialType.System_String || IsNullableValueType(prop.Type)) + { + sb.AppendLine($" if ({filterRef} != null) return true;"); + continue; + } + + if (prop.Type is INamedTypeSymbol namedProp && (namedProp.TypeKind == TypeKind.Enum || namedProp.IsValueType)) + { + sb.AppendLine(" return true;"); + sb.AppendLine(" }"); + return; + } + } + + sb.AppendLine(" return false;"); + sb.AppendLine(" }"); + } + + private static void EmitApplyNestedHelper(StringBuilder sb, INamedTypeSymbol filterSymbol, INamedTypeSymbol entitySymbol) + { + sb.AppendLine(); + sb.AppendLine($" private static IQueryable<{entitySymbol.ToDisplayString()}> {GetApplyHelperName(filterSymbol)}(IQueryable<{entitySymbol.ToDisplayString()}> source, {filterSymbol.ToDisplayString()} filter)"); + sb.AppendLine(" {"); + sb.AppendLine(" if (filter == null) return source;"); + EmitParityPrefilters(sb, filterSymbol, entitySymbol); + var filterState = BuildFilterState(filterSymbol, entitySymbol, "x", "filter"); + if (!string.IsNullOrWhiteSpace(filterState.Condition) && filterState.Condition != "true") + { + sb.AppendLine($" source = source.Where(x => {filterState.Condition});"); + } + EmitOrderingAndPagination(sb, filterSymbol, entitySymbol); + sb.AppendLine(" return source;"); + sb.AppendLine(" }"); + } + + private static string GetApplyHelperName(INamedTypeSymbol filterSymbol) + { + return "ApplyGenerated_" + filterSymbol.Name; + } + + private static string GetHasFiltersHelperName(INamedTypeSymbol filterSymbol) + { + return "HasFilters_" + filterSymbol.Name; + } + + private static IEnumerable<(string Active, string Expression)> GetStringFilterConditions(string filterRef) + { + yield return ($"{filterRef}.Eq != null", "{target} == " + filterRef + ".Eq"); + yield return ($"{filterRef}.Not != null", "{target} != " + filterRef + ".Not"); + yield return ($"{filterRef}.Equals != null", "STRING_EQUALS"); + yield return ($"{filterRef}.Contains != null", "STRING_CONTAINS"); + yield return ($"{filterRef}.NotContains != null", "NOT_STRING_CONTAINS"); + yield return ($"{filterRef}.StartsWith != null", "STRING_STARTSWITH"); + yield return ($"{filterRef}.NotStartsWith != null", "NOT_STRING_STARTSWITH"); + yield return ($"{filterRef}.EndsWith != null", "STRING_ENDSWITH"); + yield return ($"{filterRef}.NotEndsWith != null", "NOT_STRING_ENDSWITH"); + yield return ($"{filterRef}.IsNull != null", $"({filterRef}.IsNull.Value ? {{target}} == null : {{target}} != null)"); + yield return ($"{filterRef}.IsNotNull != null", $"({filterRef}.IsNotNull.Value ? {{target}} != null : {{target}} == null)"); + yield return ($"{filterRef}.IsEmpty != null", "IS_EMPTY"); + yield return ($"{filterRef}.IsNotEmpty != null", "IS_NOT_EMPTY"); + } + + private static IEnumerable<(string Active, string Expression)> GetOperatorFilterConditions(string filterRef, bool supportsNullChecks) + { + yield return ($"{filterRef}.Eq != null", "{target} == " + filterRef + ".Eq"); + yield return ($"{filterRef}.Not != null", "{target} != " + filterRef + ".Not"); + yield return ($"{filterRef}.Gt != null", "{target} > " + filterRef + ".Gt"); + yield return ($"{filterRef}.Lt != null", "{target} < " + filterRef + ".Lt"); + yield return ($"{filterRef}.Gte != null", "{target} >= " + filterRef + ".Gte"); + yield return ($"{filterRef}.Lte != null", "{target} <= " + filterRef + ".Lte"); + + if (supportsNullChecks) + { + yield return ($"{filterRef}.IsNull != null", $"({filterRef}.IsNull.Value ? {{target}} == null : {{target}} != null)"); + yield return ($"{filterRef}.IsNotNull != null", $"({filterRef}.IsNotNull.Value ? {{target}} != null : {{target}} == null)"); + } + } + + private static string ApplyStringCondition(string template, string targetRef, string filterRef, string pathGuard) + { + return template switch + { + "STRING_EQUALS" => BuildDynamicStringMethodCondition(targetRef, $"{filterRef}.Equals", pathGuard, "Equals", $"{filterRef}.Compare"), + "STRING_CONTAINS" => BuildDynamicStringMethodCondition(targetRef, $"{filterRef}.Contains", pathGuard, "Contains", $"{filterRef}.Compare"), + "NOT_STRING_CONTAINS" => $"!({BuildDynamicStringMethodCondition(targetRef, $"{filterRef}.NotContains", pathGuard, "Contains", $"{filterRef}.Compare")})", + "STRING_STARTSWITH" => BuildDynamicStringMethodCondition(targetRef, $"{filterRef}.StartsWith", pathGuard, "StartsWith", $"{filterRef}.Compare"), + "NOT_STRING_STARTSWITH" => $"!({BuildDynamicStringMethodCondition(targetRef, $"{filterRef}.NotStartsWith", pathGuard, "StartsWith", $"{filterRef}.Compare")})", + "STRING_ENDSWITH" => BuildDynamicStringMethodCondition(targetRef, $"{filterRef}.EndsWith", pathGuard, "EndsWith", $"{filterRef}.Compare"), + "NOT_STRING_ENDSWITH" => $"!({BuildDynamicStringMethodCondition(targetRef, $"{filterRef}.NotEndsWith", pathGuard, "EndsWith", $"{filterRef}.Compare")})", + "IS_EMPTY" => $"({filterRef}.IsEmpty.Value ? {BuildDynamicStringMethodCondition(targetRef, "string.Empty", pathGuard, "Equals", $"{filterRef}.Compare", valueIsLiteral: true)} : !({BuildDynamicStringMethodCondition(targetRef, "string.Empty", pathGuard, "Equals", $"{filterRef}.Compare", valueIsLiteral: true)}))", + "IS_NOT_EMPTY" => $"({filterRef}.IsNotEmpty.Value ? !({BuildDynamicStringMethodCondition(targetRef, "string.Empty", pathGuard, "Equals", $"{filterRef}.Compare", valueIsLiteral: true)}) : {BuildDynamicStringMethodCondition(targetRef, "string.Empty", pathGuard, "Equals", $"{filterRef}.Compare", valueIsLiteral: true)})", + _ => WrapWithGuard(template.Replace("{target}", targetRef), pathGuard) + }; + } + + private static string BuildDynamicStringMethodCondition(string targetRef, string valueRef, string pathGuard, string methodName, string comparisonRef, bool valueIsLiteral = false) + { + var valueExpression = valueIsLiteral ? valueRef : valueRef; + var withComparison = BuildStringMethodCondition(targetRef, valueExpression, pathGuard, methodName, $"{comparisonRef}.Value"); + var withoutComparison = BuildStringMethodCondition(targetRef, valueExpression, pathGuard, methodName, null); + return $"({comparisonRef} != null ? {withComparison} : {withoutComparison})"; + } + + private static string BuildStringMethodCondition(string targetRef, string valueRef, string pathGuard, string methodName, string comparisonExpression) + { + var methodCall = string.IsNullOrWhiteSpace(comparisonExpression) + ? BuildStringMethodInvocation(targetRef, methodName, valueRef) + : BuildStringMethodInvocation(targetRef, methodName, valueRef, comparisonExpression); + + return WrapWithGuard($"{targetRef} != null && {methodCall}", pathGuard); + } + + private static string BuildStringMethodInvocation(string targetRef, string methodName, string valueRef, string comparisonExpression = null) + { + return string.IsNullOrWhiteSpace(comparisonExpression) + ? $"{targetRef}.{methodName}({valueRef})" + : $"{targetRef}.{methodName}({valueRef}, {comparisonExpression})"; + } + + private static string BuildPathGuard(string paramName, string targetPath, bool includeLeaf) + { + var parts = targetPath.Split('.'); + var count = includeLeaf ? parts.Length : parts.Length - 1; + if (count <= 0) + { + return null; + } + + return string.Join(" && ", Enumerable.Range(1, count).Select(i => BuildMemberAccess(paramName, string.Join(".", parts.Take(i))) + " != null")); + } + + private static string BuildMemberAccess(string paramName, string targetPath) + { + return string.IsNullOrWhiteSpace(targetPath) ? paramName : $"{paramName}.{targetPath}"; + } + + private static string WrapWithGuard(string condition, string pathGuard) + { + if (string.IsNullOrWhiteSpace(pathGuard)) + { + return condition; + } + + return $"({pathGuard} && ({condition}))"; + } + + private static string CombineStates(IEnumerable expressions, string combineOperator) + { + var parts = expressions.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => $"({x})").ToArray(); + if (parts.Length == 0) + { + return null; + } + + if (parts.Length == 1) + { + return parts[0]; + } + + var op = string.Equals(combineOperator, "And", StringComparison.Ordinal) ? " && " : " || "; + return string.Join(op, parts); + } + + private static (string Active, string Condition) BuildConditionState(string active, string condition) + { + return string.IsNullOrWhiteSpace(active) || string.IsNullOrWhiteSpace(condition) ? default : (active, condition); + } + + private static string GetCompareCombineOperator(AttributeData compareToAttr) + { + var combine = compareToAttr.NamedArguments.FirstOrDefault(x => x.Key == "CombineWith").Value; + return combine.Value?.ToString() == "0" ? "And" : "Or"; + } + + private static INamedTypeSymbol ExtractFilterableType(AttributeData compareToAttr) + { + foreach (var argument in compareToAttr.ConstructorArguments) + { + if (argument.Kind != TypedConstantKind.Array && argument.Value is INamedTypeSymbol namedType) + { + return namedType; + } + } + + return null; + } + + private static string GetCollectionFilterOption(AttributeData collectionFilterAttr) + { + if (collectionFilterAttr == null) + { + return "Any"; + } + + var filterOptionArg = collectionFilterAttr.ConstructorArguments.Length > 0 ? collectionFilterAttr.ConstructorArguments[0] : default; + if (filterOptionArg.Value != null) + { + return filterOptionArg.Value.ToString() == "0" ? "Any" : "All"; + } + + var namedArg = collectionFilterAttr.NamedArguments.FirstOrDefault(kv => kv.Key == "FilterOption").Value; + return namedArg.Value?.ToString() == "1" ? "All" : "Any"; + } + + private static bool SupportsNullChecks(ITypeSymbol type) + { + return IsNullableValueType(type) || type.SpecialType == SpecialType.System_String; + } + + private static bool IsCollectionType(ITypeSymbol type) + { + return type is INamedTypeSymbol named && IsCollectionLike(named); + } + + private static bool InheritsFrom(INamedTypeSymbol type, string baseTypeName, string baseNamespace) + { + for (var current = type; current != null; current = current.BaseType) + { + if (current.Name == baseTypeName && current.ContainingNamespace?.ToDisplayString() == baseNamespace) + { + return true; + } + } + + return false; + } + + private static string InferMethodFromName(string typeName) + { + if (typeName.Contains("StartsWith", StringComparison.Ordinal)) return "StartsWith"; + if (typeName.Contains("EndsWith", StringComparison.Ordinal)) return "EndsWith"; + if (typeName.Contains("Contains", StringComparison.Ordinal)) return "Contains"; + if (typeName.Contains("Equals", StringComparison.Ordinal)) return "Equals"; + return null; + } + + private static string GetStringFilterMethod(AttributeData attribute) + { + var optionArg = attribute.ConstructorArguments.Length > 0 ? attribute.ConstructorArguments[0].Value?.ToString() : null; + return optionArg switch + { + "0" => "Equals", + "1" => "StartsWith", + "2" => "EndsWith", + "4" => "Contains", + _ => null + }; + } + + private static string GetStringComparisonExpression(AttributeData attribute) + { + var comparisonArg = attribute.ConstructorArguments.Length > 1 ? attribute.ConstructorArguments[1].Value?.ToString() : null; + if (!string.IsNullOrWhiteSpace(comparisonArg)) + { + return $"(StringComparison){comparisonArg}"; + } + + var namedComparison = attribute.NamedArguments.FirstOrDefault(x => x.Key == "Comparison").Value; + return namedComparison.Value == null ? null : $"(StringComparison){namedComparison.Value}"; + } + + private static string GetOperatorName(AttributeData attribute) + { + var operatorArg = attribute.ConstructorArguments.Length > 0 ? attribute.ConstructorArguments[0].Value?.ToString() : null; + return operatorArg switch + { + "0" => "Equal", + "1" => "NotEqual", + "2" => "GreaterThan", + "3" => "GreaterThanOrEqual", + "4" => "LessThan", + "5" => "LessThanOrEqual", + "6" => "IsNull", + "7" => "IsNotNull", + _ => null + }; + } + + private static string BuildStringFilterPredicate(string filterRef, string targetRef) + { + var conditions = new List<(string Active, string Expression)> + { + ($"{filterRef}.Eq != null", $"{targetRef} == {filterRef}.Eq"), + ($"{filterRef}.Not != null", $"{targetRef} != {filterRef}.Not"), + ($"{filterRef}.Equals != null", BuildGuardedStringComparison(targetRef, "Equals", $"{filterRef}.Equals", filterRef)), + ($"{filterRef}.Contains != null", BuildGuardedStringComparison(targetRef, "Contains", $"{filterRef}.Contains", filterRef)), + ($"{filterRef}.NotContains != null", $"!({BuildGuardedStringComparison(targetRef, "Contains", $"{filterRef}.NotContains", filterRef)})"), + ($"{filterRef}.StartsWith != null", BuildGuardedStringComparison(targetRef, "StartsWith", $"{filterRef}.StartsWith", filterRef)), + ($"{filterRef}.NotStartsWith != null", $"!({BuildGuardedStringComparison(targetRef, "StartsWith", $"{filterRef}.NotStartsWith", filterRef)})"), + ($"{filterRef}.EndsWith != null", BuildGuardedStringComparison(targetRef, "EndsWith", $"{filterRef}.EndsWith", filterRef)), + ($"{filterRef}.NotEndsWith != null", $"!({BuildGuardedStringComparison(targetRef, "EndsWith", $"{filterRef}.NotEndsWith", filterRef)})"), + ($"{filterRef}.IsNull != null", $"({filterRef}.IsNull.Value ? {targetRef} == null : {targetRef} != null)"), + ($"{filterRef}.IsNotNull != null", $"({filterRef}.IsNotNull.Value ? {targetRef} != null : {targetRef} == null)"), + ($"{filterRef}.IsEmpty != null", $"({filterRef}.IsEmpty.Value ? {BuildGuardedStringComparison(targetRef, "Equals", "string.Empty", filterRef)} : !({BuildGuardedStringComparison(targetRef, "Equals", "string.Empty", filterRef)}))"), + ($"{filterRef}.IsNotEmpty != null", $"({filterRef}.IsNotEmpty.Value ? !({BuildGuardedStringComparison(targetRef, "Equals", "string.Empty", filterRef)}) : {BuildGuardedStringComparison(targetRef, "Equals", "string.Empty", filterRef)})") + }; + + var anyActive = BuildAnyActiveExpression(conditions); + var andExpression = BuildAndExpression(conditions); + var orExpression = BuildOrExpression(conditions); + + return $"{filterRef} == null || (!({anyActive}) || ({filterRef}.CombineWith == CombineType.Or ? ({orExpression}) : ({andExpression})))"; + } + + private static string BuildOperatorFilterPredicate(string filterRef, string targetRef, bool supportsNullChecks) + { + var conditions = new List<(string Active, string Expression)> + { + ($"{filterRef}.Eq != null", $"{targetRef} == {filterRef}.Eq"), + ($"{filterRef}.Not != null", $"{targetRef} != {filterRef}.Not"), + ($"{filterRef}.Gt != null", $"{targetRef} > {filterRef}.Gt"), + ($"{filterRef}.Lt != null", $"{targetRef} < {filterRef}.Lt"), + ($"{filterRef}.Gte != null", $"{targetRef} >= {filterRef}.Gte"), + ($"{filterRef}.Lte != null", $"{targetRef} <= {filterRef}.Lte") + }; + + if (supportsNullChecks) + { + conditions.Add(($"{filterRef}.IsNull != null", $"({filterRef}.IsNull.Value ? {targetRef} == null : {targetRef} != null)")); + conditions.Add(($"{filterRef}.IsNotNull != null", $"({filterRef}.IsNotNull.Value ? {targetRef} != null : {targetRef} == null)")); + } + + var anyActive = BuildAnyActiveExpression(conditions); + var andExpression = BuildAndExpression(conditions); + var orExpression = BuildOrExpression(conditions); + + return $"{filterRef} == null || (!({anyActive}) || ({filterRef}.CombineWith == CombineType.Or ? ({orExpression}) : ({andExpression})))"; + } + + private static string BuildGuardedStringComparison(string targetRef, string methodName, string valueRef, string filterRef) + { + return $"{targetRef} != null && ({BuildStringComparison(targetRef, methodName, valueRef, filterRef)})"; + } + + private static string BuildStringComparison(string targetRef, string methodName, string valueRef, string filterRef) + { + return $"({filterRef}.Compare != null ? {targetRef}.{methodName}({valueRef}, {filterRef}.Compare.Value) : {targetRef}.{methodName}({valueRef}))"; + } + + private static string BuildAnyActiveExpression(IEnumerable<(string Active, string Expression)> conditions) + { + return string.Join(" || ", conditions.Select(c => $"({c.Active})")); + } + + private static string BuildAndExpression(IEnumerable<(string Active, string Expression)> conditions) + { + return string.Join(" && ", conditions.Select(c => $"(!({c.Active}) || ({c.Expression}))")); + } + + private static string BuildOrExpression(IEnumerable<(string Active, string Expression)> conditions) + { + return string.Join(" || ", conditions.Select(c => $"(({c.Active}) && ({c.Expression}))")); + } + + private static IEnumerable EnumerateSortablePaths(INamedTypeSymbol entitySymbol, int depth = 2, string prefix = null) + { + foreach (var property in entitySymbol.GetMembers().OfType().Where(p => !p.IsStatic && !p.IsIndexer)) + { + var currentPath = string.IsNullOrEmpty(prefix) ? property.Name : $"{prefix}.{property.Name}"; + + if (IsSortableLeaf(property.Type)) + { + yield return currentPath; + continue; + } + + if (depth <= 1 || property.Type is not INamedTypeSymbol named || IsCollectionLike(named)) + { + continue; + } + + foreach (var nestedPath in EnumerateSortablePaths(named, depth - 1, currentPath)) + { + yield return nestedPath; + } + } + } + + private static bool IsSortableLeaf(ITypeSymbol type) + { + if (type.SpecialType == SpecialType.System_String) + { + return true; + } + + if (type.TypeKind == TypeKind.Enum || type.IsValueType) + { + return true; + } + + return type is INamedTypeSymbol named && named.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T; + } + + private static bool IsCollectionLike(INamedTypeSymbol type) + { + if (type.AllInterfaces.Any(i => i.Name == nameof(System.Collections.IEnumerable))) + { + return type.SpecialType != SpecialType.System_String; + } + + return false; + } + + private static IPropertySymbol ResolveMemberByPath(INamedTypeSymbol entitySymbol, string path) + { + // Supports simple names and one-level navigation: Prop or Nav.Prop + var parts = path.Split('.'); + ITypeSymbol currentType = entitySymbol; + IPropertySymbol lastProp = null; + foreach (var part in parts) + { + lastProp = currentType.GetMembers().OfType().FirstOrDefault(p => p.Name == part); + if (lastProp == null) return null; + currentType = lastProp.Type; + } + return lastProp; + } + + private static IPropertySymbol FindPropertyIncludingBase(INamedTypeSymbol typeSymbol, string propertyName) + { + for (var current = typeSymbol; current != null; current = current.BaseType) + { + var property = current.GetMembers().OfType() + .FirstOrDefault(p => !p.IsStatic && !p.IsIndexer && p.Name == propertyName); + + if (property != null) + { + return property; + } + } + + return null; + } + + private static void EmitParityPrefilters(StringBuilder sb, INamedTypeSymbol filterSymbol, INamedTypeSymbol entitySymbol) + { + var filterProps = filterSymbol.GetMembers().OfType() + .Where(p => !p.IsStatic && !p.IsIndexer) + .ToArray(); + + foreach (var prop in filterProps.Where(p => p.Type is IArrayTypeSymbol)) + { + sb.AppendLine($" if (filter.{prop.Name} != null && filter.{prop.Name}.Length == 0)"); + sb.AppendLine(" {"); + sb.AppendLine(" return source.Where(_ => false);"); + sb.AppendLine(" }"); + } + + var candidateForNullEquality = filterSymbol.BaseType?.Name == "FilterBase" && filterProps.Length == 1 + ? filterProps[0] + : null; + + if (candidateForNullEquality != null && + candidateForNullEquality.Type.SpecialType == SpecialType.System_String && + !candidateForNullEquality.GetAttributes().Any(a => a.AttributeClass?.Name == "CompareToAttribute") && + !candidateForNullEquality.GetAttributes().Any(a => a.AttributeClass?.Name == "StringFilterOptionsAttribute")) + { + var entityProp = entitySymbol.GetMembers().OfType().FirstOrDefault(x => x.Name == candidateForNullEquality.Name); + if (entityProp != null && entityProp.Type.SpecialType == SpecialType.System_String) + { + sb.AppendLine($" if (filter.{candidateForNullEquality.Name} == string.Empty)"); + sb.AppendLine(" {"); + sb.AppendLine($" source = source.Where(x => x.{entityProp.Name} == string.Empty);"); + sb.AppendLine(" }"); + sb.AppendLine($" else if (filter.{candidateForNullEquality.Name} == null)"); + sb.AppendLine(" {"); + sb.AppendLine($" source = source.Where(x => x.{entityProp.Name} == null);"); + sb.AppendLine(" }"); + } + } + + foreach (var prop in filterProps) + { + if (!TryGetSingleCompareTarget(prop, entitySymbol, out var targetPath, out var targetProp)) + { + continue; + } + + var nullGuards = BuildNullGuardsForPath(targetPath); + + if (!IsNullableValueType(targetProp.Type)) + { + continue; + } + + if (prop.Type is INamedTypeSymbol namedRange && namedRange.Name == "Range") + { + sb.AppendLine($" if (filter.{prop.Name} != null && (filter.{prop.Name}.Min != null || filter.{prop.Name}.Max != null))"); + sb.AppendLine(" {"); + sb.AppendLine($" source = source.Where(x => {nullGuards});"); + sb.AppendLine(" }"); + continue; + } + + if (prop.Type is INamedTypeSymbol namedOperator && namedOperator.Name == "OperatorFilter") + { + sb.AppendLine($" if (filter.{prop.Name} != null && (filter.{prop.Name}.Eq != null || filter.{prop.Name}.Not != null || filter.{prop.Name}.Gt != null || filter.{prop.Name}.Lt != null || filter.{prop.Name}.Gte != null || filter.{prop.Name}.Lte != null))"); + sb.AppendLine(" {"); + sb.AppendLine($" source = source.Where(x => {nullGuards});"); + sb.AppendLine(" }"); + } + } + + foreach (var prop in filterProps.Where(p => p.Type.SpecialType == SpecialType.System_String)) + { + var stringFilterOptions = prop.GetAttributes().FirstOrDefault(a => a.AttributeClass?.Name == "StringFilterOptionsAttribute"); + var compareToAttr = prop.GetAttributes().FirstOrDefault(a => a.AttributeClass?.Name == "CompareToAttribute"); + if (stringFilterOptions == null || compareToAttr == null) + { + continue; + } + + var optionArg = stringFilterOptions.ConstructorArguments.Length > 0 ? stringFilterOptions.ConstructorArguments[0].Value?.ToString() : null; + if (optionArg != "4" && !string.Equals(optionArg, "Contains", StringComparison.Ordinal)) // StringFilterOption.Contains + { + continue; + } + + string target = null; + if (compareToAttr.ConstructorArguments.Length > 0) + { + var targetArg = compareToAttr.ConstructorArguments[0]; + target = targetArg.Kind == TypedConstantKind.Array + ? targetArg.Values.FirstOrDefault().Value?.ToString() + : targetArg.Value?.ToString(); + } + if (string.IsNullOrWhiteSpace(target) || !target.Contains('.')) + { + continue; + } + + var pathParts = target.Split('.'); + var accessPath = string.Join(".", pathParts); + var nullGuards = string.Join(" && ", Enumerable.Range(1, pathParts.Length) + .Select(i => "x." + string.Join(".", pathParts.Take(i)) + " != null")); + + var comparisonArg = stringFilterOptions.ConstructorArguments.Length > 1 + ? stringFilterOptions.ConstructorArguments[1].Value?.ToString() + : null; + + var comparison = comparisonArg == null + ? "StringComparison.InvariantCultureIgnoreCase" + : $"(StringComparison){comparisonArg}"; + + sb.AppendLine($" if (filter.{prop.Name} != null)"); + sb.AppendLine(" {"); + sb.AppendLine($" source = source.Where(x => {nullGuards} && x.{accessPath}.Contains(filter.{prop.Name}, {comparison}));"); + sb.AppendLine(" }"); + } + } + + private static bool TryGetSingleCompareTarget(IPropertySymbol filterProp, INamedTypeSymbol entitySymbol, out string targetPath, out IPropertySymbol targetProp) + { + targetPath = null; + targetProp = null; + + var compareToAttrs = filterProp.GetAttributes().Where(a => a.AttributeClass?.Name == "CompareToAttribute").ToArray(); + if (compareToAttrs.Length == 0) + { + targetProp = entitySymbol.GetMembers().OfType().FirstOrDefault(ep => ep.Name == filterProp.Name); + if (targetProp == null) + { + return false; + } + + targetPath = filterProp.Name; + return true; + } + + if (compareToAttrs.Length != 1) + { + return false; + } + + var targetNames = ExtractStringTargets(compareToAttrs[0]).ToArray(); + if (targetNames.Length != 1) + { + return false; + } + + targetPath = targetNames[0]; + targetProp = ResolveMemberByPath(entitySymbol, targetPath); + return targetProp != null; + } + + private static IEnumerable ExtractStringTargets(AttributeData attribute) + { + foreach (var argument in attribute.ConstructorArguments) + { + foreach (var targetName in ExtractTargetPropertyNames(argument)) + { + yield return targetName; + } + } + } + + private static IEnumerable ExtractTargetPropertyNames(TypedConstant propNamesConst) + { + if (propNamesConst.Kind == TypedConstantKind.Array) + { + return propNamesConst.Values + .Select(v => v.Value?.ToString()) + .Where(targetName => !string.IsNullOrWhiteSpace(targetName)) + .Select(targetName => targetName!); + } + + var targetName = propNamesConst.Value?.ToString(); + return string.IsNullOrWhiteSpace(targetName) ? Enumerable.Empty() : new[] { targetName }; + } + + private static string BuildNullGuardsForPath(string path) + { + var parts = path.Split('.'); + return string.Join(" && ", Enumerable.Range(1, parts.Length) + .Select(i => "x." + string.Join(".", parts.Take(i)) + " != null")); + } + + private static bool IsNullableValueType(ITypeSymbol type) + { + return type is INamedTypeSymbol named && + named.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T; + } + + private static void EmitCollectionFilter(StringBuilder sb, INamedTypeSymbol entitySymbol, IPropertySymbol filterProp, IPropertySymbol entityProp, AttributeData collectionFilterAttr, string parentParam) + { + var fpName = filterProp.Name; + var epName = entityProp.Name; + + // Get FilterOption (Any or All) + var filterOption = GetCollectionFilterOption(collectionFilterAttr); + + // Check if entity property is a collection + if (entityProp.Type is not INamedTypeSymbol collectionType) return; + + // Try to get the element type + ITypeSymbol elementType = null; + if (collectionType.IsGenericType) + { + elementType = collectionType.TypeArguments.FirstOrDefault(); + } + + if (elementType == null) return; + + // Check if filter property type is an IFilter + var isFilterType = IsFilterType(filterProp.Type); + if (!isFilterType) return; + + var nestedFilterType = filterProp.Type as INamedTypeSymbol; + if (nestedFilterType == null) return; + + sb.AppendLine($" if (filter.{fpName} != null)"); + sb.AppendLine(" {"); + + // Generate nested lambda parameter + var nestedParam = GetNestedParameterName(parentParam); + + // Build nested filter conditions with proper filter path + var nestedConditions = BuildNestedFilterConditions(nestedFilterType, elementType as INamedTypeSymbol, nestedParam, $"filter.{fpName}"); + + if (!string.IsNullOrEmpty(nestedConditions)) + { + sb.AppendLine($" source = source.Where({parentParam} => {parentParam}.{epName}.{filterOption}({nestedParam} =>"); + sb.AppendLine($" {nestedConditions}"); + sb.AppendLine(" ));"); + } + + sb.AppendLine(" }"); + } + + private static bool IsFilterType(ITypeSymbol type) + { + if (type == null) return false; + + // Check if implements IFilter interface + var implementsIFilter = type.AllInterfaces.Any(iface => + iface.Name == "IFilter" && + iface.ContainingNamespace?.ToDisplayString() == "AutoFilterer.Abstractions"); + if (implementsIFilter) return true; + + // Check if inherits from FilterBase + var baseType = type.BaseType; + while (baseType != null) + { + if (baseType.Name == "FilterBase" && baseType.ContainingNamespace?.ToDisplayString() == "AutoFilterer.Types") + { + return true; + } + baseType = baseType.BaseType; + } + + return false; + } + + private static string GetNestedParameterName(string parentParam) + { + // x -> a -> b -> c -> d + var paramNames = new[] { "x", "a", "b", "c", "d", "e", "f", "g", "h", "i", "j" }; + var index = Array.IndexOf(paramNames, parentParam); + if (index >= 0 && index < paramNames.Length - 1) + { + return paramNames[index + 1]; + } + return "item"; + } + + private static string BuildNestedFilterConditions(INamedTypeSymbol nestedFilterType, INamedTypeSymbol nestedEntityType, string paramName, string filterPath = "filter") + { + if (nestedFilterType == null || nestedEntityType == null) return string.Empty; + + var conditions = new List(); + + foreach (var filterProp in nestedFilterType.GetMembers().OfType().Where(p => !p.IsStatic && !p.IsIndexer)) + { + var ignore = filterProp.GetAttributes().Any(a => a.AttributeClass?.Name == "IgnoreFilterAttribute"); + if (ignore) continue; + + // Check for nested CollectionFilter (recursive) + var nestedCollectionAttr = filterProp.GetAttributes() + .FirstOrDefault(a => a.AttributeClass?.Name == "CollectionFilterAttribute"); + + if (nestedCollectionAttr != null) + { + // Handle nested collection filters recursively + var compareToAttrs = filterProp.GetAttributes() + .Where(a => a.AttributeClass?.Name == "CompareToAttribute") + .ToArray(); + + if (compareToAttrs.Length == 0) + { + var entityProp = nestedEntityType.GetMembers().OfType().FirstOrDefault(ep => ep.Name == filterProp.Name); + if (entityProp != null) + { + var nestedCondition = BuildNestedCollectionCondition(filterProp, entityProp, nestedCollectionAttr, paramName, filterPath); + if (!string.IsNullOrEmpty(nestedCondition)) + { + conditions.Add(nestedCondition); + } + } + } + else + { + foreach (var attr in compareToAttrs) + { + var propNamesConst = attr.ConstructorArguments.Length > 0 ? attr.ConstructorArguments[0] : default; + var targetNames = ExtractTargetPropertyNames(propNamesConst); + + foreach (var targetName in targetNames) + { + var entityProp = ResolveNestedMemberByPath(nestedEntityType, targetName); + if (entityProp != null) + { + var nestedCondition = BuildNestedCollectionCondition(filterProp, entityProp, nestedCollectionAttr, paramName, filterPath); + if (!string.IsNullOrEmpty(nestedCondition)) + { + conditions.Add(nestedCondition); + } + } + } + } + } + continue; + } + + // Handle regular scalar comparisons + var compareToAttrs2 = filterProp.GetAttributes() + .Where(a => a.AttributeClass?.Name == "CompareToAttribute") + .ToArray(); + + if (compareToAttrs2.Length == 0) + { + var entityProp = nestedEntityType.GetMembers().OfType().FirstOrDefault(ep => ep.Name == filterProp.Name); + if (entityProp != null) + { + var condition = BuildNestedScalarCondition(filterProp, entityProp, paramName, filterPath); + if (!string.IsNullOrEmpty(condition)) + { + conditions.Add(condition); + } + } + } + else + { + foreach (var attr in compareToAttrs2) + { + var propNamesConst = attr.ConstructorArguments.Length > 0 ? attr.ConstructorArguments[0] : default; + var targetNames = ExtractTargetPropertyNames(propNamesConst); + + foreach (var targetName in targetNames) + { + var entityProp = ResolveNestedMemberByPath(nestedEntityType, targetName); + if (entityProp != null) + { + var condition = BuildNestedScalarCondition(filterProp, entityProp, paramName, filterPath); + if (!string.IsNullOrEmpty(condition)) + { + conditions.Add(condition); + } + } + } + } + } + } + + // Combine conditions with AND + if (conditions.Count == 0) return "true"; + if (conditions.Count == 1) return conditions[0]; + return string.Join(" && ", conditions.Select(c => $"({c})")); + } + + private static string BuildNestedCollectionCondition(IPropertySymbol filterProp, IPropertySymbol entityProp, AttributeData collectionFilterAttr, string parentParam, string filterPath) + { + var fpName = filterProp.Name; + var epName = entityProp.Name; + + // Get FilterOption + var filterOption = GetCollectionFilterOption(collectionFilterAttr); + + if (entityProp.Type is not INamedTypeSymbol collectionType) return string.Empty; + + ITypeSymbol elementType = null; + if (collectionType.IsGenericType) + { + elementType = collectionType.TypeArguments.FirstOrDefault(); + } + + if (elementType == null) return string.Empty; + + var isFilterType = IsFilterType(filterProp.Type); + if (!isFilterType) return string.Empty; + + var nestedFilterType = filterProp.Type as INamedTypeSymbol; + if (nestedFilterType == null) return string.Empty; + + var deeperParam = GetNestedParameterName(parentParam); + var deeperFilterPath = $"{filterPath}.{fpName}"; + var innerConditions = BuildNestedFilterConditions(nestedFilterType, elementType as INamedTypeSymbol, deeperParam, deeperFilterPath); + + if (string.IsNullOrEmpty(innerConditions)) return string.Empty; + + return $"{filterPath}.{fpName} != null && {parentParam}.{epName}.{filterOption}({deeperParam} => {innerConditions})"; + } + + private static string BuildNestedScalarCondition(IPropertySymbol filterProp, IPropertySymbol entityProp, string paramName, string filterPath) + { + var fpName = filterProp.Name; + var epName = entityProp.Name; + var filterRef = $"{filterPath}.{fpName}"; + + // Handle StringFilter + if (filterProp.Type is INamedTypeSymbol named && named.ToDisplayString().StartsWith("AutoFilterer.Types.StringFilter")) + { + var conditions = new List(); + conditions.Add($"{filterRef}.Eq != null && {paramName}.{epName} == {filterRef}.Eq"); + conditions.Add($"{filterRef}.Contains != null && {paramName}.{epName}.Contains({filterRef}.Contains)"); + conditions.Add($"{filterRef}.StartsWith != null && {paramName}.{epName}.StartsWith({filterRef}.StartsWith)"); + conditions.Add($"{filterRef}.EndsWith != null && {paramName}.{epName}.EndsWith({filterRef}.EndsWith)"); + return $"{filterRef} == null || ({string.Join(" || ", conditions)})"; + } + + // Handle Range + if (filterProp.Type is INamedTypeSymbol namedRange && namedRange.Name == "Range") + { + var conditions = new List(); + conditions.Add($"{filterRef}.Min == null || {paramName}.{epName} >= {filterRef}.Min"); + conditions.Add($"{filterRef}.Max == null || {paramName}.{epName} <= {filterRef}.Max"); + return $"{filterRef} == null || ({string.Join(" && ", conditions)})"; + } + + // Handle OperatorFilter + if (filterProp.Type is INamedTypeSymbol namedOp && namedOp.Name == "OperatorFilter") + { + var conditions = new List(); + conditions.Add($"{filterRef}.Eq == null || {paramName}.{epName} == {filterRef}.Eq"); + conditions.Add($"{filterRef}.Gt == null || {paramName}.{epName} > {filterRef}.Gt"); + conditions.Add($"{filterRef}.Lt == null || {paramName}.{epName} < {filterRef}.Lt"); + conditions.Add($"{filterRef}.Gte == null || {paramName}.{epName} >= {filterRef}.Gte"); + conditions.Add($"{filterRef}.Lte == null || {paramName}.{epName} <= {filterRef}.Lte"); + return $"{filterRef} == null || ({string.Join(" && ", conditions)})"; + } + + // Default scalar comparison + if (filterProp.Type.SpecialType == SpecialType.System_String) + { + return $"string.IsNullOrEmpty({filterRef}) || {paramName}.{epName} == {filterRef}"; + } + else + { + var isNullable = filterProp.NullableAnnotation == Microsoft.CodeAnalysis.NullableAnnotation.Annotated; + if (isNullable) + { + return $"{filterRef} == null || {paramName}.{epName}.Equals({filterRef})"; + } + } + + return string.Empty; + } + + private static IPropertySymbol ResolveNestedMemberByPath(INamedTypeSymbol entitySymbol, string path) + { + if (entitySymbol == null || string.IsNullOrEmpty(path)) return null; + + var parts = path.Split('.'); + ITypeSymbol currentType = entitySymbol; + IPropertySymbol lastProp = null; + + foreach (var part in parts) + { + lastProp = currentType.GetMembers().OfType().FirstOrDefault(p => p.Name == part); + if (lastProp == null) return null; + currentType = lastProp.Type; + } + + return lastProp; + } + + private static void EmitScalarComparison(StringBuilder sb, INamedTypeSymbol entitySymbol, IPropertySymbol filterProp, IPropertySymbol entityProp, string combineType) + { + var fpName = filterProp.Name; + var epName = entityProp.Name; + + // Handle StringFilter + if (filterProp.Type is INamedTypeSymbol named && named.ToDisplayString().StartsWith("AutoFilterer.Types.StringFilter")) + { + sb.AppendLine($" if (filter.{fpName} != null)"); + sb.AppendLine(" {"); + if (combineType == "Or") + { + sb.AppendLine(" source = source.Where(x => "); + sb.AppendLine($" (filter.{fpName}.Eq != null && x.{epName} == filter.{fpName}.Eq) ||"); + sb.AppendLine($" (filter.{fpName}.Contains != null && x.{epName}.Contains(filter.{fpName}.Contains)) ||"); + sb.AppendLine($" (filter.{fpName}.StartsWith != null && x.{epName}.StartsWith(filter.{fpName}.StartsWith)) ||"); + sb.AppendLine($" (filter.{fpName}.EndsWith != null && x.{epName}.EndsWith(filter.{fpName}.EndsWith))"); + sb.AppendLine(" );"); + } + else + { + sb.AppendLine($" if (filter.{fpName}.Eq != null) source = source.Where(x => x.{epName} == filter.{fpName}.Eq);"); + sb.AppendLine($" if (filter.{fpName}.Contains != null) source = source.Where(x => x.{epName}.Contains(filter.{fpName}.Contains));"); + sb.AppendLine($" if (filter.{fpName}.StartsWith != null) source = source.Where(x => x.{epName}.StartsWith(filter.{fpName}.StartsWith));"); + sb.AppendLine($" if (filter.{fpName}.EndsWith != null) source = source.Where(x => x.{epName}.EndsWith(filter.{fpName}.EndsWith));"); + } + sb.AppendLine(" }"); + return; + } + + // Handle Range + if (filterProp.Type is INamedTypeSymbol namedRange && namedRange.Name == "Range" && namedRange.ContainingNamespace.ToDisplayString() == "AutoFilterer.Types") + { + sb.AppendLine($" if (filter.{fpName} != null)"); + sb.AppendLine(" {"); + sb.AppendLine($" if (filter.{fpName}.Min != null) source = source.Where(x => x.{epName} >= filter.{fpName}.Min);"); + sb.AppendLine($" if (filter.{fpName}.Max != null) source = source.Where(x => x.{epName} <= filter.{fpName}.Max);"); + sb.AppendLine(" }"); + return; + } + + // Handle OperatorFilter + if (filterProp.Type is INamedTypeSymbol namedOp && namedOp.Name == "OperatorFilter") + { + sb.AppendLine($" if (filter.{fpName} != null)"); + sb.AppendLine(" {"); + sb.AppendLine($" if (filter.{fpName}.Eq != null) source = source.Where(x => x.{epName} == filter.{fpName}.Eq);"); + sb.AppendLine($" if (filter.{fpName}.Gt != null) source = source.Where(x => x.{epName} > filter.{fpName}.Gt);"); + sb.AppendLine($" if (filter.{fpName}.Lt != null) source = source.Where(x => x.{epName} < filter.{fpName}.Lt);"); + sb.AppendLine($" if (filter.{fpName}.Gte != null) source = source.Where(x => x.{epName} >= filter.{fpName}.Gte);"); + sb.AppendLine($" if (filter.{fpName}.Lte != null) source = source.Where(x => x.{epName} <= filter.{fpName}.Lte);"); + sb.AppendLine(" }"); + return; + } + + // Default scalar/string equality comparison + if (filterProp.Type.SpecialType == SpecialType.System_String) + { + sb.AppendLine($" if (!string.IsNullOrEmpty(filter.{fpName})) source = source.Where(x => x.{epName} == filter.{fpName});"); + } + else + { + // Generate only for nullable value types to avoid unintended defaults + var isNullable = filterProp.NullableAnnotation == Microsoft.CodeAnalysis.NullableAnnotation.Annotated; + if (isNullable) + { + sb.AppendLine($" if (filter.{fpName} != null) source = source.Where(x => x.{epName} == filter.{fpName});"); + } + } + } + + private static string GetNamespaceRecursively(INamespaceSymbol symbol) + { + if (symbol == null || symbol.IsGlobalNamespace) return string.Empty; + var parent = GetNamespaceRecursively(symbol.ContainingNamespace); + return string.IsNullOrEmpty(parent) ? symbol.Name : parent + "." + symbol.Name; + } +} diff --git a/src/AutoFilterer.Generators/FilterGenerator.cs b/src/AutoFilterer.Generators/FilterGenerator.cs index cac4204..c0a3599 100644 --- a/src/AutoFilterer.Generators/FilterGenerator.cs +++ b/src/AutoFilterer.Generators/FilterGenerator.cs @@ -1,4 +1,4 @@ -using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; using System; using System.Collections.Generic; @@ -75,23 +75,143 @@ private static void Execute(Compilation compilation, ImmutableArray a.AttributeClass?.Name == nameof(GenerateAutoFilterAttribute)); - var targetNamespace = attribute?.ConstructorArguments.FirstOrDefault().Value?.ToString().Trim('\"'); // Temporary... Attribute has only one argument for now. + var targetNamespace = attribute?.ConstructorArguments.FirstOrDefault().Value?.ToString().Trim('\"'); if (string.IsNullOrEmpty(targetNamespace)) { targetNamespace = GetNamespaceRecursively(symbol.ContainingNamespace); } + // Parse generation options from attribute + var baseClass = GetNamedArgumentValue(attribute, nameof(GenerateAutoFilterAttribute.BaseClass)) ?? "PaginationFilterBase"; + var useStringFilter = GetNamedArgumentValue(attribute, nameof(GenerateAutoFilterAttribute.UseStringFilter)); + + // Handle nullable bool options - use default true when not specified + var useRangeForNumbersTemp = GetNamedArgumentValue(attribute, nameof(GenerateAutoFilterAttribute.UseRangeForNumbers)); + var useRangeForNumbers = useRangeForNumbersTemp ?? true; + + var useRangeForDatesTemp = GetNamedArgumentValue(attribute, nameof(GenerateAutoFilterAttribute.UseRangeForDates)); + var useRangeForDates = useRangeForDatesTemp ?? true; + + var generateForEnumPropertiesTemp = GetNamedArgumentValue(attribute, nameof(GenerateAutoFilterAttribute.GenerateForEnumProperties)); + var generateForEnumProperties = generateForEnumPropertiesTemp ?? true; + var properties = symbol.GetMembers() .OfType() - .Where(x => !x.IsStatic && !x.IsIndexer && x.Kind == SymbolKind.Property); + .Where(x => !x.IsStatic && !x.IsIndexer && x.Kind == SymbolKind.Property) + .Where(x => !ShouldSkipProperty(x)); - var sourceCode = GetFilterDtoCode(symbol.Name, properties, targetNamespace); + var sourceCode = GetFilterDtoCode(symbol.Name, properties, targetNamespace, baseClass, useStringFilter, useRangeForNumbers, useRangeForDates, generateForEnumProperties); context.AddSource($"{symbol.Name}FilterDto.g.cs", SourceText.From(sourceCode, Encoding.UTF8)); } } + private static T GetNamedArgumentValue(AttributeData attribute, string argumentName) + { + if (attribute == null) + { + return default; + } + + foreach (var namedArgument in attribute.NamedArguments) + { + if (namedArgument.Key == argumentName) + { + try + { + return (T)namedArgument.Value.Value; + } + catch + { + return default; + } + } + } + + return default; + } + + private static bool ShouldSkipProperty(IPropertySymbol property) + { + // Skip collection types (arrays, IEnumerable, etc.) + if (property.Type is IArrayTypeSymbol) + { + return true; + } + + if (property.Type is INamedTypeSymbol namedType && namedType.IsGenericType) + { + var typeDefinition = namedType.ConstructedFrom?.ToDisplayString() ?? namedType.ToDisplayString(); + + // Skip IEnumerable, ICollection, IList, List, etc. + if (typeDefinition.StartsWith("System.Collections.Generic.IEnumerable<") || + typeDefinition.StartsWith("System.Collections.Generic.ICollection<") || + typeDefinition.StartsWith("System.Collections.Generic.IList<") || + typeDefinition.StartsWith("System.Collections.Generic.List<") || + typeDefinition.StartsWith("System.Collections.Generic.HashSet<") || + typeDefinition.StartsWith("System.Collections.Generic.IReadOnlyCollection<") || + typeDefinition.StartsWith("System.Collections.Generic.IReadOnlyList<")) + { + return true; + } + } + + // Skip navigation properties to other complex types (non-primitive, non-enum) + // Only generate for basic types, strings, enums, and nullable versions of these + var type = property.Type; + var underlyingType = type; + + if (type is INamedTypeSymbol namedValueType && namedValueType.IsValueType && namedValueType.IsGenericType) + { + var genericDefinition = namedValueType.ConstructedFrom?.ToDisplayString(); + if (genericDefinition == "System.Nullable") + { + underlyingType = namedValueType.TypeArguments[0]; + } + } + + // Check if it's a type we should generate for + if (underlyingType.SpecialType == SpecialType.System_String) + { + return false; + } + + // Check for enums + if (underlyingType.TypeKind == TypeKind.Enum) + { + return false; + } + + // Check for basic numeric types + if (underlyingType.SpecialType >= SpecialType.System_Boolean && underlyingType.SpecialType <= SpecialType.System_UInt64) + { + return false; + } + + // Check for decimal + if (underlyingType.SpecialType == SpecialType.System_Decimal) + { + return false; + } + + // Check for DateTime, DateTimeOffset, TimeSpan, Guid + var typeName = underlyingType.ToDisplayString(); + if (typeName == "System.DateTime" || typeName == "System.DateTimeOffset" || + typeName == "System.TimeSpan" || typeName == "System.Guid") + { + return false; + } + + // Skip any other complex types (navigation properties) + return true; + } + private static string GetFilterDtoCode(string className, IEnumerable properties, - string @namespace = null) + string @namespace = null, + string baseClass = "PaginationFilterBase", + bool useStringFilter = false, + bool useRangeForNumbers = true, + bool useRangeForDates = true, + bool generateForEnumProperties = true) { var generatedCode = new StringBuilder(); generatedCode.AppendLine("using System;"); @@ -100,20 +220,83 @@ private static string GetFilterDtoCode(string className, IEnumerable") + { + underlyingType = namedType.TypeArguments[0]; + } + } + + // Skip enum properties if not configured to generate them + if (underlyingType.TypeKind == TypeKind.Enum && !generateForEnumProperties) + { + continue; } - if (property.Type.SpecialType == SpecialType.System_String) + // Handle string type mapping + if (originalType.SpecialType == SpecialType.System_String && useStringFilter) { - generatedCode.AppendLine("\t\t[ToLowerContainsComparison]"); + propertyType = "StringFilter"; + } + // Handle numeric types + else if (TypeMapping.Mappings.TryGetValue(propertyType, out var mapped)) + { + // Check if this is a numeric or date type that should use Range + var typeName = underlyingType.ToDisplayString(); + + // For numbers, respect useRangeForNumbers flag + if ((underlyingType.SpecialType >= SpecialType.System_SByte && underlyingType.SpecialType <= SpecialType.System_UInt64) || + underlyingType.SpecialType == SpecialType.System_Decimal) + { + if (!useRangeForNumbers) + { + // Use the original type instead of Range + propertyType = originalType.ToDisplayString(NullableFlowState.None); + } + else + { + propertyType = mapped; + } + } + // For DateTime/DateTimeOffset/TimeSpan, respect useRangeForDates flag + else if (typeName == "System.DateTime" || typeName == "System.DateTimeOffset" || typeName == "System.TimeSpan") + { + if (!useRangeForDates) + { + propertyType = originalType.ToDisplayString(NullableFlowState.None); + } + else + { + propertyType = mapped; + } + } + // For bool and Guid, always keep as-is (they don't use Range) + else + { + propertyType = mapped; + } + } + + // Add attributes based on type + if (originalType.SpecialType == SpecialType.System_String) + { + if (!useStringFilter) + { + generatedCode.AppendLine("\t\t[ToLowerContainsComparison]"); + } + // When useStringFilter is true, StringFilter handles the filtering internally, no attribute needed } generatedCode.AppendLine($"\t\tpublic virtual {propertyType} {property.Name} {{ get; set; }}"); diff --git a/src/AutoFilterer.Generators/GenerateAutoFilterAttribute.cs b/src/AutoFilterer.Generators/GenerateAutoFilterAttribute.cs index f31bbad..93f26ef 100644 --- a/src/AutoFilterer.Generators/GenerateAutoFilterAttribute.cs +++ b/src/AutoFilterer.Generators/GenerateAutoFilterAttribute.cs @@ -1,4 +1,4 @@ -using System; +using System; [AttributeUsage(AttributeTargets.Class)] public class GenerateAutoFilterAttribute : Attribute @@ -11,6 +11,16 @@ public GenerateAutoFilterAttribute(string @namespace) { Namespace = @namespace; } - + public string Namespace { get; } + + public string BaseClass { get; set; } + + public bool UseStringFilter { get; set; } + + public bool UseRangeForNumbers { get; set; } = true; + + public bool UseRangeForDates { get; set; } = true; + + public bool GenerateForEnumProperties { get; set; } = true; } \ No newline at end of file diff --git a/src/AutoFilterer.Generators/TypeMapping.cs b/src/AutoFilterer.Generators/TypeMapping.cs index 96f5208..6f5872f 100644 --- a/src/AutoFilterer.Generators/TypeMapping.cs +++ b/src/AutoFilterer.Generators/TypeMapping.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text; @@ -18,11 +18,11 @@ public class TypeMapping { "short", "Range" }, { "short?", "Range" }, { "ushort", "Range" }, - { "ushort?", "Range" }, + { "ushort?", "Range" }, { "int", "Range" }, { "int?", "Range" }, { "uint", "Range" }, - { "uint?", "Range" }, + { "uint?", "Range" }, { "long", "Range" }, { "long?", "Range" }, { "ulong", "Range" }, @@ -33,9 +33,17 @@ public class TypeMapping { "float?", "Range" }, { "decimal", "Range" }, { "decimal?", "Range" }, + // Boolean types (kept as is for direct equality comparison) + { "bool", "bool" }, + { "bool?", "bool?" }, + // Guid types (kept as is for direct equality comparison) + { "System.Guid", "System.Guid" }, + { "System.Guid?", "System.Guid?" }, // Special cases for some types: { "System.DateTime", "Range" }, { "System.DateTime?", "Range" }, + { "System.DateTimeOffset", "Range" }, + { "System.DateTimeOffset?", "Range" }, { "System.TimeSpan", "Range" }, { "System.TimeSpan?", "Range" }, }; diff --git a/src/AutoFilterer/Attributes/OperatorComparisonAttribute.cs b/src/AutoFilterer/Attributes/OperatorComparisonAttribute.cs index 09ed271..15e582a 100644 --- a/src/AutoFilterer/Attributes/OperatorComparisonAttribute.cs +++ b/src/AutoFilterer/Attributes/OperatorComparisonAttribute.cs @@ -25,10 +25,12 @@ public override Expression BuildExpression(ExpressionBuildContext context) var filterProp = BuildFilterExpression(context); var targetIsNullable = context.TargetProperty.PropertyType.IsNullable() || context.TargetProperty.PropertyType == typeof(string); + var targetType = context.TargetProperty.PropertyType; + var nullableValueComparison = context.TargetProperty.PropertyType.IsNullable() && OperatorType != OperatorType.IsNull && OperatorType != OperatorType.IsNotNull; - if (context.TargetProperty.PropertyType.IsNullable()) + if (nullableValueComparison) { - prop = Expression.Property(prop, nameof(Nullable.Value)); + filterProp = Expression.Convert(filterProp, targetType); } switch (OperatorType) @@ -78,4 +80,4 @@ static OperatorComparisonAttribute() } #endregion -} \ No newline at end of file +} diff --git a/src/AutoFilterer/Attributes/StringFilterOptionsAttribute.cs b/src/AutoFilterer/Attributes/StringFilterOptionsAttribute.cs index b3b94e7..0b37d50 100644 --- a/src/AutoFilterer/Attributes/StringFilterOptionsAttribute.cs +++ b/src/AutoFilterer/Attributes/StringFilterOptionsAttribute.cs @@ -40,10 +40,11 @@ private Expression BuildExpressionWithComparison(StringFilterOption option, Expr { var method = typeof(string).GetMethod(option.ToString(), types: new[] { typeof(string), typeof(StringComparison) }); var filterProp = BuildFilterExpression(context); + var source = Expression.Property(context.ExpressionBody, context.TargetProperty.Name); var comparison = Expression.Call( method: method, - instance: Expression.Property(context.ExpressionBody, context.TargetProperty.Name), + instance: source, arguments: new Expression[] { filterProp, Expression.Constant(Comparison) }); return comparison; @@ -54,10 +55,11 @@ private Expression BuildExpressionWithoutComparison(StringFilterOption option, E var method = typeof(string).GetMethod(option.ToString(), types: new[] { typeof(string) }); var filterProp = BuildFilterExpression(context); + var source = Expression.Property(context.ExpressionBody, context.TargetProperty.Name); var comparison = Expression.Call( method: method, - instance: Expression.Property(context.ExpressionBody, context.TargetProperty.Name), + instance: source, arguments: new[] { filterProp }); return comparison; diff --git a/src/AutoFilterer/Types/FilterBase.cs b/src/AutoFilterer/Types/FilterBase.cs index 8297fad..767e8ca 100644 --- a/src/AutoFilterer/Types/FilterBase.cs +++ b/src/AutoFilterer/Types/FilterBase.cs @@ -66,14 +66,18 @@ public virtual Expression BuildExpression(Type entityType, Expression body) { foreach (var targetPropertyName in attribute.PropertyNames) { - var targetProperty = entityType.GetProperty(targetPropertyName); - if (targetProperty == null) - continue; - var bodyParameter = finalExpression is MemberExpression ? finalExpression : body; + if (!TryResolveTargetPath(entityType, bodyParameter, targetPropertyName, out var targetBody, out var targetProperty, out var nullGuard)) + continue; + var expression = attribute.BuildExpressionForProperty( - new ExpressionBuildContext(bodyParameter, targetProperty, filterProperty, filterPropertyExpression, this, filterPropertyValue)); + new ExpressionBuildContext(targetBody, targetProperty, filterProperty, filterPropertyExpression, this, filterPropertyValue)); + + if (expression != null && nullGuard != null) + { + expression = Expression.AndAlso(nullGuard, expression); + } innerExpression = innerExpression.Combine(expression, attribute.CombineWith); } @@ -95,4 +99,52 @@ public virtual Expression BuildExpression(Type entityType, Expression body) return finalExpression; } + + private static bool TryResolveTargetPath(Type entityType, Expression expressionBody, string targetPropertyName, out Expression targetBody, out PropertyInfo targetProperty, out Expression nullGuard) + { + targetBody = expressionBody; + targetProperty = null; + nullGuard = null; + + if (string.IsNullOrWhiteSpace(targetPropertyName)) + { + return false; + } + + var parts = targetPropertyName.Split('.'); + var currentType = entityType; + var currentExpression = expressionBody; + + for (var i = 0; i < parts.Length; i++) + { + var property = currentType.GetProperty(parts[i]); + if (property == null) + { + return false; + } + + if (i == parts.Length - 1) + { + targetBody = currentExpression; + targetProperty = property; + return true; + } + + currentExpression = Expression.Property(currentExpression, property); + currentType = property.PropertyType; + + if (CanBeNull(property.PropertyType)) + { + var notNull = Expression.NotEqual(currentExpression, Expression.Constant(null, property.PropertyType)); + nullGuard = nullGuard == null ? notNull : Expression.AndAlso(nullGuard, notNull); + } + } + + return false; + } + + private static bool CanBeNull(Type type) + { + return !type.IsValueType || Nullable.GetUnderlyingType(type) != null; + } } diff --git a/src/AutoFilterer/Types/Range.cs b/src/AutoFilterer/Types/Range.cs index 1cf2889..8f83ae8 100644 --- a/src/AutoFilterer/Types/Range.cs +++ b/src/AutoFilterer/Types/Range.cs @@ -66,16 +66,16 @@ BinaryExpression GetRangeComparison() BinaryExpression minExp = default, maxExp = default; var propertyExpression = Expression.Property(context.ExpressionBody, context.TargetProperty.Name); - if (context.TargetProperty.PropertyType.IsNullable()) - { - propertyExpression = Expression.Property(propertyExpression, nameof(Nullable.Value)); - } if (Min != null) { + var minExpression = Expression.Convert( + Expression.Property(Expression.Constant(this), nameof(Min)).GetValueExpressionIfNullable(), + propertyExpression.Type); + minExp = Expression.GreaterThanOrEqual( propertyExpression, - Expression.Property(Expression.Constant(this), nameof(Min)).GetValueExpressionIfNullable()); + minExpression); if (Max == null) { return minExp; @@ -84,9 +84,13 @@ BinaryExpression GetRangeComparison() if (Max != null) { + var maxExpression = Expression.Convert( + Expression.Property(Expression.Constant(this), nameof(Max)).GetValueExpressionIfNullable(), + propertyExpression.Type); + maxExp = Expression.LessThanOrEqual( propertyExpression, - Expression.Property(Expression.Constant(this), nameof(Max)).GetValueExpressionIfNullable()); + maxExpression); if (Min == null) { return maxExp; diff --git a/src/AutoFilterer/Types/StringFilter.cs b/src/AutoFilterer/Types/StringFilter.cs index df058b8..5aee7e6 100644 --- a/src/AutoFilterer/Types/StringFilter.cs +++ b/src/AutoFilterer/Types/StringFilter.cs @@ -104,37 +104,75 @@ public virtual Expression BuildExpression(ExpressionBuildContext context) expression = expression.Combine(OperatorComparisonAttribute.IsNotNull.BuildExpression(ContextFor(context, nameof(IsNotNull), null)), CombineWith); if (Equals != null) - expression = expression.Combine(new StringFilterOptionsAttribute(StringFilterOption.Equals) { Comparison = Compare }.BuildExpression(ContextFor(context, nameof(Equals), Equals)), CombineWith); + expression = expression.Combine( + GuardTargetNotNull( + context, + new StringFilterOptionsAttribute(StringFilterOption.Equals) { Comparison = Compare } + .BuildExpression(ContextFor(context, nameof(Equals), Equals))), + CombineWith); if (Contains != null) - expression = expression.Combine(new StringFilterOptionsAttribute(StringFilterOption.Contains) { Comparison = Compare }.BuildExpression(ContextFor(context, nameof(Contains), Contains)), CombineWith); + expression = expression.Combine( + GuardTargetNotNull( + context, + new StringFilterOptionsAttribute(StringFilterOption.Contains) { Comparison = Compare } + .BuildExpression(ContextFor(context, nameof(Contains), Contains))), + CombineWith); if (NotContains != null) - expression = expression.Combine(Expression.Not(new StringFilterOptionsAttribute(StringFilterOption.Contains) { Comparison = Compare }.BuildExpression(ContextFor(context, nameof(NotContains), NotContains))), CombineWith); + expression = expression.Combine( + Expression.Not( + GuardTargetNotNull( + context, + new StringFilterOptionsAttribute(StringFilterOption.Contains) { Comparison = Compare } + .BuildExpression(ContextFor(context, nameof(NotContains), NotContains)))), + CombineWith); if (StartsWith != null) - expression = expression.Combine(new StringFilterOptionsAttribute(StringFilterOption.StartsWith) { Comparison = Compare }.BuildExpression(ContextFor(context, nameof(StartsWith), StartsWith)), CombineWith); + expression = expression.Combine( + GuardTargetNotNull( + context, + new StringFilterOptionsAttribute(StringFilterOption.StartsWith) { Comparison = Compare } + .BuildExpression(ContextFor(context, nameof(StartsWith), StartsWith))), + CombineWith); if (NotStartsWith != null) - expression = expression.Combine(Expression.Not(new StringFilterOptionsAttribute(StringFilterOption.StartsWith) { Comparison = Compare }.BuildExpression(ContextFor(context, nameof(NotStartsWith), NotStartsWith))), CombineWith); + expression = expression.Combine( + Expression.Not( + GuardTargetNotNull( + context, + new StringFilterOptionsAttribute(StringFilterOption.StartsWith) { Comparison = Compare } + .BuildExpression(ContextFor(context, nameof(NotStartsWith), NotStartsWith)))), + CombineWith); if (EndsWith != null) - expression = expression.Combine(new StringFilterOptionsAttribute(StringFilterOption.EndsWith) { Comparison = Compare }.BuildExpression(ContextFor(context, nameof(EndsWith), EndsWith)), CombineWith); + expression = expression.Combine( + GuardTargetNotNull( + context, + new StringFilterOptionsAttribute(StringFilterOption.EndsWith) { Comparison = Compare } + .BuildExpression(ContextFor(context, nameof(EndsWith), EndsWith))), + CombineWith); if (NotEndsWith != null) - expression = expression.Combine(Expression.Not(new StringFilterOptionsAttribute(StringFilterOption.EndsWith) { Comparison = Compare }.BuildExpression(ContextFor(context, nameof(NotEndsWith), NotEndsWith))), CombineWith); + expression = expression.Combine( + Expression.Not( + GuardTargetNotNull( + context, + new StringFilterOptionsAttribute(StringFilterOption.EndsWith) { Comparison = Compare } + .BuildExpression(ContextFor(context, nameof(NotEndsWith), NotEndsWith)))), + CombineWith); if (IsEmpty != null) { if (IsEmpty.Value) { - expression = expression.Combine(new StringFilterOptionsAttribute(StringFilterOption.Equals) { Comparison = Compare } - .BuildExpression(ContextForConstant(context, string.Empty)), CombineWith); + expression = expression.Combine(GuardTargetNotNull(context, new StringFilterOptionsAttribute(StringFilterOption.Equals) { Comparison = Compare } + .BuildExpression(ContextForConstant(context, string.Empty))), CombineWith); } else { - expression = expression.Combine(Expression.Not(new StringFilterOptionsAttribute(StringFilterOption.Equals) { Comparison = Compare } - .BuildExpression(ContextForConstant(context, string.Empty))), CombineWith); + expression = expression.Combine(Expression.Not(GuardTargetNotNull(context, new StringFilterOptionsAttribute(StringFilterOption.Equals) { Comparison = Compare } + .BuildExpression(ContextForConstant(context, string.Empty)))), CombineWith); } } @@ -142,13 +180,13 @@ public virtual Expression BuildExpression(ExpressionBuildContext context) { if (IsNotEmpty.Value) { - expression = expression.Combine(Expression.Not(new StringFilterOptionsAttribute(StringFilterOption.Equals) { Comparison = Compare } - .BuildExpression(ContextForConstant(context, string.Empty))), CombineWith); + expression = expression.Combine(Expression.Not(GuardTargetNotNull(context, new StringFilterOptionsAttribute(StringFilterOption.Equals) { Comparison = Compare } + .BuildExpression(ContextForConstant(context, string.Empty)))), CombineWith); } else { - expression = expression.Combine(new StringFilterOptionsAttribute(StringFilterOption.Equals) { Comparison = Compare } - .BuildExpression(ContextForConstant(context, string.Empty)), CombineWith); + expression = expression.Combine(GuardTargetNotNull(context, new StringFilterOptionsAttribute(StringFilterOption.Equals) { Comparison = Compare } + .BuildExpression(ContextForConstant(context, string.Empty))), CombineWith); } } @@ -179,4 +217,11 @@ private ExpressionBuildContext ContextForConstant(ExpressionBuildContext origina originalContext.FilterObject, value); } + + private static Expression GuardTargetNotNull(ExpressionBuildContext context, Expression comparisonExpression) + { + var source = Expression.Property(context.ExpressionBody, context.TargetProperty.Name); + var sourceNotNull = Expression.NotEqual(source, Expression.Constant(null, typeof(string))); + return Expression.AndAlso(sourceNotNull, comparisonExpression); + } } diff --git a/tests/AutoFilterer.Generators.Tests/AutoFilterer.Generators.Tests.csproj b/tests/AutoFilterer.Generators.Tests/AutoFilterer.Generators.Tests.csproj index 7c2c97e..d8c3ff1 100644 --- a/tests/AutoFilterer.Generators.Tests/AutoFilterer.Generators.Tests.csproj +++ b/tests/AutoFilterer.Generators.Tests/AutoFilterer.Generators.Tests.csproj @@ -3,7 +3,7 @@ - net9.0 + net10.0 false @@ -21,6 +21,7 @@ + diff --git a/tests/AutoFilterer.Generators.Tests/Environment/Dtos/TestFilters.cs b/tests/AutoFilterer.Generators.Tests/Environment/Dtos/TestFilters.cs new file mode 100644 index 0000000..dfd8f92 --- /dev/null +++ b/tests/AutoFilterer.Generators.Tests/Environment/Dtos/TestFilters.cs @@ -0,0 +1,852 @@ +using AutoFilterer; +using AutoFilterer.Attributes; +using AutoFilterer.Enums; +using AutoFilterer.Types; +using System; +using Book = AutoFilterer.Generators.Tests.Environment.Models.Book; +using Author = AutoFilterer.Generators.Tests.Environment.Models.Author; +using Publisher = AutoFilterer.Generators.Tests.Environment.Models.Publisher; +using Preferences = AutoFilterer.Generators.Tests.Environment.Models.Preferences; +using Level1 = AutoFilterer.Generators.Tests.Environment.Models.Level1; +using Level2 = AutoFilterer.Generators.Tests.Environment.Models.Level2; +using Level3 = AutoFilterer.Generators.Tests.Environment.Models.Level3; +using Level4 = AutoFilterer.Generators.Tests.Environment.Models.Level4; +using Level5 = AutoFilterer.Generators.Tests.Environment.Models.Level5; +using Level6 = AutoFilterer.Generators.Tests.Environment.Models.Level6; +using Level7 = AutoFilterer.Generators.Tests.Environment.Models.Level7; +using Level8 = AutoFilterer.Generators.Tests.Environment.Models.Level8; + +namespace AutoFilterer.Generators.Tests.Environment.Dtos; + +// Basic scalar filter +[GenerateApplyFilter(typeof(Book))] +public class BookFilter_Basic : FilterBase +{ + public string Title { get; set; } +} + +// StringFilter tests +[GenerateApplyFilter(typeof(Book))] +public class BookFilter_StringFilter : FilterBase +{ + [CompareTo(nameof(Book.Title))] + public StringFilter Title { get; set; } +} + +// OperatorFilter tests +[GenerateApplyFilter(typeof(Book))] +public class BookFilter_OperatorFilter : FilterBase +{ + [CompareTo(nameof(Book.TotalPage))] + public OperatorFilter TotalPage { get; set; } + + [CompareTo(nameof(Book.Year))] + public OperatorFilter Year { get; set; } +} + +// Range tests +[GenerateApplyFilter(typeof(Book))] +public class BookFilter_Range : FilterBase +{ + [CompareTo(nameof(Book.Year))] + public Range Year { get; set; } + + [CompareTo(nameof(Book.TotalPage))] + public Range TotalPage { get; set; } + + [CompareTo(nameof(Book.Views))] + public Range Views { get; set; } +} + +// Orderable tests +[GenerateApplyFilter(typeof(Book))] +public class BookFilter_Orderable : OrderableFilterBase +{ + public string Title { get; set; } +} + +// Pagination tests +[GenerateApplyFilter(typeof(Book))] +public class BookFilter_Pagination : PaginationFilterBase +{ + public string Title { get; set; } +} + +// Nested collection filter - Book nested +public class BookNestedFilter : FilterBase +{ + [CompareTo(nameof(Book.Title))] + public StringFilter Title { get; set; } + + [CompareTo(nameof(Book.Year))] + public Range Year { get; set; } + + [CompareTo(nameof(Book.TotalPage))] + public OperatorFilter TotalPage { get; set; } + + [CompareTo(nameof(Book.Views))] + public OperatorFilter Views { get; set; } +} + +// Collection filter tests +[GenerateApplyFilter(typeof(Author))] +public class AuthorFilter_CollectionAny : FilterBase +{ + [CompareTo(nameof(Author.Name))] + public StringFilter Name { get; set; } + + [CompareTo(nameof(Author.Books))] + [CollectionFilter(CollectionFilterType.Any)] + public BookNestedFilter Books { get; set; } +} + +[GenerateApplyFilter(typeof(Author))] +public class AuthorFilter_CollectionAll : FilterBase +{ + [CompareTo(nameof(Author.Name))] + public string Name { get; set; } + + [CompareTo(nameof(Author.Books))] + [CollectionFilter(CollectionFilterType.All)] + public BookNestedFilter Books { get; set; } +} + +// Complex nested filter +public class AuthorNestedFilter : FilterBase +{ + [CompareTo(nameof(Author.Name))] + public StringFilter Name { get; set; } + + [CompareTo(nameof(Author.Books))] + [CollectionFilter(CollectionFilterType.Any)] + public BookNestedFilter Books { get; set; } +} + +[GenerateApplyFilter(typeof(Publisher))] +public class PublisherFilter_NestedCollection : FilterBase +{ + [CompareTo(nameof(Publisher.Name))] + public string Name { get; set; } + + [CompareTo(nameof(Publisher.Authors))] + [CollectionFilter(CollectionFilterType.Any)] + public AuthorNestedFilter Authors { get; set; } +} + +// Combined filter with all features +[GenerateApplyFilter(typeof(Author))] +public class AuthorFilter_Complete : PaginationFilterBase +{ + [CompareTo(nameof(Author.Name))] + public StringFilter Name { get; set; } + + [CompareTo(nameof(Author.Country))] + public string Country { get; set; } + + [CompareTo(nameof(Author.Age))] + public OperatorFilter Age { get; set; } + + [CompareTo(nameof(Author.Books))] + [CollectionFilter(CollectionFilterType.Any)] + public BookNestedFilter Books { get; set; } +} + +// CompareTo with multiple properties (Or) +[GenerateApplyFilter(typeof(Book))] +public class BookFilter_MultiplePropertyOr : FilterBase +{ + [CompareTo(nameof(Book.Title), nameof(Book.Author), CombineWith = CombineType.Or)] + [StringFilterOptions(StringFilterOption.Contains)] + public string Query { get; set; } +} + +// CompareTo with multiple properties (And) +[GenerateApplyFilter(typeof(Book))] +public class BookFilter_MultiplePropertyAnd : FilterBase +{ + [CompareTo(nameof(Book.Title), nameof(Book.Author), CombineWith = CombineType.And)] + [StringFilterOptions(StringFilterOption.Contains)] + public string Query { get; set; } +} + +// CompareTo with type attribute +[GenerateApplyFilter(typeof(Book))] +public class BookFilter_TypeCompareTo : FilterBase +{ + [CompareTo(typeof(ToLowerContainsComparisonAttribute), nameof(Book.Title))] + public string Search { get; set; } +} + +// Multiple type CompareTo with Or combination +[GenerateApplyFilter(typeof(Book))] +public class BookFilter_MultipleTypeCompareTo : FilterBase +{ + [CompareTo(typeof(ToLowerContainsComparisonAttribute), nameof(Book.Title))] + [CompareTo(typeof(StartsWithAttribute), nameof(Book.Author))] + public string Search { get; set; } + + public class StartsWithAttribute : StringFilterOptionsAttribute + { + public StartsWithAttribute() : base(StringFilterOption.StartsWith, StringComparison.InvariantCultureIgnoreCase) + { + } + } +} + +// Multiple type CompareTo with And combination +[GenerateApplyFilter(typeof(Book))] +public class BookFilter_MultipleTypeCompareToAnd : FilterBase +{ + [CompareTo(typeof(ToLowerContainsComparisonAttribute), nameof(Book.Title))] + [CompareTo(typeof(EndsWithAttribute), nameof(Book.Author), CombineWith = CombineType.And)] + public string Search { get; set; } + + public class EndsWithAttribute : StringFilterOptionsAttribute + { + public EndsWithAttribute() : base(StringFilterOption.EndsWith, StringComparison.InvariantCultureIgnoreCase) + { + } + } +} + +// Array search without attribute +[GenerateApplyFilter(typeof(Preferences))] +public class PreferencesFilter_ArraySearchWithout : FilterBase +{ + public int[] SecurityLevel { get; set; } +} + +// Array search with attribute +[GenerateApplyFilter(typeof(Preferences))] +public class PreferencesFilter_ArraySearchWith : FilterBase +{ + [ArraySearchFilter] + public int[] SecurityLevel { get; set; } +} + +// Array search Guid without attribute +[GenerateApplyFilter(typeof(Preferences))] +public class PreferencesFilter_ArraySearchGuidWithout : FilterBase +{ + public Guid?[] OrganizationUnitId { get; set; } +} + +// StringFilter with advanced properties +[GenerateApplyFilter(typeof(Book))] +public class BookFilter_StringFilter_Advanced : FilterBase +{ + [CompareTo(nameof(Book.Title))] + public StringFilter Title { get; set; } +} + +// OperatorFilter with all operators +[GenerateApplyFilter(typeof(Book))] +public class BookFilter_OperatorFilter_Advanced : FilterBase +{ + [CompareTo(nameof(Book.TotalPage))] + public OperatorFilter TotalPage { get; set; } + + [CompareTo(nameof(Book.Views))] + public OperatorFilter Views { get; set; } +} + +// CompareTo with multiple properties (Range) +[GenerateApplyFilter(typeof(Book))] +public class BookFilter_Range_MultipleProperty : FilterBase +{ + [CompareTo(nameof(Book.TotalPage), nameof(Book.ReadCount))] + public Range PageRange { get; set; } +} + +// StringFilterOptions on string property +[GenerateApplyFilter(typeof(Book))] +public class BookFilter_StringOptionsContains : FilterBase +{ + [CompareTo(nameof(Book.Title))] + [StringFilterOptions(StringFilterOption.Contains)] + public string Query { get; set; } +} + +public abstract class BookFilter_BaseWithIgnored : FilterBase +{ + [IgnoreFilter] + [CompareTo(nameof(Book.Title))] + public virtual string IgnoredQuery { get; set; } +} + +[GenerateApplyFilter(typeof(Book))] +public class BookFilter_InheritedIgnore : BookFilter_BaseWithIgnored +{ +} + +[GenerateApplyFilter(typeof(Book))] +public class BookFilter_DottedPath : FilterBase +{ + [CompareTo("AuthorModel.Name")] + [StringFilterOptions(StringFilterOption.Contains, StringComparison.InvariantCultureIgnoreCase)] + public string AuthorName { get; set; } +} + +// Filter with invalid CompareTo target for error handling parity test +[GenerateApplyFilter(typeof(Book))] +public class BookFilter_InvalidTarget : FilterBase +{ + [CompareTo("NonExistentProperty")] + public string Search { get; set; } +} + +// Filter for ToLowerEqualsComparison semantics +[GenerateApplyFilter(typeof(Book))] +public class BookFilter_ToLowerEquals : FilterBase +{ + [CompareTo(typeof(ToLowerEqualsComparisonAttribute), nameof(Book.Title))] + public string Title { get; set; } +} + +// Filter for CombineWith at top level across scalar properties +[GenerateApplyFilter(typeof(Book))] +public class BookFilter_ScalarCombineWith : FilterBase +{ + [CompareTo(nameof(Book.Title))] + [StringFilterOptions(StringFilterOption.Contains, StringComparison.InvariantCultureIgnoreCase)] + public string Title { get; set; } + + [CompareTo(nameof(Book.Author))] + [StringFilterOptions(StringFilterOption.Contains, StringComparison.InvariantCultureIgnoreCase)] + public string Author { get; set; } +} + +// Orderable filter for edge cases +[GenerateApplyFilter(typeof(Book))] +public class BookFilter_OrderableEdge : OrderableFilterBase +{ + [CompareTo(nameof(Book.Title))] + public string Title { get; set; } +} + +// Nested non-collection object filter +public class AuthorModelFilter : FilterBase +{ + [CompareTo(nameof(Author.Name))] + [StringFilterOptions(StringFilterOption.Contains, StringComparison.InvariantCultureIgnoreCase)] + public string Name { get; set; } +} + +[GenerateApplyFilter(typeof(Book))] +public class BookFilter_NestedObject : FilterBase +{ + [CompareTo("AuthorModel")] + public AuthorModelFilter AuthorFilter { get; set; } +} + +// OperatorFilter matrix tests +[GenerateApplyFilter(typeof(Book))] +public class BookFilter_OperatorFilter_Matrix : FilterBase +{ + [CompareTo(nameof(Book.TotalPage))] + public OperatorFilter TotalPage { get; set; } + + [CompareTo(nameof(Book.Views))] + public OperatorFilter Views { get; set; } +} + +// StringFilter matrix tests +[GenerateApplyFilter(typeof(Book))] +public class BookFilter_StringFilter_Matrix : FilterBase +{ + [CompareTo(nameof(Book.Title))] + public StringFilter Title { get; set; } +} + +// Invalid CompareTo filterable type for error handling tests +[GenerateApplyFilter(typeof(Book))] +public class BookFilter_InvalidFilterableType : FilterBase +{ + [CompareTo(typeof(StringFilter), nameof(Book.TotalPage))] + public string Search { get; set; } +} + +// Implicit nested object mapping tests - property name matches model property without [CompareTo] +public class AuthorFilterImplicit : FilterBase +{ + [CompareTo(nameof(Author.Name))] + [StringFilterOptions(StringFilterOption.Contains, StringComparison.InvariantCultureIgnoreCase)] + public string Name { get; set; } + + [CompareTo(nameof(Author.Country))] + public string Country { get; set; } + + [CompareTo(nameof(Author.Age))] + public Range Age { get; set; } +} + +[GenerateApplyFilter(typeof(Book))] +public class BookFilter_NestedObjectImplicit : FilterBase +{ + // Implicit mapping: property name "AuthorModel" matches Book.AuthorModel without [CompareTo] + public AuthorFilterImplicit AuthorModel { get; set; } + + public string Title { get; set; } +} + +// Implicit nested collection mapping tests - property name matches model property without [CompareTo] +public class BookFilterImplicit : FilterBase +{ + public string Title { get; set; } + + [CompareTo(nameof(Book.Year))] + public Range Year { get; set; } +} + +[GenerateApplyFilter(typeof(Author))] +public class AuthorFilter_CollectionImplicit : FilterBase +{ + public string Name { get; set; } + + // Implicit mapping: property name "Books" matches Author.Books without [CompareTo] + public BookFilterImplicit Books { get; set; } +} + +// Deep nested collection filter with All for empty filter semantics tests +public class AuthorNestedFilter_CollectionAll : FilterBase +{ + [CompareTo(nameof(Author.Name))] + public StringFilter Name { get; set; } + + [CompareTo(nameof(Author.Books))] + [CollectionFilter(CollectionFilterType.All)] + public BookNestedFilter Books { get; set; } +} + +[GenerateApplyFilter(typeof(Publisher))] +public class PublisherFilter_NestedCollectionAll : FilterBase +{ + [CompareTo(nameof(Publisher.Name))] + public string Name { get; set; } + + [CompareTo(nameof(Publisher.Authors))] + [CollectionFilter(CollectionFilterType.All)] + public AuthorNestedFilter_CollectionAll Authors { get; set; } +} + +public class Level8Filter : FilterBase +{ + [CompareTo(nameof(Level8.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level8.Score))] + public Range Score { get; set; } +} + +public class Level7Filter : FilterBase +{ + [CompareTo(nameof(Level7.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level7.Children))] + [CollectionFilter(CollectionFilterType.Any)] + public Level8Filter Children { get; set; } +} + +public class Level7Filter_All : FilterBase +{ + [CompareTo(nameof(Level7.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level7.Children))] + [CollectionFilter(CollectionFilterType.All)] + public Level8Filter Children { get; set; } +} + +public class Level6Filter : FilterBase +{ + [CompareTo(nameof(Level6.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level6.Children))] + [CollectionFilter(CollectionFilterType.Any)] + public Level7Filter Children { get; set; } +} + +public class Level6Filter_AllAny : FilterBase +{ + [CompareTo(nameof(Level6.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level6.Children))] + [CollectionFilter(CollectionFilterType.All)] + public Level7Filter_All Children { get; set; } +} + +public class Level5Filter : FilterBase +{ + [CompareTo(nameof(Level5.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level5.Children))] + [CollectionFilter(CollectionFilterType.Any)] + public Level6Filter Children { get; set; } +} + +public class Level5Filter_AllAnyAny : FilterBase +{ + [CompareTo(nameof(Level5.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level5.Children))] + [CollectionFilter(CollectionFilterType.All)] + public Level6Filter_AllAny Children { get; set; } +} + +public class Level4Filter : FilterBase +{ + [CompareTo(nameof(Level4.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level4.Children))] + [CollectionFilter(CollectionFilterType.Any)] + public Level5Filter Children { get; set; } +} + +public class Level4Filter_AllAnyAnyAny : FilterBase +{ + [CompareTo(nameof(Level4.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level4.Children))] + [CollectionFilter(CollectionFilterType.All)] + public Level5Filter_AllAnyAny Children { get; set; } +} + +public class Level3Filter : FilterBase +{ + [CompareTo(nameof(Level3.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level3.Children))] + [CollectionFilter(CollectionFilterType.Any)] + public Level4Filter Children { get; set; } +} + +public class Level3Filter_AllAnyAnyAnyAny : FilterBase +{ + [CompareTo(nameof(Level3.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level3.Children))] + [CollectionFilter(CollectionFilterType.All)] + public Level4Filter_AllAnyAnyAny Children { get; set; } +} + +public class Level2Filter : FilterBase +{ + [CompareTo(nameof(Level2.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level2.Children))] + [CollectionFilter(CollectionFilterType.Any)] + public Level3Filter Children { get; set; } +} + +public class Level2Filter_AllAnyAnyAnyAnyAny : FilterBase +{ + [CompareTo(nameof(Level2.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level2.Children))] + [CollectionFilter(CollectionFilterType.All)] + public Level3Filter_AllAnyAnyAnyAny Children { get; set; } +} + +[GenerateApplyFilter(typeof(Level1))] +public class Level1Filter_AnyAllMixed : FilterBase +{ + [CompareTo(nameof(Level1.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level1.Children))] + [CollectionFilter(CollectionFilterType.Any)] + public Level2Filter Children { get; set; } +} + +[GenerateApplyFilter(typeof(Level1))] +public class Level1Filter_AllAnyAnyAnyAnyAnyAny : FilterBase +{ + [CompareTo(nameof(Level1.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level1.Children))] + [CollectionFilter(CollectionFilterType.All)] + public Level2Filter_AllAnyAnyAnyAnyAny Children { get; set; } +} + +// Alternating Any/All pattern: Any-Any-All-Any-Any-All +public class Level6Filter_AltPattern : FilterBase +{ + [CompareTo(nameof(Level6.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level6.Children))] + [CollectionFilter(CollectionFilterType.All)] + public Level7Filter_All Children { get; set; } +} + +public class Level5Filter_AltPattern : FilterBase +{ + [CompareTo(nameof(Level5.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level5.Children))] + [CollectionFilter(CollectionFilterType.Any)] + public Level6Filter_AltPattern Children { get; set; } +} + +public class Level4Filter_AltPattern : FilterBase +{ + [CompareTo(nameof(Level4.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level4.Children))] + [CollectionFilter(CollectionFilterType.Any)] + public Level5Filter_AltPattern Children { get; set; } +} + +public class Level3Filter_AltPattern : FilterBase +{ + [CompareTo(nameof(Level3.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level3.Children))] + [CollectionFilter(CollectionFilterType.All)] + public Level4Filter_AltPattern Children { get; set; } +} + +public class Level2Filter_AltPattern : FilterBase +{ + [CompareTo(nameof(Level2.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level2.Children))] + [CollectionFilter(CollectionFilterType.Any)] + public Level3Filter_AltPattern Children { get; set; } +} + +[GenerateApplyFilter(typeof(Level1))] +public class Level1Filter_AltPattern : FilterBase +{ + [CompareTo(nameof(Level1.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level1.Children))] + [CollectionFilter(CollectionFilterType.Any)] + public Level2Filter_AltPattern Children { get; set; } +} + +// All-All pattern (all quantifiers are All) +public class Level6Filter_AllAll : FilterBase +{ + [CompareTo(nameof(Level6.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level6.Children))] + [CollectionFilter(CollectionFilterType.All)] + public Level7Filter_All Children { get; set; } +} + +public class Level5Filter_AllAll : FilterBase +{ + [CompareTo(nameof(Level5.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level5.Children))] + [CollectionFilter(CollectionFilterType.All)] + public Level6Filter_AllAll Children { get; set; } +} + +public class Level4Filter_AllAll : FilterBase +{ + [CompareTo(nameof(Level4.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level4.Children))] + [CollectionFilter(CollectionFilterType.All)] + public Level5Filter_AllAll Children { get; set; } +} + +public class Level3Filter_AllAll : FilterBase +{ + [CompareTo(nameof(Level3.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level3.Children))] + [CollectionFilter(CollectionFilterType.All)] + public Level4Filter_AllAll Children { get; set; } +} + +public class Level2Filter_AllAll : FilterBase +{ + [CompareTo(nameof(Level2.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level2.Children))] + [CollectionFilter(CollectionFilterType.All)] + public Level3Filter_AllAll Children { get; set; } +} + +[GenerateApplyFilter(typeof(Level1))] +public class Level1Filter_AllAll : FilterBase +{ + [CompareTo(nameof(Level1.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level1.Children))] + [CollectionFilter(CollectionFilterType.All)] + public Level2Filter_AllAll Children { get; set; } +} + +// Prefix-Any-Suffix-All pattern: Any-Any-Any-Any-All-All-All-All (depth 8) +// This tests the boundary where quantifiers switch from Any to All at a specific depth +public class Level7Filter_PrefixAnySuffixAll : FilterBase +{ + [CompareTo(nameof(Level7.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level7.Children))] + [CollectionFilter(CollectionFilterType.All)] + public Level8Filter Children { get; set; } +} + +public class Level6Filter_PrefixAnySuffixAll : FilterBase +{ + [CompareTo(nameof(Level6.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level6.Children))] + [CollectionFilter(CollectionFilterType.All)] + public Level7Filter_PrefixAnySuffixAll Children { get; set; } +} + +public class Level5Filter_PrefixAnySuffixAll : FilterBase +{ + [CompareTo(nameof(Level5.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level5.Children))] + [CollectionFilter(CollectionFilterType.All)] + public Level6Filter_PrefixAnySuffixAll Children { get; set; } +} + +public class Level4Filter_PrefixAnySuffixAll : FilterBase +{ + [CompareTo(nameof(Level4.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level4.Children))] + [CollectionFilter(CollectionFilterType.All)] + public Level5Filter_PrefixAnySuffixAll Children { get; set; } +} + +public class Level3Filter_PrefixAnySuffixAll : FilterBase +{ + [CompareTo(nameof(Level3.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level3.Children))] + [CollectionFilter(CollectionFilterType.Any)] + public Level4Filter_PrefixAnySuffixAll Children { get; set; } +} + +public class Level2Filter_PrefixAnySuffixAll : FilterBase +{ + [CompareTo(nameof(Level2.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level2.Children))] + [CollectionFilter(CollectionFilterType.Any)] + public Level3Filter_PrefixAnySuffixAll Children { get; set; } +} + +[GenerateApplyFilter(typeof(Level1))] +public class Level1Filter_PrefixAnySuffixAll : FilterBase +{ + [CompareTo(nameof(Level1.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level1.Children))] + [CollectionFilter(CollectionFilterType.Any)] + public Level2Filter_PrefixAnySuffixAll Children { get; set; } +} + +// Sparse flips pattern: Any-Any-All-Any-All-All-Any-All (depth 8) +// This tests irregular quantifier flips across depth +public class Level7Filter_SparseFlips : FilterBase +{ + [CompareTo(nameof(Level7.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level7.Children))] + [CollectionFilter(CollectionFilterType.Any)] + public Level8Filter Children { get; set; } +} + +public class Level6Filter_SparseFlips : FilterBase +{ + [CompareTo(nameof(Level6.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level6.Children))] + [CollectionFilter(CollectionFilterType.All)] + public Level7Filter_SparseFlips Children { get; set; } +} + +public class Level5Filter_SparseFlips : FilterBase +{ + [CompareTo(nameof(Level5.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level5.Children))] + [CollectionFilter(CollectionFilterType.Any)] + public Level6Filter_SparseFlips Children { get; set; } +} + +public class Level4Filter_SparseFlips : FilterBase +{ + [CompareTo(nameof(Level4.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level4.Children))] + [CollectionFilter(CollectionFilterType.All)] + public Level5Filter_SparseFlips Children { get; set; } +} + +public class Level3Filter_SparseFlips : FilterBase +{ + [CompareTo(nameof(Level3.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level3.Children))] + [CollectionFilter(CollectionFilterType.Any)] + public Level4Filter_SparseFlips Children { get; set; } +} + +public class Level2Filter_SparseFlips : FilterBase +{ + [CompareTo(nameof(Level2.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level2.Children))] + [CollectionFilter(CollectionFilterType.Any)] + public Level3Filter_SparseFlips Children { get; set; } +} + +[GenerateApplyFilter(typeof(Level1))] +public class Level1Filter_SparseFlips : FilterBase +{ + [CompareTo(nameof(Level1.Value))] + public StringFilter Value { get; set; } + + [CompareTo(nameof(Level1.Children))] + [CollectionFilter(CollectionFilterType.Any)] + public Level2Filter_SparseFlips Children { get; set; } +} + diff --git a/tests/AutoFilterer.Generators.Tests/Environment/Models/TestModels.cs b/tests/AutoFilterer.Generators.Tests/Environment/Models/TestModels.cs new file mode 100644 index 0000000..781f79e --- /dev/null +++ b/tests/AutoFilterer.Generators.Tests/Environment/Models/TestModels.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; + +namespace AutoFilterer.Generators.Tests.Environment.Models; + +public class Book +{ + public int Id { get; set; } + public string Title { get; set; } + public string Author { get; set; } + public int TotalPage { get; set; } + public int ReadCount { get; set; } + public int Year { get; set; } + public bool IsPublished { get; set; } + public int? Views { get; set; } + + // Navigation property + public int AuthorId { get; set; } + public Author AuthorModel { get; set; } +} + +public class Author +{ + public int Id { get; set; } + public string Name { get; set; } + public string Country { get; set; } + public int? Age { get; set; } + public List Books { get; set; } = new(); +} + +public class Publisher +{ + public int Id { get; set; } + public string Name { get; set; } + public List Authors { get; set; } = new(); +} + +public class Preferences +{ + public Guid UserId { get; set; } + public bool IsTwoFactorEnabled { get; set; } + public string GivenName { get; set; } + public int SecurityLevel { get; set; } + public int? ReadLimit { get; set; } + public Guid? OrganizationUnitId { get; set; } +} + +// Recursive hierarchical models for Phase 2 N-depth mixed quantifier tests +public class Level8 +{ + public int Id { get; set; } + public string Value { get; set; } + public int Score { get; set; } +} + +public class Level7 +{ + public int Id { get; set; } + public string Value { get; set; } + public List Children { get; set; } = new(); +} + +public class Level6 +{ + public int Id { get; set; } + public string Value { get; set; } + public List Children { get; set; } = new(); +} + +public class Level5 +{ + public int Id { get; set; } + public string Value { get; set; } + public List Children { get; set; } = new(); +} + +public class Level4 +{ + public int Id { get; set; } + public string Value { get; set; } + public List Children { get; set; } = new(); +} + +public class Level3 +{ + public int Id { get; set; } + public string Value { get; set; } + public List Children { get; set; } = new(); +} + +public class Level2 +{ + public int Id { get; set; } + public string Value { get; set; } + public List Children { get; set; } = new(); +} + +public class Level1 +{ + public int Id { get; set; } + public string Value { get; set; } + public List Children { get; set; } = new(); +} diff --git a/tests/AutoFilterer.Generators.Tests/FilterGeneratorTests.cs b/tests/AutoFilterer.Generators.Tests/FilterGeneratorTests.cs index ade47e0..cf53180 100644 --- a/tests/AutoFilterer.Generators.Tests/FilterGeneratorTests.cs +++ b/tests/AutoFilterer.Generators.Tests/FilterGeneratorTests.cs @@ -1,70 +1,34 @@ -using AutoFilterer.Tests.Core; +using AutoFilterer.Tests.Core; using AutoFilterer.Extensions; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using AutoFilterer.Generators.Tests.Environment.Dtos; +using AutoFilterer.Generators.Tests.Environment.Models; +using AutoFilterer.Types; using AutoFilterer.Attributes; using AutoFilterer.Enums; using Castle.Core.Internal; using Xunit; +using System; +using System.Collections.Generic; +using System.Linq; -namespace AutoFilterer.Generators.Tests; - -[GenerateAutoFilter] - -public class Book -{ - public string Title { get; set; } - public int? Year { get; set; } - public int TotalPage { get; set; } - public DateTime PublishTime { get; set; } -} - -[GenerateAutoFilter("MyCustomNamespace")] -public class BookInCustomNamespace -{ - public string Title { get; set; } - public int? Year { get; set; } - public int TotalPage { get; set; } - public DateTime PublishTime { get; set; } -} - -public class FilterGeneratorTests +namespace AutoFilterer.Generators.Tests.FilterGenerator_TestClasses { - [Fact] - public void ShouldBookFilterBeCreated() - { - // If there is no compile error. Everything is OK 👍 - Assert.True(typeof(BookFilter) != null); - } - - [Fact] - public void ShouldBookFilterInCustomNamespaceBeCreated() - { - // If there is no compile error. Everything is OK 👍 - Assert.True(typeof(MyCustomNamespace.BookInCustomNamespaceFilter) != null); - } - - [Fact] - public void ShouldTitleBeString() + [GenerateAutoFilter] + public class Book { - var type = typeof(BookFilter); - - Assert.True(type.GetProperty(nameof(Book.Title)).PropertyType == typeof(string)); + public string Title { get; set; } + public int? Year { get; set; } + public int TotalPage { get; set; } + public DateTime PublishTime { get; set; } } - [Theory] - [AutoMoqData] - public void Test(List books) + [GenerateAutoFilter("MyCustomNamespace")] + public class BookInCustomNamespace { - var filter = new BookFilter(); - filter.Page = 1; - filter.PerPage = 2; - filter.Year = new Types.Range(min: 1990, max: 2021); - - books.AsQueryable().ApplyFilter(filter); + public string Title { get; set; } + public int? Year { get; set; } + public int TotalPage { get; set; } + public DateTime PublishTime { get; set; } } [GenerateAutoFilter("MappingTest")] @@ -98,29 +62,415 @@ public class AllTypesTestType public TimeSpan? _TimeSpanN { get; set; } } - [Fact] - public void ShouldCreateEachTypeCorrectFromMapping() + [GenerateAutoFilter("MappingTest")] + public class StringAttributeTestType { - Assert.NotNull(typeof(MappingTest.AllTypesTestTypeFilter)); + public string Title { get; set; } + } - var filter = new MappingTest.AllTypesTestTypeFilter(); + // Test class with expanded type mappings (bool, Guid, DateTimeOffset, enum) + [GenerateAutoFilter("ExpandedMappingTest")] + public class ExpandedTypeTestType + { + public bool IsActive { get; set; } + public bool? IsActiveNullable { get; set; } + public System.Guid Id { get; set; } + public System.Guid? IdNullable { get; set; } + public System.DateTimeOffset CreatedAt { get; set; } + public System.DateTimeOffset? CreatedAtNullable { get; set; } + public TestEnum Status { get; set; } + public TestEnum? StatusNullable { get; set; } } - [GenerateAutoFilter("MappingTest")] - public class StringAttributeTestType + public enum TestEnum { - public string Title { get; set; } + Active, + Inactive, + Pending } - [Fact] - public void ShouldHaveToLowerContainsComparisonAttribute() + // Test class with collection and navigation properties (should be skipped) + [GenerateAutoFilter("NavigationTest")] + public class NavigationTestType { - var attribute = - typeof(MappingTest.StringAttributeTestTypeFilter) - .GetProperty(nameof(MappingTest.StringAttributeTestTypeFilter.Title)) - .GetAttribute(); + public string Name { get; set; } + public int Count { get; set; } + public System.Collections.Generic.List Items { get; set; } + public ComplexType Related { get; set; } + } - Assert.NotNull(attribute); + public class ComplexType + { + public string Property { get; set; } + } + + // Test class with UseStringFilter option + [GenerateAutoFilter(UseStringFilter = true)] + public class UseStringFilterTestType + { + public string Name { get; set; } + public string Description { get; set; } + } + + // Test class with UseRangeForNumbers = false + [GenerateAutoFilter(UseRangeForNumbers = false)] + public class NoRangeForNumbersTestType + { + public int Count { get; set; } + public int? CountNullable { get; set; } + public decimal Price { get; set; } + } + + // Test class with UseRangeForDates = false + [GenerateAutoFilter(UseRangeForDates = false)] + public class NoRangeForDatesTestType + { + public System.DateTime CreatedDate { get; set; } + public System.DateTime? UpdatedDate { get; set; } + public System.DateTimeOffset Timestamp { get; set; } + } + + // Test class with GenerateForEnumProperties = false + [GenerateAutoFilter(GenerateForEnumProperties = false)] + public class NoEnumPropertiesTestType + { + public string Name { get; set; } + public TestEnum Status { get; set; } + public TestEnum? Category { get; set; } + } + + // Test class with custom base class + [GenerateAutoFilter(BaseClass = "FilterBase")] + public class CustomBaseClassTestType + { + public string Name { get; set; } + public int Count { get; set; } + } + + // Test class combining multiple options + [GenerateAutoFilter(UseStringFilter = true, UseRangeForNumbers = false, UseRangeForDates = false)] + public class CombinedOptionsTestType + { + public string Name { get; set; } + public int Count { get; set; } + public System.DateTime Date { get; set; } + public TestEnum Status { get; set; } } } +namespace AutoFilterer.Generators.Tests +{ + public class FilterGeneratorTests + { + [Fact] + public void ShouldBookFilterBeCreated() + { + // If there is no compile error. Everything is OK 👍 + Assert.True(typeof(FilterGenerator_TestClasses.BookFilter) != null); + } + + [Fact] + public void ShouldBookFilterInCustomNamespaceBeCreated() + { + // If there is no compile error. Everything is OK 👍 + Assert.True(typeof(MyCustomNamespace.BookInCustomNamespaceFilter) != null); + } + + [Fact] + public void ShouldTitleBeString() + { + var type = typeof(FilterGenerator_TestClasses.BookFilter); + + Assert.True(type.GetProperty(nameof(FilterGenerator_TestClasses.Book.Title)).PropertyType == typeof(string)); + } + + [Theory] + [AutoMoqData] + public void Test(List books) + { + var filter = new FilterGenerator_TestClasses.BookFilter(); + filter.Page = 1; + filter.PerPage = 2; + filter.Year = new Types.Range(min: 1990, max: 2021); + + books.AsQueryable().ApplyFilter(filter); + } + + [Fact] + public void ShouldCreateEachTypeCorrectFromMapping() + { + Assert.NotNull(typeof(MappingTest.AllTypesTestTypeFilter)); + Assert.IsType(new MappingTest.AllTypesTestTypeFilter()); + } + + [Fact] + public void ShouldHaveToLowerContainsComparisonAttribute() + { + var attribute = + typeof(MappingTest.StringAttributeTestTypeFilter) + .GetProperty(nameof(MappingTest.StringAttributeTestTypeFilter.Title)) + .GetAttribute(); + + Assert.NotNull(attribute); + } + + [Fact] + public void ShouldCreateBookFilter_MultiplePropertyOr() + { + Assert.NotNull(typeof(BookFilter_MultiplePropertyOr)); + } + + [Fact] + public void ShouldCreateBookFilter_MultiplePropertyAnd() + { + Assert.NotNull(typeof(BookFilter_MultiplePropertyAnd)); + } + + [Fact] + public void ShouldCreateBookFilter_TypeCompareTo() + { + Assert.NotNull(typeof(BookFilter_TypeCompareTo)); + } + + [Fact] + public void ShouldCreateBookFilter_MultipleTypeCompareTo() + { + Assert.NotNull(typeof(BookFilter_MultipleTypeCompareTo)); + } + + [Fact] + public void ShouldCreateBookFilter_MultipleTypeCompareToAnd() + { + Assert.NotNull(typeof(BookFilter_MultipleTypeCompareToAnd)); + } + + [Fact] + public void ShouldCreatePreferencesFilter_ArraySearchWithout() + { + Assert.NotNull(typeof(PreferencesFilter_ArraySearchWithout)); + } + + [Fact] + public void ShouldCreatePreferencesFilter_ArraySearchWith() + { + Assert.NotNull(typeof(PreferencesFilter_ArraySearchWith)); + } + + [Fact] + public void ShouldCreatePreferencesFilter_ArraySearchGuidWithout() + { + Assert.NotNull(typeof(PreferencesFilter_ArraySearchGuidWithout)); + } + + [Fact] + public void ShouldCreateBookFilter_StringFilter_Advanced() + { + Assert.NotNull(typeof(BookFilter_StringFilter_Advanced)); + } + + [Fact] + public void ShouldCreateBookFilter_OperatorFilter_Advanced() + { + Assert.NotNull(typeof(BookFilter_OperatorFilter_Advanced)); + } + + [Fact] + public void ShouldCreateBookFilter_Range_MultipleProperty() + { + Assert.NotNull(typeof(BookFilter_Range_MultipleProperty)); + } + + // Expanded type mapping tests + [Fact] + public void ShouldCreateExpandedTypeTestTypeFilter() + { + Assert.NotNull(typeof(ExpandedMappingTest.ExpandedTypeTestTypeFilter)); + } + + [Fact] + public void ShouldHaveBoolPropertyInExpandedTypeFilter() + { + var filter = typeof(ExpandedMappingTest.ExpandedTypeTestTypeFilter); + var isActiveProperty = filter.GetProperty("IsActive"); + Assert.NotNull(isActiveProperty); + Assert.Equal(typeof(bool), isActiveProperty.PropertyType); + } + + [Fact] + public void ShouldHaveGuidPropertyInExpandedTypeFilter() + { + var filter = typeof(ExpandedMappingTest.ExpandedTypeTestTypeFilter); + var idProperty = filter.GetProperty("Id"); + Assert.NotNull(idProperty); + Assert.Equal(typeof(System.Guid), idProperty.PropertyType); + } + + [Fact] + public void ShouldHaveDateTimeOffsetPropertyInExpandedTypeFilter() + { + var filter = typeof(ExpandedMappingTest.ExpandedTypeTestTypeFilter); + var createdAtProperty = filter.GetProperty("CreatedAt"); + Assert.NotNull(createdAtProperty); + // DateTime should use Range + Assert.Equal("Range`1", createdAtProperty.PropertyType.Name); + } + + [Fact] + public void ShouldHaveEnumPropertyInExpandedTypeFilter() + { + var filter = typeof(ExpandedMappingTest.ExpandedTypeTestTypeFilter); + var statusProperty = filter.GetProperty("Status"); + Assert.NotNull(statusProperty); + // Enums should be kept as-is + Assert.Equal(typeof(FilterGenerator_TestClasses.TestEnum), statusProperty.PropertyType); + } + + // Navigation property tests + [Fact] + public void ShouldCreateNavigationTestTypeFilter() + { + Assert.NotNull(typeof(NavigationTest.NavigationTestTypeFilter)); + } + + [Fact] + public void ShouldNotHaveCollectionPropertyInNavigationTestTypeFilter() + { + var filter = typeof(NavigationTest.NavigationTestTypeFilter); + var itemsProperty = filter.GetProperty("Items"); + Assert.Null(itemsProperty); + } + + [Fact] + public void ShouldNotHaveNavigationPropertyInNavigationTestTypeFilter() + { + var filter = typeof(NavigationTest.NavigationTestTypeFilter); + var relatedProperty = filter.GetProperty("Related"); + Assert.Null(relatedProperty); + } + + // UseStringFilter option tests + [Fact] + public void ShouldCreateUseStringFilterTestTypeFilter() + { + Assert.NotNull(typeof(FilterGenerator_TestClasses.UseStringFilterTestTypeFilter)); + } + + [Fact] + public void ShouldHaveStringFilterPropertyInUseStringFilterTestTypeFilter() + { + var filter = typeof(FilterGenerator_TestClasses.UseStringFilterTestTypeFilter); + var nameProperty = filter.GetProperty("Name"); + Assert.NotNull(nameProperty); + Assert.Equal(typeof(StringFilter), nameProperty.PropertyType); + } + + // UseRangeForNumbers option tests + [Fact] + public void ShouldCreateNoRangeForNumbersTestTypeFilter() + { + Assert.NotNull(typeof(FilterGenerator_TestClasses.NoRangeForNumbersTestTypeFilter)); + } + + [Fact] + public void ShouldHaveIntInsteadOfRangeWhenUseRangeForNumbersIsFalse() + { + var filter = typeof(FilterGenerator_TestClasses.NoRangeForNumbersTestTypeFilter); + var countProperty = filter.GetProperty("Count"); + Assert.NotNull(countProperty); + Assert.Equal(typeof(int), countProperty.PropertyType); + } + + // UseRangeForDates option tests + [Fact] + public void ShouldCreateNoRangeForDatesTestTypeFilter() + { + Assert.NotNull(typeof(FilterGenerator_TestClasses.NoRangeForDatesTestTypeFilter)); + } + + [Fact] + public void ShouldHaveDateTimeInsteadOfRangeWhenUseRangeForDatesIsFalse() + { + var filter = typeof(FilterGenerator_TestClasses.NoRangeForDatesTestTypeFilter); + var createdDateProperty = filter.GetProperty("CreatedDate"); + Assert.NotNull(createdDateProperty); + Assert.Equal(typeof(System.DateTime), createdDateProperty.PropertyType); + } + + // GenerateForEnumProperties option tests + [Fact] + public void ShouldCreateNoEnumPropertiesTestTypeFilter() + { + Assert.NotNull(typeof(FilterGenerator_TestClasses.NoEnumPropertiesTestTypeFilter)); + } + + [Fact] + public void ShouldNotHaveEnumPropertyWhenGenerateForEnumPropertiesIsFalse() + { + var filter = typeof(FilterGenerator_TestClasses.NoEnumPropertiesTestTypeFilter); + var statusProperty = filter.GetProperty("Status"); + Assert.Null(statusProperty); + } + + // Custom base class tests + [Fact] + public void ShouldCreateCustomBaseClassTestTypeFilter() + { + Assert.NotNull(typeof(FilterGenerator_TestClasses.CustomBaseClassTestTypeFilter)); + } + + [Fact] + public void ShouldHaveCustomBaseClass() + { + var filter = typeof(FilterGenerator_TestClasses.CustomBaseClassTestTypeFilter); + Assert.Equal(typeof(FilterBase), filter.BaseType); + } + + // Combined options tests + [Fact] + public void ShouldCreateCombinedOptionsTestTypeFilter() + { + Assert.NotNull(typeof(FilterGenerator_TestClasses.CombinedOptionsTestTypeFilter)); + } + + [Fact] + public void ShouldRespectAllCombinedOptions() + { + var filter = typeof(FilterGenerator_TestClasses.CombinedOptionsTestTypeFilter); + + // String should be StringFilter + var nameProperty = filter.GetProperty("Name"); + Assert.Equal(typeof(StringFilter), nameProperty.PropertyType); + + // int should be int (not Range) + var countProperty = filter.GetProperty("Count"); + Assert.Equal(typeof(int), countProperty.PropertyType); + + // DateTime should be DateTime (not Range) + var dateProperty = filter.GetProperty("Date"); + Assert.Equal(typeof(System.DateTime), dateProperty.PropertyType); + + // Enum should be present + var statusProperty = filter.GetProperty("Status"); + Assert.NotNull(statusProperty); + } + + // Backward compatibility tests + [Fact] + public void DefaultUseRangeForNumbers_ShouldUseRange() + { + var filter = typeof(FilterGenerator_TestClasses.BookFilter); + var yearProperty = filter.GetProperty(nameof(FilterGenerator_TestClasses.Book.Year)); + Assert.NotNull(yearProperty); + Assert.Equal("Range`1", yearProperty.PropertyType.Name); + } + + [Fact] + public void DefaultUseStringFilter_ShouldUseStringType() + { + var filter = typeof(FilterGenerator_TestClasses.BookFilter); + var titleProperty = filter.GetProperty(nameof(FilterGenerator_TestClasses.Book.Title)); + Assert.NotNull(titleProperty); + Assert.Equal(typeof(string), titleProperty.PropertyType); + } + } +} diff --git a/tests/AutoFilterer.Generators.Tests/GeneratedFilterInvoker.cs b/tests/AutoFilterer.Generators.Tests/GeneratedFilterInvoker.cs new file mode 100644 index 0000000..2efd074 --- /dev/null +++ b/tests/AutoFilterer.Generators.Tests/GeneratedFilterInvoker.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Reflection; + +namespace AutoFilterer.Generators.Tests; + +internal static class GeneratedFilterInvoker +{ + private static readonly ConcurrentDictionary<(Type EntityType, Type FilterType), MethodInfo> applyFilterCache = new(); + private static readonly ConcurrentDictionary methodCache = new(); + + public static IQueryable ApplyFilter(IQueryable source, object filter) + { + if (filter == null) + { + return source; + } + + var method = applyFilterCache.GetOrAdd((typeof(TEntity), filter.GetType()), key => ResolveApplyFilterMethod(key.EntityType, key.FilterType)); + return (IQueryable)method.Invoke(null, new[] { source, filter }); + } + + public static MethodInfo GetGeneratedApplyFilterMethod(Type filterType) + { + return methodCache.GetOrAdd(filterType, ResolveApplyFilterMethod); + } + + private static MethodInfo ResolveApplyFilterMethod(Type filterType) + { + var method = filterType.Assembly + .GetTypes() + .Where(t => t.IsSealed && t.IsAbstract) + .SelectMany(t => t.GetMethods(BindingFlags.Public | BindingFlags.Static)) + .FirstOrDefault(m => + { + if (m.Name != "ApplyFilter") + { + return false; + } + + var parameters = m.GetParameters(); + return parameters.Length == 2 && parameters[1].ParameterType == filterType; + }); + + if (method == null) + { + throw new InvalidOperationException($"Generated ApplyFilter overload not found for '{filterType.FullName}'."); + } + + return method; + } + + private static MethodInfo ResolveApplyFilterMethod(Type entityType, Type filterType) + { + var method = GetGeneratedApplyFilterMethod(filterType); + var parameters = method.GetParameters(); + + if (!parameters[0].ParameterType.IsGenericType || parameters[0].ParameterType.GetGenericTypeDefinition() != typeof(IQueryable<>) || parameters[0].ParameterType.GetGenericArguments()[0] != entityType) + { + throw new InvalidOperationException($"Generated ApplyFilter overload for '{filterType.FullName}' does not target '{entityType.FullName}'."); + } + + return method; + } +} diff --git a/tests/AutoFilterer.Generators.Tests/GeneratedMethodInspector.cs b/tests/AutoFilterer.Generators.Tests/GeneratedMethodInspector.cs new file mode 100644 index 0000000..9c9b565 --- /dev/null +++ b/tests/AutoFilterer.Generators.Tests/GeneratedMethodInspector.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Reflection.Emit; + +namespace AutoFilterer.Generators.Tests; + +internal static class GeneratedMethodInspector +{ + private static readonly OpCode[] oneByteOpCodes = new OpCode[0x100]; + private static readonly OpCode[] twoByteOpCodes = new OpCode[0x100]; + + static GeneratedMethodInspector() + { + foreach (var field in typeof(OpCodes).GetFields(BindingFlags.Public | BindingFlags.Static)) + { + if (field.GetValue(null) is not OpCode opCode) + { + continue; + } + + var value = unchecked((ushort)opCode.Value); + if (value < 0x100) + { + oneByteOpCodes[value] = opCode; + } + else if ((value & 0xff00) == 0xfe00) + { + twoByteOpCodes[value & 0xff] = opCode; + } + } + } + + public static bool CallsMethod(Type filterType, string methodName) + { + return GetCalledMethods(GeneratedFilterInvoker.GetGeneratedApplyFilterMethod(filterType)) + .Any(method => method.Name == methodName); + } + + private static IEnumerable GetCalledMethods(MethodInfo method) + { + var body = method.GetMethodBody(); + if (body == null) + { + yield break; + } + + var il = body.GetILAsByteArray(); + var offset = 0; + + while (offset < il.Length) + { + var opCode = ReadOpCode(il, ref offset); + + if (opCode.OperandType == OperandType.InlineMethod) + { + var token = ReadInt32(il, ref offset); + MethodBase resolvedMethod; + + try + { + resolvedMethod = method.Module.ResolveMethod(token, method.DeclaringType?.GetGenericArguments(), method.GetGenericArguments()); + } + catch + { + continue; + } + + yield return resolvedMethod; + continue; + } + + offset += GetOperandSize(opCode.OperandType, il, offset); + } + } + + private static OpCode ReadOpCode(byte[] il, ref int offset) + { + var value = il[offset++]; + if (value != 0xfe) + { + return oneByteOpCodes[value]; + } + + return twoByteOpCodes[il[offset++]]; + } + + private static int ReadInt32(byte[] il, ref int offset) + { + var value = BitConverter.ToInt32(il, offset); + offset += 4; + return value; + } + + private static int GetOperandSize(OperandType operandType, byte[] il, int offset) + { + return operandType switch + { + OperandType.InlineNone => 0, + OperandType.ShortInlineBrTarget => 1, + OperandType.ShortInlineI => 1, + OperandType.ShortInlineVar => 1, + OperandType.InlineVar => 2, + OperandType.InlineI => 4, + OperandType.InlineBrTarget => 4, + OperandType.InlineField => 4, + OperandType.InlineI8 => 8, + OperandType.InlineR => 8, + OperandType.InlineSig => 4, + OperandType.InlineString => 4, + OperandType.InlineTok => 4, + OperandType.InlineType => 4, + OperandType.InlineSwitch => 4 + (BitConverter.ToInt32(il, offset) * 4), + OperandType.ShortInlineR => 4, + OperandType.InlineMethod => 0, + _ => throw new ArgumentOutOfRangeException(nameof(operandType), operandType, null) + }; + } +} diff --git a/tests/AutoFilterer.Generators.Tests/GeneratorAotPathTests.cs b/tests/AutoFilterer.Generators.Tests/GeneratorAotPathTests.cs new file mode 100644 index 0000000..032c780 --- /dev/null +++ b/tests/AutoFilterer.Generators.Tests/GeneratorAotPathTests.cs @@ -0,0 +1,26 @@ +using AutoFilterer.Generators.Tests.Environment.Dtos; +using System; +using System.Collections.Generic; +using Xunit; + +namespace AutoFilterer.Generators.Tests; + +public class GeneratorAotPathTests +{ + public static IEnumerable ReflectionFreeFilterTypes() + { + yield return new object[] { typeof(BookFilter_Basic) }; + yield return new object[] { typeof(BookFilter_Range) }; + yield return new object[] { typeof(BookFilter_StringFilter_Advanced) }; + yield return new object[] { typeof(BookFilter_OperatorFilter_Advanced) }; + yield return new object[] { typeof(BookFilter_OrderableEdge) }; + yield return new object[] { typeof(Level1Filter_AllAnyAnyAnyAnyAnyAny) }; + } + + [Theory] + [MemberData(nameof(ReflectionFreeFilterTypes))] + public void GeneratedApplyFilter_DoesNotCallReflectionFallback(Type filterType) + { + Assert.False(GeneratedMethodInspector.CallsMethod(filterType, "ApplyFilterTo")); + } +} diff --git a/tests/AutoFilterer.Generators.Tests/GeneratorParityBaselineTests.cs b/tests/AutoFilterer.Generators.Tests/GeneratorParityBaselineTests.cs new file mode 100644 index 0000000..f90b955 --- /dev/null +++ b/tests/AutoFilterer.Generators.Tests/GeneratorParityBaselineTests.cs @@ -0,0 +1,479 @@ +using AutoFilterer.Extensions; +using AutoFilterer.Generators.Tests.Environment.Dtos; +using AutoFilterer.Generators.Tests.Environment.Models; +using AutoFilterer.Types; +using AutoFilterer.Attributes; +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace AutoFilterer.Generators.Tests; + +/// +/// Parity baseline tests - verify that source-generated ApplyFilter +/// matches runtime FilterBase.BuildExpression() behavior for key scenarios. +/// +public class GeneratorParityBaselineTests +{ + #region CollectionFilterType.All Parity + + [Fact] + public void Parity_CollectionFilterType_All_NonEmptyCollection_MatchesRuntime() + { + // Arrange - authors with books, filter requires ALL books to match criteria + var authors = new[] + { + new Author + { + Id = 1, + Name = "Author A", + Books = new List + { + new Book { Title = "Book 1", Year = 2000, TotalPage = 300 }, + new Book { Title = "Book 2", Year = 2001, TotalPage = 310 }, + new Book { Title = "Book 3", Year = 2002, TotalPage = 320 } + } + }, + new Author + { + Id = 2, + Name = "Author B", + Books = new List + { + new Book { Title = "Book 4", Year = 1999, TotalPage = 280 }, + new Book { Title = "Book 5", Year = 2005, TotalPage = 400 } + } + }, + new Author + { + Id = 3, + Name = "Author C", + Books = new List + { + new Book { Title = "Book 6", Year = 2000, TotalPage = 305 } + } + } + }; + + var filter = new AuthorFilter_CollectionAll + { + Books = new BookNestedFilter + { + Year = new Range { Min = 2000 } + } + }; + + // Act - generator version + var generatorResult = authors.AsQueryable().ApplyFilter(filter).ToList(); + + // Assert - expected runtime behavior: ALL books must have Year >= 2000 + Assert.Equal(2, generatorResult.Count); + Assert.Contains(generatorResult, a => a.Name == "Author A"); + Assert.Contains(generatorResult, a => a.Name == "Author C"); + Assert.DoesNotContain(generatorResult, a => a.Name == "Author B"); // Has a book from 1999 + } + + [Fact] + public void Parity_CollectionFilterType_All_EmptyCollection_VacuousTruth() + { + // Arrange - author with empty book collection + var authors = new[] + { + new Author + { + Id = 1, + Name = "Author With No Books", + Books = new List() + }, + new Author + { + Id = 2, + Name = "Author With Books", + Books = new List + { + new Book { Title = "Book 1", Year = 2000, TotalPage = 300 } + } + } + }; + + var filter = new AuthorFilter_CollectionAll + { + Books = new BookNestedFilter + { + Year = new Range { Min = 2000 } + } + }; + + // Act - generator version + var generatorResult = authors.AsQueryable().ApplyFilter(filter).ToList(); + + // Assert - vacuous truth: empty collection satisfies ALL condition + Assert.Equal(2, generatorResult.Count); // Both authors match + Assert.Contains(generatorResult, a => a.Name == "Author With No Books"); + } + + [Fact] + public void Parity_CollectionFilterType_All_MultipleFilters_AllMustMatch() + { + // Arrange + var authors = new[] + { + new Author + { + Id = 1, + Name = "Author A", + Books = new List + { + new Book { Title = "Clean", Year = 2000, TotalPage = 300 }, + new Book { Title = "Clean", Year = 2001, TotalPage = 310 } + } + }, + new Author + { + Id = 2, + Name = "Author B", + Books = new List + { + new Book { Title = "Dirty", Year = 2000, TotalPage = 300 } + } + } + }; + + var filter = new AuthorFilter_CollectionAll + { + Books = new BookNestedFilter + { + Title = new StringFilter { StartsWith = "Clean" }, + Year = new Range { Min = 2000 } + } + }; + + // Act + var generatorResult = authors.AsQueryable().ApplyFilter(filter).ToList(); + + // Assert - ALL books must have Title starting with "Clean" AND Year >= 2000 + Assert.Single(generatorResult); + Assert.Equal("Author A", generatorResult[0].Name); + } + + #endregion + + #region StringFilter.Compare Parity + + [Fact] + public void Parity_StringFilter_Compare_Ordinal_MatchesRuntime() + { + // Arrange - case-sensitive matching with Ordinal + var books = new[] + { + new Book { Id = 1, Title = "ABC", Author = "John" }, + new Book { Id = 2, Title = "abc", Author = "Jane" }, + new Book { Id = 3, Title = "Abc", Author = "Bob" }, + new Book { Id = 4, Title = "aBc", Author = "Alice" } + }; + + var filter = new BookFilter_StringFilter_Advanced + { + Title = new StringFilter + { + Compare = StringComparison.Ordinal, + Contains = "AB" + } + }; + + // Act + var generatorResult = books.AsQueryable().ApplyFilter(filter).ToList(); + + // Assert - only exact case match + Assert.Single(generatorResult); + Assert.Equal("ABC", generatorResult[0].Title); + } + + [Fact] + public void Parity_StringFilter_Compare_OrdinalIgnoreCase_MatchesRuntime() + { + // Arrange - case-insensitive matching + var books = new[] + { + new Book { Id = 1, Title = "ABC", Author = "John" }, + new Book { Id = 2, Title = "abc", Author = "Jane" }, + new Book { Id = 3, Title = "Abc", Author = "Bob" }, + new Book { Id = 4, Title = "XYZ", Author = "Alice" } + }; + + var filter = new BookFilter_StringFilter_Advanced + { + Title = new StringFilter + { + Compare = StringComparison.OrdinalIgnoreCase, + Contains = "AB" + } + }; + + // Act + var generatorResult = books.AsQueryable().ApplyFilter(filter).ToList(); + + // Assert - all case variations match + Assert.Equal(3, generatorResult.Count); + Assert.DoesNotContain(generatorResult, b => b.Title == "XYZ"); + } + + [Fact] + public void Parity_StringFilter_Compare_InvariantCulture_MatchesRuntime() + { + // Arrange - culture-sensitive comparison + var books = new[] + { + new Book { Id = 1, Title = "café", Author = "Pierre" }, + new Book { Id = 2, Title = "cafe", Author = "John" }, + new Book { Id = 3, Title = "CAFÉ", Author = "Marie" } + }; + + var filter = new BookFilter_StringFilter_Advanced + { + Title = new StringFilter + { + Compare = StringComparison.InvariantCultureIgnoreCase, + Contains = "caf" + } + }; + + // Act + var generatorResult = books.AsQueryable().ApplyFilter(filter).ToList(); + + // Assert - accent-insensitive matching for InvariantCultureIgnoreCase + Assert.Equal(3, generatorResult.Count); + } + + #endregion + + #region Scalar String Empty-String Parity + + [Fact] + public void Parity_ScalarString_EmptyString_MatchesExactEmpty() + { + // Arrange - filter for empty string value + var books = new[] + { + new Book { Id = 1, Title = "", Author = "Empty Title" }, + new Book { Id = 2, Title = null, Author = "Null Title" }, + new Book { Id = 3, Title = "NonEmpty", Author = "Has Content" }, + new Book { Id = 4, Title = " ", Author = "Spaces Only" } + }; + + var filter = new BookFilter_Basic { Title = "" }; + + // Act + var generatorResult = books.AsQueryable().ApplyFilter(filter).ToList(); + + // Assert - only exact empty string matches, not null or whitespace + Assert.Single(generatorResult); + Assert.Equal("", generatorResult[0].Title); + } + + [Fact] + public void Parity_ScalarString_NullVsEmptyString_DifferentBehavior() + { + // Arrange - verify null and empty are treated differently + var books = new[] + { + new Book { Id = 1, Title = "", Author = "Empty" }, + new Book { Id = 2, Title = null, Author = "Null" } + }; + + var filterEmpty = new BookFilter_Basic { Title = "" }; + var filterNull = new BookFilter_Basic { Title = null }; + + // Act + var resultEmpty = books.AsQueryable().ApplyFilter(filterEmpty).ToList(); + var resultNull = books.AsQueryable().ApplyFilter(filterNull).ToList(); + + // Assert + Assert.Single(resultEmpty); + Assert.Equal("", resultEmpty[0].Title); + + Assert.Single(resultNull); + Assert.Null(resultNull[0].Title); + } + + #endregion + + #region Nullable Range Parity + + [Fact] + public void Parity_NullableRange_NullValuesHandledCorrectly() + { + // Arrange - mix of null and non-null Views values + var books = new[] + { + new Book { Id = 1, Title = "Book 1", Views = 100 }, + new Book { Id = 2, Title = "Book 2", Views = 200 }, + new Book { Id = 3, Title = "Book 3", Views = null }, + new Book { Id = 4, Title = "Book 4", Views = 150 }, + new Book { Id = 5, Title = "Book 5", Views = null } + }; + + var filter = new BookFilter_OperatorFilter_Advanced + { + Views = new OperatorFilter { Gte = 150 } + }; + + // Act + var generatorResult = books.AsQueryable().ApplyFilter(filter).ToList(); + + // Assert - null values should be excluded, only non-null >= 150 included + Assert.Equal(2, generatorResult.Count); + Assert.All(generatorResult, b => Assert.True(b.Views.HasValue && b.Views.Value >= 150)); + } + + [Fact] + public void Parity_NullableRange_IsNullOperator_MatchesNulls() + { + // Arrange + var books = new[] + { + new Book { Id = 1, Title = "Book 1", Views = 100 }, + new Book { Id = 2, Title = "Book 2", Views = null }, + new Book { Id = 3, Title = "Book 3", Views = 200 } + }; + + var filter = new BookFilter_OperatorFilter_Advanced + { + Views = new OperatorFilter { IsNull = true } + }; + + // Act + var generatorResult = books.AsQueryable().ApplyFilter(filter).ToList(); + + // Assert - only null values match + Assert.Single(generatorResult); + Assert.Null(generatorResult[0].Views); + } + + [Fact] + public void Parity_NullableRange_RangeFilter_NullValueInSource_Ignored() + { + // Arrange - Range filter on nullable property (Views is int?) + var books = new[] + { + new Book { Id = 1, Title = "Book 1", Views = 2000 }, + new Book { Id = 2, Title = "Book 2", Views = null }, + new Book { Id = 3, Title = "Book 3", Views = 2010 } + }; + + var filter = new BookFilter_OperatorFilter_Advanced + { + Views = new OperatorFilter { Gte = 1999, Lte = 2005 } + }; + + // Act + var generatorResult = books.AsQueryable().ApplyFilter(filter).ToList(); + + // Assert - null Views values should be filtered out + Assert.Single(generatorResult); + Assert.Equal(2000, generatorResult[0].Views); + } + + #endregion + + #region Guid Array Runtime Parity + + [Fact] + public void Parity_GuidArray_NullableProperty_MatchesAnyValue() + { + // Arrange - test Guid array against nullable Guid property + var preferences = new[] + { + new Preferences + { + UserId = Guid.Parse("11111111-1111-1111-1111-111111111111"), + OrganizationUnitId = Guid.Parse("AAAA0000-AAAA-0000-AAAA-000000000001"), + GivenName = "Alice" + }, + new Preferences + { + UserId = Guid.Parse("22222222-2222-2222-2222-222222222222"), + OrganizationUnitId = null, // Nullable - should still match if in array + GivenName = "Bob" + }, + new Preferences + { + UserId = Guid.Parse("33333333-3333-3333-3333-333333333333"), + OrganizationUnitId = Guid.Parse("BBBB0000-BBBB-0000-BBBB-000000000002"), + GivenName = "Charlie" + } + }; + + var filter = new PreferencesFilter_ArraySearchGuidWithout + { + OrganizationUnitId = new Guid?[] + { + Guid.Parse("AAAA0000-AAAA-0000-AAAA-000000000001"), + Guid.Parse("BBBB0000-BBBB-0000-BBBB-000000000002"), + null // Include null in search array + } + }; + + // Act + var generatorResult = preferences.AsQueryable().ApplyFilter(filter).ToList(); + + // Assert - should match Alice, Bob (null), and Charlie + Assert.Equal(3, generatorResult.Count); + } + + [Fact] + public void Parity_GuidArray_EmptyArray_NoMatches() + { + // Arrange - empty search array should return no results + var preferences = new[] + { + new Preferences + { + UserId = Guid.Parse("11111111-1111-1111-1111-111111111111"), + OrganizationUnitId = Guid.Parse("AAAA0000-AAAA-0000-AAAA-000000000001"), + GivenName = "Alice" + } + }; + + var filter = new PreferencesFilter_ArraySearchGuidWithout + { + OrganizationUnitId = Array.Empty() + }; + + // Act + var generatorResult = preferences.AsQueryable().ApplyFilter(filter).ToList(); + + // Assert - empty array matches nothing + Assert.Empty(generatorResult); + } + + #endregion + + #region Invalid Metadata/Runtime Error Parity + + [Fact] + public void Parity_InvalidCompareToProperty_ThrowsOrIgnoresBasedOnFlag() + { + // Arrange - filter referencing non-existent property + var books = new[] + { + new Book { Id = 1, Title = "Test", Author = "John" } + }; + + var filter = new BookFilter_InvalidTarget + { + Search = "value" + }; + + // Act & Assert + // With IgnoreExceptions=true (default), should silently ignore invalid property + // This test verifies the generator doesn't crash, even if it produces no matches + var generatorResult = books.AsQueryable().ApplyFilter(filter).ToList(); + Assert.NotNull(generatorResult); + // Either all or none may match depending on generator handling + // Key point: no exception thrown + } + + #endregion +} diff --git a/tests/AutoFilterer.Generators.Tests/GeneratorParityFinalBacklogTests.cs b/tests/AutoFilterer.Generators.Tests/GeneratorParityFinalBacklogTests.cs new file mode 100644 index 0000000..9c2b0d2 --- /dev/null +++ b/tests/AutoFilterer.Generators.Tests/GeneratorParityFinalBacklogTests.cs @@ -0,0 +1,1683 @@ +using AutoFilterer.Enums; +using AutoFilterer.Extensions; +using AutoFilterer.Generators.Tests.Environment.Dtos; +using AutoFilterer.Generators.Tests.Environment.Models; +using AutoFilterer.Types; +using Author = AutoFilterer.Generators.Tests.Environment.Models.Author; +using Publisher = AutoFilterer.Generators.Tests.Environment.Models.Publisher; +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace AutoFilterer.Generators.Tests; + +/// +/// Final strict parity tests covering the remaining backlog gaps: +/// 1) CompareTo multi-target OR semantics +/// 2) int[] ArraySearch parity with and without attribute +/// 3) single typed CompareTo(typeof(...)) parity +/// 4) StringFilter.NotContains + Compare mode +/// 5) stronger non-nullable Range bound parity with meaningful dataset +/// +public class GeneratorParityFinalBacklogTests +{ + #region CompareTo Multi-Target OR Semantics + + [Fact] + public void Parity_CompareToMultipleTargets_OR_Semantics_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "Clean Code", Author = "Robert Martin" }, + new Book { Id = 2, Title = "Clean Architecture", Author = "Other Author" }, + new Book { Id = 3, Title = "Code Complete", Author = "Steve McConnell" }, + new Book { Id = 4, Title = "Refactoring", Author = "Martin Fowler" }, + new Book { Id = 5, Title = "Book Title", Author = "Martin Smith" } + }; + + var filter = new BookFilter_MultiplePropertyOr { Query = "Martin" }; + AssertParity(books, filter); + } + + [Fact] + public void Parity_CompareToMultipleTargets_OR_MatchesWhenAnyTargetMatches() + { + var books = new[] + { + new Book { Id = 1, Title = "Martin's Book", Author = "Other" }, + new Book { Id = 2, Title = "Book Title", Author = "Martin Smith" }, + new Book { Id = 3, Title = "Neither", Author = "Matches" }, + new Book { Id = 4, Title = "Martin Book", Author = "Martin" } + }; + + var filter = new BookFilter_MultiplePropertyOr { Query = "Martin" }; + AssertParity(books, filter); + } + + [Fact] + public void Parity_CompareToMultipleTargets_OR_CaseSensitivity_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "CLEAN code", Author = "John" }, + new Book { Id = 2, Title = "clean CODE", Author = "Jane" }, + new Book { Id = 3, Title = "Other", Author = "CLEAN" }, + new Book { Id = 4, Title = "None", Author = "Other" } + }; + + var filter = new BookFilter_MultiplePropertyOr { Query = "clean" }; + AssertParity(books, filter); + } + + #endregion + + #region int[] ArraySearch Parity (With and Without Attribute) + + [Fact] + public void Parity_IntArray_WithoutAttribute_MatchesRuntime() + { + var preferences = new[] + { + new Preferences { UserId = Guid.Parse("11111111-1111-1111-1111-111111111111"), SecurityLevel = 1, GivenName = "Alice" }, + new Preferences { UserId = Guid.Parse("22222222-2222-2222-2222-222222222222"), SecurityLevel = 2, GivenName = "Bob" }, + new Preferences { UserId = Guid.Parse("33333333-3333-3333-3333-333333333333"), SecurityLevel = 3, GivenName = "Charlie" }, + new Preferences { UserId = Guid.Parse("44444444-4444-4444-4444-444444444444"), SecurityLevel = 4, GivenName = "Diana" } + }; + + var filter = new PreferencesFilter_ArraySearchWithout + { + SecurityLevel = new[] { 1, 3 } + }; + + AssertParityPreferences(preferences, filter); + } + + [Fact] + public void Parity_IntArray_WithAttribute_MatchesRuntime() + { + var preferences = new[] + { + new Preferences { UserId = Guid.Parse("11111111-1111-1111-1111-111111111111"), SecurityLevel = 1, GivenName = "Alice" }, + new Preferences { UserId = Guid.Parse("22222222-2222-2222-2222-222222222222"), SecurityLevel = 2, GivenName = "Bob" }, + new Preferences { UserId = Guid.Parse("33333333-3333-3333-3333-333333333333"), SecurityLevel = 3, GivenName = "Charlie" }, + new Preferences { UserId = Guid.Parse("44444444-4444-4444-4444-444444444444"), SecurityLevel = 4, GivenName = "Diana" } + }; + + var filter = new PreferencesFilter_ArraySearchWith + { + SecurityLevel = new[] { 1, 3 } + }; + + AssertParityPreferences(preferences, filter); + } + + [Fact] + public void Parity_IntArray_WithAndWithoutAttribute_SameBehavior() + { + var preferences = new[] + { + new Preferences { UserId = Guid.Parse("11111111-1111-1111-1111-111111111111"), SecurityLevel = 5, GivenName = "Alice" }, + new Preferences { UserId = Guid.Parse("22222222-2222-2222-2222-222222222222"), SecurityLevel = 10, GivenName = "Bob" }, + new Preferences { UserId = Guid.Parse("33333333-3333-3333-3333-333333333333"), SecurityLevel = 15, GivenName = "Charlie" }, + new Preferences { UserId = Guid.Parse("44444444-4444-4444-4444-444444444444"), SecurityLevel = 20, GivenName = "Diana" } + }; + + var filterWithout = new PreferencesFilter_ArraySearchWithout { SecurityLevel = new[] { 10, 20 } }; + var filterWith = new PreferencesFilter_ArraySearchWith { SecurityLevel = new[] { 10, 20 } }; + + var runtimeWithout = ExecutePreferences(() => filterWithout.ApplyFilterTo(preferences.AsQueryable()).ToList()); + var generatedWithout = ExecutePreferences(() => GeneratedFilterInvoker.ApplyFilter(preferences.AsQueryable(), filterWithout).ToList()); + + var runtimeWith = ExecutePreferences(() => filterWith.ApplyFilterTo(preferences.AsQueryable()).ToList()); + var generatedWith = ExecutePreferences(() => GeneratedFilterInvoker.ApplyFilter(preferences.AsQueryable(), filterWith).ToList()); + + Assert.Equal(runtimeWithout.Result.Select(x => x.UserId), generatedWithout.Result.Select(x => x.UserId)); + Assert.Equal(runtimeWith.Result.Select(x => x.UserId), generatedWith.Result.Select(x => x.UserId)); + Assert.Equal(runtimeWithout.Result.Select(x => x.UserId), runtimeWith.Result.Select(x => x.UserId)); + } + + [Fact] + public void Parity_IntArray_EmptyArray_MatchesRuntime() + { + var preferences = new[] + { + new Preferences { UserId = Guid.Parse("11111111-1111-1111-1111-111111111111"), SecurityLevel = 1, GivenName = "Alice" } + }; + + var filterWithout = new PreferencesFilter_ArraySearchWithout { SecurityLevel = Array.Empty() }; + var filterWith = new PreferencesFilter_ArraySearchWith { SecurityLevel = Array.Empty() }; + + var runtimeWithout = ExecutePreferences(() => filterWithout.ApplyFilterTo(preferences.AsQueryable()).ToList()); + var generatedWithout = ExecutePreferences(() => GeneratedFilterInvoker.ApplyFilter(preferences.AsQueryable(), filterWithout).ToList()); + + var runtimeWith = ExecutePreferences(() => filterWith.ApplyFilterTo(preferences.AsQueryable()).ToList()); + var generatedWith = ExecutePreferences(() => GeneratedFilterInvoker.ApplyFilter(preferences.AsQueryable(), filterWith).ToList()); + + Assert.Empty(runtimeWithout.Result); + Assert.Empty(generatedWithout.Result); + Assert.Empty(runtimeWith.Result); + Assert.Empty(generatedWith.Result); + } + + [Fact] + public void Parity_IntArray_MultipleValues_MatchesAny() + { + var preferences = new[] + { + new Preferences { UserId = Guid.Parse("11111111-1111-1111-1111-111111111111"), SecurityLevel = 1, GivenName = "Alice" }, + new Preferences { UserId = Guid.Parse("22222222-2222-2222-2222-222222222222"), SecurityLevel = 5, GivenName = "Bob" }, + new Preferences { UserId = Guid.Parse("33333333-3333-3333-3333-333333333333"), SecurityLevel = 10, GivenName = "Charlie" }, + new Preferences { UserId = Guid.Parse("44444444-4444-4444-4444-444444444444"), SecurityLevel = 15, GivenName = "Diana" }, + new Preferences { UserId = Guid.Parse("55555555-5555-5555-5555-555555555555"), SecurityLevel = 20, GivenName = "Eve" } + }; + + var filter = new PreferencesFilter_ArraySearchWith { SecurityLevel = new[] { 5, 15, 25 } }; + + AssertParityPreferences(preferences, filter); + } + + #endregion + + #region Single Typed CompareTo(typeof(...)) Parity + + [Fact] + public void Parity_SingleTypedCompareTo_ToLowerContains_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "clean code", Author = "John" }, + new Book { Id = 2, Title = "CLEAN CODE", Author = "Jane" }, + new Book { Id = 3, Title = "Clean Code", Author = "Bob" }, + new Book { Id = 4, Title = "Other", Author = "Alice" } + }; + + var filter = new BookFilter_TypeCompareTo { Search = "clean" }; + AssertParity(books, filter); + } + + [Fact] + public void Parity_SingleTypedCompareTo_CaseInsensitive_ExactMatch() + { + var books = new[] + { + new Book { Id = 1, Title = "Test Book", Author = "John" }, + new Book { Id = 2, Title = "TEST BOOK", Author = "Jane" }, + new Book { Id = 3, Title = "test book", Author = "Bob" }, + new Book { Id = 4, Title = "Test Books", Author = "Alice" } + }; + + var filter = new BookFilter_TypeCompareTo { Search = "test book" }; + AssertParity(books, filter); + } + + [Fact] + public void Parity_SingleTypedCompareTo_PartialMatch_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "The Clean Code", Author = "John" }, + new Book { Id = 2, Title = "Clean Code", Author = "Jane" }, + new Book { Id = 3, Title = "Code Clean", Author = "Bob" }, + new Book { Id = 4, Title = "Other Book", Author = "Alice" } + }; + + var filter = new BookFilter_TypeCompareTo { Search = "clean" }; + AssertParity(books, filter); + } + + [Fact] + public void Parity_SingleTypedCompareTo_NullValue_NoFilter() + { + var books = new[] + { + new Book { Id = 1, Title = "Clean Code", Author = "John" }, + new Book { Id = 2, Title = "Other Book", Author = "Jane" } + }; + + var filter = new BookFilter_TypeCompareTo { Search = null }; + AssertParity(books, filter); + } + + [Fact] + public void Parity_SingleTypedCompareTo_EmptyString_NoMatch() + { + var books = new[] + { + new Book { Id = 1, Title = "Clean Code", Author = "John" }, + new Book { Id = 2, Title = "", Author = "Jane" }, + new Book { Id = 3, Title = "Other Book", Author = "Bob" } + }; + + var filter = new BookFilter_TypeCompareTo { Search = "" }; + AssertParity(books, filter); + } + + #endregion + + #region StringFilter.NotContains + Compare Mode + + [Fact] + public void Parity_StringFilter_NotContains_CompareOrdinal_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "CLEAN CODE", Author = "John" }, + new Book { Id = 2, Title = "Clean Code", Author = "Jane" }, + new Book { Id = 3, Title = "clean code", Author = "Bob" }, + new Book { Id = 4, Title = "Other Book", Author = "Alice" } + }; + + var filter = new BookFilter_StringFilter_Advanced + { + Title = new StringFilter + { + NotContains = "Clean", + Compare = StringComparison.Ordinal + } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_StringFilter_NotContains_CompareIgnoreCase_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "CLEAN CODE", Author = "John" }, + new Book { Id = 2, Title = "Clean Code", Author = "Jane" }, + new Book { Id = 3, Title = "clean code", Author = "Bob" }, + new Book { Id = 4, Title = "Other Book", Author = "Alice" } + }; + + var filter = new BookFilter_StringFilter_Advanced + { + Title = new StringFilter + { + NotContains = "clean", + Compare = StringComparison.OrdinalIgnoreCase + } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_StringFilter_NotContains_CompareInvariantCulture_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "café", Author = "Pierre" }, + new Book { Id = 2, Title = "cafe", Author = "John" }, + new Book { Id = 3, Title = "CAFÉ", Author = "Marie" }, + new Book { Id = 4, Title = "Other", Author = "Alice" } + }; + + var filter = new BookFilter_StringFilter_Advanced + { + Title = new StringFilter + { + NotContains = "caf", + Compare = StringComparison.InvariantCultureIgnoreCase + } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_StringFilter_NotContains_EmptyString_AllMatch() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A", Author = "John" }, + new Book { Id = 2, Title = "", Author = "Jane" }, + new Book { Id = 3, Title = null, Author = "Bob" } + }; + + var filter = new BookFilter_StringFilter_Advanced + { + Title = new StringFilter + { + NotContains = "", + Compare = StringComparison.Ordinal + } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_StringFilter_NotContains_NotExistingSubstring_AllMatch() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A", Author = "John" }, + new Book { Id = 2, Title = "Book B", Author = "Jane" }, + new Book { Id = 3, Title = "Book C", Author = "Bob" } + }; + + var filter = new BookFilter_StringFilter_Advanced + { + Title = new StringFilter + { + NotContains = "XYZ", + Compare = StringComparison.Ordinal + } + }; + + AssertParity(books, filter); + } + + #endregion + + #region Strong Non-Nullable Range Bound Parity + + [Fact] + public void Parity_Range_NonNullable_MinAndMax_BothBoundsStrict() + { + var books = new[] + { + new Book { Id = 1, Title = "Book 1", Year = 1995, TotalPage = 150 }, + new Book { Id = 2, Title = "Book 2", Year = 2000, TotalPage = 250 }, + new Book { Id = 3, Title = "Book 3", Year = 2005, TotalPage = 350 }, + new Book { Id = 4, Title = "Book 4", Year = 2010, TotalPage = 450 }, + new Book { Id = 5, Title = "Book 5", Year = 1999, TotalPage = 199 }, + new Book { Id = 6, Title = "Book 6", Year = 2006, TotalPage = 301 } + }; + + var filter = new BookFilter_Range + { + Year = new Range { Min = 2000, Max = 2005 } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_Range_NonNullable_BoundaryValues_Inclusive() + { + var books = new[] + { + new Book { Id = 1, Title = "Below Min", Year = 1999, TotalPage = 100 }, + new Book { Id = 2, Title = "Exact Min", Year = 2000, TotalPage = 200 }, + new Book { Id = 3, Title = "In Range", Year = 2005, TotalPage = 250 }, + new Book { Id = 4, Title = "Exact Max", Year = 2010, TotalPage = 300 }, + new Book { Id = 5, Title = "Above Max", Year = 2011, TotalPage = 400 } + }; + + var filter = new BookFilter_Range + { + Year = new Range { Min = 2000, Max = 2010 } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_Range_NonNullable_OnlyMin_StrictLowerBound() + { + var books = new[] + { + new Book { Id = 1, Title = "Book 1", Year = 1990, TotalPage = 100 }, + new Book { Id = 2, Title = "Book 2", Year = 2000, TotalPage = 200 }, + new Book { Id = 3, Title = "Book 3", Year = 2000, TotalPage = 205 }, + new Book { Id = 4, Title = "Book 4", Year = 1999, TotalPage = 150 } + }; + + var filter = new BookFilter_Range + { + Year = new Range { Min = 2000 } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_Range_NonNullable_OnlyMax_StrictUpperBound() + { + var books = new[] + { + new Book { Id = 1, Title = "Book 1", Year = 2010, TotalPage = 300 }, + new Book { Id = 2, Title = "Book 2", Year = 2000, TotalPage = 200 }, + new Book { Id = 3, Title = "Book 3", Year = 2000, TotalPage = 205 }, + new Book { Id = 4, Title = "Book 4", Year = 2011, TotalPage = 400 } + }; + + var filter = new BookFilter_Range + { + Year = new Range { Max = 2000 } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_Range_NonNullable_LargeDataset_PerformanceAndCorrectness() + { + var books = Enumerable.Range(1, 100).Select(i => new Book + { + Id = i, + Title = $"Book {i}", + Year = 1900 + i, + TotalPage = 100 + i * 2 + }).ToArray(); + + var filter = new BookFilter_Range + { + Year = new Range { Min = 1950, Max = 1999 } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_Range_NonNullable_MultipleBooksSameBoundary() + { + var books = new[] + { + new Book { Id = 1, Title = "Book 1", Year = 2000, TotalPage = 100 }, + new Book { Id = 2, Title = "Book 2", Year = 2000, TotalPage = 200 }, + new Book { Id = 3, Title = "Book 3", Year = 2000, TotalPage = 300 }, + new Book { Id = 4, Title = "Book 4", Year = 2010, TotalPage = 400 }, + new Book { Id = 5, Title = "Book 5", Year = 2010, TotalPage = 500 } + }; + + var filter = new BookFilter_Range + { + Year = new Range { Min = 2000, Max = 2010 } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_Range_NonNullable_AdjacentBoundaries() + { + var books = new[] + { + new Book { Id = 1, Title = "Book 1", Year = 1999, TotalPage = 100 }, + new Book { Id = 2, Title = "Book 2", Year = 2000, TotalPage = 200 }, + new Book { Id = 3, Title = "Book 3", Year = 2001, TotalPage = 300 }, + new Book { Id = 4, Title = "Book 4", Year = 2002, TotalPage = 400 }, + new Book { Id = 5, Title = "Book 5", Year = 2003, TotalPage = 500 } + }; + + var filter = new BookFilter_Range + { + Year = new Range { Min = 2000, Max = 2002 } + }; + + AssertParity(books, filter); + } + + #endregion + + #region Dotted-Path CompareTo Parity + + [Fact] + public void Parity_DottedPathCompareTo_AuthorName_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "Book 1", Author = "Robert Martin", AuthorModel = new Author { Id = 1, Name = "Robert Martin" } }, + new Book { Id = 2, Title = "Book 2", Author = "John Doe", AuthorModel = new Author { Id = 2, Name = "John Doe" } }, + new Book { Id = 3, Title = "Book 3", Author = "Robert Smith", AuthorModel = new Author { Id = 3, Name = "Robert Smith" } }, + new Book { Id = 4, Title = "Book 4", Author = "Jane Wilson", AuthorModel = new Author { Id = 4, Name = "Jane Wilson" } }, + new Book { Id = 5, Title = "Book 5", Author = "Robert Johnson", AuthorModel = new Author { Id = 5, Name = "Robert Johnson" } } + }; + + var filter = new BookFilter_DottedPath { AuthorName = "Robert" }; + AssertParity(books, filter); + } + + [Fact] + public void Parity_DottedPathCompareTo_CaseInsensitive_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "Book 1", AuthorModel = new Author { Id = 1, Name = "MARTIN" } }, + new Book { Id = 2, Title = "Book 2", AuthorModel = new Author { Id = 2, Name = "martin" } }, + new Book { Id = 3, Title = "Book 3", AuthorModel = new Author { Id = 3, Name = "Martin" } }, + new Book { Id = 4, Title = "Book 4", AuthorModel = new Author { Id = 4, Name = "Other" } } + }; + + var filter = new BookFilter_DottedPath { AuthorName = "martin" }; + AssertParity(books, filter); + } + + [Fact] + public void Parity_DottedPathCompareTo_PartialMatch_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "Book 1", AuthorModel = new Author { Id = 1, Name = "The Clean Coder" } }, + new Book { Id = 2, Title = "Book 2", AuthorModel = new Author { Id = 2, Name = "Clean Code" } }, + new Book { Id = 3, Title = "Book 3", AuthorModel = new Author { Id = 3, Name = "Code Clean" } }, + new Book { Id = 4, Title = "Book 4", AuthorModel = new Author { Id = 4, Name = "Other Author" } } + }; + + var filter = new BookFilter_DottedPath { AuthorName = "clean" }; + AssertParity(books, filter); + } + + [Fact] + public void Parity_DottedPathCompareTo_NullAuthorModel_NoMatch() + { + var books = new[] + { + new Book { Id = 1, Title = "Book 1", AuthorModel = new Author { Id = 1, Name = "Robert Martin" } }, + new Book { Id = 2, Title = "Book 2", AuthorModel = null }, + new Book { Id = 3, Title = "Book 3", AuthorModel = new Author { Id = 3, Name = "John Doe" } } + }; + + var filter = new BookFilter_DottedPath { AuthorName = "Martin" }; + AssertParity(books, filter); + } + + [Fact] + public void Parity_DottedPathCompareTo_NullValue_NoFilter() + { + var books = new[] + { + new Book { Id = 1, Title = "Book 1", AuthorModel = new Author { Id = 1, Name = "Robert Martin" } }, + new Book { Id = 2, Title = "Book 2", AuthorModel = new Author { Id = 2, Name = "John Doe" } } + }; + + var filter = new BookFilter_DottedPath { AuthorName = null }; + AssertParity(books, filter); + } + + #endregion + + #region CollectionFilterType.All Strict Parity + + [Fact] + public void Parity_CollectionFilterType_All_NonEmptyCollection_StrictParity() + { + var authors = new[] + { + new Author + { + Id = 1, + Name = "Author A", + Books = new List + { + new Book { Title = "Book 1", Year = 2000, TotalPage = 300 }, + new Book { Title = "Book 2", Year = 2001, TotalPage = 310 }, + new Book { Title = "Book 3", Year = 2002, TotalPage = 320 } + } + }, + new Author + { + Id = 2, + Name = "Author B", + Books = new List + { + new Book { Title = "Book 4", Year = 1999, TotalPage = 280 }, + new Book { Title = "Book 5", Year = 2005, TotalPage = 400 } + } + }, + new Author + { + Id = 3, + Name = "Author C", + Books = new List + { + new Book { Title = "Book 6", Year = 2000, TotalPage = 305 } + } + } + }; + + var filter = new AuthorFilter_CollectionAll + { + Books = new BookNestedFilter + { + Year = new Range { Min = 2000 } + } + }; + + AssertParityAuthor(authors, filter); + } + + [Fact] + public void Parity_CollectionFilterType_All_EmptyCollection_StrictParity() + { + var authors = new[] + { + new Author + { + Id = 1, + Name = "Author With No Books", + Books = new List() + }, + new Author + { + Id = 2, + Name = "Author With Books", + Books = new List + { + new Book { Title = "Book 1", Year = 2000, TotalPage = 300 } + } + } + }; + + var filter = new AuthorFilter_CollectionAll + { + Books = new BookNestedFilter + { + Year = new Range { Min = 2000 } + } + }; + + AssertParityAuthor(authors, filter); + } + + [Fact] + public void Parity_CollectionFilterType_All_MatchingItems_StrictParity() + { + var authors = new[] + { + new Author + { + Id = 1, + Name = "All Match", + Books = new List + { + new Book { Title = "Clean Code", Year = 2000 }, + new Book { Title = "Clean Architecture", Year = 2010 } + } + }, + new Author + { + Id = 2, + Name = "Partial Match", + Books = new List + { + new Book { Title = "Clean Code", Year = 2000 }, + new Book { Title = "Other Book", Year = 2015 } + } + }, + new Author + { + Id = 3, + Name = "No Match", + Books = new List + { + new Book { Title = "Other Book", Year = 2015 }, + new Book { Title = "Another Book", Year = 2020 } + } + } + }; + + var filter = new AuthorFilter_CollectionAll + { + Books = new BookNestedFilter + { + Title = new StringFilter { Contains = "Clean" } + } + }; + + AssertParityAuthor(authors, filter); + } + + [Fact] + public void Parity_CollectionFilterType_All_NonMatchingItems_StrictParity() + { + var authors = new[] + { + new Author + { + Id = 1, + Name = "All Clean", + Books = new List + { + new Book { Title = "Clean Code", Year = 2000 }, + new Book { Title = "Clean Architecture", Year = 2010 } + } + }, + new Author + { + Id = 2, + Name = "One Dirty", + Books = new List + { + new Book { Title = "Clean Code", Year = 2000 }, + new Book { Title = "Dirty Code", Year = 2010 } + } + } + }; + + var filter = new AuthorFilter_CollectionAll + { + Books = new BookNestedFilter + { + Title = new StringFilter { Contains = "Clean" } + } + }; + + AssertParityAuthor(authors, filter); + } + + [Fact] + public void Parity_CollectionFilterType_All_MultipleFilters_StrictParity() + { + var authors = new[] + { + new Author + { + Id = 1, + Name = "All Match Both", + Books = new List + { + new Book { Title = "Clean Code", Year = 2000 }, + new Book { Title = "Clean Design", Year = 2005 } + } + }, + new Author + { + Id = 2, + Name = "Match One", + Books = new List + { + new Book { Title = "Clean Code", Year = 2000 }, + new Book { Title = "Dirty Code", Year = 1995 } + } + } + }; + + var filter = new AuthorFilter_CollectionAll + { + Books = new BookNestedFilter + { + Title = new StringFilter { Contains = "Clean" }, + Year = new Range { Min = 1999 } + } + }; + + AssertParityAuthor(authors, filter); + } + + [Fact] + public void Parity_CollectionFilterType_All_NullBooksProperty_StrictParity() + { + var authors = new[] + { + new Author + { + Id = 1, + Name = "Author With Null Books", + Books = null + }, + new Author + { + Id = 2, + Name = "Author With Books", + Books = new List + { + new Book { Title = "Book 1", Year = 2000 } + } + } + }; + + var filter = new AuthorFilter_CollectionAll + { + Books = new BookNestedFilter + { + Year = new Range { Min = 1999 } + } + }; + + AssertParityAuthor(authors, filter); + } + + #endregion + + #region Implicit Nested Mapping Parity + + [Fact] + public void Parity_ImplicitNestedObject_ByPropertyName_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A", AuthorModel = new Author { Name = "Alice Smith" } }, + new Book { Id = 2, Title = "Book B", AuthorModel = new Author { Name = "Bob Jones" } }, + new Book { Id = 3, Title = "Book C", AuthorModel = new Author { Name = "Alice Johnson" } }, + new Book { Id = 4, Title = "Book D", AuthorModel = null } + }; + + var filter = new BookFilter_NestedObjectImplicit + { + AuthorModel = new AuthorFilterImplicit { Name = "Alice" } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_ImplicitNestedObject_WithMultipleProperties_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A", AuthorModel = new Author { Name = "Alice", Country = "USA", Age = 30 } }, + new Book { Id = 2, Title = "Book B", AuthorModel = new Author { Name = "Bob", Country = "UK", Age = 35 } }, + new Book { Id = 3, Title = "Book C", AuthorModel = new Author { Name = "Alice", Country = "Canada", Age = 25 } }, + new Book { Id = 4, Title = "Book D", AuthorModel = null } + }; + + var filter = new BookFilter_NestedObjectImplicit + { + AuthorModel = new AuthorFilterImplicit + { + Name = "Alice", + Age = new Range { Min = 28, Max = 35 } + } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_ImplicitNestedObject_NullNestedObject_Ignored() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A", AuthorModel = null }, + new Book { Id = 2, Title = "Book B", AuthorModel = new Author { Name = "Alice" } }, + new Book { Id = 3, Title = "Book C", AuthorModel = null } + }; + + var filter = new BookFilter_NestedObjectImplicit + { + AuthorModel = new AuthorFilterImplicit { Name = "Alice" } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_ImplicitNestedObject_NullFilterValue_SkipsFilter() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A", AuthorModel = new Author { Name = "Alice" } }, + new Book { Id = 2, Title = "Book B", AuthorModel = new Author { Name = "Bob" } }, + new Book { Id = 3, Title = "Book C", AuthorModel = null } + }; + + var filter = new BookFilter_NestedObjectImplicit + { + AuthorModel = null + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_ImplicitNestedCollection_ByPropertyName_MatchesRuntime() + { + var authors = new[] + { + new Author + { + Id = 1, + Name = "Author A", + Books = new List + { + new Book { Title = "Python Book", Year = 2020 }, + new Book { Title = "Java Book", Year = 2019 } + } + }, + new Author + { + Id = 2, + Name = "Author B", + Books = new List + { + new Book { Title = "C# Book", Year = 2021 }, + new Book { Title = "JavaScript Book", Year = 2020 } + } + }, + new Author + { + Id = 3, + Name = "Author C", + Books = new List() + } + }; + + var filter = new AuthorFilter_CollectionImplicit + { + Books = new BookFilterImplicit + { + Year = new Range { Min = 2020 } + } + }; + + AssertParityAuthor(authors, filter); + } + + [Fact] + public void Parity_ImplicitNestedCollection_WithTitleAndYear_MatchesRuntime() + { + var authors = new[] + { + new Author + { + Id = 1, + Name = "Alice", + Books = new List + { + new Book { Title = "Clean Python", Year = 2020 }, + new Book { Title = "Design Patterns", Year = 2015 } + } + }, + new Author + { + Id = 2, + Name = "Bob", + Books = new List + { + new Book { Title = "Clean Code", Year = 2020 }, + new Book { Title = "Refactoring", Year = 2018 } + } + }, + new Author + { + Id = 3, + Name = "Charlie", + Books = new List() + } + }; + + var filter = new AuthorFilter_CollectionImplicit + { + Name = "Alice", + Books = new BookFilterImplicit + { + Title = "Clean", + Year = new Range { Min = 2019, Max = 2021 } + } + }; + + AssertParityAuthor(authors, filter); + } + + [Fact] + public void Parity_ImplicitNestedCollection_EmptyCollection_NoMatches() + { + var authors = new[] + { + new Author + { + Id = 1, + Name = "Author A", + Books = new List() + }, + new Author + { + Id = 2, + Name = "Author B", + Books = new List + { + new Book { Title = "Book 1", Year = 2020 } + } + } + }; + + var filter = new AuthorFilter_CollectionImplicit + { + Books = new BookFilterImplicit + { + Year = new Range { Min = 2020 } + } + }; + + AssertParityAuthor(authors, filter); + } + + [Fact] + public void Parity_ImplicitNestedCollection_NullBooksProperty_Ignored() + { + var authors = new[] + { + new Author + { + Id = 1, + Name = "Author With Null Books", + Books = null + }, + new Author + { + Id = 2, + Name = "Author With Books", + Books = new List + { + new Book { Title = "Book 1", Year = 2020 } + } + } + }; + + var filter = new AuthorFilter_CollectionImplicit + { + Books = new BookFilterImplicit + { + Year = new Range { Min = 2020 } + } + }; + + AssertParityAuthor(authors, filter); + } + + #endregion + + #region Deep-Edge Nested Collection Null/Empty Parity + + [Fact] + public void Parity_DeepNested_NullOuterCollection_MatchesRuntime() + { + var publishers = new[] + { + new Publisher + { + Id = 1, + Name = "Publisher A", + Authors = new List + { + new Author + { + Id = 1, + Name = "Author A", + Books = new List + { + new Book { Title = "Book 1", Year = 2000 } + } + } + } + }, + new Publisher + { + Id = 2, + Name = "Publisher B", + Authors = null + }, + new Publisher + { + Id = 3, + Name = "Publisher C", + Authors = new List + { + new Author + { + Id = 2, + Name = "Author C", + Books = new List + { + new Book { Title = "Book 2", Year = 2010 } + } + } + } + } + }; + + var filter = new PublisherFilter_NestedCollection + { + Authors = new AuthorNestedFilter + { + Books = new BookNestedFilter + { + Year = new Range { Min = 2005 } + } + } + }; + + AssertParityPublisher(publishers, filter); + } + + [Fact] + public void Parity_DeepNested_NullInnerCollection_MatchesRuntime() + { + var publishers = new[] + { + new Publisher + { + Id = 1, + Name = "Publisher A", + Authors = new List + { + new Author + { + Id = 1, + Name = "Author A", + Books = new List + { + new Book { Title = "Book 1", Year = 2000 } + } + } + } + }, + new Publisher + { + Id = 2, + Name = "Publisher B", + Authors = new List + { + new Author + { + Id = 2, + Name = "Author B", + Books = null + } + } + }, + new Publisher + { + Id = 3, + Name = "Publisher C", + Authors = new List + { + new Author + { + Id = 3, + Name = "Author C", + Books = new List + { + new Book { Title = "Book 2", Year = 2010 } + } + } + } + } + }; + + var filter = new PublisherFilter_NestedCollection + { + Authors = new AuthorNestedFilter + { + Books = new BookNestedFilter + { + Year = new Range { Min = 2005 } + } + } + }; + + AssertParityPublisher(publishers, filter); + } + + [Fact] + public void Parity_DeepNested_NullBothOuterAndInnerCollections_MatchesRuntime() + { + var publishers = new[] + { + new Publisher + { + Id = 1, + Name = "Publisher A", + Authors = new List + { + new Author + { + Id = 1, + Name = "Author A", + Books = new List + { + new Book { Title = "Book 1", Year = 2000 } + } + } + } + }, + new Publisher + { + Id = 2, + Name = "Publisher B", + Authors = new List + { + new Author + { + Id = 2, + Name = "Author B", + Books = null + }, + new Author + { + Id = 3, + Name = "Author C", + Books = new List + { + new Book { Title = "Book 2", Year = 2010 } + } + } + } + }, + new Publisher + { + Id = 3, + Name = "Publisher C", + Authors = null + } + }; + + var filter = new PublisherFilter_NestedCollection + { + Authors = new AuthorNestedFilter + { + Books = new BookNestedFilter + { + Year = new Range { Min = 2005 } + } + } + }; + + AssertParityPublisher(publishers, filter); + } + + [Fact] + public void Parity_DeepNested_EmptyFilterObject_Any_MatchesRuntime() + { + var publishers = new[] + { + new Publisher + { + Id = 1, + Name = "Publisher A", + Authors = new List + { + new Author + { + Id = 1, + Name = "Author A", + Books = new List + { + new Book { Title = "Book 1", Year = 2000 } + } + } + } + }, + new Publisher + { + Id = 2, + Name = "Publisher B", + Authors = new List + { + new Author + { + Id = 2, + Name = "Author B", + Books = new List() + } + } + }, + new Publisher + { + Id = 3, + Name = "Publisher C", + Authors = new List + { + new Author + { + Id = 3, + Name = "Author C", + Books = new List + { + new Book { Title = "Book 2", Year = 2010 } + } + } + } + } + }; + + // Empty nested filter object for Any - should match when nested collection has any items + var filter = new PublisherFilter_NestedCollection + { + Authors = new AuthorNestedFilter + { + Books = new BookNestedFilter() + } + }; + + AssertParityPublisher(publishers, filter); + } + + [Fact] + public void Parity_DeepNested_EmptyFilterObject_All_MatchesRuntime() + { + var publishers = new[] + { + new Publisher + { + Id = 1, + Name = "Publisher A", + Authors = new List + { + new Author + { + Id = 1, + Name = "Author A", + Books = new List + { + new Book { Title = "Book 1", Year = 2000 } + } + } + } + }, + new Publisher + { + Id = 2, + Name = "Publisher B", + Authors = new List + { + new Author + { + Id = 2, + Name = "Author B", + Books = new List() + } + } + }, + new Publisher + { + Id = 3, + Name = "Publisher C", + Authors = new List + { + new Author + { + Id = 3, + Name = "Author C", + Books = new List + { + new Book { Title = "Book 2", Year = 2010 } + } + } + } + } + }; + + // Empty nested filter object for All - should match when nested collection has all items matching + var filter = new PublisherFilter_NestedCollectionAll + { + Authors = new AuthorNestedFilter_CollectionAll + { + Books = new BookNestedFilter() + } + }; + + AssertParityPublisher(publishers, filter); + } + + [Fact] + public void Parity_DeepNested_EmptyFilterObject_All_MultipleAuthors_MatchesRuntime() + { + var publishers = new[] + { + new Publisher + { + Id = 1, + Name = "All Authors Have Books", + Authors = new List + { + new Author + { + Id = 1, + Name = "Author A", + Books = new List + { + new Book { Title = "Book 1", Year = 2000 }, + new Book { Title = "Book 2", Year = 2005 } + } + }, + new Author + { + Id = 2, + Name = "Author B", + Books = new List + { + new Book { Title = "Book 3", Year = 2010 } + } + } + } + }, + new Publisher + { + Id = 2, + Name = "One Author Has Empty Books", + Authors = new List + { + new Author + { + Id = 3, + Name = "Author C", + Books = new List + { + new Book { Title = "Book 4", Year = 2015 } + } + }, + new Author + { + Id = 4, + Name = "Author D", + Books = new List() + } + } + } + }; + + var filter = new PublisherFilter_NestedCollectionAll + { + Authors = new AuthorNestedFilter_CollectionAll + { + Books = new BookNestedFilter() + } + }; + + AssertParityPublisher(publishers, filter); + } + + [Fact] + public void Parity_DeepNested_MultipleLevels_CombinedFilters_MatchesRuntime() + { + var publishers = new[] + { + new Publisher + { + Id = 1, + Name = "Tech Publisher", + Authors = new List + { + new Author + { + Id = 1, + Name = "Alice", + Books = new List + { + new Book { Title = "Clean Code", Year = 2000 }, + new Book { Title = "Refactoring", Year = 2005 } + } + }, + new Author + { + Id = 2, + Name = "Bob", + Books = new List + { + new Book { Title = "Design Patterns", Year = 1995 }, + new Book { Title = "Clean Architecture", Year = 2010 } + } + } + } + }, + new Publisher + { + Id = 2, + Name = "Other Publisher", + Authors = new List + { + new Author + { + Id = 3, + Name = "Charlie", + Books = new List + { + new Book { Title = "Fiction Book", Year = 2015 } + } + } + } + } + }; + + var filter = new PublisherFilter_NestedCollection + { + Authors = new AuthorNestedFilter + { + Books = new BookNestedFilter + { + Title = new StringFilter { Contains = "Clean" } + } + } + }; + + AssertParityPublisher(publishers, filter); + } + + #endregion + + private static void AssertParity(IEnumerable books, TFilter filter) + where TFilter : FilterBase + { + var runtime = Execute(() => filter.ApplyFilterTo(books.AsQueryable()).ToList()); + var generated = Execute(() => GeneratedFilterInvoker.ApplyFilter(books.AsQueryable(), filter).ToList()); + + Assert.Equal(runtime.Exception?.GetType(), generated.Exception?.GetType()); + + if (runtime.Exception != null) + { + Assert.Equal(runtime.Exception.Message, generated.Exception.Message); + return; + } + + Assert.Equal(runtime.Result.Select(x => x.Id), generated.Result.Select(x => x.Id)); + } + + private static void AssertParityAuthor(IEnumerable authors, TFilter filter) + where TFilter : FilterBase + { + var runtime = ExecuteAuthor(() => filter.ApplyFilterTo(authors.AsQueryable()).ToList()); + var generated = ExecuteAuthor(() => GeneratedFilterInvoker.ApplyFilter(authors.AsQueryable(), filter).ToList()); + + Assert.Equal(runtime.Exception?.GetType(), generated.Exception?.GetType()); + + if (runtime.Exception != null) + { + Assert.Equal(runtime.Exception.Message, generated.Exception.Message); + return; + } + + Assert.Equal(runtime.Result.Select(x => x.Id), generated.Result.Select(x => x.Id)); + } + + private static void AssertParityPreferences(IEnumerable preferences, TFilter filter) + where TFilter : FilterBase + { + var runtime = ExecutePreferences(() => filter.ApplyFilterTo(preferences.AsQueryable()).ToList()); + var generated = ExecutePreferences(() => GeneratedFilterInvoker.ApplyFilter(preferences.AsQueryable(), filter).ToList()); + + Assert.Equal(runtime.Exception?.GetType(), generated.Exception?.GetType()); + + if (runtime.Exception != null) + { + Assert.Equal(runtime.Exception.Message, generated.Exception.Message); + return; + } + + Assert.Equal(runtime.Result.Select(x => x.UserId), generated.Result.Select(x => x.UserId)); + } + + private static void AssertParityPublisher(IEnumerable publishers, TFilter filter) + where TFilter : FilterBase + { + var runtime = ExecutePublisher(() => filter.ApplyFilterTo(publishers.AsQueryable()).ToList()); + var generated = ExecutePublisher(() => GeneratedFilterInvoker.ApplyFilter(publishers.AsQueryable(), filter).ToList()); + + Assert.Equal(runtime.Exception?.GetType(), generated.Exception?.GetType()); + + if (runtime.Exception != null) + { + Assert.Equal(runtime.Exception.Message, generated.Exception.Message); + return; + } + + Assert.Equal(runtime.Result.Select(x => x.Id), generated.Result.Select(x => x.Id)); + } + + private static ExecutionResult Execute(Func> run) + { + try + { + return new ExecutionResult { Result = run() }; + } + catch (Exception ex) + { + return new ExecutionResult { Exception = ex }; + } + } + + private static ExecutionResultAuthor ExecuteAuthor(Func> run) + { + try + { + return new ExecutionResultAuthor { Result = run() }; + } + catch (Exception ex) + { + return new ExecutionResultAuthor { Exception = ex }; + } + } + + private static ExecutionResultPreferences ExecutePreferences(Func> run) + { + try + { + return new ExecutionResultPreferences { Result = run() }; + } + catch (Exception ex) + { + return new ExecutionResultPreferences { Exception = ex }; + } + } + + private static ExecutionResultPublisher ExecutePublisher(Func> run) + { + try + { + return new ExecutionResultPublisher { Result = run() }; + } + catch (Exception ex) + { + return new ExecutionResultPublisher { Exception = ex }; + } + } + + private sealed class ExecutionResult + { + public List Result { get; set; } = new List(); + public Exception Exception { get; set; } + } + + private sealed class ExecutionResultAuthor + { + public List Result { get; set; } = new List(); + public Exception Exception { get; set; } + } + + private sealed class ExecutionResultPreferences + { + public List Result { get; set; } = new List(); + public Exception Exception { get; set; } + } + + private sealed class ExecutionResultPublisher + { + public List Result { get; set; } = new List(); + public Exception Exception { get; set; } + } +} diff --git a/tests/AutoFilterer.Generators.Tests/GeneratorParityNextWaveTests.cs b/tests/AutoFilterer.Generators.Tests/GeneratorParityNextWaveTests.cs new file mode 100644 index 0000000..93835de --- /dev/null +++ b/tests/AutoFilterer.Generators.Tests/GeneratorParityNextWaveTests.cs @@ -0,0 +1,438 @@ +using AutoFilterer.Enums; +using AutoFilterer.Extensions; +using AutoFilterer.Generators.Tests.Environment.Dtos; +using AutoFilterer.Generators.Tests.Environment.Models; +using AutoFilterer.Types; +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace AutoFilterer.Generators.Tests; + +public class GeneratorParityNextWaveTests +{ + #region CompareTo Multiple Targets AND Semantics + + [Fact] + public void Parity_CompareToMultipleTargets_AND_Semantics_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "Clean Code", Author = "Robert Martin" }, + new Book { Id = 2, Title = "Clean Architecture", Author = "Robert Martin" }, + new Book { Id = 3, Title = "Code Complete", Author = "Steve McConnell" }, + new Book { Id = 4, Title = "Refactoring", Author = "Martin Fowler" } + }; + + var filter = new BookFilter_MultiplePropertyAnd { Query = "Martin" }; + AssertParity(books, filter); + } + + [Fact] + public void Parity_CompareToMultipleTargets_AND_NoMatchWhenPartial() + { + var books = new[] + { + new Book { Id = 1, Title = "Martin's Book", Author = "Other Author" }, + new Book { Id = 2, Title = "Book Title", Author = "Martin Smith" }, + new Book { Id = 3, Title = "Martin's Work", Author = "Martin Jones" } + }; + + var filter = new BookFilter_MultiplePropertyAnd { Query = "Martin" }; + AssertParity(books, filter); + } + + #endregion + + #region Multiple Typed CompareTo Attributes OR/AND + + [Fact] + public void Parity_MultipleTypedCompareTo_OR_Semantics_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "clean code", Author = "John Doe" }, + new Book { Id = 2, Title = "Other", Author = "CLEAN Smith" }, + new Book { Id = 3, Title = "None", Author = "Bob Jones" } + }; + + var filter = new BookFilter_MultipleTypeCompareTo { Search = "clean" }; + AssertParity(books, filter); + } + + [Fact] + public void Parity_MultipleTypedCompareTo_AND_Semantics_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "testing book", Author = "John testing" }, + new Book { Id = 2, Title = "testing book", Author = "John other" }, + new Book { Id = 3, Title = "other book", Author = "John testing" } + }; + + var filter = new BookFilter_MultipleTypeCompareToAnd { Search = "testing" }; + AssertParity(books, filter); + } + + #endregion + + #region Invalid CompareTo Metadata (IgnoreExceptions=false) + + [Fact] + public void Parity_InvalidCompareTo_ThrowsWhenIgnoreExceptionsFalse() + { + var books = new[] + { + new Book { Id = 1, Title = "Test", Author = "John" } + }; + + var filter = new BookFilter_InvalidTarget { Search = "value" }; + AssertParity(books, filter); + } + + #endregion + + #region ToLowerEqualsComparison Semantics + + [Fact] + public void Parity_ToLowerEquals_CaseInsensitiveExactMatch() + { + var books = new[] + { + new Book { Id = 1, Title = "Clean Code", Author = "John" }, + new Book { Id = 2, Title = "CLEAN CODE", Author = "Jane" }, + new Book { Id = 3, Title = "clean code", Author = "Bob" }, + new Book { Id = 4, Title = "Clean Codes", Author = "Alice" }, + new Book { Id = 5, Title = "The Clean Code", Author = "Tom" } + }; + + var filter = new BookFilter_ToLowerEquals { Title = "clean code" }; + AssertParity(books, filter); + } + + [Fact] + public void Parity_ToLowerEquals_SkipsFilterWhenValueNull() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A", Author = "John" }, + new Book { Id = 2, Title = "Book B", Author = "Jane" }, + new Book { Id = 3, Title = "Book C", Author = "Bob" } + }; + + var filter = new BookFilter_ToLowerEquals { Title = null }; + AssertParity(books, filter); + } + + [Fact] + public void Parity_ToLowerEquals_SkipsFilterWhenValueEmptyString() + { + var books = new[] + { + new Book { Id = 1, Title = "", Author = "John" }, + new Book { Id = 2, Title = null, Author = "Jane" }, + new Book { Id = 3, Title = "Book C", Author = "Bob" } + }; + + var filter = new BookFilter_ToLowerEquals { Title = "" }; + AssertParity(books, filter); + } + + [Fact] + public void Parity_ToLowerEquals_SpecialCharactersMatched() + { + var books = new[] + { + new Book { Id = 1, Title = "Test-Book_123", Author = "John" }, + new Book { Id = 2, Title = "test-book_123", Author = "Jane" }, + new Book { Id = 3, Title = "TEST-BOOK_123", Author = "Bob" }, + new Book { Id = 4, Title = "TestBook123", Author = "Alice" } + }; + + var filter = new BookFilter_ToLowerEquals { Title = "test-book_123" }; + AssertParity(books, filter); + } + + #endregion + + #region Top-Level FilterBase.CombineWith Across Scalar Properties + + [Fact] + public void Parity_FilterBaseCombineWith_OR_AcrossScalarProperties() + { + var books = new[] + { + new Book { Id = 1, Title = "Test", Author = "Other" }, + new Book { Id = 2, Title = "Other", Author = "Test" }, + new Book { Id = 3, Title = "Test", Author = "Test" }, + new Book { Id = 4, Title = "None", Author = "None" } + }; + + var filter = new BookFilter_ScalarCombineWith + { + Title = "Test", + Author = "Test", + CombineWith = CombineType.Or + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_FilterBaseCombineWith_AND_AcrossScalarProperties() + { + var books = new[] + { + new Book { Id = 1, Title = "Test", Author = "Other" }, + new Book { Id = 2, Title = "Other", Author = "Test" }, + new Book { Id = 3, Title = "Test", Author = "Test" }, + new Book { Id = 4, Title = "None", Author = "None" } + }; + + var filter = new BookFilter_ScalarCombineWith + { + Title = "Test", + Author = "Test", + CombineWith = CombineType.And + }; + + AssertParity(books, filter); + } + + #endregion + + #region Orderable Edge Behavior + + [Fact] + public void Parity_Orderable_NullSort_DoesNotThrow() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A", Author = "John", TotalPage = 100 }, + new Book { Id = 2, Title = "Book B", Author = "Jane", TotalPage = 200 } + }; + + var filter = new BookFilter_OrderableEdge + { + Sort = null, + SortBy = Sorting.Ascending + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_Orderable_DottedPathSort_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A", AuthorModel = new Author { Name = "Zoe" } }, + new Book { Id = 2, Title = "Book B", AuthorModel = new Author { Name = "Alice" } }, + new Book { Id = 3, Title = "Book C", AuthorModel = new Author { Name = "Bob" } } + }; + + var filter = new BookFilter_OrderableEdge + { + Sort = "AuthorModel.Name", + SortBy = Sorting.Ascending + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_Orderable_DottedPathSort_Descending_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A", AuthorModel = new Author { Name = "Zoe" } }, + new Book { Id = 2, Title = "Book B", AuthorModel = new Author { Name = "Alice" } }, + new Book { Id = 3, Title = "Book C", AuthorModel = new Author { Name = "Bob" } } + }; + + var filter = new BookFilter_OrderableEdge + { + Sort = "AuthorModel.Name", + SortBy = Sorting.Descending + }; + + AssertParity(books, filter); + } + + #endregion + + #region Nested Non-Collection Object Filter Behavior + + [Fact] + public void Parity_NestedNonCollectionObject_FilterMatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A", AuthorModel = new Author { Name = "Alice Smith" } }, + new Book { Id = 2, Title = "Book B", AuthorModel = new Author { Name = "Bob Jones" } }, + new Book { Id = 3, Title = "Book C", AuthorModel = new Author { Name = "Alice Johnson" } } + }; + + var filter = new BookFilter_NestedObject + { + AuthorFilter = new AuthorModelFilter { Name = "Alice" } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_NestedNonCollectionObject_NullNestedObject_Ignored() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A", AuthorModel = null }, + new Book { Id = 2, Title = "Book B", AuthorModel = new Author { Name = "Alice" } }, + new Book { Id = 3, Title = "Book C", AuthorModel = null } + }; + + var filter = new BookFilter_NestedObject + { + AuthorFilter = new AuthorModelFilter { Name = "Alice" } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_NestedNonCollectionObject_NullFilterValue_SkipsFilter() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A", AuthorModel = new Author { Name = "Alice" } }, + new Book { Id = 2, Title = "Book B", AuthorModel = new Author { Name = "Bob" } }, + new Book { Id = 3, Title = "Book C", AuthorModel = null } + }; + + var filter = new BookFilter_NestedObject + { + AuthorFilter = null + }; + + AssertParity(books, filter); + } + + #endregion + + #region Nullable Range Min+Max Behavior + + [Fact] + public void Parity_NullableRange_MinAndMax_FilterBothBounds() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A", Views = 100 }, + new Book { Id = 2, Title = "Book B", Views = 200 }, + new Book { Id = 3, Title = "Book C", Views = null }, + new Book { Id = 4, Title = "Book D", Views = 300 }, + new Book { Id = 5, Title = "Book E", Views = 250 } + }; + + var filter = new BookFilter_Range + { + Views = new Range { Min = 150, Max = 250 } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_NullableRange_OnlyMin_OnlyLowerBound() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A", Views = 100 }, + new Book { Id = 2, Title = "Book B", Views = 200 }, + new Book { Id = 3, Title = "Book C", Views = null }, + new Book { Id = 4, Title = "Book D", Views = 150 } + }; + + var filter = new BookFilter_Range + { + Views = new Range { Min = 150 } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_NullableRange_OnlyMax_OnlyUpperBound() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A", Views = 100 }, + new Book { Id = 2, Title = "Book B", Views = 200 }, + new Book { Id = 3, Title = "Book C", Views = null }, + new Book { Id = 4, Title = "Book D", Views = 150 } + }; + + var filter = new BookFilter_Range + { + Views = new Range { Max = 150 } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_NullableRange_NullMinAndMax_Ignored() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A", Views = 100 }, + new Book { Id = 2, Title = "Book B", Views = null }, + new Book { Id = 3, Title = "Book C", Views = 300 } + }; + + var filter = new BookFilter_Range + { + Views = new Range { Min = null, Max = null } + }; + + AssertParity(books, filter); + } + + #endregion + + private static void AssertParity(IEnumerable books, TFilter filter) + where TFilter : FilterBase + { + var runtime = Execute(() => filter.ApplyFilterTo(books.AsQueryable()).ToList()); + var generated = Execute(() => GeneratedFilterInvoker.ApplyFilter(books.AsQueryable(), filter).ToList()); + + Assert.Equal(runtime.Exception?.GetType(), generated.Exception?.GetType()); + + if (runtime.Exception != null) + { + Assert.Equal(runtime.Exception.Message, generated.Exception.Message); + return; + } + + Assert.Equal(runtime.Result.Select(x => x.Id), generated.Result.Select(x => x.Id)); + } + + private static ExecutionResult Execute(Func> run) + { + try + { + return new ExecutionResult { Result = run() }; + } + catch (Exception ex) + { + return new ExecutionResult { Exception = ex }; + } + } + + private sealed class ExecutionResult + { + public List Result { get; set; } = new List(); + + public Exception Exception { get; set; } + } +} diff --git a/tests/AutoFilterer.Generators.Tests/GeneratorParityP1MatrixTests.cs b/tests/AutoFilterer.Generators.Tests/GeneratorParityP1MatrixTests.cs new file mode 100644 index 0000000..849fcdf --- /dev/null +++ b/tests/AutoFilterer.Generators.Tests/GeneratorParityP1MatrixTests.cs @@ -0,0 +1,870 @@ +using AutoFilterer.Enums; +using AutoFilterer.Extensions; +using AutoFilterer.Generators.Tests.Environment.Dtos; +using AutoFilterer.Generators.Tests.Environment.Models; +using AutoFilterer.Types; +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace AutoFilterer.Generators.Tests; + +/// +/// Strict parity tests for remaining P1 scenario groups: +/// - Full OperatorFilter matrix with CombineWith +/// - Full StringFilter matrix +/// - CollectionFilterType.Any and nested chains +/// - Multi-target Range CompareTo +/// - Invalid CompareTo filterable type behavior +/// +public class GeneratorParityP1MatrixTests +{ + #region OperatorFilter Full Matrix + + [Fact] + public void Parity_OperatorFilter_Eq_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A", TotalPage = 100 }, + new Book { Id = 2, Title = "Book B", TotalPage = 200 }, + new Book { Id = 3, Title = "Book C", TotalPage = 100 } + }; + + var filter = new BookFilter_OperatorFilter_Matrix + { + TotalPage = new OperatorFilter { Eq = 100 } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_OperatorFilter_Not_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A", TotalPage = 100 }, + new Book { Id = 2, Title = "Book B", TotalPage = 200 }, + new Book { Id = 3, Title = "Book C", TotalPage = 300 } + }; + + var filter = new BookFilter_OperatorFilter_Matrix + { + TotalPage = new OperatorFilter { Not = 200 } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_OperatorFilter_Gt_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A", TotalPage = 100 }, + new Book { Id = 2, Title = "Book B", TotalPage = 200 }, + new Book { Id = 3, Title = "Book C", TotalPage = 300 } + }; + + var filter = new BookFilter_OperatorFilter_Matrix + { + TotalPage = new OperatorFilter { Gt = 150 } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_OperatorFilter_Gte_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A", TotalPage = 100 }, + new Book { Id = 2, Title = "Book B", TotalPage = 200 }, + new Book { Id = 3, Title = "Book C", TotalPage = 300 } + }; + + var filter = new BookFilter_OperatorFilter_Matrix + { + TotalPage = new OperatorFilter { Gte = 200 } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_OperatorFilter_Lt_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A", TotalPage = 100 }, + new Book { Id = 2, Title = "Book B", TotalPage = 200 }, + new Book { Id = 3, Title = "Book C", TotalPage = 300 } + }; + + var filter = new BookFilter_OperatorFilter_Matrix + { + TotalPage = new OperatorFilter { Lt = 250 } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_OperatorFilter_Lte_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A", TotalPage = 100 }, + new Book { Id = 2, Title = "Book B", TotalPage = 200 }, + new Book { Id = 3, Title = "Book C", TotalPage = 300 } + }; + + var filter = new BookFilter_OperatorFilter_Matrix + { + TotalPage = new OperatorFilter { Lte = 200 } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_OperatorFilter_MultipleOperators_OR_Semantics() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A", TotalPage = 100 }, + new Book { Id = 2, Title = "Book B", TotalPage = 200 }, + new Book { Id = 3, Title = "Book C", TotalPage = 300 }, + new Book { Id = 4, Title = "Book D", TotalPage = 150 } + }; + + var filter = new BookFilter_OperatorFilter_Matrix + { + TotalPage = new OperatorFilter + { + Gt = 150, + Eq = 100, + CombineWith = CombineType.Or + } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_OperatorFilter_NullableProperty_IsNull() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A", Views = 100 }, + new Book { Id = 2, Title = "Book B", Views = null }, + new Book { Id = 3, Title = "Book C", Views = 200 } + }; + + var filter = new BookFilter_OperatorFilter_Matrix + { + Views = new OperatorFilter { IsNull = true } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_OperatorFilter_NullableProperty_IsNotNull() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A", Views = 100 }, + new Book { Id = 2, Title = "Book B", Views = null }, + new Book { Id = 3, Title = "Book C", Views = 200 } + }; + + var filter = new BookFilter_OperatorFilter_Matrix + { + Views = new OperatorFilter { IsNotNull = true } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_OperatorFilter_NullableProperty_WithComparison() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A", Views = 100 }, + new Book { Id = 2, Title = "Book B", Views = null }, + new Book { Id = 3, Title = "Book C", Views = 200 }, + new Book { Id = 4, Title = "Book D", Views = 150 } + }; + + var filter = new BookFilter_OperatorFilter_Matrix + { + Views = new OperatorFilter { Gte = 150 } + }; + + AssertParity(books, filter); + } + + #endregion + + #region StringFilter Full Matrix + + [Fact] + public void Parity_StringFilter_Contains_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "Clean Code" }, + new Book { Id = 2, Title = "Code Complete" }, + new Book { Id = 3, Title = "Refactoring" }, + new Book { Id = 4, Title = "Coding Patterns" } + }; + + var filter = new BookFilter_StringFilter_Matrix + { + Title = new StringFilter { Contains = "Code" } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_StringFilter_NotContains_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "Clean Code" }, + new Book { Id = 2, Title = "Code Complete" }, + new Book { Id = 3, Title = "Refactoring" }, + new Book { Id = 4, Title = "Coding Patterns" } + }; + + var filter = new BookFilter_StringFilter_Matrix + { + Title = new StringFilter { NotContains = "Code" } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_StringFilter_StartsWith_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "Clean Code" }, + new Book { Id = 2, Title = "Code Complete" }, + new Book { Id = 3, Title = "The Clean Code" }, + new Book { Id = 4, Title = "Refactoring" } + }; + + var filter = new BookFilter_StringFilter_Matrix + { + Title = new StringFilter { StartsWith = "Clean" } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_StringFilter_EndsWith_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "Clean Code" }, + new Book { Id = 2, Title = "Code Complete" }, + new Book { Id = 3, Title = "The Clean Code" }, + new Book { Id = 4, Title = "Refactoring" } + }; + + var filter = new BookFilter_StringFilter_Matrix + { + Title = new StringFilter { EndsWith = "Code" } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_StringFilter_IsNull_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A" }, + new Book { Id = 2, Title = null }, + new Book { Id = 3, Title = "Book C" }, + new Book { Id = 4, Title = null } + }; + + var filter = new BookFilter_StringFilter_Matrix + { + Title = new StringFilter { IsNull = true } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_StringFilter_IsNotNull_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A" }, + new Book { Id = 2, Title = null }, + new Book { Id = 3, Title = "Book C" }, + new Book { Id = 4, Title = null } + }; + + var filter = new BookFilter_StringFilter_Matrix + { + Title = new StringFilter { IsNotNull = true } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_StringFilter_IsEmpty_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A" }, + new Book { Id = 2, Title = "" }, + new Book { Id = 3, Title = "Book C" }, + new Book { Id = 4, Title = "" } + }; + + var filter = new BookFilter_StringFilter_Matrix + { + Title = new StringFilter { IsEmpty = true } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_StringFilter_IsNotEmpty_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A" }, + new Book { Id = 2, Title = "" }, + new Book { Id = 3, Title = "Book C" }, + new Book { Id = 4, Title = "" } + }; + + var filter = new BookFilter_StringFilter_Matrix + { + Title = new StringFilter { IsNotEmpty = true } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_StringFilter_Contains_CaseSensitive() + { + var books = new[] + { + new Book { Id = 1, Title = "CLEAN CODE" }, + new Book { Id = 2, Title = "Clean Code" }, + new Book { Id = 3, Title = "clean code" }, + new Book { Id = 4, Title = "Clean Code" } + }; + + var filter = new BookFilter_StringFilter_Matrix + { + Title = new StringFilter + { + Contains = "Clean", + Compare = StringComparison.Ordinal + } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_StringFilter_MultipleOptions_OR_Semantics() + { + var books = new[] + { + new Book { Id = 1, Title = "Clean Code" }, + new Book { Id = 2, Title = "Patterns" }, + new Book { Id = 3, Title = "" }, + new Book { Id = 4, Title = "Refactoring" }, + new Book { Id = 5, Title = "Clean" } + }; + + var filter = new BookFilter_StringFilter_Matrix + { + Title = new StringFilter + { + StartsWith = "Clean", + IsEmpty = true, + CombineWith = CombineType.Or + } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_StringFilter_Eq_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "Clean Code" }, + new Book { Id = 2, Title = "Code Complete" }, + new Book { Id = 3, Title = "Clean Code" }, + new Book { Id = 4, Title = "Refactoring" } + }; + + var filter = new BookFilter_StringFilter_Matrix + { + Title = new StringFilter { Eq = "Clean Code" } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_StringFilter_Equals_Property_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "Clean Code" }, + new Book { Id = 2, Title = "Code Complete" }, + new Book { Id = 3, Title = "Clean Code" }, + new Book { Id = 4, Title = "Refactoring" } + }; + + var filter = new BookFilter_StringFilter_Matrix + { + Title = new StringFilter { Equals = "Clean Code" } + }; + + AssertParity(books, filter); + } + + #endregion + + #region CollectionFilterType.Any and Nested Chains + + [Fact] + public void Parity_CollectionFilterType_Any_NonEmptyCollection_MatchesRuntime() + { + var authors = new[] + { + new Author + { + Id = 1, + Name = "Author A", + Books = new List + { + new Book { Title = "Book 1", Year = 2000 }, + new Book { Title = "Book 2", Year = 2001 } + } + }, + new Author + { + Id = 2, + Name = "Author B", + Books = new List + { + new Book { Title = "Book 3", Year = 1999 }, + new Book { Title = "Book 4", Year = 2005 } + } + }, + new Author + { + Id = 3, + Name = "Author C", + Books = new List + { + new Book { Title = "Book 5", Year = 2002 } + } + } + }; + + var filter = new AuthorFilter_CollectionAny + { + Books = new BookNestedFilter + { + Year = new Range { Min = 2000 } + } + }; + + AssertParityAuthors(authors, filter); + } + + [Fact] + public void Parity_CollectionFilterType_Any_EmptyCollection_NoMatches() + { + var authors = new[] + { + new Author + { + Id = 1, + Name = "Author With No Books", + Books = new List() + }, + new Author + { + Id = 2, + Name = "Author With Books", + Books = new List + { + new Book { Title = "Book 1", Year = 2000 } + } + } + }; + + var filter = new AuthorFilter_CollectionAny + { + Books = new BookNestedFilter + { + Year = new Range { Min = 2000 } + } + }; + + AssertParityAuthors(authors, filter); + } + + [Fact] + public void Parity_CollectionFilterType_Any_NestedChain_Publisher_Authors_Books() + { + var publishers = new[] + { + new Publisher + { + Id = 1, + Name = "Tech Publisher", + Authors = new List + { + new Author + { + Id = 1, + Name = "Author A", + Books = new List + { + new Book { Title = "Clean Code", Year = 2008 }, + new Book { Title = "Code Complete", Year = 1993 } + } + }, + new Author + { + Id = 2, + Name = "Author B", + Books = new List + { + new Book { Title = "Refactoring", Year = 1999 }, + new Book { Title = "Patterns", Year = 1994 } + } + } + } + }, + new Publisher + { + Id = 2, + Name = "Other Publisher", + Authors = new List + { + new Author + { + Id = 3, + Name = "Author C", + Books = new List + { + new Book { Title = "Book X", Year = 2010 } + } + } + } + } + }; + + var filter = new PublisherFilter_NestedCollection + { + Authors = new AuthorNestedFilter + { + Books = new BookNestedFilter + { + Year = new Range { Min = 2000, Max = 2009 } + } + } + }; + + var runtime = ExecutePublishers(() => filter.ApplyFilterTo(publishers.AsQueryable()).ToList()); + var generated = ExecutePublishers(() => GeneratedFilterInvoker.ApplyFilter(publishers.AsQueryable(), filter).ToList()); + + Assert.Equal(runtime.Exception?.GetType(), generated.Exception?.GetType()); + + if (runtime.Exception != null) + { + Assert.Equal(runtime.Exception.Message, generated.Exception.Message); + return; + } + + Assert.Equal(runtime.Result.Select(x => x.Id), generated.Result.Select(x => x.Id)); + } + + [Fact] + public void Parity_CollectionFilterType_Any_MultipleFilters_AllMustMatch() + { + var authors = new[] + { + new Author + { + Id = 1, + Name = "Author A", + Books = new List + { + new Book { Title = "Clean Code", Year = 2008, TotalPage = 400 }, + new Book { Title = "Code Complete", Year = 1993, TotalPage = 900 } + } + }, + new Author + { + Id = 2, + Name = "Author B", + Books = new List + { + new Book { Title = "Clean Code", Year = 2008, TotalPage = 200 } + } + } + }; + + var filter = new AuthorFilter_CollectionAny + { + Books = new BookNestedFilter + { + Title = new StringFilter { Contains = "Clean" }, + Year = new Range { Min = 2000 }, + TotalPage = new OperatorFilter { Gt = 300 } + } + }; + + AssertParityAuthors(authors, filter); + } + + #endregion + + #region Multi-Target Range CompareTo + + [Fact] + public void Parity_Range_MultipleTargets_OR_Semantics_MatchesRuntime() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A", TotalPage = 250, ReadCount = 100 }, + new Book { Id = 2, Title = "Book B", TotalPage = 150, ReadCount = 300 }, + new Book { Id = 3, Title = "Book C", TotalPage = 400, ReadCount = 200 }, + new Book { Id = 4, Title = "Book D", TotalPage = 180, ReadCount = 80 } + }; + + var filter = new BookFilter_Range_MultipleProperty + { + PageRange = new Range { Min = 200, Max = 300 } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_Range_MinAndMax_BothBoundsEnforced() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A", TotalPage = 150 }, + new Book { Id = 2, Title = "Book B", TotalPage = 250 }, + new Book { Id = 3, Title = "Book C", TotalPage = 350 }, + new Book { Id = 4, Title = "Book D", TotalPage = 200 } + }; + + var filter = new BookFilter_Range + { + Year = new Range { Min = 200, Max = 300 } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_Range_OnlyMin_LowerBoundOnly() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A", TotalPage = 150 }, + new Book { Id = 2, Title = "Book B", TotalPage = 250 }, + new Book { Id = 3, Title = "Book C", TotalPage = 350 } + }; + + var filter = new BookFilter_Range + { + Year = new Range { Min = 200 } + }; + + AssertParity(books, filter); + } + + [Fact] + public void Parity_Range_OnlyMax_UpperBoundOnly() + { + var books = new[] + { + new Book { Id = 1, Title = "Book A", TotalPage = 150 }, + new Book { Id = 2, Title = "Book B", TotalPage = 250 }, + new Book { Id = 3, Title = "Book C", TotalPage = 350 } + }; + + var filter = new BookFilter_Range + { + Year = new Range { Max = 300 } + }; + + AssertParity(books, filter); + } + + #endregion + + #region Invalid CompareTo Filterable Type + + [Fact] + public void Parity_InvalidCompareTo_FilterableTypeMismatch_ThrowsOrIgnores() + { + var books = new[] + { + new Book { Id = 1, Title = "Test", TotalPage = 100 } + }; + + var filter = new BookFilter_InvalidFilterableType + { + Search = "value" + }; + + var runtime = Execute(() => filter.ApplyFilterTo(books.AsQueryable()).ToList()); + var generated = Execute(() => GeneratedFilterInvoker.ApplyFilter(books.AsQueryable(), filter).ToList()); + + Assert.Equal(runtime.Exception?.GetType(), generated.Exception?.GetType()); + + if (runtime.Exception != null) + { + Assert.Equal(runtime.Exception.Message, generated.Exception.Message); + } + else + { + Assert.Equal(runtime.Result.Select(x => x.Id), generated.Result.Select(x => x.Id)); + } + } + + #endregion + + private static void AssertParity(IEnumerable books, TFilter filter) + where TFilter : FilterBase + { + var runtime = Execute(() => filter.ApplyFilterTo(books.AsQueryable()).ToList()); + var generated = Execute(() => GeneratedFilterInvoker.ApplyFilter(books.AsQueryable(), filter).ToList()); + + Assert.Equal(runtime.Exception?.GetType(), generated.Exception?.GetType()); + + if (runtime.Exception != null) + { + Assert.Equal(runtime.Exception.Message, generated.Exception.Message); + return; + } + + Assert.Equal(runtime.Result.Select(x => x.Id), generated.Result.Select(x => x.Id)); + } + + private static void AssertParityAuthors(IEnumerable authors, TFilter filter) + where TFilter : FilterBase + { + var runtime = ExecuteAuthors(() => filter.ApplyFilterTo(authors.AsQueryable()).ToList()); + var generated = ExecuteAuthors(() => GeneratedFilterInvoker.ApplyFilter(authors.AsQueryable(), filter).ToList()); + + Assert.Equal(runtime.Exception?.GetType(), generated.Exception?.GetType()); + + if (runtime.Exception != null) + { + Assert.Equal(runtime.Exception.Message, generated.Exception.Message); + return; + } + + Assert.Equal(runtime.Result.Select(x => x.Id), generated.Result.Select(x => x.Id)); + } + + private static void AssertParityPublishers(IEnumerable publishers, TFilter filter) + where TFilter : FilterBase + { + var runtime = ExecutePublishers(() => filter.ApplyFilterTo(publishers.AsQueryable()).ToList()); + var generated = ExecutePublishers(() => GeneratedFilterInvoker.ApplyFilter(publishers.AsQueryable(), filter).ToList()); + + Assert.Equal(runtime.Exception?.GetType(), generated.Exception?.GetType()); + + if (runtime.Exception != null) + { + Assert.Equal(runtime.Exception.Message, generated.Exception.Message); + return; + } + + Assert.Equal(runtime.Result.Select(x => x.Id), generated.Result.Select(x => x.Id)); + } + + private static ExecutionResult Execute(Func> run) + { + try + { + return new ExecutionResult { Result = run() }; + } + catch (Exception ex) + { + return new ExecutionResult { Exception = ex }; + } + } + + private static ExecutionResultAuthors ExecuteAuthors(Func> run) + { + try + { + return new ExecutionResultAuthors { Result = run() }; + } + catch (Exception ex) + { + return new ExecutionResultAuthors { Exception = ex }; + } + } + + private static ExecutionResultPublishers ExecutePublishers(Func> run) + { + try + { + return new ExecutionResultPublishers { Result = run() }; + } + catch (Exception ex) + { + return new ExecutionResultPublishers { Exception = ex }; + } + } + + private sealed class ExecutionResult + { + public List Result { get; set; } = new List(); + public Exception Exception { get; set; } + } + + private sealed class ExecutionResultAuthors + { + public List Result { get; set; } = new List(); + public Exception Exception { get; set; } + } + + private sealed class ExecutionResultPublishers + { + public List Result { get; set; } = new List(); + public Exception Exception { get; set; } + } +} diff --git a/tests/AutoFilterer.Generators.Tests/GeneratorParityP2RecursiveMixedTests.cs b/tests/AutoFilterer.Generators.Tests/GeneratorParityP2RecursiveMixedTests.cs new file mode 100644 index 0000000..5538b50 --- /dev/null +++ b/tests/AutoFilterer.Generators.Tests/GeneratorParityP2RecursiveMixedTests.cs @@ -0,0 +1,1840 @@ +using AutoFilterer.Enums; +using AutoFilterer.Extensions; +using AutoFilterer.Generators.Tests.Environment.Dtos; +using AutoFilterer.Generators.Tests.Environment.Models; +using AutoFilterer.Types; +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace AutoFilterer.Generators.Tests; + +/// +/// Phase 2: Recursive N-depth mixed quantifier parity tests. +/// Covers mixed Any/All chains across depths 1-8 with various patterns: +/// - all-any +/// - all-all +/// - alternating any/all +/// - prefix-any-suffix-all +/// - sparse flips +/// +public class GeneratorParityP2RecursiveMixedTests +{ + #region Depth 1: Single Level Any (Baseline) + + [Fact] + public void Parity_Recursive_D1_Any_MatchesRuntime() + { + var data = new[] + { + new Level1 + { + Id = 1, + Value = "A", + Children = new List + { + new Level2 + { + Id = 1, + Value = "X", + Children = new List + { + new Level3 + { + Id = 1, + Value = "Target", + Children = new List + { + new Level4 + { + Id = 1, + Value = "L4", + Children = new List + { + new Level5 + { + Id = 1, + Value = "L5", + Children = new List + { + new Level6 + { + Id = 1, + Value = "L6", + Children = new List + { + new Level7 + { + Id = 1, + Value = "L7", + Children = new List + { + new Level8 { Id = 1, Value = "Match", Score = 50 } + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + new Level1 + { + Id = 2, + Value = "B", + Children = new List + { + new Level2 + { + Id = 2, + Value = "Y", + Children = new List + { + new Level3 + { + Id = 2, + Value = "NoMatch", + Children = new List + { + new Level4 + { + Id = 2, + Value = "L4", + Children = new List + { + new Level5 + { + Id = 2, + Value = "L5", + Children = new List + { + new Level6 + { + Id = 2, + Value = "L6", + Children = new List + { + new Level7 + { + Id = 2, + Value = "L7", + Children = new List + { + new Level8 { Id = 2, Value = "Other", Score = 30 } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + }; + + var filter = new Level1Filter_AnyAllMixed + { + Children = new Level2Filter + { + Children = new Level3Filter + { + Children = new Level4Filter + { + Children = new Level5Filter + { + Children = new Level6Filter + { + Children = new Level7Filter + { + Children = new Level8Filter + { + Value = new StringFilter { Contains = "Match" } + } + } + } + } + } + } + } + }; + + AssertParityLevel1(data, filter); + } + + #endregion + + #region Depth 2-4: All-Any Pattern + + [Fact] + public void Parity_Recursive_D2_AllAny_MatchesRuntime() + { + var data = new[] + { + new Level1 + { + Id = 1, + Value = "A", + Children = new List + { + new Level2 + { + Id = 1, + Value = "X", + Children = new List + { + new Level3 + { + Id = 1, + Value = "Target", + Children = new List + { + new Level4 + { + Id = 1, + Value = "L4", + Children = new List + { + new Level5 + { + Id = 1, + Value = "L5", + Children = new List + { + new Level6 + { + Id = 1, + Value = "L6", + Children = new List + { + new Level7 + { + Id = 1, + Value = "L7", + Children = new List + { + new Level8 { Id = 1, Value = "Match", Score = 50 } + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + new Level1 + { + Id = 2, + Value = "B", + Children = new List + { + new Level2 + { + Id = 2, + Value = "Y", + Children = new List + { + new Level3 + { + Id = 2, + Value = "NoMatch", + Children = new List + { + new Level4 + { + Id = 2, + Value = "L4", + Children = new List + { + new Level5 + { + Id = 2, + Value = "L5", + Children = new List + { + new Level6 + { + Id = 2, + Value = "L6", + Children = new List + { + new Level7 + { + Id = 2, + Value = "L7", + Children = new List + { + new Level8 { Id = 2, Value = "Other", Score = 30 } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + }; + + var filter = new Level1Filter_AllAnyAnyAnyAnyAnyAny + { + Children = new Level2Filter_AllAnyAnyAnyAnyAny + { + Children = new Level3Filter_AllAnyAnyAnyAny + { + Children = new Level4Filter_AllAnyAnyAny + { + Children = new Level5Filter_AllAnyAny + { + Children = new Level6Filter_AllAny + { + Children = new Level7Filter_All + { + Children = new Level8Filter + { + Value = new StringFilter { Contains = "Match" } + } + } + } + } + } + } + } + }; + + AssertParityLevel1(data, filter); + } + + [Fact] + public void Parity_Recursive_D3_AllAnyAny_VacuousTruth() + { + var data = new[] + { + new Level1 { Id = 1, Value = "A", Children = new List() }, + new Level1 + { + Id = 2, + Value = "B", + Children = new List + { + new Level2 { Id = 1, Value = "X", Children = new List() } + } + } + }; + + var filter = new Level1Filter_AllAnyAnyAnyAnyAnyAny + { + Children = new Level2Filter_AllAnyAnyAnyAnyAny + { + Children = new Level3Filter_AllAnyAnyAnyAny + { + Children = new Level4Filter_AllAnyAnyAny + { + Children = new Level5Filter_AllAnyAny + { + Children = new Level6Filter_AllAny + { + Children = new Level7Filter_All + { + Children = new Level8Filter + { + Value = new StringFilter { Contains = "Match" } + } + } + } + } + } + } + } + }; + + AssertParityLevel1(data, filter); + } + + #endregion + + #region Depth 5-8: All-All Pattern + + [Fact] + public void Parity_Recursive_D7_AllAll_MatchesRuntime() + { + var data = new[] + { + new Level1 + { + Id = 1, + Value = "A", + Children = new List + { + new Level2 + { + Id = 1, + Value = "X", + Children = new List + { + new Level3 + { + Id = 1, + Value = "T1", + Children = new List + { + new Level4 + { + Id = 1, + Value = "L4", + Children = new List + { + new Level5 + { + Id = 1, + Value = "L5", + Children = new List + { + new Level6 + { + Id = 1, + Value = "L6", + Children = new List + { + new Level7 + { + Id = 1, + Value = "L7", + Children = new List + { + new Level8 { Id = 1, Value = "AllMatch", Score = 50 }, + new Level8 { Id = 2, Value = "AllMatch", Score = 60 } + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + new Level1 + { + Id = 2, + Value = "B", + Children = new List + { + new Level2 + { + Id = 2, + Value = "Y", + Children = new List + { + new Level3 + { + Id = 2, + Value = "T2", + Children = new List + { + new Level4 + { + Id = 2, + Value = "L4", + Children = new List + { + new Level5 + { + Id = 2, + Value = "L5", + Children = new List + { + new Level6 + { + Id = 2, + Value = "L6", + Children = new List + { + new Level7 + { + Id = 2, + Value = "L7", + Children = new List + { + new Level8 { Id = 3, Value = "AllMatch", Score = 70 } + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + new Level1 + { + Id = 3, + Value = "C", + Children = new List + { + new Level2 + { + Id = 3, + Value = "Z", + Children = new List + { + new Level3 + { + Id = 3, + Value = "T3", + Children = new List + { + new Level4 + { + Id = 3, + Value = "L4", + Children = new List + { + new Level5 + { + Id = 3, + Value = "L5", + Children = new List + { + new Level6 + { + Id = 3, + Value = "L6", + Children = new List + { + new Level7 + { + Id = 3, + Value = "L7", + Children = new List + { + new Level8 { Id = 4, Value = "NoMatch", Score = 10 } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + }; + + var filter = new Level1Filter_AllAll + { + Children = new Level2Filter_AllAll + { + Children = new Level3Filter_AllAll + { + Children = new Level4Filter_AllAll + { + Children = new Level5Filter_AllAll + { + Children = new Level6Filter_AllAll + { + Children = new Level7Filter_All + { + Children = new Level8Filter + { + Value = new StringFilter { Contains = "AllMatch" } + } + } + } + } + } + } + } + }; + + AssertParityLevel1(data, filter); + } + + #endregion + + #region Depth 4: AllAnyAnyAny Pattern + + [Fact] + public void Parity_Recursive_D4_AllAnyAnyAny_MatchesRuntime() + { + var data = new[] + { + new Level1 + { + Id = 1, + Value = "A", + Children = new List + { + new Level2 + { + Id = 1, + Value = "X", + Children = new List + { + new Level3 + { + Id = 1, + Value = "Target", + Children = new List + { + new Level4 + { + Id = 1, + Value = "L4", + Children = new List + { + new Level5 + { + Id = 1, + Value = "L5", + Children = new List + { + new Level6 + { + Id = 1, + Value = "L6", + Children = new List + { + new Level7 + { + Id = 1, + Value = "L7", + Children = new List + { + new Level8 { Id = 1, Value = "Match", Score = 50 } + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + new Level1 + { + Id = 2, + Value = "B", + Children = new List + { + new Level2 + { + Id = 2, + Value = "Y", + Children = new List + { + new Level3 + { + Id = 2, + Value = "NoMatch", + Children = new List + { + new Level4 + { + Id = 2, + Value = "L4", + Children = new List + { + new Level5 + { + Id = 2, + Value = "L5", + Children = new List + { + new Level6 + { + Id = 2, + Value = "L6", + Children = new List + { + new Level7 + { + Id = 2, + Value = "L7", + Children = new List + { + new Level8 { Id = 2, Value = "Other", Score = 30 } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + }; + + var filter = new Level1Filter_AllAnyAnyAnyAnyAnyAny + { + Children = new Level2Filter_AllAnyAnyAnyAnyAny + { + Children = new Level3Filter_AllAnyAnyAnyAny + { + Children = new Level4Filter_AllAnyAnyAny + { + Children = new Level5Filter_AllAnyAny + { + Children = new Level6Filter_AllAny + { + Children = new Level7Filter_All + { + Children = new Level8Filter + { + Value = new StringFilter { Contains = "Match" } + } + } + } + } + } + } + } + }; + + AssertParityLevel1(data, filter); + } + + #endregion + + #region Depth 5: AllAnyAnyAnyAny Pattern + + [Fact] + public void Parity_Recursive_D5_AllAnyAnyAnyAny_MatchesRuntime() + { + var data = new[] + { + new Level1 + { + Id = 1, + Value = "A", + Children = new List + { + new Level2 + { + Id = 1, + Value = "X", + Children = new List + { + new Level3 + { + Id = 1, + Value = "Target", + Children = new List + { + new Level4 + { + Id = 1, + Value = "L4", + Children = new List + { + new Level5 + { + Id = 1, + Value = "L5", + Children = new List + { + new Level6 + { + Id = 1, + Value = "L6", + Children = new List + { + new Level7 + { + Id = 1, + Value = "L7", + Children = new List + { + new Level8 { Id = 1, Value = "Match", Score = 50 } + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + new Level1 + { + Id = 2, + Value = "B", + Children = new List + { + new Level2 + { + Id = 2, + Value = "Y", + Children = new List + { + new Level3 + { + Id = 2, + Value = "NoMatch", + Children = new List + { + new Level4 + { + Id = 2, + Value = "L4", + Children = new List + { + new Level5 + { + Id = 2, + Value = "L5", + Children = new List + { + new Level6 + { + Id = 2, + Value = "L6", + Children = new List + { + new Level7 + { + Id = 2, + Value = "L7", + Children = new List + { + new Level8 { Id = 2, Value = "Other", Score = 30 } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + }; + + var filter = new Level1Filter_AllAnyAnyAnyAnyAnyAny + { + Children = new Level2Filter_AllAnyAnyAnyAnyAny + { + Children = new Level3Filter_AllAnyAnyAnyAny + { + Children = new Level4Filter_AllAnyAnyAny + { + Children = new Level5Filter_AllAnyAny + { + Children = new Level6Filter_AllAny + { + Children = new Level7Filter_All + { + Children = new Level8Filter + { + Value = new StringFilter { Contains = "Match" } + } + } + } + } + } + } + } + }; + + AssertParityLevel1(data, filter); + } + + #endregion + + #region Depth 6: AllAnyAnyAnyAnyAny Pattern + + [Fact] + public void Parity_Recursive_D6_AllAnyAnyAnyAnyAny_MatchesRuntime() + { + var data = new[] + { + new Level1 + { + Id = 1, + Value = "A", + Children = new List + { + new Level2 + { + Id = 1, + Value = "X", + Children = new List + { + new Level3 + { + Id = 1, + Value = "Target", + Children = new List + { + new Level4 + { + Id = 1, + Value = "L4", + Children = new List + { + new Level5 + { + Id = 1, + Value = "L5", + Children = new List + { + new Level6 + { + Id = 1, + Value = "L6", + Children = new List + { + new Level7 + { + Id = 1, + Value = "L7", + Children = new List + { + new Level8 { Id = 1, Value = "Match", Score = 50 } + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + new Level1 + { + Id = 2, + Value = "B", + Children = new List + { + new Level2 + { + Id = 2, + Value = "Y", + Children = new List + { + new Level3 + { + Id = 2, + Value = "NoMatch", + Children = new List + { + new Level4 + { + Id = 2, + Value = "L4", + Children = new List + { + new Level5 + { + Id = 2, + Value = "L5", + Children = new List + { + new Level6 + { + Id = 2, + Value = "L6", + Children = new List + { + new Level7 + { + Id = 2, + Value = "L7", + Children = new List + { + new Level8 { Id = 2, Value = "Other", Score = 30 } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + }; + + var filter = new Level1Filter_AllAnyAnyAnyAnyAnyAny + { + Children = new Level2Filter_AllAnyAnyAnyAnyAny + { + Children = new Level3Filter_AllAnyAnyAnyAny + { + Children = new Level4Filter_AllAnyAnyAny + { + Children = new Level5Filter_AllAnyAny + { + Children = new Level6Filter_AllAny + { + Children = new Level7Filter_All + { + Children = new Level8Filter + { + Value = new StringFilter { Contains = "Match" } + } + } + } + } + } + } + } + }; + + AssertParityLevel1(data, filter); + } + + #endregion + + #region Depth 8: Full Depth AllAnyAnyAnyAnyAnyAnyAny Pattern + + [Fact] + public void Parity_Recursive_D8_FullDepth_MatchesRuntime() + { + var data = new[] + { + new Level1 + { + Id = 1, + Value = "A", + Children = new List + { + new Level2 + { + Id = 1, + Value = "X", + Children = new List + { + new Level3 + { + Id = 1, + Value = "Target", + Children = new List + { + new Level4 + { + Id = 1, + Value = "L4", + Children = new List + { + new Level5 + { + Id = 1, + Value = "L5", + Children = new List + { + new Level6 + { + Id = 1, + Value = "L6", + Children = new List + { + new Level7 + { + Id = 1, + Value = "L7", + Children = new List + { + new Level8 { Id = 1, Value = "Match", Score = 50 } + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + new Level1 + { + Id = 2, + Value = "B", + Children = new List + { + new Level2 + { + Id = 2, + Value = "Y", + Children = new List + { + new Level3 + { + Id = 2, + Value = "NoMatch", + Children = new List + { + new Level4 + { + Id = 2, + Value = "L4", + Children = new List + { + new Level5 + { + Id = 2, + Value = "L5", + Children = new List + { + new Level6 + { + Id = 2, + Value = "L6", + Children = new List + { + new Level7 + { + Id = 2, + Value = "L7", + Children = new List + { + new Level8 { Id = 2, Value = "Other", Score = 30 } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + }; + + var filter = new Level1Filter_AllAnyAnyAnyAnyAnyAny + { + Children = new Level2Filter_AllAnyAnyAnyAnyAny + { + Children = new Level3Filter_AllAnyAnyAnyAny + { + Children = new Level4Filter_AllAnyAnyAny + { + Children = new Level5Filter_AllAnyAny + { + Children = new Level6Filter_AllAny + { + Children = new Level7Filter_All + { + Children = new Level8Filter + { + Value = new StringFilter { Contains = "Match" } + } + } + } + } + } + } + } + }; + + AssertParityLevel1(data, filter); + } + + #endregion + + #region Alternating Any/All Pattern + + [Fact] + public void Parity_Recursive_AltPattern_MatchesRuntime() + { + var data = new[] + { + new Level1 + { + Id = 1, + Value = "A", + Children = new List + { + new Level2 + { + Id = 1, + Value = "X", + Children = new List + { + new Level3 + { + Id = 1, + Value = "Target", + Children = new List + { + new Level4 + { + Id = 1, + Value = "L4", + Children = new List + { + new Level5 + { + Id = 1, + Value = "L5", + Children = new List + { + new Level6 + { + Id = 1, + Value = "L6", + Children = new List + { + new Level7 + { + Id = 1, + Value = "L7", + Children = new List + { + new Level8 { Id = 1, Value = "Match", Score = 50 }, + new Level8 { Id = 2, Value = "Match2", Score = 60 } + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + new Level1 + { + Id = 2, + Value = "B", + Children = new List + { + new Level2 + { + Id = 2, + Value = "Y", + Children = new List + { + new Level3 + { + Id = 2, + Value = "NoMatch", + Children = new List + { + new Level4 + { + Id = 2, + Value = "L4", + Children = new List + { + new Level5 + { + Id = 2, + Value = "L5", + Children = new List + { + new Level6 + { + Id = 2, + Value = "L6", + Children = new List + { + new Level7 + { + Id = 2, + Value = "L7", + Children = new List + { + new Level8 { Id = 3, Value = "NoMatch", Score = 10 } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + }; + + var filter = new Level1Filter_AltPattern + { + Children = new Level2Filter_AltPattern + { + Children = new Level3Filter_AltPattern + { + Children = new Level4Filter_AltPattern + { + Children = new Level5Filter_AltPattern + { + Children = new Level6Filter_AltPattern + { + Children = new Level7Filter_All + { + Children = new Level8Filter + { + Value = new StringFilter { Contains = "Match" } + } + } + } + } + } + } + } + }; + + AssertParityLevel1(data, filter); + } + + #endregion + + #region Prefix-Any-Suffix-All Pattern (Depth 8) + + [Fact] + public void Parity_Recursive_D8_PrefixAnySuffixAll_MatchesRuntime() + { + var data = new[] + { + new Level1 + { + Id = 1, + Value = "A", + Children = new List + { + new Level2 + { + Id = 1, + Value = "X", + Children = new List + { + new Level3 + { + Id = 1, + Value = "T1", + Children = new List + { + new Level4 + { + Id = 1, + Value = "L4", + Children = new List + { + new Level5 + { + Id = 1, + Value = "L5", + Children = new List + { + new Level6 + { + Id = 1, + Value = "L6", + Children = new List + { + new Level7 + { + Id = 1, + Value = "L7", + Children = new List + { + new Level8 { Id = 1, Value = "Target", Score = 50 }, + new Level8 { Id = 2, Value = "Target", Score = 55 } + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + new Level1 + { + Id = 2, + Value = "B", + Children = new List + { + new Level2 + { + Id = 2, + Value = "Y", + Children = new List + { + new Level3 + { + Id = 2, + Value = "T2", + Children = new List + { + new Level4 + { + Id = 2, + Value = "L4", + Children = new List + { + new Level5 + { + Id = 2, + Value = "L5", + Children = new List + { + new Level6 + { + Id = 2, + Value = "L6", + Children = new List + { + new Level7 + { + Id = 2, + Value = "L7", + Children = new List + { + new Level8 { Id = 3, Value = "Target", Score = 60 }, + new Level8 { Id = 4, Value = "Other", Score = 10 } + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + new Level1 + { + Id = 3, + Value = "C", + Children = new List + { + new Level2 + { + Id = 3, + Value = "Z", + Children = new List + { + new Level3 + { + Id = 3, + Value = "T3", + Children = new List + { + new Level4 + { + Id = 3, + Value = "L4", + Children = new List + { + new Level5 + { + Id = 3, + Value = "L5", + Children = new List + { + new Level6 + { + Id = 3, + Value = "L6", + Children = new List + { + new Level7 + { + Id = 3, + Value = "L7", + Children = new List + { + new Level8 { Id = 5, Value = "NoMatch", Score = 20 } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + }; + + var filter = new Level1Filter_PrefixAnySuffixAll + { + Children = new Level2Filter_PrefixAnySuffixAll + { + Children = new Level3Filter_PrefixAnySuffixAll + { + Children = new Level4Filter_PrefixAnySuffixAll + { + Children = new Level5Filter_PrefixAnySuffixAll + { + Children = new Level6Filter_PrefixAnySuffixAll + { + Children = new Level7Filter_PrefixAnySuffixAll + { + Children = new Level8Filter + { + Value = new StringFilter { Contains = "Target" } + } + } + } + } + } + } + } + }; + + AssertParityLevel1(data, filter); + } + + #endregion + + #region Sparse Flips Pattern (Depth 8: Any-Any-All-Any-All-All-Any-All) + + [Fact] + public void Parity_Recursive_D8_SparseFlips_MatchesRuntime() + { + var data = new[] + { + new Level1 + { + Id = 1, + Value = "A", + Children = new List + { + new Level2 + { + Id = 1, + Value = "X", + Children = new List + { + new Level3 + { + Id = 1, + Value = "T1", + Children = new List + { + new Level4 + { + Id = 1, + Value = "L4", + Children = new List + { + new Level5 + { + Id = 1, + Value = "L5", + Children = new List + { + new Level6 + { + Id = 1, + Value = "L6", + Children = new List + { + new Level7 + { + Id = 1, + Value = "L7", + Children = new List + { + new Level8 { Id = 1, Value = "Match", Score = 50 } + } + } + } + }, + new Level6 + { + Id = 2, + Value = "L6b", + Children = new List + { + new Level7 + { + Id = 2, + Value = "L7b", + Children = new List + { + new Level8 { Id = 2, Value = "Other", Score = 10 } + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + new Level1 + { + Id = 2, + Value = "B", + Children = new List + { + new Level2 + { + Id = 2, + Value = "Y", + Children = new List + { + new Level3 + { + Id = 2, + Value = "T2", + Children = new List + { + new Level4 + { + Id = 2, + Value = "L4", + Children = new List + { + new Level5 + { + Id = 2, + Value = "L5", + Children = new List + { + new Level6 + { + Id = 3, + Value = "L6", + Children = new List + { + new Level7 + { + Id = 3, + Value = "L7", + Children = new List + { + new Level8 { Id = 3, Value = "Match", Score = 60 } + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + new Level1 + { + Id = 3, + Value = "C", + Children = new List + { + new Level2 + { + Id = 3, + Value = "Z", + Children = new List + { + new Level3 + { + Id = 3, + Value = "T3", + Children = new List + { + new Level4 + { + Id = 3, + Value = "L4", + Children = new List + { + new Level5 + { + Id = 3, + Value = "L5", + Children = new List + { + new Level6 + { + Id = 4, + Value = "L6", + Children = new List + { + new Level7 + { + Id = 4, + Value = "L7", + Children = new List + { + new Level8 { Id = 4, Value = "NoMatch", Score = 30 } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + }; + + var filter = new Level1Filter_SparseFlips + { + Children = new Level2Filter_SparseFlips + { + Children = new Level3Filter_SparseFlips + { + Children = new Level4Filter_SparseFlips + { + Children = new Level5Filter_SparseFlips + { + Children = new Level6Filter_SparseFlips + { + Children = new Level7Filter_SparseFlips + { + Children = new Level8Filter + { + Value = new StringFilter { Contains = "Match" } + } + } + } + } + } + } + } + }; + + AssertParityLevel1(data, filter); + } + + #endregion + + #region Helper Methods + + private static void AssertParityLevel1(IEnumerable data, TFilter filter) + where TFilter : FilterBase + { + var runtime = ExecuteLevel1(() => filter.ApplyFilterTo(data.AsQueryable()).ToList()); + var generated = ExecuteLevel1(() => GeneratedFilterInvoker.ApplyFilter(data.AsQueryable(), filter).ToList()); + + Assert.Equal(runtime.Exception?.GetType(), generated.Exception?.GetType()); + + if (runtime.Exception != null) + { + Assert.Equal(runtime.Exception.Message, generated.Exception.Message); + return; + } + + Assert.Equal(runtime.Result.Select(x => x.Id), generated.Result.Select(x => x.Id)); + } + + private static ExecutionResultLevel1 ExecuteLevel1(Func> run) + { + try + { + return new ExecutionResultLevel1 { Result = run() }; + } + catch (Exception ex) + { + return new ExecutionResultLevel1 { Exception = ex }; + } + } + + private sealed class ExecutionResultLevel1 + { + public List Result { get; set; } = new List(); + public Exception Exception { get; set; } + } + + #endregion +} diff --git a/tests/AutoFilterer.Generators.Tests/GeneratorSmokeTests.cs b/tests/AutoFilterer.Generators.Tests/GeneratorSmokeTests.cs new file mode 100644 index 0000000..6e8950a --- /dev/null +++ b/tests/AutoFilterer.Generators.Tests/GeneratorSmokeTests.cs @@ -0,0 +1,446 @@ +using AutoFilterer.Generators.Tests.Environment.Dtos; +using AutoFilterer.Generators.Tests.Environment.Models; +using System.Linq; +using Xunit; + +namespace AutoFilterer.Generators.Tests; + +/// +/// These tests validate that the source generator creates compilable code +/// and that the ApplyFilter extension method works correctly. +/// +public class GeneratorSmokeTests +{ + [Fact] + public void Generator_CreatesApplyFilterExtension_ForBasicFilter() + { + // Arrange + var data = TestDataHelper.GetSampleBooks(); + var filter = new BookFilter_Basic { Title = "Clean Code" }; + + // Act - This will only compile if the generator created the ApplyFilter extension + var result = data.AsQueryable().ApplyFilter(filter).ToList(); + + // Assert + Assert.Single(result); + Assert.Equal("Clean Code", result[0].Title); + } + + [Fact] + public void Generator_CreatesApplyFilterExtension_ForStringFilter() + { + // Arrange + var data = TestDataHelper.GetSampleBooks(); + var filter = new BookFilter_StringFilter + { + Title = new AutoFilterer.Types.StringFilter { Eq = "Clean Code" } + }; + + // Act + var result = data.AsQueryable().ApplyFilter(filter).ToList(); + + // Assert + Assert.Single(result); + } + + [Fact] + public void Generator_CreatesApplyFilterExtension_ForOperatorFilter() + { + // Arrange + var data = TestDataHelper.GetSampleBooks(); + var filter = new BookFilter_OperatorFilter + { + TotalPage = new AutoFilterer.Types.OperatorFilter { Gt = 400 } + }; + + // Act + var result = data.AsQueryable().ApplyFilter(filter).ToList(); + + // Assert + Assert.True(result.Count > 0); + Assert.All(result, book => Assert.True(book.TotalPage > 400)); + } + + [Fact] + public void Generator_CreatesApplyFilterExtension_ForRangeFilter() + { + // Arrange + var data = TestDataHelper.GetSampleBooks(); + var filter = new BookFilter_Range + { + Year = new AutoFilterer.Types.Range { Min = 2000, Max = 2010 } + }; + + // Act + var result = data.AsQueryable().ApplyFilter(filter).ToList(); + + // Assert + Assert.True(result.Count > 0); + Assert.All(result, book => Assert.True(book.Year >= 2000 && book.Year <= 2010)); + } + + [Fact] + public void Generator_CreatesApplyFilterExtension_ForOrderableFilter() + { + // Arrange + var data = TestDataHelper.GetSampleBooks(); + var filter = new BookFilter_Orderable + { + SortBy = AutoFilterer.Enums.Sorting.Descending, + Sort = nameof(Environment.Models.Book.Year) + }; + + // Act + var result = data.AsQueryable().ApplyFilter(filter).ToList(); + + // Assert + Assert.Equal(data.Count, result.Count); + // Check if sorted descending by year + for (int i = 0; i < result.Count - 1; i++) + { + Assert.True(result[i].Year >= result[i + 1].Year); + } + } + + [Fact] + public void Generator_CreatesApplyFilterExtension_ForPaginationFilter() + { + // Arrange + var data = TestDataHelper.GetSampleBooks(); + var filter = new BookFilter_Pagination + { + Page = 2, + PerPage = 3 + }; + + // Act + var result = data.AsQueryable().ApplyFilter(filter).ToList(); + + // Assert + Assert.Equal(3, result.Count); + } + + [Fact] + public void Generator_CreatesApplyFilterExtension_ForCollectionFilter() + { + // Arrange + var data = TestDataHelper.GetSampleAuthors(); + var filter = new AuthorFilter_CollectionAny + { + Books = new BookNestedFilter + { + Title = new AutoFilterer.Types.StringFilter { Eq = "Clean Code" } + } + }; + + // Act + var result = data.AsQueryable().ApplyFilter(filter).ToList(); + + // Assert + Assert.Single(result); + Assert.Equal("Robert C. Martin", result[0].Name); + } + + [Fact] + public void Generator_CreatesApplyFilterExtension_ForNestedCollectionFilter() + { + // Arrange + var data = TestDataHelper.GetSamplePublishers(); + var filter = new PublisherFilter_NestedCollection + { + Authors = new AuthorNestedFilter + { + Books = new BookNestedFilter + { + Year = new AutoFilterer.Types.Range { Min = 2008, Max = 2008 } + } + } + }; + + // Act + var result = data.AsQueryable().ApplyFilter(filter).ToList(); + + // Assert + Assert.Single(result); + Assert.Equal("Pearson", result[0].Name); + } + + [Fact] + public void Generator_CreatesApplyFilterExtension_ForCompleteFilter() + { + // Arrange + var data = TestDataHelper.GetSampleAuthors(); + var filter = new AuthorFilter_Complete + { + Country = "USA", + Books = new BookNestedFilter + { + Year = new AutoFilterer.Types.Range { Min = 2008, Max = 2008 } + }, + Page = 1, + PerPage = 10 + }; + + // Act + var result = data.AsQueryable().ApplyFilter(filter).ToList(); + + // Assert + Assert.Single(result); + Assert.Equal("Robert C. Martin", result[0].Name); + } + + [Fact] + public void Generator_AppliesStringFilter_AllStringMembers() + { + // Arrange + var books = new[] + { + new Book { Id = 1, Title = null, TotalPage = 100, Views = null }, + new Book { Id = 2, Title = string.Empty, TotalPage = 200, Views = 10 }, + new Book { Id = 3, Title = "Alpha", TotalPage = 300, Views = 20 }, + new Book { Id = 4, Title = "AlphaBeta", TotalPage = 400, Views = null }, + new Book { Id = 5, Title = "Beta", TotalPage = 500, Views = 30 }, + new Book { Id = 6, Title = "Gamma", TotalPage = 600, Views = 40 }, + }; + + var eq = books.AsQueryable().ApplyFilter(new BookFilter_StringFilter_Advanced { Title = new AutoFilterer.Types.StringFilter { Eq = "Alpha" } }).ToList(); + var not = books.AsQueryable().ApplyFilter(new BookFilter_StringFilter_Advanced { Title = new AutoFilterer.Types.StringFilter { Not = "Alpha" } }).ToList(); + var equals = books.AsQueryable().ApplyFilter(new BookFilter_StringFilter_Advanced { Title = new AutoFilterer.Types.StringFilter { Equals = "AlphaBeta" } }).ToList(); + var contains = books.AsQueryable().ApplyFilter(new BookFilter_StringFilter_Advanced { Title = new AutoFilterer.Types.StringFilter { Contains = "Alpha" } }).ToList(); + var notContains = books.AsQueryable().ApplyFilter(new BookFilter_StringFilter_Advanced { Title = new AutoFilterer.Types.StringFilter { NotContains = "Alpha" } }).ToList(); + var startsWith = books.AsQueryable().ApplyFilter(new BookFilter_StringFilter_Advanced { Title = new AutoFilterer.Types.StringFilter { StartsWith = "Al" } }).ToList(); + var notStartsWith = books.AsQueryable().ApplyFilter(new BookFilter_StringFilter_Advanced { Title = new AutoFilterer.Types.StringFilter { NotStartsWith = "Al" } }).ToList(); + var endsWith = books.AsQueryable().ApplyFilter(new BookFilter_StringFilter_Advanced { Title = new AutoFilterer.Types.StringFilter { EndsWith = "ta" } }).ToList(); + var notEndsWith = books.AsQueryable().ApplyFilter(new BookFilter_StringFilter_Advanced { Title = new AutoFilterer.Types.StringFilter { NotEndsWith = "ta" } }).ToList(); + var isNull = books.AsQueryable().ApplyFilter(new BookFilter_StringFilter_Advanced { Title = new AutoFilterer.Types.StringFilter { IsNull = true } }).ToList(); + var isNotNull = books.AsQueryable().ApplyFilter(new BookFilter_StringFilter_Advanced { Title = new AutoFilterer.Types.StringFilter { IsNotNull = true } }).ToList(); + var isEmpty = books.AsQueryable().ApplyFilter(new BookFilter_StringFilter_Advanced { Title = new AutoFilterer.Types.StringFilter { IsEmpty = true } }).ToList(); + var isNotEmpty = books.AsQueryable().ApplyFilter(new BookFilter_StringFilter_Advanced { Title = new AutoFilterer.Types.StringFilter { IsNotEmpty = true } }).ToList(); + var andCombined = books.AsQueryable().ApplyFilter(new BookFilter_StringFilter_Advanced + { + Title = new AutoFilterer.Types.StringFilter { Contains = "Alpha", EndsWith = "ta", CombineWith = AutoFilterer.Enums.CombineType.And } + }).ToList(); + var orCombined = books.AsQueryable().ApplyFilter(new BookFilter_StringFilter_Advanced + { + Title = new AutoFilterer.Types.StringFilter { Contains = "Alpha", StartsWith = "Ga", CombineWith = AutoFilterer.Enums.CombineType.Or } + }).ToList(); + + // Nested path coverage (BookNestedFilter.Title) + var authors = new[] + { + new Author { Id = 1, Name = "A", Books = books.Where(x => x.Id > 1 && x.Id <= 3).ToList() }, + new Author { Id = 2, Name = "B", Books = books.Where(x => x.Id >= 4).ToList() } + }; + var nestedAnd = authors.AsQueryable().ApplyFilter(new AuthorFilter_CollectionAny + { + Books = new BookNestedFilter + { + Title = new AutoFilterer.Types.StringFilter { Contains = "Alpha", EndsWith = "ta", CombineWith = AutoFilterer.Enums.CombineType.And } + } + }).ToList(); + + // Assert + Assert.Single(eq); + Assert.Equal(5, not.Count); + Assert.Single(equals); + Assert.Equal(2, contains.Count); + Assert.Equal(4, notContains.Count); + Assert.Equal(2, startsWith.Count); + Assert.Equal(4, notStartsWith.Count); + Assert.Equal(2, endsWith.Count); + Assert.Equal(4, notEndsWith.Count); + Assert.Single(isNull); + Assert.Equal(5, isNotNull.Count); + Assert.Single(isEmpty); + Assert.Equal(5, isNotEmpty.Count); + Assert.Single(andCombined); + Assert.Equal("AlphaBeta", andCombined[0].Title); + Assert.Equal(3, orCombined.Count); + Assert.Single(nestedAnd); + Assert.Equal("B", nestedAnd[0].Name); + } + + [Fact] + public void Generator_AppliesOperatorFilter_AllOperatorMembers() + { + // Arrange + var books = new[] + { + new Book { Id = 1, TotalPage = 100, Views = null }, + new Book { Id = 2, TotalPage = 200, Views = 10 }, + new Book { Id = 3, TotalPage = 300, Views = 20 }, + new Book { Id = 4, TotalPage = 400, Views = null }, + new Book { Id = 5, TotalPage = 500, Views = 30 }, + }; + + var eq = books.AsQueryable().ApplyFilter(new BookFilter_OperatorFilter_Advanced { TotalPage = new AutoFilterer.Types.OperatorFilter { Eq = 300 } }).ToList(); + var not = books.AsQueryable().ApplyFilter(new BookFilter_OperatorFilter_Advanced { TotalPage = new AutoFilterer.Types.OperatorFilter { Not = 300 } }).ToList(); + var gt = books.AsQueryable().ApplyFilter(new BookFilter_OperatorFilter_Advanced { TotalPage = new AutoFilterer.Types.OperatorFilter { Gt = 300 } }).ToList(); + var lt = books.AsQueryable().ApplyFilter(new BookFilter_OperatorFilter_Advanced { TotalPage = new AutoFilterer.Types.OperatorFilter { Lt = 300 } }).ToList(); + var gte = books.AsQueryable().ApplyFilter(new BookFilter_OperatorFilter_Advanced { TotalPage = new AutoFilterer.Types.OperatorFilter { Gte = 300 } }).ToList(); + var lte = books.AsQueryable().ApplyFilter(new BookFilter_OperatorFilter_Advanced { TotalPage = new AutoFilterer.Types.OperatorFilter { Lte = 300 } }).ToList(); + var isNull = books.AsQueryable().ApplyFilter(new BookFilter_OperatorFilter_Advanced { Views = new AutoFilterer.Types.OperatorFilter { IsNull = true } }).ToList(); + var isNotNull = books.AsQueryable().ApplyFilter(new BookFilter_OperatorFilter_Advanced { Views = new AutoFilterer.Types.OperatorFilter { IsNotNull = true } }).ToList(); + var andCombined = books.AsQueryable().ApplyFilter(new BookFilter_OperatorFilter_Advanced + { + Views = new AutoFilterer.Types.OperatorFilter { Gte = 15, Lte = 25, CombineWith = AutoFilterer.Enums.CombineType.And } + }).ToList(); + var orCombined = books.AsQueryable().ApplyFilter(new BookFilter_OperatorFilter_Advanced + { + Views = new AutoFilterer.Types.OperatorFilter { Gt = 25, Lt = 15, CombineWith = AutoFilterer.Enums.CombineType.Or } + }).ToList(); + var nullOnNonNullable = books.AsQueryable().ApplyFilter(new BookFilter_OperatorFilter_Advanced + { + TotalPage = new AutoFilterer.Types.OperatorFilter { IsNull = true, IsNotNull = true } + }).ToList(); + + // Nested path coverage (BookNestedFilter.Views/TotalPage) + var authors = new[] + { + new Author { Id = 1, Name = "A", Books = books.Where(x => x.Id <= 2).ToList() }, + new Author { Id = 2, Name = "B", Books = books.Where(x => x.Id == 3 || x.Id == 5).ToList() }, + new Author { Id = 3, Name = "C", Books = books.Where(x => x.Id == 4).ToList() }, + }; + var nestedNullableNull = authors.AsQueryable().ApplyFilter(new AuthorFilter_CollectionAny + { + Books = new BookNestedFilter + { + Views = new AutoFilterer.Types.OperatorFilter { IsNull = true } + } + }).ToList(); + var nestedNonNullableNullIgnored = authors.AsQueryable().ApplyFilter(new AuthorFilter_CollectionAny + { + Books = new BookNestedFilter + { + TotalPage = new AutoFilterer.Types.OperatorFilter { IsNull = true } + } + }).ToList(); + + // Assert + Assert.Single(eq); + Assert.Equal(4, not.Count); + Assert.Equal(2, gt.Count); + Assert.Equal(2, lt.Count); + Assert.Equal(3, gte.Count); + Assert.Equal(3, lte.Count); + Assert.Equal(2, isNull.Count); + Assert.Equal(3, isNotNull.Count); + Assert.Single(andCombined); + Assert.Equal(20, andCombined[0].Views); + Assert.Equal(2, orCombined.Count); + Assert.Equal(5, nullOnNonNullable.Count); + Assert.Equal(2, nestedNullableNull.Count); + Assert.Equal(3, nestedNonNullableNullIgnored.Count); + } + + [Fact] + public void Generator_AppliesStringFilterOptions_OnStringProperty() + { + // Arrange + var data = TestDataHelper.GetSampleBooks(); + var filter = new BookFilter_StringOptionsContains { Query = "Clean" }; + + // Act + var result = data.AsQueryable().ApplyFilter(filter).ToList(); + + // Assert + Assert.Equal(2, result.Count); + Assert.All(result, x => Assert.Contains("Clean", x.Title)); + } + + [Fact] + public void Generator_AppliesArraySearch_AndCompareToFilterableType() + { + // Arrange + var preferences = TestDataHelper.GetSamplePreferences(); + var books = TestDataHelper.GetSampleBooks(); + + // Act + Assert array search without attribute + var arrayWithoutResult = preferences.AsQueryable().ApplyFilter(new PreferencesFilter_ArraySearchWithout + { + SecurityLevel = new[] { 1, 3 } + }).ToList(); + Assert.Equal(3, arrayWithoutResult.Count); + + // Act + Assert array search with attribute + var arrayWithResult = preferences.AsQueryable().ApplyFilter(new PreferencesFilter_ArraySearchWith + { + SecurityLevel = new[] { 2 } + }).ToList(); + Assert.Single(arrayWithResult); + Assert.Equal("Bob", arrayWithResult[0].GivenName); + + // Act + Assert compareTo filterable type (ToLowerContainsComparisonAttribute) + var typeCompareResult = books.AsQueryable().ApplyFilter(new BookFilter_TypeCompareTo + { + Search = "clean" + }).ToList(); + Assert.Equal(2, typeCompareResult.Count); + } + + [Fact] + public void Generator_AppliesCompareToOr_ForMultipleStringTargets() + { + // Arrange + var data = TestDataHelper.GetSampleBooks(); + + // Act + var result = data.AsQueryable().ApplyFilter(new BookFilter_MultiplePropertyOr + { + Query = "Pragmatic" + }).ToList(); + + // Assert + Assert.Single(result); + Assert.Equal("The Pragmatic Programmer", result[0].Title); + } + + [Fact] + public void Generator_AppliesCompareToOr_ForMultipleRangeTargets() + { + // Arrange + var data = TestDataHelper.GetSampleBooks(); + + // Act + var result = data.AsQueryable().ApplyFilter(new BookFilter_Range_MultipleProperty + { + PageRange = new AutoFilterer.Types.Range { Min = 550 } + }).ToList(); + + // Assert + Assert.Equal(2, result.Count); + } + + [Fact] + public void Generator_AppliesInheritedIgnoreFilterAttribute() + { + // Arrange + var data = TestDataHelper.GetSampleBooks(); + + // Act + var result = data.AsQueryable().ApplyFilter(new BookFilter_InheritedIgnore + { + IgnoredQuery = "Nonexistent" + }).ToList(); + + // Assert + Assert.Equal(data.Count, result.Count); + } + + [Fact] + public void Generator_AppliesCompareToDottedPathMemberAccess() + { + // Arrange + var data = TestDataHelper.GetSampleAuthors().SelectMany(a => a.Books).ToList(); + + // Act + var result = data.AsQueryable().ApplyFilter(new BookFilter_DottedPath + { + AuthorName = "andrew" + }).ToList(); + + // Assert + Assert.Equal(3, result.Count); + Assert.All(result, b => Assert.Equal("Andrew Hunt", b.AuthorModel.Name)); + } +} diff --git a/tests/AutoFilterer.Generators.Tests/TestDataHelper.cs b/tests/AutoFilterer.Generators.Tests/TestDataHelper.cs new file mode 100644 index 0000000..8a51951 --- /dev/null +++ b/tests/AutoFilterer.Generators.Tests/TestDataHelper.cs @@ -0,0 +1,116 @@ +using AutoFilterer.Generators.Tests.Environment.Models; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace AutoFilterer.Generators.Tests; + +public static class TestDataHelper +{ + public static List GetSampleBooks() + { + return new List + { + new Environment.Models.Book { Id = 1, Title = "Clean Code", TotalPage = 464, Year = 2008, IsPublished = true, AuthorId = 1, Author = "Robert C. Martin", ReadCount = 100, Views = 5000 }, + new Environment.Models.Book { Id = 2, Title = "The Pragmatic Programmer", TotalPage = 352, Year = 1999, IsPublished = true, AuthorId = 2, Author = "Andrew Hunt", ReadCount = 200, Views = 3000 }, + new Environment.Models.Book { Id = 3, Title = "Design Patterns", TotalPage = 395, Year = 1994, IsPublished = true, AuthorId = 3, Author = "Eric Evans", ReadCount = 150, Views = 4000 }, + new Environment.Models.Book { Id = 4, Title = "Refactoring", TotalPage = 448, Year = 1999, IsPublished = true, AuthorId = 2, Author = "Andrew Hunt", ReadCount = 180, Views = 4500 }, + new Environment.Models.Book { Id = 5, Title = "Test Driven Development", TotalPage = 240, Year = 2002, IsPublished = true, AuthorId = 1, Author = "Robert C. Martin", ReadCount = 120, Views = 3500 }, + new Environment.Models.Book { Id = 6, Title = "Working Effectively with Legacy Code", TotalPage = 456, Year = 2004, IsPublished = true, AuthorId = 1, Author = "Robert C. Martin", ReadCount = 90, Views = 4200 }, + new Environment.Models.Book { Id = 7, Title = "Domain-Driven Design", TotalPage = 560, Year = 2003, IsPublished = true, AuthorId = 3, Author = "Eric Evans", ReadCount = 110, Views = 5500 }, + new Environment.Models.Book { Id = 8, Title = "Continuous Delivery", TotalPage = 512, Year = 2010, IsPublished = true, AuthorId = 2, Author = "Andrew Hunt", ReadCount = 130, Views = 3800 }, + new Environment.Models.Book { Id = 9, Title = "The Clean Coder", TotalPage = 256, Year = 2011, IsPublished = false, AuthorId = 1, Author = "Robert C. Martin", ReadCount = 80, Views = 2800 }, + new Environment.Models.Book { Id = 10, Title = "Agile Software Development", TotalPage = 552, Year = 2002, IsPublished = true, AuthorId = 1, Author = "Robert C. Martin", ReadCount = 140, Views = 4900 }, + }; + } + + public static List GetSampleAuthors() + { + var authors = new List + { + new Environment.Models.Author { Id = 1, Name = "Robert C. Martin", Country = "USA", Age = 70 }, + new Environment.Models.Author { Id = 2, Name = "Andrew Hunt", Country = "USA", Age = 65 }, + new Environment.Models.Author { Id = 3, Name = "Eric Evans", Country = "USA", Age = 55 }, + }; + + var books = GetSampleBooks(); + foreach (var author in authors) + { + author.Books = books + .Where(b => b.AuthorId == author.Id) + .Select(b => new Environment.Models.Book + { + Id = b.Id, + Title = b.Title, + TotalPage = b.TotalPage, + Year = b.Year, + IsPublished = b.IsPublished, + AuthorId = b.AuthorId, + Author = b.Author, + ReadCount = b.ReadCount, + Views = b.Views, + }) + .ToList(); + foreach (var book in author.Books) + { + book.AuthorModel = author; + } + } + + return authors; + } + + public static List GetSamplePublishers() + { + var authors = GetSampleAuthors(); + + return new List + { + new Environment.Models.Publisher { Id = 1, Name = "Pearson", Authors = authors.Where(a => a.Id == 1).ToList() }, + new Environment.Models.Publisher { Id = 2, Name = "Addison-Wesley", Authors = authors.Where(a => a.Id == 2 || a.Id == 3).ToList() }, + }; + } + + public static List GetSamplePreferences() + { + return new List + { + new Environment.Models.Preferences + { + UserId = Guid.Parse("11111111-1111-1111-1111-111111111111"), + IsTwoFactorEnabled = true, + GivenName = "Alice", + SecurityLevel = 1, + ReadLimit = 100, + OrganizationUnitId = Guid.Parse("11111111-1111-1111-1111-111111111111") + }, + new Environment.Models.Preferences + { + UserId = Guid.Parse("22222222-2222-2222-2222-222222222222"), + IsTwoFactorEnabled = false, + GivenName = "Bob", + SecurityLevel = 2, + ReadLimit = 200, + OrganizationUnitId = Guid.Parse("33333333-3333-3333-3333-333333333333") + }, + new Environment.Models.Preferences + { + UserId = Guid.Parse("33333333-3333-3333-3333-333333333333"), + IsTwoFactorEnabled = true, + GivenName = "Charlie", + SecurityLevel = 3, + ReadLimit = 300, + OrganizationUnitId = Guid.Parse("11111111-1111-1111-1111-111111111111") + }, + new Environment.Models.Preferences + { + UserId = Guid.Parse("44444444-4444-4444-4444-444444444444"), + IsTwoFactorEnabled = false, + GivenName = "Diana", + SecurityLevel = 1, + ReadLimit = null, + OrganizationUnitId = Guid.Parse("22222222-2222-2222-2222-222222222222") + }, + }; + } +}