mongoid_slug 0.9.0 → 0.10.0

Sign up to get free protection for your applications and to get access to all the features.
data/LICENSE CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2010 Paper Cavalier
1
+ Copyright (c) 2010-2012 Hakan Ensari
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining
4
4
  a copy of this software and associated documentation files (the
data/README.md CHANGED
@@ -1,22 +1,25 @@
1
1
  Mongoid Slug
2
2
  ============
3
3
 
4
- Mongoid Slug generates a URL slug or permalink based on one or more
5
- fields in a Mongoid model. It sits idly on top of [stringex] [1] and
6
- works with non-Latin characters.
4
+ Mongoid Slug generates a URL slug or permalink based on one or more fields in a
5
+ Mongoid model. It sits idly on top of [stringex] [1], supporting non-Latin
6
+ characters.
7
7
 
8
- [![travis](https://secure.travis-ci.org/hakanensari/mongoid-slug.png)](http://travis-ci.org/hakanensari/mongoid-slug)
8
+ [![travis] [2]] [3]
9
9
 
10
- Quick Start
11
- -----------
10
+ Installation
11
+ ------------
12
12
 
13
- Add mongoid_slug to your Gemfile:
13
+ Add to your Gemfile:
14
14
 
15
15
  ```ruby
16
16
  gem 'mongoid_slug'
17
17
  ```
18
18
 
19
- Set up some slugs:
19
+ Usage
20
+ -----
21
+
22
+ Set up a slug:
20
23
 
21
24
  ```ruby
22
25
  class Book
@@ -24,34 +27,18 @@ class Book
24
27
  include Mongoid::Slug
25
28
 
26
29
  field :title
27
- embeds_many :authors
28
-
29
30
  slug :title
30
31
  end
31
-
32
- class Author
33
- include Mongoid::Document
34
- include Mongoid::Slug
35
-
36
- field :first
37
- field :last
38
- embedded_in :book, :inverse_of => :authors
39
-
40
- slug :first, :last, :as => :name
41
- end
42
32
  ```
43
33
 
44
- In your controller, use available finders:
34
+ Find a record by its slug:
45
35
 
46
36
  ```ruby
47
- # GET /books/a-thousand-plateaus/authors/gilles-deleuze
48
- author = Book.find_by_slug(params[:book_id]).
49
- authors.
50
- find_by_name(params[:id])
37
+ # GET /books/a-thousand-plateaus
38
+ book = Book.find_by_slug params[:book_id]
51
39
  ```
52
40
 
53
- [Read here] [2]
54
- for all available options.
41
+ [Read here] [3] for all available options.
55
42
 
56
43
  Scoping
57
44
  -------
@@ -61,37 +48,89 @@ To scope a slug by a reference association, pass `:scope`:
61
48
  ```ruby
62
49
  class Company
63
50
  include Mongoid::Document
51
+
64
52
  references_many :employees
65
53
  end
66
54
 
67
55
  class Employee
68
- include Mongoid::Document
69
- include Mongoid::Slug
70
- field :name
71
- slug :name, :scope => :company
72
- referenced_in :company
56
+ include Mongoid::Document
57
+ include Mongoid::Slug
58
+
59
+ field :name
60
+ referenced_in :company
61
+
62
+ slug :name, :scope => :company
73
63
  end
74
64
  ```
75
65
 
76
- In this example, if you create an employee without associating it with
77
- any company, the scope will fall back to the root employees collection.
66
+ In this example, if you create an employee without associating it with any
67
+ company, the scope will fall back to the root employees collection.
78
68
 
79
- Currently, if you have an irregular association name, you **must**
80
- specify the `:inverse_of` option on the other side of the assocation.
69
+ Currently, if you have an irregular association name, you **must** specify the
70
+ `:inverse_of` option on the other side of the assocation.
81
71
 
82
72
  Embedded objects are automatically scoped by their parent.
83
73
 
84
- If the value of `:scope` is not an association, it should be the name of a field within the model itself:
74
+ The value of `:scope` can alternatively be a field within the model itself:
85
75
 
86
76
  ```ruby
87
77
  class Employee
88
- include Mongoid::Document
89
- include Mongoid::Slug
90
- field :name
91
- field :company_id
92
- slug :name, :scope => :company_id
78
+ include Mongoid::Document
79
+ include Mongoid::Slug
80
+
81
+ field :name
82
+ field :company_id
83
+
84
+ slug :name, :scope => :company_id
85
+ end
86
+ ```
87
+
88
+ History
89
+ -------
90
+
91
+ To specify that the history of a document should be kept track of, pass
92
+ `:history` with a value of `true`.
93
+
94
+ ```ruby
95
+ class Page
96
+ include Mongoid::Document
97
+ include Mongoid::Slug
98
+
99
+ field :title
100
+
101
+ slug :title, history: true
102
+ end
103
+ ```
104
+
105
+ The document will then be returned for any of the saved slugs:
106
+
107
+ ```ruby
108
+ page = Page.new title: "Home"
109
+ page.save
110
+ page.update_attributes title: "Welcome"
111
+
112
+ Page.find_by_slug("welcome") == Page.find_by_slug("home") #=> true
113
+ ```
114
+
115
+ Reserved Slugs
116
+ --------------
117
+
118
+ Pass words you do not want to be slugged using the `reserve` option:
119
+
120
+ ```ruby
121
+ class Friend
122
+ include Mongoid::Document
123
+
124
+ field :name
125
+ slug :name, reserve: ['admin', 'root']
93
126
  end
127
+
128
+ friend = Friend.create name: 'admin'
129
+ Friend.find_by_slug('admin') # => nil
130
+ friend.slug # => 'admin-1'
94
131
  ```
95
132
 
96
133
  [1]: https://github.com/rsl/stringex/
97
- [2]: https://github.com/hakanensari/mongoid-slug/blob/master/lib/mongoid/slug.rb
134
+ [2]: https://secure.travis-ci.org/hakanensari/mongoid-slug.png
135
+ [3]: http://travis-ci.org/hakanensari/mongoid-slug
136
+ [4]: https://github.com/hakanensari/mongoid-slug/blob/master/lib/mongoid/slug.rb
data/lib/mongoid/slug.rb CHANGED
@@ -1,213 +1,353 @@
1
1
  require 'mongoid'
2
2
  require 'stringex'
3
3
 
4
- module Mongoid #:nodoc:
5
-
6
- # The slug module helps you generate a URL slug or permalink based on
7
- # one or more fields in a Mongoid model.
8
- #
9
- # class Person
10
- # include Mongoid::Document
11
- # include Mongoid::Slug
12
- #
13
- # field :name
14
- # slug :name
15
- # end
16
- #
4
+ 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.
17
7
  module Slug
18
8
  extend ActiveSupport::Concern
19
9
 
20
10
  included do
21
11
  cattr_accessor :slug_builder,
22
- :slugged_fields,
23
12
  :slug_name,
24
- :slug_scope
13
+ :slug_history_name,
14
+ :slug_scope,
15
+ :reserved_words_in_slug,
16
+ :slugged_attributes
25
17
  end
26
18
 
27
19
  module ClassMethods
28
-
29
- # Sets one ore more fields as source of slug.
30
- #
31
- # Takes a list of fields to slug and an optional options hash.
32
- #
33
- # The options hash respects the following members:
34
- #
35
- # * `:as`, which specifies name of the field that stores the
36
- # slug. Defaults to `slug`.
37
- #
38
- # * `:scope`, which specifies a reference association to scope
39
- # the slug by. Embedded documents are by default scoped by their
40
- # parent.
41
- #
42
- # * `:permanent`, which specifies whether the slug should be
43
- # immutable once created. Defaults to `false`.
20
+ # @overload slug(*fields)
21
+ # Sets one ore more fields as source of slug.
22
+ # @param [Array] fields One or more fields the slug should be based on.
23
+ # @yield If given, the block is used to build a custom slug.
44
24
  #
45
- # * `:index`, which specifies whether an index should be defined
46
- # for the slug. Defaults to `false` and has no effect if the
47
- # document is embedded. Make sure you have a unique index on the
48
- # slug of root documents to avoid the (very unlikely) race
49
- # condition that would ensue if two documents with identical
50
- # slugs were to be saved simultaneously.
25
+ # @overload slug(*fields, options)
26
+ # Sets one ore more fields as source of slug.
27
+ # @param [Array] fields One or more fields the slug should be based on.
28
+ # @param [Hash] options
29
+ # @param options [String] :as The name of the field that stores the
30
+ # slug. Defaults to `slug`.
31
+ # @param options [Boolean] :history Whether a history of changes to
32
+ # the slug should be retained. When searched by slug, the document now
33
+ # 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
+ # @param options [Boolean] :permanent Whether the slug should be
40
+ # immutable. Defaults to `false`.
41
+ # @param options [Array] :reserve` A list of reserved slugs
42
+ # @param options :scope [Symbol] a reference association or field to
43
+ # scope the slug by. Embedded documents are, by default, scoped by
44
+ # their parent.
45
+ # @yield If given, a block is used to build a slug.
51
46
  #
52
- # Alternatively, this method can be given a block to build a
53
- # custom slug out of the specified fields.
47
+ # @example A custom builder
48
+ # class Person
49
+ # include Mongoid::Document
50
+ # include Mongoid::Slug
54
51
  #
55
- # The block takes a single argument, the document itself, and
56
- # should return a string that will serve as the base of the slug.
57
- #
58
- # Here, for instance, we slug an array field.
59
- #
60
- # class Person
61
- # include Mongoid::Document
62
- # include Mongoid::Slug
63
- #
64
- # field :names, :type => Array
65
- # slug :names do |doc|
66
- # doc.names.join(' ')
67
- # end
52
+ # field :names, :type => Array
53
+ # slug :names do |doc|
54
+ # doc.names.join(' ')
55
+ # end
56
+ # end
68
57
  #
69
58
  def slug(*fields, &block)
70
- options = fields.extract_options!
71
- self.slug_scope = options[:scope]
72
- self.slug_name = options[:as] || :slug
73
- self.slugged_fields = fields.map(&:to_s)
74
-
75
- self.slug_builder =
76
- if block_given?
77
- block
78
- else
79
- lambda do |doc|
80
- slugged_fields.map { |f| doc.read_attribute(f) }.
81
- join(' ')
82
- end
83
- end
59
+ 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
84
74
 
85
75
  field slug_name
86
76
 
77
+ if slug_history_name
78
+ field slug_history_name, :type => Array, :default => []
79
+ end
80
+
87
81
  if options[:index]
88
- index(slug_name, :unique => !slug_scope)
82
+ index slug_name, :unique => !slug_scope
83
+ index slug_history_name if slug_history_name
89
84
  end
90
85
 
91
- if options[:permanent]
92
- before_create :generate_slug
93
- else
94
- before_save :generate_slug
86
+ set_callback options[:permanent] ? :create : :save, :before do |doc|
87
+ doc.build_slug if doc.slug_should_be_rebuilt?
95
88
  end
96
89
 
97
- # Build a finder based on the slug name.
90
+ # Build a finder for slug.
98
91
  #
99
92
  # Defaults to `find_by_slug`.
100
93
  instance_eval <<-CODE
101
94
  def self.find_by_#{slug_name}(slug)
102
- where(slug_name => slug).first
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
103
100
  end
104
101
 
105
102
  def self.find_by_#{slug_name}!(slug)
106
- where(slug_name => slug).first ||
107
- raise(Mongoid::Errors::DocumentNotFound.new(self, slug))
103
+ self.find_by_#{slug_name}(slug) ||
104
+ raise(Mongoid::Errors::DocumentNotFound.new self, slug)
108
105
  end
109
106
  CODE
110
- end
111
- end
112
107
 
113
- # Returns the slug.
114
- def to_param
115
- read_attribute(slug_name) || begin
116
- generate_slug!
117
- save
118
- read_attribute(slug_name)
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
+ }
119
118
  end
120
- end
119
+
120
+ # Finds a unique slug, were specified string used to generate a slug.
121
+ #
122
+ # Returned slug will the same as the specified string when there are no
123
+ # duplicates.
124
+ #
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+))?$/
121
151
 
