familia 2.0.0.pre16 → 2.0.0.pre17

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +2 -2
  3. data/.github/workflows/{code-smellage.yml → code-smells.yml} +3 -63
  4. data/.gitignore +2 -0
  5. data/.rubocop.yml +6 -0
  6. data/CHANGELOG.rst +22 -0
  7. data/CLAUDE.md +38 -0
  8. data/Gemfile.lock +1 -1
  9. data/docs/archive/FAMILIA_TECHNICAL.md +1 -1
  10. data/docs/overview.md +2 -2
  11. data/docs/reference/api-technical.md +1 -1
  12. data/examples/encrypted_fields.rb +1 -1
  13. data/examples/safe_dump.rb +1 -1
  14. data/lib/familia/base.rb +6 -4
  15. data/lib/familia/data_type/class_methods.rb +63 -0
  16. data/lib/familia/data_type/connection.rb +83 -0
  17. data/lib/familia/data_type/settings.rb +96 -0
  18. data/lib/familia/data_type/types/hashkey.rb +2 -1
  19. data/lib/familia/data_type/types/sorted_set.rb +113 -10
  20. data/lib/familia/data_type/types/stringkey.rb +0 -4
  21. data/lib/familia/data_type.rb +6 -193
  22. data/lib/familia/features/encrypted_fields.rb +5 -2
  23. data/lib/familia/features/external_identifier.rb +49 -8
  24. data/lib/familia/features/object_identifier.rb +84 -12
  25. data/lib/familia/features/relationships/indexing/unique_index_generators.rb +6 -1
  26. data/lib/familia/features/relationships/indexing.rb +7 -1
  27. data/lib/familia/features/relationships/participation/participant_methods.rb +6 -2
  28. data/lib/familia/features/transient_fields.rb +7 -2
  29. data/lib/familia/features.rb +6 -1
  30. data/lib/familia/field_type.rb +0 -18
  31. data/lib/familia/horreum/{core/connection.rb → connection.rb} +21 -0
  32. data/lib/familia/horreum/{subclass/definition.rb → definition.rb} +109 -32
  33. data/lib/familia/horreum/{subclass/management.rb → management.rb} +1 -3
  34. data/lib/familia/horreum/{core/serialization.rb → persistence.rb} +72 -169
  35. data/lib/familia/horreum/{subclass/related_fields_management.rb → related_fields.rb} +22 -2
  36. data/lib/familia/horreum/serialization.rb +172 -0
  37. data/lib/familia/horreum.rb +29 -8
  38. data/lib/familia/version.rb +1 -1
  39. data/try/configuration/scenarios_try.rb +1 -1
  40. data/try/core/connection_try.rb +4 -4
  41. data/try/core/database_consistency_try.rb +1 -0
  42. data/try/core/errors_try.rb +3 -3
  43. data/try/core/familia_try.rb +1 -1
  44. data/try/core/isolated_dbclient_try.rb +2 -2
  45. data/try/core/tools_try.rb +2 -2
  46. data/try/data_types/sorted_set_zadd_options_try.rb +625 -0
  47. data/try/features/field_groups_try.rb +244 -0
  48. data/try/features/relationships/indexing_try.rb +10 -0
  49. data/try/features/transient_fields/refresh_reset_try.rb +2 -0
  50. data/try/helpers/test_helpers.rb +3 -4
  51. data/try/horreum/auto_indexing_on_save_try.rb +212 -0
  52. data/try/horreum/commands_try.rb +2 -0
  53. data/try/horreum/defensive_initialization_try.rb +86 -0
  54. data/try/horreum/destroy_related_fields_cleanup_try.rb +2 -0
  55. data/try/horreum/settings_try.rb +2 -0
  56. data/try/memory/memory_docker_ruby_dump.sh +1 -1
  57. data/try/models/customer_try.rb +5 -5
  58. data/try/valkey.conf +26 -0
  59. metadata +19 -11
  60. data/lib/familia/horreum/core.rb +0 -21
  61. /data/lib/familia/horreum/{core/database_commands.rb → database_commands.rb} +0 -0
  62. /data/lib/familia/horreum/{shared/settings.rb → settings.rb} +0 -0
  63. /data/lib/familia/horreum/{core/utils.rb → utils.rb} +0 -0
