Automating Patterns with Generators

by Brian Rossetti on November 27, 2016

While working on a recent Mininum Viable Product (MVP), there was a predetermined pattern involving Model Profiles and Form Objects that would be crucial to the development of the MVP. In order to address this, I dove into Rails generators so we could quickly implement new Profile Types and Form Objects as we received more detail.

Generators rely on Thor to provide most of their functionality. This library is not well documented but if you include it in your project and run bundler open thor from the command line, you can take a peak at the source code, which has alot of documentation itself through comments. When it comes to generators, rails makes it easy to start, you just run the generator to create generators:

>rails generate generator photographer

  create  lib/generators/photographer
  create  lib/generators/photographer/test_generator.rb
  create  lib/generators/photographer/USAGE
  create  lib/generators/photographer/templates
  invoke  test_unit
  create  test/lib/generators/photographer_generator_test.rb

The Generator Requirments

  1. A Model file needs to be added to the like so: models/vendors/profiles/#{model_name}.rb
  2. A migration file should be added for the Model
  3. Each Model needs a Form Object file created and added like so: models/form_object/#{model_name}.rb
  4. The Model Name needs to be added to a lib file that catelogs each profile. The name needs to be appended to the file: lib/vendor_groupings.rb inside seveal arrays.
  5. Each file should be able to process any attributes that are passed in and append the fields to the appropriate spot in the file.

When trying to figure out how to implement generators, I beleive the best spot to start is to open some of the source code of libraries that already have generators built out so you can see examples of how a generator is used. Generators run methods one at a time from the top of a generator file to the bottom.

First we need a model and a migration file that takes attribute and type as an argument. This sounds alot like when you create a devise user, lets open that library up and see how they do it:

bundler open devise

From Devise in lib/generators/active_record/devise_generator

require 'rails/generators/active_record'
require 'generators/devise/orm_helpers'

module ActiveRecord
  module Generators
    class DeviseGenerator < ActiveRecord::Generators::Base
      argument :attributes, type: :array, default: [], banner: "field:type field:type"

      include Devise::Generators::OrmHelpers
      source_root File.expand_path("../templates", __FILE__)

      def copy_devise_migration
        if (behavior == :invoke && model_exists?) || (behavior == :revoke && migration_exists?(table_name))
          migration_template "migration_existing.rb", "db/migrate/add_devise_to_#{table_name}.rb", migration_version: migration_version
        else
          migration_template "migration.rb", "db/migrate/devise_create_#{table_name}.rb", migration_version: migration_version
        end
      end

      ..

      def migration_data
<<RUBY
      ## Database authenticatable
      t.string :email,              null: false, default: ""
      t.string :encrypted_password, null: false, default: ""

      ## Recoverable
      t.string   :reset_password_token
      t.datetime :reset_password_sent_at

      ## Rememberable
      t.datetime :remember_created_at

      ## Trackable
      t.integer  :sign_in_count, default: 0, null: false
      t.datetime :current_sign_in_at
      t.datetime :last_sign_in_at
      t.#{ip_column} :current_sign_in_ip
      t.#{ip_column} :last_sign_in_ip

      ## Confirmable
      # t.string   :confirmation_token
      # t.datetime :confirmed_at
      # t.datetime :confirmation_sent_at
      # t.string   :unconfirmed_email # Only if using reconfirmable

      ## Lockable
      # t.integer  :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts
      # t.string   :unlock_token # Only if unlock strategy is :email or :both
      # t.datetime :locked_at
RUBY
      end

      ..

      def migration_version
        if rails5?
          "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
        end
      end
    end
  end
end

from Devise in lib/generators/active_record/templates/migration

class DeviseCreate<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_version %>
  def change
    create_table :<%= table_name %> do |t|
<%= migration_data -%>

<% attributes.each do |attribute| -%>
      t.<%= attribute.type %> :<%= attribute.name %>
<% end -%>

      t.timestamps null: false
    end

    add_index :<%= table_name %>, :email,                unique: true
    add_index :<%= table_name %>, :reset_password_token, unique: true
    # add_index :<%= table_name %>, :confirmation_token,   unique: true
    # add_index :<%= table_name %>, :unlock_token,         unique: true
  end
end

This looks like exactly what we are looking for to start off. All we need is to in our app is add a model template and a migration template. If we add a model and migration template, we can accomplish much of what we are looking for. Devise also uses the argument class level assignment to allow attributes to be passed into the generator.

argument :attributes, type: :array, default: [], banner: "field:type field:type"

initial rails 5 Profile generator:

in :yourproject/lib/generators/profiles/profiles_generator.rb

require 'rails/generators/active_record'

class ProfilesGenerator < Rails::Generators::NamedBase
  include Rails::Generators::Migration

  source_root File.expand_path('../templates', __FILE__)

  argument :attributes, type: :array, default: [], banner: "field:type field:type"

  def generate_namespaced_model_file
    template "profile.rb", File.join('app/models/vendor/profile/', "#{file_path.tr('/', '_')}.rb")
  end

  def generate_migration_file
    migration_template "migration.rb", "db/migrate/vendor_profile_create_#{table_name}.rb", migration_version: migration_version
  end

  def migration_version
    "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
  end
