Mert Özen Her Satırda Daha İleri
Veritabanı PostgreSQL — #20

PostgreSQL Aggregate, Array ve Koşullu Function'lar: FILTER, ORDER BY'lı string_agg/array_agg, unnest, ANY/ALL, COALESCE ve GREATEST

Mert Özen 15 Kas 2026 14 dk 10 görüntülenme
PostgreSQL Aggregate, Array ve Koşullu Function'lar: FILTER, ORDER BY'lı string_agg/array_agg, unnest, ANY/ALL, COALESCE ve GREATEST

PostgreSQL'de FILTER (WHERE ...) her aggregate'e kendi koşulunu verir ve hiç satır yokken count dışındaki aggregate'ler NULL döner, boş array değil. ORDER BY'lı string_agg, array_agg ve jsonb_agg, medyan ve percentile, unnest ve array operatörleri, GIN index'in @> ile çalışıp = ANY ile çalışmaması, IN listesinin aslında = ANY olması, NOT IN'in NULL tuzağı ve GREATEST'in NULL davranışının lehçeden lehçeye değişmesi.

Sürüm notu: Bu yazıdaki örnekler PostgreSQL 18.7 (resmî postgres:18 Docker image'ı) üzerinde, 02'de kurduğumuz Bookstore lab'ında, şema yeniden yüklendikten hemen sonra çalıştırılmıştır; psql'de NULL değerler, 02'deki psqlrc'de olduğu gibi (null) olarak gösteriliyor.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı), back office'te kategori başına stok özeti istiyor: kaç kitap var, kaçı tükenmiş, 49 TL ve üstü kitaplardan rafta kaç adet var, bu pahalı kitaplardan tükenmiş olanlar hangileri. Aynı gruplamada her kolon farklı bir koşul istiyor. Ardından aynı aggregate'leri hiç satır döndürmeyen bir koşulla çalıştırdık:

SELECT b.category,
       count(*) AS books,
       count(*) FILTER (WHERE s.quantity_on_hand = 0) AS out_of_stock,
       sum(s.quantity_on_hand) FILTER (WHERE b.price >= 49) AS units_premium,
       array_agg(b.book_id ORDER BY b.book_id) FILTER (WHERE s.quantity_on_hand = 0 AND b.price >= 49) AS sold_out_premium
FROM books AS b
JOIN stock_items AS s ON s.book_id = b.book_id
GROUP BY b.category
ORDER BY b.category;
SELECT count(*) AS cnt, sum(quantity_on_hand) AS total, avg(quantity_on_hand) AS average,
       array_agg(book_id) AS ids, string_agg(book_id::text, ',') AS csv, bool_and(quantity_on_hand > 0) AS all_in_stock
FROM stock_items
WHERE book_id > 1000000;

Tükenmiş pahalı kitabı olmayan kategorilerde son kolon boş array mi döner, NULL mı? Hiç satır yokken count ile sum aynı şeyi mi söyler?

 category | books | out_of_stock | units_premium |  sold_out_premium
----------+-------+--------------+---------------+--------------------
 Classic  | 22000 |          106 |         49419 | {5913,10914,27643}
 Fantasy  |  4000 |           25 |         10641 | (null)
 Horror   |  2000 |            7 |          4924 | (null)
 SciFi    | 12000 |           69 |         30397 | (null)

 cnt | total  | average |  ids   |  csv   | all_in_stock
-----+--------+---------+--------+--------+--------------
   0 | (null) |  (null) | (null) | (null) | (null)

Hiç satır gelmediğinde count 0, geri kalan bütün aggregate'ler NULL döndürüyor: boş array, boş metin ya da false değil. array_agg'in sonucunu bir JSON alanına ya da C# tarafında bir listeye koyuyorsan bu fark önemli; coalesce(array_agg(...), '{}') ile düzeltmek gerekiyor. Tek sorguda her kolona ayrı koşul veren FILTER (WHERE ...) ise SQL standardının bir parçası; PostgreSQL destekliyor, T-SQL desteklemiyor. Bu yazıda PostgreSQL'in aggregate, array ve koşullu function'larını görüyoruz. Aggregate ve GROUP BY'ın genel mantığı SQL Temelleri serisinde, T-SQL'deki COALESCE, NULLIF ve ISNULL aynı serinin dönüşüm ve NULL function'ları yazısında.

FILTER ile Koşullu Aggregate

