familia 2.0.0.pre25 → 2.0.0

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/CHANGELOG.rst +69 -0
  4. data/Gemfile +1 -0
  5. data/Gemfile.lock +2 -2
  6. data/README.md +1 -3
  7. data/docs/guides/feature-encrypted-fields.md +1 -1
  8. data/docs/guides/feature-expiration.md +1 -1
  9. data/docs/guides/feature-quantization.md +1 -1
  10. data/docs/overview.md +7 -7
  11. data/docs/reference/api-technical.md +103 -7
  12. data/familia.gemspec +1 -2
  13. data/lib/familia/data_type/types/hashkey.rb +238 -0
  14. data/lib/familia/data_type/types/listkey.rb +110 -4
  15. data/lib/familia/data_type/types/sorted_set.rb +365 -0
  16. data/lib/familia/data_type/types/stringkey.rb +139 -0
  17. data/lib/familia/data_type/types/unsorted_set.rb +122 -2
  18. data/lib/familia/features/relationships/indexing/rebuild_strategies.rb +2 -1
  19. data/lib/familia/features/relationships/participation/through_model_operations.rb +4 -3
  20. data/lib/familia/features/relationships/participation.rb +6 -6
  21. data/lib/familia/horreum/management.rb +29 -0
  22. data/lib/familia/version.rb +1 -1
  23. data/try/features/relationships/prefix_vs_config_name_try.rb +418 -0
  24. metadata +3 -27
  25. data/docs/migrating/v2.0.0-pre.md +0 -84
  26. data/docs/migrating/v2.0.0-pre11.md +0 -253
  27. data/docs/migrating/v2.0.0-pre12.md +0 -306
  28. data/docs/migrating/v2.0.0-pre13.md +0 -95
  29. data/docs/migrating/v2.0.0-pre14.md +0 -37
  30. data/docs/migrating/v2.0.0-pre18.md +0 -58
  31. data/docs/migrating/v2.0.0-pre19.md +0 -197
  32. data/docs/migrating/v2.0.0-pre22.md +0 -241
  33. data/docs/migrating/v2.0.0-pre5.md +0 -131
  34. data/docs/migrating/v2.0.0-pre6.md +0 -154
  35. data/docs/migrating/v2.0.0-pre7.md +0 -222
@@ -87,9 +87,113 @@ module Familia
87
87
  end
88
88
  alias remove remove_element # deprecated
89
89
 
