Aplikasi yang menyimpan data permanen sebaiknya tidak dimulai dari tampilan, melainkan dari struktur datanya. Aturan seperti “dua judul tidak boleh berbagi ISBN” atau “jatuh tempo harus setelah tanggal pinjam” paling murah ditegakkan di database: sekali dipasang sebagai constraint, aturan itu berlaku untuk semua aplikasi yang menyentuh data, bukan hanya untuk satu layar yang kebetulan memvalidasi. Pada bagian ini kita menerjemahkan cerita kebutuhan pengelola perpustakaan menjadi tujuh tabel, memasang seluruh constraint dan index, lalu membuktikan lewat uji bahwa setiap aturan benar-benar ditegakkan oleh PostgreSQL.

Dari Cerita Kebutuhan ke Entitas #

Mulai dari kebutuhan pengelola, bukan dari bentuk akhir tabel. Pengelola perpustakaan bercerita begini: kami punya koleksi buku; tiap buku punya ISBN, judul, penerbit, tahun terbit, dan jumlah copy. Buku dikelompokkan per kategori. Satu buku bisa ditulis beberapa penulis, dan penulis yang sama bisa menulis beberapa buku. Anggota mendaftar dengan nama, email, dan telepon. Saat meminjam, anggota mengambil beberapa buku sekaligus dengan jatuh tempo dua minggu; saat mengembalikan, tiap buku dicatat tanggalnya dan keterlambatan dikenakan denda Rp1000 per hari.

Dari cerita itu, entitas dan relasinya menyingkir sendiri:

  • kategori, penulis, buku, dan anggota adalah entitas inti yang berdiri sendiri.
  • Relasi buku–penulis adalah banyak-ke-banyak, sehingga butuh tabel junction buku_penulis — kolom array penulis pada tabel buku akan melanggar bentuk normal dan tak bisa diikat foreign key.
  • Peminjaman berbentuk header-detail: satu peminjaman (siapa, kapan, jatuh tempo kapan) memuat banyak peminjaman_item (buku apa saja, sudah kembali atau belum).

Pola header-detail ini yang membuat tanggal_kembali ditaruh di peminjaman_item, bukan di peminjaman: anggota boleh mengembalikan sebagian buku lebih dulu, jadi tanggal kembali harus dicatat per buku.

Tabel Master Katalog #

Empat tabel pertama meng-cover seluruh kebutuhan katalog. Berikut berkas tabel-master.sql:

-- tabel-master.sql — tabel inti katalog perpustakaan.
-- kategori, penulis, buku, dan tabel junction buku_penulis.

CREATE TABLE kategori (
    id   integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    nama text NOT NULL UNIQUE
);

CREATE TABLE penulis (
    id     integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    nama   text NOT NULL,
    negara text NOT NULL DEFAULT 'Indonesia'
);

CREATE TABLE buku (
    id           integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    isbn         text NOT NULL UNIQUE CHECK (isbn ~ '^[0-9]{13}$'),
    judul        text NOT NULL,
    kategori_id  integer NOT NULL REFERENCES kategori (id),
    penerbit     text NOT NULL,
    tahun_terbit smallint NOT NULL CHECK (tahun_terbit BETWEEN 1900 AND 2100),
    stok         integer NOT NULL CHECK (stok >= 0)
);

CREATE TABLE buku_penulis (
    buku_id    integer NOT NULL REFERENCES buku (id) ON DELETE CASCADE,
    penulis_id integer NOT NULL REFERENCES penulis (id) ON DELETE CASCADE,
    PRIMARY KEY (buku_id, penulis_id)
);

COMMENT ON TABLE kategori IS 'Kelompok subjek buku, mis. Fiksi atau Teknologi.';
COMMENT ON TABLE penulis IS 'Data penulis; satu buku boleh ditulis beberapa penulis.';
COMMENT ON TABLE buku IS 'Katalog judul buku beserta jumlah copy yang dimiliki.';
COMMENT ON COLUMN buku.isbn IS 'ISBN-13 tanpa tanda hubung, tepat 13 digit angka.';
COMMENT ON COLUMN buku.stok IS 'Jumlah copy fisik milik perpustakaan, bukan yang tersedia.';
COMMENT ON TABLE buku_penulis IS 'Tabel junction relasi banyak-ke-banyak buku dan penulis.';

