Incremental model pada Chapter Incremental Models menjaga tabel target selalu mutakhir: koreksi menimpa nilai lama, dan nilai lama hilang begitu saja. Namun sebagian pertanyaan bisnis justru menuntut nilai lama tersebut — di kota mana Budi tinggal ketika pesanan Januari dibuat, sebelum pindah bulan berikutnya? Chapter Slowly Changing Dimensions di section Data Modeling dan Data Warehouse sudah membangun mekanisme SCD Type 2 secara manual: tutup baris lama, sisipkan versi baru, lalu join fact ke versi yang benar. dbt mengotomatiskan seluruh mekanisme tersebut lewat fitur snapshot: cukup satu berkas deklarasi di proyek, dan setiap kali dbt snapshot dipanggil, pola tutup-versi-lama-lalu-sisipkan-versi-baru berjalan tanpa satu pun UPDATE tulisan tangan.

Deklarasi Snapshot dalam YAML #

Snapshot dideklarasikan pada berkas YAML di direktori snapshots/ — direktori yang sudah terdaftar di snapshot-paths pada dbt_project.yml Chapter dbt. Deklarasi untuk tabel pelanggan TokoKita tampak seperti ini:

yaml
version: 2

snapshots:
  - name: pelanggan_snapshot
    relation: source('aplikasi_toko', 'pelanggan')
    description: "Riwayat perubahan atribut pelanggan (SCD Type 2)"
    config:
      strategy: check
      unique_key: id
      check_cols: [kota]
      hard_deletes: invalidate

Mari kita telaah baris-baris konfigurasi di atas:

  1. Baris 5 (relation): tabel sumber yang dipantau — pelanggan dari deklarasi source pada Chapter dbt.
  2. Baris 8 (strategy: check): cara dbt mendeteksi perubahan, dibahas setelah simulasi.
  3. Baris 9 (unique_key: id): pengenal baris sumber; satu pelanggan menghasilkan rangkaian versi yang tersambung lewat id yang sama.
  4. Baris 10 (check_cols: [kota]): hanya perubahan pada kolom kota yang menumbuhkan versi baru; perubahan kolom lain diabaikan.
  5. Baris 11 (hard_deletes: invalidate): bila baris terhapus dari sumber, versi current-nya ditutup dengan dbt_valid_to, bukan dibiarkan menggantung.

Kita periksa struktur berkasnya dengan yaml.safe_load, parser yang sama yang dbt pakai membaca deklarasi snapshot:

python
"""Validasi YAML deklarasi snapshot bab 5 dengan yaml.safe_load."""
import yaml
from pathlib import Path

BASE = Path(__file__).parent

data = yaml.safe_load((BASE / "ch5_snapshots.yml").read_text(encoding="utf-8"))
snap = data["snapshots"][0]
print("=== ch5_snapshots.yml -> valid ===")
print(f"name        : {snap['name']}")
print(f"relation    : {snap['relation']}")
print(f"strategy    : {snap['config']['strategy']}")
print(f"unique_key  : {snap['config']['unique_key']}")
print(f"check_cols  : {snap['config']['check_cols']}")
print(f"hard_deletes: {snap['config']['hard_deletes']}")

Output yang Dihasilkan:

text
=== ch5_snapshots.yml -> valid ===
name        : pelanggan_snapshot
relation    : source('aplikasi_toko', 'pelanggan')
strategy    : check
unique_key  : id
check_cols  : ['kota']
hard_deletes: invalidate

Empat Kolom yang dbt Tambahkan #

Tabel hasil snapshot berisi semua kolom sumber plus empat kolom meta. dbt_scd_id adalah hash pengenal versi baris; dbt_updated_at mencatat kapan dbt merekam baris tersebut; dbt_valid_from menandai sejak kapan versi berlaku; dan dbt_valid_to menandai akhir masa berlakunya — nilai NULL berarti versi tersebut masih current. Snapshot bersifat append-only: dbt tidak pernah mengubah isi kolom sumber pada baris lama, sehingga riwayat dapat diaudit. Simulasi di bawah menyederhanakan dbt_scd_id sebagai hash md5(id || '|' || kota) dan meniadakan dbt_updated_at agar logika intinya tetap terbaca.

Simulasi Dua Run Snapshot di DuckDB #

dbt tidak terpasang di lingkungan bab ini, jadi kita jalankan logika strategi check secara langsung di DuckDB. Blok di bawah memotong bagian awal skrip yang membuat tabel sumber pelanggan_sumber serta tabel snapshot kosong:

python
def jalankan_snapshot(tanggal):
    """Tiruan logika strategi check dbt: bandingkan hash, tutup lama, sisip baru."""
    # 1) tutup versi current yang hash-nya tidak lagi cocok dengan sumber
    con.execute(f"""
    UPDATE snapshot_pelanggan
    SET dbt_valid_to = DATE '{tanggal}'
    WHERE dbt_valid_to IS NULL
      AND dbt_scd_id NOT IN (
          SELECT md5(id || '|' || kota) FROM pelanggan_sumber
      )
    """)
    # 2) sisipkan versi baru untuk baris sumber yang hash-nya belum current
    con.execute(f"""
    INSERT INTO snapshot_pelanggan
    SELECT id, email, kota,
           md5(id || '|' || kota) AS dbt_scd_id,
           DATE '{tanggal}'        AS dbt_valid_from,
           NULL                    AS dbt_valid_to
    FROM pelanggan_sumber
    WHERE md5(id || '|' || kota) NOT IN (
        SELECT dbt_scd_id FROM snapshot_pelanggan WHERE dbt_valid_to IS NULL
    )
    """)


