diff --git a/.cursor/rules/general.mdc b/.cursor/rules/general.mdc new file mode 100644 index 0000000..f84aa55 --- /dev/null +++ b/.cursor/rules/general.mdc @@ -0,0 +1,11 @@ +--- +alwaysApply: true +--- + +# Query Kit Fork + +This library is a fork from an upstream library. Meaning: + +- All changes should be made with the intention of making it easy to merge in upstream stranges. +- Keep large changes in separate files and wrap changes in methods as much as possible. +- Provide good descriptions of the before and after changes in the comments, so it's clear what changed. 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..f33f9f2 --- /dev/null +++ b/QueryKit.MartenTests/Documents/TestDocument.cs @@ -0,0 +1,43 @@ +namespace QueryKit.MartenTests.Documents; + +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 string[] Tags { get; set; } = []; + public string[]? NullableTags { get; set; } + public List Items { get; set; } = []; + public List? NullableItems { get; set; } + public NestedItem? SingleNestItem { 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 class NestedItem +{ + public string? Name { get; set; } + public int Value { 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..deb286e --- /dev/null +++ b/QueryKit.MartenTests/Tests/MartenGuidFilteringTests.cs @@ -0,0 +1,619 @@ +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(Skip = "Marten does not support ToString() on Guid")] + 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_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() + { + // 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 can_filter_by_name_and_additionalids_with_or_on_guid_array() + { + // Arrange + var testPrefix = "prefix-" + Guid.NewGuid().ToString("N").Substring(0, 8); + var id1 = Guid.NewGuid(); + var id2 = Guid.NewGuid(); + var id3 = Guid.NewGuid(); + var id4 = Guid.NewGuid(); + + var doc1 = new TestDocument + { + Id = Guid.NewGuid(), + Title = testPrefix + "-one", + AdditionalIds = new[] { id1, id2 } + }; + var doc2 = new TestDocument + { + Id = Guid.NewGuid(), + Title = testPrefix + "-two", + AdditionalIds = new[] { id3 } + }; + var doc3 = new TestDocument + { + Id = Guid.NewGuid(), + Title = testPrefix + "-three", + AdditionalIds = new[] { id4 } + }; + var doc4 = new TestDocument + { + Id = Guid.NewGuid(), + Title = "other-title", + AdditionalIds = new[] { id2, id3 } + }; + + Session.Store(doc1, doc2, doc3, doc4); + await Session.SaveChangesAsync(); + + var input = $"""Title @= "{testPrefix}" && (AdditionalIds ^$ {id2} || AdditionalIds ^$ "{id3}")"""; + + // Act + var queryable = Session.Query(); + var appliedQueryable = queryable.ApplyQueryKitFilter(input); + var results = appliedQueryable.ToList(); + + // Assert + results.Should().ContainSingle(x => x.Id == doc1.Id); + results.Should().ContainSingle(x => x.Id == doc2.Id); + results.Should().NotContain(x => x.Id == doc3.Id); + results.Should().NotContain(x => x.Id == doc4.Id); + } + + [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.MartenTests/Tests/MartenNestedFilteringTests.cs b/QueryKit.MartenTests/Tests/MartenNestedFilteringTests.cs new file mode 100644 index 0000000..04a3c72 --- /dev/null +++ b/QueryKit.MartenTests/Tests/MartenNestedFilteringTests.cs @@ -0,0 +1,829 @@ +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 MartenNestedFilteringTests : TestBase +{ + [Fact] + public async Task can_filter_by_guid_array_count_equals_zero() + { + // Arrange + var faker = new Faker(); + var docWithEmptyArray = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + AdditionalIds = [] + }; + var docWithItems = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + AdditionalIds = [Guid.NewGuid(), Guid.NewGuid()] + }; + + Session.Store(docWithEmptyArray, docWithItems); + await Session.SaveChangesAsync(); + + var input = """AdditionalIds #== 0"""; + + // 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(docWithEmptyArray.Id); + } + + [Fact] + public async Task can_filter_by_guid_array_count_greater_than_zero() + { + // Arrange + var faker = new Faker(); + var docWithEmptyArray = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + AdditionalIds = [] + }; + var docWithItems = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + AdditionalIds = [Guid.NewGuid(), Guid.NewGuid()] + }; + + Session.Store(docWithEmptyArray, docWithItems); + await Session.SaveChangesAsync(); + + var input = """AdditionalIds #> 0"""; + + // 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(docWithItems.Id); + } + + [Fact] + public async Task can_filter_by_nullable_guid_array_count_equals_zero() + { + // Arrange + var faker = new Faker(); + var docWithEmptyArray = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + NullableAdditionalIds = [] + }; + var docWithItems = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + NullableAdditionalIds = [Guid.NewGuid(), Guid.NewGuid()] + }; + var docWithNull = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + NullableAdditionalIds = null + }; + + Session.Store(docWithEmptyArray, docWithItems, docWithNull); + await Session.SaveChangesAsync(); + + var input = """NullableAdditionalIds #== 0"""; + + // 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(docWithEmptyArray.Id); + } + + [Fact] + public async Task can_filter_by_nullable_guid_array_count_greater_than_zero() + { + // Arrange + var faker = new Faker(); + var docWithEmptyArray = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + NullableAdditionalIds = [] + }; + var docWithItems = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + NullableAdditionalIds = [Guid.NewGuid(), Guid.NewGuid()] + }; + var docWithNull = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + NullableAdditionalIds = null + }; + + Session.Store(docWithEmptyArray, docWithItems, docWithNull); + await Session.SaveChangesAsync(); + + var input = """NullableAdditionalIds #> 0"""; + + // 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(docWithItems.Id); + } + + [Fact] + public async Task can_filter_by_string_array_count_equals_zero() + { + // Arrange + var faker = new Faker(); + var docWithEmptyArray = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + Tags = [] + }; + var docWithItems = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + Tags = ["tag1", "tag2", "tag3"] + }; + + Session.Store(docWithEmptyArray, docWithItems); + await Session.SaveChangesAsync(); + + var input = """Tags #== 0"""; + + // 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(docWithEmptyArray.Id); + } + + [Fact] + public async Task can_filter_by_string_array_count_greater_than_zero() + { + // Arrange + var faker = new Faker(); + var docWithEmptyArray = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + Tags = [] + }; + var docWithItems = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + Tags = ["tag1", "tag2", "tag3"] + }; + + Session.Store(docWithEmptyArray, docWithItems); + await Session.SaveChangesAsync(); + + var input = """Tags #> 0"""; + + // 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(docWithItems.Id); + } + + [Fact] + public async Task can_filter_by_string_array_count_equals_specific_value() + { + // Arrange + var faker = new Faker(); + var docWithTwoTags = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + Tags = ["tag1", "tag2"] + }; + var docWithThreeTags = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + Tags = ["tag1", "tag2", "tag3"] + }; + var docWithOneTag = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + Tags = ["tag1"] + }; + + Session.Store(docWithTwoTags, docWithThreeTags, docWithOneTag); + await Session.SaveChangesAsync(); + + var input = """Tags #== 2"""; + + // 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(docWithTwoTags.Id); + } + + [Fact] + public async Task can_filter_by_nullable_string_array_count_equals_zero() + { + // Arrange + var faker = new Faker(); + var docWithEmptyArray = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + NullableTags = [] + }; + var docWithItems = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + NullableTags = ["tag1", "tag2"] + }; + var docWithNull = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + NullableTags = null + }; + + Session.Store(docWithEmptyArray, docWithItems, docWithNull); + await Session.SaveChangesAsync(); + + var input = """NullableTags #== 0"""; + + // 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(docWithEmptyArray.Id); + } + + [Fact] + public async Task can_filter_by_nullable_string_array_count_greater_than_zero() + { + // Arrange + var faker = new Faker(); + var docWithEmptyArray = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + NullableTags = [] + }; + var docWithItems = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + NullableTags = ["tag1", "tag2"] + }; + var docWithNull = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + NullableTags = null + }; + + Session.Store(docWithEmptyArray, docWithItems, docWithNull); + await Session.SaveChangesAsync(); + + var input = """NullableTags #> 0"""; + + // 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(docWithItems.Id); + } + + [Fact] + public async Task can_filter_by_nested_object_list_count_equals_zero() + { + // Arrange + var faker = new Faker(); + var docWithEmptyList = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + Items = [] + }; + var docWithItems = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + Items = [ + new NestedItem { Name = "Item1", Value = 10 }, + new NestedItem { Name = "Item2", Value = 20 } + ] + }; + + Session.Store(docWithEmptyList, docWithItems); + await Session.SaveChangesAsync(); + + var input = """Items #== 0"""; + + // 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(docWithEmptyList.Id); + } + + [Fact] + public async Task can_filter_by_nested_object_list_count_greater_than_zero() + { + // Arrange + var faker = new Faker(); + var docWithEmptyList = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + Items = [] + }; + var docWithItems = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + Items = [ + new NestedItem { Name = "Item1", Value = 10 }, + new NestedItem { Name = "Item2", Value = 20 } + ] + }; + + Session.Store(docWithEmptyList, docWithItems); + await Session.SaveChangesAsync(); + + var input = """Items #> 0"""; + + // 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(docWithItems.Id); + } + + [Fact] + public async Task can_filter_by_nested_object_list_count_equals_specific_value() + { + // Arrange + var faker = new Faker(); + var docWithTwoItems = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + Items = [ + new NestedItem { Name = "Item1", Value = 10 }, + new NestedItem { Name = "Item2", Value = 20 } + ] + }; + var docWithThreeItems = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + Items = [ + new NestedItem { Name = "Item1", Value = 10 }, + new NestedItem { Name = "Item2", Value = 20 }, + new NestedItem { Name = "Item3", Value = 30 } + ] + }; + var docWithOneItem = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + Items = [ + new NestedItem { Name = "Item1", Value = 10 } + ] + }; + + Session.Store(docWithTwoItems, docWithThreeItems, docWithOneItem); + await Session.SaveChangesAsync(); + + var input = """Items #== 2"""; + + // 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(docWithTwoItems.Id); + } + + [Fact] + public async Task can_filter_with_complex_conditions_using_count_operators() + { + // Arrange + var faker = new Faker(); + var doc1 = new TestDocument + { + Id = Guid.NewGuid(), + Title = "Has tags and items", + Tags = ["tag1", "tag2"], + Items = [ + new NestedItem { Name = "Item1", Value = 10 } + ], + AdditionalIds = [Guid.NewGuid()] + }; + var doc2 = new TestDocument + { + Id = Guid.NewGuid(), + Title = "No tags but has items", + Tags = [], + Items = [ + new NestedItem { Name = "Item1", Value = 10 }, + new NestedItem { Name = "Item2", Value = 20 } + ], + AdditionalIds = [] + }; + var doc3 = new TestDocument + { + Id = Guid.NewGuid(), + Title = "Has tags but no items", + Tags = ["tag1"], + Items = [], + AdditionalIds = [Guid.NewGuid(), Guid.NewGuid()] + }; + var doc4 = new TestDocument + { + Id = Guid.NewGuid(), + Title = "Empty everything", + Tags = [], + Items = [], + AdditionalIds = [] + }; + + Session.Store(doc1, doc2, doc3, doc4); + await Session.SaveChangesAsync(); + + var input = """(Tags #> 0 && Items #> 0) || (AdditionalIds #> 1)"""; + + // 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); // Has tags and items + results.Should().Contain(x => x.Id == doc3.Id); // Has AdditionalIds count > 1 + results.Should().NotContain(x => x.Id == doc2.Id); + results.Should().NotContain(x => x.Id == doc4.Id); + } + + [Fact] + public async Task can_filter_by_nested_object_property_name() + { + // Arrange + var faker = new Faker(); + var docWithNestedItem = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + SingleNestItem = new NestedItem { Name = "TestItem", Value = 10 } + }; + var docWithDifferentName = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + SingleNestItem = new NestedItem { Name = "OtherItem", Value = 20 } + }; + var docWithNullNestedItem = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + SingleNestItem = null + }; + + Session.Store(docWithNestedItem, docWithDifferentName, docWithNullNestedItem); + await Session.SaveChangesAsync(); + + var input = """SingleNestItem.Name == "TestItem" """; + + // 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(docWithNestedItem.Id); + } + + [Fact] + public async Task can_filter_by_nested_object_property_value() + { + // Arrange + var faker = new Faker(); + var docWithValue10 = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + SingleNestItem = new NestedItem { Name = "Item1", Value = 10 } + }; + var docWithValue20 = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + SingleNestItem = new NestedItem { Name = "Item2", Value = 20 } + }; + var docWithValue30 = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + SingleNestItem = new NestedItem { Name = "Item3", Value = 30 } + }; + var docWithNullNestedItem = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + SingleNestItem = null + }; + + Session.Store(docWithValue10, docWithValue20, docWithValue30, docWithNullNestedItem); + await Session.SaveChangesAsync(); + + var input = """SingleNestItem.Value > 15"""; + + // 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 == docWithValue20.Id); + results.Should().Contain(x => x.Id == docWithValue30.Id); + results.Should().NotContain(x => x.Id == docWithValue10.Id); + results.Should().NotContain(x => x.Id == docWithNullNestedItem.Id); + } + + [Fact] + public async Task can_filter_by_nested_object_property_when_null() + { + // Arrange + var faker = new Faker(); + var docWithNestedItem = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + SingleNestItem = new NestedItem { Name = "TestItem", Value = 10 } + }; + var docWithNullNestedItem = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + SingleNestItem = null + }; + + Session.Store(docWithNestedItem, docWithNullNestedItem); + await Session.SaveChangesAsync(); + + var input = """SingleNestItem == 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(docWithNullNestedItem.Id); + } + + [Fact] + public async Task can_filter_by_nested_object_property_name_with_contains() + { + // Arrange + var faker = new Faker(); + var docWithMatchingName = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + SingleNestItem = new NestedItem { Name = "TestItem123", Value = 10 } + }; + var docWithNonMatchingName = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + SingleNestItem = new NestedItem { Name = "OtherItem", Value = 20 } + }; + var docWithNullNestedItem = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + SingleNestItem = null + }; + + Session.Store(docWithMatchingName, docWithNonMatchingName, docWithNullNestedItem); + await Session.SaveChangesAsync(); + + var input = """SingleNestItem.Name @= "Test" """; + + // 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(docWithMatchingName.Id); + } + + [Fact] + public async Task can_filter_with_complex_nested_object_conditions() + { + // Arrange + var faker = new Faker(); + var doc1 = new TestDocument + { + Id = Guid.NewGuid(), + Title = "Doc1", + SingleNestItem = new NestedItem { Name = "Item1", Value = 10 }, + Age = 25 + }; + var doc2 = new TestDocument + { + Id = Guid.NewGuid(), + Title = "Doc2", + SingleNestItem = new NestedItem { Name = "Item2", Value = 20 }, + Age = 30 + }; + var doc3 = new TestDocument + { + Id = Guid.NewGuid(), + Title = "Doc3", + SingleNestItem = new NestedItem { Name = "Item1", Value = 30 }, + Age = 25 + }; + var doc4 = new TestDocument + { + Id = Guid.NewGuid(), + Title = "Doc4", + SingleNestItem = null, + Age = 25 + }; + + Session.Store(doc1, doc2, doc3, doc4); + await Session.SaveChangesAsync(); + + var input = """SingleNestItem.Name == "Item1" && SingleNestItem.Value > 15"""; + + // 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(doc3.Id); + results.Should().NotContain(x => x.Id == doc1.Id); // Name matches but Value is 10, not > 15 + results.Should().NotContain(x => x.Id == doc2.Id); // Value > 15 but Name doesn't match + results.Should().NotContain(x => x.Id == doc4.Id); // SingleNestItem is null + } + + [Fact] + public async Task can_filter_by_nested_object_property_name_not_null() + { + // Arrange + var faker = new Faker(); + var docWithName = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + SingleNestItem = new NestedItem { Name = "TestItem", Value = 10 } + }; + var docWithNullName = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + SingleNestItem = new NestedItem { Name = null, Value = 20 } + }; + var docWithEmptyName = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + SingleNestItem = new NestedItem { Name = "", Value = 30 } + }; + var docWithNullNestedItem = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + SingleNestItem = null + }; + + Session.Store(docWithName, docWithNullName, docWithEmptyName, docWithNullNestedItem); + await Session.SaveChangesAsync(); + + var input = """SingleNestItem.Name != null"""; + + // Act + var queryable = Session.Query(); + var appliedQueryable = queryable.ApplyQueryKitFilter(input); + var results = appliedQueryable.ToList(); + + // Assert + results.Count.Should().Be(2); // docWithName and docWithEmptyName (empty string is not null) + results.Should().Contain(x => x.Id == docWithName.Id); + results.Should().Contain(x => x.Id == docWithEmptyName.Id); + results.Should().NotContain(x => x.Id == docWithNullName.Id); + results.Should().NotContain(x => x.Id == docWithNullNestedItem.Id); + } + + [Fact] + public async Task can_filter_by_nested_object_property_name_not_empty() + { + // Arrange + var faker = new Faker(); + var docWithName = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + SingleNestItem = new NestedItem { Name = "TestItem", Value = 10 } + }; + var docWithNullName = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + SingleNestItem = new NestedItem { Name = null, Value = 20 } + }; + var docWithEmptyName = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + SingleNestItem = new NestedItem { Name = "", Value = 30 } + }; + var docWithNullNestedItem = new TestDocument + { + Id = Guid.NewGuid(), + Title = faker.Lorem.Sentence(), + SingleNestItem = null + }; + + Session.Store(docWithName, docWithNullName, docWithEmptyName, docWithNullNestedItem); + await Session.SaveChangesAsync(); + + var input = """SingleNestItem.Name != "" """; + + // Act + var queryable = Session.Query(); + var appliedQueryable = queryable.ApplyQueryKitFilter(input); + var results = appliedQueryable.ToList(); + + // Assert + results.Count.Should().Be(1); // Only docWithName has a non-empty name + results.Should().Contain(x => x.Id == docWithName.Id); + results.Should().NotContain(x => x.Id == docWithNullName.Id); + results.Should().NotContain(x => x.Id == docWithEmptyName.Id); + results.Should().NotContain(x => x.Id == docWithNullNestedItem.Id); + } +} + diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index f64b30f..4fed541 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -224,8 +224,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.Guid]).Contains(x.Id)""""); + .Be(""""x => value(System.Guid[]).Contains(x.Id)""""); } [Fact] 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 diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index d594e33..0b69413 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -306,12 +306,38 @@ 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); } var rawType = targetType; + // Handle null for any nullable type (nullable value types or nullable reference types) + if (right == "null") + { + // For nullable value types (like Guid?, int?, etc.) + if (Nullable.GetUnderlyingType(rawType) != null) + { + return Expression.Constant(null, rawType); + } + // For nullable reference types (like string?, NestedItem?, etc.) + // Check if the type is a reference type (not a value type) + if (!rawType.IsValueType) + { + return Expression.Constant(null, rawType); + } + // For non-nullable value types, we can't assign null, but we'll let it fall through + // to potentially throw a more specific error + } + targetType = TransformTargetTypeIfNullable(targetType); if (TypeConversionFunctions.TryGetValue(targetType, out var conversionFunction)) @@ -632,27 +658,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); - } - - // Only convert to string for operators that require string comparison (Contains, StartsWith, etc.) - // For equality/comparison operators, keep as GUID for better EF Core translation - if (temp.op.IsStringComparisonOperator()) - { - var guidStringExpr = HandleGuidConversion(temp.leftExpr, temp.leftExpr.Type); - return temp.op.GetExpression(guidStringExpr, CreateRightExpr(temp.leftExpr, temp.right, temp.op, config, guidPropertyPath), - config?.DbContextType); - } - - // For non-string operators, use direct GUID comparison - return temp.op.GetExpression(temp.leftExpr, 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)) @@ -663,16 +669,13 @@ private static Parser ComparisonExprParser(ParameterExpression pa // Handle GUID conversion for property-to-property comparisons // Only convert to string for string operators var leftExpr = temp.leftExpr; - if (temp.op.IsStringComparisonOperator()) + if (leftExpr.Type == typeof(Guid) || leftExpr.Type == typeof(Guid?)) { - if (leftExpr.Type == typeof(Guid) || leftExpr.Type == typeof(Guid?)) - { - leftExpr = HandleGuidConversion(leftExpr, leftExpr.Type); - } - if (rightPropertyExpr.Type == typeof(Guid) || rightPropertyExpr.Type == typeof(Guid?)) - { - rightPropertyExpr = HandleGuidConversion(rightPropertyExpr, rightPropertyExpr.Type); - } + leftExpr = HandleGuidConversion(leftExpr, leftExpr.Type, null, config); + } + if (rightPropertyExpr.Type == typeof(Guid) || rightPropertyExpr.Type == typeof(Guid?)) + { + rightPropertyExpr = HandleGuidConversion(rightPropertyExpr, rightPropertyExpr.Type, null, config); } // Ensure compatible types for property-to-property comparison @@ -687,27 +690,26 @@ private static Parser ComparisonExprParser(ParameterExpression pa { propertyPath = GetPropertyPath(memberExpr, parameter); } - + var leftExprForComparison = temp.leftExpr; - // If the left expression is a conditional with Object type, convert it to the proper type if (leftExprForComparison.Type == typeof(object)) { var innerExpr = leftExprForComparison; - + // Unwrap Convert/Unary expressions while (innerExpr is UnaryExpression unaryExpr && unaryExpr.NodeType == ExpressionType.Convert) { innerExpr = unaryExpr.Operand; } - + if (innerExpr is ConditionalExpression conditionalExpr) { // Determine the actual type var trueType = conditionalExpr.IfTrue.Type; var falseType = conditionalExpr.IfFalse.Type; Type actualType = typeof(object); - + if (trueType == falseType) { actualType = trueType; @@ -716,7 +718,6 @@ private static Parser ComparisonExprParser(ParameterExpression pa { var underlyingTrue = Nullable.GetUnderlyingType(trueType) ?? trueType; var underlyingFalse = Nullable.GetUnderlyingType(falseType) ?? falseType; - if (underlyingTrue == underlyingFalse) { actualType = typeof(Nullable<>).MakeGenericType(underlyingTrue); @@ -730,15 +731,13 @@ private static Parser ComparisonExprParser(ParameterExpression pa { actualType = falseType; } - + // If we found a better type, recreate the conditional with the correct type if (actualType != typeof(object) && actualType != null) { - // Create a new conditional expression with the correct return type - // This ensures proper type handling without unnecessary conversions var newIfTrue = conditionalExpr.IfTrue; var newIfFalse = conditionalExpr.IfFalse; - + // Convert branches to the target type if needed if (newIfTrue.Type != actualType) { @@ -748,7 +747,7 @@ private static Parser ComparisonExprParser(ParameterExpression pa { newIfFalse = Expression.Convert(newIfFalse, actualType); } - + leftExprForComparison = Expression.Condition( conditionalExpr.Test, newIfTrue, @@ -757,16 +756,16 @@ private static Parser ComparisonExprParser(ParameterExpression pa } } } - + var rightExpr = CreateRightExpr(leftExprForComparison, temp.right, temp.op, config, propertyPath); - + // Handle nested collection filtering if (leftExprForComparison is MethodCallExpression methodCall && IsNestedCollectionExpression(methodCall)) { return CreateNestedCollectionFilterExpression(methodCall, rightExpr, temp.op); } - - + + return temp.op.GetExpression(leftExprForComparison, rightExpr, config?.DbContextType); }); @@ -830,7 +829,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); } } } @@ -970,31 +969,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); } @@ -1269,6 +1255,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 1aa63f4..37fa7f4 100644 --- a/QueryKit/Operators/ComparisonOperator.cs +++ b/QueryKit/Operators/ComparisonOperator.cs @@ -716,11 +716,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); } @@ -739,11 +740,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); } @@ -927,7 +929,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)) @@ -946,9 +978,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); - return Expression.Call(anyMethod, left, anyLambda); + // 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, leftEnumerable, anyLambda); } private Expression GetCollectionExpression(Expression left, Expression right, string methodName, bool negate, bool usesAll) @@ -987,7 +1027,39 @@ private Expression GetCountExpression(Expression left, Expression right, string throw new QueryKitParsingException("Left expression should be of type IEnumerable"); } - var leftGenericType = left.Type.GetGenericArguments()[0]; + // Handle nullable types - unwrap if needed + var leftType = left.Type; + var underlyingType = Nullable.GetUnderlyingType(leftType); + var isNullable = underlyingType != null; + if (isNullable) + { + leftType = underlyingType; + } + + // Handle arrays and generic collections differently + Type elementType; + if (leftType.IsArray) + { + elementType = leftType.GetElementType(); + if (elementType == null) + { + throw new QueryKitParsingException("Could not determine element type of array"); + } + } + else if (leftType.IsGenericType) + { + var genericArgs = leftType.GetGenericArguments(); + if (genericArgs.Length == 0) + { + throw new QueryKitParsingException("Generic type has no type arguments"); + } + elementType = genericArgs[0]; + } + else + { + throw new QueryKitParsingException("Left expression is not a collection type"); + } + var rightType = right.Type; if (rightType != typeof(int)) @@ -1002,9 +1074,32 @@ private Expression GetCountExpression(Expression left, Expression right, string throw new QueryKitParsingException("Count method not found"); } - var specificCountMethod = countMethod.MakeGenericMethod(leftGenericType); + var specificCountMethod = countMethod.MakeGenericMethod(elementType); + + // If left is nullable, we need to handle null case + // Marten supports Count() directly on arrays and collections without AsEnumerable() + Expression leftForCount = left; + if (isNullable) + { + // For nullable collections, we need to use null-coalescing to handle null case + // Create an empty array of the correct type + object emptyArray; + if (leftType.IsArray) + { + emptyArray = Array.CreateInstance(elementType, 0); + } + else + { + // For generic collections like List, create an empty instance + var emptyCollectionType = typeof(List<>).MakeGenericType(elementType); + emptyArray = Activator.CreateInstance(emptyCollectionType); + } + leftForCount = Expression.Coalesce(left, Expression.Constant(emptyArray, leftType)); + } - var countExpression = Expression.Call(null, specificCountMethod, left); + // Marten supports Count() directly on arrays and collections - no need for AsEnumerable() + // Arrays and generic collections both implement IEnumerable, so Count() works directly + var countExpression = Expression.Call(null, specificCountMethod, leftForCount); var comparisonMethod = typeof(Expression).GetMethod(methodName, new[] { typeof(Expression), typeof(Expression) }); if (comparisonMethod == null) { 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