diff --git a/src/EFCore.Relational.Specification.Tests/RelationalQueryTestBase.cs b/src/EFCore.Relational.Specification.Tests/RelationalQueryTestBase.cs new file mode 100644 index 00000000000..0c16453ab7a --- /dev/null +++ b/src/EFCore.Relational.Specification.Tests/RelationalQueryTestBase.cs @@ -0,0 +1,51 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore.Specification.Tests; +using Microsoft.EntityFrameworkCore.Specification.Tests.TestModels.Northwind; +using Microsoft.EntityFrameworkCore.Specification.Tests.TestUtilities.Xunit; +using Xunit; + +namespace Microsoft.EntityFrameworkCore.Relational.Specification.Tests +{ + public abstract class RelationalQueryTestBase : QueryTestBase + where TFixture : NorthwindQueryFixtureBase, new() + { + [ConditionalFact] + public virtual void String_Like_Literal() + { + using (var context = CreateContext()) + { + var count = context.Customers.Count(c => EF.Functions.Like(c.ContactName, "%M%")); + Assert.Equal(19, count); + } + } + + [ConditionalFact] + public virtual void String_Like_Identity() + { + using (var context = CreateContext()) + { + var count = context.Customers.Count(c => EF.Functions.Like(c.ContactName, c.ContactName)); + Assert.Equal(91, count); + } + } + + [ConditionalFact] + public virtual void String_Like_Literal_With_Escape() + { + using (var context = CreateContext()) + { + var count = context.Customers.Count(c => EF.Functions.Like(c.ContactName, "!%", '!')); + Assert.Equal(0, count); + } + } + + protected RelationalQueryTestBase(TFixture fixture) : base(fixture) {} + } +} diff --git a/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs b/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs index eac69264891..572698cd9f1 100644 --- a/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs +++ b/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs @@ -483,6 +483,14 @@ public static string UnsupportedPropertyType([CanBeNull] object entity, [CanBeNu GetString("UnsupportedPropertyType", nameof(entity), nameof(property), nameof(clrType)), entity, property, clrType); + /// + /// This function can only be invoked from LINQ to Entities. + /// + public static string DbFunctionsDirectCall + { + get { return GetString("DbFunctionsDirectCall"); } + } + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/src/EFCore.Relational/Properties/RelationalStrings.resx b/src/EFCore.Relational/Properties/RelationalStrings.resx index eda52f7c6ff..5cf82d0e0c9 100644 --- a/src/EFCore.Relational/Properties/RelationalStrings.resx +++ b/src/EFCore.Relational/Properties/RelationalStrings.resx @@ -307,4 +307,7 @@ No mapping to a relational type can be found for property '{entity}.{property}' with the CLR type '{clrType}'. + + This function can only be invoked from LINQ to Entities. + \ No newline at end of file diff --git a/src/EFCore.Relational/Query/ExpressionTranslators/Internal/LikeTranslator.cs b/src/EFCore.Relational/Query/ExpressionTranslators/Internal/LikeTranslator.cs new file mode 100644 index 00000000000..8f3d2aaf2f6 --- /dev/null +++ b/src/EFCore.Relational/Query/ExpressionTranslators/Internal/LikeTranslator.cs @@ -0,0 +1,51 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore.Query.Expressions; +using Microsoft.EntityFrameworkCore.Utilities; + +namespace Microsoft.EntityFrameworkCore.Query.ExpressionTranslators.Internal +{ + /// + /// This API supports the Entity Framework Core infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public class LikeTranslator : IMethodCallTranslator + { + private static readonly MethodInfo _methodInfo + = typeof(RelationalDbFunctionsExtensions).GetRuntimeMethod(nameof(RelationalDbFunctionsExtensions.Like), + new[] { typeof(DbFunctions), typeof(string), typeof(string) }); + + private static readonly MethodInfo _methodInfoWithEscape + = typeof(RelationalDbFunctionsExtensions).GetRuntimeMethod(nameof(RelationalDbFunctionsExtensions.Like), + new[] { typeof(DbFunctions), typeof(string), typeof(string), typeof(char) }); + + /// + /// This API supports the Entity Framework Core infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public virtual Expression Translate(MethodCallExpression methodCallExpression) + { + Check.NotNull(methodCallExpression, nameof(methodCallExpression)); + + if (Equals(methodCallExpression.Method, _methodInfo)) + { + return new LikeExpression(methodCallExpression.Arguments[1], methodCallExpression.Arguments[2]); + } + + if (Equals(methodCallExpression.Method, _methodInfoWithEscape)) + { + return new LikeExpression(methodCallExpression.Arguments[1], methodCallExpression.Arguments[2], methodCallExpression.Arguments[3]); + } + + return null; + } + } +} diff --git a/src/EFCore.Relational/Query/ExpressionTranslators/RelationalCompositeMethodCallTranslator.cs b/src/EFCore.Relational/Query/ExpressionTranslators/RelationalCompositeMethodCallTranslator.cs index 4451b16d26c..13a759d6f30 100644 --- a/src/EFCore.Relational/Query/ExpressionTranslators/RelationalCompositeMethodCallTranslator.cs +++ b/src/EFCore.Relational/Query/ExpressionTranslators/RelationalCompositeMethodCallTranslator.cs @@ -34,7 +34,8 @@ protected RelationalCompositeMethodCallTranslator( { new EnumHasFlagTranslator(), new EqualsTranslator(dependencies.Logger), - new IsNullOrEmptyTranslator() + new IsNullOrEmptyTranslator(), + new LikeTranslator() }; } diff --git a/src/EFCore.Relational/Query/ExpressionVisitors/SqlTranslatingExpressionVisitor.cs b/src/EFCore.Relational/Query/ExpressionVisitors/SqlTranslatingExpressionVisitor.cs index 67b7bd7488e..40e81a0d01e 100644 --- a/src/EFCore.Relational/Query/ExpressionVisitors/SqlTranslatingExpressionVisitor.cs +++ b/src/EFCore.Relational/Query/ExpressionVisitors/SqlTranslatingExpressionVisitor.cs @@ -597,7 +597,9 @@ var arguments = methodCallExpression.Arguments .Where(e => !(e is QuerySourceReferenceExpression) && !(e is SubQueryExpression)) - .Select(e => (e as ConstantExpression)?.Value is Array ? e : Visit(e)) + .Select(e => (e as ConstantExpression)?.Value is Array || e.Type == typeof(DbFunctions) + ? e + : Visit(e)) .Where(e => e != null) .ToArray(); diff --git a/src/EFCore.Relational/Query/Expressions/LikeExpression.cs b/src/EFCore.Relational/Query/Expressions/LikeExpression.cs index 9f20a45b22b..36f11b0e343 100644 --- a/src/EFCore.Relational/Query/Expressions/LikeExpression.cs +++ b/src/EFCore.Relational/Query/Expressions/LikeExpression.cs @@ -28,6 +28,22 @@ public LikeExpression([NotNull] Expression match, [NotNull] Expression pattern) Pattern = pattern; } + /// + /// Creates a new instance of LikeExpression. + /// + /// The expression to match. + /// The pattern to match. + /// The escape character to use in . + public LikeExpression([NotNull] Expression match, [NotNull] Expression pattern, [CanBeNull] Expression escapeChar) + { + Check.NotNull(match, nameof(match)); + Check.NotNull(pattern, nameof(pattern)); + + Match = match; + Pattern = pattern; + EscapeChar = escapeChar; + } + /// /// Gets the match expression. /// @@ -44,6 +60,15 @@ public LikeExpression([NotNull] Expression match, [NotNull] Expression pattern) /// public virtual Expression Pattern { get; } + /// + /// Gets the escape character to use in . + /// + /// + /// The escape character to use. If null, no escape character is used. + /// + [CanBeNull] + public virtual Expression EscapeChar { get; } + /// /// Returns the node type of this . (Inherited from .) /// @@ -87,10 +112,12 @@ protected override Expression VisitChildren(ExpressionVisitor visitor) { var newMatchExpression = visitor.Visit(Match); var newPatternExpression = visitor.Visit(Pattern); + var newEscapeCharExpression = EscapeChar == null ? null : visitor.Visit(EscapeChar); return (newMatchExpression != Match) || (newPatternExpression != Pattern) - ? new LikeExpression(newMatchExpression, newPatternExpression) + || (newEscapeCharExpression != EscapeChar) + ? new LikeExpression(newMatchExpression, newPatternExpression, newEscapeCharExpression) : this; } @@ -98,6 +125,6 @@ protected override Expression VisitChildren(ExpressionVisitor visitor) /// Creates a representation of the Expression. /// /// A representation of the Expression. - public override string ToString() => Match + " LIKE " + Pattern; + public override string ToString() => $"{Match} LIKE {Pattern}{(EscapeChar == null ? "" : $" ESCAPE {EscapeChar}")}"; } } diff --git a/src/EFCore.Relational/Query/Sql/DefaultQuerySqlGenerator.cs b/src/EFCore.Relational/Query/Sql/DefaultQuerySqlGenerator.cs index 06ddaf18b42..dc15bfcef61 100644 --- a/src/EFCore.Relational/Query/Sql/DefaultQuerySqlGenerator.cs +++ b/src/EFCore.Relational/Query/Sql/DefaultQuerySqlGenerator.cs @@ -1243,6 +1243,12 @@ public virtual Expression VisitLike(LikeExpression likeExpression) Visit(likeExpression.Pattern); + if (likeExpression.EscapeChar != null) + { + _relationalCommandBuilder.Append(" ESCAPE "); + Visit(likeExpression.EscapeChar); + } + _typeMapping = parentTypeMapping; return likeExpression; diff --git a/src/EFCore.Relational/RelationalDbFunctionsExtensions.cs b/src/EFCore.Relational/RelationalDbFunctionsExtensions.cs new file mode 100644 index 00000000000..ef0f660ffb1 --- /dev/null +++ b/src/EFCore.Relational/RelationalDbFunctionsExtensions.cs @@ -0,0 +1,51 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore.Internal; +using JetBrains.Annotations; + +namespace Microsoft.EntityFrameworkCore +{ + /// + /// Provides CLR methods that get translated to database functions when used in LINQ to Entities queries. + /// The methods on this class are accessed via . + /// + // ReSharper disable once InconsistentNaming + public static class RelationalDbFunctionsExtensions + { + /// + /// Indicates whether the specified input string matches the given pattern by sending an SQL LIKE + /// expression to the database. + /// + /// Should always be . + /// The string to search for a match. + /// The regular expression pattern to match. + /// true if the LIKE expression finds a match; otherwise, false. + public static bool Like( + [NotNull] this DbFunctions functions, + [NotNull] string input, + [NotNull] string pattern) + => throw new NotSupportedException(RelationalStrings.DbFunctionsDirectCall); + + /// + /// Indicates whether the specified input string matches the given pattern by sending an SQL LIKE + /// expression to the database. + /// + /// Should always be . + /// The string to search for a match. + /// The regular expression pattern to match. + /// Character to use as an escape character in . + /// true if the LIKE expression finds a match; otherwise, false. + public static bool Like( + [NotNull] this DbFunctions functions, + [NotNull] string input, + [NotNull] string pattern, + char escapeChar) + => throw new NotSupportedException(RelationalStrings.DbFunctionsDirectCall); + } +} diff --git a/src/EFCore.SqlServer/Query/ExpressionTranslators/Internal/SqlServerCompositeMethodCallTranslator.cs b/src/EFCore.SqlServer/Query/ExpressionTranslators/Internal/SqlServerCompositeMethodCallTranslator.cs index 5e561c3e594..88c3ce88830 100644 --- a/src/EFCore.SqlServer/Query/ExpressionTranslators/Internal/SqlServerCompositeMethodCallTranslator.cs +++ b/src/EFCore.SqlServer/Query/ExpressionTranslators/Internal/SqlServerCompositeMethodCallTranslator.cs @@ -1,6 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; using JetBrains.Annotations; namespace Microsoft.EntityFrameworkCore.Query.ExpressionTranslators.Internal diff --git a/src/EFCore/DbFunctions.cs b/src/EFCore/DbFunctions.cs new file mode 100644 index 00000000000..c15076486b1 --- /dev/null +++ b/src/EFCore/DbFunctions.cs @@ -0,0 +1,15 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.EntityFrameworkCore +{ + /// + /// Provides CLR methods that get translated to database functions when used in LINQ to Entities queries. + /// The methods on this class are accessed via . + /// + // ReSharper disable once InconsistentNaming + public class DbFunctions + { + internal DbFunctions() { } + } +} diff --git a/src/EFCore/EF.cs b/src/EFCore/EF.cs index 9e48dcc113f..b9f212c2dda 100644 --- a/src/EFCore/EF.cs +++ b/src/EFCore/EF.cs @@ -41,5 +41,11 @@ public static TProperty Property( { throw new InvalidOperationException(CoreStrings.PropertyMethodInvoked); } + + /// + /// Provides CLR methods that get translated to database functions when used in LINQ to Entities queries. + /// Calling these methods in other contexts (e.g. LINQ to Objects) will throw a . + /// + public static DbFunctions Functions { get; } = new DbFunctions(); } } diff --git a/test/EFCore.SqlServer.FunctionalTests/QuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/QuerySqlServerTest.cs index 15ac075775d..12b15353c6e 100644 --- a/test/EFCore.SqlServer.FunctionalTests/QuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/QuerySqlServerTest.cs @@ -11,6 +11,7 @@ using Microsoft.EntityFrameworkCore.Specification.Tests; using Microsoft.EntityFrameworkCore.Specification.Tests.TestModels.Northwind; using Microsoft.EntityFrameworkCore.Specification.Tests.TestUtilities.Xunit; +using Microsoft.EntityFrameworkCore.Relational.Specification.Tests; using Microsoft.EntityFrameworkCore.SqlServer.FunctionalTests.Utilities; using Xunit; using Xunit.Abstractions; @@ -22,7 +23,7 @@ namespace Microsoft.EntityFrameworkCore.SqlServer.FunctionalTests { - public class QuerySqlServerTest : QueryTestBase + public class QuerySqlServerTest : RelationalQueryTestBase { public QuerySqlServerTest(NorthwindQuerySqlServerFixture fixture, ITestOutputHelper testOutputHelper) : base(fixture) @@ -4843,6 +4844,43 @@ FROM [Customers] AS [c] Sql); } + public override void String_Like_Literal() + { + using (var context = CreateContext()) + { + var count = context.Customers.Count(c => EF.Functions.Like(c.ContactName, "%M%")); + Assert.Equal(34, count); // case-insensitive + } + + Assert.Equal( + @"SELECT COUNT(*) +FROM [Customers] AS [c] +WHERE [c].[ContactName] LIKE N'%M%'", + Sql); + } + + public override void String_Like_Identity() + { + base.String_Like_Identity(); + + Assert.Equal( + @"SELECT COUNT(*) +FROM [Customers] AS [c] +WHERE [c].[ContactName] LIKE [c].[ContactName]", + Sql); + } + + public override void String_Like_Literal_With_Escape() + { + base.String_Like_Literal_With_Escape(); + + Assert.Equal( + @"SELECT COUNT(*) +FROM [Customers] AS [c] +WHERE [c].[ContactName] LIKE N'!%' ESCAPE '!'", + Sql); + } + public override void String_Compare_simple_zero() { base.String_Compare_simple_zero(); @@ -7398,4 +7436,4 @@ THEN CAST(1 AS BIT) ELSE CAST(0 AS BIT) private static string Sql => TestSqlLoggerFactory.Sql.Replace(Environment.NewLine, FileLineEnding); } -} \ No newline at end of file +} diff --git a/test/EFCore.Sqlite.FunctionalTests/QuerySqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/QuerySqliteTest.cs index 7cd28b3cfe2..7f1ed0dfa82 100644 --- a/test/EFCore.Sqlite.FunctionalTests/QuerySqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/QuerySqliteTest.cs @@ -2,6 +2,8 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Linq; +using Microsoft.EntityFrameworkCore.Relational.Specification.Tests; using Microsoft.EntityFrameworkCore.Specification.Tests; using Xunit; using Xunit.Abstractions; @@ -11,7 +13,7 @@ #endif namespace Microsoft.EntityFrameworkCore.Sqlite.FunctionalTests { - public class QuerySqliteTest : QueryTestBase + public class QuerySqliteTest : RelationalQueryTestBase { public QuerySqliteTest(NorthwindQuerySqliteFixture fixture, ITestOutputHelper testOutputHelper) : base(fixture) @@ -124,6 +126,16 @@ public override void Sum_with_coalesce() Sql); } + public override void String_Like_Literal() + { + using (var context = CreateContext()) + { + var count = context.Customers.Count(c => EF.Functions.Like(c.ContactName, "%M%")); + Assert.Equal(34, count); + } + } + + private const string FileLineEnding = @" ";