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
@@ -46,17 +46,81 @@ module Familia
46
46
  add val, score
47
47
  end
48
48
 
49
- def add(val, score = nil)
50
- # TODO: Support some or all of the ZADD options.
51
- # XX: Only update existing elements. Don't add new ones.
52
- # NX: Only add new elements. Don't update existing ones.
53
- # LT: Only update if new score < current score. Doesn't prevent adding.
54
- # GT: Only update if new score > current score. Doesn't prevent adding.
55
- # CH: Return total changed elements (new + updated) instead of just new.
56
- # INCR: Acts like ZINCRBY. Only one score-element pair allowed.
57
- # Note: GT, LT and NX options are mutually exclusive.
49
+ # Adds an element to the sorted set with an optional score and ZADD options.
50
+ #
51
+ # This method supports Redis ZADD options for conditional adds and updates:
52
+ # - **NX**: Only add new elements (don't update existing)
53
+ # - **XX**: Only update existing elements (don't add new)
54
+ # - **GT**: Only update if new score > current score
55
+ # - **LT**: Only update if new score < current score
56
+ # - **CH**: Return changed count (new + updated) instead of just new count
57
+ #
58
+ # @param val [Object] The value to add to the sorted set
59
+ # @param score [Numeric, nil] The score for ranking (defaults to current timestamp)
60
+ # @param nx [Boolean] Only add new elements, don't update existing (default: false)
61
+ # @param xx [Boolean] Only update existing elements, don't add new (default: false)
62
+ # @param gt [Boolean] Only update if new score > current score (default: false)
63
+ # @param lt [Boolean] Only update if new score < current score (default: false)
64
+ # @param ch [Boolean] Return changed count instead of added count (default: false)
65
+ #
66
+ # @return [Boolean] Returns the return value from the redis gem's ZADD
67
+ # command. Returns true if element was added or changed (with CH option),
68
+ # false if element score was updated without change tracking or no
69
+ # operation occurred due to option constraints (NX, XX, GT, LT).
70
+ #
71
+ # @raise [ArgumentError] If mutually exclusive options are specified together
72
+ # (NX+XX, GT+LT, NX+GT, NX+LT)
73
+ #
74
+ # @example Add new element with timestamp
75
+ # metrics.add('pageview', Time.now.to_f) #=> true
76
+ #
77
+ # @example Preserve original timestamp on subsequent saves
78
+ # index.add(email, Time.now.to_f, nx: true) #=> true
79
+ # index.add(email, Time.now.to_f, nx: true) #=> false (unchanged)
80
+ #
81
+ # @example Update timestamp only for existing entries
82
+ # index.add(email, Time.now.to_f, xx: true) #=> false (if doesn't exist)
83
+ #
84
+ # @example Only update if new score is higher (leaderboard)
85
+ # scores.add(player, 1000, gt: true) #=> true (new entry)
86
+ # scores.add(player, 1500, gt: true) #=> false (updated)
87
+ # scores.add(player, 1200, gt: true) #=> false (not updated, score lower)
88
+ #
89
+ # @example Track total changes for analytics
90
+ # changed = metrics.add(user, score, ch: true) #=> true (new or updated)
91
+ #
92
+ # @example Combined options: only update existing, only if score increases
93
+ # index.add(key, new_score, xx: true, gt: true)
94
+ #
95
+ # @note GT and LT options do NOT prevent adding new elements, they only
96
+ # affect update behavior for existing elements.
97
+ #
98
+ # @note Default behavior (no options) adds new elements and updates existing
99
+ # ones unconditionally, matching standard Redis ZADD semantics.
100
+ #
101
+ # @note INCR option is not supported. Use the increment method for ZINCRBY operations.
102
+ #
103
+ def add(val, score = nil, nx: false, xx: false, gt: false, lt: false, ch: false)
58
104
  score ||= Familia.now
