friendly_id 3.3.3.0 → 4.0.0.beta7

Sign up to get free protection for your applications and to get access to all the features.
Files changed (79) hide show
  1. data/.gitignore +11 -0
  2. data/.travis.yml +24 -0
  3. data/.yardopts +4 -0
  4. data/Changelog.md +9 -10
  5. data/README.md +39 -48
  6. data/Rakefile +56 -58
  7. data/WhatsNew.md +95 -0
  8. data/bench.rb +63 -0
  9. data/friendly_id.gemspec +40 -0
  10. data/gemfiles/Gemfile.rails-3.0.rb +18 -0
  11. data/gemfiles/Gemfile.rails-3.0.rb.lock +52 -0
  12. data/gemfiles/Gemfile.rails-3.1.rb +18 -0
  13. data/gemfiles/Gemfile.rails-3.1.rb.lock +57 -0
  14. data/lib/friendly_id.rb +126 -80
  15. data/lib/friendly_id/active_record_adapter/relation.rb +10 -2
  16. data/lib/friendly_id/active_record_adapter/slugged_model.rb +3 -9
  17. data/lib/friendly_id/base.rb +132 -0
  18. data/lib/friendly_id/configuration.rb +65 -152
  19. data/lib/friendly_id/finder_methods.rb +20 -0
  20. data/lib/friendly_id/history.rb +88 -0
  21. data/lib/friendly_id/migration.rb +18 -0
  22. data/lib/friendly_id/model.rb +22 -0
  23. data/lib/friendly_id/object_utils.rb +40 -0
  24. data/lib/friendly_id/reserved.rb +46 -0
  25. data/lib/friendly_id/scoped.rb +131 -0
  26. data/lib/friendly_id/slug.rb +9 -0
  27. data/lib/friendly_id/slug_sequencer.rb +82 -0
  28. data/lib/friendly_id/slugged.rb +191 -76
  29. data/lib/friendly_id/version.rb +2 -2
  30. data/test/base_test.rb +54 -0
  31. data/test/configuration_test.rb +27 -0
  32. data/test/core_test.rb +30 -0
  33. data/test/databases.yml +19 -0
  34. data/test/helper.rb +88 -0
  35. data/test/history_test.rb +55 -0
  36. data/test/object_utils_test.rb +26 -0
  37. data/test/reserved_test.rb +26 -0
  38. data/test/schema.rb +59 -0
  39. data/test/scoped_test.rb +57 -0
  40. data/test/shared.rb +118 -0
  41. data/test/slugged_test.rb +83 -0
  42. data/test/sti_test.rb +48 -0
  43. metadata +110 -102
  44. data/Contributors.md +0 -46
  45. data/Guide.md +0 -626
  46. data/extras/README.txt +0 -3
  47. data/extras/bench.rb +0 -40
  48. data/extras/extras.rb +0 -38
  49. data/extras/prof.rb +0 -19
  50. data/extras/template-gem.rb +0 -26
  51. data/extras/template-plugin.rb +0 -28
  52. data/generators/friendly_id/friendly_id_generator.rb +0 -30
  53. data/generators/friendly_id/templates/create_slugs.rb +0 -18
  54. data/lib/tasks/friendly_id.rake +0 -19
  55. data/rails/init.rb +0 -2
  56. data/test/active_record_adapter/ar_test_helper.rb +0 -149
  57. data/test/active_record_adapter/basic_slugged_model_test.rb +0 -14
  58. data/test/active_record_adapter/cached_slug_test.rb +0 -76
  59. data/test/active_record_adapter/core.rb +0 -138
  60. data/test/active_record_adapter/custom_normalizer_test.rb +0 -20
  61. data/test/active_record_adapter/custom_table_name_test.rb +0 -22
  62. data/test/active_record_adapter/default_scope_test.rb +0 -30
  63. data/test/active_record_adapter/optimistic_locking_test.rb +0 -18
  64. data/test/active_record_adapter/scoped_model_test.rb +0 -129
  65. data/test/active_record_adapter/simple_test.rb +0 -76
  66. data/test/active_record_adapter/slug_test.rb +0 -34
  67. data/test/active_record_adapter/slugged.rb +0 -33
  68. data/test/active_record_adapter/slugged_status_test.rb +0 -28
  69. data/test/active_record_adapter/sti_test.rb +0 -22
  70. data/test/active_record_adapter/support/database.jdbcsqlite3.yml +0 -2
  71. data/test/active_record_adapter/support/database.mysql.yml +0 -4
  72. data/test/active_record_adapter/support/database.mysql2.yml +0 -4
  73. data/test/active_record_adapter/support/database.postgres.yml +0 -6
  74. data/test/active_record_adapter/support/database.sqlite3.yml +0 -2
  75. data/test/active_record_adapter/support/models.rb +0 -104
  76. data/test/active_record_adapter/tasks_test.rb +0 -82
  77. data/test/compatibility/ancestry/Gemfile.lock +0 -34
  78. data/test/friendly_id_test.rb +0 -96
  79. data/test/test_helper.rb +0 -13
