familia 2.0.0.pre4 → 2.0.0.pre5

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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +3 -0
  3. data/.rubocop_todo.yml +17 -17
  4. data/CLAUDE.md +3 -3
  5. data/Gemfile +5 -1
  6. data/Gemfile.lock +18 -3
  7. data/README.md +36 -157
  8. data/TEST_COVERAGE.md +40 -0
  9. data/docs/overview.md +359 -0
  10. data/docs/wiki/API-Reference.md +270 -0
  11. data/docs/wiki/Encrypted-Fields-Overview.md +64 -0
  12. data/docs/wiki/Home.md +49 -0
  13. data/docs/wiki/Implementation-Guide.md +183 -0
  14. data/docs/wiki/Security-Model.md +143 -0
  15. data/lib/familia/base.rb +18 -27
  16. data/lib/familia/connection.rb +6 -5
  17. data/lib/familia/{datatype → data_type}/commands.rb +2 -5
  18. data/lib/familia/{datatype → data_type}/serialization.rb +8 -10
  19. data/lib/familia/{datatype → data_type}/types/hashkey.rb +2 -2
  20. data/lib/familia/{datatype → data_type}/types/list.rb +17 -18
  21. data/lib/familia/{datatype → data_type}/types/sorted_set.rb +17 -17
  22. data/lib/familia/{datatype → data_type}/types/string.rb +2 -1
  23. data/lib/familia/{datatype → data_type}/types/unsorted_set.rb +17 -18
  24. data/lib/familia/{datatype.rb → data_type.rb} +10 -12
  25. data/lib/familia/encryption/manager.rb +102 -0
  26. data/lib/familia/encryption/provider.rb +49 -0
  27. data/lib/familia/encryption/providers/aes_gcm_provider.rb +103 -0
  28. data/lib/familia/encryption/providers/secure_xchacha20_poly1305_provider.rb +184 -0
  29. data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +118 -0
  30. data/lib/familia/encryption/registry.rb +50 -0
  31. data/lib/familia/encryption.rb +178 -0
  32. data/lib/familia/encryption_request_cache.rb +68 -0
  33. data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +153 -0
  34. data/lib/familia/features/encrypted_fields.rb +28 -0
  35. data/lib/familia/features/expiration.rb +107 -77
  36. data/lib/familia/features/quantization.rb +5 -9
  37. data/lib/familia/features/relatable_objects.rb +2 -4
  38. data/lib/familia/features/safe_dump.rb +14 -17
  39. data/lib/familia/features/transient_fields/redacted_string.rb +159 -0
  40. data/lib/familia/features/transient_fields/single_use_redacted_string.rb +62 -0
  41. data/lib/familia/features/transient_fields/transient_field_type.rb +139 -0
  42. data/lib/familia/features/transient_fields.rb +47 -0
  43. data/lib/familia/features.rb +40 -24
  44. data/lib/familia/field_type.rb +270 -0
  45. data/lib/familia/horreum/connection.rb +8 -11
  46. data/lib/familia/horreum/{commands.rb → database_commands.rb} +7 -19
  47. data/lib/familia/horreum/definition_methods.rb +453 -0
  48. data/lib/familia/horreum/{class_methods.rb → management_methods.rb} +19 -243
  49. data/lib/familia/horreum/serialization.rb +46 -18
  50. data/lib/familia/horreum/settings.rb +10 -2
  51. data/lib/familia/horreum/utils.rb +9 -10
  52. data/lib/familia/horreum.rb +18 -10
  53. data/lib/familia/logging.rb +14 -14
  54. data/lib/familia/settings.rb +39 -3
  55. data/lib/familia/utils.rb +45 -0
  56. data/lib/familia/version.rb +1 -1
  57. data/lib/familia.rb +2 -1
  58. data/try/core/base_enhancements_try.rb +115 -0
  59. data/try/core/connection_try.rb +0 -1
  60. data/try/core/errors_try.rb +0 -1
  61. data/try/core/familia_extended_try.rb +3 -4
  62. data/try/core/familia_try.rb +0 -1
  63. data/try/core/pools_try.rb +2 -2
  64. data/try/core/secure_identifier_try.rb +0 -1
  65. data/try/core/settings_try.rb +0 -1
  66. data/try/core/utils_try.rb +0 -1
  67. data/try/{datatypes → data_types}/boolean_try.rb +1 -2
  68. data/try/{datatypes → data_types}/datatype_base_try.rb +2 -3
  69. data/try/{datatypes → data_types}/hash_try.rb +1 -2
  70. data/try/{datatypes → data_types}/list_try.rb +1 -2
  71. data/try/{datatypes → data_types}/set_try.rb +1 -2
  72. data/try/{datatypes → data_types}/sorted_set_try.rb +1 -2
  73. data/try/{datatypes → data_types}/string_try.rb +1 -2
  74. data/try/debugging/README.md +32 -0
  75. data/try/debugging/cache_behavior_tracer.rb +91 -0
  76. data/try/debugging/encryption_method_tracer.rb +138 -0
  77. data/try/debugging/provider_diagnostics.rb +110 -0
  78. data/try/edge_cases/hash_symbolization_try.rb +0 -1
  79. data/try/edge_cases/json_serialization_try.rb +0 -1
  80. data/try/edge_cases/reserved_keywords_try.rb +42 -11
  81. data/try/encryption/config_persistence_try.rb +192 -0
  82. data/try/encryption/encryption_core_try.rb +328 -0
  83. data/try/encryption/instance_variable_scope_try.rb +31 -0
  84. data/try/encryption/module_loading_try.rb +28 -0
  85. data/try/encryption/providers/aes_gcm_provider_try.rb +178 -0
  86. data/try/encryption/providers/xchacha20_poly1305_provider_try.rb +169 -0
  87. data/try/encryption/roundtrip_validation_try.rb +28 -0
  88. data/try/encryption/secure_memory_handling_try.rb +125 -0
  89. data/try/features/encrypted_fields_core_try.rb +117 -0
  90. data/try/features/encrypted_fields_integration_try.rb +220 -0
  91. data/try/features/encrypted_fields_no_cache_security_try.rb +205 -0
  92. data/try/features/encrypted_fields_security_try.rb +370 -0
  93. data/try/features/encryption_fields/aad_protection_try.rb +53 -0
  94. data/try/features/encryption_fields/context_isolation_try.rb +120 -0
  95. data/try/features/encryption_fields/error_conditions_try.rb +116 -0
  96. data/try/features/encryption_fields/fresh_key_derivation_try.rb +122 -0
  97. data/try/features/encryption_fields/fresh_key_try.rb +163 -0
  98. data/try/features/encryption_fields/key_rotation_try.rb +117 -0
  99. data/try/features/encryption_fields/memory_security_try.rb +37 -0
  100. data/try/features/encryption_fields/missing_current_key_version_try.rb +23 -0
  101. data/try/features/encryption_fields/nonce_uniqueness_try.rb +54 -0
  102. data/try/features/encryption_fields/thread_safety_try.rb +199 -0
  103. data/try/features/expiration_try.rb +0 -1
  104. data/try/features/feature_dependencies_try.rb +159 -0
  105. data/try/features/quantization_try.rb +0 -1
  106. data/try/features/real_feature_integration_try.rb +148 -0
  107. data/try/features/relatable_objects_try.rb +0 -1
  108. data/try/features/safe_dump_advanced_try.rb +0 -1
  109. data/try/features/safe_dump_try.rb +0 -1
  110. data/try/features/transient_fields/redacted_string_try.rb +248 -0
  111. data/try/features/transient_fields/refresh_reset_try.rb +164 -0
  112. data/try/features/transient_fields/simple_refresh_test.rb +50 -0
  113. data/try/features/transient_fields/single_use_redacted_string_try.rb +310 -0
  114. data/try/features/transient_fields_core_try.rb +181 -0
  115. data/try/features/transient_fields_integration_try.rb +260 -0
  116. data/try/helpers/test_helpers.rb +42 -0
  117. data/try/horreum/base_try.rb +157 -3
  118. data/try/horreum/enhanced_conflict_handling_try.rb +176 -0
  119. data/try/horreum/field_categories_try.rb +118 -0
  120. data/try/horreum/field_definition_try.rb +96 -0
  121. data/try/horreum/initialization_try.rb +0 -1
  122. data/try/horreum/relations_try.rb +0 -1
  123. data/try/horreum/serialization_persistent_fields_try.rb +165 -0
  124. data/try/horreum/serialization_try.rb +2 -3
  125. data/try/memory/memory_basic_test.rb +73 -0
  126. data/try/memory/memory_detailed_test.rb +121 -0
  127. data/try/memory/memory_docker_ruby_dump.sh +80 -0
  128. data/try/memory/memory_search_for_string.rb +83 -0
  129. data/try/memory/test_actual_redactedstring_protection.rb +38 -0
  130. data/try/models/customer_safe_dump_try.rb +0 -1
  131. data/try/models/customer_try.rb +0 -1
  132. data/try/models/datatype_base_try.rb +1 -2
  133. data/try/models/familia_object_try.rb +0 -1
  134. metadata +85 -18
