Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
42 changes: 42 additions & 0 deletions src/EFCore.SqlServer/Extensions/SqlServerDbFunctionsExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,48 @@ public static bool Contains(
string searchCondition)
=> throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(Contains)));

/// <summary>
/// Provides a mapping to the SQL Server CONTAINSTABLE full-text search function.
/// </summary>
/// <param name="_">The <see cref="DbFunctions" /> instance.</param>
/// <param name="property">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> ContainsTable(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move these to SqlServerQueryableExtensions, similar to RelationalQueryableExtensions.

this DbFunctions _,
object property,
string searchCondition)
=> throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(ContainsTable)));

/// <summary>
/// Provides a mapping to the SQL Server FREETEXTTABLE full-text search function.
/// </summary>
/// <param name="_">The <see cref="DbFunctions" /> instance.</param>
/// <param name="property">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> FreeTextTable(
this DbFunctions _,
object property,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this intentionally object as opposed to string? Is there a reason for that?

Also, is it possible for the full-text search column to be nullable? If so this parameter needs to be nullable too.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regarding the object type: I used object to stay consistent with other full-text extensions like SqlServerDbFunctionsExtensions.Contains, which takes an object property to allow the expression tree to capture the property access before it's translated. However, if the convention for Table-Valued Functions in EF Core prefers a specific type like string, I’m happy to change it.

Regarding nullability: Yes, full-text search columns can be nullable in SQL Server. I will update the parameter to be object? (or string?) to correctly reflect the provider's capabilities and avoid any potential issues with nullable properties

string freeText)
=> throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(FreeTextTable)));

/// <summary>
/// The result type for SQL Server full-text table-valued functions (CONTAINSTABLE / FREETEXTTABLE).
/// </summary>
public class FullTextTableResult
{
/// <summary>
/// The key of the row matched.
/// </summary>
public object Key { get; set; } = default!;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It isn't ideal for this to be an untyped object, but I can't really see a way for this to be generic over the key type... The key column is determined in the CREATE FULLTEXT INDEX DDL, and we have no way of flowing that as a generic parameter here.

I'm thinking it might be a good idea to accept the key type as a generic type parameter to the FreeTextTable/ContainsTable methods; another alternative is to accept an additional parameter on the functions where the user selects the key property - that would allow us to flow that type to the return type, but it would be a bit silly (if the user specifies anything other than the key property from CREATE FULLTEXT INDEX, the call fails).

Any thoughts on this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the Generic approach (ContainsTable<TKey>) is the best way to go.

It’s simple and explicit. The user knows their database schema best, so they can just provide the correct type for the Key. This also keeps the translation logic clean and avoids the extra work of trying to infer the type from other properties.

If you're okay with this, I will update the code to use <TKey> and make FullTextTableResult generic


/// <summary>
/// The ranking value assigned to the row.
/// </summary>
public int Rank { get; set; }
}

#endregion Full-text search

#region DateDiffYear
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics.CodeAnalysis;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Query.SqlExpressions;
using static Microsoft.EntityFrameworkCore.SqlServerDbFunctionsExtensions;

// ReSharper disable once CheckNamespace
namespace Microsoft.EntityFrameworkCore.SqlServer.Query.Internal;

/// <summary>
/// An expression that represents a SQL Server full-text table-valued function (e.g., CONTAINSTABLE).
/// </summary>
public class FullTextTableExpression : TableExpressionBase
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why introduce a new expression type? Is TableValuedFunctionExpression not suitable somehow?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason I didn't use TableValuedFunctionExpression is that its Print method calls VisitCollection(Arguments). This treats all arguments as standard SqlExpression nodes, which the ExpressionPrinter translates into SQL literals (quoted strings) or parameters.

For CONTAINSTABLE, the first two arguments must be raw identifiers (Table and Column). By using a custom expression, I can bypass the standard Visit logic and use expressionPrinter.Append(Column.TableAlias) to print the table name directly without quotes, ensuring the SQL is syntactically correct for SQL Server

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't need a whole new expression type for this. We should be able to use TableValuedFunctionExpression, with the first two arguments (the table and column names) just being SqlFragmentExpressions. We'd still need special handling in SqlServerQuerySqlGenerator to quote those names, but at least we wouldn't have an additional expression type.

