Pusat bintang kita isi sekarang. Fact table berbeda dari tabel dimensi pada dua hal: isinya jauh lebih panjang tumbuhnya, dan setiap barisnya harus menghubungkan ke dimensi yang tepat pada saat transaksi terjadi, bukan dimensi versi terbaru. Pada bagian ini kita membangun src/fact_loader.py yang menyelesaikan resolusi foreign key, menyimpan degenerate dimension, dan menjaga pemuatan tetap idempoten. Konsep grain dan jenis measure telah kita kunci di Bagian 1 dan 2; teorinya dibahas chapter Fact Table dan Dimension Table.
Struktur fact_pesanan #
DDL pada src/db.py mendefinisikan sebelas kolom sesuai spesifikasi Bagian 2:
"fact_pesanan": """
CREATE TABLE IF NOT EXISTS fact_pesanan (
pesanan_id VARCHAR,
baris INTEGER,
tanggal_sk INTEGER,
pelanggan_sk INTEGER,
produk_sk INTEGER,
toko_sk INTEGER,
jumlah INTEGER,
harga_satuan BIGINT,
diskon BIGINT,
total BIGINT,
status VARCHAR
)
""",Empat kolom *_sk adalah foreign key ke dimensi, pesanan_id dan baris adalah degenerate dimension sekaligus pembeda grain, dan empat kolom terakhir adalah measure beserta statusnya. Sama seperti dimensi, tidak ada constraint; tabel ini adalah transaction fact table murni: satu baris peristiwa, tidak pernah diubah setelah termuat.
Resolusi Foreign Key #
Seluruh pekerjaan berat fact loader terjadi pada satu pernyataan SQL di src/fact_loader.py:
def muat_fact_pesanan(con, df_pesanan) -> int:
"""Muat baris pesanan baru ke fact_pesanan; baris yang sudah ada dilewati.
Resolusi pelanggan_sk memakai join SCD Type 2: versi dimensi yang dipilih
adalah versi yang sah pada tanggal transaksi (valid_from <= tanggal <=
valid_to), bukan sekadar versi terbaru. pesanan_id dan baris disimpan
sebagai degenerate dimension. Mengembalikan jumlah baris yang disisipkan.
"""
df = df_pesanan.copy()
df["tanggal"] = pd.to_datetime(df["tanggal"]).dt.date
for kolom in ("jumlah", "harga_satuan", "diskon"):
df[kolom] = df[kolom].astype("int64")
con.register("sumber_fact", df)
hasil = con.execute(
"""
INSERT INTO fact_pesanan
SELECT s.pesanan_id,
s.baris,
t.tanggal_sk,
p.pelanggan_sk,
r.produk_sk,
k.toko_sk,
s.jumlah::INTEGER,
s.harga_satuan::BIGINT,
s.diskon::BIGINT,
(s.jumlah * s.harga_satuan - s.diskon)::BIGINT AS total,
s.status
FROM sumber_fact AS s
JOIN dim_tanggal AS t
ON t.tanggal = s.tanggal
JOIN dim_pelanggan AS p
ON p.pelanggan_id = s.pelanggan_id
AND s.tanggal BETWEEN p.valid_from AND p.valid_to
JOIN dim_produk AS r
ON r.produk_id = s.produk_id
JOIN dim_toko AS k
ON k.toko_id = s.toko_id
WHERE NOT EXISTS (
SELECT 1 FROM fact_pesanan AS f
WHERE f.pesanan_id = s.pesanan_id AND f.baris = s.baris
)
"""
).fetchall()
con.unregister("sumber_fact")
return int(hasil[0][0]) if hasil else 0Mari kita telaah bagian-bagian kuncinya:
- Baris
df["tanggal"] = pd.to_datetime(...).dt.date: kolom tanggal dinormalisasi menjadi objekdateagar join dengandim_tanggal.tanggaltidak gagal karena perbedaan tipe antara teks CSV dan DATE. - Baris
for kolom in (...): kolom numerik dilemparkan keint64sehingga DuckDB membacanya sebagai BIGINT dan koma desimal terselip tidak lolos. - Baris
JOIN dim_tanggal ... ON t.tanggal = s.tanggal: tanggal teks sumber dipetakan ke smart keytanggal_sk. - Baris
JOIN dim_pelanggan ... AND s.tanggal BETWEEN p.valid_from AND p.valid_to: inilah resolusi sadar tanggal; saat Bagian 6 menambahkan versi kedua seorang pelanggan, pesanan lama tetap menunjuk versi yang sah pada saat transaksi, bukan versi terbaru. - Baris
(s.jumlah * s.harga_satuan - s.diskon)::BIGINT AS total: measuretotaldihitung sekali saat pemuatan; query analitik cukupSUM(total)tanpa menghitung ulang. - Baris
WHERE NOT EXISTS ...: pasanganpesanan_iddanbarisyang sudah ada dilewati, sehingga menjalankan ulang pemuatan tidak pernah menggandakan fakta.
Menjalankan Pemuatan Fact #
Dengan dimensi Bagian 3 sudah termuat, skrip demo_fact.py memuat data/pesanan.csv lalu memuat ulang berkas yang sama:
df_pesanan = pd.read_csv("data/pesanan.csv")
disisipkan = fact_loader.muat_fact_pesanan(con, df_pesanan)
print(f"pemuatan pertama : {disisipkan} baris disisipkan")
disisipkan = fact_loader.muat_fact_pesanan(con, df_pesanan)
print(f"pemuatan kedua : {disisipkan} baris disisipkan")Output yang Dihasilkan:
pemuatan pertama : 32 baris disisipkan
pemuatan kedua : 0 baris disisipkan
=== grain: baris per pesanan ===
ORD-0001 2 baris; nilai Rp254.000
ORD-0002 2 baris; nilai Rp149.000
ORD-0003 1 baris; nilai Rp70.000
ORD-0004 2 baris; nilai Rp230.000
ORD-0005 1 baris; nilai Rp285.000
=== degenerate dimension + resolusi SK (ORD-0004) ===
ORD-0004/1 tgl_sk=20250214 plg_sk=3 prk_sk=1 tko_sk=2 | Bandung | Kopi Arabika Gayo 250g @ TokoKita Dago x1 = Rp85.000
ORD-0004/2 tgl_sk=20250214 plg_sk=3 prk_sk=7 tko_sk=2 | Bandung | French Press 350ml @ TokoKita Dago x1 = Rp145.000Pemuatan kedua menyisipkan nol baris: idempotensi bekerja. Pesanan ORD-0004 menempati dua baris sesuai grain, dan kedua barisnya berhasil digabungkan kembali ke atribut Bandung dan TokoKita Dago melalui surrogate key. Perilaku ini dikunci tests/test_fact.py yang menguji resolusi kunci, hitungan total, idempotensi, dan baris di luar kalender. Kini dimensi dan fact table termuat terpisah; Bagian 5 merangkai semuanya menjadi satu pipeline terjadwal dengan model SQL gaya dbt.