familia 2.0.0.pre18 → 2.0.0.pre19
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/CHANGELOG.rst +58 -6
- data/CLAUDE.md +34 -9
- data/Gemfile +2 -2
- data/Gemfile.lock +9 -47
- data/README.md +39 -0
- data/changelog.d/20251011_012003_delano_159_datatype_transaction_pipeline_support.rst +91 -0
- data/changelog.d/20251011_203905_delano_next.rst +30 -0
- data/changelog.d/20251011_212633_delano_next.rst +13 -0
- data/changelog.d/20251011_221253_delano_next.rst +26 -0
- data/docs/guides/feature-expiration.md +18 -18
- data/docs/migrating/v2.0.0-pre19.md +197 -0
- data/examples/datatype_standalone.rb +281 -0
- data/lib/familia/connection/behavior.rb +252 -0
- data/lib/familia/connection/handlers.rb +95 -0
- data/lib/familia/connection/operation_core.rb +1 -1
- data/lib/familia/connection/{pipeline_core.rb → pipelined_core.rb} +2 -2
- data/lib/familia/connection/transaction_core.rb +7 -9
- data/lib/familia/connection.rb +3 -2
- data/lib/familia/data_type/connection.rb +151 -7
- data/lib/familia/data_type/database_commands.rb +7 -4
- data/lib/familia/data_type/serialization.rb +4 -0
- data/lib/familia/data_type/types/hashkey.rb +1 -1
- data/lib/familia/errors.rb +51 -14
- data/lib/familia/features/expiration/extensions.rb +8 -10
- data/lib/familia/features/expiration.rb +19 -19
- data/lib/familia/features/relationships/indexing/multi_index_generators.rb +39 -38
- data/lib/familia/features/relationships/indexing/unique_index_generators.rb +115 -43
- data/lib/familia/features/relationships/indexing.rb +37 -42
- data/lib/familia/features/relationships/indexing_relationship.rb +14 -4
- data/lib/familia/field_type.rb +2 -1
- data/lib/familia/horreum/connection.rb +11 -35
- data/lib/familia/horreum/database_commands.rb +129 -10
- data/lib/familia/horreum/definition.rb +2 -1
- data/lib/familia/horreum/management.rb +21 -15
- data/lib/familia/horreum/persistence.rb +190 -66
- data/lib/familia/horreum/serialization.rb +3 -0
- data/lib/familia/horreum/utils.rb +0 -8
- data/lib/familia/horreum.rb +31 -12
- data/lib/familia/logging.rb +2 -5
- data/lib/familia/settings.rb +7 -7
- data/lib/familia/version.rb +1 -1
- data/lib/middleware/database_logger.rb +76 -5
- data/try/edge_cases/string_coercion_try.rb +4 -4
- data/try/features/expiration/expiration_try.rb +1 -1
- data/try/features/relationships/indexing_try.rb +28 -4
- data/try/features/relationships/relationships_api_changes_try.rb +4 -4
- data/try/integration/connection/fiber_context_preservation_try.rb +3 -3
- data/try/integration/connection/operation_mode_guards_try.rb +1 -1
- data/try/integration/connection/pipeline_fallback_integration_try.rb +12 -12
- data/try/integration/create_method_try.rb +22 -22
- data/try/integration/data_types/datatype_pipelines_try.rb +104 -0
- data/try/integration/data_types/datatype_transactions_try.rb +247 -0
- data/try/integration/models/customer_safe_dump_try.rb +5 -1
- data/try/integration/models/familia_object_try.rb +1 -1
- data/try/integration/persistence_operations_try.rb +162 -10
- data/try/unit/data_types/boolean_try.rb +1 -1
- data/try/unit/data_types/string_try.rb +1 -1
- data/try/unit/horreum/auto_indexing_on_save_try.rb +32 -16
- data/try/unit/horreum/automatic_index_validation_try.rb +253 -0
- data/try/unit/horreum/base_try.rb +1 -1
- data/try/unit/horreum/class_methods_try.rb +2 -2
- data/try/unit/horreum/initialization_try.rb +1 -1
- data/try/unit/horreum/relations_try.rb +4 -4
- data/try/unit/horreum/serialization_try.rb +2 -2
- data/try/unit/horreum/unique_index_edge_cases_try.rb +376 -0
- data/try/unit/horreum/unique_index_guard_validation_try.rb +281 -0
- metadata +14 -2
@@ -0,0 +1,253 @@
|
|
1
|
+
# try/unit/horreum/automatic_index_validation_try.rb
|
2
|
+
|
3
|
+
#
|
4
|
+
# Automatic index validation tests
|
5
|
+
# Tests that unique index validation happens automatically when adding to indexes
|
6
|
+
#
|
7
|
+
|
8
|
+
require_relative '../../support/helpers/test_helpers'
|
9
|
+
|
10
|
+
# Test classes for automatic validation
|
11
|
+
class ::AutoValidCompany < Familia::Horreum
|
12
|
+
feature :relationships
|
13
|
+
|
14
|
+
identifier_field :company_id
|
15
|
+
field :company_id
|
16
|
+
field :name
|
17
|
+
end
|
18
|
+
|
19
|
+
class ::AutoValidEmployee < Familia::Horreum
|
20
|
+
feature :relationships
|
21
|
+
|
22
|
+
identifier_field :emp_id
|
23
|
+
field :emp_id
|
24
|
+
field :badge_number
|
25
|
+
field :email
|
26
|
+
|
27
|
+
# Instance-scoped unique index (should auto-validate in add_to_* methods)
|
28
|
+
unique_index :badge_number, :badge_index, within: AutoValidCompany
|
29
|
+
|
30
|
+
# Class-level unique index (auto-validates in save)
|
31
|
+
unique_index :email, :email_index
|
32
|
+
end
|
33
|
+
|
34
|
+
class ::AutoValidUser < Familia::Horreum
|
35
|
+
feature :relationships
|
36
|
+
|
37
|
+
identifier_field :user_id
|
38
|
+
field :user_id
|
39
|
+
field :email
|
40
|
+
|
41
|
+
unique_index :email, :email_index
|
42
|
+
end
|
43
|
+
|
44
|
+
# Setup
|
45
|
+
@company_id = "comp_#{rand(1000000)}"
|
46
|
+
@company = AutoValidCompany.new(company_id: @company_id, name: 'Test Corp')
|
47
|
+
@company.save
|
48
|
+
|
49
|
+
@emp1_id = "emp_#{rand(1000000)}"
|
50
|
+
@emp2_id = "emp_#{rand(1000000)}"
|
51
|
+
|
52
|
+
# =============================================
|
53
|
+
# 1. Automatic Validation in add_to_* Methods
|
54
|
+
# =============================================
|
55
|
+
|
56
|
+
## First employee can add badge to company index
|
57
|
+
@emp1 = AutoValidEmployee.new(emp_id: @emp1_id, badge_number: 'BADGE123', email: 'emp1@example.com')
|
58
|
+
@emp1.save # Save first to establish class-level email index
|
59
|
+
@emp1.add_to_auto_valid_company_badge_index(@company)
|
60
|
+
@company.badge_index.has_key?('BADGE123')
|
61
|
+
#=> true
|
62
|
+
|
63
|
+
## Duplicate badge is automatically rejected without manual guard call
|
64
|
+
@emp2 = AutoValidEmployee.new(emp_id: @emp2_id, badge_number: 'BADGE123', email: 'emp2@example.com')
|
65
|
+
begin
|
66
|
+
@emp2.add_to_auto_valid_company_badge_index(@company)
|
67
|
+
false
|
68
|
+
rescue Familia::RecordExistsError => e
|
69
|
+
e.message.include?('AutoValidEmployee exists in AutoValidCompany with badge_number=BADGE123')
|
70
|
+
end
|
71
|
+
#=> true
|
72
|
+
|
73
|
+
## Badge was not added after validation failure
|
74
|
+
@company.badge_index.get('BADGE123')
|
75
|
+
#=> @emp1_id
|
76
|
+
|
77
|
+
## Different badge number works fine
|
78
|
+
@emp2.badge_number = 'BADGE456'
|
79
|
+
@emp2.add_to_auto_valid_company_badge_index(@company)
|
80
|
+
@company.badge_index.has_key?('BADGE456')
|
81
|
+
#=> true
|
82
|
+
|
83
|
+
## Same employee can re-add (idempotent)
|
84
|
+
@emp1.add_to_auto_valid_company_badge_index(@company)
|
85
|
+
@company.badge_index.get('BADGE123')
|
86
|
+
#=> @emp1_id
|
87
|
+
|
88
|
+
## Different company allows same badge (scoped uniqueness)
|
89
|
+
@company2_id = "comp_#{rand(1000000)}"
|
90
|
+
@company2 = AutoValidCompany.new(company_id: @company2_id, name: 'Other Corp')
|
91
|
+
@company2.save
|
92
|
+
@emp3_id = "emp_#{rand(1000000)}"
|
93
|
+
@emp3 = AutoValidEmployee.new(emp_id: @emp3_id, badge_number: 'BADGE123', email: 'emp3@example.com')
|
94
|
+
@emp3.add_to_auto_valid_company_badge_index(@company2)
|
95
|
+
@company2.badge_index.has_key?('BADGE123')
|
96
|
+
#=> true
|
97
|
+
|
98
|
+
## Nil badge_number handled gracefully (no validation or addition)
|
99
|
+
@emp_nil = AutoValidEmployee.new(emp_id: "emp_nil_#{rand(1000000)}", badge_number: nil, email: 'empnil@example.com')
|
100
|
+
@emp_nil.add_to_auto_valid_company_badge_index(@company)
|
101
|
+
#=> nil
|
102
|
+
|
103
|
+
## Nil parent handled gracefully (no validation or addition)
|
104
|
+
@emp4 = AutoValidEmployee.new(emp_id: "emp4_#{rand(1000000)}", badge_number: 'BADGE789', email: 'emp4@example.com')
|
105
|
+
@emp4.add_to_auto_valid_company_badge_index(nil)
|
106
|
+
#=> nil
|
107
|
+
|
108
|
+
# =============================================
|
109
|
+
# 2. Transaction Detection in save()
|
110
|
+
# =============================================
|
111
|
+
|
112
|
+
## Normal save works outside transaction
|
113
|
+
@user1_id = "user_#{rand(1000000)}"
|
114
|
+
@user1 = AutoValidUser.new(user_id: @user1_id, email: 'user1@example.com')
|
115
|
+
@user1.save
|
116
|
+
#=> true
|
117
|
+
|
118
|
+
## save() raises error when called within transaction
|
119
|
+
@user2_id = "user_#{rand(1000000)}"
|
120
|
+
begin
|
121
|
+
AutoValidUser.transaction do
|
122
|
+
@user2 = AutoValidUser.new(user_id: @user2_id, email: 'user2@example.com')
|
123
|
+
@user2.save
|
124
|
+
end
|
125
|
+
false
|
126
|
+
rescue Familia::OperationModeError => e
|
127
|
+
e.message.include?('Cannot call save within a transaction')
|
128
|
+
end
|
129
|
+
#=> true
|
130
|
+
|
131
|
+
## Object was not saved due to transaction error
|
132
|
+
AutoValidUser.find_by_email('user2@example.com')
|
133
|
+
#=> nil
|
134
|
+
|
135
|
+
## Transaction with explicit field updates works (bypass save)
|
136
|
+
@user3_id = "user_#{rand(1000000)}"
|
137
|
+
@user3 = AutoValidUser.new(user_id: @user3_id, email: 'user3@example.com')
|
138
|
+
AutoValidUser.transaction do |_tx|
|
139
|
+
@user3.hmset(@user3.to_h_for_storage)
|
140
|
+
end
|
141
|
+
@user3.exists?
|
142
|
+
#=> true
|
143
|
+
|
144
|
+
## save() works after transaction completes
|
145
|
+
@user4_id = "user_#{rand(1000000)}"
|
146
|
+
AutoValidUser.transaction do
|
147
|
+
# Do something else in transaction
|
148
|
+
end
|
149
|
+
@user4 = AutoValidUser.new(user_id: @user4_id, email: 'user4@example.com')
|
150
|
+
@user4.save
|
151
|
+
#=> true
|
152
|
+
|
153
|
+
# =============================================
|
154
|
+
# 3. Combined Automatic Validation Scenarios
|
155
|
+
# =============================================
|
156
|
+
|
157
|
+
## Employee with duplicate class-level email caught in save
|
158
|
+
@emp5_id = "emp_#{rand(1000000)}"
|
159
|
+
@emp5 = AutoValidEmployee.new(emp_id: @emp5_id, badge_number: 'BADGE999', email: 'emp1@example.com')
|
160
|
+
begin
|
161
|
+
@emp5.save
|
162
|
+
false
|
163
|
+
rescue Familia::RecordExistsError => e
|
164
|
+
e.message.include?('AutoValidEmployee exists email=emp1@example.com')
|
165
|
+
end
|
166
|
+
#=> true
|
167
|
+
|
168
|
+
## Employee can save with unique email
|
169
|
+
@emp1.save
|
170
|
+
AutoValidEmployee.find_by_email('emp1@example.com')&.emp_id
|
171
|
+
#=> @emp1_id
|
172
|
+
|
173
|
+
## After save, duplicate instance-scoped index still caught automatically
|
174
|
+
@emp6_id = "emp_#{rand(1000000)}"
|
175
|
+
@emp6 = AutoValidEmployee.new(emp_id: @emp6_id, badge_number: 'BADGE123', email: 'emp6@example.com')
|
176
|
+
@emp6.save # Class-level index is fine
|
177
|
+
begin
|
178
|
+
@emp6.add_to_auto_valid_company_badge_index(@company) # Instance-scoped duplicate
|
179
|
+
false
|
180
|
+
rescue Familia::RecordExistsError => e
|
181
|
+
e.message.include?('badge_number=BADGE123')
|
182
|
+
end
|
183
|
+
#=> true
|
184
|
+
|
185
|
+
# =============================================
|
186
|
+
# 4. Error Message Quality
|
187
|
+
# =============================================
|
188
|
+
|
189
|
+
## Instance-scoped validation error includes both class names
|
190
|
+
begin
|
191
|
+
@emp2.badge_number = 'BADGE123' # Reset to duplicate
|
192
|
+
@emp2.add_to_auto_valid_company_badge_index(@company)
|
193
|
+
rescue Familia::RecordExistsError => e
|
194
|
+
[e.message.include?('AutoValidEmployee'), e.message.include?('AutoValidCompany')]
|
195
|
+
end
|
196
|
+
#=> [true, true]
|
197
|
+
|
198
|
+
## Instance-scoped validation error includes field name and value
|
199
|
+
begin
|
200
|
+
@emp2.add_to_auto_valid_company_badge_index(@company)
|
201
|
+
rescue Familia::RecordExistsError => e
|
202
|
+
[e.message.include?('badge_number'), e.message.include?('BADGE123')]
|
203
|
+
end
|
204
|
+
#=> [true, true]
|
205
|
+
|
206
|
+
## Error type is RecordExistsError
|
207
|
+
begin
|
208
|
+
@emp2.add_to_auto_valid_company_badge_index(@company)
|
209
|
+
rescue => e
|
210
|
+
e.class
|
211
|
+
end
|
212
|
+
#=> Familia::RecordExistsError
|
213
|
+
|
214
|
+
# =============================================
|
215
|
+
# 5. Performance - No Double Validation
|
216
|
+
# =============================================
|
217
|
+
|
218
|
+
## Manual guard call before add_to_* is redundant but harmless
|
219
|
+
@emp7_id = "emp_#{rand(1000000)}"
|
220
|
+
@emp7 = AutoValidEmployee.new(emp_id: @emp7_id, badge_number: 'BADGE777', email: 'emp7@example.com')
|
221
|
+
@emp7.guard_unique_auto_valid_company_badge_index!(@company)
|
222
|
+
@emp7.add_to_auto_valid_company_badge_index(@company)
|
223
|
+
@company.badge_index.has_key?('BADGE777')
|
224
|
+
#=> true
|
225
|
+
|
226
|
+
## Manual guard call detects duplicate
|
227
|
+
@emp8_id = "emp_#{rand(1000000)}"
|
228
|
+
@emp8 = AutoValidEmployee.new(emp_id: @emp8_id, badge_number: 'BADGE777', email: 'emp8@example.com')
|
229
|
+
begin
|
230
|
+
@emp8.guard_unique_auto_valid_company_badge_index!(@company) # Should fail - duplicate badge
|
231
|
+
false
|
232
|
+
rescue Familia::RecordExistsError
|
233
|
+
true
|
234
|
+
end
|
235
|
+
#=> true
|
236
|
+
|
237
|
+
# Teardown - clean up test objects
|
238
|
+
[@emp1, @emp2, @emp3, @emp_nil, @emp4, @emp5, @emp6, @emp7, @emp8].compact.each do |obj|
|
239
|
+
obj.destroy! if obj.respond_to?(:destroy!) && obj.respond_to?(:exists?) && obj.exists?
|
240
|
+
end
|
241
|
+
|
242
|
+
[@user1, @user3, @user4].compact.each do |obj|
|
243
|
+
obj.destroy! if obj.respond_to?(:destroy!) && obj.respond_to?(:exists?) && obj.exists?
|
244
|
+
end
|
245
|
+
|
246
|
+
[@company, @company2].each do |obj|
|
247
|
+
obj.destroy! if obj.respond_to?(:destroy!) && obj.respond_to?(:exists?) && obj.exists?
|
248
|
+
end
|
249
|
+
|
250
|
+
# Clean up class-level indexes
|
251
|
+
[AutoValidEmployee.email_index, AutoValidUser.email_index].each do |index|
|
252
|
+
index.delete! if index.respond_to?(:delete!) && index.respond_to?(:exists?) && index.exists?
|
253
|
+
end
|
@@ -16,9 +16,9 @@ module AnotherModuleName
|
|
16
16
|
end
|
17
17
|
end
|
18
18
|
|
19
|
-
## create factory method with existence checking
|
19
|
+
## create! factory method with existence checking
|
20
20
|
TestUser
|
21
|
-
#==> _.respond_to?(:create)
|
21
|
+
#==> _.respond_to?(:create!)
|
22
22
|
#==> _.respond_to?(:exists?)
|
23
23
|
|
24
24
|
## multiget method is available
|
@@ -101,7 +101,7 @@ Familia.debug = false
|
|
101
101
|
|
102
102
|
## Clean up saved test objects
|
103
103
|
[@customer6, @complex].map(&:delete!)
|
104
|
-
#=> [
|
104
|
+
#=> [1, 1]
|
105
105
|
|
106
106
|
## "Cleaning up" test objects that were never saved returns true regardless
|
107
107
|
## b/c it takes place in a transaction and it's the transaction's success
|
@@ -92,15 +92,15 @@ prefs = @test_user.preferences
|
|
92
92
|
@test_user.preferences.size
|
93
93
|
#=> 2
|
94
94
|
|
95
|
-
## Clearing a counter returns
|
95
|
+
## Clearing a counter returns 0 when not set yet
|
96
96
|
@test_product.views.clear
|
97
97
|
@test_product.views.clear
|
98
|
-
#=>
|
98
|
+
#=> 0
|
99
99
|
|
100
|
-
## Clearing a counter returns
|
100
|
+
## Clearing a counter returns 1 when it is set
|
101
101
|
@test_product.views.increment
|
102
102
|
@test_product.views.clear
|
103
|
-
#=>
|
103
|
+
#=> 1
|
104
104
|
|
105
105
|
## Counter Database type works
|
106
106
|
@test_product.views.increment
|
@@ -20,10 +20,10 @@ Familia.dbclient.set('debug:starting_save_if_not_exists_tests', Familia.now.to_s
|
|
20
20
|
@new_customer.save_if_not_exists
|
21
21
|
#=> true
|
22
22
|
|
23
|
-
## save_if_not_exists raises error when customer already exists
|
23
|
+
## save_if_not_exists! raises error when customer already exists
|
24
24
|
@duplicate_customer = Customer.new "new-customer-#{@test_id}@test.com"
|
25
25
|
@duplicate_customer.name = 'Duplicate Customer'
|
26
|
-
@duplicate_customer.save_if_not_exists
|
26
|
+
@duplicate_customer.save_if_not_exists!
|
27
27
|
#=!> Familia::RecordExistsError
|
28
28
|
#==> error.message.include?("Key already exists")
|
29
29
|
|
@@ -0,0 +1,376 @@
|
|
1
|
+
# Testing edge cases for unique index validation
|
2
|
+
# lib/familia/features/relationships/indexing/unique_index_generators.rb
|
3
|
+
# lib/familia/horreum/persistence.rb
|
4
|
+
|
5
|
+
require_relative '../../../lib/familia'
|
6
|
+
|
7
|
+
Familia.debug = false
|
8
|
+
|
9
|
+
# ========================================
|
10
|
+
# Setup: Define test models
|
11
|
+
# ========================================
|
12
|
+
|
13
|
+
class EdgeCaseCompany < Familia::Horreum
|
14
|
+
feature :relationships
|
15
|
+
|
16
|
+
identifier_field :company_id
|
17
|
+
field :company_id
|
18
|
+
field :company_name
|
19
|
+
|
20
|
+
# init receives no arguments - fields already set from new()
|
21
|
+
# Use ||= to apply defaults if needed
|
22
|
+
def init
|
23
|
+
# No defaults needed for this class
|
24
|
+
# Could add: @company_name ||= 'Unknown Company'
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
class EdgeCaseEmployee < Familia::Horreum
|
29
|
+
feature :relationships
|
30
|
+
|
31
|
+
identifier_field :emp_id
|
32
|
+
field :emp_id
|
33
|
+
field :email
|
34
|
+
field :badge_number
|
35
|
+
field :department
|
36
|
+
field :status
|
37
|
+
|
38
|
+
# Class-level unique index for email (auto-populates on save)
|
39
|
+
unique_index :email, :email_index
|
40
|
+
|
41
|
+
# Instance-scoped unique index for badge_number within Company
|
42
|
+
unique_index :badge_number, :badge_index, within: EdgeCaseCompany
|
43
|
+
|
44
|
+
# Multi-index for department (1:many) within Company
|
45
|
+
multi_index :department, :dept_index, within: EdgeCaseCompany
|
46
|
+
|
47
|
+
# init receives no arguments - fields already set from new()
|
48
|
+
# Use ||= to apply defaults if needed
|
49
|
+
def init
|
50
|
+
@status ||= 'active' # Apply default status if not provided
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
class EdgeCaseProduct < Familia::Horreum
|
55
|
+
feature :relationships
|
56
|
+
|
57
|
+
identifier_field :product_id
|
58
|
+
field :product_id
|
59
|
+
field :sku
|
60
|
+
|
61
|
+
# Allow empty strings in unique index
|
62
|
+
unique_index :sku, :sku_index
|
63
|
+
|
64
|
+
# init receives no arguments - fields already set from new()
|
65
|
+
# Use ||= to apply defaults if needed
|
66
|
+
def init
|
67
|
+
# No defaults needed for this class
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
# Clear all indexes before starting
|
72
|
+
EdgeCaseEmployee.email_index.clear
|
73
|
+
EdgeCaseProduct.sku_index.clear
|
74
|
+
|
75
|
+
# ========================================
|
76
|
+
# Test 1: Duplicate instance-scoped index values
|
77
|
+
# ========================================
|
78
|
+
|
79
|
+
## Setup companies and employees
|
80
|
+
@company1 = EdgeCaseCompany.new(company_id: 'c1', company_name: 'Acme Corp')
|
81
|
+
@company2 = EdgeCaseCompany.new(company_id: 'c2', company_name: 'Tech Inc')
|
82
|
+
@company1.save
|
83
|
+
@company2.save
|
84
|
+
#=> true
|
85
|
+
|
86
|
+
## Create employee with badge_number in company1
|
87
|
+
@emp1 = EdgeCaseEmployee.new(emp_id: 'e1', email: 'john@test.com', badge_number: 'B12345')
|
88
|
+
@emp1.save # This auto-populates email index
|
89
|
+
@emp1.add_to_edge_case_company_badge_index(@company1) # Manual for instance-scoped
|
90
|
+
@company1.find_by_badge_number('B12345')&.emp_id
|
91
|
+
#=> 'e1'
|
92
|
+
|
93
|
+
## Create another employee with same badge_number - guard should detect duplicate
|
94
|
+
@emp2 = EdgeCaseEmployee.new(emp_id: 'e2', email: 'jane@test.com', badge_number: 'B12345')
|
95
|
+
@emp2.save # Different email is OK
|
96
|
+
begin
|
97
|
+
@emp2.guard_unique_edge_case_company_badge_index!(@company1)
|
98
|
+
false
|
99
|
+
rescue Familia::RecordExistsError
|
100
|
+
true
|
101
|
+
end
|
102
|
+
#=> true
|
103
|
+
|
104
|
+
## Same badge_number should work in different company (different scope)
|
105
|
+
@emp2.add_to_edge_case_company_badge_index(@company2)
|
106
|
+
@company2.find_by_badge_number('B12345')&.emp_id
|
107
|
+
#=> 'e2'
|
108
|
+
|
109
|
+
## Verify badge exists in both companies with different employees
|
110
|
+
[@company1.find_by_badge_number('B12345')&.emp_id, @company2.find_by_badge_number('B12345')&.emp_id]
|
111
|
+
#=> ['e1', 'e2']
|
112
|
+
|
113
|
+
## Cleanup for next test
|
114
|
+
EdgeCaseEmployee.email_index.clear
|
115
|
+
@company1.badge_index.clear
|
116
|
+
@company2.badge_index.clear
|
117
|
+
#=> 1
|
118
|
+
|
119
|
+
# ========================================
|
120
|
+
# Test 2: Field updates with auto-index cleanup
|
121
|
+
# ========================================
|
122
|
+
|
123
|
+
## Create employee with email (save auto-populates index)
|
124
|
+
@emp3 = EdgeCaseEmployee.new(emp_id: 'e3', email: 'original@test.com')
|
125
|
+
@emp3.save
|
126
|
+
EdgeCaseEmployee.find_by_email('original@test.com')&.emp_id
|
127
|
+
#=> 'e3'
|
128
|
+
|
129
|
+
## Update email - must manually update index (no automatic cleanup on field change)
|
130
|
+
old_email = @emp3.email
|
131
|
+
@emp3.email = 'updated@test.com'
|
132
|
+
@emp3.update_in_class_email_index(old_email) # Manual update required
|
133
|
+
EdgeCaseEmployee.find_by_email('original@test.com')
|
134
|
+
#=> nil
|
135
|
+
|
136
|
+
## New email should resolve to employee
|
137
|
+
EdgeCaseEmployee.find_by_email('updated@test.com')&.emp_id
|
138
|
+
#=> 'e3'
|
139
|
+
|
140
|
+
## Update instance-scoped index
|
141
|
+
@emp3.badge_number = 'B99999'
|
142
|
+
@emp3.add_to_edge_case_company_badge_index(@company1)
|
143
|
+
@company1.find_by_badge_number('B99999')&.emp_id
|
144
|
+
#=> 'e3'
|
145
|
+
|
146
|
+
## Change badge and update index
|
147
|
+
old_badge = @emp3.badge_number
|
148
|
+
@emp3.badge_number = 'B11111'
|
149
|
+
@emp3.update_in_edge_case_company_badge_index(@company1, old_badge)
|
150
|
+
@company1.find_by_badge_number('B99999')
|
151
|
+
#=> nil
|
152
|
+
|
153
|
+
## New badge should work
|
154
|
+
@company1.find_by_badge_number('B11111')&.emp_id
|
155
|
+
#=> 'e3'
|
156
|
+
|
157
|
+
## Cleanup
|
158
|
+
EdgeCaseEmployee.email_index.clear
|
159
|
+
@company1.badge_index.clear
|
160
|
+
#=> 1
|
161
|
+
|
162
|
+
# ========================================
|
163
|
+
# Test 3: Save within explicit transactions (validation bypass)
|
164
|
+
# ========================================
|
165
|
+
|
166
|
+
## Create first employee successfully
|
167
|
+
@emp4 = EdgeCaseEmployee.new(emp_id: 'e4', email: 'txn@test.com')
|
168
|
+
@emp4.save
|
169
|
+
EdgeCaseEmployee.find_by_email('txn@test.com')&.emp_id
|
170
|
+
#=> 'e4'
|
171
|
+
|
172
|
+
## Save cannot be called inside transaction - it raises OperationModeError
|
173
|
+
@emp5 = EdgeCaseEmployee.new(emp_id: 'e5', email: 'txn@test.com')
|
174
|
+
error_raised = false
|
175
|
+
begin
|
176
|
+
EdgeCaseEmployee.transaction do |tx|
|
177
|
+
@emp5.save # This will raise
|
178
|
+
end
|
179
|
+
rescue Familia::OperationModeError => e
|
180
|
+
error_raised = e.message.include?("Cannot call save within a transaction")
|
181
|
+
end
|
182
|
+
error_raised
|
183
|
+
#=> true
|
184
|
+
|
185
|
+
## However, we can bypass validation by manually adding to index inside transaction
|
186
|
+
result = EdgeCaseEmployee.transaction do |tx|
|
187
|
+
# Manually add without validation (dangerous!)
|
188
|
+
EdgeCaseEmployee.email_index['txn_bypass@test.com'] = 'e5'
|
189
|
+
'manual_bypass'
|
190
|
+
end
|
191
|
+
result.successful?
|
192
|
+
#=> true
|
193
|
+
|
194
|
+
## After transaction, the manual entry exists (no validation occurred)
|
195
|
+
EdgeCaseEmployee.email_index['txn_bypass@test.com']
|
196
|
+
#=> 'e5'
|
197
|
+
|
198
|
+
## After transaction, the manual entry exists (no validation occurred)
|
199
|
+
EdgeCaseEmployee.email_index['txn_bypass@test.com']
|
200
|
+
#=> 'e5'
|
201
|
+
|
202
|
+
## Cleanup
|
203
|
+
EdgeCaseEmployee.email_index.clear
|
204
|
+
#=> 1
|
205
|
+
|
206
|
+
# ========================================
|
207
|
+
# Test 4: Multiple empty string values in same index
|
208
|
+
# ========================================
|
209
|
+
|
210
|
+
## Create product with empty SKU
|
211
|
+
@prod1 = EdgeCaseProduct.new(product_id: 'p1', sku: '')
|
212
|
+
@prod1.save
|
213
|
+
EdgeCaseProduct.find_by_sku('')&.product_id
|
214
|
+
#=> 'p1'
|
215
|
+
|
216
|
+
## Try to create another product with empty SKU - should fail
|
217
|
+
@prod2 = EdgeCaseProduct.new(product_id: 'p2', sku: '')
|
218
|
+
begin
|
219
|
+
@prod2.save
|
220
|
+
false
|
221
|
+
rescue Familia::RecordExistsError => e
|
222
|
+
e.message.include?('sku=')
|
223
|
+
end
|
224
|
+
#=> true
|
225
|
+
|
226
|
+
## nil values should be skipped (not indexed)
|
227
|
+
@prod3 = EdgeCaseProduct.new(product_id: 'p3', sku: nil)
|
228
|
+
@prod3.save # Should succeed - nil values aren't indexed
|
229
|
+
@prod3.identifier
|
230
|
+
#=> 'p3'
|
231
|
+
|
232
|
+
## Verify nil doesn't exist in index (empty string != nil)
|
233
|
+
EdgeCaseProduct.sku_index[''] # Empty string key
|
234
|
+
#=> 'p1'
|
235
|
+
|
236
|
+
## nil is not indexed
|
237
|
+
EdgeCaseProduct.sku_index.keys.include?(nil)
|
238
|
+
#=> false
|
239
|
+
|
240
|
+
## Cleanup
|
241
|
+
EdgeCaseProduct.sku_index.clear
|
242
|
+
#=> 1
|
243
|
+
|
244
|
+
# ========================================
|
245
|
+
# Test 5: Concurrent saves with same unique value
|
246
|
+
# ========================================
|
247
|
+
|
248
|
+
## Setup fresh index
|
249
|
+
EdgeCaseEmployee.email_index.clear
|
250
|
+
#=> 0
|
251
|
+
|
252
|
+
## Create two employees with same email (simulating race condition)
|
253
|
+
@emp6 = EdgeCaseEmployee.new(emp_id: 'e6', email: 'race@test.com')
|
254
|
+
@emp7 = EdgeCaseEmployee.new(emp_id: 'e7', email: 'race@test.com')
|
255
|
+
@emp7.emp_id
|
256
|
+
#=> 'e7'
|
257
|
+
|
258
|
+
## First save succeeds
|
259
|
+
@emp6.save
|
260
|
+
EdgeCaseEmployee.find_by_email('race@test.com')&.emp_id
|
261
|
+
#=> 'e6'
|
262
|
+
|
263
|
+
## Second save fails due to validation
|
264
|
+
begin
|
265
|
+
@emp7.save
|
266
|
+
false
|
267
|
+
rescue Familia::RecordExistsError
|
268
|
+
true
|
269
|
+
end
|
270
|
+
#=> true
|
271
|
+
|
272
|
+
## Simulate race condition: both check validation, then both write
|
273
|
+
EdgeCaseEmployee.email_index.clear
|
274
|
+
@emp8 = EdgeCaseEmployee.new(emp_id: 'e8', email: 'race2@test.com')
|
275
|
+
@emp9 = EdgeCaseEmployee.new(emp_id: 'e9', email: 'race2@test.com')
|
276
|
+
[@emp8.emp_id, @emp9.emp_id]
|
277
|
+
#=> ['e8', 'e9']
|
278
|
+
|
279
|
+
## Both pass validation check (index is empty)
|
280
|
+
begin
|
281
|
+
@emp8.guard_unique_email_index!
|
282
|
+
@emp9.guard_unique_email_index!
|
283
|
+
true
|
284
|
+
rescue
|
285
|
+
false
|
286
|
+
end
|
287
|
+
#=> true
|
288
|
+
|
289
|
+
## Both write to index (last write wins in Redis)
|
290
|
+
@emp8.add_to_class_email_index
|
291
|
+
@emp9.add_to_class_email_index
|
292
|
+
# Verify the index contains the identifier (orphaned entry - wastes space but harmless)
|
293
|
+
EdgeCaseEmployee.email_index['race2@test.com']
|
294
|
+
#=> 'e9'
|
295
|
+
|
296
|
+
## find_by returns nil for orphaned index entries (object never saved)
|
297
|
+
# This is correct behavior - orphaned entries degrade gracefully to nil
|
298
|
+
EdgeCaseEmployee.find_by_email('race2@test.com')
|
299
|
+
#=> nil
|
300
|
+
|
301
|
+
## To properly handle concurrent saves, check existence inside transaction
|
302
|
+
# Note: Can't read inside MULTI block, so need WATCH/MULTI pattern
|
303
|
+
result = nil
|
304
|
+
EdgeCaseEmployee.dbclient.watch('edge_case_employee:email_index') do
|
305
|
+
if EdgeCaseEmployee.email_index['race3@test.com'].nil?
|
306
|
+
EdgeCaseEmployee.transaction do |tx|
|
307
|
+
EdgeCaseEmployee.email_index['race3@test.com'] = 'e10'
|
308
|
+
result = 'success'
|
309
|
+
end
|
310
|
+
else
|
311
|
+
result = 'duplicate'
|
312
|
+
end
|
313
|
+
end
|
314
|
+
result
|
315
|
+
#=> 'success'
|
316
|
+
|
317
|
+
## Cleanup
|
318
|
+
EdgeCaseEmployee.email_index.clear
|
319
|
+
#=> 1
|
320
|
+
|
321
|
+
# ========================================
|
322
|
+
# Edge Case: Update with validation in compound operation
|
323
|
+
# ========================================
|
324
|
+
|
325
|
+
## Test compound index updates in transaction
|
326
|
+
@company3 = EdgeCaseCompany.new(company_id: 'c3', company_name: 'Test Corp')
|
327
|
+
@company3.save
|
328
|
+
#=> true
|
329
|
+
|
330
|
+
## Create employee
|
331
|
+
@emp11 = EdgeCaseEmployee.new(emp_id: 'e11', email: 'compound@test.com', badge_number: 'B555')
|
332
|
+
@emp11.save
|
333
|
+
@emp11.add_to_edge_case_company_badge_index(@company3)
|
334
|
+
@emp11.emp_id
|
335
|
+
#=> 'e11'
|
336
|
+
|
337
|
+
## Update multiple indexed fields atomically
|
338
|
+
@emp11 = EdgeCaseEmployee.new(emp_id: 'e11', email: 'compound@test.com', badge_number: 'B555')
|
339
|
+
@emp11.save
|
340
|
+
@emp11.add_to_edge_case_company_badge_index(@company3)
|
341
|
+
|
342
|
+
old_email = @emp11.email
|
343
|
+
old_badge = @emp11.badge_number
|
344
|
+
@emp11.email = 'compound_new@test.com'
|
345
|
+
@emp11.badge_number = 'B666'
|
346
|
+
|
347
|
+
# Update both indexes in single transaction
|
348
|
+
result = EdgeCaseEmployee.transaction do |tx|
|
349
|
+
@emp11.update_in_class_email_index(old_email)
|
350
|
+
@emp11.update_in_edge_case_company_badge_index(@company3, old_badge)
|
351
|
+
'updated'
|
352
|
+
end
|
353
|
+
result.successful?
|
354
|
+
#=> true
|
355
|
+
|
356
|
+
## Verify updates succeeded
|
357
|
+
[EdgeCaseEmployee.find_by_email('compound_new@test.com')&.emp_id, @company3.find_by_badge_number('B666')&.emp_id]
|
358
|
+
#=> ['e11', 'e11']
|
359
|
+
|
360
|
+
## Old values should be gone
|
361
|
+
[EdgeCaseEmployee.find_by_email('compound@test.com'), @company3.find_by_badge_number('B555')]
|
362
|
+
#=> [nil, nil]
|
363
|
+
|
364
|
+
|
365
|
+
# Final cleanup
|
366
|
+
EdgeCaseEmployee.email_index.clear
|
367
|
+
if @company3&.respond_to?(:badge_index) && @company3.badge_index.respond_to?(:clear)
|
368
|
+
@company3.badge_index.clear
|
369
|
+
end
|
370
|
+
|
371
|
+
# Clean up test objects - check if they still exist before destroying
|
372
|
+
[@company1, @company2, @company3].compact.each do |obj|
|
373
|
+
obj.destroy! if obj.respond_to?(:destroy!) && obj.respond_to?(:exists?) && obj.exists?
|
374
|
+
end
|
375
|
+
|
376
|
+
puts "All edge case tests completed"
|