From f75201bce0404edaea70bd74e3c2be08bfd88ced Mon Sep 17 00:00:00 2001 From: Lawrence Moorehead Date: Tue, 15 Jul 2025 11:42:00 -0400 Subject: [PATCH 1/3] Checking in consolidated Tests. Note that this will not update the action to run the Marten specific tests in the pipeline. --- .github/workflows/nuget-pipeline.yaml | 100 ++++ .vscode/settings.json | 3 + GitVersion.yml | 58 +++ .../Documents/TestDocument.cs | 30 ++ .../QueryKit.MartenTests.csproj | 30 ++ QueryKit.MartenTests/TestBase.cs | 62 +++ .../Tests/MartenGuidFilteringTests.cs | 435 ++++++++++++++++++ QueryKit.sln | 7 + 8 files changed, 725 insertions(+) create mode 100644 .github/workflows/nuget-pipeline.yaml create mode 100644 .vscode/settings.json create mode 100644 GitVersion.yml create mode 100644 QueryKit.MartenTests/Documents/TestDocument.cs create mode 100644 QueryKit.MartenTests/QueryKit.MartenTests.csproj create mode 100644 QueryKit.MartenTests/TestBase.cs create mode 100644 QueryKit.MartenTests/Tests/MartenGuidFilteringTests.cs diff --git a/.github/workflows/nuget-pipeline.yaml b/.github/workflows/nuget-pipeline.yaml new file mode 100644 index 0000000..7158266 --- /dev/null +++ b/.github/workflows/nuget-pipeline.yaml @@ -0,0 +1,100 @@ +name: NuGet Pipeline + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + permissions: + checks: write # required by dorny/test-reporter + pull-requests: write # required for PR comments + strategy: + matrix: + dotnet-version: ['9.0.x'] + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 # Required for GitVersion + + - name: Install GitVersion + uses: gittools/actions/gitversion/setup@v3.1.11 + with: + versionSpec: '6.0.x' + + - name: Determine Version + id: gitversion + uses: gittools/actions/gitversion/execute@v3.1.11 + with: + useConfigFile: true + + - name: Setup .NET Core SDK ${{ matrix.dotnet-version }} + uses: actions/setup-dotnet@v3 + with: + dotnet-version: ${{ matrix.dotnet-version }} + source-url: https://pkgs.dev.azure.com/elemdisc/Elem/_packaging/elemd/nuget/v3/index.json + env: + NUGET_AUTH_TOKEN: ${{ secrets.AZURE_ARTIFACTS_PAT }} + + - name: Install dependencies + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore /p:Version=${{ steps.gitversion.outputs.semVer }} + + - name: Test + run: | + dotnet test --no-restore --verbosity normal \ + --logger "trx;LogFileName=test-results.trx" \ + --collect:"XPlat Code Coverage" \ + --results-directory TestResults \ + QueryKit.UnitTests/QueryKit.UnitTests.csproj + + dotnet test --no-restore --verbosity normal \ + --logger "trx;LogFileName=integration-results.trx" \ + --collect:"XPlat Code Coverage" \ + --results-directory TestResults \ + QueryKit.IntegrationTests/QueryKit.IntegrationTests.csproj + + dotnet test --no-restore --verbosity normal \ + --logger "trx;LogFileName=marten-results.trx" \ + --collect:"XPlat Code Coverage" \ + --results-directory TestResults \ + QueryKit.MartenTests/QueryKit.MartenTests.csproj + + - name: Test Report + uses: dorny/test-reporter@v1 + if: success() || failure() + with: + name: .NET Test Results + path: "TestResults/*.trx" # will find all .trx files + reporter: dotnet-trx + + - name: Code Coverage Report + uses: irongut/CodeCoverageSummary@v1.3.0 + with: + filename: "TestResults/**/coverage.cobertura.xml" # will find all coverage files + badge: true + format: markdown + output: both + # Optional: fail if coverage is below a threshold + # fail_below_min: true + # minimum_coverage: 80 + + - name: Add Coverage PR Comment + uses: marocchino/sticky-pull-request-comment@v2 + if: github.event_name == 'pull_request' + with: + path: code-coverage-results.md + + - name: Pack + run: dotnet pack --configuration Release --no-build --output nupkgs /p:Version=${{ steps.gitversion.outputs.semVer }} + + - name: Push Package + run: dotnet nuget push "./nupkgs/*.nupkg" --api-key AzureDevOps --skip-duplicate + \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9792498 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.formatOnSave": false +} \ No newline at end of file diff --git a/GitVersion.yml b/GitVersion.yml new file mode 100644 index 0000000..25b971f --- /dev/null +++ b/GitVersion.yml @@ -0,0 +1,58 @@ +workflow: GitHubFlow/v1 +assembly-versioning-scheme: MajorMinorPatch +assembly-file-versioning-scheme: MajorMinorPatch +tag-prefix: '[vV]?' +version-in-branch-pattern: (?[vV]?\d+(\.\d+)?(\.\d+)?).* +major-version-bump-message: (\+)?semver:\s?(breaking|major) +minor-version-bump-message: (\+)?semver:\s?(feature|minor) +patch-version-bump-message: (\+)?semver:\s?(fix|patch) +no-bump-message: (\+)?semver:\s?(none|skip) +commit-date-format: yyyy-MM-dd +merge-message-formats: {} +update-build-number: true +semantic-version-format: Strict +strategies: +- ConfiguredNextVersion +- Mainline +branches: + main: + mode: ContinuousDeployment + label: '' + increment: Patch + prevent-increment: + of-merged-branch: true + track-merge-target: false + track-merge-message: true + regex: ^master$|^main$ + source-branches: [] + is-source-branch-for: [] + tracks-release-branches: false + is-release-branch: false + is-main-branch: true + feature: + mode: ContinuousDelivery + label: '{BranchName}' + increment: Patch + prevent-increment: + when-current-commit-tagged: false + track-merge-message: true + regex: ^(?.+) + source-branches: + - main + - release + is-source-branch-for: [] + is-main-branch: false + pull-request: + mode: ContinuousDelivery + label: PullRequest + increment: Inherit + prevent-increment: + of-merged-branch: true + when-current-commit-tagged: false + label-number-pattern: '[/-](?\d+)' + track-merge-message: true + regex: ^((refs\/)?pull|pull\-requests|pr)[/-] + source-branches: + - main + - feature + is-source-branch-for: [] \ No newline at end of file diff --git a/QueryKit.MartenTests/Documents/TestDocument.cs b/QueryKit.MartenTests/Documents/TestDocument.cs new file mode 100644 index 0000000..8b664c6 --- /dev/null +++ b/QueryKit.MartenTests/Documents/TestDocument.cs @@ -0,0 +1,30 @@ +namespace QueryKit.MartenTests.Documents; + +public class TestDocument +{ + public Guid Id { get; set; } + public string Title { get; set; } + public Guid? RelatedId { get; set; } + public decimal Rating { get; set; } + public int Age { get; set; } + public BirthMonthEnum BirthMonth { get; set; } + public DateTimeOffset? SpecificDate { get; set; } + public DateOnly? Date { get; set; } + public TimeOnly? Time { get; set; } +} + +public enum BirthMonthEnum +{ + January = 1, + February = 2, + March = 3, + April = 4, + May = 5, + June = 6, + July = 7, + August = 8, + September = 9, + October = 10, + November = 11, + December = 12 +} \ No newline at end of file diff --git a/QueryKit.MartenTests/QueryKit.MartenTests.csproj b/QueryKit.MartenTests/QueryKit.MartenTests.csproj new file mode 100644 index 0000000..8ab7732 --- /dev/null +++ b/QueryKit.MartenTests/QueryKit.MartenTests.csproj @@ -0,0 +1,30 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + diff --git a/QueryKit.MartenTests/TestBase.cs b/QueryKit.MartenTests/TestBase.cs new file mode 100644 index 0000000..737ef6d --- /dev/null +++ b/QueryKit.MartenTests/TestBase.cs @@ -0,0 +1,62 @@ +using Marten; +using Microsoft.Extensions.DependencyInjection; +using Weasel.Core; +using Testcontainers.PostgreSql; +using Xunit; + +namespace QueryKit.MartenTests; + +public abstract class TestBase : IAsyncLifetime +{ + private readonly IDocumentStore _store; + private readonly IServiceScope _scope; + protected IDocumentSession Session; + private readonly PostgreSqlContainer _dbContainer; + + protected TestBase() + { + _dbContainer = new PostgreSqlBuilder().Build(); + + var services = new ServiceCollection(); + + services.AddMarten(opts => + { + opts.Connection("Host=localhost;Port=5432;Database=querykit_tests;Username=postgres;Password=postgres"); + opts.AutoCreateSchemaObjects = AutoCreate.All; + }); + + var provider = services.BuildServiceProvider(); + _scope = provider.CreateScope(); + _store = provider.GetRequiredService(); + Session = _store.LightweightSession(); + } + + public async Task InitializeAsync() + { + await _dbContainer.StartAsync(); + + var services = new ServiceCollection(); + services.AddMarten(opts => + { + opts.Connection(_dbContainer.GetConnectionString()); + opts.AutoCreateSchemaObjects = AutoCreate.All; + }); + + var provider = services.BuildServiceProvider(); + _scope?.Dispose(); + _store?.Dispose(); + + var scope = provider.CreateScope(); + var store = provider.GetRequiredService(); + Session?.Dispose(); + Session = store.LightweightSession(); + } + + public async Task DisposeAsync() + { + Session?.Dispose(); + _scope?.Dispose(); + _store?.Dispose(); + await _dbContainer.DisposeAsync(); + } +} \ No newline at end of file diff --git a/QueryKit.MartenTests/Tests/MartenGuidFilteringTests.cs b/QueryKit.MartenTests/Tests/MartenGuidFilteringTests.cs new file mode 100644 index 0000000..34a2a1f --- /dev/null +++ b/QueryKit.MartenTests/Tests/MartenGuidFilteringTests.cs @@ -0,0 +1,435 @@ +using Bogus; +using FluentAssertions; +using QueryKit.MartenTests.Documents; +using System.Collections.Generic; +using System.Linq; +using Marten.Linq; +using QueryKit.Exceptions; +using Marten; + +namespace QueryKit.MartenTests.Tests; + +public class MartenGuidFilteringTests : TestBase +{ + [Fact] + public async Task can_filter_by_guid() + { + // Arrange + var faker = new Faker(); + var doc1 = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence() + }; + var doc2 = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence() + }; + + Session.Store(doc1, doc2); + await Session.SaveChangesAsync(); + + var input = $"""Id == "{doc1.Id}" """; + + // Act + var queryable = Session.Query(); + var appliedQueryable = queryable.ApplyQueryKitFilter(input); + var results = appliedQueryable.ToList(); + + // Assert + results.Count.Should().Be(1); + results[0].Id.Should().Be(doc1.Id); + } + + [Fact] + public async Task can_filter_by_nullable_guid() + { + // Arrange + var faker = new Faker(); + var relatedId = Guid.NewGuid(); + var doc1 = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + RelatedId = relatedId + }; + var doc2 = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + RelatedId = null + }; + + Session.Store(doc1, doc2); + await Session.SaveChangesAsync(); + + var input = $"""RelatedId == "{relatedId}" """; + + // Act + var queryable = Session.Query(); + var appliedQueryable = queryable.ApplyQueryKitFilter(input); + var results = appliedQueryable.ToList(); + + // Assert + results.Count.Should().Be(1); + results[0].Id.Should().Be(doc1.Id); + } + + [Fact] + public async Task can_filter_by_nullable_guid_is_null() + { + // Arrange + var faker = new Faker(); + var doc1 = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + RelatedId = Guid.NewGuid() + }; + var doc2 = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + RelatedId = null + }; + + Session.Store(doc1, doc2); + await Session.SaveChangesAsync(); + + var input = """RelatedId == null"""; + + // Act + var queryable = Session.Query(); + var appliedQueryable = queryable.ApplyQueryKitFilter(input); + var results = appliedQueryable.ToList(); + + // Assert + results.Count.Should().Be(1); + results[0].Id.Should().Be(doc2.Id); + } + + [Fact] + public async Task can_filter_by_guid_contains() + { + // Arrange + var faker = new Faker(); + var guidToFind = Guid.NewGuid(); + var doc1 = new TestDocument + { + Id = guidToFind, + Title = faker.Lorem.Sentence() + }; + var doc2 = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence() + }; + + Session.Store(doc1, doc2); + await Session.SaveChangesAsync(); + + var input = $"""Id @= "{guidToFind}" """; + + // Act + var queryable = Session.Query(); + var appliedQueryable = queryable.ApplyQueryKitFilter(input); + var results = appliedQueryable.ToList(); + + // Assert + results.Count.Should().Be(1); + results[0].Id.Should().Be(guidToFind); + } + + [Fact] + public async Task can_filter_by_guid_in_operator() + { + // Arrange + var faker = new Faker(); + var doc1 = new TestDocument + { + Id = Guid.NewGuid(), + RelatedId = Guid.NewGuid(), + Title = faker.Lorem.Sentence() + }; + var doc2 = new TestDocument + { + Id = Guid.NewGuid(), + RelatedId = Guid.NewGuid(), + Title = faker.Lorem.Sentence() + }; + var doc3 = new TestDocument + { + Id = Guid.NewGuid(), + RelatedId = Guid.NewGuid(), + Title = faker.Lorem.Sentence() + }; + + Session.Store(doc1, doc2, doc3); + await Session.SaveChangesAsync(); + + var input = $"""RelatedId ^^ ["{doc1.RelatedId}", "{doc3.RelatedId}"]"""; + + // Act + var queryable = Session.Query(); + var appliedQueryable = queryable.ApplyQueryKitFilter(input); + var results = appliedQueryable.ToList(); + + // Assert + results.Count.Should().Be(2); + results.Should().Contain(x => x.Id == doc1.Id); + results.Should().NotContain(x => x.Id == doc2.Id); + results.Should().Contain(x => x.Id == doc3.Id); + } + + [Fact] + public async Task can_filter_with_complex_conditions() + { + // Arrange + var faker = new Faker(); + var specificDateTime = new DateTimeOffset(2022, 7, 1, 0, 0, 3, TimeSpan.Zero); + var specificDate = new DateOnly(2022, 7, 1); + var specificTime = new TimeOnly(0, 0, 3); + + // Document that should match via Rating > 3.5 + var matchingDoc1 = new TestDocument + { + Id = Guid.NewGuid(), + Title = "waffle & chicken special", + Age = 35, + Rating = 4.0M, // This will match via Rating > 3.5 + BirthMonth = BirthMonthEnum.February, + SpecificDate = null, + Date = null, + Time = null + }; + + // Document that should match via GUID condition AND Age < 18 + var matchingDoc2 = new TestDocument + { + Id = Guid.Parse("aa648248-cb69-4217-ac95-d7484795afb2"), + Title = "something else", + Age = 15, // Changed to satisfy Age < 18 + Rating = 2.5M, + BirthMonth = BirthMonthEnum.February, + SpecificDate = null, + Date = null, + Time = null + }; + + // Document that should match via Title == "lamb" AND Age < 18 + var matchingDoc3 = new TestDocument + { + Id = Guid.NewGuid(), + Title = "lamb", + Age = 15, // Changed to satisfy Age < 18 + Rating = 2.5M, + BirthMonth = BirthMonthEnum.February, + SpecificDate = null, + Date = null, + Time = null + }; + + // Document that should match via Title == null AND Age < 18 + var matchingDoc4 = new TestDocument + { + Id = Guid.NewGuid(), + Title = null, + Age = 15, + Rating = 2.5M, + BirthMonth = BirthMonthEnum.February, + SpecificDate = null, + Date = null, + Time = null + }; + + // Document that should match via Title contains "waffle & chicken" AND Age > 30 AND BirthMonth == January AND Title starts with "ally" + var matchingDoc6 = new TestDocument + { + Id = Guid.NewGuid(), + Title = "ally smith with waffle & chicken", // Now matches both Title _= "ally" and Title @=* "waffle & chicken" + Age = 35, // Now matches Age > 30 + Rating = 2.5M, + BirthMonth = BirthMonthEnum.January, + SpecificDate = null, + Date = null, + Time = null + }; + + // Document that should match via dates + var matchingDoc8 = new TestDocument + { + Id = Guid.NewGuid(), + Title = "something else", + Age = 25, + Rating = 2.5M, + BirthMonth = BirthMonthEnum.February, + SpecificDate = specificDateTime, + Date = specificDate, + Time = null + }; + + // Document that should match via dates (time condition) + var matchingDoc9 = new TestDocument + { + Id = Guid.NewGuid(), + Title = "something else", + Age = 25, + Rating = 2.5M, + BirthMonth = BirthMonthEnum.February, + SpecificDate = specificDateTime, + Date = null, + Time = specificTime + }; + + // Document that should not match any conditions + var nonMatchingDoc = new TestDocument + { + Id = Guid.NewGuid(), + Title = "no match", + Age = 25, + Rating = 2.5M, + BirthMonth = BirthMonthEnum.February, + SpecificDate = DateTimeOffset.Now, + Date = DateOnly.FromDateTime(DateTime.Now), + Time = TimeOnly.FromDateTime(DateTime.Now) + }; + + Session.Store(matchingDoc1, matchingDoc2, matchingDoc3, matchingDoc4, + matchingDoc6, matchingDoc8, matchingDoc9, nonMatchingDoc); + await Session.SaveChangesAsync(); + + var input = """((Title @=* "waffle & chicken" && Age > 30) || Id == "aa648248-cb69-4217-ac95-d7484795afb2" || Title == "lamb" || Title == null) && (Age < 18 || (BirthMonth == January && Title _= "ally")) || Rating > 3.5 || SpecificDate == 2022-07-01T00:00:03Z && (Date == 2022-07-01 || Time == 00:00:03)"""; + + // Act + var queryable = Session.Query(); + var appliedQueryable = queryable.ApplyQueryKitFilter(input); + var results = appliedQueryable.ToList(); + + // Assert + results.Should().Contain(x => x.Id == matchingDoc1.Id); + results.Should().Contain(x => x.Id == matchingDoc2.Id); + results.Should().Contain(x => x.Id == matchingDoc3.Id); + results.Should().Contain(x => x.Id == matchingDoc4.Id); + results.Should().Contain(x => x.Id == matchingDoc6.Id); + results.Should().Contain(x => x.Id == matchingDoc8.Id); + results.Should().Contain(x => x.Id == matchingDoc9.Id); + results.Should().NotContain(x => x.Id == nonMatchingDoc.Id); + + // Verify specific matching conditions + var doc1 = results.FirstOrDefault(x => x.Id == matchingDoc1.Id); + doc1.Should().NotBeNull("matches Rating > 3.5"); + + var doc2 = results.FirstOrDefault(x => x.Id == matchingDoc2.Id); + doc2.Should().NotBeNull("matches specific GUID AND Age < 18"); + + var doc3 = results.FirstOrDefault(x => x.Id == matchingDoc3.Id); + doc3.Should().NotBeNull("matches Title == lamb AND Age < 18"); + + var doc4 = results.FirstOrDefault(x => x.Id == matchingDoc4.Id); + doc4.Should().NotBeNull("matches 'Title is null' AND 'Age < 18'"); + + var doc6 = results.FirstOrDefault(x => x.Id == matchingDoc6.Id); + doc6.Should().NotBeNull("matches 'Title contains waffle & chicken AND Age > 30' AND 'BirthMonth == January AND Title starts with ally'"); + + var doc8 = results.FirstOrDefault(x => x.Id == matchingDoc8.Id); + doc8.Should().NotBeNull("matches SpecificDate AND Date conditions"); + + var doc9 = results.FirstOrDefault(x => x.Id == matchingDoc9.Id); + doc9.Should().NotBeNull("matches SpecificDate AND Time conditions"); + + results.FirstOrDefault(x => x.Id == nonMatchingDoc.Id).Should().BeNull("doesn't match any conditions"); + } + + [Fact] + public async Task should_handle_malformed_guid_gracefully() + { + // Arrange + var faker = new Faker(); + var doc1 = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence() + }; + + Session.Store(doc1); + await Session.SaveChangesAsync(); + + var input = """Id == "not-a-guid" """; + + // Act & Assert + var queryable = Session.Query(); + var exception = await Assert.ThrowsAsync(() => + queryable.ApplyQueryKitFilter(input).ToListAsync()); + exception.Message.Should().Contain("parsing failure"); + } + + [Fact] + public async Task should_handle_invalid_guid_in_array() + { + // Arrange + var faker = new Faker(); + var doc1 = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence() + }; + + Session.Store(doc1); + await Session.SaveChangesAsync(); + + var input = """Id ^^ ["not-a-guid", "also-not-a-guid"]"""; + + // Act & Assert + var queryable = Session.Query(); + var exception = await Assert.ThrowsAsync(() => + queryable.ApplyQueryKitFilter(input).ToListAsync()); + exception.Message.Should().Contain("parsing failure"); + } + + [Fact] + public async Task should_handle_empty_guid_string() + { + // Arrange + var faker = new Faker(); + var doc1 = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence() + }; + + Session.Store(doc1); + await Session.SaveChangesAsync(); + + var input = """Id == "" """; + + // Act & Assert + var queryable = Session.Query(); + var exception = await Assert.ThrowsAsync(() => + queryable.ApplyQueryKitFilter(input).ToListAsync()); + exception.Message.Should().Contain("parsing failure"); + } + + [Fact] + public async Task should_handle_non_guid_string() + { + // Arrange + var faker = new Faker(); + var doc1 = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence() + }; + + Session.Store(doc1); + await Session.SaveChangesAsync(); + + var input = """Id == "123" """; + + // Act & Assert + var queryable = Session.Query(); + var exception = await Assert.ThrowsAsync(() => + queryable.ApplyQueryKitFilter(input).ToListAsync()); + exception.Message.Should().Contain("parsing failure"); + } +} \ No newline at end of file diff --git a/QueryKit.sln b/QueryKit.sln index 64f86ba..67a0bc1 100644 --- a/QueryKit.sln +++ b/QueryKit.sln @@ -1,5 +1,6 @@  Microsoft Visual Studio Solution File, Format Version 12.00 +# Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QueryKit", "QueryKit\QueryKit.csproj", "{A6A443BB-45D5-46C9-B419-07BE36BD50F0}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QueryKit.UnitTests", "QueryKit.UnitTests\QueryKit.UnitTests.csproj", "{44664CB7-E20F-40BA-A3D8-D201BD45FC58}" @@ -10,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QueryKit.WebApiTestProject" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedTestingHelper", "SharedTestingHelper\SharedTestingHelper.csproj", "{2E3FF641-B1CB-4BB8-9E01-A48F97BB7022}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QueryKit.MartenTests", "QueryKit.MartenTests\QueryKit.MartenTests.csproj", "{FB747910-B6F4-4305-A9FF-BB99F978E79E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -36,5 +39,9 @@ Global {2E3FF641-B1CB-4BB8-9E01-A48F97BB7022}.Debug|Any CPU.Build.0 = Debug|Any CPU {2E3FF641-B1CB-4BB8-9E01-A48F97BB7022}.Release|Any CPU.ActiveCfg = Release|Any CPU {2E3FF641-B1CB-4BB8-9E01-A48F97BB7022}.Release|Any CPU.Build.0 = Release|Any CPU + {FB747910-B6F4-4305-A9FF-BB99F978E79E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FB747910-B6F4-4305-A9FF-BB99F978E79E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FB747910-B6F4-4305-A9FF-BB99F978E79E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FB747910-B6F4-4305-A9FF-BB99F978E79E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal From 03894fe838a65aa6b25d641280aef4eeda2c935d Mon Sep 17 00:00:00 2001 From: Lawrence Moorehead Date: Tue, 15 Jul 2025 18:07:50 -0400 Subject: [PATCH 2/3] Marten Tests --- .../Documents/TestDocument.cs | 2 + .../Tests/MartenGuidFilteringTests.cs | 134 +++++++++++++++++- 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/QueryKit.MartenTests/Documents/TestDocument.cs b/QueryKit.MartenTests/Documents/TestDocument.cs index 8b664c6..dcb8b6e 100644 --- a/QueryKit.MartenTests/Documents/TestDocument.cs +++ b/QueryKit.MartenTests/Documents/TestDocument.cs @@ -5,6 +5,8 @@ public class TestDocument public Guid Id { get; set; } public string Title { get; set; } public Guid? RelatedId { get; set; } + public Guid[] AdditionalIds { get; set; } = []; + public Guid[]? NullableAdditionalIds { get; set; } public decimal Rating { get; set; } public int Age { get; set; } public BirthMonthEnum BirthMonth { get; set; } diff --git a/QueryKit.MartenTests/Tests/MartenGuidFilteringTests.cs b/QueryKit.MartenTests/Tests/MartenGuidFilteringTests.cs index 34a2a1f..dc52dc3 100644 --- a/QueryKit.MartenTests/Tests/MartenGuidFilteringTests.cs +++ b/QueryKit.MartenTests/Tests/MartenGuidFilteringTests.cs @@ -109,7 +109,7 @@ public async Task can_filter_by_nullable_guid_is_null() results[0].Id.Should().Be(doc2.Id); } - [Fact] + [Fact(Skip = "Marten does not support ToString() on Guid")] public async Task can_filter_by_guid_contains() { // Arrange @@ -182,6 +182,138 @@ public async Task can_filter_by_guid_in_operator() results.Should().Contain(x => x.Id == doc3.Id); } + [Fact] + public async Task can_filter_by_additionalids_has_operator() + { + // Arrange + var guidToFind = Guid.NewGuid(); + var docWithGuid = new TestDocument + { + Id = Guid.NewGuid(), + Title = "Has Guid", + AdditionalIds = new[] { guidToFind, Guid.NewGuid() } + }; + var docWithoutGuid = new TestDocument + { + Id = Guid.NewGuid(), + Title = "No Guid", + AdditionalIds = new[] { Guid.NewGuid(), Guid.NewGuid() } + }; + + Session.Store(docWithGuid, docWithoutGuid); + await Session.SaveChangesAsync(); + + var input = $"""AdditionalIds ^$ {guidToFind} """; + + // Act + var queryable = Session.Query(); + var appliedQueryable = queryable.ApplyQueryKitFilter(input); + var results = appliedQueryable.ToList(); + + // Assert + results.Count.Should().Be(1); + results[0].Id.Should().Be(docWithGuid.Id); + } + + [Fact] + public async Task can_filter_by_additionalids_does_not_have_operator() + { + // Arrange + var guidToExclude = Guid.NewGuid(); + var docWithGuid = new TestDocument + { + Id = Guid.NewGuid(), + Title = "Has Guid", + AdditionalIds = new[] { guidToExclude, Guid.NewGuid() } + }; + var docWithoutGuid = new TestDocument + { + Id = Guid.NewGuid(), + Title = "No Guid", + AdditionalIds = new[] { Guid.NewGuid(), Guid.NewGuid() } + }; + + Session.Store(docWithGuid, docWithoutGuid); + await Session.SaveChangesAsync(); + + var input = $"""AdditionalIds !^$ "{guidToExclude}" """; + + // Act + var queryable = Session.Query(); + var appliedQueryable = queryable.ApplyQueryKitFilter(input); + var results = appliedQueryable.ToList(); + + // Assert + results.Count.Should().Be(1); + results[0].Id.Should().Be(docWithoutGuid.Id); + } + + [Fact] + public async Task can_filter_by_nullableadditionalids_has_operator() + { + // Arrange + var guidToFind = Guid.NewGuid(); + var docWithGuid = new TestDocument + { + Id = Guid.NewGuid(), + Title = "Has Guid", + NullableAdditionalIds = new[] { guidToFind, Guid.NewGuid() } + }; + var docWithoutGuid = new TestDocument + { + Id = Guid.NewGuid(), + Title = "No Guid", + NullableAdditionalIds = new[] { Guid.NewGuid(), Guid.NewGuid() } + }; + + Session.Store(docWithGuid, docWithoutGuid); + await Session.SaveChangesAsync(); + + var input = $"""NullableAdditionalIds ^$ {guidToFind} """; + + // Act + var queryable = Session.Query(); + var appliedQueryable = queryable.ApplyQueryKitFilter(input); + var results = appliedQueryable.ToList(); + + // Assert + results.Count.Should().Be(1); + results[0].Id.Should().Be(docWithGuid.Id); + } + + [Fact] + public async Task can_filter_by_nullableadditionalids_does_not_have_operator() + { + // Arrange + var guidToExclude = Guid.NewGuid(); + var docWithGuid = new TestDocument + { + Id = Guid.NewGuid(), + Title = "Has Guid", + NullableAdditionalIds = new[] { guidToExclude, Guid.NewGuid() } + }; + var docWithoutGuid = new TestDocument + { + Id = Guid.NewGuid(), + Title = "No Guid", + NullableAdditionalIds = new[] { Guid.NewGuid(), Guid.NewGuid() } + }; + + Session.Store(docWithGuid, docWithoutGuid); + await Session.SaveChangesAsync(); + + var input = $"""NullableAdditionalIds !^$ "{guidToExclude}" """; + + // Act + var queryable = Session.Query(); + var appliedQueryable = queryable.ApplyQueryKitFilter(input); + var results = appliedQueryable.ToList(); + + // Assert + results.Count.Should().Be(1); + results[0].Id.Should().Be(docWithoutGuid.Id); + } + [Fact] public async Task can_filter_with_complex_conditions() { From 5eac98e30417debed3bf698cc592cda00b95c471 Mon Sep 17 00:00:00 2001 From: Lawrence Moorehead Date: Tue, 15 Jul 2025 18:08:05 -0400 Subject: [PATCH 3/3] Fork Guid Support --- .../CustomFilterPropertyTests.cs | 4 +- QueryKit.UnitTests/FilterParserTests.cs | 13 +- QueryKit/FilterParser.cs | 139 ++++++++++++++---- QueryKit/Operators/ComparisonOperator.cs | 50 ++++++- QueryKit/Operators/OperatorTypeHelper.cs | 21 +++ 5 files changed, 183 insertions(+), 44 deletions(-) create mode 100644 QueryKit/Operators/OperatorTypeHelper.cs diff --git a/QueryKit.UnitTests/CustomFilterPropertyTests.cs b/QueryKit.UnitTests/CustomFilterPropertyTests.cs index 70acd13..c1a2a1a 100644 --- a/QueryKit.UnitTests/CustomFilterPropertyTests.cs +++ b/QueryKit.UnitTests/CustomFilterPropertyTests.cs @@ -119,7 +119,7 @@ public void can_have_custom_prop_name_for_multiple_props() config.Property(x => x.Id).HasQueryName("identifier"); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should().Be($"""x => ((x.Title == "{stringValue}") OrElse (x.Id.ToString() == "{guidValue}"))"""); + filterExpression.ToString().Should().Be($"""x => ((x.Title == "{stringValue}") OrElse (x.Id == {guidValue}))"""); } [Fact] @@ -135,7 +135,7 @@ public void can_have_custom_prop_name_for_some_props() config.Property(x => x.Title).HasQueryName("special_title"); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should().Be($"""x => ((x.Title == "{stringValue}") OrElse (x.Id.ToString() == "{guidValue}"))"""); + filterExpression.ToString().Should().Be($"""x => ((x.Title == "{stringValue}") OrElse (x.Id == {guidValue}))"""); } [Fact] diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index 58317f4..edeea9d 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -42,7 +42,7 @@ public void complex_with_lots_of_types() var filterExpression = FilterParser.ParseFilter(input); filterExpression.ToString().Should() - .Be(""""x => (((((((x.Title.ToLower().Contains("waffle & chicken".ToLower()) AndAlso (x.Age > 30)) OrElse (x.Id.ToString() == "aa648248-cb69-4217-ac95-d7484795afb2")) OrElse (x.Title == "lamb")) OrElse (x.Title == null)) AndAlso ((x.Age < 18) OrElse ((x.BirthMonth == new Nullable`1(January)) AndAlso x.Title.StartsWith("ally")))) OrElse (x.Rating > 3.5)) OrElse ((x.SpecificDate == new Nullable`1(new DateTimeOffset(637922304030000000, 00:00:00))) AndAlso ((x.Date == new Nullable`1(new DateOnly(2022, 7, 1))) OrElse (x.Time == new Nullable`1(new TimeOnly(0, 0, 3, 0, 0))))))""""); + .Be(""""x => (((((((x.Title.ToLower().Contains("waffle & chicken".ToLower()) AndAlso (x.Age > 30)) OrElse (x.Id == aa648248-cb69-4217-ac95-d7484795afb2)) OrElse (x.Title == "lamb")) OrElse (x.Title == null)) AndAlso ((x.Age < 18) OrElse ((x.BirthMonth == new Nullable`1(January)) AndAlso x.Title.StartsWith("ally")))) OrElse (x.Rating > 3.5)) OrElse ((x.SpecificDate == new Nullable`1(new DateTimeOffset(637922304030000000, 00:00:00))) AndAlso ((x.Date == new Nullable`1(new DateOnly(2022, 7, 1))) OrElse (x.Time == new Nullable`1(new TimeOnly(0, 0, 3, 0, 0))))))""""); } [Fact] @@ -76,7 +76,7 @@ public void can_handle_guid_with_double_quotes() var guid = Guid.NewGuid(); var input = $"""Id == "{guid}" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be($"x => (x.Id.ToString() == \"{guid}\")"); + filterExpression.ToString().Should().Be($"x => (x.Id == {guid})"); } [Fact] @@ -85,7 +85,7 @@ public void can_handle_guid_without_double_quotes() var guid = Guid.NewGuid(); var input = $"""Id == {guid} """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be($"x => (x.Id.ToString() == \"{guid}\")"); + filterExpression.ToString().Should().Be($"x => (x.Id == {guid})"); } [Fact] @@ -93,7 +93,9 @@ public void can_handle_guid_with_null() { var input = $"""SecondaryId == null """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be($"x => (IIF(x.SecondaryId.HasValue, x.SecondaryId.Value.ToString(), null) == null)"); + // filterExpression.ToString().Should().Be($"x => (IIF(x.SecondaryId.HasValue, x.SecondaryId.Value), null) == null)"); + // This seems to be correct per the integration tests. + filterExpression.ToString().Should().Be($"x => (x.SecondaryId == null)"); } [Fact] @@ -224,8 +226,9 @@ public void simple_in_operator_for_guid() { var input = """Id ^^ ["6d623e92-d2cf-4496-a2df-f49fa77328ee"]"""; var filterExpression = FilterParser.ParseFilter(input); + // TODO: Add to DbContext Integration Tests filterExpression.ToString().Should() - .Be(""""x => value(System.Collections.Generic.List`1[System.String]).Contains(x.Id.ToString())""""); + .Be(""""x => value(System.Guid[]).Contains(x.Id)""""); } [Fact] diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 9cf8457..45b7420 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -262,7 +262,15 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ { return Expression.Constant(intVal, typeof(int)); } - targetType = targetType.GetGenericArguments()[0]; + // Fix: handle arrays as well as generic collections + if (targetType.IsArray) + { + targetType = targetType.GetElementType(); + } + else + { + targetType = targetType.GetGenericArguments()[0]; + } return CreateRightExprFromType(targetType, right, op); } @@ -574,18 +582,7 @@ private static Parser ComparisonExprParser(ParameterExpression pa } if (temp.leftExpr.Type == typeof(Guid) || temp.leftExpr.Type == typeof(Guid?)) - { - // Try to determine the property path for HasConversion support - string? guidPropertyPath = null; - if (temp.leftExpr is MemberExpression guidMemberExpr) - { - guidPropertyPath = GetPropertyPath(guidMemberExpr, parameter); - } - - var guidStringExpr = HandleGuidConversion(temp.leftExpr, temp.leftExpr.Type); - return temp.op.GetExpression(guidStringExpr, CreateRightExpr(temp.leftExpr, temp.right, temp.op, config, guidPropertyPath), - config?.DbContextType); - } + return HandleGuidComparison(temp.leftExpr, temp.right, temp.op, config); // Check if the right side is a property path for property-to-property comparison if (IsPropertyPath(temp.right, parameter.Type)) @@ -597,11 +594,11 @@ private static Parser ComparisonExprParser(ParameterExpression pa var leftExpr = temp.leftExpr; if (leftExpr.Type == typeof(Guid) || leftExpr.Type == typeof(Guid?)) { - leftExpr = HandleGuidConversion(leftExpr, leftExpr.Type); + leftExpr = HandleGuidConversion(leftExpr, leftExpr.Type, null, config); } if (rightPropertyExpr.Type == typeof(Guid) || rightPropertyExpr.Type == typeof(Guid?)) { - rightPropertyExpr = HandleGuidConversion(rightPropertyExpr, rightPropertyExpr.Type); + rightPropertyExpr = HandleGuidConversion(rightPropertyExpr, rightPropertyExpr.Type, null, config); } // Ensure compatible types for property-to-property comparison @@ -689,7 +686,7 @@ private static Parser ComparisonExprParser(ParameterExpression pa var selectLambda = Expression.Lambda(lambdaBody, innerParameter); var selectResult = Expression.Call(null, selectMethod, member, selectLambda); - return HandleGuidConversion(selectResult, propertyType, "Select"); + return HandleGuidConversion(selectResult, propertyType, "Select", config); } } } @@ -829,31 +826,18 @@ private static Parser OrExprParser(ParameterExpression parameter, (op, left, right) => op.GetExpression(left, right) ); - private static Expression GetGuidToStringExpression(Expression leftExpr) - { - var toStringMethod = typeof(Guid).GetMethod("ToString", Type.EmptyTypes); - - return leftExpr.Type == typeof(Guid?) ? - Expression.Condition( - Expression.Property(leftExpr, "HasValue"), - Expression.Call(Expression.Property(leftExpr, "Value"), toStringMethod!), - Expression.Constant(null, typeof(string)) - ) : - Expression.Call(leftExpr, toStringMethod!); - } - - private static Expression HandleGuidConversion(Expression expression, Type propertyType, string? selectMethodName = null) + private static Expression HandleGuidConversion(Expression expression, Type propertyType, string? selectMethodName = null, IQueryKitConfiguration? config = null) { if (propertyType != typeof(Guid) && propertyType != typeof(Guid?)) return expression; - if (string.IsNullOrWhiteSpace(selectMethodName)) return GetGuidToStringExpression(expression); + if (string.IsNullOrWhiteSpace(selectMethodName)) return expression; var selectMethod = typeof(Enumerable).GetMethods() .First(m => m.Name == selectMethodName && m.GetParameters().Length == 2) .MakeGenericMethod(propertyType, typeof(string)); var param = Expression.Parameter(propertyType, "g"); - var toStringLambda = Expression.Lambda(GetGuidToStringExpression(param), param); + var toStringLambda = Expression.Lambda(Expression.Call(param, typeof(Guid).GetMethod("ToString", Type.EmptyTypes)), param); return Expression.Call(selectMethod, expression, toStringLambda); } @@ -1128,6 +1112,97 @@ private static object ConvertStringToBasicType(string value) // Default to string return value; } + + // --- BEGIN FORK: Custom Guid handling for Marten/EF compatibility --- + private static Expression HandleGuidComparison(Expression leftExpr, string right, ComparisonOperator op, IQueryKitConfiguration? config) + { + if (OperatorTypeHelper.IsStringOperator(op)) + { + // Convert left to string + var toStringMethod = typeof(Guid).GetMethod("ToString", Type.EmptyTypes); + Expression leftString = leftExpr.Type == typeof(Guid?) + ? Expression.Condition( + Expression.Property(leftExpr, "HasValue"), + Expression.Call(Expression.Property(leftExpr, "Value"), toStringMethod!), + Expression.Constant(null, typeof(string)) + ) + : Expression.Call(leftExpr, toStringMethod!); + // Right is a string, trim quotes + var rightStringValue = right.Trim('"'); + var rightStringExpr = Expression.Constant(rightStringValue, typeof(string)); + return op.GetExpression(leftString, rightStringExpr, config?.DbContextType); + } + bool isNullable = leftExpr.Type == typeof(Guid?); + bool isNull = right.Trim('"').Equals("null", StringComparison.OrdinalIgnoreCase); + var opName = op.Name; + // Handle 'in' and 'not in' operators (array) + if ((opName == "^^" || opName == "!^^" || opName == "^$" || opName == "!^$") && right.StartsWith("[") && right.EndsWith("]")) + { + var guids = right.Trim('[', ']').Split(',') + .Select(x => x.Trim().Trim('"')) + .Select(x => Guid.TryParse(x, out var g) ? g : throw new ParsingException(new InvalidOperationException($"Invalid Guid value in array: '{x}'"))) + .ToArray(); + var leftType = leftExpr.Type; + Expression leftForContains = leftExpr; + + // Create the array with the correct element type to match leftExpr + Expression arrayExpr; + MethodInfo containsMethod; + + if (leftType == typeof(Guid?)) + { + var nullableGuids = guids.Select(g => (Guid?)g).ToArray(); + arrayExpr = Expression.Constant(nullableGuids, typeof(Guid?[])); + containsMethod = typeof(Enumerable).GetMethods() + .First(m => m.Name == "Contains" && m.GetParameters().Length == 2) + .MakeGenericMethod(typeof(Guid?)); + } + else if (leftType == typeof(Guid)) + { + var nonNullableGuids = guids.ToArray(); + arrayExpr = Expression.Constant(nonNullableGuids, typeof(Guid[])); + containsMethod = typeof(Enumerable).GetMethods() + .First(m => m.Name == "Contains" && m.GetParameters().Length == 2) + .MakeGenericMethod(typeof(Guid)); + } + else + { + throw new ParsingException(new InvalidOperationException($"Unsupported left expression type for Guid array comparison: {leftType}")); + } + + // Call arrayExpr.Contains(leftExpr) - checking if the single value is in the array + var containsExpr = Expression.Call(containsMethod, arrayExpr, leftForContains); + + // For 'not in', negate the expression + if (opName == "!^^" || opName == "!^$") + return Expression.Not(containsExpr); + return containsExpr; + } + // Handle null for nullable Guid + if (isNull && isNullable) + { + return op.GetExpression(leftExpr, Expression.Constant(null, typeof(Guid?)), config?.DbContextType); + } + // Handle single Guid + if (Guid.TryParse(right.Trim('"'), out var guidValue)) + { + // Ensure type compatibility for property-to-property and property-to-value comparisons + var rightType = leftExpr.Type; + object rightGuidObj = guidValue; + if (rightType == typeof(Guid?)) + { + rightGuidObj = (Guid?)guidValue; + } + else if (rightType == typeof(Guid)) + { + rightGuidObj = guidValue; + } + var rightGuidExpr = Expression.Constant(rightGuidObj, rightType); + return op.GetExpression(leftExpr, rightGuidExpr, config?.DbContextType); + } + throw new ParsingException(new InvalidOperationException($"Invalid Guid value: '{right}'")); + } + // --- END FORK --- } diff --git a/QueryKit/Operators/ComparisonOperator.cs b/QueryKit/Operators/ComparisonOperator.cs index 42917e4..485f4e0 100644 --- a/QueryKit/Operators/ComparisonOperator.cs +++ b/QueryKit/Operators/ComparisonOperator.cs @@ -707,11 +707,12 @@ public HasType(bool caseInsensitive = false, bool usesAll = false) : base("^$", public override bool IsCountOperator() => false; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { - if (left.Type.IsGenericType && + if ((left.Type.IsGenericType && (left.Type.GetGenericTypeDefinition() == typeof(List<>) || left.Type.GetGenericTypeDefinition() == typeof(ICollection<>) || left.Type.GetGenericTypeDefinition() == typeof(IList<>) || typeof(IEnumerable<>).IsAssignableFrom(left.Type.GetGenericTypeDefinition()))) + || left.Type.IsArray) { return GetCollectionExpression(left, right, Expression.Equal, UsesAll); } @@ -730,11 +731,12 @@ public DoesNotHaveType(bool caseInsensitive = false, bool usesAll = false) : bas public override bool IsCountOperator() => false; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { - if (left.Type.IsGenericType && + if ((left.Type.IsGenericType && (left.Type.GetGenericTypeDefinition() == typeof(List<>) || left.Type.GetGenericTypeDefinition() == typeof(ICollection<>) || left.Type.GetGenericTypeDefinition() == typeof(IList<>) || typeof(IEnumerable<>).IsAssignableFrom(left.Type.GetGenericTypeDefinition()))) + || left.Type.IsArray) { return GetCollectionExpression(left, right, Expression.NotEqual, UsesAll); } @@ -918,7 +920,37 @@ internal static List GetAliasMatches(IQueryKitConfiguratio private Expression GetCollectionExpression(Expression left, Expression right, Func comparisonFunction, bool usesAll) { - var xParameter = Expression.Parameter(left.Type.GetGenericArguments()[0], "z"); + Type elementType; + if (left.Type.IsArray) + { + elementType = left.Type.GetElementType(); + } + else if (left.Type.IsGenericType) + { + elementType = left.Type.GetGenericArguments()[0]; + } + else + { + throw new QueryKitParsingException("Left expression is not a collection type"); + } + + // Convert right value to correct type BEFORE creating the lambda + if (right is ConstantExpression constExpr && constExpr.Type == typeof(string) && + (elementType == typeof(Guid) || elementType == typeof(Guid?))) + { + var strVal = (string)constExpr.Value; + if (Guid.TryParse(strVal.Trim('"'), out var guidVal)) + { + object val = elementType == typeof(Guid?) ? (Guid?)guidVal : guidVal; + right = Expression.Constant(val, elementType); + } + else + { + throw new QueryKitParsingException($"Could not parse '{strVal}' as Guid for collection comparison"); + } + } + + var xParameter = Expression.Parameter(elementType, "z"); Expression body; if (CaseInsensitive && xParameter.Type == typeof(string) && right.Type == typeof(string)) @@ -937,9 +969,17 @@ private Expression GetCollectionExpression(Expression left, Expression right, Fu var anyMethod = typeof(Enumerable) .GetMethods() .Single(m => m.Name == (usesAll ? "All" : "Any") && m.GetParameters().Length == 2) - .MakeGenericMethod(left.Type.GetGenericArguments()[0]); + .MakeGenericMethod(elementType); + + // If left is array, convert to IEnumerable + Expression leftEnumerable = left; + if (left.Type.IsArray) + { + var asEnumerableMethod = typeof(Enumerable).GetMethod("AsEnumerable").MakeGenericMethod(elementType); + leftEnumerable = Expression.Call(asEnumerableMethod, left); + } - return Expression.Call(anyMethod, left, anyLambda); + return Expression.Call(anyMethod, leftEnumerable, anyLambda); } private Expression GetCollectionExpression(Expression left, Expression right, string methodName, bool negate, bool usesAll) diff --git a/QueryKit/Operators/OperatorTypeHelper.cs b/QueryKit/Operators/OperatorTypeHelper.cs new file mode 100644 index 0000000..9aa8c1f --- /dev/null +++ b/QueryKit/Operators/OperatorTypeHelper.cs @@ -0,0 +1,21 @@ +using QueryKit.Operators; + +namespace QueryKit.Operators +{ + public static class OperatorTypeHelper + { + public static bool IsStringOperator(ComparisonOperator op) + { + var typeName = op.GetType().Name; + return + typeName == "ContainsType" || + typeName == "StartsWithType" || + typeName == "EndsWithType" || + typeName == "NotContainsType" || + typeName == "NotStartsWithType" || + typeName == "NotEndsWithType" || + typeName == "SoundsLikeType" || + typeName == "DoesNotSoundLikeType"; + } + } +} \ No newline at end of file