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.
@@ -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,9 @@
1
+ module Slugged
2
+ class Railtie < Rails::Railtie
3
+
4
+ initializer "slugged.initialize_cache" do
5
+ Slugged.cache = Rails.cache if Rails.cache.present?
6
+ end
7
+
8
+ end
9
+ 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
@@ -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