Output yang Dihasilkan:

text
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
COMMENT
COMMENT
COMMENT
COMMENT
COMMENT
COMMENT

Bedah keputusan desainnya satu per satu:

  1. GENERATED ALWAYS AS IDENTITY PRIMARY KEY: setiap tabel memakai surrogate key integer yang diisi otomatis. Kita sengaja tidak menjadikan isbn primary key buku: ISBN adalah data bisnis yang bisa salah ketik dan perlu dikoreksi, sedangkan primary key idealnya tidak pernah berubah setelah dipakai sebagai rujukan baris lain.
  2. isbn text UNIQUE CHECK (isbn ~ '^[0-9]{13}$'): dua lapis aturan sekaligus. UNIQUE menolak dua judul berbagi ISBN, sementara CHECK dengan operator regex ~ memastikan formatnya tepat 13 digit angka — pola ISBN-13 tanpa tanda hubung.
  3. stok >= 0 dan rentang tahun_terbit: stok negatif dan tahun 1800 adalah data yang mustahil secara bisnis, jadi ditolak di tingkat database, bukan ditanggung aplikasi.
  4. PRIMARY KEY (buku_id, penulis_id) pada buku_penulis: primary key komposit sekaligus menjadi jaminan bahwa pasangan buku–penulis hanya tercatat sekali; duplikasi relasi yang sama ditolak otomatis. Opsi ON DELETE CASCADE membuat menghapus buku ikut membersihkan relasi penulisnya.

Tabel Sirkulasi #

Tiga tabel berikutnya menampung keanggotaan dan transaksi. Berikut tabel-sirkulasi.sql:

-- tabel-sirkulasi.sql — tabel keanggotaan dan transaksi pinjam-kembali.
-- Satu peminjaman boleh memuat beberapa buku lewat peminjaman_item.

CREATE TABLE anggota (
    id             integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    nama           text NOT NULL,
    email          text NOT NULL UNIQUE,
    telepon        text NOT NULL,
    tanggal_daftar date NOT NULL
);

CREATE TABLE peminjaman (
    id             integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    anggota_id     integer NOT NULL REFERENCES anggota (id),
    tanggal_pinjam date NOT NULL,
    jatuh_tempo    date NOT NULL,
    CONSTRAINT jatuh_tempo_setelah_pinjam CHECK (jatuh_tempo > tanggal_pinjam)
);

CREATE TABLE peminjaman_item (
    peminjaman_id   integer NOT NULL REFERENCES peminjaman (id) ON DELETE CASCADE,
    buku_id         integer NOT NULL REFERENCES buku (id),
    tanggal_kembali date,
    PRIMARY KEY (peminjaman_id, buku_id)
);

COMMENT ON TABLE anggota IS 'Peminjam terdaftar perpustakaan.';
COMMENT ON TABLE peminjaman IS 'Header transaksi: siapa meminjam dan kapan jatuh tempo.';
COMMENT ON TABLE peminjaman_item IS 'Buku yang diambil dalam satu transaksi peminjaman.';
COMMENT ON COLUMN peminjaman_item.tanggal_kembali IS 'NULL berarti copy masih di tangan anggota.';

Output yang Dihasilkan:

text
CREATE TABLE
CREATE TABLE
CREATE TABLE
COMMENT
COMMENT
COMMENT
COMMENT

Tiga keputusan penting pada tabel ini:

  1. email UNIQUE pada anggota: satu orang cukup punya satu akun pinjam; email dipilih penanda duplikat karena nama orang bisa sama persis.
  2. CONSTRAINT jatuh_tempo_setelah_pinjam CHECK (jatuh_tempo > tanggal_pinjam): constraint diberi nama eksplisit agar pesan error yang muncul nanti langsung menyebut aturan yang dilanggar, bukan nama default seperti peminjaman_jatuh_tempo_check. Perhatikan pembandingnya > — jatuh tempo boleh besok, tapi tidak boleh hari yang sama.
  3. PRIMARY KEY (peminjaman_id, buku_id) pada peminjaman_item: satu judul hanya boleh diambil sekali dalam satu transaksi — aturan bisnis yang masuk akal karena meminjam dua copy buku yang sama dalam satu kunjungan tidak lazim. Konsekuensinya, keinginan meminjam copy kedua dari judul yang sama harus lewat transaksi peminjaman baru.

