PostgreSQL で character(n) 型の列を主キーにしていたら Entity Framework Core で酷い目にあった

PostgreSQL で Entity Framework Core(Npgsql.EntityFrameworkCore.PostgreSQL) を使っていて、 テーブルの主キーの列に character(n) 型を使っていたら、 エンティティの更新に失敗して嵌った。

再現サンプルは下記の通り。

using Microsoft.EntityFrameworkCore;
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Threading.Tasks;

namespace NpgsqlEFSample
{
    [Table("books")]
    public class Book
    {
        [Key]
        [Required]
        [Column("code", TypeName = "character(20)")]
        public string Code { get; set; }

        [Column("title")]
        public string Title { get; set; }
    }

    public class ApplicationDbContext : DbContext
    {
        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) =>
            optionsBuilder.UseNpgsql("Host=localhost;Username=postgres;Password=p@ssword;Database=sample");

        public DbSet<Book> Books => Set<Book>();
    }

    class Program
    {
        static void Main(string[] args)
        {
            try
            {
                MainAsync(args).GetAwaiter().GetResult();
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.Message);
            }

            Console.WriteLine("Press Enter Key.");
            Console.ReadLine();
        }

        static async Task MainAsync(string[] args)
        {
            using (var context = new ApplicationDbContext())
            {
                // データベースを作りなおす
                await context.Database.EnsureDeletedAsync();
                await context.Database.EnsureCreatedAsync();

                context.Books.Add(new Book()
                {
                    Code = "12345",
                    Title = "Foo",
                });
                await context.SaveChangesAsync();
            }

            using (var context = new ApplicationDbContext())
            {
                var book = await context.Books
                    .FirstOrDefaultAsync(b => b.Code == "12345");
                book.Title = "Bar";
                await context.SaveChangesAsync();
            }

            using (var context = new ApplicationDbContext())
            {
                var books = await context.Books.ToListAsync();
                foreach (var book in books)
                {
                    Console.WriteLine($"{book.Code}:{book.Title}");
                }
            }
        }
    }
}

このコードを実行すると、次のような例外が発生してしまう。

f:id:griefworker:20180522162315p:plain

原因

context.Books.Add(new Book()
{
    Code = "12345",
    Title = "Foo",
});
await context.SaveChangesAsync();

を実行すると、Code プロパティにマップしている code 列は character(20) 型なので、 テーブルには 12345 (後ろに空白が 15 個ある) が保存される。

var book = await context.Books
    .FirstOrDefaultAsync(b => b.Code == "12345");

で取得した bookCode プロパティには、後ろに空白が 15 個ある 12345 が格納されている。

book.Title = "Bar";
await context.SaveChangesAsync();

を実行すると、Entity Framework Core は主キーである code 列の値が 12345 (後ろに空白が 15 個ある) の行を更新しようとするが、後ろに空白を含んでいるとなぜか条件に引っかからず、更新できない。 そして 1 行更新しようとしたけど更新できなかったから例外発生。

試しに

var book = await context.Books
    .FirstOrDefaultAsync(b => b.Code == "12345               "); // 12345 の後ろに空白が 15 個ある

で取得しようとするとヒットせず null が返ってくる。

ちなみに、Npgsql.EntityFrameworkCore.PostgreSQL ではなく、Npgsql を直接使った場合も、 後ろに空白を含む文字列で検索してもヒットしなかった。

対策1 varchar に変更する

character ではなく varchar を使うのが手っ取り早いし、 根本的な解決になる。

using Microsoft.EntityFrameworkCore;
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Threading.Tasks;

namespace NpgsqlEFSample
{
    [Table("books")]
    public class Book
    {
        [Key]
        [Required]
        [Column("code", TypeName = "varchar(20)")]  // varchar を使えば後ろに空白は入らない
        public string Code { get; set; }

        [Column("title")]
        public string Title { get; set; }
    }

    public class ApplicationDbContext : DbContext
    {
        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) =>
            optionsBuilder.UseNpgsql("Host=localhost;Username=postgres;Password=p@ssword;Database=sample");

        public DbSet<Book> Books => Set<Book>();
    }

    class Program
    {
        static void Main(string[] args)
        {
            try
            {
                MainAsync(args).GetAwaiter().GetResult();
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.Message);
            }

            Console.WriteLine("Press Enter Key.");
            Console.ReadLine();
        }

        static async Task MainAsync(string[] args)
        {
            using (var context = new ApplicationDbContext())
            {
                // データベースを作りなおす
                await context.Database.EnsureDeletedAsync();
                await context.Database.EnsureCreatedAsync();

                context.Books.Add(new Book()
                {
                    Code = "12345",
                    Title = "Foo",
                });
                await context.SaveChangesAsync();
            }

            using (var context = new ApplicationDbContext())
            {
                var book = await context.Books
                    .FirstOrDefaultAsync(b => b.Code == "12345");
                book.Title = "Bar";
                await context.SaveChangesAsync();
            }

            using (var context = new ApplicationDbContext())
            {
                var books = await context.Books.ToListAsync();
                foreach (var book in books)
                {
                    Console.WriteLine($"{book.Code}:{book.Title}");
                }
            }
        }
    }
}

実行すると、ちゃんと更新できる。

f:id:griefworker:20180522162324p:plain

「どうしても character でないといけない」という場合は使えないけど、 新規に作成するデータベースでそんなことあるかなぁ。

対策2 空白を除去してから保存する

スマートなやり方ではないけど、SaveChangesAsync で保存する前に、 キーのプロパティに格納されている文字列から空白を除去してやれば、 無事保存される。

using Microsoft.EntityFrameworkCore;
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Threading.Tasks;

namespace NpgsqlEFSample
{
    [Table("books")]
    public class Book
    {
        [Key]
        [Required]
        [Column("code", TypeName = "character(20)")]
        public string Code { get; set; }

        [Column("title")]
        public string Title { get; set; }
    }

    public class ApplicationDbContext : DbContext
    {
        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) =>
            optionsBuilder.UseNpgsql("Host=localhost;Username=postgres;Password=p@ssword;Database=sample");

        public DbSet<Book> Books => Set<Book>();
    }

    class Program
    {
        static void Main(string[] args)
        {
            try
            {
                MainAsync(args).GetAwaiter().GetResult();
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.Message);
            }

            Console.WriteLine("Press Enter Key.");
            Console.ReadLine();
        }

        static async Task MainAsync(string[] args)
        {
            using (var context = new ApplicationDbContext())
            {
                // データベースを作りなおす
                await context.Database.EnsureDeletedAsync();
                await context.Database.EnsureCreatedAsync();

                context.Books.Add(new Book()
                {
                    Code = "12345",
                    Title = "Foo",
                });
                await context.SaveChangesAsync();
            }

            using (var context = new ApplicationDbContext())
            {
                // 変更を追跡しない
                var book = await context.Books
                    .AsNoTracking()
                    .FirstOrDefaultAsync(b => b.Code == "12345");

                // 変更を追跡していないので、キーのプロパティを編集できる
                book.Code = book.Code.TrimEnd();
                book.Title = "Bar";
                context.Books.Update(book);
                await context.SaveChangesAsync();
            }

            using (var context = new ApplicationDbContext())
            {
                var books = await context.Books.ToListAsync();
                foreach (var book in books)
                {
                    Console.WriteLine($"{book.Code}:{book.Title}");
                }
            }
        }
    }
}

実行するとちゃんと更新に成功する。

f:id:griefworker:20180522162333p:plain