Skip to content
This repository has been archived by the owner on Nov 11, 2017. It is now read-only.

Commit

Permalink
initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
Chad Pytel committed Apr 13, 2009
0 parents commit 38ff649
Show file tree
Hide file tree
Showing 15 changed files with 423 additions and 0 deletions.
64 changes: 64 additions & 0 deletions README
@@ -0,0 +1,64 @@
=== Philosophy

It's nice to have human-readable identifiers in your URLs, but can be a bit of a pain to get just right. Overriding to_param to spit out some string after the id is a great technique in a pinch, but I felt like it was high time to come up with something a bit more comprehensive.

Friendly Identifier uses a source column (such as a name or title) to generate a more compact "friendly identifier". It overrides to_param to use that identifier, and also overrides ActiveRecord::Base#find to search using that identifier.

Ultimately, it's designed to boil down to a single line in your model that Just Works(tm).

=== Usage

friendly_identifier(source_column, options)

class Foo < ActiveRecord::Base
friendly_identifier :name
end

class Bar < ActiveRecord::Base
friendly_identifier :title, :scope => :category_id
end

class Baz < ActiveRecord::Base
friendly_identifier :title, :identifier_column => :url_slug
end


=== Options

* :keep_updated - Change the identifier whenever the field it is based on is changed. Defaults to true, but set to false if you need your identifiers to be customizable or URLs to remain unchanged after creation.

* :scope - Passed on to validates_uniqueness_of :friendly_identifier.

* :identifier_column - Pass in the name of an existing column you're already using and would like to reuse for the same sort functionality.

* Formatting callback: You can override "self.format(str)" in your class to provide your own identifier-formatting method.


=== Requirements

Your models simply need a string column named "friendly_identifier".


==== Caveats

Beware these possible side effects:

* In many cases, you don't want your URLs to change if you rename the name or title of your object. Use the :keep_updated => false option to handle this, and let the UI handle changing/updating your

* Can be a bit unpredictable with really complex associations (let me know if you have any trouble)

* Does validate presence, which effectively requires that your source column be present, so you might as well add a check for that.


==== Coming Soon(ish)

* Generator to create a migration for your models


==== Feedback Welcome!

Feel free to get in touch via email if you have problems, suggestions for improvement, or even just want to show me a site that you used this plugin on.

Nick Zadrozny<br/>
nick@zadrozny.com (email/jabber/gtalk)<br/>
http://beyondthepath.com/
22 changes: 22 additions & 0 deletions Rakefile
@@ -0,0 +1,22 @@
require 'rake'
require 'rake/testtask'
require 'rake/rdoctask'

desc 'Default: run unit tests.'
task :default => :test

desc 'Test the friendly_identifier plugin.'
Rake::TestTask.new(:test) do |t|
t.libs << 'lib'
t.pattern = 'test/**/*_test.rb'
t.verbose = true
end

desc 'Generate documentation for the friendly_identifier plugin.'
Rake::RDocTask.new(:rdoc) do |rdoc|
rdoc.rdoc_dir = 'rdoc'
rdoc.title = 'FriendlyIdentifier'
rdoc.options << '--line-numbers' << '--inline-source'
rdoc.rdoc_files.include('README')
rdoc.rdoc_files.include('lib/**/*.rb')
end
5 changes: 5 additions & 0 deletions init.rb
@@ -0,0 +1,5 @@
require 'friendly_identifier'

