From 4412fdb998cc0187e7861febd7c19e35d62ada76 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Mon, 19 Oct 2026 20:39:01 +0300 Subject: [PATCH] fix(filter): support the in operator on a collection and reject one value The in operators (^^ and !^^) threw a .NET exception for one value instead of a list: ArgumentException, or NullReferenceException for ^^*. They also failed on a collection property and on a nested path such as Ingredients.Name. - One value now throws QueryKitParsingException: "The ^^ operator needs a list value, for example ["a", "b"]." - On a collection, ^^ matches when any element is in the list, and %^^ when all elements are in the list. !^^ is the negation of ^^: no element is in the list. Fixes #204 Fixes #202 --- .../features/collections-and-nested.md | 3 + .../verify-querykit/features/filtering.md | 1 + .../Tests/CollectionTypeFilterTests.cs | 53 +++++++++ .../Tests/HasConversionTests.cs | 4 +- QueryKit.UnitTests/InOperatorTests.cs | 102 ++++++++++++++++++ QueryKit/Operators/ComparisonOperator.cs | 31 ++++++ 6 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 QueryKit.UnitTests/InOperatorTests.cs diff --git a/.agents/skills/verify-querykit/features/collections-and-nested.md b/.agents/skills/verify-querykit/features/collections-and-nested.md index 736409e..8063f18 100644 --- a/.agents/skills/verify-querykit/features/collections-and-nested.md +++ b/.agents/skills/verify-querykit/features/collections-and-nested.md @@ -12,6 +12,7 @@ A developer filters on a property of a related object with a dotted path, and on - `collection-primitive-compare` compares each value in a primitive list with a comparison operator (`Tags == "dinner"`, `Tags != "dinner"`, `Tags _= "br"`). The filter matches when any value matches. - `collection-primitive-null` checks the list itself for null (`Tags == null`, `Tags != null`). - `collection-array` gives the same results on an array property (`Labels`) as on a list property (`Tags`). +- `collection-in` matches when any value of a list is in the given list (`Tags ^^ ["dinner", "sweet"]`). `%^^` needs all values in the list. `!^^` matches when no value is in the list. ## How to get to it (user POV) @@ -33,10 +34,12 @@ Preconditions: - **Primitive list, compare.** Run `qk run collections-compare --filter 'Tags == "dinner"'`. Both targets give `["Beef Stew"]`. The `expression` contains `.Any(`. Then run `qk run collections-compare-not --filter 'Tags != "dinner"'`. Both targets give `Pancakes` and `Salt Bread`. - **Primitive list, null.** Run `qk run collections-null --filter 'Tags != null'`. Both targets give all four recipes. The `expression` has no `.Any(`. - **Array property.** Run each filter above again with `Labels` in place of `Tags`. Each filter gives the same `titles` as the `Tags` filter. The `sql` reads the `text[]` column `r."Labels"`. +- **In operator on a collection.** Run `qk run collections-in --filter 'Tags ^^ ["dinner", "sweet"]'`. Both targets give `Pancakes` and `Beef Stew`. The `sql` has `IN ('dinner', 'sweet')` inside `unnest(r."Tags")`. Then run `qk run collections-in-nested --filter 'Ingredients.Name ^^ ["salt"]'`. Both targets give `Beef Stew` and `Salt Bread`. ## Gotchas - `All` is true for an empty collection. So `Plain Water`, which has no ingredients, matches `Ingredients.Stock %>= 1`. - `Tags ^$ "winner"` without `*` does not match `Winner`. Use `^$*` for a case-insensitive match. - `Tags != "dinner"` matches a recipe that has any tag other than `dinner`. It does not match a recipe with no tags. Use `!^$` to get the recipes that do not have the tag. +- `Tags !^^ ["dinner"]` is the negation of `Tags ^^ ["dinner"]`, so it matches a recipe with no tags. This is different from `Tags != "dinner"`. - The driver adds `Include` for `Author` and `Ingredients` after QueryKit runs. The first statement in `sql` is the filter query. EF Core loads the ingredients in a second statement. diff --git a/.agents/skills/verify-querykit/features/filtering.md b/.agents/skills/verify-querykit/features/filtering.md index 4c73065..b36316f 100644 --- a/.agents/skills/verify-querykit/features/filtering.md +++ b/.agents/skills/verify-querykit/features/filtering.md @@ -31,6 +31,7 @@ Preconditions: - **Starts with.** Run `qk run filtering-starts-with --filter 'Title _= "P"'`. Both targets give `["Pancakes", "Plain Water"]`. - **Logic and parentheses.** Run `qk run filtering-logic --filter '(IsVegetarian == true && Rating < 2) || Title == "Beef Stew"'`. Both targets give `["Beef Stew", "Plain Water"]`. - **In operator.** Run `qk run filtering-in --filter 'Rating ^^ [3, 5]'`. Both targets give `["Pancakes", "Beef Stew"]`. +- **In operator with one value.** Run `qk run filtering-in-one-value --filter 'Title ^^ "Pancakes"'`. Both targets give `error.type` `QueryKit.Exceptions.QueryKitParsingException` with `isQueryKitException` true. The message says that the operator needs a list value. - **Enum by integer.** Run `qk run filtering-enum --filter 'Visibility == 2'`. Both targets give `["Salt Bread", "Plain Water"]`. The `expression` shows `x.Visibility == Private`. - **Date and null.** Run `qk run filtering-date-null --filter 'CreatedAt >= "2024-03-01T00:00:00Z" && DateOfOrigin != null'`. Both targets give `["Salt Bread"]`. - **Property grouping.** Run `qk run filtering-group --filter '(Title, Directions) @=* "bake"'`. Both targets give `["Salt Bread"]`. diff --git a/QueryKit.IntegrationTests/Tests/CollectionTypeFilterTests.cs b/QueryKit.IntegrationTests/Tests/CollectionTypeFilterTests.cs index 7b9f632..3f38a0c 100644 --- a/QueryKit.IntegrationTests/Tests/CollectionTypeFilterTests.cs +++ b/QueryKit.IntegrationTests/Tests/CollectionTypeFilterTests.cs @@ -1,7 +1,9 @@ namespace QueryKit.IntegrationTests.Tests; +using Exceptions; using FluentAssertions; using Microsoft.EntityFrameworkCore; +using SharedTestingHelper.Fakes.Ingredients; using SharedTestingHelper.Fakes.Recipes; // A list endpoint often projects an entity to a DTO that exposes a collection as IReadOnlyList, and then applies the filter. @@ -20,6 +22,11 @@ private static readonly (string Operation, string[] Expected)[] Cases = ("""@= "ee" """, ["pancakes"]), ("""_= "br" """, ["pancakes", "bread"]), ("#== 2", ["pancakes", "bread"]), + ("""^^ ["dinner", "sweet"] """, ["pancakes", "stew"]), + ("""!^^ ["dinner", "sweet"] """, ["bread", "water"]), + ("""%^^ ["dinner", "breakfast", "sweet"] """, ["pancakes", "stew", "water"]), + ("""^^* ["WINNER"] """, ["bread"]), + ("""!^^* ["WINNER"] """, ["pancakes", "stew", "water"]), ]; public static TheoryData Filters() @@ -61,6 +68,52 @@ public async Task collection_filter_on_a_projected_dto_matches_for_every_collect recipes.Select(x => x.Title[prefix.Length..]).Should().BeEquivalentTo(expectedTitles); } + [Theory] + [InlineData("""Ingredients.Name ^^ ["salt", "beef"] """, new[] { "stew", "bread" })] + [InlineData("""Ingredients.Name !^^ ["salt", "beef"] """, new[] { "pancakes", "water" })] + [InlineData("""Ingredients.Name %^^ ["salt", "beef"] """, new[] { "stew", "water" })] + public async Task in_operator_on_a_nested_collection_path_matches(string input, string[] expectedTitles) + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var prefix = $"in nested {Guid.NewGuid()} "; + var pancakes = new FakeRecipeBuilder().WithTitle($"{prefix}pancakes").Build(); + pancakes.AddIngredient(new FakeIngredientBuilder().WithName("flour").Build()); + var stew = new FakeRecipeBuilder().WithTitle($"{prefix}stew").Build(); + stew.AddIngredient(new FakeIngredientBuilder().WithName("beef").Build()); + stew.AddIngredient(new FakeIngredientBuilder().WithName("salt").Build()); + var bread = new FakeRecipeBuilder().WithTitle($"{prefix}bread").Build(); + bread.AddIngredient(new FakeIngredientBuilder().WithName("salt").Build()); + bread.AddIngredient(new FakeIngredientBuilder().WithName("flour").Build()); + var water = new FakeRecipeBuilder().WithTitle($"{prefix}water").Build(); + await testingServiceScope.InsertAsync(pancakes, stew, bread, water); + + // Act + var recipes = await testingServiceScope.DbContext().Recipes + .Where(x => x.Title.StartsWith(prefix)) + .ApplyQueryKitFilter(input) + .ToListAsync(); + + // Assert + recipes.Select(x => x.Title[prefix.Length..]).Should().BeEquivalentTo(expectedTitles); + } + + [Theory] + [InlineData("""Title ^^ "pancakes" """)] + [InlineData("""Tags ^^ "dinner" """)] + public async Task in_operator_with_one_value_throws_querykit_parsing_exception(string input) + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + + // Act + var act = () => testingServiceScope.DbContext().Recipes.ApplyQueryKitFilter(input).ToListAsync(); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("""The ^^ operator needs a list value, for example ["a", "b"]."""); + } + public class RecipeDto { public string Title { get; init; } = null!; diff --git a/QueryKit.IntegrationTests/Tests/HasConversionTests.cs b/QueryKit.IntegrationTests/Tests/HasConversionTests.cs index 2c71895..e8f1661 100644 --- a/QueryKit.IntegrationTests/Tests/HasConversionTests.cs +++ b/QueryKit.IntegrationTests/Tests/HasConversionTests.cs @@ -180,7 +180,7 @@ public async Task can_filter_by_nullable_int_with_query_name_and_has_conversion( var input = $"""Title == "{title}" && years == 41"""; var config = new QueryKitConfiguration(config => { - config.Property(x => x.Age).HasQueryName("years").HasConversion(); + config.Property(x => x.Age!).HasQueryName("years").HasConversion(); }); // Act @@ -212,7 +212,7 @@ public async Task can_filter_by_enum_with_query_name_and_has_conversion() var input = $"""Title == "{title}" && month == March"""; var config = new QueryKitConfiguration(config => { - config.Property(x => x.BirthMonth).HasQueryName("month").HasConversion(); + config.Property(x => x.BirthMonth!).HasQueryName("month").HasConversion(); }); // Act diff --git a/QueryKit.UnitTests/InOperatorTests.cs b/QueryKit.UnitTests/InOperatorTests.cs new file mode 100644 index 0000000..3d82437 --- /dev/null +++ b/QueryKit.UnitTests/InOperatorTests.cs @@ -0,0 +1,102 @@ +namespace QueryKit.UnitTests; + +using Exceptions; +using FluentAssertions; + +public class InOperatorTests +{ + [Theory] + [InlineData("""Name ^^ "a" """, "^^")] + [InlineData("""Name !^^ "a" """, "!^^")] + [InlineData("""Name ^^* "a" """, "^^*")] + [InlineData("""Name !^^* "a" """, "!^^*")] + [InlineData("Score ^^ 5", "^^")] + [InlineData("""Size ^^ "Big" """, "^^")] + [InlineData("""Id ^^ "8b1f0a62-4a5e-4c52-9f39-2a8d8a1c9e11" """, "^^")] + [InlineData("""List ^^ "dinner" """, "^^")] + public void in_operator_with_one_value_throws_querykit_parsing_exception(string input, string op) + { + var act = () => Rows().AsQueryable().ApplyQueryKitFilter(input).ToList(); + + act.Should().Throw() + .WithMessage($"""The {op} operator needs a list value, for example ["a", "b"]."""); + } + + [Theory] + [InlineData("""Name ^^ ["pancakes", "stew"] """, new[] { "pancakes", "stew" })] + [InlineData("""Name !^^ ["pancakes", "stew"] """, new[] { "bread", "water" })] + [InlineData("""Name ^^* ["PANCAKES"] """, new[] { "pancakes" })] + [InlineData("Score ^^ [1, 3]", new[] { "pancakes", "bread" })] + public void in_operator_with_a_list_on_a_scalar_property_matches(string input, string[] expected) + { + var result = Rows().AsQueryable().ApplyQueryKitFilter(input).ToList(); + + result.Select(x => x.Name).Should().BeEquivalentTo(expected); + } + + private static readonly (string Operation, string[] Expected)[] CollectionCases = + [ + ("""^^ ["dinner", "sweet"] """, ["pancakes", "stew"]), + ("""!^^ ["dinner", "sweet"] """, ["bread", "water"]), + ("""%^^ ["dinner", "breakfast", "sweet"] """, ["pancakes", "stew", "water"]), + ("""^^* ["WINNER"] """, ["bread"]), + ("""!^^* ["WINNER"] """, ["pancakes", "stew", "water"]), + ]; + + public static TheoryData CollectionFilters() + { + var data = new TheoryData(); + foreach (var property in new[] { nameof(Row.List), nameof(Row.ReadOnlyList), nameof(Row.Set), nameof(Row.Array), "Children.Name" }) + foreach (var (operation, expected) in CollectionCases) + data.Add(property, operation, expected); + return data; + } + + [Theory] + [MemberData(nameof(CollectionFilters))] + public void in_operator_on_a_collection_matches_when_any_element_is_in_the_list(string property, string operation, string[] expected) + { + var result = Rows().AsQueryable().ApplyQueryKitFilter($"{property} {operation}").ToList(); + + result.Select(x => x.Name).Should().BeEquivalentTo(expected); + } + + [Theory] + [InlineData("Numbers ^^ [2, 9]", new[] { "pancakes", "stew" })] + [InlineData("Numbers !^^ [2, 9]", new[] { "bread", "water" })] + public void in_operator_on_a_number_collection_matches(string input, string[] expected) + { + var result = Rows().AsQueryable().ApplyQueryKitFilter(input).ToList(); + + result.Select(x => x.Name).Should().BeEquivalentTo(expected); + } + + private static Row[] Rows() => + [ + new("pancakes", 1, ["breakfast", "sweet"], [1, 2]), + new("stew", 2, ["dinner"], [9]), + new("bread", 3, ["bread", "Winner"], [3]), + new("water", 4, [], []), + ]; + + public enum Size { Big, Small } + + public class Row(string name, int score, string[] tags, int[] numbers) + { + public Guid Id { get; } = Guid.NewGuid(); + public string Name { get; } = name; + public int Score { get; } = score; + public Size Size { get; } = Size.Big; + public List List { get; } = [..tags]; + public IReadOnlyList ReadOnlyList { get; } = tags; + public HashSet Set { get; } = [..tags]; + public string[] Array { get; } = tags; + public List Numbers { get; } = [..numbers]; + public List Children { get; } = tags.Select(x => new Child(x)).ToList(); + } + + public class Child(string name) + { + public string Name { get; } = name; + } +} diff --git a/QueryKit/Operators/ComparisonOperator.cs b/QueryKit/Operators/ComparisonOperator.cs index 7d06e91..02e5d2e 100644 --- a/QueryKit/Operators/ComparisonOperator.cs +++ b/QueryKit/Operators/ComparisonOperator.cs @@ -587,6 +587,20 @@ public InType(bool caseInsensitive = false, bool usesAll = false) : base("^^", 1 public override bool IsCountOperator() => false; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { + ThrowIfNotList(right); + + // On a collection, the filter matches when any element is in the list, or all elements with %. + if (left.Type.IsCollection()) + { + var element = Expression.Parameter(left.Type.GetCollectionElementType()!, "z"); + var elementInList = GetExpression(element, right, dbContextType, caseMode); + var anyOrAll = typeof(Enumerable) + .GetMethods() + .Single(m => m.Name == (UsesAll ? "All" : "Any") && m.GetParameters().Length == 2) + .MakeGenericMethod(element.Type); + return Expression.Call(anyOrAll, left, Expression.Lambda(elementInList, element)); + } + var leftType = left.Type; object? values = null; @@ -825,6 +839,14 @@ public NotInType(bool caseInsensitive = false, bool usesAll = false) : base("!^^ public override bool IsCountOperator() => false; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { + ThrowIfNotList(right); + + // On a collection, not in is the negation of in: no element is in the list. + if (left.Type.IsCollection()) + { + return Expression.Not(new InType(CaseInsensitive, UsesAll).GetExpression(left, right, dbContextType, caseMode)); + } + var leftType = left.Type; object? values = null; @@ -1004,6 +1026,15 @@ internal static List GetAliasMatches(IQueryKitConfiguratio return matches; } + // The in operators compare with a list, for example ["a", "b"]. One value is a parsing error. + private void ThrowIfNotList(Expression right) + { + if (!right.Type.IsCollection()) + { + throw new QueryKitParsingException($"The {Operator()} operator needs a list value, for example [\"a\", \"b\"]."); + } + } + // A comparison on a collection compares each element with the right side. A right side of the collection type itself (a null) compares the whole collection. private static bool ComparesElements(Expression left, Expression right) => left.Type.IsCollection() && right.Type != left.Type;