familia 1.1.0.pre.rc1 → 1.2.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f7e5fe48e629eab30af342be4ad81f6afe5854b33b678b926b9c3470c0515627
4
- data.tar.gz: 1d938baae35feb17f3d9855055152fdb0ba441317315fd3b2de6932e3c47e0cf
3
+ metadata.gz: 4a664f87a493fffb0e362752c8f988c8de27d83008f3a796b66737e3d15e771d
4
+ data.tar.gz: cb22d85f774e40a7b656713760f2f1e31b154171620038c282ce2dbe5e88492b
5
5
  SHA512:
6
- metadata.gz: 707e9aba376653fa439ab1ffc85c4b794f1f52444dbed86a093f1d9c93a1952d8f84fe465cdcf8798bf0f72e5a4099586f1895cad5225f6d81899deeedcbf3a5
7
- data.tar.gz: a9323482330bef4a632d92fd6828da32f0be7aa4f107d659e4440a2e3a09ad3dd0c2011d90a5e1a3380ca85fdf5afe47fb4634e9cf21951986d92115523bfbba
6
+ metadata.gz: 3942bab094053731bd0d45c47a89c7bde62d26b99339cd2fad36e5481b28f91cd3eecb17c8ef6f77e3e486858151f6d5821e973f9fb7c068431abf11634806db
7
+ data.tar.gz: 32933d4adbb991b3cb790f4a47134f7e428fa617b3f33d3068d98a2e795d2ee58b2654ac22fb913abdc46dd0f3459bcdfd50d71273a137d2f337c4da521e475d
data/.gitignore CHANGED
@@ -8,6 +8,7 @@
8
8
  *.env
9
9
  *.log
10
10
  *.md
11
+ !README.md
11
12
  *.txt
12
13
  !LICENSE.txt
13
14
  .ruby-version
@@ -64,6 +64,6 @@ repos:
64
64
  stages: [prepare-commit-msg]
65
65
  name: Link commit to Github issue
66
66
  args:
67
- - "--default=[NOJIRA]"
67
+ - "--default="
68
68
  - "--pattern=[a-zA-Z0-9]{0,10}-?[0-9]{1,5}"
69
69
  - "--template=[#{}]"
data/VERSION.yml CHANGED
@@ -1,5 +1,4 @@
1
1
  ---
2
2
  :MAJOR: 1
3
- :MINOR: 1
3
+ :MINOR: 2
4
4
  :PATCH: 0
5
- :PRE: rc1
@@ -22,22 +22,26 @@ module Familia
22
22
  redis.move rediskey, db
23
23
  end
24
24
 
25
- # Checks if the calling object's key exists in Redis and has a non-zero size.
25
+ # Checks if the calling object's key exists in Redis.
26
26
  #
27
- # This method retrieves the Redis URI associated with the calling object's class
28
- # using `Familia.redis_uri_by_class`. It then checks if the specified key exists
29
- # in Redis and that its size is not zero. If debugging is enabled, it logs the
30
- # existence check using `Familia.trace`.
27
+ # @param check_size [Boolean] When true (default), also verifies the hash has a non-zero size.
28
+ # When false, only checks key existence regardless of content.
29
+ # @return [Boolean] Returns `true` if the key exists in Redis. When `check_size` is true,
30
+ # also requires the hash to have at least one field.
31
31
  #
32
- # @return [Boolean] Returns `true` if the key exists in Redis and its size is not zero, otherwise `false`.
33
- # @example
34
- # if some_object.exists?
35
- # # perform action
36
- # end
37
- def exists?
38
- true_or_false = self.class.redis.exists?(rediskey) && !size.zero?
39
- Familia.trace :EXISTS, redis, "#{key} #{true_or_false.inspect}", caller(1..1) if Familia.debug?
40
- true_or_false
32
+ # @example Check existence with size validation (default behavior)
33
+ # some_object.exists? # => false for empty hashes
34
+ # some_object.exists?(check_size: true) # => false for empty hashes
35
+ #
36
+ # @example Check existence only
37
+ # some_object.exists?(check_size: false) # => true for empty hashes
38
+ #
39
+ # @note The default behavior maintains backward compatibility by treating empty hashes
40
+ # as non-existent. Use `check_size: false` for pure key existence checking.
41
+ def exists?(check_size: true)
42
+ key_exists = self.class.redis.exists?(rediskey)
43
+ return key_exists unless check_size
44
+ key_exists && !size.zero?
41
45
  end
42
46
 
43
47
  # Returns the number of fields in the main object hash
@@ -121,8 +121,9 @@ module Familia
121
121
  def save update_expiration: true
122
122
  Familia.trace :SAVE, redis, redisuri, caller(1..1) if Familia.debug?
123
123
 
124
- # Update our object's life story
125
- self.key ||= self.identifier
124
+ # Update our object's life story, keeping the mandatory built-in
125
+ # key field in sync with the field that is the chosen identifier.
126
+ self.key = self.identifier
126
127
  self.created ||= Familia.now.to_i if respond_to?(:created)
127
128
  self.updated = Familia.now.to_i if respond_to?(:updated)
128
129
 
@@ -137,6 +138,41 @@ module Familia
137
138
  ret.successful?
138
139
  end
139
140
 
