familia 2.11.1 → 2.11.2

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: 515d7050ae261290c4e85c6757a68edaa2aae60e21e05b9a6d1fd0457d90fad5
4
- data.tar.gz: 653efc3da9a21910e7e071ae3065ab06e730334226d8f4820c0ed692fbec8453
3
+ metadata.gz: d7b0eed1c3632c68c8c4c91617830f0b15a10907da5c7af0bbc14b9cf67a5336
4
+ data.tar.gz: 4a123b805e826b8ede218675c16781f428fab6c3194968c5a3840323b949b28e
5
5
  SHA512:
6
- metadata.gz: 9e82443e034e2a2457bdc5366edcc18586d5df1f1ef4e60dc8c50d3d7431850c8899c42e15200d8ad0638455f13f2024a357b5e904f424fc9315312194329cce
7
- data.tar.gz: 5eed6b2a2017510bac669b43602a1960b0719fa839eb8e61114be27df7010ea4d90eb73abdefc202b86e6c05d0e9dc674087e90e8c828dd649aaad4c446d38a4
6
+ metadata.gz: 6c245370d354eaa0d8af0b0994cd44ea10f8dec3b260c9750d3cceb66563c51ee8f4390062429c8aced6cd578d919c7a806a0eef2e5be0f9d90bbc00dcf87b06
7
+ data.tar.gz: a824edcd1af5d3535168600d90a3e189b0203c884fe17c7382cafb22ebc6c19a36c68011a468e9dd8b16fed386b902eac16709625004f73b7f4085b0ae0a601e
@@ -53,7 +53,7 @@ jobs:
53
53
  bundler-cache: true
54
54
 
55
55
  - name: Setup tmate session
56
- uses: mxschmitt/action-tmate@7b6a61a73bbb9793cb80ad69b8dd8ac19261834c # v3
56
+ uses: mxschmitt/action-tmate@35b54afac29c97fb54faba5b513f8fbd1882f113 # v3
57
57
  if: ${{ github.event_name == 'workflow_dispatch' && inputs.debug_enabled }}
58
58
  with:
59
59
  detached: true
@@ -158,4 +158,4 @@ jobs:
158
158
  echo "Releasing familia ${gem_version} from tag ${RELEASE_TAG}"
159
159
 
160
160
  - name: Build and push gem to RubyGems
161
- uses: rubygems/release-gem@6317d8d1f7e28c24d28f6eff169ea854948bd9f7 # v1.2.0
161
+ uses: rubygems/release-gem@052cc82692552de3ef2b81fd670e41d13cba8092 # v1.4.0
@@ -49,7 +49,7 @@ jobs:
49
49
  bundler-cache: true
50
50
 
51
51
  - name: Setup tmate session
52
- uses: mxschmitt/action-tmate@7b6a61a73bbb9793cb80ad69b8dd8ac19261834c # v3
52
+ uses: mxschmitt/action-tmate@35b54afac29c97fb54faba5b513f8fbd1882f113 # v3
53
53
  if: ${{ github.event_name == 'workflow_dispatch' && inputs.debug_enabled }}
54
54
  with:
55
55
  detached: true
data/CHANGELOG.rst CHANGED
@@ -7,6 +7,40 @@ The format is based on `Keep a Changelog <https://keepachangelog.com/en/1.1.0/>`
7
7
 
8
8
  <!--scriv-insert-here-->
9
9
 
