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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.rst +58 -6
  3. data/CLAUDE.md +34 -9
  4. data/Gemfile +2 -2
  5. data/Gemfile.lock +9 -47
  6. data/README.md +39 -0
  7. data/changelog.d/20251011_012003_delano_159_datatype_transaction_pipeline_support.rst +91 -0
  8. data/changelog.d/20251011_203905_delano_next.rst +30 -0
  9. data/changelog.d/20251011_212633_delano_next.rst +13 -0
  10. data/changelog.d/20251011_221253_delano_next.rst +26 -0
  11. data/docs/guides/feature-expiration.md +18 -18
  12. data/docs/migrating/v2.0.0-pre19.md +197 -0
  13. data/examples/datatype_standalone.rb +281 -0
  14. data/lib/familia/connection/behavior.rb +252 -0
  15. data/lib/familia/connection/handlers.rb +95 -0
  16. data/lib/familia/connection/operation_core.rb +1 -1
  17. data/lib/familia/connection/{pipeline_core.rb → pipelined_core.rb} +2 -2
  18. data/lib/familia/connection/transaction_core.rb +7 -9
  19. data/lib/familia/connection.rb +3 -2
  20. data/lib/familia/data_type/connection.rb +151 -7
  21. data/lib/familia/data_type/database_commands.rb +7 -4
  22. data/lib/familia/data_type/serialization.rb +4 -0
  23. data/lib/familia/data_type/types/hashkey.rb +1 -1
  24. data/lib/familia/errors.rb +51 -14
  25. data/lib/familia/features/expiration/extensions.rb +8 -10
  26. data/lib/familia/features/expiration.rb +19 -19
  27. data/lib/familia/features/relationships/indexing/multi_index_generators.rb +39 -38
  28. data/lib/familia/features/relationships/indexing/unique_index_generators.rb +115 -43
  29. data/lib/familia/features/relationships/indexing.rb +37 -42
  30. data/lib/familia/features/relationships/indexing_relationship.rb +14 -4
  31. data/lib/familia/field_type.rb +2 -1
  32. data/lib/familia/horreum/connection.rb +11 -35
  33. data/lib/familia/horreum/database_commands.rb +129 -10
  34. data/lib/familia/horreum/definition.rb +2 -1
  35. data/lib/familia/horreum/management.rb +21 -15
  36. data/lib/familia/horreum/persistence.rb +190 -66
  37. data/lib/familia/horreum/serialization.rb +3 -0
  38. data/lib/familia/horreum/utils.rb +0 -8
  39. data/lib/familia/horreum.rb +31 -12
  40. data/lib/familia/logging.rb +2 -5
  41. data/lib/familia/settings.rb +7 -7
  42. data/lib/familia/version.rb +1 -1
  43. data/lib/middleware/database_logger.rb +76 -5
  44. data/try/edge_cases/string_coercion_try.rb +4 -4
  45. data/try/features/expiration/expiration_try.rb +1 -1
  46. data/try/features/relationships/indexing_try.rb +28 -4
  47. data/try/features/relationships/relationships_api_changes_try.rb +4 -4
  48. data/try/integration/connection/fiber_context_preservation_try.rb +3 -3
  49. data/try/integration/connection/operation_mode_guards_try.rb +1 -1
  50. data/try/integration/connection/pipeline_fallback_integration_try.rb +12 -12
  51. data/try/integration/create_method_try.rb +22 -22
  52. data/try/integration/data_types/datatype_pipelines_try.rb +104 -0
  53. data/try/integration/data_types/datatype_transactions_try.rb +247 -0
  54. data/try/integration/models/customer_safe_dump_try.rb +5 -1
  55. data/try/integration/models/familia_object_try.rb +1 -1
  56. data/try/integration/persistence_operations_try.rb +162 -10
  57. data/try/unit/data_types/boolean_try.rb +1 -1
  58. data/try/unit/data_types/string_try.rb +1 -1
  59. data/try/unit/horreum/auto_indexing_on_save_try.rb +32 -16
  60. data/try/unit/horreum/automatic_index_validation_try.rb +253 -0
  61. data/try/unit/horreum/base_try.rb +1 -1
  62. data/try/unit/horreum/class_methods_try.rb +2 -2
  63. data/try/unit/horreum/initialization_try.rb +1 -1
  64. data/try/unit/horreum/relations_try.rb +4 -4
  65. data/try/unit/horreum/serialization_try.rb +2 -2
  66. data/try/unit/horreum/unique_index_edge_cases_try.rb +376 -0
  67. data/try/unit/horreum/unique_index_guard_validation_try.rb +281 -0
  68. 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
@@ -41,7 +41,7 @@ Familia.debug = false
41
41
 
42
42
  ## Remove the key
43
43
  @hashkey.delete!
44
- #=> true
44
+ #=> 1
45
45
 
46
46
  ## Horreum objects can update and save their fields (1 of 2)
47
47
  @customer.name = 'John Doe'
@@ -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
- #=> [true, true]
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 false when not set yet
95
+ ## Clearing a counter returns 0 when not set yet
96
96
  @test_product.views.clear
97
97
  @test_product.views.clear
98
- #=> false
98
+ #=> 0
99
99
 
100
- ## Clearing a counter returns true when it is set
100
+ ## Clearing a counter returns 1 when it is set
101
101
  @test_product.views.increment
102
102
  @test_product.views.clear
103
- #=> true
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"