mongoid-slug 7.0.0 → 7.1.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.
@@ -1,172 +1,204 @@
1
- # frozen_string_literal: true
2
-
3
- require 'forwardable'
4
-
5
- # Can use e.g. Mongoid::Slug::UniqueSlug.new(ModelClass.new).find_unique "slug-1" for auto-suggest ui
6
- module Mongoid
7
- module Slug
8
- class UniqueSlug
9
- MUTEX_FOR_SLUG = Mutex.new
10
- class SlugState
11
- attr_reader :last_entered_slug, :existing_slugs, :existing_history_slugs, :sorted_existing
12
-
13
- def initialize(slug, documents, pattern)
14
- @slug = slug
15
- @documents = documents
16
- @pattern = pattern
17
- @last_entered_slug = []
18
- @existing_slugs = []
19
- @existing_history_slugs = []
20
- @sorted_existing = []
21
- regexp_pattern = Regexp.new(@pattern)
22
- @documents.each do |doc|
23
- history_slugs = doc._slugs
24
- next if history_slugs.nil?
25
-
26
- existing_slugs.push(*history_slugs.grep(regexp_pattern))
27
- last_entered_slug.push(*history_slugs.last) if history_slugs.last =~ regexp_pattern
28
- existing_history_slugs.push(*history_slugs.first(history_slugs.length - 1).grep(regexp_pattern))
29
- end
30
- end
31
-
32
- def slug_included?
33
- existing_slugs.include? @slug
34
- end
35
-
36
- def include_slug
37
- existing_slugs << @slug
38
- end
39
-
40
- def highest_existing_counter
41
- sort_existing_slugs
42
- @sorted_existing.last || 0
43
- end
44
-
45
- def sort_existing_slugs
46
- # remove the slug part and leave the absolute integer part and sort
47
- re = /^#{Regexp.escape(@slug)}/
48
- @sorted_existing = existing_slugs.map do |s|
49
- s.sub(re, '').to_i.abs
50
- end.sort
51
- end
52
-
53
- def inspect
54
- {
55
- slug: @slug,
56
- existing_slugs: existing_slugs,
57
- last_entered_slug: last_entered_slug,
58
- existing_history_slugs: existing_history_slugs,
59
- sorted_existing: sorted_existing
60
- }
61
- end
62
- end
63
-
64
- extend Forwardable
65
-
66
- attr_reader :model, :_slug
67
-
68
- def_delegators :@model, :slug_scope, :reflect_on_association, :read_attribute,
69
- :check_against_id, :slug_reserved_words, :slug_url_builder, :collection_name,
70
- :embedded?, :reflect_on_all_associations, :reflect_on_all_association,
71
- :slug_by_model_type, :slug_max_length
72
-
73
- def initialize(model)
74
- @model = model
75
- @_slug = ''
76
- @state = nil
77
- end
78
-
79
- def metadata
80
- if @model.respond_to?(:_association)
81
- @model.send(:_association)
82
- elsif @model.respond_to?(:relation_metadata)
83
- @model.relation_metadata
84
- else
85
- @model.metadata
86
- end
87
- end
88
-
89
- def find_unique(attempt = nil)
90
- MUTEX_FOR_SLUG.synchronize do
91
- @_slug = if attempt
92
- attempt.to_url
93
- else
94
- slug_url_builder.call(model)
95
- end
96
-
97
- @_slug = @_slug[0...slug_max_length] if slug_max_length
98
-
99
- where_hash = {}
100
- where_hash[:_slugs.all] = [regex_for_slug]
101
- where_hash[:_id.ne] = model._id
102
-
103
- if (scope = slug_scope) && reflect_on_association(scope).nil?
104
- # scope is not an association, so it's scoped to a local field
105
- # (e.g. an association id in a denormalized db design)
106
- where_hash[scope] = model.try(:read_attribute, scope)
107
- end
108
-
109
- where_hash[:_type] = model.try(:read_attribute, :_type) if slug_by_model_type
110
-
111
- @state = SlugState.new @_slug, uniqueness_scope.unscoped.where(where_hash), escaped_pattern
112
-
113
- # do not allow a slug that can be interpreted as the current document id
114
- @state.include_slug unless model.class.look_like_slugs?([@_slug])
115
-
116
- # make sure that the slug is not equal to a reserved word
117
- @state.include_slug if slug_reserved_words.any? { |word| word === @_slug } # rubocop:disable Style/CaseEquality
118
-
119
- # only look for a new unique slug if the existing slugs contains the current slug
120
- # - e.g if the slug 'foo-2' is taken, but 'foo' is available, the user can use 'foo'.
121
- if @state.slug_included?
122
- highest = @state.highest_existing_counter
123
- @_slug += "-#{highest.succ}"
124
- end
125
- @_slug
126
- end
127
- end
128
-
129
- def escaped_pattern
130
- "^#{Regexp.escape(@_slug)}(?:-(\\d+))?$"
131
- end
132
-
133
- # Regular expression that matches slug, slug-1, ... slug-n
134
- # If slug_name field was indexed, MongoDB will utilize that
135
- # index to match /^.../ pattern.
136
- # Use Regexp::Raw to avoid the multiline option when querying the server.
137
- def regex_for_slug
138
- if embedded?
139
- Regexp.new(escaped_pattern)
140
- else
141
- BSON::Regexp::Raw.new(escaped_pattern)
142
- end
143
- end
144
-
145
- def uniqueness_scope
146
- if slug_scope && (metadata = reflect_on_association(slug_scope))
147
-
148
- parent = model.send(metadata.name)
149
-
150
- # Make sure doc is actually associated with something, and that
151
- # some referenced docs have been persisted to the parent
152
- #
153
- # TODO: we need better reflection for reference associations,
154
- # like association_name instead of forcing collection_name here
155
- # -- maybe in the forthcoming Mongoid refactorings?
156
- inverse = metadata.inverse_of || collection_name
157
- return parent.respond_to?(inverse) ? parent.send(inverse) : model.class
158
- end
159
-
160
- if embedded?
161
- parent_metadata = reflect_on_all_association(:embedded_in)[0]
162
- return model._parent.send(parent_metadata.inverse_of || self.metadata.name)
163
- end
164
-
165
- # unless embedded or slug scope, return the deepest document superclass
166
- appropriate_class = model.class
167
- appropriate_class = appropriate_class.superclass while appropriate_class.superclass.include?(Mongoid::Document)
168
- appropriate_class
169
- end
170
- end
171
- end
172
- end
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ # Can use e.g. Mongoid::Slug::UniqueSlug.new(ModelClass.new).find_unique "slug-1" for auto-suggest ui
6
+ module Mongoid
7
+ module Slug
8
+ class UniqueSlug
9
+ MUTEX_FOR_SLUG = Mutex.new
10
+ class SlugState
11
+ attr_reader :last_entered_slug, :existing_slugs, :existing_history_slugs, :sorted_existing
12
+
13
+ def initialize(slug, documents, pattern)
14
+ @slug = slug
15
+ @documents = documents
16
+ @pattern = pattern
17
+ @last_entered_slug = []
18
+ @existing_slugs = []
19
+ @existing_history_slugs = []
20
+ @sorted_existing = []
21
+ regexp_pattern = Regexp.new(@pattern)
22
+ @documents.each do |doc|
23
+ history_slugs = doc._slugs
24
+ next if history_slugs.nil?
25
+
26
+ existing_slugs.push(*history_slugs.grep(regexp_pattern))
27
+ last_entered_slug.push(*history_slugs.last) if history_slugs.last =~ regexp_pattern
28
+ existing_history_slugs.push(*history_slugs.first(history_slugs.length - 1).grep(regexp_pattern))
29
+ end
30
+ end
31
+
32
+ def slug_included?
33
+ existing_slugs.include? @slug
34
+ end
35
+
36
+ def include_slug
37
+ existing_slugs << @slug
38
+ end
39
+
40
+ def highest_existing_counter
41
+ sort_existing_slugs
42
+ @sorted_existing.last || 0
43
+ end
44
+
45
+ def sort_existing_slugs
46
+ # remove the slug part and leave the absolute integer part and sort
47
+ @sorted_existing = existing_slugs.map do |s|
48
+ s.delete_prefix(@slug).to_i.abs
49
+ end.sort
50
+ end
51
+
52
+ def inspect
53
+ {
54
+ slug: @slug,
55
+ existing_slugs: existing_slugs,
56
+ last_entered_slug: last_entered_slug,
57
+ existing_history_slugs: existing_history_slugs,
58
+ sorted_existing: sorted_existing
59
+ }
60
+ end
61
+ end
62
+
63
+ extend Forwardable
64
+
65
+ attr_reader :model, :_slug
66
+
67
+ def_delegators :@model, :slug_scope, :reflect_on_association, :read_attribute,
68
+ :check_against_id, :slug_reserved_words, :slug_url_builder, :collection_name,
69
+ :embedded?, :reflect_on_all_associations, :reflect_on_all_association,
70
+ :slug_by_model_type, :slug_max_length
71
+
72
+ def initialize(model)
73
+ @model = model
74
+ @_slug = ''
75
+ @state = nil
76
+ end
77
+
78
+ def metadata
79
+ if @model.respond_to?(:_association)
80
+ @model.send(:_association)
81
+ elsif @model.respond_to?(:relation_metadata)
82
+ @model.relation_metadata
83
+ else
84
+ @model.metadata
85
+ end
86
+ end
87
+
88
+ def find_unique(attempt = nil)
89
+ MUTEX_FOR_SLUG.synchronize do
90
+ @_slug = if attempt
91
+ attempt.to_url
92
+ else
93
+ slug_url_builder.call(model)
94
+ end
95
+
96
+ @_slug = @_slug[0...slug_max_length] if slug_max_length
97
+
98
+ where_hash = {}
99
+ where_hash[:_slugs.all] = [regex_for_slug]
100
+ where_hash[:_id.ne] = model._id
101
+
102
+ Array(slug_scope).each do |individual_scope|
103
+ next unless reflect_on_association(individual_scope).nil?
104
+
105
+ # scope is not an association, so it's scoped to a local field
106
+ # (e.g. an association id in a denormalized db design)
107
+ where_hash[individual_scope] = model.try(:read_attribute, individual_scope)
108
+ end
109
+
110
+ where_hash[:_type] = model.try(:read_attribute, :_type) if slug_by_model_type
111
+
112
+ @state = SlugState.new @_slug, uniqueness_scope.unscoped.where(where_hash), escaped_pattern
113
+
114
+ # do not allow a slug that can be interpreted as the current document id
115
+ @state.include_slug unless model.class.look_like_slugs?([@_slug])
116
+
117
+ # make sure that the slug is not equal to a reserved word
118
+ @state.include_slug if slug_reserved_words.any? { |word| word === @_slug } # rubocop:disable Style/CaseEquality
119
+
120
+ # only look for a new unique slug if the existing slugs contains the current slug
121
+ # - e.g if the slug 'foo-2' is taken, but 'foo' is available, the user can use 'foo'.
122
+ if @state.slug_included?
123
+ highest = @state.highest_existing_counter
124
+ @_slug += "-#{highest.succ}"
125
+ end
126
+ @_slug
127
+ end
128
+ end
129
+
130
+ def escaped_pattern
131
+ "^#{Regexp.escape(@_slug)}(?:-(\\d+))?$"
132
+ end
133
+
134
+ # Regular expression that matches slug, slug-1, ... slug-n
135
+ # If slug_name field was indexed, MongoDB will utilize that
136
+ # index to match /^.../ pattern.
137
+ # Use Regexp::Raw to avoid the multiline option when querying the server.
138
+ def regex_for_slug
139
+ if embedded?
140
+ Regexp.new(escaped_pattern)
141
+ else
142
+ BSON::Regexp::Raw.new(escaped_pattern)
143
+ end
144
+ end
145
+
146
+ def uniqueness_scope
147
+ if embedded?
148
+ parent_metadata = reflect_on_all_association(:embedded_in)[0]
149
+ return model._parent.send(parent_metadata.inverse_of || metadata.name)
150
+ end
151
+
152
+ # If slug_scope is present, we need to handle whether it's a single scope or multiple scopes.
153
+ if slug_scope
154
+ # We'll track individual scope results in an array.
155
+ scope_results = []
156
+
157
+ Array(slug_scope).each do |individual_scope|
158
+ next unless (metadata = reflect_on_association(individual_scope))
159
+
160
+ # For each scope, we identify its association metadata and fetch the parent record.
161
+ parent = model.send(metadata.name)
162
+
163
+ # It's important to handle nil cases if the parent record doesn't exist.
164
+ if parent.nil?
165
+ # You might want to handle this scenario differently based on your application's logic.
166
+ next
167
+ end
168
+
169
+ # Make sure doc is actually associated with something, and that
170
+ # some referenced docs have been persisted to the parent
171
+ #
172
+ # TODO: we need better reflection for reference associations,
173
+ # like association_name instead of forcing collection_name here
174
+ # -- maybe in the forthcoming Mongoid refactorings?
175
+ inverse = metadata.inverse_of || collection_name
176
+ next unless parent.respond_to?(inverse)
177
+
178
+ # Add the associated records of the parent (based on the inverse) to our results.
179
+ scope_results << parent.send(inverse)
180
+ end
181
+
182
+ # After iterating through all scopes, we need to decide how to combine the results (if there are multiple).
183
+ # This part depends on how your application should treat multiple scopes.
184
+ # Here, we'll simply return the first non-empty scope result as an example.
185
+ scope_results.each do |result|
186
+ return result if result.present? # or any other logic for selecting among multiple scope results
187
+ end
188
+
189
+ # If we reach this point, it means no valid parent scope was found (all were nil or didn't match the
190
+ # conditions).
191
+ # You might want to raise an error, return a default scope, or handle this scenario based on your
192
+ # application's logic.
193
+ # For this example, we're returning the model's class as a default.
194
+ return model.class
195
+ end
196
+
197
+ # Unless embedded or slug scope, return the deepest document superclass.
198
+ appropriate_class = model.class
199
+ appropriate_class = appropriate_class.superclass while appropriate_class.superclass.include?(Mongoid::Document)
200
+ appropriate_class
201
+ end
202
+ end
203
+ end
204
+ end
@@ -1,7 +1,7 @@
1
- # frozen_string_literal: true
2
-
3
- module Mongoid # :nodoc:
4
- module Slug
5
- VERSION = '7.0.0'
6
- end
7
- end
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid # :nodoc:
4
+ module Slug
5
+ VERSION = '7.1.0'
6
+ end
7
+ end