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:

python
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:

python
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:

python
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:

  1. Baris df = df.copy(): DataFrame sumber disalin agar penambahan kolom hash tidak mengubah data pemanggil.
  2. Baris df["nk_hash"] = ... dan df["attr_hash"] = ...: kedua kolom hash dihitung di sisi Python, satu baris sumber satu hash.
  3. Baris con.register("sumber_dim", df): DataFrame didaftarkan sebagai tabel sumber_dim sehingga dapat dibaca SQL DuckDB tanpa menulis berkas perantara.
  4. Baris mulai = sk_berikutnya(...): surrogate key berikutnya diambil dari nilai terbesar yang ada, sehingga pemuatan lanjutan tidak menimpa kunci lama.
  5. Baris SELECT ({mulai} + ROW_NUMBER() ... - 1)::INTEGER: kunci baru diterbitkan berurutan sesuai urutan pelanggan_id, lalu dilemparkan ke INTEGER.
  6. Baris DATE '{valid_dari}', DATE '{valid_ke}', TRUE: seluruh pelanggan awal dibuka sebagai versi pertama yang berlaku sejak 1 Januari 2025 hingga 9999-12-31.
  7. 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:

python
    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:

python
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:

text
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=False

Empat 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.