ActiveRecord::Base.class_eval do
include BeyondThePath::Plugins::FriendlyIdentifier
end
47 changes: 47 additions & 0 deletions install.rb
@@ -0,0 +1,47 @@
puts <<EOT
friendly_identifier
A Rails ActiveRecord plugin that lets you make human readable URLs using
"friendly" identifiers.
Usage:
friendly_identifier(source_column, options)
Examples:
class Person < ActiveRecord::Base
friendly_identifier :name
end
class LegacyWidget < ActiveRecord::Base
friendly_identifier :name, :scope => :category_id, :identifier_column => :url_slug
# you can override the format_identifier callback
def self.format_identifier(str)
s.downcase.gsub(/'/,'').gsub(/\W/,' ').strip.gsub(/ +/, '_')
end
end
Options:
* :keep_updated - Change the identifier whenever the field it is based on is
changed. Defaults to true, but set to false if you need your identifiers
to be customizable or URLs to remain unchanged after creation.
* :scope - Passed on to validates_uniqueness_of :friendly_identifier.
* :identifier_column - Pass in the name of an existing column you already
have defined and would like to reuse for the same sort functionality.
* You can override the format_identifier class method to match your own
preferred filtering style. Say, by using underscores instead of dashes.
Requirements:
* Your models simply need a string column named "friendly_identifier".
Feedback welcome. See the README for commentary, caveats, and contact info.
EOT
84 changes: 84 additions & 0 deletions lib/friendly_identifier.rb
@@ -0,0 +1,84 @@
require 'active_record'

module BeyondThePath
module Plugins
module FriendlyIdentifier #:nodoc:

mattr_accessor :identifier_options

def self.included(mod)
mod.extend(ClassMethods)
end

module ClassMethods
def friendly_identifier(source, options = {})

# Merge with default options
class_inheritable_accessor :identifier_options
self.identifier_options = {
:keep_updated => true,
:identifier_column => :friendly_identifier
}.merge(options)

# Include/override class methods and object instance methods
include BeyondThePath::Plugins::FriendlyIdentifier::InstanceMethods
class_eval do
extend BeyondThePath::Plugins::FriendlyIdentifier::SingletonMethods
end

# Identifier must be present, otherwise what's the point?
validates_presence_of identifier_options[:identifier_column]

# Identifiers should be unique in their given scope
validates_uniqueness_of identifier_options[:identifier_column],
:scope => (identifier_options[:scope])

# Update the identifier, #set_identifier! figures out when
before_validation { |record| record.set_identifier!(source) }

end
end

# Adds class methods.
module SingletonMethods

def find(*args)
if args.first.is_a? String
super(:first, :conditions => ["#{identifier_options[:identifier_column]} = ?", args.first])
else
super
end
end

def format_identifier(s)
s.gsub!(/'/,'') # remove characters that occur mid-word
s.gsub!(/[\W]/,' ') # translate non-words into spaces
s.strip! # remove spaces from the ends
s.gsub!(/\ +/,'-') # replace spaces with hyphens
s.downcase # lowercase what's left
end

end

# Adds instance methods.
module InstanceMethods

def set_identifier!(source)
source_column = source.to_s
identifier_column = identifier_options[:identifier_column].to_s
if identifier_options[:keep_updated] or self[identifier_column].blank?
if self[source_column]
self[identifier_column] = self.class.format_identifier(self[source_column].to_s.dup)
end
end
end

def to_param
self[identifier_options[:identifier_column]]
end

end

end
end
end
4 changes: 4 additions & 0 deletions tasks/friendly_identifier_tasks.rake
@@ -0,0 +1,4 @@
# desc "Explaining what the task does"
# task :friendly_identifier do
# # Task goes here
# end
4 changes: 4 additions & 0 deletions test/database.yml
@@ -0,0 +1,4 @@
plugin_test:
adapter: sqlite3
database: ":memory:"
verbosity: silent
1 change: 1 addition & 0 deletions test/debug.log
@@ -0,0 +1 @@
# Logfile created on Thu Mar 01 18:59:01 PST 2007 by logger.rb/1.5.2.7
5 changes: 5 additions & 0 deletions test/fixtures/category.rb
@@ -0,0 +1,5 @@
class Category < ActiveRecord::Base
friendly_identifier :name, :keep_updated => false
has_many :widgets
has_many :gadgets
end
11 changes: 11 additions & 0 deletions test/fixtures/gadget.rb
@@ -0,0 +1,11 @@
class Gadget < ActiveRecord::Base
belongs_to :category
friendly_identifier :name, :scope => :category_id, :identifier_column => :url_slug

validates_presence_of :required_stuff
after_save :something_with_required_stuff
def something_with_required_stuff
raise "We need required stuff to be present" if required_stuff.nil?
end

end
4 changes: 4 additions & 0 deletions test/fixtures/widget.rb
@@ -0,0 +1,4 @@
class Widget < ActiveRecord::Base
belongs_to :category
friendly_identifier :name, :scope => :category_id
end
129 changes: 129 additions & 0 deletions test/friendly_identifier_test.rb
@@ -0,0 +1,129 @@
require File.join(File.dirname(__FILE__), 'test_helper')
require File.join(File.dirname(__FILE__), 'fixtures/widget')
require File.join(File.dirname(__FILE__), 'fixtures/category')
require File.join(File.dirname(__FILE__), 'fixtures/gadget')

class FriendlyIdentifierTest < Test::Unit::TestCase

def test_create_valid_default_objects
assert_valid create_widget
assert_valid create_gadget
assert_valid create_category
end

def test_should_be_set_on_create
widget = create_widget
assert_not_nil widget.friendly_identifier, "Friendly identifier column not set on create"
assert_equal widget.friendly_identifier, widget.to_param, "Friendly identifier should be the value of to_param"
end

def test_must_be_unique_in_same_scope
t = "Same Name, Same Category"
widget1 = create_widget(:name => t)
widget2 = create_widget(:name => t)
assert !widget2.valid?, "Friendly identifiers must be unique in the same scope"
end

def test_should_allow_duplicates_in_different_scopes
t = "Same Name, Different Category"
widget1 = create_widget(:name => t, :category_id => 1)
widget2 = create_widget(:name => t, :category_id => 2)
assert widget2.valid?, "Duplicate friendly identifiers should be allowed in different scopes"
end

def test_should_update_on_change
widget = create_widget
widget.update_attributes(:name => "Bar")
assert_equal "bar", widget.friendly_identifier, "Friendly identifier did not get updated"
end

def test_optionally_should_not_update_on_change
category = create_category
previous_identifier = category.friendly_identifier
category.update_attributes(:name => "Category B")
assert_equal previous_identifier, category.friendly_identifier, "Friendly identifier was changed even though it shouldn't have been"
end

def test_explicitly_given_value_should_take_precedence_on_create
# Actually we're going to leave this undefined for now...
# This is only really clear to me for updates where :keep_updated => true
end

def test_explicitly_given_value_should_take_precedence_on_create
# Left undefined for now, as per the above
end

def test_should_allow_user_configurable_identifier_column
gadget = create_gadget
assert_not_nil gadget.url_slug, "Friendly identifier column should be user configurable"
assert_equal gadget.url_slug, gadget.to_param, "Friendly identifier is not returning the value stored in a custom identifier column"
assert_not_nil (g = Gadget.find(gadget.to_param)), "Can't find an object with a different identifier column"
assert_equal gadget.name, g.name, "Gadget we found is not the same as the one we want"
rescue ActiveRecord::StatementInvalid
flunk "Can't find an object with a different identifier column"
end

def test_should_not_mess_with_validations_and_after_save
gadget = create_gadget :required_stuff => nil
assert !gadget.valid?
rescue
flunk "We're forcing a save when we shouldn't be"
end

def test_should_perform_strict_redirects
# Maintain a history of identifiers and perform a 301 redirect for old ones
# I think this might require a separate table, or at least an extra column
end

def test_should_format_nicely
w = create_widget :name => "Nick's test du-jour!"
assert_equal "nicks-test-du-jour", w.to_param
w = create_widget :name => 'a!@#$b%^&*()c'
assert_equal 'a-b-c', w.to_param
end

def test_should_handle_weird_strings_nicely
w = create_widget :name => "ice".freeze rescue flunk
w = create_widget :name => :test_symbols rescue flunk "Exception when passing a symbol"
end

def test_should_not_ever_modify_source_column
name = "Can't touch this"
w = create_widget :name => name
assert_equal name, w.name
w.update_attributes(:name => name)
assert_equal name, w.name
end

def test_should_play_nice_with_chumby
w = create_widget :name => 'Chumby Analog Clock (white)'
assert_equal 'chumby-analog-clock-white', w.to_param
end

private

def create_widget(options={})
Widget.create({
:name => "Fooriffic Widget",
:category_id => 1
}.merge(options))
end

def create_category(options={})
Category.create({
:name => "Categlorious Assemblage"
}.merge(options))
end

def create_gadget(options={})
Gadget.create({
:name => "Gadgetacular Gadget",
:required_stuff => "required"
}.merge(options))
end

def assert_valid(obj)
assert obj.valid?, "#{obj.class.to_s} is not valid: #{obj.errors.full_messages.join(', ')}"
end

end

0 comments on commit 38ff649

Please sign in to comment.