Haseeb Annadamban

Building Custom Generators in Rails

ยท 1856 words ยท 9 minutes to read

In Ruby on Rails, A generator is a tool that helps create scaffolding (basic code structure). By default, Rails provides several built-in generators like rails generate scaffold, rails generate controller, etc. Many of the gems come with their own Rails generators too.

A custom generator is used to create a customized scaffolding for your application. Custom generators in Rails allow developers to create their code generators to streamline repetitive tasks and enforce consistency across the codebase.

Benefits of Custom Generator ๐Ÿ”—

You might wonder why you’d need custom generators when Rails already provides a rich set of built-in tools. Here are some reasons:

  1. Consistency: With a custom generator, you can enforce consistency across your application by creating the same structure and naming conventions for related models or controllers.
  2. Customization: By writing your generator script, you can add custom features, methods, or logic that aren’t provided by the built-in generators.
  3. Reusability: Once you’ve created a custom generator, you can reuse it across multiple projects or within your team to ensure consistent code quality and structure

Creating Custom Generator ๐Ÿ”—

Let’s create one custom generator to learn more about it. In this tutorial, we will create a custom scaffold that generates the model, controller, specs, pundit policy, and services. This will make use of the existing generators in Rails to create models, controllers, and views. It will use the pundit gem to generate a pundit policy. This scenario makes it easy for me to walk you through all the major aspects of the code generator. I’m not here to discuss about the debate whether scaffolding is good or bad. Just know this debate exists.

Step 1: Generate The Generator ๐Ÿ”—

rails g generator custom_scaffold

It will generate the following files in lib

generators
โ””โ”€โ”€ custom_scaffold
 โ”œโ”€โ”€ USAGE
 โ”œโ”€โ”€ custom_scaffold_generator.rb
 โ””โ”€โ”€ templates

Step 2: Define The Generator ๐Ÿ”—

In lib/generators/custom_scaffold_generator, you can add logic to create files, directories, or modify existing files.

The default custom_scaffold_generator.rb will look like this.

class CustomScaffoldGenerator < Rails::Generators::NamedBase
 source_root File.expand_path("templates", __dir__)
end

In this source_root is where it will look for templates. A template is a file with erb type templates that will be used to create the files.

Let’s start by creating a service. Add the following method into CustomScaffoldGenerator

def create_service
 template 'service.rb', "app/services/#{file_name}_service.rb"
end

The template method is used to define which template should be used for creation and under what name. The name of the method create_service is not important. You can create any number of files from a single method.

In our case let’s say we want to generate separate services for create, update, and destroy. Replace the existing create_service method with this create_services method.

def create_services
  %w[create update destroy].each do |action|
    @action = action
 template 'service.rb', File.join('app/services', class_path, "#{file_name}_#{action}_service.rb")
  end
end

Step 3: Create The Template ๐Ÿ”—

The templates are simple ERB-styled files where we define which pages to replace. Create the file service.rb.tt with the following content.

class <%= class_name %><%= @action.capitalize %>Service
  def self.call(*args)
    new(*args).call
  end

  def initialize(<%= @action == 'destroy' ? singular_name : "params" %>)
 @<%= @action == 'destroy' ? singular_name : "params" %> = <%= @action == 'destroy' ? singular_name : "params" %>
 end

 def call
 # TODO: Implement <%= @action %> logic
    OpenStruct.new(success?: true)
  end

  private

  attr_reader :<%= @action == 'destroy' ? singular_name : "params" %>
end

As in regular Rails templates, class_name is passed an argument. The template can access @action. This internally uses a gem called Thor. Please see its documentation to learn more about the templates.

The class_name is generated from the first argument Here are the other methods and equivalent code in rails. Here is a list of other useful arguments

  1. class_name: Returns the class name for the resource, derived from the first argument. Example: If the first argument is Post, class_name returns Post.

  2. singular_name: Returns the singular form of the resource name, typically in snake_case. Example: If the first argument is Post, singular_name returns post.

  3. plural_name: Returns the plural form of the resource name, typically in snake_case. Example: If the first argument is Post, plural_name returns posts.

  4. file_name: Returns the file name for the resource, typically in snake_case. Example: If the first argument is Post, file_name returns post.

  5. table_name: Returns the table name for the resource, typically in snake_case and pluralized. Example: If the first argument is Post, table_name returns posts.

  6. human_name: Returns a human-readable version of the resource name. Example: If the first argument is Post, human_name returns Post.

  7. i18n_scope: Returns the I18n scope for the resource. Example: If the first argument is Post, i18n_scope returns activerecord.models.post.

  8. route_url: Returns the default route URL for the resource. Example: If the first argument is Post, route_url returns posts_url.

  9. resource_name: Returns the name of the resource in a human-readable form, usually capitalized. Example: If the first argument is Post, resource_name returns Post.

  10. resource_plural_name: Returns the plural form of the resource name, typically in a human-readable form. Example: If the first argument is Post, resource_plural_name returns Posts.

Step 4: Generate ๐Ÿ”—

To generate

rails generate custom_scaffold Article

Destroying Generated Files ๐Ÿ”—

Rails provides methods to destroy all the files generated by a generator.

rails destroy custom_scaffold Article

Using Existing Generators In Our Custom Generator ๐Ÿ”—

Rails comes with a default scaffold generator. It will generate the model, migrations, controller, and some tests with default code.

For example, the following will generate a basic implementation of an article with all the required forms, model migrations, and controllers/

rails generate scaffold Article name:string price:decimal

We can use this to generate a basic scaffolding. So that we don’t have to write the code directly every time.

Let’s make use of it in our generator. Add the following code into CustomScaffoldGenerator.

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

def create_scaffold
 generate "rails:scaffold", "#{name} #{attributes.join(' ')}"
end

To run this use,

rails generate custom_scaffold Article name:string price:decimal

The argument method is used to pass attributes to the scaffold. We configured it to accept attributes as an array.

Inside the create_scaffold method, We used existing scaffold generator using generate "rails:scaffold"

Then we passed all the arguments as a single string value.

To remove this you might have to call rails destroy scaffold Article separately.

Using Generators From Other Gems ๐Ÿ”—

Using generators from other gems is also straight forward. The Pundit is an authorization gem. Let’s add the pundit gem generator to generate the policies.

First, Make sure it is in your Gemfile and is installed by running:

bundle add pundit && rails g pundit:install

Add this method to the CustomScaffoldGenerator to generate the pundit policy.

def create_policy
 generate "pundit:policy", name
end

Adding content into existing files ๐Ÿ”—

We can make use of inject_into_file to add content to existing files.

The inject_into_file method takes the following parameters:

  • File path: ‘config/routes.rb’ - This specifies the file to be modified.
  • Options: options like after: or before with a specific regex or string
  • Content to inject: The block passed to inject_into_file contains the content to be injected into the file.

Pundit Gem is used for authorization. We need to call the authorize method before each action. This can generated. Let’s add helpers in our generator to achieve this.

def add_pundit_in_controller
  def add_pundit_in_controller
 inject_into_file "app/controllers/#{plural_file_name}_controller.rb", after: /before_action :set_#{singular_name}.*\n/ do
      "  before_action :authorize_#{singular_name}\n"
    end

 inject_into_file "app/controllers/#{plural_file_name}_controller.rb", after: "private\n" do
      "\n  def authorize_#{singular_name}\n" +
      "    authorize @#{singular_name} || #{class_name}\n" +
      "  end\n"
    end
  end
end

In the code above, The first inject_into_file adds before_action :authorize_article after the generated before_action :set_article callback using a regex.

The second inject_into_file simply adds the authorize_article method to the next line after the private keyword.

Replacing Existing Content ๐Ÿ”—

To edit the existing code, We can make use of the gsub_file method.

The gsub_file method takes the following arguments:

  • File path: The relative path to the file you want to modify.
  • Pattern: The pattern to search for within the file.
  • Replacement: The content to replace the matched pattern.

We have generated our service. But, to use it, we have to edit the controller and add it ourselves. We will edit the controller and replace existing controller methods with updated code.

Add the following method in the CustomScaffoldGenerator.

