SQL Server または Azure SQL のテンポラル テーブル

SQL Server テンポラル テーブル は、そのデータが更新または削除された後でも、テーブルに格納されたすべてのデータを自動的に追跡します。 これは、メイン テーブルに変更が加えられたたびにタイムスタンプ付きの履歴データが格納される並列の "履歴テーブル" を作成することによって実現されます。 これにより、監査用などの履歴データを照会したり、偶発的な変更や削除後の復旧など、復元したりできます。

EF Core では、次の機能がサポートされます。

  • EF Core マイグレーションを使用したテンポラル テーブルの作成
  • 既存のテーブルをテンポラルテーブルに変換するために、再度マイグレーションを使用する
  • 履歴データのクエリ
  • 過去のある時点からのデータの復元

テンポラル テーブルの設定

モデル ビルダーを使用して、テーブルをテンポラルとして構成できます。 例えば次が挙げられます。

modelBuilder
    .Entity<Employee>()
    .ToTable("Employees", b => b.IsTemporal());

ヒント

ここで示すコードは 、TemporalTablesSample.csから取得されています。

EF Core を使用してデータベースを作成する場合、新しいテーブルはテンポラル テーブルとして構成され、タイムスタンプと履歴テーブルの SQL Server の既定値が設定されます。 たとえば、 Employee エンティティ型を考えてみましょう。

public class Employee
{
    public Guid EmployeeId { get; set; }
    public string Name { get; set; }
    public string Position { get; set; }
    public string Department { get; set; }
    public string Address { get; set; }
    public decimal AnnualSalary { get; set; }
}

作成されるテンポラル テーブルは次のようになります。

DECLARE @historyTableSchema sysname = SCHEMA_NAME()
EXEC(N'CREATE TABLE [Employees] (
    [EmployeeId] uniqueidentifier NOT NULL,
    [Name] nvarchar(100) NULL,
    [Position] nvarchar(100) NULL,
    [Department] nvarchar(100) NULL,
    [Address] nvarchar(1024) NULL,
    [AnnualSalary] decimal(10,2) NOT NULL,
    [PeriodEnd] datetime2 GENERATED ALWAYS AS ROW END NOT NULL,
    [PeriodStart] datetime2 GENERATED ALWAYS AS ROW START NOT NULL,
    CONSTRAINT [PK_Employees] PRIMARY KEY ([EmployeeId]),
    PERIOD FOR SYSTEM_TIME([PeriodStart], [PeriodEnd])
) WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + N'].[EmployeeHistory]))');

SQL Server では、datetime2PeriodEnd という 2 つの非表示のPeriodStart列が作成されます。 これらの "期間列" は、行内のデータが存在していた時間範囲を表します。 既定では、これらの列は EF Core モデルの シャドウ プロパティ にマップされ、後で示すようにクエリで使用できます。 EF Core 11 以降では、期間列をエンティティ型 の CLR プロパティにマップ することもできます。

Von Bedeutung

これらの列の時刻は、常に SQL Server によって生成される UTC 時刻です。 UTC 時刻は、次に示すクエリのように、テンポラル テーブルに関連するすべての操作に使用されます。

EmployeeHistoryという名前の関連する履歴テーブルが自動的に作成されていることにも注意してください。 期間列と履歴テーブルの名前は、モデル ビルダーへの追加構成で変更できます。 例えば次が挙げられます。

modelBuilder
    .Entity<Employee>()
    .ToTable(
        "Employees",
        b => b.IsTemporal(
            b =>
            {
                b.HasPeriodStart("ValidFrom");
                b.HasPeriodEnd("ValidTo");
                b.UseHistoryTable("EmployeeHistoricalData");
            }));

これは、SQL Server によって作成されたテーブルに反映されます。

DECLARE @historyTableSchema sysname = SCHEMA_NAME()
EXEC(N'CREATE TABLE [Employees] (
    [EmployeeId] uniqueidentifier NOT NULL,
    [Name] nvarchar(100) NULL,
    [Position] nvarchar(100) NULL,
    [Department] nvarchar(100) NULL,
    [Address] nvarchar(1024) NULL,
    [AnnualSalary] decimal(10,2) NOT NULL,
    [ValidFrom] datetime2 GENERATED ALWAYS AS ROW START NOT NULL,
    [ValidTo] datetime2 GENERATED ALWAYS AS ROW END NOT NULL,
    CONSTRAINT [PK_Employees] PRIMARY KEY ([EmployeeId]),
    PERIOD FOR SYSTEM_TIME([ValidFrom], [ValidTo])
) WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + N'].[EmployeeHistoricalData]))');

テンポラル テーブルの使用

ほとんどの場合、テンポラル テーブルは他のテーブルと同様に使用されます。 つまり、期間列と履歴データは、アプリケーションで無視できるように SQL Server によって透過的に処理されます。 たとえば、新しいエンティティは、通常の方法でデータベースに保存できます。

