处理Rail中STI子类的路线的最佳实践


175

我的Rails视图和控制器上到处是redirect_tolink_toform_for方法调用。有时link_toredirect_to并且在链接的路径中是显式的(例如link_to 'New Person', new_person_path),但是很多时候路径是隐式的(例如link_to 'Show', person)。

我在模型中添加了一些单表继承(STI)(例如Employee < Person),所有这些方法都破坏了子类的实例(例如Employee);当rails执行时link_to @person,它会出错undefined method employee_path' for #<#<Class:0x000001022bcd40>:0x0000010226d038>。Rails正在寻找由对象的类名(即员工)定义的路线。这些员工路线未定义,也没有员工控制者,因此也未定义操作。

之前已经问过这个问题:

  1. StackOverflow上,答案是编辑整个代码库中的每个link_to等实例,并明确声明路径
  2. 再次在StackOverflow上,两个人建议使用routes.rb来将子类资源映射到父类(map.resources :employees, :controller => 'people')。在同一SO问题中的最高答案建议使用以下方法对代码库中的每个实例对象进行类型转换.becomes
  3. StackOverflow上,另一个答案是,“做自己重复做”阵营中的方法,并建议为每个子类创建重复的脚手架。
  4. 在SO中,又是同样的问题,其中最高答案似乎是错误的(Rails magic Just Works!)
  5. 在网络上的其他地方,我发现了此博客文章,F2Andy建议在代码中的所有路径中进行编辑。
  6. 在博客文章Logical Reality Design上的Single Table Inheritance和RESTful Routes上,建议将子类的资源映射到超类控制器,如上面的SO答案2所示。
  7. Alex Reisner在Rails中发表了“单表继承”一文,他主张不要将子类的资源映射到父类中routes.rb,因为这只会从link_toredirect_to而不是从中捕获路由中断form_for。因此,他建议改为在父类中添加一个方法,以使子类位于其类周围。听起来不错,但是他的方法给了我错误undefined local variable or method `child' for #

所以这似乎是最优雅,拥有最共识(但不是所有的答案优雅的,也不太大的共识),是资源添加到您的routes.rb。除非这不适用于form_for。我需要澄清!为了提炼以上选择,我的选择是

  1. 将子类的资源映射到超类的控制器中routes.rb(并希望我不需要在任何子类上调用form_for)
  2. 重写rails内部方法以使类相互说谎
  3. 在代码中编辑每个实例,以隐式或显式调用对象操作的路径,从而更改路径或类型转换对象。

鉴于所有这些矛盾的答案,我需要裁决。在我看来,没有好的答案。这是滑轨设计的失败吗?如果是这样,它是否可以修复?如果不是这样,那么我希望有人可以让我直接理解这一点,带我逐步了解每种选择的优缺点(或解释为什么这不是一种选择),以及哪个是正确的答案以及为什么。还是我在网上找不到合适的答案?


1
Alex Reisner的代码中有一个错字,在我对他的博客发表评论后,他已将其修正。因此,希望亚历克斯的解决方案现在可行。我的问题仍然存在:哪个是正确的解决方案?
ziggurism 2010年

1
尽管大约有3年历史,但我在rookieonrails.blogspot.com/2008/01/…上找到了此博客文章,并从rails邮件列表中获得了链接的对话,内容丰富。响应者之一描述了多态帮助者和命名帮助者之间的区别。
ziggurism,2010年

2
您未列出的选项是修补Rails,以便link_to,form_for等具有单个表继承的优点。这可能是一项艰巨的工作,但我希望看到它能够解决。
M. Scott Ford

Answers:


140

这是我能够以最小的副作用提出的最简单的解决方案。

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

现在url_for @person将映射到contact_path预期的位置。

工作原理: URL助手依赖于YourModel.model_name模型并生成(其中包括许多)单/复数路由键。这里Person基本上是说我就像Contact哥们,问他


4
我当时正在考虑做同样的事情,但是担心#model_name可能会在Rails的其他地方使用,并且此更改可能会干扰正常的功能。有什么想法吗?
nkassis

3
我完全同意神秘的陌生人@nkassis。这是一个很酷的技巧,但是您怎么知道自己没有破坏rails的内部结构呢?
tsherif 2012年

6
眼镜。另外,我们在生产中使用此代码,我可以证明它不会弄乱:1)模型间关系,2)STI模型实例化(通过build_x/ create_x)。另一方面,玩魔术的代价是您永远不会百分百确定会发生什么变化。
Prathan Thananart

11
如果您要根据类来为属性使用不同的人名,则会中断i18n。
Rufo Sanchez 2012年

4
除了完全像这样重写之外,您还可以重写所需的位。参见gist.github.com/sj26/5843855
sj26 2013年

47

我有同样的问题。使用STI之后,该form_for方法将发布到错误的子URL。

NoMethodError (undefined method `building_url' for

我最终为子类添加了额外的路由,并将它们指向相同的控制器

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

另外:

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

在这种情况下,结构实际上是建筑物(子类)

在与进行提交后,它似乎对我有用form_for


2
这可行,但是在我们的路线中添加了许多不必要的路径。难道没有一种以较少干扰的方式做到这一点的方法吗?
安德斯·金伯格

1
您可以在route.rb文件中以编程方式设置路由,因此可以进行一些元编程来设置子路由。但是,在不缓存类的环境中(例如,开发),您需要先预加载类。因此,您需要在某处指定子类的一种方式。有关示例,请参见gist.github.com/1713398
克里斯·布鲁姆

在我的情况下,不希望将对象名称(路径)暴露给用户(这会使用户感到困惑)。
laffuste 2014年



