From Rails to Hanami (Lotus) Part 2: Sequel Migrations, Model Validations, Specs and Fixtures
Do or do not. There is no try.
― Master Yoda
Recap
In previous post From Rails to Hanami (Lotus) Part 1: Container Architecture, Models, Views and Assets, I show how was the migration of two Rails applications to a single Hanami container, mounting each application individually, reading from Postgres database and doing all write operation throught the Rails “core” application via REST api.
Now it’s time to migrate the API business logic (models, validations, services, etc) from the Rails application to Hanami. And to ensure that everything works as expected, the specs need to be migrated first. And for the specs run, the database migrations need to run too. So, let the fun begin!
Migrations
Sequel migrations is very similar to Hanami migrations (almost the same, I guess). To create a migration, I manually created the file db/migrations/20160327165432_create_users.rb
with the following:
Sequel.migration do
change do
create_table :users do
primary_key :id
column :email, String, size: 255, null: false, unique: true
column :name, String, size: 255, null: false
end
end
end
And to run the migration, I used the Sequel command line passing the connection string and the directory which contains the migrations as argument to option -m
. Also supplied the -E
option to print all SQL output:
$ sequel postgres://localhost/bookshelf_development -m ./db/migrations -E
(0.000288s) SET standard_conforming_strings = ON
(0.000138s) SET client_min_messages = 'WARNING'
(0.000182s) SET DateStyle = 'ISO'
(0.000769s) SELECT NULL AS "nil" FROM "schema_migrations" LIMIT 1
(0.000221s) SELECT * FROM "schema_migrations" LIMIT 1
(0.000515s) SELECT "filename" FROM "schema_migrations" ORDER BY "filename"
Begin applying migration 20160327165432_create_users.rb, direction: up
(0.000120s) BEGIN
(0.004716s) CREATE TABLE "users" ("id" serial PRIMARY KEY, "email" varchar(255) NOT NULL UNIQUE, "name" varchar(255) NOT NULL)
(0.001198s) SELECT pg_attribute.attname AS pk FROM pg_class, pg_attribute, pg_index, pg_namespace WHERE pg_class.oid = pg_attribute.attrelid AND pg_class.relnamespace = pg_namespace.oid AND pg_class.oid = pg_index.indrelid AND pg_index.indkey[0] = pg_attribute.attnum AND pg_index.indisprimary = 't' AND pg_class.oid = CAST(CAST('"schema_migrations"' AS regclass) AS oid)
(0.000238s) INSERT INTO "schema_migrations" ("filename") VALUES ('20160327165432_create_users.rb') RETURNING NULL
(0.000478s) COMMIT
Finished applying migration 20160327165432_create_users.rb, direction: up, took 0.007772 seconds
Note: Sequel use the column filename
on table schema_migrations
to control the schema version. If it don’t exists, this error occours:
$ sequel postgres://localhost/bookshelf_development -m ./db/migrations
Error: Sequel::Migrator::Error: Migrator table schema_migrations does not contain column filename
As the idea here is run the migrations on the schema previouslly migrated by ActiveRecord, I need to add this column manually before run the Sequel migrations:
$ psql -dbookshelf_development
bookshelf_development=# alter table schema_migrations add column filename varchar not null;
ALTER TABLE
This also will be needed on first release to production, since the schema should be prepared to run the Sequel migrations.
In order to help with these database operations, I created a Rakefile with the common operations like drop, create, migrate, rollback, version, etc:
$ rake -T db
rake db:console # Start a database console on environment
rake db:create # Creates database
rake db:drop # Drops database
rake db:migrate[version] # Perform migration up to latest migration available
rake db:reset # Perform migration reset (full rollback and migration) only on local environment
rake db:rollback[version] # Perform rollback to specified target or previous version as default
rake db:seed # Seed the database with application required data
rake db:structure:dump # Dump database structure to db/schema.sql
rake db:structure:load # Load db/schema.sql database structure
rake db:structure:to_rails # Setup database structure from Sequel to Rails Migrations
rake db:structure:to_sequel # Setup database structure from Rails to Sequel Migrations
rake db:version # Prints current schema version
The entire Rakefile is available on this Gist. Just need to load the task and manage the database with rake commands (like we did with Rails 2).
Now, with the migrations setting up to run, I just need to copy all migration files from Rails project and made the necessary changes to Sequel migration.
For example, the db/migrate/20150210151518_create_users.rb
ActiveRecord migration:
class CreateUsers < ActiveRecord::Migration
def change
create_table :users do |t|
t.references :account, index: true
t.string :email, limit: 255, null: false
t.string :name, limit: 255, null: false
t.string :role, null: false, default: 'owner'
t.timestamps null: false
end
add_foreign_key :users, :accounts
add_index :users, :email, unique: true
end
end
Becomes db/migrations/20150210151518_create_users.rb
Sequel migration:
Sequel.migration do
change do
create_table :users do |t|
primary_key :id
foreign_key :account_id, :accounts, null: false
column :email, String, size: 255, null: false, unique: true
column :name, String, size: 255, null: false
column :role, String, null: false, default: 'owner'
column :created_at, DateTime, null: false
column :updated_at, DateTime, null: false
end
end
end
Made this, I was able to migrate the development and test databases simply running rake db:migrate
and HANAMI_ENV=test rake db:migrate
respectively.
Models
The models was previously copied from ActiveRecord model with the necessary change to work with Sequel. As it was used only to read from database, the validations and write methods was entirely removed in this process.
Now, I was manually adding the validations on each model and doing the necessary changes.
For example, the app/models/account.rb
:
class Account < ActiveRecord::Base
has_many :users
validates :document, presence: true, uniqueness: true
validates :name, presence: true
validates :guid, presence: true
validates :kind, inclusion: {in: %w(business personal)}
validate :validate_document
before_validation on: :create do
self.guid ||= SecureRandom.uuid
end
def validate_document
errors.add(:document, 'is invalid') unless document_valid?
end
end
Becomes lib/bookshelf/entities/account.rb
:
class Account < Sequel::Model
plugin :validation_helpers
one_to_many :users
def validate
super
validates_presence [:name, :document, :guid]
validates_unique :document
validates_includes %w(business personal), :kind
validate_document
end
def before_validation
super
self.guid ||= SecureRandom.uuid
end
def validate_document
errors.add(:document, 'is invalid') unless document_valid?
end
end
Note: I am using the Sequel validation_helpers
plugin here, that will be detailed ahead.
Specs
Considering that at this point the database schema is not a concern, I started to work on specs migration.
The spec_helper.rb
was correctly generated by Hanami and looks like this:
ENV['HANAMI_ENV'] ||= 'test'
require_relative '../config/environment'
Hanami::Application.preload!
Dir[__dir__ + '/support/**/*.rb'].each { |f| require f }
And runnig the rspec
command, everything seems to work:
$ rspec
No examples found.
Finished in 0.00036 seconds (files took 1.15 seconds to load)
0 examples, 0 failures
Now it’s time to copy all specs from the models and lib of Rails project and made the necessary changes.
For example, the spec/models/account_spec.rb
:
require 'rails_helper'
RSpec.describe Account, type: :model do
context "validations" do
it { is_expected.to validate_presence_of :name }
it { is_expected.to validate_presence_of :document }
it { is_expected.to validate_uniqueness_of :document }
it { is_expected.to validate_inclusion_of :kind, in: %w(business personal) }
context "document" do
it "should be validaded" do
account = described_class.new(document: '12312312312')
account.validate
expect(account.errors[:document]).to_not be_empty
end
end
end
end
Becomes spec/bookshelf/entities/account_spec.rb
:
require 'spec_helper'
RSpec.describe Account, type: :model do
context "validations" do
it { is_expected.to validate_presence :name }
it { is_expected.to validate_presence :document }
it { is_expected.to validate_unique :document }
it { is_expected.to validate_inclusion :kind, %w(business personal) }
context "document" do
it "should be validaded" do
account = described_class.new(document: '12312312312')
account.validate
expect(account.errors.on(:document)).to_not be_nil
end
end
end
These Rspec validation macros comes from rspec_sequel_matchers
gem. Just added to Gemfile
and worked like a charm.
At this point and after made all necessary changes, I was able to run the specs for models and lib classes:
$ rspec spec/bookshelf
...........................................................................................................
Finished in 50.27 seconds (files took 3.37 seconds to load)
201 examples, 0 failures, 0 pending
Feature specs
The models and lib classes are covered at this point. Now it’s time to migrate the specs for the main API, which deals practically with all business logic operations in the application.
In the previous project, I was already using Rack::Test
. So, just added the gem rack-test
to Gemfile
and changed the spec/features_helper.rb
to:
require_relative './spec_helper'
RSpec.configure do |config|
config.include RSpec::RackApplication, type: :feature
end
And added the spec/support/rack_application.rb
file with the content:
require 'rack/test'
module RSpec
module RackApplication
include Rack::Test::Methods
private
def app
@@app ||= Hanami::Container.new
end
end
end
Now, I was able to define a feature spec like:
require "features_helper"
describe "POST /accounts", type: :feature do
let(:payload) do
File.read("spec/samples/create_account_valid.json")
end
context "with valid data" do
it "should create account" do
post "/api/accounts", payload
json_body = JSON.load(last_response.body)
expect(json_body["id"]).to_not be_nil
end
end
context "with duplicated data" do
before(:each) do
Account.create(name: payload["name"], payload["document"])
end
it "should not create account" do
post "/api/accounts", payload
json_body = JSON.load(last_response.body)
expect(json_body["message"]).to eq("Duplicated document")
end
end
end
Almost the same spec defined on Rails application. Just copied all specs files from previous project and made the necessary changes, which was basically the require
on first line.
Fixtures replacement
Now the specs are almost migrated, except for one detail: fixtures. The previous project used a lot of ActiveRecord fixtures for build test scenarios. If I can solve this dependency, all specs will pass.
The best replacement for AC fixtures was the fixture_dependencies gem. Just added to Gemfile
and configured it on spec/support/fixtures.rb
file:
require 'fixture_dependencies/rspec/sequel'
FixtureDependencies.fixture_path = Hanami.root.join('spec/fixtures')
RSpec::Core::ExampleGroup.class_eval do
alias :fixtures :load
end
And just needed to copy all fixtures from Rails project to spec/fixtures
. On files using some ERB code, just appended a .erb
on filename. That’s it!
Here some usage example. Given the file spec/fixtures/account.yml.erb
:
acme:
name: "Acme Inc"
document: "12345678"
guid: "<%= SecureRandom.uuid %>"
And the spec/fixtures/users.yml
:
owner:
name: "The Owner"
email: "[email protected]"
account: acme
employee:
name: "The Worker"
email: "[email protected]"
account: acme
To load all users on a spec, just call:
let(:users) do
fixtures(:users)
end
Or to load a specific User
, just need to call:
let(:employee) do
fixtures(:user__employee)
end
If the fixture naming was correcly, all associations will be resolved by fixture_dependencies
in runtime (works more like a factory).
And to ensure that all fixtures are correctly defined, I added a spec just for it on spec/bookshelf/fixtures_spec.rb
:
require "spec_helper"
describe "fixtures" do
Dir[Hanami.root.join("spec/fixtures/*.{yml,erb}")].each do |fixture_file|
fixture_name = File.basename(fixture_file.split(".").first)
it "#{fixture_name} should be successful loaded" do
fixtures(fixture_name)
end
end
end
Now, just need to change all occurrences of fixtures :all
(that is also a code smell) and change to the correct fixture used in the spec. And now, all my specs are running successfully.
Wrapping up
With the specs running, we was able to migrate all write related code from Rails project. And once all specs are green, the second step of this migration to Hanami was concluded. For now, only the Sidekiq workers still on Rails project (for a little while).
To validate if this Hanami application behave correctly, we deployed it as a mirror of the running Rails application on a separated database. All requests are replicated from Rails to Hanami and we was comparing and checking it for several days, until that it was migrated “for real”.
And was another successful! \o/
On next post, I will detail the final topics and answer some questions about the migration process:
- Timezone issues
- Ruby core extensions
- Sidekiq Workers
- I18n
- FAQ
Consider to share this post with your friends or co-workers. For questions, comments or suggestions, use the comments below. Code hard and success!