Skip to content

Commit c52831e

Browse files
add multi-field scoping for slugs (#274)
* add multiple scopes support * upd changelog and readme * fix some comments * refactor array iterations * fix compound indexes * rename for clarity * fix readme * Update README.md * Update slug.rb --------- Co-authored-by: Johnny Shields <[email protected]>
1 parent 6b6ad2f commit c52831e

10 files changed

+184
-28
lines changed

Diff for: CHANGELOG.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
## 7.0.1 (Next)
1+
## 7.1.0 (Next)
22

3+
* [#274](https://github.com/mongoid/mongoid-slug/pull/274): Added support for scoping slugs by multiple fields - [@mikekosulin](https://github.com/mikekosulin)
34
* Your contribution here.
45

56
## 7.0.0 (2023/09/18)

Diff for: README.md

+16
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,22 @@ class Employee
193193
end
194194
```
195195

196+
You may scope slugs using multiple fields as per the following example:
197+
198+
```ruby
199+
class Employee
200+
include Mongoid::Document
201+
include Mongoid::Slug
202+
203+
field :name
204+
field :company_id
205+
field :department_id
206+
207+
# Scope slug uniqueness by a combination of company and department
208+
slug :name, scope: %i[company_id department_id]
209+
end
210+
```
211+
196212
### Slug Max Length
197213

198214
MongoDB [featureCompatibilityVersion](https://docs.mongodb.com/manual/reference/command/setFeatureCompatibilityVersion/#std-label-view-fcv)

Diff for: lib/mongoid/slug.rb

+19-9
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,10 @@ module ClassMethods
5757
# @param options [Boolean] :permanent Whether the slug should be
5858
# immutable. Defaults to `false`.
5959
# @param options [Array] :reserve` A list of reserved slugs
60-
# @param options :scope [Symbol] a reference association or field to
61-
# scope the slug by. Embedded documents are, by default, scoped by
62-
# their parent.
60+
# @param options :scope [Symbol, Array<Symbol>] a reference association, field,
61+
# or array of fields to scope the slug by.
62+
# Embedded documents are, by default, scoped by their parent. Now it supports not only
63+
# a single association or field but also an array of them.
6364
# @param options :max_length [Integer] the maximum length of the text portion of the slug
6465
# @yield If given, a block is used to build a slug.
6566
#
@@ -90,8 +91,7 @@ def slug(*fields, &block)
9091

9192
# Set indexes
9293
if slug_index && !embedded?
93-
Mongoid::Slug::IndexBuilder.build_indexes(self, slug_scope_key, slug_by_model_type,
94-
options[:localize])
94+
Mongoid::Slug::IndexBuilder.build_indexes(self, slug_scope_keys, slug_by_model_type, options[:localize])
9595
end
9696

9797
self.slug_url_builder = block_given? ? block : default_slug_url_builder
@@ -113,13 +113,23 @@ def look_like_slugs?(*args)
113113
with_default_scope.look_like_slugs?(*args)
114114
end
115115

116-
# Returns the scope key for indexing, considering associations
116+
def slug_scopes
117+
# If slug_scope is set (i.e., not nil), we convert it to an array to ensure we can handle it consistently.
118+
# If it's not set, we use an array with a single nil element, signifying no specific scope.
119+
slug_scope ? Array(slug_scope) : [nil]
120+
end
121+
122+
# Returns the scope keys for indexing, considering associations
117123
#
118124
# @return [ Array<Document>, Document ]
119-
def slug_scope_key
125+
def slug_scope_keys
120126
return nil unless slug_scope
121127

122-
reflect_on_association(slug_scope).try(:key) || slug_scope
128+
# If slug_scope is an array, we map over its elements to get each individual scope's key.
129+
slug_scopes.map do |individual_scope|
130+
# Attempt to find the association and get its key. If no association is found, use the scope as-is.
131+
reflect_on_association(individual_scope).try(:key) || individual_scope
132+
end
123133
end
124134

125135
# Find documents by slugs.
@@ -297,7 +307,7 @@ def new_with_slugs?
297307
def persisted_with_slug_changes?
298308
if localized?
299309
changes = _slugs_change
300-
return (persisted? && false) if changes.nil?
310+
return false if changes.nil?
301311

302312
# ensure we check for changes only between the same locale
303313
original = changes.first.try(:fetch, I18n.locale.to_s, nil)

Diff for: lib/mongoid/slug/index_builder.rb

+3-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ module IndexBuilder
88
# Creates indexes on a document for a given slug scope
99
#
1010
# @param [ Mongoid::Document ] doc The document on which to create the index(es)
11-
# @param [ String or Symbol ] scope_key The optional scope key for the index(es)
11+
# @param [ String or Symbol or Array<String, Symbol> ] scope_key The optional scope key for the index(es)
1212
# @param [ Boolean ] by_model_type Whether or not to use single table inheritance
1313
# @param [ Boolean or Array ] localize The locale for localized index field
1414
#
@@ -28,7 +28,8 @@ def build_index(doc, scope_key = nil, by_model_type = false, locale = nil)
2828
# See: http://docs.mongodb.org/manual/core/index-compound/
2929
fields = {}
3030
fields[:_type] = 1 if by_model_type
31-
fields[scope_key] = 1 if scope_key
31+
32+
Array(scope_key).each { |key| fields[key] = 1 }
3233

3334
locale = ::I18n.default_locale if locale.is_a?(TrueClass)
3435
if locale

Diff for: lib/mongoid/slug/unique_slug.rb

+50-15
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,12 @@ def find_unique(attempt = nil)
100100
where_hash[:_slugs.all] = [regex_for_slug]
101101
where_hash[:_id.ne] = model._id
102102

103-
if (scope = slug_scope) && reflect_on_association(scope).nil?
103+
Array(slug_scope).each do |individual_scope|
104+
next unless reflect_on_association(individual_scope).nil?
105+
104106
# scope is not an association, so it's scoped to a local field
105107
# (e.g. an association id in a denormalized db design)
106-
where_hash[scope] = model.try(:read_attribute, scope)
108+
where_hash[individual_scope] = model.try(:read_attribute, individual_scope)
107109
end
108110

109111
where_hash[:_type] = model.try(:read_attribute, :_type) if slug_by_model_type
@@ -143,26 +145,59 @@ def regex_for_slug
143145
end
144146

145147
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
148+
# If slug_scope is present, we need to handle whether it's a single scope or multiple scopes.
149+
if slug_scope
150+
# We'll track individual scope results in an array.
151+
scope_results = []
152+
153+
Array(slug_scope).each do |individual_scope|
154+
next unless (metadata = reflect_on_association(individual_scope))
155+
156+
# For each scope, we identify its association metadata and fetch the parent record.
157+
parent = model.send(metadata.name)
158+
159+
# It's important to handle nil cases if the parent record doesn't exist.
160+
if parent.nil?
161+
# You might want to handle this scenario differently based on your application's logic.
162+
next
163+
end
164+
165+
# Make sure doc is actually associated with something, and that
166+
# some referenced docs have been persisted to the parent
167+
#
168+
# TODO: we need better reflection for reference associations,
169+
# like association_name instead of forcing collection_name here
170+
# -- maybe in the forthcoming Mongoid refactorings?
171+
inverse = metadata.inverse_of || collection_name
172+
next unless parent.respond_to?(inverse)
173+
174+
# Add the associated records of the parent (based on the inverse) to our results.
175+
scope_results << parent.send(inverse)
176+
end
177+
178+
# After iterating through all scopes, we need to decide how to combine the results (if there are multiple).
179+
# This part depends on how your application should treat multiple scopes.
180+
# Here, we'll simply return the first non-empty scope result as an example.
181+
scope_results.each do |result|
182+
return result if result.present? # or any other logic for selecting among multiple scope results
183+
end
184+
185+
# If we reach this point, it means no valid parent scope was found (all were nil or didn't match the
186+
# conditions).
187+
# You might want to raise an error, return a default scope, or handle this scenario based on your
188+
# application's logic.
189+
# For this example, we're returning the model's class as a default.
190+
return model.class
158191
end
159192

193+
# The rest of your method remains unchanged, handling cases where slug_scope isn't defined.
194+
# This is your existing logic for embedded models or deeper superclass retrieval.
160195
if embedded?
161196
parent_metadata = reflect_on_all_association(:embedded_in)[0]
162197
return model._parent.send(parent_metadata.inverse_of || self.metadata.name)
163198
end
164199

165-
# unless embedded or slug scope, return the deepest document superclass
200+
# Unless embedded or slug scope, return the deepest document superclass.
166201
appropriate_class = model.class
167202
appropriate_class = appropriate_class.superclass while appropriate_class.superclass.include?(Mongoid::Document)
168203
appropriate_class

Diff for: lib/mongoid/slug/version.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22

33
module Mongoid # :nodoc:
44
module Slug
5-
VERSION = '7.0.0'
5+
VERSION = '7.1.0'
66
end
77
end

Diff for: spec/models/page_with_categories.rb

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# frozen_string_literal: true
2+
3+
class PageWithCategories
4+
include Mongoid::Document
5+
include Mongoid::Slug
6+
field :title
7+
field :content
8+
9+
field :page_category
10+
field :page_sub_category
11+
12+
field :order, type: Integer
13+
slug :title, scope: %i[page_category page_sub_category]
14+
default_scope -> { asc(:order) }
15+
end

Diff for: spec/mongoid/index_builder_spec.rb

+29
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,35 @@
5151
end
5252
end
5353

54+
context 'when scope_key is set and is array' do
55+
let(:doc) do
56+
Class.new do
57+
include Mongoid::Document
58+
field :title, type: String
59+
field :page_category, type: String
60+
field :page_sub_category, type: String
61+
end
62+
end
63+
let(:scope_key) { %i[page_category page_sub_category] }
64+
65+
before do
66+
doc.field :page_category, type: String
67+
doc.field :page_sub_category, type: String
68+
69+
Mongoid::Slug::IndexBuilder.build_indexes(doc, scope_key, by_model_type, locales)
70+
end
71+
72+
context 'when by_model_type is true' do
73+
let(:by_model_type) { true }
74+
75+
it { is_expected.to eq [[{ _slugs: 1, page_category: 1, page_sub_category: 1, _type: 1 }, {}]] }
76+
end
77+
78+
context 'when by_model_type is false' do
79+
it { is_expected.to eq [[{ _slugs: 1, page_category: 1, page_sub_category: 1 }, {}]] }
80+
end
81+
end
82+
5483
context 'when scope_key is not set' do
5584
context 'when by_model_type is true' do
5685
let(:by_model_type) { true }

Diff for: spec/mongoid/slug_spec.rb

+48
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,54 @@ module Mongoid
163163
end
164164
end
165165

166+
context 'when the object has multiple scopes' do
167+
let(:category1) { 'category1' }
168+
let(:category2) { 'category2' }
169+
let(:sub_category1) { 'sub_category1' }
170+
let(:sub_category2) { 'sub_category2' }
171+
let(:common_title) { 'Common Title' }
172+
173+
context 'when pages have the same title and different categories' do
174+
it 'creates pages with the same slug' do
175+
page1 = PageWithCategories.create!(title: common_title, page_category: category1)
176+
page2 = PageWithCategories.create!(title: common_title, page_category: category2)
177+
178+
expect(page1.slug).to eq(page2.slug)
179+
end
180+
end
181+
182+
context 'when pages have the same title and same category but different sub-categories' do
183+
it 'creates pages with the same slug' do
184+
page1 = PageWithCategories.create!(title: common_title, page_category: category1,
185+
page_sub_category: sub_category1)
186+
page2 = PageWithCategories.create!(title: common_title, page_category: category1,
187+
page_sub_category: sub_category2)
188+
189+
expect(page1.slug).to eq(page2.slug)
190+
end
191+
end
192+
193+
context 'when pages have the same title, same category, and same sub-category' do
194+
it 'creates pages with different slugs' do
195+
page1 = PageWithCategories.create!(title: common_title, page_category: category1,
196+
page_sub_category: sub_category1)
197+
page2 = PageWithCategories.create!(title: common_title, page_category: category1,
198+
page_sub_category: sub_category1)
199+
200+
expect(page1.slug).not_to eq(page2.slug)
201+
end
202+
end
203+
204+
context 'when pages have the same title and same category, without sub-categories' do
205+
it 'creates pages with different slugs' do
206+
page1 = PageWithCategories.create!(title: common_title, page_category: category1)
207+
page2 = PageWithCategories.create!(title: common_title, page_category: category1)
208+
209+
expect(page1.slug).not_to eq(page2.slug)
210+
end
211+
end
212+
end
213+
166214
context 'when the object is embedded' do
167215
let(:subject) do
168216
book.subjects.create(name: 'Psychoanalysis')

Diff for: spec/spec_helper.rb

+1
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ def database_id
4848
Book.create_indexes
4949
AuthorPolymorphic.create_indexes
5050
BookPolymorphic.create_indexes
51+
PageWithCategories.create_indexes
5152
end
5253

5354
c.after(:each) do

0 commit comments

Comments
 (0)