warning
This is an unofficial, community-maintained guide to help upgrading projects that are built on Hanami 1.x to Hanami 2.x. To contribute, fork our repo and create PR.
info
This is work-in-progress guide, if you see this valuable, consider support or contribution to speed up the completion.
info
Hanami 2 is an almost complete framework rewrite, which makes upgrading projects uncommonly hard. However, it’s important to say, that the core team states there won’t be any such major rewrites of the framework in the future, and the official upgrade guides will be available.
This document was created as a result of combined work of Ascenda developers, after upgrading multiple services of various sizes. Based on our experience, here are our thoughts.
Special thanks to Ng Chen Hon, Hieu Nguyen and Sebastian Wilgosz for all the initial exploration.
1. Small Applications
If you have very small application, consider creating a brand new Hanami 2 application, with views and persistence in place, and just copy your code there.
tip
You can use HanamiSmith to spin such quickly, or refer to the Sean Colins' sample PR to upgrade it all at once
2. Medium-size applications
If you have medium-size application, without crazy custom monkey patches or wrappers on your gems or hanami itself, consider doing it in 3 steps.
- Upgrade ROM to 5.0 and get rid of Hanami-Model
- Upgrade Ruby to 3.x
- Upgrade Hanami to 2.x
Reasons:
- Hanami 1.x requires Ruby < 3, while Hanami 2.x requires Ruby > 3
- Hanami Model requires Ruby < 3, <= ROM 3.3
- Hanami Utils requires Ruby < 3
2.1 Prepare app for ROM 5 upgrade
Upgrading ROM is a big task on it's own, so in the next section we split this even further. Below you can list the steps we extracted first before the actual switch of Hanami-Model -> ROM 5.0
- Update all gems to the latest possible versions.
- Increase the test coverage as much as possible - In our projects we aimed to ~85-95% branch test coverage.
- Upgrade migrations to use ROM directly
Upgrade migrations to use ROM directly
Move migrations to the db/migrate folder
# config/environment.rb
##
# Migrations
#
# THEN
migrations 'db/migrations'
# NOW
migrations 'db/migrate'
Add minor tweaks for migrations to use ROM directly.
Here are some notes on syntax change:
# THEN
Hanami::Model.migration do
# NOW
ROM::SQL.migration do
# NOW we use UUID as primary keys in most tables
execute 'CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'
# THEN
primary_key :id
# NOW
column :id, 'serial', primary_key: true, null: false
# THEN
primary_key :id
# NOW
column :id, 'serial', primary_key: true, null: false
Add rake tasks for DB management (copied from ROM)
We have added a new rake file, for db-related tasks, that we could customize further in the future. This is a subject to be removed after successful update finishes.
# rakelib/db.rake
require 'rom'
require 'dotenv/tasks'
require 'dotenv'
require 'open3'
class MigrationError < StandardError
end
class DbTaskHelper
  class << self
    HOST         = 'PGHOST'.freeze
    PORT         = 'PGPORT'.freeze
    USER         = 'PGUSER'.freeze
    PASSWORD     = 'PGPASSWORD'.freeze
    DATABASE_URL = 'DATABASE_URL'.freeze
    def set_environment_variables # rubocop:disable Metrics/AbcSize
      ENV[HOST]     = host      unless host.nil?
      ENV[PORT]     = port.to_s unless port.nil?
      ENV[PASSWORD] = password  unless password.nil?
      ENV[USER]     = username  unless username.nil?
    end
    def call_db_command(command, *flags)
      Open3.popen3(*command_with_credentials(command, flags)) do |_stdin, _stdout, stderr, wait_thr|
        raise MigrationError, stderr.read unless wait_thr.value.success? # wait_thr.value is the exit status
      end
    rescue SystemCallError => e
      raise MigrationError, e.message
    end
    private
      def database_url
        @database_url ||= URI.parse(ENV.fetch(DATABASE_URL, nil))
      end
      def host
        database_url.host
      end
      def port
        database_url.port
      end
      def username
        database_url.user
      end
      def password
        database_url.password
      end
      def database
        database_url.path[1..]
      end
      def connection
        Sequel.connect(database_url.to_s)
      end
      def command_with_credentials(command, flags = [])
        result = [escape(command)]
        result << "--host=#{host}" if host
        result << "--port=#{port}" if port
        result << "--username=#{username}" if username
        result << '--no-password'
        flags.map { |f| result << f }
        result << database
        result.compact
      end
      def escape(string)
        Shellwords.escape(string) unless string.nil?
      end
  end
