From 8cf258a8b3a5610a59302dcdf2690e1be7cc486a Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Wed, 30 Sep 2026 16:24:36 +0300 Subject: [PATCH] fix(parser)!: parse numbers with the invariant culture only The number grammar also accepted the decimal separator of the current culture. Under de-DE, Rating > 4,5 parsed, and the value conversion read it with the invariant culture as 45. The API returned wrong rows with no error. The number grammar, the count operator value, and the literal checks in IsPropertyPath now use the invariant culture only. A number always uses the '.' decimal point. BREAKING CHANGE: a number with a decimal comma, for example Rating > 4,5, throws a QueryKitException in every culture. Before, a server with a comma culture read this value as 45. Send the number with a '.' decimal point, for example Rating > 4.5. --- .../FilterParsingRegressionTests.cs | 15 +++-------- QueryKit/FilterParser.cs | 25 +++---------------- 2 files changed, 7 insertions(+), 33 deletions(-) diff --git a/QueryKit.UnitTests/FilterParsingRegressionTests.cs b/QueryKit.UnitTests/FilterParsingRegressionTests.cs index 2116f59..eaacd16 100644 --- a/QueryKit.UnitTests/FilterParsingRegressionTests.cs +++ b/QueryKit.UnitTests/FilterParsingRegressionTests.cs @@ -28,21 +28,12 @@ public void decimal_value_uses_invariant_culture(string cultureName) [Theory] [InlineData("de-DE")] [InlineData("fr-FR")] - public void decimal_value_with_culture_decimal_comma_is_read_as_before(string cultureName) - { - var input = """Rating > 4,5"""; - - var filterExpression = WithCulture(cultureName, () => FilterParser.ParseFilter(input)); - - filterExpression.ToDisplayString().Should().Be("x => (x.Rating > 45)"); - } - - [Fact] - public void decimal_value_with_decimal_comma_is_not_accepted_in_a_culture_with_decimal_point() + [InlineData("en-US")] + public void decimal_value_with_decimal_comma_is_not_accepted(string cultureName) { var input = """Rating > 4,5"""; - var act = () => WithCulture("en-US", () => FilterParser.ParseFilter(input)); + var act = () => WithCulture(cultureName, () => FilterParser.ParseFilter(input)); act.Should().Throw(); } diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 37bac4d..038cf69 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -206,24 +206,8 @@ from micros in DateTimeMicrosParser from timeZone in DateTimeZoneParser select dateFormat + timeFormat + micros + timeZone; - // A number with a '.' decimal point, or with the decimal separator of the current culture. - // The longer match wins, so '4.5' parses in every culture and '4,5' still parses in a culture that uses ','. - private static readonly Parser UnsignedNumberParser = input => - { - var invariant = Parse.DecimalInvariant(input); - var culture = Parse.Decimal(input); - return culture.WasSuccessful && (!invariant.WasSuccessful || culture.Remainder.Position > invariant.Remainder.Position) - ? culture - : invariant; - }; - + // A number always uses the '.' decimal point, so a filter has the same meaning in every culture. private static readonly Parser NumberParser = - from sign in Parse.Char('-').Optional().Select(x => x.IsDefined ? "-" : "") - from number in UnsignedNumberParser - select sign + number; - - // List items are separated by ',', so a list number always uses the '.' decimal point. - private static readonly Parser ListNumberParser = from sign in Parse.Char('-').Optional().Select(x => x.IsDefined ? "-" : "") from number in Parse.DecimalInvariant select sign + number; @@ -247,7 +231,7 @@ from closingQuotes in Parse.Char('"').Repeat(count).Text() .Or(GuidFormatParser) .Or(DateTimeFormatParser) .Or(TimeFormatParser) - .Or(ListNumberParser) + .Or(NumberParser) .Or(RawStringLiteralParser.Or(DoubleQuoteParser)) .Or(Identifier) .DelimitedBy(Parse.Char(',').Token()); @@ -472,7 +456,7 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ var targetType = leftExprType; if (isEnumerable) { - if (op.IsCountOperator() && (int.TryParse(right, out var intVal) || int.TryParse(right, NumberStyles.Integer, CultureInfo.InvariantCulture, out intVal))) + if (op.IsCountOperator() && int.TryParse(right, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intVal)) { return FilterValue.Parameter(intVal, typeof(int)); } @@ -1365,8 +1349,7 @@ private static bool IsPropertyPath(string value, Type entityType) if (value == "null" || value.StartsWith("\"") || value.StartsWith("[") || - value.Contains("-") && (DateTime.TryParse(value, out _) || DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out _)) || - decimal.TryParse(value, out _) || + value.Contains("-") && DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out _) || decimal.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out _) || bool.TryParse(value, out _) || Guid.TryParse(value, out _))