SQL join: memilih catatan terakhir dalam hubungan satu ke banyak

298

Misalkan saya memiliki tabel pelanggan dan tabel pembelian. Setiap pembelian milik satu pelanggan. Saya ingin mendapatkan daftar semua pelanggan beserta pembelian terakhir mereka dalam satu pernyataan SELECT. Apa praktik terbaik? Adakah saran untuk membuat indeks?

Silakan gunakan nama tabel / kolom ini dalam jawaban Anda:

  • pelanggan: id, nama
  • pembelian: id, customer_id, item_id, tanggal

Dan dalam situasi yang lebih rumit, apakah akan (menguntungkan kinerja) bermanfaat untuk mendenormalkan basis data dengan menempatkan pembelian terakhir ke dalam tabel pelanggan?

Jika id (pembelian) dijamin disortir berdasarkan tanggal, dapatkah laporan disederhanakan dengan menggunakan sesuatu seperti LIMIT 1?

netvope
sumber
Ya, itu mungkin layak dinormalkan (jika itu banyak meningkatkan kinerja, yang hanya bisa Anda ketahui dengan menguji kedua versi). Tetapi kerugian dari denormalisasi biasanya layak untuk dihindari.
Vince Bowdren

Jawaban:

451

Ini adalah contoh dari greatest-n-per-groupmasalah yang telah muncul secara teratur di StackOverflow.

Inilah cara saya biasanya merekomendasikan untuk menyelesaikannya:

SELECT c.*, p1.*
FROM customer c
JOIN purchase p1 ON (c.id = p1.customer_id)
LEFT OUTER JOIN purchase p2 ON (c.id = p2.customer_id AND 
    (p1.date < p2.date OR (p1.date = p2.date AND p1.id < p2.id)))
WHERE p2.id IS NULL;

Penjelasan: diberikan satu baris p1, seharusnya tidak ada baris p2dengan pelanggan yang sama dan tanggal kemudian (atau dalam kasus ikatan, nanti id). Ketika kami menemukan itu benar, maka p1adalah pembelian terbaru untuk pelanggan itu.

Mengenai indeks, saya akan membuat indeks senyawa dalam purchaseatas kolom ( customer_id, date, id). Itu memungkinkan sambungan luar dilakukan menggunakan indeks penutup. Pastikan untuk menguji pada platform Anda, karena optimasi bergantung pada implementasi. Gunakan fitur RDBMS Anda untuk menganalisis rencana pengoptimalan. Misalnya EXPLAINdi MySQL.


Beberapa orang menggunakan subquery alih-alih solusi yang saya tunjukkan di atas, tetapi saya menemukan solusi saya membuatnya lebih mudah untuk menyelesaikan ikatan.

Bill Karwin
sumber
3
Menguntungkan, secara umum. Tetapi itu tergantung pada merek basis data yang Anda gunakan, dan jumlah dan distribusi data dalam basis data Anda. Satu-satunya cara untuk mendapatkan jawaban yang tepat adalah bagi Anda untuk menguji kedua solusi terhadap data Anda.
Bill Karwin
27
Jika Anda ingin menyertakan pelanggan yang tidak pernah melakukan pembelian, maka ubah JOIN pembelian p1 ON (c.id = p1.customer_id) menjadi LEFT JOIN pembelian p1 ON (c.id = p1.customer_id)
GordonM
5
@ russds, Anda memerlukan beberapa kolom unik yang dapat Anda gunakan untuk menyelesaikan dasi. Tidak masuk akal untuk memiliki dua baris identik dalam database relasional.
Bill Karwin
6
Apa tujuan dari "WHERE p2.id IS NULL"?
clu
3
solusi ini hanya berfungsi, jika ada lebih dari 1 catatan pembelian. ist ada 1: 1 link, TIDAK berfungsi. harus ada "WHERE (p2.id IS NULL atau p1.id = p2.id)
Bruno Jennrich
126

Anda juga dapat mencoba melakukan ini menggunakan sub pilih

