friendly_id 3.3.3.0 → 4.0.0.beta7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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