mongoid_slug 0.10.0 → 1.0.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
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
+