Mert Özen Her Satırda Daha İleri
Veritabanı Data Modeling ve Normalizasyon — #09

Modeling Anti-Pattern'leri: EAV, Virgülle Ayrılmış Liste, Tekrarlayan Kolonlar ve Tek Büyük Lookup Tablosu

Mert Özen 14 Kas 2026 15 dk 3 görüntülenme
Modeling Anti-Pattern'leri: EAV, Virgülle Ayrılmış Liste, Tekrarlayan Kolonlar ve Tek Büyük Lookup Tablosu

Modeling anti-pattern'leri, veritabanının tip, benzersizlik ve foreign key kontrollerini devre dışı bırakan kısayollardır; hepsi bir sorguda sessizce yanlış sonuç verir. SQL Server'da EAV'nin kaybettiği satırı ve JSON kolon alternatifini, virgülle ayrılmış etiketlerin LIKE tuzağını, phone1/phone2/phone3'ün yazılamayan kuralını, tek büyük lookup tablosunun yanlış foreign key'ini, her şeyi nullable bırakmayı ve para için FLOAT'ı gösteriyoruz.

Sürüm notu: Bu yazıdaki SQL (Structured Query Language) örnekleri SQL Server 2025 (17.0, Cumulative Update 10) üzerinde çalıştırılmıştır.

Önce Tahmin Et: Kaç Klasik Kitabımız Var?

Pazarlama ekibi kitaplara etiket koymak istedi ve en hızlı çözüm books tablosuna bir tags kolonu eklemek oldu. Etiketler virgülle ayrılıyor. Altı kitaba etiket girildi ve kampanya için "klasik" etiketli kitaplar isteniyor:

ALTER TABLE dbo.books ADD tags VARCHAR(200) NULL;
GO
UPDATE dbo.books SET tags = 'classic,romance'      WHERE book_id IN (1, 2);
UPDATE dbo.books SET tags = 'classic, gothic'      WHERE book_id = 5;
UPDATE dbo.books SET tags = 'scifi,classic'        WHERE book_id = 26;
UPDATE dbo.books SET tags = 'scifi,modern-classic' WHERE book_id = 30;
UPDATE dbo.books SET tags = 'scifi,clasic'         WHERE book_id = 28;

SELECT book_id, title, tags
FROM dbo.books
WHERE tags LIKE '%classic%'
ORDER BY book_id;

Tahmin et: Bu sorgu doğru sayıda kitap döndürür mü? Etiketleri girerken kimse hata yapmadıysa evet; ama bir kişi boşluk koydu, bir kitap "modern-classic" olarak etiketlendi ve Foundation'ın etiketinde bir harf eksik. Sonuç:

(2 rows affected)

(1 rows affected)

(1 rows affected)

(1 rows affected)

(1 rows affected)
book_id  title                      tags
-------  -------------------------  --------------------
1        Pride and Prejudice        classic,romance
2        Emma                       classic,romance
5        Wuthering Heights          classic, gothic
26       Dune                       scifi,classic
30       The Left Hand of Darkness  scifi,modern-classic

(5 rows affected)

Beş satır döndü; biri yanlış (The Left Hand of Darkness "modern-classic"), biri eksik (Foundation "clasic"). Sorgu ne yanlışı ne eksiği fark edebildi. Bu yazıda bu tür modeling anti-pattern'lerini aynı şablonla inceleyeceğiz: neden cazip görünür, hangi sorguda patlar, doğru model nedir ve ne zaman gerçekten kabul edilebilir. Örnekler, SQL Temelleri serisinin Bookstore şemasının yüklü olduğu dm_09 veritabanında.

EAV: Her Şeyi Satıra Çeviren Esneklik

Kitapçı e-kitap ve sesli kitap satmaya başlıyor. E-kitabın dosya boyutu, sesli kitabın anlatıcısı ve süresi var; basılı kitabın bunların hiçbiri yok. "Her format için kolon mu ekleyeceğiz?" sorusuna sık verilen cevap EAV (Entity-Attribute-Value): her özellik, kitap kimliği, özellik adı ve değerden oluşan bir satır.