Sementara itu tanggal_kembali sengaja dibiarkan tanpa NOT NULL: nilai NULL justru bermakna — buku masih beredar. Seluruh laporan ketersediaan dan keterlambatan di bagian berikutnya bergantung pada konvensi sederhana ini.

Index untuk Kolom Foreign Key #

PRIMARY KEY dan UNIQUE otomatis membuat index, tapi kolom foreign key tidak — PostgreSQL membiarkannya sebagai tanggung jawab perancang. Padahal kolom seperti peminjaman_item.buku_id adalah kolom yang paling sering di-join dan difilter. Kita pasang index untuk empat kolom foreign key tersibuk:

-- indeks-foreign-key.sql — index untuk kolom foreign key yang sering di-join.
-- PRIMARY KEY dan UNIQUE otomatis membuat index; kolom foreign key TIDAK,
-- sehingga index berikut dipasang manual.

CREATE INDEX idx_buku_kategori        ON buku (kategori_id);
CREATE INDEX idx_buku_penulis_penulis ON buku_penulis (penulis_id);
CREATE INDEX idx_peminjaman_anggota   ON peminjaman (anggota_id);
CREATE INDEX idx_peminjaman_item_buku ON peminjaman_item (buku_id);

-- Bukti: daftar index pada empat tabel yang paling sering di-join.
SELECT indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'public'
  AND tablename IN ('buku', 'buku_penulis', 'peminjaman', 'peminjaman_item')
ORDER BY tablename, indexname;

Output yang Dihasilkan:

text
CREATE INDEX
CREATE INDEX
CREATE INDEX
CREATE INDEX
        indexname         |                                                indexdef                                                 
--------------------------+---------------------------------------------------------------------------------------------------------
 buku_isbn_key            | CREATE UNIQUE INDEX buku_isbn_key ON public.buku USING btree (isbn)
 buku_pkey                | CREATE UNIQUE INDEX buku_pkey ON public.buku USING btree (id)
 idx_buku_kategori        | CREATE INDEX idx_buku_kategori ON public.buku USING btree (kategori_id)
 buku_penulis_pkey        | CREATE UNIQUE INDEX buku_penulis_pkey ON public.buku_penulis USING btree (buku_id, penulis_id)
 idx_buku_penulis_penulis | CREATE INDEX idx_buku_penulis_penulis ON public.buku_penulis USING btree (penulis_id)
 idx_peminjaman_anggota   | CREATE INDEX idx_peminjaman_anggota ON public.peminjaman USING btree (anggota_id)
 peminjaman_pkey          | CREATE UNIQUE INDEX peminjaman_pkey ON public.peminjaman USING btree (id)
 idx_peminjaman_item_buku | CREATE INDEX idx_peminjaman_item_buku ON public.peminjaman_item USING btree (buku_id)
 peminjaman_item_pkey     | CREATE UNIQUE INDEX peminjaman_item_pkey ON public.peminjaman_item USING btree (peminjaman_id, buku_id)
(9 rows)

Query penutupnya membaca view pg_indexes dari system catalog: sembilan index tampil berurutan per tabel, dan di situ terlihat bahwa index seperti buku_isbn_key dibuat otomatis oleh UNIQUE, sementara index berprefiks idx_ adalah yang kita pasang manual. Index pada buku_penulis.penulis_id penting karena primary key komposit tabel itu hanya mempercepat pencarian dari sisi buku_id; mencari “semua buku karya penulis X” butuh index dari sisi sebaliknya.

Menguji Skema: Positif dan Negatif #

Skema belum bisa dipercaya sebelum aturannya terbukti. Berkas berikut menguji delapan kasus dalam satu eksekusi: satu uji positif (data sah diterima) dan tujuh uji negatif. Karena psql berhenti di error pertama, tiap INSERT yang diharapkan gagal dibungkus BEGIN ... EXCEPTION di dalam DO block, lalu hasilnya dirangkum sebagai LULUS/GAGAL:

-- uji-constraint.sql — uji positif dan uji negatif untuk seluruh constraint skema.
-- Pelanggaran dibungkus BEGIN...EXCEPTION di dalam DO block sehingga satu file
-- bisa menguji banyak kasus; hasilnya dirangkum LULUS/GAGAL.

