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
@@ -0,0 +1,172 @@
1
+ # lib/familia/horreum/serialization.rb
2
+
3
+ module Familia
4
+ class Horreum
5
+ # Serialization - Instance-level methods for object serialization
6
+ # Handles conversion between Ruby objects and Valkey hash storage
7
+ module Serialization
8
+ # Converts the object's persistent fields to a hash for external use.
9
+ #
10
+ # Serializes persistent field values for external consumption (APIs, logs),
11
+ # excluding non-loggable fields like encrypted fields for security.
12
+ # Only non-nil values are included in the resulting hash.
13
+ #
14
+ # @return [Hash] Hash with field names as keys and serialized values
15
+ # safe for external exposure
16
+ #
17
+ # @example Converting an object to hash format for API response
18
+ # user = User.new(name: "John", email: "john@example.com", age: 30)
19
+ # user.to_h
20
+ # # => {"name"=>"John", "email"=>"john@example.com", "age"=>"30"}
21
+ # # encrypted fields are excluded for security
22
+ #
23
+ # @note Only loggable fields are included for security
24
+ # @note Only fields with non-nil values are included
25
+ #
26
+ def to_h
27
+ self.class.persistent_fields.each_with_object({}) do |field, hsh|
28
+ field_type = self.class.field_types[field]
29
+
30
+ # Security: Skip non-loggable fields (e.g., encrypted fields)
31
+ next unless field_type.loggable
32
+
33
+ method_name = field_type.method_name
34
+ val = send(method_name)
35
+ prepared = serialize_value(val)
36
+ Familia.ld " [to_h] field: #{field} val: #{val.class} prepared: #{prepared&.class || '[nil]'}"
37
+
38
+ # Only include non-nil values in the hash for Valkey
39
+ # Use string key for database compatibility
40
+ hsh[field.to_s] = prepared unless prepared.nil?
41
+ end
42
+ end
43
+
44
+ # Converts the object's persistent fields to a hash for database storage.
45
+ #
46
+ # Serializes ALL persistent field values for database storage, including
47
+ # encrypted fields. This is used internally by commit_fields and other
48
+ # persistence operations.
49
+ #
50
+ # @return [Hash] Hash with field names as keys and serialized values
51
+ # ready for database storage
52
+ #
53
+ # @note Includes ALL persistent fields, including encrypted fields
54
+ # @note Only fields with non-nil values are included for storage efficiency
55
+ #
56
+ def to_h_for_storage
57
+ self.class.persistent_fields.each_with_object({}) do |field, hsh|
58
+ field_type = self.class.field_types[field]
59
+ method_name = field_type.method_name
60
+ val = send(method_name)
61
+ prepared = serialize_value(val)
62
+ Familia.ld " [to_h_for_storage] field: #{field} val: #{val.class} prepared: #{prepared&.class || '[nil]'}"
63
+
64
+ # Only include non-nil values in the hash for Valkey
65
+ # Use string key for database compatibility
66
+ hsh[field.to_s] = prepared unless prepared.nil?
67
+ end
68
+ end
69
+
70
+ # Converts the object's persistent fields to an array.
71
+ #
72
+ # Serializes all persistent field values in field definition order,
73
+ # preparing them for Valkey storage. Each value is processed through
74
+ # the serialization pipeline to ensure Valkey compatibility.
75
+ #
76
+ # @return [Array] Array of serialized field values in field order
77
+ #
78
+ # @example Converting an object to array format
79
+ # user = User.new(name: "John", email: "john@example.com", age: 30)
80
+ # user.to_a
81
+ # # => ["John", "john@example.com", "30"]
82
+ #
83
+ # @note Values are serialized using the same process as other persistence
84
+ # methods to maintain data consistency across operations.
85
+ #
86
+ def to_a
87
+ self.class.persistent_fields.filter_map do |field|
88
+ field_type = self.class.field_types[field]
89
+
90
+ # Security: Skip non-loggable fields (e.g., encrypted fields)
91
+ next unless field_type.loggable
92
+
93
+ method_name = field_type.method_name
94
+ val = send(method_name)
95
+ prepared = serialize_value(val)
96
+ Familia.ld " [to_a] field: #{field} method: #{method_name} val: #{val.class} prepared: #{prepared.class}"
97
+ prepared
98
+ end
99
+ end
100
+
101
+ # Serializes a Ruby object for Valkey storage.
102
+ #
103
+ # Converts Ruby objects into the DB-compatible string representations using
104
+ # the Familia distinguisher for type coercion. Falls back to JSON serialization
105
+ # for complex types (Hash, Array) when the primary distinguisher returns nil.
106
+ #
107
+ # The serialization process:
108
+ # 1. Attempts conversion using Familia.distinguisher with relaxed type checking
109
+ # 2. For Hash/Array types that return nil, tries custom dump_method or Familia::JsonSerializer.dump
110
+ # 3. Logs warnings when serialization fails completely
111
+ #
112
+ # @param val [Object] The Ruby object to serialize for Valkey storage
113
+ #
114
+ # @return [String, nil] The serialized value ready for Valkey storage, or nil
115
+ # if serialization failed
116
+ #
117
+ # @example Serializing different data types
118
+ # serialize_value("hello") # => "hello"
119
+ # serialize_value(42) # => "42"
120
+ # serialize_value({name: "John"}) # => '{"name":"John"}'
121
+ # serialize_value([1, 2, 3]) # => "[1,2,3]"
122
+ #
123
+ # @note This method integrates with Familia's type system and supports
124
+ # custom serialization methods when available on the object
125
+ #
126
+ # @see Familia.distinguisher The primary serialization mechanism
127
+ #
128
+ def serialize_value(val)
129
+ # Security: Handle ConcealedString safely - extract encrypted data for storage
130
+ return val.encrypted_value if val.respond_to?(:encrypted_value)
131
+
132
+ prepared = Familia.distinguisher(val, strict_values: false)
133
+
134
+ # If the distinguisher returns nil, try using the dump_method but only
135
+ # use JSON serialization for complex types that need it.
136
+ if prepared.nil? && (val.is_a?(Hash) || val.is_a?(Array))
137
+ prepared = val.respond_to?(dump_method) ? val.send(dump_method) : Familia::JsonSerializer.dump(val)
138
+ end
139
+
140
+ # If both the distinguisher and dump_method return nil, log an error
141
+ Familia.ld "[#{self.class}#serialize_value] nil returned for #{self.class}" if prepared.nil?
142
+
143
+ prepared
144
+ end
145
+
146
+ # Converts a Database string value back to its original Ruby type
147
+ #
148
+ # This method attempts to deserialize JSON strings back to their original
149
+ # Hash or Array types. Simple string values are returned as-is.
150
+ #
151
+ # @param val [String] The string value from Database to deserialize
152
+ # @param symbolize [Boolean] Whether to symbolize hash keys (default: true for compatibility)
153
+ # @return [Object] The deserialized value (Hash, Array, or original string)
154
+ #
155
+ def deserialize_value(val, symbolize: true)
156
+ return val if val.nil? || val == ''
157
+
158
+ # Try to parse as JSON first for complex types
159
+ begin
160
+ parsed = Familia::JsonSerializer.parse(val, symbolize_names: symbolize)
161
+ # Only return parsed value if it's a complex type (Hash/Array)
162
+ # Simple values should remain as strings
163
+ return parsed if parsed.is_a?(Hash) || parsed.is_a?(Array)
164
+ rescue Familia::SerializerError
165
+ # Not valid JSON, return as-is
166
+ end
167
+
168
+ val
169
+ end
170
+ end
171
+ end
172
+ end
@@ -1,9 +1,14 @@
1
1
  # lib/familia/horreum.rb