CREATE TABLE dbo.book_attributes (
    book_id         INT           NOT NULL,
    attribute_name  VARCHAR(50)   NOT NULL,
    attribute_value NVARCHAR(200) NULL,
    CONSTRAINT PK_book_attributes PRIMARY KEY (book_id, attribute_name),
    CONSTRAINT FK_book_attributes_books FOREIGN KEY (book_id) REFERENCES dbo.books (book_id)
);
INSERT INTO dbo.book_attributes (book_id, attribute_name, attribute_value) VALUES
    (26, 'format', N'ebook'),     (26, 'file_size_mb', N'4.2'),
    (28, 'format', N'ebook'),     (28, 'file_size_mb', N'7.8'),
    (30, 'format', N'ebook'),     (30, 'filesize_mb',  N'6.1'),
    (10, 'format', N'ebook'),     (10, 'file_size_mb', N'unknown'),
    (34, 'format', N'audiobook'), (34, 'narrator',     N'Grace Hopper');

SELECT f.book_id, CAST(s.attribute_value AS DECIMAL(9,2)) AS file_size_mb
FROM dbo.book_attributes AS f
JOIN dbo.book_attributes AS s
     ON s.book_id = f.book_id AND s.attribute_name = 'file_size_mb'
WHERE f.attribute_name = 'format' AND f.attribute_value = N'ebook'
  AND CAST(s.attribute_value AS DECIMAL(9,2)) > 5;
(10 rows affected)
Msg 8114, Level 16, State 5, Server b8be943222fa, Line 15
Error converting data type nvarchar to numeric.

Cazip olduğu nokta açık: yeni bir özellik için şema değişikliği gerekmiyor. Patladığı nokta da açık: "5 MB'tan büyük e-kitaplar" gibi sıradan bir soru, tabloyu kendisiyle birleştirmeyi ve metni sayıya çevirmeyi gerektiriyor ve tek bir 'unknown' değeri bütün sorguyu düşürüyor. TRY_CAST ile hatayı susturabilirsin:

SELECT f.book_id, TRY_CAST(s.attribute_value AS DECIMAL(9,2)) AS file_size_mb
FROM dbo.book_attributes AS f
JOIN dbo.book_attributes AS s
     ON s.book_id = f.book_id AND s.attribute_name = 'file_size_mb'
WHERE f.attribute_name = 'format' AND f.attribute_value = N'ebook'
  AND TRY_CAST(s.attribute_value AS DECIMAL(9,2)) > 5;
book_id  file_size_mb
-------  ------------
28       7.80

(1 rows affected)

Sorgu çalıştı ama sonuç yanlış. The Left Hand of Darkness'ın 6.1 MB'lık satırı filesize_mb adıyla girilmiş ve hiçbir koşula takılmadan kayboldu. EAV'da veritabanı, kolonlar için verdiği üç garantiyi veremez: veri tipi (her değer metin), zorunluluk (bir e-kitabın dosya boyutunun olması gerektiğini söyleyemezsin) ve ad (özellik adı bir veri, yazım hatası da geçerli bir veri). Her özellik için ayrı bir self-join gerektiği için ekran sorguları da hızla büyür.

Doğru model çoğu zaman sıradan kolonlardır: özellik birkaç formatta ortaksa nullable bir kolon, formatlar gerçekten farklıysa her format için ayrı bir tablo (subtype, serinin 3. yazısındaki 1-1 nedenlerinden biri). Özellikler gerçekten esnekse SQL Server 2025'in native json tipi daha iyi bir orta yol, çünkü JSON (JavaScript Object Notation) document'ine kısıt ve index koyabilirsin:

ALTER TABLE dbo.books ADD details JSON NULL;
GO
ALTER TABLE dbo.books ADD
    book_format AS CAST(JSON_VALUE(details, '$.format') AS VARCHAR(20)),
    CONSTRAINT CK_books_details_format
        CHECK (JSON_VALUE(details, '$.format') IN ('paperback', 'hardcover', 'ebook', 'audiobook')),
    CONSTRAINT CK_books_details_file_size
        CHECK (JSON_VALUE(details, '$.fileSizeMb') IS NULL
               OR COALESCE(TRY_CAST(JSON_VALUE(details, '$.fileSizeMb') AS DECIMAL(9,2)), 0) > 0);