@@ -23,8 +23,10 @@ module Familia
23
23
  # ├── add_to_customer_domains(customer, score) # Add myself to customer's domains
24
24
  # ├── remove_from_customer_domains(customer) # Remove myself from customer's domains
25
25
  # ├── score_in_customer_domains(customer) # Get my score (sorted_set only)
26
- # ├── update_score_in_customer_domains(customer) # Update my score (sorted_set only)
27
26
  # └── position_in_customer_domains(customer) # Get my position (list only)
27
+ #
28
+ # Note: To update scores, use the DataType API directly:
29
+ # customer.domains.add(domain.identifier, new_score, xx: true)
28
30
 
29
31
  module Builder
30
32
  extend CollectionOperations
@@ -126,7 +128,9 @@ module Familia
126
128
 
127
129
  # Build score-related methods for sorted sets
128
130
  # Creates: domain.score_in_customer_domains(customer)
129
- # domain.update_score_in_customer_domains(customer, new_score)
131
+ #
132
+ # Note: Score updates use DataType API directly:
133
+ # customer.domains.add(domain.identifier, new_score, xx: true)
130
134
  def self.build_score_methods(participant_class, target_name, collection_name)
131
135
  # Get score method
132
136
  score_method = "score_in_#{target_name}_#{collection_name}"
@@ -1,6 +1,7 @@
1
1
  # lib/familia/features/transient_fields.rb
2
2
 
3
3
  require_relative 'transient_fields/redacted_string'
4
+ require_relative 'transient_fields/transient_field_type'
4
5
 
5
6
  module Familia
6
7
  module Features
@@ -104,7 +105,7 @@ module Familia
104
105
  # (HashiCorp Vault, AWS Secrets Manager) or languages with secure memory handling.
105
106
  #
106
107
  module TransientFields
107
- Familia::Base.add_feature self, :transient_fields, depends_on: nil
108
+ Familia::Base.add_feature self, :transient_fields, depends_on: nil, field_group: :transient_fields
108
109
 
109
110
  def self.included(base)
110
111
  Familia.trace :LOADED, self, base if Familia.debug?
@@ -143,8 +144,12 @@ module Familia
143
144
  @transient_fields ||= []
144
145
  @transient_fields << name unless @transient_fields.include?(name)
145
146
 
147
+ # Add to field_groups if the group exists
148
+ if field_groups&.key?(:transient_fields)
149
+ field_groups[:transient_fields] << name
150
+ end
151
+
146
152
  # Use the field type system for proper integration
147
- require_relative 'transient_fields/transient_field_type'
148
153
  field_type = TransientFieldType.new(name, as: as, **kwargs.merge(fast_method: false))
149
154
  register_field_type(field_type)
150
155
  end
@@ -4,7 +4,7 @@
4
4
  require_relative 'features/autoloader'
5
5
 
6
6
  module Familia
7
- FeatureDefinition = Data.define(:name, :depends_on)
7
+ FeatureDefinition = Data.define(:name, :depends_on, :field_group)
8
8
 
9
9
  # Familia::Features
10
10
  #
@@ -147,6 +147,11 @@ module Familia
147
147
  calling_location = caller_locations(1, 1)&.first
148
148
  options[:calling_location] = calling_location&.path
149
149
 
150
+ # Initialize field group if feature declares one
151
+ if feature_def&.field_group && respond_to?(:field_group)
152
+ field_group(feature_def.field_group)
153
+ end
154
+
150
155
  # Add feature options if the class supports them (Horreum classes)
151
156
  add_feature_options(feature_name, **options) if respond_to?(:add_feature_options)
152
157
 
@@ -117,25 +117,7 @@ module Familia
117
117
  klass.define_method :"#{method_name}=" do |value|
118
118
  instance_variable_set(:"@#{field_name}", value)
119
119
 
