REST API tidak menampilkan tabel ke penggunanya — ia menampilkan resource: pengguna yang mendaftar, artikel yang dibaca, tag yang menyarungkan topik. Namun setiap request POST /artikel dan GET /pengguna pada akhirnya jatuh ke baris-baris tabel yang harus menjawab pertanyaan-pertanyaan sulit sebelum baris pertama ditulis: identifier seperti apa yang aman diekspos ke dunia luar, kolom apa yang wajib hadir agar klien tahu usia dan versi data, apa yang terjadi saat data “dihapus”, dan bagaimana kata sandi disimpan tanpa pernah menyimpannya. Pada bagian ini kita menjawab semuanya dalam empat tabel yang dikunci constraint, lalu membuktikan bahwa setiap aturan benar-benar ditegakkan PostgreSQL.

Dari Endpoint ke Tabel #

Daftar endpoint berikut adalah kontrak yang harus dipenuhi database — setiap barisnya nanti punya pasangan query di Bagian 2:

EndpointFungsiMenyentuh tabel
POST /pendaftarandaftar akun barupengguna
POST /loginverifikasi kredensialpengguna
GET /artikeldaftar artikel terbit, halaman per halamanartikel, pengguna
GET /artikel/:iddetail satu artikel beserta tagartikel, tag, artikel_tag
POST /artikeltulis artikel baru, boleh langsung bertagartikel, artikel_tag
PATCH /artikel/:idsunting sebagian kolomartikel
DELETE /artikel/:idhapus artikelartikel
GET /artikel?tag=...&q=...filter tag dan pencarian teksartikel, tag, artikel_tag

Kontrak itu melahirkan empat entitas: pengguna (penulis), artikel (isi blog), tag (label), dan artikel_tag (tabel junction untuk relasi banyak-ke-banyak). Semuanya diikat foreign key dan primary key komposit — pola yang sama dengan proyek-proyek sebelumnya; yang baru di sini adalah bentuk kolomnya, yang dipilih untuk kebutuhan API.

Keputusan Desain untuk API #

Lima keputusan membedakan skema API dari skema aplikasi desktop biasa:

KeputusanPilihan skemaAlasan API
Primary keyuuid + DEFAULT gen_random_uuid()id tidak berurutan, tidak bisa dienumerasi dari URL; aman dibuat tanpa koordinasi
Identitas loginemail unikkredensial yang manusiawi; versi parsialnya datang di Bagian 3
Kata sandipassword_hash text NOT NULLdatabase tidak boleh tahu sandi asli — hanya hash dari aplikasi
Jejak waktudibuat_pada, diubah_pada timestamptzaudit, header respons, dan penanda versi untuk deteksi bentrok
Penghapusandihapus_pada timestamptz NULLsoft delete: data hilang dari endpoint tapi masih teraudit

Kolom status pada artikel (draft/published) dijaga CHECK biasa, bukan enum — menambah status baru (misalnya archived) cukup mengubah constraint, tanpa migrasi tipe yang merepotkan pengembang aplikasi.

UUID: Identifier yang Aman Diekspos #

Integer auto-increment menghasilkan 1, 2, 3, … — dan URL GET /artikel/1 mengundang siapa pun menebak-nebak GET /artikel/2 sampai GET /artikel/999999 sambil menghitung berapa isi blog Anda. Identifier berurutan juga bocor informasi bisnis: jumlah order hari ini bisa dibaca dari selisih id. Tipe uuid memutus keduanya: 128 bit acak yang praktis mustahil ditebak atau ditabrak. Fungsi gen_random_uuid() mengisinya langsung dari database, sehingga aplikasi tidak perlu memilih identifier — cukup INSERT lalu baca hasilnya lewat RETURNING. Detail tipe ini dibahas tuntas di chapter UUID .

Soft Delete dan Debat GDPR #