90
- def intersection *setkeys
91
- # TODO
90
+ # Returns the intersection of this set with one or more other sets.
91
+ # @param other_sets [Array<UnsortedSet, String>] Other sets (as UnsortedSet instances or raw keys)
92
+ # @return [Array] Deserialized members present in all sets
93
+ def intersection(*other_sets)
94
+ keys = extract_keys(other_sets)
95
+ elements = dbclient.sinter(dbkey, *keys)
96
+ deserialize_values(*elements)
97
+ end
98
+ alias inter intersection
99
+
100
+ # Returns the union of this set with one or more other sets.
101
+ # @param other_sets [Array<UnsortedSet, String>] Other sets (as UnsortedSet instances or raw keys)
102
+ # @return [Array] Deserialized members present in any of the sets
103
+ def union(*other_sets)
104
+ keys = extract_keys(other_sets)
105
+ elements = dbclient.sunion(dbkey, *keys)
106
+ deserialize_values(*elements)
107
+ end
108
+
109
+ # Returns the difference of this set minus one or more other sets.
110
+ # @param other_sets [Array<UnsortedSet, String>] Other sets (as UnsortedSet instances or raw keys)
111
+ # @return [Array] Deserialized members present in this set but not in any other sets
112
+ def difference(*other_sets)
113
+ keys = extract_keys(other_sets)
114
+ elements = dbclient.sdiff(dbkey, *keys)
115
+ deserialize_values(*elements)
116
+ end
117
+ alias diff difference
118
+
119
+ # Checks membership for multiple values at once.
120
+ # @param values [Array] Values to check for membership
121
+ # @return [Array<Boolean>] Array of booleans indicating membership for each value
122
+ def member_any?(*values)
123
+ values = values.flatten
124
+ serialized = values.map { |v| serialize_value(v) }
125
+ dbclient.smismember(dbkey, *serialized)
126
+ end
127
+ alias members? member_any?
128
+
129
+ # Iterates over set members using cursor-based iteration.
130
+ # @param cursor [Integer] Starting cursor position (default: 0)
131
+ # @param match [String, nil] Optional pattern to filter members
132
+ # @param count [Integer, nil] Optional hint for number of elements to return per call
133
+ # @return [Array<Integer, Array>] Two-element array: [new_cursor, deserialized_members]
134
+ def scan(cursor = 0, match: nil, count: nil)
135
+ opts = {}
136
+ opts[:match] = match if match
137
+ opts[:count] = count if count
138
+
139
+ new_cursor, elements = dbclient.sscan(dbkey, cursor, **opts)
140
+ [new_cursor.to_i, deserialize_values(*elements)]
141
+ end
142
+
143
+ # Returns the cardinality of the intersection without retrieving members.
144
+ # More memory-efficient than intersection when only the count is needed.
145
+ # @param other_sets [Array<UnsortedSet, String>] Other sets (as UnsortedSet instances or raw keys)
146
+ # @param limit [Integer] Stop counting after reaching this limit (0 = no limit)
147
+ # @return [Integer] Number of elements in the intersection
148
+ def intercard(*other_sets, limit: 0)
149
+ keys = extract_keys(other_sets)
150
+ all_keys = [dbkey, *keys]
151
+ if limit.positive?
152
+ dbclient.sintercard(all_keys.size, *all_keys, limit: limit)
153
+ else
154
+ dbclient.sintercard(all_keys.size, *all_keys)
155
+ end
156
+ end
157
+ alias intersection_cardinality intercard
158
+
159
+ # Stores the intersection of this set with other sets into a destination key.
160
+ # @param destination [UnsortedSet, String] Destination set (as UnsortedSet instance or raw key)
161
+ # @param other_sets [Array<UnsortedSet, String>] Other sets to intersect with
162
+ # @return [Integer] Number of elements in the resulting set
163
+ def interstore(destination, *other_sets)
164
+ dest_key = extract_key(destination)
165
+ keys = extract_keys(other_sets)
166
+ result = dbclient.sinterstore(dest_key, dbkey, *keys)
167
+ update_expiration
168
+ result
169
+ end
170
+ alias intersection_store interstore
171
+
172
+ # Stores the union of this set with other sets into a destination key.
173
+ # @param destination [UnsortedSet, String] Destination set (as UnsortedSet instance or raw key)
174
+ # @param other_sets [Array<UnsortedSet, String>] Other sets to union with
175
+ # @return [Integer] Number of elements in the resulting set
176
+ def unionstore(destination, *other_sets)
177
+ dest_key = extract_key(destination)
178
+ keys = extract_keys(other_sets)
179
+ result = dbclient.sunionstore(dest_key, dbkey, *keys)
180
+ update_expiration
181
+ result
182
+ end
183
+ alias union_store unionstore
184
+
185
+ # Stores the difference of this set minus other sets into a destination key.
186
+ # @param destination [UnsortedSet, String] Destination set (as UnsortedSet instance or raw key)
187
+ # @param other_sets [Array<UnsortedSet, String>] Other sets to subtract
188
+ # @return [Integer] Number of elements in the resulting set
189
+ def diffstore(destination, *other_sets)
190
+ dest_key = extract_key(destination)
191
+ keys = extract_keys(other_sets)
192
+ result = dbclient.sdiffstore(dest_key, dbkey, *keys)
193
+ update_expiration
194
+ result
92
195
  end
196
+ alias difference_store diffstore
93
197
 
94
198
  def pop
95
199
  dbclient.spop dbkey
@@ -115,6 +219,22 @@ module Familia
115
219
  end
116
220
  alias random sampleraw
117
221
 
222
+ private
223
+
224
+ # Extracts the database key from a set reference.
225
+ # @param set_ref [UnsortedSet, String] An UnsortedSet instance or raw key string
226
+ # @return [String] The database key
227
+ def extract_key(set_ref)
228
+ set_ref.respond_to?(:dbkey) ? set_ref.dbkey : set_ref.to_s
229
+ end
230
+
231
+ # Extracts database keys from an array of set references.
232
+ # @param set_refs [Array<UnsortedSet, String>] Array of UnsortedSet instances or raw keys
233
+ # @return [Array<String>] Array of database keys
234
+ def extract_keys(set_refs)
235
+ set_refs.flatten.map { |s| extract_key(s) }
236
+ end
237
+
118
238
  Familia::DataType.register self, :set