120
- # If this field is the identifier and object_identifier feature is loaded,
121
- # update objid_lookup mapping when identifier is set after objid generation
122
- if respond_to?(:objid) &&
123
- self.class.respond_to?(:identifier_field) &&
124
- self.class.identifier_field == field_name &&
125
- self.class.respond_to?(:objid_lookup)
126
- current_objid = instance_variable_get(:@objid)
127
- self.class.objid_lookup[current_objid] = value if current_objid && value
128
- end
129
120
 
130
- # If this field is the identifier and external_identifier feature is loaded,
131
- # update extid_lookup mapping when identifier is set after extid generation
132
- if respond_to?(:extid) &&
133
- self.class.respond_to?(:identifier_field) &&
134
- self.class.identifier_field == field_name &&
135
- self.class.respond_to?(:extid_lookup)
136
- current_extid = instance_variable_get(:@extid)
137
- self.class.extid_lookup[current_extid] = value if current_extid && value
138
- end
139
121
  end
140
122
  end
141
123
  end
@@ -149,6 +149,7 @@ module Familia
149
149
  # @see MultiResult For details on the return value structure
150
150
  # @see #batch_update For similar atomic field updates with MultiResult
151
151
  def transaction(&)
152
+ ensure_relatives_initialized!
152
153
  Familia::Connection::TransactionCore.execute_transaction(-> { dbclient }, &)
153
154
  end
154
155
  alias multi transaction
@@ -243,12 +244,32 @@ module Familia
243
244
  # @see MultiResult For details on the return value structure
244
245
  # @see Familia.transaction For atomic command execution
245
246
  def pipelined(&block)
247
+ ensure_relatives_initialized!
246
248
  Familia::Connection::PipelineCore.execute_pipeline(-> { dbclient }, &block)
247
249
  end
248
250
  alias pipeline pipelined
249
251
 
250
252
  private
251
253
 
254
+ # Ensures that related fields have been initialized before entering transactions or pipelines.
255
+ #
256
+ # This prevents Redis::Future errors when lazy initialization would occur inside
257
+ # transaction/pipeline blocks. When commands execute inside transactions, Redis returns
258
+ # Future objects that don't respond to standard methods, causing cryptic NoMethodError.
259
+ #
260
+ # @raise [RuntimeError] if instance has relations but they haven't been initialized
261
+ # @note Skips check for class methods - they create temporary instances internally
262
+ # @note Uses singleton class to avoid polluting instance variables
263
+ def ensure_relatives_initialized!
264
+ return if is_a?(Class) # Class methods handle their own instances
265
+ return unless self.class.respond_to?(:relations?) && self.class.relations?
266
+ return if singleton_class.instance_variable_defined?(:"@relatives_initialized")
267
+
268
+ raise "#{self.class} has related fields but they haven't been initialized. " \
269
+ "Did you override initialize without calling super? " \
270
+ "Related fields: #{self.class.related_fields.keys.join(', ')}"
271
+ end
272
+
252
273
  # Builds the class-level connection chain with handlers in priority order
253
274
  def build_connection_chain
254
275
  # Cache handlers at class level to avoid creating new instances per model instance
@@ -1,7 +1,6 @@
1
- # lib/familia/horreum/subclass/definition.rb
1
+ # lib/familia/horreum/definition.rb
2
2
 
3
- require_relative 'related_fields_management'
4
- require_relative '../shared/settings'
3
+ require_relative 'settings'
5
4
 
6
5
  module Familia
7
6
  VALID_STRATEGIES = %i[raise skip ignore warn overwrite].freeze
@@ -32,6 +31,10 @@ module Familia
32
31
  @dump_method = nil
33
32
  @load_method = nil
34
33
 
34
+ # Field groups
35
+ @field_groups = nil
36
+ @current_field_group = nil
37
+
35
38
  # DefinitionMethods - Class-level DSL methods for defining Horreum model structure
36
39
  #
37
40
  # This module is extended into classes that include Familia::Horreum,
@@ -48,6 +51,84 @@ module Familia
48
51
  include Familia::Settings
49
52
  include Familia::Horreum::RelatedFieldsManagement # Provides DataType field methods
50
53
 
