A small example app to show how to program business web app back-ends in SQL with PostgreSQL.
It's an app to manage timesheets. Basically, you set who works on what and when. The people who work on stuff are called consultants. The stuff they work on is called projects. One consultant works on one project at a time, and can only work on one project each day.
The challenge of building business apps is to model the business data and processes, and to keep up with the evolution of those. They are information processing apps. So it might be a good idea to use a high-level, data-manipulation-specific programming language. SQL is also very often already part of the stack anyway!
⚠ I'm referring to SQL, not PL/pgSQL (aka stored procedures).
- Tables are defined using SQL migration files, as usual for a typical business app.
- Business operations are written as SQL functions.
- HTTP endpoints are also written as SQL functions. Since the database does not talk HTTP, these are not actual endpoints, only functions that accept an HTTP request-like data structure and return an HTTP response-like data structure.
- A tiny, generic Node.js web server exposes those SQL functions as actual HTTP endpoints.
You'll need: Docker, Node.js, psql. To run API tests you'll also need hurl.
- Copy
.env.example
to.env
. - Run the database using
docker compose up -d
. - Deploy the app to the database using
sh scripts/deploy.sh
. - Optional: insert some data using
sh scripts/insert_sample_data.sh
. - Install dependencies for the web server using
npm install
. - Start the web server using
npm start
. - Call the HTTP endpoints using your favorite HTTP client. Take a look at the
API tests in
test/api
to see what endpoints are available and how to use them. They are written in Hurl.
- For API tests:
sh scripts/run_api_tests.sh
. - For SQL unit tests:
sh scripts/run_sql_tests.sh
.
SQL has no assert function. However, something similar can be achieved using
table constraints. See the assert_true
function in test/sql/assert_true.sql
.
See also test/sql/assert_equal_timesheet_day.sql
for a more complex assertion
that compares two rows of a table.
These files are meant to be run using
psql (psql --file=...
). They contain plain SQL along with psql meta-commands.
Defining code entities (functions, types, etc.) in conventional languages is declarative. This is possible because compilers are stateless one-off processes which start from scratch every time. This is different in SQL. The database is both the compiler and the program, and it is always running. Code entities are defined imperatively. In addition, some code entities, like tables, contain data and cannot be dropped and recreated once they have been used in production.
In typical apps, the solution to this problem is to use a migration tool that applies incremental changes to the database. Of course, it would be possible to use this kind of tool for an app entirely written in SQL, but it wouldn't work well with usual version control tools like git.
Consider a SQL function that has been defined in a migration file and deployed to production. When the code of this function must be inevitably patched down the line, a new migration file must be written with the new version of the function. Since git sees this as a new file, it cannot help with diffing the two versions of the function and that makes it hard to tell what's been changed.
In craql, I've tried to use the right tool for the right job: for tables and entities on which tables depend, I use a migration tool. Other entities are dropped and recreated every time the code is deployed.
There is still a big challenge to consider: the order of the dropping and creating. Consider a function A that calls a function B. SQL doesn't allow B to be dropped if A exists nor can A be created before B.
My solution is to have that order be explicit in the source code.
src/create.psql
is a script that creates all stateless code entities.
src/drop.psql
does the reverse. When deploying, src/drop.psql
is run first,
then migrations are applied, then src/create.psql
is run.
A custom one I wrote in SQL, obviously ;). It loads the migration files into a
temporary table in the database, then computes (in SQL) which ones have not been
already applied, then applies those (using \gexec
from psql). It also checks
that migration file corresponding to migrations that have already been applied
in the past have not changed since. See scripts/migrate.psql
.
No. This uses SQL only supported by PostgreSQL 14 and later.