Skip to content

[release/9.0-staging] Cosmos Full Text Search support (query part) #35909

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Apr 14, 2025
Merged
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
1 change: 1 addition & 0 deletions src/EFCore.Analyzers/EFDiagnostics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ public static class EFDiagnostics
public const string MetricsExperimental = "EF9101";
public const string PagingExperimental = "EF9102";
public const string CosmosVectorSearchExperimental = "EF9103";
public const string CosmosFullTextSearchExperimental = "EF9104";
}
1 change: 1 addition & 0 deletions src/EFCore.Cosmos/EFCore.Cosmos.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<NoWarn>$(NoWarn);EF9101</NoWarn> <!-- Metrics is experimental -->
<NoWarn>$(NoWarn);EF9102</NoWarn> <!-- Paging is experimental -->
<NoWarn>$(NoWarn);EF9103</NoWarn> <!-- Vector search is experimental -->
<NoWarn>$(NoWarn);EF9104</NoWarn> <!-- Full-text search is experimental -->
</PropertyGroup>

<ItemGroup>
Expand Down
54 changes: 54 additions & 0 deletions src/EFCore.Cosmos/Extensions/CosmosDbFunctionsExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,60 @@ public static T CoalesceUndefined<T>(
T expression2)
=> throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(CoalesceUndefined)));

/// <summary>
/// Checks if the specified property contains the given keyword using full-text search.
/// </summary>
/// <param name="_">The <see cref="DbFunctions" /> instance.</param>
/// <param name="property">The property to search.</param>
/// <param name="keyword">The keyword to search for.</param>
/// <returns><see langword="true" /> if the property contains the keyword; otherwise, <see langword="false" />.</returns>
[Experimental(EFDiagnostics.CosmosFullTextSearchExperimental)]
public static bool FullTextContains(this DbFunctions _, string property, string keyword)
=> throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(FullTextContains)));

/// <summary>
/// Checks if the specified property contains all the given keywords using full-text search.
/// </summary>
/// <param name="_">The <see cref="DbFunctions" /> instance.</param>
/// <param name="property">The property to search.</param>
/// <param name="keywords">The keywords to search for.</param>
/// <returns><see langword="true" /> if the property contains all the keywords; otherwise, <see langword="false" />.</returns>
[Experimental(EFDiagnostics.CosmosFullTextSearchExperimental)]
public static bool FullTextContainsAll(this DbFunctions _, string property, params string[] keywords)
=> throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(FullTextContainsAll)));

/// <summary>
/// Checks if the specified property contains any of the given keywords using full-text search.
/// </summary>
/// <param name="_">The <see cref="DbFunctions" /> instance.</param>
/// <param name="property">The property to search.</param>
/// <param name="keywords">The keywords to search for.</param>
/// <returns><see langword="true" /> if the property contains any of the keywords; otherwise, <see langword="false" />.</returns>
[Experimental(EFDiagnostics.CosmosFullTextSearchExperimental)]
public static bool FullTextContainsAny(this DbFunctions _, string property, params string[] keywords)
=> throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(FullTextContainsAny)));

/// <summary>
/// Returns the full-text search score for the specified property and keywords.
/// </summary>
/// <param name="_">The <see cref="DbFunctions" /> instance.</param>
/// <param name="property">The property to search.</param>
/// <param name="keywords">The keywords to search for.</param>
/// <returns>The full-text search score.</returns>
[Experimental(EFDiagnostics.CosmosFullTextSearchExperimental)]
public static double FullTextScore(this DbFunctions _, string property, string[] keywords)
=> throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(FullTextScore)));

/// <summary>
/// Combines scores provided by two or more specified functions.
/// </summary>
/// <param name="_">The <see cref="DbFunctions" /> instance.</param>
/// <param name="functions">The functions to compute the score for.</param>
/// <returns>The combined score.</returns>
[Experimental(EFDiagnostics.CosmosFullTextSearchExperimental)]
public static double Rrf(this DbFunctions _, params double[] functions)
=> throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(Rrf)));

