Collections and Resources - DRYing Up

Part 6 of building a Rails 7 application

Now that I've got some scaffolded code in there for the models I intend to use for this application there is something I can do to DRY up the code somewhat.

Looking at CodeClimate for my repository as it stands right now it has given me a rating of C, with 28 instances of duplication.

image.png

So looking at what CodeClimate has flagged as an issue shows this:

image.png

All this controller code is the same with the only thing changing being the name of the class and instance variables. The same thing is happening in the update action and all other actions as well. I can DRY this up by introducing a concern which refers to something a bit more generic.

I'll call a specific instance of something a resource and a collection of them a collection.

The concern would look like this:

app\controllers\concerns\resources.rb

# DRY up controllers by using "resource" for a single record, "collection" for all records
module Resources
  extend ActiveSupport::Concern

  included do
    before_action :set_resource, only: %i[show edit update destroy]
    before_action :set_collection, only: %i[index]
  end

  private

  def set_collection
    @collection = resource_class.all
  end

  def set_resource
    @resource = resource_class.find(params[:id])
  end

  def resource_class
    # eg ItemSellPacksController -> ItemSellPack
    @resource_class ||= controller_name.gsub('Controller', '').singularize.classify.safe_constantize
  end

  def resource_name
    # eg ItemSellPack -> item_sell_pack
    @resource_name ||= resource_class.name.underscore.to_sym
  end

  def resource_human_name
    # eg ItemSellPack -> Item sell pack
    @resource_human_name ||= resource_class.model_name.human
  end
end

Now I can change my controllers from this:

class BrandsController < ApplicationController
  before_action :set_brand, only: %i[show edit update destroy]

  # GET /brands or /brands.json
  def index
    @brands = Brand.all
  end

  # GET /brands/1 or /brands/1.json
  def show; end

  # GET /brands/new
  def new
    @brand = Brand.new
  end

  # GET /brands/1/edit
  def edit; end

  # POST /brands or /brands.json
  def create
    @brand = Brand.new(brand_params)

    respond_to do |format|
      if @brand.save
        format.html { redirect_to(brand_url(@brand), notice: 'Brand was successfully created.') }
        format.json { render(:show, status: :created, location: @brand) }
      else
        format.html { render(:new, status: :unprocessable_entity) }
        format.json { render(json: @brand.errors, status: :unprocessable_entity) }
      end
    end
  end

  # PATCH/PUT /brands/1 or /brands/1.json
  def update
    respond_to do |format|
      if @brand.update(brand_params)
        format.html { redirect_to(brand_url(@brand), notice: 'Brand was successfully updated.') }
        format.json { render(:show, status: :ok, location: @brand) }
      else
        format.html { render(:edit, status: :unprocessable_entity) }
        format.json { render(json: @brand.errors, status: :unprocessable_entity) }
      end
    end
  end

  # DELETE /brands/1 or /brands/1.json
  def destroy
    @brand.destroy!

    respond_to do |format|
      format.html { redirect_to(brands_url, notice: 'Brand was successfully destroyed.') }
      format.json { head(:no_content) }
    end
  end

  private

  # Use callbacks to share common setup or constraints between actions.
  def set_brand
    @brand = Brand.find(params[:id])
  end

  # Only allow a list of trusted parameters through.
  def brand_params
    params.require(:brand).permit(:name, :canonical, :count)
  end
end

to this:

class BrandsController < ApplicationController
  include Resources

  # GET /brands or /brands.json
  def index; end

  # GET /brands/1 or /brands/1.json
  def show; end

  # GET /brands/new
  def new
    @resource = resource_class.new
  end

  # GET /brands/1/edit
  def edit; end

  # POST /brands or /brands.json
  def create
    @resource = resource_class.new(brand_params)

    respond_to do |format|
      if @resource.save
        format.html { redirect_to(brand_url(@resource), notice: 'Brand was successfully created.') }
        format.json { render(:show, status: :created, location: @resource) }
      else
        format.html { render(:new, status: :unprocessable_entity) }
        format.json { render(json: @resource.errors, status: :unprocessable_entity) }
      end
    end
  end

  # PATCH/PUT /brands/1 or /brands/1.json
  def update
    respond_to do |format|
      if @resource.update(brand_params)
        format.html { redirect_to(brand_url(@resource), notice: 'Brand was successfully updated.') }
        format.json { render(:show, status: :ok, location: @resource) }
      else
        format.html { render(:edit, status: :unprocessable_entity) }
        format.json { render(json: @resource.errors, status: :unprocessable_entity) }
      end
    end
  end

  # DELETE /brands/1 or /brands/1.json
  def destroy
    @resource.destroy!

    respond_to do |format|
      format.html { redirect_to(brands_url, notice: 'Brand was successfully destroyed.') }
      format.json { head(:no_content) }
    end
  end

  private

  # Only allow a list of trusted parameters through.
  def brand_params
    params.require(:brand).permit(:name, :canonical, :count)
  end
