Caius Theory

Now with even more cowbell…

#to_param and keyword slugs

Imagine you've got a blogging app and it's currently generating URL paths like posts/10 for individual posts. You decide the path should contain the post title (in some form) to make your URLs friendlier when someone reads them. I know I certainly prefer to read http://caiustheory.com/abusing-ruby-19-and-json-for-fun vs http://caiustheory.com/?id=70. (That's a fun blog post if you're into (ab)using ruby occasionally!)

Now you know all about how to change the URL path that rails generates—just define to_param in your app. Something simple that generates a slug consisting of hyphens and lowercase alphanumerical characters. For example:

# 70-abusing-ruby-1-9-json-for-fun
def to_param
  "#{id}-#{title.gsub(/\W/, "-").squeeze("-")}".downcase
end

NB: You might want to go the route of storing the slug against the post record in the database and thus generating it before saving the record. In which case the rest of this post is sort of moot and you just need to search on that column. If not, then read on!

Now we're generating a nice human-readable URL we need to change the way we find the post in the controller's show action. Up until now it's been a simple @post = Post.find(params[:id]) to grab the record out the database. Problem now is params[:id] is "70-abusing-ruby-1-9-json-for-fun", rather than just "70". A quick check in the String#to_i docs reveals it "Returns the result of interpreting leading characters in str as an integer base base (between 2 and 36)." Basically it extracts the first number it comes across and returns it.

Knowing that we can just lean on it to extract the id before using find to look for the post: @post = Post.find(params[:id].to_i). Fantastic! We've got nice human readable paths on our blog posts and they can be found in the database. All finished… or are we?

There's still a rather embarassing bug in our code where we're not explicitly checking the slug in the URL against the slug of the Post we've extracted from the database. If we visited /posts/70-ruby-19-sucks-and-python-rules-4eva it would load the blog post and render it without batting an eyelid. This has caused rather a few embarrassing situations for some high profile media outlets who don't (or didn't) check their URLs and just output the content. Luckily there's a simple way for us to check this.

All we want to do is render the content if the id param matches the slug of the post exactly, and return a 404 page if it doesn't. We already know the id param (params[:id]) and have pulled the Post object out of the database and stored it in an instance variable (@post). The @post knows how to generate it's own slug, using #to_param.

So we end up with something like the following in our posts controller, which does all the above and correctly returns a 404 if someone enters an invalid slug (even if it starts with a valid post id):

def show
  @post = Post.find(params[:id].to_i)
  render_404 && return unless params[:id] == @post.to_param
end

def render_404
  render :file => Rails.root + "public/404.html", :status => :not_found
end

And going to an invalid path like /posts/70-ruby-19-sucks-and-python-rules-4eva just renders the default rails 404 page with a 404 HTTP status. (If you want the id to appear at the end of the path, alter to_param accordingly and do something like params[:id].match(/\d+$/) to extract the Post's id to search on.)

Hey presto, we've implemented human readable slugs that are tamper-proof (without storing them in the database.)

(And bonus points if in fact you spotted I used my blog as an example, but that it isn't a rails app. (Nor contains the blog post ID in the pretty URL.) It's actually powered by Habari at the time of posting!

Refactoring code logically

And now an example of how I write my ruby code and get it down to the bare, readable, minimum code needed. This is real life code taken from a website I'm building, but I've changed the objects to a blog post because more people will relate to that easier.

The show object has an id passed in using the params Hash, I want to check if that post exists in the database first. If it does, then render the page, and if it doesn't return a 404 error page.

So I start off by writing this in longhand ruby, I'm using the merb framework with DataMapper ORM by the way.

def show
  @post = Post.first(params[:id])
  if @post
    render
  else
    raise "404 - Not found"
  end
end

Now whilst theres nothing wrong with this code, it just doesn't look right to me. There is a big if/else statement in there whilst I'm sure there doesn't need to be.

Now I know if I return at any point in a ruby method, it exits the method at that point. So the first thing to is to refactor the if test to remove a line of code. I shall assign @post to the result of the DB as the actual if statement's test.

def show
  if (@post = Post.first(params[:id]))
    render
  else
    raise "404 - Not found"
  end
end

So thats reading slightly better, and also is a line less of code. Now I wonder if I can use a return true in there to stop me having to explicitly state an else clause.

def show
  if (@post = post.first(params[:id]))
    render
    return true
  end
  raise "404 - Not found"
end

Now the eagerest amongst you will be wondering what the advantage of that code is. It doesn't appear any more readable (slightly less in fact as you have to figure out its an implicit else) and is exactly the same amount of lines as the previous example. But what if we change the if to an if ! and flip the code logic around?

def show
  if ! (@post = Post.first(params[:id]))
    raise "404 - not found"
  end
  render
end

Now a raise will stop the code executing, and in the real application you would in fact just redirect to your 404 error page. The problem now is the if ! looks ugly and isn't easily readable.

All unless does is if !, that is, if the inverse of the result of the test statement is true, then invoke the block given to it. A quick example for you:

# without unless
if !@user.logged_in?
  puts "Please login."
end
    
# using unless
unless @user.logged_in?
  puts "Please login."
end

Now whilst if ! doesn't seem that bad compared to unless, the readablility of the code increases. It reads more as a flow of logic, and is quicker for the human brain to walk through (my brain anyway!)

So using unless we get 4 lines of code that is easily readable.

def show
  unless (@post = Post.first(params[:id]))
    raise "404 - Not found"
  end
  render
end

Now what if we go one step further and use the unless shorthand way of testing and exectuting one line of code?

def show
  raise "404 - Not found" unless (@post = Post.first(params[:id]))
  render
end

And that is generally how I write my code logically. Of course for something simple like this I'd probably jump in at the last block having refactored it in my head first, but for more complex things I tend to write them exlicitly and then refactor them down whilst maintaining readability of my code.

Use datamapper sessions with merb & datamapper

Issue

Can't use merb sessions with datamapper & mysql, get back an error about needing an id on the text column or something (I had the error a couple of days ago.)

Solution

I suggest grabbing merb_datamapper svn source to fix this in. To do so make sure you have subversion installed on your machine (I'm assuming a Unix based machine here.)

  1. Checkout the source

     svn co http://svn.devjavu.com/merb/plugins/merb_datamapper
    
  2. Open up the affected file in your favourite editor (I use TextMate)

     cd merb_datamapper
     mate lib/merb/sessions/data_mapper_session.rb
    
  3. Find line 25 that contains

     `property :session_id, :text, :lazy => false, :key => true`
    

    and remove :text, :lazy => false to replace it with :string

     `property :session_id, :string, :key => true`
    

    Save and close the file, thats the editing done. Now to install the gem.

  4. Build the gem

     rake gem
    
  5. Install the gem

     sudo gem install pkg/merb_datamapper-0.5.gem
    

And you're away with the fix installed. Now just run merb to create your sessions table in the db. Hope this helped!