This repository has been archived by the owner on Nov 11, 2017. It is now read-only.
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Chad Pytel
committed
Apr 13, 2009
0 parents
commit 38ff649
Showing
15 changed files
with
423 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Original file line | Diff line number | Diff line change |
---|---|---|---|
@@ -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/ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Original file line | Diff line number | Diff line change |
---|---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Original file line | Diff line number | Diff line change |
---|---|---|---|
@@ -0,0 +1,5 @@ | |||
require 'friendly_identifier' | |||
|
|||
ActiveRecord::Base.class_eval do | |||
include BeyondThePath::Plugins::FriendlyIdentifier | |||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Original file line | Diff line number | Diff line change |
---|---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Original file line | Diff line number | Diff line change |
---|---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Original file line | Diff line number | Diff line change |
---|---|---|---|
@@ -0,0 +1,4 @@ | |||
# desc "Explaining what the task does" | |||
# task :friendly_identifier do | |||
# # Task goes here | |||
# end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Original file line | Diff line number | Diff line change |
---|---|---|---|
@@ -0,0 +1,4 @@ | |||
plugin_test: | |||
adapter: sqlite3 | |||
database: ":memory:" | |||
verbosity: silent |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Original file line | Diff line number | Diff line change |
---|---|---|---|
@@ -0,0 +1 @@ | |||
# Logfile created on Thu Mar 01 18:59:01 PST 2007 by logger.rb/1.5.2.7 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Original file line | Diff line number | Diff line change |
---|---|---|---|
@@ -0,0 +1,5 @@ | |||
class Category < ActiveRecord::Base | |||
friendly_identifier :name, :keep_updated => false | |||
has_many :widgets | |||
has_many :gadgets | |||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Original file line | Diff line number | Diff line change |
---|---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Original file line | Diff line number | Diff line change |
---|---|---|---|
@@ -0,0 +1,4 @@ | |||
class Widget < ActiveRecord::Base | |||
belongs_to :category | |||
friendly_identifier :name, :scope => :category_id | |||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Original file line | Diff line number | Diff line change |
---|---|---|---|
@@ -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 |
Oops, something went wrong.