Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/verify-querykit/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ In the commands below, `qk` means `.agents/skills/verify-querykit/qk`.

## Seed data

The driver model is `Recipe`. Every recipe has one `Author` and zero or more `Ingredients`. The seed data is fixed in `harness/Driver/Model.cs`.
The driver model is `Recipe`. Every recipe has one `Author` and zero or more `Ingredients`. `Labels` is a `string[]` (a Postgres `text[]` column) with the same values as `Tags`, which is a `List<string>`. The seed data is fixed in `harness/Driver/Model.cs`.

| Title | Author.Name | Rating | Price | IsVegetarian | Visibility | CreatedAt (UTC) | DateOfOrigin | Sku | Serving | ServeTime | Directions | Tags | Ingredients (Name:Stock) |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ A developer filters on a property of a related object with a dotted path, and on
- `collection-all` matches when all children match (`Ingredients.Stock %>= 1`).
- `collection-count` compares the number of children (`Ingredients #== 0`).
- `collection-primitive-has` matches a value in a primitive list (`Tags ^$`, `Tags ^$*`, `Tags !^$`).
- `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`).

## How to get to it (user POV)

Expand All @@ -27,9 +30,13 @@ Preconditions:
- **All children.** Run `qk run collections-all --filter 'Ingredients.Stock %>= 1'`. Both targets give `Beef Stew`, `Salt Bread`, and `Plain Water`. The `expression` contains `.All(`.
- **Count.** Run `qk run collections-count --filter 'Ingredients #== 0'`. Both targets give `["Plain Water"]`.
- **Primitive list, case-insensitive.** Run `qk run collections-has --filter 'Tags ^$* "winner"'`. Both targets give `["Salt Bread"]`. The stored tag is `Winner`.
- **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"`.