10
+ .. _changelog-2.11.2:
11
+
12
+ 2.11.2 — 2026-07-05
13
+ ===================
14
+
15
+
16
+ Changed
17
+ -------
18
+
19
+ - ``to_h_for_storage`` omits nil fields; every write path removes a field that has
20
+ become nil. ``save``/``commit_fields`` do a full overwrite (the stored hash
21
+ matches the in-memory object, so a field nil in memory is deleted);
22
+ ``save_fields``/``multi_field_update``/``multi_field_fast_write`` delete a named
23
+ field passed as nil. ``to_h`` still returns every declared field, nils included.
24
+
25
+ - Claim caveat: a full ``save``/``commit_fields`` of a stale copy clears a field
26
+ another writer claimed via ``HSETNX``. To claim and update without disturbing
27
+ it, use the targeted writers or ``refresh!`` first.
28
+
29
+ Fixed
30
+ -----
31
+
32
+ - Nil-valued fields are no longer stored as the JSON string ``"null"``. Because a
33
+ hash has no native NULL, this left declared fields perpetually present, breaking
34
+ ``HSETNX``/``HEXISTS`` atomic-claim patterns and wasting memory. Nil fields are
35
+ now omitted, and clearing a field to nil removes it from storage (``HDEL``), so
36
+ absence again means "no value". No migration required: stale ``"null"`` values
37
+ are cleaned up on the next save and already decode back to ``nil`` on read.
38
+
39
+ AI Assistance
40
+ -------------
41
+
42
+ - Investigation, implementation, and tryouts coverage produced with Claude Code.
43
+
10
44
  .. _changelog-2.11.1:
11
45
 
12
46
  2.11.1 — 2026-07-04
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- familia (2.11.1)
4
+ familia (2.11.2)
5
5
  concurrent-ruby (~> 1.3)
6
6
  connection_pool (>= 2.4, < 4.0)
7
7
  csv (~> 3.3)
@@ -9,7 +9,7 @@ PATH
9
9
  logger (~> 1.7)
10
10
  oj (~> 3.16)
11
11
  redis (>= 5.0, < 6.0)
12
- stringio (~> 3.1.1)
12
+ stringio (>= 3.1.1, < 3.3.0)
13
13
  uri-valkey (~> 1.4)
14
14
 
15
15
  GEM
@@ -166,7 +166,7 @@ GEM
166
166
  ruby-progressbar (1.13.0)
167
167
  simpleidn (0.2.3)
168
168
  stackprof (0.2.28)
169
- stringio (3.1.9)
169
+ stringio (3.2.0)
170
170
  timecop (0.9.11)
171
171
  tryouts (3.7.1)
172
172
  concurrent-ruby (~> 1.0, < 2)
data/familia.gemspec CHANGED
@@ -26,7 +26,7 @@ Gem::Specification.new do |spec|
26
26
  spec.add_dependency 'logger', '~> 1.7'
27
27
  spec.add_dependency 'oj', '~> 3.16'
28
28
  spec.add_dependency 'redis', '>= 5.0', '< 6.0'
29
- spec.add_dependency 'stringio', '~> 3.1.1'
29
+ spec.add_dependency 'stringio', '>= 3.1.1', '< 3.3.0'
30
30
  spec.add_dependency 'uri-valkey', '~> 1.4'
31
31
 
32
32
  spec.metadata['rubygems_mfa_required'] = 'true'
@@ -157,8 +157,14 @@ module Familia
157
157
  #
158
158
  # @param hsh [Hash] Hash of field-value pairs to set
159
159
  # @return [String] 'OK' on success
160
- def hmset(hsh = {})
160
+ def hmset(hsh = nil)
161
161
  hsh ||= to_h_for_storage
162
+ # An empty hash means there is nothing to persist -- e.g. an object whose
163
+ # only non-nil field would be a Proc-derived identifier, so no declared
164
+ # field is written. HMSET with zero field/value pairs is a Redis error,
165
+ # so treat it as a successful no-op rather than crashing the save.
166
+ return 'OK' if hsh.empty?
167
+
162
168
  Familia.trace :HMSET, nil, hsh if Familia.debug?
163
169
  dbclient.hmset dbkey(suffix), hsh
164
170
  end
@@ -88,6 +88,15 @@ module Familia
88
88
  # @example Single-expression short-circuit
89
89
  # user.save && AuditLog.record(:user_updated, user.identifier)
90
90
  #
91
+ # @note This is a FULL-OVERWRITE of the object's scalar state: afterwards
92
+ # the stored hash matches the in-memory object exactly. Non-nil fields are
93
+ # written and fields that are nil in memory are removed from storage. A
94
+ # field managed out of band -- e.g. one claimed by another actor via
95
+ # HSETNX while this (possibly stale) copy still holds nil for it -- is
96
+ # therefore cleared by a full save. To update an object without disturbing
97
+ # such fields, use the targeted writers ({#save_fields},
98
+ # {#multi_field_update}, {#multi_field_fast_write}) or {#refresh!} first.
99
+ #
91
100
  # @see #save_if_not_exists! For conditional saves
