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
@@ -387,3 +387,137 @@ found_no_prefix&.id
387
387
  findable_custom.destroy! rescue nil
388
388
  findable_hyphen.destroy! rescue nil
389
389
  findable_no_prefix.destroy! rescue nil
390
+
391
+ # ========================================
392
+ # extid? Method Test Coverage
393
+ # ========================================
394
+
395
+ ## extid? returns false for nil
396
+ ExternalIdTest.extid?(nil)
397
+ #=> false
398
+
399
+ ## extid? returns false for empty string
400
+ ExternalIdTest.extid?('')
401
+ #=> false
402
+
403
+ ## extid? returns false for whitespace-only string
404
+ ExternalIdTest.extid?(' ')
405
+ #=> false
406
+
407
+ ## extid? returns true for valid extid with default format
408
+ # Valid format: ext_ prefix + exactly 25 alphanumeric characters
409
+ ExternalIdTest.extid?('ext_0123456789abcdefghijklmno')
410
+ #=> true
411
+
412
+ ## extid? returns true for valid extid with uppercase characters
413
+ # The pattern allows [0-9a-zA-Z] for the ID part
414
+ ExternalIdTest.extid?('ext_0123456789ABCDEFGHIJKLMNO')
415
+ #=> true
416
+
417
+ ## extid? returns true for valid extid with mixed case
418
+ ExternalIdTest.extid?('ext_0123456789AbCdEfGhIjKlMnO')
419
+ #=> true
420
+
421
+ ## extid? returns true for generated extid
422
+ obj = ExternalIdTest.new
423
+ ExternalIdTest.extid?(obj.extid)
424
+ #=> true
425
+
426
+ ## extid? returns false for wrong prefix
427
+ ExternalIdTest.extid?('xxx_0123456789abcdefghijklmno')
428
+ #=> false
429
+
430
+ ## extid? returns false for missing prefix
431
+ ExternalIdTest.extid?('0123456789abcdefghijklmno')
432
+ #=> false
433
+
434
+ ## extid? accepts ID part with 24 chars (within 20-32 range)
435
+ ExternalIdTest.extid?('ext_0123456789abcdefghijklmn')
436
+ #=> true
437
+
438
+ ## extid? accepts ID part with 26 chars (within 20-32 range)
439
+ ExternalIdTest.extid?('ext_0123456789abcdefghijklmnop')
440
+ #=> true
441
+
442
+ ## extid? returns false for ID part too short (19 chars, below minimum)
443
+ ExternalIdTest.extid?('ext_0123456789abcdefghi')
444
+ #=> false
445
+
446
+ ## extid? returns false for ID part too long (33 chars, above maximum)
447
+ ExternalIdTest.extid?('ext_0123456789abcdefghijklmnopqrstuvw')
448
+ #=> false
449
+
450
+ ## extid? returns false for invalid characters in ID (underscore)
451
+ ExternalIdTest.extid?('ext_0123456789abcdefghij_lmno')
452
+ #=> false
453
+
454
+ ## extid? returns false for invalid characters in ID (hyphen)
455
+ ExternalIdTest.extid?('ext_0123456789abcdefghij-lmno')
456
+ #=> false
457
+
458
+ ## extid? returns false for invalid characters in ID (special chars)
459
+ ExternalIdTest.extid?('ext_0123456789abcdefghij!@#$%')
460
+ #=> false
461
+
462
+ ## extid? with custom prefix format returns true for valid extid
463
+ CustomPrefixTest.extid?('cust_0123456789abcdefghijklmno')
464
+ #=> true
465
+
466
+ ## extid? with custom prefix format returns false for default prefix
467
+ CustomPrefixTest.extid?('ext_0123456789abcdefghijklmno')
468
+ #=> false
469
+
470
+ ## extid? with custom prefix format returns true for generated extid
471
+ custom_obj = CustomPrefixTest.new
472
+ CustomPrefixTest.extid?(custom_obj.extid)
473
+ #=> true
474
+
475
+ ## extid? with hyphen format returns true for valid extid
476
+ CustomFormatTest.extid?('ext-0123456789abcdefghijklmno')
477
+ #=> true
478
+
479
+ ## extid? with hyphen format returns false for underscore format
480
+ CustomFormatTest.extid?('ext_0123456789abcdefghijklmno')
481
+ #=> false
482
+
483
+ ## extid? with hyphen format returns true for generated extid
484
+ hyphen_obj = CustomFormatTest.new
485
+ CustomFormatTest.extid?(hyphen_obj.extid)
486
+ #=> true
487
+
488
+ ## extid? with no-prefix format returns true for valid extid
489
+ NoPrefixFormatTest.extid?('api/0123456789abcdefghijklmno')
490
+ #=> true
491
+
492
+ ## extid? with no-prefix format returns false for default format
493
+ NoPrefixFormatTest.extid?('ext_0123456789abcdefghijklmno')
494
+ #=> false
495
+
496
+ ## extid? with no-prefix format returns true for generated extid
497
+ no_prefix_obj = NoPrefixFormatTest.new
498
+ NoPrefixFormatTest.extid?(no_prefix_obj.extid)
499
+ #=> true
500
+
501
+ ## extid? returns false for partial match at start
502
+ ExternalIdTest.extid?('ext_0123456789abcdefghijklmno_extra')
503
+ #=> false
504
+
505
+ ## extid? returns false for partial match with leading chars
506
+ ExternalIdTest.extid?('prefix_ext_0123456789abcdefghijklmno')
507
+ #=> false
508
+
509
+ ## extid? returns false for spaces in ID
510
+ ExternalIdTest.extid?('ext_0123456789 bcdefghijklmno')
511
+ #=> false
512
+
513
+ ## extid? returns false for just the prefix
514
+ ExternalIdTest.extid?('ext_')
515
+ #=> false
516
+
517
+ ## extid? with Symbol input (symbols support =~ matching)
518
+ ExternalIdTest.extid?(:ext_0123456789abcdefghijklmno)
519
+ #=> true
520
+
521
+ ## extid? with Symbol input returns false for invalid format
522
+ ExternalIdTest.extid?(:invalid_symbol)
523
+ #=> false
@@ -201,3 +201,141 @@ race_obj.save # Save so find_by_objid can locate
201
201
  found = BasicObjectTest.find_by_objid(generated_objid)
