From 786cb40576d47874549753e6d92fbcf3e65a4f64 Mon Sep 17 00:00:00 2001 From: Andriy Svyryd Date: Wed, 2 Mar 2022 11:10:39 -0800 Subject: [PATCH 01/11] Map check constraints only to the tables for which they are declared Return null name for constraints on entity types not mapped to a table Change calls from Array.Empty to Enumerable.Empty Fixes #24219 --- .../Query/Internal/RandomTranslator.cs | 2 +- .../Design/CSharpSnapshotGenerator.cs | 3 +- .../Internal/CSharpDbContextGenerator.cs | 4 +- .../RelationalEntityTypeExtensions.cs | 14 +-- .../RelationalForeignKeyExtensions.cs | 25 +++- .../Extensions/RelationalIndexExtensions.cs | 22 +++- .../Extensions/RelationalKeyExtensions.cs | 35 +++++- .../Metadata/IMutableCheckConstraint.cs | 2 +- .../Metadata/IReadOnlyCheckConstraint.cs | 8 +- src/EFCore.Relational/Metadata/ITable.cs | 4 +- .../Metadata/Internal/CheckConstraint.cs | 23 +++- .../Metadata/Internal/RelationalModel.cs | 25 +++- .../Metadata/Internal/Table.cs | 18 +++ .../Internal/MigrationsModelDiffer.cs | 2 +- .../Operations/AddCheckConstraintOperation.cs | 2 +- .../Query/Internal/RandomTranslator.cs | 4 +- .../Update/Internal/CommandBatchPreparer.cs | 2 +- ...erverGeometryCollectionMemberTranslator.cs | 4 +- .../SqlServerGeometryMemberTranslator.cs | 8 +- ...qlServerMultiLineStringMemberTranslator.cs | 4 +- .../SqlServerPolygonMemberTranslator.cs | 8 +- .../SqlServerDateTimeMemberTranslator.cs | 12 +- .../Internal/SqlServerNewGuidTranslator.cs | 4 +- .../Query/Internal/SqliteRandomTranslator.cs | 6 +- src/EFCore/DbContext.cs | 4 +- src/EFCore/Metadata/IReadOnlyEntityType.cs | 12 +- src/EFCore/Metadata/Internal/Model.cs | 4 +- src/EFCore/Metadata/RuntimeModel.cs | 2 +- .../Metadata/RelationalModelTest.cs | 114 ++++++++++++++++-- .../SqlServerEndToEndTest.cs | 2 + 30 files changed, 300 insertions(+), 79 deletions(-) diff --git a/src/EFCore.Cosmos/Query/Internal/RandomTranslator.cs b/src/EFCore.Cosmos/Query/Internal/RandomTranslator.cs index 116a52898ab..fefab0714ff 100644 --- a/src/EFCore.Cosmos/Query/Internal/RandomTranslator.cs +++ b/src/EFCore.Cosmos/Query/Internal/RandomTranslator.cs @@ -43,7 +43,7 @@ public virtual SqlExpression Translate( => MethodInfo.Equals(method) ? _sqlExpressionFactory.Function( "RAND", - Array.Empty(), + Enumerable.Empty(), method.ReturnType) : null; } diff --git a/src/EFCore.Design/Migrations/Design/CSharpSnapshotGenerator.cs b/src/EFCore.Design/Migrations/Design/CSharpSnapshotGenerator.cs index 533cb70ebf9..42c13f2477e 100644 --- a/src/EFCore.Design/Migrations/Design/CSharpSnapshotGenerator.cs +++ b/src/EFCore.Design/Migrations/Design/CSharpSnapshotGenerator.cs @@ -931,7 +931,8 @@ protected virtual void GenerateCheckConstraint( .Append(", ") .Append(Code.Literal(checkConstraint.Sql)); - if (checkConstraint.Name != (checkConstraint.GetDefaultName() ?? checkConstraint.ModelName)) + if (checkConstraint.Name != null + && checkConstraint.Name != (checkConstraint.GetDefaultName() ?? checkConstraint.ModelName)) { stringBuilder .Append(", c => c.HasName(") diff --git a/src/EFCore.Design/Scaffolding/Internal/CSharpDbContextGenerator.cs b/src/EFCore.Design/Scaffolding/Internal/CSharpDbContextGenerator.cs index 78e4ff45d77..57459df310e 100644 --- a/src/EFCore.Design/Scaffolding/Internal/CSharpDbContextGenerator.cs +++ b/src/EFCore.Design/Scaffolding/Internal/CSharpDbContextGenerator.cs @@ -558,7 +558,7 @@ private void GenerateIndex(IIndex index) var lines = new List { - $".{nameof(EntityTypeBuilder.HasIndex)}({_code.Lambda(index.Properties, "e")}, {_code.Literal(index.GetDatabaseName())})" + $".{nameof(EntityTypeBuilder.HasIndex)}({_code.Lambda(index.Properties, "e")}, {_code.Literal(index.GetDatabaseName()!)})" }; annotations.Remove(RelationalAnnotationNames.Name); @@ -845,7 +845,7 @@ private void GenerateManyToMany(ISkipNavigation skipNavigation) _annotationCodeGenerator.RemoveAnnotationsHandledByConventions(index, indexAnnotations); lines.Add( - $"j.{nameof(EntityTypeBuilder.HasIndex)}({_code.Literal(index.Properties.Select(e => e.Name).ToArray())}, {_code.Literal(index.GetDatabaseName())})"); + $"j.{nameof(EntityTypeBuilder.HasIndex)}({_code.Literal(index.Properties.Select(e => e.Name).ToArray())}, {_code.Literal(index.GetDatabaseName()!)})"); indexAnnotations.Remove(RelationalAnnotationNames.Name); if (index.IsUnique) diff --git a/src/EFCore.Relational/Extensions/RelationalEntityTypeExtensions.cs b/src/EFCore.Relational/Extensions/RelationalEntityTypeExtensions.cs index 7d609106682..fea3d7d740d 100644 --- a/src/EFCore.Relational/Extensions/RelationalEntityTypeExtensions.cs +++ b/src/EFCore.Relational/Extensions/RelationalEntityTypeExtensions.cs @@ -39,7 +39,7 @@ public static class RelationalEntityTypeExtensions return entityType.GetRootType().GetTableName(); } - return (entityType as IConventionEntityType)?.GetViewNameConfigurationSource() == null + return ((entityType as IConventionEntityType)?.GetViewNameConfigurationSource() == null) && ((entityType as IConventionEntityType)?.GetFunctionNameConfigurationSource() == null) #pragma warning disable CS0618 // Type or member is obsolete && ((entityType as IConventionEntityType)?.GetDefiningQueryConfigurationSource() == null) @@ -254,7 +254,7 @@ public static void SetSchema(this IMutableEntityType entityType, string? value) public static IEnumerable GetDefaultMappings(this IEntityType entityType) => (IEnumerable?)entityType.FindRuntimeAnnotationValue( RelationalAnnotationNames.DefaultMappings) - ?? Array.Empty(); + ?? Enumerable.Empty(); /// /// Returns the tables to which the entity type is mapped. @@ -264,7 +264,7 @@ public static IEnumerable GetDefaultMappings(this IEntityType public static IEnumerable GetTableMappings(this IEntityType entityType) => (IEnumerable?)entityType.FindRuntimeAnnotationValue( RelationalAnnotationNames.TableMappings) - ?? Array.Empty(); + ?? Enumerable.Empty(); /// /// Returns the name of the view to which the entity type is mapped or if not mapped to a view. @@ -286,7 +286,7 @@ public static IEnumerable GetTableMappings(this IEntityType entit return ((entityType as IConventionEntityType)?.GetFunctionNameConfigurationSource() == null) #pragma warning disable CS0618 // Type or member is obsolete - && (entityType as IConventionEntityType)?.GetDefiningQueryConfigurationSource() == null + && ((entityType as IConventionEntityType)?.GetDefiningQueryConfigurationSource() == null) #pragma warning restore CS0618 // Type or member is obsolete && ((entityType as IConventionEntityType)?.GetSqlQueryConfigurationSource() == null) ? GetDefaultViewName(entityType) @@ -428,7 +428,7 @@ public static void SetViewSchema(this IMutableEntityType entityType, string? val public static IEnumerable GetViewMappings(this IEntityType entityType) => (IEnumerable?)entityType.FindRuntimeAnnotationValue( RelationalAnnotationNames.ViewMappings) - ?? Array.Empty(); + ?? Enumerable.Empty(); /// /// Gets the default SQL query name that would be used for this entity type when mapped using @@ -493,7 +493,7 @@ public static void SetSqlQuery(this IMutableEntityType entityType, string? name) public static IEnumerable GetSqlQueryMappings(this IEntityType entityType) => (IEnumerable?)entityType.FindRuntimeAnnotationValue( RelationalAnnotationNames.SqlQueryMappings) - ?? Array.Empty(); + ?? Enumerable.Empty(); /// /// Returns the name of the function to which the entity type is mapped or if not mapped to a function. @@ -549,7 +549,7 @@ public static void SetFunctionName(this IMutableEntityType entityType, string? n public static IEnumerable GetFunctionMappings(this IEntityType entityType) => (IEnumerable?)entityType.FindRuntimeAnnotationValue( RelationalAnnotationNames.FunctionMappings) - ?? Array.Empty(); + ?? Enumerable.Empty(); /// /// Finds an with the given name. diff --git a/src/EFCore.Relational/Extensions/RelationalForeignKeyExtensions.cs b/src/EFCore.Relational/Extensions/RelationalForeignKeyExtensions.cs index 1be45e2ac3b..efeff92fa22 100644 --- a/src/EFCore.Relational/Extensions/RelationalForeignKeyExtensions.cs +++ b/src/EFCore.Relational/Extensions/RelationalForeignKeyExtensions.cs @@ -21,6 +21,12 @@ public static class RelationalForeignKeyExtensions /// The foreign key constraint name. public static string? GetConstraintName(this IReadOnlyForeignKey foreignKey) { + var tableName = foreignKey.DeclaringEntityType.GetTableName(); + if (tableName == null) + { + return null; + } + var annotation = foreignKey.FindAnnotation(RelationalAnnotationNames.Name); return annotation != null ? (string?)annotation.Value @@ -39,6 +45,12 @@ public static class RelationalForeignKeyExtensions in StoreObjectIdentifier storeObject, in StoreObjectIdentifier principalStoreObject) { + if (storeObject.StoreObjectType != StoreObjectType.Table + || principalStoreObject.StoreObjectType != StoreObjectType.Table) + { + return null; + } + var annotation = foreignKey.FindAnnotation(RelationalAnnotationNames.Name); return annotation != null ? (string?)annotation.Value @@ -50,10 +62,15 @@ public static class RelationalForeignKeyExtensions /// /// The foreign key. /// The default constraint name that would be used for this foreign key. - public static string GetDefaultName(this IReadOnlyForeignKey foreignKey) + public static string? GetDefaultName(this IReadOnlyForeignKey foreignKey) { var tableName = foreignKey.DeclaringEntityType.GetTableName(); var principalTableName = foreignKey.PrincipalEntityType.GetTableName(); + if (tableName == null + || principalTableName == null) + { + return null; + } var name = new StringBuilder() .Append("FK_") @@ -79,6 +96,12 @@ public static string GetDefaultName(this IReadOnlyForeignKey foreignKey) in StoreObjectIdentifier storeObject, in StoreObjectIdentifier principalStoreObject) { + if (storeObject.StoreObjectType != StoreObjectType.Table + || principalStoreObject.StoreObjectType != StoreObjectType.Table) + { + return null; + } + var propertyNames = foreignKey.Properties.GetColumnNames(storeObject); var principalPropertyNames = foreignKey.PrincipalKey.Properties.GetColumnNames(principalStoreObject); if (propertyNames == null diff --git a/src/EFCore.Relational/Extensions/RelationalIndexExtensions.cs b/src/EFCore.Relational/Extensions/RelationalIndexExtensions.cs index 705c7004d33..288071d3225 100644 --- a/src/EFCore.Relational/Extensions/RelationalIndexExtensions.cs +++ b/src/EFCore.Relational/Extensions/RelationalIndexExtensions.cs @@ -19,8 +19,10 @@ public static class RelationalIndexExtensions /// /// The index. /// The name of the index in the database. - public static string GetDatabaseName(this IReadOnlyIndex index) - => (string?)index[RelationalAnnotationNames.Name] + public static string? GetDatabaseName(this IReadOnlyIndex index) + => index.DeclaringEntityType.GetTableName() == null + ? null + : (string?)index[RelationalAnnotationNames.Name] ?? index.Name ?? index.GetDefaultDatabaseName(); @@ -31,7 +33,9 @@ public static string GetDatabaseName(this IReadOnlyIndex index) /// The identifier of the store object. /// The name of the index in the database. public static string? GetDatabaseName(this IReadOnlyIndex index, in StoreObjectIdentifier storeObject) - => (string?)index[RelationalAnnotationNames.Name] + => storeObject.StoreObjectType != StoreObjectType.Table + ? null + : (string?)index[RelationalAnnotationNames.Name] ?? index.Name ?? index.GetDefaultDatabaseName(storeObject); @@ -40,9 +44,14 @@ public static string GetDatabaseName(this IReadOnlyIndex index) /// /// The index. /// The default name that would be used for this index. - public static string GetDefaultDatabaseName(this IReadOnlyIndex index) + public static string? GetDefaultDatabaseName(this IReadOnlyIndex index) { var tableName = index.DeclaringEntityType.GetTableName(); + if (tableName == null) + { + return null; + } + var baseName = new StringBuilder() .Append("IX_") .Append(tableName) @@ -61,6 +70,11 @@ public static string GetDefaultDatabaseName(this IReadOnlyIndex index) /// The default name that would be used for this index. public static string? GetDefaultDatabaseName(this IReadOnlyIndex index, in StoreObjectIdentifier storeObject) { + if (storeObject.StoreObjectType != StoreObjectType.Table) + { + return null; + } + var columnNames = index.Properties.GetColumnNames(storeObject); if (columnNames == null) { diff --git a/src/EFCore.Relational/Extensions/RelationalKeyExtensions.cs b/src/EFCore.Relational/Extensions/RelationalKeyExtensions.cs index 5bb15a3cf93..b1590465495 100644 --- a/src/EFCore.Relational/Extensions/RelationalKeyExtensions.cs +++ b/src/EFCore.Relational/Extensions/RelationalKeyExtensions.cs @@ -20,7 +20,10 @@ public static class RelationalKeyExtensions /// The key. /// The key constraint name for this key. public static string? GetName(this IReadOnlyKey key) - => key.GetName(StoreObjectIdentifier.Table(key.DeclaringEntityType.GetTableName()!, key.DeclaringEntityType.GetSchema())); + { + var table = StoreObjectIdentifier.Create(key.DeclaringEntityType, StoreObjectType.Table); + return !table.HasValue ? null : key.GetName(table.Value); + } /// /// Returns the key constraint name for this key for a particular table. @@ -29,17 +32,36 @@ public static class RelationalKeyExtensions /// The identifier of the containing store object. /// The key constraint name for this key. public static string? GetName(this IReadOnlyKey key, in StoreObjectIdentifier storeObject) - => (string?)key[RelationalAnnotationNames.Name] - ?? key.GetDefaultName(storeObject); + { + if (storeObject.StoreObjectType != StoreObjectType.Table) + { + return null; + } + + foreach (var containingType in key.DeclaringEntityType.GetDerivedTypesInclusive()) + { + if (StoreObjectIdentifier.Create(containingType, storeObject.StoreObjectType) == storeObject) + { + return (string?)key[RelationalAnnotationNames.Name] ?? key.GetDefaultName(storeObject); + } + } + + return null; + } /// /// Returns the default key constraint name that would be used for this key. /// /// The key. /// The default key constraint name that would be used for this key. - public static string GetDefaultName(this IReadOnlyKey key) + public static string? GetDefaultName(this IReadOnlyKey key) { var tableName = key.DeclaringEntityType.GetTableName(); + if (tableName == null) + { + return null; + } + var name = key.IsPrimaryKey() ? "PK_" + tableName : new StringBuilder() @@ -60,6 +82,11 @@ public static string GetDefaultName(this IReadOnlyKey key) /// The default key constraint name that would be used for this key. public static string? GetDefaultName(this IReadOnlyKey key, in StoreObjectIdentifier storeObject) { + if (storeObject.StoreObjectType != StoreObjectType.Table) + { + return null; + } + string? name; if (key.IsPrimaryKey()) { diff --git a/src/EFCore.Relational/Metadata/IMutableCheckConstraint.cs b/src/EFCore.Relational/Metadata/IMutableCheckConstraint.cs index 6afcc51acc3..6f7e4349037 100644 --- a/src/EFCore.Relational/Metadata/IMutableCheckConstraint.cs +++ b/src/EFCore.Relational/Metadata/IMutableCheckConstraint.cs @@ -19,5 +19,5 @@ public interface IMutableCheckConstraint : IReadOnlyCheckConstraint, IMutableAnn /// /// Gets or sets the name of the check constraint in the database. /// - new string Name { get; set; } + new string? Name { get; set; } } diff --git a/src/EFCore.Relational/Metadata/IReadOnlyCheckConstraint.cs b/src/EFCore.Relational/Metadata/IReadOnlyCheckConstraint.cs index 5de8906d38d..a9f2f811206 100644 --- a/src/EFCore.Relational/Metadata/IReadOnlyCheckConstraint.cs +++ b/src/EFCore.Relational/Metadata/IReadOnlyCheckConstraint.cs @@ -19,7 +19,7 @@ public interface IReadOnlyCheckConstraint : IReadOnlyAnnotatable /// /// Gets the database name of the check constraint. /// - string Name { get; } + string? Name { get; } /// /// Returns the default database name that would be used for this check constraint. @@ -43,8 +43,10 @@ public interface IReadOnlyCheckConstraint : IReadOnlyAnnotatable /// /// The identifier of the store object. /// The default name that would be used for this check constraint. - string GetDefaultName(in StoreObjectIdentifier storeObject) - => Uniquifier.Truncate(ModelName, EntityType.Model.GetMaxIdentifierLength()); + string? GetDefaultName(in StoreObjectIdentifier storeObject) + => storeObject.StoreObjectType == StoreObjectType.Table + ? Uniquifier.Truncate(ModelName, EntityType.Model.GetMaxIdentifierLength()) + : null; /// /// Gets the entity type on which this check constraint is defined. diff --git a/src/EFCore.Relational/Metadata/ITable.cs b/src/EFCore.Relational/Metadata/ITable.cs index 9a1362af677..21ba462211d 100644 --- a/src/EFCore.Relational/Metadata/ITable.cs +++ b/src/EFCore.Relational/Metadata/ITable.cs @@ -51,9 +51,7 @@ public interface ITable : ITableBase /// /// Gets the check constraints for this table. /// - IEnumerable CheckConstraints - => EntityTypeMappings.SelectMany(m => m.EntityType.GetDeclaredCheckConstraints()) - .Distinct((x, y) => x!.Name == y!.Name); + IEnumerable CheckConstraints { get; } /// /// Gets the comment for this table. diff --git a/src/EFCore.Relational/Metadata/Internal/CheckConstraint.cs b/src/EFCore.Relational/Metadata/Internal/CheckConstraint.cs index 07409a22446..f8655df492d 100644 --- a/src/EFCore.Relational/Metadata/Internal/CheckConstraint.cs +++ b/src/EFCore.Relational/Metadata/Internal/CheckConstraint.cs @@ -280,15 +280,32 @@ public override bool IsReadOnly /// 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 + public virtual string? Name { - get => _name ?? ((IReadOnlyCheckConstraint)this).GetDefaultName() ?? ModelName; + get => EntityType.GetTableName() == null + ? null + : _name ?? ((IReadOnlyCheckConstraint)this).GetDefaultName(); set => SetName(value, ConfigurationSource.Explicit); } /// public virtual string? GetName(in StoreObjectIdentifier storeObject) - => _name ?? ((IReadOnlyCheckConstraint)this).GetDefaultName(storeObject) ?? ModelName; + { + if (storeObject.StoreObjectType != StoreObjectType.Table) + { + return null; + } + + foreach (var containingType in EntityType.GetDerivedTypesInclusive()) + { + if (StoreObjectIdentifier.Create(containingType, storeObject.StoreObjectType) == storeObject) + { + return _name ?? ((IReadOnlyCheckConstraint)this).GetDefaultName(storeObject); + } + } + + return null; + } /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to diff --git a/src/EFCore.Relational/Metadata/Internal/RelationalModel.cs b/src/EFCore.Relational/Metadata/Internal/RelationalModel.cs index 98db63d5249..1c6b8dd5d43 100644 --- a/src/EFCore.Relational/Metadata/Internal/RelationalModel.cs +++ b/src/EFCore.Relational/Metadata/Internal/RelationalModel.cs @@ -151,7 +151,7 @@ public static IRelationalModel Create( foreach (var table in databaseModel.Tables.Values) { PopulateRowInternalForeignKeys(table); - PopulateConstraints(table); + PopulateConstraints(table, designTime); if (relationalAnnotationProvider != null) { @@ -177,9 +177,9 @@ public static IRelationalModel Create( if (designTime) { - foreach (var checkConstraint in ((ITable)table).CheckConstraints) + foreach (var checkConstraint in table.CheckConstraints.Values) { - ((AnnotatableBase)checkConstraint).AddAnnotations( + checkConstraint.AddAnnotations( relationalAnnotationProvider.For(checkConstraint, designTime)); } } @@ -751,7 +751,7 @@ private static StoreFunction GetOrCreateStoreFunction(IRuntimeDbFunction dbFunct return storeFunction; } - private static void PopulateConstraints(Table table) + private static void PopulateConstraints(Table table, bool designTime) { var storeObject = StoreObjectIdentifier.Table(table.Name, table.Schema); foreach (var entityTypeMapping in ((ITable)table).EntityTypeMappings) @@ -967,6 +967,23 @@ private static void PopulateConstraints(Table table) tableIndexes.Add(tableIndex); tableIndex.MappedIndexes.Add(index); } + + if (designTime) + { + foreach (var checkConstraint in entityType.GetCheckConstraints()) + { + var name = checkConstraint.GetName(storeObject); + if (name == null) + { + continue; + } + + if (!table.CheckConstraints.ContainsKey(name)) + { + table.CheckConstraints.Add(name, (CheckConstraint)checkConstraint); + } + } + } } } diff --git a/src/EFCore.Relational/Metadata/Internal/Table.cs b/src/EFCore.Relational/Metadata/Internal/Table.cs index 2be86c69d4a..2ed34a918d8 100644 --- a/src/EFCore.Relational/Metadata/Internal/Table.cs +++ b/src/EFCore.Relational/Metadata/Internal/Table.cs @@ -113,6 +113,15 @@ public virtual UniqueConstraint? PrimaryKey public virtual SortedDictionary Indexes { get; } = new(); + /// + /// 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 SortedDictionary CheckConstraints { get; } + = new(); + /// public virtual bool IsExcludedFromMigrations => EntityTypeMappings.First().EntityType.IsTableExcludedFromMigrations(); @@ -174,6 +183,15 @@ IEnumerable ITable.Indexes get => Indexes.Values; } + /// + IEnumerable ITable.CheckConstraints + { + [DebuggerStepThrough] + get => EntityTypeMappings.First().EntityType is RuntimeEntityType + ? throw new InvalidOperationException(CoreStrings.RuntimeModelMissingData) + : CheckConstraints.Values; + } + /// [DebuggerStepThrough] IColumn? ITable.FindColumn(string name) diff --git a/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs b/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs index cba40a8ebbd..39a9df02f75 100644 --- a/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs +++ b/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs @@ -1527,7 +1527,7 @@ protected virtual IEnumerable Remove(ICheckConstraint source var operation = new DropCheckConstraintOperation { - Name = source.Name, + Name = source.Name!, Schema = sourceEntityType.GetSchema(), Table = sourceEntityType.GetTableName()! }; diff --git a/src/EFCore.Relational/Migrations/Operations/AddCheckConstraintOperation.cs b/src/EFCore.Relational/Migrations/Operations/AddCheckConstraintOperation.cs index 1547f775081..7747f10915a 100644 --- a/src/EFCore.Relational/Migrations/Operations/AddCheckConstraintOperation.cs +++ b/src/EFCore.Relational/Migrations/Operations/AddCheckConstraintOperation.cs @@ -46,7 +46,7 @@ public static AddCheckConstraintOperation CreateFrom(ICheckConstraint checkConst var operation = new AddCheckConstraintOperation { - Name = checkConstraint.Name, + Name = checkConstraint.Name!, Sql = checkConstraint.Sql, Schema = checkConstraint.EntityType.GetSchema(), Table = checkConstraint.EntityType.GetTableName()! diff --git a/src/EFCore.Relational/Query/Internal/RandomTranslator.cs b/src/EFCore.Relational/Query/Internal/RandomTranslator.cs index 5fc45186084..9c1ab0e26db 100644 --- a/src/EFCore.Relational/Query/Internal/RandomTranslator.cs +++ b/src/EFCore.Relational/Query/Internal/RandomTranslator.cs @@ -43,9 +43,9 @@ public RandomTranslator(ISqlExpressionFactory sqlExpressionFactory) => MethodInfo.Equals(method) ? _sqlExpressionFactory.Function( "RAND", - Array.Empty(), + Enumerable.Empty(), nullable: false, - argumentsPropagateNullability: Array.Empty(), + argumentsPropagateNullability: Enumerable.Empty(), method.ReturnType) : null; } diff --git a/src/EFCore.Relational/Update/Internal/CommandBatchPreparer.cs b/src/EFCore.Relational/Update/Internal/CommandBatchPreparer.cs index 099058647df..ff17701027c 100644 --- a/src/EFCore.Relational/Update/Internal/CommandBatchPreparer.cs +++ b/src/EFCore.Relational/Update/Internal/CommandBatchPreparer.cs @@ -156,7 +156,7 @@ protected virtual IEnumerable CreateModificationCo continue; } - var mappings = (IReadOnlyCollection)entry.EntityType.GetTableMappings(); + var mappings = entry.EntityType.GetTableMappings(); IModificationCommand? firstCommands = null; foreach (var mapping in mappings) { diff --git a/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerGeometryCollectionMemberTranslator.cs b/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerGeometryCollectionMemberTranslator.cs index d42e7c6008a..516cd05ae9a 100644 --- a/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerGeometryCollectionMemberTranslator.cs +++ b/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerGeometryCollectionMemberTranslator.cs @@ -46,10 +46,10 @@ public SqlServerGeometryCollectionMemberTranslator(ISqlExpressionFactory sqlExpr return _sqlExpressionFactory.Function( instance!, "STNumGeometries", - Array.Empty(), + Enumerable.Empty(), nullable: true, instancePropagatesNullability: true, - argumentsPropagateNullability: Array.Empty(), + argumentsPropagateNullability: Enumerable.Empty(), returnType); } diff --git a/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerGeometryMemberTranslator.cs b/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerGeometryMemberTranslator.cs index 508c6a70ba0..ce8ad4c2b4a 100644 --- a/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerGeometryMemberTranslator.cs +++ b/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerGeometryMemberTranslator.cs @@ -86,10 +86,10 @@ public SqlServerGeometryMemberTranslator( return _sqlExpressionFactory.Function( instance, functionName, - Array.Empty(), + Enumerable.Empty(), nullable: true, instancePropagatesNullability: true, - argumentsPropagateNullability: Array.Empty(), + argumentsPropagateNullability: Enumerable.Empty(), returnType, resultTypeMapping); } @@ -131,10 +131,10 @@ public SqlServerGeometryMemberTranslator( _sqlExpressionFactory.Function( instance, "STGeometryType", - Array.Empty(), + Enumerable.Empty(), nullable: true, instancePropagatesNullability: true, - argumentsPropagateNullability: Array.Empty(), + argumentsPropagateNullability: Enumerable.Empty(), typeof(string)), whenClauses, null); diff --git a/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerMultiLineStringMemberTranslator.cs b/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerMultiLineStringMemberTranslator.cs index 59f8598680c..c65692b3920 100644 --- a/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerMultiLineStringMemberTranslator.cs +++ b/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerMultiLineStringMemberTranslator.cs @@ -46,10 +46,10 @@ public SqlServerMultiLineStringMemberTranslator(ISqlExpressionFactory sqlExpress return _sqlExpressionFactory.Function( instance!, "STIsClosed", - Array.Empty(), + Enumerable.Empty(), nullable: true, instancePropagatesNullability: true, - argumentsPropagateNullability: Array.Empty(), + argumentsPropagateNullability: Enumerable.Empty(), returnType); } diff --git a/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerPolygonMemberTranslator.cs b/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerPolygonMemberTranslator.cs index 1904aba0a7b..db7e90e0f3b 100644 --- a/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerPolygonMemberTranslator.cs +++ b/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerPolygonMemberTranslator.cs @@ -80,10 +80,10 @@ public SqlServerPolygonMemberTranslator( _sqlExpressionFactory.Function( instance, "NumRings", - Array.Empty(), + Enumerable.Empty(), nullable: true, instancePropagatesNullability: true, - argumentsPropagateNullability: Array.Empty(), + argumentsPropagateNullability: Enumerable.Empty(), returnType), _sqlExpressionFactory.Constant(1)); } @@ -98,10 +98,10 @@ public SqlServerPolygonMemberTranslator( return _sqlExpressionFactory.Function( instance, functionName, - Array.Empty(), + Enumerable.Empty(), nullable: true, instancePropagatesNullability: true, - argumentsPropagateNullability: Array.Empty(), + argumentsPropagateNullability: Enumerable.Empty(), returnType, resultTypeMapping); } diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerDateTimeMemberTranslator.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerDateTimeMemberTranslator.cs index 5a20742cc90..5ce73b14346 100644 --- a/src/EFCore.SqlServer/Query/Internal/SqlServerDateTimeMemberTranslator.cs +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerDateTimeMemberTranslator.cs @@ -96,17 +96,17 @@ public SqlServerDateTimeMemberTranslator( case nameof(DateTime.Now): return _sqlExpressionFactory.Function( declaringType == typeof(DateTime) ? "GETDATE" : "SYSDATETIMEOFFSET", - Array.Empty(), + Enumerable.Empty(), nullable: false, - argumentsPropagateNullability: Array.Empty(), + argumentsPropagateNullability: Enumerable.Empty(), returnType); case nameof(DateTime.UtcNow): var serverTranslation = _sqlExpressionFactory.Function( declaringType == typeof(DateTime) ? "GETUTCDATE" : "SYSUTCDATETIME", - Array.Empty(), + Enumerable.Empty(), nullable: false, - argumentsPropagateNullability: Array.Empty(), + argumentsPropagateNullability: Enumerable.Empty(), returnType); return declaringType == typeof(DateTime) @@ -121,9 +121,9 @@ public SqlServerDateTimeMemberTranslator( _sqlExpressionFactory.Fragment("date"), _sqlExpressionFactory.Function( "GETDATE", - Array.Empty(), + Enumerable.Empty(), nullable: false, - argumentsPropagateNullability: Array.Empty(), + argumentsPropagateNullability: Enumerable.Empty(), typeof(DateTime)) }, nullable: true, diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerNewGuidTranslator.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerNewGuidTranslator.cs index 87036517db0..65b7d074c31 100644 --- a/src/EFCore.SqlServer/Query/Internal/SqlServerNewGuidTranslator.cs +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerNewGuidTranslator.cs @@ -41,9 +41,9 @@ public SqlServerNewGuidTranslator(ISqlExpressionFactory sqlExpressionFactory) => MethodInfo.Equals(method) ? _sqlExpressionFactory.Function( "NEWID", - Array.Empty(), + Enumerable.Empty(), nullable: false, - argumentsPropagateNullability: Array.Empty(), + argumentsPropagateNullability: Enumerable.Empty(), method.ReturnType) : null; } diff --git a/src/EFCore.Sqlite.Core/Query/Internal/SqliteRandomTranslator.cs b/src/EFCore.Sqlite.Core/Query/Internal/SqliteRandomTranslator.cs index 0670c539e9b..bee733a576a 100644 --- a/src/EFCore.Sqlite.Core/Query/Internal/SqliteRandomTranslator.cs +++ b/src/EFCore.Sqlite.Core/Query/Internal/SqliteRandomTranslator.cs @@ -49,14 +49,14 @@ public SqliteRandomTranslator(ISqlExpressionFactory sqlExpressionFactory) _sqlExpressionFactory.Divide( _sqlExpressionFactory.Function( "random", - Array.Empty(), + Enumerable.Empty(), nullable: false, - argumentsPropagateNullability: Array.Empty(), + argumentsPropagateNullability: Enumerable.Empty(), method.ReturnType), _sqlExpressionFactory.Constant(9223372036854780000.0)) }, nullable: false, - argumentsPropagateNullability: Array.Empty(), + argumentsPropagateNullability: Enumerable.Empty(), method.ReturnType) : null; } diff --git a/src/EFCore/DbContext.cs b/src/EFCore/DbContext.cs index b83d8f0ef41..1f68bb0335a 100644 --- a/src/EFCore/DbContext.cs +++ b/src/EFCore/DbContext.cs @@ -482,7 +482,7 @@ protected internal virtual void OnConfiguring(DbContextOptionsBuilder optionsBui /// /// /// If a model is explicitly set on the options for this context (via ) - /// then this method will not be run. + /// then this method will not be run. However, it will still run when creating a compiled model. /// /// /// See Pre-convention model building in EF Core for more information and @@ -504,7 +504,7 @@ protected internal virtual void ConfigureConventions(ModelConfigurationBuilder c /// /// /// If a model is explicitly set on the options for this context (via ) - /// then this method will not be run. + /// then this method will not be run. However, it will still run when creating a compiled model. /// /// /// See Modeling entity types and relationships for more information and diff --git a/src/EFCore/Metadata/IReadOnlyEntityType.cs b/src/EFCore/Metadata/IReadOnlyEntityType.cs index 01d84043020..a13bb8dd4ff 100644 --- a/src/EFCore/Metadata/IReadOnlyEntityType.cs +++ b/src/EFCore/Metadata/IReadOnlyEntityType.cs @@ -159,7 +159,17 @@ bool IsAssignableFrom(IReadOnlyEntityType derivedType) { Check.NotNull(derivedType, nameof(derivedType)); - var baseType = derivedType; + if (derivedType == this) + { + return true; + } + + if (!GetDirectlyDerivedTypes().Any()) + { + return false; + } + + var baseType = derivedType.BaseType; while (baseType != null) { if (baseType == this) diff --git a/src/EFCore/Metadata/Internal/Model.cs b/src/EFCore/Metadata/Internal/Model.cs index 51448d255e4..e1a419d65a6 100644 --- a/src/EFCore/Metadata/Internal/Model.cs +++ b/src/EFCore/Metadata/Internal/Model.cs @@ -458,11 +458,11 @@ public virtual IEnumerable FindEntityTypes(Type type) /// 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 IReadOnlyCollection GetEntityTypes(string name) + public virtual IEnumerable GetEntityTypes(string name) { var entityType = FindEntityType(name); return entityType == null - ? Array.Empty() + ? Enumerable.Empty() : new[] { entityType }; } diff --git a/src/EFCore/Metadata/RuntimeModel.cs b/src/EFCore/Metadata/RuntimeModel.cs index 3c41e2924d2..75968bbab15 100644 --- a/src/EFCore/Metadata/RuntimeModel.cs +++ b/src/EFCore/Metadata/RuntimeModel.cs @@ -126,7 +126,7 @@ private IEnumerable FindEntityTypes(Type type) { var entityType = FindEntityType(GetDisplayName(type)); var result = entityType == null - ? Array.Empty() + ? Enumerable.Empty() : new[] { entityType }; return _sharedTypes.TryGetValue(type, out var sharedTypes) diff --git a/test/EFCore.Relational.Tests/Metadata/RelationalModelTest.cs b/test/EFCore.Relational.Tests/Metadata/RelationalModelTest.cs index 5e4d681368b..3bc63e87639 100644 --- a/test/EFCore.Relational.Tests/Metadata/RelationalModelTest.cs +++ b/test/EFCore.Relational.Tests/Metadata/RelationalModelTest.cs @@ -172,8 +172,9 @@ private static void AssertViews(IRelationalModel model, Mapping mapping) Assert.Equal("viewSchema", ordersView.Schema); Assert.Null(ordersView.ViewDefinitionSql); - var orderDate = orderType.FindProperty(nameof(Order.OrderDate)); + var orderPk = orderType.FindPrimaryKey(); + var orderDate = orderType.FindProperty(nameof(Order.OrderDate)); var orderDateMapping = orderDate.GetViewColumnMappings().Single(); Assert.NotNull(orderDateMapping.TypeMapping); Assert.Equal("default_datetime_mapping", orderDateMapping.TypeMapping.StoreType); @@ -212,6 +213,56 @@ private static void AssertViews(IRelationalModel model, Mapping mapping) Assert.False(customerView.IsOptional(specialCustomerType)); Assert.False(customerView.IsOptional(extraSpecialCustomerType)); + var mappedToTable = orderType.GetTableName() != null; + var ordersCustomerForeignKey = orderType.FindNavigation(nameof(Order.Customer)).ForeignKey; + Assert.Equal(mappedToTable + ? "FK_Order_Customer_CustomerId" + : null, ordersCustomerForeignKey.GetConstraintName()); + Assert.Null(ordersCustomerForeignKey.GetConstraintName( + StoreObjectIdentifier.View(ordersView.Name, ordersView.Schema), + StoreObjectIdentifier.View(customerView.Name, customerView.Schema))); + Assert.Equal(mappedToTable + ? "FK_Order_Customer_CustomerId" + : null, ordersCustomerForeignKey.GetDefaultName()); + Assert.Null(ordersCustomerForeignKey.GetDefaultName( + StoreObjectIdentifier.View(ordersView.Name, ordersView.Schema), + StoreObjectIdentifier.View(customerView.Name, customerView.Schema))); + + var ordersCustomerIndex = orderType.FindIndex(ordersCustomerForeignKey.Properties); + Assert.Equal(mappedToTable + ? "IX_Order_CustomerId" + : null, ordersCustomerIndex.GetDatabaseName()); + Assert.Null(ordersCustomerIndex.GetDatabaseName( + StoreObjectIdentifier.Table(ordersView.Name, ordersView.Schema))); + Assert.Equal(mappedToTable + ? "IX_Order_CustomerId" + : null, ordersCustomerIndex.GetDefaultDatabaseName()); + Assert.Null(ordersCustomerIndex.GetDefaultDatabaseName( + StoreObjectIdentifier.Table(ordersView.Name, ordersView.Schema))); + + var specialityCK = specialCustomerType.GetCheckConstraints().Single(); + Assert.Equal(mappedToTable + ? "Speciality" + : null, specialityCK.Name); + Assert.Null(specialityCK.GetName( + StoreObjectIdentifier.Table(ordersView.Name, ordersView.Schema))); + Assert.Equal(mappedToTable + ? "Speciality" + : null, specialityCK.GetDefaultName()); + Assert.Equal("Speciality", specialityCK.GetDefaultName( + StoreObjectIdentifier.Table(ordersView.Name, ordersView.Schema))); + + Assert.Equal(mappedToTable + ? "PK_Order" + : null, orderPk.GetName()); + Assert.Null(orderPk.GetName( + StoreObjectIdentifier.Table(ordersView.Name, ordersView.Schema))); + Assert.Equal(mappedToTable + ? "PK_Order" + : null, orderPk.GetDefaultName()); + Assert.Equal("PK_OrderView", orderPk.GetDefaultName( + StoreObjectIdentifier.Table(ordersView.Name, ordersView.Schema))); + if (mapping == Mapping.TPT) { Assert.Equal(2, specialCustomerType.GetViewMappings().Count()); @@ -365,6 +416,7 @@ private static void AssertTables(IRelationalModel model, Mapping mapping) Assert.Equal(orderCustomerFk, orderCustomerFkConstraint.MappedForeignKeys.Single()); Assert.Equal(new[] { orderDateFkConstraint, orderCustomerFkConstraint }, ordersTable.ForeignKeyConstraints); + var customerType = model.Model.FindEntityType(typeof(Customer)); var specialCustomerType = model.Model.FindEntityType(typeof(SpecialCustomer)); var extraSpecialCustomerType = model.Model.FindEntityType(typeof(ExtraSpecialCustomer)); var orderDetailsOwnership = orderType.FindNavigation(nameof(Order.Details)).ForeignKey; @@ -425,10 +477,44 @@ private static void AssertTables(IRelationalModel model, Mapping mapping) Assert.Equal("FK_DateDetails", orderDateFkConstraint.Name); - var customerType = model.Model.FindEntityType(typeof(Customer)); var customerTable = customerType.GetTableMappings().Single().Table; Assert.Equal("Customer", customerTable.Name); + var ordersCustomerForeignKey = orderType.FindNavigation(nameof(Order.Customer)).ForeignKey; + Assert.Equal("FK_Order_Customer_CustomerId", ordersCustomerForeignKey.GetConstraintName()); + Assert.Equal("FK_Order_Customer_CustomerId", ordersCustomerForeignKey.GetConstraintName( + StoreObjectIdentifier.Table(ordersTable.Name, ordersTable.Schema), + StoreObjectIdentifier.Table(customerTable.Name, customerTable.Schema))); + Assert.Equal("FK_Order_Customer_CustomerId", ordersCustomerForeignKey.GetDefaultName()); + Assert.Equal("FK_Order_Customer_CustomerId", ordersCustomerForeignKey.GetDefaultName( + StoreObjectIdentifier.Table(ordersTable.Name, ordersTable.Schema), + StoreObjectIdentifier.Table(customerTable.Name, customerTable.Schema))); + + var ordersCustomerIndex = orderType.FindIndex(ordersCustomerForeignKey.Properties); + Assert.Equal("IX_Order_CustomerId", ordersCustomerIndex.GetDatabaseName()); + Assert.Equal("IX_Order_CustomerId", ordersCustomerIndex.GetDatabaseName( + StoreObjectIdentifier.Table(ordersTable.Name, ordersTable.Schema))); + Assert.Equal("IX_Order_CustomerId", ordersCustomerIndex.GetDefaultDatabaseName()); + Assert.Equal("IX_Order_CustomerId", ordersCustomerIndex.GetDefaultDatabaseName( + StoreObjectIdentifier.Table(ordersTable.Name, ordersTable.Schema))); + + Assert.Equal("PK_Order", orderPk.GetName()); + Assert.Equal("PK_Order", orderPk.GetName( + StoreObjectIdentifier.Table(ordersTable.Name, ordersTable.Schema))); + Assert.Equal("PK_Order", orderPk.GetDefaultName()); + Assert.Equal("PK_Order", orderPk.GetDefaultName( + StoreObjectIdentifier.Table(ordersTable.Name, ordersTable.Schema))); + + var specialCustomerTable = + specialCustomerType.GetTableMappings().Select(t => t.Table).Last(); + var specialityCK = specialCustomerType.GetCheckConstraints().Single(); + Assert.Equal("Speciality", specialityCK.Name); + Assert.Equal("Speciality", specialityCK.GetName( + StoreObjectIdentifier.Table(specialCustomerTable.Name, specialCustomerTable.Schema))); + Assert.Equal("Speciality", specialityCK.GetDefaultName()); + Assert.Equal("Speciality", specialityCK.GetDefaultName( + StoreObjectIdentifier.Table(specialCustomerTable.Name, specialCustomerTable.Schema))); + Assert.False(customerTable.IsOptional(customerType)); Assert.False(customerTable.IsOptional(specialCustomerType)); Assert.False(customerTable.IsOptional(extraSpecialCustomerType)); @@ -443,8 +529,7 @@ private static void AssertTables(IRelationalModel model, Mapping mapping) Assert.True(specialCustomerType.GetTableMappings().Last().IsSplitEntityTypePrincipal); Assert.True(specialCustomerType.GetTableMappings().Last().IncludesDerivedTypes); - var specialCustomerTable = - specialCustomerType.GetTableMappings().Select(t => t.Table).First(t => t.Name == "SpecialCustomer"); + Assert.Equal("SpecialCustomer", specialCustomerTable.Name); Assert.Equal("SpecialSchema", specialCustomerTable.Schema); Assert.Equal(5, specialCustomerTable.Columns.Count()); @@ -455,8 +540,7 @@ private static void AssertTables(IRelationalModel model, Mapping mapping) Assert.False(specialityColumn.IsNullable); var addressColumn = specialCustomerTable.Columns.Single( - c => - c.Name == nameof(SpecialCustomer.Details) + "_" + nameof(CustomerDetails.Address)); + c => c.Name == nameof(SpecialCustomer.Details) + "_" + nameof(CustomerDetails.Address)); Assert.False(addressColumn.IsNullable); var specialityProperty = specialityColumn.PropertyMappings.First().Property; @@ -464,10 +548,16 @@ private static void AssertTables(IRelationalModel model, Mapping mapping) RelationalStrings.PropertyNotMappedToTable( nameof(SpecialCustomer.Speciality), nameof(SpecialCustomer), "Customer"), Assert.Throws( - () => - specialityProperty.IsColumnNullable(StoreObjectIdentifier.Table(customerTable.Name, customerTable.Schema))) + () => specialityProperty.IsColumnNullable(StoreObjectIdentifier.Table(customerTable.Name, customerTable.Schema))) .Message); + var extraSpecialCustomerTable = + extraSpecialCustomerType.GetTableMappings().Select(t => t.Table).First(t => t.Name == "ExtraSpecialCustomer"); + + Assert.Empty(customerTable.CheckConstraints); + Assert.Same(specialityCK, specialCustomerTable.CheckConstraints.Single()); + Assert.Same(specialityCK, extraSpecialCustomerTable.CheckConstraints.Single()); + Assert.Equal(3, customerPk.GetMappedConstraints().Count()); var specialCustomerPkConstraint = specialCustomerTable.PrimaryKey; Assert.Equal("PK_SpecialCustomer", specialCustomerPkConstraint.Name); @@ -520,7 +610,6 @@ private static void AssertTables(IRelationalModel model, Mapping mapping) Assert.True(specialCustomerTypeMapping.IsSplitEntityTypePrincipal); Assert.True(specialCustomerTypeMapping.IncludesDerivedTypes); - var specialCustomerTable = specialCustomerTypeMapping.Table; Assert.Same(customerTable, specialCustomerTable); Assert.Equal(4, specialCustomerTable.EntityTypeMappings.Count()); @@ -530,11 +619,12 @@ private static void AssertTables(IRelationalModel model, Mapping mapping) var specialityColumn = specialCustomerTable.Columns.Single(c => c.Name == nameof(SpecialCustomer.Speciality)); Assert.True(specialityColumn.IsNullable); - var addressColumn = specialCustomerTable.Columns.Single( - c => + var addressColumn = specialCustomerTable.Columns.Single(c => c.Name == nameof(SpecialCustomer.Details) + "_" + nameof(CustomerDetails.Address)); Assert.True(addressColumn.IsNullable); + Assert.Same(specialityCK, specialCustomerTable.CheckConstraints.Single()); + var specialCustomerPkConstraint = specialCustomerTable.PrimaryKey; Assert.Equal("PK_Customer", specialCustomerPkConstraint.Name); Assert.Same(specialCustomerPkConstraint.MappedKeys.First(), customerPk); @@ -598,6 +688,8 @@ private IRelationalModel CreateTestModel(bool mapToTables = false, bool mapToVie cb.ToTable("SpecialCustomer", "SpecialSchema"); } + cb.HasCheckConstraint($"Speciality", $"[Speciality] IN ('Specialist', 'Generalist')"); + cb.Property(s => s.Speciality).IsRequired(); cb.HasOne(c => c.RelatedCustomer).WithOne() diff --git a/test/EFCore.SqlServer.FunctionalTests/SqlServerEndToEndTest.cs b/test/EFCore.SqlServer.FunctionalTests/SqlServerEndToEndTest.cs index 36097abba24..7bc02009588 100644 --- a/test/EFCore.SqlServer.FunctionalTests/SqlServerEndToEndTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/SqlServerEndToEndTest.cs @@ -980,7 +980,9 @@ public class Student public enum Grade { +#pragma warning disable SA1602 // Enumeration items should be documented A, B, C, D, F +#pragma warning restore SA1602 // Enumeration items should be documented } public class Enrollment From 41a753d5fe83b9da43941c6fdafd0dcbed96be27 Mon Sep 17 00:00:00 2001 From: "dotnet-maestro[bot]" <42748379+dotnet-maestro[bot]@users.noreply.github.com> Date: Thu, 3 Mar 2022 18:46:24 +0000 Subject: [PATCH 02/11] Update dependencies from https://github.com/dotnet/runtime build 20220227.1 (#27514) [main] Update dependencies from dotnet/runtime --- eng/Version.Details.xml | 44 ++++++++++++++++++++--------------------- eng/Versions.props | 22 ++++++++++----------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 401c4812c95..fb6bdc31337 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -1,49 +1,49 @@ - + https://github.com/dotnet/runtime - 4017327955f1d8ddc43980eb1848c52fbb131dfc + 68fb7fc68cc1af800bee1d38af22b5027bf4ab4e - + https://github.com/dotnet/runtime - 4017327955f1d8ddc43980eb1848c52fbb131dfc + 68fb7fc68cc1af800bee1d38af22b5027bf4ab4e - + https://github.com/dotnet/runtime - 4017327955f1d8ddc43980eb1848c52fbb131dfc + 68fb7fc68cc1af800bee1d38af22b5027bf4ab4e - + https://github.com/dotnet/runtime - 4017327955f1d8ddc43980eb1848c52fbb131dfc + 68fb7fc68cc1af800bee1d38af22b5027bf4ab4e - + https://github.com/dotnet/runtime - 4017327955f1d8ddc43980eb1848c52fbb131dfc + 68fb7fc68cc1af800bee1d38af22b5027bf4ab4e - + https://github.com/dotnet/runtime - 4017327955f1d8ddc43980eb1848c52fbb131dfc + 68fb7fc68cc1af800bee1d38af22b5027bf4ab4e - + https://github.com/dotnet/runtime - 4017327955f1d8ddc43980eb1848c52fbb131dfc + 68fb7fc68cc1af800bee1d38af22b5027bf4ab4e - + https://github.com/dotnet/runtime - 4017327955f1d8ddc43980eb1848c52fbb131dfc + 68fb7fc68cc1af800bee1d38af22b5027bf4ab4e - + https://github.com/dotnet/runtime - 4017327955f1d8ddc43980eb1848c52fbb131dfc + 68fb7fc68cc1af800bee1d38af22b5027bf4ab4e - + https://github.com/dotnet/runtime - 4017327955f1d8ddc43980eb1848c52fbb131dfc + 68fb7fc68cc1af800bee1d38af22b5027bf4ab4e - + https://github.com/dotnet/runtime - 4017327955f1d8ddc43980eb1848c52fbb131dfc + 68fb7fc68cc1af800bee1d38af22b5027bf4ab4e diff --git a/eng/Versions.props b/eng/Versions.props index 1cd42cab6ea..caed9265c62 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -15,17 +15,17 @@ False - 7.0.0-preview.3.22120.1 - 7.0.0-preview.3.22120.1 - 7.0.0-preview.3.22120.1 - 7.0.0-preview.3.22120.1 - 7.0.0-preview.3.22120.1 - 7.0.0-preview.3.22120.1 - 7.0.0-preview.3.22120.1 - 7.0.0-preview.3.22120.1 - 7.0.0-preview.3.22120.1 - 7.0.0-preview.3.22120.1 - 7.0.0-preview.3.22120.1 + 7.0.0-preview.3.22127.1 + 7.0.0-preview.3.22127.1 + 7.0.0-preview.3.22127.1 + 7.0.0-preview.3.22127.1 + 7.0.0-preview.3.22127.1 + 7.0.0-preview.3.22127.1 + 7.0.0-preview.3.22127.1 + 7.0.0-preview.3.22127.1 + 7.0.0-preview.3.22127.1 + 7.0.0-preview.3.22127.1 + 7.0.0-preview.3.22127.1 4.0.1 From fe5bebc1f8476821dcb41efc0f1d7a2df041b626 Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Fri, 4 Mar 2022 02:24:23 +0200 Subject: [PATCH 03/11] Stop wrapping single changes in transactions where possible (#27500) Closes #27439 Closes #27507 Co-authored-by: Andriy Svyryd --- .../Internal/MigrationsModelDiffer.cs | 2 +- .../Properties/RelationalStrings.Designer.cs | 12 + .../Properties/RelationalStrings.resx | 6 + .../Update/IBatchExecutor.cs | 12 +- .../Update/ICommandBatchPreparer.cs | 6 +- .../Update/IUpdateSqlGenerator.cs | 57 +- .../Update/Internal/BatchExecutor.cs | 38 +- .../Update/Internal/CommandBatchPreparer.cs | 28 +- .../Update/ModificationCommandBatch.cs | 10 + .../Update/ReaderModificationCommandBatch.cs | 93 +++- .../Update/UpdateSqlGenerator.cs | 30 +- .../SqlServerMigrationsSqlGenerator.cs | 2 +- .../Internal/ISqlServerUpdateSqlGenerator.cs | 15 +- .../SqlServerModificationCommandBatch.cs | 52 +- .../Internal/SqlServerUpdateSqlGenerator.cs | 90 +++- .../StoreValueGenerationContext.cs | 29 ++ .../StoreValueGenerationData.cs | 25 + .../Update/StoreValueGenerationFixtureBase.cs | 91 ++++ .../Update/StoreValueGenerationTestBase.cs | 379 ++++++++++++++ .../FakeProvider/FakeSqlGenerator.cs | 15 +- .../Update/BatchExecutorTest.cs | 10 +- .../Update/CommandBatchPreparerTest.cs | 92 ++-- .../ReaderModificationCommandBatchTest.cs | 28 +- .../DataAnnotationSqlServerTest.cs | 2 + .../OptimisticConcurrencySqlServerTest.cs | 1 + ...eteMappingInheritanceQuerySqlServerTest.cs | 3 + .../Query/InheritanceQuerySqlServerTest.cs | 3 + .../Query/TPTInheritanceQuerySqlServerTest.cs | 8 + .../TPTTableSplittingSqlServerTest.cs | 3 + .../TableSplittingSqlServerTest.cs | 2 + ...oreValueGenerationIdentitySqlServerTest.cs | 472 +++++++++++++++++ ...oreValueGenerationSequenceSqlServerTest.cs | 491 ++++++++++++++++++ .../UpdatesSqlServerTest.cs | 2 + .../Update/StoreValueGenerationSqliteTest.cs | 370 +++++++++++++ 34 files changed, 2333 insertions(+), 146 deletions(-) create mode 100644 test/EFCore.Relational.Specification.Tests/TestModels/StoreValueGenerationModel/StoreValueGenerationContext.cs create mode 100644 test/EFCore.Relational.Specification.Tests/TestModels/StoreValueGenerationModel/StoreValueGenerationData.cs create mode 100644 test/EFCore.Relational.Specification.Tests/Update/StoreValueGenerationFixtureBase.cs create mode 100644 test/EFCore.Relational.Specification.Tests/Update/StoreValueGenerationTestBase.cs create mode 100644 test/EFCore.SqlServer.FunctionalTests/Update/StoreValueGenerationIdentitySqlServerTest.cs create mode 100644 test/EFCore.SqlServer.FunctionalTests/Update/StoreValueGenerationSequenceSqlServerTest.cs create mode 100644 test/EFCore.Sqlite.FunctionalTests/Update/StoreValueGenerationSqliteTest.cs diff --git a/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs b/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs index 39a9df02f75..7e9ebc3ba4e 100644 --- a/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs +++ b/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs @@ -2189,7 +2189,7 @@ private IEnumerable GetDataOperations( var commandBatches = new CommandBatchPreparer(CommandBatchPreparerDependencies) .BatchCommands(entries, updateAdapter); - foreach (var commandBatch in commandBatches) + foreach (var (commandBatch, _) in commandBatches) { InsertDataOperation? batchInsertOperation = null; foreach (var command in commandBatch.ModificationCommands) diff --git a/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs b/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs index d891f6405b1..a96438e2c9b 100644 --- a/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs +++ b/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs @@ -843,6 +843,18 @@ public static string MissingParameterValue(object? parameter) GetString("MissingParameterValue", nameof(parameter)), parameter); + /// + /// Cannot execute an ModificationCommandBatch which hasn't been completed. + /// + public static string ModificationCommandBatchAlreadyComplete + => GetString("ModificationCommandBatchAlreadyComplete"); + + /// + /// Cannot execute an ModificationCommandBatch which hasn't been completed. + /// + public static string ModificationCommandBatchNotComplete + => GetString("ModificationCommandBatchNotCompleted"); + /// /// Cannot save changes for an entity of type '{entityType}' in state '{entityState}'. This may indicate a bug in Entity Framework, please open an issue at https://go.microsoft.com/fwlink/?linkid=2142044. Consider using 'DbContextOptionsBuilder.EnableSensitiveDataLogging' to see the key values of the entity. /// diff --git a/src/EFCore.Relational/Properties/RelationalStrings.resx b/src/EFCore.Relational/Properties/RelationalStrings.resx index 57b5e40ab57..9ba10958462 100644 --- a/src/EFCore.Relational/Properties/RelationalStrings.resx +++ b/src/EFCore.Relational/Properties/RelationalStrings.resx @@ -669,6 +669,12 @@ No value was provided for the required parameter '{parameter}'. + + Cannot add commands to a completed ModificationCommandBatch. + + + Cannot execute an ModificationCommandBatch which hasn't been completed. + Cannot save changes for an entity of type '{entityType}' in state '{entityState}'. This may indicate a bug in Entity Framework, please open an issue at https://go.microsoft.com/fwlink/?linkid=2142044. Consider using 'DbContextOptionsBuilder.EnableSensitiveDataLogging' to see the key values of the entity. diff --git a/src/EFCore.Relational/Update/IBatchExecutor.cs b/src/EFCore.Relational/Update/IBatchExecutor.cs index e8209d5328b..20ebf876acc 100644 --- a/src/EFCore.Relational/Update/IBatchExecutor.cs +++ b/src/EFCore.Relational/Update/IBatchExecutor.cs @@ -28,17 +28,21 @@ public interface IBatchExecutor /// /// Executes the commands in the batches against the given database connection. /// - /// The batches to execute. + /// + /// A list of value tuples, each of which contains a batch to execute, and whether more batches are available. + /// /// The database connection to use. /// The total number of rows affected. int Execute( - IEnumerable commandBatches, + IEnumerable<(ModificationCommandBatch Batch, bool HasMore)> commandBatches, IRelationalConnection connection); /// /// Executes the commands in the batches against the given database connection. /// - /// The batches to execute. + /// + /// A list of value tuples, each of which contains a batch to execute, and whether more batches are available. + /// /// The database connection to use. /// A to observe while waiting for the task to complete. /// @@ -47,7 +51,7 @@ int Execute( /// /// If the is canceled. Task ExecuteAsync( - IEnumerable commandBatches, + IEnumerable<(ModificationCommandBatch Batch, bool HasMore)> commandBatches, IRelationalConnection connection, CancellationToken cancellationToken = default); } diff --git a/src/EFCore.Relational/Update/ICommandBatchPreparer.cs b/src/EFCore.Relational/Update/ICommandBatchPreparer.cs index 9bfdd8ca3e8..b045c936d51 100644 --- a/src/EFCore.Relational/Update/ICommandBatchPreparer.cs +++ b/src/EFCore.Relational/Update/ICommandBatchPreparer.cs @@ -32,8 +32,6 @@ public interface ICommandBatchPreparer /// /// The entries that represent the entities to be modified. /// The model data. - /// The list of batches to execute. - IEnumerable BatchCommands( - IList entries, - IUpdateAdapter updateAdapter); + /// A list of value tuples, each of which contains a batch to execute, and whether more batches are available. + IEnumerable<(ModificationCommandBatch Batch, bool HasMore)> BatchCommands(IList entries, IUpdateAdapter updateAdapter); } diff --git a/src/EFCore.Relational/Update/IUpdateSqlGenerator.cs b/src/EFCore.Relational/Update/IUpdateSqlGenerator.cs index 3022c1c7e29..9fbaca6a97f 100644 --- a/src/EFCore.Relational/Update/IUpdateSqlGenerator.cs +++ b/src/EFCore.Relational/Update/IUpdateSqlGenerator.cs @@ -54,6 +54,26 @@ void AppendNextSequenceValueOperation( /// The builder to which the SQL fragment should be appended. void AppendBatchHeader(StringBuilder commandStringBuilder); + /// + /// Prepends a SQL command for turning on autocommit mode in the database, in case it is off. + /// + /// The builder to which the SQL should be prepended. + void PrependEnsureAutocommit(StringBuilder commandStringBuilder); + + /// + /// Appends a SQL command for deleting a row to the commands being built. + /// + /// The builder to which the SQL should be appended. + /// The command that represents the delete operation. + /// The ordinal of this command in the batch. + /// Returns whether the SQL appended must be executed in a transaction to work correctly. + /// The for the command. + ResultSetMapping AppendDeleteOperation( + StringBuilder commandStringBuilder, + IReadOnlyModificationCommand command, + int commandPosition, + out bool requiresTransaction); + /// /// Appends a SQL command for deleting a row to the commands being built. /// @@ -64,7 +84,22 @@ void AppendNextSequenceValueOperation( ResultSetMapping AppendDeleteOperation( StringBuilder commandStringBuilder, IReadOnlyModificationCommand command, - int commandPosition); + int commandPosition) + => AppendDeleteOperation(commandStringBuilder, command, commandPosition, out _); + + /// + /// Appends a SQL command for inserting a row to the commands being built. + /// + /// The builder to which the SQL should be appended. + /// The command that represents the delete operation. + /// The ordinal of this command in the batch. + /// Returns whether the SQL appended must be executed in a transaction to work correctly. + /// The for the command. + ResultSetMapping AppendInsertOperation( + StringBuilder commandStringBuilder, + IReadOnlyModificationCommand command, + int commandPosition, + out bool requiresTransaction); /// /// Appends a SQL command for inserting a row to the commands being built. @@ -76,7 +111,22 @@ ResultSetMapping AppendDeleteOperation( ResultSetMapping AppendInsertOperation( StringBuilder commandStringBuilder, IReadOnlyModificationCommand command, - int commandPosition); + int commandPosition) + => AppendInsertOperation(commandStringBuilder, command, commandPosition, out _); + + /// + /// Appends a SQL command for updating a row to the commands being built. + /// + /// The builder to which the SQL should be appended. + /// The command that represents the delete operation. + /// The ordinal of this command in the batch. + /// Returns whether the SQL appended must be executed in a transaction to work correctly. + /// The for the command. + ResultSetMapping AppendUpdateOperation( + StringBuilder commandStringBuilder, + IReadOnlyModificationCommand command, + int commandPosition, + out bool requiresTransaction); /// /// Appends a SQL command for updating a row to the commands being built. @@ -88,5 +138,6 @@ ResultSetMapping AppendInsertOperation( ResultSetMapping AppendUpdateOperation( StringBuilder commandStringBuilder, IReadOnlyModificationCommand command, - int commandPosition); + int commandPosition) + => AppendUpdateOperation(commandStringBuilder, command, commandPosition, out _); } diff --git a/src/EFCore.Relational/Update/Internal/BatchExecutor.cs b/src/EFCore.Relational/Update/Internal/BatchExecutor.cs index 0ac9a116e26..6eb20e22be9 100644 --- a/src/EFCore.Relational/Update/Internal/BatchExecutor.cs +++ b/src/EFCore.Relational/Update/Internal/BatchExecutor.cs @@ -49,9 +49,18 @@ public BatchExecutor( /// doing so can result in application failures when updating to a new Entity Framework Core release. /// public virtual int Execute( - IEnumerable commandBatches, + IEnumerable<(ModificationCommandBatch Batch, bool HasMore)> commandBatches, IRelationalConnection connection) { + using var batchEnumerator = commandBatches.GetEnumerator(); + + if (!batchEnumerator.MoveNext()) + { + return 0; + } + + var (batch, hasMoreBatches) = batchEnumerator.Current; + var rowsAffected = 0; var transaction = connection.CurrentTransaction; var beganTransaction = false; @@ -62,7 +71,9 @@ public virtual int Execute( if (transaction == null && transactionEnlistManager?.EnlistedTransaction is null && transactionEnlistManager?.CurrentAmbientTransaction is null - && CurrentContext.Context.Database.AutoTransactionsEnabled) + && CurrentContext.Context.Database.AutoTransactionsEnabled + // Don't start a transaction if we have a single batch which doesn't require a transaction (single command), for perf. + && (hasMoreBatches || batch.RequiresTransaction)) { transaction = connection.BeginTransaction(); beganTransaction = true; @@ -79,11 +90,13 @@ public virtual int Execute( } } - foreach (var batch in commandBatches) + do { + batch = batchEnumerator.Current.Batch; batch.Execute(connection); rowsAffected += batch.ModificationCommands.Count; } + while (batchEnumerator.MoveNext()); if (beganTransaction) { @@ -143,10 +156,19 @@ public virtual int Execute( /// doing so can result in application failures when updating to a new Entity Framework Core release. /// public virtual async Task ExecuteAsync( - IEnumerable commandBatches, + IEnumerable<(ModificationCommandBatch Batch, bool HasMore)> commandBatches, IRelationalConnection connection, CancellationToken cancellationToken = default) { + using var batchEnumerator = commandBatches.GetEnumerator(); + + if (!batchEnumerator.MoveNext()) + { + return 0; + } + + var (batch, hasMoreBatches) = batchEnumerator.Current; + var rowsAffected = 0; var transaction = connection.CurrentTransaction; var beganTransaction = false; @@ -157,7 +179,9 @@ public virtual async Task ExecuteAsync( if (transaction == null && transactionEnlistManager?.EnlistedTransaction is null && transactionEnlistManager?.CurrentAmbientTransaction is null - && CurrentContext.Context.Database.AutoTransactionsEnabled) + && CurrentContext.Context.Database.AutoTransactionsEnabled + // Don't start a transaction if we have a single batch which doesn't require a transaction (single command), for perf. + && (hasMoreBatches || batch.RequiresTransaction)) { transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); beganTransaction = true; @@ -174,11 +198,13 @@ public virtual async Task ExecuteAsync( } } - foreach (var batch in commandBatches) + do { + batch = batchEnumerator.Current.Batch; await batch.ExecuteAsync(connection, cancellationToken).ConfigureAwait(false); rowsAffected += batch.ModificationCommands.Count; } + while (batchEnumerator.MoveNext()); if (beganTransaction) { diff --git a/src/EFCore.Relational/Update/Internal/CommandBatchPreparer.cs b/src/EFCore.Relational/Update/Internal/CommandBatchPreparer.cs index ff17701027c..9bd866d77dd 100644 --- a/src/EFCore.Relational/Update/Internal/CommandBatchPreparer.cs +++ b/src/EFCore.Relational/Update/Internal/CommandBatchPreparer.cs @@ -50,7 +50,7 @@ public CommandBatchPreparer(CommandBatchPreparerDependencies dependencies) /// 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 IEnumerable BatchCommands( + public virtual IEnumerable<(ModificationCommandBatch Batch, bool HasMore)> BatchCommands( IList entries, IUpdateAdapter updateAdapter) { @@ -58,8 +58,10 @@ public virtual IEnumerable BatchCommands( var commands = CreateModificationCommands(entries, updateAdapter, parameterNameGenerator.GenerateNext); var sortedCommandSets = TopologicalSort(commands); - foreach (var independentCommandSet in sortedCommandSets) + for (var commandSetIndex = 0; commandSetIndex < sortedCommandSets.Count; commandSetIndex++) { + var independentCommandSet = sortedCommandSets[commandSetIndex]; + independentCommandSet.Sort(Dependencies.ModificationCommandComparer); var batch = Dependencies.ModificationCommandBatchFactory.Create(); @@ -83,7 +85,9 @@ public virtual IEnumerable BatchCommands( batch.ModificationCommands.SelectMany(c => c.Entries), batch.ModificationCommands.Count); } - yield return batch; + batch.Complete(); + + yield return (batch, true); } else { @@ -92,7 +96,10 @@ public virtual IEnumerable BatchCommands( foreach (var command in batch.ModificationCommands) { - yield return StartNewBatch(parameterNameGenerator, command); + batch = StartNewBatch(parameterNameGenerator, command); + batch.Complete(); + + yield return (batch, true); } } @@ -100,6 +107,8 @@ public virtual IEnumerable BatchCommands( } } + var hasMoreCommandSets = commandSetIndex < sortedCommandSets.Count - 1; + if (batch.ModificationCommands.Count == 1 || batch.ModificationCommands.Count >= _minBatchSize) { @@ -109,16 +118,21 @@ public virtual IEnumerable BatchCommands( batch.ModificationCommands.SelectMany(c => c.Entries), batch.ModificationCommands.Count); } - yield return batch; + batch.Complete(); + + yield return (batch, hasMoreCommandSets); } else { Dependencies.UpdateLogger.BatchSmallerThanMinBatchSize( batch.ModificationCommands.SelectMany(c => c.Entries), batch.ModificationCommands.Count, _minBatchSize); - foreach (var command in batch.ModificationCommands) + for (var commandIndex = 0; commandIndex < batch.ModificationCommands.Count; commandIndex++) { - yield return StartNewBatch(parameterNameGenerator, command); + var singleCommandBatch = StartNewBatch(parameterNameGenerator, batch.ModificationCommands[commandIndex]); + singleCommandBatch.Complete(); + + yield return (singleCommandBatch, hasMoreCommandSets || commandIndex < batch.ModificationCommands.Count - 1); } } } diff --git a/src/EFCore.Relational/Update/ModificationCommandBatch.cs b/src/EFCore.Relational/Update/ModificationCommandBatch.cs index b01c52628af..152e665be45 100644 --- a/src/EFCore.Relational/Update/ModificationCommandBatch.cs +++ b/src/EFCore.Relational/Update/ModificationCommandBatch.cs @@ -33,6 +33,16 @@ public abstract class ModificationCommandBatch /// public abstract bool AddCommand(IReadOnlyModificationCommand modificationCommand); + /// + /// Indicates that no more commands will be added to this batch, and prepares it for execution. + /// + public abstract void Complete(); + + /// + /// Indicates whether the batch requires a transaction in order to execute correctly. + /// + public abstract bool RequiresTransaction { get; } + /// /// Sends insert/update/delete commands to the database. /// diff --git a/src/EFCore.Relational/Update/ReaderModificationCommandBatch.cs b/src/EFCore.Relational/Update/ReaderModificationCommandBatch.cs index b84e25a6b57..1a35f6aa66c 100644 --- a/src/EFCore.Relational/Update/ReaderModificationCommandBatch.cs +++ b/src/EFCore.Relational/Update/ReaderModificationCommandBatch.cs @@ -21,6 +21,8 @@ namespace Microsoft.EntityFrameworkCore.Update; public abstract class ReaderModificationCommandBatch : ModificationCommandBatch { private readonly List _modificationCommands = new(); + private string? _finalCommandText; + private bool _requiresTransaction = true; /// /// Creates a new instance. @@ -74,6 +76,11 @@ public override IReadOnlyList ModificationCommands /// public override bool AddCommand(IReadOnlyModificationCommand modificationCommand) { + if (_finalCommandText is not null) + { + throw new InvalidOperationException(RelationalStrings.ModificationCommandBatchAlreadyComplete); + } + if (ModificationCommands.Count == 0) { ResetCommandText(); @@ -103,15 +110,35 @@ public override bool AddCommand(IReadOnlyModificationCommand modificationCommand /// protected virtual void ResetCommandText() { - if (CachedCommandText.Length > 0) - { - CachedCommandText = new StringBuilder(); - } + CachedCommandText.Clear(); UpdateSqlGenerator.AppendBatchHeader(CachedCommandText); + _batchHeaderLength = CachedCommandText.Length; + + SetRequiresTransaction(true); + LastCachedCommandIndex = -1; } + private int _batchHeaderLength; + + /// + /// Whether any SQL has already been added to the batch command text. + /// + protected virtual bool IsCachedCommandTextEmpty + => CachedCommandText.Length == _batchHeaderLength; + + /// + public override bool RequiresTransaction + => _requiresTransaction; + + /// + /// Sets whether the batch requires a transaction in order to execute correctly. + /// + /// Whether the batch requires a transaction in order to execute correctly. + protected virtual void SetRequiresTransaction(bool requiresTransaction) + => _requiresTransaction = requiresTransaction; + /// /// Checks whether a new command can be added to the batch. /// @@ -126,18 +153,15 @@ protected virtual void ResetCommandText() protected abstract bool IsCommandTextValid(); /// - /// Gets the command text for all the commands in the current batch and also caches it - /// on . + /// Processes all unprocessed commands in the batch, making sure their corresponding SQL is populated in + /// . /// - /// The command text. - protected virtual string GetCommandText() + protected virtual void UpdateCachedCommandText() { for (var i = LastCachedCommandIndex + 1; i < ModificationCommands.Count; i++) { UpdateCachedCommandText(i); } - - return CachedCommandText.ToString(); } /// @@ -149,22 +173,35 @@ protected virtual void UpdateCachedCommandText(int commandPosition) { var newModificationCommand = ModificationCommands[commandPosition]; + bool requiresTransaction; + switch (newModificationCommand.EntityState) { case EntityState.Added: CommandResultSet[commandPosition] = - UpdateSqlGenerator.AppendInsertOperation(CachedCommandText, newModificationCommand, commandPosition); + UpdateSqlGenerator.AppendInsertOperation( + CachedCommandText, newModificationCommand, commandPosition, out requiresTransaction); break; case EntityState.Modified: CommandResultSet[commandPosition] = - UpdateSqlGenerator.AppendUpdateOperation(CachedCommandText, newModificationCommand, commandPosition); + UpdateSqlGenerator.AppendUpdateOperation( + CachedCommandText, newModificationCommand, commandPosition, out requiresTransaction); break; case EntityState.Deleted: CommandResultSet[commandPosition] = - UpdateSqlGenerator.AppendDeleteOperation(CachedCommandText, newModificationCommand, commandPosition); + UpdateSqlGenerator.AppendDeleteOperation( + CachedCommandText, newModificationCommand, commandPosition, out requiresTransaction); break; + + default: + throw new InvalidOperationException( + RelationalStrings.ModificationCommandInvalidEntityState( + newModificationCommand.Entries[0].EntityType, + newModificationCommand.EntityState)); } + _requiresTransaction = commandPosition > 0 || requiresTransaction; + LastCachedCommandIndex = commandPosition; } @@ -175,15 +212,33 @@ protected virtual void UpdateCachedCommandText(int commandPosition) protected virtual int GetParameterCount() => ModificationCommands.Sum(c => c.ColumnModifications.Count); + /// + public override void Complete() + { + UpdateCachedCommandText(); + + // Some database have a mode where autocommit is off, and so executing a command outside of an explicit transaction implicitly + // creates a new transaction (which needs to be explicitly committed). + // The below is a hook for allowing providers to turn autocommit on, in case it's off. + if (!RequiresTransaction) + { + UpdateSqlGenerator.PrependEnsureAutocommit(CachedCommandText); + } + + _finalCommandText = CachedCommandText.ToString(); + } + /// /// Generates a for the batch. /// /// The command. protected virtual RawSqlCommand CreateStoreCommand() { + Check.DebugAssert(_finalCommandText is not null, "_finalCommandText is not null, checked in Execute"); + var commandBuilder = Dependencies.CommandBuilderFactory .Create() - .Append(GetCommandText()); + .Append(_finalCommandText); var parameterValues = new Dictionary(GetParameterCount()); @@ -229,6 +284,11 @@ protected virtual RawSqlCommand CreateStoreCommand() /// The connection to the database to update. public override void Execute(IRelationalConnection connection) { + if (_finalCommandText is null) + { + throw new InvalidOperationException(RelationalStrings.ModificationCommandBatchNotComplete); + } + var storeCommand = CreateStoreCommand(); try @@ -263,6 +323,11 @@ public override async Task ExecuteAsync( IRelationalConnection connection, CancellationToken cancellationToken = default) { + if (_finalCommandText is null) + { + throw new InvalidOperationException(RelationalStrings.ModificationCommandBatchNotComplete); + } + var storeCommand = CreateStoreCommand(); try diff --git a/src/EFCore.Relational/Update/UpdateSqlGenerator.cs b/src/EFCore.Relational/Update/UpdateSqlGenerator.cs index f18c7f3fc3b..f1768588955 100644 --- a/src/EFCore.Relational/Update/UpdateSqlGenerator.cs +++ b/src/EFCore.Relational/Update/UpdateSqlGenerator.cs @@ -53,11 +53,13 @@ protected virtual ISqlGenerationHelper SqlGenerationHelper /// The builder to which the SQL should be appended. /// The command that represents the delete operation. /// The ordinal of this command in the batch. + /// Returns whether the SQL appended must be executed in a transaction to work correctly. /// The for the command. public virtual ResultSetMapping AppendInsertOperation( StringBuilder commandStringBuilder, IReadOnlyModificationCommand command, - int commandPosition) + int commandPosition, + out bool requiresTransaction) { var name = command.TableName; var schema = command.Schema; @@ -72,9 +74,13 @@ public virtual ResultSetMapping AppendInsertOperation( { var keyOperations = operations.Where(o => o.IsKey).ToList(); + requiresTransaction = true; + return AppendSelectAffectedCommand(commandStringBuilder, name, schema, readOperations, keyOperations, commandPosition); } + requiresTransaction = false; + return ResultSetMapping.NoResultSet; } @@ -84,11 +90,13 @@ public virtual ResultSetMapping AppendInsertOperation( /// The builder to which the SQL should be appended. /// The command that represents the delete operation. /// The ordinal of this command in the batch. + /// Returns whether the SQL appended must be executed in a transaction to work correctly. /// The for the command. public virtual ResultSetMapping AppendUpdateOperation( StringBuilder commandStringBuilder, IReadOnlyModificationCommand command, - int commandPosition) + int commandPosition, + out bool requiresTransaction) { var name = command.TableName; var schema = command.Schema; @@ -104,9 +112,13 @@ public virtual ResultSetMapping AppendUpdateOperation( { var keyOperations = operations.Where(o => o.IsKey).ToList(); + requiresTransaction = true; + return AppendSelectAffectedCommand(commandStringBuilder, name, schema, readOperations, keyOperations, commandPosition); } + requiresTransaction = false; + return AppendSelectAffectedCountCommand(commandStringBuilder, name, schema, commandPosition); } @@ -116,11 +128,13 @@ public virtual ResultSetMapping AppendUpdateOperation( /// The builder to which the SQL should be appended. /// The command that represents the delete operation. /// The ordinal of this command in the batch. + /// Returns whether the SQL appended must be executed in a transaction to work correctly. /// The for the command. public virtual ResultSetMapping AppendDeleteOperation( StringBuilder commandStringBuilder, IReadOnlyModificationCommand command, - int commandPosition) + int commandPosition, + out bool requiresTransaction) { var name = command.TableName; var schema = command.Schema; @@ -128,6 +142,8 @@ public virtual ResultSetMapping AppendDeleteOperation( AppendDeleteCommand(commandStringBuilder, name, schema, conditionOperations); + requiresTransaction = false; + return AppendSelectAffectedCountCommand(commandStringBuilder, name, schema, commandPosition); } @@ -530,6 +546,14 @@ public virtual void AppendBatchHeader(StringBuilder commandStringBuilder) { } + /// + /// Prepends a SQL command for turning on autocommit mode in the database, in case it is off. + /// + /// The builder to which the SQL should be prepended. + public virtual void PrependEnsureAutocommit(StringBuilder commandStringBuilder) + { + } + /// /// Generates SQL that will obtain the next value in the given sequence. /// diff --git a/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs b/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs index 616cbdcc5d1..1645b116e79 100644 --- a/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs +++ b/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs @@ -1374,7 +1374,7 @@ protected override void Generate( GenerateIdentityInsert(builder, operation, on: true, model); var sqlBuilder = new StringBuilder(); - ((SqlServerUpdateSqlGenerator)Dependencies.UpdateSqlGenerator).AppendBulkInsertOperation( + ((ISqlServerUpdateSqlGenerator)Dependencies.UpdateSqlGenerator).AppendBulkInsertOperation( sqlBuilder, GenerateModificationCommands(operation, model).ToList(), 0); diff --git a/src/EFCore.SqlServer/Update/Internal/ISqlServerUpdateSqlGenerator.cs b/src/EFCore.SqlServer/Update/Internal/ISqlServerUpdateSqlGenerator.cs index 4099dd73dec..c7f92fb002a 100644 --- a/src/EFCore.SqlServer/Update/Internal/ISqlServerUpdateSqlGenerator.cs +++ b/src/EFCore.SqlServer/Update/Internal/ISqlServerUpdateSqlGenerator.cs @@ -27,5 +27,18 @@ public interface ISqlServerUpdateSqlGenerator : IUpdateSqlGenerator ResultSetMapping AppendBulkInsertOperation( StringBuilder commandStringBuilder, IReadOnlyList modificationCommands, - int commandPosition); + int commandPosition, + out bool requiresTransaction); + + /// + /// 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. + /// + ResultSetMapping AppendBulkInsertOperation( + StringBuilder commandStringBuilder, + IReadOnlyList modificationCommands, + int commandPosition) + => AppendBulkInsertOperation(commandStringBuilder, modificationCommands, commandPosition, out _); } diff --git a/src/EFCore.SqlServer/Update/Internal/SqlServerModificationCommandBatch.cs b/src/EFCore.SqlServer/Update/Internal/SqlServerModificationCommandBatch.cs index c775d2b83f0..18e4ee6d501 100644 --- a/src/EFCore.SqlServer/Update/Internal/SqlServerModificationCommandBatch.cs +++ b/src/EFCore.SqlServer/Update/Internal/SqlServerModificationCommandBatch.cs @@ -85,7 +85,8 @@ protected override bool IsCommandTextValid() { if (--_commandsLeftToLengthCheck < 0) { - var commandTextLength = GetCommandText().Length; + UpdateCachedCommandText(); + var commandTextLength = CachedCommandText.Length; if (commandTextLength >= MaxScriptLength) { return false; @@ -138,28 +139,24 @@ private static int CountParameters(IReadOnlyModificationCommand modificationComm protected override void ResetCommandText() { base.ResetCommandText(); + _bulkInsertCommands.Clear(); } - /// - /// 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 override string GetCommandText() - => base.GetCommandText() + GetBulkInsertCommandText(ModificationCommands.Count); - - private string GetBulkInsertCommandText(int lastIndex) + private void AppendBulkInsertCommandText(int lastIndex) { if (_bulkInsertCommands.Count == 0) { - return string.Empty; + return; } - var stringBuilder = new StringBuilder(); + var wasCachedCommandTextEmpty = IsCachedCommandTextEmpty; + var resultSetMapping = UpdateSqlGenerator.AppendBulkInsertOperation( - stringBuilder, _bulkInsertCommands, lastIndex - _bulkInsertCommands.Count); + CachedCommandText, _bulkInsertCommands, lastIndex - _bulkInsertCommands.Count, out var requiresTransaction); + + SetRequiresTransaction(!wasCachedCommandTextEmpty || requiresTransaction); + for (var i = lastIndex - _bulkInsertCommands.Count; i < lastIndex; i++) { CommandResultSet[i] = resultSetMapping; @@ -169,8 +166,19 @@ private string GetBulkInsertCommandText(int lastIndex) { CommandResultSet[lastIndex - 1] = ResultSetMapping.LastInResultSet; } + } - return stringBuilder.ToString(); + /// + /// 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 override void UpdateCachedCommandText() + { + base.UpdateCachedCommandText(); + + AppendBulkInsertCommandText(ModificationCommands.Count); } /// @@ -188,7 +196,9 @@ protected override void UpdateCachedCommandText(int commandPosition) if (_bulkInsertCommands.Count > 0 && !CanBeInsertedInSameStatement(_bulkInsertCommands[0], newModificationCommand)) { - CachedCommandText.Append(GetBulkInsertCommandText(commandPosition)); + // The new Add command cannot be added to the pending bulk insert commands (e.g. different table). + // Write out the pending commands before starting a new pending chain. + AppendBulkInsertCommandText(commandPosition); _bulkInsertCommands.Clear(); } @@ -198,8 +208,14 @@ protected override void UpdateCachedCommandText(int commandPosition) } else { - CachedCommandText.Append(GetBulkInsertCommandText(commandPosition)); - _bulkInsertCommands.Clear(); + // If we have any pending bulk insert commands, write them out before the next non-Add command + if (_bulkInsertCommands.Count > 0) + { + // Note that we don't care about the transactionality of the bulk insert SQL, since there's the additional non-Add + // command coming right afterwards, and so a transaction is required in any case. + AppendBulkInsertCommandText(commandPosition); + _bulkInsertCommands.Clear(); + } base.UpdateCachedCommandText(commandPosition); } diff --git a/src/EFCore.SqlServer/Update/Internal/SqlServerUpdateSqlGenerator.cs b/src/EFCore.SqlServer/Update/Internal/SqlServerUpdateSqlGenerator.cs index 709e7558e76..1d18cee739b 100644 --- a/src/EFCore.SqlServer/Update/Internal/SqlServerUpdateSqlGenerator.cs +++ b/src/EFCore.SqlServer/Update/Internal/SqlServerUpdateSqlGenerator.cs @@ -35,7 +35,8 @@ public SqlServerUpdateSqlGenerator( public virtual ResultSetMapping AppendBulkInsertOperation( StringBuilder commandStringBuilder, IReadOnlyList modificationCommands, - int commandPosition) + int commandPosition, + out bool requiresTransaction) { var table = StoreObjectIdentifier.Table(modificationCommands[0].TableName, modificationCommands[0].Schema); if (modificationCommands.Count == 1) @@ -45,13 +46,16 @@ public virtual ResultSetMapping AppendBulkInsertOperation( !o.IsKey || !o.IsRead || o.Property?.GetValueGenerationStrategy(table) == SqlServerValueGenerationStrategy.IdentityColumn) - ? AppendInsertOperation(commandStringBuilder, modificationCommands[0], commandPosition) + // Do a regular INSERT+SELECT for IDENTITY, but not if there are any non-IDENTITY generated columns + ? AppendInsertOperation(commandStringBuilder, modificationCommands[0], commandPosition, out requiresTransaction) + // If we have a non-identity generated column, do INSERT ... OUTPUT INTO @inserted; SELECT ... FROM @inserted : AppendInsertOperationWithServerKeys( commandStringBuilder, modificationCommands[0], modificationCommands[0].ColumnModifications.Where(o => o.IsKey).ToList(), modificationCommands[0].ColumnModifications.Where(o => o.IsRead).ToList(), - commandPosition); + commandPosition, + out requiresTransaction); } var readOperations = modificationCommands[0].ColumnModifications.Where(o => o.IsRead).ToList(); @@ -59,18 +63,23 @@ public virtual ResultSetMapping AppendBulkInsertOperation( var keyOperations = modificationCommands[0].ColumnModifications.Where(o => o.IsKey).ToList(); var defaultValuesOnly = writeOperations.Count == 0; - var nonIdentityOperations = modificationCommands[0].ColumnModifications - .Where(o => o.Property?.GetValueGenerationStrategy(table) != SqlServerValueGenerationStrategy.IdentityColumn) + var writableOperations = modificationCommands[0].ColumnModifications + .Where(o => + o.Property?.GetValueGenerationStrategy(table) != SqlServerValueGenerationStrategy.IdentityColumn + && o.Property?.GetComputedColumnSql() is null + && o.Property?.GetColumnType() is not "rowversion" and not "timestamp") .ToList(); if (defaultValuesOnly) { - if (nonIdentityOperations.Count == 0 + if (writableOperations.Count == 0 || readOperations.Count == 0) { + requiresTransaction = modificationCommands.Count > 1; foreach (var modification in modificationCommands) { - AppendInsertOperation(commandStringBuilder, modification, commandPosition); + AppendInsertOperation(commandStringBuilder, modification, commandPosition, out var localRequiresTransaction); + requiresTransaction = requiresTransaction || localRequiresTransaction; } return readOperations.Count == 0 @@ -78,31 +87,36 @@ public virtual ResultSetMapping AppendBulkInsertOperation( : ResultSetMapping.LastInResultSet; } - if (nonIdentityOperations.Count > 1) + if (writableOperations.Count > 1) { - nonIdentityOperations.RemoveRange(1, nonIdentityOperations.Count - 1); + writableOperations.RemoveRange(1, writableOperations.Count - 1); } } if (readOperations.Count == 0) { - return AppendBulkInsertWithoutServerValues(commandStringBuilder, modificationCommands, writeOperations); + return AppendBulkInsertWithoutServerValues( + commandStringBuilder, modificationCommands, writeOperations, out requiresTransaction); } if (defaultValuesOnly) { return AppendBulkInsertWithServerValuesOnly( - commandStringBuilder, modificationCommands, commandPosition, nonIdentityOperations, keyOperations, readOperations); + commandStringBuilder, modificationCommands, commandPosition, writableOperations, keyOperations, readOperations, + out requiresTransaction); } if (modificationCommands[0].Entries.SelectMany(e => e.EntityType.GetAllBaseTypesInclusive()) .Any(e => e.IsMemoryOptimized())) { - if (!nonIdentityOperations.Any(o => o.IsRead && o.IsKey)) + requiresTransaction = modificationCommands.Count > 1; + + if (!writableOperations.Any(o => o.IsRead && o.IsKey)) { foreach (var modification in modificationCommands) { - AppendInsertOperation(commandStringBuilder, modification, commandPosition++); + AppendInsertOperation(commandStringBuilder, modification, commandPosition++, out var localRequiresTransaction); + requiresTransaction = requiresTransaction || localRequiresTransaction; } } else @@ -110,7 +124,9 @@ public virtual ResultSetMapping AppendBulkInsertOperation( foreach (var modification in modificationCommands) { AppendInsertOperationWithServerKeys( - commandStringBuilder, modification, keyOperations, readOperations, commandPosition++); + commandStringBuilder, modification, keyOperations, readOperations, commandPosition++, + out var localRequiresTransaction); + requiresTransaction = requiresTransaction || localRequiresTransaction; } } @@ -118,13 +134,15 @@ public virtual ResultSetMapping AppendBulkInsertOperation( } return AppendBulkInsertWithServerValues( - commandStringBuilder, modificationCommands, commandPosition, writeOperations, keyOperations, readOperations); + commandStringBuilder, modificationCommands, commandPosition, writeOperations, keyOperations, readOperations, + out requiresTransaction); } private ResultSetMapping AppendBulkInsertWithoutServerValues( StringBuilder commandStringBuilder, IReadOnlyList modificationCommands, - List writeOperations) + List writeOperations, + out bool requiresTransaction) { Check.DebugAssert(writeOperations.Count > 0, $"writeOperations.Count is {writeOperations.Count}"); @@ -143,6 +161,8 @@ private ResultSetMapping AppendBulkInsertWithoutServerValues( commandStringBuilder.AppendLine(SqlGenerationHelper.StatementTerminator); + requiresTransaction = false; + return ResultSetMapping.NoResultSet; } @@ -158,7 +178,8 @@ private ResultSetMapping AppendBulkInsertWithServerValues( int commandPosition, List writeOperations, List keyOperations, - List readOperations) + List readOperations, + out bool requiresTransaction) { AppendDeclareTable( commandStringBuilder, @@ -190,6 +211,8 @@ private ResultSetMapping AppendBulkInsertWithServerValues( commandStringBuilder, readOperations, keyOperations, InsertedTableBaseName, commandPosition, name, schema, orderColumn: PositionColumnName); + requiresTransaction = true; + return ResultSetMapping.NotLastInResultSet; } @@ -197,28 +220,31 @@ private ResultSetMapping AppendBulkInsertWithServerValuesOnly( StringBuilder commandStringBuilder, IReadOnlyList modificationCommands, int commandPosition, - List nonIdentityOperations, + List writableOperations, List keyOperations, - List readOperations) + List readOperations, + out bool requiresTransaction) { AppendDeclareTable(commandStringBuilder, InsertedTableBaseName, commandPosition, keyOperations); var name = modificationCommands[0].TableName; var schema = modificationCommands[0].Schema; - AppendInsertCommandHeader(commandStringBuilder, name, schema, nonIdentityOperations); + AppendInsertCommandHeader(commandStringBuilder, name, schema, writableOperations); AppendOutputClause(commandStringBuilder, keyOperations, InsertedTableBaseName, commandPosition); - AppendValuesHeader(commandStringBuilder, nonIdentityOperations); - AppendValues(commandStringBuilder, name, schema, nonIdentityOperations); + AppendValuesHeader(commandStringBuilder, writableOperations); + AppendValues(commandStringBuilder, name, schema, writableOperations); for (var i = 1; i < modificationCommands.Count; i++) { commandStringBuilder.AppendLine(","); - AppendValues(commandStringBuilder, name, schema, nonIdentityOperations); + AppendValues(commandStringBuilder, name, schema, writableOperations); } commandStringBuilder.Append(SqlGenerationHelper.StatementTerminator); AppendSelectCommand(commandStringBuilder, readOperations, keyOperations, InsertedTableBaseName, commandPosition, name, schema); + requiresTransaction = true; + return ResultSetMapping.NotLastInResultSet; } @@ -399,7 +425,8 @@ private ResultSetMapping AppendInsertOperationWithServerKeys( IReadOnlyModificationCommand command, IReadOnlyList keyOperations, IReadOnlyList readOperations, - int commandPosition) + int commandPosition, + out bool requiresTransaction) { var name = command.TableName; var schema = command.Schema; @@ -415,6 +442,8 @@ private ResultSetMapping AppendInsertOperationWithServerKeys( AppendValues(commandStringBuilder, name, schema, writeOperations); commandStringBuilder.Append(SqlGenerationHelper.StatementTerminator); + requiresTransaction = true; + return AppendSelectCommand( commandStringBuilder, readOperations, keyOperations, InsertedTableBaseName, commandPosition, name, schema); } @@ -515,6 +544,19 @@ public override void AppendBatchHeader(StringBuilder commandStringBuilder) .Append("SET NOCOUNT ON") .AppendLine(SqlGenerationHelper.StatementTerminator); + /// + /// 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 void PrependEnsureAutocommit(StringBuilder commandStringBuilder) + { + // SQL Server allows turning off autocommit via the IMPLICIT_TRANSACTIONS setting (see + // https://docs.microsoft.com/sql/t-sql/statements/set-implicit-transactions-transact-sql). + commandStringBuilder.Insert(0, $"SET IMPLICIT_TRANSACTIONS OFF{SqlGenerationHelper.StatementTerminator}{Environment.NewLine}"); + } + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in diff --git a/test/EFCore.Relational.Specification.Tests/TestModels/StoreValueGenerationModel/StoreValueGenerationContext.cs b/test/EFCore.Relational.Specification.Tests/TestModels/StoreValueGenerationModel/StoreValueGenerationContext.cs new file mode 100644 index 00000000000..37ad0921d58 --- /dev/null +++ b/test/EFCore.Relational.Specification.Tests/TestModels/StoreValueGenerationModel/StoreValueGenerationContext.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.TestModels.StoreValueGenerationModel; + +#nullable enable + +public class StoreValueGenerationContext : PoolableDbContext +{ + public StoreValueGenerationContext(DbContextOptions options) + : base(options) + { + } + + public DbSet WithSomeDatabaseGenerated + => Set(nameof(WithSomeDatabaseGenerated)); + public DbSet WithSomeDatabaseGenerated2 + => Set(nameof(WithSomeDatabaseGenerated2)); + + public DbSet WithNoDatabaseGenerated + => Set(nameof(WithNoDatabaseGenerated)); + public DbSet WithNoDatabaseGenerated2 + => Set(nameof(WithNoDatabaseGenerated2)); + + public DbSet WithAllDatabaseGenerated + => Set(nameof(WithAllDatabaseGenerated)); + public DbSet WithAllDatabaseGenerated2 + => Set(nameof(WithAllDatabaseGenerated2)); +} diff --git a/test/EFCore.Relational.Specification.Tests/TestModels/StoreValueGenerationModel/StoreValueGenerationData.cs b/test/EFCore.Relational.Specification.Tests/TestModels/StoreValueGenerationModel/StoreValueGenerationData.cs new file mode 100644 index 00000000000..b19365b9f8d --- /dev/null +++ b/test/EFCore.Relational.Specification.Tests/TestModels/StoreValueGenerationModel/StoreValueGenerationData.cs @@ -0,0 +1,25 @@ +// 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.TestModels.StoreValueGenerationModel; + +#nullable enable + +public class StoreValueGenerationData : IEquatable +{ + // Generated on add (except for WithNoDatabaseGenerated2) + public int Id { get; set; } + + // Generated on update (except for WithNoDatabaseGenerated2) + public int Data1 { get; set; } + + // Not generated, except for for WithAllDatabaseGenerated + public int Data2 { get; set; } + + public bool Equals(StoreValueGenerationData? other) + => other is not null + && (ReferenceEquals(this, other) + || (Id == other.Id + && Data1 == other.Data1 + && Data2 == other.Data2)); +} diff --git a/test/EFCore.Relational.Specification.Tests/Update/StoreValueGenerationFixtureBase.cs b/test/EFCore.Relational.Specification.Tests/Update/StoreValueGenerationFixtureBase.cs new file mode 100644 index 00000000000..b0b27159638 --- /dev/null +++ b/test/EFCore.Relational.Specification.Tests/Update/StoreValueGenerationFixtureBase.cs @@ -0,0 +1,91 @@ +// 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.TestModels.StoreValueGenerationModel; + +namespace Microsoft.EntityFrameworkCore.Update; + +#nullable enable + +public abstract class StoreValueGenerationFixtureBase : SharedStoreFixtureBase +{ + protected override string StoreName { get; } = "StoreValueGenerationTest"; + + protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext context) + { + foreach (var name in new[] + { + nameof(StoreValueGenerationContext.WithNoDatabaseGenerated), + nameof(StoreValueGenerationContext.WithNoDatabaseGenerated2) + }) + { + modelBuilder + .SharedTypeEntity(name) + .Property(w => w.Id) + .ValueGeneratedNever(); + } + + foreach (var name in new[] + { + nameof(StoreValueGenerationContext.WithSomeDatabaseGenerated), + nameof(StoreValueGenerationContext.WithSomeDatabaseGenerated2), + nameof(StoreValueGenerationContext.WithAllDatabaseGenerated), + nameof(StoreValueGenerationContext.WithAllDatabaseGenerated2) + }) + { + modelBuilder + .SharedTypeEntity(name) + .Property(w => w.Data1) + .HasComputedColumnSql("80"); + } + + foreach (var name in new[] + { + nameof(StoreValueGenerationContext.WithAllDatabaseGenerated), + nameof(StoreValueGenerationContext.WithAllDatabaseGenerated2) + }) + { + modelBuilder + .SharedTypeEntity(name) + .Property(w => w.Data2) + .HasComputedColumnSql("81"); + } + } + + protected override void Seed(StoreValueGenerationContext context) + { + context.WithSomeDatabaseGenerated.AddRange(new() { Data2 = 1 }, new() { Data2 = 2 }); + context.WithSomeDatabaseGenerated2.AddRange(new() { Data2 = 1 }, new() { Data2 = 2 }); + + context.WithNoDatabaseGenerated.AddRange(new() { Id = 1, Data1 = 10, Data2 = 20 }, new() { Id = 2, Data1 = 11, Data2 = 21 }); + context.WithNoDatabaseGenerated2.AddRange(new() { Id = 1, Data1 = 10, Data2 = 20 }, new() { Id = 2, Data1 = 11, Data2 = 21 }); + + context.WithAllDatabaseGenerated.AddRange(new(), new()); + context.WithAllDatabaseGenerated2.AddRange(new(), new()); + + context.SaveChanges(); + } + + protected override void Clean(DbContext context) + { + var storeValueGenerationContext = CreateContext(); + + storeValueGenerationContext.WithSomeDatabaseGenerated.RemoveRange(storeValueGenerationContext.WithSomeDatabaseGenerated); + storeValueGenerationContext.WithSomeDatabaseGenerated2.RemoveRange(storeValueGenerationContext.WithSomeDatabaseGenerated2); + + storeValueGenerationContext.WithNoDatabaseGenerated.RemoveRange(storeValueGenerationContext.WithNoDatabaseGenerated); + storeValueGenerationContext.WithNoDatabaseGenerated2.RemoveRange(storeValueGenerationContext.WithNoDatabaseGenerated2); + + storeValueGenerationContext.WithAllDatabaseGenerated.RemoveRange(storeValueGenerationContext.WithAllDatabaseGenerated); + storeValueGenerationContext.WithAllDatabaseGenerated2.RemoveRange(storeValueGenerationContext.WithAllDatabaseGenerated2); + + storeValueGenerationContext.SaveChanges(); + } + + protected override bool ShouldLogCategory(string logCategory) + => logCategory == DbLoggerCategory.Database.Transaction.Name + || logCategory == DbLoggerCategory.Database.Command.Name; + + public TestSqlLoggerFactory TestSqlLoggerFactory + => (TestSqlLoggerFactory)ListLoggerFactory; +} diff --git a/test/EFCore.Relational.Specification.Tests/Update/StoreValueGenerationTestBase.cs b/test/EFCore.Relational.Specification.Tests/Update/StoreValueGenerationTestBase.cs new file mode 100644 index 00000000000..5b89e789ace --- /dev/null +++ b/test/EFCore.Relational.Specification.Tests/Update/StoreValueGenerationTestBase.cs @@ -0,0 +1,379 @@ +// 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.TestModels.StoreValueGenerationModel; + +namespace Microsoft.EntityFrameworkCore.Update; + +#nullable enable + +public abstract class StoreValueGenerationTestBase : IClassFixture + where TFixture : StoreValueGenerationFixtureBase +{ + protected StoreValueGenerationTestBase(TFixture fixture) + { + Fixture = fixture; + + fixture.Reseed(); + + ClearLog(); + } + + #region Single operation + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Add_with_generated_values(bool async) + => Test(EntityState.Added, secondOperationType: null, GeneratedValues.Some, async); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Add_with_no_generated_values(bool async) + => Test(EntityState.Added, secondOperationType: null, GeneratedValues.None, async); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Add_with_all_generated_values(bool async) + => Test(EntityState.Added, secondOperationType: null, GeneratedValues.All, async); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Modify_with_generated_values(bool async) + => Test(EntityState.Modified, secondOperationType: null, GeneratedValues.Some, async); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Modify_with_no_generated_values(bool async) + => Test(EntityState.Modified, secondOperationType: null, GeneratedValues.None, async); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Delete(bool async) + => Test(EntityState.Deleted, secondOperationType: null, GeneratedValues.Some, async); + + #endregion Single operation + + #region Two operations with same entity type + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Add_Add_with_same_entity_type_and_generated_values(bool async) + => Test(EntityState.Added, EntityState.Added, GeneratedValues.Some, async, withSameEntityType: true); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Add_Add_with_same_entity_type_and_no_generated_values(bool async) + => Test(EntityState.Added, EntityState.Added, GeneratedValues.None, async, withSameEntityType: true); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Add_Add_with_same_entity_type_and_all_generated_values(bool async) + => Test(EntityState.Added, EntityState.Added, GeneratedValues.All, async, withSameEntityType: true); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Modify_Modify_with_same_entity_type_and_generated_values(bool async) + => Test(EntityState.Modified, EntityState.Modified, GeneratedValues.Some, async, withSameEntityType: true); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Modify_Modify_with_same_entity_type_and_no_generated_values(bool async) + => Test(EntityState.Modified, EntityState.Modified, GeneratedValues.None, async, withSameEntityType: true); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Delete_Delete_with_same_entity_type(bool async) + => Test(EntityState.Deleted, EntityState.Deleted, GeneratedValues.Some, async, withSameEntityType: true); + + #endregion Two operations with same entity type + + #region Two operations with different entity types + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Add_Add_with_different_entity_types_and_generated_values(bool async) + => Test(EntityState.Added, EntityState.Added, GeneratedValues.Some, async, withSameEntityType: false); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Add_Add_with_different_entity_types_and_no_generated_values(bool async) + => Test(EntityState.Added, EntityState.Added, GeneratedValues.None, async, withSameEntityType: false); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Add_Add_with_different_entity_types_and_all_generated_values(bool async) + => Test(EntityState.Added, EntityState.Added, GeneratedValues.All, async, withSameEntityType: false); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Modify_Modify_with_different_entity_types_and_generated_values(bool async) + => Test(EntityState.Modified, EntityState.Modified, GeneratedValues.Some, async, withSameEntityType: false); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Modify_Modify_with_different_entity_types_and_no_generated_values(bool async) + => Test(EntityState.Modified, EntityState.Modified, GeneratedValues.None, async, withSameEntityType: false); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Delete_Delete_with_different_entity_types(bool async) + => Test(EntityState.Deleted, EntityState.Deleted, GeneratedValues.Some, async, withSameEntityType: false); + + #endregion Two operations with different entity types + + protected virtual async Task Test( + EntityState firstOperationType, + EntityState? secondOperationType, + GeneratedValues generatedValues, + bool async, + bool withSameEntityType = true) + { + await using var context = CreateContext(); + + var firstDbSet = generatedValues switch + { + GeneratedValues.Some => context.WithSomeDatabaseGenerated, + GeneratedValues.None => context.WithNoDatabaseGenerated, + GeneratedValues.All => context.WithAllDatabaseGenerated, + _ => throw new ArgumentOutOfRangeException(nameof(generatedValues)) + }; + + var secondDbSet = secondOperationType is null + ? null + : (generatedValues, withSameEntityType) switch + { + (GeneratedValues.Some, true) => context.WithSomeDatabaseGenerated, + (GeneratedValues.Some, false) => context.WithSomeDatabaseGenerated2, + (GeneratedValues.None, true) => context.WithNoDatabaseGenerated, + (GeneratedValues.None, false) => context.WithNoDatabaseGenerated2, + (GeneratedValues.All, true) => context.WithAllDatabaseGenerated, + (GeneratedValues.All, false) => context.WithAllDatabaseGenerated2, + _ => throw new ArgumentOutOfRangeException(nameof(generatedValues)) + }; + + StoreValueGenerationData first; + StoreValueGenerationData? second; + + switch (firstOperationType) + { + case EntityState.Added: + switch (generatedValues) + { + case GeneratedValues.Some: + first = new StoreValueGenerationData { Data2 = 1000 }; + firstDbSet.Add(first); + break; + case GeneratedValues.None: + first = new StoreValueGenerationData { Id = 100, Data1 = 1000, Data2 = 1000 }; + firstDbSet.Add(first); + break; + case GeneratedValues.All: + first = new StoreValueGenerationData(); + firstDbSet.Add(first); + break; + default: + throw new ArgumentOutOfRangeException(nameof(generatedValues)); + } + break; + + case EntityState.Modified: + switch (generatedValues) + { + case GeneratedValues.Some: + first = firstDbSet.OrderBy(w => w.Id).First(); + first.Data2 = 1000; + break; + case GeneratedValues.None: + first = firstDbSet.OrderBy(w => w.Id).First(); + (first.Data1, first.Data2) = (1000, 1000); + break; + default: + throw new ArgumentOutOfRangeException(nameof(generatedValues)); + } + break; + + case EntityState.Deleted: + switch (generatedValues) + { + case GeneratedValues.Some: + first = firstDbSet.OrderBy(w => w.Id).First(); + context.Remove(first); + break; + case GeneratedValues.None: + first = firstDbSet.OrderBy(w => w.Id).First(); + context.Remove(first); + break; + default: + throw new ArgumentOutOfRangeException(nameof(generatedValues)); + } + break; + + default: + throw new ArgumentOutOfRangeException(nameof(firstOperationType)); + } + + switch (secondOperationType) + { + case EntityState.Added: + switch (generatedValues) + { + case GeneratedValues.Some: + second = new StoreValueGenerationData { Data2 = 1001 }; + secondDbSet!.Add(second); + break; + case GeneratedValues.None: + second = new StoreValueGenerationData { Id = 101, Data1 = 1001, Data2 = 1001 }; + secondDbSet!.Add(second); + break; + case GeneratedValues.All: + second = new StoreValueGenerationData(); + secondDbSet!.Add(second); + break; + default: + throw new ArgumentOutOfRangeException(nameof(generatedValues)); + } + break; + + case EntityState.Modified: + switch (generatedValues) + { + case GeneratedValues.Some: + second = secondDbSet!.OrderBy(w => w.Id).Skip(1).First(); + second.Data2 = 1001; + break; + case GeneratedValues.None: + second = secondDbSet!.OrderBy(w => w.Id).Skip(1).First(); + (second.Data1, second.Data2) = (1001, 1001); + break; + default: + throw new ArgumentOutOfRangeException(nameof(generatedValues)); + } + break; + + case EntityState.Deleted: + switch (generatedValues) + { + case GeneratedValues.Some: + second = secondDbSet!.OrderBy(w => w.Id).Skip(1).First(); + context.Remove(second); + break; + case GeneratedValues.None: + second = secondDbSet!.OrderBy(w => w.Id).Skip(1).First(); + context.Remove(second); + break; + default: + throw new ArgumentOutOfRangeException(nameof(generatedValues)); + } + break; + + case null: + second = null; + break; + + default: + throw new ArgumentOutOfRangeException(nameof(firstOperationType)); + } + + // Execute + Fixture.ListLoggerFactory.Clear(); + + if (async) + { + await context.SaveChangesAsync(); + } + else + { + context.SaveChanges(); + } + + // Make sure a transaction was created (or not) + if (ShouldCreateImplicitTransaction(firstOperationType, secondOperationType, generatedValues, withSameEntityType)) + { + Assert.Contains(Fixture.ListLoggerFactory.Log, l => l.Id == RelationalEventId.TransactionStarted); + Assert.Contains(Fixture.ListLoggerFactory.Log, l => l.Id == RelationalEventId.TransactionCommitted); + } + else + { + Assert.DoesNotContain(Fixture.ListLoggerFactory.Log, l => l.Id == RelationalEventId.TransactionStarted); + Assert.DoesNotContain(Fixture.ListLoggerFactory.Log, l => l.Id == RelationalEventId.TransactionCommitted); + } + + // Make sure the updates executed in the expected number of commands + Assert.Equal( + ShouldExecuteInNumberOfCommands(firstOperationType, secondOperationType, generatedValues, withSameEntityType), + Fixture.ListLoggerFactory.Log.Count(l => l.Id == RelationalEventId.CommandExecuted)); + + // To make sure generated values have been propagated, re-load the rows from the database and compare + context.ChangeTracker.Clear(); + + using (Fixture.TestSqlLoggerFactory.SuspendRecordingEvents()) + { + if (firstOperationType != EntityState.Deleted) + { + Assert.Equal(await firstDbSet.FindAsync(first.Id), first); + } + + if (second is not null && secondOperationType != EntityState.Deleted) + { + Assert.Equal(await secondDbSet!.FindAsync(second.Id), second); + } + } + } + + /// + /// Providers can override this to specify when should create a transaction, and when not. + /// By default, it's assumed that multiple updates always require a transaction, whereas a single update never does. + /// + protected virtual bool ShouldCreateImplicitTransaction( + EntityState firstOperationType, + EntityState? secondOperationType, + GeneratedValues generatedValues, + bool withSameEntityType) + { + // By default, two changes require a transaction + if (secondOperationType is not null) + { + return true; + } + + // Deletes don't ever need to bring back database-generated values + if (firstOperationType == EntityState.Deleted) + { + return false; + } + + // By default, assume that fetching back database-generated values requires a transaction + return generatedValues != GeneratedValues.None; + } + + /// + /// Providers can override this to specify how many commands (batches) are used to execute the update. + /// By default, it's assumed all operations are batched in one command. + /// + protected virtual int ShouldExecuteInNumberOfCommands( + EntityState firstOperationType, + EntityState? secondOperationType, + GeneratedValues generatedValues, + bool withSameEntityType) + => 1; + + protected TFixture Fixture { get; } + + protected StoreValueGenerationContext CreateContext() + => Fixture.CreateContext(); + + public static IEnumerable IsAsyncData = new[] { new object[] { false }, new object[] { true } }; + + protected virtual void AssertSql(params string[] expected) + => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); + + protected virtual void ClearLog() + => Fixture.TestSqlLoggerFactory.Clear(); + + protected enum GeneratedValues + { + Some, + None, + All + } +} diff --git a/test/EFCore.Relational.Tests/TestUtilities/FakeProvider/FakeSqlGenerator.cs b/test/EFCore.Relational.Tests/TestUtilities/FakeProvider/FakeSqlGenerator.cs index 16607104898..3a45ae9629b 100644 --- a/test/EFCore.Relational.Tests/TestUtilities/FakeProvider/FakeSqlGenerator.cs +++ b/test/EFCore.Relational.Tests/TestUtilities/FakeProvider/FakeSqlGenerator.cs @@ -13,28 +13,31 @@ public FakeSqlGenerator(UpdateSqlGeneratorDependencies dependencies) public override ResultSetMapping AppendInsertOperation( StringBuilder commandStringBuilder, IReadOnlyModificationCommand command, - int commandPosition) + int commandPosition, + out bool requiresTransaction) { AppendInsertOperationCalls++; - return base.AppendInsertOperation(commandStringBuilder, command, commandPosition); + return base.AppendInsertOperation(commandStringBuilder, command, commandPosition, out requiresTransaction); } public override ResultSetMapping AppendUpdateOperation( StringBuilder commandStringBuilder, IReadOnlyModificationCommand command, - int commandPosition) + int commandPosition, + out bool requiresTransaction) { AppendUpdateOperationCalls++; - return base.AppendUpdateOperation(commandStringBuilder, command, commandPosition); + return base.AppendUpdateOperation(commandStringBuilder, command, commandPosition, out requiresTransaction); } public override ResultSetMapping AppendDeleteOperation( StringBuilder commandStringBuilder, IReadOnlyModificationCommand command, - int commandPosition) + int commandPosition, + out bool requiresTransaction) { AppendDeleteOperationCalls++; - return base.AppendDeleteOperation(commandStringBuilder, command, commandPosition); + return base.AppendDeleteOperation(commandStringBuilder, command, commandPosition, out requiresTransaction); } public int AppendBatchHeaderCalls { get; set; } diff --git a/test/EFCore.Relational.Tests/Update/BatchExecutorTest.cs b/test/EFCore.Relational.Tests/Update/BatchExecutorTest.cs index 4188eba9ccb..4e5cbc3f560 100644 --- a/test/EFCore.Relational.Tests/Update/BatchExecutorTest.cs +++ b/test/EFCore.Relational.Tests/Update/BatchExecutorTest.cs @@ -15,8 +15,8 @@ public async Task ExecuteAsync_calls_Commit_if_no_transaction(bool async) using var context = new TestContext(); var connection = SetupConnection(context); - context.Add( - new Foo { Id = "1" }); + context.Add(new Foo { Id = "1" }); + context.Add(new Bar { Id = "1" }); if (async) { @@ -83,10 +83,16 @@ public TestContext() } public DbSet Foos { get; set; } + public DbSet Bars { get; set; } } private class Foo { public string Id { get; set; } } + + private class Bar + { + public string Id { get; set; } + } } diff --git a/test/EFCore.Relational.Tests/Update/CommandBatchPreparerTest.cs b/test/EFCore.Relational.Tests/Update/CommandBatchPreparerTest.cs index 058c1356a52..cf221e4cac1 100644 --- a/test/EFCore.Relational.Tests/Update/CommandBatchPreparerTest.cs +++ b/test/EFCore.Relational.Tests/Update/CommandBatchPreparerTest.cs @@ -25,7 +25,7 @@ public void BatchCommands_creates_valid_batch_for_added_entities() var modelData = new UpdateAdapter(stateManager); - var commandBatches = CreateCommandBatchPreparer().BatchCommands(new[] { entry }, modelData).ToArray(); + var commandBatches = CreateBatches(new[] { entry }, modelData); Assert.Single(commandBatches); Assert.Equal(1, commandBatches.First().ModificationCommands.Count); @@ -66,7 +66,7 @@ public void BatchCommands_creates_valid_batch_for_modified_entities() var modelData = new UpdateAdapter(stateManager); - var commandBatches = CreateCommandBatchPreparer().BatchCommands(new[] { entry }, modelData).ToArray(); + var commandBatches = CreateBatches(new[] { entry }, modelData); Assert.Single(commandBatches); Assert.Equal(1, commandBatches.First().ModificationCommands.Count); @@ -107,7 +107,7 @@ public void BatchCommands_creates_valid_batch_for_deleted_entities() var modelData = new UpdateAdapter(stateManager); - var commandBatches = CreateCommandBatchPreparer().BatchCommands(new[] { entry }, modelData).ToArray(); + var commandBatches = CreateBatches(new[] { entry }, modelData); Assert.Single(commandBatches); Assert.Equal(1, commandBatches.First().ModificationCommands.Count); @@ -142,7 +142,7 @@ public void BatchCommands_sorts_related_added_entities() new RelatedFakeEntity { Id = 42 }); relatedEntry.SetEntityState(EntityState.Added); - var commandBatches = CreateCommandBatchPreparer().BatchCommands(new[] { relatedEntry, entry }, modelData).ToArray(); + var commandBatches = CreateBatches(new[] { relatedEntry, entry }, modelData); Assert.Equal( new[] { entry, relatedEntry }, @@ -165,7 +165,7 @@ public void BatchCommands_sorts_added_and_related_modified_entities() new RelatedFakeEntity { Id = 42 }); relatedEntry.SetEntityState(EntityState.Modified); - var commandBatches = CreateCommandBatchPreparer().BatchCommands(new[] { relatedEntry, entry }, modelData).ToArray(); + var commandBatches = CreateBatches(new[] { relatedEntry, entry }, modelData); Assert.Equal( new[] { entry, relatedEntry }, @@ -188,7 +188,7 @@ public void BatchCommands_sorts_unrelated_entities() var modelData = new UpdateAdapter(stateManager); - var commandBatches = CreateCommandBatchPreparer().BatchCommands(new[] { secondEntry, firstEntry }, modelData).ToArray(); + var commandBatches = CreateBatches(new[] { secondEntry, firstEntry }, modelData); Assert.Equal( new[] { firstEntry, secondEntry }, @@ -216,8 +216,7 @@ public void BatchCommands_sorts_entities_when_reparenting() var modelData = new UpdateAdapter(stateManager); - var commandBatches = CreateCommandBatchPreparer().BatchCommands(new[] { relatedEntry, previousParent, newParent }, modelData) - .ToArray(); + var commandBatches = CreateBatches(new[] { relatedEntry, previousParent, newParent }, modelData); Assert.Equal( new[] { newParent, relatedEntry, previousParent }, @@ -244,7 +243,7 @@ public void BatchCommands_sorts_when_reassigning_child() var modelData = new UpdateAdapter(stateManager); - var commandBatches = CreateCommandBatchPreparer().BatchCommands(new[] { newChild, previousChild }, modelData).ToArray(); + var commandBatches = CreateBatches(new[] { newChild, previousChild }, modelData); Assert.Equal( new[] { previousChild, newChild }, @@ -279,8 +278,7 @@ public void BatchCommands_sorts_entities_while_reassigning_child_tree() var modelData = new UpdateAdapter(stateManager); - var sortedEntities = CreateCommandBatchPreparer() - .BatchCommands(new[] { newEntity, newChildEntity, oldEntity, oldChildEntity }, modelData) + var sortedEntities = CreateBatches(new[] { newEntity, newChildEntity, oldEntity, oldChildEntity }, modelData) .Select(cb => cb.ModificationCommands.Single()).Select(mc => mc.Entries.Single()).ToArray(); Assert.Equal( @@ -348,8 +346,8 @@ public void Batch_command_does_not_order_non_unique_index_values() var modelData = new UpdateAdapter(stateManager); - var sortedEntities = CreateCommandBatchPreparer() - .BatchCommands(new[] { fakeEntry, fakeEntry2, relatedFakeEntry }, modelData) + var sortedEntities = + CreateBatches(new[] { fakeEntry, fakeEntry2, relatedFakeEntry }, modelData) .Select(cb => cb.ModificationCommands.Single()).Select(mc => mc.Entries.Single()).ToArray(); Assert.Equal( @@ -404,8 +402,7 @@ public void Batch_command_throws_on_commands_with_circular_dependencies(bool sen Assert.Equal( CoreStrings.CircularDependency(ListLoggerFactory.NormalizeLineEndings(expectedCycle)), Assert.Throws( - () => CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: sensitiveLogging) - .BatchCommands(new[] { fakeEntry, relatedFakeEntry }, modelData).ToArray()).Message); + () => CreateBatches(new[] { fakeEntry, relatedFakeEntry }, modelData, sensitiveLogging)).Message); } [InlineData(true)] @@ -451,8 +448,7 @@ public void Batch_command_throws_on_commands_with_circular_dependencies_includin Assert.Equal( CoreStrings.CircularDependency(ListLoggerFactory.NormalizeLineEndings(expectedCycle)), Assert.Throws( - () => CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: sensitiveLogging) - .BatchCommands(new[] { fakeEntry, relatedFakeEntry, fakeEntry2 }, modelData).ToArray()).Message); + () => CreateBatches(new[] { fakeEntry, relatedFakeEntry, fakeEntry2 }, modelData, sensitiveLogging)).Message); } [InlineData(true)] @@ -490,9 +486,8 @@ FakeEntity [Deleted]" Assert.Equal( CoreStrings.CircularDependency(ListLoggerFactory.NormalizeLineEndings(expectedCycle)), Assert.Throws( - () => CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: sensitiveLogging).BatchCommands( - // Order is important for this test. Entry which is not part of cycle but tail should come first. - new[] { anotherFakeEntry, fakeEntry, relatedFakeEntry }, modelData).ToArray()).Message); + // Order is important for this test. Entry which is not part of cycle but tail should come first. + () => CreateBatches(new[] { anotherFakeEntry, fakeEntry, relatedFakeEntry }, modelData, sensitiveLogging)).Message); } [ConditionalFact] @@ -513,10 +508,9 @@ public void BatchCommands_works_with_duplicate_values_for_unique_indexes() var modelData = new UpdateAdapter(stateManager); - var batches = CreateCommandBatchPreparer(updateAdapter: modelData) - .BatchCommands(new[] { fakeEntry, fakeEntry2 }, modelData).ToArray(); + var batches = CreateBatches(new[] { fakeEntry, fakeEntry2 }, modelData); - Assert.Equal(2, batches.Length); + Assert.Equal(2, batches.Count); } [ConditionalFact] @@ -534,9 +528,8 @@ public void BatchCommands_creates_valid_batch_for_shared_table_added_entities() var modelData = new UpdateAdapter(stateManager); - var commandBatches = CreateCommandBatchPreparer(updateAdapter: modelData) - .BatchCommands(new[] { firstEntry, secondEntry }, modelData) - .ToArray(); + var commandBatches = CreateBatches(new[] { firstEntry, secondEntry }, modelData); + Assert.Single(commandBatches); Assert.Equal(1, commandBatches.First().ModificationCommands.Count); @@ -588,9 +581,7 @@ public void BatchCommands_creates_valid_batch_for_shared_table_modified_entities var modelData = new UpdateAdapter(stateManager); - var commandBatches = CreateCommandBatchPreparer(updateAdapter: modelData) - .BatchCommands(new[] { entry }, modelData) - .ToArray(); + var commandBatches = CreateBatches(new[] { entry }, modelData); Assert.Single(commandBatches); Assert.Equal(1, commandBatches.First().ModificationCommands.Count); @@ -645,8 +636,7 @@ public void BatchCommands_creates_valid_batch_for_shared_table_deleted_entities( var modelData = new UpdateAdapter(stateManager); - var commandBatches = CreateCommandBatchPreparer(updateAdapter: modelData) - .BatchCommands(new[] { firstEntry, secondEntry }, modelData).ToArray(); + var commandBatches = CreateBatches(new[] { firstEntry, secondEntry }, modelData); Assert.Single(commandBatches); Assert.Equal(1, commandBatches.First().ModificationCommands.Count); @@ -701,8 +691,7 @@ public void BatchCommands_throws_on_conflicting_updates_for_shared_table_added_e nameof(RelatedFakeEntity), "{Id: 42}", EntityState.Deleted, nameof(FakeEntity), "{Id: 42}", EntityState.Added), Assert.Throws( - () => CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: true) - .BatchCommands(new[] { firstEntry, secondEntry }, modelData).ToArray()).Message); + () => CreateBatches(new[] { firstEntry, secondEntry }, modelData, sensitiveLogging: true)).Message); } else { @@ -711,8 +700,7 @@ public void BatchCommands_throws_on_conflicting_updates_for_shared_table_added_e nameof(RelatedFakeEntity), EntityState.Deleted, nameof(FakeEntity), EntityState.Added), Assert.Throws( - () => CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: false) - .BatchCommands(new[] { firstEntry, secondEntry }, modelData).ToArray()).Message); + () => CreateBatches(new[] { firstEntry, secondEntry }, modelData, sensitiveLogging: false)).Message); } } @@ -755,8 +743,7 @@ public void BatchCommands_throws_on_conflicting_values_for_shared_table_added_en nameof(FakeEntity), nameof(RelatedFakeEntity), "{Id: 42}", "{RelatedId: 1}", "{RelatedId: 2}", "RelatedId"), Assert.Throws( - () => CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: true) - .BatchCommands(new[] { firstEntry, secondEntry }, modelData).ToArray()).Message); + () => CreateBatches(new[] { firstEntry, secondEntry }, modelData, sensitiveLogging: true)).Message); } else { @@ -765,8 +752,7 @@ public void BatchCommands_throws_on_conflicting_values_for_shared_table_added_en nameof(FakeEntity), nameof(RelatedFakeEntity), "{'RelatedId'}", "{'RelatedId'}", "RelatedId"), Assert.Throws( - () => CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: false) - .BatchCommands(new[] { firstEntry, secondEntry }, modelData).ToArray()).Message); + () => CreateBatches(new[] { firstEntry, secondEntry }, modelData, sensitiveLogging: false)).Message); } } else @@ -778,8 +764,7 @@ public void BatchCommands_throws_on_conflicting_values_for_shared_table_added_en nameof(FakeEntity), nameof(RelatedFakeEntity), "{Id: 42}", "{RelatedId: 1}", "{RelatedId: 2}", "RelatedId"), Assert.Throws( - () => CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: true) - .BatchCommands(new[] { firstEntry, secondEntry }, modelData).ToArray()).Message); + () => CreateBatches(new[] { firstEntry, secondEntry }, modelData, sensitiveLogging: true)).Message); } else { @@ -788,8 +773,7 @@ public void BatchCommands_throws_on_conflicting_values_for_shared_table_added_en nameof(FakeEntity), nameof(RelatedFakeEntity), "{'RelatedId'}", "{'RelatedId'}", "RelatedId"), Assert.Throws( - () => CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: false) - .BatchCommands(new[] { firstEntry, secondEntry }, modelData).ToArray()).Message); + () => CreateBatches(new[] { firstEntry, secondEntry }, modelData, sensitiveLogging: false)).Message); } } } @@ -812,8 +796,7 @@ public void BatchCommands_creates_batch_on_incomplete_updates_for_shared_table_n var modelData = new UpdateAdapter(stateManager); - var commandBatches = CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: true) - .BatchCommands(new[] { firstEntry }, modelData).ToArray(); + var commandBatches = CreateBatches(new[] { firstEntry }, modelData, sensitiveLogging: true); if (state == EntityState.Deleted) { @@ -896,8 +879,7 @@ public void BatchCommands_works_with_incomplete_updates_for_shared_table_no_leaf var modelData = new UpdateAdapter(stateManager); - var batches = CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: false) - .BatchCommands(new[] { firstEntry, secondEntry }, modelData).ToArray(); + var batches = CreateBatches(new[] { firstEntry, secondEntry }, modelData, sensitiveLogging: false); Assert.Single(batches); } @@ -920,8 +902,7 @@ public void BatchCommands_creates_batch_on_incomplete_updates_for_shared_table_n var modelData = new UpdateAdapter(stateManager); - var commandBatches = CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: true) - .BatchCommands(new[] { firstEntry, secondEntry }, modelData).ToArray(); + var commandBatches = CreateBatches(new[] { firstEntry, secondEntry }, modelData, sensitiveLogging: true); if (state == EntityState.Deleted) { @@ -929,11 +910,11 @@ public void BatchCommands_creates_batch_on_incomplete_updates_for_shared_table_n Assert.Equal( "1", Assert.Throws( - () => Assert.Equal(2, commandBatches.Length)).Actual); + () => Assert.Equal(2, commandBatches.Count)).Actual); } else { - Assert.Equal(2, commandBatches.Length); + Assert.Equal(2, commandBatches.Count); Assert.Equal(1, commandBatches.First().ModificationCommands.Count); var command = commandBatches.First().ModificationCommands.Single(); @@ -966,6 +947,15 @@ public void BatchCommands_creates_batch_on_incomplete_updates_for_shared_table_n private static IServiceProvider CreateContextServices(IModel model) => RelationalTestHelpers.Instance.CreateContextServices(model); + public List CreateBatches( + IUpdateEntry[] entries, + IUpdateAdapter updateAdapter, + bool sensitiveLogging = false) + => CreateCommandBatchPreparer(updateAdapter: updateAdapter, sensitiveLogging: sensitiveLogging) + .BatchCommands(entries, updateAdapter) + .Select(t => t.Batch) + .ToList(); + public ICommandBatchPreparer CreateCommandBatchPreparer( IModificationCommandBatchFactory modificationCommandBatchFactory = null, IUpdateAdapter updateAdapter = null, diff --git a/test/EFCore.Relational.Tests/Update/ReaderModificationCommandBatchTest.cs b/test/EFCore.Relational.Tests/Update/ReaderModificationCommandBatchTest.cs index e2f17161743..f4848969fe0 100644 --- a/test/EFCore.Relational.Tests/Update/ReaderModificationCommandBatchTest.cs +++ b/test/EFCore.Relational.Tests/Update/ReaderModificationCommandBatchTest.cs @@ -25,6 +25,7 @@ public void AddCommand_adds_command_if_possible() batch.ShouldValidateSql = true; batch.AddCommand(command); + batch.Complete(); Assert.Equal(2, batch.ModificationCommands.Count); Assert.Same(command, batch.ModificationCommands[0]); @@ -42,6 +43,7 @@ public void AddCommand_does_not_add_command_if_not_possible() batch.ShouldValidateSql = true; batch.AddCommand(command); + batch.Complete(); Assert.Equal(1, batch.ModificationCommands.Count); Assert.Equal(".", batch.CommandText); @@ -58,6 +60,7 @@ public void AddCommand_does_not_add_command_if_resulting_sql_is_invalid() batch.ShouldValidateSql = false; batch.AddCommand(command); + batch.Complete(); Assert.Equal(1, batch.ModificationCommands.Count); Assert.Equal(".", batch.CommandText); @@ -75,6 +78,7 @@ public void UpdateCommandText_compiles_inserts() RelationalTestHelpers.Instance.CreateContextServices().GetRequiredService()); var batch = new ModificationCommandBatchFake(fakeSqlGenerator); batch.AddCommand(command); + batch.Complete(); batch.UpdateCachedCommandTextBase(0); @@ -96,6 +100,7 @@ public void UpdateCommandText_compiles_updates() batch.AddCommand(command); batch.UpdateCachedCommandTextBase(0); + batch.Complete(); Assert.Equal(1, fakeSqlGenerator.AppendBatchHeaderCalls); Assert.Equal(1, fakeSqlGenerator.AppendUpdateOperationCalls); @@ -115,6 +120,7 @@ public void UpdateCommandText_compiles_deletes() batch.AddCommand(command); batch.UpdateCachedCommandTextBase(0); + batch.Complete(); Assert.Equal(1, fakeSqlGenerator.AppendBatchHeaderCalls); Assert.Equal(1, fakeSqlGenerator.AppendDeleteOperationCalls); @@ -133,6 +139,7 @@ public void UpdateCommandText_compiles_multiple_commands() var batch = new ModificationCommandBatchFake(fakeSqlGenerator); batch.AddCommand(command); batch.AddCommand(command); + batch.Complete(); Assert.Equal("..", batch.CommandText); @@ -153,6 +160,7 @@ public async Task ExecuteAsync_executes_batch_commands_and_consumes_reader() var batch = new ModificationCommandBatchFake(); batch.AddCommand(command); + batch.Complete(); await batch.ExecuteAsync(connection); @@ -175,6 +183,7 @@ public async Task ExecuteAsync_saves_store_generated_values() var batch = new ModificationCommandBatchFake(); batch.AddCommand(command); + batch.Complete(); await batch.ExecuteAsync(connection); @@ -198,6 +207,7 @@ public async Task ExecuteAsync_saves_store_generated_values_on_non_key_columns() var batch = new ModificationCommandBatchFake(); batch.AddCommand(command); + batch.Complete(); await batch.ExecuteAsync(connection); @@ -220,6 +230,7 @@ public async Task ExecuteAsync_saves_store_generated_values_when_updating() var batch = new ModificationCommandBatchFake(); batch.AddCommand(command); + batch.Complete(); await batch.ExecuteAsync(connection); @@ -243,6 +254,7 @@ public async Task Exception_not_thrown_for_more_than_one_row_returned_for_single var batch = new ModificationCommandBatchFake(); batch.AddCommand(command); + batch.Complete(); await batch.ExecuteAsync(connection); @@ -265,6 +277,7 @@ public async Task Exception_thrown_if_rows_returned_for_command_without_store_ge var batch = new ModificationCommandBatchFake(); batch.AddCommand(command); + batch.Complete(); var exception = async ? await Assert.ThrowsAsync(() => batch.ExecuteAsync(connection)) @@ -289,6 +302,7 @@ public async Task Exception_thrown_if_no_rows_returned_for_command_with_store_ge var batch = new ModificationCommandBatchFake(); batch.AddCommand(command); + batch.Complete(); var exception = async ? await Assert.ThrowsAsync(() => batch.ExecuteAsync(connection)) @@ -316,6 +330,7 @@ public async Task DbException_is_wrapped_with_DbUpdateException(bool async) var batch = new ModificationCommandBatchFake(); batch.AddCommand(command); + batch.Complete(); var actualException = async ? await Assert.ThrowsAsync(() => batch.ExecuteAsync(connection)) @@ -343,6 +358,7 @@ public async Task OperationCanceledException_is_not_wrapped_with_DbUpdateExcepti var batch = new ModificationCommandBatchFake(); batch.AddCommand(command); + batch.Complete(); var actualException = async ? await Assert.ThrowsAsync(() => batch.ExecuteAsync(connection)) @@ -393,6 +409,8 @@ public void CreateStoreCommand_creates_parameters_for_each_ModificationCommand() false, true, false, false, true) })); + batch.Complete(); + var storeCommand = batch.CreateStoreCommandBase(); Assert.Equal(2, storeCommand.RelationalCommand.Parameters.Count); @@ -430,6 +448,8 @@ public void PopulateParameters_creates_parameter_for_write_ModificationCommand() sensitiveLoggingEnabled: true) })); + batch.Complete(); + var storeCommand = batch.CreateStoreCommandBase(); Assert.Equal(1, storeCommand.RelationalCommand.Parameters.Count); @@ -465,6 +485,8 @@ public void PopulateParameters_creates_parameter_for_condition_ModificationComma sensitiveLoggingEnabled: true) })); + batch.Complete(); + var storeCommand = batch.CreateStoreCommandBase(); Assert.Equal(1, storeCommand.RelationalCommand.Parameters.Count); @@ -500,6 +522,8 @@ public void PopulateParameters_creates_parameters_for_write_and_condition_Modifi sensitiveLoggingEnabled: true) })); + batch.Complete(); + var storeCommand = batch.CreateStoreCommandBase(); Assert.Equal(2, storeCommand.RelationalCommand.Parameters.Count); @@ -537,6 +561,8 @@ public void PopulateParameters_does_not_create_parameter_for_read_ModificationCo sensitiveLoggingEnabled: true) })); + batch.Complete(); + var storeCommand = batch.CreateStoreCommandBase(); Assert.Equal(0, storeCommand.RelationalCommand.Parameters.Count); @@ -626,7 +652,7 @@ private static ModificationCommandBatchFactoryDependencies CreateDependencies( } public string CommandText - => GetCommandText(); + => CachedCommandText.ToString(); public bool ShouldAddCommand { get; set; } diff --git a/test/EFCore.SqlServer.FunctionalTests/DataAnnotationSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/DataAnnotationSqlServerTest.cs index 5338e33bbb9..b2c5db89b7b 100644 --- a/test/EFCore.SqlServer.FunctionalTests/DataAnnotationSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/DataAnnotationSqlServerTest.cs @@ -164,6 +164,7 @@ FROM [Sample] AS [s] @p1='00000000-0000-0000-0003-000000000001' @p3='00000001-0000-0000-0000-000000000001' +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; UPDATE [Sample] SET [Name] = @p0, [RowVersion] = @p1 WHERE [Unique_No] = @p2 AND [RowVersion] = @p3; @@ -174,6 +175,7 @@ FROM [Sample] AS [s] @p1='00000000-0000-0000-0002-000000000001' @p3='00000001-0000-0000-0000-000000000001' +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; UPDATE [Sample] SET [Name] = @p0, [RowVersion] = @p1 WHERE [Unique_No] = @p2 AND [RowVersion] = @p3; diff --git a/test/EFCore.SqlServer.FunctionalTests/OptimisticConcurrencySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/OptimisticConcurrencySqlServerTest.cs index bc860720142..469a2b2120b 100644 --- a/test/EFCore.SqlServer.FunctionalTests/OptimisticConcurrencySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/OptimisticConcurrencySqlServerTest.cs @@ -166,6 +166,7 @@ FROM [Engines] AS [e] @p4='47.64491' (Nullable = true) @p5='-122.128101' (Nullable = true) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; UPDATE [Engines] SET [Name] = @p0 WHERE [Id] = @p1 AND [EngineSupplierId] = @p2 AND [Name] = @p3 AND [StorageLocation_Latitude] = @p4 AND [StorageLocation_Longitude] = @p5; diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/IncompleteMappingInheritanceQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/IncompleteMappingInheritanceQuerySqlServerTest.cs index 26b289461a3..85a40748b6c 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/IncompleteMappingInheritanceQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/IncompleteMappingInheritanceQuerySqlServerTest.cs @@ -389,6 +389,7 @@ FROM [Countries] AS [c] @p5='True' (Nullable = true) @p6='Little spotted kiwi' (Size = 4000) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; INSERT INTO [Animals] ([Species], [CountryId], [Discriminator], [EagleId], [FoundOn], [IsFlightless], [Name]) VALUES (@p0, @p1, @p2, @p3, @p4, @p5, @p6);", @@ -400,6 +401,7 @@ FROM [Animals] AS [a] @"@p1='Apteryx owenii' (Nullable = false) (Size = 100) @p0='Aquila chrysaetos canadensis' (Size = 100) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; UPDATE [Animals] SET [EagleId] = @p0 WHERE [Species] = @p1; @@ -411,6 +413,7 @@ FROM [Animals] AS [a] // @"@p0='Apteryx owenii' (Nullable = false) (Size = 100) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; DELETE FROM [Animals] WHERE [Species] = @p0; diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/InheritanceQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/InheritanceQuerySqlServerTest.cs index 155f1612578..3f58c6ef539 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/InheritanceQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/InheritanceQuerySqlServerTest.cs @@ -363,6 +363,7 @@ FROM [Countries] AS [c] @p5='True' (Nullable = true) @p6='Little spotted kiwi' (Size = 4000) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; INSERT INTO [Animals] ([Species], [CountryId], [Discriminator], [EagleId], [FoundOn], [IsFlightless], [Name]) VALUES (@p0, @p1, @p2, @p3, @p4, @p5, @p6);", @@ -374,6 +375,7 @@ FROM [Animals] AS [a] @"@p1='Apteryx owenii' (Nullable = false) (Size = 100) @p0='Aquila chrysaetos canadensis' (Size = 100) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; UPDATE [Animals] SET [EagleId] = @p0 WHERE [Species] = @p1; @@ -385,6 +387,7 @@ FROM [Animals] AS [a] // @"@p0='Apteryx owenii' (Nullable = false) (Size = 100) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; DELETE FROM [Animals] WHERE [Species] = @p0; diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/TPTInheritanceQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/TPTInheritanceQuerySqlServerTest.cs index dee1a0b6105..6c45fc18098 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/TPTInheritanceQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/TPTInheritanceQuerySqlServerTest.cs @@ -102,6 +102,7 @@ FROM [Countries] AS [c] @p1='1' @p2='Little spotted kiwi' (Size = 4000) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; INSERT INTO [Animals] ([Species], [CountryId], [Name]) VALUES (@p0, @p1, @p2);", @@ -110,6 +111,7 @@ INSERT INTO [Animals] ([Species], [CountryId], [Name]) @p4=NULL (Size = 100) @p5='True' +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; INSERT INTO [Birds] ([Species], [EagleId], [IsFlightless]) VALUES (@p3, @p4, @p5);", @@ -117,6 +119,7 @@ INSERT INTO [Birds] ([Species], [EagleId], [IsFlightless]) @"@p6='Apteryx owenii' (Nullable = false) (Size = 100) @p7='0' (Size = 1) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; INSERT INTO [Kiwi] ([Species], [FoundOn]) VALUES (@p6, @p7);", @@ -130,6 +133,7 @@ FROM [Animals] AS [a] @"@p1='Apteryx owenii' (Nullable = false) (Size = 100) @p0='Aquila chrysaetos canadensis' (Size = 100) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; UPDATE [Birds] SET [EagleId] = @p0 WHERE [Species] = @p1; @@ -143,6 +147,7 @@ FROM [Animals] AS [a] // @"@p0='Apteryx owenii' (Nullable = false) (Size = 100) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; DELETE FROM [Kiwi] WHERE [Species] = @p0; @@ -150,6 +155,7 @@ DELETE FROM [Kiwi] // @"@p1='Apteryx owenii' (Nullable = false) (Size = 100) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; DELETE FROM [Birds] WHERE [Species] = @p1; @@ -157,6 +163,7 @@ DELETE FROM [Birds] // @"@p2='Apteryx owenii' (Nullable = false) (Size = 100) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; DELETE FROM [Animals] WHERE [Species] = @p2; @@ -527,6 +534,7 @@ FROM [Animals] AS [a] @p1='0' @p2='Bald eagle' (Size = 4000) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; INSERT INTO [Animals] ([Species], [CountryId], [Name]) VALUES (@p0, @p1, @p2);"); diff --git a/test/EFCore.SqlServer.FunctionalTests/TPTTableSplittingSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/TPTTableSplittingSqlServerTest.cs index 0181ac8adef..4f9bbb9011a 100644 --- a/test/EFCore.SqlServer.FunctionalTests/TPTTableSplittingSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/TPTTableSplittingSqlServerTest.cs @@ -187,6 +187,7 @@ public override async Task Can_change_dependent_instance_non_derived() @"@p1='Trek Pro Fit Madone 6 Series' (Nullable = false) (Size = 450) @p0='repairman' (Size = 4000) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; UPDATE [Vehicles] SET [Operator_Name] = @p0 WHERE [Name] = @p1; @@ -195,6 +196,7 @@ public override async Task Can_change_dependent_instance_non_derived() @"@p2='Trek Pro Fit Madone 6 Series' (Nullable = false) (Size = 450) @p3='Repair' (Size = 4000) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; INSERT INTO [LicensedOperators] ([VehicleName], [LicenseType]) VALUES (@p2, @p3);", @@ -228,6 +230,7 @@ public override async Task Can_change_principal_instance_non_derived() @"@p1='Trek Pro Fit Madone 6 Series' (Nullable = false) (Size = 450) @p0='2' +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; UPDATE [Vehicles] SET [SeatingCapacity] = @p0 WHERE [Name] = @p1; diff --git a/test/EFCore.SqlServer.FunctionalTests/TableSplittingSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/TableSplittingSqlServerTest.cs index a80051258f6..4fa4ba61080 100644 --- a/test/EFCore.SqlServer.FunctionalTests/TableSplittingSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/TableSplittingSqlServerTest.cs @@ -210,6 +210,7 @@ public override async Task Can_change_dependent_instance_non_derived() @p1='Repair' (Size = 4000) @p2='repairman' (Size = 4000) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; UPDATE [Vehicles] SET [Operator_Discriminator] = @p0, [LicenseType] = @p1, [Operator_Name] = @p2 WHERE [Name] = @p3; @@ -233,6 +234,7 @@ public override async Task Can_change_principal_instance_non_derived() @"@p1='Trek Pro Fit Madone 6 Series' (Nullable = false) (Size = 450) @p0='2' +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; UPDATE [Vehicles] SET [SeatingCapacity] = @p0 WHERE [Name] = @p1; diff --git a/test/EFCore.SqlServer.FunctionalTests/Update/StoreValueGenerationIdentitySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Update/StoreValueGenerationIdentitySqlServerTest.cs new file mode 100644 index 00000000000..7bb46716dd1 --- /dev/null +++ b/test/EFCore.SqlServer.FunctionalTests/Update/StoreValueGenerationIdentitySqlServerTest.cs @@ -0,0 +1,472 @@ +// 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.Update; + +#nullable enable + +public class StoreValueGenerationIdentitySqlServerTest : StoreValueGenerationTestBase< + StoreValueGenerationIdentitySqlServerTest.StoreValueGenerationIdentitySqlServerFixture> +{ + public StoreValueGenerationIdentitySqlServerTest( + StoreValueGenerationIdentitySqlServerFixture fixture, + ITestOutputHelper testOutputHelper) + : base(fixture) + { + Fixture.TestSqlLoggerFactory.Clear(); + // Fixture.TestSqlLoggerFactory.SetTestOutputHelper(testOutputHelper); + } + + protected override int ShouldExecuteInNumberOfCommands( + EntityState firstOperationType, + EntityState? secondOperationType, + GeneratedValues generatedValues, + bool withDatabaseGenerated) + => secondOperationType is null ? 1 : 2; + + #region Single operation + + public override async Task Add_with_generated_values(bool async) + { + await base.Add_with_generated_values(async); + + AssertSql( + @"@p0='1000' + +SET NOCOUNT ON; +INSERT INTO [WithSomeDatabaseGenerated] ([Data2]) +VALUES (@p0); +SELECT [Id], [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();"); + } + + public override async Task Add_with_no_generated_values(bool async) + { + await base.Add_with_no_generated_values(async); + + AssertSql( + @"@p0='100' +@p1='1000' +@p2='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +INSERT INTO [WithNoDatabaseGenerated] ([Id], [Data1], [Data2]) +VALUES (@p0, @p1, @p2);"); + } + + public override async Task Add_with_all_generated_values(bool async) + { + await base.Add_with_all_generated_values(async); + + AssertSql( + @"SET NOCOUNT ON; +INSERT INTO [WithAllDatabaseGenerated] +DEFAULT VALUES; +SELECT [Id], [Data1], [Data2] +FROM [WithAllDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();"); + } + + public override async Task Modify_with_generated_values(bool async) + { + await base.Modify_with_generated_values(async); + + AssertSql( + @"@p1='1' +@p0='1000' + +SET NOCOUNT ON; +UPDATE [WithSomeDatabaseGenerated] SET [Data2] = @p0 +WHERE [Id] = @p1; +SELECT [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = @p1;"); + } + + public override async Task Modify_with_no_generated_values(bool async) + { + await base.Modify_with_no_generated_values(async); + + AssertSql( + @"@p2='1' +@p0='1000' +@p1='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [WithNoDatabaseGenerated] SET [Data1] = @p0, [Data2] = @p1 +WHERE [Id] = @p2; +SELECT @@ROWCOUNT;"); + } + + public override async Task Delete(bool async) + { + await base.Delete(async); + + AssertSql( + @"@p0='1' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +DELETE FROM [WithSomeDatabaseGenerated] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT;"); + } + + #endregion Single operation + + #region Two operations with same entity type + + public override async Task Add_Add_with_same_entity_type_and_generated_values(bool async) + { + await base.Add_Add_with_same_entity_type_and_generated_values(async); + + AssertSql( + @"@p0='1000' + +SET NOCOUNT ON; +INSERT INTO [WithSomeDatabaseGenerated] ([Data2]) +VALUES (@p0); +SELECT [Id], [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();", + // + @"@p0='1001' + +SET NOCOUNT ON; +INSERT INTO [WithSomeDatabaseGenerated] ([Data2]) +VALUES (@p0); +SELECT [Id], [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();"); + } + + public override async Task Add_Add_with_same_entity_type_and_no_generated_values(bool async) + { + await base.Add_Add_with_same_entity_type_and_no_generated_values(async); + + AssertSql( + @"@p0='100' +@p1='1000' +@p2='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +INSERT INTO [WithNoDatabaseGenerated] ([Id], [Data1], [Data2]) +VALUES (@p0, @p1, @p2);", + // + @"@p0='101' +@p1='1001' +@p2='1001' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +INSERT INTO [WithNoDatabaseGenerated] ([Id], [Data1], [Data2]) +VALUES (@p0, @p1, @p2);"); + } + + public override async Task Add_Add_with_same_entity_type_and_all_generated_values(bool async) + { + await base.Add_Add_with_same_entity_type_and_all_generated_values(async); + + AssertSql( + @"SET NOCOUNT ON; +INSERT INTO [WithAllDatabaseGenerated] +DEFAULT VALUES; +SELECT [Id], [Data1], [Data2] +FROM [WithAllDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();", + // + @"SET NOCOUNT ON; +INSERT INTO [WithAllDatabaseGenerated] +DEFAULT VALUES; +SELECT [Id], [Data1], [Data2] +FROM [WithAllDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();"); + } + + public override async Task Modify_Modify_with_same_entity_type_and_generated_values(bool async) + { + await base.Modify_Modify_with_same_entity_type_and_generated_values(async); + + AssertSql( + @"@p1='1' +@p0='1000' + +SET NOCOUNT ON; +UPDATE [WithSomeDatabaseGenerated] SET [Data2] = @p0 +WHERE [Id] = @p1; +SELECT [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = @p1;", + // + @"@p1='2' +@p0='1001' + +SET NOCOUNT ON; +UPDATE [WithSomeDatabaseGenerated] SET [Data2] = @p0 +WHERE [Id] = @p1; +SELECT [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = @p1;"); + } + + public override async Task Modify_Modify_with_same_entity_type_and_no_generated_values(bool async) + { + await base.Modify_Modify_with_same_entity_type_and_no_generated_values(async); + + AssertSql( + @"@p2='1' +@p0='1000' +@p1='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [WithNoDatabaseGenerated] SET [Data1] = @p0, [Data2] = @p1 +WHERE [Id] = @p2; +SELECT @@ROWCOUNT;", + // + @"@p2='2' +@p0='1001' +@p1='1001' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [WithNoDatabaseGenerated] SET [Data1] = @p0, [Data2] = @p1 +WHERE [Id] = @p2; +SELECT @@ROWCOUNT;"); + } + + public override async Task Delete_Delete_with_same_entity_type(bool async) + { + await base.Delete_Delete_with_same_entity_type(async); + + AssertSql( + @"@p0='1' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +DELETE FROM [WithSomeDatabaseGenerated] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT;", + // + @"@p0='2' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +DELETE FROM [WithSomeDatabaseGenerated] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT;"); + } + + #endregion Two operations with same entity type + + #region Two operations with different entity types + + public override async Task Add_Add_with_different_entity_types_and_generated_values(bool async) + { + await base.Add_Add_with_different_entity_types_and_generated_values(async); + + AssertSql( + @"@p0='1000' + +SET NOCOUNT ON; +INSERT INTO [WithSomeDatabaseGenerated] ([Data2]) +VALUES (@p0); +SELECT [Id], [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();", + // + @"@p0='1001' + +SET NOCOUNT ON; +INSERT INTO [WithSomeDatabaseGenerated2] ([Data2]) +VALUES (@p0); +SELECT [Id], [Data1] +FROM [WithSomeDatabaseGenerated2] +WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();"); + } + + public override async Task Add_Add_with_different_entity_types_and_no_generated_values(bool async) + { + await base.Add_Add_with_different_entity_types_and_no_generated_values(async); + + AssertSql( + @"@p0='100' +@p1='1000' +@p2='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +INSERT INTO [WithNoDatabaseGenerated] ([Id], [Data1], [Data2]) +VALUES (@p0, @p1, @p2);", + // + @"@p0='101' +@p1='1001' +@p2='1001' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +INSERT INTO [WithNoDatabaseGenerated2] ([Id], [Data1], [Data2]) +VALUES (@p0, @p1, @p2);"); + } + + public override async Task Add_Add_with_different_entity_types_and_all_generated_values(bool async) + { + await base.Add_Add_with_different_entity_types_and_all_generated_values(async); + + AssertSql( + @"SET NOCOUNT ON; +INSERT INTO [WithAllDatabaseGenerated] +DEFAULT VALUES; +SELECT [Id], [Data1], [Data2] +FROM [WithAllDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();", + // + @"SET NOCOUNT ON; +INSERT INTO [WithAllDatabaseGenerated2] +DEFAULT VALUES; +SELECT [Id], [Data1], [Data2] +FROM [WithAllDatabaseGenerated2] +WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();"); + } + + public override async Task Modify_Modify_with_different_entity_types_and_generated_values(bool async) + { + await base.Modify_Modify_with_different_entity_types_and_generated_values(async); + + AssertSql( + @"@p1='1' +@p0='1000' + +SET NOCOUNT ON; +UPDATE [WithSomeDatabaseGenerated] SET [Data2] = @p0 +WHERE [Id] = @p1; +SELECT [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = @p1;", + // + @"@p1='2' +@p0='1001' + +SET NOCOUNT ON; +UPDATE [WithSomeDatabaseGenerated2] SET [Data2] = @p0 +WHERE [Id] = @p1; +SELECT [Data1] +FROM [WithSomeDatabaseGenerated2] +WHERE @@ROWCOUNT = 1 AND [Id] = @p1;"); + } + + public override async Task Modify_Modify_with_different_entity_types_and_no_generated_values(bool async) + { + await base.Modify_Modify_with_different_entity_types_and_no_generated_values(async); +AssertSql( + @"@p2='1' +@p0='1000' +@p1='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [WithNoDatabaseGenerated] SET [Data1] = @p0, [Data2] = @p1 +WHERE [Id] = @p2; +SELECT @@ROWCOUNT;", + // + @"@p2='2' +@p0='1001' +@p1='1001' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [WithNoDatabaseGenerated2] SET [Data1] = @p0, [Data2] = @p1 +WHERE [Id] = @p2; +SELECT @@ROWCOUNT;"); + } + + public override async Task Delete_Delete_with_different_entity_types(bool async) + { + await base.Delete_Delete_with_different_entity_types(async); + + AssertSql( + @"@p0='1' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +DELETE FROM [WithSomeDatabaseGenerated] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT;", + // + @"@p0='2' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +DELETE FROM [WithSomeDatabaseGenerated2] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT;"); + } + + #endregion Two operations with different entity types + + protected override async Task Test( + EntityState firstOperationType, + EntityState? secondOperationType, + GeneratedValues generatedValues, + bool async, + bool withSameEntityType = true) + { + await base.Test(firstOperationType, secondOperationType, generatedValues, async, withSameEntityType); + + if (!ShouldCreateImplicitTransaction(firstOperationType, secondOperationType, generatedValues, withSameEntityType)) + { + Assert.Contains("SET IMPLICIT_TRANSACTIONS OFF", Fixture.TestSqlLoggerFactory.SqlStatements[0]); + } + } + + public class StoreValueGenerationIdentitySqlServerFixture : StoreValueGenerationFixtureBase + { + private string? _identityResetCommand; + + protected override string StoreName { get; } = "StoreValueGenerationIdentityTest"; + + protected override ITestStoreFactory TestStoreFactory + => SqlServerTestStoreFactory.Instance; + + public override void Reseed() + { + using var context = CreateContext(); + Clean(context); + Seed(context); + } + + protected override void Clean(DbContext context) + { + base.Clean(context); + + // Reset the IDENTITY values since we assert on them + context.Database.ExecuteSqlRaw(GetIdentityResetCommand()); + } + + private string GetIdentityResetCommand() + { + if (_identityResetCommand is not null) + { + return _identityResetCommand; + } + + var context = CreateContext(); + var builder = new StringBuilder(); + + var tablesWithIdentity = context.Model.GetEntityTypes() + .Where(e => e.GetProperties().Any(p => p.GetValueGenerationStrategy() == SqlServerValueGenerationStrategy.IdentityColumn)) + .Select(e => e.GetTableName()); + + foreach (var table in tablesWithIdentity) + { + builder.AppendLine($"DBCC CHECKIDENT ('{table}', RESEED, 0);"); + } + + return _identityResetCommand = builder.ToString(); + } + } +} diff --git a/test/EFCore.SqlServer.FunctionalTests/Update/StoreValueGenerationSequenceSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Update/StoreValueGenerationSequenceSqlServerTest.cs new file mode 100644 index 00000000000..eb65ef829db --- /dev/null +++ b/test/EFCore.SqlServer.FunctionalTests/Update/StoreValueGenerationSequenceSqlServerTest.cs @@ -0,0 +1,491 @@ +// 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.TestModels.StoreValueGenerationModel; + +namespace Microsoft.EntityFrameworkCore.Update; + +#nullable enable + +public class StoreValueGenerationSequenceSqlServerTest : StoreValueGenerationTestBase< + StoreValueGenerationSequenceSqlServerTest.StoreValueGenerationSequenceSqlServerFixture> +{ + public StoreValueGenerationSequenceSqlServerTest( + StoreValueGenerationSequenceSqlServerFixture fixture, + ITestOutputHelper testOutputHelper) + : base(fixture) + { + Fixture.TestSqlLoggerFactory.Clear(); + // Fixture.TestSqlLoggerFactory.SetTestOutputHelper(testOutputHelper); + } + + protected override int ShouldExecuteInNumberOfCommands( + EntityState firstOperationType, + EntityState? secondOperationType, + GeneratedValues generatedValues, + bool withDatabaseGenerated) + => secondOperationType is null ? 1 : 2; + + #region Single operation + + public override async Task Add_with_generated_values(bool async) + { + await base.Add_with_generated_values(async); + + AssertSql( + @"@p0='1000' + +SET NOCOUNT ON; +DECLARE @inserted0 TABLE ([Id] int); +INSERT INTO [WithSomeDatabaseGenerated] ([Data2]) +OUTPUT INSERTED.[Id] +INTO @inserted0 +VALUES (@p0); +SELECT [t].[Id], [t].[Data1] FROM [WithSomeDatabaseGenerated] t +INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id]);"); + } + + public override async Task Add_with_no_generated_values(bool async) + { + await base.Add_with_no_generated_values(async); + + AssertSql( + @"@p0='100' +@p1='1000' +@p2='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +INSERT INTO [WithNoDatabaseGenerated] ([Id], [Data1], [Data2]) +VALUES (@p0, @p1, @p2);"); + } + + public override async Task Add_with_all_generated_values(bool async) + { + await base.Add_with_all_generated_values(async); + + AssertSql( + @"SET NOCOUNT ON; +DECLARE @inserted0 TABLE ([Id] int); +INSERT INTO [WithAllDatabaseGenerated] +OUTPUT INSERTED.[Id] +INTO @inserted0 +DEFAULT VALUES; +SELECT [t].[Id], [t].[Data1], [t].[Data2] FROM [WithAllDatabaseGenerated] t +INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id]);"); + } + + public override async Task Modify_with_generated_values(bool async) + { + await base.Modify_with_generated_values(async); + + AssertSql( + @"@p1='5' +@p0='1000' + +SET NOCOUNT ON; +UPDATE [WithSomeDatabaseGenerated] SET [Data2] = @p0 +WHERE [Id] = @p1; +SELECT [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = @p1;"); + } + + public override async Task Modify_with_no_generated_values(bool async) + { + await base.Modify_with_no_generated_values(async); + + AssertSql( + @"@p2='1' +@p0='1000' +@p1='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [WithNoDatabaseGenerated] SET [Data1] = @p0, [Data2] = @p1 +WHERE [Id] = @p2; +SELECT @@ROWCOUNT;"); + } + + public override async Task Delete(bool async) + { + await base.Delete(async); + + AssertSql( + @"@p0='5' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +DELETE FROM [WithSomeDatabaseGenerated] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT;"); + } + + #endregion Single operation + + #region Two operations with same entity type + + public override async Task Add_Add_with_same_entity_type_and_generated_values(bool async) + { + await base.Add_Add_with_same_entity_type_and_generated_values(async); + + AssertSql( + @"@p0='1000' + +SET NOCOUNT ON; +DECLARE @inserted0 TABLE ([Id] int); +INSERT INTO [WithSomeDatabaseGenerated] ([Data2]) +OUTPUT INSERTED.[Id] +INTO @inserted0 +VALUES (@p0); +SELECT [t].[Id], [t].[Data1] FROM [WithSomeDatabaseGenerated] t +INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id]);", + // + @"@p0='1001' + +SET NOCOUNT ON; +DECLARE @inserted0 TABLE ([Id] int); +INSERT INTO [WithSomeDatabaseGenerated] ([Data2]) +OUTPUT INSERTED.[Id] +INTO @inserted0 +VALUES (@p0); +SELECT [t].[Id], [t].[Data1] FROM [WithSomeDatabaseGenerated] t +INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id]);"); + } + + public override async Task Add_Add_with_same_entity_type_and_no_generated_values(bool async) + { + await base.Add_Add_with_same_entity_type_and_no_generated_values(async); + + AssertSql( + @"@p0='100' +@p1='1000' +@p2='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +INSERT INTO [WithNoDatabaseGenerated] ([Id], [Data1], [Data2]) +VALUES (@p0, @p1, @p2);", + // + @"@p0='101' +@p1='1001' +@p2='1001' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +INSERT INTO [WithNoDatabaseGenerated] ([Id], [Data1], [Data2]) +VALUES (@p0, @p1, @p2);"); + } + + public override async Task Add_Add_with_same_entity_type_and_all_generated_values(bool async) + { + await base.Add_Add_with_same_entity_type_and_all_generated_values(async); + + AssertSql( + @"SET NOCOUNT ON; +DECLARE @inserted0 TABLE ([Id] int); +INSERT INTO [WithAllDatabaseGenerated] +OUTPUT INSERTED.[Id] +INTO @inserted0 +DEFAULT VALUES; +SELECT [t].[Id], [t].[Data1], [t].[Data2] FROM [WithAllDatabaseGenerated] t +INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id]);", + // + @"SET NOCOUNT ON; +DECLARE @inserted0 TABLE ([Id] int); +INSERT INTO [WithAllDatabaseGenerated] +OUTPUT INSERTED.[Id] +INTO @inserted0 +DEFAULT VALUES; +SELECT [t].[Id], [t].[Data1], [t].[Data2] FROM [WithAllDatabaseGenerated] t +INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id]);"); + } + + public override async Task Modify_Modify_with_same_entity_type_and_generated_values(bool async) + { + await base.Modify_Modify_with_same_entity_type_and_generated_values(async); + + AssertSql( + @"@p1='5' +@p0='1000' + +SET NOCOUNT ON; +UPDATE [WithSomeDatabaseGenerated] SET [Data2] = @p0 +WHERE [Id] = @p1; +SELECT [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = @p1;", + // + @"@p1='6' +@p0='1001' + +SET NOCOUNT ON; +UPDATE [WithSomeDatabaseGenerated] SET [Data2] = @p0 +WHERE [Id] = @p1; +SELECT [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = @p1;"); + } + + public override async Task Modify_Modify_with_same_entity_type_and_no_generated_values(bool async) + { + await base.Modify_Modify_with_same_entity_type_and_no_generated_values(async); + + AssertSql( + @"@p2='1' +@p0='1000' +@p1='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [WithNoDatabaseGenerated] SET [Data1] = @p0, [Data2] = @p1 +WHERE [Id] = @p2; +SELECT @@ROWCOUNT;", + // + @"@p2='2' +@p0='1001' +@p1='1001' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [WithNoDatabaseGenerated] SET [Data1] = @p0, [Data2] = @p1 +WHERE [Id] = @p2; +SELECT @@ROWCOUNT;"); + } + + public override async Task Delete_Delete_with_same_entity_type(bool async) + { + await base.Delete_Delete_with_same_entity_type(async); + + AssertSql( + @"@p0='5' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +DELETE FROM [WithSomeDatabaseGenerated] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT;", + // + @"@p0='6' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +DELETE FROM [WithSomeDatabaseGenerated] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT;"); + } + + #endregion Two operations with same entity type + + #region Two operations with different entity types + + public override async Task Add_Add_with_different_entity_types_and_generated_values(bool async) + { + await base.Add_Add_with_different_entity_types_and_generated_values(async); + + AssertSql( + @"@p0='1000' + +SET NOCOUNT ON; +DECLARE @inserted0 TABLE ([Id] int); +INSERT INTO [WithSomeDatabaseGenerated] ([Data2]) +OUTPUT INSERTED.[Id] +INTO @inserted0 +VALUES (@p0); +SELECT [t].[Id], [t].[Data1] FROM [WithSomeDatabaseGenerated] t +INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id]);", + // + @"@p0='1001' + +SET NOCOUNT ON; +DECLARE @inserted0 TABLE ([Id] int); +INSERT INTO [WithSomeDatabaseGenerated2] ([Data2]) +OUTPUT INSERTED.[Id] +INTO @inserted0 +VALUES (@p0); +SELECT [t].[Id], [t].[Data1] FROM [WithSomeDatabaseGenerated2] t +INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id]);"); + } + + public override async Task Add_Add_with_different_entity_types_and_no_generated_values(bool async) + { + await base.Add_Add_with_different_entity_types_and_no_generated_values(async); + + AssertSql( + @"@p0='100' +@p1='1000' +@p2='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +INSERT INTO [WithNoDatabaseGenerated] ([Id], [Data1], [Data2]) +VALUES (@p0, @p1, @p2);", + // + @"@p0='101' +@p1='1001' +@p2='1001' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +INSERT INTO [WithNoDatabaseGenerated2] ([Id], [Data1], [Data2]) +VALUES (@p0, @p1, @p2);"); + } + + public override async Task Add_Add_with_different_entity_types_and_all_generated_values(bool async) + { + await base.Add_Add_with_different_entity_types_and_all_generated_values(async); + + AssertSql( + @"SET NOCOUNT ON; +DECLARE @inserted0 TABLE ([Id] int); +INSERT INTO [WithAllDatabaseGenerated] +OUTPUT INSERTED.[Id] +INTO @inserted0 +DEFAULT VALUES; +SELECT [t].[Id], [t].[Data1], [t].[Data2] FROM [WithAllDatabaseGenerated] t +INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id]);", + // + @"SET NOCOUNT ON; +DECLARE @inserted0 TABLE ([Id] int); +INSERT INTO [WithAllDatabaseGenerated2] +OUTPUT INSERTED.[Id] +INTO @inserted0 +DEFAULT VALUES; +SELECT [t].[Id], [t].[Data1], [t].[Data2] FROM [WithAllDatabaseGenerated2] t +INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id]);"); + } + + public override async Task Modify_Modify_with_different_entity_types_and_generated_values(bool async) + { + await base.Modify_Modify_with_different_entity_types_and_generated_values(async); + + AssertSql( + @"@p1='5' +@p0='1000' + +SET NOCOUNT ON; +UPDATE [WithSomeDatabaseGenerated] SET [Data2] = @p0 +WHERE [Id] = @p1; +SELECT [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = @p1;", + // + @"@p1='8' +@p0='1001' + +SET NOCOUNT ON; +UPDATE [WithSomeDatabaseGenerated2] SET [Data2] = @p0 +WHERE [Id] = @p1; +SELECT [Data1] +FROM [WithSomeDatabaseGenerated2] +WHERE @@ROWCOUNT = 1 AND [Id] = @p1;"); + } + + public override async Task Modify_Modify_with_different_entity_types_and_no_generated_values(bool async) + { + await base.Modify_Modify_with_different_entity_types_and_no_generated_values(async); +AssertSql( + @"@p2='1' +@p0='1000' +@p1='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [WithNoDatabaseGenerated] SET [Data1] = @p0, [Data2] = @p1 +WHERE [Id] = @p2; +SELECT @@ROWCOUNT;", + // + @"@p2='2' +@p0='1001' +@p1='1001' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [WithNoDatabaseGenerated2] SET [Data1] = @p0, [Data2] = @p1 +WHERE [Id] = @p2; +SELECT @@ROWCOUNT;"); + } + + public override async Task Delete_Delete_with_different_entity_types(bool async) + { + await base.Delete_Delete_with_different_entity_types(async); + + AssertSql( + @"@p0='5' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +DELETE FROM [WithSomeDatabaseGenerated] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT;", + // + @"@p0='8' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +DELETE FROM [WithSomeDatabaseGenerated2] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT;"); + } + + #endregion Two operations with different entity types + + protected override async Task Test( + EntityState firstOperationType, + EntityState? secondOperationType, + GeneratedValues generatedValues, + bool async, + bool withSameEntityType = true) + { + await base.Test(firstOperationType, secondOperationType, generatedValues, async, withSameEntityType); + + if (!ShouldCreateImplicitTransaction(firstOperationType, secondOperationType, generatedValues, withSameEntityType)) + { + Assert.Contains("SET IMPLICIT_TRANSACTIONS OFF", Fixture.TestSqlLoggerFactory.SqlStatements[0]); + } + } + + public class StoreValueGenerationSequenceSqlServerFixture : StoreValueGenerationFixtureBase + { + protected override string StoreName { get; } = "StoreValueGenerationSequenceTest"; + + protected override ITestStoreFactory TestStoreFactory + => SqlServerTestStoreFactory.Instance; + + protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext context) + { + base.OnModelCreating(modelBuilder, context); + + modelBuilder.HasSequence("Ids"); + + foreach (var name in new[] + { + nameof(StoreValueGenerationContext.WithSomeDatabaseGenerated), + nameof(StoreValueGenerationContext.WithSomeDatabaseGenerated2), + nameof(StoreValueGenerationContext.WithAllDatabaseGenerated), + nameof(StoreValueGenerationContext.WithAllDatabaseGenerated2) + }) + { + modelBuilder + .SharedTypeEntity(name) + .Property(w => w.Id) + .HasDefaultValueSql("NEXT VALUE FOR [Ids]"); + } + } + + public override void Reseed() + { + using var context = CreateContext(); + Clean(context); + Seed(context); + } + + protected override void Clean(DbContext context) + { + base.Clean(context); + + // Reset the sequence values since we assert on them + context.Database.ExecuteSqlRaw("ALTER SEQUENCE [Ids] RESTART WITH 1"); + } + } +} diff --git a/test/EFCore.SqlServer.FunctionalTests/UpdatesSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/UpdatesSqlServerTest.cs index 4320cebe7ae..ac06c242721 100644 --- a/test/EFCore.SqlServer.FunctionalTests/UpdatesSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/UpdatesSqlServerTest.cs @@ -44,6 +44,7 @@ public virtual void Save_with_shared_foreign_key() @p1=NULL (Size = 4000) @p2='777' +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; INSERT INTO [Categories] ([Id], [Name], [PrincipalId]) VALUES (@p0, @p1, @p2);", @@ -131,6 +132,7 @@ public override void Save_replaced_principal() @"@p1='78' @p0='New Category' (Size = 4000) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; UPDATE [Categories] SET [Name] = @p0 WHERE [Id] = @p1; diff --git a/test/EFCore.Sqlite.FunctionalTests/Update/StoreValueGenerationSqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Update/StoreValueGenerationSqliteTest.cs new file mode 100644 index 00000000000..0904be07d53 --- /dev/null +++ b/test/EFCore.Sqlite.FunctionalTests/Update/StoreValueGenerationSqliteTest.cs @@ -0,0 +1,370 @@ +// 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.Update; + +#nullable enable + +public class StoreValueGenerationSqliteTest : StoreValueGenerationTestBase< + StoreValueGenerationSqliteTest.StoreValueGenerationSqliteFixture> +{ + public StoreValueGenerationSqliteTest(StoreValueGenerationSqliteFixture fixture, ITestOutputHelper testOutputHelper) + : base(fixture) + { + fixture.TestSqlLoggerFactory.Clear(); + fixture.TestSqlLoggerFactory.SetTestOutputHelper(testOutputHelper); + } + + protected override int ShouldExecuteInNumberOfCommands( + EntityState firstOperationType, + EntityState? secondOperationType, + GeneratedValues generatedValues, + bool withDatabaseGenerated) + => secondOperationType is null ? 1 : 2; + + #region Single operation + + public override async Task Add_with_generated_values(bool async) + { + await base.Add_with_generated_values(async); + + AssertSql( + @"@p0='1000' + +INSERT INTO ""WithSomeDatabaseGenerated"" (""Data2"") +VALUES (@p0); +SELECT ""Id"", ""Data1"" +FROM ""WithSomeDatabaseGenerated"" +WHERE changes() = 1 AND ""rowid"" = last_insert_rowid();"); + } + + public override async Task Add_with_no_generated_values(bool async) + { + await base.Add_with_no_generated_values(async); + + AssertSql( + @"@p0='100' +@p1='1000' +@p2='1000' + +INSERT INTO ""WithNoDatabaseGenerated"" (""Id"", ""Data1"", ""Data2"") +VALUES (@p0, @p1, @p2);"); + } + + public override async Task Add_with_all_generated_values(bool async) + { + await base.Add_with_all_generated_values(async); + + AssertSql( + @"INSERT INTO ""WithAllDatabaseGenerated"" +DEFAULT VALUES; +SELECT ""Id"", ""Data1"", ""Data2"" +FROM ""WithAllDatabaseGenerated"" +WHERE changes() = 1 AND ""rowid"" = last_insert_rowid();"); + } + + public override async Task Modify_with_generated_values(bool async) + { + await base.Modify_with_generated_values(async); + + AssertSql( + @"@p1='1' +@p0='1000' + +UPDATE ""WithSomeDatabaseGenerated"" SET ""Data2"" = @p0 +WHERE ""Id"" = @p1; +SELECT ""Data1"" +FROM ""WithSomeDatabaseGenerated"" +WHERE changes() = 1 AND ""Id"" = @p1;"); + } + + public override async Task Modify_with_no_generated_values(bool async) + { + await base.Modify_with_no_generated_values(async); + + AssertSql( + @"@p2='1' +@p0='1000' +@p1='1000' + +UPDATE ""WithNoDatabaseGenerated"" SET ""Data1"" = @p0, ""Data2"" = @p1 +WHERE ""Id"" = @p2; +SELECT changes();"); + } + + public override async Task Delete(bool async) + { + await base.Delete(async); + + AssertSql( + @"@p0='1' + +DELETE FROM ""WithSomeDatabaseGenerated"" +WHERE ""Id"" = @p0; +SELECT changes();"); + } + + #endregion Single operation + + #region Two operations with same entity type + + public override async Task Add_Add_with_same_entity_type_and_generated_values(bool async) + { + await base.Add_Add_with_same_entity_type_and_generated_values(async); + + AssertSql( + @"@p0='1000' + +INSERT INTO ""WithSomeDatabaseGenerated"" (""Data2"") +VALUES (@p0); +SELECT ""Id"", ""Data1"" +FROM ""WithSomeDatabaseGenerated"" +WHERE changes() = 1 AND ""rowid"" = last_insert_rowid();", + // + @"@p0='1001' + +INSERT INTO ""WithSomeDatabaseGenerated"" (""Data2"") +VALUES (@p0); +SELECT ""Id"", ""Data1"" +FROM ""WithSomeDatabaseGenerated"" +WHERE changes() = 1 AND ""rowid"" = last_insert_rowid();"); + } + + public override async Task Add_Add_with_same_entity_type_and_no_generated_values(bool async) + { + await base.Add_Add_with_same_entity_type_and_no_generated_values(async); + + AssertSql( + @"@p0='100' +@p1='1000' +@p2='1000' + +INSERT INTO ""WithNoDatabaseGenerated"" (""Id"", ""Data1"", ""Data2"") +VALUES (@p0, @p1, @p2);", + // + @"@p0='101' +@p1='1001' +@p2='1001' + +INSERT INTO ""WithNoDatabaseGenerated"" (""Id"", ""Data1"", ""Data2"") +VALUES (@p0, @p1, @p2);"); + } + + public override async Task Add_Add_with_same_entity_type_and_all_generated_values(bool async) + { + await base.Add_Add_with_same_entity_type_and_all_generated_values(async); + + AssertSql( + @"INSERT INTO ""WithAllDatabaseGenerated"" +DEFAULT VALUES; +SELECT ""Id"", ""Data1"", ""Data2"" +FROM ""WithAllDatabaseGenerated"" +WHERE changes() = 1 AND ""rowid"" = last_insert_rowid();", + // + @"INSERT INTO ""WithAllDatabaseGenerated"" +DEFAULT VALUES; +SELECT ""Id"", ""Data1"", ""Data2"" +FROM ""WithAllDatabaseGenerated"" +WHERE changes() = 1 AND ""rowid"" = last_insert_rowid();"); + } + + public override async Task Modify_Modify_with_same_entity_type_and_generated_values(bool async) + { + await base.Modify_Modify_with_same_entity_type_and_generated_values(async); + + AssertSql( + @"@p1='1' +@p0='1000' + +UPDATE ""WithSomeDatabaseGenerated"" SET ""Data2"" = @p0 +WHERE ""Id"" = @p1; +SELECT ""Data1"" +FROM ""WithSomeDatabaseGenerated"" +WHERE changes() = 1 AND ""Id"" = @p1;", + // + @"@p1='2' +@p0='1001' + +UPDATE ""WithSomeDatabaseGenerated"" SET ""Data2"" = @p0 +WHERE ""Id"" = @p1; +SELECT ""Data1"" +FROM ""WithSomeDatabaseGenerated"" +WHERE changes() = 1 AND ""Id"" = @p1;"); + } + + public override async Task Modify_Modify_with_same_entity_type_and_no_generated_values(bool async) + { + await base.Modify_Modify_with_same_entity_type_and_no_generated_values(async); + + AssertSql( + @"@p2='1' +@p0='1000' +@p1='1000' + +UPDATE ""WithNoDatabaseGenerated"" SET ""Data1"" = @p0, ""Data2"" = @p1 +WHERE ""Id"" = @p2; +SELECT changes();", + // + @"@p2='2' +@p0='1001' +@p1='1001' + +UPDATE ""WithNoDatabaseGenerated"" SET ""Data1"" = @p0, ""Data2"" = @p1 +WHERE ""Id"" = @p2; +SELECT changes();"); + } + + public override async Task Delete_Delete_with_same_entity_type(bool async) + { + await base.Delete_Delete_with_same_entity_type(async); + + AssertSql( + @"@p0='1' + +DELETE FROM ""WithSomeDatabaseGenerated"" +WHERE ""Id"" = @p0; +SELECT changes();", + // + @"@p0='2' + +DELETE FROM ""WithSomeDatabaseGenerated"" +WHERE ""Id"" = @p0; +SELECT changes();"); + } + + #endregion Two operations with same entity type + + #region Two operations with different entity types + + public override async Task Add_Add_with_different_entity_types_and_generated_values(bool async) + { + await base.Add_Add_with_different_entity_types_and_generated_values(async); + + AssertSql( + @"@p0='1000' + +INSERT INTO ""WithSomeDatabaseGenerated"" (""Data2"") +VALUES (@p0); +SELECT ""Id"", ""Data1"" +FROM ""WithSomeDatabaseGenerated"" +WHERE changes() = 1 AND ""rowid"" = last_insert_rowid();", + // + @"@p0='1001' + +INSERT INTO ""WithSomeDatabaseGenerated2"" (""Data2"") +VALUES (@p0); +SELECT ""Id"", ""Data1"" +FROM ""WithSomeDatabaseGenerated2"" +WHERE changes() = 1 AND ""rowid"" = last_insert_rowid();"); + } + + public override async Task Add_Add_with_different_entity_types_and_no_generated_values(bool async) + { + await base.Add_Add_with_different_entity_types_and_no_generated_values(async); + + AssertSql( + @"@p0='100' +@p1='1000' +@p2='1000' + +INSERT INTO ""WithNoDatabaseGenerated"" (""Id"", ""Data1"", ""Data2"") +VALUES (@p0, @p1, @p2);", + // + @"@p0='101' +@p1='1001' +@p2='1001' + +INSERT INTO ""WithNoDatabaseGenerated2"" (""Id"", ""Data1"", ""Data2"") +VALUES (@p0, @p1, @p2);"); + } + + public override async Task Add_Add_with_different_entity_types_and_all_generated_values(bool async) + { + await base.Add_Add_with_different_entity_types_and_all_generated_values(async); + + AssertSql( + @"INSERT INTO ""WithAllDatabaseGenerated"" +DEFAULT VALUES; +SELECT ""Id"", ""Data1"", ""Data2"" +FROM ""WithAllDatabaseGenerated"" +WHERE changes() = 1 AND ""rowid"" = last_insert_rowid();", + // + @"INSERT INTO ""WithAllDatabaseGenerated2"" +DEFAULT VALUES; +SELECT ""Id"", ""Data1"", ""Data2"" +FROM ""WithAllDatabaseGenerated2"" +WHERE changes() = 1 AND ""rowid"" = last_insert_rowid();"); + } + + public override async Task Modify_Modify_with_different_entity_types_and_generated_values(bool async) + { + await base.Modify_Modify_with_different_entity_types_and_generated_values(async); + + AssertSql( + @"@p1='1' +@p0='1000' + +UPDATE ""WithSomeDatabaseGenerated"" SET ""Data2"" = @p0 +WHERE ""Id"" = @p1; +SELECT ""Data1"" +FROM ""WithSomeDatabaseGenerated"" +WHERE changes() = 1 AND ""Id"" = @p1;", + // + @"@p1='2' +@p0='1001' + +UPDATE ""WithSomeDatabaseGenerated2"" SET ""Data2"" = @p0 +WHERE ""Id"" = @p1; +SELECT ""Data1"" +FROM ""WithSomeDatabaseGenerated2"" +WHERE changes() = 1 AND ""Id"" = @p1;"); + } + + public override async Task Modify_Modify_with_different_entity_types_and_no_generated_values(bool async) + { + await base.Modify_Modify_with_different_entity_types_and_no_generated_values(async); + + AssertSql( + @"@p2='1' +@p0='1000' +@p1='1000' + +UPDATE ""WithNoDatabaseGenerated"" SET ""Data1"" = @p0, ""Data2"" = @p1 +WHERE ""Id"" = @p2; +SELECT changes();", + // + @"@p2='2' +@p0='1001' +@p1='1001' + +UPDATE ""WithNoDatabaseGenerated2"" SET ""Data1"" = @p0, ""Data2"" = @p1 +WHERE ""Id"" = @p2; +SELECT changes();"); + } + + public override async Task Delete_Delete_with_different_entity_types(bool async) + { + await base.Delete_Delete_with_different_entity_types(async); + + AssertSql( + @"@p0='1' + +DELETE FROM ""WithSomeDatabaseGenerated"" +WHERE ""Id"" = @p0; +SELECT changes();", + // + @"@p0='2' + +DELETE FROM ""WithSomeDatabaseGenerated2"" +WHERE ""Id"" = @p0; +SELECT changes();"); + } + + #endregion Two operations with different entity types + + public class StoreValueGenerationSqliteFixture : StoreValueGenerationFixtureBase + { + protected override ITestStoreFactory TestStoreFactory + => SqliteTestStoreFactory.Instance; + } +} From 49ea9871cb8314587be5ed230803614a87b7736f Mon Sep 17 00:00:00 2001 From: Arthur Vickers Date: Fri, 4 Mar 2022 10:33:41 +0000 Subject: [PATCH 04/11] Be consistent about using ConditionalFact and ConditionalTheory (#27558) --- .../Migrations/Design/MigrationsBundleTest.cs | 8 ++++---- .../Migrations/ModelSnapshotSqlServerTest.cs | 2 +- .../Migrations/MigrationsTestBase.cs | 4 ++-- .../Migrations/Operations/CreateIndexOperationTest.cs | 2 +- .../Query/Internal/QuerySqlGeneratorTest.cs | 4 ++-- .../SqlServerMigrationsAnnotationProviderTest.cs | 2 +- .../SqlServerMigrationBuilderTest.cs | 4 ++-- .../Migrations/SqliteMigrationBuilderTest.cs | 4 ++-- .../Storage/SqliteRelationalConnectionTest.cs | 4 ++-- test/EFCore.Tests/DbSetTest.cs | 2 +- 10 files changed, 18 insertions(+), 18 deletions(-) diff --git a/test/EFCore.Design.Tests/Migrations/Design/MigrationsBundleTest.cs b/test/EFCore.Design.Tests/Migrations/Design/MigrationsBundleTest.cs index 3a2da1d4292..49b9e305c8f 100644 --- a/test/EFCore.Design.Tests/Migrations/Design/MigrationsBundleTest.cs +++ b/test/EFCore.Design.Tests/Migrations/Design/MigrationsBundleTest.cs @@ -7,7 +7,7 @@ namespace Microsoft.EntityFrameworkCore.Migrations.Design; public class MigrationsBundleTest { - [Fact] + [ConditionalFact] public void Short_names_are_unique() { foreach (var command in GetCommands()) @@ -24,7 +24,7 @@ public void Short_names_are_unique() } } - [Fact] + [ConditionalFact] public void Long_names_are_unique() { foreach (var command in GetCommands()) @@ -41,7 +41,7 @@ public void Long_names_are_unique() } } - [Fact] + [ConditionalFact] public void HandleResponseFiles_is_true() { var app = new CommandLineApplication { Name = "efbundle" }; @@ -50,7 +50,7 @@ public void HandleResponseFiles_is_true() Assert.True(app.HandleResponseFiles); } - [Fact] + [ConditionalFact] public void AllowArgumentSeparator_is_true() { var app = new CommandLineApplication { Name = "efbundle" }; diff --git a/test/EFCore.Design.Tests/Migrations/ModelSnapshotSqlServerTest.cs b/test/EFCore.Design.Tests/Migrations/ModelSnapshotSqlServerTest.cs index 0049e6289ee..b27e404b29e 100644 --- a/test/EFCore.Design.Tests/Migrations/ModelSnapshotSqlServerTest.cs +++ b/test/EFCore.Design.Tests/Migrations/ModelSnapshotSqlServerTest.cs @@ -1276,7 +1276,7 @@ public virtual void Primary_key_is_stored_in_snapshot() ); }); - [Fact] + [ConditionalFact] public void HasNoKey_is_handled() => Test( builder => builder.Entity().Ignore(e => e.EntityWithTwoProperties).HasNoKey(), diff --git a/test/EFCore.Relational.Specification.Tests/Migrations/MigrationsTestBase.cs b/test/EFCore.Relational.Specification.Tests/Migrations/MigrationsTestBase.cs index 439686def68..b8ecced9d14 100644 --- a/test/EFCore.Relational.Specification.Tests/Migrations/MigrationsTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Migrations/MigrationsTestBase.cs @@ -935,7 +935,7 @@ public virtual Task Alter_column_remove_comment() Assert.Null(column.Comment); }); - [Fact] + [ConditionalFact] public virtual Task Alter_column_set_collation() => Test( builder => builder.Entity("People").Property("Name"), @@ -951,7 +951,7 @@ public virtual Task Alter_column_set_collation() } }); - [Fact] + [ConditionalFact] public virtual Task Alter_column_reset_collation() => Test( builder => builder.Entity("People").Property("Name"), diff --git a/test/EFCore.Relational.Tests/Migrations/Operations/CreateIndexOperationTest.cs b/test/EFCore.Relational.Tests/Migrations/Operations/CreateIndexOperationTest.cs index c1ea868be05..61e3afdf453 100644 --- a/test/EFCore.Relational.Tests/Migrations/Operations/CreateIndexOperationTest.cs +++ b/test/EFCore.Relational.Tests/Migrations/Operations/CreateIndexOperationTest.cs @@ -5,7 +5,7 @@ namespace Microsoft.EntityFrameworkCore.Migrations.Operations; public class CreateIndexOperationTest { - [Fact] + [ConditionalFact] public void IsDescending_count_matches_column_count() { var operation = new CreateIndexOperation(); diff --git a/test/EFCore.Relational.Tests/Query/Internal/QuerySqlGeneratorTest.cs b/test/EFCore.Relational.Tests/Query/Internal/QuerySqlGeneratorTest.cs index 97af72b1cc9..ad30087f12a 100644 --- a/test/EFCore.Relational.Tests/Query/Internal/QuerySqlGeneratorTest.cs +++ b/test/EFCore.Relational.Tests/Query/Internal/QuerySqlGeneratorTest.cs @@ -7,7 +7,7 @@ namespace Microsoft.EntityFrameworkCore.Query.Internal; public class QuerySqlGeneratorTest { - [Theory] + [ConditionalTheory] [InlineData("INSERT something")] [InlineData("SELECTANDSOMEOTHERSTUFF")] [InlineData("SELECT")] @@ -29,7 +29,7 @@ public void CheckComposableSql_throws(string sql) () => CreateDummyQuerySqlGenerator().CheckComposableSql(sql.Replace("SELECT", "WITH"))).Message); } - [Theory] + [ConditionalTheory] [InlineData("SELECT something")] [InlineData(" SELECT something")] [InlineData("-- comment\n SELECT something")] diff --git a/test/EFCore.SqlServer.Tests/Migrations/SqlServerMigrationsAnnotationProviderTest.cs b/test/EFCore.SqlServer.Tests/Migrations/SqlServerMigrationsAnnotationProviderTest.cs index 29435d3d7b4..2bba0d466df 100644 --- a/test/EFCore.SqlServer.Tests/Migrations/SqlServerMigrationsAnnotationProviderTest.cs +++ b/test/EFCore.SqlServer.Tests/Migrations/SqlServerMigrationsAnnotationProviderTest.cs @@ -14,7 +14,7 @@ public SqlServerMigrationsAnnotationProviderTest() _annotations = new SqlServerAnnotationProvider(new RelationalAnnotationProviderDependencies()); } - [Fact] + [ConditionalFact] public void For_property_handles_identity_annotations() { var modelBuilder = SqlServerTestHelpers.Instance.CreateConventionBuilder(); diff --git a/test/EFCore.SqlServer.Tests/SqlServerMigrationBuilderTest.cs b/test/EFCore.SqlServer.Tests/SqlServerMigrationBuilderTest.cs index 8b5c70d0986..3b0b3a26e76 100644 --- a/test/EFCore.SqlServer.Tests/SqlServerMigrationBuilderTest.cs +++ b/test/EFCore.SqlServer.Tests/SqlServerMigrationBuilderTest.cs @@ -5,14 +5,14 @@ namespace Microsoft.EntityFrameworkCore.SqlServer.Tests; public class SqlServerMigrationBuilderTest { - [Fact] + [ConditionalFact] public void IsSqlServer_when_using_SqlServer() { var migrationBuilder = new MigrationBuilder("Microsoft.EntityFrameworkCore.SqlServer"); Assert.True(migrationBuilder.IsSqlServer()); } - [Fact] + [ConditionalFact] public void Not_IsSqlServer_when_using_different_provider() { var migrationBuilder = new MigrationBuilder("Microsoft.EntityFrameworkCore.InMemory"); diff --git a/test/EFCore.Sqlite.Tests/Migrations/SqliteMigrationBuilderTest.cs b/test/EFCore.Sqlite.Tests/Migrations/SqliteMigrationBuilderTest.cs index 84305972b12..be15dfbe848 100644 --- a/test/EFCore.Sqlite.Tests/Migrations/SqliteMigrationBuilderTest.cs +++ b/test/EFCore.Sqlite.Tests/Migrations/SqliteMigrationBuilderTest.cs @@ -5,14 +5,14 @@ namespace Microsoft.EntityFrameworkCore.Migrations; public class SqliteMigrationBuilderTest { - [Fact] + [ConditionalFact] public void IsSqlite_when_using_Sqlite() { var migrationBuilder = new MigrationBuilder("Microsoft.EntityFrameworkCore.Sqlite"); Assert.True(migrationBuilder.IsSqlite()); } - [Fact] + [ConditionalFact] public void Not_IsSqlite_when_using_different_provider() { var migrationBuilder = new MigrationBuilder("Microsoft.EntityFrameworkCore.InMemory"); diff --git a/test/EFCore.Sqlite.Tests/Storage/SqliteRelationalConnectionTest.cs b/test/EFCore.Sqlite.Tests/Storage/SqliteRelationalConnectionTest.cs index a370d1088ef..061f914ef25 100644 --- a/test/EFCore.Sqlite.Tests/Storage/SqliteRelationalConnectionTest.cs +++ b/test/EFCore.Sqlite.Tests/Storage/SqliteRelationalConnectionTest.cs @@ -7,7 +7,7 @@ namespace Microsoft.EntityFrameworkCore.Storage; public class SqliteRelationalConnectionTest { - [Fact] + [ConditionalFact] public void Sets_DefaultTimeout_when_connectionString() { var services = SqliteTestHelpers.Instance.CreateContextServices( @@ -20,7 +20,7 @@ public void Sets_DefaultTimeout_when_connectionString() Assert.Equal(42, connection.DefaultTimeout); } - [Fact] + [ConditionalFact] public void Sets_DefaultTimeout_when_connection() { var originalConnection = new SqliteConnection("Data Source=:memory:") { DefaultTimeout = 21 }; diff --git a/test/EFCore.Tests/DbSetTest.cs b/test/EFCore.Tests/DbSetTest.cs index 1f08319ada9..11e5847a297 100644 --- a/test/EFCore.Tests/DbSetTest.cs +++ b/test/EFCore.Tests/DbSetTest.cs @@ -133,7 +133,7 @@ public void Direct_use_of_Set_throws_if_context_disposed() Assert.Throws(() => context.Set()).Message); } - [Fact] + [ConditionalFact] public void Direct_use_of_Set_for_shared_type_throws_if_context_disposed() { var context = new EarlyLearningCenter(); From e8b51bdec79b5b78e4c3acb1287121c9b273f65d Mon Sep 17 00:00:00 2001 From: Weihan Li Date: Sun, 6 Mar 2022 00:55:14 +0800 Subject: [PATCH 05/11] Remove DisableImplicitNamespaceImports config (#27545) --- Directory.Build.props | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 5e985fd1e50..94132fb6e9b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -35,10 +35,6 @@ $(NoWarn.Replace(';1591', '')) - - - true - From 96aeb2ddf8e402bf228e3a203cc313f384c3f6cb Mon Sep 17 00:00:00 2001 From: "dotnet-maestro[bot]" <42748379+dotnet-maestro[bot]@users.noreply.github.com> Date: Mon, 7 Mar 2022 13:54:48 +0000 Subject: [PATCH 06/11] Update dependencies from https://github.com/dotnet/runtime build 20220307.1 (#27585) [main] Update dependencies from dotnet/runtime --- eng/Version.Details.xml | 44 ++++++++++++++++++++--------------------- eng/Versions.props | 22 ++++++++++----------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index fb6bdc31337..e61dbbd7eba 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -1,49 +1,49 @@ - + https://github.com/dotnet/runtime - 68fb7fc68cc1af800bee1d38af22b5027bf4ab4e + 7698a9a80c5f6270aa1122d79ce419c7b03f2498 - + https://github.com/dotnet/runtime - 68fb7fc68cc1af800bee1d38af22b5027bf4ab4e + 7698a9a80c5f6270aa1122d79ce419c7b03f2498 - + https://github.com/dotnet/runtime - 68fb7fc68cc1af800bee1d38af22b5027bf4ab4e + 7698a9a80c5f6270aa1122d79ce419c7b03f2498 - + https://github.com/dotnet/runtime - 68fb7fc68cc1af800bee1d38af22b5027bf4ab4e + 7698a9a80c5f6270aa1122d79ce419c7b03f2498 - + https://github.com/dotnet/runtime - 68fb7fc68cc1af800bee1d38af22b5027bf4ab4e + 7698a9a80c5f6270aa1122d79ce419c7b03f2498 - + https://github.com/dotnet/runtime - 68fb7fc68cc1af800bee1d38af22b5027bf4ab4e + 7698a9a80c5f6270aa1122d79ce419c7b03f2498 - + https://github.com/dotnet/runtime - 68fb7fc68cc1af800bee1d38af22b5027bf4ab4e + 7698a9a80c5f6270aa1122d79ce419c7b03f2498 - + https://github.com/dotnet/runtime - 68fb7fc68cc1af800bee1d38af22b5027bf4ab4e + 7698a9a80c5f6270aa1122d79ce419c7b03f2498 - + https://github.com/dotnet/runtime - 68fb7fc68cc1af800bee1d38af22b5027bf4ab4e + 7698a9a80c5f6270aa1122d79ce419c7b03f2498 - + https://github.com/dotnet/runtime - 68fb7fc68cc1af800bee1d38af22b5027bf4ab4e + 7698a9a80c5f6270aa1122d79ce419c7b03f2498 - + https://github.com/dotnet/runtime - 68fb7fc68cc1af800bee1d38af22b5027bf4ab4e + 7698a9a80c5f6270aa1122d79ce419c7b03f2498 diff --git a/eng/Versions.props b/eng/Versions.props index caed9265c62..1424e4e05ae 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -15,17 +15,17 @@ False - 7.0.0-preview.3.22127.1 - 7.0.0-preview.3.22127.1 - 7.0.0-preview.3.22127.1 - 7.0.0-preview.3.22127.1 - 7.0.0-preview.3.22127.1 - 7.0.0-preview.3.22127.1 - 7.0.0-preview.3.22127.1 - 7.0.0-preview.3.22127.1 - 7.0.0-preview.3.22127.1 - 7.0.0-preview.3.22127.1 - 7.0.0-preview.3.22127.1 + 7.0.0-preview.3.22157.1 + 7.0.0-preview.3.22157.1 + 7.0.0-preview.3.22157.1 + 7.0.0-preview.3.22157.1 + 7.0.0-preview.3.22157.1 + 7.0.0-preview.3.22157.1 + 7.0.0-preview.3.22157.1 + 7.0.0-preview.3.22157.1 + 7.0.0-preview.3.22157.1 + 7.0.0-preview.3.22157.1 + 7.0.0-preview.3.22157.1 4.0.1 From 094766bd94bb44a6e4f7a1a2cbfba328a68c2b74 Mon Sep 17 00:00:00 2001 From: "dotnet-maestro[bot]" <42748379+dotnet-maestro[bot]@users.noreply.github.com> Date: Mon, 7 Mar 2022 14:10:42 +0000 Subject: [PATCH 07/11] Update dependencies from https://github.com/dotnet/arcade build 20220304.3 (#27586) [main] Update dependencies from dotnet/arcade --- eng/Version.Details.xml | 8 ++++---- global.json | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index e61dbbd7eba..368101f46fe 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -47,13 +47,13 @@ - + https://github.com/dotnet/arcade - f7136626d0109856df867481219eb7366951985d + 8ed47fcae6a5d2d40483ed81858f4ede8eab7ae2 - + https://github.com/dotnet/arcade - f7136626d0109856df867481219eb7366951985d + 8ed47fcae6a5d2d40483ed81858f4ede8eab7ae2 diff --git a/global.json b/global.json index d6fcb9e26c5..c6b1bc1bbab 100644 --- a/global.json +++ b/global.json @@ -18,7 +18,7 @@ "rollForward": "latestMajor" }, "msbuild-sdks": { - "Microsoft.DotNet.Arcade.Sdk": "7.0.0-beta.22124.4", - "Microsoft.DotNet.Helix.Sdk": "7.0.0-beta.22124.4" + "Microsoft.DotNet.Arcade.Sdk": "7.0.0-beta.22154.3", + "Microsoft.DotNet.Helix.Sdk": "7.0.0-beta.22154.3" } } From 66a53b7a4c940424119f9eea8c1ce1fcd3624133 Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Tue, 8 Mar 2022 21:48:05 +0200 Subject: [PATCH 08/11] Tiny workaround for analyzer issue (#27591) --- src/EFCore.Analyzers/InternalUsageDiagnosticAnalyzer.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/EFCore.Analyzers/InternalUsageDiagnosticAnalyzer.cs b/src/EFCore.Analyzers/InternalUsageDiagnosticAnalyzer.cs index b4d65f6a132..55faac08b05 100644 --- a/src/EFCore.Analyzers/InternalUsageDiagnosticAnalyzer.cs +++ b/src/EFCore.Analyzers/InternalUsageDiagnosticAnalyzer.cs @@ -16,7 +16,8 @@ public sealed class InternalUsageDiagnosticAnalyzer : DiagnosticAnalyzer private static readonly int EFLen = "EntityFrameworkCore".Length; private static readonly DiagnosticDescriptor Descriptor - = new( + // HACK: Work around dotnet/roslyn-analyzers#5828 by not using target-typed new + = new DiagnosticDescriptor( Id, title: AnalyzerStrings.InternalUsageTitle, messageFormat: AnalyzerStrings.InternalUsageMessageFormat, From ac143e65f4f2586a9a5f040435d89655c6708e78 Mon Sep 17 00:00:00 2001 From: Brice Lambson Date: Tue, 8 Mar 2022 20:14:31 -0800 Subject: [PATCH 09/11] Scaffolding: Enable text templates (#27565) Part of #4038 --- .github/dependabot.yml | 1 + .../DesignTimeServiceCollectionExtensions.cs | 6 +- .../Design/Internal/DatabaseOperations.cs | 3 +- src/EFCore.Design/EFCore.Design.csproj | 1 + .../Properties/DesignStrings.Designer.cs | 23 +- .../Properties/DesignStrings.resx | 62 ++-- .../IModelCodeGeneratorSelector.cs | 11 + .../Internal/ModelCodeGeneratorSelector.cs | 14 +- .../Internal/ReverseEngineerScaffolder.cs | 2 +- .../Internal/TextTemplatingModelGenerator.cs | 172 ++++++++++ .../Scaffolding/ModelCodeGenerationOptions.cs | 6 + .../Scaffolding/ModelCodeGenerator.cs | 2 +- .../Scaffolding/TemplatedModelGenerator.cs | 36 ++ .../TextTemplating/ITextTemplating.cs | 21 ++ .../TextTemplating/ITextTemplatingCallback.cs | 32 ++ .../Internal/TextTemplatingCallback.cs | 65 ++++ .../Internal/TextTemplatingService.cs | 241 +++++++++++++ .../Design/DesignTimeServicesTest.cs | 8 +- .../Internal/CSharpDbContextGeneratorTest.cs | 6 +- .../Internal/CSharpModelGeneratorTest.cs | 3 +- .../ModelCodeGeneratorSelectorTest.cs | 119 +++++++ .../Internal/ModelCodeGeneratorTestBase.cs | 3 +- .../TextTemplatingModelGeneratorTest.cs | 316 ++++++++++++++++++ .../TestUtilities/TempDirectory.cs | 3 + .../Internal/TextTemplatingServiceTest.cs | 183 ++++++++++ .../TestUtilities/BuildReference.cs | 1 + 26 files changed, 1298 insertions(+), 42 deletions(-) create mode 100644 src/EFCore.Design/Scaffolding/Internal/TextTemplatingModelGenerator.cs create mode 100644 src/EFCore.Design/Scaffolding/TemplatedModelGenerator.cs create mode 100644 src/EFCore.Design/TextTemplating/ITextTemplating.cs create mode 100644 src/EFCore.Design/TextTemplating/ITextTemplatingCallback.cs create mode 100644 src/EFCore.Design/TextTemplating/Internal/TextTemplatingCallback.cs create mode 100644 src/EFCore.Design/TextTemplating/Internal/TextTemplatingService.cs create mode 100644 test/EFCore.Design.Tests/Scaffolding/Internal/ModelCodeGeneratorSelectorTest.cs create mode 100644 test/EFCore.Design.Tests/Scaffolding/Internal/TextTemplatingModelGeneratorTest.cs create mode 100644 test/EFCore.Design.Tests/TextTemplating/Internal/TextTemplatingServiceTest.cs diff --git a/.github/dependabot.yml b/.github/dependabot.yml index fecd3c2648f..2d15c4e1f22 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -18,6 +18,7 @@ updates: - dependency-name: Microsoft.Data.SqlClient - dependency-name: Microsoft.DotNet.PlatformAbstractions - dependency-name: mod_spatialite + - dependency-name: Mono.TextTemplating - dependency-name: NetTopologySuite* - dependency-name: Newtonsoft.Json - dependency-name: SQLitePCLRaw* diff --git a/src/EFCore.Design/Design/DesignTimeServiceCollectionExtensions.cs b/src/EFCore.Design/Design/DesignTimeServiceCollectionExtensions.cs index 15a2dfe99cc..e198619bc55 100644 --- a/src/EFCore.Design/Design/DesignTimeServiceCollectionExtensions.cs +++ b/src/EFCore.Design/Design/DesignTimeServiceCollectionExtensions.cs @@ -7,6 +7,8 @@ using Microsoft.EntityFrameworkCore.Migrations.Internal; using Microsoft.EntityFrameworkCore.Scaffolding; using Microsoft.EntityFrameworkCore.Scaffolding.Internal; +using Microsoft.EntityFrameworkCore.TextTemplating; +using Microsoft.EntityFrameworkCore.TextTemplating.Internal; namespace Microsoft.EntityFrameworkCore.Design; @@ -52,7 +54,9 @@ public static IServiceCollection AddEntityFrameworkDesignTimeServices( .TryAddSingleton(reporter) .TryAddSingleton() .TryAddSingleton() - .TryAddSingleton() + .TryAddSingleton() + .TryAddSingletonEnumerable() + .TryAddSingletonEnumerable() .TryAddSingleton() .TryAddSingleton() .TryAddSingleton() diff --git a/src/EFCore.Design/Design/Internal/DatabaseOperations.cs b/src/EFCore.Design/Design/Internal/DatabaseOperations.cs index 0aded8b9714..e6db7ce4479 100644 --- a/src/EFCore.Design/Design/Internal/DatabaseOperations.cs +++ b/src/EFCore.Design/Design/Internal/DatabaseOperations.cs @@ -96,7 +96,8 @@ public virtual SavedModelFiles ScaffoldContext( UseNullableReferenceTypes = _nullable, ContextDir = MakeDirRelative(outputDir, outputContextDir), ContextName = dbContextClassName, - SuppressOnConfiguring = suppressOnConfiguring + SuppressOnConfiguring = suppressOnConfiguring, + ProjectDir = _projectDir }); return scaffolder.Save( diff --git a/src/EFCore.Design/EFCore.Design.csproj b/src/EFCore.Design/EFCore.Design.csproj index f18008e2f39..55c208b9953 100644 --- a/src/EFCore.Design/EFCore.Design.csproj +++ b/src/EFCore.Design/EFCore.Design.csproj @@ -60,6 +60,7 @@ + diff --git a/src/EFCore.Design/Properties/DesignStrings.Designer.cs b/src/EFCore.Design/Properties/DesignStrings.Designer.cs index 1f43f910c25..84c4d344d98 100644 --- a/src/EFCore.Design/Properties/DesignStrings.Designer.cs +++ b/src/EFCore.Design/Properties/DesignStrings.Designer.cs @@ -1,5 +1,7 @@ // +using System; +using System.Reflection; using System.Resources; #nullable enable @@ -118,7 +120,7 @@ public static string CompiledModelTypeMapping(object? entityType, object? proper entityType, property, customize, className); /// - /// The property '{entityType}.{property}' has a value comparer configured using a ValueComparer instance. Instead, create types that inherit from ValueConverter and ValueComparer and use '{method}HasConversion=<ConverterType, ComparerType=>()' or '{method}(Type converterType, Type comparerType)' to configure the value converter and comparer. + /// The property '{entityType}.{property}' has a value comparer configured using a ValueComparer instance. Instead, create types that inherit from ValueConverter and ValueComparer and use '{method}=<ConverterType, ComparerType=>()' or '{method}(Type converterType, Type comparerType)' to configure the value converter and comparer. /// public static string CompiledModelValueComparer(object? entityType, object? property, object? method) => string.Format( @@ -126,7 +128,7 @@ public static string CompiledModelValueComparer(object? entityType, object? prop entityType, property, method); /// - /// The property '{entityType}.{property}' has a value converter configured using a ValueConverter instance or inline expressions. Instead, create a type that inherits from ValueConverter and use '{method}HasConversion=<ConverterType=>()' or '{method}(Type converterType)' to configure the value converter. + /// The property '{entityType}.{property}' has a value converter configured using a ValueConverter instance or inline expressions. Instead, create a type that inherits from ValueConverter and use '{method}=<ConverterType=>()' or '{method}(Type converterType)' to configure the value converter. /// public static string CompiledModelValueConverter(object? entityType, object? property, object? method) => string.Format( @@ -199,6 +201,14 @@ public static string DuplicateMigrationName(object? migrationName) GetString("DuplicateMigrationName", nameof(migrationName)), migrationName); + /// + /// The encoding '{encoding}' specified in the output directive will be ignored. EF Core always scaffolds files using the encoding 'utf-8'. + /// + public static string EncodingIgnored(object? encoding) + => string.Format( + GetString("EncodingIgnored", nameof(encoding)), + encoding); + /// /// An error occurred while accessing the database. Continuing without the information provided by the database. Error: {message} /// @@ -657,6 +667,14 @@ public static string UnhandledEnumValue(object? enumValue) GetString("UnhandledEnumValue", nameof(enumValue)), enumValue); + /// + /// Failed to resolve type for directive processor {name}. + /// + public static string UnknownDirectiveProcessor(object? name) + => string.Format( + GetString("UnknownDirectiveProcessor", nameof(name)), + name); + /// /// Cannot scaffold C# literals of type '{literalType}'. The provider should implement CoreTypeMapping.GenerateCodeLiteral to support using it at design time. /// @@ -777,3 +795,4 @@ private static string GetString(string name, params string[] formatterNames) } } } + diff --git a/src/EFCore.Design/Properties/DesignStrings.resx b/src/EFCore.Design/Properties/DesignStrings.resx index 0a7e6577b7f..c8e89f0c6b7 100644 --- a/src/EFCore.Design/Properties/DesignStrings.resx +++ b/src/EFCore.Design/Properties/DesignStrings.resx @@ -1,17 +1,17 @@  - @@ -189,6 +189,9 @@ The name '{migrationName}' is used by an existing migration. + + The encoding '{encoding}' specified in the output directive will be ignored. EF Core always scaffolds files using the encoding 'utf-8'. + An error occurred while accessing the database. Continuing without the information provided by the database. Error: {message} @@ -378,6 +381,9 @@ Change your target project to the migrations project by using the Package Manage Unhandled enum value '{enumValue}'. + + Failed to resolve type for directive processor {name}. + Cannot scaffold C# literals of type '{literalType}'. The provider should implement CoreTypeMapping.GenerateCodeLiteral to support using it at design time. @@ -420,4 +426,4 @@ Change your target project to the migrations project by using the Package Manage Writing model snapshot to '{file}'. - + \ No newline at end of file diff --git a/src/EFCore.Design/Scaffolding/IModelCodeGeneratorSelector.cs b/src/EFCore.Design/Scaffolding/IModelCodeGeneratorSelector.cs index b2d40980104..1e8d73b84a1 100644 --- a/src/EFCore.Design/Scaffolding/IModelCodeGeneratorSelector.cs +++ b/src/EFCore.Design/Scaffolding/IModelCodeGeneratorSelector.cs @@ -16,5 +16,16 @@ public interface IModelCodeGeneratorSelector /// /// The programming language. /// The . + [Obsolete("Use the overload that takes ModelCodeGenerationOptions instead.")] IModelCodeGenerator Select(string? language); + + /// + /// Selects an service for a given set of options. + /// + /// The options. + /// The . + IModelCodeGenerator Select(ModelCodeGenerationOptions options) +#pragma warning disable CS0618 // Type or member is obsolete + => Select(options.Language); +#pragma warning restore CS0618 } diff --git a/src/EFCore.Design/Scaffolding/Internal/ModelCodeGeneratorSelector.cs b/src/EFCore.Design/Scaffolding/Internal/ModelCodeGeneratorSelector.cs index 517182fe6ac..5eaff106a3c 100644 --- a/src/EFCore.Design/Scaffolding/Internal/ModelCodeGeneratorSelector.cs +++ b/src/EFCore.Design/Scaffolding/Internal/ModelCodeGeneratorSelector.cs @@ -13,6 +13,8 @@ namespace Microsoft.EntityFrameworkCore.Scaffolding.Internal; /// public class ModelCodeGeneratorSelector : LanguageBasedSelector, IModelCodeGeneratorSelector { + private readonly IEnumerable _templatedModelGenerators; + /// /// 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 @@ -20,7 +22,13 @@ public class ModelCodeGeneratorSelector : LanguageBasedSelector public ModelCodeGeneratorSelector(IEnumerable services) - : base(services) - { - } + : base(services.Except(services.OfType()).ToList()) + => _templatedModelGenerators = services.OfType().ToList(); + + /// + public virtual IModelCodeGenerator Select(ModelCodeGenerationOptions options) + => _templatedModelGenerators + .Where(g => options.ProjectDir != null && g.HasTemplates(options.ProjectDir)) + .LastOrDefault() + ?? Select(options.Language); } diff --git a/src/EFCore.Design/Scaffolding/Internal/ReverseEngineerScaffolder.cs b/src/EFCore.Design/Scaffolding/Internal/ReverseEngineerScaffolder.cs index 9d984db84dd..894569031a4 100644 --- a/src/EFCore.Design/Scaffolding/Internal/ReverseEngineerScaffolder.cs +++ b/src/EFCore.Design/Scaffolding/Internal/ReverseEngineerScaffolder.cs @@ -114,7 +114,7 @@ public virtual ScaffoldedModel ScaffoldModel( : DefaultDbContextName; } - var codeGenerator = ModelCodeGeneratorSelector.Select(codeOptions.Language); + var codeGenerator = ModelCodeGeneratorSelector.Select(codeOptions); return codeGenerator.GenerateModel(model, codeOptions); } diff --git a/src/EFCore.Design/Scaffolding/Internal/TextTemplatingModelGenerator.cs b/src/EFCore.Design/Scaffolding/Internal/TextTemplatingModelGenerator.cs new file mode 100644 index 00000000000..4b524c61332 --- /dev/null +++ b/src/EFCore.Design/Scaffolding/Internal/TextTemplatingModelGenerator.cs @@ -0,0 +1,172 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CodeDom.Compiler; +using System.Text; +using Microsoft.EntityFrameworkCore.Design.Internal; +using Microsoft.EntityFrameworkCore.Internal; +using Microsoft.EntityFrameworkCore.TextTemplating; +using Microsoft.EntityFrameworkCore.TextTemplating.Internal; + +namespace Microsoft.EntityFrameworkCore.Scaffolding.Internal; + +internal class TextTemplatingModelGenerator : TemplatedModelGenerator +{ + private readonly ITextTemplating _host; + private readonly IOperationReporter _reporter; + + public TextTemplatingModelGenerator( + ModelCodeGeneratorDependencies dependencies, + ITextTemplating textTemplatingService, + IOperationReporter reporter) + : base(dependencies) + { + _host = textTemplatingService; + _reporter = reporter; + } + + public override bool HasTemplates(string projectDir) + => File.Exists(Path.Combine(projectDir, TemplatesDirectory, "DbContext.t4")); + + public override ScaffoldedModel GenerateModel(IModel model, ModelCodeGenerationOptions options) + { + if (options.ContextName == null) + { + throw new ArgumentException( + CoreStrings.ArgumentPropertyNull(nameof(options.ContextName), nameof(options)), nameof(options)); + } + + if (options.ConnectionString == null) + { + throw new ArgumentException( + CoreStrings.ArgumentPropertyNull(nameof(options.ConnectionString), nameof(options)), nameof(options)); + } + + var resultingFiles = new ScaffoldedModel(); + + var contextTemplate = Path.Combine(options.ProjectDir!, TemplatesDirectory, "DbContext.t4"); + + Check.DebugAssert(_host.Session == null, "Session is not null."); + _host.Session = _host.CreateSession(); + try + { + _host.Session.Add("Model", model); + _host.Session.Add("Options", options); + _host.Session.Add("NamespaceHint", options.ContextNamespace ?? options.ModelNamespace); + _host.Session.Add("ProjectDefaultNamespace", options.RootNamespace); + + var handler = new TextTemplatingCallback(); + var generatedCode = ProcessTemplate(contextTemplate, handler); + + var dbContextFileName = options.ContextName + handler.Extension; + resultingFiles.ContextFile = new ScaffoldedFile + { + Path = options.ContextDir != null + ? Path.Combine(options.ContextDir, dbContextFileName) + : dbContextFileName, + Code = generatedCode + }; + } + finally + { + _host.Session = null; + } + + var entityTypeTemplate = Path.Combine(options.ProjectDir!, TemplatesDirectory, "EntityType.t4"); + if (File.Exists(entityTypeTemplate)) + { + foreach (var entityType in model.GetEntityTypes()) + { + // TODO: Should this be handled inside the template? + if (CSharpDbContextGenerator.IsManyToManyJoinEntityType(entityType)) + { + continue; + } + + _host.Session = _host.CreateSession(); + try + { + _host.Session.Add("EntityType", entityType); + _host.Session.Add("Options", options); + _host.Session.Add("NamespaceHint", options.ModelNamespace); + _host.Session.Add("ProjectDefaultNamespace", options.RootNamespace); + + var handler = new TextTemplatingCallback(); + var generatedCode = ProcessTemplate(entityTypeTemplate, handler); + if (string.IsNullOrWhiteSpace(generatedCode)) + { + continue; + } + + var entityTypeFileName = entityType.Name + handler.Extension; + resultingFiles.AdditionalFiles.Add( + new ScaffoldedFile { Path = entityTypeFileName, Code = generatedCode }); + } + finally + { + _host.Session = null; + } + } + } + + return resultingFiles; + } + + private string ProcessTemplate(string inputFile, TextTemplatingCallback handler) + { + var output = _host.ProcessTemplate( + inputFile, + File.ReadAllText(inputFile), + handler); + + foreach (CompilerError error in handler.Errors) + { + var builder = new StringBuilder(); + + if (!string.IsNullOrEmpty(error.FileName)) + { + builder.Append(error.FileName); + + if (error.Line > 0) + { + builder + .Append("(") + .Append(error.Line); + + if (error.Column > 0) + { + builder + .Append(",") + .Append(error.Line); + } + builder.Append(")"); + } + + builder.Append(" : "); + } + + builder + .Append(error.IsWarning ? "warning" : "error") + .Append(" ") + .Append(error.ErrorNumber) + .Append(": ") + .AppendLine(error.ErrorText); + + if (error.IsWarning) + { + _reporter.WriteWarning(builder.ToString()); + } + else + { + _reporter.WriteError(builder.ToString()); + } + } + + if (handler.OutputEncoding != Encoding.UTF8) + { + _reporter.WriteWarning(DesignStrings.EncodingIgnored(handler.OutputEncoding.WebName)); + } + + return output; + } +} diff --git a/src/EFCore.Design/Scaffolding/ModelCodeGenerationOptions.cs b/src/EFCore.Design/Scaffolding/ModelCodeGenerationOptions.cs index 430a7254dc1..84e6e295ae9 100644 --- a/src/EFCore.Design/Scaffolding/ModelCodeGenerationOptions.cs +++ b/src/EFCore.Design/Scaffolding/ModelCodeGenerationOptions.cs @@ -73,4 +73,10 @@ public class ModelCodeGenerationOptions /// /// The connection string. public virtual string? ConnectionString { get; set; } + + /// + /// Gets or sets the root project directory. + /// + /// The directory. + public virtual string? ProjectDir { get; set; } } diff --git a/src/EFCore.Design/Scaffolding/ModelCodeGenerator.cs b/src/EFCore.Design/Scaffolding/ModelCodeGenerator.cs index 21dcc65c7bc..9d1eb72129b 100644 --- a/src/EFCore.Design/Scaffolding/ModelCodeGenerator.cs +++ b/src/EFCore.Design/Scaffolding/ModelCodeGenerator.cs @@ -24,7 +24,7 @@ protected ModelCodeGenerator(ModelCodeGeneratorDependencies dependencies) /// Gets the programming language supported by this service. /// /// The language. - public abstract string Language { get; } + public abstract string? Language { get; } /// /// Dependencies for this service. diff --git a/src/EFCore.Design/Scaffolding/TemplatedModelGenerator.cs b/src/EFCore.Design/Scaffolding/TemplatedModelGenerator.cs new file mode 100644 index 00000000000..688aabd9790 --- /dev/null +++ b/src/EFCore.Design/Scaffolding/TemplatedModelGenerator.cs @@ -0,0 +1,36 @@ +// 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.Scaffolding; + +/// +/// Base type for model code generators that use templates. +/// +internal abstract class TemplatedModelGenerator : ModelCodeGenerator +{ + /// + /// Initializes a new instance of the class. + /// + /// The dependencies. + protected TemplatedModelGenerator(ModelCodeGeneratorDependencies dependencies) + : base(dependencies) + { + } + + /// + /// Gets the subdirectory under the project to look for templates in. + /// + /// The subdirectory. + protected static string TemplatesDirectory { get; } = Path.Combine("Templates", "EFCore"); + + /// + public override string? Language + => null; + + /// + /// Checks whether the templates required for this generator are present. + /// + /// The root project directory. + /// if the templates are present; otherwise, . + public abstract bool HasTemplates(string projectDir); +} diff --git a/src/EFCore.Design/TextTemplating/ITextTemplating.cs b/src/EFCore.Design/TextTemplating/ITextTemplating.cs new file mode 100644 index 00000000000..c0e7a822cd2 --- /dev/null +++ b/src/EFCore.Design/TextTemplating/ITextTemplating.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.VisualStudio.TextTemplating; + +namespace Microsoft.EntityFrameworkCore.TextTemplating; + +/// +/// The text template transformation service. +/// +public interface ITextTemplating : ITextTemplatingSessionHost +{ + /// + /// Transforms the contents of a text template file to produce the generated text output. + /// + /// The path of the template file. + /// The contents of the template file. + /// The callback used to process errors and information. + /// The output. + string ProcessTemplate(string inputFile, string content, ITextTemplatingCallback? callback = null); +} diff --git a/src/EFCore.Design/TextTemplating/ITextTemplatingCallback.cs b/src/EFCore.Design/TextTemplating/ITextTemplatingCallback.cs new file mode 100644 index 00000000000..b29afc78fb4 --- /dev/null +++ b/src/EFCore.Design/TextTemplating/ITextTemplatingCallback.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CodeDom.Compiler; +using System.Text; + +namespace Microsoft.EntityFrameworkCore.TextTemplating; + +/// +/// Callback interface to be implemented by clients of that wish to process errors and information. +/// +public interface ITextTemplatingCallback +{ + /// + /// Receives errors and warnings. + /// + /// An error or warning. + void ErrorCallback(CompilerError error); + + /// + /// Receives the file name extension that is expected for the generated text output. + /// + /// The extension. + void SetFileExtension(string extension); + + /// + /// Receives the encoding that is expected for the generated text output. + /// + /// The encoding. + /// A value indicating whether the encoding was specified in the encoding parameter of the output directive. + void SetOutputEncoding(Encoding encoding, bool fromOutputDirective); +} diff --git a/src/EFCore.Design/TextTemplating/Internal/TextTemplatingCallback.cs b/src/EFCore.Design/TextTemplating/Internal/TextTemplatingCallback.cs new file mode 100644 index 00000000000..6899abcbe26 --- /dev/null +++ b/src/EFCore.Design/TextTemplating/Internal/TextTemplatingCallback.cs @@ -0,0 +1,65 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CodeDom.Compiler; +using System.Text; + +namespace Microsoft.EntityFrameworkCore.TextTemplating.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 TextTemplatingCallback : ITextTemplatingCallback +{ + private CompilerErrorCollection? _errors; + private string _extension = ".cs"; + private Encoding _outputEncoding = Encoding.UTF8; + private bool _fromOutputDirective; + + /// + /// 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 Extension + => _extension; + + /// + /// 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 CompilerErrorCollection Errors + => _errors ??= new CompilerErrorCollection(); + + /// + /// 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 Encoding OutputEncoding + => _outputEncoding; + + void ITextTemplatingCallback.ErrorCallback(CompilerError error) + => Errors.Add(error); + + void ITextTemplatingCallback.SetFileExtension(string extension) + => _extension = extension; + + void ITextTemplatingCallback.SetOutputEncoding(Encoding? encoding, bool fromOutputDirective) + { + if (_fromOutputDirective) + { + return; + } + + _outputEncoding = encoding ?? Encoding.UTF8; + _fromOutputDirective = fromOutputDirective; + } +} diff --git a/src/EFCore.Design/TextTemplating/Internal/TextTemplatingService.cs b/src/EFCore.Design/TextTemplating/Internal/TextTemplatingService.cs new file mode 100644 index 00000000000..b52984b3ab4 --- /dev/null +++ b/src/EFCore.Design/TextTemplating/Internal/TextTemplatingService.cs @@ -0,0 +1,241 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CodeDom.Compiler; +using System.Text; +using Microsoft.EntityFrameworkCore.Internal; +using Microsoft.VisualStudio.TextTemplating; +using Engine = Mono.TextTemplating.TemplatingEngine; + +namespace Microsoft.EntityFrameworkCore.TextTemplating.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 TextTemplatingService : ITextTemplating, ITextTemplatingEngineHost, IServiceProvider +{ + private readonly IServiceProvider _serviceProvider; + private ITextTemplatingCallback? _callback; + private string? _templateFile; + + /// + /// 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 TextTemplatingService(IServiceProvider serviceProvider) + => _serviceProvider = serviceProvider; + + /// + /// 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 ITextTemplatingSession? Session { get; set; } + + /// + /// 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 IList StandardAssemblyReferences { get; } = new string[] + { + typeof(ITextTemplatingEngineHost).Assembly.Location, + typeof(CompilerErrorCollection).Assembly.Location + }; + + /// + /// 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 IList StandardImports { get; } = new[] + { + "System" + }; + + /// + /// 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? TemplateFile + => _templateFile; + + /// + /// 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 ProcessTemplate(string inputFile, string content, ITextTemplatingCallback? callback = null) + { + _templateFile = inputFile; + _callback = callback; + + var sessionCreated = false; + if (Session == null) + { + Session = CreateSession(); + sessionCreated = true; + } + + try + { + return new Engine().ProcessTemplate(content, this); + } + finally + { + _templateFile = null; + _callback = null; + + if (sessionCreated) + { + Session = 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 virtual ITextTemplatingSession CreateSession() + => new TextTemplatingSession(); + + /// + /// 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 object? GetHostOption(string optionName) + => 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 virtual bool LoadIncludeText(string requestFileName, out string content, out string location) + { + // TODO: Expand variables? + location = ResolvePath(requestFileName); + var exists = File.Exists(location); + content = exists + ? File.ReadAllText(location) + : string.Empty; + + return exists; + } + + /// + /// 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 LogErrors(CompilerErrorCollection errors) + { + foreach (CompilerError error in errors) + { + _callback?.ErrorCallback(error); + } + } + + /// + /// 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 AppDomain ProvideTemplatingAppDomain(string content) + => AppDomain.CurrentDomain; + + /// + /// 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 ResolveAssemblyReference(string assemblyReference) + { + try + { + return Assembly.Load(assemblyReference).Location; + } + catch + { + } + + // TODO: Expand variables? + return assemblyReference; + } + + /// + /// 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 Type ResolveDirectiveProcessor(string processorName) + => throw new FileNotFoundException(DesignStrings.UnknownDirectiveProcessor(processorName)); + + /// + /// 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 ResolveParameterValue(string directiveId, string processorName, string parameterName) + => string.Empty; + + /// + /// 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 ResolvePath(string path) + => !Path.IsPathRooted(path) + ? Path.Combine(Path.GetDirectoryName(TemplateFile)!, path) + : path; + + /// + /// 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 SetFileExtension(string extension) + => _callback?.SetFileExtension(extension); + + /// + /// 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 SetOutputEncoding(Encoding encoding, bool fromOutputDirective) + => _callback?.SetOutputEncoding(encoding, fromOutputDirective); + + /// + /// 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 object? GetService(Type serviceType) + => _serviceProvider.GetService(serviceType); +} diff --git a/test/EFCore.Design.Tests/Design/DesignTimeServicesTest.cs b/test/EFCore.Design.Tests/Design/DesignTimeServicesTest.cs index ac5b86ad43a..7c871ad77d0 100644 --- a/test/EFCore.Design.Tests/Design/DesignTimeServicesTest.cs +++ b/test/EFCore.Design.Tests/Design/DesignTimeServicesTest.cs @@ -8,6 +8,8 @@ using Microsoft.EntityFrameworkCore.Scaffolding.Metadata; using Microsoft.EntityFrameworkCore.SqlServer.Design.Internal; using Microsoft.EntityFrameworkCore.SqlServer.Scaffolding.Internal; +using Microsoft.EntityFrameworkCore.TextTemplating; +using Microsoft.EntityFrameworkCore.TextTemplating.Internal; using Microsoft.Extensions.DependencyInjection.Extensions; namespace Microsoft.EntityFrameworkCore.Design; @@ -94,7 +96,11 @@ public class UserMigrationsIdGenerator : IMigrationsIdGenerator Assert.Equal(typeof(CSharpMigrationsGenerator), serviceProvider.GetRequiredService().GetType()); Assert.Equal( typeof(MigrationsCodeGeneratorSelector), serviceProvider.GetRequiredService().GetType()); - Assert.Equal(typeof(CSharpModelGenerator), serviceProvider.GetRequiredService().GetType()); + Assert.Equal(typeof(TextTemplatingService), serviceProvider.GetRequiredService().GetType()); + Assert.Collection( + serviceProvider.GetServices(), + s => Assert.Equal(typeof(TextTemplatingModelGenerator), s.GetType()), + s => Assert.Equal(typeof(CSharpModelGenerator), s.GetType())); Assert.Equal(typeof(ModelCodeGeneratorSelector), serviceProvider.GetRequiredService().GetType()); Assert.Equal( typeof(CSharpRuntimeModelCodeGenerator), serviceProvider.GetRequiredService().GetType()); diff --git a/test/EFCore.Design.Tests/Scaffolding/Internal/CSharpDbContextGeneratorTest.cs b/test/EFCore.Design.Tests/Scaffolding/Internal/CSharpDbContextGeneratorTest.cs index a785ec29210..3b8eaf0a1ba 100644 --- a/test/EFCore.Design.Tests/Scaffolding/Internal/CSharpDbContextGeneratorTest.cs +++ b/test/EFCore.Design.Tests/Scaffolding/Internal/CSharpDbContextGeneratorTest.cs @@ -191,7 +191,8 @@ public void Required_options_to_GenerateModel_are_not_null() var generator = CreateServices() .AddSingleton() .BuildServiceProvider(validateScopes: true) - .GetRequiredService(); + .GetServices() + .Last(g => g is CSharpModelGenerator); Assert.StartsWith( CoreStrings.ArgumentPropertyNull(nameof(ModelCodeGenerationOptions.ContextName), "options"), @@ -217,7 +218,8 @@ public void Plugins_work() var generator = CreateServices() .AddSingleton() .BuildServiceProvider(validateScopes: true) - .GetRequiredService(); + .GetServices() + .Last(g => g is CSharpModelGenerator); var scaffoldedModel = generator.GenerateModel( new Model(), diff --git a/test/EFCore.Design.Tests/Scaffolding/Internal/CSharpModelGeneratorTest.cs b/test/EFCore.Design.Tests/Scaffolding/Internal/CSharpModelGeneratorTest.cs index 7da73280ac2..9023d6205a3 100644 --- a/test/EFCore.Design.Tests/Scaffolding/Internal/CSharpModelGeneratorTest.cs +++ b/test/EFCore.Design.Tests/Scaffolding/Internal/CSharpModelGeneratorTest.cs @@ -53,6 +53,7 @@ private static IModelCodeGenerator CreateGenerator() .AddSingleton() .AddSingleton() .BuildServiceProvider(validateScopes: true) - .GetRequiredService(); + .GetServices() + .Last(g => g is CSharpModelGenerator); } } diff --git a/test/EFCore.Design.Tests/Scaffolding/Internal/ModelCodeGeneratorSelectorTest.cs b/test/EFCore.Design.Tests/Scaffolding/Internal/ModelCodeGeneratorSelectorTest.cs new file mode 100644 index 00000000000..e05d4609c8d --- /dev/null +++ b/test/EFCore.Design.Tests/Scaffolding/Internal/ModelCodeGeneratorSelectorTest.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. + +using Microsoft.EntityFrameworkCore.Internal; + +namespace Microsoft.EntityFrameworkCore.Scaffolding.Internal; + +public class ModelCodeGeneratorSelectorTest +{ + [ConditionalFact] + public void Select_returns_last_service_for_language() + { + var expected = new TestModelCodeGenerator("C#"); + var selector = new ModelCodeGeneratorSelector( + new[] + { + new TestModelCodeGenerator("C#"), + expected + }); + + var result = selector.Select( + new ModelCodeGenerationOptions + { + Language = "C#" + }); + + Assert.Same(expected, result); + } + + [ConditionalFact] + public void Select_throws_when_no_service_for_language() + { + var selector = new ModelCodeGeneratorSelector( + new[] + { + new TestModelCodeGenerator("C#") + }); + var options = new ModelCodeGenerationOptions + { + Language = "VB" + }; + + var ex = Assert.Throws( + () => selector.Select(options)); + + Assert.Equal(DesignStrings.NoLanguageService("VB", nameof(IModelCodeGenerator)), ex.Message); + } + + [ConditionalFact] + public void Select_returns_last_templated_service_with_templates() + { + var expected = new TestTemplatedModelGenerator(hasTemplates: true); + var selector = new ModelCodeGeneratorSelector( + new IModelCodeGenerator[] + { + new TestTemplatedModelGenerator(hasTemplates: true), + expected, + new TestTemplatedModelGenerator(hasTemplates: false), + new TestModelCodeGenerator("C#") + }); + + var result = selector.Select( + new ModelCodeGenerationOptions + { + Language = "C#", + ProjectDir = Directory.GetCurrentDirectory() + }); + + Assert.Same(expected, result); + } + + [ConditionalFact] + public void Select_returns_last_service_for_language_when_no_templates() + { + var expected = new TestModelCodeGenerator("C#"); + var selector = new ModelCodeGeneratorSelector( + new IModelCodeGenerator[] + { + new TestTemplatedModelGenerator(hasTemplates: false), + new TestModelCodeGenerator("C#"), + expected + }); + + var result = selector.Select( + new ModelCodeGenerationOptions + { + Language = "C#" + }); + + Assert.Same(expected, result); + } + + private class TestModelCodeGenerator : ModelCodeGenerator + { + public TestModelCodeGenerator(string language) + : base(new()) + => Language = language; + + public override string Language { get; } + + public override ScaffoldedModel GenerateModel(IModel model, ModelCodeGenerationOptions options) + => throw new NotImplementedException(); + } + + private class TestTemplatedModelGenerator : TemplatedModelGenerator + { + private readonly bool _hasTemplates; + + public TestTemplatedModelGenerator(bool hasTemplates) + : base(new()) + => _hasTemplates = hasTemplates; + + public override ScaffoldedModel GenerateModel(IModel model, ModelCodeGenerationOptions options) + => throw new NotImplementedException(); + + public override bool HasTemplates(string projectDir) + => _hasTemplates; + } +} diff --git a/test/EFCore.Design.Tests/Scaffolding/Internal/ModelCodeGeneratorTestBase.cs b/test/EFCore.Design.Tests/Scaffolding/Internal/ModelCodeGeneratorTestBase.cs index a4f7385ad32..00fec7696b0 100644 --- a/test/EFCore.Design.Tests/Scaffolding/Internal/ModelCodeGeneratorTestBase.cs +++ b/test/EFCore.Design.Tests/Scaffolding/Internal/ModelCodeGeneratorTestBase.cs @@ -28,7 +28,8 @@ protected void Test( AddScaffoldingServices(services); var generator = services.BuildServiceProvider(validateScopes: true) - .GetRequiredService(); + .GetServices() + .Last(g => g is CSharpModelGenerator); options.ModelNamespace ??= "TestNamespace"; options.ContextName = "TestDbContext"; diff --git a/test/EFCore.Design.Tests/Scaffolding/Internal/TextTemplatingModelGeneratorTest.cs b/test/EFCore.Design.Tests/Scaffolding/Internal/TextTemplatingModelGeneratorTest.cs new file mode 100644 index 00000000000..c359d40c655 --- /dev/null +++ b/test/EFCore.Design.Tests/Scaffolding/Internal/TextTemplatingModelGeneratorTest.cs @@ -0,0 +1,316 @@ +// 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.Design.Internal; +using Microsoft.EntityFrameworkCore.Internal; +using Microsoft.EntityFrameworkCore.SqlServer.Design.Internal; +using Microsoft.EntityFrameworkCore.TestUtilities.Xunit; + +namespace Microsoft.EntityFrameworkCore.Scaffolding.Internal; + +[PlatformSkipCondition(TestPlatform.Linux, SkipReason = "CI time out")] +public class TextTemplatingModelGeneratorTest +{ + [ConditionalFact] + public void HasTemplates_works_when_templates() + { + using var projectDir = new TempDirectory(); + + var template = Path.Combine(projectDir, "Templates", "EFCore", "DbContext.t4"); + Directory.CreateDirectory(Path.GetDirectoryName(template)); + File.Create(template).Close(); + + var generator = CreateGenerator(); + + var result = generator.HasTemplates(projectDir); + + Assert.True(result); + } + + [ConditionalFact] + public void HasTemplates_works_when_no_templates() + { + using var projectDir = new TempDirectory(); + + var generator = CreateGenerator(); + + var result = generator.HasTemplates(projectDir); + + Assert.False(result); + } + + [ConditionalFact] + public void GenerateModel_uses_templates() + { + using var projectDir = new TempDirectory(); + + var contextTemplate = Path.Combine(projectDir, "Templates", "EFCore", "DbContext.t4"); + Directory.CreateDirectory(Path.GetDirectoryName(contextTemplate)); + File.WriteAllText( + contextTemplate, + "My DbContext template"); + + File.WriteAllText( + Path.Combine(projectDir, "Templates", "EFCore", "EntityType.t4"), + "My entity type template"); + + var generator = CreateGenerator(); + var model = new ModelBuilder() + .Entity("Entity1", b => { }) + .FinalizeModel(); + + var result = generator.GenerateModel( + model, + new() + { + ContextName = "Context", + ConnectionString = @"Name=DefaultConnection", + ProjectDir = projectDir + }); + + Assert.Equal("Context.cs", result.ContextFile.Path); + Assert.Equal("My DbContext template", result.ContextFile.Code); + + var entityType = Assert.Single(result.AdditionalFiles); + Assert.Equal("Entity1.cs", entityType.Path); + Assert.Equal("My entity type template", entityType.Code); + } + + [ConditionalFact] + public void GenerateModel_works_when_no_entity_type_template() + { + using var projectDir = new TempDirectory(); + + var contextTemplate = Path.Combine(projectDir, "Templates", "EFCore", "DbContext.t4"); + Directory.CreateDirectory(Path.GetDirectoryName(contextTemplate)); + File.WriteAllText( + contextTemplate, + "My DbContext template"); + + var generator = CreateGenerator(); + var model = new ModelBuilder() + .Entity("Entity1", b => { }) + .FinalizeModel(); + + var result = generator.GenerateModel( + model, + new() + { + ContextName = "Context", + ConnectionString = @"Name=DefaultConnection", + ProjectDir = projectDir + }); + + Assert.Equal("Context.cs", result.ContextFile.Path); + Assert.Equal("My DbContext template", result.ContextFile.Code); + + Assert.Empty(result.AdditionalFiles); + } + + [ConditionalFact] + public void GenerateModel_sets_session_variables() + { + using var projectDir = new TempDirectory(); + + var contextTemplate = Path.Combine(projectDir, "Templates", "EFCore", "DbContext.t4"); + Directory.CreateDirectory(Path.GetDirectoryName(contextTemplate)); + File.WriteAllText( + contextTemplate, + @"Model not null: <#= Session[""Model""] != null #> +Options not null: <#= Session[""Options""] != null #> +NamespaceHint: <#= Session[""NamespaceHint""] #> +ProjectDefaultNamespace: <#= Session[""ProjectDefaultNamespace""] #>"); + + File.WriteAllText( + Path.Combine(projectDir, "Templates", "EFCore", "EntityType.t4"), + @"EntityType not null: <#= Session[""EntityType""] != null #> +Options not null: <#= Session[""Options""] != null #> +NamespaceHint: <#= Session[""NamespaceHint""] #> +ProjectDefaultNamespace: <#= Session[""ProjectDefaultNamespace""] #>"); + + var generator = CreateGenerator(); + var model = new ModelBuilder() + .Entity("Entity1", b => { }) + .FinalizeModel(); + + var result = generator.GenerateModel( + model, + new() + { + ContextName = "Context", + ConnectionString = @"Name=DefaultConnection", + ContextNamespace = "ContextNamespace", + ModelNamespace = "ModelNamespace", + RootNamespace = "RootNamespace", + ProjectDir = projectDir + }); + + Assert.Equal( + @"Model not null: True +Options not null: True +NamespaceHint: ContextNamespace +ProjectDefaultNamespace: RootNamespace", + result.ContextFile.Code); + + var entityType = Assert.Single(result.AdditionalFiles); + Assert.Equal( + @"EntityType not null: True +Options not null: True +NamespaceHint: ModelNamespace +ProjectDefaultNamespace: RootNamespace", + entityType.Code); + } + + [ConditionalFact] + public void GenerateModel_defaults_to_model_namespace_when_no_context_namespace() + { + using var projectDir = new TempDirectory(); + + var contextTemplate = Path.Combine(projectDir, "Templates", "EFCore", "DbContext.t4"); + Directory.CreateDirectory(Path.GetDirectoryName(contextTemplate)); + File.WriteAllText( + contextTemplate, + @"<#= Session[""NamespaceHint""] #>"); + + var generator = CreateGenerator(); + var model = new ModelBuilder() + .FinalizeModel(); + + var result = generator.GenerateModel( + model, + new() + { + ContextName = "Context", + ConnectionString = @"Name=DefaultConnection", + ModelNamespace = "ModelNamespace", + ProjectDir = projectDir + }); + + Assert.Equal( + "ModelNamespace", + result.ContextFile.Code); + } + + [ConditionalFact] + public void GenerateModel_uses_output_extension() + { + using var projectDir = new TempDirectory(); + + var contextTemplate = Path.Combine(projectDir, "Templates", "EFCore", "DbContext.t4"); + Directory.CreateDirectory(Path.GetDirectoryName(contextTemplate)); + File.WriteAllText( + contextTemplate, + @"<#@ output extension="".vb"" #>"); + + File.WriteAllText( + Path.Combine(projectDir, "Templates", "EFCore", "EntityType.t4"), + @"<#@ output extension="".fs"" #> +My entity type template"); + + var generator = CreateGenerator(); + var model = new ModelBuilder() + .Entity("Entity1", b => { }) + .FinalizeModel(); + + var result = generator.GenerateModel( + model, + new() + { + ContextName = "Context", + ConnectionString = @"Name=DefaultConnection", + ProjectDir = projectDir + }); + + Assert.Equal("Context.vb", result.ContextFile.Path); + + var entityType = Assert.Single(result.AdditionalFiles); + Assert.Equal("Entity1.fs", entityType.Path); + } + + [ConditionalFact] + public void GenerateModel_warns_when_output_encoding() + { + using var projectDir = new TempDirectory(); + + var contextTemplate = Path.Combine(projectDir, "Templates", "EFCore", "DbContext.t4"); + Directory.CreateDirectory(Path.GetDirectoryName(contextTemplate)); + File.WriteAllText( + contextTemplate, + @"<#@ output encoding=""us-ascii"" #>"); + + var reporter = new TestOperationReporter(); + var generator = CreateGenerator(reporter); + var model = new ModelBuilder() + .FinalizeModel(); + + var result = generator.GenerateModel( + model, + new() + { + ContextName = "Context", + ConnectionString = @"Name=DefaultConnection", + ProjectDir = projectDir + }); + + Assert.Collection( + reporter.Messages, + x => + { + Assert.Equal(LogLevel.Warning, x.Level); + Assert.Equal(DesignStrings.EncodingIgnored("us-ascii"), x.Message); + }); + } + + [ConditionalFact] + public void GenerateModel_reports_errors() + { + using var projectDir = new TempDirectory(); + + var contextTemplate = Path.Combine(projectDir, "Templates", "EFCore", "DbContext.t4"); + Directory.CreateDirectory(Path.GetDirectoryName(contextTemplate)); + File.WriteAllText( + contextTemplate, + @"<# Warning(""This is a warning""); +Error(""This is an error""); #>"); + + var reporter = new TestOperationReporter(); + var generator = CreateGenerator(reporter); + var model = new ModelBuilder() + .FinalizeModel(); + + var result = generator.GenerateModel( + model, + new() + { + ContextName = "Context", + ConnectionString = @"Name=DefaultConnection", + ProjectDir = projectDir + }); + + Assert.Collection( + reporter.Messages, + x => + { + Assert.Equal(LogLevel.Warning, x.Level); + Assert.Contains("This is a warning", x.Message); + }, + x => + { + Assert.Equal(LogLevel.Error, x.Level); + Assert.Contains("This is an error", x.Message); + }); + } + + private static TemplatedModelGenerator CreateGenerator(IOperationReporter reporter = null) + { + var serviceCollection = new ServiceCollection() + .AddEntityFrameworkDesignTimeServices(reporter); + new SqlServerDesignTimeServices().ConfigureDesignTimeServices(serviceCollection); + + return serviceCollection + .BuildServiceProvider() + .GetServices() + .OfType() + .Last(); + } +} diff --git a/test/EFCore.Design.Tests/TestUtilities/TempDirectory.cs b/test/EFCore.Design.Tests/TestUtilities/TempDirectory.cs index 06cf980780e..c38c5b2c951 100644 --- a/test/EFCore.Design.Tests/TestUtilities/TempDirectory.cs +++ b/test/EFCore.Design.Tests/TestUtilities/TempDirectory.cs @@ -15,6 +15,9 @@ public TempDirectory() public string Path { get; } + public static implicit operator string(TempDirectory dir) + => dir.Path; + public void Dispose() => Directory.Delete(Path, recursive: true); } diff --git a/test/EFCore.Design.Tests/TextTemplating/Internal/TextTemplatingServiceTest.cs b/test/EFCore.Design.Tests/TextTemplating/Internal/TextTemplatingServiceTest.cs new file mode 100644 index 00000000000..64159cb9898 --- /dev/null +++ b/test/EFCore.Design.Tests/TextTemplating/Internal/TextTemplatingServiceTest.cs @@ -0,0 +1,183 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CodeDom.Compiler; +using Microsoft.EntityFrameworkCore.Internal; +using Microsoft.EntityFrameworkCore.TestUtilities.Xunit; +using Microsoft.VisualStudio.TextTemplating; + +namespace Microsoft.EntityFrameworkCore.TextTemplating.Internal; + +[PlatformSkipCondition(TestPlatform.Linux, SkipReason = "CI time out")] +public class TextTemplatingServiceTest +{ + [ConditionalFact] + public void Service_works() + { + var host = new TextTemplatingService( + new ServiceCollection() + .AddSingleton("Hello, Services!") + .BuildServiceProvider()); + var callback = new TextTemplatingCallback(); + + var result = host.ProcessTemplate( + @"T:\test.tt", + @"<#@ template hostSpecific=""true"" #><#= ((IServiceProvider)Host).GetService(typeof(string)) #>", + callback); + + Assert.Empty(callback.Errors); + Assert.Equal("Hello, Services!", result); + } + + [ConditionalFact] + public void Session_works() + { + var host = new TextTemplatingService( + new ServiceCollection() + .BuildServiceProvider()); + host.Session = new TextTemplatingSession + { + ["Value"] = "Hello, Session!" + }; + var callback = new TextTemplatingCallback(); + + var result = host.ProcessTemplate( + @"T:\test.tt", + @"<#= Session[""Value""] #>", + callback); + + Assert.Empty(callback.Errors); + Assert.Equal("Hello, Session!", result); + } + + [ConditionalFact] + public void Session_works_with_parameter() + { + var host = new TextTemplatingService( + new ServiceCollection() + .BuildServiceProvider()); + host.Session = new TextTemplatingSession + { + ["Value"] = "Hello, Session!" + }; + var callback = new TextTemplatingCallback(); + + var result = host.ProcessTemplate( + @"T:\test.tt", + @"<#@ parameter name=""Value"" type=""System.String"" #><#= Value #>", + callback); + + Assert.Empty(callback.Errors); + Assert.Equal("Hello, Session!", result); + } + + [ConditionalFact] + public void Include_works() + { + using var dir = new TempDirectory(); + File.WriteAllText( + Path.Combine(dir, "test.ttinclude"), + "Hello, Include!"); + + var host = new TextTemplatingService( + new ServiceCollection() + .BuildServiceProvider()); + var callback = new TextTemplatingCallback(); + + var result = host.ProcessTemplate( + Path.Combine(dir, "test.tt"), + @"<#@ include file=""test.ttinclude"" #>", + callback); + + Assert.Empty(callback.Errors); + Assert.Equal("Hello, Include!", result); + } + + [ConditionalFact] + public void Error_works() + { + var host = new TextTemplatingService( + new ServiceCollection() + .BuildServiceProvider()); + var callback = new TextTemplatingCallback(); + + host.ProcessTemplate( + @"T:\test.tt", + @"<# Error(""Hello, Error!""); #>", + callback); + + var error = Assert.Single(callback.Errors.Cast()); + Assert.Equal("Hello, Error!", error.ErrorText); + } + + [ConditionalFact] + public void Directive_throws_when_processor_unknown() + { + var host = new TextTemplatingService( + new ServiceCollection() + .BuildServiceProvider()); + var callback = new TextTemplatingCallback(); + + var ex = Assert.Throws( + () => host.ProcessTemplate( + @"T:\test.tt", + @"<#@ test processor=""TestDirectiveProcessor"" #>", + callback)); + + Assert.Equal(DesignStrings.UnknownDirectiveProcessor("TestDirectiveProcessor"), ex.Message); + } + + [ConditionalFact] + public void ResolvePath_work() + { + using var dir = new TempDirectory(); + + var host = new TextTemplatingService( + new ServiceCollection() + .BuildServiceProvider()); + var callback = new TextTemplatingCallback(); + + var result = host.ProcessTemplate( + Path.Combine(dir, "test.tt"), + @"<#@ template hostSpecific=""true"" #><#= Host.ResolvePath(""data.json"") #>", + callback); + + Assert.Empty(callback.Errors); + Assert.Equal(Path.Combine(dir, "data.json"), result); + } + + [ConditionalFact] + public void Output_works() + { + var host = new TextTemplatingService( + new ServiceCollection() + .BuildServiceProvider()); + var callback = new TextTemplatingCallback(); + + host.ProcessTemplate( + @"T:\test.tt", + @"<#@ output extension="".txt"" encoding=""us-ascii"" #>", + callback); + + Assert.Empty(callback.Errors); + Assert.Equal(".txt", callback.Extension); + Assert.Equal(Encoding.ASCII, callback.OutputEncoding); + } + + [ConditionalFact] + public void Assembly_works() + { + var host = new TextTemplatingService( + new ServiceCollection() + .BuildServiceProvider()); + var callback = new TextTemplatingCallback(); + + var result = host.ProcessTemplate( + @"T:\test.tt", + @"<#@ assembly name=""Microsoft.EntityFrameworkCore"" #><#= nameof(Microsoft.EntityFrameworkCore.DbContext) #>", + callback); + + Assert.Empty(callback.Errors); + Assert.Equal("DbContext", result); + } +} diff --git a/test/EFCore.Relational.Specification.Tests/TestUtilities/BuildReference.cs b/test/EFCore.Relational.Specification.Tests/TestUtilities/BuildReference.cs index 41437f641f3..e56c51a4b99 100644 --- a/test/EFCore.Relational.Specification.Tests/TestUtilities/BuildReference.cs +++ b/test/EFCore.Relational.Specification.Tests/TestUtilities/BuildReference.cs @@ -24,6 +24,7 @@ private BuildReference(IEnumerable references, bool copyLocal public static BuildReference ByName(string name, bool copyLocal = false) { var references = (from l in DependencyContext.Default.CompileLibraries + where l.Assemblies.Any(a => IOPath.GetFileNameWithoutExtension(a) == name) from r in l.ResolveReferencePaths() where IOPath.GetFileNameWithoutExtension(r) == name select MetadataReference.CreateFromFile(r)).ToList(); From e1a3ee7d8e66daa2080ae5ecedd94d27926e75fd Mon Sep 17 00:00:00 2001 From: Andriy Svyryd Date: Wed, 9 Mar 2022 13:51:18 -0800 Subject: [PATCH 10/11] Store name information on TableBuilder --- .../RelationalEntityTypeBuilderExtensions.cs | 18 +++--- .../Builders/OwnedNavigationTableBuilder.cs | 14 ++++- .../Builders/OwnedNavigationTableBuilder`.cs | 4 +- .../Metadata/Builders/TableBuilder.cs | 14 ++++- .../Metadata/Builders/TableBuilder`.cs | 2 +- .../OwnedNavigationTemporalTableBuilder.cs | 6 +- .../OwnedNavigationTemporalTableBuilder`.cs | 14 ++++- .../Metadata/Builders/TemporalTableBuilder.cs | 6 +- .../Builders/TemporalTableBuilder`.cs | 14 ++++- .../Migrations/ModelSnapshotSqlServerTest.cs | 4 +- .../RelationalModelBuilderTest.cs | 58 +++++++++++++++---- .../RelationalTestModelBuilderExtensions.cs | 40 +++++++++++++ .../SqlServerApiConsistencyTest.cs | 9 ++- .../SqlServerModelBuilderGenericTest.cs | 32 +++++++--- .../SqlServerTestModelBuilderExtensions.cs | 19 ------ 15 files changed, 187 insertions(+), 67 deletions(-) diff --git a/src/EFCore.Relational/Extensions/RelationalEntityTypeBuilderExtensions.cs b/src/EFCore.Relational/Extensions/RelationalEntityTypeBuilderExtensions.cs index 3998a996b4a..98000b2490b 100644 --- a/src/EFCore.Relational/Extensions/RelationalEntityTypeBuilderExtensions.cs +++ b/src/EFCore.Relational/Extensions/RelationalEntityTypeBuilderExtensions.cs @@ -51,7 +51,7 @@ public static EntityTypeBuilder ToTable( { Check.NotNull(buildAction, nameof(buildAction)); - buildAction(new TableBuilder(entityTypeBuilder)); + buildAction(new TableBuilder(null, null, entityTypeBuilder)); return entityTypeBuilder; } @@ -76,7 +76,7 @@ public static EntityTypeBuilder ToTable( entityTypeBuilder.Metadata.SetTableName(name); entityTypeBuilder.Metadata.SetSchema(null); - buildAction(new TableBuilder(entityTypeBuilder)); + buildAction(new TableBuilder(name, null, entityTypeBuilder)); return entityTypeBuilder; } @@ -192,7 +192,7 @@ public static EntityTypeBuilder ToTable( entityTypeBuilder.Metadata.SetTableName(name); entityTypeBuilder.Metadata.SetSchema(schema); - buildAction(new TableBuilder(entityTypeBuilder)); + buildAction(new TableBuilder(name, schema, entityTypeBuilder)); return entityTypeBuilder; } @@ -281,7 +281,7 @@ public static OwnedNavigationBuilder ToTable( { Check.NotNull(buildAction, nameof(buildAction)); - buildAction(new OwnedNavigationTableBuilder(referenceOwnershipBuilder)); + buildAction(new OwnedNavigationTableBuilder(null, null, referenceOwnershipBuilder)); return referenceOwnershipBuilder; } @@ -303,7 +303,7 @@ public static OwnedNavigationBuilder ToTable(referenceOwnershipBuilder)); + buildAction(new OwnedNavigationTableBuilder(null, null, referenceOwnershipBuilder)); return referenceOwnershipBuilder; } @@ -344,7 +344,7 @@ public static OwnedNavigationBuilder ToTable( referenceOwnershipBuilder.OwnedEntityType.SetTableName(name); referenceOwnershipBuilder.OwnedEntityType.SetSchema(null); - buildAction(new OwnedNavigationTableBuilder(referenceOwnershipBuilder)); + buildAction(new OwnedNavigationTableBuilder(name, null, referenceOwnershipBuilder)); return referenceOwnershipBuilder; } @@ -371,7 +371,7 @@ public static OwnedNavigationBuilder ToTable(referenceOwnershipBuilder)); + buildAction(new OwnedNavigationTableBuilder(name, null, referenceOwnershipBuilder)); return referenceOwnershipBuilder; } @@ -423,7 +423,7 @@ public static OwnedNavigationBuilder ToTable( referenceOwnershipBuilder.OwnedEntityType.SetTableName(name); referenceOwnershipBuilder.OwnedEntityType.SetSchema(schema); - buildAction(new OwnedNavigationTableBuilder(referenceOwnershipBuilder)); + buildAction(new OwnedNavigationTableBuilder(name, schema, referenceOwnershipBuilder)); return referenceOwnershipBuilder; } @@ -472,7 +472,7 @@ public static OwnedNavigationBuilder ToTable(referenceOwnershipBuilder)); + buildAction(new OwnedNavigationTableBuilder(name, schema, referenceOwnershipBuilder)); return referenceOwnershipBuilder; } diff --git a/src/EFCore.Relational/Metadata/Builders/OwnedNavigationTableBuilder.cs b/src/EFCore.Relational/Metadata/Builders/OwnedNavigationTableBuilder.cs index 0c36a863602..f8c5214cbe4 100644 --- a/src/EFCore.Relational/Metadata/Builders/OwnedNavigationTableBuilder.cs +++ b/src/EFCore.Relational/Metadata/Builders/OwnedNavigationTableBuilder.cs @@ -18,11 +18,23 @@ public class OwnedNavigationTableBuilder /// doing so can result in application failures when updating to a new Entity Framework Core release. /// [EntityFrameworkInternal] - public OwnedNavigationTableBuilder(OwnedNavigationBuilder ownedNavigationBuilder) + public OwnedNavigationTableBuilder(string? name, string? schema, OwnedNavigationBuilder ownedNavigationBuilder) { + Name = name; + Schema = schema; OwnedNavigationBuilder = ownedNavigationBuilder; } + /// + /// The specified table name. + /// + public virtual string? Name { get; } + + /// + /// The specified table schema. + /// + public virtual string? Schema { get; } + /// /// The entity type being configured. /// diff --git a/src/EFCore.Relational/Metadata/Builders/OwnedNavigationTableBuilder`.cs b/src/EFCore.Relational/Metadata/Builders/OwnedNavigationTableBuilder`.cs index ff74ddd1933..af93328a20f 100644 --- a/src/EFCore.Relational/Metadata/Builders/OwnedNavigationTableBuilder`.cs +++ b/src/EFCore.Relational/Metadata/Builders/OwnedNavigationTableBuilder`.cs @@ -18,8 +18,8 @@ public class OwnedNavigationTableBuilder : OwnedNavigationTableBuilder /// doing so can result in application failures when updating to a new Entity Framework Core release. /// [EntityFrameworkInternal] - public OwnedNavigationTableBuilder(OwnedNavigationBuilder referenceOwnershipBuilder) - : base(referenceOwnershipBuilder) + public OwnedNavigationTableBuilder(string? name, string? schema, OwnedNavigationBuilder referenceOwnershipBuilder) + : base(name, schema, referenceOwnershipBuilder) { } diff --git a/src/EFCore.Relational/Metadata/Builders/TableBuilder.cs b/src/EFCore.Relational/Metadata/Builders/TableBuilder.cs index cd913ad8774..8a74222b55d 100644 --- a/src/EFCore.Relational/Metadata/Builders/TableBuilder.cs +++ b/src/EFCore.Relational/Metadata/Builders/TableBuilder.cs @@ -18,11 +18,23 @@ public class TableBuilder /// doing so can result in application failures when updating to a new Entity Framework Core release. /// [EntityFrameworkInternal] - public TableBuilder(EntityTypeBuilder entityTypeBuilder) + public TableBuilder(string? name, string? schema, EntityTypeBuilder entityTypeBuilder) { + Name = name; + Schema = schema; EntityTypeBuilder = entityTypeBuilder; } + /// + /// The specified table name. + /// + public virtual string? Name { get; } + + /// + /// The specified table schema. + /// + public virtual string? Schema { get; } + /// /// The entity type being configured. /// diff --git a/src/EFCore.Relational/Metadata/Builders/TableBuilder`.cs b/src/EFCore.Relational/Metadata/Builders/TableBuilder`.cs index 2a238815dd9..c02dd30e4f5 100644 --- a/src/EFCore.Relational/Metadata/Builders/TableBuilder`.cs +++ b/src/EFCore.Relational/Metadata/Builders/TableBuilder`.cs @@ -19,7 +19,7 @@ public class TableBuilder : TableBuilder /// [EntityFrameworkInternal] public TableBuilder(string? name, string? schema, EntityTypeBuilder entityTypeBuilder) - : base(entityTypeBuilder) + : base(name, schema, entityTypeBuilder) { } diff --git a/src/EFCore.SqlServer/Metadata/Builders/OwnedNavigationTemporalTableBuilder.cs b/src/EFCore.SqlServer/Metadata/Builders/OwnedNavigationTemporalTableBuilder.cs index 2824ca108ab..c067f3a93de 100644 --- a/src/EFCore.SqlServer/Metadata/Builders/OwnedNavigationTemporalTableBuilder.cs +++ b/src/EFCore.SqlServer/Metadata/Builders/OwnedNavigationTemporalTableBuilder.cs @@ -35,11 +35,7 @@ public OwnedNavigationTemporalTableBuilder(OwnedNavigationBuilder referenceOwner /// The name of the history table. /// The same builder instance so that multiple calls can be chained. public virtual OwnedNavigationTemporalTableBuilder UseHistoryTable(string name) - { - _referenceOwnershipBuilder.OwnedEntityType.SetHistoryTableName(name); - - return this; - } + => UseHistoryTable(name, null); /// /// Configures a history table for the entity mapped to a temporal table. diff --git a/src/EFCore.SqlServer/Metadata/Builders/OwnedNavigationTemporalTableBuilder`.cs b/src/EFCore.SqlServer/Metadata/Builders/OwnedNavigationTemporalTableBuilder`.cs index c5ae7530334..2eb7cc7a7f1 100644 --- a/src/EFCore.SqlServer/Metadata/Builders/OwnedNavigationTemporalTableBuilder`.cs +++ b/src/EFCore.SqlServer/Metadata/Builders/OwnedNavigationTemporalTableBuilder`.cs @@ -23,6 +23,18 @@ public OwnedNavigationTemporalTableBuilder(OwnedNavigationBuilder referenceOwner { } + /// + /// Configures a history table for the entity mapped to a temporal table. + /// + /// + /// See Using SQL Server temporal tables with EF Core + /// for more information. + /// + /// The name of the history table. + /// The same builder instance so that multiple calls can be chained. + public new virtual OwnedNavigationTemporalTableBuilder UseHistoryTable(string name) + => (OwnedNavigationTemporalTableBuilder)base.UseHistoryTable(name); + /// /// Configures a history table for the entity mapped to a temporal table. /// @@ -33,6 +45,6 @@ public OwnedNavigationTemporalTableBuilder(OwnedNavigationBuilder referenceOwner /// The name of the history table. /// The schema of the history table. /// The same builder instance so that multiple calls can be chained. - public new virtual OwnedNavigationTemporalTableBuilder UseHistoryTable(string name, string? schema = null) + public new virtual OwnedNavigationTemporalTableBuilder UseHistoryTable(string name, string? schema) => (OwnedNavigationTemporalTableBuilder)base.UseHistoryTable(name, schema); } diff --git a/src/EFCore.SqlServer/Metadata/Builders/TemporalTableBuilder.cs b/src/EFCore.SqlServer/Metadata/Builders/TemporalTableBuilder.cs index c7b9f353eff..a62bfc54250 100644 --- a/src/EFCore.SqlServer/Metadata/Builders/TemporalTableBuilder.cs +++ b/src/EFCore.SqlServer/Metadata/Builders/TemporalTableBuilder.cs @@ -35,11 +35,7 @@ public TemporalTableBuilder(EntityTypeBuilder entityTypeBuilder) /// The name of the history table. /// The same builder instance so that multiple calls can be chained. public virtual TemporalTableBuilder UseHistoryTable(string name) - { - _entityTypeBuilder.Metadata.SetHistoryTableName(name); - - return this; - } + => UseHistoryTable(name, null); /// /// Configures a history table for the entity mapped to a temporal table. diff --git a/src/EFCore.SqlServer/Metadata/Builders/TemporalTableBuilder`.cs b/src/EFCore.SqlServer/Metadata/Builders/TemporalTableBuilder`.cs index fd006adbc99..8afbd9aab12 100644 --- a/src/EFCore.SqlServer/Metadata/Builders/TemporalTableBuilder`.cs +++ b/src/EFCore.SqlServer/Metadata/Builders/TemporalTableBuilder`.cs @@ -23,6 +23,18 @@ public TemporalTableBuilder(EntityTypeBuilder entityTypeBuilder) { } + /// + /// Configures a history table for the entity mapped to a temporal table. + /// + /// + /// See Using SQL Server temporal tables with EF Core + /// for more information and examples. + /// + /// The name of the history table. + /// The same builder instance so that multiple calls can be chained. + public new virtual TemporalTableBuilder UseHistoryTable(string name) + => (TemporalTableBuilder)base.UseHistoryTable(name); + /// /// Configures a history table for the entity mapped to a temporal table. /// @@ -33,6 +45,6 @@ public TemporalTableBuilder(EntityTypeBuilder entityTypeBuilder) /// The name of the history table. /// The schema of the history table. /// The same builder instance so that multiple calls can be chained. - public new virtual TemporalTableBuilder UseHistoryTable(string name, string? schema = null) + public new virtual TemporalTableBuilder UseHistoryTable(string name, string? schema) => (TemporalTableBuilder)base.UseHistoryTable(name, schema); } diff --git a/test/EFCore.Design.Tests/Migrations/ModelSnapshotSqlServerTest.cs b/test/EFCore.Design.Tests/Migrations/ModelSnapshotSqlServerTest.cs index b27e404b29e..285844ce4c9 100644 --- a/test/EFCore.Design.Tests/Migrations/ModelSnapshotSqlServerTest.cs +++ b/test/EFCore.Design.Tests/Migrations/ModelSnapshotSqlServerTest.cs @@ -2085,7 +2085,7 @@ public virtual void Temporal_table_information_is_stored_in_snapshot() "Microsoft.EntityFrameworkCore.Migrations.ModelSnapshotSqlServerTest+EntityWithStringProperty"); var annotations = temporalEntity.GetAnnotations().ToList(); - Assert.Equal(6, annotations.Count); + Assert.Equal(7, annotations.Count); Assert.Contains(annotations, a => a.Name == SqlServerAnnotationNames.IsTemporal && a.Value as bool? == true); Assert.Contains( annotations, @@ -2147,7 +2147,7 @@ public virtual void Temporal_table_information_is_stored_in_snapshot_minimal_set "Microsoft.EntityFrameworkCore.Migrations.ModelSnapshotSqlServerTest+EntityWithStringProperty"); var annotations = temporalEntity.GetAnnotations().ToList(); - Assert.Equal(6, annotations.Count); + Assert.Equal(7, annotations.Count); Assert.Contains(annotations, a => a.Name == SqlServerAnnotationNames.IsTemporal && a.Value as bool? == true); Assert.Contains( annotations, diff --git a/test/EFCore.Relational.Tests/ModelBuilding/RelationalModelBuilderTest.cs b/test/EFCore.Relational.Tests/ModelBuilding/RelationalModelBuilderTest.cs index eec6cde04b5..6ff38786a8d 100644 --- a/test/EFCore.Relational.Tests/ModelBuilding/RelationalModelBuilderTest.cs +++ b/test/EFCore.Relational.Tests/ModelBuilding/RelationalModelBuilderTest.cs @@ -11,6 +11,10 @@ public class RelationalModelBuilderTest : ModelBuilderTest public abstract class TestTableBuilder where TEntity : class { + public abstract string? Name { get; } + + public abstract string? Schema { get; } + public abstract TestTableBuilder ExcludeFromMigrations(bool excluded = true); } @@ -22,9 +26,15 @@ public GenericTestTableBuilder(TableBuilder tableBuilder) TableBuilder = tableBuilder; } - protected TableBuilder TableBuilder { get; } + private TableBuilder TableBuilder { get; } + + public override string? Name + => TableBuilder.Name; + + public override string? Schema + => TableBuilder.Schema; - public TableBuilder Instance + TableBuilder IInfrastructure>.Instance => TableBuilder; protected virtual TestTableBuilder Wrap(TableBuilder tableBuilder) @@ -42,9 +52,15 @@ public NonGenericTestTableBuilder(TableBuilder tableBuilder) TableBuilder = tableBuilder; } - protected TableBuilder TableBuilder { get; } + private TableBuilder TableBuilder { get; } - public TableBuilder Instance + public override string? Name + => TableBuilder.Name; + + public override string? Schema + => TableBuilder.Schema; + + TableBuilder IInfrastructure.Instance => TableBuilder; protected virtual TestTableBuilder Wrap(TableBuilder tableBuilder) @@ -57,10 +73,16 @@ public override TestTableBuilder ExcludeFromMigrations(bool excluded = public abstract class TestOwnedNavigationTableBuilder where TEntity : class { + public abstract string? Name { get; } + + public abstract string? Schema { get; } + public abstract TestOwnedNavigationTableBuilder ExcludeFromMigrations(bool excluded = true); } - public class GenericTestOwnedNavigationTableBuilder : TestOwnedNavigationTableBuilder, IInfrastructure> + public class GenericTestOwnedNavigationTableBuilder : + TestOwnedNavigationTableBuilder, + IInfrastructure> where TEntity : class { public GenericTestOwnedNavigationTableBuilder(OwnedNavigationTableBuilder tableBuilder) @@ -68,9 +90,16 @@ public GenericTestOwnedNavigationTableBuilder(OwnedNavigationTableBuilder TableBuilder { get; } + private OwnedNavigationTableBuilder TableBuilder { get; } + + public override string? Name + => TableBuilder.Name; - public OwnedNavigationTableBuilder Instance => TableBuilder; + public override string? Schema + => TableBuilder.Schema; + + OwnedNavigationTableBuilder IInfrastructure>.Instance + => TableBuilder; protected virtual TestOwnedNavigationTableBuilder Wrap(OwnedNavigationTableBuilder tableBuilder) => new GenericTestOwnedNavigationTableBuilder(tableBuilder); @@ -87,9 +116,16 @@ public NonGenericTestOwnedNavigationTableBuilder(OwnedNavigationTableBuilder tab TableBuilder = tableBuilder; } - protected OwnedNavigationTableBuilder TableBuilder { get; } + private OwnedNavigationTableBuilder TableBuilder { get; } - public OwnedNavigationTableBuilder Instance => TableBuilder; + public override string? Name + => TableBuilder.Name; + + public override string? Schema + => TableBuilder.Schema; + + OwnedNavigationTableBuilder IInfrastructure.Instance + => TableBuilder; protected virtual TestOwnedNavigationTableBuilder Wrap(OwnedNavigationTableBuilder tableBuilder) => new NonGenericTestOwnedNavigationTableBuilder(tableBuilder); @@ -110,9 +146,9 @@ public NonGenericTestCheckConstraintBuilder(CheckConstraintBuilder checkConstrai CheckConstraintBuilder = checkConstraintBuilder; } - protected CheckConstraintBuilder CheckConstraintBuilder { get; } + private CheckConstraintBuilder CheckConstraintBuilder { get; } - public CheckConstraintBuilder Instance + CheckConstraintBuilder IInfrastructure.Instance => CheckConstraintBuilder; protected virtual TestCheckConstraintBuilder Wrap(CheckConstraintBuilder checkConstraintBuilder) diff --git a/test/EFCore.Relational.Tests/ModelBuilding/RelationalTestModelBuilderExtensions.cs b/test/EFCore.Relational.Tests/ModelBuilding/RelationalTestModelBuilderExtensions.cs index 4c7b694d633..51ce5387750 100644 --- a/test/EFCore.Relational.Tests/ModelBuilding/RelationalTestModelBuilderExtensions.cs +++ b/test/EFCore.Relational.Tests/ModelBuilding/RelationalTestModelBuilderExtensions.cs @@ -146,6 +146,25 @@ public static ModelBuilderTest.TestEntityTypeBuilder ToTable( return builder; } + public static ModelBuilderTest.TestEntityTypeBuilder ToTable( + this ModelBuilderTest.TestEntityTypeBuilder builder, + Action> buildAction) + where TEntity : class + { + switch (builder) + { + case IInfrastructure> genericBuilder: + genericBuilder.Instance.ToTable(b => buildAction(new RelationalModelBuilderTest.GenericTestTableBuilder(b))); + break; + case IInfrastructure nongenericBuilder: + nongenericBuilder.Instance.ToTable( + b => buildAction(new RelationalModelBuilderTest.NonGenericTestTableBuilder(b))); + break; + } + + return builder; + } + public static ModelBuilderTest.TestEntityTypeBuilder ToTable( this ModelBuilderTest.TestEntityTypeBuilder builder, string? name, @@ -232,6 +251,27 @@ public static ModelBuilderTest.TestOwnedNavigationBuilder ToTable( + this ModelBuilderTest.TestOwnedNavigationBuilder builder, + Action> buildAction) + where TOwnerEntity : class + where TRelatedEntity : class + { + switch (builder) + { + case IInfrastructure> genericBuilder: + genericBuilder.Instance.ToTable( + b => buildAction(new RelationalModelBuilderTest.GenericTestOwnedNavigationTableBuilder(b))); + break; + case IInfrastructure nongenericBuilder: + nongenericBuilder.Instance.ToTable( + b => buildAction(new RelationalModelBuilderTest.NonGenericTestOwnedNavigationTableBuilder(b))); + break; + } + + return builder; + } + public static ModelBuilderTest.TestOwnedNavigationBuilder ToTable( this ModelBuilderTest.TestOwnedNavigationBuilder builder, string? name, diff --git a/test/EFCore.SqlServer.FunctionalTests/SqlServerApiConsistencyTest.cs b/test/EFCore.SqlServer.FunctionalTests/SqlServerApiConsistencyTest.cs index b345098aae3..a176589a48d 100644 --- a/test/EFCore.SqlServer.FunctionalTests/SqlServerApiConsistencyTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/SqlServerApiConsistencyTest.cs @@ -30,7 +30,14 @@ public class SqlServerApiConsistencyFixture : ApiConsistencyFixtureBase typeof(SqlServerModelBuilderExtensions), typeof(SqlServerPropertyBuilderExtensions), typeof(SqlServerEntityTypeBuilderExtensions), - typeof(SqlServerServiceCollectionExtensions) + typeof(SqlServerServiceCollectionExtensions), + typeof(SqlServerDbFunctionsExtensions), + typeof(OwnedNavigationTemporalPeriodPropertyBuilder), + typeof(TemporalPeriodPropertyBuilder), + typeof(OwnedNavigationTemporalTableBuilder), + typeof(OwnedNavigationTemporalTableBuilder<>), + typeof(TemporalTableBuilder), + typeof(TemporalTableBuilder<>) }; public override diff --git a/test/EFCore.SqlServer.Tests/ModelBuilding/SqlServerModelBuilderGenericTest.cs b/test/EFCore.SqlServer.Tests/ModelBuilding/SqlServerModelBuilderGenericTest.cs index 666b2d1f06e..d0fe859cf83 100644 --- a/test/EFCore.SqlServer.Tests/ModelBuilding/SqlServerModelBuilderGenericTest.cs +++ b/test/EFCore.SqlServer.Tests/ModelBuilding/SqlServerModelBuilderGenericTest.cs @@ -639,7 +639,13 @@ public virtual void Owned_types_can_be_mapped_to_different_tables() modelBuilder.Entity( bb => { - bb.ToTable("BT", "BS", t => t.ExcludeFromMigrations()); + bb.ToTable("BT", "BS", t => + { + t.ExcludeFromMigrations(); + + Assert.Equal("BT", t.Name); + Assert.Equal("BS", t.Schema); + }); bb.OwnsOne( b => b.AlternateLabel, tb => { @@ -652,7 +658,13 @@ public virtual void Owned_types_can_be_mapped_to_different_tables() l => l.AnotherBookLabel, ab => { ab.Ignore(l => l.Book); - ab.ToTable("AT1", "AS1", t => t.ExcludeFromMigrations(false)); + ab.ToTable("AT1", "AS1", t => + { + t.ExcludeFromMigrations(false); + + Assert.Equal("AT1", t.Name); + Assert.Equal("AS1", t.Schema); + }); ab.OwnsOne(s => s.SpecialBookLabel) .ToTable("ST11", "SS11") .Ignore(l => l.Book) @@ -934,7 +946,12 @@ public virtual void Temporal_table_default_settings() var modelBuilder = CreateModelBuilder(); var model = modelBuilder.Model; - modelBuilder.Entity().ToTable(tb => tb.IsTemporal()); + modelBuilder.Entity().ToTable(tb => + { + tb.IsTemporal(); + Assert.Null(tb.Name); + Assert.Null(tb.Schema); + }); modelBuilder.FinalizeModel(); var entity = model.FindEntityType(typeof(Customer)); @@ -1251,7 +1268,6 @@ public abstract class TestTemporalTableBuilder where TEntity : class { public abstract TestTemporalTableBuilder UseHistoryTable(string name, string schema); - public abstract TestTemporalPeriodPropertyBuilder HasPeriodStart(string propertyName); public abstract TestTemporalPeriodPropertyBuilder HasPeriodEnd(string propertyName); } @@ -1265,9 +1281,9 @@ public GenericTestTemporalTableBuilder(TemporalTableBuilder temporalTab TemporalTableBuilder = temporalTableBuilder; } - protected TemporalTableBuilder TemporalTableBuilder { get; } + private TemporalTableBuilder TemporalTableBuilder { get; } - public TemporalTableBuilder Instance + TemporalTableBuilder IInfrastructure>.Instance => TemporalTableBuilder; protected virtual TestTemporalTableBuilder Wrap(TemporalTableBuilder tableBuilder) @@ -1291,9 +1307,9 @@ public NonGenericTestTemporalTableBuilder(TemporalTableBuilder temporalTableBuil TemporalTableBuilder = temporalTableBuilder; } - protected TemporalTableBuilder TemporalTableBuilder { get; } + private TemporalTableBuilder TemporalTableBuilder { get; } - public TemporalTableBuilder Instance + TemporalTableBuilder IInfrastructure.Instance => TemporalTableBuilder; protected virtual TestTemporalTableBuilder Wrap(TemporalTableBuilder temporalTableBuilder) diff --git a/test/EFCore.SqlServer.Tests/ModelBuilding/SqlServerTestModelBuilderExtensions.cs b/test/EFCore.SqlServer.Tests/ModelBuilding/SqlServerTestModelBuilderExtensions.cs index c0102a8eb0e..030428c099a 100644 --- a/test/EFCore.SqlServer.Tests/ModelBuilding/SqlServerTestModelBuilderExtensions.cs +++ b/test/EFCore.SqlServer.Tests/ModelBuilding/SqlServerTestModelBuilderExtensions.cs @@ -44,25 +44,6 @@ public static ModelBuilderTest.TestIndexBuilder IsClustered( return builder; } - public static ModelBuilderTest.TestEntityTypeBuilder ToTable( - this ModelBuilderTest.TestEntityTypeBuilder builder, - Action> buildAction) - where TEntity : class - { - switch (builder) - { - case IInfrastructure> genericBuilder: - genericBuilder.Instance.ToTable(b => buildAction(new RelationalModelBuilderTest.GenericTestTableBuilder(b))); - break; - case IInfrastructure nongenericBuilder: - nongenericBuilder.Instance.ToTable( - b => buildAction(new RelationalModelBuilderTest.NonGenericTestTableBuilder(b))); - break; - } - - return builder; - } - public static RelationalModelBuilderTest.TestTableBuilder IsTemporal( this RelationalModelBuilderTest.TestTableBuilder builder, bool temporal = true) From a5bc598977d41095fa37895e85ea626217fa9eb4 Mon Sep 17 00:00:00 2001 From: Maurycy Markowski Date: Mon, 7 Mar 2022 15:39:59 -0800 Subject: [PATCH 11/11] Fix to #27423 - Renaming and dropping columns with Temporal Tables generates faulty migration Problem is that when we drop column from the history table we need to disable versioning (and drop columns in both tables separately). However, since we have disabled the period, renaming (and other operations) also needs to happen for both tables now and we don't do it. So the column is only renamed for the temporal table and the name in history table stays the same. When we try to switch versioning back on the exception is thrown. Fix is to flow the information to ColumnOperations that the column is part of a temporal table and when we process the migrations, if the versioning for the temporal table has been disabled, mirror the necessary operation(s) to the history table also. Fixes #27423 --- .../Internal/SqlServerAnnotationProvider.cs | 13 +- .../SqlServerMigrationsAnnotationProvider.cs | 31 + .../SqlServerMigrationsSqlGenerator.cs | 119 ++- .../Migrations/MigrationsSqlServerTest.cs | 759 ++++++++++++++++++ 4 files changed, 905 insertions(+), 17 deletions(-) diff --git a/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs b/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs index 7734540838a..e8c2670cd64 100644 --- a/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs +++ b/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs @@ -264,13 +264,12 @@ public override IEnumerable For(IColumn column, bool designTime) ? periodEndProperty.GetColumnName(storeObjectIdentifier) : periodEndPropertyName; - if (column.Name == periodStartColumnName - || column.Name == periodEndColumnName) - { - yield return new Annotation(SqlServerAnnotationNames.IsTemporal, true); - yield return new Annotation(SqlServerAnnotationNames.TemporalPeriodStartColumnName, periodStartColumnName); - yield return new Annotation(SqlServerAnnotationNames.TemporalPeriodEndColumnName, periodEndColumnName); - } + // TODO: issue #27459 - we want to avoid having those annotations on every column + yield return new Annotation(SqlServerAnnotationNames.IsTemporal, true); + yield return new Annotation(SqlServerAnnotationNames.TemporalHistoryTableName, entityType.GetHistoryTableName()); + yield return new Annotation(SqlServerAnnotationNames.TemporalHistoryTableSchema, entityType.GetHistoryTableSchema()); + yield return new Annotation(SqlServerAnnotationNames.TemporalPeriodStartColumnName, periodStartColumnName); + yield return new Annotation(SqlServerAnnotationNames.TemporalPeriodEndColumnName, periodEndColumnName); } } } diff --git a/src/EFCore.SqlServer/Migrations/Internal/SqlServerMigrationsAnnotationProvider.cs b/src/EFCore.SqlServer/Migrations/Internal/SqlServerMigrationsAnnotationProvider.cs index 9ef5261bad3..f94295e8c5d 100644 --- a/src/EFCore.SqlServer/Migrations/Internal/SqlServerMigrationsAnnotationProvider.cs +++ b/src/EFCore.SqlServer/Migrations/Internal/SqlServerMigrationsAnnotationProvider.cs @@ -104,4 +104,35 @@ public override IEnumerable ForRename(ITable table) table[SqlServerAnnotationNames.TemporalHistoryTableSchema]); } } + + /// + public override IEnumerable ForRename(IColumn column) + { + if (column.Table[SqlServerAnnotationNames.IsTemporal] as bool? == true) + { + yield return new Annotation(SqlServerAnnotationNames.IsTemporal, true); + + yield return new Annotation( + SqlServerAnnotationNames.TemporalHistoryTableName, + column.Table[SqlServerAnnotationNames.TemporalHistoryTableName]); + + yield return new Annotation( + SqlServerAnnotationNames.TemporalHistoryTableSchema, + column.Table[SqlServerAnnotationNames.TemporalHistoryTableSchema]); + + if (column[SqlServerAnnotationNames.TemporalPeriodStartColumnName] is string periodStartColumnName) + { + yield return new Annotation( + SqlServerAnnotationNames.TemporalPeriodStartColumnName, + periodStartColumnName); + } + + if (column[SqlServerAnnotationNames.TemporalPeriodEndColumnName] is string periodEndColumnName) + { + yield return new Annotation( + SqlServerAnnotationNames.TemporalPeriodEndColumnName, + periodEndColumnName); + } + } + } } diff --git a/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs b/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs index 1645b116e79..c51474cd220 100644 --- a/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs +++ b/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs @@ -2355,21 +2355,47 @@ alterTableOperation.OldTable[SqlServerAnnotationNames.TemporalHistoryTableSchema // if only difference is in temporal annotations being removed or history table changed etc - we can ignore this operation if (!CanSkipAlterColumnOperation(alterColumnOperation.OldColumn, alterColumnOperation)) { + operations.Add(operation); + // when modifying a period column, we need to perform the operations as a normal column first, and only later enable period // removing the period information now, so that when we generate SQL that modifies the column we won't be making them auto generated as period // (making column auto generated is not allowed in ALTER COLUMN statement) // in later operation we enable the period and the period columns get set to auto generated automatically - if (alterColumnOperation[SqlServerAnnotationNames.IsTemporal] as bool? == true - && alterColumnOperation.OldColumn[SqlServerAnnotationNames.IsTemporal] is null) + // + // if the column is not period we just remove temporal information - it's no longer needed and could affect the generated sql + // we will generate all the necessary operations involved with temporal tables here + alterColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.IsTemporal); + alterColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.TemporalPeriodStartColumnName); + alterColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.TemporalPeriodEndColumnName); + alterColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.TemporalHistoryTableName); + alterColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.TemporalHistoryTableSchema); + + // this is the case where we are not converting from normal table to temporal + // just a normal modification to a column on a temporal table + // in that case we need to double check if we need have disabled versioning earlier in this migration + // if so, we need to mirror the operation to the history table + if (alterColumnOperation.OldColumn[SqlServerAnnotationNames.IsTemporal] as bool? == true) { - alterColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.IsTemporal); - alterColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.TemporalPeriodStartColumnName); - alterColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.TemporalPeriodEndColumnName); + alterColumnOperation.OldColumn.RemoveAnnotation(SqlServerAnnotationNames.IsTemporal); + alterColumnOperation.OldColumn.RemoveAnnotation(SqlServerAnnotationNames.TemporalPeriodStartColumnName); + alterColumnOperation.OldColumn.RemoveAnnotation(SqlServerAnnotationNames.TemporalPeriodEndColumnName); + alterColumnOperation.OldColumn.RemoveAnnotation(SqlServerAnnotationNames.TemporalHistoryTableName); + alterColumnOperation.OldColumn.RemoveAnnotation(SqlServerAnnotationNames.TemporalHistoryTableSchema); + + if (versioningMap.ContainsKey((table, schema))) + { + var alterHistoryTableColumn = CopyColumnOperation(alterColumnOperation); + alterHistoryTableColumn.Table = historyTableName!; + alterHistoryTableColumn.Schema = historyTableSchema; + alterHistoryTableColumn.OldColumn = CopyColumnOperation(alterColumnOperation.OldColumn); + alterHistoryTableColumn.OldColumn.Table = historyTableName!; + alterHistoryTableColumn.OldColumn.Schema = historyTableSchema; + + operations.Add(alterHistoryTableColumn); + } // TODO: test what happens if default value just changes (from temporal to temporal) } - - operations.Add(operation); } break; @@ -2396,6 +2422,8 @@ alterTableOperation.OldTable[SqlServerAnnotationNames.TemporalHistoryTableSchema break; case AddColumnOperation addColumnOperation: + operations.Add(addColumnOperation); + // when adding a period column, we need to add it as a normal column first, and only later enable period // removing the period information now, so that when we generate SQL that adds the column we won't be making them auto generated as period // it won't work, unless period is enabled @@ -2403,6 +2431,8 @@ alterTableOperation.OldTable[SqlServerAnnotationNames.TemporalHistoryTableSchema if (addColumnOperation[SqlServerAnnotationNames.IsTemporal] as bool? == true) { addColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.IsTemporal); + addColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.TemporalHistoryTableName); + addColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.TemporalHistoryTableSchema); addColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.TemporalPeriodStartColumnName); addColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.TemporalPeriodEndColumnName); @@ -2411,14 +2441,48 @@ alterTableOperation.OldTable[SqlServerAnnotationNames.TemporalHistoryTableSchema { addColumnOperation.DefaultValue = DateTime.MaxValue; } + + // when adding (non-period) column to an exisiting temporal table we need to check if we have disabled the period + // due to some other operations in the same migration (e.g. delete column) + // if so, we need to also add the same column to history table + if (addColumnOperation.Name != periodStartColumnName + && addColumnOperation.Name != periodEndColumnName) + { + if (versioningMap.ContainsKey((table, schema))) + { + var addHistoryTableColumnOperation = CopyColumnOperation(addColumnOperation); + addHistoryTableColumnOperation.Table = historyTableName!; + addHistoryTableColumnOperation.Schema = historyTableSchema; + + operations.Add(addHistoryTableColumnOperation); + } + } + } + + break; + + case RenameColumnOperation renameColumnOperation: + operations.Add(renameColumnOperation); + + // if we disabled period for the temporal table and now we are renaming the column, + // we need to also rename this same column in history table + if (versioningMap.ContainsKey((table, schema))) + { + var renameHistoryTableColumnOperation = new RenameColumnOperation + { + IsDestructiveChange = renameColumnOperation.IsDestructiveChange, + Name = renameColumnOperation.Name, + NewName = renameColumnOperation.NewName, + Table = historyTableName!, + Schema = historyTableSchema + }; + + operations.Add(renameHistoryTableColumnOperation); } - operations.Add(addColumnOperation); break; default: - // CreateTableOperation - // RenameColumnOperation operations.Add(operation); break; } @@ -2607,6 +2671,7 @@ static bool CanSkipAlterColumnOperation(ColumnOperation first, ColumnOperation s && ColumnOperationsOnlyDifferByTemporalTableAnnotation(first, second) && ColumnOperationsOnlyDifferByTemporalTableAnnotation(second, first); + // don't compare name, table or schema - they are not being set in the model differ (since they should always be the same) static bool ColumnPropertiesAreTheSame(ColumnOperation first, ColumnOperation second) => first.ClrType == second.ClrType && first.Collation == second.Collation @@ -2651,5 +2716,39 @@ static bool ColumnOperationsOnlyDifferByTemporalTableAnnotation(ColumnOperation || a.Name == SqlServerAnnotationNames.TemporalPeriodStartColumnName || a.Name == SqlServerAnnotationNames.TemporalPeriodEndColumnName); } + + static TOperation CopyColumnOperation(ColumnOperation source) + where TOperation : ColumnOperation, new() + { + var result = new TOperation + { + ClrType = source.ClrType, + Collation = source.Collation, + ColumnType = source.ColumnType, + Comment = source.Comment, + ComputedColumnSql = source.ComputedColumnSql, + DefaultValue = source.DefaultValue, + DefaultValueSql = source.DefaultValueSql, + IsDestructiveChange = source.IsDestructiveChange, + IsFixedLength = source.IsFixedLength, + IsNullable = source.IsNullable, + IsRowVersion = source.IsRowVersion, + IsStored = source.IsStored, + IsUnicode = source.IsUnicode, + MaxLength = source.MaxLength, + Name = source.Name, + Precision = source.Precision, + Scale = source.Scale, + Table = source.Table, + Schema = source.Schema + }; + + foreach (var annotation in source.GetAnnotations()) + { + result.AddAnnotation(annotation.Name, annotation.Value); + } + + return result; + } } } diff --git a/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs index 36394b3efb6..dada1fa78a0 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs @@ -3229,6 +3229,88 @@ await Test( EXEC(N'ALTER TABLE [RenamedCustomers] SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + '].[HistoryTable]))')"); } + [ConditionalFact] + public virtual async Task Rename_temporal_table_rename_and_modify_column_in_same_migration() + { + await Test( + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + e.Property("Discount"); + e.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + builder => builder.Entity( + "Customer", e => + { + e.Property("DoB"); + e.ToTable("Customers"); + }), + builder => builder.Entity( + "Customer", e => + { + e.Property("Discount").HasComment("for VIP only"); + e.Property("DateOfBirth"); + e.ToTable("RenamedCustomers"); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("RenamedCustomers", table.Name); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("Start", table[SqlServerAnnotationNames.TemporalPeriodStartPropertyName]); + Assert.Equal("End", table[SqlServerAnnotationNames.TemporalPeriodEndPropertyName]); + Assert.Equal("HistoryTable", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("Discount", c.Name), + c => Assert.Equal("DateOfBirth", c.Name), + c => Assert.Equal("Name", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"ALTER TABLE [Customers] SET (SYSTEM_VERSIONING = OFF)", + // + @"ALTER TABLE [Customers] DROP CONSTRAINT [PK_Customers];", + // + @"EXEC sp_rename N'[Customers]', N'RenamedCustomers';", + // + @"EXEC sp_rename N'[RenamedCustomers].[DoB]', N'DateOfBirth', N'COLUMN';", + // + @"EXEC sp_rename N'[HistoryTable].[DoB]', N'DateOfBirth', N'COLUMN';", + // + @"DECLARE @defaultSchema AS sysname; +SET @defaultSchema = SCHEMA_NAME(); +DECLARE @description AS sql_variant; +SET @description = N'for VIP only'; +EXEC sp_addextendedproperty 'MS_Description', @description, 'SCHEMA', @defaultSchema, 'TABLE', N'RenamedCustomers', 'COLUMN', N'Discount';", + // + @"DECLARE @defaultSchema AS sysname; +SET @defaultSchema = SCHEMA_NAME(); +DECLARE @description AS sql_variant; +SET @description = N'for VIP only'; +EXEC sp_addextendedproperty 'MS_Description', @description, 'SCHEMA', @defaultSchema, 'TABLE', N'HistoryTable', 'COLUMN', N'Discount';", + // + @"ALTER TABLE [RenamedCustomers] ADD CONSTRAINT [PK_RenamedCustomers] PRIMARY KEY ([Id]);", + // + @"DECLARE @historyTableSchema sysname = SCHEMA_NAME() +EXEC(N'ALTER TABLE [RenamedCustomers] SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + '].[HistoryTable]))')"); + } + [ConditionalFact] public virtual async Task Rename_temporal_table_with_custom_history_table_schema() { @@ -5296,6 +5378,104 @@ await Test( @"EXEC sp_rename N'[Customer].[End]', N'ModifiedEnd', N'COLUMN';"); } + [ConditionalFact] + public virtual async Task Alter_period_column_of_temporal_table() + { + await Test( + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + e.Property("Name"); + e.ToTable("Customers", tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + builder => { }, + builder => builder.Entity("Customer").Property("End").HasComment("My comment").ValueGeneratedOnAddOrUpdate(), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customers", table.Name); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("Start", table[SqlServerAnnotationNames.TemporalPeriodStartPropertyName]); + Assert.Equal("End", table[SqlServerAnnotationNames.TemporalPeriodEndPropertyName]); + Assert.Equal("HistoryTable", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("Name", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"DECLARE @defaultSchema AS sysname; +SET @defaultSchema = SCHEMA_NAME(); +DECLARE @description AS sql_variant; +SET @description = N'My comment'; +EXEC sp_addextendedproperty 'MS_Description', @description, 'SCHEMA', @defaultSchema, 'TABLE', N'Customers', 'COLUMN', N'End';"); + } + + [ConditionalFact] + public virtual async Task Rename_regular_columns_of_temporal_table() + { + await Test( + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + + e.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + builder => builder.Entity( + "Customer", e => + { + e.Property("Name"); + }), + builder => builder.Entity( + "Customer", e => + { + e.Property("FullName"); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customer", table.Name); + Assert.NotNull(table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("HistoryTable", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + Assert.Equal("Start", table[SqlServerAnnotationNames.TemporalPeriodStartPropertyName]); + Assert.Equal("End", table[SqlServerAnnotationNames.TemporalPeriodEndPropertyName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("FullName", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"EXEC sp_rename N'[Customer].[Name]', N'FullName', N'COLUMN';"); + } + [ConditionalFact] public virtual async Task Create_temporal_table_with_comments() { @@ -6008,6 +6188,585 @@ await Test( @"ALTER TABLE [myModifiedDefaultSchema].[Customers] SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [myModifiedDefaultSchema].[CustomersHistory]))"); } + [ConditionalFact] + public virtual async Task Temporal_table_rename_and_delete_columns_in_one_migration() + { + await Test( + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + + e.ToTable("Customers", tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + builder => builder.Entity( + "Customer", e => + { + e.Property("Name"); + e.Property("Number"); + e.Property("Dob"); + }), + builder => builder.Entity( + "Customer", e => + { + e.Property("FullName"); + e.Property("DateOfBirth"); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customers", table.Name); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("Start", table[SqlServerAnnotationNames.TemporalPeriodStartPropertyName]); + Assert.Equal("End", table[SqlServerAnnotationNames.TemporalPeriodEndPropertyName]); + Assert.Equal("HistoryTable", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("DateOfBirth", c.Name), + c => Assert.Equal("FullName", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"ALTER TABLE [Customers] SET (SYSTEM_VERSIONING = OFF)", + // + @"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'[Customers]') AND [c].[name] = N'Number'); +IF @var0 IS NOT NULL EXEC(N'ALTER TABLE [Customers] DROP CONSTRAINT [' + @var0 + '];'); +ALTER TABLE [Customers] DROP COLUMN [Number];", + // + @"DECLARE @var1 sysname; +SELECT @var1 = [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'[HistoryTable]') AND [c].[name] = N'Number'); +IF @var1 IS NOT NULL EXEC(N'ALTER TABLE [HistoryTable] DROP CONSTRAINT [' + @var1 + '];'); +ALTER TABLE [HistoryTable] DROP COLUMN [Number];", + // + @"EXEC sp_rename N'[Customers].[Name]', N'FullName', N'COLUMN';", + // + @"EXEC sp_rename N'[HistoryTable].[Name]', N'FullName', N'COLUMN';", + // + @"EXEC sp_rename N'[Customers].[Dob]', N'DateOfBirth', N'COLUMN';", + // + @"EXEC sp_rename N'[HistoryTable].[Dob]', N'DateOfBirth', N'COLUMN';", + // + @"DECLARE @historyTableSchema sysname = SCHEMA_NAME() +EXEC(N'ALTER TABLE [Customers] SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + '].[HistoryTable]))')"); + } + + [ConditionalFact] + public virtual async Task Temporal_table_rename_and_delete_columns_and_also_rename_table_in_one_migration() + { + await Test( + builder => builder.Entity( + "Customer", e => + { + e.Property("Name"); + e.Property("Number"); + + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + + e.ToTable("Customers", tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + + builder => builder.Entity( + "Customer", e => + { + e.Property("FullName"); + + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + + e.ToTable("ModifiedCustomers", tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("ModifiedCustomers", table.Name); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("Start", table[SqlServerAnnotationNames.TemporalPeriodStartPropertyName]); + Assert.Equal("End", table[SqlServerAnnotationNames.TemporalPeriodEndPropertyName]); + Assert.Equal("HistoryTable", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("FullName", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"ALTER TABLE [Customers] SET (SYSTEM_VERSIONING = OFF)", + // + @"ALTER TABLE [Customers] DROP CONSTRAINT [PK_Customers];", + // + @"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'[Customers]') AND [c].[name] = N'Number'); +IF @var0 IS NOT NULL EXEC(N'ALTER TABLE [Customers] DROP CONSTRAINT [' + @var0 + '];'); +ALTER TABLE [Customers] DROP COLUMN [Number];", + // + @"DECLARE @var1 sysname; +SELECT @var1 = [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'[HistoryTable]') AND [c].[name] = N'Number'); +IF @var1 IS NOT NULL EXEC(N'ALTER TABLE [HistoryTable] DROP CONSTRAINT [' + @var1 + '];'); +ALTER TABLE [HistoryTable] DROP COLUMN [Number];", + // + @"EXEC sp_rename N'[Customers]', N'ModifiedCustomers';", + // + @"EXEC sp_rename N'[ModifiedCustomers].[Name]', N'FullName', N'COLUMN';", + // + @"EXEC sp_rename N'[HistoryTable].[Name]', N'FullName', N'COLUMN';", + // + @"ALTER TABLE [ModifiedCustomers] ADD CONSTRAINT [PK_ModifiedCustomers] PRIMARY KEY ([Id]);", + // + @"DECLARE @historyTableSchema sysname = SCHEMA_NAME() +EXEC(N'ALTER TABLE [ModifiedCustomers] SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + '].[HistoryTable]))')"); + } + + [ConditionalFact] + public virtual async Task Temporal_table_rename_and_delete_columns_and_also_rename_history_table_in_one_migration() + { + await Test( + builder => builder.Entity( + "Customer", e => + { + e.Property("Name"); + e.Property("Number"); + + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + + e.ToTable("Customers", tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + + builder => builder.Entity( + "Customer", e => + { + e.Property("FullName"); + + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + + e.ToTable("Customers", tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("ModifiedHistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customers", table.Name); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("Start", table[SqlServerAnnotationNames.TemporalPeriodStartPropertyName]); + Assert.Equal("End", table[SqlServerAnnotationNames.TemporalPeriodEndPropertyName]); + Assert.Equal("ModifiedHistoryTable", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("FullName", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"ALTER TABLE [Customers] SET (SYSTEM_VERSIONING = OFF)", + // + @"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'[Customers]') AND [c].[name] = N'Number'); +IF @var0 IS NOT NULL EXEC(N'ALTER TABLE [Customers] DROP CONSTRAINT [' + @var0 + '];'); +ALTER TABLE [Customers] DROP COLUMN [Number];", + // + @"DECLARE @var1 sysname; +SELECT @var1 = [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'[HistoryTable]') AND [c].[name] = N'Number'); +IF @var1 IS NOT NULL EXEC(N'ALTER TABLE [HistoryTable] DROP CONSTRAINT [' + @var1 + '];'); +ALTER TABLE [HistoryTable] DROP COLUMN [Number];", + // + @"EXEC sp_rename N'[Customers].[Name]', N'FullName', N'COLUMN';", + // + @"EXEC sp_rename N'[HistoryTable].[Name]', N'FullName', N'COLUMN';", + // + @"EXEC sp_rename N'[HistoryTable]', N'ModifiedHistoryTable';", + // + @"DECLARE @historyTableSchema sysname = SCHEMA_NAME() +EXEC(N'ALTER TABLE [Customers] SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + '].[ModifiedHistoryTable]))')"); + } + + [ConditionalFact] + public virtual async Task Temporal_table_delete_column_and_add_another_column_in_one_migration() + { + await Test( + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + + e.ToTable("Customers", tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + builder => builder.Entity( + "Customer", e => + { + e.Property("Name"); + e.Property("Number"); + }), + builder => builder.Entity( + "Customer", e => + { + e.Property("Name"); + e.Property("DateOfBirth"); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customers", table.Name); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("Start", table[SqlServerAnnotationNames.TemporalPeriodStartPropertyName]); + Assert.Equal("End", table[SqlServerAnnotationNames.TemporalPeriodEndPropertyName]); + Assert.Equal("HistoryTable", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("Name", c.Name), + c => Assert.Equal("DateOfBirth", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"ALTER TABLE [Customers] SET (SYSTEM_VERSIONING = OFF)", + // + @"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'[Customers]') AND [c].[name] = N'Number'); +IF @var0 IS NOT NULL EXEC(N'ALTER TABLE [Customers] DROP CONSTRAINT [' + @var0 + '];'); +ALTER TABLE [Customers] DROP COLUMN [Number];", + // + @"DECLARE @var1 sysname; +SELECT @var1 = [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'[HistoryTable]') AND [c].[name] = N'Number'); +IF @var1 IS NOT NULL EXEC(N'ALTER TABLE [HistoryTable] DROP CONSTRAINT [' + @var1 + '];'); +ALTER TABLE [HistoryTable] DROP COLUMN [Number];", + // + @"ALTER TABLE [Customers] ADD [DateOfBirth] datetime2 NOT NULL DEFAULT '0001-01-01T00:00:00.0000000';", + // + @"ALTER TABLE [HistoryTable] ADD [DateOfBirth] datetime2 NOT NULL DEFAULT '0001-01-01T00:00:00.0000000';", + // + @"DECLARE @historyTableSchema sysname = SCHEMA_NAME() +EXEC(N'ALTER TABLE [Customers] SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + '].[HistoryTable]))')"); + } + + [ConditionalFact] + public virtual async Task Temporal_table_delete_column_and_alter_another_column_in_one_migration() + { + await Test( + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + + e.ToTable("Customers", tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + builder => builder.Entity( + "Customer", e => + { + e.Property("Name"); + e.Property("Number"); + e.Property("DateOfBirth"); + }), + builder => builder.Entity( + "Customer", e => + { + e.Property("Name").HasComment("My comment"); + e.Property("DateOfBirth"); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customers", table.Name); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("Start", table[SqlServerAnnotationNames.TemporalPeriodStartPropertyName]); + Assert.Equal("End", table[SqlServerAnnotationNames.TemporalPeriodEndPropertyName]); + Assert.Equal("HistoryTable", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("DateOfBirth", c.Name), + c => Assert.Equal("Name", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"ALTER TABLE [Customers] SET (SYSTEM_VERSIONING = OFF)", + // + @"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'[Customers]') AND [c].[name] = N'Number'); +IF @var0 IS NOT NULL EXEC(N'ALTER TABLE [Customers] DROP CONSTRAINT [' + @var0 + '];'); +ALTER TABLE [Customers] DROP COLUMN [Number];", + // + @"DECLARE @var1 sysname; +SELECT @var1 = [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'[HistoryTable]') AND [c].[name] = N'Number'); +IF @var1 IS NOT NULL EXEC(N'ALTER TABLE [HistoryTable] DROP CONSTRAINT [' + @var1 + '];'); +ALTER TABLE [HistoryTable] DROP COLUMN [Number];", + // + @"DECLARE @defaultSchema AS sysname; +SET @defaultSchema = SCHEMA_NAME(); +DECLARE @description AS sql_variant; +SET @description = N'My comment'; +EXEC sp_addextendedproperty 'MS_Description', @description, 'SCHEMA', @defaultSchema, 'TABLE', N'Customers', 'COLUMN', N'Name';", + // + @"DECLARE @defaultSchema AS sysname; +SET @defaultSchema = SCHEMA_NAME(); +DECLARE @description AS sql_variant; +SET @description = N'My comment'; +EXEC sp_addextendedproperty 'MS_Description', @description, 'SCHEMA', @defaultSchema, 'TABLE', N'HistoryTable', 'COLUMN', N'Name';", + // + @"DECLARE @historyTableSchema sysname = SCHEMA_NAME() +EXEC(N'ALTER TABLE [Customers] SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + '].[HistoryTable]))')"); + } + + [ConditionalFact] + public virtual async Task Temporal_table_rename_and_alter_period_column_in_one_migration() + { + await Test( + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + e.Property("Name"); + e.ToTable("Customers", tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").HasComment("My comment").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + e.Property("Name"); + e.ToTable("Customers", tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start").HasColumnName("ModifiedStart"); + ttb.HasPeriodEnd("End"); + })); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customers", table.Name); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("ModifiedStart", table[SqlServerAnnotationNames.TemporalPeriodStartPropertyName]); + Assert.Equal("End", table[SqlServerAnnotationNames.TemporalPeriodEndPropertyName]); + Assert.Equal("HistoryTable", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("Name", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"EXEC sp_rename N'[Customers].[Start]', N'ModifiedStart', N'COLUMN';", + // + @"DECLARE @defaultSchema AS sysname; +SET @defaultSchema = SCHEMA_NAME(); +DECLARE @description AS sql_variant; +SET @description = N'My comment'; +EXEC sp_addextendedproperty 'MS_Description', @description, 'SCHEMA', @defaultSchema, 'TABLE', N'Customers', 'COLUMN', N'End';"); + } + + [ConditionalFact] + public virtual async Task Temporal_table_delete_column_rename_and_alter_period_column_in_one_migration() + { + await Test( + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + e.Property("Name"); + e.Property("DateOfBirth"); + e.ToTable("Customers", tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").HasComment("My comment").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + e.Property("Name"); + e.ToTable("Customers", tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start").HasColumnName("ModifiedStart"); + ttb.HasPeriodEnd("End"); + })); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customers", table.Name); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("ModifiedStart", table[SqlServerAnnotationNames.TemporalPeriodStartPropertyName]); + Assert.Equal("End", table[SqlServerAnnotationNames.TemporalPeriodEndPropertyName]); + Assert.Equal("HistoryTable", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("Name", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"ALTER TABLE [Customers] SET (SYSTEM_VERSIONING = OFF)", + // + @"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'[Customers]') AND [c].[name] = N'DateOfBirth'); +IF @var0 IS NOT NULL EXEC(N'ALTER TABLE [Customers] DROP CONSTRAINT [' + @var0 + '];'); +ALTER TABLE [Customers] DROP COLUMN [DateOfBirth];", + // + @"DECLARE @var1 sysname; +SELECT @var1 = [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'[HistoryTable]') AND [c].[name] = N'DateOfBirth'); +IF @var1 IS NOT NULL EXEC(N'ALTER TABLE [HistoryTable] DROP CONSTRAINT [' + @var1 + '];'); +ALTER TABLE [HistoryTable] DROP COLUMN [DateOfBirth];", + // + @"EXEC sp_rename N'[Customers].[Start]', N'ModifiedStart', N'COLUMN';", + // + @"EXEC sp_rename N'[HistoryTable].[Start]', N'ModifiedStart', N'COLUMN';", + // + @"DECLARE @defaultSchema AS sysname; +SET @defaultSchema = SCHEMA_NAME(); +DECLARE @description AS sql_variant; +SET @description = N'My comment'; +EXEC sp_addextendedproperty 'MS_Description', @description, 'SCHEMA', @defaultSchema, 'TABLE', N'Customers', 'COLUMN', N'End';", + // + @"DECLARE @defaultSchema AS sysname; +SET @defaultSchema = SCHEMA_NAME(); +DECLARE @description AS sql_variant; +SET @description = N'My comment'; +EXEC sp_addextendedproperty 'MS_Description', @description, 'SCHEMA', @defaultSchema, 'TABLE', N'HistoryTable', 'COLUMN', N'End';", + // + @"DECLARE @historyTableSchema sysname = SCHEMA_NAME() +EXEC(N'ALTER TABLE [Customers] SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + '].[HistoryTable]))')"); + } + protected override string NonDefaultCollation => _nonDefaultCollation ??= GetDatabaseCollation() == "German_PhoneBook_CI_AS" ? "French_CI_AS"