92
101
  # @see #transaction For atomic operations after save
93
102
  #
@@ -319,17 +328,24 @@ module Familia
319
328
  Familia.debug "[commit_fields] Begin #{self.class} #{dbkey} #{prepared_value} (exp: #{update_expiration})"
320
329
 
321
330
  result = transaction do |_conn|
322
- # Set all fields atomically
323
- result = hmset(prepared_value)
331
+ # Set all non-nil fields atomically
332
+ hmset_result = hmset(prepared_value)
333
+
334
+ # Remove any fields cleared to nil so their prior stored value is not
335
+ # left stale (HMSET never deletes omitted fields).
336
+ remove_stale_nil_fields
324
337
 
325
338
  # Update expiration in same transaction to ensure atomicity
326
- self.update_expiration if result && update_expiration
339
+ self.update_expiration if hmset_result && update_expiration
327
340
 
328
- # Touch instances timeline so the object is visible
329
- # to list-based enumeration (instances.to_a, count, etc.)
330
- touch_instances! if result
341
+ # Touch instances timeline so the object is visible to list-based
342
+ # enumeration (instances.to_a, count, etc.). Skip it when nothing was
343
+ # persisted and no hash key exists -- otherwise the identifier is
344
+ # registered in `instances` pointing at a missing hash (see
345
+ # {#persist_to_storage}).
346
+ touch_instances! if hmset_result && !prepared_value.empty?
331
347
 
332
- result
348
+ hmset_result
333
349
  end
334
350
 
335
351
  # Clear dirty tracking after successful commit
@@ -376,10 +392,15 @@ module Familia
376
392
  Familia.trace :MULTI_FIELD_UPDATE, nil, fields.keys if Familia.debug?
377
393
 
378
394
  result = transaction do |_conn|
379
- # 1. Update all fields atomically (Redis only, no in-memory mutation)
395
+ # 1. Update all fields atomically (Redis only, no in-memory mutation).
396
+ # A nil value deletes the field rather than storing "null", so absence
397
+ # stays authoritative (see Serialization#to_h_for_storage).
380
398
  fields.each do |field, value|
381
- prepared_value = serialize_value(value)
382
- hset field, prepared_value
399
+ if value.nil?
400
+ remove_field(field)
401
+ else
402
+ hset field, serialize_value(value)
403
+ end
383
404
  end
384
405
 
385
406
  # 2. Update expiration in same transaction
@@ -434,15 +455,24 @@ module Familia
434
455
 
435
456
  Familia.trace :MULTI_FIELD_FAST_WRITE, nil, fields.keys if Familia.debug?
436
457
 
437
- # Serialize values before the transaction (read-only on instance)
458
+ # Serialize values before the transaction (read-only on instance).
459
+ # A nil value deletes the field rather than storing "null" (see
460
+ # Serialization#to_h_for_storage), so split writes from removals.
438
461
  serialized = {}
462
+ nil_fields = []
439
463
  fields.each do |field, value|
440
- serialized[field] = serialize_value(value)
464
+ if value.nil?
465
+ nil_fields << field.to_s
466
+ else
467
+ serialized[field] = serialize_value(value)
468
+ end
441
469
  end
442
470
 
443
471
  result = transaction do |_conn|
444
472
  hmset(serialized)
445
473
 
474
+ dbclient.hdel(dbkey, *nil_fields) unless nil_fields.empty?
475
+
446
476
  update_expiration if update_exp
447
477
 
448
478
  touch_instances!
@@ -478,20 +508,29 @@ module Familia
478
508
  Familia.trace :SAVE_FIELDS, nil, field_names if Familia.debug?
479
509
 
480
510
  result = transaction do |_conn|
481
- # Build hash of field names to serialized values
511
+ # Build hash of non-nil field values; collect nil'd fields for removal.
512
+ # A nil field is deleted rather than stored as "null" so that absence
513
+ # stays authoritative (see Serialization#to_h_for_storage).
482
514
  fields_hash = {}
