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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +2 -2
- data/.github/workflows/{code-smellage.yml → code-smells.yml} +3 -63
- data/.gitignore +2 -0
- data/.rubocop.yml +6 -0
- data/CHANGELOG.rst +22 -0
- data/CLAUDE.md +38 -0
- data/Gemfile.lock +1 -1
- data/docs/archive/FAMILIA_TECHNICAL.md +1 -1
- data/docs/overview.md +2 -2
- data/docs/reference/api-technical.md +1 -1
- data/examples/encrypted_fields.rb +1 -1
- data/examples/safe_dump.rb +1 -1
- data/lib/familia/base.rb +6 -4
- data/lib/familia/data_type/class_methods.rb +63 -0
- data/lib/familia/data_type/connection.rb +83 -0
- data/lib/familia/data_type/settings.rb +96 -0
- data/lib/familia/data_type/types/hashkey.rb +2 -1
- data/lib/familia/data_type/types/sorted_set.rb +113 -10
- data/lib/familia/data_type/types/stringkey.rb +0 -4
- data/lib/familia/data_type.rb +6 -193
- data/lib/familia/features/encrypted_fields.rb +5 -2
- data/lib/familia/features/external_identifier.rb +49 -8
- data/lib/familia/features/object_identifier.rb +84 -12
- data/lib/familia/features/relationships/indexing/unique_index_generators.rb +6 -1
- data/lib/familia/features/relationships/indexing.rb +7 -1
- data/lib/familia/features/relationships/participation/participant_methods.rb +6 -2
- data/lib/familia/features/transient_fields.rb +7 -2
- data/lib/familia/features.rb +6 -1
- data/lib/familia/field_type.rb +0 -18
- data/lib/familia/horreum/{core/connection.rb → connection.rb} +21 -0
- data/lib/familia/horreum/{subclass/definition.rb → definition.rb} +109 -32
- data/lib/familia/horreum/{subclass/management.rb → management.rb} +1 -3
- data/lib/familia/horreum/{core/serialization.rb → persistence.rb} +72 -169
- data/lib/familia/horreum/{subclass/related_fields_management.rb → related_fields.rb} +22 -2
- data/lib/familia/horreum/serialization.rb +172 -0
- data/lib/familia/horreum.rb +29 -8
- data/lib/familia/version.rb +1 -1
- data/try/configuration/scenarios_try.rb +1 -1
- data/try/core/connection_try.rb +4 -4
- data/try/core/database_consistency_try.rb +1 -0
- data/try/core/errors_try.rb +3 -3
- data/try/core/familia_try.rb +1 -1
- data/try/core/isolated_dbclient_try.rb +2 -2
- data/try/core/tools_try.rb +2 -2
- data/try/data_types/sorted_set_zadd_options_try.rb +625 -0
- data/try/features/field_groups_try.rb +244 -0
- data/try/features/relationships/indexing_try.rb +10 -0
- data/try/features/transient_fields/refresh_reset_try.rb +2 -0
- data/try/helpers/test_helpers.rb +3 -4
- data/try/horreum/auto_indexing_on_save_try.rb +212 -0
- data/try/horreum/commands_try.rb +2 -0
- data/try/horreum/defensive_initialization_try.rb +86 -0
- data/try/horreum/destroy_related_fields_cleanup_try.rb +2 -0
- data/try/horreum/settings_try.rb +2 -0
- data/try/memory/memory_docker_ruby_dump.sh +1 -1
- data/try/models/customer_try.rb +5 -5
- data/try/valkey.conf +26 -0
- metadata +19 -11
- data/lib/familia/horreum/core.rb +0 -21
- /data/lib/familia/horreum/{core/database_commands.rb → database_commands.rb} +0 -0
- /data/lib/familia/horreum/{shared/settings.rb → settings.rb} +0 -0
- /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
|
-
#
|
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
|
data/lib/familia/features.rb
CHANGED
@@ -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
|
|
data/lib/familia/field_type.rb
CHANGED
@@ -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/
|
1
|
+
# lib/familia/horreum/definition.rb
|
2
2
|
|
3
|
-
require_relative '
|
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 '
|
182
|
+
require_relative '../field_type'
|
102
183
|
|
103
184
|
# Create appropriate field type based on category
|
104
185
|
field_type = if category == :transient
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
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(
|
127
|
-
@suffix =
|
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(
|
141
|
-
@prefix =
|
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(
|
152
|
-
Familia.trace :LOGICAL_DATABASE_DEF, "instvar:#{@logical_database}",
|
153
|
-
@logical_database =
|
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,4 +1,4 @@
|
|
1
|
-
# lib/familia/horreum/
|
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
|
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
|
-
#
|
75
|
-
|
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/
|
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
|
-
|
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
|