mongoid-slug 6.0.0 → 6.0.1
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.
- checksums.yaml +4 -4
- data/LICENSE +20 -20
- data/README.md +361 -336
- data/lib/mongoid/slug.rb +328 -328
- data/lib/mongoid/slug/criteria.rb +107 -107
- data/lib/mongoid/slug/{index.rb → index_builder.rb} +67 -45
- data/lib/mongoid/slug/railtie.rb +9 -9
- data/lib/mongoid/slug/slug_id_strategy.rb +3 -3
- data/lib/mongoid/slug/unique_slug.rb +173 -173
- data/lib/mongoid/slug/version.rb +5 -5
- data/lib/mongoid_slug.rb +2 -2
- data/lib/tasks/mongoid_slug.rake +15 -19
- data/spec/models/alias.rb +6 -6
- data/spec/models/article.rb +9 -9
- data/spec/models/artist.rb +8 -8
- data/spec/models/artwork.rb +10 -10
- data/spec/models/author.rb +15 -15
- data/spec/models/author_polymorphic.rb +15 -15
- data/spec/models/book.rb +12 -12
- data/spec/models/book_polymorphic.rb +12 -12
- data/spec/models/caption.rb +17 -17
- data/spec/models/entity.rb +11 -11
- data/spec/models/friend.rb +7 -7
- data/spec/models/incorrect_slug_persistence.rb +9 -9
- data/spec/models/integer_id.rb +9 -9
- data/spec/models/magazine.rb +7 -7
- data/spec/models/page.rb +9 -9
- data/spec/models/page_localize.rb +9 -9
- data/spec/models/page_slug_localized.rb +9 -9
- data/spec/models/page_slug_localized_custom.rb +10 -10
- data/spec/models/page_slug_localized_history.rb +9 -9
- data/spec/models/partner.rb +7 -7
- data/spec/models/person.rb +12 -12
- data/spec/models/relationship.rb +8 -8
- data/spec/models/string_id.rb +9 -9
- data/spec/models/subject.rb +7 -7
- data/spec/models/without_slug.rb +5 -5
- data/spec/mongoid/criteria_spec.rb +207 -207
- data/spec/mongoid/index_builder_spec.rb +105 -0
- data/spec/mongoid/slug_spec.rb +1175 -1169
- data/spec/shared/indexes.rb +41 -41
- data/spec/spec_helper.rb +61 -61
- data/spec/tasks/mongoid_slug_rake_spec.rb +73 -73
- metadata +31 -32
- data/spec/mongoid/index_spec.rb +0 -33
@@ -1,107 +1,107 @@
|
|
1
|
-
module Mongoid
|
2
|
-
module Slug
|
3
|
-
class Criteria < Mongoid::Criteria
|
4
|
-
# Find the matching 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
|
-
respond_to?(type_method, true) ? method(type_method) : ->(_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
|
-
Mongoid::Compatibility::ObjectId.legal?(id)
|
71
|
-
end
|
72
|
-
|
73
|
-
# a string will always look like a slug
|
74
|
-
def string_slug_strategy(_id)
|
75
|
-
true
|
76
|
-
end
|
77
|
-
|
78
|
-
def for_slugs(slugs)
|
79
|
-
# _translations
|
80
|
-
localized = (begin
|
81
|
-
@klass.fields['_slugs'].options[:localize]
|
82
|
-
rescue StandardError
|
83
|
-
false
|
84
|
-
end)
|
85
|
-
if localized
|
86
|
-
def_loc = I18n.default_locale
|
87
|
-
query = { '$in' => slugs }
|
88
|
-
where({ '$or' => [{ _slugs: query }, { "_slugs.#{def_loc}" => query }] }).limit(slugs.length)
|
89
|
-
else
|
90
|
-
where(_slugs: { '$in' => slugs }).limit(slugs.length)
|
91
|
-
end
|
92
|
-
end
|
93
|
-
|
94
|
-
def execute_or_raise_for_slugs(slugs, multi)
|
95
|
-
result = uniq
|
96
|
-
check_for_missing_documents_for_slugs!(result, slugs)
|
97
|
-
multi ? result : result.first
|
98
|
-
end
|
99
|
-
|
100
|
-
def check_for_missing_documents_for_slugs!(result, slugs)
|
101
|
-
missing_slugs = slugs - result.map(&:slugs).flatten
|
102
|
-
return unless !missing_slugs.blank? && Mongoid.raise_not_found_error
|
103
|
-
raise Errors::DocumentNotFound.new(klass, slugs, missing_slugs)
|
104
|
-
end
|
105
|
-
end
|
106
|
-
end
|
107
|
-
end
|
1
|
+
module Mongoid
|
2
|
+
module Slug
|
3
|
+
class Criteria < Mongoid::Criteria
|
4
|
+
# Find the matching 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
|
+
respond_to?(type_method, true) ? method(type_method) : ->(_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
|
+
Mongoid::Compatibility::ObjectId.legal?(id)
|
71
|
+
end
|
72
|
+
|
73
|
+
# a string will always look like a slug
|
74
|
+
def string_slug_strategy(_id)
|
75
|
+
true
|
76
|
+
end
|
77
|
+
|
78
|
+
def for_slugs(slugs)
|
79
|
+
# _translations
|
80
|
+
localized = (begin
|
81
|
+
@klass.fields['_slugs'].options[:localize]
|
82
|
+
rescue StandardError
|
83
|
+
false
|
84
|
+
end)
|
85
|
+
if localized
|
86
|
+
def_loc = I18n.default_locale
|
87
|
+
query = { '$in' => slugs }
|
88
|
+
where({ '$or' => [{ _slugs: query }, { "_slugs.#{def_loc}" => query }] }).limit(slugs.length)
|
89
|
+
else
|
90
|
+
where(_slugs: { '$in' => slugs }).limit(slugs.length)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def execute_or_raise_for_slugs(slugs, multi)
|
95
|
+
result = uniq
|
96
|
+
check_for_missing_documents_for_slugs!(result, slugs)
|
97
|
+
multi ? result : result.first
|
98
|
+
end
|
99
|
+
|
100
|
+
def check_for_missing_documents_for_slugs!(result, slugs)
|
101
|
+
missing_slugs = slugs - result.map(&:slugs).flatten
|
102
|
+
return unless !missing_slugs.blank? && Mongoid.raise_not_found_error
|
103
|
+
raise Errors::DocumentNotFound.new(klass, slugs, missing_slugs)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
@@ -1,45 +1,67 @@
|
|
1
|
-
module Mongoid
|
2
|
-
module Slug
|
3
|
-
module
|
4
|
-
|
5
|
-
|
6
|
-
#
|
7
|
-
#
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
#
|
26
|
-
#
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
1
|
+
module Mongoid
|
2
|
+
module Slug
|
3
|
+
module IndexBuilder
|
4
|
+
extend self
|
5
|
+
|
6
|
+
# Creates indexes on a document for a given slug scope
|
7
|
+
#
|
8
|
+
# @param [ Mongoid::Document ] doc The document on which to create the index(es)
|
9
|
+
# @param [ String or Symbol ] scope_key The optional scope key for the index(es)
|
10
|
+
# @param [ Boolean ] by_model_type Whether or not to use single table inheritance
|
11
|
+
# @param [ Boolean or Array ] localize The locale for localized index field
|
12
|
+
#
|
13
|
+
# @return [ Array(Hash, Hash) ] the indexable fields and index options.
|
14
|
+
def build_indexes(doc, scope_key = nil, by_model_type = false, locales = nil)
|
15
|
+
if locales.is_a?(Array)
|
16
|
+
locales.each { |locale| build_index(doc, scope_key, by_model_type, locale) }
|
17
|
+
else
|
18
|
+
build_index(doc, scope_key, by_model_type, locales)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def build_index(doc, scope_key = nil, by_model_type = false, locale = nil)
|
25
|
+
# The order of field keys is intentional.
|
26
|
+
# See: http://docs.mongodb.org/manual/core/index-compound/
|
27
|
+
fields = {}
|
28
|
+
fields[:_type] = 1 if by_model_type
|
29
|
+
fields[scope_key] = 1 if scope_key
|
30
|
+
|
31
|
+
locale = ::I18n.default_locale if locale.is_a?(TrueClass)
|
32
|
+
if locale
|
33
|
+
fields[:"_slugs.#{locale}"] = 1
|
34
|
+
else
|
35
|
+
fields[:_slugs] = 1
|
36
|
+
end
|
37
|
+
|
38
|
+
# By design, we use the unique index constraint when possible to enforce slug uniqueness.
|
39
|
+
# When migrating legacy data to Mongoid slug, the _slugs field may be null on many records,
|
40
|
+
# hence we set the sparse index option to ignore these from the unique index.
|
41
|
+
# See: http://docs.mongodb.org/manual/core/index-sparse/
|
42
|
+
#
|
43
|
+
# There are three edge cases where the index must not be unique:
|
44
|
+
#
|
45
|
+
# 1) Legacy tables with `scope_key`. The sparse indexes on compound keys (scope + _slugs) are
|
46
|
+
# whenever ANY of the key values are present (e.g. when scope is set and _slugs is unset),
|
47
|
+
# and collisions will occur when multiple records have the same scope but null slugs.
|
48
|
+
#
|
49
|
+
# 2) Single Table Inheritance (`by_model_type`). MongoDB creates indexes on the parent collection,
|
50
|
+
# irrespective of how STI is defined in Mongoid, i.e. ANY child index will be applied to EVERY child.
|
51
|
+
# This can cause collisions using various combinations of scopes.
|
52
|
+
#
|
53
|
+
# In the future, MongoDB may implement partial indexes or improve sparse index behavior.
|
54
|
+
# See: https://jira.mongodb.org/browse/SERVER-785
|
55
|
+
# https://jira.mongodb.org/browse/SERVER-13780
|
56
|
+
# https://jira.mongodb.org/browse/SERVER-10403
|
57
|
+
options = {}
|
58
|
+
unless scope_key || by_model_type
|
59
|
+
options[:unique] = true
|
60
|
+
options[:sparse] = true
|
61
|
+
end
|
62
|
+
|
63
|
+
doc.index(fields, options)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
data/lib/mongoid/slug/railtie.rb
CHANGED
@@ -1,9 +1,9 @@
|
|
1
|
-
module Mongoid
|
2
|
-
module Slug
|
3
|
-
class Railtie < Rails::Railtie
|
4
|
-
rake_tasks do
|
5
|
-
Dir[File.join(File.dirname(__FILE__), '../../tasks/*.rake')].each { |f| load f }
|
6
|
-
end
|
7
|
-
end
|
8
|
-
end
|
9
|
-
end
|
1
|
+
module Mongoid
|
2
|
+
module Slug
|
3
|
+
class Railtie < Rails::Railtie
|
4
|
+
rake_tasks do
|
5
|
+
Dir[File.join(File.dirname(__FILE__), '../../tasks/*.rake')].each { |f| load f }
|
6
|
+
end
|
7
|
+
end
|
8
|
+
end
|
9
|
+
end
|
@@ -1,3 +1,3 @@
|
|
1
|
-
Mongoid::Fields.option(:slug_id_strategy) do |_model, field, value|
|
2
|
-
field.options[:slug_id_strategy] = value
|
3
|
-
end
|
1
|
+
Mongoid::Fields.option(:slug_id_strategy) do |_model, field, value|
|
2
|
+
field.options[:slug_id_strategy] = value
|
3
|
+
end
|
@@ -1,173 +1,173 @@
|
|
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
|
-
regexp_pattern = Regexp.new(@pattern)
|
20
|
-
@documents.each do |doc|
|
21
|
-
history_slugs = doc._slugs
|
22
|
-
next if history_slugs.nil?
|
23
|
-
existing_slugs.push(*history_slugs.find_all { |cur_slug| cur_slug =~ regexp_pattern })
|
24
|
-
last_entered_slug.push(*history_slugs.last) if history_slugs.last =~ regexp_pattern
|
25
|
-
existing_history_slugs.push(*history_slugs.first(history_slugs.length - 1).find_all { |cur_slug| cur_slug =~ regexp_pattern })
|
26
|
-
end
|
27
|
-
end
|
28
|
-
|
29
|
-
def slug_included?
|
30
|
-
existing_slugs.include? @slug
|
31
|
-
end
|
32
|
-
|
33
|
-
def include_slug
|
34
|
-
existing_slugs << @slug
|
35
|
-
end
|
36
|
-
|
37
|
-
def highest_existing_counter
|
38
|
-
sort_existing_slugs
|
39
|
-
@sorted_existing.last || 0
|
40
|
-
end
|
41
|
-
|
42
|
-
def sort_existing_slugs
|
43
|
-
# remove the slug part and leave the absolute integer part and sort
|
44
|
-
re = /^#{Regexp.escape(@slug)}/
|
45
|
-
@sorted_existing = existing_slugs.map do |s|
|
46
|
-
s.sub(re, '').to_i.abs
|
47
|
-
end.sort
|
48
|
-
end
|
49
|
-
|
50
|
-
def inspect
|
51
|
-
{
|
52
|
-
slug: @slug,
|
53
|
-
existing_slugs: existing_slugs,
|
54
|
-
last_entered_slug: last_entered_slug,
|
55
|
-
existing_history_slugs: existing_history_slugs,
|
56
|
-
sorted_existing: sorted_existing
|
57
|
-
}
|
58
|
-
end
|
59
|
-
end
|
60
|
-
|
61
|
-
extend Forwardable
|
62
|
-
|
63
|
-
attr_reader :model, :_slug
|
64
|
-
|
65
|
-
def_delegators :@model, :slug_scope, :reflect_on_association, :read_attribute,
|
66
|
-
:check_against_id, :slug_reserved_words, :slug_url_builder, :collection_name,
|
67
|
-
:embedded?, :reflect_on_all_associations, :reflect_on_all_association,
|
68
|
-
:slug_by_model_type, :slug_max_length
|
69
|
-
|
70
|
-
def initialize(model)
|
71
|
-
@model = model
|
72
|
-
@_slug = ''
|
73
|
-
@state = nil
|
74
|
-
end
|
75
|
-
|
76
|
-
def metadata
|
77
|
-
if @model.respond_to?(:_association)
|
78
|
-
@model.send(:_association)
|
79
|
-
elsif @model.respond_to?(:relation_metadata)
|
80
|
-
@model.relation_metadata
|
81
|
-
else
|
82
|
-
@model.metadata
|
83
|
-
end
|
84
|
-
end
|
85
|
-
|
86
|
-
def find_unique(attempt = nil)
|
87
|
-
MUTEX_FOR_SLUG.synchronize do
|
88
|
-
@_slug = if attempt
|
89
|
-
attempt.to_url
|
90
|
-
else
|
91
|
-
slug_url_builder.call(model)
|
92
|
-
end
|
93
|
-
|
94
|
-
@_slug = @_slug[0...slug_max_length] if slug_max_length
|
95
|
-
|
96
|
-
where_hash = {}
|
97
|
-
where_hash[:_slugs.all] = [regex_for_slug]
|
98
|
-
where_hash[:_id.ne] = model._id
|
99
|
-
|
100
|
-
if (scope = slug_scope) && reflect_on_association(scope).nil?
|
101
|
-
# scope is not an association, so it's scoped to a local field
|
102
|
-
# (e.g. an association id in a denormalized db design)
|
103
|
-
where_hash[scope] = model.try(:read_attribute, scope)
|
104
|
-
end
|
105
|
-
|
106
|
-
where_hash[:_type] = model.try(:read_attribute, :_type) if slug_by_model_type
|
107
|
-
|
108
|
-
@state = SlugState.new @_slug, uniqueness_scope.unscoped.where(where_hash), escaped_pattern
|
109
|
-
|
110
|
-
# do not allow a slug that can be interpreted as the current document id
|
111
|
-
@state.include_slug unless model.class.look_like_slugs?([@_slug])
|
112
|
-
|
113
|
-
# make sure that the slug is not equal to a reserved word
|
114
|
-
@state.include_slug if slug_reserved_words.any? { |word| word === @_slug }
|
115
|
-
|
116
|
-
# only look for a new unique slug if the existing slugs contains the current slug
|
117
|
-
# - e.g if the slug 'foo-2' is taken, but 'foo' is available, the user can use 'foo'.
|
118
|
-
if @state.slug_included?
|
119
|
-
highest = @state.highest_existing_counter
|
120
|
-
@_slug += "-#{highest.succ}"
|
121
|
-
end
|
122
|
-
@_slug
|
123
|
-
end
|
124
|
-
end
|
125
|
-
|
126
|
-
def escaped_pattern
|
127
|
-
"^#{Regexp.escape(@_slug)}(?:-(\\d+))?$"
|
128
|
-
end
|
129
|
-
|
130
|
-
# Regular expression that matches slug, slug-1, ... slug-n
|
131
|
-
# If slug_name field was indexed, MongoDB will utilize that
|
132
|
-
# index to match /^.../ pattern.
|
133
|
-
# Use Regexp::Raw to avoid the multiline option when querying the server.
|
134
|
-
def regex_for_slug
|
135
|
-
if embedded? || Mongoid::Compatibility::Version.mongoid3? || Mongoid::Compatibility::Version.mongoid4?
|
136
|
-
Regexp.new(escaped_pattern)
|
137
|
-
else
|
138
|
-
BSON::Regexp::Raw.new(escaped_pattern)
|
139
|
-
end
|
140
|
-
end
|
141
|
-
|
142
|
-
def uniqueness_scope
|
143
|
-
if slug_scope && (metadata = reflect_on_association(slug_scope))
|
144
|
-
|
145
|
-
parent = model.send(metadata.name)
|
146
|
-
|
147
|
-
# Make sure doc is actually associated with something, and that
|
148
|
-
# some referenced docs have been persisted to the parent
|
149
|
-
#
|
150
|
-
# TODO: we need better reflection for reference associations,
|
151
|
-
# like association_name instead of forcing collection_name here
|
152
|
-
# -- maybe in the forthcoming Mongoid refactorings?
|
153
|
-
inverse = metadata.inverse_of || collection_name
|
154
|
-
return parent.respond_to?(inverse) ? parent.send(inverse) : model.class
|
155
|
-
end
|
156
|
-
|
157
|
-
if embedded?
|
158
|
-
parent_metadata = if Mongoid::Compatibility::Version.mongoid7_or_newer?
|
159
|
-
reflect_on_all_association(:embedded_in)[0]
|
160
|
-
else
|
161
|
-
reflect_on_all_associations(:embedded_in)[0]
|
162
|
-
end
|
163
|
-
return model._parent.send(parent_metadata.inverse_of || self.metadata.name)
|
164
|
-
end
|
165
|
-
|
166
|
-
# unless embedded or slug scope, return the deepest document superclass
|
167
|
-
appropriate_class = model.class
|
168
|
-
appropriate_class = appropriate_class.superclass while appropriate_class.superclass.include?(Mongoid::Document)
|
169
|
-
appropriate_class
|
170
|
-
end
|
171
|
-
end
|
172
|
-
end
|
173
|
-
end
|
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
|
+
regexp_pattern = Regexp.new(@pattern)
|
20
|
+
@documents.each do |doc|
|
21
|
+
history_slugs = doc._slugs
|
22
|
+
next if history_slugs.nil?
|
23
|
+
existing_slugs.push(*history_slugs.find_all { |cur_slug| cur_slug =~ regexp_pattern })
|
24
|
+
last_entered_slug.push(*history_slugs.last) if history_slugs.last =~ regexp_pattern
|
25
|
+
existing_history_slugs.push(*history_slugs.first(history_slugs.length - 1).find_all { |cur_slug| cur_slug =~ regexp_pattern })
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def slug_included?
|
30
|
+
existing_slugs.include? @slug
|
31
|
+
end
|
32
|
+
|
33
|
+
def include_slug
|
34
|
+
existing_slugs << @slug
|
35
|
+
end
|
36
|
+
|
37
|
+
def highest_existing_counter
|
38
|
+
sort_existing_slugs
|
39
|
+
@sorted_existing.last || 0
|
40
|
+
end
|
41
|
+
|
42
|
+
def sort_existing_slugs
|
43
|
+
# remove the slug part and leave the absolute integer part and sort
|
44
|
+
re = /^#{Regexp.escape(@slug)}/
|
45
|
+
@sorted_existing = existing_slugs.map do |s|
|
46
|
+
s.sub(re, '').to_i.abs
|
47
|
+
end.sort
|
48
|
+
end
|
49
|
+
|
50
|
+
def inspect
|
51
|
+
{
|
52
|
+
slug: @slug,
|
53
|
+
existing_slugs: existing_slugs,
|
54
|
+
last_entered_slug: last_entered_slug,
|
55
|
+
existing_history_slugs: existing_history_slugs,
|
56
|
+
sorted_existing: sorted_existing
|
57
|
+
}
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
extend Forwardable
|
62
|
+
|
63
|
+
attr_reader :model, :_slug
|
64
|
+
|
65
|
+
def_delegators :@model, :slug_scope, :reflect_on_association, :read_attribute,
|
66
|
+
:check_against_id, :slug_reserved_words, :slug_url_builder, :collection_name,
|
67
|
+
:embedded?, :reflect_on_all_associations, :reflect_on_all_association,
|
68
|
+
:slug_by_model_type, :slug_max_length
|
69
|
+
|
70
|
+
def initialize(model)
|
71
|
+
@model = model
|
72
|
+
@_slug = ''
|
73
|
+
@state = nil
|
74
|
+
end
|
75
|
+
|
76
|
+
def metadata
|
77
|
+
if @model.respond_to?(:_association)
|
78
|
+
@model.send(:_association)
|
79
|
+
elsif @model.respond_to?(:relation_metadata)
|
80
|
+
@model.relation_metadata
|
81
|
+
else
|
82
|
+
@model.metadata
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def find_unique(attempt = nil)
|
87
|
+
MUTEX_FOR_SLUG.synchronize do
|
88
|
+
@_slug = if attempt
|
89
|
+
attempt.to_url
|
90
|
+
else
|
91
|
+
slug_url_builder.call(model)
|
92
|
+
end
|
93
|
+
|
94
|
+
@_slug = @_slug[0...slug_max_length] if slug_max_length
|
95
|
+
|
96
|
+
where_hash = {}
|
97
|
+
where_hash[:_slugs.all] = [regex_for_slug]
|
98
|
+
where_hash[:_id.ne] = model._id
|
99
|
+
|
100
|
+
if (scope = slug_scope) && reflect_on_association(scope).nil?
|
101
|
+
# scope is not an association, so it's scoped to a local field
|
102
|
+
# (e.g. an association id in a denormalized db design)
|
103
|
+
where_hash[scope] = model.try(:read_attribute, scope)
|
104
|
+
end
|
105
|
+
|
106
|
+
where_hash[:_type] = model.try(:read_attribute, :_type) if slug_by_model_type
|
107
|
+
|
108
|
+
@state = SlugState.new @_slug, uniqueness_scope.unscoped.where(where_hash), escaped_pattern
|
109
|
+
|
110
|
+
# do not allow a slug that can be interpreted as the current document id
|
111
|
+
@state.include_slug unless model.class.look_like_slugs?([@_slug])
|
112
|
+
|
113
|
+
# make sure that the slug is not equal to a reserved word
|
114
|
+
@state.include_slug if slug_reserved_words.any? { |word| word === @_slug }
|
115
|
+
|
116
|
+
# only look for a new unique slug if the existing slugs contains the current slug
|
117
|
+
# - e.g if the slug 'foo-2' is taken, but 'foo' is available, the user can use 'foo'.
|
118
|
+
if @state.slug_included?
|
119
|
+
highest = @state.highest_existing_counter
|
120
|
+
@_slug += "-#{highest.succ}"
|
121
|
+
end
|
122
|
+
@_slug
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
def escaped_pattern
|
127
|
+
"^#{Regexp.escape(@_slug)}(?:-(\\d+))?$"
|
128
|
+
end
|
129
|
+
|
130
|
+
# Regular expression that matches slug, slug-1, ... slug-n
|
131
|
+
# If slug_name field was indexed, MongoDB will utilize that
|
132
|
+
# index to match /^.../ pattern.
|
133
|
+
# Use Regexp::Raw to avoid the multiline option when querying the server.
|
134
|
+
def regex_for_slug
|
135
|
+
if embedded? || Mongoid::Compatibility::Version.mongoid3? || Mongoid::Compatibility::Version.mongoid4?
|
136
|
+
Regexp.new(escaped_pattern)
|
137
|
+
else
|
138
|
+
BSON::Regexp::Raw.new(escaped_pattern)
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
def uniqueness_scope
|
143
|
+
if slug_scope && (metadata = reflect_on_association(slug_scope))
|
144
|
+
|
145
|
+
parent = model.send(metadata.name)
|
146
|
+
|
147
|
+
# Make sure doc is actually associated with something, and that
|
148
|
+
# some referenced docs have been persisted to the parent
|
149
|
+
#
|
150
|
+
# TODO: we need better reflection for reference associations,
|
151
|
+
# like association_name instead of forcing collection_name here
|
152
|
+
# -- maybe in the forthcoming Mongoid refactorings?
|
153
|
+
inverse = metadata.inverse_of || collection_name
|
154
|
+
return parent.respond_to?(inverse) ? parent.send(inverse) : model.class
|
155
|
+
end
|
156
|
+
|
157
|
+
if embedded?
|
158
|
+
parent_metadata = if Mongoid::Compatibility::Version.mongoid7_or_newer?
|
159
|
+
reflect_on_all_association(:embedded_in)[0]
|
160
|
+
else
|
161
|
+
reflect_on_all_associations(:embedded_in)[0]
|
162
|
+
end
|
163
|
+
return model._parent.send(parent_metadata.inverse_of || self.metadata.name)
|
164
|
+
end
|
165
|
+
|
166
|
+
# unless embedded or slug scope, return the deepest document superclass
|
167
|
+
appropriate_class = model.class
|
168
|
+
appropriate_class = appropriate_class.superclass while appropriate_class.superclass.include?(Mongoid::Document)
|
169
|
+
appropriate_class
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|