59
- ret = dbclient.zadd dbkey, score, serialize_value(val)
105
+
106
+ # Validate mutual exclusivity
107
+ validate_zadd_options!(nx: nx, xx: xx, gt: gt, lt: lt)
108
+
109
+ # Build options hash for redis gem
110
+ opts = {}
111
+ opts[:nx] = true if nx
112
+ opts[:xx] = true if xx
113
+ opts[:gt] = true if gt
114
+ opts[:lt] = true if lt
115
+ opts[:ch] = true if ch
116
+
117
+ # Pass options to ZADD
118
+ ret = if opts.empty?
119
+ dbclient.zadd(dbkey, score, serialize_value(val))
120
+ else
121
+ dbclient.zadd(dbkey, score, serialize_value(val), **opts)
122
+ end
123
+
60
124
  update_expiration
61
125
  ret
62
126
  end
@@ -242,6 +306,45 @@ module Familia
242
306
  at(-1)
243
307
  end
244
308
 
309
+
310
+ private
311
+
312
+ # Validates that mutually exclusive ZADD options are not specified together.
313
+ #
314
+ # @param nx [Boolean] NX option flag
315
+ # @param xx [Boolean] XX option flag
316
+ # @param gt [Boolean] GT option flag
317
+ # @param lt [Boolean] LT option flag
318
+ #
319
+ # @raise [ArgumentError] If mutually exclusive options are specified
320
+ #
321
+ # @note Valid combinations: XX+GT, XX+LT
322
+ # @note Invalid combinations: NX+XX, GT+LT, NX+GT, NX+LT
323
+ #
324
+ def validate_zadd_options!(nx:, xx:, gt:, lt:)
325
+ # NX and XX are mutually exclusive
326
+ if nx && xx
327
+ raise ArgumentError, "ZADD options NX and XX are mutually exclusive"
328
+ end
329
+
330
+ # GT and LT are mutually exclusive
331
+ if gt && lt
332
+ raise ArgumentError, "ZADD options GT and LT are mutually exclusive"
333
+ end
334
+
335
+ # NX is mutually exclusive with GT
336
+ if nx && gt
337
+ raise ArgumentError, "ZADD options NX and GT are mutually exclusive"
338
+ end
339
+
340
+ # NX is mutually exclusive with LT
341
+ if nx && lt
342
+ raise ArgumentError, "ZADD options NX and LT are mutually exclusive"
343
+ end
344
+
345
+ # Note: XX + GT and XX + LT are valid combinations
346
+ end
347
+
245
348
  Familia::DataType.register self, :sorted_set
246
349
  Familia::DataType.register self, :zset
247
350
  end
@@ -114,10 +114,6 @@ module Familia
114
114
  ret.positive?
115
115
  end
116
116
 
117
- def nil?
118
- value.nil?
119
- end
120
-
121
117
  Familia::DataType.register self, :string
122
118
  Familia::DataType.register self, :stringkey
123
119
  end
@@ -1,5 +1,8 @@
1
1
  # lib/familia/data_type.rb
2
2
 
3
+ require_relative 'data_type/class_methods'
4
+ require_relative 'data_type/settings'
5
+ require_relative 'data_type/connection'
3
6
  require_relative 'data_type/commands'
4
7
  require_relative 'data_type/serialization'
5
8
 
@@ -14,6 +17,7 @@ module Familia
14
17
  # @abstract Subclass and implement Database data type specific methods
15
18
  class DataType
16
19
  include Familia::Base
20
+ extend ClassMethods
17
21
  extend Familia::Features
18
22
 
19
23
  using Familia::Refinements::TimeLiterals
@@ -29,60 +33,6 @@ module Familia
29
33
  attr_reader :registered_types, :valid_options, :has_related_fields
30
34
  end
31
35
 
