slugged 0.3.2
Sign up to get free protection for your applications and to get access to all the features.
- data/.document +5 -0
- data/.gitignore +14 -0
- data/.rvmrc +1 -0
- data/Gemfile +20 -0
- data/Gemfile.lock +82 -0
- data/LICENSE +20 -0
- data/README.md +125 -0
- data/Rakefile +110 -0
- data/lib/generators/slugged/slug_migration/slug_migration_generator.rb +24 -0
- data/lib/generators/slugged/slug_migration/templates/migration.erb +12 -0
- data/lib/generators/slugged/slugs/slugs_generator.rb +24 -0
- data/lib/generators/slugged/slugs/templates/migration.erb +20 -0
- data/lib/slugged.rb +80 -0
- data/lib/slugged/active_record_methods.rb +112 -0
- data/lib/slugged/caching.rb +87 -0
- data/lib/slugged/finders.rb +19 -0
- data/lib/slugged/memory_cache.rb +29 -0
- data/lib/slugged/railtie.rb +9 -0
- data/lib/slugged/scopes.rb +13 -0
- data/lib/slugged/slug.rb +36 -0
- data/lib/slugged/slug_history.rb +41 -0
- data/lib/slugged/version.rb +8 -0
- data/test/caching_test.rb +77 -0
- data/test/helper.rb +44 -0
- data/test/is_sluggable_test.rb +155 -0
- data/test/model_definitions.rb +19 -0
- data/test/slug_history_test.rb +86 -0
- data/test/slugged_test.rb +27 -0
- metadata +174 -0
@@ -0,0 +1,20 @@
|
|
1
|
+
class CreateSluggedSlugs < ActiveRecord::Migration
|
2
|
+
|
3
|
+
def self.up
|
4
|
+
create_table :slugs do |t|
|
5
|
+
t.string :scope
|
6
|
+
t.string :slug
|
7
|
+
t.integer :record_id
|
8
|
+
t.datetime :created_at
|
9
|
+
end
|
10
|
+
add_index :slugs, [:scope, :slug]
|
11
|
+
add_index :slugs, [:scope, :record_id]
|
12
|
+
add_index :slugs, [:scope, :slug, :created_at]
|
13
|
+
add_index :slugs, [:scope, :record_id, :created_at]
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.down
|
17
|
+
drop_table :slugs
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
data/lib/slugged.rb
ADDED
@@ -0,0 +1,80 @@
|
|
1
|
+
require 'active_support'
|
2
|
+
require 'active_record'
|
3
|
+
require 'uuid'
|
4
|
+
|
5
|
+
require 'active_support/dependencies/autoload'
|
6
|
+
require 'active_support/core_ext/module/attribute_accessors'
|
7
|
+
require 'active_support/concern'
|
8
|
+
|
9
|
+
module Slugged
|
10
|
+
extend ActiveSupport::Autoload
|
11
|
+
|
12
|
+
mattr_accessor :cache_key_prefix, :cache
|
13
|
+
|
14
|
+
class << self
|
15
|
+
|
16
|
+
def with_counter(prefix, counter = 0)
|
17
|
+
counter < 1 ? prefix : "#{prefix}--#{counter}"
|
18
|
+
end
|
19
|
+
|
20
|
+
def next_value(scope, prefix)
|
21
|
+
counter = 0
|
22
|
+
slug = self.with_counter(prefix, counter)
|
23
|
+
while scope.with_cached_slug(slug).exists?
|
24
|
+
counter += 1
|
25
|
+
slug = self.with_counter(prefix, counter)
|
26
|
+
end
|
27
|
+
slug
|
28
|
+
end
|
29
|
+
|
30
|
+
def uuid
|
31
|
+
@uuid ||= UUID.new
|
32
|
+
end
|
33
|
+
|
34
|
+
def generate_uuid_slug
|
35
|
+
uuid.generate
|
36
|
+
end
|
37
|
+
|
38
|
+
def last_known_slug_id(scope, slug)
|
39
|
+
Slugged::Slug.id_for(Slugged.key_for_scope(scope), slug)
|
40
|
+
end
|
41
|
+
|
42
|
+
def record_slug(record, slug)
|
43
|
+
Slugged::Slug.record_slug(record, slug)
|
44
|
+
end
|
45
|
+
|
46
|
+
def previous_slugs_for(record)
|
47
|
+
Slugged::Slug.previous_for(record)
|
48
|
+
end
|
49
|
+
|
50
|
+
def remove_slug_history_for(record)
|
51
|
+
Slugged::Slug.remove_history_for(record)
|
52
|
+
end
|
53
|
+
|
54
|
+
def key_for_scope(scope)
|
55
|
+
if scope.respond_to?(:slug_scope_key)
|
56
|
+
scope.slug_scope_key
|
57
|
+
elsif scope.class.respond_to?(:slug_scope_key)
|
58
|
+
scope.class.slug_scope_key
|
59
|
+
else
|
60
|
+
scope.to_s
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
end
|
65
|
+
|
66
|
+
self.cache_key_prefix ||= "cached-slugs"
|
67
|
+
|
68
|
+
autoload :Caching
|
69
|
+
autoload :Scopes
|
70
|
+
autoload :Finders
|
71
|
+
autoload :SlugHistory
|
72
|
+
autoload :Slug
|
73
|
+
autoload :MemoryCache
|
74
|
+
|
75
|
+
require 'slugged/active_record_methods'
|
76
|
+
ActiveRecord::Base.extend Slugged::ActiveRecordMethods
|
77
|
+
|
78
|
+
require 'slugged/railtie' if defined?(Rails::Railtie)
|
79
|
+
|
80
|
+
end
|
@@ -0,0 +1,112 @@
|
|
1
|
+
module Slugged
|
2
|
+
module ActiveRecordMethods
|
3
|
+
AR_CLASS_ATTRIBUTE_NAMES = %w(cached_slug_column slug_source slug_convertor_proc default_uuid_slug use_slug_history sync_slugs slug_scope use_slug_cache use_slug_to_param).map(&:to_sym)
|
4
|
+
|
5
|
+
def is_sluggable(source = :name, options = {})
|
6
|
+
options.symbolize_keys!
|
7
|
+
class_attribute *AR_CLASS_ATTRIBUTE_NAMES
|
8
|
+
attr_accessor :found_via_slug
|
9
|
+
# Load extensions
|
10
|
+
extend ClassMethods
|
11
|
+
include InstanceMethods
|
12
|
+
extend Slugged::Scopes
|
13
|
+
extend Slugged::Finders
|
14
|
+
self.slug_source = source.to_sym
|
15
|
+
set_slug_options options
|
16
|
+
alias_method :to_param, :to_slug if use_slug_to_param
|
17
|
+
include Slugged::SlugHistory if use_slug_history
|
18
|
+
include Slugged::Caching if use_slug_cache
|
19
|
+
before_save :autogenerate_slug
|
20
|
+
end
|
21
|
+
|
22
|
+
module InstanceMethods
|
23
|
+
|
24
|
+
def to_slug
|
25
|
+
cached_slug.present? ? cached_slug : id.to_s
|
26
|
+
end
|
27
|
+
|
28
|
+
def generate_slug
|
29
|
+
slug_value = send(self.slug_source)
|
30
|
+
slug_value = self.slug_convertor_proc.call(slug_value) if slug_value.present?
|
31
|
+
if slug_value.present?
|
32
|
+
scope = self.class.other_than(self).slug_scope_relation(self)
|
33
|
+
slug_value = Slugged.next_value(scope, slug_value)
|
34
|
+
write_attribute self.cached_slug_column, slug_value
|
35
|
+
elsif self.default_uuid_slug
|
36
|
+
write_attribute self.cached_slug_column, Slugged.generate_uuid_slug
|
37
|
+
else
|
38
|
+
write_attribute self.cached_slug_column, nil
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def generate_slug!
|
43
|
+
generate_slug
|
44
|
+
save :validate => false
|
45
|
+
end
|
46
|
+
|
47
|
+
def autogenerate_slug
|
48
|
+
generate_slug if should_generate_slug?
|
49
|
+
end
|
50
|
+
|
51
|
+
def should_generate_slug?
|
52
|
+
send(self.cached_slug_column).blank? || (self.sync_slugs && send(:"#{self.slug_source}_changed?"))
|
53
|
+
end
|
54
|
+
|
55
|
+
def has_better_slug?
|
56
|
+
found_via_slug.present? && found_via_slug != to_slug
|
57
|
+
end
|
58
|
+
|
59
|
+
def slug_scope_key(nested_scope = [])
|
60
|
+
self.class.slug_scope_key(nested_scope)
|
61
|
+
end
|
62
|
+
|
63
|
+
end
|
64
|
+
|
65
|
+
module ClassMethods
|
66
|
+
|
67
|
+
def update_all_slugs!
|
68
|
+
find_each { |r| r.generate_slug! }
|
69
|
+
end
|
70
|
+
|
71
|
+
def slug_scope_key(nested_scope = [])
|
72
|
+
([table_name, slug_scope] + Array(nested_scope)).flatten.compact.join("|")
|
73
|
+
end
|
74
|
+
|
75
|
+
def slug_scope_relation(record)
|
76
|
+
has_slug_scope? ? where(slug_scope => record.send(slug_scope)) : scoped
|
77
|
+
end
|
78
|
+
|
79
|
+
protected
|
80
|
+
|
81
|
+
def has_slug_scope?
|
82
|
+
self.slug_scope.present?
|
83
|
+
end
|
84
|
+
|
85
|
+
def set_slug_options(options)
|
86
|
+
set_slug_convertor options[:convertor]
|
87
|
+
self.cached_slug_column = (options[:slug_column] || :cached_slug).to_sym
|
88
|
+
self.slug_scope = options[:scope]
|
89
|
+
self.default_uuid_slug = !!options.fetch(:uuid, true)
|
90
|
+
self.sync_slugs = !!options.fetch(:sync, true)
|
91
|
+
self.use_slug_cache = !!options.fetch(:use_cache, true)
|
92
|
+
self.use_slug_to_param = !!options.fetch(:to_param, true)
|
93
|
+
self.use_slug_history = !!options.fetch(:history, Slugged::Slug.usable?)
|
94
|
+
end
|
95
|
+
|
96
|
+
def set_slug_convertor(convertor)
|
97
|
+
if convertor.present?
|
98
|
+
unless convertor.respond_to?(:call)
|
99
|
+
convertor_key = convertor.to_sym
|
100
|
+
convertor = proc { |r| r.try(convertor_key) }
|
101
|
+
end
|
102
|
+
self.slug_convertor_proc = convertor
|
103
|
+
else
|
104
|
+
self.slug_convertor_proc = proc do |slug|
|
105
|
+
slug.respond_to?(:to_url) ? slug.to_url : ActiveSupport::Multibyte::Chars.new(slug.to_s).parameterize
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
require 'digest/sha2'
|
2
|
+
|
3
|
+
module Slugged
|
4
|
+
# Mixin for adding simple caching support to models using slugged.
|
5
|
+
# Usually included by passing the :cache option as true (by default it is
|
6
|
+
# true, you can disable by passing :cache as false or nil).
|
7
|
+
module Caching
|
8
|
+
extend ActiveSupport::Concern
|
9
|
+
|
10
|
+
mattr_accessor :cache_expires_in
|
11
|
+
# Cache for 10 minutes by default.
|
12
|
+
self.cache_expires_in = 600
|
13
|
+
|
14
|
+
included do
|
15
|
+
after_save :globally_cache_slug
|
16
|
+
end
|
17
|
+
|
18
|
+
module InstanceMethods
|
19
|
+
# Automatically called in after_save, will cache this records id
|
20
|
+
# with to match the current records slug / scope
|
21
|
+
def globally_cache_slug
|
22
|
+
return unless send(:"#{self.cached_slug_column}_changed?")
|
23
|
+
value = self.to_slug
|
24
|
+
self.class.cache_slug_lookup!(value, self) if value.present?
|
25
|
+
unless use_slug_history
|
26
|
+
value = send(:"#{self.cached_slug_column}_was")
|
27
|
+
self.class.cache_slug_lookup!(value, nil)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# Wraps remove_slug_history! to remove each of the slugs
|
32
|
+
# recording in this models slug history.
|
33
|
+
def remove_slug_history!
|
34
|
+
previous_slugs.each { |s| self.class.cache_slug_lookup!(s, nil) }
|
35
|
+
super
|
36
|
+
end
|
37
|
+
|
38
|
+
end
|
39
|
+
|
40
|
+
module ClassMethods
|
41
|
+
|
42
|
+
# Wraps find_using_slug to look in the cache.
|
43
|
+
def find_using_slug(slug, options = {})
|
44
|
+
# First, attempt to load an id and then record from the cache.
|
45
|
+
if (cached_id = lookup_cached_id_from_slug(slug)).present?
|
46
|
+
return find(cached_id, options).tap { |r| r.found_via_slug = slug }
|
47
|
+
end
|
48
|
+
# Otherwise, fallback to the normal approach.
|
49
|
+
super.tap do |record|
|
50
|
+
cache_slug_lookup!(slug, record) if record.present?
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
# Returns a slug cache key for a given slug.
|
55
|
+
def slug_cache_key(slug)
|
56
|
+
[Slugged.cache_key_prefix, slug_scope_key(Digest::SHA256.hexdigest(slug.to_s.strip))].compact.join("/")
|
57
|
+
end
|
58
|
+
|
59
|
+
def has_cache_for_slug?(slug)
|
60
|
+
lookup_cached_id_from_slug(slug).present?
|
61
|
+
end
|
62
|
+
|
63
|
+
# Modify the cache for a given slug. If record is nil, it will
|
64
|
+
# delete the item from the slug cache, otherwise it will store
|
65
|
+
# the records id.
|
66
|
+
def cache_slug_lookup!(slug, record)
|
67
|
+
return if Slugged.cache.blank?
|
68
|
+
cache = Slugged.cache
|
69
|
+
key = slug_cache_key(slug)
|
70
|
+
# Set an expires in option for caching.
|
71
|
+
caching_options = Hash.new.tap do |hash|
|
72
|
+
expiry = Slugged::Caching.cache_expires_in
|
73
|
+
hash[:expires_in] = expiry.to_i if expiry.present?
|
74
|
+
end
|
75
|
+
record.nil? ? cache.delete(key) : cache.write(key, record.id, caching_options)
|
76
|
+
end
|
77
|
+
|
78
|
+
protected
|
79
|
+
|
80
|
+
def lookup_cached_id_from_slug(slug)
|
81
|
+
Slugged.cache && Slugged.cache.read(slug_cache_key(slug))
|
82
|
+
end
|
83
|
+
|
84
|
+
end
|
85
|
+
|
86
|
+
end
|
87
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module Slugged
|
2
|
+
module Finders
|
3
|
+
|
4
|
+
def find_using_slug(slug, options = {})
|
5
|
+
slug = slug.to_s
|
6
|
+
value = nil
|
7
|
+
value ||= find_by_id(slug.to_i, options) if slug =~ /\A\d+\Z/
|
8
|
+
value ||= with_cached_slug(slug).first(options)
|
9
|
+
value ||= find_using_slug_history(slug, options) if use_slug_history
|
10
|
+
value.found_via_slug = slug if value.present?
|
11
|
+
value
|
12
|
+
end
|
13
|
+
|
14
|
+
def find_using_slug!(slug, options = {})
|
15
|
+
find_using_slug(slug, options) or raise ActiveRecord::RecordNotFound
|
16
|
+
end
|
17
|
+
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module Slugged
|
2
|
+
# Implements a simple cache store that uses the
|
3
|
+
# current processes memory. This makes is primarily
|
4
|
+
# used for testing purposes in the situations where
|
5
|
+
# caching is used.
|
6
|
+
class MemoryCache
|
7
|
+
|
8
|
+
def self.write(key, value, options = {})
|
9
|
+
cache[key.to_s] = value
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.read(key)
|
13
|
+
cache[key.to_s]
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.delete(key)
|
17
|
+
cache.delete key.to_s
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.reset!
|
21
|
+
@cache = nil
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.cache
|
25
|
+
@cache ||= {}
|
26
|
+
end
|
27
|
+
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
module Slugged
|
2
|
+
module Scopes
|
3
|
+
|
4
|
+
def with_cached_slug(slug)
|
5
|
+
where(self.cached_slug_column => slug.to_s)
|
6
|
+
end
|
7
|
+
|
8
|
+
def other_than(record)
|
9
|
+
record.new_record? ? scoped : where("#{quoted_table_name}.#{connection.quote_column_name(:id)} != ?", record.id)
|
10
|
+
end
|
11
|
+
|
12
|
+
end
|
13
|
+
end
|
data/lib/slugged/slug.rb
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
module Slugged
|
2
|
+
class Slug < ActiveRecord::Base
|
3
|
+
set_table_name "slugs"
|
4
|
+
|
5
|
+
validates_presence_of :record_id, :slug, :scope
|
6
|
+
|
7
|
+
scope :ordered, order('created_at DESC')
|
8
|
+
scope :only_slug, select(:slug)
|
9
|
+
scope :for_record, lambda { |r| where(:record_id => r.id, :scope => Slugged.key_for_scope(r)) }
|
10
|
+
scope :for_slug, lambda { |scope, slug| where(:scope=> scope.to_s, :slug => slug.to_s)}
|
11
|
+
|
12
|
+
def self.id_for(scope, slug)
|
13
|
+
ordered.for_slug(scope, slug).first.try(:record_id)
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.record_slug(record, slug)
|
17
|
+
scope = Slugged.key_for_scope(record)
|
18
|
+
# Clear slug history in this scope before recording the new slug
|
19
|
+
for_slug(scope, slug).delete_all
|
20
|
+
create :scope => scope, :record_id => record.id, :slug => slug.to_s
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.previous_for(record)
|
24
|
+
ordered.only_slug.for_record(record).all.map(&:slug)
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.remove_history_for(record)
|
28
|
+
for_record(record).delete_all
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.usable?
|
32
|
+
table_exists? rescue false
|
33
|
+
end
|
34
|
+
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
module Slugged
|
2
|
+
module SlugHistory
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
included do
|
6
|
+
after_save :record_slug_changes
|
7
|
+
after_destroy :remove_slug_history!
|
8
|
+
end
|
9
|
+
|
10
|
+
module InstanceMethods
|
11
|
+
|
12
|
+
def previous_slugs
|
13
|
+
Slugged.previous_slugs_for(self)
|
14
|
+
end
|
15
|
+
|
16
|
+
def remove_slug_history!
|
17
|
+
Slugged.remove_slug_history_for(self)
|
18
|
+
end
|
19
|
+
|
20
|
+
protected
|
21
|
+
|
22
|
+
def record_slug_changes
|
23
|
+
slug_column = self.cached_slug_column
|
24
|
+
return unless send(:"#{slug_column}_changed?")
|
25
|
+
value = send(:"#{slug_column}_was")
|
26
|
+
Slugged.record_slug(self, value) if value.present?
|
27
|
+
end
|
28
|
+
|
29
|
+
end
|
30
|
+
|
31
|
+
module ClassMethods
|
32
|
+
|
33
|
+
def find_using_slug_history(slug, options = {})
|
34
|
+
id = Slugged.last_known_slug_id(self, slug)
|
35
|
+
id.present? ? find_by_id(id, options) : nil
|
36
|
+
end
|
37
|
+
|
38
|
+
end
|
39
|
+
|
40
|
+
end
|
41
|
+
end
|