@@ -0,0 +1,260 @@
1
+ # try/features/transient_fields_integration_try.rb
2
+
3
+ require_relative '../helpers/test_helpers'
4
+
5
+ class SecretService < Familia::Horreum
6
+ feature :transient_fields
7
+ identifier_field :service_id
8
+
9
+ field :service_id
10
+ field :name
11
+ field :endpoint_url
12
+ transient_field :api_key
13
+ transient_field :password
14
+ transient_field :secret_token, as: :token
15
+ end
16
+
17
+ @service = SecretService.new
18
+ @service.service_id = 'test_service_1'
19
+ @service.name = 'Test API Service'
20
+ @service.endpoint_url = 'https://api.example.com'
21
+ @service.api_key = 'sk-1234567890abcdef'
22
+ @service.password = 'super_secret_password'
23
+ @service.token = 'token-xyz789'
24
+
25
+
26
+
27
+ ## Class includes transient_fields feature
28
+ SecretService.feature(:transient_fields)
29
+ defined?(SecretService.feature)
30
+ #=> "method"
31
+
32
+ ## Class has correct field definitions
33
+ SecretService.fields.sort
34
+ #=> [:api_key, :endpoint_url, :name, :password, :secret_token, :service_id]
35
+
36
+ ## Persistent fields exclude transient ones
37
+ SecretService.persistent_fields.sort
38
+ #=> [:endpoint_url, :name, :service_id]
39
+
40
+ ## Transient field definitions have correct category
41
+ SecretService.field_types[:api_key].category
42
+ #=> :transient
43
+
44
+ ## Password field definition has correct category
45
+ SecretService.field_types[:password].category
46
+ #=> :transient
47
+
48
+ ## Secret token field definition has correct category
49
+ SecretService.field_types[:secret_token].category
50
+ #=> :transient
51
+
52
+ ## Regular field definition has correct category
53
+ SecretService.field_types[:name].category
54
+ #=> :field
55
+
56
+ ## Transient field stores RedactedString object for api_key
57
+ @service.api_key.class
58
+ #=> RedactedString
59
+
60
+ ## Transient field stores RedactedString object for password
61
+ @service.password.class
62
+ #=> RedactedString
63
+
64
+ ## Transient field stores RedactedString object for token alias
65
+ @service.token.class
66
+ #=> RedactedString
67
+
68
+ ## Regular field stores normal string value for name
69
+ @service.name.class
70
+ #=> String
71
+
72
+ ## Regular field stores normal string value for endpoint_url
73
+ @service.endpoint_url.class
74
+ #=> String
75
+
76
+ ## Transient field value is redacted in string representation
77
+ @service.api_key.to_s
78
+ #=> "[REDACTED]"
79
+
80
+ ## Transient field value is redacted in inspect output
81
+ @service.password.inspect
82
+ #=> "[REDACTED]"
83
+
84
+ ## Transient field can expose value securely through block
85
+ result = nil
86
+ @service.api_key.expose { |val| result = val.dup }
87
+ result
88
+ #=> "sk-1234567890abcdef"
89
+
90
+ ## Transient field with custom method name exposes value correctly
91
+ result = nil
92
+ @service.token.expose { |val| result = val.dup }
93
+ result
94
+ #=> "token-xyz789"
95
+
96
+ ## Setting transient field with existing RedactedString works
97
+ already_redacted = RedactedString.new("already_wrapped")
98
+ @service.password = already_redacted
99
+ @service.password.class
100
+ #=> RedactedString
101
+
102
+ ## Serialization to_h only includes persistent fields
103
+ hash_result = @service.to_h
104
+ hash_result.keys.sort
105
+ #=> [:endpoint_url, :name, :service_id]
106
+
107
+ ## Serialization to_h excludes api_key transient field
108
+ hash_result = @service.to_h
109
+ hash_result.key?("api_key")
110
+ #=> false
111
+
112
+ ## Serialization to_h excludes password transient field
113
+ hash_result = @service.to_h
114
+ hash_result.key?("password")
115
+ #=> false
116
+
117
+ ## Serialization to_h excludes secret_token transient field
118
+ hash_result = @service.to_h
119
+ hash_result.key?("secret_token")
120
+ #=> false
121
+
122
+ ## Serialization to_a only includes persistent field values
123
+ array_result = @service.to_a
124
+ array_result.length
125
+ #=> 3
126
+
127
+ ## Array contains values in persistent field order
128
+ array_result = @service.to_a
129
+ persistent_fields_values = SecretService.persistent_fields.map { |f| @service.send(SecretService.field_types[f].method_name) }
130
+ array_result == persistent_fields_values
131
+ #=> true
132
+
133
+ ## Database persistence only stores persistent fields
134
+ @service.save
135
+ raw_data = @service.hgetall
136
+ raw_data.keys.sort
137
+ #=> ["endpoint_url", "name", "service_id"]
138
+
139
+ ## Raw Database data excludes api_key transient field
140
+ raw_data = @service.hgetall
141
+ raw_data.key?("api_key")
142
+ #=> false
143
+
144
+ ## Raw Database data excludes password transient field
145
+ raw_data = @service.hgetall
146
+ raw_data.key?("password")
147
+ #=> false
148
+
149
+ ## Raw Database data excludes secret_token transient field
150
+ raw_data = @service.hgetall
151
+ raw_data.key?("secret_token")
152
+ #=> false
153
+
154
+ ## Database refresh only loads persistent fields name
155
+ fresh_service = SecretService.new
156
+ fresh_service.service_id = 'test_service_1'
157
+ fresh_service.refresh!
158
+ fresh_service.name
159
+ #=> "Test API Service"
160
+
161
+ ## Database refresh only loads persistent fields endpoint_url
162
+ fresh_service = SecretService.new
163
+ fresh_service.service_id = 'test_service_1'
164
+ fresh_service.refresh!
165
+ fresh_service.endpoint_url
166
+ #=> "https://api.example.com"
167
+
168
+ ## Database refresh leaves transient api_key as nil
169
+ fresh_service = SecretService.new
170
+ fresh_service.service_id = 'test_service_1'
171
+ fresh_service.refresh!
172
+ fresh_service.api_key
173
+ #=> nil
174
+
175
+ ## Database refresh leaves transient password as nil
176
+ fresh_service = SecretService.new
177
+ fresh_service.service_id = 'test_service_1'
178
+ fresh_service.refresh!
179
+ fresh_service.password
180
+ #=> nil
181
+
182
+ ## Database refresh leaves transient token as nil
183
+ fresh_service = SecretService.new
184
+ fresh_service.service_id = 'test_service_1'
185
+ fresh_service.refresh!
186
+ fresh_service.token
187
+ #=> nil
188
+
189
+ ## String interpolation with transient field shows redacted value
190
+ log_message = "Connecting to #{@service.name} with key: #{@service.api_key}"
191
+ log_message.include?("[REDACTED]")
192
+ #=> true
193
+
194
+ ## String interpolation with transient field hides actual value
195
+ log_message = "Connecting to #{@service.name} with key: #{@service.api_key}"
196
+ log_message.include?("sk-1234567890abcdef")
197
+ #=> false
198
+
199
+ ## Hash containing transient field shows redacted in string output
200
+ config_hash = {
201
+ service: @service.name,
202
+ key: @service.api_key,
203
+ url: @service.endpoint_url
204
+ }
205
+ config_hash.to_s.include?("[REDACTED]")
206
+ #=> true
207
+
208
+ ## Hash containing transient field hides actual value in string output
209
+ config_hash = {
210
+ service: @service.name,
211
+ key: @service.api_key,
212
+ url: @service.endpoint_url
213
+ }
214
+ config_hash.to_s.include?("sk-1234567890abcdef")
215
+ #=> false
216
+
217
+ ## Exception messages with transient fields are safe
218
+ begin
219
+ raise StandardError, "Failed to authenticate with key: #{@service.api_key}"
220
+ rescue => e
221
+ e.message.include?("[REDACTED]")
222
+ end
223
+ #=> true
224
+
225
+ ## Multiple transient field assignment creates RedactedString instances
226
+ new_service = SecretService.new
227
+ new_service.service_id = 'test_service_2'
228
+ new_service.name = 'Another Service'
229
+ new_service.api_key = 'new-api-key-123'
230
+ new_service.password = 'new-password-456'
231
+ new_service.token = 'new-token-789'
232
+ [new_service.api_key, new_service.password, new_service.token].all? { |f| f.is_a?(RedactedString) }
233
+ #=> true
234
+
235
+ ## Transient field can be set to nil value
236
+ new_service = SecretService.new
237
+ new_service.api_key = nil
238
+ new_service.api_key
239
+ #=> nil
240
+
241
+ ## Persistent field definitions are correctly identified
242
+ SecretService.field_types.values.select(&:persistent?).map(&:name).sort
243
+ #=> [:endpoint_url, :name, :service_id]
244
+
245
+ ## Transient field definitions are correctly identified
246
+ transient_fields = SecretService.field_types.values.reject(&:persistent?).map(&:name).sort
247
+ transient_fields
248
+ #=> [:api_key, :password, :secret_token]
249
+
250
+
251
+ # TEARDOWN
252
+
253
+ # Clean up Database
254
+ @service.destroy! if @service.exists?
255
+
256
+ # Clean up any test objects
257
+ @service = nil
258
+
259
+ # Force garbage collection to trigger any finalizers
260
+ GC.start
@@ -5,6 +5,7 @@
5
5
  # e.g. FAMILIA_TRACE=1 FAMILIA_DEBUG=1 bundle exec try