54
+ # Defines a field group to organize related fields.
55
+ #
56
+ # Field groups provide a way to categorize and query fields by purpose or feature.
57
+ # When a block is provided, fields defined within the block are automatically
58
+ # added to the group. Without a block, an empty group is initialized.
59
+ #
60
+ # @param name [Symbol, String] the name of the field group
61
+ # @yield optional block for defining fields within the group
62
+ # @return [Array<Symbol>] the array of field names in the group
63
+ #
64
+ # @raise [Familia::Problem] if attempting to nest field groups
65
+ #
66
+ # @example Manual field grouping
67
+ # class User < Familia::Horreum
68
+ # field_group :personal_info do
69
+ # field :name
70
+ # field :email
71
+ # end
72
+ # end
73
+ #
74
+ # User.personal_info # => [:name, :email]
75
+ #
76
+ # @example Initialize empty group
77
+ # class User < Familia::Horreum
78
+ # field_group :placeholder
79
+ # end
80
+ #
81
+ # User.placeholder # => []
82
+ #
83
+ def field_group(name, &block)
84
+
85
+ # Prevent nested field groups
86
+ if @current_field_group
87
+ raise Familia::Problem,
88
+ "Cannot define field group :#{name} while :#{@current_field_group} is being defined. " \
89
+ "Nested field groups are not supported."
90
+ end
91
+
92
+ # Initialize group
93
+ field_groups[name.to_sym] ||= []
94
+
95
+ if block_given?
96
+ @current_field_group = name.to_sym
97
+ begin
98
+ instance_eval(&block)
99
+ ensure
100
+ @current_field_group = nil
101
+ end
102
+ else
103
+ Familia.ld "[field_group] Created field group :#{name} but no block given" if Familia.debug?
104
+ end
105
+
106
+ field_groups[name.to_sym]
107
+ end
108
+
109
+ # Returns the list of all field group names defined for the class.
110
+ #
111
+ # @return [Array<Symbol>] array of field group names
112
+ #
113
+ # @example
114
+ # class User < Familia::Horreum
115
+ # field_group :personal_info do
116
+ # field :name
117
+ # end
118
+ # field_group :metadata do
119
+ # field :created_at
120
+ # end
121
+ # end
122
+ #
123
+ # User.field_groups # => [
124
+ # :personal_info => [...],
125
+ # :metadata => [..]
126
+ # ]
127
+ #
128
+ def field_groups
129
+ @field_groups ||= {}
130
+ end
131
+
51
132
  # Sets or retrieves the unique identifier field for the class.
52
133
  #
53
134
  # This method defines or returns the field or method that contains the unique
@@ -98,21 +179,21 @@ module Familia
98
179
  #
99
180
  def field(name, as: name, fast_method: :"#{name}!", on_conflict: :raise, category: nil)
100
181
  # Use field type system for consistency
101
- require_relative '../../field_type'
182
+ require_relative '../field_type'
102
183
 
103
184
  # Create appropriate field type based on category
104
185
  field_type = if category == :transient
105
- require_relative '../../features/transient_fields/transient_field_type'
106
- TransientFieldType.new(name, as: as, fast_method: false, on_conflict: on_conflict)
107
- else
108
- # For regular fields and other categories, create custom field type with category override
109
- custom_field_type = Class.new(FieldType) do
110
- define_method :category do
111
- category || :field
112
- end
113
- end
114
- custom_field_type.new(name, as: as, fast_method: fast_method, on_conflict: on_conflict)
115
- end
186
+ require_relative '../features/transient_fields/transient_field_type'
187
+ TransientFieldType.new(name, as: as, fast_method: false, on_conflict: on_conflict)
188
+ else
189
+ # For regular fields and other categories, create custom field type with category override
190
+ custom_field_type = Class.new(FieldType) do
191
+ define_method :category do
192
+ category || :field
193
+ end
194
+ end
195
+ custom_field_type.new(name, as: as, fast_method: fast_method, on_conflict: on_conflict)
196
+ end
116
197
 
117
198
  register_field_type(field_type)
118
199
  end
@@ -123,8 +204,8 @@ module Familia
123
204
  # @param blk [Proc] a block that returns the suffix (optional).
124
205
  # @return [String, Symbol] the current suffix or Familia.default_suffix if none is set.
