diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerSqlTranslatingExpressionVisitor.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerSqlTranslatingExpressionVisitor.cs index b62e3ba5c11..57f5d2ad8a4 100644 --- a/src/EFCore.SqlServer/Query/Internal/SqlServerSqlTranslatingExpressionVisitor.cs +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerSqlTranslatingExpressionVisitor.cs @@ -535,8 +535,8 @@ Expression TranslateStartsEndsWithContains(Expression instance, Expression patte _sqlExpressionFactory.Constant( methodType switch { - StartsEndsWithContains.StartsWith => s + '%', - StartsEndsWithContains.EndsWith => '%' + s, + StartsEndsWithContains.StartsWith => $"{s}%", + StartsEndsWithContains.EndsWith => $"%{s}", StartsEndsWithContains.Contains => $"%{s}%", _ => throw new ArgumentOutOfRangeException(nameof(methodType), methodType, null) @@ -552,8 +552,8 @@ string when _sqlServerSingletonOptions.EngineType is SqlServerEngineType.AzureSy _sqlExpressionFactory.Constant( methodType switch { - StartsEndsWithContains.StartsWith => EscapeLikePattern(s) + '%', - StartsEndsWithContains.EndsWith => '%' + EscapeLikePattern(s), + StartsEndsWithContains.StartsWith => $"{EscapeLikePattern(s)}%", + StartsEndsWithContains.EndsWith => $"%{EscapeLikePattern(s)}", StartsEndsWithContains.Contains => $"%{EscapeLikePattern(s)}%", _ => throw new ArgumentOutOfRangeException(nameof(methodType), methodType, null) @@ -566,20 +566,27 @@ string when _sqlServerSingletonOptions.EngineType is SqlServerEngineType.AzureSy _sqlExpressionFactory.Constant( methodType switch { - StartsEndsWithContains.StartsWith => s + "%", - StartsEndsWithContains.EndsWith => "%" + s, + StartsEndsWithContains.StartsWith => $"{s}%", + StartsEndsWithContains.EndsWith => $"%{s}", StartsEndsWithContains.Contains => $"%{s}%", _ => throw new ArgumentOutOfRangeException(nameof(methodType), methodType, null) })), + // Azure Synapse does not support ESCAPE clause in LIKE + // fallback to translation like with column/expression + char s when _sqlServerSingletonOptions.EngineType is SqlServerEngineType.AzureSynapse + => TranslateWithoutLike( + patternIsNonEmptyConstantString: true, + pattern: _sqlExpressionFactory.Constant(s.ToString(), stringTypeMapping)), + char s => _sqlExpressionFactory.Like( translatedInstance, _sqlExpressionFactory.Constant( methodType switch { - StartsEndsWithContains.StartsWith => LikeEscapeChar + s + "%", - StartsEndsWithContains.EndsWith => "%" + LikeEscapeChar + s, + StartsEndsWithContains.StartsWith => $"{LikeEscapeChar}{s}%", + StartsEndsWithContains.EndsWith => $"%{LikeEscapeChar}{s}", StartsEndsWithContains.Contains => $"%{LikeEscapeChar}{s}%", _ => throw new ArgumentOutOfRangeException(nameof(methodType), methodType, null) @@ -621,8 +628,10 @@ string when _sqlServerSingletonOptions.EngineType is SqlServerEngineType.AzureSy return TranslateWithoutLike(); } - SqlExpression TranslateWithoutLike(bool patternIsNonEmptyConstantString = false) + SqlExpression TranslateWithoutLike(bool patternIsNonEmptyConstantString = false, SqlExpression? pattern = null) { + pattern ??= translatedPattern; + return methodType switch { // For StartsWith/EndsWith, use LEFT or RIGHT instead to extract substring and compare: @@ -634,7 +643,7 @@ StartsEndsWithContains.StartsWith or StartsEndsWithContains.EndsWith => _sqlExpressionFactory.AndAlso( _sqlExpressionFactory.IsNotNull(translatedInstance), _sqlExpressionFactory.AndAlso( - _sqlExpressionFactory.IsNotNull(translatedPattern), + _sqlExpressionFactory.IsNotNull(pattern), _sqlExpressionFactory.Equal( _sqlExpressionFactory.Function( methodType is StartsEndsWithContains.StartsWith ? "LEFT" : "RIGHT", @@ -642,7 +651,7 @@ StartsEndsWithContains.StartsWith or StartsEndsWithContains.EndsWith translatedInstance, _sqlExpressionFactory.Function( "LEN", - [translatedPattern], + [pattern], nullable: true, argumentsPropagateNullability: Statics.TrueArrays[1], typeof(int)) @@ -651,7 +660,7 @@ StartsEndsWithContains.StartsWith or StartsEndsWithContains.EndsWith argumentsPropagateNullability: Statics.TrueArrays[2], typeof(string), stringTypeMapping), - translatedPattern))), + pattern))), // For Contains, just use CHARINDEX and check if the result is greater than 0. StartsEndsWithContains.Contains when patternIsNonEmptyConstantString @@ -665,11 +674,11 @@ StartsEndsWithContains.Contains when patternIsNonEmptyConstantString => _sqlExpressionFactory.AndAlso( _sqlExpressionFactory.IsNotNull(translatedInstance), _sqlExpressionFactory.AndAlso( - _sqlExpressionFactory.IsNotNull(translatedPattern), + _sqlExpressionFactory.IsNotNull(pattern), _sqlExpressionFactory.OrElse( CharIndexGreaterThanZero(), _sqlExpressionFactory.Like( - translatedPattern, + pattern, _sqlExpressionFactory.Constant(string.Empty, stringTypeMapping))))), _ => throw new UnreachableException() @@ -679,7 +688,7 @@ SqlExpression CharIndexGreaterThanZero() => _sqlExpressionFactory.GreaterThan( _sqlExpressionFactory.Function( "CHARINDEX", - [translatedPattern, translatedInstance], + [pattern, translatedInstance], nullable: true, argumentsPropagateNullability: Statics.TrueArrays[2], typeof(int)), @@ -746,24 +755,24 @@ SqlParameterExpression CreateRuntimeParameter( string s => methodType switch { - StartsEndsWithContains.StartsWith => EscapeLikePattern(s) + '%', - StartsEndsWithContains.EndsWith => '%' + EscapeLikePattern(s), + StartsEndsWithContains.StartsWith => $"{EscapeLikePattern(s)}%", + StartsEndsWithContains.EndsWith => $"%{EscapeLikePattern(s)}", StartsEndsWithContains.Contains => $"%{EscapeLikePattern(s)}%", _ => throw new ArgumentOutOfRangeException(nameof(methodType), methodType, null) }, char s when !IsLikeWildChar(s) => methodType switch { - StartsEndsWithContains.StartsWith => s + "%", - StartsEndsWithContains.EndsWith => "%" + s, + StartsEndsWithContains.StartsWith => $"{s}%", + StartsEndsWithContains.EndsWith => $"%{s}", StartsEndsWithContains.Contains => $"%{s}%", _ => throw new ArgumentOutOfRangeException(nameof(methodType), methodType, null) }, char s => methodType switch { - StartsEndsWithContains.StartsWith => LikeEscapeChar + s + "%", - StartsEndsWithContains.EndsWith => "%" + LikeEscapeChar + s, + StartsEndsWithContains.StartsWith => $"{LikeEscapeChar}{s}%", + StartsEndsWithContains.EndsWith => $"%{LikeEscapeChar}{s}", StartsEndsWithContains.Contains => $"%{LikeEscapeChar}{s}%", _ => throw new ArgumentOutOfRangeException(nameof(methodType), methodType, null) }, diff --git a/src/EFCore.Sqlite.Core/Query/Internal/SqliteSqlTranslatingExpressionVisitor.cs b/src/EFCore.Sqlite.Core/Query/Internal/SqliteSqlTranslatingExpressionVisitor.cs index b595ff3eb43..62fa285c7f7 100644 --- a/src/EFCore.Sqlite.Core/Query/Internal/SqliteSqlTranslatingExpressionVisitor.cs +++ b/src/EFCore.Sqlite.Core/Query/Internal/SqliteSqlTranslatingExpressionVisitor.cs @@ -305,20 +305,20 @@ Expression TranslateStartsEndsWith(Expression instance, Expression pattern, bool string s => s.Any(IsLikeWildChar) ? _sqlExpressionFactory.Like( translatedInstance, - _sqlExpressionFactory.Constant(startsWith ? EscapeLikePattern(s) + '%' : '%' + EscapeLikePattern(s)), + _sqlExpressionFactory.Constant(startsWith ? $"{EscapeLikePattern(s)}%" : $"%{EscapeLikePattern(s)}"), _sqlExpressionFactory.Constant(LikeEscapeString)) : _sqlExpressionFactory.Like( translatedInstance, - _sqlExpressionFactory.Constant(startsWith ? s + '%' : '%' + s)), + _sqlExpressionFactory.Constant(startsWith ? $"{s}%" : $"%{s}")), char s => IsLikeWildChar(s) ? _sqlExpressionFactory.Like( translatedInstance, - _sqlExpressionFactory.Constant(startsWith ? LikeEscapeString + s + "%" : '%' + LikeEscapeString + s), + _sqlExpressionFactory.Constant(startsWith ? $"{LikeEscapeChar}{s}%" : $"%{LikeEscapeChar}{s}"), _sqlExpressionFactory.Constant(LikeEscapeString)) : _sqlExpressionFactory.Like( translatedInstance, - _sqlExpressionFactory.Constant(startsWith ? s + "%" : "%" + s)), + _sqlExpressionFactory.Constant(startsWith ? $"{s}%" : $"%{s}")), _ => throw new UnreachableException() }; @@ -436,11 +436,11 @@ Expression TranslateStartsEndsWith(Expression instance, Expression pattern, bool // Return % which always matches instead. "" => "%", - string s => startsWith ? EscapeLikePattern(s) + '%' : '%' + EscapeLikePattern(s), + string s => startsWith ? $"{EscapeLikePattern(s)}%" : $"%{EscapeLikePattern(s)}", - char s when IsLikeWildChar(s) => startsWith ? LikeEscapeString + s + '%' : '%' + LikeEscapeString + s, + char s when IsLikeWildChar(s) => startsWith ? $"{LikeEscapeChar}{s}%" : $"%{LikeEscapeChar}{s}", - char s => startsWith ? s + "%" : "%" + s, + char s => startsWith ? $"{s}%" : $"%{s}", _ => throw new UnreachableException() }; diff --git a/test/EFCore.Specification.Tests/Query/FunkyDataQueryTestBase.cs b/test/EFCore.Specification.Tests/Query/FunkyDataQueryTestBase.cs index 57a78779076..11817519f8e 100644 --- a/test/EFCore.Specification.Tests/Query/FunkyDataQueryTestBase.cs +++ b/test/EFCore.Specification.Tests/Query/FunkyDataQueryTestBase.cs @@ -242,6 +242,69 @@ await AssertQuery( ss => ss.Set().Where(c => true).Select(c => c.FirstName)); } + [Theory, MemberData(nameof(IsAsyncData))] + public virtual async Task String_starts_with_on_argument_with_wildcard_char_constant(bool async) + { + await AssertQuery( + async, + ss => ss.Set().Where(c => c.FirstName!.StartsWith('%')).Select(c => c.FirstName), + ss => ss.Set().Where(c => c.FirstName!.MaybeScalar(x => x.StartsWith('%')) == true).Select(c => c.FirstName)); + + await AssertQuery( + async, + ss => ss.Set().Where(c => c.FirstName!.StartsWith('_')).Select(c => c.FirstName), + ss => ss.Set().Where(c => c.FirstName!.MaybeScalar(x => x.StartsWith('_')) == true).Select(c => c.FirstName)); + + await AssertQuery( + async, + ss => ss.Set().Where(c => c.FirstName!.StartsWith('[')).Select(c => c.FirstName), + ss => ss.Set().Where(c => c.FirstName!.MaybeScalar(x => x.StartsWith('[')) == true).Select(c => c.FirstName)); + + await AssertQuery( + async, + ss => ss.Set().Where(c => c.FirstName!.StartsWith('B')).Select(c => c.FirstName), + ss => ss.Set().Where(c => c.FirstName!.MaybeScalar(x => x.StartsWith('B')) == true).Select(c => c.FirstName)); + + await AssertQuery( + async, + ss => ss.Set().Where(c => !c.FirstName!.StartsWith('_')).Select(c => c.FirstName), + ss => ss.Set().Where(c => c.FirstName!.MaybeScalar(x => x.StartsWith('_')) != true).Select(c => c.FirstName)); + } + + [Theory, MemberData(nameof(IsAsyncData))] + public virtual async Task String_starts_with_on_argument_with_wildcard_char_parameter(bool async) + { + var prm1 = '%'; + await AssertQuery( + async, + ss => ss.Set().Where(c => c.FirstName!.StartsWith(prm1)).Select(c => c.FirstName), + ss => ss.Set().Where(c => c.FirstName!.MaybeScalar(x => x.StartsWith(prm1)) == true).Select(c => c.FirstName)); + + var prm2 = '_'; + await AssertQuery( + async, + ss => ss.Set().Where(c => c.FirstName!.StartsWith(prm2)).Select(c => c.FirstName), + ss => ss.Set().Where(c => c.FirstName!.MaybeScalar(x => x.StartsWith(prm2)) == true).Select(c => c.FirstName)); + + var prm3 = '['; + await AssertQuery( + async, + ss => ss.Set().Where(c => c.FirstName!.StartsWith(prm3)).Select(c => c.FirstName), + ss => ss.Set().Where(c => c.FirstName!.MaybeScalar(x => x.StartsWith(prm3)) == true).Select(c => c.FirstName)); + + var prm4 = 'B'; + await AssertQuery( + async, + ss => ss.Set().Where(c => c.FirstName!.StartsWith(prm4)).Select(c => c.FirstName), + ss => ss.Set().Where(c => c.FirstName!.MaybeScalar(x => x.StartsWith(prm4)) == true).Select(c => c.FirstName)); + + var prm5 = '_'; + await AssertQuery( + async, + ss => ss.Set().Where(c => !c.FirstName!.StartsWith(prm5)).Select(c => c.FirstName), + ss => ss.Set().Where(c => c.FirstName!.MaybeScalar(x => x.StartsWith(prm5)) != true).Select(c => c.FirstName)); + } + [Theory, MemberData(nameof(IsAsyncData))] public virtual async Task String_starts_with_on_argument_with_bracket(bool async) { diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/FunkyDataQueryAzureSynapseTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/FunkyDataQueryAzureSynapseTest.cs index 77ad6088443..a9bf35f73e7 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/FunkyDataQueryAzureSynapseTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/FunkyDataQueryAzureSynapseTest.cs @@ -273,6 +273,88 @@ FROM [FunkyCustomers] AS [f] """); } + public override async Task String_starts_with_on_argument_with_wildcard_char_constant(bool async) + { + await base.String_starts_with_on_argument_with_wildcard_char_constant(async); + + AssertSql( + """ +SELECT [f].[FirstName] +FROM [FunkyCustomers] AS [f] +WHERE [f].[FirstName] IS NOT NULL AND LEFT([f].[FirstName], LEN(N'%')) = N'%' +""", + // + """ +SELECT [f].[FirstName] +FROM [FunkyCustomers] AS [f] +WHERE [f].[FirstName] IS NOT NULL AND LEFT([f].[FirstName], LEN(N'_')) = N'_' +""", + // + """ +SELECT [f].[FirstName] +FROM [FunkyCustomers] AS [f] +WHERE [f].[FirstName] IS NOT NULL AND LEFT([f].[FirstName], LEN(N'[')) = N'[' +""", + // + """ +SELECT [f].[FirstName] +FROM [FunkyCustomers] AS [f] +WHERE [f].[FirstName] LIKE N'B%' +""", + // + """ +SELECT [f].[FirstName] +FROM [FunkyCustomers] AS [f] +WHERE [f].[FirstName] IS NULL OR LEFT([f].[FirstName], LEN(N'_')) <> N'_' +"""); + } + + public override async Task String_starts_with_on_argument_with_wildcard_char_parameter(bool async) + { + await base.String_starts_with_on_argument_with_wildcard_char_parameter(async); + + AssertSql( + """ +@prm1='%' (Size = -1) (DbType = String) + +SELECT [f].[FirstName] +FROM [FunkyCustomers] AS [f] +WHERE [f].[FirstName] IS NOT NULL AND LEFT([f].[FirstName], LEN(@prm1)) = @prm1 +""", + // + """ +@prm2='_' (Size = -1) (DbType = String) + +SELECT [f].[FirstName] +FROM [FunkyCustomers] AS [f] +WHERE [f].[FirstName] IS NOT NULL AND LEFT([f].[FirstName], LEN(@prm2)) = @prm2 +""", + // + """ +@prm3='[' (Size = -1) (DbType = String) + +SELECT [f].[FirstName] +FROM [FunkyCustomers] AS [f] +WHERE [f].[FirstName] IS NOT NULL AND LEFT([f].[FirstName], LEN(@prm3)) = @prm3 +""", + // + """ +@prm4='B' (Size = -1) (DbType = String) + +SELECT [f].[FirstName] +FROM [FunkyCustomers] AS [f] +WHERE [f].[FirstName] IS NOT NULL AND LEFT([f].[FirstName], LEN(@prm4)) = @prm4 +""", + // + """ +@prm5='_' (Size = -1) (DbType = String) + +SELECT [f].[FirstName] +FROM [FunkyCustomers] AS [f] +WHERE [f].[FirstName] IS NULL OR LEFT([f].[FirstName], LEN(@prm5)) <> @prm5 +"""); + } + public override async Task String_starts_with_on_argument_with_bracket(bool async) { await base.String_starts_with_on_argument_with_bracket(async); diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/FunkyDataQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/FunkyDataQuerySqlServerTest.cs index f9ab13a3d98..a75006b30da 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/FunkyDataQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/FunkyDataQuerySqlServerTest.cs @@ -272,6 +272,88 @@ FROM [FunkyCustomers] AS [f] """); } + public override async Task String_starts_with_on_argument_with_wildcard_char_constant(bool async) + { + await base.String_starts_with_on_argument_with_wildcard_char_constant(async); + + AssertSql( + """ +SELECT [f].[FirstName] +FROM [FunkyCustomers] AS [f] +WHERE [f].[FirstName] LIKE N'\%%' ESCAPE N'\' +""", + // + """ +SELECT [f].[FirstName] +FROM [FunkyCustomers] AS [f] +WHERE [f].[FirstName] LIKE N'\_%' ESCAPE N'\' +""", + // + """ +SELECT [f].[FirstName] +FROM [FunkyCustomers] AS [f] +WHERE [f].[FirstName] LIKE N'\[%' ESCAPE N'\' +""", + // + """ +SELECT [f].[FirstName] +FROM [FunkyCustomers] AS [f] +WHERE [f].[FirstName] LIKE N'B%' +""", + // + """ +SELECT [f].[FirstName] +FROM [FunkyCustomers] AS [f] +WHERE [f].[FirstName] NOT LIKE N'\_%' ESCAPE N'\' OR [f].[FirstName] IS NULL +"""); + } + + public override async Task String_starts_with_on_argument_with_wildcard_char_parameter(bool async) + { + await base.String_starts_with_on_argument_with_wildcard_char_parameter(async); + + AssertSql( + """ +@prm1_startswith='\%%' (Size = 4000) + +SELECT [f].[FirstName] +FROM [FunkyCustomers] AS [f] +WHERE [f].[FirstName] LIKE @prm1_startswith ESCAPE N'\' +""", + // + """ +@prm2_startswith='\_%' (Size = 4000) + +SELECT [f].[FirstName] +FROM [FunkyCustomers] AS [f] +WHERE [f].[FirstName] LIKE @prm2_startswith ESCAPE N'\' +""", + // + """ +@prm3_startswith='\[%' (Size = 4000) + +SELECT [f].[FirstName] +FROM [FunkyCustomers] AS [f] +WHERE [f].[FirstName] LIKE @prm3_startswith ESCAPE N'\' +""", + // + """ +@prm4_startswith='B%' (Size = 4000) + +SELECT [f].[FirstName] +FROM [FunkyCustomers] AS [f] +WHERE [f].[FirstName] LIKE @prm4_startswith ESCAPE N'\' +""", + // + """ +@prm5_startswith='\_%' (Size = 4000) + +SELECT [f].[FirstName] +FROM [FunkyCustomers] AS [f] +WHERE [f].[FirstName] NOT LIKE @prm5_startswith ESCAPE N'\' OR [f].[FirstName] IS NULL +"""); + } + public override async Task String_starts_with_on_argument_with_bracket(bool async) { await base.String_starts_with_on_argument_with_bracket(async);