friendly_id4 4.0.0.beta1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|