activity_notification 2.3.2 → 2.4.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.
- checksums.yaml +4 -4
- data/.github/workflows/build.yml +9 -36
- data/CHANGELOG.md +26 -1
- data/Gemfile +1 -1
- data/README.md +9 -1
- data/activity_notification.gemspec +5 -5
- data/ai-curated-specs/issues/172/design.md +220 -0
- data/ai-curated-specs/issues/172/tasks.md +326 -0
- data/ai-curated-specs/issues/188/design.md +227 -0
- data/ai-curated-specs/issues/188/requirements.md +78 -0
- data/ai-curated-specs/issues/188/tasks.md +203 -0
- data/ai-curated-specs/issues/188/upstream-contributions.md +592 -0
- data/ai-curated-specs/issues/50/design.md +235 -0
- data/ai-curated-specs/issues/50/requirements.md +49 -0
- data/ai-curated-specs/issues/50/tasks.md +232 -0
- data/app/controllers/activity_notification/notifications_api_controller.rb +22 -0
- data/app/controllers/activity_notification/notifications_controller.rb +27 -1
- data/app/mailers/activity_notification/mailer.rb +2 -2
- data/app/views/activity_notification/notifications/default/_index.html.erb +6 -1
- data/app/views/activity_notification/notifications/default/destroy_all.js.erb +6 -0
- data/docs/Setup.md +43 -6
- data/gemfiles/Gemfile.rails-7.0 +2 -0
- data/gemfiles/Gemfile.rails-7.2 +0 -2
- data/gemfiles/Gemfile.rails-8.0 +24 -0
- data/lib/activity_notification/apis/notification_api.rb +51 -2
- data/lib/activity_notification/controllers/concerns/swagger/notifications_api.rb +59 -0
- data/lib/activity_notification/helpers/view_helpers.rb +28 -0
- data/lib/activity_notification/mailers/helpers.rb +14 -7
- data/lib/activity_notification/models/concerns/target.rb +16 -0
- data/lib/activity_notification/models.rb +1 -1
- data/lib/activity_notification/notification_resilience.rb +115 -0
- data/lib/activity_notification/orm/dynamoid/extension.rb +4 -87
- data/lib/activity_notification/orm/dynamoid/notification.rb +19 -2
- data/lib/activity_notification/orm/dynamoid.rb +42 -6
- data/lib/activity_notification/rails/routes.rb +3 -2
- data/lib/activity_notification/version.rb +1 -1
- data/lib/activity_notification.rb +1 -0
- data/lib/generators/templates/controllers/notifications_api_controller.rb +5 -0
- data/lib/generators/templates/controllers/notifications_api_with_devise_controller.rb +5 -0
- data/lib/generators/templates/controllers/notifications_controller.rb +5 -0
- data/lib/generators/templates/controllers/notifications_with_devise_controller.rb +5 -0
- data/spec/concerns/apis/notification_api_spec.rb +161 -5
- data/spec/concerns/models/target_spec.rb +7 -0
- data/spec/controllers/controller_spec_utility.rb +1 -1
- data/spec/controllers/notifications_api_controller_shared_examples.rb +113 -0
- data/spec/controllers/notifications_controller_shared_examples.rb +150 -0
- data/spec/helpers/view_helpers_spec.rb +14 -0
- data/spec/jobs/notification_resilience_job_spec.rb +167 -0
- data/spec/mailers/notification_resilience_spec.rb +263 -0
- data/spec/models/notification_spec.rb +1 -1
- data/spec/models/subscription_spec.rb +1 -1
- data/spec/rails_app/app/helpers/devise_helper.rb +2 -0
- data/spec/rails_app/config/application.rb +1 -0
- data/spec/rails_app/config/initializers/zeitwerk.rb +10 -0
- metadata +67 -53
@@ -0,0 +1,592 @@
|
|
1
|
+
# Upstream Contributions for Dynamoid
|
2
|
+
|
3
|
+
This document outlines the improvements made to ActivityNotification's Dynamoid integration that could be contributed back to the Dynamoid project.
|
4
|
+
|
5
|
+
## 1. None Method Implementation
|
6
|
+
|
7
|
+
### Overview
|
8
|
+
The `none()` method provides an empty query result set, similar to ActiveRecord's `none` method. This is useful for conditional queries and maintaining consistent interfaces.
|
9
|
+
|
10
|
+
### Implementation
|
11
|
+
|
12
|
+
```ruby
|
13
|
+
module Dynamoid
|
14
|
+
module Criteria
|
15
|
+
class None < Chain
|
16
|
+
def ==(other)
|
17
|
+
other.is_a?(None)
|
18
|
+
end
|
19
|
+
|
20
|
+
def records
|
21
|
+
[]
|
22
|
+
end
|
23
|
+
|
24
|
+
def count
|
25
|
+
0
|
26
|
+
end
|
27
|
+
|
28
|
+
def delete_all
|
29
|
+
end
|
30
|
+
|
31
|
+
def empty?
|
32
|
+
true
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
class Chain
|
37
|
+
# Return new none object
|
38
|
+
def none
|
39
|
+
None.new(self.source)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
module ClassMethods
|
44
|
+
define_method(:none) do |*args, &blk|
|
45
|
+
chain = Dynamoid::Criteria::Chain.new(self)
|
46
|
+
chain.send(:none, *args, &blk)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
```
|
52
|
+
|
53
|
+
### Benefits
|
54
|
+
- Provides consistent API with ActiveRecord
|
55
|
+
- Enables conditional query building without complex logic
|
56
|
+
- Returns predictable empty results for edge cases
|
57
|
+
- Maintains chainable query interface
|
58
|
+
|
59
|
+
### Usage Examples
|
60
|
+
```ruby
|
61
|
+
# Conditional queries
|
62
|
+
users = condition ? User.where(active: true) : User.none
|
63
|
+
|
64
|
+
# Default empty state
|
65
|
+
def search_results(query)
|
66
|
+
return User.none if query.blank?
|
67
|
+
User.where(name: query)
|
68
|
+
end
|
69
|
+
```
|
70
|
+
|
71
|
+
### Tests
|
72
|
+
```ruby
|
73
|
+
describe "none method" do
|
74
|
+
it "returns empty results" do
|
75
|
+
expect(User.none.count).to eq(0)
|
76
|
+
expect(User.none.to_a).to eq([])
|
77
|
+
expect(User.none).to be_empty
|
78
|
+
end
|
79
|
+
|
80
|
+
it "is chainable" do
|
81
|
+
expect(User.where(active: true).none.count).to eq(0)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
```
|
85
|
+
|
86
|
+
## 2. Limit Method Implementation
|
87
|
+
|
88
|
+
### Overview
|
89
|
+
The `limit()` method provides a more intuitive alias for Dynamoid's `record_limit()` method, matching ActiveRecord's interface and improving developer experience.
|
90
|
+
|
91
|
+
### Implementation
|
92
|
+
|
93
|
+
```ruby
|
94
|
+
module Dynamoid
|
95
|
+
module Criteria
|
96
|
+
class Chain
|
97
|
+
# Set query result limit as record_limit of Dynamoid
|
98
|
+
# @scope class
|
99
|
+
# @param [Integer] limit Query result limit as record_limit
|
100
|
+
# @return [Dynamoid::Criteria::Chain] Database query of filtered notifications or subscriptions
|
101
|
+
def limit(limit)
|
102
|
+
record_limit(limit)
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
```
|
108
|
+
|
109
|
+
### Benefits
|
110
|
+
- Provides familiar ActiveRecord-style method name
|
111
|
+
- Improves code readability and developer experience
|
112
|
+
- Maintains backward compatibility with existing `record_limit` method
|
113
|
+
- Reduces cognitive load when switching between ORMs
|
114
|
+
|
115
|
+
### Usage Examples
|
116
|
+
```ruby
|
117
|
+
# More intuitive than record_limit(10)
|
118
|
+
User.limit(10)
|
119
|
+
|
120
|
+
# Chainable with other methods
|
121
|
+
User.where(active: true).limit(5)
|
122
|
+
|
123
|
+
# Consistent with ActiveRecord patterns
|
124
|
+
def recent_users(count = 10)
|
125
|
+
User.where(created_at: Time.current.beginning_of_day..).limit(count)
|
126
|
+
end
|
127
|
+
```
|
128
|
+
|
129
|
+
### Tests
|
130
|
+
```ruby
|
131
|
+
describe "limit method" do
|
132
|
+
it "limits query results" do
|
133
|
+
create_list(:user, 20)
|
134
|
+
expect(User.limit(5).count).to eq(5)
|
135
|
+
end
|
136
|
+
|
137
|
+
it "is chainable" do
|
138
|
+
create_list(:user, 20, active: true)
|
139
|
+
result = User.where(active: true).limit(3)
|
140
|
+
expect(result.count).to eq(3)
|
141
|
+
end
|
142
|
+
|
143
|
+
it "behaves identically to record_limit" do
|
144
|
+
create_list(:user, 10)
|
145
|
+
expect(User.limit(5).to_a).to eq(User.record_limit(5).to_a)
|
146
|
+
end
|
147
|
+
end
|
148
|
+
```
|
149
|
+
|
150
|
+
## 3. Exists? Method Implementation
|
151
|
+
|
152
|
+
### Overview
|
153
|
+
The `exists?()` method provides an efficient way to check if any records match the current query criteria without loading the actual records, similar to ActiveRecord's `exists?` method.
|
154
|
+
|
155
|
+
### Implementation
|
156
|
+
|
157
|
+
```ruby
|
158
|
+
module Dynamoid
|
159
|
+
module Criteria
|
160
|
+
class Chain
|
161
|
+
# Return if records exist
|
162
|
+
# @scope class
|
163
|
+
# @return [Boolean] If records exist
|
164
|
+
def exists?
|
165
|
+
record_limit(1).count > 0
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
170
|
+
```
|
171
|
+
|
172
|
+
### Benefits
|
173
|
+
- Provides efficient existence checking without loading full records
|
174
|
+
- Matches ActiveRecord's interface for consistency
|
175
|
+
- Optimizes performance by limiting query to single record
|
176
|
+
- Enables cleaner conditional logic in applications
|
177
|
+
|
178
|
+
### Performance Considerations
|
179
|
+
- Uses `record_limit(1)` to minimize data transfer
|
180
|
+
- Only performs count operation, not full record retrieval
|
181
|
+
- Significantly faster than loading all records just to check existence
|
182
|
+
|
183
|
+
### Usage Examples
|
184
|
+
```ruby
|
185
|
+
# Check if any active users exist
|
186
|
+
if User.where(active: true).exists?
|
187
|
+
# Process active users
|
188
|
+
end
|
189
|
+
|
190
|
+
# Conditional processing
|
191
|
+
def process_notifications
|
192
|
+
return unless Notification.where(unread: true).exists?
|
193
|
+
# Process unread notifications
|
194
|
+
end
|
195
|
+
|
196
|
+
# Validation logic
|
197
|
+
def validate_unique_email
|
198
|
+
errors.add(:email, 'already taken') if User.where(email: email).exists?
|
199
|
+
end
|
200
|
+
```
|
201
|
+
|
202
|
+
### Tests
|
203
|
+
```ruby
|
204
|
+
describe "exists? method" do
|
205
|
+
it "returns true when records exist" do
|
206
|
+
create(:user, active: true)
|
207
|
+
expect(User.where(active: true).exists?).to be true
|
208
|
+
end
|
209
|
+
|
210
|
+
it "returns false when no records exist" do
|
211
|
+
expect(User.where(active: true).exists?).to be false
|
212
|
+
end
|
213
|
+
|
214
|
+
it "is efficient and doesn't load full records" do
|
215
|
+
create_list(:user, 100, active: true)
|
216
|
+
|
217
|
+
# Should be much faster than loading all records
|
218
|
+
expect {
|
219
|
+
User.where(active: true).exists?
|
220
|
+
}.to perform_faster_than {
|
221
|
+
User.where(active: true).to_a.any?
|
222
|
+
}
|
223
|
+
end
|
224
|
+
end
|
225
|
+
```
|
226
|
+
|
227
|
+
## 4. Update_all Method Implementation
|
228
|
+
|
229
|
+
### Overview
|
230
|
+
The `update_all()` method provides batch update functionality for Dynamoid queries, similar to ActiveRecord's `update_all` method. This enables efficient bulk updates without loading individual records.
|
231
|
+
|
232
|
+
### Implementation
|
233
|
+
|
234
|
+
```ruby
|
235
|
+
module Dynamoid
|
236
|
+
module Criteria
|
237
|
+
class Chain
|
238
|
+
# Update all records matching the current criteria
|
239
|
+
# TODO: Make this batch operation more efficient
|
240
|
+
def update_all(conditions = {})
|
241
|
+
each do |document|
|
242
|
+
document.update_attributes(conditions)
|
243
|
+
end
|
244
|
+
end
|
245
|
+
end
|
246
|
+
end
|
247
|
+
end
|
248
|
+
```
|
249
|
+
|
250
|
+
### Benefits
|
251
|
+
- Provides familiar ActiveRecord-style batch update interface
|
252
|
+
- Enables bulk operations on query results
|
253
|
+
- Maintains consistency with other ORM patterns
|
254
|
+
- Simplifies common bulk update scenarios
|
255
|
+
|
256
|
+
### Current Implementation Notes
|
257
|
+
- Current implementation iterates through each record individually
|
258
|
+
- Future optimization could implement true batch operations
|
259
|
+
- Maintains compatibility with existing Dynamoid update patterns
|
260
|
+
|
261
|
+
### Usage Examples
|
262
|
+
```ruby
|
263
|
+
# Bulk status updates
|
264
|
+
User.where(active: false).update_all(status: 'inactive')
|
265
|
+
|
266
|
+
# Batch timestamp updates
|
267
|
+
Notification.where(read: false).update_all(updated_at: Time.current)
|
268
|
+
|
269
|
+
# Conditional bulk updates
|
270
|
+
def mark_old_notifications_as_read
|
271
|
+
Notification.where(created_at: ..1.week.ago).update_all(read: true)
|
272
|
+
end
|
273
|
+
```
|
274
|
+
|
275
|
+
### Future Optimization Opportunities
|
276
|
+
```ruby
|
277
|
+
# Potential batch implementation using DynamoDB batch operations
|
278
|
+
def update_all(conditions = {})
|
279
|
+
# Group updates into batches of 25 (DynamoDB limit)
|
280
|
+
all.each_slice(25) do |batch|
|
281
|
+
batch_requests = batch.map do |document|
|
282
|
+
{
|
283
|
+
update_item: {
|
284
|
+
table_name: document.class.table_name,
|
285
|
+
key: document.key,
|
286
|
+
update_expression: build_update_expression(conditions),
|
287
|
+
expression_attribute_values: conditions
|
288
|
+
}
|
289
|
+
}
|
290
|
+
end
|
291
|
+
|
292
|
+
dynamodb_client.batch_write_item(request_items: {
|
293
|
+
document.class.table_name => batch_requests
|
294
|
+
})
|
295
|
+
end
|
296
|
+
end
|
297
|
+
```
|
298
|
+
|
299
|
+
### Tests
|
300
|
+
```ruby
|
301
|
+
describe "update_all method" do
|
302
|
+
it "updates all matching records" do
|
303
|
+
users = create_list(:user, 5, active: true)
|
304
|
+
User.where(active: true).update_all(status: 'updated')
|
305
|
+
|
306
|
+
users.each(&:reload)
|
307
|
+
expect(users.map(&:status)).to all(eq('updated'))
|
308
|
+
end
|
309
|
+
|
310
|
+
it "works with empty result sets" do
|
311
|
+
expect {
|
312
|
+
User.where(active: false).update_all(status: 'updated')
|
313
|
+
}.not_to raise_error
|
314
|
+
end
|
315
|
+
|
316
|
+
it "updates only matching records" do
|
317
|
+
active_users = create_list(:user, 3, active: true)
|
318
|
+
inactive_users = create_list(:user, 2, active: false)
|
319
|
+
|
320
|
+
User.where(active: true).update_all(status: 'updated')
|
321
|
+
|
322
|
+
active_users.each(&:reload)
|
323
|
+
inactive_users.each(&:reload)
|
324
|
+
|
325
|
+
expect(active_users.map(&:status)).to all(eq('updated'))
|
326
|
+
expect(inactive_users.map(&:status)).to all(be_nil)
|
327
|
+
end
|
328
|
+
end
|
329
|
+
```
|
330
|
+
|
331
|
+
## 5. Null Operator Extensions
|
332
|
+
|
333
|
+
### Overview
|
334
|
+
Enhanced null value handling in Dynamoid queries, providing more intuitive ways to query for null and non-null values. This improves the developer experience when working with optional attributes.
|
335
|
+
|
336
|
+
### Implementation Context
|
337
|
+
The null operator extensions are primarily used within the UniquenessValidator but demonstrate a pattern that could be useful throughout Dynamoid:
|
338
|
+
|
339
|
+
```ruby
|
340
|
+
# From UniquenessValidator implementation
|
341
|
+
def filter_criteria(criteria, document, attribute)
|
342
|
+
value = document.read_attribute(attribute)
|
343
|
+
value.nil? ? criteria.where("#{attribute}.null" => true) : criteria.where(attribute => value)
|
344
|
+
end
|
345
|
+
```
|
346
|
+
|
347
|
+
### Benefits
|
348
|
+
- Provides intuitive null value querying
|
349
|
+
- Improves validation logic for optional fields
|
350
|
+
- Enables more expressive query conditions
|
351
|
+
- Maintains consistency with DynamoDB's null handling
|
352
|
+
|
353
|
+
### Usage Examples
|
354
|
+
```ruby
|
355
|
+
# Query for records with null values
|
356
|
+
User.where("email.null" => true)
|
357
|
+
|
358
|
+
# Query for records with non-null values
|
359
|
+
User.where("email.null" => false)
|
360
|
+
|
361
|
+
# In validation contexts
|
362
|
+
def validate_uniqueness_with_nulls
|
363
|
+
scope_criteria = base_criteria
|
364
|
+
if email.nil?
|
365
|
+
scope_criteria.where("email.null" => true)
|
366
|
+
else
|
367
|
+
scope_criteria.where(email: email)
|
368
|
+
end
|
369
|
+
end
|
370
|
+
```
|
371
|
+
|
372
|
+
### Potential Extensions
|
373
|
+
```ruby
|
374
|
+
module Dynamoid
|
375
|
+
module Criteria
|
376
|
+
class Chain
|
377
|
+
# Add convenience methods for null queries
|
378
|
+
def where_null(attribute)
|
379
|
+
where("#{attribute}.null" => true)
|
380
|
+
end
|
381
|
+
|
382
|
+
def where_not_null(attribute)
|
383
|
+
where("#{attribute}.null" => false)
|
384
|
+
end
|
385
|
+
end
|
386
|
+
end
|
387
|
+
end
|
388
|
+
```
|
389
|
+
|
390
|
+
### Tests
|
391
|
+
```ruby
|
392
|
+
describe "null operator extensions" do
|
393
|
+
it "finds records with null values" do
|
394
|
+
user_with_email = create(:user, email: 'test@example.com')
|
395
|
+
user_without_email = create(:user, email: nil)
|
396
|
+
|
397
|
+
results = User.where("email.null" => true)
|
398
|
+
expect(results).to include(user_without_email)
|
399
|
+
expect(results).not_to include(user_with_email)
|
400
|
+
end
|
401
|
+
|
402
|
+
it "finds records with non-null values" do
|
403
|
+
user_with_email = create(:user, email: 'test@example.com')
|
404
|
+
user_without_email = create(:user, email: nil)
|
405
|
+
|
406
|
+
results = User.where("email.null" => false)
|
407
|
+
expect(results).to include(user_with_email)
|
408
|
+
expect(results).not_to include(user_without_email)
|
409
|
+
end
|
410
|
+
end
|
411
|
+
```
|
412
|
+
|
413
|
+
## 6. Uniqueness Validator Implementation
|
414
|
+
|
415
|
+
### Overview
|
416
|
+
A comprehensive UniquenessValidator for Dynamoid that provides ActiveRecord-style uniqueness validation with support for scoped validation and null value handling.
|
417
|
+
|
418
|
+
### Implementation
|
419
|
+
|
420
|
+
```ruby
|
421
|
+
module Dynamoid
|
422
|
+
module Validations
|
423
|
+
# Validates whether or not a field is unique against the records in the database.
|
424
|
+
class UniquenessValidator < ActiveModel::EachValidator
|
425
|
+
# Validate the document for uniqueness violations.
|
426
|
+
# @param [Document] document The document to validate.
|
427
|
+
# @param [Symbol] attribute The name of the attribute.
|
428
|
+
# @param [Object] value The value of the object.
|
429
|
+
def validate_each(document, attribute, value)
|
430
|
+
return unless validation_required?(document, attribute)
|
431
|
+
if not_unique?(document, attribute, value)
|
432
|
+
error_options = options.except(:scope).merge(value: value)
|
433
|
+
document.errors.add(attribute, :taken, **error_options)
|
434
|
+
end
|
435
|
+
end
|
436
|
+
|
437
|
+
private
|
438
|
+
|
439
|
+
# Are we required to validate the document?
|
440
|
+
# @api private
|
441
|
+
def validation_required?(document, attribute)
|
442
|
+
document.new_record? ||
|
443
|
+
document.send("attribute_changed?", attribute.to_s) ||
|
444
|
+
scope_value_changed?(document)
|
445
|
+
end
|
446
|
+
|
447
|
+
# Scope reference has changed?
|
448
|
+
# @api private
|
449
|
+
def scope_value_changed?(document)
|
450
|
+
Array.wrap(options[:scope]).any? do |item|
|
451
|
+
document.send("attribute_changed?", item.to_s)
|
452
|
+
end
|
453
|
+
end
|
454
|
+
|
455
|
+
# Check whether a record is uniqueness.
|
456
|
+
# @api private
|
457
|
+
def not_unique?(document, attribute, value)
|
458
|
+
klass = document.class
|
459
|
+
while klass.superclass.respond_to?(:validators) && klass.superclass.validators.include?(self)
|
460
|
+
klass = klass.superclass
|
461
|
+
end
|
462
|
+
criteria = create_criteria(klass, document, attribute, value)
|
463
|
+
criteria.exists?
|
464
|
+
end
|
465
|
+
|
466
|
+
# Create the validation criteria.
|
467
|
+
# @api private
|
468
|
+
def create_criteria(base, document, attribute, value)
|
469
|
+
criteria = scope(base, document)
|
470
|
+
filter_criteria(criteria, document, attribute)
|
471
|
+
end
|
472
|
+
|
473
|
+
# @api private
|
474
|
+
def scope(criteria, document)
|
475
|
+
Array.wrap(options[:scope]).each do |item|
|
476
|
+
criteria = filter_criteria(criteria, document, item)
|
477
|
+
end
|
478
|
+
criteria
|
479
|
+
end
|
480
|
+
|
481
|
+
# Filter the criteria.
|
482
|
+
# @api private
|
483
|
+
def filter_criteria(criteria, document, attribute)
|
484
|
+
value = document.read_attribute(attribute)
|
485
|
+
value.nil? ? criteria.where("#{attribute}.null" => true) : criteria.where(attribute => value)
|
486
|
+
end
|
487
|
+
end
|
488
|
+
end
|
489
|
+
end
|
490
|
+
```
|
491
|
+
|
492
|
+
### Benefits
|
493
|
+
- Provides ActiveRecord-compatible uniqueness validation
|
494
|
+
- Supports scoped uniqueness validation
|
495
|
+
- Handles null values correctly
|
496
|
+
- Optimizes validation by only checking when necessary
|
497
|
+
- Supports inheritance hierarchies
|
498
|
+
|
499
|
+
### Key Features
|
500
|
+
1. **Conditional Validation**: Only validates when record is new or attribute has changed
|
501
|
+
2. **Scope Support**: Validates uniqueness within specified scopes
|
502
|
+
3. **Null Handling**: Properly handles null values in uniqueness checks
|
503
|
+
4. **Inheritance Support**: Works correctly with model inheritance
|
504
|
+
5. **Performance Optimization**: Uses `exists?` method for efficient checking
|
505
|
+
|
506
|
+
### Usage Examples
|
507
|
+
```ruby
|
508
|
+
class User
|
509
|
+
include Dynamoid::Document
|
510
|
+
|
511
|
+
field :email, :string
|
512
|
+
field :username, :string
|
513
|
+
field :organization_id, :string
|
514
|
+
|
515
|
+
# Basic uniqueness validation
|
516
|
+
validates :email, uniqueness: true
|
517
|
+
|
518
|
+
# Scoped uniqueness validation
|
519
|
+
validates :username, uniqueness: { scope: :organization_id }
|
520
|
+
|
521
|
+
# With custom error message
|
522
|
+
validates :email, uniqueness: { message: 'is already registered' }
|
523
|
+
end
|
524
|
+
|
525
|
+
# Usage in models
|
526
|
+
user = User.new(email: 'existing@example.com')
|
527
|
+
user.valid? # => false
|
528
|
+
user.errors[:email] # => ["has already been taken"]
|
529
|
+
```
|
530
|
+
|
531
|
+
### Tests
|
532
|
+
```ruby
|
533
|
+
describe "UniquenessValidator" do
|
534
|
+
describe "basic uniqueness" do
|
535
|
+
it "validates uniqueness of email" do
|
536
|
+
create(:user, email: 'test@example.com')
|
537
|
+
duplicate = build(:user, email: 'test@example.com')
|
538
|
+
|
539
|
+
expect(duplicate).not_to be_valid
|
540
|
+
expect(duplicate.errors[:email]).to include('has already been taken')
|
541
|
+
end
|
542
|
+
|
543
|
+
it "allows unique emails" do
|
544
|
+
create(:user, email: 'test1@example.com')
|
545
|
+
unique = build(:user, email: 'test2@example.com')
|
546
|
+
|
547
|
+
expect(unique).to be_valid
|
548
|
+
end
|
549
|
+
end
|
550
|
+
|
551
|
+
describe "scoped uniqueness" do
|
552
|
+
it "validates uniqueness within scope" do
|
553
|
+
org1 = create(:organization)
|
554
|
+
org2 = create(:organization)
|
555
|
+
|
556
|
+
create(:user, username: 'john', organization: org1)
|
557
|
+
|
558
|
+
# Same username in different org should be valid
|
559
|
+
user_different_org = build(:user, username: 'john', organization: org2)
|
560
|
+
expect(user_different_org).to be_valid
|
561
|
+
|
562
|
+
# Same username in same org should be invalid
|
563
|
+
user_same_org = build(:user, username: 'john', organization: org1)
|
564
|
+
expect(user_same_org).not_to be_valid
|
565
|
+
end
|
566
|
+
end
|
567
|
+
|
568
|
+
describe "null value handling" do
|
569
|
+
it "allows multiple records with null values" do
|
570
|
+
create(:user, email: nil)
|
571
|
+
user_with_null = build(:user, email: nil)
|
572
|
+
|
573
|
+
expect(user_with_null).to be_valid
|
574
|
+
end
|
575
|
+
end
|
576
|
+
|
577
|
+
describe "performance optimization" do
|
578
|
+
it "only validates when necessary" do
|
579
|
+
user = create(:user, email: 'test@example.com')
|
580
|
+
|
581
|
+
# Should not validate when no changes
|
582
|
+
expect(User).not_to receive(:where)
|
583
|
+
user.valid?
|
584
|
+
|
585
|
+
# Should validate when email changes
|
586
|
+
user.email = 'new@example.com'
|
587
|
+
expect(User).to receive(:where).and_call_original
|
588
|
+
user.valid?
|
589
|
+
end
|
590
|
+
end
|
591
|
+
end
|
592
|
+
```
|