Designing a database schema for a budget tracker with Automigrate

March 5, 2024

Today, I will guide you through the step-by-step process of creating a database schema for a simple app in Clojure using Automigrate. Automigrate is a Clojure tool designed to streamline the modeling and modification of database schemas through EDN-structures and auto-generated migrations. The aim of this article is to demonstrate the fundamental features of Automigrate through an example that closely resembles a real-world application.

Auto-generated migrations offer significant advantages when designing a database schema for an application. This declarative approach allows for rapid modifications to the schema based on changes in the models, eliminating the need to manually describe the changes. Instead, you simply define the desired state, and Automigrate handles the rest. This focus on the domain logic of the app means you can maintain a clear understanding of the database schema without needing a direct connection to the database. Moreover, in situations where you wish to experiment with different solutions locally, auto-generated migrations offers backward migrations, enabling you to apply alternative modifications with ease.

Currently, Automigrate supports only PostgreSQL (with plans to support other databases). For the purposes of this article, we will utilize PostgreSQL.

A project idea

Suppose we aim to create a simple personal budget tracker application. The main goal is to monitor expenses and income while maintaining an accurate balance. We would like to have multiple budgets per user with settings such as currency and a set of custom transaction categories. We want to be able to assign a category to transaction.

From a database perspective, we would likely require the following entities: account (or users), budget, transaction and category. Simple relationships between database entities can be represented by following diagram:

DB simple diagram

We will incrementally add more detail to these entities and explore how Automigrate facilitates this process.

Setup

Clone example project

To simplify the process of reproducing steps from this article you can use the example setup directory from the tool's repository.

$ git clone git@github.com:abogoyavlensky/automigrate.git
...

$ cd automigrate/examples/empty

The directory already contains a minimal setup, including an empty models.edn file. To proceed, you'll need Docker installed on your local machine, which can be done by following the official guide.

Note: as of the time this article was written the latest version of Automigrate is 0.3.2.

Run database

Following the initial setup, let's verify that we can execute Automigrate commands. First, we'll build the Docker image of the demo service using Docker Compose and then run the database service:

$ docker compose build demo
$ docker compose up -d db

Next, let's confirm that we can get an empty list of migrations:

$ docker compose run --rm demo clojure -X:migrations list
Migrations not found.

Now that we're set up, we're ready to follow all the commands presented in this guide. For convenience, let's access a shell inside the container that contains the example project:

$ docker compose run --rm demo /bin/bash

All subsequent commands will be executed within the container of the demo service.

Database viewer (optional)

Adminer is a convenient database management tool that we can use to inspect actual database schema changes:

$ docker compose up -d adminer

Ensure that port 8081 is available to run the web interface for the database. Next, verify that you can log into Adminer and view the empty state of the database.

Use demo as the value for the username, password, and database name.

Login to Adminer

Now we can verify that database is empty:

Empty DB state

A first model

Let's begin by creating a model for accounts. To keep things straightforward, this table will only include an id, username, password, and a couple of date fields to track the time of changes. The model might look like this:

DB diagram account

Add a model

To add this model open file models.edn and add following:

{:account [[:id :serial {:primary-key true}]
           [:username [:varchar 255] {:null false
                                      :unique true}]
           [:password [:varchar 255] {:null false}]
           [:updated-at :timestamp {:default [:now]}]
           [:created-at :timestamp {:default [:now]}]]}

The models file should contain a map where the keys are the model names, which will correspond to table names in the database. The value for each key can either be a map with keys :fields, :indexes, :types, or simply a vector if there are only fields. In this scenario, we can opt for the simplified version and define a vector of fields directly without using a map.

A field definition is a vector consisting of three elements: the field name (which will be the column name in the database), the field type (which has a direct mapping to database column types, with PostgreSQL being used in this guide), and an optional map with different field options.

Accordingly, we added id and designated it as the primary key. We determined that username should be a variable character field with a length of 255, set to be unique and not null. The date fields are timestamps, automatically set to the current date by default at the moment a record is created in the database.

Make a migration

After introducing a new model we can generate our first migration:

$ clojure -X:migrations make
Created migration: migrations/0001_auto_create_table_account.edn
Actions:
  - create table account

