familia 2.0.0.pre6 → 2.0.0.pre7

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/claude-code-review.yml +57 -0
  3. data/.github/workflows/claude.yml +71 -0
  4. data/.gitignore +5 -1
  5. data/.rubocop.yml +3 -0
  6. data/CLAUDE.md +32 -13
  7. data/Gemfile +2 -2
  8. data/Gemfile.lock +2 -2
  9. data/docs/wiki/Feature-System-Guide.md +36 -5
  10. data/docs/wiki/Home.md +30 -20
  11. data/docs/wiki/Relationships-Guide.md +684 -0
  12. data/examples/bit_encoding_integration.rb +237 -0
  13. data/examples/redis_command_validation_example.rb +231 -0
  14. data/examples/relationships_basic.rb +273 -0
  15. data/lib/familia/connection.rb +3 -3
  16. data/lib/familia/data_type.rb +7 -4
  17. data/lib/familia/features/encrypted_fields/concealed_string.rb +21 -23
  18. data/lib/familia/features/encrypted_fields.rb +413 -4
  19. data/lib/familia/features/expiration.rb +319 -33
  20. data/lib/familia/features/quantization.rb +385 -44
  21. data/lib/familia/features/relationships/cascading.rb +438 -0
  22. data/lib/familia/features/relationships/indexing.rb +370 -0
  23. data/lib/familia/features/relationships/membership.rb +503 -0
  24. data/lib/familia/features/relationships/permission_management.rb +264 -0
  25. data/lib/familia/features/relationships/querying.rb +620 -0
  26. data/lib/familia/features/relationships/redis_operations.rb +274 -0
  27. data/lib/familia/features/relationships/score_encoding.rb +442 -0
  28. data/lib/familia/features/relationships/tracking.rb +379 -0
  29. data/lib/familia/features/relationships.rb +466 -0
  30. data/lib/familia/features/transient_fields.rb +192 -10
  31. data/lib/familia/features.rb +2 -1
  32. data/lib/familia/horreum/subclass/definition.rb +1 -1
  33. data/lib/familia/validation/command_recorder.rb +336 -0
  34. data/lib/familia/validation/expectations.rb +519 -0
  35. data/lib/familia/validation/test_helpers.rb +443 -0
  36. data/lib/familia/validation/validator.rb +412 -0
  37. data/lib/familia/validation.rb +140 -0
  38. data/lib/familia/version.rb +1 -1
  39. data/try/edge_cases/hash_symbolization_try.rb +1 -0
  40. data/try/edge_cases/reserved_keywords_try.rb +1 -0
  41. data/try/edge_cases/string_coercion_try.rb +2 -0
  42. data/try/encryption/encryption_core_try.rb +3 -1
  43. data/try/features/categorical_permissions_try.rb +515 -0
  44. data/try/features/encryption_fields/concealed_string_core_try.rb +3 -0
  45. data/try/features/encryption_fields/context_isolation_try.rb +1 -0
  46. data/try/features/relationships_edge_cases_try.rb +145 -0
  47. data/try/features/relationships_performance_minimal_try.rb +132 -0
  48. data/try/features/relationships_performance_simple_try.rb +155 -0
  49. data/try/features/relationships_performance_try.rb +420 -0
  50. data/try/features/relationships_performance_working_try.rb +144 -0
  51. data/try/features/relationships_try.rb +237 -0
  52. data/try/features/safe_dump_try.rb +3 -0
  53. data/try/features/transient_fields/redacted_string_try.rb +2 -0
  54. data/try/features/transient_fields/single_use_redacted_string_try.rb +2 -0
  55. data/try/helpers/test_helpers.rb +1 -1
  56. data/try/horreum/base_try.rb +14 -8
  57. data/try/horreum/enhanced_conflict_handling_try.rb +2 -0
  58. data/try/horreum/relations_try.rb +1 -1
  59. data/try/validation/atomic_operations_try.rb.disabled +320 -0
  60. data/try/validation/command_validation_try.rb.disabled +207 -0
  61. data/try/validation/performance_validation_try.rb.disabled +324 -0
  62. data/try/validation/real_world_scenarios_try.rb.disabled +390 -0
  63. metadata +32 -4
  64. data/docs/wiki/RelatableObjects-Guide.md +0 -563
  65. data/lib/familia/features/relatable_objects.rb +0 -125
  66. data/try/features/relatable_objects_try.rb +0 -220
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Basic Relationships Example
4
+ # This example demonstrates the core features of Familia's relationships system
5
+
6
+ require_relative '../lib/familia'
7
+
8
+ # Configure Familia for the example
9
+ Familia.configure do |config|
10
+ config.redis_uri = ENV.fetch('REDIS_URI', 'redis://localhost:6379/15')
11
+ end
12
+
13
+ puts "=== Familia Relationships Basic Example ==="
14
+ puts
15
+
16
+ # Define our model classes
17
+ class Customer < Familia::Horreum
18
+ feature :relationships
19
+
20
+ identifier_field :custid
21
+ field :custid, :name, :email, :plan
22
+
23
+ # Define collections for tracking relationships
24
+ set :domains # Simple set of domain IDs
25
+ list :projects # Ordered list of project IDs
26
+ sorted_set :activity # Activity feed with timestamps
27
+
28
+ # Create indexes for fast lookups
29
+ indexed_by :email_lookup, field: :email
30
+ indexed_by :plan_lookup, field: :plan
31
+
32
+ # Track in global collections
33
+ tracked_in :all_customers, type: :sorted_set, score: :created_at
34
+
35
+ def created_at
36
+ Time.now.to_i
37
+ end
38
+ end
39
+
40
+ class Domain < Familia::Horreum
41
+ feature :relationships
42
+
43
+ identifier_field :domain_id
44
+ field :domain_id, :name, :dns_zone, :status
45
+
46
+ # Declare membership in customer collections
47
+ member_of Customer, :domains, type: :set
48
+
49
+ # Track domains by status
50
+ tracked_in :active_domains, type: :sorted_set,
51
+ score: ->(domain) { domain.status == 'active' ? Time.now.to_i : 0 }
52
+ end
53
+
54
+ class Project < Familia::Horreum
55
+ feature :relationships
56
+
57
+ identifier_field :project_id
58
+ field :project_id, :name, :priority
59
+
60
+ # Member of customer projects list (ordered)
61
+ member_of Customer, :projects, type: :list
62
+ end
63
+
64
+ puts "=== 1. Basic Object Creation ==="
65
+
66
+ # Create some sample objects
67
+ customer = Customer.new(
68
+ custid: "cust_#{SecureRandom.hex(4)}",
69
+ name: "Acme Corporation",
70
+ email: "admin@acme.com",
71
+ plan: "enterprise"
72
+ )
73
+
74
+ domain1 = Domain.new(
75
+ domain_id: "dom_#{SecureRandom.hex(4)}",
76
+ name: "acme.com",
77
+ dns_zone: "acme.com.",
78
+ status: "active"
79
+ )
80
+
81
+ domain2 = Domain.new(
82
+ domain_id: "dom_#{SecureRandom.hex(4)}",
83
+ name: "staging.acme.com",
84
+ dns_zone: "staging.acme.com.",
85
+ status: "active"
86
+ )
87
+
88
+ project = Project.new(
89
+ project_id: "proj_#{SecureRandom.hex(4)}",
90
+ name: "Website Redesign",
91
+ priority: "high"
92
+ )
93
+
94
+ puts "✓ Created customer: #{customer.name} (#{customer.custid})"
95
+ puts "✓ Created domains: #{domain1.name}, #{domain2.name}"
96
+ puts "✓ Created project: #{project.name}"
97
+ puts
98
+
99
+ puts "=== 2. Establishing Relationships ==="
100
+
101
+ # Add objects to indexed lookups
102
+ Customer.add_to_email_lookup(customer)
103
+ Customer.add_to_plan_lookup(customer)
104
+ puts "✓ Added customer to email and plan indexes"
105
+
106
+ # Add customer to global tracking
107
+ Customer.add_to_all_customers(customer)
108
+ puts "✓ Added customer to global customer tracking"
109
+
110
+ # Establish member_of relationships (bidirectional)
111
+ domain1.add_to_customer_domains(customer.custid)
112
+ customer.domains.add(domain1.identifier)
113
+
114
+ domain2.add_to_customer_domains(customer.custid)
115
+ customer.domains.add(domain2.identifier)
116
+
117
+ project.add_to_customer_projects(customer.custid)
118
+ customer.projects.add(project.identifier)
119
+
120
+ puts "✓ Established domain ownership relationships"
121
+ puts "✓ Established project ownership relationships"
122
+
123
+ # Track domains in status collections
124
+ Domain.add_to_active_domains(domain1)
125
+ Domain.add_to_active_domains(domain2)
126
+ puts "✓ Added domains to active tracking"
127
+ puts
128
+
129
+ puts "=== 3. Querying Relationships ==="
130
+
131
+ # Test indexed lookups
132
+ found_customer_id = Customer.email_lookup.get(customer.email)
133
+ puts "Email lookup for #{customer.email}: #{found_customer_id}"
134
+
135
+ enterprise_customers = Customer.plan_lookup.get("enterprise")
136
+ puts "Enterprise customer found: #{enterprise_customers}"
137
+
138
+ # Test membership queries
139
+ puts "\nDomain membership checks:"
140
+ puts " #{domain1.name} belongs to customer? #{domain1.in_customer_domains?(customer.custid)}"
141
+ puts " #{domain2.name} belongs to customer? #{domain2.in_customer_domains?(customer.custid)}"
142
+
143
+ puts "\nCustomer collections:"
144
+ puts " Customer has #{customer.domains.size} domains"
145
+ puts " Customer has #{customer.projects.size} projects"
146
+ puts " Domain IDs: #{customer.domains.members}"
147
+ puts " Project IDs: #{customer.projects.members}"
148
+
149
+ # Test tracked_in collections
150
+ all_customers_count = Customer.all_customers.size
151
+ puts "\nGlobal tracking:"
152
+ puts " Total customers in system: #{all_customers_count}"
153
+
154
+ active_domains_count = Domain.active_domains.size
155
+ puts " Active domains in system: #{active_domains_count}"
156
+ puts
157
+
158
+ puts "=== 4. Range Queries ==="
159
+
160
+ # Get recent customers (last 24 hours)
161
+ yesterday = (Time.now - 24.hours).to_i
162
+ recent_customers = Customer.all_customers.range_by_score(yesterday, '+inf')
163
+ puts "Recent customers (last 24h): #{recent_customers.size}"
164
+
165
+ # Get all active domains by score
166
+ active_domain_scores = Domain.active_domains.range_by_score(1, '+inf', with_scores: true)
167
+ puts "Active domains with timestamps:"
168
+ active_domain_scores.each do |domain_id, timestamp|
169
+ puts " #{domain_id}: active since #{Time.at(timestamp.to_i)}"
170
+ end
171
+ puts
172
+
173
+ puts "=== 5. Batch Operations ==="
174
+
175
+ # Create additional test data
176
+ additional_customers = []
177
+ 3.times do |i|
178
+ cust = Customer.new(
179
+ custid: "batch_cust_#{i}",
180
+ name: "Customer #{i}",
181
+ email: "customer#{i}@example.com",
182
+ plan: i.even? ? "basic" : "premium"
183
+ )
184
+ additional_customers << cust
185
+
186
+ # Add to indexes and tracking
187
+ Customer.add_to_email_lookup(cust)
188
+ Customer.add_to_plan_lookup(cust)
189
+ Customer.add_to_all_customers(cust)
190
+ end
191
+
192
+ puts "✓ Created and indexed #{additional_customers.size} additional customers"
193
+
194
+ # Query by plan
195
+ basic_customers = Customer.plan_lookup.get("basic")
196
+ premium_customers = Customer.plan_lookup.get("premium")
197
+ enterprise_customers = Customer.plan_lookup.get("enterprise")
198
+
199
+ puts "\nCustomer distribution by plan:"
200
+ puts " Basic: #{basic_customers ? 1 : 0} customers"
201
+ puts " Premium: #{premium_customers ? 1 : 0} customers"
202
+ puts " Enterprise: #{enterprise_customers ? 1 : 0} customers"
203
+ puts
204
+
205
+ puts "=== 6. Relationship Cleanup ==="
206
+
207
+ # Remove relationships
208
+ puts "Cleaning up relationships..."
209
+
210
+ # Remove from member_of relationships
211
+ domain1.remove_from_customer_domains(customer.custid)
212
+ customer.domains.remove(domain1.identifier)
213
+ puts "✓ Removed #{domain1.name} from customer domains"
214
+
215
+ # Remove from tracking collections
216
+ Domain.active_domains.remove(domain2.identifier)
217
+ puts "✓ Removed #{domain2.name} from active domains"
218
+
219
+ # Verify cleanup
220
+ puts "\nAfter cleanup:"
221
+ puts " Customer domains: #{customer.domains.size}"
222
+ puts " Active domains: #{Domain.active_domains.size}"
223
+ puts
224
+
225
+ puts "=== 7. Advanced Usage - Permission Encoding ==="
226
+
227
+ # Demonstrate basic score encoding for permissions
228
+ class DocumentAccess
229
+ # Permission flags (powers of 2)
230
+ READ = 1
231
+ WRITE = 2
232
+ DELETE = 4
233
+ ADMIN = 8
234
+
235
+ def self.encode_permissions(timestamp, permissions)
236
+ "#{timestamp}.#{permissions}".to_f
237
+ end
238
+
239
+ def self.decode_permissions(score)
240
+ parts = score.to_s.split('.')
241
+ timestamp = parts[0].to_i
242
+ permissions = parts[1] ? parts[1].to_i : 0
243
+ [timestamp, permissions]
244
+ end
245
+ end
246
+
247
+ # Example permission encoding
248
+ now = Time.now.to_i
249
+ read_write_permissions = DocumentAccess::READ | DocumentAccess::WRITE # 3
250
+ admin_permissions = DocumentAccess::ADMIN # 8
251
+
252
+ encoded_score = DocumentAccess.encode_permissions(now, read_write_permissions)
253
+ timestamp, permissions = DocumentAccess.decode_permissions(encoded_score)
254
+
255
+ puts "Permission encoding example:"
256
+ puts " Original: timestamp=#{now}, permissions=#{read_write_permissions}"
257
+ puts " Encoded score: #{encoded_score}"
258
+ puts " Decoded: timestamp=#{timestamp}, permissions=#{permissions}"
259
+ puts " Has read access: #{(permissions & DocumentAccess::READ) != 0}"
260
+ puts " Has write access: #{(permissions & DocumentAccess::WRITE) != 0}"
261
+ puts " Has delete access: #{(permissions & DocumentAccess::DELETE) != 0}"
262
+ puts
263
+
264
+ puts "=== Example Complete! ==="
265
+ puts
266
+ puts "Key takeaways:"
267
+ puts "• tracked_in: Use for activity feeds, leaderboards, time-series data"
268
+ puts "• indexed_by: Use for fast O(1) lookups by field values"
269
+ puts "• member_of: Use for bidirectional ownership/membership"
270
+ puts "• Score encoding: Combine timestamps with metadata for rich queries"
271
+ puts "• Batch operations: Use Redis pipelines for efficiency"
272
+ puts
273
+ puts "See docs/wiki/Relationships-Guide.md for comprehensive documentation"
@@ -171,7 +171,7 @@ module Familia
171
171
  result = dbclient.multi do |conn|
