familia 2.0.0.pre8 → 2.0.0.pre12
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 +13 -0
- data/.github/workflows/docs.yml +1 -1
- data/.gitignore +9 -9
- data/.rubocop.yml +19 -0
- data/.yardopts +22 -1
- data/CHANGELOG.md +247 -0
- data/CLAUDE.md +12 -59
- data/Gemfile.lock +1 -1
- data/README.md +62 -2
- data/changelog.d/README.md +77 -0
- data/docs/archive/.gitignore +2 -0
- data/docs/archive/FAMILIA_RELATIONSHIPS.md +210 -0
- data/docs/archive/FAMILIA_TECHNICAL.md +823 -0
- data/docs/archive/FAMILIA_UPDATE.md +226 -0
- data/docs/archive/README.md +63 -0
- data/docs/guides/.gitignore +2 -0
- data/docs/{wiki → guides}/Home.md +1 -1
- data/docs/{wiki → guides}/Implementation-Guide.md +1 -1
- data/docs/{wiki → guides}/Relationships-Guide.md +103 -50
- data/docs/guides/relationships-methods.md +266 -0
- data/docs/migrating/.gitignore +2 -0
- data/docs/migrating/v2.0.0-pre.md +84 -0
- data/docs/migrating/v2.0.0-pre11.md +255 -0
- data/docs/migrating/v2.0.0-pre12.md +306 -0
- data/docs/migrating/v2.0.0-pre5.md +110 -0
- data/docs/migrating/v2.0.0-pre6.md +154 -0
- data/docs/migrating/v2.0.0-pre7.md +222 -0
- data/docs/overview.md +6 -7
- data/{examples/redis_command_validation_example.rb → docs/reference/auditing_database_commands.rb} +29 -32
- data/examples/{bit_encoding_integration.rb → permissions.rb} +30 -27
- data/examples/relationships.rb +205 -0
- data/examples/safe_dump.rb +281 -0
- data/familia.gemspec +4 -4
- data/lib/familia/base.rb +52 -0
- data/lib/familia/connection.rb +4 -21
- data/lib/familia/{encryption_request_cache.rb → encryption/request_cache.rb} +1 -1
- data/lib/familia/errors.rb +2 -0
- data/lib/familia/features/autoloader.rb +57 -0
- data/lib/familia/features/external_identifier.rb +310 -0
- data/lib/familia/features/object_identifier.rb +307 -0
- data/lib/familia/features/relationships/indexing.rb +160 -175
- data/lib/familia/features/relationships/membership.rb +16 -21
- data/lib/familia/features/relationships/tracking.rb +61 -21
- data/lib/familia/features/relationships.rb +15 -8
- data/lib/familia/features/safe_dump.rb +66 -72
- data/lib/familia/features.rb +93 -5
- data/lib/familia/horreum/subclass/definition.rb +49 -3
- data/lib/familia/horreum.rb +15 -24
- data/lib/familia/secure_identifier.rb +51 -75
- data/lib/familia/verifiable_identifier.rb +162 -0
- data/lib/familia/version.rb +1 -1
- data/lib/familia.rb +1 -0
- data/setup.cfg +5 -0
- data/try/core/secure_identifier_try.rb +47 -18
- data/try/core/verifiable_identifier_try.rb +171 -0
- data/try/features/{external_identifiers/external_identifiers_try.rb → external_identifier/external_identifier_try.rb} +25 -28
- data/try/features/feature_improvements_try.rb +126 -0
- data/try/features/{object_identifiers/object_identifiers_integration_try.rb → object_identifier/object_identifier_integration_try.rb} +28 -30
- data/try/features/{object_identifiers/object_identifiers_try.rb → object_identifier/object_identifier_try.rb} +13 -13
- data/try/features/real_feature_integration_try.rb +7 -6
- data/try/features/relationships/relationships_api_changes_try.rb +339 -0
- data/try/features/relationships/relationships_try.rb +6 -5
- data/try/features/safe_dump/safe_dump_try.rb +8 -9
- data/try/helpers/test_helpers.rb +17 -17
- metadata +62 -41
- data/examples/relationships_basic.rb +0 -273
- data/lib/familia/features/external_identifiers/external_identifier_field_type.rb +0 -120
- data/lib/familia/features/external_identifiers.rb +0 -111
- data/lib/familia/features/object_identifiers/object_identifier_field_type.rb +0 -91
- data/lib/familia/features/object_identifiers.rb +0 -194
- /data/docs/{wiki → guides}/API-Reference.md +0 -0
- /data/docs/{wiki → guides}/Connection-Pooling-Guide.md +0 -0
- /data/docs/{wiki → guides}/Encrypted-Fields-Overview.md +0 -0
- /data/docs/{wiki → guides}/Expiration-Feature-Guide.md +0 -0
- /data/docs/{wiki → guides}/Feature-System-Guide.md +0 -0
- /data/docs/{wiki → guides}/Features-System-Developer-Guide.md +0 -0
- /data/docs/{wiki → guides}/Field-System-Guide.md +0 -0
- /data/docs/{wiki → guides}/Quantization-Feature-Guide.md +0 -0
- /data/docs/{wiki → guides}/Security-Model.md +0 -0
- /data/docs/{wiki → guides}/Transient-Fields-Guide.md +0 -0
@@ -0,0 +1,310 @@
|
|
1
|
+
# lib/familia/features/external_identifier.rb
|
2
|
+
|
3
|
+
module Familia
|
4
|
+
module Features
|
5
|
+
# Familia::Features::ExternalIdentifier
|
6
|
+
#
|
7
|
+
module ExternalIdentifier
|
8
|
+
def self.included(base)
|
9
|
+
Familia.trace :LOADED, self, base, caller(1..1) if Familia.debug?
|
10
|
+
base.extend ClassMethods
|
11
|
+
|
12
|
+
# Ensure default prefix is set in feature options
|
13
|
+
base.add_feature_options(:external_identifier, prefix: 'ext')
|
14
|
+
|
15
|
+
# Add class-level mapping for extid -> id lookups
|
16
|
+
base.class_hashkey :extid_lookup
|
17
|
+
|
18
|
+
# Register the extid field using our custom field type
|
19
|
+
base.register_field_type(ExternalIdentifierFieldType.new(:extid, as: :extid, fast_method: false))
|
20
|
+
end
|
21
|
+
|
22
|
+
# Error classes
|
23
|
+
class ExternalIdentifierError < FieldTypeError; end
|
24
|
+
|
25
|
+
# ExternalIdentifierFieldType - Fields that derive deterministic external identifiers
|
26
|
+
#
|
27
|
+
# External identifier fields derive shorter, public-facing identifiers that are
|
28
|
+
# deterministically derived from object identifiers. These IDs are safe for use
|
29
|
+
# in URLs, APIs, and other external contexts where shorter IDs are preferred.
|
30
|
+
#
|
31
|
+
# Key characteristics:
|
32
|
+
# - Deterministic generation from objid ensures consistency
|
33
|
+
# - Shorter than objid (128-bit vs 256-bit) for external use
|
34
|
+
# - Base-36 encoding for URL-safe identifiers
|
35
|
+
# - 'ext_' prefix for clear identification as external IDs
|
36
|
+
# - Lazy generation preserves values from initialization
|
37
|
+
#
|
38
|
+
# @example Using external identifier fields
|
39
|
+
# class User < Familia::Horreum
|
40
|
+
# feature :object_identifier
|
41
|
+
# feature :external_identifier
|
42
|
+
# field :email
|
43
|
+
# end
|
44
|
+
#
|
45
|
+
# user = User.new(email: 'user@example.com')
|
46
|
+
# user.objid # => "01234567-89ab-7def-8000-123456789abc"
|
47
|
+
# user.extid # => "ext_abc123def456ghi789" (deterministic from objid)
|
48
|
+
#
|
49
|
+
# # Same objid always produces same extid
|
50
|
+
# user2 = User.new(objid: user.objid, email: 'user@example.com')
|
51
|
+
# user2.extid # => "ext_abc123def456ghi789" (identical to user.extid)
|
52
|
+
#
|
53
|
+
class ExternalIdentifierFieldType < Familia::FieldType
|
54
|
+
# Override getter to provide lazy generation from objid
|
55
|
+
#
|
56
|
+
# Derives the external identifier deterministically from the object's
|
57
|
+
# objid. This ensures consistency - the same objid will always produce
|
58
|
+
# the same extid. Only derives when objid is available.
|
59
|
+
#
|
60
|
+
# @param klass [Class] The class to define the method on
|
61
|
+
#
|
62
|
+
def define_getter(klass)
|
63
|
+
field_name = @name
|
64
|
+
method_name = @method_name
|
65
|
+
|
66
|
+
handle_method_conflict(klass, method_name) do
|
67
|
+
klass.define_method method_name do
|
68
|
+
# Check if we already have a value (from initialization or previous generation)
|
69
|
+
existing_value = instance_variable_get(:"@#{field_name}")
|
70
|
+
return existing_value unless existing_value.nil?
|
71
|
+
|
72
|
+
# Derive external identifier from objid if available
|
73
|
+
derived_extid = derive_external_identifier
|
74
|
+
return unless derived_extid
|
75
|
+
|
76
|
+
instance_variable_set(:"@#{field_name}", derived_extid)
|
77
|
+
|
78
|
+
# Update mapping if we have an identifier
|
79
|
+
self.class.extid_lookup[derived_extid] = identifier if respond_to?(:identifier) && identifier
|
80
|
+
|
81
|
+
derived_extid
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
# Override setter to preserve values during initialization
|
87
|
+
#
|
88
|
+
# This ensures that values passed during object initialization
|
89
|
+
# (e.g., when loading from Redis) are preserved and not overwritten
|
90
|
+
# by the lazy generation logic.
|
91
|
+
#
|
92
|
+
# @param klass [Class] The class to define the method on
|
93
|
+
#
|
94
|
+
def define_setter(klass)
|
95
|
+
field_name = @name
|
96
|
+
method_name = @method_name
|
97
|
+
|
98
|
+
handle_method_conflict(klass, :"#{method_name}=") do
|
99
|
+
klass.define_method :"#{method_name}=" do |value|
|
100
|
+
# Remove old mapping if extid is changing
|
101
|
+
old_value = instance_variable_get(:"@#{field_name}")
|
102
|
+
self.class.extid_lookup.del(old_value) if old_value && old_value != value && respond_to?(:identifier)
|
103
|
+
|
104
|
+
# Set the new value
|
105
|
+
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
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
# External identifier fields are persisted to database
|
116
|
+
#
|
117
|
+
# @return [Boolean] true - external identifiers are always persisted
|
118
|
+
#
|
119
|
+
def persistent?
|
120
|
+
true
|
121
|
+
end
|
122
|
+
|
123
|
+
# Category for external identifier fields
|
124
|
+
#
|
125
|
+
# @return [Symbol] :external_identifier
|
126
|
+
#
|
127
|
+
def category
|
128
|
+
:external_identifier
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
# ExternalIdentifier::ClassMethods
|
133
|
+
#
|
134
|
+
module ClassMethods
|
135
|
+
# Find an object by its external identifier
|
136
|
+
#
|
137
|
+
# @param extid [String] The external identifier to search for
|
138
|
+
# @return [Object, nil] The object if found, nil otherwise
|
139
|
+
#
|
140
|
+
def find_by_extid(extid)
|
141
|
+
return nil if extid.to_s.empty?
|
142
|
+
|
143
|
+
if Familia.debug?
|
144
|
+
reference = caller(1..1).first
|
145
|
+
Familia.trace :FIND_BY_EXTID, Familia.dbclient, extid, reference
|
146
|
+
end
|
147
|
+
|
148
|
+
# Look up the primary ID from the external ID mapping
|
149
|
+
primary_id = extid_lookup[extid]
|
150
|
+
return nil if primary_id.nil?
|
151
|
+
|
152
|
+
# Find the object by its primary ID
|
153
|
+
find_by_id(primary_id)
|
154
|
+
rescue Familia::NotFound
|
155
|
+
# If the object was deleted but mapping wasn't cleaned up
|
156
|
+
extid_lookup.del(extid)
|
157
|
+
nil
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
# Derives a deterministic, public-facing external identifier from the object's
|
162
|
+
# internal `objid`.
|
163
|
+
#
|
164
|
+
# This method uses the `objid`'s high-quality randomness to seed a
|
165
|
+
# pseudorandom number generator (PRNG). The PRNG then acts as a complex,
|
166
|
+
# deterministic function to produce a new identifier that has no discernible
|
167
|
+
# mathematical correlation to the `objid`. This is a security measure to
|
168
|
+
# prevent leaking information (like timestamps from UUIDv7) from the internal
|
169
|
+
# identifier to the public one.
|
170
|
+
#
|
171
|
+
# The resulting identifier is always deterministic: the same `objid` will
|
172
|
+
# always produce the same `extid`, which is crucial for lookups.
|
173
|
+
#
|
174
|
+
# @return [String, nil] A prefixed, base36-encoded external identifier, or nil
|
175
|
+
# if the `objid` is not present.
|
176
|
+
# @raise [ExternalIdentifierError] if the `objid` provenance is unknown.
|
177
|
+
def derive_external_identifier
|
178
|
+
raise ExternalIdentifierError, 'Missing objid field' unless respond_to?(:objid)
|
179
|
+
|
180
|
+
current_objid = objid
|
181
|
+
return nil if current_objid.nil? || current_objid.to_s.empty?
|
182
|
+
|
183
|
+
# Validate objid provenance for security guarantees
|
184
|
+
validate_objid_provenance!
|
185
|
+
|
186
|
+
# Normalize the objid to a consistent hex representation first.
|
187
|
+
normalized_hex = normalize_objid_to_hex(current_objid)
|
188
|
+
|
189
|
+
# Use the objid's randomness to create a deterministic, yet secure,
|
190
|
+
# external identifier. We do not use SecureRandom here because the output
|
191
|
+
# must be deterministic.
|
192
|
+
#
|
193
|
+
# The process is as follows:
|
194
|
+
# 1. The objid (a high-entropy value) is hashed to create a uniform seed.
|
195
|
+
# 2. The seed initializes a standard PRNG (Random.new).
|
196
|
+
# 3. The PRNG acts as a deterministic function to generate a sequence of
|
197
|
+
# bytes that appears random, obscuring the original objid.
|
198
|
+
|
199
|
+
# 1. Create a high-quality, uniform seed from the objid's entropy.
|
200
|
+
seed = Digest::SHA256.digest(normalized_hex)
|
201
|
+
|
202
|
+
# 2. Initialize a PRNG with the seed. The same seed will always produce
|
203
|
+
# the same sequence of "random" numbers.
|
204
|
+
prng = Random.new(seed.unpack1('Q>'))
|
205
|
+
|
206
|
+
# 3. Generate 16 bytes (128 bits) of deterministic output.
|
207
|
+
random_bytes = prng.bytes(16)
|
208
|
+
|
209
|
+
# Encode as a base36 string for a compact, URL-safe identifier.
|
210
|
+
# 128 bits is approximately 25 characters in base36.
|
211
|
+
external_part = random_bytes.unpack1('H*').to_i(16).to_s(36).rjust(25, '0')
|
212
|
+
|
213
|
+
# Get prefix from feature options, default to "ext"
|
214
|
+
options = self.class.feature_options(:external_identifier)
|
215
|
+
prefix = options[:prefix] || 'ext'
|
216
|
+
|
217
|
+
"#{prefix}_#{external_part}"
|
218
|
+
end
|
219
|
+
|
220
|
+
# Full-length alias for extid for clarity when needed
|
221
|
+
#
|
222
|
+
# @return [String] The external identifier
|
223
|
+
#
|
224
|
+
def external_identifier
|
225
|
+
extid
|
226
|
+
end
|
227
|
+
|
228
|
+
# Full-length alias setter for extid
|
229
|
+
#
|
230
|
+
# @param value [String] The external identifier to set
|
231
|
+
#
|
232
|
+
def external_identifier=(value)
|
233
|
+
self.extid = value
|
234
|
+
end
|
235
|
+
|
236
|
+
def destroy!
|
237
|
+
# Clean up extid mapping when object is destroyed
|
238
|
+
current_extid = instance_variable_get(:@extid)
|
239
|
+
self.class.extid_lookup.del(current_extid) if current_extid
|
240
|
+
|
241
|
+
super if defined?(super)
|
242
|
+
end
|
243
|
+
|
244
|
+
private
|
245
|
+
|
246
|
+
# Validate that objid comes from a known secure ObjectIdentifier generator
|
247
|
+
#
|
248
|
+
# This ensures we only derive external identifiers from objid values that
|
249
|
+
# have known provenance and security properties. External identifiers derived
|
250
|
+
# from objid values of unknown origin cannot provide security guarantees.
|
251
|
+
#
|
252
|
+
# @raise [ExternalIdentifierError] if objid has unknown provenance
|
253
|
+
#
|
254
|
+
def validate_objid_provenance!
|
255
|
+
# Check if we have provenance information about the objid generator
|
256
|
+
generator_used = objid_generator_used
|
257
|
+
|
258
|
+
if generator_used.nil?
|
259
|
+
error_msg = <<~MSG.strip
|
260
|
+
Cannot derive external identifier: objid provenance unknown.
|
261
|
+
External identifiers can only be derived from objid values created
|
262
|
+
by the ObjectIdentifier feature to ensure security guarantees.
|
263
|
+
MSG
|
264
|
+
raise ExternalIdentifierError, error_msg
|
265
|
+
end
|
266
|
+
|
267
|
+
# Additional validation: ensure the ObjectIdentifier feature is active
|
268
|
+
return if self.class.features_enabled.include?(:object_identifier)
|
269
|
+
|
270
|
+
raise ExternalIdentifierError,
|
271
|
+
'ExternalIdentifier requires ObjectIdentifier feature for secure provenance.'
|
272
|
+
end
|
273
|
+
|
274
|
+
# Normalize objid to hex format based on the known generator type
|
275
|
+
#
|
276
|
+
# Since we track which generator was used, we can safely normalize the objid
|
277
|
+
# to hex format without relying on string pattern matching. This eliminates
|
278
|
+
# the ambiguity between uuid7, uuid4, and hex formats.
|
279
|
+
#
|
280
|
+
# @param objid_value [String] The objid to normalize
|
281
|
+
# @return [String] Hex string suitable for SecureIdentifier processing
|
282
|
+
#
|
283
|
+
def normalize_objid_to_hex(objid_value)
|
284
|
+
generator_used = objid_generator_used
|
285
|
+
|
286
|
+
case generator_used
|
287
|
+
when :uuid_v7, :uuid_v4
|
288
|
+
# UUID formats: remove hyphens to get 128-bit hex string
|
289
|
+
objid_value.delete('-')
|
290
|
+
when :hex
|
291
|
+
# Already in hex format (256-bit)
|
292
|
+
objid_value
|
293
|
+
else
|
294
|
+
# Custom generator: attempt to normalize, but we can't guarantee format
|
295
|
+
normalized = objid_value.to_s.delete('-')
|
296
|
+
unless normalized.match?(/\A[0-9a-fA-F]+\z/)
|
297
|
+
error_msg = <<~MSG.strip
|
298
|
+
Cannot normalize objid from custom generator #{generator_used}:
|
299
|
+
value must be in hexadecimal format, got: #{objid_value}
|
300
|
+
MSG
|
301
|
+
raise ExternalIdentifierError, error_msg
|
302
|
+
end
|
303
|
+
normalized
|
304
|
+
end
|
305
|
+
end
|
306
|
+
|
307
|
+
Familia::Base.add_feature self, :external_identifier, depends_on: [:object_identifier]
|
308
|
+
end
|
309
|
+
end
|
310
|
+
end
|
@@ -0,0 +1,307 @@
|
|
1
|
+
# lib/familia/features/object_identifier.rb
|
2
|
+
|
3
|
+
module Familia
|
4
|
+
module Features
|
5
|
+
# ObjectIdentifier is a feature that provides unique object identifier management
|
6
|
+
# with configurable generation strategies. Object identifiers are crucial for
|
7
|
+
# distinguishing objects in distributed systems and providing stable references.
|
8
|
+
#
|
9
|
+
# Object identifiers are:
|
10
|
+
# - Unique across the system
|
11
|
+
# - Persistent (stored in Redis/Valkey)
|
12
|
+
# - Lazily generated (only when first accessed)
|
13
|
+
# - Configurable (multiple generation strategies available)
|
14
|
+
# - Preserved during initialization (existing IDs never regenerated)
|
15
|
+
#
|
16
|
+
# Generation Strategies:
|
17
|
+
# - :uuid_v7 (default) - UUID version 7 with embedded timestamp for sortability
|
18
|
+
# - :uuid_v4 - UUID version 4 for compatibility with legacy systems
|
19
|
+
# - :hex - High-entropy hexadecimal identifier using SecureIdentifier
|
20
|
+
# - Proc - Custom generation logic provided as a callable
|
21
|
+
#
|
22
|
+
# Example Usage:
|
23
|
+
#
|
24
|
+
# # Default UUID v7 generation
|
25
|
+
# class User < Familia::Horreum
|
26
|
+
# feature :object_identifier
|
27
|
+
# field :email
|
28
|
+
# end
|
29
|
+
#
|
30
|
+
# user = User.new(email: 'user@example.com')
|
31
|
+
# user.objid # => "01234567-89ab-7def-8000-123456789abc" (UUID v7)
|
32
|
+
#
|
33
|
+
# # UUID v4 for legacy compatibility
|
34
|
+
# class LegacyUser < Familia::Horreum
|
35
|
+
# feature :object_identifier, generator: :uuid_v4
|
36
|
+
# field :email
|
37
|
+
# end
|
38
|
+
#
|
39
|
+
# legacy = LegacyUser.new(email: 'legacy@example.com')
|
40
|
+
# legacy.objid # => "f47ac10b-58cc-4372-a567-0e02b2c3d479" (UUID v4)
|
41
|
+
#
|
42
|
+
# # High-entropy hex for security-sensitive applications
|
43
|
+
# class SecureDocument < Familia::Horreum
|
44
|
+
# feature :object_identifier, generator: :hex
|
45
|
+
# field :title
|
46
|
+
# end
|
47
|
+
#
|
48
|
+
# doc = SecureDocument.new(title: 'Classified')
|
49
|
+
# doc.objid # => "a1b2c3d4e5f6..." (256-bit hex)
|
50
|
+
#
|
51
|
+
# # Custom generation strategy
|
52
|
+
# class TimestampedItem < Familia::Horreum
|
53
|
+
# feature :object_identifier, generator: -> { "item_#{Time.now.to_i}_#{SecureRandom.hex(4)}" }
|
54
|
+
# field :data
|
55
|
+
# end
|
56
|
+
#
|
57
|
+
# item = TimestampedItem.new(data: 'test')
|
58
|
+
# item.objid # => "item_1693857600_a1b2c3d4"
|
59
|
+
#
|
60
|
+
# Data Integrity Guarantees:
|
61
|
+
#
|
62
|
+
# The feature preserves the object identifier passed during initialization,
|
63
|
+
# ensuring that existing objects loaded from Redis maintain their IDs:
|
64
|
+
#
|
65
|
+
# # Loading existing object from Redis preserves ID
|
66
|
+
# existing = User.new(objid: 'existing-uuid-value', email: 'existing@example.com')
|
67
|
+
# existing.objid # => "existing-uuid-value" (preserved, not regenerated)
|
68
|
+
#
|
69
|
+
# Performance Characteristics:
|
70
|
+
#
|
71
|
+
# - Lazy Generation: IDs generated only when first accessed
|
72
|
+
# - Thread-Safe: Generator strategy configured once during initialization
|
73
|
+
# - Memory Efficient: No unnecessary ID generation for unused objects
|
74
|
+
# - Redis Efficient: Only persists non-nil values to conserve memory
|
75
|
+
#
|
76
|
+
# Security Considerations:
|
77
|
+
#
|
78
|
+
# - UUID v7 includes timestamp information (may leak timing data)
|
79
|
+
# - UUID v4 provides strong randomness without timing correlation
|
80
|
+
# - Hex generator provides maximum entropy (256 bits) for security-critical use cases
|
81
|
+
# - Custom generators allow domain-specific security requirements
|
82
|
+
#
|
83
|
+
module ObjectIdentifier
|
84
|
+
DEFAULT_GENERATOR = :uuid_v7
|
85
|
+
|
86
|
+
def self.included(base)
|
87
|
+
Familia.trace :LOADED, self, base, caller(1..1) if Familia.debug?
|
88
|
+
base.extend ClassMethods
|
89
|
+
|
90
|
+
# Ensure default generator is set in feature options
|
91
|
+
base.add_feature_options(:object_identifier, generator: DEFAULT_GENERATOR)
|
92
|
+
|
93
|
+
# Register the objid field using a simple custom field type
|
94
|
+
base.register_field_type(ObjectIdentifierFieldType.new(:objid, as: :objid, fast_method: false))
|
95
|
+
end
|
96
|
+
|
97
|
+
# ObjectIdentifierFieldType - Generate a unique object identifier
|
98
|
+
#
|
99
|
+
# Object identifier fields automatically generate unique identifiers when first
|
100
|
+
# accessed if not already set. The generation strategy is configurable via
|
101
|
+
# feature options. These fields preserve any values set during initialization
|
102
|
+
# to ensure data integrity when loading existing objects from Redis.
|
103
|
+
#
|
104
|
+
# The field type tracks the generator used for each objid to provide provenance
|
105
|
+
# information for security-sensitive operations like external identifier generation.
|
106
|
+
# This ensures that downstream features can validate the source and format of
|
107
|
+
# object identifiers without relying on string pattern matching, which cannot
|
108
|
+
# reliably distinguish between uuid7, uuid4, or hex formats in all cases.
|
109
|
+
#
|
110
|
+
# @example Using object identifier fields
|
111
|
+
# class User < Familia::Horreum
|
112
|
+
# feature :object_identifier, generator: :uuid_v7
|
113
|
+
# end
|
114
|
+
#
|
115
|
+
# user = User.new
|
116
|
+
# user.objid # Generates UUID v7 on first access
|
117
|
+
# user.objid_generator_used # => :uuid_v7
|
118
|
+
#
|
119
|
+
# # Loading existing object preserves ID but cannot determine original generator
|
120
|
+
# user2 = User.new(objid: "existing-uuid")
|
121
|
+
# user2.objid # Returns "existing-uuid", not regenerated
|
122
|
+
# user2.objid_generator_used # => nil (unknown provenance)
|
123
|
+
#
|
124
|
+
class ObjectIdentifierFieldType < Familia::FieldType
|
125
|
+
# Override getter to provide lazy generation with configured strategy
|
126
|
+
#
|
127
|
+
# Generates the identifier using the configured strategy if not already set.
|
128
|
+
# This preserves any values set during initialization while providing
|
129
|
+
# automatic generation for new objects.
|
130
|
+
#
|
131
|
+
# @param klass [Class] The class to define the method on
|
132
|
+
#
|
133
|
+
def define_getter(klass)
|
134
|
+
field_name = @name
|
135
|
+
method_name = @method_name
|
136
|
+
|
137
|
+
handle_method_conflict(klass, method_name) do
|
138
|
+
klass.define_method method_name do
|
139
|
+
# Check if we already have a value (from initialization or previous generation)
|
140
|
+
existing_value = instance_variable_get(:"@#{field_name}")
|
141
|
+
return existing_value unless existing_value.nil?
|
142
|
+
|
143
|
+
# Generate new identifier using configured strategy
|
144
|
+
generated_id = generate_object_identifier
|
145
|
+
instance_variable_set(:"@#{field_name}", generated_id)
|
146
|
+
|
147
|
+
# Track which generator was used for provenance
|
148
|
+
options = self.class.feature_options(:object_identifier)
|
149
|
+
generator = options[:generator] || DEFAULT_GENERATOR
|
150
|
+
instance_variable_set(:"@#{field_name}_generator_used", generator)
|
151
|
+
|
152
|
+
generated_id
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
# Define getter for generator provenance tracking
|
157
|
+
handle_method_conflict(klass, :"#{method_name}_generator_used") do
|
158
|
+
klass.define_method :"#{method_name}_generator_used" do
|
159
|
+
instance_variable_get(:"@#{field_name}_generator_used")
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
# Override setter to preserve values during initialization
|
165
|
+
#
|
166
|
+
# This ensures that values passed during object initialization
|
167
|
+
# (e.g., when loading from Redis) are preserved and not overwritten
|
168
|
+
# by the lazy generation logic.
|
169
|
+
#
|
170
|
+
# @param klass [Class] The class to define the method on
|
171
|
+
#
|
172
|
+
def define_setter(klass)
|
173
|
+
field_name = @name
|
174
|
+
method_name = @method_name
|
175
|
+
|
176
|
+
handle_method_conflict(klass, :"#{method_name}=") do
|
177
|
+
klass.define_method :"#{method_name}=" do |value|
|
178
|
+
instance_variable_set(:"@#{field_name}", value)
|
179
|
+
|
180
|
+
# When setting objid from external source (e.g., loading from Redis),
|
181
|
+
# we cannot determine the original generator, so we clear the provenance
|
182
|
+
# tracking to indicate unknown origin. This prevents false assumptions
|
183
|
+
# about the security properties of externally-provided identifiers.
|
184
|
+
instance_variable_set(:"@#{field_name}_generator_used", nil)
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
# Object identifier fields are persisted to database
|
190
|
+
#
|
191
|
+
# @return [Boolean] true - An object identifier is always persisted
|
192
|
+
#
|
193
|
+
def persistent?
|
194
|
+
true
|
195
|
+
end
|
196
|
+
|
197
|
+
# Category for object identifier fields
|
198
|
+
#
|
199
|
+
# @return [Symbol] :object_identifier
|
200
|
+
#
|
201
|
+
def category
|
202
|
+
:object_identifier
|
203
|
+
end
|
204
|
+
end
|
205
|
+
|
206
|
+
module ClassMethods
|
207
|
+
# Generate a new object identifier using the configured strategy
|
208
|
+
#
|
209
|
+
# @return [String] A new unique identifier
|
210
|
+
#
|
211
|
+
def generate_object_identifier
|
212
|
+
options = feature_options(:object_identifier)
|
213
|
+
generator = options[:generator] || DEFAULT_GENERATOR
|
214
|
+
|
215
|
+
case generator
|
216
|
+
when :uuid_v7
|
217
|
+
SecureRandom.uuid_v7
|
218
|
+
when :uuid_v4
|
219
|
+
SecureRandom.uuid_v4
|
220
|
+
when :hex
|
221
|
+
Familia.generate_hex_id
|
222
|
+
when Proc
|
223
|
+
generator.call
|
224
|
+
else
|
225
|
+
unless generator.respond_to?(:call)
|
226
|
+
raise Familia::Problem, "Invalid object identifier generator: #{generator.inspect}"
|
227
|
+
end
|
228
|
+
|
229
|
+
generator.call
|
230
|
+
|
231
|
+
end
|
232
|
+
end
|
233
|
+
|
234
|
+
# Find an object by its object identifier
|
235
|
+
#
|
236
|
+
# @param objid [String] The object identifier to search for
|
237
|
+
# @return [Object, nil] The object if found, nil otherwise
|
238
|
+
#
|
239
|
+
def find_by_objid(objid)
|
240
|
+
return nil if objid.to_s.empty?
|
241
|
+
|
242
|
+
if Familia.debug?
|
243
|
+
reference = caller(1..1).first
|
244
|
+
Familia.trace :FIND_BY_OBJID, Familia.dbclient, objid, reference
|
245
|
+
end
|
246
|
+
|
247
|
+
# Use the object identifier as the key for lookup
|
248
|
+
# This is a simple stub implementation - would need more sophisticated
|
249
|
+
# search logic in a real application
|
250
|
+
find_by_id(objid)
|
251
|
+
rescue Familia::NotFound
|
252
|
+
nil
|
253
|
+
end
|
254
|
+
end
|
255
|
+
|
256
|
+
# Instance method for generating object identifier using configured strategy
|
257
|
+
#
|
258
|
+
# This method is called by the ObjectIdentifierFieldType when lazy generation
|
259
|
+
# is needed. It uses the class-level generator configuration to create new IDs.
|
260
|
+
#
|
261
|
+
# @return [String] A newly generated unique identifier
|
262
|
+
# @private
|
263
|
+
#
|
264
|
+
def generate_object_identifier
|
265
|
+
self.class.generate_object_identifier
|
266
|
+
end
|
267
|
+
|
268
|
+
# Full-length alias for objid for clarity when needed
|
269
|
+
#
|
270
|
+
# @return [String] The object identifier
|
271
|
+
#
|
272
|
+
def object_identifier
|
273
|
+
objid
|
274
|
+
end
|
275
|
+
|
276
|
+
# Full-length alias setter for objid
|
277
|
+
#
|
278
|
+
# @param value [String] The object identifier to set
|
279
|
+
#
|
280
|
+
def object_identifier=(value)
|
281
|
+
self.objid = value
|
282
|
+
end
|
283
|
+
|
284
|
+
# Initialize object identifier configuration
|
285
|
+
#
|
286
|
+
# Called during object initialization to set up the ID generation strategy.
|
287
|
+
# This hook is called AFTER field initialization, ensuring that any objid
|
288
|
+
# values passed during construction are preserved.
|
289
|
+
#
|
290
|
+
def init
|
291
|
+
super if defined?(super)
|
292
|
+
|
293
|
+
# The generator strategy is configured at the class level via feature options.
|
294
|
+
# We don't need to store it per-instance since it's consistent for the class.
|
295
|
+
# The actual generation happens lazily in the getter when needed.
|
296
|
+
|
297
|
+
return unless Familia.debug?
|
298
|
+
|
299
|
+
options = self.class.feature_options(:object_identifier)
|
300
|
+
generator = options[:generator] || DEFAULT_GENERATOR
|
301
|
+
Familia.trace :OBJID_INIT, dbclient, "Generator strategy: #{generator}", caller(1..1)
|
302
|
+
end
|
303
|
+
|
304
|
+
Familia::Base.add_feature self, :object_identifier, depends_on: []
|
305
|
+
end
|
306
|
+
end
|
307
|
+
end
|