Using a non-default attribute in path helpers, instead of :id - ruby-on-rails

What I have
I now have a route like
get 'book/:id' => 'books#show', as: :book
The code to create that URL is:
book_path #book
In the controller:
#book = Book.find(params[:id])
So far so good. Now, this id is obviously the internal ActiveRecord or database primary key.
What I want
Instead, I would (in this ficticious example) use the ISBN instead. I could do this like so:
get 'book/:isbn' => 'books#show', as: :book
book_path(isbn: #book.isbn)
#book = Book.find_by(isbn: params[:isbn])
This is a bit verbose; I want the convention "isbn" for this model in the whole application, and nobody should need to care about it, ever. So, I want my 3 lines to look like this:
get 'book/:isbn' => 'books#show', as: :book
book_path #book # (!)
#book = Book.find(params[:isbn]) # (!)
Is it possible to configure the route so that my wish comes true? Obviously, this should automatically work everywhere, at the very least for GET requests where the user sees the ISBN in the URL.
In case it matters, the #isbn attribute is not a primary key in the usual sense, but it is guaranteed that there is only ever one record with a particular ISBN in the table. So at least this part of the Primary Key contract is fulfilled.

To override the Rails default :id parameter, you can use to_param method:
class Book < ActiveRecord::Base
def to_param # overridden
isbn
end
end
And in controller:
book = Book.find_by(isbn: '123456')
If you don't have any problem with using any gem for it, then I suggest you should give a look to friendly_id Gem. This does a good job in such case.

In your routes.rb, use match to match /book/:isbn to book#show
match '/book/:isbn' => 'book#show', via: :get, as: :show_book
In your book.rb, you can override the find method in order find based on isbn instead of id. Something along the lines of:
def self.find(*args)
c = Book.select("id").find_by(name: args[0])
args[0] = c.id
super(args)
end
This will first find by isbn and then using the id, it will call the original find method. (I have not test this, you will have to modify it to make it work with multiple isbns)
But I am not sure if this is recommended and what caveats it will present.
Instead, why don't you use find_by_isbn[params[:isbn]]? It is more readable than find_by(:isbn, params[:isbn]).

Related

What routes are necessary when the Model and Controller names do not match?

I have a Model called Category and another called Articles. Categories are "sections" that have many Articles, for instance News and Events. Both Categories use the kind of Articles, except they're shown under a different section of my website.
Right now I'm creating the News controller (NewsController), and I'd like to visit /news/new to add News. Likewise, the same would apply to EventsController and /events/new.
What do I have to use on my routes to do this?
My first attempt was to use:
resources :categories do
resources :articles, path: '/news'
end
But this forces me to use /categories/1/news/new, which is kinda ugly.
If News will always be category_id 1 and Events will always be 2, how would I specify this on my routes, so I can easily access them with the URLs I mentioned?
Explained Differently
I have an Articles model. I'd like to have a controller called NewsController to handle Articles, so that /news/new (and the rest of the paths) would work with Article. I'd also like to have a controller called EventsController that would also handle Articles, so that /events would also work with Article. The difference between them is that they have different category_id.
Is this possible to do via routes?
Update
Made some progress.
resources :categories do
resources :articles
end
get 'news/new' => 'articles#new', defaults: {category_id: 1}
get 'events/new' => 'articles#new', defaults: {category_id: 2}
This fixes what I wanted to do with /news/new and /events/new, but I'd be missing the rest of the routes (edit, show, update, etc). Also, this makes me use the Articles controller, which currently does not exist and would also make the News controller obsolete/useless.
My logic may be wrong, it's kinda evident with what I just made, but perhaps with this update I can better illustrate what I'm trying to do.
Update 2
I'm currently testing the following:
resources :articles, path: '/news', controller: 'news'
resources :articles, path: '/events', controller: 'events'
So far it makes sense, it makes the routes I wanted, it uses both controllers with their own configurations, and it hasn't spat any errors when I visit both /news and /events (yet).
It's also possible to do:
resources :articles, path: '/news', defaults: {category_id: 1}
resources :articles, path: '/events', defaults: {category_id: 2}
But this would depend on an Article controller, which could handle both types of Categories. Either solution works (theoretically), though I'd incline more on the first since the individual controllers would allow more specific configuration to both cases. The second, though, is more adequate when there're not that many difference between the Articles being created. The defaults property isn't explicitly necessary either, I just put it there for convenience.
Your question is asking something that I question as not making sense and maybe your design is flawed.
Why would you have news resources related to category resources if they are not related?
Is categories just a name space?
If news records really are always going to be related to the same first category as your question implies then you can not use ID's as you have no control over what the id will be for the first category and the first category could have an ID of anything in which case you could just use the top level news resources and do a find first category in your model in a before create then you don't have to worry about an ugly url.
If news records really are related to categories then the you must supply the relevant category id and nest your routes but you could pretty up the url using the following from
https://gist.github.com/jcasimir/1209730
Which states the following
Friendly URLs
By default, Rails applications build URLs based on the primary key --
the id column from the database. Imagine we have a Person model and
associated controller. We have a person record for Bob Martin that has
id number 6. The URL for his show page would be:
/people/6
But, for aesthetic or SEO purposes, we want Bob's name in the URL. The
last segment, the 6 here, is called the "slug". Let's look at a few
ways to implement better slugs. Simple Approach
The simplest approach is to override the to_param method in the Person
model. Whenever we call a route helper like this:
person_path(#person)
Rails will call to_param to convert the object to a slug for the URL.
If your model does not define this method then it will use the
implementation in ActiveRecord::Base which just returns the id.
For this method to succeed, it's critical that all links use the
ActiveRecord object rather than calling id. Don't ever do this:
person_path(#person.id) # Bad!
Instead, always pass the object:
person_path(#person)
Slug Generation
Instead, in the model, we can override to_param to include a
parameterized version of the person's name:
class Person < ActiveRecord::Base def to_param
[id, name.parameterize].join("-") end end
For our user Bob Martin with id number 6, this will generate a slug
6-bob_martin. The full URL would be:
/people/6-bob-martin
The parameterize method from ActiveSupport will deal with converting
any characters that aren't valid for a URL. Object Lookup
What do we need to change about our finders? Nothing! When we call
Person.find(x), the parameter x is converted to an integer to perform
the SQL lookup. Check out how to_i deals with strings which have a mix
of letters and numbers:
"1".to_i
=> 1
"1-with-words".to_i
=> 1
"1-2345".to_i
=> 1
"6-bob-martin".to_i
=> 6
The to_i method will stop interpreting the string as soon as it hits a
non-digit. Since our implementation of to_param always has the id at
the front followed by a hyphen, it will always do lookups based on
just the id and discard the rest of the slug. Benefits / Limitations
We've added content to the slug which will improve SEO and make our
URLs more readable.
One limitation is that the users cannot manipulate the URL in any
meaningful way. Knowing the url 6-bob-martin doesn't allow you to
guess the url 7-russ-olsen, you still need to know the ID.
And the numeric ID is still in the URL. If this is something you want
to obfuscate, then the simple scheme doesn't help. Using a Non-ID
Field
Sometimes you want to get away from the ID all together and use
another attribute in the database for lookup. Imagine we have a Tag
object that has a name column. The name would be something like ruby
or rails. Link Generation
Creating links can again override to_param:
class Tag < ActiveRecord::Base validates_uniqueness_of :name
def to_param
name end end
Now when we call tag_path(#tag) we'd get a URL like /tags/ruby. Object
Lookup
The lookup is harder, though. When a request comes in to /tags/ruby
the ruby will be stored in params[:id]. A typical controller will call
Tag.find(params[:id]), essentially Tag.find("ruby"), and it will fail.
Option 1: Query Name from Controller
Instead, we can modify the controller to
Tag.find_by_name(params[:id]). It will work, but it's bad
object-oriented design. We're breaking the encapsulation of the Tag
class.
The DRY Principle says that a piece of knowledge should have a single
representation in a system. In this implementation of tags, the idea
of "A tag can be found by its name" has now been represented in the
to_param of the model and the controller lookup. That's a maintenance
headache. Option 2: Custom Finder
In our model we could define a custom finder:
class Tag < ActiveRecord::Base validates_uniqueness_of :name
def to_param
name end
def self.find_by_param(input)
find_by_name(input) end end
Then in the controller call Tag.find_by_param(params[:id]). This layer
of abstraction means that only the model knows exactly how a Tag is
converted to and from a parameter. The encapsulation is restored.
But we have to remember to use Tag.find_by_param instead of Tag.find
everywhere. Especially if you're retrofitting the friendly ID onto an
existing system, this can be a significant effort. Option 3:
Overriding Find
Instead of implementing the custom finder, we could override the find
method:
class Tag < ActiveRecord::Base #... def self.find(input)
find_by_name(input) end end
It will work when you pass in a name slug, but will break when a
numeric ID is passed in. How could we handle both?
The first temptation is to do some type switching:
class Tag < ActiveRecord::Base #... def self.find(input)
if input.is_a?(Integer)
super
else
find_by_name(input)
end end end
That'll work, but checking type is very against the Ruby ethos.
Writing is_a? should always make you ask "Is there a better way?"
Yes, based on these facts:
Databases give the id of 1 to the first record
Ruby converts strings starting with a letter to 0
class Tag < ActiveRecord::Base #... def self.find(input)
if input.to_i != 0
super
else
find_by_name(input)
end end end
Or, condensed down with a ternary:
class Tag < ActiveRecord::Base #... def self.find(input)
input.to_i == 0 ? find_by_name(input) : super end end
Our goal is achieved, but we've introduced a possible bug: if a name
starts with a digit it will look like an ID. If it's acceptable to our
business domain, we can add a validation that names cannot start with
a digit:
class Tag < ActiveRecord::Base #... validates_format_of :name,
:without => /^\d/ def self.find(input)
input.to_i == 0 ? find_by_name(input) : super end end
Now everything should work great! Using the FriendlyID Gem
Does implementing two additional methods seem like a pain? Or, more
seriously, are you going to implement this kind of functionality in
multiple models of your application? Then it might be worth checking
out the FriendlyID gem: https://github.com/norman/friendly_id Setup
The gem is just about to hit a 4.0 version. As of this writing, you
want to use the beta. In your Gemfile:
gem "friendly_id", "~> 4.0.0.beta8"
Then run bundle from the command line. Simple Usage
The minimum configuration in your model is:
class Tag < ActiveRecord::Base extend FriendlyId friendly_id :name
end
This will allow you to use the name column or the id for lookups using
find, just like we did before. Dedicated Slug
But the library does a great job of maintaining a dedicated slug
column for you. If we were dealing with articles, for instance, we
don't want to generate the slug over and over. More importantly, we'll
want to store the slug in the database to be queried directly.
The library defaults to a String column named slug. If you have that
column, you can use the :slugged option to automatically generate and
store the slug:
class Tag < ActiveRecord::Base extend FriendlyId friendly_id
:name, :use => :slugged end
Usage
You can see it in action here:
t = Tag.create(:name => "Ruby on Rails")
=> #
Tag.find 16
=> #
Tag.find "ruby-on-rails"
=> #
t.to_param
=> "ruby-on-rails"
We can use .find with an ID or the slug transparently. When the object
is converted to a parameter for links, we'll get the slug with no ID
number. We get good encapsulation, easy usage, improved SEO and easy
to read URLs.
If you are sure there will be only 2 categories, why not simply add a boolean to the articles?
Like: article.event = true if events category, false if news
Then you can add a scopes to Article class for both categories
class Article
scope :events, -> { where(event: true) }
scope :news, -> { where(event: false) }
end
Create controllers, for example:
class EventsController < ApplicationController
def index
#articles = Article.events
end
def create
#article.new(params)
#article.event = true
#article.save
end
...
end
and routes: resources :events
You should try to use dynamic segments: http://guides.rubyonrails.org/routing.html#route-globbing-and-wildcard-segments
Add some slug attribute to Category, it should be unique and add index to it.
# routes
resources :articles, except: [:index, :new]
get '*category_slug/new', to: 'articles#new'
get '*category_slug', to: 'articles#index'
# controller
class ArticlesController < ApplicationController
def index
#category = Category.find_by slug: params[:category_slug]
#articles = #category.articles
end
def new
#category = Category.find_by slug: params[:category_slug]
#article = #category.articles.build
end
...
end
Remember to put a category in a hidden field in the form_for #article

Rails routing using custom attribute rather than table column

Using Rails 4.2, I want to create a custom route using an attr_accessor rather than a table column, but I can't get the resource_path method to work.
I want custom route like this: /foos/the-title-parameterized-1 (where "1" is the id of the object).
Foo model:
#...
attr_accessor :slug
#dynamically generate a slug:
def slug
"#{self.title.parameterize[0..200]}-#{self.id}"
end
#...
routes.rb:
get 'foos/:slug' => 'foos#show', :as => 'foo'
foos_controller.rb:
def show
#foo = Foo.find params[:slug].split("-").last.to_i
end
In my show view, when I use helper method foo_path it returns the route using the id of the object rather than the slug like this: /foos/1. Is it possible to get this helper method to use the accessor method? Am I off track with this approach?
I would prefer to use Friendly Id but I dont believe its possible without creating a slug column in my model table. I dont want to create a column because there are millions of records.
You want to override the to_param method in your model:
class Foo < ActiveRecord::Base
# ...
def to_param
slug
end
# or
alias :to_param :slug
end
You may also want to use the :constraints option in your route so that only URLs that match e.g. /-\d+\z/ are matched.
Keep in mind also that, using this method, the path /foos/i-am-not-this-foos-slug-123 will lead to the same record as /foos/i-am-this-foos-slug-123.

Use variable other than :id in rails 3 routes

I'm trying to get my rails 3 app to use a route that looks something like:
exampleapp.com/patients/123456
rather than
exampleapp.com/patients/1
where "123456" would be associated with the patient's medical record number (:mrn), which is already in the :patients table and is a unique integer. I want to use the :mrn in place of the usual :id. How would I go about this?
Sorry if this has already been asked - I couldn't find the terminology as to what I'm trying to do is called. Thanks!
You could do this,
class Patient < ActiveRecord::Base
self.primary_key = "mrn"
end
However, this will change a bunch of other things. The to_params will use mrn. The controller will still use params["id"], but the value will be the mrn field. The Patient.find method will work on the mrn field, but not the id field. (You can user Patient.find_by_mrn and Patient.find_by_id which will work on their specified fields.) Also, all foreign keys will be to the mrn value.
You can edit the mrn field, and you will still have an id field (unless you turn it off), however, editing could be a pain because all the foreign keys will have to be corrected.
Alternatively, if you just want to change the URL, then in your config/routes.rb file instead of
resources :patient
use
match "/patients/:mrn" => "patients#show"
match "/patients/:mrn" => "patients#update", :via => :put
You could just add this to your Patients model
def class Patient < ActiveRecord::Base
self.primary_key = "mrn"
end
You can get per-resource identifier customization by redefining the member_scope and nested_scope methods on the Resource instance.
resources :patients do
#scope[:scope_level_resource].tap do |u|
def u.member_scope
"#{path}/:mrn"
end
def u.nested_scope
"#{path}/:#{singular}_mrn"
end
end
end

Rails - FriendlyId and Conditions

I have a model posts, which belongs_to category (which uses friendly_id). Now i want to list all Posts in an Category. To get the index page i want to use a link like: http://mysite/posts/category/_category_slug_, for that i made the following route:
match 'posts/category/:category/' => 'posts#index'
And in my post controller i got:
def index
if params[:category]
#posts = Post.all(:joins => :category, :conditions => {"categories.cached_slug" => params[:category]})
else
#posts = Post.all.reverse
end
...
It works like it should, but i dont think its the friedndly_id way to do it.
Is there a better way to achive this? thanks for your help.
FriendlyId adds the ability to do a find on a model using the slug, and is smart enough to check the cached_slug column first.
You can achieve the same result by performing a find on the Category model first then getting all the posts.
This assumes there is a has_many and belongs_to association in place with the referencing ID columns (or HABTM)
def index
if params[:category]
#posts = Category.find(params[:category]).posts
else
#posts = Post.all.reverse
end
...
Since you're passing in a category param (being friendly_id), it makes sense to reference it via the Category model.
-- more info added --
ALSO: Historical finds will still work ..
So, if you have generated a new slug by renaming a category, the old url will behave correctly (great if you're avoiding 404's)

If I want to override the resource path (e.g. post_path(#post)), where do I do that?

UPDATE (again) | Ignore the module thing-o I put up here before. Better off just using to_param and making the routes work.
to_param
if slug
"#{id}-#{slug}"
else
super
end
end
match "/posts/:id(-:slug)" => "posts#show", :as => :post, :constraints => { :id => /\d+|\d+-.*/ }
The trick is in that route. It makes -:slug optional, and adds a constraint to :id. The constraint's preference is to match just a numeric value (which is what gets used when a request comes in, so you have :id and :slug in your params), but the pipe allows it to also match a numeric value followed by the slug, for the purpose of route generation. It's a mild hack, but this whole thing is really. Allowing resource to provide a hash of params would solve this. This could be applied to any fancy route you want (e.g. having the :id at the end instead of the start).
Rails 3.0.7.
Take a route like:
match "/posts/:id-:slug" => "posts#show", :as => :post
This provides a post_path(obj) helper for me, but of course, it only wants to generate a single argument from my model, and from what I can tell, there's no way to return multiple values from my models' to_param method. I know I can write to_param like this:
to_param
"#{id}-#{slug}"
end
But this is passed as a single argument to the route, which ultimately doesn't match any routes. Similarly, I know I can remove the "-:slug" from my route, but then the :id parameter contains bogus input and is basically a bit of a hack (though apparently the done thing).
It'd be awesome if a future version of Rails let you return a hash from to_param, like:
to_param
{ :id => id, :slug => slug }
end
then used those when locating the correct route to use.
What I'm trying to figure out, is where I would go about overriding post_path() to provide the correct route. I can put it in a view helper, but then it's not available in my controllers, so I'm guessing this is the wrong place to do it.
Just curious more than anything. I know it works fine if I just omit the :slug on the route, but in a more complex situation I can imagine overriding the default paths to be a useful thing to know. Being able to generate a route just by passing a resource is a pretty nice feature I'd like to adopts, but the marketing dept will always have us filling the URLs will all kinds of keywords mumbo jumbo, so some control would be excellent :)
There are two sides to routing: route generation and route matching.
The route generation, as you point out, is quite easy. Override to_param in any ActiveModel:
# In a model:
def to_param
# Use another column instead of the primary key 'id':
awesome_identifier.to_s
end
The route matching is less obvious. It is possible to specify :awesome_identifier in place of every occurrence of :id in the default resource routes. However, I have found that Rails will give you less resistance if you leave :id in your routes, and only change the logic in your controller. Note that this is a trade-off, because fundamentally the :id in your routes is not really correct.
# In a controller:
def index
#awesome_record = AwesomeModel.find_by_awesome_identifier(params[:id])
# ...
end
As you have noted, Rails has optimised a single use case: if you want the primary key to be used for quick record look-up, but still prefer a slug to be included in the URL for user-friendliness or search engine optimisation. In that case you can return a string of the form "#{id}-#{whatever}" from the to_param method, and everything after the dash will be ignored when feeding the same string back into the find method.

Resources