familia 2.0.0.pre5 → 2.0.0.pre7

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 (151) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/claude-code-review.yml +57 -0
  3. data/.github/workflows/claude.yml +71 -0
  4. data/.gitignore +5 -1
  5. data/.rubocop.yml +3 -0
  6. data/CLAUDE.md +32 -10
  7. data/Gemfile +2 -2
  8. data/Gemfile.lock +4 -3
  9. data/docs/wiki/API-Reference.md +95 -18
  10. data/docs/wiki/Connection-Pooling-Guide.md +437 -0
  11. data/docs/wiki/Encrypted-Fields-Overview.md +40 -3
  12. data/docs/wiki/Expiration-Feature-Guide.md +596 -0
  13. data/docs/wiki/Feature-System-Guide.md +631 -0
  14. data/docs/wiki/Features-System-Developer-Guide.md +892 -0
  15. data/docs/wiki/Field-System-Guide.md +784 -0
  16. data/docs/wiki/Home.md +82 -15
  17. data/docs/wiki/Implementation-Guide.md +126 -33
  18. data/docs/wiki/Quantization-Feature-Guide.md +721 -0
  19. data/docs/wiki/Relationships-Guide.md +684 -0
  20. data/docs/wiki/Security-Model.md +65 -25
  21. data/docs/wiki/Transient-Fields-Guide.md +280 -0
  22. data/examples/bit_encoding_integration.rb +237 -0
  23. data/examples/redis_command_validation_example.rb +231 -0
  24. data/examples/relationships_basic.rb +273 -0
  25. data/lib/familia/base.rb +1 -1
  26. data/lib/familia/connection.rb +3 -3
  27. data/lib/familia/data_type/types/counter.rb +38 -0
  28. data/lib/familia/data_type/types/hashkey.rb +18 -0
  29. data/lib/familia/data_type/types/lock.rb +43 -0
  30. data/lib/familia/data_type/types/string.rb +9 -2
  31. data/lib/familia/data_type.rb +9 -6
  32. data/lib/familia/encryption/encrypted_data.rb +137 -0
  33. data/lib/familia/encryption/manager.rb +21 -4
  34. data/lib/familia/encryption/providers/aes_gcm_provider.rb +20 -0
  35. data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +20 -0
  36. data/lib/familia/encryption.rb +1 -1
  37. data/lib/familia/errors.rb +17 -3
  38. data/lib/familia/features/encrypted_fields/concealed_string.rb +293 -0
  39. data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +94 -26
  40. data/lib/familia/features/encrypted_fields.rb +413 -4
  41. data/lib/familia/features/expiration.rb +319 -33
  42. data/lib/familia/features/quantization.rb +385 -44
  43. data/lib/familia/features/relationships/cascading.rb +438 -0
  44. data/lib/familia/features/relationships/indexing.rb +370 -0
  45. data/lib/familia/features/relationships/membership.rb +503 -0
  46. data/lib/familia/features/relationships/permission_management.rb +264 -0
  47. data/lib/familia/features/relationships/querying.rb +620 -0
  48. data/lib/familia/features/relationships/redis_operations.rb +274 -0
  49. data/lib/familia/features/relationships/score_encoding.rb +442 -0
  50. data/lib/familia/features/relationships/tracking.rb +379 -0
  51. data/lib/familia/features/relationships.rb +466 -0
  52. data/lib/familia/features/safe_dump.rb +1 -1
  53. data/lib/familia/features/transient_fields/redacted_string.rb +1 -1
  54. data/lib/familia/features/transient_fields.rb +192 -10
  55. data/lib/familia/features.rb +2 -1
  56. data/lib/familia/field_type.rb +5 -2
  57. data/lib/familia/horreum/{connection.rb → core/connection.rb} +2 -8
  58. data/lib/familia/horreum/{database_commands.rb → core/database_commands.rb} +14 -3
  59. data/lib/familia/horreum/core/serialization.rb +535 -0
  60. data/lib/familia/horreum/{utils.rb → core/utils.rb} +0 -2
  61. data/lib/familia/horreum/core.rb +21 -0
  62. data/lib/familia/horreum/{settings.rb → shared/settings.rb} +0 -2
  63. data/lib/familia/horreum/{definition_methods.rb → subclass/definition.rb} +45 -29
  64. data/lib/familia/horreum/{management_methods.rb → subclass/management.rb} +9 -8
  65. data/lib/familia/horreum/{related_fields_management.rb → subclass/related_fields_management.rb} +15 -10
  66. data/lib/familia/horreum.rb +17 -17
  67. data/lib/familia/validation/command_recorder.rb +336 -0
  68. data/lib/familia/validation/expectations.rb +519 -0
  69. data/lib/familia/validation/test_helpers.rb +443 -0
  70. data/lib/familia/validation/validator.rb +412 -0
  71. data/lib/familia/validation.rb +140 -0
  72. data/lib/familia/version.rb +1 -1
  73. data/lib/familia.rb +1 -1
  74. data/try/core/create_method_try.rb +240 -0
  75. data/try/core/database_consistency_try.rb +299 -0
  76. data/try/core/errors_try.rb +25 -4
  77. data/try/core/familia_try.rb +1 -1
  78. data/try/core/persistence_operations_try.rb +297 -0
  79. data/try/data_types/counter_try.rb +93 -0
  80. data/try/data_types/lock_try.rb +133 -0
  81. data/try/debugging/debug_aad_process.rb +82 -0
  82. data/try/debugging/debug_concealed_internal.rb +59 -0
  83. data/try/debugging/debug_concealed_reveal.rb +61 -0
  84. data/try/debugging/debug_context_aad.rb +68 -0
  85. data/try/debugging/debug_context_simple.rb +80 -0
  86. data/try/debugging/debug_cross_context.rb +62 -0
  87. data/try/debugging/debug_database_load.rb +64 -0
  88. data/try/debugging/debug_encrypted_json_check.rb +53 -0
  89. data/try/debugging/debug_encrypted_json_step_by_step.rb +62 -0
  90. data/try/debugging/debug_exists_lifecycle.rb +54 -0
  91. data/try/debugging/debug_field_decrypt.rb +74 -0
  92. data/try/debugging/debug_fresh_cross_context.rb +73 -0
  93. data/try/debugging/debug_load_path.rb +66 -0
  94. data/try/debugging/debug_method_definition.rb +46 -0
  95. data/try/debugging/debug_method_resolution.rb +41 -0
  96. data/try/debugging/debug_minimal.rb +24 -0
  97. data/try/debugging/debug_provider.rb +68 -0
  98. data/try/debugging/debug_secure_behavior.rb +73 -0
  99. data/try/debugging/debug_string_class.rb +46 -0
  100. data/try/debugging/debug_test.rb +46 -0
  101. data/try/debugging/debug_test_design.rb +80 -0
  102. data/try/edge_cases/hash_symbolization_try.rb +1 -0
  103. data/try/edge_cases/reserved_keywords_try.rb +1 -0
  104. data/try/edge_cases/string_coercion_try.rb +2 -0
  105. data/try/encryption/encryption_core_try.rb +6 -4
  106. data/try/features/categorical_permissions_try.rb +515 -0
  107. data/try/features/encrypted_fields_core_try.rb +19 -11
  108. data/try/features/encrypted_fields_integration_try.rb +66 -70
  109. data/try/features/encrypted_fields_no_cache_security_try.rb +22 -8
  110. data/try/features/encrypted_fields_security_try.rb +151 -144
  111. data/try/features/encryption_fields/aad_protection_try.rb +108 -23
  112. data/try/features/encryption_fields/concealed_string_core_try.rb +253 -0
  113. data/try/features/encryption_fields/context_isolation_try.rb +30 -8
  114. data/try/features/encryption_fields/error_conditions_try.rb +6 -6
  115. data/try/features/encryption_fields/fresh_key_derivation_try.rb +20 -14
  116. data/try/features/encryption_fields/fresh_key_try.rb +27 -22
  117. data/try/features/encryption_fields/key_rotation_try.rb +16 -10
  118. data/try/features/encryption_fields/nonce_uniqueness_try.rb +15 -13
  119. data/try/features/encryption_fields/secure_by_default_behavior_try.rb +310 -0
  120. data/try/features/encryption_fields/thread_safety_try.rb +6 -6
  121. data/try/features/encryption_fields/universal_serialization_safety_try.rb +174 -0
  122. data/try/features/feature_dependencies_try.rb +3 -3
  123. data/try/features/relationships_edge_cases_try.rb +145 -0
  124. data/try/features/relationships_performance_minimal_try.rb +132 -0
  125. data/try/features/relationships_performance_simple_try.rb +155 -0
  126. data/try/features/relationships_performance_try.rb +420 -0
  127. data/try/features/relationships_performance_working_try.rb +144 -0
  128. data/try/features/relationships_try.rb +237 -0
  129. data/try/features/safe_dump_try.rb +3 -0
  130. data/try/features/transient_fields/redacted_string_try.rb +2 -0
  131. data/try/features/transient_fields/single_use_redacted_string_try.rb +2 -0
  132. data/try/features/transient_fields_core_try.rb +1 -1
  133. data/try/features/transient_fields_integration_try.rb +1 -1
  134. data/try/helpers/test_helpers.rb +26 -1
  135. data/try/horreum/base_try.rb +14 -8
  136. data/try/horreum/enhanced_conflict_handling_try.rb +3 -1
  137. data/try/horreum/initialization_try.rb +1 -1
  138. data/try/horreum/relations_try.rb +2 -2
  139. data/try/horreum/serialization_persistent_fields_try.rb +8 -8
  140. data/try/horreum/serialization_try.rb +39 -4
  141. data/try/models/customer_safe_dump_try.rb +1 -1
  142. data/try/models/customer_try.rb +1 -1
  143. data/try/validation/atomic_operations_try.rb.disabled +320 -0
  144. data/try/validation/command_validation_try.rb.disabled +207 -0
  145. data/try/validation/performance_validation_try.rb.disabled +324 -0
  146. data/try/validation/real_world_scenarios_try.rb.disabled +390 -0
  147. metadata +81 -12
  148. data/TEST_COVERAGE.md +0 -40
  149. data/lib/familia/features/relatable_objects.rb +0 -125
  150. data/lib/familia/horreum/serialization.rb +0 -473
  151. data/try/features/relatable_objects_try.rb +0 -220
