friendly_id4 4.0.0.beta1
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/Guide.md +368 -0
- data/README.md +92 -0
- data/Rakefile +111 -0
- data/WhatsNew.md +142 -0
- data/bench.rb +71 -0
- data/friendly_id.gemspec +31 -0
- data/lib/friendly_id.rb +14 -0
- 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 +27 -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 +54 -0
- data/lib/friendly_id/slug.rb +3 -0
- data/lib/friendly_id/slug_sequencer.rb +80 -0
- data/lib/friendly_id/slugged.rb +51 -0
- data/lib/friendly_id/version.rb +9 -0
- data/lib/generators/friendly_id_generator.rb +21 -0
- data/test/config/mysql.yml +5 -0
- data/test/config/postgres.yml +6 -0
- data/test/config/sqlite3.yml +3 -0
- data/test/core_test.rb +43 -0
- data/test/helper.rb +86 -0
- data/test/history_test.rb +46 -0
- data/test/object_utils_test.rb +26 -0
- data/test/schema.rb +53 -0
- data/test/scoped_test.rb +49 -0
- data/test/shared.rb +76 -0
- data/test/slugged_test.rb +111 -0
- metadata +163 -0
@@ -0,0 +1,27 @@
|
|
1
|
+
require "friendly_id/slug"
|
2
|
+
|
3
|
+
module FriendlyId
|
4
|
+
module History
|
5
|
+
|
6
|
+
def self.included(base)
|
7
|
+
base.class_eval do
|
8
|
+
include Slugged unless include? Slugged
|
9
|
+
extend Finder
|
10
|
+
has_many :friendly_id_slugs, :as => :sluggable, :dependent => :destroy
|
11
|
+
before_save :build_friendly_id_slug, :if => lambda {|r| r.slug_sequencer.slug_changed?}
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def build_friendly_id_slug
|
18
|
+
self.friendly_id_slugs.build :slug => friendly_id
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
module Finder
|
23
|
+
def find_by_friendly_id(*args)
|
24
|
+
where("friendly_id_slugs.slug = ?", args.shift).includes(:friendly_id_slugs).first(*args)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
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 :friendly_id_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.present? ? friendly_id : 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
|
@@ -0,0 +1,54 @@
|
|
1
|
+
require "friendly_id/slugged"
|
2
|
+
|
3
|
+
module FriendlyId
|
4
|
+
|
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
|
17
|
+
module Scoped
|
18
|
+
def self.included(klass)
|
19
|
+
klass.send :include, Slugged unless klass.include? Slugged
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
class SlugSequencer
|
24
|
+
private
|
25
|
+
|
26
|
+
alias conflict_without_scope conflict
|
27
|
+
|
28
|
+
# Checks for naming conflicts, taking scopes into account.
|
29
|
+
# @return ActiveRecord::Base
|
30
|
+
def conflict_with_scope
|
31
|
+
column = friendly_id_config.scope_column
|
32
|
+
conflicts.where("#{column} = ?", sluggable.send(column)).first
|
33
|
+
end
|
34
|
+
|
35
|
+
def conflict
|
36
|
+
friendly_id_config.scope ? conflict_with_scope : conflict_without_scope
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
class Configuration
|
41
|
+
attr_accessor :scope
|
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
|
50
|
+
def scope_column
|
51
|
+
(klass.reflections[@scope].try(:association_foreign_key) || @scope).to_s
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,80 @@
|
|
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
|
+
separator = Regexp.escape friendly_id_config.sequence_separator
|
27
|
+
base != sluggable.current_friendly_id.try(:sub, /#{separator}[\d]*\z/, '')
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def base
|
33
|
+
sluggable.send friendly_id_config.base
|
34
|
+
end
|
35
|
+
|
36
|
+
def column
|
37
|
+
sluggable.connection.quote_column_name friendly_id_config.query_field
|
38
|
+
end
|
39
|
+
|
40
|
+
def conflict?
|
41
|
+
!! conflict
|
42
|
+
end
|
43
|
+
|
44
|
+
def conflict
|
45
|
+
unless defined? @conflict
|
46
|
+
@conflict = conflicts.first
|
47
|
+
end
|
48
|
+
@conflict
|
49
|
+
end
|
50
|
+
|
51
|
+
# @NOTE AR-specific code here
|
52
|
+
def conflicts
|
53
|
+
pkey = sluggable.class.primary_key
|
54
|
+
value = sluggable.send pkey
|
55
|
+
scope = sluggable.class.where("#{column} = ? OR #{column} LIKE ?", normalized, wildcard)
|
56
|
+
scope = scope.where("#{pkey} <> ?", value) unless sluggable.new_record?
|
57
|
+
scope = scope.order("LENGTH(#{column}) DESC, #{column} DESC")
|
58
|
+
end
|
59
|
+
|
60
|
+
def friendly_id_config
|
61
|
+
sluggable.friendly_id_config
|
62
|
+
end
|
63
|
+
|
64
|
+
def new_record?
|
65
|
+
sluggable.new_record?
|
66
|
+
end
|
67
|
+
|
68
|
+
def normalized
|
69
|
+
@normalized ||= sluggable.normalize_friendly_id(base)
|
70
|
+
end
|
71
|
+
|
72
|
+
def separator
|
73
|
+
friendly_id_config.sequence_separator
|
74
|
+
end
|
75
|
+
|
76
|
+
def wildcard
|
77
|
+
"#{normalized}#{separator}%"
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
require "friendly_id/slug_sequencer"
|
2
|
+
|
3
|
+
module FriendlyId
|
4
|
+
|
5
|
+
# This module adds in-table slugs to an ActiveRecord model.
|
6
|
+
module Slugged
|
7
|
+
|
8
|
+
# @NOTE AR-specific code here
|
9
|
+
def self.included(klass)
|
10
|
+
klass.before_save :set_slug
|
11
|
+
klass.friendly_id_config.use_slugs = true
|
12
|
+
end
|
13
|
+
|
14
|
+
# @NOTE AS-specific code here
|
15
|
+
def normalize_friendly_id(value)
|
16
|
+
value.to_s.parameterize
|
17
|
+
end
|
18
|
+
|
19
|
+
def slug_sequencer
|
20
|
+
SlugSequencer.new(self)
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def set_slug
|
26
|
+
send "#{friendly_id_config.slug_column}=", slug_sequencer.generate
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
class Configuration
|
31
|
+
attr :use_slugs
|
32
|
+
attr_writer :slug_column, :sequence_separator, :use_slugs
|
33
|
+
|
34
|
+
DEFAULTS[:slug_column] = 'slug'
|
35
|
+
DEFAULTS[:sequence_separator] = '--'
|
36
|
+
|
37
|
+
undef query_field
|
38
|
+
|
39
|
+
def query_field
|
40
|
+
use_slugs ? slug_column : base
|
41
|
+
end
|
42
|
+
|
43
|
+
def sequence_separator
|
44
|
+
@sequence_separator ||= DEFAULTS[:sequence_separator]
|
45
|
+
end
|
46
|
+
|
47
|
+
def slug_column
|
48
|
+
@slug_column ||= DEFAULTS[:slug_column]
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -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
ADDED
@@ -0,0 +1,43 @@
|
|
1
|
+
require File.expand_path("../helper.rb", __FILE__)
|
2
|
+
|
3
|
+
Author, Book = 2.times.map do
|
4
|
+
Class.new(ActiveRecord::Base) do
|
5
|
+
has_friendly_id :name
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
class CoreTest < MiniTest::Unit::TestCase
|
10
|
+
|
11
|
+
include FriendlyId::Test
|
12
|
+
include FriendlyId::Test::Shared
|
13
|
+
|
14
|
+
def klass
|
15
|
+
Author
|
16
|
+
end
|
17
|
+
|
18
|
+
test "models don't use friendly_id by default" do
|
19
|
+
assert !Class.new(ActiveRecord::Base).uses_friendly_id?
|
20
|
+
end
|
21
|
+
|
22
|
+
test "model classes should have a friendly id config" do
|
23
|
+
assert klass.has_friendly_id(:name).friendly_id_config
|
24
|
+
end
|
25
|
+
|
26
|
+
test "should raise error when bad config options are set" do
|
27
|
+
assert_raises ArgumentError do
|
28
|
+
klass.has_friendly_id :name, :garbage => :in
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
test "should reserve 'new' and 'edit' by default" do
|
33
|
+
["new", "edit"].each do |word|
|
34
|
+
transaction do
|
35
|
+
assert_raises(ActiveRecord::RecordInvalid) {klass.create! :name => word}
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
test "instances should have a friendly id" do
|
41
|
+
with_instance_of(klass) {|record| assert record.friendly_id}
|
42
|
+
end
|
43
|
+
end
|
data/test/helper.rb
ADDED
@@ -0,0 +1,86 @@
|
|
1
|
+
$: << File.expand_path("../../lib", __FILE__)
|
2
|
+
$: << File.expand_path("../", __FILE__)
|
3
|
+
$:.uniq!
|
4
|
+
|
5
|
+
require "rubygems"
|
6
|
+
require "bundler/setup"
|
7
|
+
require "mocha"
|
8
|
+
require "minitest/unit"
|
9
|
+
require "active_record"
|
10
|
+
|
11
|
+
if ENV["COVERAGE"]
|
12
|
+
require 'simplecov'
|
13
|
+
SimpleCov.start
|
14
|
+
end
|
15
|
+
|
16
|
+
require "friendly_id"
|
17
|
+
|
18
|
+
# If you want to see the ActiveRecord log, invoke the tests using `rake test LOG=true`
|
19
|
+
if ENV["LOG"]
|
20
|
+
require "logger"
|
21
|
+
ActiveRecord::Base.logger = Logger.new($stdout)
|
22
|
+
end
|
23
|
+
|
24
|
+
module FriendlyId
|
25
|
+
module Test
|
26
|
+
|
27
|
+
def self.included(base)
|
28
|
+
MiniTest::Unit.autorun
|
29
|
+
end
|
30
|
+
|
31
|
+
def transaction
|
32
|
+
ActiveRecord::Base.transaction { yield ; raise ActiveRecord::Rollback }
|
33
|
+
end
|
34
|
+
|
35
|
+
def with_instance_of(*args)
|
36
|
+
klass = args.shift
|
37
|
+
args[0] ||= {:name => "a"}
|
38
|
+
transaction { yield klass.create!(*args) }
|
39
|
+
end
|
40
|
+
|
41
|
+
module Database
|
42
|
+
extend self
|
43
|
+
|
44
|
+
def connect
|
45
|
+
ActiveRecord::Base.establish_connection config
|
46
|
+
version = ActiveRecord::VERSION::STRING
|
47
|
+
driver = FriendlyId::Test::Database.driver
|
48
|
+
message = "Using #{RUBY_ENGINE} #{RUBY_VERSION} AR #{version} with #{driver}"
|
49
|
+
puts "-" * 72
|
50
|
+
if in_memory?
|
51
|
+
ActiveRecord::Migration.verbose = false
|
52
|
+
Schema.up
|
53
|
+
puts "#{message} (in-memory)"
|
54
|
+
else
|
55
|
+
puts message
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def config
|
60
|
+
@config ||= YAML::load(File.open(config_file))
|
61
|
+
end
|
62
|
+
|
63
|
+
def config_file
|
64
|
+
File.expand_path("../config/#{driver}.yml", __FILE__)
|
65
|
+
end
|
66
|
+
|
67
|
+
def driver
|
68
|
+
(ENV["DB"] or "sqlite3").downcase
|
69
|
+
end
|
70
|
+
|
71
|
+
def in_memory?
|
72
|
+
config["database"] == ":memory:"
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
class Module
|
79
|
+
def test(name, &block)
|
80
|
+
define_method("test_#{name.gsub(/[^a-z0-9]/i, "_")}".to_sym, &block)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
require "schema"
|
85
|
+
require "shared"
|
86
|
+
FriendlyId::Test::Database.connect
|