end
require 'pathname'
require 'fileutils'
module ROM
  module SQL
    module RakeSupport
      MissingEnv = Class.new(StandardError)
      class << self
        def run_migrations(options = {})
          gateway.run_migrations(options)
          return unless ENV['HANAMI_ENV'] == 'development'
          system("pg_dump -s --no-owner --no-privileges #{ENV.fetch('DATABASE_URL')} > db/structure.sql")
        end
        def create_migration(*args)
          gateway.migrator.create_file(*args)
        end
        # Global environment used for running migrations. You normally
        # set in the `db:setup` task with `ROM::SQL::RakeSupport.env = ROM.container(...)`
        # or something similar.
        #
        # @api public
        attr_accessor :env
        private
          def gateway
            if env.nil? # rubocop:disable Style/GuardClause
              Gateway.instance ||
                raise(MissingEnv, 'Set up a configutation with ROM::SQL::RakeSupport.env= in the db:setup task')
            else
              env.gateways[:default]
            end
          end
      end
      @env = nil
    end
  end
end
# Copied from ROM
namespace :db do # rubocop:disable Metrics/BlockLength
  task :create do
    DbTaskHelper.set_environment_variables
    begin
      DbTaskHelper.call_db_command('createdb')
    rescue MigrationError => e
      puts e.message
    end
  end
  task :setup do
    ROM::SQL::RakeSupport.env =
      ROM::Configuration.new(
        :sql,
        ENV.fetch('DATABASE_URL'),
        logger: Hanami.logger,
        extensions: %i(pg_array pg_json)
      )
  end
  desc 'Create database and run migrations'
  task prepare: %i(create migrate)
  desc 'Drop database'
  task :drop do
    DbTaskHelper.set_environment_variables
    DbTaskHelper.call_db_command('dropdb')
  end
  desc 'Rollback migration (options [step])'
  task :rollback, [:step] => :environment do |_, args|
    Rake::Task['db:setup'].invoke
    step = (args[:step] || 1).to_i
    # Reference: https://github.com/jeremyevans/sequel/blob/d9104d2cf0611f749a16fe93c4171a1147dfd4b2/lib/sequel/extensions/migration.rb#L598
    if step >= 20_000_101
      ROM::SQL::RakeSupport.run_migrations(target: step)
      puts "<= db:rollback version=[#{step}] executed"
      exit
    end
    gateway = ROM::SQL::RakeSupport.env.gateways[:default]
    unless gateway.dataset?(:schema_migrations)
      puts '<= db:rollback failed due to missing schema_migrations'
      exit 0
    end
    schema_migrations = gateway.dataset(:schema_migrations).all
    versions =
      schema_migrations
        .sort_by { |s| s[:filename] }
        .reverse
        .map { |s| s[:filename].split('_').first }
    versions.shift(step)
    target = versions.first.to_i
    ROM::SQL::RakeSupport.run_migrations(target: step)
    puts "<= db:rollback version=[#{target}] executed"
  end
  task :rom_configuration do
    Rake::Task['db:setup'].invoke
  end
  desc 'Perform migration reset (full erase and migration up)'
  task reset: :rom_configuration do
    DbTaskHelper.call_db_command('dropdb', '--force')
    Rake::Task['db:create'].invoke
    ROM::SQL::RakeSupport.run_migrations
    puts '<= db:reset executed'
  end
  desc 'Create a migration (parameters: NAME, VERSION)'
  task :create_migration, %i(name version) => :rom_configuration do |_, args|
    name, version = args.values_at(:name, :version)
    if name.nil?
      puts "No NAME specified. Example usage:
        `rake db:create_migration[create_users]`"
      exit
    end
    path = ROM::SQL::RakeSupport.create_migration(*[name, version].compact)
    puts "<= migration file created #{path}"
  end
  desc 'Migrate the database (options [version_number])]'
  task :migrate, [:version] => :rom_configuration do |_, args|
    version = args[:version]
    options = { allow_missing_migration_files: true }
    if version.nil?
      ROM::SQL::RakeSupport.run_migrations(**options)
      puts '<= db:migrate executed'
    else
      ROM::SQL::RakeSupport.run_migrations(target: version.to_i, **options)
      puts "<= db:migrate version=[#{version}] executed"
    end
  end
  desc 'Perform migration down (erase all data)'
  task clean: :rom_configuration do
    ROM::SQL::RakeSupport.run_migrations(target: 0, allow_missing_migration_files: true)
    puts '<= db:clean executed'
  end