32
- # DataType::ClassMethods
33
- #
34
- module ClassMethods
35
- attr_accessor :parent, :suffix, :prefix, :uri
36
- attr_writer :logical_database
37
-
38
- # To be called inside every class that inherits DataType
39
- # +methname+ is the term used for the class and instance methods
40
- # that are created for the given +klass+ (e.g. set, list, etc)
41
- def register(klass, methname)
42
- Familia.trace :REGISTER, nil, "[#{self}] Registering #{klass} as #{methname.inspect}" if Familia.debug?
43
-
44
- @registered_types[methname] = klass
45
- end
46
-
47
- # Get the registered type class from a given method name
48
- # +methname+ is the method name used to register the class (e.g. :set, :list, etc)
49
- # Returns the registered class or nil if not found
50
- def registered_type(methname)
51
- @registered_types[methname]
52
- end
53
-
54
- def logical_database(val = nil)
55
- @logical_database = val unless val.nil?
56
- @logical_database || parent&.logical_database
57
- end
58
-
59
- def uri(val = nil)
60
- @uri = val unless val.nil?
61
- @uri || (parent ? parent.uri : Familia.uri)
62
- end
63
-
64
- def inherited(obj)
65
- Familia.trace :DATATYPE, nil, "#{obj} is my kinda type" if Familia.debug?
66
- obj.logical_database = logical_database
67
- obj.default_expiration = default_expiration # method added via Features::Expiration
68
- obj.uri = uri
69
- super
70
- end
71
-
72
- def valid_keys_only(opts)
73
- opts.slice(*DataType.valid_options)
74
- end
75
-
76
- def relations?
77
- @has_related_fields ||= false
78
- end
79
- end
80
- extend ClassMethods
81
-
82
- attr_reader :keystring, :opts, :uri, :logical_database
83
-
84
- alias url uri
85
-
86
36
  # +keystring+: If parent is set, this will be used as the suffix
87
37
  # for dbkey. Otherwise this becomes the value of the key.
88
38
  # If this is an Array, the elements will be joined.
@@ -125,145 +75,8 @@ module Familia
125
75
  init if respond_to? :init
126
76
  end
127
77
 
