STI, one controller

Ruby on-RailsRubyRuby on-Rails-3

Ruby on-Rails Problem Overview


I'm new to rails and I'm kind of stuck with this design problem, that might be easy to solve, but I don't get anywhere: I have two different kinds of advertisements: highlights and bargains. Both of them have the same attributes: title, description and one image (with paperclip). They also have the same kind of actions to apply on them: index, new, edit, create, update and destroy.

I set a STI like this:

Ad Model: ad.rb

class Ad < ActiveRecord::Base
end

Bargain Model: bargain.rb

class Bargain < Ad
end

Highlight Model: highlight.rb

class Highlight < Ad
end

The problem is that I'd like to have only one controller (AdsController) that executes the actions I said on bargains or highlights depending on the URL, say www.foo.com/bargains[/...] or www.foo.com/highlights[/...].

For example:

How can i do that?

Thanks!

Ruby on-Rails Solutions


Solution 1 - Ruby on-Rails

First. Add some new routes:

resources :highlights, :controller => "ads", :type => "Highlight"
resources :bargains, :controller => "ads", :type => "Bargain"

And fix some actions in AdsController. For example:

def new
  @ad = Ad.new()
  @ad.type = params[:type]
end

For best approach for all this controller job look this comment

That's all. Now you can go to localhost:3000/highlights/new and new Highlight will be initialized.

Index action can look like this:

def index
  @ads = Ad.where(:type => params[:type])
end

Go to localhost:3000/highlights and list of highlights will appear.
Same way for bargains: localhost:3000/bargains

etc

URLS

<%= link_to 'index', :highlights %>
<%= link_to 'new', [:new, :highlight] %>
<%= link_to 'edit', [:edit, @ad] %>
<%= link_to 'destroy', @ad, :method => :delete %>

for being polymorphic :)

<%= link_to 'index', @ad.class %>

Solution 2 - Ruby on-Rails

fl00r has a good solution, however I would make one adjustment.

This may or may not be required in your case. It depends on what behavior is changing in your STI models, especially validations & lifecycle hooks.

Add a private method to your controller to convert your type param to the actual class constant you want to use:

def ad_type
  params[:type].constantize
end

The above is insecure, however. Add a whitelist of types:

def ad_types
  [MyType, MyType2]
end

def ad_type
  params[:type].constantize if params[:type].in? ad_types
end

More on the rails constantize method here: http://api.rubyonrails.org/classes/ActiveSupport/Inflector.html#method-i-constantize

Then in the controller actions you can do:

def new
  ad_type.new
end

def create
  ad_type.new(params)
  # ...
end

def index
  ad_type.all
end

And now you are using the actual class with the correct behavior instead of the parent class with the attribute type set.

Solution 3 - Ruby on-Rails

I just wanted to include this link because there are a number of interesting tricks all related to this topic.

Alex Reisner - Single Table Inheritance in Rails

Solution 4 - Ruby on-Rails

I know this is an old question by here is a pattern I like which includes the answers from @flOOr and @Alan_Peabody. (Tested in Rails 4.2, probably works in Rails 5)

In your model, create your whitelist at startup. In dev this must be eager loaded.

class Ad < ActiveRecord::Base
    Rails.application.eager_load! if Rails.env.development?
    TYPE_NAMES = self.subclasses.map(&:name)
    #You can add validation like the answer by @dankohn
end

Now we can reference this whitelist in any controller to build the correct scope, as well as in a collection for a :type select on a form, etc.

class AdsController < ApplicationController
    before_action :set_ad, :only => [:show, :compare, :edit, :update, :destroy]

    def new
        @ad = ad_scope.new
    end

    def create
        @ad = ad_scope.new(ad_params)
        #the usual stuff comes next...
    end

    private
    def set_ad
        #works as normal but we use our scope to ensure subclass
        @ad = ad_scope.find(params[:id])
    end

    #return the scope of a Ad STI subclass based on params[:type] or default to Ad
    def ad_scope
        #This could also be done in some kind of syntax that makes it more like a const.
	    @ad_scope ||= params[:type].try(:in?, Ad::TYPE_NAMES) ? params[:type].constantize : Ad
    end

    #strong params check works as expected
    def ad_params
        params.require(:ad).permit({:foo})
    end
end

We need to handle our forms because the routing should to be sent to the base class controller, despite the actual :type of the object. To do this we use "becomes" to trick the form builder into correct routing, and the :as directive to force the input names to be the base class as well. This combination allows us to use unmodified routes (resources :ads) as well as the strong params check on the params[:ad] coming back from the form.

#/views/ads/_form.html.erb
<%= form_for(@ad.becomes(Ad), :as => :ad) do |f| %>

Solution 5 - Ruby on-Rails

[Rewritten with simpler solution that works fully:]

Iterating on the other answers, I have come up with the following solution for a single controller with Single Table Inheritance that works well with Strong Parameters in Rails 4.1. Just including :type as a permitted parameter caused an ActiveRecord::SubclassNotFound error if an invalid type is entered. Moreover, type is not updated because the SQL query explicitly looks for the old type. Instead, :type needs to be updated separately with update_column if it is different than what is current set and is a valid type. Note also that I've succeeded in DRYing up all lists of types.

# app/models/company.rb
class Company < ActiveRecord::Base
  COMPANY_TYPES = %w[Publisher Buyer Printer Agent]
  validates :type, inclusion: { in: COMPANY_TYPES,
    :message => "must be one of: #{COMPANY_TYPES.join(', ')}" }
end

Company::COMPANY_TYPES.each do |company_type|
  string_to_eval = <<-heredoc
    class #{company_type} < Company
      def self.model_name  # http://stackoverflow.com/a/12762230/1935918
        Company.model_name
      end
    end
  heredoc
  eval(string_to_eval, TOPLEVEL_BINDING)
end

And in the controller:

  # app/controllers/companies_controller.rb
  def update
    @company = Company.find(params[:id])

    # This separate step is required to change Single Table Inheritance types
    new_type = params[:company][:type]
    if new_type != @company.type && Company::COMPANY_TYPES.include?(new_type)
      @company.update_column :type, new_type
    end

    @company.update(company_params)
    respond_with(@company)
  end

And routes:

# config/routes.rb
Rails.application.routes.draw do
  resources :companies
  Company::COMPANY_TYPES.each do |company_type|
    resources company_type.underscore.to_sym, type: company_type, controller: 'companies', path: 'companies'
  end
  root 'companies#index'

Finally, I recommend using the responders gem and setting scaffolding to use a responders_controller, which is compatible with STI. Config for scaffolding is:

# config/application.rb
    config.generators do |g|
      g.scaffold_controller "responders_controller"
    end

Attributions

All content for this solution is sourced from the original question on Stackoverflow.

The content on this page is licensed under the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Content TypeOriginal AuthorOriginal Content on Stackoverflow
QuestionPizzicatoView Question on Stackoverflow
Solution 1 - Ruby on-Railsfl00rView Answer on Stackoverflow
Solution 2 - Ruby on-RailsAlan PeabodyView Answer on Stackoverflow
Solution 3 - Ruby on-RailsAndrew LankView Answer on Stackoverflow
Solution 4 - Ruby on-RailsgenkilabsView Answer on Stackoverflow
Solution 5 - Ruby on-RailsDan KohnView Answer on Stackoverflow