diff --git a/src/EFCore.Design/Design/Internal/CSharpHelper.cs b/src/EFCore.Design/Design/Internal/CSharpHelper.cs index b2d58b724b6..d185ad07de6 100644 --- a/src/EFCore.Design/Design/Internal/CSharpHelper.cs +++ b/src/EFCore.Design/Design/Internal/CSharpHelper.cs @@ -1001,7 +1001,28 @@ public virtual string Fragment(MethodCallCodeFragment fragment) } private string Fragment(NestedClosureCodeFragment fragment) - => fragment.Parameter + " => " + fragment.Parameter + Fragment(fragment.MethodCall); + { + if (fragment.MethodCalls.Count == 1) + { + return fragment.Parameter + " => " + fragment.Parameter + Fragment(fragment.MethodCalls[0]); + } + + var builder = new IndentedStringBuilder(); + builder.AppendLine(fragment.Parameter + " =>"); + builder.AppendLine("{"); + using (builder.Indent()) + { + foreach (var methodCall in fragment.MethodCalls) + { + builder.Append(fragment.Parameter + Fragment(methodCall)); + builder.AppendLine(";"); + } + } + + builder.AppendLine("}"); + + return builder.ToString(); + } private static bool IsIdentifierStartCharacter(char ch) { diff --git a/src/EFCore.Relational/Extensions/RelationalEntityTypeBuilderExtensions.cs b/src/EFCore.Relational/Extensions/RelationalEntityTypeBuilderExtensions.cs index b730f74f701..4ac89b407cb 100644 --- a/src/EFCore.Relational/Extensions/RelationalEntityTypeBuilderExtensions.cs +++ b/src/EFCore.Relational/Extensions/RelationalEntityTypeBuilderExtensions.cs @@ -2,7 +2,6 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using Microsoft.EntityFrameworkCore.Metadata; @@ -29,6 +28,21 @@ public static class RelationalEntityTypeBuilderExtensions string? name) => entityTypeBuilder.ToTable(name, (string?)null); + /// + /// Configures the table that the entity type maps to when targeting a relational database. + /// + /// The builder for the entity type being configured. + /// An action that performs configuration of the table. + /// The same builder instance so that multiple calls can be chained. + public static EntityTypeBuilder ToTable( + this EntityTypeBuilder entityTypeBuilder, + Action buildAction) + { + buildAction(new TableBuilder(null, null, entityTypeBuilder)); + + return entityTypeBuilder; + } + /// /// Configures the table that the entity type maps to when targeting a relational database. /// @@ -55,6 +69,23 @@ public static class RelationalEntityTypeBuilderExtensions where TEntity : class => entityTypeBuilder.ToTable(name, (string?)null); + /// + /// Configures the table that the entity type maps to when targeting a relational database. + /// + /// The entity type being configured. + /// The builder for the entity type being configured. + /// An action that performs configuration of the table. + /// The same builder instance so that multiple calls can be chained. + public static EntityTypeBuilder ToTable( + this EntityTypeBuilder entityTypeBuilder, + Action> buildAction) + where TEntity : class + { + buildAction(new TableBuilder(null, null, entityTypeBuilder)); + + return entityTypeBuilder; + } + /// /// Configures the table that the entity type maps to when targeting a relational database. /// @@ -119,7 +150,7 @@ public static class RelationalEntityTypeBuilderExtensions Check.NotNull(name, nameof(name)); Check.NullButNotEmpty(schema, nameof(schema)); - buildAction(new TableBuilder(name, schema, entityTypeBuilder.Metadata)); + buildAction(new TableBuilder(name, schema, entityTypeBuilder)); entityTypeBuilder.Metadata.SetTableName(name); entityTypeBuilder.Metadata.SetSchema(schema); @@ -181,7 +212,7 @@ public static class RelationalEntityTypeBuilderExtensions Check.NotNull(name, nameof(name)); Check.NullButNotEmpty(schema, nameof(schema)); - buildAction(new TableBuilder(name, schema, entityTypeBuilder.Metadata)); + buildAction(new TableBuilder(name, schema, entityTypeBuilder)); entityTypeBuilder.Metadata.SetTableName(name); entityTypeBuilder.Metadata.SetSchema(schema); diff --git a/src/EFCore.Relational/Metadata/Builders/TableBuilder.cs b/src/EFCore.Relational/Metadata/Builders/TableBuilder.cs index a0b97cff56f..b97e3769bb4 100644 --- a/src/EFCore.Relational/Metadata/Builders/TableBuilder.cs +++ b/src/EFCore.Relational/Metadata/Builders/TableBuilder.cs @@ -22,12 +22,15 @@ public class TableBuilder /// doing so can result in application failures when updating to a new Entity Framework Core release. /// [EntityFrameworkInternal] - public TableBuilder(string name, string? schema, IMutableEntityType entityType) + public TableBuilder(string? name, string? schema, EntityTypeBuilder entityTypeBuilder) { - EntityType = entityType; + EntityTypeBuilder = entityTypeBuilder; } - private IMutableEntityType EntityType { [DebuggerStepThrough] get; } + /// + /// The entity type builder for the entity being configured. + /// + public virtual EntityTypeBuilder EntityTypeBuilder { get; } /// /// Configures the table to be ignored by migrations. @@ -36,7 +39,8 @@ public TableBuilder(string name, string? schema, IMutableEntityType entityType) /// The same builder instance so that multiple calls can be chained. public virtual TableBuilder ExcludeFromMigrations(bool excluded = true) { - EntityType.SetIsTableExcludedFromMigrations(excluded); + EntityTypeBuilder.Metadata.SetIsTableExcludedFromMigrations(excluded); + return this; } diff --git a/src/EFCore.Relational/Metadata/Builders/TableBuilder`.cs b/src/EFCore.Relational/Metadata/Builders/TableBuilder`.cs index 8fdef0c8c4f..a28ed85a3f2 100644 --- a/src/EFCore.Relational/Metadata/Builders/TableBuilder`.cs +++ b/src/EFCore.Relational/Metadata/Builders/TableBuilder`.cs @@ -1,6 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System.Diagnostics; using Microsoft.EntityFrameworkCore.Infrastructure; namespace Microsoft.EntityFrameworkCore.Metadata.Builders @@ -22,11 +23,19 @@ public class TableBuilder : TableBuilder /// doing so can result in application failures when updating to a new Entity Framework Core release. /// [EntityFrameworkInternal] - public TableBuilder(string name, string? schema, IMutableEntityType entityType) - : base(name, schema, entityType) + public TableBuilder(string? name, string? schema, EntityTypeBuilder entityTypeBuilder) + : base(name, schema, entityTypeBuilder) { } + /// + /// The entity type builder for the entity being configured. + /// + public new virtual EntityTypeBuilder EntityTypeBuilder + { + [DebuggerStepThrough] get => (EntityTypeBuilder)base.EntityTypeBuilder; + } + /// /// Configures the table to be ignored by migrations. /// diff --git a/src/EFCore.Relational/Migrations/IMigrationsAnnotationProvider.cs b/src/EFCore.Relational/Migrations/IMigrationsAnnotationProvider.cs index 9349e8822a6..efdfb82145b 100644 --- a/src/EFCore.Relational/Migrations/IMigrationsAnnotationProvider.cs +++ b/src/EFCore.Relational/Migrations/IMigrationsAnnotationProvider.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.Collections.Generic; +using System.Linq; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.Extensions.DependencyInjection; @@ -101,5 +102,41 @@ public interface IMigrationsAnnotationProvider /// The check constraint. /// The annotations. IEnumerable ForRemove(ICheckConstraint checkConstraint); + + /// + /// Gets provider-specific Migrations annotations for the given + /// when it is being renamed. + /// + /// The table. + /// The annotations. + IEnumerable ForRename(ITable table) + => Enumerable.Empty(); + + /// + /// Gets provider-specific Migrations annotations for the given + /// when it is being renamed. + /// + /// The column. + /// The annotations. + IEnumerable ForRename(IColumn column) + => Enumerable.Empty(); + + /// + /// Gets provider-specific Migrations annotations for the given + /// when it is being renamed. + /// + /// The index. + /// The annotations. + IEnumerable ForRename(ITableIndex index) + => Enumerable.Empty(); + + /// + /// Gets provider-specific Migrations annotations for the given + /// when it is being renamed. + /// + /// The sequence. + /// The annotations. + IEnumerable ForRename(ISequence sequence) + => Enumerable.Empty(); } } diff --git a/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs b/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs index de046e80639..531f899f816 100644 --- a/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs +++ b/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs @@ -429,7 +429,6 @@ public virtual IReadOnlyList GetDifferences(IRelationalModel IRelationalModel? target) { var targetMigrationsAnnotations = target?.GetAnnotations().ToList(); - if (source == null) { if (targetMigrationsAnnotations?.Count > 0) @@ -603,13 +602,17 @@ protected virtual IEnumerable Remove(string source, DiffCont if (source.Schema != target.Schema || source.Name != target.Name) { - yield return new RenameTableOperation + var renameTableOperation = new RenameTableOperation { Schema = source.Schema, Name = source.Name, NewSchema = target.Schema, NewName = target.Name }; + + renameTableOperation.AddAnnotations(MigrationsAnnotations.ForRename(source)); + + yield return renameTableOperation; } var sourceMigrationsAnnotations = source.GetAnnotations(); @@ -983,13 +986,17 @@ private static bool EntityTypePathEquals(IEntityType source, IEntityType target, if (source.Name != target.Name) { - yield return new RenameColumnOperation + var renameColumnOperation = new RenameColumnOperation { Schema = table.Schema, Table = table.Name, Name = source.Name, NewName = target.Name }; + + renameColumnOperation.AddAnnotations(MigrationsAnnotations.ForRename(source)); + + yield return renameColumnOperation; } var sourceTypeMapping = sourceMapping.TypeMapping; @@ -1400,13 +1407,17 @@ private bool IndexStructureEquals(ITableIndex source, ITableIndex target, DiffCo if (sourceName != targetName) { - yield return new RenameIndexOperation + var renameIndexOperation = new RenameIndexOperation { Schema = targetTable.Schema, Table = targetTable.Name, Name = sourceName, NewName = targetName }; + + renameIndexOperation.AddAnnotations(MigrationsAnnotations.ForRename(source)); + + yield return renameIndexOperation; } } @@ -1550,13 +1561,17 @@ protected virtual IEnumerable Remove(ICheckConstraint source if (source.Schema != target.Schema || source.Name != target.Name) { - yield return new RenameSequenceOperation + var renameSequenceOperation = new RenameSequenceOperation { Schema = source.Schema, Name = source.Name, NewSchema = target.Schema, NewName = target.Name }; + + renameSequenceOperation.AddAnnotations(MigrationsAnnotations.ForRename(source)); + + yield return renameSequenceOperation; } if (source.StartValue != target.StartValue) diff --git a/src/EFCore.Relational/Migrations/MigrationBuilder.cs b/src/EFCore.Relational/Migrations/MigrationBuilder.cs index ca7ce759cf1..64ad133d5ef 100644 --- a/src/EFCore.Relational/Migrations/MigrationBuilder.cs +++ b/src/EFCore.Relational/Migrations/MigrationBuilder.cs @@ -1066,6 +1066,7 @@ public MigrationBuilder(string? activeProvider) Name = name, NewName = newName }; + Operations.Add(operation); return new OperationBuilder(operation); diff --git a/src/EFCore.Relational/Migrations/MigrationsAnnotationProvider.cs b/src/EFCore.Relational/Migrations/MigrationsAnnotationProvider.cs index 8a9f1972efa..4907e44cfb4 100644 --- a/src/EFCore.Relational/Migrations/MigrationsAnnotationProvider.cs +++ b/src/EFCore.Relational/Migrations/MigrationsAnnotationProvider.cs @@ -71,5 +71,21 @@ public virtual IEnumerable ForRemove(ISequence sequence) /// public virtual IEnumerable ForRemove(ICheckConstraint checkConstraint) => Enumerable.Empty(); + + /// + public virtual IEnumerable ForRename(ITable table) + => Enumerable.Empty(); + + /// + public virtual IEnumerable ForRename(IColumn column) + => Enumerable.Empty(); + + /// + public virtual IEnumerable ForRename(ITableIndex index) + => Enumerable.Empty(); + + /// + public virtual IEnumerable ForRename(ISequence sequence) + => Enumerable.Empty(); } } diff --git a/src/EFCore.Relational/Migrations/Operations/AddPrimaryKeyOperation.cs b/src/EFCore.Relational/Migrations/Operations/AddPrimaryKeyOperation.cs index f77e8e092f2..b68e567b506 100644 --- a/src/EFCore.Relational/Migrations/Operations/AddPrimaryKeyOperation.cs +++ b/src/EFCore.Relational/Migrations/Operations/AddPrimaryKeyOperation.cs @@ -50,6 +50,7 @@ public static AddPrimaryKeyOperation CreateFrom(IPrimaryKeyConstraint primaryKey Name = primaryKey.Name, Columns = primaryKey.Columns.Select(c => c.Name).ToArray() }; + operation.AddAnnotations(primaryKey.GetAnnotations()); return operation; diff --git a/src/EFCore.SqlServer/Design/Internal/SqlServerAnnotationCodeGenerator.cs b/src/EFCore.SqlServer/Design/Internal/SqlServerAnnotationCodeGenerator.cs index 12472c0bfda..96fb6770f7c 100644 --- a/src/EFCore.SqlServer/Design/Internal/SqlServerAnnotationCodeGenerator.cs +++ b/src/EFCore.SqlServer/Design/Internal/SqlServerAnnotationCodeGenerator.cs @@ -3,9 +3,12 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.SqlServer.Metadata.Internal; using Microsoft.EntityFrameworkCore.Utilities; @@ -78,6 +81,91 @@ public SqlServerAnnotationCodeGenerator(AnnotationCodeGeneratorDependencies depe return fragments; } + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public override IReadOnlyList GenerateFluentApiCalls( + IEntityType entityType, + IDictionary annotations) + { + var result = base.GenerateFluentApiCalls(entityType, annotations); + + if (annotations.TryGetValue(SqlServerAnnotationNames.IsTemporal, out var isTemporalAnnotation) + && isTemporalAnnotation.Value as bool? == true) + { + var historyTableName = annotations[SqlServerAnnotationNames.TemporalHistoryTableName].Value as string; + var historyTableSchema = annotations.ContainsKey(SqlServerAnnotationNames.TemporalHistoryTableSchema) + ? annotations[SqlServerAnnotationNames.TemporalHistoryTableSchema].Value as string + : null; + + var periodStartProperty = entityType.GetProperty(entityType.GetTemporalPeriodStartPropertyName()!); + var periodEndProperty = entityType.GetProperty(entityType.GetTemporalPeriodEndPropertyName()!); + var periodStartColumnName = periodStartProperty[RelationalAnnotationNames.ColumnName] as string; + var periodEndColumnName = periodEndProperty[RelationalAnnotationNames.ColumnName] as string; + + // ttb => ttb.WithHistoryTable("HistoryTable", "schema") + var temporalTableBuilderCalls = new List(); + if (historyTableName != null) + { + temporalTableBuilderCalls.Add( + historyTableSchema != null + ? new MethodCallCodeFragment(nameof(TemporalTableBuilder.WithHistoryTable), historyTableName, historyTableSchema) + : new MethodCallCodeFragment(nameof(TemporalTableBuilder.WithHistoryTable), historyTableName)); + } + + // ttb => ttb.HasPeriodStart("Start").HasColumnName("ColumnStart") + temporalTableBuilderCalls.Add( + periodStartColumnName != null + ? new MethodCallCodeFragment( + nameof(TemporalTableBuilder.HasPeriodStart), + new[] { periodStartProperty.Name }, + new MethodCallCodeFragment( + nameof(TemporalPeriodPropertyBuilder.HasColumnName), + periodStartColumnName)) + : new MethodCallCodeFragment( + nameof(TemporalTableBuilder.HasPeriodStart), + periodStartProperty.Name)); + + // ttb => ttb.HasPeriodEnd("End").HasColumnName("ColumnEnd") + temporalTableBuilderCalls.Add( + periodEndColumnName != null + ? new MethodCallCodeFragment( + nameof(TemporalTableBuilder.HasPeriodEnd), + new[] { periodEndProperty.Name }, + new MethodCallCodeFragment( + nameof(TemporalPeriodPropertyBuilder.HasColumnName), + periodEndColumnName)) + : new MethodCallCodeFragment( + nameof(TemporalTableBuilder.HasPeriodEnd), + periodEndProperty.Name)); + + + // ToTable(tb => tb.IsTemporal(ttb => { ... })) + var toTemporalTableCall = new MethodCallCodeFragment( + nameof(RelationalEntityTypeBuilderExtensions.ToTable), + new NestedClosureCodeFragment( + "tb", + new MethodCallCodeFragment( + nameof(SqlServerTableBuilderExtensions.IsTemporal), + new NestedClosureCodeFragment( + "ttb", + temporalTableBuilderCalls)))); + + annotations.Remove(SqlServerAnnotationNames.IsTemporal); + annotations.Remove(SqlServerAnnotationNames.TemporalHistoryTableName); + annotations.Remove(SqlServerAnnotationNames.TemporalHistoryTableSchema); + annotations.Remove(SqlServerAnnotationNames.TemporalPeriodStartPropertyName); + annotations.Remove(SqlServerAnnotationNames.TemporalPeriodEndPropertyName); + + return result.Concat(new[] { toTemporalTableCall }).ToList(); + } + + return result; + } + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in diff --git a/src/EFCore.SqlServer/Extensions/SqlServerEntityTypeBuilderExtensions.cs b/src/EFCore.SqlServer/Extensions/SqlServerEntityTypeBuilderExtensions.cs index 0523cdbdc04..35d4efc7996 100644 --- a/src/EFCore.SqlServer/Extensions/SqlServerEntityTypeBuilderExtensions.cs +++ b/src/EFCore.SqlServer/Extensions/SqlServerEntityTypeBuilderExtensions.cs @@ -116,5 +116,216 @@ public static class SqlServerEntityTypeBuilderExtensions return entityTypeBuilder.CanSetAnnotation(SqlServerAnnotationNames.MemoryOptimized, memoryOptimized, fromDataAnnotation); } + + /// + /// Configures the table as temporal. + /// + /// The builder for the entity being configured. + /// A value indicating whether the table is temporal. + /// Indicates whether the configuration was specified using a data annotation. + /// + /// The same builder instance if the configuration was applied, + /// otherwise. + /// + public static IConventionEntityTypeBuilder? IsTemporal( + this IConventionEntityTypeBuilder entityTypeBuilder, + bool temporal = true, + bool fromDataAnnotation = false) + { + if (entityTypeBuilder.CanSetIsTemporal(temporal, fromDataAnnotation)) + { + entityTypeBuilder.Metadata.SetIsTemporal(temporal, fromDataAnnotation); + + return entityTypeBuilder; + } + + return null; + } + + /// + /// Returns a value indicating whether the mapped table can be configured as temporal. + /// + /// The builder for the entity type being configured. + /// A value indicating whether the table is temporal. + /// Indicates whether the configuration was specified using a data annotation. + /// if the mapped table can be configured as temporal. + public static bool CanSetIsTemporal( + this IConventionEntityTypeBuilder entityTypeBuilder, + bool temporal = true, + bool fromDataAnnotation = false) + { + Check.NotNull(entityTypeBuilder, nameof(entityTypeBuilder)); + + return entityTypeBuilder.CanSetAnnotation(SqlServerAnnotationNames.IsTemporal, temporal, fromDataAnnotation); + } + + /// + /// Configures a history table name for the entity mapped to a temporal table. + /// + /// The builder for the entity being configured. + /// The name of the history table. + /// Indicates whether the configuration was specified using a data annotation. + /// + /// The same builder instance if the configuration was applied, + /// otherwise. + /// + public static IConventionEntityTypeBuilder? WithHistoryTableName( + this IConventionEntityTypeBuilder entityTypeBuilder, + string name, + bool fromDataAnnotation = false) + { + if (entityTypeBuilder.CanSetHistoryTableName(name, fromDataAnnotation)) + { + entityTypeBuilder.Metadata.SetTemporalHistoryTableName(name, fromDataAnnotation); + + return entityTypeBuilder; + } + + return null; + } + + /// + /// Returns a value indicating whether the given history table name can be set for the entity. + /// + /// The builder for the entity type being configured. + /// The name of the history table. + /// Indicates whether the configuration was specified using a data annotation. + /// if the mapped table can have history table name. + public static bool CanSetHistoryTableName( + this IConventionEntityTypeBuilder entityTypeBuilder, + string name, + bool fromDataAnnotation = false) + { + Check.NotNull(entityTypeBuilder, nameof(entityTypeBuilder)); + Check.NotNull(name, nameof(name)); + + return entityTypeBuilder.CanSetAnnotation(SqlServerAnnotationNames.TemporalHistoryTableName, name, fromDataAnnotation); + } + + /// + /// Configures a history table schema for the entity mapped to a temporal table. + /// + /// The builder for the entity being configured. + /// The schema of the history table. + /// Indicates whether the configuration was specified using a data annotation. + /// + /// The same builder instance if the configuration was applied, + /// otherwise. + /// + public static IConventionEntityTypeBuilder? WithHistoryTableSchema( + this IConventionEntityTypeBuilder entityTypeBuilder, + string? schema, + bool fromDataAnnotation = false) + { + if (entityTypeBuilder.CanSetHistoryTableSchema(schema, fromDataAnnotation)) + { + entityTypeBuilder.Metadata.SetTemporalHistoryTableSchema(schema, fromDataAnnotation); + + return entityTypeBuilder; + } + + return null; + } + + /// + /// Returns a value indicating whether the mapped table can have history table schema. + /// + /// The builder for the entity type being configured. + /// The schema of the history table. + /// Indicates whether the configuration was specified using a data annotation. + /// if the mapped table can have history table schema. + public static bool CanSetHistoryTableSchema( + this IConventionEntityTypeBuilder entityTypeBuilder, + string? schema, + bool fromDataAnnotation = false) + { + Check.NotNull(entityTypeBuilder, nameof(entityTypeBuilder)); + + return entityTypeBuilder.CanSetAnnotation(SqlServerAnnotationNames.TemporalHistoryTableSchema, schema, fromDataAnnotation); + } + + /// + /// Configures a period start property for the entity mapped to a temporal table. + /// + /// The builder for the entity being configured. + /// The name of the period start property. + /// Indicates whether the configuration was specified using a data annotation. + /// + /// The same builder instance if the configuration was applied, + /// otherwise. + /// + public static IConventionEntityTypeBuilder? HasPeriodStart( + this IConventionEntityTypeBuilder entityTypeBuilder, + string? propertyName, + bool fromDataAnnotation = false) + { + if (entityTypeBuilder.CanSetPeriodStart(propertyName, fromDataAnnotation)) + { + entityTypeBuilder.Metadata.SetTemporalPeriodStartPropertyName(propertyName, fromDataAnnotation); + + return entityTypeBuilder; + } + + return null; + } + + /// + /// Returns a value indicating whether the mapped table can have period start property. + /// + /// The builder for the entity type being configured. + /// The name of the period start property. + /// Indicates whether the configuration was specified using a data annotation. + /// if the mapped table can have period start property. + public static bool CanSetPeriodStart( + this IConventionEntityTypeBuilder entityTypeBuilder, + string? propertyName, + bool fromDataAnnotation = false) + { + Check.NotNull(entityTypeBuilder, nameof(entityTypeBuilder)); + + return entityTypeBuilder.CanSetAnnotation(SqlServerAnnotationNames.TemporalPeriodStartPropertyName, propertyName, fromDataAnnotation); + } + + /// + /// Configures a period end property for the entity mapped to a temporal table. + /// + /// The builder for the entity being configured. + /// The name of the period end property. + /// Indicates whether the configuration was specified using a data annotation. + /// + /// The same builder instance if the configuration was applied, + /// otherwise. + /// + public static IConventionEntityTypeBuilder? HasPeriodEnd( + this IConventionEntityTypeBuilder entityTypeBuilder, + string? propertyName, + bool fromDataAnnotation = false) + { + if (entityTypeBuilder.CanSetPeriodEnd(propertyName, fromDataAnnotation)) + { + entityTypeBuilder.Metadata.SetTemporalPeriodEndPropertyName(propertyName, fromDataAnnotation); + + return entityTypeBuilder; + } + + return null; + } + + /// + /// Returns a value indicating whether the mapped table can have period end property. + /// + /// The builder for the entity type being configured. + /// The name of the period end property. + /// Indicates whether the configuration was specified using a data annotation. + /// if the mapped table can have period end property. + public static bool CanSetPeriodEnd( + this IConventionEntityTypeBuilder entityTypeBuilder, + string? propertyName, + bool fromDataAnnotation = false) + { + Check.NotNull(entityTypeBuilder, nameof(entityTypeBuilder)); + + return entityTypeBuilder.CanSetAnnotation(SqlServerAnnotationNames.TemporalPeriodEndPropertyName, propertyName, fromDataAnnotation); + } } } diff --git a/src/EFCore.SqlServer/Extensions/SqlServerEntityTypeExtensions.cs b/src/EFCore.SqlServer/Extensions/SqlServerEntityTypeExtensions.cs index 386289a3fce..8cb963567a2 100644 --- a/src/EFCore.SqlServer/Extensions/SqlServerEntityTypeExtensions.cs +++ b/src/EFCore.SqlServer/Extensions/SqlServerEntityTypeExtensions.cs @@ -12,6 +12,8 @@ namespace Microsoft.EntityFrameworkCore /// public static class SqlServerEntityTypeExtensions { + private const string DefaultHistoryTableNameSuffix = "History"; + /// /// Returns a value indicating whether the entity type is mapped to a memory-optimized table. /// @@ -52,5 +54,227 @@ public static void SetIsMemoryOptimized(this IMutableEntityType entityType, bool /// The configuration source for the memory-optimized setting. public static ConfigurationSource? GetIsMemoryOptimizedConfigurationSource(this IConventionEntityType entityType) => entityType.FindAnnotation(SqlServerAnnotationNames.MemoryOptimized)?.GetConfigurationSource(); + + /// + /// Returns a value indicating whether the entity type is mapped to a temporal table. + /// + /// The entity type. + /// if the entity type is mapped to a temporal table. + public static bool IsTemporal(this IReadOnlyEntityType entityType) + => entityType[SqlServerAnnotationNames.IsTemporal] as bool? ?? false; + + /// + /// Sets a value indicating whether the entity type is mapped to a temporal table. + /// + /// The entity type. + /// The value to set. + public static void SetIsTemporal(this IMutableEntityType entityType, bool temporal) + => entityType.SetOrRemoveAnnotation(SqlServerAnnotationNames.IsTemporal, temporal); + + /// + /// Sets a value indicating whether the entity type is mapped to a temporal table. + /// + /// The entity type. + /// The value to set. + /// Indicates whether the configuration was specified using a data annotation. + /// The configured value. + public static bool? SetIsTemporal( + this IConventionEntityType entityType, + bool? temporal, + bool fromDataAnnotation = false) + { + entityType.SetOrRemoveAnnotation(SqlServerAnnotationNames.IsTemporal, temporal, fromDataAnnotation); + + return temporal; + } + + /// + /// Gets the configuration source for the temporal table setting. + /// + /// The entity type. + /// The configuration source for the temporal table setting. + public static ConfigurationSource? GetIsTemporalConfigurationSource(this IConventionEntityType entityType) + => entityType.FindAnnotation(SqlServerAnnotationNames.IsTemporal)?.GetConfigurationSource(); + + /// + /// Returns a value representing the name of the period start property of the entity mapped to a temporal table. + /// + /// The entity type. + /// Name of the period start property. + public static string? GetTemporalPeriodStartPropertyName(this IReadOnlyEntityType entityType) + => entityType[SqlServerAnnotationNames.TemporalPeriodStartPropertyName] as string; + + /// + /// Sets a value representing the name of the period start property of the entity mapped to a temporal table. + /// + /// The entity type. + /// The value to set. + public static void SetTemporalPeriodStartPropertyName(this IMutableEntityType entityType, string? periodStartPropertyName) + => entityType.SetAnnotation(SqlServerAnnotationNames.TemporalPeriodStartPropertyName, periodStartPropertyName); + + /// + /// Sets a value representing the name of the period start property of the entity mapped to a temporal table. + /// + /// The entity type. + /// The value to set. + /// Indicates whether the configuration was specified using a data annotation. + /// The configured value. + public static string? SetTemporalPeriodStartPropertyName( + this IConventionEntityType entityType, + string? periodStartPropertyName, + bool fromDataAnnotation = false) + { + entityType.SetAnnotation( + SqlServerAnnotationNames.TemporalPeriodStartPropertyName, + periodStartPropertyName, + fromDataAnnotation); + + return periodStartPropertyName; + } + + /// + /// Gets the configuration source for the temporal table period start property name setting. + /// + /// The entity type. + /// The configuration source for the temporal table period start property name setting. + public static ConfigurationSource? GetTemporalPeriodStartPropertyNameConfigurationSource(this IConventionEntityType entityType) + => entityType.FindAnnotation(SqlServerAnnotationNames.TemporalPeriodStartPropertyName)?.GetConfigurationSource(); + + /// + /// Returns a value representing the name of the period end property of the entity mapped to a temporal table. + /// + /// The entity type. + /// Name of the period start property. + public static string? GetTemporalPeriodEndPropertyName(this IReadOnlyEntityType entityType) + => entityType[SqlServerAnnotationNames.TemporalPeriodEndPropertyName] as string; + + /// + /// Sets a value representing the name of the period end property of the entity mapped to a temporal table. + /// + /// The entity type. + /// The value to set. + public static void SetTemporalPeriodEndPropertyName(this IMutableEntityType entityType, string? periodEndPropertyName) + => entityType.SetAnnotation(SqlServerAnnotationNames.TemporalPeriodEndPropertyName, periodEndPropertyName); + + /// + /// Sets a value representing the name of the period end property of the entity mapped to a temporal table. + /// + /// The entity type. + /// The value to set. + /// Indicates whether the configuration was specified using a data annotation. + /// The configured value. + public static string? SetTemporalPeriodEndPropertyName( + this IConventionEntityType entityType, + string? periodEndPropertyName, + bool fromDataAnnotation = false) + { + entityType.SetAnnotation( + SqlServerAnnotationNames.TemporalPeriodEndPropertyName, + periodEndPropertyName, + fromDataAnnotation); + + return periodEndPropertyName; + } + + /// + /// Gets the configuration source for the temporal table period end property name setting. + /// + /// The entity type. + /// The configuration source for the temporal table period end property name setting. + public static ConfigurationSource? GetTemporalPeriodEndPropertyNameConfigurationSource(this IConventionEntityType entityType) + => entityType.FindAnnotation(SqlServerAnnotationNames.TemporalPeriodEndPropertyName)?.GetConfigurationSource(); + + /// + /// Returns a value representing the name of the history table associated with the entity mapped to a temporal table. + /// + /// The entity type. + /// Name of the history table. + public static string? GetTemporalHistoryTableName(this IReadOnlyEntityType entityType) + => entityType[SqlServerAnnotationNames.TemporalHistoryTableName] is string historyTableName + ? historyTableName + : entityType[SqlServerAnnotationNames.IsTemporal] as bool? == true + ? entityType.ShortName() + DefaultHistoryTableNameSuffix + : null; + + /// + /// Sets a value representing the name of the history table associated with the entity mapped to a temporal table. + /// + /// The entity type. + /// The value to set. + public static void SetTemporalHistoryTableName(this IMutableEntityType entityType, string? historyTableName) + => entityType.SetAnnotation(SqlServerAnnotationNames.TemporalHistoryTableName, historyTableName); + + /// + /// Sets a value representing the name of the history table associated with the entity mapped to a temporal table. + /// + /// The entity type. + /// The value to set. + /// Indicates whether the configuration was specified using a data annotation. + /// The configured value. + public static string? SetTemporalHistoryTableName( + this IConventionEntityType entityType, + string? historyTableName, + bool fromDataAnnotation = false) + { + entityType.SetAnnotation( + SqlServerAnnotationNames.TemporalHistoryTableName, + historyTableName, + fromDataAnnotation); + + return historyTableName; + } + + /// + /// Gets the configuration source for the temporal history table name setting. + /// + /// The entity type. + /// The configuration source for the temporal history table name setting. + public static ConfigurationSource? GetTemporalHistoryTableNameConfigurationSource(this IConventionEntityType entityType) + => entityType.FindAnnotation(SqlServerAnnotationNames.TemporalHistoryTableName)?.GetConfigurationSource(); + + /// + /// Returns a value representing the schema of the history table associated with the entity mapped to a temporal table. + /// + /// The entity type. + /// Name of the history table. + public static string? GetTemporalHistoryTableSchema(this IReadOnlyEntityType entityType) + => entityType[SqlServerAnnotationNames.TemporalHistoryTableSchema] as string + ?? entityType[RelationalAnnotationNames.Schema] as string; + + /// + /// Sets a value representing the schema of the history table associated with the entity mapped to a temporal table. + /// + /// The entity type. + /// The value to set. + public static void SetTemporalHistoryTableSchema(this IMutableEntityType entityType, string? historyTableSchema) + => entityType.SetAnnotation(SqlServerAnnotationNames.TemporalHistoryTableSchema, historyTableSchema); + + /// + /// Sets a value representing the schema of the history table associated with the entity mapped to a temporal table. + /// + /// The entity type. + /// The value to set. + /// Indicates whether the configuration was specified using a data annotation. + /// The configured value. + public static string? SetTemporalHistoryTableSchema( + this IConventionEntityType entityType, + string? historyTableSchema, + bool fromDataAnnotation = false) + { + entityType.SetAnnotation( + SqlServerAnnotationNames.TemporalHistoryTableSchema, + historyTableSchema, + fromDataAnnotation); + + return historyTableSchema; + } + + /// + /// Gets the configuration source for the temporal history table schema setting. + /// + /// The entity type. + /// The configuration source for the temporal history table schema setting. + public static ConfigurationSource? GetTemporalHistoryTableSchemaConfigurationSource(this IConventionEntityType entityType) + => entityType.FindAnnotation(SqlServerAnnotationNames.TemporalHistoryTableSchema)?.GetConfigurationSource(); } } diff --git a/src/EFCore.SqlServer/Extensions/SqlServerTableBuilderExtensions.cs b/src/EFCore.SqlServer/Extensions/SqlServerTableBuilderExtensions.cs new file mode 100644 index 00000000000..f9acd0a4c4f --- /dev/null +++ b/src/EFCore.SqlServer/Extensions/SqlServerTableBuilderExtensions.cs @@ -0,0 +1,81 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +// ReSharper disable once CheckNamespace +namespace Microsoft.EntityFrameworkCore +{ + /// + /// SQL Server specific extension methods for . + /// + public static class SqlServerTableBuilderExtensions + { + /// + /// Configures the table as temporal. + /// + /// The builder for the table being configured. + /// A value indicating whether the table is temporal. + /// An object that can be used to configure the temporal table. + public static TemporalTableBuilder IsTemporal( + this TableBuilder tableBuilder, + bool temporal = true) + { + tableBuilder.EntityTypeBuilder.Metadata.SetIsTemporal(temporal); + + return new TemporalTableBuilder(tableBuilder.EntityTypeBuilder); + } + + /// + /// Configures the table as temporal. + /// + /// The builder for the table being configured. + /// An action that performs configuration of the temporal table. + /// The same builder instance so that multiple calls can be chained. + public static TableBuilder IsTemporal( + this TableBuilder tableBuilder, + Action buildAction) + { + tableBuilder.EntityTypeBuilder.Metadata.SetIsTemporal(true); + buildAction(new TemporalTableBuilder(tableBuilder.EntityTypeBuilder)); + + return tableBuilder; + } + + /// + /// Configures the table as temporal. + /// + /// The entity type being configured. + /// The builder for the table being configured. + /// A value indicating whether the table is temporal. + /// An object that can be used to configure the temporal table. + public static TemporalTableBuilder IsTemporal( + this TableBuilder tableBuilder, + bool temporal = true) + where TEntity : class + { + tableBuilder.EntityTypeBuilder.Metadata.SetIsTemporal(temporal); + + return new TemporalTableBuilder(tableBuilder.EntityTypeBuilder); + } + + /// + /// Configures the table as temporal. + /// + /// The entity type being configured. + /// The builder for the table being configured. + /// An action that performs configuration of the temporal table. + /// The same builder instance so that multiple calls can be chained. + public static TableBuilder IsTemporal( + this TableBuilder tableBuilder, + Action> buildAction) + where TEntity: class + { + tableBuilder.EntityTypeBuilder.Metadata.SetIsTemporal(true); + buildAction(new TemporalTableBuilder(tableBuilder.EntityTypeBuilder)); + + return tableBuilder; + } + } +} diff --git a/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs b/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs index 393c4f9fb6c..780de7f6e6f 100644 --- a/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs +++ b/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs @@ -59,6 +59,7 @@ public override void Validate(IModel model, IDiagnosticsLogger @@ -209,6 +210,112 @@ public override void Validate(IModel model, IDiagnosticsLogger + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected virtual void ValidateTemporalTables( + IModel model, + IDiagnosticsLogger logger) + { + var temporalEntityTypes = model.GetEntityTypes().Where(t => t.IsTemporal()).ToList(); + foreach (var temporalEntityType in temporalEntityTypes) + { + if (temporalEntityType.BaseType != null) + { + throw new InvalidOperationException(SqlServerStrings.TemporalOnlyOnRoot(temporalEntityType.DisplayName())); + } + + ValidateTemporalPeriodProperty(temporalEntityType, periodStart: true); + ValidateTemporalPeriodProperty(temporalEntityType, periodStart: false); + + var derivedTableMappings = temporalEntityType.GetDerivedTypes().Select(t => t.GetTableName()).Distinct().ToList(); + if (derivedTableMappings.Count > 0 + && (derivedTableMappings.Count != 1 || derivedTableMappings.First() != temporalEntityType.GetTableName())) + { + throw new InvalidOperationException(SqlServerStrings.TemporalOnlySupportedForTPH(temporalEntityType.DisplayName())); + } + } + } + + private void ValidateTemporalPeriodProperty(IEntityType temporalEntityType, bool periodStart) + { + var annotationPropertyName = periodStart + ? temporalEntityType.GetTemporalPeriodStartPropertyName() + : temporalEntityType.GetTemporalPeriodEndPropertyName(); + + if (annotationPropertyName == null) + { + throw new InvalidOperationException( + SqlServerStrings.TemporalMustDefinePeriodProperties( + temporalEntityType.DisplayName())); + } + + var periodProperty = temporalEntityType.FindProperty(annotationPropertyName); + if (periodProperty == null) + { + throw new InvalidOperationException( + SqlServerStrings.TemporalExpectedPeriodPropertyNotFound( + temporalEntityType.DisplayName(), annotationPropertyName)); + } + + if (!periodProperty.IsShadowProperty() && !temporalEntityType.IsPropertyBag) + { + throw new InvalidOperationException( + SqlServerStrings.TemporalPeriodPropertyMustBeInShadowState( + temporalEntityType.DisplayName(), periodProperty.Name)); + } + + if (periodProperty.IsNullable + || periodProperty.ClrType != typeof(DateTime)) + { + throw new InvalidOperationException( + SqlServerStrings.TemporalPeriodPropertyMustBeNonNullableDateTime( + temporalEntityType.DisplayName(), periodProperty.Name, nameof(DateTime))); + } + + var expectedPeriodColumnName = "datetime2"; + if (periodProperty.GetColumnType() != expectedPeriodColumnName) + { + throw new InvalidOperationException( + SqlServerStrings.TemporalPeriodPropertyMustBeMappedToDatetime2( + temporalEntityType.DisplayName(), periodProperty.Name, expectedPeriodColumnName)); + } + + if (periodProperty.TryGetDefaultValue(out var _)) + { + throw new InvalidOperationException( + SqlServerStrings.TemporalPeriodPropertyCantHaveDefaultValue( + temporalEntityType.DisplayName(), periodProperty.Name)); + } + + if (temporalEntityType.GetTableName() is string tableName) + { + var storeObjectIdentifier = StoreObjectIdentifier.Table(tableName, temporalEntityType.GetSchema()); + var periodColumnName = periodProperty.GetColumnName(storeObjectIdentifier); + + var propertiesMappedToPeriodColumn = temporalEntityType.GetProperties().Where(p => p.Name != periodProperty.Name && p.GetColumnName(storeObjectIdentifier) == periodColumnName).ToList(); + foreach (var propertyMappedToPeriodColumn in propertiesMappedToPeriodColumn) + { + if (propertyMappedToPeriodColumn.ValueGenerated != ValueGenerated.OnAddOrUpdate) + { + throw new InvalidOperationException(SqlServerStrings.TemporalPropertyMappedToPeriodColumnMustBeValueGeneratedOnAddOrUpdate( + temporalEntityType.DisplayName(), propertyMappedToPeriodColumn.Name, nameof(ValueGenerated.OnAddOrUpdate))); + } + + if (propertyMappedToPeriodColumn.TryGetDefaultValue(out var _)) + { + throw new InvalidOperationException(SqlServerStrings.TemporalPropertyMappedToPeriodColumnCantHaveDefaultValue( + temporalEntityType.DisplayName(), propertyMappedToPeriodColumn.Name)); + } + } + } + + // TODO: check that period property is excluded from query (once the annotation is added) + } + /// /// 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 @@ -223,7 +330,6 @@ public override void Validate(IModel model, IDiagnosticsLogger t.IsTemporal()) + && mappedTypes.Select(t => t.GetRootType()).Distinct().Count() > 1) + { + throw new InvalidOperationException(SqlServerStrings.TemporalNotSupportedForTableSplitting(tableName)); + } + base.ValidateSharedTableCompatibility(mappedTypes, tableName, schema, logger); } diff --git a/src/EFCore.SqlServer/Metadata/Builders/TemporalPeriodPropertyBuilder.cs b/src/EFCore.SqlServer/Metadata/Builders/TemporalPeriodPropertyBuilder.cs new file mode 100644 index 00000000000..c3d5e892829 --- /dev/null +++ b/src/EFCore.SqlServer/Metadata/Builders/TemporalPeriodPropertyBuilder.cs @@ -0,0 +1,74 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.ComponentModel; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace Microsoft.EntityFrameworkCore.Metadata.Builders +{ + /// + /// + /// Instances of this class are returned from methods when using the API + /// and it is not designed to be directly constructed in your application code. + /// + /// + public class TemporalPeriodPropertyBuilder + { + private readonly EntityTypeBuilder _entityTypeBuilder; + private readonly string _periodPropertyName; + + /// + /// 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. + /// + [EntityFrameworkInternal] + public TemporalPeriodPropertyBuilder(EntityTypeBuilder entityTypeBuilder, string periodPropertyName) + { + _entityTypeBuilder = entityTypeBuilder; + _periodPropertyName = periodPropertyName; + } + + /// + /// Configures the column name the period property maps to. + /// + /// The name of the column. + /// The same builder instance so that multiple calls can be chained. + public virtual TemporalPeriodPropertyBuilder HasColumnName(string name) + { + _entityTypeBuilder.Metadata.GetProperty(_periodPropertyName).SetColumnName(name); + + return this; + } + + #region Hidden System.Object members + + /// + /// Returns a string that represents the current object. + /// + /// A string that represents the current object. + [EditorBrowsable(EditorBrowsableState.Never)] + public override string? ToString() + => base.ToString(); + + /// + /// Determines whether the specified object is equal to the current object. + /// + /// The object to compare with the current object. + /// if the specified object is equal to the current object; otherwise, . + [EditorBrowsable(EditorBrowsableState.Never)] + public override bool Equals(object? obj) + => base.Equals(obj); + + /// + /// Serves as the default hash function. + /// + /// A hash code for the current object. + [EditorBrowsable(EditorBrowsableState.Never)] + public override int GetHashCode() + => base.GetHashCode(); + + #endregion + } +} diff --git a/src/EFCore.SqlServer/Metadata/Builders/TemporalTableBuilder.cs b/src/EFCore.SqlServer/Metadata/Builders/TemporalTableBuilder.cs new file mode 100644 index 00000000000..c00b99edf1d --- /dev/null +++ b/src/EFCore.SqlServer/Metadata/Builders/TemporalTableBuilder.cs @@ -0,0 +1,110 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.ComponentModel; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace Microsoft.EntityFrameworkCore.Metadata.Builders +{ + /// + /// + /// Instances of this class are returned from methods when using the API + /// and it is not designed to be directly constructed in your application code. + /// + /// + public class TemporalTableBuilder + { + private readonly EntityTypeBuilder _entityTypeBuilder; + + /// + /// 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. + /// + [EntityFrameworkInternal] + public TemporalTableBuilder(EntityTypeBuilder entityTypeBuilder) + { + _entityTypeBuilder = entityTypeBuilder; + } + + /// + /// Configures a history table for the entity mapped to a temporal table. + /// + /// The name of the history table. + /// The same builder instance so that multiple calls can be chained. + public virtual TemporalTableBuilder WithHistoryTable(string name) + { + _entityTypeBuilder.Metadata.SetTemporalHistoryTableName(name); + + return this; + } + + /// + /// Configures a history table for the entity mapped to a temporal table. + /// + /// The name of the history table. + /// The schema of the history table. + /// The same builder instance so that multiple calls can be chained. + public virtual TemporalTableBuilder WithHistoryTable(string name, string? schema) + { + _entityTypeBuilder.Metadata.SetTemporalHistoryTableName(name); + _entityTypeBuilder.Metadata.SetTemporalHistoryTableSchema(schema); + + return this; + } + + /// + /// Returns an object that can be used to configure a period start property of the entity type mapped to a temporal table. + /// + /// The name of the period start property. + /// An object that can be used to configure the period start property. + public virtual TemporalPeriodPropertyBuilder HasPeriodStart(string propertyName) + { + _entityTypeBuilder.Metadata.SetTemporalPeriodStartPropertyName(propertyName); + + return new TemporalPeriodPropertyBuilder(_entityTypeBuilder, propertyName); + } + + /// + /// Returns an object that can be used to configure a period end property of the entity type mapped to a temporal table. + /// + /// The name of the period end property. + /// An object that can be used to configure the period end property. + public virtual TemporalPeriodPropertyBuilder HasPeriodEnd(string propertyName) + { + _entityTypeBuilder.Metadata.SetTemporalPeriodEndPropertyName(propertyName); + + return new TemporalPeriodPropertyBuilder(_entityTypeBuilder, propertyName); + } + + #region Hidden System.Object members + + /// + /// Returns a string that represents the current object. + /// + /// A string that represents the current object. + [EditorBrowsable(EditorBrowsableState.Never)] + public override string? ToString() + => base.ToString(); + + /// + /// Determines whether the specified object is equal to the current object. + /// + /// The object to compare with the current object. + /// if the specified object is equal to the current object; otherwise, . + [EditorBrowsable(EditorBrowsableState.Never)] + public override bool Equals(object? obj) + => base.Equals(obj); + + /// + /// Serves as the default hash function. + /// + /// A hash code for the current object. + [EditorBrowsable(EditorBrowsableState.Never)] + public override int GetHashCode() + => base.GetHashCode(); + + #endregion + } +} diff --git a/src/EFCore.SqlServer/Metadata/Builders/TemporalTableBuilder`.cs b/src/EFCore.SqlServer/Metadata/Builders/TemporalTableBuilder`.cs new file mode 100644 index 00000000000..b05a67d7806 --- /dev/null +++ b/src/EFCore.SqlServer/Metadata/Builders/TemporalTableBuilder`.cs @@ -0,0 +1,39 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace Microsoft.EntityFrameworkCore.Metadata.Builders +{ + /// + /// + /// Instances of this class are returned from methods when using the API + /// and it is not designed to be directly constructed in your application code. + /// + /// + /// The entity type being configured. + public class TemporalTableBuilder : TemporalTableBuilder + where TEntity : class + { + /// + /// 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. + /// + [EntityFrameworkInternal] + public TemporalTableBuilder(EntityTypeBuilder entityTypeBuilder) + : base(entityTypeBuilder) + { + } + + /// + /// Configures a history table for the entity mapped to a temporal table. + /// + /// 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 WithHistoryTable(string name, string? schema = null) + => (TemporalTableBuilder)base.WithHistoryTable(name, schema); + } +} diff --git a/src/EFCore.SqlServer/Metadata/Conventions/SqlServerConventionSetBuilder.cs b/src/EFCore.SqlServer/Metadata/Conventions/SqlServerConventionSetBuilder.cs index 3dc8e37e187..c7729b91c62 100644 --- a/src/EFCore.SqlServer/Metadata/Conventions/SqlServerConventionSetBuilder.cs +++ b/src/EFCore.SqlServer/Metadata/Conventions/SqlServerConventionSetBuilder.cs @@ -64,6 +64,11 @@ public override ConventionSet CreateConventionSet() ReplaceConvention( conventionSet.EntityTypeAnnotationChangedConventions, (RelationalValueGenerationConvention)valueGenerationConvention); + ConventionSet.AddBefore( + conventionSet.EntityTypeAnnotationChangedConventions, + new SqlServerTemporalConvention(), + typeof(SqlServerValueGenerationConvention)); + ReplaceConvention(conventionSet.EntityTypePrimaryKeyChangedConventions, valueGenerationConvention); conventionSet.KeyAddedConventions.Add(sqlServerInMemoryTablesConvention); diff --git a/src/EFCore.SqlServer/Metadata/Conventions/SqlServerTemporalConvention.cs b/src/EFCore.SqlServer/Metadata/Conventions/SqlServerTemporalConvention.cs new file mode 100644 index 00000000000..bd3650a7780 --- /dev/null +++ b/src/EFCore.SqlServer/Metadata/Conventions/SqlServerTemporalConvention.cs @@ -0,0 +1,85 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.SqlServer.Metadata.Internal; + +namespace Microsoft.EntityFrameworkCore.Metadata.Conventions +{ + /// + /// A convention that manipulates temporal settings for an entity mapped to a temporal table. + /// + public class SqlServerTemporalConvention : IEntityTypeAnnotationChangedConvention + { + private const string PeriodStartDefaultName = "PeriodStart"; + private const string PeriodEndDefaultName = "PeriodEnd"; + + /// + public virtual void ProcessEntityTypeAnnotationChanged( + IConventionEntityTypeBuilder entityTypeBuilder, + string name, + IConventionAnnotation? annotation, + IConventionAnnotation? oldAnnotation, + IConventionContext context) + { + if (name == SqlServerAnnotationNames.IsTemporal) + { + if (annotation?.Value as bool? == true) + { + if (entityTypeBuilder.Metadata.GetTemporalPeriodStartPropertyName() == null) + { + entityTypeBuilder.HasPeriodStart(PeriodStartDefaultName); + } + + if (entityTypeBuilder.Metadata.GetTemporalPeriodEndPropertyName() == null) + { + entityTypeBuilder.HasPeriodEnd(PeriodEndDefaultName); + } + } + else + { + entityTypeBuilder.HasPeriodStart(null); + entityTypeBuilder.HasPeriodEnd(null); + } + } + + if (name == SqlServerAnnotationNames.TemporalPeriodStartPropertyName + || name == SqlServerAnnotationNames.TemporalPeriodEndPropertyName) + { + if (oldAnnotation?.Value is string oldPeriodPropertyName) + { + var oldPeriodProperty = entityTypeBuilder.Metadata.GetProperty(oldPeriodPropertyName); + entityTypeBuilder.RemoveUnusedImplicitProperties(new[] { oldPeriodProperty }); + + if (oldPeriodProperty.GetTypeConfigurationSource() == ConfigurationSource.Explicit) + { + if ((name == SqlServerAnnotationNames.TemporalPeriodStartPropertyName + && oldPeriodProperty.GetDefaultValue() is DateTime start + && start == DateTime.MinValue) + || (name == SqlServerAnnotationNames.TemporalPeriodEndPropertyName + && oldPeriodProperty.GetDefaultValue() is DateTime end + && end == DateTime.MaxValue)) + { + oldPeriodProperty.Builder.HasDefaultValue(null); + } + } + } + + if (annotation?.Value is string periodPropertyName) + { + var periodPropertyBuilder = entityTypeBuilder.Property( + typeof(DateTime), + periodPropertyName); + + if (periodPropertyBuilder != null) + { + // set column name explicitly so that we don't try to uniquefy it to some other column + // in case another property is defined that maps to the same column + periodPropertyBuilder.HasColumnName(periodPropertyName); + } + } + } + } + } +} diff --git a/src/EFCore.SqlServer/Metadata/Conventions/SqlServerValueGenerationConvention.cs b/src/EFCore.SqlServer/Metadata/Conventions/SqlServerValueGenerationConvention.cs index 6d892ade23e..4b944986043 100644 --- a/src/EFCore.SqlServer/Metadata/Conventions/SqlServerValueGenerationConvention.cs +++ b/src/EFCore.SqlServer/Metadata/Conventions/SqlServerValueGenerationConvention.cs @@ -53,6 +53,46 @@ public class SqlServerValueGenerationConvention : RelationalValueGenerationConve base.ProcessPropertyAnnotationChanged(propertyBuilder, name, annotation, oldAnnotation, context); } + /// + /// Called after an annotation is changed on an entity. + /// + /// The builder for the entity type. + /// The annotation name. + /// The new annotation. + /// The old annotation. + /// Additional information associated with convention execution. + public override void ProcessEntityTypeAnnotationChanged( + IConventionEntityTypeBuilder entityTypeBuilder, + string name, + IConventionAnnotation? annotation, + IConventionAnnotation? oldAnnotation, + IConventionContext context) + { + if ((name == SqlServerAnnotationNames.TemporalPeriodStartPropertyName + || name == SqlServerAnnotationNames.TemporalPeriodEndPropertyName) + && annotation?.Value is string propertyName) + { + var periodProperty = entityTypeBuilder.Metadata.FindProperty(propertyName); + if (periodProperty != null) + { + periodProperty.Builder.ValueGenerated(GetValueGenerated(periodProperty)); + } + + // cleanup the previous period property - its possible that it won't be deleted + // (e.g. when removing period with default name, while the property with that same name has been explicitly defined) + if (oldAnnotation?.Value is string oldPropertyName) + { + var oldPeriodProperty = entityTypeBuilder.Metadata.FindProperty(oldPropertyName); + if (oldPeriodProperty != null) + { + oldPeriodProperty.Builder.ValueGenerated(GetValueGenerated(oldPeriodProperty)); + } + } + } + + base.ProcessEntityTypeAnnotationChanged(entityTypeBuilder, name, annotation, oldAnnotation, context); + } + /// /// Returns the store value generation strategy to set for the given property. /// @@ -88,9 +128,20 @@ public class SqlServerValueGenerationConvention : RelationalValueGenerationConve IReadOnlyProperty property, in StoreObjectIdentifier storeObject, ITypeMappingSource typeMappingSource) - => RelationalValueGenerationConvention.GetValueGenerated(property, storeObject) + => GetTemporalValueGenerated(property, storeObject) + ?? RelationalValueGenerationConvention.GetValueGenerated(property, storeObject) ?? (property.GetValueGenerationStrategy(storeObject, typeMappingSource) != SqlServerValueGenerationStrategy.None ? ValueGenerated.OnAdd : (ValueGenerated?)null); + + private ValueGenerated? GetTemporalValueGenerated(IReadOnlyProperty property, in StoreObjectIdentifier storeObject) + { + var entityType = property.DeclaringEntityType; + return entityType.IsTemporal() + && (entityType.GetTemporalPeriodStartPropertyName() == property.Name + || entityType.GetTemporalPeriodEndPropertyName() == property.Name) + ? ValueGenerated.OnAddOrUpdate + : null; + } } } diff --git a/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationNames.cs b/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationNames.cs index 5665ee5f9fd..11e71994946 100644 --- a/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationNames.cs +++ b/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationNames.cs @@ -139,6 +139,62 @@ public static class SqlServerAnnotationNames /// public const string Sparse = Prefix + "Sparse"; + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public const string IsTemporal = Prefix + "IsTemporal"; + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public const string TemporalHistoryTableName = Prefix + "TemporalHistoryTableName"; + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public const string TemporalHistoryTableSchema = Prefix + "TemporalHistoryTableSchema"; + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public const string TemporalPeriodStartPropertyName = Prefix + "TemporalPeriodStartPropertyName"; + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public const string TemporalPeriodStartColumnName = Prefix + "TemporalPeriodStartColumnName"; + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public const string TemporalPeriodEndPropertyName = Prefix + "TemporalPeriodEndPropertyName"; + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public const string TemporalPeriodEndColumnName = Prefix + "TemporalPeriodEndColumnName"; + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in diff --git a/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs b/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs index f911acafd75..4a39e8ee221 100644 --- a/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs +++ b/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs @@ -102,11 +102,35 @@ public override IEnumerable For(ITable table, bool designTime) yield break; } + var entityType = table.EntityTypeMappings.First().EntityType; + // Model validation ensures that these facets are the same on all mapped entity types - if (table.EntityTypeMappings.First().EntityType.IsMemoryOptimized()) + if (entityType.IsMemoryOptimized()) { yield return new Annotation(SqlServerAnnotationNames.MemoryOptimized, true); } + + if (entityType.IsTemporal() && designTime) + { + yield return new Annotation(SqlServerAnnotationNames.IsTemporal, true); + yield return new Annotation(SqlServerAnnotationNames.TemporalHistoryTableName, entityType.GetTemporalHistoryTableName()); + yield return new Annotation(SqlServerAnnotationNames.TemporalHistoryTableSchema, entityType.GetTemporalHistoryTableSchema()); + + var storeObjectIdentifier = StoreObjectIdentifier.Table(table.Name, table.Schema); + var periodStartPropertyName = entityType.GetTemporalPeriodStartPropertyName(); + if (periodStartPropertyName != null) + { + var periodStartProperty = entityType.GetProperty(periodStartPropertyName); + yield return new Annotation(SqlServerAnnotationNames.TemporalPeriodStartColumnName, periodStartProperty.GetColumnName(storeObjectIdentifier)); + } + + var periodEndPropertyName = entityType.GetTemporalPeriodEndPropertyName(); + if (periodEndPropertyName != null) + { + var periodEndProperty = entityType.GetProperty(entityType.GetTemporalPeriodEndPropertyName()!); + yield return new Annotation(SqlServerAnnotationNames.TemporalPeriodEndColumnName, periodEndProperty.GetColumnName(storeObjectIdentifier)); + } + } } /// @@ -214,6 +238,28 @@ public override IEnumerable For(IColumn column, bool designTime) { yield return new Annotation(SqlServerAnnotationNames.Sparse, isSparse); } + + var entityType = column.Table.EntityTypeMappings.First().EntityType; + if (entityType.IsTemporal() && designTime) + { + var periodStartPropertyName = entityType.GetTemporalPeriodStartPropertyName(); + var periodEndPropertyName = entityType.GetTemporalPeriodEndPropertyName(); + + var periodStartProperty = entityType.GetProperty(periodStartPropertyName!); + var periodEndProperty = entityType.GetProperty(periodEndPropertyName!); + + var storeObjectIdentifier = StoreObjectIdentifier.Table(table.Name, table.Schema); + var periodStartColumnName = periodStartProperty.GetColumnName(storeObjectIdentifier); + var periodEndColumnName = periodEndProperty.GetColumnName(storeObjectIdentifier); + + 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); + } + } } } } diff --git a/src/EFCore.SqlServer/Migrations/Internal/SqlServerMigrationsAnnotationProvider.cs b/src/EFCore.SqlServer/Migrations/Internal/SqlServerMigrationsAnnotationProvider.cs index 3e6a0f1941f..ce571dd8c8e 100644 --- a/src/EFCore.SqlServer/Migrations/Internal/SqlServerMigrationsAnnotationProvider.cs +++ b/src/EFCore.SqlServer/Migrations/Internal/SqlServerMigrationsAnnotationProvider.cs @@ -44,5 +44,78 @@ public override IEnumerable ForRemove(IRelationalModel model) /// public override IEnumerable ForRemove(ITable table) => table.GetAnnotations(); + + /// + public override IEnumerable ForRemove(IUniqueConstraint constraint) + { + if (constraint.Table[SqlServerAnnotationNames.IsTemporal] as bool? == true) + { + yield return new Annotation(SqlServerAnnotationNames.IsTemporal, true); + + yield return new Annotation( + SqlServerAnnotationNames.TemporalPeriodStartColumnName, + constraint.Table[SqlServerAnnotationNames.TemporalPeriodStartColumnName]); + + yield return new Annotation( + SqlServerAnnotationNames.TemporalPeriodEndColumnName, + constraint.Table[SqlServerAnnotationNames.TemporalPeriodEndColumnName]); + + yield return new Annotation( + SqlServerAnnotationNames.TemporalHistoryTableName, + constraint.Table[SqlServerAnnotationNames.TemporalHistoryTableName]); + + yield return new Annotation( + SqlServerAnnotationNames.TemporalHistoryTableSchema, + constraint.Table[SqlServerAnnotationNames.TemporalHistoryTableSchema]); + } + } + + /// + public override IEnumerable ForRemove(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); + } + } + } + + /// + public override IEnumerable ForRename(ITable table) + { + if (table[SqlServerAnnotationNames.IsTemporal] as bool? == true) + { + yield return new Annotation(SqlServerAnnotationNames.IsTemporal, true); + + yield return new Annotation( + SqlServerAnnotationNames.TemporalHistoryTableName, + table[SqlServerAnnotationNames.TemporalHistoryTableName]); + + yield return new Annotation( + SqlServerAnnotationNames.TemporalHistoryTableSchema, + table[SqlServerAnnotationNames.TemporalHistoryTableSchema]); + } + } } } diff --git a/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs b/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs index 95f6cf7f618..7ea9207ed4f 100644 --- a/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs +++ b/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Globalization; @@ -9,6 +10,7 @@ using System.Linq; using System.Text; using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Migrations.Operations; @@ -65,7 +67,7 @@ public class SqlServerMigrationsSqlGenerator : MigrationsSqlGenerator _operations = operations; try { - return base.Generate(operations, model, options); + return base.Generate(RewriteOperations(operations, model), model, options); } finally { @@ -544,7 +546,67 @@ protected override void Generate(RenameSequenceOperation operation, IModel? mode throw new ArgumentException(SqlServerStrings.CannotProduceUnterminatedSQLWithComments(nameof(CreateTableOperation))); } - base.Generate(operation, model, builder, terminate: false); + if (operation[SqlServerAnnotationNames.IsTemporal] as bool? == true) + { + var historyTableSchema = operation[SqlServerAnnotationNames.TemporalHistoryTableSchema] as string ?? model?.GetDefaultSchema(); + + if (historyTableSchema != operation.Schema && historyTableSchema != null) + { + Generate(new EnsureSchemaOperation { Name = historyTableSchema }, model, builder); + } + + var needsExec = historyTableSchema == null; + var subBuilder = needsExec + ? new MigrationCommandListBuilder(Dependencies) + : builder; + + subBuilder + .Append("CREATE TABLE ") + .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name, operation.Schema)) + .AppendLine(" ("); + + using (subBuilder.Indent()) + { + CreateTableColumns(operation, model, subBuilder); + CreateTableConstraints(operation, model, subBuilder); + subBuilder.AppendLine(","); + var startColumnName = operation[SqlServerAnnotationNames.TemporalPeriodStartColumnName] as string; + var endColumnName = operation[SqlServerAnnotationNames.TemporalPeriodEndColumnName] as string; + var start = Dependencies.SqlGenerationHelper.DelimitIdentifier(startColumnName!); + var end = Dependencies.SqlGenerationHelper.DelimitIdentifier(endColumnName!); + subBuilder.AppendLine($"PERIOD FOR SYSTEM_TIME({start}, {end})"); + } + + if (needsExec) + { + subBuilder + .EndCommand(); + + var execBody = subBuilder.GetCommandList().Single().CommandText.Replace("'", "''"); + + builder + .AppendLine("DECLARE @historyTableSchema sysname = SCHEMA_NAME()") + .Append("EXEC(N'") + .Append(execBody); + } + + var historyTableName = operation[SqlServerAnnotationNames.TemporalHistoryTableName] as string; + var historyTable = default(string); + if (needsExec) + { + historyTable = Dependencies.SqlGenerationHelper.DelimitIdentifier(historyTableName!); + builder.Append($") WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + N'].{ historyTable}))')"); + } + else + { + historyTable = Dependencies.SqlGenerationHelper.DelimitIdentifier(historyTableName!, historyTableSchema); + builder.Append($") WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = {historyTable}))"); + } + } + else + { + base.Generate(operation, model, builder, terminate: false); + } var memoryOptimized = IsMemoryOptimized(operation); if (memoryOptimized) @@ -644,6 +706,22 @@ protected override void Generate(RenameSequenceOperation operation, IModel? mode .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator) .EndCommand(suppressTransaction: IsMemoryOptimized(operation, model, operation.Schema, operation.Name)); } + + if (operation[SqlServerAnnotationNames.IsTemporal] as bool? == true) + { + var schema = operation.Schema ?? model?[RelationalAnnotationNames.DefaultSchema] as string; + var historyTableSchema = operation[SqlServerAnnotationNames.TemporalHistoryTableSchema] as string ?? schema; + if (operation[SqlServerAnnotationNames.TemporalHistoryTableName] is string historyTableName) + { + var dropHistoryTableOperation = new DropTableOperation + { + Name = historyTableName, + Schema = historyTableSchema, + }; + + Generate(dropHistoryTableOperation, model, builder, terminate); + } + } } /// @@ -737,7 +815,6 @@ protected override void Generate(RenameSequenceOperation operation, IModel? mode bool terminate = true) { base.Generate(operation, model, builder, terminate: false); - if (terminate) { builder @@ -1214,6 +1291,29 @@ protected override void Generate(AlterTableOperation operation, IModel? model, M .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator) .EndCommand(suppressTransaction: IsMemoryOptimized(operation, model, operation.Schema, operation.Table)); } + + if (operation[SqlServerAnnotationNames.IsTemporal] as bool? == true) + { + var historyTableName = operation[SqlServerAnnotationNames.TemporalHistoryTableName] as string; + var historyTableSchema = operation[SqlServerAnnotationNames.TemporalHistoryTableSchema] as string ?? model?.GetDefaultSchema(); + var periodStartColumnName = operation[SqlServerAnnotationNames.TemporalPeriodStartColumnName] as string; + var periodEndColumnName = operation[SqlServerAnnotationNames.TemporalPeriodEndColumnName] as string; + + // when dropping column, we only need to drop the column from history table as well if that column is not part of the period + // for columns that are part of the period - if we are removing them from the temporal table, it means + // that we are converting back to a regular table, and the history table will be removed anyway + // so we don't need to keep it in sync + if (operation.Name != periodStartColumnName + && operation.Name != periodEndColumnName) + { + Generate(new DropColumnOperation + { + Name = operation.Name, + Table = historyTableName!, + Schema = historyTableSchema + }, model, builder, terminate); + } + } } /// @@ -1471,6 +1571,24 @@ protected override void Generate(UpdateDataOperation operation, IModel? model, M builder.Append(" SPARSE"); } + var periodStartColumnName = operation[SqlServerAnnotationNames.TemporalPeriodStartColumnName] as string; + var periodEndColumnName = operation[SqlServerAnnotationNames.TemporalPeriodEndColumnName] as string; + + if (name == periodStartColumnName + || name == periodEndColumnName) + { + builder.Append(" GENERATED ALWAYS AS ROW "); + + if (name == periodStartColumnName) + { + builder.Append("START"); + } + else + { + builder.Append("END"); + } + } + builder.Append(operation.IsNullable ? " NULL" : " NOT NULL"); DefaultValue(operation.DefaultValue, operation.DefaultValueSql, columnType, builder); @@ -2091,5 +2209,377 @@ private static bool HasDifferences(IEnumerable source, IEnumerable< return count != targetAnnotations.Count; } + + private IReadOnlyList RewriteOperations( + IReadOnlyList migrationOperations, + IModel? model) + { + var operations = new List(); + + var versioningMap = new Dictionary<(string?, string?), (string, string?)>(); + var periodMap = new Dictionary<(string?, string?), (string, string)>(); + + foreach (var operation in migrationOperations) + { + var isTemporal = operation[SqlServerAnnotationNames.IsTemporal] as bool? == true; + if (isTemporal) + { + string? table = null; + string? schema = null; + + if (operation is ITableMigrationOperation tableMigrationOperation) + { + table = tableMigrationOperation.Table; + schema = tableMigrationOperation.Schema; + } + + schema ??= model?.GetDefaultSchema(); + var historyTableName = operation[SqlServerAnnotationNames.TemporalHistoryTableName] as string; + var historyTableSchema = operation[SqlServerAnnotationNames.TemporalHistoryTableSchema] as string ?? model?.GetDefaultSchema(); + var periodStartColumnName = operation[SqlServerAnnotationNames.TemporalPeriodStartColumnName] as string; + var periodEndColumnName = operation[SqlServerAnnotationNames.TemporalPeriodEndColumnName] as string; + + switch (operation) + { + case DropTableOperation dropTableOperation: + DisableVersioning(table!, schema, historyTableName!, historyTableSchema); + operations.Add(operation); + + versioningMap.Remove((table, schema)); + periodMap.Remove((table, schema)); + break; + + case RenameTableOperation renameTableOperation: + DisableVersioning(table!, schema, historyTableName!, historyTableSchema); + operations.Add(operation); + + // since table was renamed, remove old entry and add new entry + // marked as versioning disabled, so we enable it in the end for the new table + versioningMap.Remove((table, schema)); + versioningMap[(renameTableOperation.NewName, renameTableOperation.NewSchema)] = (historyTableName!, historyTableSchema); + + // same thing for disabled system period - remove one associated with old table and add one for the new table + if (periodMap.TryGetValue((table, schema), out var result)) + { + periodMap.Remove((table, schema)); + periodMap[(renameTableOperation.NewName, renameTableOperation.NewSchema)] = result; + } + + break; + + case AlterTableOperation alterTableOperation: + var oldIsTemporal = alterTableOperation.OldTable[SqlServerAnnotationNames.IsTemporal] as bool? == true; + if (!oldIsTemporal) + { + periodMap[(alterTableOperation.Name, alterTableOperation.Schema)] = (periodStartColumnName!, periodEndColumnName!); + versioningMap[(alterTableOperation.Name, alterTableOperation.Schema)] = (historyTableName!, historyTableSchema); + } + else + { + var oldHistoryTableName = alterTableOperation.OldTable[SqlServerAnnotationNames.TemporalHistoryTableName] as string; + var oldHistoryTableSchema = alterTableOperation.OldTable[SqlServerAnnotationNames.TemporalHistoryTableSchema] as string + ?? alterTableOperation.OldTable.Schema + ?? model?[RelationalAnnotationNames.DefaultSchema] as string; + + if (oldHistoryTableName != historyTableName + || oldHistoryTableSchema != historyTableSchema) + { + if (historyTableSchema != null) + { + operations.Add(new EnsureSchemaOperation { Name = historyTableSchema }); + } + + operations.Add(new RenameTableOperation + { + Name = oldHistoryTableName!, + Schema = oldHistoryTableSchema, + NewName = historyTableName, + NewSchema = historyTableSchema + }); + + if (versioningMap.ContainsKey((alterTableOperation.Name, alterTableOperation.Schema))) + { + versioningMap[(alterTableOperation.Name, alterTableOperation.Schema)] = (historyTableName!, historyTableSchema); + } + } + } + + operations.Add(operation); + break; + + case AlterColumnOperation alterColumnOperation: + // if only difference is in temporal annotations being removed or history table changed etc - we can ignore this operation + if (!CanSkipAlterColumnOperation(alterColumnOperation.OldColumn, alterColumnOperation)) + { + // 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) + { + alterColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.IsTemporal); + alterColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.TemporalPeriodStartColumnName); + alterColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.TemporalPeriodEndColumnName); + + // TODO: test what happens if default value just changes (from temporal to temporal) + } + + operations.Add(operation); + } + break; + + case DropPrimaryKeyOperation: + case AddPrimaryKeyOperation: + DisableVersioning(table!, schema, historyTableName!, historyTableSchema); + operations.Add(operation); + break; + + case DropColumnOperation dropColumnOperation: + DisableVersioning(table!, schema, historyTableName!, historyTableSchema); + if (dropColumnOperation.Name == periodStartColumnName + || dropColumnOperation.Name == periodEndColumnName) + { + // period columns can be null here - it doesn't really matter since we are never enabling the period back + // if we remove the period columns, it means we will be dropping the table also or at least convert it back to regular + // which will clear the entry in the periodMap for this table + DisablePeriod(table!, schema, periodStartColumnName!, periodEndColumnName!); + } + + operations.Add(operation); + + break; + + case AddColumnOperation 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 + // but we can't enable period without adding the columns first - chicken and egg + if (addColumnOperation[SqlServerAnnotationNames.IsTemporal] as bool? == true) + { + addColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.IsTemporal); + addColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.TemporalPeriodStartColumnName); + addColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.TemporalPeriodEndColumnName); + + // model differ adds default value, but for period end we need to replace it with the correct one - DateTime.MaxValue + if (addColumnOperation.Name == periodEndColumnName) + { + addColumnOperation.DefaultValue = DateTime.MaxValue; + } + } + + operations.Add(addColumnOperation); + break; + + default: + // CreateTableOperation + // RenameColumnOperation + operations.Add(operation); + break; + } + } + else + { + if (operation is AlterTableOperation alterTableOperation + && alterTableOperation.OldTable[SqlServerAnnotationNames.IsTemporal] as bool? == true) + { + var historyTableName = alterTableOperation.OldTable[SqlServerAnnotationNames.TemporalHistoryTableName] as string; + var historyTableSchema = alterTableOperation.OldTable[SqlServerAnnotationNames.TemporalHistoryTableSchema] as string + ?? alterTableOperation.OldTable.Schema + ?? model?[RelationalAnnotationNames.DefaultSchema] as string; + + var periodStartColumnName = alterTableOperation.OldTable[SqlServerAnnotationNames.TemporalPeriodStartColumnName] as string; + var periodEndColumnName = alterTableOperation.OldTable[SqlServerAnnotationNames.TemporalPeriodEndColumnName] as string; + + DisableVersioning(alterTableOperation.Name, alterTableOperation.Schema, historyTableName!, historyTableSchema); + DisablePeriod(alterTableOperation.Name, alterTableOperation.Schema, periodStartColumnName!, periodEndColumnName!); + + if (historyTableName != null) + { + operations.Add( + new DropTableOperation + { + Name = historyTableName, + Schema = alterTableOperation.OldTable.Schema + }); + } + + operations.Add(operation); + + // when we disable versioning and period earlier, we marked it to be re-enabled + // since table is no longer temporal we don't need to do that anymore + versioningMap.Remove((alterTableOperation.Name, alterTableOperation.Schema)); + periodMap.Remove((alterTableOperation.Name, alterTableOperation.Schema)); + } + else if (operation is AlterColumnOperation alterColumnOperation) + { + // if only difference is in temporal annotations being removed or history table changed etc - we can ignore this operation + if (alterColumnOperation.OldColumn?[SqlServerAnnotationNames.IsTemporal] as bool? != true + || !CanSkipAlterColumnOperation(alterColumnOperation.OldColumn, alterColumnOperation)) + { + operations.Add(operation); + } + } + else + { + operations.Add(operation); + } + } + } + + foreach (var periodMapEntry in periodMap) + { + EnablePeriod(periodMapEntry.Key.Item1!, periodMapEntry.Key.Item2, periodMapEntry.Value.Item1, periodMapEntry.Value.Item2); + } + + foreach (var versioningMapEntry in versioningMap) + { + EnableVersioning(versioningMapEntry.Key.Item1!, versioningMapEntry.Key.Item2, versioningMapEntry.Value.Item1, versioningMapEntry.Value.Item2); + } + + return operations; + + void DisableVersioning(string table, string? schema, string historyTableName, string? historyTableSchema) + { + if (!versioningMap.TryGetValue((table, schema), out var result)) + { + versioningMap[(table, schema)] = (historyTableName, historyTableSchema); + + operations.Add( + new SqlOperation + { + Sql = new StringBuilder() + .Append("ALTER TABLE ") + .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(table, schema)) + .AppendLine(" SET (SYSTEM_VERSIONING = OFF)") + .ToString() + }); + } + } + + void EnableVersioning(string table, string? schema, string historyTableName, string? historyTableSchema) + { + var stringBuilder = new StringBuilder(); + + if (historyTableSchema == null) + { + // need to run command using EXEC to inject default schema + stringBuilder.AppendLine("DECLARE @historyTableSchema sysname = SCHEMA_NAME()"); + stringBuilder.Append("EXEC(N'"); + } + + var historyTable = default(string); + if (historyTableSchema != null) + { + historyTable = Dependencies.SqlGenerationHelper.DelimitIdentifier(historyTableName, historyTableSchema); + } + else + { + historyTable = Dependencies.SqlGenerationHelper.DelimitIdentifier(historyTableName); + } + + stringBuilder + .Append("ALTER TABLE ") + .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(table, schema)); + + if (historyTableSchema != null) + { + stringBuilder.AppendLine($" SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = {historyTable}))"); + } + else + { + stringBuilder.AppendLine($" SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + '].{historyTable}))')"); + } + + operations.Add( + new SqlOperation + { + Sql = stringBuilder.ToString() + }); + } + + void DisablePeriod(string table, string? schema, string periodStartColumnName, string periodEndColumnName) + { + if (!periodMap.TryGetValue((table, schema), out var result)) + { + periodMap[(table, schema)] = (periodStartColumnName, periodEndColumnName); + + operations.Add( + new SqlOperation + { + Sql = new StringBuilder() + .Append("ALTER TABLE ") + .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(table, schema)) + .AppendLine(" DROP PERIOD FOR SYSTEM_TIME") + .ToString() + }); + } + } + + void EnablePeriod(string table, string? schema, string periodStartColumnName, string periodEndColumnName) + { + operations.Add( + new SqlOperation + { + Sql = new StringBuilder() + .Append("ALTER TABLE ") + .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(table, schema)) + .Append(" ADD PERIOD FOR SYSTEM_TIME (") + .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(periodStartColumnName)) + .Append(", ") + .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(periodEndColumnName)) + .Append(")") + .ToString() + }); + } + + static bool CanSkipAlterColumnOperation(ColumnOperation first, ColumnOperation second) + => ColumnPropertiesAreTheSame(first, second) + && ColumnOperationsOnlyDifferByTemporalTableAnnotation(first, second) + && ColumnOperationsOnlyDifferByTemporalTableAnnotation(second, first); + + static bool ColumnPropertiesAreTheSame(ColumnOperation first, ColumnOperation second) + => first.ClrType == second.ClrType + && first.Collation == second.Collation + && first.ColumnType == second.ColumnType + && first.Comment == second.Comment + && first.ComputedColumnSql == second.ComputedColumnSql + && Equals(first.DefaultValue, second.DefaultValue) + && first.DefaultValueSql == second.DefaultValueSql + && first.IsDestructiveChange == second.IsDestructiveChange + && first.IsFixedLength == second.IsFixedLength + && first.IsNullable == second.IsNullable + && first.IsReadOnly == second.IsReadOnly + && first.IsRowVersion == second.IsRowVersion + && first.IsStored == second.IsStored + && first.IsUnicode == second.IsUnicode + && first.MaxLength == second.MaxLength + && first.Precision == second.Precision + && first.Scale == second.Scale; + + static bool ColumnOperationsOnlyDifferByTemporalTableAnnotation(ColumnOperation first, ColumnOperation second) + { + var unmatched = first.GetAnnotations().ToList(); + foreach (var annotation in second.GetAnnotations()) + { + var index = unmatched.FindIndex( + a => a.Name == annotation.Name && StructuralComparisons.StructuralEqualityComparer.Equals(a.Value, annotation.Value)); + if (index == -1) + { + continue; + } + + unmatched.RemoveAt(index); + } + + return unmatched.All(a => a.Name == SqlServerAnnotationNames.IsTemporal + || a.Name == SqlServerAnnotationNames.TemporalHistoryTableName + || a.Name == SqlServerAnnotationNames.TemporalHistoryTableSchema + || a.Name == SqlServerAnnotationNames.TemporalPeriodStartPropertyName + || a.Name == SqlServerAnnotationNames.TemporalPeriodEndPropertyName + || a.Name == SqlServerAnnotationNames.TemporalPeriodStartColumnName + || a.Name == SqlServerAnnotationNames.TemporalPeriodEndColumnName); + } + } } } diff --git a/src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs b/src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs index 2f78d646cda..b37ba30cfed 100644 --- a/src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs +++ b/src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs @@ -225,6 +225,94 @@ public static string SequenceBadType(object? property, object? entityType, objec public static string TransientExceptionDetected => GetString("TransientExceptionDetected"); + /// + /// Only root entity type should be marked as temporal. Entity type: '{entityType}'. + /// + public static string TemporalOnlyOnRoot(object? entityType) + => string.Format( + GetString("TemporalOnlyOnRoot", nameof(entityType)), + entityType); + + /// + /// Temporal tables are only supported for entities using Table-Per-Hierarchy inheritance mapping. Entity type: '{entityType}'. + /// + public static string TemporalOnlySupportedForTPH(object? entityType) + => string.Format( + GetString("TemporalOnlySupportedForTPH", nameof(entityType)), + entityType); + + /// + /// Temporal tables are not supported for table splitting scenario. Table: '{table}'. + /// + public static string TemporalNotSupportedForTableSplitting(object? table) + => string.Format( + GetString("TemporalNotSupportedForTableSplitting", nameof(table)), + table); + + /// + /// Entity type '{entityType}' mapped to temporal table must have a period start and a period end property. + /// + public static string TemporalMustDefinePeriodProperties(object? entityType) + => string.Format( + GetString("TemporalMustDefinePeriodProperties", nameof(entityType)), + entityType); + + /// + /// Entity type '{entityType}' mapped to temporal table does not contain the expected period property: '{propertyName}'. + /// + public static string TemporalExpectedPeriodPropertyNotFound(object? entityType, object? propertyName) + => string.Format( + GetString("TemporalExpectedPeriodPropertyNotFound", nameof(entityType), nameof(propertyName)), + entityType, propertyName); + + /// + /// Period property '{entityType}.{propertyName}' must be a shadow property. + /// + public static string TemporalPeriodPropertyMustBeInShadowState(object? entityType, object? propertyName) + => string.Format( + GetString("TemporalPeriodPropertyMustBeInShadowState", nameof(entityType), nameof(propertyName)), + entityType, propertyName); + + /// + /// Period property '{entityType}.{propertyName}' must be non-nullable and of type '{dateTimeType}'. + /// + public static string TemporalPeriodPropertyMustBeNonNullableDateTime(object? entityType, object? propertyName, object? dateTimeType) + => string.Format( + GetString("TemporalPeriodPropertyMustBeNonNullableDateTime", nameof(entityType), nameof(propertyName), nameof(dateTimeType)), + entityType, propertyName, dateTimeType); + + /// + /// Period property '{entityType}.{propertyName}' must be mapped to a column of type '{columnType}'. + /// + public static string TemporalPeriodPropertyMustBeMappedToDatetime2(object? entityType, object? propertyName, object? columnType) + => string.Format( + GetString("TemporalPeriodPropertyMustBeMappedToDatetime2", nameof(entityType), nameof(propertyName), nameof(columnType)), + entityType, propertyName, columnType); + + /// + /// Period property '{entityType}.{propertyName}' can't have a default value specified. + /// + public static string TemporalPeriodPropertyCantHaveDefaultValue(object? entityType, object? propertyName) + => string.Format( + GetString("TemporalPeriodPropertyCantHaveDefaultValue", nameof(entityType), nameof(propertyName)), + entityType, propertyName); + + /// + /// Property '{entityType}.{propertyName}' is mapped to the period column and must have ValueGenerated set to '{valueGeneratedValue}'. + /// + public static string TemporalPropertyMappedToPeriodColumnMustBeValueGeneratedOnAddOrUpdate(object? entityType, object? propertyName, object? valueGeneratedValue) + => string.Format( + GetString("TemporalPropertyMappedToPeriodColumnMustBeValueGeneratedOnAddOrUpdate", nameof(entityType), nameof(propertyName), nameof(valueGeneratedValue)), + entityType, propertyName, valueGeneratedValue); + + /// + /// Property '{entityType}.{propertyName}' is mapped to the period column and can't have default value specified. + /// + public static string TemporalPropertyMappedToPeriodColumnCantHaveDefaultValue(object? entityType, object? propertyName) + => string.Format( + GetString("TemporalPropertyMappedToPeriodColumnCantHaveDefaultValue", nameof(entityType), nameof(propertyName)), + entityType, propertyName); + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name)!; diff --git a/src/EFCore.SqlServer/Properties/SqlServerStrings.resx b/src/EFCore.SqlServer/Properties/SqlServerStrings.resx index 4dfc644ac23..0d791f4e64e 100644 --- a/src/EFCore.SqlServer/Properties/SqlServerStrings.resx +++ b/src/EFCore.SqlServer/Properties/SqlServerStrings.resx @@ -274,4 +274,49 @@ An exception has been raised that is likely due to a transient failure. Consider enabling transient error resiliency by adding 'EnableRetryOnFailure' to the 'UseSqlServer' call. + + + Only root entity type should be marked as temporal. Entity type: '{entityType}'. + + + + Temporal tables are only supported for entities using Table-Per-Hierarchy inheritance mapping. Entity type: '{entityType}'. + + + + Temporal tables are not supported for table splitting scenario. Table: '{table}'. + + + + Entity type '{entityType}' mapped to temporal table must have a period start and a period end property. + + + + Entity type '{entityType}' mapped to temporal table does not contain the expected period property: '{propertyName}'. + + + + Period property '{entityType}.{propertyName}' must be a shadow property. + + + + Period property '{entityType}.{propertyName}' must be non-nullable and of type '{dateTimeType}'. + + + + Period property '{entityType}.{propertyName}' must be mapped to a column of type '{columnType}'. + + + + Period property '{entityType}.{propertyName}' can't have a default value specified. + + + + Property '{entityType}.{propertyName}' is mapped to the period column and must have ValueGenerated set to '{valueGeneratedValue}'. + + + + Property '{entityType}.{propertyName}' is mapped to the period column and can't have default value specified. + + \ No newline at end of file diff --git a/src/EFCore.SqlServer/Query/Internal/SearchConditionConvertingExpressionVisitor.cs b/src/EFCore.SqlServer/Query/Internal/SearchConditionConvertingExpressionVisitor.cs index 48e83186e45..3a5298219b6 100644 --- a/src/EFCore.SqlServer/Query/Internal/SearchConditionConvertingExpressionVisitor.cs +++ b/src/EFCore.SqlServer/Query/Internal/SearchConditionConvertingExpressionVisitor.cs @@ -7,7 +7,6 @@ using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Query; using Microsoft.EntityFrameworkCore.Query.SqlExpressions; -using Microsoft.EntityFrameworkCore.SqlServer.Internal; using Microsoft.EntityFrameworkCore.Utilities; namespace Microsoft.EntityFrameworkCore.SqlServer.Query.Internal diff --git a/src/EFCore.SqlServer/Scaffolding/Internal/SqlServerDatabaseModelFactory.cs b/src/EFCore.SqlServer/Scaffolding/Internal/SqlServerDatabaseModelFactory.cs index 3a464834155..39b6d177f95 100644 --- a/src/EFCore.SqlServer/Scaffolding/Internal/SqlServerDatabaseModelFactory.cs +++ b/src/EFCore.SqlServer/Scaffolding/Internal/SqlServerDatabaseModelFactory.cs @@ -492,6 +492,16 @@ private static string EscapeLiteral(string s) [t].[is_memory_optimized]"; } + if (supportsTemporalTable) + { + commandText += @", + [t].[temporal_type], + (SELECT [t2].[name] FROM [sys].[tables] AS t2 WHERE [t2].[object_id] = [t].[history_table_id]) AS [history_table_name], + (SELECT SCHEMA_NAME([t2].[schema_id]) FROM [sys].[tables] AS t2 WHERE [t2].[object_id] = [t].[history_table_id]) AS [history_table_schema], + (SELECT [c].[name] FROM [sys].[columns] as [c] WHERE [c].[object_id] = [t].[object_id] AND [c].[generated_always_type] = 1) as [period_start_column], + (SELECT [c].[name] FROM [sys].[columns] as [c] WHERE [c].[object_id] = [t].[object_id] AND [c].[generated_always_type] = 2) as [period_end_column]"; + } + commandText += @" FROM [sys].[tables] AS [t] LEFT JOIN [sys].[extended_properties] AS [e] ON [e].[major_id] = [t].[object_id] AND [e].[minor_id] = 0 AND [e].[class] = 1 AND [e].[name] = 'MS_Description'"; @@ -540,6 +550,16 @@ private static string EscapeLiteral(string s) CAST(0 AS bit) AS [is_memory_optimized]"; } + if (supportsTemporalTable) + { + viewCommandText += @", + 1 AS [temporal_type], + NULL AS [history_table_name], + NULL AS [history_table_schema], + NULL AS [period_start_column], + NULL AS [period_end_column]"; + } + viewCommandText += @" FROM [sys].[views] AS [v] LEFT JOIN [sys].[extended_properties] AS [e] ON [e].[major_id] = [v].[object_id] AND [e].[minor_id] = 0 AND [e].[class] = 1 AND [e].[name] = 'MS_Description'"; @@ -587,6 +607,26 @@ private static string EscapeLiteral(string s) } } + if (supportsTemporalTable) + { + if (reader.GetValueOrDefault("temporal_type") == 2) + { + table[SqlServerAnnotationNames.IsTemporal] = true; + + var historyTableName = reader.GetValueOrDefault("history_table_name"); + table[SqlServerAnnotationNames.TemporalHistoryTableName] = historyTableName; + + var historyTableSchema = reader.GetValueOrDefault("history_table_schema"); + table[SqlServerAnnotationNames.TemporalHistoryTableSchema] = historyTableSchema; + + var periodStartColumnName = reader.GetValueOrDefault("period_start_column"); + table[SqlServerAnnotationNames.TemporalPeriodStartColumnName] = periodStartColumnName; + + var periodEndColumnName = reader.GetValueOrDefault("period_end_column"); + table[SqlServerAnnotationNames.TemporalPeriodEndColumnName] = periodEndColumnName; + } + } + tables.Add(table); } } @@ -629,8 +669,15 @@ private static string EscapeLiteral(string s) [cc].[is_persisted] AS [computed_is_persisted], CAST([e].[value] AS nvarchar(MAX)) AS [comment], [c].[collation_name], - [c].[is_sparse] -FROM + [c].[is_sparse]"; + + if (SupportsTemporalTable()) + { + commandText += @", + [c].[generated_always_type]"; + } + + commandText += @"FROM ( SELECT[v].[name], [v].[object_id], [v].[schema_id] FROM [sys].[views] v WHERE "; @@ -692,6 +739,7 @@ UNION ALL var comment = dataRecord.GetValueOrDefault("comment"); var collation = dataRecord.GetValueOrDefault("collation_name"); var isSparse = dataRecord.GetValueOrDefault("is_sparse"); + var generatedAlwaysType = SupportsTemporalTable() ? dataRecord.GetValueOrDefault("generated_always_type") : 0; _logger.ColumnFound( DisplayName(tableSchema, tableName), diff --git a/src/EFCore/Design/NestedClosureCodeFragment.cs b/src/EFCore/Design/NestedClosureCodeFragment.cs index 25c99dd2912..a8a66993d17 100644 --- a/src/EFCore/Design/NestedClosureCodeFragment.cs +++ b/src/EFCore/Design/NestedClosureCodeFragment.cs @@ -1,6 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System.Collections.Generic; using Microsoft.EntityFrameworkCore.Utilities; namespace Microsoft.EntityFrameworkCore.Design @@ -21,7 +22,21 @@ public NestedClosureCodeFragment(string parameter, MethodCallCodeFragment method Check.NotNull(methodCall, nameof(methodCall)); Parameter = parameter; - MethodCall = methodCall; + MethodCalls = new List { methodCall }; + } + + /// + /// Initializes a new instance of the class. + /// + /// The nested closure parameter's name. + /// The list of method calls used as the body of the nested closure. + public NestedClosureCodeFragment(string parameter, IReadOnlyList methodCalls) + { + Check.NotEmpty(parameter, nameof(parameter)); + Check.NotEmpty(methodCalls, nameof(methodCalls)); + + Parameter = parameter; + MethodCalls = methodCalls; } /// @@ -31,9 +46,9 @@ public NestedClosureCodeFragment(string parameter, MethodCallCodeFragment method public virtual string Parameter { get; } /// - /// Gets the method call used as the body of the nested closure. + /// Gets the method calls used as the body of the nested closure. /// /// The method call. - public virtual MethodCallCodeFragment MethodCall { get; } + public virtual IReadOnlyList MethodCalls { get; } } } diff --git a/src/EFCore/Query/ShapedQueryCompilingExpressionVisitor.cs b/src/EFCore/Query/ShapedQueryCompilingExpressionVisitor.cs index 8c9ca6beb07..8dec328db35 100644 --- a/src/EFCore/Query/ShapedQueryCompilingExpressionVisitor.cs +++ b/src/EFCore/Query/ShapedQueryCompilingExpressionVisitor.cs @@ -579,6 +579,7 @@ private Expression ProcessEntityShaper(EntityShaperExpression entityShaperExpres var valueBufferExpression = Expression.Call( materializationContextVariable, MaterializationContext.GetValueBufferMethod); var shadowProperties = concreteEntityType.GetProperties().Where(p => p.IsShadowProperty()); + blockExpressions.Add( Expression.Assign( shadowValuesVariable, diff --git a/test/EFCore.Design.Tests/Migrations/ModelSnapshotSqlServerTest.cs b/test/EFCore.Design.Tests/Migrations/ModelSnapshotSqlServerTest.cs index 6eac43f0675..2a99c04d80c 100644 --- a/test/EFCore.Design.Tests/Migrations/ModelSnapshotSqlServerTest.cs +++ b/test/EFCore.Design.Tests/Migrations/ModelSnapshotSqlServerTest.cs @@ -1793,6 +1793,65 @@ public virtual void Discriminator_of_enum_to_string() }); } + [ConditionalFact] + public virtual void Temporal_table_information_is_stored_in_snapshot() + { + Test( + builder => builder.Entity().ToTable(tb => tb.IsTemporal(ttb => + { + ttb.WithHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start").HasColumnName("PeriodStart"); + ttb.HasPeriodEnd("End").HasColumnName("PeriodEnd"); + })), + AddBoilerPlate( + GetHeading() + + @" + modelBuilder.Entity(""Microsoft.EntityFrameworkCore.Migrations.ModelSnapshotSqlServerTest+EntityWithStringProperty"", b => + { + b.Property(""Id"") + .ValueGeneratedOnAdd() + .HasColumnType(""int"") + .HasAnnotation(""SqlServer:ValueGenerationStrategy"", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property(""End"") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType(""datetime2"") + .HasColumnName(""PeriodEnd""); + + b.Property(""Name"") + .HasColumnType(""nvarchar(max)""); + + b.Property(""Start"") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType(""datetime2"") + .HasColumnName(""PeriodStart""); + + b.HasKey(""Id""); + + b.ToTable(""EntityWithStringProperty""); + + b + .ToTable(tb => tb.IsTemporal(ttb => +{ + ttb.WithHistoryTable(""HistoryTable""); + ttb.HasPeriodStart(""Start"").HasColumnName(""PeriodStart""); + ttb.HasPeriodEnd(""End"").HasColumnName(""PeriodEnd""); +} +)); + });", usingSystem: true), + o => + { + var temporalEntity = o.FindEntityType("Microsoft.EntityFrameworkCore.Migrations.ModelSnapshotSqlServerTest+EntityWithStringProperty"); + var annotations = temporalEntity.GetAnnotations().ToList(); + + Assert.Equal(6, annotations.Count); + Assert.Contains(annotations, a => a.Name == SqlServerAnnotationNames.IsTemporal && a.Value as bool? == true); + Assert.Contains(annotations, a => a.Name == SqlServerAnnotationNames.TemporalHistoryTableName && a.Value as string == "HistoryTable"); + Assert.Contains(annotations, a => a.Name == SqlServerAnnotationNames.TemporalPeriodStartPropertyName && a.Value as string == "Start"); + Assert.Contains(annotations, a => a.Name == SqlServerAnnotationNames.TemporalPeriodEndPropertyName && a.Value as string == "End"); + }); + } + #endregion #region Owned types diff --git a/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.cs b/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.cs index d783ed3cab1..632b8741840 100644 --- a/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.cs +++ b/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.cs @@ -1855,7 +1855,7 @@ public void Passes_for_unnamed_index_with_all_properties_not_mapped_to_any_table { var modelBuilder = CreateConventionalModelBuilder(); - modelBuilder.Entity().ToTable(null); + modelBuilder.Entity().ToTable((string)null); modelBuilder.Entity().HasIndex(nameof(Animal.Id), nameof(Animal.Name)); var definition = RelationalResources @@ -1874,7 +1874,7 @@ public void Passes_for_named_index_with_all_properties_not_mapped_to_any_table() { var modelBuilder = CreateConventionalModelBuilder(); - modelBuilder.Entity().ToTable(null); + modelBuilder.Entity().ToTable((string)null); modelBuilder.Entity() .HasIndex( new[] { nameof(Animal.Id), nameof(Animal.Name) }, @@ -1897,7 +1897,7 @@ public void Detects_mix_of_index_property_mapped_and_not_mapped_to_any_table_unm { var modelBuilder = CreateConventionalModelBuilder(); - modelBuilder.Entity().ToTable(null); + modelBuilder.Entity().ToTable((string)null); modelBuilder.Entity().ToTable("Cats"); modelBuilder.Entity().HasIndex(nameof(Animal.Name), nameof(Cat.Identity)); @@ -1918,7 +1918,7 @@ public void Detects_mix_of_index_property_mapped_and_not_mapped_to_any_table_map { var modelBuilder = CreateConventionalModelBuilder(); - modelBuilder.Entity().ToTable(null); + modelBuilder.Entity().ToTable((string)null); modelBuilder.Entity().ToTable("Cats"); modelBuilder.Entity() .HasIndex( diff --git a/test/EFCore.Relational.Tests/ModelBuilding/RelationalModelBuilderGenericTestBase.cs b/test/EFCore.Relational.Tests/ModelBuilding/RelationalModelBuilderGenericTestBase.cs index 5759b9c3070..e497018cfa6 100644 --- a/test/EFCore.Relational.Tests/ModelBuilding/RelationalModelBuilderGenericTestBase.cs +++ b/test/EFCore.Relational.Tests/ModelBuilding/RelationalModelBuilderGenericTestBase.cs @@ -1,6 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata.Builders; // ReSharper disable InconsistentNaming @@ -14,7 +15,7 @@ public abstract class TestTableBuilder public abstract TestTableBuilder ExcludeFromMigrations(bool excluded = true); } - public class GenericTestTableBuilder : TestTableBuilder + public class GenericTestTableBuilder : TestTableBuilder, IInfrastructure> where TEntity : class { public GenericTestTableBuilder(TableBuilder tableBuilder) @@ -24,6 +25,8 @@ public GenericTestTableBuilder(TableBuilder tableBuilder) protected TableBuilder TableBuilder { get; } + public TableBuilder Instance => TableBuilder; + protected virtual TestTableBuilder Wrap(TableBuilder tableBuilder) => new GenericTestTableBuilder(tableBuilder); @@ -31,7 +34,7 @@ public override TestTableBuilder ExcludeFromMigrations(bool excluded = => Wrap(TableBuilder.ExcludeFromMigrations(excluded)); } - public class NonGenericTestTableBuilder : TestTableBuilder + public class NonGenericTestTableBuilder : TestTableBuilder, IInfrastructure where TEntity : class { public NonGenericTestTableBuilder(TableBuilder tableBuilder) @@ -41,6 +44,8 @@ public NonGenericTestTableBuilder(TableBuilder tableBuilder) protected TableBuilder TableBuilder { get; } + public TableBuilder Instance => TableBuilder; + protected virtual TestTableBuilder Wrap(TableBuilder tableBuilder) => new NonGenericTestTableBuilder(tableBuilder); diff --git a/test/EFCore.Relational.Tests/RelationalApiConsistencyTest.cs b/test/EFCore.Relational.Tests/RelationalApiConsistencyTest.cs index 9fff29b0c7e..baa23004753 100644 --- a/test/EFCore.Relational.Tests/RelationalApiConsistencyTest.cs +++ b/test/EFCore.Relational.Tests/RelationalApiConsistencyTest.cs @@ -194,7 +194,10 @@ public override { typeof(IMutableSequence).GetMethod("set_ClrType"), typeof(RelationalEntityTypeBuilderExtensions).GetMethod( - nameof(RelationalEntityTypeBuilderExtensions.ExcludeTableFromMigrations)) + nameof(RelationalEntityTypeBuilderExtensions.ExcludeTableFromMigrations)), + typeof(RelationalEntityTypeBuilderExtensions).GetMethod( + nameof(RelationalEntityTypeBuilderExtensions.ToTable), + new Type[] { typeof(EntityTypeBuilder), typeof(Action) }) }; public override HashSet AsyncMethodExceptions { get; } = new() diff --git a/test/EFCore.Specification.Tests/ApiConsistencyTestBase.cs b/test/EFCore.Specification.Tests/ApiConsistencyTestBase.cs index 05aceeafe9c..aaea7c48144 100644 --- a/test/EFCore.Specification.Tests/ApiConsistencyTestBase.cs +++ b/test/EFCore.Specification.Tests/ApiConsistencyTestBase.cs @@ -481,7 +481,10 @@ private string ValidateConventionBuilderMethods(IReadOnlyList method ? method.Name[3..] : method.Name.StartsWith("To", StringComparison.Ordinal) ? method.Name[2..] - : method.Name); + : method.Name.StartsWith("With", StringComparison.Ordinal) + ? method.Name[4..] + : method.Name); + if (!methodLookup.TryGetValue(expectedName, out var canSetMethod)) { return $"{declaringType.Name} expected to have a {expectedName} method"; diff --git a/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs index cfde56958fe..26495b51fb0 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs @@ -6,7 +6,6 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Scaffolding; using Microsoft.EntityFrameworkCore.Scaffolding.Metadata.Internal; using Microsoft.EntityFrameworkCore.SqlServer.Internal; @@ -1853,6 +1852,1804 @@ public override async Task UpdateDataOperation_multiple_columns() SELECT @@ROWCOUNT;"); } + [ConditionalFact] + public virtual async Task Create_temporal_table_default_column_mappings_and_default_history_table() + { + await Test( + builder => { }, + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.Property("SystemTimeStart").ValueGeneratedOnAddOrUpdate(); + e.Property("SystemTimeEnd").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + + e.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.HasPeriodStart("SystemTimeStart"); + ttb.HasPeriodEnd("SystemTimeEnd"); + })); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customer", table.Name); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("CustomerHistory", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + Assert.Equal("SystemTimeStart", table[SqlServerAnnotationNames.TemporalPeriodStartColumnName]); + Assert.Equal("SystemTimeEnd", table[SqlServerAnnotationNames.TemporalPeriodEndColumnName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("Name", c.Name), + c => Assert.Equal("SystemTimeEnd", c.Name), + c => Assert.Equal("SystemTimeStart", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"DECLARE @historyTableSchema sysname = SCHEMA_NAME() +EXEC(N'CREATE TABLE [Customer] ( + [Id] int NOT NULL, + [Name] nvarchar(max) NULL, + [SystemTimeEnd] datetime2 GENERATED ALWAYS AS ROW END NOT NULL, + [SystemTimeStart] datetime2 GENERATED ALWAYS AS ROW START NOT NULL, + CONSTRAINT [PK_Customer] PRIMARY KEY ([Id]), + PERIOD FOR SYSTEM_TIME([SystemTimeStart], [SystemTimeEnd]) +) WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + N'].[CustomerHistory]))');"); + } + + [ConditionalFact] + public virtual async Task Create_temporal_table_custom_column_mappings_and_default_history_table() + { + await Test( + builder => { }, + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.Property("SystemTimeStart").ValueGeneratedOnAddOrUpdate(); + e.Property("SystemTimeEnd").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + + e.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.HasPeriodStart("SystemTimeStart").HasColumnName("Start"); + ttb.HasPeriodEnd("SystemTimeEnd").HasColumnName("End"); + })); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customer", table.Name); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("CustomerHistory", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + Assert.Equal("Start", table[SqlServerAnnotationNames.TemporalPeriodStartColumnName]); + Assert.Equal("End", table[SqlServerAnnotationNames.TemporalPeriodEndColumnName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("Name", c.Name), + c => Assert.Equal("End", c.Name), + c => Assert.Equal("Start", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"DECLARE @historyTableSchema sysname = SCHEMA_NAME() +EXEC(N'CREATE TABLE [Customer] ( + [Id] int NOT NULL, + [Name] nvarchar(max) NULL, + [End] datetime2 GENERATED ALWAYS AS ROW END NOT NULL, + [Start] datetime2 GENERATED ALWAYS AS ROW START NOT NULL, + CONSTRAINT [PK_Customer] PRIMARY KEY ([Id]), + PERIOD FOR SYSTEM_TIME([Start], [End]) +) WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + N'].[CustomerHistory]))');"); + } + + [ConditionalFact] + public virtual async Task Create_temporal_table_default_column_mappings_and_custom_history_table() + { + await Test( + builder => { }, + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.Property("SystemTimeStart").ValueGeneratedOnAddOrUpdate(); + e.Property("SystemTimeEnd").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + + e.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.WithHistoryTable("HistoryTable"); + ttb.HasPeriodStart("SystemTimeStart"); + ttb.HasPeriodEnd("SystemTimeEnd"); + })); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customer", table.Name); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("SystemTimeStart", table[SqlServerAnnotationNames.TemporalPeriodStartColumnName]); + Assert.Equal("SystemTimeEnd", table[SqlServerAnnotationNames.TemporalPeriodEndColumnName]); + 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("SystemTimeEnd", c.Name), + c => Assert.Equal("SystemTimeStart", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"DECLARE @historyTableSchema sysname = SCHEMA_NAME() +EXEC(N'CREATE TABLE [Customer] ( + [Id] int NOT NULL, + [Name] nvarchar(max) NULL, + [SystemTimeEnd] datetime2 GENERATED ALWAYS AS ROW END NOT NULL, + [SystemTimeStart] datetime2 GENERATED ALWAYS AS ROW START NOT NULL, + CONSTRAINT [PK_Customer] PRIMARY KEY ([Id]), + PERIOD FOR SYSTEM_TIME([SystemTimeStart], [SystemTimeEnd]) +) WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + N'].[HistoryTable]))');"); + } + + [ConditionalFact] + public virtual async Task Create_temporal_table_with_explicitly_defined_schema() + { + await Test( + builder => { }, + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.Property("SystemTimeStart").ValueGeneratedOnAddOrUpdate(); + e.Property("SystemTimeEnd").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + + e.ToTable("Customers", "mySchema", tb => tb.IsTemporal(ttb => + { + ttb.HasPeriodStart("SystemTimeStart"); + ttb.HasPeriodEnd("SystemTimeEnd"); + })); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customers", table.Name); + Assert.Equal("mySchema", table.Schema); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("CustomerHistory", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + Assert.Equal("SystemTimeStart", table[SqlServerAnnotationNames.TemporalPeriodStartColumnName]); + Assert.Equal("SystemTimeEnd", table[SqlServerAnnotationNames.TemporalPeriodEndColumnName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("Name", c.Name), + c => Assert.Equal("SystemTimeEnd", c.Name), + c => Assert.Equal("SystemTimeStart", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"IF SCHEMA_ID(N'mySchema') IS NULL EXEC(N'CREATE SCHEMA [mySchema];');", + // + @"CREATE TABLE [mySchema].[Customers] ( + [Id] int NOT NULL, + [Name] nvarchar(max) NULL, + [SystemTimeEnd] datetime2 GENERATED ALWAYS AS ROW END NOT NULL, + [SystemTimeStart] datetime2 GENERATED ALWAYS AS ROW START NOT NULL, + CONSTRAINT [PK_Customers] PRIMARY KEY ([Id]), + PERIOD FOR SYSTEM_TIME([SystemTimeStart], [SystemTimeEnd]) +) WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [mySchema].[CustomerHistory]));"); + } + + [ConditionalFact] + public virtual async Task Create_temporal_table_with_default_schema_for_model_changed_and_no_explicit_table_schema_provided() + { + await Test( + builder => { }, + builder => + { + builder.HasDefaultSchema("myDefaultSchema"); + builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.Property("SystemTimeStart").ValueGeneratedOnAddOrUpdate(); + e.Property("SystemTimeEnd").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + + e.ToTable("Customers", tb => tb.IsTemporal(ttb => + { + ttb.HasPeriodStart("SystemTimeStart"); + ttb.HasPeriodEnd("SystemTimeEnd"); + })); + }); + }, + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customers", table.Name); + Assert.Equal("myDefaultSchema", table.Schema); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("CustomerHistory", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + Assert.Equal("SystemTimeStart", table[SqlServerAnnotationNames.TemporalPeriodStartColumnName]); + Assert.Equal("SystemTimeEnd", table[SqlServerAnnotationNames.TemporalPeriodEndColumnName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("Name", c.Name), + c => Assert.Equal("SystemTimeEnd", c.Name), + c => Assert.Equal("SystemTimeStart", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"IF SCHEMA_ID(N'myDefaultSchema') IS NULL EXEC(N'CREATE SCHEMA [myDefaultSchema];');", + // + @"CREATE TABLE [myDefaultSchema].[Customers] ( + [Id] int NOT NULL, + [Name] nvarchar(max) NULL, + [SystemTimeEnd] datetime2 GENERATED ALWAYS AS ROW END NOT NULL, + [SystemTimeStart] datetime2 GENERATED ALWAYS AS ROW START NOT NULL, + CONSTRAINT [PK_Customers] PRIMARY KEY ([Id]), + PERIOD FOR SYSTEM_TIME([SystemTimeStart], [SystemTimeEnd]) +) WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [myDefaultSchema].[CustomerHistory]));"); + } + + [ConditionalFact] + public virtual async Task Create_temporal_table_with_default_schema_for_model_changed_and_explicit_table_schema_provided() + { + await Test( + builder => { }, + builder => + { + builder.HasDefaultSchema("myDefaultSchema"); + builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.Property("SystemTimeStart").ValueGeneratedOnAddOrUpdate(); + e.Property("SystemTimeEnd").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + + e.ToTable("Customers", "mySchema", tb => tb.IsTemporal(ttb => + { + ttb.HasPeriodStart("SystemTimeStart"); + ttb.HasPeriodEnd("SystemTimeEnd"); + })); + }); + }, + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customers", table.Name); + Assert.Equal("mySchema", table.Schema); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("CustomerHistory", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + Assert.Equal("SystemTimeStart", table[SqlServerAnnotationNames.TemporalPeriodStartColumnName]); + Assert.Equal("SystemTimeEnd", table[SqlServerAnnotationNames.TemporalPeriodEndColumnName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("Name", c.Name), + c => Assert.Equal("SystemTimeEnd", c.Name), + c => Assert.Equal("SystemTimeStart", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"IF SCHEMA_ID(N'mySchema') IS NULL EXEC(N'CREATE SCHEMA [mySchema];');", + // + @"CREATE TABLE [mySchema].[Customers] ( + [Id] int NOT NULL, + [Name] nvarchar(max) NULL, + [SystemTimeEnd] datetime2 GENERATED ALWAYS AS ROW END NOT NULL, + [SystemTimeStart] datetime2 GENERATED ALWAYS AS ROW START NOT NULL, + CONSTRAINT [PK_Customers] PRIMARY KEY ([Id]), + PERIOD FOR SYSTEM_TIME([SystemTimeStart], [SystemTimeEnd]) +) WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [mySchema].[CustomerHistory]));"); + } + + [ConditionalFact] + public virtual async Task Create_temporal_table_with_default_schema_for_table_and_explicit_history_table_schema_provided() + { + await Test( + builder => { }, + builder => + { + builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.Property("SystemTimeStart").ValueGeneratedOnAddOrUpdate(); + e.Property("SystemTimeEnd").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + + e.ToTable("Customers", tb => tb.IsTemporal(ttb => + { + ttb.WithHistoryTable("HistoryTable", "historySchema"); + ttb.HasPeriodStart("SystemTimeStart"); + ttb.HasPeriodEnd("SystemTimeEnd"); + })); + }); + }, + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customers", table.Name); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("HistoryTable", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + Assert.Equal("historySchema", table[SqlServerAnnotationNames.TemporalHistoryTableSchema]); + Assert.Equal("SystemTimeStart", table[SqlServerAnnotationNames.TemporalPeriodStartColumnName]); + Assert.Equal("SystemTimeEnd", table[SqlServerAnnotationNames.TemporalPeriodEndColumnName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("Name", c.Name), + c => Assert.Equal("SystemTimeEnd", c.Name), + c => Assert.Equal("SystemTimeStart", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"IF SCHEMA_ID(N'historySchema') IS NULL EXEC(N'CREATE SCHEMA [historySchema];');", + // + @"CREATE TABLE [Customers] ( + [Id] int NOT NULL, + [Name] nvarchar(max) NULL, + [SystemTimeEnd] datetime2 GENERATED ALWAYS AS ROW END NOT NULL, + [SystemTimeStart] datetime2 GENERATED ALWAYS AS ROW START NOT NULL, + CONSTRAINT [PK_Customers] PRIMARY KEY ([Id]), + PERIOD FOR SYSTEM_TIME([SystemTimeStart], [SystemTimeEnd]) +) WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [historySchema].[HistoryTable]));"); + } + + [ConditionalFact] + public virtual async Task Drop_temporal_table_default_history_table() + { + 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.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.HasPeriodStart("Start").HasColumnName("PeriodStart"); + ttb.HasPeriodEnd("End").HasColumnName("PeriodEnd"); + })); + }), + builder => { }, + model => + { + Assert.Empty(model.Tables); + }); + + AssertSql( + @"ALTER TABLE [Customer] SET (SYSTEM_VERSIONING = OFF)", + // + @"DROP TABLE [Customer];", + // + @"DROP TABLE [CustomerHistory];"); + } + + [ConditionalFact] + public virtual async Task Drop_temporal_table_custom_history_table() + { + 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.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.WithHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start").HasColumnName("PeriodStart"); + ttb.HasPeriodEnd("End").HasColumnName("PeriodEnd"); + })); + }), + builder => { }, + model => + { + Assert.Empty(model.Tables); + }); + + AssertSql( + @"ALTER TABLE [Customer] SET (SYSTEM_VERSIONING = OFF)", + // + @"DROP TABLE [Customer];", + // + @"DROP TABLE [HistoryTable];"); + } + + [ConditionalFact] + public virtual async Task Drop_temporal_table_custom_history_table_and_history_table_schema() + { + 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.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.WithHistoryTable("HistoryTable", "historySchema"); + ttb.HasPeriodStart("Start").HasColumnName("PeriodStart"); + ttb.HasPeriodEnd("End").HasColumnName("PeriodEnd"); + })); + }), + builder => { }, + model => + { + Assert.Empty(model.Tables); + }); + + AssertSql( + @"ALTER TABLE [Customer] SET (SYSTEM_VERSIONING = OFF)", + // + @"DROP TABLE [Customer];", + // + @"DROP TABLE [historySchema].[HistoryTable];"); + } + + [ConditionalFact] + public virtual async Task Rename_temporal_table() + { + 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.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.WithHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + builder => builder.Entity( + "Customer", e => + { + e.ToTable("Customers"); + }), + builder => builder.Entity( + "Customer", e => + { + 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.TemporalPeriodStartColumnName]); + Assert.Equal("End", table[SqlServerAnnotationNames.TemporalPeriodEndColumnName]); + Assert.Equal("HistoryTable", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("End", c.Name), + c => Assert.Equal("Name", c.Name), + c => Assert.Equal("Start", 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';", + // + @"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() + { + 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.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.WithHistoryTable("HistoryTable", "historySchema"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + + builder => builder.Entity( + "Customer", e => + { + e.ToTable("Customers"); + }), + builder => builder.Entity( + "Customer", e => + { + 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.TemporalPeriodStartColumnName]); + Assert.Equal("End", table[SqlServerAnnotationNames.TemporalPeriodEndColumnName]); + Assert.Equal("HistoryTable", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("End", c.Name), + c => Assert.Equal("Name", c.Name), + c => Assert.Equal("Start", 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';", + // + @"ALTER TABLE [RenamedCustomers] ADD CONSTRAINT [PK_RenamedCustomers] PRIMARY KEY ([Id]);", + // + @"ALTER TABLE [RenamedCustomers] SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [historySchema].[HistoryTable]))"); + } + + [ConditionalFact] + public virtual async Task Rename_history_table() + { + 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"); + }), + builder => builder.Entity( + "Customer", e => + { + e.ToTable("Customers", tb => tb.IsTemporal(ttb => + { + ttb.WithHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + builder => builder.Entity( + "Customer", e => + { + e.ToTable("Customers", tb => tb.IsTemporal(ttb => + { + ttb.WithHistoryTable("RenamedHistoryTable"); + 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.TemporalPeriodStartColumnName]); + Assert.Equal("End", table[SqlServerAnnotationNames.TemporalPeriodEndColumnName]); + Assert.Equal("RenamedHistoryTable", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("End", c.Name), + c => Assert.Equal("Name", c.Name), + c => Assert.Equal("Start", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"EXEC sp_rename N'[HistoryTable]', N'RenamedHistoryTable';"); + } + + [ConditionalFact] + public virtual async Task Change_history_table_schema() + { + 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"); + }), + builder => builder.Entity( + "Customer", e => + { + e.ToTable("Customers", tb => tb.IsTemporal(ttb => + { + ttb.WithHistoryTable("HistoryTable", "historySchema"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + builder => builder.Entity( + "Customer", e => + { + e.ToTable("Customers", tb => tb.IsTemporal(ttb => + { + ttb.WithHistoryTable("HistoryTable", "modifiedHistorySchema"); + 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.TemporalPeriodStartColumnName]); + Assert.Equal("End", table[SqlServerAnnotationNames.TemporalPeriodEndColumnName]); + Assert.Equal("HistoryTable", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + Assert.Equal("modifiedHistorySchema", table[SqlServerAnnotationNames.TemporalHistoryTableSchema]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("End", c.Name), + c => Assert.Equal("Name", c.Name), + c => Assert.Equal("Start", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"IF SCHEMA_ID(N'modifiedHistorySchema') IS NULL EXEC(N'CREATE SCHEMA [modifiedHistorySchema];');", + // + @"ALTER SCHEMA [modifiedHistorySchema] TRANSFER [historySchema].[HistoryTable];"); + } + + [ConditionalFact] + public virtual async Task Rename_temporal_table_history_table_and_their_schemas() + { + 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"); + }), + + builder => builder.Entity( + "Customer", e => + { + e.ToTable("Customers", "schema", tb => tb.IsTemporal(ttb => + { + ttb.WithHistoryTable("HistoryTable", "historySchema"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + + + e.ToTable("Customers"); + }), + builder => builder.Entity( + "Customer", e => + { + e.ToTable("RenamedCustomers", "newSchema", tb => tb.IsTemporal(ttb => + { + ttb.WithHistoryTable("RenamedHistoryTable", "newHistorySchema"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("RenamedCustomers", table.Name); + Assert.Equal("newSchema", table.Schema); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("Start", table[SqlServerAnnotationNames.TemporalPeriodStartColumnName]); + Assert.Equal("End", table[SqlServerAnnotationNames.TemporalPeriodEndColumnName]); + Assert.Equal("RenamedHistoryTable", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + Assert.Equal("newHistorySchema", table[SqlServerAnnotationNames.TemporalHistoryTableSchema]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("End", c.Name), + c => Assert.Equal("Name", c.Name), + c => Assert.Equal("Start", 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];", + // + @"IF SCHEMA_ID(N'newSchema') IS NULL EXEC(N'CREATE SCHEMA [newSchema];');", + // + @"EXEC sp_rename N'[Customers]', N'RenamedCustomers'; +ALTER SCHEMA [newSchema] TRANSFER [RenamedCustomers];", + // + @"IF SCHEMA_ID(N'newHistorySchema') IS NULL EXEC(N'CREATE SCHEMA [newHistorySchema];');", + // + @"EXEC sp_rename N'[historySchema].[HistoryTable]', N'RenamedHistoryTable'; +ALTER SCHEMA [newHistorySchema] TRANSFER [historySchema].[RenamedHistoryTable];", + // + @"ALTER TABLE [newSchema].[RenamedCustomers] ADD CONSTRAINT [PK_RenamedCustomers] PRIMARY KEY ([Id]);", + // + @"ALTER TABLE [newSchema].[RenamedCustomers] SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [newHistorySchema].[RenamedHistoryTable]))"); + } + + [ConditionalFact] + public virtual async Task Remove_columns_from_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("Customers", tb => tb.IsTemporal(ttb => + { + ttb.WithHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + builder => builder.Entity( + "Customer", e => + { + e.Property("Name"); + e.Property("Number"); + }), + builder => + { + }, + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customers", table.Name); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("Start", table[SqlServerAnnotationNames.TemporalPeriodStartColumnName]); + Assert.Equal("End", table[SqlServerAnnotationNames.TemporalPeriodEndColumnName]); + Assert.Equal("HistoryTable", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("End", c.Name), + c => Assert.Equal("Start", 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'Name'); +IF @var0 IS NOT NULL EXEC(N'ALTER TABLE [Customers] DROP CONSTRAINT [' + @var0 + '];'); +ALTER TABLE [Customers] DROP COLUMN [Name];", + // + @"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'Name'); +IF @var1 IS NOT NULL EXEC(N'ALTER TABLE [HistoryTable] DROP CONSTRAINT [' + @var1 + '];'); +ALTER TABLE [HistoryTable] DROP COLUMN [Name];", + // + @"DECLARE @var2 sysname; +SELECT @var2 = [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 @var2 IS NOT NULL EXEC(N'ALTER TABLE [Customers] DROP CONSTRAINT [' + @var2 + '];'); +ALTER TABLE [Customers] DROP COLUMN [Number];", + // + @"DECLARE @var3 sysname; +SELECT @var3 = [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 @var3 IS NOT NULL EXEC(N'ALTER TABLE [HistoryTable] DROP CONSTRAINT [' + @var3 + '];'); +ALTER TABLE [HistoryTable] DROP COLUMN [Number];", + // + @"DECLARE @historyTableSchema sysname = SCHEMA_NAME() +EXEC(N'ALTER TABLE [Customers] SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + '].[HistoryTable]))')"); + } + + [ConditionalFact] + public virtual async Task Add_columns_to_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("Customers", tb => tb.IsTemporal(ttb => + { + ttb.WithHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + builder => { }, + builder => builder.Entity( + "Customer", e => + { + e.Property("Name"); + e.Property("Number"); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customers", table.Name); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("Start", table[SqlServerAnnotationNames.TemporalPeriodStartColumnName]); + Assert.Equal("End", table[SqlServerAnnotationNames.TemporalPeriodEndColumnName]); + Assert.Equal("HistoryTable", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("End", c.Name), + c => Assert.Equal("Start", c.Name), + c => Assert.Equal("Name", c.Name), + c => Assert.Equal("Number", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"ALTER TABLE [Customers] ADD [Name] nvarchar(max) NULL;", + // + @"ALTER TABLE [Customers] ADD [Number] int NOT NULL DEFAULT 0;"); + } + + [ConditionalFact] + public virtual async Task Convert_temporal_table_with_default_column_mappings_and_custom_history_table_to_normal_table_keep_period_columns() + { + await Test( + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.Property("PeriodStart").ValueGeneratedOnAddOrUpdate(); + e.Property("PeriodEnd").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + + e.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.WithHistoryTable("HistoryTable"); + ttb.HasPeriodStart("PeriodStart"); + ttb.HasPeriodEnd("PeriodEnd"); + })); + }), + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.Property("PeriodStart"); + e.Property("PeriodEnd"); + e.HasKey("Id"); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customer", table.Name); + Assert.Null(table[SqlServerAnnotationNames.IsTemporal]); + Assert.Null(table[SqlServerAnnotationNames.TemporalHistoryTableName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("Name", c.Name), + c => Assert.Equal("PeriodEnd", c.Name), + c => Assert.Equal("PeriodStart", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"ALTER TABLE [Customer] SET (SYSTEM_VERSIONING = OFF)", + // + @"ALTER TABLE [Customer] DROP PERIOD FOR SYSTEM_TIME", + // + @"DROP TABLE [HistoryTable];"); + } + + [ConditionalFact] + public virtual async Task Convert_temporal_table_with_default_column_mappings_and_default_history_table_to_normal_table() + { + await Test( + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.Property("PeriodStart").ValueGeneratedOnAddOrUpdate(); + e.Property("PeriodEnd").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + + e.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.HasPeriodStart("PeriodStart"); + ttb.HasPeriodEnd("PeriodEnd"); + })); + }), + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.HasKey("Id"); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customer", table.Name); + Assert.Null(table[SqlServerAnnotationNames.IsTemporal]); + Assert.Null(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 [Customer] SET (SYSTEM_VERSIONING = OFF)", + // + @"ALTER TABLE [Customer] DROP PERIOD FOR SYSTEM_TIME", + // + @"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'[Customer]') AND [c].[name] = N'PeriodEnd'); +IF @var0 IS NOT NULL EXEC(N'ALTER TABLE [Customer] DROP CONSTRAINT [' + @var0 + '];'); +ALTER TABLE [Customer] DROP COLUMN [PeriodEnd];", + // + @"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'[Customer]') AND [c].[name] = N'PeriodStart'); +IF @var1 IS NOT NULL EXEC(N'ALTER TABLE [Customer] DROP CONSTRAINT [' + @var1 + '];'); +ALTER TABLE [Customer] DROP COLUMN [PeriodStart];", + // + @"DROP TABLE [CustomerHistory];"); + } + + [ConditionalFact] + public virtual async Task Convert_temporal_table_with_default_column_mappings_and_custom_history_table_to_normal_table_remove_period_columns() + { + await Test( + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.Property("PeriodStart").ValueGeneratedOnAddOrUpdate(); + e.Property("PeriodEnd").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + + e.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.WithHistoryTable("HistoryTable"); + ttb.HasPeriodStart("PeriodStart"); + ttb.HasPeriodEnd("PeriodEnd"); + })); + }), + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.HasKey("Id"); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customer", table.Name); + Assert.Null(table[SqlServerAnnotationNames.IsTemporal]); + Assert.Null(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 [Customer] SET (SYSTEM_VERSIONING = OFF)", + // + @"ALTER TABLE [Customer] DROP PERIOD FOR SYSTEM_TIME", + // + @"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'[Customer]') AND [c].[name] = N'PeriodEnd'); +IF @var0 IS NOT NULL EXEC(N'ALTER TABLE [Customer] DROP CONSTRAINT [' + @var0 + '];'); +ALTER TABLE [Customer] DROP COLUMN [PeriodEnd];", + // + @"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'[Customer]') AND [c].[name] = N'PeriodStart'); +IF @var1 IS NOT NULL EXEC(N'ALTER TABLE [Customer] DROP CONSTRAINT [' + @var1 + '];'); +ALTER TABLE [Customer] DROP COLUMN [PeriodStart];", + // + @"DROP TABLE [HistoryTable];"); + } + + [ConditionalFact] + public virtual async Task Convert_normal_table_to_temporal_table_with_minimal_configuration() + { + await Test( + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.HasKey("Id"); + }), + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.Property("PeriodStart").ValueGeneratedOnAddOrUpdate(); + e.Property("PeriodEnd").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + e.ToTable(tb => tb.IsTemporal()); + + e.Metadata[SqlServerAnnotationNames.TemporalPeriodStartPropertyName] = "PeriodStart"; + e.Metadata[SqlServerAnnotationNames.TemporalPeriodEndPropertyName] = "PeriodEnd"; + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customer", table.Name); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("CustomerHistory", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("Name", c.Name), + c => Assert.Equal("PeriodEnd", c.Name), + c => Assert.Equal("PeriodStart", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"ALTER TABLE [Customer] ADD [PeriodEnd] datetime2 NOT NULL DEFAULT '9999-12-31T23:59:59.9999999';", + // + @"ALTER TABLE [Customer] ADD [PeriodStart] datetime2 NOT NULL DEFAULT '0001-01-01T00:00:00.0000000';", + // + @"ALTER TABLE [Customer] ADD PERIOD FOR SYSTEM_TIME ([PeriodStart], [PeriodEnd])", + // + @"DECLARE @historyTableSchema sysname = SCHEMA_NAME() +EXEC(N'ALTER TABLE [Customer] SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + '].[CustomerHistory]))')"); + } + + [ConditionalFact] + public virtual async Task Convert_normal_table_with_period_columns_to_temporal_table_default_column_mappings_and_default_history_table() + { + await Test( + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.Property("Start"); + e.Property("End"); + e.HasKey("Id"); + }), + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + + e.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customer", table.Name); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("CustomerHistory", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("End", c.Name), + c => Assert.Equal("Name", c.Name), + c => Assert.Equal("Start", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"ALTER TABLE [Customer] ADD PERIOD FOR SYSTEM_TIME ([Start], [End])", + // + @"DECLARE @historyTableSchema sysname = SCHEMA_NAME() +EXEC(N'ALTER TABLE [Customer] SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + '].[CustomerHistory]))')"); + } + + [ConditionalFact] + public virtual async Task Convert_normal_table_with_period_columns_to_temporal_table_default_column_mappings_and_specified_history_table() + { + await Test( + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.Property("Start"); + e.Property("End"); + e.HasKey("Id"); + }), + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + + e.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.WithHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customer", table.Name); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("HistoryTable", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + Assert.Equal("Start", table[SqlServerAnnotationNames.TemporalPeriodStartColumnName]); + Assert.Equal("End", table[SqlServerAnnotationNames.TemporalPeriodEndColumnName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("End", c.Name), + c => Assert.Equal("Name", c.Name), + c => Assert.Equal("Start", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"ALTER TABLE [Customer] ADD PERIOD FOR SYSTEM_TIME ([Start], [End])", + // + @"DECLARE @historyTableSchema sysname = SCHEMA_NAME() +EXEC(N'ALTER TABLE [Customer] SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + '].[HistoryTable]))')"); + } + + [ConditionalFact] + public virtual async Task Convert_normal_table_to_temporal_table_default_column_mappings_and_default_history_table() + { + await Test( + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.HasKey("Id"); + }), + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + + e.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customer", table.Name); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.NotNull(table[SqlServerAnnotationNames.TemporalHistoryTableName]); + Assert.Equal("Start", table[SqlServerAnnotationNames.TemporalPeriodStartColumnName]); + Assert.Equal("End", table[SqlServerAnnotationNames.TemporalPeriodEndColumnName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("Name", c.Name), + c => Assert.Equal("End", c.Name), + c => Assert.Equal("Start", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"ALTER TABLE [Customer] ADD [End] datetime2 NOT NULL DEFAULT '9999-12-31T23:59:59.9999999';", + // + @"ALTER TABLE [Customer] ADD [Start] datetime2 NOT NULL DEFAULT '0001-01-01T00:00:00.0000000';", + // + @"ALTER TABLE [Customer] ADD PERIOD FOR SYSTEM_TIME ([Start], [End])", + // + @"DECLARE @historyTableSchema sysname = SCHEMA_NAME() +EXEC(N'ALTER TABLE [Customer] SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + '].[CustomerHistory]))')"); + } + + [ConditionalFact] + public virtual async Task Convert_normal_table_without_period_columns_to_temporal_table_default_column_mappings_and_specified_history_table() + { + await Test( + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.HasKey("Id"); + }), + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + + e.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.WithHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customer", table.Name); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("HistoryTable", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + Assert.Equal("Start", table[SqlServerAnnotationNames.TemporalPeriodStartColumnName]); + Assert.Equal("End", table[SqlServerAnnotationNames.TemporalPeriodEndColumnName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("Name", c.Name), + c => Assert.Equal("End", c.Name), + c => Assert.Equal("Start", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"ALTER TABLE [Customer] ADD [End] datetime2 NOT NULL DEFAULT '9999-12-31T23:59:59.9999999';", + // + @"ALTER TABLE [Customer] ADD [Start] datetime2 NOT NULL DEFAULT '0001-01-01T00:00:00.0000000';", + // + @"ALTER TABLE [Customer] ADD PERIOD FOR SYSTEM_TIME ([Start], [End])", + // + @"DECLARE @historyTableSchema sysname = SCHEMA_NAME() +EXEC(N'ALTER TABLE [Customer] SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + '].[HistoryTable]))')"); + } + + [ConditionalFact] + public virtual async Task Rename_period_properties_of_temporal_table() + { + 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.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.WithHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.Property("ModifiedStart").ValueGeneratedOnAddOrUpdate(); + e.Property("ModifiedEnd").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + + e.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.WithHistoryTable("HistoryTable"); + ttb.HasPeriodStart("ModifiedStart"); + ttb.HasPeriodEnd("ModifiedEnd"); + })); + }), + 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("ModifiedStart", table[SqlServerAnnotationNames.TemporalPeriodStartColumnName]); + Assert.Equal("ModifiedEnd", table[SqlServerAnnotationNames.TemporalPeriodEndColumnName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("ModifiedEnd", c.Name), + c => Assert.Equal("Name", c.Name), + c => Assert.Equal("ModifiedStart", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"EXEC sp_rename N'[Customer].[Start]', N'ModifiedStart', N'COLUMN';", + // + @"EXEC sp_rename N'[Customer].[End]', N'ModifiedEnd', N'COLUMN';"); + } + + [ConditionalFact] + public virtual async Task Rename_period_columns_of_temporal_table() + { + 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"); + }), + builder => builder.Entity( + "Customer", e => + { + e.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.WithHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + builder => builder.Entity( + "Customer", e => + { + e.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.WithHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start").HasColumnName("ModifiedStart"); + ttb.HasPeriodEnd("End").HasColumnName("ModifiedEnd"); + })); + }), + 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("ModifiedStart", table[SqlServerAnnotationNames.TemporalPeriodStartColumnName]); + Assert.Equal("ModifiedEnd", table[SqlServerAnnotationNames.TemporalPeriodEndColumnName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("ModifiedEnd", c.Name), + c => Assert.Equal("Name", c.Name), + c => Assert.Equal("ModifiedStart", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"EXEC sp_rename N'[Customer].[Start]', N'ModifiedStart', N'COLUMN';", + // + @"EXEC sp_rename N'[Customer].[End]', N'ModifiedEnd', N'COLUMN';"); + } + + [ConditionalFact] + public virtual async Task Create_temporal_table_with_comments() + { + await Test( + builder => { }, + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name").HasComment("Column comment"); + e.Property("SystemTimeStart").ValueGeneratedOnAddOrUpdate(); + e.Property("SystemTimeEnd").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + e.HasComment("Table comment"); + + e.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.HasPeriodStart("SystemTimeStart"); + ttb.HasPeriodEnd("SystemTimeEnd"); + })); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customer", table.Name); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.NotNull(table[SqlServerAnnotationNames.TemporalHistoryTableName]); + Assert.Equal("SystemTimeStart", table[SqlServerAnnotationNames.TemporalPeriodStartColumnName]); + Assert.Equal("SystemTimeEnd", table[SqlServerAnnotationNames.TemporalPeriodEndColumnName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("Name", c.Name), + c => Assert.Equal("SystemTimeEnd", c.Name), + c => Assert.Equal("SystemTimeStart", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"DECLARE @historyTableSchema sysname = SCHEMA_NAME() +EXEC(N'CREATE TABLE [Customer] ( + [Id] int NOT NULL, + [Name] nvarchar(max) NULL, + [SystemTimeEnd] datetime2 GENERATED ALWAYS AS ROW END NOT NULL, + [SystemTimeStart] datetime2 GENERATED ALWAYS AS ROW START NOT NULL, + CONSTRAINT [PK_Customer] PRIMARY KEY ([Id]), + PERIOD FOR SYSTEM_TIME([SystemTimeStart], [SystemTimeEnd]) +) WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + N'].[CustomerHistory]))'); +DECLARE @defaultSchema AS sysname; +SET @defaultSchema = SCHEMA_NAME(); +DECLARE @description AS sql_variant; +SET @description = N'Table comment'; +EXEC sp_addextendedproperty 'MS_Description', @description, 'SCHEMA', @defaultSchema, 'TABLE', N'Customer'; +SET @description = N'Column comment'; +EXEC sp_addextendedproperty 'MS_Description', @description, 'SCHEMA', @defaultSchema, 'TABLE', N'Customer', 'COLUMN', N'Name';"); + } + + [ConditionalFact] + public virtual async Task Convert_normal_table_to_temporal_while_also_adding_comments_and_index() + { + await Test( + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.HasKey("Id"); + }), + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name").HasComment("Column comment"); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + e.HasIndex("Name"); + + e.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.WithHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customer", table.Name); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("HistoryTable", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + Assert.Equal("Start", table[SqlServerAnnotationNames.TemporalPeriodStartColumnName]); + Assert.Equal("End", table[SqlServerAnnotationNames.TemporalPeriodEndColumnName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("Name", c.Name), + c => Assert.Equal("End", c.Name), + c => Assert.Equal("Start", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"DECLARE @var0 sysname; +SELECT @var0 = [d].[name] +FROM [sys].[default_constraints] [d] +INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] +WHERE ([d].[parent_object_id] = OBJECT_ID(N'[Customer]') AND [c].[name] = N'Name'); +IF @var0 IS NOT NULL EXEC(N'ALTER TABLE [Customer] DROP CONSTRAINT [' + @var0 + '];'); +ALTER TABLE [Customer] ALTER COLUMN [Name] nvarchar(450) NULL; +DECLARE @defaultSchema AS sysname; +SET @defaultSchema = SCHEMA_NAME(); +DECLARE @description AS sql_variant; +SET @description = N'Column comment'; +EXEC sp_addextendedproperty 'MS_Description', @description, 'SCHEMA', @defaultSchema, 'TABLE', N'Customer', 'COLUMN', N'Name';", + // + @"ALTER TABLE [Customer] ADD [End] datetime2 NOT NULL DEFAULT '9999-12-31T23:59:59.9999999';", + // + @"ALTER TABLE [Customer] ADD [Start] datetime2 NOT NULL DEFAULT '0001-01-01T00:00:00.0000000';", + // + @"CREATE INDEX [IX_Customer_Name] ON [Customer] ([Name]);", + // + @"ALTER TABLE [Customer] ADD PERIOD FOR SYSTEM_TIME ([Start], [End])", + // + @"DECLARE @historyTableSchema sysname = SCHEMA_NAME() +EXEC(N'ALTER TABLE [Customer] SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + '].[HistoryTable]))')"); + } + + [ConditionalFact] + public async Task Alter_comments_for_temporal_table() + { + await Test( + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.Property("SystemTimeStart").ValueGeneratedOnAddOrUpdate(); + e.Property("SystemTimeEnd").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + + e.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.HasPeriodStart("SystemTimeStart"); + ttb.HasPeriodEnd("SystemTimeEnd"); + })); + }), + builder => builder.Entity( + "Customer", e => + { + e.Property("Name").HasComment("Column comment"); + e.HasComment("Table comment"); + }), + builder => builder.Entity( + "Customer", e => + { + e.Property("Name").HasComment("Modified column comment"); + e.HasComment("Modified table comment"); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customer", table.Name); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.NotNull(table[SqlServerAnnotationNames.TemporalHistoryTableName]); + Assert.Equal("SystemTimeStart", table[SqlServerAnnotationNames.TemporalPeriodStartColumnName]); + Assert.Equal("SystemTimeEnd", table[SqlServerAnnotationNames.TemporalPeriodEndColumnName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("Name", c.Name), + c => Assert.Equal("SystemTimeEnd", c.Name), + c => Assert.Equal("SystemTimeStart", 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; +EXEC sp_dropextendedproperty 'MS_Description', 'SCHEMA', @defaultSchema, 'TABLE', N'Customer'; +SET @description = N'Modified table comment'; +EXEC sp_addextendedproperty 'MS_Description', @description, 'SCHEMA', @defaultSchema, 'TABLE', N'Customer';", + // + @"DECLARE @defaultSchema AS sysname; +SET @defaultSchema = SCHEMA_NAME(); +DECLARE @description AS sql_variant; +EXEC sp_dropextendedproperty 'MS_Description', 'SCHEMA', @defaultSchema, 'TABLE', N'Customer', 'COLUMN', N'Name'; +SET @description = N'Modified column comment'; +EXEC sp_addextendedproperty 'MS_Description', @description, 'SCHEMA', @defaultSchema, 'TABLE', N'Customer', 'COLUMN', N'Name';"); + } + + [ConditionalFact] + public virtual async Task Add_index_to_temporal_table() + { + await Test( + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.Property("Number"); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + + e.ToTable("Customers", tb => tb.IsTemporal(ttb => + { + ttb.WithHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + + builder => { }, + builder => builder.Entity( + "Customer", e => + { + e.HasIndex("Name"); + e.HasIndex("Number").IsUnique(); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customers", table.Name); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("Start", table[SqlServerAnnotationNames.TemporalPeriodStartColumnName]); + Assert.Equal("End", table[SqlServerAnnotationNames.TemporalPeriodEndColumnName]); + Assert.Equal("HistoryTable", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + Assert.Equal(2, table.Indexes.Count); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("End", c.Name), + c => Assert.Equal("Name", c.Name), + c => Assert.Equal("Number", c.Name), + c => Assert.Equal("Start", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"DECLARE @var0 sysname; +SELECT @var0 = [d].[name] +FROM [sys].[default_constraints] [d] +INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] +WHERE ([d].[parent_object_id] = OBJECT_ID(N'[Customers]') AND [c].[name] = N'Name'); +IF @var0 IS NOT NULL EXEC(N'ALTER TABLE [Customers] DROP CONSTRAINT [' + @var0 + '];'); +ALTER TABLE [Customers] ALTER COLUMN [Name] nvarchar(450) NULL;", + // + @"CREATE INDEX [IX_Customers_Name] ON [Customers] ([Name]);", + // + @"CREATE UNIQUE INDEX [IX_Customers_Number] ON [Customers] ([Number]);"); + } + protected override string NonDefaultCollation => _nonDefaultCollation ??= GetDatabaseCollation() == "German_PhoneBook_CI_AS" ? "French_CI_AS" diff --git a/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerDatabaseCleaner.cs b/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerDatabaseCleaner.cs index c13b4516a82..19dda8eac3e 100644 --- a/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerDatabaseCleaner.cs +++ b/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerDatabaseCleaner.cs @@ -3,7 +3,6 @@ using System.Diagnostics; using Microsoft.EntityFrameworkCore.Diagnostics.Internal; -using Microsoft.EntityFrameworkCore.Internal; using Microsoft.EntityFrameworkCore.Migrations.Operations; using Microsoft.EntityFrameworkCore.Scaffolding; using Microsoft.EntityFrameworkCore.Scaffolding.Metadata; @@ -84,20 +83,38 @@ protected override string BuildCustomEndingSql(DatabaseModel databaseModel) EXEC (@SQL);"; protected override MigrationOperation Drop(DatabaseTable table) - => AddMemoryOptimizedAnnotation(base.Drop(table), table); + => AddSqlServerSpecificAnnotations(base.Drop(table), table); protected override MigrationOperation Drop(DatabaseForeignKey foreignKey) - => AddMemoryOptimizedAnnotation(base.Drop(foreignKey), foreignKey.Table); + => AddSqlServerSpecificAnnotations(base.Drop(foreignKey), foreignKey.Table); protected override MigrationOperation Drop(DatabaseIndex index) - => AddMemoryOptimizedAnnotation(base.Drop(index), index.Table); + => AddSqlServerSpecificAnnotations(base.Drop(index), index.Table); - private static TOperation AddMemoryOptimizedAnnotation(TOperation operation, DatabaseTable table) + private static TOperation AddSqlServerSpecificAnnotations(TOperation operation, DatabaseTable table) where TOperation : MigrationOperation { operation[SqlServerAnnotationNames.MemoryOptimized] = table[SqlServerAnnotationNames.MemoryOptimized] as bool?; + if (table[SqlServerAnnotationNames.IsTemporal] != null) + { + operation[SqlServerAnnotationNames.IsTemporal] + = table[SqlServerAnnotationNames.IsTemporal]; + + operation[SqlServerAnnotationNames.TemporalHistoryTableName] + = table[SqlServerAnnotationNames.TemporalHistoryTableName]; + + operation[SqlServerAnnotationNames.TemporalHistoryTableSchema] + = table[SqlServerAnnotationNames.TemporalHistoryTableSchema]; + + operation[SqlServerAnnotationNames.TemporalPeriodStartColumnName] + = table[SqlServerAnnotationNames.TemporalPeriodStartColumnName]; + + operation[SqlServerAnnotationNames.TemporalPeriodEndColumnName] + = table[SqlServerAnnotationNames.TemporalPeriodEndColumnName]; + } + return operation; } } diff --git a/test/EFCore.SqlServer.Tests/Infrastructure/SqlServerModelValidatorTest.cs b/test/EFCore.SqlServer.Tests/Infrastructure/SqlServerModelValidatorTest.cs index e1a3a735149..a57ac21f9de 100644 --- a/test/EFCore.SqlServer.Tests/Infrastructure/SqlServerModelValidatorTest.cs +++ b/test/EFCore.SqlServer.Tests/Infrastructure/SqlServerModelValidatorTest.cs @@ -8,6 +8,7 @@ using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.SqlServer.Diagnostics.Internal; using Microsoft.EntityFrameworkCore.SqlServer.Internal; +using Microsoft.EntityFrameworkCore.SqlServer.Metadata.Internal; using Microsoft.EntityFrameworkCore.TestUtilities; using Xunit; @@ -660,6 +661,156 @@ protected virtual void ConfigureProperty(IMutableProperty property, string confi } } + [ConditionalFact] + public void Temporal_can_only_be_specified_on_root_entities() + { + var modelBuilder = CreateConventionalModelBuilder(); + modelBuilder.Entity(); + modelBuilder.Entity().ToTable(tb => tb.IsTemporal()); + + VerifyError(SqlServerStrings.TemporalOnlyOnRoot(nameof(Dog)), modelBuilder); + } + + [ConditionalFact] + public void Temporal_enitty_must_have_period_start() + { + var modelBuilder = CreateConventionalModelBuilder(); + modelBuilder.Entity().ToTable(tb => tb.IsTemporal()); + modelBuilder.Entity().Metadata.RemoveAnnotation(SqlServerAnnotationNames.TemporalPeriodStartPropertyName); + + VerifyError(SqlServerStrings.TemporalMustDefinePeriodProperties(nameof(Dog)), modelBuilder); + } + + [ConditionalFact] + public void Temporal_enitty_must_have_period_end() + { + var modelBuilder = CreateConventionalModelBuilder(); + modelBuilder.Entity().ToTable(tb => tb.IsTemporal()); + modelBuilder.Entity().Metadata.RemoveAnnotation(SqlServerAnnotationNames.TemporalPeriodEndPropertyName); + + VerifyError(SqlServerStrings.TemporalMustDefinePeriodProperties(nameof(Dog)), modelBuilder); + } + + [ConditionalFact] + public void Temporal_enitty_without_expected_period_start_property() + { + var modelBuilder = CreateConventionalModelBuilder(); + modelBuilder.Entity().ToTable(tb => tb.IsTemporal(ttb => ttb.HasPeriodStart("Start"))); + modelBuilder.Entity().Metadata.RemoveProperty("Start"); + + VerifyError(SqlServerStrings.TemporalExpectedPeriodPropertyNotFound(nameof(Dog), "Start"), modelBuilder); + } + + [ConditionalFact] + public void Temporal_period_property_must_be_in_shadow_state() + { + var modelBuilder = CreateConventionalModelBuilder(); + modelBuilder.Entity().ToTable(tb => tb.IsTemporal(ttb => ttb.HasPeriodStart("DateOfBirth"))); + + VerifyError(SqlServerStrings.TemporalPeriodPropertyMustBeInShadowState(nameof(Human), "DateOfBirth"), modelBuilder); + } + + [ConditionalFact] + public void Temporal_period_property_must_non_nullable_datetime() + { + var modelBuilder1 = CreateConventionalModelBuilder(); + modelBuilder1.Entity().Property(typeof(DateTime?), "Start"); + modelBuilder1.Entity().ToTable(tb => tb.IsTemporal(ttb => ttb.HasPeriodStart("Start"))); + + VerifyError(SqlServerStrings.TemporalPeriodPropertyMustBeNonNullableDateTime(nameof(Dog), "Start", nameof(DateTime)), modelBuilder1); + + var modelBuilder2 = CreateConventionalModelBuilder(); + modelBuilder2.Entity().Property(typeof(int), "Start"); + modelBuilder2.Entity().ToTable(tb => tb.IsTemporal(ttb => ttb.HasPeriodStart("Start"))); + + VerifyError(SqlServerStrings.TemporalPeriodPropertyMustBeNonNullableDateTime(nameof(Dog), "Start", nameof(DateTime)), modelBuilder2); + } + + [ConditionalFact] + public void Temporal_period_property_must_be_mapped_to_datetime2() + { + var modelBuilder = CreateConventionalModelBuilder(); + modelBuilder.Entity().Property(typeof(DateTime), "Start").HasColumnType("datetime"); + modelBuilder.Entity().ToTable(tb => tb.IsTemporal(ttb => ttb.HasPeriodStart("Start"))); + + VerifyError(SqlServerStrings.TemporalPeriodPropertyMustBeMappedToDatetime2(nameof(Dog), "Start", "datetime2"), modelBuilder); + } + + [ConditionalFact] + public void Temporal_all_properties_mapped_to_period_column_must_have_value_generated_OnAddOrUpdate() + { + var modelBuilder = CreateConventionalModelBuilder(); + modelBuilder.Entity().Property(typeof(DateTime), "Start2").HasColumnName("StartColumn").ValueGeneratedOnAddOrUpdate(); + modelBuilder.Entity().Property(typeof(DateTime), "Start3").HasColumnName("StartColumn"); + modelBuilder.Entity().ToTable(tb => tb.IsTemporal(ttb => ttb.HasPeriodStart("Start").HasColumnName("StartColumn"))); + + VerifyError(SqlServerStrings.TemporalPropertyMappedToPeriodColumnMustBeValueGeneratedOnAddOrUpdate( + nameof(Dog), "Start3", nameof(ValueGenerated.OnAddOrUpdate)), modelBuilder); + } + + [ConditionalFact] + public void Temporal_all_properties_mapped_to_period_column_cant_have_default_values() + { + var modelBuilder = CreateConventionalModelBuilder(); + modelBuilder.Entity().Property(typeof(DateTime), "Start2").HasColumnName("StartColumn").ValueGeneratedOnAddOrUpdate(); + modelBuilder.Entity().Property(typeof(DateTime), "Start3").HasColumnName("StartColumn").ValueGeneratedOnAddOrUpdate().HasDefaultValue(DateTime.MinValue); + modelBuilder.Entity().ToTable(tb => tb.IsTemporal(ttb => ttb.HasPeriodStart("Start").HasColumnName("StartColumn"))); + + VerifyError(SqlServerStrings.TemporalPropertyMappedToPeriodColumnCantHaveDefaultValue( + nameof(Dog), "Start3"), modelBuilder); + } + + [ConditionalFact] + public void Temporal_period_property_cant_have_default_value() + { + var modelBuilder = CreateConventionalModelBuilder(); + modelBuilder.Entity().Property(typeof(DateTime), "Start").HasDefaultValue(new DateTime(2000, 1, 1)); + modelBuilder.Entity().ToTable(tb => tb.IsTemporal(ttb => ttb.HasPeriodStart("Start"))); + + VerifyError(SqlServerStrings.TemporalPeriodPropertyCantHaveDefaultValue(nameof(Dog), "Start"), modelBuilder); + } + + [ConditionalFact] + public void Temporal_doesnt_work_on_TPH() + { + var modelBuilder = CreateConventionalModelBuilder(); + modelBuilder.Entity().ToTable(tb => tb.IsTemporal()); + modelBuilder.Entity().ToTable("Dogs"); + modelBuilder.Entity().ToTable("Cats"); + + VerifyError(SqlServerStrings.TemporalOnlySupportedForTPH(nameof(Animal)), modelBuilder); + } + + [ConditionalFact] + public void Temporal_doesnt_work_on_table_splitting() + { + var modelBuilder = CreateConventionalModelBuilder(); + modelBuilder.Entity().ToTable("Splitting", tb => tb.IsTemporal()); + modelBuilder.Entity().ToTable("Splitting", tb => tb.IsTemporal()); + modelBuilder.Entity().HasOne(x => x.Details).WithOne().HasForeignKey(x => x.Id); + + VerifyError(SqlServerStrings.TemporalNotSupportedForTableSplitting("Splitting"), modelBuilder); + } + + public class Human + { + public int Id { get; set; } + public DateTime DateOfBirth { get; set; } + } + + public class Splitting1 + { + public int Id { get; set; } + public Splitting2 Details { get; set; } + } + + public class Splitting2 + { + public int Id { get; set; } + public string Name { get; set; } + public DateTime Detail { get; set; } + } + private class Cheese { public int Id { get; set; } diff --git a/test/EFCore.SqlServer.Tests/ModelBuilding/SqlServerModelBuilderGenericTest.cs b/test/EFCore.SqlServer.Tests/ModelBuilding/SqlServerModelBuilderGenericTest.cs index 79325022bd7..8531d6afb74 100644 --- a/test/EFCore.SqlServer.Tests/ModelBuilding/SqlServerModelBuilderGenericTest.cs +++ b/test/EFCore.SqlServer.Tests/ModelBuilding/SqlServerModelBuilderGenericTest.cs @@ -4,7 +4,9 @@ using System; using System.Collections.Generic; using System.Linq; +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.TestUtilities; using Xunit; @@ -700,8 +702,316 @@ public override void Can_configure_owned_type_key() Assert.Equal(nameof(CustomerDetails.Id), owned.FindPrimaryKey().Properties.Single().Name); } + + [ConditionalFact] + public virtual void Temporal_table_default_settings() + { + var modelBuilder = CreateModelBuilder(); + var model = modelBuilder.Model; + + modelBuilder.Entity().ToTable(tb => tb.IsTemporal()); + modelBuilder.FinalizeModel(); + + var entity = model.FindEntityType(typeof(Customer)); + Assert.True(entity.IsTemporal()); + Assert.Equal("CustomerHistory", entity.GetTemporalHistoryTableName()); + Assert.Null(entity.GetTemporalHistoryTableSchema()); + + var periodStart = entity.GetProperty(entity.GetTemporalPeriodStartPropertyName()); + var periodEnd = entity.GetProperty(entity.GetTemporalPeriodEndPropertyName()); + + Assert.Equal("PeriodStart", periodStart.Name); + Assert.True(periodStart.IsShadowProperty()); + Assert.Equal(typeof(DateTime), periodStart.ClrType); + Assert.Equal(ValueGenerated.OnAddOrUpdate, periodStart.ValueGenerated); + + Assert.Equal("PeriodEnd", periodEnd.Name); + Assert.True(periodEnd.IsShadowProperty()); + Assert.Equal(typeof(DateTime), periodEnd.ClrType); + Assert.Equal(ValueGenerated.OnAddOrUpdate, periodEnd.ValueGenerated); + } + + [ConditionalFact] + public virtual void Temporal_table_with_history_table_configuration() + { + var modelBuilder = CreateModelBuilder(); + var model = modelBuilder.Model; + + modelBuilder.Entity().ToTable(tb => tb.IsTemporal(ttb => + { + ttb.WithHistoryTable("HistoryTable", "historySchema"); + ttb.HasPeriodStart("MyPeriodStart").HasColumnName("PeriodStartColumn"); + ttb.HasPeriodEnd("MyPeriodEnd").HasColumnName("PeriodEndColumn"); + })); + + modelBuilder.FinalizeModel(); + + var entity = model.FindEntityType(typeof(Customer)); + Assert.True(entity.IsTemporal()); + Assert.Equal(5, entity.GetProperties().Count()); + + Assert.Equal("HistoryTable", entity.GetTemporalHistoryTableName()); + Assert.Equal("historySchema", entity.GetTemporalHistoryTableSchema()); + + var periodStart = entity.GetProperty(entity.GetTemporalPeriodStartPropertyName()); + var periodEnd = entity.GetProperty(entity.GetTemporalPeriodEndPropertyName()); + + Assert.Equal("MyPeriodStart", periodStart.Name); + Assert.Equal("PeriodStartColumn", periodStart[RelationalAnnotationNames.ColumnName]); + Assert.True(periodStart.IsShadowProperty()); + Assert.Equal(typeof(DateTime), periodStart.ClrType); + Assert.Equal(ValueGenerated.OnAddOrUpdate, periodStart.ValueGenerated); + + Assert.Equal("MyPeriodEnd", periodEnd.Name); + Assert.Equal("PeriodEndColumn", periodEnd[RelationalAnnotationNames.ColumnName]); + Assert.True(periodEnd.IsShadowProperty()); + Assert.Equal(typeof(DateTime), periodEnd.ClrType); + Assert.Equal(ValueGenerated.OnAddOrUpdate, periodEnd.ValueGenerated); + } + + [ConditionalFact] + public virtual void Temporal_table_with_changed_configuration() + { + var modelBuilder = CreateModelBuilder(); + var model = modelBuilder.Model; + + modelBuilder.Entity().ToTable(tb => tb.IsTemporal(ttb => + { + ttb.WithHistoryTable("HistoryTable", "historySchema"); + ttb.HasPeriodStart("MyPeriodStart").HasColumnName("PeriodStartColumn"); + ttb.HasPeriodEnd("MyPeriodEnd").HasColumnName("PeriodEndColumn"); + })); + + modelBuilder.Entity().ToTable(tb => tb.IsTemporal(ttb => + { + ttb.WithHistoryTable("ChangedHistoryTable", "changedHistorySchema"); + ttb.HasPeriodStart("ChangedMyPeriodStart").HasColumnName("ChangedPeriodStartColumn"); + ttb.HasPeriodEnd("ChangedMyPeriodEnd").HasColumnName("ChangedPeriodEndColumn"); + })); + + modelBuilder.FinalizeModel(); + + var entity = model.FindEntityType(typeof(Customer)); + Assert.True(entity.IsTemporal()); + Assert.Equal(5, entity.GetProperties().Count()); + + Assert.Equal("ChangedHistoryTable", entity.GetTemporalHistoryTableName()); + Assert.Equal("changedHistorySchema", entity.GetTemporalHistoryTableSchema()); + + var periodStart = entity.GetProperty(entity.GetTemporalPeriodStartPropertyName()); + var periodEnd = entity.GetProperty(entity.GetTemporalPeriodEndPropertyName()); + + Assert.Equal("ChangedMyPeriodStart", periodStart.Name); + Assert.Equal("ChangedPeriodStartColumn", periodStart[RelationalAnnotationNames.ColumnName]); + Assert.True(periodStart.IsShadowProperty()); + Assert.Equal(typeof(DateTime), periodStart.ClrType); + Assert.Equal(ValueGenerated.OnAddOrUpdate, periodStart.ValueGenerated); + + Assert.Equal("ChangedMyPeriodEnd", periodEnd.Name); + Assert.Equal("ChangedPeriodEndColumn", periodEnd[RelationalAnnotationNames.ColumnName]); + Assert.True(periodEnd.IsShadowProperty()); + Assert.Equal(typeof(DateTime), periodEnd.ClrType); + Assert.Equal(ValueGenerated.OnAddOrUpdate, periodEnd.ValueGenerated); + } + + [ConditionalFact] + public virtual void Temporal_table_with_explicit_properties_mapped_to_the_period_columns() + { + var modelBuilder = CreateModelBuilder(); + var model = modelBuilder.Model; + + modelBuilder.Entity().ToTable(tb => tb.IsTemporal(ttb => + { + ttb.WithHistoryTable("HistoryTable", schema: null); + ttb.HasPeriodStart("Start").HasColumnName("PeriodStartColumn"); + ttb.HasPeriodEnd("End").HasColumnName("PeriodEndColumn"); + })); + + modelBuilder.Entity() + .Property("MappedStart") + .HasColumnName("PeriodStartColumn") + .ValueGeneratedOnAddOrUpdate(); + + modelBuilder.Entity() + .Property("MappedEnd") + .HasColumnName("PeriodEndColumn") + .ValueGeneratedOnAddOrUpdate(); + + modelBuilder.FinalizeModel(); + + var entity = model.FindEntityType(typeof(Customer)); + Assert.True(entity.IsTemporal()); + Assert.Equal(7, entity.GetProperties().Count()); + + Assert.Equal("HistoryTable", entity.GetTemporalHistoryTableName()); + + var periodStart = entity.GetProperty(entity.GetTemporalPeriodStartPropertyName()); + var periodEnd = entity.GetProperty(entity.GetTemporalPeriodEndPropertyName()); + + Assert.Equal("Start", periodStart.Name); + Assert.Equal("PeriodStartColumn", periodStart[RelationalAnnotationNames.ColumnName]); + Assert.True(periodStart.IsShadowProperty()); + Assert.Equal(typeof(DateTime), periodStart.ClrType); + Assert.Equal(ValueGenerated.OnAddOrUpdate, periodStart.ValueGenerated); + + Assert.Equal("End", periodEnd.Name); + Assert.Equal("PeriodEndColumn", periodEnd[RelationalAnnotationNames.ColumnName]); + Assert.True(periodEnd.IsShadowProperty()); + Assert.Equal(typeof(DateTime), periodEnd.ClrType); + Assert.Equal(ValueGenerated.OnAddOrUpdate, periodEnd.ValueGenerated); + + var propertyMappedToStart = entity.GetProperty("MappedStart"); + Assert.Equal("PeriodStartColumn", propertyMappedToStart[RelationalAnnotationNames.ColumnName]); + + var propertyMappedToEnd = entity.GetProperty("MappedEnd"); + Assert.Equal("PeriodEndColumn", propertyMappedToEnd[RelationalAnnotationNames.ColumnName]); + } + + [ConditionalFact] + public virtual void Temporal_table_with_explicit_properties_with_same_name_as_default_periods_but_different_periods_defined_explicity_as_well() + { + var modelBuilder = CreateModelBuilder(); + var model = modelBuilder.Model; + + modelBuilder.Entity() + .Property("PeriodStart") + .HasColumnName("PeriodStartColumn"); + + modelBuilder.Entity() + .Property("PeriodEnd") + .HasColumnName("PeriodEndColumn"); + + modelBuilder.Entity().ToTable(tb => tb.IsTemporal(ttb => + { + ttb.WithHistoryTable("HistoryTable", schema: null); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + + modelBuilder.FinalizeModel(); + + var entity = model.FindEntityType(typeof(Customer)); + Assert.True(entity.IsTemporal()); + Assert.Equal(7, entity.GetProperties().Count()); + + Assert.Equal("HistoryTable", entity.GetTemporalHistoryTableName()); + + var periodStart = entity.GetProperty(entity.GetTemporalPeriodStartPropertyName()); + var periodEnd = entity.GetProperty(entity.GetTemporalPeriodEndPropertyName()); + + Assert.Equal("Start", periodStart.Name); + Assert.Equal("Start", periodStart[RelationalAnnotationNames.ColumnName]); + Assert.True(periodStart.IsShadowProperty()); + Assert.Equal(typeof(DateTime), periodStart.ClrType); + Assert.Equal(ValueGenerated.OnAddOrUpdate, periodStart.ValueGenerated); + + Assert.Equal("End", periodEnd.Name); + Assert.Equal("End", periodEnd[RelationalAnnotationNames.ColumnName]); + Assert.True(periodEnd.IsShadowProperty()); + Assert.Equal(typeof(DateTime), periodEnd.ClrType); + Assert.Equal(ValueGenerated.OnAddOrUpdate, periodEnd.ValueGenerated); + + var propertyMappedToStart = entity.GetProperty("PeriodStart"); + Assert.Equal("PeriodStartColumn", propertyMappedToStart[RelationalAnnotationNames.ColumnName]); + Assert.Equal(ValueGenerated.Never, propertyMappedToStart.ValueGenerated); + + var propertyMappedToEnd = entity.GetProperty("PeriodEnd"); + Assert.Equal("PeriodEndColumn", propertyMappedToEnd[RelationalAnnotationNames.ColumnName]); + Assert.Equal(ValueGenerated.Never, propertyMappedToEnd.ValueGenerated); + } + + [ConditionalFact] + public virtual void Switching_from_temporal_to_non_temporal_default_settings() + { + var modelBuilder = CreateModelBuilder(); + var model = modelBuilder.Model; + + modelBuilder.Entity().ToTable(tb => tb.IsTemporal()); + modelBuilder.Entity().ToTable(tb => tb.IsTemporal(false)); + + modelBuilder.FinalizeModel(); + + var entity = model.FindEntityType(typeof(Customer)); + Assert.False(entity.IsTemporal()); + Assert.Null(entity.GetTemporalPeriodStartPropertyName()); + Assert.Null(entity.GetTemporalPeriodEndPropertyName()); + Assert.Equal(3, entity.GetProperties().Count()); + } + protected override TestModelBuilder CreateModelBuilder(Action configure = null) => CreateTestModelBuilder(SqlServerTestHelpers.Instance, configure); } + + public abstract class TestTemporalTableBuilder + where TEntity : class + { + public abstract TestTemporalTableBuilder WithHistoryTable(string name, string schema); + + public abstract TestTemporalPeriodPropertyBuilder HasPeriodStart(string propertyName); + public abstract TestTemporalPeriodPropertyBuilder HasPeriodEnd(string propertyName); + } + + public class GenericTestTemporalTableBuilder : TestTemporalTableBuilder, IInfrastructure> + where TEntity : class + { + public GenericTestTemporalTableBuilder(TemporalTableBuilder temporalTableBuilder) + { + TemporalTableBuilder = temporalTableBuilder; + } + + protected TemporalTableBuilder TemporalTableBuilder { get; } + + public TemporalTableBuilder Instance => TemporalTableBuilder; + + protected virtual TestTemporalTableBuilder Wrap(TemporalTableBuilder tableBuilder) + => new GenericTestTemporalTableBuilder(tableBuilder); + + public override TestTemporalTableBuilder WithHistoryTable(string name, string schema) + => Wrap(TemporalTableBuilder.WithHistoryTable(name, schema)); + + public override TestTemporalPeriodPropertyBuilder HasPeriodStart(string propertyName) + => new TestTemporalPeriodPropertyBuilder(TemporalTableBuilder.HasPeriodStart(propertyName)); + + public override TestTemporalPeriodPropertyBuilder HasPeriodEnd(string propertyName) + => new TestTemporalPeriodPropertyBuilder(TemporalTableBuilder.HasPeriodEnd(propertyName)); + } + + public class NonGenericTestTemporalTableBuilder : TestTemporalTableBuilder, IInfrastructure + where TEntity : class + { + public NonGenericTestTemporalTableBuilder(TemporalTableBuilder temporalTableBuilder) + { + TemporalTableBuilder = temporalTableBuilder; + } + + protected TemporalTableBuilder TemporalTableBuilder { get; } + + public TemporalTableBuilder Instance => TemporalTableBuilder; + + protected virtual TestTemporalTableBuilder Wrap(TemporalTableBuilder temporalTableBuilder) + => new NonGenericTestTemporalTableBuilder(temporalTableBuilder); + + public override TestTemporalTableBuilder WithHistoryTable(string name, string schema) + => Wrap(TemporalTableBuilder.WithHistoryTable(name, schema)); + + public override TestTemporalPeriodPropertyBuilder HasPeriodStart(string propertyName) + => new TestTemporalPeriodPropertyBuilder(TemporalTableBuilder.HasPeriodStart(propertyName)); + + public override TestTemporalPeriodPropertyBuilder HasPeriodEnd(string propertyName) + => new TestTemporalPeriodPropertyBuilder(TemporalTableBuilder.HasPeriodEnd(propertyName)); + } + + public class TestTemporalPeriodPropertyBuilder + { + public TestTemporalPeriodPropertyBuilder(TemporalPeriodPropertyBuilder temporalPeriodPropertyBuilder) + { + TemporalPeriodPropertyBuilder = temporalPeriodPropertyBuilder; + } + + protected TemporalPeriodPropertyBuilder TemporalPeriodPropertyBuilder { get; } + + public TestTemporalPeriodPropertyBuilder HasColumnName(string name) + => new TestTemporalPeriodPropertyBuilder(TemporalPeriodPropertyBuilder.HasColumnName(name)); + } } } diff --git a/test/EFCore.SqlServer.Tests/ModelBuilding/SqlServerTestModelBuilderExtensions.cs b/test/EFCore.SqlServer.Tests/ModelBuilding/SqlServerTestModelBuilderExtensions.cs index 4910a2694c2..d12e8da9843 100644 --- a/test/EFCore.SqlServer.Tests/ModelBuilding/SqlServerTestModelBuilderExtensions.cs +++ b/test/EFCore.SqlServer.Tests/ModelBuilding/SqlServerTestModelBuilderExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -44,5 +45,59 @@ public static class SqlServerTestModelBuilderExtensions 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) + where TEntity : class + { + switch (builder) + { + case IInfrastructure> genericBuilder: + genericBuilder.Instance.IsTemporal(temporal); + break; + case IInfrastructure nongenericBuilder: + nongenericBuilder.Instance.IsTemporal(temporal); + break; + } + + return builder; + } + + public static RelationalModelBuilderTest.TestTableBuilder IsTemporal( + this RelationalModelBuilderTest.TestTableBuilder builder, + Action> buildAction) + where TEntity : class + { + switch (builder) + { + case IInfrastructure> genericBuilder: + genericBuilder.Instance.IsTemporal(b => buildAction(new SqlServerModelBuilderGenericTest.GenericTestTemporalTableBuilder(b))); + break; + case IInfrastructure nongenericBuilder: + nongenericBuilder.Instance.IsTemporal(b => buildAction(new SqlServerModelBuilderGenericTest.NonGenericTestTemporalTableBuilder(b))); + break; + } + + return builder; + } } } diff --git a/test/EFCore.SqlServer.Tests/Scaffolding/SqlServerCodeGeneratorTest.cs b/test/EFCore.SqlServer.Tests/Scaffolding/SqlServerCodeGeneratorTest.cs index f0aebbfd3fb..d311bd88ec3 100644 --- a/test/EFCore.SqlServer.Tests/Scaffolding/SqlServerCodeGeneratorTest.cs +++ b/test/EFCore.SqlServer.Tests/Scaffolding/SqlServerCodeGeneratorTest.cs @@ -47,7 +47,7 @@ public virtual void Use_provider_method_is_generated_correctly_with_options() var nestedClosure = Assert.IsType(a); Assert.Equal("x", nestedClosure.Parameter); - Assert.Same(providerOptions, nestedClosure.MethodCall); + Assert.Same(providerOptions, nestedClosure.MethodCalls[0]); }); Assert.Null(result.ChainedCall); } diff --git a/test/EFCore.Sqlite.Tests/Scaffolding/SqliteCodeGeneratorTest.cs b/test/EFCore.Sqlite.Tests/Scaffolding/SqliteCodeGeneratorTest.cs index 77fa52defa6..2c8d0b31a66 100644 --- a/test/EFCore.Sqlite.Tests/Scaffolding/SqliteCodeGeneratorTest.cs +++ b/test/EFCore.Sqlite.Tests/Scaffolding/SqliteCodeGeneratorTest.cs @@ -46,7 +46,7 @@ public virtual void Use_provider_method_is_generated_correctly_with_options() var nestedClosure = Assert.IsType(a); Assert.Equal("x", nestedClosure.Parameter); - Assert.Same(providerOptions, nestedClosure.MethodCall); + Assert.Same(providerOptions, nestedClosure.MethodCalls[0]); }); Assert.Null(result.ChainedCall); }