end

templates in :yourproject/lib/generators/profiles/templates/:file

class VendorProfileCreate<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_version %>
  def change
    create_table :vendor_profile_<%= table_name %> do |t|

<% attributes.each do |attribute| -%>
      t.<%= attribute.type %> :<%= attribute.name %>
<% end -%>

      t.timestamps null: false
    end
  end
end

class Vendor::Profile::<%=class_name%> < ApplicationRecord
  # migration file should create table under vendor_profile_<%=class_name.to_s.tableize%>
  self.table_name = "vendor_profile_<%=class_name.to_s.tableize%>"
end

in this project we had a lib file that kept an array of the Profile Name and the Profile Model Name, lets use the generator to add and new profiles we create to that file:

in :yourproject/lib/generators/profiles/profile_generator.rb

require 'rails/generators/active_record'

class ProfilesGenerator < Rails::Generators::NamedBase
  include Rails::Generators::Migration

  source_root File.expand_path('../templates', __FILE__)

  ..

  def add_model_to_profile_types
    gsub_file 'lib/vendor_groupings.rb', /(#{Regexp.escape("VENDOR_PROFILE_TYPES = [")})/ do |match|
      "#{match}\n    #{profile_type_insert}"
    end
  end

  ..

  def profile_type_insert
    "[\"#{class_name}\", ::Vendor::Profile::#{class_name}],"
  end

end

here we use gsub_file method which allows us to use a regex to locate a position in a file and append whatever we want in the block using the match here as the reference in the file we want to gsub.

in :yourproject/lib/vendor_groupings.rb

before generator:

module VendorGroupings
  VENDOR_PROFILE_TYPES = [
    ["Model", ::Vendor::Profile::Model],
    ["Caterer", ::Vendor::Profile::Caterer]
  ]
end

after generator:

module VendorGroupings
  VENDOR_PROFILE_TYPES = [
    ["Photographer", ::Vendor::Profile::Photographer],
    ["Model", ::Vendor::Profile::Model],
    ["Caterer", ::Vendor::Profile::Caterer]
  ]
end

as you can see, when we gsub, we are just adding a new line to the matched regex, and then inserting the profile type into vendor_grouping.rb.

now lets add a form object to the generator using the same methods:

in :yourproject/lib/generators/profiles/profile_generator.rb

require 'rails/generators/active_record'

class ProfilesGenerator < Rails::Generators::NamedBase
  include Rails::Generators::Migration

  source_root File.expand_path('../templates', __FILE__)

  ..

  def generate_form_object
    template "form_object.rb", File.join('app/models/form_object/', "#{file_path.tr('/', '_')}_profile.rb")
  end

  def add_attributes_to_form_object
    gsub_file "app/models/form_object/#{class_name}_profile.rb", /(#{Regexp.escape("#     attribute_placeholder")})/ do |match|
      string = ""
      count = 0
      attributes.each do |attribute|
        count+=1
        string+="#     "
        count == attributes.count ? string += "#{attribute.name}: \"#{attribute.name.humanize.titleize}\"" : string += "#{attribute.name}: \"#{attribute.name.humanize.titleize}\",\n    "
      end
      string
    end
  end
end

in :yourproject/lib/generators/profiles/templates/:file

module FormObject
  class <%=class_name%>Profile < FormObject::Base
    attr_reader :profile
    # delegate

    def initialize(profile)
      @profile ||= profile
    end

    def attributes
      # need to define the attributes that will be used to dynamically generate forms
      @attributes ||= self.class.attributes + <%= attributes.map { |attribute| attribute.name.to_sym } %>
    end
  end
end

Lets add the pluralized model name to the routes file:

in :yourproject/lib/generators/profiles/profile_generator.rb

require 'rails/generators/active_record'

class ProfilesGenerator < Rails::Generators::NamedBase
  include Rails::Generators::Migration

  source_root File.expand_path('../templates', __FILE__)

  ..

  def add_resource_routes
    route "resources :#{class_name.underscore.pluralize}"
  end
end

when you run the generator, this will show up in your routes file:

in :youproject/config/routes.rb

Rails.application.routes.draw do

  resources :hello_worlds
  ..

end

This generator continued to get more advanced as the project moved along but I think thats enough for an example of how to implement your own generators. Generators are a little different then programming a regular Ruby object when you first jump in, but if you invest a little bit of time to understanding them, they turn into powerful tools for automating development in your app. If you want to get a better understanding of generators, I would suggest finding libraries with powerful generators, and opening up the source code to see how they operate.

here are a few examples:

Ruby On Rails

Let's Get In Touch!


Our best work gets done when we can work face-to-face with you.

770-317-4866