172
172
  Fiber[:familia_transaction] = conn
173
173
  begin
174
- block_result = yield(conn) # rubocop:disable Lint/UselessAssignment
174
+ block_result = yield(conn)
175
175
  ensure
176
176
  Fiber[:familia_transaction] = nil # cleanup reference
177
177
  end
@@ -220,7 +220,7 @@ module Familia
220
220
  result = dbclient.pipelined do |conn|
221
221
  Fiber[:familia_pipeline] = conn
222
222
  begin
223
- block_result = yield(conn) # rubocop:disable Lint/UselessAssignment
223
+ block_result = yield(conn)
224
224
  ensure
225
225
  Fiber[:familia_pipeline] = nil # cleanup reference
226
226
  end
@@ -244,7 +244,7 @@ module Familia
244
244
  # conn.expire("custom_key", 3600)
245
245
  # end
246
246
  #
247
- def with_connection(&block)
247
+ def with_connection(&)
248
248
  yield dbclient
249
249
  end
250
250
 
@@ -27,6 +27,8 @@ module Familia
27
27
  attr_writer :logical_database, :uri
28
28
  end
29
29
 
30
+ # DataType::ClassMethods
31
+ #
30
32
  module ClassMethods
31
33
  # To be called inside every class that inherits DataType
32
34
  # +methname+ is the term used for the class and instance methods