2
2
 
3
- require_relative 'horreum/subclass/definition'
4
- require_relative 'horreum/subclass/management'
5
- require_relative 'horreum/shared/settings'
6
- require_relative 'horreum/core'
3
+ require_relative 'horreum/settings'
4
+ require_relative 'horreum/connection'
5
+ require_relative 'horreum/database_commands'
6
+ require_relative 'horreum/related_fields'
7
+ require_relative 'horreum/definition'
8
+ require_relative 'horreum/management'
9
+ require_relative 'horreum/persistence'
10
+ require_relative 'horreum/serialization'
11
+ require_relative 'horreum/utils'
7
12
 
8
13
  module Familia
9
14
  #
@@ -44,8 +49,12 @@ module Familia
44
49
  #
45
50
  class Horreum
46
51
  include Familia::Base
47
- include Familia::Horreum::Core
52
+ include Familia::Horreum::Persistence
53
+ include Familia::Horreum::Serialization
54
+ include Familia::Horreum::Connection
55
+ include Familia::Horreum::DatabaseCommands
48
56
  include Familia::Horreum::Settings
57
+ include Familia::Horreum::Utils
49
58
 
50
59
  using Familia::Refinements::TimeLiterals
51
60
 
@@ -223,6 +232,15 @@ module Familia
223
232
  init