202
202
  found && found.id == "race_test_123"
203
203
  #=> true
204
+
205
+ # ========================================
206
+ # objid? Method Test Coverage
207
+ # ========================================
208
+
209
+ ## objid? returns false for nil
210
+ BasicObjectTest.objid?(nil)
211
+ #=> false
212
+
213
+ ## objid? returns false for empty string
214
+ BasicObjectTest.objid?('')
215
+ #=> false
216
+
217
+ ## objid? returns false for whitespace-only string
218
+ BasicObjectTest.objid?(' ')
219
+ #=> false
220
+
221
+ ## objid? returns true for valid UUID v7 format
222
+ # UUID v7 format: xxxxxxxx-xxxx-7xxx-xxxx-xxxxxxxxxxxx (version char is '7')
223
+ BasicObjectTest.objid?('01234567-89ab-7def-89ab-0123456789ab')
224
+ #=> true
225
+
226
+ ## objid? returns true for generated UUID v7 objid
227
+ obj = BasicObjectTest.new
228
+ BasicObjectTest.objid?(obj.objid)
229
+ #=> true
230
+
231
+ ## objid? returns false for UUID v4 in UUID v7 class
232
+ # Version char is '4' instead of '7'
233
+ BasicObjectTest.objid?('01234567-89ab-4def-89ab-0123456789ab')
234
+ #=> false
235
+
236
+ ## objid? returns false for wrong version char
237
+ BasicObjectTest.objid?('01234567-89ab-5def-89ab-0123456789ab')
238
+ #=> false
239
+
240
+ ## objid? returns false for missing hyphens
241
+ BasicObjectTest.objid?('0123456789ab7def89ab0123456789ab')
242
+ #=> false
243
+
244
+ ## objid? returns false for wrong length (too short)
245
+ BasicObjectTest.objid?('01234567-89ab-7def-89ab-012345678')
246
+ #=> false
247
+
248
+ ## objid? returns false for wrong length (too long)
249
+ BasicObjectTest.objid?('01234567-89ab-7def-89ab-0123456789abcd')
250
+ #=> false
251
+
252
+ ## objid? returns false for hyphens in wrong positions
253
+ BasicObjectTest.objid?('0123456-789ab-7def-89ab-0123456789ab')
254
+ #=> false
255
+
256
+ ## objid? returns false for UUID with non-hex characters
257
+ # Validates that all characters are valid hexadecimal digits
258
+ BasicObjectTest.objid?('gggggggg-gggg-7ggg-gggg-gggggggggggg')
259
+ #=> false
260
+
261
+ ## objid? with UUID v4 generator returns true for valid v4 format
262
+ UuidV4Test.objid?('01234567-89ab-4def-89ab-0123456789ab')
263
+ #=> true
264
+
265
+ ## objid? with UUID v4 generator returns true for generated objid
266
+ v4_obj = UuidV4Test.new
267
+ UuidV4Test.objid?(v4_obj.objid)
268
+ #=> true
269
+
270
+ ## objid? with UUID v4 generator returns false for v7 format
271
+ UuidV4Test.objid?('01234567-89ab-7def-89ab-0123456789ab')
272
+ #=> false
273
+
274
+ ## objid? with hex generator returns true for valid hex string
275
+ HexTest.objid?('0123456789abcdef')
276
+ #=> true
277
+
278
+ ## objid? with hex generator returns true for generated hex objid
279
+ hex_obj = HexTest.new
280
+ HexTest.objid?(hex_obj.objid)
281
+ #=> true
282
+
283
+ ## objid? with hex generator returns true for uppercase hex
284
+ HexTest.objid?('0123456789ABCDEF')
285
+ #=> true
286
+
287
+ ## objid? with hex generator returns true for mixed case hex
288
+ HexTest.objid?('0123456789AbCdEf')
289
+ #=> true
290
+
291
+ ## objid? with hex generator returns false for non-hex chars
292
+ HexTest.objid?('0123456789ghijkl')
293
+ #=> false
294
+
295
+ ## objid? with hex generator returns false for UUID format
296
+ HexTest.objid?('01234567-89ab-7def-89ab-0123456789ab')
297
+ #=> false
298
+
299
+ ## objid? with hex generator returns true for short hex
300
+ HexTest.objid?('abc')
301
+ #=> true
302
+
303
+ ## objid? with hex generator returns true for long hex
304
+ HexTest.objid?('0123456789abcdef0123456789abcdef')
305
+ #=> true
306
+
307
+ ## objid? with custom proc generator returns false (unsupported)
308
+ CustomProcTest.objid?('custom_12345678')
309
+ #=> false
310
+
311
+ ## objid? with custom proc generator returns false for any input
312
+ CustomProcTest.objid?('anything')
313
+ #=> false
314
+
315
+ ## objid? UUID validation checks position 8 for hyphen
316
+ BasicObjectTest.objid?('01234567X89ab-7def-89ab-0123456789ab')
317
+ #=> false
318
+
319
+ ## objid? UUID validation checks position 13 for hyphen
320
+ BasicObjectTest.objid?('01234567-89abX7def-89ab-0123456789ab')
321
+ #=> false
322
+
323
+ ## objid? UUID validation checks position 18 for hyphen
324
+ BasicObjectTest.objid?('01234567-89ab-7defX89ab-0123456789ab')
325
+ #=> false
326
+
327
+ ## objid? UUID validation checks position 23 for hyphen
328
+ BasicObjectTest.objid?('01234567-89ab-7def-89abX0123456789ab')
329
+ #=> false
330
+
331
+ ## objid? returns false for partial UUID match
332
+ BasicObjectTest.objid?('01234567-89ab-7def')
333
+ #=> false
334
+
335
+ ## objid? with Symbol input for UUID v7 (symbols support string comparison)
336
+ BasicObjectTest.objid?(:'01234567-89ab-7def-89ab-0123456789ab')
337
+ #=> true
338
+
339
+ ## objid? with Symbol input returns false for invalid format
340
+ BasicObjectTest.objid?(:invalid_objid)
341
+ #=> false
@@ -553,6 +553,9 @@ found3 = @company.find_by_badge_number("BADGE003")
553
553
  # The architecture prevents this via factory pattern, but guard provides explicit protection
