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
@@ -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
|
data/lib/familia/horreum.rb
CHANGED
@@ -1,9 +1,14 @@
|
|
1
1
|
# lib/familia/horreum.rb
|
2
2
|
|
3
|
-
require_relative 'horreum/
|
4
|
-
require_relative 'horreum/
|
5
|
-
require_relative 'horreum/
|
6
|
-
require_relative 'horreum/
|
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::
|
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
|
-
|
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
|
data/lib/familia/version.rb
CHANGED
data/try/core/connection_try.rb
CHANGED
@@ -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:
|
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:
|
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:
|
51
|
+
dbclient = Familia.create_dbclient('redis://localhost:2525/2')
|
52
52
|
dbclient.ping
|
53
53
|
#=> "PONG"
|
54
54
|
|
data/try/core/errors_try.rb
CHANGED
@@ -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:
|
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:
|
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
|
data/try/core/familia_try.rb
CHANGED
@@ -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..
|
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:
|
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
|
data/try/core/tools_try.rb
CHANGED
@@ -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
|
-
dest_redis = Redis.new(db:
|
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
|
|