familia 2.0.0.pre21 → 2.0.0.pre23
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/claude-code-review.yml +8 -5
- data/.talismanrc +5 -1
- data/CHANGELOG.rst +76 -0
- data/Gemfile.lock +8 -8
- data/docs/1106-participates_in-bidirectional-solution.md +201 -58
- data/examples/through_relationships.rb +275 -0
- data/lib/familia/connection/operation_core.rb +1 -2
- data/lib/familia/connection/pipelined_core.rb +1 -3
- data/lib/familia/connection/transaction_core.rb +1 -2
- data/lib/familia/data_type/serialization.rb +76 -51
- data/lib/familia/data_type/types/sorted_set.rb +5 -10
- data/lib/familia/data_type/types/stringkey.rb +22 -0
- data/lib/familia/features/external_identifier.rb +29 -0
- data/lib/familia/features/object_identifier.rb +47 -0
- data/lib/familia/features/relationships/README.md +1 -1
- data/lib/familia/features/relationships/indexing/rebuild_strategies.rb +15 -15
- data/lib/familia/features/relationships/indexing/unique_index_generators.rb +8 -0
- data/lib/familia/features/relationships/participation/participant_methods.rb +59 -10
- data/lib/familia/features/relationships/participation/target_methods.rb +51 -7
- data/lib/familia/features/relationships/participation/through_model_operations.rb +150 -0
- data/lib/familia/features/relationships/participation.rb +39 -15
- data/lib/familia/features/relationships/participation_relationship.rb +19 -1
- data/lib/familia/features/relationships.rb +1 -1
- data/lib/familia/horreum/database_commands.rb +6 -1
- data/lib/familia/horreum/management.rb +141 -10
- data/lib/familia/horreum/persistence.rb +3 -0
- data/lib/familia/identifier_extractor.rb +1 -1
- data/lib/familia/version.rb +1 -1
- data/lib/multi_result.rb +59 -31
- data/pr_agent.toml +6 -1
- data/try/features/count_any_edge_cases_try.rb +486 -0
- data/try/features/count_any_methods_try.rb +197 -0
- data/try/features/external_identifier/external_identifier_try.rb +134 -0
- data/try/features/object_identifier/object_identifier_try.rb +138 -0
- data/try/features/relationships/indexing_rebuild_try.rb +6 -0
- data/try/features/relationships/participation_commands_verification_spec.rb +1 -1
- data/try/features/relationships/participation_commands_verification_try.rb +1 -1
- data/try/features/relationships/participation_method_prefix_try.rb +133 -0
- data/try/features/relationships/participation_reverse_index_try.rb +1 -1
- data/try/features/relationships/{participation_bidirectional_try.rb → participation_reverse_methods_try.rb} +6 -6
- data/try/features/relationships/participation_through_try.rb +173 -0
- data/try/integration/data_types/datatype_pipelines_try.rb +5 -3
- data/try/integration/data_types/datatype_transactions_try.rb +13 -7
- data/try/integration/models/customer_try.rb +3 -3
- data/try/unit/data_types/boolean_try.rb +35 -22
- data/try/unit/data_types/hash_try.rb +2 -2
- data/try/unit/data_types/serialization_try.rb +386 -0
- data/try/unit/horreum/destroy_related_fields_cleanup_try.rb +2 -1
- metadata +9 -8
- data/changelog.d/20251105_flexible_external_identifier_format.rst +0 -66
- data/changelog.d/20251107_112554_delano_179_participation_asymmetry.rst +0 -44
- data/changelog.d/20251107_213121_delano_fix_thread_safety_races_011CUumCP492Twxm4NLt2FvL.rst +0 -20
- data/changelog.d/20251107_fix_participates_in_symbol_resolution.rst +0 -91
- data/changelog.d/20251107_optimized_redis_exists_checks.rst +0 -94
- data/changelog.d/20251108_frozen_string_literal_pragma.rst +0 -44
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# examples/through_relationships.rb
|
|
3
|
+
#
|
|
4
|
+
# frozen_string_literal: true
|
|
5
|
+
|
|
6
|
+
# Through Relationships Example
|
|
7
|
+
# Demonstrates the :through option for participates_in, which creates
|
|
8
|
+
# intermediate join models with additional attributes (roles, metadata, etc.)
|
|
9
|
+
|
|
10
|
+
$LOAD_PATH.unshift File.expand_path('../lib', __dir__)
|
|
11
|
+
require 'familia'
|
|
12
|
+
|
|
13
|
+
# Configure Familia for the example
|
|
14
|
+
# Use port 2525 (Familia test database) or set REDIS_URL
|
|
15
|
+
Familia.configure do |config|
|
|
16
|
+
config.uri = ENV.fetch('REDIS_URL', 'redis://localhost:2525/')
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
puts '=== Familia Through Relationships Example ==='
|
|
20
|
+
puts
|
|
21
|
+
puts 'The :through option creates join models between participants and targets,'
|
|
22
|
+
puts 'similar to has_many :through in ActiveRecord but for Redis.'
|
|
23
|
+
puts
|
|
24
|
+
|
|
25
|
+
# ============================================================================
|
|
26
|
+
# MODEL DEFINITIONS
|
|
27
|
+
# ============================================================================
|
|
28
|
+
|
|
29
|
+
# The through model MUST use feature :object_identifier
|
|
30
|
+
# This enables deterministic key generation for the join record
|
|
31
|
+
class Membership < Familia::Horreum
|
|
32
|
+
logical_database 15
|
|
33
|
+
feature :object_identifier # REQUIRED for through models
|
|
34
|
+
feature :relationships
|
|
35
|
+
|
|
36
|
+
identifier_field :objid
|
|
37
|
+
|
|
38
|
+
# Foreign keys (auto-set by through operations)
|
|
39
|
+
field :organization_objid
|
|
40
|
+
field :user_objid
|
|
41
|
+
|
|
42
|
+
# Additional attributes - this is why we use :through!
|
|
43
|
+
field :role # 'owner', 'admin', 'member'
|
|
44
|
+
field :permissions # JSON or comma-separated list
|
|
45
|
+
field :invited_by # Who invited this user
|
|
46
|
+
field :invited_at # When they were invited
|
|
47
|
+
field :joined_at # When they accepted
|
|
48
|
+
field :updated_at # Auto-set for cache invalidation
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class Organization < Familia::Horreum
|
|
52
|
+
logical_database 15
|
|
53
|
+
feature :object_identifier
|
|
54
|
+
feature :relationships
|
|
55
|
+
|
|
56
|
+
identifier_field :objid # Use objid as identifier (auto-generated)
|
|
57
|
+
field :name
|
|
58
|
+
field :plan
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
class User < Familia::Horreum
|
|
62
|
+
logical_database 15
|
|
63
|
+
feature :object_identifier
|
|
64
|
+
feature :relationships
|
|
65
|
+
|
|
66
|
+
identifier_field :objid # Use objid as identifier (auto-generated)
|
|
67
|
+
field :email
|
|
68
|
+
field :name
|
|
69
|
+
|
|
70
|
+
# Declare participation WITH a through model
|
|
71
|
+
# This creates Membership records when adding users to organizations
|
|
72
|
+
participates_in Organization, :members,
|
|
73
|
+
score: -> { Familia.now.to_f },
|
|
74
|
+
through: :Membership
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
puts '=== 1. Model Setup ==='
|
|
78
|
+
puts
|
|
79
|
+
puts 'Through model (Membership) requirements:'
|
|
80
|
+
puts ' • feature :object_identifier - enables deterministic keys'
|
|
81
|
+
puts ' • Fields for foreign keys: organization_objid, user_objid'
|
|
82
|
+
puts ' • Additional fields: role, permissions, invited_by, etc.'
|
|
83
|
+
puts
|
|
84
|
+
puts 'Participant (User) declaration:'
|
|
85
|
+
puts ' participates_in Organization, :members,'
|
|
86
|
+
puts ' score: -> { Familia.now.to_f },'
|
|
87
|
+
puts ' through: :Membership'
|
|
88
|
+
puts
|
|
89
|
+
|
|
90
|
+
# ============================================================================
|
|
91
|
+
# CREATING OBJECTS
|
|
92
|
+
# ============================================================================
|
|
93
|
+
|
|
94
|
+
puts '=== 2. Creating Objects ==='
|
|
95
|
+
|
|
96
|
+
org = Organization.new(name: 'Acme Corp', plan: 'enterprise')
|
|
97
|
+
org.save
|
|
98
|
+
puts "Created organization: #{org.name} (#{org.objid})"
|
|
99
|
+
|
|
100
|
+
alice = User.new(email: 'alice@acme.com', name: 'Alice')
|
|
101
|
+
alice.save
|
|
102
|
+
puts "Created user: #{alice.name} (#{alice.objid})"
|
|
103
|
+
|
|
104
|
+
bob = User.new(email: 'bob@acme.com', name: 'Bob')
|
|
105
|
+
bob.save
|
|
106
|
+
puts "Created user: #{bob.name} (#{bob.objid})"
|
|
107
|
+
|
|
108
|
+
charlie = User.new(email: 'charlie@acme.com', name: 'Charlie')
|
|
109
|
+
charlie.save
|
|
110
|
+
puts "Created user: #{charlie.name} (#{charlie.objid})"
|
|
111
|
+
puts
|
|
112
|
+
|
|
113
|
+
# ============================================================================
|
|
114
|
+
# ADDING MEMBERS WITH THROUGH ATTRIBUTES
|
|
115
|
+
# ============================================================================
|
|
116
|
+
|
|
117
|
+
puts '=== 3. Adding Members with Roles ==='
|
|
118
|
+
puts
|
|
119
|
+
|
|
120
|
+
# Add Alice as owner - through_attrs sets Membership fields
|
|
121
|
+
membership_alice = org.add_members_instance(alice, through_attrs: {
|
|
122
|
+
role: 'owner',
|
|
123
|
+
permissions: 'all',
|
|
124
|
+
joined_at: Familia.now.to_f,
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
puts "Added #{alice.name} as #{membership_alice.role}"
|
|
128
|
+
puts " Membership objid: #{membership_alice.objid}"
|
|
129
|
+
puts " Membership class: #{membership_alice.class.name}"
|
|
130
|
+
puts
|
|
131
|
+
|
|
132
|
+
# Add Bob as admin
|
|
133
|
+
membership_bob = org.add_members_instance(bob, through_attrs: {
|
|
134
|
+
role: 'admin',
|
|
135
|
+
permissions: 'read,write,invite',
|
|
136
|
+
invited_by: alice.objid,
|
|
137
|
+
invited_at: Familia.now.to_f,
|
|
138
|
+
joined_at: Familia.now.to_f,
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
puts "Added #{bob.name} as #{membership_bob.role}"
|
|
142
|
+
puts " Invited by: #{membership_bob.invited_by}"
|
|
143
|
+
puts
|
|
144
|
+
|
|
145
|
+
# Add Charlie as member
|
|
146
|
+
membership_charlie = org.add_members_instance(charlie, through_attrs: {
|
|
147
|
+
role: 'member',
|
|
148
|
+
permissions: 'read',
|
|
149
|
+
invited_by: bob.objid,
|
|
150
|
+
invited_at: Familia.now.to_f,
|
|
151
|
+
joined_at: Familia.now.to_f,
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
puts "Added #{charlie.name} as #{membership_charlie.role}"
|
|
155
|
+
puts
|
|
156
|
+
|
|
157
|
+
# ============================================================================
|
|
158
|
+
# QUERYING MEMBERSHIPS
|
|
159
|
+
# ============================================================================
|
|
160
|
+
|
|
161
|
+
puts '=== 4. Querying Memberships ==='
|
|
162
|
+
puts
|
|
163
|
+
|
|
164
|
+
# Check organization members
|
|
165
|
+
puts "Organization #{org.name} has #{org.members.size} members:"
|
|
166
|
+
org.members.members.each do |user_id|
|
|
167
|
+
puts " - #{user_id}"
|
|
168
|
+
end
|
|
169
|
+
puts
|
|
170
|
+
|
|
171
|
+
# Check if user is member
|
|
172
|
+
puts "Is Alice a member? #{alice.in_organization_members?(org)}"
|
|
173
|
+
puts "Is Bob a member? #{bob.in_organization_members?(org)}"
|
|
174
|
+
puts
|
|
175
|
+
|
|
176
|
+
# Load and inspect through model directly
|
|
177
|
+
# Key format: {target_prefix}:{target_objid}:{participant_prefix}:{participant_objid}:{through_prefix}
|
|
178
|
+
membership_key = "organization:#{org.objid}:user:#{alice.objid}:membership"
|
|
179
|
+
loaded_membership = Membership.load(membership_key)
|
|
180
|
+
|
|
181
|
+
puts 'Direct membership lookup for Alice:'
|
|
182
|
+
puts " Key: #{membership_key}"
|
|
183
|
+
puts " Role: #{loaded_membership.role}"
|
|
184
|
+
puts " Permissions: #{loaded_membership.permissions}"
|
|
185
|
+
puts " Updated at: #{Time.at(loaded_membership.updated_at.to_f)}"
|
|
186
|
+
puts
|
|
187
|
+
|
|
188
|
+
# ============================================================================
|
|
189
|
+
# UPDATING MEMBERSHIP ATTRIBUTES
|
|
190
|
+
# ============================================================================
|
|
191
|
+
|
|
192
|
+
puts '=== 5. Updating Membership (Idempotent) ==='
|
|
193
|
+
puts
|
|
194
|
+
|
|
195
|
+
# Re-adding with different attrs updates the existing membership
|
|
196
|
+
puts "Promoting #{charlie.name} from member to admin..."
|
|
197
|
+
|
|
198
|
+
updated_membership = org.add_members_instance(charlie, through_attrs: {
|
|
199
|
+
role: 'admin',
|
|
200
|
+
permissions: 'read,write',
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
puts " New role: #{updated_membership.role}"
|
|
204
|
+
puts " Same objid? #{updated_membership.objid == membership_charlie.objid}"
|
|
205
|
+
puts ' (Idempotent: updates existing, no duplicates)'
|
|
206
|
+
puts
|
|
207
|
+
|
|
208
|
+
# ============================================================================
|
|
209
|
+
# REMOVING MEMBERS
|
|
210
|
+
# ============================================================================
|
|
211
|
+
|
|
212
|
+
puts '=== 6. Removing Members ==='
|
|
213
|
+
puts
|
|
214
|
+
|
|
215
|
+
puts "Removing #{charlie.name} from organization..."
|
|
216
|
+
org.remove_members_instance(charlie)
|
|
217
|
+
|
|
218
|
+
# Verify removal
|
|
219
|
+
puts " Is Charlie still a member? #{charlie.in_organization_members?(org)}"
|
|
220
|
+
|
|
221
|
+
# Through model is also destroyed
|
|
222
|
+
removed_key = "organization:#{org.objid}:user:#{charlie.objid}:membership"
|
|
223
|
+
removed_membership = Membership.load(removed_key)
|
|
224
|
+
puts " Membership record exists? #{removed_membership&.exists? || false}"
|
|
225
|
+
puts
|
|
226
|
+
|
|
227
|
+
# ============================================================================
|
|
228
|
+
# BACKWARD COMPATIBILITY
|
|
229
|
+
# ============================================================================
|
|
230
|
+
|
|
231
|
+
puts '=== 7. Backward Compatibility ==='
|
|
232
|
+
puts
|
|
233
|
+
|
|
234
|
+
# Define a relationship WITHOUT :through
|
|
235
|
+
class Project < Familia::Horreum
|
|
236
|
+
logical_database 15
|
|
237
|
+
feature :object_identifier
|
|
238
|
+
feature :relationships
|
|
239
|
+
identifier_field :objid
|
|
240
|
+
field :name
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
class Task < Familia::Horreum
|
|
244
|
+
logical_database 15
|
|
245
|
+
feature :object_identifier
|
|
246
|
+
feature :relationships
|
|
247
|
+
identifier_field :objid
|
|
248
|
+
field :title
|
|
249
|
+
# No :through - works exactly as before
|
|
250
|
+
participates_in Project, :tasks, score: -> { Familia.now.to_f }
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
project = Project.new(name: 'Website Redesign')
|
|
254
|
+
project.save
|
|
255
|
+
|
|
256
|
+
task = Task.new(title: 'Design mockups')
|
|
257
|
+
task.save
|
|
258
|
+
|
|
259
|
+
# Without :through, returns the target (not a through model)
|
|
260
|
+
result = project.add_tasks_instance(task)
|
|
261
|
+
puts "Without :through, add returns: #{result.class.name}"
|
|
262
|
+
puts ' (Returns the target, not a through model)'
|
|
263
|
+
puts
|
|
264
|
+
|
|
265
|
+
# ============================================================================
|
|
266
|
+
# CLEANUP
|
|
267
|
+
# ============================================================================
|
|
268
|
+
|
|
269
|
+
puts '=== 8. Cleanup ==='
|
|
270
|
+
|
|
271
|
+
[org, alice, bob, charlie, project, task].each do |obj|
|
|
272
|
+
obj.destroy! if obj&.exists?
|
|
273
|
+
end
|
|
274
|
+
puts 'Cleaned up all test objects'
|
|
275
|
+
puts
|
|
@@ -87,8 +87,7 @@ module Familia
|
|
|
87
87
|
|
|
88
88
|
# Return MultiResult format for consistency
|
|
89
89
|
results = proxy.collected_results
|
|
90
|
-
|
|
91
|
-
MultiResult.new(summary_boolean, results)
|
|
90
|
+
MultiResult.new(results)
|
|
92
91
|
end
|
|
93
92
|
end
|
|
94
93
|
end
|
|
@@ -80,9 +80,7 @@ module Familia
|
|
|
80
80
|
end
|
|
81
81
|
|
|
82
82
|
# Return same MultiResult format as other methods
|
|
83
|
-
|
|
84
|
-
summary_boolean = command_return_values.none? { |ret| ret.is_a?(Exception) }
|
|
85
|
-
MultiResult.new(summary_boolean, command_return_values)
|
|
83
|
+
MultiResult.new(command_return_values)
|
|
86
84
|
end
|
|
87
85
|
end
|
|
88
86
|
end
|
|
@@ -158,8 +158,7 @@ module Familia
|
|
|
158
158
|
end
|
|
159
159
|
|
|
160
160
|
# Return same MultiResult format as other methods
|
|
161
|
-
|
|
162
|
-
MultiResult.new(summary_boolean, command_return_values)
|
|
161
|
+
MultiResult.new(command_return_values)
|
|
163
162
|
end
|
|
164
163
|
end
|
|
165
164
|
end
|
|
@@ -8,46 +8,45 @@ module Familia
|
|
|
8
8
|
# Serializes a value for storage in the database.
|
|
9
9
|
#
|
|
10
10
|
# @param val [Object] The value to be serialized.
|
|
11
|
-
# @
|
|
12
|
-
# serialization (default: true).
|
|
13
|
-
# @return [String, nil] The serialized representation of the value, or nil
|
|
14
|
-
# if serialization fails.
|
|
11
|
+
# @return [String] The JSON-serialized representation of the value.
|
|
15
12
|
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
13
|
+
# Serialization priority:
|
|
14
|
+
# 1. Familia objects (Base instances or classes) → extract identifier
|
|
15
|
+
# 2. All other values → JSON serialize for type preservation
|
|
19
16
|
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
17
|
+
# This unifies behavior with Horreum fields (Issue #190), ensuring
|
|
18
|
+
# consistent type preservation across DataType and Horreum.
|
|
22
19
|
#
|
|
23
|
-
# @example
|
|
24
|
-
# serialize_value(
|
|
25
|
-
# serialize_value("hello") #=> "hello"
|
|
20
|
+
# @example Familia object reference
|
|
21
|
+
# serialize_value(customer_obj) #=> "customer_123" (identifier)
|
|
26
22
|
#
|
|
27
|
-
# @
|
|
28
|
-
#
|
|
23
|
+
# @example Primitive values (JSON encoded)
|
|
24
|
+
# serialize_value(42) #=> "42"
|
|
25
|
+
# serialize_value("hello") #=> '"hello"'
|
|
26
|
+
# serialize_value(true) #=> "true"
|
|
27
|
+
# serialize_value(nil) #=> "null"
|
|
28
|
+
# serialize_value([1, 2, 3]) #=> "[1,2,3]"
|
|
29
29
|
#
|
|
30
|
-
def serialize_value(val
|
|
31
|
-
prepared = nil
|
|
32
|
-
|
|
30
|
+
def serialize_value(val)
|
|
33
31
|
Familia.trace :TOREDIS, nil, "#{val}<#{val.class}|#{opts[:class]}>" if Familia.debug?
|
|
34
32
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
# Priority 1: Handle Familia object references - extract identifier
|
|
34
|
+
# This preserves the existing behavior for storing object references
|
|
35
|
+
if val.is_a?(Familia::Base) || (val.is_a?(Class) && val.ancestors.include?(Familia::Base))
|
|
36
|
+
prepared = val.is_a?(Class) ? val.name : val.identifier
|
|
37
|
+
Familia.debug " Familia object: #{val.class} => #{prepared}"
|
|
38
|
+
return prepared
|
|
38
39
|
end
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
end
|
|
41
|
+
# Priority 2: Everything else gets JSON serialized for type preservation
|
|
42
|
+
# This unifies behavior with Horreum fields (Issue #190)
|
|
43
|
+
prepared = Familia::JsonSerializer.dump(val)
|
|
44
|
+
Familia.debug " JSON serialized: #{val.class} => #{prepared}"
|
|
45
45
|
|
|
46
46
|
if Familia.debug?
|
|
47
|
-
Familia.trace :TOREDIS, nil, "#{val}<#{val.class}
|
|
47
|
+
Familia.trace :TOREDIS, nil, "#{val}<#{val.class}> => #{prepared}<#{prepared.class}>"
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
-
Familia.warn "[#{self.class}#serialize_value] nil returned for #{opts[:class]}##{name}" if prepared.nil?
|
|
51
50
|
prepared
|
|
52
51
|
end
|
|
53
52
|
|
|
@@ -81,27 +80,41 @@ module Familia
|
|
|
81
80
|
def deserialize_values_with_nil(*values)
|
|
82
81
|
Familia.debug "deserialize_values: (#{@opts}) #{values}"
|
|
83
82
|
return [] if values.empty?
|
|
84
|
-
return values.flatten unless @opts[:class]
|
|
85
83
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
84
|
+
# If a class option is specified, use class-based deserialization
|
|
85
|
+
if @opts[:class]
|
|
86
|
+
unless @opts[:class].respond_to?(load_method)
|
|
87
|
+
raise Familia::Problem, "No such method: #{@opts[:class]}##{load_method}"
|
|
88
|
+
end
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
values.collect! do |obj|
|
|
91
|
+
next if obj.nil?
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
val = @opts[:class].send load_method, obj
|
|
94
|
+
Familia.debug "[#{self.class}#deserialize_values] nil returned for #{@opts[:class]}##{name}" if val.nil?
|
|
95
95
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
96
|
+
val
|
|
97
|
+
rescue StandardError => e
|
|
98
|
+
Familia.info val
|
|
99
|
+
Familia.info "Parse error for #{dbkey} (#{load_method}): #{e.message}"
|
|
100
|
+
Familia.info e.backtrace
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
return values
|
|
102
105
|
end
|
|
103
106
|
|
|
104
|
-
|
|
107
|
+
# No class option: JSON deserialize each value for type preservation (Issue #190)
|
|
108
|
+
values.flatten.collect do |obj|
|
|
109
|
+
next if obj.nil?
|
|
110
|
+
|
|
111
|
+
begin
|
|
112
|
+
Familia::JsonSerializer.parse(obj)
|
|
113
|
+
rescue Familia::SerializerError
|
|
114
|
+
# Fallback for legacy data stored without JSON encoding
|
|
115
|
+
obj
|
|
116
|
+
end
|
|
117
|
+
end
|
|
105
118
|
end
|
|
106
119
|
|
|
107
120
|
# Deserializes a single value from the database.
|
|
@@ -110,13 +123,15 @@ module Familia
|
|
|
110
123
|
# @return [Object, nil] The deserialized object, the default value if
|
|
111
124
|
# val is nil, or nil if deserialization fails.
|
|
112
125
|
#
|
|
113
|
-
#
|
|
114
|
-
#
|
|
126
|
+
# Deserialization priority:
|
|
127
|
+
# 1. Redis::Future objects → return as-is (transaction handling)
|
|
128
|
+
# 2. nil values → return default option value
|
|
129
|
+
# 3. Class option specified → use class-based deserialization
|
|
130
|
+
# 4. No class option → JSON parse for type preservation
|
|
115
131
|
#
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
# for serialization since everything becomes a string in Valkey.
|
|
132
|
+
# This unifies behavior with Horreum fields (Issue #190), ensuring
|
|
133
|
+
# consistent type preservation. Legacy data stored without JSON
|
|
134
|
+
# encoding is returned as-is.
|
|
120
135
|
#
|
|
121
136
|
def deserialize_value(val)
|
|
122
137
|
# Handle Redis::Future objects during transactions first
|
|
@@ -124,10 +139,20 @@ module Familia
|
|
|
124
139
|
|
|
125
140
|
return @opts[:default] if val.nil?
|
|
126
141
|
|
|
127
|
-
|
|
142
|
+
# If a class option is specified, use the existing class-based deserialization
|
|
143
|
+
if @opts[:class]
|
|
144
|
+
ret = deserialize_values val
|
|
145
|
+
return ret&.first # return the object or nil
|
|
146
|
+
end
|
|
128
147
|
|
|
129
|
-
|
|
130
|
-
|
|
148
|
+
# No class option: JSON deserialize for type preservation (Issue #190)
|
|
149
|
+
# This unifies behavior with Horreum fields
|
|
150
|
+
begin
|
|
151
|
+
Familia::JsonSerializer.parse(val)
|
|
152
|
+
rescue Familia::SerializerError
|
|
153
|
+
# Fallback for legacy data stored without JSON encoding
|
|
154
|
+
val
|
|
155
|
+
end
|
|
131
156
|
end
|
|
132
157
|
end
|
|
133
158
|
end
|
|
@@ -129,7 +129,7 @@ module Familia
|
|
|
129
129
|
alias add_element add
|
|
130
130
|
|
|
131
131
|
def score(val)
|
|
132
|
-
ret = dbclient.zscore dbkey, serialize_value(val
|
|
132
|
+
ret = dbclient.zscore dbkey, serialize_value(val)
|
|
133
133
|
ret&.to_f
|
|
134
134
|
end
|
|
135
135
|
alias [] score
|
|
@@ -142,13 +142,13 @@ module Familia
|
|
|
142
142
|
|
|
143
143
|
# rank of member +v+ when ordered lowest to highest (starts at 0)
|
|
144
144
|
def rank(v)
|
|
145
|
-
ret = dbclient.zrank dbkey, serialize_value(v
|
|
145
|
+
ret = dbclient.zrank dbkey, serialize_value(v)
|
|
146
146
|
ret&.to_i
|
|
147
147
|
end
|
|
148
148
|
|
|
149
149
|
# rank of member +v+ when ordered highest to lowest (starts at 0)
|
|
150
150
|
def revrank(v)
|
|
151
|
-
ret = dbclient.zrevrank dbkey, serialize_value(v
|
|
151
|
+
ret = dbclient.zrevrank dbkey, serialize_value(v)
|
|
152
152
|
ret&.to_i
|
|
153
153
|
end
|
|
154
154
|
|
|
@@ -269,7 +269,7 @@ module Familia
|
|
|
269
269
|
end
|
|
270
270
|
|
|
271
271
|
def increment(val, by = 1)
|
|
272
|
-
dbclient.zincrby(dbkey, by, val).to_i
|
|
272
|
+
dbclient.zincrby(dbkey, by, serialize_value(val)).to_i
|
|
273
273
|
end
|
|
274
274
|
alias incr increment
|
|
275
275
|
alias incrby increment
|
|
@@ -285,12 +285,7 @@ module Familia
|
|
|
285
285
|
# @return [Integer] The number of members that were removed (0 or 1)
|
|
286
286
|
def remove_element(value)
|
|
287
287
|
Familia.trace :REMOVE_ELEMENT, nil, "#{value}<#{value.class}>" if Familia.debug?
|
|
288
|
-
|
|
289
|
-
# that are in the sorted set. If it's a horreum object, the value is
|
|
290
|
-
# the identifier and not a serialized version of the object. So either
|
|
291
|
-
# the value exists in the sorted set or it doesn't -- we don't need to
|
|
292
|
-
# raise an error if it's not found.
|
|
293
|
-
dbclient.zrem dbkey, serialize_value(value, strict_values: false)
|
|
288
|
+
dbclient.zrem dbkey, serialize_value(value)
|
|
294
289
|
end
|
|
295
290
|
alias remove remove_element # deprecated
|
|
296
291
|
|
|
@@ -6,6 +6,28 @@ module Familia
|
|
|
6
6
|
class StringKey < DataType
|
|
7
7
|
def init; end
|
|
8
8
|
|
|
9
|
+
# StringKey uses raw string serialization (not JSON) because Redis string
|
|
10
|
+
# operations like INCR, DECR, APPEND operate on raw values.
|
|
11
|
+
# This overrides the base JSON serialization from DataType.
|
|
12
|
+
def serialize_value(val)
|
|
13
|
+
Familia.trace :TOREDIS, nil, "#{val}<#{val.class}>" if Familia.debug?
|
|
14
|
+
|
|
15
|
+
# Handle Familia object references - extract identifier
|
|
16
|
+
if val.is_a?(Familia::Base) || (val.is_a?(Class) && val.ancestors.include?(Familia::Base))
|
|
17
|
+
return val.is_a?(Class) ? val.name : val.identifier
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# StringKey uses raw string conversion for Redis compatibility
|
|
21
|
+
val.to_s
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# StringKey returns raw values (not JSON parsed)
|
|
25
|
+
def deserialize_value(val)
|
|
26
|
+
return val if val.is_a?(Redis::Future)
|
|
27
|
+
return @opts[:default] if val.nil?
|
|
28
|
+
val
|
|
29
|
+
end
|
|
30
|
+
|
|
9
31
|
# Returns the number of elements in the list
|
|
10
32
|
# @return [Integer] number of elements
|
|
11
33
|
def char_count
|
|
@@ -176,6 +176,35 @@ module Familia
|
|
|
176
176
|
# extid_lookup.remove_field(extid)
|
|
177
177
|
nil
|
|
178
178
|
end
|
|
179
|
+
|
|
180
|
+
# Check if a string matches the extid format for the Horreum class. The specific
|
|
181
|
+
# class is important b/c each one can have its own custom prefix, like `ext_`.
|
|
182
|
+
#
|
|
183
|
+
# The validator accepts a reasonable range of ID lengths (20-32 characters) to
|
|
184
|
+
# accommodate potential changes to the entropy or encoding while maintaining
|
|
185
|
+
# security. The current implementation generates exactly 25 base36 characters
|
|
186
|
+
# from 16 bytes (128 bits), but this flexibility allows for future adjustments
|
|
187
|
+
# without breaking validation.
|
|
188
|
+
#
|
|
189
|
+
# @param guess [String] The string to check
|
|
190
|
+
# @return [Boolean] true if the guess matches the extid format, false otherwise
|
|
191
|
+
def extid?(guess)
|
|
192
|
+
return false if guess.to_s.empty?
|
|
193
|
+
|
|
194
|
+
options = feature_options(:external_identifier)
|
|
195
|
+
format = options[:format] || 'ext_%{id}'
|
|
196
|
+
|
|
197
|
+
# Extract prefix and suffix from format
|
|
198
|
+
return false unless format.include?('%{id}')
|
|
199
|
+
prefix, suffix = format.split('%{id}', 2)
|
|
200
|
+
|
|
201
|
+
# Build regex pattern to match the extid format
|
|
202
|
+
# Accept 20-32 base36 characters to allow for entropy/encoding variations
|
|
203
|
+
# Current generation: 16 bytes -> base36 -> 25 chars (rjust with '0')
|
|
204
|
+
pattern = /\A#{Regexp.escape(prefix)}[0-9a-z]{20,32}#{Regexp.escape(suffix)}\z/i
|
|
205
|
+
|
|
206
|
+
!!(guess =~ pattern)
|
|
207
|
+
end
|
|
179
208
|
end
|
|
180
209
|
|
|
181
210
|
# Instance methods for external identifier management
|
|
@@ -279,6 +279,53 @@ module Familia
|
|
|
279
279
|
# objid_lookup.remove_field(objid)
|
|
280
280
|
nil
|
|
281
281
|
end
|
|
282
|
+
|
|
283
|
+
# Check if a string matches the objid format for the Horreum class. The specific
|
|
284
|
+
# class is important b/c each one can have its own type of objid generator.
|
|
285
|
+
#
|
|
286
|
+
# @param guess [String] The string to check
|
|
287
|
+
# @return [Boolean] true if the guess matches the objid format, false otherwise
|
|
288
|
+
def objid?(guess)
|
|
289
|
+
return false if guess.to_s.empty?
|
|
290
|
+
|
|
291
|
+
options = feature_options(:object_identifier)
|
|
292
|
+
generator = options[:generator] || DEFAULT_GENERATOR
|
|
293
|
+
|
|
294
|
+
case generator
|
|
295
|
+
when :uuid_v7, :uuid_v4
|
|
296
|
+
# UUID format: xxxxxxxx-xxxx-Vxxx-xxxx-xxxxxxxxxxxx (36 chars with hyphens)
|
|
297
|
+
# Validate structure and that all characters are valid hex digits
|
|
298
|
+
guess_str = guess.to_s
|
|
299
|
+
return false unless guess_str.length == 36
|
|
300
|
+
return false unless guess_str[8] == '-' && guess_str[13] == '-' && guess_str[18] == '-' && guess_str[23] == '-'
|
|
301
|
+
|
|
302
|
+
# Extract segments and validate each is valid hex
|
|
303
|
+
segments = guess_str.split('-')
|
|
304
|
+
return false unless segments.length == 5
|
|
305
|
+
return false unless segments[0] =~ /\A[0-9a-fA-F]{8}\z/ # 8 hex chars
|
|
306
|
+
return false unless segments[1] =~ /\A[0-9a-fA-F]{4}\z/ # 4 hex chars
|
|
307
|
+
return false unless segments[2] =~ /\A[0-9a-fA-F]{4}\z/ # 4 hex chars (includes version)
|
|
308
|
+
return false unless segments[3] =~ /\A[0-9a-fA-F]{4}\z/ # 4 hex chars
|
|
309
|
+
return false unless segments[4] =~ /\A[0-9a-fA-F]{12}\z/ # 12 hex chars
|
|
310
|
+
|
|
311
|
+
# Validate version character
|
|
312
|
+
version_char = guess_str[14]
|
|
313
|
+
if generator == :uuid_v7
|
|
314
|
+
version_char == '7'
|
|
315
|
+
else # generator == :uuid_v4
|
|
316
|
+
version_char == '4'
|
|
317
|
+
end
|
|
318
|
+
when :hex
|
|
319
|
+
# Hex format: pure hexadecimal without hyphens
|
|
320
|
+
!!(guess =~ /\A[0-9a-fA-F]+\z/)
|
|
321
|
+
when Proc
|
|
322
|
+
# Cannot determine format for custom Proc generators
|
|
323
|
+
Familia.warn "[objid?] Validation not supported for custom Proc generators on #{name}" if Familia.debug?
|
|
324
|
+
false
|
|
325
|
+
else
|
|
326
|
+
false
|
|
327
|
+
end
|
|
328
|
+
end
|
|
282
329
|
end
|
|
283
330
|
|
|
284
331
|
# Instance methods for object identifier management
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
**participates_in** - Collection membership ("this object belongs in that collection")
|
|
14
14
|
```ruby
|
|
15
|
-
participates_in Organization, :members, score: :joined_at,
|
|
15
|
+
participates_in Organization, :members, score: :joined_at, generate_participant_methods: true
|
|
16
16
|
# Creates: org.members, org.add_member(), customer.add_to_organization_members()
|
|
17
17
|
```
|
|
18
18
|
|