Skip to content

Commit 9785ec0

Browse files
committed
Add CONTAINSTABLE and FREETEXTTABLE support for SQL Server
1 parent 347ab47 commit 9785ec0

File tree

5 files changed

+242
-0
lines changed

5 files changed

+242
-0
lines changed

src/EFCore.SqlServer/Extensions/SqlServerDbFunctionsExtensions.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,48 @@ public static bool Contains(
9292
string searchCondition)
9393
=> throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(Contains)));
9494

95+
/// <summary>
96+
/// Provides a mapping to the SQL Server CONTAINSTABLE full-text search function.
97+
/// </summary>
98+
/// <param name="_">The <see cref="DbFunctions" /> instance.</param>
99+
/// <param name="property">The property to search.</param>
100+
/// <param name="searchCondition">The search condition.</param>
101+
/// <returns>The table-valued function result containing Key and Rank columns.</returns>
102+
public static IQueryable<FullTextTableResult> ContainsTable(
103+
this DbFunctions _,
104+
object property,
105+
string searchCondition)
106+
=> throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(ContainsTable)));
107+
108+
/// <summary>
109+
/// Provides a mapping to the SQL Server FREETEXTTABLE full-text search function.
110+
/// </summary>
111+
/// <param name="_">The <see cref="DbFunctions" /> instance.</param>
112+
/// <param name="property">The property to search.</param>
113+
/// <param name="freeText">The text that will be searched for in the property.</param>
114+
/// <returns>The table-valued function result containing Key and Rank columns.</returns>
115+
public static IQueryable<FullTextTableResult> FreeTextTable(
116+
this DbFunctions _,
117+
object property,
118+
string freeText)
119+
=> throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(FreeTextTable)));
120+
121+
/// <summary>
122+
/// The result type for SQL Server full-text table-valued functions (CONTAINSTABLE / FREETEXTTABLE).
123+
/// </summary>
124+
public class FullTextTableResult
125+
{
126+
/// <summary>
127+
/// The key of the row matched.
128+
/// </summary>
129+
public object Key { get; set; } = default!;
130+
131+
/// <summary>
132+
/// The ranking value assigned to the row.
133+
/// </summary>
134+
public int Rank { get; set; }
135+
}
136+
95137
#endregion Full-text search
96138

97139
#region DateDiffYear
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Diagnostics.CodeAnalysis;
5+
using Microsoft.EntityFrameworkCore.Infrastructure;
6+
using Microsoft.EntityFrameworkCore.Query.SqlExpressions;
7+
using static Microsoft.EntityFrameworkCore.SqlServerDbFunctionsExtensions;
8+
9+
// ReSharper disable once CheckNamespace
10+
namespace Microsoft.EntityFrameworkCore.SqlServer.Query.Internal;
11+
12+
/// <summary>
13+
/// An expression that represents a SQL Server full-text table-valued function (e.g., CONTAINSTABLE).
14+
/// </summary>
15+
public class FullTextTableExpression : TableExpressionBase
16+
{
17+
/// <summary>
18+
/// Creates a new instance of the <see cref="FullTextTableExpression" /> class.
19+
/// </summary>
20+
/// <param name="functionName">The name of the full-text function.</param>
21+
/// <param name="column">The column to search.</param>
22+
/// <param name="searchCondition">The search condition expression.</param>
23+
/// <param name="alias">The table alias.</param>
24+
public FullTextTableExpression(
25+
string functionName,
26+
ColumnExpression column,
27+
SqlExpression searchCondition,
28+
string alias)
29+
: base(alias)
30+
{
31+
FunctionName = functionName;
32+
Column = column;
33+
SearchCondition = searchCondition;
34+
}
35+
36+
/// <summary>
37+
/// The name of the function (e.g. CONTAINSTABLE).
38+
/// </summary>
39+
public virtual string FunctionName { get; }
40+
41+
/// <summary>
42+
/// The column being searched.
43+
/// </summary>
44+
public virtual ColumnExpression Column { get; }
45+
46+
/// <summary>
47+
/// The search condition.
48+
/// </summary>
49+
public virtual SqlExpression SearchCondition { get; }
50+
51+
/// <inheritdoc />
52+
protected override TableExpressionBase WithAnnotations(IReadOnlyDictionary<string, IAnnotation> annotations)
53+
=> this;
54+
55+
/// <inheritdoc />
56+
public override TableExpressionBase WithAlias(string newAlias)
57+
=> new FullTextTableExpression(FunctionName, Column, SearchCondition, newAlias);
58+
59+
/// <inheritdoc />
60+
protected override void Print(ExpressionPrinter expressionPrinter)
61+
{
62+
expressionPrinter.Append(FunctionName!).Append("(").Append(Column.TableAlias!).Append(", ");
63+
expressionPrinter.Visit(Column);
64+
expressionPrinter.Append(", ");
65+
expressionPrinter.Visit(SearchCondition);
66+
expressionPrinter.Append(") AS ").Append(Alias!);
67+
}
68+
69+
/// <inheritdoc />
70+
public override TableExpressionBase Quote()
71+
=> this;
72+
73+
/// <inheritdoc />
74+
public override TableExpressionBase Clone(string? alias, ExpressionVisitor visitor)
75+
{
76+
var newColumn = (ColumnExpression)visitor.Visit(Column);
77+
var newSearchCondition = (SqlExpression)visitor.Visit(SearchCondition);
78+
79+
return new FullTextTableExpression(FunctionName, newColumn, newSearchCondition, alias ?? Alias!);
80+
}
81+
}

