EF Core ile PostgreSQL: Npgsql Provider, snake_case, Array/JSON Mapping, Enum, xmin Concurrency ve Migration Farkları
EF Core'u PostgreSQL'e taşımak UseNpgsql'den fazlası: karşılaştırma büyük/küçük harfe duyarlıdır ve SQL Server'da çalışan == araması sessizce boş döner. EFCore.NamingConventions ile snake_case, text[] ve @> çevirisi, jsonb'ye eşlenen complex type ve ExecuteUpdate ile jsonb_set, PostgreSQL enum, uuid v7, xmin ile concurrency token, generated tsvector ve CREATE INDEX CONCURRENTLY'nin migration'ı transaction dışına taşıması.
Sürüm notu: Bu yazıdaki kodlar .NET 10, Entity Framework Core 10.0.15, Npgsql.EntityFrameworkCore.PostgreSQL 10.0.3 ve EFCore.NamingConventions 10.0.1 ile derlenip PostgreSQL 18.7 (resmî postgres:18 Docker image'ı) üzerinde, 02'de kurduğumuz Bookstore lab'ında çalıştırılmıştır.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için katalog servisini SQL Server'dan PostgreSQL'e taşıdık. Entity'ler, LINQ (Language Integrated Query; C# içinde sorgu yazma syntax'ı) sorguları, hepsi aynı; yalnızca UseSqlServer yerine UseNpgsql yazdık. Katalogda "The Hobbit #2" adında bir kitap var ve kullanıcı arama kutusuna küçük harfle yazıyor:
IQueryable<Book> exact = db.Books.Where(b => b.Title == "the hobbit #2");
IQueryable<Book> ilike = db.Books.Where(b => EF.Functions.ILike(b.Title, "the hobbit #2"));
Console.WriteLine($"== : {await exact.CountAsync()} rows");
Console.WriteLine($"ILike : {await ilike.CountAsync()} rows");
SQL Server'da ilk sorgu kitabı bulur, çünkü varsayılan collation büyük/küçük harfe duyarsızdır. PostgreSQL'de kaç satır döner?
== : 0 rows
ILike : 1 rows
SELECT b.book_id, b.created_at, b.format, b.isbn, b.price, b.public_id, b.search_vector, b.tags, b.title, b.xmin, b.details
FROM catalog.books AS b
WHERE b.title ILIKE 'the hobbit #2'
Hiç. PostgreSQL'de karşılaştırma varsayılan olarak büyük/küçük harfe duyarlı. Kod derlendi, testler (SQL Server'a karşı yazılmışsa) geçti, ama arama sessizce boş dönüyor. Bu yazıda Entity Framework Core'u (EF Core; tabloları C# nesnelerine eşleyen Microsoft kütüphanesi) PostgreSQL ile kullanmanın farklarını görüyoruz: provider kurulumu ve snake_case isimlendirme, PostgreSQL'e özgü mapping'ler (array, JSON, enum, uuid), xmin ile concurrency, full-text ve migration'ların PostgreSQL'deki davranışı.
Provider, Data Source ve snake_case
02'de EF Core'un varsayılan isimlendirmesinin PostgreSQL'de "Books", "BookId" gibi tırnak gerektiren isimler ürettiğini görmüştük. EFCore.NamingConventions paketi tablo, kolon, key ve index isimlerini snake_case'e çeviriyor. Bu yazının entity'leri, mevcut lab tablolarına dokunmamak için kendi catalog schema'sında duruyor:
public sealed class Book
{
public int BookId { get; set; }
public Guid PublicId { get; set; }
public required string Isbn { get; set; }
public required string Title { get; set; }
public decimal Price { get; set; }
public BookFormat Format { get; set; }
public List<string> Tags { get; set; } = [];
public BookDetails Details { get; set; } = new();
public DateTime CreatedAt { get; set; }
public uint Version { get; set; }
public NpgsqlTsVector SearchVector { get; set; } = null!;
}
public static DbContextOptions<BookstoreContext> CreateOptions(string connectionString) =>
new DbContextOptionsBuilder<BookstoreContext>()
.UseNpgsql(connectionString, npgsql => npgsql.MapEnum<BookFormat>("book_format", "catalog"))
.UseSnakeCaseNamingConvention()
.Options;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("catalog");
modelBuilder.Entity<Book>(book =>
{
book.HasAlternateKey(b => b.PublicId);
book.Property(b => b.PublicId).ValueGeneratedOnAdd(); // varsayılan olarak yalnızca primary key'e Guid üretilir
book.HasIndex(b => b.Isbn).IsUnique();
book.Property(b => b.Isbn).HasMaxLength(13);
book.Property(b => b.Price).HasPrecision(10, 2);
book.ComplexProperty(b => b.Details, details => details.ToJson());
book.Property(b => b.Version).IsRowVersion();
book.HasIndex(b => b.Tags).HasMethod("gin");
book.HasGeneratedTsVectorColumn(b => b.SearchVector, "english", b => new { b.Title })
.HasIndex(b => b.SearchVector)
.HasMethod("gin")
.IsCreatedConcurrently();
});
}
ASP.NET Core uygulamasında bu ayarlar AddDbContext içinde yapılır; 11'de dependency injection ile kaydettiğimiz NpgsqlDataSource'u UseNpgsql(dataSource, ...) ile vermek, pool ve type mapping ayarlarını Dapper koduyla paylaşmayı sağlar. Enum'ı data source kullanıyorsan iki yerde de map etmen gerekiyor. Migration'ın ürettiği SQL:
CREATE TYPE catalog.book_format AS ENUM ('audiobook', 'ebook', 'hardcover', 'paperback');
CREATE TABLE catalog.books (
book_id integer GENERATED BY DEFAULT AS IDENTITY,
public_id uuid NOT NULL,
isbn character varying(13) NOT NULL,
title text NOT NULL,
price numeric(10,2) NOT NULL,
format catalog.book_format NOT NULL,
tags text[] NOT NULL,
created_at timestamp with time zone NOT NULL,
search_vector tsvector GENERATED ALWAYS AS (to_tsvector('english', title)) STORED NOT NULL,
details jsonb NOT NULL,
CONSTRAINT pk_books PRIMARY KEY (book_id),
CONSTRAINT ak_books_public_id UNIQUE (public_id)
);
CREATE UNIQUE INDEX ix_books_isbn ON catalog.books (isbn);
CREATE INDEX ix_books_tags ON catalog.books USING gin (tags);
Birkaç ayrıntı göze çarpıyor. int key, 03'te önerdiğimiz IDENTITY oldu; provider BY DEFAULT kullanıyor, ALWAYS istersen UseIdentityAlwaysColumn(). DateTime timestamptz'ye, List<string> text[]'e, complex type jsonb'ye eşlendi. Version kolonu tabloda yok: xmin her tabloda zaten var olan bir system column. Enum değerleri ise alfabetik sırayla oluşturulmuş; PostgreSQL enum'ları tanım sırasına göre sıralanır, yani ORDER BY format C# enum'ındaki sırayı vermez. search_vector için provider STORED'u kendisi yazdı; 10'da gördüğümüz gibi 18'de varsayılan virtual olurdu.
Array, JSON, Enum ve uuid Mapping'i
ef-seed komutu lab'daki ilk 2.000 kitabı EF Core ile catalog.books'a kopyalıyor; uygulama rolü bookstore_app ile:
dotnet tool restore
$env:BOOKSTORE_PG_OWNER = "Host=localhost;Port=5432;Database=bookstore;Username=bookstore_owner;Password=<owner-password>"
dotnet ef database update --project src/Bookstore.PgLab --context Bookstore.PgLab.Catalog.BookstoreContext
dotnet run --project src/Bookstore.PgLab -- ef-seed
dotnet run --project src/Bookstore.PgLab -- ef-query
saved 2000 books; first PublicId 01a1d7f6-7ffc-721d-8bb0-ba5eea9e74f7 (version 7), xmin 2493
Tags.Contains: 200 rows
SELECT b.book_id, b.created_at, b.format, b.isbn, b.price, b.public_id, b.search_vector, b.tags, b.title, b.xmin, b.details
FROM catalog.books AS b
WHERE b.tags @> ARRAY['fantasy']::text[]
Details.Pages: 11 rows
SELECT b.book_id, b.created_at, b.format, b.isbn, b.price, b.public_id, b.search_vector, b.tags, b.title, b.xmin, b.details
FROM catalog.books AS b
WHERE (CAST(b.details ->> 'Pages' AS integer)) > 1000 AND b.format = 'ebook'::catalog.book_format
ExecuteUpdate on a JSON property: 1 row
Guid'ler provider tarafından istemcide, sıralı version 7 olarak üretildi; 03'te index yoğunluğu için v7'yi önermiştik. Bunu varsayılan olarak yalnızca primary key alıyor: ilk denememizde alternate key olan PublicId boş kaldı ve ikinci kitapta "another instance with the same key value for {'PublicId'} is already being tracked" hatası aldık, ValueGeneratedOnAdd() bu yüzden orada. Değeri sunucuda üretmek istersen PostgreSQL 18'de HasDefaultValueSql("uuidv7()") kullanabilirsin.
Tags.Contains("fantasy") @> operatörüne çevrildi ve GIN (Generalized Inverted Index; bir satırdaki birden çok değeri ayrı ayrı index'leyen yapı) index'i kullanabiliyor. Provider 10'da bu çeviri değişti: varsayılan artık 'fantasy' = ANY(tags), ama kolonda GIN index tanımlıysa index'in kullanabildiği @> korunuyor. Index'i sonradan ekleyeceğin kolonlarda bunu kontrol et.
JSON tarafında EF Core 10'un complex type'ları (EF Core serisinin EF Core 10 yenilikleri yazısı) provider 10 ile jsonb'ye eşleniyor ve sorgu ->> ile çevriliyor. Dikkat: snake_case convention JSON içindeki key'lere uygulanmıyor, belgede "Pages" yazıyor. 05'teki gibi bu alanla sık filtreleme yapacaksan aynı ifadeyle bir expression index gerekir. ExecuteUpdate ile tek bir JSON alanını güncellemek de mümkün; üretilen SQL pg_stat_statements'ta şöyle görünüyor:
UPDATE catalog.books AS b
SET details = jsonb_set(b.details, $2, to_jsonb($1))
WHERE b.book_id = $3
Range tipleri de (NpgsqlRange<DateOnly> ↔ daterange) aynı şekilde eşlenir; 04'teki kampanya dönemleri bir entity'de bu tiple tutulabilir.
xmin ile Optimistic Concurrency
EF Core serisinin concurrency yazısında SQL Server'ın rowversion kolonunu kullanmıştık. PostgreSQL'de ayrı bir kolona gerek yok: 08'de gördüğümüz xmin, satırı son yazan transaction'ın ID'si ve her güncellemede değişiyor. uint bir property'yi IsRowVersion() ile işaretlemek yeterli. Selin ve Emre aynı kitabı açıp fiyatını değiştiriyor:
both loaded book 2 with xmin 2493
UPDATE catalog.books SET price = @p0
WHERE book_id = @p1 AND xmin = @p2
RETURNING search_vector, xmin;
Selin saved, new xmin 2495
UPDATE catalog.books SET price = @p0
WHERE book_id = @p1 AND xmin = @p2
RETURNING search_vector, xmin;
Emre: DbUpdateConcurrencyException: The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded.
UPDATE, WHERE'e okunan xmin'i ekliyor ve RETURNING ile yenisini geri alıyor; generated search_vector da başlık değişebileceği için geri okunuyor. Emre'nin güncellemesi 0 satır etkiledi ve EF Core exception fırlattı; çözümü (yeniden yükle, kullanıcıya göster ya da birleştir) EF Core serisindeki gibi. Bir not: xmin bütün seed'i yazan tek transaction'ın ID'si olduğu için 2.000 satırın hepsinde aynıydı; token satır başına benzersiz değil, "bu satır okuduğumdan beri değişti mi" sorusuna cevap veriyor. 09'daki REPEATABLE READ'in 40001 hatasından farkı da bu: optimistic concurrency transaction'lar arasında, kullanıcının ekranda beklediği süre boyunca çalışır.
Full-Text, ILike ve PostgreSQL Fonksiyonları
10'da kurduğumuz yapının EF Core karşılığı HasGeneratedTsVectorColumn: generated tsvector kolonunu ve GIN index'ini modelden üretiyor. Sorguda EF.Functions ile:
IQueryable<Book> search = db.Books.Where(b => b.SearchVector.Matches(EF.Functions.WebSearchToTsQuery("english", "hobbits")));
full-text: 100 rows
SELECT b.book_id, b.created_at, b.format, b.isbn, b.price, b.public_id, b.search_vector, b.tags, b.title, b.xmin, b.details
FROM catalog.books AS b
WHERE b.search_vector @@ websearch_to_tsquery('english', 'hobbits')
Tahmin bölümündeki büyük/küçük harf farkı için üç yol var. EF.Functions.ILike tek sorguluk çözüm ama B-tree index kullanamaz (06'daki trigram GIN index'i kullanabilir). b.Title.ToLower() == term lower(title)'a çevrilir; 06'daki gibi lower(title) üzerinde expression index ile birlikte kullan. Kalıcı çözüm ise kolona case-insensitive bir collation vermek: 03'teki nondeterministic ICU (International Components for Unicode; dil kurallarını içeren Unicode kütüphanesi) collation'ı (tr_ci; 18'den beri LIKE ile de çalışıyor) UseCollation("tr_ci") ile kolona atayabilirsin. Collation'ın kendisini migration'da modelBuilder.HasCollation(...) ile oluşturmak gerekiyor; Türkçe I/İ kuralının e-posta gibi alanlarda istenmeyebileceğini de 03'te görmüştük.
Migration'ların PostgreSQL'deki Davranışı
PostgreSQL'de DDL (Data Definition Language; CREATE, ALTER gibi şema komutları) transactional olduğu için EF Core her migration'ı START TRANSACTION ... COMMIT içinde çalıştırıyor: migration'ın ortasında hata olursa şema yarım kalmıyor. SQL Server'da da çoğu DDL transaction içinde çalışır, ama PostgreSQL'de CREATE TYPE, CREATE SCHEMA dahil neredeyse hepsi geri alınabiliyor.
İstisnası CREATE INDEX CONCURRENTLY: transaction içinde çalışamıyor. İlk denememizde search_vector kolonunu ve concurrent index'i aynı migration'a koyduk; EF Core migration'ı uygularken şunu yazdı:
The migration operation 'CREATE INDEX CONCURRENTLY ix_books_search_vector ON catalog.books USING gin (search_vector);
' from migration 'AddTitleSearch' cannot be executed in a transaction. If the app is terminated or an unrecoverable error occurs while this operation is being executed then the migration will be left in a partially applied state and would need to be reverted manually before it can be applied again. Create a separate migration that contains just this operation.
EF Core transaction'ı commit edip index'i dışarıda çalıştırıyor; index oluşturma başarısız olursa kolon eklenmiş ama migration kaydedilmemiş olur ve tekrar denemek kolon zaten var hatası verir. Üstüne 06'daki INVALID index de kalır. Önerisine uyup concurrent index'i kendi migration'ına aldık; snapshot'taki son hâl:
INSERT INTO "__EFMigrationsHistory" (migration_id, product_version)
VALUES ('20261114024951_InitialCatalog', '10.0.15');
COMMIT;
CREATE INDEX CONCURRENTLY ix_books_search_vector ON catalog.books USING gin (search_vector);
INSERT INTO "__EFMigrationsHistory" (migration_id, product_version)
VALUES ('20261114024952_AddTitleSearchIndex', '10.0.15');
İlk migration'ın sonuna elle eklediğimiz bir bölüm daha var: 02'de uygulama rolüne yalnızca public schema'da yetki vermiştik, yeni catalog schema'sı için migration'ın kendisi veriyor:
// owner'ın catalog schema'sında oluşturduğu her şeyde uygulama rolüne DML yetkisi
migrationBuilder.Sql("GRANT USAGE ON SCHEMA catalog TO bookstore_app;");
migrationBuilder.Sql("GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA catalog TO bookstore_app;");
migrationBuilder.Sql("GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA catalog TO bookstore_app;");
migrationBuilder.Sql("ALTER DEFAULT PRIVILEGES IN SCHEMA catalog GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO bookstore_app;");
migrationBuilder.Sql("ALTER DEFAULT PRIVILEGES IN SCHEMA catalog GRANT USAGE, SELECT ON SEQUENCES TO bookstore_app;");
Migration'ları owner rolüyle (BOOKSTORE_PG_OWNER, design-time factory'de), uygulamayı yetkisi kısıtlı bookstore_app ile çalıştırıyoruz; yetki modelinin ayrıntıları 14'te. EF Core 11 ve Npgsql provider 11 ile gelen değişiklikleri, provider'ın kararlı sürümü çıktığında serinin sürüm yazılarında ele alacağız; bu yazının yazıldığı gün EF Core 11 çıkmış durumda, ama Npgsql provider 11 hâlâ release candidate (11.0.0-rc.3).
Üretimde Dikkat
Production'da migration'ı uygulama açılışında Migrate() ile değil, deploy pipeline'ında dotnet ef migrations bundle ya da üretilmiş SQL script ile, owner rolüyle çalıştır. Script'i review'da oku: ALTER TABLE'ın tabloyu yeniden yazıp yazmadığına ve 09'daki lock kuyruğu etkisine bak; oturuma lock_timeout ver.
Büyük tablolarda index'leri IsCreatedConcurrently() ile ve her biri ayrı migration'da ekle. Başarısız bir deploy'dan sonra pg_index'te indisvalid = false kalan index var mı kontrol et.
Bağlantı hatalarına ve 09'daki 40001/40P01 hatalarına karşı EnableRetryOnFailure'ı aç; kendi açtığın transaction'ları 09'da gördüğümüz gibi CreateExecutionStrategy() içine al. EF Core'un ürettiği sorguların planını 07'deki yöntemle izle: TagWith, pg_stat_statements ve auto_explain. JSON alanlarıyla filtreleme ve ILike kolayca index'siz sorgu üretir.
Sık Düşülen Tuzaklar
SQL Server için yazılmış raw SQL'i taşımak: köşeli parantez, TOP, GETDATE(), ISNULL PostgreSQL'de yok. SqlQueryRaw("SELECT TOP 5 [BookId] ...") denememiz 42601 syntax error at or near "5" ile bitti. Raw SQL'leri taşımadan önce listele; SQL Temelleri serisinin lehçe farkları yazısı karşılıkları topluca veriyor.
Karşılaştırmanın büyük/küçük harfe duyarlı olduğunu unutmak: tahmindeki gibi sessizce boş sonuç. E-posta, kullanıcı adı gibi alanlarda karar baştan verilmeli.
JSON key'lerinin de snake_case olacağını varsaymak: raw SQL ya da başka bir servis aynı belgeyi okuyorsa "Pages" ile "pages" farklı key'ler.
Concurrent index'i diğer şema değişiklikleriyle aynı migration'a koymak: yarım kalan migration'ı elle temizlemek zorunda kalırsın.
Küçük Bir Deneme
★☆☆ BookFormat'a Comic değerini ekle ve yeni migration üret. Üretilen SQL ALTER TYPE ... ADD VALUE mu? Bu komut transaction içinde çalışabiliyor mu?
★★☆ Title kolonuna 03'teki case-insensitive ICU collation'ı UseCollation ile ver, migration'ı uygula ve tahmindeki == sorgusunu tekrar çalıştır. StartsWith ne SQL üretiyor ve çalışıyor mu?
★★★ Details.Pages üzerinde 05'teki gibi bir expression index'i migrationBuilder.Sql ile ekle (CONCURRENTLY, ayrı migration) ve ef-query'deki sorgunun planında kullanıldığını EXPLAIN ile göster.
Bir sonraki yazıda Blok C ile ekosisteme geçiyoruz: bu seride tek tek kullandığımız pg_stat_statements, pg_trgm, unaccent gibi extension'ların mekanizması, managed servislerdeki kısıtlar ve hangi extension'ın ne zaman kurulacağı.