Migration Example
If you've not used Sequel before, this recipe will show you the basics of creating migrations, which are how the database schema is managed in Brut.
Feature
- An accounts table will store an email and a deactivated date
- A blog posts table will store a title and content, and be attributed to an account.
Recipe
We'll create the migration, create data models, create and lint factories, then create seed data.
Create the Migration
Create the migration file with bin/db new_migration
:
> bin/db new_migration Accounts and Blog Posts
[ bin/db ] Migration created:
app/src/back_end/data_models/migrations/20250711215310_Accounts-and-Blog-Posts.rb
NOTE
Your filename will be different, since it embeds a timestamp for when bin/db new_migration
was run.
Now, use Sequel's migrations API, keeping in mind Brut's augmentations, to create our tables.
Our tables will use an external ids. Note that Brut will ensure both tables have primary keys and have created_at
fields.
# app/src/back_end/data_models/migrations/20250711215310_Accounts-and-Blog-Posts.rb
Sequel.migration do
up do
create_table :accounts,
comment: "People or systems who can access this system",
external_id: true do
column :email, :text, unique: true
column :deactivated_at, :timestamptz, null: true
end
create_table :blog_posts,
comment: "Posts on our amazing blog",
external_id: true do
column :title, :text
column :content, :text
foreign_key :account_id, :accounts
end
end
end
You can apply this migration with bin/db migrate
:
bin/db migrate
IMPORTANT
This only applied migrations to the dev database. bin/ci
and bin/test e2e
will apply them to the test database, but you may need to do it yourself via bin/db migration -e test
NOTE
There is no down migration. If you need to change and re-apply this before you have promoted it to production, rebuild your dev database with bin/db rebuild
. It will apply all migrations from a fresh, empty database.
You can examine the tables with psql
, via bin/dbconsole
:
> bin/dbconsole
development=# \d accounts
Table "public.accounts"
Column | Type | Collation | Nullable | Default
----------------+--------------------------+-----------+----------+----------------------------------
id | integer | | not null | generated by default as identity
email | text | | not null |
deactivated_at | timestamp with time zone | | |
created_at | timestamp with time zone | | not null |
external_id | citext | | not null |
Indexes:
"accounts_pkey" PRIMARY KEY, btree (id)
"accounts_email_key" UNIQUE CONSTRAINT, btree (email)
"accounts_external_id_key" UNIQUE CONSTRAINT, btree (external_id)
Referenced by:
TABLE "blog_posts" CONSTRAINT "blog_posts_account_id_fkey" FOREIGN KEY (account_id) REFERENCES accounts(id)
development=# \d blog_posts
Table "public.blog_posts"
Column | Type | Collation | Nullable | Default
-------------+--------------------------+-----------+----------+----------------------------------
id | integer | | not null | generated by default as identity
title | text | | not null |
content | text | | not null |
account_id | integer | | not null |
created_at | timestamp with time zone | | not null |
external_id | citext | | not null |
Indexes:
"blog_posts_pkey" PRIMARY KEY, btree (id)
"blog_posts_account_id_index" btree (account_id)
"blog_posts_external_id_key" UNIQUE CONSTRAINT, btree (external_id)
Foreign-key constraints:
"blog_posts_account_id_fkey" FOREIGN KEY (account_id) REFERENCES accounts(id)
Note that all columns are NOT NULL
except deactivated_at
, which we explicitly set as nullable. Note that the foreign key on account_id
has an index and is non-nullable. And note that both tables have external_id
and created_at
columns. Brut will manage their contents.
Create Data Models
Brut doesn't create your data models for you, since it assumes you prefer writing code in your editor and not on the command line. Your data models will initially be pretty short.
# app/src/back_end/data_models/db/account.rb
class DB::Account < AppDataModel
has_external_id :ac
one_to_many :blog_posts
end
# app/src/back_end/data_models/db/blog_post.rb
class DB::BlogPost < AppDataModel
many_to_one :account
end
You can run bin/console
to try to test these, but it's easier to create factories and use the specs/lint_factories.spec.rb
to do that for us.
Create Factories
Factories go in specs/factories/db
and have a .factory.db
suffix:
# specs/factories/db/account.factory.rb
FactoryBot.define do
factory :account, class: "DB::Account" do
email { Faker::Internet.unique.email }
trait :inactive do
deactivated_at { Time.now }
end
end
end
# specs/factories/db/blog_post.factory.rb
FactoryBot.define do
factory :blog_post, class: "DB::BlogPost" do
title { Faker::Lorem.sentence }
content { Faker::Lorem.paragraphs.join("\n\n") }
account
end
end
Note that the blog post factory creates an account. This is so that create(:blog_post)
will always succeeed in creating valid data.
To prove it, we'll lint our factories:
bin/test run specs/lint_factories.spec.rb
This will create every combination of every factory and fail if doing so raises an error.
Create Seed Data
Now, we'll set up seed data. mkbrut
should've created app/src/back_end/data_models/seed/seed_data.rb
, so we'll use that.
require "brut/back_end/seed_data"
class SeedData < Brut::BackEnd::SeedData
include FactoryBot::Syntax::Methods
def seed!
pat = create(:account, email: "pat@example.com")
chris = create(:account, :inactive, email: "chris@example.com")
5.times do
create(:blog_post, account: pat)
create(:blog_post, account: chris)
end
end
end
We can apply this with bin/db seed
:
bin/db seed
IMPORTANT
bin/db rebuild
will not apply seed data, however bin/setup
should. For now, if you want to totally reset your database, you will need to do bin/db rebuild && bin/db seed && bin/db rebuild -e test