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:

AlasanContoh di dunia nyataSifat kolom yang dipisah
data sensitif perlu dipagarikartu_kredit terpisah dari pelangganakses bisa dibatasi per tabel lewat GRANT
data opsional dan jarang terisihanya 2 dari 100 pelanggan punya kartuhindari tabel lebar yang didominasi NULL
kolom besar dan jarang dibacafoto profil, dokumen byteatabel utama tetap ramping dan cepat dipindai
siklus hidup berbedakartu sering diganti, profil jarangupdate 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:

sql buat-tabel.sql
-- 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:

text
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:

  1. pelanggan_id integer NOT NULL UNIQUE REFERENCES pelanggan (id) — tiga constraint dalam satu baris: REFERENCES memastikan kartu hanya menunjuk pelanggan nyata, UNIQUE memastikan satu pelanggan maksimal satu kartu, dan NOT NULL memastikan setiap kartu punya pemilik.
  2. id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY — identitas internal tabel kartu tetap dibangkitkan server; kolom ini berbeda peran dari pelanggan_id yang menjadi jangkar relasi.
  3. 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:

sql kartu-ganda.sql
-- 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:

text
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:

text
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:

  1. Arah kartu ke pelanggan (variasi pertama) — pelanggan_id NOT NULL menjamin setiap kartu pasti berpemilik; pelanggan tanpa kartu terwakili oleh ketiadaan baris di kartu_kredit.
  2. Arah pelanggan ke kartu (variasi kedua) — kartu_id UNIQUE boleh NULL, sehingga pelanggan tanpa kartu terwakili oleh NULL; sebaliknya, kartu kini bisa tercatat tanpa pemilik karena tidak ada constraint yang memaksa pelanggan merujuknya.
  3. 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:

sql pk-as-fk.sql
-- 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:

text
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:

  1. 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 di pelanggan.id. Kesatu-ke-satu-an dijamin paling ketat: tidak ada kartu tanpa pemilik, tidak ada dua kartu untuk satu pemilik.
  2. Identitas kartu menyatu dengan identitas pemiliknya — nilai pelanggan_id di tabel kartu selalu sama dengan id pemiliknya, sehingga tabel ini tidak punya id sendiri.
  3. ON DELETE CASCADE — bila pemilik dihapus, kartunya ikut terhapus. Query penutup memperlihatkan tabel kartu kosong setelah DELETE 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:

PertimbanganDigabung (kolom di pelanggan)Dipisah (tabel kartu_kredit)
kerapatan NULLkolom kartu kosong bagi mayoritas pelangganhanya pelanggan berkartu yang punya baris
pagar keamanankolom sensitif satu tabel dengan data umumGRANT/REVOKE bisa membatasi per tabel
frekuensi updateganti kartu menulis ulang baris pelangganbaris pelanggan tidak tersentuh
kompleksitas bacacukup satu tabel, tanpa joinlaporan gabungan butuh LEFT JOIN
batas jumlahsulit melarang dua kartu per pelangganUNIQUE 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
sql
-- 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
sql
-- 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
sql
-- 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
sql
-- 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;