end
From now on, you’ll use rake db:migrate and related commands instead of hanami db migrate
bundle exec rake db:migrate
Upgrade tests  to prepare them for switch to rom-factory
During ROM upgrade, we will need to switch from fabricator gem to use factory, with new syntax. This pr extracts syntax changes, so it will be easier to review the main pr.
We have created a helper to implement the interface from rom-factory, so we can change them without affecting all our test suits.
module RSpec
  module Helpers
    module Factories
      def build(entity_type, **attributes)
        # this will be changes to Factory usage soon
        Fabricate.build(entity_type, **attributes)
      end
      def create(entity_type, **attributes)
        # this will be changes to Factory usage soon
        Fabricate.create(entity_type, **attributes)
      end
    end
  end
end
Enable pg_array and pg_json extensions on sequel dbs
Upgrading to ROM 5 also meant to upgrade sequel gem.
Sequel shows deprecation warning, saying that PG_JSON extension will be enabled by default after the upgrade.
Also, we had multiple database clients hooked up, and for some of them we used sequel gem directly. We needed to enable the pg_array and pg_json extensions to make behaviour more unified for all dbs.
# spec/spec_helper.rb
DB_CLIENT.extension(:pg_array, :pg_json)
This improved reading jsonb columns and automatic hash transformations.
Make sure you sort columns to use copy_table
copy_table requires columns to be provided in the fixed order. If you pass the hash, things may break in after upgrading sequel
Refactor tests to not use Hanami::Model::Error
# THEN
expect { worker.perform }.to raise_error(Hanami::Model::Error, 'something')
# NOW
let(:rom_error) { Hanami::Model::Error.new('something') }
# Now use hte rom_error wherever Hanami::Model::Error was called before
expect { worker.perform }.to raise_error(rom_error)
Add application_entity
# THEN
class Alert < Hanami::Entity
# frozen_string_literal: true
# NOW
class Alert < ApplicationEntity
end
# lib/your_app/entities/_application_entity.rb
# This file needs to be loaded first, therefore it is named with `_` prefix.
# It's only a temporary solution which will be fixed by wrapping entities in the
# Entities namespace soon
#
class ApplicationEntity < Hanami::Entity
end
Remove usage of root relation from the repository
# THEN
your_repo
  .root
  .some_items
# NOW
your_repo
  .root
  .some_items
Now upgrade to ROM 5.0
Remove hanami-model and hanami-fabrication
hanami-model and hanami-fabrication# Gemfile
- gem 'hanami-model', '~> 1.3'
- gem 'hanami-fabrication', ascenda_private: 'Kaligo/hanami-fabrication', tag: 'v0.2.0'
Install ROM & needed gems
# Gemfile
gem 'dry-schema'
gem 'hanami', '~> 1.3'
gem 'hanami-validations', '2.0'
gem 'rom', '~> 5.0'
gem 'rom-sql', '~>3.5'
gem 'rom-factory'
Setup relevant infrastructure
Example ROM configuration file. Another examples you can find in HME028, where we configure ROM from scratch.
require 'sequel'
Sequel.extension :pg_array, :pg_json
Sequel.default_timezone = :utc
require 'rom'
require 'rom/sql'
ROM::SQL.load_extensions :postgres
module Persistence
  def self.db
    @db ||= ROM.container(configuration)
  end
  def self.relations
    db.relations
  end
  def self.configuration
    @configuration ||=
      ROM::Configuration.new(
        :sql,
        ENV.fetch('DATABASE_URL'),
        **options
      ).tap do |config|
        config.auto_registration(Hanami.root.join('lib/your_app/persistence'), namespace: 'Persistence')
        config.plugin(:sql, relations: :pagination)
      end
  end
  def self.options
    {
      logger: Hanami.logger,
      extensions: %i[pg_array pg_json]
    }
  end
