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
4 changes: 3 additions & 1 deletion .agents/skills/verify-querykit/features/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ A developer catches `QueryKitException` to return a `400` for bad input. QueryKi
## Sub-features

- `error-parsing` throws `ParsingException` for bad syntax: an unknown operator, a missing quote, a missing parenthesis, or a missing value.
- `error-bad-value` throws `ParsingException` for a value that does not convert to the property type, in the scalar form and in the `^^ [...]` list form. The message names the value, the type, and the property name as the client wrote it.
- `error-unknown-filter-property` throws `UnknownFilterPropertyException` for an unknown filter property.
- `error-sort` throws `SortParsingException` for an unknown sort property.
- `error-depth` throws `QueryKitPropertyDepthExceededException` when a path is deeper than `MaxPropertyDepth` (see `configuration.md`).
Expand All @@ -22,11 +23,12 @@ Preconditions:
- **Bad operator.** Run `qk run error-handling-bad-operator --filter 'Title === "x"'`. Exit `2`. Both targets have `error.type` `QueryKit.Exceptions.ParsingException` and `isQueryKitException` `true`.
- **Missing quote.** Run `qk run error-handling-missing-quote --filter 'Title == "unterminated'`. Exit `2`. Both targets have `ParsingException`. The message contains `expected "`.
- **Missing parenthesis.** Run `qk run error-handling-missing-paren --filter '(Rating > 1'`. Exit `2`. Both targets have `ParsingException`. The message contains `expected )`.
- **Bad value.** Run `qk run error-handling-bad-value --filter 'Visibility ^^ [Public, Bogus]'`. Exit `2`. Both targets have `ParsingException` with the message `The value 'Bogus' is not a valid Visibility for the filter property 'Visibility'.` Also drive `Rating == "abc"` (the type is `Int32`).
- **Unknown filter property.** Run `qk run error-handling-unknown-property --filter 'Nope == 1'`. Exit `2`. Both targets have `QueryKit.Exceptions.UnknownFilterPropertyException` with the message `The filter property 'Nope' was not recognized.`
- **Unknown sort property.** Run `qk run error-handling-unknown-sort --sort 'Nope desc'`. Exit `2`. Both targets have `QueryKit.Exceptions.SortParsingException` with the message `Parsing failed during sorting. 'Nope' was not recognized.`

## Gotchas

- Some bad input escapes the `QueryKitException` hierarchy. `Rating > "abc"` throws `System.FormatException`, and `Title @= null` throws `System.ArgumentNullException` on the memory target. The driver exits `1` for these. A consumer that catches only `QueryKitException` returns a `500` for them.
- If the driver exits `1`, an exception escaped the `QueryKitException` hierarchy. A consumer that catches only `QueryKitException` returns a `500` for it. Report this as a fault.
- An unquoted string value such as `Title == salt` is not an error. It is a literal (see `filtering.md`).
- On the Postgres target, the `sql` field is absent when QueryKit throws. This is correct, because the query was never built.
4 changes: 2 additions & 2 deletions .agents/skills/verify-querykit/features/filtering.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,6 @@ Preconditions:

- Without `--sort`, both targets return rows in seed order. The Postgres order comes from the `ORDER BY r."Id"` that EF Core adds, not from QueryKit.
- An unquoted value such as `Title == salt` does not throw. QueryKit reads it as the literal `"salt"` and returns no rows.
- `Title @= null` throws `System.ArgumentNullException` on the memory target. This is not a `QueryKitException`.
- `Rating > "abc"` throws `System.FormatException`, not a `QueryKitException`. The driver exits `1`.
- `Title @= null` throws `QueryKitParsingException` on both targets. The message tells the client to use `==` or `!=` for null.
- `Rating > "abc"` throws `ParsingException` on both targets (see `error-handling.md`).
- The case-insensitive operators use `lower()` in SQL by default. The `upper` preset changes this (see `configuration.md`).
20 changes: 20 additions & 0 deletions QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -441,4 +441,24 @@ public async Task timestamp_without_time_zone_value_matches_with_unspecified_kin
// Assert
people.Select(x => x.Id).Should().Equal(fakePersonOne.Id);
}

