Skip to content
Browse files

initial commit

  • Loading branch information...
0 parents commit 38ff649e15e0d594d825d8176be83e2ada0d9596 Chad Pytel committed
64 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 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 init.rb
@@ -0,0 +1,5 @@
+require 'friendly_identifier'
+
+ActiveRecord::Base.class_eval do
+ include BeyondThePath::Plugins::FriendlyIdentifier
+end
47 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 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 tasks/friendly_identifier_tasks.rake
@@ -0,0 +1,4 @@
+# desc "Explaining what the task does"
+# task :friendly_identifier do
+# # Task goes here
+# end
4 test/database.yml
@@ -0,0 +1,4 @@
+plugin_test:
+ adapter: sqlite3
+ database: ":memory:"
+ verbosity: silent
1 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 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 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 test/fixtures/widget.rb
@@ -0,0 +1,4 @@
+class Widget < ActiveRecord::Base
+ belongs_to :category
+ friendly_identifier :name, :scope => :category_id
+end
129 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
17 test/schema.rb
@@ -0,0 +1,17 @@
+ActiveRecord::Schema.define(:version => 1) do
+ create_table :widgets, :force => true do |t|
+ t.column :name, :string
+ t.column :friendly_identifier, :string
+ t.column :category_id, :integer
+ end
+ create_table :gadgets, :force => true do |t|
+ t.column :name, :string
+ t.column :required_stuff, :string
+ t.column :url_slug, :string
+ t.column :category_id, :integer
+ end
+ create_table :categories, :force => true do |t|
+ t.column :name, :string
+ t.column :friendly_identifier, :string
+ end
+end
25 test/test_helper.rb
@@ -0,0 +1,25 @@
+TEST_ROOT = File.dirname(__FILE__)
+$:.unshift(TEST_ROOT + '/../lib')
+
+require 'rubygems'
+require 'test/unit'
+require 'active_record'
+require 'active_record/fixtures'
+require 'active_support/binding_of_caller'
+require 'active_support/breakpoint'
+require File.join(TEST_ROOT, '/../init')
+
+# Load database schema
+config = YAML::load(IO.read(File.join(TEST_ROOT, 'database.yml')))
+ActiveRecord::Base.establish_connection(config[ENV['DB'] || 'plugin_test'])
+schema = File.join(TEST_ROOT, 'schema.rb')
+load schema if File.exists?(schema)
+
+# Create a logger
+ActiveRecord::Base.logger = Logger.new(File.join(TEST_ROOT, 'debug.log'))
+
+# Test options
+class Test::Unit::TestCase #:nodoc:
+ self.use_transactional_fixtures = true
+ self.use_instantiated_fixtures = false
+end
1 uninstall.rb
@@ -0,0 +1 @@
+# Uninstall hook code here

0 comments on commit 38ff649

Please sign in to comment.
Something went wrong with that request. Please try again.