GO
CREATE INDEX IX_books_book_format ON dbo.books (book_format);
GO
UPDATE dbo.books SET details = '{"format":"ebook","fileSizeMb":4.2}' WHERE book_id = 26;
UPDATE dbo.books SET details = '{"format":"ebook","fileSizeMb":7.8}' WHERE book_id = 28;
UPDATE dbo.books SET details = '{"format":"audiobook","narrator":"Grace Hopper"}' WHERE book_id = 34;
UPDATE dbo.books SET details = '{"format":"ebook","fileSizeMb":"unknown"}' WHERE book_id = 10;
GO
SELECT book_id, book_format, JSON_VALUE(details, '$.fileSizeMb' RETURNING DECIMAL(9,2)) AS file_size_mb
FROM dbo.books
WHERE book_format = 'ebook';
(1 rows affected)

(1 rows affected)

(1 rows affected)
Msg 547, Level 16, State 1, Server b8be943222fa, Line 4
The UPDATE statement conflicted with the CHECK constraint "CK_books_details_file_size". The conflict occurred in database "dm_09", table "dbo.books", column 'details'.
The statement has been terminated.
book_id  book_format  file_size_mb
-------  -----------  ------------
26       ebook        4.20
28       ebook        7.80

(2 rows affected)

'unknown' bu kez veritabanına girmedi. Kısıtı yazarken küçük bir tuzağa düştüm: ilk denememde koşul TRY_CAST(...) > 0 idi ve 'unknown' geçti, çünkü TRY_CAST NULL döndürüyor, NULL > 0 UNKNOWN oluyor ve CHECK yalnızca FALSE sonucu reddediyor. COALESCE bu yüzden orada. En sık filtrelenen özellik (book_format) computed column olarak dışarı çıktı ve index'lendi. PostgreSQL'deki karşılığı jsonb ve GIN (Generalized Inverted Index) index; ayrıntısı PostgreSQL serisinde.

EAV ne zaman kabul edilebilir? Özellikleri geliştiricinin değil, son kullanıcının tanımladığı durumlarda: bir SaaS (Software as a Service) ürününde her müşterinin kendi "özel alan"larını eklemesi gibi. O durumda bile özellik tanımlarını (ad, tip, zorunluluk) ayrı bir metadata tablosunda tut ve değerleri tipine göre doğrula. Kendi özelliklerini bilen bir uygulama için EAV, şemayı veritabanından alıp uygulama koduna dağıtmaktır.

Virgülle Ayrılmış Liste

Tahmin sorusundaki tags kolonu bu anti-pattern'in kendisi. Cazip, çünkü ekranda etiketler zaten virgülle gösteriliyor ve bir kolon eklemek iki tablo eklemekten kolay. LIKE yerine listeyi parçalayıp tam eşleşme aramak yanlış satırı düzeltiyor:

SELECT b.book_id, b.title
FROM dbo.books AS b
CROSS APPLY STRING_SPLIT(b.tags, ',') AS t
WHERE TRIM(t.value) = 'classic'
ORDER BY b.book_id;
book_id  title
-------  -------------------
1        Pride and Prejudice
2        Emma
5        Wuthering Heights
26       Dune

(4 rows affected)

Ama eksik satır hâlâ eksik ve her sorgu her satırın metnini parçalamak zorunda; tags üzerinde işe yarar bir index kuramazsın. Etiket adını değiştirmek (scifi yerine science-fiction) her satırda metin düzenlemesi, bir etiketi kaç kitabın kullandığını saymak bir parse işi. Bu, 6. yazıda 1NF ile çözdüğümüz tekrarlayan grubun ta kendisi. Doğru model bir etiket tablosu ve bir M-N bağlantı tablosu; mevcut veriyi taşırken de liste son bir kez işe yarıyor:

CREATE TABLE dbo.tags (
    tag_id INT IDENTITY(1,1) NOT NULL,
    name   VARCHAR(50)       NOT NULL,
    CONSTRAINT PK_tags PRIMARY KEY (tag_id),
    CONSTRAINT UQ_tags_name UNIQUE (name)
);
CREATE TABLE dbo.book_tags (
    book_id INT NOT NULL,
    tag_id  INT NOT NULL,
    CONSTRAINT PK_book_tags PRIMARY KEY (book_id, tag_id),
    CONSTRAINT FK_book_tags_books FOREIGN KEY (book_id) REFERENCES dbo.books (book_id),
    CONSTRAINT FK_book_tags_tags FOREIGN KEY (tag_id) REFERENCES dbo.tags (tag_id)
);
CREATE INDEX IX_book_tags_tag_id ON dbo.book_tags (tag_id);

INSERT INTO dbo.tags (name)
VALUES ('classic'), ('romance'), ('gothic'), ('scifi'), ('modern-classic');