T-SQL'de aynı rapor SUM(CASE WHEN quantity_on_hand = 0 THEN 1 ELSE 0 END) kalıbıyla yazılır. PostgreSQL'de de çalışır, ama FILTER hem okunaklı hem de her aggregate'e uyuyor: CASE ile array_agg'e koşul vermek, istenmeyen satırlar için NULL element üretir, FILTER ise satırı aggregate'e hiç sokmaz. FILTER window function'larla da çalışıyor (count(*) FILTER (WHERE ...) OVER (...)).

Kurallar basit: FILTER'ın içindeki koşul WHERE gibi satır bazında değerlendiriliyor, ama sorgunun genel WHERE'inden bağımsız. Genel WHERE satırı tamamen eliyor, FILTER ise yalnızca o kolonun aggregate'inden. Tükenmiş kitap sayısını, kategori toplamıyla aynı satırda görmemizin sebebi bu.

Sıralı ve DISTINCT Aggregate'ler: string_agg, array_agg, jsonb_agg

Sipariş detay ekranı için bir siparişin satırlarını tek satırda toplamak:

SELECT o.order_number,
       string_agg(b.title, ', ' ORDER BY oi.unit_price DESC, b.title) AS titles,
       array_agg(b.isbn ORDER BY b.isbn) AS isbns,
       jsonb_agg(jsonb_build_object('isbn', b.isbn, 'qty', oi.quantity) ORDER BY b.isbn) AS lines,
       bool_or(b.category = 'Horror') AS has_horror,
       bool_and(oi.quantity = 1) AS all_single
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id
JOIN books AS b ON b.book_id = oi.book_id
WHERE o.order_id = 1003
GROUP BY o.order_number;
SELECT array_agg(DISTINCT b.category ORDER BY b.category) AS categories,
       (array_agg(b.title ORDER BY oi.unit_price DESC))[1:2] AS top2
FROM order_items AS oi
JOIN books AS b ON b.book_id = oi.book_id
WHERE oi.order_id BETWEEN 1000 AND 1010;
 order_number |                        titles                        |                    isbns                    |                                                      lines                                                      | has_horror | all_single
--------------+------------------------------------------------------+---------------------------------------------+-----------------------------------------------------------------------------------------------------------------+------------+------------
 BK-00001003  | Middlemarch #24663, Hyperion #2496, Jane Eyre #31289 | {9780000002496,9780000024663,9780000031289} | [{"qty": 1, "isbn": "9780000002496"}, {"qty": 1, "isbn": "9780000024663"}, {"qty": 2, "isbn": "9780000031289"}] | f          | f

       categories        |                       top2
-------------------------+---------------------------------------------------
 {Classic,Fantasy,SciFi} | {"War and Peace #17632","The Silmarillion #6057"}

Aggregate'in içine yazılan ORDER BY, sonuçtaki element'lerin sırasını belirliyor; yazmazsan sıra planın satırları hangi sırayla okuduğuna bağlı ve yarın değişebilir. T-SQL'de bunun karşılığı STRING_AGG(...) WITHIN GROUP (ORDER BY ...); PostgreSQL'de aynı syntax array_agg, jsonb_agg ve json_agg'de de var. DISTINCT ile tekrarları ayıklıyorsan ORDER BY yalnızca aggregate edilen ifadeye göre olabilir. Aggregate'in sonucunu [1:2] ile dilimlemek "en pahalı iki kitap" gibi küçük bir "grup başına ilk n" ihtiyacını window function'sız çözüyor; büyük gruplarda bütün array önce oluşturulduğu için maliyetli. jsonb_agg'i 05'te API'ye hazır JSON üretirken görmüştük; burada dikkat edilecek nokta, jsonb'nin object key'lerini kendi sırasına göre (önce kısa olanlar) sakladığı: qty, isbn'den önce geldi. Key sırası önemliyse json_agg ve json_build_object. bool_or ve bool_and "en az biri" ve "hepsi" sorularının aggregate hâli; standart adı every olan bir eşdeğer de var.

Ordered-Set Aggregate'ler: Medyan, Percentile ve mode

SELECT currency,
       round(avg(total_amount), 2) AS mean,
       percentile_cont(0.5) WITHIN GROUP (ORDER BY total_amount) AS median,
       percentile_disc(0.9) WITHIN GROUP (ORDER BY total_amount) AS p90,
       mode() WITHIN GROUP (ORDER BY total_amount) AS most_common
FROM orders
WHERE created_at >= '2026-10-01'
GROUP BY currency
ORDER BY currency;
 currency | mean  | median |  p90   | most_common
