Atribut pelanggan berubah: orang pindah kota, segmen naik dari reguler ke loyal. Bila dim_pelanggan hanya menyimpan kondisi terkini, pesanan lama Dewi di Bandung akan tampil seolah terjadi di Jakarta begitu dia pindah, dan analisis per kota menjadi salah secara diam-diam. Slowly Changing Dimension Type 2 menjawab masalah ini dengan menyimpan setiap versi atribut sebagai baris tersendiri, sesuai teori chapter Slowly Changing Dimensions. Pada bagian ini kita menjalankan src/scd2.py pada data perubahan sungguhan lalu membuktikan fact table memilih versi yang tepat.
Tiga Strategi Menyikapi Perubahan #
| Strategi | Perlakuan | Akibat pada analisis |
|---|---|---|
| Type 1: timpa | nilai lama ditimpa nilai baru | riwayat hilang; pesanan lama ikut berpindah kota |
| Type 2: versi baru | baris lama ditutup, baris baru dibuka | riwayat utuh; fact table menunjuk versi saat transaksi |
| Type 3: kolom tambahan | kolom kota_lama disimpan | hanya satu tingkat riwayat yang mungkin |
TokoKita memilih Type 2 untuk dim_pelanggan karena pertanyaan bisnis keenam pada Bagian 1 menanyakan perilaku belanja sebelum dan sesudah pelanggan pindah. Dimensi lain diperlakukan Type 1 sederhana: dim_produk dan dim_toko dimuat ulang penuh oleh model Bagian 5 karena atributnya tidak dipakai membedakan riwayat. Data perubahan sendiri datang dari data/pelanggan_update.csv:
pelanggan_id,tanggal_efektif,kota_baru,segmen_baru
PLG-003,2025-03-01,Jakarta,loyal
PLG-005,2025-04-01,,vipDewi (PLG-003) pindah dari Bandung ke Jakarta per 1 Maret; Rina (PLG-005) naik segmen dari loyal ke vip per 1 April tanpa berpindah kota, ditandai kolom kota_baru yang kosong.
Menutup Versi Lama, Membuka Versi Baru #
Inti src/scd2.py adalah fungsi terapkan_perubahan; berikut inti keputusannya per baris perubahan:
versi_kini = con.execute(
"""
SELECT pelanggan_sk, nama, email, kota, segmen, attr_hash
FROM dim_pelanggan
WHERE pelanggan_id = ? AND is_current
""",
[baris.pelanggan_id],
).fetchone()
if versi_kini is None:
hasil["dilewati"].append(f"{baris.pelanggan_id}: pelanggan tidak dikenal")
continue
kota_baru = baris.kota_baru if pd.notna(baris.kota_baru) else versi_kini[3]
segmen_baru = baris.segmen_baru if pd.notna(baris.segmen_baru) else versi_kini[4]
hash_baru = hash_atribut([versi_kini[1], versi_kini[2], kota_baru, segmen_baru])
if hash_baru == versi_kini[5]:
hasil["dilewati"].append(f"{baris.pelanggan_id}: atribut tidak berubah")
continueMari kita telaah logika tersebut:
- Baris
SELECT ... WHERE is_current: versi yang sedang berlaku dicari berdasarkan natural key; pelanggan yang tidak dikenal dilewati dengan catatan, bukan gagal total. - Baris
kota_baru = ... if pd.notna(...) else versi_kini[3]: kolom perubahan yang kosong diisi nilai versi sekarang, sehingga baris Rina yang hanya mengubah segmen tetap mempertahankan kotanya. - Baris
hash_baru = hash_atribut(...): sidik jari atribut gabungan dihitung ulang; perbandingan denganattr_hashversi lama mendeteksi perubahan tanpa membandingkan kolom satu per satu. - Baris
if hash_baru == versi_kini[5]: perubahan yang ternyata tidak mengubah atribut apa pun dilewati, dan pemeriksaan lanjutan pada berkas sumber juga melewati perubahan yang sudah pernah diterapkan sehingga fungsi ini idempoten.
Bila lolos seluruh pemeriksaan, dua aksi mengikuti: _tutup_versi mengubah versi lama menjadi valid_to = tanggal_efektif - 1 hari dengan is_current = FALSE, lalu _buka_versi menyisipkan baris baru dengan surrogate key segar, valid_from = tanggal_efektif, valid_to = 9999-12-31, dan is_current = TRUE.
Demo: Pelanggan Pindah Kota #
Pipeline Bagian 5 sudah menerapkan kedua perubahan. Skrip demo_scd2.py memeriksa hasilnya langsung pada gudang data; berikut bagian pertamanya:
con = db.connect()
print("=== versi dim_pelanggan untuk PLG-003 dan PLG-005 ===")
for baris in con.execute("""
SELECT pelanggan_id, kota, segmen, valid_from, valid_to, is_current
FROM dim_pelanggan
WHERE pelanggan_id IN ('PLG-003', 'PLG-005')
ORDER BY pelanggan_id, valid_from
""").fetchall():
print(f" {baris[0]} {baris[1]:8s} {baris[2]:8s} "
f"{baris[3].strftime('%Y-%m-%d')} s.d. {baris[4].strftime('%Y-%m-%d')} is_current={baris[5]}")Output yang Dihasilkan (keluaran penuh skrip, dua bagian berikutnya menanyakan pesanan Dewi dan GMV per kota):
=== versi dim_pelanggan untuk PLG-003 dan PLG-005 ===
PLG-003 Bandung loyal 2025-01-01 s.d. 2025-02-28 is_current=False
PLG-003 Jakarta loyal 2025-03-01 s.d. 9999-12-31 is_current=True
PLG-005 Jakarta loyal 2025-01-01 s.d. 2025-03-31 is_current=False
PLG-005 Jakarta vip 2025-04-01 s.d. 9999-12-31 is_current=True
=== pesanan Dewi (PLG-003) dengan versi kota saat transaksi ===
ORD-0004 2025-02-14 kota pelanggan saat transaksi: Bandung
ORD-0007 2025-03-08 kota pelanggan saat transaksi: Jakarta
ORD-0012 2025-04-19 kota pelanggan saat transaksi: Jakarta
=== GMV per kota pelanggan (status selesai) ===
Jakarta Rp1.413.000
Bandung Rp505.000
Surabaya Rp367.000
Yogyakarta Rp365.000
Medan Rp170.000
Semarang Rp70.000Tiga pesanan Dewi terbagi dengan benar: pesanan 14 Februari menempel pada versi Bandung, dua pesanan setelah 1 Maret menempel pada versi Jakarta. Inilah hasil join BETWEEN pada model fact Bagian 5. Rina mengalami hal yang sama pada segmen: ORD-0008 tercatat sebagai pelanggan loyal, ORD-0010 sebagai vip. Seluruh perilaku ini diuji pada tests/test_scd2.py, termasuk penerapan ganda yang harus menghasilkan nol perubahan baru.
Alternatif ASOF JOIN #
DuckDB menyediakan ASOF JOIN untuk pola “versi terakhir yang mulai berlaku sebelum tanggal tertentu” tanpa menulis BETWEEN. Skrip demo_asof.py menjalankan bentuk berikut:
for baris in con.execute("""
SELECT s.pesanan_id, p.kota
FROM (SELECT DISTINCT pesanan_id, tanggal_sk FROM fact_pesanan) AS s
JOIN dim_tanggal AS t ON t.tanggal_sk = s.tanggal_sk
ASOF JOIN dim_pelanggan AS p
ON p.pelanggan_id = 'PLG-003'
AND t.tanggal >= p.valid_from
WHERE s.pesanan_id IN ('ORD-0004', 'ORD-0007', 'ORD-0012')
ORDER BY s.pesanan_id
""").fetchall():
print(f" {baris[0]} -> kota {baris[1]}")Output yang Dihasilkan:
=== resolusi versi SCD2 dengan ASOF JOIN ===
ORD-0004 -> kota Bandung
ORD-0007 -> kota Jakarta
ORD-0012 -> kota JakartaHasilnya sama dengan BETWEEN karena valid_to versi terbuka kita adalah 9999-12-31; ASOF JOIN lebih ringkas saat dimensi berisi banyak versi berurutan. Fitur ini terdokumentasi pada https://duckdb.org/docs/
. Riwayat pelanggan kini utuh dan teruji; satu kelemahan masih tersisa: bagaimana kita tahu gudang data ini sehat setiap kali pipeline berjalan? Bagian 7 menjawabnya dengan pemeriksaan kualitas data.