DELETE /artikel/:id tidak wajib berarti DELETE FROM artikel. Kolom dihapus_pada menyimpan dua kebijakan sekaligus: selama NULL, artikel hidup dan tampil di semua endpoint; begitu terisi waktu, artikel menghilang dari query mana pun yang menyaring dihapus_pada IS NULL. Keuntungannya nyata — pengguna minta “undo” lima menit setelah menghapus, laporan audit ingin tahu apa yang pernah ada — dan foreign key ke baris itu tetap utuh. Kebalikannya juga jujur: data pribadi yang harus terhapus permanen (hak dilupakan ala GDPR) tidak terhapus sama sekali di soft delete, sehingga sebagian domain tetap membutuhkan DELETE sungguhan atau purging terjadwal. Pada blog kita, artikel bisa di-restore dan akun penulis cukup dinonaktifkan — soft delete pas; untuk data yang diatur regulasi, tambahkan pekerjaan pembersihan terpisah.

Membangun Empat Tabel #

Skema lengkapnya berdiri dalam satu berkas — perhatikan bahwa setiap kolom audit dan penanda soft delete hadir sejak hari pertama:

sql buat-tabel.sql
-- buat-tabel.sql — skema database di balik REST API blog: pengguna, artikel,
-- tag, dan tabel junction artikel_tag. Dirancang dari kebutuhan endpoint:
-- UUID sebagai primary key, kolom audit, soft delete, dan kata sandi
-- yang hanya boleh berupa hash.

CREATE TABLE pengguna (
    id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    email         text NOT NULL,
    password_hash text NOT NULL,
    dibuat_pada   timestamptz NOT NULL,
    dihapus_pada  timestamptz,
    CONSTRAINT email_pengguna_unik UNIQUE (email)
);

CREATE TABLE artikel (
    id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    penulis_id  uuid NOT NULL REFERENCES pengguna (id),
    judul       text NOT NULL,
    isi         text NOT NULL,
    status      text NOT NULL DEFAULT 'draft',
    dibuat_pada timestamptz NOT NULL,
    diubah_pada timestamptz NOT NULL,
    dihapus_pada timestamptz,
    CONSTRAINT judul_tidak_kosong CHECK (judul <> ''),
    CONSTRAINT isi_tidak_kosong  CHECK (isi <> ''),
    CONSTRAINT status_sah CHECK (status IN ('draft', 'published'))
);

CREATE TABLE tag (
    id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    nama        text NOT NULL,
    dibuat_pada timestamptz NOT NULL,
    CONSTRAINT nama_tag_unik UNIQUE (nama),
    CONSTRAINT tag_selalu_lowercase CHECK (nama = lower(nama))
);

CREATE TABLE artikel_tag (
    artikel_id uuid NOT NULL REFERENCES artikel (id) ON DELETE CASCADE,
    tag_id     uuid NOT NULL REFERENCES tag (id) ON DELETE CASCADE,
    PRIMARY KEY (artikel_id, tag_id)
);

COMMENT ON TABLE pengguna IS 'Akun penulis; login memakai email, kata sandi hanya tersimpan sebagai hash.';
COMMENT ON COLUMN pengguna.dihapus_pada IS 'NULL berarti akun aktif; terisi berarti akun dihapus lembut (soft delete).';
COMMENT ON TABLE artikel IS 'Satu baris = satu artikel blog; penulis tidak dapat diubah setelah dibuat.';
COMMENT ON COLUMN artikel.diubah_pada IS 'Waktu revisi terakhir; dipakai sebagai penanda versi untuk konkurensi optimis.';
COMMENT ON TABLE tag IS 'Label artikel, selalu lowercase dan unik — bentuk yang aman dipakai di URL.';
COMMENT ON TABLE artikel_tag IS 'Tabel junction relasi banyak-ke-banyak artikel dan tag.';

Output yang Dihasilkan:

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

