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)); }