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.
- 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
|