SELECT  c.*, p.*
FROM    customer c INNER JOIN
        (
            SELECT  customer_id,
                    MAX(date) MaxDate
            FROM    purchase
            GROUP BY customer_id
        ) MaxDates ON c.id = MaxDates.customer_id INNER JOIN
        purchase p ON   MaxDates.customer_id = p.customer_id
                    AND MaxDates.MaxDate = p.date

Pilih harus bergabung pada semua pelanggan dan tanggal pembelian terakhir mereka .

Adriaan Stander
sumber
4
Terima kasih ini baru saja menyelamatkan saya - solusi ini tampaknya lebih dapat diterima dan dipelihara daripada yang lain + tidak spesifik produk
Daveo
Bagaimana saya memodifikasi ini jika saya ingin mendapatkan pelanggan bahkan jika tidak ada pembelian?
clu
3
@ clu: Ubah INNER JOINke a LEFT OUTER JOIN.
Sasha Chedygov
3
Sepertinya ini mengasumsikan hanya ada satu pembelian pada hari itu. Jika ada dua Anda akan mendapatkan dua baris output untuk satu pelanggan, saya kira?
artfulrobot
1
@IstiaqueAhmed - INNER JOIN terakhir mengambil nilai Max (tanggal) dan mengikatnya kembali ke tabel sumber. Tanpa itu bergabung, satu-satunya informasi yang Anda miliki dari purchasetabel adalah tanggal dan customer_id, tetapi permintaan meminta semua bidang dari tabel.
Tertawa Vergil
26

Anda belum menentukan basis datanya. Jika itu adalah salah satu yang memungkinkan fungsi analitis mungkin lebih cepat untuk menggunakan pendekatan ini daripada GROUP BY satu (pasti lebih cepat di Oracle, kemungkinan besar lebih cepat di edisi SQL Server akhir, tidak tahu tentang yang lain).

Sintaks dalam SQL Server adalah:

SELECT c.*, p.*
FROM customer c INNER JOIN 
     (SELECT RANK() OVER (PARTITION BY customer_id ORDER BY date DESC) r, *
             FROM purchase) p
ON (c.id = p.customer_id)
WHERE p.r = 1
Madalina Dragomir
sumber
10
Ini adalah jawaban yang salah untuk pertanyaan itu karena Anda menggunakan "RANK ()" alih-alih "ROW_NUMBER ()". RANK masih akan memberi Anda masalah ikatan yang sama ketika dua pembelian memiliki tanggal yang sama persis. Itulah yang dilakukan fungsi Peringkat; jika pencocokan 2 teratas, mereka berdua diberi nilai 1 dan catatan 3 mendapat nilai 3. Dengan Row_Number, tidak ada dasi, ini unik untuk seluruh partisi.
MikeTeeVee
4
Mencoba pendekatan Bill Karwin terhadap pendekatan Madalina di sini, dengan rencana eksekusi diaktifkan di bawah sql server 2008 saya menemukan bahwa apprach Bill Karwin memiliki biaya permintaan 43% dibandingkan dengan pendekatan Madalina yang menggunakan 57% - jadi meskipun ada sintaks yang lebih elegan dari jawaban ini, saya masih akan menyukai versi Bill!
Shawson
26

Pendekatan lain adalah dengan menggunakan NOT EXISTSkondisi dalam kondisi bergabung Anda untuk menguji pembelian selanjutnya:

SELECT *
FROM customer c
LEFT JOIN purchase p ON (
       c.id = p.customer_id
   AND NOT EXISTS (
     SELECT 1 FROM purchase p1
     WHERE p1.customer_id = c.id
     AND p1.id > p.id
   )
)
Stefan Haberl
sumber
Bisakah Anda menjelaskan AND NOT EXISTSbagian itu dengan kata-kata yang mudah?
Istiaque Ahmed
Sub pilih hanya memeriksa apakah ada baris dengan id yang lebih tinggi. Anda hanya akan mendapatkan baris di set hasil Anda, jika tidak ada yang dengan id lebih tinggi ditemukan. Itu harus menjadi yang tertinggi dan unik.
Stefan Haberl
2
Bagi saya ini adalah solusi yang paling mudah dibaca . Jika ini penting.
fguillen
:) Terima kasih. Saya selalu berusaha untuk solusi paling mudah dibaca, karena itu adalah penting.
Stefan Haberl
19

