Add custom polymorphic mapping

Allow the use of `direct` to specify custom mappings for polymorphic_url, e.g:

  resource :basket
  direct(class: "Basket") { [:basket] }

This will then generate the following:

  >> link_to "Basket", @basket
  => <a href="/basket">Basket</a>

More importantly it will generate the correct url when used with `form_for`.

Fixes #1769.
This commit is contained in:
Andrew White 2017-02-20 20:22:42 +00:00
parent d67e252028
commit 3bf47b018b
6 changed files with 314 additions and 65 deletions

View File

@ -2021,8 +2021,8 @@ module ActionDispatch
end
module DirectUrls
# Define a custom url helper that will be added to the url helpers
# module. This allows you override and/or replace the default behavior
# Define custom routing behavior that will be added to the application's
# routes. This allows you override and/or replace the default behavior
# of routing helpers, e.g:
#
# direct :homepage do
@ -2037,8 +2037,35 @@ module ActionDispatch
# { controller: 'pages', action: 'index', subdomain: 'www' }
# end
#
# The return value must be a valid set of arguments for `url_for` which
# will actually build the url string. This can be one of the following:
# The above example show how to define a custom url helper but it's also
# possible to alter the behavior of `polymorphic_url` and consequently the
# behavior of `link_to` and `form_for` when passed a model instance, e.g:
#
# direct class: "Basket" do
# [:basket]
# end
#
# NOTE: This custom behavior only applies to simple polymorphic urls where
# a single model instance is passed and not more complicated forms, e.g:
#
# # config/routes.rb
# resource :profile
# namespace :admin do
# resources :users
# end
#
# direct(class: "User") { [:profile] }
#
# # app/views/application/_menu.html.erb
# link_to 'Profile', @current_user
# link_to 'Profile', [:admin, @current_user]
#
# The first `link_to` will generate '/profile' but the second will generate
# the standard polymorphic url of '/admin/users/1'.
#
# The return value from the block passed to `direct` must be a valid set of
# arguments for `url_for` which will actually build the url string. This can
# be one of the following:
#
# * A string, which is treated as a generated url
# * A hash, e.g. { controller: 'pages', action: 'index' }
@ -2046,6 +2073,9 @@ module ActionDispatch
# * An Active Model instance
# * An Active Model class
#
# NOTE: Other url helpers can be called in the block but be careful not to invoke
# your custom url helper again otherwise it will result in a stack overflow error
#
# You can also specify default options that will be passed through to
# your url helper definition, e.g:
#
@ -2053,14 +2083,28 @@ module ActionDispatch
# [ :products, options.merge(params.permit(:page, :size)) ]
# end
#
# NOTE: It is the url helper's responsibility to return the correct
# set of options to be passed to the `url_for` call.
def direct(name, options = nil, &block)
case name
# You can pass options to a polymorphic mapping do - the arity for the block
# needs to be two as the instance is passed as the first argument, e.g:
#
# direct class: 'Basket', anchor: 'items' do |basket, options|
# [:basket, options]
# end
#
# This generates the url '/basket#items' because when the last item in an
# array passed to `polymorphic_url` is a hash then it's treated as options
# to the url helper that gets called.
#
# NOTE: The `direct` method doesn't observe the current scope in routes.rb
# and because of this it's recommended to define them outside of any blocks
# such as `namespace` or `scope`.
def direct(name_or_hash, options = nil, &block)
case name_or_hash
when Hash
@set.add_polymorphic_mapping(name_or_hash, &block)
when String, Symbol
@set.add_url_helper(name, options, &block)
@set.add_url_helper(name_or_hash, options, &block)
else
raise ArgumentError, "The direct method only accepts a string or symbol"
raise ArgumentError, "The direct method only accepts a hash, string or symbol"
end
end
end

View File