125
206
  #
126
- def suffix(a = nil, &blk)
127
- @suffix = a || blk if a || !blk.nil?
207
+ def suffix(val = nil, &blk)
208
+ @suffix = val || blk if val || !blk.nil?
128
209
  @suffix || Familia.default_suffix
129
210
  end
130
211
 
@@ -137,8 +218,8 @@ module Familia
137
218
  # which typically occurs with anonymous classes that haven't had their prefix
138
219
  # explicitly set.
139
220
  #
140
- def prefix(a = nil)
141
- @prefix = a if a
221
+ def prefix(val = nil)
222
+ @prefix = val if val
142
223
  @prefix || begin
143
224
  if name.nil?
144
225
  raise Problem, 'Cannot generate prefix for anonymous class. ' \
@@ -148,9 +229,9 @@ module Familia
148
229
  end
149
230
  end
150
231
 
151
- def logical_database(v = nil)
152
- Familia.trace :LOGICAL_DATABASE_DEF, "instvar:#{@logical_database}", v if Familia.debug?
153
- @logical_database = v unless v.nil?
232
+ def logical_database(num = nil)
233
+ Familia.trace :LOGICAL_DATABASE_DEF, "instvar:#{@logical_database}", num if Familia.debug?
234
+ @logical_database = num unless num.nil?
154
235
  @logical_database || parent&.logical_database
155
236
  end
156
237
 
@@ -221,6 +302,12 @@ module Familia
221
302
  # Complete the registration after installation. If we do this beforehand
222
303
  # we can run into issues where it looks like it's already installed.
223
304
  field_types[field_type.name] = field_type
305
+
306
+ # Add to current field group if one is active
307
+ if @current_field_group
308
+ @field_groups[@current_field_group] << field_type.name
309
+ end
310
+
224
311
  # Freeze the field_type to ensure immutability (maintains Data class heritage)
225
312
  field_type.freeze
226
313
  end
@@ -303,16 +390,6 @@ module Familia
303
390
  @feature_options[feature_name.to_sym]
304
391
  end
305
392
 
306
- # Create and register a transient field type
307
- #
308
- # @param name [Symbol] The field name
309
- #
310
- def transient_field(name, **)
311
- require_relative '../../features/transient_fields/transient_field_type'
312
- field_type = TransientFieldType.new(name, **, fast_method: false)
313
- register_field_type(field_type)
314
- end
315
-
316
393
  private
317
394
 
318
395
  # Hook to detect silent overwrites and handle conflicts
@@ -1,6 +1,4 @@
1
- # lib/familia/horreum/subclass/management.rb
2
-
3
- require_relative 'related_fields_management'
1
+ # lib/familia/horreum/management.rb
4
2
 
5
3
  module Familia
6
4
  class Horreum
@@ -1,4 +1,4 @@
1
- # lib/familia/horreum/serialization.rb
1
+ # lib/familia/horreum/persistence.rb
2
2
 
3
3
  module Familia
4
4
  # Familia::Horreum
@@ -34,7 +34,7 @@ module Familia
34
34
  # Serialization - Instance-level methods for object persistence and retrieval
35
35
  # Handles conversion between Ruby objects and Valkey hash storage
36
36
  #
37
- module Serialization
37
+ module Persistence
38
38
  # Persists the object to Valkey storage with automatic timestamping.
39
39
  #
40
40
  # Saves the current object state to Valkey storage, automatically setting
@@ -68,11 +68,18 @@ module Familia
68
68
  self.updated = Familia.now.to_i if respond_to?(:updated)
69
69
 
70
70
  # Commit our tale to the Database chronicles
71
- #
71
+ # Wrap in transaction for atomicity between save and indexing
72
72
  ret = commit_fields(update_expiration: update_expiration)
73
73
 
74
- # Add to class-level instances collection after successful save
75
- self.class.instances.add(identifier, Familia.now) if ret && self.class.respond_to?(:instances)
74
+ # Auto-index for class-level indexes after successful save
75
+ # Use transaction to ensure atomicity with the save operation
76
+ if ret
77
+ transaction do |conn|
78
+ auto_update_class_indexes
79
+ # Add to class-level instances collection after successful save
80
+ self.class.instances.add(identifier, Familia.now) if self.class.respond_to?(:instances)
81
+ end
82
+ end
76
83
 
