From 9a0236c292b74b01aee9d93d6399860462f14f00 Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Sat, 16 Jan 2021 12:13:03 +0100 Subject: [PATCH] Support for SQL Server sparse columns Closes #8023 --- .../SqlServerAnnotationCodeGenerator.cs | 117 ++++++++++-------- .../SqlServerPropertyBuilderExtensions.cs | 72 +++++++++++ .../Extensions/SqlServerPropertyExtensions.cs | 44 +++++++ .../Internal/SqlServerModelValidator.cs | 12 ++ .../Internal/SqlServerAnnotationNames.cs | 28 +++-- .../Internal/SqlServerAnnotationProvider.cs | 53 ++++---- .../SqlServerMigrationsSqlGenerator.cs | 35 ++++-- .../Properties/SqlServerStrings.Designer.cs | 8 ++ .../Properties/SqlServerStrings.resx | 3 + .../Internal/SqlServerDatabaseModelFactory.cs | 9 +- .../Migrations/MigrationsSqlServerTest.cs | 44 +++++++ .../SqlServerDatabaseModelFactoryTest.cs | 24 +++- .../SqlServerAnnotationCodeGeneratorTest.cs | 32 +++++ .../SqlServerModelValidatorTest.cs | 24 ++++ 14 files changed, 404 insertions(+), 101 deletions(-) diff --git a/src/EFCore.SqlServer/Design/Internal/SqlServerAnnotationCodeGenerator.cs b/src/EFCore.SqlServer/Design/Internal/SqlServerAnnotationCodeGenerator.cs index ee75e9981d6..a4a39b55a58 100644 --- a/src/EFCore.SqlServer/Design/Internal/SqlServerAnnotationCodeGenerator.cs +++ b/src/EFCore.SqlServer/Design/Internal/SqlServerAnnotationCodeGenerator.cs @@ -41,9 +41,16 @@ public SqlServerAnnotationCodeGenerator([NotNull] AnnotationCodeGeneratorDepende public override IReadOnlyList GenerateFluentApiCalls( IModel model, IDictionary annotations) - => base.GenerateFluentApiCalls(model, annotations) - .Concat(GenerateValueGenerationStrategy(annotations, onModel: true)) - .ToList(); + { + var fragments = new List(base.GenerateFluentApiCalls(model, annotations)); + + if (GenerateValueGenerationStrategy(annotations, onModel: true) is MethodCallCodeFragment valueGenerationStrategy) + { + fragments.Add(valueGenerationStrategy); + } + + return fragments; + } /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -54,9 +61,24 @@ public override IReadOnlyList GenerateFluentApiCalls( public override IReadOnlyList GenerateFluentApiCalls( IProperty property, IDictionary annotations) - => base.GenerateFluentApiCalls(property, annotations) - .Concat(GenerateValueGenerationStrategy(annotations, onModel: false)) - .ToList(); + { + var fragments = new List(base.GenerateFluentApiCalls(property, annotations)); + + if (GenerateValueGenerationStrategy(annotations, onModel: false) is MethodCallCodeFragment valueGenerationStrategy) + { + fragments.Add(valueGenerationStrategy); + } + + if (GetAndRemove(annotations, SqlServerAnnotationNames.Sparse) is bool isSparse) + { + fragments.Add( + isSparse + ? new(nameof(SqlServerPropertyBuilderExtensions.IsSparse)) + : new(nameof(SqlServerPropertyBuilderExtensions.IsSparse), false)); + } + + return fragments; + } /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -113,7 +135,7 @@ protected override MethodCallCodeFragment GenerateFluentApi(IIndex index, IAnnot _ => null }; - private IReadOnlyList GenerateValueGenerationStrategy( + private MethodCallCodeFragment GenerateValueGenerationStrategy( IDictionary annotations, bool onModel) { @@ -126,67 +148,58 @@ private IReadOnlyList GenerateValueGenerationStrategy( } else { - return Array.Empty(); + return null; } switch (strategy) { case SqlServerValueGenerationStrategy.IdentityColumn: - var seed = GetAndRemove(SqlServerAnnotationNames.IdentitySeed) ?? 1; - var increment = GetAndRemove(SqlServerAnnotationNames.IdentityIncrement) ?? 1; - return new List - { - new( - onModel - ? nameof(SqlServerModelBuilderExtensions.UseIdentityColumns) - : nameof(SqlServerPropertyBuilderExtensions.UseIdentityColumn), - (seed, increment) switch - { - (1, 1) => Array.Empty(), - (_, 1) => new object[] { seed }, - _ => new object[] { seed, increment } - }) - }; + var seed = GetAndRemove(annotations, SqlServerAnnotationNames.IdentitySeed) ?? 1; + var increment = GetAndRemove(annotations, SqlServerAnnotationNames.IdentityIncrement) ?? 1; + return new( + onModel + ? nameof(SqlServerModelBuilderExtensions.UseIdentityColumns) + : nameof(SqlServerPropertyBuilderExtensions.UseIdentityColumn), + (seed, increment) switch + { + (1, 1) => Array.Empty(), + (_, 1) => new object[] { seed }, + _ => new object[] { seed, increment } + }); case SqlServerValueGenerationStrategy.SequenceHiLo: - var name = GetAndRemove(SqlServerAnnotationNames.HiLoSequenceName); - var schema = GetAndRemove(SqlServerAnnotationNames.HiLoSequenceSchema); - return new List - { - new( - nameof(SqlServerModelBuilderExtensions.UseHiLo), - (name, schema) switch - { - (null, null) => Array.Empty(), - (_, null) => new object[] { name }, - _ => new object[] { name, schema } - }) - }; + var name = GetAndRemove(annotations, SqlServerAnnotationNames.HiLoSequenceName); + var schema = GetAndRemove(annotations, SqlServerAnnotationNames.HiLoSequenceSchema); + return new( + nameof(SqlServerModelBuilderExtensions.UseHiLo), + (name, schema) switch + { + (null, null) => Array.Empty(), + (_, null) => new object[] { name }, + _ => new object[] { name, schema } + }); case SqlServerValueGenerationStrategy.None: - return new List - { - new( - nameof(ModelBuilder.HasAnnotation), - SqlServerAnnotationNames.ValueGenerationStrategy, - SqlServerValueGenerationStrategy.None) - }; + return new( + nameof(ModelBuilder.HasAnnotation), + SqlServerAnnotationNames.ValueGenerationStrategy, + SqlServerValueGenerationStrategy.None); default: throw new ArgumentOutOfRangeException(); } + } - T GetAndRemove(string annotationName) + private static T GetAndRemove(IDictionary annotations, string annotationName) + { + if (annotations.TryGetValue(annotationName, out var annotation) + && annotation.Value != null) { - if (annotations.TryGetValue(annotationName, out var annotation) - && annotation.Value != null) - { - annotations.Remove(annotationName); - return (T)annotation.Value; - } - - return default; + annotations.Remove(annotationName); + return (T)annotation.Value; } + + return default; } } } diff --git a/src/EFCore.SqlServer/Extensions/SqlServerPropertyBuilderExtensions.cs b/src/EFCore.SqlServer/Extensions/SqlServerPropertyBuilderExtensions.cs index 87b10a7cf72..6994b4ab519 100644 --- a/src/EFCore.SqlServer/Extensions/SqlServerPropertyBuilderExtensions.cs +++ b/src/EFCore.SqlServer/Extensions/SqlServerPropertyBuilderExtensions.cs @@ -294,5 +294,77 @@ public static bool CanSetValueGenerationStrategy( && propertyBuilder.CanSetAnnotation( SqlServerAnnotationNames.ValueGenerationStrategy, valueGenerationStrategy, fromDataAnnotation); } + + /// + /// Configures whether the property's column is created as a sparse column when targeting SQL Server. + /// + /// The builder for the property being configured. + /// A value indicating whether the property's column is created as a sparse column. + /// A builder to further configure the property. + /// See https://docs.microsoft.com/sql/relational-databases/tables/use-sparse-columns. + public static PropertyBuilder IsSparse([NotNull] this PropertyBuilder propertyBuilder, bool sparse = true) + { + Check.NotNull(propertyBuilder, nameof(propertyBuilder)); + + propertyBuilder.Metadata.SetIsSparse(sparse); + + return propertyBuilder; + } + + /// + /// Configures whether the property's column is created as a sparse column when targeting SQL Server. + /// + /// The builder for the index being configured. + /// A value indicating whether the index is created with online option. + /// A builder to further configure the index. + /// See https://docs.microsoft.com/sql/relational-databases/tables/use-sparse-columns. + public static PropertyBuilder IsSparse( + [NotNull] this PropertyBuilder propertyBuilder, + bool sparse = true) + => (PropertyBuilder)IsSparse((PropertyBuilder)propertyBuilder, sparse); + + /// + /// Configures whether the property's column is created as a sparse column when targeting SQL Server. + /// + /// The builder for the property being configured. + /// A value indicating whether the property's column is created as a sparse column. + /// Indicates whether the configuration was specified using a data annotation. + /// The same builder instance if the configuration was applied, otherwise. + /// See https://docs.microsoft.com/sql/relational-databases/tables/use-sparse-columns. + public static IConventionPropertyBuilder IsSparse( + [NotNull] this IConventionPropertyBuilder propertyBuilder, + bool? sparse, + bool fromDataAnnotation = false) + { + if (propertyBuilder.CanSetIsSparse(sparse, fromDataAnnotation)) + { + propertyBuilder.Metadata.SetIsSparse(sparse, fromDataAnnotation); + + return propertyBuilder; + } + + return null; + } + + /// + /// Returns a value indicating whether the property's column can be configured as a sparse column when targeting SQL Server. + /// + /// The builder for the property being configured. + /// A value indicating whether the property's column is created as a sparse column. + /// Indicates whether the configuration was specified using a data annotation. + /// The same builder instance if the configuration was applied, otherwise. + /// + /// if the property's column can be configured as a sparse column when targeting SQL Server. + /// + /// See https://docs.microsoft.com/sql/relational-databases/tables/use-sparse-columns. + public static bool CanSetIsSparse( + [NotNull] this IConventionPropertyBuilder property, + bool? sparse, + bool fromDataAnnotation = false) + { + Check.NotNull(property, nameof(property)); + + return property.CanSetAnnotation(SqlServerAnnotationNames.Sparse, sparse, fromDataAnnotation); + } } } diff --git a/src/EFCore.SqlServer/Extensions/SqlServerPropertyExtensions.cs b/src/EFCore.SqlServer/Extensions/SqlServerPropertyExtensions.cs index 4de01863479..67b9f8aef5d 100644 --- a/src/EFCore.SqlServer/Extensions/SqlServerPropertyExtensions.cs +++ b/src/EFCore.SqlServer/Extensions/SqlServerPropertyExtensions.cs @@ -489,5 +489,49 @@ public static bool IsCompatibleWithValueGeneration([NotNull] IProperty property) ?? property.FindTypeMapping()?.Converter) == null; } + + /// + /// Returns a value indicating whether the property's column is sparse. + /// + /// The property. + /// if the property's column is sparse. + public static bool? IsSparse([NotNull] this IProperty property) + => (bool?)property[SqlServerAnnotationNames.Sparse]; + + /// + /// Sets a value indicating whether the property's columns is sparse. + /// + /// The property. + /// The value to set. + public static void SetIsSparse([NotNull] this IMutableProperty property, bool? sparse) + => property.SetOrRemoveAnnotation(SqlServerAnnotationNames.Sparse, sparse); + + /// + /// Sets a value indicating whether the property's column is sparse. + /// + /// The property. + /// The value to set. + /// Indicates whether the configuration was specified using a data annotation. + /// The configured value. + public static bool? SetIsSparse( + [NotNull] this IConventionProperty property, + bool? sparse, + bool fromDataAnnotation = false) + { + property.SetOrRemoveAnnotation( + SqlServerAnnotationNames.Sparse, + sparse, + fromDataAnnotation); + + return sparse; + } + + /// + /// Returns the for whether the property's column is sparse. + /// + /// The property. + /// The for whether the property's column is sparse. + public static ConfigurationSource? GetIsSparseConfigurationSource([NotNull] this IConventionProperty property) + => property.FindAnnotation(SqlServerAnnotationNames.Sparse)?.GetConfigurationSource(); } } diff --git a/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs b/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs index 39a0886f218..7c90858e935 100644 --- a/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs +++ b/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs @@ -363,6 +363,18 @@ protected override void ValidateCompatible( break; } } + + if (property.IsSparse() != duplicateProperty.IsSparse()) + { + throw new InvalidOperationException( + SqlServerStrings.DuplicateColumnSparsenessMismatch( + duplicateProperty.DeclaringEntityType.DisplayName(), + duplicateProperty.Name, + property.DeclaringEntityType.DisplayName(), + property.Name, + columnName, + storeObject.DisplayName())); + } } /// diff --git a/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationNames.cs b/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationNames.cs index f647aad30c9..7649dd54a24 100644 --- a/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationNames.cs +++ b/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationNames.cs @@ -35,7 +35,7 @@ public static class SqlServerAnnotationNames /// 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 Include = Prefix + "Include"; + public const string CreatedOnline = Prefix + "Online"; /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -43,7 +43,7 @@ public static class SqlServerAnnotationNames /// 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 CreatedOnline = Prefix + "Online"; + public const string EditionOptions = Prefix + "EditionOptions"; /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -51,7 +51,7 @@ public static class SqlServerAnnotationNames /// 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 ValueGenerationStrategy = Prefix + "ValueGenerationStrategy"; + public const string FillFactor = Prefix + "FillFactor"; /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -75,7 +75,7 @@ public static class SqlServerAnnotationNames /// 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 MemoryOptimized = Prefix + "MemoryOptimized"; + public const string Identity = Prefix + "Identity"; /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -83,7 +83,7 @@ public static class SqlServerAnnotationNames /// 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 Identity = Prefix + "Identity"; + public const string IdentityIncrement = Prefix + "IdentityIncrement"; /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -99,7 +99,7 @@ public static class SqlServerAnnotationNames /// 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 IdentityIncrement = Prefix + "IdentityIncrement"; + public const string Include = Prefix + "Include"; /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -107,7 +107,7 @@ public static class SqlServerAnnotationNames /// 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 EditionOptions = Prefix + "EditionOptions"; + public const string MaxDatabaseSize = Prefix + "DatabaseMaxSize"; /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -115,7 +115,15 @@ public static class SqlServerAnnotationNames /// 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 MaxDatabaseSize = Prefix + "DatabaseMaxSize"; + public const string MemoryOptimized = Prefix + "MemoryOptimized"; + + /// + /// 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 PerformanceLevelSql = Prefix + "PerformanceLevelSql"; /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -131,7 +139,7 @@ public static class SqlServerAnnotationNames /// 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 PerformanceLevelSql = Prefix + "PerformanceLevelSql"; + public const string Sparse = Prefix + "Sparse"; /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -139,6 +147,6 @@ public static class SqlServerAnnotationNames /// 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 FillFactor = Prefix + "FillFactor"; + public const string ValueGenerationStrategy = Prefix + "ValueGenerationStrategy"; } } diff --git a/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs b/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs index 0f39713cd6c..2f370264822 100644 --- a/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs +++ b/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs @@ -83,9 +83,7 @@ public override IEnumerable For(IRelationalModel model) if (model.Tables.Any(t => !t.IsExcludedFromMigrations && (t[SqlServerAnnotationNames.MemoryOptimized] as bool? == true))) { - yield return new Annotation( - SqlServerAnnotationNames.MemoryOptimized, - true); + yield return new Annotation(SqlServerAnnotationNames.MemoryOptimized, true); } } @@ -100,9 +98,7 @@ public override IEnumerable For(ITable table) // Model validation ensures that these facets are the same on all mapped entity types if (table.EntityTypeMappings.First().EntityType.IsMemoryOptimized()) { - yield return new Annotation( - SqlServerAnnotationNames.MemoryOptimized, - true); + yield return new Annotation(SqlServerAnnotationNames.MemoryOptimized, true); } } @@ -118,12 +114,10 @@ public override IEnumerable For(IUniqueConstraint constraint) var key = constraint.MappedKeys.First(); var table = constraint.Table; - var isClustered = key.IsClustered(StoreObjectIdentifier.Table(table.Name, table.Schema)); - if (isClustered.HasValue) + + if (key.IsClustered(StoreObjectIdentifier.Table(table.Name, table.Schema)) is bool isClustered) { - yield return new Annotation( - SqlServerAnnotationNames.Clustered, - isClustered.Value); + yield return new Annotation(SqlServerAnnotationNames.Clustered, isClustered); } } @@ -139,12 +133,10 @@ public override IEnumerable For(ITableIndex index) var modelIndex = index.MappedIndexes.First(); var table = index.Table; - var isClustered = modelIndex.IsClustered(StoreObjectIdentifier.Table(table.Name, table.Schema)); - if (isClustered.HasValue) + + if (modelIndex.IsClustered(StoreObjectIdentifier.Table(table.Name, table.Schema)) is bool isClustered) { - yield return new Annotation( - SqlServerAnnotationNames.Clustered, - isClustered.Value); + yield return new Annotation(SqlServerAnnotationNames.Clustered, isClustered); } var includeProperties = modelIndex.GetIncludeProperties(); @@ -161,20 +153,14 @@ public override IEnumerable For(ITableIndex index) includeColumns); } - var isOnline = modelIndex.IsCreatedOnline(); - if (isOnline.HasValue) + if (modelIndex.IsCreatedOnline() is bool isOnline) { - yield return new Annotation( - SqlServerAnnotationNames.CreatedOnline, - isOnline.Value); + yield return new Annotation(SqlServerAnnotationNames.CreatedOnline, isOnline); } - var fillFactor = modelIndex.GetFillFactor(); - if (fillFactor.HasValue) + if (modelIndex.GetFillFactor() is int fillFactor) { - yield return new Annotation( - SqlServerAnnotationNames.FillFactor, - fillFactor.Value); + yield return new Annotation(SqlServerAnnotationNames.FillFactor, fillFactor); } } @@ -187,22 +173,29 @@ public override IEnumerable For(ITableIndex index) public override IEnumerable For(IColumn column) { var table = StoreObjectIdentifier.Table(column.Table.Name, column.Table.Schema); - var property = column.PropertyMappings.Where( + var identityProperty = column.PropertyMappings.Where( m => m.TableMapping.IsSharedTablePrincipal && m.TableMapping.EntityType == m.Property.DeclaringEntityType) .Select(m => m.Property) .FirstOrDefault( p => p.GetValueGenerationStrategy(table) == SqlServerValueGenerationStrategy.IdentityColumn); - if (property != null) + if (identityProperty != null) { - var seed = property.GetIdentitySeed(table); - var increment = property.GetIdentityIncrement(table); + var seed = identityProperty.GetIdentitySeed(table); + var increment = identityProperty.GetIdentityIncrement(table); yield return new Annotation( SqlServerAnnotationNames.Identity, string.Format(CultureInfo.InvariantCulture, "{0}, {1}", seed ?? 1, increment ?? 1)); } + + // Model validation ensures that these facets are the same on all mapped properties + var property = column.PropertyMappings.First().Property; + if (property.IsSparse() is bool isSparse) + { + yield return new Annotation(SqlServerAnnotationNames.Sparse, isSparse); + } } } } diff --git a/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs b/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs index 62bef11a845..19f56523fe0 100644 --- a/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs +++ b/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs @@ -1463,13 +1463,34 @@ protected override void ColumnDefinition( Check.NotNull(operation, nameof(operation)); Check.NotNull(builder, nameof(builder)); - base.ColumnDefinition( - schema, - table, - name, - operation, - model, - builder); + if (operation.ComputedColumnSql != null) + { + ComputedColumnDefinition(schema, table, name, operation, model, builder); + + return; + } + + var columnType = operation.ColumnType ?? GetColumnType(schema, table, name, operation, model); + builder + .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(name)) + .Append(" ") + .Append(columnType); + + if (operation.Collation != null) + { + builder + .Append(" COLLATE ") + .Append(operation.Collation); + } + + if (operation[SqlServerAnnotationNames.Sparse] is bool isSparse && isSparse) + { + builder.Append(" SPARSE"); + } + + builder.Append(operation.IsNullable ? " NULL" : " NOT NULL"); + + DefaultValue(operation.DefaultValue, operation.DefaultValueSql, columnType, builder); var identity = operation[SqlServerAnnotationNames.Identity] as string; if (identity != null diff --git a/src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs b/src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs index a555f7c81aa..f50d610ca5f 100644 --- a/src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs +++ b/src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs @@ -75,6 +75,14 @@ public static string DuplicateColumnSequenceMismatch([CanBeNull] object? entityT GetString("DuplicateColumnSequenceMismatch", nameof(entityType1), nameof(property1), nameof(entityType2), nameof(property2), nameof(columnName), nameof(table)), entityType1, property1, entityType2, property2, columnName, table); + /// + /// '{entityType1}.{property1}' and '{entityType2}.{property2}' are both mapped to column '{columnName}' in '{table}', but are configured with different sparseness. + /// + public static string DuplicateColumnSparsenessMismatch([CanBeNull] object? entityType1, [CanBeNull] object? property1, [CanBeNull] object? entityType2, [CanBeNull] object? property2, [CanBeNull] object? columnName, [CanBeNull] object? table) + => string.Format( + GetString("DuplicateColumnSparsenessMismatch", nameof(entityType1), nameof(property1), nameof(entityType2), nameof(property2), nameof(columnName), nameof(table)), + entityType1, property1, entityType2, property2, columnName, table); + /// /// The indexes {index1} on '{entityType1}' and {index2} on '{entityType2}' are both mapped to '{table}.{indexName}', but have different clustered configurations. /// diff --git a/src/EFCore.SqlServer/Properties/SqlServerStrings.resx b/src/EFCore.SqlServer/Properties/SqlServerStrings.resx index df7acefb11b..a657153bd6a 100644 --- a/src/EFCore.SqlServer/Properties/SqlServerStrings.resx +++ b/src/EFCore.SqlServer/Properties/SqlServerStrings.resx @@ -138,6 +138,9 @@ '{entityType1}.{property1}' and '{entityType2}.{property2}' are both mapped to column '{columnName}' in '{table}', but are configured with different hi-lo sequences. + + '{entityType1}.{property1}' and '{entityType2}.{property2}' are both mapped to column '{columnName}' in '{table}', but are configured with different sparseness. + The indexes {index1} on '{entityType1}' and {index2} on '{entityType2}' are both mapped to '{table}.{indexName}', but have different clustered configurations. diff --git a/src/EFCore.SqlServer/Scaffolding/Internal/SqlServerDatabaseModelFactory.cs b/src/EFCore.SqlServer/Scaffolding/Internal/SqlServerDatabaseModelFactory.cs index 4bcd923c989..fe3c02addb3 100644 --- a/src/EFCore.SqlServer/Scaffolding/Internal/SqlServerDatabaseModelFactory.cs +++ b/src/EFCore.SqlServer/Scaffolding/Internal/SqlServerDatabaseModelFactory.cs @@ -631,7 +631,8 @@ private void GetColumns( [cc].[definition] AS [computed_sql], [cc].[is_persisted] AS [computed_is_persisted], CAST([e].[value] AS nvarchar(MAX)) AS [comment], - [c].[collation_name] + [c].[collation_name], + [c].[is_sparse] FROM ( SELECT[v].[name], [v].[object_id], [v].[schema_id] @@ -693,6 +694,7 @@ UNION ALL var computedIsPersisted = dataRecord.GetValueOrDefault("computed_is_persisted"); var comment = dataRecord.GetValueOrDefault("comment"); var collation = dataRecord.GetValueOrDefault("collation_name"); + var isSparse = dataRecord.GetValueOrDefault("is_sparse"); _logger.ColumnFound( DisplayName(tableSchema, tableName), @@ -751,6 +753,11 @@ UNION ALL column["ConcurrencyToken"] = true; } + if (isSparse) + { + column[SqlServerAnnotationNames.Sparse] = true; + } + table.Columns.Add(column); } } diff --git a/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs index 6d772b461e9..bcc7f3e13a8 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs @@ -129,6 +129,25 @@ [Y] int NOT NULL );"); } + [ConditionalFact] + public virtual async Task Create_table_with_sparse_column() + { + await Test( + _ => { }, + builder => builder.Entity("People", e => e.Property("SomeProperty").IsSparse()), + model => + { + var table = Assert.Single(model.Tables); + var column = Assert.Single(table.Columns, c => c.Name == "SomeProperty"); + Assert.True((bool?)column[SqlServerAnnotationNames.Sparse]); + }); + + AssertSql( + @"CREATE TABLE [People] ( + [SomeProperty] nvarchar(max) SPARSE NULL +);"); + } + public override async Task Drop_table() { await base.Drop_table(); @@ -945,6 +964,31 @@ FROM [sys].[default_constraints] [d] ALTER TABLE [People] ADD DEFAULT N'Doe' FOR [Name];"); } + [ConditionalFact] + public virtual async Task Alter_column_make_sparse() + { + await Test( + builder => builder.Entity("People").Property("SomeProperty"), + builder => { }, + builder => builder.Entity("People").Property("SomeProperty") + .IsSparse(), + model => + { + var column = Assert.Single(Assert.Single(model.Tables).Columns); + Assert.True((bool?)column[SqlServerAnnotationNames.Sparse]); + }); + + AssertSql( + @"DECLARE @var0 sysname; +SELECT @var0 = [d].[name] +FROM [sys].[default_constraints] [d] +INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] +WHERE ([d].[parent_object_id] = OBJECT_ID(N'[People]') AND [c].[name] = N'SomeProperty'); +IF @var0 IS NOT NULL EXEC(N'ALTER TABLE [People] DROP CONSTRAINT [' + @var0 + '];'); +ALTER TABLE [People] ALTER COLUMN [SomeProperty] nvarchar(max) SPARSE NULL;"); + } + + public override async Task Drop_column() { await base.Drop_column(); diff --git a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/SqlServerDatabaseModelFactoryTest.cs b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/SqlServerDatabaseModelFactoryTest.cs index 7873412fcf3..4de521b5c6d 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/SqlServerDatabaseModelFactoryTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/SqlServerDatabaseModelFactoryTest.cs @@ -148,7 +148,7 @@ public void Sequence_high_min_max_start_values_are_not_null_if_decimal() { Test( @" -CREATE SEQUENCE [dbo].[HighDecimalSequence] +CREATE SEQUENCE [dbo].[HighDecimalSequence] AS [numeric](38, 0) START WITH -99999999999999999999999999999999999999 INCREMENT BY 1 @@ -1555,6 +1555,28 @@ NonDefaultCollation nvarchar(max) COLLATE German_PhoneBook_CI_AS, "DROP TABLE ColumnsWithCollation;"); } + [ConditionalFact] + public void Column_sparseness_is_set() + { + Test( + @" +CREATE TABLE ColumnsWithSparseness ( + Id int, + Sparse nvarchar(max) SPARSE NULL, + NonSparse nvarchar(max) NULL +);", + Enumerable.Empty(), + Enumerable.Empty(), + dbModel => + { + var columns = dbModel.Tables.Single().Columns; + + Assert.True((bool)columns.Single(c => c.Name == "Sparse")[SqlServerAnnotationNames.Sparse]); + Assert.Null(columns.Single(c => c.Name == "NonSparse")[SqlServerAnnotationNames.Sparse]); + }, + "DROP TABLE ColumnsWithSparseness;"); + } + [ConditionalFact] [SqlServerCondition(SqlServerCondition.SupportsHiddenColumns)] public void Hidden_columns_are_not_created() diff --git a/test/EFCore.SqlServer.Tests/Design/Internal/SqlServerAnnotationCodeGeneratorTest.cs b/test/EFCore.SqlServer.Tests/Design/Internal/SqlServerAnnotationCodeGeneratorTest.cs index a9190f2318d..c399d0adcb2 100644 --- a/test/EFCore.SqlServer.Tests/Design/Internal/SqlServerAnnotationCodeGeneratorTest.cs +++ b/test/EFCore.SqlServer.Tests/Design/Internal/SqlServerAnnotationCodeGeneratorTest.cs @@ -2,7 +2,9 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections.Generic; using System.Linq; +using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Metadata.Conventions; using Microsoft.EntityFrameworkCore.SqlServer.Design.Internal; using Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal; @@ -230,6 +232,36 @@ public void GenerateFluentApi_IProperty_works_with_HiLo() schema => Assert.Equal("HiLoIndexSchema", schema)); } + [ConditionalFact] + public void GenerateFluentApi_IProperty_works_with_IsSparse() + { + var generator = CreateGenerator(); + var modelBuilder = SqlServerConventionSetBuilder.CreateModelBuilder(); + modelBuilder.Entity("SomeEntity", x => + { + x.Property("Default"); + x.Property("Sparse").IsSparse(); + x.Property("NonSparse").IsSparse(false); + }); + + Assert.Null(GenerateFluentApiCall("SomeEntity", "Default")); + + var sparseCall = GenerateFluentApiCall("SomeEntity", "Sparse"); + Assert.Equal("IsSparse", sparseCall.Method); + Assert.Empty(sparseCall.Arguments); + + var nonSparseCall = GenerateFluentApiCall("SomeEntity", "NonSparse"); + Assert.Equal("IsSparse", nonSparseCall.Method); + Assert.Collection(nonSparseCall.Arguments, o => Assert.False((bool)o)); + + MethodCallCodeFragment GenerateFluentApiCall(string entityTypeName, string propertyName) + { + var property = modelBuilder.Model.FindEntityType(entityTypeName).FindProperty(propertyName); + var annotations = property.GetAnnotations().ToDictionary(a => a.Name, a => a); + return generator.GenerateFluentApiCalls(property, annotations).SingleOrDefault(); + } + } + private SqlServerAnnotationCodeGenerator CreateGenerator() => new( new AnnotationCodeGeneratorDependencies( diff --git a/test/EFCore.SqlServer.Tests/Infrastructure/SqlServerModelValidatorTest.cs b/test/EFCore.SqlServer.Tests/Infrastructure/SqlServerModelValidatorTest.cs index 5644face999..4095be71598 100644 --- a/test/EFCore.SqlServer.Tests/Infrastructure/SqlServerModelValidatorTest.cs +++ b/test/EFCore.SqlServer.Tests/Infrastructure/SqlServerModelValidatorTest.cs @@ -213,6 +213,30 @@ public virtual void Detects_duplicate_column_names_within_hierarchy_with_differe modelBuilder.Model); } + [ConditionalFact] + public virtual void Detects_duplicate_column_names_within_hierarchy_with_different_sparseness() + { + var modelBuilder = CreateConventionalModelBuilder(); + modelBuilder.Entity(); + modelBuilder.Entity( + cb => + { + cb.ToTable("Animal"); + cb.Property(c => c.Breed).HasColumnName(nameof(Cat.Breed)).IsSparse(); + }); + modelBuilder.Entity( + db => + { + db.ToTable("Animal"); + db.Property(d => d.Breed).HasColumnName(nameof(Dog.Breed)); + }); + + VerifyError( + SqlServerStrings.DuplicateColumnSparsenessMismatch( + nameof(Cat), nameof(Cat.Breed), nameof(Dog), nameof(Dog.Breed), nameof(Cat.Breed), nameof(Animal)), + modelBuilder.Model); + } + [ConditionalFact] public virtual void Passes_for_incompatible_foreignKeys_within_hierarchy_when_one_name_configured_explicitly_for_sqlServer() {