INSERT INTO kategori (nama) VALUES ('Fiksi');
INSERT INTO penulis (nama, negara) VALUES ('Andini Raharjo', 'Indonesia');
INSERT INTO anggota (nama, email, telepon, tanggal_daftar)
VALUES ('Budi Santoso', '[email protected]', '081210001001', DATE '2025-09-01');
INSERT INTO buku (isbn, judul, kategori_id, penerbit, tahun_terbit, stok)
VALUES ('9786020000001', 'Hujan di Batavia', 1, 'Penerbit Cerah', 2019, 3);
INSERT INTO buku_penulis (buku_id, penulis_id) VALUES (1, 1);

CREATE TABLE hasil_uji (
    urutan integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    kasus  text NOT NULL,
    hasil  text NOT NULL
);

DO $$
BEGIN
    -- Uji positif: data sah harus diterima.
    INSERT INTO buku (isbn, judul, kategori_id, penerbit, tahun_terbit, stok)
    VALUES ('9786020000006', 'Senja di Rantau', 1, 'Penerbit Cerah', 2023, 5);
    INSERT INTO hasil_uji (kasus, hasil)
    VALUES ('buku sah kedua diterima', 'LULUS: baris tersimpan');

    -- Uji negatif: tiap pelanggaran harus ditolak database.
    BEGIN
        INSERT INTO buku (isbn, judul, kategori_id, penerbit, tahun_terbit, stok)
        VALUES ('978602ABC0001', 'ISBN Salah', 1, 'Uji', 2020, 1);
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('ISBN bukan 13 digit angka', 'GAGAL: baris justru diterima');
    EXCEPTION WHEN check_violation THEN
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('ISBN bukan 13 digit angka', 'LULUS: ditolak CHECK isbn');
    END;

    BEGIN
        INSERT INTO buku (isbn, judul, kategori_id, penerbit, tahun_terbit, stok)
        VALUES ('9786020000099', 'Buku Tua', 1, 'Uji', 1899, 1);
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('tahun terbit 1899', 'GAGAL: baris justru diterima');
    EXCEPTION WHEN check_violation THEN
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('tahun terbit 1899', 'LULUS: ditolak CHECK tahun_terbit');
    END;

    BEGIN
        INSERT INTO buku (isbn, judul, kategori_id, penerbit, tahun_terbit, stok)
        VALUES ('9786020000098', 'Stok Aneh', 1, 'Uji', 2020, -1);
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('stok negatif', 'GAGAL: baris justru diterima');
    EXCEPTION WHEN check_violation THEN
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('stok negatif', 'LULUS: ditolak CHECK stok');
    END;

    BEGIN
        INSERT INTO buku (isbn, judul, kategori_id, penerbit, tahun_terbit, stok)
        VALUES ('9786020000097', NULL, 1, 'Uji', 2020, 1);
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('judul kosong', 'GAGAL: baris justru diterima');
    EXCEPTION WHEN not_null_violation THEN
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('judul kosong', 'LULUS: ditolak NOT NULL');
    END;

    BEGIN
        INSERT INTO buku (isbn, judul, kategori_id, penerbit, tahun_terbit, stok)
        VALUES ('9786020000001', 'Judul Berbeda, ISBN Sama', 1, 'Uji', 2020, 1);
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('ISBN duplikat', 'GAGAL: baris justru diterima');
    EXCEPTION WHEN unique_violation THEN
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('ISBN duplikat', 'LULUS: ditolak UNIQUE buku.isbn');
    END;

    BEGIN
        INSERT INTO buku (isbn, judul, kategori_id, penerbit, tahun_terbit, stok)
        VALUES ('9786020000096', 'Kategori Hantu', 999, 'Uji', 2020, 1);
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('kategori tidak ada', 'GAGAL: baris justru diterima');
    EXCEPTION WHEN foreign_key_violation THEN
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('kategori tidak ada', 'LULUS: ditolak FOREIGN KEY');
    END;

    BEGIN
        INSERT INTO buku_penulis (buku_id, penulis_id) VALUES (1, 1);
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('pasangan buku-penulis duplikat', 'GAGAL: baris justru diterima');
    EXCEPTION WHEN unique_violation THEN
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('pasangan buku-penulis duplikat', 'LULUS: ditolak PRIMARY KEY junction');
    END;
END $$;

