Sejauh ini kita memperlakukan view sebagai jendela baca-saja: SELECT ke view, angka laporan keluar, tabel dasar tidak tersentuh. Pertanyaan yang hampir selalu muncul di titik ini — “kalau view terasa seperti tabel, bisakah saya INSERT, UPDATE, atau DELETE lewat view?” — punya jawaban yang mengejutkan: kadang ya, dan tanpa satu baris konfigurasi pun. Untuk kelas view tertentu, PostgreSQL otomatis menerjemahkan perintah tulis pada view menjadi perintah tulis pada tabel dasarnya. Kapabilitas ini membuka pola desain yang kuat — view sebagai gerbang terbatas ke tabel — sekaligus satu jebakan klasik: tanpa pengaman, view bisa kehilangan baris yang barusan Anda insert. Chapter ini membedah syarat otomatisnya, jebakannya, dan CHECK OPTION yang menutupnya.

Domain Latihan yang Sama #

Domain Toko Maju Jaya yang sama dengan chapter-chapter sebelumnya: produk, pelanggan, dan penjualan. File setup berikut membuat ulang ketiga tabel dari nol, sehingga contoh-contoh chapter ini bisa dijalankan sendiri:

sql buat-tabel.sql
-- Domain latihan section View dan Materialized View:
-- katalog produk, pelanggan, dan transaksi Toko Maju Jaya.
CREATE TABLE produk (
    id       integer PRIMARY KEY,
    nama     text NOT NULL,
    kategori text NOT NULL,
    harga    numeric(10,2) NOT NULL,
    stok     integer NOT NULL
);

CREATE TABLE pelanggan (
    id   integer PRIMARY KEY,
    nama text NOT NULL,
    kota text NOT NULL
);

CREATE TABLE penjualan (
    id           integer PRIMARY KEY,
    tanggal      date NOT NULL,
    produk_id    integer NOT NULL REFERENCES produk(id),
    pelanggan_id integer NOT NULL REFERENCES pelanggan(id),
    jumlah       integer NOT NULL
);

INSERT INTO produk (id, nama, kategori, harga, stok) VALUES
    (1, 'Kopi Gayo',    'Kopi', 85000.00,  40),
    (2, 'Kopi Toraja',  'Kopi', 90000.00,  25),
    (3, 'Teh Melati',   'Teh',  35000.00, 100),
    (4, 'Teh Hijau',    'Teh',  40000.00,  60),
    (5, 'Gula Merah',   'Gula', 15000.00, 200),
    (6, 'Gula Putih',   'Gula', 16000.00, 150),
    (7, 'Kopi Lampung', 'Kopi', 78000.00,   0);

INSERT INTO pelanggan (id, nama, kota) VALUES
    (1, 'Budi Santoso', 'Jakarta'),
    (2, 'Siti Aminah',  'Bandung'),
    (3, 'Andi Wijaya',  'Surabaya'),
    (4, 'Dewi Lestari', 'Yogyakarta'),
    (5, 'Rina Marlina', 'Medan');

INSERT INTO penjualan (id, tanggal, produk_id, pelanggan_id, jumlah) VALUES
    ( 1, DATE '2026-01-05', 1, 1, 10),
    ( 2, DATE '2026-01-08', 3, 2, 25),
    ( 3, DATE '2026-01-12', 5, 3, 40),
    ( 4, DATE '2026-01-15', 2, 1,  8),
    ( 5, DATE '2026-01-20', 4, 4, 15),
    ( 6, DATE '2026-01-22', 1, 2, 12),
    ( 7, DATE '2026-02-02', 3, 5, 20),
    ( 8, DATE '2026-02-05', 6, 3, 30),
    ( 9, DATE '2026-02-10', 2, 4,  6),
    (10, DATE '2026-02-14', 4, 1, 18);

SELECT id, nama, kategori, harga, stok FROM produk ORDER BY id;
SELECT id, nama, kota FROM pelanggan ORDER BY id;
SELECT id, tanggal, produk_id, pelanggan_id, jumlah FROM penjualan ORDER BY id;

Output yang Dihasilkan:

text
CREATE TABLE
CREATE TABLE
CREATE TABLE
INSERT 0 7
INSERT 0 5
INSERT 0 10
 id |     nama     | kategori |  harga   | stok 