15

我也遇到了这个问题,并在与我们类似的问题上得到了这个答案。它为我工作。

form_for @list.becomes(List)

此处显示的答案:在同一控制器上使用STI路径

.becomes方法被定义为主要用于解决像您这样的STI问题form_for

.becomes此处的信息:http : //apidock.com/rails/ActiveRecord/Base/成为

超级迟到的响应,但这是我能找到的最佳答案,对我来说效果很好。希望这可以帮助一些人。干杯!


14

遵循@Prathan Thananart的想法,但尝试不破坏任何内容。(因为涉及很多魔术)

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

现在,url_for @person将按预期映射到contact_path。


5

好的,我在Rails的这一领域感到非常沮丧,并且已经采取了以下方法,也许这会对其他人有所帮助。

首先,请注意,网上和网上的许多解决方案都建议对客户端提供的参数使用常量。这是已知的DoS攻击媒介,因为Ruby不会垃圾收集符号,因此攻击者可以创建任意符号并消耗可用内存。

我已经实现了下面的方法,该方法支持模型子类的实例化,并且是上述问题中的SAFE。它与Rails 4的功能非常相似,但是也允许多个级别的子类化(与Rails 4不同),并且可以在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)

在尝试了多种解决“子类加载问题”的方法后,我发现了唯一可行的方法是在模型类中使用“ require_dependency”。这样可以确保类加载在开发中正常工作,并且在生产中不会引起任何问题。在开发中,没有'require_dependency',AR不会知道所有子类,这会影响在类型列上发出的用于匹配的SQL。另外,如果没有'require_dependency',您也可能同时遇到多个版本的模型类!(例如,当您更改基类或中间类时,可能会发生这种情况,子类似乎并不总是重新加载,而是从旧类保留为子类)

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

require_dependency 'person'
require_dependency 'organisation'

我也不会像上面建议的那样覆盖model_name,因为我使用I18n,并且对于不同子类的属性需要不同的字符串,例如:tax_identifier对组织而言成为'ABN',对于Person(在澳大利亚)成为'TFN'。

我也使用路由映射,如上面建议的那样,设置类型:

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

除了路由映射之外,我还使用InheritedResources和SimpleForm,并对新操作使用以下通用表单包装器:

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

...,并进行编辑操作:

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

为了使此工作有效,在我的基本ResourceContoller中,我将InheritedResource的resource_request_name公开为视图的辅助方法:

helper_method :resource_request_name 

如果您不使用InheritedResources,请在“ 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

总是很高兴听到别人的经验和进步。


以我的经验(至少在Rails 3.0.9中),如果由字符串命名的常数尚不存在,则常数化会失败。那么如何将其用于创建任意新符号?
Lex Lindsey

4

我最近记录了我在Rails 3.0应用程序中尝试获得稳定的STI模式的尝试。这是TL; DR版本:

# 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

这种方法可以解决您列出的问题以及其他人在STI方法中遇到的其他问题。


3

我发现的最干净的解决方案是将以下内容添加到基类中:

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

它适用于所有子类,并且比覆盖整个模型名称对象安全得多。通过仅定位路由键,我们解决了路由问题,而不会破坏I18n或冒着因覆盖Rails定义的模型名称而引起的任何潜在副作用的风险。


2

如果没有嵌套路由,可以尝试以下方法:

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

或者,您可以采用另一种方式,使用一些OOP魔术,如此处所述:https : //coderwall.com/p/yijmuq

第二种方式,您可以为所有嵌套模型创建类似的帮助器。


2

这是一种安全的干净方法,可以使其在我们使用的表单和整个应用程序中正常工作。

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

然后我有自己的表格。为此添加的部分是as::district。

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

希望这可以帮助。


1

如果我考虑这样的STI继承:

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

在'app / models / a_model.rb'中,我添加:

module ManagedAtAModelLevel
  def model_name
    AModel.model_name
  end
end

然后在AModel类中:

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

因此,我什至可以轻松选择要使用默认模型的模型,而无需触摸子类定义。非常干燥。


1

这样对我有效(在基类中定义此方法):

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

1

您可以创建返回伪父对象以路由目的的方法

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

然后简单地调用form_for @ employee.routing_object,如果没有类型,它将返回Person类对象


1

在@ prathan-thananart答案之后,对于多个STI类,可以将以下内容添加到父模型->

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

这将使每个窗体与联系人数据发送PARAMS作为params[:contact]代替params[:contact_person]params[:contact_whatever]


0

我赞成使用PolymorphicRoutesurl_for基于资源,任何名称空间等动态生成路由。

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"

admin名称空间

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

0

覆盖model_name似乎很危险。使用.becomes似乎是更安全的选择。

一个问题是在您不知道要处理的模型(因此不知道基本模型)的情况下。

我只想分享这种情况,可以使用:

foo.becomes(foo.class.base_class)

为了易于使用,我将此方法添加到了我的ApplicationRecord

def becomes_base
  becomes(self.class.base_class)
end

对我来说,添加.becomes_base一些路由助手方法似乎没什么大不了的。


-6

骇人听闻,但只是解决方案列表中的另一个。

class Parent < ActiveRecord::Base; end

Class Child < Parent
  def class
    Parent
  end
end

在rails 2.x和3.x上工作


5
这样可以解决一个问题,但会造成另一个问题。现在,当您尝试执行Child.new此操作时,它将返回一个Parent类而不是子类。这意味着您不能通过控制器的批量分配来创建子类(因为type默认情况下是受保护的属性),除非您也明确设置了type属性。
克里斯·布鲁姆
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.