From speaking with Rails devs, many quickly stop using the built-in generators (save for a few like model
or migration
), the common reason being that the generated files no longer suit the project - so it’s easier to start from scratch rather generate and then modify them. What a pity! To paraphrase DHH, generators can help improve team productivity and encourage consistency - we just need to tweak them.
In this post, I look at one approach for customising generators, based on one we customised recently at work: the rake task generator.
TL;DR on Overriding a Generator
- Copy over the Generator classes to
/lib/generators/...
and modify - Copy over the template file to
/lib/templates/...
and modify - Bonus: Use
hook_for
to generate a test file if the generator doesn’t do it automatically
The Problem
At work, we use rake tasks quite a fair bit, both for cron-like jobs and one-off migration or support tasks. Rails has a built-in task generator, with the form:
bin/rails g task file_name action
It’s never quite worked for us:
- We like to keep one task “action” per file, with the file named the same as the action - this makes it easier to grep for the task. The built-in generator requires separate
file_name
andaction
arguments, which is a little unnecessary. - We sometimes namespace tasks e.g. with a “onetime” namespace for one-off tasks, but the built-in generator ignores paths in the
file_name
and also uses thefile_name
as the namespace. - We write tests for our rake tasks, but the generator doesn’t create test files.
Let’s address these! There are different approaches available, but in this case it made sense to override the generator entirely - the team wasn’t using it, so we weren’t breaking established conventions.
Overriding the Task Generator
Rails searches for generators in a few paths, so as long as we place our file in /lib/generators/task/task_generator.rb
, Rails will load our custom file instead.
We’ll copy over the original task generator to use as a base. Most of the default generators can be found in railties/lib/rails/generators/rails
, the one we’re after is here:
module Rails
module Generators
class TaskGenerator < NamedBase # :nodoc:
argument :actions, type: :array, default: [], banner: "action action"
def create_task_files
template "task.rb", File.join("lib/tasks", "#{file_name}.rake")
end
end
end
end
Copy it over to /lib/rails/generators/task/task_generator.rb
. As a simple sanity check to see if we've overridden the class, let’s make the generator spit out a hardcoded file name and run it. Change this line:
template "task.rb", File.join("lib/tasks", "hardcoded_file_name.rake")
Then run the command:
bin/rails g task path/to/my_task
# create lib/tasks/hardcoded_file_name.rake
It works! Let's revert the hardcoding and leave the rest of the class as-is for now. We'll be back, promise.
Overriding the Generator Template
We'll going to be customising the template as well, so let's override that. When invoked, generators search for templates in a different set of paths from the invoker (I explored this more in another post) - we'll be wanting to place our template at /lib/templates/rails/task/task.rb.tt
.
Copy the original and modify it as such (the lack of indentation is intentional):
<% class_path.each_with_index do |path_fragment, index| -%>
<%= indent("namespace :#{path_fragment}", index * 2) %> do
<% end -%>
<% content = capture do -%>
desc "TODO"
task <%= file_name %>: :environment do
end
<% end -%>
<%= indent(content, class_path.size * 2) %>
<% (0...class_path.size).reverse_each do |index| -%>
<%= indent('end', index * 2) %>
<% end -%>
Let's breakdown the changes. First, we'll sort our name-spacing, including the indentation (note that indent
is actually a private method on Rails::Generators::Base
, so you won't be able to use it in your regular view templates):
<% class_path.each_with_index do |path_fragment, index| -%>
<%= indent("namespace :#{path_fragment}", index * 2) %> do
<% end -%>
...
<% (0...class_path.size).reverse_each do |index| -%>
<%= indent('end', index * 2) %>
<% end -%>
With the previous command bin/rails g task path/to/my_task
, the above results in:
namespace :path do
namespace :to do
...
end
end
Now lets sort the task itself:
...
<% content = capture do -%>
desc "TODO"
task <%= file_name %>: :environment do
end
<% end -%>
<%= indent(content, class_path.size * 2) %>
...
Here, we indent the task description strings by first assigning it to a variable, content
via the capture
helper. Then, we indent and insert it into the file. So running the command again will give us:
namespace :path do
namespace :to do
desc "TODO"
task my_task: :environment do
end
end
end
Almost there! Our file is still being created as /lib/tasks/my_task.rake
though, instead of /lib/tasks/path/to/my_task.rake
.
Fixing the Generated Path
Let's fix that. Here's the diff for our generator:
4,5d3
< argument :actions, type: :array, default: [], banner: "action action"
<
7c5
< template "task.rb", File.join("lib/tasks", "#{file_name}.rake")
---
> template "task.rb", File.join("lib/tasks", class_path, "#{file_name}.rake")
We've removed the unnecessary actions
argument and added the class_path
to the generated file's path. Running the command again nests the file nicely now.
Hooking in a Test file
“Should you test rake tasks?”... is not a discussion for this article, let’s assume you do! We can customise our generator to create a test file.
The method we’re looking for is hook_for, which will cause the generator to attempt to also invoke other generators. Add the following DSL to your generator class:
hook_for :test_framework
In short, this attempts to invoke Rspec::Generators::TaskGenerator
, which... doesn’t exist yet. Let’s resolve that.
Adding RSpec Generator and Template
RSpec doesn’t have a custom generator for tasks, but the steps for overriding it are more or less the same. Let’s start by creating the TaskGenerator class in /lib/generators/rspec/task/task_generator.rb
:
# frozen_string_literal: true
require 'generators/rspec'
module Rspec
module Generators
class TaskGenerator < Base # :nodoc:
def create_task_specs
template "task_spec.rb", File.join("spec/tasks", class_path, "#{file_name}_rake_spec.rb")
end
end
end
end
Next, we need to create the corresponding template in /lib/templates/rspec/task/task_spec.rb.tt
(note: we can definitely DRY this out more, you might be able to draw some inspiration from these examples by Thoughtbot and 10Pines):
require 'rails_helper'
require 'rake'
describe '<%= class_path.join(':') %>' do
describe ':<%= file_name %>' do
before(:all) do
Rake::Task.define_task(:environment)
Rake.application = rake = Rake::Application.new
rake.rake_require 'tasks/<%= File.join(class_path, file_name) %>'
@task = rake['<%= class_path.concat([file_name]).join(':') %>']
end
it 'works' do
@task.execute
end
end
end
With that done, running the generator command now creates the files just as we wanted. Neat!
Go forth and generate!
I hope this has given you some inspiration to give generators a second chance - with a little tweaking, they can be really useful in saving time for you and your team. What ideas do you have for improving your generators?
Top comments (0)