----+--------------+----------+----------+------
  1 | Kopi Gayo    | Kopi     | 85000.00 |   40
  2 | Kopi Toraja  | Kopi     | 90000.00 |   25
  3 | Teh Melati   | Teh      | 35000.00 |  100
  4 | Teh Hijau    | Teh      | 40000.00 |   60
  5 | Gula Merah   | Gula     | 15000.00 |  200
  6 | Gula Putih   | Gula     | 16000.00 |  150
  7 | Kopi Lampung | Kopi     | 78000.00 |    0
(7 rows)

 id |     nama     |    kota    
----+--------------+------------
  1 | Budi Santoso | Jakarta
  2 | Siti Aminah  | Bandung
  3 | Andi Wijaya  | Surabaya
  4 | Dewi Lestari | Yogyakarta
  5 | Rina Marlina | Medan
(5 rows)

 id |  tanggal   | produk_id | pelanggan_id | jumlah 
----+------------+-----------+--------------+--------
  1 | 2026-01-05 |         1 |            1 |     10
  2 | 2026-01-08 |         3 |            2 |     25
  3 | 2026-01-12 |         5 |            3 |     40
  4 | 2026-01-15 |         2 |            1 |      8
  5 | 2026-01-20 |         4 |            4 |     15
  6 | 2026-01-22 |         1 |            2 |     12
  7 | 2026-02-02 |         3 |            5 |     20
  8 | 2026-02-05 |         6 |            3 |     30
  9 | 2026-02-10 |         2 |            4 |      6
 10 | 2026-02-14 |         4 |            1 |     18
(10 rows)

INSERT, UPDATE, DELETE Lewat View #

Kita mulai dari view yang paling sederhana: produk_teh membaca satu tabel (produk), menyaring satu kategori, tanpa agregat maupun pembatasan hasil. Tiga perintah tulis kemudian diarahkan ke view itu — dan ketiganya berhasil:

-- View sederhana = satu tabel, tanpa agregat/DISTINCT/GROUP BY/LIMIT:
-- PostgreSQL menerjemahkan INSERT/UPDATE/DELETE padanya menjadi
-- operasi pada tabel dasar produk.
CREATE VIEW produk_teh AS
SELECT id, nama, kategori, harga, stok
FROM produk
WHERE kategori = 'Teh';

SELECT id, nama, harga, stok
FROM produk_teh
ORDER BY id;

-- INSERT lewat view: baris mendarat di tabel produk.
INSERT INTO produk_teh (id, nama, kategori, harga, stok)
VALUES (8, 'Teh Oolong', 'Teh', 55000.00, 50);

SELECT id, nama, kategori, harga, stok
FROM produk
WHERE id = 8;

-- UPDATE lewat view: hanya baris yang terlihat view yang tersentuh.
UPDATE produk_teh
SET harga = 52000.00, stok = stok + 10
WHERE nama = 'Teh Oolong';

SELECT id, nama, harga, stok
FROM produk
WHERE id = 8;

-- DELETE lewat view: sama, terjemahan DELETE pada produk
-- dengan predicate view ikut serta.
DELETE FROM produk_teh
WHERE nama = 'Teh Oolong';

SELECT count(*) AS jumlah_produk
FROM produk;

Output yang Dihasilkan:

text
CREATE VIEW
 id |    nama    |  harga   | stok 
----+------------+----------+------
  3 | Teh Melati | 35000.00 |  100
  4 | Teh Hijau  | 40000.00 |   60
(2 rows)

INSERT 0 1
 id |    nama    | kategori |  harga   | stok 
----+------------+----------+----------+------
  8 | Teh Oolong | Teh      | 55000.00 |   50
(1 row)

UPDATE 1
 id |    nama    |  harga   | stok 
----+------------+----------+------
  8 | Teh Oolong | 52000.00 |   60
(1 row)

DELETE 1
 jumlah_produk 
---------------
             7
(1 row)

