Skip to content

Commit 442604c

Browse files
authored
Merge pull request #35953 from dotnet/merge_fts_from_staging
[release/9.0] Merge Cosmos full-text search support from release/9.0-staging
2 parents d0d53e5 + bf3fa4e commit 442604c

15 files changed

+403
-30
lines changed

src/EFCore.Analyzers/EFDiagnostics.cs

Lines changed: 1 addition & 0 deletions
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
}

src/EFCore.Cosmos/EFCore.Cosmos.csproj

Lines changed: 1 addition & 0 deletions
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>

src/EFCore.Cosmos/Extensions/CosmosDbFunctionsExtensions.cs

Lines changed: 54 additions & 0 deletions
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

src/EFCore.Cosmos/Properties/CosmosStrings.Designer.cs

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/EFCore.Cosmos/Properties/CosmosStrings.resx

Lines changed: 9 additions & 0 deletions
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>

src/EFCore.Cosmos/Query/Internal/CosmosMethodCallTranslatorProvider.cs

Lines changed: 2 additions & 1 deletion
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),

src/EFCore.Cosmos/Query/Internal/CosmosQuerySqlGenerator.cs

Lines changed: 12 additions & 0 deletions
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

src/EFCore.Cosmos/Query/Internal/CosmosShapedQueryCompilingExpressionVisitor.InExpressionValuesExpandingExpressionVisitor.cs

Lines changed: 104 additions & 23 deletions
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
}

src/EFCore.Cosmos/Query/Internal/CosmosShapedQueryCompilingExpressionVisitor.PagingQueryingEnumerable.cs

Lines changed: 1 addition & 1 deletion
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),

src/EFCore.Cosmos/Query/Internal/CosmosShapedQueryCompilingExpressionVisitor.QueryingEnumerable.cs

Lines changed: 1 addition & 1 deletion
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),

0 commit comments

Comments
 (0)