@@ -0,0 +1,88 @@
1
+ require "friendly_id/slug"
2
+
3
+ module FriendlyId
4
+
5
+ =begin
6
+ This module adds the ability to store a log of a model's slugs, so that when its
7
+ friendly id changes, it's still possible to perform finds by the old id.
8
+
9
+ The primary use case for this is avoiding broken URLs.
10
+
11
+ == Setup
12
+
13
+ In order to use this module, you must add a table to your database schema to
14
+ store the slug records. FriendlyId provides a generator for this purpose:
15
+
16
+ rails generate friendly_id
17
+ rake db:migrate
18
+
19
+ This will add a table named +friendly_id_slugs+, used by the {FriendlyId::Slug}
20
+ model.
21
+
22
+ == Considerations
23
+
24
+ This module is incompatible with the +:scoped+ module.
25
+
26
+ Because recording slug history requires creating additional database records,
27
+ this module has an impact on the performance of the associated model's +create+
28
+ method.
29
+
30
+ == Example
31
+
32
+ class Post < ActiveRecord::Base
33
+ extend FriendlyId
34
+ friendly_id :title, :use => :history
35
+ end
36
+
37
+ class PostsController < ApplicationController
38
+
39
+ before_filter :find_post
40
+
41
+ ...
42
+
43
+ def find_post
44
+ return unless params[:id]
45
+ @post = begin
46
+ Post.find params[:id]
47
+ rescue ActiveRecord::RecordNotFound
48
+ Post.find_by_friendly_id params[:id]
49
+ end
50
+ # If an old id or a numeric id was used to find the record, then
51
+ # the request path will not match the post_path, and we should do
52
+ # a 301 redirect that uses the current friendly id.
53
+ if request.path != post_path(@post)
54
+ return redirect_to @post, :status => :moved_permanently
55
+ end
56
+ end
57
+ end
58
+ =end
59
+ module History
60
+
61
+ # Configures the model instance to use the History add-on.
62
+ def self.included(klass)
63
+ klass.instance_eval do
64
+ raise "FriendlyId::History is incompatibe with FriendlyId::Scoped" if self < Scoped
65
+ @friendly_id_config.use :slugged
66
+ has_many :slugs, :as => :sluggable, :dependent => :destroy, :class_name => "FriendlyId::Slug"
67
+ before_save :build_slug, :if => lambda {|r| r.slug_sequencer.slug_changed?}
68
+ scope :with_friendly_id, lambda {|id| includes(:slugs).where("friendly_id_slugs.slug = ?", id)}
69
+ extend Finder
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def build_slug
76
+ slugs.build :slug => friendly_id
77
+ end
78
+ end
79
+
80
+ # Adds a finder that explictly uses slugs from the slug table.
81
+ module Finder
82
+
83
+ # Search for a record in the slugs table using the specified slug.
84
+ def find_by_friendly_id(*args)
85
+ with_friendly_id(args.shift).first(*args)
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,18 @@
1
+ class CreateFriendlyIdSlugs < ActiveRecord::Migration
2
+
3
+ def self.up
4
+ create_table :friendly_id_slugs do |t|
5
+ t.string :slug, :null => false
6
+ t.integer :sluggable_id, :null => false
7
+ t.string :sluggable_type, :limit => 40
8
+ t.datetime :created_at
9
+ end
10
+ add_index :friendly_id_slugs, :sluggable_id
11
+ add_index :friendly_id_slugs, [:slug, :sluggable_type], :unique => true
12
+ add_index :friendly_id_slugs, :sluggable_type
13
+ end
14
+
15
+ def self.down
16
+ drop_table :friendly_id_slugs
17
+ end
18
+ end
@@ -0,0 +1,22 @@
1
+ module FriendlyId
2
+ # Instance methods that will be added to all classes using FriendlyId.
3
+ module Model
4
+
5
+ attr_reader :current_friendly_id
6
+
7
+ # Convenience method for accessing the class method of the same name.
8
+ def friendly_id_config
9
+ self.class.friendly_id_config
10
+ end
11
+
12
+ # Get the instance's friendly_id.
13
+ def friendly_id
14
+ send friendly_id_config.query_field
15
+ end
16
+
17
+ # Either the friendly_id, or the numeric id cast to a string.
18
+ def to_param
19
+ (friendly_id.present? ? friendly_id : id).to_s
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,40 @@
1
+ module FriendlyId
2
+ # Utility methods for determining whether any object is a friendly id.
3
+ #
4
+ # Monkey-patching Object is a somewhat extreme measure not to be taken lightly
5
+ # by libraries, but in this case I decided to do it because to me, it feels
6
+ # cleaner than adding a module method to {FriendlyId}. I've given the methods
7
+ # names that unambigously refer to the library of their origin, which should
8
+ # be sufficient to avoid conflicts with other libraries.
9
+ module ObjectUtils
10
+
11
+ # True is the id is definitely friendly, false if definitely unfriendly,
12
+ # else nil.
13
+ #
14
+ # An object is considired "definitely unfriendly" if its class is or
15
+ # inherits from Numeric, Symbol or ActiveRecord::Base.
16
+ #
17
+ # An object is considered "definitely friendly" if it responds to +to_i+,
18
+ # and its value when cast to an integer and then back to a string is
19
+ # different from its value when merely cast to a string:
20
+ #
21
+ # 123.friendly_id? #=> false
22
+ # "123".friendly_id? #=> nil
23
+ # "abc123".friendly_id? #=> true
24
+ def friendly_id?
25
+ if [Numeric, Symbol, ActiveRecord::Base].detect {|klass| self.class < klass}
26
+ false
27
+ elsif respond_to?(:to_i) && to_i.to_s != to_s
28
+ true
29
+ end
30
+ end
31
+
32
+ # True if the id is definitely unfriendly, false if definitely friendly,
33
+ # else nil.
34
+ def unfriendly_id?
35
+ val = friendly_id? ; !val unless val.nil?
36
+ end
37
+ end
38
+ end
39
+
40
+ Object.send :include, FriendlyId::ObjectUtils
@@ -0,0 +1,46 @@
1
+ module FriendlyId
2
+
3
+ =begin
4
+ This module adds the ability to exlude a list of words from use as
5
+ FriendlyId slugs.
6
+
7
+ By default, FriendlyId reserves the words "new" and "edit" when this module
8
+ is included. You can configure this globally by using {FriendlyId.defaults FriendlyId.defaults}:
9
+
10
+ FriendlyId.defaults do |config|
11
+ config.use :reserved
12
+ # Reserve words for English and Spanish URLs
13
+ config.reserved_words = %w(new edit nueva nuevo editar)
14
+ end
15
+ =end
16
+ module Reserved
17
+
18
+ # When included, this module adds configuration options to the model class's
19
+ # friendly_id_config.
20
+ def self.included(model_class)
21
+ model_class.class_eval do
22
+ friendly_id_config.class.send :include, Reserved::Configuration
23
+ friendly_id_config.defaults[:reserved_words] ||= ["new", "edit"]
24
+ end
25
+ end
26
+
27
+ # This module adds the +:reserved_words+ configuration option to
28
+ # {FriendlyId::Configuration FriendlyId::Configuration}.
29
+ module Configuration
30
+ attr_writer :reserved_words
31
+
32
+ # Overrides {FriendlyId::Configuration#base} to add a validation to the
33
+ # model class.
34
+ def base=(base)
35
+ super
36
+ reserved_words = model_class.friendly_id_config.reserved_words
37
+ model_class.validates_exclusion_of base, :in => reserved_words
38
+ end
39
+
40
+ # An array of words forbidden as slugs.
41
+ def reserved_words
42
+ @reserved_words ||= @defaults[:reserved_words]
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,131 @@
1
+ require "friendly_id/slugged"
2
+
3
+ module FriendlyId
4
+
5
+ =begin
6
+ This module allows FriendlyId to generate unique slugs within a scope.
7
+
8
+ This allows, for example, two restaurants in different cities to have the slug
9
+ +joes-diner+:
10
+
11
+ class Restaurant < ActiveRecord::Base
12
+ extend FriendlyId
13
+ belongs_to :city
14
+ friendly_id :name, :use => :scoped, :scope => :city
15
+ end
16
+
17
+ class City < ActiveRecord::Base
18
+ extend FriendlyId
19
+ has_many :restaurants
20
+ friendly_id :name, :use => :slugged
21
+ end
22
+
23
+ City.find("seattle").restaurants.find("joes-diner")
24
+ City.find("chicago").restaurants.find("joes-diner")
25
+
26
+ Without :scoped in this case, one of the restaurants would have the slug
27
+ +joes-diner+ and the other would have +joes-diner--2+.
28
+
29
+ The value for the +:scope+ option can be the name of a +belongs_to+ relation, or
30
+ a column.
31
+
32
+ == Tips For Working With Scoped Slugs
33
+
34
+ === Finding Records by Friendly ID
35
+
36
+ If you are using scopes your friendly ids may not be unique, so a simple find
37
+ like
38
+
39
+ Restaurant.find("joes-diner")
40
+
41
+ may return the wrong record. In these cases it's best to query through the
42
+ relation:
43
+
44
+ @city.restaurants.find("joes-diner")
45
+
46
+ Alternatively, you could pass the scope value as a query parameter:
47
+
48
+ Restaurant.find("joes-diner").where(:city_id => @city.id)
49
+
50
+
51
+ === Finding All Records That Match a Scoped ID
52
+
53
+ Query the slug column directly:
54
+
55
+ Restaurant.find_all_by_slug("joes-diner")
56
+
57
+ === Routes for Scoped Models
58
+
59
+ Recall that FriendlyId is a database-centric library, and does not set up any
60
+ routes for scoped models. You must do this yourself in your application. Here's
61
+ an example of one way to set this up:
62
+
63
+ # in routes.rb
64
+ resources :cities do
65
+ resources :restaurants
66
+ end
67
+
68
+ # in views
69
+ <%= link_to 'Show', [@city, @restaurant] %>
70
+
71
+ # in controllers
72
+ @city = City.find(params[:city_id])
73
+ @restaurant = @city.restaurants.find(params[:id])
74
+
75
+ # URLs:
76
+ http://example.org/cities/seattle/restaurants/joes-diner
77
+ http://example.org/cities/chicago/restaurants/joes-diner
78
+
79
+ =end
80
+ module Scoped
81
+
82
+
83
+ # Sets up behavior and configuration options for FriendlyId's scoped slugs
84
+ # feature.
85
+ def self.included(model_class)
86
+ model_class.instance_eval do
87
+ raise "FriendlyId::Scoped is incompatibe with FriendlyId::History" if self < History
88
+ include Slugged unless self < Slugged
89
+ friendly_id_config.class.send :include, Configuration
90
+ friendly_id_config.slug_sequencer_class.send :include, SlugSequencer
91
+ end
92
+ end
93
+
94
+ # This module adds the +:scope+ configuration option to
95
+ # {FriendlyId::Configuration FriendlyId::Configuration}.
96
+ module Configuration
97
+
98
+ # Gets the scope value.
99
+ #
100
+ # When setting this value, the argument should be a symbol referencing a
101
+ # +belongs_to+ relation, or a column.
102
+ #
103
+ # @return Symbol The scope value
104
+ attr_accessor :scope
105
+
106
+ # Gets the scope column.
107
+ #
108
+ # Checks to see if the +:scope+ option passed to
109
+ # {FriendlyId::Base#friendly_id} refers to a relation, and if so, returns
110
+ # the realtion's foreign key. Otherwise it assumes the option value was
111
+ # the name of column and returns it cast to a String.
112
+ #
113
+ # @return String The scope column
114
+ def scope_column
115
+ (model_class.reflections[@scope].try(:association_foreign_key) || @scope).to_s
116
+ end
117
+ end
118
+
119
+ # This module overrides {FriendlyId::SlugSequencer#conflict} to consider
120
+ # scope, to avoid adding sequences to slugs under different scopes.
121
+ module SlugSequencer
122
+
123
+ private
124
+
125
+ def conflict
126
+ column = friendly_id_config.scope_column
127
+ conflicts.where("#{column} = ?", sluggable.send(column)).first
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,9 @@
1
+ module FriendlyId
2
+ # A FriendlyId slug stored in an external table.
3
+ #
4
+ # @see FriendlyId::History
5
+ class Slug < ActiveRecord::Base
6
+ self.table_name = "friendly_id_slugs"
7
+ belongs_to :sluggable, :polymorphic => true
8
+ end
9
+ end
@@ -0,0 +1,82 @@
1
+ module FriendlyId
2
+ # This class offers functionality to check slug strings for uniqueness and,
3
+ # if necessary, append a sequence to ensure it.
4
+ class SlugSequencer
5
+ attr_reader :sluggable
6
+
7
+ def initialize(sluggable)
8
+ @sluggable = sluggable
9
+ end
10
+
11
+ # Given a slug, get the next available slug in the sequence.
12
+ def next
13
+ sequence = conflict.slug.split(separator)[1].to_i
14
+ next_sequence = sequence == 0 ? 2 : sequence.next
15
+ "#{normalized}#{separator}#{next_sequence}"
16
+ end
17
+
18
+ # Generate a new sequenced slug.
19
+ def generate
20
+ if new_record? or slug_changed?
21
+ conflict? ? self.next : normalized
22
+ else
23
+ sluggable.friendly_id
24
+ end
25
+ end
26
+
27
+ # Whether or not the model instance's slug has changed.
28
+ def slug_changed?
29
+ separator = Regexp.escape friendly_id_config.sequence_separator
30
+ base != sluggable.current_friendly_id.try(:sub, /#{separator}[\d]*\z/, '')
31
+ end
32
+
33
+ private
34
+
35
+ def base
36
+ sluggable.send friendly_id_config.base
37
+ end
38
+
39
+ def column
40
+ sluggable.connection.quote_column_name friendly_id_config.query_field
41
+ end
42
+
43
+ def conflict?
44
+ !! conflict
45
+ end
46
+
47
+ def conflict
48
+ unless defined? @conflict
49
+ @conflict = conflicts.first
50
+ end
51
+ @conflict
52
+ end
53
+
54
+ def conflicts
55
+ pkey = sluggable.class.primary_key
56
+ value = sluggable.send pkey
57
+ scope = sluggable.class.where("#{column} = ? OR #{column} LIKE ?", normalized, wildcard)
58
+ scope = scope.where("#{pkey} <> ?", value) unless sluggable.new_record?
59
+ scope = scope.order("LENGTH(#{column}) DESC, #{column} DESC")
60
+ end
61
+
62
+ def friendly_id_config
63
+ sluggable.friendly_id_config
64
+ end
65
+
66
+ def new_record?
67
+ sluggable.new_record?
68
+ end
69
+
70
+ def normalized
71
+ @normalized ||= sluggable.normalize_friendly_id(base)
72
+ end
73
+
74
+ def separator
75
+ friendly_id_config.sequence_separator
76
+ end
77
+
78
+ def wildcard
79
+ "#{normalized}#{separator}%"
80
+ end
81
+ end
82
+ end