554
554
  begin
555
555
  # Simulate calling rebuild_via_participation with multi-index cardinality
556
+ # Note: dept_index is a multi-index, so we need to use dept_index_for to get a DataType instance
557
+ # But for this test, we just need any HashKey-like object to pass for serialization
558
+ index_hashkey = @company.badge_index # Use a valid HashKey index
556
559
  Familia::Features::Relationships::Indexing::RebuildStrategies.rebuild_via_participation(
557
560
  @company,
558
561
  RebuildTestEmployee,
@@ -560,6 +563,7 @@ begin
560
563
  :add_to_rebuild_test_company_dept_index,
561
564
  @company.employees,
562
565
  :multi, # Wrong cardinality!
566
+ index_hashkey, # Added required parameter
563
567
  batch_size: 100
564
568
  )
565
569
  "should have raised"
@@ -571,6 +575,7 @@ end
571
575
  ## Guard accepts correct cardinality (:unique)
572
576
  begin
573
577
  index_config = RebuildTestEmployee.indexing_relationships.find { |r| r.index_name == :badge_index }
578
+ index_hashkey = @company.badge_index # Get the index HashKey for serialization
574
579
  Familia::Features::Relationships::Indexing::RebuildStrategies.rebuild_via_participation(
575
580
  @company,
576
581
  RebuildTestEmployee,
@@ -578,6 +583,7 @@ begin
578
583
  :add_to_rebuild_test_company_badge_index,
579
584
  @company.employees,
580
585
  :unique, # Correct cardinality
586
+ index_hashkey, # Added required parameter
581
587
  batch_size: 100
582
588
  )
