Tiga chapter pertama section ini mengolah data dengan tipe bawaan PostgreSQL. Sekarang kita mulai mendesain tipe sendiri, dan bentuk paling sederhananya adalah composite type — tipe berisi kumpulan field bernama, persis seperti satu baris tabel, tetapi dipakai sebagai nilai kolom. Kasus nyatanya: setiap pelanggan Toko Maju Jaya punya alamat dengan jalan, kota, dan kode_pos. Ketiga potong itu selalu dibaca bersama dan tidak pernah berdiri sendiri, jadi memampatnya menjadi satu kolom bertipe alamat membuat skema terbaca lebih jernih daripada tiga kolom lepas atau satu kolom text[] tanpa nama.

Membuat Tipe Composite dan Kolomnya #

Tipe composite dibuat dengan CREATE TYPE ... AS yang mendata nama dan tipe setiap field. Setelah itu nama tipe dipakai seperti tipe biasa dalam CREATE TABLE:

sql buat-tipe.sql
-- tipe composite: kumpulan field dengan nama, dibuat lewat CREATE TYPE
CREATE TYPE alamat AS (
    jalan    text,
    kota     text,
    kode_pos text
);

-- kolom bertipe composite menyimpan SELURUH struktur dalam satu nilai
CREATE TABLE pelanggan (
    id      integer PRIMARY KEY,
    nama    text NOT NULL,
    rumah   alamat
);

INSERT INTO pelanggan (id, nama, rumah) VALUES
    (1, 'Budi', ROW('Jl. Mawar 10', 'Bandung', '40115')),
    (2, 'Siti', ('Jl. Melati 5', 'Surabaya', '60111')),
    (3, 'Dewi', ROW('Jl. Kenanga 3', 'Bogor', NULL));

SELECT id, nama, rumah
FROM pelanggan
ORDER BY id;

Output yang Dihasilkan:

text
CREATE TYPE
CREATE TABLE
INSERT 0 3
 id | nama |              rumah              
----+------+---------------------------------
  1 | Budi | ("Jl. Mawar 10",Bandung,40115)
  2 | Siti | ("Jl. Melati 5",Surabaya,60111)
  3 | Dewi | ("Jl. Kenanga 3",Bogor,)
(3 rows)

CREATE TYPE alamat AS (...) mendefinisikan tipe baru bernama alamat dengan tiga field text. Kolom rumah pada tabel pelanggan lalu menyimpan seluruh struktur dalam satu nilai. Pengisian nilai memakai constructor ROW(...), dan psql menampilkannya sebagai tuple ("Jl. Mawar 10",Bandung,40115). Bentuk ('Jl. Melati 5','Surabaya','60111') tanpa kata ROW sama sahnya — PostgreSQL menyisipkan constructor secara implisit. Baris Dewi memperlihatkan field kode_pos yang boleh NULL di tengah nilai yang lainnya.

Mengakses Field: Kurung Wajib #

Field dari kolom composite diakses dengan sintaks (kolom).field — tanda kurung di sekitar nama kolom bukan gaya, melainkan keharusan sintaks:

sql akses-field.sql
-- akses field: (kolom).field -- KURUNG di sekitar nama kolom WAJIB
SELECT id, nama, (rumah).kota AS kota, (rumah).kode_pos AS kode_pos
FROM pelanggan
ORDER BY id;

-- field composite bisa langsung menjadi kondisi WHERE
SELECT id, nama
FROM pelanggan
WHERE (rumah).kota = 'Bandung'
ORDER BY id;

-- nilai composite dibandingkan SELURUHNYA dengan ROW() bertipe sama
SELECT id, nama
FROM pelanggan
WHERE rumah = ROW('Jl. Mawar 10', 'Bandung', '40115')::alamat
ORDER BY id;

-- membangun nilai composite mandiri di luar tabel
SELECT ROW('Jl. Anggrek 7', 'Depok', '16411')::alamat AS alamat_kantor;

Output yang Dihasilkan:

text
 id | nama |   kota   | kode_pos 