end

There is still some more I can do here, notice how I now have some empty methods (like index and show) and others that are still exactly the same no matter which controller it is in. The create / update / destroy methods now only differ in the URLs or text they include.

I'll address these issues in a moment.

Now since I no longer have @brand or @brands only @resource and @collection the views need to be modified as well.

This edit.html.erb goes from:

<div class="mx-auto md:w-2/3 w-full">
  <h1 class="font-bold text-4xl">Editing brand</h1>

  <%= render "form", brand: @brand %>

  <%= link_to "Show this brand", @brand, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
  <%= link_to "Back to brands", brands_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
</div>

to this:

<div class="mx-auto md:w-2/3 w-full">
  <h1 class="font-bold text-4xl">Editing brand</h1>

  <%= render "form", brand: @resource %>

  <%= link_to "Show this brand", @resource, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
  <%= link_to "Back to brands", brands_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
</div>

Now back to reducing the duplication in the controller actions I'll add another concern that looks like this:

app\controllers\concerns\actions.rb

# DRY up controllers by extracting generic action methods that can be overridden if necessary
module Actions
  extend ActiveSupport::Concern

  # GET /collection or /collection.json
  def index; end

  # GET /collection/1 or /collection/1.json
  def show; end

  # GET /collection/new
  def new
    @resource = resource_class.new
  end

  # GET /collection/1/edit
  def edit; end

  # POST /collection or /collection.json
  def create
    @resource = resource_class.new(resource_params)

    respond_to do |format|
      if @resource.save
        format.html { redirect_to(resource_url(@resource), notice: "#{resource_human_name} was successfully created.") }
        format.json { render(:show, status: :created, location: @resource) }
      else
        format.html { render(:new, status: :unprocessable_entity) }
        format.json { render(json: @resource.errors, status: :unprocessable_entity) }
      end
    end
  end

  # PATCH/PUT /collection/1 or /collection/1.json
  def update
    respond_to do |format|
      if @resource.update(resource_params)
        format.html { redirect_to(resource_url(@resource), notice: "#{resource_human_name} was successfully updated.") }
        format.json { render(:show, status: :ok, location: @resource) }
      else
        format.html { render(:edit, status: :unprocessable_entity) }
        format.json { render(json: @resource.errors, status: :unprocessable_entity) }
      end
    end
  end

  # DELETE /collection/1 or /collection/1.json
  def destroy
    @resource.destroy!

    respond_to do |format|
      format.html { redirect_to(collection_url, notice: "#{resource_human_name} was successfully destroyed.") }
      format.json { head(:no_content) }
    end
  end
end

To support DRYing up create, update and destroy there are some new methods that have been added: resource_params - These are the permitted parameters the controller will accept resource_url - The URL to a single instance of a resource collection_url - The URL to a collection of class of resource

Here are the concerns that implement these:

app\controllers\concerns\urls.rb

# DRY up controllers by extracting generic resource URL usage
module Urls
  extend ActiveSupport::Concern

  private

  def resource_url(resource)
    polymorphic_url(resource)
  end

  def collection_url
    polymorphic_url(resource_class)
  end
end

app\controllers\concerns\parameters.rb

# DRY up controllers by extracting generic parameter processing
module Parameters
  extend ActiveSupport::Concern

  private

  def resource_params
    params.require(resource_name).permit(permitted_params)
  end

  def permitted_params
    []
  end
end

Now my controller will look like this:

class BrandsController < ApplicationController
  include Resources
  include Actions
  include Urls
  include Parameters

  private

  # Only allow a list of trusted parameters through.
  def permitted_params
    %i(name canonical count)
  end
end

There is one last thing I can do now as all my controllers are going to want to use those inclusions. I'll add a new controller class called ResourcesController and inherit that instead.

app\controllers\resources_controller.rb

class ResourcesController < ApplicationController
  include Resources
  include Actions
  include Urls
  include Parameters
end

Which means my controller looks like this:

class BrandsController < ResourcesController
  private

  # Only allow a list of trusted parameters through.
  def permitted_params
    %i(name canonical count)
  end
end

I can now execute all my tests to confirm the controller still behaves correctly.

# Running:

.......DEBUGGER: Attaching after process 8708 fork to child process 8812
Capybara starting Puma...
* Version 5.6.4 , codename: Birdie's Version
* Min threads: 0, max threads: 4
* Listening on http://127.0.0.1:57978
...................................................................................................................................................

Finished in 25.379320s, 6.0679 runs/s, 7.1712 assertions/s.
154 runs, 182 assertions, 0 failures, 0 errors, 0 skips
Coverage report generated for Unit Tests to /Users/andrewfoster/rails/catalogue_cleanser/coverage. 494 / 565 LOC (87.43%) covered.

And now I'll have a look at what CodeClimate thinks of the result:

image.png

Much better!