STI, one controller
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
clas开发者_如何学运维s 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:
- GET www.foo.com/highlights => a list of all the ads that are highlights.
- GET www.foo.com/highlights/new => form to create a new highlight etc...
How can i do that?
Thanks!
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 %>
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.
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
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| %>
[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
精彩评论