diff --git a/src/EFCore.SqlServer/Extensions/SqlServerEntityTypeBuilderExtensions.cs b/src/EFCore.SqlServer/Extensions/SqlServerEntityTypeBuilderExtensions.cs index 0523cdbdc04..4b0ed004ab8 100644 --- a/src/EFCore.SqlServer/Extensions/SqlServerEntityTypeBuilderExtensions.cs +++ b/src/EFCore.SqlServer/Extensions/SqlServerEntityTypeBuilderExtensions.cs @@ -1,6 +1,9 @@ // 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 System.Linq.Expressions; +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.SqlServer.Metadata.Internal; using Microsoft.EntityFrameworkCore.Utilities; @@ -116,5 +119,105 @@ public static bool CanSetIsMemoryOptimized( return entityTypeBuilder.CanSetAnnotation(SqlServerAnnotationNames.MemoryOptimized, memoryOptimized, fromDataAnnotation); } + + /// + /// Configures the table that the entity maps to when targeting SQL Server as temporal. + /// + /// The builder for the entity type being configured. + /// A value specifying the property name representing start of the period. + /// A value specifying the property name representing end of the period. + /// A value specifying the history table name for this entity. Default name will be used if none is specified. + /// The same builder instance so that multiple calls can be chained. + public static EntityTypeBuilder IsTemporal( + this EntityTypeBuilder entityTypeBuilder, + string periodStartPropertyName, + string periodEndPropertyName, + string? historyTableName = null) + { + Check.NotNull(entityTypeBuilder, nameof(entityTypeBuilder)); + + var value = new SqlServerTemporalTableTransientAnnotationValue(periodStartPropertyName, periodEndPropertyName, historyTableName); + + entityTypeBuilder.Metadata.SetIsTemporal(value); + + // also add Start and End properties in shadow state + entityTypeBuilder.Property(periodStartPropertyName); + entityTypeBuilder.Property(periodEndPropertyName); + + return entityTypeBuilder; + } + + /// + /// Configures the table that the entity maps to when targeting SQL Server as temporal. + /// + /// The entity type being configured. + /// The builder for the entity type being configured. + /// A value specifying the property representing start of the period. + /// A value specifying the property representing end of the period. + /// A value specifying the history table name for this entity. Default name will be used if none is specified. + /// The same builder instance so that multiple calls can be chained. + public static EntityTypeBuilder IsTemporal( + this EntityTypeBuilder entityTypeBuilder, + Expression> periodStartPropertyExpression, + Expression> periodEndPropertyExpression, + string? historyTableName = null) + where TEntity : class + => (EntityTypeBuilder)IsTemporal( + entityTypeBuilder, + periodStartPropertyExpression.GetMemberAccess().Name, + periodEndPropertyExpression.GetMemberAccess().Name, + historyTableName); + + /// + /// Configures the table that the entity maps to when targeting SQL Server as temporal. + /// + /// The builder for the entity type being configured. + /// A value specifying the property name representing start of the period. + /// A value specifying the property name representing end of the period. + /// A value specifying the history table name for this entity. Default name will be used if none is specified. + /// 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, + string periodStartPropertyName, + string periodEndPropertyName, + string? historyTableName = null, + bool fromDataAnnotation = false) + { + if (entityTypeBuilder.CanSetIsTemporal(periodStartPropertyName, periodEndPropertyName, historyTableName, fromDataAnnotation)) + { + entityTypeBuilder.Metadata.SetIsTemporal(periodStartPropertyName, periodEndPropertyName, historyTableName, fromDataAnnotation); + + return entityTypeBuilder; + } + + return null; + } + + /// + /// Returns a value indicating whether the mapped table can be configured as memory-optimized. + /// + /// The builder for the entity type being configured. + /// A value specifying the property name representing start of the period. + /// A value specifying the property name representing end of the period. + /// A value specifying the history table name for this entity. Default name will be used if none is specified. + /// Indicates whether the configuration was specified using a data annotation. + /// if the mapped table can be configured as memory-optimized. + public static bool CanSetIsTemporal( + this IConventionEntityTypeBuilder entityTypeBuilder, + string periodStartPropertyName, + string periodEndPropertyName, + string? historyTableName = null, + bool fromDataAnnotation = false) + { + Check.NotNull(entityTypeBuilder, nameof(entityTypeBuilder)); + + var value = new SqlServerTemporalTableTransientAnnotationValue(periodStartPropertyName, periodEndPropertyName, historyTableName); + + return entityTypeBuilder.CanSetAnnotation(SqlServerAnnotationNames.Temporal, value, fromDataAnnotation); + } } } diff --git a/src/EFCore.SqlServer/Extensions/SqlServerEntityTypeExtensions.cs b/src/EFCore.SqlServer/Extensions/SqlServerEntityTypeExtensions.cs index 386289a3fce..df4a3df489e 100644 --- a/src/EFCore.SqlServer/Extensions/SqlServerEntityTypeExtensions.cs +++ b/src/EFCore.SqlServer/Extensions/SqlServerEntityTypeExtensions.cs @@ -52,5 +52,50 @@ 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.Temporal] is SqlServerTemporalTableTransientAnnotationValue + || entityType[SqlServerAnnotationNames.Temporal] is SqlServerTemporalTableAnnotationValue; + + /// + /// Sets a value indicating that the entity type is mapped to a temporal table and relevant mapping configuration. + /// + /// The entity type. + /// The value to set. + public static void SetIsTemporal(this IMutableEntityType entityType, SqlServerTemporalTableTransientAnnotationValue temporal) + => entityType.SetOrRemoveAnnotation(SqlServerAnnotationNames.Temporal, temporal); + + /// + /// Sets a value indicating that the entity type is mapped to a temporal table and relevant mapping configuration. + /// + /// The entity type. + /// A value specifying the property name representing start of the period. + /// A value specifying the property name representing end of the period. + /// A value specifying the history table name for this entity. Default name will be used if none is specified. + /// Indicates whether the configuration was specified using a data annotation. + public static void SetIsTemporal( + this IConventionEntityType entityType, + string periodStartPropertyName, + string periodEndPropertyName, + string? historyTableName = null, + bool fromDataAnnotation = false) + { + // TODO: maumar - do we need to return value here? + var value = new SqlServerTemporalTableTransientAnnotationValue(periodStartPropertyName, periodEndPropertyName, historyTableName); + entityType.SetOrRemoveAnnotation(SqlServerAnnotationNames.Temporal, value, fromDataAnnotation); + } + + /// + /// 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.Temporal)?.GetConfigurationSource(); } } diff --git a/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs b/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs index 83989029de3..a8f4ce8ab49 100644 --- a/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs +++ b/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs @@ -223,6 +223,12 @@ protected override void ValidateSharedTableCompatibility( { var firstMappedType = mappedTypes[0]; var isMemoryOptimized = firstMappedType.IsMemoryOptimized(); + var firstTemporalAnnotation = firstMappedType.FindAnnotation(SqlServerAnnotationNames.Temporal); + if (firstTemporalAnnotation != null + && firstTemporalAnnotation.Value is SqlServerTemporalTableTransientAnnotationValue) + { + throw new InvalidOperationException("Annotation should not be transient at this point."); + } foreach (var otherMappedType in mappedTypes.Skip(1)) { @@ -234,6 +240,19 @@ protected override void ValidateSharedTableCompatibility( isMemoryOptimized ? firstMappedType.DisplayName() : otherMappedType.DisplayName(), !isMemoryOptimized ? firstMappedType.DisplayName() : otherMappedType.DisplayName())); } + + var temporalAnnotation = otherMappedType.FindAnnotation(SqlServerAnnotationNames.Temporal); + if (temporalAnnotation is null != firstTemporalAnnotation is null) + { + throw new InvalidOperationException("Temporal annotation missing on root or derived."); + } + + if (temporalAnnotation is not null + && firstTemporalAnnotation is not null + && temporalAnnotation.Value != firstTemporalAnnotation.Value) + { + throw new InvalidOperationException("Temporal annotation values are different between root and derived."); + } } base.ValidateSharedTableCompatibility(mappedTypes, tableName, schema, logger); diff --git a/src/EFCore.SqlServer/Metadata/Conventions/SqlServerConventionSetBuilder.cs b/src/EFCore.SqlServer/Metadata/Conventions/SqlServerConventionSetBuilder.cs index 7a5e5bad138..ea93b94427f 100644 --- a/src/EFCore.SqlServer/Metadata/Conventions/SqlServerConventionSetBuilder.cs +++ b/src/EFCore.SqlServer/Metadata/Conventions/SqlServerConventionSetBuilder.cs @@ -101,6 +101,9 @@ public override ConventionSet CreateConventionSet() (SharedTableConvention)new SqlServerSharedTableConvention(Dependencies, RelationalDependencies)); conventionSet.ModelFinalizingConventions.Add(new SqlServerDbFunctionConvention(Dependencies, RelationalDependencies)); + var temporalConvention = new SqlServerTemporalConvention(); + conventionSet.ModelFinalizingConventions.Add(temporalConvention); + return conventionSet; } diff --git a/src/EFCore.SqlServer/Metadata/Conventions/SqlServerTemporalConvention.cs b/src/EFCore.SqlServer/Metadata/Conventions/SqlServerTemporalConvention.cs new file mode 100644 index 00000000000..41f6157a1c1 --- /dev/null +++ b/src/EFCore.SqlServer/Metadata/Conventions/SqlServerTemporalConvention.cs @@ -0,0 +1,53 @@ +// 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.Linq; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.SqlServer.Metadata.Internal; + +namespace Microsoft.EntityFrameworkCore.Metadata.Conventions +{ + /// + /// TODO: add comments + /// + public class SqlServerTemporalConvention : IModelFinalizingConvention + { + /// + /// TODO: add comments + /// + public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext context) + { + // TODO: is this the right place to do all the tweaks? + foreach (var rootEntityType in modelBuilder.Metadata.GetEntityTypes().Select(et => et.GetRootType()).Distinct()) + { + if (rootEntityType[SqlServerAnnotationNames.Temporal] is SqlServerTemporalTableTransientAnnotationValue transientValue) + //if (rootEntityType.FindAnnotation(SqlServerAnnotationNames.Temporal) is IConventionAnnotation annotation + // && annotation.Value is SqlServerTemporalTableTransientAnnotationValue transientValue) + { + var startProperty = rootEntityType.FindProperty(transientValue.PeriodStartPropertyName); + var startColumn = startProperty!.GetColumnBaseName(); + startProperty!.SetValueGenerated(ValueGenerated.OnAddOrUpdate); + var endProperty = rootEntityType.FindProperty(transientValue.PeriodEndPropertyName); + endProperty!.SetValueGenerated(ValueGenerated.OnAddOrUpdate); + var endColumn = endProperty!.GetColumnBaseName(); + + var finalValue = new SqlServerTemporalTableAnnotationValue(startColumn, endColumn, transientValue.HistoryTableName); + rootEntityType.SetAnnotation(SqlServerAnnotationNames.Temporal, finalValue); + } + } + + foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()) + { + if (entityType != entityType.GetRootType() + && entityType.GetRootType()[SqlServerAnnotationNames.Temporal] is SqlServerTemporalTableAnnotationValue rootAnnotationValue + //&& entityType.GetRootType().FindAnnotation(SqlServerAnnotationNames.Temporal) is IConventionAnnotation rootAnnotation + //&& rootAnnotation.Value is SqlServerTemporalTableAnnotationValue rootAnnotationValue + //&& entityType.FindAnnotation(SqlServerAnnotationNames.Temporal) == null) + && entityType[SqlServerAnnotationNames.Temporal] != null) + { + entityType.SetAnnotation(SqlServerAnnotationNames.Temporal, rootAnnotationValue); + } + } + } + } +} diff --git a/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationNames.cs b/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationNames.cs index 5665ee5f9fd..a8405a50346 100644 --- a/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationNames.cs +++ b/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationNames.cs @@ -139,6 +139,14 @@ 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 Temporal = Prefix + "Temporal"; + /// /// 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 fd56c73fdc1..3e40639db23 100644 --- a/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs +++ b/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs @@ -97,6 +97,13 @@ public override IEnumerable For(ITable table) { yield return new Annotation(SqlServerAnnotationNames.MemoryOptimized, true); } + + if (table.EntityTypeMappings.First().EntityType.IsTemporal()) + { + var value = table.EntityTypeMappings.First().EntityType[SqlServerAnnotationNames.Temporal]; + + yield return new Annotation(SqlServerAnnotationNames.Temporal, value); + } } /// @@ -192,6 +199,17 @@ public override IEnumerable For(IColumn column) { yield return new Annotation(SqlServerAnnotationNames.Sparse, isSparse); } + + var entityType = column.Table.EntityTypeMappings.First().EntityType; + if (entityType.IsTemporal() + && entityType[SqlServerAnnotationNames.Temporal] is SqlServerTemporalTableAnnotationValue annotationValue + && (column.Name == annotationValue.PeriodStartColumnName || column.Name == annotationValue.PeriodEndColumnName)) + { + // TODO: should we use dedicated annotations for these? + yield return new Annotation( + SqlServerAnnotationNames.Temporal, + new SqlServerTemporalTableAnnotationValue(annotationValue.PeriodStartColumnName, annotationValue.PeriodEndColumnName, annotationValue.HistoryTableName)); + } } } } diff --git a/src/EFCore.SqlServer/Metadata/Internal/SqlServerTemporalTableAnnotationValue.cs b/src/EFCore.SqlServer/Metadata/Internal/SqlServerTemporalTableAnnotationValue.cs new file mode 100644 index 00000000000..21bfd6e6f9c --- /dev/null +++ b/src/EFCore.SqlServer/Metadata/Internal/SqlServerTemporalTableAnnotationValue.cs @@ -0,0 +1,103 @@ +// 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. + +namespace Microsoft.EntityFrameworkCore.SqlServer.Metadata.Internal +{ + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public struct SqlServerTemporalTableAnnotationValue + { + /// + /// 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 SqlServerTemporalTableAnnotationValue( + string? periodStartColumnName, + string? periodEndColumnName, + string? historyTableName) + { + PeriodStartColumnName = periodStartColumnName; + PeriodEndColumnName = periodEndColumnName; + HistoryTableName = historyTableName; + } + + /// + /// 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 string? PeriodStartColumnName { get; set; } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public string? PeriodEndColumnName { get; set; } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public string? HistoryTableName { get; set; } + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public struct SqlServerTemporalTableTransientAnnotationValue + { + /// + /// 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 SqlServerTemporalTableTransientAnnotationValue( + string periodStartPropertyName, + string periodEndPropertyName, + string? historyTableName) + { + PeriodStartPropertyName = periodStartPropertyName; + PeriodEndPropertyName = periodEndPropertyName; + HistoryTableName = historyTableName; + } + + /// + /// 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 string PeriodStartPropertyName { get; private set; } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public string PeriodEndPropertyName { get; private set; } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public string? HistoryTableName { get; set; } + } +} diff --git a/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs b/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs index 1af1ba7deb4..b32f96cefc0 100644 --- a/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs +++ b/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; @@ -34,6 +35,8 @@ namespace Microsoft.EntityFrameworkCore.Migrations /// public class SqlServerMigrationsSqlGenerator : MigrationsSqlGenerator { + private const string DefaultSchema = "dbo"; + private IReadOnlyList _operations = null!; private int _variableCounter; @@ -543,7 +546,39 @@ protected override void Generate( throw new ArgumentException(SqlServerStrings.CannotProduceUnterminatedSQLWithComments(nameof(CreateTableOperation))); } - base.Generate(operation, model, builder, terminate: false); + if (operation[SqlServerAnnotationNames.Temporal] is SqlServerTemporalTableAnnotationValue temporal) + { + builder + .Append("CREATE TABLE ") + .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name, operation.Schema)) + .AppendLine(" ("); + + using (builder.Indent()) + { + CreateTableColumns(operation, model, builder); + CreateTableConstraints(operation, model, builder); + builder.AppendLine(","); + + Debug.Assert(temporal.PeriodStartColumnName != null, "PeriodStart shouldn't be null at this point."); + Debug.Assert(temporal.PeriodEndColumnName != null, "PeriodEnd shouldn't be null at this point."); + + var start = Dependencies.SqlGenerationHelper.DelimitIdentifier(temporal.PeriodStartColumnName); + var end = Dependencies.SqlGenerationHelper.DelimitIdentifier(temporal.PeriodEndColumnName); + builder.AppendLine($"PERIOD FOR SYSTEM_TIME({start}, {end})"); + } + + builder.Append(") WITH (SYSTEM_VERSIONING = ON"); + if (temporal.HistoryTableName != null) + { + builder.Append($" (HISTORY_TABLE = {Dependencies.SqlGenerationHelper.DelimitIdentifier(temporal.HistoryTableName, operation.Schema ?? DefaultSchema)})"); + } + + builder.Append(")"); + } + else + { + base.Generate(operation, model, builder, terminate: false); + } var memoryOptimized = IsMemoryOptimized(operation); if (memoryOptimized) @@ -635,7 +670,25 @@ protected override void Generate( MigrationCommandListBuilder builder, bool terminate = true) { - base.Generate(operation, model, builder, terminate: false); + if (operation[SqlServerAnnotationNames.Temporal] is SqlServerTemporalTableAnnotationValue temporalTableAnnotationValue) + { + builder.Append("ALTER TABLE ") + .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name, operation.Schema)) + .AppendLine(" SET (SYSTEM_VERSIONING = OFF)"); + + base.Generate(operation, model, builder, terminate: false); + + Debug.Assert(temporalTableAnnotationValue.HistoryTableName != null, "History table name should never be null at this point"); + + builder.AppendLine(); + builder + .Append("DROP TABLE ") + .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(temporalTableAnnotationValue.HistoryTableName, operation.Schema)); + } + else + { + base.Generate(operation, model, builder, terminate: false); + } if (terminate) { @@ -1501,6 +1554,19 @@ protected override void ColumnDefinition( builder.Append(" SPARSE"); } + if (operation[SqlServerAnnotationNames.Temporal] is SqlServerTemporalTableAnnotationValue temporalTable) + { + builder.Append(" GENERATED ALWAYS AS ROW "); + if (name == temporalTable.PeriodStartColumnName) + { + builder.Append("START"); + } + else + { + builder.Append("END"); + } + } + builder.Append(operation.IsNullable ? " NULL" : " NOT NULL"); DefaultValue(operation.DefaultValue, operation.DefaultValueSql, columnType, builder); @@ -2039,6 +2105,10 @@ private static bool IsIdentity(ColumnOperation operation) || operation[SqlServerAnnotationNames.ValueGenerationStrategy] as SqlServerValueGenerationStrategy? == SqlServerValueGenerationStrategy.IdentityColumn; + //private static bool IsTemporal(Annotatable annotatable) + // => annotatable[SqlServerAnnotationNames.Temporal] is SqlServerTemporalTableAnnotationValue; + + private void GenerateExecWhenIdempotent( MigrationCommandListBuilder builder, Action generate) diff --git a/src/EFCore.SqlServer/Scaffolding/Internal/SqlServerDatabaseModelFactory.cs b/src/EFCore.SqlServer/Scaffolding/Internal/SqlServerDatabaseModelFactory.cs index 8a123b9cecc..3249f9ef9e8 100644 --- a/src/EFCore.SqlServer/Scaffolding/Internal/SqlServerDatabaseModelFactory.cs +++ b/src/EFCore.SqlServer/Scaffolding/Internal/SqlServerDatabaseModelFactory.cs @@ -492,6 +492,13 @@ private void GetTables( [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]"; + } + 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 +547,13 @@ AND [t].[name] <> '" CAST(0 AS bit) AS [is_memory_optimized]"; } + if (supportsTemporalTable) + { + viewCommandText += @", + 1 AS [temporal_type], + NULL AS [history_table_name]"; + } + 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 +601,15 @@ FROM [sys].[views] AS [v] } } + if (supportsTemporalTable) + { + if (reader.GetValueOrDefault("temporal_type") == 2) + { + var historyTableName = reader.GetValueOrDefault("history_table_name"); + table[SqlServerAnnotationNames.Temporal] = new SqlServerTemporalTableAnnotationValue(null, null, historyTableName); + } + } + tables.Add(table); } } diff --git a/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs index cfde56958fe..fa2a9b37647 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs @@ -5,9 +5,12 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Conventions; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Scaffolding; +using Microsoft.EntityFrameworkCore.Scaffolding.Metadata; using Microsoft.EntityFrameworkCore.Scaffolding.Metadata.Internal; using Microsoft.EntityFrameworkCore.SqlServer.Internal; using Microsoft.EntityFrameworkCore.SqlServer.Metadata.Internal; @@ -1853,6 +1856,198 @@ public override async Task UpdateDataOperation_multiple_columns() SELECT @@ROWCOUNT;"); } + [ConditionalFact] + public virtual async Task Create_temporal_table_default_period_column_mappings_and_default_history_table() + { + await TestTemporal( + builder => { }, + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.HasKey("Id"); + e.IsTemporal("SystemTimeStart", "SystemTimeEnd"); + }), + model => + { + var table = Assert.Single(model.Tables); + var temporalAnnotation = table[SqlServerAnnotationNames.Temporal]; + Assert.NotNull(temporalAnnotation); + Assert.True(table[SqlServerAnnotationNames.Temporal] is SqlServerTemporalTableAnnotationValue); + var typedTemporalAnnotation = (SqlServerTemporalTableAnnotationValue)temporalAnnotation!; + + // TODO: is there a way to get this information from model at this point, or do we only have access to database stuff? + //Assert.Equal("SystemTimeStart", typedTemporalAnnotation.PeriodStartColumnName); + //Assert.Equal("SystemTimeStart", typedTemporalAnnotation.PeriodEndColumnName); + Assert.NotNull(typedTemporalAnnotation.HistoryTableName); + + 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( + @"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);"); + } + + [ConditionalFact] + public virtual async Task Create_temporal_table_custom_history_table() + { + await TestTemporal( + builder => { }, + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.HasKey("Id"); + e.IsTemporal("SystemTimeStart", "SystemTimeEnd", "CustomerHistory"); + }), + model => + { + var table = Assert.Single(model.Tables); + var temporalAnnotation = table[SqlServerAnnotationNames.Temporal]; + Assert.NotNull(temporalAnnotation); + Assert.True(table[SqlServerAnnotationNames.Temporal] is SqlServerTemporalTableAnnotationValue); + var typedTemporalAnnotation = (SqlServerTemporalTableAnnotationValue)temporalAnnotation!; + + Assert.Equal("CustomerHistory", typedTemporalAnnotation.HistoryTableName); + + 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( + @"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 = [dbo].[CustomerHistory]));"); + } + + [ConditionalFact] + public virtual async Task Create_temporal_table_custom_period_column_mappings() + { + await TestTemporal( + builder => { }, + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.Property("Start").HasColumnName("PeriodStart"); + e.Property("End").HasColumnName("PeriodEnd"); + e.HasKey("Id"); + e.IsTemporal("Start", "End"); + }), + model => + { + var table = Assert.Single(model.Tables); + var temporalAnnotation = table[SqlServerAnnotationNames.Temporal]; + Assert.NotNull(temporalAnnotation); + Assert.True(table[SqlServerAnnotationNames.Temporal] is SqlServerTemporalTableAnnotationValue); + var typedTemporalAnnotation = (SqlServerTemporalTableAnnotationValue)temporalAnnotation!; + + Assert.NotNull(typedTemporalAnnotation.HistoryTableName); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("PeriodEnd", c.Name), + c => Assert.Equal("Name", c.Name), + c => Assert.Equal("PeriodStart", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"CREATE TABLE [Customer] ( + [Id] int NOT NULL, + [PeriodEnd] datetime2 GENERATED ALWAYS AS ROW END NOT NULL, + [Name] nvarchar(max) NULL, + [PeriodStart] datetime2 GENERATED ALWAYS AS ROW START NOT NULL, + CONSTRAINT [PK_Customer] PRIMARY KEY ([Id]), + PERIOD FOR SYSTEM_TIME([PeriodStart], [PeriodEnd]) +) WITH (SYSTEM_VERSIONING = ON);"); + } + + // TODO: if history table is not specified we get this information by querying the database + // can we simulate this here? + //[ConditionalFact] + public virtual async Task Drop_temporal_table_default_history_table() + { + await TestTemporal( + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.Property("Start").HasColumnName("PeriodStart"); + e.Property("End").HasColumnName("PeriodEnd"); + e.HasKey("Id"); + e.IsTemporal("SystemTimeStart", "SystemTimeEnd"); + }), + builder => { }, + model => + { + Assert.Empty(model.Tables); + }); + + AssertSql( + @""); + } + + [ConditionalFact] + public virtual async Task Drop_temporal_table_custom_history_table() + { + await TestTemporal( + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.Property("Start").HasColumnName("PeriodStart"); + e.Property("End").HasColumnName("PeriodEnd"); + e.HasKey("Id"); + e.IsTemporal("SystemTimeStart", "SystemTimeEnd", "CustomerHistory"); + }), + builder => { }, + model => + { + Assert.Empty(model.Tables); + }); + + AssertSql( + @"ALTER TABLE [Customer] SET (SYSTEM_VERSIONING = OFF) +DROP TABLE [Customer] +DROP TABLE [CustomerHistory];"); + } + protected override string NonDefaultCollation => _nonDefaultCollation ??= GetDatabaseCollation() == "German_PhoneBook_CI_AS" ? "French_CI_AS" @@ -1881,6 +2076,35 @@ protected override ReferentialAction Normalize(ReferentialAction value) ? ReferentialAction.NoAction : value; + private Task TestTemporal( + //Action buildCommonAction, + Action buildSourceAction, + Action buildTargetAction, + Action asserter) + { + var context = CreateContext(); + var modelDiffer = context.GetService(); + var modelRuntimeInitializer = context.GetService(); + + var conventionSet = new ConventionSet(); + conventionSet.ModelFinalizingConventions.Add(new SqlServerTemporalConvention()); + + var sourceModelBuilder = new ModelBuilder(conventionSet); + //buildCommonAction(sourceModelBuilder); + buildSourceAction(sourceModelBuilder); + var sourceModel = modelRuntimeInitializer.Initialize(sourceModelBuilder.FinalizeModel(), designTime: true, validationLogger: null); + + var targetModelBuilder = new ModelBuilder(conventionSet); + //buildCommonAction(targetModelBuilder); + buildTargetAction(targetModelBuilder); + + var targetModel = modelRuntimeInitializer.Initialize(targetModelBuilder.FinalizeModel(), designTime: true, validationLogger: null); + + var operations = modelDiffer.GetDifferences(sourceModel.GetRelationalModel(), targetModel.GetRelationalModel()); + + return Test(sourceModel, targetModel, operations, asserter); + } + public class MigrationsSqlServerFixture : MigrationsFixtureBase { protected override string StoreName { get; } = nameof(MigrationsSqlServerTest); diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/QueryBugsTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/QueryBugsTest.cs index 531db16e307..81c79d00d16 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/QueryBugsTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/QueryBugsTest.cs @@ -10023,6 +10023,98 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) #endregion + + [ConditionalFact] + public virtual void TemportalTest() + { + using (var ctx = new MyContext()) + { + ctx.Database.EnsureDeleted(); + ctx.Database.EnsureCreated(); + + var p11 = new TemporalPost { Id = 11, Name = "p11" }; + var p12 = new TemporalPost { Id = 12, Name = "p12" }; + var p21 = new TemporalPost { Id = 21, Name = "p21" }; + var p22 = new TemporalPost { Id = 22, Name = "p22" }; + var p23 = new TemporalPost { Id = 23, Name = "p23" }; + + var b1 = new TemporalBlog { Id = 1, Name = "b1", Posts = new List { p11, p12 } }; + var b2 = new TemporalBlog { Id = 2, Name = "b2", Posts = new List { p21, p22, p23 } }; + + ctx.Blogs.AddRange(b1, b2); + ctx.Posts.AddRange(p11, p12, p21, p22, p23); + ctx.SaveChanges(); + } + + using (var ctx = new MyContext()) + { + var b = ctx.Blogs.First(); + b.Name = "Renamed"; + + ctx.SaveChanges(); + } + + using (var ctx = new MyContext()) + { + //var dateTime = DateTime.Parse("2020-03-18 08:00:00"); + var dateTime = new DateTime(2020, 3, 18, 8, 0, 0); + //var query = ctx.Posts.AsOf(dateTime).Where(p => p.Blog.Name != "Foo").ToList(); + } + } + + public class MyContext : DbContext + { + public DbSet Blogs { get; set; } + public DbSet Posts { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + //modelBuilder.Entity().IsMemoryOptimized(); + + modelBuilder.Entity().IsTemporal("Start", "End", "BlogHistory"); + modelBuilder.Entity().Property(x => x.Id).ValueGeneratedNever(); + modelBuilder.Entity().Property("Start").HasColumnName("StartColumn");//.ValueGeneratedOnAdd(); + modelBuilder.Entity().Property("End");//.ValueGeneratedOnAdd(); + + modelBuilder.Entity().IsTemporal(x => x.PeriodStart, x => x.PeriodEnd); + modelBuilder.Entity().Property(x => x.Id).ValueGeneratedNever(); + + modelBuilder.Entity().HasMany(x => x.Posts).WithOne(x => x.Blog).IsRequired();//.OnDelete(DeleteBehavior.NoAction); + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=TemporalRepro2;Trusted_Connection=True;MultipleActiveResultSets=true"); + } + } + + public class TemporalBlog + { + public int Id { get; set; } + public string Name { get; set; } + public List Posts { get; set; } + + //[DatabaseGenerated(DatabaseGeneratedOption.Computed)] + //public DateTime Start { get; set; } + + //[DatabaseGenerated(DatabaseGeneratedOption.Computed)] + //public DateTime End { get; set; } + } + + public class TemporalPost + { + public int Id { get; set; } + public string Name { get; set; } + + //[DatabaseGenerated(DatabaseGeneratedOption.Computed)] + public DateTime PeriodStart { get; set; } + + //[DatabaseGenerated(DatabaseGeneratedOption.Computed)] + public DateTime PeriodEnd { get; set; } + + public TemporalBlog Blog { get; set; } + } + protected override string StoreName => "QueryBugsTest"; protected TestSqlLoggerFactory TestSqlLoggerFactory => (TestSqlLoggerFactory)ListLoggerFactory; diff --git a/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerDatabaseCleaner.cs b/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerDatabaseCleaner.cs index c13b4516a82..72e0dc85539 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,26 @@ 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.Temporal] != null) + { + operation[SqlServerAnnotationNames.Temporal] + = table[SqlServerAnnotationNames.Temporal]; + } + return operation; } }