128
- # TODO: Replace with Chain of Responsibility pattern
129
- def dbclient
130
- return Fiber[:familia_transaction] if Fiber[:familia_transaction]
131
- return @dbclient if @dbclient
132
-
133
- # Delegate to parent if present, otherwise fall back to Familia
134
- parent ? parent.dbclient : Familia.dbclient(opts[:logical_database])
135
- end
136
-
137
- # Produces the full dbkey for this object.
138
- #
139
- # @return [String] The full dbkey.
140
- #
141
- # This method determines the appropriate dbkey based on the context of the DataType object:
142
- #
143
- # 1. If a hardcoded key is set in the options, it returns that key.
144
- # 2. For instance-level DataType objects, it uses the parent instance's dbkey method.
145
- # 3. For class-level DataType objects, it uses the parent class's dbkey method.
146
- # 4. For standalone DataType objects, it uses the keystring as the full dbkey.
147
- #
148
- # For class-level DataType objects (parent_class? == true):
149
- # - The suffix is optional and used to differentiate between different types of objects.
150
- # - If no suffix is provided, the class's default suffix is used (via the self.suffix method).
151
- # - If a nil suffix is explicitly passed, it won't appear in the resulting dbkey.
152
- # - Passing nil as the suffix is how class-level DataType objects are created without
153
- # the global default 'object' suffix.
154
- #
155
- # @example Instance-level DataType
156
- # user_instance.some_datatype.dbkey # => "user:123:some_datatype"
157
- #
158
- # @example Class-level DataType
159
- # User.some_datatype.dbkey # => "user:some_datatype"
160
- #
161
- # @example Standalone DataType
162
- # DataType.new("mykey").dbkey # => "mykey"
163
- #
164
- # @example Class-level DataType with explicit nil suffix
165
- # User.dbkey("123", nil) # => "user:123"
166
- #
167
- def dbkey
168
- # Return the hardcoded key if it's set. This is useful for
169
- # support legacy keys that aren't derived in the same way.
170
- return opts[:dbkey] if opts[:dbkey]
171
-
172
- if parent_instance?
173
- # This is an instance-level datatype object so the parent instance's
174
- # dbkey method is defined in Familia::Horreum::InstanceMethods.
175
- parent.dbkey(keystring)
176
- elsif parent_class?
177
- # This is a class-level datatype object so the parent class' dbkey
178
- # method is defined in Familia::Horreum::DefinitionMethods.
179
- parent.dbkey(keystring, nil)
180
- else
181
- # This is a standalone DataType object where it's keystring
182
- # is the full database key (dbkey).
183
- keystring
184
- end
185
- end
186
-
187
- def class?
188
- !@opts[:class].to_s.empty? && @opts[:class].is_a?(Familia)
189
- end
190
-
191
- # Provides a structured way to "gear down" to run db commands that are
192
- # not implemented in our DataType classes since we intentionally don't
193
- # have a method_missing method.
194
- def direct_access
195
- yield(dbclient, dbkey)
196
- end
197
-
198
- def parent_instance?
199
- parent&.is_a?(Horreum::ParentDefinition)
200
- end
201
-
202
- def parent_class?
203
- parent.is_a?(Class) && parent.ancestors.include?(Familia::Horreum)
204
- end
205
-
206
- def parent?
207
- parent_class? || parent_instance?
208
- end
209
-
210
- def parent
211
- # Return cached ParentDefinition if available
212
- return @parent if @parent
213
-
214
- # Return class-level parent if no instance parent
215
- return self.class.parent unless @parent_ref
216
-
217
- # Create ParentDefinition dynamically from stored reference.
218
- # This ensures we get the current identifier value (available after initialization)
219
- # rather than a stale nil value from initialization time. Cannot cache due to frozen object.
220
- Horreum::ParentDefinition.from_parent(@parent_ref)
221
- end
222
-
223
- def parent=(value)
224
- case value
225
- when Horreum::ParentDefinition
226
- @parent = value
227
- when nil
228
- @parent = nil
229
- @parent_ref = nil
230
- else
231
- # Store parent instance reference for lazy ParentDefinition creation.
232
- # During initialization, the parent's identifier may not be available yet,
233
- # so we defer ParentDefinition creation until first access for memory efficiency.
234
- # Note: @parent_ref is not cleared after use because DataType objects are frozen.
235
- @parent_ref = value
236
- @parent = nil # Will be created dynamically in parent method
237
- end
238
- end
239
-
240
- def uri
241
- # Return explicit instance URI if set
242
- return @uri if @uri
243
-
244
- # If we have a parent with logical_database, build URI with that database
245
- if parent && parent.respond_to?(:logical_database) && parent.logical_database
246
- new_uri = (self.class.uri || Familia.uri).dup
247
- new_uri.db = parent.logical_database
248
- new_uri
249
- else
250
- # Fall back to class-level URI or global Familia.uri
251
- self.class.uri || Familia.uri
252
- end
253
- end
254
-
255
- def uri=(value)
256
- @uri = value
257
- end
258
-
259
- def dump_method
260
- self.class.dump_method
261
- end
262
-
263
- def load_method
264
- self.class.load_method
265
- end
266
-
78
+ include Settings
79
+ include Connection
267
80
  include Commands
268
81
  include Serialization
269
82
  end
@@ -259,7 +259,7 @@ module Familia
259
259
  # - Insider threats with application access
260
260
  #
261
261
  module EncryptedFields
262
- Familia::Base.add_feature self, :encrypted_fields
262
+ Familia::Base.add_feature self, :encrypted_fields, depends_on: nil, field_group: :encrypted_fields
263
263
 
264
264
  def self.included(base)
265
265
  Familia.trace :LOADED, self, base if Familia.debug?
@@ -297,7 +297,10 @@ module Familia
297
297
  @encrypted_fields ||= []
298
298
  @encrypted_fields << name unless @encrypted_fields.include?(name)
