slugged 0.3.2
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/.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
|