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

175

Mes vues Rails et les contrôleurs sont jonchées redirect_to, link_toet les form_forappels de méthode. Parfois link_toet redirect_tosont 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 (par exemple Employee < Person), et toutes ces méthodes sont interrompues pour une instance de la sous-classe (par exemple Employee); lorsque les rails s'exécutent link_to @person, il se trompe 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és ne sont pas définis et il n'y a pas de contrôleur d'employés, donc les actions ne sont pas définies non plus.

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

  1. Chez StackOverflow , la réponse est de modifier chaque instance de link_to etc dans l'ensemble de votre base de code, et d'indiquer le chemin explicitement
  2. Sur StackOverflow à nouveau, deux personnes suggèrent d'utiliser routes.rbpour mapper les ressources de la sous-classe à la classe parente ( map.resources :employees, :controller => 'people'). La première réponse à 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 chez StackOverflow , la meilleure réponse est dans le camp Do Repeat Yourself, et suggère de créer des échafaudages en double pour chaque sous-classe.
  4. Voici à nouveau la même question chez SO, où la première réponse semble tout simplement fausse (Rails magic Just Works!)
  5. Ailleurs sur le web, j'ai trouvé ce billet de blog où F2Andy recommande de modifier le chemin partout dans le code.
  6. Dans le billet de blog Héritage de table unique et routes RESTful chez Logical Reality Design, il est recommandé de mapper les ressources de la sous-classe vers le contrôleur de la superclasse, comme dans la réponse SO 2 ci-dessus.
  7. Alex Reisner a publié un héritage de table unique dans les rails , dans lequel il déconseille de mapper les ressources des classes enfants à la classe parente dans routes.rb, car cela ne détecte que les ruptures de routage de link_toet 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. Cela semble 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 a le plus consensus (mais ce n'est pas tout ce que élégant, ni que beaucoup de consensus), est l'ajouter les ressources à votreroutes.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. mapper 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 aucune sous-classe)
  2. Remplacer les méthodes internes des rails pour que les classes se mentent
  3. Modifiez chaque instance du code dans laquelle le chemin d'accès à l'action d'un objet est appelé implicitement ou explicitement, en modifiant le chemin ou en convertissant le type de 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 des rails? Si tel est le cas, est-ce un bogue qui pourrait être corrigé? Ou si ce n'est pas le cas, j'espère que quelqu'un pourra me donner raison, me présenter les avantages et les inconvénients de chaque option (ou expliquer pourquoi ce n'est pas une option), laquelle est la bonne réponse et pourquoi. Ou y a-t-il une bonne réponse que je ne trouve pas sur le Web?

ziggourisme
la source
1
Il y avait une faute de frappe dans le code d'Alex Reisner, qu'il a corrigé après avoir commenté son blog. J'espère que la solution d'Alex est maintenant viable. Ma question se pose toujours: quelle est la bonne solution?
ziggurism
1
Bien qu'il ait environ trois ans, j'ai trouvé ce billet de blog à rookieonrails.blogspot.com/2008/01/… et la conversation liée de la liste de diffusion des rails informative. L'un des répondants décrit la différence entre les helpers polymorphes et les helpers nommés.
ziggurism
2
Une option que vous ne listez pas est de patcher Rails afin que link_to, form_for et autres soient bien placés avec un héritage de table unique. C'est peut-être un travail difficile, mais c'est quelque chose que j'aimerais voir corrigé.
M. Scott Ford

Réponses:

140

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

Maintenant url_for @personmappera contact_pathcomme prévu.

Comment ça marche: les assistants d'URL s'appuient YourModel.model_namepour réfléchir sur le modèle et générer (entre autres) des clés de route au singulier / au pluriel. Voici Personessentiellement en disant que je suis juste comme Contactmec, demandez-lui .

Prathan Thananart
la source
4
Je pensais faire la même chose, mais j'avais peur que #model_name puisse être utilisé ailleurs dans Rails, et que ce changement puisse interférer avec le fonctionnement normal. Des pensées?
nkassis le
3
Je suis totalement d'accord avec le mystérieux inconnu @nkassis. C'est un hack sympa, mais comment savez-vous que vous ne cassez pas les composants internes des rails?
tsherif
6
Spécifications. Aussi, nous utilisons ce code en production, et je peux attester qu'il ne gâche pas: 1) les relations inter-modèles, 2) l'instanciation du modèle STI (via build_x/ create_x). D'un autre côté, le prix à jouer avec la magie est que vous n'êtes jamais sûr à 100% de ce qui peut changer.
Prathan Thananart
10
Cela casse i18n si vous essayez d'avoir différents noms humains pour les attributs en fonction de la classe.
Rufo Sanchez
4
Plutôt que de remplacer complètement comme ça, vous pouvez simplement remplacer les bits dont vous avez besoin. Voir gist.github.com/sj26/5843855
sj26
47

J'ai eu le même problème. Après avoir utilisé STI, la form_forméthode publiait sur la mauvaise URL de l'enfant.

NoMethodError (undefined method `building_url' for

J'ai fini par ajouter les routes supplémentaires pour les classes enfants et les diriger 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.

James
la source
2
Cela fonctionne, mais ajoute beaucoup de chemins inutiles à nos itinéraires. N'y a-t-il pas un moyen de le faire d'une manière moins intrusive?
Anders Kindberg
1
Vous pouvez configurer des routes par programme dans votre fichier routes.rb, vous pouvez donc faire une petite méta-programmation pour configurer les routes enfants. Cependant, dans les environnements où les classes ne sont pas mises en cache (par exemple en développement), vous devez d'abord précharger les classes. Donc, d'une manière ou d'une autre, vous devez spécifier les sous-classes quelque part. Voir gist.github.com/1713398 pour un exemple.
Chris Bloom
Dans mon cas, exposer le nom de l'objet (chemin) à l'utilisateur n'est pas souhaitable (et déroutant pour l'utilisateur).
laffuste
33

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

ActiveRecord::Base#becomes
Siwei Shen 申思维
la source
2
J'ai dû définir explicitement l'URL pour qu'elle affiche à la fois le from et l' enregistre correctement. <%= form_for @child, :as => :child, url: @child.becomes(Parent)
lulalala
4
@lulalala Try<%= form_for @child.becomes(Parent)
Richard Jones
14

Suivant 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é à contact_path comme prévu.

eloyesp
la source
14

J'avais aussi des problèmes avec ce problème et j'ai trouvé cette réponse sur une question similaire à la nôtre. Cela a fonctionné pour moi.

form_for @list.becomes(List)

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

La .becomesméthode est définie comme principalement utilisée pour résoudre des problèmes d'IST comme votreform_for .

.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é!

Marcus
la source
5

Ok, j'ai eu une tonne de frustration dans ce domaine de Rails, et je 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 car Ruby ne récupère pas les symboles, 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 qui est SÉCURISÉE par rapport au problème de contantize ci-dessus. Il est très similaire à ce que fait les rails 4, mais permet également plus d'un niveau 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 'problème de chargement de sous-classes dans le développement', beaucoup 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 de classe fonctionne correctement dans le développement et ne pose aucun problème en production. En développement, sans "require_dependency", AR ne connaîtra pas toutes les sous-classes, ce qui a un impact sur 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 arriver lorsque vous changez une classe de base ou intermédiaire, les sous-classes ne semblent pas toujours se recharger et sont laissées sous-classées par rapport à 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 des différentes sous-classes, par exemple: tax_identifier devient 'ABN' pour Organization et 'TFN' pour Person (en Australie).

J'utilise également la cartographie 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 d'itinéraire, j'utilise InheritedResources et SimpleForm et j'utilise le wrapper de formulaire générique suivant pour les 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 mon ResourceContoller de base, j'expose resource_request_name de InheritedResource comme méthode d'assistance 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.

Andrew Hacking
la source
D'après mon expérience (au moins dans Rails 3.0.9), la constante échoue si la constante nommée par la chaîne n'existe pas déjà. Alors, comment peut-il être utilisé pour créer de nouveaux symboles arbitraires?
Lex Lindsey
4

J'ai récemment documenté mes tentatives pour obtenir un modèle STI stable 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 IST.

Chris Bloom
la source
2

Vous pouvez essayer ceci, si vous n'avez pas d'itinéraires imbriqués:

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

Ou vous pouvez aller d'une autre manière et utiliser un peu de magie OOP comme décrit ici: https://coderwall.com/p/yijmuq

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

everm1nd
la source
2

Voici un moyen sûr et propre 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 dans ma forme. La pièce supplémentaire pour cela est le as:: district.

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

J'espère que cela t'aides.

Jake
la source
2

La solution la plus propre que j'ai trouvée est d'ajouter ce qui suit à la classe de base:

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

Cela fonctionne pour toutes les sous-classes et est beaucoup plus sûr que de remplacer tout l'objet de nom de modèle. En ciblant uniquement les clés de route, nous résolvons les problèmes de routage sans casser I18n ni risquer des effets secondaires potentiels causés par le remplacement du nom du modèle tel que défini par Rails.

Alexander Makarenko
la source
1

Si je considère un héritage IST comme ceci:

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 souhaite utiliser par défaut, et cela sans même toucher à la définition de la sous-classe. Très sec.

Laurent B.
la source
1

Cette façon 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
ka8725
la source
1

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

mêler
la source
1

Suite à 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 chaque formulaire avec des données de contact pour envoyer params comme params[:contact]lieu de params[:contact_person], params[:contact_whatever].

Zlatko Alomerovic
la source
-6

hackish, mais juste un autre à la liste des solutions.

class Parent < ActiveRecord::Base; end

Class Child < Parent
  def class
    Parent
  end
end

fonctionne sur les rails 2.x et 3.x

Ohad Levy
la source
5
Cela résout un problème, mais en crée un autre. Maintenant, lorsque vous essayez de le faire, Child.newcela renvoie une Parentclasse plutôt que la sous-classe. Cela signifie que vous ne pouvez pas créer les sous-classes via une affectation en masse via un contrôleur (car il types'agit d'un attribut protégé par défaut) à moins que vous ne définissiez également l'attribut type explicitement.
Chris Bloom