515
+ nil_fields = []
483
516
  field_names.each do |field|
484
517
  field_sym = field.to_sym
485
518
  raise ArgumentError, "Unknown field: #{field}" unless respond_to?(field_sym)
486
519
 
487
520
  value = send(field_sym)
488
- prepared_value = serialize_value(value)
489
- fields_hash[field] = prepared_value
521
+ if value.nil?
522
+ nil_fields << field.to_s
523
+ else
524
+ fields_hash[field] = serialize_value(value)
525
+ end
490
526
  end
491
527
 
492
- # Set all fields at once using hmset
528
+ # Set all non-nil fields at once (hmset no-ops on an empty hash)
493
529
  hmset(fields_hash)
494
530
 
531
+ # Remove any nil'd fields so their prior stored value does not linger
532
+ dbclient.hdel(dbkey, *nil_fields) unless nil_fields.empty?
533
+
495
534
  # Update expiration in same transaction
496
535
  self.update_expiration if update_expiration
497
536
 
@@ -959,6 +998,46 @@ module Familia
959
998
  end
960
999
  private :prepare_for_save
961
1000
 
1001
+ # Names (as strings) of declared persistent fields whose current in-memory
1002
+ # value is nil. These are omitted from {Serialization#to_h_for_storage}, so
1003
+ # on an update their previously-stored value must be explicitly deleted.
1004
+ #
1005
+ # @return [Array<String>]
1006
+ #
1007
+ def nil_persistent_field_names
1008
+ self.class.persistent_fields.each_with_object([]) do |field, names|
1009
+ field_type = self.class.field_types[field]
1010
+ names << field.to_s if send(field_type.method_name).nil?
1011
+ end
1012
+ end
1013
+ private :nil_persistent_field_names
1014
+
1015
+ # Deletes any now-nil persistent fields from the object hash so a value
1016
+ # cleared to nil does not leave a stale entry behind. This is what keeps
1017
+ # "absent" and "nil" the same observable state after a round trip, and
1018
+ # what makes HSETNX/HEXISTS on a nil'd field behave correctly.
1019
+ #
1020
+ # Because it removes every field that is nil in memory, save/commit_fields
1021
+ # are a full-overwrite of scalar state (see {#save}). A field managed out of
1022
+ # band (e.g. an HSETNX claim) that is still nil on this in-memory copy is
1023
+ # cleared by a full save; use the targeted writers or {#refresh!} to avoid
1024
+ # that.
1025
+ #
1026
+ # Intended to run inside the save/commit transaction. HDEL of an absent
1027
+ # field is a harmless no-op, so it is safe on both the create and update
1028
+ # paths (and queues cleanly inside MULTI/EXEC).
1029
+ #
1030
+ # @return [void]
1031
+ #
1032
+ def remove_stale_nil_fields
1033
+ names = nil_persistent_field_names
1034
+ return if names.empty?
1035
+
1036
+ Familia.trace :HDEL, nil, names if Familia.debug?
1037
+ dbclient.hdel(dbkey, *names)
1038
+ end
1039
+ private :remove_stale_nil_fields
1040
+
962
1041
  # Persists the object's data to storage within a transaction.
963
1042
  #
964
1043
  # This is the primary code path that adds an object to the class-level
@@ -975,18 +1054,34 @@ module Familia
975
1054
  # @return [Object] The result of the hmset operation
976
1055
  #
977
1056
  def persist_to_storage(update_expiration)
978
- # 1. Save all fields to hashkey at once
1057
+ # 1. Save all non-nil fields to hashkey at once
979
1058
  prepared_h = to_h_for_storage
980
1059
  hmset_result = hmset(prepared_h)
981
1060
 
1061
+ # 1b. Remove any fields cleared to nil so a nil'd value does not linger
1062
+ # in storage. HMSET only sets the fields it is given; it never deletes
1063
+ # omitted ones, so an update that clears a field to nil would otherwise
1064
+ # leave the prior value behind. Harmless no-op on the create path.
1065
+ remove_stale_nil_fields
1066
+
982
1067
  # 2. Set expiration in same transaction
