All you ever dreamed to know about Rail generators.
Rail generators are powerful, but we must understand their magic to get the most from them.
The first thing we must understand is that the generators are not meant to be auto-discovered by Rails the way we used to, and it makes sense as we don’t want interpreters to run those files by mistake. I will explain myself later.
Welcome to the very special world of the Rails generators.
I recently wanted to use dry-rb libraries to handle the business logic layer in my Rails projects. Still, I quickly realised that I would have to write many files each time I added an action, such as Schema, Contract , Operation, and their test counterparts.
So, I naturally thought about adding a couple of generators for that.
I wanted to add the actions and their test counterpart files, and for that, I wanted to take advantage of using hook_for :test_framework
and that is when I realised that it wasn’t that simple to make it work 😅
Let’s create a simple Rails app:
rails _7.1.3.2_ new my_new_rails_app --template ~/my_awesome_template.rb
Let’s create our generator to generate our Action (Service Object to help handle Business Logic).
We can follow the guide Creating Generators with Generators
bin/rails generate generator business_logic/action
create lib/generators/business_logic/action
create lib/generators/business_logic/action/action_generator.rb
create lib/generators/business_logic/action/USAGE
create lib/generators/business_logic/action/templates
invoke test_unit
create test/lib/generators/business_logic/action_generator_test.rb
But if there is one thing the generator handles poorly, it is the namespaces.
class BusinessLogic::ActionGenerator < Rails::Generators::NamedBase
source_root File.expand_path("templates", __dir__)
end
instead of
module BusinessLogic
class ActionGenerator < Rails::Generators::NamedBase
source_root File.expand_path("templates", __dir__)
end
end
So let’s create the files manually or modify the files after they are generated, I leave that up to you.
lib/generators
└── business_logic
└── action
├── action_generator.rb
└── templates
└── action.rb.erb.tt
lib/generators/business_logic/action/action_generator.rb
module BusinessLogic
module Generators
class ActionGenerator < Rails::Generators::NamedBase
source_root File.expand_path("templates", __dir__)
def generate_files
template "action.rb.erb.tt", "app/actions/#{file_name}_action.rb"
end
end
end
end
If you have a keen eye, you’ve spotted the module Generators
namespace I’ve added, and that will be our first lesson on how generators have their own ways to be loaded.
Indeed, Rails::Generators::Base
remove that namespace, so it is completely ignored!
and that comment is quite inaccurate as it forgets to mention the removal of the namespace.
Here is the template:
lib/generators/business_logic/action/templates/action.rb.erb.tt
class <%= class_name %>Action
def perform
# write the logic here
end
end
NOTE: The helpers I use, like file_name
or class_name
come from Rails::Generators::NamedBase
. There are more, and you can easily extend this list with your own helper methods.
NOTE: Even if any extension will work, It’s important to add the extension .tt
for the template files so they can’t be inferred by a template engine by mistake, like ERB here (even worse if you use .rb
)
Let’s see if this generator shows up on the list of generators:
rails generatr --help
BusinessLogic:
business_logic:action
and rails generate business_logic:action --help
Usage:
bin/rails generate business_logic:action NAME [options]
Options:
[--skip-namespace] # Skip namespace (affects only isolated engines)
# Default: false
[--skip-collision-check] # Skip collision check
# Default: false
Runtime options:
-f, [--force] # Overwrite files that already exist
-p, [--pretend], [--no-pretend], [--skip-pretend] # Run but do not make any changes
-q, [--quiet], [--no-quiet], [--skip-quiet] # Suppress status output
-s, [--skip], [--no-skip], [--skip-skip] # Skip files that already exist
Description:
Create business logic files for action generator.
Show the default template! Wow, that seems to work. Let’s run it!
rails generate business_logic:action Foo
create app/actions/foo_action.rb
Here is the generated file:
app/actions/foo_action.rb
class FooAction
def perform
# write the logic here
end
end
That works perfectly, the ERB file was inferred properly with Foo
replacing <%= class_name %>
as well as #{file_name}
in the file path.
One more thing: you need to remove this directory from auto-loading as such:
# Please, add to the `ignore` list any other `lib` subdirectories that do
# not contain `.rb` files, or that should not be reloaded or eager loaded.
# Common ones are `templates`, `generators`, or `middleware`, for example.
config.autoload_lib(ignore: %w(assets tasks generators))
and you want to do it for a good reason! You do not want Zeitwerk running your generator by mistake while eagerly loading all the files!
Now, we want to create a generator that can generate the test file for our new FooAction
class, and to do that, we want to take advantage of the great method hook_for :test_framework
module BusinessLogic
class ActionGenerator < Rails::Generators::NamedBase
source_root File.expand_path("templates", __dir__)
hook_for :test_framework
def generate_files
template "action.rb.erb.tt", "app/actions/#{file_name}_action.rb"
end
end
end
The method hook_for
is provided by railties. This is the Rails version of Thor.invoke_from_option, it needs a class_options to be defined, here test_framework.
Rails initialise this value as :test_unit
rails r "puts Rails.application.config.generators.test_framework"
=> :test_unit
It sets the value when it loads test_unit.
If you use RSpec, you have to manually set the configuration
config/initializers/generators.rb
Rails.application.config.generators do |generator|
generators.integration_tool :rspec
generators.test_framework :rspec
end
Or use the gem rspec-rails which we set the values for you. You might need to make it available in development, too, though:
group :development, :test do
gem "rspec-rails" # Development for accessing the generators.
end
NOTE: You still can override this value when calling your generator:
rails generate business_logic:action Foo --test_framework=test_unit
And now, the question that tripped me out! Where on Earth do I need to place the test generator so that the hook can catch it?
You are free to follow the long documentation of the method [Rails::Generators::Base#hook_for] and try to figure it out. I personally tried and failed, so instead, I’ve tweaked the method of [Rails::Command::Behavior#lookup] to output the paths is trying to load.
Running
rails generate business_logic:action Foo
I got the following output:
rails/generators/business_logic/test_unit/test_unit_generator
rails/generators/business_logic/test_unit_generator
rails/generators/test_unit/action/action_generator
rails/generators/test_unit/action_generator
generators/business_logic/test_unit/test_unit_generator
generators/business_logic/test_unit_generator
generators/test_unit/action/action_generator
generators/test_unit/action_generator
I haven’t placed my generator under any rails
directory, so let’s ignore them:
generators/business_logic/test_unit/test_unit_generator
generators/business_logic/test_unit_generator
generators/test_unit/action/action_generator
generators/test_unit/action_generator
I’m not happy with any of the proposed paths. Let’s keep going.
Let’s change the hook for:
hook_for :test_framework, in: 'hook_for_in', as: 'hook_for_as'
generators/hook_for_in/test_unit/test_unit_generator
generators/hook_for_in/test_unit_generator
generators/test_unit/hook_for_as/hook_for_as_generator
generators/test_unit/hook_for_as_generator
NOTE: You can use namespaces as well:
hook_for :test_framework, in: 'look:here', as: 'for:me'
generators/look/here/test_unit/test_unit_generator
generators/look/here/test_unit_generator
generators/test_unit/for/me/for/me_generator
generators/test_unit/for/me_generator
NOTE: Do not use slash! If it works at that stage, it won’t find the namespace.
WARNING: hook_for :test_framework, in: 'look/here', as: 'for/me'
DON’T WORK.
I’m going to use:
hook_for :test_framework, in: 'business_logic:action'
Okay, those 2 guys look like what I want:
generators/business_logic/action/test_unit/test_unit_generator
generators/business_logic/action/test_unit_generator
I’m choosing the one with test_unit
folder so the test templates can be isolated from the generator templates:
generators/business_logic/action/test_unit/test_unit_generator
I have a nice folder structure:
tree lib/generators
lib/generators
└── business_logic
└── action
├── action_generator.rb
├── templates
│ └── action.rb.erb.tt
└── test_unit
├── templates
│ └── action_test.rb.erb.tt
└── test_unit_generator.rb
Now, we need to know how to name this generator.
Indeed, [Rails::Generators.find_by_namespace] will search a specific lookup based on conventions and what we set up.
Here are the lookups:
lookups: ['business_logic:action:test_unit', 'test_unit:action']
If you prefer your class to be named ActionGenerator
module TestUnit
class ActionGenerator < Rails::Generators::NamedBase
source_root File.expand_path("templates", __dir__)
def generate_files
template "action_test.rb.erb.tt", "test/actions/#{file_name}_test.rb"
end
end
If you prefer your class to be named TestUnitGenerator
module BusinessLogic
module Action
class TestUnitGenerator < Rails::Generators::NamedBase
source_root File.expand_path("templates", __dir__)
def generate_files
template "action_test.rb.erb.tt", "test/actions/#{file_name}_test.rb"
end
end
end
end
NOTE: As before, module Generators
will be ignored, so it’s up to you whether to include them or not. It is purely cosmetic.
Once invoked, we got the expected result.
rails generate business_logic:action Foo
invoke test_unit
create test/actions/foo_test.rb
create app/actions/foo_action.rb
Rubygems
Let’s say we want to use those generators in all our projects. We can either use a custom template when creating a new Rails app
rails _7.1.3.2_ new my_new_rails_app --template ~/my_awesome_template.rb
or extracting those generators into a Rubygem. Let’s explore how to extract it as a Ruby gem. We’ll name the gem Action Generator, as we want to generate the Business Logic in the form of Action Classes:
You can find the modified Host App code here.
The gems:
Action Generator
TestUnit Action Generator
Rspec Action Generator
Some aspects deserve to be covered here. First of all, due to the really specific way to be discovered, you do not need to require the gem:
gem "action_generator", git: "git@github.com:joel/action_generator.git", require: false
Bundle make available the PATH of all gems into `$LOAD_PATH` and the method of [Rails::Command::Behavior.lookup!](https://github.com/rails/rails/blob/7-1-stable/railties/lib/rails/command/behavior.rb#L60) will load the generators for you as long as they are placed following the convention.
In case they do not follow the conventions, you will need to require the gem, and have a Railtie
class:
lib/action_generator/railtie.rb
module ActionGenerator
class Railtie < Rails::Railtie
# Initializers are run during Rails startup
initializer "action_generator.configure_rails_initialization" do |app|
generators do
require "action_generator/foo_generator"
end
end
end
end
lib/action_generator.rb
require "action_generator/railtie" if defined?(Rails::Railtie)
config/initializers/generators.rb
require "action_generator" if Rails.env.development?
We often see a Railtie
class filled up with a requirement for a generator that follows the convention. It’s useless, and on top of that, it will already be discovered and in the path when the generator block is executed.
If you need a specific behaviour that implies deviating from the convention and using that mechanism, I assume you know what you are doing, and that article is no help for what you already know.
Scaffold Generator
At some point, you might want to glue several generators at once, as Rails do: rails g scaffold user name:string
That is pretty handy and becomes a need quickly:
We are going to add another generator for the schema, this is responsible for the inputs of the action. So we have now 2 generators:
tree lib/generators
lib/generators
├── business_logic
│ ├── action
│ │ ├── action_generator.rb
│ │ ├── templates
│ │ │ └── action.rb.erb.tt
│ │ └── test_unit
│ │ ├── templates
│ │ │ └── action_test.rb.erb.tt
│ │ └── test_unit_generator.rb
│ ├── business_logic_generator.rb
│ └── schema
│ ├── schema_generator.rb
│ ├── templates
│ │ └── schema.rb.erb.tt
│ └── test_unit
│ ├── templates
│ │ └── schema_test.rb.erb.tt
│ └── test_unit_generator.rb
├── field.rb
└── parser.rb
[Add Scaffold Generator](https://github.com/joel/host_app_for_generators/pull/5/files)
Now, we can create the scaffold generator:
lib/generators/business_logic/business_logic_generator.rb
module BusinessLogic
module Generators
class BusinessLogicGenerator < Rails::Generators::NamedBase
include Parser
desc <<~DESC
Description:
Generates Business Logic Files
example: rails generate business_logic User create name:string
DESC
def invoke_generators
invoke "business_logic:action", [name, verb], options
invoke "business_logic:schema", [name, verb, fields], options
end
end
end
end
We can see 2 new arguments, verb
and fields
. The first one, name
comes from [Rails::Generators::NamedBase](https://github.com/rails/rails/blob/7-1-stable/railties/lib/rails/generators/named_base.rb#L9)
The options
are anything passed in the CLI that is not an argument:
rails generate business_logic User create name:string email:string --test_framework=test_unit
Like--test_framework=test_unit
NOTE: You can re-arrange and change arguments and options from one call to another, which is super handy; later, we will see how to do the same through hook_for
.
Here we have our name: User
our action: create
and the fields collection: name:string email:string
The arguments must be declared on each generator so you can use a module to create and inherit from a custom generator.
module Parser
extend ActiveSupport::Concern
def initialize(args, *options)
super
parse_fields!
end
included do
argument :verb, type: :string, default: 'create', desc: 'CRUD verb'
argument :fields, type: :array, default: [], desc: 'field:type'
attr_reader :parsed_fields
end
private
def parse_fields!
@parsed_fields ||= [].tap do |parsed_fields|
fields.each do |field_raw_value|
parsed_fields << field_raw_value.split(/:/) # name:string, age:integer
end
end
end
end
We do a simple parsing on the field collection. Note that the method parsed_fields
will be accessible from any templates.
lib/generators/business_logic/schema/templates/schema.rb.erb.tt
class <%= class_name %>Schema
<%- if parsed_fields.any? -%>
attr_writer <%= parsed_fields.map { |field| ":#{field.name}" }.join(", ") %>
<%- end -%>
end
[Control Passing Arguments](https://github.com/joel/host_app_for_generators/pull/6/files)
When calling another generator through hook_for
you can tweak the arguments, and that is really handy sometimes.
hook_for :test_framework, in: "business_logic:action" do |test|
new_options = options.dup
new_options[:target_root_path] = "test"
invoke test, [name, verb, fields], new_options
end
[In App Scaffold Generator Reinvoke](https://github.com/joel/host_app_for_generators/pull/7/files)
If you want to call the generator from the scaffold more than once, you will run into an issue. Indeed, Thor, like Rake (and Make), has task management and prevents invoking a task more than once.
module BusinessLogic
module Generators
class BusinessLogicGenerator < Rails::Generators::NamedBase
def invoke_generators
if verb == "all"
%w[create update destroy].each do |action|
reinvoke "business_logic:action", [name, action], options
end
else
invoke "business_logic:action", [name, verb], options
end
end
no_commands do
def reinvoke(task, args, options)
script = self.class.new(args, options)
script.invoke(task)
end
end
end
end
end
This article is a feedback of my investigation in the world of Rails generators. It helps me as the memo, and I hope it can help others.
Happy hacking