def use_service_in_controller
 gsub_file "app/controllers/#{plural_file_name}_controller.rb", /def destroy[\s\S]*?^  end$/m do
    <<-METHOD.strip_heredoc
 def destroy
 result = #{class_name}DestroyService.call(@#{singular_name})
 respond_to do |format|
 if result.success?
 format.html { redirect_to #{plural_name}_url, notice: '#{class_name} was successfully destroyed.' }
 format.json { render :show, status: :ok, location: #{plural_name}_url }
 else
 format.html { render :show, status: :unprocessable_entity }
 format.json { render json: result.errors, status: :unprocessable_entity }
 end
 end
 end
 METHOD
  end

  %w[create update].each do |action|
 gsub_file "app/controllers/#{plural_file_name}_controller.rb", /def #{action}[\s\S]*?^  end$/m do
      <<-METHOD.strip_heredoc
 def #{action}
 result = #{class_name}#{action.capitalize}Service.call(#{singular_name}_params)
 respond_to do |format|
 if result.success?
 format.html { redirect_to @#{singular_name}, notice: '#{class_name} was successfully #{action}d.' }
 format.json { render :show, status: :#{action == 'create' ? 'created' : 'ok'}, location: @#{singular_name} }
 else
 format.html { render :#{action == 'create' ? 'new' : 'edit'}, status: :unprocessable_entity }
 format.json { render json: result.errors, status: :unprocessable_entity }
 end
 end
 end
 METHOD
    end
  end
end

This will edit the create, update, and destroy methods. But now, The issue is the create, update, and destroy methods are not implemented in the existing services. With the knowledge so far. You will be able to add a sample code for separate services if needed. I would suggest creating three specific methods create_create_service, create_update_service and create destroy_service if you are doing that.

Final code ๐Ÿ”—

class CustomScaffoldGenerator < Rails::Generators::NamedBase
 source_root File.expand_path('templates', __dir__)

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

  def create_scaffold
 generate "rails:scaffold", "#{name} #{attributes.join(' ')}"
  end

  def create_services
    %w[create update destroy].each do |action|
      @action = action
 template 'service.rb', File.join('app/services', class_path, "#{file_name}_#{action}_service.rb")
    end
  end

  def create_policy
 generate "pundit:policy", name
  end

  def add_pundit_in_controller
 inject_into_file "app/controllers/#{plural_file_name}_controller.rb", after: /before_action :set_#{singular_name}.*\n/ do
      " before_action :authorize_#{singular_name}\n"
    end

 inject_into_file "app/controllers/#{plural_file_name}_controller.rb", after: "private\n" do
      "\n def authorize_#{singular_name}\n" +
      "   authorize @#{singular_name} || #{class_name}\n" +
      " end\n"
    end
  end

  def use_service_in_controller
    %w[create update destroy].each do |action|
 gsub_file "app/controllers/#{plural_file_name}_controller.rb", /def #{action}[\s\S]*?^  end$/m do
        <<-METHOD.strip_heredoc
 def #{action}
 result = #{class_name}#{action.capitalize}Service.call(#{action == 'destroy' ? '@' + singular_name : singular_name + '_params'})
 respond_to do |format|
 if result.success?
 format.html { redirect_to #{action == 'destroy' ? plural_name + '_url' : '@' + singular_name}, notice: '#{class_name} was successfully #{action}d.' }
 format.json { render :show, status: :#{action == 'create' ? 'created' : 'ok'}, location: #{action == 'destroy' ? plural_name + '_url' : '@' + singular_name} }
 else
 format.html { render :#{action == 'destroy' ? 'show' : (action == 'create' ? 'new' : 'edit')}, status: :unprocessable_entity }
 format.json { render json: result.errors, status: :unprocessable_entity }
 end
 end
 end
 METHOD
      end
    end
  end
end

Conclusion ๐Ÿ”—

Rails generators can streamline your development process. Automating repetitive tasks, allows you to focus more on the unique aspects of your application, improving both efficiency and consistency. Whether you are creating a new model, controller, or even a custom generator, understanding how to leverage this can make your development workflow more robust.

By customizing and creating your generators, you can enforce coding standards and project conventions across your team, ensuring that everyone is on the same page. This not only enhances productivity but also maintains the quality and maintainability of your codebase.

Remember, while generators are a great asset, they should be used thoughtfully. Always review and understand the code they produce, making adjustments as necessary to fit the specific needs and best practices of your project.

Your Feedback Matters!

I'd love to hear your thoughts on this. Connect with me on LinkedIn Or Twitter