Skip to content

Commit 1c96bce

Browse files
authored
[release/9.0-staging] Cosmos Full Text Search support (query part) (#35909)
* Limited port of #35868 Fixes #35476 Fixes #35853 (need to fix this one, otherwise vector translator will try to translate full text methods and fail) Description This PR enables full-text search queries using EF Core 9 when targeting Azure Cosmos Db. This is one of the flagship new features for Cosmos and the aim here is to help with it's adoption. This is very limited port of the full feature we are adding in EF 10 Preview 4. We are only adding querying capabilities: function stubs for FullTextContains, FullTextContainsAll, FullTextContainsAny, FullTextScore and RRF as well as logic translating these signatures to built-in Cosmos functions. No model building or data manipulation - containers need to be created outside EF (using Cosmos SDK or in the Data Explorer). Customer impact Customers will be able to use the upcoming full text search capabilities when working with Azure Cosmos Db without the need to upgrade to EF 10 preview. How found Partner team ask. Regression No Testing Extensively tested on EF 10, manual testing on EF9. End-to-end testing is not possible because we can't create containers programmatically (no support for it inside EF Core itself, and the Cosmos SDK which supports it is currently only available in beta, so we can't take dependency on it). Instead, we created containers and data using EF 10, ported all the query tests from EF 10 and ran them using the EF9 bits. Risk Low. Code here is purely additive and actually localized to only a handful of places in the code: validation in ApplyOrdering/AppendOrdering, FTS method translator, parameter inliner and sql generator. Feature is marked as experimental and quirks have been added. * fix FTContainsAll/Any for constants, change TFScore signature to accept params, fixed SqlFunctionExpression to use the new ctor internally, added mandatory property overrides for FragmentExpression (vector search) fixed parameter inliner to not match parameter of type string, but only string[] when processing ContainsAll/Any
1 parent 8e275d1 commit 1c96bce

15 files changed

+403
-30
lines changed

Diff for: src/EFCore.Analyzers/EFDiagnostics.cs

+1
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,5 @@ public static class EFDiagnostics
1919
public const string MetricsExperimental = "EF9101";
2020
public const string PagingExperimental = "EF9102";
2121
public const string CosmosVectorSearchExperimental = "EF9103";
22+
public const string CosmosFullTextSearchExperimental = "EF9104";
2223
}

Diff for: src/EFCore.Cosmos/EFCore.Cosmos.csproj

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
<NoWarn>$(NoWarn);EF9101</NoWarn> <!-- Metrics is experimental -->
1313
<NoWarn>$(NoWarn);EF9102</NoWarn> <!-- Paging is experimental -->
1414
<NoWarn>$(NoWarn);EF9103</NoWarn> <!-- Vector search is experimental -->
15+
<NoWarn>$(NoWarn);EF9104</NoWarn> <!-- Full-text search is experimental -->
1516
</PropertyGroup>
1617

1718
<ItemGroup>

Diff for: src/EFCore.Cosmos/Extensions/CosmosDbFunctionsExtensions.cs

+54
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,60 @@ public static T CoalesceUndefined<T>(
5252
T expression2)
5353
=> throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(CoalesceUndefined)));
5454

55+
/// <summary>
56+
/// Checks if the specified property contains the given keyword using full-text search.
57+
/// </summary>
58+
/// <param name="_">The <see cref="DbFunctions" /> instance.</param>
59+
/// <param name="property">The property to search.</param>
60+
/// <param name="keyword">The keyword to search for.</param>
61+
/// <returns><see langword="true" /> if the property contains the keyword; otherwise, <see langword="false" />.</returns>
62+
[Experimental(EFDiagnostics.CosmosFullTextSearchExperimental)]
63+
public static bool FullTextContains(this DbFunctions _, string property, string keyword)
64+
=> throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(FullTextContains)));
65+
66+
/// <summary>
67+
/// Checks if the specified property contains all the given keywords using full-text search.
68+
/// </summary>
69+
/// <param name="_">The <see cref="DbFunctions" /> instance.</param>
70+
/// <param name="property">The property to search.</param>
71+
/// <param name="keywords">The keywords to search for.</param>
72+
/// <returns><see langword="true" /> if the property contains all the keywords; otherwise, <see langword="false" />.</returns>
73+
[Experimental(EFDiagnostics.CosmosFullTextSearchExperimental)]
74+
public static bool FullTextContainsAll(this DbFunctions _, string property, params string[] keywords)
75+
=> throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(FullTextContainsAll)));
76+
77+
/// <summary>
78+
/// Checks if the specified property contains any of the given keywords using full-text search.
79+
/// </summary>
80+
/// <param name="_">The <see cref="DbFunctions" /> instance.</param>
81+
/// <param name="property">The property to search.</param>
82+
/// <param name="keywords">The keywords to search for.</param>
83+
/// <returns><see langword="true" /> if the property contains any of the keywords; otherwise, <see langword="false" />.</returns>
84+
[Experimental(EFDiagnostics.CosmosFullTextSearchExperimental)]
85+
public static bool FullTextContainsAny(this DbFunctions _, string property, params string[] keywords)
86+
=> throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(FullTextContainsAny)));
87+
88+
/// <summary>
89+
/// Returns the full-text search score for the specified property and keywords.
90+
/// </summary>
91+
/// <param name="_">The <see cref="DbFunctions" /> instance.</param>
92+
/// <param name="property">The property to score.</param>
93+
/// <param name="keywords">The keywords to score by.</param>
94+
/// <returns>The full-text search score.</returns>
95+
[Experimental(EFDiagnostics.CosmosFullTextSearchExperimental)]
96+
public static double FullTextScore(this DbFunctions _, string property, params string[] keywords)
97+
=> throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(FullTextScore)));
98+
99+
/// <summary>
100+
/// Combines scores provided by two or more specified functions.
101+
/// </summary>
102+
/// <param name="_">The <see cref="DbFunctions" /> instance.</param>
103+
/// <param name="functions">The functions to compute the score for.</param>
104+
/// <returns>The combined score.</returns>
105+
[Experimental(EFDiagnostics.CosmosFullTextSearchExperimental)]
106+
public static double Rrf(this DbFunctions _, params double[] functions)
107+
=> throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(Rrf)));
108+
55109
/// <summary>
56110
/// Returns the distance between two vectors, using the distance function and data type defined using
57111
/// <see