583
589
  "no error"
@@ -30,7 +30,7 @@ RSpec.describe 'participation_commands_verification_try' do
30
30
  field :display_domain
31
31
  field :created_at
32
32
  participates_in ReverseIndexCustomer, :domains, score: :created_at
33
- participates_in ReverseIndexCustomer, :preferred_domains, bidirectional: true
33
+ participates_in ReverseIndexCustomer, :preferred_domains, generate_participant_methods: true
34
34
  class_participates_in :all_domains, score: :created_at
35
35
  end
36
36
  end
@@ -49,7 +49,7 @@ class ReverseIndexDomain < Familia::Horreum
49
49
  field :created_at
50
50
 
51
51
  participates_in ReverseIndexCustomer, :domains, score: :created_at
52
- participates_in ReverseIndexCustomer, :preferred_domains, bidirectional: true
52
+ participates_in ReverseIndexCustomer, :preferred_domains, generate_participant_methods: true
53
53
  class_participates_in :all_domains, score: :created_at
54
54
  end
55
55
 
@@ -0,0 +1,133 @@
1
+ # try/features/relationships/participation_method_prefix_try.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative '../../../lib/familia'
6
+
7
+ # Test the method_prefix: option for participates_in
8
+ # This allows shorter reverse method names for namespaced classes
9
+
10
+ # Simulate a namespaced target class
11
+ module ::Admin
12
+ class MethodPrefixTeam < Familia::Horreum
13
+ feature :relationships
14
+
15
+ identifier_field :team_id
16
+ field :team_id
17
+ field :name
18
+
19
+ sorted_set :members
20
+ sorted_set :admins
21
+
22
+ def init
23
+ @team_id ||= "team_#{SecureRandom.hex(4)}"
24
+ end
25
+ end
26
+ end
27
+
28
+ # Test class using method_prefix: option
29
+ class ::MethodPrefixUser < Familia::Horreum
30
+ feature :relationships
31
+
32
+ identifier_field :user_id
33
+ field :user_id
34
+ field :email
35
+
36
+ # Use method_prefix to get shorter method names
37
+ # Instead of admin_method_prefix_team_instances, we get team_instances
38
+ participates_in Admin::MethodPrefixTeam, :members, method_prefix: :team
39
+
40
+ def init
41
+ @user_id ||= "user_#{SecureRandom.hex(4)}"
42
+ end
43
+ end
44
+
45
+ # Test class using as: which should take precedence over method_prefix:
46
+ class ::MethodPrefixPriorityUser < Familia::Horreum
47
+ feature :relationships
48
+
49
+ identifier_field :user_id
50
+ field :user_id
51
+ field :email
52
+
53
+ # as: takes precedence over method_prefix:
54
+ participates_in Admin::MethodPrefixTeam, :admins, method_prefix: :team, as: :my_teams
55
+
56
+ def init
57
+ @user_id ||= "user_#{SecureRandom.hex(4)}"
58
+ end
59
+ end
60
+
61
+ # Test class without method_prefix (default behavior)
62
+ class ::MethodPrefixDefaultUser < Familia::Horreum
63
+ feature :relationships
64
+
65
+ identifier_field :user_id
66
+ field :user_id
67
+ field :email
68
+
69
+ # Default: uses config_name (method_prefix_team - demodularized, no namespace)
70
+ participates_in Admin::MethodPrefixTeam, :members
71
+
72
+ def init
73
+ @user_id ||= "user_#{SecureRandom.hex(4)}"
74
+ end
75
+ end
76
+
77
+ @user = MethodPrefixUser.new(email: 'alice@example.com')
78
+ @priority_user = MethodPrefixPriorityUser.new(email: 'bob@example.com')
79
+ @default_user = MethodPrefixDefaultUser.new(email: 'charlie@example.com')
80
+ @team = Admin::MethodPrefixTeam.new(name: 'Engineering')
81
+
82
+ ## method_prefix: generates shortened method names
83
+ # The method_prefix: :team option should generate team_* methods
84
+ @user.respond_to?(:team_instances)
85
+ #=> true
86
+
87
+ ## method_prefix: generates team_ids method
88
+ @user.respond_to?(:team_ids)
89
+ #=> true
90
+
91
+ ## method_prefix: generates team? method
92
+ @user.respond_to?(:team?)
93
+ #=> true
94
+
95
+ ## method_prefix: generates team_count method
96
+ @user.respond_to?(:team_count)
97
+ #=> true
98
+
99
+ ## method_prefix: does NOT generate verbose config_name methods
100
+ # The verbose admin_method_prefix_team_* methods should not be created
101
+ @user.respond_to?(:admin_method_prefix_team_instances)
102
+ #=> false
103
+
104
+ ## as: takes precedence over method_prefix:
105
+ # When both as: and method_prefix: are provided, as: wins
106
+ @priority_user.respond_to?(:my_teams_instances)
107
+ #=> true
108
+
109
+ ## as: takes precedence - method_prefix method NOT generated
110
+ @priority_user.respond_to?(:team_instances)
111
+ #=> false
112
+
113
+ ## default behavior preserved when no method_prefix
114
+ # Without method_prefix:, the config_name is used (method_prefix_team)
115
+ # Note: config_name uses the class name without namespace
116
+ @default_user.respond_to?(:method_prefix_team_instances)
117
+ #=> true
118
+
119
+ ## default behavior - no custom shortened method names
120
+ # The team_instances method is not generated unless method_prefix: :team is used
121
+ @default_user.respond_to?(:team_instances)
122
+ #=> false
123
+
124
+ ## ParticipationRelationship stores method_prefix
125
+ # The relationship metadata should include the method_prefix
126
+ rel = MethodPrefixUser.participation_relationships.first
127
+ rel.method_prefix
128
+ #=> :team
129
+
130
+ ## ParticipationRelationship method_prefix is nil for default
131
+ rel_default = MethodPrefixDefaultUser.participation_relationships.first
132
+ rel_default.method_prefix
133
+ #=> nil
@@ -28,7 +28,7 @@ class ReverseIndexDomain < Familia::Horreum
28
28
  field :created_at