context.AddRange(
    new Employee
    {
        Name = "Pinky Pie",
        Address = "Sugarcube Corner, Ponyville, Equestria",
        Department = "DevDiv",
        Position = "Party Organizer",
        AnnualSalary = 100.0m
    },
    new Employee
    {
        Name = "Rainbow Dash",
        Address = "Cloudominium, Ponyville, Equestria",
        Department = "DevDiv",
        Position = "Ponyville weather patrol",
        AnnualSalary = 900.0m
    },
    new Employee
    {
        Name = "Fluttershy",
        Address = "Everfree Forest, Equestria",
        Department = "DevDiv",
        Position = "Animal caretaker",
        AnnualSalary = 30.0m
    });

await context.SaveChangesAsync();

このデータは、通常の方法で照会、更新、および削除できます。 例えば次が挙げられます。

var employee = await context.Employees.SingleAsync(e => e.Name == "Rainbow Dash");
context.Remove(employee);
await context.SaveChangesAsync();

また、通常の 追跡クエリの後に、現在のデータの期間列の値に 追跡対象エンティティからアクセスできます。 期間列が CLR プロパティにマップされている場合は、エンティティで直接アクセスできます。それ以外の場合は、 EF.Property を使用してシャドウ プロパティとしてアクセスします。 例えば次が挙げられます。

var employees = await context.Employees.ToListAsync();
foreach (var employee in employees)
{
    var employeeEntry = context.Entry(employee);
    var validFrom = employeeEntry.Property<DateTime>("ValidFrom").CurrentValue;
    var validTo = employeeEntry.Property<DateTime>("ValidTo").CurrentValue;

    Console.WriteLine($"  Employee {employee.Name} valid from {validFrom} to {validTo}");
}

これにより、次の内容が出力されます。

Starting data:
  Employee Pinky Pie valid from 8/26/2021 4:38:58 PM to 12/31/9999 11:59:59 PM
  Employee Rainbow Dash valid from 8/26/2021 4:38:58 PM to 12/31/9999 11:59:59 PM
  Employee Fluttershy valid from 8/26/2021 4:38:58 PM to 12/31/9999 11:59:59 PM

ValidTo列 (既定では PeriodEnd) にdatetime2の最大値が含まれていることに注意してください。 これは常に、テーブル内の現在の行の場合です。 ValidFrom列 (既定では PeriodStart と呼ばれます) には、行が挿入された UTC 時刻が含まれます。

履歴データのクエリ

EF Core では、いくつかの特殊なクエリ演算子を使用して履歴データを含むクエリをサポートしています。

  • TemporalAsOf: 指定された UTC 時刻にアクティブ (現在) であった行を返します。 これは、特定の主キーの現在のテーブルまたは履歴テーブルの 1 行です。
  • TemporalAll: 履歴データ内のすべての行を返します。 通常、これは、特定の主キーの履歴テーブルまたは現在のテーブルの多数の行です。
  • TemporalFromTo: 指定された 2 つの UTC 時刻の間にアクティブであったすべての行を返します。 指定された主キーに対して、履歴テーブルや現在のテーブルから多くの行が含まれる可能性があります。
  • TemporalBetween: TemporalFromToと同じですが、上限でアクティブになった行が含まれる点が異なります。
  • TemporalContainedIn: アクティブになり始め、終了した 2 つの UTC 時刻の間にアクティブになっているすべての行を返します。 指定された主キーに対して、履歴テーブルや現在のテーブルから多くの行が含まれる可能性があります。

これらの各演算子に含まれる行の詳細については、 SQL Server テンポラル テーブルのドキュメント を参照してください。

たとえば、データの更新と削除を行った後、 TemporalAll を使用してクエリを実行して履歴データを表示できます。

var history = await context
    .Employees
    .TemporalAll()
    .Where(e => e.Name == "Rainbow Dash")
    .OrderBy(e => EF.Property<DateTime>(e, "ValidFrom"))
    .Select(
        e => new
        {
            Employee = e,
            ValidFrom = EF.Property<DateTime>(e, "ValidFrom"),
            ValidTo = EF.Property<DateTime>(e, "ValidTo")
        })
    .ToListAsync();

foreach (var pointInTime in history)
{
    Console.WriteLine(
        $"  Employee {pointInTime.Employee.Name} was '{pointInTime.Employee.Position}' from {pointInTime.ValidFrom} to {pointInTime.ValidTo}");
}

EF.Property メソッドが期間列の値にアクセスする方法をご覧ください。 これは、データを並べ替えるために OrderBy 句で使用され、プロジェクションで返されるデータにこれらの値を含めます。 期間列が CLR プロパティにマップされている場合は、 EF.Propertyを使用する代わりに、クエリで直接参照できます。

このクエリでは、次のデータが返されます。

Historical data for Rainbow Dash:
  Employee Rainbow Dash was 'Ponyville weather patrol' from 8/26/2021 4:38:58 PM to 8/26/2021 4:40:29 PM
  Employee Rainbow Dash was 'Wonderbolt Trainee' from 8/26/2021 4:40:29 PM to 8/26/2021 4:41:59 PM
  Employee Rainbow Dash was 'Wonderbolt Reservist' from 8/26/2021 4:41:59 PM to 8/26/2021 4:43:29 PM
  Employee Rainbow Dash was 'Wonderbolt' from 8/26/2021 4:43:29 PM to 8/26/2021 4:44:59 PM

