We don’t have to be experts at building complex generators in order to save time. Sometimes the smaller and simpler tasks are perfect for custom generators. While you’re likely familiar with some of the most commonly-used Rails generators like resource, model, scaffold, and migration, there are some other smaller generators as well. They don’t use ActiveRecord or generate a dozen files, but they do save time and reduce tedium.

While the most well-known generators tend to do more and create multiple files, generators can be just as useful for small tasks. ::Becoming familiar with how they work drastically expands the types of tasks that generators can automate, but even the most basic understanding empowers us to build time-saving custom Generators for simple tasks.::

The Rails benchmark generator illustrates this perfectly. Even better, since the benchmark generator creates code that’s designed to only ever run in development. ::Generators can do so much more than generate code to run in production.:: We shouldn’t pigeonhole generators by believing they have to be large, complex, or create production code, and the benchmark generator provides a great example of just how true that is.

From time-to-time, we’ll want to compare two different bits of code to see which is faster. When we need this, there’s a fair amount of boilerplate setup that can be tedious to remember. It would be fair to say that someone could create the file manually, but the benchmark generator makes the process almost trivial.

Let’s dive right into the code. We’ll start with the usage file so we have some context. (Figure 1)

railties/lib/rails/generators/rails/benchmark/USAGE Description: Generate benchmarks to compare performance optimizations. Makes use of the `benchmark-ips` gem as it provides a number of benefits like: - Simple significance test - Automatic warmup - No need to specify the number of iterationsExample: `bin/rails generate benchmark opt_compare` This will create: script/benchmarks/opt_compare.rb You can run the generated benchmark file using: `ruby script/benchmarks/opt_compare.rb` You can specify different reports: `bin/rails generate benchmark opt_compare patch1 patch2 patch3`
Figure 1

The benchmark generator accepts a single required argument and, optionally, a list of report names. It creates precisely one file, but it also proactively ensures that the benchmark-ips gem is included in our Gemfile without creating a duplicate each time the generator is run.

↩︎

Now that we have an idea of what to expect, let’s look at all 30 lines of generator code that makes it work. (Figure 2) Before we look at the code, even though the file has 30 lines of code, only 6 of those lines are doing any work when we run it.

railties/lib/rails/generators/rails/benchmark/benchmark_generator.rb # frozen_string_literal: truerequire "rails/generators/named_base"module Rails module Generators class BenchmarkGenerator < NamedBase IPS_GEM_NAME = "benchmark-ips" IPS_GEM_USED_REGEXP = /gem.*\b#{IPS_GEM_NAME}\b.*/ argument :reports, type: :array, default: ["before", "after"] def generate_layout add_ips_to_gemfile unless ips_installed? template("benchmark.rb.tt", "script/benchmarks/#{file_name}.rb") end private def add_ips_to_gemfile gem(IPS_GEM_NAME, group: [:development, :test]) end def ips_installed? in_root do return File.read("Gemfile").match?(IPS_GEM_USED_REGEXP) end end end endend
Figure 2

The benchmark generator involves very few lines of code related to its core work. It ensures the benchmark-ips gem is in the Gemfile, and it makes a single template call.

↩︎

Given the logic in the generator, we also need to consider the template file in order to appreciate the concise nature of it all. (Figure 3) To be fair, it also adds 15 lines of code to bring us up to 45 lines of code.

railties/lib/rails/generators/rails/benchmark/templates/benchmark.rb.tt # frozen_string_literal: truerequire_relative "../../config/environment"# Any benchmarking setup goes here...Benchmark.ips do |x|<%- reports.each do |report| -%> x.report("<%= report %>") { }<%- end -%> x.compare!end
Figure 3

The ERb template for the benchmark file uses a single loop paired with a single dynamic value. It can’t get much simpler.

↩︎

And finally, we need to look at the 86 lines of tests that ensure the generator works. (Figure 4) That number goes down to 58 lines if we ignore the fact that 28 lines are almost exactly the same as the contents of our template. Subtract another 11 that were automatically provided by the benchmark generator, and 47 lines would be a more accurate representation of the amount of effort required.