INSERT INTO dbo.book_tags (book_id, tag_id)
SELECT b.book_id, t.tag_id
FROM dbo.books AS b
CROSS APPLY STRING_SPLIT(b.tags, ',') AS s
JOIN dbo.tags AS t ON t.name = TRIM(s.value);

SELECT b.book_id, b.tags
FROM dbo.books AS b
WHERE b.tags IS NOT NULL
  AND NOT EXISTS (
      SELECT 1
      FROM STRING_SPLIT(b.tags, ',') AS s
      JOIN dbo.tags AS t ON t.name = TRIM(s.value)
      GROUP BY ()
      HAVING COUNT(*) = (SELECT COUNT(*) FROM STRING_SPLIT(b.tags, ',')));
(5 rows affected)

(11 rows affected)
book_id  tags
-------  ------------
28       scifi,clasic

(1 rows affected)

Taşıma script'inin son sorgusu, etiketlerinin hepsi tanınmayan kitapları listeliyor: Foundation'ın "clasic"i burada yakalandı. Yeni modelde aynı hata oluşamaz, çünkü book_tags yalnızca tags'te var olan bir etikete bağlanabilir. Liste ne zaman kabul edilebilir? Veritabanının içine hiç bakmadığı, yalnızca saklayıp geri verdiği durumlarda: bir import log'undaki ham satır ya da bir dış sistemin olduğu gibi saklanması gereken alanı gibi. İçinde arama, sayma ya da join yapacağın gün liste tabloya dönüşmelidir.

Tekrarlayan Kolonlar: phone1, phone2, phone3

Müşterinin birden çok telefonu olabiliyor. Tablo yerine kolon eklemek cazip: phone2, phone3. Liste anti-pattern'inin kolonlara yayılmış hâli bu. "Bu numara kimin?" sorusu üç kolonu birden aramayı gerektiriyor:

ALTER TABLE dbo.customers ADD phone2 VARCHAR(20) NULL, phone3 VARCHAR(20) NULL;
GO
UPDATE dbo.customers SET phone = '+90 532 000 0001', phone2 = '+90 216 000 0101' WHERE customer_id = 1;
UPDATE dbo.customers SET phone = '+90 533 000 0002', phone3 = '+90 532 000 0001' WHERE customer_id = 2;

SELECT c.customer_id, c.first_name, p.slot
FROM dbo.customers AS c
CROSS APPLY (VALUES ('phone', c.phone), ('phone2', c.phone2), ('phone3', c.phone3)) AS p (slot, number)
WHERE p.number = '+90 532 000 0001';
(1 rows affected)

(1 rows affected)
customer_id  first_name  slot
-----------  ----------  ------
1            Ada         phone
2            Alan        phone3

(2 rows affected)

Aynı numara Ada'nın birinci, Alan'ın üçüncü telefonu olarak kaydedildi ve hiçbir kısıt bunu engellemedi, çünkü üç kolon üzerinde "bir numara tek müşteriye aittir" kuralını yazamazsın. UNIQUE tek bir kolonun ya da kolon kombinasyonunun benzersizliğini ister; kolonlar arası benzersizlik onun işi değil. Dördüncü telefon bir ALTER TABLE, "telefonu olmayan müşteriler" sorgusu üç koşul. Doğru model bir child tablo:

CREATE TABLE dbo.customer_phones (
    customer_id INT         NOT NULL,
    phone_type  VARCHAR(10) NOT NULL,
    phone       VARCHAR(20) NOT NULL,
    CONSTRAINT PK_customer_phones PRIMARY KEY (customer_id, phone),
    CONSTRAINT UQ_customer_phones_phone UNIQUE (phone),
    CONSTRAINT FK_customer_phones_customers FOREIGN KEY (customer_id) REFERENCES dbo.customers (customer_id),
    CONSTRAINT CK_customer_phones_type CHECK (phone_type IN ('mobile', 'home', 'work'))
);
INSERT INTO dbo.customer_phones (customer_id, phone_type, phone) VALUES
    (1, 'mobile', '+90 532 000 0001'),
    (1, 'home',   '+90 216 000 0101'),
    (2, 'mobile', '+90 533 000 0002');
INSERT INTO dbo.customer_phones (customer_id, phone_type, phone) VALUES
    (2, 'work',   '+90 532 000 0001');
