friendly_id4 4.0.0.pre → 4.0.0.pre3
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +9 -0
- data/ABOUT.md +139 -0
- data/Guide.md +405 -0
- data/README.md +65 -47
- data/Rakefile +12 -16
- data/bench.rb +71 -0
- data/friendly_id.gemspec +29 -0
- data/lib/friendly_id.rb +9 -116
- data/lib/friendly_id/base.rb +29 -0
- data/lib/friendly_id/configuration.rb +31 -0
- data/lib/friendly_id/finder_methods.rb +14 -0
- data/lib/friendly_id/history.rb +29 -0
- data/lib/friendly_id/migration.rb +17 -0
- data/lib/friendly_id/model.rb +22 -0
- data/lib/friendly_id/object_utils.rb +27 -0
- data/lib/friendly_id/scoped.rb +24 -2
- data/lib/friendly_id/slug_sequencer.rb +79 -0
- data/lib/friendly_id/slugged.rb +11 -84
- data/lib/friendly_id/version.rb +1 -1
- data/lib/generators/friendly_id_generator.rb +21 -0
- data/test/core_test.rb +23 -56
- data/test/helper.rb +41 -0
- data/test/history_test.rb +46 -0
- data/test/object_utils_test.rb +18 -0
- data/test/scoped_test.rb +30 -49
- data/test/shared.rb +47 -0
- data/test/slugged_test.rb +31 -52
- metadata +56 -22
- data/lib/friendly_id/test.rb +0 -23
- data/lib/friendly_id/test/generic.rb +0 -84
- data/test/test_helper.rb +0 -23
@@ -0,0 +1,14 @@
|
|
1
|
+
module FriendlyId
|
2
|
+
# These methods will override the finder methods in ActiveRecord::Relation.
|
3
|
+
module FinderMethods
|
4
|
+
|
5
|
+
protected
|
6
|
+
|
7
|
+
def find_one(id)
|
8
|
+
return super if !@klass.uses_friendly_id? or id.unfriendly_id?
|
9
|
+
where(@klass.friendly_id_config.query_field => id).first or super
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
ActiveRecord::Relation.send :include, FriendlyId::FinderMethods
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module FriendlyId
|
2
|
+
module History
|
3
|
+
|
4
|
+
def self.included(base)
|
5
|
+
base.class_eval do
|
6
|
+
include Slugged unless include? Slugged
|
7
|
+
extend Finder
|
8
|
+
has_many :friendly_id_slugs, :as => :sluggable, :dependent => :destroy
|
9
|
+
before_save :build_friendly_id_slug, :if => lambda {|r| r.slug_sequencer.slug_changed?}
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
private
|
14
|
+
|
15
|
+
def build_friendly_id_slug
|
16
|
+
self.friendly_id_slugs.build :slug => friendly_id
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
module Finder
|
21
|
+
def find_by_friendly_id(*args)
|
22
|
+
where("friendly_id_slugs.slug = ?", args.shift).includes(:friendly_id_slugs).first(*args)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
class FriendlyIdSlug < ActiveRecord::Base
|
28
|
+
belongs_to :sluggable, :polymorphic => true
|
29
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
class CreateFriendlyIdSlugs < ActiveRecord::Migration
|
2
|
+
def self.up
|
3
|
+
create_table :friendly_id_slugs do |t|
|
4
|
+
t.string :slug, :null => false
|
5
|
+
t.integer :sluggable_id, :null => false
|
6
|
+
t.string :sluggable_type, :limit => 40
|
7
|
+
t.datetime :created_at
|
8
|
+
end
|
9
|
+
add_index :friendly_id_slugs, :sluggable_id
|
10
|
+
add_index :friendly_id_slugs, [:slug, :sluggable_type], :unique => true
|
11
|
+
add_index :friendly_id_slugs, :sluggable_type
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.down
|
15
|
+
drop_table :slugs
|
16
|
+
end
|
17
|
+
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 or id).to_s
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module FriendlyId
|
2
|
+
# Utility methods that are in Object because it's impossible to predict what
|
3
|
+
# kinds of objects get passed into FinderMethods#find_one and
|
4
|
+
# Model#normalize_friendly_id.
|
5
|
+
module ObjectUtils
|
6
|
+
|
7
|
+
# True is the id is definitely friendly, false if definitely unfriendly,
|
8
|
+
# else nil.
|
9
|
+
def friendly_id?
|
10
|
+
if kind_of?(Integer) or kind_of?(Symbol) or self.class.respond_to? :friendly_id_config
|
11
|
+
false
|
12
|
+
elsif to_i.to_s != to_s
|
13
|
+
true
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
# True if the id is definitely unfriendly, false if definitely friendly,
|
18
|
+
# else nil.
|
19
|
+
def unfriendly_id?
|
20
|
+
val = friendly_id? ; !val unless val.nil?
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
class Object
|
26
|
+
include FriendlyId::ObjectUtils
|
27
|
+
end
|
data/lib/friendly_id/scoped.rb
CHANGED
@@ -2,7 +2,18 @@ require "friendly_id/slugged"
|
|
2
2
|
|
3
3
|
module FriendlyId
|
4
4
|
|
5
|
-
# This module adds scopes to in-table slugs.
|
5
|
+
# This module adds scopes to in-table slugs. It's not loaded by default,
|
6
|
+
# so in order to active this feature you must include the module in your
|
7
|
+
# class.
|
8
|
+
#
|
9
|
+
# You can scope by an explicit column, or by a `belongs_to` relation.
|
10
|
+
#
|
11
|
+
# @example
|
12
|
+
# class Restaurant < ActiveRecord::Base
|
13
|
+
# belongs_to :city
|
14
|
+
# include FriendlyId::Scoped
|
15
|
+
# has_friendly_id :name, :scope => :city
|
16
|
+
# end
|
6
17
|
module Scoped
|
7
18
|
def self.included(klass)
|
8
19
|
klass.send :include, Slugged unless klass.include? Slugged
|
@@ -10,8 +21,12 @@ module FriendlyId
|
|
10
21
|
end
|
11
22
|
|
12
23
|
class SlugSequencer
|
24
|
+
private
|
25
|
+
|
13
26
|
alias conflict_without_scope conflict
|
14
27
|
|
28
|
+
# Checks for naming conflicts, taking scopes into account.
|
29
|
+
# @return ActiveRecord::Base
|
15
30
|
def conflict_with_scope
|
16
31
|
column = friendly_id_config.scope_column
|
17
32
|
conflicts.where("#{column} = ?", sluggable.send(column)).first
|
@@ -25,8 +40,15 @@ module FriendlyId
|
|
25
40
|
class Configuration
|
26
41
|
attr_accessor :scope
|
27
42
|
|
43
|
+
# Gets the scope column.
|
44
|
+
#
|
45
|
+
# Checks to see if the +:scope+ option passed to {#has_friendly_id}
|
46
|
+
# refers to a relation, and if so, returns the realtion's foreign key.
|
47
|
+
# Otherwise it assumes the option value was the name of column and returns
|
48
|
+
# it cast to a String.
|
49
|
+
# @return String The scope column
|
28
50
|
def scope_column
|
29
|
-
klass.reflections[@scope].try(:association_foreign_key) || @scope.to_s
|
51
|
+
(klass.reflections[@scope].try(:association_foreign_key) || @scope).to_s
|
30
52
|
end
|
31
53
|
end
|
32
54
|
end
|
@@ -0,0 +1,79 @@
|
|
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
|
+
def next
|
12
|
+
sequence = conflict.slug.split(separator)[1].to_i
|
13
|
+
next_sequence = sequence == 0 ? 2 : sequence.next
|
14
|
+
"#{normalized}#{separator}#{next_sequence}"
|
15
|
+
end
|
16
|
+
|
17
|
+
def generate
|
18
|
+
if slug_changed? or new_record?
|
19
|
+
conflict? ? self.next : normalized
|
20
|
+
else
|
21
|
+
sluggable.friendly_id
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def slug_changed?
|
26
|
+
base != sluggable.current_friendly_id.try(:sub, /--[\d]*\z/, '')
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def base
|
32
|
+
sluggable.send friendly_id_config.base
|
33
|
+
end
|
34
|
+
|
35
|
+
def column
|
36
|
+
friendly_id_config.query_field
|
37
|
+
end
|
38
|
+
|
39
|
+
def conflict?
|
40
|
+
!! conflict
|
41
|
+
end
|
42
|
+
|
43
|
+
def conflict
|
44
|
+
unless defined? @conflict
|
45
|
+
@conflict = conflicts.first
|
46
|
+
end
|
47
|
+
@conflict
|
48
|
+
end
|
49
|
+
|
50
|
+
# @NOTE AR-specific code here
|
51
|
+
def conflicts
|
52
|
+
pkey = sluggable.class.arel_table.primary_key.name
|
53
|
+
value = sluggable.send pkey
|
54
|
+
scope = sluggable.class.where("#{column} = ? OR #{column} LIKE ?", normalized, wildcard)
|
55
|
+
scope = scope.where("#{pkey} <> ?", value) unless sluggable.new_record?
|
56
|
+
scope = scope.order("#{column} DESC")
|
57
|
+
end
|
58
|
+
|
59
|
+
def friendly_id_config
|
60
|
+
sluggable.friendly_id_config
|
61
|
+
end
|
62
|
+
|
63
|
+
def new_record?
|
64
|
+
sluggable.new_record?
|
65
|
+
end
|
66
|
+
|
67
|
+
def normalized
|
68
|
+
@normalized ||= sluggable.normalize_friendly_id(base)
|
69
|
+
end
|
70
|
+
|
71
|
+
def separator
|
72
|
+
friendly_id_config.sequence_separator
|
73
|
+
end
|
74
|
+
|
75
|
+
def wildcard
|
76
|
+
"#{normalized}#{separator}%"
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
data/lib/friendly_id/slugged.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
require "friendly_id/slug_sequencer"
|
2
|
+
|
1
3
|
module FriendlyId
|
2
4
|
|
3
5
|
# This module adds in-table slugs to an ActiveRecord model.
|
@@ -14,10 +16,14 @@ module FriendlyId
|
|
14
16
|
value.to_s.parameterize
|
15
17
|
end
|
16
18
|
|
19
|
+
def slug_sequencer
|
20
|
+
SlugSequencer.new(self)
|
21
|
+
end
|
22
|
+
|
17
23
|
private
|
18
24
|
|
19
25
|
def set_slug
|
20
|
-
send "#{friendly_id_config.slug_column}=",
|
26
|
+
send "#{friendly_id_config.slug_column}=", slug_sequencer.generate
|
21
27
|
end
|
22
28
|
end
|
23
29
|
|
@@ -27,101 +33,22 @@ module FriendlyId
|
|
27
33
|
DEFAULTS[:slug_column] = 'slug'
|
28
34
|
DEFAULTS[:sequence_separator] = '--'
|
29
35
|
|
36
|
+
undef query_field
|
37
|
+
|
30
38
|
def query_field
|
31
39
|
use_slugs? ? slug_column : base
|
32
40
|
end
|
33
41
|
|
34
42
|
def sequence_separator
|
35
|
-
@sequence_separator
|
43
|
+
@sequence_separator ||= DEFAULTS[:sequence_separator]
|
36
44
|
end
|
37
45
|
|
38
46
|
def slug_column
|
39
|
-
@slug_column
|
47
|
+
@slug_column ||= DEFAULTS[:slug_column]
|
40
48
|
end
|
41
49
|
|
42
50
|
def use_slugs?
|
43
51
|
@use_slugs
|
44
52
|
end
|
45
53
|
end
|
46
|
-
|
47
|
-
# This class offers functionality to check slug strings for uniqueness and,
|
48
|
-
# if necessary, append a sequence to ensure it.
|
49
|
-
class SlugSequencer
|
50
|
-
attr_reader :sluggable
|
51
|
-
|
52
|
-
def initialize(sluggable)
|
53
|
-
@sluggable = sluggable
|
54
|
-
end
|
55
|
-
|
56
|
-
def base
|
57
|
-
sluggable.send friendly_id_config.base
|
58
|
-
end
|
59
|
-
|
60
|
-
def changed?
|
61
|
-
base != current_friendly_id.try(:sub, /--[\d]*\z/, '')
|
62
|
-
end
|
63
|
-
|
64
|
-
def column
|
65
|
-
friendly_id_config.query_field
|
66
|
-
end
|
67
|
-
|
68
|
-
def conflict?
|
69
|
-
!! conflict
|
70
|
-
end
|
71
|
-
|
72
|
-
def conflict
|
73
|
-
unless defined? @conflict
|
74
|
-
@conflict = conflicts.first
|
75
|
-
end
|
76
|
-
@conflict
|
77
|
-
end
|
78
|
-
|
79
|
-
# @NOTE AR-specific code here
|
80
|
-
def conflicts
|
81
|
-
pkey = sluggable.class.arel_table.primary_key.name
|
82
|
-
value = sluggable.send pkey
|
83
|
-
scope = sluggable.class.where("#{column} = ? OR #{column} LIKE ?", normalized, wildcard)
|
84
|
-
scope = scope.where("#{pkey} <> ?", value) unless sluggable.new_record?
|
85
|
-
scope = scope.order("#{column} DESC")
|
86
|
-
end
|
87
|
-
|
88
|
-
def current_friendly_id
|
89
|
-
sluggable.instance_variable_get(:@_current_friendly_id)
|
90
|
-
end
|
91
|
-
|
92
|
-
def friendly_id_config
|
93
|
-
sluggable.friendly_id_config
|
94
|
-
end
|
95
|
-
|
96
|
-
def new_record?
|
97
|
-
sluggable.new_record?
|
98
|
-
end
|
99
|
-
|
100
|
-
def normalized
|
101
|
-
@normalized ||= sluggable.normalize_friendly_id(base)
|
102
|
-
end
|
103
|
-
|
104
|
-
def separator
|
105
|
-
friendly_id_config.sequence_separator
|
106
|
-
end
|
107
|
-
|
108
|
-
def next
|
109
|
-
sequence = conflict.slug.split(separator)[1].to_i
|
110
|
-
next_sequence = sequence == 0 ? 2 : sequence.next
|
111
|
-
"#{normalized}#{separator}#{next_sequence}"
|
112
|
-
end
|
113
|
-
|
114
|
-
def to_s
|
115
|
-
if changed? or new_record?
|
116
|
-
conflict? ? self.next : normalized
|
117
|
-
else
|
118
|
-
sluggable.friendly_id
|
119
|
-
end
|
120
|
-
end
|
121
|
-
|
122
|
-
def wildcard
|
123
|
-
"#{normalized}#{separator}%"
|
124
|
-
end
|
125
|
-
end
|
126
|
-
|
127
54
|
end
|
data/lib/friendly_id/version.rb
CHANGED
@@ -0,0 +1,21 @@
|
|
1
|
+
require 'rails/generators'
|
2
|
+
require 'rails/generators/migration'
|
3
|
+
|
4
|
+
class FriendlyIdGenerator < Rails::Generators::Base
|
5
|
+
include Rails::Generators::Migration
|
6
|
+
|
7
|
+
source_root File.expand_path('../../friendly_id', __FILE__)
|
8
|
+
|
9
|
+
def copy_files(*args)
|
10
|
+
migration_template 'migration.rb', 'db/migrate/create_friendly_id_slugs.rb'
|
11
|
+
end
|
12
|
+
|
13
|
+
# Taken from ActiveRecord's migration generator
|
14
|
+
def self.next_migration_number(dirname) #:nodoc:
|
15
|
+
if ActiveRecord::Base.timestamped_migrations
|
16
|
+
Time.now.utc.strftime("%Y%m%d%H%M%S")
|
17
|
+
else
|
18
|
+
"%.3d" % (current_migration_number(dirname) + 1)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
data/test/core_test.rb
CHANGED
@@ -1,68 +1,35 @@
|
|
1
|
-
require File.expand_path("../
|
1
|
+
require File.expand_path("../helper.rb", __FILE__)
|
2
2
|
|
3
|
-
|
3
|
+
setup { Class.new ActiveRecord::Base }
|
4
4
|
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
@instance ||= klass.create!(:name => name)
|
9
|
-
end
|
10
|
-
|
11
|
-
# @TODO - this kind of setup is repeated in a few places and should probably
|
12
|
-
# be abstracted.
|
13
|
-
def klass
|
14
|
-
@@klass ||= make_model("core", :name) do |t|
|
15
|
-
t.string :name, :unique => true
|
16
|
-
t.boolean :active, :default => true
|
17
|
-
end
|
18
|
-
@@klass
|
19
|
-
end
|
20
|
-
|
21
|
-
def other_class
|
22
|
-
@@other_class ||= make_model("core_two", :name) do |t|
|
23
|
-
t.string :name, :unique => true
|
24
|
-
t.boolean :active, :default => true
|
25
|
-
end
|
26
|
-
end
|
27
|
-
|
28
|
-
def setup
|
29
|
-
klass.stubs(:name).returns("Cores")
|
30
|
-
other_class.stubs(:name).returns("CoreTwos")
|
31
|
-
end
|
32
|
-
|
33
|
-
test "models don't use friendly_id by default" do
|
34
|
-
assert !Class.new(ActiveRecord::Base).uses_friendly_id?
|
35
|
-
end
|
5
|
+
test "models don't use friendly_id by default" do |klass|
|
6
|
+
assert !klass.uses_friendly_id?
|
7
|
+
end
|
36
8
|
|
37
|
-
|
38
|
-
|
39
|
-
|
9
|
+
test "model classes should have a friendly id config" do |klass|
|
10
|
+
assert klass.has_friendly_id(:name).friendly_id_config
|
11
|
+
end
|
40
12
|
|
41
|
-
|
42
|
-
|
13
|
+
test "should raise error when bad config options are set" do |klass|
|
14
|
+
assert_raise ArgumentError do
|
15
|
+
klass.has_friendly_id :name, :garbage => :in
|
43
16
|
end
|
17
|
+
end
|
44
18
|
|
45
|
-
|
46
|
-
assert_equal nil, "1".friendly_id?
|
47
|
-
assert_equal nil, "1".unfriendly_id?
|
48
|
-
end
|
19
|
+
[User, Book].map {|klass| klass.has_friendly_id :name}
|
49
20
|
|
50
|
-
|
51
|
-
assert "a".friendly_id?
|
52
|
-
end
|
21
|
+
setup {User}
|
53
22
|
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
end
|
59
|
-
|
60
|
-
test "reserves 'new' and 'edit' by default" do
|
61
|
-
FriendlyId::Configuration::DEFAULTS[:reserved_words].each do |word|
|
62
|
-
assert_raises ActiveRecord::RecordInvalid do
|
63
|
-
klass.create! :name => word
|
64
|
-
end
|
23
|
+
test "should reserve 'new' and 'edit' by default" do |klass|
|
24
|
+
["new", "edit"].each do |word|
|
25
|
+
transaction do
|
26
|
+
assert_raise(ActiveRecord::RecordInvalid) {klass.create! :name => word}
|
65
27
|
end
|
66
28
|
end
|
29
|
+
end
|
67
30
|
|
31
|
+
test "instances should have a friendly id" do |klass|
|
32
|
+
with_instance_of(klass) {|record| assert record.friendly_id}
|
68
33
|
end
|
34
|
+
|
35
|
+
require File.expand_path("../shared.rb", __FILE__)
|