Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions src/EFCore.SqlServer/Extensions/SqlServerQueryableExtensions.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// SQL Server specific extension methods for LINQ queries.
/// </summary>
/// <remarks>
/// See <see href="https://aka.ms/efcore-docs-database-functions">Database functions</see>, and
/// <see href="https://aka.ms/efcore-docs-sqlserver">Accessing SQL Server and Azure SQL databases with EF Core</see>
/// for more information and examples.
/// </remarks>
public static class SqlServerQueryableExtensions
{
private static readonly MethodInfo _containsTableMethod
= typeof(SqlServerQueryableExtensions).GetMethod(nameof(ContainsTable))!;

private static readonly MethodInfo _freeTextTableMethod
= typeof(SqlServerQueryableExtensions).GetMethod(nameof(FreeTextTable))!;

/// <summary>
/// Provides a mapping to the SQL Server CONTAINSTABLE full-text search function.
/// </summary>
/// <remarks>
/// See <see href="https://aka.ms/efcore-docs-database-functions">Database functions</see>, and
/// <see href="https://aka.ms/efcore-docs-sqlserver">Accessing SQL Server and Azure SQL databases with EF Core</see>
/// for more information and examples.
/// </remarks>
/// <typeparam name="TEntity">The type of entity being queried.</typeparam>
/// <typeparam name="TKey">The type of the entity's primary key.</typeparam>
/// <param name="source">The source query.</param>
/// <param name="propertySelector">A lambda expression selecting the property to search.</param>
/// <param name="searchCondition">The search condition.</param>
/// <returns>The table-valued function result containing Key and Rank columns.</returns>
public static IQueryable<FullTextTableResult<TKey>> ContainsTable<TEntity, TKey>(
this IQueryable<TEntity> source,
Expression<Func<TEntity, object>> propertySelector,
string searchCondition)
where TEntity : class
=> source.Provider.CreateQuery<FullTextTableResult<TKey>>(
Expression.Call(
null,
_containsTableMethod.MakeGenericMethod(typeof(TEntity), typeof(TKey)),
source.Expression,
propertySelector,
Expression.Constant(searchCondition)));

/// <summary>
/// Provides a mapping to the SQL Server FREETEXTTABLE full-text search function.
/// </summary>
/// <remarks>
/// See <see href="https://aka.ms/efcore-docs-database-functions">Database functions</see>, and
/// <see href="https://aka.ms/efcore-docs-sqlserver">Accessing SQL Server and Azure SQL databases with EF Core</see>
/// for more information and examples.
/// </remarks>
/// <typeparam name="TEntity">The type of entity being queried.</typeparam>
/// <typeparam name="TKey">The type of the entity's primary key.</typeparam>
/// <param name="source">The source query.</param>
/// <param name="propertySelector">A lambda expression selecting the property to search.</param>
/// <param name="freeText">The text that will be searched for in the property.</param>
/// <returns>The table-valued function result containing Key and Rank columns.</returns>
public static IQueryable<FullTextTableResult<TKey>> FreeTextTable<TEntity, TKey>(
this IQueryable<TEntity> source,
Expression<Func<TEntity, object>> propertySelector,
string freeText)
where TEntity : class
=> source.Provider.CreateQuery<FullTextTableResult<TKey>>(
Expression.Call(
null,
_freeTextTableMethod.MakeGenericMethod(typeof(TEntity), typeof(TKey)),
source.Expression,
propertySelector,
Expression.Constant(freeText)));

/// <summary>
/// The result type for SQL Server full-text table-valued functions (CONTAINSTABLE / FREETEXTTABLE).
/// </summary>
/// <typeparam name="TKey">The type of the key column.</typeparam>
public class FullTextTableResult<TKey>(TKey key, int rank)
{
/// <summary>
/// The key of the row matched.
/// </summary>
public TKey Key { get; } = key;

/// <summary>
/// The ranking value assigned to the row.
/// </summary>
public int Rank { get; } = rank;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -914,6 +914,39 @@ protected override bool TryGetOperatorInfo(SqlExpression expression, out int pre
return precedence != default;
}

/// <summary>
/// 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.
/// </summary>
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<T>(
IReadOnlyList<T> items,
Action<T> generationAction,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -71,6 +72,118 @@ protected SqlServerQueryableMethodTranslatingExpressionVisitor(
protected override QueryableMethodTranslatingExpressionVisitor CreateSubqueryVisitor()
=> new SqlServerQueryableMethodTranslatingExpressionVisitor(this);

/// <summary>
/// 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.
/// </summary>
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<TableExpressionBase> { tvf },
predicate: null,
groupBy: new List<SqlExpression>(),
having: null,
projections: new List<ProjectionExpression>(),
distinct: false,
orderings: new List<OrderingExpression>(),
offset: null,
limit: null,
sqlAliasManager: _queryCompilationContext.SqlAliasManager);
#pragma warning restore EF1001
#pragma warning restore EF1001
var projectionMap = new Dictionary<ProjectionMember, Expression>();

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);
}
/// <summary>
/// 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Customer>()
join ct in ctx.Set<Customer>()
.ContainsTable<Customer, string>(
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<Customer>()
join ct in ctx.Set<Customer>()
.FreeTextTable<Customer, string>(
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<Employee>()
join ct in ctx.Set<Employee>()
.ContainsTable<Employee, uint>(
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]
""");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -817,4 +817,4 @@ protected override DbParameter CreateDbParameter(string name, object value)

private void AssertSql(params string[] expected)
=> Fixture.TestSqlLoggerFactory.AssertBaseline(expected);
}
}
6 changes: 3 additions & 3 deletions test/EFCore.SqlServer.FunctionalTests/config.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
}
Loading