mongoid-slug 7.0.0 → 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.
data/lib/mongoid/slug.rb CHANGED
@@ -1,333 +1,343 @@
1
- # frozen_string_literal: true
2
-
3
- require 'mongoid'
4
- require 'stringex'
5
- require 'mongoid/slug/criteria'
6
- require 'mongoid/slug/index_builder'
7
- require 'mongoid/slug/unique_slug'
8
- require 'mongoid/slug/slug_id_strategy'
9
- require 'mongoid/slug/railtie' if defined?(Rails)
10
-
11
- module Mongoid
12
- # Slugs your Mongoid model.
13
- module Slug
14
- extend ActiveSupport::Concern
15
-
16
- MONGO_INDEX_KEY_LIMIT_BYTES = 1024
17
-
18
- included do
19
- cattr_accessor :slug_reserved_words,
20
- :slug_scope,
21
- :slug_index,
22
- :slugged_attributes,
23
- :slug_url_builder,
24
- :slug_history,
25
- :slug_by_model_type,
26
- :slug_max_length
27
-
28
- # field :_slugs, type: Array, default: [], localize: false
29
- # alias_attribute :slugs, :_slugs
30
- end
31
-
32
- class << self
33
- attr_accessor :default_slug
34
-
35
- def configure(&block)
36
- instance_eval(&block)
37
- end
38
-
39
- def slug(&block)
40
- @default_slug = block if block_given?
41
- end
42
- end
43
-
44
- module ClassMethods
45
- # @overload slug(*fields)
46
- # Sets one ore more fields as source of slug.
47
- # @param [Array] fields One or more fields the slug should be based on.
48
- # @yield If given, the block is used to build a custom slug.
49
- #
50
- # @overload slug(*fields, options)
51
- # Sets one ore more fields as source of slug.
52
- # @param [Array] fields One or more fields the slug should be based on.
53
- # @param [Hash] options
54
- # @param options [Boolean] :history Whether a history of changes to
55
- # the slug should be retained. When searched by slug, the document now
56
- # matches both past and present slugs.
57
- # @param options [Boolean] :permanent Whether the slug should be
58
- # immutable. Defaults to `false`.
59
- # @param options [Array] :reserve` A list of reserved slugs
60
- # @param options :scope [Symbol] a reference association or field to
61
- # scope the slug by. Embedded documents are, by default, scoped by
62
- # their parent.
63
- # @param options :max_length [Integer] the maximum length of the text portion of the slug
64
- # @yield If given, a block is used to build a slug.
65
- #
66
- # @example A custom builder
67
- # class Person
68
- # include Mongoid::Document
69
- # include Mongoid::Slug
70
- #
71
- # field :names, :type => Array
72
- # slug :names do |doc|
73
- # doc.names.join(' ')
74
- # end
75
- # end
76
- #
77
- def slug(*fields, &block)
78
- options = fields.extract_options!
79
-
80
- self.slug_scope = options[:scope]
81
- self.slug_index = options[:index].nil? ? true : options[:index]
82
- self.slug_reserved_words = options[:reserve] || Set.new(%w[new edit])
83
- self.slugged_attributes = fields.map(&:to_s)
84
- self.slug_history = options[:history]
85
- self.slug_by_model_type = options[:by_model_type]
86
- self.slug_max_length = options.key?(:max_length) ? options[:max_length] : MONGO_INDEX_KEY_LIMIT_BYTES - 32
87
-
88
- field :_slugs, type: Array, localize: options[:localize]
89
- alias_attribute :slugs, :_slugs
90
-
91
- # Set indexes
92
- if slug_index && !embedded?
93
- Mongoid::Slug::IndexBuilder.build_indexes(self, slug_scope_key, slug_by_model_type,
94
- options[:localize])
95
- end
96
-
97
- self.slug_url_builder = block_given? ? block : default_slug_url_builder
98
-
99
- #-- always create slug on create
100
- #-- do not create new slug on update if the slug is permanent
101
- if options[:permanent]
102
- set_callback :create, :before, :build_slug
103
- else
104
- set_callback :save, :before, :build_slug, if: :slug_should_be_rebuilt?
105
- end
106
- end
107
-
108
- def default_slug_url_builder
109
- Mongoid::Slug.default_slug || ->(cur_object) { cur_object.slug_builder.to_url }
110
- end
111
-
112
- def look_like_slugs?(*args)
113
- with_default_scope.look_like_slugs?(*args)
114
- end
115
-
116
- # Returns the scope key for indexing, considering associations
117
- #
118
- # @return [ Array<Document>, Document ]
119
- def slug_scope_key
120
- return nil unless slug_scope
121
-
122
- reflect_on_association(slug_scope).try(:key) || slug_scope
123
- end
124
-
125
- # Find documents by slugs.
126
- #
127
- # A document matches if any of its slugs match one of the supplied params.
128
- #
129
- # A document matching multiple supplied params will be returned only once.
130
- #
131
- # If any supplied param does not match a document a Mongoid::Errors::DocumentNotFound will be raised.
132
- #
133
- # @example Find by a slug.
134
- # Model.find_by_slug!('some-slug')
135
- #
136
- # @example Find by multiple slugs.
137
- # Model.find_by_slug!('some-slug', 'some-other-slug')
138
- #
139
- # @param [ Array<Object> ] args The slugs to search for.
140
- #
141
- # @return [ Array<Document>, Document ] The matching document(s).
142
- def find_by_slug!(*args)
143
- with_default_scope.find_by_slug!(*args)
144
- end
145
-
146
- def queryable
147
- current_scope || Criteria.new(self) # Use Mongoid::Slug::Criteria for slugged documents.
148
- end
149
-
150
- private
151
-
152
- if Threaded.method(:current_scope).arity == -1
153
- def current_scope
154
- Threaded.current_scope(self)
155
- end
156
- else
157
- def current_scope
158
- Threaded.current_scope
159
- end
160
- end
161
- end
162
-
163
- # Builds a new slug.
164
- #
165
- # @return [true]
166
- def build_slug
167
- if localized?
168
- begin
169
- orig_locale = I18n.locale
170
- all_locales.each do |target_locale|
171
- I18n.locale = target_locale
172
- apply_slug
173
- end
174
- ensure
175
- I18n.locale = orig_locale
176
- end
177
- else
178
- apply_slug
179
- end
180
- true
181
- end
182
-
183
- def apply_slug
184
- new_slug = find_unique_slug
185
-
186
- # skip slug generation and use Mongoid id
187
- # to find document instead
188
- return true if new_slug.empty?
189
-
190
- # avoid duplicate slugs
191
- _slugs&.delete(new_slug)
192
-
193
- if !!slug_history && _slugs.is_a?(Array)
194
- append_slug(new_slug)
195
- else
196
- self._slugs = [new_slug]
197
- end
198
- end
199
-
200
- # Builds slug then atomically sets it in the database.
201
- #
202
- # This method is adapted to use the :set method variants from both
203
- # Mongoid 3 (two args) and Mongoid 4 (hash arg)
204
- def set_slug!
205
- build_slug
206
- method(:set).arity == 1 ? set(_slugs: _slugs) : set(:_slugs, _slugs)
207
- end
208
-
209
- # Atomically unsets the slug field in the database. It is important to unset
210
- # the field for the sparse index on slugs.
211
- #
212
- # This also resets the in-memory value of the slug field to its default (empty array)
213
- def unset_slug!
214
- unset(:_slugs)
215
- clear_slug!
216
- end
217
-
218
- # Rolls back the slug value from the Mongoid changeset.
219
- def reset_slug!
220
- reset__slugs!
221
- end
222
-
223
- # Sets the slug to its default value.
224
- def clear_slug!
225
- self._slugs = []
226
- end
227
-
228
- # Finds a unique slug, were specified string used to generate a slug.
229
- #
230
- # Returned slug will the same as the specified string when there are no
231
- # duplicates.
232
- #
233
- # @return [String] A unique slug
234
- def find_unique_slug
235
- UniqueSlug.new(self).find_unique
236
- end
237
-
238
- # @return [Boolean] Whether the slug requires to be rebuilt
239
- def slug_should_be_rebuilt?
240
- new_record? || _slugs_changed? || slugged_attributes_changed?
241
- end
242
-
243
- def slugged_attributes_changed?
244
- slugged_attributes.any? { |f| attribute_changed? f.to_s }
245
- end
246
-
247
- # @return [String] A string which Action Pack uses for constructing an URL
248
- # to this record.
249
- def to_param
250
- slug || super
251
- end
252
-
253
- # @return [String] the slug, or nil if the document does not have a slug.
254
- def slug
255
- return _slugs.last if _slugs
256
-
257
- _id.to_s
258
- end
259
-
260
- def slug_builder
261
- cur_slug = nil
262
- if new_with_slugs? || persisted_with_slug_changes?
263
- # user defined slug
264
- cur_slug = _slugs.last
265
- end
266
- # generate slug if the slug is not user defined or does not exist
267
- cur_slug || pre_slug_string
268
- end
269
-
270
- private
271
-
272
- def append_slug(value)
273
- if localized?
274
- # This is necessary for the scenario in which the slugged locale is not yet present
275
- # but the default locale is. In this situation, self._slugs falls back to the default
276
- # which is undesired
277
- current_slugs = _slugs_translations.fetch(I18n.locale.to_s, [])
278
- current_slugs << value
279
- self._slugs_translations = _slugs_translations.merge(I18n.locale.to_s => current_slugs)
280
- else
281
- _slugs << value
282
- end
283
- end
284
-
285
- # Returns true if object is a new record and slugs are present
286
- def new_with_slugs?
287
- if localized?
288
- # We need to check if slugs are present for the locale without falling back
289
- # to a default
290
- new_record? && _slugs_translations.fetch(I18n.locale.to_s, []).any?
291
- else
292
- new_record? && _slugs.present?
293
- end
294
- end
295
-
296
- # Returns true if object has been persisted and has changes in the slug
297
- def persisted_with_slug_changes?
298
- if localized?
299
- changes = _slugs_change
300
- return (persisted? && false) if changes.nil?
301
-
302
- # ensure we check for changes only between the same locale
303
- original = changes.first.try(:fetch, I18n.locale.to_s, nil)
304
- compare = changes.last.try(:fetch, I18n.locale.to_s, nil)
305
- persisted? && original != compare
306
- else
307
- persisted? && _slugs_changed?
308
- end
309
- end
310
-
311
- def localized?
312
- fields['_slugs'].options[:localize]
313
- rescue StandardError
314
- false
315
- end
316
-
317
- # Return all possible locales for model
318
- # Avoiding usage of I18n.available_locales in case the user hasn't set it properly, or is
319
- # doing something crazy, but at the same time we need a fallback in case the model doesn't
320
- # have any localized attributes at all (extreme edge case).
321
- def all_locales
322
- locales = slugged_attributes
323
- .map { |attr| send("#{attr}_translations").keys if respond_to?("#{attr}_translations") }
324
- .flatten.compact.uniq
325
- locales = I18n.available_locales if locales.empty?
326
- locales
327
- end
328
-
329
- def pre_slug_string
330
- slugged_attributes.map { |f| send f }.join ' '
331
- end
332
- end
333
- end
1
+ # frozen_string_literal: true
2
+
3
+ require 'mongoid'
4
+ require 'stringex'
5
+ require 'mongoid/slug/criteria'
6
+ require 'mongoid/slug/index_builder'
7
+ require 'mongoid/slug/unique_slug'
8
+ require 'mongoid/slug/slug_id_strategy'
9
+ require 'mongoid/slug/railtie' if defined?(Rails)
10
+
11
+ module Mongoid
12
+ # Slugs your Mongoid model.
13
+ module Slug
14
+ extend ActiveSupport::Concern
15
+
16
+ MONGO_INDEX_KEY_LIMIT_BYTES = 1024
17
+
18
+ included do
19
+ cattr_accessor :slug_reserved_words,
20
+ :slug_scope,
21
+ :slug_index,
22
+ :slugged_attributes,
23
+ :slug_url_builder,
24
+ :slug_history,
25
+ :slug_by_model_type,
26
+ :slug_max_length
27
+
28
+ # field :_slugs, type: Array, default: [], localize: false
29
+ # alias_attribute :slugs, :_slugs
30
+ end
31
+
32
+ class << self
33
+ attr_accessor :default_slug
34
+
35
+ def configure(&block)
36
+ instance_eval(&block)
37
+ end
38
+
39
+ def slug(&block)
40
+ @default_slug = block if block_given?
41
+ end
42
+ end
43
+
44
+ module ClassMethods
45
+ # @overload slug(*fields)
46
+ # Sets one ore more fields as source of slug.
47
+ # @param [Array] fields One or more fields the slug should be based on.
48
+ # @yield If given, the block is used to build a custom slug.
49
+ #
50
+ # @overload slug(*fields, options)
51
+ # Sets one ore more fields as source of slug.
52
+ # @param [Array] fields One or more fields the slug should be based on.
53
+ # @param [Hash] options
54
+ # @param options [Boolean] :history Whether a history of changes to
55
+ # the slug should be retained. When searched by slug, the document now
56
+ # matches both past and present slugs.
57
+ # @param options [Boolean] :permanent Whether the slug should be
58
+ # immutable. Defaults to `false`.
59
+ # @param options [Array] :reserve` A list of reserved slugs
60
+ # @param options :scope [Symbol, Array<Symbol>] a reference association, field,
61
+ # or array of fields to scope the slug by.
62
+ # Embedded documents are, by default, scoped by their parent. Now it supports not only
63
+ # a single association or field but also an array of them.
64
+ # @param options :max_length [Integer] the maximum length of the text portion of the slug
65
+ # @yield If given, a block is used to build a slug.
66
+ #
67
+ # @example A custom builder
68
+ # class Person
69
+ # include Mongoid::Document
70
+ # include Mongoid::Slug
71
+ #
72
+ # field :names, :type => Array
73
+ # slug :names do |doc|
74
+ # doc.names.join(' ')
75
+ # end
76
+ # end
77
+ #
78
+ def slug(*fields, &block)
79
+ options = fields.extract_options!
80
+
81
+ self.slug_scope = options[:scope]
82
+ self.slug_index = options[:index].nil? || options[:index]
83
+ self.slug_reserved_words = options[:reserve] || Set.new(%w[new edit])
84
+ self.slugged_attributes = fields.map(&:to_s)
85
+ self.slug_history = options[:history]
86
+ self.slug_by_model_type = options[:by_model_type]
87
+ self.slug_max_length = options.key?(:max_length) ? options[:max_length] : MONGO_INDEX_KEY_LIMIT_BYTES - 32
88
+
89
+ field :_slugs, type: Array, localize: options[:localize]
90
+ alias_attribute :slugs, :_slugs
91
+
92
+ # Set indexes
93
+ if slug_index && !embedded?
94
+ Mongoid::Slug::IndexBuilder.build_indexes(self, slug_scope_keys, slug_by_model_type, options[:localize])
95
+ end
96
+
97
+ self.slug_url_builder = block_given? ? block : default_slug_url_builder
98
+
99
+ #-- always create slug on create
100
+ #-- do not create new slug on update if the slug is permanent
101
+ if options[:permanent]
102
+ set_callback :create, :before, :build_slug
103
+ else
104
+ set_callback :save, :before, :build_slug, if: :slug_should_be_rebuilt?
105
+ end
106
+ end
107
+
108
+ def default_slug_url_builder
109
+ Mongoid::Slug.default_slug || ->(cur_object) { cur_object.slug_builder.to_url }
110
+ end
111
+
112
+ def look_like_slugs?(*args)
113
+ with_default_scope.look_like_slugs?(*args)
114
+ end
115
+
116
+ def slug_scopes
117
+ # If slug_scope is set (i.e., not nil), we convert it to an array to ensure we can handle it consistently.
118
+ # If it's not set, we use an array with a single nil element, signifying no specific scope.
119
+ slug_scope ? Array(slug_scope) : [nil]
120
+ end
121
+
122
+ # Returns the scope keys for indexing, considering associations
123
+ #
124
+ # @return [ Array<Document>, Document ]
125
+ def slug_scope_keys
126
+ return nil unless slug_scope
127
+
128
+ # If slug_scope is an array, we map over its elements to get each individual scope's key.
129
+ slug_scopes.map do |individual_scope|
130
+ # Attempt to find the association and get its key. If no association is found, use the scope as-is.
131
+ reflect_on_association(individual_scope).try(:key) || individual_scope
132
+ end
133
+ end
134
+
135
+ # Find documents by slugs.
136
+ #
137
+ # A document matches if any of its slugs match one of the supplied params.
138
+ #
139
+ # A document matching multiple supplied params will be returned only once.
140
+ #
141
+ # If any supplied param does not match a document a Mongoid::Errors::DocumentNotFound will be raised.
142
+ #
143
+ # @example Find by a slug.
144
+ # Model.find_by_slug!('some-slug')
145
+ #
146
+ # @example Find by multiple slugs.
147
+ # Model.find_by_slug!('some-slug', 'some-other-slug')
148
+ #
149
+ # @param [ Array<Object> ] args The slugs to search for.
150
+ #
151
+ # @return [ Array<Document>, Document ] The matching document(s).
152
+ def find_by_slug!(*args)
153
+ with_default_scope.find_by_slug!(*args)
154
+ end
155
+
156
+ def queryable
157
+ current_scope || Criteria.new(self) # Use Mongoid::Slug::Criteria for slugged documents.
158
+ end
159
+
160
+ private
161
+
162
+ if Threaded.method(:current_scope).arity == -1
163
+ def current_scope
164
+ Threaded.current_scope(self)
165
+ end
166
+ else
167
+ def current_scope
168
+ Threaded.current_scope
169
+ end
170
+ end
171
+ end
172
+
173
+ # Builds a new slug.
174
+ #
175
+ # @return [true]
176
+ def build_slug
177
+ if localized?
178
+ begin
179
+ orig_locale = I18n.locale
180
+ all_locales.each do |target_locale|
181
+ I18n.locale = target_locale
182
+ apply_slug
183
+ end
184
+ ensure
185
+ I18n.locale = orig_locale
186
+ end
187
+ else
188
+ apply_slug
189
+ end
190
+ true
191
+ end
192
+
193
+ def apply_slug
194
+ new_slug = find_unique_slug
195
+
196
+ # skip slug generation and use Mongoid id
197
+ # to find document instead
198
+ return true if new_slug.empty?
199
+
200
+ # avoid duplicate slugs
201
+ _slugs&.delete(new_slug)
202
+
203
+ if !!slug_history && _slugs.is_a?(Array)
204
+ append_slug(new_slug)
205
+ else
206
+ self._slugs = [new_slug]
207
+ end
208
+ end
209
+
210
+ # Builds slug then atomically sets it in the database.
211
+ #
212
+ # This method is adapted to use the :set method variants from both
213
+ # Mongoid 3 (two args) and Mongoid 4 (hash arg)
214
+ def set_slug!
215
+ build_slug
216
+ method(:set).arity == 1 ? set(_slugs: _slugs) : set(:_slugs, _slugs)
217
+ end
218
+
219
+ # Atomically unsets the slug field in the database. It is important to unset
220
+ # the field for the sparse index on slugs.
221
+ #
222
+ # This also resets the in-memory value of the slug field to its default (empty array)
223
+ def unset_slug!
224
+ unset(:_slugs)
225
+ clear_slug!
226
+ end
227
+
228
+ # Rolls back the slug value from the Mongoid changeset.
229
+ def reset_slug!
230
+ reset__slugs!
231
+ end
232
+
233
+ # Sets the slug to its default value.
234
+ def clear_slug!
235
+ self._slugs = []
236
+ end
237
+
238
+ # Finds a unique slug, were specified string used to generate a slug.
239
+ #
240
+ # Returned slug will the same as the specified string when there are no
241
+ # duplicates.
242
+ #
243
+ # @return [String] A unique slug
244
+ def find_unique_slug
245
+ UniqueSlug.new(self).find_unique
246
+ end
247
+
248
+ # @return [Boolean] Whether the slug requires to be rebuilt
249
+ def slug_should_be_rebuilt?
250
+ new_record? || _slugs_changed? || slugged_attributes_changed?
251
+ end
252
+
253
+ def slugged_attributes_changed?
254
+ slugged_attributes.any? { |f| attribute_changed? f.to_s }
255
+ end
256
+
257
+ # @return [String] A string which Action Pack uses for constructing an URL
258
+ # to this record.
259
+ def to_param
260
+ slug || super
261
+ end
262
+
263
+ # @return [String] the slug, or nil if the document does not have a slug.
264
+ def slug
265
+ return _slugs.last if _slugs
266
+
267
+ _id.to_s
268
+ end
269
+
270
+ def slug_builder
271
+ cur_slug = nil
272
+ if new_with_slugs? || persisted_with_slug_changes?
273
+ # user defined slug
274
+ cur_slug = _slugs.last
275
+ end
276
+ # generate slug if the slug is not user defined or does not exist
277
+ cur_slug || pre_slug_string
278
+ end
279
+
280
+ private
281
+
282
+ def append_slug(value)
283
+ if localized?
284
+ # This is necessary for the scenario in which the slugged locale is not yet present
285
+ # but the default locale is. In this situation, self._slugs falls back to the default
286
+ # which is undesired
287
+ current_slugs = _slugs_translations.fetch(I18n.locale.to_s, [])
288
+ current_slugs << value
289
+ self._slugs_translations = _slugs_translations.merge(I18n.locale.to_s => current_slugs)
290
+ else
291
+ _slugs << value
292
+ end
293
+ end
294
+
295
+ # Returns true if object is a new record and slugs are present
296
+ def new_with_slugs?
297
+ if localized?
298
+ # We need to check if slugs are present for the locale without falling back
299
+ # to a default
300
+ new_record? && _slugs_translations.fetch(I18n.locale.to_s, []).any?
301
+ else
302
+ new_record? && _slugs.present?
303
+ end
304
+ end
305
+
306
+ # Returns true if object has been persisted and has changes in the slug
307
+ def persisted_with_slug_changes?
308
+ if localized?
309
+ changes = _slugs_change
310
+ return false if changes.nil?
311
+
312
+ # ensure we check for changes only between the same locale
313
+ original = changes.first.try(:fetch, I18n.locale.to_s, nil)
314
+ compare = changes.last.try(:fetch, I18n.locale.to_s, nil)
315
+ persisted? && original != compare
316
+ else
317
+ persisted? && _slugs_changed?
318
+ end
319
+ end
320
+
321
+ def localized?
322
+ fields['_slugs'].options[:localize]
323
+ rescue StandardError
324
+ false
325
+ end
326
+
327
+ # Return all possible locales for model
328
+ # Avoiding usage of I18n.available_locales in case the user hasn't set it properly, or is
329
+ # doing something crazy, but at the same time we need a fallback in case the model doesn't
330
+ # have any localized attributes at all (extreme edge case).
331
+ def all_locales
332
+ locales = slugged_attributes
333
+ .map { |attr| send("#{attr}_translations").keys if respond_to?("#{attr}_translations") }
334
+ .flatten.compact.uniq
335
+ locales = I18n.available_locales if locales.empty?
336
+ locales
337
+ end
338
+
339
+ def pre_slug_string
340
+ slugged_attributes.map { |f| send f }.join ' '
341
+ end
342
+ end
343
+ end