----+------+----------+----------
  1 | Budi | Bandung  | 40115
  2 | Siti | Surabaya | 60111
  3 | Dewi | Bogor    | 
(3 rows)

 id | nama 
----+------
  1 | Budi
(1 row)

 id | nama 
----+------
  1 | Budi
(1 row)

         alamat_kantor         
-------------------------------
 ("Jl. Anggrek 7",Depok,16411)
(1 row)

(rumah).kota mengekstrak satu field, bisa dipakai di daftar SELECT maupun di klausa WHERE seperti kolom biasa. Perbandingan bisa dilakukan dua arah: per field seperti di query kedua, atau seluruh nilai sekaligus seperti di query ketiga — rumah = ROW(...)::alamat benar hanya bila ketiga field sama persis, termasuk NULL pada kode_pos yang dianggap sama. Query keempat membangun nilai composite mandiri tanpa tabel — bentuk yang nanti sering dipakai sebagai argumen dan nilai balik function.

Kenapa kurung wajib? Tanpa kurung, parser SQL membaca rumah.kota sebagai kombinasi tabel.kolom, bukan kolom.field:

-- tanpa kurung, PostgreSQL membaca "rumah" sebagai nama TABEL, bukan kolom
SELECT rumah.kota FROM pelanggan;

Output yang Dihasilkan:

text
psql:akses-tanpa-kurung.sql:2: ERROR:  missing FROM-clause entry for table "rumah"
LINE 1: SELECT rumah.kota FROM pelanggan;
               ^

Error missing FROM-clause entry for table "rumah" memperlihatkan kebingungan parser: PostgreSQL mencari tabel bernama rumah di klausa FROM dan tidak menemukannya. Aturan praktisnya sederhana: akses field selalu ditulis (kolom).field — kecuali di satu tempat yang kita bahas berikutnya.

Memperbarui Field dan Seluruh Nilai #

Di statement UPDATE, sintaks membalak arah: satu field diperbarui tanpa kurung lewat SET kolom.field = nilai, karena di posisi SET parser sudah tahu bahwa yang disebut adalah kolom:

-- di UPDATE, satu field diperbarui TANPA kurung: SET kolom.field = nilai
UPDATE pelanggan SET rumah.kota = 'Bogor Selatan'
WHERE id = 1;

SELECT id, nama, (rumah).kota AS kota
FROM pelanggan
WHERE id = 1;

-- atau seluruh nilai composite diganti sekaligus
UPDATE pelanggan
SET rumah = ROW('Jl. Melati 99', 'Surabaya', '60119')::alamat
WHERE id = 2;

SELECT id, nama, rumah
FROM pelanggan
WHERE id <= 2
ORDER BY id;

Output yang Dihasilkan:

text
UPDATE 1
 id | nama |     kota      
----+------+---------------
  1 | Budi | Bogor Selatan
(1 row)

UPDATE 1
 id | nama |                 rumah                  
----+------+----------------------------------------
  1 | Budi | ("Jl. Mawar 10","Bogor Selatan",40115)
  2 | Siti | ("Jl. Melati 99",Surabaya,60119)
(2 rows)

UPDATE ... SET rumah.kota = 'Bogor Selatan' mengubah satu field dan membiarkan jalan serta kode_pos tetap utuh — jauh lebih ringkas daripada menulis ulang ROW(...) penuh. Query kedua menunjukkan sisi sebaliknya: mengganti seluruh nilai composite sekaligus dengan constructor ROW(...)::alamat, yang menimpa ketiga field lamanya.

Mengubah Struktur Tipe: ALTER TYPE #

Struktur composite type bisa berkembang setelah dipakai — field baru ditambahkan dengan ADD ATTRIBUTE, field lama dibuang dengan DROP ATTRIBUTE:

sql alter-tipe.sql
-- menambah field baru: baris lama mendapat NULL untuk field tersebut
ALTER TYPE alamat ADD ATTRIBUTE provinsi text;

SELECT id, nama, rumah
FROM pelanggan
ORDER BY id;

-- menghapus field dari tipe
ALTER TYPE alamat DROP ATTRIBUTE kode_pos;