----------+-------+--------+--------+-------------
 EUR      | 78.02 |  72.85 | 138.51 |       32.77
 TRY      | 79.12 |  73.27 | 142.48 |       32.77

Ekim siparişlerinde ortalama sepet tutarı medyandan beş birim yukarıda: birkaç büyük sipariş ortalamayı çekiyor, "tipik müşteri" için medyan daha doğru bir sayı. percentile_cont iki değerin arasına düşen sonucu enterpole ediyor (sonuç double precision), percentile_disc ise veride gerçekten var olan bir değeri döndürüyor. SQL Server'da PERCENTILE_CONT yalnızca window function olarak çalışıyor ve GROUP BY ile tek satır almak için DISTINCT ya da alt sorgu gerekiyor; PostgreSQL'de normal bir aggregate. mode() en sık görülen değer.

Array Function'ları ve Operatörleri

Array tipini ve text[] kolonu 03'te tanıdık. Günlük işte en çok kullanılan function'lar:

SELECT t.tag, t.n FROM unnest(ARRAY['classic', 'bestseller', 'gift']) WITH ORDINALITY AS t(tag, n);
SELECT * FROM unnest(ARRAY['9780000002496', '9780000024663'], ARRAY[1, 2]) AS t(isbn, qty);
SELECT array_length('{}'::text[], 1) AS len_empty, cardinality('{}'::text[]) AS card_empty,
       array_length(ARRAY[[1, 2], [3, 4]], 1) AS dim1, cardinality(ARRAY[[1, 2], [3, 4]]) AS card_2d,
       array_append(ARRAY['classic'], 'gift') AS appended, array_remove(ARRAY['a', 'b', 'a'], 'a') AS removed,
       array_position(ARRAY['a', 'b', 'c'], 'c') AS pos, ARRAY[1, 2] || 3 AS concat;
SELECT ARRAY['classic', 'gift'] @> ARRAY['gift'] AS contains, ARRAY['classic'] && ARRAY['gift', 'classic'] AS overlaps,
       'gift' = ANY (ARRAY['classic', 'gift']) AS any_eq, 5 > ALL (ARRAY[1, 2, 3]) AS gt_all;
    tag     | n
------------+---
 classic    | 1
 bestseller | 2
 gift       | 3

     isbn      | qty
---------------+-----
 9780000002496 |   1
 9780000024663 |   2

 len_empty | card_empty | dim1 | card_2d |    appended    | removed | pos | concat
-----------+------------+------+---------+----------------+---------+-----+---------
    (null) |          0 |    2 |       4 | {classic,gift} | {b}     |   3 | {1,2,3}

 contains | overlaps | any_eq | gt_all
----------+----------+--------+--------
 t        | t        | t      | t

unnest array'i satırlara açıyor; WITH ORDINALITY sıra numarası ekliyor, birden fazla array verildiğinde ise element'leri yan yana kolonlara diziyor. Bu ikincisi, uygulamadan gelen "ISBN (kitapların uluslararası standart numarası) ve adet" listesini tek parametre çifti olarak alıp tabloya dönüştürmenin kısa yolu. array_length boş array için 0 değil NULL döndürüyor, çünkü boş array'in hiç boyutu yok; "element sayısı" için cardinality daha güvenli. Çok boyutlu array'de de array_length(..., 1) yalnızca ilk boyutu sayıyor. @> "hepsini içeriyor mu", && "ortak element var mı", = ANY "herhangi biri eşit mi", > ALL "hepsinden büyük mü" sorusu.

Array kolonda arama yapacaksan index türü önemli. 40.000 kitaba etiket koyup 40 tanesine signed ekledik:

CREATE TABLE book_tags AS
SELECT book_id,
       CASE book_id % 4 WHEN 0 THEN ARRAY['classic', 'bestseller']
                        WHEN 1 THEN ARRAY['classic']
                        WHEN 2 THEN ARRAY['gift']
                        ELSE ARRAY['fantasy', 'gift'] END
       || CASE WHEN book_id % 997 = 0 THEN ARRAY['signed'] ELSE '{}' END AS tags
FROM books;
ANALYZE book_tags;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM book_tags WHERE tags @> ARRAY['signed'];
CREATE INDEX ix_book_tags_tags ON book_tags USING gin (tags);
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM book_tags WHERE tags @> ARRAY['signed'];
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM book_tags WHERE 'signed' = ANY (tags);
                       QUERY PLAN