@@ -57,16 +59,16 @@ module Familia
57
59
  end
58
60
 
59
61
  def valid_keys_only(opts)
60
- opts.select { |k, _| DataType.valid_options.include? k }
62
+ opts.slice(*DataType.valid_options)
61
63
  end
62
64
 
63
- def has_relations?
64
- @has_relations ||= false
65
+ def relations?
66
+ @has_relations ||= false # rubocop:disable ThreadSafety/ClassInstanceVariable
65
67
  end
66
68
  end
67
69
  extend ClassMethods
68
70
 
69
- attr_reader :keystring, :parent, :opts
71
+ attr_reader :keystring, :opts
70
72
  attr_writer :dump_method, :load_method
71
73
 
72
74
  # +keystring+: If parent is set, this will be used as the suffix
@@ -199,6 +201,7 @@ module Familia
199
201
  @opts[:parent]
200
202
  end
201
203
 
204
+
202
205
  def logical_database
203
206
  @opts[:logical_database] || self.class.logical_database
204
207
  end
@@ -105,7 +105,7 @@ class ConcealedString
105
105
  def belongs_to_context?(expected_record, expected_field_name)
106
106
  return false if @record.nil? || @field_type.nil?
107
107
 
108
- @record.class.name == expected_record.class.name &&
108
+ @record.instance_of?(expected_record.class) &&
109
109
  @record.identifier == expected_record.identifier &&