Diff for: src/EFCore.Cosmos/Properties/CosmosStrings.Designer.cs

+22
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: src/EFCore.Cosmos/Properties/CosmosStrings.resx

+9
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,15 @@
283283
<data name="OneOfTwoValuesMustBeSet" xml:space="preserve">
284284
<value>Exactly one of '{param1}' or '{param2}' must be set.</value>
285285
</data>
286+
<data name="OrderByDescendingScoringFunction" xml:space="preserve">
287+
<value>Ordering based on scoring function is not supported inside '{orderByDescending}'. Use '{orderBy}' instead.</value>
288+
</data>
289+
<data name="OrderByMultipleScoringFunctionWithoutRrf" xml:space="preserve">
290+
<value>Only one ordering using scoring function is allowed. Use 'EF.Functions.{rrf}' method to combine multiple scoring functions.</value>
291+
</data>
292+
<data name="OrderByScoringFunctionMixedWithRegularOrderby" xml:space="preserve">
293+
<value>Ordering using a scoring function is mutually exclusive with other forms of ordering.</value>
294+
</data>
286295
<data name="OrphanedNestedDocument" xml:space="preserve">
287296
<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>
288297
</data>

Diff for: src/EFCore.Cosmos/Query/Internal/CosmosMethodCallTranslatorProvider.cs