## 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.
- 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.
2 changes: 2 additions & 0 deletions .agents/skills/verify-querykit/harness/Driver/Model.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public class Recipe
public string? Serving { get; set; }
public TimeOnly? ServeTime { get; set; }
public List<string> Tags { get; set; } = [];
public string[] Labels { get; set; } = [];
public Guid AuthorId { get; set; }
public Author Author { get; set; } = null!;
public List<Ingredient> Ingredients { get; set; } = [];
Expand Down Expand Up @@ -104,6 +105,7 @@ private static Recipe Recipe(int n, string title, Author author, int rating, dec
Serving = serving,
ServeTime = serveTime,
Tags = tags,
Labels = tags.ToArray(),
AuthorId = author.Id,
Author = author,
Ingredients = ingredients
Expand Down
71 changes: 71 additions & 0 deletions QueryKit.IntegrationTests/Tests/CollectionTypeFilterTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
namespace QueryKit.IntegrationTests.Tests;

using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using SharedTestingHelper.Fakes.Recipes;

// A list endpoint often projects an entity to a DTO that exposes a collection as IReadOnlyList<T>, and then applies the filter.
public class CollectionTypeFilterTests : TestBase
{
private static readonly (string Operation, string[] Expected)[] Cases =
[
("""^$ "dinner" """, ["stew"]),
("""!^$ "dinner" """, ["pancakes", "bread", "water"]),
("""== "dinner" """, ["stew"]),
("""==* "DINNER" """, ["stew"]),
("""!= "dinner" """, ["pancakes", "bread"]),
("""!=* "DINNER" """, ["pancakes", "bread"]),
("""%!= "dinner" """, ["pancakes", "bread", "water"]),
("""%== "dinner" """, ["stew", "water"]),
("""@= "ee" """, ["pancakes"]),
("""_= "br" """, ["pancakes", "bread"]),
("#== 2", ["pancakes", "bread"]),
];

public static TheoryData<string, string, string[]> Filters()
{
var data = new TheoryData<string, string, string[]>();
foreach (var property in new[] { nameof(RecipeDto.List), nameof(RecipeDto.ReadOnlyList), nameof(RecipeDto.ReadOnlyCollection) })
foreach (var (operation, expected) in Cases)
data.Add(property, operation, expected);
return data;
}

[Theory]
[MemberData(nameof(Filters))]
public async Task collection_filter_on_a_projected_dto_matches_for_every_collection_type(string property, string operation, string[] expectedTitles)
{
// Arrange
var testingServiceScope = new TestingServiceScope();
var prefix = $"collection type {Guid.NewGuid()} ";
var pancakes = new FakeRecipeBuilder().WithTitle($"{prefix}pancakes").Build().SetTags(["breakfast", "sweet"]);
var stew = new FakeRecipeBuilder().WithTitle($"{prefix}stew").Build().SetTags(["dinner"]);
var bread = new FakeRecipeBuilder().WithTitle($"{prefix}bread").Build().SetTags(["bread", "Winner"]);
var water = new FakeRecipeBuilder().WithTitle($"{prefix}water").Build().SetTags([]);
await testingServiceScope.InsertAsync(pancakes, stew, bread, water);

// Act
var recipes = await testingServiceScope.DbContext().Recipes
.Where(x => x.Title.StartsWith(prefix))
.Select(x => new RecipeDto
{
Title = x.Title,
List = x.Tags,
ReadOnlyList = x.Tags,
ReadOnlyCollection = x.Tags,
})
.ApplyQueryKitFilter($"{property} {operation}")
.ToListAsync();

// Assert
recipes.Select(x => x.Title[prefix.Length..]).Should().BeEquivalentTo(expectedTitles);
}

public class RecipeDto
{
public string Title { get; init; } = null!;
public List<string> List { get; init; } = null!;
public IReadOnlyList<string> ReadOnlyList { get; init; } = null!;
public IReadOnlyCollection<string> ReadOnlyCollection { get; init; } = null!;
}
}
2 changes: 1 addition & 1 deletion QueryKit.UnitTests/AliasCultureTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public void query_name_does_not_match_with_the_dotted_and_dotless_i_rules_of_tr_
{
var config = new QueryKitConfiguration(settings =>
{
settings.Property<Recipe>(x => x.Rating).HasQueryName(queryName);
settings.Property<Recipe>(x => x.Rating!).HasQueryName(queryName);
});

var turkish = () => WithCulture("tr-TR", () => FilterParser.ParseFilter<Recipe>(input, config));
Expand Down
191 changes: 191 additions & 0 deletions QueryKit.UnitTests/CollectionTypeFilterTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
namespace QueryKit.UnitTests;

using System.Collections.ObjectModel;
using FluentAssertions;

// A filter on a collection property gives the same rows for every type that implements IEnumerable<T>.
public class CollectionTypeFilterTests
{
public static readonly string[] CollectionProperties =
[
nameof(Tagged.List), nameof(Tagged.ReadOnlyList), nameof(Tagged.ReadOnlyCollection), nameof(Tagged.Collection),
nameof(Tagged.Set), nameof(Tagged.Array), nameof(Tagged.Enumerable), nameof(Tagged.Custom),
];

private static readonly (string Operation, string[] Expected)[] TextCases =
[
("""^$ "dinner" """, ["stew"]),
("""!^$ "dinner" """, ["pancakes", "bread", "water"]),
("""^$* "WINNER" """, ["bread"]),
("""== "dinner" """, ["stew"]),
("""==* "DINNER" """, ["stew"]),
("""!= "dinner" """, ["pancakes", "bread"]),
("""!=* "DINNER" """, ["pancakes", "bread"]),
("""%!= "dinner" """, ["pancakes", "bread", "water"]),
("""%== "dinner" """, ["stew", "water"]),
("""@= "ee" """, ["pancakes"]),
("""_= "br" """, ["pancakes", "bread"]),
("""_-= "er" """, ["stew", "bread"]),
("#== 2", ["pancakes", "bread"]),
("#> 0", ["pancakes", "stew", "bread"]),
];

public static TheoryData<string, string, string[]> TextFilters()
{
var data = new TheoryData<string, string, string[]>();
foreach (var property in CollectionProperties)
foreach (var (operation, expected) in TextCases)
data.Add(property, operation, expected);
return data;
}

[Theory]
[MemberData(nameof(TextFilters))]
public void text_collection_filter_matches_for_every_collection_type(string property, string operation, string[] expected)
{
var result = TaggedRows().AsQueryable().ApplyQueryKitFilter($"{property} {operation}").ToList();

result.Select(x => x.Name).Should().BeEquivalentTo(expected);
}

[Theory]
[InlineData(nameof(Scored.Array), "> 6", new[] { "a" })]
[InlineData(nameof(Scored.ReadOnlyList), "> 6", new[] { "a" })]
[InlineData(nameof(Scored.Array), "== 5", new[] { "b" })]
[InlineData(nameof(Scored.ReadOnlyList), "== 5", new[] { "b" })]
[InlineData(nameof(Scored.Array), "^$ 5", new[] { "b" })]
[InlineData(nameof(Scored.ReadOnlyList), "^$ 5", new[] { "b" })]
[InlineData(nameof(Scored.Array), "#== 0", new[] { "c" })]
[InlineData(nameof(Scored.ReadOnlyList), "#== 0", new[] { "c" })]
public void number_collection_filter_matches_for_every_collection_type(string property, string operation, string[] expected)
{
var rows = new[]
{
new Scored("a", [1, 9]),
new Scored("b", [5]),
new Scored("c", []),
};

var result = rows.AsQueryable().ApplyQueryKitFilter($"{property} {operation}").ToList();

result.Select(x => x.Name).Should().BeEquivalentTo(expected);
}

[Theory]
[InlineData("Tags == null", new[] { "none" })]
[InlineData("Tags != null", new[] { "empty", "one" })]
[InlineData("Labels == null", new[] { "none" })]
[InlineData("Labels != null", new[] { "empty", "one" })]
[InlineData("ReadOnlyTags == null", new[] { "none" })]
[InlineData("ReadOnlyTags != null", new[] { "empty", "one" })]
public void null_comparison_on_a_collection_checks_the_collection_for_null(string input, string[] expected)
{
var rows = new[]
{
new NullableTagged { Name = "none" },
new NullableTagged { Name = "empty", Tags = [], Labels = [], ReadOnlyTags = [] },
new NullableTagged { Name = "one", Tags = ["x"], Labels = ["x"], ReadOnlyTags = ["x"] },
};

var result = rows.AsQueryable().ApplyQueryKitFilter(input).ToList();

result.Select(x => x.Name).Should().BeEquivalentTo(expected);
}

[Theory]
[InlineData("ChildArray.Name == null", new[] { "second" })]
[InlineData("ChildReadOnlyList.Name == null", new[] { "second" })]
[InlineData("ChildReadOnlyList.Name != null", new[] { "first", "second" })]
public void null_comparison_on_a_nested_path_compares_each_element(string input, string[] expected)
{
var rows = new[]
{
new Parent("first", [new Child("a")]),
new Parent("second", [new Child("a"), new Child(null)]),
};

var result = rows.AsQueryable().ApplyQueryKitFilter(input).ToList();

result.Select(x => x.Name).Should().BeEquivalentTo(expected);
}

[Theory]
[InlineData("""ChildArray.Name == "b" """)]
[InlineData("""ChildReadOnlyList.Name == "b" """)]
[InlineData("""ChildCustom.Name == "b" """)]
public void nested_path_through_a_collection_matches_for_every_collection_type(string input)
{
var rows = new[]
{
new Parent("first", [new Child("a")]),
new Parent("second", [new Child("a"), new Child("b")]),
};

var result = rows.AsQueryable().ApplyQueryKitFilter(input).ToList();

result.Select(x => x.Name).Should().Equal("second");
}

private static Tagged[] TaggedRows() =>
[
new("pancakes", ["breakfast", "sweet"]),
new("stew", ["dinner"]),
new("bread", ["bread", "Winner"]),
new("water", []),
];

public class TagList : List<string>
{
public TagList(IEnumerable<string> tags) : base(tags)
{
}
}

public class Tagged(string name, string[] tags)
{
public string Name { get; } = name;
public List<string> List { get; } = [..tags];
public IReadOnlyList<string> ReadOnlyList { get; } = tags.ToList();
public IReadOnlyCollection<string> ReadOnlyCollection { get; } = new ReadOnlyCollection<string>(tags);
public ICollection<string> Collection { get; } = tags.ToList();
public HashSet<string> Set { get; } = [..tags];
public string[] Array { get; } = tags;
public IEnumerable<string> Enumerable { get; } = tags;
public TagList Custom { get; } = new(tags);
}

public class Scored(string name, int[] scores)
{
public string Name { get; } = name;
public int[] Array { get; } = scores;
public IReadOnlyList<int> ReadOnlyList { get; } = scores.ToList();
}

public class NullableTagged
{
public string Name { get; init; } = null!;
public List<string>? Tags { get; init; }
public string[]? Labels { get; init; }
public IReadOnlyList<string>? ReadOnlyTags { get; init; }
}

public class ChildList : List<Child>
{
public ChildList(IEnumerable<Child> children) : base(children)
{
}
}

public class Parent(string name, Child[] children)
{
public string Name { get; } = name;
public Child[] ChildArray { get; } = children;
public IReadOnlyList<Child> ChildReadOnlyList { get; } = children;
public ChildList ChildCustom { get; } = new(children);
}

public class Child(string? name)
{
public string? Name { get; } = name;
}
}
4 changes: 2 additions & 2 deletions QueryKit.UnitTests/FilterParserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -607,7 +607,7 @@ public void simple_child_collection_for_string_not_equal()
var input = """Ingredients.Name != "flour" """;
var filterExpression = FilterParser.ParseFilter<Recipe>(input);
filterExpression.ToDisplayString().Should()
.Be(""""x => x.Ingredients.Select(y => y.Name).Any(z => (z != "flour"))"""");
.Be(""""x => x.Ingredients.Select(y => y.Name).Any(z => Not((z == "flour")))"""");
}

[Fact]
Expand All @@ -616,7 +616,7 @@ public void simple_child_collection_for_string_case_insensitive_not_equal()
var input = """Ingredients.Name !=* "flour" """;
var filterExpression = FilterParser.ParseFilter<Recipe>(input);
filterExpression.ToDisplayString().Should()
.Be(""""x => x.Ingredients.Select(y => y.Name).Any(z => (z.ToLower() != "flour".ToLower()))"""");
.Be(""""x => x.Ingredients.Select(y => y.Name).Any(z => Not((z.ToLower() == "flour".ToLower())))"""");
}

[Fact]
Expand Down
2 changes: 1 addition & 1 deletion QueryKit.UnitTests/HasConversionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ public void nullable_int_with_query_name_and_has_conversion_compares_the_int()
};
var config = new QueryKitConfiguration(config =>
{
config.Property<NullableNumberRow>(x => x.Number).HasQueryName("count").HasConversion<string>();
config.Property<NullableNumberRow>(x => x.Number!).HasQueryName("count").HasConversion<string>();
});

// Act
Expand Down
27 changes: 26 additions & 1 deletion QueryKit/Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,29 @@ public static bool IsNullable(this Type type)
{
return type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>);
}
}

// A collection is a type that implements IEnumerable<T>, for example List<T>, IReadOnlyList<T>, HashSet<T>, or T[].
// A string is not a collection.
public static bool IsCollection(this Type type)
{
return type.GetCollectionElementType() != null;
}

// The T of IEnumerable<T>. A type such as Dictionary<TKey, TValue> or a class that inherits from List<T> has other generic arguments, so read the interface.
public static Type? GetCollectionElementType(this Type type)
{
if (type == typeof(string))
{
return null;
}

if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>))
{
return type.GetGenericArguments()[0];
}

return type.GetInterfaces()
.FirstOrDefault(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IEnumerable<>))
?.GetGenericArguments()[0];
}
}
Loading
Loading