DEV Community

loading...

Don’t give up on your Rails Generators!

wasabigeek profile image Nick Originally published at wasabigeek.com ・5 min read

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
Enter fullscreen mode Exit fullscreen mode

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 and action 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 the file_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
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

Then run the command:

bin/rails g task path/to/my_task
# create  lib/tasks/hardcoded_file_name.rake
Enter fullscreen mode Exit fullscreen mode

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 -%>
Enter fullscreen mode Exit fullscreen mode

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 -%>
Enter fullscreen mode Exit fullscreen mode

With the previous command bin/rails g task path/to/my_task, the above results in:

namespace :path do
  namespace :to do
    ...
  end
end
Enter fullscreen mode Exit fullscreen mode

Now lets sort the task itself:

...
<% content = capture do -%>
desc "TODO"
task <%= file_name %>: :environment do
end
<% end -%>
<%= indent(content, class_path.size * 2) %>
...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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?

Discussion (0)

pic
Editor guide