Creating your first Rails Engine


Why learning Rails Engines?

Many of the best practices and OOD principles can be learned by just reading the rails gem.

The catch tho is that you need to learn a bit more about ruby and be proficient in rails.

Then the last step is to understand how the engine works.

The result: Anytime you have an issue, read the rails code and you will allays find a pattern that you can resuse in your code base.

What we are going to build?

The problem

Every time I want to use an enum in a model, my developer OCD trips me up.

You define a string in the helper as an enum value:

class Article < ApplicationRecord
  enum status: [:active, :archived], _suffix: true
end

But in the migration, you need to define an integer as a column:

class CreateArticles < ActiveRecord::Migration[7.1]
  def change
    create_table :articles do |t|
      t.string :name
      t.integer :status

      t.timestamps
    end
  end
end

This makes everything complicated to map the default:

if you change the order in your array, it blows up everything, and when you query the SQL database, you also get numbers when in the application layer you have complete abstraction.

This can be even more confusing.

The solution

We want to build something like this:

class Article < ApplicationRecord
  # @NOTE:
  # The column `status` is supposed to be of type string in your database.
  literal_enum :status, ["pending", "accepted"]
end

And it saves a string in the database:

class CreateArticles < ActiveRecord::Migration[7.1]
  def change
    create_table :articles do |t|
      t.string :name
      t.string :status

      t.timestamps
    end
  end
end

Pair programming session: building a gem together

STEP 0: Understanding the problem

Here is what we are trying to mimic: Original gist

Huge kudos to @hundredwatt (Jason Nochlin) for showing me how to do it :)

# frozen_string_literal: true

require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"

  gem "rails"
  # If you want to test against edge Rails replace the previous line with this:
  # gem "rails", github: "rails/rails", branch: "main"

  gem "sqlite3"
end

require "active_record"
require "minitest/autorun"
require "logger"

# This connection will do for database-independent bug reports.
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Base.logger = Logger.new(STDOUT)

ActiveRecord::Schema.define do
  create_table :posts, force: true do |t|
    t.string :status
  end
end

class ApplicationRecord < ActiveRecord::Base
  primary_abstract_class

  def self.literal_enum(name = nil, values = nil, **options)
    if values.is_a?(Array)
      values = values.map { |value| [value] * 2 }.to_h
    end

    enum(name, values, **options)
  end
end

class Post < ApplicationRecord
  literal_enum :status, ['pending', 'accepted']
end

post = Post.create! status: 'accepted'
p post.status_before_type_cast # => 'accepted'

Can you see the self.literal_enum in ApplicationRecord?

The goal is to simple put that into a rails engine, with the tests.

STEP 1: Create your rail engine

The three types of Rails engines are mountable, full, and “normal” engines.

  1. Mountable Engine: It is namespace-isolated and includes assets, namespaced controllers and helpers, a layout view template, and mounts the engine inside a dummy testing application.

  2. Full Engine: It includes the basic structure of an engine, such as an app directory tree, a routes file, and an engine.rb file. It does not include namespace isolation or assets.

  3. Just the plugin: It does not have the app directory and the namespace. Honestly, I did not find a use case for this one.

Here we will be using the –mountable version, create your engine with:

rails plugin new enum_literal_clone --mountable

STEP 2: Understanding the directory

I’ll be quick; here’s what you need to know for this tutorial:

  • ./app This is the basic Rails app, like a child rails app that will be included when you install this gem into your other Rails apps.

  • ./lib/enum_literal_clone/engine.rb This is the glue between your child Rails app (i.e: the rails engine generated) and your host Rails app, the one where you will install the gem.

  • ./test/dummy/app This is a full host Rails app where your basic Rails app is already installed, for testing purposes.

You can cd into ./test/dummy and rails g model ...

If you do the same in the root folder, those migrations will be copied into your main host Rails app when installing the gem.

Like devise, when you create your User model migrations.

STEP 3: Set up your dummy rails app

cd ./test/dummy

rails g model Article name:string status:string
rails db:create
rails db:migrate

rails s
irb: welcome to rails
=> Article.create(name: "hello", status: "done")

These are the basics to interact with the dummy app.

If you cd ./enum_literal_clone and try rails s you will notice that the model Article does not exist.

STEP 4: Create the concern

With the content:

# in: app/models/concerns/literal_enum_clone/binder.rb
module LiteralEnumClone::Binder
  extend ActiveSupport::Concern

  class_methods do
    def literal_enum(name = nil, values = nil, **options)
      if values.is_a?(Array)
        values = values.map { |value| [value] * 2 }.to_h
      end

      enum(name, values, **options)
    end
  end
end

You can see that the important part is that your concern is namespaces with LiteralEnumClone.

STEP 5: Include the concern in engine.rb

# in: lib/literal_enum_clone/engine.rb
module LiteralEnumClone
  class Engine < ::Rails::Engine
    isolate_namespace LiteralEnum

    initializer "literal_enum_clone.active_record" do
      ActiveSupport.on_load :active_record do
        include LiteralEnumClone::Binder
      end
    end
  end
end

This will include LiteralEnumClone::Binder only when needed.

STEP 6: Update your dummy model

# in: test/dummy/app/models/article.rb
class Article < ApplicationRecord
  literal_enum :status, ["pending", "accepted"]
end

STEP 7: Manual testing in your console

cd ./test/dummy

rails s
irb: welcome to rails
=> article = Article.create(name: "hello", status: "pending")

# Suposed to fail with an arguement error
Article.create(name: "hello", status: "done")

# Check in the DB level that you saved the string 🤙
ActiveRecord::Base.connection
      .execute("SELECT * FROM articles")
      .first["status"]

# Normal enum method working
article.pending?
=> true

STEP 8: Test your concern

# test/models/conern_binder_test.rb

require "test_helper"

class LiteralEnum::ConernBinderTest < ActiveSupport::TestCase
  test "Enum methods works" do
    article = Article.new(name: "Hello", status: "pending")
    assert article.pending?
  end

  test "Statuses are validated" do
    assert_raises ArgumentError do
      Article.new(name: "Hello", status: "will_fail")
    end
  end

  test "Saves string to database" do
    article = Article.create(name: "Hello", status: "pending")
    database_status_value = ActiveRecord::Base.connection
      .execute("SELECT * FROM articles")
      .first["status"]
    assert_equal database_status_value, "pending"
  end

  test "Enum will return a string" do
    article = Article.create(name: "Hello", status: "pending")

    assert_equal article.status, "pending"
  end

  test "Works with a symbol" do
    article = Article.create(name: "Hello", status: :pending)

    assert_equal article.status, "pending"
  end
end

STEP 9: Ship it to gems.org 🤙

Here is the repo and the gem published:

Special thanks

Huge shout-out to @hundredwatt (Jason Nochlin)!

I was tired of this integer in the database when using enums, and he jumped into the discussion on Twitter and showed me this: https://gist.github.com/hundredwatt/9742581230a320eaac008ef01e90f156

After learning about Rails engines, I thought that this could be a great first gem ;)

The original class_method is actually from the gist by @hundredwatt (Jason Nochlin).

Also to from Alexandre Ruban from hotrails.dev for putting this awesome deep dive about rebuilding turbo rails plugin.

I would’ve never tried otherwise… even tho the original ruby on rails guides about rails engine is a really good start also.

What’s next?

I hope this tutorial helped you understand how you can extract pieces of logic that you’ve already seen in your career.

But most importantly, to see beyond the code and make you realize that contributing to the Rails ecosystem is not hard.

However, if you don’t have the tools, you will never understand how to package your logic for someone else.

Whether it’s your team or community.

See you next time!


Related posts

Back to homepage