diff --git a/Directory.Packages.props b/Directory.Packages.props
index 3e248f71184..bb90845f3f8 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -37,7 +37,7 @@
-
+
diff --git a/src/EFCore.Cosmos/EFCore.Cosmos.csproj b/src/EFCore.Cosmos/EFCore.Cosmos.csproj
index 5a078af20b3..f690652f7fd 100644
--- a/src/EFCore.Cosmos/EFCore.Cosmos.csproj
+++ b/src/EFCore.Cosmos/EFCore.Cosmos.csproj
@@ -1,4 +1,4 @@
-
+
Azure Cosmos provider for Entity Framework Core.
diff --git a/src/EFCore.Cosmos/Extensions/CosmosDbFunctionsExtensions.cs b/src/EFCore.Cosmos/Extensions/CosmosDbFunctionsExtensions.cs
index 3dc681450be..ce37b16ece4 100644
--- a/src/EFCore.Cosmos/Extensions/CosmosDbFunctionsExtensions.cs
+++ b/src/EFCore.Cosmos/Extensions/CosmosDbFunctionsExtensions.cs
@@ -52,6 +52,55 @@ public static T CoalesceUndefined(
T expression2)
=> throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(CoalesceUndefined)));
+ ///
+ /// Checks if the specified property contains the given keyword using full-text search.
+ ///
+ /// The instance.
+ /// The property to search.
+ /// The keyword to search for.
+ /// if the property contains the keyword; otherwise, .
+ public static bool FullTextContains(this DbFunctions _, string property, string keyword)
+ => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(FullTextContains)));
+
+ ///
+ /// Checks if the specified property contains all the given keywords using full-text search.
+ ///
+ /// The instance.
+ /// The property to search.
+ /// The keywords to search for.
+ /// if the property contains all the keywords; otherwise, .
+ public static bool FullTextContainsAll(this DbFunctions _, string property, params string[] keywords)
+ => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(FullTextContainsAll)));
+
+ ///
+ /// Checks if the specified property contains any of the given keywords using full-text search.
+ ///
+ /// The instance.
+ /// The property to search.
+ /// The keywords to search for.
+ /// if the property contains any of the keywords; otherwise, .
+ public static bool FullTextContainsAny(this DbFunctions _, string property, params string[] keywords)
+ => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(FullTextContainsAny)));
+
+ ///
+ /// Returns the full-text search score for the specified property and keywords.
+ ///
+ /// The instance.
+ /// The property to score.
+ /// The keywords to score by.
+ /// The full-text search score.
+ public static double FullTextScore(this DbFunctions _, string property, params string[] keywords)
+ => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(FullTextScore)));
+
+ ///
+ /// Combines scores provided by two or more specified functions.
+ ///
+ /// The instance.
+ /// The functions to compute the score for.
+ /// The combined score.
+ public static double Rrf(this DbFunctions _, params double[] functions)
+ => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(Rrf)));
+
///
/// Returns the distance between two vectors, using the distance function and data type defined using
/// entityTypeBuilder.CanSetAnnotation(CosmosAnnotationNames.DefaultTimeToLive, seconds, fromDataAnnotation);
+ ///
+ /// Configures a default language to use for full-text search at container scope.
+ ///
+ ///
+ /// See Modeling entity types and relationships, and
+ /// Accessing Azure Cosmos DB with EF Core for more information and examples.
+ ///
+ /// The builder for the entity type being configured.
+ /// The default language.
+ /// The same builder instance so that multiple calls can be chained.
+ public static EntityTypeBuilder HasDefaultFullTextLanguage(
+ this EntityTypeBuilder entityTypeBuilder,
+ string? language)
+ {
+ entityTypeBuilder.Metadata.SetDefaultFullTextSearchLanguage(language);
+
+ return entityTypeBuilder;
+ }
+
+ ///
+ /// Configures a default language to use for full-text search at container scope.
+ ///
+ ///
+ /// See Modeling entity types and relationships, and
+ /// Accessing Azure Cosmos DB with EF Core for more information and examples.
+ ///
+ /// The builder for the entity type being configured.
+ /// The default language.
+ /// The same builder instance so that multiple calls can be chained.
+ public static EntityTypeBuilder HasDefaultFullTextLanguage(
+ this EntityTypeBuilder entityTypeBuilder,
+ string? language)
+ where TEntity : class
+ => (EntityTypeBuilder)HasDefaultFullTextLanguage((EntityTypeBuilder)entityTypeBuilder, language);
+
+ ///
+ /// Configures a default language to use for full-text search at container scope.
+ ///
+ ///
+ /// See Modeling entity types and relationships, and
+ /// Accessing Azure Cosmos DB with EF Core for more information and examples.
+ ///
+ /// The builder for the entity type being configured.
+ /// The default language.
+ /// Indicates whether the configuration was specified using a data annotation.
+ ///
+ /// The same builder instance if the configuration was applied,
+ /// otherwise.
+ ///
+ public static IConventionEntityTypeBuilder? HasDefaultFullTextLanguage(
+ this IConventionEntityTypeBuilder entityTypeBuilder,
+ string? language,
+ bool fromDataAnnotation = false)
+ {
+ if (!entityTypeBuilder.CanSetDefaultFullTextLanguage(language, fromDataAnnotation))
+ {
+ return null;
+ }
+
+ entityTypeBuilder.Metadata.SetDefaultFullTextSearchLanguage(language, fromDataAnnotation);
+
+ return entityTypeBuilder;
+ }
+
+ ///
+ /// Returns a value indicating whether the default full-text language can be set
+ /// from the current configuration source
+ ///
+ ///
+ /// See Modeling entity types and relationships, and
+ /// Accessing Azure Cosmos DB with EF Core for more information and examples.
+ ///
+ /// The builder for the entity type being configured.
+ /// The default language.
+ /// Indicates whether the configuration was specified using a data annotation.
+ /// if the configuration can be applied.
+ public static bool CanSetDefaultFullTextLanguage(
+ this IConventionEntityTypeBuilder entityTypeBuilder,
+ string? language,
+ bool fromDataAnnotation = false)
+ => entityTypeBuilder.CanSetAnnotation(CosmosAnnotationNames.DefaultFullTextSearchLanguage, language, fromDataAnnotation);
+
///
/// Configures the manual provisioned throughput offering.
///
diff --git a/src/EFCore.Cosmos/Extensions/CosmosEntityTypeExtensions.cs b/src/EFCore.Cosmos/Extensions/CosmosEntityTypeExtensions.cs
index c05e3ca331d..4fb4d6c8724 100644
--- a/src/EFCore.Cosmos/Extensions/CosmosEntityTypeExtensions.cs
+++ b/src/EFCore.Cosmos/Extensions/CosmosEntityTypeExtensions.cs
@@ -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 System.Diagnostics.CodeAnalysis;
using Microsoft.EntityFrameworkCore.Cosmos.Metadata.Internal;
// ReSharper disable once CheckNamespace
@@ -586,4 +587,48 @@ public static void SetThroughput(this IMutableEntityType entityType, int? throug
public static ConfigurationSource? GetThroughputConfigurationSource(this IConventionEntityType entityType)
=> entityType.FindAnnotation(CosmosAnnotationNames.Throughput)
?.GetConfigurationSource();
+
+ ///
+ /// Returns the default language for the full-text search at container scope.
+ ///
+ /// The entity type.
+ /// The default language for the full-text search.
+ public static string? GetDefaultFullTextSearchLanguage(this IReadOnlyEntityType entityType)
+ => entityType.BaseType != null
+ ? entityType.GetRootType().GetDefaultFullTextSearchLanguage()
+ : (string?)entityType[CosmosAnnotationNames.DefaultFullTextSearchLanguage];
+
+ ///
+ /// Sets the default language for the full-text search at container scope.
+ ///
+ /// The entity type.
+ /// The default language for the full-text search.
+ public static void SetDefaultFullTextSearchLanguage(this IMutableEntityType entityType, string? language)
+ => entityType.SetOrRemoveAnnotation(
+ CosmosAnnotationNames.DefaultFullTextSearchLanguage,
+ language);
+
+ ///
+ /// Sets the default language for the full-text search at container scope.
+ ///
+ /// The entity type.
+ /// The default language for the full-text search.
+ /// Indicates whether the configuration was specified using a data annotation.
+ public static string? SetDefaultFullTextSearchLanguage(
+ this IConventionEntityType entityType,
+ string? language,
+ bool fromDataAnnotation = false)
+ => (string?)entityType.SetOrRemoveAnnotation(
+ CosmosAnnotationNames.DefaultFullTextSearchLanguage,
+ language,
+ fromDataAnnotation)?.Value;
+
+ ///
+ /// Gets the for the default full-text search language at container scope.
+ ///
+ /// The entity type to find configuration source for.
+ /// The for the default full-text search language.
+ public static ConfigurationSource? GetDefaultFullTextSearchLanguageConfigurationSource(this IConventionEntityType entityType)
+ => entityType.FindAnnotation(CosmosAnnotationNames.DefaultFullTextSearchLanguage)
+ ?.GetConfigurationSource();
}
diff --git a/src/EFCore.Cosmos/Extensions/CosmosIndexBuilderExtensions.cs b/src/EFCore.Cosmos/Extensions/CosmosIndexBuilderExtensions.cs
index 217e3691eae..e5301bbe5c6 100644
--- a/src/EFCore.Cosmos/Extensions/CosmosIndexBuilderExtensions.cs
+++ b/src/EFCore.Cosmos/Extensions/CosmosIndexBuilderExtensions.cs
@@ -14,7 +14,6 @@ namespace Microsoft.EntityFrameworkCore;
/// See Modeling entity types and relationships, and
/// Accessing Azure Cosmos DB with EF Core for more information and examples.
///
-[Experimental(EFDiagnostics.CosmosVectorSearchExperimental)]
public static class CosmosIndexBuilderExtensions
{
///
@@ -28,6 +27,7 @@ public static class CosmosIndexBuilderExtensions
/// The builder for the index being configured.
/// The type of vector index to create.
/// A builder to further configure the index.
+ [Experimental(EFDiagnostics.CosmosVectorSearchExperimental)]
public static IndexBuilder ForVectors(this IndexBuilder indexBuilder, VectorIndexType? indexType)
{
indexBuilder.Metadata.SetVectorIndexType(indexType);
@@ -46,6 +46,7 @@ public static IndexBuilder ForVectors(this IndexBuilder indexBuilder, VectorInde
/// The builder for the index being configured.
/// The type of vector index to create.
/// A builder to further configure the index.
+ [Experimental(EFDiagnostics.CosmosVectorSearchExperimental)]
public static IndexBuilder ForVectors(
this IndexBuilder indexBuilder,
VectorIndexType? indexType)
@@ -66,6 +67,7 @@ public static IndexBuilder ForVectors(
/// The same builder instance if the configuration was applied,
/// otherwise.
///
+ [Experimental(EFDiagnostics.CosmosVectorSearchExperimental)]
public static IConventionIndexBuilder? ForVectors(
this IConventionIndexBuilder indexBuilder,
VectorIndexType? indexType,
@@ -91,9 +93,90 @@ public static IndexBuilder ForVectors(
/// The index type to use.
/// Indicates whether the configuration was specified using a data annotation.
/// if the index can be configured for vectors.
+ [Experimental(EFDiagnostics.CosmosVectorSearchExperimental)]
public static bool CanSetVectorIndexType(
this IConventionIndexBuilder indexBuilder,
VectorIndexType? indexType,
bool fromDataAnnotation = false)
=> indexBuilder.CanSetAnnotation(CosmosAnnotationNames.VectorIndexType, indexType, fromDataAnnotation);
+
+ ///
+ /// Configures the index as a full-text index.
+ /// See Full-text search in Azure Cosmos DB for NoSQL for more information.
+ ///
+ ///
+ /// See Modeling entity types and relationships, and
+ /// Accessing Azure Cosmos DB with EF Core for more information and examples.
+ ///
+ /// The builder for the index being configured.
+ /// The value indicating whether the index is configured for Full-text search.
+ /// A builder to further configure the index.
+ public static IndexBuilder IsFullTextIndex(this IndexBuilder indexBuilder, bool? value = true)
+ {
+ indexBuilder.Metadata.SetIsFullTextIndex(value);
+
+ return indexBuilder;
+ }
+
+ ///
+ /// Configures the index as a full-text index.
+ /// See Full-text search in Azure Cosmos DB for NoSQL for more information.
+ ///
+ ///
+ /// See Modeling entity types and relationships, and
+ /// Accessing Azure Cosmos DB with EF Core for more information and examples.
+ ///
+ /// The builder for the index being configured.
+ /// The value indicating whether the index is configured for Full-text search.
+ /// A builder to further configure the index.
+ public static IndexBuilder IsFullTextIndex(
+ this IndexBuilder indexBuilder,
+ bool? value = true)
+ => (IndexBuilder)IsFullTextIndex((IndexBuilder)indexBuilder, value);
+
+ ///
+ /// Configures the index as a full-text index.
+ /// See Full-text search in Azure Cosmos DB for NoSQL for more information.
+ ///
+ ///
+ /// See Modeling entity types and relationships, and
+ /// Accessing Azure Cosmos DB with EF Core for more information and examples.
+ ///
+ /// The builder for the index being configured.
+ /// The value indicating whether the index is configured for Full-text search.
+ /// Indicates whether the configuration was specified using a data annotation.
+ ///
+ /// The same builder instance if the configuration was applied,
+ /// otherwise.
+ ///
+ public static IConventionIndexBuilder? IsFullTextIndex(
+ this IConventionIndexBuilder indexBuilder,
+ bool? value,
+ bool fromDataAnnotation = false)
+ {
+ if (indexBuilder.CanSetIsFullTextIndex(fromDataAnnotation))
+ {
+ indexBuilder.Metadata.SetIsFullTextIndex(value, fromDataAnnotation);
+ return indexBuilder;
+ }
+
+ return null;
+ }
+
+ ///
+ /// Returns a value indicating whether the index can be configured as a full-text index.
+ ///
+ ///
+ /// See Modeling entity types and relationships, and
+ /// Accessing Azure Cosmos DB with EF Core for more information and examples.
+ ///
+ /// The builder for the index being configured.
+ /// The value indicating whether the index is configured for Full-text search.
+ /// Indicates whether the configuration was specified using a data annotation.
+ /// if the index can be configured as a Full-text index.
+ public static bool CanSetIsFullTextIndex(
+ this IConventionIndexBuilder indexBuilder,
+ bool? value,
+ bool fromDataAnnotation = false)
+ => indexBuilder.CanSetAnnotation(CosmosAnnotationNames.FullTextIndex, value, fromDataAnnotation);
}
diff --git a/src/EFCore.Cosmos/Extensions/CosmosIndexExtensions.cs b/src/EFCore.Cosmos/Extensions/CosmosIndexExtensions.cs
index 6f9ab7b6184..2bee225a817 100644
--- a/src/EFCore.Cosmos/Extensions/CosmosIndexExtensions.cs
+++ b/src/EFCore.Cosmos/Extensions/CosmosIndexExtensions.cs
@@ -14,7 +14,6 @@ namespace Microsoft.EntityFrameworkCore;
/// See Modeling entity types and relationships, and
/// Accessing Azure Cosmos DB with EF Core for more information and examples.
///
-[Experimental(EFDiagnostics.CosmosVectorSearchExperimental)]
public static class CosmosIndexExtensions
{
///
@@ -23,6 +22,7 @@ public static class CosmosIndexExtensions
///
/// The index.
/// The index type to use, or if none is set.
+ [Experimental(EFDiagnostics.CosmosVectorSearchExperimental)]
public static VectorIndexType? GetVectorIndexType(this IReadOnlyIndex index)
=> (index is RuntimeIndex)
? throw new InvalidOperationException(CoreStrings.RuntimeModelMissingData)
@@ -34,6 +34,7 @@ public static class CosmosIndexExtensions
///
/// The index.
/// The index type to use.
+ [Experimental(EFDiagnostics.CosmosVectorSearchExperimental)]
public static void SetVectorIndexType(this IMutableIndex index, VectorIndexType? indexType)
=> index.SetAnnotation(CosmosAnnotationNames.VectorIndexType, indexType);
@@ -45,6 +46,7 @@ public static void SetVectorIndexType(this IMutableIndex index, VectorIndexType?
/// The index.
/// Indicates whether the configuration was specified using a data annotation.
/// The configured value.
+ [Experimental(EFDiagnostics.CosmosVectorSearchExperimental)]
public static string? SetVectorIndexType(
this IConventionIndex index,
VectorIndexType? indexType,
@@ -59,6 +61,52 @@ public static void SetVectorIndexType(this IMutableIndex index, VectorIndexType?
///
/// The property.
/// The for whether the index is clustered.
+ [Experimental(EFDiagnostics.CosmosVectorSearchExperimental)]
public static ConfigurationSource? GetVectorIndexTypeConfigurationSource(this IConventionIndex property)
=> property.FindAnnotation(CosmosAnnotationNames.VectorIndexType)?.GetConfigurationSource();
+
+ ///
+ /// Returns the value indicating whether the index is configured for full-text search.
+ /// See Full-text search in Azure Cosmos DB for NoSQL for more information.
+ ///
+ /// The index.
+ /// The index type to use, or if none is set.
+ public static bool? IsFullTextIndex(this IReadOnlyIndex index)
+ => (index is RuntimeIndex)
+ ? throw new InvalidOperationException(CoreStrings.RuntimeModelMissingData)
+ : (bool?)index[CosmosAnnotationNames.FullTextIndex];
+
+ ///
+ /// Configures the index for full-text search.
+ /// See Full-text search in Azure Cosmos DB for NoSQL for more information.
+ ///
+ /// The index.
+ /// The value indicating whether the index is configured for full-text search.
+ public static void SetIsFullTextIndex(this IMutableIndex index, bool? value)
+ => index.SetAnnotation(CosmosAnnotationNames.FullTextIndex, value);
+
+ ///
+ /// Configures the index for full-text search.
+ /// See Full-text search in Azure Cosmos DB for NoSQL for more information.
+ ///
+ /// The index.
+ /// The value indicating whether the index is configured for full-text search.
+ /// Indicates whether the configuration was specified using a data annotation.
+ /// The configured value.
+ public static string? SetIsFullTextIndex(
+ this IConventionIndex index,
+ bool? value,
+ bool fromDataAnnotation = false)
+ => (string?)index.SetAnnotation(
+ CosmosAnnotationNames.FullTextIndex,
+ value,
+ fromDataAnnotation)?.Value;
+
+ ///
+ /// Returns the for whether the .
+ ///
+ /// The property.
+ /// The for whether the index is clustered.
+ public static ConfigurationSource? GetIsFullTextIndexConfigurationSource(this IConventionIndex property)
+ => property.FindAnnotation(CosmosAnnotationNames.FullTextIndex)?.GetConfigurationSource();
}
diff --git a/src/EFCore.Cosmos/Extensions/CosmosPropertyBuilderExtensions.cs b/src/EFCore.Cosmos/Extensions/CosmosPropertyBuilderExtensions.cs
index 3d8618e3d7f..15cf6c8268c 100644
--- a/src/EFCore.Cosmos/Extensions/CosmosPropertyBuilderExtensions.cs
+++ b/src/EFCore.Cosmos/Extensions/CosmosPropertyBuilderExtensions.cs
@@ -243,4 +243,100 @@ private static CosmosVectorType CreateVectorType(DistanceFunction distanceFuncti
? new CosmosVectorType(distanceFunction, dimensions)
: throw new ArgumentException(
CoreStrings.InvalidEnumValue(distanceFunction, nameof(distanceFunction), typeof(DistanceFunction)));
+
+ ///
+ /// Enables full-text search for this property using a specified language.
+ ///
+ ///
+ /// See Modeling entity types and relationships, and
+ /// Accessing Azure Cosmos DB with EF Core for more information and examples.
+ ///
+ /// The builder for the property being configured.
+ /// The language used for full-text search. Setting this to ( will use the default language for the container, or "en-US" if default language was not specified.
+ /// The value indicating whether full-text search should be enabled for this property.
+ /// The same builder instance so that multiple calls can be chained.
+ public static PropertyBuilder EnableFullTextSearch(
+ this PropertyBuilder propertyBuilder,
+ string? language = null,
+ bool enabled = true)
+ {
+ propertyBuilder.Metadata.SetIsFullTextSearchEnabled(enabled);
+ propertyBuilder.Metadata.SetFullTextSearchLanguage(language);
+
+ return propertyBuilder;
+ }
+
+ ///
+ /// Enables full-text search for this property using a specified language.
+ ///
+ ///
+ /// See Modeling entity types and relationships, and
+ /// Accessing Azure Cosmos DB with EF Core for more information and examples.
+ ///
+ ///
+ /// See Modeling entity types and relationships, and
+ /// Accessing Azure Cosmos DB with EF Core for more information and examples.
+ ///
+ /// The type of the property being configured.
+ /// The builder for the property being configured.
+ /// The language used for full-text search. Setting this to ( will use the default language for the container, or "en-US" if default language was not specified.
+ /// The value indicating whether full-text search should be enabled for this property.
+ /// The same builder instance so that multiple calls can be chained.
+ public static PropertyBuilder EnableFullTextSearch(
+ this PropertyBuilder propertyBuilder,
+ string? language = null,
+ bool enabled = true)
+ => (PropertyBuilder)EnableFullTextSearch((PropertyBuilder)propertyBuilder, language, enabled);
+
+ ///
+ /// Enables full-text search for this property using a specified language.
+ ///
+ ///
+ /// See Modeling entity types and relationships, and
+ /// Accessing Azure Cosmos DB with EF Core for more information and examples.
+ ///
+ /// The builder for the property being configured.
+ /// The language used for full-text search. Setting this to ( will use the default language for the container, or "en-US" if default language was not specified.
+ /// The value indicating whether full-text search should be enabled for this property.
+ /// Indicates whether the configuration was specified using a data annotation.
+ ///
+ /// The same builder instance if the configuration was applied,
+ /// otherwise.
+ ///
+ public static IConventionPropertyBuilder? EnableFullTextSearch(
+ this IConventionPropertyBuilder propertyBuilder,
+ string? language,
+ bool enabled,
+ bool fromDataAnnotation = false)
+ {
+ if (!propertyBuilder.CanSetEnableFullTextSearch(language, enabled, fromDataAnnotation))
+ {
+ return null;
+ }
+
+ propertyBuilder.Metadata.SetIsFullTextSearchEnabled(enabled, fromDataAnnotation);
+ propertyBuilder.Metadata.SetFullTextSearchLanguage(language, fromDataAnnotation);
+
+ return propertyBuilder;
+ }
+
+ ///
+ /// Returns a value indicating whether full-text search can be enabled for this property using a specified language.
+ ///
+ ///
+ /// See Modeling entity types and relationships, and
+ /// Accessing Azure Cosmos DB with EF Core for more information and examples.
+ ///
+ /// The builder for the property being configured.
+ /// The language for full-text search.
+ /// The value indicating whether full-text search should be enabled for this property.
+ /// Indicates whether the configuration was specified using a data annotation.
+ /// if the vector type can be set.
+ public static bool CanSetEnableFullTextSearch(
+ this IConventionPropertyBuilder propertyBuilder,
+ string? language,
+ bool enabled,
+ bool fromDataAnnotation = false)
+ => propertyBuilder.CanSetAnnotation(CosmosAnnotationNames.IsFullTextSearchEnabled, enabled, fromDataAnnotation)
+ && propertyBuilder.CanSetAnnotation(CosmosAnnotationNames.FullTextSearchLanguage, language, fromDataAnnotation);
}
diff --git a/src/EFCore.Cosmos/Extensions/CosmosPropertyExtensions.cs b/src/EFCore.Cosmos/Extensions/CosmosPropertyExtensions.cs
index 6439a41eee7..91d0e5dab50 100644
--- a/src/EFCore.Cosmos/Extensions/CosmosPropertyExtensions.cs
+++ b/src/EFCore.Cosmos/Extensions/CosmosPropertyExtensions.cs
@@ -128,4 +128,88 @@ public static void SetVectorType(this IMutableProperty property, CosmosVectorTyp
[Experimental(EFDiagnostics.CosmosVectorSearchExperimental)]
public static ConfigurationSource? GetVectorTypeConfigurationSource(this IConventionProperty property)
=> property.FindAnnotation(CosmosAnnotationNames.VectorType)?.GetConfigurationSource();
+
+ ///
+ /// Returns the value indicating whether full-text search is enabled for this property.
+ ///
+ /// The property.
+ /// if full-text search is enabled for this property, otherwise.
+ public static bool? GetIsFullTextSearchEnabled(this IReadOnlyProperty property)
+ => (bool?)property[CosmosAnnotationNames.IsFullTextSearchEnabled];
+
+ ///
+ /// Enables full-text search for this property.
+ ///
+ /// The property.
+ /// Indicates whether full-text search is enabled for the property.
+ public static void SetIsFullTextSearchEnabled(this IMutableProperty property, bool? enabled)
+ => property.SetAnnotation(CosmosAnnotationNames.IsFullTextSearchEnabled, enabled);
+
+ ///
+ /// Enables full-text search for this property.
+ ///
+ /// The property.
+ /// Indicates whether full-text search is enabled for the property.
+ /// Indicates whether the configuration was specified using a data annotation.
+ /// The configured value.
+ public static bool? SetIsFullTextSearchEnabled(
+ this IConventionProperty property,
+ bool? enabled,
+ bool fromDataAnnotation = false)
+ => (bool?)property.SetAnnotation(
+ CosmosAnnotationNames.IsFullTextSearchEnabled,
+ enabled,
+ fromDataAnnotation)?.Value;
+
+ ///
+ /// Gets the for enabling full-text search for this property.
+ ///
+ /// The property.
+ ///
+ /// The for enabling full-text search for this property.
+ ///
+ public static ConfigurationSource? GetIsFullTextSearchEnabledConfigurationSource(this IConventionProperty property)
+ => property.FindAnnotation(CosmosAnnotationNames.IsFullTextSearchEnabled)?.GetConfigurationSource();
+
+ ///
+ /// Returns the full-text search language defined for this property.
+ ///
+ /// The property.
+ /// The full-text search language for this property.
+ public static string? GetFullTextSearchLanguage(this IReadOnlyProperty property)
+ => (string?)property[CosmosAnnotationNames.FullTextSearchLanguage];
+
+ ///
+ /// Sets the full-text search language defined for this property.
+ ///
+ /// The property.
+ /// The full-text search language for this property.
+ public static void SetFullTextSearchLanguage(this IMutableProperty property, string? language)
+ => property.SetAnnotation(CosmosAnnotationNames.FullTextSearchLanguage, language);
+
+ ///
+ /// Sets the full-text search language defined for this property.
+ ///
+ /// The property.
+ /// The full-text search language for the property.
+ /// Indicates whether the configuration was specified using a data annotation.
+ /// The configured value.
+ public static string? SetFullTextSearchLanguage(
+ this IConventionProperty property,
+ string? language,
+ bool fromDataAnnotation = false)
+ => (string?)property.SetAnnotation(
+ CosmosAnnotationNames.FullTextSearchLanguage,
+ language,
+ fromDataAnnotation)?.Value;
+
+ ///
+ /// Gets the for the definition of the full-text search language for this property.
+ ///
+ /// The property.
+ ///
+ /// The for the definition of full-text-search language for this property.
+ ///
+ public static ConfigurationSource? GetFullTextSearchLanguageConfigurationSource(this IConventionProperty property)
+ => property.FindAnnotation(CosmosAnnotationNames.FullTextSearchLanguage)?.GetConfigurationSource();
}
diff --git a/src/EFCore.Cosmos/Infrastructure/Internal/CosmosModelValidator.cs b/src/EFCore.Cosmos/Infrastructure/Internal/CosmosModelValidator.cs
index 5faeed750bc..d50ff3f65e7 100644
--- a/src/EFCore.Cosmos/Infrastructure/Internal/CosmosModelValidator.cs
+++ b/src/EFCore.Cosmos/Infrastructure/Internal/CosmosModelValidator.cs
@@ -166,6 +166,7 @@ protected virtual void ValidateSharedContainerCompatibility(
int? analyticalTtl = null;
int? defaultTtl = null;
ThroughputProperties? throughput = null;
+ string? defaultFullTextSearchLanguage = null;
IEntityType? firstEntityType = null;
bool? isDiscriminatorMappingComplete = null;
@@ -330,6 +331,27 @@ protected virtual void ValidateSharedContainerCompatibility(
CosmosStrings.ThroughputTypeMismatch(manualType.DisplayName(), autoscaleType.DisplayName(), container));
}
}
+
+ var currentFullTextSearchDefaultLanguage = entityType.GetDefaultFullTextSearchLanguage();
+ if (currentFullTextSearchDefaultLanguage != null)
+ {
+ if (defaultFullTextSearchLanguage == null)
+ {
+ defaultFullTextSearchLanguage = currentFullTextSearchDefaultLanguage;
+ }
+ else if (defaultFullTextSearchLanguage != currentFullTextSearchDefaultLanguage)
+ {
+ var conflictingEntityType = mappedTypes.First(et => et.GetDefaultFullTextSearchLanguage() != null);
+
+ throw new InvalidOperationException(
+ CosmosStrings.FullTextSearchDefaultLanguageMismatch(
+ defaultFullTextSearchLanguage,
+ conflictingEntityType.DisplayName(),
+ entityType.DisplayName(),
+ currentFullTextSearchDefaultLanguage,
+ container));
+ }
+ }
}
}
@@ -570,6 +592,18 @@ protected virtual void ValidateIndexes(
index.Properties[0].Name));
}
}
+ else if (index.IsFullTextIndex() == true)
+ {
+ // composite vector validation is done during container creation
+ if (index.Properties[0].GetIsFullTextSearchEnabled() != true)
+ {
+ throw new InvalidOperationException(
+ CosmosStrings.FullTextIndexOnNonFullTextProperty(
+ index.DeclaringEntityType.DisplayName(),
+ index.Properties[0].Name,
+ nameof(CosmosPropertyBuilderExtensions.EnableFullTextSearch)));
+ }
+ }
else
{
throw new InvalidOperationException(
diff --git a/src/EFCore.Cosmos/Metadata/Internal/CosmosAnnotationNames.cs b/src/EFCore.Cosmos/Metadata/Internal/CosmosAnnotationNames.cs
index 6b15e55b624..731a0df078b 100644
--- a/src/EFCore.Cosmos/Metadata/Internal/CosmosAnnotationNames.cs
+++ b/src/EFCore.Cosmos/Metadata/Internal/CosmosAnnotationNames.cs
@@ -45,6 +45,38 @@ public static class CosmosAnnotationNames
///
public const string PartitionKeyNames = Prefix + "PartitionKeyNames";
+ ///
+ /// 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.
+ ///
+ public const string FullTextIndex = Prefix + "FullTextIndex";
+
+ ///
+ /// 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.
+ ///
+ public const string DefaultFullTextSearchLanguage = Prefix + "DefaultFullTextSearchLanguage";
+
+ ///
+ /// 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.
+ ///
+ public const string IsFullTextSearchEnabled = Prefix + "IsFullTextSearchEnabled";
+
+ ///
+ /// 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.
+ ///
+ public const string FullTextSearchLanguage = Prefix + "FullTextSearchLanguage";
+
///
/// 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
diff --git a/src/EFCore.Cosmos/Properties/CosmosStrings.Designer.cs b/src/EFCore.Cosmos/Properties/CosmosStrings.Designer.cs
index 6c63a1c718e..83995a0cb94 100644
--- a/src/EFCore.Cosmos/Properties/CosmosStrings.Designer.cs
+++ b/src/EFCore.Cosmos/Properties/CosmosStrings.Designer.cs
@@ -53,6 +53,14 @@ public static string BadVectorDataType(object? clrType)
public static string CanConnectNotSupported
=> GetString("CanConnectNotSupported");
+ ///
+ /// A full-text index on '{entityType}' is defined over multiple properties (`{properties}`). A full-text index can only target a single property.
+ ///
+ public static string CompositeFullTextIndex(object? entityType, object? properties)
+ => string.Format(
+ GetString("CompositeFullTextIndex", nameof(entityType), nameof(properties)),
+ entityType, properties);
+
///
/// A vector index on '{entityType}' is defined over properties `{properties}`. A vector index can only target a single property.
///
@@ -95,6 +103,14 @@ public static string ContainerNotOnRoot(object? entityType, object? baseEntityTy
public static string CosmosNotInUse
=> GetString("CosmosNotInUse");
+ ///
+ /// Creating a container with full-text search or vector properties inside a collection navigation is currently not supported using EF Core; path: '{path}'. Create the container using other means (e.g. Microsoft.Azure.Cosmos SDK).
+ ///
+ public static string CreatingContainerWithFullTextOnCollectionNotSupported(object? path)
+ => string.Format(
+ GetString("CreatingContainerWithFullTextOnCollectionNotSupported", nameof(path)),
+ path);
+
///
/// Joins across documents aren't supported in Cosmos; consider modeling your data differently so that related data is in the same document. Alternatively, perform two separate queries to query the two documents.
///
@@ -147,6 +163,30 @@ public static string ETagNonStringStoreType(object? property, object? entityType
public static string ExceptNotSupported
=> GetString("ExceptNotSupported");
+ ///
+ /// A full-text index is defined for `{entityType}.{property}`, but full-text search was not enabled for this property. Use '{enableFullText}' method in 'OnModelCreating' to enable full-text search for this property.
+ ///
+ public static string FullTextIndexOnNonFullTextProperty(object? entityType, object? property, object? enableFullText)
+ => string.Format(
+ GetString("FullTextIndexOnNonFullTextProperty", nameof(entityType), nameof(property), nameof(enableFullText)),
+ entityType, property, enableFullText);
+
+ ///
+ /// Property '{entityType}.{property}' was configured for full-text search, but has type '{clrType}'; only string properties can be configured for full-text search.
+ ///
+ public static string FullTextSearchConfiguredForUnsupportedPropertyType(object? entityType, object? property, object? clrType)
+ => string.Format(
+ GetString("FullTextSearchConfiguredForUnsupportedPropertyType", nameof(entityType), nameof(property), nameof(clrType)),
+ entityType, property, clrType);
+
+ ///
+ /// The default full-text search language was configured to '{defaultLanguage1}' on '{entityType1}', but on '{entityType2}' it was configured to '{defaultLanguage2}'. All entity types mapped to the same container '{container}' must be configured with the same default full-text search language.
+ ///
+ public static string FullTextSearchDefaultLanguageMismatch(object? defaultLanguage1, object? entityType1, object? entityType2, object? defaultLanguage2, object? container)
+ => string.Format(
+ GetString("FullTextSearchDefaultLanguageMismatch", nameof(defaultLanguage1), nameof(entityType1), nameof(entityType2), nameof(defaultLanguage2), nameof(container)),
+ defaultLanguage1, entityType1, entityType2, defaultLanguage2, container);
+
///
/// 'HasShadowId' was called on a non-root entity type '{entityType}'. JSON 'id' configuration can only be made on the document root.
///
@@ -327,6 +367,28 @@ public static string OneOfTwoValuesMustBeSet(object? param1, object? param2)
GetString("OneOfTwoValuesMustBeSet", nameof(param1), nameof(param2)),
param1, param2);
+ ///
+ /// Ordering based on scoring function is not supported inside '{orderByDescending}'. Use '{orderBy}' instead.
+ ///
+ public static string OrderByDescendingScoringFunction(object? orderByDescending, object? orderBy)
+ => string.Format(
+ GetString("OrderByDescendingScoringFunction", nameof(orderByDescending), nameof(orderBy)),
+ orderByDescending, orderBy);
+
+ ///
+ /// Only one ordering using scoring function is allowed. Use 'EF.Functions.{rrf}' method to combine multiple scoring functions.
+ ///
+ public static string OrderByMultipleScoringFunctionWithoutRrf(object? rrf)
+ => string.Format(
+ GetString("OrderByMultipleScoringFunctionWithoutRrf", nameof(rrf)),
+ rrf);
+
+ ///
+ /// Ordering using a scoring function is mutually exclusive with other forms of ordering.
+ ///
+ public static string OrderByScoringFunctionMixedWithRegularOrderby
+ => GetString("OrderByScoringFunctionMixedWithRegularOrderby");
+
///
/// 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.
///
diff --git a/src/EFCore.Cosmos/Properties/CosmosStrings.resx b/src/EFCore.Cosmos/Properties/CosmosStrings.resx
index 8f9a875524b..6259f9dfd10 100644
--- a/src/EFCore.Cosmos/Properties/CosmosStrings.resx
+++ b/src/EFCore.Cosmos/Properties/CosmosStrings.resx
@@ -132,6 +132,9 @@
Complex projections in subqueries are currently unsupported.
+
+ A full-text index on '{entityType}' is defined over multiple properties (`{properties}`). A full-text index can only target a single property.
+
A vector index on '{entityType}' is defined over properties `{properties}`. A vector index can only target a single property.
@@ -147,6 +150,9 @@
Cosmos-specific methods can only be used when the context is using the Cosmos provider.
+
+ Creating a container with full-text search or vector properties inside a collection navigation is currently not supported using EF Core; path: '{path}'. Create the container using other means (e.g. Microsoft.Azure.Cosmos SDK).
+
Joins across documents aren't supported in Cosmos; consider modeling your data differently so that related data is in the same document. Alternatively, perform two separate queries to query the two documents.
@@ -168,6 +174,15 @@
The 'Except()' LINQ operator isn't supported by Cosmos.
+
+ A full-text index is defined for `{entityType}.{property}`, but full-text search was not enabled for this property. Use '{enableFullText}' method in 'OnModelCreating' to enable full-text search for this property.
+
+
+ Property '{entityType}.{property}' was configured for full-text search, but has type '{clrType}'; only string properties can be configured for full-text search.
+
+
+ The default full-text search language was configured to '{defaultLanguage1}' on '{entityType1}', but on '{entityType2}' it was configured to '{defaultLanguage2}'. All entity types mapped to the same container '{container}' must be configured with the same default full-text search language.
+
'HasShadowId' was called on a non-root entity type '{entityType}'. JSON 'id' configuration can only be made on the document root.
@@ -277,12 +292,18 @@
Cosmos SQL does not allow Offset without Limit. Consider specifying a 'Take' operation on the query.
-
- SingleOrDefault and FirstOrDefault cannot be used Cosmos SQL does not allow Offset without Limit. Consider specifying a 'Take' operation on the query.
-
Exactly one of '{param1}' or '{param2}' must be set.
+
+ Ordering based on scoring function is not supported inside '{orderByDescending}'. Use '{orderBy}' instead.
+
+
+ Only one ordering using scoring function is allowed. Use 'EF.Functions.{rrf}' method to combine multiple scoring functions.
+
+
+ Ordering using a scoring function is mutually exclusive with other forms of ordering.
+
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.
@@ -319,6 +340,9 @@
Reversing the ordering is not supported when limit or offset are already applied.
+
+ SingleOrDefault and FirstOrDefault cannot be used Cosmos SQL does not allow Offset without Limit. Consider specifying a 'Take' operation on the query.
+
The provisioned throughput was configured to '{throughput1}' on '{entityType1}', but on '{entityType2}' it was configured to '{throughput2}'. All entity types mapped to the same container '{container}' must be configured with the same provisioned throughput.
diff --git a/src/EFCore.Cosmos/Query/Internal/CosmosMethodCallTranslatorProvider.cs b/src/EFCore.Cosmos/Query/Internal/CosmosMethodCallTranslatorProvider.cs
index 352e3d443e1..166b809c8e5 100644
--- a/src/EFCore.Cosmos/Query/Internal/CosmosMethodCallTranslatorProvider.cs
+++ b/src/EFCore.Cosmos/Query/Internal/CosmosMethodCallTranslatorProvider.cs
@@ -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),
diff --git a/src/EFCore.Cosmos/Query/Internal/CosmosQuerySqlGenerator.cs b/src/EFCore.Cosmos/Query/Internal/CosmosQuerySqlGenerator.cs
index 9e9c8151d2f..cf0c28cd552 100644
--- a/src/EFCore.Cosmos/Query/Internal/CosmosQuerySqlGenerator.cs
+++ b/src/EFCore.Cosmos/Query/Internal/CosmosQuerySqlGenerator.cs
@@ -341,6 +341,15 @@ protected override Expression VisitSelect(SelectExpression selectExpression)
{
_sqlBuilder.AppendLine().Append("ORDER BY ");
+ var orderByScoringFunction = selectExpression.Orderings is [{ Expression: SqlFunctionExpression { IsScoringFunction: true } }];
+ if (orderByScoringFunction)
+ {
+ _sqlBuilder.Append("RANK ");
+ }
+
+ Check.DebugAssert(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));
}
@@ -811,8 +820,7 @@ protected virtual void GenerateIn(InExpression inExpression, bool negated)
{
Check.DebugAssert(
inExpression.ValuesParameter is null,
- "InExpression.ValuesParameter must have been expanded to constants before SQL generation (in "
- + "InExpressionValuesExpandingExpressionVisitor)");
+ "InExpression.ValuesParameter must have been expanded to constants before SQL generation (in ParameterInliner)");
Check.DebugAssert(inExpression.Values is not null, "Missing Values on InExpression");
Visit(inExpression.Item);
diff --git a/src/EFCore.Cosmos/Query/Internal/CosmosShapedQueryCompilingExpressionVisitor.InExpressionValuesExpandingExpressionVisitor.cs b/src/EFCore.Cosmos/Query/Internal/CosmosShapedQueryCompilingExpressionVisitor.InExpressionValuesExpandingExpressionVisitor.cs
deleted file mode 100644
index 98563fd0a2a..00000000000
--- a/src/EFCore.Cosmos/Query/Internal/CosmosShapedQueryCompilingExpressionVisitor.InExpressionValuesExpandingExpressionVisitor.cs
+++ /dev/null
@@ -1,56 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-#nullable disable
-
-using System.Collections;
-
-namespace Microsoft.EntityFrameworkCore.Cosmos.Query.Internal;
-
-public partial class CosmosShapedQueryCompilingExpressionVisitor
-{
- private sealed class InExpressionValuesExpandingExpressionVisitor(
- ISqlExpressionFactory sqlExpressionFactory,
- IReadOnlyDictionary parametersValues)
- : ExpressionVisitor
- {
- protected override Expression VisitExtension(Expression expression)
- {
- if (expression is InExpression inExpression)
- {
- IReadOnlyList values;
-
- switch (inExpression)
- {
- case { Values: IReadOnlyList values2 }:
- values = values2;
- break;
-
- // TODO: IN with subquery (return immediately, nothing to do here)
-
- case { ValuesParameter: SqlParameterExpression valuesParameter }:
- {
- var typeMapping = valuesParameter.TypeMapping;
- var mutableValues = new List();
- foreach (var value in (IEnumerable)parametersValues[valuesParameter.Name])
- {
- mutableValues.Add(sqlExpressionFactory.Constant(value, value?.GetType() ?? typeof(object), typeMapping));
- }
-
- values = mutableValues;
- break;
- }
-
- default:
- throw new UnreachableException();
- }
-
- return values.Count == 0
- ? sqlExpressionFactory.ApplyDefaultTypeMapping(sqlExpressionFactory.Constant(false))
- : sqlExpressionFactory.In((SqlExpression)Visit(inExpression.Item), values);
- }
-
- return base.VisitExtension(expression);
- }
- }
-}
diff --git a/src/EFCore.Cosmos/Query/Internal/CosmosShapedQueryCompilingExpressionVisitor.PagingQueryingEnumerable.cs b/src/EFCore.Cosmos/Query/Internal/CosmosShapedQueryCompilingExpressionVisitor.PagingQueryingEnumerable.cs
index e90c24664a5..6e84ffa7827 100644
--- a/src/EFCore.Cosmos/Query/Internal/CosmosShapedQueryCompilingExpressionVisitor.PagingQueryingEnumerable.cs
+++ b/src/EFCore.Cosmos/Query/Internal/CosmosShapedQueryCompilingExpressionVisitor.PagingQueryingEnumerable.cs
@@ -75,7 +75,7 @@ public IAsyncEnumerator> GetAsyncEnumerator(CancellationToken canc
private CosmosSqlQuery GenerateQuery()
=> _querySqlGeneratorFactory.Create().GetSqlQuery(
- (SelectExpression)new InExpressionValuesExpandingExpressionVisitor(
+ (SelectExpression)new ParameterInliner(
_sqlExpressionFactory,
_cosmosQueryContext.ParameterValues)
.Visit(_selectExpression),
diff --git a/src/EFCore.Cosmos/Query/Internal/CosmosShapedQueryCompilingExpressionVisitor.ParameterInliner.cs b/src/EFCore.Cosmos/Query/Internal/CosmosShapedQueryCompilingExpressionVisitor.ParameterInliner.cs
new file mode 100644
index 00000000000..8cf86fb69f9
--- /dev/null
+++ b/src/EFCore.Cosmos/Query/Internal/CosmosShapedQueryCompilingExpressionVisitor.ParameterInliner.cs
@@ -0,0 +1,130 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#nullable disable
+
+using System.Collections;
+
+namespace Microsoft.EntityFrameworkCore.Cosmos.Query.Internal;
+
+public partial class CosmosShapedQueryCompilingExpressionVisitor
+{
+ private sealed class ParameterInliner(
+ ISqlExpressionFactory sqlExpressionFactory,
+ IReadOnlyDictionary parametersValues)
+ : ExpressionVisitor
+ {
+ protected override Expression VisitExtension(Expression expression)
+ {
+ expression = base.VisitExtension(expression);
+
+ switch (expression)
+ {
+ // Inlines array parameter of InExpression, transforming: 'item IN (@valuesArray)' to: 'item IN (value1, value2)'
+ case InExpression inExpression:
+ {
+ IReadOnlyList values;
+
+ switch (inExpression)
+ {
+ case { Values: IReadOnlyList values2 }:
+ values = values2;
+ break;
+
+ // TODO: IN with subquery (return immediately, nothing to do here)
+
+ case { ValuesParameter: SqlParameterExpression valuesParameter }:
+ {
+ var typeMapping = valuesParameter.TypeMapping;
+ var mutableValues = new List();
+ foreach (var value in (IEnumerable)parametersValues[valuesParameter.Name])
+ {
+ mutableValues.Add(sqlExpressionFactory.Constant(value, value?.GetType() ?? typeof(object), typeMapping));
+ }
+
+ values = mutableValues;
+ break;
+ }
+
+ default:
+ throw new UnreachableException();
+ }
+
+ 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 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 FullTextContainsAll(x, @keywordsArray) to FullTextContainsAll(x, keyword1, keyword2))
+ case SqlFunctionExpression
+ {
+ Name: "FullTextContainsAny" or "FullTextContainsAll",
+ Arguments: [var property, SqlParameterExpression { TypeMapping: { ElementTypeMapping: var elementTypeMapping }, Type: Type type } keywords]
+ } fullTextContainsAllAnyFunction
+ when type == typeof(string[]):
+ {
+ var keywordValues = new List();
+ foreach (var value in (IEnumerable)parametersValues[keywords.Name])
+ {
+ keywordValues.Add(sqlExpressionFactory.Constant(value, typeof(string), elementTypeMapping));
+ }
+
+ return sqlExpressionFactory.Function(
+ fullTextContainsAllAnyFunction.Name,
+ [property, .. keywordValues],
+ fullTextContainsAllAnyFunction.Type,
+ fullTextContainsAllAnyFunction.TypeMapping);
+ }
+
+ // Inlines array parameter of full-text score, transforming FullTextScore(x, @keywordsArray) to FullTextScore(x, [keyword1, keyword2]))
+ case SqlFunctionExpression
+ {
+ Name: "FullTextScore",
+ IsScoringFunction: true,
+ Arguments: [var property, SqlParameterExpression { TypeMapping: { ElementTypeMapping: not null } typeMapping } keywords]
+ } fullTextScoreFunction:
+ {
+ var keywordValues = new List();
+ foreach (var value in (IEnumerable)parametersValues[keywords.Name])
+ {
+ keywordValues.Add((string)value);
+ }
+
+ return new SqlFunctionExpression(
+ fullTextScoreFunction.Name,
+ isScoringFunction: true,
+ [property, sqlExpressionFactory.Constant(keywordValues, typeMapping)],
+ fullTextScoreFunction.Type,
+ fullTextScoreFunction.TypeMapping);
+ }
+
+ default:
+ return expression;
+ }
+ }
+ }
+}
diff --git a/src/EFCore.Cosmos/Query/Internal/CosmosShapedQueryCompilingExpressionVisitor.QueryingEnumerable.cs b/src/EFCore.Cosmos/Query/Internal/CosmosShapedQueryCompilingExpressionVisitor.QueryingEnumerable.cs
index 07b0c22115c..26c7d885cdc 100644
--- a/src/EFCore.Cosmos/Query/Internal/CosmosShapedQueryCompilingExpressionVisitor.QueryingEnumerable.cs
+++ b/src/EFCore.Cosmos/Query/Internal/CosmosShapedQueryCompilingExpressionVisitor.QueryingEnumerable.cs
@@ -71,7 +71,7 @@ IEnumerator IEnumerable.GetEnumerator()
private CosmosSqlQuery GenerateQuery()
=> _querySqlGeneratorFactory.Create().GetSqlQuery(
- (SelectExpression)new InExpressionValuesExpandingExpressionVisitor(
+ (SelectExpression)new ParameterInliner(
_sqlExpressionFactory,
_cosmosQueryContext.ParameterValues)
.Visit(_selectExpression),
diff --git a/src/EFCore.Cosmos/Query/Internal/Expressions/FragmentExpression.cs b/src/EFCore.Cosmos/Query/Internal/Expressions/FragmentExpression.cs
index cdb0bbff323..3efd431ca2f 100644
--- a/src/EFCore.Cosmos/Query/Internal/Expressions/FragmentExpression.cs
+++ b/src/EFCore.Cosmos/Query/Internal/Expressions/FragmentExpression.cs
@@ -23,6 +23,14 @@ public class FragmentExpression(string fragment) : Expression, IPrintableExpress
///
public virtual string Fragment { get; } = fragment;
+ ///
+ public override ExpressionType NodeType
+ => base.NodeType;
+
+ ///
+ public override Type Type
+ => typeof(object);
+
///
protected override Expression VisitChildren(ExpressionVisitor visitor)
=> this;
diff --git a/src/EFCore.Cosmos/Query/Internal/Expressions/SelectExpression.cs b/src/EFCore.Cosmos/Query/Internal/Expressions/SelectExpression.cs
index 3f31abdf5a8..38205061893 100644
--- a/src/EFCore.Cosmos/Query/Internal/Expressions/SelectExpression.cs
+++ b/src/EFCore.Cosmos/Query/Internal/Expressions/SelectExpression.cs
@@ -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;
@@ -381,6 +382,12 @@ public void ApplyOffset(SqlExpression sqlExpression)
///
public void ApplyOrdering(OrderingExpression orderingExpression)
{
+ if (orderingExpression is { Expression: SqlFunctionExpression { IsScoringFunction: true }, IsAscending: false })
+ {
+ throw new InvalidOperationException(
+ CosmosStrings.OrderByDescendingScoringFunction(nameof(Queryable.OrderByDescending), nameof(Queryable.OrderBy)));
+ }
+
_orderings.Clear();
_orderings.Add(orderingExpression);
}
@@ -393,6 +400,19 @@ public void ApplyOrdering(OrderingExpression orderingExpression)
///
public void AppendOrdering(OrderingExpression orderingExpression)
{
+ if (_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);
@@ -752,6 +772,11 @@ private void PrintSql(ExpressionPrinter expressionPrinter, bool withTags = true)
if (Orderings.Any())
{
expressionPrinter.AppendLine().Append("ORDER BY ");
+ if (Orderings is [{ Expression: SqlFunctionExpression { IsScoringFunction: true } }])
+ {
+ expressionPrinter.Append("RANK ");
+ }
+
expressionPrinter.VisitCollection(Orderings);
}
diff --git a/src/EFCore.Cosmos/Query/Internal/Expressions/SqlFunctionExpression.cs b/src/EFCore.Cosmos/Query/Internal/Expressions/SqlFunctionExpression.cs
index 91b53ca7039..1e805e080a9 100644
--- a/src/EFCore.Cosmos/Query/Internal/Expressions/SqlFunctionExpression.cs
+++ b/src/EFCore.Cosmos/Query/Internal/Expressions/SqlFunctionExpression.cs
@@ -3,6 +3,8 @@
// ReSharper disable once CheckNamespace
+using System.Diagnostics.CodeAnalysis;
+
namespace Microsoft.EntityFrameworkCore.Cosmos.Query.Internal;
///
@@ -24,10 +26,27 @@ public SqlFunctionExpression(
IEnumerable arguments,
Type type,
CoreTypeMapping? typeMapping)
+ : this(name, isScoringFunction: false, arguments, type, typeMapping)
+ {
+ }
+
+ ///
+ /// 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.
+ ///
+ public SqlFunctionExpression(
+ string name,
+ bool isScoringFunction,
+ IEnumerable arguments,
+ Type type,
+ CoreTypeMapping? typeMapping)
: base(type, typeMapping)
{
Name = name;
Arguments = arguments.ToList();
+ IsScoringFunction = isScoringFunction;
}
///
@@ -38,6 +57,14 @@ public SqlFunctionExpression(
///
public virtual string Name { get; }
+ ///
+ /// 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.
+ ///
+ public virtual bool IsScoringFunction { get; }
+
///
/// 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
@@ -63,7 +90,7 @@ protected override Expression VisitChildren(ExpressionVisitor visitor)
}
return changed
- ? new SqlFunctionExpression(Name, arguments, Type, TypeMapping)
+ ? new SqlFunctionExpression(Name, IsScoringFunction, arguments, Type, TypeMapping)
: this;
}
@@ -74,7 +101,7 @@ protected override Expression VisitChildren(ExpressionVisitor visitor)
/// doing so can result in application failures when updating to a new Entity Framework Core release.
///
public virtual SqlFunctionExpression ApplyTypeMapping(CoreTypeMapping? typeMapping)
- => new(Name, Arguments, Type, typeMapping ?? TypeMapping);
+ => new(Name, IsScoringFunction, Arguments, Type, typeMapping ?? TypeMapping);
///
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
@@ -85,7 +112,7 @@ public virtual SqlFunctionExpression ApplyTypeMapping(CoreTypeMapping? typeMappi
public virtual SqlFunctionExpression Update(IReadOnlyList arguments)
=> arguments.SequenceEqual(Arguments)
? this
- : new SqlFunctionExpression(Name, arguments, Type, TypeMapping);
+ : new SqlFunctionExpression(Name, IsScoringFunction, arguments, Type, TypeMapping);
///
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
diff --git a/src/EFCore.Cosmos/Query/Internal/ISqlExpressionFactory.cs b/src/EFCore.Cosmos/Query/Internal/ISqlExpressionFactory.cs
index 12c46524fc0..f462c37ff0f 100644
--- a/src/EFCore.Cosmos/Query/Internal/ISqlExpressionFactory.cs
+++ b/src/EFCore.Cosmos/Query/Internal/ISqlExpressionFactory.cs
@@ -243,6 +243,14 @@ public interface ISqlExpressionFactory
///
SqlExpression Function(string functionName, IEnumerable arguments, Type returnType, CoreTypeMapping? typeMapping = null);
+ ///
+ /// 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.
+ ///
+ SqlExpression ScoringFunction(string functionName, IEnumerable arguments, Type returnType, CoreTypeMapping? typeMapping = null);
+
///
/// 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
diff --git a/src/EFCore.Cosmos/Query/Internal/SqlExpressionFactory.cs b/src/EFCore.Cosmos/Query/Internal/SqlExpressionFactory.cs
index 48808848457..693450799ae 100644
--- a/src/EFCore.Cosmos/Query/Internal/SqlExpressionFactory.cs
+++ b/src/EFCore.Cosmos/Query/Internal/SqlExpressionFactory.cs
@@ -660,6 +660,27 @@ public virtual SqlExpression Function(
IEnumerable arguments,
Type returnType,
CoreTypeMapping? typeMapping = null)
+ => BuildFunction(functionName, isScoringFunction: false, arguments, returnType, typeMapping);
+
+ ///
+ /// 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.
+ ///
+ public virtual SqlExpression ScoringFunction(
+ string functionName,
+ IEnumerable arguments,
+ Type returnType,
+ CoreTypeMapping? typeMapping = null)
+ => BuildFunction(functionName, isScoringFunction: true, arguments, returnType, typeMapping);
+
+ private SqlExpression BuildFunction(
+ string functionName,
+ bool isScoringFunction,
+ IEnumerable arguments,
+ Type returnType,
+ CoreTypeMapping? typeMapping = null)
{
var typeMappedArguments = new List();
@@ -670,6 +691,7 @@ public virtual SqlExpression Function(
return new SqlFunctionExpression(
functionName,
+ isScoringFunction,
typeMappedArguments,
returnType,
typeMapping);
diff --git a/src/EFCore.Cosmos/Query/Internal/Translators/CosmosFullTextSearchTranslator.cs b/src/EFCore.Cosmos/Query/Internal/Translators/CosmosFullTextSearchTranslator.cs
new file mode 100644
index 00000000000..b7de73b0257
--- /dev/null
+++ b/src/EFCore.Cosmos/Query/Internal/Translators/CosmosFullTextSearchTranslator.cs
@@ -0,0 +1,86 @@
+// 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;
+
+namespace Microsoft.EntityFrameworkCore.Cosmos.Query.Internal;
+
+///
+/// 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.
+///
+public class CosmosFullTextSearchTranslator(ISqlExpressionFactory sqlExpressionFactory, ITypeMappingSource typeMappingSource)
+ : IMethodCallTranslator
+{
+ ///
+ /// 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.
+ ///
+ public SqlExpression? Translate(
+ SqlExpression? instance,
+ MethodInfo method,
+ IReadOnlyList arguments,
+ IDiagnosticsLogger logger)
+ {
+ if (method.DeclaringType != typeof(CosmosDbFunctionsExtensions))
+ {
+ return null;
+ }
+
+ return method.Name switch
+ {
+ nameof(CosmosDbFunctionsExtensions.FullTextContains)
+ when arguments is [_, var property, var keyword] => sqlExpressionFactory.Function(
+ "FullTextContains",
+ [property, keyword],
+ typeof(bool),
+ typeMappingSource.FindMapping(typeof(bool))),
+
+ nameof(CosmosDbFunctionsExtensions.FullTextScore)
+ when arguments is [_, var property, var keywords] => sqlExpressionFactory.ScoringFunction(
+ "FullTextScore",
+ [
+ property,
+ keywords,
+ ],
+ typeof(double),
+ typeMappingSource.FindMapping(typeof(double))),
+
+ nameof(CosmosDbFunctionsExtensions.Rrf)
+ when arguments is [_, ArrayConstantExpression functions] => sqlExpressionFactory.ScoringFunction(
+ "RRF",
+ functions.Items,
+ typeof(double),
+ typeMappingSource.FindMapping(typeof(double))),
+
+ nameof(CosmosDbFunctionsExtensions.FullTextContainsAny) or nameof(CosmosDbFunctionsExtensions.FullTextContainsAll)
+ when arguments is [_, SqlExpression property, SqlConstantExpression { Type: var keywordClrType, Value: string[] values } keywords]
+ && keywordClrType == typeof(string[]) => sqlExpressionFactory.Function(
+ method.Name == nameof(CosmosDbFunctionsExtensions.FullTextContainsAny) ? "FullTextContainsAny" : "FullTextContainsAll",
+ [property, ..values.Select(x => sqlExpressionFactory.Constant(x))],
+ typeof(bool),
+ typeMappingSource.FindMapping(typeof(bool))),
+
+ nameof(CosmosDbFunctionsExtensions.FullTextContainsAny) or nameof(CosmosDbFunctionsExtensions.FullTextContainsAll)
+ when arguments is [_, SqlExpression property, SqlParameterExpression { Type: var keywordClrType } keywords]
+ && keywordClrType == typeof(string[]) => sqlExpressionFactory.Function(
+ method.Name == nameof(CosmosDbFunctionsExtensions.FullTextContainsAny) ? "FullTextContainsAny" : "FullTextContainsAll",
+ [property, keywords],
+ typeof(bool),
+ typeMappingSource.FindMapping(typeof(bool))),
+
+ nameof(CosmosDbFunctionsExtensions.FullTextContainsAny) or nameof(CosmosDbFunctionsExtensions.FullTextContainsAll)
+ when arguments is [_, SqlExpression property, ArrayConstantExpression keywords] => sqlExpressionFactory.Function(
+ method.Name == nameof(CosmosDbFunctionsExtensions.FullTextContainsAny) ? "FullTextContainsAny" : "FullTextContainsAll",
+ [property, .. keywords.Items],
+ typeof(bool),
+ typeMappingSource.FindMapping(typeof(bool))),
+
+ _ => null
+ };
+ }
+}
diff --git a/src/EFCore.Cosmos/Query/Internal/Translators/CosmosVectorSearchTranslator.cs b/src/EFCore.Cosmos/Query/Internal/Translators/CosmosVectorSearchTranslator.cs
index 9c6e62d02a2..d22d18d0529 100644
--- a/src/EFCore.Cosmos/Query/Internal/Translators/CosmosVectorSearchTranslator.cs
+++ b/src/EFCore.Cosmos/Query/Internal/Translators/CosmosVectorSearchTranslator.cs
@@ -31,7 +31,7 @@ public class CosmosVectorSearchTranslator(ISqlExpressionFactory sqlExpressionFac
IDiagnosticsLogger logger)
{
if (method.DeclaringType != typeof(CosmosDbFunctionsExtensions)
- && method.Name != nameof(CosmosDbFunctionsExtensions.VectorDistance))
+ || method.Name != nameof(CosmosDbFunctionsExtensions.VectorDistance))
{
return null;
}
diff --git a/src/EFCore.Cosmos/Storage/Internal/ContainerProperties.cs b/src/EFCore.Cosmos/Storage/Internal/ContainerProperties.cs
index 78965ed3073..8c3ad424e65 100644
--- a/src/EFCore.Cosmos/Storage/Internal/ContainerProperties.cs
+++ b/src/EFCore.Cosmos/Storage/Internal/ContainerProperties.cs
@@ -18,4 +18,6 @@ public readonly record struct ContainerProperties(
int? DefaultTimeToLive,
ThroughputProperties? Throughput,
IReadOnlyList Indexes,
- IReadOnlyList<(IProperty Property, CosmosVectorType VectorType)> Vectors);
+ IReadOnlyList<(IProperty Property, CosmosVectorType VectorType)> Vectors,
+ string DefaultFullTextLanguage,
+ IReadOnlyList<(IProperty Property, string? Language)> FullTextProperties);
diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs
index 910a9a293de..df8dfc31480 100644
--- a/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs
+++ b/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs
@@ -8,6 +8,7 @@
using System.Text;
using Microsoft.EntityFrameworkCore.Cosmos.Diagnostics.Internal;
using Microsoft.EntityFrameworkCore.Cosmos.Infrastructure.Internal;
+using Microsoft.EntityFrameworkCore.Cosmos.Internal;
using Microsoft.EntityFrameworkCore.Cosmos.Metadata.Internal;
using Microsoft.EntityFrameworkCore.Internal;
using Newtonsoft.Json;
@@ -238,6 +239,8 @@ private static async Task CreateContainerIfNotExistsOnceAsync(
var partitionKeyPaths = parameters.PartitionKeyStoreNames.Select(e => "/" + e).ToList();
var vectorIndexes = new Collection();
+ var fullTextIndexPaths = new Collection();
+ var fullTextProperties = parametersTuple.Parameters.FullTextProperties.Select(x => x.Property).ToList();
foreach (var index in parameters.Indexes)
{
var vectorIndexType = (VectorIndexType?)index.FindAnnotation(CosmosAnnotationNames.VectorIndexType)?.Value;
@@ -247,17 +250,52 @@ private static async Task CreateContainerIfNotExistsOnceAsync(
Check.DebugAssert(index.Properties.Count == 1, "Vector index must have one property.");
vectorIndexes.Add(
- new VectorIndexPath { Path = "/" + index.Properties[0].GetJsonPropertyName(), Type = vectorIndexType.Value });
+ new VectorIndexPath { Path = GetJsonPropertyPathFromRoot(index.Properties[0]), Type = vectorIndexType.Value });
+ }
+
+ if (index.IsFullTextIndex() == true)
+ {
+ if (index.Properties.Count > 1)
+ {
+ throw new InvalidOperationException(
+ CosmosStrings.CompositeFullTextIndex(
+ index.DeclaringEntityType.DisplayName(),
+ string.Join(",", index.Properties.Select(e => e.Name))));
+ }
+
+ fullTextIndexPaths.Add(
+ new FullTextIndexPath { Path = GetJsonPropertyPathFromRoot(index.Properties[0]) });
}
}
+ var fullTextPaths = new Collection();
+ foreach (var fullTextProperty in parameters.FullTextProperties)
+ {
+ if (fullTextProperty.Property.ClrType != typeof(string))
+ {
+ throw new InvalidOperationException(
+ CosmosStrings.FullTextSearchConfiguredForUnsupportedPropertyType(
+ fullTextProperty.Property.DeclaringType.DisplayName(),
+ fullTextProperty.Property.Name,
+ fullTextProperty.Property.ClrType.Name));
+ }
+
+ fullTextPaths.Add(
+ new FullTextPath
+ {
+ Path = GetJsonPropertyPathFromRoot(fullTextProperty.Property),
+ // TODO: remove the fallback once Cosmos SDK allows optional language (see #35939)
+ Language = fullTextProperty.Language ?? parameters.DefaultFullTextLanguage ?? "en-US"
+ });
+ }
+
var embeddings = new Collection();
foreach (var tuple in parameters.Vectors)
{
embeddings.Add(
new Embedding
{
- Path = "/" + tuple.Property.GetJsonPropertyName(),
+ Path = GetJsonPropertyPathFromRoot(tuple.Property),
DataType = CosmosVectorType.CreateDefaultVectorDataType(tuple.Property.ClrType),
Dimensions = tuple.VectorType.Dimensions,
DistanceFunction = tuple.VectorType.DistanceFunction
@@ -271,14 +309,27 @@ private static async Task CreateContainerIfNotExistsOnceAsync(
AnalyticalStoreTimeToLiveInSeconds = parameters.AnalyticalStoreTimeToLiveInSeconds,
};
- if (embeddings.Any())
+ if (embeddings.Count != 0)
{
containerProperties.VectorEmbeddingPolicy = new VectorEmbeddingPolicy(embeddings);
}
- if (vectorIndexes.Any())
+ if (vectorIndexes.Count != 0 || fullTextIndexPaths.Count != 0)
{
- containerProperties.IndexingPolicy = new IndexingPolicy { VectorIndexes = vectorIndexes };
+ containerProperties.IndexingPolicy = new IndexingPolicy
+ {
+ VectorIndexes = vectorIndexes,
+ FullTextIndexes = fullTextIndexPaths
+ };
+ }
+
+ if (fullTextPaths.Count != 0)
+ {
+ containerProperties.FullTextPolicy = new FullTextPolicy
+ {
+ DefaultLanguage = parameters.DefaultFullTextLanguage,
+ FullTextPaths = fullTextPaths
+ };
}
var response = await wrapper.Client.GetDatabase(wrapper._databaseId).CreateContainerIfNotExistsAsync(
@@ -290,6 +341,26 @@ private static async Task CreateContainerIfNotExistsOnceAsync(
return response.StatusCode == HttpStatusCode.Created;
}
+ private static string GetJsonPropertyPathFromRoot(IReadOnlyProperty property)
+ => GetPathFromRoot((IReadOnlyEntityType)property.DeclaringType) + "/" + property.GetJsonPropertyName();
+
+ private static string GetPathFromRoot(IReadOnlyEntityType entityType)
+ {
+ if (entityType.IsOwned())
+ {
+ var ownership = entityType.FindOwnership()!;
+ var resultPath = GetPathFromRoot(ownership.PrincipalEntityType) + "/" + ownership.GetNavigation(pointsToPrincipal: false)!.TargetEntityType.GetContainingPropertyName();
+
+ return !ownership.IsUnique
+ ? throw new NotSupportedException(CosmosStrings.CreatingContainerWithFullTextOnCollectionNotSupported(resultPath))
+ : resultPath;
+ }
+ else
+ {
+ return "";
+ }
+ }
+
///
/// 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
diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseCreator.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseCreator.cs
index 79a6f678d0c..3b974e781b4 100644
--- a/src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseCreator.cs
+++ b/src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseCreator.cs
@@ -3,6 +3,7 @@
using Microsoft.EntityFrameworkCore.Cosmos.Internal;
using Microsoft.EntityFrameworkCore.Cosmos.Metadata.Internal;
+using Microsoft.EntityFrameworkCore.Internal;
namespace Microsoft.EntityFrameworkCore.Cosmos.Storage.Internal;
@@ -125,6 +126,8 @@ private static IEnumerable GetContainersToCreate(IModel mod
ThroughputProperties? throughput = null;
var indexes = new List();
var vectors = new List<(IProperty Property, CosmosVectorType VectorType)>();
+ string? defaultFullTextLanguage = null;
+ var fullTextProperties = new List<(IProperty Property, string? Language)>();
foreach (var entityType in mappedTypes)
{
@@ -136,15 +139,9 @@ private static IEnumerable GetContainersToCreate(IModel mod
analyticalTtl ??= entityType.GetAnalyticalStoreTimeToLive();
defaultTtl ??= entityType.GetDefaultTimeToLive();
throughput ??= entityType.GetThroughput();
- indexes.AddRange(entityType.GetIndexes());
+ defaultFullTextLanguage ??= entityType.GetDefaultFullTextSearchLanguage();
- foreach (var property in entityType.GetProperties())
- {
- if (property.FindTypeMapping() is CosmosVectorTypeMapping vectorTypeMapping)
- {
- vectors.Add((property, vectorTypeMapping.VectorType));
- }
- }
+ ProcessEntityType(entityType, indexes, vectors, fullTextProperties);
}
yield return new ContainerProperties(
@@ -154,7 +151,38 @@ private static IEnumerable GetContainersToCreate(IModel mod
defaultTtl,
throughput,
indexes,
- vectors);
+ vectors,
+ defaultFullTextLanguage ?? "en-US",
+ fullTextProperties);
+ }
+
+ static void ProcessEntityType(
+ IEntityType entityType,
+ List indexes,
+ List<(IProperty Property, CosmosVectorType VectorType)> vectors,
+ List<(IProperty Property, string? Language)> fullTextProperties)
+ {
+ indexes.AddRange(entityType.GetIndexes());
+
+ foreach (var property in entityType.GetProperties())
+ {
+ if (property.FindTypeMapping() is CosmosVectorTypeMapping vectorTypeMapping)
+ {
+ vectors.Add((property, vectorTypeMapping.VectorType));
+ }
+
+ if (property.GetIsFullTextSearchEnabled() == true)
+ {
+ fullTextProperties.Add((property, property.GetFullTextSearchLanguage()));
+ }
+ }
+
+ foreach (var ownedType in entityType.GetNavigations()
+ .Where(x => x.ForeignKey.IsOwnership && !x.IsOnDependent && !x.TargetEntityType.IsDocumentRoot())
+ .Select(x => x.TargetEntityType))
+ {
+ ProcessEntityType(ownedType, indexes, vectors, fullTextProperties);
+ }
}
}
diff --git a/test/EFCore.Cosmos.FunctionalTests/AddHocFullTextSearchCosmosTest.cs b/test/EFCore.Cosmos.FunctionalTests/AddHocFullTextSearchCosmosTest.cs
new file mode 100644
index 00000000000..3fcf7978bcd
--- /dev/null
+++ b/test/EFCore.Cosmos.FunctionalTests/AddHocFullTextSearchCosmosTest.cs
@@ -0,0 +1,456 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Azure.Cosmos;
+using Microsoft.EntityFrameworkCore.Cosmos.Internal;
+
+namespace Microsoft.EntityFrameworkCore;
+
+[CosmosCondition(CosmosCondition.DoesNotUseTokenCredential)]
+public class AddHocFullTextSearchCosmosTest(NonSharedFixture fixture) : NonSharedModelTestBase(fixture), IClassFixture
+{
+ protected override string StoreName
+ => "AdHocFullTextSearchTests";
+
+ protected override ITestStoreFactory TestStoreFactory
+ => CosmosTestStoreFactory.Instance;
+
+ #region CompositeFullTextIndex
+
+ [ConditionalFact]
+ public async Task Validate_composite_full_text_index_throws()
+ {
+ var message = (await Assert.ThrowsAsync(
+ () => InitializeAsync())).Message;
+
+ Assert.Equal(
+ CosmosStrings.CompositeFullTextIndex(
+ nameof(ContextCompositeFullTextIndex.Entity),
+ "Name,Number"),
+ message);
+ }
+
+ protected class ContextCompositeFullTextIndex(DbContextOptions options) : DbContext(options)
+ {
+ public DbSet Entities { get; set; } = null!;
+
+ public class Entity
+ {
+ public int Id { get; set; }
+ public string PartitionKey { get; set; } = null!;
+ public string Name { get; set; } = null!;
+ public int Number { get; set; }
+ }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ => modelBuilder.Entity(b =>
+ {
+ b.ToContainer("Entities");
+ b.HasPartitionKey(x => x.PartitionKey);
+ b.Property(x => x.Name).EnableFullTextSearch();
+ b.Property(x => x.Number).EnableFullTextSearch();
+ b.HasIndex(x => new { x.Name, x.Number }).IsFullTextIndex();
+ });
+ }
+
+ #endregion
+
+ #region FullTextIndexWithoutProperty
+
+ [ConditionalFact]
+ public async Task Validate_full_text_index_without_property()
+ {
+ var message = (await Assert.ThrowsAsync(
+ () => InitializeAsync())).Message;
+
+ Assert.Equal(
+ CosmosStrings.FullTextIndexOnNonFullTextProperty(
+ nameof(ContextFullTextIndexWithoutProperty.Entity),
+ nameof(ContextFullTextIndexWithoutProperty.Entity.Name),
+ nameof(CosmosPropertyBuilderExtensions.EnableFullTextSearch)),
+ message);
+ }
+
+ protected class ContextFullTextIndexWithoutProperty(DbContextOptions options) : DbContext(options)
+ {
+ public DbSet Entities { get; set; } = null!;
+
+ public class Entity
+ {
+ public int Id { get; set; }
+ public string PartitionKey { get; set; } = null!;
+ public string Name { get; set; } = null!;
+ public int Number { get; set; }
+ }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ => modelBuilder.Entity(b =>
+ {
+ b.ToContainer("Entities");
+ b.HasPartitionKey(x => x.PartitionKey);
+ b.HasIndex(x => x.Name).IsFullTextIndex();
+ });
+ }
+
+ #endregion
+
+ #region FullTextPropertyOnCollectionNavigation
+
+ [ConditionalFact]
+ public async Task Validate_full_text_property_on_collection_navigation_container_creation()
+ {
+ var message = (await Assert.ThrowsAsync(
+ () => InitializeAsync())).Message;
+
+ Assert.Equal(
+ CosmosStrings.CreatingContainerWithFullTextOnCollectionNotSupported("/Collection"),
+ message);
+ }
+
+ protected class ContextFullTextPropertyOnCollectionNavigation(DbContextOptions options) : DbContext(options)
+ {
+ public DbSet Entities { get; set; } = null!;
+
+ public class Entity
+ {
+ public int Id { get; set; }
+ public string PartitionKey { get; set; } = null!;
+ public List Collection { get; set; } = null!;
+ }
+
+ public class Json
+ {
+ public string Name { get; set; } = null!;
+ }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ => modelBuilder.Entity(b =>
+ {
+ b.ToContainer("Entities");
+ b.HasPartitionKey(x => x.PartitionKey);
+ b.OwnsMany(x => x.Collection, bb =>
+ {
+ bb.Property(x => x.Name).EnableFullTextSearch();
+ bb.HasIndex(x => x.Name).IsFullTextIndex();
+ });
+ });
+ }
+
+ #endregion
+
+ #region FullTextOnNonStringProperty
+
+ [ConditionalFact]
+ public async Task Validate_full_text_on_non_string_property()
+ {
+ var message = (await Assert.ThrowsAsync(
+ () => InitializeAsync())).Message;
+
+ Assert.Equal(
+ CosmosStrings.FullTextSearchConfiguredForUnsupportedPropertyType(
+ nameof(ContextFullTextOnNonStringProperty.Entity),
+ nameof(ContextFullTextOnNonStringProperty.Entity.Number),
+ typeof(int).Name),
+ message);
+ }
+
+ protected class ContextFullTextOnNonStringProperty(DbContextOptions options) : DbContext(options)
+ {
+ public DbSet Entities { get; set; } = null!;
+
+ public class Entity
+ {
+ public int Id { get; set; }
+ public string PartitionKey { get; set; } = null!;
+ public int Number { get; set; }
+ }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ => modelBuilder.Entity(b =>
+ {
+ b.ToContainer("Entities");
+ b.HasPartitionKey(x => x.PartitionKey);
+ b.Property(x => x.Number).EnableFullTextSearch();
+ b.HasIndex(x => x.Number).IsFullTextIndex();
+ });
+ }
+
+ #endregion
+
+ #region SettingDefaultFullTextSearchLanguage
+
+ [ConditionalFact]
+ public async Task Set_unsupported_full_text_search_default_language()
+ {
+ var exception = (await Assert.ThrowsAsync(
+ () => InitializeAsync()));
+
+ Assert.Contains("The Full Text Policy contains an unsupported language pl-PL.", exception.Message);
+ }
+
+ protected class ContextSettingDefaultFullTextSearchLanguage(DbContextOptions options) : DbContext(options)
+ {
+ public DbSet Entities { get; set; } = null!;
+
+ public class Entity
+ {
+ public int Id { get; set; }
+ public string PartitionKey { get; set; } = null!;
+ public string Name { get; set; } = null!;
+ public int Number { get; set; }
+ }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ => modelBuilder.Entity(b =>
+ {
+ b.ToContainer("Entities").HasDefaultFullTextLanguage("pl-PL");
+ b.HasPartitionKey(x => x.PartitionKey);
+ b.Property(x => x.Name).EnableFullTextSearch();
+ b.HasIndex(x => x.Name).IsFullTextIndex();
+ });
+ }
+
+ #endregion
+
+ #region DefaultFullTextSearchLanguageMismatch
+
+ [ConditionalFact]
+ public async Task Set_different_full_text_search_default_language_for_the_same_container()
+ {
+ var message = (await Assert.ThrowsAsync(
+ () => InitializeAsync())).Message;
+
+ Assert.Equal(
+ CosmosStrings.FullTextSearchDefaultLanguageMismatch(
+ "pl-PL",
+ nameof(ContextDefaultFullTextSearchLanguageMismatch.Entity1),
+ nameof(ContextDefaultFullTextSearchLanguageMismatch.Entity2),
+ "en-US",
+ "Entities"), message);
+ }
+
+ protected class ContextDefaultFullTextSearchLanguageMismatch(DbContextOptions options) : DbContext(options)
+ {
+ public DbSet Entities1 { get; set; } = null!;
+ public DbSet Entities2 { get; set; } = null!;
+
+ public class Entity1
+ {
+ public int Id { get; set; }
+ public string PartitionKey { get; set; } = null!;
+ public string Name { get; set; } = null!;
+ public int Number { get; set; }
+ }
+
+ public class Entity2
+ {
+ public int Id { get; set; }
+ public string PartitionKey { get; set; } = null!;
+ public string Name { get; set; } = null!;
+ public int Number { get; set; }
+ }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(b =>
+ {
+ b.ToContainer("Entities").HasDefaultFullTextLanguage("pl-PL");
+ b.HasPartitionKey(x => x.PartitionKey);
+ b.Property(x => x.Name).EnableFullTextSearch();
+ b.HasIndex(x => x.Name).IsFullTextIndex();
+ });
+
+ modelBuilder.Entity(b =>
+ {
+ b.ToContainer("Entities").HasDefaultFullTextLanguage("en-US");
+ b.HasPartitionKey(x => x.PartitionKey);
+ b.Property(x => x.Name).EnableFullTextSearch();
+ b.HasIndex(x => x.Name).IsFullTextIndex();
+ });
+ }
+ }
+
+ #endregion
+
+ #region DefaultFullTextSearchLanguageNoMismatchWhenNotSpecified
+
+ [ConditionalFact]
+ public async Task Explicitly_setting_default_full_text_language_doesnt_clash_with_not_setting_it_on_other_entity_for_the_same_container()
+ {
+ var exception = (await Assert.ThrowsAsync(
+ () => InitializeAsync()));
+
+ Assert.Contains("The Full Text Policy contains an unsupported language pl-PL.", exception.Message);
+ }
+
+ protected class ContextDefaultFullTextSearchLanguageNoMismatchWhenNotSpecified(DbContextOptions options) : DbContext(options)
+ {
+ public DbSet Entities1 { get; set; } = null!;
+ public DbSet Entities2 { get; set; } = null!;
+ public DbSet Entities3 { get; set; } = null!;
+
+ public class Entity1
+ {
+ public int Id { get; set; }
+ public string PartitionKey { get; set; } = null!;
+ public string Name { get; set; } = null!;
+ public int Number { get; set; }
+ }
+
+ public class Entity2
+ {
+ public int Id { get; set; }
+ public string PartitionKey { get; set; } = null!;
+ public string Name { get; set; } = null!;
+ public int Number { get; set; }
+ }
+
+ public class Entity3
+ {
+ public int Id { get; set; }
+ public string PartitionKey { get; set; } = null!;
+ public string Name { get; set; } = null!;
+ public int Number { get; set; }
+ }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(b =>
+ {
+ b.ToContainer("Entities");
+ b.HasPartitionKey(x => x.PartitionKey);
+ b.Property(x => x.Name).EnableFullTextSearch();
+ b.HasIndex(x => x.Name).IsFullTextIndex();
+ });
+
+ modelBuilder.Entity(b =>
+ {
+ b.ToContainer("Entities").HasDefaultFullTextLanguage("pl-PL");
+ b.HasPartitionKey(x => x.PartitionKey);
+ b.Property(x => x.Name).EnableFullTextSearch();
+ b.HasIndex(x => x.Name).IsFullTextIndex();
+ });
+
+ modelBuilder.Entity(b =>
+ {
+ b.ToContainer("Entities");
+ b.HasPartitionKey(x => x.PartitionKey);
+ b.Property(x => x.Name).EnableFullTextSearch();
+ b.HasIndex(x => x.Name).IsFullTextIndex();
+ });
+ }
+ }
+
+ #endregion
+
+ #region DefaultFullTextSearchLanguageUsedWhenPropertyDoesntSpecifyOneExplicitly
+
+ [ConditionalFact]
+ public async Task Default_full_text_language_is_used_for_full_text_properties_if_they_dont_specify_language_themselves()
+ {
+ var exception = (await Assert.ThrowsAsync(
+ () => InitializeAsync()));
+
+ Assert.Contains("The Full Text Policy contains an unsupported language pl-PL.", exception.Message);
+ }
+
+ protected class ContextDefaultFullTextSearchLanguageUsedWhenPropertyDoesntSpecifyOneExplicitly(DbContextOptions options) : DbContext(options)
+ {
+ public DbSet Entities { get; set; } = null!;
+
+ public class Entity
+ {
+ public int Id { get; set; }
+ public string PartitionKey { get; set; } = null!;
+ public string Name { get; set; } = null!;
+ public int Number { get; set; }
+ }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ => modelBuilder.Entity(b =>
+ {
+ b.ToContainer("Entities").HasDefaultFullTextLanguage("pl-PL");
+ b.HasPartitionKey(x => x.PartitionKey);
+ b.Property(x => x.Name).EnableFullTextSearch();
+ b.HasIndex(x => x.Name).IsFullTextIndex();
+ });
+ }
+
+ #endregion
+
+ #region ExplicitFullTextLanguageOverridesTheDefault
+
+ [ConditionalFact]
+ public async Task Explicitly_setting_full_text_language_overrides_default()
+ {
+ var exception = (await Assert.ThrowsAsync(
+ () => InitializeAsync()));
+
+ Assert.Contains("The Full Text Policy contains an unsupported language pl-PL.", exception.Message);
+ }
+
+ protected class ContextExplicitFullTextLanguageOverridesTheDefault(DbContextOptions options) : DbContext(options)
+ {
+ public DbSet Entities { get; set; } = null!;
+
+ public class Entity
+ {
+ public int Id { get; set; }
+ public string PartitionKey { get; set; } = null!;
+ public string Name { get; set; } = null!;
+ public int Number { get; set; }
+ }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ => modelBuilder.Entity(b =>
+ {
+ b.ToContainer("Entities").HasDefaultFullTextLanguage("pl-PL");
+ b.HasPartitionKey(x => x.PartitionKey);
+ b.Property(x => x.Name).EnableFullTextSearch("de-DE");
+ b.HasIndex(x => x.Name).IsFullTextIndex();
+ });
+ }
+
+ #endregion
+
+ #region EnableThenDisable
+
+ [ConditionalFact]
+ public async Task Enable_full_text_search_for_property_then_disable_it()
+ {
+ var message = (await Assert.ThrowsAsync(
+ () => InitializeAsync())).Message;
+
+ Assert.Equal(
+ CosmosStrings.FullTextIndexOnNonFullTextProperty(
+ nameof(ContextFullTextIndexWithoutProperty.Entity),
+ nameof(ContextFullTextIndexWithoutProperty.Entity.Name),
+ nameof(CosmosPropertyBuilderExtensions.EnableFullTextSearch)),
+ message);
+ }
+
+ protected class ContextEnableThenDisable(DbContextOptions options) : DbContext(options)
+ {
+ public DbSet Entities { get; set; } = null!;
+
+ public class Entity
+ {
+ public int Id { get; set; }
+ public string PartitionKey { get; set; } = null!;
+ public string Name { get; set; } = null!;
+ public int Number { get; set; }
+ }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ => modelBuilder.Entity(b =>
+ {
+ b.ToContainer("Entities");
+ b.HasPartitionKey(x => x.PartitionKey);
+ b.Property(x => x.Name).EnableFullTextSearch("de-DE");
+ b.Property(x => x.Name).EnableFullTextSearch(language: null, enabled: false);
+ b.HasIndex(x => x.Name).IsFullTextIndex();
+ });
+ }
+
+ #endregion
+}
diff --git a/test/EFCore.Cosmos.FunctionalTests/FullTextSearchCosmosTest.cs b/test/EFCore.Cosmos.FunctionalTests/FullTextSearchCosmosTest.cs
new file mode 100644
index 00000000000..b74d601792b
--- /dev/null
+++ b/test/EFCore.Cosmos.FunctionalTests/FullTextSearchCosmosTest.cs
@@ -0,0 +1,1112 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Azure.Cosmos;
+using Microsoft.EntityFrameworkCore.Cosmos.Extensions;
+using Microsoft.EntityFrameworkCore.Cosmos.Internal;
+
+namespace Microsoft.EntityFrameworkCore;
+
+[CosmosCondition(CosmosCondition.DoesNotUseTokenCredential)]
+public class FullTextSearchCosmosTest : IClassFixture
+{
+ public FullTextSearchCosmosTest(FullTextSearchFixture fixture, ITestOutputHelper testOutputHelper)
+ {
+ Fixture = fixture;
+ _testOutputHelper = testOutputHelper;
+ fixture.TestSqlLoggerFactory.Clear();
+ }
+
+ protected FullTextSearchFixture Fixture { get; }
+
+ private readonly ITestOutputHelper _testOutputHelper;
+
+ [ConditionalFact]
+ public virtual async Task Use_FullTextContains_in_predicate_using_constant_argument()
+ {
+ await using var context = CreateContext();
+
+ var result = await context.Set()
+ .Where(x => EF.Functions.FullTextContains(x.Description, "beaver"))
+ .ToListAsync();
+
+ Assert.Equal(3, result.Count);
+ Assert.True(result.All(x => x.Description.Contains("beaver")));
+
+ AssertSql(
+"""
+SELECT VALUE c
+FROM root c
+WHERE FullTextContains(c["Description"], "beaver")
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task Use_FullTextContains_in_predicate_using_parameter_argument()
+ {
+ await using var context = CreateContext();
+
+ var beaver = "beaver";
+ var result = await context.Set()
+ .Where(x => EF.Functions.FullTextContains(x.Description, beaver))
+ .ToListAsync();
+
+ Assert.Equal(3, result.Count);
+ Assert.True(result.All(x => x.Description.Contains("beaver")));
+
+ AssertSql(
+"""
+@beaver='beaver'
+
+SELECT VALUE c
+FROM root c
+WHERE FullTextContains(c["Description"], @beaver)
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task Use_FullTextContainsAny_constant_in_predicate()
+ {
+ await using var context = CreateContext();
+
+ var result = await context.Set()
+ .Where(x => EF.Functions.FullTextContainsAny(x.Description, "bat"))
+ .ToListAsync();
+
+ Assert.Equal(2, result.Count);
+ Assert.True(result.All(x => x.Description.Contains("bat")));
+
+ AssertSql(
+"""
+SELECT VALUE c
+FROM root c
+WHERE FullTextContainsAny(c["Description"], "bat")
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task Use_FullTextContainsAny_constant_array_in_predicate()
+ {
+ await using var context = CreateContext();
+
+ var result = await context.Set()
+ .Where(x => EF.Functions.FullTextContainsAny(x.Description, new[] { "bat", "beaver" }))
+ .ToListAsync();
+
+ Assert.Equal(4, result.Count);
+ Assert.True(result.All(x => x.Description.Contains("bat") || x.Description.Contains("beaver")));
+
+ AssertSql(
+"""
+SELECT VALUE c
+FROM root c
+WHERE FullTextContainsAny(c["Description"], "bat", "beaver")
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task Use_FullTextContainsAny_mixed_in_predicate()
+ {
+ await using var context = CreateContext();
+
+ var beaver = "beaver";
+
+ var result = await context.Set()
+ .Where(x => EF.Functions.FullTextContainsAny(x.Description, beaver, "bat"))
+ .ToListAsync();
+
+ Assert.Equal(4, result.Count);
+ Assert.True(result.All(x => x.Description.Contains("beaver") || x.Description.Contains("bat")));
+
+ AssertSql(
+"""
+@beaver='beaver'
+
+SELECT VALUE c
+FROM root c
+WHERE FullTextContainsAny(c["Description"], @beaver, "bat")
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task Use_FullTextContainsAll_in_predicate()
+ {
+ await using var context = CreateContext();
+
+ var beaver = "beaver";
+ var result = await context.Set()
+ .Where(x => EF.Functions.FullTextContainsAll(x.Description, beaver, "salmon", "frog"))
+ .ToListAsync();
+
+ Assert.Equal(1, result.Count);
+ Assert.True(result.All(x => x.Description.Contains("beaver") && x.Description.Contains("salmon") && x.Description.Contains("frog")));
+
+ AssertSql(
+"""
+@beaver='beaver'
+
+SELECT VALUE c
+FROM root c
+WHERE FullTextContainsAll(c["Description"], @beaver, "salmon", "frog")
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task Use_FullTextContainsAll_in_predicate_parameter()
+ {
+ await using var context = CreateContext();
+
+ var beaver = "beaver";
+ var result = await context.Set()
+ .Where(x => EF.Functions.FullTextContainsAll(x.Description, beaver))
+ .ToListAsync();
+
+ Assert.Equal(3, result.Count);
+ Assert.True(result.All(x => x.Description.Contains("beaver")));
+
+ AssertSql(
+"""
+@beaver='beaver'
+
+SELECT VALUE c
+FROM root c
+WHERE FullTextContainsAll(c["Description"], @beaver)
+""");
+ }
+
+
+ [ConditionalFact]
+ public virtual async Task Use_FullTextContainsAll_in_predicate_with_parameterized_keyword_list()
+ {
+ await using var context = CreateContext();
+
+ var keywords = new string[] { "beaver", "salmon", "frog" };
+ var result = await context.Set()
+ .Where(x => EF.Functions.FullTextContainsAll(x.Description, keywords))
+ .ToListAsync();
+
+ Assert.Equal(1, result.Count);
+ Assert.True(result.All(x => x.Description.Contains("beaver") && x.Description.Contains("salmon") && x.Description.Contains("frog")));
+
+ AssertSql(
+"""
+SELECT VALUE c
+FROM root c
+WHERE FullTextContainsAll(c["Description"], "beaver", "salmon", "frog")
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task Use_FullTextContains_in_projection_using_constant_argument()
+ {
+ await using var context = CreateContext();
+
+ var result = await context.Set()
+ .OrderBy(x => x.Id)
+ .Select(x => new { x.Description, ContainsBeaver = EF.Functions.FullTextContains(x.Description, "beaver") })
+ .ToListAsync();
+
+ Assert.True(result.All(x => x.Description.Contains("beaver") == x.ContainsBeaver));
+
+ AssertSql(
+"""
+SELECT c["Description"], FullTextContains(c["Description"], "beaver") AS ContainsBeaver
+FROM root c
+ORDER BY c["Id"]
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task Use_FullTextContains_in_projection_using_parameter_argument()
+ {
+ await using var context = CreateContext();
+
+ var beaver = "beaver";
+ var result = await context.Set()
+ .OrderBy(x => x.Id)
+ .Select(x => new { x.Description, ContainsBeaver = EF.Functions.FullTextContains(x.Description, beaver) })
+ .ToListAsync();
+
+ Assert.True(result.All(x => x.Description.Contains("beaver") == x.ContainsBeaver));
+
+ AssertSql(
+"""
+@beaver='beaver'
+
+SELECT c["Description"], FullTextContains(c["Description"], @beaver) AS ContainsBeaver
+FROM root c
+ORDER BY c["Id"]
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task Use_FullTextContains_in_projection_using_complex_expression()
+ {
+ await using var context = CreateContext();
+
+ var beaver = "beaver";
+ var result = await context.Set()
+ .OrderBy(x => x.Id)
+ .Select(x => new { x.Id, x.Description, ContainsBeaverOrSometimesDuck = EF.Functions.FullTextContains(x.Description, x.Id < 3 ? beaver : "duck") })
+ .ToListAsync();
+
+ Assert.True(result.All(x => (x.Id < 3 ? x.Description.Contains("beaver") : x.Description.Contains("duck")) == x.ContainsBeaverOrSometimesDuck));
+
+ AssertSql(
+"""
+@beaver='beaver'
+
+SELECT c["Id"], c["Description"], FullTextContains(c["Description"], ((c["Id"] < 3) ? @beaver : "duck")) AS ContainsBeaverOrSometimesDuck
+FROM root c
+ORDER BY c["Id"]
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task Use_FullTextContains_non_property()
+ {
+ await using var context = CreateContext();
+
+ var result = await context.Set()
+ .Where(x => EF.Functions.FullTextContains("habitat is the natural environment in which a particular species thrives", x.PartitionKey))
+ .ToListAsync();
+
+ AssertSql(
+"""
+SELECT VALUE c
+FROM root c
+WHERE FullTextContains("habitat is the natural environment in which a particular species thrives", c["PartitionKey"])
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task OrderByRank_FullTextScore_constant()
+ {
+ await using var context = CreateContext();
+
+ var result = await context.Set()
+ .OrderBy(x => EF.Functions.FullTextScore(x.Description, "otter"))
+ .ToListAsync();
+
+ AssertSql(
+"""
+SELECT VALUE c
+FROM root c
+ORDER BY RANK FullTextScore(c["Description"], ["otter"])
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task OrderByRank_FullTextScore_constants()
+ {
+ await using var context = CreateContext();
+
+ var result = await context.Set()
+ .OrderBy(x => EF.Functions.FullTextScore(x.Description, "otter", "beaver"))
+ .ToListAsync();
+
+ AssertSql(
+"""
+SELECT VALUE c
+FROM root c
+ORDER BY RANK FullTextScore(c["Description"], ["otter","beaver"])
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task OrderByRank_FullTextScore_constant_array()
+ {
+ await using var context = CreateContext();
+
+ var result = await context.Set()
+ .OrderBy(x => EF.Functions.FullTextScore(x.Description, new string[] { "otter", "beaver" }))
+ .ToListAsync();
+
+ AssertSql(
+"""
+SELECT VALUE c
+FROM root c
+ORDER BY RANK FullTextScore(c["Description"], ["otter","beaver"])
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task OrderByRank_FullTextScore_constant_array_with_one_element()
+ {
+ await using var context = CreateContext();
+
+ var result = await context.Set()
+ .OrderBy(x => EF.Functions.FullTextScore(x.Description, new string[] { "otter" }))
+ .ToListAsync();
+
+ AssertSql(
+"""
+SELECT VALUE c
+FROM root c
+ORDER BY RANK FullTextScore(c["Description"], ["otter"])
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task OrderByRank_FullTextScore_parameter_array()
+ {
+ await using var context = CreateContext();
+
+ var prm = new string[] { "otter", "beaver" };
+ var result = await context.Set()
+ .OrderBy(x => EF.Functions.FullTextScore(x.Description, prm))
+ .ToListAsync();
+
+ AssertSql(
+"""
+SELECT VALUE c
+FROM root c
+ORDER BY RANK FullTextScore(c["Description"], ["otter","beaver"])
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task OrderByRank_FullTextScore_using_parameters()
+ {
+ await using var context = CreateContext();
+
+ var otter = "otter";
+ var beaver = "beaver";
+
+ var result = await context.Set()
+ .OrderBy(x => EF.Functions.FullTextScore(x.Description, new string[] { otter, beaver }))
+ .ToListAsync();
+
+ AssertSql(
+"""
+@otter='otter'
+@beaver='beaver'
+
+SELECT VALUE c
+FROM root c
+ORDER BY RANK FullTextScore(c["Description"], [@otter, @beaver])
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task OrderByRank_FullTextScore_using_one_parameter()
+ {
+ await using var context = CreateContext();
+
+ var otter = "otter";
+
+ var result = await context.Set()
+ .OrderBy(x => EF.Functions.FullTextScore(x.Description, otter))
+ .ToListAsync();
+
+ AssertSql(
+"""
+@otter='otter'
+
+SELECT VALUE c
+FROM root c
+ORDER BY RANK FullTextScore(c["Description"], [@otter])
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task OrderByRank_FullTextScore_using_parameters_constant_mix()
+ {
+ await using var context = CreateContext();
+
+ var beaver = "beaver";
+
+ var result = await context.Set()
+ .OrderBy(x => EF.Functions.FullTextScore(x.Description, new string[] { "otter", beaver }))
+ .ToListAsync();
+
+ AssertSql(
+"""
+@beaver='beaver'
+
+SELECT VALUE c
+FROM root c
+ORDER BY RANK FullTextScore(c["Description"], ["otter", @beaver])
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task OrderByRank_FullTextScore_using_parameter()
+ {
+ await using var context = CreateContext();
+
+ var otter = "otter";
+
+ var result = await context.Set()
+ .OrderBy(x => EF.Functions.FullTextScore(x.Description, otter))
+ .ToListAsync();
+
+ AssertSql(
+"""
+@otter='otter'
+
+SELECT VALUE c
+FROM root c
+ORDER BY RANK FullTextScore(c["Description"], [@otter])
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task OrderByRank_FullTextScore_using_complex_expression()
+ {
+ await using var context = CreateContext();
+
+ var otter = "otter";
+
+ var message = (await Assert.ThrowsAsync(
+ () => context.Set()
+ .OrderBy(x => EF.Functions.FullTextScore(x.Description, new string[] { x.Id > 2 ? otter : "beaver" }))
+ .ToListAsync())).Message;
+
+ Assert.Contains(
+ "The second argument of the FullTextScore function must be a non-empty array of string literals.",
+ message);
+ }
+
+ [ConditionalFact]
+ public virtual async Task Select_FullTextScore()
+ {
+ await using var context = CreateContext();
+
+ var message = (await Assert.ThrowsAsync(
+ () => context.Set()
+ .Select(x => EF.Functions.FullTextScore(x.Description, new string[] { "otter", "beaver" }))
+ .ToListAsync())).Message;
+
+ Assert.Contains(
+ "The FullTextScore function is only allowed in the ORDER BY RANK clause.",
+ message);
+ }
+
+ [ConditionalFact]
+ public virtual async Task OrderByRank_FullTextScore_on_non_FTS_property()
+ {
+ await using var context = CreateContext();
+ var result = await context.Set()
+ .OrderBy(x => EF.Functions.FullTextScore(x.PartitionKey, new string[] { "taxonomy" }))
+ .ToListAsync();
+
+ AssertSql(
+"""
+SELECT VALUE c
+FROM root c
+ORDER BY RANK FullTextScore(c["PartitionKey"], ["taxonomy"])
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task OrderByRank_with_RRF_using_two_FullTextScore_functions()
+ {
+ await using var context = CreateContext();
+
+ var result = await context.Set()
+ .OrderBy(x => EF.Functions.Rrf(
+ EF.Functions.FullTextScore(x.Description, new string[] { "beaver" }),
+ EF.Functions.FullTextScore(x.Description, new string[] { "otter", "bat" })))
+ .ToListAsync();
+
+ AssertSql(
+"""
+SELECT VALUE c
+FROM root c
+ORDER BY RANK RRF(FullTextScore(c["Description"], ["beaver"]), FullTextScore(c["Description"], ["otter","bat"]))
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task OrderByRank_with_nested_RRF()
+ {
+ await using var context = CreateContext();
+
+ var message = (await Assert.ThrowsAsync(
+ () => context.Set().OrderBy(x => EF.Functions.Rrf(
+ EF.Functions.Rrf(
+ EF.Functions.FullTextScore(x.Description, new string[] { "bison" }),
+ EF.Functions.FullTextScore(x.Description, new string[] { "fox", "bat" })),
+ EF.Functions.FullTextScore(x.Description, new string[] { "beaver" }),
+ EF.Functions.FullTextScore(x.Description, new string[] { "otter", "bat" })))
+ .ToListAsync())).Message;
+
+ // TODO: this doesn't seem right
+ Assert.Contains(
+ "'RRF' is not a recognized built-in function name.",
+ message);
+ }
+
+ [ConditionalFact]
+ public virtual async Task OrderByRank_with_RRF_with_one_argument()
+ {
+ await using var context = CreateContext();
+
+ var message = (await Assert.ThrowsAsync(
+ () => context.Set()
+ .OrderBy(x => EF.Functions.Rrf(EF.Functions.FullTextScore(x.Description, new string[] { "beaver" })))
+ .ToListAsync())).Message;
+
+ // TODO: this doesn't seem right
+ Assert.Contains(
+ "The ORDER BY RANK clause must be followed by a VectorDistance and/or a FullTextScore function call.",
+ message);
+ }
+
+
+ [ConditionalFact]
+ public virtual async Task OrderByRank_RRF_with_non_function_argument()
+ {
+ await using var context = CreateContext();
+ var message = (await Assert.ThrowsAsync(
+ () => context.Set()
+ .OrderBy(x => EF.Functions.Rrf(
+ EF.Functions.FullTextScore(x.Description, new string[] { "beaver" }),
+ 20.5d))
+ .ToListAsync())).Message;
+
+ Assert.Contains(
+ "The ORDER BY RANK clause must be followed by a VectorDistance and/or a FullTextScore function call.",
+ message);
+ }
+
+ [ConditionalFact]
+ public virtual async Task OrderByRank_Take()
+ {
+ await using var context = CreateContext();
+ var result = await context.Set()
+ .OrderBy(x => EF.Functions.FullTextScore(x.Description, new string[] { "beaver" }))
+ .Take(10)
+ .ToListAsync();
+
+ AssertSql(
+"""
+SELECT VALUE c
+FROM root c
+ORDER BY RANK FullTextScore(c["Description"], ["beaver"])
+OFFSET 0 LIMIT 10
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task OrderByRank_Skip_Take()
+ {
+ await using var context = CreateContext();
+ var result = await context.Set()
+ .OrderBy(x => EF.Functions.FullTextScore(x.Description, new string[] { "beaver", "dolphin" }))
+ .Skip(1)
+ .Take(20)
+ .ToListAsync();
+
+ AssertSql(
+"""
+SELECT VALUE c
+FROM root c
+ORDER BY RANK FullTextScore(c["Description"], ["beaver","dolphin"])
+OFFSET 1 LIMIT 20
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task OrderByDescending_FullTextScore()
+ {
+ await using var context = CreateContext();
+ var message = (await Assert.ThrowsAsync(
+ () => context.Set()
+ .OrderByDescending(x => EF.Functions.FullTextScore(x.Description, new string[] { "beaver", "dolphin" }))
+ .ToListAsync())).Message;
+
+ Assert.Equal(CosmosStrings.OrderByDescendingScoringFunction("OrderByDescending", "OrderBy"), message);
+ }
+
+ [ConditionalFact]
+ public virtual async Task OrderBy_scoring_function_overridden_by_another()
+ {
+ await using var context = CreateContext();
+ var result = await context.Set()
+ .OrderBy(x => EF.Functions.FullTextScore(x.Description, new string[] { "beaver", "dolphin", "first" }))
+ .OrderBy(x => EF.Functions.FullTextScore(x.Description, new string[] { "beaver", "dolphin", "second" }))
+ .ToListAsync();
+
+ AssertSql(
+"""
+SELECT VALUE c
+FROM root c
+ORDER BY RANK FullTextScore(c["Description"], ["beaver","dolphin","second"])
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task OrderBy_scoring_function_overridden_by_regular_OrderBy()
+ {
+ await using var context = CreateContext();
+ var result = await context.Set()
+ .OrderBy(x => EF.Functions.FullTextScore(x.Description, new string[] { "beaver", "dolphin" }))
+ .OrderBy(x => x.Name)
+ .ToListAsync();
+
+ AssertSql(
+"""
+SELECT VALUE c
+FROM root c
+ORDER BY c["Name"]
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task Regular_OrderBy_overridden_by_OrderBy_using_scoring_function()
+ {
+ await using var context = CreateContext();
+ var result = await context.Set()
+ .OrderBy(x => x.Name)
+ .OrderBy(x => EF.Functions.FullTextScore(x.Description, new string[] { "beaver", "dolphin" }))
+ .ToListAsync();
+
+ AssertSql(
+"""
+SELECT VALUE c
+FROM root c
+ORDER BY RANK FullTextScore(c["Description"], ["beaver","dolphin"])
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task OrderBy_scoring_function_ThenBy_scoring_function()
+ {
+ await using var context = CreateContext();
+ var message = (await Assert.ThrowsAsync(
+ () => context.Set()
+ .OrderBy(x => EF.Functions.FullTextScore(x.Description, new string[] { "beaver", "dolphin", "first" }))
+ .ThenBy(x => EF.Functions.FullTextScore(x.Description, new string[] { "beaver", "dolphin", "second" }))
+ .ToListAsync())).Message;
+
+ Assert.Equal(CosmosStrings.OrderByMultipleScoringFunctionWithoutRrf("Rrf"), message);
+ }
+
+ [ConditionalFact]
+ public virtual async Task OrderBy_scoring_function_ThenBy_regular()
+ {
+ await using var context = CreateContext();
+
+ var message = (await Assert.ThrowsAsync(
+ () => context.Set()
+ .OrderBy(x => EF.Functions.FullTextScore(x.Description, new string[] { "beaver", "dolphin" }))
+ .ThenBy(x => x.Name)
+ .ToListAsync())).Message;
+
+ Assert.Equal(CosmosStrings.OrderByScoringFunctionMixedWithRegularOrderby, message);
+ }
+
+ [ConditionalFact]
+ public virtual async Task OrderBy_regular_ThenBy_scoring_function()
+ {
+ await using var context = CreateContext();
+ var message = (await Assert.ThrowsAsync(
+ () => context.Set()
+ .OrderBy(x => x.Name)
+ .ThenBy(x => EF.Functions.FullTextScore(x.Description, new string[] { "beaver", "dolphin" }))
+ .ToListAsync())).Message;
+
+ Assert.Equal(CosmosStrings.OrderByScoringFunctionMixedWithRegularOrderby, message);
+ }
+
+ [ConditionalFact]
+ public virtual async Task OrderByRank_Where()
+ {
+ await using var context = CreateContext();
+ var result = await context.Set()
+ .OrderBy(x => EF.Functions.FullTextScore(x.Description, new string[] { "beaver", "dolphin" }))
+ .Where(x => x.PartitionKey + "Foo" == "habitatFoo")
+ .ToListAsync();
+
+ AssertSql(
+"""
+SELECT VALUE c
+FROM root c
+WHERE ((c["PartitionKey"] || "Foo") = "habitatFoo")
+ORDER BY RANK FullTextScore(c["Description"], ["beaver","dolphin"])
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task OrderByRank_Distinct()
+ {
+ await using var context = CreateContext();
+
+ var message = (await Assert.ThrowsAsync(
+ () => context.Set()
+ .OrderBy(x => EF.Functions.FullTextScore(x.Description, new string[] { "beaver" }))
+ .Select(x => x.Name)
+ .Distinct()
+ .ToListAsync())).Message;
+
+ Assert.Contains(
+ "The DISTINCT keyword is not allowed with the ORDER BY RANK clause.",
+ message);
+ }
+
+ [ConditionalFact]
+ public virtual async Task Use_FullTextContains_in_predicate_on_nested_owned_type()
+ {
+ await using var context = CreateContext();
+
+ var result = await context.Set()
+ .Where(x => EF.Functions.FullTextContains(x.Owned.NestedReference.AnotherDescription, "beaver"))
+ .ToListAsync();
+
+ Assert.Equal(3, result.Count);
+ Assert.True(result.All(x => x.Description.Contains("beaver")));
+
+ AssertSql(
+"""
+SELECT VALUE c
+FROM root c
+WHERE FullTextContains(c["Owned"]["NestedReference"]["AnotherDescription"], "beaver")
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task OrderByRank_with_FullTextScore_on_nested_owned_type()
+ {
+ await using var context = CreateContext();
+
+ var result = await context.Set()
+ .OrderBy(x => EF.Functions.FullTextScore(x.Owned.NestedReference.AnotherDescription, new string[] { "beaver", "dolphin" }))
+ .ToListAsync();
+
+ AssertSql(
+"""
+SELECT VALUE c
+FROM root c
+ORDER BY RANK FullTextScore(c["Owned"]["NestedReference"]["AnotherDescription"], ["beaver","dolphin"])
+""");
+ }
+
+ [ConditionalFact(Skip = "issue #35898")]
+ public virtual async Task Use_FullTextContains_in_predicate_on_nested_owned_collection_element()
+ {
+ await using var context = CreateContext();
+
+ var result = await context.Set()
+ .Where(x => EF.Functions.FullTextContains(x.Owned.NestedCollection[0].AnotherDescription, "beaver"))
+ .ToListAsync();
+
+ Assert.Equal(3, result.Count);
+ Assert.True(result.All(x => x.Description.Contains("beaver")));
+
+ AssertSql(
+"""
+SELECT VALUE c
+FROM root c
+WHERE FullTextContains(c["Owned"]["NestedCollection"][0]["AnotherDescription"], "beaver")
+""");
+ }
+
+ [ConditionalFact(Skip = "issue #35898")]
+ public virtual async Task OrderByRank_with_FullTextScore_on_nested_owned_collection_element()
+ {
+ await using var context = CreateContext();
+
+ var result = await context.Set()
+ .OrderBy(x => EF.Functions.FullTextScore(x.Owned.NestedCollection[0].AnotherDescription, new string[] { "beaver", "dolphin" }))
+ .ToListAsync();
+
+ AssertSql(
+"""
+SELECT VALUE c
+FROM root c
+ORDER BY RANK FullTextScore(c["Owned"]["NestedCollection"][0]["AnotherDescription"], ["beaver","dolphin"])
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task OrderBy_scoring_function_on_property_with_modified_json_name()
+ {
+ await using var context = CreateContext();
+ var result = await context.Set()
+ .OrderBy(x => EF.Functions.FullTextScore(x.ModifiedDescription, new string[] { "beaver", "dolphin" }))
+ .ToListAsync();
+
+ AssertSql(
+"""
+SELECT VALUE c
+FROM root c
+ORDER BY RANK FullTextScore(c["CustomDecription"], ["beaver","dolphin"])
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task OrderByRank_with_FullTextScore_on_nested_owned_type_with_modified_json_name()
+ {
+ await using var context = CreateContext();
+
+ var result = await context.Set()
+ .OrderBy(x => EF.Functions.FullTextScore(x.Owned.ModifiedNestedReference.AnotherDescription, new string[] { "beaver", "dolphin" }))
+ .ToListAsync();
+
+ AssertSql(
+"""
+SELECT VALUE c
+FROM root c
+ORDER BY RANK FullTextScore(c["Owned"]["CustomNestedReference"]["AnotherDescription"], ["beaver","dolphin"])
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task OrderByRank_with_FullTextScore_on_property_without_index()
+ {
+ await using var context = CreateContext();
+
+ var result = await context.Set()
+ .OrderBy(x => EF.Functions.FullTextScore(x.DescriptionNoIndex, new string[] { "beaver", "dolphin" }))
+ .ToListAsync();
+
+ AssertSql(
+"""
+SELECT VALUE c
+FROM root c
+ORDER BY RANK FullTextScore(c["DescriptionNoIndex"], ["beaver","dolphin"])
+""");
+ }
+
+ private class FullTextSearchAnimals
+ {
+ public int Id { get; set; }
+
+ public string PartitionKey { get; set; } = null!;
+
+ public string Name { get; set; } = null!;
+
+ public string Description { get; set; } = null!;
+
+ public string ModifiedDescription { get; set; } = null!;
+ public string DescriptionNoIndex { get; set; } = null!;
+
+ public FullTextSearchOwned Owned { get; set; } = null!;
+ }
+
+ private class FullTextSearchOwned
+ {
+ public FullTextSearchNested NestedReference { get; set; } = null!;
+ public FullTextSearchNested ModifiedNestedReference { get; set; } = null!;
+
+ public List NestedCollection { get; set; } = null!;
+ }
+
+ private class FullTextSearchNested
+ {
+ public string AnotherDescription { get; set; } = null!;
+ }
+
+ protected DbContext CreateContext()
+ => Fixture.CreateContext();
+
+ private void AssertSql(params string[] expected)
+ => Fixture.TestSqlLoggerFactory.AssertBaseline(expected);
+
+ public class FullTextSearchFixture : SharedStoreFixtureBase
+ {
+ protected override string StoreName
+ => "FullTextSearchTest";
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext context)
+ => modelBuilder.Entity(b =>
+ {
+ b.ToContainer("FullTextSearchAnimals");
+ b.HasPartitionKey(x => x.PartitionKey);
+ b.Property(x => x.Name);
+ b.Property(x => x.Name).EnableFullTextSearch();
+ b.HasIndex(x => x.Name).IsFullTextIndex();
+
+ b.Property(x => x.Description).EnableFullTextSearch();
+ b.HasIndex(x => x.Description).IsFullTextIndex();
+
+ b.Property(x => x.ModifiedDescription).ToJsonProperty("CustomDecription");
+ b.Property(x => x.ModifiedDescription).EnableFullTextSearch();
+ b.HasIndex(x => x.ModifiedDescription).IsFullTextIndex();
+
+ b.Property(x => x.DescriptionNoIndex).EnableFullTextSearch();
+
+ b.OwnsOne(x => x.Owned, bb =>
+ {
+ bb.OwnsOne(x => x.NestedReference, bbb =>
+ {
+ bbb.Property(x => x.AnotherDescription).EnableFullTextSearch();
+ bbb.HasIndex(x => x.AnotherDescription).IsFullTextIndex();
+ });
+
+ bb.OwnsOne(x => x.ModifiedNestedReference, bbb =>
+ {
+ bbb.ToJsonProperty("CustomNestedReference");
+ bbb.Property(x => x.AnotherDescription).EnableFullTextSearch();
+ bbb.HasIndex(x => x.AnotherDescription).IsFullTextIndex();
+ });
+
+ // issue #35898
+ //bb.OwnsMany(x => x.NestedCollection, bbb =>
+ //{
+ // bbb.Property(x => x.AnotherDescription).EnableFullTextSearch();
+ // bbb.HasIndex(x => x.AnotherDescription).IsFullTextIndex();
+ //});
+ });
+ });
+
+ protected override Task SeedAsync(PoolableDbContext context)
+ {
+ var landAnimals = new FullTextSearchAnimals
+ {
+ Id = 1,
+ PartitionKey = "habitat",
+ Name = "List of several land animals",
+ Description = "bison, beaver, moose, fox, wolf, marten, horse, shrew, hare, duck, turtle, frog",
+ ModifiedDescription = "bison, beaver, moose, fox, wolf, marten, horse, shrew, hare, duck, turtle, frog",
+ DescriptionNoIndex = "bison, beaver, moose, fox, wolf, marten, horse, shrew, hare, duck, turtle, frog",
+ Owned = new FullTextSearchOwned
+ {
+ NestedReference = new FullTextSearchNested
+ {
+ AnotherDescription = "bison, beaver, moose, fox, wolf, marten, horse, shrew, hare, duck, turtle, frog",
+ },
+ ModifiedNestedReference = new FullTextSearchNested
+ {
+ AnotherDescription = "bison, beaver, moose, fox, wolf, marten, horse, shrew, hare, duck, turtle, frog",
+ },
+ // issue #35898
+ //NestedCollection =
+ //[
+ // new FullTextSearchNested
+ // {
+ // AnotherDescription = "bison, beaver, moose, fox, wolf, marten, horse, shrew, hare, duck, turtle, frog",
+ // }
+ //]
+ }
+ };
+
+ var waterAnimals = new FullTextSearchAnimals
+ {
+ Id = 2,
+ PartitionKey = "habitat",
+ Name = "List of several water animals",
+ Description = "beaver, otter, duck, dolphin, salmon, turtle, frog",
+ ModifiedDescription = "beaver, otter, duck, dolphin, salmon, turtle, frog",
+ DescriptionNoIndex = "beaver, otter, duck, dolphin, salmon, turtle, frog",
+ Owned = new FullTextSearchOwned
+ {
+ NestedReference = new FullTextSearchNested
+ {
+ AnotherDescription = "beaver, otter, duck, dolphin, salmon, turtle, frog",
+ },
+ ModifiedNestedReference = new FullTextSearchNested
+ {
+ AnotherDescription = "beaver, otter, duck, dolphin, salmon, turtle, frog",
+ },
+ // issue #35898
+ //NestedCollection =
+ //[
+ // new FullTextSearchNested
+ // {
+ // AnotherDescription = "beaver, otter, duck, dolphin, salmon, turtle, frog",
+ // }
+ //]
+ }
+ };
+
+ var airAnimals = new FullTextSearchAnimals
+ {
+ Id = 3,
+ PartitionKey = "habitat",
+ Name = "List of several air animals",
+ Description = "duck, bat, eagle, butterfly, sparrow",
+ ModifiedDescription = "duck, bat, eagle, butterfly, sparrow",
+ DescriptionNoIndex = "duck, bat, eagle, butterfly, sparrow",
+ Owned = new FullTextSearchOwned
+ {
+ NestedReference = new FullTextSearchNested
+ {
+ AnotherDescription = "duck, bat, eagle, butterfly, sparrow",
+ },
+ ModifiedNestedReference = new FullTextSearchNested
+ {
+ AnotherDescription = "duck, bat, eagle, butterfly, sparrow",
+ },
+ // issue #35898
+ //NestedCollection =
+ //[
+ // new FullTextSearchNested
+ // {
+ // AnotherDescription = "duck, bat, eagle, butterfly, sparrow",
+ // }
+ //]
+ }
+ };
+
+ var mammals = new FullTextSearchAnimals
+ {
+ Id = 4,
+ PartitionKey = "taxonomy",
+ Name = "List of several mammals",
+ Description = "bison, beaver, moose, fox, wolf, marten, horse, shrew, hare, bat",
+ ModifiedDescription = "bison, beaver, moose, fox, wolf, marten, horse, shrew, hare, bat",
+ DescriptionNoIndex = "bison, beaver, moose, fox, wolf, marten, horse, shrew, hare, bat",
+ Owned = new FullTextSearchOwned
+ {
+ NestedReference = new FullTextSearchNested
+ {
+ AnotherDescription = "bison, beaver, moose, fox, wolf, marten, horse, shrew, hare, bat",
+ },
+ ModifiedNestedReference = new FullTextSearchNested
+ {
+ AnotherDescription = "bison, beaver, moose, fox, wolf, marten, horse, shrew, hare, bat",
+ },
+ // issue #35898
+ //NestedCollection =
+ //[
+ // new FullTextSearchNested
+ // {
+ // AnotherDescription = "bison, beaver, moose, fox, wolf, marten, horse, shrew, hare, bat",
+ // }
+ //]
+ }
+ };
+
+ var avians = new FullTextSearchAnimals
+ {
+ Id = 5,
+ PartitionKey = "taxonomy",
+ Name = "List of several avians",
+ Description = "duck, eagle, sparrow",
+ ModifiedDescription = "duck, eagle, sparrow",
+ DescriptionNoIndex = "duck, eagle, sparrow",
+ Owned = new FullTextSearchOwned
+ {
+ NestedReference = new FullTextSearchNested
+ {
+ AnotherDescription = "duck, eagle, sparrow",
+ },
+ ModifiedNestedReference = new FullTextSearchNested
+ {
+ AnotherDescription = "duck, eagle, sparrow",
+ },
+ // issue #35898
+ //NestedCollection =
+ //[
+ // new FullTextSearchNested
+ // {
+ // AnotherDescription = "duck, eagle, sparrow",
+ // }
+ //]
+ }
+ };
+
+ context.Set().AddRange(landAnimals, waterAnimals, airAnimals, mammals, avians);
+ return context.SaveChangesAsync();
+ }
+
+ public TestSqlLoggerFactory TestSqlLoggerFactory
+ => (TestSqlLoggerFactory)ListLoggerFactory;
+
+ protected override ITestStoreFactory TestStoreFactory
+ => CosmosTestStoreFactory.Instance;
+ }
+}
diff --git a/test/EFCore.Cosmos.FunctionalTests/HybridSearchCosmosTest.cs b/test/EFCore.Cosmos.FunctionalTests/HybridSearchCosmosTest.cs
new file mode 100644
index 00000000000..ed87b74e00a
--- /dev/null
+++ b/test/EFCore.Cosmos.FunctionalTests/HybridSearchCosmosTest.cs
@@ -0,0 +1,259 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Azure.Cosmos;
+using Microsoft.EntityFrameworkCore.Cosmos.Extensions;
+
+namespace Microsoft.EntityFrameworkCore;
+
+#pragma warning disable EF9103
+[CosmosCondition(CosmosCondition.DoesNotUseTokenCredential)]
+public class HybridSearchCosmosTest : IClassFixture
+{
+ public HybridSearchCosmosTest(HybridSearchFixture fixture, ITestOutputHelper testOutputHelper)
+ {
+ Fixture = fixture;
+ _testOutputHelper = testOutputHelper;
+ fixture.TestSqlLoggerFactory.Clear();
+ }
+
+ protected HybridSearchFixture Fixture { get; }
+
+ private readonly ITestOutputHelper _testOutputHelper;
+
+ [ConditionalFact]
+ public virtual async Task Hybrid_search_vector_distance_and_FullTextScore_in_OrderByRank()
+ {
+ await using var context = CreateContext();
+
+ var inputVector = new ReadOnlyMemory([2, -1, 4, 3, 5, -2, 5, -7, 3, 1]);
+
+ var result = await context.Set()
+ .OrderBy(x => EF.Functions.Rrf(
+ EF.Functions.FullTextScore(x.Description, new string[] { "beaver", "otter" }),
+ EF.Functions.VectorDistance(x.SBytes, inputVector)))
+ .ToListAsync();
+
+ AssertSql(
+"""
+@inputVector='[2,-1,4,3,5,-2,5,-7,3,1]'
+
+SELECT VALUE c
+FROM root c
+ORDER BY RANK RRF(FullTextScore(c["Description"], ["beaver","otter"]), VectorDistance(c["SBytes"], @inputVector, false, {'distanceFunction':'dotproduct', 'dataType':'int8'}))
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task Hybrid_search_vector_distance_and_FullTextScore_with_single_constant_argument()
+ {
+ await using var context = CreateContext();
+
+ var inputVector = new ReadOnlyMemory([2, -1, 4, 3, 5, -2, 5, -7, 3, 1]);
+
+ var result = await context.Set()
+ .OrderBy(x => EF.Functions.Rrf(
+ EF.Functions.FullTextScore(x.Description, "beaver"),
+ EF.Functions.VectorDistance(x.SBytes, inputVector)))
+ .ToListAsync();
+
+ AssertSql(
+"""
+@inputVector='[2,-1,4,3,5,-2,5,-7,3,1]'
+
+SELECT VALUE c
+FROM root c
+ORDER BY RANK RRF(FullTextScore(c["Description"], ["beaver"]), VectorDistance(c["SBytes"], @inputVector, false, {'distanceFunction':'dotproduct', 'dataType':'int8'}))
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task Hybrid_search_vector_distance_and_FullTextScore_in_OrderByRank_from_owned_type()
+ {
+ await using var context = CreateContext();
+
+ var inputVector = new ReadOnlyMemory([0.33f, -0.52f, 0.45f, -0.67f, 0.89f, -0.34f, 0.86f, -0.78f, 0.86f, -0.78f]);
+ var result = await context.Set()
+ .OrderBy(x => EF.Functions.Rrf(
+ EF.Functions.FullTextScore(x.Owned.AnotherDescription, new string[] { "beaver" }),
+ EF.Functions.VectorDistance(x.Owned.Singles, inputVector)))
+ .ToListAsync();
+
+ AssertSql(
+"""
+@inputVector='[0.33,-0.52,0.45,-0.67,0.89,-0.34,0.86,-0.78,0.86,-0.78]'
+
+SELECT VALUE c
+FROM root c
+ORDER BY RANK RRF(FullTextScore(c["Owned"]["AnotherDescription"], ["beaver"]), VectorDistance(c["Owned"]["Singles"], @inputVector, false, {'distanceFunction':'cosine', 'dataType':'float32'}))
+""");
+ }
+
+ [ConditionalFact]
+ public virtual async Task Hybrid_search_vector_distance_and_FullTextScore_in_OrderByRank_with_array_args()
+ {
+ await using var context = CreateContext();
+
+ var prm = new string[] { "beaver", "otter" };
+ var inputVector = new ReadOnlyMemory([0.33f, -0.52f, 0.45f, -0.67f, 0.89f, -0.34f, 0.86f, -0.78f, 0.86f, -0.78f]);
+ var result = await context.Set()
+ .OrderBy(x => EF.Functions.Rrf(
+ EF.Functions.VectorDistance(x.Owned.Singles, inputVector),
+ EF.Functions.FullTextScore(x.Owned.AnotherDescription, prm)))
+ .ToListAsync();
+
+ AssertSql(
+"""
+@inputVector='[0.33,-0.52,0.45,-0.67,0.89,-0.34,0.86,-0.78,0.86,-0.78]'
+
+SELECT VALUE c
+FROM root c
+ORDER BY RANK RRF(VectorDistance(c["Owned"]["Singles"], @inputVector, false, {'distanceFunction':'cosine', 'dataType':'float32'}), FullTextScore(c["Owned"]["AnotherDescription"], ["beaver","otter"]))
+""");
+ }
+
+ private class HybridSearchAnimals
+ {
+ public int Id { get; set; }
+
+ public string PartitionKey { get; set; } = null!;
+
+ public string Name { get; set; } = null!;
+
+ public string Description { get; set; } = null!;
+
+ public ReadOnlyMemory Bytes { get; set; } = null!;
+
+ public ReadOnlyMemory SBytes { get; set; } = null!;
+
+ public byte[] BytesArray { get; set; } = null!;
+
+ public float[] SinglesArray { get; set; } = null!;
+
+ public HybridOwned Owned { get; set; } = null!;
+ }
+
+ public class HybridOwned
+ {
+ public string AnotherDescription { get; set; } = null!;
+ public ReadOnlyMemory Singles { get; set; } = null!;
+ }
+
+ protected DbContext CreateContext()
+ => Fixture.CreateContext();
+
+ private void AssertSql(params string[] expected)
+ => Fixture.TestSqlLoggerFactory.AssertBaseline(expected);
+
+ public class HybridSearchFixture : SharedStoreFixtureBase
+ {
+ protected override string StoreName
+ => "HybridSearchTest";
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext context)
+ {
+ modelBuilder.Entity(b =>
+ {
+ b.ToContainer("HybridSearchAnimals");
+ b.HasPartitionKey(x => x.PartitionKey);
+ b.Property(x => x.Name).EnableFullTextSearch();
+ b.HasIndex(x => x.Name).IsFullTextIndex();
+
+ b.Property(x => x.Description).EnableFullTextSearch();
+ b.HasIndex(x => x.Description).IsFullTextIndex();
+
+ b.HasIndex(e => e.Bytes).ForVectors(VectorIndexType.Flat);
+ b.HasIndex(e => e.SBytes).ForVectors(VectorIndexType.Flat);
+ b.HasIndex(e => e.BytesArray).ForVectors(VectorIndexType.Flat);
+ b.HasIndex(e => e.SinglesArray).ForVectors(VectorIndexType.Flat);
+
+ b.Property(e => e.Bytes).IsVector(DistanceFunction.Cosine, 10);
+ b.Property(e => e.SBytes).IsVector(DistanceFunction.DotProduct, 10);
+ b.Property(e => e.BytesArray).IsVector(DistanceFunction.Cosine, 10);
+ b.Property(e => e.SinglesArray).IsVector(DistanceFunction.Cosine, 10);
+
+ b.OwnsOne(x => x.Owned, bb =>
+ {
+ bb.HasIndex(e => e.Singles).ForVectors(VectorIndexType.Flat);
+ bb.Property(e => e.Singles).IsVector(DistanceFunction.Cosine, 10);
+
+ bb.Property(x => x.AnotherDescription).EnableFullTextSearch();
+ bb.HasIndex(x => x.AnotherDescription).IsFullTextIndex();
+ });
+ });
+ }
+
+ protected override Task SeedAsync(PoolableDbContext context)
+ {
+ var landAnimals = new HybridSearchAnimals
+ {
+ Id = 1,
+ PartitionKey = "habitat",
+ Name = "List of several land animals",
+ Description = "bison, beaver, moose, fox, wolf, marten, horse, shrew, hare, duck, turtle, frog",
+
+ Bytes = new ReadOnlyMemory([2, 1, 4, 3, 5, 2, 5, 7, 3, 1]),
+ SBytes = new ReadOnlyMemory([2, -1, 4, 3, 5, -2, 5, -7, 3, 1]),
+
+ BytesArray = [2, 1, 4, 3, 5, 2, 5, 7, 3, 1],
+ SinglesArray = [0.33f, -0.52f, 0.45f, -0.67f, 0.89f, -0.34f, 0.86f, -0.78f, 0.86f, -0.78f],
+
+ Owned = new HybridOwned
+ {
+ AnotherDescription = "bison, beaver, moose, fox, wolf, marten, horse, shrew, hare, duck, turtle, frog",
+ Singles = new ReadOnlyMemory([0.33f, -0.52f, 0.45f, -0.67f, 0.89f, -0.34f, 0.86f, -0.78f, 0.86f, -0.78f]),
+ }
+ };
+
+
+ var airAnimals = new HybridSearchAnimals
+ {
+ Id = 2,
+ PartitionKey = "habitat",
+ Name = "List of several air animals",
+ Description = "duck, bat, eagle, butterfly, sparrow",
+
+ Bytes = new ReadOnlyMemory([2, 1, 4, 3, 5, 2, 5, 7, 3, 1]),
+ SBytes = new ReadOnlyMemory([2, -1, 4, 3, 5, -2, 5, -7, 3, 1]),
+ BytesArray = [2, 1, 4, 3, 5, 2, 5, 7, 3, 1],
+ SinglesArray = [0.33f, -0.52f, 0.45f, -0.67f, 0.89f, -0.34f, 0.86f, -0.78f, 0.86f, -0.78f],
+
+ Owned = new HybridOwned
+ {
+ AnotherDescription = "duck, bat, eagle, butterfly, sparrow",
+ Singles = new ReadOnlyMemory([0.33f, -0.52f, 0.45f, -0.67f, 0.89f, -0.34f, 0.86f, -0.78f, 0.86f, -0.78f]),
+ }
+ };
+
+ var mammals = new HybridSearchAnimals
+ {
+ Id = 3,
+ PartitionKey = "taxonomy",
+ Name = "List of several mammals",
+ Description = "bison, beaver, moose, fox, wolf, marten, horse, shrew, hare, bat",
+
+ Bytes = new ReadOnlyMemory([2, 1, 4, 3, 5, 2, 5, 7, 3, 1]),
+ SBytes = new ReadOnlyMemory([2, -1, 4, 3, 5, -2, 5, -7, 3, 1]),
+
+ BytesArray = [2, 1, 4, 3, 5, 2, 5, 7, 3, 1],
+ SinglesArray = [0.33f, -0.52f, 0.45f, -0.67f, 0.89f, -0.34f, 0.86f, -0.78f, 0.86f, -0.78f],
+
+ Owned = new HybridOwned
+ {
+ AnotherDescription = "bison, beaver, moose, fox, wolf, marten, horse, shrew, hare, bat",
+ Singles = new ReadOnlyMemory([0.33f, -0.52f, 0.45f, -0.67f, 0.89f, -0.34f, 0.86f, -0.78f, 0.86f, -0.78f]),
+ }
+ };
+
+ context.Set().AddRange(landAnimals, airAnimals, mammals);
+ return context.SaveChangesAsync();
+ }
+
+ public TestSqlLoggerFactory TestSqlLoggerFactory
+ => (TestSqlLoggerFactory)ListLoggerFactory;
+
+ protected override ITestStoreFactory TestStoreFactory
+ => CosmosTestStoreFactory.Instance;
+ }
+}
+#pragma warning restore EF9103
diff --git a/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs b/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs
index e9cccb6050f..3d55838d9d1 100644
--- a/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs
+++ b/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs
@@ -407,6 +407,9 @@ private async Task CreateContainersAsync(DbContext context)
};
}
+ // TODO: see issue #35854
+ // once Azure.ResourceManager.CosmosDB package supports vectors and FTS, those need to be added here
+
await database.Value.GetCosmosDBSqlContainers().CreateOrUpdateAsync(
WaitUntil.Completed, container.Id, content).ConfigureAwait(false);
}
@@ -441,6 +444,8 @@ await database.Value.GetCosmosDBSqlContainers().CreateOrUpdateAsync(
ThroughputProperties? throughput = null;
var indexes = new List();
var vectors = new List<(IProperty Property, CosmosVectorType VectorType)>();
+ string? fullTextDefaultLanguage = null;
+ var fullTextProperties = new List<(IProperty Property, string? Language)>();
foreach (var entityType in mappedTypes)
{
@@ -452,17 +457,10 @@ await database.Value.GetCosmosDBSqlContainers().CreateOrUpdateAsync(
analyticalTtl ??= entityType.GetAnalyticalStoreTimeToLive();
defaultTtl ??= entityType.GetDefaultTimeToLive();
throughput ??= entityType.GetThroughput();
- indexes.AddRange(entityType.GetIndexes());
+ fullTextDefaultLanguage ??= entityType.GetDefaultFullTextSearchLanguage();
- foreach (var property in entityType.GetProperties())
- {
- if (property.FindTypeMapping() is CosmosVectorTypeMapping vectorTypeMapping)
- {
- vectors.Add((property, vectorTypeMapping.VectorType));
- }
- }
+ ProcessEntityType(entityType, indexes, vectors, fullTextProperties);
}
-#pragma warning restore EF9103
yield return new Cosmos.Storage.Internal.ContainerProperties(
containerName,
@@ -471,8 +469,40 @@ await database.Value.GetCosmosDBSqlContainers().CreateOrUpdateAsync(
defaultTtl,
throughput,
indexes,
- vectors);
+ vectors,
+ fullTextDefaultLanguage ?? "en-US",
+ fullTextProperties);
}
+
+ static void ProcessEntityType(
+ IEntityType entityType,
+ List indexes,
+ List<(IProperty Property, CosmosVectorType VectorType)> vectors,
+ List<(IProperty Property, string? Language)> fullTextProperties)
+ {
+ indexes.AddRange(entityType.GetIndexes());
+
+ foreach (var property in entityType.GetProperties())
+ {
+ if (property.FindTypeMapping() is CosmosVectorTypeMapping vectorTypeMapping)
+ {
+ vectors.Add((property, vectorTypeMapping.VectorType));
+ }
+
+ if (property.GetIsFullTextSearchEnabled() == true)
+ {
+ fullTextProperties.Add((property, property.GetFullTextSearchLanguage()));
+ }
+ }
+
+ foreach (var ownedType in entityType.GetNavigations()
+ .Where(x => x.ForeignKey.IsOwnership && !x.IsOnDependent && !x.TargetEntityType.IsDocumentRoot())
+ .Select(x => x.TargetEntityType))
+ {
+ ProcessEntityType(ownedType, indexes, vectors, fullTextProperties);
+ }
+ }
+#pragma warning restore EF9103
}
private static IReadOnlyList GetPartitionKeyStoreNames(IEntityType entityType)
@@ -499,7 +529,7 @@ private async Task DeleteContainers(DbContext context)
}
}
- foreach(var container in containers)
+ foreach (var container in containers)
{
await container.DeleteContainerAsync();
}
diff --git a/test/EFCore.Cosmos.FunctionalTests/VectorSearchCosmosTest.cs b/test/EFCore.Cosmos.FunctionalTests/VectorSearchCosmosTest.cs
index 8d44d4a4964..99e471e70aa 100644
--- a/test/EFCore.Cosmos.FunctionalTests/VectorSearchCosmosTest.cs
+++ b/test/EFCore.Cosmos.FunctionalTests/VectorSearchCosmosTest.cs
@@ -8,7 +8,6 @@
namespace Microsoft.EntityFrameworkCore;
#pragma warning disable EF9103
-
public class VectorSearchCosmosTest : IClassFixture
{
public VectorSearchCosmosTest(VectorSearchFixture fixture, ITestOutputHelper testOutputHelper)
@@ -77,17 +76,17 @@ public virtual async Task Query_for_vector_distance_singles()
var booksFromStore = await context
.Set()
.Select(
- e => EF.Functions.VectorDistance(e.Singles, inputVector, false, DistanceFunction.DotProduct))
+ e => EF.Functions.VectorDistance(e.OwnedReference.NestedOwned.NestedSingles, inputVector, false, DistanceFunction.DotProduct))
.ToListAsync();
Assert.Equal(3, booksFromStore.Count);
Assert.All(booksFromStore, s => Assert.NotEqual(0.0, s));
AssertSql(
- """
+"""
@inputVector='[0.33,-0.52,0.45,-0.67,0.89,-0.34,0.86,-0.78,0.86,-0.78]'
-SELECT VALUE VectorDistance(c["Singles"], @inputVector, false, {'distanceFunction':'dotproduct', 'dataType':'float32'})
+SELECT VALUE VectorDistance(c["OwnedReference"]["NestedOwned"]["NestedSingles"], @inputVector, false, {'distanceFunction':'dotproduct', 'dataType':'float32'})
FROM root c
""");
}
@@ -192,17 +191,18 @@ public virtual async Task Vector_distance_singles_in_OrderBy()
var booksFromStore = await context
.Set()
- .OrderBy(e => EF.Functions.VectorDistance(e.Singles, inputVector))
+ .OrderBy(e => EF.Functions.VectorDistance(e.OwnedReference.NestedOwned.NestedSingles, inputVector))
.ToListAsync();
Assert.Equal(3, booksFromStore.Count);
+
AssertSql(
- """
+"""
@p='[0.33,-0.52,0.45,-0.67,0.89,-0.34,0.86,-0.78]'
SELECT VALUE c
FROM root c
-ORDER BY VectorDistance(c["Singles"], @p, false, {'distanceFunction':'cosine', 'dataType':'float32'})
+ORDER BY VectorDistance(c["OwnedReference"]["NestedOwned"]["NestedSingles"], @p, false, {'distanceFunction':'cosine', 'dataType':'float32'})
""");
}
@@ -250,6 +250,33 @@ ORDER BY VectorDistance(c["SinglesArray"], @p, false, {'distanceFunction':'cosin
""");
}
+ [ConditionalFact]
+ public virtual async Task RRF_with_two_Vector_distance_functions_in_OrderBy()
+ {
+ await using var context = CreateContext();
+ var inputVector1 = new byte[] { 2, 1, 4, 6, 5, 2, 5, 7, 3, 1 };
+ var inputVector2 = new[] { 0.33f, -0.52f, 0.45f, -0.67f, 0.89f, -0.34f, 0.86f, -0.78f };
+
+ var booksFromStore = await context
+ .Set()
+ .OrderBy(e => EF.Functions.Rrf(
+ EF.Functions.VectorDistance(e.BytesArray, inputVector1),
+ EF.Functions.VectorDistance(e.SinglesArray, inputVector2)))
+ .ToListAsync();
+
+ Assert.Equal(3, booksFromStore.Count);
+
+ AssertSql(
+"""
+@p='[2,1,4,6,5,2,5,7,3,1]'
+@p0='[0.33,-0.52,0.45,-0.67,0.89,-0.34,0.86,-0.78]'
+
+SELECT VALUE c
+FROM root c
+ORDER BY RANK RRF(VectorDistance(c["BytesArray"], @p, false, {'distanceFunction':'cosine', 'dataType':'uint8'}), VectorDistance(c["SinglesArray"], @p0, false, {'distanceFunction':'cosine', 'dataType':'float32'}))
+""");
+ }
+
[ConditionalFact]
public virtual async Task VectorDistance_throws_when_used_on_non_vector()
{
@@ -294,7 +321,7 @@ public virtual async Task VectorDistance_throws_when_used_with_non_const_args()
(await Assert.ThrowsAsync(
async () => await context
.Set()
- .OrderBy(e => EF.Functions.VectorDistance(e.Singles, inputVector, e.IsPublished))
+ .OrderBy(e => EF.Functions.VectorDistance(e.OwnedReference.NestedOwned.NestedSingles, inputVector, e.IsPublished))
.ToListAsync())).Message);
Assert.Equal(
@@ -303,7 +330,7 @@ public virtual async Task VectorDistance_throws_when_used_with_non_const_args()
async () => await context
.Set()
.OrderBy(
- e => EF.Functions.VectorDistance(e.Singles, inputVector, false, e.DistanceFunction))
+ e => EF.Functions.VectorDistance(e.OwnedReference.NestedOwned.NestedSingles, inputVector, false, e.DistanceFunction))
.ToListAsync())).Message);
}
@@ -327,8 +354,6 @@ private class Book
public ReadOnlyMemory SBytes { get; set; } = null!;
- public ReadOnlyMemory Singles { get; set; } = null!;
-
public byte[] BytesArray { get; set; } = null!;
public float[] SinglesArray { get; set; } = null!;
@@ -337,7 +362,6 @@ private class Book
public List OwnedCollection { get; set; } = null!;
}
- [Owned]
protected class Owned1
{
public int Prop { get; set; }
@@ -345,10 +369,10 @@ protected class Owned1
public List NestedOwnedCollection { get; set; } = null!;
}
- [Owned]
protected class Owned2
{
public string Prop { get; set; } = null!;
+ public ReadOnlyMemory NestedSingles { get; set; } = null!;
}
protected DbContext CreateContext()
@@ -372,15 +396,30 @@ protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext con
b.HasIndex(e => e.Bytes).ForVectors(VectorIndexType.Flat);
b.HasIndex(e => e.SBytes).ForVectors(VectorIndexType.Flat);
- b.HasIndex(e => e.Singles).ForVectors(VectorIndexType.Flat);
b.HasIndex(e => e.BytesArray).ForVectors(VectorIndexType.Flat);
b.HasIndex(e => e.SinglesArray).ForVectors(VectorIndexType.Flat);
b.Property(e => e.Bytes).IsVector(DistanceFunction.Cosine, 10);
b.Property(e => e.SBytes).IsVector(DistanceFunction.DotProduct, 10);
- b.Property(e => e.Singles).IsVector(DistanceFunction.Cosine, 10);
b.Property(e => e.BytesArray).IsVector(DistanceFunction.Cosine, 10);
b.Property(e => e.SinglesArray).IsVector(DistanceFunction.Cosine, 10);
+
+ b.OwnsOne(x => x.OwnedReference, bb =>
+ {
+ bb.OwnsOne(x => x.NestedOwned, bbb =>
+ {
+ bbb.HasIndex(x => x.NestedSingles).ForVectors(VectorIndexType.Flat);
+ bbb.Property(x => x.NestedSingles).IsVector(DistanceFunction.Cosine, 10);
+ });
+
+ bb.OwnsMany(x => x.NestedOwnedCollection, bbb => bbb.Ignore(x => x.NestedSingles));
+ });
+
+ b.OwnsMany(x => x.OwnedCollection, bb =>
+ {
+ bb.OwnsOne(x => x.NestedOwned, bbb => bbb.Ignore(x => x.NestedSingles));
+ bb.OwnsMany(x => x.NestedOwnedCollection, bbb => bbb.Ignore(x => x.NestedSingles));
+ });
});
protected override Task SeedAsync(PoolableDbContext context)
@@ -393,13 +432,16 @@ protected override Task SeedAsync(PoolableDbContext context)
Isbn = new ReadOnlyMemory("978-1617298363"u8.ToArray()),
Bytes = new ReadOnlyMemory([2, 1, 4, 3, 5, 2, 5, 7, 3, 1]),
SBytes = new ReadOnlyMemory([2, -1, 4, 3, 5, -2, 5, -7, 3, 1]),
- Singles = new ReadOnlyMemory([0.33f, -0.52f, 0.45f, -0.67f, 0.89f, -0.34f, 0.86f, -0.78f, 0.86f, -0.78f]),
BytesArray = [2, 1, 4, 3, 5, 2, 5, 7, 3, 1],
SinglesArray = [0.33f, -0.52f, 0.45f, -0.67f, 0.89f, -0.34f, 0.86f, -0.78f, 0.86f, -0.78f],
OwnedReference = new Owned1
{
Prop = 7,
- NestedOwned = new Owned2 { Prop = "7" },
+ NestedOwned = new Owned2
+ {
+ Prop = "7",
+ NestedSingles = new ReadOnlyMemory([0.33f, -0.52f, 0.45f, -0.67f, 0.89f, -0.34f, 0.86f, -0.78f, 0.86f, -0.78f])
+ },
NestedOwnedCollection = new List { new() { Prop = "71" }, new() { Prop = "72" } }
},
OwnedCollection = new List { new() { Prop = 71 }, new() { Prop = 72 } }
@@ -413,13 +455,16 @@ protected override Task SeedAsync(PoolableDbContext context)
Isbn = new ReadOnlyMemory("978-1449312961"u8.ToArray()),
Bytes = new ReadOnlyMemory([2, 1, 4, 3, 5, 2, 5, 7, 3, 1]),
SBytes = new ReadOnlyMemory([2, -1, 4, 3, 5, -2, 5, -7, 3, 1]),
- Singles = new ReadOnlyMemory([0.33f, -0.52f, 0.45f, -0.67f, 0.89f, -0.34f, 0.86f, -0.78f, 0.86f, -0.78f]),
BytesArray = [2, 1, 4, 3, 5, 2, 5, 7, 3, 1],
SinglesArray = [0.33f, -0.52f, 0.45f, -0.67f, 0.89f, -0.34f, 0.86f, -0.78f, 0.86f, -0.78f],
OwnedReference = new Owned1
{
Prop = 7,
- NestedOwned = new Owned2 { Prop = "7" },
+ NestedOwned = new Owned2
+ {
+ Prop = "7",
+ NestedSingles = new ReadOnlyMemory([0.33f, -0.52f, 0.45f, -0.67f, 0.89f, -0.34f, 0.86f, -0.78f, 0.86f, -0.78f])
+ },
NestedOwnedCollection = new List { new() { Prop = "71" }, new() { Prop = "72" } }
},
OwnedCollection = new List { new() { Prop = 71 }, new() { Prop = 72 } }
@@ -433,13 +478,16 @@ protected override Task SeedAsync(PoolableDbContext context)
Isbn = new ReadOnlyMemory("978-0596807269"u8.ToArray()),
Bytes = new ReadOnlyMemory([2, 1, 4, 3, 5, 2, 5, 7, 3, 1]),
SBytes = new ReadOnlyMemory([2, -1, 4, 3, 5, -2, 5, -7, 3, 1]),
- Singles = new ReadOnlyMemory([0.33f, -0.52f, 0.45f, -0.67f, 0.89f, -0.34f, 0.86f, -0.78f, 0.86f, -0.78f]),
BytesArray = [2, 1, 4, 3, 5, 2, 5, 7, 3, 1],
SinglesArray = [0.33f, -0.52f, 0.45f, -0.67f, 0.89f, -0.34f, 0.86f, -0.78f, 0.86f, -0.78f],
OwnedReference = new Owned1
{
Prop = 7,
- NestedOwned = new Owned2 { Prop = "7" },
+ NestedOwned = new Owned2
+ {
+ Prop = "7",
+ NestedSingles = new ReadOnlyMemory([0.33f, -0.52f, 0.45f, -0.67f, 0.89f, -0.34f, 0.86f, -0.78f, 0.86f, -0.78f]),
+ },
NestedOwnedCollection = new List { new() { Prop = "71" }, new() { Prop = "72" } }
},
OwnedCollection = new List { new() { Prop = 71 }, new() { Prop = 72 } }