299
299
 
300
- require_relative 'encrypted_fields/encrypted_field_type'
300
+ # Add to field_groups if the group exists
301
+ if field_groups&.key?(:encrypted_fields)
302
+ field_groups[:encrypted_fields] << name
303
+ end
301
304
 
302
305
  field_type = EncryptedFieldType.new(name, aad_fields: aad_fields, **)
303
306
  register_field_type(field_type)
@@ -10,6 +10,7 @@ module Familia
10
10
  def self.included(base)
11
11
  Familia.trace :LOADED, self, base if Familia.debug?
12
12
  base.extend ModelClassMethods
13
+ base.include ModelInstanceMethods
13
14
 
14
15
  # Ensure default prefix is set in feature options
15
16
  base.add_feature_options(:external_identifier, prefix: 'ext')
@@ -75,9 +76,6 @@ module Familia
75
76
 
76
77
  instance_variable_set(:"@#{field_name}", derived_extid)
77
78
 
78
- # Update mapping if we have an identifier (objid)
79
- self.class.extid_lookup[derived_extid] = identifier if respond_to?(:identifier) && identifier
80
-
81
79
  derived_extid
82
80
  end
83
81
  end
@@ -103,11 +101,6 @@ module Familia
103
101
 
104
102
  # Set the new value
105
103
  instance_variable_set(:"@#{field_name}", value)
106
-
107
- # Update mapping if we have both extid and identifier
108
- return unless value && respond_to?(:identifier) && identifier
109
-
110
- self.class.extid_lookup[value] = identifier
111
104
  end
112
105
  end
113
106
  end
@@ -159,6 +152,54 @@ module Familia
159
152
  end
160
153
  end
161
154
 
155
+ # Instance methods for external identifier management
156
+ module ModelInstanceMethods
157
+ # Override save to update extid_lookup mapping
158
+ #
159
+ # This ensures the extid_lookup index is populated during save operations
160
+ # rather than during object initialization, preventing unwanted database
161
+ # writes when calling .new()
162
+ #
163
+ # @param update_expiration [Boolean] Whether to update key expiration
164
+ # @return [Boolean] True if save was successful
165
+ #
166
+ def save(update_expiration: true)
167
+ result = super
168
+
169
+ # Update extid_lookup mapping after successful save
170
+ if result && respond_to?(:extid) && respond_to?(:identifier)
171
+ current_extid = extid # Triggers lazy generation if needed
172
+ if current_extid && identifier
173
+ self.class.extid_lookup[current_extid] = identifier
174
+ end
175
+ end
176
+
177
+ result
178
+ end
179
+
180
+ # Override save_if_not_exists to update extid_lookup mapping
181
+ #
182
+ # This ensures the extid_lookup index is populated during create operations
183
+ # which use save_if_not_exists instead of save.
184
+ #
185
+ # @param update_expiration [Boolean] Whether to update key expiration
186
+ # @return [Boolean] True if save was successful
187
+ #
188
+ def save_if_not_exists(update_expiration: true)
189
+ result = super
190
+
191
+ # Update extid_lookup mapping after successful save
192
+ if result && respond_to?(:extid) && respond_to?(:identifier)
193
+ current_extid = extid # Triggers lazy generation if needed
194
+ if current_extid && identifier
195
+ self.class.extid_lookup[current_extid] = identifier
196
+ end
197
+ end
198
+
199
+ result
200
+ end
201
+ end
202
+
162
203
  # Derives a deterministic, public-facing external identifier from the object's
163
204
  # internal `objid`.
164
205
  #
@@ -88,6 +88,7 @@ module Familia
88
88
  def self.included(base)
89
89
  Familia.trace :LOADED, self, base if Familia.debug?
90
90
  base.extend ModelClassMethods
91
+ base.include ModelInstanceMethods
91
92
 
92
93
  # Ensure default generator is set in feature options
93
94
  base.add_feature_options(:object_identifier, generator: DEFAULT_GENERATOR)