122
- private
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)
123
156
 
124
- def find_unique_slug
125
- # TODO: An epic method which calls for refactoring.
126
- slug = slug_builder.call(self).to_url
127
-
128
- # Regular expression that matches slug, slug-1, ... slug-n
129
- # If slug_name field was indexed, MongoDB will utilize that
130
- # index to match /^.../ pattern.
131
- pattern = /^#{Regexp.escape(slug)}(?:-(\d+))?$/
132
-
133
- if slug_scope &&
134
- self.class.reflect_on_association(slug_scope).nil?
135
- # scope is not an association, so it's scoped to a local field
136
- # (e.g. an association id in a denormalized db design)
137
- existing_slugs =
138
- self.class.
139
- only(slug_name).
140
- where(slug_name => pattern,
141
- :_id.ne => _id,
142
- slug_scope => self[slug_scope])
143
- else
144
- existing_slugs =
145
- uniqueness_scope.
146
- only(slug_name).
147
- where(slug_name => pattern, :_id.ne => _id)
148
- end
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
149
161
 
150
- existing_slugs = existing_slugs.map do |obj|
151
- obj.try(:read_attribute, slug_name)
152
- end
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
180
+
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)
186
+
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
191
+
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
199
+
200
+ history_slugged_documents =
201
+ scope_object.
202
+ where(where_hash)
203
+ end
153
204
 
