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
3 changes: 3 additions & 0 deletions .agents/skills/verify-querykit/features/sorting.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ A developer passes a comma-separated sort string to `ApplyQueryKitSort`, or pass
- `sort-sieve-prefix` sorts descending with a `-` prefix (`-Rating`).
- `sort-multi` sorts on more than one key, including a nested property (`Author.Name`).
- `sort-aggregate` applies a filter, a sort, and a configuration in one `ApplyQueryKit(QueryKitData)` call.
- `sort-collection-rejected` rejects a sort on a collection property (`Tags`, `Labels`, `Ingredients`) with `SortParsingException`.

## How to get to it (user POV)

Expand All @@ -26,9 +27,11 @@ Preconditions:
- **Nested key and Sieve prefix.** Run `qk run sorting-multi --sort 'Author.Name, -Rating'`. Both targets give `["Plain Water", "Beef Stew", "Pancakes", "Salt Bread"]`.
- **Filter then sort.** Run `qk run sorting-filter-then-sort --filter 'Author.Name == "Julia Child"' --sort 'Title desc'`. Both targets give `["Salt Bread", "Pancakes"]`.
- **Aggregate with a configuration.** Run `qk run sorting-aggregate --aggregate --config aliases --filter 'chef == "Julia Child"' --sort 'name desc'`. The `input.api` is `ApplyQueryKit(QueryKitData)`. Both targets give `["Salt Bread", "Pancakes"]`.
- **Collection rejected.** Run `qk run sorting-collection --sort 'Title, Tags'`. Both targets give `error.type` `QueryKit.Exceptions.SortParsingException` with `isQueryKitException` true. The message says that `Tags` is a collection. Run it again with `Labels` (a `string[]`) and with `Ingredients` (a navigation collection). Each gives the same error.

## Gotchas

- EF Core adds `r."Id"` and `a."Id"` after the QueryKit sort keys, because the driver uses split-query mode. Only the keys before them come from QueryKit.
- A sort on a property with `PreventSort()` is ignored without an error. The rows come back in source order (see `configuration.md`).
- An unknown sort property throws `SortParsingException` (see `error-handling.md`).
- Postgres can order by an array column, but QueryKit rejects the sort before the query runs. The same sort fails in memory, so QueryKit rejects it on every target.
18 changes: 18 additions & 0 deletions QueryKit.IntegrationTests/Tests/DatabaseSortingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -462,4 +462,22 @@ public async Task can_sort_descending_by_nullable_navigation_property()
recipes[1].Title.Should().Be($"RecipeZ_{uniqueId}"); // Zoe
recipes[2].Title.Should().Be($"RecipeA_{uniqueId}"); // Alice
}

[Theory]
[InlineData("Tags")]
[InlineData("Tags desc")]
[InlineData("Ingredients")]
public async Task sort_on_a_collection_property_throws_sort_parsing_exception(string input)
{
// Arrange
var testingServiceScope = new TestingServiceScope();
var queryableRecipes = testingServiceScope.DbContext().Recipes;

// Act
var act = () => queryableRecipes.ApplyQueryKitSort(input).ToListAsync();

// Assert
await act.Should().ThrowAsync<SortParsingException>()
.WithMessage("*is a collection, and a collection has no single sort order.");
}
}
112 changes: 112 additions & 0 deletions QueryKit.UnitTests/CollectionSortTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
namespace QueryKit.UnitTests;

using Configuration;
using Exceptions;
using FluentAssertions;