141
+ # Updates multiple fields atomically in a Redis transaction.
142
+ #
143
+ # @param fields [Hash] Field names and values to update. Special key :update_expiration
144
+ # controls whether to update key expiration (default: true)
145
+ # @return [MultiResult] Transaction result
146
+ #
147
+ # @example Update multiple fields without affecting expiration
148
+ # metadata.batch_update(viewed: 1, updated: Time.now.to_i, update_expiration: false)
149
+ #
150
+ # @example Update fields with expiration refresh
151
+ # user.batch_update(name: "John", email: "john@example.com")
152
+ #
153
+ def batch_update(**kwargs)
154
+ update_expiration = kwargs.delete(:update_expiration) { true }
155
+ fields = kwargs
156
+
157
+ Familia.trace :BATCH_UPDATE, redis, fields.keys, caller(1..1) if Familia.debug?
158
+
159
+ command_return_values = transaction do |conn|
160
+ fields.each do |field, value|
161
+ prepared_value = serialize_value(value)
162
+ conn.hset rediskey, field, prepared_value
163
+ # Update instance variable to keep object in sync
164
+ send("#{field}=", value) if respond_to?("#{field}=")
165
+ end
166
+ end
167
+
168
+ # Update expiration if requested and supported
169
+ self.update_expiration(ttl: nil) if update_expiration && respond_to?(:update_expiration)
170
+
171
+ # Return same MultiResult format as other methods
172
+ summary_boolean = command_return_values.all? { |ret| %w[OK 0 1].include?(ret.to_s) }
173
+ MultiResult.new(summary_boolean, command_return_values)
174
+ end
175
+
140
176
  # Apply a smattering of fields to this object like fairy dust.
141
177
  #
142
178
  # @param fields [Hash] A magical bag of named attributes to sprinkle onto
@@ -267,6 +303,26 @@ module Familia
267
303
  delete!
268
304
  end
269
305
 
306
+ # The Great Nilpocalypse: clear_fields!
307
+ #
308
+ # Imagine your object as a grand old mansion, every room stuffed with
309
+ # trinkets, secrets, and the odd rubber duck. This method? It flings open
310
+ # every window and lets a wild wind of nothingness sweep through, leaving
311
+ # each field as empty as a poet’s wallet.
312
+ #
313
+ # All your precious attributes—gone! Swept into the void! It’s a spring
314
+ # cleaning for the soul, a reset button for your existential dread.
315
+ #
316
+ # @return [void] Nothing left but echoes and nils.
317
+ #
318
+ # @example The Vanishing Act
319
+ # wizard.clear_fields!
320
+ # # => All fields are now nil, like a spell gone slightly too well.
321
+ #
322
+ def clear_fields!
323
+ self.class.fields.each { |field| send("#{field}=", nil) }
324
+ end
325
+
270
326
  # The Great Redis Refresh-o-matic 3000
271
327
  #
272
328
  # Imagine your object as a forgetful time traveler. This method is like
@@ -335,8 +391,10 @@ module Familia
335
391
  self.class.fields.inject({}) do |hsh, field|
336
392
  val = send(field)
337
393
  prepared = serialize_value(val)
338
- Familia.ld " [to_h] field: #{field} val: #{val.class} prepared: #{prepared.class}"
339
- hsh[field] = prepared
394
+ Familia.ld " [to_h] field: #{field} val: #{val.class} prepared: #{prepared&.class || '[nil]'}"
395
+
396
+ # Only include non-nil values in the hash for Redis
397
+ hsh[field] = prepared unless prepared.nil?
340
398
  hsh
341
399
  end
342
400
  end
@@ -403,18 +461,47 @@ module Familia
403
461
  def serialize_value(val)
404
462
  prepared = Familia.distinguisher(val, strict_values: false)
405
463
 
406
- if prepared.nil? && val.respond_to?(dump_method)
407
- prepared = val.send(dump_method)
464
+ # If the distinguisher returns nil, try using the dump_method but only
465
+ # use JSON serialization for complex types that need it.
466
+ if prepared.nil? && (val.is_a?(Hash) || val.is_a?(Array))
467
+ prepared = val.respond_to?(dump_method) ? val.send(dump_method) : JSON.dump(val)
408
468
  end
409
469
 
470
+ # If both the distinguisher and dump_method return nil, log an error
410
471
  if prepared.nil?
411
- Familia.ld "[#{self.class}#serialize_value] nil returned for #{self.class}##{name}"
472
+ Familia.ld "[#{self.class}#serialize_value] nil returned for #{self.class}"
412
473
  end
413
474
 
414
475
  prepared
415
476
  end
416
477
  alias to_redis serialize_value
417
478
 
479
+ # Converts a Redis string value back to its original Ruby type
480
+ #
481
+ # This method attempts to deserialize JSON strings back to their original
482
+ # Hash or Array types. Simple string values are returned as-is.
483
+ #
484
+ # @param val [String] The string value from Redis to deserialize
485
+ # @param symbolize_keys [Boolean] Whether to symbolize hash keys (default: true for compatibility)
486
+ # @return [Object] The deserialized value (Hash, Array, or original string)
487
+ #
488
+ def deserialize_value(val, symbolize: true)
489
+ return val if val.nil? || val == ""
490
+
491
+ # Try to parse as JSON first for complex types
492
+ begin
493
+ parsed = JSON.parse(val, symbolize_names: symbolize)
494
+ # Only return parsed value if it's a complex type (Hash/Array)
495
+ # Simple values should remain as strings
496
+ return parsed if parsed.is_a?(Hash) || parsed.is_a?(Array)
497
+ rescue JSON::ParserError
498
+ # Not valid JSON, return as-is
499
+ end
500
+
501
+ val
502
+ end
503
+ alias from_redis deserialize_value
504
+
418
505
  end
419
506
  # End of Serialization module
420
507
 
@@ -474,6 +561,11 @@ module Familia
474
561
  def tuple
475
562
  [successful?, results]
476
563
  end
564
+ alias to_a tuple
565
+
566
+ def to_h
567
+ { success: successful?, results: results }
568
+ end
477
569
 
478
570
  # Convenient method to check if the commit was successful.