def tampil(judul):
    print(judul)
    for r in con.execute("""
        SELECT id, email, kota,
               strftime(dbt_valid_from, '%Y-%m-%d'),
               COALESCE(strftime(dbt_valid_to, '%Y-%m-%d'), 'current')
        FROM snapshot_pelanggan ORDER BY id, dbt_valid_from
    """).fetchall():
        print(" ", r)


jalankan_snapshot("2026-01-10")
tampil("=== RUN 1: snapshot 2026-01-10 ===")

print()
print("--- sumber berubah: Budi pindah ke Depok, pelanggan baru Rina ---")
con.execute("""
UPDATE pelanggan_sumber SET kota = 'Depok' WHERE id = 1;
INSERT INTO pelanggan_sumber VALUES (3, '[email protected]', 'Surabaya');
""")

jalankan_snapshot("2026-01-20")
tampil("=== RUN 2: snapshot 2026-01-20 ===")

Output yang Dihasilkan:

text
=== RUN 1: snapshot 2026-01-10 ===
  (1, '[email protected]', 'Jakarta', '2026-01-10', 'current')
  (2, '[email protected]', 'Bandung', '2026-01-10', 'current')

--- sumber berubah: Budi pindah ke Depok, pelanggan baru Rina ---
=== RUN 2: snapshot 2026-01-20 ===
  (1, '[email protected]', 'Jakarta', '2026-01-10', '2026-01-20')
  (1, '[email protected]', 'Depok', '2026-01-20', 'current')
  (2, '[email protected]', 'Bandung', '2026-01-10', 'current')
  (3, '[email protected]', 'Surabaya', '2026-01-20', 'current')

Mari kita telaah bagian-bagian penting skrip di atas:

  1. Langkah 1 di jalankan_snapshot (UPDATE): menutup setiap versi current yang hash-nya tidak lagi cocok dengan sumber — baris Jakarta milik Budi mendapat dbt_valid_to = 2026-01-20. Inilah deteksi perubahan strategi check: perbandingan hash, bukan perbandingan isi kolom satu per satu.
  2. Langkah 2 (INSERT): menyisipkan versi baru untuk sumber yang hash-nya belum terwakili sebagai current — Budi versi Depok dan Rina yang baru pertama kali direkam.
  3. Fungsi tampil: COALESCE(strftime(dbt_valid_to, ...), 'current') menampilkan status versi; dua baris milik id = 1 kini membentuk riwayat: Jakarta sampai 20 Januari, Depok sejak 20 Januari.

Membaca Keadaan pada Titik Waktu #

Nilai snapshot baru terasa saat di-query. Pola point-in-time: pilih versi yang dbt_valid_from <= tanggal dan dbt_valid_to > tanggal (atau NULL):

python
print("=== keadaan pada 2026-01-15 (sebelum perpindahan) ===")
for r in con.execute("""
    SELECT email, kota FROM snapshot_pelanggan
    WHERE dbt_valid_from <= DATE '2026-01-15'
      AND (dbt_valid_to > DATE '2026-01-15' OR dbt_valid_to IS NULL)
    ORDER BY id
""").fetchall():
    print(" ", r)

print()
print("=== keadaan saat ini (dbt_valid_to IS NULL) ===")
for r in con.execute("""
    SELECT email, kota FROM snapshot_pelanggan
    WHERE dbt_valid_to IS NULL ORDER BY id
""").fetchall():
    print(" ", r)

Output yang Dihasilkan:

text
=== keadaan pada 2026-01-15 (sebelum perpindahan) ===
  ('[email protected]', 'Jakarta')
  ('[email protected]', 'Bandung')

=== keadaan saat ini (dbt_valid_to IS NULL) ===
  ('[email protected]', 'Depok')
  ('[email protected]', 'Bandung')
  ('[email protected]', 'Surabaya')

Query pertama menjawab pertanyaan pembuka chapter: pada 15 Januari, Budi masih tercatat di Jakarta — fact table pesanan Januari dapat di-join ke versi dimensi yang benar lewat pola yang sama dengan Chapter Slowly Changing Dimensions. Query kedua adalah pintu menuju dimensi current: cukup menyaring dbt_valid_to IS NULL. Model lain membaca snapshot ini lewat ref('pelanggan_snapshot') seperti model biasa.

Strategi check vs timestamp, dan hard_deletes #

Dua strategi deteksi tersedia. Strategi check membandingkan hash unique_key dan check_cols — tidak butuh kolom waktu apa pun, jadi cocok untuk sumber aplikasi yang tidak memelihara updated_at dengan disiplin. Strategi timestamp memakai kolom updated_at milik sumber dan hanya menumbuhkan versi bila kolom tersebut berubah — lebih murah, tetapi percaya sepenuhnya pada disiplin sumber. Untuk baris yang terhapus dari sumber, hard_deletes menentukan perilaku: ignore membiarkan versi tetap current, invalidate menutupnya dengan dbt_valid_to, dan new_record menambah baris penanda lewat kolom meta dbt_is_deleted. Rincian strategi dan konfigurasi ini ada di dokumentasi Snapshots dbt .

Rangkaian model dbt TokoKita kini lengkap: staging, intermediate, marts, incremental, hingga snapshot. Tabel-tabel tersebut terbentuk rapi — tetapi apakah isinya benar? Satu double-load bisa menggandakan GMV, satu status berkapital bisa lolos dari filter. Chapter berikutnya memasang lapisan pertahanan untuk kejadian seperti itu: Testing Model Data.