familia 2.0.0.pre5 → 2.0.0.pre6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CLAUDE.md +8 -5
- data/Gemfile +1 -1
- data/Gemfile.lock +4 -3
- data/docs/wiki/API-Reference.md +95 -18
- data/docs/wiki/Connection-Pooling-Guide.md +437 -0
- data/docs/wiki/Encrypted-Fields-Overview.md +40 -3
- data/docs/wiki/Expiration-Feature-Guide.md +596 -0
- data/docs/wiki/Feature-System-Guide.md +600 -0
- data/docs/wiki/Features-System-Developer-Guide.md +892 -0
- data/docs/wiki/Field-System-Guide.md +784 -0
- data/docs/wiki/Home.md +72 -15
- data/docs/wiki/Implementation-Guide.md +126 -33
- data/docs/wiki/Quantization-Feature-Guide.md +721 -0
- data/docs/wiki/RelatableObjects-Guide.md +563 -0
- data/docs/wiki/Security-Model.md +65 -25
- data/docs/wiki/Transient-Fields-Guide.md +280 -0
- data/lib/familia/base.rb +1 -1
- data/lib/familia/data_type/types/counter.rb +38 -0
- data/lib/familia/data_type/types/hashkey.rb +18 -0
- data/lib/familia/data_type/types/lock.rb +43 -0
- data/lib/familia/data_type/types/string.rb +9 -2
- data/lib/familia/data_type.rb +2 -2
- data/lib/familia/encryption/encrypted_data.rb +137 -0
- data/lib/familia/encryption/manager.rb +21 -4
- data/lib/familia/encryption/providers/aes_gcm_provider.rb +20 -0
- data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +20 -0
- data/lib/familia/encryption.rb +1 -1
- data/lib/familia/errors.rb +17 -3
- data/lib/familia/features/encrypted_fields/concealed_string.rb +295 -0
- data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +94 -26
- data/lib/familia/features/expiration.rb +1 -1
- data/lib/familia/features/quantization.rb +1 -1
- data/lib/familia/features/safe_dump.rb +1 -1
- data/lib/familia/features/transient_fields/redacted_string.rb +1 -1
- data/lib/familia/features/transient_fields.rb +1 -1
- data/lib/familia/field_type.rb +5 -2
- data/lib/familia/horreum/{connection.rb → core/connection.rb} +2 -8
- data/lib/familia/horreum/{database_commands.rb → core/database_commands.rb} +14 -3
- data/lib/familia/horreum/core/serialization.rb +535 -0
- data/lib/familia/horreum/{utils.rb → core/utils.rb} +0 -2
- data/lib/familia/horreum/core.rb +21 -0
- data/lib/familia/horreum/{settings.rb → shared/settings.rb} +0 -2
- data/lib/familia/horreum/{definition_methods.rb → subclass/definition.rb} +44 -28
- data/lib/familia/horreum/{management_methods.rb → subclass/management.rb} +9 -8
- data/lib/familia/horreum/{related_fields_management.rb → subclass/related_fields_management.rb} +15 -10
- data/lib/familia/horreum.rb +17 -17
- data/lib/familia/version.rb +1 -1
- data/lib/familia.rb +1 -1
- data/try/core/create_method_try.rb +240 -0
- data/try/core/database_consistency_try.rb +299 -0
- data/try/core/errors_try.rb +25 -4
- data/try/core/familia_try.rb +1 -1
- data/try/core/persistence_operations_try.rb +297 -0
- data/try/data_types/counter_try.rb +93 -0
- data/try/data_types/lock_try.rb +133 -0
- data/try/debugging/debug_aad_process.rb +82 -0
- data/try/debugging/debug_concealed_internal.rb +59 -0
- data/try/debugging/debug_concealed_reveal.rb +61 -0
- data/try/debugging/debug_context_aad.rb +68 -0
- data/try/debugging/debug_context_simple.rb +80 -0
- data/try/debugging/debug_cross_context.rb +62 -0
- data/try/debugging/debug_database_load.rb +64 -0
- data/try/debugging/debug_encrypted_json_check.rb +53 -0
- data/try/debugging/debug_encrypted_json_step_by_step.rb +62 -0
- data/try/debugging/debug_exists_lifecycle.rb +54 -0
- data/try/debugging/debug_field_decrypt.rb +74 -0
- data/try/debugging/debug_fresh_cross_context.rb +73 -0
- data/try/debugging/debug_load_path.rb +66 -0
- data/try/debugging/debug_method_definition.rb +46 -0
- data/try/debugging/debug_method_resolution.rb +41 -0
- data/try/debugging/debug_minimal.rb +24 -0
- data/try/debugging/debug_provider.rb +68 -0
- data/try/debugging/debug_secure_behavior.rb +73 -0
- data/try/debugging/debug_string_class.rb +46 -0
- data/try/debugging/debug_test.rb +46 -0
- data/try/debugging/debug_test_design.rb +80 -0
- data/try/encryption/encryption_core_try.rb +3 -3
- data/try/features/encrypted_fields_core_try.rb +19 -11
- data/try/features/encrypted_fields_integration_try.rb +66 -70
- data/try/features/encrypted_fields_no_cache_security_try.rb +22 -8
- data/try/features/encrypted_fields_security_try.rb +151 -144
- data/try/features/encryption_fields/aad_protection_try.rb +108 -23
- data/try/features/encryption_fields/concealed_string_core_try.rb +250 -0
- data/try/features/encryption_fields/context_isolation_try.rb +29 -8
- data/try/features/encryption_fields/error_conditions_try.rb +6 -6
- data/try/features/encryption_fields/fresh_key_derivation_try.rb +20 -14
- data/try/features/encryption_fields/fresh_key_try.rb +27 -22
- data/try/features/encryption_fields/key_rotation_try.rb +16 -10
- data/try/features/encryption_fields/nonce_uniqueness_try.rb +15 -13
- data/try/features/encryption_fields/secure_by_default_behavior_try.rb +310 -0
- data/try/features/encryption_fields/thread_safety_try.rb +6 -6
- data/try/features/encryption_fields/universal_serialization_safety_try.rb +174 -0
- data/try/features/feature_dependencies_try.rb +3 -3
- data/try/features/transient_fields_core_try.rb +1 -1
- data/try/features/transient_fields_integration_try.rb +1 -1
- data/try/helpers/test_helpers.rb +25 -0
- data/try/horreum/enhanced_conflict_handling_try.rb +1 -1
- data/try/horreum/initialization_try.rb +1 -1
- data/try/horreum/relations_try.rb +1 -1
- data/try/horreum/serialization_persistent_fields_try.rb +8 -8
- data/try/horreum/serialization_try.rb +39 -4
- data/try/models/customer_safe_dump_try.rb +1 -1
- data/try/models/customer_try.rb +1 -1
- metadata +51 -10
- data/TEST_COVERAGE.md +0 -40
- data/lib/familia/horreum/serialization.rb +0 -473
@@ -0,0 +1,535 @@
|
|
1
|
+
# lib/familia/horreum/serialization.rb
|
2
|
+
#
|
3
|
+
module Familia
|
4
|
+
# Familia::Horreum
|
5
|
+
#
|
6
|
+
# Core persistence class for object-relational mapping with Valkey/Redis.
|
7
|
+
# Provides serialization, field management, and database interaction capabilities.
|
8
|
+
#
|
9
|
+
class Horreum
|
10
|
+
# Valid return values from database commands
|
11
|
+
#
|
12
|
+
# Defines the set of acceptable response values that indicate successful
|
13
|
+
# command execution in Valkey operations. These values are used to validate
|
14
|
+
# database responses and determine operation success.
|
15
|
+
#
|
16
|
+
# @return [Array<String, Boolean, Integer, nil>] Frozen array of valid return values:
|
17
|
+
# - "OK" - Standard success response for most commands
|
18
|
+
# - true - Boolean success indicator
|
19
|
+
# - 1 - Numeric success indicator (operation performed)
|
20
|
+
# - 0 - Numeric indicator (operation attempted, no change needed)
|
21
|
+
# - nil - Valid response for certain operations
|
22
|
+
#
|
23
|
+
# @example Validating a command response
|
24
|
+
# response = redis.set("key", "value")
|
25
|
+
# valid = @valid_command_return_values.include?(response)
|
26
|
+
# # => true if response is "OK"
|
27
|
+
#
|
28
|
+
@valid_command_return_values = ['OK', true, 1, 0, nil].freeze
|
29
|
+
|
30
|
+
class << self
|
31
|
+
attr_reader :valid_command_return_values
|
32
|
+
end
|
33
|
+
|
34
|
+
# Serialization: Object persistence and retrieval from the DB
|
35
|
+
# Handles conversion between Ruby objects and Valkey hash storage
|
36
|
+
#
|
37
|
+
module Serialization
|
38
|
+
# Persists the object to Valkey storage with automatic timestamping.
|
39
|
+
#
|
40
|
+
# Saves the current object state to Valkey storage, automatically setting
|
41
|
+
# created and updated timestamps if the object supports them. The method
|
42
|
+
# commits all persistent fields and optionally updates the key's expiration.
|
43
|
+
#
|
44
|
+
# @param update_expiration [Boolean] Whether to update the key's expiration
|
45
|
+
# time after saving. Defaults to true.
|
46
|
+
#
|
47
|
+
# @return [Boolean] true if the save operation was successful, false otherwise.
|
48
|
+
#
|
49
|
+
# @example Save an object to Valkey
|
50
|
+
# user = User.new(name: "John", email: "john@example.com")
|
51
|
+
# user.save
|
52
|
+
# # => true
|
53
|
+
#
|
54
|
+
# @example Save without updating expiration
|
55
|
+
# user.save(update_expiration: false)
|
56
|
+
# # => true
|
57
|
+
#
|
58
|
+
# @note When Familia.debug? is enabled, this method will trace the save
|
59
|
+
# operation for debugging purposes.
|
60
|
+
#
|
61
|
+
# @see #commit_fields The underlying method that performs the field persistence
|
62
|
+
#
|
63
|
+
def save(update_expiration: true)
|
64
|
+
Familia.trace :SAVE, dbclient, uri, caller(1..1) if Familia.debug?
|
65
|
+
|
66
|
+
# No longer need to sync computed identifier with a cache field
|
67
|
+
self.created ||= Familia.now.to_i if respond_to?(:created)
|
68
|
+
self.updated = Familia.now.to_i if respond_to?(:updated)
|
69
|
+
|
70
|
+
# Commit our tale to the Database chronicles
|
71
|
+
#
|
72
|
+
ret = commit_fields(update_expiration: update_expiration)
|
73
|
+
|
74
|
+
Familia.ld "[save] #{self.class} #{dbkey} #{ret} (update_expiration: #{update_expiration})"
|
75
|
+
|
76
|
+
# Did Database accept our offering?
|
77
|
+
!ret.nil?
|
78
|
+
end
|
79
|
+
|
80
|
+
# Saves the object to Valkey storage only if it doesn't already exist.
|
81
|
+
#
|
82
|
+
# Conditionally persists the object to Valkey storage by first checking if the
|
83
|
+
# identifier field already exists. If the object already exists in storage,
|
84
|
+
# raises an error. Otherwise, proceeds with a normal save operation including
|
85
|
+
# automatic timestamping.
|
86
|
+
#
|
87
|
+
# This method provides atomic conditional creation to prevent duplicate objects
|
88
|
+
# from being saved when uniqueness is required based on the identifier field.
|
89
|
+
#
|
90
|
+
# @param update_expiration [Boolean] Whether to update the key's expiration
|
91
|
+
# time after saving. Defaults to true.
|
92
|
+
#
|
93
|
+
# @return [Boolean] true if the save operation was successful
|
94
|
+
#
|
95
|
+
# @raise [Familia::RecordExistsError] If an object with the same identifier
|
96
|
+
# already exists in Valkey storage
|
97
|
+
#
|
98
|
+
# @example Save a new user only if it doesn't exist
|
99
|
+
# user = User.new(id: 123, name: "John")
|
100
|
+
# user.save_if_not_exists
|
101
|
+
# # => true (saved successfully)
|
102
|
+
#
|
103
|
+
# @example Attempting to save an existing object
|
104
|
+
# existing_user = User.new(id: 123, name: "Jane")
|
105
|
+
# existing_user.save_if_not_exists
|
106
|
+
# # => raises Familia::RecordExistsError
|
107
|
+
#
|
108
|
+
# @example Save without updating expiration
|
109
|
+
# user.save_if_not_exists(update_expiration: false)
|
110
|
+
# # => true
|
111
|
+
#
|
112
|
+
# @note This method uses HSETNX to atomically check and set the identifier
|
113
|
+
# field, ensuring race-condition-free conditional creation.
|
114
|
+
#
|
115
|
+
# @see #save The underlying save method called when the object doesn't exist
|
116
|
+
#
|
117
|
+
# Check if save_if_not_exists is implemented correctly. It should:
|
118
|
+
#
|
119
|
+
# Check if record exists
|
120
|
+
# If exists, raise Familia::RecordExistsError
|
121
|
+
# If not exists, save
|
122
|
+
def save_if_not_exists(update_expiration: true)
|
123
|
+
identifier_field = self.class.identifier_field
|
124
|
+
|
125
|
+
Familia.ld "[save_if_not_exists]: #{self.class} #{identifier_field}=#{identifier}"
|
126
|
+
Familia.trace :SAVE_IF_NOT_EXISTS, dbclient, uri, caller(1..1) if Familia.debug?
|
127
|
+
|
128
|
+
dbclient.watch(dbkey) do
|
129
|
+
if dbclient.exists(dbkey).positive?
|
130
|
+
dbclient.unwatch
|
131
|
+
raise Familia::RecordExistsError, dbkey
|
132
|
+
end
|
133
|
+
|
134
|
+
result = dbclient.multi do |multi|
|
135
|
+
multi.hmset(dbkey, to_h_for_storage)
|
136
|
+
end
|
137
|
+
|
138
|
+
result.is_a?(Array) # transaction succeeded
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
# Commits object fields to the DB storage.
|
143
|
+
#
|
144
|
+
# Persists the current state of all object fields to the DB using HMSET.
|
145
|
+
# Optionally updates the key's expiration time if the feature is enabled
|
146
|
+
# for the object's class.
|
147
|
+
#
|
148
|
+
# @param update_expiration [Boolean] Whether to update the expiration time
|
149
|
+
# of the Valkey key. Defaults to true.
|
150
|
+
#
|
151
|
+
# @return [Object] The result of the HMSET operation from the DB.
|
152
|
+
#
|
153
|
+
# @example Basic usage
|
154
|
+
# user.name = "John"
|
155
|
+
# user.email = "john@example.com"
|
156
|
+
# result = user.commit_fields
|
157
|
+
#
|
158
|
+
# @example Without updating expiration
|
159
|
+
# result = user.commit_fields(update_expiration: false)
|
160
|
+
#
|
161
|
+
# @note The expiration update is only performed for classes that have
|
162
|
+
# the expiration feature enabled. For others, it's a no-op.
|
163
|
+
#
|
164
|
+
# @note This method performs debug logging of the object's class, dbkey,
|
165
|
+
# and current state before committing to the DB.
|
166
|
+
#
|
167
|
+
def commit_fields(update_expiration: true)
|
168
|
+
prepared_value = to_h_for_storage
|
169
|
+
Familia.ld "[commit_fields] Begin #{self.class} #{dbkey} #{prepared_value} (exp: #{update_expiration})"
|
170
|
+
|
171
|
+
result = hmset(prepared_value)
|
172
|
+
|
173
|
+
# Only classes that have the expiration ferature enabled will
|
174
|
+
# actually set an expiration time on their keys. Otherwise
|
175
|
+
# this will be a no-op that simply logs the attempt.
|
176
|
+
update_expiration(default_expiration: nil) if update_expiration
|
177
|
+
|
178
|
+
result
|
179
|
+
end
|
180
|
+
|
181
|
+
# Updates multiple fields atomically in a Database transaction.
|
182
|
+
#
|
183
|
+
# @param fields [Hash] Field names and values to update. Special key :update_expiration
|
184
|
+
# controls whether to update key expiration (default: true)
|
185
|
+
# @return [MultiResult] Transaction result
|
186
|
+
#
|
187
|
+
# @example Update multiple fields without affecting expiration
|
188
|
+
# metadata.batch_update(viewed: 1, updated: Time.now.to_i, update_expiration: false)
|
189
|
+
#
|
190
|
+
# @example Update fields with expiration refresh
|
191
|
+
# user.batch_update(name: "John", email: "john@example.com")
|
192
|
+
#
|
193
|
+
def batch_update(**kwargs)
|
194
|
+
update_expiration = kwargs.delete(:update_expiration) { true }
|
195
|
+
fields = kwargs
|
196
|
+
|
197
|
+
Familia.trace :BATCH_UPDATE, dbclient, fields.keys, caller(1..1) if Familia.debug?
|
198
|
+
|
199
|
+
command_return_values = transaction do |conn|
|
200
|
+
fields.each do |field, value|
|
201
|
+
prepared_value = serialize_value(value)
|
202
|
+
conn.hset dbkey, field, prepared_value
|
203
|
+
# Update instance variable to keep object in sync
|
204
|
+
send("#{field}=", value) if respond_to?("#{field}=")
|
205
|
+
end
|
206
|
+
end
|
207
|
+
|
208
|
+
# Update expiration if requested and supported
|
209
|
+
self.update_expiration(default_expiration: nil) if update_expiration && respond_to?(:update_expiration)
|
210
|
+
|
211
|
+
# Return same MultiResult format as other methods
|
212
|
+
summary_boolean = command_return_values.all? { |ret| %w[OK 0 1].include?(ret.to_s) }
|
213
|
+
MultiResult.new(summary_boolean, command_return_values)
|
214
|
+
end
|
215
|
+
|
216
|
+
# Updates the object by applying multiple field values.
|
217
|
+
#
|
218
|
+
# Sets multiple attributes on the object instance using their corresponding
|
219
|
+
# setter methods. Only fields that have defined setter methods will be updated.
|
220
|
+
#
|
221
|
+
# @param fields [Hash] Hash of field names (as keys) and their values to apply
|
222
|
+
# to the object instance.
|
223
|
+
#
|
224
|
+
# @return [self] Returns the updated object instance for method chaining.
|
225
|
+
#
|
226
|
+
# @example Update multiple fields on an object
|
227
|
+
# user.apply_fields(name: "John", email: "john@example.com", age: 30)
|
228
|
+
# # => #<User:0x007f8a1c8b0a28 @name="John", @email="john@example.com", @age=30>
|
229
|
+
#
|
230
|
+
def apply_fields(**fields)
|
231
|
+
fields.each do |field, value|
|
232
|
+
# Apply the field value if the setter method exists
|
233
|
+
send("#{field}=", value) if respond_to?("#{field}=")
|
234
|
+
end
|
235
|
+
self
|
236
|
+
end
|
237
|
+
|
238
|
+
# Permanently removes this object from the DB storage.
|
239
|
+
#
|
240
|
+
# Deletes the object's Valkey key and all associated data. This operation
|
241
|
+
# is irreversible and will permanently destroy all stored information
|
242
|
+
# for this object instance.
|
243
|
+
#
|
244
|
+
# @return [void]
|
245
|
+
#
|
246
|
+
# @example Remove a user object from storage
|
247
|
+
# user = User.new(id: 123)
|
248
|
+
# user.destroy!
|
249
|
+
# # Object is now permanently removed from the DB
|
250
|
+
#
|
251
|
+
# @note This method provides high-level object lifecycle management.
|
252
|
+
# It operates at the object level for ORM-style operations, while
|
253
|
+
# `delete!` operates directly on database keys. Use `destroy!` when
|
254
|
+
# removing complete objects from the system.
|
255
|
+
#
|
256
|
+
# @note When debugging is enabled, this method will trace the deletion
|
257
|
+
# operation for diagnostic purposes.
|
258
|
+
#
|
259
|
+
# @see #delete! The underlying method that performs the key deletion
|
260
|
+
#
|
261
|
+
def destroy!
|
262
|
+
Familia.trace :DESTROY, dbclient, uri, caller(1..1) if Familia.debug?
|
263
|
+
delete!
|
264
|
+
end
|
265
|
+
|
266
|
+
# Clears all fields by setting them to nil.
|
267
|
+
#
|
268
|
+
# Resets all object fields to nil values, effectively clearing the object's
|
269
|
+
# state. This operation affects all fields defined on the object's class,
|
270
|
+
# setting each one to nil through their corresponding setter methods.
|
271
|
+
#
|
272
|
+
# @return [void]
|
273
|
+
#
|
274
|
+
# @example Clear all fields on an object
|
275
|
+
# user.name = "John"
|
276
|
+
# user.email = "john@example.com"
|
277
|
+
# user.clear_fields!
|
278
|
+
# # => user.name and user.email are now nil
|
279
|
+
#
|
280
|
+
# @note This operation does not persist the changes to the DB. Call save
|
281
|
+
# after clear_fields! if you want to persist the cleared state.
|
282
|
+
#
|
283
|
+
def clear_fields!
|
284
|
+
self.class.field_method_map.each_value { |method_name| send("#{method_name}=", nil) }
|
285
|
+
end
|
286
|
+
|
287
|
+
# Refreshes the object state from the DB storage.
|
288
|
+
#
|
289
|
+
# Reloads all persistent field values from the DB, overwriting any unsaved
|
290
|
+
# changes in the current object instance. This operation synchronizes the
|
291
|
+
# object with its stored state in the database.
|
292
|
+
#
|
293
|
+
# @return [void]
|
294
|
+
#
|
295
|
+
# @raise [Familia::KeyNotFoundError] If the Valkey key does not exist
|
296
|
+
#
|
297
|
+
# @example Refresh object from the DB
|
298
|
+
# user.name = "Changed Name" # unsaved change
|
299
|
+
# user.refresh!
|
300
|
+
# # => user.name is now the value from the DB storage
|
301
|
+
#
|
302
|
+
# @note This method discards any unsaved changes to the object. Use with
|
303
|
+
# caution when the object has been modified but not yet persisted.
|
304
|
+
#
|
305
|
+
# @note Transient fields are reset to nil during refresh since they have
|
306
|
+
# no authoritative source in Valkey storage.
|
307
|
+
#
|
308
|
+
def refresh!
|
309
|
+
Familia.trace :REFRESH, dbclient, uri, caller(1..1) if Familia.debug?
|
310
|
+
raise Familia::KeyNotFoundError, dbkey unless dbclient.exists(dbkey)
|
311
|
+
|
312
|
+
fields = hgetall
|
313
|
+
Familia.ld "[refresh!] #{self.class} #{dbkey} fields:#{fields.keys}"
|
314
|
+
|
315
|
+
# Reset transient fields to nil for semantic clarity and ORM consistency
|
316
|
+
# Transient fields have no authoritative source, so they should return to
|
317
|
+
# their uninitialized state during refresh operations
|
318
|
+
reset_transient_fields!
|
319
|
+
|
320
|
+
optimistic_refresh(**fields)
|
321
|
+
end
|
322
|
+
|
323
|
+
# Refreshes object state from the DB and returns self for method chaining.
|
324
|
+
#
|
325
|
+
# Loads the current state of the object from the DB storage, updating all
|
326
|
+
# field values to match their persisted state. This method provides a
|
327
|
+
# chainable interface to the refresh! operation.
|
328
|
+
#
|
329
|
+
# @return [self] The refreshed object instance, enabling method chaining
|
330
|
+
#
|
331
|
+
# @raise [Familia::KeyNotFoundError] If the Valkey key does not exist
|
332
|
+
#
|
333
|
+
# @example Refresh and chain operations
|
334
|
+
# user.refresh.save
|
335
|
+
# user.refresh.apply_fields(status: 'active')
|
336
|
+
#
|
337
|
+
# @see #refresh! The underlying refresh operation
|
338
|
+
#
|
339
|
+
def refresh
|
340
|
+
refresh!
|
341
|
+
self
|
342
|
+
end
|
343
|
+
|
344
|
+
# Converts the object's persistent fields to a hash for external use.
|
345
|
+
#
|
346
|
+
# Serializes persistent field values for external consumption (APIs, logs),
|
347
|
+
# excluding non-loggable fields like encrypted fields for security.
|
348
|
+
# Only non-nil values are included in the resulting hash.
|
349
|
+
#
|
350
|
+
# @return [Hash] Hash with field names as keys and serialized values
|
351
|
+
# safe for external exposure
|
352
|
+
#
|
353
|
+
# @example Converting an object to hash format for API response
|
354
|
+
# user = User.new(name: "John", email: "john@example.com", age: 30)
|
355
|
+
# user.to_h
|
356
|
+
# # => {"name"=>"John", "email"=>"john@example.com", "age"=>"30"}
|
357
|
+
# # encrypted fields are excluded for security
|
358
|
+
#
|
359
|
+
# @note Only loggable fields are included for security
|
360
|
+
# @note Only fields with non-nil values are included
|
361
|
+
#
|
362
|
+
def to_h
|
363
|
+
self.class.persistent_fields.each_with_object({}) do |field, hsh|
|
364
|
+
field_type = self.class.field_types[field]
|
365
|
+
|
366
|
+
# Security: Skip non-loggable fields (e.g., encrypted fields)
|
367
|
+
next unless field_type.loggable
|
368
|
+
|
369
|
+
method_name = field_type.method_name
|
370
|
+
val = send(method_name)
|
371
|
+
prepared = serialize_value(val)
|
372
|
+
Familia.ld " [to_h] field: #{field} val: #{val.class} prepared: #{prepared&.class || '[nil]'}"
|
373
|
+
|
374
|
+
# Only include non-nil values in the hash for Valkey
|
375
|
+
# Use string key for database compatibility
|
376
|
+
hsh[field.to_s] = prepared unless prepared.nil?
|
377
|
+
end
|
378
|
+
end
|
379
|
+
|
380
|
+
# Converts the object's persistent fields to a hash for database storage.
|
381
|
+
#
|
382
|
+
# Serializes ALL persistent field values for database storage, including
|
383
|
+
# encrypted fields. This is used internally by commit_fields and other
|
384
|
+
# persistence operations.
|
385
|
+
#
|
386
|
+
# @return [Hash] Hash with field names as keys and serialized values
|
387
|
+
# ready for database storage
|
388
|
+
#
|
389
|
+
# @note Includes ALL persistent fields, including encrypted fields
|
390
|
+
# @note Only fields with non-nil values are included for storage efficiency
|
391
|
+
#
|
392
|
+
def to_h_for_storage
|
393
|
+
self.class.persistent_fields.each_with_object({}) do |field, hsh|
|
394
|
+
field_type = self.class.field_types[field]
|
395
|
+
method_name = field_type.method_name
|
396
|
+
val = send(method_name)
|
397
|
+
prepared = serialize_value(val)
|
398
|
+
Familia.ld " [to_h_for_storage] field: #{field} val: #{val.class} prepared: #{prepared&.class || '[nil]'}"
|
399
|
+
|
400
|
+
# Only include non-nil values in the hash for Valkey
|
401
|
+
# Use string key for database compatibility
|
402
|
+
hsh[field.to_s] = prepared unless prepared.nil?
|
403
|
+
end
|
404
|
+
end
|
405
|
+
|
406
|
+
# Converts the object's persistent fields to an array.
|
407
|
+
#
|
408
|
+
# Serializes all persistent field values in field definition order,
|
409
|
+
# preparing them for Valkey storage. Each value is processed through
|
410
|
+
# the serialization pipeline to ensure Valkey compatibility.
|
411
|
+
#
|
412
|
+
# @return [Array] Array of serialized field values in field order
|
413
|
+
#
|
414
|
+
# @example Converting an object to array format
|
415
|
+
# user = User.new(name: "John", email: "john@example.com", age: 30)
|
416
|
+
# user.to_a
|
417
|
+
# # => ["John", "john@example.com", "30"]
|
418
|
+
#
|
419
|
+
# @note Values are serialized using the same process as other persistence
|
420
|
+
# methods to maintain data consistency across operations.
|
421
|
+
#
|
422
|
+
def to_a
|
423
|
+
self.class.persistent_fields.filter_map do |field|
|
424
|
+
field_type = self.class.field_types[field]
|
425
|
+
|
426
|
+
# Security: Skip non-loggable fields (e.g., encrypted fields)
|
427
|
+
next unless field_type.loggable
|
428
|
+
|
429
|
+
method_name = field_type.method_name
|
430
|
+
val = send(method_name)
|
431
|
+
prepared = serialize_value(val)
|
432
|
+
Familia.ld " [to_a] field: #{field} method: #{method_name} val: #{val.class} prepared: #{prepared.class}"
|
433
|
+
prepared
|
434
|
+
end
|
435
|
+
end
|
436
|
+
|
437
|
+
# Serializes a Ruby object for Valkey storage.
|
438
|
+
#
|
439
|
+
# Converts Ruby objects into the DB-compatible string representations using
|
440
|
+
# the Familia distinguisher for type coercion. Falls back to JSON serialization
|
441
|
+
# for complex types (Hash, Array) when the primary distinguisher returns nil.
|
442
|
+
#
|
443
|
+
# The serialization process:
|
444
|
+
# 1. Attempts conversion using Familia.distinguisher with relaxed type checking
|
445
|
+
# 2. For Hash/Array types that return nil, tries custom dump_method or JSON.dump
|
446
|
+
# 3. Logs warnings when serialization fails completely
|
447
|
+
#
|
448
|
+
# @param val [Object] The Ruby object to serialize for Valkey storage
|
449
|
+
#
|
450
|
+
# @return [String, nil] The serialized value ready for Valkey storage, or nil
|
451
|
+
# if serialization failed
|
452
|
+
#
|
453
|
+
# @example Serializing different data types
|
454
|
+
# serialize_value("hello") # => "hello"
|
455
|
+
# serialize_value(42) # => "42"
|
456
|
+
# serialize_value({name: "John"}) # => '{"name":"John"}'
|
457
|
+
# serialize_value([1, 2, 3]) # => "[1,2,3]"
|
458
|
+
#
|
459
|
+
# @note This method integrates with Familia's type system and supports
|
460
|
+
# custom serialization methods when available on the object
|
461
|
+
#
|
462
|
+
# @see Familia.distinguisher The primary serialization mechanism
|
463
|
+
#
|
464
|
+
def serialize_value(val)
|
465
|
+
# Security: Handle ConcealedString safely - extract encrypted data for storage
|
466
|
+
if val.respond_to?(:encrypted_value)
|
467
|
+
return val.encrypted_value
|
468
|
+
end
|
469
|
+
|
470
|
+
prepared = Familia.distinguisher(val, strict_values: false)
|
471
|
+
|
472
|
+
# If the distinguisher returns nil, try using the dump_method but only
|
473
|
+
# use JSON serialization for complex types that need it.
|
474
|
+
if prepared.nil? && (val.is_a?(Hash) || val.is_a?(Array))
|
475
|
+
prepared = val.respond_to?(dump_method) ? val.send(dump_method) : JSON.dump(val)
|
476
|
+
end
|
477
|
+
|
478
|
+
# If both the distinguisher and dump_method return nil, log an error
|
479
|
+
Familia.ld "[#{self.class}#serialize_value] nil returned for #{self.class}" if prepared.nil?
|
480
|
+
|
481
|
+
prepared
|
482
|
+
end
|
483
|
+
|
484
|
+
# Converts a Database string value back to its original Ruby type
|
485
|
+
#
|
486
|
+
# This method attempts to deserialize JSON strings back to their original
|
487
|
+
# Hash or Array types. Simple string values are returned as-is.
|
488
|
+
#
|
489
|
+
# @param val [String] The string value from Database to deserialize
|
490
|
+
# @param symbolize_keys [Boolean] Whether to symbolize hash keys (default: true for compatibility)
|
491
|
+
# @return [Object] The deserialized value (Hash, Array, or original string)
|
492
|
+
#
|
493
|
+
def deserialize_value(val, symbolize: true)
|
494
|
+
return val if val.nil? || val == ''
|
495
|
+
|
496
|
+
# Try to parse as JSON first for complex types
|
497
|
+
begin
|
498
|
+
parsed = JSON.parse(val, symbolize_names: symbolize)
|
499
|
+
# Only return parsed value if it's a complex type (Hash/Array)
|
500
|
+
# Simple values should remain as strings
|
501
|
+
return parsed if parsed.is_a?(Hash) || parsed.is_a?(Array)
|
502
|
+
rescue JSON::ParserError
|
503
|
+
# Not valid JSON, return as-is
|
504
|
+
end
|
505
|
+
|
506
|
+
val
|
507
|
+
end
|
508
|
+
|
509
|
+
private
|
510
|
+
|
511
|
+
# Reset all transient fields to nil
|
512
|
+
#
|
513
|
+
# This method ensures that transient fields return to their uninitialized
|
514
|
+
# state during refresh operations. This provides semantic clarity (refresh
|
515
|
+
# means "reload from authoritative source"), ORM consistency with other
|
516
|
+
# frameworks, and prevents stale transient data accumulation.
|
517
|
+
#
|
518
|
+
# @return [void]
|
519
|
+
#
|
520
|
+
def reset_transient_fields!
|
521
|
+
return unless self.class.respond_to?(:transient_fields)
|
522
|
+
|
523
|
+
self.class.transient_fields.each do |field_name|
|
524
|
+
field_type = self.class.field_types[field_name]
|
525
|
+
next unless field_type&.method_name
|
526
|
+
|
527
|
+
# Set the transient field back to nil
|
528
|
+
send("#{field_type.method_name}=", nil)
|
529
|
+
Familia.ld "[reset_transient_fields!] Reset #{field_name} to nil"
|
530
|
+
end
|
531
|
+
end
|
532
|
+
end
|
533
|
+
|
534
|
+
end
|
535
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# lib/familia/horreum/core.rb
|
2
|
+
|
3
|
+
require_relative 'core/database_commands'
|
4
|
+
require_relative 'core/serialization'
|
5
|
+
require_relative 'core/connection'
|
6
|
+
require_relative 'core/utils'
|
7
|
+
|
8
|
+
module Familia
|
9
|
+
class Horreum
|
10
|
+
module Core
|
11
|
+
include Familia::Horreum::DatabaseCommands
|
12
|
+
include Familia::Horreum::Serialization
|
13
|
+
# include for instance methods after it's loaded. Note that Horreum::Utils
|
14
|
+
# are also included and at one time also has a uri method. This connection
|
15
|
+
# module is also extended for the class level methods. It will require some
|
16
|
+
# disambiguation at some point.
|
17
|
+
include Familia::Horreum::Connection
|
18
|
+
include Familia::Horreum::Utils
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|