@@ -160,9 +161,6 @@ module Familia
160
161
  generator = options[:generator] || DEFAULT_GENERATOR
161
162
  instance_variable_set(:"@#{field_name}_generator_used", generator)
162
163
 
163
- # Update mapping from objid to model primary key
164
- self.class.objid_lookup[generated_id] = identifier if respond_to?(:identifier) && identifier
165
-
166
164
  generated_id
167
165
  end
168
166
  end
@@ -198,14 +196,11 @@ module Familia
198
196
 
199
197
  instance_variable_set(:"@#{field_name}", value)
200
198
 
201
- # Update mapping from objid to this new identifier
202
- self.class.objid_lookup[value] = identifier unless value.nil? || identifier.nil?
203
-
204
199
  # When setting objid from external source (e.g., loading from Valkey/Redis),
205
- # we cannot determine the original generator, so we clear the provenance
206
- # tracking to indicate unknown origin. This prevents false assumptions
207
- # about the security properties of externally-provided identifiers.
208
- instance_variable_set(:"@#{field_name}_generator_used", nil)
200
+ # infer the generator type from the format to restore provenance tracking.
201
+ # This allows features like ExternalIdentifier to work correctly on loaded objects.
202
+ inferred_generator = infer_objid_generator(value)
203
+ instance_variable_set(:"@#{field_name}_generator_used", inferred_generator)
209
204
  end
210
205
  end
211
206
  end
@@ -284,6 +279,54 @@ module Familia
284
279
  end
285
280
  end
286
281
 
282
+ # Instance methods for object identifier management
283
+ module ModelInstanceMethods
284
+ # Override save to update objid_lookup mapping
285
+ #
286
+ # This ensures the objid_lookup index is populated during save operations
287
+ # rather than during object initialization, preventing unwanted database
288
+ # writes when calling .new()
289
+ #
290
+ # @param update_expiration [Boolean] Whether to update key expiration
291
+ # @return [Boolean] True if save was successful
292
+ #
293
+ def save(update_expiration: true)
294
+ result = super
295
+
296
+ # Update objid_lookup mapping after successful save
297
+ if result && respond_to?(:objid) && respond_to?(:identifier)
298
+ current_objid = objid # Triggers lazy generation if needed
299
+ if current_objid && identifier
300
+ self.class.objid_lookup[current_objid] = identifier
301
+ end
302
+ end
303
+
304
+ result
305
+ end
306
+
307
+ # Override save_if_not_exists to update objid_lookup mapping
308
+ #
309
+ # This ensures the objid_lookup index is populated during create operations
310
+ # which use save_if_not_exists instead of save.
311
+ #
312
+ # @param update_expiration [Boolean] Whether to update key expiration
313
+ # @return [Boolean] True if save was successful
314
+ #
315
+ def save_if_not_exists(update_expiration: true)
316
+ result = super
317
+
318
+ # Update objid_lookup mapping after successful save
319
+ if result && respond_to?(:objid) && respond_to?(:identifier)
320
+ current_objid = objid # Triggers lazy generation if needed
321
+ if current_objid && identifier
322
+ self.class.objid_lookup[current_objid] = identifier
323
+ end
324
+ end
325
+
326
+ result
327
+ end
328
+ end
329
+
287
330
  # Instance method for generating object identifier using configured strategy
288
331
  #
289
332
  # This method is called by the ObjectIdentifierFieldType when lazy generation
@@ -304,10 +347,39 @@ module Familia
304
347
  objid
305
348
  end
306
349
 
307
- # Full-length alias setter for objid
350
+ # Infers the generator type (:uuid_v7, :uuid_v4, :hex) from the format of an objid string.
308
351
  #
309
- # @param value [String] The object identifier to set
352
+ # This method analyzes the objid format to restore provenance tracking when loading
353
+ # objects from Redis, allowing dependent features like ExternalIdentifier to work correctly.
310
354
  #