+2-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ public CosmosMethodCallTranslatorProvider(
3636
new CosmosRegexTranslator(sqlExpressionFactory),
3737
new CosmosStringMethodTranslator(sqlExpressionFactory),
3838
new CosmosTypeCheckingTranslator(sqlExpressionFactory),
39-
new CosmosVectorSearchTranslator(sqlExpressionFactory, typeMappingSource)
39+
new CosmosVectorSearchTranslator(sqlExpressionFactory, typeMappingSource),
40+
new CosmosFullTextSearchTranslator(sqlExpressionFactory, typeMappingSource)
4041
//new LikeTranslator(sqlExpressionFactory),
4142
//new EnumHasFlagTranslator(sqlExpressionFactory),
4243
//new GetValueOrDefaultTranslator(sqlExpressionFactory),

Diff for: src/EFCore.Cosmos/Query/Internal/CosmosQuerySqlGenerator.cs

+12
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ namespace Microsoft.EntityFrameworkCore.Cosmos.Query.Internal;
1414
/// </summary>
1515
public class CosmosQuerySqlGenerator(ITypeMappingSource typeMappingSource) : SqlExpressionVisitor
1616
{
17+
private static readonly bool UseOldBehavior35476 =
18+
AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue35476", out var enabled35476) && enabled35476;
19+
1720
private readonly IndentedStringBuilder _sqlBuilder = new();
1821
private IReadOnlyDictionary<string, object> _parameterValues = null!;
1922
private List<SqlParameter> _sqlParameters = null!;
@@ -341,6 +344,15 @@ protected override Expression VisitSelect(SelectExpression selectExpression)
341344
{
342345
_sqlBuilder.AppendLine().Append("ORDER BY ");
343346

347+
var orderByScoringFunction = selectExpression.Orderings is [{ Expression: SqlFunctionExpression { IsScoringFunction: true } }];
348+
if (!UseOldBehavior35476 && orderByScoringFunction)
349+
{
350+
_sqlBuilder.Append("RANK ");
351+
}
352+
353+
Check.DebugAssert(UseOldBehavior35476 || orderByScoringFunction || selectExpression.Orderings.All(x => x.Expression is not SqlFunctionExpression { IsScoringFunction: true }),
354+
"Scoring function can only appear as first (and only) ordering, or not at all.");
355+
344356
GenerateList(selectExpression.Orderings, e => Visit(e));
345357
}
346358

Diff for: src/EFCore.Cosmos/Query/Internal/CosmosShapedQueryCompilingExpressionVisitor.InExpressionValuesExpandingExpressionVisitor.cs

+104-23
Original file line numberDiff line numberDiff line change
@@ -9,48 +9,129 @@ namespace Microsoft.EntityFrameworkCore.Cosmos.Query.Internal;
99

1010
public partial class CosmosShapedQueryCompilingExpressionVisitor
1111
{
12-
private sealed class InExpressionValuesExpandingExpressionVisitor(
12+
private static readonly bool UseOldBehavior35476 =
13+
AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue35476", out var enabled35476) && enabled35476;
14+
15+
private sealed class ParameterInliner(
1316
ISqlExpressionFactory sqlExpressionFactory,
1417
IReadOnlyDictionary<string, object> parametersValues)
1518
: ExpressionVisitor
1619
{
1720
protected override Expression VisitExtension(Expression expression)
1821
{
19-
if (expression is InExpression inExpression)
22+
if (!UseOldBehavior35476)
2023
{
21-
IReadOnlyList<SqlExpression> values;
24+
expression = base.VisitExtension(expression);
25+
}
2226

23-
switch (inExpression)
27+
switch (expression)
28+
{
29+
// Inlines array parameter of InExpression, transforming: 'item IN (@valuesArray)' to: 'item IN (value1, value2)'
30+
case InExpression inExpression:
2431
{
25-
case { Values: IReadOnlyList<SqlExpression> values2 }:
26-
values = values2;
27-
break;
28-
29-
// TODO: IN with subquery (return immediately, nothing to do here)
32+
IReadOnlyList<SqlExpression> values;
3033

31-
case { ValuesParameter: SqlParameterExpression valuesParameter }:
34+
switch (inExpression)
3235
{
33-
var typeMapping = valuesParameter.TypeMapping;
34-
var mutableValues = new List<SqlExpression>();
35-
foreach (var value in (IEnumerable)parametersValues[valuesParameter.Name])
36+
case { Values: IReadOnlyList<SqlExpression> values2 }:
37+
values = values2;
38+
break;
39+
40+
// TODO: IN with subquery (return immediately, nothing to do here)
41+
42+
case { ValuesParameter: SqlParameterExpression valuesParameter }:
3643
{
37-
mutableValues.Add(sqlExpressionFactory.Constant(value, value?.GetType() ?? typeof(object), typeMapping));
44+
var typeMapping = valuesParameter.TypeMapping;
45+
var mutableValues = new List<SqlExpression>();
46+
foreach (var value in (IEnumerable)parametersValues[valuesParameter.Name])
47+
{
48+
mutableValues.Add(sqlExpressionFactory.Constant(value, value?.GetType() ?? typeof(object), typeMapping));
49+
}
50+
51+
values = mutableValues;
52+
break;
3853
}
3954

40-
values = mutableValues;
41-
break;
55+
default:
56+
throw new UnreachableException();
4257
}
4358

44-
default:
45-
throw new UnreachableException();
59+
return values.Count == 0
60+
? sqlExpressionFactory.ApplyDefaultTypeMapping(sqlExpressionFactory.Constant(false))
61+
: sqlExpressionFactory.In((SqlExpression)Visit(inExpression.Item), values);
4662
}
4763

48-
return values.Count == 0
49-
? sqlExpressionFactory.ApplyDefaultTypeMapping(sqlExpressionFactory.Constant(false))
50-
: sqlExpressionFactory.In((SqlExpression)Visit(inExpression.Item), values);
51-
}
64+
// Converts Offset and Limit parameters to constants when ORDER BY RANK is detected in the SelectExpression (i.e. we order by scoring function)
65+
// Cosmos only supports constants in Offset and Limit for this scenario currently (ORDER BY RANK limitation)
66+
case SelectExpression { Orderings: [{ Expression: SqlFunctionExpression { IsScoringFunction: true } }], Limit: var limit, Offset: var offset } hybridSearch
67+
when !UseOldBehavior35476 && (limit is SqlParameterExpression || offset is SqlParameterExpression):
68+
{
69+
if (hybridSearch.Limit is SqlParameterExpression limitPrm)
70+
{
71+
hybridSearch.ApplyLimit(
72+
sqlExpressionFactory.Constant(
73+
parametersValues[limitPrm.Name],
74+
limitPrm.TypeMapping));
75+
}
76+
77+
if (hybridSearch.Offset is SqlParameterExpression offsetPrm)
78+
{
79+
hybridSearch.ApplyOffset(
80+
sqlExpressionFactory.Constant(
81+
parametersValues[offsetPrm.Name],
82+
offsetPrm.TypeMapping));
83+
}
84+
85+
return base.VisitExtension(expression);
86+
}
5287

53-
return base.VisitExtension(expression);
88+
// Inlines array parameter of full-text functions, transforming FullTextContainsAll(x, @keywordsArray) to FullTextContainsAll(x, keyword1, keyword2))
89+
case SqlFunctionExpression
90+
{
91+
Name: "FullTextContainsAny" or "FullTextContainsAll",
92+
Arguments: [var property, SqlParameterExpression { TypeMapping: { ElementTypeMapping: var elementTypeMapping }, Type: Type type } keywords]
93+
} fullTextContainsAllAnyFunction
94+
when !UseOldBehavior35476 && type == typeof(string[]):
95+
{
96+
var keywordValues = new List<SqlExpression>();
97+
foreach (var value in (IEnumerable)parametersValues[keywords.Name])
98+
{
99+
keywordValues.Add(sqlExpressionFactory.Constant(value, typeof(string), elementTypeMapping));
100+
}
101+
102+
return sqlExpressionFactory.Function(
103+
fullTextContainsAllAnyFunction.Name,
104+
[property, .. keywordValues],
105+
fullTextContainsAllAnyFunction.Type,
106+
fullTextContainsAllAnyFunction.TypeMapping);
107+
}
108+
109+
// Inlines array parameter of full-text score, transforming FullTextScore(x, @keywordsArray) to FullTextScore(x, [keyword1, keyword2]))
110+
case SqlFunctionExpression
111+
{
112+
Name: "FullTextScore",
113+
IsScoringFunction: true,
114+
Arguments: [var property, SqlParameterExpression { TypeMapping: { ElementTypeMapping: not null } typeMapping } keywords]
115+
} fullTextScoreFunction
116+
when !UseOldBehavior35476:
117+
{
118+
var keywordValues = new List<string>();
119+
foreach (var value in (IEnumerable)parametersValues[keywords.Name])
120+
{
121+
keywordValues.Add((string)value);
122+
}
123+
124+
return new SqlFunctionExpression(
125+
fullTextScoreFunction.Name,
126+
isScoringFunction: true,
127+
[property, sqlExpressionFactory.Constant(keywordValues, typeMapping)],
128+
fullTextScoreFunction.Type,
129+
fullTextScoreFunction.TypeMapping);
130+
}
131+
132+
default:
133+
return expression;
134+
}
54135
}
55136
}
56137
}

Diff for: src/EFCore.Cosmos/Query/Internal/CosmosShapedQueryCompilingExpressionVisitor.PagingQueryingEnumerable.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ public IAsyncEnumerator<CosmosPage<T>> GetAsyncEnumerator(CancellationToken canc
7575

7676
private CosmosSqlQuery GenerateQuery()
7777
=> _querySqlGeneratorFactory.Create().GetSqlQuery(
78-
(SelectExpression)new InExpressionValuesExpandingExpressionVisitor(
78+
(SelectExpression)new ParameterInliner(
7979
_sqlExpressionFactory,
8080
_cosmosQueryContext.ParameterValues)
8181
.Visit(_selectExpression),

Diff for: src/EFCore.Cosmos/Query/Internal/CosmosShapedQueryCompilingExpressionVisitor.QueryingEnumerable.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ IEnumerator IEnumerable.GetEnumerator()
7171

7272
private CosmosSqlQuery GenerateQuery()
7373
=> _querySqlGeneratorFactory.Create().GetSqlQuery(
74-
(SelectExpression)new InExpressionValuesExpandingExpressionVisitor(
74+
(SelectExpression)new ParameterInliner(
7575
_sqlExpressionFactory,
7676
_cosmosQueryContext.ParameterValues)
7777
.Visit(_selectExpression),

Diff for: src/EFCore.Cosmos/Query/Internal/Expressions/FragmentExpression.cs

+8
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ public class FragmentExpression(string fragment) : Expression, IPrintableExpress
2323
/// </summary>
2424
public virtual string Fragment { get; } = fragment;
2525

26+
/// <inheritdoc />
27+
public override ExpressionType NodeType
28+
=> base.NodeType;
29+
30+
/// <inheritdoc />
31+
public override Type Type
32+
=> typeof(object);
33+
2634
/// <inheritdoc />
2735
protected override Expression VisitChildren(ExpressionVisitor visitor)
2836
=> this;

0 commit comments

Comments
 (0)