Best Practices für den Umgang mit Routen für STI-Unterklassen in Schienen

175

Meine Rails Ansichten und Controller sind mit übersät redirect_to, link_tound form_forMethodenaufrufe. Manchmal link_tound redirect_toexplizit in den Pfaden, die sie verknüpfen (z. B. link_to 'New Person', new_person_path), aber oft sind die Pfade implizit (z link_to 'Show', person. B. ).

Ich füge meinem Modell eine einzelne Tabellenvererbung (STI) hinzu (sagen wir Employee < Person), und alle diese Methoden brechen für eine Instanz der Unterklasse ab (sagen wir Employee). Wenn Schienen ausgeführt werden link_to @person, tritt ein Fehler auf undefined method employee_path' for #<#<Class:0x000001022bcd40>:0x0000010226d038>. Rails sucht nach einer Route, die durch den Klassennamen des Objekts definiert ist, bei dem es sich um einen Mitarbeiter handelt. Diese Mitarbeiterrouten sind nicht definiert, und es gibt keinen Mitarbeiter-Controller, sodass die Aktionen auch nicht definiert sind.

Diese Frage wurde schon einmal gestellt:

  1. Bei StackOverflow besteht die Antwort darin, jede Instanz von link_to usw. in Ihrer gesamten Codebasis zu bearbeiten und den Pfad explizit anzugeben
  2. Bei StackOverflow schlagen zwei Personen erneut vor, routes.rbdie Ressourcen der Unterklasse der übergeordneten Klasse ( map.resources :employees, :controller => 'people') zuzuordnen . Die oberste Antwort in derselben SO-Frage schlägt vor, jedes Instanzobjekt in der Codebasis mit zu typisieren.becomes
  3. Eine weitere Antwort bei StackOverflow ist die Antwort im Do Do Repeat Yourself-Camp und schlägt vor, für jede Unterklasse ein doppeltes Gerüst zu erstellen.
  4. Hier ist wieder die gleiche Frage bei SO, wo die Top-Antwort einfach falsch zu sein scheint (Rails Magic Just Works!).
  5. An anderer Stelle im Web habe ich diesen Blog-Beitrag gefunden, in dem F2Andy empfiehlt, den Pfad überall im Code zu bearbeiten.
  6. In dem Blogbeitrag Single Table Inheritance und RESTful Routes bei Logical Reality Design wird empfohlen, die Ressourcen für die Unterklasse dem Superklassen-Controller zuzuordnen, wie in SO-Antwort Nummer 2 oben.
  7. Alex Reisner hat eine Post- Single-Table-Vererbung in Rails , in der er sich dafür einsetzt, die Ressourcen der untergeordneten Klassen nicht der übergeordneten Klasse in routes.rbzuzuordnen, da dadurch nur Routing-Brüche von link_tound redirect_to, aber nicht von abgefangen werden form_for. Daher empfiehlt er, der übergeordneten Klasse stattdessen eine Methode hinzuzufügen, damit die Unterklassen über ihre Klasse lügen. Hört sich gut an, aber seine Methode hat mir den Fehler gegeben undefined local variable or method `child' for #.

So ist die Antwort , die eleganteste scheint und hat die meisten Konsens (aber es ist nicht alles , dass elegant, noch , dass viel Konsens), die die Ressourcen , um Ihre hinzufügen routes.rb. Nur dass das nicht funktioniert form_for. Ich brauche etwas Klarheit! Um die oben genannten Optionen zu destillieren, sind meine Optionen

  1. Ordnen Sie die Ressourcen der Unterklasse dem Controller der Oberklasse in zu routes.rb(und hoffen Sie, dass ich form_for für keine Unterklassen aufrufen muss).
  2. Überschreiben Sie interne Methoden für Schienen, damit die Klassen sich gegenseitig belügen
  3. Bearbeiten Sie jede Instanz im Code, in der der Pfad zur Aktion eines Objekts implizit oder explizit aufgerufen wird, indem Sie entweder den Pfad ändern oder das Objekt typumwandeln.

Bei all diesen widersprüchlichen Antworten brauche ich eine Entscheidung. Mir scheint, es gibt keine gute Antwort. Ist dies ein Fehler im Design der Schienen? Wenn ja, ist es ein Fehler, der möglicherweise behoben wird? Oder wenn nicht, dann hoffe ich, dass mich jemand klarstellen kann, mich durch die Vor- und Nachteile jeder Option führt (oder erklärt, warum dies keine Option ist) und welche die richtige Antwort ist und warum. Oder gibt es eine richtige Antwort, die ich im Web nicht finde?

Zikkurismus
quelle
1
Es gab einen Tippfehler in Alex Reisners Code, den er behoben hat, nachdem ich seinen Blog kommentiert hatte. Hoffentlich ist jetzt Alex 'Lösung realisierbar. Meine Frage bleibt noch: Welches ist die richtige Lösung?
Zikkurismus
1
Obwohl es ungefähr drei Jahre alt ist, fand ich diesen Blog-Beitrag unter rookieonrails.blogspot.com/2008/01/… und die verknüpfte Konversation von der Rails-Mailingliste informativ. Einer der Responder beschreibt den Unterschied zwischen polymorphen Helfern und benannten Helfern.
Zikkurismus
2
Eine Option, die Sie nicht auflisten, besteht darin, Rails so zu patchen, dass link_to, form_for und dergleichen mit der Vererbung einzelner Tabellen gut platziert sind. Das mag ein harter Job sein, aber es ist etwas, das ich gerne behoben sehen würde.
M. Scott Ford

Antworten:

140

Dies ist die einfachste Lösung, die ich mit minimalen Nebenwirkungen finden konnte.

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

Jetzt url_for @personwird contact_pathwie erwartet zugeordnet.

So funktioniert es: URL-Helfer verlassen sich darauf YourModel.model_name, über das Modell nachzudenken und (unter anderem) Singular- / Plural-Routenschlüssel zu generieren. Hier Personheißt es im Grunde, ich bin wie ein ContactTyp, frag ihn .

Prathan Thananart
quelle
4
Ich dachte daran, dasselbe zu tun, war aber besorgt, dass #model_name an anderer Stelle in Rails verwendet werden könnte und dass diese Änderung die normale Funktion beeinträchtigen könnte. Irgendwelche Gedanken?
nkassis
3
Ich stimme dem mysteriösen Fremden @nkassis voll und ganz zu. Dies ist ein cooler Hack, aber woher weißt du, dass du die Interna von Rails nicht kaputt machst?
Tsherif
6
Technische Daten Außerdem verwenden wir diesen Code in der Produktion, und ich kann bestätigen, dass er nicht durcheinander bringt: 1) Beziehungen zwischen Modellen, 2) Instanziierung von STI-Modellen (über build_x/ create_x). Andererseits ist der Preis für das Spielen mit Magie, dass Sie nie 100% sicher sind, was sich ändern kann.
Prathan Thananart
10
Dies bricht i18n ab, wenn Sie versuchen, je nach Klasse unterschiedliche menschliche Namen für Attribute zu verwenden.
Rufo Sanchez
4
Anstatt dies vollständig zu überschreiben, können Sie einfach die benötigten Bits überschreiben. Siehe gist.github.com/sj26/5843855
sj26
46

Ich hatte das gleiche Problem. Nach der Verwendung von STI wurde die form_forMethode an die falsche untergeordnete URL gesendet.

NoMethodError (undefined method `building_url' for

Am Ende fügte ich die zusätzlichen Routen für die untergeordneten Klassen hinzu und zeigte sie auf dieselben Controller

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

Zusätzlich:

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

In diesem Fall ist die Struktur tatsächlich ein Gebäude (Kinderklasse).

Es scheint für mich zu funktionieren, nachdem ich einen Submit mit gemacht habe form_for.

James
quelle
2
Dies funktioniert, fügt unseren Routen jedoch viele unnötige Pfade hinzu. Gibt es nicht eine Möglichkeit, dies weniger aufdringlich zu tun?
Anders Kindberg
1
Sie können Routen programmgesteuert in Ihrer Datei route.rb einrichten, sodass Sie eine kleine Metaprogrammierung durchführen können, um die untergeordneten Routen einzurichten. In Umgebungen, in denen Klassen nicht zwischengespeichert werden (z. B. Entwicklung), müssen Sie die Klassen jedoch zuerst vorladen. Auf die eine oder andere Weise müssen Sie die Unterklassen irgendwo angeben. Ein Beispiel finden Sie unter gist.github.com/1713398 .
Chris Bloom
In meinem Fall ist es nicht wünschenswert (und für den Benutzer verwirrend), den Objektnamen (Pfad) dem Benutzer zur Verfügung zu stellen.
Laffuste
33