77
84
  Familia.ld "[save] #{self.class} #{dbkey} #{ret} (update_expiration: #{update_expiration})"
78
85
 
@@ -128,7 +135,7 @@ module Familia
128
135
  Familia.ld "[save_if_not_exists]: #{self.class} #{identifier_field}=#{identifier}"
129
136
  Familia.trace :SAVE_IF_NOT_EXISTS, nil, uri if Familia.debug?
130
137
 
131
- dbclient.watch(dbkey) do
138
+ success = dbclient.watch(dbkey) do
132
139
  if dbclient.exists(dbkey).positive?
133
140
  dbclient.unwatch
134
141
  raise Familia::RecordExistsError, dbkey
@@ -140,6 +147,16 @@ module Familia
140
147
 
141
148
  result.is_a?(Array) # transaction succeeded
142
149
  end
150
+
151
+ # Auto-index for class-level indexes after successful save
152
+ # Use transaction to ensure atomicity with the save operation
153
+ if success
154
+ transaction do |conn|
155
+ auto_update_class_indexes
156
+ end
157
+ end
158
+
159
+ success
143
160
  end
144
161
 
145
162
  # Commits object fields to the DB storage.
@@ -361,169 +378,6 @@ module Familia
361
378
  self
362
379
  end
363
380
 