110
110
  @field_type.instance_variable_get(:@name) == expected_field_name
111
111
  end
@@ -169,7 +169,6 @@ class ConcealedString
169
169
  '[CONCEALED]'
170
170
  end
171
171
 
172
-
173
172
  # String methods that should return safe concealed values
174
173
  def upcase
175
174
  '[CONCEALED]'
@@ -180,7 +179,7 @@ class ConcealedString
180
179
  end
181
180
 
182
181
  def length
183
- 11 # Fixed concealed length to match '[CONCEALED]' length
182
+ 11 # Fixed concealed length to match '[CONCEALED]' length
184
183
  end
185
184
 
186
185
  def size
@@ -188,19 +187,19 @@ class ConcealedString
188
187
  end
189
188
 
190
189
  def present?
191
- true # Always return true since encrypted data exists
190
+ true # Always return true since encrypted data exists
192
191
  end
193
192
 
194
193
  def blank?
195
- false # Never blank if encrypted data exists
194
+ false # Never blank if encrypted data exists
196
195
  end
197
196
 
198
197
  # String concatenation operations return concealed result
199
- def +(other)
198
+ def +(_other)
200
199
  '[CONCEALED]'
201
200
  end
202
201
 
203
- def concat(other)
202
+ def concat(_other)
204
203
  '[CONCEALED]'
205
204
  end
206
205
 
@@ -218,12 +217,12 @@ class ConcealedString
218
217
  '[CONCEALED]'
219
218
  end
220
219
 
221
- def gsub(*args)
220
+ def gsub(*)
222
221
  '[CONCEALED]'
223
222
  end
224
223
 
225
- def include?(substring)
226
- false # Never reveal substring presence
224
+ def include?(_substring)
225
+ false # Never reveal substring presence
227
226
  end
228
227
 
229
228
  # Enumerable methods for safety
@@ -261,20 +260,29 @@ class ConcealedString
261
260
  ['[CONCEALED]']
262
261
  end
263
262
 
264
- def deconstruct_keys(keys)
263
+ def deconstruct_keys(*)
265
264
  { concealed: true }
266
265
  end
267
266
 
268
267
  # Prevent exposure in JSON serialization
269
- def to_json(*args)
268
+ def to_json(*)
270
269
  '"[CONCEALED]"'
271
270
  end
272
271
 
273
272
  # Prevent exposure in Rails serialization (as_json -> to_json)
274
- def as_json(*args)
273
+ def as_json(*)
275
274
  '[CONCEALED]'
276
275
  end
277
276
 
277
+ # Finalizer to attempt memory cleanup
278
+ def self.finalizer_proc(encrypted_data)
279
+ proc do
280
+ # Best effort cleanup - Ruby doesn't guarantee memory security
281
+ # Only clear if not frozen to avoid FrozenError
282
+ encrypted_data.clear if encrypted_data.respond_to?(:clear) && !encrypted_data.frozen?
283
+ end
284
+ end
285
+
278
286
  private
279
287
 
280
288
  # Check if a string looks like encrypted JSON data
@@ -282,14 +290,4 @@ class ConcealedString
282
290
  Familia::Encryption::EncryptedData.valid?(data)
283
291
  end
284
292
 
285
- # Finalizer to attempt memory cleanup
286
- def self.finalizer_proc(encrypted_data)
287
- proc do |id|
288
- # Best effort cleanup - Ruby doesn't guarantee memory security
289
- # Only clear if not frozen to avoid FrozenError
290
- if encrypted_data&.respond_to?(:clear) && !encrypted_data.frozen?
291
- encrypted_data.clear
292
- end
293
- end
294
- end
295
293
  end