Di antara tiga bentuk relasi antartabel, relasi one-to-one adalah yang paling sering dicurigai. Kalau satu baris pelanggan berhubungan dengan tepat satu baris data kartu kredit, dan sebaliknya, kenapa tidak saja digabung menjadi satu tabel? Kecurigaan itu sehat: mayoritas kasus memang lebih baik dipecah dengan kolom biasa. Namun ada situasi ketika pemisahan dua tabel dengan relasi satu-ke-satu justru desain yang paling jernih — dan PostgreSQL punya pola implementasi yang menjanjikan kesatuannya di level server, bukan hanya di niat desainer. Toko Maju Jaya akan menjadi kasus kita: toko mulai menerima pembayaran kartu kredit, dan data kartu pun butuh tempat.
Kapan Relasi One-to-One Benar-benar Dibutuhkan #
Empat alasan lazim membuat pemisahan 1:1 lebih baik daripada penggabungan kolom:
| Alasan | Contoh di dunia nyata | Sifat kolom yang dipisah |
|---|---|---|
| data sensitif perlu dipagari | kartu_kredit terpisah dari pelanggan | akses bisa dibatasi per tabel lewat GRANT |
| data opsional dan jarang terisi | hanya 2 dari 100 pelanggan punya kartu | hindari tabel lebar yang didominasi NULL |
| kolom besar dan jarang dibaca | foto profil, dokumen bytea | tabel utama tetap ramping dan cepat dipindai |
| siklus hidup berbeda | kartu sering diganti, profil jarang | update sering tidak menyentuh tabel utama |
Alasan pertama dan kedua yang kita pakai untuk Toko Maju Jaya. Nomor kartu adalah data yang tidak boleh terbaca oleh sembarang staf kasir, sehingga memisahkannya ke tabel sendiri memungkinkan pagar izin akses per tabel. Alasan kedua bekerja pada arah sebaliknya: kartu hanya dimiliki sebagian kecil pelanggan, dan menggabungkan seluruh kolom kartu ke tabel pelanggan menghasilkan banyak sel kosong yang mengembangkan ukuran baris tanpa manfaat.
Implementasi: Foreign Key plus UNIQUE #
Relasi satu-ke-satu dibangun dari bahan yang sama dengan relasi lainnya: foreign key di satu sisi. Pembedanya, kolom rujukan itu juga diberi UNIQUE sehingga satu baris induk hanya boleh dirujuk satu baris anak — itulah yang memotong relasi satu-ke-banyak menjadi satu-ke-satu:
-- Relasi one-to-one: data kartu dipisah ke tabel sendiri dan
-- diikat kombinasi foreign key + UNIQUE pada kolom pelanggan_id.
CREATE TABLE pelanggan (
id integer PRIMARY KEY,
nama text NOT NULL,
kota text NOT NULL,
email text NOT NULL UNIQUE
);
CREATE TABLE kartu_kredit (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
pelanggan_id integer NOT NULL UNIQUE REFERENCES pelanggan (id),
nomor_pmask text NOT NULL,
pemegang text NOT NULL,
masa_berlaku date NOT NULL
);
INSERT INTO pelanggan (id, nama, kota, email) VALUES
(1, 'Budi Santoso', 'Bandung', '[email protected]'),
(2, 'Siti Rahayu', 'Jakarta', '[email protected]'),
(3, 'Dewi Lestari', 'Medan', '[email protected]');
-- Nomor kartu selalu fiktif dan tersamar di materi ini.
INSERT INTO kartu_kredit (pelanggan_id, nomor_pmask, pemegang, masa_berlaku) VALUES
(1, '4111-****-****-1234', 'BUDI SANTOSO', DATE '2028-06-30'),
(3, '5555-****-****-5678', 'DEWI LESTARI', DATE '2027-11-30');
-- INNER JOIN: hanya pelanggan yang punya kartu
SELECT p.id, p.nama, k.nomor_pmask, k.masa_berlaku
FROM pelanggan p
JOIN kartu_kredit k ON k.pelanggan_id = p.id
ORDER BY p.id;
-- LEFT JOIN: pelanggan tanpa kartu tetap tampil
SELECT p.id, p.nama, COALESCE(k.nomor_pmask, '-') AS kartu
FROM pelanggan p
LEFT JOIN kartu_kredit k ON k.pelanggan_id = p.id
ORDER BY p.id;Output yang Dihasilkan:
CREATE TABLE
CREATE TABLE
INSERT 0 3
INSERT 0 2
id | nama | nomor_pmask | masa_berlaku
----+--------------+---------------------+--------------
1 | Budi Santoso | 4111-****-****-1234 | 2028-06-30
3 | Dewi Lestari | 5555-****-****-5678 | 2027-11-30
(2 rows)
id | nama | kartu
----+--------------+---------------------
1 | Budi Santoso | 4111-****-****-1234
2 | Siti Rahayu | -
3 | Dewi Lestari | 5555-****-****-5678
(3 rows)Tiga keputusan pada definisi di atas menentukan karakter relasi ini:
pelanggan_id integer NOT NULL UNIQUE REFERENCES pelanggan (id)— tiga constraint dalam satu baris:REFERENCESmemastikan kartu hanya menunjuk pelanggan nyata,UNIQUEmemastikan satu pelanggan maksimal satu kartu, danNOT NULLmemastikan setiap kartu punya pemilik.id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY— identitas internal tabel kartu tetap dibangkitkan server; kolom ini berbeda peran daripelanggan_idyang menjadi jangkar relasi.nomor_pmask— nomor kartu selalu disimpan dalam bentuk tersamar di materi ini; di sistem nyata, nomor penuh seharusnya dienkripsi dan hanya cuplikannya yang disimpan sebagai teks biasa.
Dua query penutup file memperlihatkan sisi membaca datanya. JOIN menghasilkan dua baris — hanya pelanggan dengan kartu. LEFT JOIN menghasilkan tiga baris — Siti Rahayu tampil dengan tanda - hasil COALESCE, karena relasi 1:1 di sini bersifat opsional di sisi pelanggan tanpa kartu.
Menolak Kartu Kedua #
Sisi penulisan data dijaga kombinasi constraint yang sama. Percobaan mendaftarkan kartu kedua untuk pelanggan yang sudah punya kartu akan ditolak server:
-- Pelanggan yang sama mencoba mendaftarkan kartu kedua.
-- UNIQUE pada pelanggan_id menolaknya: satu pelanggan, satu kartu.
INSERT INTO kartu_kredit (pelanggan_id, nomor_pmask, pemegang, masa_berlaku)
VALUES (1, '4111-****-****-9999', 'BUDI SANTOSO', DATE '2029-01-31');Output yang Dihasilkan:
psql:kartu-ganda.sql:4: ERROR: duplicate key value violates unique constraint "kartu_kredit_pelanggan_id_key"
DETAIL: Key (pelanggan_id)=(1) already exists.Error duplicate key value violates unique constraint "kartu_kredit_pelanggan_id_key" berasal dari constraint UNIQUE pada pelanggan_id. Perhatikan nama constraint otomatisnya mengikuti pola <tabel>_<kolom>_key — pola penamaan otomatis PostgreSQL ini sudah kita kenal di chapter Foreign Key
. Tanpa UNIQUE, insert ini justru berhasil, dan relasi diam-diam berubah menjadi satu-ke-banyak tanpa sepengetahuan siapa pun.
Dua Arah Penempatan Kolom Penghubung #
Kolom penghubung 1:1 bisa diletakkan di kedua sisi. Variasi pertama menaruhnya di tabel kartu (kartu_kredit.pelanggan_id); variasi kedua menaruhnya di tabel pelanggan (pelanggan.kartu_id):
-- Arah kedua: kolom penghubung diletakkan di tabel pelanggan,
-- rujukannya menunjuk ke kartu_kredit.
DROP TABLE IF EXISTS kartu_kredit, pelanggan CASCADE;
CREATE TABLE kartu_kredit (
id integer PRIMARY KEY,
nomor_pmask text NOT NULL,
pemegang text NOT NULL,
masa_berlaku date NOT NULL
);
CREATE TABLE pelanggan (
id integer PRIMARY KEY,
nama text NOT NULL,
kota text NOT NULL,
email text NOT NULL UNIQUE,
kartu_id integer UNIQUE REFERENCES kartu_kredit (id)
);
INSERT INTO kartu_kredit (id, nomor_pmask, pemegang, masa_berlaku) VALUES
(10, '4111-****-****-1234', 'BUDI SANTOSO', DATE '2028-06-30'),
(11, '5555-****-****-5678', 'DEWI LESTARI', DATE '2027-11-30');
INSERT INTO pelanggan (id, nama, kota, email, kartu_id) VALUES
(1, 'Budi Santoso', 'Bandung', '[email protected]', 10),
(2, 'Siti Rahayu', 'Jakarta', '[email protected]', NULL),
(3, 'Dewi Lestari', 'Medan', '[email protected]', 11);
SELECT p.nama, p.email, COALESCE(k.nomor_pmask, '-') AS kartu
FROM pelanggan p
LEFT JOIN kartu_kredit k ON k.id = p.kartu_id
ORDER BY p.id;
-- Kelemahan arah ini: kartu bisa tercatat tanpa pemilik,
-- NULL di kartu_id membuat rujukan tak terjaga.
SELECT k.id AS kartu, k.nomor_pmask,
COUNT(p.id) AS jumlah_pemilik
FROM kartu_kredit k
LEFT JOIN pelanggan p ON p.kartu_id = k.id
GROUP BY k.id, k.nomor_pmask
ORDER BY k.id;Output yang Dihasilkan:
DROP TABLE
CREATE TABLE
CREATE TABLE
INSERT 0 2
INSERT 0 3
nama | email | kartu
--------------+------------------+---------------------
Budi Santoso | [email protected] | 4111-****-****-1234
Siti Rahayu | [email protected] | -
Dewi Lestari | [email protected] | 5555-****-****-5678
(3 rows)
kartu | nomor_pmask | jumlah_pemilik
-------+---------------------+----------------
10 | 4111-****-****-1234 | 1
11 | 5555-****-****-5678 | 1
(2 rows)Kedua desain menghasilkan laporan yang tampak sama, tetapi karakter jaminannya berbeda:
- Arah kartu ke pelanggan (variasi pertama) —
pelanggan_id NOT NULLmenjamin setiap kartu pasti berpemilik; pelanggan tanpa kartu terwakili oleh ketiadaan baris dikartu_kredit. - Arah pelanggan ke kartu (variasi kedua) —
kartu_id UNIQUEboleh NULL, sehingga pelanggan tanpa kartu terwakili oleh NULL; sebaliknya, kartu kini bisa tercatat tanpa pemilik karena tidak ada constraint yang memaksapelangganmerujuknya. - Query penghitung pemilik di akhir file memperlihatkan risiko arah kedua: satu kartu bisa saja tanpa pemilik, dan kondisi itu hanya bisa dideteksi lewat query audit, bukan ditolak server.
Aturan memilihnya sederhana: letakkan kolom penghubung di sisi yang keberadaannya bergantung pada sisi lain. Kartu bergantung pada pemiliknya, maka pelanggan_id menaruh diri di tabel kartu — itulah variasi pertama, dan itulah yang dipakai sisa materi section ini.
Bentuk Tersekat: Primary Key Sekaligus Foreign Key #
Bentuk ketiga menghilangkan kolom identitas terpisah pada tabel anak: pelanggan_id sendiri yang menjadi primary key sekaligus foreign key. Satu kolom menjalankan dua peran:
-- Bentuk tersekat: pelanggan_id menjadi PRIMARY KEY sekaligus foreign key.
-- Satu kolom menjalankan dua peran: identitas kartu DAN jangkar relasi.
DROP TABLE IF EXISTS kartu_kredit, pelanggan CASCADE;
CREATE TABLE pelanggan (
id integer PRIMARY KEY,
nama text NOT NULL,
kota text NOT NULL,
email text NOT NULL UNIQUE
);
CREATE TABLE kartu_kredit (
pelanggan_id integer PRIMARY KEY REFERENCES pelanggan (id) ON DELETE CASCADE,
nomor_pmask text NOT NULL,
pemegang text NOT NULL,
masa_berlaku date NOT NULL
);
INSERT INTO pelanggan (id, nama, kota, email) VALUES
(1, 'Budi Santoso', 'Bandung', '[email protected]'),
(2, 'Siti Rahayu', 'Jakarta', '[email protected]');
INSERT INTO kartu_kredit (pelanggan_id, nomor_pmask, pemegang, masa_berlaku) VALUES
(1, '4111-****-****-1234', 'BUDI SANTOSO', DATE '2028-06-30');
SELECT p.nama, k.nomor_pmask, k.masa_berlaku
FROM pelanggan p
JOIN kartu_kredit k ON k.pelanggan_id = p.id
ORDER BY p.id;
-- Pelanggan dihapus: kartunya ikut terhapus, tidak menyisakan kartu yatim.
DELETE FROM pelanggan WHERE id = 1;
SELECT pelanggan_id, nomor_pmask FROM kartu_kredit ORDER BY pelanggan_id;Output yang Dihasilkan:
DROP TABLE
CREATE TABLE
CREATE TABLE
INSERT 0 2
INSERT 0 1
nama | nomor_pmask | masa_berlaku
--------------+---------------------+--------------
Budi Santoso | 4111-****-****-1234 | 2028-06-30
(1 row)
DELETE 1
pelanggan_id | nomor_pmask
--------------+-------------
(0 rows)Konsekuensi bentuk tersekat ini perlu dibaca perlahan:
pelanggan_id integer PRIMARY KEY REFERENCES pelanggan (id)— sebagai primary key, kolom ini otomatis unik dan tidak boleh NULL; sebagai foreign key, nilainya wajib ada dipelanggan.id. Kesatu-ke-satu-an dijamin paling ketat: tidak ada kartu tanpa pemilik, tidak ada dua kartu untuk satu pemilik.- Identitas kartu menyatu dengan identitas pemiliknya — nilai
pelanggan_iddi tabel kartu selalu sama dengan id pemiliknya, sehingga tabel ini tidak punya id sendiri. ON DELETE CASCADE— bila pemilik dihapus, kartunya ikut terhapus. Query penutup memperlihatkan tabel kartu kosong setelahDELETE FROM pelanggan WHERE id = 1; tanpa aksi referensial ini, penghapusan akan ditolak karena rujukan masih aktif.
Bentuk PK-as-FK cocok untuk tabel pelengkap yang hidupnya benar-benar menempel pada induknya. Tapi ketebalan jaminannya juga punya harga: tabel anak tidak bisa lagi ditambahi baris ke dua untuk entitas yang sama di kemudian hari tanpa merombak primary key — desain ini memutus jalan mundur.
Digabung atau Dipisah #
Keputusan akhir 1:1 selalu kembali ke pertanyaan: satu tabel atau dua tabel? Tabel berikut merangkum pertukarannya untuk kasus kartu kredit:
| Pertimbangan | Digabung (kolom di pelanggan) | Dipisah (tabel kartu_kredit) |
|---|---|---|
| kerapatan NULL | kolom kartu kosong bagi mayoritas pelanggan | hanya pelanggan berkartu yang punya baris |
| pagar keamanan | kolom sensitif satu tabel dengan data umum | GRANT/REVOKE bisa membatasi per tabel |
| frekuensi update | ganti kartu menulis ulang baris pelanggan | baris pelanggan tidak tersentuh |
| kompleksitas baca | cukup satu tabel, tanpa join | laporan gabungan butuh LEFT JOIN |
| batas jumlah | sulit melarang dua kartu per pelanggan | UNIQUE menolak kartu kedua |
Dua baris terakhir sering jadi penentu. Bila aturan bisnis menyebut “maksimal satu kartu per pelanggan”, penggabungan kolom tidak punya mekanisme server untuk menegakkannya bila kartunya berupa beberapa kolom; tabel terpisah dengan UNIQUE memilikinya. Sebaliknya, bila data itu selalu terisi dan tidak sensitif — misalnya kolom kota pada pelanggan — memisahkannya hanya menambah join tanpa manfaat.
Dengan kartu kredit kini berumah rapi, Toko Maju Jaya menghadapi relasi yang jauh lebih sering muncul: satu pelanggan yang datang berkali-kali, membawa pesanan baru setiap kunjungannya. Satu pelanggan, banyak pesanan — inilah pola yang mendominasi hampir setiap skema database, dan chapter berikutnya membedahnya sampai ke anti-pola yang paling sering menggagalkannya.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
arah-berbeda.sql
Unduh berkas-- Arah kedua: kolom penghubung diletakkan di tabel pelanggan,
-- rujukannya menunjuk ke kartu_kredit.
DROP TABLE IF EXISTS kartu_kredit, pelanggan CASCADE;
CREATE TABLE kartu_kredit (
id integer PRIMARY KEY,
nomor_pmask text NOT NULL,
pemegang text NOT NULL,
masa_berlaku date NOT NULL
);
CREATE TABLE pelanggan (
id integer PRIMARY KEY,
nama text NOT NULL,
kota text NOT NULL,
email text NOT NULL UNIQUE,
kartu_id integer UNIQUE REFERENCES kartu_kredit (id)
);
INSERT INTO kartu_kredit (id, nomor_pmask, pemegang, masa_berlaku) VALUES
(10, '4111-****-****-1234', 'BUDI SANTOSO', DATE '2028-06-30'),
(11, '5555-****-****-5678', 'DEWI LESTARI', DATE '2027-11-30');
INSERT INTO pelanggan (id, nama, kota, email, kartu_id) VALUES
(1, 'Budi Santoso', 'Bandung', '[email protected]', 10),
(2, 'Siti Rahayu', 'Jakarta', '[email protected]', NULL),
(3, 'Dewi Lestari', 'Medan', '[email protected]', 11);
SELECT p.nama, p.email, COALESCE(k.nomor_pmask, '-') AS kartu
FROM pelanggan p
LEFT JOIN kartu_kredit k ON k.id = p.kartu_id
ORDER BY p.id;
-- Kelemahan arah ini: kartu bisa tercatat tanpa pemilik,
-- NULL di kartu_id membuat rujukan tak terjaga.
SELECT k.id AS kartu, k.nomor_pmask,
COUNT(p.id) AS jumlah_pemilik
FROM kartu_kredit k
LEFT JOIN pelanggan p ON p.kartu_id = k.id
GROUP BY k.id, k.nomor_pmask
ORDER BY k.id;
buat-tabel.sql
Unduh berkas-- Relasi one-to-one: data kartu dipisah ke tabel sendiri dan
-- diikat kombinasi foreign key + UNIQUE pada kolom pelanggan_id.
CREATE TABLE pelanggan (
id integer PRIMARY KEY,
nama text NOT NULL,
kota text NOT NULL,
email text NOT NULL UNIQUE
);
CREATE TABLE kartu_kredit (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
pelanggan_id integer NOT NULL UNIQUE REFERENCES pelanggan (id),
nomor_pmask text NOT NULL,
pemegang text NOT NULL,
masa_berlaku date NOT NULL
);
INSERT INTO pelanggan (id, nama, kota, email) VALUES
(1, 'Budi Santoso', 'Bandung', '[email protected]'),
(2, 'Siti Rahayu', 'Jakarta', '[email protected]'),
(3, 'Dewi Lestari', 'Medan', '[email protected]');
-- Nomor kartu selalu fiktif dan tersamar di materi ini.
INSERT INTO kartu_kredit (pelanggan_id, nomor_pmask, pemegang, masa_berlaku) VALUES
(1, '4111-****-****-1234', 'BUDI SANTOSO', DATE '2028-06-30'),
(3, '5555-****-****-5678', 'DEWI LESTARI', DATE '2027-11-30');
-- INNER JOIN: hanya pelanggan yang punya kartu
SELECT p.id, p.nama, k.nomor_pmask, k.masa_berlaku
FROM pelanggan p
JOIN kartu_kredit k ON k.pelanggan_id = p.id
ORDER BY p.id;
-- LEFT JOIN: pelanggan tanpa kartu tetap tampil
SELECT p.id, p.nama, COALESCE(k.nomor_pmask, '-') AS kartu
FROM pelanggan p
LEFT JOIN kartu_kredit k ON k.pelanggan_id = p.id
ORDER BY p.id;
kartu-ganda.sql
Unduh berkas-- Pelanggan yang sama mencoba mendaftarkan kartu kedua.
-- UNIQUE pada pelanggan_id menolaknya: satu pelanggan, satu kartu.
INSERT INTO kartu_kredit (pelanggan_id, nomor_pmask, pemegang, masa_berlaku)
VALUES (1, '4111-****-****-9999', 'BUDI SANTOSO', DATE '2029-01-31');
pk-as-fk.sql
Unduh berkas-- Bentuk tersekat: pelanggan_id menjadi PRIMARY KEY sekaligus foreign key.
-- Satu kolom menjalankan dua peran: identitas kartu DAN jangkar relasi.
DROP TABLE IF EXISTS kartu_kredit, pelanggan CASCADE;
CREATE TABLE pelanggan (
id integer PRIMARY KEY,
nama text NOT NULL,
kota text NOT NULL,
email text NOT NULL UNIQUE
);
CREATE TABLE kartu_kredit (
pelanggan_id integer PRIMARY KEY REFERENCES pelanggan (id) ON DELETE CASCADE,
nomor_pmask text NOT NULL,
pemegang text NOT NULL,
masa_berlaku date NOT NULL
);
INSERT INTO pelanggan (id, nama, kota, email) VALUES
(1, 'Budi Santoso', 'Bandung', '[email protected]'),
(2, 'Siti Rahayu', 'Jakarta', '[email protected]');
INSERT INTO kartu_kredit (pelanggan_id, nomor_pmask, pemegang, masa_berlaku) VALUES
(1, '4111-****-****-1234', 'BUDI SANTOSO', DATE '2028-06-30');
SELECT p.nama, k.nomor_pmask, k.masa_berlaku
FROM pelanggan p
JOIN kartu_kredit k ON k.pelanggan_id = p.id
ORDER BY p.id;
-- Pelanggan dihapus: kartunya ikut terhapus, tidak menyisakan kartu yatim.
DELETE FROM pelanggan WHERE id = 1;
SELECT pelanggan_id, nomor_pmask FROM kartu_kredit ORDER BY pelanggan_id;