224
233
  end
225
234
 
235
+ # Override this method in subclasses for custom initialization logic.
236
+ # This is called AFTER fields are set and relatives are initialized.
237
+ #
238
+ # DO NOT override initialize() - use this init() hook instead.
239
+ #
240
+ # Example:
241
+ # def init(name = nil)
242
+ # @name = name || SecureRandom.hex(4)
243
+ # end
226
244
  def init(*args, **kwargs)
227
245
  # Default no-op
228
246
  end
@@ -233,6 +251,8 @@ module Familia
233
251
  # This needs to be called in the initialize method.
234
252
  #
235
253
  def initialize_relatives
254
+ # Store initialization flag on singleton class to avoid polluting instance variables
255
+ return if singleton_class.instance_variable_defined?(:"@relatives_initialized")
236
256
  # Generate instances of each DataType. These need to be
237
257
  # unique for each instance of this class so they can piggyback
238
258
  # on the specifc index of this instance.
@@ -272,6 +292,9 @@ module Familia
272
292
  # e.g. customer.name #=> `#<Familia::HashKey:0x0000...>`
273
293
  instance_variable_set :"@#{name}", related_object
274
294
  end
295
+
296
+ # Mark relatives as initialized on singleton class to avoid polluting instance variables
297
+ singleton_class.instance_variable_set(:"@relatives_initialized", true)
275
298
  end
276
299
 
277
300
  def initialize_with_keyword_args_deserialize_value(**fields)
@@ -311,7 +334,7 @@ module Familia
311
334
  send(definition)
312
335
  when Proc
313
336
  definition.call(self)
314
- end
337
+ end
315
338
 
316
339
  # Return nil for unpopulated identifiers (like unsaved ActiveRecord objects)
317
340
  # Only raise errors when the identifier is actually needed for db operations
@@ -331,7 +354,6 @@ module Familia
331
354
  # @return [Redis] the Database connection instance.
332
355
  #
333
356
 
334
-
335
357
  def generate_id
336
358
  @objid ||= Familia.generate_id
337
359
  end
@@ -390,6 +412,5 @@ module Familia
390
412
  end
391
413
 
392
414
  # Builds the instance-level connection chain with handlers in priority order
393
-
394
415
  end
395
416
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Familia
4
4
  # Version information for the Familia
5
- VERSION = '2.0.0.pre16'.freeze unless defined?(Familia::VERSION)
5
+ VERSION = '2.0.0.pre17'.freeze unless defined?(Familia::VERSION)
6
6
  end
@@ -28,7 +28,7 @@ end
28
28
  begin
29
29
  # Test with custom URI
30
30
  original_uri = Familia.uri
31
- test_uri = 'redis://localhost:6379/10'
31
+ test_uri = 'redis://localhost:2525/10'
32
32
 
33
33
  Familia.uri = test_uri
34
34
  current_uri = Familia.uri
@@ -12,10 +12,10 @@ Familia.uri
12
12
 
13
13
  ## Default URI points to localhost database server
14
14
  Familia.uri.to_s
15
- #=> "redis://127.0.0.1"
15
+ #=> "redis://127.0.0.1:2525"
16
16
 