[Theory]
[InlineData("""Age == "abc" """)]
[InlineData("""Age == abc""")]
[InlineData("""Rating > "abc" """)]
[InlineData("""Rating > abc""")]
[InlineData("""BirthMonth ^^ ["Bogus"]""")]
[InlineData("""BirthMonth ^^ [Bogus]""")]
public async Task invalid_value_throws_parsing_exception(string input)
{
// Arrange
var testingServiceScope = new TestingServiceScope();

// Act
var queryablePeople = testingServiceScope.DbContext().People;
var act = async () => await queryablePeople.ApplyQueryKitFilter(input).ToListAsync();

// Assert
await act.Should().ThrowAsync<ParsingException>();
}
}
13 changes: 11 additions & 2 deletions QueryKit.UnitTests/DotNumberCultureTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,20 @@ public void dot_number_on_an_integer_property_throws_parsing_exception(string cu
[InlineData("de-DE", "Rating ^^ [\"4.0\"]")]
[InlineData("de-DE", "Rating > @4.4")]
[InlineData("de-DE", "HaveMadeItMyself == 4.4")]
public void number_that_v1_14_2_also_converted_throws_format_exception(string cultureName, string input)
public void number_that_v1_14_2_also_converted_throws_parsing_exception(string cultureName, string input)
{
var act = () => WithCulture(cultureName, () => FilterParser.ParseFilter<Recipe>(input));

act.Should().ThrowExactly<FormatException>();
act.Should().ThrowExactly<ParsingException>().WithInnerExceptionExactly<FormatException>();
}

[Fact]
public void dot_number_on_an_integer_property_names_the_value_in_a_comma_culture()
{
var act = () => WithCulture("de-DE", () => FilterParser.ParseFilter<Recipe>("Rating > 4.4"));

act.Should().ThrowExactly<ParsingException>()
.WithMessage("The value '4.4' is not a valid Int32 for the filter property 'Rating'.");
}

[Fact]
Expand Down
2 changes: 1 addition & 1 deletion QueryKit.UnitTests/FilterParserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -832,7 +832,7 @@ public void can_throw_exception_when_invalid_enum_value()
var input = $"""BirthMonth == invalid""";
var act = () => FilterParser.ParseFilter<TestingPerson>(input);
act.Should().Throw<ParsingException>()
.WithMessage("There was a parsing failure, likely due to an invalid comparison or logical operator. You may also be missing double quotes surrounding a string or guid.*");
.WithMessage("The value 'invalid' is not a valid BirthMonthEnum for the filter property 'BirthMonth'.");
}

[Fact]
Expand Down
57 changes: 57 additions & 0 deletions QueryKit.UnitTests/FilterParsingRegressionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ namespace QueryKit.UnitTests;
using System.Globalization;
using System.Linq.Expressions;
using System.Reflection;
using Configuration;
using Exceptions;
using FluentAssertions;
using Operators;
Expand Down Expand Up @@ -405,6 +406,62 @@ public void date_time_list_value_matches_scalar_value(string input)
scalarResult.Select(x => x.Title).Should().Equal("match");
}