Mari kita bedah apa yang sebenarnya terjadi:

  1. INSERT INTO produk_teh VALUES (8, 'Teh Oolong', 'Teh', ...) — output INSERT 0 1 muncul, dan query ke produk WHERE id = 8 memperlihatkan baris Teh Oolong di tabel dasar. PostgreSQL menulis ulang statement ini menjadi INSERT INTO produk ... di belakang layar — mekanisme query rewrite yang sama dengan yang menjalankan SELECT pada view.
  2. UPDATE produk_teh SET harga = 52000.00 ... — statement diterjemahkan menjadi UPDATE produk SET harga = 52000.00 ... WHERE kategori = 'Teh' AND nama = 'Teh Oolong': predicate view di-AND-kan dengan predicate statement, sehingga baris di luar jangkauan view mustahil tersentuh. Angka stok naik dari 50 ke 60 (stok + 10) membuktikan ekspresi kolom pada SET dievaluasi normal.
  3. DELETE FROM produk_teh WHERE nama = 'Teh Oolong' — terjemahan yang sama berlaku; count(*) akhir kembali ke 7, bentuk data bersih seperti semula.
  4. Kolom pada INSERT harus lengkap untuk constraint tabel — view mengekspos kelima kolom produk, jadi nilai kategori = 'Teh' ikut disertakan; kolom tabel dasar yang tidak diekspos view akan menerima nilai default-nya.

Perhatikan pola besar di balik tiga poin itu: view yang bisa ditulis adalah peta satu-ke-satu ke sebagian baris tabel dasar. Selama pemetaan itu jelas — satu tabel sumber, tanpa pengubahan bentuk — PostgreSQL tahu persis ke mana setiap baris tulis harus mendarat.

Syarat View yang Otomatis Bisa Ditulis #

Kelas view yang “otomatis bisa ditulis” (automatically updatable) punya syarat yang bisa didaftar pendek. Aturan-aturan ini bukan arbitrer; masing-masing menjaga pemetaan satu-ke-satu antara baris view dan baris tabel:

SyaratContoh pelanggaranAkibat
FROM berisi tepat satu tabel atau view updatable lainpenjualan JOIN produkView tidak bisa ditulis
Tanpa DISTINCT, GROUP BY, HAVING, LIMIT, OFFSET, UNION/INTERSECT/EXCEPT, WITHGROUP BY pr.kategori pada ringkasan_penjualanView tidak bisa ditulis
Daftar SELECT tanpa agregat, window function, atau set-returning functioncount(*), sum(...)View tidak bisa ditulis
Kolom yang ingin ditulis berupa referensi langsung kolom sumberharga * 2 AS harga_mahalView tetap terbaca; kolom ekspresi tidak bisa ditulis

Intuisinya per baris: agregat dan GROUP BY mengubah banyak baris menjadi satu — UPDATE pada baris hasil agregat tidak punya padanan tunggal di tabel dasar; DISTINCT dan LIMIT memotong baris tanpa identitas yang stabil — baris ke-berapa yang dimaksud DELETE?; join menggabungkan dua sumber — kolom dari tabel sebelah mana yang harus ditulis? View yang melanggar salah satu syarat tetap sepenuhnya berfungsi untuk membaca; yang hilang hanyalah kemampuan menulis. Kita akan melihat penolakan konkretnya pada ringkasan_penjualan di paruh akhir chapter, dan status updatability setiap view bisa dibaca dari catalog information_schema.views — kolom is_updatable dan is_insertable_into.

Jebakan Tanpa CHECK OPTION: Baris yang Hilang #

Ada satu celah pada demo sebelumnya yang sengaja belum dibahas. produk_teh menyaring kategori = 'Teh', tetapi INSERT lewat view itu sama sekali tidak diwajibkan mengirim kategori Teh. Padahal nilai kolom kategori tetap harus dikirim — kolomnya NOT NULL di tabel dasar. Hasilnya: baris yang lolos ke tabel dasar namun tersaring keluar dari view:

-- TANPA CHECK OPTION, view tidak menjaga batasnya sendiri:
-- baris yang TIDAK memenuhi WHERE view tetap lolos ke tabel dasar.
INSERT INTO produk_teh (id, nama, kategori, harga, stok)
VALUES (8, 'Kopi Robusta', 'Kopi', 60000.00, 20);

-- View tidak menampilkan baris itu: kategori-nya 'Kopi',
-- tersaring oleh WHERE kategori = 'Teh'.
SELECT id, nama, kategori
FROM produk_teh
WHERE id = 8;

-- ... padahal barisnya benar-benar ada di tabel dasar.
SELECT id, nama, kategori, harga, stok
FROM produk
WHERE id = 8;

-- Baris yang tak terlihat view juga tak bisa dihapus lewat view;
-- pembersihannya harus langsung dari tabel dasar.
DELETE FROM produk
WHERE id = 8;

SELECT count(*) AS jumlah_produk
FROM produk;

Output yang Dihasilkan:

text
INSERT 0 1
 id | nama | kategori 
----+------+----------
(0 rows)

 id |     nama     | kategori |  harga   | stok 
----+--------------+----------+----------+------
  8 | Kopi Robusta | Kopi     | 60000.00 |   20
(1 row)

DELETE 1
 jumlah_produk 
---------------
             7
(1 row)

INSERT mengembalikan INSERT 0 1 — sukses di mata PostgreSQL — tetapi query id = 8 pada view menghasilkan (0 rows), sementara query yang sama pada produk menemukan Kopi Robusta lengkap dengan harganya. Baris itu berstatus hantu: ada di tabel, tak terlihat dari view, dan UPDATE/DELETE lewat view tidak akan pernah menyentuhnya karena terjemahan otomatisnya menyertakan predicate view. Pembersihannya harus turun ke tabel dasar, seperti DELETE FROM produk WHERE id = 8 di akhir file.

WITH CHECK OPTION: Gerbang di Depan View #

WITH CHECK OPTION ditempelkan di akhir definisi view dan mengubah aturan mainnya: setiap baris baru hasil INSERT, dan setiap baris hasil UPDATE, wajib tetap terlihat oleh view itu. Baris di luar jangkauan ditolak sebelum menyentuh tabel:

-- CHECK OPTION memasang gerbang: setiap baris baru atau baris hasil
-- UPDATE harus tetap terlihat oleh view -- kalau tidak, ditolak.
DROP VIEW produk_teh;

CREATE VIEW produk_teh AS
SELECT id, nama, kategori, harga, stok
FROM produk
WHERE kategori = 'Teh'
WITH CHECK OPTION;

SELECT id, nama, harga, stok
FROM produk_teh
ORDER BY id;

Output yang Dihasilkan:

text
DROP VIEW
CREATE VIEW
 id |    nama    |  harga   | stok 
----+------------+----------+------
  3 | Teh Melati | 35000.00 |  100
  4 | Teh Hijau  | 40000.00 |   60
(2 rows)

Definisi view-nya sama persis dengan sebelumnya — satu tabel, predicate kategori — hanya ditambah satu baris WITH CHECK OPTION. Sekarang INSERT yang sama yang tadinya menghasilkan baris hantu berubah nasib:

-- INSERT baris berkategori 'Kopi' lewat view Teh: DITOLAK.
-- Baris semacam itu akan tersaring WHERE kategori = 'Teh',
-- sehingga tidak akan pernah terlihat oleh view ini.
INSERT INTO produk_teh (id, nama, kategori, harga, stok)
VALUES (8, 'Kopi Robusta', 'Kopi', 60000.00, 20);

Output yang Dihasilkan:

text
psql:check-option-error.sql:5: ERROR:  new row violates check option for view "produk_teh"
DETAIL:  Failing row contains (8, Kopi Robusta, Kopi, 60000.00, 20).

Error new row violates check option for view "produk_teh" datang lengkap dengan DETAIL yang menampilkan baris gagal — Failing row contains (8, Kopi Robusta, Kopi, 60000.00, 20) — sehingga penyebabnya langsung terbaca. Statement UPDATE yang mencoba menggeser baris keluar dari view (misalnya SET kategori = 'Gula' pada Teh Melati) ditolak dengan error yang sama. Perintah DELETE tidak terpengaruh check option; view memang hanya bisa menghapus baris yang terlihatnya sendiri.

LOCAL vs CASCADED pada View Berlapis #

Untuk view tunggal di atas tabel, WITH CHECK OPTION selesai di situ. Pembeda LOCAL dan CASCADED baru berperan pada view berlapis — view yang membaca view lain — karena pertanyaannya menjadi: predicate lapis mana saja yang ikut diperiksa? Kita bangun lapisan bawah stok_menipis (stok di bawah 50, tanpa check option) dan lapisan atas teh_menipis_local dengan WITH LOCAL CHECK OPTION:

-- Pembeda LOCAL dan CASCADED hanya relevan untuk view berlapis.
-- Lapis bawah: produk dengan stok di bawah 50, TANPA check option.
CREATE VIEW stok_menipis AS
SELECT id, nama, kategori, harga, stok
FROM produk
WHERE stok < 50;

-- Lapis atas dengan LOCAL CHECK OPTION:
-- hanya predicate lapis atas (kategori = 'Teh') yang diperiksa.
CREATE VIEW teh_menipis_local AS
SELECT id, nama, kategori, harga, stok
FROM stok_menipis
WHERE kategori = 'Teh'
WITH LOCAL CHECK OPTION;

-- stok 80 melanggar predicate lapis bawah (stok < 50),
-- tetapi LOCAL hanya memeriksa lapis atas -- INSERT LOLOS.
INSERT INTO teh_menipis_local (id, nama, kategori, harga, stok)
VALUES (9, 'Teh Chamomile', 'Teh', 45000.00, 80);

-- Akibatnya baris itu masuk ke tabel dasar ...
SELECT id, nama, kategori, stok
FROM produk
WHERE id = 9;

-- ... tetapi tidak terlihat dari view (dan juga dari lapis bawah).
SELECT id, nama, stok
FROM teh_menipis_local
WHERE id = 9;

-- Bersihkan, lalu buat versi CASCADED untuk perbandingan.
DELETE FROM produk
WHERE id = 9;

CREATE VIEW teh_menipis_casc AS
SELECT id, nama, kategori, harga, stok
FROM stok_menipis
WHERE kategori = 'Teh'
WITH CASCADED CHECK OPTION;

-- Satu view agregat untuk pengujian tabel aturan di bawah.
CREATE VIEW ringkasan_penjualan AS
SELECT pr.kategori,
       count(*)                 AS transaksi,
       sum(pe.jumlah)           AS total_unit,
       sum(pe.jumlah * pr.harga) AS total_nilai
FROM penjualan pe
JOIN produk pr ON pr.id = pe.produk_id
GROUP BY pr.kategori;

-- information_schema.views membaca status updatability
-- dan level check option dari semua view milik schema public.
SELECT table_name, check_option, is_updatable, is_insertable_into
FROM information_schema.views
WHERE table_schema = 'public'
ORDER BY table_name;

Output yang Dihasilkan:

text
CREATE VIEW
CREATE VIEW
INSERT 0 1
 id |     nama      | kategori | stok 
----+---------------+----------+------
  9 | Teh Chamomile | Teh      |   80
(1 row)

 id | nama | stok 
----+------+------
(0 rows)

DELETE 1
CREATE VIEW
CREATE VIEW
     table_name      | check_option | is_updatable | is_insertable_into 
---------------------+--------------+--------------+--------------------
 produk_teh          | CASCADED     | YES          | YES
 ringkasan_penjualan | NONE         | NO           | NO
 stok_menipis        | NONE         | YES          | YES
 teh_menipis_casc    | CASCADED     | YES          | YES
 teh_menipis_local   | LOCAL        | YES          | YES
(5 rows)

Hasilnya membuktikan LOCAL mengecek predicate lapis atas saja: INSERT Teh Chamomile dengan stok 80 lolos — kategori = 'Teh' terpenuhi — padahal stok 80 melanggar predicate stok < 50 milik stok_menipis. Barisnya mendarat di produk, namun tidak terlihat dari teh_menipis_local maupun dari stok_menipis: baris hantu versi berlapis. Versi CASCADED memeriksa seluruh rantai, dan INSERT yang sama langsung gagal:

-- INSERT yang sama lewat versi CASCADED: DITOLAK.
-- Pemeriksaan menjalar ke seluruh rantai view di bawahnya:
-- predicate stok < 50 milik stok_menipis ikut dievaluasi,
-- dan stok 80 jelas gagal melewatinya.
INSERT INTO teh_menipis_casc (id, nama, kategori, harga, stok)
VALUES (9, 'Teh Chamomile', 'Teh', 45000.00, 80);

Output yang Dihasilkan:

text
psql:check-option-cascaded-error.sql:6: ERROR:  new row violates check option for view "stok_menipis"
DETAIL:  Failing row contains (9, Teh Chamomile, Teh, 45000.00, 80).

Error new row violates check option for view "stok_menipis" menyebut nama lapisan bawah — bukan teh_menipis_casc yang kita tulis di statement — karena pelanggaran ditemukan pada predicate stok_menipis dalam penelusuran berjenjang. Tabel catalog di file sebelumnya merangkum status kelimanya: produk_teh tercatat CASCADED padahal kita menulis WITH CHECK OPTION polos (CASCADED memang default-nya), teh_menipis_local tercatat LOCAL, teh_menipis_casc tercatat CASCADED, dan baris ringkasan_penjualan memperlihatkan is_updatable = NO — subjek bagian penutup.

View Agregat Menolak Ditulis #

Tertutupnya bab ini adalah penolakan yang menegaskan tabel syarat di awal: view agregat memang tidak punya padanan tulis. ringkasan_penjualan menggabungkan join, GROUP BY, dan tiga agregat — tiga pelanggaran sekaligus — sehingga satu-satunya perintah yang sah padanya adalah membaca:

-- View agregat TIDAK otomatis bisa ditulis:
-- satu baris INSERT tidak punya makna yang jelas untuk hasil GROUP BY.
INSERT INTO ringkasan_penjualan (kategori, transaksi)
VALUES ('Susu', 0);

Output yang Dihasilkan:

text
psql:insert-agregat-error.sql:4: ERROR:  cannot insert into view "ringkasan_penjualan"
DETAIL:  Views containing GROUP BY are not automatically updatable.
HINT:  To enable inserting into the view, provide an INSTEAD OF INSERT trigger or an unconditional ON INSERT DO INSTEAD rule.

Penolakan lengkapnya tiga lapis: cannot insert into view "ringkasan_penjualan", DETAIL: Views containing GROUP BY are not automatically updatable, dan HINT yang menyebut dua jalan keluar eksplisit — trigger INSTEAD OF INSERT atau aturan ON INSERT DO INSTEAD. Hint itu bukan basa-basi: keduanya adalah mekanisme resmi untuk memberi view kompleks perilaku tulis buatan, misalnya INSERT kategori baru yang diterjemahkan menjadi serangkaian operasi tabel. Logika macam itu adalah materi course Triggers, bukan tambalan yang perlu ditakuti di sini.

View kini utuh gambarnya: bisa dibaca selalu, bisa ditulis kalau strukturnya sederhana, dan bisa dijaga batasnya dengan CHECK OPTION. Namun semua itu tetap membayar biaya komputasi setiap kali dibaca — view agregat yang dibaca sering akan menghitung ulang join dan GROUP BY-nya lagi dan lagi. Bagi laporan berat yang jarang berubah datanya, PostgreSQL menyediakan varian yang membekukan hasilnya ke tabel fisik dan menyegarkannya sesuai perintah: Materialized View .

Referensi #

Berkas Sumber

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

baris-hilang.sql

Unduh berkas
sql
-- TANPA CHECK OPTION, view tidak menjaga batasnya sendiri:
-- baris yang TIDAK memenuhi WHERE view tetap lolos ke tabel dasar.
INSERT INTO produk_teh (id, nama, kategori, harga, stok)
VALUES (8, 'Kopi Robusta', 'Kopi', 60000.00, 20);

-- View tidak menampilkan baris itu: kategori-nya 'Kopi',
-- tersaring oleh WHERE kategori = 'Teh'.
SELECT id, nama, kategori
FROM produk_teh
WHERE id = 8;

-- ... padahal barisnya benar-benar ada di tabel dasar.
SELECT id, nama, kategori, harga, stok
FROM produk
WHERE id = 8;

-- Baris yang tak terlihat view juga tak bisa dihapus lewat view;
-- pembersihannya harus langsung dari tabel dasar.
DELETE FROM produk
WHERE id = 8;

SELECT count(*) AS jumlah_produk
FROM produk;

buat-tabel.sql

Unduh berkas
sql
-- Domain latihan section View dan Materialized View:
-- katalog produk, pelanggan, dan transaksi Toko Maju Jaya.
CREATE TABLE produk (
    id       integer PRIMARY KEY,
    nama     text NOT NULL,
    kategori text NOT NULL,
    harga    numeric(10,2) NOT NULL,
    stok     integer NOT NULL
);

CREATE TABLE pelanggan (
    id   integer PRIMARY KEY,
    nama text NOT NULL,
    kota text NOT NULL
);

CREATE TABLE penjualan (
    id           integer PRIMARY KEY,
    tanggal      date NOT NULL,
    produk_id    integer NOT NULL REFERENCES produk(id),
    pelanggan_id integer NOT NULL REFERENCES pelanggan(id),
    jumlah       integer NOT NULL
);

INSERT INTO produk (id, nama, kategori, harga, stok) VALUES
    (1, 'Kopi Gayo',    'Kopi', 85000.00,  40),
    (2, 'Kopi Toraja',  'Kopi', 90000.00,  25),
    (3, 'Teh Melati',   'Teh',  35000.00, 100),
    (4, 'Teh Hijau',    'Teh',  40000.00,  60),
    (5, 'Gula Merah',   'Gula', 15000.00, 200),
    (6, 'Gula Putih',   'Gula', 16000.00, 150),
    (7, 'Kopi Lampung', 'Kopi', 78000.00,   0);

INSERT INTO pelanggan (id, nama, kota) VALUES
    (1, 'Budi Santoso', 'Jakarta'),
    (2, 'Siti Aminah',  'Bandung'),
    (3, 'Andi Wijaya',  'Surabaya'),
    (4, 'Dewi Lestari', 'Yogyakarta'),
    (5, 'Rina Marlina', 'Medan');

INSERT INTO penjualan (id, tanggal, produk_id, pelanggan_id, jumlah) VALUES
    ( 1, DATE '2026-01-05', 1, 1, 10),
    ( 2, DATE '2026-01-08', 3, 2, 25),
    ( 3, DATE '2026-01-12', 5, 3, 40),
    ( 4, DATE '2026-01-15', 2, 1,  8),
    ( 5, DATE '2026-01-20', 4, 4, 15),
    ( 6, DATE '2026-01-22', 1, 2, 12),
    ( 7, DATE '2026-02-02', 3, 5, 20),
    ( 8, DATE '2026-02-05', 6, 3, 30),
    ( 9, DATE '2026-02-10', 2, 4,  6),
    (10, DATE '2026-02-14', 4, 1, 18);

SELECT id, nama, kategori, harga, stok FROM produk ORDER BY id;
SELECT id, nama, kota FROM pelanggan ORDER BY id;
SELECT id, tanggal, produk_id, pelanggan_id, jumlah FROM penjualan ORDER BY id;

buat-view-check-option.sql

Unduh berkas
sql
-- CHECK OPTION memasang gerbang: setiap baris baru atau baris hasil
-- UPDATE harus tetap terlihat oleh view -- kalau tidak, ditolak.
DROP VIEW produk_teh;

CREATE VIEW produk_teh AS
SELECT id, nama, kategori, harga, stok
FROM produk
WHERE kategori = 'Teh'
WITH CHECK OPTION;

SELECT id, nama, harga, stok
FROM produk_teh
ORDER BY id;

check-option-berlapis.sql

Unduh berkas
sql
-- Pembeda LOCAL dan CASCADED hanya relevan untuk view berlapis.
-- Lapis bawah: produk dengan stok di bawah 50, TANPA check option.
CREATE VIEW stok_menipis AS
SELECT id, nama, kategori, harga, stok
FROM produk
WHERE stok < 50;

-- Lapis atas dengan LOCAL CHECK OPTION:
-- hanya predicate lapis atas (kategori = 'Teh') yang diperiksa.
CREATE VIEW teh_menipis_local AS
SELECT id, nama, kategori, harga, stok
FROM stok_menipis
WHERE kategori = 'Teh'
WITH LOCAL CHECK OPTION;