154
- if existing_slugs.count > 0
155
- # Sort the existing_slugs in increasing order by comparing the
156
- # suffix numbers:
157
- # slug, slug-1, slug-2, ..., slug-n
158
- existing_slugs.sort! do |a, b|
159
- (pattern.match(a)[1] || -1).to_i <=>
160
- (pattern.match(b)[1] || -1).to_i
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
211
+
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
224
+
225
+ existing_slugs += existing_history_slugs
226
+ end
227
+
228
+ if reserved_words_in_slug.any? { |word| word === slug }
229
+ existing_slugs << slug
161
230
  end
162
- max = existing_slugs.last.match(/-(\d+)$/).try(:[], 1).to_i
163
231
 
164
- slug += "-#{max + 1}"
232
+ if existing_slugs.count > 0
233
+ # Sort the existing_slugs in increasing order by comparing the
234
+ # suffix numbers:
235
+ # slug, slug-1, slug-2, ..., slug-n
236
+ existing_slugs.sort! do |a, b|
237
+ (pattern.match(a)[1] || -1).to_i <=>
238
+ (pattern.match(b)[1] || -1).to_i
239
+ end
240
+ max = existing_slugs.last.match(/-(\d+)$/).try(:[], 1).to_i
241
+
242
+ slug += "-#{max + 1}"
243
+ end
244
+
245
+ slug
165
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)
166
254
 
