slugged 0.3.2

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