@@ -1,473 +0,0 @@
1
- # lib/familia/horreum/serialization.rb
2
- #
3
- module Familia
4
- # Familia::Horreum
5
- #
6
- class Horreum
7
- # The Sacred Scrolls of Database Responses
8
- #
9
- # Behold! The mystical runes that Database whispers back to us:
10
- #
11
- # "OK" - The sweet sound of success, like a tiny "ding!" from the depths of data.
12
- # true - The boolean Buddha nods in agreement.
13
- # 1 - A lonely digit, standing tall and proud. "I did something!" it proclaims.
14
- # 0 - The silent hero. It tried its best, bless its heart.
15
- # nil - The zen master of responses. It's not nothing, it's... enlightenment!
16
- #
17
- # These sacred signs are our guide through the Database wilderness. When we cast
18
- # our spells (er, commands), we seek these friendly faces in the returned
19
- # smoke signals.
20
- #
21
- # Should our Database rituals summon anything else, we pause. We ponder. We
22
- # possibly panic. For the unexpected in Redis-land is like finding a penguin
23
- # in your pasta - delightfully confusing, but probably not what you ordered.
24
- #
25
- # May your Database returns be ever valid, and your data ever flowing!
26
- #
27
- @valid_command_return_values = ['OK', true, 1, 0, nil].freeze
28
-
29
- class << self
30
- attr_reader :valid_command_return_values
31
- end
32
-
33
- # Serialization: Where Objects Go to Become Strings (and Vice Versa)!
34
- #
35
- # This module is chock-full of methods that'll make your head spin (in a
36
- # good way)! We've got loaders, dumpers, and refreshers galore. It's like
37
- # a laundromat for your data, but instead of quarters, it runs on Database commands.
38
- #
39
- # A Note on Our Refreshing Refreshers:
40
- # In the wild world of Ruby, '!' usually means "Watch out! I'm dangerous!"
41
- # But here in Familia-land, we march to the beat of a different drummer.
42
- # Our refresh! method is the real deal, doing all the heavy lifting.
43
- # The non-bang refresh? Oh, it's just as rowdy, but it plays nice with
44
- # method chaining. It's like the polite twin who still knows how to party.
45
- #
46
- # Remember: In Familia, refreshing isn't just a chore, it's a chance to
47
- # dance with data! Whether you bang(!) or not, you're still invited to
48
- # the Database disco.
49
- #
50
- # (P.S. If you're reading these docs, lol sorry. I asked Claude 3.5 to
51
- # write in the style of _why the lucky stiff today and got this uncanny
52
- # valley response. I hope you enjoy reading it as much as I did writing
53
- # the prompt for it. - @delano).
54
- #
55
- # (Ahem! What I meant to say was that if you're reading this, congratulations!
56
- # You've stumbled upon the secret garden of documentation. Feel free to smell
57
- # the Ruby roses, but watch out for the Database thorns!)
58
- #
59
- module Serialization
60
- # Save our precious data to Redis, with a sprinkle of timestamp magic!
61
- #
62
- # This method is like a conscientious historian, not only recording your
63
- # object's current state but also meticulously timestamping when it was
64
- # created and last updated. It's the record keeper of your data's life story!
65
- #
66
- # @return [Boolean] true if the save was successful, false if Database was grumpy.
67
- #
68
- # @example Preserving your pet rock for posterity
69
- # rocky = PetRock.new(name: "Dwayne")
70
- # rocky.save
71
- # # => true (Dwayne is now immortalized in Redis)
72
- #
73
- # @note This method will leave breadcrumbs (traces) if you're in debug mode.
74
- # It's like Hansel and Gretel, but for data operations!
75
- #
76
- def save(update_expiration: true)
77
- Familia.trace :SAVE, dbclient, uri, caller(1..1) if Familia.debug?
78
-
79
- # No longer need to sync computed identifier with a cache field
80
- self.created ||= Familia.now.to_i if respond_to?(:created)
81
- self.updated = Familia.now.to_i if respond_to?(:updated)
82
-
83
- # Commit our tale to the Database chronicles
84
- #
85
- ret = commit_fields(update_expiration: update_expiration)
86
-
87
- Familia.ld "[save] #{self.class} #{dbkey} #{ret} (update_expiration: #{update_expiration})"
88
-
89
- # Did Database accept our offering?
90
- !ret.nil?
91
- end
92
-
93
- # Updates multiple fields atomically in a Database transaction.
94
- #
95
- # @param fields [Hash] Field names and values to update. Special key :update_expiration
96
- # controls whether to update key expiration (default: true)
97
- # @return [MultiResult] Transaction result
98
- #
99
- # @example Update multiple fields without affecting expiration
100
- # metadata.batch_update(viewed: 1, updated: Time.now.to_i, update_expiration: false)
101
- #
102
- # @example Update fields with expiration refresh
103
- # user.batch_update(name: "John", email: "john@example.com")
104
- #
105
- def batch_update(**kwargs)
106
- update_expiration = kwargs.delete(:update_expiration) { true }
107
- fields = kwargs
108
-
109
- Familia.trace :BATCH_UPDATE, dbclient, fields.keys, caller(1..1) if Familia.debug?
110
-
111
- command_return_values = transaction do |conn|
112
- fields.each do |field, value|
113
- prepared_value = serialize_value(value)
114
- conn.hset dbkey, field, prepared_value
115
- # Update instance variable to keep object in sync
116
- send("#{field}=", value) if respond_to?("#{field}=")
117
- end
118
- end
119
-
120
- # Update expiration if requested and supported
121
- self.update_expiration(default_expiration: nil) if update_expiration && respond_to?(:update_expiration)
122
-
123
- # Return same MultiResult format as other methods
124
- summary_boolean = command_return_values.all? { |ret| %w[OK 0 1].include?(ret.to_s) }
125
- MultiResult.new(summary_boolean, command_return_values)
126
- end
127
-
128
- # Apply a smattering of fields to this object like fairy dust.
129
- #
130
- # @param fields [Hash] A magical bag of named attributes to sprinkle onto
131
- # this instance. Each key-value pair is like a tiny spell, ready to
132
- # enchant our object's properties.
133
- #
134
- # @return [self] Returns the newly bejeweled instance, now sparkling with
135
- # fresh attributes.
136
- #
137
- # @example Giving your object a makeover
138
- # dragon.apply_fields(name: "Puff", breathes: "fire", loves: "Toys
139
- # named Jackie")
140
- # # => #<Dragon:0x007f8a1c8b0a28 @name="Puff", @breathes="fire",
141
- # @loves="Toys named Jackie">
142
- #
143
- def apply_fields(**fields)
144
- fields.each do |field, value|
145
- # Whisper the new value into the object's ear (if it's listening)
146
- send("#{field}=", value) if respond_to?("#{field}=")
147
- end
148
- self
149
- end
150
-
151
- # Commit our precious fields to Redis.
152
- #
153
- # This method performs a sacred ritual, sending our cherished attributes
154
- # on a journey through the ethernet to find their resting place in Redis.
155
- # It executes a transaction that includes setting field values and,
156
- # if applicable, updating the expiration time.
157
- #
158
- # @param update_expiration [Boolean] Whether to update the expiration time
159
- # of the dbkey. This is true by default, but can be disabled if you
160
- # don't want to mess with the cosmic balance of your key's lifespan.
161
- #
162
- # @return [MultiResult] A mystical object containing:
163
- # - success: A boolean indicating if all Database commands succeeded
164
- # - results: An array of strings, cryptic messages from the Database gods
165
- #
166
- # The MultiResult object responds to:
167
- # - successful?: Returns the boolean success value
168
- # - results: Returns the array of command return values
169
- #
170
- # @note Be warned, young programmer! This method dabbles in the arcane
171
- # art of transactions. Side effects may include data persistence and a
172
- # slight tingling sensation. The method does not raise exceptions for
173
- # unexpected Database responses, but logs warnings and returns a failure status.
174
- #
175
- # @example Offering your changes to the Database deities
176
- # unicorn.name = "Charlie"
177
- # unicorn.horn_length = "magnificent"
178
- # result = unicorn.commit_fields
179
- # if result.successful?
180
- # puts "The Database gods are pleased with your offering"
181
- # p result.results # => ["OK", "OK"]
182
- # else
183
- # puts "The Database gods frown upon your offering"
184
- # p result.results # Examine the unexpected values
185
- # end
186
- #
187
- # @see Familia::Horreum.valid_command_return_values for the list of
188
- # acceptable Database command return values.
189
- #
190
- # @note This method performs logging at various levels:
191
- # - Debug: Logs the object's class, dbkey, and current state before committing
192
- # - Warn: Logs any unexpected return values from Database commands
193
- # - Debug: Logs the final result, including success status and all return values
194
- #
195
- # @note The expiration update is only performed for classes that have
196
- # the expiration feature enabled. For others, it's a no-op.
197
- #
198
- def commit_fields(update_expiration: true)
199
- prepared_value = to_h
200
- Familia.ld "[commit_fields] Begin #{self.class} #{dbkey} #{prepared_value} (exp: #{update_expiration})"
201
-
202
- result = hmset(prepared_value)
203
-
204
- # Only classes that have the expiration ferature enabled will
205
- # actually set an expiration time on their keys. Otherwise
206
- # this will be a no-op that simply logs the attempt.
207
- self.update_expiration(default_expiration: nil) if update_expiration
208
-
209
- result
210
- end
211
-
212
- # Dramatically vanquish this object from the face of Redis! (ed: delete it)
213
- #
214
- # This method is the doomsday device of our little data world. It will
215
- # mercilessly eradicate all traces of our object from Redis, leaving naught
216
- # but digital dust in its wake. Use with caution, lest you accidentally
217
- # destroy the wrong data-verse!
218
- #
219
- # @return [void] Returns nothing, for nothing remains after destruction.
220
- #
221
- # @example Bidding a fond farewell to your pet rock
222
- # rocky = PetRock.new(name: "Dwayne")
223
- # rocky.destroy!
224
- # # => *poof* Rocky is no more. A moment of silence, please.
225
- #
226
- # This method is part of Familia's high-level object lifecycle management. While `delete!`
227
- # operates directly on dbkeys, `destroy!` operates at the object level and is used for
228
- # ORM-style operations. Use `destroy!` when removing complete objects from the system, and
229
- # `delete!` when working directly with dbkeys.
230
- #
231
- # @note If debugging is enabled, this method will leave a trace of its
232
- # destructive path, like breadcrumbs for future data archaeologists.
233
- #
234
- # @see #delete! The actual hitman carrying out the deed.
235
- #
236
- def destroy!
237
- Familia.trace :DESTROY, dbclient, uri, caller(1..1) if Familia.debug?
238
- delete!
239
- end
240
-
241
- # The Great Nilpocalypse: clear_fields!
242
- #
243
- # Imagine your object as a grand old mansion, every room stuffed with
244
- # trinkets, secrets, and the odd rubber duck. This method? It flings open
245
- # every window and lets a wild wind of nothingness sweep through, leaving
246
- # each field as empty as a poet’s wallet.
247
- #
248
- # All your precious attributes—gone! Swept into the void! It’s a spring
249
- # cleaning for the soul, a reset button for your existential dread.
250
- #
251
- # @return [void] Nothing left but echoes and nils.
252
- #
253
- # @example The Vanishing Act
254
- # wizard.clear_fields!
255
- # # => All fields are now nil, like a spell gone slightly too well.
256
- #
257
- def clear_fields!
258
- self.class.field_method_map.each_value { |method_name| send("#{method_name}=", nil) }
259
- end
260
-
261
- # The Great Database Refresh-o-matic 3000
262
- #
263
- # Imagine your object as a forgetful time traveler. This method is like
264
- # zapping it with a memory ray from Redis-topia. ZAP! New memories!
265
- #
266
- # WARNING: This is not a gentle mind-meld. It's more like a full brain
267
- # transplant. Any half-baked ideas floating in your object's head? POOF!
268
- # Gone quicker than cake at a hobbit's birthday party. Unsaved spells
269
- # will definitely be forgotten.
270
- #
271
- # @return [void] What do you get for this daring act of digital amnesia? A shiny
272
- # list of all the brain bits that got a makeover!
273
- #
274
- # Remember: In the game of Redis-Refresh, you win or you... well, you
275
- # always win, but sometimes you forget why you played in the first place.
276
- #
277
- # @raise [Familia::KeyNotFoundError] If the dbkey does not exist.
278
- #
279
- # @example
280
- # object.refresh!
281
- def refresh!
282
- Familia.trace :REFRESH, dbclient, uri, caller(1..1) if Familia.debug?
283
- raise Familia::KeyNotFoundError, dbkey unless dbclient.exists(dbkey)
284
-
285
- fields = hgetall
286
- Familia.ld "[refresh!] #{self.class} #{dbkey} fields:#{fields.keys}"
287
-
288
- # Reset transient fields to nil for semantic clarity and ORM consistency
289
- # Transient fields have no authoritative source, so they should return to
290
- # their uninitialized state during refresh operations
291
- reset_transient_fields!
292
-
293
- optimistic_refresh(**fields)
294
- end
295
-
296
- # Ah, the magical refresh dance! It's like giving your object a
297
- # sip from the fountain of youth.
298
- #
299
- # This method twirls your object around, dips it into the Database pool,
300
- # and brings it back sparkling clean and up-to-date. It's using the
301
- # refresh! spell behind the scenes, so expect some Database whispering.
302
- #
303
- # @note Caution, young Rubyist! While this method loves to play
304
- # chain-tag with other methods, it's still got that refresh! kick.
305
- # It'll update your object faster than you can say "matz!"
306
- #
307
- # @return [self] Your object, freshly bathed in Database waters, ready
308
- # to dance with more methods in a conga line of Ruby joy!
309
- #
310
- # @raise [Familia::KeyNotFoundError] If the dbkey does not exist.
311
- #
312
- def refresh
313
- refresh!
314
- self
315
- end
316
-
317
- # Transform this object into a magical hash of wonders!
318
- #
319
- # This method performs an alchemical transmutation, turning our noble object
320
- # into a more plebeian hash. But fear not, for in this form, it can slip through
321
- # the cracks of the universe (or at least, into Redis) with ease.
322
- #
323
- # @return [Hash] A glittering hash, each key a field name, each value a
324
- # Redis-ready treasure.
325
- #
326
- # @example Turning your dragon into a hash
327
- # dragon.to_h
328
- # # => {"name"=>"Puff", "breathes"=>"fire", "age"=>1000}
329
- #
330
- # @note Watch in awe as each field is lovingly prepared for its Database adventure!
331
- #
332
- def to_h
333
- self.class.persistent_fields.each_with_object({}) do |field, hsh|
334
- field_type = self.class.field_types[field]
335
- method_name = field_type.method_name
336
- val = send(method_name)
337
- prepared = serialize_value(val)
338
- Familia.ld " [to_h] field: #{field} val: #{val.class} prepared: #{prepared&.class || '[nil]'}"
339
-
340
- # Only include non-nil values in the hash for Redis
341
- hsh[field] = prepared unless prepared.nil?
342
- end
343
- end
344
-
345
- # Line up all our attributes in a neat little array parade!
346
- #
347
- # This method marshals all our object's attributes into an orderly procession,
348
- # ready to march into Database in perfect formation. It's like a little data army,
349
- # but friendlier and less prone to conquering neighboring databases.
350
- #
351
- # @return [Array] A splendid array of Redis-ready values, in the order of our fields.
352
- #
353
- # @example Arranging your unicorn's attributes in a line
354
- # unicorn.to_a
355
- # # => ["Charlie", "magnificent", 5]
356
- #
357
- # @note Each value is carefully disguised in its Database costume
358
- # before joining the parade.
359
- #
360
- def to_a
361
- self.class.persistent_fields.collect do |field|
362
- field_type = self.class.field_types[field]
363
- method_name = field_type.method_name
364
- val = send(method_name)
365
- prepared = serialize_value(val)
366
- Familia.ld " [to_a] field: #{field} method: #{method_name} val: #{val.class} prepared: #{prepared.class}"
367
- prepared
368
- end
369
- end
370
-
371
- # Behold, the grand tale of two serialization sorcerers:
372
- # Familia::DataType and Familia::Horreum!
373
- #
374
- # These twin wizards, though cut from the same magical cloth,
375
- # have their own unique spells for turning Ruby objects into
376
- # Redis-friendly potions. Let's peek into their spell books:
377
- #
378
- # Shared Incantations:
379
- # - Both transform various data creatures for Database safekeeping
380
- # - They tame wild Strings, Symbols, and those slippery Numerics
381
- # - Secret rituals (aka custom serialization) are welcome
382
- #
383
- # Mystical Differences:
384
- # - DataType reads the future in opts[:class] tea leaves
385
- # - Horreum prefers to interrogate types more thoroughly
386
- # - DataType leaves a trail of debug breadcrumbs
387
- #
388
- # But wait! Enter the wise Familia.distinguisher,
389
- # a grand unifier of serialization magic!
390
- #
391
- # This clever mediator:
392
- # 1. Juggles a circus of data types from both realms
393
- # 2. Offers a 'strict_values' toggle for the type-obsessed
394
- # 3. Welcomes custom spells via dump_method
395
- # 4. Sprinkles debug fairy dust à la DataType
396
- #
397
- # By channeling the Familia.distinguisher, we've created a
398
- # harmonious serialization symphony, flexible enough to dance
399
- # with any data type that shimmies our way. And should we need
400
- # to teach it new tricks, we know just where to wave our wands!
401
- #
402
- # @param value [Object] The mystical object to be transformed
403
- #
404
- # @return [String] The transformed, Redis-ready value.
405
- #
406
- def serialize_value(val)
407
- prepared = Familia.distinguisher(val, strict_values: false)
408
-
409
- # If the distinguisher returns nil, try using the dump_method but only
410
- # use JSON serialization for complex types that need it.
411
- if prepared.nil? && (val.is_a?(Hash) || val.is_a?(Array))
412
- prepared = val.respond_to?(dump_method) ? val.send(dump_method) : JSON.dump(val)
413
- end
414
-
415
- # If both the distinguisher and dump_method return nil, log an error
416
- Familia.ld "[#{self.class}#serialize_value] nil returned for #{self.class}" if prepared.nil?
417
-
418
- prepared
419
- end
420
-
421
- # Converts a Database string value back to its original Ruby type
422
- #
423
- # This method attempts to deserialize JSON strings back to their original
424
- # Hash or Array types. Simple string values are returned as-is.
425
- #
426
- # @param val [String] The string value from Database to deserialize
427
- # @param symbolize_keys [Boolean] Whether to symbolize hash keys (default: true for compatibility)
428
- # @return [Object] The deserialized value (Hash, Array, or original string)
429
- #
430
- def deserialize_value(val, symbolize: true)
431
- return val if val.nil? || val == ''
432
-
433
- # Try to parse as JSON first for complex types
434
- begin
435
- parsed = JSON.parse(val, symbolize_names: symbolize)
436
- # Only return parsed value if it's a complex type (Hash/Array)
437
- # Simple values should remain as strings
438
- return parsed if parsed.is_a?(Hash) || parsed.is_a?(Array)
439
- rescue JSON::ParserError
440
- # Not valid JSON, return as-is
441
- end
442
-
443
- val
444
- end
445
-
446
- private
447
-
448
- # Reset all transient fields to nil
449
- #
450
- # This method ensures that transient fields return to their uninitialized
451
- # state during refresh operations. This provides semantic clarity (refresh
452
- # means "reload from authoritative source"), ORM consistency with other
453
- # frameworks, and prevents stale transient data accumulation.
454
- #
455
- # @return [void]
456
- #
457
- def reset_transient_fields!
458
- return unless self.class.respond_to?(:transient_fields)
459
-
460
- self.class.transient_fields.each do |field_name|
461
- field_type = self.class.field_types[field_name]
462
- next unless field_type&.method_name
463
-
464
- # Set the transient field back to nil
465
- send("#{field_type.method_name}=", nil)
466
- Familia.ld "[reset_transient_fields!] Reset #{field_name} to nil"
467
- end
468
- end
469
- end
470
-
471
- include Serialization # these become Horreum instance methods
472
- end
473
- end
@@ -1,220 +0,0 @@
1
- # try/features/relatable_objects_try.rb
2
-
3
- # Test RelatableObject feature functionality
4
-
5
- require_relative '../helpers/test_helpers'
6
-
7
- Familia.debug = false
8
-
9
- class RelatableTest < Familia::Horreum
10
- feature :relatable_object
11
- identifier_field :id
12
- field :id
13
- field :name
14
- end
15
-
16
- class RelatedTest < Familia::Horreum
17
- feature :relatable_object
18
- identifier_field :id
19
- field :id
20
- field :name
21
- end
22
-
23
- class NonRelatableTest < Familia::Horreum
24
- identifier_field :id
25
- field :id
26
- field :name
27
- end
28
-
29
- # Setup test objects
30
- @relatable_obj = RelatableTest.new
31
- @relatable_obj.id = 'test_rel_1'
32
- @relatable_obj.name = 'Test Relatable 1'
33
-
34
- @related_obj = RelatedTest.new
35
- @related_obj.id = 'test_rel_2'
36
- @related_obj.name = 'Test Related 2'
37
-
38
- @non_relatable = NonRelatableTest.new
39
- @non_relatable.id = 'test_non_rel'
40
- @non_relatable.name = 'Non Relatable'
41
-
42
- ## Class has RelatableObject methods mixed in
43
- RelatableTest.respond_to?(:relatable_objids)
44
- #=> true
45
-
46
- ## Class has owners class method
47
- RelatableTest.respond_to?(:owners)
48
- #=> true
49
-
50
- ## Class has relatable? method
51
- RelatableTest.respond_to?(:relatable?)
52
- #=> true
53
-
54
- ## Class has generate_objid method
55
- RelatableTest.respond_to?(:generate_objid)
56
- #=> true
57
-
58
- ## Class has generate_extid method
59
- RelatableTest.respond_to?(:generate_extid)
60
- #=> true
61
-
62
- ## Class has find_by_objid method
63
- RelatableTest.respond_to?(:find_by_objid)
64
- #=> true
65
-
66
- ## Object has objid method
67
- @relatable_obj.respond_to?(:objid)
68
- #=> true
69
-
70
- ## Object has extid method
71
- @relatable_obj.respond_to?(:extid)
72
- #=> true
73
-
74
- ## Object has api_version field
75
- @relatable_obj.respond_to?(:api_version)
76
- #=> true
77
-
78
- ## Object has owner? method
79
- @relatable_obj.respond_to?(:owner?)
80
- #=> true
81
-
82
- ## Object has owned? method
83
- @relatable_obj.respond_to?(:owned?)
84
- #=> true
85
-
86
- ## Object has relatable_objid alias
87
- @relatable_obj.respond_to?(:relatable_objid)
88
- #=> true
89
-
90
- ## Object has external_identifier alias
91
- @relatable_obj.respond_to?(:external_identifier)
92
- #=> true
93
-
94
- ## objid is lazily generated on first access
95
- @relatable_obj.objid
96
- #=:> String
97
-
98
- ## objid is a UUID v7 format
99
- objid = @relatable_obj.objid
100
- objid.match?(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i)
101
- #=> true
102
-
103
- ## objid is cached after first generation
104
- objid1 = @relatable_obj.objid
105
- objid2 = @related_obj.objid
106
- [objid1, objid2]
107
- #=/=> _[0].eql?(_[1])
108
-
109
- ## extid is lazily generated on first access
110
- @relatable_obj.extid
111
- #=:> String
112
-
113
- ## extid starts with 'ext_' prefix (from our mock)
114
- @relatable_obj.extid.start_with?('ext_')
115
- #=> true
116
-
117
- ## extid is cached after first generation
118
- extid1 = @relatable_obj.extid
119
- extid2 = @related_obj.extid
120
- [extid1, extid2]
121
- #=/=> _[0].eql?(_[1])
122
-
123
- ## api_version defaults to 'v2'
124
- @relatable_obj.api_version
125
- #=> 'v2'
126
-
127
- ## relatable_objid is alias for objid
128
- [@relatable_obj.relatable_objid, @relatable_obj.objid]
129
- #==> _[0].eql?(_[1])
130
-
131
- ## external_identifier is alias for extid
132
- [@relatable_obj.external_identifier, @relatable_obj.extid]
133
- #==> _[0].eql?(_[1])
134
-
135
- ## relatable? prevents self-ownership (same class)
136
- RelatableTest.relatable?(@relatable_obj)
137
- #=!> V2::Features::RelatableObjectError
138
-
139
- ## relatable? returns true for different relatable classes
140
- RelatableTest.relatable?(@related_obj)
141
- #=> true
142
-
143
- ## relatable? raises error for non-relatable objects
144
- RelatableTest.relatable?(@non_relatable)
145
- #=!> V2::Features::RelatableObjectError
146
-
147
-
148
- ## relatable? with block executes block for relatable objects
149
- result = nil
150
- RelatableTest.relatable?(@related_obj) do
151
- result = "executed"
152
- end
153
- result
154
- #=> "executed"
155
-
156
- ## owned? returns false when no owner is set
157
- @relatable_obj.owned?
158
- #=> false
159
-
160
- ## owner? returns false when objects are not related
161
- @relatable_obj.owner?(@related_obj)
162
- #=> false
163
-
164
- ## generate_objid creates UUID v7
165
- generated_id = RelatableTest.generate_objid
166
- generated_id.match?(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i)
167
- #=> true
168
-
169
- ## generate_extid creates external ID
170
- RelatableTest.generate_extid
171
- #==> _.start_with?('ext_')
172
- #==> _.size == 54
173
-
174
- ## find_by_objid returns nil for empty objid
175
- result = RelatableTest.find_by_objid('')
176
- result.nil?
177
- #=> true
178
-
179
- ## find_by_objid returns nil for nil objid
180
- result = RelatableTest.find_by_objid(nil)
181
- result.nil?
182
- #=> true
183
-
184
- ## Class has relatable_objids sorted set
185
- objids_set = RelatableTest.relatable_objids
186
- objids_set.class.name
187
- #=> "Familia::SortedSet"
188
-
189
- ## Class has owners hash key
190
- owners_hash = RelatableTest.owners
191
- owners_hash.class.name
192
- #=> "Familia::HashKey"
193
-
194
- ## Objects can be persisted and retrieved
195
- @relatable_obj.save
196
- retrieved = RelatableTest.find(@relatable_obj.id)
197
- retrieved.id == @relatable_obj.id
198
- #=> true
199
-
200
- ## API version is preserved when persisting
201
- retrieved = RelatableTest.find(@relatable_obj.id)
202
- retrieved.api_version
203
- #=> 'v2'
204
-
205
- ## Objid is preserved when persisting
206
- original_objid = @relatable_obj.objid
207
- retrieved = RelatableTest.find(@relatable_obj.id)
208
- retrieved.objid == original_objid
209
- #=> true
210
-
211
- ## Extid is preserved when persisting
212
- original_extid = @relatable_obj.extid
213
- retrieved = RelatableTest.find(@relatable_obj.id)
214
- retrieved.extid == original_extid
215
- #=> true
216
-
217
- # Cleanup
218
- @relatable_obj.destroy! if @relatable_obj
219
- @related_obj.destroy! if @related_obj
220
- @non_relatable.destroy! if @non_relatable