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
@@ -1,273 +0,0 @@
|
|
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"
|
@@ -1,120 +0,0 @@
|
|
1
|
-
# lib/familia/features/external_identifiers/external_identifier_field_type.rb
|
2
|
-
|
3
|
-
require 'familia/field_type'
|
4
|
-
|
5
|
-
module Familia
|
6
|
-
module Features
|
7
|
-
module ExternalIdentifiers
|
8
|
-
# ExternalIdentifierFieldType - Fields that generate deterministic external identifiers
|
9
|
-
#
|
10
|
-
# External identifier fields generate shorter, public-facing identifiers that are
|
11
|
-
# deterministically derived from object identifiers. These IDs are safe for use
|
12
|
-
# in URLs, APIs, and other external contexts where shorter IDs are preferred.
|
13
|
-
#
|
14
|
-
# Key characteristics:
|
15
|
-
# - Deterministic generation from objid ensures consistency
|
16
|
-
# - Shorter than objid (128-bit vs 256-bit) for external use
|
17
|
-
# - Base-36 encoding for URL-safe identifiers
|
18
|
-
# - 'ext_' prefix for clear identification as external IDs
|
19
|
-
# - Lazy generation preserves values from initialization
|
20
|
-
#
|
21
|
-
# @example Using external identifier fields
|
22
|
-
# class User < Familia::Horreum
|
23
|
-
# feature :object_identifiers
|
24
|
-
# feature :external_identifiers
|
25
|
-
# field :email
|
26
|
-
# end
|
27
|
-
#
|
28
|
-
# user = User.new(email: 'user@example.com')
|
29
|
-
# user.objid # => "01234567-89ab-7def-8000-123456789abc"
|
30
|
-
# user.extid # => "ext_abc123def456ghi789" (deterministic from objid)
|
31
|
-
#
|
32
|
-
# # Same objid always produces same extid
|
33
|
-
# user2 = User.new(objid: user.objid, email: 'user@example.com')
|
34
|
-
# user2.extid # => "ext_abc123def456ghi789" (identical to user.extid)
|
35
|
-
#
|
36
|
-
class ExternalIdentifierFieldType < FieldType
|
37
|
-
# Override getter to provide lazy generation from objid
|
38
|
-
#
|
39
|
-
# Generates the external identifier deterministically from the object's
|
40
|
-
# objid. This ensures consistency - the same objid will always produce
|
41
|
-
# the same extid. Only generates when objid is available.
|
42
|
-
#
|
43
|
-
# @param klass [Class] The class to define the method on
|
44
|
-
#
|
45
|
-
def define_getter(klass)
|
46
|
-
field_name = @name
|
47
|
-
method_name = @method_name
|
48
|
-
|
49
|
-
handle_method_conflict(klass, method_name) do
|
50
|
-
klass.define_method method_name do
|
51
|
-
# Check if we already have a value (from initialization or previous generation)
|
52
|
-
existing_value = instance_variable_get(:"@#{field_name}")
|
53
|
-
return existing_value unless existing_value.nil?
|
54
|
-
|
55
|
-
# Generate external identifier from objid if available
|
56
|
-
generated_extid = generate_external_identifier
|
57
|
-
return unless generated_extid
|
58
|
-
|
59
|
-
instance_variable_set(:"@#{field_name}", generated_extid)
|
60
|
-
|
61
|
-
# Update mapping if we have an identifier
|
62
|
-
if respond_to?(:identifier) && identifier
|
63
|
-
self.class.extid_lookup[generated_extid] = identifier
|
64
|
-
end
|
65
|
-
|
66
|
-
generated_extid
|
67
|
-
end
|
68
|
-
end
|
69
|
-
end
|
70
|
-
|
71
|
-
# Override setter to preserve values during initialization
|
72
|
-
#
|
73
|
-
# This ensures that values passed during object initialization
|
74
|
-
# (e.g., when loading from Redis) are preserved and not overwritten
|
75
|
-
# by the lazy generation logic.
|
76
|
-
#
|
77
|
-
# @param klass [Class] The class to define the method on
|
78
|
-
#
|
79
|
-
def define_setter(klass)
|
80
|
-
field_name = @name
|
81
|
-
method_name = @method_name
|
82
|
-
|
83
|
-
handle_method_conflict(klass, :"#{method_name}=") do
|
84
|
-
klass.define_method :"#{method_name}=" do |value|
|
85
|
-
# Remove old mapping if extid is changing
|
86
|
-
old_value = instance_variable_get(:"@#{field_name}")
|
87
|
-
if old_value && old_value != value && respond_to?(:identifier)
|
88
|
-
self.class.extid_lookup.del(old_value)
|
89
|
-
end
|
90
|
-
|
91
|
-
# Set the new value
|
92
|
-
instance_variable_set(:"@#{field_name}", value)
|
93
|
-
|
94
|
-
# Update mapping if we have both extid and identifier
|
95
|
-
if value && respond_to?(:identifier) && identifier
|
96
|
-
self.class.extid_lookup[value] = identifier
|
97
|
-
end
|
98
|
-
end
|
99
|
-
end
|
100
|
-
end
|
101
|
-
|
102
|
-
# External identifier fields are persisted to database
|
103
|
-
#
|
104
|
-
# @return [Boolean] true - external identifiers are always persisted
|
105
|
-
#
|
106
|
-
def persistent?
|
107
|
-
true
|
108
|
-
end
|
109
|
-
|
110
|
-
# Category for external identifier fields
|
111
|
-
#
|
112
|
-
# @return [Symbol] :external_identifier
|
113
|
-
#
|
114
|
-
def category
|
115
|
-
:external_identifier
|
116
|
-
end
|
117
|
-
end
|
118
|
-
end
|
119
|
-
end
|
120
|
-
end
|
@@ -1,111 +0,0 @@
|
|
1
|
-
# lib/familia/features/external_identifiers.rb
|
2
|
-
|
3
|
-
require_relative 'external_identifiers/external_identifier_field_type'
|
4
|
-
|
5
|
-
module Familia
|
6
|
-
module Features
|
7
|
-
|
8
|
-
# Familia::Features::ExternalIdentifiers
|
9
|
-
#
|
10
|
-
module ExternalIdentifiers
|
11
|
-
def self.included(base)
|
12
|
-
Familia.trace :LOADED, self, base, caller(1..1) if Familia.debug?
|
13
|
-
base.extend ClassMethods
|
14
|
-
|
15
|
-
# Ensure default prefix is set in feature options
|
16
|
-
base.add_feature_options(:external_identifiers, prefix: 'ext')
|
17
|
-
|
18
|
-
# Add class-level mapping for extid -> id lookups
|
19
|
-
base.class_hashkey :extid_lookup
|
20
|
-
|
21
|
-
# Register the extid field using our custom field type
|
22
|
-
base.register_field_type(
|
23
|
-
ExternalIdentifiers::ExternalIdentifierFieldType.new(:extid, as: :extid, fast_method: false)
|
24
|
-
)
|
25
|
-
end
|
26
|
-
|
27
|
-
# ExternalIdentifiers::ClassMethods
|
28
|
-
#
|
29
|
-
module ClassMethods
|
30
|
-
def generate_extid(objid = nil)
|
31
|
-
unless features_enabled.include?(:object_identifiers)
|
32
|
-
raise Familia::Problem,
|
33
|
-
'ExternalIdentifiers requires ObjectIdentifiers feature'
|
34
|
-
end
|
35
|
-
return nil if objid.to_s.empty?
|
36
|
-
|
37
|
-
objid_hex = objid.to_s.delete('-')
|
38
|
-
external_part = Familia.shorten_to_external_id(objid_hex, base: 36)
|
39
|
-
prefix = feature_options(:external_identifiers)[:prefix] || 'ext'
|
40
|
-
"#{prefix}_#{external_part}"
|
41
|
-
end
|
42
|
-
|
43
|
-
# Find an object by its external identifier
|
44
|
-
#
|
45
|
-
# @param extid [String] The external identifier to search for
|
46
|
-
# @return [Object, nil] The object if found, nil otherwise
|
47
|
-
#
|
48
|
-
def find_by_extid(extid)
|
49
|
-
return nil if extid.to_s.empty?
|
50
|
-
|
51
|
-
if Familia.debug?
|
52
|
-
reference = caller(1..1).first
|
53
|
-
Familia.trace :FIND_BY_EXTID, Familia.dbclient, extid, reference
|
54
|
-
end
|
55
|
-
|
56
|
-
# Look up the primary ID from the external ID mapping
|
57
|
-
primary_id = extid_lookup[extid]
|
58
|
-
return nil if primary_id.nil?
|
59
|
-
|
60
|
-
# Find the object by its primary ID
|
61
|
-
find_by_id(primary_id)
|
62
|
-
rescue Familia::NotFound
|
63
|
-
# If the object was deleted but mapping wasn't cleaned up
|
64
|
-
extid_lookup.del(extid)
|
65
|
-
nil
|
66
|
-
end
|
67
|
-
end
|
68
|
-
|
69
|
-
# Generate external identifier deterministically from objid
|
70
|
-
def generate_external_identifier
|
71
|
-
return nil unless respond_to?(:objid)
|
72
|
-
|
73
|
-
current_objid = objid
|
74
|
-
return nil if current_objid.nil? || current_objid.to_s.empty?
|
75
|
-
|
76
|
-
# Convert objid to hex string for processing
|
77
|
-
objid_hex = current_objid.delete('-') # Remove UUID hyphens if present
|
78
|
-
|
79
|
-
# Generate deterministic external ID using SecureIdentifier
|
80
|
-
external_part = Familia.shorten_to_external_id(objid_hex, base: 36)
|
81
|
-
|
82
|
-
# Get prefix from feature options, default to "ext"
|
83
|
-
options = self.class.feature_options(:external_identifiers)
|
84
|
-
prefix = options[:prefix] || 'ext'
|
85
|
-
|
86
|
-
"#{prefix}_#{external_part}"
|
87
|
-
end
|
88
|
-
|
89
|
-
def external_identifier
|
90
|
-
extid
|
91
|
-
end
|
92
|
-
|
93
|
-
def init
|
94
|
-
super if defined?(super)
|
95
|
-
# External IDs are generated from objid, so no additional setup needed
|
96
|
-
end
|
97
|
-
|
98
|
-
def destroy!
|
99
|
-
# Clean up extid mapping when object is destroyed
|
100
|
-
current_extid = instance_variable_get(:@extid)
|
101
|
-
if current_extid
|
102
|
-
self.class.extid_lookup.del(current_extid)
|
103
|
-
end
|
104
|
-
|
105
|
-
super if defined?(super)
|
106
|
-
end
|
107
|
-
|
108
|
-
Familia::Base.add_feature self, :external_identifiers, depends_on: [:object_identifiers]
|
109
|
-
end
|
110
|
-
end
|
111
|
-
end
|
@@ -1,91 +0,0 @@
|
|
1
|
-
# lib/familia/features/object_identifiers/object_identifier_field_type.rb
|
2
|
-
|
3
|
-
require 'familia/field_type'
|
4
|
-
|
5
|
-
module Familia
|
6
|
-
module Features
|
7
|
-
module ObjectIdentifiers
|
8
|
-
# ObjectIdentifierFieldType - Fields that generate unique object identifiers
|
9
|
-
#
|
10
|
-
# Object identifier fields automatically generate unique identifiers when first
|
11
|
-
# accessed if not already set. The generation strategy is configurable via
|
12
|
-
# feature options. These fields preserve any values set during initialization
|
13
|
-
# to ensure data integrity when loading existing objects from Redis.
|
14
|
-
#
|
15
|
-
# @example Using object identifier fields
|
16
|
-
# class User < Familia::Horreum
|
17
|
-
# feature :object_identifiers, generator: :uuid_v7
|
18
|
-
# end
|
19
|
-
#
|
20
|
-
# user = User.new
|
21
|
-
# user.objid # Generates UUID v7 on first access
|
22
|
-
#
|
23
|
-
# # Loading existing object preserves ID
|
24
|
-
# user2 = User.new(objid: "existing-uuid")
|
25
|
-
# user2.objid # Returns "existing-uuid", not regenerated
|
26
|
-
#
|
27
|
-
class ObjectIdentifierFieldType < FieldType
|
28
|
-
# Override getter to provide lazy generation with configured strategy
|
29
|
-
#
|
30
|
-
# Generates the identifier using the configured strategy if not already set.
|
31
|
-
# This preserves any values set during initialization while providing
|
32
|
-
# automatic generation for new objects.
|
33
|
-
#
|
34
|
-
# @param klass [Class] The class to define the method on
|
35
|
-
#
|
36
|
-
def define_getter(klass)
|
37
|
-
field_name = @name
|
38
|
-
method_name = @method_name
|
39
|
-
|
40
|
-
handle_method_conflict(klass, method_name) do
|
41
|
-
klass.define_method method_name do
|
42
|
-
# Check if we already have a value (from initialization or previous generation)
|
43
|
-
existing_value = instance_variable_get(:"@#{field_name}")
|
44
|
-
return existing_value unless existing_value.nil?
|
45
|
-
|
46
|
-
# Generate new identifier using configured strategy
|
47
|
-
generated_id = generate_object_identifier
|
48
|
-
instance_variable_set(:"@#{field_name}", generated_id)
|
49
|
-
generated_id
|
50
|
-
end
|
51
|
-
end
|
52
|
-
end
|
53
|
-
|
54
|
-
# Override setter to preserve values during initialization
|
55
|
-
#
|
56
|
-
# This ensures that values passed during object initialization
|
57
|
-
# (e.g., when loading from Redis) are preserved and not overwritten
|
58
|
-
# by the lazy generation logic.
|
59
|
-
#
|
60
|
-
# @param klass [Class] The class to define the method on
|
61
|
-
#
|
62
|
-
def define_setter(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 |value|
|
68
|
-
instance_variable_set(:"@#{field_name}", value)
|
69
|
-
end
|
70
|
-
end
|
71
|
-
end
|
72
|
-
|
73
|
-
# Object identifier fields are persisted to database
|
74
|
-
#
|
75
|
-
# @return [Boolean] true - object identifiers are always persisted
|
76
|
-
#
|
77
|
-
def persistent?
|
78
|
-
true
|
79
|
-
end
|
80
|
-
|
81
|
-
# Category for object identifier fields
|
82
|
-
#
|
83
|
-
# @return [Symbol] :object_identifier
|
84
|
-
#
|
85
|
-
def category
|
86
|
-
:object_identifier
|
87
|
-
end
|
88
|
-
end
|
89
|
-
end
|
90
|
-
end
|
91
|
-
end
|