983
1068
  self.update_expiration if update_expiration
984
1069
 
985
1070
  # 3. Update class-level indexes
986
1071
  auto_update_class_indexes
987
1072
 
988
- # 4. Touch instances timeline (delegates to touch_instances!)
989
- touch_instances!
1073
+ # 4. Touch instances timeline (delegates to touch_instances!).
1074
+ #
1075
+ # Skip it when there is genuinely nothing stored and no hash key exists.
1076
+ # prepared_h is empty only when every declared persistent field is nil
1077
+ # AND the identifier is not itself a stored field (a Proc-derived
1078
+ # identifier) -- hmset no-op'd and no key was created. Registering the
1079
+ # identifier in `instances` in that case would leave a dangling
1080
+ # reference: the identifier would list in instances.to_a while exists?
1081
+ # (check_size: true) returns false and any load follows a dead pointer.
1082
+ # In the ubiquitous `identifier_field :id; field :id` pattern the id
1083
+ # keeps prepared_h non-empty, so this only skips the degenerate case.
1084
+ touch_instances! unless prepared_h.empty?
990
1085
 
991
1086
  hmset_result
992
1087
  end
@@ -22,7 +22,9 @@ module Familia
22
22
  # # Note: Returns actual Ruby types, not JSON strings
23
23
  #
24
24
  # @note Only loggable fields are included. Encrypted fields are excluded.
25
- # @note Nil values are excluded from the returned hash (storage optimization)
25
+ # @note Every persistent field appears, including those whose value is nil.
26
+ # This differs from {#to_h_for_storage}, which omits nil fields so that
27
+ # absence -- not a stored "null" -- represents "no value" in Valkey/Redis.
26
28
  #
27
29
  def to_h
28
30
  self.class.persistent_fields.each_with_object({}) do |field, hsh|
@@ -55,13 +57,25 @@ module Familia
55
57
  # # Note: Strings are JSON-encoded with quotes
56
58
  #
57
59
  # @note This is an internal method used by commit_fields and hmset
58
- # @note Nil values are excluded to optimize Redis storage
60
+ # @note Nil-valued fields are omitted so that field *absence* -- not a
61
+ # stored "null" -- represents "no value" in the Valkey/Redis hash. This
62
+ # is what keeps HSETNX/HEXISTS-based claim patterns usable and avoids
63
+ # materializing every declared field. Fields cleared to nil are actively
64
+ # removed from storage on save; see {Persistence#remove_stale_nil_fields}.
59
65
  #
60
66
  def to_h_for_storage
61
67
  self.class.persistent_fields.each_with_object({}) do |field, hsh|
62
68
  field_type = self.class.field_types[field]
63
69
 
64
70
  val = send(field_type.method_name)
71
+
72
+ # Omit nil fields entirely. A Valkey/Redis hash has no NULL: absence is
73
+ # the native "no value". Persisting nil as the JSON string "null" would
74
+ # make declared fields perpetually present, defeating HSETNX/HEXISTS and
75
+ # wasting memory. Round-trips are unaffected -- deserialize_value already
76
+ # returns nil for an absent field.
77
+ next if val.nil?
78
+
65
79
  prepared = serialize_value(val)
66
80
 
67
81
  if Familia.debug?
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Familia
4
4
  # Version information for the Familia
5
- VERSION = '2.11.1'.freeze
5
+ VERSION = '2.11.2'.freeze
6
6
  end
@@ -127,13 +127,13 @@ result = @partial_fresh.re_encrypt_fields!
127
127
 
128
128
  raw_token = Familia.dbclient.hget(@partial_fresh.dbkey, 'token')
129
129
  raw_secret = Familia.dbclient.hget(@partial_fresh.dbkey, 'secret')
