Rewriting your web app in PostgreSQL stored procedures

This program is written almost entirely using PostgreSQL stored procedures. These replace all of the controller, model, and serializer logic that you might find in a typical web app.

The part that’s implemented in Ruby amounts to handling results and passing parameters:

    ok, res = db.call('invoice_paid', 4, 'cash')
    if ok
    	puts 'paid'
    else
    	puts 'error'
    	puts res.inspect
    end

The rest — including serializing to JSON, setting status codes, and building error objects — is done entirely in PostgreSQL’s stored procedure language, plpgsql. This is a superset of the query language that looks a lot like bash.

Here’s an example, from invoice_get:

    js := row_to_json(r) from (
    		select * from store.invoice_view where id = $1
    	) r;
    	status := 200;
    if js is null then
    	js := '{}';
    	status := 404;
    end if;

Validation is done with very specific constraints, like this email regex:

    CREATE TABLE peeps.people (
    	id serial primary key,
    	email text UNIQUE CHECK (email ~ '\A\S+@\S+\.\S+\Z'),
    	name text NOT NULL CHECK (LENGTH(name) > 0),
    	city text,
    	state text,
    	country char(2) REFERENCES peeps.countries(code)
    );

There’s also some more complicated logic, like finding the minimum cost of a ship with at least the given weight:

    create or replace function store.shipcost(char(2), numeric) returns numeric as $$
    declare
    	c numeric;
    begin
    	if $2 = 0 then
    		return 0;
    	end if;
    	if ($2 < 0) or ($2 > 999) then
    		return 1000;
    	end if;
    	select cost into c from store.shipchart
    		where country = $1
    		and weight >= $2
    		order by cost asc limit 1;
    	if c is not null then
    		return c;
    	end if;
    	select cost into c from store.shipchart
    		where country is null
    		and weight >= $2
    		order by cost asc limit 1;
    	if c is not null then
    		return c;
    	end if;
    	return 1001;
    end;

There are triggers to update related records when other records are updated. For example, lineitem_calc.sql calculates the total price of the items sold based on the item’s price and the quantity sold:

    if (tg_op != 'DELETE') then
    		update store.lineitems l
    			set price = l.quantity * i.price
    			from store.items i
    			where l.item_id = i.id
    			and l.id = NEW.id;
    	end if;

Tests on the stored procedures are done in ruby, using a set of fixtures:

    res = DB.exec("SELECT * FROM store.invoice_shipcost(1)")
    assert_equal '6', res[0]['cost']

There are a couple potential benefits to this approach:

  • Enables easily moving between web frameworks, or using something like http://postgrest.org/en/v6.0/ to generate an API from your schema
  • Puts all your data logic in one place. Some logic — eg constraints — is normally handled by the DB anyway. Why not do all of it here?

Potential drawbacks:

  • Ties you very heavily to a particular database implementation, which an ORM gives you relative freedom to change
  • The tooling — eg, for testing or debugging — doesn’t seem great

But probably the most compelling reason — to those who seek this sort of solution — is the minimalism that’s common to other types of framework avoidance. There are still those who prefer writing vanilla Javascript or C to their more modern, complex cousins, and I see an approach in common with that here. But most will still prefer the power of frameworks over the elegance and control of simple solutions like this.