Ich schlage vor, Sie werfen einen Blick auf: https://stackoverflow.com/a/605172/445908 . Mit dieser Methode können Sie "form_for" verwenden.

ActiveRecord::Base#becomes
Siwei Shen 申思维
quelle
2
Ich musste die URL explizit festlegen, damit sie sowohl von als auch richtig rendert. <%= form_for @child, :as => :child, url: @child.becomes(Parent)
Lulalala
4
@lulalala Versuch<%= form_for @child.becomes(Parent)
Richard Jones
14

Der Idee von @Prathan Thananart folgen, aber versuchen, nichts zu zerstören. (da es so viel Magie gibt)

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

Jetzt wird url_for @person wie erwartet contact_path zugeordnet.

eloyesp
quelle
14

Ich hatte auch Probleme mit diesem Problem und kam mit dieser Antwort auf eine ähnliche Frage wie wir. Es hat bei mir funktioniert.

form_for @list.becomes(List)

Hier gezeigte Antwort: Verwenden des STI-Pfads mit demselben Controller

Die .becomesMethode wird hauptsächlich zur Lösung von STI-Problemen wie Ihrem verwendet form_for.

.becomesInfos hier: http://apidock.com/rails/ActiveRecord/Base/becomes

Super späte Antwort, aber dies ist die beste Antwort, die ich finden konnte und es hat gut für mich funktioniert. Hoffe das hilft jemandem. Prost!

Marcus
quelle
5

Ok, ich war in diesem Bereich von Rails sehr frustriert und bin zu folgendem Ansatz gelangt. Vielleicht hilft dies anderen.

Beachten Sie zunächst, dass eine Reihe von Lösungen über und um das Netz die Verwendung der Konstante für vom Client bereitgestellte Parameter empfehlen. Dies ist ein bekannter DoS-Angriffsvektor, da Ruby keine Müllsammelsymbole sammelt, sodass ein Angreifer beliebige Symbole erstellen und verfügbaren Speicher belegen kann.

Ich habe den folgenden Ansatz implementiert, der die Instanziierung von Modellunterklassen unterstützt und gegenüber dem obigen Contantize-Problem SICHER ist. Es ist dem, was Rails 4 macht, sehr ähnlich, erlaubt aber auch mehr als eine Unterklassenebene (im Gegensatz zu Rails 4) und funktioniert 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)

Nachdem ich verschiedene Ansätze für das Problem des Ladens von Unterklassen in der Entwicklung ausprobiert hatte, die den oben vorgeschlagenen ähnlich waren, stellte ich fest, dass das einzige, was zuverlässig funktionierte, die Verwendung von "require_dependency" in meinen Modellklassen war. Dies stellt sicher, dass das Laden von Klassen in der Entwicklung ordnungsgemäß funktioniert und keine Probleme in der Produktion verursacht. In der Entwicklung kennt AR ohne 'require_dependency' nicht alle Unterklassen, was sich auf die SQL auswirkt, die für den Abgleich in der Typspalte ausgegeben wird. Außerdem können Sie ohne 'require_dependency' auch in eine Situation geraten, in der mehrere Versionen der Modellklassen gleichzeitig ausgeführt werden! (z. B. kann dies passieren, wenn Sie eine Basis- oder Zwischenklasse ändern. Die Unterklassen scheinen nicht immer neu geladen zu werden und werden von der alten Klasse in Unterklassen belassen.)

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

require_dependency 'person'
require_dependency 'organisation'

Ich überschreibe auch nicht model_name wie oben vorgeschlagen, da ich I18n verwende und unterschiedliche Zeichenfolgen für die Attribute verschiedener Unterklassen benötige, z. B.: Tax_identifier wird zu 'ABN' für Organisation und 'TFN' für Person (in Australien).

Ich verwende auch die Routenzuordnung, wie oben vorgeschlagen, um den Typ festzulegen:

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

Zusätzlich zur Routenzuordnung verwende ich InheritedResources und SimpleForm und verwende den folgenden generischen Formular-Wrapper für neue Aktionen:

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

... und für Bearbeitungsaktionen:

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

