· documentation · 4 min read
How do Rails Form Helpers Know Where to Go?
Following `polymorphic_path` down the rabbit hole.
Why do Rails 7 Form Helpers accept the parameters they do?
Have you ever wondered how form_with
helpers function?
How is it possible that they can infer a) how to handle building a path, and b) whether the path should be to create a new instance or update an existing instance?
TL;DR: if an object defines delegate :persisted?, :to_model, to:
an object that inherits behavior from ActiveModel::Model
or inherits behavior from ActiveModel::Model
itself, the form_with
method can effectively figure out the resource name, action, and, if needed, the resource ID.
This can be particularly useful if you are passing an instance of a form object that doesn’t inherit from ActiveRecord::Base
into a form helper.
The Deep Dive
The secret lies in 2 clever instance methods that are defined in ActiveModel::Model
and leveraged by the polymorphic_path
method:
persisted?
to_model
For the purposes of this exercise, let’s build up the contract required for the polymorphic_path from a PORO (Plain Old Ruby Object). Imagine we have a Person
class:
class Person
def initialize(id)
@id = id
end
end
# rails console
p = Person.new(1)
app.polymorphic_path(p)
# => NoMethodError: undefined method `to_model' for #<Person:0x000000011707ec58 @id=1>
If we pass an instance of the Person
class to polymorphic_path
, the method first checks to see whether the record can be converted to a model by calling…to_model
. Let’s give our class the ability to respond to that message:
class Person
def initialize(id)
@id = id
end
def to_model
self
end
end
# rails console
p = Person.new(1)
app.polymorphic_path(p)
# => NoMethodError: undefined method `model_name' for #<Person:0x0000000117327860 @id=1>
As a model, the routes helper expects that our Person
class will also be able to respond to the model_name
message with an object an object that responds to name
:
ModelName = Struct.new(:name)
class Person
def initialize(id)
@id = id
end
def to_model
self
end
def model_name
ModelName.new('Person')
end
end
# rails console
p = Person.new(1)
app.polymorphic_path(p)
# => NoMethodError: undefined method `persisted?' for #<Person:0x00000001173958d8 @id=1>
Next, the polymorphic_path checks to see whether the object is persisted?
. When persisted?
, the polymorphic_path
method attempts to construct a path for the edit
action. Otherwise, it attempts to construct a path for the create
action.
Let’s see how adding a persisted?
method changes the behavior:
ModelName = Struct.new(:name)
class Person
def initialize(id)
@id = id
end
def to_model
self
end
def model_name
ModelName.new('Person')
end
def persisted?
false
end
end
# rails console
p = Person.new(1)
app.polymorphic_path(p)
# => NoMethodError: undefined method `route_key' for #<struct ModelName name="person">
Seems like polymorphic_path
is expecting model_name to also respond to :route_key
, which would be the pluralized version of the class name:
ModelName = Struct.new(:name, :route_key)
class Person
def initialize(id)
@id = id
end
def to_model
self
end
def model_name
ModelName.new('Person', 'people')
end
def persisted?
false
end
end
# rails console
p = Person.new(1)
app.polymorphic_path(p)
# => NoMethodError: undefined method `people_path'
Success! At this point, polymorphic_path has generated a path that, if defined in our routes file, would map to a controller action! What happens if our model is persisted?
? In that case, polymorphic_path
expects that model_name
will respond to a singular_route_key
call:
ModelName = Struct.new(:name, :route_key, :singular_route_key)
class Person
def initialize(id)
@id = id
end
def to_model
self
end
def model_name
ModelName.new('Person', 'people', 'person')
end
def persisted?
true
end
end
# rails console
p = Person.new(1)
app.polymorphic_path(p)
# => NoMethodError: undefined method `person_path'
Pretty close! The only outstanding detail to confirm is that the polymorphic_path is correctly populating the correct ID (i.e. 1
) for our instance. After updating the routes.rb
with resources :person
, we see our person_path
is populated with the ruby object’s ID in memory, rather than the ID stored in the instance variable.
To remedy this, we need to override our PORO’s definition of to_param
:
ModelName = Struct.new(:name, :route_key, :singular_route_key)
class Person
def initialize(id)
@id = id
end
def to_model
self
end
def model_name
ModelName.new('Person', 'people', 'person')
end
def persisted?
true
end
def to_param
@id.to_s
end
end
# rails console
p = Person.new(1)
app.polymorphic_path(p)
# => "/person/1"
SUCCESS! While building up the API of messages a PORO must respond to is a good exercise to better understand the inner workings of an API we might take for granted, it’s impractical to worry about defining these methods on every object.
Thankfully, we are able to include ActiveModel::Model
in our PORO to gain access to these methods, or we can choose to delegate :persisted?, :to_model
to another model.