6
6
 
7
7
  require 'digest'
8
+
8
9
  require_relative '../../lib/familia'
9
10
 
10
11
  Familia.enable_database_logging = true
@@ -162,3 +163,44 @@ class Limiter < Familia::Horreum
162
163
  @name
163
164
  end
164
165
  end
166
+
167
+ # # In test:
168
+ # using RedactedStringTestHelper
169
+
170
+ # secret = RedactedString.new("test-key")
171
+ # expect(secret.raw).to eq("test-key")
172
+ #
173
+ # Or with rack
174
+ #
175
+ # post '/vault' do
176
+ # passphrase = RedactedString.new(request.params['passphrase'])
177
+ # passphrase.expose do |plain|
178
+ # vault.unlock(plain)
179
+ # end
180
+ # # passphrase wiped
181
+ # end
182
+ #
183
+ # NOTE: This will do nothing unless RedactedString is already requried
184
+ unless defined?(RedactedString)
185
+ require_relative '../../lib/familia/features/transient_fields/redacted_string'
186
+ end
187
+ module RedactedStringTestHelper
188
+ refine RedactedString do
189
+ def raw
190
+ # Only available when refinement is used
191
+ @value
192
+ end
193
+ end
194
+ end
195
+
196
+ unless defined?(SingleUseRedactedString)
197
+ require_relative '../../lib/familia/features/transient_fields/single_use_redacted_string'
198
+ end
199
+ module SingleUseRedactedStringTestHelper
200
+ refine SingleUseRedactedString do
201
+ def raw
202
+ # Only available when refinement is used
203
+ @value
204
+ end
205
+ end
206
+ end
@@ -1,11 +1,10 @@
1
1
  # try/horreum/base_try.rb
