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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/claude-code-review.yml +8 -5
  3. data/.talismanrc +5 -1
  4. data/CHANGELOG.rst +76 -0
  5. data/Gemfile.lock +8 -8
  6. data/docs/1106-participates_in-bidirectional-solution.md +201 -58
  7. data/examples/through_relationships.rb +275 -0
  8. data/lib/familia/connection/operation_core.rb +1 -2
  9. data/lib/familia/connection/pipelined_core.rb +1 -3
  10. data/lib/familia/connection/transaction_core.rb +1 -2
  11. data/lib/familia/data_type/serialization.rb +76 -51
  12. data/lib/familia/data_type/types/sorted_set.rb +5 -10
  13. data/lib/familia/data_type/types/stringkey.rb +22 -0
  14. data/lib/familia/features/external_identifier.rb +29 -0
  15. data/lib/familia/features/object_identifier.rb +47 -0
  16. data/lib/familia/features/relationships/README.md +1 -1
  17. data/lib/familia/features/relationships/indexing/rebuild_strategies.rb +15 -15
  18. data/lib/familia/features/relationships/indexing/unique_index_generators.rb +8 -0
  19. data/lib/familia/features/relationships/participation/participant_methods.rb +59 -10
  20. data/lib/familia/features/relationships/participation/target_methods.rb +51 -7
  21. data/lib/familia/features/relationships/participation/through_model_operations.rb +150 -0
  22. data/lib/familia/features/relationships/participation.rb +39 -15
  23. data/lib/familia/features/relationships/participation_relationship.rb +19 -1
  24. data/lib/familia/features/relationships.rb +1 -1
  25. data/lib/familia/horreum/database_commands.rb +6 -1
  26. data/lib/familia/horreum/management.rb +141 -10
  27. data/lib/familia/horreum/persistence.rb +3 -0
  28. data/lib/familia/identifier_extractor.rb +1 -1
  29. data/lib/familia/version.rb +1 -1
  30. data/lib/multi_result.rb +59 -31
  31. data/pr_agent.toml +6 -1
  32. data/try/features/count_any_edge_cases_try.rb +486 -0
  33. data/try/features/count_any_methods_try.rb +197 -0
  34. data/try/features/external_identifier/external_identifier_try.rb +134 -0
  35. data/try/features/object_identifier/object_identifier_try.rb +138 -0
  36. data/try/features/relationships/indexing_rebuild_try.rb +6 -0
  37. data/try/features/relationships/participation_commands_verification_spec.rb +1 -1
  38. data/try/features/relationships/participation_commands_verification_try.rb +1 -1
  39. data/try/features/relationships/participation_method_prefix_try.rb +133 -0
  40. data/try/features/relationships/participation_reverse_index_try.rb +1 -1
  41. data/try/features/relationships/{participation_bidirectional_try.rb → participation_reverse_methods_try.rb} +6 -6
  42. data/try/features/relationships/participation_through_try.rb +173 -0
  43. data/try/integration/data_types/datatype_pipelines_try.rb +5 -3
  44. data/try/integration/data_types/datatype_transactions_try.rb +13 -7
  45. data/try/integration/models/customer_try.rb +3 -3
  46. data/try/unit/data_types/boolean_try.rb +35 -22
  47. data/try/unit/data_types/hash_try.rb +2 -2
  48. data/try/unit/data_types/serialization_try.rb +386 -0
  49. data/try/unit/horreum/destroy_related_fields_cleanup_try.rb +2 -1
  50. metadata +9 -8
  51. data/changelog.d/20251105_flexible_external_identifier_format.rst +0 -66
  52. data/changelog.d/20251107_112554_delano_179_participation_asymmetry.rst +0 -44
  53. data/changelog.d/20251107_213121_delano_fix_thread_safety_races_011CUumCP492Twxm4NLt2FvL.rst +0 -20
  54. data/changelog.d/20251107_fix_participates_in_symbol_resolution.rst +0 -91
  55. data/changelog.d/20251107_optimized_redis_exists_checks.rst +0 -94
  56. 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
- summary_boolean = results.all? { |ret| !ret.is_a?(Exception) }
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
- # Pipeline success is true if no exceptions occurred (all commands executed)
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
- summary_boolean = command_return_values.all? { |ret| %w[OK 0 1].include?(ret.to_s) }
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
- # @param strict_values [Boolean] Whether to enforce strict value
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
- # @note When a class option is specified, it uses Familia.identifier_extractor
17
- # to extract the identifier from objects. Otherwise, it extracts identifiers
18
- # from Familia::Base instances or class names.
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
- # @example With a class option
21
- # serialize_value(User.new(name: "Cloe"), strict_values: false) #=> '{"name":"Cloe"}'
17
+ # This unifies behavior with Horreum fields (Issue #190), ensuring
18
+ # consistent type preservation across DataType and Horreum.
22
19
  #
23
- # @example Without a class option
24
- # serialize_value(123) #=> "123"
25
- # serialize_value("hello") #=> "hello"
20
+ # @example Familia object reference
21
+ # serialize_value(customer_obj) #=> "customer_123" (identifier)
26
22
  #
27
- # @raise [Familia::NotDistinguishableError] If serialization fails under strict
28
- # mode.
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, strict_values: true)
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
- if opts[:class]
36
- prepared = Familia.identifier_extractor(opts[:class])
37
- Familia.debug " from opts[class] <#{opts[:class]}>: #{prepared || '<nil>'}"
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
- if prepared.nil?
41
- # Enforce strict values when no class option is specified
42
- prepared = Familia.identifier_extractor(val)
43
- Familia.debug " from <#{val.class}> => <#{prepared.class}>"
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}|#{opts[:class]}> => #{prepared}<#{prepared.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
- unless @opts[:class].respond_to?(load_method)
87
- raise Familia::Problem, "No such method: #{@opts[:class]}##{load_method}"
88
- end
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
- values.collect! do |obj|
91
- next if obj.nil?
90
+ values.collect! do |obj|
91
+ next if obj.nil?
92
92
 
93
- val = @opts[:class].send load_method, obj
94
- Familia.debug "[#{self.class}#deserialize_values] nil returned for #{@opts[:class]}##{name}" if val.nil?
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
- 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
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
- values
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
- # @note If no class option is specified, the original value is
114
- # returned unchanged.
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
- # NOTE: Currently only the DataType class uses this method. Horreum
117
- # fields are a newer addition and don't support the full range of
118
- # deserialization options that DataType supports. It uses serialize_value
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
- return val unless @opts[:class]
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
- ret = deserialize_values val
130
- ret&.first # return the object or nil
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, strict_values: false)
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, strict_values: false)
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, strict_values: false)
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
- # We use `strict_values: false` here to allow for the deletion of values
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, bidirectional: true
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