familia 2.0.0.pre16 → 2.0.0.pre17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +2 -2
- data/.github/workflows/{code-smellage.yml → code-smells.yml} +3 -63
- data/.gitignore +2 -0
- data/.rubocop.yml +6 -0
- data/CHANGELOG.rst +22 -0
- data/CLAUDE.md +38 -0
- data/Gemfile.lock +1 -1
- data/docs/archive/FAMILIA_TECHNICAL.md +1 -1
- data/docs/overview.md +2 -2
- data/docs/reference/api-technical.md +1 -1
- data/examples/encrypted_fields.rb +1 -1
- data/examples/safe_dump.rb +1 -1
- data/lib/familia/base.rb +6 -4
- data/lib/familia/data_type/class_methods.rb +63 -0
- data/lib/familia/data_type/connection.rb +83 -0
- data/lib/familia/data_type/settings.rb +96 -0
- data/lib/familia/data_type/types/hashkey.rb +2 -1
- data/lib/familia/data_type/types/sorted_set.rb +113 -10
- data/lib/familia/data_type/types/stringkey.rb +0 -4
- data/lib/familia/data_type.rb +6 -193
- data/lib/familia/features/encrypted_fields.rb +5 -2
- data/lib/familia/features/external_identifier.rb +49 -8
- data/lib/familia/features/object_identifier.rb +84 -12
- data/lib/familia/features/relationships/indexing/unique_index_generators.rb +6 -1
- data/lib/familia/features/relationships/indexing.rb +7 -1
- data/lib/familia/features/relationships/participation/participant_methods.rb +6 -2
- data/lib/familia/features/transient_fields.rb +7 -2
- data/lib/familia/features.rb +6 -1
- data/lib/familia/field_type.rb +0 -18
- data/lib/familia/horreum/{core/connection.rb → connection.rb} +21 -0
- data/lib/familia/horreum/{subclass/definition.rb → definition.rb} +109 -32
- data/lib/familia/horreum/{subclass/management.rb → management.rb} +1 -3
- data/lib/familia/horreum/{core/serialization.rb → persistence.rb} +72 -169
- data/lib/familia/horreum/{subclass/related_fields_management.rb → related_fields.rb} +22 -2
- data/lib/familia/horreum/serialization.rb +172 -0
- data/lib/familia/horreum.rb +29 -8
- data/lib/familia/version.rb +1 -1
- data/try/configuration/scenarios_try.rb +1 -1
- data/try/core/connection_try.rb +4 -4
- data/try/core/database_consistency_try.rb +1 -0
- data/try/core/errors_try.rb +3 -3
- data/try/core/familia_try.rb +1 -1
- data/try/core/isolated_dbclient_try.rb +2 -2
- data/try/core/tools_try.rb +2 -2
- data/try/data_types/sorted_set_zadd_options_try.rb +625 -0
- data/try/features/field_groups_try.rb +244 -0
- data/try/features/relationships/indexing_try.rb +10 -0
- data/try/features/transient_fields/refresh_reset_try.rb +2 -0
- data/try/helpers/test_helpers.rb +3 -4
- data/try/horreum/auto_indexing_on_save_try.rb +212 -0
- data/try/horreum/commands_try.rb +2 -0
- data/try/horreum/defensive_initialization_try.rb +86 -0
- data/try/horreum/destroy_related_fields_cleanup_try.rb +2 -0
- data/try/horreum/settings_try.rb +2 -0
- data/try/memory/memory_docker_ruby_dump.sh +1 -1
- data/try/models/customer_try.rb +5 -5
- data/try/valkey.conf +26 -0
- metadata +19 -11
- data/lib/familia/horreum/core.rb +0 -21
- /data/lib/familia/horreum/{core/database_commands.rb → database_commands.rb} +0 -0
- /data/lib/familia/horreum/{shared/settings.rb → settings.rb} +0 -0
- /data/lib/familia/horreum/{core/utils.rb → utils.rb} +0 -0
@@ -46,17 +46,81 @@ module Familia
|
|
46
46
|
add val, score
|
47
47
|
end
|
48
48
|
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
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
|
-
|
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
|
data/lib/familia/data_type.rb
CHANGED
@@ -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
|
-
|
129
|
-
|
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
|
-
|
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
|
-
#
|
206
|
-
#
|
207
|
-
|
208
|
-
instance_variable_set(:"@#{field_name}_generator_used",
|
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
|
-
#
|
350
|
+
# Infers the generator type (:uuid_v7, :uuid_v4, :hex) from the format of an objid string.
|
308
351
|
#
|
309
|
-
#
|
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.
|
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:
|