diff --git a/src/EFCore.SqlServer/Design/Internal/SqlServerAnnotationCodeGenerator.cs b/src/EFCore.SqlServer/Design/Internal/SqlServerAnnotationCodeGenerator.cs index ac70e797bc5..8a02d188f69 100644 --- a/src/EFCore.SqlServer/Design/Internal/SqlServerAnnotationCodeGenerator.cs +++ b/src/EFCore.SqlServer/Design/Internal/SqlServerAnnotationCodeGenerator.cs @@ -147,6 +147,34 @@ private static readonly MethodInfo TemporalPropertyHasColumnNameMethodInfo = typeof(TemporalPeriodPropertyBuilder).GetRuntimeMethod( nameof(TemporalPeriodPropertyBuilder.HasColumnName), [typeof(string)])!; + private static readonly MethodInfo ModelHasFullTextCatalogMethodInfo + = typeof(SqlServerModelBuilderExtensions).GetRuntimeMethod( + nameof(SqlServerModelBuilderExtensions.HasFullTextCatalog), [typeof(ModelBuilder), typeof(string)])!; + + private static readonly MethodInfo FullTextCatalogIsDefaultMethodInfo + = typeof(SqlServerFullTextCatalogBuilder).GetRuntimeMethod( + nameof(SqlServerFullTextCatalogBuilder.IsDefault), [typeof(bool)])!; + + private static readonly MethodInfo FullTextCatalogIsAccentSensitiveMethodInfo + = typeof(SqlServerFullTextCatalogBuilder).GetRuntimeMethod( + nameof(SqlServerFullTextCatalogBuilder.IsAccentSensitive), [typeof(bool)])!; + + private static readonly MethodInfo IndexHasFullTextKeyIndexMethodInfo + = typeof(SqlServerIndexBuilderExtensions).GetRuntimeMethod( + nameof(SqlServerIndexBuilderExtensions.HasFullTextKeyIndex), [typeof(IndexBuilder), typeof(string)])!; + + private static readonly MethodInfo IndexHasFullTextCatalogMethodInfo + = typeof(SqlServerIndexBuilderExtensions).GetRuntimeMethod( + nameof(SqlServerIndexBuilderExtensions.HasFullTextCatalog), [typeof(IndexBuilder), typeof(string)])!; + + private static readonly MethodInfo IndexHasFullTextChangeTrackingMethodInfo + = typeof(SqlServerIndexBuilderExtensions).GetRuntimeMethod( + nameof(SqlServerIndexBuilderExtensions.HasFullTextChangeTracking), [typeof(IndexBuilder), typeof(FullTextChangeTracking)])!; + + private static readonly MethodInfo IndexHasFullTextLanguageMethodInfo + = typeof(SqlServerIndexBuilderExtensions).GetRuntimeMethod( + nameof(SqlServerIndexBuilderExtensions.HasFullTextLanguage), [typeof(IndexBuilder), typeof(string), typeof(string)])!; + #endregion MethodInfos /// @@ -192,6 +220,28 @@ public override IReadOnlyList GenerateFluentApiCalls( SqlServerAnnotationNames.PerformanceLevelSql, ModelHasPerformanceLevelSqlMethodInfo, fragments); + if (annotations.Remove(SqlServerAnnotationNames.FullTextCatalogs, out var catalogsAnnotation) + && catalogsAnnotation.Value is Dictionary catalogs) + { + foreach (var catalog in catalogs.Values.OrderBy(c => c.Name)) + { + var catalogCall = new MethodCallCodeFragment(ModelHasFullTextCatalogMethodInfo, catalog.Name); + + if (catalog.IsDefault) + { + catalogCall = catalogCall.Chain(new MethodCallCodeFragment(FullTextCatalogIsDefaultMethodInfo)); + } + + if (!catalog.IsAccentSensitive) + { + catalogCall = catalogCall.Chain( + new MethodCallCodeFragment(FullTextCatalogIsAccentSensitiveMethodInfo, false)); + } + + fragments.Add(catalogCall); + } + } + return fragments; } @@ -419,6 +469,30 @@ protected override bool IsHandledByConvention(IProperty property, IAnnotation an _ => 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. + /// + public override IReadOnlyList GenerateFluentApiCalls( + IIndex index, + IDictionary annotations) + { + var fragments = new List(base.GenerateFluentApiCalls(index, annotations)); + + if (annotations.Remove(SqlServerAnnotationNames.FullTextLanguages, out var languagesAnnotation) + && languagesAnnotation.Value is Dictionary languages) + { + foreach (var (propertyName, language) in languages.OrderBy(l => l.Key)) + { + fragments.Add(new MethodCallCodeFragment(IndexHasFullTextLanguageMethodInfo, propertyName, language)); + } + } + + return fragments; + } + /// /// 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 @@ -432,10 +506,21 @@ protected override bool IsHandledByConvention(IProperty property, IAnnotation an ? new MethodCallCodeFragment(IndexIsClusteredMethodInfo, false) : new MethodCallCodeFragment(IndexIsClusteredMethodInfo), - SqlServerAnnotationNames.Include => new MethodCallCodeFragment(IndexIncludePropertiesMethodInfo, annotation.Value), - SqlServerAnnotationNames.FillFactor => new MethodCallCodeFragment(IndexHasFillFactorMethodInfo, annotation.Value), - SqlServerAnnotationNames.SortInTempDb => new MethodCallCodeFragment(IndexSortInTempDbMethodInfo, annotation.Value), - SqlServerAnnotationNames.DataCompression => new MethodCallCodeFragment(IndexUseDataCompressionMethodInfo, annotation.Value), + SqlServerAnnotationNames.Include + => new MethodCallCodeFragment(IndexIncludePropertiesMethodInfo, annotation.Value), + SqlServerAnnotationNames.FillFactor + => new MethodCallCodeFragment(IndexHasFillFactorMethodInfo, annotation.Value), + SqlServerAnnotationNames.SortInTempDb + => new MethodCallCodeFragment(IndexSortInTempDbMethodInfo, annotation.Value), + SqlServerAnnotationNames.DataCompression + => new MethodCallCodeFragment(IndexUseDataCompressionMethodInfo, annotation.Value), + + SqlServerAnnotationNames.FullTextIndex + => new MethodCallCodeFragment(IndexHasFullTextKeyIndexMethodInfo, annotation.Value), + SqlServerAnnotationNames.FullTextCatalog + => new MethodCallCodeFragment(IndexHasFullTextCatalogMethodInfo, annotation.Value), + SqlServerAnnotationNames.FullTextChangeTracking + => new MethodCallCodeFragment(IndexHasFullTextChangeTrackingMethodInfo, annotation.Value), _ => null }; diff --git a/src/EFCore.SqlServer/Design/Internal/SqlServerCSharpRuntimeAnnotationCodeGenerator.cs b/src/EFCore.SqlServer/Design/Internal/SqlServerCSharpRuntimeAnnotationCodeGenerator.cs index f5bbce3af2e..129080855fc 100644 --- a/src/EFCore.SqlServer/Design/Internal/SqlServerCSharpRuntimeAnnotationCodeGenerator.cs +++ b/src/EFCore.SqlServer/Design/Internal/SqlServerCSharpRuntimeAnnotationCodeGenerator.cs @@ -39,6 +39,7 @@ public override void Generate(IModel model, CSharpRuntimeAnnotationCodeGenerator annotations.Remove(SqlServerAnnotationNames.MaxDatabaseSize); annotations.Remove(SqlServerAnnotationNames.PerformanceLevelSql); annotations.Remove(SqlServerAnnotationNames.ServiceTierSql); + annotations.Remove(SqlServerAnnotationNames.FullTextCatalogs); } base.Generate(model, parameters); @@ -50,6 +51,7 @@ public override void Generate(IRelationalModel model, CSharpRuntimeAnnotationCod if (!parameters.IsRuntime) { var annotations = parameters.Annotations; + annotations.Remove(SqlServerAnnotationNames.FullTextCatalogs); annotations.Remove(SqlServerAnnotationNames.MemoryOptimized); annotations.Remove(SqlServerAnnotationNames.EditionOptions); } @@ -105,6 +107,10 @@ public override void Generate(IIndex index, CSharpRuntimeAnnotationCodeGenerator annotations.Remove(SqlServerAnnotationNames.DataCompression); annotations.Remove(SqlServerAnnotationNames.VectorIndexMetric); annotations.Remove(SqlServerAnnotationNames.VectorIndexType); + annotations.Remove(SqlServerAnnotationNames.FullTextIndex); + annotations.Remove(SqlServerAnnotationNames.FullTextCatalog); + annotations.Remove(SqlServerAnnotationNames.FullTextChangeTracking); + annotations.Remove(SqlServerAnnotationNames.FullTextLanguages); } base.Generate(index, parameters); @@ -124,6 +130,10 @@ public override void Generate(ITableIndex index, CSharpRuntimeAnnotationCodeGene annotations.Remove(SqlServerAnnotationNames.DataCompression); annotations.Remove(SqlServerAnnotationNames.VectorIndexMetric); annotations.Remove(SqlServerAnnotationNames.VectorIndexType); + annotations.Remove(SqlServerAnnotationNames.FullTextIndex); + annotations.Remove(SqlServerAnnotationNames.FullTextCatalog); + annotations.Remove(SqlServerAnnotationNames.FullTextChangeTracking); + annotations.Remove(SqlServerAnnotationNames.FullTextLanguages); } base.Generate(index, parameters); diff --git a/src/EFCore.SqlServer/Extensions/SqlServerEntityTypeBuilderExtensions.cs b/src/EFCore.SqlServer/Extensions/SqlServerEntityTypeBuilderExtensions.cs index fb705dd81b9..39e115cd27c 100644 --- a/src/EFCore.SqlServer/Extensions/SqlServerEntityTypeBuilderExtensions.cs +++ b/src/EFCore.SqlServer/Extensions/SqlServerEntityTypeBuilderExtensions.cs @@ -360,6 +360,61 @@ public static SqlServerVectorIndexBuilder HasVectorIndex( return new SqlServerVectorIndexBuilder(indexBuilder); } + /// + /// Configures a full-text index on the specified properties for SQL Server. + /// + /// + /// See Full-Text Search + /// for more information on SQL Server full-text search. + /// + /// The entity type being configured. + /// The builder for the entity type being configured. + /// + /// A lambda expression representing the properties to include in the full-text index + /// (blog => new { blog.Title, blog.Content }). + /// + /// A builder to further configure the full-text index. + public static SqlServerFullTextIndexBuilder HasFullTextIndex( + this EntityTypeBuilder entityTypeBuilder, + Expression> indexExpression) + where TEntity : class + { + Check.NotNull(indexExpression); + + var indexBuilder = entityTypeBuilder.HasIndex(indexExpression); + + // Having the FullTextIndex annotation (storing the KEY INDEX name) is what marks an index as a full-text index. + // The KEY INDEX name itself must be set later by the user via the builder API. + indexBuilder.Metadata.SetFullTextKeyIndex(null); + + return new SqlServerFullTextIndexBuilder(indexBuilder); + } + + /// + /// Configures a full-text index on the specified properties for SQL Server. + /// + /// + /// See Full-Text Search + /// for more information on SQL Server full-text search. + /// + /// The builder for the entity type being configured. + /// The names of the properties to include in the full-text index. + /// A builder to further configure the full-text index. + public static SqlServerFullTextIndexBuilder HasFullTextIndex( + this EntityTypeBuilder entityTypeBuilder, + params string[] propertyNames) + { + Check.NotEmpty(propertyNames); + + var indexBuilder = entityTypeBuilder.HasIndex(propertyNames); + + // Having the FullTextIndex annotation (storing the KEY INDEX name) is what marks an index as a full-text index. + // The KEY INDEX name itself must be set later by the user via the builder API. + indexBuilder.Metadata.SetFullTextKeyIndex(null); + + return new SqlServerFullTextIndexBuilder(indexBuilder); + } + /// /// Configures a history table name for the entity mapped to a temporal table. /// diff --git a/src/EFCore.SqlServer/Extensions/SqlServerIndexBuilderExtensions.cs b/src/EFCore.SqlServer/Extensions/SqlServerIndexBuilderExtensions.cs index a27ab542b33..f91f79214e6 100644 --- a/src/EFCore.SqlServer/Extensions/SqlServerIndexBuilderExtensions.cs +++ b/src/EFCore.SqlServer/Extensions/SqlServerIndexBuilderExtensions.cs @@ -563,4 +563,332 @@ public static bool CanSetDataCompression( DataCompressionType? dataCompressionType, bool fromDataAnnotation = false) => indexBuilder.CanSetAnnotation(SqlServerAnnotationNames.DataCompression, dataCompressionType, fromDataAnnotation); + + /// + /// Configures the KEY INDEX for the full-text index when targeting SQL Server. + /// + /// + /// See Full-Text Search + /// for more information on SQL Server full-text search. + /// + /// The builder for the index being configured. + /// The name of the KEY INDEX. + /// A builder to further configure the index. + public static IndexBuilder HasFullTextKeyIndex(this IndexBuilder indexBuilder, string keyIndexName) + { + Check.NotEmpty(keyIndexName); + + indexBuilder.Metadata.SetFullTextKeyIndex(keyIndexName); + + return indexBuilder; + } + + /// + /// Configures the KEY INDEX for the full-text index when targeting SQL Server. + /// + /// + /// See Full-Text Search + /// for more information on SQL Server full-text search. + /// + /// The builder for the index being configured. + /// The name of the KEY INDEX. + /// A builder to further configure the index. + public static IndexBuilder HasFullTextKeyIndex( + this IndexBuilder indexBuilder, + string keyIndexName) + => (IndexBuilder)HasFullTextKeyIndex((IndexBuilder)indexBuilder, keyIndexName); + + /// + /// Configures the KEY INDEX for the full-text index when targeting SQL Server. + /// + /// + /// See Full-Text Search + /// for more information on SQL Server full-text search. + /// + /// The builder for the index being configured. + /// The name of the KEY INDEX. + /// Indicates whether the configuration was specified using a data annotation. + /// + /// The same builder instance if the configuration was applied, + /// otherwise. + /// + public static IConventionIndexBuilder? HasFullTextKeyIndex( + this IConventionIndexBuilder indexBuilder, + string? keyIndexName, + bool fromDataAnnotation = false) + { + if (indexBuilder.CanSetFullTextKeyIndex(keyIndexName, fromDataAnnotation)) + { + indexBuilder.Metadata.SetFullTextKeyIndex(keyIndexName, fromDataAnnotation); + + return indexBuilder; + } + + return null; + } + + /// + /// Returns a value indicating whether the index can be configured with the specified KEY INDEX when targeting SQL Server. + /// + /// + /// See Full-Text Search + /// for more information on SQL Server full-text search. + /// + /// The builder for the index being configured. + /// The name of the KEY INDEX. + /// Indicates whether the configuration was specified using a data annotation. + /// if the index can be configured with the specified KEY INDEX when targeting SQL Server. + public static bool CanSetFullTextKeyIndex( + this IConventionIndexBuilder indexBuilder, + string? keyIndexName, + bool fromDataAnnotation = false) + => indexBuilder.CanSetAnnotation(SqlServerAnnotationNames.FullTextIndex, keyIndexName, fromDataAnnotation); + + /// + /// Configures the full-text catalog for the full-text index when targeting SQL Server. + /// + /// + /// See Full-Text Search + /// for more information on SQL Server full-text search. + /// + /// The builder for the index being configured. + /// The name of the full-text catalog. + /// A builder to further configure the index. + public static IndexBuilder HasFullTextCatalog(this IndexBuilder indexBuilder, string catalogName) + { + Check.NotEmpty(catalogName); + + indexBuilder.Metadata.SetFullTextCatalog(catalogName); + + return indexBuilder; + } + + /// + /// Configures the full-text catalog for the full-text index when targeting SQL Server. + /// + /// + /// See Full-Text Search + /// for more information on SQL Server full-text search. + /// + /// The builder for the index being configured. + /// The name of the full-text catalog. + /// A builder to further configure the index. + public static IndexBuilder HasFullTextCatalog( + this IndexBuilder indexBuilder, + string catalogName) + => (IndexBuilder)HasFullTextCatalog((IndexBuilder)indexBuilder, catalogName); + + /// + /// Configures the full-text catalog for the full-text index when targeting SQL Server. + /// + /// + /// See Full-Text Search + /// for more information on SQL Server full-text search. + /// + /// The builder for the index being configured. + /// The name of the full-text catalog. + /// Indicates whether the configuration was specified using a data annotation. + /// + /// The same builder instance if the configuration was applied, + /// otherwise. + /// + public static IConventionIndexBuilder? HasFullTextCatalog( + this IConventionIndexBuilder indexBuilder, + string? catalogName, + bool fromDataAnnotation = false) + { + if (indexBuilder.CanSetFullTextCatalog(catalogName, fromDataAnnotation)) + { + indexBuilder.Metadata.SetFullTextCatalog(catalogName, fromDataAnnotation); + + return indexBuilder; + } + + return null; + } + + /// + /// Returns a value indicating whether the index can be configured with the specified full-text catalog when targeting SQL Server. + /// + /// + /// See Full-Text Search + /// for more information on SQL Server full-text search. + /// + /// The builder for the index being configured. + /// The name of the full-text catalog. + /// Indicates whether the configuration was specified using a data annotation. + /// if the index can be configured with the specified full-text catalog when targeting SQL Server. + public static bool CanSetFullTextCatalog( + this IConventionIndexBuilder indexBuilder, + string? catalogName, + bool fromDataAnnotation = false) + => indexBuilder.CanSetAnnotation(SqlServerAnnotationNames.FullTextCatalog, catalogName, fromDataAnnotation); + + /// + /// Configures the change tracking mode for the full-text index when targeting SQL Server. + /// + /// + /// See Full-Text Search + /// for more information on SQL Server full-text search. + /// + /// The builder for the index being configured. + /// The change tracking mode. + /// A builder to further configure the index. + public static IndexBuilder HasFullTextChangeTracking(this IndexBuilder indexBuilder, FullTextChangeTracking changeTracking) + { + indexBuilder.Metadata.SetFullTextChangeTracking(changeTracking); + + return indexBuilder; + } + + /// + /// Configures the change tracking mode for the full-text index when targeting SQL Server. + /// + /// + /// See Full-Text Search + /// for more information on SQL Server full-text search. + /// + /// The builder for the index being configured. + /// The change tracking mode. + /// A builder to further configure the index. + public static IndexBuilder HasFullTextChangeTracking( + this IndexBuilder indexBuilder, + FullTextChangeTracking changeTracking) + => (IndexBuilder)HasFullTextChangeTracking((IndexBuilder)indexBuilder, changeTracking); + + /// + /// Configures the change tracking mode for the full-text index when targeting SQL Server. + /// + /// + /// See Full-Text Search + /// for more information on SQL Server full-text search. + /// + /// The builder for the index being configured. + /// The change tracking mode. + /// Indicates whether the configuration was specified using a data annotation. + /// + /// The same builder instance if the configuration was applied, + /// otherwise. + /// + public static IConventionIndexBuilder? HasFullTextChangeTracking( + this IConventionIndexBuilder indexBuilder, + FullTextChangeTracking? changeTracking, + bool fromDataAnnotation = false) + { + if (indexBuilder.CanSetFullTextChangeTracking(changeTracking, fromDataAnnotation)) + { + indexBuilder.Metadata.SetFullTextChangeTracking(changeTracking, fromDataAnnotation); + + return indexBuilder; + } + + return null; + } + + /// + /// Returns a value indicating whether the index can be configured with the specified change tracking mode + /// when targeting SQL Server. + /// + /// + /// See Full-Text Search + /// for more information on SQL Server full-text search. + /// + /// The builder for the index being configured. + /// The change tracking mode. + /// Indicates whether the configuration was specified using a data annotation. + /// + /// if the index can be configured with the specified change tracking mode when targeting SQL Server. + /// + public static bool CanSetFullTextChangeTracking( + this IConventionIndexBuilder indexBuilder, + FullTextChangeTracking? changeTracking, + bool fromDataAnnotation = false) + => indexBuilder.CanSetAnnotation(SqlServerAnnotationNames.FullTextChangeTracking, changeTracking, fromDataAnnotation); + + /// + /// Configures the language for a specific property in the full-text index when targeting SQL Server. + /// + /// + /// See Full-Text Search + /// for more information on SQL Server full-text search. + /// + /// The builder for the index being configured. + /// The name of the property. + /// The language term (e.g. "English", "1033"). + /// A builder to further configure the index. + public static IndexBuilder HasFullTextLanguage(this IndexBuilder indexBuilder, string propertyName, string language) + { + Check.NotEmpty(propertyName); + Check.NotEmpty(language); + + indexBuilder.Metadata.SetFullTextLanguage(propertyName, language); + + return indexBuilder; + } + + /// + /// Configures the language for a specific property in the full-text index when targeting SQL Server. + /// + /// + /// See Full-Text Search + /// for more information on SQL Server full-text search. + /// + /// The builder for the index being configured. + /// The name of the property. + /// The language term (e.g. "English", "1033"). + /// A builder to further configure the index. + public static IndexBuilder HasFullTextLanguage( + this IndexBuilder indexBuilder, + string propertyName, + string language) + => (IndexBuilder)HasFullTextLanguage((IndexBuilder)indexBuilder, propertyName, language); + + /// + /// Configures the languages for properties in the full-text index when targeting SQL Server. + /// + /// + /// See Full-Text Search + /// for more information on SQL Server full-text search. + /// + /// The builder for the index being configured. + /// A dictionary of property names to language terms, or to remove all. + /// Indicates whether the configuration was specified using a data annotation. + /// + /// The same builder instance if the configuration was applied, + /// otherwise. + /// + public static IConventionIndexBuilder? HasFullTextLanguages( + this IConventionIndexBuilder indexBuilder, + IReadOnlyDictionary? languages, + bool fromDataAnnotation = false) + { + if (indexBuilder.CanSetFullTextLanguages(languages, fromDataAnnotation)) + { + indexBuilder.Metadata.SetFullTextLanguages(languages, fromDataAnnotation); + + return indexBuilder; + } + + return null; + } + + /// + /// Returns a value indicating whether the languages for properties in the full-text index can be set + /// when targeting SQL Server. + /// + /// + /// See Full-Text Search + /// for more information on SQL Server full-text search. + /// + /// The builder for the index being configured. + /// A dictionary of property names to language terms. + /// Indicates whether the configuration was specified using a data annotation. + /// + /// if the languages for properties can be set when targeting SQL Server. + /// + public static bool CanSetFullTextLanguages( + this IConventionIndexBuilder indexBuilder, + IReadOnlyDictionary? languages, + bool fromDataAnnotation = false) + => indexBuilder.CanSetAnnotation(SqlServerAnnotationNames.FullTextLanguages, languages, fromDataAnnotation); } diff --git a/src/EFCore.SqlServer/Extensions/SqlServerIndexExtensions.cs b/src/EFCore.SqlServer/Extensions/SqlServerIndexExtensions.cs index b0e984a3290..d77063eaae8 100644 --- a/src/EFCore.SqlServer/Extensions/SqlServerIndexExtensions.cs +++ b/src/EFCore.SqlServer/Extensions/SqlServerIndexExtensions.cs @@ -17,6 +17,8 @@ namespace Microsoft.EntityFrameworkCore; /// public static class SqlServerIndexExtensions { + #region IsClustered + /// /// Returns a value indicating whether the index is clustered. /// @@ -84,6 +86,10 @@ public static void SetIsClustered(this IMutableIndex index, bool? value) public static ConfigurationSource? GetIsClusteredConfigurationSource(this IConventionIndex property) => property.FindAnnotation(SqlServerAnnotationNames.Clustered)?.GetConfigurationSource(); + #endregion IsClustered + + #region IncludeProperties + /// /// Returns included property names, or if they have not been specified. /// @@ -151,6 +157,10 @@ public static void SetIncludeProperties(this IMutableIndex index, IReadOnlyList< public static ConfigurationSource? GetIncludePropertiesConfigurationSource(this IConventionIndex index) => index.FindAnnotation(SqlServerAnnotationNames.Include)?.GetConfigurationSource(); + #endregion IncludeProperties + + #region IsCreatedOnline + /// /// Returns a value indicating whether the index is online. /// @@ -218,6 +228,10 @@ public static void SetIsCreatedOnline(this IMutableIndex index, bool? createdOnl public static ConfigurationSource? GetIsCreatedOnlineConfigurationSource(this IConventionIndex index) => index.FindAnnotation(SqlServerAnnotationNames.CreatedOnline)?.GetConfigurationSource(); + #endregion IsCreatedOnline + + #region FillFactor + /// /// Returns the fill factor that the index uses. /// @@ -299,6 +313,10 @@ public static void SetFillFactor(this IMutableIndex index, int? fillFactor) public static ConfigurationSource? GetFillFactorConfigurationSource(this IConventionIndex index) => index.FindAnnotation(SqlServerAnnotationNames.FillFactor)?.GetConfigurationSource(); + #endregion FillFactor + + #region SortInTempDb + /// /// Returns a value indicating whether the index is sorted in tempdb. /// @@ -366,6 +384,10 @@ public static void SetSortInTempDb(this IMutableIndex index, bool? sortInTempDb) public static ConfigurationSource? GetSortInTempDbConfigurationSource(this IConventionIndex index) => index.FindAnnotation(SqlServerAnnotationNames.SortInTempDb)?.GetConfigurationSource(); + #endregion SortInTempDb + + #region DataCompression + /// /// Returns the data compression that the index uses. /// @@ -433,6 +455,10 @@ public static void SetDataCompression(this IMutableIndex index, DataCompressionT public static ConfigurationSource? GetDataCompressionConfigurationSource(this IConventionIndex index) => index.FindAnnotation(SqlServerAnnotationNames.DataCompression)?.GetConfigurationSource(); + #endregion DataCompression + + #region VectorMetric + /// /// Returns whether the index is a vector index. /// @@ -514,6 +540,10 @@ public static void SetVectorMetric(this IMutableIndex index, string? metric) public static ConfigurationSource? GetVectorMetricConfigurationSource(this IConventionIndex index) => index.FindAnnotation(SqlServerAnnotationNames.VectorIndexMetric)?.GetConfigurationSource(); + #endregion VectorMetric + + #region VectorIndexType + /// /// Returns the type of the vector index. /// @@ -583,4 +613,352 @@ public static void SetVectorIndexType(this IMutableIndex index, string? type) [Experimental(EFDiagnostics.SqlServerVectorSearch)] public static ConfigurationSource? GetVectorIndexTypeConfigurationSource(this IConventionIndex index) => index.FindAnnotation(SqlServerAnnotationNames.VectorIndexType)?.GetConfigurationSource(); + + #endregion VectorIndexType + + #region FullTextKeyIndex + + /// + /// Returns whether the index is a full-text index. + /// + /// The index. + /// if the index is a full-text index. + public static bool IsFullTextIndex(this IReadOnlyIndex index) + => index is RuntimeIndex + ? throw new InvalidOperationException(CoreStrings.RuntimeModelMissingData) + : index.FindAnnotation(SqlServerAnnotationNames.FullTextIndex) is not null; + + /// + /// Returns the KEY INDEX name for the full-text index. + /// + /// The index. + /// The KEY INDEX name, or if the index is not a full-text index. + public static string? GetFullTextKeyIndex(this IReadOnlyIndex index) + => index is RuntimeIndex + ? throw new InvalidOperationException(CoreStrings.RuntimeModelMissingData) + : (string?)index[SqlServerAnnotationNames.FullTextIndex]; + + /// + /// Returns the KEY INDEX name for the full-text index. + /// + /// The index. + /// The identifier of the store object. + /// The KEY INDEX name, or if the index is not a full-text index. + public static string? GetFullTextKeyIndex(this IReadOnlyIndex index, in StoreObjectIdentifier storeObject) + { + if (index is RuntimeIndex) + { + throw new InvalidOperationException(CoreStrings.RuntimeModelMissingData); + } + + var annotation = index.FindAnnotation(SqlServerAnnotationNames.FullTextIndex); + if (annotation != null) + { + return (string?)annotation.Value; + } + + var sharedTableRootIndex = index.FindSharedObjectRootIndex(storeObject); + return sharedTableRootIndex?.GetFullTextKeyIndex(storeObject); + } + + /// + /// Sets the KEY INDEX name for the full-text index. Setting a non-null value marks this index as a full-text index. + /// + /// The index. + /// The KEY INDEX name to set. + public static void SetFullTextKeyIndex(this IMutableIndex index, string? keyIndexName) + => index.SetAnnotation(SqlServerAnnotationNames.FullTextIndex, keyIndexName); + + /// + /// Sets the KEY INDEX name for the full-text index. Setting a non-null value marks this index as a full-text index. + /// + /// The index. + /// The KEY INDEX name to set. + /// Indicates whether the configuration was specified using a data annotation. + /// The configured value. + public static string? SetFullTextKeyIndex( + this IConventionIndex index, + string? keyIndexName, + bool fromDataAnnotation = false) + => (string?)index.SetAnnotation( + SqlServerAnnotationNames.FullTextIndex, + keyIndexName, + fromDataAnnotation)?.Value; + + /// + /// Returns the for the KEY INDEX of the full-text index. + /// + /// The index. + /// The for the KEY INDEX. + public static ConfigurationSource? GetFullTextKeyIndexConfigurationSource(this IConventionIndex index) + => index.FindAnnotation(SqlServerAnnotationNames.FullTextIndex)?.GetConfigurationSource(); + + #endregion FullTextKeyIndex + + #region FullTextCatalog + + /// + /// Returns the full-text catalog name for the full-text index. + /// + /// The index. + /// The full-text catalog name, or if not set. + public static string? GetFullTextCatalog(this IReadOnlyIndex index) + => index is RuntimeIndex + ? throw new InvalidOperationException(CoreStrings.RuntimeModelMissingData) + : (string?)index[SqlServerAnnotationNames.FullTextCatalog]; + + /// + /// Returns the full-text catalog name for the full-text index. + /// + /// The index. + /// The identifier of the store object. + /// The full-text catalog name, or if not set. + public static string? GetFullTextCatalog(this IReadOnlyIndex index, in StoreObjectIdentifier storeObject) + { + if (index is RuntimeIndex) + { + throw new InvalidOperationException(CoreStrings.RuntimeModelMissingData); + } + + var annotation = index.FindAnnotation(SqlServerAnnotationNames.FullTextCatalog); + if (annotation != null) + { + return (string?)annotation.Value; + } + + var sharedTableRootIndex = index.FindSharedObjectRootIndex(storeObject); + return sharedTableRootIndex?.GetFullTextCatalog(storeObject); + } + + /// + /// Sets the full-text catalog name for the full-text index. + /// + /// The index. + /// The catalog name to set. + public static void SetFullTextCatalog(this IMutableIndex index, string? catalogName) + => index.SetAnnotation(SqlServerAnnotationNames.FullTextCatalog, catalogName); + + /// + /// Sets the full-text catalog name for the full-text index. + /// + /// The index. + /// The catalog name to set. + /// Indicates whether the configuration was specified using a data annotation. + /// The configured value. + public static string? SetFullTextCatalog( + this IConventionIndex index, + string? catalogName, + bool fromDataAnnotation = false) + => (string?)index.SetAnnotation( + SqlServerAnnotationNames.FullTextCatalog, + catalogName, + fromDataAnnotation)?.Value; + + /// + /// Returns the for the full-text catalog of the full-text index. + /// + /// The index. + /// The for the full-text catalog. + public static ConfigurationSource? GetFullTextCatalogConfigurationSource(this IConventionIndex index) + => index.FindAnnotation(SqlServerAnnotationNames.FullTextCatalog)?.GetConfigurationSource(); + + #endregion FullTextCatalog + + #region FullTextChangeTracking + + /// + /// Returns the change tracking mode for the full-text index. + /// + /// The index. + /// The change tracking mode, or if not set. + public static FullTextChangeTracking? GetFullTextChangeTracking(this IReadOnlyIndex index) + => index is RuntimeIndex + ? throw new InvalidOperationException(CoreStrings.RuntimeModelMissingData) + : (FullTextChangeTracking?)index[SqlServerAnnotationNames.FullTextChangeTracking]; + + /// + /// Returns the change tracking mode for the full-text index. + /// + /// The index. + /// The identifier of the store object. + /// The change tracking mode, or if not set. + public static FullTextChangeTracking? GetFullTextChangeTracking( + this IReadOnlyIndex index, + in StoreObjectIdentifier storeObject) + { + if (index is RuntimeIndex) + { + throw new InvalidOperationException(CoreStrings.RuntimeModelMissingData); + } + + var annotation = index.FindAnnotation(SqlServerAnnotationNames.FullTextChangeTracking); + if (annotation != null) + { + return (FullTextChangeTracking?)annotation.Value; + } + + var sharedTableRootIndex = index.FindSharedObjectRootIndex(storeObject); + return sharedTableRootIndex?.GetFullTextChangeTracking(storeObject); + } + + /// + /// Sets the change tracking mode for the full-text index. + /// + /// The index. + /// The change tracking mode to set. + public static void SetFullTextChangeTracking(this IMutableIndex index, FullTextChangeTracking? changeTracking) + => index.SetAnnotation(SqlServerAnnotationNames.FullTextChangeTracking, changeTracking); + + /// + /// Sets the change tracking mode for the full-text index. + /// + /// The index. + /// The change tracking mode to set. + /// Indicates whether the configuration was specified using a data annotation. + /// The configured value. + public static FullTextChangeTracking? SetFullTextChangeTracking( + this IConventionIndex index, + FullTextChangeTracking? changeTracking, + bool fromDataAnnotation = false) + => (FullTextChangeTracking?)index.SetAnnotation( + SqlServerAnnotationNames.FullTextChangeTracking, + changeTracking, + fromDataAnnotation)?.Value; + + /// + /// Returns the for the change tracking mode of the full-text index. + /// + /// The index. + /// The for the change tracking mode. + public static ConfigurationSource? GetFullTextChangeTrackingConfigurationSource(this IConventionIndex index) + => index.FindAnnotation(SqlServerAnnotationNames.FullTextChangeTracking)?.GetConfigurationSource(); + + #endregion FullTextChangeTracking + + #region FullTextLanguage + + /// + /// Returns the full-text language for a specific property in the full-text index. + /// + /// The index. + /// The property name. + /// The language term, or if not set. + public static string? GetFullTextLanguage(this IReadOnlyIndex index, string propertyName) + { + if (index is RuntimeIndex) + { + throw new InvalidOperationException(CoreStrings.RuntimeModelMissingData); + } + + var languages = (IReadOnlyDictionary?)index[SqlServerAnnotationNames.FullTextLanguages]; + return languages != null && languages.TryGetValue(propertyName, out var language) ? language : null; + } + + /// + /// Returns all full-text languages configured for the full-text index. + /// + /// The index. + /// A dictionary of property names to language terms, or if none are set. + public static IReadOnlyDictionary? GetFullTextLanguages(this IReadOnlyIndex index) + => index is RuntimeIndex + ? throw new InvalidOperationException(CoreStrings.RuntimeModelMissingData) + : (IReadOnlyDictionary?)index[SqlServerAnnotationNames.FullTextLanguages]; + + /// + /// Sets the full-text language for a specific property in the full-text index. + /// + /// The index. + /// The property name. + /// The language term to set, or to remove. + public static void SetFullTextLanguage(this IMutableIndex index, string propertyName, string? language) + { + var languages = (Dictionary?)index[SqlServerAnnotationNames.FullTextLanguages]; + if (language is null) + { + if (languages != null) + { + languages.Remove(propertyName); + if (languages.Count == 0) + { + index.RemoveAnnotation(SqlServerAnnotationNames.FullTextLanguages); + } + } + } + else + { + languages ??= []; + languages[propertyName] = language; + index.SetAnnotation(SqlServerAnnotationNames.FullTextLanguages, languages); + } + } + + /// + /// Sets the full-text languages for all properties in the full-text index. + /// + /// The index. + /// A dictionary of property names to language terms, or to remove all. + public static void SetFullTextLanguages(this IMutableIndex index, IReadOnlyDictionary? languages) + => index.SetAnnotation(SqlServerAnnotationNames.FullTextLanguages, languages); + + /// + /// Sets the full-text language for a specific property in the full-text index. + /// + /// The index. + /// The property name. + /// The language term to set, or to remove. + /// Indicates whether the configuration was specified using a data annotation. + /// The configured value. + public static string? SetFullTextLanguage( + this IConventionIndex index, + string propertyName, + string? language, + bool fromDataAnnotation = false) + { + var languages = (Dictionary?)index[SqlServerAnnotationNames.FullTextLanguages]; + if (language is null) + { + if (languages != null) + { + languages.Remove(propertyName); + if (languages.Count == 0) + { + index.RemoveAnnotation(SqlServerAnnotationNames.FullTextLanguages); + } + } + } + else + { + languages ??= []; + languages[propertyName] = language; + index.SetAnnotation(SqlServerAnnotationNames.FullTextLanguages, languages, fromDataAnnotation); + } + + return language; + } + + /// + /// Sets the full-text languages for all properties in the full-text index. + /// + /// The index. + /// A dictionary of property names to language terms, or to remove all. + /// Indicates whether the configuration was specified using a data annotation. + /// The configured value. + public static IReadOnlyDictionary? SetFullTextLanguages( + this IConventionIndex index, + IReadOnlyDictionary? languages, + bool fromDataAnnotation = false) + => (IReadOnlyDictionary?)index.SetAnnotation( + SqlServerAnnotationNames.FullTextLanguages, + languages, + fromDataAnnotation)?.Value; + + /// + /// Returns the for the full-text languages of the full-text index. + /// + /// The index. + /// The for the full-text languages. + public static ConfigurationSource? GetFullTextLanguagesConfigurationSource(this IConventionIndex index) + => index.FindAnnotation(SqlServerAnnotationNames.FullTextLanguages)?.GetConfigurationSource(); + + #endregion FullTextLanguage } diff --git a/src/EFCore.SqlServer/Extensions/SqlServerModelBuilderExtensions.cs b/src/EFCore.SqlServer/Extensions/SqlServerModelBuilderExtensions.cs index 3bf2a56a7fb..257eb95c9fa 100644 --- a/src/EFCore.SqlServer/Extensions/SqlServerModelBuilderExtensions.cs +++ b/src/EFCore.SqlServer/Extensions/SqlServerModelBuilderExtensions.cs @@ -718,4 +718,39 @@ public static bool CanUseNamedDefaultConstraints( bool value, bool fromDataAnnotation = false) => modelBuilder.CanSetAnnotation(RelationalAnnotationNames.UseNamedDefaultConstraints, value, fromDataAnnotation); + + /// + /// Configures a full-text catalog in the model. + /// + /// + /// See Full-Text Search + /// for more information on SQL Server full-text search. + /// + /// The model builder. + /// The name of the full-text catalog. + /// A builder to further configure the full-text catalog. + public static SqlServerFullTextCatalogBuilder HasFullTextCatalog( + this ModelBuilder modelBuilder, + string name) + { + Check.NotEmpty(name); + + var catalog = HasFullTextCatalog(modelBuilder.Model, name, ConfigurationSource.Explicit); + return new SqlServerFullTextCatalogBuilder(catalog); + } + + private static SqlServerFullTextCatalog HasFullTextCatalog( + IMutableModel model, + string name, + ConfigurationSource configurationSource) + { + var catalog = SqlServerFullTextCatalog.FindFullTextCatalog(model, name); + if (catalog != null) + { + catalog.UpdateConfigurationSource(configurationSource); + return catalog; + } + + return SqlServerFullTextCatalog.AddFullTextCatalog(model, name, configurationSource); + } } diff --git a/src/EFCore.SqlServer/Extensions/SqlServerModelExtensions.cs b/src/EFCore.SqlServer/Extensions/SqlServerModelExtensions.cs index b3c63d4bbce..7bab4a6c2f5 100644 --- a/src/EFCore.SqlServer/Extensions/SqlServerModelExtensions.cs +++ b/src/EFCore.SqlServer/Extensions/SqlServerModelExtensions.cs @@ -472,4 +472,175 @@ public static void SetPerformanceLevelSql(this IMutableModel model, string? valu /// The for the performance level of the database. public static ConfigurationSource? GetPerformanceLevelSqlConfigurationSource(this IConventionModel model) => model.FindAnnotation(SqlServerAnnotationNames.PerformanceLevelSql)?.GetConfigurationSource(); + + /// + /// Adds a full-text catalog to the model. + /// + /// + /// See Full-Text Search + /// for more information on SQL Server full-text search. + /// + /// The model. + /// The name of the full-text catalog. + /// The added to the model. + public static IMutableSqlServerFullTextCatalog AddFullTextCatalog( + this IMutableModel model, + string name) + => SqlServerFullTextCatalog.AddFullTextCatalog(model, name, ConfigurationSource.Explicit); + + /// + /// Adds a full-text catalog to the model. + /// + /// + /// See Full-Text Search + /// for more information on SQL Server full-text search. + /// + /// The model. + /// The name of the full-text catalog. + /// Indicates whether the configuration was specified using a data annotation. + /// The added to the model, or if the catalog could not be added. + public static IConventionSqlServerFullTextCatalog? AddFullTextCatalog( + this IConventionModel model, + string name, + bool fromDataAnnotation = false) + => SqlServerFullTextCatalog.AddFullTextCatalog( + (IMutableModel)model, name, fromDataAnnotation ? ConfigurationSource.DataAnnotation : ConfigurationSource.Convention); + + /// + /// Finds a full-text catalog in the model, or returns if not found. + /// + /// + /// See Full-Text Search + /// for more information on SQL Server full-text search. + /// + /// The model. + /// The name of the full-text catalog. + /// The , or if not found. + public static IReadOnlySqlServerFullTextCatalog? FindFullTextCatalog( + this IReadOnlyModel model, + string name) + => SqlServerFullTextCatalog.FindFullTextCatalog(model, name); + + /// + /// Finds a full-text catalog in the model, or returns if not found. + /// + /// + /// See Full-Text Search + /// for more information on SQL Server full-text search. + /// + /// The model. + /// The name of the full-text catalog. + /// The , or if not found. + public static IMutableSqlServerFullTextCatalog? FindFullTextCatalog( + this IMutableModel model, + string name) + => (IMutableSqlServerFullTextCatalog?)((IReadOnlyModel)model).FindFullTextCatalog(name); + + /// + /// Finds a full-text catalog in the model, or returns if not found. + /// + /// + /// See Full-Text Search + /// for more information on SQL Server full-text search. + /// + /// The model. + /// The name of the full-text catalog. + /// The , or if not found. + public static IConventionSqlServerFullTextCatalog? FindFullTextCatalog( + this IConventionModel model, + string name) + => (IConventionSqlServerFullTextCatalog?)((IReadOnlyModel)model).FindFullTextCatalog(name); + + /// + /// Finds a full-text catalog in the model, or returns if not found. + /// + /// + /// See Full-Text Search + /// for more information on SQL Server full-text search. + /// + /// The model. + /// The name of the full-text catalog. + /// The , or if not found. + public static ISqlServerFullTextCatalog? FindFullTextCatalog( + this IModel model, + string name) + => (ISqlServerFullTextCatalog?)((IReadOnlyModel)model).FindFullTextCatalog(name); + + /// + /// Removes a full-text catalog from the model. + /// + /// + /// See Full-Text Search + /// for more information on SQL Server full-text search. + /// + /// The model. + /// The name of the full-text catalog. + /// The removed , or if not found. + public static IMutableSqlServerFullTextCatalog? RemoveFullTextCatalog( + this IMutableModel model, + string name) + => SqlServerFullTextCatalog.RemoveFullTextCatalog(model, name); + + /// + /// Removes a full-text catalog from the model. + /// + /// + /// See Full-Text Search + /// for more information on SQL Server full-text search. + /// + /// The model. + /// The name of the full-text catalog. + /// The removed , or if not found. + public static IConventionSqlServerFullTextCatalog? RemoveFullTextCatalog( + this IConventionModel model, + string name) + => SqlServerFullTextCatalog.RemoveFullTextCatalog((IMutableModel)model, name); + + /// + /// Gets all full-text catalogs defined in the model. + /// + /// + /// See Full-Text Search + /// for more information on SQL Server full-text search. + /// + /// The model. + /// All full-text catalogs defined in the model. + public static IEnumerable GetFullTextCatalogs(this IReadOnlyModel model) + => SqlServerFullTextCatalog.GetFullTextCatalogs(model); + + /// + /// Gets all full-text catalogs defined in the model. + /// + /// + /// See Full-Text Search + /// for more information on SQL Server full-text search. + /// + /// The model. + /// All full-text catalogs defined in the model. + public static IEnumerable GetFullTextCatalogs(this IMutableModel model) + => SqlServerFullTextCatalog.GetFullTextCatalogs(model).Cast(); + + /// + /// Gets all full-text catalogs defined in the model. + /// + /// + /// See Full-Text Search + /// for more information on SQL Server full-text search. + /// + /// The model. + /// All full-text catalogs defined in the model. + public static IEnumerable GetFullTextCatalogs(this IConventionModel model) + => SqlServerFullTextCatalog.GetFullTextCatalogs(model).Cast(); + + /// + /// Gets all full-text catalogs defined in the model. + /// + /// + /// See Full-Text Search + /// for more information on SQL Server full-text search. + /// + /// The model. + /// All full-text catalogs defined in the model. + public static IEnumerable GetFullTextCatalogs(this IModel model) + => SqlServerFullTextCatalog.GetFullTextCatalogs(model).Cast(); } diff --git a/src/EFCore.SqlServer/FullTextChangeTracking.cs b/src/EFCore.SqlServer/FullTextChangeTracking.cs new file mode 100644 index 00000000000..454b2cdd520 --- /dev/null +++ b/src/EFCore.SqlServer/FullTextChangeTracking.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// ReSharper disable once CheckNamespace + +namespace Microsoft.EntityFrameworkCore; + +/// +/// Indicates the change tracking mode for a SQL Server full-text index. +/// +/// +/// See Full-Text Search for more +/// information on SQL Server full-text search. +/// +public enum FullTextChangeTracking +{ + /// + /// SQL Server automatically maintains the full-text index as the underlying data changes. + /// + Auto, + + /// + /// Changes are tracked but not propagated automatically. Changes must be propagated manually + /// via ALTER FULLTEXT INDEX ... START UPDATE POPULATION. + /// + Manual, + + /// + /// Change tracking is disabled. The full-text index must be fully repopulated manually. + /// + Off, + + /// + /// No population is performed after the full-text index is created. The index must be populated + /// manually later. + /// + OffNoPopulation +} diff --git a/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs b/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs index 91d2e8b93c8..c823f78be3d 100644 --- a/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs +++ b/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs @@ -22,11 +22,23 @@ public class SqlServerModelValidator( RelationalModelValidatorDependencies relationalDependencies) : RelationalModelValidator(dependencies, relationalDependencies) { + private int _entityFullTextIndexCount; + + /// + public override void Validate(IModel model, IDiagnosticsLogger logger) + { + base.Validate(model, logger); + + ValidateFullTextCatalogs(model, logger); + } + /// protected override void ValidateEntityType( IEntityType entityType, IDiagnosticsLogger logger) { + _entityFullTextIndexCount = 0; + base.ValidateEntityType(entityType, logger); ValidateTemporalTable(entityType, logger); @@ -214,6 +226,7 @@ protected override void ValidateIndex( base.ValidateIndex(index, logger); ValidateIndexIncludeProperties(index); + ValidateFullTextIndex(index); #pragma warning disable EF9105 // Vector indexes are experimental ValidateVectorIndex(index); @@ -272,6 +285,69 @@ protected virtual void ValidateIndexIncludeProperties(IIndex index) } } + /// + /// 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. + /// + protected virtual void ValidateFullTextIndex( + IIndex index) + { + if (!index.IsFullTextIndex()) + { + return; + } + + var entityType = index.DeclaringEntityType; + + if (++_entityFullTextIndexCount > 1) + { + throw new InvalidOperationException( + SqlServerStrings.FullTextIndexDuplicateOnTable( + entityType.DisplayName())); + } + + // Validate that a KEY INDEX name has been configured + if (index.GetFullTextKeyIndex() is null) + { + throw new InvalidOperationException( + SqlServerStrings.FullTextIndexMissingKeyIndex( + index.DisplayName(), + entityType.DisplayName())); + } + + // Validate that FTS columns are text or varbinary types + foreach (var property in index.Properties) + { + var typeMapping = (RelationalTypeMapping?)property.FindTypeMapping(); + if (typeMapping?.StoreTypeNameBase is not ("varchar" or "nvarchar" or "varbinary" or "binary")) + { + throw new InvalidOperationException( + SqlServerStrings.FullTextIndexOnInvalidColumn( + index.DisplayName(), + entityType.DisplayName(), + property.Name)); + } + } + + // Validate that language properties are part of the index + if (index.GetFullTextLanguages() is { } languages) + { + foreach (var propertyName in languages.Keys) + { + if (!index.Properties.Any(p => p.Name == propertyName)) + { + throw new InvalidOperationException( + SqlServerStrings.FullTextIndexLanguagePropertyNotInIndex( + index.DisplayName(), + entityType.DisplayName(), + propertyName)); + } + } + } + } + /// /// 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 @@ -733,4 +809,31 @@ protected override void ValidateCompatible( index.AreCompatibleForSqlServer(duplicateIndex, table, shouldThrow: true); } + + /// + /// 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. + /// + protected virtual void ValidateFullTextCatalogs( + IModel model, + IDiagnosticsLogger logger) + { + // Validate that at most one catalog is marked as default + var defaultCatalogCount = 0; + + foreach (var catalog in model.GetFullTextCatalogs()) + { + if (catalog.IsDefault) + { + defaultCatalogCount++; + + if (defaultCatalogCount > 1) + { + throw new InvalidOperationException(SqlServerStrings.FullTextMultipleDefaultCatalogs); + } + } + } + } } diff --git a/src/EFCore.SqlServer/Metadata/Builders/SqlServerFullTextCatalogBuilder.cs b/src/EFCore.SqlServer/Metadata/Builders/SqlServerFullTextCatalogBuilder.cs new file mode 100644 index 00000000000..640025a5fea --- /dev/null +++ b/src/EFCore.SqlServer/Metadata/Builders/SqlServerFullTextCatalogBuilder.cs @@ -0,0 +1,52 @@ +// 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.SqlServer.Metadata.Internal; + +namespace Microsoft.EntityFrameworkCore.Metadata.Builders; + +/// +/// Provides a simple API for configuring a SQL Server full-text catalog. +/// +/// +/// +/// Instances of this class are returned from methods when using the API +/// and it is not designed to be directly constructed in your application code. +/// +/// +/// See Full-Text Search +/// for more information on SQL Server full-text search. +/// +/// +/// The underlying full-text catalog. +public class SqlServerFullTextCatalogBuilder(SqlServerFullTextCatalog catalog) +{ + /// + /// The underlying full-text catalog being configured. + /// + public virtual SqlServerFullTextCatalog Metadata { get; } = catalog; + + /// + /// Marks this catalog as the default full-text catalog for the database. + /// + /// Whether this is the default catalog. + /// The same builder instance so that multiple calls can be chained. + public virtual SqlServerFullTextCatalogBuilder IsDefault(bool @default = true) + { + Metadata.IsDefault = @default; + + return this; + } + + /// + /// Sets whether the full-text catalog is accent-sensitive. + /// + /// Whether the catalog is accent-sensitive. + /// The same builder instance so that multiple calls can be chained. + public virtual SqlServerFullTextCatalogBuilder IsAccentSensitive(bool accentSensitive = true) + { + Metadata.IsAccentSensitive = accentSensitive; + + return this; + } +} diff --git a/src/EFCore.SqlServer/Metadata/Builders/SqlServerFullTextIndexBuilder.cs b/src/EFCore.SqlServer/Metadata/Builders/SqlServerFullTextIndexBuilder.cs new file mode 100644 index 00000000000..2eb5c77ad53 --- /dev/null +++ b/src/EFCore.SqlServer/Metadata/Builders/SqlServerFullTextIndexBuilder.cs @@ -0,0 +1,119 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.Metadata.Builders; + +/// +/// Provides a simple API for configuring a full-text index on SQL Server. +/// +/// +/// +/// Instances of this class are returned from methods when using the API +/// and it is not designed to be directly constructed in your application code. +/// +/// +/// See Full-Text Search +/// for more information on SQL Server full-text search. +/// +/// +/// The index builder. +public class SqlServerFullTextIndexBuilder(IndexBuilder indexBuilder) +{ + /// + /// The index being configured. + /// + public virtual IMutableIndex Metadata + => indexBuilder.Metadata; + + /// + /// Configures the name of the index in the database when targeting a relational database. + /// + /// + /// See Indexes for more information and examples. + /// + /// The name of the index. + /// A builder to further configure the index. + public virtual SqlServerFullTextIndexBuilder HasDatabaseName(string? name) + { + Metadata.SetDatabaseName(name); + + return this; + } + + /// + /// Configures the KEY INDEX for the full-text index. This is the unique, non-nullable, single-column index + /// used as the unique key for the full-text index. + /// + /// + /// See + /// SQL Server documentation for CREATE FULLTEXT INDEX + /// . + /// + /// The name of the KEY INDEX. + /// A builder to further configure the full-text index. + public virtual SqlServerFullTextIndexBuilder HasKeyIndex(string keyIndexName) + { + Check.NotEmpty(keyIndexName); + + Metadata.SetFullTextKeyIndex(keyIndexName); + + return this; + } + + /// + /// Configures the full-text catalog for the full-text index. + /// + /// + /// See + /// SQL Server documentation for CREATE FULLTEXT INDEX + /// . + /// + /// The name of the full-text catalog. + /// A builder to further configure the full-text index. + public virtual SqlServerFullTextIndexBuilder OnCatalog(string catalogName) + { + Check.NotEmpty(catalogName); + + Metadata.SetFullTextCatalog(catalogName); + + return this; + } + + /// + /// Configures the change tracking mode for the full-text index. + /// + /// + /// See + /// SQL Server documentation for CREATE FULLTEXT INDEX + /// . + /// + /// The change tracking mode. + /// A builder to further configure the full-text index. + public virtual SqlServerFullTextIndexBuilder WithChangeTracking(FullTextChangeTracking changeTracking) + { + Metadata.SetFullTextChangeTracking(changeTracking); + + return this; + } + + /// + /// Configures the language for a specific property in the full-text index. + /// + /// + /// See + /// SQL Server documentation for CREATE FULLTEXT INDEX + /// . + /// + /// The name of the property. + /// The language term (e.g. "English", "1033"). + /// A builder to further configure the full-text index. + public virtual SqlServerFullTextIndexBuilder HasLanguage(string propertyName, string language) + { + Check.NotEmpty(propertyName); + Check.NotEmpty(language); + + Metadata.SetFullTextLanguage(propertyName, language); + + return this; + } +} diff --git a/src/EFCore.SqlServer/Metadata/Builders/SqlServerFullTextIndexBuilder`1.cs b/src/EFCore.SqlServer/Metadata/Builders/SqlServerFullTextIndexBuilder`1.cs new file mode 100644 index 00000000000..80b3456a785 --- /dev/null +++ b/src/EFCore.SqlServer/Metadata/Builders/SqlServerFullTextIndexBuilder`1.cs @@ -0,0 +1,120 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.Metadata.Builders; + +/// +/// Provides a simple API for configuring a full-text index on SQL Server. +/// +/// +/// +/// Instances of this class are returned from methods when using the API +/// and it is not designed to be directly constructed in your application code. +/// +/// +/// See Full-Text Search +/// for more information on SQL Server full-text search. +/// +/// +/// The entity type being configured. +/// The index builder. +public class SqlServerFullTextIndexBuilder(IndexBuilder indexBuilder) +{ + /// + /// The index being configured. + /// + public virtual IMutableIndex Metadata + => indexBuilder.Metadata; + + /// + /// Configures the name of the index in the database when targeting a relational database. + /// + /// + /// See Indexes for more information and examples. + /// + /// The name of the index. + /// A builder to further configure the index. + public virtual SqlServerFullTextIndexBuilder HasDatabaseName(string? name) + { + Metadata.SetDatabaseName(name); + + return this; + } + + /// + /// Configures the KEY INDEX for the full-text index. This is the unique, non-nullable, single-column index + /// used as the unique key for the full-text index. + /// + /// + /// See + /// SQL Server documentation for CREATE FULLTEXT INDEX + /// . + /// + /// The name of the KEY INDEX. + /// A builder to further configure the full-text index. + public virtual SqlServerFullTextIndexBuilder HasKeyIndex(string keyIndexName) + { + Check.NotEmpty(keyIndexName); + + Metadata.SetFullTextKeyIndex(keyIndexName); + + return this; + } + + /// + /// Configures the full-text catalog for the full-text index. + /// + /// + /// See + /// SQL Server documentation for CREATE FULLTEXT INDEX + /// . + /// + /// The name of the full-text catalog. + /// A builder to further configure the full-text index. + public virtual SqlServerFullTextIndexBuilder OnCatalog(string catalogName) + { + Check.NotEmpty(catalogName); + + Metadata.SetFullTextCatalog(catalogName); + + return this; + } + + /// + /// Configures the change tracking mode for the full-text index. + /// + /// + /// See + /// SQL Server documentation for CREATE FULLTEXT INDEX + /// . + /// + /// The change tracking mode. + /// A builder to further configure the full-text index. + public virtual SqlServerFullTextIndexBuilder WithChangeTracking(FullTextChangeTracking changeTracking) + { + Metadata.SetFullTextChangeTracking(changeTracking); + + return this; + } + + /// + /// Configures the language for a specific property in the full-text index. + /// + /// + /// See + /// SQL Server documentation for CREATE FULLTEXT INDEX + /// . + /// + /// The name of the property. + /// The language term (e.g. "English", "1033"). + /// A builder to further configure the full-text index. + public virtual SqlServerFullTextIndexBuilder HasLanguage(string propertyName, string language) + { + Check.NotEmpty(propertyName); + Check.NotEmpty(language); + + Metadata.SetFullTextLanguage(propertyName, language); + + return this; + } +} diff --git a/src/EFCore.SqlServer/Metadata/IConventionSqlServerFullTextCatalog.cs b/src/EFCore.SqlServer/Metadata/IConventionSqlServerFullTextCatalog.cs new file mode 100644 index 00000000000..fd8de40b9dc --- /dev/null +++ b/src/EFCore.SqlServer/Metadata/IConventionSqlServerFullTextCatalog.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.Metadata; + +/// +/// Represents a SQL Server full-text catalog in the model in a form that +/// can be mutated while building the model. +/// +/// +/// See Full-Text Search +/// for more information on SQL Server full-text search. +/// +public interface IConventionSqlServerFullTextCatalog : IReadOnlySqlServerFullTextCatalog, IConventionAnnotatable +{ + /// + /// Gets the in which this full-text catalog is defined. + /// + new IConventionModel Model { get; } + + /// + /// Gets the configuration source for this . + /// + /// The configuration source for . + ConfigurationSource GetConfigurationSource(); + + /// + /// Sets a value indicating whether this is the default full-text catalog for the database. + /// + /// Whether this is the default catalog. + /// Indicates whether the configuration was specified using a data annotation. + /// The configured value. + bool? SetIsDefault(bool? @default, bool fromDataAnnotation = false); + + /// + /// Returns the configuration source for . + /// + /// The configuration source for . + ConfigurationSource? GetIsDefaultConfigurationSource(); + + /// + /// Sets a value indicating whether the full-text catalog is accent-sensitive. + /// + /// Whether the catalog is accent-sensitive. + /// Indicates whether the configuration was specified using a data annotation. + /// The configured value. + bool? SetIsAccentSensitive(bool? accentSensitive, bool fromDataAnnotation = false); + + /// + /// Returns the configuration source for . + /// + /// The configuration source for . + ConfigurationSource? GetIsAccentSensitiveConfigurationSource(); +} diff --git a/src/EFCore.SqlServer/Metadata/IMutableSqlServerFullTextCatalog.cs b/src/EFCore.SqlServer/Metadata/IMutableSqlServerFullTextCatalog.cs new file mode 100644 index 00000000000..a118125daa0 --- /dev/null +++ b/src/EFCore.SqlServer/Metadata/IMutableSqlServerFullTextCatalog.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.Metadata; + +/// +/// Represents a SQL Server full-text catalog in the model that can be mutated directly. +/// +/// +/// See Full-Text Search +/// for more information on SQL Server full-text search. +/// +public interface IMutableSqlServerFullTextCatalog : IReadOnlySqlServerFullTextCatalog, IMutableAnnotatable +{ + /// + /// Gets the in which this full-text catalog is defined. + /// + new IMutableModel Model { get; } + + /// + /// Gets or sets a value indicating whether this is the default full-text catalog for the database. + /// + new bool IsDefault { get; set; } + + /// + /// Gets or sets a value indicating whether the full-text catalog is accent-sensitive. + /// + new bool IsAccentSensitive { get; set; } +} diff --git a/src/EFCore.SqlServer/Metadata/IReadOnlySqlServerFullTextCatalog.cs b/src/EFCore.SqlServer/Metadata/IReadOnlySqlServerFullTextCatalog.cs new file mode 100644 index 00000000000..b39b01c43e1 --- /dev/null +++ b/src/EFCore.SqlServer/Metadata/IReadOnlySqlServerFullTextCatalog.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.Metadata; + +/// +/// Represents a SQL Server full-text catalog in the model. +/// +/// +/// See Full-Text Search +/// for more information on SQL Server full-text search. +/// +public interface IReadOnlySqlServerFullTextCatalog : IReadOnlyAnnotatable +{ + /// + /// Gets the name of the full-text catalog. + /// + string Name { get; } + + /// + /// Gets the model in which this full-text catalog is defined. + /// + IReadOnlyModel Model { get; } + + /// + /// Gets a value indicating whether this is the default full-text catalog for the database. + /// + bool IsDefault { get; } + + /// + /// Gets a value indicating whether the full-text catalog is accent-sensitive. + /// + bool IsAccentSensitive { get; } +} diff --git a/src/EFCore.SqlServer/Metadata/ISqlServerFullTextCatalog.cs b/src/EFCore.SqlServer/Metadata/ISqlServerFullTextCatalog.cs new file mode 100644 index 00000000000..4fba6188d01 --- /dev/null +++ b/src/EFCore.SqlServer/Metadata/ISqlServerFullTextCatalog.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.Metadata; + +/// +/// Represents a SQL Server full-text catalog in the model. +/// +/// +/// See Full-Text Search +/// for more information on SQL Server full-text search. +/// +public interface ISqlServerFullTextCatalog : IReadOnlySqlServerFullTextCatalog, IAnnotatable +{ + /// + /// Gets the model in which this full-text catalog is defined. + /// + new IModel Model { get; } +} diff --git a/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationNames.cs b/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationNames.cs index 8c09b3c919f..fb932681f6f 100644 --- a/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationNames.cs +++ b/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationNames.cs @@ -314,4 +314,44 @@ public static class SqlServerAnnotationNames /// doing so can result in application failures when updating to a new Entity Framework Core release. /// public const string VectorIndexType = Prefix + "VectorIndexType"; + + /// + /// 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 FullTextLanguages = Prefix + "FullTextLanguages"; + + /// + /// 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 FullTextChangeTracking = Prefix + "FullTextChangeTracking"; + + /// + /// 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 FullTextCatalog = Prefix + "FullTextCatalog"; + + /// + /// 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 FullTextCatalogs = Prefix + "FullTextCatalogs"; } diff --git a/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs b/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs index 9b4d18a7d19..2e8784e2afd 100644 --- a/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs +++ b/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs @@ -13,17 +13,9 @@ namespace Microsoft.EntityFrameworkCore.SqlServer.Metadata.Internal; /// 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 SqlServerAnnotationProvider : RelationalAnnotationProvider +public class SqlServerAnnotationProvider(RelationalAnnotationProviderDependencies dependencies) + : RelationalAnnotationProvider(dependencies) { - /// - /// Initializes a new instance of this class. - /// - /// Parameter object containing dependencies for this service. - public SqlServerAnnotationProvider(RelationalAnnotationProviderDependencies dependencies) - : base(dependencies) - { - } - /// /// 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 @@ -76,6 +68,11 @@ public override IEnumerable For(IRelationalModel model, bool design { yield return new Annotation(SqlServerAnnotationNames.MemoryOptimized, true); } + + if (model.Model.FindAnnotation(SqlServerAnnotationNames.FullTextCatalogs) is IAnnotation annotation) + { + yield return annotation; + } } /// @@ -194,6 +191,41 @@ public override IEnumerable For(ITableIndex index, bool designTime) } #pragma warning restore EF9105 + if (modelIndex.GetFullTextKeyIndex(table) is { } keyIndex) + { + yield return new Annotation(SqlServerAnnotationNames.FullTextIndex, keyIndex); + + if (modelIndex.GetFullTextCatalog(table) is { } catalog) + { + yield return new Annotation(SqlServerAnnotationNames.FullTextCatalog, catalog); + } + + if (modelIndex.GetFullTextChangeTracking(table) is { } changeTracking) + { + yield return new Annotation(SqlServerAnnotationNames.FullTextChangeTracking, changeTracking); + } + + if (modelIndex.GetFullTextLanguages() is { } languages) + { + // Resolve property names to column names for SQL generation + var resolvedLanguages = new Dictionary(); + foreach (var (propertyName, language) in languages) + { + var columnName = modelIndex.DeclaringEntityType.FindProperty(propertyName)! + .GetColumnName(table); + if (columnName != null) + { + resolvedLanguages[columnName] = language; + } + } + + if (resolvedLanguages.Count > 0) + { + yield return new Annotation(SqlServerAnnotationNames.FullTextLanguages, resolvedLanguages); + } + } + } + if (modelIndex.IsClustered(table) is { } isClustered) { yield return new Annotation(SqlServerAnnotationNames.Clustered, isClustered); diff --git a/src/EFCore.SqlServer/Metadata/Internal/SqlServerFullTextCatalog.cs b/src/EFCore.SqlServer/Metadata/Internal/SqlServerFullTextCatalog.cs new file mode 100644 index 00000000000..4fbffa7b707 --- /dev/null +++ b/src/EFCore.SqlServer/Metadata/Internal/SqlServerFullTextCatalog.cs @@ -0,0 +1,274 @@ +// 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.Infrastructure; +using Microsoft.EntityFrameworkCore.SqlServer.Metadata.Internal; + +namespace Microsoft.EntityFrameworkCore.SqlServer.Metadata.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 SqlServerFullTextCatalog( + string name, + IReadOnlyModel model, + ConfigurationSource configurationSource) + : ConventionAnnotatable, IMutableSqlServerFullTextCatalog, IConventionSqlServerFullTextCatalog, ISqlServerFullTextCatalog +{ + private bool? _isDefault; + private bool? _isAccentSensitive; + + private ConfigurationSource _configurationSource = configurationSource; + private ConfigurationSource? _isDefaultConfigurationSource; + private ConfigurationSource? _isAccentSensitiveConfigurationSource; + + + /// + /// 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 static IEnumerable GetFullTextCatalogs(IReadOnlyAnnotatable model) + => ((Dictionary?)model[SqlServerAnnotationNames.FullTextCatalogs]) + ?.OrderBy(t => t.Key).Select(t => t.Value) + ?? []; + + /// + /// 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 static SqlServerFullTextCatalog? FindFullTextCatalog(IReadOnlyAnnotatable model, string name) + { + var catalogs = (Dictionary?)model[SqlServerAnnotationNames.FullTextCatalogs]; + + return catalogs == null || !catalogs.TryGetValue(name, out var catalog) + ? null + : catalog; + } + + /// + /// 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 static SqlServerFullTextCatalog AddFullTextCatalog( + IMutableModel model, + string name, + ConfigurationSource configurationSource) + { + var catalog = new SqlServerFullTextCatalog(name, model, configurationSource); + var catalogs = (Dictionary?)model[SqlServerAnnotationNames.FullTextCatalogs]; + if (catalogs == null) + { + catalogs = []; + model[SqlServerAnnotationNames.FullTextCatalogs] = catalogs; + } + + catalogs.Add(name, catalog); + return catalog; + } + + /// + /// 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 static SqlServerFullTextCatalog? RemoveFullTextCatalog(IMutableModel model, string name) + { + var catalogs = (Dictionary?)model[SqlServerAnnotationNames.FullTextCatalogs]; + if (catalogs == null || !catalogs.TryGetValue(name, out var catalog)) + { + return null; + } + + catalogs.Remove(name); + return catalog; + } + + /// + /// 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 IReadOnlyModel Model { get; } = model; + + /// + /// 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 override bool IsReadOnly + => Model is Annotatable annotatable && annotatable.IsReadOnly; + + /// + /// 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 string Name { get; } = name; + + /// + /// 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 ConfigurationSource GetConfigurationSource() + => _configurationSource; + + /// + /// 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 void UpdateConfigurationSource(ConfigurationSource configurationSource) + => _configurationSource = _configurationSource.Max(configurationSource); + + /// + /// 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 IsDefault + { + get => _isDefault ?? false; + set => SetIsDefault(value, ConfigurationSource.Explicit); + } + + /// + /// 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? SetIsDefault(bool? isDefault, ConfigurationSource configurationSource) + { + EnsureMutable(); + + _isDefault = isDefault; + + _isDefaultConfigurationSource = isDefault == null + ? null + : configurationSource.Max(_isDefaultConfigurationSource); + + return isDefault; + } + + /// + /// 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 ConfigurationSource? GetIsDefaultConfigurationSource() + => _isDefaultConfigurationSource; + + /// + /// 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 IsAccentSensitive + { + get => _isAccentSensitive ?? true; + set => SetIsAccentSensitive(value, ConfigurationSource.Explicit); + } + + /// + /// 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? SetIsAccentSensitive(bool? isAccentSensitive, ConfigurationSource configurationSource) + { + EnsureMutable(); + + _isAccentSensitive = isAccentSensitive; + + _isAccentSensitiveConfigurationSource = isAccentSensitive == null + ? null + : configurationSource.Max(_isAccentSensitiveConfigurationSource); + + return isAccentSensitive; + } + + /// + /// 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 ConfigurationSource? GetIsAccentSensitiveConfigurationSource() + => _isAccentSensitiveConfigurationSource; + + /// + /// 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. + /// + IMutableModel IMutableSqlServerFullTextCatalog.Model + { + [DebuggerStepThrough] + get => (IMutableModel)Model; + } + + /// + /// 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. + /// + IConventionModel IConventionSqlServerFullTextCatalog.Model + { + [DebuggerStepThrough] + get => (IConventionModel)Model; + } + + /// + /// 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. + /// + IModel ISqlServerFullTextCatalog.Model + { + [DebuggerStepThrough] + get => (IModel)Model; + } + + /// + /// 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. + /// + [DebuggerStepThrough] + bool? IConventionSqlServerFullTextCatalog.SetIsDefault(bool? isDefault, bool fromDataAnnotation) + => SetIsDefault(isDefault, fromDataAnnotation ? ConfigurationSource.DataAnnotation : ConfigurationSource.Convention); + + /// + /// 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. + /// + [DebuggerStepThrough] + bool? IConventionSqlServerFullTextCatalog.SetIsAccentSensitive(bool? isAccentSensitive, bool fromDataAnnotation) + => SetIsAccentSensitive(isAccentSensitive, fromDataAnnotation ? ConfigurationSource.DataAnnotation : ConfigurationSource.Convention); +} diff --git a/src/EFCore.SqlServer/Migrations/Internal/SqlServerMigrationsAnnotationProvider.cs b/src/EFCore.SqlServer/Migrations/Internal/SqlServerMigrationsAnnotationProvider.cs index 8aae2c65c39..03bd3df3fd1 100644 --- a/src/EFCore.SqlServer/Migrations/Internal/SqlServerMigrationsAnnotationProvider.cs +++ b/src/EFCore.SqlServer/Migrations/Internal/SqlServerMigrationsAnnotationProvider.cs @@ -54,6 +54,30 @@ public override IEnumerable ForRemove(IColumn column) } } + /// + public override IEnumerable ForRemove(ITableIndex index) + { + if (index[SqlServerAnnotationNames.FullTextIndex] is string keyIndex) + { + yield return new Annotation(SqlServerAnnotationNames.FullTextIndex, keyIndex); + + if (index[SqlServerAnnotationNames.FullTextCatalog] is string catalog) + { + yield return new Annotation(SqlServerAnnotationNames.FullTextCatalog, catalog); + } + + if (index[SqlServerAnnotationNames.FullTextChangeTracking] is FullTextChangeTracking changeTracking) + { + yield return new Annotation(SqlServerAnnotationNames.FullTextChangeTracking, changeTracking); + } + + if (index.FindAnnotation(SqlServerAnnotationNames.FullTextLanguages) is IAnnotation languagesAnnotation) + { + yield return languagesAnnotation; + } + } + } + /// public override IEnumerable ForRename(ITable table) { diff --git a/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs b/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs index 50973cf48eb..5397181e7a8 100644 --- a/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs +++ b/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs @@ -831,25 +831,15 @@ protected override void Generate( MigrationCommandListBuilder builder, bool terminate = true) { - if (operation[SqlServerAnnotationNames.VectorIndexMetric] is string vectorMetric) + if (operation[SqlServerAnnotationNames.FullTextIndex] is string keyIndex) { - builder.Append("CREATE VECTOR INDEX ") - .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name)) - .Append(" ON ") - .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table, operation.Schema)) - .Append("("); - GenerateIndexColumnList(operation, model, builder); - builder.Append(")"); - - IndexOptions(operation, model, builder); - - if (terminate) - { - builder - .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator) - .EndCommand(suppressTransaction: true); - } + GenerateFullTextIndex(keyIndex); + return; + } + if (operation[SqlServerAnnotationNames.VectorIndexMetric] is string) + { + GenerateVectorIndex(); return; } @@ -909,6 +899,79 @@ protected override void Generate( .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator) .EndCommand(suppressTransaction: memoryOptimized); } + + void GenerateFullTextIndex(string keyIndex) + { + builder.Append("CREATE FULLTEXT INDEX ON ") + .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table, operation.Schema)) + .Append("("); + + var languages = (Dictionary?)operation.FindAnnotation(SqlServerAnnotationNames.FullTextLanguages)?.Value; + + for (var i = 0; i < operation.Columns.Length; i++) + { + if (i > 0) + { + builder.Append(", "); + } + + builder.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Columns[i])); + + if (languages is not null && languages.TryGetValue(operation.Columns[i], out var language)) + { + builder.Append(" LANGUAGE ").Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(language)); + } + } + + builder.Append(") KEY INDEX ") + .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(keyIndex)); + + if (operation[SqlServerAnnotationNames.FullTextCatalog] is string catalog) + { + builder.Append(" ON ").Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(catalog)); + } + + if (operation[SqlServerAnnotationNames.FullTextChangeTracking] is FullTextChangeTracking changeTracking) + { + builder.Append(" WITH CHANGE_TRACKING = "); + builder.Append(changeTracking switch + { + FullTextChangeTracking.Auto => "AUTO", + FullTextChangeTracking.Manual => "MANUAL", + FullTextChangeTracking.Off => "OFF", + FullTextChangeTracking.OffNoPopulation => "OFF, NO POPULATION", + + _ => throw new UnreachableException(), + }); + } + + if (terminate) + { + builder + .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator) + .EndCommand(suppressTransaction: true); + } + } + + void GenerateVectorIndex() + { + builder.Append("CREATE VECTOR INDEX ") + .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name)) + .Append(" ON ") + .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table, operation.Schema)) + .Append("("); + GenerateIndexColumnList(operation, model, builder); + builder.Append(")"); + + IndexOptions(operation, model, builder); + + if (terminate) + { + builder + .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator) + .EndCommand(suppressTransaction: true); + } + } } /// @@ -1170,6 +1233,8 @@ protected override void Generate( .AppendLine(); } + GenerateFullTextCatalogStatements(operation, builder); + if (!IsMemoryOptimized(operation)) { builder.EndCommand(suppressTransaction: true); @@ -1255,6 +1320,79 @@ protected override void Generate( builder.EndCommand(suppressTransaction: true); } + private void GenerateFullTextCatalogStatements( + AlterDatabaseOperation operation, + MigrationCommandListBuilder builder) + { + var oldCatalogs = SqlServerFullTextCatalog.GetFullTextCatalogs(operation.OldDatabase).ToDictionary(c => c.Name, c => c); + var newCatalogs = SqlServerFullTextCatalog.GetFullTextCatalogs(operation).ToDictionary(c => c.Name, c => c); + + // Drop removed catalogs + foreach (var (name, _) in oldCatalogs) + { + if (!newCatalogs.ContainsKey(name)) + { + builder + .Append("DROP FULLTEXT CATALOG ") + .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(name)) + .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator) + .AppendLine(); + } + } + + // Create added catalogs + foreach (var (name, catalog) in newCatalogs) + { + if (!oldCatalogs.ContainsKey(name)) + { + builder.Append("CREATE FULLTEXT CATALOG ") + .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(name)); + + if (!catalog.IsAccentSensitive) + { + builder.Append(" WITH ACCENT_SENSITIVITY = OFF"); + } + + if (catalog.IsDefault) + { + builder.Append(" AS DEFAULT"); + } + + builder + .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator) + .AppendLine(); + } + } + + // Alter changed catalogs + foreach (var (name, catalog) in newCatalogs) + { + if (oldCatalogs.TryGetValue(name, out var oldProps)) + { + if (oldProps.IsAccentSensitive != catalog.IsAccentSensitive) + { + builder + .Append("ALTER FULLTEXT CATALOG ") + .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(name)) + .Append(" REBUILD WITH ACCENT_SENSITIVITY = ") + .Append(catalog.IsAccentSensitive ? "ON" : "OFF") + .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator) + .AppendLine(); + } + + if (!oldProps.IsDefault && catalog.IsDefault) + { + builder + .Append("ALTER FULLTEXT CATALOG ") + .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(name)) + .Append(" AS DEFAULT") + .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator) + .AppendLine(); + } + } + } + } + /// /// Builds commands for the given /// by making calls on the given . @@ -1335,6 +1473,22 @@ protected override void Generate( throw new InvalidOperationException(SqlServerStrings.IndexTableRequired); } + if (operation[SqlServerAnnotationNames.FullTextIndex] is string) + { + builder + .Append("DROP FULLTEXT INDEX ON ") + .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table!, operation.Schema)); + + if (terminate) + { + builder + .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator) + .EndCommand(suppressTransaction: true); + } + + return; + } + var memoryOptimized = IsMemoryOptimized(operation, model, operation.Schema, operation.Table); if (memoryOptimized) { diff --git a/src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs b/src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs index 319114f6269..d28336f91f4 100644 --- a/src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs +++ b/src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs @@ -161,6 +161,44 @@ public static string DuplicateKeyMismatchedClustering(object? key1, object? enti public static string ExecuteUpdateCannotSetJsonPropertyOnOldSqlServer => GetString("ExecuteUpdateCannotSetJsonPropertyOnOldSqlServer"); + /// + /// Full-text index '{index}' on entity type '{entityType}' includes property '{property}' which is not mapped to a text or varbinary column type supported by full-text search. + /// + public static string FullTextIndexOnInvalidColumn(object? index, object? entityType, object? property) + => string.Format( + GetString("FullTextIndexOnInvalidColumn", nameof(index), nameof(entityType), nameof(property)), + index, entityType, property); + + /// + /// Full-text index '{index}' on entity type '{entityType}' does not have a KEY INDEX configured. SQL Server requires a KEY INDEX for every full-text index. Use 'HasKeyIndex' to configure the KEY INDEX. + /// + public static string FullTextIndexMissingKeyIndex(object? index, object? entityType) + => string.Format( + GetString("FullTextIndexMissingKeyIndex", nameof(index), nameof(entityType)), + index, entityType); + + /// + /// Entity type '{entityType}' has multiple full-text indexes configured. SQL Server supports only one full-text index per table. + /// + public static string FullTextIndexDuplicateOnTable(object? entityType) + => string.Format( + GetString("FullTextIndexDuplicateOnTable", nameof(entityType)), + entityType); + + /// + /// Full-text index '{index}' on entity type '{entityType}' specifies a language for property '{property}', but that property is not part of the index. + /// + public static string FullTextIndexLanguagePropertyNotInIndex(object? index, object? entityType, object? property) + => string.Format( + GetString("FullTextIndexLanguagePropertyNotInIndex", nameof(index), nameof(entityType), nameof(property)), + index, entityType, property); + + /// + /// Multiple full-text catalogs are marked as default. Only one full-text catalog can be the default. + /// + public static string FullTextMultipleDefaultCatalogs + => GetString("FullTextMultipleDefaultCatalogs"); + /// /// Identity value generation cannot be used for the property '{property}' on entity type '{entityType}' because the property type is '{propertyType}'. Identity value generation can only be used with signed integer properties. /// diff --git a/src/EFCore.SqlServer/Properties/SqlServerStrings.resx b/src/EFCore.SqlServer/Properties/SqlServerStrings.resx index cc58af89273..6e76cd344ad 100644 --- a/src/EFCore.SqlServer/Properties/SqlServerStrings.resx +++ b/src/EFCore.SqlServer/Properties/SqlServerStrings.resx @@ -1,17 +1,17 @@  - @@ -171,6 +171,21 @@ 'ExecuteUpdate' cannot set a property in a JSON column to an expression containing a column on SQL Server versions before 2022. If you're on SQL Server 2022 and above, your compatibility level may be set to a lower value; consider raising it. + + Full-text index '{index}' on entity type '{entityType}' includes property '{property}' which is not mapped to a text or varbinary column type supported by full-text search. + + + Full-text index '{index}' on entity type '{entityType}' does not have a KEY INDEX configured. SQL Server requires a KEY INDEX for every full-text index. Use 'HasKeyIndex' to configure the KEY INDEX. + + + Entity type '{entityType}' has multiple full-text indexes configured. SQL Server supports only one full-text index per table. + + + Full-text index '{index}' on entity type '{entityType}' specifies a language for property '{property}', but that property is not part of the index. + + + Multiple full-text catalogs are marked as default. Only one full-text catalog can be the default. + Identity value generation cannot be used for the property '{property}' on entity type '{entityType}' because the property type is '{propertyType}'. Identity value generation can only be used with signed integer properties. diff --git a/src/EFCore.SqlServer/Scaffolding/Internal/SqlServerDatabaseModelFactory.cs b/src/EFCore.SqlServer/Scaffolding/Internal/SqlServerDatabaseModelFactory.cs index 4c1025f29ee..c741b1fa2d3 100644 --- a/src/EFCore.SqlServer/Scaffolding/Internal/SqlServerDatabaseModelFactory.cs +++ b/src/EFCore.SqlServer/Scaffolding/Internal/SqlServerDatabaseModelFactory.cs @@ -104,6 +104,11 @@ public override DatabaseModel Create(DbConnection connection, DatabaseModelFacto GetTables(connection, databaseModel, tableFilter, typeAliases, databaseCollation); + if (SupportsFullTextSearch) + { + GetFullTextCatalogs(connection, databaseModel); + } + foreach (var schema in schemaList .Except( databaseModel.Sequences.Select(s => s.Schema) @@ -655,6 +660,11 @@ FROM [sys].[views] AS [v] GetIndexes(connection, tables, tableFilterSql); } + if (SupportsFullTextSearch) + { + GetFullTextIndexes(connection, tables, tableFilterSql); + } + GetForeignKeys(connection, tables, tableFilterSql); if (SupportsTriggers) @@ -1001,10 +1011,10 @@ private void GetIndexes(DbConnection connection, IReadOnlyList ta using var command = connection.CreateCommand(); var commandText = $""" WITH [TablesAndViews] AS ( - SELECT [object_id], [schema_id], [name], [type], [is_ms_shipped], [temporal_type] + SELECT [object_id], [schema_id], [name], [type], [is_ms_shipped], [temporal_type] FROM [sys].[tables] UNION ALL - SELECT [object_id], [schema_id], [name], [type], [is_ms_shipped], 0 AS [temporal_type] + SELECT [object_id], [schema_id], [name], [type], [is_ms_shipped], 0 AS [temporal_type] FROM [sys].[views] ) SELECT @@ -1225,6 +1235,146 @@ void ProcessRegularIndex() } } + private void GetFullTextCatalogs(DbConnection connection, DatabaseModel databaseModel) + { + using var command = connection.CreateCommand(); + command.CommandText = """ +SELECT [name], [is_default], [is_accent_sensitivity_on] +FROM [sys].[fulltext_catalogs]; +"""; + + using var reader = command.ExecuteReader(); + var catalogs = new Dictionary(); + + while (reader.Read()) + { + var name = reader.GetFieldValue("name"); + var isDefault = reader.GetFieldValue("is_default"); + var isAccentSensitive = reader.GetFieldValue("is_accent_sensitivity_on"); + + var catalog = new SqlServerFullTextCatalog(name, model: null!, ConfigurationSource.DataAnnotation); + if (isDefault) + { + catalog.IsDefault = true; + } + + if (!isAccentSensitive) + { + catalog.IsAccentSensitive = false; + } + + catalogs.Add(name, catalog); + } + + if (catalogs.Count > 0) + { + databaseModel[SqlServerAnnotationNames.FullTextCatalogs] = catalogs; + } + } + + private void GetFullTextIndexes(DbConnection connection, IReadOnlyList tables, string tableFilter) + { + using var command = connection.CreateCommand(); + command.CommandText = $""" +SELECT + SCHEMA_NAME([t].[schema_id]) AS [table_schema], + [t].[name] AS [table_name], + [i].[name] AS [key_index_name], + [fc].[name] AS [catalog_name], + [fi].[change_tracking_state_desc] AS [change_tracking], + COL_NAME([fic].[object_id], [fic].[column_id]) AS [column_name], + [l].[name] AS [language_name] +FROM [sys].[fulltext_indexes] AS [fi] +JOIN [sys].[tables] AS [t] ON [fi].[object_id] = [t].[object_id] +JOIN [sys].[indexes] AS [i] ON [fi].[object_id] = [i].[object_id] AND [fi].[unique_index_id] = [i].[index_id] +JOIN [sys].[fulltext_index_columns] AS [fic] ON [fi].[object_id] = [fic].[object_id] +LEFT JOIN [sys].[fulltext_catalogs] AS [fc] ON [fi].[fulltext_catalog_id] = [fc].[fulltext_catalog_id] +LEFT JOIN [sys].[fulltext_languages] AS [l] ON [fic].[language_id] = [l].[lcid] +WHERE {tableFilter} +ORDER BY [table_schema], [table_name], [fic].[column_id]; +"""; + + using var reader = command.ExecuteReader(); + var tableGroups = reader.Cast() + .GroupBy(r => ( + tableSchema: r.GetValueOrDefault("table_schema"), + tableName: r.GetFieldValue("table_name"))) + .ToDictionary(g => g.Key, g => g.ToList()); + + foreach (var ((tableSchema, tableName), records) in tableGroups) + { + var table = tables.SingleOrDefault(t => t.Schema == tableSchema && t.Name == tableName); + if (table is null) + { + continue; + } + + var firstRecord = records[0]; + var keyIndexName = firstRecord.GetFieldValue("key_index_name"); + var catalogName = firstRecord.GetValueOrDefault("catalog_name"); + var changeTrackingDesc = firstRecord.GetValueOrDefault("change_tracking"); + + var index = new DatabaseIndex + { + Table = table, + Name = null, + IsUnique = false + }; + + index[SqlServerAnnotationNames.FullTextIndex] = keyIndexName; + + if (catalogName is not null) + { + index[SqlServerAnnotationNames.FullTextCatalog] = catalogName; + } + + var changeTracking = changeTrackingDesc switch + { + "MANUAL" => FullTextChangeTracking.Manual, + "OFF" => FullTextChangeTracking.Off, + // AUTO is the default, don't scaffold it + _ => (FullTextChangeTracking?)null + }; + + if (changeTracking is not null) + { + index[SqlServerAnnotationNames.FullTextChangeTracking] = changeTracking.Value; + } + + var languages = new Dictionary(); + + foreach (var record in records) + { + var columnName = record.GetValueOrDefault("column_name"); + var column = table.Columns.FirstOrDefault(c => c.Name == columnName) + ?? table.Columns.FirstOrDefault(c => c.Name!.Equals(columnName, StringComparison.OrdinalIgnoreCase)); + + if (column is null) + { + continue; + } + + index.Columns.Add(column); + + var languageName = record.GetValueOrDefault("language_name"); + if (languageName is not null) + { + languages[column.Name!] = languageName; + } + } + + if (languages.Count > 0) + { + index[SqlServerAnnotationNames.FullTextLanguages] = languages; + } + + if (index.Columns.Count > 0) + { + table.Indexes.Add(index); + } + } + } + private void GetForeignKeys(DbConnection connection, IReadOnlyList tables, string tableFilter) { using var command = connection.CreateCommand(); @@ -1420,6 +1570,9 @@ private bool SupportsSequences private bool SupportsIndexes => _engineEdition != EngineEdition.DynamicsCrm; + private bool SupportsFullTextSearch + => IsFullFeaturedEngineEdition; + private bool SupportsVectorIndexes => _compatibilityLevel >= 170 && IsFullFeaturedEngineEdition; diff --git a/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs index a51f3ed207c..8f8dd721554 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs @@ -2830,6 +2830,564 @@ await Test( AssertSql("DROP INDEX [IX_VectorEntities_Vector] ON [VectorEntities];"); } + #region Full-text search + + [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFullTextSearch)] + public virtual async Task Create_full_text_index() + { + await Test( + builder => + { + builder.HasFullTextCatalog("TestCatalog"); + + builder.Entity( + "FullTextEntities", e => + { + e.Property("Id"); + e.Property("Title").HasMaxLength(450); + e.HasKey("Id").HasName("PK_FullTextEntities"); + }); + }, + builder => { }, + builder => builder.Entity("FullTextEntities") + .HasFullTextIndex("Title") + .HasKeyIndex("PK_FullTextEntities") + .OnCatalog("TestCatalog"), + model => + { + var table = Assert.Single(model.Tables); + var index = Assert.Single(table.Indexes); + var indexColumn = Assert.Single(index.Columns); + + Assert.Same(table.Columns.Single(c => c.Name == "Title"), indexColumn); + Assert.NotNull(index[SqlServerAnnotationNames.FullTextIndex]); + }); + + AssertSql( + """ +CREATE FULLTEXT INDEX ON [FullTextEntities]([Title]) KEY INDEX [PK_FullTextEntities] ON [TestCatalog]; +"""); + } + + [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFullTextSearch)] + public virtual async Task Create_full_text_index_with_all_options() + { + await Test( + builder => + { + builder.HasFullTextCatalog("TestCatalog"); + + builder.Entity( + "FullTextEntities", e => + { + e.Property("Id"); + e.Property("Title").HasMaxLength(450); + e.Property("Body").HasMaxLength(450); + e.HasKey("Id").HasName("PK_FullTextEntities"); + }); + }, + builder => { }, + builder => builder.Entity("FullTextEntities") + .HasFullTextIndex("Title", "Body") + .HasKeyIndex("PK_FullTextEntities") + .OnCatalog("TestCatalog") + .WithChangeTracking(FullTextChangeTracking.Manual) + .HasLanguage("Title", "English") + .HasLanguage("Body", "French"), + model => + { + var table = Assert.Single(model.Tables); + var index = Assert.Single(table.Indexes); + + Assert.Equal(2, index.Columns.Count); + Assert.NotNull(index[SqlServerAnnotationNames.FullTextIndex]); + Assert.Equal("TestCatalog", index[SqlServerAnnotationNames.FullTextCatalog]); + Assert.Equal(FullTextChangeTracking.Manual, index[SqlServerAnnotationNames.FullTextChangeTracking]); + }); + + AssertSql( + """ +CREATE FULLTEXT INDEX ON [FullTextEntities]([Title] LANGUAGE [English], [Body] LANGUAGE [French]) KEY INDEX [PK_FullTextEntities] ON [TestCatalog] WITH CHANGE_TRACKING = MANUAL; +"""); + } + + [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFullTextSearch)] + public virtual async Task Create_full_text_index_with_change_tracking_off() + { + await Test( + builder => + { + builder.HasFullTextCatalog("TestCatalog"); + + builder.Entity( + "FullTextEntities", e => + { + e.Property("Id"); + e.Property("Title").HasMaxLength(450); + e.HasKey("Id").HasName("PK_FullTextEntities"); + }); + }, + builder => { }, + builder => builder.Entity("FullTextEntities") + .HasFullTextIndex("Title") + .HasKeyIndex("PK_FullTextEntities") + .OnCatalog("TestCatalog") + .WithChangeTracking(FullTextChangeTracking.Off), + model => + { + var table = Assert.Single(model.Tables); + var index = Assert.Single(table.Indexes); + + Assert.NotNull(index[SqlServerAnnotationNames.FullTextIndex]); + Assert.Equal(FullTextChangeTracking.Off, index[SqlServerAnnotationNames.FullTextChangeTracking]); + }); + + AssertSql( + """ +CREATE FULLTEXT INDEX ON [FullTextEntities]([Title]) KEY INDEX [PK_FullTextEntities] ON [TestCatalog] WITH CHANGE_TRACKING = OFF; +"""); + } + + [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFullTextSearch)] + public virtual async Task Drop_full_text_index() + { + await Test( + builder => + { + builder.HasFullTextCatalog("TestCatalog"); + + builder.Entity( + "FullTextEntities", e => + { + e.Property("Id"); + e.Property("Title").HasMaxLength(450); + e.HasKey("Id").HasName("PK_FullTextEntities"); + }); + }, + builder => builder.Entity("FullTextEntities") + .HasFullTextIndex("Title") + .HasKeyIndex("PK_FullTextEntities") + .OnCatalog("TestCatalog"), + builder => { }, + model => + { + var table = Assert.Single(model.Tables); + Assert.Empty(table.Indexes); + }); + + AssertSql( + """ +DROP FULLTEXT INDEX ON [FullTextEntities]; +"""); + } + + [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFullTextSearch)] + public virtual async Task Create_full_text_catalog() + { + await Test( + builder => { }, + builder => builder.HasFullTextCatalog("MyCatalog"), + model => { }); + + AssertSql( + """ +CREATE FULLTEXT CATALOG [MyCatalog]; +"""); + } + + [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFullTextSearch)] + public virtual async Task Create_full_text_catalog_as_default_accent_insensitive() + { + await Test( + builder => { }, + builder => builder.HasFullTextCatalog("MyCatalog").IsDefault().IsAccentSensitive(false), + model => { }); + + AssertSql( + """ +CREATE FULLTEXT CATALOG [MyCatalog] WITH ACCENT_SENSITIVITY = OFF AS DEFAULT; +"""); + } + + [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFullTextSearch)] + public virtual async Task Drop_full_text_catalog() + { + await Test( + builder => builder.HasFullTextCatalog("MyCatalog"), + builder => { }, + model => { }); + + AssertSql( + """ +DROP FULLTEXT CATALOG [MyCatalog]; +"""); + } + + [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFullTextSearch)] + public virtual async Task Alter_full_text_catalog_accent_sensitivity() + { + await Test( + builder => builder.HasFullTextCatalog("MyCatalog"), + builder => builder.HasFullTextCatalog("MyCatalog").IsAccentSensitive(false), + model => { }); + + AssertSql( + """ +ALTER FULLTEXT CATALOG [MyCatalog] REBUILD WITH ACCENT_SENSITIVITY = OFF; +"""); + } + + [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFullTextSearch)] + public virtual async Task Alter_full_text_catalog_set_as_default() + { + await Test( + builder => builder.HasFullTextCatalog("MyCatalog"), + builder => builder.HasFullTextCatalog("MyCatalog").IsDefault(), + model => { }); + + AssertSql( + """ +ALTER FULLTEXT CATALOG [MyCatalog] AS DEFAULT; +"""); + } + + [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFullTextSearch)] + public virtual async Task Create_full_text_index_with_change_tracking_off_no_population() + { + await Test( + builder => + { + builder.HasFullTextCatalog("TestCatalog"); + + builder.Entity( + "FullTextEntities", e => + { + e.Property("Id"); + e.Property("Title").HasMaxLength(450); + e.HasKey("Id").HasName("PK_FullTextEntities"); + }); + }, + builder => { }, + builder => builder.Entity("FullTextEntities") + .HasFullTextIndex("Title") + .HasKeyIndex("PK_FullTextEntities") + .OnCatalog("TestCatalog") + .WithChangeTracking(FullTextChangeTracking.OffNoPopulation), + model => + { + var table = Assert.Single(model.Tables); + var index = Assert.Single(table.Indexes); + + Assert.NotNull(index[SqlServerAnnotationNames.FullTextIndex]); + // NO POPULATION is a creation-only option; scaffolding reads back as Off + Assert.Equal(FullTextChangeTracking.Off, index[SqlServerAnnotationNames.FullTextChangeTracking]); + }); + + AssertSql( + """ +CREATE FULLTEXT INDEX ON [FullTextEntities]([Title]) KEY INDEX [PK_FullTextEntities] ON [TestCatalog] WITH CHANGE_TRACKING = OFF, NO POPULATION; +"""); + } + + [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFullTextSearch)] + public virtual async Task Add_column_to_full_text_index() + { + await Test( + builder => + { + builder.HasFullTextCatalog("TestCatalog"); + + builder.Entity( + "FullTextEntities", e => + { + e.Property("Id"); + e.Property("Title").HasMaxLength(450); + e.Property("Body").HasMaxLength(450); + e.HasKey("Id").HasName("PK_FullTextEntities"); + }); + }, + builder => builder.Entity("FullTextEntities") + .HasFullTextIndex("Title") + .HasKeyIndex("PK_FullTextEntities") + .OnCatalog("TestCatalog"), + builder => builder.Entity("FullTextEntities") + .HasFullTextIndex("Title", "Body") + .HasKeyIndex("PK_FullTextEntities") + .OnCatalog("TestCatalog"), + model => + { + var table = Assert.Single(model.Tables); + var index = Assert.Single(table.Indexes); + + Assert.Equal(2, index.Columns.Count); + Assert.NotNull(index[SqlServerAnnotationNames.FullTextIndex]); + }); + + AssertSql( + """ +DROP FULLTEXT INDEX ON [FullTextEntities]; +""", + // + """ +CREATE FULLTEXT INDEX ON [FullTextEntities]([Title], [Body]) KEY INDEX [PK_FullTextEntities] ON [TestCatalog]; +"""); + } + + [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFullTextSearch)] + public virtual async Task Remove_column_from_full_text_index() + { + await Test( + builder => + { + builder.HasFullTextCatalog("TestCatalog"); + + builder.Entity( + "FullTextEntities", e => + { + e.Property("Id"); + e.Property("Title").HasMaxLength(450); + e.Property("Body").HasMaxLength(450); + e.HasKey("Id").HasName("PK_FullTextEntities"); + }); + }, + builder => builder.Entity("FullTextEntities") + .HasFullTextIndex("Title", "Body") + .HasKeyIndex("PK_FullTextEntities") + .OnCatalog("TestCatalog"), + builder => builder.Entity("FullTextEntities") + .HasFullTextIndex("Title") + .HasKeyIndex("PK_FullTextEntities") + .OnCatalog("TestCatalog"), + model => + { + var table = Assert.Single(model.Tables); + var index = Assert.Single(table.Indexes); + + Assert.Single(index.Columns); + Assert.NotNull(index[SqlServerAnnotationNames.FullTextIndex]); + }); + + AssertSql( + """ +DROP FULLTEXT INDEX ON [FullTextEntities]; +""", + // + """ +CREATE FULLTEXT INDEX ON [FullTextEntities]([Title]) KEY INDEX [PK_FullTextEntities] ON [TestCatalog]; +"""); + } + + [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFullTextSearch)] + public virtual async Task Change_full_text_index_change_tracking_mode() + { + await Test( + builder => + { + builder.HasFullTextCatalog("TestCatalog"); + + builder.Entity( + "FullTextEntities", e => + { + e.Property("Id"); + e.Property("Title").HasMaxLength(450); + e.HasKey("Id").HasName("PK_FullTextEntities"); + }); + }, + builder => builder.Entity("FullTextEntities") + .HasFullTextIndex("Title") + .HasKeyIndex("PK_FullTextEntities") + .OnCatalog("TestCatalog") + .WithChangeTracking(FullTextChangeTracking.Auto), + builder => builder.Entity("FullTextEntities") + .HasFullTextIndex("Title") + .HasKeyIndex("PK_FullTextEntities") + .OnCatalog("TestCatalog") + .WithChangeTracking(FullTextChangeTracking.Manual), + model => + { + var table = Assert.Single(model.Tables); + var index = Assert.Single(table.Indexes); + + Assert.NotNull(index[SqlServerAnnotationNames.FullTextIndex]); + Assert.Equal(FullTextChangeTracking.Manual, index[SqlServerAnnotationNames.FullTextChangeTracking]); + }); + + AssertSql( + """ +DROP FULLTEXT INDEX ON [FullTextEntities]; +""", + // + """ +CREATE FULLTEXT INDEX ON [FullTextEntities]([Title]) KEY INDEX [PK_FullTextEntities] ON [TestCatalog] WITH CHANGE_TRACKING = MANUAL; +"""); + } + + [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFullTextSearch)] + public virtual async Task Change_full_text_index_catalog() + { + await Test( + builder => + { + builder.HasFullTextCatalog("CatalogA"); + builder.HasFullTextCatalog("CatalogB"); + + builder.Entity( + "FullTextEntities", e => + { + e.Property("Id"); + e.Property("Title").HasMaxLength(450); + e.HasKey("Id").HasName("PK_FullTextEntities"); + }); + }, + builder => builder.Entity("FullTextEntities") + .HasFullTextIndex("Title") + .HasKeyIndex("PK_FullTextEntities") + .OnCatalog("CatalogA"), + builder => builder.Entity("FullTextEntities") + .HasFullTextIndex("Title") + .HasKeyIndex("PK_FullTextEntities") + .OnCatalog("CatalogB"), + model => + { + var table = Assert.Single(model.Tables); + var index = Assert.Single(table.Indexes); + + Assert.NotNull(index[SqlServerAnnotationNames.FullTextIndex]); + Assert.Equal("CatalogB", index[SqlServerAnnotationNames.FullTextCatalog]); + }); + + AssertSql( + """ +DROP FULLTEXT INDEX ON [FullTextEntities]; +""", + // + """ +CREATE FULLTEXT INDEX ON [FullTextEntities]([Title]) KEY INDEX [PK_FullTextEntities] ON [CatalogB]; +"""); + } + + [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFullTextSearch)] + public virtual async Task Change_full_text_index_language() + { + await Test( + builder => + { + builder.HasFullTextCatalog("TestCatalog"); + + builder.Entity( + "FullTextEntities", e => + { + e.Property("Id"); + e.Property("Title").HasMaxLength(450); + e.HasKey("Id").HasName("PK_FullTextEntities"); + }); + }, + builder => builder.Entity("FullTextEntities") + .HasFullTextIndex("Title") + .HasKeyIndex("PK_FullTextEntities") + .OnCatalog("TestCatalog") + .HasLanguage("Title", "English"), + builder => builder.Entity("FullTextEntities") + .HasFullTextIndex("Title") + .HasKeyIndex("PK_FullTextEntities") + .OnCatalog("TestCatalog") + .HasLanguage("Title", "French"), + model => + { + var table = Assert.Single(model.Tables); + var index = Assert.Single(table.Indexes); + + Assert.NotNull(index[SqlServerAnnotationNames.FullTextIndex]); + }); + + AssertSql( + """ +DROP FULLTEXT INDEX ON [FullTextEntities]; +""", + // + """ +CREATE FULLTEXT INDEX ON [FullTextEntities]([Title] LANGUAGE [French]) KEY INDEX [PK_FullTextEntities] ON [TestCatalog]; +"""); + } + + [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFullTextSearch)] + public virtual async Task Create_full_text_catalog_and_index_together() + { + await Test( + builder => builder.Entity( + "FullTextEntities", e => + { + e.Property("Id"); + e.Property("Title").HasMaxLength(450); + e.HasKey("Id").HasName("PK_FullTextEntities"); + }), + builder => { }, + builder => + { + builder.HasFullTextCatalog("MyCatalog"); + builder.Entity("FullTextEntities") + .HasFullTextIndex("Title") + .HasKeyIndex("PK_FullTextEntities") + .OnCatalog("MyCatalog"); + }, + model => + { + var table = Assert.Single(model.Tables); + var index = Assert.Single(table.Indexes); + + Assert.NotNull(index[SqlServerAnnotationNames.FullTextIndex]); + Assert.Equal("MyCatalog", index[SqlServerAnnotationNames.FullTextCatalog]); + }); + + AssertSql( + """ +CREATE FULLTEXT CATALOG [MyCatalog]; +""", + // + """ +CREATE FULLTEXT INDEX ON [FullTextEntities]([Title]) KEY INDEX [PK_FullTextEntities] ON [MyCatalog]; +"""); + } + + [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFullTextSearch)] + public virtual async Task Drop_full_text_index_and_catalog_together() + { + await Test( + builder => builder.Entity( + "FullTextEntities", e => + { + e.Property("Id"); + e.Property("Title").HasMaxLength(450); + e.HasKey("Id").HasName("PK_FullTextEntities"); + }), + builder => + { + builder.HasFullTextCatalog("MyCatalog"); + + builder.Entity("FullTextEntities") + .HasFullTextIndex("Title") + .HasKeyIndex("PK_FullTextEntities") + .OnCatalog("MyCatalog"); + }, + builder => { }, + model => + { + var table = Assert.Single(model.Tables); + Assert.Empty(table.Indexes); + }); + + AssertSql( + """ +DROP FULLTEXT INDEX ON [FullTextEntities]; +""", + // + """ +DROP FULLTEXT CATALOG [MyCatalog]; +"""); + } + + #endregion Full-text search + public override async Task Add_primary_key_int() { var exception = await Assert.ThrowsAsync(() => base.Add_primary_key_int()); diff --git a/test/EFCore.SqlServer.FunctionalTests/ModelBuilding/SqlServerModelBuilderTestBase.cs b/test/EFCore.SqlServer.FunctionalTests/ModelBuilding/SqlServerModelBuilderTestBase.cs index 702523323e5..2b742819a79 100644 --- a/test/EFCore.SqlServer.FunctionalTests/ModelBuilding/SqlServerModelBuilderTestBase.cs +++ b/test/EFCore.SqlServer.FunctionalTests/ModelBuilding/SqlServerModelBuilderTestBase.cs @@ -285,6 +285,111 @@ public virtual void Can_configure_vector_index_with_fluent_api() Assert.Equal(nameof(VectorIndexEntity.Vector), index.Properties.Single().Name); } + [ConditionalFact] + public virtual void Can_configure_full_text_index_with_fluent_api() + { + var modelBuilder = CreateModelBuilder(); + + modelBuilder.Entity(b => + { + b.HasFullTextIndex(e => e.Title) + .HasDatabaseName("FTI_FullTextEntity") + .HasKeyIndex("PK_FullTextEntity") + .OnCatalog("MyCatalog") + .WithChangeTracking(FullTextChangeTracking.Manual) + .HasLanguage("Title", "English"); + }); + + var model = modelBuilder.FinalizeModel(); + var entityType = model.FindEntityType(typeof(FullTextEntity))!; + var index = entityType.GetIndexes().Single(); + + Assert.True(index.IsFullTextIndex()); + Assert.Equal("FTI_FullTextEntity", index.GetDatabaseName()); + Assert.Equal("PK_FullTextEntity", index.GetFullTextKeyIndex()); + Assert.Equal("MyCatalog", index.GetFullTextCatalog()); + Assert.Equal(FullTextChangeTracking.Manual, index.GetFullTextChangeTracking()); + Assert.Equal("English", index.GetFullTextLanguage("Title")); + Assert.Equal(nameof(FullTextEntity.Title), index.Properties.Single().Name); + } + + [ConditionalFact] + public virtual void Can_configure_full_text_index_with_multiple_columns() + { + var modelBuilder = CreateModelBuilder(); + + modelBuilder.Entity(b => + { + b.HasFullTextIndex(e => new { e.Title, e.Body }) + .HasKeyIndex("PK_FullTextEntity") + .HasLanguage("Title", "English") + .HasLanguage("Body", "French"); + }); + + var model = modelBuilder.FinalizeModel(); + var entityType = model.FindEntityType(typeof(FullTextEntity))!; + var index = entityType.GetIndexes().Single(); + + Assert.True(index.IsFullTextIndex()); + Assert.Equal(2, index.Properties.Count); + Assert.Equal("Title", index.Properties[0].Name); + Assert.Equal("Body", index.Properties[1].Name); + Assert.Equal("English", index.GetFullTextLanguage("Title")); + Assert.Equal("French", index.GetFullTextLanguage("Body")); + } + + [ConditionalFact] + public virtual void Can_configure_full_text_catalog_with_fluent_api() + { + var modelBuilder = CreateModelBuilder(); + + modelBuilder.HasFullTextCatalog("MyCatalog") + .IsDefault() + .IsAccentSensitive(false); + + modelBuilder.Entity(b => + { + b.HasFullTextIndex(e => e.Title) + .HasKeyIndex("PK_FullTextEntity") + .OnCatalog("MyCatalog"); + }); + + var model = modelBuilder.FinalizeModel(); + var catalogs = model.GetFullTextCatalogs().ToList(); + Assert.Single(catalogs); + Assert.Equal("MyCatalog", catalogs[0].Name); + Assert.True(catalogs[0].IsDefault); + Assert.False(catalogs[0].IsAccentSensitive); + } + + [ConditionalFact] + public virtual void Can_configure_full_text_index_with_change_tracking_off_no_population() + { + var modelBuilder = CreateModelBuilder(); + + modelBuilder.Entity(b => + { + b.HasFullTextIndex(e => e.Title) + .HasKeyIndex("PK_FullTextEntity") + .WithChangeTracking(FullTextChangeTracking.OffNoPopulation); + }); + + var model = modelBuilder.FinalizeModel(); + var entityType = model.FindEntityType(typeof(FullTextEntity))!; + var index = entityType.GetIndexes().Single(); + + Assert.True(index.IsFullTextIndex()); + Assert.Equal(FullTextChangeTracking.OffNoPopulation, index.GetFullTextChangeTracking()); + } + + protected class FullTextEntity + { + public int Id { get; set; } + public string? Title { get; set; } + public string? Body { get; set; } + public byte[]? Document { get; set; } + } + [ConditionalTheory, InlineData(true), InlineData(false)] public virtual void Can_avoid_attributes_when_discovering_properties(bool useAttributes) { @@ -2299,4 +2404,93 @@ public override TestVectorIndexBuilder UseType(string? type) => Wrap(VectorIndexBuilder.UseType(type)); } #pragma warning restore EF9105 + + public abstract class TestFullTextIndexBuilder + where TEntity : class + { + public abstract IMutableIndex Metadata { get; } + public abstract TestFullTextIndexBuilder HasDatabaseName(string? name); + public abstract TestFullTextIndexBuilder HasKeyIndex(string keyIndexName); + public abstract TestFullTextIndexBuilder OnCatalog(string catalogName); + public abstract TestFullTextIndexBuilder WithChangeTracking(FullTextChangeTracking changeTracking); + public abstract TestFullTextIndexBuilder HasLanguage(string propertyName, string language); + } + + public class GenericTestFullTextIndexBuilder(SqlServerFullTextIndexBuilder fullTextIndexBuilder) + : TestFullTextIndexBuilder, + IInfrastructure> + where TEntity : class + { + private SqlServerFullTextIndexBuilder FullTextIndexBuilder { get; } = fullTextIndexBuilder; + + SqlServerFullTextIndexBuilder IInfrastructure>.Instance + => FullTextIndexBuilder; + + public override IMutableIndex Metadata + => FullTextIndexBuilder.Metadata; + + protected virtual TestFullTextIndexBuilder Wrap(SqlServerFullTextIndexBuilder fullTextIndexBuilder) + => new GenericTestFullTextIndexBuilder(fullTextIndexBuilder); + + public override TestFullTextIndexBuilder HasDatabaseName(string? name) + => Wrap(FullTextIndexBuilder.HasDatabaseName(name)); + + public override TestFullTextIndexBuilder HasKeyIndex(string keyIndexName) + => Wrap(FullTextIndexBuilder.HasKeyIndex(keyIndexName)); + + public override TestFullTextIndexBuilder OnCatalog(string catalogName) + => Wrap(FullTextIndexBuilder.OnCatalog(catalogName)); + + public override TestFullTextIndexBuilder WithChangeTracking(FullTextChangeTracking changeTracking) + => Wrap(FullTextIndexBuilder.WithChangeTracking(changeTracking)); + + public override TestFullTextIndexBuilder HasLanguage(string propertyName, string language) + => Wrap(FullTextIndexBuilder.HasLanguage(propertyName, language)); + } + + public class NonGenericTestFullTextIndexBuilder(SqlServerFullTextIndexBuilder fullTextIndexBuilder) + : TestFullTextIndexBuilder, + IInfrastructure + where TEntity : class + { + private SqlServerFullTextIndexBuilder FullTextIndexBuilder { get; } = fullTextIndexBuilder; + + SqlServerFullTextIndexBuilder IInfrastructure.Instance + => FullTextIndexBuilder; + + public override IMutableIndex Metadata + => FullTextIndexBuilder.Metadata; + + protected virtual TestFullTextIndexBuilder Wrap(SqlServerFullTextIndexBuilder fullTextIndexBuilder) + => new NonGenericTestFullTextIndexBuilder(fullTextIndexBuilder); + + public override TestFullTextIndexBuilder HasDatabaseName(string? name) + => Wrap(FullTextIndexBuilder.HasDatabaseName(name)); + + public override TestFullTextIndexBuilder HasKeyIndex(string keyIndexName) + => Wrap(FullTextIndexBuilder.HasKeyIndex(keyIndexName)); + + public override TestFullTextIndexBuilder OnCatalog(string catalogName) + => Wrap(FullTextIndexBuilder.OnCatalog(catalogName)); + + public override TestFullTextIndexBuilder WithChangeTracking(FullTextChangeTracking changeTracking) + => Wrap(FullTextIndexBuilder.WithChangeTracking(changeTracking)); + + public override TestFullTextIndexBuilder HasLanguage(string propertyName, string language) + => Wrap(FullTextIndexBuilder.HasLanguage(propertyName, language)); + } + + public class TestFullTextCatalogBuilder(SqlServerFullTextCatalogBuilder catalogBuilder) + { + private SqlServerFullTextCatalogBuilder CatalogBuilder { get; } = catalogBuilder; + + public virtual TestFullTextCatalogBuilder IsDefault(bool @default = true) + => Wrap(CatalogBuilder.IsDefault(@default)); + + public virtual TestFullTextCatalogBuilder IsAccentSensitive(bool accentSensitive = true) + => Wrap(CatalogBuilder.IsAccentSensitive(accentSensitive)); + + private TestFullTextCatalogBuilder Wrap(SqlServerFullTextCatalogBuilder catalogBuilder) + => new(catalogBuilder); + } } diff --git a/test/EFCore.SqlServer.FunctionalTests/ModelBuilding/SqlServerTestModelBuilderExtensions.cs b/test/EFCore.SqlServer.FunctionalTests/ModelBuilding/SqlServerTestModelBuilderExtensions.cs index 3c492c5d322..c37779955d6 100644 --- a/test/EFCore.SqlServer.FunctionalTests/ModelBuilding/SqlServerTestModelBuilderExtensions.cs +++ b/test/EFCore.SqlServer.FunctionalTests/ModelBuilding/SqlServerTestModelBuilderExtensions.cs @@ -170,4 +170,34 @@ public static SqlServerModelBuilderTestBase.TestVectorIndexBuilder HasV throw new InvalidOperationException(); } } + + public static SqlServerModelBuilderTestBase.TestFullTextIndexBuilder HasFullTextIndex( + this ModelBuilderTest.TestEntityTypeBuilder builder, + Expression> indexExpression) + where TEntity : class + { + switch (builder) + { + case IInfrastructure> genericBuilder: + return new SqlServerModelBuilderTestBase.GenericTestFullTextIndexBuilder( + genericBuilder.Instance.HasFullTextIndex(indexExpression)); + case IInfrastructure nonGenericBuilder: + var members = indexExpression.GetMemberAccessList(); + var propertyNames = members.Select(m => + { + var name = m.Name; + var dot = name.LastIndexOf('.'); + return dot >= 0 ? name[(dot + 1)..] : name; + }).ToArray(); + return new SqlServerModelBuilderTestBase.NonGenericTestFullTextIndexBuilder( + nonGenericBuilder.Instance.HasFullTextIndex(propertyNames)); + default: + throw new InvalidOperationException(); + } + } + + public static SqlServerModelBuilderTestBase.TestFullTextCatalogBuilder HasFullTextCatalog( + this ModelBuilderTest.TestModelBuilder builder, + string name) + => new(((IInfrastructure)builder).Instance.HasFullTextCatalog(name)); } diff --git a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/Full_text_index/DbContextAssemblyAttributes.cs b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/Full_text_index/DbContextAssemblyAttributes.cs new file mode 100644 index 00000000000..c224873f6fa --- /dev/null +++ b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/Full_text_index/DbContextAssemblyAttributes.cs @@ -0,0 +1,9 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using TestNamespace; + +#pragma warning disable 219, 612, 618 +#nullable disable + +[assembly: DbContextModel(typeof(DbContext), typeof(DbContextModel))] diff --git a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/Full_text_index/DbContextModel.cs b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/Full_text_index/DbContextModel.cs new file mode 100644 index 00000000000..583ee4d90cc --- /dev/null +++ b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/Full_text_index/DbContextModel.cs @@ -0,0 +1,48 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +#pragma warning disable 219, 612, 618 +#nullable disable + +namespace TestNamespace +{ + [DbContext(typeof(DbContext))] + public partial class DbContextModel : RuntimeModel + { + private static readonly bool _useOldBehavior31751 = + System.AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue31751", out var enabled31751) && enabled31751; + + static DbContextModel() + { + var model = new DbContextModel(); + + if (_useOldBehavior31751) + { + model.Initialize(); + } + else + { + var thread = new System.Threading.Thread(RunInitialization, 10 * 1024 * 1024); + thread.Start(); + thread.Join(); + + void RunInitialization() + { + model.Initialize(); + } + } + + model.Customize(); + _instance = (DbContextModel)model.FinalizeModel(); + } + + private static DbContextModel _instance; + public static IModel Instance => _instance; + + partial void Initialize(); + + partial void Customize(); + } +} diff --git a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/Full_text_index/DbContextModelBuilder.cs b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/Full_text_index/DbContextModelBuilder.cs new file mode 100644 index 00000000000..caff7aababc --- /dev/null +++ b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/Full_text_index/DbContextModelBuilder.cs @@ -0,0 +1,95 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Internal; +using Microsoft.EntityFrameworkCore.Update.Internal; + +#pragma warning disable 219, 612, 618 +#nullable disable + +namespace TestNamespace +{ + public partial class DbContextModel + { + private DbContextModel() + : base(skipDetectChanges: false, modelId: new Guid("00000000-0000-0000-0000-000000000000"), entityTypeCount: 1) + { + } + + partial void Initialize() + { + var fullTextEntity = FullTextEntityEntityType.Create(this); + + FullTextEntityEntityType.CreateAnnotations(fullTextEntity); + + AddAnnotation("Relational:MaxIdentifierLength", 128); + AddAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + AddRuntimeAnnotation("Relational:RelationalModelFactory", () => CreateRelationalModel()); + } + + private IRelationalModel CreateRelationalModel() + { + var relationalModel = new RelationalModel(this); + + var fullTextEntity = FindEntityType("Microsoft.EntityFrameworkCore.Scaffolding.CompiledModelSqlServerTest+FullTextEntity")!; + + var defaultTableMappings = new List>(); + fullTextEntity.SetRuntimeAnnotation("Relational:DefaultMappings", defaultTableMappings); + var microsoftEntityFrameworkCoreScaffoldingCompiledModelSqlServerTestFullTextEntityTableBase = new TableBase("Microsoft.EntityFrameworkCore.Scaffolding.CompiledModelSqlServerTest+FullTextEntity", null, relationalModel); + var idColumnBase = new ColumnBase("Id", "int", microsoftEntityFrameworkCoreScaffoldingCompiledModelSqlServerTestFullTextEntityTableBase); + microsoftEntityFrameworkCoreScaffoldingCompiledModelSqlServerTestFullTextEntityTableBase.Columns.Add("Id", idColumnBase); + var titleColumnBase = new ColumnBase("Title", "nvarchar(450)", microsoftEntityFrameworkCoreScaffoldingCompiledModelSqlServerTestFullTextEntityTableBase) + { + IsNullable = true + }; + microsoftEntityFrameworkCoreScaffoldingCompiledModelSqlServerTestFullTextEntityTableBase.Columns.Add("Title", titleColumnBase); + relationalModel.DefaultTables.Add("Microsoft.EntityFrameworkCore.Scaffolding.CompiledModelSqlServerTest+FullTextEntity", microsoftEntityFrameworkCoreScaffoldingCompiledModelSqlServerTestFullTextEntityTableBase); + var microsoftEntityFrameworkCoreScaffoldingCompiledModelSqlServerTestFullTextEntityMappingBase = new TableMappingBase(fullTextEntity, microsoftEntityFrameworkCoreScaffoldingCompiledModelSqlServerTestFullTextEntityTableBase, null); + microsoftEntityFrameworkCoreScaffoldingCompiledModelSqlServerTestFullTextEntityTableBase.AddTypeMapping(microsoftEntityFrameworkCoreScaffoldingCompiledModelSqlServerTestFullTextEntityMappingBase, false); + defaultTableMappings.Add(microsoftEntityFrameworkCoreScaffoldingCompiledModelSqlServerTestFullTextEntityMappingBase); + RelationalModel.CreateColumnMapping((ColumnBase)idColumnBase, fullTextEntity.FindProperty("Id")!, microsoftEntityFrameworkCoreScaffoldingCompiledModelSqlServerTestFullTextEntityMappingBase); + RelationalModel.CreateColumnMapping((ColumnBase)titleColumnBase, fullTextEntity.FindProperty("Title")!, microsoftEntityFrameworkCoreScaffoldingCompiledModelSqlServerTestFullTextEntityMappingBase); + + var tableMappings = new List(); + fullTextEntity.SetRuntimeAnnotation("Relational:TableMappings", tableMappings); + var fullTextEntityTable = new Table("FullTextEntity", null, relationalModel); + var idColumn = new Column("Id", "int", fullTextEntityTable); + fullTextEntityTable.Columns.Add("Id", idColumn); + idColumn.Accessors = ColumnAccessorsFactory.CreateGeneric(idColumn); + var titleColumn = new Column("Title", "nvarchar(450)", fullTextEntityTable) + { + IsNullable = true + }; + fullTextEntityTable.Columns.Add("Title", titleColumn); + titleColumn.Accessors = ColumnAccessorsFactory.CreateGeneric(titleColumn); + relationalModel.Tables.Add(("FullTextEntity", null), fullTextEntityTable); + var fullTextEntityTableMapping = new TableMapping(fullTextEntity, fullTextEntityTable, null); + fullTextEntityTable.AddTypeMapping(fullTextEntityTableMapping, false); + tableMappings.Add(fullTextEntityTableMapping); + RelationalModel.CreateColumnMapping(idColumn, fullTextEntity.FindProperty("Id")!, fullTextEntityTableMapping); + RelationalModel.CreateColumnMapping(titleColumn, fullTextEntity.FindProperty("Title")!, fullTextEntityTableMapping); + var pK_FullTextEntity = new UniqueConstraint("PK_FullTextEntity", fullTextEntityTable, new[] { idColumn }); + fullTextEntityTable.PrimaryKey = pK_FullTextEntity; + pK_FullTextEntity.SetRowKeyValueFactory(new SimpleRowKeyValueFactory(pK_FullTextEntity)); + var pK_FullTextEntityKey = RelationalModel.GetKey(this, + "Microsoft.EntityFrameworkCore.Scaffolding.CompiledModelSqlServerTest+FullTextEntity", + new[] { "Id" }); + pK_FullTextEntity.MappedKeys.Add(pK_FullTextEntityKey); + RelationalModel.GetOrCreateUniqueConstraints(pK_FullTextEntityKey).Add(pK_FullTextEntity); + fullTextEntityTable.UniqueConstraints.Add("PK_FullTextEntity", pK_FullTextEntity); + var iX_FullTextEntity_Title = new TableIndex( + "IX_FullTextEntity_Title", fullTextEntityTable, new[] { titleColumn }, false); + iX_FullTextEntity_Title.SetRowIndexValueFactory(new SimpleRowIndexValueFactory(iX_FullTextEntity_Title)); + var iX_FullTextEntity_TitleIx = RelationalModel.GetIndex(this, + "Microsoft.EntityFrameworkCore.Scaffolding.CompiledModelSqlServerTest+FullTextEntity", + new[] { "Title" }); + iX_FullTextEntity_Title.MappedIndexes.Add(iX_FullTextEntity_TitleIx); + RelationalModel.GetOrCreateTableIndexes(iX_FullTextEntity_TitleIx).Add(iX_FullTextEntity_Title); + fullTextEntityTable.Indexes.Add("IX_FullTextEntity_Title", iX_FullTextEntity_Title); + return relationalModel.MakeReadOnly(); + } + } +} diff --git a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/Full_text_index/FullTextEntityEntityType.cs b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/Full_text_index/FullTextEntityEntityType.cs new file mode 100644 index 00000000000..7e68610cd19 --- /dev/null +++ b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/Full_text_index/FullTextEntityEntityType.cs @@ -0,0 +1,192 @@ +// +using System; +using System.Collections.Generic; +using System.Reflection; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.ChangeTracking.Internal; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Internal; +using Microsoft.EntityFrameworkCore.Scaffolding; +using Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal; +using Microsoft.EntityFrameworkCore.Storage; + +#pragma warning disable 219, 612, 618 +#nullable disable + +namespace TestNamespace +{ + [EntityFrameworkInternal] + public partial class FullTextEntityEntityType + { + public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null) + { + var runtimeEntityType = model.AddEntityType( + "Microsoft.EntityFrameworkCore.Scaffolding.CompiledModelSqlServerTest+FullTextEntity", + typeof(CompiledModelSqlServerTest.FullTextEntity), + baseEntityType, + propertyCount: 2, + unnamedIndexCount: 1, + keyCount: 1); + + var id = runtimeEntityType.AddProperty( + "Id", + typeof(int), + propertyInfo: typeof(CompiledModelSqlServerTest.FullTextEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(CompiledModelSqlServerTest.FullTextEntity).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + valueGenerated: ValueGenerated.OnAdd, + afterSaveBehavior: PropertySaveBehavior.Throw, + sentinel: 0); + id.SetGetter( + int (CompiledModelSqlServerTest.FullTextEntity instance) => FullTextEntityUnsafeAccessors.Id(instance), + bool (CompiledModelSqlServerTest.FullTextEntity instance) => FullTextEntityUnsafeAccessors.Id(instance) == 0); + id.SetSetter( + CompiledModelSqlServerTest.FullTextEntity (CompiledModelSqlServerTest.FullTextEntity instance, int value) => + { + FullTextEntityUnsafeAccessors.Id(instance) = value; + return instance; + }); + id.SetMaterializationSetter( + CompiledModelSqlServerTest.FullTextEntity (CompiledModelSqlServerTest.FullTextEntity instance, int value) => + { + FullTextEntityUnsafeAccessors.Id(instance) = value; + return instance; + }); + id.SetAccessors( + int (IInternalEntry entry) => (entry.FlaggedAsStoreGenerated(0) ? entry.ReadStoreGeneratedValue(0) : (entry.FlaggedAsTemporary(0) && FullTextEntityUnsafeAccessors.Id(((CompiledModelSqlServerTest.FullTextEntity)(entry.Entity))) == 0 ? entry.ReadTemporaryValue(0) : FullTextEntityUnsafeAccessors.Id(((CompiledModelSqlServerTest.FullTextEntity)(entry.Entity))))), + int (IInternalEntry entry) => FullTextEntityUnsafeAccessors.Id(((CompiledModelSqlServerTest.FullTextEntity)(entry.Entity))), + int (IInternalEntry entry) => entry.ReadOriginalValue(id, 0), + int (IInternalEntry entry) => ((InternalEntityEntry)entry).ReadRelationshipSnapshotValue(id, 0)); + id.SetPropertyIndexes( + index: 0, + originalValueIndex: 0, + shadowIndex: -1, + relationshipIndex: 0, + storeGenerationIndex: 0); + id.TypeMapping = IntTypeMapping.Default.Clone( + comparer: new ValueComparer( + bool (int v1, int v2) => v1 == v2, + int (int v) => v, + int (int v) => v), + keyComparer: new ValueComparer( + bool (int v1, int v2) => v1 == v2, + int (int v) => v, + int (int v) => v), + providerValueComparer: new ValueComparer( + bool (int v1, int v2) => v1 == v2, + int (int v) => v, + int (int v) => v)); + id.SetCurrentValueComparer(new EntryCurrentValueComparer(id)); + id.AddAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + var title = runtimeEntityType.AddProperty( + "Title", + typeof(string), + propertyInfo: typeof(CompiledModelSqlServerTest.FullTextEntity).GetProperty("Title", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(CompiledModelSqlServerTest.FullTextEntity).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + nullable: true); + title.SetGetter( + string (CompiledModelSqlServerTest.FullTextEntity instance) => FullTextEntityUnsafeAccessors.Title(instance), + bool (CompiledModelSqlServerTest.FullTextEntity instance) => FullTextEntityUnsafeAccessors.Title(instance) == null); + title.SetSetter( + CompiledModelSqlServerTest.FullTextEntity (CompiledModelSqlServerTest.FullTextEntity instance, string value) => + { + FullTextEntityUnsafeAccessors.Title(instance) = value; + return instance; + }); + title.SetMaterializationSetter( + CompiledModelSqlServerTest.FullTextEntity (CompiledModelSqlServerTest.FullTextEntity instance, string value) => + { + FullTextEntityUnsafeAccessors.Title(instance) = value; + return instance; + }); + title.SetAccessors( + string (IInternalEntry entry) => FullTextEntityUnsafeAccessors.Title(((CompiledModelSqlServerTest.FullTextEntity)(entry.Entity))), + string (IInternalEntry entry) => FullTextEntityUnsafeAccessors.Title(((CompiledModelSqlServerTest.FullTextEntity)(entry.Entity))), + string (IInternalEntry entry) => entry.ReadOriginalValue<string>(title, 1), + string (IInternalEntry entry) => entry.GetCurrentValue<string>(title)); + title.SetPropertyIndexes( + index: 1, + originalValueIndex: 1, + shadowIndex: -1, + relationshipIndex: -1, + storeGenerationIndex: -1); + title.TypeMapping = SqlServerStringTypeMapping.Default.Clone( + comparer: new ValueComparer<string>( + bool (string v1, string v2) => v1 == v2, + int (string v) => ((object)v).GetHashCode(), + string (string v) => v), + keyComparer: new ValueComparer<string>( + bool (string v1, string v2) => v1 == v2, + int (string v) => ((object)v).GetHashCode(), + string (string v) => v), + providerValueComparer: new ValueComparer<string>( + bool (string v1, string v2) => v1 == v2, + int (string v) => ((object)v).GetHashCode(), + string (string v) => v), + mappingInfo: new RelationalTypeMappingInfo( + storeTypeName: "nvarchar(450)", + size: 450, + unicode: true, + dbType: System.Data.DbType.String)); + title.AddAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.None); + + var key = runtimeEntityType.AddKey( + new[] { id }); + runtimeEntityType.SetPrimaryKey(key); + + var index = runtimeEntityType.AddIndex( + new[] { title }); + + return runtimeEntityType; + } + + public static void CreateAnnotations(RuntimeEntityType runtimeEntityType) + { + var id = runtimeEntityType.FindProperty("Id"); + var title = runtimeEntityType.FindProperty("Title"); + var key = runtimeEntityType.FindKey(new[] { id }); + key.SetPrincipalKeyValueFactory(KeyValueFactoryFactory.CreateSimpleNonNullableFactory<int>(key)); + key.SetIdentityMapFactory(IdentityMapFactoryFactory.CreateFactory<int>(key)); + runtimeEntityType.SetOriginalValuesFactory( + ISnapshot (IInternalEntry source) => + { + var structuralType = ((CompiledModelSqlServerTest.FullTextEntity)(source.Entity)); + return ((ISnapshot)(new Snapshot<int, string>(((ValueComparer<int>)(((IProperty)id).GetValueComparer())).Snapshot(source.GetCurrentValue<int>(id)), (source.GetCurrentValue<string>(title) == null ? null : ((ValueComparer<string>)(((IProperty)title).GetValueComparer())).Snapshot(source.GetCurrentValue<string>(title)))))); + }); + runtimeEntityType.SetStoreGeneratedValuesFactory( + ISnapshot () => ((ISnapshot)(new Snapshot<int>(((ValueComparer<int>)(((IProperty)id).GetValueComparer())).Snapshot(default(int)))))); + runtimeEntityType.SetTemporaryValuesFactory( + ISnapshot (IInternalEntry source) => ((ISnapshot)(new Snapshot<int>(default(int))))); + runtimeEntityType.SetShadowValuesFactory( + ISnapshot (IDictionary<string, object> source) => Snapshot.Empty); + runtimeEntityType.SetEmptyShadowValuesFactory( + ISnapshot () => Snapshot.Empty); + runtimeEntityType.SetRelationshipSnapshotFactory( + ISnapshot (IInternalEntry source) => + { + var structuralType = ((CompiledModelSqlServerTest.FullTextEntity)(source.Entity)); + return ((ISnapshot)(new Snapshot<int>(((ValueComparer<int>)(((IProperty)id).GetKeyValueComparer())).Snapshot(source.GetCurrentValue<int>(id))))); + }); + runtimeEntityType.SetCounts(new PropertyCounts( + propertyCount: 2, + navigationCount: 0, + complexPropertyCount: 0, + complexCollectionCount: 0, + originalValueCount: 2, + shadowCount: 0, + relationshipCount: 1, + storeGeneratedCount: 1)); + runtimeEntityType.AddAnnotation("Relational:FunctionName", null); + runtimeEntityType.AddAnnotation("Relational:Schema", null); + runtimeEntityType.AddAnnotation("Relational:SqlQuery", null); + runtimeEntityType.AddAnnotation("Relational:TableName", "FullTextEntity"); + runtimeEntityType.AddAnnotation("Relational:ViewName", null); + runtimeEntityType.AddAnnotation("Relational:ViewSchema", null); + + Customize(runtimeEntityType); + } + + static partial void Customize(RuntimeEntityType runtimeEntityType); + } +} diff --git a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/Full_text_index/FullTextEntityUnsafeAccessors.cs b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/Full_text_index/FullTextEntityUnsafeAccessors.cs new file mode 100644 index 00000000000..f7c19b36f4b --- /dev/null +++ b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/Full_text_index/FullTextEntityUnsafeAccessors.cs @@ -0,0 +1,19 @@ +// <auto-generated /> +using System; +using System.Runtime.CompilerServices; +using Microsoft.EntityFrameworkCore.Scaffolding; + +#pragma warning disable 219, 612, 618 +#nullable disable + +namespace TestNamespace +{ + public static class FullTextEntityUnsafeAccessors + { + [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "<Id>k__BackingField")] + public static extern ref int Id(CompiledModelSqlServerTest.FullTextEntity @this); + + [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "<Title>k__BackingField")] + public static extern ref string Title(CompiledModelSqlServerTest.FullTextEntity @this); + } +} diff --git a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/CompiledModelSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/CompiledModelSqlServerTest.cs index 186373bad7c..9e10fbd86c5 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/CompiledModelSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/CompiledModelSqlServerTest.cs @@ -218,6 +218,37 @@ public virtual Task Vector_index() useContext: null, additionalSourceFiles: []); + [ConditionalFact] + public virtual Task Full_text_index() + => Test( + modelBuilder => + { + modelBuilder.HasFullTextCatalog("MyCatalog"); + + modelBuilder.Entity<FullTextEntity>(b => + { + b.HasFullTextIndex(e => e.Title) + .HasKeyIndex("PK_FullTextEntity") + .OnCatalog("MyCatalog") + .WithChangeTracking(FullTextChangeTracking.Manual) + .HasLanguage("Title", "English"); + }); + }, + model => + { + var entityType = model.FindEntityType(typeof(FullTextEntity))!; + var index = entityType.GetIndexes().Single(); + // Full-text index annotations are not used at runtime, so they are not included in the compiled model + Assert.Null(index[SqlServerAnnotationNames.FullTextIndex]); + Assert.Null(index[SqlServerAnnotationNames.FullTextCatalog]); + Assert.Null(index[SqlServerAnnotationNames.FullTextChangeTracking]); + Assert.Null(index[SqlServerAnnotationNames.FullTextLanguages]); + // Full-text catalogs should also be absent at runtime + Assert.Null(model[SqlServerAnnotationNames.FullTextCatalogs]); + }, + useContext: null, + additionalSourceFiles: []); + protected override bool UseSprocReturnValue => true; @@ -480,4 +511,10 @@ public class VectorIndexEntity public int Id { get; set; } public SqlVector<float>? Vector { get; set; } } + + public class FullTextEntity + { + public int Id { get; set; } + public string? Title { get; set; } + } } diff --git a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/SqlServerDatabaseModelFactoryTest.cs b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/SqlServerDatabaseModelFactoryTest.cs index 163f4e9dec8..362afdb9d03 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/SqlServerDatabaseModelFactoryTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/SqlServerDatabaseModelFactoryTest.cs @@ -5814,6 +5814,86 @@ public void Vector_type() #endregion + #region Full-Text Search + + [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFullTextSearch)] + public void Full_text_catalog() + => Test( + [ + "CREATE FULLTEXT CATALOG [TestCatalog] WITH ACCENT_SENSITIVITY = OFF AS DEFAULT", + "CREATE TABLE [dbo].[FtsTable] (Id int CONSTRAINT PK_FtsTable PRIMARY KEY, Title nvarchar(200))", + ], + tables: [], + schemas: [], + (dbModel, scaffoldingFactory) => + { + var catalogs = SqlServerFullTextCatalog.GetFullTextCatalogs(dbModel).ToList(); + Assert.Single(catalogs); + Assert.Equal("TestCatalog", catalogs[0].Name); + Assert.True(catalogs[0].IsDefault); + Assert.False(catalogs[0].IsAccentSensitive); + }, + """ + DROP TABLE [dbo].[FtsTable]; + DROP FULLTEXT CATALOG [TestCatalog]; + """); + + [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFullTextSearch)] + public void Full_text_index() + => Test( + [ + "CREATE FULLTEXT CATALOG [TestCatalog]", + "CREATE TABLE [dbo].[FtsTable] (Id int CONSTRAINT PK_FtsTable PRIMARY KEY, Title nvarchar(200), Body nvarchar(max))", + "CREATE FULLTEXT INDEX ON [dbo].[FtsTable] (Title LANGUAGE 'English', Body) KEY INDEX [PK_FtsTable] ON [TestCatalog] WITH CHANGE_TRACKING = MANUAL", + ], + tables: [], + schemas: [], + (dbModel, scaffoldingFactory) => + { + var table = dbModel.Tables.Single(t => t.Name == "FtsTable"); + var index = Assert.Single(table.Indexes); + + Assert.NotNull(index[SqlServerAnnotationNames.FullTextIndex]); + Assert.Equal("TestCatalog", index[SqlServerAnnotationNames.FullTextCatalog]); + Assert.Equal(FullTextChangeTracking.Manual, index[SqlServerAnnotationNames.FullTextChangeTracking]); + + var languages = (Dictionary<string, string>?)index[SqlServerAnnotationNames.FullTextLanguages]; + Assert.NotNull(languages); + Assert.True(languages.ContainsKey("Title")); + }, + """ + DROP FULLTEXT INDEX ON [dbo].[FtsTable]; + DROP TABLE [dbo].[FtsTable]; + DROP FULLTEXT CATALOG [TestCatalog]; + """); + + [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFullTextSearch)] + public void Full_text_index_with_defaults() + => Test( + [ + "CREATE FULLTEXT CATALOG [DefaultCatalog] AS DEFAULT", + "CREATE TABLE [dbo].[FtsTable2] (Id int CONSTRAINT PK_FtsTable2 PRIMARY KEY, Title nvarchar(200))", + "CREATE FULLTEXT INDEX ON [dbo].[FtsTable2] (Title) KEY INDEX [PK_FtsTable2]", + ], + tables: [], + schemas: [], + (dbModel, scaffoldingFactory) => + { + var table = dbModel.Tables.Single(t => t.Name == "FtsTable2"); + var index = Assert.Single(table.Indexes); + + Assert.NotNull(index[SqlServerAnnotationNames.FullTextIndex]); + // Default change tracking is AUTO, which is omitted during scaffolding + Assert.Null(index[SqlServerAnnotationNames.FullTextChangeTracking]); + }, + """ + DROP FULLTEXT INDEX ON [dbo].[FtsTable2]; + DROP TABLE [dbo].[FtsTable2]; + DROP FULLTEXT CATALOG [DefaultCatalog]; + """); + + #endregion + #region Warnings [ConditionalFact] diff --git a/test/EFCore.SqlServer.FunctionalTests/SqlServerApiConsistencyTest.cs b/test/EFCore.SqlServer.FunctionalTests/SqlServerApiConsistencyTest.cs index 79cfc3d2d42..6a6c3256fd8 100644 --- a/test/EFCore.SqlServer.FunctionalTests/SqlServerApiConsistencyTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/SqlServerApiConsistencyTest.cs @@ -18,6 +18,16 @@ protected override Assembly TargetAssembly public class SqlServerApiConsistencyFixture : ApiConsistencyFixtureBase { + public override HashSet<MethodInfo> UnmatchedMetadataMethods { get; } = + [ + typeof(SqlServerIndexExtensions).GetMethod( + nameof(SqlServerIndexExtensions.SetFullTextLanguage), + [typeof(IMutableIndex), typeof(string), typeof(string)]), + typeof(SqlServerIndexExtensions).GetMethod( + nameof(SqlServerIndexExtensions.SetFullTextLanguage), + [typeof(IConventionIndex), typeof(string), typeof(string), typeof(bool)]) + ]; + public override HashSet<Type> FluentApiTypes { get; } = [ typeof(SqlServerDbContextOptionsBuilder), @@ -38,7 +48,10 @@ public class SqlServerApiConsistencyFixture : ApiConsistencyFixtureBase typeof(OwnedNavigationTemporalTableBuilder<,>), typeof(TemporalPeriodPropertyBuilder), typeof(TemporalTableBuilder), - typeof(TemporalTableBuilder<>) + typeof(TemporalTableBuilder<>), + typeof(SqlServerFullTextCatalogBuilder), + typeof(SqlServerFullTextIndexBuilder), + typeof(SqlServerFullTextIndexBuilder<>) ]; public override @@ -119,7 +132,15 @@ protected override void Initialize() MirrorTypes.Add( typeof(SqlServerComplexTypePrimitiveCollectionBuilderExtensions), typeof(SqlServerComplexTypePropertyBuilderExtensions)); + MetadataTypes.Add( + typeof(IReadOnlySqlServerFullTextCatalog), + (typeof(IMutableSqlServerFullTextCatalog), + typeof(IConventionSqlServerFullTextCatalog), + null, + typeof(ISqlServerFullTextCatalog))); + base.Initialize(); } + } } diff --git a/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerDatabaseCleaner.cs b/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerDatabaseCleaner.cs index bbfd0768f9d..89bbe8eb725 100644 --- a/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerDatabaseCleaner.cs +++ b/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerDatabaseCleaner.cs @@ -58,6 +58,10 @@ protected override string BuildCustomEndingSql(DatabaseModel databaseModel) GO DECLARE @SQL varchar(max) = ''; +SELECT @SQL = @SQL + 'DROP FULLTEXT CATALOG ' + QUOTENAME(name) + ';' FROM sys.fulltext_catalogs; +EXEC (@SQL); + +SET @SQL =''; SELECT @SQL = @SQL + 'DROP FUNCTION ' + QUOTENAME(ROUTINE_SCHEMA) + '.' + QUOTENAME(ROUTINE_NAME) + ';' FROM [INFORMATION_SCHEMA].[ROUTINES] WHERE ROUTINE_TYPE = 'FUNCTION' AND ROUTINE_BODY = 'SQL'; EXEC (@SQL); diff --git a/test/EFCore.SqlServer.Tests/Design/Internal/SqlServerAnnotationCodeGeneratorTest.cs b/test/EFCore.SqlServer.Tests/Design/Internal/SqlServerAnnotationCodeGeneratorTest.cs index 330acdea131..30a74ded1ab 100644 --- a/test/EFCore.SqlServer.Tests/Design/Internal/SqlServerAnnotationCodeGeneratorTest.cs +++ b/test/EFCore.SqlServer.Tests/Design/Internal/SqlServerAnnotationCodeGeneratorTest.cs @@ -199,6 +199,147 @@ public void GenerateFluentApi_IIndex_works_with_includes() Assert.Equal(["FirstName"], properties.AsEnumerable()); } + [ConditionalFact] + public void GenerateFluentApi_IIndex_works_with_full_text_key_index() + { + var generator = CreateGenerator(); + var modelBuilder = SqlServerConventionSetBuilder.CreateModelBuilder(); + modelBuilder.Entity( + "Post", + x => + { + x.Property<int>("Id"); + x.Property<string>("Title"); + x.HasFullTextIndex("Title").HasKeyIndex("PK_Post"); + }); + + var index = (IIndex)modelBuilder.Model.FindEntityType("Post")!.GetIndexes().Single(); + var annotations = index.GetAnnotations().ToDictionary(a => a.Name, a => a); + var results = generator.GenerateFluentApiCalls(index, annotations); + + var keyIndexResult = results.Single(r => r.Method == "HasFullTextKeyIndex"); + Assert.Equal(1, keyIndexResult.Arguments.Count); + Assert.Equal("PK_Post", keyIndexResult.Arguments[0]); + } + + [ConditionalFact] + public void GenerateFluentApi_IIndex_works_with_full_text_catalog() + { + var generator = CreateGenerator(); + var modelBuilder = SqlServerConventionSetBuilder.CreateModelBuilder(); + modelBuilder.Entity( + "Post", + x => + { + x.Property<int>("Id"); + x.Property<string>("Title"); + x.HasFullTextIndex("Title").HasKeyIndex("PK_Post").OnCatalog("MyCatalog"); + }); + + var index = (IIndex)modelBuilder.Model.FindEntityType("Post")!.GetIndexes().Single(); + var annotations = index.GetAnnotations().ToDictionary(a => a.Name, a => a); + var results = generator.GenerateFluentApiCalls(index, annotations); + + var catalogResult = results.Single(r => r.Method == "HasFullTextCatalog"); + Assert.Equal(1, catalogResult.Arguments.Count); + Assert.Equal("MyCatalog", catalogResult.Arguments[0]); + } + + [ConditionalFact] + public void GenerateFluentApi_IIndex_works_with_full_text_change_tracking() + { + var generator = CreateGenerator(); + var modelBuilder = SqlServerConventionSetBuilder.CreateModelBuilder(); + modelBuilder.Entity( + "Post", + x => + { + x.Property<int>("Id"); + x.Property<string>("Title"); + x.HasFullTextIndex("Title").HasKeyIndex("PK_Post").WithChangeTracking(FullTextChangeTracking.Manual); + }); + + var index = (IIndex)modelBuilder.Model.FindEntityType("Post")!.GetIndexes().Single(); + var annotations = index.GetAnnotations().ToDictionary(a => a.Name, a => a); + var results = generator.GenerateFluentApiCalls(index, annotations); + + var changeTrackingResult = results.Single(r => r.Method == "HasFullTextChangeTracking"); + Assert.Equal(1, changeTrackingResult.Arguments.Count); + Assert.Equal(FullTextChangeTracking.Manual, changeTrackingResult.Arguments[0]); + } + + [ConditionalFact] + public void GenerateFluentApi_IIndex_works_with_full_text_languages() + { + var generator = CreateGenerator(); + var modelBuilder = SqlServerConventionSetBuilder.CreateModelBuilder(); + modelBuilder.Entity( + "Post", + x => + { + x.Property<int>("Id"); + x.Property<string>("Title"); + x.Property<string>("Body"); + x.HasFullTextIndex("Title", "Body") + .HasKeyIndex("PK_Post") + .HasLanguage("Title", "English") + .HasLanguage("Body", "French"); + }); + + var index = (IIndex)modelBuilder.Model.FindEntityType("Post")!.GetIndexes().Single(); + var annotations = index.GetAnnotations().ToDictionary(a => a.Name, a => a); + var results = generator.GenerateFluentApiCalls(index, annotations); + + var languageResults = results.Where(r => r.Method == "HasFullTextLanguage").ToList(); + Assert.Equal(2, languageResults.Count); + Assert.Contains(languageResults, r => r.Arguments[0] is "Body" && r.Arguments[1] is "French"); + Assert.Contains(languageResults, r => r.Arguments[0] is "Title" && r.Arguments[1] is "English"); + } + + [ConditionalFact] + public void GenerateFluentApi_IModel_works_with_full_text_catalog() + { + var generator = CreateGenerator(); + var modelBuilder = SqlServerConventionSetBuilder.CreateModelBuilder(); + modelBuilder.HasFullTextCatalog("MyCatalog").IsDefault().IsAccentSensitive(false); + + var annotations = modelBuilder.Model.GetAnnotations().ToDictionary(a => a.Name, a => a); + var results = generator.GenerateFluentApiCalls((IModel)modelBuilder.Model, annotations); + + var catalogResult = results.Single(r => r.Method == "HasFullTextCatalog"); + Assert.Equal(1, catalogResult.Arguments.Count); + Assert.Equal("MyCatalog", catalogResult.Arguments[0]); + + var isDefaultChain = catalogResult.ChainedCall; + Assert.NotNull(isDefaultChain); + Assert.Equal("IsDefault", isDefaultChain.Method); + Assert.Equal(0, isDefaultChain.Arguments.Count); + + var accentChain = isDefaultChain.ChainedCall; + Assert.NotNull(accentChain); + Assert.Equal("IsAccentSensitive", accentChain.Method); + Assert.Equal(1, accentChain.Arguments.Count); + Assert.Equal(false, accentChain.Arguments[0]); + } + + [ConditionalFact] + public void GenerateFluentApi_IModel_works_with_full_text_catalog_defaults() + { + var generator = CreateGenerator(); + var modelBuilder = SqlServerConventionSetBuilder.CreateModelBuilder(); + modelBuilder.HasFullTextCatalog("MyCatalog"); + + var annotations = modelBuilder.Model.GetAnnotations().ToDictionary(a => a.Name, a => a); + var results = generator.GenerateFluentApiCalls((IModel)modelBuilder.Model, annotations); + + var catalogResult = results.Single(r => r.Method == "HasFullTextCatalog"); + Assert.Equal(1, catalogResult.Arguments.Count); + Assert.Equal("MyCatalog", catalogResult.Arguments[0]); + + // No chained calls when using defaults + Assert.Null(catalogResult.ChainedCall); + } + [ConditionalFact] public void GenerateFluentApi_IModel_works_with_identity() { diff --git a/test/EFCore.SqlServer.Tests/Infrastructure/SqlServerModelValidatorTest.cs b/test/EFCore.SqlServer.Tests/Infrastructure/SqlServerModelValidatorTest.cs index ba6285db4c4..71fc901f46a 100644 --- a/test/EFCore.SqlServer.Tests/Infrastructure/SqlServerModelValidatorTest.cs +++ b/test/EFCore.SqlServer.Tests/Infrastructure/SqlServerModelValidatorTest.cs @@ -1217,6 +1217,151 @@ public class VectorEntityWithNonVector #endregion Vector + #region Full-text search + + [ConditionalFact] + public virtual void Throws_for_multiple_full_text_indexes_on_same_entity() + { + var modelBuilder = CreateConventionModelBuilder(); + + modelBuilder.Entity<FullTextEntityWithTwoIndexes>( + b => + { + b.HasFullTextIndex(e => e.Title).HasKeyIndex("PK_FullTextEntityWithTwoIndexes"); + b.HasFullTextIndex(e => e.Body).HasKeyIndex("PK_FullTextEntityWithTwoIndexes"); + }); + + VerifyError( + SqlServerStrings.FullTextIndexDuplicateOnTable( + nameof(FullTextEntityWithTwoIndexes)), + modelBuilder); + } + + [ConditionalFact] + public virtual void Throws_for_full_text_index_missing_key_index() + { + var modelBuilder = CreateConventionModelBuilder(); + + modelBuilder.Entity<FullTextEntityValid>(b => + { + var indexBuilder = b.HasIndex(e => e.Title); + // Set the annotation with a null value to simulate a missing KEY INDEX + indexBuilder.Metadata.SetAnnotation(SqlServerAnnotationNames.FullTextIndex, null); + }); + + VerifyError( + SqlServerStrings.FullTextIndexMissingKeyIndex( + "{'Title'}", + nameof(FullTextEntityValid)), + modelBuilder); + } + + [ConditionalFact] + public virtual void Throws_for_full_text_index_on_invalid_column_type() + { + var modelBuilder = CreateConventionModelBuilder(); + + modelBuilder.Entity<FullTextEntityWithIntColumn>( + b => b.HasFullTextIndex(e => e.Count).HasKeyIndex("PK_FullTextEntityWithIntColumn")); + + VerifyError( + SqlServerStrings.FullTextIndexOnInvalidColumn( + "{'Count'}", + nameof(FullTextEntityWithIntColumn), + nameof(FullTextEntityWithIntColumn.Count)), + modelBuilder); + } + + [ConditionalFact] + public virtual void Does_not_throw_for_full_text_index_on_string_column() + { + var modelBuilder = CreateConventionModelBuilder(); + + modelBuilder.Entity<FullTextEntityValid>( + b => b.HasFullTextIndex(e => e.Title).HasKeyIndex("PK_FullTextEntityValid")); + + Validate(modelBuilder); + } + + [ConditionalFact] + public virtual void Does_not_throw_for_full_text_index_on_byte_array_column() + { + var modelBuilder = CreateConventionModelBuilder(); + + modelBuilder.Entity<FullTextEntityWithBinary>( + b => b.HasFullTextIndex(e => e.Document).HasKeyIndex("PK_FullTextEntityWithBinary")); + + Validate(modelBuilder); + } + + [ConditionalFact] + public virtual void Does_not_throw_for_full_text_index_on_mixed_string_and_binary_columns() + { + var modelBuilder = CreateConventionModelBuilder(); + + modelBuilder.Entity<FullTextEntityWithMixedColumns>( + b => b.HasFullTextIndex(e => new { e.Title, e.Document }).HasKeyIndex("PK_FullTextEntityWithMixedColumns")); + + Validate(modelBuilder); + } + + [ConditionalFact] + public virtual void Throws_for_full_text_index_with_mixed_valid_and_invalid_columns() + { + var modelBuilder = CreateConventionModelBuilder(); + + modelBuilder.Entity<FullTextEntityWithMixedValidInvalid>( + b => b.HasFullTextIndex(e => new { e.Title, e.Count }).HasKeyIndex("PK_FullTextEntityWithMixedValidInvalid")); + + VerifyError( + SqlServerStrings.FullTextIndexOnInvalidColumn( + "{'Title', 'Count'}", + nameof(FullTextEntityWithMixedValidInvalid), + nameof(FullTextEntityWithMixedValidInvalid.Count)), + modelBuilder); + } + + public class FullTextEntityWithTwoIndexes + { + public int Id { get; set; } + public string Title { get; set; } + public string Body { get; set; } + } + + public class FullTextEntityWithIntColumn + { + public int Id { get; set; } + public int Count { get; set; } + } + + public class FullTextEntityValid + { + public int Id { get; set; } + public string Title { get; set; } + } + + public class FullTextEntityWithBinary + { + public int Id { get; set; } + public byte[] Document { get; set; } + } + + public class FullTextEntityWithMixedColumns + { + public int Id { get; set; } + public string Title { get; set; } + public byte[] Document { get; set; } + } + + public class FullTextEntityWithMixedValidInvalid + { + public int Id { get; set; } + public string Title { get; set; } + public int Count { get; set; } + } + + #endregion Full-text search + public class Human { public int Id { get; set; }