Best practices to handle routes for STI subclasses in rails
Asked Answered
I

18

183

My Rails views and controllers are littered with redirect_to, link_to, and form_for method calls. Sometimes link_to and redirect_to are explicit in the paths they're linking (e.g. link_to 'New Person', new_person_path), but many times the paths are implicit (e.g. link_to 'Show', person).

I add some single table inheritance (STI) to my model (say Employee < Person), and all of these methods break for an instance of the subclass (say Employee); when rails executes link_to @person, it errors with undefined method employee_path' for #<#<Class:0x000001022bcd40>:0x0000010226d038>. Rails is looking for a route defined by the class name of the object, which is employee. These employee routes are not defined, and there is no employee controller so the actions aren't defined either.

This question has been asked before:

  1. At StackOverflow, the answer is to edit every instance of link_to etc in your entire codebase, and state the path explicitly
  2. On StackOverflow again, two people suggest using routes.rb to map the subclass resources to the parent class (map.resources :employees, :controller => 'people'). The top answer in that same SO question suggests type-casting every instance object in the codebase using .becomes
  3. Yet another one at StackOverflow, the top answer is way in the Do Repeat Yourself camp, and suggests creating duplicate scaffolding for every subclass.
  4. Here's the same question again at SO, where the top answer seems to just be wrong (Rails magic Just Works!)
  5. Elsewhere on the web, I found this blog post where F2Andy recommends editing in the path everywhere in the code.
  6. On the blog post Single Table Inheritance and RESTful Routes at Logical Reality Design, it is recommended to map the resources for the subclass to the superclass controller, as in SO answer number 2 above.
  7. Alex Reisner has a post Single Table Inheritance in Rails, in which he advocates against mapping the resources of the child classes to the parent class in routes.rb, since that only catches routing breakages from link_to and redirect_to, but not from form_for. So he recommends instead adding a method to the parent class to get the subclasses to lie about their class. Sounds good, but his method gave me the error undefined local variable or method `child' for #.

So the answer that seems most elegant and has the most consensus (but it's not all that elegant, nor that much consensus), is the add the resources to your routes.rb. Except this doesn't work for form_for. I need some clarity! To distill the choices above, my options are

  1. map the resources of the subclass to the controller of the superclass in routes.rb (and hope I don't need to call form_for on any subclasses)
  2. Override rails internal methods to make the classes lie to each other
  3. Edit every instance in the code where the path to an object's action is invoked implicitly or explicitly, either changing the path or type-casting the object.

With all these conflicting answers, I need a ruling. It seems to me like there is no good answer. Is this a failing in rails' design? If so, is it a bug that may get fixed? Or if not, then I'm hoping someone can set me straight on this, walk me through the pros and cons of each option (or explain why that's not an option), and which one is the right answer, and why. Or is there a right answer that I'm not finding on the web?

Inlaid answered 22/12, 2010 at 7:45 Comment(4)
There was a typo in Alex Reisner's code, which he's fixed after I commented on his blog. So hopefully now Alex's solution is viable. My question still stands: which is the right solution?Inlaid
Although it's about three years old, I found this blog post at rookieonrails.blogspot.com/2008/01/… and the linked conversation from the rails mailing list informative. One of the responders describes the difference between polymorphic helpers and named helpers.Inlaid
An option that you don't list is to patch Rails so that link_to, form_for and the like place nice with single table inheritance. That may be a tough job, but it is something that I would love to see fixed.Vietnamese
#605370Chrysa
T
144

This is the simplest solution I was able to come up with with minimal side effect.

class Person < Contact
  def self.model_name
    Contact.model_name
  end
end

Now url_for @person will map to contact_path as expected.

How it works: URL helpers rely on YourModel.model_name to reflect upon the model and generate (amongst many things) singular/plural route keys. Here Person is basically saying I'm just like Contact dude, ask him.

Tripterous answered 27/2, 2012 at 10:31 Comment(11)
Wow, why isn't this answer the most up voted one? Definitely the best solution for this problem!Gorlovka
I was thinking of doing the same thing, but was worried that #model_name might be used elsewhere in Rails, and that this change might interfere with normal functioning. Any thoughts?Quadriplegic
I totally agree with mysterious stranger @nkassis. This is a cool hack, but how do you know you're not busting up rails' internals?Asbestosis
Specs. Also, we use this code in production, and I can attest that it doesn't mess up: 1) inter-model relationships, 2) STI model instantiation (via build_x/create_x). On the other hand, the price of playing with magic is you're never 100% sure what may change.Tripterous
This breaks i18n if you're trying to have different human names for attributes depending on the class.Fitton
Rather than completely overriding like this, you can just override the bits you need. See gist.github.com/sj26/5843855Jockstrap
This solution will break i18n usage. "@model.model_name.human" is frequently used in an i18n app. "@model.becomes" seems better in this situation.Torp
I might also just add that instead of explicitly specifying the parent model with Contact.model_name, another option is base_class.model_name.Dairy
Check out eloysp's answer below. They propose an approach that changes the model name only for routing concerns and should not mess up anything.Brythonic
I like your solution as it's a very simple approach but unfortunately, it breaks things if you use ActiveAdmin. And who knows what else.Corporal
@Jockstrap lol that is way more overriding than the answerWalkthrough
T
48

I had the same problem. After using STI, the form_for method was posting to the wrong child url.

NoMethodError (undefined method `building_url' for