479
571
  #
@@ -72,7 +72,14 @@ module Familia
72
72
  end
73
73
 
74
74
  # Instance initialization
75
- # This method sets up the object's state, including Redis-related data
75
+ # This method sets up the object's state, including Redis-related data.
76
+ #
77
+ # Usage:
78
+ #
79
+ # Session.new("abc123", "user456") # positional (brittle)
80
+ # Session.new(sessid: "abc123", custid: "user456") # hash (robust)
81
+ # Session.new({sessid: "abc123", custid: "user456"}) # legacy hash (robust)
82
+ #
76
83
  def initialize(*args, **kwargs)
77
84
  Familia.ld "[Horreum] Initializing #{self.class}"
78
85
  initialize_relatives
@@ -81,7 +88,7 @@ module Familia
81
88
  # that every object horreum class has a unique identifier field. Ideally
82
89
  # this logic would live somewhere else b/c we only need to call it once
83
90
  # per class definition. Here it gets called every time an instance is
84
- # instantiated/
91
+ # instantiated.
85
92
  unless self.class.fields.include?(:key)
86
93
  # Define the 'key' field for this class
87
94
  # This approach allows flexibility in how identifiers are generated
@@ -89,25 +96,45 @@ module Familia
89
96
  self.class.field :key
90
97
  end
91
98
 
92
- # If there are positional arguments, they should be the field
93
- # values in the order they were defined in the implementing class.
99
+ # Detect if first argument is a hash (legacy support)
100
+ if args.size == 1 && args.first.is_a?(Hash) && kwargs.empty?
101
+ kwargs = args.first
102
+ args = []
103
+ end
104
+
105
+ # Initialize object with arguments using one of three strategies:
94
106
  #
95
- # Handle keyword arguments
96
- # Fields is a known quantity, so we iterate over it rather than kwargs
97
- # to ensure that we only set fields that are defined in the class. And
98
- # to avoid runaways.
99
- if args.any?
100
- initialize_with_positional_args(*args)
101
- elsif kwargs.any?
107
+ # 1. **Keyword Arguments** (Recommended): Order-independent field assignment
108
+ # Example: Customer.new(name: "John", email: "john@example.com")
109
+ # - Robust against field reordering
110
+ # - Self-documenting
111
+ # - Only sets provided fields
112
+ #
113
+ # 2. **Positional Arguments** (Legacy): Field assignment by definition order
114
+ # Example: Customer.new("john@example.com", "password123")
115
+ # - Brittle: breaks if field order changes
116
+ # - Compact syntax
117
+ # - Maps to fields in class definition order
118
+ #
119
+ # 3. **No Arguments**: Object created with all fields as nil
120
+ # - Minimal memory footprint in Redis
121
+ # - Fields set on-demand via accessors or save()
122
+ # - Avoids default value conflicts with nil-skipping serialization
123
+ #
124
+ # Note: We iterate over self.class.fields (not kwargs) to ensure only
125
+ # defined fields are set, preventing typos from creating undefined attributes.
126
+ #
127
+ if kwargs.any?
102
128
  initialize_with_keyword_args(**kwargs)
129
+ elsif args.any?
130
+ initialize_with_positional_args(*args)
103
131
  else
104
132
  Familia.ld "[Horreum] #{self.class} initialized with no arguments"
105
- # If there are no arguments, we need to set the default values
106
- # for the fields. This is done in the order they were defined.
107
- # self.class.fields.each do |field|
108
- # default = self.class.defaults[field]
109
- # send(:"#{field}=", default) if default
110
- # end
133
+ # Default values are intentionally NOT set here to:
134
+ # - Maintain Redis memory efficiency (only store non-nil values)
135
+ # - Avoid conflicts with nil-skipping serialization logic
136
+ # - Preserve consistent exists? behavior (empty vs default-filled objects)
137
+ # - Keep initialization lightweight for unused fields
111
138
  end
112
139
 
113
140
  # Implementing classes can define an init method to do any
@@ -203,6 +230,12 @@ module Familia
203
230
  end
204
231
  private :initialize_with_keyword_args
205
232
 
233
+ def initialize_with_keyword_args_from_redis(**fields)
234
+ # Deserialize Redis string values back to their original types
235
+ deserialized_fields = fields.transform_values { |value| deserialize_value(value) }
236
+ initialize_with_keyword_args(**deserialized_fields)
237
+ end
238
+
206
239
  # A thin wrapper around the private initialize method that accepts a field
207
240
  # hash and refreshes the existing object.
208
241
  #
@@ -218,7 +251,7 @@ module Familia
218
251
  # @return [Array] The list of field names that were updated.
219
252
  def optimistic_refresh(**fields)
220
253
  Familia.ld "[optimistic_refresh] #{self.class} #{rediskey} #{fields.keys}"
221
- initialize_with_keyword_args(**fields)
254
+ initialize_with_keyword_args_from_redis(**fields)
222
255
  end
223
256
 
224
257
  # Determines the unique identifier for the instance
@@ -247,6 +280,19 @@ module Familia
247
280
 
248
281
  unique_id
249
282
  end
283
+
284
+ # The principle is: **If Familia objects have `to_s`, then they should work
285
+ # everywhere strings are expected**, including as Redis hash field names.
286
+ def to_s
287
+ # Enable polymorphic string usage for Familia objects
288
+ # This allows passing Familia objects directly where strings are expected
289
+ # without requiring explicit .identifier calls
290
+ identifier.to_s
291
+ rescue => e
292
+ # Fallback for cases where identifier might fail
293
+ Familia.ld "[#{self.class}#to_s] Failed to get identifier: #{e.message}"
294
+ "#<#{self.class}:0x#{object_id.to_s(16)}>"
295
+ end
250
296
  end
