Honeybadger Staff
Posted on February 9, 2023
This article was originally written by William Kennedy on the Honeybadger Developer Blog.
Rails generators are one of the most prominent features of Rails. We can generate feature scaffolds, migrate databases, create mailers, and even create new background jobs. There is nothing more satisfying than running a generator with everything set up and ready to go.
Scaffolds also serve an essential function in a lot of successful gems. Many popular gems ship with an install command that sets up everything the developer needs to succeed with that library.
If you’re a long-time Rails user, you probably take generators for granted until you venture outside of Rails.
Generators are easy to view as "magic". How they work can be pretty obscure to a newcomer. This article will dig into exactly how generators work under the hood, and then we'll build one ourselves.
What is a Generator
A generator consists of the following:
- A class that inherits from
Rails::Generators::NamedBase
- A template(optional)
- A repeatable pattern
Kinds of Generators
Generators come in all shapes and sizes, but generally, they come in two variations—generators that take arguments and do something with them and installers that set up the plumbing.
These kinds of generators exist in the Noticed gemand within Rails itself via the various rails scaffold commands
and even the rails new
command, which is a Rails generator itself.
In the Noticed gem, we can do the following:
rails generate noticed:model
This generates a new model called notifications, a migration, and some tests.
It then proceeds to give you some instructions on what to do next.
Now let’s dive into the code, which you can read here.
The critical thing here is that we inherit from Rails::Generators::NamedBase
; note that we have a source_root
method that points to the templates folder.
module Noticed
module Generators
class ModelGenerator < Rails::Generators::NamedBase
include Rails::Generators::ResourceHelpers
source_root File.expand_path("../templates", __FILE__)
desc "Generates a Notification model for storing notifications."
argument :name, type: :string, default: "Notification", banner: "Notification"
argument :attributes, type: :array, default: [], banner: "field:type field:type"
...
This class governs all behavior that goes into the noticed generator.
Note that we also have the argument
method, which defaults to string. We can also pass arguments into this generator. For example, we might want the notifications model to be called something else instead of notification.
rails generate noticed:model Notice
Noticed has a generator targeted for taking an argument in the form of a Ruby Class.
rails generate noticed:notification CommentNotification
This can be seen here and is remarkably simple. Like the previous generator, it inherits from Rails::Generators::NamedBase
and has the source_root
method, which points to the templates folder. However, this example uses a template that can be seen here.
# To deliver this notification:
#
# <%= class_name %>.with(post: @post).deliver_later(current_user)
# <%= class_name %>.with(post: @post).deliver(current_user)
class <%= class_name %> < Noticed::Base
# Add your delivery methods
#
# deliver_by :database
# deliver_by :email, mailer: "UserMailer"
# deliver_by :slack
# deliver_by :custom, class: "MyDeliveryMethod"
# Add required params
#
# param :post
# Define helper methods to make rendering easier.
#
# def message
# t(".message")
# end
#
# def url
# post_path(params[:post])
# end
end
Invocation Order
All public methods in the generator will be called one after the other. Private methods will not be called but are available in your public methods like regular Ruby classes.
Building A Generator - Getting Started
The best way to understand generators is, of course, to build one ourselves. This generator will be simple initially but easy to build upon in the future. We will keep our generator simple and create new StimulusJS controllers.
The first thing we do is build a new Rails app to get started.
Assuming you’re using Rails 7, run the following command:
rails new rails_generator
Now we change into that directory:
cd rails_generator
Once that's ready, we will use a Rails generator to create our first generator.
rails g generator stimulus
Then we will do the following:
create lib/generators/stimulus
create lib/generators/stimulus/stimulus_generator.rb
create lib/generators/stimulus/USAGE
create lib/generators/stimulus/templates
invoke test_unit
create test/lib/generators/stimulus_generator_test.rb
The first file I like to edit is the lib/generators/stimulus/USAGE
file. This allows us to do some documentation upfront and provide helpful hints to fellow developers who might want to use your generator.
So, let’s change that by updating the lib/generators/stimulus/USAGE
file:
Description:
Generates a New Stimulus Controller
Example:
bin/rails generate stimulus Thing
This will create:
app/javascripts/controllers/thing_controller.js
In our terminal, we can run the following, and we’ll see the output.
rails g stimulus --help
Usage:
rails generate stimulus NAME [options]
Options:
[--skip-namespace], [--no-skip-namespace] # Skip namespace (affects only isolated engines)
[--skip-collision-check], [--no-skip-collision-check] # Skip collision check
Runtime options:
-f, [--force] # Overwrite files that already exist
-p, [--pretend], [--no-pretend] # Run but do not make any changes
-q, [--quiet], [--no-quiet] # Suppress status output
-s, [--skip], [--no-skip] # Skip files that already exist
Description:
Generates a New Stimulus Controller
Example:
bin/rails generate stimulus Thing
This will create:
app/javascripts/controllers/thing_controller.js
This is a good start, but nothing happens if we run the generator. Let’s fix this. Let’s start by adding a test to our test/lib/generators/stimulus_generator_test.rb
# test/lib/generators/stimulus_generator_test.rb
require "test_helper"
require "generators/stimulus/stimulus_generator"
class StimulusGeneratorTest < Rails::Generators::TestCase
tests StimulusGenerator
destination Rails.root.join("tmp/generators")
setup :prepare_destination
test 'should add thing controller file' do
run_generator ['Thing']
assert_file 'app/javascript/controllers/thing_controller.js'
end
end
Now we have created a failing test. We can run it.
rails test test/lib/generators/stimulus_generator_test.rb
# Running:
F
Failure:
StimulusGeneratorTest#test_should_add_thing_controller_file [/Users/williamkennedy/projects/honeybadger/rails_generators/test/lib/generators/stimulus_generator_test.rb:12]:
Expected file "app/javascript/controllers/thing_controller.js" to exist, but does not
rails test test/lib/generators/stimulus_generator_test.rb:9
Finished in 0.007052s, 141.8037 runs/s, 141.8037 assertions/s.
1 runs, 1 assertions, 1 failures, 0 errors, 0 skips
Let’s fix this.
# lib/generators/stimulus/stimulus_generator.rb
class StimulusGenerator < Rails::Generators::NamedBase
source_root File.expand_path("templates", __dir__)
def create_stimulus_controller
create_file "app/javascript/controllers/#{file_path}_controller.js"
end
end
Now when we run our tests, it should pass.
rails test test/lib/generators/stimulus_generator_test.rb
Running 1 tests in a single process (parallelization threshold is 50)
Run options: --seed 41043
# Running:
.
Finished in 0.007780s, 128.5347 runs/s, 128.5347 assertions/s.
1 runs, 1 assertions, 0 failures, 0 errors, 0 skips
Building A Generator - Generating Code
So far, what we’ve built is pretty cool but not practical. It only creates an empty file. However, we’ve got all the building blocks ready to take our generator further, so let’s start with a non-empty file.
Most stimulus controllers have the following code. The connect and initialize methods are optional.
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
initialize() {
}
connect() {
}
}
Our generator does not add any of this code, and it would be helpful if it did. Let’s modify our generator to do this.
In our tests, let’s add the following:
# test/lib/generators/stimulus_generator_test.rb
class StimulusGeneratorTest < Rails::Generators::TestCase
...
test 'should add stimulusjs boilerplate' do
run_generator ['Thing']
assert_file "app/javascript/controllers/thing_controller.js" do |content|
assert_match /Controller/, content
assert_match /connect()/, content
assert_match /@hotwired/, content
end
end
end
In true test-driven-development fashion, we run this test, and it fails.
rails test test/lib/generators/stimulus_generator_test.rb
Run options: --seed 15750
# Running:
F
Failure:
StimulusGeneratorTest#test_should_add_stimulusjs_boilerplate [/Users/williamkennedy/projects/honeybadger/rails_generators/test/lib/generators/stimulus_generator_test.rb:17]:
Expected /Controller/ to match "".
rails test test/lib/generators/stimulus_generator_test.rb:15
.
Finished in 0.009860s, 202.8398 runs/s, 405.6795 assertions/s.
2 runs, 4 assertions, 1 failures, 0 errors, 0 skips
We can take advantage of our templates folder to make this test pass. Let’s create a new file that will be a template for our stimulus controller.
touch lib/generators/stimulus/templates/stimulus_controller.js.erb
In that file, add our stimulus controller boilerplate.
// lib/generators/stimulus/templates stimulus_controller.js.erb
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
initialize() {
}
connect() {
}
}
Let’s also update our generator class.
# lib/generators/stimulus/stimulus_generator.rb
class StimulusGenerator < Rails::Generators::NamedBase
source_root File.expand_path("templates", __dir__)
def create_stimulus_controller
template 'stimulus_controller.js.erb', "app/javascript/controllers/#{file_path}_controller.js"
end
end
Now, let’s rerun our test.
rails test test/lib/generators/stimulus_generator_test.rb
Running 2 tests in a single process (parallelization threshold is 50)
Run options: --seed 61204
# Running:
..
Finished in 0.010278s, 194.5904 runs/s, 778.3615 assertions/s.
2 runs, 8 assertions, 0 failures, 0 errors, 0 skips
We have saved ourselves quite a lot of time. The next time we need a new stimulus controller, we run our generator, and we’re ready to go. However, now that we have gone beyond the basics of creating a file, could we go further. Could we make our generator even better?
Build A Generator - Handling Command Line Arguments
When creating a new Rails model, we can pass in options.
rails g model Item name:string description:text
This will create an ”items” table with name and description columns. StimulusJS controllers have several options that allow us to expand the usefulness of our controller.
Wouldn’t it be great if we could also generate markup from our generator? Let’s try it.
Go back to our tests and write our test case.
# test/lib/generators/stimulus_generator_test.rb
class StimulusGeneratorTest < Rails::Generators::TestCase
...
test 'should generate actions' do
run_generator ["toggle", "--actions", "toggle hide"]
assert_file "app/javascript/controllers/toggle_controller.js" do |content|
assert_match /toggle()/, content
assert_match /hide()/, content
end
end
end
Let’s run our tests.
rails test test/lib/generators/stimulus_generator_test.rb
Running 3 tests in a single process (parallelization threshold is 50)
Run options: --seed 2776
# Running:
.F
Failure:
StimulusGeneratorTest#test_should_generate_actions [/Users/williamkennedy/projects/honeybadger/rails_generators/test/lib/generators/stimulus_generator_test.rb:26]:
Expected /toggle()/ to match "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n\n initialize() {\n }\n \n connect() {\n }\n}\n\n".
rails test test/lib/generators/stimulus_generator_test.rb:24
.
Finished in 0.013761s, 218.0074 runs/s, 799.3605 assertions/s.
3 runs, 11 assertions, 1 failures, 0 errors, 0 skips
When taking arguments in our generators, we can use the class_option
method. We now need to update our generator once again.
class StimulusGenerator < Rails::Generators::NamedBase
source_root File.expand_path("templates", __dir__)
class_option :actions, type: :array, default: []
def create_stimulus_controller
template 'stimulus_controller.js.erb', "app/javascript/controllers/#{file_path}_controller.js"
end
end
Note that we have an empty array as the default for the argument. This means we don’t have to worry about nil errors if we don’t use the action argument.
We can access the arguments by using the options
method. Here’s how I have done it in the lib/generators/stimulus/templates/stimulus_controller.js.erb
:
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
initialize() {
}
connect() {
}
<% options[:actions].each do |action| %>
<%= action %>(){
}
<% end %>
}
Now when we can run our generator with options.
rails g stimulus toggle --actions toggle hide
This will produce the following in the file app/javascript/controllers/toggle_controller.js
:
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
initialize() {
}
connect() {
}
toggle(){
}
hide(){
}
}
And, of course, if we rerun our tests, we’ll see that they pass.
rails test test/lib/generators/stimulus_generator_test.rb
Running 3 tests in a single process (parallelization threshold is 50)
Run options: --seed 47721
# Running:
...
Finished in 0.015036s, 199.5211 runs/s, 864.5916 assertions/s.
3 runs, 13 assertions, 0 failures, 0 errors, 0 skips
We could do the same process for the other stimulus features, such as targets, values, and CSS classes.
- Create the test.
- Add the class option.
- Adjust our template.
- Ensure the test passes.
Conclusion
Generators are pretty helpful and can save hours, reduce boilerplate errors, and create a standard across your codebase as it gets larger over time.
However, there is a balance to be struck on when to automate something using a generator. Luckily, XKCD has a handy guide.
The source code for this app can be found here.
Posted on February 9, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 29, 2024