Bedah keputusan pentingnya:

  1. DEFAULT gen_random_uuid() pada kedua primary key: aplikasi boleh mengirim INSERT tanpa menyebut id sama sekali; database yang memilih dan mengembalikannya.
  2. password_hash text NOT NULL: kolom ini hanya menerima hash. Aplikasi menghitung bcrypt/argon2 di sisi sendiri lalu mengirim hasilnya — database tidak pernah melihat sandi asli, sehingga kebocoran dump database tidak langsung membocorkan kredensial.
  3. status_sah CHECK (status IN ('draft', 'published')): endpoint PATCH tidak bisa menyimpan status karatan; nama constraint eksplisit membuat pesan error langsung menyebut aturannya.
  4. tag_selalu_lowercase CHECK (nama = lower(nama)): normalisasi input di gerbang terakhir — aplikasi boleh lupa men-lowercase, database tidak. Bentuk konsisten ini juga aman dipakai sebagai segmen URL.
  5. ON DELETE CASCADE pada artikel_tag: saat artikel benar-benar dihapus permanen (purging), hubungan tag-nya ikut bersih — tidak menimbulkan hubungan yatim.

Index yang Lahir Otomatis #

PRIMARY KEY dan UNIQUE selalu membuat index — foreign key tidak. Fakta ini cepat atau lambat mengejutkan: kolom penulis_id yang paling sering di-join justru telanjang. Audit berikut membuktikannya:

-- indeks-otomatis.sql — bukti index mana yang lahir otomatis dari skema.
-- PRIMARY KEY dan UNIQUE selalu membuat index; FOREIGN KEY tidak.

SELECT tablename, indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'public'
  AND tablename IN ('pengguna', 'artikel', 'tag', 'artikel_tag')
ORDER BY tablename, indexname;

Output yang Dihasilkan:

text
  tablename  |      indexname      |                                          indexdef                                           
-------------+---------------------+---------------------------------------------------------------------------------------------
 artikel     | artikel_pkey        | CREATE UNIQUE INDEX artikel_pkey ON public.artikel USING btree (id)
 artikel_tag | artikel_tag_pkey    | CREATE UNIQUE INDEX artikel_tag_pkey ON public.artikel_tag USING btree (artikel_id, tag_id)
 pengguna    | email_pengguna_unik | CREATE UNIQUE INDEX email_pengguna_unik ON public.pengguna USING btree (email)
 pengguna    | pengguna_pkey       | CREATE UNIQUE INDEX pengguna_pkey ON public.pengguna USING btree (id)
 tag         | nama_tag_unik       | CREATE UNIQUE INDEX nama_tag_unik ON public.tag USING btree (nama)
 tag         | tag_pkey            | CREATE UNIQUE INDEX tag_pkey ON public.tag USING btree (id)
(6 rows)

Enam index tampil: *_pkey dan *_unik lahir otomatis dari deklarasi skema. Yang tidak tampil adalah index untuk artikel.penulis_id — dan juga untuk pola query endpoint seperti feed terurut — karena PostgreSQL tidak membuatnya sendiri; keduanya kita pasang manual di Bagian 4 setelah punya bukti EXPLAIN.

Menguji Skema #

Skema belum bisa dipercaya sebelum aturannya terbukti. Berkas berikut menaburkan data contoh, lalu menguji satu jalur positif dan enam jalur pelanggaran dalam satu eksekusi — tiap pelanggaran dibungkus BEGIN ... EXCEPTION di dalam DO block agar psql tidak berhenti di error pertama:

-- uji-constraint.sql — uji positif dan uji negatif seluruh constraint skema.
-- Data contoh memakai UUID dan waktu tetap agar hasil dapat dicocokkan.
-- Pelanggaran dibungkus BEGIN...EXCEPTION di dalam DO block sehingga satu
-- berkas menguji banyak kasus; hasilnya dirangkum LULUS/GAGAL.

INSERT INTO pengguna (id, email, password_hash, dibuat_pada)
VALUES
    ('11111111-1111-4111-8111-111111111111',
     '[email protected]', 'sandi_hash_budi_placeholder',
     TIMESTAMPTZ '2026-04-01 08:00:00+07'),
    ('22222222-2222-4222-8222-222222222222',
     '[email protected]', 'sandi_hash_siti_placeholder',
     TIMESTAMPTZ '2026-04-02 09:30:00+07');

-- Baris ketiga sengaja tidak menyebut kolom id: nilainya dihasilkan
-- gen_random_uuid() dan pasti berbeda di setiap eksekusi — kolom id
-- karena itu tidak ditampilkan pada query di bawah.
INSERT INTO pengguna (email, password_hash, dibuat_pada)
VALUES ('[email protected]', 'sandi_hash_rahmat_placeholder',
        TIMESTAMPTZ '2026-04-03 10:15:00+07');

INSERT INTO tag (id, nama, dibuat_pada)
VALUES
    ('10000000-0000-4000-8000-000000000001', 'postgresql',
     TIMESTAMPTZ '2026-04-01 08:05:00+07'),
    ('10000000-0000-4000-8000-000000000002', 'sql',
     TIMESTAMPTZ '2026-04-01 08:05:00+07');

INSERT INTO artikel (id, penulis_id, judul, isi, status,
                     dibuat_pada, diubah_pada)
VALUES
    ('20000000-0000-4000-8000-000000000001',
     '11111111-1111-4111-8111-111111111111',
     'Kenali UUID sebagai Primary Key',
     'Primary key UUID membuat identifier sulit ditebak dan aman diekspos di URL.',
     'published',
     TIMESTAMPTZ '2026-05-01 09:00:00+07',
     TIMESTAMPTZ '2026-05-01 09:00:00+07');

INSERT INTO artikel_tag (artikel_id, tag_id)
VALUES ('20000000-0000-4000-8000-000000000001',
        '10000000-0000-4000-8000-000000000001');

-- id tidak ditampilkan: nilai gen_random_uuid() berbeda tiap eksekusi.
SELECT email, dibuat_pada, dihapus_pada
FROM pengguna
ORDER BY email;

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

DO $$
BEGIN
    -- Uji positif: artikel sah kedua diterima.
    INSERT INTO artikel (id, penulis_id, judul, isi, status,
                         dibuat_pada, diubah_pada)
    VALUES
        ('20000000-0000-4000-8000-000000000002',
         '22222222-2222-4222-8222-222222222222',
         'Rancangan Skema untuk REST API',
         'Skema yang baik membuat validasi endpoint menjadi murah.',
         'draft',
         TIMESTAMPTZ '2026-05-03 10:00:00+07',
         TIMESTAMPTZ '2026-05-03 10:00:00+07');
    INSERT INTO hasil_uji (kasus, hasil)
    VALUES ('artikel sah kedua diterima', 'LULUS: baris tersimpan');

    -- Uji negatif: tiap pelanggaran harus ditolak database.
    BEGIN
        INSERT INTO artikel (id, penulis_id, judul, isi, status,
                             dibuat_pada, diubah_pada)
        VALUES
            ('20000000-0000-4000-8000-000000000003',
             '11111111-1111-4111-8111-111111111111',
             'Status tidak sah', 'Uji status di luar daftar.',
             'archived',
             TIMESTAMPTZ '2026-05-04 07:00:00+07',
             TIMESTAMPTZ '2026-05-04 07:00:00+07');
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('status di luar draft/published', 'GAGAL: baris justru diterima');
    EXCEPTION WHEN check_violation THEN
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('status di luar draft/published',
                'LULUS: ditolak CHECK status_sah');
    END;

    BEGIN
        INSERT INTO artikel (id, penulis_id, judul, isi, status,
                             dibuat_pada, diubah_pada)
        VALUES
            ('20000000-0000-4000-8000-000000000003',
             '11111111-1111-4111-8111-111111111111',
             '', 'Uji judul kosong.', 'draft',
             TIMESTAMPTZ '2026-05-04 07:10:00+07',
             TIMESTAMPTZ '2026-05-04 07:10:00+07');
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('judul string kosong', 'GAGAL: baris justru diterima');
    EXCEPTION WHEN check_violation THEN
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('judul string kosong',
                'LULUS: ditolak CHECK judul_tidak_kosong');
    END;

    BEGIN
        INSERT INTO pengguna (id, email, password_hash, dibuat_pada)
        VALUES ('99999999-9999-4999-8999-999999999999',
                '[email protected]', 'sandi_hash_palsu_placeholder',
                TIMESTAMPTZ '2026-05-04 07:20:00+07');
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('email yang sudah terdaftar', 'GAGAL: baris justru diterima');
    EXCEPTION WHEN unique_violation THEN
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('email yang sudah terdaftar',
                'LULUS: ditolak UNIQUE email_pengguna_unik');
    END;

    BEGIN
        INSERT INTO artikel (id, penulis_id, judul, isi, status,
                             dibuat_pada, diubah_pada)
        VALUES
            ('20000000-0000-4000-8000-000000000004',
             '99999999-9999-4999-8999-999999999999',
             'Penulis hantu', 'Penulis tidak ada di tabel pengguna.', 'draft',
             TIMESTAMPTZ '2026-05-04 07:30:00+07',
             TIMESTAMPTZ '2026-05-04 07:30:00+07');
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('penulis tidak dikenal', 'GAGAL: baris justru diterima');
    EXCEPTION WHEN foreign_key_violation THEN
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('penulis tidak dikenal',
                'LULUS: ditolak foreign key penulis');
    END;

    BEGIN
        INSERT INTO artikel_tag (artikel_id, tag_id)
        VALUES ('20000000-0000-4000-8000-000000000001',
                '10000000-0000-4000-8000-000000000001');
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('tag yang sama dua kali pada satu artikel',
                'GAGAL: baris justru diterima');
    EXCEPTION WHEN unique_violation THEN
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('tag yang sama dua kali pada satu artikel',
                'LULUS: ditolak primary key artikel_tag');
    END;

    BEGIN
        INSERT INTO tag (id, nama, dibuat_pada)
        VALUES ('99999999-9999-4999-8999-999999999998', 'PostgreSQL',
                TIMESTAMPTZ '2026-05-04 07:40:00+07');
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('tag dengan huruf besar', 'GAGAL: baris justru diterima');
    EXCEPTION WHEN check_violation THEN
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('tag dengan huruf besar',
                'LULUS: ditolak CHECK tag_selalu_lowercase');
    END;
END $$;

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

Output yang Dihasilkan:

text
INSERT 0 2
INSERT 0 1
INSERT 0 2
INSERT 0 1
INSERT 0 1
       email        |      dibuat_pada       | dihapus_pada 
--------------------+------------------------+--------------
 [email protected]   | 2026-04-01 08:00:00+07 | 
 [email protected] | 2026-04-03 10:15:00+07 | 
 [email protected]   | 2026-04-02 09:30:00+07 | 
(3 rows)

CREATE TABLE
DO
 urutan |                  kasus                   |                   hasil                   
--------+------------------------------------------+-------------------------------------------
      1 | artikel sah kedua diterima               | LULUS: baris tersimpan
      2 | status di luar draft/published           | LULUS: ditolak CHECK status_sah
      3 | judul string kosong                      | LULUS: ditolak CHECK judul_tidak_kosong
      4 | email yang sudah terdaftar               | LULUS: ditolak UNIQUE email_pengguna_unik
      5 | penulis tidak dikenal                    | LULUS: ditolak foreign key penulis
      6 | tag yang sama dua kali pada satu artikel | LULUS: ditolak primary key artikel_tag
      7 | tag dengan huruf besar                   | LULUS: ditolak CHECK tag_selalu_lowercase
(7 rows)

Tujuh baris LULUS: artikel sah tetap diterima, status di luar daftar ditolak, judul kosong ditolak, email terdaftar ditolak, penulis hantu ditolak foreign key, tag ganda ditolak primary key komposit, dan tag huruf besar ditolak aturan lowercase. Perhatikan pula tiga baris pertama pada query pengguna: baris ketiga ([email protected]) dimasukkan tanpa menyebut id — nilainya dihasilkan gen_random_uuid().

Email Duplikat Ditolak mentah-mentah #

Sebagai penutup, berkas berikut sengaja gagal supaya pesan error aslinya terlihat — inilah yang diterima aplikasi lalu dipetakan menjadi respons 409 Conflict:

-- email-duplikat.sql — email yang sudah terdaftar harus ditolak.
-- Berkas ini sengaja berakhir dengan error; psql keluar dengan kode 3.
-- Inilah error yang oleh aplikasi dipetakan menjadi respons 409 Conflict.
INSERT INTO pengguna (id, email, password_hash, dibuat_pada)
VALUES ('99999999-9999-4999-8999-999999999999',
        '[email protected]', 'sandi_hash_palsu_placeholder',
        TIMESTAMPTZ '2026-05-04 08:00:00+07');

Output yang Dihasilkan:

text
psql:email-duplikat.sql:7: ERROR:  duplicate key value violates unique constraint "email_pengguna_unik"
DETAIL:  Key (email)=([email protected]) already exists.

Dengan ON_ERROR_STOP=1, pelanggaran UNIQUE menghentikan berkas dan psql keluar kode 3. Constraint bernama email_pengguna_unik tampil di pesan error — keuntungan memberi nama eksplisit sejak awal. Pola pemetaan error seperti ini menjadi pokok bahasan Bagian 3.

Skema berdiri dan teruji, tapi masih sepi pengunjung: belum ada endpoint yang benar-benar dipanggil. Bagian 2 menghidupkannya — query untuk GET yang datang per halaman, POST yang mengembalikan id, dan PATCH yang menyunting seperlunya.

Referensi #

Berkas Sumber

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

buat-tabel.sql

Unduh berkas
sql
-- buat-tabel.sql — skema database di balik REST API blog: pengguna, artikel,
-- tag, dan tabel junction artikel_tag. Dirancang dari kebutuhan endpoint:
-- UUID sebagai primary key, kolom audit, soft delete, dan kata sandi
-- yang hanya boleh berupa hash.

CREATE TABLE pengguna (
    id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    email         text NOT NULL,
    password_hash text NOT NULL,
    dibuat_pada   timestamptz NOT NULL,
    dihapus_pada  timestamptz,
    CONSTRAINT email_pengguna_unik UNIQUE (email)
);

CREATE TABLE artikel (
    id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    penulis_id  uuid NOT NULL REFERENCES pengguna (id),
    judul       text NOT NULL,
    isi         text NOT NULL,
    status      text NOT NULL DEFAULT 'draft',
    dibuat_pada timestamptz NOT NULL,
    diubah_pada timestamptz NOT NULL,
    dihapus_pada timestamptz,
    CONSTRAINT judul_tidak_kosong CHECK (judul <> ''),
    CONSTRAINT isi_tidak_kosong  CHECK (isi <> ''),
    CONSTRAINT status_sah CHECK (status IN ('draft', 'published'))
);

CREATE TABLE tag (
    id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    nama        text NOT NULL,
    dibuat_pada timestamptz NOT NULL,
    CONSTRAINT nama_tag_unik UNIQUE (nama),
    CONSTRAINT tag_selalu_lowercase CHECK (nama = lower(nama))
);

CREATE TABLE artikel_tag (
    artikel_id uuid NOT NULL REFERENCES artikel (id) ON DELETE CASCADE,
    tag_id     uuid NOT NULL REFERENCES tag (id) ON DELETE CASCADE,
    PRIMARY KEY (artikel_id, tag_id)
);

COMMENT ON TABLE pengguna IS 'Akun penulis; login memakai email, kata sandi hanya tersimpan sebagai hash.';
COMMENT ON COLUMN pengguna.dihapus_pada IS 'NULL berarti akun aktif; terisi berarti akun dihapus lembut (soft delete).';
COMMENT ON TABLE artikel IS 'Satu baris = satu artikel blog; penulis tidak dapat diubah setelah dibuat.';
COMMENT ON COLUMN artikel.diubah_pada IS 'Waktu revisi terakhir; dipakai sebagai penanda versi untuk konkurensi optimis.';
COMMENT ON TABLE tag IS 'Label artikel, selalu lowercase dan unik — bentuk yang aman dipakai di URL.';
COMMENT ON TABLE artikel_tag IS 'Tabel junction relasi banyak-ke-banyak artikel dan tag.';

email-duplikat.sql