251
297
  end
252
298
 
@@ -16,7 +16,7 @@ module Familia
16
16
  # +return+ [Integer] Returns 1 if the field is new and added, 0 if the
17
17
  # field already existed and the value was updated.
18
18
  def []=(field, val)
19
- ret = redis.hset rediskey, field, serialize_value(val)
19
+ ret = redis.hset rediskey, field.to_s, serialize_value(val)
20
20
  update_expiration
21
21
  ret
22
22
  rescue TypeError => e
@@ -31,12 +31,12 @@ module Familia
31
31
  alias store []=
32
32
 
33
33
  def [](field)
34
- deserialize_value redis.hget(rediskey, field)
34
+ deserialize_value redis.hget(rediskey, field.to_s)
35
35
  end
36
36
  alias get []
37
37
 
38
38
  def fetch(field, default = nil)
39
- ret = self[field]
39
+ ret = self[field.to_s]
40
40
  if ret.nil?
41
41
  raise IndexError, "No such index for: #{field}" if default.nil?
42
42
 
@@ -62,7 +62,7 @@ module Familia
62
62
  alias all hgetall
63
63
 
64
64
  def key?(field)
65
- redis.hexists rediskey, field
65
+ redis.hexists rediskey, field.to_s
66
66
  end
67
67
  alias has_key? key?
68
68
  alias include? key?
@@ -72,12 +72,12 @@ module Familia
72
72
  # @param field [String] The field to remove
73
73
  # @return [Integer] The number of fields that were removed (0 or 1)
74
74
  def remove_field(field)
75
- redis.hdel rediskey, field
75
+ redis.hdel rediskey, field.to_s
76
76
  end
77
77
  alias remove remove_field # deprecated
78
78
 
79
79
  def increment(field, by = 1)
80
- redis.hincrby(rediskey, field, by).to_i
80
+ redis.hincrby(rediskey, field.to_s, by).to_i
81
81
  end
82
82
  alias incr increment
83
83
  alias incrby increment
@@ -100,7 +100,8 @@ module Familia
100
100
  alias merge! update
101
101
 
102
102
  def values_at *fields
103
- elements = redis.hmget(rediskey, *fields.flatten.compact)
103
+ string_fields = fields.flatten.compact.map(&:to_s)
104
+ elements = redis.hmget(rediskey, *string_fields)
104
105
  deserialize_values(*elements)
105
106
  end
106
107
 
@@ -2,7 +2,6 @@
2
2
 
3
3
  module Familia
4
4
  class SortedSet < RedisType
5
-
6
5
  # Returns the number of elements in the sorted set
7
6
  # @return [Integer] number of elements
8
7
  def element_count
data/lib/familia.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # rubocop:disable all
2
2
  # frozen_string_literal: true
3
3
 
4
+ require 'json'
4
5
  require 'redis'
5
6
  require 'uri/redis'
6
7
 
