familia 2.0.0.pre7 → 2.0.0.pre8

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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +1 -1
  3. data/Gemfile.lock +3 -3
  4. data/README.md +35 -0
  5. data/docs/wiki/Feature-System-Guide.md +0 -15
  6. data/lib/familia/features/external_identifiers/external_identifier_field_type.rb +120 -0
  7. data/lib/familia/features/external_identifiers.rb +111 -0
  8. data/lib/familia/features/object_identifiers/object_identifier_field_type.rb +91 -0
  9. data/lib/familia/features/object_identifiers.rb +194 -0
  10. data/lib/familia/features/relationships/cascading.rb +0 -1
  11. data/lib/familia/features/relationships/indexing.rb +0 -1
  12. data/lib/familia/features/relationships/membership.rb +0 -1
  13. data/lib/familia/features/relationships/querying.rb +7 -12
  14. data/lib/familia/features/relationships/score_encoding.rb +1 -3
  15. data/lib/familia/features/relationships/tracking.rb +0 -1
  16. data/lib/familia/features/transient_fields.rb +8 -10
  17. data/lib/familia/features.rb +16 -13
  18. data/lib/familia/horreum/core/serialization.rb +2 -5
  19. data/lib/familia/horreum/subclass/definition.rb +34 -0
  20. data/lib/familia/version.rb +1 -3
  21. data/try/core/errors_try.rb +1 -1
  22. data/try/features/{encrypted_fields_core_try.rb → encrypted_fields/encrypted_fields_core_try.rb} +1 -1
  23. data/try/features/{encrypted_fields_integration_try.rb → encrypted_fields/encrypted_fields_integration_try.rb} +1 -1
  24. data/try/features/{encrypted_fields_no_cache_security_try.rb → encrypted_fields/encrypted_fields_no_cache_security_try.rb} +1 -1
  25. data/try/features/{encrypted_fields_security_try.rb → encrypted_fields/encrypted_fields_security_try.rb} +1 -1
  26. data/try/features/{expiration_try.rb → expiration/expiration_try.rb} +1 -1
  27. data/try/features/external_identifiers/external_identifiers_try.rb +203 -0
  28. data/try/features/object_identifiers/object_identifiers_integration_try.rb +289 -0
  29. data/try/features/object_identifiers/object_identifiers_try.rb +191 -0
  30. data/try/features/{quantization_try.rb → quantization/quantization_try.rb} +1 -1
  31. data/try/features/{categorical_permissions_try.rb → relationships/categorical_permissions_try.rb} +1 -1
  32. data/try/features/{relationships_edge_cases_try.rb → relationships/relationships_edge_cases_try.rb} +1 -1
  33. data/try/features/{relationships_performance_minimal_try.rb → relationships/relationships_performance_minimal_try.rb} +1 -1
  34. data/try/features/{relationships_performance_simple_try.rb → relationships/relationships_performance_simple_try.rb} +1 -1
  35. data/try/features/{relationships_performance_try.rb → relationships/relationships_performance_try.rb} +1 -1
  36. data/try/features/{relationships_performance_working_try.rb → relationships/relationships_performance_working_try.rb} +1 -1
  37. data/try/features/{relationships_try.rb → relationships/relationships_try.rb} +1 -1
  38. data/try/features/{safe_dump_advanced_try.rb → safe_dump/safe_dump_advanced_try.rb} +1 -1
  39. data/try/features/{safe_dump_try.rb → safe_dump/safe_dump_try.rb} +1 -1
  40. data/try/features/{transient_fields_core_try.rb → transient_fields/transient_fields_core_try.rb} +1 -1
  41. data/try/features/{transient_fields_integration_try.rb → transient_fields/transient_fields_integration_try.rb} +1 -1
  42. metadata +38 -31
  43. /data/try/features/{encryption_fields → encrypted_fields}/aad_protection_try.rb +0 -0
  44. /data/try/features/{encryption_fields → encrypted_fields}/concealed_string_core_try.rb +0 -0
  45. /data/try/features/{encryption_fields → encrypted_fields}/context_isolation_try.rb +0 -0
  46. /data/try/features/{encryption_fields → encrypted_fields}/error_conditions_try.rb +0 -0
  47. /data/try/features/{encryption_fields → encrypted_fields}/fresh_key_derivation_try.rb +0 -0
  48. /data/try/features/{encryption_fields → encrypted_fields}/fresh_key_try.rb +0 -0
  49. /data/try/features/{encryption_fields → encrypted_fields}/key_rotation_try.rb +0 -0
  50. /data/try/features/{encryption_fields → encrypted_fields}/memory_security_try.rb +0 -0
  51. /data/try/features/{encryption_fields → encrypted_fields}/missing_current_key_version_try.rb +0 -0
  52. /data/try/features/{encryption_fields → encrypted_fields}/nonce_uniqueness_try.rb +0 -0
  53. /data/try/features/{encryption_fields → encrypted_fields}/secure_by_default_behavior_try.rb +0 -0
  54. /data/try/features/{encryption_fields → encrypted_fields}/thread_safety_try.rb +0 -0
  55. /data/try/features/{encryption_fields → encrypted_fields}/universal_serialization_safety_try.rb +0 -0
