Skip to content

v1.16.0

Choose a tag to compare

@pdevito3 pdevito3 released this 09 Oct 21:34
· 3 commits to main since this release
1bce657

What's Changed

v1.16.0 is a bug fix release. It has no new settings and no API removals. Some fixes change behavior that code can depend on, for example an exception type or the text of an expression. Each fix with such a change has a Behavior change line. These are not considered breaking due to the nature of them being foundational bugs vs changes in actual intended behavior.

Every example below comes from the test suite or from a run on this release.

Exceptions

All errors that come from a bad filter or sort are now a QueryKitException. An API that maps QueryKitException to 400 no longer returns 500 for these inputs.

A value that does not convert throws ParsingException (#197)

A value that does not convert to the property type threw FormatException, OverflowException, ArgumentException, or InvalidOperationException. Now it throws ParsingException, and the original exception is the inner exception. The message names the value, the target type, and the query name.

Rating == "abc"
  before: FormatException
  after:  ParsingException: The value 'abc' is not a valid Int32 for the filter property 'Rating'.

BirthMonth ^^ ["Bogus"]
  before: ArgumentException: Requested value 'Bogus' was not found.
  after:  ParsingException: The value 'Bogus' is not a valid BirthMonthEnum for the filter property 'BirthMonth'.

BirthMonth ^^ ["January"]
  before and after: filters

Behavior change: catch ParsingException or QueryKitException instead of the System exceptions.

An unknown property in arithmetic is unknown (#151)

An unknown property in arithmetic threw ArgumentException, also when AllowUnknownProperties was true. Now it gets the same result as in every other clause.

(Nope + 1) > 3
  before: ArgumentException: Property 'Nope' not found on type 'TestingPerson'
  after:  UnknownFilterPropertyException: The filter property 'Nope' was not recognized.

(Nope + 1) > 3 || Age > 100   (AllowUnknownProperties, IgnoredClauseBehavior.Remove)
  before: ArgumentException
  after:  x => (x.Age > 100)

Behavior change: catch UnknownFilterPropertyException or QueryKitException instead of ArgumentException. With AllowUnknownProperties, the clause no longer throws.

A bad sort direction throws QueryKitParsingException (#182)

sort: Age sideways
  before: ArgumentException: Invalid direction: sideways. Allowed values are 'asc' and 'desc'.
  after:  QueryKitParsingException with the same message

Behavior change: catch QueryKitParsingException or QueryKitException instead of ArgumentException.

An unknown logical operator throws QueryKitParsingException (#177)

Only code that calls LogicalOperator.GetByOperatorString directly sees this change. The filter parser never sends an unknown operator.

LogicalOperator.GetByOperatorString("xor")
  before: System.Exception: Operator xor is not supported
  after:  QueryKitParsingException: Operator xor is not supported

The has operator names itself in its error (#175)

Title ^$ "winner"   (Title is not a collection)
  before: QueryKitParsingException: DoesNotHaveType is only supported for collections
  after:  QueryKitParsingException: HasType is only supported for collections

Security: PreventFilter, PreventSort, and MaxPropertyDepth

PreventFilter and PreventSort apply to every property path (#192)

PreventFilter applied only to a property on the left side of a comparison, with its name in the exact case. Arithmetic, the right side of a comparison, a property list, derived properties, and custom operations skipped the check. A caller could learn the value of a hidden field one comparison at a time, for example with (Salary + 0) > 50000. PreventSort also needed the exact case.

Now each property reference goes through the check. A prevented clause follows IgnoredClauseBehavior, and a prevented sort is skipped.

(Age + 0) > 30 && Title == "a"   (Age has PreventFilter)
  before: ((x.Age + 0) > 30) AndAlso (x.Title == "a")
  after:  x => ((True == True) AndAlso (x.Title == "a"))

FirstName == LastName   (LastName has PreventFilter)
  before: x => (x.FirstName == x.LastName)
  after:  x => (True == True)

(lastname, FirstName) @=* "s"   (LastName has PreventFilter)
  before: LastName contains "s" OrElse FirstName contains "s"
  after:  x => ((x.FirstName != null) AndAlso x.FirstName.ToLower().Contains("s".ToLower()))

sort: age desc   (Age has HasQueryName("years").PreventSort())
  before: sorts by Age
  after:  the sort is skipped

Behavior change: a filter or sort that reaches a prevented property through one of these paths no longer filters or sorts by that property.

A fully prevented property is ignored by its query name (#194)

A filter on the query name of a property with both PreventFilter and PreventSort threw InvalidOperationException. The member name of the same property already gave an ignored clause. Now the query name gives the same result.

Title has HasQueryName("name").PreventFilter().PreventSort()

name == "x" && Age > 100
  before: InvalidOperationException: 'Title' is not allowed for filtering or sorting.
  after:  x => ((True == True) AndAlso (x.Age > 100))

name == "x" || Age > 100   (IgnoredClauseBehavior.Remove)
  before: InvalidOperationException
  after:  x => (x.Age > 100)

MaxPropertyDepth applies to property paths in arithmetic (#178)

A property path in arithmetic skipped the depth check, so a caller could reach deeper navigation properties than the limit permits.

On Ingredient, MaxPropertyDepth = 0

(Recipe.Rating + 0) > 1
  before: filters, and the query joins Recipe
  after:  QueryKitPropertyDepthExceededException: The property path 'Recipe.Rating' has a depth of 1, which exceeds the maximum allowed depth of 0.

Recipe.Rating == 1
  before and after: QueryKitPropertyDepthExceededException

HasMaxDepth applies only to its own property (#180)

HasMaxDepth on Address also applied to every property whose name starts with Address, for example AddressBackup. Such a path skipped the global MaxPropertyDepth.

MaxPropertyDepth = 0, Address has HasMaxDepth(1)

AddressBackup.State == "x"
  before: filters (uses the limit of Address)
  after:  QueryKitPropertyDepthExceededException

sort: AddressBackup.State
  before: sorts
  after:  QueryKitPropertyDepthExceededException

Address.State == "x"
  before and after: filters

Query names and property paths

Query names resolve in the grammar (#193)

QueryKit no longer replaces query names in the filter text before the parse. The grammar reads a query name as a property. As a result, a query name works in a property list and in arithmetic, and the text of a quoted value does not change. Query names match with the invariant culture.

FirstName has query name "first", Rating has query name "stars"

Title == "first == x"
  before: x => (x.Title == "FirstName == x")
  after:  x => (x.Title == "first == x")

(first, LastName) == "Paul"
  before: UnknownFilterPropertyException: first
  after:  x => ((x.FirstName == "Paul") OrElse (x.LastName == "Paul"))

(stars + 0) > 3
  before: ArgumentException: Property 'stars' not found on type 'TestingPerson'
  after:  x => ((x.Rating + Convert(0, Nullable`1)) > Convert(3, Nullable`1))

FIRST == "x"   (tr-TR culture)
  before: UnknownFilterPropertyException: The filter property 'FIRST' was not recognized.
  after:  x => (x.FirstName == "x")

Behavior change: a query name inside a quoted value or after a dot is not replaced anymore.

A property path works on the right side of a comparison (#196)

The right side of a comparison read only one name, so the README examples with a path on the right side threw. Now the right side reads a dotted path and resolves it like the left side. The path obeys MaxPropertyDepth and PreventFilter.

Title == Author.Name
  before: ParsingException
  after:  x => (x.Title == x.Author.Name)

Title == Author.Nmae
  before: ParsingException
  after:  UnknownFilterPropertyException: The filter property 'Nmae' was not recognized.

Title == "foo.bar"
  before and after: x => (x.Title == "foo.bar")

Behavior change: an unquoted dotted value that is not a property, for example Title == foo.bar, throws UnknownFilterPropertyException instead of ParsingException. Quote the text to compare with a literal value.

A child collection member resolves in any case (#179)

The first member after a collection needed the exact case, and an unknown member threw NullReferenceException.

ingredients.name == "flour"
  before: NullReferenceException
  after:  x => x.Ingredients.Select(y => y.Name).Any(z => (z == "flour"))

Ingredients.Nope == "x"
  before: NullReferenceException
  after:  UnknownFilterPropertyException: The filter property 'Nope' was not recognized.

Behavior change: an unknown member after a collection follows AllowUnknownProperties.

HasConversion is found by the property path (#198)

Every HasConversion lookup now uses the property path, so a query name no longer changes how a converted property filters. A converted number, enum, or Guid is read by its own type.

Email has HasConversion to string

Email == null
  before: compares with new EmailAddress("null"), 0 rows
  after:  compares with null, rows with a null Email

Email.Value == "a@x.com"   (Email has a query name)
  before: x.Email.Value == "a@x.com"
  after:  x.Email == new EmailAddress("a@x.com"), the same as without a query name

Number == 2   (Number is an int with HasConversion<string>() and no query name)
  before: ParsingException, inner InvalidOperationException: The binary operator Equal is not defined for the types 'System.Int32' and 'System.String'.
  after:  compares the int, 1 row

Behavior change: a null literal on a converted reference type matches null rows. A converted Guid gives a Guid constant in the expression text. The rows for a Guid do not change.

Alias replacement keeps a query name after a dot (#127)

Only code that calls QueryKitPropertyMappings.ReplaceAliasesWithPropertyPaths directly sees this change. The segment after a dot belongs to another type, so the old replacement gave a wrong path.

Title has HasQueryName("name")

ReplaceAliasesWithPropertyPaths("Author.Name == \"x\" && name == \"y\"")
  before: Author.Title == "x" && Title == "y"
  after:  Author.Name == "x" && Title == "y"

Values and literals

A comma inside a quoted list item stays in the item (#142)

The list text was split on every comma, so a filter could not match a value that holds a comma. A custom operation still gets the list text as before.

Title ^^ ["Warm, with syrup", "a\b"]
  before: 3 items: Warm | with syrup | a\b - the title "Warm, with syrup" does not match
  after:  2 items: Warm, with syrup | a\b - the title "Warm, with syrup" matches

Title ^^ ["a,b,c"]
  before: 3 values
  after:  1 value: a,b,c

Age ^^ [20, 30, 40]
  before and after: 3 values

Behavior change: a client that sends ["a,b,c"] and means three values must send ["a","b","c"].

A quoted "null" is text for a string property (#189)

Title == "null"
  before: x => (x.Title == null)
  after:  x => (x.Title == "null")

Title @= "null"
  before: x.Title.Contains(null)
  after:  x => ((x.Title != null) AndAlso x.Title.Contains("null"))

Title == null
  before and after: x => (x.Title == null)

Age == "null"
  before and after: x => (x.Age == null)

A quoted custom operation value stays a string (#118)

A quoted value for a custom operation became an int, a decimal, a bool, or null. A client could not send a code with leading zeros. A quoted date or Guid still converts, and an unquoted value converts as before.

Custom operation sku_is is x.Sku == (string)value, and Pancakes has Sku 001

sku_is == "001"   (in memory)
  before: InvalidCastException, the operation gets the int 1
  after:  Pancakes

sku_is == "001"   (Postgres)
  before: 0 rows, r."Sku" = 1::text
  after:  Pancakes, r."Sku" = '001'::text

total_stock_above == 20   (unquoted)
  before and after: Beef Stew

Behavior change: a custom operation that casts a quoted value, for example (int)value for "10", throws InvalidCastException. Send the value without quotes to get a number, a boolean, or null.

A number always uses the '.' decimal point (#181)

The number grammar also accepted the decimal separator of the current culture. Under de-DE, Rating > 4,5 parsed, and the value became 45. The API returned wrong rows with no error.

de-DE culture

Price > 4,5
  before: 0 rows, the value is 45
  after:  ParsingException: There was a parsing failure, likely due to an invalid comparison or logical operator. ... Failed at Line 1, Column 11.

Price > 4.5
  before and after: Beef Stew

Behavior change: a number with a decimal comma throws in every culture. Send the number with a '.' decimal point.

A date in a list uses the invariant culture (#190)

A list of DateOnly, TimeOnly, or DateTimeOffset values used the current culture. A single comparison already used the invariant culture.

Date ^^ ["2024-01-15"]   (ar-SA culture)
  before: FormatException
  after:  matches 2024-01-15

Date ^^ ["2024-01-15"]   (th-TH culture)
  before: 0 rows (read with the Thai Buddhist calendar)
  after:  matches 2024-01-15

Date == "2024-01-15"   (any culture)
  before and after: matches 2024-01-15

A quoted time keeps a short fraction (#183)

A quoted time dropped a fraction with fewer than 3 digits.

Time == "08:30:00.5"
  before: matches 08:30:00
  after:  matches 08:30:00.500

Time == "08:30:00.500"
  before and after: matches 08:30:00.500

Behavior change: Time == "08:30:00.5" matches 08:30:00.500, not 08:30:00. Remove the fraction to match the whole second.

The zone of a date and time comes after the fraction (#165)

The date parser accepted the zone before the fraction, which is not an ISO 8601 format.

SpecificDateTime == 2024-01-15T08:00:00Z.5
  before: parses
  after:  ParsingException

SpecificDate == 2024-01-15T10:00:00+02:00.500
  before: parses
  after:  ParsingException

SpecificDateTime == 2024-01-15T08:00:00.5Z
  before and after: parses

Behavior change: an unquoted date and time value with the zone before the fraction throws ParsingException. Write the zone after the fraction.

A DateTimeOffset value is converted to UTC (#184)

A DateTimeOffset constant kept its offset, and only a parameter went to UTC. Now both paths give the same value. The instant does not change, so the rows do not change.

SpecificDate == 2022-07-01T00:00:03+01:00
  before: new DateTimeOffset(637922304030000000, 01:00:00)
  after:  new DateTimeOffset(637922268030000000, 00:00:00)

Behavior change: the expression text and the Offset of the value change.

A TimeOnly value works on net6.0 (#176, #200)

On net6.0, every filter with a TimeOnly value threw, because the TimeOnly constructor that QueryKit used needs .NET 7. Now the value is a constant when that constructor does not exist. A new test project runs this filter on the .NET 6 runtime in CI.

net6.0

Time == "08:30:00"
  before: ArgumentNullException: Value cannot be null. (Parameter 'constructor')
  after:  x => (x.Time == 08:30)

Operators

Case-sensitive string operators check for null (#185)

The case-sensitive @=, _=, _-=, and their negations had no null check. In memory, a null property threw NullReferenceException. The case-insensitive forms already had the check.

Title values: null, "lamb", "other" (in memory)

Title _= "la"
  before: NullReferenceException
  after:  lamb

Title !_= "la"
  before: NullReferenceException
  after:  null, other

expression for Title _= "lam"
  before: x => x.Title.StartsWith("lam")
  after:  x => ((x.Title != null) AndAlso x.Title.StartsWith("lam"))

Behavior change: the expression text has a null check. On a database, the rows do not change.

String operators reject a null value (#187)

Title @= null built x.Title.Contains(null). In memory, the query threw when it ran. On Postgres, it returned no rows. Now the filter throws when it is parsed.

Directions @= null
  before: ArgumentNullException when the query runs (in memory), 0 rows (Postgres)
  after:  QueryKitParsingException: The '@=' operator does not accept a null value. Use '==' or '!=' to compare with null.

Behavior change: a null value on the right side of @=, _=, _-=, !@=, !_=, or !_-= (and their case-insensitive forms) throws QueryKitParsingException. Use == or != to compare with null.

Does-not-have excludes collections with the value (#188)

Tags !^$ "sweet" matched every collection with any other value. It returned rows that have the value and left out empty collections. On Postgres, EF could not translate it. Now it is the negation of has.

Pancakes [breakfast, sweet], Beef Stew [dinner], Salt Bread [bread, Winner], Plain Water []

Tags !^$ "sweet"   (in memory)
  before: Pancakes, Beef Stew, Salt Bread
  after:  Beef Stew, Salt Bread, Plain Water

Tags !^$ "sweet"   (Postgres)
  before: InvalidOperationException
  after:  Beef Stew, Salt Bread, Plain Water

Behavior change: !^$ returns only collections without the value, and empty collections too.

The operator factories pass usesAll (#186)

The public ComparisonOperator factories took a usesAll argument but did not use it. Filter strings do not change.

Ingredients.Name, value "waffle"

ComparisonOperator.EqualsOperator(usesAll: true)
  before: Any - a recipe with one waffle ingredient matches
  after:  All - only a recipe where every ingredient is waffle matches

Behavior change: code that passed usesAll: true and expected Any must pass usesAll: false.

Operator alias text stays inside a quoted value (#195)

Aliases: and, or, eq, gt

Title eq "salt and pepper or eq gt" and Rating gt 3
  before: x.Title == "salt && pepper || == gt" AndAlso x.Rating > 3
  after:  x => ((x.Title == "salt and pepper or eq gt") AndAlso (x.Rating > 3))

Parser

A long && or || chain cannot overflow the stack (#191)

The parser recursed once for each logical operator. A flat chain of about 212 clauses overflowed a 256 KB stack and stopped the process. Now the parser reads the chain in a loop. Precedence and the expression text do not change.

256 KB stack

Age == 1 || Age == 2 || ... (300 clauses)
  before: stack overflow, the process stops
  after:  parses

Age == 1 && Title == "a" || Age == 2
  before and after: x => (((x.Age == 1) AndAlso (x.Title == "a")) OrElse (x.Age == 2))

EF Core still reads the tree with recursion. It overflowed at about 729 clauses on a 1 MB stack. Use MaxInputLength to limit the size of a filter.

Build

  • The integration tests pull the Testcontainers images through mirror.gcr.io, because Docker Hub limited anonymous pulls in CI (#199)
  • The Unit Tests workflow also runs QueryKit.Net6Tests on the .NET 6 runtime (#200)

Full Changelog: v1.15.0...v1.16.0