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