(3 rows affected)
Msg 2627, Level 14, State 1, Server b8be943222fa, Line 14
Violation of UNIQUE KEY constraint 'UQ_customer_phones_phone'. Cannot insert duplicate key in object 'dbo.customer_phones'. The duplicate key value is (+90 532 000 0001).
The statement has been terminated.

Kural artık tek bir constraint; telefonun türü de bir kolon. Ne zaman kabul edilebilir? Kolonların anlamı gerçekten farklıysa: billing_address ve shipping_address tekrarlayan bir grup değil, iki farklı rol. Ölçüt şu: kolon adlarındaki sayıyı silince aynı ad kalıyorsa (phone1, phone2) tekrarlayan bir grup var; farklı adlar kalıyorsa yok.

Tek Büyük Lookup Tablosu

Şemada bir sürü küçük lookup tablosu birikince biri hepsini tek bir tabloda toplamayı öneriyor: sipariş durumları, ödeme yöntemleri, diller, hepsi lookup_type kolonuyla ayrılmış tek bir lookups tablosu. Cazip, çünkü tek bir yönetim ekranı, tek bir cache ve tek bir API (Application Programming Interface) endpoint'i yetiyor. Sorun foreign key'de:

CREATE TABLE dbo.lookups (
    lookup_id   INT IDENTITY(1,1) NOT NULL,
    lookup_type VARCHAR(30)       NOT NULL,
    code        VARCHAR(30)       NOT NULL,
    label       NVARCHAR(100)     NOT NULL,
    CONSTRAINT PK_lookups PRIMARY KEY (lookup_id),
    CONSTRAINT UQ_lookups_type_code UNIQUE (lookup_type, code)
);
INSERT INTO dbo.lookups (lookup_type, code, label) VALUES
    ('order_status',   'Pending',   N'Pending'),
    ('order_status',   'Shipped',   N'Shipped'),
    ('payment_method', 'card',      N'Credit card'),
    ('language',       'tr',        N'Turkish');

ALTER TABLE dbo.orders ADD status_id INT NULL
    CONSTRAINT FK_orders_status_lookup REFERENCES dbo.lookups (lookup_id);
GO
UPDATE dbo.orders
SET status_id = (SELECT lookup_id FROM dbo.lookups WHERE lookup_type = 'language' AND code = 'tr')
WHERE order_id = 1;

SELECT o.order_id, l.lookup_type, l.code
FROM dbo.orders AS o
JOIN dbo.lookups AS l ON l.lookup_id = o.status_id;
(4 rows affected)

(1 rows affected)
order_id  lookup_type  code
--------  -----------  ----
1         language     tr

(1 rows affected)

Siparişin durumu artık "Türkçe". Foreign key yalnızca lookup_id'nin var olduğunu kontrol ediyor; hangi türden olduğunu bilmiyor. Bookstore şemasındaki orijinal orders.status kolonu bu hatayı yapamıyordu, çünkü bir CHECK ile beş değere kısıtlıydı. Tek tabloda kalmak zorundaysan, türü de foreign key'in parçası yapabilirsin; sabit bir persisted computed column bunun için yeterli:

ALTER TABLE dbo.orders ADD
    status_type AS CAST('order_status' AS VARCHAR(30)) PERSISTED,
    status_code VARCHAR(30) NULL;
GO
ALTER TABLE dbo.orders ADD CONSTRAINT FK_orders_status_lookup
    FOREIGN KEY (status_type, status_code) REFERENCES dbo.lookups (lookup_type, code);
GO
UPDATE dbo.orders SET status_code = 'Shipped' WHERE order_id = 1;
UPDATE dbo.orders SET status_code = 'tr' WHERE order_id = 2;
(1 rows affected)
Msg 547, Level 16, State 1, Server b8be943222fa, Line 2
The UPDATE statement conflicted with the FOREIGN KEY constraint "FK_orders_status_lookup". The conflict occurred in database "dm_09", table "dbo.lookups".
The statement has been terminated.

Shipped kabul edildi, tr reddedildi. Ama bu bir yama: her referans veren tabloya sabit bir kolon ekliyorsun ve lookups'ın tek bir label kolonu, ödeme yönteminin komisyon oranı ya da dilin yazım yönü gibi türe özgü attribute'lara yer bırakmıyor. Doğru model her kavram için ayrı bir tablo ya da küçük ve kodla birlikte değişen kümeler için CHECK constraint; bu kararın ayrıntısı Çözüm Desenleri serisinin "Enum mı Lookup Tablosu mu?" yazısında.

