mongoid-slug 6.0.0 → 6.0.1

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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +20 -20
  3. data/README.md +361 -336
  4. data/lib/mongoid/slug.rb +328 -328
  5. data/lib/mongoid/slug/criteria.rb +107 -107
  6. data/lib/mongoid/slug/{index.rb → index_builder.rb} +67 -45
  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 -173
  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 -11
  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 -207
  39. data/spec/mongoid/index_builder_spec.rb +105 -0
  40. data/spec/mongoid/slug_spec.rb +1175 -1169
  41. data/spec/shared/indexes.rb +41 -41
  42. data/spec/spec_helper.rb +61 -61
  43. data/spec/tasks/mongoid_slug_rake_spec.rb +73 -73
  44. metadata +31 -32
  45. data/spec/mongoid/index_spec.rb +0 -33
@@ -1,107 +1,107 @@
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
+ 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,45 +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
- # In the future, MongoDB may implement partial indexes or improve sparse index behavior.
32
- # See: https://jira.mongodb.org/browse/SERVER-785
33
- # https://jira.mongodb.org/browse/SERVER-13780
34
- # https://jira.mongodb.org/browse/SERVER-10403
35
- options = {}
36
- unless scope_key || by_model_type
37
- options[:unique] = true
38
- options[:sparse] = true
39
- end
40
-
41
- [fields, options]
42
- end
43
- end
44
- end
45
- 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,173 +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
- 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
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