PostgreSQL で Entity Framework Core を使っていて money 型でハマった

PostgreSQL で Entity Framework Core を使っていて、PostgreSQL の money 型の列を C# の decimal 型のプロパティにマップしたら、プロパティに格納される値がなんか変でハマった。テーブルに格納されている値の 100 分の 1 になってる。

試したのは下記のコード。

using Microsoft.EntityFrameworkCore;
using System;
using System.Threading.Tasks;

namespace PgSample
{
    public class Product
    {
        public long Id { get; set; }

        public string Name { get; set; }

        public decimal Price { get; set; }
    }

    public class ApplicationDbContext : DbContext
    {
        public ApplicationDbContext() : base()
        {
            Products = Set<Product>();
        }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            optionsBuilder.UseNpgsql(
                "Host=localhost;Username=postgres;Password=p@ssword;Database=sample");
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Product>(b =>
            {
                b.Property(e => e.Id)
                    .UseNpgsqlSerialColumn()
                    .HasColumnName("id");

                b.Property(e => e.Name)
                    .IsRequired()
                    .HasColumnName("name");

                b.Property(e => e.Price)
                    .IsRequired()
                    .HasColumnName("price")
                    .HasColumnType("money");

                b.ToTable("products");
                b.HasKey(e => e.Id);
            });
        }

        public DbSet<Product> Products { get; }
    }

    class Program
    {
        static void Main(string[] args)
        {
            MainAsync(args).GetAwaiter().GetResult();

            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.Products.Add(new Product()
                {
                    Name = "foo",
                    Price = 12345,
                });
                context.Products.Add(new Product()
                {
                    Name = "bar",
                    Price = 67890
                });
                await context.SaveChangesAsync();
            }

            // Entity Framework Core で普通に取得
            Console.WriteLine("EF Normal");
            using (var context = new ApplicationDbContext())
            {
                var products = await context.Products.ToListAsync();
                foreach (var p in products)
                {
                    Console.WriteLine(
                        $"id:{p.Id}\tname:{p.Name}\tprice:{p.Price}");
                }
            }

            // Entity Framework Core で SQL を指定して取得
            Console.WriteLine("FromSql");
            using (var context = new ApplicationDbContext())
            {
                var products = await context.Products
                    .FromSql("SELECT id, name, price FROM products")
                    .ToListAsync();
                foreach (var p in products)
                {
                    Console.WriteLine(
                        $"id:{p.Id}\tname:{p.Name}\tprice:{p.Price}");
                }
            }

            // Entity Framework Core で money を numeric にキャストする SQL を指定して取得
            Console.WriteLine("FromSql with convert");
            using (var context = new ApplicationDbContext())
            {
                var products = await context.Products
                    .FromSql("SELECT id, name, price::money::numeric FROM products")
                    .ToListAsync();
                foreach (var p in products)
                {
                    Console.WriteLine(
                        $"id:{p.Id}\tname:{p.Name}\tprice:{p.Price}");
                }
            }

            // NpgsqlConnection を直に使って取得
            Console.WriteLine("ExecuteReaderAsync");
            using (var context = new ApplicationDbContext())
            {
                using (var connection = context.Database.GetDbConnection())
                {
                    await connection.OpenAsync();
                    using (var command = connection.CreateCommand())
                    {
                        command.CommandText = "SELECT id, name, price FROM products";
                        using (var reader = await command.ExecuteReaderAsync())
                        {
                            while (await reader.ReadAsync())
                            {
                                Console.WriteLine(
                                    $"id:{reader[0]}\tname:{reader[1]}\tprice:{reader[2]}");
                            }
                        }
                    }
                }
            }

            // NpgsqlConnection を直に使い、money を numeric にキャストする SQL を指定して取得
            Console.WriteLine("ExecuteReaderAsync with convert");
            using (var context = new ApplicationDbContext())
            {
                using (var connection = context.Database.GetDbConnection())
                {
                    await connection.OpenAsync();
                    using (var command = connection.CreateCommand())
                    {
                        command.CommandText = "SELECT id, name, price::money::numeric FROM products";
                        using (var reader = await command.ExecuteReaderAsync())
                        {
                            while (await reader.ReadAsync())
                            {
                                Console.WriteLine(
                                    $"id:{reader[0]}\tname:{reader[1]}\tprice:{reader[2]}");
                            }
                        }
                    }
                }
            }
        }
    }
}

実行結果がこちら。

f:id:griefworker:20180426114225p:plain

Entity Framework Core の問題かと思ってたら、Npgsql を直に使った場合も同じだった。 Npgsql 内で PostgreSQL の money 型を C# の decimal 型にマップしているところに原因がありそう。 GitHub の Npgsql リポジトリで MoneyHandler のソースコードを見てみた。

https://github.com/npgsql/npgsql/blob/b8ee19e50884ad740ebc78086d90082818f8c2c5/src/Npgsql/TypeHandlers/NumericHandlers/MoneyHandler.cs

短いのでクラスを引用。

[TypeMapping("money", NpgsqlDbType.Money, dbType: DbType.Currency)]
class MoneyHandler : NpgsqlSimpleTypeHandler<decimal>
{
    public override decimal Read(NpgsqlReadBuffer buf, int len, FieldDescription fieldDescription = null)
        => buf.ReadInt64() / 100m;

    public override int ValidateAndGetLength(decimal value, NpgsqlParameter parameter)
        => value < -92233720368547758.08M || value > 92233720368547758.07M
            ? throw new OverflowException($"The supplied value ({value}) is outside the range for a PostgreSQL money value.")
            : 8;

    public override void Write(decimal value, NpgsqlWriteBuffer buf, NpgsqlParameter parameter)
        => buf.WriteInt64((long)(Math.Round(value, 2, MidpointRounding.AwayFromZero) * 100m));
}

Read 時に 100m で割っている。怪しい。こいつが犯人か。 なぜこんな実装になっているのか調べたけど、ちょっと時間切れ。 money ではなく numeric を使った方が良かったかもな。