Npgsql(ef core)を利用してエンティティのバージョン管理を行う

SQL Serverを利用していると特に工夫することなく利用できるエンティティのバージョン管理(Concurrency Tokens - EF Core | Microsoft Docs)ですが、PostgreSQLでは少し工夫が必要です。

PostgreSQLではMisc | Npgsql Documentationに記載のある通り、内部的に管理されているシステム列のうちxmin列の値をバージョン管理に使用します。

このため、コンソールアプリケーションなどのようにefのChangeTrackerが状態をすべて管理できる場合にはバージョンを気にする必要はないのですが、ASP.NETのような状態管理がefから離れるようなシステムの場合には少し面倒になります。

バージョン管理を利用するための設定

バージョン管理を使用するための設定はContextクラスに以下の設定を追加すればOKです。

 public class HogeContext : DbContext
{
        
        public DbSet<PostalAddress> PostalAddresses { get; set; }
        ...
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            // このようにForNpgsqlUseXminAsConcurrencyTokenを追加すればバージョン管理対象となる
            modelBuilder.Entity<PostalAddress.PostalAddress>().ForNpgsqlUseXminAsConcurrencyToken();
            ....
        }
}

エンティティの取得時にバージョン番号を設定する

ASP.NETなどのウェブアプリケーションではエンティの取得時にバージョン番号を取得してクライアントへバージョン番号を送信し、データの更新時に取得時のバージョン番号をクライアントからサーバーへ送信して、更新時にバージョン番号が一致することを確認します。そのためにはエンティティの取得時にバージョン番号を予め取得する必要があります。

ところが、Npgsqlのバージョン番号はShadow Propertiesに定義されているため普通に取得することはできません。このShadow Propertiesについてはこちらに説明がありますが、efのChangeTrackerにより値が管理されています。

Shadow Properties - EF Core | Microsoft Docs

値の取得方法は以下の通りとなります。

(uint) context.Entry(entity).Property("xmin").CurrentValue;

このようにすることで、Shadow Propertyに定義されているxmin列から値を取得することができます。

ただ、Contextクラスから取得する必要があるため通常のLinqで取得した処理の後に取得した対象エンティティの全てに上記の値を設定する必要があります。

取得した値は、各エンティティにはCurrentVersionというフィールドを定義し[NotMapped]を設定しています。

エンティティ

namespace Hoge
{
    public class PostalAddress
    {
        [Required]
        [MaxLength(36)]
        [MinLength(36)]
        public string Id { get; set; }

        ...

        // NotMappedでCurrentVersionを定義している
        [NotMapped]
        public uint CurrentVersion { get; set; }
    }
}

このエンティティにバージョン番号を設定するためDbContextの拡張メソッドを定義しています。

バージョン番号の取得

public static class DbContextExtensions
{
    public  static void CurrentVersion<T>(this DbContext context,  T entity)
        where T:class, IVersionable
    {
        entity.CurrentVersion = (uint) context.Entry(entity).Property("xmin").CurrentValue;
    }
}

UpdateやDeleteのときにバージョン番号を設定する

次に実際の更新処理についてです。NpgsqlではUpdateやDeleteなどをefから発行すれば自動的にバージョン番号を付加したSQLを発行してくれます。

[15:24:16 INF] Executed DbCommand (0ms) [Parameters=[@p0='7f396576-9ddb-4b5f-9ce4-3a2dcf641c27' (Nullable = false), @p1='3347' (DbType = Object)], CommandType='Text', CommandTimeout='30']
DELETE FROM "customers"
WHERE "id" = @p0 AND "xmin" = @p1;

ここに記載されている条件のxmin=@p1がバージョン番号の比較になります。特に設定しないときには更新対象のデータ取得時一緒に取得され(、ShadowPropertiesに設定され)るバージョン番号が設定されます。

ASP.NETなどのウェブアプリケーションでは、ここにクライアントから送信されてきたバージョン番号を設定する必要があります。設定は例によってShadow Propertiesへ設定することになります。

この処理についてもDbContextに拡張メソッドを定義して設定できるようにしています。

public static void SetCurrentVersion<T>(this DbContext context, T entity, ILogger logger = null)
    where T : class, IVersionable
{
    logger?.LogDebug(
        $"{entity}.CurrentVersion = {entity.CurrentVersion} / xmin = {context.Entry(entity).Property("xmin").CurrentValue}");
    logger?.LogDebug(
        $"{entity} xmin.OriginalValue = {context.Entry(entity).Property("xmin").OriginalValue}");

    // ここでOriginalValueに値を設定する
    context.Entry(entity).Property("xmin").OriginalValue = entity.CurrentVersion;

    logger?.LogDebug(
        $"--> {entity} xmin.OriginalValue = {context.Entry(entity).Property("xmin").OriginalValue}");
}

Shadow PropertiesにはOriginalValueCurrentValueがありますが、OrigginalValueが比較対象のバージョン番号として利用されているようです。(ef coreのソースを読めていないので挙動としてそのようになっているという認識です。)

最後に

SQL Serverだとスムーズに対応できるバージョン番号ですが、PostgreSQLを利用する場合には少し手間が必要です。どうやるんだろうかと思って色々と調べてみたところこんな感じでうまく行っていますという内容ですので、間違いやもっといい方法があれば教えていただけるとうれしいです。

実戦で役立つ C#プログラミングのイディオム/定石&パターン

実戦で役立つ C#プログラミングのイディオム/定石&パターン

追記

aspnet/EntityFrameworkCore: Entity Framework Core is a lightweight and extensible version of the popular Entity Framework data access technologyCurrentVersionのコメントを読んでみたところ以下の通りデータベースに処理を行うときに使用する値と記載がありました。

github.com

        /// <summary>
        ///     Gets the original property values for this entity. The original values are the property
        ///     values as they were when the entity was retrieved from the database.
        /// </summary>
        /// <value> The original values. </value>
        public virtual PropertyValues OriginalValues
        {
            [DebuggerStepThrough] get => new OriginalPropertyValues(InternalEntry);
        }