355
+ # @param objid_value [String] The objid string to analyze
356
+ # @return [Symbol, nil] The inferred generator type or nil if unknown
357
+ def infer_objid_generator(objid_value)
358
+ return nil if objid_value.nil? || objid_value.to_s.empty?
359
+
360
+ objid_str = objid_value.to_s
361
+
362
+ # UUID format: xxxxxxxx-xxxx-Vxxx-xxxx-xxxxxxxxxxxx (36 chars with hyphens)
363
+ # where V is the version nibble at position 14
364
+ if objid_str.length == 36 && objid_str[8] == '-' && objid_str[13] == '-' && objid_str[18] == '-' && objid_str[23] == '-'
365
+ version_char = objid_str[14]
366
+ case version_char
367
+ when '7'
368
+ :uuid_v7
369
+ when '4'
370
+ :uuid_v4
371
+ else
372
+ nil # Unknown UUID version
373
+ end
374
+ # Hex format: pure hexadecimal without hyphens (32 or 64 chars typically)
375
+ elsif objid_str.match?(/\A[0-9a-fA-F]+\z/)
376
+ :hex
377
+ else
378
+ nil # Unknown format
379
+ end
380
+ end
381
+ private :infer_objid_generator
382
+
311
383
  def object_identifier=(value)
312
384
  self.objid = value
313
385
  end
@@ -33,9 +33,12 @@ module Familia
33
33
  # - Employee.rebuild_email_index
34
34
  #
35
35
  # Generates on Employee (self):
36
- # - employee.add_to_class_email_index
36
+ # - employee.add_to_class_email_index (called automatically on save)
37
37
  # - employee.remove_from_class_email_index
38
38
  # - employee.update_in_class_email_index(old_email)
39
+ #
40
+ # Note: Class-level indexes auto-populate on save(). Instance-scoped indexes
41
+ # (with within:) remain manual as they require parent context.
39
42
  module UniqueIndexGenerators
40
43
  module_function
41
44
 
@@ -114,6 +117,7 @@ module Familia
114
117
 
115
118
  # Generate bulk query method (e.g., company.find_all_by_badge_number)
116
119
  define_method("find_all_by_#{field}") do |field_values|
120
+ field_values = Array(field_values)
117
121
  return [] if field_values.empty?
118
122
 
119
123
  # Use declared field accessor instead of manual instantiation
@@ -229,6 +233,7 @@ module Familia
229
233
 
230
234
  # Generate class-level bulk query method
231
235
  indexed_class.define_singleton_method("find_all_by_#{field}") do |field_values|
236
+ field_values = Array(field_values)
232
237
  return [] if field_values.empty?
233
238
 
234
239
  index_hash = send(index_name) # Access the class-level hashkey DataType
@@ -18,7 +18,7 @@ module Familia
18
18
  # end
19
19
  #
20
20
  # user = User.new(user_id: 'u1', email: 'alice@example.com')
21
- # user.add_to_class_email_lookup
21
+ # user.save # Automatically populates email_lookup index
22
22
  # User.find_by_email('alice@example.com') # → user
23
23
  #
24
24
  # @example Instance-scoped unique index (within parent, 1:1 via HashKey)
@@ -58,6 +58,12 @@ module Familia
58
58
  # - Instance unique: "company:c1:badge_index" → HashKey
59
59
  # - Instance multi: "company:c1:dept_index:engineering" → UnsortedSet
60
60
  #
61
+ # Auto-Indexing:
62
+ # Class-level unique_index declarations automatically populate on save():
63
+ # user = User.new(email: 'test@example.com')
64
+ # user.save # Auto-indexes email → user_id
65
+ # Instance-scoped indexes (with within:) remain manual (require parent context).
66
+ #
61
67
  # Design Philosophy:
62
68
  # Indexing is for finding objects by attribute, not ordering them.
63
69
  # Use multi_index with UnsortedSet (no temporal scores), then sort in Ruby: