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.
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.
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.
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
Building a Rails Engine