29
29
 
30
30
  participates_in ReverseIndexCustomer, :domains, score: :created_at
31
- participates_in ReverseIndexCustomer, :preferred_domains, bidirectional: true
31
+ participates_in ReverseIndexCustomer, :preferred_domains, generate_participant_methods: true
32
32
  class_participates_in :all_domains, score: :created_at
33
33
  end
34
34
 
@@ -1,10 +1,10 @@
1
- # try/features/relationships/participation_bidirectional_try.rb
1
+ # try/features/relationships/participation_reverse_methods_try.rb
2
2
  #
3
3
  # frozen_string_literal: true
4
4
 
5
5
  require_relative '../../../lib/familia'
6
6
 
7
- # Test demonstrating true bidirectional participation functionality
7
+ # Test demonstrating reverse collection methods (generate_participant_methods: true)
8
8
  # This shows the improvement from asymmetric to symmetric relationship access
9
9
 
10
10
  # Setup test classes
@@ -51,8 +51,8 @@ class ::ProjectUser < Familia::Horreum
51
51
  field :name
52
52
  field :role
53
53
 
54
- # Define bidirectional participation relationships
55
- # These will auto-generate reverse collection methods with _instances suffix
54
+ # Define participation relationships with reverse collection methods
55
+ # These auto-generate methods like user.project_team_instances (when generate_participant_methods: true)
56
56
  participates_in ProjectTeam, :members # Generates: user.project_team_instances