2
2
 
3
- require_relative '../../lib/familia'
4
3
  require_relative '../helpers/test_helpers'
5
4
 
6
5
  Familia.debug = false
7
6
 
8
- @identifier = 'tryouts-27@onetimesecret.com'
7
+ @identifier = 'tryouts-27@onetimesecret.dev'
9
8
  @customer = Customer.new @identifier
10
9
  @hashkey = Familia::HashKey.new 'tryouts-27'
11
10
 
@@ -55,7 +54,7 @@ Familia.debug = false
55
54
  ## Horreum object fields have a fast attribute method (1 of 2)
56
55
  Familia.trace :LOAD, @customer.dbclient, @customer.uri, caller if Familia.debug?
57
56
  @customer.name! 'Jane Doe'
58
- #=> 0
57
+ #=> true
59
58
 
60
59
  ## Horreum object fields have a fast attribute method (2 of 2)
61
60
  @customer.refresh!
@@ -116,3 +115,158 @@ class ArrayIdentifierTest < Familia::Horreum
116
115
  field :name
117
116
  end
118
117
  #=!> Familia::Problem
118
+
119
+ ## Redefining a field method after it can give a warning
120
+ class FieldRedefine < Familia::Horreum
121
+ identifier_field :email
122
+ field :name
123
+ field :uniquefieldname, on_conflict: :warn
124
+
125
+ def uniquefieldname
126
+ true
127
+ end
128
+ end
129
+ #=2> /WARNING/
130
+ #=2> /uniquefieldname/
131
+
132
+ ## Defining a field with the same name as an existing method can give a warning
133
+ class ::FieldRedefine2 < Familia::Horreum
134
+ identifier_field :email
135
+ field :name
136
+
137
+ def uniquefieldname
138
+ true
139
+ end
140
+
141
+ field :uniquefieldname, on_conflict: :warn
142
+ end
143
+ #=2> /WARNING/
144
+ #=2> /uniquefieldname/
145
+
146
+ ## Redefining a field method after it can raise an error
147
+ class FieldRedefine3 < Familia::Horreum
148
+ identifier_field :email
149
+ field :name
150
+ field :uniquefieldname, on_conflict: :raise
151
+
152
+ def uniquefieldname
153
+ true
154
+ end
155
+ end
156
+ #=!> ArgumentError
157
+
158
+ ## Defining a field with the same name as an existing method can raise an error
159
+ class FieldRedefine4 < Familia::Horreum
160
+ identifier_field :email
161
+ field :name
162
+
163
+ def uniquefieldname
164
+ true
165
+ end
166
+
167
+ field :uniquefieldname, on_conflict: :raise
168
+ end
169
+ #=!> ArgumentError
170
+
171
+ ## Field aliasing works with 'as' parameter
172
+ class AliasedFieldTest < Familia::Horreum
173
+ identifier_field :email
174
+ field :email
175
+ field :display_size, as: :width
176
+ end
177
+ @aliased = AliasedFieldTest.new email: 'test@example.com'
178
+ @aliased.width = 42
179
+ @aliased.width
180
+ #=> 42
181
+
182
+ ## Aliased field getter method uses alias name
183
+ @aliased.respond_to?(:width)
184
+ #=> true
185
+
186
+ ## Aliased field setter method uses alias name
187
+ @aliased.respond_to?(:width=)
188
+ #=> true
189
+
190
+ ## Original field name is not accessible as method
191
+ @aliased.respond_to?(:display_size)
192
+ #=> false
193
+
194
+ ## Aliased field fast method works correctly
195
+ @aliased.save
196
+ @aliased.display_size! 100
197
+ #=> true
198
+
199
+ ## Aliased field refresh works correctly
200
+ @aliased.width = 50 # unsaved change
201
+ @aliased.refresh!
202
+ @aliased.width
203
+ #=> "100"
204
+
205
+ ## Fast method with custom name
206
+ class CustomFastMethodTest < Familia::Horreum
207
+ identifier_field :email
208
+ field :score, fast_method: :score_now!
209
+ field :email
210
+ end
211
+ @custom_fast = CustomFastMethodTest.new email: 'fast@example.com'
212
+ @custom_fast.respond_to?(:score_now!)
213
+ #=> true
214
+
215
+ ## Custom fast method works
216
+ @custom_fast.save
217
+ @custom_fast.score_now! 75
218
+ #=> true
219
+
220
+ ## Field with :warn conflict handling allows redefinition with warning
221
+ class WarnConflictTest < Familia::Horreum
222
+ identifier_field :email
223
+ field :email
224
+ field :test_method, on_conflict: :warn
225
+
226
+ def test_method
227
+ "original"
228
+ end
229
+
230
+ end
231
+ @warn_test = WarnConflictTest.new email: 'warn@example.com'
232
+ @warn_test.test_method
233
+ #=> "original"
234
+
235
+ ## Field with :skip conflict handling skips redefinition silently
236
+ class SkipConflictTest < Familia::Horreum
237
+ identifier_field :email
238
+ field :email
239
+
240
+ def skip_method
241
+ "original"
242
+ end
243
+
244
+ field :skip_method, on_conflict: :skip
245
+ end
246
+ @skip_test = SkipConflictTest.new email: 'skip@example.com'
247
+ @skip_test.skip_method
248
+ #=> "original"
249
+
250
+ ## Combined aliasing and custom fast method
251
+ class CombinedTest < Familia::Horreum
252
+ identifier_field :email
253
+ field :internal_count, as: :count, fast_method: :count_immediately!
254
+ field :email
255
+ end
256
+ @combined = CombinedTest.new email: 'combined1@example.com'
257
+ @combined.count = 10
258
+ @combined.count
259
+ #=> 10
260
+
261
+ ## Combined test fast method works
262
+ @combined.save
263
+ @combined.count_immediately! 20
264
+ @combined.count
265
+ #=> 20
266
+
267
+ ## Combined test refresh works
268
+ combined = CombinedTest.new email: 'combined2@example.com'
269
+ combined.count = 5 # unsaved change
270
+ combined.refresh!
271
+ combined.count
272
+ #=> 5
@@ -0,0 +1,176 @@
1
+ # try/horreum/enhanced_conflict_handling_try.rb
2
+
3
+ require_relative '../helpers/test_helpers'
4
+
5
+ Familia.debug = false
6
+
7
+ ## Valid strategies are defined correctly
8
+ Familia::VALID_STRATEGIES.include?(:raise)
9
+ #=> true
10
+
11
+ ## Valid strategies include all expected options
12
+ Familia::VALID_STRATEGIES
13
+ #=> [:raise, :skip, :warn, :overwrite]
14
+
15
+ ## Overwrite strategy removes existing method and defines new one
16
+ class OverwriteStrategyTest < Familia::Horreum
17
+ identifier_field :id
18
+ field :id
19
+
20
+ def conflicting_method
21
+ "original_method"
22
+ end
23
+
24
+ field :conflicting_method, on_conflict: :overwrite
25
+ end
26
+ @overwrite_test = OverwriteStrategyTest.new(id: 'overwrite1')
27
+ @overwrite_test.conflicting_method = "new_value"
28
+ @overwrite_test.conflicting_method
29
+ #=> "new_value"
30
+
31
+ ## Overwrite strategy works with fast methods too
32
+ @overwrite_test.save
33
+ @overwrite_test.conflicting_method! "fast_value"
34
+ #=> true
35
+
36
+ ## Invalid conflict strategy raises error during field definition
37
+ class InvalidStrategyTest < Familia::Horreum
38
+ identifier_field :id
39
+ field :id
40
+ field :test_field, on_conflict: :invalid_strategy
41
+ end
42
+ #=!> ArgumentError
43
+
44
+ ## Method conflict detection works with instance methods
45
+ class ConflictDetectionTest < Familia::Horreum
46
+ identifier_field :id
47
+ field :id
48
+
49
+ def existing_method
50
+ "exists"
51
+ end
52
+
53
+ field :existing_method, on_conflict: :raise
54
+ end
55
+ #=!> ArgumentError
56
+
57
+ ## Conflict detection provides helpful error message
58
+ begin
59
+ class ConflictMessageTest < Familia::Horreum
60
+ identifier_field :id
61
+ field :id
62
+
63
+ def another_method
64
+ "exists"
65
+ end
66
+
67
+ field :another_method, on_conflict: :raise
68
+ end
69
+ rescue ArgumentError => e
70
+ e.message.include?("another_method")
71
+ end
72
+ #=> true
73
+
74
+ ## Method location information in error message when possible
75
+
76
+ class LocationInfoTest < Familia::Horreum
77
+ identifier_field :id
78
+ field :id
79
+
80
+ def location_test_method
81
+ "exists"
82
+ end
83
+
84
+ field :location_test_method, on_conflict: :raise
85
+ end
86
+ #=!> ArgumentError
87
+ #==> error.message.include?("already defined")
88
+
89
+ ## Skip strategy silently ignores conflicts
90
+ class SkipStrategyTest < Familia::Horreum
91
+ identifier_field :id
92
+ field :id
93
+
94
+ def skip_method
95
+ "original"
96
+ end
97
+
98
+ field :skip_method, on_conflict: :skip
99
+ end
100
+ @skip_test = SkipStrategyTest.new(id: 'skip1')
101
+ @skip_test.skip_method
102
+ #=> "original"
103
+
104
+ ## Skip strategy doesn't create accessor methods when method exists
105
+ @skip_test.respond_to?(:skip_method=)
106
+ #=> false
107
+
108
+ ## Warn strategy shows warning but continues with definition
109
+ class WarnStrategyTest < Familia::Horreum
110
+ identifier_field :id
111
+ field :id
112
+
113
+ def warn_method
114
+ "original"
115
+ end
116
+
117
+ field :warn_method, on_conflict: :warn
118
+ end
119
+ #=2> /WARNING/
120
+ @warn_test = WarnStrategyTest.new(id: 'warn1')
121
+ @warn_test.warn_method = "new_value"
122
+ @warn_test.warn_method
123
+ #=> "new_value"
124
+
125
+ ## Fast method names must end with exclamation mark
126
+ class InvalidFastMethodTest < Familia::Horreum
127
+ identifier_field :id
128
+ field :id
129
+ field :test_field, fast_method: :invalid_name
130
+ end
131
+ #=!> ArgumentError
132
+
133
+ ## Fast method validation works with custom names
134
+ class ValidFastMethodTest < Familia::Horreum
135
+ identifier_field :id
136
+ field :id
137
+ field :score, fast_method: :update_score_now!
138
+ end
139
+ @valid_fast = ValidFastMethodTest.new(id: 'valid1')
140
+ @valid_fast.respond_to?(:update_score_now!)
141
+ #=> true
142
+
143
+ ## Method added hook detects conflicts after field definition
144
+ class MethodAddedHookTest < Familia::Horreum
145
+ identifier_field :id
146
+ field :id
147
+ field :hook_test, on_conflict: :warn
148
+
149
+ def hook_test
150
+ "redefined_after"
151
+ end
152
+ end
153
+ #=2> /WARNING/
154
+ #=2> /hook_test/
155
+ #=2> /redefined after field definition/
156
+
157
+ ## Method added hook works with raise strategy too
158
+ class MethodAddedRaiseTest < Familia::Horreum
159
+ identifier_field :id
160
+ field :id
161
+ field :raise_hook_test, on_conflict: :raise
162
+
163
+ def raise_hook_test
164
+ "redefined"
165
+ end
166
+ end
167
+ #=!> ArgumentError
168
+
169
+ @overwrite_test.destroy! rescue nil
170
+ @skip_test.destroy! rescue nil
171
+ @warn_test.destroy! rescue nil
172
+ @valid_fast.destroy! rescue nil
173
+ @overwrite_test = nil
174
+ @skip_test = nil
175
+ @warn_test = nil
176
+ @valid_fast = nil