130
- # Nil encrypted fields are stored as the JSON literal "null" by Familia's
131
- # scalar serializer -- not as an encrypted envelope. The contract we care
132
- # about here is: re_encrypt_fields! must not turn a nil field into
133
- # ciphertext. Parsing "null" yields nil, so we assert that directly.
134
- token_parsed = JSON.parse(raw_token) rescue :unparseable
135
- [result, token_parsed, JSON.parse(raw_secret)['key_version']]
136
- #=> [true, nil, 'v2']
130
+ # A nil field is not persisted at all -- in a Redis hash, absence *is* the
131
+ # representation of "no value" -- so the token field simply does not exist in
132
+ # storage (hget returns nil). The contract we care about here is: re_encrypt_fields!
133
+ # must not turn a nil field into ciphertext; an absent field trivially satisfies
134
+ # that, and is now directly observable via HEXISTS/hget being nil.
135
+ [result, raw_token.nil?, JSON.parse(raw_secret)['key_version']]
136
+ #=> [true, true, 'v2']
137
137
 
138
138
  ## is idempotent when current key is unchanged
139
139
  # Calling re_encrypt_fields! twice without rotating must succeed both times
@@ -67,10 +67,11 @@ key_parts = dbkey.split(':')
67
67
  expected_fields = @serial_test.class.persistent_fields.length
68
68
  redis_field_count = Familia.dbclient.hlen(@serial_test.dbkey)
69
69
  actual_object_fields = @serial_test.to_h.keys.length
70
- # The JSON Serializer stores all fields (including nil as "null")
71
- # Expected fields (5) >= redis count (5) >= to_h count (5, even though email is nil)
70
+ # Storage omits nil fields (absence represents "no value"), so only the single
71
+ # non-nil field (id) is written even though the class declares 5 persistent
72
+ # fields. to_h still reports every declared field, including the nil ones.
72
73
  [expected_fields, redis_field_count, actual_object_fields]
73
- #=> [5, 5, 5]
74
+ #=> [5, 1, 5]
74
75
 
75
76
  ## Memory vs persistence state consistency after save
76
77
  @consistency_obj = ConsistencyTestModel.new(id: next_test_id, name: 'Memory Test', email: 'test@example.com')
