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 } }