Aynı aileden bir de polymorphic foreign key var: yorum tablosunda target_type ve target_id kolonlarıyla hem kitaba hem yazara hem siparişe bağlanmak. Tek büyük lookup tablosunun tersine, burada bir kolon birden çok tabloyu gösteriyor ve yine hiçbir foreign key tanımlanamıyor. Alternatifler (her hedef için ayrı nullable foreign key ve CHECK, ortak bir supertype tablosu, hedef başına bağlantı tablosu) Çözüm Desenleri serisinin "Polymorphic Association" yazısında ayrıntısıyla var; burada tekrar etmiyorum.

Her Kolonu Nullable Bırakmak

Bir kolonu nullable bırakmak, karar vermeyi ertelemenin en kolay yolu. ORM'ler (Object-Relational Mapper) de buna yardım eder: nullable reference type'lar kapalı bir C# projesinde her string nullable bir kolona dönüşür. Sonuç, "boş olabilir mi?" sorusunun cevabının şemadan kaybolması. Bir şemadaki nullable kolonları listelemek, review'ın iyi bir başlangıç noktası:

SELECT t.name AS table_name, c.name AS column_name
FROM sys.columns AS c
JOIN sys.tables AS t ON t.object_id = c.object_id
WHERE c.is_nullable = 1
  AND c.is_computed = 0
  AND t.name IN ('books', 'customers', 'orders')
  AND c.name NOT IN ('tags', 'details', 'phone2', 'phone3', 'status_code')
ORDER BY t.name, c.column_id;
table_name  column_name
----------  --------------
books       subtitle
books       category_id
books       published_year
books       page_count
customers   phone
customers   city
orders      shipped_at

(7 rows affected)

(Bu yazıda eklediğim örnek kolonları dışarıda bıraktım.) Bookstore'da yedi nullable kolon var ve her birinin NULL'ı farklı bir şey söylüyor: subtitle için "alt başlığı yok", page_count için "bilmiyoruz", shipped_at için "henüz kargoya verilmedi", yani bir durum. Serinin 3. yazısında nullable foreign key için sorduğumuz "bilinmiyor mu, yok mu?" sorusu her nullable kolon için geçerli. Her şey nullable olduğunda bu anlamların hiçbiri yazılı değildir, sorgular COALESCE ile dolar ve COUNT(page_count) ile COUNT(*)'ın neden farklı olduğunu kimse hatırlamaz. Kural basit: varsayılan NOT NULL, her nullable kolon için NULL'ın anlamı tek cümleyle yazılı. Sentinel değerlerle NULL'dan kaçmanın bedeli Çözüm Desenleri serisinin "Sentinel Value'lar" yazısında.

Para için FLOAT

FLOAT geniş aralıklı ve hızlı bir tip; ölçüm, koordinat ve bilimsel hesap için doğru seçim. Para için yanlış, çünkü ikilik tabanda 0,1 gibi ondalık değerleri tam olarak temsil edemez. On tane 0,10'luk ödemeyi toplayalım:

CREATE TABLE #payments (amount_float FLOAT NOT NULL, amount_decimal DECIMAL(10,2) NOT NULL);
INSERT INTO #payments (amount_float, amount_decimal)
SELECT 0.1, 0.1 FROM GENERATE_SERIES(1, 10);

SELECT SUM(amount_float)                         AS float_sum,
       CAST(SUM(amount_float) AS DECIMAL(20,17))  AS float_sum_exact,
       IIF(SUM(amount_float) = 1.0, 'equal', 'not equal') AS float_check,
       SUM(amount_decimal)                       AS decimal_sum,
       IIF(SUM(amount_decimal) = 1.0, 'equal', 'not equal') AS decimal_check
FROM #payments;
(10 rows affected)
float_sum            float_sum_exact     float_check  decimal_sum  decimal_check
-------------------  ------------------  -----------  -----------  -------------
0.99999999999999989  .99999999999999989  not equal    1.00         equal

(1 rows affected)

FLOAT toplam 1'e eşit değil; DECIMAL toplam tam olarak 1,00. Fark ekranda yuvarlanınca görünmez ama eşitlik kontrollerinde, mutabakat raporlarında ve "toplamı sıfır olan hesap" sorgularında ortaya çıkar. Para için DECIMAL(p, s) kullan, ölçeği para biriminin gerektirdiği hassasiyete göre seç ve yuvarlamayı kodda bilinçli yap. Money value object'i, KDV (Katma Değer Vergisi) hesabı ve kuruş dağıtımı Çözüm Desenleri serisinin "Para ve Yuvarlama" yazısında.

Üretimde Dikkat

Anti-pattern'i düzeltmek bir migration'dır ve çoğu zaman tek adımda yapılamaz. Etiket örneğindeki gibi yeni yapıyı yanına kur, veriyi taşı, uygulamayı önce iki yapıya birden yazacak, sonra yalnızca yeniden okuyacak şekilde değiştir, eski kolonu en son kaldır. Bu expand-contract akışının deploy tarafı Deployment Stratejileri serisinin "Migration'ların Deploy'daki Yeri" yazısında.

Taşıma script'ine bir "tanınmayan veri" raporu koy. t3.sql'in son sorgusu Foundation'ın yazım hatasını yakaladı; gerçek veride bu liste uzun olur ve her satır bir karar ister. Satır bazlı hata raporu ve staging tablosu Çözüm Desenleri serisinin "Toplu Import ve Validation Raporu" yazısında.

Esnek alan ihtiyacını sorgulayarak karşıla. "Ürün özellikleri esnek olsun" isteğinin arkasında genellikle bilinen, sayılı birkaç özellik vardır. Önce bunları listele; gerçekten bilinmeyen kısım kalırsa onu JSON kolonuna koy, kısıtla ve sık sorgulanan özellikleri computed column ile dışarı çıkar.

Review'da anti-pattern'leri sorgu ile ara. Adında sayı olan kolonlar, adı tags, ids, list ile biten metin kolonları, attribute_name/attribute_value çifti ve lookup_type gibi kolonlar sys.columns üzerinde basit bir sorguyla bulunur.

Sık Düşülen Tuzaklar

Anti-pattern'i düzeltirken migration planı yapmamak. Kolonu kaldırıp tabloyu tek bir deploy'da eklemek, eski kodun çalışmaya devam ettiği dakikalarda hataya yol açar. Önce genişlet, sonra daralt.

Her esneklik isteğine EAV ile cevap vermek. EAV'nin bedeli, veritabanının tip, zorunluluk ve ad kontrolünü bırakmasıdır; bu bedeli yalnızca özellikleri gerçekten son kullanıcı tanımlıyorsa öde.

Listeyi LIKE ile aramak. '%classic%' "modern-classic"i de bulur; virgüllerle çevrelemek (',' + tags + ',') yanlış eşleşmeyi azaltır ama boşluk ve yazım hatası sorununu çözmez. Liste sorgulanıyorsa tablodur.

CHECK constraint'te NULL'ı unutmak. CHECK yalnızca FALSE sonucu reddeder; UNKNOWN sonuç veren her ifade geçer. JSON kısıtında yaşadığımız bu tuzak, nullable kolonlardaki her CHECK için geçerli.

Tek büyük lookup tablosunu "tekrar azaltma" sanmak. Farklı kavramların aynı tabloda durması normalizasyon değildir; aynı yapıdaki satırlar aynı anlamı taşımıyorsa onları birleştirmek, foreign key'in anlamını kaybettirir.

Küçük Bir Deneme

★☆☆ book_tags'e Foundation için doğru classic etiketini ekle ve "klasik kitaplar" sorgusunu yeni modelle yaz. Kaç satır dönüyor?

★★☆ book_attributes'teki veriyi books.details JSON kolonuna taşıyan bir script yaz. filesize_mb ve unknown satırları için ne yapıyorsun? Script hangi satırları rapor etmeli?

★★★ lookups tablosunu kavram başına tablolara (order_statuses, payment_methods, languages) bölen bir expand-contract planı çıkar: hangi adımda hangi kolon eklenir, uygulama ne zaman yeni tabloya geçer, eski foreign key ne zaman kaldırılır?

Anti-pattern'lerin çoğu, normalize bir OLTP (Online Transaction Processing) şemasında bir şeyi kısayoldan yapma isteğinden doğuyor. Bir sonraki yazıda bambaşka bir ihtiyaca bakıyoruz: raporlama. Normalize OLTP şemasının analitik sorgularda neden yorulduğunu, fact ve dimension tablolarını, star schema'yı ve slowly changing dimension tiplerini Bookstore'un satış verisiyle kuracağız.