This migration is generated automatically. The name of the migration file reflects the first action in the migration, in this case, the creation of the account table.

List existing migrations

Now, we can check the list of migrations and observe that our migration hasn't been applied yet. This is indicated by an empty "box" next to the migration name:

$ clojure -X:migrations list
Existing migrations:
[ ] 0001_auto_create_table_account.edn

We also have the option to review the SQL for the migration by executing explain command and providing a :number argument with the migration number:

$ clojure -X:migrations explain :number 1
SQL for forward migration 0001_auto_create_table_account.edn:

BEGIN;

CREATE TABLE account (
  id SERIAL CONSTRAINT account_pkey PRIMARY KEY,
  username VARCHAR(255) CONSTRAINT account_username_key UNIQUE NOT NULL,
  password VARCHAR(255) NOT NULL,
  updated_at TIMESTAMP DEFAULT NOW(),
  created_at TIMESTAMP DEFAULT NOW()
);

COMMIT;

Apply migration to the database

We are now prepared to execute our first migration and apply the changes to the database:

$ clojure -X:migrations migrate
Applying 0001_auto_create_table_account...
0001_auto_create_table_account successfully applied.

Let's verify the list of migrations again. An x inside the "box" indicates that the migration has been successfully applied:

$ clojure -X:migrations list
Existing migrations:
[x] 0001_auto_create_table_account.edn

Verify changes in the database

Now, let's examine the actual changes made to the database. We should find two tables: account and automigrate_migrations. The latter is a technical table used to track the status of applied migrations. Automigrate creates this table automatically, and its name can be customized through the tool's configuration.

DB tables account

We can observe that, at this point, only one migration has been applied:

Db migrations account

Finally, we can inspect the account table in the database to confirm the changes:

DB scheme account

This step demonstrates the ease with which Automigrate allows for the application of database schema changes, facilitating a smooth development process for our simple application.

Add a column

At some point, we realized it would be beneficial to include an email address for each account. To do this, we simply add the new field to the model:

 {:account [[:id :serial {:primary-key true}]
            [:username [:varchar 255] {:null false
                                       :unique true}]
            [:password [:varchar 255] {:null false}]
+           [:email [:varchar 255]]
            [:updated-at :timestamp {:default [:now]}]
            [:created-at :timestamp {:default [:now]}]]}

Next, we execute make and migrate commands:

$ clojure -X:migrations make
Created migration: migrations/0002_auto_add_column_email_to_account.edn
Actions:
  - add column email to account
$ clojure -X:migrations migrate
Applying 0002_auto_add_column_email_to_account...
0002_auto_add_column_email_to_account successfully applied.

Just like that, the new column email is added to the database:

DB scheme account with email

Foreign Key and Index

To store different budgets and settings for them, we can create a budget table. Budget contains title and currency and also a reference to the account. The structure can be visualized as follows:

DB diagram budget

Given that a user can have multiple budgets, a Foreign Key on the account table by id is necessary. Additionally, to ensure uniqueness within a user's set of budgets—since different users might assign identical names to their budgets but within a single user's scope each budget name must be unique—we will implement a unique index on the account and title.

The modifications to the models are as follows:

{...
 :budget {:fields [[:id :serial {:primary-key true}]
                   [:owner-id :integer {:foreign-key :account/id
                                        :on-delete :cascade
                                        :null false}]
                   [:title [:varchar 255] {:null false}]
                   [:currency [:varchar 3] {:null false}]
                   [:updated-at :timestamp {:default [:now]}]
                   [:created-at :timestamp {:default [:now]}]]
          :indexes [[:budget-owner-title-unique-idx
                     :btree
                     {:fields [:owner-id :title]
                      :unique true}]]}}

The structure of index definition is similar to a field, but options in the third argument are mandatory and contain index-specific things: set of fields :fields for index and uniqueness flag :unique. There is also possible to make a partial index by using :where option with HoneySQL syntax in it.

To generate and apply the migration to the database, we execute:

$ clojure -X:migrations make
Created migration: migrations/0003_auto_create_table_budget_etc.edn
Actions:
  - create table budget
  - create index budget_owner_title_unique_idx on budget
$ clojure -X:migrations migrate
Applying 0003_auto_create_table_budget_etc...
0003_auto_create_table_budget_etc successfully applied.

