Rails polymorphic url generation sucks. Here's something better. 3

Posted by james
on Wednesday, October 10
If you've ever written a rails app with a lot of polymorphic resources, you've probably noticed that generating urls for your polymorphic routes is a real pain. In edge rails, some of the problems have been solved by the new polymorphic url methods. Unfortunately, those solutions are both inadequate, and "not recommended for use by end users" (see DHH's comment at the bottom). The problem is that if you want to use the same views and controller code without a lot of ugly conditionals to generate your urls, you're out of luck.

The Problem

## routes.rb

map.resources :photos
map.resources :users, :has_many => :photos
map.resources :tags, :has_many => :photos

## photos_controller.rb

class PhotosController
  ## in the create method
  wants.html { redirect_to url_for(url_helper_params) } # I know DHH doesn't want us to use this, but how else can I get this effect?

  ## in the destroy method
  wants.html { redirect_to photos_url } # unfortunately, this will drop any parent paths we may have
                                        # a user who called DELETE /users/james/photos/1 ends up at /photos instead of /users/james/photos)

  private
    # Note: I actually use a modification to make_resourceful to do this far more cleanly, but wanted to be as complete as possible in this example.
    def parent_object
      case
        when params[:user_id] then User.find_by_id(params[:article_id])
        when params[:tag_id] then Tag.find_by_id(params[:news_id])
      end    
    end
  
    def url_helper_params
      [parent_object, @photo].reject { |obj| obj.nil? } # we have to get rid of any the parent_object if it's nil, or else this will fail :(
    end
end

# index.rhtml

<%= link_to "New Photo", new_photo_path %> # same problem as our destroy redirect
We have routes like /users/james/photos, and /tags/montrealonrails/photos. We want to seamlessly maintain the top level of the path without resorting to conditionals or repetition. That is, if you browse to /users/james/photos, and click on the show link, you should go to /users/james/photos/1, not /photos/1. There should also be a polymorphic plural url helper. We have url_for for members (/photos/1), but for collections (/photos), we're stuck resorting to calling the url helpers themselves. So, to create a collection url, we'd have to check what the parent object was, and then call the appropriate helper based on that. And, all of this is further complicated when there are namespaces (/cms/products). Wouldn't it be great if there was a better way?

Stupid Simple Urls

Try saying that three times fast. Done? Good, because it's not called that - I was just messing with you.

URLigence

Intelligent url building.
## routes.rb

map.resources :photos
map.resources :users, :has_many => :photos
map.resources :tags, :has_many => :photos

map.namespace :admin do |admin|
  admin.resources :products, :has_many => :options
  admin.resources :options
end

##

assert_equal "/photos", smart_url(:photos)
assert_equal "/photos", smart_url(nil, nil, nil, :photos) # in case your parent objects aren't there
assert_equal "/users/james/photos", smart_url(User.find_by_name(:james), :photos)
assert_equal "/users/james/photos/1", smart_url(User.find_by_name(:james), Photo.find_by_id(1))
assert_equal "/photos/1", smart_url(nil, Photo.find_by_id(1)) # in the case that your parent object isn't there
assert_equal "/photos/1/edit", smart_url(:edit, Photo.find_by_id(1))

assert_equal "/admin/products", smart_url(:admin, :products)
assert_equal "/admin/products/1", smart_url(:admin, Product.find_by_id(1))
assert_equal "/admin/products/1/options", smart_url(:admin, Product.find_by_id(1), :options)
assert_equal "/admin/products/1/edit", smart_url(:edit, :admin, Product.find_by_id(1))
assert_equal "/admin/options", smart_url(:admin, nil, :options) # in the case that your parent object isn't there
It's pretty straightforward. Just call smart_url with any parameters that might be there, including ones that might be nil, and symbols at the beginning for namespaces (simple_url/cms/products), member actions (/products/1/edit), or both (/cms/products/1/edit), objects in the middle, and a symbol at the end for a resource's index method (/products). That's it.

Get It

Get version 0.1 at http://svn.jamesgolick.com/urligence/tags/stable. Oh, and don't take the name of the tag too seriously - I just wrote it tonight.

svn export http://svn.jamesgolick.com/urligence/tags/stable vendor/plugins/urligence

Let me know what you think of it.

Comments

Leave a response

  1. BoskoOctober 12, 2007 @ 01:07 AM

    My friend Mina put together something similar for our purposes, only he also has some controller magic so that you can easily refer to a polymorphic resource's parent from the polymorphic resource's controller.

    I've pointed him at your post so hopefully he'll comment with details.

  2. Mina NaguibOctober 16, 2007 @ 10:42 AM

    Hi, sorry for the late reply.

    While what I've done isn't as pluginized, I see that we've both re-invented the same wheel albeit sith slightly different conventions.

    Basically I stuck with the notion of "parent" with these helpers in ApplicationController:

    <table class="CodeRay"><tr> <td title="click to toggle" class="line_numbers" onclick="with (this.firstChild.style) { display = (display == '') ? 'none' : '' }">
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    
    </td> <td class="code">
      #
      # Returns a hash of parent => id pairs extracted from the nested URL params
      #
      def parents_hash
    
        # Caching
        if @parents_hash.nil?
          @parents_hash = {}
          request.path_parameters.each do |key, value|
            next unless key =~ /^(.+)_id$/
            @parents_hash[$1] = value.to_i
          end
        end
        
        return @parents_hash
        
      end
    
      #
      # Similar to parents_hash but returns an array of hashes (first element is oldest parent, then later.. etc)
      # Each array element is a hash containing :name, :id
      #
      def parents_array
    
        # Caching
        if @parents_array.nil? 
          hash = parents_hash
          @parents_array = []
          path_parts = request.path.split("/")
          path_parts.each do |part|
            next unless part =~ /^[a-z_]+$/
            singular = part.singularize or next
            next unless hash.has_key?(singular)
            @parents_array << {
              :name =>  singular,
              :id =>  hash[singular]
            }
          end
        end
    
        return @parents_array
    
      end
    
      #
      # Returns a parent object extracted from a nested URL
      # Takes an array index of depth (how far up parents to get) - defaults to -1 (most immediate)
      # Returns nil if no such parent exists
      def parent(level = -1)
    
        #Caching
        @parent ||= {}
        unless @parent.has_key?(level)
          if hash = parents_array[level]
            @parent[level] = hash[:name].classify.constantize.find(hash[:id])
          else
            @parent[level] = nil
          end
        end
    
        return @parent[level]
    
      end
    
      #
      # Returns an array of parent objects
      #
      def parents
    
        # Caching
        if @parents.nil?
          @parents = []
          parents_array.each_index do |i|
            @parents << parent(i)
          end
        end
    
        return @parents
    
      end
    </td> </tr></table>

    The bulk of the code above is to provide support for the parent(x) function which returns an ActiveRecord instance of parent X - x is optional and defaults to -1 which means for a url like this: http://domain.com/categories/20/posts/19 parent will return the AR instance of Category id 20

    That in itself is awesomely helpful since in PostsController for example you can do:

    <table class="CodeRay"><tr> <td title="click to toggle" class="line_numbers" onclick="with (this.firstChild.style) { display = (display == '') ? 'none' : '' }">
    1
    2
    
    </td> <td class="code">
    def index
       (parent ? parent.posts : Post).find(:all)
    </td> </tr></table>

    Now on to the URL helpers I ended up creating a family of poly* URL generators (polypath/polyurl/polyppath/polypurl).. The ones with _p in their name automatically prepend parents defined above so you can generate a URL that automatically includes "your parents":

    <table class="CodeRay"><tr> <td title="click to toggle" class="line_numbers" onclick="with (this.firstChild.style) { display = (display == '') ? 'none' : '' }">
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    
    </td> <td class="code">
      #
      # The magic method to construct polymorphic urls
      # Takes any combination of:
      #   * Instances of AR: Adds /model_name_plural/instance_id
      #   * Symbols: Adds ;symbol_name
      #   * Strings: Adds /string
      #   * Arrays: Flattens and follows above rules
      #   * Hashes: Turns into query params
      #   * Anything else: Raises an error
      #
      def poly_path(*args)
    
        path = ""
        qs = {}
    
        args.flatten.each do |a|
          next if a.blank?
          if a.kind_of? ActiveRecord::Base
            path += "/" + a.class.to_s.pluralize.underscore + "/" + a.to_param
          elsif a.is_a? Symbol
            path += ";" + a.to_s
          elsif a.is_a? String
            path += "/" + a
          elsif a.is_a? Hash
            qs = qs.merge(a)
          else
            raise "Don't know how to handle #{a.class}"
          end
        end
    
        path = "/" + path unless path =~ /^\//
        path = path + qs.to_query_string unless qs.blank?
    
        return path
      end
    
      #
      # Same as poly_path but returns a URL instead of just a path
      #
      def poly_url(*args)
         request.protocol + request.host + request.port_string + poly_path(*args)
      end
    
      #
      # A shortcut to poly_path(parents, ...)
      #
      def poly_p_path(*args)
        poly_path(parents, args)
      end
    
      #
      # A shortcut to poly_url(parents, ...)
      #
      def poly_p_url(*args)
        poly_url(parents, args)
      end
    </td> </tr></table>

    When I have some time I'll check out your plugin and would happily post back delta features I find in common.

    finally, I hope the blog keeps all the formatting above.. there's no preview comment.. here it goes.. :)

  3. matteOctober 30, 2007 @ 02:31 PM

    Have you looked at the ResourceFu plugin? I'm using it for polymorphic attendees to both webinars and training courses. I haven't found any documentation outside the readme but it works really well once you understand it.

Comment






Clicky Web Analytics