364
- # Converts the object's persistent fields to a hash for external use.
365
- #
366
- # Serializes persistent field values for external consumption (APIs, logs),
367
- # excluding non-loggable fields like encrypted fields for security.
368
- # Only non-nil values are included in the resulting hash.
369
- #
370
- # @return [Hash] Hash with field names as keys and serialized values
371
- # safe for external exposure
372
- #
373
- # @example Converting an object to hash format for API response
374
- # user = User.new(name: "John", email: "john@example.com", age: 30)
375
- # user.to_h
376
- # # => {"name"=>"John", "email"=>"john@example.com", "age"=>"30"}
377
- # # encrypted fields are excluded for security
378
- #
379
- # @note Only loggable fields are included for security
380
- # @note Only fields with non-nil values are included
381
- #
382
- def to_h
383
- self.class.persistent_fields.each_with_object({}) do |field, hsh|
384
- field_type = self.class.field_types[field]
385
-
386
- # Security: Skip non-loggable fields (e.g., encrypted fields)
387
- next unless field_type.loggable
388
-
389
- method_name = field_type.method_name
390
- val = send(method_name)
391
- prepared = serialize_value(val)
392
- Familia.ld " [to_h] field: #{field} val: #{val.class} prepared: #{prepared&.class || '[nil]'}"
393
-
394
- # Only include non-nil values in the hash for Valkey
395
- # Use string key for database compatibility
396
- hsh[field.to_s] = prepared unless prepared.nil?
397
- end
398
- end
399
-
400
- # Converts the object's persistent fields to a hash for database storage.
401
- #
402
- # Serializes ALL persistent field values for database storage, including
403
- # encrypted fields. This is used internally by commit_fields and other
404
- # persistence operations.
405
- #
406
- # @return [Hash] Hash with field names as keys and serialized values
407
- # ready for database storage
408
- #
409
- # @note Includes ALL persistent fields, including encrypted fields
410
- # @note Only fields with non-nil values are included for storage efficiency
411
- #
412
- def to_h_for_storage
413
- self.class.persistent_fields.each_with_object({}) do |field, hsh|
414
- field_type = self.class.field_types[field]
415
- method_name = field_type.method_name
416
- val = send(method_name)
417
- prepared = serialize_value(val)
418
- Familia.ld " [to_h_for_storage] field: #{field} val: #{val.class} prepared: #{prepared&.class || '[nil]'}"
419
-
420
- # Only include non-nil values in the hash for Valkey
421
- # Use string key for database compatibility
422
- hsh[field.to_s] = prepared unless prepared.nil?
423
- end
424
- end
425
-
426
- # Converts the object's persistent fields to an array.
427
- #
428
- # Serializes all persistent field values in field definition order,
429
- # preparing them for Valkey storage. Each value is processed through
430
- # the serialization pipeline to ensure Valkey compatibility.
431
- #
432
- # @return [Array] Array of serialized field values in field order
433
- #
434
- # @example Converting an object to array format
435
- # user = User.new(name: "John", email: "john@example.com", age: 30)
436
- # user.to_a
437
- # # => ["John", "john@example.com", "30"]
438
- #
439
- # @note Values are serialized using the same process as other persistence
440
- # methods to maintain data consistency across operations.
441
- #
442
- def to_a
443
- self.class.persistent_fields.filter_map do |field|
444
- field_type = self.class.field_types[field]
445
-
446
- # Security: Skip non-loggable fields (e.g., encrypted fields)
447
- next unless field_type.loggable
448
-
449
- method_name = field_type.method_name
450
- val = send(method_name)
451
- prepared = serialize_value(val)
452
- Familia.ld " [to_a] field: #{field} method: #{method_name} val: #{val.class} prepared: #{prepared.class}"
453
- prepared
454
- end
455
- end
456
-
457
- # Serializes a Ruby object for Valkey storage.
458
- #
459
- # Converts Ruby objects into the DB-compatible string representations using
460
- # the Familia distinguisher for type coercion. Falls back to JSON serialization
461
- # for complex types (Hash, Array) when the primary distinguisher returns nil.
462
- #
463
- # The serialization process:
464
- # 1. Attempts conversion using Familia.distinguisher with relaxed type checking
465
- # 2. For Hash/Array types that return nil, tries custom dump_method or Familia::JsonSerializer.dump
466
- # 3. Logs warnings when serialization fails completely
467
- #
468
- # @param val [Object] The Ruby object to serialize for Valkey storage
469
- #
470
- # @return [String, nil] The serialized value ready for Valkey storage, or nil
471
- # if serialization failed
472
- #
473
- # @example Serializing different data types
474
- # serialize_value("hello") # => "hello"
475
- # serialize_value(42) # => "42"
476
- # serialize_value({name: "John"}) # => '{"name":"John"}'
477
- # serialize_value([1, 2, 3]) # => "[1,2,3]"
478
- #
479
- # @note This method integrates with Familia's type system and supports
480
- # custom serialization methods when available on the object
481
- #
482
- # @see Familia.distinguisher The primary serialization mechanism
483
- #
484
- def serialize_value(val)
485
- # Security: Handle ConcealedString safely - extract encrypted data for storage
486
- return val.encrypted_value if val.respond_to?(:encrypted_value)
487
-
488
- prepared = Familia.distinguisher(val, strict_values: false)
489
-
490
- # If the distinguisher returns nil, try using the dump_method but only
491
- # use JSON serialization for complex types that need it.
492
- if prepared.nil? && (val.is_a?(Hash) || val.is_a?(Array))
493
- prepared = val.respond_to?(dump_method) ? val.send(dump_method) : Familia::JsonSerializer.dump(val)
494
- end
495
-
496
- # If both the distinguisher and dump_method return nil, log an error
497
- Familia.ld "[#{self.class}#serialize_value] nil returned for #{self.class}" if prepared.nil?
498
-
499
- prepared
500
- end
501
-
502
- # Converts a Database string value back to its original Ruby type
503
- #
504
- # This method attempts to deserialize JSON strings back to their original
505
- # Hash or Array types. Simple string values are returned as-is.
506
- #
507
- # @param val [String] The string value from Database to deserialize
508
- # @param symbolize [Boolean] Whether to symbolize hash keys (default: true for compatibility)
509
- # @return [Object] The deserialized value (Hash, Array, or original string)
510
- #
511
- def deserialize_value(val, symbolize: true)
512
- return val if val.nil? || val == ''
513
-
514
- # Try to parse as JSON first for complex types
515
- begin
516
- parsed = Familia::JsonSerializer.parse(val, symbolize_names: symbolize)
517
- # Only return parsed value if it's a complex type (Hash/Array)
518
- # Simple values should remain as strings
519
- return parsed if parsed.is_a?(Hash) || parsed.is_a?(Array)
520
- rescue Familia::SerializerError
521
- # Not valid JSON, return as-is
522
- end
523
-
524
- val
525
- end
526
-
527
381
  private
