mongoid-slug 6.0.1 → 7.1.0

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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +20 -20
  3. data/README.md +408 -361
  4. data/lib/mongoid/slug/criteria.rb +120 -107
  5. data/lib/mongoid/slug/index_builder.rb +70 -67
  6. data/lib/mongoid/slug/railtie.rb +11 -9
  7. data/lib/mongoid/slug/slug_id_strategy.rb +5 -3
  8. data/lib/mongoid/slug/unique_slug.rb +204 -173
  9. data/lib/mongoid/slug/version.rb +7 -5
  10. data/lib/mongoid/slug.rb +343 -328
  11. data/lib/mongoid_slug.rb +4 -2
  12. data/lib/tasks/mongoid_slug.rake +17 -15
  13. metadata +9 -168
  14. data/spec/models/alias.rb +0 -6
  15. data/spec/models/article.rb +0 -9
  16. data/spec/models/artist.rb +0 -8
  17. data/spec/models/artwork.rb +0 -10
  18. data/spec/models/author.rb +0 -15
  19. data/spec/models/author_polymorphic.rb +0 -15
  20. data/spec/models/book.rb +0 -12
  21. data/spec/models/book_polymorphic.rb +0 -12
  22. data/spec/models/caption.rb +0 -17
  23. data/spec/models/entity.rb +0 -11
  24. data/spec/models/friend.rb +0 -7
  25. data/spec/models/incorrect_slug_persistence.rb +0 -9
  26. data/spec/models/integer_id.rb +0 -9
  27. data/spec/models/magazine.rb +0 -7
  28. data/spec/models/page.rb +0 -9
  29. data/spec/models/page_localize.rb +0 -9
  30. data/spec/models/page_slug_localized.rb +0 -9
  31. data/spec/models/page_slug_localized_custom.rb +0 -10
  32. data/spec/models/page_slug_localized_history.rb +0 -9
  33. data/spec/models/partner.rb +0 -7
  34. data/spec/models/person.rb +0 -12
  35. data/spec/models/relationship.rb +0 -8
  36. data/spec/models/string_id.rb +0 -9
  37. data/spec/models/subject.rb +0 -7
  38. data/spec/models/without_slug.rb +0 -5
  39. data/spec/mongoid/criteria_spec.rb +0 -207
  40. data/spec/mongoid/index_builder_spec.rb +0 -105
  41. data/spec/mongoid/slug_spec.rb +0 -1175
  42. data/spec/shared/indexes.rb +0 -41
  43. data/spec/spec_helper.rb +0 -61
  44. data/spec/tasks/mongoid_slug_rake_spec.rb +0 -73