The budget table is now established in the database, complete with the specified index and Foreign Key as outlined:

DB scheme budget

Check, Enum, Comment

In our final step, we aim to add tables for categories and transactions. To ensure distinct categories for different budgets, the category table will include a Foreign Key referencing budget. Similarly, transactions will reference a specific budget to track expenses accurately. Categories will be distinguished between spending and income types, leading to the following comprehensive database schema:

DB scheme budget

To implement this schema we introduce following changes to our models.edn:

{...
 :category {:fields [[:id :serial {:primary-key true}]
                     [:budget-id :integer {:foreign-key :budget/id
                                           :on-delete :cascade
                                           :null false}]
                     [:title [:varchar 255] {:null false}]
                     [:icon [:varchar 255]]
                     [:tx-type
                      [:enum :tx-type-enum]
                      {:default "spending"
                       :null false
                       :comment "Transaction direction"}]
                     [:updated-at :timestamp {:default [:now]}]
                     [:created-at :timestamp {:default [:now]}]]
            :types [[:tx-type-enum :enum {:choices ["spending" "income"]}]]
            :indexes [[:category-account-title-tx-type-unique-idx
                       :btree
                       {:fields [:budget-id :title :tx-type]
                        :unique true}]]}

 :transaction [[:id :serial {:primary-key true}]
               [:budget-id :integer {:foreign-key :budget/id
                                     :on-delete :cascade
                                     :null false}]
               [:category-id :integer {:foreign-key :category/id
                                       :on-delete :cascade
                                       :null false}]
               [:amount [:numeric 12 2] {:null false
                                         :check [:<> :amount 0]}]
               [:note [:varchar 255]]
               [:updated-at :timestamp {:default [:now]}]
               [:created-at :timestamp {:default [:now]}]]}

For transactions, the amount field uses the numeric type (in Postgresql the same as "decimal") for precise storage. The amount can be either positive or negative, but cannot be zero. This validation is enforced using a Check Constraint [:<> :amount 0] with HoneySQL syntax.

For category, we should define transaction type, and we used custom Enum type with possible values: spending, income. The structure of custom type definition is also similar to a field definition, but options are required. So we need to add Enum type definition in :types key of the model. Then we can use it as a value for :tx-type field definition.

In the category model, we define the transaction type using a custom Enum type with possible values: spending, income. The structure for defining custom types is akin to field definitions but requires specifying options. Therefore, we need to add the Enum type definition under the :types key of the model before using it in the :tx-type field definition.

To clarify the meaning of the :tx-type field of category model we added a comment to the field. This comment will be displayed in the database as well.

Let's proceed with the migration and apply it:

$ clojure -X:migrations make
Created migration: migrations/0004_auto_create_type_tx_type_enum_etc.edn
Actions:
  - create type tx_type_enum
  - create table category
  - create table transaction
  - create index category_account_title_tx_type_unique_idx on category
$ clojure -X:migrations migrate
Applying 0004_auto_create_type_tx_type_enum_etc...
0004_auto_create_type_tx_type_enum_etc successfully applied.

Now, we can check the status of all existing migrations and confirm that all have been applied:

$ clojure -X:migrations list
Existing migrations:
[x] 0001_auto_create_table_account.edn
[x] 0002_auto_add_column_email_to_account.edn
[x] 0003_auto_create_table_budget_etc.edn
[x] 0004_auto_create_type_tx_type_enum_etc.edn

We can then verify that the database has been updated according to the changes made to the models, showcasing the versatility and power of Automigrate in handling complex database schema designs:

DB scheme category

DB scheme transaction

Overview

Throughout this guide, we've explored how to model and modify a database schema within a Clojure application using Automigrate. This library allows developers to concentrate on the domain-specific aspects of their applications without the need to divert attention to SQL. While Automigrate is continually being developed and enhanced, it already offers capabilities for managing tables, indexes, column-level constraints, and enum types, including comprehensive support for backward migrations. One of the primary advantages of Automigrate is the visibility it provides into the database schema through models.

This brief introduction aimed to highlight the main benefits of Automigrate and demonstrate its basic utility. Thank you for your attention, and I hope you found this guide informative!