Damit dies funktioniert, mache ich in meinem Basis-ResourceContoller den Ressourcenanforderungsnamen von InheritedResource als Hilfsmethode für die Ansicht verfügbar:

helper_method :resource_request_name 

Wenn Sie InheritedResources nicht verwenden, verwenden Sie in Ihrem 'ResourceController' Folgendes:

# 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

Immer froh, andere Erfahrungen und Verbesserungen zu hören.

Andrew Hacking
quelle
Nach meiner Erfahrung (zumindest in Rails 3.0.9) schlägt die Konstantisierung fehl, wenn die durch die Zeichenfolge benannte Konstante noch nicht vorhanden ist. Wie kann es also verwendet werden, um beliebige neue Symbole zu erstellen?
Lex Lindsey
4

Ich habe kürzlich meine Versuche dokumentiert , ein stabiles STI-Muster in einer Rails 3.0-App zum Laufen zu bringen. Hier ist die 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

Dieser Ansatz umgeht die von Ihnen aufgelisteten Probleme sowie eine Reihe anderer Probleme, die andere mit STI-Ansätzen hatten.

Chris Bloom
quelle
2

Sie können dies versuchen, wenn Sie keine verschachtelten Routen haben:

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

Oder Sie gehen einen anderen Weg und verwenden OOP-Magie wie hier beschrieben: https://coderwall.com/p/yijmuq

Auf die zweite Weise können Sie ähnliche Helfer für alle Ihre verschachtelten Modelle erstellen.

everm1nd
quelle
2

Hier finden Sie eine sichere und saubere Möglichkeit, damit es in Formularen und in Ihrer gesamten Anwendung, die wir verwenden, funktioniert.

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

Dann habe ich in meiner Form. Das hinzugefügte Stück hierfür ist der Bezirk as ::.

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

Hoffe das hilft.

Jake
quelle
2

Die sauberste Lösung, die ich gefunden habe, besteht darin, der Basisklasse Folgendes hinzuzufügen:

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

Es funktioniert für alle Unterklassen und ist viel sicherer als das Überschreiben des gesamten Modellnamenobjekts. Indem wir nur auf die Routenschlüssel abzielen, lösen wir die Routing-Probleme, ohne I18n zu beschädigen oder mögliche Nebenwirkungen zu riskieren, die durch das Überschreiben des von Rails definierten Modellnamens verursacht werden.

Alexander Makarenko
quelle
1

Wenn ich eine STI-Vererbung wie diese betrachte:

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' füge ich hinzu:

module ManagedAtAModelLevel
  def model_name
    AModel.model_name
  end
end

Und dann in der AModel-Klasse:

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

Daher kann ich sogar einfach auswählen, welches Modell ich als Standardmodell verwenden möchte, ohne die Definition der Unterklasse zu berühren. Sehr trocken.

Laurent B.
quelle
1

Dieser Weg funktioniert bei mir ok (definieren Sie diese Methode in der Basisklasse):

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
quelle
1

Sie können eine Methode erstellen, die ein übergeordnetes Dummy-Objekt für den Routing-Zweck zurückgibt

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

und rufen Sie dann einfach form_for @ employee.routing_object auf, das ohne Typ das Personenklassenobjekt zurückgibt

mischen
quelle
1

Nach der Antwort von @ prathan-thananart und für die mehreren STI-Klassen können Sie dem übergeordneten Modell Folgendes hinzufügen ->

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

Dadurch wird jedes Formular mit Kontaktdaten zum Senden von Parametern params[:contact]anstelle von params[:contact_person], params[:contact_whatever].

Zlatko Alomerovic
quelle
-6

hackisch, aber nur ein weiterer auf der Liste der Lösungen.

class Parent < ActiveRecord::Base; end

Class Child < Parent
  def class
    Parent
  end
end

funktioniert auf Schienen 2.x und 3.x.

Ohad Levy
quelle
5
Dies behebt ein Problem, schafft jedoch ein anderes. Wenn Sie dies versuchen, Child.newwird eine ParentKlasse anstelle der Unterklasse zurückgegeben. Dies bedeutet, dass Sie die Unterklassen nicht über die Massenzuweisung über einen Controller erstellen können (da dies typestandardmäßig ein geschütztes Attribut ist), es sei denn, Sie legen das Typattribut auch explizit fest.
Chris Bloom