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.
- data/.gitignore +11 -0
- data/.travis.yml +24 -0
- data/.yardopts +4 -0
- data/Changelog.md +9 -10
- data/README.md +39 -48
- data/Rakefile +56 -58
- data/WhatsNew.md +95 -0
- data/bench.rb +63 -0
- data/friendly_id.gemspec +40 -0
- data/gemfiles/Gemfile.rails-3.0.rb +18 -0
- data/gemfiles/Gemfile.rails-3.0.rb.lock +52 -0
- data/gemfiles/Gemfile.rails-3.1.rb +18 -0
- data/gemfiles/Gemfile.rails-3.1.rb.lock +57 -0
- data/lib/friendly_id.rb +126 -80
- data/lib/friendly_id/active_record_adapter/relation.rb +10 -2
- data/lib/friendly_id/active_record_adapter/slugged_model.rb +3 -9
- data/lib/friendly_id/base.rb +132 -0
- data/lib/friendly_id/configuration.rb +65 -152
- data/lib/friendly_id/finder_methods.rb +20 -0
- data/lib/friendly_id/history.rb +88 -0
- data/lib/friendly_id/migration.rb +18 -0
- data/lib/friendly_id/model.rb +22 -0
- data/lib/friendly_id/object_utils.rb +40 -0
- data/lib/friendly_id/reserved.rb +46 -0
- data/lib/friendly_id/scoped.rb +131 -0
- data/lib/friendly_id/slug.rb +9 -0
- data/lib/friendly_id/slug_sequencer.rb +82 -0
- data/lib/friendly_id/slugged.rb +191 -76
- data/lib/friendly_id/version.rb +2 -2
- data/test/base_test.rb +54 -0
- data/test/configuration_test.rb +27 -0
- data/test/core_test.rb +30 -0
- data/test/databases.yml +19 -0
- data/test/helper.rb +88 -0
- data/test/history_test.rb +55 -0
- data/test/object_utils_test.rb +26 -0
- data/test/reserved_test.rb +26 -0
- data/test/schema.rb +59 -0
- data/test/scoped_test.rb +57 -0
- data/test/shared.rb +118 -0
- data/test/slugged_test.rb +83 -0
- data/test/sti_test.rb +48 -0
- metadata +110 -102
- data/Contributors.md +0 -46
- data/Guide.md +0 -626
- data/extras/README.txt +0 -3
- data/extras/bench.rb +0 -40
- data/extras/extras.rb +0 -38
- data/extras/prof.rb +0 -19
- data/extras/template-gem.rb +0 -26
- data/extras/template-plugin.rb +0 -28
- data/generators/friendly_id/friendly_id_generator.rb +0 -30
- data/generators/friendly_id/templates/create_slugs.rb +0 -18
- data/lib/tasks/friendly_id.rake +0 -19
- data/rails/init.rb +0 -2
- data/test/active_record_adapter/ar_test_helper.rb +0 -149
- data/test/active_record_adapter/basic_slugged_model_test.rb +0 -14
- data/test/active_record_adapter/cached_slug_test.rb +0 -76
- data/test/active_record_adapter/core.rb +0 -138
- data/test/active_record_adapter/custom_normalizer_test.rb +0 -20
- data/test/active_record_adapter/custom_table_name_test.rb +0 -22
- data/test/active_record_adapter/default_scope_test.rb +0 -30
- data/test/active_record_adapter/optimistic_locking_test.rb +0 -18
- data/test/active_record_adapter/scoped_model_test.rb +0 -129
- data/test/active_record_adapter/simple_test.rb +0 -76
- data/test/active_record_adapter/slug_test.rb +0 -34
- data/test/active_record_adapter/slugged.rb +0 -33
- data/test/active_record_adapter/slugged_status_test.rb +0 -28
- data/test/active_record_adapter/sti_test.rb +0 -22
- data/test/active_record_adapter/support/database.jdbcsqlite3.yml +0 -2
- data/test/active_record_adapter/support/database.mysql.yml +0 -4
- data/test/active_record_adapter/support/database.mysql2.yml +0 -4
- data/test/active_record_adapter/support/database.postgres.yml +0 -6
- data/test/active_record_adapter/support/database.sqlite3.yml +0 -2
- data/test/active_record_adapter/support/models.rb +0 -104
- data/test/active_record_adapter/tasks_test.rb +0 -82
- data/test/compatibility/ancestry/Gemfile.lock +0 -34
- data/test/friendly_id_test.rb +0 -96
- 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,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
|