167
- slug
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
268
+ 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
168
279
  end
169
280
 
170
- def generate_slug
171
- # Generate a slug for new records only if the slug was not set.
172
- # If we're not a new record generate a slug if our slugged fields
173
- # changed on us.
174
- if (new_record? && !read_attribute(slug_name)) ||
175
- (!new_record? && slugged_fields_changed?)
176
- generate_slug!
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!
177
293
  end
294
+
295
+ true
178
296
  end
179
297
 
180
- def generate_slug!
181
- write_attribute(slug_name, find_unique_slug)
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
182
307
  end
183
308
 
184
- def slugged_fields_changed?
185
- slugged_fields.any? { |f| attribute_changed?(f) }
309
+ # @return [Boolean] Whether the slug requires to be rebuilt
310
+ def slug_should_be_rebuilt?
311
+ new_record? or slug_changed? or slugged_attributes_changed?
186
312
  end
187
313
 
188
- def uniqueness_scope
189
- if slug_scope
190
- metadata = self.class.reflect_on_association(slug_scope)
191
- parent = self.send(metadata.name)
314
+ unless self.respond_to? :slug
315
+ def slug
316
+ read_attribute slug_name
317
+ end
192
318
 
193
- # Make sure doc is actually associated with something, and that
194
- # some referenced docs have been persisted to the parent
195
- #
196
- # TODO: we need better reflection for reference associations,
197
- # like association_name instead of forcing collection_name here
198
- # -- maybe in the forthcoming Mongoid refactorings?
199
- inverse = metadata.inverse_of || collection_name
200
- parent.respond_to?(inverse) ? parent.send(inverse) : self.class
201
- elsif embedded?
202
- parent_metadata = reflect_on_all_associations(:embedded_in)[0]
203
- _parent.send(parent_metadata.inverse_of || self.metadata.name)
204
- else
205
- appropriate_class = self.class
206
- while appropriate_class.superclass.include?(Mongoid::Document)
207
- appropriate_class = appropriate_class.superclass
208
- end
209
- appropriate_class
319
+ def slug_changed?
320
+ attribute_changed? slug_name
321
+ end
322
+
323
+ def slug_was
324
+ attribute_was slug_name
325
+ end
326
+ end
327
+
328
+ def slugged_attributes_changed?
329
+ slugged_attributes.any? { |f| attribute_changed? f }
330
+ end
331
+
332
+ # @return [String] A string which Action Pack uses for constructing an URL
333
+ # to this record.
334
+ def to_param
335
+ unless slug
336
+ build_slug
337
+ save
210
338
  end
339
+
340
+ slug
341
+ end
342
+
343
+ private
344
+
345
+ def find_unique_slug
346
+ find_unique_slug_for user_defined_slug || slug_builder.call(self)
347
+ end
348
+
349
+ def user_defined_slug
350
+ slug if new_record? and slug.present? or slug_changed?
211
351
  end
212
352
  end
213
353
  end
@@ -1,5 +1,5 @@
1
1
  module Mongoid #:nodoc:
2
2
  module Slug
3
- VERSION = '0.9.0'
3
+ VERSION = '0.10.0'
4
4
  end
5
5
  end
@@ -0,0 +1,6 @@
1
+ class Alias
2
+ include Mongoid::Document
3
+ include Mongoid::Slug
4
+ field :name, :as => :author_name
5
+ slug :author_name
6
+ end
data/spec/models/book.rb CHANGED
@@ -2,7 +2,7 @@ class Book
2
2
  include Mongoid::Document
3
3
  include Mongoid::Slug
4
4
  field :title
5
- slug :title, :index => true
5
+ slug :title, :index => true, :history => true
6
6
  embeds_many :subjects
7
7
  references_many :authors
8
8
  end
@@ -0,0 +1,6 @@
1
+ class Friend
2
+ include Mongoid::Document
3
+ include Mongoid::Slug
4
+ field :name
5
+ slug :name, :reserve => ['foo', 'bar', /^[a-z]{2}$/i]
6
+ end
@@ -2,6 +2,6 @@ class Subject
2
2
  include Mongoid::Document
3
3
  include Mongoid::Slug
4
4
  field :name
5
- slug :name
5
+ slug :name, :scope => :book, :history => true
6
6
  embedded_in :book
7
7
  end
@@ -58,7 +58,7 @@ module Mongoid
58
58
 
59
59
  it "generates a unique slug by appending a counter to duplicate text" do
60
60
  dup = book.subjects.create(:name => subject.name)
61
- dup.to_param.should eql 'psychoanalysis-1'
61
+ dup.to_param.should eql "psychoanalysis-1"
62
62
  end
63
63
 
64
64
  it "does not update slug if slugged fields have not changed" do
@@ -147,26 +147,25 @@ module Mongoid
147
147
  dup = Author.create(
148
148
  :first_name => author.first_name,
149
149
  :last_name => author.last_name)
150
- dup.to_param.should eql 'gilles-deleuze-1'
150
+ dup.to_param.should eql "gilles-deleuze-1"
151
151
 
152
152
  dup2 = Author.create(
153
153
  :first_name => author.first_name,
154
154
  :last_name => author.last_name)
155
155
 
156
156
  dup.save
157
- dup2.to_param.should eql 'gilles-deleuze-2'
157
+ dup2.to_param.should eql "gilles-deleuze-2"
158
158
  end
159
159
 
160
160
  it "does not update slug if slugged fields have changed but generated slug is identical" do
161
161
  author.last_name = "DELEUZE"
162
162
  author.save
163
- author.to_param.should eql 'gilles-deleuze'
163
+ author.to_param.should eql "gilles-deleuze"
164
164
  end
165
165
 
166
166
  it "finds by slug" do
167
167
  Author.find_by_slug("gilles-deleuze").should eql author
168
168
  end
169
-
170
169
  end
171
170
 
172
171
  context "when :as is passed as an argument" do
@@ -196,6 +195,40 @@ module Mongoid
196
195
  end
197
196
  end
198
197
 
198
+ context "when :history is passed as an argument" do
199
+ let(:book) do
200
+ Book.create(:title => "Book Title")
201
+ end
202
+
203
+ before(:each) do
204
+ book.title = "Other Book Title"
205
+ book.save
206
+ end
207
+
208
+ it "saves the old slug in the owner's history" do
209
+ book.slug_history.should include("book-title")
210
+ end
211
+
212
+ it "returns the document for the old slug" do
213
+ Book.find_by_slug("book-title").should == book
214
+ end
215
+
216
+ it "returns the document for the new slug" do
217
+ Book.find_by_slug("other-book-title").should == book
218
+ end
219
+
220
+ it "generates a unique slug by appending a counter to duplicate text" do
221
+ dup = Book.create(:title => "Book Title")
222
+ dup.to_param.should eql "book-title-1"
223
+ end
224
+
225
+ it "ensures no duplicate values are stored in history" do
226
+ book.update_attributes :title => 'Book Title'
227
+ book.update_attributes :title => 'Foo'
228
+ book.slug_history.find_all { |slug| slug == 'book-title' }.size.should eql 1
229
+ end
230
+ end
231
+
199
232
  context "when slug is scoped by a reference association" do
200
233
  let(:author) do
201
234
  book.authors.create(:first_name => "Gilles", :last_name => "Deleuze")
@@ -214,7 +247,7 @@ module Mongoid
214
247
  dup = book.authors.create(
215
248
  :first_name => author.first_name,
216
249
  :last_name => author.last_name)
217
- dup.to_param.should eql 'gilles-deleuze-1'
250
+ dup.to_param.should eql "gilles-deleuze-1"
218
251
  end
219
252
 
220
253
  context "with an irregular association name" do
@@ -235,46 +268,70 @@ module Mongoid
235
268
  dup.to_param.should eql character.to_param
236
269
  end
237
270
  end
271
+
272
+ context "when using history and reusing a slug within the scope" do
273
+ let!(:subject1) do
274
+ book.subjects.create(:name => "A Subject")
275
+ end
276
+ let!(:subject2) do
277
+ book.subjects.create(:name => "Another Subject")
278
+ end
279
+
280
+ before(:each) do
281
+ subject1.name = "Something Else Entirely"
282
+ subject1.save
283
+ subject2.name = "A Subject"
284
+ subject2.save
285
+ end
286
+
287
+ it "allows using the slug" do
288
+ subject2.slug.should == "a-subject"
289
+ end
290
+
291
+ it "removes the slug from the old owner's history" do
292
+ subject1.slug_history.should_not include("a-subject")
293
+ end
294
+ end
238
295
  end