17
17
  ## Can parse URI from string
18
- uri = URI.parse('redis://localhost:6379/1')
18
+ uri = URI.parse('redis://localhost:2525/1')
19
19
  uri.host
20
20
  #=> "localhost"
21
21
 
@@ -29,7 +29,7 @@ Familia.connect
29
29
 
30
30
  ## Can create connection to different URI
31
31
  ## Doesn't confirm the logical DB number, dbclient.options raises an error?
32
- test_uri = 'redis://localhost:6379/2'
32
+ test_uri = 'redis://localhost:2525/2'
33
33
  Familia.create_dbclient(test_uri)
34
34
  #=:> Redis
35
35
 
@@ -48,7 +48,7 @@ Familia.enable_database_counter
48
48
  #=> true
49
49
 
50
50
  ## Middleware gets registered when enabled
51
- dbclient = Familia.create_dbclient('redis://localhost:6379/3')
51
+ dbclient = Familia.create_dbclient('redis://localhost:2525/2')
52
52
  dbclient.ping
53
53
  #=> "PONG"
54
54
 
@@ -214,6 +214,7 @@ exists_after_batch = @batch_obj.exists?
214
214
 
215
215
  ## Transient fields don't affect exists? behavior
216
216
  class TransientConsistencyTest < Familia::Horreum
217
+ feature :transient_fields
217
218
  identifier_field :id
218
219
  field :id
219
220
  field :name
@@ -40,16 +40,16 @@ raise Familia::NotDistinguishableError, 'A customized message'
40
40
  #=~> /A customized message/
41
41
 
42
42
  ## NotConnected error stores URI
43
- test_uri = URI.parse('redis://localhost:6379')
43
+ test_uri = URI.parse('redis://localhost:2525')
44
44
  begin
45
45
  raise Familia::NotConnected.new(test_uri)
46
46
  rescue Familia::NotConnected => e
47
47
  e.uri.to_s
48
48
  end
49
- #=> "redis://localhost"
49
+ #=> "redis://localhost:2525"
50
50
 
51
51
  ## NotConnected error has custom message
52
- test_uri = URI.parse('redis://localhost:6379')
52
+ test_uri = URI.parse('redis://localhost:2525')
53
53
  begin
54
54
  raise Familia::NotConnected.new(test_uri)
55
55
  rescue Familia::NotConnected => e
@@ -12,7 +12,7 @@ Familia.uri
12
12
 
13
13
  ## Familia has a uri as a string
14
14
  Familia.uri.to_s
15
- #=> 'redis://127.0.0.1'
15
+ #=> 'redis://127.0.0.1:2525'
16
16
 
17
17
  ## Familia has a url, an alias to uri
18
18
  Familia.url.eql?(Familia.uri)
@@ -8,7 +8,7 @@
8
8
  require_relative '../helpers/test_helpers'
9
9
 
10
10
  # Clean up any existing test data in all test databases
11
- (0..15).each do |db|
11
+ (0..2).each do |db|
12
12
  Familia.with_isolated_dbclient(db) do |client|
13
13
  client.flushdb
14
14
  end
@@ -143,7 +143,7 @@ result
143
143
  #=> "seven"
144
144
 
145
145
  ## isolated_dbclient with String URI argument
146
- client = Familia.isolated_dbclient("redis://localhost:6379/8")
146
+ client = Familia.isolated_dbclient("redis://localhost:2525/8")
147
147
  client.set("uri_test", "eight")
148
148
  result = client.get("uri_test")
149
149
  client.close
@@ -6,8 +6,8 @@ require_relative '../helpers/test_helpers'
6
6
 
7
7
  ## move_keys across Valkey/Redis instances (if available)
8
8
  begin
9
- source_redis = Redis.new(db: 10)
10
- dest_redis = Redis.new(db: 11)
9
+ source_redis = Redis.new(db: 1, port: 2525)
10
+ dest_redis = Redis.new(db: 2, port: 2525)
11
11
  source_redis.set('test:key1', 'value1')
12
12
  source_redis.set('test:key2', 'value2')
13
13