---------------------------------------------------------
 Aggregate (actual rows=1.00 loops=1)
   Buffers: shared hit=387
   ->  Seq Scan on book_tags (actual rows=40.00 loops=1)
         Filter: (tags @> '{signed}'::text[])
         Rows Removed by Filter: 39960
         Buffers: shared hit=387
 Planning:
   Buffers: shared hit=8

                                   QUERY PLAN
--------------------------------------------------------------------------------
 Aggregate (actual rows=1.00 loops=1)
   Buffers: shared hit=42
   ->  Bitmap Heap Scan on book_tags (actual rows=40.00 loops=1)
         Recheck Cond: (tags @> '{signed}'::text[])
         Heap Blocks: exact=40
         Buffers: shared hit=42
         ->  Bitmap Index Scan on ix_book_tags_tags (actual rows=40.00 loops=1)
               Index Cond: (tags @> '{signed}'::text[])
               Index Searches: 1
               Buffers: shared hit=2
 Planning:
   Buffers: shared hit=22

                       QUERY PLAN
---------------------------------------------------------
 Aggregate (actual rows=1.00 loops=1)
   Buffers: shared hit=384
   ->  Seq Scan on book_tags (actual rows=40.00 loops=1)
         Filter: ('signed'::text = ANY (tags))
         Rows Removed by Filter: 39960
         Buffers: shared hit=384