119
239
  Familia::DataType.register self, :unsorted_set
120
240
  end
@@ -301,7 +301,8 @@ module Familia
301
301
  # Build key pattern for SCAN
302
302
  # For instance-scoped indexes, we still scan all objects of indexed_class
303
303
  # (not scoped under parent), then filter by scope during processing
304
- pattern = "#{indexed_class.config_name}:*:object"
304
+ # Use centralized scan_pattern method for consistent key generation
305
+ pattern = indexed_class.scan_pattern
305
306
 
306
307
  Familia.info "[Rebuild] Starting via_scan for #{indexed_class.name}.#{field} (pattern: #{pattern})"
307
308
  Familia.warn "[Rebuild] Using SCAN fallback - consider adding instances collection for better performance"
@@ -51,9 +51,10 @@ module Familia
51
51
  # @return [String] Deterministic key for the through model
52
52
  #
53
53
  def build_key(target:, participant:, through_class:)
54
- "#{target.class.config_name}:#{target.objid}:" \
55
- "#{participant.class.config_name}:#{participant.objid}:" \
56
- "#{through_class.config_name}"
54
+ # Use prefix for Redis key construction (prefix may differ from config_name if explicitly set)
55
+ "#{target.class.prefix}:#{target.objid}:" \
56
+ "#{participant.class.prefix}:#{participant.objid}:" \
57
+ "#{through_class.prefix}"
57
58
  end
58
59
 
59
60
  # Find or create a through model instance
@@ -600,9 +600,8 @@ module Familia
600
600
  # @param collection_names [Array<String>, nil] Optional collection name filter
601
601
  # @return [Array<String>] Array of unique target instance IDs
602
602
  def participating_ids_for_target(target_class, collection_names = nil)
603
-
604
- # Use config_name to get the proper snake_case format (e.g., "project_team")
605
- target_prefix = "#{target_class.config_name}#{Familia.delim}"
603
+ # Use centralized key_prefix method for consistent key generation
604
+ target_prefix = target_class.key_prefix
606
605
  ids = Set.new
607
606
 
608
607
  participations.members.each do |key|
@@ -635,7 +634,8 @@ module Familia
635
634
  # @param collection_names [Array<String>, nil] Optional collection name filter
636
635
  # @return [Boolean] true if any matching participation exists
637
636
  def participating_in_target?(target_class, collection_names = nil)
638
- target_prefix = "#{target_class.config_name}#{Familia.delim}"
637
+ # Use centralized key_prefix method for consistent key generation
638
+ target_prefix = target_class.key_prefix
639
639
 
640
640
  participations.members.any? do |key|
641
641
  next false unless key.start_with?(target_prefix)
@@ -667,9 +667,9 @@ module Familia
667
667
  next unless target_class_config && target_id && collection_name_from_key
668
668
 
669
669
  # Find the matching participation configuration
670
- # Note: target_class_config from key is snake_case
670
+ # Note: target_class_config from key uses prefix (may differ from config_name)
671
671
  config = self.class.participation_relationships.find do |cfg|
672
- cfg.target_class.config_name == target_class_config &&
672
+ cfg.target_class.prefix.to_s == target_class_config &&
673
673
  cfg.collection_name.to_s == collection_name_from_key
674
674
  end
675
675
 
@@ -471,6 +471,35 @@ module Familia
471
471
  # at the class level are created without the global default 'object'
472
472
  # suffix. See DataType#dbkey "parent_class?" for more details.
473
473
  #