[Theory]
[InlineData("""Age == "abc" """)]
[InlineData("""Age == abc""")]
[InlineData("""Rating > "abc" """)]
[InlineData("""Rating > abc""")]
[InlineData("""Age == 99999999999""")]
[InlineData("""Id == "abc" """)]
[InlineData("""SpecificDateTime == "abc" """)]
[InlineData("""Favorite == "abc" """)]
[InlineData("""Age ^^ ["abc"]""")]
[InlineData("""BirthMonth == "Bogus" """)]
[InlineData("""BirthMonth ^^ ["Bogus"]""")]
[InlineData("""BirthMonth ^^ [Bogus]""")]
public void invalid_value_throws_parsing_exception(string input)
{
var act = () => FilterParser.ParseFilter<TestingPerson>(input);

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

[Theory]
[InlineData("""BirthMonth ^^ ["Bogus"]""", "Bogus", "BirthMonthEnum", "BirthMonth")]
[InlineData("""BirthMonth ^^ [Bogus]""", "Bogus", "BirthMonthEnum", "BirthMonth")]
[InlineData("""BirthMonth ^^ [January, Bogus]""", "Bogus", "BirthMonthEnum", "BirthMonth")]
[InlineData("""BirthMonth == "Bogus" """, "Bogus", "BirthMonthEnum", "BirthMonth")]
[InlineData("""BirthMonth == Bogus""", "Bogus", "BirthMonthEnum", "BirthMonth")]
[InlineData("""Age == "abc" """, "abc", "Int32", "Age")]
[InlineData("""Age == 99999999999""", "99999999999", "Int32", "Age")]
[InlineData("""Age ^^ [1, abc]""", "abc", "Int32", "Age")]
[InlineData("""Rating > abc""", "abc", "Decimal", "Rating")]
[InlineData("""Id == "abc" """, "abc", "Guid", "Id")]
[InlineData("""SpecificDateTime == "abc" """, "abc", "DateTime", "SpecificDateTime")]
[InlineData("""Favorite == "abc" """, "abc", "Boolean", "Favorite")]
public void invalid_value_message_names_the_value_the_type_and_the_property(string input, string value, string type, string property)
{
var act = () => FilterParser.ParseFilter<TestingPerson>(input);

act.Should().ThrowExactly<ParsingException>()
.WithMessage($"The value '{value}' is not a valid {type} for the filter property '{property}'.");
}

[Fact]
public void invalid_value_message_names_the_query_name()
{
var config = new QueryKitConfiguration(settings =>
{
settings.Property<TestingPerson>(x => x.BirthMonth!).HasQueryName("month");
});

var act = () => FilterParser.ParseFilter<TestingPerson>("""month ^^ ["Bogus"]""", config);

act.Should().ThrowExactly<ParsingException>()
.WithMessage("The value 'Bogus' is not a valid BirthMonthEnum for the filter property 'month'.")
.WithInnerExceptionExactly<ArgumentException>();
}

private static TResult WithCulture<TResult>(string cultureName, Func<TResult> action)
{
var originalCulture = CultureInfo.CurrentCulture;
Expand Down
4 changes: 2 additions & 2 deletions QueryKit.UnitTests/QueryNameOverUnknownTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,11 @@ public void failure_before_the_query_name_throws_parsing_exception()
}

[Fact]
public void failure_before_the_query_name_throws_its_own_format_exception()
public void failure_before_the_query_name_throws_its_own_parsing_exception()
{
var act = () => FilterParser.ParseFilter<TestingPerson>("""Age > "x" && is adult == true""", Config);

act.Should().ThrowExactly<FormatException>();
act.Should().ThrowExactly<ParsingException>().WithInnerExceptionExactly<FormatException>();
}

[Fact]
Expand Down
7 changes: 7 additions & 0 deletions QueryKit/Exceptions/ParsingException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ public ParsingException(Exception exception)
{
}

// A filter value that does not convert to the type of its property. The client sent the value and the property,
// so the message can name them.
public ParsingException(string value, string propertyName, Type targetType, Exception exception)
: base($"The value '{value}' is not a valid {targetType.Name} for the filter property '{propertyName}'.", exception)
{
}

private static string BuildMessage(Exception exception)
{
const string baseMessage = "There was a parsing failure, likely due to an invalid comparison or logical operator. You may also be missing double quotes surrounding a string or guid.";
Expand Down
Loading
Loading