SELECT urutan, kasus, hasil
FROM hasil_uji
ORDER BY urutan;

Output yang Dihasilkan:

text
INSERT 0 1
INSERT 0 1
INSERT 0 1
INSERT 0 1
INSERT 0 1
CREATE TABLE
DO
 urutan |             kasus              |                hasil                
--------+--------------------------------+-------------------------------------
      1 | buku sah kedua diterima        | LULUS: baris tersimpan
      2 | ISBN bukan 13 digit angka      | LULUS: ditolak CHECK isbn
      3 | tahun terbit 1899              | LULUS: ditolak CHECK tahun_terbit
      4 | stok negatif                   | LULUS: ditolak CHECK stok
      5 | judul kosong                   | LULUS: ditolak NOT NULL
      6 | ISBN duplikat                  | LULUS: ditolak UNIQUE buku.isbn
      7 | kategori tidak ada             | LULUS: ditolak FOREIGN KEY
      8 | pasangan buku-penulis duplikat | LULUS: ditolak PRIMARY KEY junction
(8 rows)

Delapan baris LULUS pada output berarti delapan aturan bekerja: format ISBN, rentang tahun, stok non-negatif, judul wajib isi, ISBN unik, kategori harus ada, pasangan buku–penulis tak boleh dobel, dan data sah tetap diterima. Pola ini akan kita bawa terus: berkas uji menyertai setiap tahap proyek, dan versi lengkapnya tersedia di folder tests/ pada Berkas Sumber halaman overview proyek.

Jatuh Tempo yang Tidak Masuk Akal #

Uji terakhir sengaja dibuat sebagai berkas terpisah yang memang gagal. Peminjaman dengan jatuh tempo sama persis dengan tanggal pinjam harus ditolak CHECK:

-- tanggal-tidak-valid.sql — jatuh tempo yang sama dengan tanggal pinjam
-- harus ditolak CHECK constraint.
-- File ini sengaja berakhir dengan error; psql keluar dengan kode 3.
INSERT INTO peminjaman (anggota_id, tanggal_pinjam, jatuh_tempo)
VALUES (1, DATE '2026-01-10', DATE '2026-01-10');

Output yang Dihasilkan:

text
psql:tanggal-tidak-valid.sql:5: ERROR:  new row for relation "peminjaman" violates check constraint "jatuh_tempo_setelah_pinjam"
DETAIL:  Failing row contains (1, 1, 2026-01-10, 2026-01-10).

Karena psql dijalankan dengan ON_ERROR_STOP=1, pelanggaran CHECK ini menghentikan berkas dan membuat psql keluar dengan kode 3 — kode khusus “error SQL” yang dibedakan dari kegagal koneksi (kode 2). Pada pipeline pengujian, exit 3 pada berkas yang memang menguji penolakan berarti lulus. Perhatikan juga pesan errornya menyebut nama constraint jatuh_tempo_setelah_pinjam — inilah keuntungan memberi nama eksplisit saat mendeklarasikan constraint.

Skema kini berdiri dan teruji. Fondasi ini masih kosong penghuni: belum ada satu judul buku pun terdaftar. Bagian 2 mengisinya dengan alur kerja sehari-hari pengelola — menambah buku beserta penulisnya, mencari judul, mengelola anggota, dan menyesuaikan stok.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

indeks-foreign-key.sql

Unduh berkas
sql
-- indeks-foreign-key.sql — index untuk kolom foreign key yang sering di-join.
-- PRIMARY KEY dan UNIQUE otomatis membuat index; kolom foreign key TIDAK,
-- sehingga index berikut dipasang manual.

CREATE INDEX idx_buku_kategori        ON buku (kategori_id);
CREATE INDEX idx_buku_penulis_penulis ON buku_penulis (penulis_id);
CREATE INDEX idx_peminjaman_anggota   ON peminjaman (anggota_id);
CREATE INDEX idx_peminjaman_item_buku ON peminjaman_item (buku_id);

-- Bukti: daftar index pada empat tabel yang paling sering di-join.
SELECT indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'public'
  AND tablename IN ('buku', 'buku_penulis', 'peminjaman', 'peminjaman_item')
ORDER BY tablename, indexname;

tabel-master.sql

Unduh berkas
sql
-- tabel-master.sql — tabel inti katalog perpustakaan.
-- kategori, penulis, buku, dan tabel junction buku_penulis.

