PostgreSQL Deep Dive Part 4 — Mastering Enums and Domains
Dive into PostgreSQL's enums and domains. Learn how to implement domain-driven design principles directly in your database architecture.
Published At
12/26/2024
Reading Time
~ 6 min read
Access complete, unfiltered study notes maintained on GitHub for easily maintaing Second Brain Dir
In our journey through PostgreSQL's type system, we've explored the built-in types that form the foundation of database design. Now, let's venture into territory that truly sets PostgreSQL apart: its ability to extend the type system through domains, and enums. This capability isn't just a feature—it's a gateway to implementing true domain-driven design at the database level.
The Power of ENUM Types: Beyond Simple Constants 🔄
Let's start with a real-world scenario. Imagine you're building an e-commerce platform where orders flow through various states. You could use strings or integers to represent these states, but ENUMs provide a more robust solution.
-- First, let's define our order states
CREATE TYPE order_status AS ENUM (
'pending',
'processing',
'shipped',
'delivered',
'cancelled',
'returned'
);
-- Create our orders table using the enum
CREATE TABLE orders (
order_id SERIAL PRIMARY KEY,
status order_status NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO orders (status) VALUES
('pending'),
('processing');
-- Throws error if status values does not match with ENUM values
-- First, let's define our order states
CREATE TYPE order_status AS ENUM (
'pending',
'processing',
'shipped',
'delivered',
'cancelled',
'returned'
);
-- Create our orders table using the enum
CREATE TABLE orders (
order_id SERIAL PRIMARY KEY,
status order_status NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO orders (status) VALUES
('pending'),
('processing');
-- Throws error if status values does not match with ENUM values
The Hidden Power of ENUM Ordering
One often-overlooked feature of ENUMs is their natural ordering based on the declaration sequence:
-- Ordering of table will happen based on enum position
INSERT INTO orders (status) VALUES
('pending'),
('processing'),
('shipped'),
('delivered'),
('cancelled'),
('returned'),
('pending'),
('processing'),
('shipped'),
('cancelled');
SELECT
order_id,
status,
FROM orders
ORDER BY status;
--| order_id | status |
--|----------|------------|
--| 9 | pending |
--| 1 | pending |
--| 3 | pending |
--| 10 | processing |
--| 4 | processing |
--| 2 | processing |
--| 11 | shipped |
--| 5 | shipped |
--| 6 | delivered |
--| 12 | cancelled |
--| 7 | cancelled |
--| 8 | returned |
-- Here ordering is not alphabetical order but on the order of ENUM defined
-- Ordering of table will happen based on enum position
INSERT INTO orders (status) VALUES
('pending'),
('processing'),
('shipped'),
('delivered'),
('cancelled'),
('returned'),
('pending'),
('processing'),
('shipped'),
('cancelled');
SELECT
order_id,
status,
FROM orders
ORDER BY status;
--| order_id | status |
--|----------|------------|
--| 9 | pending |
--| 1 | pending |
--| 3 | pending |
--| 10 | processing |
--| 4 | processing |
--| 2 | processing |
--| 11 | shipped |
--| 5 | shipped |
--| 6 | delivered |
--| 12 | cancelled |
--| 7 | cancelled |
--| 8 | returned |
-- Here ordering is not alphabetical order but on the order of ENUM defined
Modifying ENUM Types
Modifying ENUMs requires special consideration:
-- Add a new value at the end of enum list
ALTER TYPE order_status ADD VALUE 'payment_pending';
-- Add values at specific positions in ENUM
ALTER TYPE order_status ADD VALUE 'payment_done' BEFORE 'processing';
ALTER TYPE order_status ADD VALUE 'processing_done' AFTER 'processing';
-- View all enum values
SELECT * FROM pg_catalog.pg_enum;
-- Display enum ranges
SELECT enum_range(null::mood); -- Show all enum values
SELECT enum_range(null::mood, 'sad'::mood); -- Show values from start to 'sad'
SELECT enum_range('afraid'::mood, 'sad'::mood); -- Show values from 'afraid' to 'sad'
-- Add a new value at the end of enum list
ALTER TYPE order_status ADD VALUE 'payment_pending';
-- Add values at specific positions in ENUM
ALTER TYPE order_status ADD VALUE 'payment_done' BEFORE 'processing';
ALTER TYPE order_status ADD VALUE 'processing_done' AFTER 'processing';
-- View all enum values
SELECT * FROM pg_catalog.pg_enum;
-- Display enum ranges
SELECT enum_range(null::mood); -- Show all enum values
SELECT enum_range(null::mood, 'sad'::mood); -- Show values from start to 'sad'
SELECT enum_range('afraid'::mood, 'sad'::mood); -- Show values from 'afraid' to 'sad'
Domain Types: Adding Business Rules to Data Types 📋
Domains allow us to create custom types with built-in constraints, perfect for enforcing business rules at the data level.
Building a Robust Email Domain
-- Create a comprehensive email domain
CREATE DOMAIN email_address AS TEXT
CHECK (
VALUE ~ '^[a-zA-Z0-9.!#$%&''*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$'
AND length(VALUE) <= 254
);
-- Create a US phone number domain
CREATE DOMAIN us_phone AS TEXT
CHECK (
VALUE ~ '^\+1[0-9]{10}$'
OR VALUE ~ '^[0-9]{10}$'
);
CREATE DOMAIN us_postal_code AS TEXT
CONSTRAINT format CHECK (
VALUE ~ '^\d{5}$' OR VALUE ~ '^\d{5}-\d{4}$'
);
-- Practical application
CREATE TABLE contacts (
id SERIAL PRIMARY KEY,
email email_address NOT NULL,
phone us_phone,
postal us_postal_code NOT NULL,
verified BOOLEAN DEFAULT FALSE
);
-- Create a comprehensive email domain
CREATE DOMAIN email_address AS TEXT
CHECK (
VALUE ~ '^[a-zA-Z0-9.!#$%&''*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$'
AND length(VALUE) <= 254
);
-- Create a US phone number domain
CREATE DOMAIN us_phone AS TEXT
CHECK (
VALUE ~ '^\+1[0-9]{10}$'
OR VALUE ~ '^[0-9]{10}$'
);
CREATE DOMAIN us_postal_code AS TEXT
CONSTRAINT format CHECK (
VALUE ~ '^\d{5}$' OR VALUE ~ '^\d{5}-\d{4}$'
);
-- Practical application
CREATE TABLE contacts (
id SERIAL PRIMARY KEY,
email email_address NOT NULL,
phone us_phone,
postal us_postal_code NOT NULL,
verified BOOLEAN DEFAULT FALSE
);
Domain Constraints in Action
-- Testing domain constraints with examples
INSERT INTO contacts (email, phone, postal) VALUES
('valid@email.com', '+11234567890', '12345'), -- Works
('invalid@', '+11234567890', '12345'), -- Fails email check
('valid@email.com', '123-456-7890', '12345'), -- Fails phone check
('valid@email.com', '+11234567890', '123456'); -- Fails postal check
-- Creating a domain for monetary amounts with business rules
CREATE DOMAIN positive_amount AS NUMERIC
CHECK (
VALUE > 0 -- Must be positive
AND VALUE < 1000000 -- Less than 1 million
AND scale(VALUE) <= 2 -- Max 2 decimal places
);
-- Using the domain in a financial transactions table
CREATE TABLE transactions (
id SERIAL PRIMARY KEY,
amount positive_amount, -- Uses our custom domain
description TEXT
);
-- Testing domain constraints with examples
INSERT INTO contacts (email, phone, postal) VALUES
('valid@email.com', '+11234567890', '12345'), -- Works
('invalid@', '+11234567890', '12345'), -- Fails email check
('valid@email.com', '123-456-7890', '12345'), -- Fails phone check
('valid@email.com', '+11234567890', '123456'); -- Fails postal check
-- Creating a domain for monetary amounts with business rules
CREATE DOMAIN positive_amount AS NUMERIC
CHECK (
VALUE > 0 -- Must be positive
AND VALUE < 1000000 -- Less than 1 million
AND scale(VALUE) <= 2 -- Max 2 decimal places
);
-- Using the domain in a financial transactions table
CREATE TABLE transactions (
id SERIAL PRIMARY KEY,
amount positive_amount, -- Uses our custom domain
description TEXT
);
This deep dive into PostgreSQL's ENUM and Domain illustrates how the database can become an active participant in enforcing your domain model, not just a passive data store.
☔
Do you have any questions, or simply wish to contact me privately? Don't hesitate to shoot me a DM on Twitter.
Have a wonderful day.
Abhishek 🙏