mongoid-slug 5.3.0 → 6.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (50) hide show
  1. checksums.yaml +5 -5
  2. data/LICENSE +20 -20
  3. data/README.md +361 -360
  4. data/lib/mongoid/slug.rb +328 -366
  5. data/lib/mongoid/slug/criteria.rb +107 -107
  6. data/lib/mongoid/slug/{index.rb → index_builder.rb} +67 -51
  7. data/lib/mongoid/slug/railtie.rb +9 -9
  8. data/lib/mongoid/slug/slug_id_strategy.rb +3 -3
  9. data/lib/mongoid/slug/unique_slug.rb +173 -154
  10. data/lib/mongoid/slug/version.rb +5 -5
  11. data/lib/mongoid_slug.rb +2 -2
  12. data/lib/tasks/mongoid_slug.rake +15 -19
  13. data/spec/models/alias.rb +6 -6
  14. data/spec/models/article.rb +9 -9
  15. data/spec/models/artist.rb +8 -8
  16. data/spec/models/artwork.rb +10 -10
  17. data/spec/models/author.rb +15 -15
  18. data/spec/models/author_polymorphic.rb +15 -15
  19. data/spec/models/book.rb +12 -12
  20. data/spec/models/book_polymorphic.rb +12 -12
  21. data/spec/models/caption.rb +17 -17
  22. data/spec/models/entity.rb +11 -12
  23. data/spec/models/friend.rb +7 -7
  24. data/spec/models/incorrect_slug_persistence.rb +9 -9
  25. data/spec/models/integer_id.rb +9 -9
  26. data/spec/models/magazine.rb +7 -7
  27. data/spec/models/page.rb +9 -9
  28. data/spec/models/page_localize.rb +9 -9
  29. data/spec/models/page_slug_localized.rb +9 -9
  30. data/spec/models/page_slug_localized_custom.rb +10 -10
  31. data/spec/models/page_slug_localized_history.rb +9 -9
  32. data/spec/models/partner.rb +7 -7
  33. data/spec/models/person.rb +12 -12
  34. data/spec/models/relationship.rb +8 -8
  35. data/spec/models/string_id.rb +9 -9
  36. data/spec/models/subject.rb +7 -7
  37. data/spec/models/without_slug.rb +5 -5
  38. data/spec/mongoid/criteria_spec.rb +207 -208
  39. data/spec/mongoid/index_builder_spec.rb +105 -0
  40. data/spec/mongoid/slug_spec.rb +1175 -1170
  41. data/spec/shared/indexes.rb +41 -41
  42. data/spec/spec_helper.rb +61 -67
  43. data/spec/tasks/mongoid_slug_rake_spec.rb +73 -73
  44. metadata +14 -24
  45. data/lib/mongoid/slug/paranoia.rb +0 -20
  46. data/spec/models/document_paranoid.rb +0 -9
  47. data/spec/models/paranoid_document.rb +0 -8
  48. data/spec/models/paranoid_permanent.rb +0 -10
  49. data/spec/mongoid/index_spec.rb +0 -34
  50. data/spec/mongoid/paranoia_spec.rb +0 -230