Unduh berkas
sql
-- email-duplikat.sql — email yang sudah terdaftar harus ditolak.
-- Berkas ini sengaja berakhir dengan error; psql keluar dengan kode 3.
-- Inilah error yang oleh aplikasi dipetakan menjadi respons 409 Conflict.
INSERT INTO pengguna (id, email, password_hash, dibuat_pada)
VALUES ('99999999-9999-4999-8999-999999999999',
        '[email protected]', 'sandi_hash_palsu_placeholder',
        TIMESTAMPTZ '2026-05-04 08:00:00+07');

indeks-otomatis.sql

Unduh berkas
sql
-- indeks-otomatis.sql — bukti index mana yang lahir otomatis dari skema.
-- PRIMARY KEY dan UNIQUE selalu membuat index; FOREIGN KEY tidak.

SELECT tablename, indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'public'
  AND tablename IN ('pengguna', 'artikel', 'tag', 'artikel_tag')
ORDER BY tablename, indexname;

uji-constraint.sql

Unduh berkas
sql
-- uji-constraint.sql — uji positif dan uji negatif seluruh constraint skema.
-- Data contoh memakai UUID dan waktu tetap agar hasil dapat dicocokkan.
-- Pelanggaran dibungkus BEGIN...EXCEPTION di dalam DO block sehingga satu
-- berkas menguji banyak kasus; hasilnya dirangkum LULUS/GAGAL.

INSERT INTO pengguna (id, email, password_hash, dibuat_pada)
VALUES
    ('11111111-1111-4111-8111-111111111111',
     '[email protected]', 'sandi_hash_budi_placeholder',
     TIMESTAMPTZ '2026-04-01 08:00:00+07'),
    ('22222222-2222-4222-8222-222222222222',
     '[email protected]', 'sandi_hash_siti_placeholder',
     TIMESTAMPTZ '2026-04-02 09:30:00+07');

-- Baris ketiga sengaja tidak menyebut kolom id: nilainya dihasilkan
-- gen_random_uuid() dan pasti berbeda di setiap eksekusi — kolom id
-- karena itu tidak ditampilkan pada query di bawah.
INSERT INTO pengguna (email, password_hash, dibuat_pada)
VALUES ('[email protected]', 'sandi_hash_rahmat_placeholder',
        TIMESTAMPTZ '2026-04-03 10:15:00+07');

INSERT INTO tag (id, nama, dibuat_pada)
VALUES
    ('10000000-0000-4000-8000-000000000001', 'postgresql',
     TIMESTAMPTZ '2026-04-01 08:05:00+07'),
    ('10000000-0000-4000-8000-000000000002', 'sql',
     TIMESTAMPTZ '2026-04-01 08:05:00+07');

INSERT INTO artikel (id, penulis_id, judul, isi, status,
                     dibuat_pada, diubah_pada)
VALUES
    ('20000000-0000-4000-8000-000000000001',
     '11111111-1111-4111-8111-111111111111',
     'Kenali UUID sebagai Primary Key',
     'Primary key UUID membuat identifier sulit ditebak dan aman diekspos di URL.',
     'published',
     TIMESTAMPTZ '2026-05-01 09:00:00+07',
     TIMESTAMPTZ '2026-05-01 09:00:00+07');

INSERT INTO artikel_tag (artikel_id, tag_id)
VALUES ('20000000-0000-4000-8000-000000000001',
        '10000000-0000-4000-8000-000000000001');

-- id tidak ditampilkan: nilai gen_random_uuid() berbeda tiap eksekusi.
SELECT email, dibuat_pada, dihapus_pada
FROM pengguna
ORDER BY email;

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

