From 18bbb46a1b98037e65b56faf1eb4f995e6b3bad4 Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Sun, 9 Jun 2024 09:52:21 +0200 Subject: [PATCH] Cosmos: Translate string.Contains/StartsWith/EndsWith with OrdinalIgnoreCase Closes #25250 --- .../CosmosStringMethodTranslator.cs | 72 ++++++++++ .../NorthwindFunctionsQueryCosmosTest.cs | 123 +++++++++++++++++ .../NorthwindFunctionsQueryInMemoryTest.cs | 14 +- ...rthwindFunctionsQueryRelationalTestBase.cs | 30 ++++ .../Query/NorthwindFunctionsQueryTestBase.cs | 130 ++++++++++++++++++ .../NorthwindFunctionsQuerySqlServerTest.cs | 71 ++++++++++ 6 files changed, 439 insertions(+), 1 deletion(-) diff --git a/src/EFCore.Cosmos/Query/Internal/Translators/CosmosStringMethodTranslator.cs b/src/EFCore.Cosmos/Query/Internal/Translators/CosmosStringMethodTranslator.cs index 7242f764e96..7a15a8ace21 100644 --- a/src/EFCore.Cosmos/Query/Internal/Translators/CosmosStringMethodTranslator.cs +++ b/src/EFCore.Cosmos/Query/Internal/Translators/CosmosStringMethodTranslator.cs @@ -24,12 +24,21 @@ private static readonly MethodInfo ReplaceMethodInfo private static readonly MethodInfo ContainsMethodInfo = typeof(string).GetRuntimeMethod(nameof(string.Contains), [typeof(string)])!; + private static readonly MethodInfo ContainsWithStringComparisonMethodInfo + = typeof(string).GetRuntimeMethod(nameof(string.Contains), [typeof(string), typeof(StringComparison)])!; + private static readonly MethodInfo StartsWithMethodInfo = typeof(string).GetRuntimeMethod(nameof(string.StartsWith), [typeof(string)])!; + private static readonly MethodInfo StartsWithWithStringComparisonMethodInfo + = typeof(string).GetRuntimeMethod(nameof(string.StartsWith), [typeof(string), typeof(StringComparison)])!; + private static readonly MethodInfo EndsWithMethodInfo = typeof(string).GetRuntimeMethod(nameof(string.EndsWith), [typeof(string)])!; + private static readonly MethodInfo EndsWithWithStringComparisonMethodInfo + = typeof(string).GetRuntimeMethod(nameof(string.EndsWith), [typeof(string), typeof(StringComparison)])!; + private static readonly MethodInfo ToLowerMethodInfo = typeof(string).GetRuntimeMethod(nameof(string.ToLower), [])!; @@ -119,16 +128,79 @@ private static readonly MethodInfo StringComparisonWithComparisonTypeArgumentSta return TranslateSystemFunction("CONTAINS", typeof(bool), instance, arguments[0]); } + if (ContainsWithStringComparisonMethodInfo.Equals(method)) + { + if (arguments[1] is SqlConstantExpression { Value: StringComparison comparisonType }) + { + return comparisonType switch + { + StringComparison.Ordinal + => TranslateSystemFunction( + "CONTAINS", typeof(bool), instance, arguments[0], sqlExpressionFactory.Constant(false)), + StringComparison.OrdinalIgnoreCase + => TranslateSystemFunction( + "CONTAINS", typeof(bool), instance, arguments[0], sqlExpressionFactory.Constant(true)), + + _ => null // TODO: Explicit translation error for unsupported StringComparison argument (depends on #26410) + }; + } + + // TODO: Explicit translation error for non-constant StringComparison argument (depends on #26410) + return null; + } + if (StartsWithMethodInfo.Equals(method)) { return TranslateSystemFunction("STARTSWITH", typeof(bool), instance, arguments[0]); } + if (StartsWithWithStringComparisonMethodInfo.Equals(method)) + { + if (arguments[1] is SqlConstantExpression { Value: StringComparison comparisonType }) + { + return comparisonType switch + { + StringComparison.Ordinal + => TranslateSystemFunction( + "STARTSWITH", typeof(bool), instance, arguments[0], sqlExpressionFactory.Constant(false)), + StringComparison.OrdinalIgnoreCase + => TranslateSystemFunction( + "STARTSWITH", typeof(bool), instance, arguments[0], sqlExpressionFactory.Constant(true)), + + _ => null // TODO: Explicit translation error for unsupported StringComparison argument (depends on #26410) + }; + } + + // TODO: Explicit translation error for non-constant StringComparison argument (depends on #26410) + return null; + } + if (EndsWithMethodInfo.Equals(method)) { return TranslateSystemFunction("ENDSWITH", typeof(bool), instance, arguments[0]); } + if (EndsWithWithStringComparisonMethodInfo.Equals(method)) + { + if (arguments[1] is SqlConstantExpression { Value: StringComparison comparisonType }) + { + return comparisonType switch + { + StringComparison.Ordinal + => TranslateSystemFunction( + "ENDSWITH", typeof(bool), instance, arguments[0], sqlExpressionFactory.Constant(false)), + StringComparison.OrdinalIgnoreCase + => TranslateSystemFunction( + "ENDSWITH", typeof(bool), instance, arguments[0], sqlExpressionFactory.Constant(true)), + + _ => null // TODO: Explicit translation error for unsupported StringComparison argument (depends on #26410) + }; + } + + // TODO: Explicit translation error for non-constant StringComparison argument (depends on #26410) + return null; + } + if (ToLowerMethodInfo.Equals(method)) { return TranslateSystemFunction("LOWER", method.ReturnType, instance); diff --git a/test/EFCore.Cosmos.FunctionalTests/Query/NorthwindFunctionsQueryCosmosTest.cs b/test/EFCore.Cosmos.FunctionalTests/Query/NorthwindFunctionsQueryCosmosTest.cs index af25b0ae41e..48575871054 100644 --- a/test/EFCore.Cosmos.FunctionalTests/Query/NorthwindFunctionsQueryCosmosTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/Query/NorthwindFunctionsQueryCosmosTest.cs @@ -24,6 +24,8 @@ public NorthwindFunctionsQueryCosmosTest( public virtual void Check_all_tests_overridden() => TestHelpers.AssertAllMethodsOverridden(GetType()); + #region String.StartsWith + public override Task String_StartsWith_Literal(bool async) => Fixture.NoSyncTest( async, async a => @@ -96,6 +98,47 @@ FROM root c """); }); + public override Task String_StartsWith_with_StringComparison_Ordinal(bool async) + => Fixture.NoSyncTest( + async, async a => + { + await base.String_StartsWith_with_StringComparison_Ordinal(a); + + AssertSql( + """ +SELECT c +FROM root c +WHERE ((c["Discriminator"] = "Customer") AND STARTSWITH(c["CompanyName"], "Qu", false)) +"""); + }); + + public override Task String_StartsWith_with_StringComparison_OrdinalIgnoreCase(bool async) + => Fixture.NoSyncTest( + async, async a => + { + await base.String_StartsWith_with_StringComparison_OrdinalIgnoreCase(a); + + AssertSql( + """ +SELECT c +FROM root c +WHERE ((c["Discriminator"] = "Customer") AND STARTSWITH(c["CompanyName"], "Qu", true)) +"""); + }); + + public override async Task String_StartsWith_with_StringComparison_unsupported(bool async) + { + // Always throws for sync. + if (async) + { + await base.String_StartsWith_with_StringComparison_unsupported(async); + } + } + + #endregion String.StartsWith + + #region String.EndsWith + public override Task String_EndsWith_Literal(bool async) => Fixture.NoSyncTest( async, async a => @@ -168,6 +211,47 @@ FROM root c """); }); + public override Task String_EndsWith_with_StringComparison_Ordinal(bool async) + => Fixture.NoSyncTest( + async, async a => + { + await base.String_EndsWith_with_StringComparison_Ordinal(a); + + AssertSql( + """ +SELECT c +FROM root c +WHERE ((c["Discriminator"] = "Customer") AND ENDSWITH(c["ContactName"], "DY", false)) +"""); + }); + + public override Task String_EndsWith_with_StringComparison_OrdinalIgnoreCase(bool async) + => Fixture.NoSyncTest( + async, async a => + { + await base.String_EndsWith_with_StringComparison_OrdinalIgnoreCase(a); + + AssertSql( + """ +SELECT c +FROM root c +WHERE ((c["Discriminator"] = "Customer") AND ENDSWITH(c["ContactName"], "DY", true)) +"""); + }); + + public override async Task String_EndsWith_with_StringComparison_unsupported(bool async) + { + // Always throws for sync. + if (async) + { + await base.String_EndsWith_with_StringComparison_unsupported(async); + } + } + + #endregion String.EndsWith + + #region String.Contains + public override Task String_Contains_Literal(bool async) => Fixture.NoSyncTest( async, async a => @@ -209,6 +293,45 @@ FROM root c """); }); + public override Task String_Contains_with_StringComparison_Ordinal(bool async) + => Fixture.NoSyncTest( + async, async a => + { + await base.String_Contains_with_StringComparison_Ordinal(a); + + AssertSql( + """ +SELECT c +FROM root c +WHERE ((c["Discriminator"] = "Customer") AND CONTAINS(c["ContactName"], "M", false)) +"""); + }); + + public override Task String_Contains_with_StringComparison_OrdinalIgnoreCase(bool async) + => Fixture.NoSyncTest( + async, async a => + { + await base.String_Contains_with_StringComparison_OrdinalIgnoreCase(a); + + AssertSql( + """ +SELECT c +FROM root c +WHERE ((c["Discriminator"] = "Customer") AND CONTAINS(c["ContactName"], "M", true)) +"""); + }); + + public override async Task String_Contains_with_StringComparison_unsupported(bool async) + { + // Always throws for sync. + if (async) + { + await base.String_Contains_with_StringComparison_unsupported(async); + } + } + + #endregion String.Contains + public override Task String_FirstOrDefault_MethodCall(bool async) => Fixture.NoSyncTest( async, async a => diff --git a/test/EFCore.InMemory.FunctionalTests/Query/NorthwindFunctionsQueryInMemoryTest.cs b/test/EFCore.InMemory.FunctionalTests/Query/NorthwindFunctionsQueryInMemoryTest.cs index 0debb45b4c1..c7e07db06c3 100644 --- a/test/EFCore.InMemory.FunctionalTests/Query/NorthwindFunctionsQueryInMemoryTest.cs +++ b/test/EFCore.InMemory.FunctionalTests/Query/NorthwindFunctionsQueryInMemoryTest.cs @@ -4,4 +4,16 @@ namespace Microsoft.EntityFrameworkCore.Query; public class NorthwindFunctionsQueryInMemoryTest(NorthwindQueryInMemoryFixture fixture) - : NorthwindFunctionsQueryTestBase>(fixture); + : NorthwindFunctionsQueryTestBase>(fixture) +{ + // StringComparison.CurrentCulture{,IgnoreCase} and InvariantCulture{,IgnoreCase} are not supported in real providers, but the in-memory + // provider does support them. + public override Task String_StartsWith_with_StringComparison_unsupported(bool async) + => Task.CompletedTask; + + public override Task String_EndsWith_with_StringComparison_unsupported(bool async) + => Task.CompletedTask; + + public override Task String_Contains_with_StringComparison_unsupported(bool async) + => Task.CompletedTask; +} diff --git a/test/EFCore.Relational.Specification.Tests/Query/NorthwindFunctionsQueryRelationalTestBase.cs b/test/EFCore.Relational.Specification.Tests/Query/NorthwindFunctionsQueryRelationalTestBase.cs index 86d95c161f4..42e50e4b532 100644 --- a/test/EFCore.Relational.Specification.Tests/Query/NorthwindFunctionsQueryRelationalTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Query/NorthwindFunctionsQueryRelationalTestBase.cs @@ -15,6 +15,36 @@ protected NorthwindFunctionsQueryRelationalTestBase(TFixture fixture) { } + // StartsWith with StringComparison not supported in relational databases, where the column collation is used to control comparison + // semantics. + public override Task String_StartsWith_with_StringComparison_Ordinal(bool async) + => AssertTranslationFailed(() => base.String_StartsWith_with_StringComparison_Ordinal(async)); + + // StartsWith with StringComparison not supported in relational databases, where the column collation is used to control comparison + // semantics. + public override Task String_StartsWith_with_StringComparison_OrdinalIgnoreCase(bool async) + => AssertTranslationFailed(() => base.String_StartsWith_with_StringComparison_OrdinalIgnoreCase(async)); + + // EndsWith with StringComparison not supported in relational databases, where the column collation is used to control comparison + // semantics. + public override Task String_EndsWith_with_StringComparison_Ordinal(bool async) + => AssertTranslationFailed(() => base.String_EndsWith_with_StringComparison_Ordinal(async)); + + // EndsWith with StringComparison not supported in relational databases, where the column collation is used to control comparison + // semantics. + public override Task String_EndsWith_with_StringComparison_OrdinalIgnoreCase(bool async) + => AssertTranslationFailed(() => base.String_EndsWith_with_StringComparison_OrdinalIgnoreCase(async)); + + // Contains with StringComparison not supported in relational databases, where the column collation is used to control comparison + // semantics. + public override Task String_Contains_with_StringComparison_Ordinal(bool async) + => AssertTranslationFailed(() => base.String_Contains_with_StringComparison_Ordinal(async)); + + // Contains with StringComparison not supported in relational databases, where the column collation is used to control comparison + // semantics. + public override Task String_Contains_with_StringComparison_OrdinalIgnoreCase(bool async) + => AssertTranslationFailed(() => base.String_Contains_with_StringComparison_OrdinalIgnoreCase(async)); + protected override QueryAsserter CreateQueryAsserter(TFixture fixture) => new RelationalQueryAsserter( fixture, RewriteExpectedQueryExpression, RewriteServerQueryExpression); diff --git a/test/EFCore.Specification.Tests/Query/NorthwindFunctionsQueryTestBase.cs b/test/EFCore.Specification.Tests/Query/NorthwindFunctionsQueryTestBase.cs index 82b72842881..d4d1f8d5313 100644 --- a/test/EFCore.Specification.Tests/Query/NorthwindFunctionsQueryTestBase.cs +++ b/test/EFCore.Specification.Tests/Query/NorthwindFunctionsQueryTestBase.cs @@ -34,6 +34,8 @@ protected virtual void ClearLog() { } + #region String.StartsWith + [ConditionalTheory] [MemberData(nameof(IsAsyncData))] public virtual Task String_StartsWith_Literal(bool async) @@ -73,6 +75,49 @@ public virtual Task String_StartsWith_MethodCall(bool async) async, ss => ss.Set().Where(c => c.ContactName.StartsWith(LocalMethod1()))); + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task String_StartsWith_with_StringComparison_Ordinal(bool async) + => AssertQuery( + async, + ss => ss.Set().Where(c => c.CompanyName.StartsWith("Qu", StringComparison.Ordinal))); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task String_StartsWith_with_StringComparison_OrdinalIgnoreCase(bool async) + => AssertQuery( + async, + ss => ss.Set().Where(c => c.CompanyName.StartsWith("Qu", StringComparison.OrdinalIgnoreCase))); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task String_StartsWith_with_StringComparison_unsupported(bool async) + { + await AssertTranslationFailed(() => + AssertQuery( + async, + ss => ss.Set().Where(c => c.CompanyName.StartsWith("Qu", StringComparison.CurrentCulture)))); + + await AssertTranslationFailed(() => + AssertQuery( + async, + ss => ss.Set().Where(c => c.ContactName.Contains("M", StringComparison.CurrentCultureIgnoreCase)))); + + await AssertTranslationFailed(() => + AssertQuery( + async, + ss => ss.Set().Where(c => c.ContactName.Contains("Qu", StringComparison.InvariantCulture)))); + + await AssertTranslationFailed(() => + AssertQuery( + async, + ss => ss.Set().Where(c => c.ContactName.Contains("Qu", StringComparison.InvariantCultureIgnoreCase)))); + } + + #endregion String.StartsWith + + #region String.EndsWith + [ConditionalTheory] [MemberData(nameof(IsAsyncData))] public virtual Task String_EndsWith_Literal(bool async) @@ -112,6 +157,50 @@ public virtual Task String_EndsWith_MethodCall(bool async) async, ss => ss.Set().Where(c => c.ContactName.EndsWith(LocalMethod2()))); + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task String_EndsWith_with_StringComparison_Ordinal(bool async) + => AssertQuery( + async, + ss => ss.Set().Where(c => c.ContactName.EndsWith("DY", StringComparison.Ordinal)), + assertEmpty: true); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task String_EndsWith_with_StringComparison_OrdinalIgnoreCase(bool async) + => AssertQuery( + async, + ss => ss.Set().Where(c => c.ContactName.EndsWith("DY", StringComparison.OrdinalIgnoreCase))); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task String_EndsWith_with_StringComparison_unsupported(bool async) + { + await AssertTranslationFailed(() => + AssertQuery( + async, + ss => ss.Set().Where(c => c.ContactName.EndsWith("Qu", StringComparison.CurrentCulture)))); + + await AssertTranslationFailed(() => + AssertQuery( + async, + ss => ss.Set().Where(c => c.ContactName.Contains("M", StringComparison.CurrentCultureIgnoreCase)))); + + await AssertTranslationFailed(() => + AssertQuery( + async, + ss => ss.Set().Where(c => c.ContactName.Contains("Qu", StringComparison.InvariantCulture)))); + + await AssertTranslationFailed(() => + AssertQuery( + async, + ss => ss.Set().Where(c => c.ContactName.Contains("Qu", StringComparison.InvariantCultureIgnoreCase)))); + } + + #endregion String.EndsWith + + #region String.Contains + [ConditionalTheory] [MemberData(nameof(IsAsyncData))] public virtual Task String_Contains_Literal(bool async) @@ -156,6 +245,47 @@ public virtual Task String_Contains_negated_in_projection(bool async) ss => ss.Set().Select(c => new { Id = c.CustomerID, Value = !c.CompanyName.Contains(c.ContactName) }), elementSorter: e => e.Id); + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task String_Contains_with_StringComparison_Ordinal(bool async) + => AssertQuery( + async, + ss => ss.Set().Where(c => c.ContactName.Contains("M", StringComparison.Ordinal))); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task String_Contains_with_StringComparison_OrdinalIgnoreCase(bool async) + => AssertQuery( + async, + ss => ss.Set().Where(c => c.ContactName.Contains("M", StringComparison.OrdinalIgnoreCase))); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task String_Contains_with_StringComparison_unsupported(bool async) + { + await AssertTranslationFailed(() => + AssertQuery( + async, + ss => ss.Set().Where(c => c.ContactName.Contains("M", StringComparison.CurrentCulture)))); + + await AssertTranslationFailed(() => + AssertQuery( + async, + ss => ss.Set().Where(c => c.ContactName.Contains("M", StringComparison.CurrentCultureIgnoreCase)))); + + await AssertTranslationFailed(() => + AssertQuery( + async, + ss => ss.Set().Where(c => c.ContactName.Contains("M", StringComparison.InvariantCulture)))); + + await AssertTranslationFailed(() => + AssertQuery( + async, + ss => ss.Set().Where(c => c.ContactName.Contains("M", StringComparison.InvariantCultureIgnoreCase)))); + } + + #endregion String.Contains + [ConditionalTheory] [MemberData(nameof(IsAsyncData))] public virtual Task String_FirstOrDefault_MethodCall(bool async) diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindFunctionsQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindFunctionsQuerySqlServerTest.cs index ae82d421235..9774dd60a50 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindFunctionsQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindFunctionsQuerySqlServerTest.cs @@ -77,6 +77,8 @@ FROM [Orders] AS [o] """); } + #region String.StartsWith + public override async Task String_StartsWith_Literal(bool async) { await base.String_StartsWith_Literal(async); @@ -139,6 +141,31 @@ WHERE [c].[ContactName] LIKE N'M%' """); } + public override async Task String_StartsWith_with_StringComparison_Ordinal(bool async) + { + await base.String_StartsWith_with_StringComparison_Ordinal(async); + + AssertSql(); + } + + public override async Task String_StartsWith_with_StringComparison_OrdinalIgnoreCase(bool async) + { + await base.String_StartsWith_with_StringComparison_OrdinalIgnoreCase(async); + + AssertSql(); + } + + public override async Task String_StartsWith_with_StringComparison_unsupported(bool async) + { + await base.String_StartsWith_with_StringComparison_unsupported(async); + + AssertSql(); + } + + #endregion String.StartsWith + + #region String.EndsWith + public override async Task String_EndsWith_Literal(bool async) { await base.String_EndsWith_Literal(async); @@ -201,6 +228,29 @@ WHERE [c].[ContactName] LIKE N'%m' """); } + public override async Task String_EndsWith_with_StringComparison_Ordinal(bool async) + { + await base.String_EndsWith_with_StringComparison_Ordinal(async); + + AssertSql(); + } + + public override async Task String_EndsWith_with_StringComparison_OrdinalIgnoreCase(bool async) + { + await base.String_EndsWith_with_StringComparison_OrdinalIgnoreCase(async); + + AssertSql(); + } + + public override async Task String_EndsWith_with_StringComparison_unsupported(bool async) + { + await base.String_EndsWith_with_StringComparison_unsupported(async); + + AssertSql(); + } + + #endregion String.EndsWith + public override async Task String_Contains_Literal(bool async) { await AssertQuery( @@ -2841,6 +2891,27 @@ FROM [Customers] AS [c] """); } + public override async Task String_Contains_with_StringComparison_Ordinal(bool async) + { + await base.String_Contains_with_StringComparison_Ordinal(async); + + AssertSql(); + } + + public override async Task String_Contains_with_StringComparison_OrdinalIgnoreCase(bool async) + { + await base.String_Contains_with_StringComparison_OrdinalIgnoreCase(async); + + AssertSql(); + } + + public override async Task String_Contains_with_StringComparison_unsupported(bool async) + { + await base.String_Contains_with_StringComparison_unsupported(async); + + AssertSql(); + } + [ConditionalTheory] [MemberData(nameof(IsAsyncData))] public virtual async Task StandardDeviation(bool async)