@@ -0,0 +1,160 @@
1
+ # try/integration/nil_field_absence_try.rb
2
+ #
3
+ # frozen_string_literal: true
4
+ #
5
+ # Nil-valued declared fields are ABSENT from the Valkey/Redis hash rather than
6
+ # stored as the JSON literal "null". Field absence is the native representation
7
+ # of "no value" in a hash, and it is what makes HSETNX/HEXISTS-based claim
8
+ # patterns work and keeps "unset" and "nil" the same observable state after a
9
+ # round trip.
10
+
11
+ require_relative '../support/helpers/test_helpers'
12
+
13
+ class NilAbsenceModel < Familia::Horreum
14
+ identifier_field :id
15
+ field :id
16
+ field :name
17
+ field :claimed_at # nil until atomically claimed via HSETNX
18
+ field :note
19
+ end
20
+
21
+ # Identifier derived from a NON-persistent attribute (a Proc), so every declared
22
+ # persistent field can be nil while the object still has a usable identifier.
23
+ # This exercises the degenerate "nothing to persist" path.
24
+ class ProcIdAbsenceModel < Familia::Horreum
25
+ identifier_field ->(obj) { obj.probe_id }
26
+ field :name
27
+ field :note
28
+
29
+ attr_accessor :probe_id
30
+ end
31
+
32
+ # Clean up any prior test data
33
+ begin
34
+ existing = Familia.dbclient.keys('nilabsencemodel:*')
35
+ Familia.dbclient.del(*existing) if existing.any?
36
+ rescue StandardError
37
+ # ignore cleanup errors
38
+ end
39
+
40
+ @id_counter = 0
41
+ def next_id
42
+ @id_counter += 1
43
+ "nil-absence-#{Familia.now.to_i}-#{@id_counter}"
44
+ end
45
+
46
+ ## A nil declared field is not written to storage (HEXISTS is false)
47
+ @obj = NilAbsenceModel.new(id: next_id, name: 'has name')
48
+ @obj.save
49
+ Familia.dbclient.hexists(@obj.dbkey, 'claimed_at')
50
+ #=> false
51
+
52
+ ## Only the non-nil fields are stored in the hash
53
+ @obj.hgetall.keys.sort
54
+ #=> ["id", "name"]
55
+
56
+ ## to_h still reports every declared field, including the nil ones
57
+ @obj.to_h.keys.sort
58
+ #=> ["claimed_at", "id", "name", "note"]
59
+
60
+ ## The nil field round-trips as nil after refresh (absent == nil)
61
+ @obj.refresh!
62
+ @obj.claimed_at
63
+ #=> nil
64
+
65
+ ## HSETNX succeeds on a nil declared field -- the field is genuinely absent
66
+ @claim = NilAbsenceModel.new(id: next_id, name: 'claimant')
67
+ @claim.save
68
+ @claim.hsetnx('claimed_at', Familia.now.to_i.to_s)
69
+ #=> true
70
+
71
+ ## A second HSETNX fails: the field now holds a value (first-writer-wins)
72
+ @claim.hsetnx('claimed_at', Familia.now.to_i.to_s)
73
+ #=> false
74
+
75
+ ## Clearing a previously-set field to nil and saving REMOVES it from storage
76
+ @edit = NilAbsenceModel.new(id: next_id, name: 'edit me', note: 'temporary')
77
+ @edit.save
78
+ @edit.note = nil
79
+ @edit.save
80
+ Familia.dbclient.hexists(@edit.dbkey, 'note')
81
+ #=> false
82
+
83
+ ## After clearing, HSETNX can claim the freed field again
84
+ @edit.hsetnx('note', 'reclaimed')
85
+ #=> true
86
+
87
+ ## An object whose only non-nil field is its identifier still exists
88
+ @bare = NilAbsenceModel.new(id: next_id)
89
+ @bare.save
90
+ @bare.exists?
91
+ #=> true
92
+
93
+ ## commit_fields also removes a field cleared to nil
94
+ @commit = NilAbsenceModel.new(id: next_id, name: 'keep', note: 'drop me')
95
+ @commit.save
96
+ @commit.note = nil
97
+ @commit.commit_fields
98
+ Familia.dbclient.hexists(@commit.dbkey, 'note')
99
+ #=> false
100
+
101
+ ## multi_field_update with a nil value deletes the field
102
+ @mfu = NilAbsenceModel.new(id: next_id, name: 'mfu', note: 'present')
103
+ @mfu.save
104
+ @mfu.multi_field_update(note: nil)
105
+ Familia.dbclient.hexists(@mfu.dbkey, 'note')
106
+ #=> false
107
+
108
+ ## save_fields with a nil value deletes the named field
109
+ @sf = NilAbsenceModel.new(id: next_id, name: 'sf', note: 'present')
110
+ @sf.save
111
+ @sf.note = nil
112
+ @sf.save_fields(:note)
113
+ Familia.dbclient.hexists(@sf.dbkey, 'note')
114
+ #=> false
115
+
116
+ ## multi_field_fast_write with a nil value deletes the named field
117
+ @mffw = NilAbsenceModel.new(id: next_id, name: 'mffw', note: 'present')
118
+ @mffw.save
119
+ @mffw.multi_field_fast_write(note: nil)
120
+ Familia.dbclient.hexists(@mffw.dbkey, 'note')
121
+ #=> false
122
+
123
+ ## multi_field_fast_write also clears the in-memory value
124
+ @mffw.note
125
+ #=> nil
126
+
127
+ ## A Proc-identifier object with all-nil fields does not create a ghost instances entry
128
+ # Nothing is persisted (hmset no-ops), so no hash key is created; the identifier
129
+ # must NOT be registered in the instances timeline, or it would dangle -- listed
130
+ # in instances.to_a while exists? reports false and any load follows a dead ref.
131
+ @ghost = ProcIdAbsenceModel.new
132
+ @ghost.probe_id = "proc-#{Familia.now.to_i}-#{@id_counter += 1}"
133
+ @ghost.save
134
+ [@ghost.exists?, ProcIdAbsenceModel.instances.to_a.include?(@ghost.identifier)]
135
+ #=> [false, false]
136
+
137
+ ## Once a Proc-identifier object has a real field value, it persists and registers normally
138
+ @ghost.name = 'now real'
139
+ @ghost.save
140
+ [@ghost.exists?, ProcIdAbsenceModel.instances.to_a.include?(@ghost.identifier)]
141
+ #=> [true, true]
142
+
143
+ ## Legacy data: a field stored as the JSON literal "null" is cleaned up on save
144
+ @legacy = NilAbsenceModel.new(id: next_id, name: 'legacy')
145
+ @legacy.save
146
+ # Simulate a hash written by the pre-fix serializer, which stored nil as "null"
147
+ Familia.dbclient.hset(@legacy.dbkey, 'claimed_at', 'null')
148
+ @legacy.refresh! # deserialize_value turns "null" back into nil
149
+ @legacy.save # the now-nil field is actively removed
150
+ Familia.dbclient.hexists(@legacy.dbkey, 'claimed_at')
151
+ #=> false
152
+
153
+ # Final cleanup
154
+ begin
155
+ leftover = Familia.dbclient.keys('nilabsencemodel:*')
156
+ leftover.concat(Familia.dbclient.keys('procidabsencemodel:*'))
157
+ Familia.dbclient.del(*leftover) if leftover.any?
158
+ rescue StandardError
159
+ # ignore cleanup errors
160
+ end
@@ -66,9 +66,12 @@ Familia.dbclient.set('debug:ending_save_if_not_exists_tests', Familia.now.to_s)
66
66
  #=> "John Doe"