SELECT id, nama, rumah
FROM pelanggan
ORDER BY id;

Output yang Dihasilkan:

text
ALTER TYPE
 id | nama |                  rumah                  
----+------+-----------------------------------------
  1 | Budi | ("Jl. Mawar 10","Bogor Selatan",40115,)
  2 | Siti | ("Jl. Melati 99",Surabaya,60119,)
  3 | Dewi | ("Jl. Kenanga 3",Bogor,,)
(3 rows)

ALTER TYPE
 id | nama |               rumah               
----+------+-----------------------------------
  1 | Budi | ("Jl. Mawar 10","Bogor Selatan",)
  2 | Siti | ("Jl. Melati 99",Surabaya,)
  3 | Dewi | ("Jl. Kenanga 3",Bogor,)
(3 rows)

ADD ATTRIBUTE provinsi text hanya mengubah katalog tipe, bukan menulis ulang data — semua baris yang sudah ada langsung “memiliki” field baru bernilai NULL, terlihat dari trailing kosong di tuple Budi dan Siti. DROP ATTRIBUTE kode_pos bekerja ke arah sebaliknya: field lenyap dari tipe, dan nilai yang pernah tersimpan di dalamnya tidak lagi bisa diakses. Karena perubahan ini menyentuh definisi tipe yang dipakai kolom rumah, seluruh tabel pengguna tipe tersebut melihat perubahan yang sama seketika.

Composite di Tabel atau Tabel Terpisah #

Kapan kolom composite mengalahkan tabel terpisah? Aturan praktisnya: composite untuk nilai, tabel untuk relasi:

KebutuhanPilihan tepatAlasan
Nilai yang selalu dibaca-tulis utuh (alamat, ukuran barang)Composite typeSatu kolom satu makna; tidak pernah di-query lintas induk
Snapshot yang membeku (alamat saat pesanan dibuat)Composite typeData historis tidak berubah dan tak butuh identitas sendiri
Field perlu index, JOIN, atau validasi FK ke tabel referensiTabel terpisahField composite tidak bisa jadi foreign key; index-nya harus berbentuk expression index
Butuh mencari “semua kota yang punya pelanggan” atau metadata per itemTabel terpisahHubungannya berdiri sendiri dan layak dianalisis lintas baris

Inspeksi lewat psql dan Katalog #

psql menyediakan \dT untuk mendaftar tipe, dan \d memperlihatkan kolom bertipe composite apa adanya. Untuk skrip yang butuh bentuk query, katalog sistem pg_attribute memuat field-field setiap composite type:

-- \dT mendaftar tipe di schema saat ini
\dT alamat

-- \d memperlihatkan kolom yang bertipe composite
\d pelanggan

-- katalog sistem: field-field sebuah composite type
SELECT a.attname AS field,
       format_type(a.atttypid, a.atttypmod) AS tipe_field
FROM pg_attribute a
WHERE a.attrelid = 'alamat'::regclass
  AND a.attnum > 0
  AND NOT a.attisdropped
ORDER BY a.attnum;

Output yang Dihasilkan:

text
      List of data types
 Schema |  Name  | Description 
--------+--------+-------------
 public | alamat | 
(1 row)

             Table "public.pelanggan"
 Column |  Type   | Collation | Nullable | Default 
--------+---------+-----------+----------+---------
 id     | integer |           | not null | 
 nama   | text    |           | not null | 
 rumah  | alamat  |           |          | 
Indexes:
    "pelanggan_pkey" PRIMARY KEY, btree (id)

  field   | tipe_field 
----------+------------
 jalan    | text
 kota     | text
 provinsi | text
(3 rows)

\dT alamat mengonfirmasi tipe ada di schema public, dan \d pelanggan menampilkan kolom rumah dengan tipe alamat — di mata psql, kolom ini tidak berbeda dari kolom integer atau text. Query katalog menutup file: pg_attribute untuk relasi alamat (composite type memang tersimpan sebagai relasi khusus di pg_class) mendata tiga field yang tersisa setelah kode_pos dihapus, lengkap dengan tipe masing-masing hasil format_type().