DO $$
BEGIN
    -- Uji positif: artikel sah kedua diterima.
    INSERT INTO artikel (id, penulis_id, judul, isi, status,
                         dibuat_pada, diubah_pada)
    VALUES
        ('20000000-0000-4000-8000-000000000002',
         '22222222-2222-4222-8222-222222222222',
         'Rancangan Skema untuk REST API',
         'Skema yang baik membuat validasi endpoint menjadi murah.',
         'draft',
         TIMESTAMPTZ '2026-05-03 10:00:00+07',
         TIMESTAMPTZ '2026-05-03 10:00:00+07');
    INSERT INTO hasil_uji (kasus, hasil)
    VALUES ('artikel sah kedua diterima', 'LULUS: baris tersimpan');

    -- Uji negatif: tiap pelanggaran harus ditolak database.
    BEGIN
        INSERT INTO artikel (id, penulis_id, judul, isi, status,
                             dibuat_pada, diubah_pada)
        VALUES
            ('20000000-0000-4000-8000-000000000003',
             '11111111-1111-4111-8111-111111111111',
             'Status tidak sah', 'Uji status di luar daftar.',
             'archived',
             TIMESTAMPTZ '2026-05-04 07:00:00+07',
             TIMESTAMPTZ '2026-05-04 07:00:00+07');
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('status di luar draft/published', 'GAGAL: baris justru diterima');
    EXCEPTION WHEN check_violation THEN
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('status di luar draft/published',
                'LULUS: ditolak CHECK status_sah');
    END;

    BEGIN
        INSERT INTO artikel (id, penulis_id, judul, isi, status,
                             dibuat_pada, diubah_pada)
        VALUES
            ('20000000-0000-4000-8000-000000000003',
             '11111111-1111-4111-8111-111111111111',
             '', 'Uji judul kosong.', 'draft',
             TIMESTAMPTZ '2026-05-04 07:10:00+07',
             TIMESTAMPTZ '2026-05-04 07:10:00+07');
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('judul string kosong', 'GAGAL: baris justru diterima');
    EXCEPTION WHEN check_violation THEN
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('judul string kosong',
                'LULUS: ditolak CHECK judul_tidak_kosong');
    END;

    BEGIN
        INSERT INTO pengguna (id, email, password_hash, dibuat_pada)
        VALUES ('99999999-9999-4999-8999-999999999999',
                '[email protected]', 'sandi_hash_palsu_placeholder',
                TIMESTAMPTZ '2026-05-04 07:20:00+07');
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('email yang sudah terdaftar', 'GAGAL: baris justru diterima');
    EXCEPTION WHEN unique_violation THEN
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('email yang sudah terdaftar',
                'LULUS: ditolak UNIQUE email_pengguna_unik');
    END;

    BEGIN
        INSERT INTO artikel (id, penulis_id, judul, isi, status,
                             dibuat_pada, diubah_pada)
        VALUES
            ('20000000-0000-4000-8000-000000000004',
             '99999999-9999-4999-8999-999999999999',
             'Penulis hantu', 'Penulis tidak ada di tabel pengguna.', 'draft',
             TIMESTAMPTZ '2026-05-04 07:30:00+07',
             TIMESTAMPTZ '2026-05-04 07:30:00+07');
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('penulis tidak dikenal', 'GAGAL: baris justru diterima');
    EXCEPTION WHEN foreign_key_violation THEN
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('penulis tidak dikenal',
                'LULUS: ditolak foreign key penulis');
    END;

    BEGIN
        INSERT INTO artikel_tag (artikel_id, tag_id)
        VALUES ('20000000-0000-4000-8000-000000000001',
                '10000000-0000-4000-8000-000000000001');
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('tag yang sama dua kali pada satu artikel',
                'GAGAL: baris justru diterima');
    EXCEPTION WHEN unique_violation THEN
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('tag yang sama dua kali pada satu artikel',
                'LULUS: ditolak primary key artikel_tag');
    END;

    BEGIN
        INSERT INTO tag (id, nama, dibuat_pada)
        VALUES ('99999999-9999-4999-8999-999999999998', 'PostgreSQL',
                TIMESTAMPTZ '2026-05-04 07:40:00+07');
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('tag dengan huruf besar', 'GAGAL: baris justru diterima');
    EXCEPTION WHEN check_violation THEN
        INSERT INTO hasil_uji (kasus, hasil)
        VALUES ('tag dengan huruf besar',
                'LULUS: ditolak CHECK tag_selalu_lowercase');
    END;
END $$;

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