@@ -303,17 +303,15 @@ module Familia
303
303
  permission_bits = decoded[:permissions]
304
304
 
305
305
  # Check if this member has the required permission bits
306
- if (permission_bits & required_bits) == required_bits
307
- valid_members << [score, member]
308
- end
306
+ valid_members << [score, member] if (permission_bits & required_bits) == required_bits
309
307
  end
310
308
 
311
309
  # Recreate filtered collection if we have valid members
312
- if valid_members.any?
313
- dbclient.zadd(filtered_key, valid_members)
314
- dbclient.expire(filtered_key, 300) # Temporary key cleanup
315
- filtered_keys << filtered_key
316
- end
310
+ next unless valid_members.any?
311
+
312
+ dbclient.zadd(filtered_key, valid_members)
313
+ dbclient.expire(filtered_key, 300) # Temporary key cleanup
314
+ filtered_keys << filtered_key
317
315
  end
318
316
 
319
317
  filtered_keys
@@ -341,9 +339,7 @@ module Familia
341
339
  permission_bits = decoded[:permissions]
342
340
 
343
341
  # Check if this member has the required permission bits
344
- if (permission_bits & required_bits) == required_bits
345
- valid_members << [score, member]
346
- end
342
+ valid_members << [score, member] if (permission_bits & required_bits) == required_bits
347
343
  end
348
344
 
349
345
  # Create filtered collection
@@ -613,7 +609,6 @@ module Familia
613
609
  collection_info
614
610
  end
615
611
  end
616
-
617
612
  end
618
613
  end
619
614
  end
@@ -40,7 +40,7 @@ module Familia
40
40
  configure: 0b00010000, # 16 - Change settings
41
41
  delete: 0b00100000, # 32 - Remove items
42
42
  transfer: 0b01000000, # 64 - Change ownership
43
- admin: 0b10000000 # 128 - Full control
43
+ admin: 0b10000000, # 128 - Full control
44
44
  }.freeze
45
45
 
46
46
  # Predefined permission combinations
@@ -60,7 +60,6 @@ module Familia
60
60
  owner: 0b11111111 # All permissions
61
61
  }.freeze
62
62
 
63
-
64
63
  class << self
65
64
  # Get permission bit flag value for a permission symbol
66
65
  #
@@ -93,7 +92,6 @@ module Familia
93
92
  }
94
93
  end
95
94
 
96
-
97
95
  # Encode a timestamp and permissions into a Redis score
98
96
  #
99
97
  # @param timestamp [Time, Integer] The timestamp to encode
@@ -372,7 +372,6 @@ module Familia
372
372
  memberships
373
373
  end
374
374
  end
375
-
376
375
  end
377
376
  end
378
377
  end
@@ -182,9 +182,7 @@ module Familia
182
182
  def clear_transient_fields!
183
183
  self.class.transient_fields.each do |field_name|
184
184
  field_value = instance_variable_get("@#{field_name}")
185
- if field_value.respond_to?(:clear!)
186
- field_value.clear!
187
- end
185
+ field_value.clear! if field_value.respond_to?(:clear!)
188
186
  end
189
187
  end
190
188
 
@@ -213,13 +211,13 @@ module Familia
213
211
  def transient_fields_summary
214
212
  self.class.transient_fields.each_with_object({}) do |field_name, summary|
215
213
  field_value = instance_variable_get("@#{field_name}")
216
- if field_value.nil?
217
- summary[field_name] = nil
218
- elsif field_value.respond_to?(:cleared?) && field_value.cleared?
219
- summary[field_name] = "[CLEARED]"
220
- else
221
- summary[field_name] = "[REDACTED]"
222
- end
214
+ summary[field_name] = if field_value.nil?
215
+ nil
216
+ elsif field_value.respond_to?(:cleared?) && field_value.cleared?
217
+ '[CLEARED]'
218
+ else
219
+ '[REDACTED]'
220
+ end
223
221
  end
224
222
  end
225
223
 
@@ -1,17 +1,15 @@
1
1
  # lib/familia/features.rb
2
2
 
3
3
  module Familia
4
-
5
4
  FeatureDefinition = Data.define(:name, :depends_on)
6
5
 
7
6
  # Familia::Features
8
7
  #
9
8
  module Features
10
-
11
9
  @features_enabled = nil
12
10
  attr_reader :features_enabled
13
11
 
14
- def feature(feature_name = nil)
12
+ def feature(feature_name = nil, **options)
15
13
  @features_enabled ||= []
16
14
 
17
15
  return features_enabled if feature_name.nil?
@@ -28,22 +26,28 @@ module Familia
28
26
  return
29
27
  end
30
28
 
31
- if Familia.debug?
32
- Familia.trace :FEATURE, nil, "#{self} includes #{feature_name.inspect}", caller(1..1)
29
+ Familia.trace :FEATURE, nil, "#{self} includes #{feature_name.inspect}", caller(1..1) if Familia.debug?
30
+
31
+ # Check dependencies and raise error if missing
32
+ feature_def = Familia::Base.feature_definitions[feature_name]
33
+ if feature_def&.depends_on&.any?
34
+ missing = feature_def.depends_on - features_enabled
35
+ if missing.any?
36
+ raise Familia::Problem,
37
+ "Feature #{feature_name} requires missing dependencies: #{missing.join(', ')}"
38
+ end
33
39
  end
34
40
 
35
41
  # Add it to the list available features_enabled for Familia::Base classes.
36
42
  features_enabled << feature_name
37
43
 
38
- klass = Familia::Base.features_available[feature_name]
39
-
40
- # Validate dependencies
41
- feature_def = Familia::Base.feature_definitions[feature_name]
42
- if feature_def&.depends_on&.any?
43
- missing = feature_def.depends_on - features_enabled
44
- raise Familia::Problem, "#{feature_name} requires: #{missing.join(', ')}" if missing.any?
44
+ # Store feature options if any were provided using the new pattern
45
+ if options.any?
46
+ add_feature_options(feature_name, **options)
45
47
  end
46
48
 
49
+ klass = Familia::Base.features_available[feature_name]
50
+
47
51
  # Extend the Familia::Base subclass (e.g. Customer) with the feature module
48
52
  include klass
49
53
 
@@ -58,7 +62,6 @@ module Familia
58
62
  # We'd need to extend the DataType instances for each Horreum subclass. That
59
63
  # avoids it getting included multiple times per DataType
60
64
  end
61
-
62
65
  end
63
66
  end
64
67
 
@@ -135,7 +135,7 @@ module Familia
135
135
  multi.hmset(dbkey, to_h_for_storage)
136
136
  end
137
137
 
138
- result.is_a?(Array) # transaction succeeded
138
+ result.is_a?(Array) # transaction succeeded
139
139
  end
140
140
  end
141
141
 
@@ -463,9 +463,7 @@ module Familia
463
463
  #
464
464
  def serialize_value(val)
465
465
  # Security: Handle ConcealedString safely - extract encrypted data for storage
466
- if val.respond_to?(:encrypted_value)
467
- return val.encrypted_value
468
- end
466
+ return val.encrypted_value if val.respond_to?(:encrypted_value)
469
467
 
470
468
  prepared = Familia.distinguisher(val, strict_values: false)
471
469
 
@@ -530,6 +528,5 @@ module Familia
530
528
  end
531
529
  end
532
530
  end
533
-
534
531
  end
535
532
  end
@@ -235,6 +235,40 @@ module Familia
235
235
  field_types[field_type.name] = field_type
236
236
  end
237
237
 
238
+ # Get feature options for a specific feature or all features
239
+ #
240
+ # @param feature_name [Symbol, nil] The feature name to get options for
241
+ # @return [Hash] The options hash for the feature, or empty hash if none
242
+ #
243
+ def feature_options(feature_name = nil)
244
+ @feature_options ||= {}
245
+ return @feature_options if feature_name.nil?
246
+
247
+ @feature_options[feature_name.to_sym] || {}
248
+ end
249
+
250
+ # Add feature options for a specific feature
251
+ #
252
+ # This method provides a clean way for features to set their default options
253
+ # without worrying about initialization state. Similar to register_field_type
254
+ # for field types.
255
+ #
256
+ # @param feature_name [Symbol] The feature name
257
+ # @param options [Hash] The options to add/merge
258
+ # @return [Hash] The updated options for the feature
259
+ #
260
+ def add_feature_options(feature_name, **options)
261
+ @feature_options ||= {}
262
+ @feature_options[feature_name.to_sym] ||= {}
263
+
264
+ # Only set defaults for options that don't already exist
265
+ options.each do |key, value|
266
+ @feature_options[feature_name.to_sym][key] ||= value
267
+ end
268
+
269
+ @feature_options[feature_name.to_sym]
270
+ end
271
+
238
272
  # Create and register a transient field type
239
273
  #
240
274
  # @param name [Symbol] The field name
@@ -2,7 +2,5 @@
2
2
 
3
3
  module Familia
4
4
  # Version information for the Familia
5
- unless defined?(Familia::VERSION)
6
- VERSION = '2.0.0.pre7'
7
- end
5
+ VERSION = '2.0.0.pre8'.freeze unless defined?(Familia::VERSION)
8
6
  end
@@ -58,7 +58,7 @@ begin
58
58
  rescue Familia::NotConnected => e
59
59
  e.message.include?('No client for')
60
60
  end
61
- # > true
61
+ #=> true
62
62
 
63
63
  ## KeyNotFoundError stores key
64
64
  begin
@@ -1,6 +1,6 @@
1
1
  # try/features/encrypted_fields_core_try.rb
2
2
 
3
- require_relative '../helpers/test_helpers'
3
+ require_relative '../../helpers/test_helpers'
4
4
  require 'base64'
5
5
 
6
6
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Test constants will be redefined in each test since variables don't persist
4
4
 
5
- require_relative '../helpers/test_helpers'
5
+ require_relative '../../helpers/test_helpers'
6
6
  require 'base64'
7
7
 
8
8
 
@@ -3,7 +3,7 @@
3
3
  # Security tests for the no-cache encryption strategy
4
4
  # These tests verify that we maintain security properties by NOT caching derived keys
5
5
 
6
- require_relative '../helpers/test_helpers'
6
+ require_relative '../../helpers/test_helpers'
7
7
 
8
8
  test_keys = {
9
9
  v1: Base64.strict_encode64('a' * 32),
@@ -1,6 +1,6 @@
1
1
  # try/features/encrypted_fields_security_try.rb
2
2
 
3
- require_relative '../helpers/test_helpers'
3
+ require_relative '../../helpers/test_helpers'
4
4
  require 'base64'
5
5
 
6
6
  # Define all test classes up front to avoid tryouts retry conflicts
@@ -1,6 +1,6 @@
1
1
  # try/features/expiration_try.rb
2
2
 
3
- require_relative '../helpers/test_helpers'
3
+ require_relative '../../helpers/test_helpers'
4
4
 
5
5
  Familia.debug = false
6
6
 
@@ -0,0 +1,203 @@
1
+ # try/features/external_identifiers_try.rb
2
+
3
+ require_relative '../../helpers/test_helpers'
4
+
5
+ Familia.debug = false
6
+
7
+ # Test ExternalIdentifiers feature functionality
8
+
9
+ # Basic class using external identifiers
10
+ class ExternalIdTest < Familia::Horreum
11
+ feature :object_identifiers
12
+ feature :external_identifiers
13
+ identifier_field :id
14
+ field :id
15
+ field :name
16
+ end
17
+
18
+ # Class with custom prefix
19
+ class CustomPrefixTest < Familia::Horreum
20
+ feature :object_identifiers
21
+ feature :external_identifiers, prefix: 'cust'
22
+ identifier_field :id
23
+ field :id
24
+ field :name
25
+ end
26
+
27
+ # Class testing data integrity preservation
28
+ class ExternalDataIntegrityTest < Familia::Horreum
29
+ feature :object_identifiers
30
+ feature :external_identifiers
31
+ identifier_field :id
32
+ field :id
33
+ field :name
34
+ end
35
+
36
+ # Test with existing external ID during initialization
37
+ @existing_ext_obj = ExternalDataIntegrityTest.new(id: 'test_id', extid: 'preset_ext_123', name: 'Preset External')
38
+
39
+ # Test objects for lazy generation and complex initialization
40
+ @lazy_obj = ExternalIdTest.new
41
+ @complex_obj = ExternalIdTest.new(id: 'complex_ext', name: 'Complex External')
42
+
43
+ ## Feature depends on object_identifiers
44
+ ExternalIdTest.features_enabled.include?(:object_identifiers)
45
+ #==> true
46
+
47
+ ## External identifiers feature is included
48
+ ExternalIdTest.features_enabled.include?(:external_identifiers)
49
+ #==> true
50
+
51
+ ## Class has extid field defined
52
+ ExternalIdTest.respond_to?(:extid)
53
+ #==> true
54
+
55
+ ## Object has extid accessor
56
+ obj = ExternalIdTest.new
57
+ obj.respond_to?(:extid)
58
+ #==> true
59
+
60
+ ## External ID is generated from objid deterministically
61
+ obj = ExternalIdTest.new
62
+ obj.id = 'test_obj'
63
+ obj.name = 'Test Object'
64
+ objid = obj.objid
65
+ extid = obj.extid
66
+ # Same objid should always produce same extid
67
+ obj2 = ExternalIdTest.new
68
+ obj2.instance_variable_set(:@objid, objid)
69
+ obj2.extid == extid
70
+ #==> true
71
+
72
+ ## External ID uses default 'ext' prefix
73
+ obj = ExternalIdTest.new
74
+ obj.extid.start_with?('ext_')
75
+ #==> true
76
+
77
+ ## Custom prefix class uses specified prefix
78
+ custom_obj = CustomPrefixTest.new
79
+ custom_obj.extid.start_with?('cust_')
80
+ #==> true
81
+
82
+ ## External ID is URL-safe base-36 format
83
+ obj = ExternalIdTest.new
84
+ extid = obj.extid
85
+ extid.match(/\Aext_[0-9a-z]+\z/)
86
+ #=*> nil
87
+
88
+ ## Custom prefix external ID format is correct
89
+ custom_obj = CustomPrefixTest.new
90
+ extid = custom_obj.extid
91
+ extid.match(/\Acust_[0-9a-z]+\z/)
92
+ #=*> nil
93
+
94
+ ## External ID is lazy - not generated until accessed
95
+ @lazy_obj.instance_variable_get(:@extid)
96
+ #=> nil
97
+
98
+ ## External ID is generated when first accessed
99
+ @lazy_obj.extid
100
+ @lazy_obj.instance_variable_get(:@extid)
101
+ #=*> nil
102
+
103
+ ## External ID value is stable across multiple calls
104
+ first_call = @lazy_obj.extid
105
+ second_call = @lazy_obj.extid
106
+ first_call == second_call
107
+ #==> true
108
+
109
+ ## Data integrity: preset extid is preserved
110
+ @existing_ext_obj.extid
111
+ #=> "preset_ext_123"
112
+
113
+ ## Data integrity: preset extid not regenerated
114
+ @existing_ext_obj.instance_variable_get(:@extid)
115
+ #=> "preset_ext_123"
116
+
117
+ ## find_by_extid class method exists
118
+ ExternalIdTest.respond_to?(:find_by_extid)
119
+ #==> true
120
+
121
+ ## find_by_extid returns correct type
122
+ result = ExternalIdTest.find_by_extid('nonexistent')
123
+ result.is_a?(ExternalIdTest) || result.nil?
124
+ #==> true
125
+
126
+ ## External ID is deterministic from objid
127
+ test_objid = "01234567-89ab-7def-8fed-cba987654321"
128
+ obj1 = ExternalIdTest.new
129
+ obj1.instance_variable_set(:@objid, test_objid)
130
+ obj2 = ExternalIdTest.new
131
+ obj2.instance_variable_set(:@objid, test_objid)
132
+ obj1.extid == obj2.extid
133
+ #==> true
134
+
135
+ ## External ID is different from objid
136
+ obj = ExternalIdTest.new
137
+ obj.objid != obj.extid
138
+ #==> true
139
+
140
+ ## External ID persists through save/load cycle
141
+ save_obj = ExternalIdTest.new
142
+ save_obj.id = 'ext_save_test'
143
+ save_obj.name = 'External Save Test'
144
+ original_extid = save_obj.extid
145
+ save_obj.save
146
+ loaded_obj = ExternalIdTest.new(id: 'ext_save_test')
147
+ loaded_obj.extid == original_extid
148
+ #==> true
149
+
150
+ ## Different objids produce different external IDs
151
+ obj1 = ExternalIdTest.new
152
+ obj2 = ExternalIdTest.new
153
+ obj1.extid != obj2.extid
154
+ #==> true
155
+
156
+ ## extid field type is ExternalIdentifierFieldType
157
+ ExternalIdTest.field_types[:extid]
158
+ #=:> Familia::Features::ExternalIdentifiers::ExternalIdentifierFieldType
159
+
160
+ ## Feature options contain correct prefix
161
+ ExternalIdTest.feature_options(:external_identifiers)[:prefix]
162
+ #=> "ext"
163
+
164
+ ## Custom prefix feature options
165
+ CustomPrefixTest.feature_options(:external_identifiers)[:prefix]
166
+ #=> "cust"
167
+
168
+ ## External ID is shorter than UUID objid
169
+ obj = ExternalIdTest.new
170
+ obj.extid.length < obj.objid.length
171
+ #==> true
172
+
173
+ ## External ID contains only lowercase alphanumeric after prefix
174
+ obj = ExternalIdTest.new
175
+ extid_suffix = obj.extid.split('_', 2)[1]
176
+ extid_suffix.match(/\A[0-9a-z]+\z/)
177
+ #=*> nil
178
+
179
+ ## Complex initialization preserves lazy generation
180
+ @complex_obj.instance_variable_get(:@extid)
181
+ #=> nil
182
+
183
+ ## External ID generation after complex initialization
184
+ @complex_obj.extid
185
+ #=*> nil
186
+
187
+ ## find_by_extid works with saved objects
188
+ @test_obj = ExternalIdTest.new(id: 'findable_test', name: 'Test Object')
189
+ @test_obj.save
190
+ found_obj = ExternalIdTest.find_by_extid(@test_obj.extid)
191
+ found_obj&.id
192
+ #=> "findable_test"
193
+
194
+ ## find_by_extid returns nil for nonexistent extids
195
+ ExternalIdTest.find_by_extid('nonexistent_extid')
196
+ #=> nil
197
+
198
+ ## extid_lookup mapping is maintained
199
+ ExternalIdTest.extid_lookup[@test_obj.extid]
200
+ #=> "findable_test"
201
+
202
+ # Cleanup test objects
203
+ @test_obj.destroy! rescue nil