// A collection has no single sort order, so a sort on a collection property is a parsing error.
public class CollectionSortTests
{
[Theory]
[InlineData(nameof(Row.List))]
[InlineData(nameof(Row.ReadOnlyList))]
[InlineData(nameof(Row.Set))]
[InlineData(nameof(Row.Array))]
[InlineData(nameof(Row.Numbers))]
[InlineData(nameof(Row.Children))]
[InlineData("Parent.Tags")]
public void sort_on_a_collection_property_throws_sort_parsing_exception(string property)
{
var act = () => Rows().AsQueryable().ApplyQueryKitSort(property).ToList();

act.Should().Throw<SortParsingException>()
.WithMessage($"Parsing failed during sorting. '{property}' is a collection, and a collection has no single sort order.");
}

[Theory]
[InlineData("List desc")]
[InlineData("-List")]
[InlineData("Name, List")]
public void sort_on_a_collection_property_throws_for_every_sort_form(string input)
{
var act = () => Rows().ApplyQueryKitSort(input).ToList();

act.Should().Throw<SortParsingException>();
}

[Fact]
public void sort_on_a_collection_property_throws_through_apply_querykit()
{
var act = () => Rows().AsQueryable().ApplyQueryKit(new QueryKitData { SortOrder = "List" }).ToList();

act.Should().Throw<SortParsingException>();
}

[Fact]
public void sort_on_a_collection_query_name_names_the_query_name()
{
var config = new QueryKitConfiguration(settings =>
{
settings.Property<Row>(x => x.List).HasQueryName("tags");
});

var act = () => Rows().AsQueryable().ApplyQueryKitSort("tags", config).ToList();

act.Should().Throw<SortParsingException>()
.WithMessage("Parsing failed during sorting. 'tags' is a collection, and a collection has no single sort order.");
}

[Fact]
public void prevented_sort_on_a_collection_property_is_ignored()
{
var config = new QueryKitConfiguration(settings =>
{
settings.Property<Row>(x => x.List).PreventSort();
});

var result = Rows().AsQueryable().ApplyQueryKitSort("List, Name desc", config).ToList();

result.Select(x => x.Name).Should().Equal("b", "a");
}

[Fact]
public void sort_on_a_string_still_sorts()
{
var result = Rows().ApplyQueryKitSort("Name desc").ToList();

result.Select(x => x.Name).Should().Equal("b", "a");
}

// A database sorts a byte array as one binary value, for example a SQL Server rowversion. In memory, byte[] is not IComparable.
[Fact]
public void sort_on_a_byte_array_parses()
{
var sorts = SortParser.ParseSort<Row>("Version desc");

sorts.Should().ContainSingle().Which.Expression!.Body.ToString().Should().Be("Convert(x.Version, Object)");
}

private static Row[] Rows() =>
[
new("a", ["x"], [1], [1]),
new("b", ["y"], [2], [2]),
];

public class Row(string name, string[] tags, int[] numbers, byte[] version)
{
public string Name { get; } = name;
public List<string> List { get; } = [..tags];
public IReadOnlyList<string> ReadOnlyList { get; } = tags;
public HashSet<string> Set { get; } = [..tags];
public string[] Array { get; } = tags;
public List<int> Numbers { get; } = [..numbers];
public List<Row> Children { get; } = [];
public Parent Parent { get; } = new(tags);
public byte[] Version { get; } = version;
}

public class Parent(string[] tags)
{
public List<string> Tags { get; } = [..tags];
}
}
8 changes: 8 additions & 0 deletions QueryKit/Exceptions/SortParsingException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,12 @@ public SortParsingException(string propertyName)
: base($"Parsing failed during sorting. '{propertyName}' was not recognized.")
{
}

private SortParsingException(string message, bool isMessage)
: base(message)
{
}

internal static SortParsingException ForCollection(string propertyName)
=> new($"Parsing failed during sorting. '{propertyName}' is a collection, and a collection has no single sort order.", isMessage: true);
}
6 changes: 6 additions & 0 deletions QueryKit/SortParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ private static SortExpressionInfo<T> CreateSortExpression<T>(string sortClause,
IsAscending = true
};
}

// A collection has no single sort order. A byte[] is one binary value, for example a SQL Server rowversion, so a database can sort it.
if (sortExpressionBody.Type.IsCollection() && sortExpressionBody.Type != typeof(byte[]))
{
throw SortParsingException.ForCollection(propertyName);
}

var isAscending = direction == Ascending;
return new SortExpressionInfo<T>
Expand Down
Loading