mongoid_slug 0.10.0 → 1.0.0.rc1

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/README.md CHANGED
@@ -1,3 +1,6 @@
1
+ *IMPORTANT:* If you are upgrading to Mongoid Slug 0.20.0 please migrate in accordance with the instructions in https://github.com/digitalplaywright/mongoid-slug/wiki/How-to-upgrade-to-0.20.0.
2
+ Mongoid Slug 0.20.0 stores the slugs in a single field _slugs of array type, and all previous slugs must be migrated.
3
+
1
4
  Mongoid Slug
2
5
  ============
3
6
 
@@ -5,7 +8,7 @@ Mongoid Slug generates a URL slug or permalink based on one or more fields in a
5
8
  Mongoid model. It sits idly on top of [stringex] [1], supporting non-Latin
6
9
  characters.
7
10
 
8
- [![travis] [2]] [3]
11
+ [![Build Status](https://secure.travis-ci.org/digitalplaywright/mongoid-slug.png)](http://travis-ci.org/digitalplaywright/mongoid-slug)
9
12
 
10
13
  Installation
11
14
  ------------
@@ -31,14 +34,52 @@ class Book
31
34
  end
32
35
  ```
33
36
 
34
- Find a record by its slug:
37
+ Find a document by its slug:
35
38
 
36
39
  ```ruby
37
40
  # GET /books/a-thousand-plateaus
38
- book = Book.find_by_slug params[:book_id]
41
+ book = Book.find params[:book_id]
42
+ ```
43
+
44
+ Mongoid Slug will attempt to determine whether you want to find using the `slugs` field or the `_id` field by inspecting the supplied parameters.
45
+
46
+ * If your document uses `BSON::ObjectId` identifiers, and all arguments passed to `find` are `String` and look like valid `BSON::ObjectId`, then Mongoid Slug will perform a find based on `_id`.
47
+ * If your document uses any other type of identifiers, and all arguments passed to `find` are of the same type, then Mongoid Slug will perform a find based on `_id`.
48
+ * Otherwise, if all arguments passed to `find` are of the type `String`, then Mongoid Slug will perform a find based on `slugs`.
49
+
50
+ To override this behaviour you may supply a hash of options as the final argument to `find` with the key `force_slugs` set to `true` or `false` as required. For example:
51
+
52
+ ```ruby
53
+ Book.fields['_id'].type
54
+ => String
55
+ book = Book.find 'a-thousand-plateaus' # Finds by _id
56
+ => ...
57
+ book = Book.find 'a-thousand-plateaus', { force_slugs: true } # Finds by slugs
58
+ => ...
39
59
  ```
40
60
 
41
- [Read here] [3] for all available options.
61
+
62
+ [Read here] [4] for all available options.
63
+
64
+ Custom Slug Generation
65
+ -------
66
+
67
+ By default Mongoid Slug generates slugs with stringex. If this is not desired you can
68
+ define your own slug generator like this:
69
+
70
+ ```
71
+ class Caption
72
+ include Mongoid::Document
73
+ include Mongoid::Slug
74
+
75
+ #create a block that takes the current object as an argument
76
+ #and returns the slug.
77
+ slug do |cur_object|
78
+ cur_object.slug_builder.to_url
79
+ end
80
+ end
81
+
82
+ ```
42
83
 
43
84
  Scoping
44
85
  -------
@@ -109,7 +150,7 @@ page = Page.new title: "Home"
109
150
  page.save
110
151
  page.update_attributes title: "Welcome"
111
152
 
112
- Page.find_by_slug("welcome") == Page.find_by_slug("home") #=> true
153
+ Page.find("welcome") == Page.find("home") #=> true
113
154
  ```
114
155
 
115
156
  Reserved Slugs
@@ -126,7 +167,7 @@ class Friend
126
167
  end
127
168
 
128
169
  friend = Friend.create name: 'admin'
129
- Friend.find_by_slug('admin') # => nil
170
+ Friend.find('admin') # => nil
130
171
  friend.slug # => 'admin-1'
131
172
  ```
132
173
 
@@ -1,41 +1,34 @@
1
- require 'mongoid'
2
- require 'stringex'
3
-
4
1
  module Mongoid
5
- # The Slug module helps you generate a URL slug or permalink based on one or
6
- # more fields in a Mongoid model.
2
+ # Slugs your Mongoid model.
7
3
  module Slug
8
4
  extend ActiveSupport::Concern
9
5
 
10
6
  included do
11
- cattr_accessor :slug_builder,
12
- :slug_name,
13
- :slug_history_name,
7
+ cattr_accessor :reserved_words,
14
8
  :slug_scope,
15
- :reserved_words_in_slug,
16
- :slugged_attributes
9
+ :slugged_attributes,
10
+ :url_builder,
11
+ :history
12
+
13
+ field :_slugs, type: Array, default: []
14
+ alias_attribute :slugs, :_slugs
17
15
  end
18
16
 
19
17
  module ClassMethods
18
+
19
+
20
20
  # @overload slug(*fields)
21
21
  # Sets one ore more fields as source of slug.
22
22
  # @param [Array] fields One or more fields the slug should be based on.
23
23
  # @yield If given, the block is used to build a custom slug.
24
24
  #
25
- # @overload slug(*fields, options)
25
+ # @overload slug(*fields, options)
26
26
  # Sets one ore more fields as source of slug.
27
27
  # @param [Array] fields One or more fields the slug should be based on.
28
28
  # @param [Hash] options
29
- # @param options [String] :as The name of the field that stores the
30
- # slug. Defaults to `slug`.
31
29
  # @param options [Boolean] :history Whether a history of changes to
32
30
  # the slug should be retained. When searched by slug, the document now
33
31
  # matches both past and present slugs.
34
- # @param options [Boolean] :index Whether an index should be defined
35
- # on the slug field. Defaults to `false` and has no effect if the
36
- # document is embedded.
37
- # Make sure you have a unique index on the slugs of root documents to
38
- # avoid race conditions.
39
32
  # @param options [Boolean] :permanent Whether the slug should be
40
33
  # immutable. Defaults to `false`.
41
34
  # @param options [Array] :reserve` A list of reserved slugs
@@ -57,176 +50,145 @@ module Mongoid
57
50
  #
58
51
  def slug(*fields, &block)
59
52
  options = fields.extract_options!
60
- options[:history] = false if options[:permanent]
61
-
62
- self.slug_scope = options[:scope]
63
- self.reserved_words_in_slug = options[:reserve] || []
64
- self.slug_name = options[:as] || :slug
65
- self.slugged_attributes = fields.map(&:to_s)
66
- if options[:history] && !options[:permanent]
67
- self.slug_history_name = "#{self.slug_name}_history".to_sym
68
- end
69
-
70
- default_builder = lambda do |doc|
71
- slugged_attributes.map { |f| doc.send f }.join ' '
72
- end
73
- self.slug_builder = block_given? ? block : default_builder
74
53
 
75
- field slug_name
54
+ self.slug_scope = options[:scope]
55
+ self.reserved_words = options[:reserve] || Set.new([:new, :edit])
56
+ self.slugged_attributes = fields.map &:to_s
57
+ self.history = options[:history]
76
58
 
77
- if slug_history_name
78
- field slug_history_name, :type => Array, :default => []
59
+ if slug_scope
60
+ index({slug_scope: 1, _slugs: 1}, {unique: true})
61
+ else
62
+ index({_slugs: 1}, {unique: true})
79
63
  end
80
64
 
81
- if options[:index]
82
- index slug_name, :unique => !slug_scope
83
- index slug_history_name if slug_history_name
65
+ #-- Why is it necessary to customize the slug builder?
66
+ default_url_builder = lambda do |cur_object|
67
+ cur_object.slug_builder.to_url
84
68
  end
85
69
 
86
- set_callback options[:permanent] ? :create : :save, :before do |doc|
87
- doc.build_slug if doc.slug_should_be_rebuilt?
70
+ self.url_builder = block_given? ? block : default_url_builder
71
+
72
+ #-- always create slug on create
73
+ #-- do not create new slug on update if the slug is permanent
74
+ if options[:permanent]
75
+ set_callback :create, :before, :build_slug
76
+ else
77
+ set_callback :save, :before, :build_slug, :if => :slug_should_be_rebuilt?
88
78
  end
89
79
 
90
- # Build a finder for slug.
91
- #
92
- # Defaults to `find_by_slug`.
93
- instance_eval <<-CODE
94
- def self.find_by_#{slug_name}(slug)
95
- if slug_history_name
96
- any_of({ slug_name => slug }, { slug_history_name => slug })
97
- else
98
- where(slug_name => slug)
99
- end.first
100
- end
101
80
 
102
- def self.find_by_#{slug_name}!(slug)
103
- self.find_by_#{slug_name}(slug) ||
104
- raise(Mongoid::Errors::DocumentNotFound.new self, slug)
105
- end
106
- CODE
81
+ end
107
82
 
108
- # Build a scope based on the slug name.
109
- #
110
- # Defaults to `by_slug`.
111
- scope "by_#{slug_name}".to_sym, lambda { |slug|
112
- if slug_history_name
113
- any_of({ slug_name => slug }, { slug_history_name => slug })
114
- else
115
- where(slug_name => slug)
116
- end
117
- }
83
+ def look_like_slugs?(*args)
84
+ with_default_scope.look_like_slugs?(*args)
118
85
  end
119
-
120
- # Finds a unique slug, were specified string used to generate a slug.
86
+
87
+
88
+
89
+ # Find documents by slugs.
121
90
  #
122
- # Returned slug will the same as the specified string when there are no
123
- # duplicates.
91
+ # A document matches if any of its slugs match one of the supplied params.
124
92
  #
125
- # @param [String] desired_slug
126
- # @param [Hash] options
127
- # @param options [Symbol] :scope The scope that should be used to
128
- # generate the slug, if the class creates scoped slugs. Defaults to
129
- # `nil`.
130
- # @param options [Constant] :model The model that the slug should be
131
- # generated for. This option overrides `:scope`, as the scope can now
132
- # be extracted from the model. Defaults to `nil`.
133
- # @return [String] A unique slug
134
- def find_unique_slug_for(desired_slug, options = {})
135
- if slug_scope && self.reflect_on_association(slug_scope).nil?
136
- scope_object = uniqueness_scope(options[:model])
137
- scope_attribute = options[:scope] || options[:model].try(:read_attribute, slug_scope)
138
- else
139
- scope_object = options[:scope] || uniqueness_scope(options[:model])
140
- scope_attribute = nil
141
- end
142
-
143
- excluded_id = options[:model]._id if options[:model]
144
-
145
- slug = desired_slug.to_url
146
-
147
- # Regular expression that matches slug, slug-1, ... slug-n
148
- # If slug_name field was indexed, MongoDB will utilize that
149
- # index to match /^.../ pattern.
150
- pattern = /^#{Regexp.escape(slug)}(?:-(\d+))?$/
151
-
152
- if slug_scope &&
153
- self.reflect_on_association(slug_scope).nil?
154
- # scope is not an association, so it's scoped to a local field
155
- # (e.g. an association id in a denormalized db design)
156
-
157
- where_hash = {}
158
- where_hash[slug_name] = pattern
159
- where_hash[:_id.ne] = excluded_id if excluded_id
160
- where_hash[slug_scope] = scope_attribute
161
-
162
- existing_slugs =
163
- deepest_document_superclass.
164
- only(slug_name).
165
- where(where_hash)
166
- else
167
- where_hash = {}
168
- where_hash[slug_name] = pattern
169
- where_hash[:_id.ne] = excluded_id if excluded_id
170
-
171
- existing_slugs =
172
- scope_object.
173
- only(slug_name).
174
- where(where_hash)
175
- end
176
-
177
- existing_slugs = existing_slugs.map do |doc|
178
- doc.slug
179
- end
93
+ # A document matching multiple supplied params will be returned only once.
94
+ #
95
+ # If any supplied param does not match a document a Mongoid::Errors::DocumentNotFound will be raised.
96
+ #
97
+ # @example Find by a slug.
98
+ # Model.find_by_slug!('some-slug')
99
+ #
100
+ # @example Find by multiple slugs.
101
+ # Model.find_by_slug!('some-slug', 'some-other-slug')
102
+ #
103
+ # @param [ Array<Object> ] args The slugs to search for.
104
+ #
105
+ # @return [ Array<Document>, Document ] The matching document(s).
106
+ def find_by_slug!(*args)
107
+ with_default_scope.find_by_slug!(*args)
108
+ end
180
109
 
181
- if slug_history_name
182
- if slug_scope &&
183
- self.reflect_on_association(slug_scope).nil?
184
- # scope is not an association, so it's scoped to a local field
185
- # (e.g. an association id in a denormalized db design)
110
+ def queryable
111
+ scope_stack.last || Criteria.new(self) # Use Mongoid::Slug::Criteria for slugged documents.
112
+ end
186
113
 
187
- where_hash = {}
188
- where_hash[slug_history_name.all] = [pattern]
189
- where_hash[:_id.ne] = excluded_id if excluded_id
190
- where_hash[slug_scope] = scope_attribute
114
+ end
191
115
 
192
- history_slugged_documents =
193
- deepest_document_superclass.
194
- where(where_hash)
195
- else
196
- where_hash = {}
197
- where_hash[slug_history_name.all] = [pattern]
198
- where_hash[:_id.ne] = excluded_id if excluded_id
116
+ # Builds a new slug.
117
+ #
118
+ # @return [true]
119
+ def build_slug
120
+ _new_slug = find_unique_slug
121
+ self._slugs.delete(_new_slug)
122
+ if self.history == true
123
+ self._slugs << _new_slug
124
+ else
125
+ self._slugs = [_new_slug]
126
+ end
127
+ true
128
+ end
129
+
130
+
131
+ # Finds a unique slug, were specified string used to generate a slug.
132
+ #
133
+ # Returned slug will the same as the specified string when there are no
134
+ # duplicates.
135
+ #
136
+ # @param [String] Desired slug
137
+ # @return [String] A unique slug
138
+ def find_unique_slug
139
+
140
+ _slug = self.url_builder.call(self)
199
141
 
200
- history_slugged_documents =
201
- scope_object.
142
+ # Regular expression that matches slug, slug-1, ... slug-n
143
+ # If slug_name field was indexed, MongoDB will utilize that
144
+ # index to match /^.../ pattern.
145
+ pattern = /^#{Regexp.escape(_slug)}(?:-(\d+))?$/
146
+
147
+ where_hash = {}
148
+ where_hash[:_slugs.all] = [pattern]
149
+ where_hash[:_id.ne] = self._id
150
+
151
+ if slug_scope && self.reflect_on_association(slug_scope).nil?
152
+ # scope is not an association, so it's scoped to a local field
153
+ # (e.g. an association id in a denormalized db design)
154
+ where_hash[slug_scope] = self.try(:read_attribute, slug_scope)
155
+
156
+ end
157
+
158
+ history_slugged_documents =
159
+ uniqueness_scope.
202
160
  where(where_hash)
203
- end
204
161
 
205
- existing_history_slugs = []
206
- history_slugged_documents.each do |doc|
207
- history_slugs = doc.read_attribute(slug_history_name)
208
- next if history_slugs.nil?
209
- existing_history_slugs.push(*history_slugs.find_all { |slug| slug =~ pattern })
210
- end
162
+ existing_slugs = []
163
+ existing_history_slugs = []
164
+ last_entered_slug = []
165
+ history_slugged_documents.each do |doc|
166
+ history_slugs = doc._slugs
167
+ next if history_slugs.nil?
168
+ existing_slugs.push(*history_slugs.find_all { |cur_slug| cur_slug =~ pattern })
169
+ last_entered_slug.push(*history_slugs.last) if history_slugs.last =~ pattern
170
+ existing_history_slugs.push(*history_slugs.first(history_slugs.length() -1).find_all { |cur_slug| cur_slug =~ pattern })
171
+ end
211
172
 
212
- # If the only conflict is in the history of a document in the same scope,
213
- # transfer the slug
214
- if slug_scope && existing_slugs.count == 0 && existing_history_slugs.count > 0
215
- history_slugged_documents.each do |doc|
216
- doc_history_slugs = doc.read_attribute(slug_history_name)
217
- next if doc_history_slugs.nil?
218
- doc_history_slugs -= existing_history_slugs
219
- doc.write_attribute(slug_history_name, doc_history_slugs)
220
- doc.save
221
- end
222
- existing_history_slugs = []
223
- end
173
+ #do not allow a slug that can be interpreted as the current document id
174
+ existing_slugs << _slug unless self.class.look_like_slugs?([_slug])
224
175
 
225
- existing_slugs += existing_history_slugs
226
- end
176
+ #make sure that the slug is not equal to a reserved word
177
+ if reserved_words.any? { |word| word === _slug }
178
+ existing_slugs << _slug
179
+ end
227
180
 
228
- if reserved_words_in_slug.any? { |word| word === slug }
229
- existing_slugs << slug
181
+ #only look for a new unique slug if the existing slugs contains the current slug
182
+ # - e.g if the slug 'foo-2' is taken, but 'foo' is available, the user can use 'foo'.
183
+ if existing_slugs.include? _slug
184
+ # If the only conflict is in the history of a document in the same scope,
185
+ # transfer the slug
186
+ if slug_scope && last_entered_slug.count == 0 && existing_history_slugs.count > 0
187
+ history_slugged_documents.each do |doc|
188
+ doc._slugs -= existing_history_slugs
189
+ doc.save
190
+ end
191
+ existing_slugs = []
230
192
  end
231
193
 
232
194
  if existing_slugs.count > 0
@@ -235,119 +197,90 @@ module Mongoid
235
197
  # slug, slug-1, slug-2, ..., slug-n
236
198
  existing_slugs.sort! do |a, b|
237
199
  (pattern.match(a)[1] || -1).to_i <=>
238
- (pattern.match(b)[1] || -1).to_i
200
+ (pattern.match(b)[1] || -1).to_i
239
201
  end
240
202
  max = existing_slugs.last.match(/-(\d+)$/).try(:[], 1).to_i
241
203
 
242
- slug += "-#{max + 1}"
243
- end
244
-
245
- slug
246
- end
247
-
248
- private
249
-
250
- def uniqueness_scope(model = nil)
251
- if model
252
- if slug_scope && (metadata = self.reflect_on_association(slug_scope))
253
- parent = model.send(metadata.name)
254
-
255
- # Make sure doc is actually associated with something, and that
256
- # some referenced docs have been persisted to the parent
257
- #
258
- # TODO: we need better reflection for reference associations,
259
- # like association_name instead of forcing collection_name here
260
- # -- maybe in the forthcoming Mongoid refactorings?
261
- inverse = metadata.inverse_of || collection_name
262
- return parent.respond_to?(inverse) ? parent.send(inverse) : self
263
- end
264
- if embedded?
265
- parent_metadata = reflect_on_all_associations(:embedded_in)[0]
266
- return model._parent.send(parent_metadata.inverse_of || model.metadata.name)
267
- end
204
+ _slug += "-#{max + 1}"
268
205
  end
269
- deepest_document_superclass
270
- end
271
-
272
- def deepest_document_superclass
273
- appropriate_class = self
274
- while appropriate_class.superclass.include?(Mongoid::Document)
275
- appropriate_class = appropriate_class.superclass
276
- end
277
- appropriate_class
278
- end
279
- end
280
206
 
281
- # Builds a new slug.
282
- #
283
- # @return [true]
284
- def build_slug
285
- old = slug
286
- write_attribute slug_name, find_unique_slug
287
-
288
- # @note I find it odd that we can't use `slug_was`, `slug_changed?`, or
289
- # `read_attribute (slug_history_name)` here.
290
-
291
- if slug_history_name && old && old != slug
292
- self.send(slug_history_name).<<(old).uniq!
293
207
  end
294
208
 
295
- true
209
+ _slug
296
210
  end
297
211
 
298
- # Finds a unique slug, were specified string used to generate a slug.
299
- #
300
- # Returned slug will the same as the specified string when there are no
301
- # duplicates.
302
- #
303
- # @param [String] Desired slug
304
- # @return [String] A unique slug
305
- def find_unique_slug_for(desired_slug)
306
- self.class.find_unique_slug_for desired_slug, :model => self
307
- end
308
212
 
309
213
  # @return [Boolean] Whether the slug requires to be rebuilt
310
214
  def slug_should_be_rebuilt?
311
- new_record? or slug_changed? or slugged_attributes_changed?
312
- end
313
-
314
- unless self.respond_to? :slug
315
- def slug
316
- read_attribute slug_name
317
- end
318
-
319
- def slug_changed?
320
- attribute_changed? slug_name
321
- end
322
-
323
- def slug_was
324
- attribute_was slug_name
325
- end
215
+ new_record? or _slugs_changed? or slugged_attributes_changed?
326
216
  end
327
217
 
328
218
  def slugged_attributes_changed?
329
- slugged_attributes.any? { |f| attribute_changed? f }
219
+ slugged_attributes.any? { |f| attribute_changed? f.to_s }
330
220
  end
331
221
 
332
222
  # @return [String] A string which Action Pack uses for constructing an URL
333
223
  # to this record.
334
224
  def to_param
335
- unless slug
225
+ unless _slugs.last
336
226
  build_slug
337
227
  save
338
228
  end
339
229
 
340
- slug
230
+ _slugs.last
341
231
  end
342
-
343
- private
232
+ alias_method :slug, :to_param
344
233
 
345
- def find_unique_slug
346
- find_unique_slug_for user_defined_slug || slug_builder.call(self)
234
+ def slug_builder
235
+
236
+ _cur_slug = nil
237
+ if (new_record? and _slugs.present?) or (persisted? and _slugs_changed?)
238
+ #user defined slug
239
+ _cur_slug = _slugs.last
240
+ end
241
+
242
+ #generate slug if the slug is not user defined or does not exist
243
+ unless _cur_slug
244
+ self.slugged_attributes.map { |f| self.send f }.join ' '
245
+ else
246
+ _cur_slug
247
+ end
347
248
  end
348
249
 
349
- def user_defined_slug
350
- slug if new_record? and slug.present? or slug_changed?
250
+ private
251
+
252
+ def uniqueness_scope
253
+
254
+ if slug_scope &&
255
+ metadata = self.reflect_on_association(slug_scope)
256
+
257
+ parent = self.send(metadata.name)
258
+
259
+ # Make sure doc is actually associated with something, and that
260
+ # some referenced docs have been persisted to the parent
261
+ #
262
+ # TODO: we need better reflection for reference associations,
263
+ # like association_name instead of forcing collection_name here
264
+ # -- maybe in the forthcoming Mongoid refactorings?
265
+ inverse = metadata.inverse_of || collection_name
266
+ return parent.respond_to?(inverse) ? parent.send(inverse) : self.class
267
+
268
+ end
269
+
270
+ if self.embedded?
271
+ parent_metadata = reflect_on_all_associations(:embedded_in)[0]
272
+ return self._parent.send(parent_metadata.inverse_of || self.metadata.name)
273
+ end
274
+
275
+ #unless embedded or slug scope, return the deepest document superclass
276
+ appropriate_class = self.class
277
+ while appropriate_class.superclass.include?(Mongoid::Document)
278
+ appropriate_class = appropriate_class.superclass
279
+ end
280
+ appropriate_class
281
+
351
282
  end
283
+
352
284
  end
353
285
  end
286
+