mongoid-slug 4.0.0

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