@@ -1,107 +1,107 @@
1
- module Mongoid
2
- module Slug
3
- class Criteria < Mongoid::Criteria
4
- # Find the matchind document(s) in the criteria for the provided ids or slugs.
5
- #
6
- # If the document _ids are of the type BSON::ObjectId, and all the supplied parameters are
7
- # convertible to BSON::ObjectId (via BSON::ObjectId#from_string), finding will be
8
- # performed via _ids.
9
- #
10
- # If the document has any other type of _id field, and all the supplied parameters are of the same
11
- # type, finding will be performed via _ids.
12
- #
13
- # Otherwise finding will be performed via slugs.
14
- #
15
- # @example Find by an id.
16
- # criteria.find(BSON::ObjectId.new)
17
- #
18
- # @example Find by multiple ids.
19
- # criteria.find([ BSON::ObjectId.new, BSON::ObjectId.new ])
20
- #
21
- # @example Find by a slug.
22
- # criteria.find('some-slug')
23
- #
24
- # @example Find by multiple slugs.
25
- # criteria.find([ 'some-slug', 'some-other-slug' ])
26
- #
27
- # @param [ Array<Object> ] args The ids or slugs to search for.
28
- #
29
- # @return [ Array<Document>, Document ] The matching document(s).
30
- def find(*args)
31
- look_like_slugs?(args.__find_args__) ? find_by_slug!(*args) : super
32
- end
33
-
34
- # Find the matchind document(s) in the criteria for the provided slugs.
35
- #
36
- # @example Find by a slug.
37
- # criteria.find('some-slug')
38
- #
39
- # @example Find by multiple slugs.
40
- # criteria.find([ 'some-slug', 'some-other-slug' ])
41
- #
42
- # @param [ Array<Object> ] args The slugs to search for.
43
- #
44
- # @return [ Array<Document>, Document ] The matching document(s).
45
- def find_by_slug!(*args)
46
- slugs = args.__find_args__
47
- raise_invalid if slugs.any?(&:nil?)
48
- for_slugs(slugs).execute_or_raise_for_slugs(slugs, args.multi_arged?)
49
- end
50
-
51
- def look_like_slugs?(args)
52
- return false unless args.all? { |id| id.is_a?(String) }
53
- id_field = @klass.fields['_id']
54
- @slug_strategy ||= id_field.options[:slug_id_strategy] || build_slug_strategy(id_field.type)
55
- args.none? { |id| @slug_strategy.call(id) }
56
- end
57
-
58
- protected
59
-
60
- # unless a :slug_id_strategy option is defined on the id field,
61
- # use object_id or string strategy depending on the id_type
62
- # otherwise default for all other id_types
63
- def build_slug_strategy(id_type)
64
- type_method = id_type.to_s.downcase.split('::').last + '_slug_strategy'
65
- respond_to?(type_method, true) ? method(type_method) : ->(_id) { false }
66
- end
67
-
68
- # a string will not look like a slug if it looks like a legal BSON::ObjectId
69
- def objectid_slug_strategy(id)
70
- Mongoid::Compatibility::ObjectId.legal?(id)
71
- end
72
-
73
- # a string will always look like a slug
74
- def string_slug_strategy(_id)
75
- true
76
- end
77
-
78
- def for_slugs(slugs)
79
- # _translations
80
- localized = (begin
81
- @klass.fields['_slugs'].options[:localize]
82
- rescue
83
- false
84
- end)
85
- if localized
86
- def_loc = I18n.default_locale
87
- query = { '$in' => slugs }
88
- where({ '$or' => [{ _slugs: query }, { "_slugs.#{def_loc}" => query }] }).limit(slugs.length)
89
- else
90
- where(_slugs: { '$in' => slugs }).limit(slugs.length)
91
- end
92
- end
93
-
94
- def execute_or_raise_for_slugs(slugs, multi)
95
- result = uniq
96
- check_for_missing_documents_for_slugs!(result, slugs)
97
- multi ? result : result.first
98
- end
99
-
100
- def check_for_missing_documents_for_slugs!(result, slugs)
101
- missing_slugs = slugs - result.map(&:slugs).flatten
102
- return unless !missing_slugs.blank? && Mongoid.raise_not_found_error
103
- raise Errors::DocumentNotFound.new(klass, slugs, missing_slugs)
104
- end
105
- end
106
- end
107
- end
1
+ module Mongoid
2
+ module Slug
3
+ class Criteria < Mongoid::Criteria
4
+ # Find the matching document(s) in the criteria for the provided ids or slugs.
5
+ #
6
+ # If the document _ids are of the type BSON::ObjectId, and all the supplied parameters are
7
+ # convertible to BSON::ObjectId (via BSON::ObjectId#from_string), finding will be
8
+ # performed via _ids.
9
+ #
10
+ # If the document has any other type of _id field, and all the supplied parameters are of the same
11
+ # type, finding will be performed via _ids.
12
+ #
13
+ # Otherwise finding will be performed via slugs.
14
+ #
15
+ # @example Find by an id.
16
+ # criteria.find(BSON::ObjectId.new)
17
+ #
18
+ # @example Find by multiple ids.
19
+ # criteria.find([ BSON::ObjectId.new, BSON::ObjectId.new ])
20
+ #
21
+ # @example Find by a slug.
22
+ # criteria.find('some-slug')
23
+ #
24
+ # @example Find by multiple slugs.
25
+ # criteria.find([ 'some-slug', 'some-other-slug' ])
26
+ #
27
+ # @param [ Array<Object> ] args The ids or slugs to search for.
28
+ #
29
+ # @return [ Array<Document>, Document ] The matching document(s).
30
+ def find(*args)
31
+ look_like_slugs?(args.__find_args__) ? find_by_slug!(*args) : super
32
+ end
33
+
34
+ # Find the matchind document(s) in the criteria for the provided slugs.
35
+ #
36
+ # @example Find by a slug.
37
+ # criteria.find('some-slug')
38
+ #
39
+ # @example Find by multiple slugs.
40
+ # criteria.find([ 'some-slug', 'some-other-slug' ])
41
+ #
42
+ # @param [ Array<Object> ] args The slugs to search for.
43
+ #
44
+ # @return [ Array<Document>, Document ] The matching document(s).
45
+ def find_by_slug!(*args)
46
+ slugs = args.__find_args__
47
+ raise_invalid if slugs.any?(&:nil?)
48
+ for_slugs(slugs).execute_or_raise_for_slugs(slugs, args.multi_arged?)
49
+ end
50
+
51
+ def look_like_slugs?(args)
52
+ return false unless args.all? { |id| id.is_a?(String) }
53
+ id_field = @klass.fields['_id']
54
+ @slug_strategy ||= id_field.options[:slug_id_strategy] || build_slug_strategy(id_field.type)
55
+ args.none? { |id| @slug_strategy.call(id) }
56
+ end
57
+
58
+ protected
59
+
60
+ # unless a :slug_id_strategy option is defined on the id field,
61
+ # use object_id or string strategy depending on the id_type
62
+ # otherwise default for all other id_types
63
+ def build_slug_strategy(id_type)
64
+ type_method = id_type.to_s.downcase.split('::').last + '_slug_strategy'
65
+ respond_to?(type_method, true) ? method(type_method) : ->(_id) { false }
66
+ end
67
+
68
+ # a string will not look like a slug if it looks like a legal BSON::ObjectId
69
+ def objectid_slug_strategy(id)
70
+ Mongoid::Compatibility::ObjectId.legal?(id)
71
+ end
72
+
73
+ # a string will always look like a slug
74
+ def string_slug_strategy(_id)
75
+ true
76
+ end
77
+
78
+ def for_slugs(slugs)
79
+ # _translations
80
+ localized = (begin
81
+ @klass.fields['_slugs'].options[:localize]
82
+ rescue StandardError
83
+ false
84
+ end)
85
+ if localized
86
+ def_loc = I18n.default_locale
87
+ query = { '$in' => slugs }
88
+ where({ '$or' => [{ _slugs: query }, { "_slugs.#{def_loc}" => query }] }).limit(slugs.length)
89
+ else
90
+ where(_slugs: { '$in' => slugs }).limit(slugs.length)
91
+ end
92
+ end
93
+
94
+ def execute_or_raise_for_slugs(slugs, multi)
95
+ result = uniq
96
+ check_for_missing_documents_for_slugs!(result, slugs)
97
+ multi ? result : result.first
98
+ end
99
+
100
+ def check_for_missing_documents_for_slugs!(result, slugs)
101
+ missing_slugs = slugs - result.map(&:slugs).flatten
102
+ return unless !missing_slugs.blank? && Mongoid.raise_not_found_error
103
+ raise Errors::DocumentNotFound.new(klass, slugs, missing_slugs)
104
+ end
105
+ end
106
+ end
107
+ end
@@ -1,51 +1,67 @@
1
- module Mongoid
2
- module Slug
3
- module Index
4
- # @param [ String or Symbol ] scope_key The optional scope key for the index
5
- # @param [ Boolean ] by_model_type Whether or not
6
- #
7
- # @return [ Array(Hash, Hash) ] the indexable fields and index options.
8
- def self.build_index(scope_key = nil, by_model_type = false)
9
- # The order of field keys is intentional.
10
- # See: http://docs.mongodb.org/manual/core/index-compound/
11
- fields = {}
12
- fields[:_type] = 1 if by_model_type
13
- fields[scope_key] = 1 if scope_key
14
- fields[:_slugs] = 1
15
-
16
- # By design, we use the unique index constraint when possible to enforce slug uniqueness.
17
- # When migrating legacy data to Mongoid slug, the _slugs field may be null on many records,
18
- # hence we set the sparse index option to ignore these from the unique index.
19
- # See: http://docs.mongodb.org/manual/core/index-sparse/
20
- #
21
- # There are three edge cases where the index must not be unique:
22
- #
23
- # 1) Legacy tables with `scope_key`. The sparse indexes on compound keys (scope + _slugs) are
24
- # whenever ANY of the key values are present (e.g. when scope is set and _slugs is unset),
25
- # and collisions will occur when multiple records have the same scope but null slugs.
26
- #
27
- # 2) Single Table Inheritance (`by_model_type`). MongoDB creates indexes on the parent collection,
28
- # irrespective of how STI is defined in Mongoid, i.e. ANY child index will be applied to EVERY child.
29
- # This can cause collisions using various combinations of scopes.
30
- #
31
- # 3) Paranoid docs rely on sparse indexes to exclude paranoid-deleted records
32
- # from the unique index constraint (i.e. when _slugs is unset.) However, when
33
- # using compound keys (`by_model_type` or `scope_key`), paranoid-deleted records
34
- # can become inadvertently indexed when _slugs is unset, causing duplicates. This
35
- # is already covered by #1 and #2 above.
36
- #
37
- # In the future, MongoDB may implement partial indexes or improve sparse index behavior.
38
- # See: https://jira.mongodb.org/browse/SERVER-785
39
- # https://jira.mongodb.org/browse/SERVER-13780
40
- # https://jira.mongodb.org/browse/SERVER-10403
41
- options = {}
42
- unless scope_key || by_model_type
43
- options[:unique] = true
44
- options[:sparse] = true
45
- end
46
-
47
- [fields, options]
48
- end
49
- end
50
- end
51
- end
1
+ module Mongoid
2
+ module Slug
3
+ module IndexBuilder
4
+ extend self
5
+
6
+ # Creates indexes on a document for a given slug scope
7
+ #
8
+ # @param [ Mongoid::Document ] doc The document on which to create the index(es)
9
+ # @param [ String or Symbol ] scope_key The optional scope key for the index(es)
10
+ # @param [ Boolean ] by_model_type Whether or not to use single table inheritance
11
+ # @param [ Boolean or Array ] localize The locale for localized index field
12
+ #
13
+ # @return [ Array(Hash, Hash) ] the indexable fields and index options.
14
+ def build_indexes(doc, scope_key = nil, by_model_type = false, locales = nil)
15
+ if locales.is_a?(Array)
16
+ locales.each { |locale| build_index(doc, scope_key, by_model_type, locale) }
17
+ else
18
+ build_index(doc, scope_key, by_model_type, locales)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def build_index(doc, scope_key = nil, by_model_type = false, locale = nil)
25
+ # The order of field keys is intentional.
26
+ # See: http://docs.mongodb.org/manual/core/index-compound/
27
+ fields = {}
28
+ fields[:_type] = 1 if by_model_type
29
+ fields[scope_key] = 1 if scope_key
30
+
31
+ locale = ::I18n.default_locale if locale.is_a?(TrueClass)
32
+ if locale
33
+ fields[:"_slugs.#{locale}"] = 1
34
+ else
35
+ fields[:_slugs] = 1
36
+ end
37
+
38
+ # By design, we use the unique index constraint when possible to enforce slug uniqueness.
39
+ # When migrating legacy data to Mongoid slug, the _slugs field may be null on many records,
40
+ # hence we set the sparse index option to ignore these from the unique index.
41
+ # See: http://docs.mongodb.org/manual/core/index-sparse/
42
+ #
43
+ # There are three edge cases where the index must not be unique:
44
+ #
45
+ # 1) Legacy tables with `scope_key`. The sparse indexes on compound keys (scope + _slugs) are
46
+ # whenever ANY of the key values are present (e.g. when scope is set and _slugs is unset),
47
+ # and collisions will occur when multiple records have the same scope but null slugs.
48
+ #
49
+ # 2) Single Table Inheritance (`by_model_type`). MongoDB creates indexes on the parent collection,
50
+ # irrespective of how STI is defined in Mongoid, i.e. ANY child index will be applied to EVERY child.
51
+ # This can cause collisions using various combinations of scopes.
52
+ #
53
+ # In the future, MongoDB may implement partial indexes or improve sparse index behavior.
54
+ # See: https://jira.mongodb.org/browse/SERVER-785
55
+ # https://jira.mongodb.org/browse/SERVER-13780
56
+ # https://jira.mongodb.org/browse/SERVER-10403
57
+ options = {}
58
+ unless scope_key || by_model_type
59
+ options[:unique] = true
60
+ options[:sparse] = true
61
+ end
62
+
63
+ doc.index(fields, options)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -1,9 +1,9 @@
1
- module Mongoid
2
- module Slug
3
- class Railtie < Rails::Railtie
4
- rake_tasks do
5
- Dir[File.join(File.dirname(__FILE__), '../../tasks/*.rake')].each { |f| load f }
6
- end
7
- end
8
- end
9
- end
1
+ module Mongoid
2
+ module Slug
3
+ class Railtie < Rails::Railtie
4
+ rake_tasks do
5
+ Dir[File.join(File.dirname(__FILE__), '../../tasks/*.rake')].each { |f| load f }
6
+ end
7
+ end
8
+ end
9
+ end
@@ -1,3 +1,3 @@
1
- Mongoid::Fields.option(:slug_id_strategy) do |_model, field, value|
2
- field.options[:slug_id_strategy] = value
3
- end
1
+ Mongoid::Fields.option(:slug_id_strategy) do |_model, field, value|
2
+ field.options[:slug_id_strategy] = value
3
+ end
@@ -1,154 +1,173 @@
1
- require 'forwardable'
2
-
3
- # Can use e.g. Mongoid::Slug::UniqueSlug.new(ModelClass.new).find_unique "slug-1" for auto-suggest ui
4
- module Mongoid
5
- module Slug
6
- class UniqueSlug
7
- MUTEX_FOR_SLUG = Mutex.new
8
- class SlugState
9
- attr_reader :last_entered_slug, :existing_slugs, :existing_history_slugs, :sorted_existing
10
-
11
- def initialize(slug, documents, pattern)
12
- @slug = slug
13
- @documents = documents
14
- @pattern = pattern
15
- @last_entered_slug = []
16
- @existing_slugs = []
17
- @existing_history_slugs = []
18
- @sorted_existing = []
19
- @documents.each do |doc|
20
- history_slugs = doc._slugs
21
- next if history_slugs.nil?
22
- existing_slugs.push(*history_slugs.find_all { |cur_slug| cur_slug =~ @pattern })
23
- last_entered_slug.push(*history_slugs.last) if history_slugs.last =~ @pattern
24
- existing_history_slugs.push(*history_slugs.first(history_slugs.length - 1).find_all { |cur_slug| cur_slug =~ @pattern })
25
- end
26
- end
27
-
28
- def slug_included?
29
- existing_slugs.include? @slug
30
- end
31
-
32
- def include_slug
33
- existing_slugs << @slug
34
- end
35
-
36
- def highest_existing_counter
37
- sort_existing_slugs
38
- @sorted_existing.last || 0
39
- end
40
-
41
- def sort_existing_slugs
42
- # remove the slug part and leave the absolute integer part and sort
43
- re = /^#{Regexp.escape(@slug)}/
44
- @sorted_existing = existing_slugs.map do |s|
45
- s.sub(re, '').to_i.abs
46
- end.sort
47
- end
48
-
49
- def inspect
50
- {
51
- slug: @slug,
52
- existing_slugs: existing_slugs,
53
- last_entered_slug: last_entered_slug,
54
- existing_history_slugs: existing_history_slugs,
55
- sorted_existing: sorted_existing
56
- }
57
- end
58
- end
59
-
60
- extend Forwardable
61
-
62
- attr_reader :model, :_slug
63
-
64
- def_delegators :@model, :slug_scope, :reflect_on_association, :read_attribute,
65
- :check_against_id, :slug_reserved_words, :slug_url_builder, :collection_name,
66
- :embedded?, :reflect_on_all_associations, :slug_by_model_type, :slug_max_length
67
-
68
- def initialize(model)
69
- @model = model
70
- @_slug = ''
71
- @state = nil
72
- end
73
-
74
- def metadata
75
- @model.respond_to?(:relation_metadata) ? @model.relation_metadata : @model.metadata
76
- end
77
-
78
- def find_unique(attempt = nil)
79
- MUTEX_FOR_SLUG.synchronize do
80
- @_slug = if attempt
81
- attempt.to_url
82
- else
83
- slug_url_builder.call(model)
84
- end
85
-
86
- @_slug = @_slug[0...slug_max_length] if slug_max_length
87
-
88
- # Regular expression that matches slug, slug-1, ... slug-n
89
- # If slug_name field was indexed, MongoDB will utilize that
90
- # index to match /^.../ pattern.
91
- pattern = /^#{Regexp.escape(@_slug)}(?:-(\d+))?$/
92
-
93
- where_hash = {}
94
- where_hash[:_slugs.all] = [pattern]
95
- where_hash[:_id.ne] = model._id
96
-
97
- if (scope = slug_scope) && reflect_on_association(scope).nil?
98
- # scope is not an association, so it's scoped to a local field
99
- # (e.g. an association id in a denormalized db design)
100
- where_hash[scope] = model.try(:read_attribute, scope)
101
- end
102
-
103
- if slug_by_model_type == true
104
- where_hash[:_type] = model.try(:read_attribute, :_type)
105
- end
106
-
107
- @state = SlugState.new @_slug, uniqueness_scope.unscoped.where(where_hash), pattern
108
-
109
- # do not allow a slug that can be interpreted as the current document id
110
- @state.include_slug unless model.class.look_like_slugs?([@_slug])
111
-
112
- # make sure that the slug is not equal to a reserved word
113
- @state.include_slug if slug_reserved_words.any? { |word| word === @_slug }
114
-
115
- # only look for a new unique slug if the existing slugs contains the current slug
116
- # - e.g if the slug 'foo-2' is taken, but 'foo' is available, the user can use 'foo'.
117
- if @state.slug_included?
118
- highest = @state.highest_existing_counter
119
- @_slug += "-#{highest.succ}"
120
- end
121
- @_slug
122
- end
123
- end
124
-
125
- def uniqueness_scope
126
- if slug_scope && (metadata = reflect_on_association(slug_scope))
127
-
128
- parent = model.send(metadata.name)
129
-
130
- # Make sure doc is actually associated with something, and that
131
- # some referenced docs have been persisted to the parent
132
- #
133
- # TODO: we need better reflection for reference associations,
134
- # like association_name instead of forcing collection_name here
135
- # -- maybe in the forthcoming Mongoid refactorings?
136
- inverse = metadata.inverse_of || collection_name
137
- return parent.respond_to?(inverse) ? parent.send(inverse) : model.class
138
- end
139
-
140
- if embedded?
141
- parent_metadata = reflect_on_all_associations(:embedded_in)[0]
142
- return model._parent.send(parent_metadata.inverse_of || self.metadata.name)
143
- end
144
-
145
- # unless embedded or slug scope, return the deepest document superclass
146
- appropriate_class = model.class
147
- while appropriate_class.superclass.include?(Mongoid::Document)
148
- appropriate_class = appropriate_class.superclass
149
- end
150
- appropriate_class
151
- end
152
- end
153
- end
154
- end
1
+ require 'forwardable'
2
+
3
+ # Can use e.g. Mongoid::Slug::UniqueSlug.new(ModelClass.new).find_unique "slug-1" for auto-suggest ui
4
+ module Mongoid
5
+ module Slug
6
+ class UniqueSlug
7
+ MUTEX_FOR_SLUG = Mutex.new
8
+ class SlugState
9
+ attr_reader :last_entered_slug, :existing_slugs, :existing_history_slugs, :sorted_existing
10
+
11
+ def initialize(slug, documents, pattern)
12
+ @slug = slug
13
+ @documents = documents
14
+ @pattern = pattern
15
+ @last_entered_slug = []
16
+ @existing_slugs = []
17
+ @existing_history_slugs = []
18
+ @sorted_existing = []
19
+ regexp_pattern = Regexp.new(@pattern)
20
+ @documents.each do |doc|
21
+ history_slugs = doc._slugs
22
+ next if history_slugs.nil?
23
+ existing_slugs.push(*history_slugs.find_all { |cur_slug| cur_slug =~ regexp_pattern })
24
+ last_entered_slug.push(*history_slugs.last) if history_slugs.last =~ regexp_pattern
25
+ existing_history_slugs.push(*history_slugs.first(history_slugs.length - 1).find_all { |cur_slug| cur_slug =~ regexp_pattern })
26
+ end
27
+ end
28
+
29
+ def slug_included?
30
+ existing_slugs.include? @slug
31
+ end
32
+
33
+ def include_slug
34
+ existing_slugs << @slug
35
+ end
36
+
37
+ def highest_existing_counter
38
+ sort_existing_slugs
39
+ @sorted_existing.last || 0
40
+ end
41
+
42
+ def sort_existing_slugs
43
+ # remove the slug part and leave the absolute integer part and sort
44
+ re = /^#{Regexp.escape(@slug)}/
45
+ @sorted_existing = existing_slugs.map do |s|
46
+ s.sub(re, '').to_i.abs
47
+ end.sort
48
+ end
49
+
50
+ def inspect
51
+ {
52
+ slug: @slug,
53
+ existing_slugs: existing_slugs,
54
+ last_entered_slug: last_entered_slug,
55
+ existing_history_slugs: existing_history_slugs,
56
+ sorted_existing: sorted_existing
57
+ }
58
+ end
59
+ end
60
+
61
+ extend Forwardable
62
+
63
+ attr_reader :model, :_slug
64
+
65
+ def_delegators :@model, :slug_scope, :reflect_on_association, :read_attribute,
66
+ :check_against_id, :slug_reserved_words, :slug_url_builder, :collection_name,
67
+ :embedded?, :reflect_on_all_associations, :reflect_on_all_association,
68
+ :slug_by_model_type, :slug_max_length
69
+
70
+ def initialize(model)
71
+ @model = model
72
+ @_slug = ''
73
+ @state = nil
74
+ end
75
+
76
+ def metadata
77
+ if @model.respond_to?(:_association)
78
+ @model.send(:_association)
79
+ elsif @model.respond_to?(:relation_metadata)
80
+ @model.relation_metadata
81
+ else
82
+ @model.metadata
83
+ end
84
+ end
85
+
86
+ def find_unique(attempt = nil)
87
+ MUTEX_FOR_SLUG.synchronize do
88
+ @_slug = if attempt
89
+ attempt.to_url
90
+ else
91
+ slug_url_builder.call(model)
92
+ end
93
+
94
+ @_slug = @_slug[0...slug_max_length] if slug_max_length
95
+
96
+ where_hash = {}
97
+ where_hash[:_slugs.all] = [regex_for_slug]
98
+ where_hash[:_id.ne] = model._id
99
+
100
+ if (scope = slug_scope) && reflect_on_association(scope).nil?
101
+ # scope is not an association, so it's scoped to a local field
102
+ # (e.g. an association id in a denormalized db design)
103
+ where_hash[scope] = model.try(:read_attribute, scope)
104
+ end
105
+
106
+ where_hash[:_type] = model.try(:read_attribute, :_type) if slug_by_model_type
107
+
108
+ @state = SlugState.new @_slug, uniqueness_scope.unscoped.where(where_hash), escaped_pattern
109
+
110
+ # do not allow a slug that can be interpreted as the current document id
111
+ @state.include_slug unless model.class.look_like_slugs?([@_slug])
112
+
113
+ # make sure that the slug is not equal to a reserved word
114
+ @state.include_slug if slug_reserved_words.any? { |word| word === @_slug }
115
+
116
+ # only look for a new unique slug if the existing slugs contains the current slug
117
+ # - e.g if the slug 'foo-2' is taken, but 'foo' is available, the user can use 'foo'.
118
+ if @state.slug_included?
119
+ highest = @state.highest_existing_counter
120
+ @_slug += "-#{highest.succ}"
121
+ end
122
+ @_slug
123
+ end
124
+ end
125
+
126
+ def escaped_pattern
127
+ "^#{Regexp.escape(@_slug)}(?:-(\\d+))?$"
128
+ end
129
+
130
+ # Regular expression that matches slug, slug-1, ... slug-n
131
+ # If slug_name field was indexed, MongoDB will utilize that
132
+ # index to match /^.../ pattern.
133
+ # Use Regexp::Raw to avoid the multiline option when querying the server.
134
+ def regex_for_slug
135
+ if embedded? || Mongoid::Compatibility::Version.mongoid3? || Mongoid::Compatibility::Version.mongoid4?
136
+ Regexp.new(escaped_pattern)
137
+ else
138
+ BSON::Regexp::Raw.new(escaped_pattern)
139
+ end
140
+ end
141
+
142
+ def uniqueness_scope
143
+ if slug_scope && (metadata = reflect_on_association(slug_scope))
144
+
145
+ parent = model.send(metadata.name)
146
+
147
+ # Make sure doc is actually associated with something, and that
148
+ # some referenced docs have been persisted to the parent
149
+ #
150
+ # TODO: we need better reflection for reference associations,
151
+ # like association_name instead of forcing collection_name here
152
+ # -- maybe in the forthcoming Mongoid refactorings?
153
+ inverse = metadata.inverse_of || collection_name
154
+ return parent.respond_to?(inverse) ? parent.send(inverse) : model.class
155
+ end
156
+
157
+ if embedded?
158
+ parent_metadata = if Mongoid::Compatibility::Version.mongoid7_or_newer?
159
+ reflect_on_all_association(:embedded_in)[0]
160
+ else
161
+ reflect_on_all_associations(:embedded_in)[0]
162
+ end
163
+ return model._parent.send(parent_metadata.inverse_of || self.metadata.name)
164
+ end
165
+
166
+ # unless embedded or slug scope, return the deepest document superclass
167
+ appropriate_class = model.class
168
+ appropriate_class = appropriate_class.superclass while appropriate_class.superclass.include?(Mongoid::Document)
169
+ appropriate_class
170
+ end
171
+ end
172
+ end
173
+ end