528
382
 
529
383
  # Reset all transient fields to nil
@@ -547,6 +401,55 @@ module Familia
547
401
  Familia.ld "[reset_transient_fields!] Reset #{field_name} to nil"
548
402
  end
549
403
  end
404
+
405
+ # Automatically update class-level indexes after save
406
+ #
407
+ # Iterates through class-level indexing relationships and calls their
408
+ # corresponding add_to_class_* methods to populate indexes. Only processes
409
+ # class-level indexes (where target_class == self.class), skipping
410
+ # instance-scoped indexes which require parent context.
411
+ #
412
+ # Uses idempotent Redis commands (HSET for unique_index) so repeated calls
413
+ # are safe and have negligible performance overhead. Note that multi_index
414
+ # always requires within: parameter, so only unique_index benefits from this.
415
+ #
416
+ # @return [void]
417
+ #
418
+ # @example Automatic indexing on save
419
+ # class Customer < Familia::Horreum
420
+ # feature :relationships
421
+ # unique_index :email, :email_lookup
422
+ # end
423
+ #
424
+ # customer = Customer.new(email: 'test@example.com')
425
+ # customer.save # Automatically calls add_to_class_email_lookup
426
+ #
427
+ # @note Only class-level unique_index declarations auto-populate.
428
+ # Instance-scoped indexes (with within:) require manual population:
429
+ # employee.add_to_company_badge_index(company)
430
+ #
431
+ # @see Familia::Features::Relationships::Indexing For index declaration details
432
+ #
433
+ def auto_update_class_indexes
434
+ return unless self.class.respond_to?(:indexing_relationships)
435
+
436
+ self.class.indexing_relationships.each do |rel|
437
+ # Skip instance-scoped indexes (require parent context)
438
+ # Instance-scoped indexes must be manually populated because they need
439
+ # the parent object reference (e.g., employee.add_to_company_badge_index(company))
440
+ unless rel.target_class == self.class
441
+ Familia.ld <<~LOG_MESSAGE
442
+ [auto_update_class_indexes] Skipping #{rel.index_name} (requires parent context)
443
+ LOG_MESSAGE
444
+ next
445
+ end
446
+
447
+ # Call the existing add_to_class_* methods
448
+ add_method = :"add_to_class_#{rel.index_name}"
449
+ send(add_method) if respond_to?(add_method)
450
+ end
451
+ end
452
+
550
453
  end
551
454
  end
552
455
  end
@@ -1,4 +1,4 @@
1
- # lib/familia/horreum/related_fields_management.rb
1
+ # lib/familia/horreum/related_fields.rb
2
2
 
3
3
  module Familia
4
4
 
@@ -170,7 +170,27 @@ module Familia
170
170
 
171
171
  related_fields[name] = RelatedFieldDefinition.new(name, klass, opts)
172
172
 
173
- attr_reader name
173
+ # Create lazy-initializing accessor that calls initialize_relatives if needed
174
+ define_method name do
175
+ ivar = :"@#{name}"
176
+ value = instance_variable_get(ivar)
177
+
178
+ # If nil and we haven't initialized relatives, do it now
179
+ # Check singleton class to avoid polluting instance variables
180
+ if value.nil? && !singleton_class.instance_variable_defined?(:"@relatives_initialized")
181
+ initialize_relatives
182
+ value = instance_variable_get(ivar)
183
+ end
184
+
185
+ # If still nil after lazy initialization attempt, raise helpful error
186
+ # Only raise if we tried to initialize but it's still nil
187
+ if value.nil? && singleton_class.instance_variable_defined?(:"@relatives_initialized")
188
+ raise "#{self.class}##{name} is nil. Did you override initialize without calling super? " \
189
+ "(Field is nil after initialization attempt)"
190
+ end
191
+
192
+ value
193
+ end
174
194
 
175
195
  define_method :"#{name}=" do |val|
176
196
  send(name).replace val