@@ -0,0 +1,159 @@
1
+ require_relative '../lib/familia'
2
+ require_relative './test_helpers'
3
+
4
+ Familia.debug = false
5
+
6
+ @identifier = 'tryouts-28@onetimesecret.com'
7
+ @customer = Customer.new @identifier
8
+
9
+ ## Basic save functionality works
10
+ @customer.name = 'John Doe'
11
+ @customer.save
12
+ #=> true
13
+
14
+ ## to_h returns field hash with all Customer fields
15
+ @customer.to_h.class
16
+ #=> Hash
17
+
18
+ ## to_h includes the fields we set (using symbol keys)
19
+ @customer.to_h[:name]
20
+ #=> "John Doe"
21
+
22
+ ## to_h includes the key field (using symbol keys)
23
+ @customer.to_h[:key]
24
+ #=> "tryouts-28@onetimesecret.com"
25
+
26
+ ## to_a returns field array in definition order
27
+ @customer.to_a.class
28
+ #=> Array
29
+
30
+ ## to_a includes values in field order (name should be at index 5)
31
+ @customer.to_a[5]
32
+ #=> "John Doe"
33
+
34
+ ## batch_update can update multiple fields atomically, to_h
35
+ @result = @customer.batch_update(name: 'Jane Smith', email: 'jane@example.com')
36
+ @result.to_h
37
+ #=> {:success=>true, :results=>[0, 0]}
38
+
39
+ ## batch_update returns successful result, successful?
40
+ @result.successful?
41
+ #=> true
42
+
43
+ ## batch_update returns successful result, tuple
44
+ @result.tuple
45
+ #=> [true, [0, 0]]
46
+
47
+ ## batch_update returns successful result, to_a
48
+ @result.to_a
49
+ #=> [true, [0, 0]]
50
+
51
+ ## batch_update updates object fields in memory, confirm fields changed
52
+ [@customer.name, @customer.email]
53
+ #=> ["Jane Smith", "jane@example.com"]
54
+
55
+ ## batch_update persists to Redis
56
+ @customer.refresh!
57
+ [@customer.name, @customer.email]
58
+ #=> ["Jane Smith", "jane@example.com"]
59
+
60
+ ## batch_update with update_expiration: false works
61
+ @customer.batch_update(name: 'Bob Jones', update_expiration: false)
62
+ @customer.refresh!
63
+ @customer.name
64
+ #=> "Bob Jones"
65
+
66
+ ## apply_fields updates object in memory only (1 of 2)
67
+ @customer.apply_fields(name: 'Memory Only', email: 'memory@test.com')
68
+ [@customer.name, @customer.email]
69
+ #=> ["Memory Only", "memory@test.com"]
70
+
71
+ ## apply_fields doesn't persist to Redis (2 of 2)
72
+ @customer.refresh!
73
+ [@customer.name, @customer.email]
74
+ #=> ["Bob Jones", "jane@example.com"]
75
+
76
+ ## serialize_value handles strings
77
+ @customer.serialize_value("test string")
78
+ #=> "test string"
79
+
80
+ ## serialize_value handles numbers
81
+ @customer.serialize_value(42)
82
+ #=> "42"
83
+
84
+ ## serialize_value handles hashes as JSON
85
+ @customer.serialize_value({key: 'value', num: 123})
86
+ #=> "{\"key\":\"value\",\"num\":123}"
87
+
88
+ ## serialize_value handles arrays as JSON
89
+ @customer.serialize_value([1, 2, 'three'])
90
+ #=> "[1,2,\"three\"]"
91
+
92
+ ## deserialize_value handles JSON strings back to objects
93
+ @customer.deserialize_value('{"key":"value","num":123}')
94
+ #=> {:key=>"value", :num=>123}
95
+
96
+ ## deserialize_value handles JSON arrays
97
+ @customer.deserialize_value('[1,2,"three"]')
98
+ #=> [1, 2, "three"]
99
+
100
+ ## deserialize_value handles plain strings
101
+ @customer.deserialize_value('plain string')
102
+ #=> "plain string"
103
+
104
+ ## transaction method works with block
105
+ result = @customer.transaction do |conn|
106
+ conn.hset @customer.rediskey, 'temp_field', 'temp_value'
107
+ conn.hset @customer.rediskey, 'another_field', 'another_value'
108
+ end
109
+ result.size
110
+ #=> 2
111
+
112
+ ## refresh! reloads from Redis
113
+ @customer.refresh!
114
+ @customer.hget('temp_field')
115
+ #=> "temp_value"
116
+
117
+ ## Empty batch_update still works
118
+ result = @customer.batch_update()
119
+ result.successful?
120
+ #=> true
121
+
122
+ ## destroy! removes object from Redis (1 of 2)
123
+ @customer.destroy!
124
+ #=> true
125
+
126
+ ## After destroy!, Redis key no longer exists (2 of 2)
127
+ @customer.exists?
128
+ #=> false
129
+
130
+ ## destroy! removes object from Redis, not the in-memory object (2 of 2)
131
+ @customer.refresh!
132
+ @customer.name
133
+ #=> "Bob Jones"
134
+
135
+ ## clear_fields! removes the in-memory object fields
136
+ @customer.clear_fields!
137
+ @customer.name
138
+ #=> nil
139
+
140
+ ## Fresh customer for testing new field creation
141
+ @fresh_customer = Customer.new 'fresh-customer@test.com'
142
+ @fresh_customer.class
143
+ #=> Customer
144
+
145
+ ## batch_update with new fields returns [1, 1] for new field creation
146
+ @fresh_customer.remove_field('role')
147
+ @fresh_customer.remove_field('planid')
148
+ @fresh_result = @fresh_customer.batch_update(role: 'admin', planid: 'premium')
149
+ @fresh_result.to_h
150
+ #=> {:success=>true, :results=>[1, 1]}
151
+
152
+ ## Fresh customer fields are set correctly
153
+ [@fresh_customer.role, @fresh_customer.planid]
154
+ #=> ["admin", "premium"]
155
+
156
+ ## Fresh customer changes persist to Redis
157
+ @fresh_customer.refresh!
158
+ [@fresh_customer.role, @fresh_customer.planid]
159
+ #=> ["admin", "premium"]
@@ -0,0 +1,113 @@
1
+ require_relative '../lib/familia'
2
+ require_relative './test_helpers'
3
+
4
+ Familia.debug = false
5
+
6
+ ## Existing positional argument initialization still works
7
+ @customer1 = Customer.new 'tryouts-29@test.com', '', '', '', '', 'John Doe'
8
+ [@customer1.custid, @customer1.name]
9
+ #=> ["tryouts-29@test.com", "John Doe"]
10
+
11
+ ## Keyword argument initialization works (order independent)
12
+ @customer2 = Customer.new(name: 'Jane Smith', custid: 'jane@test.com', email: 'jane@example.com')
13
+ [@customer2.custid, @customer2.name, @customer2.email]
14
+ #=> ["jane@test.com", "Jane Smith", "jane@example.com"]
15
+
16
+ ## Keyword arguments are order independent (different order, same result)
17
+ @customer3 = Customer.new(email: 'bob@example.com', custid: 'bob@test.com', name: 'Bob Jones')
18
+ [@customer3.custid, @customer3.name, @customer3.email]
19
+ #=> ["bob@test.com", "Bob Jones", "bob@example.com"]
20
+
21
+ ## Legacy hash support (single hash argument)
22
+ @customer4 = Customer.new({custid: 'legacy@test.com', name: 'Legacy User', role: 'admin'})
23
+ [@customer4.custid, @customer4.name, @customer4.role]
24
+ #=> ["legacy@test.com", "Legacy User", "admin"]
25
+
26
+ ## Empty initialization works
27
+ @customer5 = Customer.new
28
+ @customer5.class
29
+ #=> Customer
30
+
31
+ ## Keyword args with save and retrieval
32
+ @customer6 = Customer.new(custid: 'save-test@test.com', name: 'Save Test', email: 'save@example.com')
33
+ @customer6.save
34
+ #=> true
35
+
36
+ ## Saved customer can be retrieved with correct values
37
+ @customer6.refresh!
38
+ [@customer6.custid, @customer6.name, @customer6.email]
39
+ #=> ["save-test@test.com", "Save Test", "save@example.com"]
40
+
41
+ ## Keyword initialization sets key field correctly
42
+ @customer6.key
43
+ #=> "save-test@test.com"
44
+
45
+ ## Mixed valid and nil values in keyword args (nil values stay nil)
46
+ @customer7 = Customer.new(custid: 'mixed@test.com', name: 'Mixed Test', email: nil, role: 'user')
47
+ [@customer7.custid, @customer7.name, @customer7.email, @customer7.role]
48
+ #=> ["mixed@test.com", "Mixed Test", nil, "user"]
49
+
50
+ ## to_h works correctly with keyword-initialized objects
51
+ @customer2.to_h[:name]
52
+ #=> "Jane Smith"
53
+
54
+ ## to_a works correctly with keyword-initialized objects
55
+ @customer2.to_a[5] # name field should be at index 5
56
+ #=> "Jane Smith"
57
+
58
+ ## Session has limited fields (only sessid defined)
59
+ @session1 = Session.new('sess123')
60
+ @session1.sessid
61
+ #=> "sess123"
62
+
63
+ ## Session with keyword args works
64
+ @session2 = Session.new(sessid: 'keyword-sess')
65
+ @session2.sessid
66
+ #=> "keyword-sess"
67
+
68
+ ## Session with legacy hash
69
+ @session3 = Session.new({sessid: 'hash-sess'})
70
+ @session3.sessid
71
+ #=> "hash-sess"
72
+
73
+ ## CustomDomain with keyword initialization
74
+ @domain1 = CustomDomain.new(display_domain: 'api.example.com', custid: 'domain-test@test.com')
75
+ [@domain1.display_domain, @domain1.custid]
76
+ #=> ["api.example.com", "domain-test@test.com"]
77
+
78
+ ## CustomDomain still works with positional args
79
+ @domain2 = CustomDomain.new('web.example.com', 'positional@test.com')
80
+ [@domain2.display_domain, @domain2.custid]
81
+ #=> ["web.example.com", "positional@test.com"]
82
+
83
+ ## Keyword initialization can skip fields (they remain nil/empty)
84
+ @partial = Customer.new(custid: 'partial@test.com', name: 'Partial User')
85
+ [@partial.custid, @partial.name, @partial.email]
86
+ #=> ["partial@test.com", "Partial User", nil]
87
+
88
+ ## Complex initialization with save/refresh cycle
89
+ @complex = Customer.new(
90
+ custid: 'complex@test.com',
91
+ name: 'Complex User',
92
+ email: 'complex@example.com',
93
+ role: 'admin',
94
+ verified: true
95
+ )
96
+ @complex.save
97
+ @complex.refresh!
98
+ [@complex.custid, @complex.name, @complex.role, @complex.verified]
99
+ #=> ["complex@test.com", "Complex User", "admin", "true"]
100
+
101
+ ## Clean up saved test objects
102
+ [@customer6, @complex].map(&:delete!)
103
+ #=> [true, true]
104
+
105
+ ## "Cleaning up" test objects that were never saved returns false.
106
+ @customer1.save
107
+ ret = [
108
+ @customer1, @customer2, @customer3, @customer4, @customer6, @customer7,
109
+ @session1, @session2, @session3,
110
+ @domain1, @domain2,
111
+ @partial, @complex
112
+ ].map(&:destroy!)
113
+ #=> [true, false, false, false, false, false, false, false, false, false, false, false, false]
@@ -0,0 +1,86 @@
1
+ # try/91_json_bug_try.rb
2
+
3
+ require_relative '../lib/familia'
4
+ require_relative './test_helpers'
5
+
6
+ Familia.debug = false
7
+
8
+ # Define a simple model with fields that should handle JSON data
9
+ class JsonTest < Familia::Horreum
10
+ identifier :id
11
+ field :id
12
+ field :config # This should be able to store Hash objects
13
+ field :tags # This should be able to store Array objects
14
+ field :simple # This should store simple strings as-is
15
+ end
16
+
17
+ # Create an instance with JSON data
18
+ @test_obj = JsonTest.new
19
+ @test_obj.id = "json_test_1"
20
+
21
+ ## Test 1: Store a Hash - should serialize to JSON automatically
22
+ @test_obj.config = { theme: "dark", notifications: true, settings: { volume: 80 } }
23
+ @test_obj.config.class
24
+ #=> Hash
25
+
26
+ ## Test 2: Store an Array - should serialize to JSON automatically
27
+ @test_obj.tags = ["ruby", "redis", "json", "familia"]
28
+ @test_obj.tags.class
29
+ #=> Array
30
+
31
+ ## Test 3: Store a simple string - should remain as string
32
+ @test_obj.simple = "just a string"
33
+ @test_obj.simple.class
34
+ #=> String
35
+
36
+ ## Save the object - this should call serialize_value and use to_json
37
+ @test_obj.save
38
+ #=> true
39
+
40
+ ## Verify what's actually stored in Redis (raw)
41
+ raw_data = @test_obj.hgetall
42
+ p [:plop, @test_obj]
43
+ puts "Raw Redis data:"
44
+ raw_data
45
+ #=> {"id"=>"json_test_1", "config"=>"{\"theme\":\"dark\",\"notifications\":true,\"settings\":{\"volume\":80}}", "tags"=>"[\"ruby\",\"redis\",\"json\",\"familia\"]", "simple"=>"just a string", "key"=>"json_test_1"}
46
+
47
+ ## BUG: After refresh, JSON data comes back as strings instead of parsed objects
48
+ @test_obj.refresh!
49
+
50
+ ## Test 4: Hash should be deserialized back to Hash
51
+ puts "Config after refresh:"
52
+ puts @test_obj.config.inspect
53
+ puts "Config class: "
54
+ [@test_obj.config.class, @test_obj.config.inspect]
55
+ #=> [Hash, "{:theme=>\"dark\", :notifications=>true, :settings=>{:volume=>80}}"]
56
+
57
+ ## Test 5: Array should be deserialized back to Array
58
+ puts "Tags after refresh:"
59
+ puts @test_obj.tags.inspect
60
+ puts "Tags class: #{@test_obj.tags.class}"
61
+ @test_obj.tags.inspect
62
+ @test_obj.tags.class
63
+ #=> ["ruby", "redis", "json", "familia"]
64
+ #=> Array
65
+
66
+ ## Test 6: Simple string should remain a string (this works correctly)
67
+ puts "Simple after refresh:"
68
+ puts @test_obj.simple.inspect
69
+ puts "Simple class: #{@test_obj.simple.class}"
70
+ @test_obj.simple.inspect
71
+ @test_obj.simple.class
72
+ #=> "just a string"
73
+ #=> String
74
+
75
+ ## Demonstrate the asymmetry:
76
+ puts "\n=== ASYMMETRY DEMONSTRATION ==="
77
+ puts "Before save: config is #{@test_obj.config.class}"
78
+ @test_obj.config = { example: "data" }
79
+ puts "After assignment: config is #{@test_obj.config.class}"
80
+ @test_obj.save
81
+ puts "After save: config is still #{@test_obj.config.class}"
82
+ @test_obj.refresh!
83
+ puts "After refresh: config is now #{@test_obj.config.class}!"
84
+
85
+ ## Clean up
86
+ @test_obj.destroy!
@@ -0,0 +1,96 @@
1
+ require_relative '../lib/familia'
2
+ require_relative './test_helpers'
3
+
4
+ Familia.debug = false
5
+
6
+ # Test the updated deserialize_value method
7
+ class SymbolizeTest < Familia::Horreum
8
+ identifier :id
9
+ field :id
10
+ field :config
11
+ end
12
+
13
+ @test_obj = SymbolizeTest.new
14
+ @test_obj.id = "symbolize_test_1"
15
+
16
+ ## Test with a hash containing string keys
17
+ @test_hash = { "name" => "John", "age" => 30, "nested" => { "theme" => "dark" } }
18
+ @test_obj.config = @test_hash
19
+ @test_obj.save
20
+
21
+ ## Original hash has string keys
22
+ @test_hash.keys
23
+ #=> ["name", "age", "nested"]
24
+
25
+ ## After save and refresh, default behavior uses symbol keys
26
+ @test_obj.refresh!
27
+ @test_obj.config.keys
28
+ #=> [:name, :age, :nested]
29
+
30
+ ## Nested hash also has symbol keys
31
+ @test_obj.config[:nested].keys
32
+ #=> [:theme]
33
+
34
+ ## Get raw JSON from Redis
35
+ @raw_json = @test_obj.hget('config')
36
+ @raw_json.class
37
+ #=> String
38
+
39
+ ## deserialize_value with default symbolize: true returns symbol keys
40
+ @symbol_result = @test_obj.deserialize_value(@raw_json)
41
+ @symbol_result.keys
42
+ #=> [:name, :age, :nested]
43
+
44
+ ## Nested hash in symbol result also has symbol keys
45
+ @symbol_result[:nested].keys
46
+ #=> [:theme]
47
+
48
+ ## deserialize_value with symbolize: false returns string keys
49
+ @string_result = @test_obj.deserialize_value(@raw_json, symbolize: false)
50
+ @string_result.keys
51
+ #=> ["name", "age", "nested"]
52
+
53
+ ## Nested hash in string result also has string keys
54
+ @string_result["nested"].keys
55
+ #=> ["theme"]
56
+
57
+ ## Values are preserved correctly in both cases
58
+ @symbol_result[:name]
59
+ #=> "John"
60
+
61
+ @string_result["name"]
62
+ #=> "John"
63
+
64
+ ## Arrays are handled correctly too
65
+ @test_obj.config = [{ "item" => "value" }, "string", 123]
66
+ @test_obj.save
67
+ @array_json = @test_obj.hget('config')
68
+ #=> "[{\"item\":\"value\"},\"string\",123]"
69
+
70
+ ## Array with symbolize: true converts hash keys to symbols
71
+ @symbol_array = @test_obj.deserialize_value(@array_json)
72
+ @symbol_array[0].keys
73
+ #=> [:item]
74
+
75
+ ## Array with symbolize: false keeps hash keys as strings
76
+ @string_array = @test_obj.deserialize_value(@array_json, symbolize: false)
77
+ @string_array[0].keys
78
+ #=> ["item"]
79
+
80
+ ## Non-hash/array values are returned as-is
81
+ @test_obj.deserialize_value('"just a string"')
82
+ #=> "\"just a string\""
83
+
84
+ ## Non-hash/array values are returned as-is
85
+ @test_obj.deserialize_value('just a string')
86
+ #=> "just a string"
87
+
88
+ @test_obj.deserialize_value('42')
89
+ #=> "42"
90
+
91
+ ## Invalid JSON returns original string
92
+ @test_obj.deserialize_value('invalid json')
93
+ #=> "invalid json"
94
+
95
+ ## Clean up
96
+ @test_obj.destroy!
@@ -0,0 +1,154 @@
1
+ # try/93_string_coercion_try.rb
2
+
3
+ require_relative '../lib/familia'
4
+ require_relative './test_helpers'
5
+
6
+ Familia.debug = false
7
+
8
+ # Error handling: object without proper identifier setup
9
+ class ::BadIdentifierTest < Familia::Horreum
10
+ # No identifier method defined - should cause issues
11
+ end
12
+
13
+ @bad_obj = ::BadIdentifierTest.new
14
+
15
+ # Test polymorphic string usage for Familia objects
16
+ @customer_id = 'customer-string-coercion-test'
17
+ @customer = Customer.new(@customer_id)
18
+ @customer.name = 'John Doe'
19
+ @customer.planid = 'premium'
20
+ @customer.save
21
+
22
+ @session_id = 'session-string-coercion-test'
23
+ @session = Session.new(@session_id)
24
+ @session.custid = @customer_id
25
+ @session.useragent = 'Test Browser'
26
+ @session.save
27
+
28
+ ## Complex identifier test with array-based identifier
29
+ @bone = Bone.new
30
+ @bone.token = 'test_token'
31
+ @bone.name = 'test_name'
32
+
33
+
34
+ ## Basic to_s functionality returns identifier
35
+ @customer.to_s
36
+ #=> @customer_id
37
+
38
+ ## String interpolation works seamlessly
39
+ "Customer: #{@customer}"
40
+ #=> "Customer: #{@customer_id}"
41
+
42
+ ## Explicit identifier call returns same value
43
+ @customer.to_s == @customer.identifier
44
+ #=> true
45
+
46
+ ## Session to_s works with generated identifier
47
+ @session.to_s
48
+ #=> @session_id
49
+
50
+ ## Method accepting string parameter works with Familia object
51
+ def lookup_by_id(id_string)
52
+ id_string.to_s.upcase
53
+ end
54
+
55
+ lookup_by_id(@customer)
56
+ #=> @customer_id.upcase
57
+
58
+ ## Hash key assignment using Familia object (implicit string conversion)
59
+ @data = {}
60
+ @data[@customer] = 'customer_data'
61
+ @data[@customer]
62
+ #=> 'customer_data'
63
+
64
+ ## Array operations work with mixed types
65
+ @mixed_array = [@customer_id, @customer, @session]
66
+ @mixed_array.map(&:to_s)
67
+ #=> [@customer_id, @customer_id, @session_id]
68
+
69
+ ## String comparison works
70
+ @customer.to_s == @customer_id
71
+ #=> true
72
+
73
+ ## Join operations work seamlessly
74
+ [@customer, 'separator', @session].join(':')
75
+ #=> "#{@customer_id}:separator:#{@session_id}"
76
+
77
+ ## Case statement works with string matching
78
+ def classify_id(obj)
79
+ case obj.to_s
80
+ when /customer/
81
+ 'customer_type'
82
+ when /session/
83
+ 'session_type'
84
+ else
85
+ 'unknown_type'
86
+ end
87
+ end
88
+
89
+ classify_id(@customer)
90
+ #=> 'customer_type'
91
+
92
+ classify_id(@session)
93
+ #=> 'session_type'
94
+
95
+ ## Polymorphic method accepting both strings and Familia objects
96
+ def process_identifier(id_or_object)
97
+ # Can handle both string IDs and Familia objects uniformly
98
+ processed_id = id_or_object.to_s
99
+ "processed:#{processed_id}"
100
+ end
101
+
102
+ process_identifier(@customer_id)
103
+ #=> "processed:#{@customer_id}"
104
+
105
+ process_identifier(@customer)
106
+ #=> "processed:#{@customer_id}"
107
+
108
+ ## Redis storage using object as string key
109
+ @metadata = Familia::HashKey.new 'metadata'
110
+ @metadata[@customer] = 'customer_metadata'
111
+ @metadata[@customer.to_s] # Same key access
112
+ #=> 'customer_metadata'
113
+
114
+ ## Cleanup after test
115
+ @metadata.delete!
116
+ #=> true
117
+
118
+ @customer.delete!
119
+ #=> true
120
+
121
+ @session.delete!
122
+ #=> true
123
+
124
+ ## to_s handles identifier errors gracefully
125
+ @bad_obj.to_s.include?('BadIdentifierTest')
126
+ #=> true
127
+
128
+ ## Array-based identifier works with to_s
129
+ @bone.to_s
130
+ #=> 'test_token:test_name'
131
+
132
+ ## String operations on complex identifier
133
+ @bone.to_s.split(':')
134
+ #=> ['test_token', 'test_name']
135
+
136
+ ## Cleanup a key that does not exist
137
+ @bone.delete!
138
+ #=> false
139
+
140
+ ## Cleanup a key that exists
141
+ @bone.save
142
+ @bone.delete!
143
+ #=> true
144
+
145
+ ## Performance consideration: to_s caching behavior
146
+ @customer2 = Customer.new('performance-test')
147
+ @first_call = @customer2.to_s
148
+ @second_call = @customer2.to_s
149
+ @first_call == @second_call
150
+ #=> true
151
+
152
+ ## Delete customer2
153
+ [@customer2.exists?, @customer2.delete!]
154
+ #=> [false, false]
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: familia
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0.pre.rc1
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Delano Mandelbaum
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-11-19 00:00:00.000000000 Z
11
+ date: 2025-05-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: redis
@@ -117,10 +117,15 @@ files:
117
117
  - try/25_redis_type_hash_try.rb
118
118
  - try/26_redis_bool_try.rb
119
119
  - try/27_redis_horreum_try.rb
120
+ - try/28_redis_horreum_serialization_try.rb
121
+ - try/29_redis_horreum_initialization_try.rb
120
122
  - try/30_familia_object_try.rb
121
123
  - try/35_feature_safedump_try.rb
122
124
  - try/40_customer_try.rb
123
125
  - try/41_customer_safedump_try.rb
126
+ - try/91_json_bug_try.rb
127
+ - try/92_symbolize_try.rb
128
+ - try/93_string_coercion_try.rb
124
129
  - try/test_helpers.rb
125
130
  homepage: https://github.com/delano/familia
126
131
  licenses: