mongoid-slug 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +20 -0
  3. data/README.md +333 -0
  4. data/lib/mongoid/slug.rb +333 -0
  5. data/lib/mongoid/slug/criteria.rb +110 -0
  6. data/lib/mongoid/slug/index.rb +27 -0
  7. data/lib/mongoid/slug/paranoia.rb +22 -0
  8. data/lib/mongoid/slug/slug_id_strategy.rb +3 -0
  9. data/lib/mongoid/slug/unique_slug.rb +153 -0
  10. data/lib/mongoid/slug/version.rb +5 -0
  11. data/lib/mongoid_slug.rb +2 -0
  12. data/spec/models/alias.rb +6 -0
  13. data/spec/models/article.rb +9 -0
  14. data/spec/models/author.rb +11 -0
  15. data/spec/models/author_polymorphic.rb +11 -0
  16. data/spec/models/book.rb +12 -0
  17. data/spec/models/book_polymorphic.rb +12 -0
  18. data/spec/models/caption.rb +17 -0
  19. data/spec/models/entity.rb +12 -0
  20. data/spec/models/friend.rb +7 -0
  21. data/spec/models/incorrect_slug_persistence.rb +9 -0
  22. data/spec/models/integer_id.rb +9 -0
  23. data/spec/models/magazine.rb +7 -0
  24. data/spec/models/page.rb +9 -0
  25. data/spec/models/page_localize.rb +9 -0
  26. data/spec/models/page_slug_localized.rb +9 -0
  27. data/spec/models/page_slug_localized_custom.rb +11 -0
  28. data/spec/models/page_slug_localized_history.rb +9 -0
  29. data/spec/models/paranoid_document.rb +8 -0
  30. data/spec/models/paranoid_permanent.rb +8 -0
  31. data/spec/models/partner.rb +7 -0
  32. data/spec/models/person.rb +8 -0
  33. data/spec/models/relationship.rb +8 -0
  34. data/spec/models/string_id.rb +9 -0
  35. data/spec/models/subject.rb +7 -0
  36. data/spec/models/without_slug.rb +5 -0
  37. data/spec/mongoid/criteria_spec.rb +190 -0
  38. data/spec/mongoid/index_spec.rb +34 -0
  39. data/spec/mongoid/paranoia_spec.rb +169 -0
  40. data/spec/mongoid/slug_spec.rb +1022 -0
  41. data/spec/mongoid/slug_spec.rb.b00 +1101 -0
  42. data/spec/shared/indexes.rb +27 -0
  43. data/spec/spec_helper.rb +47 -0
  44. metadata +245 -0
@@ -0,0 +1,110 @@
1
+ module Mongoid
2
+ module Slug
3
+ class Criteria < Mongoid::Criteria
4
+ # Find the matchind document(s) in the criteria for the provided ids or slugs.
5
+ #
6
+ # If the document _ids are of the type BSON::ObjectId, and all the supplied parameters are
7
+ # convertible to BSON::ObjectId (via BSON::ObjectId#from_string), finding will be
8
+ # performed via _ids.
9
+ #
10
+ # If the document has any other type of _id field, and all the supplied parameters are of the same
11
+ # type, finding will be performed via _ids.
12
+ #
13
+ # Otherwise finding will be performed via slugs.
14
+ #
15
+ # @example Find by an id.
16
+ # criteria.find(BSON::ObjectId.new)
17
+ #
18
+ # @example Find by multiple ids.
19
+ # criteria.find([ BSON::ObjectId.new, BSON::ObjectId.new ])
20
+ #
21
+ # @example Find by a slug.
22
+ # criteria.find('some-slug')
23
+ #
24
+ # @example Find by multiple slugs.
25
+ # criteria.find([ 'some-slug', 'some-other-slug' ])
26
+ #
27
+ # @param [ Array<Object> ] args The ids or slugs to search for.
28
+ #
29
+ # @return [ Array<Document>, Document ] The matching document(s).
30
+ def find(*args)
31
+ look_like_slugs?(args.__find_args__) ? find_by_slug!(*args) : super
32
+ end
33
+
34
+ # Find the matchind document(s) in the criteria for the provided slugs.
35
+ #
36
+ # @example Find by a slug.
37
+ # criteria.find('some-slug')
38
+ #
39
+ # @example Find by multiple slugs.
40
+ # criteria.find([ 'some-slug', 'some-other-slug' ])
41
+ #
42
+ # @param [ Array<Object> ] args The slugs to search for.
43
+ #
44
+ # @return [ Array<Document>, Document ] The matching document(s).
45
+ def find_by_slug!(*args)
46
+ slugs = args.__find_args__
47
+ raise_invalid if slugs.any?(&:nil?)
48
+ for_slugs(slugs).execute_or_raise_for_slugs(slugs, args.multi_arged?)
49
+ end
50
+
51
+ def look_like_slugs?(args)
52
+ return false unless args.all? { |id| id.is_a?(String) }
53
+ id_field = @klass.fields['_id']
54
+ @slug_strategy ||= id_field.options[:slug_id_strategy] || build_slug_strategy(id_field.type)
55
+ args.none? { |id| @slug_strategy.call(id) }
56
+ end
57
+
58
+ protected
59
+
60
+ # unless a :slug_id_strategy option is defined on the id field,
61
+ # use object_id or string strategy depending on the id_type
62
+ # otherwise default for all other id_types
63
+ def build_slug_strategy id_type
64
+ type_method = id_type.to_s.downcase.split('::').last + "_slug_strategy"
65
+ self.respond_to?(type_method, true) ? method(type_method) : lambda {|id| false}
66
+ end
67
+
68
+ # a string will not look like a slug if it looks like a legal BSON::ObjectId
69
+ def objectid_slug_strategy id
70
+ if Mongoid::Slug.mongoid3?
71
+ Moped::BSON::ObjectId.legal? id
72
+ else
73
+ BSON::ObjectId.legal?(id)
74
+ end
75
+ end
76
+
77
+ # a string will always look like a slug
78
+ def string_slug_strategy id
79
+ true
80
+ end
81
+
82
+
83
+ def for_slugs(slugs)
84
+ #_translations
85
+ localized = (@klass.fields['_slugs'].options[:localize] rescue false)
86
+ if localized
87
+ def_loc = I18n.default_locale
88
+ query = { '$in' => slugs }
89
+ where({'$or' => [{ _slugs: query }, { "_slugs.#{def_loc}" => query }]}).limit(slugs.length)
90
+ else
91
+ where({ _slugs: { '$in' => slugs } }).limit(slugs.length)
92
+ end
93
+ end
94
+
95
+ def execute_or_raise_for_slugs(slugs, multi)
96
+ result = uniq
97
+ check_for_missing_documents_for_slugs!(result, slugs)
98
+ multi ? result : result.first
99
+ end
100
+
101
+ def check_for_missing_documents_for_slugs!(result, slugs)
102
+ missing_slugs = slugs - result.map(&:slugs).flatten
103
+
104
+ if !missing_slugs.blank? && Mongoid.raise_not_found_error
105
+ raise Errors::DocumentNotFound.new(klass, slugs, missing_slugs)
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,27 @@
1
+ module Mongoid
2
+ module Slug
3
+ module Index
4
+
5
+ # @param [ String or Symbol ] scope_key The optional scope key for the index
6
+ # @param [ Boolean ] by_model_type Whether or not
7
+ #
8
+ # @return [ Array(Hash, Hash) ] the indexable fields and index options.
9
+ def self.build_index(scope_key = nil, by_model_type = false)
10
+ fields = {_slugs: 1}
11
+ options = {}
12
+
13
+ if scope_key
14
+ fields.merge!({scope_key => 1})
15
+ end
16
+
17
+ if by_model_type
18
+ fields.merge!({_type: 1})
19
+ else
20
+ options.merge!({unique: true, sparse: true})
21
+ end
22
+
23
+ return [fields, options]
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,22 @@
1
+ module Mongoid
2
+ module Slug
3
+
4
+ # Lightweight compatibility shim which adds the :restore callback to
5
+ # older versions of Mongoid::Paranoia
6
+ module Paranoia
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+
11
+ define_model_callbacks :restore
12
+
13
+ def restore_with_callbacks
14
+ run_callbacks(:restore) do
15
+ restore_without_callbacks
16
+ end
17
+ end
18
+ alias_method_chain :restore, :callbacks
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,3 @@
1
+ Mongoid::Fields.option(:slug_id_strategy) do |model, field, value|
2
+ field.options[:slug_id_strategy] = value
3
+ end
@@ -0,0 +1,153 @@
1
+ require 'forwardable'
2
+
3
+ # Can use e.g. Mongoid::Slug::UniqueSlug.new(ModelClass.new).find_unique "slug-1" for auto-suggest ui
4
+ module Mongoid
5
+ module Slug
6
+ class UniqueSlug
7
+ MUTEX_FOR_SLUG = Mutex.new
8
+ class SlugState
9
+ attr_reader :last_entered_slug, :existing_slugs, :existing_history_slugs, :sorted_existing
10
+
11
+ def initialize slug, documents, pattern
12
+ @slug = slug
13
+ @documents = documents
14
+ @pattern = pattern
15
+ @last_entered_slug = []
16
+ @existing_slugs = []
17
+ @existing_history_slugs = []
18
+ @sorted_existing = []
19
+ @documents.each do |doc|
20
+ history_slugs = doc._slugs
21
+ next if history_slugs.nil?
22
+ existing_slugs.push(*history_slugs.find_all { |cur_slug| cur_slug =~ @pattern })
23
+ last_entered_slug.push(*history_slugs.last) if history_slugs.last =~ @pattern
24
+ existing_history_slugs.push(*history_slugs.first(history_slugs.length() - 1).find_all { |cur_slug| cur_slug =~ @pattern })
25
+ end
26
+ end
27
+
28
+ def slug_included?
29
+ existing_slugs.include? @slug
30
+ end
31
+
32
+ def include_slug
33
+ existing_slugs << @slug
34
+ end
35
+
36
+ def highest_existing_counter
37
+ sort_existing_slugs
38
+ @sorted_existing.last || 0
39
+ end
40
+
41
+ def sort_existing_slugs
42
+ # remove the slug part and leave the absolute integer part and sort
43
+ re = %r(^#{Regexp.escape(@slug)})
44
+ @sorted_existing = existing_slugs.map do |s|
45
+ s.sub(re,'').to_i.abs
46
+ end.sort
47
+ end
48
+
49
+ def inspect
50
+ {
51
+ :slug => @slug,
52
+ :existing_slugs => existing_slugs,
53
+ :last_entered_slug => last_entered_slug,
54
+ :existing_history_slugs => existing_history_slugs,
55
+ :sorted_existing => sorted_existing
56
+ }
57
+ end
58
+ end
59
+
60
+ extend Forwardable
61
+
62
+ attr_reader :model, :_slug
63
+
64
+ def_delegators :@model, :slug_scope, :reflect_on_association, :read_attribute,
65
+ :check_against_id, :reserved_words, :url_builder, :collection_name,
66
+ :embedded?, :reflect_on_all_associations, :by_model_type
67
+
68
+ def initialize model
69
+ @model = model
70
+ @_slug = ""
71
+ @state = nil
72
+ end
73
+
74
+ def metadata
75
+ @model.respond_to?(:relation_metadata) ? @model.relation_metadata : @model.metadata
76
+ end
77
+
78
+ def find_unique attempt = nil
79
+ MUTEX_FOR_SLUG.synchronize do
80
+ @_slug = if attempt
81
+ attempt.to_url
82
+ else
83
+ url_builder.call(model)
84
+ end
85
+ # Regular expression that matches slug, slug-1, ... slug-n
86
+ # If slug_name field was indexed, MongoDB will utilize that
87
+ # index to match /^.../ pattern.
88
+ pattern = /^#{Regexp.escape(@_slug)}(?:-(\d+))?$/
89
+
90
+ where_hash = {}
91
+ where_hash[:_slugs.all] = [pattern]
92
+ where_hash[:_id.ne] = model._id
93
+
94
+ if (scope = slug_scope) && reflect_on_association(scope).nil?
95
+ # scope is not an association, so it's scoped to a local field
96
+ # (e.g. an association id in a denormalized db design)
97
+ where_hash[scope] = model.try(:read_attribute, scope)
98
+ end
99
+
100
+ if by_model_type == true
101
+ where_hash[:_type] = model.try(:read_attribute, :_type)
102
+ end
103
+
104
+ @state = SlugState.new @_slug, uniqueness_scope.unscoped.where(where_hash), pattern
105
+
106
+ # do not allow a slug that can be interpreted as the current document id
107
+ @state.include_slug unless model.class.look_like_slugs?([@_slug])
108
+
109
+ # make sure that the slug is not equal to a reserved word
110
+ @state.include_slug if reserved_words.any? { |word| word === @_slug }
111
+
112
+ # only look for a new unique slug if the existing slugs contains the current slug
113
+ # - e.g if the slug 'foo-2' is taken, but 'foo' is available, the user can use 'foo'.
114
+ if @state.slug_included?
115
+ highest = @state.highest_existing_counter
116
+ @_slug += "-#{highest.succ}"
117
+ end
118
+ @_slug
119
+ end
120
+ end
121
+
122
+ def uniqueness_scope
123
+
124
+ if slug_scope &&
125
+ metadata = reflect_on_association(slug_scope)
126
+
127
+ parent = model.send(metadata.name)
128
+
129
+ # Make sure doc is actually associated with something, and that
130
+ # some referenced docs have been persisted to the parent
131
+ #
132
+ # TODO: we need better reflection for reference associations,
133
+ # like association_name instead of forcing collection_name here
134
+ # -- maybe in the forthcoming Mongoid refactorings?
135
+ inverse = metadata.inverse_of || collection_name
136
+ return parent.respond_to?(inverse) ? parent.send(inverse) : model.class
137
+ end
138
+
139
+ if embedded?
140
+ parent_metadata = reflect_on_all_associations(:embedded_in)[0]
141
+ return model._parent.send(parent_metadata.inverse_of || self.metadata.name)
142
+ end
143
+
144
+ #unless embedded or slug scope, return the deepest document superclass
145
+ appropriate_class = model.class
146
+ while appropriate_class.superclass.include?(Mongoid::Document)
147
+ appropriate_class = appropriate_class.superclass
148
+ end
149
+ appropriate_class
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,5 @@
1
+ module Mongoid #:nodoc:
2
+ module Slug
3
+ VERSION = '4.0.0'
4
+ end
5
+ end
@@ -0,0 +1,2 @@
1
+ # To be removed
2
+ require 'mongoid/slug'
@@ -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
@@ -0,0 +1,9 @@
1
+ class Article
2
+ include Mongoid::Document
3
+ include Mongoid::Slug
4
+ field :brief
5
+ field :title
6
+ slug :title, :brief do |doc|
7
+ [doc.title, doc.brief].reject(&:blank?).first
8
+ end
9
+ end
@@ -0,0 +1,11 @@
1
+ class Author
2
+ include Mongoid::Document
3
+ include Mongoid::Slug
4
+ field :first_name
5
+ field :last_name
6
+ slug :first_name, :last_name, :scope => :book
7
+ belongs_to :book
8
+ has_many :characters,
9
+ :class_name => 'Person',
10
+ :foreign_key => :author_id
11
+ end
@@ -0,0 +1,11 @@
1
+ class AuthorPolymorphic
2
+ include Mongoid::Document
3
+ include Mongoid::Slug
4
+ field :first_name
5
+ field :last_name
6
+ slug :first_name, :last_name, :scope => :book_polymorphic
7
+ belongs_to :book_polymorphic
8
+ has_many :characters,
9
+ :class_name => 'Person',
10
+ :foreign_key => :author_id
11
+ end
@@ -0,0 +1,12 @@
1
+ class Book
2
+ include Mongoid::Document
3
+ include Mongoid::Slug
4
+ field :title
5
+
6
+ slug :title, :history => true
7
+ embeds_many :subjects
8
+ has_many :authors
9
+ end
10
+
11
+ class ComicBook < Book
12
+ end
@@ -0,0 +1,12 @@
1
+ class BookPolymorphic
2
+ include Mongoid::Document
3
+ include Mongoid::Slug
4
+ field :title
5
+
6
+ slug :title, :history => true, :by_model_type => true
7
+ embeds_many :subjects
8
+ has_many :author_polymorphics
9
+ end
10
+
11
+ class ComicBookPolymorphic < BookPolymorphic
12
+ end
@@ -0,0 +1,17 @@
1
+ class Caption
2
+ include Mongoid::Document
3
+ include Mongoid::Slug
4
+ field :my_identity, :type => String
5
+ field :title
6
+ field :medium
7
+
8
+ # A fairly complex scenario, where we want to create a slug out of an
9
+ # my_identity field, which comprises name of artist and some more bibliographic
10
+ # info in parantheses, and the title of the work.
11
+ #
12
+ # We are only interested in the name of the artist so we remove the
13
+ # paranthesized details.
14
+ slug :my_identity, :title do |cur_object|
15
+ cur_object.slug_builder.gsub(/\s*\([^)]+\)/, '').to_url
16
+ end
17
+ end
@@ -0,0 +1,12 @@
1
+ #encoding: utf-8
2
+ class Entity
3
+ include Mongoid::Document
4
+ include Mongoid::Slug
5
+
6
+ field :_id, type: String, slug_id_strategy: UuidIdStrategy
7
+
8
+ field :name
9
+ field :user_edited_variation
10
+
11
+ slug :user_edited_variation, :history => true
12
+ end
@@ -0,0 +1,7 @@
1
+ class Friend
2
+ include Mongoid::Document
3
+ include Mongoid::Slug
4
+ field :name
5
+ field :slug_history, :type => Array
6
+ slug :name, :reserve => ['foo', 'bar', /^[a-z]{2}$/i], :history => true
7
+ end
@@ -0,0 +1,9 @@
1
+ class IncorrectSlugPersistence
2
+ include Mongoid::Document
3
+ include Mongoid::Slug
4
+
5
+ field :name
6
+ slug :name, history: true
7
+
8
+ validates_length_of :name, :minimum => 4, :maximum => 5, :allow_blank => true
9
+ end