{
/// <summary>
/// Creates a new instance of the <see cref="FullTextTableExpression" /> class.
/// </summary>
/// <param name="functionName">The name of the full-text function.</param>
/// <param name="column">The column to search.</param>
/// <param name="searchCondition">The search condition expression.</param>
/// <param name="alias">The table alias.</param>
public FullTextTableExpression(
string functionName,
ColumnExpression column,
SqlExpression searchCondition,
string alias)
: base(alias)
{
FunctionName = functionName;
Column = column;
SearchCondition = searchCondition;
}

/// <summary>
/// The name of the function (e.g. CONTAINSTABLE).
/// </summary>
public virtual string FunctionName { get; }

/// <summary>
/// The column being searched.
/// </summary>
public virtual ColumnExpression Column { get; }

/// <summary>
/// The search condition.
/// </summary>
public virtual SqlExpression SearchCondition { get; }

/// <inheritdoc />
protected override TableExpressionBase WithAnnotations(IReadOnlyDictionary<string, IAnnotation> annotations)
=> this;

/// <inheritdoc />
public override TableExpressionBase WithAlias(string newAlias)
=> new FullTextTableExpression(FunctionName, Column, SearchCondition, newAlias);

/// <inheritdoc />
protected override void Print(ExpressionPrinter expressionPrinter)
{
expressionPrinter.Append(FunctionName!).Append("(").Append(Column.TableAlias!).Append(", ");
expressionPrinter.Visit(Column);
expressionPrinter.Append(", ");
expressionPrinter.Visit(SearchCondition);
expressionPrinter.Append(") AS ").Append(Alias!);
}

/// <inheritdoc />
public override TableExpressionBase Quote()
=> this;

/// <inheritdoc />
public override TableExpressionBase Clone(string? alias, ExpressionVisitor visitor)
{
var newColumn = (ColumnExpression)visitor.Visit(Column);
var newSearchCondition = (SqlExpression)visitor.Visit(SearchCondition);

return new FullTextTableExpression(FunctionName, newColumn, newSearchCondition, alias ?? Alias!);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,9 @@ when tableExpression.FindAnnotation(SqlServerAnnotationNames.TemporalOperationTy

case SqlServerOpenJsonExpression openJsonExpression:
return VisitOpenJsonExpression(openJsonExpression);

case FullTextTableExpression fullTextTableExpression:
return VisitFullTextTable(fullTextTableExpression);
}

return base.VisitExtension(extensionExpression);
Expand Down Expand Up @@ -914,6 +917,28 @@ 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 virtual Expression VisitFullTextTable(FullTextTableExpression fullTextTableExpression)
{
Sql.Append(fullTextTableExpression.FunctionName)
.Append("(")
.Append(_sqlGenerationHelper.DelimitIdentifier(fullTextTableExpression.Column.TableAlias!))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that the first argument to these functions can only be a literal table name - not a table alias. For example, the following does not work:

SELECT *
FROM Flags AS f
WHERE (
    SELECT TOP(1) RANK
    FROM CONTAINSTABLE (f, FlagColors, 'Green or Black')
    ORDER BY RANK DESC) <> 0;

Where replacing f with a table name (Flags) does:

SELECT *
FROM Flags AS f
WHERE (
    SELECT TOP(1) RANK
    FROM CONTAINSTABLE (Flags, FlagColors, 'Green or Black')
    ORDER BY RANK DESC) <> 0;

We can use SqlFragmentExpression to represent the table and column names.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the clarification. You're right, CONTAINSTABLE requires the actual table name rather than the alias.

I will refactor the implementation to use SqlFragmentExpression for both the table and column names. This ensures they are treated as raw SQL fragments and bypasses the quoting issues we discussed. I'll also make sure to pull the base table name from the metadata instead of using the alias

.Append(", ");
Visit(fullTextTableExpression.Column);
Sql.Append(", ");
Visit(fullTextTableExpression.SearchCondition);
Sql.Append(")")
.Append(AliasSeparator)
.Append(_sqlGenerationHelper.DelimitIdentifier(fullTextTableExpression.Alias!));

return fullTextTableExpression;
}

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,79 @@ 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;

// Handle ContainsTable and FreeTextTable table-valued functions
if (method.DeclaringType == typeof(SqlServerDbFunctionsExtensions)
&& (method.Name == nameof(SqlServerDbFunctionsExtensions.ContainsTable)
|| method.Name == nameof(SqlServerDbFunctionsExtensions.FreeTextTable)))
{
var functionName = method.Name == nameof(SqlServerDbFunctionsExtensions.ContainsTable)
? "CONTAINSTABLE"
: "FREETEXTTABLE";

// Translate the arguments
var propertyReference = TranslateExpression(methodCallExpression.Arguments[1]);
if (propertyReference is not ColumnExpression columnExpression)
{
throw new InvalidOperationException(SqlServerStrings.InvalidColumnNameForFreeText);
}

var searchCondition = TranslateExpression(methodCallExpression.Arguments[2]);
if (searchCondition == null)
{
AddTranslationErrorDetails(
CoreStrings.TranslationFailed(methodCallExpression.Arguments[2].Print()));
return QueryCompilationContext.NotTranslatedExpression;
}

// Create the FullTextTableExpression
var alias = _queryCompilationContext.SqlAliasManager.GenerateTableAlias("ct");
var fullTextTableExpression = new FullTextTableExpression(functionName, columnExpression, searchCondition, alias);

// Create columns for Key and Rank (CONTAINSTABLE/FREETEXTTABLE return these columns)
var resultType = typeof(SqlServerDbFunctionsExtensions.FullTextTableResult);
var keyColumn = new ColumnExpression("Key", alias, typeof(object), null, nullable: false);
var rankColumn = new ColumnExpression("Rank", alias, typeof(int), _typeMappingSource.FindMapping(typeof(int)), nullable: false);

// Create SelectExpression with Key as the main projection
// Use Key as identifier since it's the primary key from CONTAINSTABLE/FREETEXTTABLE
var keyTypeMapping = _typeMappingSource.FindMapping(typeof(object));
#pragma warning disable EF1001 // Internal EF Core API usage.
var selectExpression = new SelectExpression(
[fullTextTableExpression],
keyColumn,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is wrong - the select projects out both the key and the column; down below you're also mixing two projection type (this is a complicated and messy part of SelectExpression currently). Instantiate a SelectExpression without any projections, then call ReplaceProjection() with a dictionary argument to provide the two projections.

[(keyColumn, keyTypeMapping?.Comparer ?? ValueComparer.CreateDefault(typeof(object), favorStructuralComparisons: false))],
_queryCompilationContext.SqlAliasManager);
#pragma warning restore EF1001 // Internal EF Core API usage.

// Add Rank to projection
var rankIndex = selectExpression.AddToProjection(rankColumn);

// Create shaper expression for FullTextTableResult
// Key is in the projection mapping (main projection), Rank is at rankIndex in the projection list
var keyBinding = new ProjectionBindingExpression(selectExpression, new ProjectionMember(), typeof(object));
var rankBinding = new ProjectionBindingExpression(selectExpression, rankIndex, typeof(int));

var shaperExpression = Expression.New(
resultType.GetConstructor([typeof(object), typeof(int)])!,
keyBinding,
rankBinding);

return new ShapedQueryExpression(selectExpression, 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 @@ -817,4 +817,24 @@ protected override DbParameter CreateDbParameter(string name, object value)

private void AssertSql(params string[] expected)
=> Fixture.TestSqlLoggerFactory.AssertBaseline(expected);

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove

[ConditionalFact]
public virtual void FullText_ContainsTable_queryable_simple()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please move tests to NorthwindDbFunctionsQuerySqlServerTest alongside the existing full-text search tests (unless there's a specific reason not to).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will move them

Copy link
Member

@roji roji Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need a lot more testing here - one simple test isn't enough.

{
using var ctx = Fixture.CreateContext();


var query = from c in ctx.Set<Microsoft.EntityFrameworkCore.TestModels.Northwind.Customer>()
join ct in EF.Functions.ContainsTable(ctx.Set<TestModels.Northwind.Customer>().Select(x => x.ContactName), "John")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requiring invokers to do ctx.Set<T>().Select() to get the property like this is problematic:

  • It isn't a good dev experience.
  • If the user diverges from this pattern in any way (e.g. projecting something other than a simple property), this will fail.
  • What happens if there's a Where before the Select? I think your code above would just ignore that (I may be wrong). In any case, these functions do not support pre-filtering or any other operator - they accept a literal table name as the source only.

To avoid all the above problems, we should instead try to make ContainsTable an extension method over DbSet; its source (first argument) would thus represent the table, and it would accept a lambda selector for the full-text property to be searched (and of course, the actual text to be searched):

var query =
    from c in ctx.Set<Customer>()
    join ct in ctx.Set<Customer>().ContainsTable(c => c.ContactName, "John")
    ....

Makes sense?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure. I’ll refactor the implementation to move ContainsTable and FreeTextTable as extension methods over IQueryable<TEntity> and update the visitor to handle this new pattern

on c.CustomerID equals (string)ct.Key
select new { c.ContactName, ct.Rank };

var result = query.ToList();
AssertSql(
"""
SELECT [c].[ContactName], [c0].[Rank]
FROM [Customers] AS [c]
INNER JOIN CONTAINSTABLE([Customers], [ContactName], N'John') AS [c0] ON [c].[CustomerID] = [c0].[Key]
""");
}
}
Loading