@@ -1,173 +1,204 @@
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
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ # Can use e.g. Mongoid::Slug::UniqueSlug.new(ModelClass.new).find_unique "slug-1" for auto-suggest ui
6
+ module Mongoid
7
+ module Slug
8
+ class UniqueSlug
9
+ MUTEX_FOR_SLUG = Mutex.new
10
+ class SlugState
11
+ attr_reader :last_entered_slug, :existing_slugs, :existing_history_slugs, :sorted_existing
12
+
13
+ def initialize(slug, documents, pattern)
14
+ @slug = slug
15
+ @documents = documents
16
+ @pattern = pattern
17
+ @last_entered_slug = []
18
+ @existing_slugs = []
19
+ @existing_history_slugs = []
20
+ @sorted_existing = []
21
+ regexp_pattern = Regexp.new(@pattern)
22
+ @documents.each do |doc|
23
+ history_slugs = doc._slugs
24
+ next if history_slugs.nil?
25
+
26
+ existing_slugs.push(*history_slugs.grep(regexp_pattern))
27
+ last_entered_slug.push(*history_slugs.last) if history_slugs.last =~ regexp_pattern
28
+ existing_history_slugs.push(*history_slugs.first(history_slugs.length - 1).grep(regexp_pattern))
29
+ end
30
+ end
31
+
32
+ def slug_included?
33
+ existing_slugs.include? @slug
34
+ end
35
+
36
+ def include_slug
37
+ existing_slugs << @slug
38
+ end
39
+
40
+ def highest_existing_counter
41
+ sort_existing_slugs
42
+ @sorted_existing.last || 0
43
+ end
44
+
45
+ def sort_existing_slugs
46
+ # remove the slug part and leave the absolute integer part and sort
47
+ @sorted_existing = existing_slugs.map do |s|
48
+ s.delete_prefix(@slug).to_i.abs
49
+ end.sort
50
+ end
51
+
52
+ def inspect
53
+ {
54
+ slug: @slug,
55
+ existing_slugs: existing_slugs,
56
+ last_entered_slug: last_entered_slug,
57
+ existing_history_slugs: existing_history_slugs,
58
+ sorted_existing: sorted_existing
59
+ }
60
+ end
61
+ end
62
+
63
+ extend Forwardable
64
+
65
+ attr_reader :model, :_slug
66
+
67
+ def_delegators :@model, :slug_scope, :reflect_on_association, :read_attribute,
68
+ :check_against_id, :slug_reserved_words, :slug_url_builder, :collection_name,
69
+ :embedded?, :reflect_on_all_associations, :reflect_on_all_association,
70
+ :slug_by_model_type, :slug_max_length
71
+
72
+ def initialize(model)
73
+ @model = model
74
+ @_slug = ''
75
+ @state = nil
76
+ end
77
+
78
+ def metadata
79
+ if @model.respond_to?(:_association)
80
+ @model.send(:_association)
81
+ elsif @model.respond_to?(:relation_metadata)
82
+ @model.relation_metadata
83
+ else
84
+ @model.metadata
85
+ end
86
+ end
87
+
88
+ def find_unique(attempt = nil)
89
+ MUTEX_FOR_SLUG.synchronize do
90
+ @_slug = if attempt
91
+ attempt.to_url
92
+ else
93
+ slug_url_builder.call(model)
94
+ end
95
+
96
+ @_slug = @_slug[0...slug_max_length] if slug_max_length
97
+
98
+ where_hash = {}
99
+ where_hash[:_slugs.all] = [regex_for_slug]
100
+ where_hash[:_id.ne] = model._id
101
+
102
+ Array(slug_scope).each do |individual_scope|
103
+ next unless reflect_on_association(individual_scope).nil?
104
+
105
+ # scope is not an association, so it's scoped to a local field
106
+ # (e.g. an association id in a denormalized db design)
107
+ where_hash[individual_scope] = model.try(:read_attribute, individual_scope)
108
+ end
109
+
110
+ where_hash[:_type] = model.try(:read_attribute, :_type) if slug_by_model_type
111
+
112
+ @state = SlugState.new @_slug, uniqueness_scope.unscoped.where(where_hash), escaped_pattern
113
+
114
+ # do not allow a slug that can be interpreted as the current document id
115
+ @state.include_slug unless model.class.look_like_slugs?([@_slug])
116
+
117
+ # make sure that the slug is not equal to a reserved word
118
+ @state.include_slug if slug_reserved_words.any? { |word| word === @_slug } # rubocop:disable Style/CaseEquality
119
+
120
+ # only look for a new unique slug if the existing slugs contains the current slug
121
+ # - e.g if the slug 'foo-2' is taken, but 'foo' is available, the user can use 'foo'.
122
+ if @state.slug_included?
123
+ highest = @state.highest_existing_counter
124
+ @_slug += "-#{highest.succ}"
125
+ end
126
+ @_slug
127
+ end
128
+ end
129
+
130
+ def escaped_pattern
131
+ "^#{Regexp.escape(@_slug)}(?:-(\\d+))?$"
132
+ end
133
+
134
+ # Regular expression that matches slug, slug-1, ... slug-n
135
+ # If slug_name field was indexed, MongoDB will utilize that
136
+ # index to match /^.../ pattern.
137
+ # Use Regexp::Raw to avoid the multiline option when querying the server.
138
+ def regex_for_slug
139
+ if embedded?
140
+ Regexp.new(escaped_pattern)
141
+ else
142
+ BSON::Regexp::Raw.new(escaped_pattern)
143
+ end
144
+ end
145
+
146
+ def uniqueness_scope
147
+ if embedded?
148
+ parent_metadata = reflect_on_all_association(:embedded_in)[0]
149
+ return model._parent.send(parent_metadata.inverse_of || metadata.name)
150
+ end
151
+
152
+ # If slug_scope is present, we need to handle whether it's a single scope or multiple scopes.
153
+ if slug_scope
154
+ # We'll track individual scope results in an array.
155
+ scope_results = []
156
+
157
+ Array(slug_scope).each do |individual_scope|
158
+ next unless (metadata = reflect_on_association(individual_scope))
159
+
160
+ # For each scope, we identify its association metadata and fetch the parent record.
161
+ parent = model.send(metadata.name)
162
+
163
+ # It's important to handle nil cases if the parent record doesn't exist.
164
+ if parent.nil?
165
+ # You might want to handle this scenario differently based on your application's logic.
166
+ next
167
+ end
168
+
169
+ # Make sure doc is actually associated with something, and that
170
+ # some referenced docs have been persisted to the parent
171
+ #
172
+ # TODO: we need better reflection for reference associations,
173
+ # like association_name instead of forcing collection_name here
174
+ # -- maybe in the forthcoming Mongoid refactorings?
175
+ inverse = metadata.inverse_of || collection_name
176
+ next unless parent.respond_to?(inverse)
177
+
178
+ # Add the associated records of the parent (based on the inverse) to our results.
179
+ scope_results << parent.send(inverse)
180
+ end
181
+
182
+ # After iterating through all scopes, we need to decide how to combine the results (if there are multiple).
183
+ # This part depends on how your application should treat multiple scopes.
184
+ # Here, we'll simply return the first non-empty scope result as an example.
185
+ scope_results.each do |result|
186
+ return result if result.present? # or any other logic for selecting among multiple scope results
187
+ end
188
+
189
+ # If we reach this point, it means no valid parent scope was found (all were nil or didn't match the
190
+ # conditions).
191
+ # You might want to raise an error, return a default scope, or handle this scenario based on your
192
+ # application's logic.
193
+ # For this example, we're returning the model's class as a default.
194
+ return model.class
195
+ end
196
+
197
+ # Unless embedded or slug scope, return the deepest document superclass.
198
+ appropriate_class = model.class
199
+ appropriate_class = appropriate_class.superclass while appropriate_class.superclass.include?(Mongoid::Document)
200
+ appropriate_class
201
+ end
202
+ end
203
+ end
204
+ end
@@ -1,5 +1,7 @@
1
- module Mongoid #:nodoc:
2
- module Slug
3
- VERSION = '6.0.1'.freeze
4
- end
5
- end
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid # :nodoc:
4
+ module Slug
5
+ VERSION = '7.1.0'
6
+ end
7
+ end