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;
}
}