This document discusses an experiment to use PostgreSQL as the sole data access layer for a web application by replacing a traditional RESTful API server with PostgREST. PostgREST is a framework that provides a RESTful interface to any PostgreSQL database without requiring additional code or configuration. The document demonstrates PostgREST by connecting to a sample Pagila database and allowing full CRUD operations and filtering through SQL queries alone. It also shows how PostgREST handles authentication, authorization, relations, and versioning directly through PostgreSQL features.
2. Taking the database seriously
Most web frameworks treat
the DB as a dumb store
This helps give them broad
appeal
What if we take a stand
instead?
??
? ?
?
3. Taking the database seriously
Is PostgreSQL powerful and
flexible enough to replace
the custom API server?
That’s my experiment
8. What’s in an app?
HTTP request handling
Authentication
Authorization
Request Parsing
Request Validation
Database Communication
Database Response Handling
HTTP Response Building
With error handling woven
throughout...
9. Maintaining bespoke APIs gets old
“Most APIs look the
same, some have
icing, some have
fondant, some are
vanilla, some
chocolate. At the
core they’re all still
cakes.” -- Jett
Durham
10. Problem 1: Boilerplate
Want to add a new route?
Create model
Add each CRUD action
Check permissions
Support filtering, pagination
Special routes for joining data
11. Problem 2: No Single Source of Truth
Constraints are removed
from DB
No longer enforced
continuously + uniformly
Imperative code means
human must write docs
Authorization is per-
controller rather than
12. Problem 3: Hierarchy
Your info is relational, your routes
hierarchical
Say projects have parts and vice
versa.
Need routes for parts by project
and project by parts?
Other people recognize the
problem, hence GraphQL
13. Demo Time!
We’ll use the Pagila example database
It was ported from MySQL “Sakila”
It’s a DVD store with films, rentals, customers, payments,
categories, actors etc
15. Security - Roles for Authorization
Anonymous Authenticator User(s)
17. Security - Roles in SQL
CREATE ROLE authenticator NOINHERIT LOGIN;
CREATE ROLE anon;
CREATE ROLE worker;
GRANT anon, worker TO authenticator;
18. Switching to a role
BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE;
SET LOCAL ROLE 'worker';
SET LOCAL "postgrest.claims.id" = 'jdoe';
-- ...
COMMIT;
19. Row-Level Security
PostgreSQL 9.5+ allows restricting access to individual rows
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
drop policy if exists authors_eigenedit on posts;
create policy authors_eigenedit on posts
using (true)
with check (
author = basic_auth.current_email()
);
21. External Actions
You can’t do everything
inside SQL
How do you
● Send an email?
● Call a 3rd party
service?
LISTEN / NOTIFY
22. How to version the API?
So far OK but… but I don’t
want to couple the internal
schema with an API!
How to encapsulate true
schema?
How to version specific
endpoints?
26. How does it work inside?
Warning: Boring / Cool
27. Generating the payload in 100% SQL
WITH pg_source AS
(SELECT "public"."festival".* FROM "public"."festival")
SELECT
(SELECT pg_catalog.count(1) FROM "public"."festival") AS total_result_set,
pg_catalog.count(t) AS page_total,
NULL AS header,
array_to_json(array_agg(row_to_json(t)))::character VARYING AS body
FROM
(SELECT * FROM pg_source LIMIT ALL OFFSET 0) t
28. Adding a filter
WITH pg_source AS
(SELECT "public"."festival".* FROM "public"."festival"
WHERE "public"."festival"."name" LIKE '%fun%'::UNKNOWN)
SELECT
(SELECT pg_catalog.count(1) FROM "public"."festival"
WHERE "public"."festival"."name" LIKE '%fun%'::UNKNOWN) AS total_result_set,
pg_catalog.count(t) AS page_total,
NULL AS header,
array_to_json(array_agg(row_to_json(t)))::character varying AS body
FROM
(SELECT * FROM pg_source LIMIT ALL OFFSET 0) t
Optimistic cast
29. Or without a global count
WITH pg_source AS
(SELECT "public"."festival".* FROM "public"."festival")
SELECT
NULL AS total_result_set,
pg_catalog.count(t) AS page_total,
NULL AS header,
array_to_json(array_agg(row_to_json(t)))::character varying AS body
FROM
(SELECT * FROM pg_source LIMIT ALL OFFSET 0) t
30. Creating CSV body
-- ...
(SELECT string_agg(a.k, ',')
FROM
(SELECT json_object_keys(r)::TEXT AS k
FROM
(SELECT row_to_json(hh) AS r
FROM pg_source AS hh LIMIT 1) s
) a
) || 'n' ||
coalesce(
string_agg(
substring(t::text, 2, length(t::text) - 2), 'n'
), ''
)
-- ...
First row
Column names
Remove quotes
31. Embedding a relation
WITH pg_source AS
(SELECT "public"."film"."id", row_to_json("director".*) AS "director"
FROM "public"."film"
LEFT OUTER JOIN
(SELECT "public"."director".*
FROM "public"."director") AS "director"
ON "director"."name" = "film"."director")
SELECT
(SELECT pg_catalog.count(1) FROM "public"."film") AS total_result_set,
pg_catalog.count(t) AS page_total,
NULL AS header,
array_to_json(array_agg(row_to_json(t)))::character varying AS body
FROM
(SELECT * FROM pg_source LIMIT ALL OFFSET 0) t
Embed row as field
Key(s) detected
32. ELSE
CASE
WHEN t.typelem <> 0::oid AND t.typlen = (-1)
THEN 'ARRAY'::text
WHEN nt.nspname = 'pg_catalog'::name THEN
format_type(a.atttypid, NULL::integer)
ELSE 'USER-DEFINED'::text
END
END::information_schema.character_data AS data_type,
information_schema._pg_char_max_length(information_schema._pg_truetypid(a.*,
t.*), information_schema._pg_truetypmod(a.*,
t.*))::information_schema.cardinal_number AS character_maximum_length,
information_schema._pg_char_octet_length(information_schema._pg_truetypid(a.
*, t.*), information_schema._pg_truetypmod(a.*,
t.*))::information_schema.cardinal_number AS character_octet_length,
information_schema._pg_numeric_precision(information_schema._pg_truetypid(a.
*, t.*), information_schema._pg_truetypmod(a.*,
t.*))::information_schema.cardinal_number AS numeric_precision,
information_schema._pg_numeric_precision_radix(information_schema._pg_truety
pid(a.*, t.*), information_schema._pg_truetypmod(a.*,
t.*))::information_schema.cardinal_number AS numeric_precision_radix,
information_schema._pg_numeric_scale(information_schema._pg_truetypid(a.*,
t.*), information_schema._pg_truetypmod(a.*,
t.*))::information_schema.cardinal_number AS numeric_scale,
information_schema._pg_datetime_precision(information_schema._pg_truetypid(a
.*, t.*), information_schema._pg_truetypmod(a.*,
t.*))::information_schema.cardinal_number AS datetime_precision,
information_schema._pg_interval_type(information_schema._pg_truetypid(a.*,
t.*), information_schema._pg_truetypmod(a.*,
t.*))::information_schema.character_data AS interval_type,
NULL::integer::information_schema.cardinal_number AS
interval_precision,
NULL::character varying::information_schema.sql_identifier
AS character_set_catalog,
NULL::character varying::information_schema.sql_identifier
AS character_set_schema,
NULL::character varying::information_schema.sql_identifier
AS character_set_name,
SELECT DISTINCT
info.table_schema AS schema,
info.table_name AS table_name,
info.column_name AS name,
info.ordinal_position AS position,
info.is_nullable::boolean AS nullable,
info.data_type AS col_type,
info.is_updatable::boolean AS updatable,
info.character_maximum_length AS max_len,
info.numeric_precision AS precision,
info.column_default AS default_value,
array_to_string(enum_info.vals, ',') AS enum
FROM (
/*
-- CTE based on information_schema.columns to remove the owner filter
*/
WITH columns AS (
SELECT current_database()::information_schema.sql_identifier AS table_catalog,
nc.nspname::information_schema.sql_identifier AS table_schema,
c.relname::information_schema.sql_identifier AS table_name,
a.attname::information_schema.sql_identifier AS column_name,
a.attnum::information_schema.cardinal_number AS ordinal_position,
pg_get_expr(ad.adbin, ad.adrelid)::information_schema.character_data AS column_default,
CASE
WHEN a.attnotnull OR t.typtype = 'd'::"char" AND t.typnotnull THEN 'NO'::text
ELSE 'YES'::text
END::information_schema.yes_or_no AS is_nullable,
CASE
WHEN t.typtype = 'd'::"char" THEN
CASE
WHEN bt.typelem <> 0::oid AND bt.typlen = (-1) THEN 'ARRAY'::text
WHEN nbt.nspname = 'pg_catalog'::name THEN format_type(t.typbasetype,
NULL::integer)
ELSE 'USER-DEFINED'::text
END
Matching up foreign keys
33. Deleting an item
WITH pg_source AS
(DELETE FROM "test"."items"
WHERE "test"."items"."id" = '1'::unknown
RETURNING "test"."items".*)
SELECT
'' AS total_result_set,
pg_catalog.count(t) AS page_total,
'',
''
FROM
(SELECT 1 FROM pg_source) t