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