474
+
475
+ # Returns the key prefix for this class including the delimiter.
476
+ #
477
+ # Centralizes key prefix generation to prevent bugs from manual
478
+ # string interpolation across the codebase.
479
+ #
480
+ # @return [String] The prefix with delimiter (e.g., "customer:")
481
+ # @example
482
+ # User.key_prefix #=> "user:"
483
+ #
484
+ def key_prefix
485
+ "#{prefix}#{Familia.delim}"
486
+ end
487
+
488
+ # Returns the SCAN pattern for finding all objects of this class.
489
+ #
490
+ # Centralizes SCAN pattern generation to ensure consistency across
491
+ # rebuild strategies and other key enumeration operations.
492
+ #
493
+ # @param match_suffix [String] The suffix to match (default: class suffix)
494
+ # @return [String] The SCAN pattern (e.g., "customer:*:object")
495
+ # @example
496
+ # User.scan_pattern #=> "user:*:object"
497
+ # User.scan_pattern('active') #=> "user:*:active"
498
+ #
499
+ def scan_pattern(match_suffix = suffix)
500
+ "#{prefix}:*:#{match_suffix}"
501
+ end
502
+
474
503
  def dbkey(identifier, suffix = self.suffix)
475
504
  if identifier.to_s.empty?
476
505
  raise NoIdentifier, "#{self} requires non-empty identifier, got: #{identifier.inspect}"
@@ -4,5 +4,5 @@
4
4
 
5
5
  module Familia
6
6
  # Version information for the Familia
7
- VERSION = '2.0.0.pre25' unless defined?(Familia::VERSION)
7
+ VERSION = '2.0.0' unless defined?(Familia::VERSION)
8
8
  end