railties/test/generators/benchmark_generator_test.rb # frozen_string_literal: truerequire "generators/generators_test_helper"require "rails/generators/rails/benchmark/benchmark_generator"module Rails module Generators class BenchmarkGeneratorTest < Rails::Generators::TestCase include GeneratorsTestHelper setup do copy_gemfile end test "generate benchmark" do run_generator ["my_benchmark"] assert_file("Gemfile") do |content| assert_match "gem \"benchmark-ips\"", content end assert_file("script/benchmarks/my_benchmark.rb") do |content| assert_equal <<~RUBY, content # frozen_string_literal: true require_relative "../../config/environment" # Any benchmarking setup goes here... Benchmark.ips do |x| x.report("before") { } x.report("after") { } x.compare! end RUBY end end test "generate benchmark with no name" do output = capture(:stderr) do run_generator [] end assert_equal <<~MSG, output No value provided for required arguments 'name' MSG end test "generate benchmark with reports" do run_generator ["my_benchmark", "with_patch", "without_patch"] assert_file("script/benchmarks/my_benchmark.rb") do |content| assert_equal <<~RUBY, content # frozen_string_literal: true require_relative "../../config/environment" # Any benchmarking setup goes here... Benchmark.ips do |x| x.report("with_patch") { } x.report("without_patch") { } x.compare! end RUBY end end test "generate benchmark twice only adds ips gem once" do run_generator ["my_benchmark"] run_generator ["my_benchmark"] assert_file("Gemfile") do |content| occurrences = content.scan("gem \"benchmark-ips\"").count assert_equal 1, occurrences, "Should only have benchmark-ips present once" end end end endend
Figure 4

While the test file contains the most lines, it’s ultimately just three tests and nine assertions. And once you’re familiar with generators, these types of tests are incredibly straightforward thanks to the custom assertions that Rails provides for generators.

↩︎

At a glance, this code isn’t entirely simple, but relative to what it can create, it’s significant. When we consider that the built-in generator generator gives us many of those lines automatically, there’s not much effort involved. However, that’s a significant claim, so let’s create an example benchmark with rails g benchmark UserQuery with_preloading without_preloading. As we saw, the built-in Generator handles two key elements for us.

  1. It ensures we have the benchmark-ips gem in our Gemfile for our development and test environments.
  2. It generates a short Ruby script that auto-loads our Rails environment and creates placeholders for our comparisons.

Based on the generated file that follows, one could make a case that this would be better handled as a snippet in a developer tool rather than a generator. ::For that snippet argument would be infallible, but with generators, it ignores the value of distribution.:: When we create a generator, it’s available not only to us, but to anybody else who ever works on the codebase. Moreover, it’s fully tested and reliable. So updates and enhancements are more predictable and automatically distributed to anyone else. And for the cherry on top, since Rails developers already know how to find and use generators, there’s no learning curve.

Finally while this particular generator doesn’t take advantage of introspection into the Rails codebase, that’s another huge benefit of generators. By being a part of our code, they have access to metadata that snippets won’t.

At first, those might sound like minor details, but that distribution represents one of the core benefits of saving time with generators. If each team member creates their own snippets, they’ll be inconsistent and redundant. If one person creates a generator, however, everyone can use it, and the number of people that use it becomes a multiplier for time savings. That makes the economics of saving time with generators work out in our favor much more frequently.

lib/generators/benchmark/benchmark_generator_test.rb # frozen_string_literal: truerequire_relative "../../config/environment"# Any benchmarking setup goes here...Benchmark.ips do |x| x.report("with_preloading") { } x.report("without_preloading") { } x.compare!end
Figure 5

It doesn’t generate much content, but it does save us from having to remember and type out some Ruby that may not always be fresh on our minds if we’re not using it ever day.

↩︎

There’s not much there, right? But it still saves time and tedium!

Once we add the code for each of the variations, we can call ruby script/benchmarks/user_query.rb to run it. It’s not fancy, and in many cases, we might never even commit the resulting code. It’s not conceptually complex. It’s merely a little more convenient and a reasonable amount faster. It’s not saving us model-generator amounts of work, but it saves us from the type of work that creates just enough friction that we either skip it or we end up distracted by the process of setting it up manually.

Custom generators don’t have to be complex or lengthy. We’re only trying to save time and reduce tedium, and as long as a generator does that, it’s a good generator. And often, removing even the tiniest amounts of friction can lead to team members taking the extra steps—like running benchmarks—that can lead to higher-quality code overall. Once we start being able to recognize these opportunities, the overall time savings available start to add up. Plus we end up spending more time on the interesting high-value work rather than the tedious boilerplate work.