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,205 @@
|
|
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 'familia'
|
7
|
+
|
8
|
+
# Configure Familia for the example
|
9
|
+
# Note: Individual models can specify logical_database if needed
|
10
|
+
Familia.configure do |config|
|
11
|
+
config.uri = 'redis://localhost:6379/'
|
12
|
+
end
|
13
|
+
|
14
|
+
puts '=== Familia Relationships Basic Example ==='
|
15
|
+
puts
|
16
|
+
|
17
|
+
# Define our model classes
|
18
|
+
class Customer < Familia::Horreum
|
19
|
+
logical_database 15 # Use test database
|
20
|
+
feature :relationships
|
21
|
+
|
22
|
+
identifier_field :custid
|
23
|
+
field :custid
|
24
|
+
field :name
|
25
|
+
field :email
|
26
|
+
field :plan
|
27
|
+
|
28
|
+
# Define collections for tracking relationships
|
29
|
+
set :domains # Simple set of domain IDs
|
30
|
+
list :projects # Ordered list of project IDs
|
31
|
+
sorted_set :activity # Activity feed with timestamps
|
32
|
+
|
33
|
+
# Create indexes for fast lookups (using class_ prefix for class-level)
|
34
|
+
class_indexed_by :email, :email_lookup # i.e. Customer.email_lookup
|
35
|
+
class_indexed_by :plan, :plan_lookup # i.e. Customer.plan_lookup
|
36
|
+
|
37
|
+
# Track in class-level collections (using class_ prefix for class-level)
|
38
|
+
class_tracked_in :all_customers, score: :created_at # i.e. Customer.all_customers
|
39
|
+
end
|
40
|
+
|
41
|
+
class Domain < Familia::Horreum
|
42
|
+
logical_database 15 # Use test database
|
43
|
+
feature :relationships
|
44
|
+
|
45
|
+
identifier_field :domain_id
|
46
|
+
field :domain_id
|
47
|
+
field :name
|
48
|
+
field :dns_zone
|
49
|
+
field :status
|
50
|
+
|
51
|
+
# Declare membership in customer collections
|
52
|
+
member_of Customer, :domains # , type: :set
|
53
|
+
|
54
|
+
# Track domains by status (using class_ prefix for class-level)
|
55
|
+
class_tracked_in :active_domains,
|
56
|
+
score: -> { status == 'active' ? Time.now.to_i : 0 }
|
57
|
+
end
|
58
|
+
|
59
|
+
class Project < Familia::Horreum
|
60
|
+
logical_database 15 # Use test database
|
61
|
+
feature :relationships
|
62
|
+
|
63
|
+
identifier_field :project_id
|
64
|
+
field :project_id
|
65
|
+
field :name
|
66
|
+
field :priority
|
67
|
+
|
68
|
+
# Member of customer projects list (ordered)
|
69
|
+
member_of Customer, :projects, type: :list
|
70
|
+
end
|
71
|
+
|
72
|
+
puts '=== 1. Basic Object Creation ==='
|
73
|
+
|
74
|
+
# Create some sample objects
|
75
|
+
customer = Customer.new(
|
76
|
+
# custid: "cust_#{SecureRandom.hex(4)}",
|
77
|
+
name: 'Acme Corporation',
|
78
|
+
email: 'admin@acme.com',
|
79
|
+
plan: 'enterprise'
|
80
|
+
)
|
81
|
+
|
82
|
+
domain1 = Domain.new(
|
83
|
+
# domain_id: "dom_#{SecureRandom.hex(4)}",
|
84
|
+
name: 'acme.com',
|
85
|
+
dns_zone: 'acme.com.',
|
86
|
+
status: 'active'
|
87
|
+
)
|
88
|
+
|
89
|
+
domain2 = Domain.new(
|
90
|
+
# domain_id: "dom_#{SecureRandom.hex(4)}",
|
91
|
+
name: 'staging.acme.com',
|
92
|
+
dns_zone: 'staging.acme.com.',
|
93
|
+
status: 'active'
|
94
|
+
)
|
95
|
+
|
96
|
+
project = Project.new(
|
97
|
+
# project_id: "proj_#{SecureRandom.hex(4)}",
|
98
|
+
name: 'Website Redesign',
|
99
|
+
priority: 'high'
|
100
|
+
)
|
101
|
+
|
102
|
+
puts "✓ Created customer: #{customer.name} (#{customer.custid})"
|
103
|
+
puts "✓ Created domains: #{domain1.name}, #{domain2.name}"
|
104
|
+
puts "✓ Created project: #{project.name}"
|
105
|
+
puts
|
106
|
+
|
107
|
+
puts '=== 2. Establishing Relationships ==='
|
108
|
+
|
109
|
+
# Save objects to automatically update indexes and class-level tracking
|
110
|
+
customer.save # Automatically adds to email_lookup, plan_lookup, and all_customers
|
111
|
+
puts '✓ Customer automatically added to indexes and tracking on save'
|
112
|
+
|
113
|
+
# Establish member_of relationships using clean << operator syntax
|
114
|
+
customer.domains << domain1 # Clean Ruby-like syntax
|
115
|
+
customer.domains << domain2 # Same as domain1.add_to_customer_domains(customer)
|
116
|
+
customer.projects << project # Same as project.add_to_customer_projects(customer)
|
117
|
+
|
118
|
+
puts '✓ Established domain ownership relationships using << operator'
|
119
|
+
puts '✓ Established project ownership relationships using << operator'
|
120
|
+
|
121
|
+
# Save domains to automatically add them to class-level tracking
|
122
|
+
domain1.save # Automatically adds to active_domains if status == 'active'
|
123
|
+
domain2.save # Automatically adds to active_domains if status == 'active'
|
124
|
+
puts '✓ Domains automatically added to status tracking on save'
|
125
|
+
puts
|
126
|
+
|
127
|
+
puts '=== 3. Querying Relationships ==='
|
128
|
+
|
129
|
+
record = Customer.get_by_email('admin@acme.com')
|
130
|
+
puts "Email lookup: #{record&.custid || 'not found'}"
|
131
|
+
|
132
|
+
Customer.get_by_plan('enterprise') # raises MoreThanOne
|
133
|
+
|
134
|
+
results = Customer.find_by_email('admin@acme.com')
|
135
|
+
puts "Email lookup: #{results&.size} found"
|
136
|
+
|
137
|
+
results = Customer.find_by_plan('enterprise')
|
138
|
+
puts "Enterprise lookup: #{results&.size} found"
|
139
|
+
puts
|
140
|
+
|
141
|
+
# Test membership queries
|
142
|
+
puts "\nDomain membership checks:"
|
143
|
+
puts " #{domain1.name} belongs to customer? #{domain1.in_customer_domains?(customer)}"
|
144
|
+
puts " #{domain2.name} belongs to customer? #{domain2.in_customer_domains?(customer)}"
|
145
|
+
|
146
|
+
puts "\nCustomer collections:"
|
147
|
+
puts " Customer has #{customer.domains.size} domains"
|
148
|
+
puts " Customer has #{customer.projects.size} projects"
|
149
|
+
puts " Domain IDs: #{customer.domains.members}"
|
150
|
+
puts " Project IDs: #{customer.projects.members}"
|
151
|
+
|
152
|
+
# Test tracked_in collections
|
153
|
+
all_customers_count = Customer.values.size
|
154
|
+
puts "\nClass-level tracking:"
|
155
|
+
puts " Total customers in system: #{all_customers_count}"
|
156
|
+
|
157
|
+
active_domains_count = Domain.active_domains.size
|
158
|
+
puts " Active domains in system: #{active_domains_count}"
|
159
|
+
puts
|
160
|
+
|
161
|
+
puts '=== 4. Range Queries ==='
|
162
|
+
|
163
|
+
# Get recent customers (last 24 hours)
|
164
|
+
yesterday = (Time.now - (24 * 3600)).to_i # 24 hours ago
|
165
|
+
recent_customers = Customer.values.rangebyscore(yesterday, '+inf')
|
166
|
+
puts "Recent customers (last 24h): #{recent_customers.size}"
|
167
|
+
|
168
|
+
# Get all active domains by score
|
169
|
+
active_domain_scores = Domain.active_domains.rangebyscore(1, '+inf', with_scores: true)
|
170
|
+
puts 'Active domains with timestamps:'
|
171
|
+
active_domain_scores.each_slice(2) do |domain_id, timestamp|
|
172
|
+
puts " #{domain_id}: active since #{Time.at(timestamp.to_i)} #{timestamp.inspect}"
|
173
|
+
end
|
174
|
+
puts
|
175
|
+
|
176
|
+
puts '=== 6. Relationship Cleanup ==='
|
177
|
+
|
178
|
+
# Remove relationships
|
179
|
+
puts 'Cleaning up relationships...'
|
180
|
+
|
181
|
+
# Remove from member_of relationships
|
182
|
+
domain1.remove_from_customer_domains(customer)
|
183
|
+
puts "✓ Removed #{domain1.name} from customer domains"
|
184
|
+
|
185
|
+
# Remove from tracking collections
|
186
|
+
Domain.active_domains.remove(domain2.identifier)
|
187
|
+
puts "✓ Removed #{domain2.name} from active domains"
|
188
|
+
|
189
|
+
# Verify cleanup
|
190
|
+
puts "\nAfter cleanup:"
|
191
|
+
puts " Customer domains: #{customer.domains.size}"
|
192
|
+
puts " Active domains: #{Domain.active_domains.size}"
|
193
|
+
puts
|
194
|
+
|
195
|
+
puts '=== Example Complete! ==='
|
196
|
+
puts
|
197
|
+
puts 'Key takeaways:'
|
198
|
+
puts '• class_tracked_in: Automatic class-level collections updated on save'
|
199
|
+
puts '• class_indexed_by: Automatic class-level indexes updated on save'
|
200
|
+
puts '• member_of: Use << operator for clean Ruby-like collection syntax'
|
201
|
+
puts '• indexed_by with parent:: Use for relationship-scoped indexes'
|
202
|
+
puts '• Save operations: Automatically update indexes and class-level tracking'
|
203
|
+
puts '• << operator: Works naturally with all collection types (sets, lists, sorted sets)'
|
204
|
+
puts
|
205
|
+
puts 'See docs/wiki/Relationships-Guide.md for comprehensive documentation'
|
@@ -0,0 +1,281 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
# examples/safe_dump.rb
|
4
|
+
#
|
5
|
+
# Demonstrates the SafeDump feature with the new DSL methods.
|
6
|
+
# SafeDump allows you to control which fields are exposed when
|
7
|
+
# serializing objects, preventing accidental exposure of sensitive data.
|
8
|
+
|
9
|
+
$LOAD_PATH.unshift File.expand_path('../lib', __dir__)
|
10
|
+
require 'familia'
|
11
|
+
|
12
|
+
# Configure connection
|
13
|
+
Familia.uri = 'redis://localhost:6379/15'
|
14
|
+
|
15
|
+
puts '=== SafeDump Feature Examples ==='
|
16
|
+
puts
|
17
|
+
|
18
|
+
# Example 1: Basic SafeDump with simple fields
|
19
|
+
class User < Familia::Horreum
|
20
|
+
feature :safe_dump
|
21
|
+
|
22
|
+
identifier_field :email
|
23
|
+
field :email
|
24
|
+
field :first_name
|
25
|
+
field :last_name
|
26
|
+
field :password_hash # Sensitive - not included in safe dump
|
27
|
+
field :ssn # Sensitive - not included in safe dump
|
28
|
+
field :created_at
|
29
|
+
|
30
|
+
# Define safe dump fields using the new DSL
|
31
|
+
safe_dump_field :email
|
32
|
+
safe_dump_field :first_name
|
33
|
+
safe_dump_field :last_name
|
34
|
+
safe_dump_field :created_at
|
35
|
+
end
|
36
|
+
|
37
|
+
puts 'Example 1: Basic SafeDump'
|
38
|
+
user = User.new(
|
39
|
+
email: 'alice@example.com',
|
40
|
+
first_name: 'Alice',
|
41
|
+
last_name: 'Smith',
|
42
|
+
password_hash: 'secret123',
|
43
|
+
ssn: '123-45-6789',
|
44
|
+
created_at: Time.now.to_i
|
45
|
+
)
|
46
|
+
|
47
|
+
puts "Full object data: #{user.to_h}"
|
48
|
+
puts "Safe dump: #{user.safe_dump}"
|
49
|
+
puts 'Notice: password_hash and ssn are excluded'
|
50
|
+
puts
|
51
|
+
|
52
|
+
# Example 2: SafeDump with computed fields using callables
|
53
|
+
class Product < Familia::Horreum
|
54
|
+
feature :safe_dump
|
55
|
+
|
56
|
+
identifier_field :sku
|
57
|
+
field :sku
|
58
|
+
field :name
|
59
|
+
field :price_cents # Store price in cents internally
|
60
|
+
field :cost_cents # Sensitive - don't expose
|
61
|
+
field :inventory_count
|
62
|
+
field :category
|
63
|
+
field :created_at
|
64
|
+
|
65
|
+
# Mix simple fields with computed fields
|
66
|
+
safe_dump_field :sku
|
67
|
+
safe_dump_field :name
|
68
|
+
safe_dump_field :category
|
69
|
+
safe_dump_field :created_at
|
70
|
+
|
71
|
+
# Computed fields using callables
|
72
|
+
safe_dump_field :price, ->(product) { "$#{format('%.2f', product.price_cents.to_i / 100.0)}" }
|
73
|
+
safe_dump_field :in_stock, ->(product) { product.inventory_count.to_i > 0 }
|
74
|
+
safe_dump_field :display_name, ->(product) { "#{product.name} (#{product.sku})" }
|
75
|
+
end
|
76
|
+
|
77
|
+
puts 'Example 2: SafeDump with computed fields'
|
78
|
+
product = Product.new(
|
79
|
+
sku: 'WIDGET-001',
|
80
|
+
name: 'Super Widget',
|
81
|
+
price_cents: 1599, # $15.99
|
82
|
+
cost_cents: 800, # $8.00 - sensitive, not exposed
|
83
|
+
inventory_count: 25,
|
84
|
+
category: 'widgets',
|
85
|
+
created_at: Time.now.to_i
|
86
|
+
)
|
87
|
+
|
88
|
+
puts "Full object data: #{product.to_h}"
|
89
|
+
puts "Safe dump: #{product.safe_dump}"
|
90
|
+
puts 'Notice: price converted to dollars, in_stock computed, cost_cents hidden'
|
91
|
+
puts
|
92
|
+
|
93
|
+
# Example 3: SafeDump with multiple field definition styles
|
94
|
+
class Order < Familia::Horreum
|
95
|
+
feature :safe_dump
|
96
|
+
|
97
|
+
identifier_field :order_id
|
98
|
+
field :order_id
|
99
|
+
field :customer_email
|
100
|
+
field :status
|
101
|
+
field :total_cents
|
102
|
+
field :payment_method
|
103
|
+
field :credit_card_number # Very sensitive!
|
104
|
+
field :processing_notes # Internal only
|
105
|
+
field :created_at
|
106
|
+
field :shipped_at
|
107
|
+
|
108
|
+
# Mix of individual fields and batch definitions
|
109
|
+
safe_dump_field :order_id
|
110
|
+
safe_dump_field :customer_email
|
111
|
+
safe_dump_field :status
|
112
|
+
|
113
|
+
# Define multiple fields at once
|
114
|
+
safe_dump_fields :created_at, :shipped_at
|
115
|
+
|
116
|
+
# Computed fields using hash syntax
|
117
|
+
safe_dump_fields(
|
118
|
+
{ total: ->(order) { "$#{format('%.2f', order.total_cents.to_i / 100.0)}" } },
|
119
|
+
{ payment_type: ->(order) { order.payment_method&.split('_')&.first&.capitalize } }
|
120
|
+
)
|
121
|
+
|
122
|
+
def customer_obscured_email
|
123
|
+
email = customer_email.to_s
|
124
|
+
return email if email.length < 3
|
125
|
+
|
126
|
+
local, domain = email.split('@', 2)
|
127
|
+
return email unless domain
|
128
|
+
|
129
|
+
obscured_local = local[0] + ('*' * [local.length - 2, 0].max) + local[-1]
|
130
|
+
"#{obscured_local}@#{domain}"
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
puts 'Example 3: Multiple definition styles'
|
135
|
+
order = Order.new(
|
136
|
+
order_id: 'ORD-2024-001',
|
137
|
+
customer_email: 'customer@example.com',
|
138
|
+
status: 'shipped',
|
139
|
+
total_cents: 2499, # $24.99
|
140
|
+
payment_method: 'credit_card',
|
141
|
+
credit_card_number: '4111-1111-1111-1111', # Never expose this!
|
142
|
+
processing_notes: 'Rush order - expedite shipping',
|
143
|
+
created_at: Time.now.to_i - 86_400, # Yesterday
|
144
|
+
shipped_at: Time.now.to_i - 3600 # 1 hour ago
|
145
|
+
)
|
146
|
+
|
147
|
+
puts "Full object data: #{order.to_h}"
|
148
|
+
puts "Safe dump: #{order.safe_dump}"
|
149
|
+
puts 'Notice: credit card and internal notes excluded, computed fields included'
|
150
|
+
puts
|
151
|
+
|
152
|
+
# Example 4: SafeDump with nested objects
|
153
|
+
class Address < Familia::Horreum
|
154
|
+
feature :safe_dump
|
155
|
+
|
156
|
+
identifier_field :id
|
157
|
+
field :id
|
158
|
+
field :street
|
159
|
+
field :city
|
160
|
+
field :state
|
161
|
+
field :zip_code
|
162
|
+
field :country
|
163
|
+
|
164
|
+
# Simple address fields
|
165
|
+
safe_dump_fields :street, :city, :state, :zip_code, :country
|
166
|
+
end
|
167
|
+
|
168
|
+
class Customer < Familia::Horreum
|
169
|
+
feature :safe_dump
|
170
|
+
|
171
|
+
identifier_field :id
|
172
|
+
field :id
|
173
|
+
field :name
|
174
|
+
field :email
|
175
|
+
field :phone
|
176
|
+
field :billing_address_id
|
177
|
+
field :shipping_address_id
|
178
|
+
field :account_balance_cents
|
179
|
+
field :credit_limit_cents # Sensitive
|
180
|
+
field :internal_notes # Internal only
|
181
|
+
|
182
|
+
safe_dump_field :id
|
183
|
+
safe_dump_field :name
|
184
|
+
safe_dump_field :email
|
185
|
+
safe_dump_field :phone
|
186
|
+
|
187
|
+
# Nested object handling - load and safe_dump related addresses
|
188
|
+
safe_dump_field :billing_address, lambda { |customer|
|
189
|
+
addr_id = customer.billing_address_id
|
190
|
+
addr_id ? Address.load(addr_id)&.safe_dump : nil
|
191
|
+
}
|
192
|
+
|
193
|
+
safe_dump_field :shipping_address, lambda { |customer|
|
194
|
+
addr_id = customer.shipping_address_id
|
195
|
+
addr_id ? Address.load(addr_id)&.safe_dump : nil
|
196
|
+
}
|
197
|
+
|
198
|
+
safe_dump_field :account_balance, lambda { |customer|
|
199
|
+
"$#{format('%.2f', customer.account_balance_cents.to_i / 100.0)}"
|
200
|
+
}
|
201
|
+
end
|
202
|
+
|
203
|
+
puts 'Example 4: SafeDump with nested objects'
|
204
|
+
|
205
|
+
# Create addresses first
|
206
|
+
billing = Address.new(
|
207
|
+
id: 'addr_1',
|
208
|
+
street: '123 Main St',
|
209
|
+
city: 'Anytown',
|
210
|
+
state: 'CA',
|
211
|
+
zip_code: '90210',
|
212
|
+
country: 'USA'
|
213
|
+
)
|
214
|
+
billing.save
|
215
|
+
|
216
|
+
shipping = Address.new(
|
217
|
+
id: 'addr_2',
|
218
|
+
street: '456 Oak Ave',
|
219
|
+
city: 'Somewhere',
|
220
|
+
state: 'NY',
|
221
|
+
zip_code: '10001',
|
222
|
+
country: 'USA'
|
223
|
+
)
|
224
|
+
shipping.save
|
225
|
+
|
226
|
+
customer = Customer.new(
|
227
|
+
id: 'cust_123',
|
228
|
+
name: 'Bob Johnson',
|
229
|
+
email: 'bob@example.com',
|
230
|
+
phone: '555-1234',
|
231
|
+
billing_address_id: 'addr_1',
|
232
|
+
shipping_address_id: 'addr_2',
|
233
|
+
account_balance_cents: 15_000, # $150.00
|
234
|
+
credit_limit_cents: 100_000, # $1000.00 - sensitive!
|
235
|
+
internal_notes: 'VIP customer - handle with care'
|
236
|
+
)
|
237
|
+
|
238
|
+
puts 'Customer safe dump:'
|
239
|
+
puts JSON.pretty_generate(customer.safe_dump)
|
240
|
+
puts 'Notice: Nested addresses included, sensitive credit limit excluded'
|
241
|
+
puts
|
242
|
+
|
243
|
+
# Example 5: Introspection methods
|
244
|
+
puts 'Example 5: SafeDump introspection'
|
245
|
+
puts "User safe dump field names: #{User.safe_dump_field_names}"
|
246
|
+
puts "Product safe dump field names: #{Product.safe_dump_field_names}"
|
247
|
+
puts "Order safe dump field names: #{Order.safe_dump_field_names}"
|
248
|
+
puts
|
249
|
+
|
250
|
+
puts "User safe dump field map keys: #{User.safe_dump_field_map.keys}"
|
251
|
+
puts "All Product safe dump fields are callable: #{Product.safe_dump_field_map.values.all? do |v|
|
252
|
+
v.respond_to?(:call)
|
253
|
+
end}"
|
254
|
+
puts
|
255
|
+
|
256
|
+
# Example 6: Legacy compatibility methods
|
257
|
+
puts 'Example 6: Legacy compatibility'
|
258
|
+
puts "Using legacy safe_dump_fields getter: #{User.safe_dump_fields}"
|
259
|
+
puts 'Setting fields with set_safe_dump_fields:'
|
260
|
+
|
261
|
+
class LegacyModel < Familia::Horreum
|
262
|
+
feature :safe_dump
|
263
|
+
identifier_field :id
|
264
|
+
field :id
|
265
|
+
field :name
|
266
|
+
field :value
|
267
|
+
end
|
268
|
+
|
269
|
+
LegacyModel.set_safe_dump_fields(:id, :name)
|
270
|
+
puts "LegacyModel fields after set_safe_dump_fields: #{LegacyModel.safe_dump_fields}"
|
271
|
+
|
272
|
+
# Clean up
|
273
|
+
puts
|
274
|
+
puts '=== Cleaning up test data ==='
|
275
|
+
[User, Product, Order, Address, Customer, LegacyModel].each do |klass|
|
276
|
+
klass.redis.del(klass.redis.keys("#{klass.name.downcase}:*"))
|
277
|
+
rescue StandardError => e
|
278
|
+
puts "Error cleaning #{klass}: #{e.message}"
|
279
|
+
end
|
280
|
+
|
281
|
+
puts 'SafeDump examples completed!'
|
data/familia.gemspec
CHANGED
@@ -19,10 +19,10 @@ Gem::Specification.new do |spec|
|
|
19
19
|
|
20
20
|
spec.required_ruby_version = Gem::Requirement.new('>= 3.4')
|
21
21
|
|
22
|
-
spec.add_dependency 'benchmark'
|
23
|
-
spec.add_dependency 'connection_pool'
|
24
|
-
spec.add_dependency 'csv'
|
25
|
-
spec.add_dependency 'logger'
|
22
|
+
spec.add_dependency 'benchmark', '~> 0.4'
|
23
|
+
spec.add_dependency 'connection_pool', '~> 2.5'
|
24
|
+
spec.add_dependency 'csv', '~> 3.3'
|
25
|
+
spec.add_dependency 'logger', '~> 1.7'
|
26
26
|
spec.add_dependency 'redis', '>= 4.8.1', '< 6.0'
|
27
27
|
spec.add_dependency 'stringio', '~> 3.1.1'
|
28
28
|
spec.add_dependency 'uri-valkey', '~> 1.4'
|
data/lib/familia/base.rb
CHANGED
@@ -19,6 +19,38 @@ module Familia
|
|
19
19
|
@dump_method = :to_json
|
20
20
|
@load_method = :from_json
|
21
21
|
|
22
|
+
def self.included(base)
|
23
|
+
# Ensure the including class gets its own feature registry
|
24
|
+
base.extend(ClassMethods)
|
25
|
+
end
|
26
|
+
|
27
|
+
module ClassMethods
|
28
|
+
attr_reader :features_available, :feature_definitions
|
29
|
+
attr_accessor :dump_method, :load_method
|
30
|
+
|
31
|
+
def add_feature(klass, feature_name, depends_on: [])
|
32
|
+
@features_available ||= {}
|
33
|
+
Familia.trace :ADD_FEATURE, klass, feature_name, caller(1..1) if Familia.debug?
|
34
|
+
|
35
|
+
# Create field definition object
|
36
|
+
feature_def = FeatureDefinition.new(
|
37
|
+
name: feature_name,
|
38
|
+
depends_on: depends_on,
|
39
|
+
)
|
40
|
+
|
41
|
+
# Track field definitions after defining field methods
|
42
|
+
@feature_definitions ||= {}
|
43
|
+
@feature_definitions[feature_name] = feature_def
|
44
|
+
|
45
|
+
features_available[feature_name] = klass
|
46
|
+
end
|
47
|
+
|
48
|
+
# Find a feature by name, traversing this class's ancestry chain
|
49
|
+
def find_feature(feature_name)
|
50
|
+
Familia::Base.find_feature(feature_name, self)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
22
54
|
# Returns a string representation of the object. Implementing classes
|
23
55
|
# are welcome to override this method to provide a more meaningful
|
24
56
|
# representation. Using this as a default via super is recommended.
|
@@ -29,6 +61,7 @@ module Familia
|
|
29
61
|
"#<#{self.class}:0x#{object_id.to_s(16)}>"
|
30
62
|
end
|
31
63
|
|
64
|
+
# Module-level methods for Familia::Base itself
|
32
65
|
class << self
|
33
66
|
attr_reader :features_available, :feature_definitions
|
34
67
|
attr_accessor :dump_method, :load_method
|
@@ -49,6 +82,25 @@ module Familia
|
|
49
82
|
|
50
83
|
features_available[feature_name] = klass
|
51
84
|
end
|
85
|
+
|
86
|
+
# Find a feature by name, traversing the ancestry chain of classes
|
87
|
+
# that include Familia::Base
|
88
|
+
def find_feature(feature_name, starting_class = self)
|
89
|
+
# Convert to symbol for consistent lookup
|
90
|
+
feature_name = feature_name.to_sym
|
91
|
+
|
92
|
+
# Walk up the ancestry chain, checking each class that includes Familia::Base
|
93
|
+
starting_class.ancestors.each do |ancestor|
|
94
|
+
next unless ancestor.respond_to?(:features_available)
|
95
|
+
next unless ancestor.features_available
|
96
|
+
|
97
|
+
if ancestor.features_available.key?(feature_name)
|
98
|
+
return ancestor.features_available[feature_name]
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
nil
|
103
|
+
end
|
52
104
|
end
|
53
105
|
|
54
106
|
def generate_id
|
data/lib/familia/connection.rb
CHANGED
@@ -57,7 +57,6 @@ module Familia
|
|
57
57
|
# Familia.connect('redis://localhost:6379')
|
58
58
|
def connect(uri = nil)
|
59
59
|
parsed_uri = normalize_uri(uri)
|
60
|
-
serverid = parsed_uri.serverid
|
61
60
|
|
62
61
|
if Familia.enable_database_logging
|
63
62
|
DatabaseLogger.logger = Familia.logger
|
@@ -70,14 +69,7 @@ module Familia
|
|
70
69
|
RedisClient.register(DatabaseCommandCounter)
|
71
70
|
end
|
72
71
|
|
73
|
-
|
74
|
-
|
75
|
-
if @database_clients.key?(serverid)
|
76
|
-
msg = "Overriding existing connection for #{serverid}"
|
77
|
-
Familia.warn(msg)
|
78
|
-
end
|
79
|
-
|
80
|
-
@database_clients[serverid] = dbclient
|
72
|
+
Redis.new(parsed_uri.conf)
|
81
73
|
end
|
82
74
|
|
83
75
|
def reconnect(uri = nil)
|
@@ -86,6 +78,7 @@ module Familia
|
|
86
78
|
|
87
79
|
# Close the existing connection if it exists
|
88
80
|
@database_clients[serverid].close if @database_clients.key?(serverid)
|
81
|
+
@database_clients.delete(serverid)
|
89
82
|
|
90
83
|
connect(parsed_uri)
|
91
84
|
end
|
@@ -126,19 +119,9 @@ module Familia
|
|
126
119
|
|
127
120
|
# Legacy behavior: create connection
|
128
121
|
parsed_uri = normalize_uri(uri)
|
122
|
+
serverid = parsed_uri.serverid
|
129
123
|
|
130
|
-
|
131
|
-
if uri.nil?
|
132
|
-
@dbclient ||= connect(parsed_uri)
|
133
|
-
@dbclient.select(parsed_uri.db) if parsed_uri.db
|
134
|
-
@dbclient
|
135
|
-
else
|
136
|
-
# When a specific DB is requested, create a new connection
|
137
|
-
# to avoid conflicts with cached connections
|
138
|
-
connection = connect(parsed_uri)
|
139
|
-
connection.select(parsed_uri.db) if parsed_uri.db
|
140
|
-
connection
|
141
|
-
end
|
124
|
+
@database_clients[serverid] ||= connect(parsed_uri)
|
142
125
|
end
|
143
126
|
|
144
127
|
# Executes Database commands atomically within a transaction (MULTI/EXEC).
|
data/lib/familia/errors.rb
CHANGED
@@ -0,0 +1,57 @@
|
|
1
|
+
# lib/familia/features/autoloader.rb
|
2
|
+
|
3
|
+
module Familia
|
4
|
+
module Features
|
5
|
+
# Autoloader is a mixin that automatically loads feature files from a 'features'
|
6
|
+
# subdirectory when included. This provides a standardized way to organize and
|
7
|
+
# auto-load project-specific features.
|
8
|
+
#
|
9
|
+
# When included in a module, it automatically:
|
10
|
+
# 1. Determines the directory containing the module file
|
11
|
+
# 2. Looks for a 'features' subdirectory in that location
|
12
|
+
# 3. Loads all *.rb files from that features directory
|
13
|
+
#
|
14
|
+
# Example usage:
|
15
|
+
#
|
16
|
+
# # apps/api/v2/models/customer/features.rb
|
17
|
+
# module V2
|
18
|
+
# class Customer < Familia::Horreum
|
19
|
+
# module Features
|
20
|
+
# include Familia::Features::Autoloader
|
21
|
+
# # Automatically loads all files from customer/features/
|
22
|
+
# end
|
23
|
+
# end
|
24
|
+
# end
|
25
|
+
#
|
26
|
+
# This would automatically load:
|
27
|
+
# - apps/api/v2/models/customer/features/deprecated_fields.rb
|
28
|
+
# - apps/api/v2/models/customer/features/legacy_support.rb
|
29
|
+
# - etc.
|
30
|
+
#
|
31
|
+
module Autoloader
|
32
|
+
def self.included(_base)
|
33
|
+
# Get the file path of the module that's including us.
|
34
|
+
# `caller_locations(1, 1).first` gives us the location where `include` was called.
|
35
|
+
# This is a robust way to find the file path, especially for anonymous modules.
|
36
|
+
calling_location = caller_locations(1, 1)&.first
|
37
|
+
return unless calling_location
|
38
|
+
|
39
|
+
including_file = calling_location.path
|
40
|
+
|
41
|
+
# Find the features directory relative to the including file
|
42
|
+
features_dir = File.join(File.dirname(including_file), 'features')
|
43
|
+
|
44
|
+
Familia.ld "[DEBUG] Autoloader: Looking for features in #{features_dir}"
|
45
|
+
|
46
|
+
if Dir.exist?(features_dir)
|
47
|
+
Dir.glob(File.join(features_dir, '*.rb')).each do |feature_file|
|
48
|
+
Familia.ld "[DEBUG] Autoloader: Loading feature #{feature_file}"
|
49
|
+
require feature_file
|
50
|
+
end
|
51
|
+
else
|
52
|
+
Familia.ld "[DEBUG] Autoloader: No features directory found at #{features_dir}"
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|