I ended up adding in the extra routes for the child classes and pointing them to the same controllers

 resources :structures
 resources :buildings, :controller => 'structures'
 resources :bridges, :controller => 'structures'

Additionally:

<% form_for(@structure, :as => :structure) do |f| %>

in this case structure is actually a building (child class)

It seems to work for me after doing a submit with form_for.

Twannatwattle answered 23/2, 2011 at 7:32 Comment(3)
This works, but adds a lot of unneccessary paths in our routes. Isn't there a way to do this in a less intrusive way?Miguelinamiguelita
You can setup routes programatically in your routes.rb file, so you could do a little meta programming to setup the child routes. However, in environments where classes aren't cached (e.g. development) you need to pre-load the classes first. So one way or the other you need to specify the subclasses somewhere. See gist.github.com/1713398 for an example.Alcantara
In my case, exposing the object name (path) to the user is not desirable (and confusing for the user).Committeewoman
A
35

I suggest you take a look at : https://mcmap.net/q/137635/-can-i-do-sti-and-still-use-polymorphic-path-helpers, using this method will enable you to use "form_for".

ActiveRecord::Base#becomes
Anopheles answered 17/4, 2012 at 0:18 Comment(2)
I had to set url explicitly in order for it to both render the from and saves properly. <%= form_for @child, :as => :child, url: @child.becomes(Parent)Outlaw
@Outlaw Try <%= form_for @child.becomes(Parent)Integrated
C
20

I was having trouble with this problem too and came by this answer on a question similar to ours. It worked for me.

form_for @list.becomes(List)

Answer shown here: Using STI path with same controller

The .becomes method is defined as mainly used for solving STI problems like your form_for one.

.becomes info here: http://apidock.com/rails/ActiveRecord/Base/becomes

Super late response, but this is the best answer I could find and it worked well for me. Hope this helps some one. Cheers!

Chronometry answered 31/12, 2016 at 13:25 Comment(0)
B
19

Use type in the routes:

resources :employee, controller: 'person', type: 'Employee' 

http://samurails.com/tutorial/single-table-inheritance-with-rails-4-part-2/

Bold answered 15/5, 2014 at 8:29 Comment(2)
Domain changed, the mentioned article is now available there: samurails.com/tutorial/…Lamar
The links in post and comment are deadDirndl
I
17