/// <summary>
/// Returns the distance between two vectors, using the distance function and data type defined using
/// <see
Expand Down
22 changes: 22 additions & 0 deletions src/EFCore.Cosmos/Properties/CosmosStrings.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions src/EFCore.Cosmos/Properties/CosmosStrings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,15 @@
<data name="OneOfTwoValuesMustBeSet" xml:space="preserve">
<value>Exactly one of '{param1}' or '{param2}' must be set.</value>
</data>
<data name="OrderByDescendingScoringFunction" xml:space="preserve">
<value>Ordering based on scoring function is not supported inside '{orderByDescending}'. Use '{orderBy}' instead.</value>
</data>
<data name="OrderByMultipleScoringFunctionWithoutRrf" xml:space="preserve">
<value>Only one ordering using scoring function is allowed. Use 'EF.Functions.{rrf}' method to combine multiple scoring functions.</value>
</data>
<data name="OrderByScoringFunctionMixedWithRegularOrderby" xml:space="preserve">
<value>Ordering using a scoring function is mutually exclusive with other forms of ordering.</value>
</data>
<data name="OrphanedNestedDocument" xml:space="preserve">
<value>The entity of type '{entityType}' is mapped as a part of the document mapped to '{missingEntityType}', but there is no tracked entity of this type with the corresponding key value. Consider using 'DbContextOptionsBuilder.EnableSensitiveDataLogging' to see the key values.</value>
</data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ public CosmosMethodCallTranslatorProvider(
new CosmosRegexTranslator(sqlExpressionFactory),
new CosmosStringMethodTranslator(sqlExpressionFactory),
new CosmosTypeCheckingTranslator(sqlExpressionFactory),
new CosmosVectorSearchTranslator(sqlExpressionFactory, typeMappingSource)
new CosmosVectorSearchTranslator(sqlExpressionFactory, typeMappingSource),
new CosmosFullTextSearchTranslator(sqlExpressionFactory, typeMappingSource)
//new LikeTranslator(sqlExpressionFactory),
//new EnumHasFlagTranslator(sqlExpressionFactory),
//new GetValueOrDefaultTranslator(sqlExpressionFactory),
Expand Down
12 changes: 12 additions & 0 deletions src/EFCore.Cosmos/Query/Internal/CosmosQuerySqlGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ namespace Microsoft.EntityFrameworkCore.Cosmos.Query.Internal;
/// </summary>
public class CosmosQuerySqlGenerator(ITypeMappingSource typeMappingSource) : SqlExpressionVisitor
{
private static readonly bool UseOldBehavior35476 =
AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue35476", out var enabled35476) && enabled35476;

private readonly IndentedStringBuilder _sqlBuilder = new();
private IReadOnlyDictionary<string, object> _parameterValues = null!;
private List<SqlParameter> _sqlParameters = null!;
Expand Down Expand Up @@ -341,6 +344,15 @@ protected override Expression VisitSelect(SelectExpression selectExpression)
{
_sqlBuilder.AppendLine().Append("ORDER BY ");

var orderByScoringFunction = selectExpression.Orderings is [{ Expression: SqlFunctionExpression { IsScoringFunction: true } }];
if (!UseOldBehavior35476 && orderByScoringFunction)
{
_sqlBuilder.Append("RANK ");
}

Check.DebugAssert(UseOldBehavior35476 || orderByScoringFunction || selectExpression.Orderings.All(x => x.Expression is not SqlFunctionExpression { IsScoringFunction: true }),
"Scoring function can only appear as first (and only) ordering, or not at all.");

GenerateList(selectExpression.Orderings, e => Visit(e));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,48 +9,106 @@ namespace Microsoft.EntityFrameworkCore.Cosmos.Query.Internal;

public partial class CosmosShapedQueryCompilingExpressionVisitor
{
private sealed class InExpressionValuesExpandingExpressionVisitor(
private static readonly bool UseOldBehavior35476 =
AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue35476", out var enabled35476) && enabled35476;

private sealed class ParameterInliner(
ISqlExpressionFactory sqlExpressionFactory,
IReadOnlyDictionary<string, object> parametersValues)
: ExpressionVisitor
{
protected override Expression VisitExtension(Expression expression)
{
if (expression is InExpression inExpression)
if (!UseOldBehavior35476)
{
IReadOnlyList<SqlExpression> values;
expression = base.VisitExtension(expression);
}

switch (inExpression)
switch (expression)
{
// Inlines array parameter of InExpression, transforming: 'item IN (@valuesArray)' to: 'item IN (value1, value2)'
case InExpression inExpression:
{
case { Values: IReadOnlyList<SqlExpression> values2 }:
values = values2;
break;
IReadOnlyList<SqlExpression> values;

// TODO: IN with subquery (return immediately, nothing to do here)

case { ValuesParameter: SqlParameterExpression valuesParameter }:
switch (inExpression)
{
var typeMapping = valuesParameter.TypeMapping;
var mutableValues = new List<SqlExpression>();
foreach (var value in (IEnumerable)parametersValues[valuesParameter.Name])
case { Values: IReadOnlyList<SqlExpression> values2 }:
values = values2;
break;

// TODO: IN with subquery (return immediately, nothing to do here)

case { ValuesParameter: SqlParameterExpression valuesParameter }:
{
mutableValues.Add(sqlExpressionFactory.Constant(value, value?.GetType() ?? typeof(object), typeMapping));
var typeMapping = valuesParameter.TypeMapping;
var mutableValues = new List<SqlExpression>();
foreach (var value in (IEnumerable)parametersValues[valuesParameter.Name])
{
mutableValues.Add(sqlExpressionFactory.Constant(value, value?.GetType() ?? typeof(object), typeMapping));
}

values = mutableValues;
break;
}

values = mutableValues;
break;
default:
throw new UnreachableException();
}

default:
throw new UnreachableException();
return values.Count == 0
? sqlExpressionFactory.ApplyDefaultTypeMapping(sqlExpressionFactory.Constant(false))
: sqlExpressionFactory.In((SqlExpression)Visit(inExpression.Item), values);
}

return values.Count == 0
? sqlExpressionFactory.ApplyDefaultTypeMapping(sqlExpressionFactory.Constant(false))
: sqlExpressionFactory.In((SqlExpression)Visit(inExpression.Item), values);
}
// Converts Offset and Limit parameters to constants when ORDER BY RANK is detected in the SelectExpression (i.e. we order by scoring function)
// Cosmos only supports constants in Offset and Limit for this scenario currently (ORDER BY RANK limitation)
case SelectExpression { Orderings: [{ Expression: SqlFunctionExpression { IsScoringFunction: true } }], Limit: var limit, Offset: var offset } hybridSearch
when !UseOldBehavior35476 && (limit is SqlParameterExpression || offset is SqlParameterExpression):
{
if (hybridSearch.Limit is SqlParameterExpression limitPrm)
{
hybridSearch.ApplyLimit(
sqlExpressionFactory.Constant(
parametersValues[limitPrm.Name],
limitPrm.TypeMapping));
}

if (hybridSearch.Offset is SqlParameterExpression offsetPrm)
{
hybridSearch.ApplyOffset(
sqlExpressionFactory.Constant(
parametersValues[offsetPrm.Name],
offsetPrm.TypeMapping));
}

return base.VisitExtension(expression);
}

// Inlines array parameter of full-text functions, transforming FullTextContains(x, @keywordsArray) to FullTextContains(x, keyword1, keyword2))
case SqlFunctionExpression
{
Name: "FullTextContainsAny" or "FullTextContainsAll",
Arguments: [var property, SqlParameterExpression { TypeMapping: { ElementTypeMapping: var elementTypeMapping } } keywords]
} fullTextContainsAllAnyFunction
when !UseOldBehavior35476:
{
var keywordValues = new List<SqlExpression>();
foreach (var value in (IEnumerable)parametersValues[keywords.Name])
{
keywordValues.Add(sqlExpressionFactory.Constant(value, typeof(string), elementTypeMapping));
}

return base.VisitExtension(expression);
return sqlExpressionFactory.Function(
fullTextContainsAllAnyFunction.Name,
[property, .. keywordValues],
fullTextContainsAllAnyFunction.Type,
fullTextContainsAllAnyFunction.TypeMapping);
}

default:
return expression;
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ public IAsyncEnumerator<CosmosPage<T>> GetAsyncEnumerator(CancellationToken canc

private CosmosSqlQuery GenerateQuery()
=> _querySqlGeneratorFactory.Create().GetSqlQuery(
(SelectExpression)new InExpressionValuesExpandingExpressionVisitor(
(SelectExpression)new ParameterInliner(
_sqlExpressionFactory,
_cosmosQueryContext.ParameterValues)
.Visit(_selectExpression),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ IEnumerator IEnumerable.GetEnumerator()

private CosmosSqlQuery GenerateQuery()
=> _querySqlGeneratorFactory.Create().GetSqlQuery(
(SelectExpression)new InExpressionValuesExpandingExpressionVisitor(
(SelectExpression)new ParameterInliner(
_sqlExpressionFactory,
_cosmosQueryContext.ParameterValues)
.Visit(_selectExpression),
Expand Down
28 changes: 28 additions & 0 deletions src/EFCore.Cosmos/Query/Internal/Expressions/SelectExpression.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.EntityFrameworkCore.Cosmos.Extensions;
using Microsoft.EntityFrameworkCore.Cosmos.Internal;
using Microsoft.EntityFrameworkCore.Internal;

Expand All @@ -16,6 +17,9 @@ namespace Microsoft.EntityFrameworkCore.Cosmos.Query.Internal;
[DebuggerDisplay("{PrintShortSql(), nq}")]
public sealed class SelectExpression : Expression, IPrintableExpression
{
private static readonly bool UseOldBehavior35476 =
AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue35476", out var enabled35476) && enabled35476;

private IDictionary<ProjectionMember, Expression> _projectionMapping = new Dictionary<ProjectionMember, Expression>();
private readonly List<SourceExpression> _sources = [];
private readonly List<ProjectionExpression> _projection = [];
Expand Down Expand Up @@ -381,6 +385,12 @@ public void ApplyOffset(SqlExpression sqlExpression)
/// </summary>
public void ApplyOrdering(OrderingExpression orderingExpression)
{
if (!UseOldBehavior35476 && orderingExpression is { Expression: SqlFunctionExpression { IsScoringFunction: true }, IsAscending: false })
{
throw new InvalidOperationException(
CosmosStrings.OrderByDescendingScoringFunction(nameof(Queryable.OrderByDescending), nameof(Queryable.OrderBy)));
}

_orderings.Clear();
_orderings.Add(orderingExpression);
}
Expand All @@ -393,6 +403,19 @@ public void ApplyOrdering(OrderingExpression orderingExpression)
/// </summary>
public void AppendOrdering(OrderingExpression orderingExpression)
{
if (!UseOldBehavior35476 && _orderings.Count > 0)
{
var existingScoringFunctionOrdering = _orderings is [{ Expression: SqlFunctionExpression { IsScoringFunction: true } }];
var appendingScoringFunctionOrdering = orderingExpression.Expression is SqlFunctionExpression { IsScoringFunction: true };
if (appendingScoringFunctionOrdering || existingScoringFunctionOrdering)
{
throw new InvalidOperationException(
appendingScoringFunctionOrdering && existingScoringFunctionOrdering
? CosmosStrings.OrderByMultipleScoringFunctionWithoutRrf(nameof(CosmosDbFunctionsExtensions.Rrf))
: CosmosStrings.OrderByScoringFunctionMixedWithRegularOrderby);
}
}

if (_orderings.FirstOrDefault(o => o.Expression.Equals(orderingExpression.Expression)) == null)
{
_orderings.Add(orderingExpression);
Expand Down Expand Up @@ -752,6 +775,11 @@ private void PrintSql(ExpressionPrinter expressionPrinter, bool withTags = true)
if (Orderings.Any())
{
expressionPrinter.AppendLine().Append("ORDER BY ");
if (!UseOldBehavior35476 && Orderings is [{ Expression: SqlFunctionExpression { IsScoringFunction: true } }])
{
expressionPrinter.Append("RANK ");
}

expressionPrinter.VisitCollection(Orderings);
}

Expand Down
Loading