57
57
  participates_in ProjectTeam, :admins # Also adds to user.project_team_instances (union)
58
58
  participates_in ProjectOrganization, :employees # Generates: user.project_organization_instances
@@ -197,13 +197,13 @@ contracting_orgs_instances.map(&:name)
197
197
  @team1.admins.to_a
198
198
  #=> [@user1.identifier]
199
199
 
200
- ## Test bidirectional consistency - forward direction
200
+ ## Test symmetric consistency - forward direction
201
201
  team1_member_ids = @team1.members.to_a
202
202
  team1_members = ProjectUser.load_multi(team1_member_ids).compact
203
203
  team1_members.map(&:name)
204
204
  #=> ["Alice"]
205
205
 
206
- ## Test bidirectional consistency - reverse direction
206
+ ## Test symmetric consistency - reverse direction
207
207
  user1_teams = @user1.project_team_instances
208
208
  user1_team_ids = user1_teams.map(&:identifier)
209
209
  user1_team_ids.include?(@team1.identifier)
@@ -0,0 +1,173 @@
1
+ # try/features/relationships/participation_through_try.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ # Test through model support for participates_in relationships
6
+
7
+ require_relative '../../support/helpers/test_helpers'
8
+
9
+ # Define through model FIRST so it can be resolved
10
+ class ::ThroughTestMembership < Familia::Horreum
11
+ feature :object_identifier
12
+ feature :relationships
13
+ identifier_field :objid
14
+ field :through_test_customer_objid
15
+ field :through_test_domain_objid
16
+ field :role
17
+ field :updated_at
18
+ end
19
+
20
+ class ::ThroughTestCustomer < Familia::Horreum
21
+ feature :object_identifier
22
+ feature :relationships
23
+ identifier_field :custid
24
+ field :custid
25
+ field :name
26
+ end
27
+
28
+ class ::ThroughTestDomain < Familia::Horreum
29
+ feature :object_identifier
30
+ feature :relationships
31
+ identifier_field :domain_id
32
+ field :domain_id
33
+ field :display_domain
34
+ field :created_at
35
+ participates_in ThroughTestCustomer, :domains, score: :created_at, through: :ThroughTestMembership
36
+ end
37
+
38
+ # Define backward compat classes in setup
39
+ class ::BackwardCompatCustomer < Familia::Horreum
40
+ feature :relationships
41
+ identifier_field :custid
42
+ field :custid
43
+ end
44
+
45
+ class ::BackwardCompatDomain < Familia::Horreum
46
+ feature :relationships
47
+ identifier_field :domain_id
48
+ field :domain_id
49
+ field :created_at
50
+ participates_in BackwardCompatCustomer, :domains, score: :created_at
51
+ end
52
+
53
+ # Setup instance variables
54
+ @customer = ThroughTestCustomer.new(custid: 'cust_through_123', name: 'Through Test Customer')
55
+ @domain = ThroughTestDomain.new(domain_id: 'dom_through_456', display_domain: 'through-test.com', created_at: Familia.now.to_i)
56
+ @customer.save
57
+ @domain.save
58
+
59
+ @compat_customer = BackwardCompatCustomer.new(custid: 'compat_cust')
60
+ @compat_domain = BackwardCompatDomain.new(domain_id: 'compat_dom', created_at: Familia.now.to_i)
61
+ @compat_customer.save
62
+ @compat_domain.save
63
+
64
+ ## ParticipationRelationship supports through parameter
65
+ Familia::Features::Relationships::ParticipationRelationship.members.include?(:through)
66
+ #=> true
67
+
68
+ ## participates_in creates relationship with through parameter
69
+ @rel = ThroughTestDomain.participation_relationships.first
70
+ @rel.through
71
+ #=> :ThroughTestMembership
72
+
73
+ ## Through class must have object_identifier feature
74
+ begin
75
+ class ::InvalidThroughModel < Familia::Horreum
76
+ feature :relationships
77
+ end
78
+ class ::BadDomain < Familia::Horreum
79
+ feature :relationships
80
+ field :name
81
+ participates_in ThroughTestCustomer, :bad_domains, through: :InvalidThroughModel
82
+ end
83
+ false
84
+ rescue ArgumentError => e
85
+ e.message.include?('must use `feature :object_identifier`')
86
+ end
87
+ #=> true
88
+
89
+ ## Adding domain creates through model automatically
90
+ @membership1 = @customer.add_domains_instance(@domain)
91
+ @through_key = "through_test_customer:#{@customer.objid}:through_test_domain:#{@domain.objid}:through_test_membership"
92
+ @loaded_membership = ThroughTestMembership.load(@through_key)
93
+ @loaded_membership&.exists?
94
+ #=> true
95
+
96
+ ## Through model receives through_attrs on add
97
+ @customer.remove_domains_instance(@domain)
98
+ @membership2 = @customer.add_domains_instance(@domain, through_attrs: { role: 'admin' })
99
+ @loaded_with_role = ThroughTestMembership.load(@through_key)
100
+ @loaded_with_role.role
101
+ #=> 'admin'
102
+
103
+ ## add_domains_instance returns through model when using :through
104
+ @membership2.class.name
105
+ #=> "ThroughTestMembership"
106
+
107
+ ## Returned through model can be chained
108
+ @membership2.respond_to?(:role)
109
+ #=> true
110
+
111
+ ## Returned through model has role attribute set
112
+ @membership2.role
113
+ #=> 'admin'
114
+
115
+ ## Removing domain destroys through model
116
+ @customer.remove_domains_instance(@domain)
117
+ @removed_check = ThroughTestMembership.load(@through_key)
118
+ @removed_check.nil? || !@removed_check.exists?
119
+ #=> true
120
+
121
+ ## Adding twice updates existing, doesn't duplicate
122
+ @membership_v1 = @customer.add_domains_instance(@domain, through_attrs: { role: 'viewer' })
123
+ @check_v1 = ThroughTestMembership.load(@through_key)
124
+ @check_v1.role
125
+ #=> 'viewer'
126
+
127
+ ## Second add updates the same through model
128
+ @membership_v2 = @customer.add_domains_instance(@domain, through_attrs: { role: 'editor' })
129
+ @check_v2 = ThroughTestMembership.load(@through_key)
130
+ @check_v2.role
131
+ #=> 'editor'
132
+
133
+ ## Only one through model exists (same objid)
134
+ @check_v1.objid == @check_v2.objid
135
+ #=> true
136
+
137
+ ## Models without :through work as before
138
+ @compat_result = @compat_customer.add_domains_instance(@compat_domain)
139
+ @compat_domain.in_backward_compat_customer_domains?(@compat_customer)
140
+ #=> true
141
+
142
+ ## No through model created for backward compat
143
+ @compat_rel = BackwardCompatDomain.participation_relationships.first
144
+ @compat_rel.through.nil?
145
+ #=> true
146
+
147
+ ## Backward compat returns self not through model
148
+ @compat_result.class.name
149
+ #=> "BackwardCompatCustomer"
150
+
151
+ ## Through model sets updated_at on create
152
+ @customer.remove_domains_instance(@domain)
153
+ @membership_with_ts = @customer.add_domains_instance(@domain, through_attrs: { role: 'owner' })
154
+ @ts_check = ThroughTestMembership.load(@through_key)
155
+ @ts_check.updated_at.is_a?(Float)
156
+ #=> true
157
+
158
+ ## updated_at is current
159
+ (Familia.now.to_f - @ts_check.updated_at) < 2.0
160
+ #=> true
161
+
162
+ ## Through model updates updated_at on attribute change
163
+ @old_ts = @ts_check.updated_at
164
+ sleep 0.1
165
+ @membership_updated = @customer.add_domains_instance(@domain, through_attrs: { role: 'admin' })
166
+ @new_ts_check = ThroughTestMembership.load(@through_key)
167
+ @new_ts_check.updated_at > @old_ts
168
+ #=> true
169
+
170
+ # Cleanup
171
+ [@customer, @domain, @compat_customer, @compat_domain].each do |obj|
172
+ obj.destroy! if obj&.respond_to?(:destroy!) && obj.exists?
173
+ end