CREATE TABLE kategori (
    id   integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    nama text NOT NULL UNIQUE
);

CREATE TABLE penulis (
    id     integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    nama   text NOT NULL,
    negara text NOT NULL DEFAULT 'Indonesia'
);

CREATE TABLE buku (
    id           integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    isbn         text NOT NULL UNIQUE CHECK (isbn ~ '^[0-9]{13}$'),
    judul        text NOT NULL,
    kategori_id  integer NOT NULL REFERENCES kategori (id),
    penerbit     text NOT NULL,
    tahun_terbit smallint NOT NULL CHECK (tahun_terbit BETWEEN 1900 AND 2100),
    stok         integer NOT NULL CHECK (stok >= 0)
);

CREATE TABLE buku_penulis (
    buku_id    integer NOT NULL REFERENCES buku (id) ON DELETE CASCADE,
    penulis_id integer NOT NULL REFERENCES penulis (id) ON DELETE CASCADE,
    PRIMARY KEY (buku_id, penulis_id)
);

COMMENT ON TABLE kategori IS 'Kelompok subjek buku, mis. Fiksi atau Teknologi.';
COMMENT ON TABLE penulis IS 'Data penulis; satu buku boleh ditulis beberapa penulis.';
COMMENT ON TABLE buku IS 'Katalog judul buku beserta jumlah copy yang dimiliki.';
COMMENT ON COLUMN buku.isbn IS 'ISBN-13 tanpa tanda hubung, tepat 13 digit angka.';
COMMENT ON COLUMN buku.stok IS 'Jumlah copy fisik milik perpustakaan, bukan yang tersedia.';
COMMENT ON TABLE buku_penulis IS 'Tabel junction relasi banyak-ke-banyak buku dan penulis.';

tabel-sirkulasi.sql

Unduh berkas
sql
-- tabel-sirkulasi.sql — tabel keanggotaan dan transaksi pinjam-kembali.
-- Satu peminjaman boleh memuat beberapa buku lewat peminjaman_item.

CREATE TABLE anggota (
    id             integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    nama           text NOT NULL,
    email          text NOT NULL UNIQUE,
    telepon        text NOT NULL,
    tanggal_daftar date NOT NULL
);

CREATE TABLE peminjaman (
    id             integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    anggota_id     integer NOT NULL REFERENCES anggota (id),
    tanggal_pinjam date NOT NULL,
    jatuh_tempo    date NOT NULL,
    CONSTRAINT jatuh_tempo_setelah_pinjam CHECK (jatuh_tempo > tanggal_pinjam)
);

CREATE TABLE peminjaman_item (
    peminjaman_id   integer NOT NULL REFERENCES peminjaman (id) ON DELETE CASCADE,
    buku_id         integer NOT NULL REFERENCES buku (id),
    tanggal_kembali date,
    PRIMARY KEY (peminjaman_id, buku_id)
);

COMMENT ON TABLE anggota IS 'Peminjam terdaftar perpustakaan.';
COMMENT ON TABLE peminjaman IS 'Header transaksi: siapa meminjam dan kapan jatuh tempo.';
COMMENT ON TABLE peminjaman_item IS 'Buku yang diambil dalam satu transaksi peminjaman.';
COMMENT ON COLUMN peminjaman_item.tanggal_kembali IS 'NULL berarti copy masih di tangan anggota.';

tanggal-tidak-valid.sql

Unduh berkas
sql
-- tanggal-tidak-valid.sql — jatuh tempo yang sama dengan tanggal pinjam
-- harus ditolak CHECK constraint.
-- File ini sengaja berakhir dengan error; psql keluar dengan kode 3.
INSERT INTO peminjaman (anggota_id, tanggal_pinjam, jatuh_tempo)
VALUES (1, DATE '2026-01-10', DATE '2026-01-10');

uji-constraint.sql

Unduh berkas
sql
-- uji-constraint.sql — uji positif dan uji negatif untuk seluruh constraint skema.
-- Pelanggaran dibungkus BEGIN...EXCEPTION di dalam DO block sehingga satu file
-- bisa menguji banyak kasus; hasilnya dirangkum LULUS/GAGAL.