67
67
 
68
68
  ## multi_field_update can update multiple fields atomically, to_h
69
+ # email was never set before the initial save, so under nil-omitting storage it
70
+ # was not persisted; this HSET therefore CREATES the field (returns 1) whereas
71
+ # name already exists (returns 0).
69
72
  @result = @customer.multi_field_update(name: 'Jane Windows', email: 'jane@example.com')
70
73
  @result.to_h
71
- #=> {:success=>true, :results=>[0, 0, false, true]}
74
+ #=> {:success=>true, :results=>[0, 1, false, true]}
72
75
 
73
76
  ## multi_field_update returns successful result, successful?
74
77
  @result.successful?
@@ -76,11 +79,11 @@ Familia.dbclient.set('debug:ending_save_if_not_exists_tests', Familia.now.to_s)
76
79
 
77
80
  ## multi_field_update returns successful result, tuple
78
81
  @result.tuple
79
- #=> [true, [0, 0, false, true]]
82
+ #=> [true, [0, 1, false, true]]
80
83
 
81
84
  ## multi_field_update returns successful result, to_a
82
85
  @result.to_a
83
- #=> [true, [0, 0, false, true]]
86
+ #=> [true, [0, 1, false, true]]
84
87
 
85
88
  ## multi_field_update updates object fields in memory, confirm fields changed
86
89
  [@customer.name, @customer.email]
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: familia
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.11.1
4
+ version: 2.11.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Delano Mandelbaum
@@ -123,16 +123,22 @@ dependencies:
123
123
  name: stringio
124
124
  requirement: !ruby/object:Gem::Requirement
125
125
  requirements:
126
- - - "~>"
126
+ - - ">="
127
127
  - !ruby/object:Gem::Version
128
128
  version: 3.1.1
129
+ - - "<"
130
+ - !ruby/object:Gem::Version
131
+ version: 3.3.0
129
132
  type: :runtime
130
133
  prerelease: false
131
134
  version_requirements: !ruby/object:Gem::Requirement
132
135
  requirements:
133
- - - "~>"
136
+ - - ">="
134
137
  - !ruby/object:Gem::Version
135
138
  version: 3.1.1
139
+ - - "<"
140
+ - !ruby/object:Gem::Version
141
+ version: 3.3.0
136
142
  - !ruby/object:Gem::Dependency
137
143
  name: uri-valkey
138
144
  requirement: !ruby/object:Gem::Requirement
@@ -548,6 +554,7 @@ files:
548
554
  - try/integration/models/customer_try.rb
549
555
  - try/integration/models/datatype_base_try.rb
550
556
  - try/integration/models/familia_object_try.rb
557
+ - try/integration/nil_field_absence_try.rb
551
558
  - try/integration/persistence_operations_try.rb
552
559
  - try/integration/relationships_persistence_round_trip_try.rb
553
560
  - try/integration/save_methods_consistency_try.rb