@ -103,6 +103,10 @@ module ActionDispatch
return polymorphic_url record, options
end
if mapping = polymorphic_mapping(record_or_hash_or_array)
return mapping.call(self, [record_or_hash_or_array], options)
end
opts = options.dup
action = opts.delete :action
type = opts.delete(:routing_type) || :url
@ -123,6 +127,10 @@ module ActionDispatch
return polymorphic_path record, options
end
if mapping = polymorphic_mapping(record_or_hash_or_array)
return mapping.call(self, [record_or_hash_or_array], options, only_path: true)
end
opts = options.dup
action = opts.delete :action
type = :path
@ -156,6 +164,11 @@ module ActionDispatch
polymorphic_path(record_or_hash, options.merge(action: action))
end
def polymorphic_mapping(record)
return false unless record.respond_to?(:to_model)
_routes.polymorphic_mappings[record.to_model.model_name.name]
end
class HelperMethodBuilder # :nodoc:
CACHE = { "path" => {}, "url" => {} }
@ -255,9 +268,13 @@ module ActionDispatch
[named_route, args]
end
def handle_model_call(target, model)
method, args = handle_model model
target.send(method, *args)
def handle_model_call(target, record)
if mapping = polymorphic_mapping(target, record)
mapping.call(target, [record], {}, only_path: suffix == "path")
else
method, args = handle_model(record)
target.send(method, *args)
end
end
def handle_list(list)
@ -303,6 +320,10 @@ module ActionDispatch
private
def polymorphic_mapping(target, record)
target._routes.polymorphic_mappings[record.to_model.model_name.name]
end
def get_method_for_class(klass)
name = @key_strategy.call klass.model_name
get_method_for_string name

View File

@ -160,7 +160,7 @@ module ActionDispatch
def add_url_helper(name, defaults, &block)
@custom_helpers << name
helper = CustomUrlHelper.new(name, defaults, &block)
helper = DirectUrlHelper.new(name, defaults, &block)
@path_helpers_module.module_eval do
define_method(:"#{name}_path") do |*args|
@ -177,47 +177,6 @@ module ActionDispatch
end
end
class CustomUrlHelper
attr_reader :name, :defaults, :block
def initialize(name, defaults, &block)
@name = name
@defaults = defaults
@block = block
end
def call(t, args, options, outer_options = {})
url_options = eval_block(t, args, options)
case url_options
when String
t.url_for(url_options)
when Hash
t.url_for(url_options.merge(outer_options))
when ActionController::Parameters
if url_options.permitted?
t.url_for(url_options.to_h.merge(outer_options))
else
raise ArgumentError, "Generating an URL from non sanitized request parameters is insecure!"
end
when Array
opts = url_options.extract_options!
t.url_for(url_options.push(opts.merge(outer_options)))
else
t.url_for([url_options, outer_options])
end
end
private
def eval_block(t, args, options)
t.instance_exec(*args, merge_defaults(options), &block)
end
def merge_defaults(options)
defaults ? defaults.merge(options) : options
end
end
class UrlHelper
def self.create(route, options, route_name, url_strategy)
if optimize_helper?(route)
@ -380,7 +339,7 @@ module ActionDispatch
attr_accessor :formatter, :set, :named_routes, :default_scope, :router
attr_accessor :disable_clear_and_finalize, :resources_path_names
attr_accessor :default_url_options
attr_reader :env_key
attr_reader :env_key, :polymorphic_mappings
alias :routes :set
@ -422,6 +381,7 @@ module ActionDispatch
@set = Journey::Routes.new
@router = Journey::Router.new @set
@formatter = Journey::Formatter.new self
@polymorphic_mappings = {}
end
def eager_load!
@ -483,6 +443,7 @@ module ActionDispatch
named_routes.clear
set.clear
formatter.clear
@polymorphic_mappings.clear
@prepend.each { |blk| eval_block(blk) }
end
@ -554,6 +515,14 @@ module ActionDispatch
@_proxy.optimize_routes_generation?
end
def polymorphic_url(record_or_hash_or_array, options = {})
@_proxy.polymorphic_url(record_or_hash_or_array, options)
end
def polymorphic_path(record_or_hash_or_array, options = {})
@_proxy.polymorphic_path(record_or_hash_or_array, options)
end
def _routes; @_proxy._routes; end
def url_options; {}; end
end
@ -629,10 +598,61 @@ module ActionDispatch
route
end
def add_polymorphic_mapping(options, &block)
defaults = options.dup
klass = defaults.delete(:class)
if klass.nil?
raise ArgumentError, "Missing :class key from polymorphic mapping options"
end
@polymorphic_mappings[klass] = DirectUrlHelper.new(klass, defaults, &block)
end
def add_url_helper(name, options, &block)
named_routes.add_url_helper(name, options, &block)
end
class DirectUrlHelper
attr_reader :name, :defaults, :block
def initialize(name, defaults, &block)
@name = name
@defaults = defaults
@block = block
end
def call(t, args, options, outer_options = {})
url_options = eval_block(t, args, options)
case url_options
when String
t.url_for(url_options)
when Hash
t.url_for(url_options.merge(outer_options))
when ActionController::Parameters
if url_options.permitted?
t.url_for(url_options.to_h.merge(outer_options))
else
raise ArgumentError, "Generating an URL from non sanitized request parameters is insecure!"
end
when Array
opts = url_options.extract_options!
t.url_for(url_options.push(opts.merge(outer_options)))
else
t.url_for([url_options, outer_options])
end
end
private
def eval_block(t, args, options)
t.instance_exec(*args, merge_defaults(options), &block)
end
def merge_defaults(options)
defaults ? defaults.merge(options) : options
end
end
class Generator
PARAMETERIZE = lambda do |name, value|
if name == :controller