INSERT INTO kategori (nama) VALUES ('Fiksi');
INSERT INTO penulis (nama, negara) VALUES ('Andini Raharjo', 'Indonesia');
INSERT INTO anggota (nama, email, telepon, tanggal_daftar)
VALUES ('Budi Santoso', '[email protected]', '081210001001', DATE '2025-09-01');
INSERT INTO buku (isbn, judul, kategori_id, penerbit, tahun_terbit, stok)
VALUES ('9786020000001', 'Hujan di Batavia', 1, 'Penerbit Cerah', 2019, 3);
INSERT INTO buku_penulis (buku_id, penulis_id) VALUES (1, 1);

CREATE TABLE hasil_uji (
    urutan integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    kasus  text NOT NULL,
    hasil  text NOT NULL
);

DO $$
BEGIN
    -- Uji positif: data sah harus diterima.
    INSERT INTO buku (isbn, judul, kategori_id, penerbit, tahun_terbit, stok)
    VALUES ('9786020000006', 'Senja di Rantau', 1, 'Penerbit Cerah', 2023, 5);
    INSERT INTO hasil_uji (kasus, hasil)
    VALUES ('buku sah kedua diterima', 'LULUS: baris tersimpan');

    -- Uji negatif: tiap pelanggaran harus ditolak database.
    BEGIN
        INSERT INTO buku (isbn, judul, kategori_id, penerbit, tahun_terbit, stok)
        VALUES ('978602ABC0001', 'ISBN Salah', 1, 'Uji', 2020, 1);
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('ISBN bukan 13 digit angka', 'GAGAL: baris justru diterima');
    EXCEPTION WHEN check_violation THEN
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('ISBN bukan 13 digit angka', 'LULUS: ditolak CHECK isbn');
    END;

    BEGIN
        INSERT INTO buku (isbn, judul, kategori_id, penerbit, tahun_terbit, stok)
        VALUES ('9786020000099', 'Buku Tua', 1, 'Uji', 1899, 1);
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('tahun terbit 1899', 'GAGAL: baris justru diterima');
    EXCEPTION WHEN check_violation THEN
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('tahun terbit 1899', 'LULUS: ditolak CHECK tahun_terbit');
    END;

    BEGIN
        INSERT INTO buku (isbn, judul, kategori_id, penerbit, tahun_terbit, stok)
        VALUES ('9786020000098', 'Stok Aneh', 1, 'Uji', 2020, -1);
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('stok negatif', 'GAGAL: baris justru diterima');
    EXCEPTION WHEN check_violation THEN
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('stok negatif', 'LULUS: ditolak CHECK stok');
    END;

    BEGIN
        INSERT INTO buku (isbn, judul, kategori_id, penerbit, tahun_terbit, stok)
        VALUES ('9786020000097', NULL, 1, 'Uji', 2020, 1);
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('judul kosong', 'GAGAL: baris justru diterima');
    EXCEPTION WHEN not_null_violation THEN
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('judul kosong', 'LULUS: ditolak NOT NULL');
    END;

    BEGIN
        INSERT INTO buku (isbn, judul, kategori_id, penerbit, tahun_terbit, stok)
        VALUES ('9786020000001', 'Judul Berbeda, ISBN Sama', 1, 'Uji', 2020, 1);
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('ISBN duplikat', 'GAGAL: baris justru diterima');
    EXCEPTION WHEN unique_violation THEN
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('ISBN duplikat', 'LULUS: ditolak UNIQUE buku.isbn');
    END;

    BEGIN
        INSERT INTO buku (isbn, judul, kategori_id, penerbit, tahun_terbit, stok)
        VALUES ('9786020000096', 'Kategori Hantu', 999, 'Uji', 2020, 1);
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('kategori tidak ada', 'GAGAL: baris justru diterima');
    EXCEPTION WHEN foreign_key_violation THEN
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('kategori tidak ada', 'LULUS: ditolak FOREIGN KEY');
    END;

    BEGIN
        INSERT INTO buku_penulis (buku_id, penulis_id) VALUES (1, 1);
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('pasangan buku-penulis duplikat', 'GAGAL: baris justru diterima');
    EXCEPTION WHEN unique_violation THEN
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('pasangan buku-penulis duplikat', 'LULUS: ditolak PRIMARY KEY junction');
    END;
END $$;

SELECT urutan, kasus, hasil
FROM hasil_uji
ORDER BY urutan;