@@ -0,0 +1,418 @@
1
+ # try/features/relationships/prefix_vs_config_name_try.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ # Tests for the bug fix where reverse lookup methods now correctly use `prefix`
6
+ # instead of `config_name` for Redis key matching.
7
+ #
8
+ # Background: When a class declares an explicit `prefix` that differs from its
9
+ # computed `config_name`, reverse lookups (like *_instances, *_ids) would fail
10
+ # to find the correct keys because they were matching against `config_name`
11
+ # instead of `prefix`.
12
+ #
13
+ # Example: CustomDomain with `prefix :customdomain` (no underscore) vs
14
+ # `config_name` returning "custom_domain" (with underscore)
15
+
16
+ require_relative '../../support/helpers/test_helpers'
17
+
18
+ Familia.debug = false
19
+
20
+ # Scenario 1: Default prefix (no explicit prefix set)
21
+ # prefix should equal config_name.to_sym
22
+ class ::PrefixTestSimpleModel < Familia::Horreum
23
+ feature :relationships
24
+
25
+ identifier_field :model_id
26
+ field :model_id
27
+ field :name
28
+
29
+ sorted_set :participants
30
+ end
31
+
32
+ class ::PrefixTestSimpleParticipant < Familia::Horreum
33
+ feature :relationships
34
+
35
+ identifier_field :participant_id
36
+ field :participant_id
37
+ field :created_at
38
+
39
+ participates_in PrefixTestSimpleModel, :participants, score: :created_at
40
+ end
41
+
42
+ # Scenario 2: Explicit prefix matching config_name
43
+ # Should work exactly the same as default
44
+ class ::PrefixMatchingTeam < Familia::Horreum
45
+ feature :relationships
46
+ prefix :prefix_matching_team # Matches config_name
47
+
48
+ identifier_field :team_id
49
+ field :team_id
50
+ field :name
51
+
52
+ sorted_set :members
53
+ end
54
+
55
+ class ::PrefixMatchingMember < Familia::Horreum
56
+ feature :relationships
57
+
58
+ identifier_field :member_id
59
+ field :member_id
60
+ field :joined_at
61
+
62
+ participates_in PrefixMatchingTeam, :members, score: :joined_at
63
+ end
64
+
65
+ # Scenario 3: THE BUG CASE - Explicit prefix differs from config_name
66
+ # CustomDomain: config_name = "custom_domain", prefix = :customdomain
67
+ class ::PrefixMismatchedDomain < Familia::Horreum
68
+ feature :relationships
69
+ prefix :mismatcheddomain # No underscore - differs from config_name "prefix_mismatched_domain"
70
+
71
+ identifier_field :domain_id
72
+ field :domain_id
73
+ field :display_domain
74
+ field :created_at
75
+
76
+ participates_in PrefixTestSimpleModel, :participants, score: :created_at
77
+ end
78
+
79
+ # Scenario 4: Another mismatched prefix case - APIKey pattern
80
+ # APIKey: config_name = "api_key", prefix = :apikey
81
+ class ::PrefixTestAPIKey < Familia::Horreum
82
+ feature :relationships
83
+ prefix :ptapikey # Differs from config_name "prefix_test_a_p_i_key"
84
+
85
+ identifier_field :key_id
86
+ field :key_id
87
+ field :created_at
88
+
89
+ sorted_set :authorized_resources
90
+ end
91
+
92
+ class ::PrefixTestAPIResource < Familia::Horreum
93
+ feature :relationships
94
+
95
+ identifier_field :resource_id
96
+ field :resource_id
97
+ field :name
98
+ field :created_at
99
+
100
+ participates_in PrefixTestAPIKey, :authorized_resources, score: :created_at
101
+ end
102
+
103
+ # Scenario 5: Namespaced class with explicit prefix
104
+ # Tests that demodularization doesn't interfere
105
+ module ::PrefixTestNS
106
+ class CustomDomain < Familia::Horreum
107
+ feature :relationships
108
+ prefix :ptnscustomdomain # Explicit, doesn't match "custom_domain"
109
+
110
+ identifier_field :domain_id
111
+ field :domain_id
112
+ field :created_at
113
+
114
+ participates_in PrefixTestSimpleModel, :participants, score: :created_at
115
+ end
116
+ end
117
+
118
+ # Scenario 6: OAuth2Provider-like - complex snake_case edge case
119
+ # config_name would be "o_auth2provider"
120
+ class ::PrefixTestOAuthProvider < Familia::Horreum
121
+ feature :relationships
122
+ prefix :ptoauthprovider # Differs from config_name
123
+
124
+ identifier_field :provider_id
125
+ field :provider_id
126
+ field :name
127
+
128
+ sorted_set :connected_users
129
+ end
130
+
131
+ class ::PrefixTestOAuthUser < Familia::Horreum
132
+ feature :relationships
133
+
134
+ identifier_field :user_id
135
+ field :user_id
136
+ field :connected_at
137
+
138
+ participates_in PrefixTestOAuthProvider, :connected_users, score: :connected_at
139
+ end
140
+
141
+ # Setup test instances
142
+ @simple_model = PrefixTestSimpleModel.new(model_id: 'model_1', name: 'Test Model')
143
+ @simple_participant = PrefixTestSimpleParticipant.new(participant_id: 'part_1', created_at: Familia.now.to_i)
144
+
145
+ @matching_team = PrefixMatchingTeam.new(team_id: 'team_1', name: 'Engineering')
146
+ @matching_member = PrefixMatchingMember.new(member_id: 'member_1', joined_at: Familia.now.to_i)
147
+
148
+ @mismatched_domain = PrefixMismatchedDomain.new(
149
+ domain_id: 'cd_1',
150
+ display_domain: 'example.com',
151
+ created_at: Familia.now.to_i
152
+ )
153
+
154
+ @api_key = PrefixTestAPIKey.new(key_id: 'key_1', created_at: Familia.now.to_i)
155
+ @api_resource = PrefixTestAPIResource.new(
156
+ resource_id: 'res_1',
157
+ name: 'Protected Resource',
158
+ created_at: Familia.now.to_i
159
+ )
160
+
161
+ @ns_domain = PrefixTestNS::CustomDomain.new(domain_id: 'otcd_1', created_at: Familia.now.to_i)
162
+
163
+ @oauth_provider = PrefixTestOAuthProvider.new(provider_id: 'oauth_1', name: 'Google')
164
+ @oauth_user = PrefixTestOAuthUser.new(user_id: 'ouser_1', connected_at: Familia.now.to_i)
165
+
166
+ # Scenario 1: Default prefix (no explicit prefix set)
167
+
168
+ ## PrefixTestSimpleModel prefix equals config_name as symbol (default behavior)
169
+ PrefixTestSimpleModel.prefix
170
+ #=> :prefix_test_simple_model
171
+
172
+ ## PrefixTestSimpleModel config_name is snake_case string
173
+ PrefixTestSimpleModel.config_name
174
+ #=> "prefix_test_simple_model"
175
+
176
+ ## Default prefix matches config_name when converted
177
+ PrefixTestSimpleModel.prefix.to_s == PrefixTestSimpleModel.config_name
178
+ #=> true
179
+
180
+ ## PrefixTestSimpleParticipant also has default prefix
181
+ PrefixTestSimpleParticipant.prefix
182
+ #=> :prefix_test_simple_participant
183
+
184
+ # Scenario 2: Explicit prefix matching config_name
185
+
186
+ ## PrefixMatchingTeam has explicit prefix that matches config_name
187
+ PrefixMatchingTeam.prefix
188
+ #=> :prefix_matching_team
189
+
190
+ ## PrefixMatchingTeam config_name matches prefix
191
+ PrefixMatchingTeam.config_name
192
+ #=> "prefix_matching_team"
193
+
194
+ ## Matching prefix and config_name work the same
195
+ PrefixMatchingTeam.prefix.to_s == PrefixMatchingTeam.config_name
196
+ #=> true
197
+
198
+ # Scenario 3: THE BUG CASE - Explicit prefix differs from config_name
199
+
200
+ ## PrefixMismatchedDomain has explicit prefix without underscore
201
+ PrefixMismatchedDomain.prefix
202
+ #=> :mismatcheddomain
203
+
204
+ ## PrefixMismatchedDomain config_name has underscores (snake_case)
205
+ PrefixMismatchedDomain.config_name
206
+ #=> "prefix_mismatched_domain"
207
+
208
+ ## PrefixMismatchedDomain prefix differs from config_name
209
+ PrefixMismatchedDomain.prefix.to_s == PrefixMismatchedDomain.config_name
210
+ #=> false
211
+
212
+ # Scenario 4: PrefixTestAPIKey prefix edge case
213
+
214
+ ## PrefixTestAPIKey has explicit prefix
215
+ PrefixTestAPIKey.prefix
216
+ #=> :ptapikey
217
+
218
+ ## PrefixTestAPIKey config_name follows snake_case convention
219
+ PrefixTestAPIKey.config_name
220
+ #=> "prefix_test_api_key"
221
+
222
+ ## PrefixTestAPIKey prefix differs from config_name
223
+ PrefixTestAPIKey.prefix.to_s == PrefixTestAPIKey.config_name
224
+ #=> false
225
+
226
+ # Scenario 5: Namespaced class verification
227
+
228
+ ## Namespaced PrefixTestNS::CustomDomain has explicit prefix
229
+ PrefixTestNS::CustomDomain.prefix
230
+ #=> :ptnscustomdomain
231
+
232
+ ## Namespaced config_name only uses demodularized name
233
+ PrefixTestNS::CustomDomain.config_name
234
+ #=> "custom_domain"
235
+
236
+ ## Namespaced prefix is distinct from config_name
237
+ PrefixTestNS::CustomDomain.prefix.to_s == PrefixTestNS::CustomDomain.config_name
238
+ #=> false
239
+
240
+ # Scenario 6: PrefixTestOAuthProvider edge case
241
+
242
+ ## PrefixTestOAuthProvider has explicit prefix
243
+ PrefixTestOAuthProvider.prefix
244
+ #=> :ptoauthprovider
245
+
246
+ ## PrefixTestOAuthProvider snake_case config_name
247
+ PrefixTestOAuthProvider.config_name
248
+ #=> "prefix_test_o_auth_provider"
249
+
250
+ ## PrefixTestOAuthProvider prefix differs from config_name
251
+ PrefixTestOAuthProvider.prefix.to_s == PrefixTestOAuthProvider.config_name
252
+ #=> false
253
+
254
+ # Functional tests: Reverse lookups with mismatched prefix/config_name
255
+
256
+ ## Save all test instances for functional tests
257
+ [@simple_model, @simple_participant, @matching_team, @matching_member,
258
+ @mismatched_domain, @api_key, @api_resource, @ns_domain,
259
+ @oauth_provider, @oauth_user].each(&:save)
260
+ true
261
+ #=> true
262
+
263
+ ## Add PrefixTestSimpleParticipant to PrefixTestSimpleModel (default prefix case)
264
+ @simple_participant.add_to_prefix_test_simple_model_participants(@simple_model)
265
+ @simple_participant.in_prefix_test_simple_model_participants?(@simple_model)
266
+ #=> true
267
+
268
+ ## PrefixTestSimpleParticipant reverse lookup finds PrefixTestSimpleModel instances
269
+ @simple_participant.prefix_test_simple_model_instances.map(&:identifier)
270
+ #=> ["model_1"]
271
+
272
+ ## Add PrefixMatchingMember to PrefixMatchingTeam (matching prefix case)
273
+ @matching_member.add_to_prefix_matching_team_members(@matching_team)
274
+ @matching_member.in_prefix_matching_team_members?(@matching_team)
275
+ #=> true
276
+
277
+ ## PrefixMatchingMember reverse lookup finds PrefixMatchingTeam instances
278
+ @matching_member.prefix_matching_team_instances.map(&:identifier)
279
+ #=> ["team_1"]
280
+
281
+ ## Add PrefixMismatchedDomain to PrefixTestSimpleModel (THE BUG CASE - mismatched prefix)
282
+ @mismatched_domain.add_to_prefix_test_simple_model_participants(@simple_model)
283
+ @mismatched_domain.in_prefix_test_simple_model_participants?(@simple_model)
284
+ #=> true
285
+
286
+ ## PrefixMismatchedDomain reverse lookup must find PrefixTestSimpleModel via prefix not config_name
287
+ @mismatched_domain.prefix_test_simple_model_instances.map(&:identifier)
288
+ #=> ["model_1"]
289
+
290
+ ## PrefixMismatchedDomain ids method also works
291
+ @mismatched_domain.prefix_test_simple_model_ids
292
+ #=> ["model_1"]
293
+
294
+ ## PrefixMismatchedDomain boolean check works
295
+ @mismatched_domain.prefix_test_simple_model?
296
+ #=> true
297
+
298
+ ## PrefixMismatchedDomain count works
299
+ @mismatched_domain.prefix_test_simple_model_count
300
+ #=> 1
301
+
302
+ ## Add PrefixTestAPIResource to PrefixTestAPIKey (mismatched prefix case)
303
+ @api_resource.add_to_prefix_test_api_key_authorized_resources(@api_key)
304
+ @api_resource.in_prefix_test_api_key_authorized_resources?(@api_key)
305
+ #=> true
306
+
307
+ ## PrefixTestAPIResource reverse lookup finds PrefixTestAPIKey via prefix not config_name
308
+ @api_resource.prefix_test_api_key_instances.map(&:identifier)
309
+ #=> ["key_1"]
310
+
311
+ ## Add PrefixTestNS::CustomDomain to PrefixTestSimpleModel (namespaced with mismatched prefix)
312
+ @ns_domain.add_to_prefix_test_simple_model_participants(@simple_model)
313
+ @ns_domain.in_prefix_test_simple_model_participants?(@simple_model)
314
+ #=> true
315
+
316
+ ## PrefixTestNS::CustomDomain reverse lookup finds PrefixTestSimpleModel
317
+ @ns_domain.prefix_test_simple_model_instances.map(&:identifier)
318
+ #=> ["model_1"]
319
+
320
+ ## Add PrefixTestOAuthUser to PrefixTestOAuthProvider (mismatched prefix case)
321
+ @oauth_user.add_to_prefix_test_o_auth_provider_connected_users(@oauth_provider)
322
+ @oauth_user.in_prefix_test_o_auth_provider_connected_users?(@oauth_provider)
323
+ #=> true
324
+
325
+ ## PrefixTestOAuthUser reverse lookup finds PrefixTestOAuthProvider via prefix
326
+ @oauth_user.prefix_test_o_auth_provider_instances.map(&:identifier)
327
+ #=> ["oauth_1"]
328
+
329
+ # Verify dbkey format uses prefix, not config_name
330
+
331
+ ## PrefixTestSimpleModel dbkey uses default prefix (which equals config_name)
332
+ @simple_model.dbkey.start_with?("prefix_test_simple_model:")
333
+ #=> true
334
+
335
+ ## PrefixMismatchedDomain dbkey uses explicit prefix
336
+ @mismatched_domain.dbkey.start_with?("mismatcheddomain:")
337
+ #=> true
338
+
339
+ ## PrefixTestAPIKey dbkey uses explicit prefix
340
+ @api_key.dbkey.start_with?("ptapikey:")
341
+ #=> true
342
+
343
+ ## PrefixTestNS::CustomDomain dbkey uses explicit prefix
344
+ @ns_domain.dbkey.start_with?("ptnscustomdomain:")
345
+ #=> true
346
+
347
+ ## PrefixTestOAuthProvider dbkey uses explicit prefix
348
+ @oauth_provider.dbkey.start_with?("ptoauthprovider:")
349
+ #=> true
350
+
351
+ # Verify participation tracking uses prefix in keys
352
+
353
+ ## PrefixTestSimpleParticipant participations include key with prefix_test_simple_model prefix
354
+ @simple_participant_keys = @simple_participant.participations.members
355
+ @simple_participant_keys.any? { |k| k.start_with?("prefix_test_simple_model:") }
356
+ #=> true
357
+
358
+ ## PrefixMismatchedDomain participations include key with prefix_test_simple_model prefix
359
+ @mismatched_domain_keys = @mismatched_domain.participations.members
360
+ @mismatched_domain_keys.any? { |k| k.start_with?("prefix_test_simple_model:") }
361
+ #=> true
362
+
363
+ ## PrefixTestAPIResource participations include key with ptapikey prefix (not prefix_test_api_key)
364
+ @api_resource_keys = @api_resource.participations.members
365
+ @api_resource_keys.any? { |k| k.start_with?("ptapikey:") }
366
+ #=> true
367
+
368
+ ## PrefixTestOAuthUser participations include key with ptoauthprovider prefix
369
+ @oauth_user_keys = @oauth_user.participations.members
370
+ @oauth_user_keys.any? { |k| k.start_with?("ptoauthprovider:") }
371
+ #=> true
372
+
373
+ # Bug fix verification: Keys use prefix, NOT config_name
374
+ # If the old bug existed, these tests would fail because config_name would be
375
+ # used for matching but keys are stored with prefix
376
+
377
+ ## PrefixTestAPIResource keys do NOT contain config_name pattern (prefix_test_api_key)
378
+ # This verifies that the key is "ptapikey:..." not "prefix_test_api_key:..."
379
+ @api_resource_keys.none? { |k| k.start_with?("prefix_test_api_key:") }
380
+ #=> true
381
+
382
+ ## PrefixTestOAuthUser keys do NOT contain config_name pattern
383
+ # This verifies that the key is "ptoauthprovider:..." not "prefix_test_o_auth_provider:..."
384
+ @oauth_user_keys.none? { |k| k.start_with?("prefix_test_o_auth_provider:") }
385
+ #=> true
386
+
387
+ ## PrefixTestAPIKey prefix is different from config_name
388
+ # This is the critical condition for the bug: prefix != config_name
389
+ PrefixTestAPIKey.prefix.to_s != PrefixTestAPIKey.config_name
390
+ #=> true
391
+
392
+ ## PrefixTestOAuthProvider prefix is different from config_name
393
+ PrefixTestOAuthProvider.prefix.to_s != PrefixTestOAuthProvider.config_name
394
+ #=> true
395
+
396
+ ## participating_ids_for_target finds IDs when prefix differs from config_name
397
+ # This is the core bug fix test - the method must use prefix, not config_name
398
+ @api_resource.participating_ids_for_target(PrefixTestAPIKey).include?("key_1")
399
+ #=> true
400
+
401
+ ## participating_in_target? returns true when prefix differs from config_name
402
+ @api_resource.participating_in_target?(PrefixTestAPIKey)
403
+ #=> true
404
+
405
+ ## PrefixTestOAuthUser also finds its target despite prefix != config_name
406
+ @oauth_user.participating_ids_for_target(PrefixTestOAuthProvider).include?("oauth_1")
407
+ #=> true
408
+
409
+ ## PrefixTestOAuthUser participating_in_target? also works
410
+ @oauth_user.participating_in_target?(PrefixTestOAuthProvider)
411
+ #=> true
412
+
413
+ ## Cleanup test data completes without errors
414
+ [@simple_model, @simple_participant, @matching_team, @matching_member,
415
+ @mismatched_domain, @api_key, @api_resource, @ns_domain,
416
+ @oauth_provider, @oauth_user].compact.each { |obj| obj.destroy if obj.respond_to?(:destroy) && obj.exists? }
417
+ true
418
+ #=> true