end
With this you can switch your app to use new DB configuration.
Hanami::Model.configuration.connection
        Persistence.configuration.default_gateway
Update repositories syntax
# THEN
class MyTableRepository < Hanami::Repository
.group(:some_id)
.pluck(%i(some_id other_column))
.select { int.count(:some_column).distinct }
my_relation.select { int.max(:timestamp) }
# NOW
class MyTableRepository < Repository[:my_tables]
.group(Sequel.lit("data->>'some_id'"))
end
.pluck(:some_id, :other_column)
.select { [integer.count(:some_column).distinct.as(:count)] }
my_relation.select { [integer.max(timestamp).as(:timestamp)] }
# Remove. map_to is not needed in ROM 5
.map_to(EntityName)
Update dry-validation syntax
dry-validation update was one of our bigger hiccups. Unfortunately, since 0.3 version, dry-schema was extracted to separate gem, and with the advanced rules, syntax had been changed a bit. If you have a large number of contracts in the system, updating all of them will be tricky.
tip
We've developed some ways to simplify this, that will be described in the last section
Update the ApplicationEntity
ApplicationEntityclass ApplicationEntity < ROM::Struct
  # Implement generic equality for entities
  #
  # Two entities are equal if they are instances of the same class and they
  # have the same id.
  #
  # Copied from hanami-model.
  # TODO: Consider removing this after fixing issue with ROM
  # overwriting schema definition of Entity
  def ==(other)
    self.class.name == other.class.name &&
      id == other.id
  end
end
Replace Hanami::Model errors
# THEN
rescue Hanami::Model::UniqueConstraintViolationError
rescue Hanami::Model::Error => e
# NOW
rescue ROM::SQL::UniqueConstraintError
rescue ROM::SQL::Error => e
Add relations to ALL tables
# example relation
module Persistence
  module Relations
    class MyTable < ROM::Relation[:sql]
      schema(:my_tables, infer: true) do
        attribute :metadata, ::Types::JSONB
      end
      auto_struct true
    end
  end
end
Add custom types if needed
TODO: Write an explanation for this
module Types
  include Dry.Types()
  JSONB = Types::Strict::Nil | ::Coercions.HashToJSONB.meta(
    read: ::Coercions.SymbolizedHash
  )
  UUID = ROM::SQL::Postgres::Types::UUID
end
Replace Fabricators with Factories.
All fabricators should be replaced by factory definitions. Example:
# THEN
Fabricator(:article) do
  id { SecureRandom.uuid }
  created_at { Faker::Time.backward(days: 14, period: :evening) }
  updated_at { Faker::Time.backward(days: 14,
end
# NOW
Shared::Factory.define(:article, struct_namespace: Object) do |f|
  f.id { SecureRandom.uuid }
  f.created_at { Faker::Time.backward(days: 14, period: :evening) }
  f.updated_at { Faker::Time.backward(days: 14, period: :evening) }
end
Upgrade Ruby to 3.X
TODO: Notes
3. Large-size applications
This approach is designed for very large monoliths, where no downtime is allowed, no production bugs accepted, and dozens of PRs are created every day, making code conflicts a huge problem, and the break on development is not allowed.
This is gradual upgrade scenario, that will take long time, but will allow your team to not create huge conflicts during the upgrade process.
This is in progress
Summary
I hope you've enjoyed this article, and if you want to see more content in this fashion,** Subscribe to this YT channel**, Newsletter, and follow me on Twitter! As always, all links you can find the description of the video or in the https://hanamimastery.com.
Also, If you have any suggestions of amazing ruby gems You'd like me to cover, or ideas on how to improve, please mention it in the comments!
Special Thanks!
I'd like to thank [LATEST SPONSORS]. for supporting this project!
Any support allows me to spend more time on creating this content, promoting great open source projects.
Also, check out two of my previous videos here! Thank you all for being here, you're awesome! - and see you in the next Hanami Mastery episode!
- John Smith- for a great cover image
Add your suggestion to our discussion panel!
I'll gladly cover them in the future episodes! Thank you!