-- stok 80 melanggar predicate lapis bawah (stok < 50),
-- tetapi LOCAL hanya memeriksa lapis atas -- INSERT LOLOS.
INSERT INTO teh_menipis_local (id, nama, kategori, harga, stok)
VALUES (9, 'Teh Chamomile', 'Teh', 45000.00, 80);

-- Akibatnya baris itu masuk ke tabel dasar ...
SELECT id, nama, kategori, stok
FROM produk
WHERE id = 9;

-- ... tetapi tidak terlihat dari view (dan juga dari lapis bawah).
SELECT id, nama, stok
FROM teh_menipis_local
WHERE id = 9;

-- Bersihkan, lalu buat versi CASCADED untuk perbandingan.
DELETE FROM produk
WHERE id = 9;

CREATE VIEW teh_menipis_casc AS
SELECT id, nama, kategori, harga, stok
FROM stok_menipis
WHERE kategori = 'Teh'
WITH CASCADED CHECK OPTION;

-- Satu view agregat untuk pengujian tabel aturan di bawah.
CREATE VIEW ringkasan_penjualan AS
SELECT pr.kategori,
       count(*)                 AS transaksi,
       sum(pe.jumlah)           AS total_unit,
       sum(pe.jumlah * pr.harga) AS total_nilai
FROM penjualan pe
JOIN produk pr ON pr.id = pe.produk_id
GROUP BY pr.kategori;

-- information_schema.views membaca status updatability
-- dan level check option dari semua view milik schema public.
SELECT table_name, check_option, is_updatable, is_insertable_into
FROM information_schema.views
WHERE table_schema = 'public'
ORDER BY table_name;

check-option-cascaded-error.sql

Unduh berkas
sql
-- INSERT yang sama lewat versi CASCADED: DITOLAK.
-- Pemeriksaan menjalar ke seluruh rantai view di bawahnya:
-- predicate stok < 50 milik stok_menipis ikut dievaluasi,
-- dan stok 80 jelas gagal melewatinya.
INSERT INTO teh_menipis_casc (id, nama, kategori, harga, stok)
VALUES (9, 'Teh Chamomile', 'Teh', 45000.00, 80);

check-option-error.sql

Unduh berkas
sql
-- INSERT baris berkategori 'Kopi' lewat view Teh: DITOLAK.
-- Baris semacam itu akan tersaring WHERE kategori = 'Teh',
-- sehingga tidak akan pernah terlihat oleh view ini.
INSERT INTO produk_teh (id, nama, kategori, harga, stok)
VALUES (8, 'Kopi Robusta', 'Kopi', 60000.00, 20);

insert-agregat-error.sql

Unduh berkas
sql
-- View agregat TIDAK otomatis bisa ditulis:
-- satu baris INSERT tidak punya makna yang jelas untuk hasil GROUP BY.
INSERT INTO ringkasan_penjualan (kategori, transaksi)
VALUES ('Susu', 0);

tulis-lewat-view.sql

Unduh berkas
sql
-- View sederhana = satu tabel, tanpa agregat/DISTINCT/GROUP BY/LIMIT:
-- PostgreSQL menerjemahkan INSERT/UPDATE/DELETE padanya menjadi
-- operasi pada tabel dasar produk.
CREATE VIEW produk_teh AS
SELECT id, nama, kategori, harga, stok
FROM produk
WHERE kategori = 'Teh';

SELECT id, nama, harga, stok
FROM produk_teh
ORDER BY id;

-- INSERT lewat view: baris mendarat di tabel produk.
INSERT INTO produk_teh (id, nama, kategori, harga, stok)
VALUES (8, 'Teh Oolong', 'Teh', 55000.00, 50);

SELECT id, nama, kategori, harga, stok
FROM produk
WHERE id = 8;

-- UPDATE lewat view: hanya baris yang terlihat view yang tersentuh.
UPDATE produk_teh
SET harga = 52000.00, stok = stok + 10
WHERE nama = 'Teh Oolong';

SELECT id, nama, harga, stok
FROM produk
WHERE id = 8;

-- DELETE lewat view: sama, terjemahan DELETE pada produk
-- dengan predicate view ikut serta.
DELETE FROM produk_teh
WHERE nama = 'Teh Oolong';

SELECT count(*) AS jumlah_produk
FROM produk;