Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
88 changes: 88 additions & 0 deletions src/EFCore.SqlServer/Extensions/SqlServerQueryableExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

// 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
{
/// <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>(
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 two SQL Server functions accept two additional parameters: language and top_n_by_rank.

this IQueryable<TEntity> source,
Expression<Func<TEntity, object>> propertySelector,
string searchCondition)
where TEntity : class
=> throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(ContainsTable)));

/// <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
=> throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(FreeTextTable)));

/// <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>
Copy link
Member

Choose a reason for hiding this comment

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

Make this a readonly struct, as it's a very think wrapper that can be immutable.

{
/// <summary>
/// Initializes a new instance of the <see cref="FullTextTableResult{TKey}" /> class.
/// </summary>
/// <param name="key">The key of the row matched.</param>
/// <param name="rank">The ranking value assigned to the row.</param>
public FullTextTableResult(TKey key, int rank)
Copy link
Member

Choose a reason for hiding this comment

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

nit: use primary constructor

{
Key = key;
Rank = rank;
}

/// <summary>
/// The key of the row matched.
/// </summary>
public TKey Key { get; set; } = default!;

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

Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// 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
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="tableFragment">The SQL fragment representing the raw table name.</param>
/// <param name="columnFragment">The SQL fragment representing the raw column name.</param>
/// <param name="searchCondition">The search condition expression.</param>
/// <param name="alias">The table alias.</param>
public FullTextTableExpression(
string functionName,
SqlFragmentExpression tableFragment,
SqlFragmentExpression columnFragment,
SqlExpression searchCondition,
string alias)
: base(alias)
{
FunctionName = functionName;
TableFragment = tableFragment;
ColumnFragment = columnFragment;
SearchCondition = searchCondition;
}

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

/// <summary>
/// The SQL fragment representing the raw table name.
/// </summary>
public virtual SqlFragmentExpression TableFragment { get; }

/// <summary>
/// The SQL fragment representing the raw column name.
/// </summary>
public virtual SqlFragmentExpression ColumnFragment { get; }

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

/// <inheritdoc />
protected override TableExpressionBase WithAnnotations(IReadOnlyDictionary<string, IAnnotation> annotations)
=> new FullTextTableExpression(FunctionName, TableFragment, ColumnFragment, SearchCondition, Alias!);

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

/// <inheritdoc />
protected override void Print(ExpressionPrinter expressionPrinter)
{
expressionPrinter.Append(FunctionName).Append("(");
expressionPrinter.Visit(TableFragment);
expressionPrinter.Append(", ");
expressionPrinter.Visit(ColumnFragment);
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)
{
return new FullTextTableExpression(
FunctionName,
(SqlFragmentExpression)visitor.Visit(TableFragment),
(SqlFragmentExpression)visitor.Visit(ColumnFragment),
(SqlExpression)visitor.Visit(SearchCondition),
(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,30 @@ 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("(");
Visit(fullTextTableExpression.TableFragment);

Sql.Append(", ");
Visit(fullTextTableExpression.ColumnFragment);

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,92 @@ 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(SqlServerQueryableExtensions)
&& (method.Name == nameof(SqlServerQueryableExtensions.ContainsTable)
|| method.Name == nameof(SqlServerQueryableExtensions.FreeTextTable)))
{
var functionName = method.Name == nameof(SqlServerQueryableExtensions.ContainsTable)
? "CONTAINSTABLE"
: "FREETEXTTABLE";

var source = Visit(methodCallExpression.Arguments[0]);
if (source is not ShapedQueryExpression shapedQuery
|| shapedQuery.QueryExpression is not SelectExpression sourceSelectExpression)
{
return QueryCompilationContext.NotTranslatedExpression;
}

var lambda = methodCallExpression.Arguments[1].UnwrapLambdaFromQuote();
Copy link
Member

Choose a reason for hiding this comment

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

Instead of referencing methodCallExpression.Arguments throughout, use collection pattern matching at the top when identifying the methods and give them a proper variable name at that point.

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.Tables.Count == 0
|| sourceSelectExpression.Tables[0] is not TableExpression tableExpression)
Copy link
Member

Choose a reason for hiding this comment

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

It's not enough to just extract the first table; we need to validate that the source select is completely empty: the moment it has e.g. a WHERE clause (or anything else) this needs to fail, as this would ignore that WHERE and produce incorrect results.

{
return QueryCompilationContext.NotTranslatedExpression;
}
var tableName = tableExpression.Name;
var columnName = columnExpression.Name;
var searchCondition = TranslateExpression(methodCallExpression.Arguments[2]);
if (searchCondition == null)
{
AddTranslationErrorDetails(CoreStrings.TranslationFailed(methodCallExpression.Arguments[2].Print()));
return QueryCompilationContext.NotTranslatedExpression;
}
var keyType = method.GetGenericArguments()[1]; // TKey is the second generic argument
var alias = _queryCompilationContext.SqlAliasManager.GenerateTableAlias("ft");
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
var alias = _queryCompilationContext.SqlAliasManager.GenerateTableAlias("ft");
var alias = _queryCompilationContext.SqlAliasManager.GenerateTableAlias("fts");

var tableFragment = new SqlFragmentExpression($"[{tableName}]");
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 - identifier delimitation (the brackets) needs to be applied in query generation, not here in translation, as it's a SQL syntax thing (that's where we apply this for all other table/column names. Your code notably is missing escaping logic, so if a table name contains the ] character this won't escape it.

var columnFragment = new SqlFragmentExpression($"[{columnName}]");
var fullTextTableExpression = new FullTextTableExpression(
functionName,
tableFragment,
columnFragment,
searchCondition,
alias);
var resultType = typeof(SqlServerQueryableExtensions.FullTextTableResult<>).MakeGenericType(keyType);
Copy link
Member

Choose a reason for hiding this comment

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

Do not call MakeGenericType, as it's incompatible with NativeAOT, which we're starting to move towards. You should already have the correct return type on the method in the inputted MethodCallExpression.

var keyTypeMapping = _typeMappingSource.FindMapping(keyType);
var keyColumn = new ColumnExpression("Key", alias, keyType, keyTypeMapping, nullable: false);
Copy link
Member

Choose a reason for hiding this comment

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

Call the columns KEY and RANK (uppercase) as per the documentation.

var rankColumn = new ColumnExpression("Rank", alias, typeof(int), _typeMappingSource.FindMapping(typeof(int)), nullable: false);

#pragma warning disable EF1001

var newSelectExpression = 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(keyType, favorStructuralComparisons: false))],
_queryCompilationContext.SqlAliasManager);

#pragma warning restore EF1001
var rankIndex = newSelectExpression.AddToProjection(rankColumn);
var keyBinding = new ProjectionBindingExpression(newSelectExpression, new ProjectionMember(), keyType);
var rankBinding = new ProjectionBindingExpression(newSelectExpression, rankIndex, 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
Loading
Loading