239
-
296
+
240
297
  context "when slug is scoped by one of the class's own fields" do
241
298
  let!(:magazine) do
242
299
  Magazine.create(:title => "Big Weekly", :publisher_id => "abc123")
243
300
  end
244
301
 
245
302
  it "should scope by local field" do
246
- magazine.to_param.should eql 'big-weekly'
303
+ magazine.to_param.should eql "big-weekly"
247
304
  magazine2 = Magazine.create(:title => "Big Weekly", :publisher_id => "def456")
248
305
  magazine2.to_param.should eql magazine.to_param
249
306
  end
250
307
 
251
308
  it "should generate a unique slug by appending a counter to duplicate text" do
252
309
  dup = Magazine.create(:title => "Big Weekly", :publisher_id => "abc123")
253
- dup.to_param.should eql 'big-weekly-1'
310
+ dup.to_param.should eql "big-weekly-1"
254
311
  end
255
312
  end
256
313
 
257
- context "when :slug is given a block" do
314
+ context "when #slug is given a block" do
258
315
  let(:caption) do
259
- Caption.create(:identity => 'Edward Hopper (American, 1882-1967)',
260
- :title => 'Soir Bleu, 1914',
261
- :medium => 'Oil on Canvas')
316
+ Caption.create(:identity => "Edward Hopper (American, 1882-1967)",
317
+ :title => "Soir Bleu, 1914",
318
+ :medium => "Oil on Canvas")
262
319
  end
263
320
 
264
321
  it "generates a slug" do
265
- caption.to_param.should eql 'edward-hopper-soir-bleu-1914'
322
+ caption.to_param.should eql "edward-hopper-soir-bleu-1914"
266
323
  end
267
324
 
268
325
  it "updates the slug" do
269
- caption.title = 'Road in Maine, 1914'
326
+ caption.title = "Road in Maine, 1914"
270
327
  caption.save
271
328
  caption.to_param.should eql "edward-hopper-road-in-maine-1914"
272
329
  end
273
330
 
274
331
  it "does not change slug if slugged fields have changed but generated slug is identical" do
275
- caption.identity = 'Edward Hopper'
332
+ caption.identity = "Edward Hopper"
276
333
  caption.save
277
- caption.to_param.should eql 'edward-hopper-soir-bleu-1914'
334
+ caption.to_param.should eql "edward-hopper-soir-bleu-1914"
278
335
  end
279
336
 
280
337
  it "finds by slug" do
@@ -298,13 +355,13 @@ module Mongoid
298
355
  it "slugs Chinese characters" do
299
356
  book.title = "中文"
300
357
  book.save
301
- book.to_param.should eql 'zhong-wen'
358
+ book.to_param.should eql "zhong-wen"
302
359
  end
303
360
 
304
361
  it "slugs non-ASCII Latin characters" do
305
- book.title = 'Paul Cézanne'
362
+ book.title = "Paul Cézanne"
306
363
  book.save
307
- book.to_param.should eql 'paul-cezanne'
364
+ book.to_param.should eql "paul-cezanne"
308
365
  end
309
366
  end
310
367
 
@@ -340,6 +397,29 @@ module Mongoid
340
397
  Person.collection.index_information.should_not have_key "permalink_1"
341
398
  end
342
399
  end
400
+
401
+ context "when :reserve is passed" do
402
+ it "does not use the the reserved slugs" do
403
+ friend1 = Friend.create(:name => "foo")
404
+ friend1.slug.should_not eql("foo")
405
+ friend1.slug.should eql("foo-1")
406
+
407
+ friend2 = Friend.create(:name => "bar")
408
+ friend2.slug.should_not eql("bar")
409
+ friend2.slug.should eql("bar-1")
410
+
411
+ friend3 = Friend.create(:name => "en")
412
+ friend3.slug.should_not eql("en")
413
+ friend3.slug.should eql("en-1")
414
+ end
415
+
416
+ it "should start with concatenation -1" do
417
+ friend1 = Friend.create(:name => "foo")
418
+ friend1.slug.should eql("foo-1")
419
+ friend2 = Friend.create(:name => "foo")
420
+ friend2.slug.should eql("foo-2")
421
+ end
422
+ end
343
423
 
344
424
  context "when the object has STI" do
345
425
  it "scopes by the superclass" do
@@ -349,6 +429,25 @@ module Mongoid
349
429
  end
350
430
  end
351
431
 