src/EFCore.SqlServer/Query/Internal/SqlServerQuerySqlGenerator.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,9 @@ when tableExpression.FindAnnotation(SqlServerAnnotationNames.TemporalOperationTy
622622

623623
case SqlServerOpenJsonExpression openJsonExpression:
624624
return VisitOpenJsonExpression(openJsonExpression);
625+
626+
case FullTextTableExpression fullTextTableExpression:
627+
return VisitFullTextTable(fullTextTableExpression);
625628
}
626629

627630
return base.VisitExtension(extensionExpression);
@@ -914,6 +917,28 @@ protected override bool TryGetOperatorInfo(SqlExpression expression, out int pre
914917
return precedence != default;
915918
}
916919

920+
/// <summary>
921+
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
922+
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
923+
/// any release. You should only use it directly in your code with extreme caution and knowing that
924+
/// doing so can result in application failures when updating to a new Entity Framework Core release.
925+
/// </summary>
926+
protected virtual Expression VisitFullTextTable(FullTextTableExpression fullTextTableExpression)
927+
{
928+
Sql.Append(fullTextTableExpression.FunctionName)
929+
.Append("(")
930+
.Append(_sqlGenerationHelper.DelimitIdentifier(fullTextTableExpression.Column.TableAlias!))
931+
.Append(", ");
932+
Visit(fullTextTableExpression.Column);
933+
Sql.Append(", ");
934+
Visit(fullTextTableExpression.SearchCondition);
935+
Sql.Append(")")
936+
.Append(AliasSeparator)
937+
.Append(_sqlGenerationHelper.DelimitIdentifier(fullTextTableExpression.Alias!));
938+
939+
return fullTextTableExpression;
940+
}
941+
917942
private void GenerateList<T>(
918943
IReadOnlyList<T> items,
919944
Action<T> generationAction,

src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.Diagnostics.CodeAnalysis;
5+
using System.Reflection;
56
using Microsoft.EntityFrameworkCore.Query.SqlExpressions;
67
using Microsoft.EntityFrameworkCore.SqlServer.Infrastructure.Internal;
78
using Microsoft.EntityFrameworkCore.SqlServer.Internal;
@@ -71,6 +72,79 @@ protected SqlServerQueryableMethodTranslatingExpressionVisitor(
7172
protected override QueryableMethodTranslatingExpressionVisitor CreateSubqueryVisitor()
7273
=> new SqlServerQueryableMethodTranslatingExpressionVisitor(this);
7374

75+
/// <summary>
76+
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
77+
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
78+
/// any release. You should only use it directly in your code with extreme caution and knowing that
79+
/// doing so can result in application failures when updating to a new Entity Framework Core release.
80+
/// </summary>
81+
protected override Expression VisitMethodCall(MethodCallExpression methodCallExpression)
82+
{
83+
var method = methodCallExpression.Method;
84+
85+
// Handle ContainsTable and FreeTextTable table-valued functions
86+
if (method.DeclaringType == typeof(SqlServerDbFunctionsExtensions)
87+
&& (method.Name == nameof(SqlServerDbFunctionsExtensions.ContainsTable)
88+
|| method.Name == nameof(SqlServerDbFunctionsExtensions.FreeTextTable)))
89+
{
90+
var functionName = method.Name == nameof(SqlServerDbFunctionsExtensions.ContainsTable)
91+
? "CONTAINSTABLE"
92+
: "FREETEXTTABLE";
93+
94+
// Translate the arguments
95+
var propertyReference = TranslateExpression(methodCallExpression.Arguments[1]);
96+
if (propertyReference is not ColumnExpression columnExpression)
97+
{
98+
throw new InvalidOperationException(SqlServerStrings.InvalidColumnNameForFreeText);
99+
}
100+
101+
var searchCondition = TranslateExpression(methodCallExpression.Arguments[2]);
102+
if (searchCondition == null)
103+
{
104+
AddTranslationErrorDetails(
105+
CoreStrings.TranslationFailed(methodCallExpression.Arguments[2].Print()));
106+
return QueryCompilationContext.NotTranslatedExpression;
107+
}
108+
109+
// Create the FullTextTableExpression
110+
var alias = _queryCompilationContext.SqlAliasManager.GenerateTableAlias("ct");
111+
var fullTextTableExpression = new FullTextTableExpression(functionName, columnExpression, searchCondition, alias);
112+
113+
// Create columns for Key and Rank (CONTAINSTABLE/FREETEXTTABLE return these columns)
114+
var resultType = typeof(SqlServerDbFunctionsExtensions.FullTextTableResult);
115+
var keyColumn = new ColumnExpression("Key", alias, typeof(object), null, nullable: false);
116+
var rankColumn = new ColumnExpression("Rank", alias, typeof(int), _typeMappingSource.FindMapping(typeof(int)), nullable: false);
117+
118+
// Create SelectExpression with Key as the main projection
119+
// Use Key as identifier since it's the primary key from CONTAINSTABLE/FREETEXTTABLE
120+
var keyTypeMapping = _typeMappingSource.FindMapping(typeof(object));
121+
#pragma warning disable EF1001 // Internal EF Core API usage.
122+
var selectExpression = new SelectExpression(
123+
[fullTextTableExpression],
124+
keyColumn,
125+
[(keyColumn, keyTypeMapping?.Comparer ?? ValueComparer.CreateDefault(typeof(object), favorStructuralComparisons: false))],
126+
_queryCompilationContext.SqlAliasManager);
127+
#pragma warning restore EF1001 // Internal EF Core API usage.
128+
129+
// Add Rank to projection
130+
var rankIndex = selectExpression.AddToProjection(rankColumn);
131+
132+
// Create shaper expression for FullTextTableResult
133+
// Key is in the projection mapping (main projection), Rank is at rankIndex in the projection list
134+
var keyBinding = new ProjectionBindingExpression(selectExpression, new ProjectionMember(), typeof(object));
135+
var rankBinding = new ProjectionBindingExpression(selectExpression, rankIndex, typeof(int));
136+
137+
var shaperExpression = Expression.New(
138+
resultType.GetConstructor([typeof(object), typeof(int)])!,
139+
keyBinding,
140+
rankBinding);
141+
142+
return new ShapedQueryExpression(selectExpression, shaperExpression);
143+
}
144+
145+
return base.VisitMethodCall(methodCallExpression);
146+
}
147+
74148
/// <summary>
75149
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
76150
/// the same compatibility standards as public APIs. It may be changed or removed without notice in

test/EFCore.SqlServer.FunctionalTests/Query/SqlQuerySqlServerTest.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -817,4 +817,24 @@ protected override DbParameter CreateDbParameter(string name, object value)
817817

818818
private void AssertSql(params string[] expected)
819819
=> Fixture.TestSqlLoggerFactory.AssertBaseline(expected);
820+
821+
[ConditionalFact]
822+
public virtual void FullText_ContainsTable_queryable_simple()
823+
{
824+
using var ctx = Fixture.CreateContext();
825+
826+
827+
var query = from c in ctx.Set<Microsoft.EntityFrameworkCore.TestModels.Northwind.Customer>()
828+
join ct in EF.Functions.ContainsTable(ctx.Set<TestModels.Northwind.Customer>().Select(x => x.ContactName), "John")
829+
on c.CustomerID equals (string)ct.Key
830+
select new { c.ContactName, ct.Rank };
831+
832+
var result = query.ToList();
833+
AssertSql(
834+
"""
835+
SELECT [c].[ContactName], [c0].[Rank]
836+
FROM [Customers] AS [c]
837+
INNER JOIN CONTAINSTABLE([Customers], [ContactName], N'John') AS [c0] ON [c].[CustomerID] = [c0].[Key]
838+
""");
839+
}
820840
}

0 commit comments

Comments
 (0)