Composite type mengemas banyak field menjadi satu nilai. Tipe kustom berikutnya justru membatasi nilai ke daftar tertutup: enum — misalnya status pesanan yang hanya boleh baru, diproses, dikirim, atau selesai, lengkap dengan urutan yang ikut kita definisikan.

Referensi #

Berkas Sumber

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

akses-field.sql

Unduh berkas
sql
-- akses field: (kolom).field -- KURUNG di sekitar nama kolom WAJIB
SELECT id, nama, (rumah).kota AS kota, (rumah).kode_pos AS kode_pos
FROM pelanggan
ORDER BY id;

-- field composite bisa langsung menjadi kondisi WHERE
SELECT id, nama
FROM pelanggan
WHERE (rumah).kota = 'Bandung'
ORDER BY id;

-- nilai composite dibandingkan SELURUHNYA dengan ROW() bertipe sama
SELECT id, nama
FROM pelanggan
WHERE rumah = ROW('Jl. Mawar 10', 'Bandung', '40115')::alamat
ORDER BY id;

-- membangun nilai composite mandiri di luar tabel
SELECT ROW('Jl. Anggrek 7', 'Depok', '16411')::alamat AS alamat_kantor;

akses-tanpa-kurung.sql

Unduh berkas
sql
-- tanpa kurung, PostgreSQL membaca "rumah" sebagai nama TABEL, bukan kolom
SELECT rumah.kota FROM pelanggan;

alter-tipe.sql

Unduh berkas
sql
-- menambah field baru: baris lama mendapat NULL untuk field tersebut
ALTER TYPE alamat ADD ATTRIBUTE provinsi text;

SELECT id, nama, rumah
FROM pelanggan
ORDER BY id;

-- menghapus field dari tipe
ALTER TYPE alamat DROP ATTRIBUTE kode_pos;

SELECT id, nama, rumah
FROM pelanggan
ORDER BY id;

buat-tipe.sql

Unduh berkas
sql
-- tipe composite: kumpulan field dengan nama, dibuat lewat CREATE TYPE
CREATE TYPE alamat AS (
    jalan    text,
    kota     text,
    kode_pos text
);

-- kolom bertipe composite menyimpan SELURUH struktur dalam satu nilai
CREATE TABLE pelanggan (
    id      integer PRIMARY KEY,
    nama    text NOT NULL,
    rumah   alamat
);

INSERT INTO pelanggan (id, nama, rumah) VALUES
    (1, 'Budi', ROW('Jl. Mawar 10', 'Bandung', '40115')),
    (2, 'Siti', ('Jl. Melati 5', 'Surabaya', '60111')),
    (3, 'Dewi', ROW('Jl. Kenanga 3', 'Bogor', NULL));

SELECT id, nama, rumah
FROM pelanggan
ORDER BY id;

inspeksi-tipe.sql

Unduh berkas
sql
-- \dT mendaftar tipe di schema saat ini
\dT alamat

-- \d memperlihatkan kolom yang bertipe composite
\d pelanggan

-- katalog sistem: field-field sebuah composite type
SELECT a.attname AS field,
       format_type(a.atttypid, a.atttypmod) AS tipe_field
FROM pg_attribute a
WHERE a.attrelid = 'alamat'::regclass
  AND a.attnum > 0
  AND NOT a.attisdropped
ORDER BY a.attnum;

update-field.sql

Unduh berkas
sql
-- di UPDATE, satu field diperbarui TANPA kurung: SET kolom.field = nilai
UPDATE pelanggan SET rumah.kota = 'Bogor Selatan'
WHERE id = 1;

SELECT id, nama, (rumah).kota AS kota
FROM pelanggan
WHERE id = 1;

-- atau seluruh nilai composite diganti sekaligus
UPDATE pelanggan
SET rumah = ROW('Jl. Melati 99', 'Surabaya', '60119')::alamat
WHERE id = 2;

SELECT id, nama, rumah
FROM pelanggan
WHERE id <= 2
ORDER BY id;