GIN (Generalized Inverted Index; array'deki her element'i ayrı ayrı index'leyen yapı) index ile @> 387 yerine 42 buffer okudu. Ama aynı soruyu 'signed' = ANY (tags) diye sorunca index kullanılmadı: GIN'in array operator class'ı @>, <@, && ve ='i destekliyor, = ANY'yi değil. 12. yazıda Entity Framework Core (EF Core) provider'ının, kolonda GIN index varsa Contains'i bu yüzden @>'ye çevirdiğini görmüştük.

IN, = ANY ve Array Parametresi

Bookstore'da bir siparişin ISBN listesiyle kitapları getirmek klasik bir IN sorgusu. PostgreSQL'in bunu nasıl gördüğüne bakalım, sonra aynı sorguyu tek bir array parametresiyle hazırlayalım:

EXPLAIN (COSTS OFF) SELECT book_id, title FROM books WHERE isbn IN ('9780000002496', '9780000024663', '9780000031289');
PREPARE books_by_isbn(text[]) AS
    SELECT book_id, title, price FROM books WHERE isbn = ANY ($1) ORDER BY book_id;
EXPLAIN (COSTS OFF) EXECUTE books_by_isbn(ARRAY['9780000002496', '9780000024663', '9780000031289']);
EXECUTE books_by_isbn(ARRAY['9780000002496', '9780000024663', '9780000031289']);
EXECUTE books_by_isbn('{}');
DEALLOCATE books_by_isbn;
                                         QUERY PLAN
--------------------------------------------------------------------------------------------
 Index Scan using ux_books_isbn on books
   Index Cond: ((isbn)::text = ANY ('{9780000002496,9780000024663,9780000031289}'::text[]))

                                            QUERY PLAN
--------------------------------------------------------------------------------------------------
 Sort
   Sort Key: book_id
   ->  Index Scan using ux_books_isbn on books
         Index Cond: ((isbn)::text = ANY ('{9780000002496,9780000024663,9780000031289}'::text[]))

 book_id |       title        | price
---------+--------------------+-------
    2496 | Hyperion #2496     | 14.89
   24663 | Middlemarch #24663 | 20.24
   31289 | Jane Eyre #31289   |  8.07

 book_id | title | price
---------+-------+-------

Planlayıcı IN (...) listesini zaten = ANY (array)'e çeviriyor; plan aynı. Fark sorgu metninde: IN ile 3 ISBN ve 300 ISBN iki farklı sorgu metni, iki ayrı prepared statement ve pg_stat_statements'ta ayrı satırlar demek, 300 element'li bir liste de 300 parametre. = ANY ($1) ise tek bir metin ve tek bir parametre; liste boşken de geçerli SQL (boş IN () syntax hatası verir). Npgsql'den string[] ya da List<string> göndermeyi 11. yazıda gördük; Dapper'ın IN @isbns yazımı ise listeyi ayrı parametrelere açıyor.

NOT IN'in bilinen tuzağı array'lerde de aynı:

SELECT 3 NOT IN (1, 2, NULL) AS not_in_with_null, 3 <> ALL (ARRAY[1, 2, NULL]) AS ne_all_with_null,
       3 = ANY (ARRAY[1, 2, NULL]) AS any_with_null, 2 = ANY (ARRAY[1, 2, NULL]) AS any_found;
SELECT count(*) AS not_in_rows FROM books
WHERE book_id NOT IN (SELECT book_id FROM order_items WHERE order_id <= 3);
SELECT count(*) AS not_in_rows_with_null FROM books
WHERE book_id NOT IN (SELECT book_id FROM order_items WHERE order_id <= 3 UNION ALL SELECT NULL);
SELECT count(*) AS not_exists_rows_with_null FROM books AS b
WHERE NOT EXISTS (SELECT 1 FROM (SELECT book_id FROM order_items WHERE order_id <= 3 UNION ALL SELECT NULL) AS x
                  WHERE x.book_id = b.book_id);
 not_in_with_null | ne_all_with_null | any_with_null | any_found
------------------+------------------+---------------+-----------
 (null)           | (null)           | (null)        | t

 not_in_rows
-------------
       39992

 not_in_rows_with_null
-----------------------
                     0

 not_exists_rows_with_null
---------------------------
                     39992

Listede tek bir NULL varsa "3 bunlardan hiçbiri değil" sorusunun cevabı bilinmiyor (NULL) ve WHERE bu satırı eliyor: alt sorguya tek bir NULL eklemek sonucu 39.992'den sıfıra indirdi. NOT EXISTS aynı durumda doğru sonucu veriyor. Alt sorgunun kolonu nullable ise NOT IN yerine NOT EXISTS kullan.

Koşullu İfadeler: COALESCE, NULLIF, GREATEST ve LEAST

SELECT greatest(10, NULL, 30) AS g, least(10, NULL, 30) AS l, greatest(NULL::int, NULL) AS g_all_null,
       coalesce(NULL, NULL, 'fallback') AS c, nullif(0, 0) AS n, 100 / nullif(0, 0) AS safe_div;
SELECT 100 / 0;
SELECT c.customer_id, c.created_at::date AS registered,
       max(o.created_at)::date AS last_order,
       greatest(c.created_at, max(o.created_at))::date AS last_activity,
       CASE WHEN max(o.created_at) IS NULL THEN 'never'
            WHEN max(o.created_at) < '2026-01-01' THEN 'dormant'
            ELSE 'active' END AS segment
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.customer_id
WHERE c.customer_id IN (2, 99991, 99998)
GROUP BY c.customer_id, c.created_at
ORDER BY c.customer_id;
 g  | l  | g_all_null |    c     |   n    | safe_div
----+----+------------+----------+--------+----------
 30 | 10 |     (null) | fallback | (null) |   (null)

ERROR:  division by zero

 customer_id | registered | last_order | last_activity | segment
-------------+------------+------------+---------------+---------
           2 | 2021-01-01 | 2026-10-20 | 2026-10-20    | active
       99991 | 2022-09-17 | (null)     | 2022-09-17    | never
       99998 | 2022-09-17 | 2023-12-29 | 2023-12-29    | dormant

PostgreSQL'de GREATEST ve LEAST NULL argümanları yok sayıyor, yalnızca hepsi NULL ise NULL dönüyor. Hiç siparişi olmayan 99991 numaralı müşterinin "son aktivite"si bu sayede kayıt tarihi oldu. Bu davranış her veritabanında aynı değil: MySQL dokümantasyonu, argümanlardan biri NULL ise GREATEST()'in NULL döndürdüğünü açıkça yazıyor, yani aynı sorgu MySQL'de bu müşteri için NULL verir. SQL Server 2022 ile gelen GREATEST/LEAST ise PostgreSQL gibi NULL'ları atlıyor; SQL Temelleri serisinin SQL Server 2022 yazısında bunu SQL Server üzerinde görmüştük. Lehçe değiştiren kodda bu fonksiyonları kontrol listesine koy.

COALESCE ilk NULL olmayan argümanı veriyor ve standart; T-SQL'deki ISNULL'ın PostgreSQL'de karşılığı yok, COALESCE kullanılıyor. NULLIF(a, b) iki değer eşitse NULL döndürüyor: sıfıra bölmeyi hatadan NULL'a çevirmenin standart yolu. CASE'in iki biçimi de (aranan değerli CASE x WHEN ... ve koşullu CASE WHEN ...) T-SQL'deki gibi; T-SQL'deki IIF ve CHOOSE kısayollarının PostgreSQL'de karşılığı yok, yerlerine CASE yazılıyor.

Bookstore: Kategori Başına Stok Durumu Tek Sorguda

Satılabilir stok, raftaki adetten rezerve edilmiş adet çıkınca kalan. Kategori başına stokta ve stok dışı kitap sayısı, oran ve genel toplam satırı:

SELECT b.category,
       count(*) FILTER (WHERE s.quantity_on_hand - s.quantity_reserved > 0) AS in_stock,
       count(*) FILTER (WHERE s.quantity_on_hand - s.quantity_reserved <= 0) AS out_of_stock,
       round(100.0 * count(*) FILTER (WHERE s.quantity_on_hand - s.quantity_reserved <= 0)
             / nullif(count(*), 0), 2) AS out_pct
FROM books AS b
JOIN stock_items AS s ON s.book_id = b.book_id
GROUP BY ROLLUP (b.category)
ORDER BY b.category NULLS LAST;
 category | in_stock | out_of_stock | out_pct
----------+----------+--------------+---------
 Classic  |    21894 |          106 |    0.48
 Fantasy  |     3975 |           25 |    0.63
 Horror   |     1993 |            7 |    0.35
 SciFi    |    11931 |           69 |    0.58
 (null)   |    39793 |          207 |    0.52

Tablo bir kez okunuyor, her kolon kendi koşuluyla sayıyor; ROLLUP sonda bütün kategorilerin toplam satırını ekliyor (kategori kolonu orada NULL). 100.0 ile çarpmak tam sayı bölmesini engelliyor; count(*) ile bölünen her yerde nullif(..., 0) alışkanlığı, filtre bir gün boş bir grup ürettiğinde sorgunun hata vermesini önlüyor.

Üretimde Dikkat

string_agg ve array_agg bütün grubu bellekte topluyor. 200.000 siparişi olan kurumsal müşterinin (lab'daki 1 numaralı müşteri) bütün sipariş numaralarını tek bir metne toplamak megabaytlarca bir değer üretir; API'ye sayfalı sonuç dön, aggregate'i yalnızca küçük gruplarda kullan.

Array kolonları için arama operatörünü index'e göre seç: GIN varsa @> ve &&. ORM'in (object'leri tablolara eşleyen kütüphane) ürettiği SQL'i EXPLAIN ile kontrol et; = ANY (tags) GIN'i kullanmıyor.

Parametre listelerinde = ANY ($1) kullan: plan cache'i ve pg_stat_statements'ı şişirmez, boş liste için özel kod gerektirmez. Çok büyük listelerde (on binlerce element) geçici tabloya COPY edip JOIN yapmak daha öngörülebilir.

Sık Düşülen Tuzaklar

Boş grupta array_agg'in boş array döndüreceğini sanmak: NULL döndürüyor; coalesce(..., '{}') ekle.

Aggregate içinde ORDER BY yazmadan sıraya güvenmek: test verisinde doğru görünen sıra, plan değişince bozulur.

NOT IN ile nullable bir alt sorgu: tek bir NULL bütün sonucu boşaltıyor.

array_length(x, 1) = 0 ile boş array kontrolü: hiçbir zaman doğru olmaz, çünkü boş array'de sonuç NULL. cardinality(x) = 0 ya da x = '{}' yaz.

GREATEST'in NULL davranışını her lehçede aynı sanmak: PostgreSQL ve SQL Server atlıyor, MySQL NULL döndürüyor.

Küçük Bir Deneme

★☆☆ 2026 Ekim siparişlerinde ödeme yöntemine göre (payments.method) sipariş sayısı, iade edilen (status = 'Refunded') ödeme sayısı ve iade oranını tek sorguda FILTER ile hesapla.

★★☆ Her müşterinin son üç siparişinin numaralarını (array_agg(order_number ORDER BY created_at DESC))[1:3] ile al ve aynı sonucu ROW_NUMBER() ile yazılmış bir sorguyla karşılaştır. 1 numaralı müşteri için iki sorgunun süresine ve EXPLAIN (ANALYZE) çıktısına bak.

★★★ unnest'in iki array'li hâliyle, uygulamadan gelen ISBN ve adet listesini (text[] ve int[]) satırlara aç, books ve stock_items ile birleştir ve satılabilir stoğu yetmeyen kalemleri döndüren tek bir sorgu yaz. Listede olmayan bir ISBN nasıl görünmeli?

Bir sonraki yazıda view ve materialized view'a geçiyoruz: updatable view, security_invoker, REFRESH MATERIALIZED VIEW CONCURRENTLY ve "en çok satan 100 kitap" listesini pg_cron ile on dakikada bir tazelemek.