Saya menemukan utas ini sebagai solusi untuk masalah saya.

Tetapi ketika saya mencobanya mereka kinerjanya rendah. Di bawah ini adalah saran saya untuk kinerja yang lebih baik.

With MaxDates as (
SELECT  customer_id,
                MAX(date) MaxDate
        FROM    purchase
        GROUP BY customer_id
)

SELECT  c.*, M.*
FROM    customer c INNER JOIN
        MaxDates as M ON c.id = M.customer_id 

Semoga ini bisa membantu.

Mathee
sumber
untuk mendapatkan hanya 1 yang saya gunakan top 1dan ordered it byMaxDatedesc
Roshna Omer
1
ini adalah solusi yang mudah dan langsung, dalam kasus SAYA (banyak pelanggan, beberapa pembelian) 10% lebih cepat dari solusi @Stefan Haberl dan lebih dari 10 kali lebih baik daripada jawaban yang diterima
Juraj Bezručka
Saran yang bagus menggunakan common table expressions (CTE) untuk menyelesaikan masalah ini. Ini secara dramatis meningkatkan kinerja kueri dalam banyak situasi.
AdamsTips
Jawaban terbaik imo, mudah dibaca, klausa MAX () memberikan kinerja hebat yang dikompartikan ke ORDER OLEH + LIMIT 1
mrj
10

Jika Anda menggunakan PostgreSQL, Anda dapat menggunakan DISTINCT ONuntuk menemukan baris pertama dalam sebuah grup.

SELECT customer.*, purchase.*
FROM customer
JOIN (
   SELECT DISTINCT ON (customer_id) *
   FROM purchase
   ORDER BY customer_id, date DESC
) purchase ON purchase.customer_id = customer.id

PostgreSQL Documents - Distinct On

Perhatikan bahwa DISTINCT ONbidang - di sini customer_id- harus cocok dengan bidang paling kiri di ORDER BYklausa.

Peringatan: Ini adalah klausa yang tidak standar.

Tate Thurston
sumber
8

Coba ini, ini akan membantu.

Saya telah menggunakan ini dalam proyek saya.

SELECT 
*
FROM
customer c
OUTER APPLY(SELECT top 1 * FROM purchase pi 
WHERE pi.customer_id = c.Id order by pi.Id desc) AS [LastPurchasePrice]
Rahul Murari
sumber
Dari mana alias "p" berasal?
TiagoA
ini tidak berkinerja baik .... butuh selamanya di mana contoh lain di sini mengambil 2 detik pada kumpulan data yang saya miliki ....
Joel_J
3

Diuji pada SQLite:

SELECT c.*, p.*, max(p.date)
FROM customer c
LEFT OUTER JOIN purchase p
ON c.id = p.customer_id
GROUP BY c.id

Fungsi max()agregat akan memastikan bahwa pembelian terakhir dipilih dari masing-masing kelompok (tetapi mengasumsikan bahwa kolom tanggal dalam format di mana max () memberikan yang terbaru - yang biasanya merupakan kasus). Jika Anda ingin menangani pembelian dengan tanggal yang sama maka Anda dapat menggunakannya max(p.date, p.id).

Dalam hal indeks, saya akan menggunakan indeks pada pembelian dengan (customer_id, tanggal, [kolom pembelian lainnya yang ingin Anda kembalikan di pilih Anda]).

The LEFT OUTER JOIN(sebagai lawan INNER JOIN) akan memastikan bahwa pelanggan yang tidak pernah melakukan pembelian juga disertakan.

Menandai
sumber
tidak akan berjalan dalam t-sql sebagai pilih c. * memiliki kolom tidak dalam grup dengan klausa
Joel_J
1

Silakan coba ini,

SELECT 
c.Id,
c.name,
(SELECT pi.price FROM purchase pi WHERE pi.Id = MAX(p.Id)) AS [LastPurchasePrice]
FROM customer c INNER JOIN purchase p 
ON c.Id = p.customerId 
GROUP BY c.Id,c.name;
Milad Shahbazi
sumber