diff --git a/src/EFCore.SqlServer/Extensions/SqlServerQueryableExtensions.cs b/src/EFCore.SqlServer/Extensions/SqlServerQueryableExtensions.cs new file mode 100644 index 00000000000..774faa946a0 --- /dev/null +++ b/src/EFCore.SqlServer/Extensions/SqlServerQueryableExtensions.cs @@ -0,0 +1,96 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq.Expressions; +using System.Reflection; + +// ReSharper disable once CheckNamespace +namespace Microsoft.EntityFrameworkCore; + +/// +/// SQL Server specific extension methods for LINQ queries. +/// +/// +/// See Database functions, and +/// Accessing SQL Server and Azure SQL databases with EF Core +/// for more information and examples. +/// +public static class SqlServerQueryableExtensions +{ + private static readonly MethodInfo _containsTableMethod + = typeof(SqlServerQueryableExtensions).GetMethod(nameof(ContainsTable))!; + + private static readonly MethodInfo _freeTextTableMethod + = typeof(SqlServerQueryableExtensions).GetMethod(nameof(FreeTextTable))!; + + /// + /// Provides a mapping to the SQL Server CONTAINSTABLE full-text search function. + /// + /// + /// See Database functions, and + /// Accessing SQL Server and Azure SQL databases with EF Core + /// for more information and examples. + /// + /// The type of entity being queried. + /// The type of the entity's primary key. + /// The source query. + /// A lambda expression selecting the property to search. + /// The search condition. + /// The table-valued function result containing Key and Rank columns. + public static IQueryable> ContainsTable( + this IQueryable source, + Expression> propertySelector, + string searchCondition) + where TEntity : class + => source.Provider.CreateQuery>( + Expression.Call( + null, + _containsTableMethod.MakeGenericMethod(typeof(TEntity), typeof(TKey)), + source.Expression, + propertySelector, + Expression.Constant(searchCondition))); + + /// + /// Provides a mapping to the SQL Server FREETEXTTABLE full-text search function. + /// + /// + /// See Database functions, and + /// Accessing SQL Server and Azure SQL databases with EF Core + /// for more information and examples. + /// + /// The type of entity being queried. + /// The type of the entity's primary key. + /// The source query. + /// A lambda expression selecting the property to search. + /// The text that will be searched for in the property. + /// The table-valued function result containing Key and Rank columns. + public static IQueryable> FreeTextTable( + this IQueryable source, + Expression> propertySelector, + string freeText) + where TEntity : class + => source.Provider.CreateQuery>( + Expression.Call( + null, + _freeTextTableMethod.MakeGenericMethod(typeof(TEntity), typeof(TKey)), + source.Expression, + propertySelector, + Expression.Constant(freeText))); + + /// + /// The result type for SQL Server full-text table-valued functions (CONTAINSTABLE / FREETEXTTABLE). + /// + /// The type of the key column. + public class FullTextTableResult(TKey key, int rank) + { + /// + /// The key of the row matched. + /// + public TKey Key { get; } = key; + + /// + /// The ranking value assigned to the row. + /// + public int Rank { get; } = rank; + } +} diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerQuerySqlGenerator.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerQuerySqlGenerator.cs index c7d228f5ff6..d312c260682 100644 --- a/src/EFCore.SqlServer/Query/Internal/SqlServerQuerySqlGenerator.cs +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerQuerySqlGenerator.cs @@ -914,6 +914,39 @@ protected override bool TryGetOperatorInfo(SqlExpression expression, out int pre return precedence != default; } + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected override Expression VisitTableValuedFunction(TableValuedFunctionExpression tableValuedFunctionExpression) + { + if (tableValuedFunctionExpression.Name is "CONTAINSTABLE" or "FREETEXTTABLE") + { + Sql.Append(tableValuedFunctionExpression.Name).Append("("); + + var tableFragment = (SqlFragmentExpression)tableValuedFunctionExpression.Arguments[0]; + Sql.Append(_sqlGenerationHelper.DelimitIdentifier(tableFragment.Sql)); + + Sql.Append(", "); + + var columnFragment = (SqlFragmentExpression)tableValuedFunctionExpression.Arguments[1]; + Sql.Append(_sqlGenerationHelper.DelimitIdentifier(columnFragment.Sql)); + + Sql.Append(", "); + + Visit(tableValuedFunctionExpression.Arguments[2]); + + Sql.Append(")") + .Append(AliasSeparator) + .Append(_sqlGenerationHelper.DelimitIdentifier(tableValuedFunctionExpression.Alias)); + + return tableValuedFunctionExpression; + } + return base.VisitTableValuedFunction(tableValuedFunctionExpression); + } + private void GenerateList( IReadOnlyList items, Action generationAction, diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs index 659981c4fd0..05325cb78e9 100644 --- a/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics.CodeAnalysis; +using System.Reflection; using Microsoft.EntityFrameworkCore.Query.SqlExpressions; using Microsoft.EntityFrameworkCore.SqlServer.Infrastructure.Internal; using Microsoft.EntityFrameworkCore.SqlServer.Internal; @@ -71,6 +72,118 @@ protected SqlServerQueryableMethodTranslatingExpressionVisitor( protected override QueryableMethodTranslatingExpressionVisitor CreateSubqueryVisitor() => new SqlServerQueryableMethodTranslatingExpressionVisitor(this); + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected override Expression VisitMethodCall(MethodCallExpression methodCallExpression) + { + var method = methodCallExpression.Method; + + if (method.DeclaringType == typeof(Microsoft.EntityFrameworkCore.SqlServerQueryableExtensions) + && method.IsGenericMethod + && methodCallExpression.Arguments is [var sourceExpression, var selectorExpression, var searchConditionExpression] + && (method.Name == nameof(Microsoft.EntityFrameworkCore.SqlServerQueryableExtensions.ContainsTable) + || method.Name == nameof(Microsoft.EntityFrameworkCore.SqlServerQueryableExtensions.FreeTextTable))) + { + var functionName = method.Name == nameof(Microsoft.EntityFrameworkCore.SqlServerQueryableExtensions.ContainsTable) + ? "CONTAINSTABLE" + : "FREETEXTTABLE"; + + var source = Visit(sourceExpression); + if (source is not ShapedQueryExpression shapedQuery + || shapedQuery.QueryExpression is not SelectExpression sourceSelectExpression) + { + return QueryCompilationContext.NotTranslatedExpression; + } + + var lambda = selectorExpression.UnwrapLambdaFromQuote(); + if (lambda is not LambdaExpression lambdaExpression) + { + return QueryCompilationContext.NotTranslatedExpression; + } + + var propertyTranslation = TranslateLambdaExpression(shapedQuery, lambdaExpression); + if (propertyTranslation is not ColumnExpression columnExpression) + { + AddTranslationErrorDetails(SqlServerStrings.InvalidColumnNameForFreeText); + return QueryCompilationContext.NotTranslatedExpression; + } + + if (sourceSelectExpression is not + { + Tables: [TableExpression tableExpression], + Predicate: null, + GroupBy: [], + Having: null, + IsDistinct: false, + Orderings: [], + Limit: null, + Offset: null + }) + { + AddTranslationErrorDetails("Source must be a simple table scan."); + return QueryCompilationContext.NotTranslatedExpression; + } + + var tableName = tableExpression.Name; + var columnName = columnExpression.Name; + var searchCondition = TranslateExpression(searchConditionExpression); + if (searchCondition == null) + { + AddTranslationErrorDetails(CoreStrings.TranslationFailed(searchConditionExpression.Print())); + return QueryCompilationContext.NotTranslatedExpression; + } + var keyType = method.GetGenericArguments()[1]; // TKey is the second generic argument + var alias = _queryCompilationContext.SqlAliasManager.GenerateTableAlias("fts"); + var tableFragment = new SqlFragmentExpression(tableExpression.Name); + var columnFragment = new SqlFragmentExpression(columnExpression.Name); + + var arguments = new SqlExpression[] { tableFragment, columnFragment, searchCondition }; + + var tvf = new TableValuedFunctionExpression(alias, functionName, arguments); + var resultType = method.ReturnType; + var keyTypeMapping = _typeMappingSource.FindMapping(keyType); + var keyColumn = new ColumnExpression("KEY", alias, keyType, keyTypeMapping, nullable: false); + var rankColumn = new ColumnExpression("RANK", alias, typeof(int), _typeMappingSource.FindMapping(typeof(int)), nullable: false); + #pragma warning disable EF1001 + #pragma warning disable EF1001 + var newSelectExpression = new SelectExpression( + alias: null, + tables: new List { tvf }, + predicate: null, + groupBy: new List(), + having: null, + projections: new List(), + distinct: false, + orderings: new List(), + offset: null, + limit: null, + sqlAliasManager: _queryCompilationContext.SqlAliasManager); + #pragma warning restore EF1001 + #pragma warning restore EF1001 + var projectionMap = new Dictionary(); + + var keyMember = new ProjectionMember(); + projectionMap.Add(keyMember, keyColumn); + + var rankProperty = resultType.GetProperty("Rank")!; + var rankMember = new ProjectionMember().Append(rankProperty); + projectionMap.Add(rankMember, rankColumn); + newSelectExpression.ReplaceProjection(projectionMap); + var keyBinding = new ProjectionBindingExpression(newSelectExpression, keyMember, keyType); + var rankBinding = new ProjectionBindingExpression(newSelectExpression, rankMember, typeof(int)); + var shaperExpression = Expression.New( + resultType.GetConstructor([keyType, typeof(int)])!, + keyBinding, + rankBinding); + return new ShapedQueryExpression(newSelectExpression, shaperExpression); + } + + return base.VisitMethodCall(methodCallExpression); + } /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindDbFunctionsQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindDbFunctionsQuerySqlServerTest.cs index 51f37ee2e49..2fb3aa78ef9 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindDbFunctionsQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindDbFunctionsQuerySqlServerTest.cs @@ -1350,4 +1350,76 @@ WHERE CAST(DATALENGTH(N'foo') AS int) = 3 private void AssertSql(params string[] expected) => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); + + [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFullTextSearch)] +public virtual void FullText_ContainsTable_queryable_simple() +{ + using var ctx = CreateContext(); + + var query = from c in ctx.Set() + join ct in ctx.Set() + .ContainsTable( + c => c.ContactName, + "John") + on c.CustomerID equals ct.Key + select new { c.ContactName, ct.Rank }; + + var result = query.ToList(); + + Assert.NotEmpty(result); + AssertSql( + """ +SELECT [c].[ContactName], [f].[RANK] +FROM [Customers] AS [c] +INNER JOIN CONTAINSTABLE([Customers], [ContactName], N'John') AS [f] ON [c].[CustomerID] = [f].[KEY] +"""); +} + +[ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFullTextSearch)] +public virtual void FullText_FreeTextTable_queryable_simple() +{ + using var ctx = CreateContext(); + + var query = from c in ctx.Set() + join ct in ctx.Set() + .FreeTextTable( + c => c.ContactName, + "John") + on c.CustomerID equals ct.Key + select new { c.ContactName, ct.Rank }; + + var result = query.ToList(); + + Assert.NotEmpty(result); + AssertSql( + """ +SELECT [c].[ContactName], [f].[RANK] +FROM [Customers] AS [c] +INNER JOIN FREETEXTTABLE([Customers], [ContactName], N'John') AS [f] ON [c].[CustomerID] = [f].[KEY] +"""); +} + +[ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFullTextSearch)] +public virtual void FullText_ContainsTable_queryable_int_key() +{ + using var ctx = CreateContext(); + + var query = from e in ctx.Set() + join ct in ctx.Set() + .ContainsTable( + e => e.Title, + "Sales") + on e.EmployeeID equals ct.Key + select new { e.Title, ct.Rank }; + + var result = query.ToList(); + + Assert.NotEmpty(result); + AssertSql( + """ +SELECT [e].[Title], [f].[RANK] +FROM [Employees] AS [e] +INNER JOIN CONTAINSTABLE([Employees], [Title], N'Sales') AS [f] ON [e].[EmployeeID] = [f].[KEY] +"""); +} } diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/SqlQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/SqlQuerySqlServerTest.cs index c9936d9d0b5..46581b1c03e 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/SqlQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/SqlQuerySqlServerTest.cs @@ -817,4 +817,4 @@ protected override DbParameter CreateDbParameter(string name, object value) private void AssertSql(params string[] expected) => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); -} +} \ No newline at end of file diff --git a/test/EFCore.SqlServer.FunctionalTests/config.json b/test/EFCore.SqlServer.FunctionalTests/config.json index f4880f2152f..339484b9eda 100644 --- a/test/EFCore.SqlServer.FunctionalTests/config.json +++ b/test/EFCore.SqlServer.FunctionalTests/config.json @@ -1,9 +1,9 @@ { - "Test": { + "Test": { "SqlServer": { "DefaultConnection": "Data Source=(localdb)\\MSSQLLocalDB;Database=master;Integrated Security=True;Connect Timeout=60;ConnectRetryCount=0", "ElasticPoolName": "", "SupportsMemoryOptimized": null } - } -} + } +} \ No newline at end of file