最後に返された行が、8/26/2021 4:44:59 PM にアクティブ状態を停止したことに注意してください。 これは、その時点で、レインボー ダッシュの行がメイン テーブルから削除されたためです。 このデータを復元する方法については、後で説明します。

同様のクエリは、 TemporalFromToTemporalBetween、または TemporalContainedInを使用して記述できます。 例えば次が挙げられます。

var history = await context
    .Employees
    .TemporalBetween(timeStamp2, timeStamp3)
    .Where(e => e.Name == "Rainbow Dash")
    .OrderBy(e => EF.Property<DateTime>(e, "ValidFrom"))
    .Select(
        e => new
        {
            Employee = e,
            ValidFrom = EF.Property<DateTime>(e, "ValidFrom"),
            ValidTo = EF.Property<DateTime>(e, "ValidTo")
        })
    .ToListAsync();

このクエリは、次の行を返します。

Historical data for Rainbow Dash between 8/26/2021 4:41:14 PM and 8/26/2021 4:42:44 PM:
  Employee Rainbow Dash was 'Wonderbolt Trainee' from 8/26/2021 4:40:29 PM to 8/26/2021 4:41:59 PM
  Employee Rainbow Dash was 'Wonderbolt Reservist' from 8/26/2021 4:41:59 PM to 8/26/2021 4:43:29 PM

履歴データの復元

前述のように、レインボーダッシュは Employees テーブルから削除されました。 これは明らかに間違いだったので、特定の時点に戻って、その時点から不足している行を復元しましょう。

var employee = await context
    .Employees
    .TemporalAsOf(timeStamp2)
    .SingleAsync(e => e.Name == "Rainbow Dash");

context.Add(employee);
await context.SaveChangesAsync();

このクエリは、指定された UTC 時刻と同じように、レインボー ダッシュの 1 行を返します。 テンポラル演算子を使用するすべてのクエリは既定では追跡されないため、ここで返されるエンティティは追跡されません。 これは、現在メイン テーブルに存在しないため、理にかなっています。 エンティティをメイン テーブルに再挿入するには、エンティティを Added としてマークし、 SaveChangesを呼び出します。

行を再度挿入した後、履歴データに対してクエリを実行すると、指定された UTC 時刻に行が復元されたことが示されます。

Historical data for Rainbow Dash:
  Employee Rainbow Dash was 'Ponyville weather patrol' from 8/26/2021 4:38:58 PM to 8/26/2021 4:40:29 PM
  Employee Rainbow Dash was 'Wonderbolt Trainee' from 8/26/2021 4:40:29 PM to 8/26/2021 4:41:59 PM
  Employee Rainbow Dash was 'Wonderbolt Reservist' from 8/26/2021 4:41:59 PM to 8/26/2021 4:43:29 PM
  Employee Rainbow Dash was 'Wonderbolt' from 8/26/2021 4:43:29 PM to 8/26/2021 4:44:59 PM
  Employee Rainbow Dash was 'Wonderbolt Trainee' from 8/26/2021 4:44:59 PM to 12/31/9999 11:59:59 PM

CLR プロパティへの期間列のマッピング

この機能は、現在プレビュー段階にある EF Core 11 で導入されています。

既定では、テンポラル テーブルの期間列は EF Core モデルの shadow プロパティ にマップされます。つまり、.NET エンティティ型に存在する必要はありません。 EF Core 11 以降では、代わりにエンティティ型の通常の CLR プロパティにピリオド列をマップできます。これにより、それらの値に直接アクセスできます。

これを行うには、期間の開始と終了 DateTime プロパティをエンティティ型に追加します。

public class Employee
{
    public Guid EmployeeId { get; set; }
    public string Name { get; set; }
    public string Position { get; set; }
    public string Department { get; set; }
    public string Address { get; set; }
    public decimal AnnualSalary { get; set; }
    public DateTime PeriodStart { get; set; }
    public DateTime PeriodEnd { get; set; }
}

次に、ラムダ式を使用してこれらのプロパティを使用するようにテンポラル テーブルを構成します。

modelBuilder
    .Entity<Employee>()
    .ToTable(
        "Employees",
        b => b.IsTemporal(
            b =>
            {
                b.HasPeriodStart(e => e.PeriodStart);
                b.HasPeriodEnd(e => e.PeriodEnd);
            }));

Period プロパティは ValueGenerated.OnAddOrUpdate で自動的に構成されるため、値は常にSQL Serverによって生成されます。 エンティティを挿入または更新するときに値を設定する必要はありません (また、設定する必要はありません)。

期間列が CLR プロパティにマップされている場合は、 EF.Propertyを使用する代わりに、エンティティで値に直接アクセスできます。

var history = context
    .Employees
    .TemporalAll()
    .Where(e => e.Name == "Rainbow Dash")
    .OrderBy(e => e.PeriodStart)
    .Select(
        e => new
        {
            Employee = e,
            e.PeriodStart,
            e.PeriodEnd
        })
    .ToList();