Following the idea of @Prathan Thananart but trying to not destroy nothing. (since there is so much magic involved)

class Person < Contact
  model_name.class_eval do
    def route_key
     "contacts"
    end
    def singular_route_key
      superclass.model_name.singular_route_key
    end
  end
end

Now url_for @person will map to contact_path as expected.

Impress answered 30/10, 2012 at 0:59 Comment(2)
I'm on rails 6.1.4 it got undefined local variable or method 'superclass' my quick fix is add the class name liek this Person.superclass.model_name.singular_route_keyWalkthrough
This should be the accepted answer, it only overrides URL helpers which is what OP asked for, and doesn't break other things such as i18nDinner
A
5

Ok, Ive had a ton of frustration in this area of Rails, and have arrived at the following approach, perhaps this will help others.

Firstly be aware that a number of solutions above and around the net suggest using constantize on client provided parameters. This is a known DoS attack vector as Ruby does not garbage collect symbols, thus allowing an attacker to create arbitrary symbols and consume available memory.

I've implemented the approach below which supports instantiation of model subclasses, and is SAFE from the contantize problem above. It is very similar to what rails 4 does, but also allows more than one level of subclassing (unlike Rails 4) and works in Rails 3.

# initializers/acts_as_castable.rb
module ActsAsCastable
  extend ActiveSupport::Concern

  module ClassMethods

    def new_with_cast(*args, &block)
      if (attrs = args.first).is_a?(Hash)
        if klass = descendant_class_from_attrs(attrs)
          return klass.new(*args, &block)
        end
      end
      new_without_cast(*args, &block)
    end

    def descendant_class_from_attrs(attrs)
      subclass_name = attrs.with_indifferent_access[inheritance_column]
      return nil if subclass_name.blank? || subclass_name == self.name
      unless subclass = descendants.detect { |sub| sub.name == subclass_name }
        raise ActiveRecord::SubclassNotFound.new("Invalid single-table inheritance type: #{subclass_name} is not a subclass of #{name}")
      end
      subclass
    end

    def acts_as_castable
      class << self
        alias_method_chain :new, :cast
      end
    end
  end
end

ActiveRecord::Base.send(:include, ActsAsCastable)

