From 087f6b6ea646f4ed463c93311a20d94d34d9f278 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Thu, 1 Oct 2026 20:20:23 +0300 Subject: [PATCH] fix(config): match aliases with the case rules of the current culture again The alias regex cache kept one Regex for each pattern. RegexOptions.IgnoreCase uses the culture that is current when the Regex is built, so the first parse fixed the case rules for all later cultures. A query name like TIP did not match in en-US after a tr-TR parse, and an operator alias matched in tr-TR after an en-US parse. The cache key now includes CultureInfo.CurrentCulture.Name. Each culture gets the same result as v1.14.2, which built a new Regex for each parse. --- .../Tests/AliasCultureTests.cs | 56 ++++++++++++++ QueryKit.UnitTests/AliasCultureTests.cs | 75 +++++++++++++++++++ QueryKit/AliasRegexCache.cs | 9 ++- 3 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 QueryKit.IntegrationTests/Tests/AliasCultureTests.cs create mode 100644 QueryKit.UnitTests/AliasCultureTests.cs diff --git a/QueryKit.IntegrationTests/Tests/AliasCultureTests.cs b/QueryKit.IntegrationTests/Tests/AliasCultureTests.cs new file mode 100644 index 0000000..f5048c4 --- /dev/null +++ b/QueryKit.IntegrationTests/Tests/AliasCultureTests.cs @@ -0,0 +1,56 @@ +namespace QueryKit.IntegrationTests.Tests; + +using System.Globalization; +using Configuration; +using Exceptions; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SharedTestingHelper.Fakes; +using WebApiTestProject.Entities; + +// Like v1.14.2, a query name matches with the case rules of the culture of each parse, +// also when a parse in another culture used the same query name before. +public class AliasCultureTests : TestBase +{ + [Fact] + public async Task query_name_matches_in_en_us_after_a_tr_tr_parse() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var title = $"culture {Guid.NewGuid()}"; + var fakePersonOne = new FakeTestingPersonBuilder() + .WithTitle(title) + .Build(); + var fakePersonTwo = new FakeTestingPersonBuilder() + .Build(); + await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo); + + var input = $"""ISIMDELTA == "{title}" """; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Title).HasQueryName("isimdelta"); + }); + + // Act + var originalCulture = CultureInfo.CurrentCulture; + IQueryable appliedQueryable; + try + { + CultureInfo.CurrentCulture = new CultureInfo("tr-TR"); + var turkish = () => testingServiceScope.DbContext().People.ApplyQueryKitFilter(input, config); + turkish.Should().Throw(); + + CultureInfo.CurrentCulture = new CultureInfo("en-US"); + appliedQueryable = testingServiceScope.DbContext().People.ApplyQueryKitFilter(input, config); + } + finally + { + CultureInfo.CurrentCulture = originalCulture; + } + var people = await appliedQueryable.ToListAsync(); + + // Assert + people.Count.Should().Be(1); + people[0].Id.Should().Be(fakePersonOne.Id); + } +} diff --git a/QueryKit.UnitTests/AliasCultureTests.cs b/QueryKit.UnitTests/AliasCultureTests.cs new file mode 100644 index 0000000..ab3cadc --- /dev/null +++ b/QueryKit.UnitTests/AliasCultureTests.cs @@ -0,0 +1,75 @@ +namespace QueryKit.UnitTests; + +using System.Globalization; +using Configuration; +using Exceptions; +using FluentAssertions; +using WebApiTestProject.Entities.Recipes; + +// Like v1.14.2, an alias matches with the case rules of the culture of each parse, +// also when a parse in another culture used the same alias before. +// Each test uses its own alias, so the result does not depend on the order of the tests. +public class AliasCultureTests +{ + [Fact] + public void query_name_matches_in_en_us_after_a_tr_tr_parse() + { + var config = new QueryKitConfiguration(settings => + { + settings.Property(x => x.Rating).HasQueryName("tipalpha"); + }); + var input = "TIPALPHA > 3"; + + var turkish = () => WithCulture("tr-TR", () => FilterParser.ParseFilter(input, config)); + turkish.Should().Throw(); + + var filterExpression = WithCulture("en-US", () => FilterParser.ParseFilter(input, config)); + filterExpression.ToString().Should().Be("x => (x.Rating > 3)"); + } + + [Fact] + public void query_name_does_not_match_in_tr_tr_after_an_en_us_parse() + { + var config = new QueryKitConfiguration(settings => + { + settings.Property(x => x.Rating).HasQueryName("tipbeta"); + }); + var input = "TIPBETA > 3"; + + var filterExpression = WithCulture("en-US", () => FilterParser.ParseFilter(input, config)); + filterExpression.ToString().Should().Be("x => (x.Rating > 3)"); + + var turkish = () => WithCulture("tr-TR", () => FilterParser.ParseFilter(input, config)); + turkish.Should().Throw(); + } + + [Fact] + public void operator_alias_does_not_match_in_tr_tr_after_an_en_us_parse() + { + var config = new QueryKitConfiguration(settings => + { + settings.EqualsOperator = "eşitgamma"; + }); + var input = """Title EŞITGAMMA "Pancakes" """; + + var filterExpression = WithCulture("en-US", () => FilterParser.ParseFilter(input, config)); + filterExpression.ToString().Should().Be("""x => (x.Title == "Pancakes")"""); + + var turkish = () => WithCulture("tr-TR", () => FilterParser.ParseFilter(input, config)); + turkish.Should().Throw(); + } + + private static TResult WithCulture(string cultureName, Func action) + { + var originalCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo(cultureName); + return action(); + } + finally + { + CultureInfo.CurrentCulture = originalCulture; + } + } +} diff --git a/QueryKit/AliasRegexCache.cs b/QueryKit/AliasRegexCache.cs index c3041be..0407457 100644 --- a/QueryKit/AliasRegexCache.cs +++ b/QueryKit/AliasRegexCache.cs @@ -1,15 +1,18 @@ namespace QueryKit; using System.Collections.Concurrent; +using System.Globalization; using System.Text.RegularExpressions; -// Keeps one Regex for each alias pattern, so a parse does not build a new Regex for each alias. +// Keeps one Regex for each alias pattern and culture, so a parse does not build a new Regex for each alias. // The patterns come only from the configuration (query names and operator aliases), never from // the filter text, so the number of entries stays small. +// RegexOptions.IgnoreCase uses the current culture when the Regex is built, so the key includes the culture. +// Like v1.14.2, which built a new Regex for each parse, each culture keeps its own case rules. internal static class AliasRegexCache { - private static readonly ConcurrentDictionary Cache = new(); + private static readonly ConcurrentDictionary<(string Pattern, string Culture), Regex> Cache = new(); public static Regex Get(string pattern) - => Cache.GetOrAdd(pattern, p => new Regex(p, RegexOptions.IgnoreCase)); + => Cache.GetOrAdd((pattern, CultureInfo.CurrentCulture.Name), key => new Regex(key.Pattern, RegexOptions.IgnoreCase)); }