432
+ context "when slug defined on alias of field" do
433
+ it "should use accessor, not alias" do
434
+ pseudonim = Alias.create(:author_name => "Max Stirner")
435
+ pseudonim.slug.should eql("max-stirner")
436
+ end
437
+ end
438
+
439
+ describe ".by_slug scope" do
440
+ let!(:author) { book.authors.create(:first_name => "Gilles", :last_name => "Deleuze") }
441
+
442
+ it "returns an empty array if no document is found" do
443
+ book.authors.by_slug("never-heard-of").should == []
444
+ end
445
+
446
+ it "returns an array containing the document if it is found" do
447
+ book.authors.by_slug(author.slug).should == [author]
448
+ end
449
+ end
450
+
352
451
  describe ".find_by_slug" do
353
452
  let!(:book) { Book.create(:title => "A Thousand Plateaus") }
354
453
 
@@ -386,11 +485,62 @@ module Mongoid
386
485
  book.reload.slug.should eql "proust-and-signs"
387
486
  end
388
487
  end
389
-
390
- context "when the slugged field is set upon creation" do
391
- it "respects the provided slug and does not generate a new one" do
392
- book = Book.create(:title => "A Thousand Plateaus", :slug => 'not-what-you-expected')
393
- book.to_param.should eql "not-what-you-expected"
488
+
489
+ describe ".for_unique_slug_for" do
490
+ it "returns the unique slug" do
491
+ Book.find_unique_slug_for("A Thousand Plateaus").should eq("a-thousand-plateaus")
492
+ end
493
+
494
+ it "returns the unique slug with a counter if necessary" do
495
+ Book.create(:title => "A Thousand Plateaus")
496
+ Book.find_unique_slug_for("A Thousand Plateaus").should eq("a-thousand-plateaus-1")
497
+ end
498
+
499
+ it "returns the unique slug as if it were the provided object" do
500
+ book = Book.create(:title => "A Thousand Plateaus")
501
+ Book.find_unique_slug_for("A Thousand Plateaus", :model => book).should eq("a-thousand-plateaus")
502
+ end
503
+ end
504
+
505
+ describe "#find_unique_slug_for" do
506
+ let!(:book) { Book.create(:title => "A Thousand Plateaus") }
507
+
508
+ it "returns the unique slug" do
509
+ book.find_unique_slug_for("Anti Oedipus").should eq("anti-oedipus")
510
+ end
511
+
512
+ it "returns the unique slug with a counter if necessary" do
513
+ Book.create(:title => "Anti Oedipus")
514
+ book.find_unique_slug_for("Anti Oedipus").should eq("anti-oedipus-1")
515
+ end
516
+ end
517
+
518
+ context "when the slugged field is set manually" do
519
+ context "when it set to a non-empty string" do
520
+ it "respects the provided slug" do
521
+ book = Book.create(:title => "A Thousand Plateaus", :slug => "not-what-you-expected")
522
+ book.to_param.should eql "not-what-you-expected"
523
+ end
524
+
525
+ it "ensures uniqueness" do
526
+ book1 = Book.create(:title => "A Thousand Plateaus", :slug => "not-what-you-expected")
527
+ book2 = Book.create(:title => "A Thousand Plateaus", :slug => "not-what-you-expected")
528
+ book2.to_param.should eql "not-what-you-expected-1"
529
+ end
530
+
531
+ it "updates the slug when a new one is passed in" do
532
+ book = Book.create(:title => "A Thousand Plateaus", :slug => "not-what-you-expected")
533
+ book.slug = "not-it-either"
534
+ book.save
535
+ book.to_param.should eql "not-it-either"
536
+ end
537
+ end
538
+
539
+ context "when it is set to an empty string" do
540
+ it "generate a new one" do
541
+ book = Book.create(:title => "A Thousand Plateaus", :slug => "")
542
+ book.to_param.should eql "a-thousand-plateaus"
543
+ end
394
544
  end
395
545
  end
396
546
  end
data/spec/spec_helper.rb CHANGED
@@ -1,12 +1,13 @@
1
- require "rubygems"
2
- require "bundler/setup"
1
+ require 'rubygems'
2
+ require 'bundler/setup'
3
3
 
4
- require "rspec"
4
+ require 'pry'
5
+ require 'rspec'
5
6
 
6
- require File.expand_path("../../lib/mongoid/slug", __FILE__)
7
+ require File.expand_path('../../lib/mongoid/slug', __FILE__)
7
8
 
8
9
  Mongoid.configure do |config|
9
- name = "mongoid_slug_test"
10
+ name = 'mongoid_slug_test'
10
11
  config.master = Mongo::Connection.new.db(name)
11
12
  end
12
13
 
metadata CHANGED
@@ -1,19 +1,19 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mongoid_slug
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.10.0
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
8
- - Paper Cavalier
8
+ - Hakan Ensari
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2012-01-10 00:00:00.000000000 Z
12
+ date: 2012-03-15 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: mongoid
16
- requirement: &70354887937940 !ruby/object:Gem::Requirement
16
+ requirement: &70314214792060 !ruby/object:Gem::Requirement
17
17
  none: false
18
18
  requirements:
19
19
  - - ~>
@@ -21,10 +21,10 @@ dependencies:
21
21
  version: '2.0'
22
22
  type: :runtime
23
23
  prerelease: false
24
- version_requirements: *70354887937940
24
+ version_requirements: *70314214792060
25
25
  - !ruby/object:Gem::Dependency
26
26
  name: stringex
27
- requirement: &70354887953780 !ruby/object:Gem::Requirement
27
+ requirement: &70314214791560 !ruby/object:Gem::Requirement
28
28
  none: false
29
29
  requirements:
30
30
  - - ~>
@@ -32,10 +32,32 @@ dependencies:
32
32
  version: '1.3'
33
33
  type: :runtime
34
34
  prerelease: false
35
- version_requirements: *70354887953780
35
+ version_requirements: *70314214791560
36
+ - !ruby/object:Gem::Dependency
37
+ name: bson_ext
38
+ requirement: &70314214791100 !ruby/object:Gem::Requirement
39
+ none: false
40
+ requirements:
41
+ - - ~>
42
+ - !ruby/object:Gem::Version
43
+ version: '1.6'
44
+ type: :development
45
+ prerelease: false
46
+ version_requirements: *70314214791100
47
+ - !ruby/object:Gem::Dependency
48
+ name: pry
49
+ requirement: &70314214790640 !ruby/object:Gem::Requirement
50
+ none: false
51
+ requirements:
52
+ - - ~>
53
+ - !ruby/object:Gem::Version
54
+ version: '0.9'
55
+ type: :development
56
+ prerelease: false
57
+ version_requirements: *70314214790640
36
58
  - !ruby/object:Gem::Dependency
37
59
  name: rake
38
- requirement: &70354887953320 !ruby/object:Gem::Requirement
60
+ requirement: &70314214790180 !ruby/object:Gem::Requirement
39
61
  none: false
40
62
  requirements:
41
63
  - - ~>
@@ -43,22 +65,21 @@ dependencies:
43
65
  version: '0.9'
44
66
  type: :development
45
67
  prerelease: false
46
- version_requirements: *70354887953320
68
+ version_requirements: *70314214790180
47
69
  - !ruby/object:Gem::Dependency
48
70
  name: rspec
49
- requirement: &70354887952860 !ruby/object:Gem::Requirement
71
+ requirement: &70314214789720 !ruby/object:Gem::Requirement
50
72
  none: false
51
73
  requirements:
52
74
  - - ~>
53
75
  - !ruby/object:Gem::Version
54
- version: '2.6'
76
+ version: '2.8'
55
77
  type: :development
56
78
  prerelease: false
57
- version_requirements: *70354887952860
58
- description: Mongoid Slug generates a URL slug or permalink based on one or more fields
59
- in a Mongoid model.
79
+ version_requirements: *70314214789720
80
+ description: ! " a \n a ."
60
81
  email:
61
- - code@papercavalier.com
82
+ - hakan.ensari@papercavalier.com
62
83
  executables: []
63
84
  extensions: []
64
85
  extra_rdoc_files: []
@@ -68,10 +89,12 @@ files:
68
89
  - lib/mongoid_slug.rb
69
90
  - LICENSE
70
91
  - README.md
92
+ - spec/models/alias.rb
71
93
  - spec/models/article.rb
72
94
  - spec/models/author.rb
73
95
  - spec/models/book.rb
74
96
  - spec/models/caption.rb
97
+ - spec/models/friend.rb
75
98
  - spec/models/magazine.rb
76
99
  - spec/models/page.rb
77
100
  - spec/models/partner.rb
@@ -80,7 +103,7 @@ files:
80
103
  - spec/models/subject.rb
81
104
  - spec/mongoid/slug_spec.rb
82
105
  - spec/spec_helper.rb
83
- homepage: http://github.com/papercavalier/mongoid-slug
106
+ homepage: http://github.com/hakanensari/mongoid-slug
84
107
  licenses: []
85
108
  post_install_message:
86
109
  rdoc_options: []
@@ -103,12 +126,14 @@ rubyforge_project: mongoid_slug
103
126
  rubygems_version: 1.8.11
104
127
  signing_key:
105
128
  specification_version: 3
106
- summary: Generates a URL slug
129
+ summary: Generates a URL slug in a Mongoid model
107
130
  test_files:
131
+ - spec/models/alias.rb
108
132
  - spec/models/article.rb
109
133
  - spec/models/author.rb
110
134
  - spec/models/book.rb
111
135
  - spec/models/caption.rb
136
+ - spec/models/friend.rb
112
137
  - spec/models/magazine.rb
113
138
  - spec/models/page.rb
114
139
  - spec/models/partner.rb