After trying various approaches for the 'sublclass loading in devlopment issue' many similar to whats suggested above, I found the only thing that worked reliably was to use 'require_dependency' in my model classes. This ensures that class loading works properly in development and causes no issues in production. In development, without 'require_dependency' AR wont know about all subclasses, which impacts the SQL emitted for matching on the type column. In addition without 'require_dependency' you can also end up in a situation with multiple versions of the model classes at the same time! (eg. this can happen when you change a base or intermediate class, the sub-classes don't always seem to reload and are left subclassing from the old class)

# contact.rb
class Contact < ActiveRecord::Base
  acts_as_castable
end

require_dependency 'person'
require_dependency 'organisation'

I also don't override model_name as suggested above because I use I18n and need different strings for the attributes of different subclasses, eg :tax_identifier becomes 'ABN' for Organisation, and 'TFN' for Person (in Australia).

I also use route mapping, as suggested above, setting the type:

resources :person, :controller => 'contacts', :defaults => { 'contact' => { 'type' => Person.sti_name } }
resources :organisation, :controller => 'contacts', :defaults => { 'contact' => { 'type' => Organisation.sti_name } }

In addition to the route mapping, I'm using InheritedResources and SimpleForm and I use the following generic form wrapper for new actions:

simple_form_for resource, as: resource_request_name, url: collection_url,
      html: { class: controller_name, multipart: true }

... and for edit actions:

simple_form_for resource, as: resource_request_name, url: resource_url,
      html: { class: controller_name, multipart: true }

And to make this work, in my base ResourceContoller I expose InheritedResource's resource_request_name as a helper method for the view:

helper_method :resource_request_name 

If you're not using InheritedResources, then use something like the following in your 'ResourceController':

# controllers/resource_controller.rb
class ResourceController < ApplicationController

protected
  helper_method :resource
  helper_method :resource_url
  helper_method :collection_url
  helper_method :resource_request_name

  def resource
    @model
  end

  def resource_url
    polymorphic_path(@model)
  end

  def collection_url
    polymorphic_path(Model)
  end

  def resource_request_name
    ActiveModel::Naming.param_key(Model)
  end
end

Always happy to hear others experiences and improvements.

Adorne answered 12/6, 2013 at 2:52 Comment(1)
In my experience (at least in Rails 3.0.9), constantize fails if the constant named by the string doesn't already exist. So how can it be used to create arbitrary new symbols?Packet
A
4

I recently documented my attempts to get a stable STI pattern working in a Rails 3.0 app. Here's the TL;DR version:

# app/controllers/kase_controller.rb
class KasesController < ApplicationController

  def new
    setup_sti_model
    # ...
  end

  def create
    setup_sti_model
    # ...
  end

private

  def setup_sti_model
    # This lets us set the "type" attribute from forms and querystrings
    model = nil
    if !params[:kase].blank? and !params[:kase][:type].blank?
      model = params[:kase].delete(:type).constantize.to_s
    end
    @kase = Kase.new(params[:kase])
    @kase.type = model
  end
end

# app/models/kase.rb
class Kase < ActiveRecord::Base
  # This solves the `undefined method alpha_kase_path` errors
  def self.inherited(child)
    child.instance_eval do
      def model_name
        Kase.model_name
      end
    end
    super
  end  
end

# app/models/alpha_kase.rb
# Splitting out the subclasses into separate files solves
# the `uninitialize constant AlphaKase` errors
class AlphaKase < Kase; end

# app/models/beta_kase.rb
class BetaKase < Kase; end

# config/initializers/preload_sti_models.rb
if Rails.env.development?
  # This ensures that `Kase.subclasses` is populated correctly
  %w[kase alpha_kase beta_kase].each do |c|
    require_dependency File.join("app","models","#{c}.rb")
  end
end

This approach gets around the problems that you list as well as a number of other issues that others have had with STI approaches.

Alcantara answered 1/2, 2012 at 9:4 Comment(0)
P
3

The cleanest solution I found is to add the following to the base class:

def self.inherited(subclass)
  super

  def subclass.model_name
    super.tap do |name|
      route_key = base_class.name.underscore
      name.instance_variable_set(:@singular_route_key, route_key)
      name.instance_variable_set(:@route_key, route_key.pluralize)
    end
  end
end

It works for all subclasses and is much safer than overriding the entire model name object. By targeting only the route keys, we solve the routing problems without breaking I18n or risking any potential side effects caused by overriding the model name as defined by Rails.

Peppi answered 19/3, 2020 at 7:52 Comment(0)
B
2

You can try this, if you have no nested routes:

resources :employee, path: :person, controller: :person

Or you can go another way and use some OOP-magic like described here: https://coderwall.com/p/yijmuq

In second way you can make similar helpers for all your nested models.

Barrage answered 20/11, 2013 at 17:33 Comment(0)
D
2

Here is a safe clean way to have it work in forms and throughout your application that we use.

resources :districts
resources :district_counties, controller: 'districts', type: 'County'
resources :district_cities, controller: 'districts', type: 'City'

Then I have in my form. The added piece for this is the as: :district.

= form_for(@district, as: :district, html: { class: "form-horizontal",         role: "form" }) do |f|

Hope this helps.

Domett answered 21/6, 2016 at 17:16 Comment(0)
H
2

Following @prathan-thananart answer, and for the multiple STI classes, you can add the following to the parent model ->

class Contact < ActiveRecord::Base
  def self.model_name
    ActiveModel::Name.new(self, nil, 'Contact')
  end
end

That will make each form with Contact data to send params as params[:contact] instead of params[:contact_person], params[:contact_whatever].

Hartsfield answered 4/4, 2019 at 15:1 Comment(0)
B
2

Overriding model_name seems dangerous. Using .becomes seems like the safer option.

One issue is in cases where you don't know what model you are dealing with (and thus the base model).

I just wanted to share that in such cases, one can use:

foo.becomes(foo.class.base_class)

For ease of use, I've added this method to my ApplicationRecord:

def becomes_base
  becomes(self.class.base_class)
end

Adding .becomes_base to a few route helper methods doesn't seem like too big of a deal to me.

Bukhara answered 4/9, 2020 at 8:33 Comment(1)
the usage on views would be like this <%= link_to link_name, polymorphic_path(@foo.becomes(@foo.class.base_class)) %>Walkthrough
R
1

If I consider an STI inheritance like this:

class AModel < ActiveRecord::Base ; end
class BModel < AModel ; end
class CModel < AModel ; end
class DModel < AModel ; end
class EModel < AModel ; end

in 'app/models/a_model.rb' I add:

module ManagedAtAModelLevel
  def model_name
    AModel.model_name
  end
end

And then in the AModel class:

class AModel < ActiveRecord::Base
  def self.instanciate_STI
    managed_deps = { 
      :b_model => true,
      :c_model => true,
      :d_model => true,
      :e_model => true
    }
    managed_deps.each do |dep, managed|
      require_dependency dep.to_s
      klass = dep.to_s.camelize.constantize
      # Inject behavior to be managed at AModel level for classes I chose
      klass.send(:extend, ManagedAtAModelLevel) if managed
    end
  end

  instanciate_STI
end

Therefore I can even easily choose which model I want to use the default one, and this without even touching the sub class definition. Very dry.

Rancidity answered 27/4, 2013 at 9:35 Comment(0)
M
1

This way works for me ok (define this method in the base class):

def self.inherited(child)
  child.instance_eval do
    alias :original_model_name :model_name
    def model_name
      Task::Base.model_name
    end
  end
  super
end
Mosque answered 21/5, 2013 at 14:57 Comment(0)
W
1

You can create method that returns dummy Parent object for routing purpouse

class Person < ActiveRecord::Base      
  def routing_object
    Person.new(id: id)
  end
end

and then simply call form_for @employee.routing_object which without type will return Person class object

Waynant answered 26/8, 2016 at 0:35 Comment(0)
I
0

I'm in favor of using PolymorphicRoutes or url_for to dynamically generate routes based on the resource, any namespace, etc.

https://api.rubyonrails.org/classes/ActionDispatch/Routing/PolymorphicRoutes.html

https://api.rubyonrails.org/classes/ActionDispatch/Routing/UrlFor.html

polymorphic_url([:admin, @article, @comment])
# => admin_article_comment_url(@article, @comment)

edit_polymorphic_path(@post) 
# => "/posts/1/edit"

With admin namespace

url_for([:admin, Role])
# => "admin/roles" # index

url_for([:admin, Role, action: :new])
# => "admin/roles/new" # new

url_for([:admin, @role])
# => "admin/roles/1" # show; for destroy, use link "method: :delete"

url_for([:edit, :admin, @role])
# => "admin/roles/1/edit" # edit
Insectivorous answered 3/9, 2020 at 3:41 Comment(0)
E
-6

hackish,but just another one to the list of solutions.

class Parent < ActiveRecord::Base; end

Class Child < Parent
  def class
    Parent
  end
end

works on rails 2.x and 3.x

Enceladus answered 23/1, 2012 at 13:52 Comment(1)
This fixes one problem, but creates another. Now when you try to do Child.new it returns a Parent class rather than the subclass. This means that you can't create the subclasses via mass assignment through a controller (since type is a protected attribute by default) unless you also set the type attribute explicitly.Alcantara

© 2022 - 2024 — McMap. All rights reserved.