View File

@ -1,6 +1,6 @@
require "abstract_unit"
class TestCustomUrlHelpers < ActionDispatch::IntegrationTest
class TestDirectUrlHelpers < ActionDispatch::IntegrationTest
class Linkable
attr_reader :id
@ -17,12 +17,37 @@ class TestCustomUrlHelpers < ActionDispatch::IntegrationTest
class Collection < Linkable; end
class Product < Linkable; end
class Model
extend ActiveModel::Naming
include ActiveModel::Conversion
attr_reader :id
def initialize(id = nil)
@id = id
end
def model_name
@_model_name ||= ActiveModel::Name.new(self.class, nil, self.class.name.demodulize)
end
def persisted?
false
end
end
class Basket < Model; end
class User < Model; end
class Video < Model; end
Routes = ActionDispatch::Routing::RouteSet.new
Routes.draw do
default_url_options host: "www.example.com"
root to: "pages#index"
get "/basket", to: "basket#show", as: :basket
get "/profile", to: "users#profile", as: :profile
get "/media/:id", to: "media#show", as: :media
resources :categories, :collections, :products
@ -40,6 +65,10 @@ class TestCustomUrlHelpers < ActionDispatch::IntegrationTest
direct(:array) { [:admin, :dashboard] }
direct(:options) { |options| [:products, options] }
direct(:defaults, size: 10) { |options| [:products, options] }
direct(class: "Basket") { |basket| [:basket] }
direct(class: "User", anchor: "details") { |user, options| [:profile, options] }
direct(class: "Video") { |video| [:media, { id: video.id }] }
end
APP = build_app Routes
@ -54,6 +83,9 @@ class TestCustomUrlHelpers < ActionDispatch::IntegrationTest
@category = Category.new("1")
@collection = Collection.new("2")
@product = Product.new("3")
@basket = Basket.new
@user = User.new
@video = Video.new("4")
@path_params = { "controller" => "pages", "action" => "index" }
@unsafe_params = ActionController::Parameters.new(@path_params)
@safe_params = ActionController::Parameters.new(@path_params).permit(:controller, :action)
@ -94,6 +126,19 @@ class TestCustomUrlHelpers < ActionDispatch::IntegrationTest
assert_equal "/products?size=10", Routes.url_helpers.defaults_path
assert_equal "/products?size=20", defaults_path(size: 20)
assert_equal "/products?size=20", Routes.url_helpers.defaults_path(size: 20)
assert_equal "/basket", polymorphic_path(@basket)
assert_equal "/basket", Routes.url_helpers.polymorphic_path(@basket)
assert_equal "/profile#details", polymorphic_path(@user)
assert_equal "/profile#details", Routes.url_helpers.polymorphic_path(@user)
assert_equal "/profile#password", polymorphic_path(@user, anchor: "password")
assert_equal "/profile#password", Routes.url_helpers.polymorphic_path(@user, anchor: "password")
assert_equal "/media/4", polymorphic_path(@video)
assert_equal "/media/4", Routes.url_helpers.polymorphic_path(@video)
assert_equal "/media/4", ActionDispatch::Routing::PolymorphicRoutes::HelperMethodBuilder.path.handle_model_call(self, @video)
end
def test_direct_urls
@ -131,6 +176,21 @@ class TestCustomUrlHelpers < ActionDispatch::IntegrationTest
assert_equal "http://www.example.com/products?size=10", Routes.url_helpers.defaults_url
assert_equal "http://www.example.com/products?size=20", defaults_url(size: 20)
assert_equal "http://www.example.com/products?size=20", Routes.url_helpers.defaults_url(size: 20)
assert_equal "http://www.example.com/basket", polymorphic_url(@basket)
assert_equal "http://www.example.com/basket", Routes.url_helpers.polymorphic_url(@basket)
assert_equal "http://www.example.com/basket", polymorphic_url(@basket)
assert_equal "http://www.example.com/basket", Routes.url_helpers.polymorphic_url(@basket)
assert_equal "http://www.example.com/profile#details", polymorphic_url(@user)
assert_equal "http://www.example.com/profile#details", Routes.url_helpers.polymorphic_url(@user)
assert_equal "http://www.example.com/profile#password", polymorphic_url(@user, anchor: "password")
assert_equal "http://www.example.com/profile#password", Routes.url_helpers.polymorphic_url(@user, anchor: "password")
assert_equal "http://www.example.com/media/4", polymorphic_url(@video)
assert_equal "http://www.example.com/media/4", Routes.url_helpers.polymorphic_url(@video)
assert_equal "http://www.example.com/media/4", ActionDispatch::Routing::PolymorphicRoutes::HelperMethodBuilder.url.handle_model_call(self, @video)
end
def test_raises_argument_error
@ -142,4 +202,14 @@ class TestCustomUrlHelpers < ActionDispatch::IntegrationTest
end
end
end
def test_missing_class_raises_argument_error
routes = ActionDispatch::Routing::RouteSet.new
assert_raises ArgumentError do
routes.draw do
direct(fragment: "core") { "http://www.rubyonrails.org" }
end
end
end
end

