web-dev-qa-db-fra.com

Meilleures pratiques pour gérer les itinéraires pour les sous-classes STI dans rails

Mes Rails vues et contrôleurs sont jonchés de redirect_to, link_to, et form_for appels de méthode. Parfois link_to et redirect_to sont explicites dans les chemins qu'ils lient (par exemple link_to 'New Person', new_person_path), mais souvent les chemins sont implicites (par exemple link_to 'Show', person).

J'ajoute un héritage de table unique (STI) à mon modèle (disons Employee < Person), et toutes ces méthodes s'arrêtent pour une instance de la sous-classe (disons Employee); lorsque Rails exécute link_to @person, il contient des erreurs avec undefined method employee_path' for #<#<Class:0x000001022bcd40>:0x0000010226d038>. Rails recherche un itinéraire défini par le nom de classe de l'objet, qui est employé. Ces itinéraires d'employé ne sont pas définis, et il n'y a pas de contrôleur d'employé, donc les actions ne sont pas définies non plus.

Cette question a déjà été posée:

  1. À StackOverflow , la réponse est d'éditer chaque instance de link_to etc. dans votre base de code entière et d'indiquer le chemin explicitement
  2. Sur StackOverflow encore une fois, deux personnes suggèrent d'utiliser routes.rb pour mapper les ressources de la sous-classe à la classe parente (map.resources :employees, :controller => 'people'). La meilleure réponse dans cette même question SO suggère de transtyper chaque objet d'instance dans la base de code en utilisant .becomes
  3. Encore un autre à StackOverflow , la meilleure réponse est dans le camp Do Repeat Yourself et suggère de créer un échafaudage en double pour chaque sous-classe.
  4. Voici encore la même question à SO, où la réponse du haut semble être juste fausse (Rails magic Just Works!)
  5. Ailleurs sur le web, j'ai trouvé cet article de blog où F2Andy recommande de modifier le chemin partout dans le code.
  6. Sur le billet de blog Single Table Inheritance and RESTful Routes chez Logical Reality Design, il est recommandé de mapper les ressources de la sous-classe au contrôleur de superclasse, comme dans SO answer numéro 2 ci-dessus.
  7. Alex Reisner a un article Single Table Inheritance in Rails , dans lequel il préconise de ne pas mapper les ressources des classes enfants à la classe parent dans routes.rb, car cela n'attrape que les ruptures de routage de link_to et redirect_to, mais pas de form_for. Il recommande donc plutôt d'ajouter une méthode à la classe parente pour que les sous-classes mentent sur leur classe. Sonne bien, mais sa méthode m'a donné l'erreur undefined local variable or method `child' for #.

Donc, la réponse qui semble la plus élégante et qui a le plus de consensus (mais ce n'est pas tout que élégant, ni que beaucoup de consensus), est l'ajout des ressources à votre routes.rb. Sauf que cela ne fonctionne pas pour form_for. J'ai besoin de clarté! Pour distiller les choix ci-dessus, mes options sont

  1. mappez les ressources de la sous-classe au contrôleur de la superclasse dans routes.rb (et j'espère que je n'ai pas besoin d'appeler form_for sur les sous-classes)
  2. Remplacer Rails méthodes internes pour faire mentir les classes les unes aux autres)
  3. Modifiez chaque instance du code dans laquelle le chemin d'accès à l'action d'un objet est invoqué implicitement ou explicitement, en modifiant le chemin d'accès ou en transtypant l'objet.

Avec toutes ces réponses contradictoires, j'ai besoin d'une décision. Il me semble qu'il n'y a pas de bonne réponse. Est-ce un échec dans la conception de Rails? Si oui, s'agit-il d'un bug qui pourrait être corrigé? Sinon, j'espère que quelqu'un pourra me mettre au clair, expliquer les avantages et les inconvénients de chaque option (ou expliquer pourquoi ce n'est pas une option), et laquelle est la bonne réponse, et pourquoi. Ou existe-t-il une bonne réponse que je ne trouve pas sur le Web?

168
ziggurism

C'est la solution la plus simple que j'ai pu proposer avec un effet secondaire minimal.

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

À présent url_for @person correspondra à contact_path comme prévu.

Comment ça marche: Les assistants URL s'appuient sur YourModel.model_name pour réfléchir sur le modèle et générer (parmi beaucoup de choses) des clés de route singulières/plurielles. Ici, Person signifie essentiellement Je suis comme Contact mec, demandez-lui .

130
Prathan Thananart

J'ai eu le même problème. Après avoir utilisé STI, le form_for la méthode a été envoyée à la mauvaise URL enfant.

NoMethodError (undefined method `building_url' for

J'ai fini par ajouter les routes supplémentaires pour les classes enfants et les pointer vers les mêmes contrôleurs

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

Aditionellement:

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

dans ce cas, la structure est en fait un bâtiment (classe enfant)

Cela semble fonctionner pour moi après avoir fait une soumission avec form_for.

46
James

Je vous suggère de jeter un œil à: https://stackoverflow.com/a/605172/445908 , l'utilisation de cette méthode vous permettra d'utiliser "form_for".

ActiveRecord::Base#becomes
31

Utilisez le type dans les itinéraires:

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

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

17
jobwat

Suivre l'idée de @Prathan Thananart mais en essayant de ne rien détruire. (car il y a tellement de magie impliquée)

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

Maintenant, url_for @person sera mappé sur contact_path comme prévu.

13
eloyesp

J'avais aussi des problèmes avec ce problème et suis venu par cette réponse sur une question similaire à la nôtre. Ça a marché pour moi.

form_for @list.becomes(List)

Réponse affichée ici: en utilisant le chemin STI avec le même contrôleur

Le .becomes La méthode est définie comme étant principalement utilisée pour résoudre des problèmes d'IST comme votre form_for une.

.becomes info ici: http://apidock.com/Rails/ActiveRecord/Base/becomes

Réponse super tardive, mais c'est la meilleure réponse que j'ai pu trouver et cela a bien fonctionné pour moi. J'espère que cela aide quelqu'un. À votre santé!

11
Marcus

Ok, j'ai eu une tonne de frustration dans ce domaine de Rails, et suis arrivé à l'approche suivante, peut-être que cela aidera les autres.

Tout d'abord, sachez qu'un certain nombre de solutions au-dessus et autour du net suggèrent d'utiliser la constante sur les paramètres fournis par le client. Il s'agit d'un vecteur d'attaque DoS connu sous la forme Ruby ne détruit pas les symboles de collecte, permettant ainsi à un attaquant de créer des symboles arbitraires et de consommer la mémoire disponible.

J'ai implémenté l'approche ci-dessous qui prend en charge l'instanciation des sous-classes de modèle et est SÉCURISÉE du problème de contantisation ci-dessus. Il est très similaire à ce que fait Rails 4, mais permet également plusieurs niveaux de sous-classement (contrairement à Rails 4) et fonctionne dans 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)

Après avoir essayé différentes approches pour le `` chargement de sous-classe dans le problème de développement '', similaires à ce qui est suggéré ci-dessus, j'ai trouvé que la seule chose qui fonctionnait de manière fiable était d'utiliser `` require_dependency '' dans mes classes de modèle. Cela garantit que le chargement des classes fonctionne correctement pendant le développement et ne cause aucun problème en production. En développement, sans 'require_dependency' AR ne connaîtra pas toutes les sous-classes, ce qui impacte le SQL émis pour la correspondance sur la colonne type. De plus, sans "require_dependency", vous pouvez également vous retrouver dans une situation avec plusieurs versions des classes de modèle en même temps! (par exemple, cela peut se produire lorsque vous modifiez une classe de base ou intermédiaire, les sous-classes ne semblent pas toujours se recharger et restent sous-classées de l'ancienne classe)

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

require_dependency 'person'
require_dependency 'organisation'

Je ne remplace pas non plus model_name comme suggéré ci-dessus car j'utilise I18n et j'ai besoin de chaînes différentes pour les attributs de différentes sous-classes, par exemple: tax_identifier devient 'ABN' pour Organisation et 'TFN' pour Personne (en Australie).

J'utilise également le mappage d'itinéraire, comme suggéré ci-dessus, en définissant le type:

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

En plus du mappage de route, j'utilise InheritedResources et SimpleForm et j'utilise l'encapsuleur de formulaire générique suivant pour de nouvelles actions:

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

... et pour les actions d'édition:

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

Et pour que cela fonctionne, dans ma base ResourceContoller j'expose resource_request_name de InheritedResource comme méthode d'aide pour la vue:

helper_method :resource_request_name 

Si vous n'utilisez pas InheritedResources, utilisez quelque chose comme ce qui suit dans votre "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

Toujours heureux d'entendre les expériences et les améliorations des autres.

5
Andrew Hacking

J'ai récemment documenté mes tentatives pour obtenir un modèle STI stable fonctionnant dans une application Rails 3.0. Voici la version 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

Cette approche contourne les problèmes que vous énumérez ainsi qu'un certain nombre d'autres problèmes que d'autres ont rencontrés avec les approches STI.

4
Chris Bloom

Vous pouvez essayer ceci, si vous n'avez pas de routes imbriquées:

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

Ou vous pouvez aller dans un autre sens et utiliser une magie OOP comme décrit ici: https://coderwall.com/p/yijmuq

Dans un deuxième temps, vous pouvez créer des assistants similaires pour tous vos modèles imbriqués.

2
everm1nd

Voici un moyen propre et sûr de le faire fonctionner dans les formulaires et tout au long de votre application que nous utilisons.

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

Ensuite, j'ai sous ma forme. La pièce ajoutée pour cela est le:: district.

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

J'espère que cela t'aides.

2
Jake

Cela fonctionne pour moi ok (définissez cette méthode dans la classe de base):

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
ka8725

Si je considère un héritage IST comme celui-ci:

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

dans 'app/models/a_model.rb' j'ajoute:

module ManagedAtAModelLevel
  def model_name
    AModel.model_name
  end
end

Et puis dans la classe 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

Par conséquent, je peux même facilement choisir le modèle que je veux utiliser par défaut, et ce sans même toucher à la définition de la sous-classe. Très sec.

1
Laurent B.

Vous pouvez créer une méthode qui renvoie un objet parent factice pour le routage purpouse

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

puis appelez simplement form_for @ employee.routing_object qui sans type renverra l'objet de classe Person

1
mingle

En suivant la réponse @ prathan-thananart, et pour les multiples classes STI, vous pouvez ajouter ce qui suit au modèle parent ->

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

Cela fera de chaque formulaire avec des données de contact pour envoyer des paramètres en tant que params[:contact] au lieu de params[:contact_person], params[:contact_whatever].

0