Desain pada Bagian 2 kita wujudkan mulai dari tabel dimensi. Tiga persoalan harus diselesaikan sekaligus pada tahap ini: menerbitkan surrogate key yang stabil, mengenali natural key yang sudah termuat agar pemuatan ulang tidak menggandakan baris, dan menyiapkan kolom riwayat pada dim_pelanggan. Seluruh logika berada pada src/db.py dan src/dim_loader.py; teori surrogate key ada pada chapter Surrogate Key dan Slowly Changing Dimensions. Pada akhir bagian ini empat dimensi sudah terisi penuh dari CSV pada folder data/.
Koneksi dan DDL Gudang #
Modul src/db.py mengurus dua hal: lokasi berkas gudang dan DDL seluruh tabel. Fungsi koneksinya sengaja sederhana:
def connect(berkas=None):
"""Buka koneksi DuckDB; berkas gudang dibuat bila belum ada."""
if berkas is None:
berkas = BERKAS_GUDANG
berkas = Path(berkas)
berkas.parent.mkdir(parents=True, exist_ok=True)
return duckdb.connect(str(berkas))Tanpa argumen, connect() membuka data/warehouse.duckdb milik proyek; uji pada Bagian 7 memakai argumen ini untuk mengarah ke berkas sementara. Seluruh DDL memakai CREATE TABLE IF NOT EXISTS sehingga skema boleh dibuat berulang kali. Perlu dicatat: DDL sengaja tidak memasang NOT NULL, PRIMARY KEY, ataupun FOREIGN KEY, sesuai keputusan Bagian 2; integritas menjadi tanggung jawab pemeriksaan kualitas data.
Sidik Jari Natural Key dengan Hash #
Dua fungsi kecil pada src/dim_loader.py menjadi fondasi seluruh pemuatan dimensi:
def hitung_hash(nilai: str) -> str:
"""Sidik jari md5 sepanjang 32 karakter heksadesimal untuk sebuah teks."""
return hashlib.md5(nilai.encode("utf-8")).hexdigest()
def hash_atribut(nilai_atribut) -> str:
"""Hash gabungan atribut; dipakai mendeteksi perubahan isi dimensi."""
return hitung_hash("|".join(str(x) for x in nilai_atribut))nk_hash menyimpan hitung_hash(pelanggan_id) sebagai pengenal natural key yang seragam panjangnya, sementara attr_hash menyimpan hash gabungan seluruh atribut deskriptif. Ketika data sumber berubah, nk_hash tetap sama tetapi attr_hash berubah; selisih keduanya adalah sinyal perubahan isi yang dipakai SCD Type 2 pada Bagian 6 tanpa membandingkan kolom satu per satu. Skema nama|email|kota|segmen dipisah karakter | juga ditulis sama persis pada model SQL Bagian 5, sehingga hash dari Python dan dari SQL selalu identik; kesamaan ini diuji pada tests/test_pipeline_e2e.py.
Pemuatan dim_pelanggan #
Fungsi berikut memuat pelanggan baru sekaligus menyiapkan kolom SCD Type 2:
def muat_dim_pelanggan(con, df, valid_dari: date = date(2025, 1, 1)) -> int:
"""Muat pelanggan baru sebagai versi SCD Type 2 pertama (idempoten).
Setiap pelanggan baru mendapat valid_from = valid_dari, valid_to =
TANGGAL_JAUH, dan is_current = TRUE. Perubahan atribut setelah pemuatan
awal ditangani oleh src/scd2.py.
"""
df = df.copy()
df["nk_hash"] = [hitung_hash(p) for p in df["pelanggan_id"]]
df["attr_hash"] = [
hash_atribut([n, e, k, s])
for n, e, k, s in zip(df["nama"], df["email"], df["kota"], df["segmen"])
]
con.register("sumber_dim", df)
mulai = sk_berikutnya(con, "dim_pelanggan", "pelanggan_sk")
con.execute(
"""
INSERT INTO dim_pelanggan
SELECT ({mulai} + ROW_NUMBER() OVER (ORDER BY pelanggan_id) - 1)::INTEGER,
nk_hash, attr_hash, pelanggan_id, nama, email, kota, segmen,
DATE '{valid_dari}', DATE '{valid_ke}', TRUE
FROM sumber_dim AS s
WHERE s.nk_hash NOT IN (SELECT nk_hash FROM dim_pelanggan)
""".format(mulai=mulai, valid_dari=valid_dari, valid_ke=TANGGAL_JAUH)
)
con.unregister("sumber_dim")
return con.execute("SELECT COUNT(*) FROM dim_pelanggan").fetchone()[0]Mari kita telaah baris-baris kode di atas:
- Baris
df = df.copy(): DataFrame sumber disalin agar penambahan kolom hash tidak mengubah data pemanggil. - Baris
df["nk_hash"] = ...dandf["attr_hash"] = ...: kedua kolom hash dihitung di sisi Python, satu baris sumber satu hash. - Baris
con.register("sumber_dim", df): DataFrame didaftarkan sebagai tabelsumber_dimsehingga dapat dibaca SQL DuckDB tanpa menulis berkas perantara. - Baris
mulai = sk_berikutnya(...): surrogate key berikutnya diambil dari nilai terbesar yang ada, sehingga pemuatan lanjutan tidak menimpa kunci lama. - Baris
SELECT ({mulai} + ROW_NUMBER() ... - 1)::INTEGER: kunci baru diterbitkan berurutan sesuai urutanpelanggan_id, lalu dilemparkan ke INTEGER. - Baris
DATE '{valid_dari}', DATE '{valid_ke}', TRUE: seluruh pelanggan awal dibuka sebagai versi pertama yang berlaku sejak 1 Januari 2025 hingga9999-12-31. - Baris
WHERE s.nk_hash NOT IN ...: hanya natural key yang belum pernah termuat yang disisipkan; inilah yang membuat fungsi idempoten.
Fungsi muat_dim_produk dan muat_dim_toko mengikuti pola yang sama dengan daftar kolom berbeda, sehingga tidak kita ulang di sini.
Dimensi Tanggal #
dim_tanggal tidak dibaca dari CSV melainkan dibangkitkan, karena kalender mudah dihitung dan tidak pernah berubah. Fungsi bangun_dim_tanggal pada src/dim_loader.py mengisi rentang tanggal hari demi hari:
kursor = mulai
while kursor <= akhir:
sk = int(kursor.strftime("%Y%m%d"))
if sk not in sudah_ada:
indeks_bulan = kursor.month - 1
indeks_hari = kursor.isoweekday() - 1
baris_baru.append(
(
sk,
kursor,
kursor.year,
(kursor.month - 1) // 3 + 1,
kursor.month,
NAMA_BULAN[indeks_bulan],
kursor.day,
kursor.isoweekday(),
NAMA_HARI[indeks_hari],
kursor.isoweekday() >= 6,
)
)
kursor += timedelta(days=1)Tiga hal perlu digarisbawahi: sk memakai smart key YYYYMMDD sesuai keputusan Bagian 2; isoweekday() menghasilkan 1 untuk Senin sampai 7 untuk Minggu sehingga akhir pekan cukup diuji >= 6; dan pemeriksaan sk not in sudah_ada membuat fungsi aman dipanggil berulang untuk memperpanjang kalender tanpa menduplikasi tanggal.
Menjalankan Pemuatan Dimensi #
Skrip demo_dim.py pada berkas sumber memuat keempat dimensi dari CSV ke gudang di memori, lalu memuat ulang seluruhnya untuk membuktikan idempotensi:
con = duckdb.connect(":memory:")
db.buat_skema_gudang(con)
df_pelanggan = pd.read_csv("data/pelanggan.csv")
df_produk = pd.read_csv("data/produk.csv")
df_toko = pd.read_csv("data/toko.csv")
dim_loader.muat_dim_pelanggan(con, df_pelanggan, valid_dari=date(2025, 1, 1))
dim_loader.muat_dim_produk(con, df_produk)
dim_loader.muat_dim_toko(con, df_toko)
disisipkan = dim_loader.bangun_dim_tanggal(con, date(2025, 1, 1), date(2025, 6, 30))
print(f"dim_tanggal : {disisipkan} baris baru")
print(f"dim_pelanggan: {dim_loader.muat_dim_pelanggan(con, df_pelanggan)} baris (muat ulang)")
print(f"dim_produk : {dim_loader.muat_dim_produk(con, df_produk)} baris (muat ulang)")
print(f"dim_toko : {dim_loader.muat_dim_toko(con, df_toko)} baris (muat ulang)")Output yang Dihasilkan:
dim_tanggal : 181 baris baru
dim_pelanggan: 10 baris (muat ulang)
dim_produk : 12 baris (muat ulang)
dim_toko : 5 baris (muat ulang)
=== contoh isi dim_pelanggan ===
sk= 1 PLG-001 Budi Santoso Jakarta reguler nk=f1c4749f... attr=43179108... valid 2025-01-01 s.d. 9999-12-31 kini=True
sk= 2 PLG-002 Siti Rahayu Bandung reguler nk=fee3146b... attr=e4df82c0... valid 2025-01-01 s.d. 9999-12-31 kini=True
sk= 3 PLG-003 Dewi Lestari Bandung loyal nk=2caa4c83... attr=52ca9986... valid 2025-01-01 s.d. 9999-12-31 kini=True
=== contoh isi dim_tanggal ===
20250105 Minggu Januari kuartal 1 akhir_pekan=True
20250314 Jumat Maret kuartal 1 akhir_pekan=FalseEmpat pemuatan ulang menghasilkan jumlah baris yang sama dengan pemuatan pertama: pelacakan nk_hash bekerja. Seluruh perilaku ini dikunci oleh tests/test_dim.py yang menguji kunci berurutan, idempotensi, kesamaan hash Python dengan md5() SQL, sampai nama hari Indonesia. Dengan dimensi siap, kita mengisi pusat bintang pada Bagian 4: fact table.