View File

@ -124,6 +124,10 @@ module ActionView
@_rendered_views ||= RenderedViewsCollection.new
end
def _routes
@controller._routes if @controller.respond_to?(:_routes)
end
# Need to experiment if this priority is the best one: rendered => output_buffer
class RenderedViewsCollection
def initialize
@ -258,10 +262,6 @@ module ActionView
end]
end
def _routes
@controller._routes if @controller.respond_to?(:_routes)
end
def method_missing(selector, *args)
begin
routes = @controller.respond_to?(:_routes) && @controller._routes

View File

@ -264,9 +264,9 @@ module ApplicationTests
end
{
"development" => ["baz", "http://www.apple.com"],
"production" => ["bar", "http://www.microsoft.com"]
}.each do |mode, (expected_action, expected_url)|
"development" => ["baz", "http://www.apple.com", "/dashboard"],
"production" => ["bar", "http://www.microsoft.com", "/profile"]
}.each do |mode, (expected_action, expected_url, expected_mapping)|
test "reloads routes when configuration is changed in #{mode}" do
controller :foo, <<-RUBY
class FooController < ApplicationController
@ -281,6 +281,25 @@ module ApplicationTests
def custom
render plain: custom_url
end
def mapping
render plain: url_for(User.new)
end
end
RUBY
app_file "app/models/user.rb", <<-RUBY
class User
extend ActiveModel::Naming
include ActiveModel::Conversion
def model_name
@_model_name ||= ActiveModel::Name.new(self.class, nil, "User")
end
def persisted?
false
end
end
RUBY
@ -288,8 +307,10 @@ module ApplicationTests
Rails.application.routes.draw do
get 'foo', to: 'foo#bar'
get 'custom', to: 'foo#custom'
get 'mapping', to: 'foo#mapping'
direct(:custom) { "http://www.microsoft.com" }
direct(class: "User") { "/profile" }
end
RUBY
@ -301,12 +322,17 @@ module ApplicationTests
get "/custom"
assert_equal "http://www.microsoft.com", last_response.body
get "/mapping"
assert_equal "/profile", last_response.body
app_file "config/routes.rb", <<-RUBY
Rails.application.routes.draw do
get 'foo', to: 'foo#baz'
get 'custom', to: 'foo#custom'
get 'mapping', to: 'foo#mapping'
direct(:custom) { "http://www.apple.com" }
direct(class: "User") { "/dashboard" }
end
RUBY
@ -317,6 +343,9 @@ module ApplicationTests
get "/custom"
assert_equal expected_url, last_response.body
get "/mapping"
assert_equal expected_mapping, last_response.body
end
end
@ -379,7 +408,11 @@ module ApplicationTests
end
def custom
render text: custom_url
render plain: custom_url
end
def mapping
render plain: url_for(User.new)
end
end
RUBY
@ -392,6 +425,21 @@ module ApplicationTests
end
RUBY
app_file "app/models/user.rb", <<-RUBY
class User
extend ActiveModel::Naming
include ActiveModel::Conversion
def model_name
@_model_name ||= ActiveModel::Name.new(self.class, nil, "User")
end
def persisted?
false
end
end
RUBY
app_file "config/routes.rb", <<-RUBY
Rails.application.routes.draw do
get 'foo', to: 'foo#index'
@ -412,6 +460,12 @@ module ApplicationTests
Rails.application.routes.draw do
get 'foo', to: 'foo#index'
get 'bar', to: 'bar#index'
get 'custom', to: 'foo#custom'
direct(:custom) { 'http://www.apple.com' }
get 'mapping', to: 'foo#mapping'
direct(class: 'User') { '/profile' }
end
RUBY
@ -425,6 +479,14 @@ module ApplicationTests
assert_equal "bar", last_response.body
assert_equal "/bar", Rails.application.routes.url_helpers.bar_path
get "/custom"
assert_equal "http://www.apple.com", last_response.body
assert_equal "http://www.apple.com", Rails.application.routes.url_helpers.custom_url
get "/mapping"
assert_equal "/profile", last_response.body
assert_equal "/profile", Rails.application.routes.url_helpers.polymorphic_path(User.new)
app_file "config/routes.rb", <<-RUBY
Rails.application.routes.draw do
get 'foo', to: 'foo#index'
@ -442,6 +504,18 @@ module ApplicationTests
assert_raises NoMethodError do
assert_equal "/bar", Rails.application.routes.url_helpers.bar_path
end
get "/custom"
assert_equal 404, last_response.status
assert_raises NoMethodError do
assert_equal "http://www.apple.com", Rails.application.routes.url_helpers.custom_url
end
get "/mapping"
assert_equal 404, last_response.status
assert_raises NoMethodError do
assert_equal "/profile", Rails.application.routes.url_helpers.polymorphic_path(User.new)
end
end
test "named routes are cleared when reloading" do
@ -463,10 +537,27 @@ module ApplicationTests
end
RUBY
app_file "app/models/user.rb", <<-RUBY
class User
extend ActiveModel::Naming
include ActiveModel::Conversion
def model_name
@_model_name ||= ActiveModel::Name.new(self.class, nil, "User")
end
def persisted?
false
end
end
RUBY
app_file "config/routes.rb", <<-RUBY
Rails.application.routes.draw do
get ':locale/foo', to: 'foo#index', as: 'foo'
get 'users', to: 'foo#users', as: 'users'
direct(:microsoft) { 'http://www.microsoft.com' }
direct(class: 'User') { '/profile' }
end
RUBY
@ -474,10 +565,12 @@ module ApplicationTests
assert_equal "foo", last_response.body
assert_equal "/en/foo", Rails.application.routes.url_helpers.foo_path(locale: "en")
assert_equal "http://www.microsoft.com", Rails.application.routes.url_helpers.microsoft_url
assert_equal "/profile", Rails.application.routes.url_helpers.polymorphic_path(User.new)
app_file "config/routes.rb", <<-RUBY
Rails.application.routes.draw do
get ':locale/bar', to: 'bar#index', as: 'foo'
get 'users', to: 'foo#users', as: 'users'
direct(:apple) { 'http://www.apple.com' }
end
RUBY
@ -491,6 +584,7 @@ module ApplicationTests
assert_equal "bar", last_response.body
assert_equal "/en/bar", Rails.application.routes.url_helpers.foo_path(locale: "en")
assert_equal "http://www.apple.com", Rails.application.routes.url_helpers.apple_url
assert_equal "/users", Rails.application.routes.url_helpers.polymorphic_path(User.new)
assert_raises NoMethodError do
assert_equal "http://www.microsoft.com", Rails.application.routes.url_helpers.microsoft_url