familia 2.0.0.pre14 → 2.0.0.pre16

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 (276) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/code-quality.yml +138 -0
  3. data/.github/workflows/code-smellage.yml +145 -0
  4. data/.github/workflows/docs.yml +31 -8
  5. data/.gitignore +1 -1
  6. data/.pre-commit-config.yaml +7 -1
  7. data/.reek.yml +98 -0
  8. data/.rubocop.yml +48 -10
  9. data/.talismanrc +9 -0
  10. data/.yardopts +18 -13
  11. data/CHANGELOG.rst +66 -6
  12. data/CLAUDE.md +1 -1
  13. data/Gemfile +6 -5
  14. data/Gemfile.lock +99 -23
  15. data/LICENSE.txt +1 -1
  16. data/README.md +285 -85
  17. data/changelog.d/README.md +2 -2
  18. data/docs/archive/FAMILIA_RELATIONSHIPS.md +22 -22
  19. data/docs/archive/FAMILIA_TECHNICAL.md +41 -41
  20. data/docs/archive/FAMILIA_UPDATE.md +3 -3
  21. data/docs/archive/README.md +3 -2
  22. data/docs/{guides/API-Reference.md → archive/api-reference.md} +87 -101
  23. data/docs/conf.py +29 -0
  24. data/docs/guides/{Field-System-Guide.md → core-field-system.md} +9 -9
  25. data/docs/guides/feature-encrypted-fields.md +785 -0
  26. data/docs/guides/{Expiration-Feature-Guide.md → feature-expiration.md} +11 -2
  27. data/docs/guides/feature-external-identifiers.md +637 -0
  28. data/docs/guides/feature-object-identifiers.md +435 -0
  29. data/docs/guides/{Quantization-Feature-Guide.md → feature-quantization.md} +94 -29
  30. data/docs/guides/feature-relationships-methods.md +684 -0
  31. data/docs/guides/feature-relationships.md +200 -0
  32. data/docs/guides/{Features-System-Developer-Guide.md → feature-system-devs.md} +4 -4
  33. data/docs/guides/{Feature-System-Guide.md → feature-system.md} +5 -5
  34. data/docs/guides/{Transient-Fields-Guide.md → feature-transient-fields.md} +2 -2
  35. data/docs/guides/{Implementation-Guide.md → implementation.md} +3 -3
  36. data/docs/guides/index.md +176 -0
  37. data/docs/guides/{Security-Model.md → security-model.md} +1 -1
  38. data/docs/migrating/v2.0.0-pre.md +1 -1
  39. data/docs/migrating/v2.0.0-pre11.md +4 -4
  40. data/docs/migrating/v2.0.0-pre12.md +2 -2
  41. data/docs/migrating/v2.0.0-pre13.md +1 -1
  42. data/docs/migrating/v2.0.0-pre5.md +33 -12
  43. data/docs/migrating/v2.0.0-pre6.md +2 -2
  44. data/docs/migrating/v2.0.0-pre7.md +8 -8
  45. data/docs/overview.md +623 -19
  46. data/docs/reference/api-technical.md +1365 -0
  47. data/examples/autoloader/mega_customer/features/deprecated_fields.rb +7 -0
  48. data/examples/autoloader/mega_customer/safe_dump_fields.rb +1 -1
  49. data/examples/autoloader/mega_customer.rb +3 -1
  50. data/examples/encrypted_fields.rb +378 -0
  51. data/examples/json_usage_patterns.rb +144 -0
  52. data/examples/relationships.rb +13 -13
  53. data/examples/safe_dump.rb +6 -6
  54. data/examples/single_connection_transaction_confusions.rb +379 -0
  55. data/lib/familia/base.rb +49 -10
  56. data/lib/familia/connection/handlers.rb +223 -0
  57. data/lib/familia/connection/individual_command_proxy.rb +64 -0
  58. data/lib/familia/connection/middleware.rb +75 -0
  59. data/lib/familia/connection/operation_core.rb +93 -0
  60. data/lib/familia/connection/operations.rb +277 -0
  61. data/lib/familia/connection/pipeline_core.rb +87 -0
  62. data/lib/familia/connection/transaction_core.rb +100 -0
  63. data/lib/familia/connection.rb +60 -186
  64. data/lib/familia/data_type/commands.rb +53 -51
  65. data/lib/familia/data_type/serialization.rb +108 -107
  66. data/lib/familia/data_type/types/counter.rb +1 -1
  67. data/lib/familia/data_type/types/hashkey.rb +13 -10
  68. data/lib/familia/data_type/types/{list.rb → listkey.rb} +13 -5
  69. data/lib/familia/data_type/types/lock.rb +3 -2
  70. data/lib/familia/data_type/types/sorted_set.rb +26 -15
  71. data/lib/familia/data_type/types/{string.rb → stringkey.rb} +7 -5
  72. data/lib/familia/data_type/types/unsorted_set.rb +20 -27
  73. data/lib/familia/data_type.rb +75 -47
  74. data/lib/familia/distinguisher.rb +85 -0
  75. data/lib/familia/encryption/encrypted_data.rb +15 -24
  76. data/lib/familia/encryption/manager.rb +6 -4
  77. data/lib/familia/encryption/providers/aes_gcm_provider.rb +1 -1
  78. data/lib/familia/encryption/providers/secure_xchacha20_poly1305_provider.rb +7 -9
  79. data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +4 -5
  80. data/lib/familia/encryption/request_cache.rb +7 -7
  81. data/lib/familia/encryption.rb +2 -3
  82. data/lib/familia/errors.rb +9 -3
  83. data/lib/familia/{autoloader.rb → features/autoloader.rb} +49 -23
  84. data/lib/familia/features/encrypted_fields/concealed_string.rb +3 -4
  85. data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +13 -14
  86. data/lib/familia/features/encrypted_fields.rb +68 -66
  87. data/lib/familia/features/expiration/extensions.rb +61 -0
  88. data/lib/familia/features/expiration.rb +35 -87
  89. data/lib/familia/features/external_identifier.rb +11 -12
  90. data/lib/familia/features/object_identifier.rb +58 -20
  91. data/lib/familia/features/quantization.rb +17 -22
  92. data/lib/familia/features/relationships/README.md +97 -0
  93. data/lib/familia/features/relationships/collection_operations.rb +104 -0
  94. data/lib/familia/features/relationships/indexing/multi_index_generators.rb +202 -0
  95. data/lib/familia/features/relationships/indexing/unique_index_generators.rb +301 -0
  96. data/lib/familia/features/relationships/indexing.rb +176 -256
  97. data/lib/familia/features/relationships/indexing_relationship.rb +35 -0
  98. data/lib/familia/features/relationships/participation/participant_methods.rb +160 -0
  99. data/lib/familia/features/relationships/participation/target_methods.rb +225 -0
  100. data/lib/familia/features/relationships/participation.rb +656 -0
  101. data/lib/familia/features/relationships/participation_relationship.rb +31 -0
  102. data/lib/familia/features/relationships/score_encoding.rb +20 -20
  103. data/lib/familia/features/relationships.rb +69 -271
  104. data/lib/familia/features/safe_dump.rb +127 -132
  105. data/lib/familia/features/transient_fields/redacted_string.rb +6 -6
  106. data/lib/familia/features/transient_fields/transient_field_type.rb +5 -5
  107. data/lib/familia/features/transient_fields.rb +5 -5
  108. data/lib/familia/features.rb +21 -21
  109. data/lib/familia/field_type.rb +24 -4
  110. data/lib/familia/horreum/core/connection.rb +229 -26
  111. data/lib/familia/horreum/core/database_commands.rb +27 -17
  112. data/lib/familia/horreum/core/serialization.rb +40 -20
  113. data/lib/familia/horreum/core/utils.rb +2 -1
  114. data/lib/familia/horreum/shared/settings.rb +2 -1
  115. data/lib/familia/horreum/subclass/definition.rb +33 -45
  116. data/lib/familia/horreum/subclass/management.rb +72 -24
  117. data/lib/familia/horreum/subclass/related_fields_management.rb +82 -21
  118. data/lib/familia/horreum.rb +196 -114
  119. data/lib/familia/json_serializer.rb +0 -1
  120. data/lib/familia/logging.rb +11 -114
  121. data/lib/familia/refinements/dear_json.rb +122 -0
  122. data/lib/familia/refinements/logger_trace.rb +20 -17
  123. data/lib/familia/refinements/stylize_words.rb +65 -0
  124. data/lib/familia/refinements/time_literals.rb +60 -52
  125. data/lib/familia/refinements.rb +2 -1
  126. data/lib/familia/secure_identifier.rb +60 -28
  127. data/lib/familia/settings.rb +83 -7
  128. data/lib/familia/utils.rb +5 -87
  129. data/lib/familia/verifiable_identifier.rb +4 -4
  130. data/lib/familia/version.rb +1 -1
  131. data/lib/familia.rb +72 -15
  132. data/lib/middleware/database_middleware.rb +56 -14
  133. data/lib/{familia/multi_result.rb → multi_result.rb} +23 -16
  134. data/try/configuration/scenarios_try.rb +1 -1
  135. data/try/connection/fiber_context_preservation_try.rb +250 -0
  136. data/try/connection/handler_constraints_try.rb +59 -0
  137. data/try/connection/operation_mode_guards_try.rb +208 -0
  138. data/try/connection/pipeline_fallback_integration_try.rb +128 -0
  139. data/try/connection/responsibility_chain_tracking_try.rb +72 -0
  140. data/try/connection/transaction_fallback_integration_try.rb +288 -0
  141. data/try/connection/transaction_mode_permissive_try.rb +153 -0
  142. data/try/connection/transaction_mode_strict_try.rb +98 -0
  143. data/try/connection/transaction_mode_warn_try.rb +131 -0
  144. data/try/connection/transaction_modes_try.rb +249 -0
  145. data/try/core/autoloader_try.rb +129 -11
  146. data/try/core/connection_try.rb +7 -7
  147. data/try/core/conventional_inheritance_try.rb +130 -0
  148. data/try/core/create_method_try.rb +15 -23
  149. data/try/core/database_consistency_try.rb +10 -10
  150. data/try/core/errors_try.rb +8 -11
  151. data/try/core/familia_extended_try.rb +2 -2
  152. data/try/core/familia_members_methods_try.rb +76 -0
  153. data/try/core/isolated_dbclient_try.rb +165 -0
  154. data/try/core/middleware_try.rb +16 -16
  155. data/try/core/persistence_operations_try.rb +4 -4
  156. data/try/core/pools_try.rb +42 -26
  157. data/try/core/secure_identifier_try.rb +28 -24
  158. data/try/core/time_utils_try.rb +10 -10
  159. data/try/core/tools_try.rb +1 -1
  160. data/try/core/utils_try.rb +2 -2
  161. data/try/data_types/boolean_try.rb +4 -4
  162. data/try/data_types/datatype_base_try.rb +0 -2
  163. data/try/data_types/list_try.rb +10 -10
  164. data/try/data_types/sorted_set_try.rb +5 -5
  165. data/try/data_types/string_try.rb +12 -12
  166. data/try/data_types/unsortedset_try.rb +33 -0
  167. data/try/debugging/cache_behavior_tracer.rb +7 -7
  168. data/try/debugging/debug_aad_process.rb +1 -1
  169. data/try/debugging/debug_concealed_internal.rb +1 -1
  170. data/try/debugging/debug_cross_context.rb +1 -1
  171. data/try/debugging/debug_fresh_cross_context.rb +1 -1
  172. data/try/debugging/encryption_method_tracer.rb +10 -10
  173. data/try/edge_cases/hash_symbolization_try.rb +1 -1
  174. data/try/edge_cases/ttl_side_effects_try.rb +1 -1
  175. data/try/encryption/config_persistence_try.rb +2 -2
  176. data/try/encryption/encryption_core_try.rb +19 -19
  177. data/try/encryption/instance_variable_scope_try.rb +1 -1
  178. data/try/encryption/module_loading_try.rb +2 -2
  179. data/try/encryption/providers/aes_gcm_provider_try.rb +1 -1
  180. data/try/encryption/providers/xchacha20_poly1305_provider_try.rb +1 -1
  181. data/try/encryption/secure_memory_handling_try.rb +1 -1
  182. data/try/features/encrypted_fields/concealed_string_core_try.rb +11 -7
  183. data/try/features/encrypted_fields/encrypted_fields_core_try.rb +1 -1
  184. data/try/features/encrypted_fields/encrypted_fields_integration_try.rb +3 -3
  185. data/try/features/encrypted_fields/encrypted_fields_no_cache_security_try.rb +10 -10
  186. data/try/features/encrypted_fields/encrypted_fields_security_try.rb +14 -14
  187. data/try/features/encrypted_fields/error_conditions_try.rb +7 -7
  188. data/try/features/encrypted_fields/fresh_key_try.rb +1 -1
  189. data/try/features/encrypted_fields/nonce_uniqueness_try.rb +1 -1
  190. data/try/features/encrypted_fields/secure_by_default_behavior_try.rb +7 -7
  191. data/try/features/encrypted_fields/universal_serialization_safety_try.rb +13 -20
  192. data/try/features/external_identifier/external_identifier_try.rb +1 -1
  193. data/try/features/feature_dependencies_try.rb +3 -3
  194. data/try/features/object_identifier/object_identifier_integration_try.rb +28 -34
  195. data/try/features/object_identifier/object_identifier_try.rb +10 -0
  196. data/try/features/quantization/quantization_try.rb +1 -1
  197. data/try/features/relationships/indexing_commands_verification_try.rb +136 -0
  198. data/try/features/relationships/indexing_try.rb +433 -0
  199. data/try/features/relationships/participation_commands_verification_spec.rb +102 -0
  200. data/try/features/relationships/participation_commands_verification_try.rb +105 -0
  201. data/try/features/relationships/participation_performance_improvements_try.rb +124 -0
  202. data/try/features/relationships/participation_reverse_index_try.rb +196 -0
  203. data/try/features/relationships/relationships_api_changes_try.rb +72 -71
  204. data/try/features/relationships/relationships_edge_cases_try.rb +15 -18
  205. data/try/features/relationships/relationships_performance_minimal_try.rb +2 -2
  206. data/try/features/relationships/relationships_performance_simple_try.rb +8 -8
  207. data/try/features/relationships/relationships_performance_try.rb +20 -20
  208. data/try/features/relationships/relationships_try.rb +27 -38
  209. data/try/features/safe_dump/safe_dump_advanced_try.rb +2 -2
  210. data/try/features/transient_fields/refresh_reset_try.rb +1 -1
  211. data/try/features/transient_fields/simple_refresh_test.rb +1 -1
  212. data/try/helpers/test_cleanup.rb +86 -0
  213. data/try/helpers/test_helpers.rb +3 -3
  214. data/try/horreum/base_try.rb +3 -2
  215. data/try/horreum/commands_try.rb +1 -1
  216. data/try/horreum/destroy_related_fields_cleanup_try.rb +330 -0
  217. data/try/horreum/initialization_try.rb +11 -7
  218. data/try/horreum/relations_try.rb +21 -13
  219. data/try/horreum/serialization_try.rb +12 -11
  220. data/try/integration/cross_component_try.rb +3 -3
  221. data/try/memory/memory_basic_test.rb +1 -1
  222. data/try/memory/memory_docker_ruby_dump.sh +1 -1
  223. data/try/models/customer_safe_dump_try.rb +1 -1
  224. data/try/models/customer_try.rb +8 -10
  225. data/try/models/datatype_base_try.rb +3 -3
  226. data/try/models/familia_object_try.rb +9 -8
  227. data/try/performance/benchmarks_try.rb +2 -2
  228. data/try/prototypes/atomic_saves_v1_context_proxy.rb +2 -2
  229. data/try/prototypes/atomic_saves_v3_connection_pool.rb +3 -3
  230. data/try/prototypes/atomic_saves_v4.rb +1 -1
  231. data/try/prototypes/lib/atomic_saves_v2_connection_switching_helpers.rb +4 -4
  232. data/try/prototypes/lib/atomic_saves_v3_connection_pool_helpers.rb +4 -4
  233. data/try/prototypes/pooling/lib/atomic_saves_v3_connection_pool_helpers.rb +4 -4
  234. data/try/prototypes/pooling/lib/connection_pool_metrics.rb +5 -5
  235. data/try/prototypes/pooling/lib/connection_pool_stress_test.rb +26 -26
  236. data/try/prototypes/pooling/lib/connection_pool_threading_models.rb +7 -7
  237. data/try/prototypes/pooling/lib/visualize_stress_results.rb +1 -1
  238. data/try/prototypes/pooling/pool_siege.rb +11 -11
  239. data/try/prototypes/pooling/run_stress_tests.rb +7 -7
  240. data/try/refinements/dear_json_array_methods_try.rb +53 -0
  241. data/try/refinements/dear_json_hash_methods_try.rb +54 -0
  242. data/try/refinements/logger_trace_methods_try.rb +44 -0
  243. data/try/refinements/time_literals_numeric_methods_try.rb +141 -0
  244. data/try/refinements/time_literals_string_methods_try.rb +80 -0
  245. metadata +77 -45
  246. data/.rubocop_todo.yml +0 -208
  247. data/docs/connection_pooling.md +0 -192
  248. data/docs/guides/Connection-Pooling-Guide.md +0 -437
  249. data/docs/guides/Encrypted-Fields-Overview.md +0 -101
  250. data/docs/guides/Feature-System-Autoloading.md +0 -228
  251. data/docs/guides/Home.md +0 -116
  252. data/docs/guides/Relationships-Guide.md +0 -737
  253. data/docs/guides/relationships-methods.md +0 -266
  254. data/docs/reference/auditing_database_commands.rb +0 -228
  255. data/examples/permissions.rb +0 -240
  256. data/lib/familia/features/autoloadable.rb +0 -113
  257. data/lib/familia/features/relationships/cascading.rb +0 -437
  258. data/lib/familia/features/relationships/membership.rb +0 -497
  259. data/lib/familia/features/relationships/permission_management.rb +0 -264
  260. data/lib/familia/features/relationships/querying.rb +0 -615
  261. data/lib/familia/features/relationships/redis_operations.rb +0 -274
  262. data/lib/familia/features/relationships/tracking.rb +0 -418
  263. data/lib/familia/refinements/snake_case.rb +0 -40
  264. data/lib/familia/validation/command_recorder.rb +0 -336
  265. data/lib/familia/validation/expectations.rb +0 -519
  266. data/lib/familia/validation/validation_helpers.rb +0 -443
  267. data/lib/familia/validation/validator.rb +0 -412
  268. data/lib/familia/validation.rb +0 -140
  269. data/try/data_types/set_try.rb +0 -33
  270. data/try/features/autoloadable/autoloadable_try.rb +0 -61
  271. data/try/features/relationships/categorical_permissions_try.rb +0 -515
  272. data/try/features/safe_dump/safe_dump_autoloading_try.rb +0 -111
  273. data/try/validation/atomic_operations_try.rb.disabled +0 -320
  274. data/try/validation/command_validation_try.rb.disabled +0 -207
  275. data/try/validation/performance_validation_try.rb.disabled +0 -324
  276. data/try/validation/real_world_scenarios_try.rb.disabled +0 -390
@@ -0,0 +1,656 @@
1
+ # lib/familia/features/relationships/participation.rb
2
+
3
+ require_relative 'participation_relationship'
4
+ require_relative 'collection_operations'
5
+ require_relative 'participation/participant_methods'
6
+ require_relative 'participation/target_methods'
7
+
8
+ module Familia
9
+ module Features
10
+ module Relationships
11
+ # Participation module for bidirectional business relationships using Valkey/Redis collections.
12
+ # Provides semantic, scored relationships with automatic reverse tracking.
13
+ #
14
+ # Unlike Indexing (which is for attribute lookups), Participation manages
15
+ # relationships where membership has meaning, scores have semantic value,
16
+ # and bidirectional tracking is essential
17
+ #
18
+ # === Architecture Overview ===
19
+ # This module is organized into clear, separate concerns:
20
+ #
21
+ # 1. CollectionOperations: Shared helpers for all collection manipulation
22
+ # 2. ParticipantMethods: Methods added to the class calling participates_in
23
+ # 3. TargetMethods: Methods added to the target class specified in participates_in
24
+ #
25
+ # This separation makes it crystal clear what methods are added to which class.
26
+ #
27
+ # @example Basic participation with temporal scoring
28
+ # class Domain < Familia::Horreum
29
+ # feature :relationships
30
+ # field :created_at
31
+ # participates_in Customer, :domains, score: :created_at
32
+ # end
33
+ #
34
+ # # TARGET (Customer) gets collection management:
35
+ # customer.domains # → Familia::SortedSet (by created_at)
36
+ # customer.add_domain(domain) # → adds with created_at score
37
+ # customer.remove_domain(domain) # → removes + cleans reverse index
38
+ # customer.add_domains([d1, d2, d3]) # → efficient bulk addition
39
+ #
40
+ # # PARTICIPANT (Domain) gets membership methods:
41
+ # domain.in_customer_domains?(customer) # → true/false
42
+ # domain.add_to_customer_domains(customer) # → self-addition
43
+ # domain.remove_from_customer_domains(customer) # → self-removal
44
+ # domain.participations # → reverse index tracking
45
+ #
46
+ # @example Class-level participation (all instances auto-tracked)
47
+ # class User < Familia::Horreum
48
+ # feature :relationships
49
+ # field :created_at
50
+ # class_participates_in :all_users, score: :created_at
51
+ # end
52
+ #
53
+ # User.all_users # → Familia::SortedSet (class-level)
54
+ # user.in_class_all_users? # → true if auto-added
55
+ # user.add_to_class_all_users # → explicit addition
56
+ #
57
+ # @example Semantic scores with permission encoding
58
+ # class Domain < Familia::Horreum
59
+ # feature :relationships
60
+ # field :created_at
61
+ # field :permission_bits
62
+ #
63
+ # participates_in Customer, :domains,
64
+ # score: -> { permission_encode(created_at, permission_bits) }
65
+ # end
66
+ #
67
+ # customer.domains_with_permission(:read) # → filtered by score
68
+ #
69
+ # Key Differences from Indexing:
70
+ # - Participation: Bidirectional relationships with semantic scores
71
+ # - Indexing: Unidirectional lookups without relationship semantics
72
+ # - Participation: Collection name in key (customer:123:domains)
73
+ # - Indexing: Field value in key (company:123:dept_index:engineering)
74
+ #
75
+ # When to Use Participation:
76
+ # - Modeling business relationships (Customer owns Domains)
77
+ # - Scores have meaning (priority, permissions, join_date)
78
+ # - Need bidirectional tracking ("what collections does this belong to?")
79
+ # - Relationship lifecycle matters (cascade cleanup, reverse tracking)
80
+ #
81
+ module Participation
82
+ using Familia::Refinements::StylizeWords
83
+
84
+ # Hook called when module is included in a class.
85
+ #
86
+ # Extends the host class with ModelClassMethods for relationship definitions
87
+ # and includes ModelInstanceMethods for instance-level operations.
88
+ #
89
+ # @param base [Class] The class including this module
90
+ def self.included(base)
91
+ base.extend ModelClassMethods
92
+ base.include ModelInstanceMethods
93
+ super
94
+ end
95
+
96
+ # Class methods for defining participation relationships.
97
+ #
98
+ # These methods are available on any class that includes the Participation module,
99
+ # allowing definition of both instance-level and class-level participation relationships.
100
+ module ModelClassMethods
101
+ # Define a class-level participation collection where all instances automatically participate.
102
+ #
103
+ # Class-level participation creates a global collection containing all instances of the class,
104
+ # with automatic management of membership based on object lifecycle events. This is useful
105
+ # for maintaining global indexes, leaderboards, or categorical groupings.
106
+ #
107
+ # The collection is created at the class level (e.g., User.all_users) rather than on
108
+ # individual instances, providing a centralized view of all objects matching the criteria.
109
+ #
110
+ # === Generated Methods
111
+ #
112
+ # ==== On the Class (Target Methods)
113
+ # - +ClassName.collection_name+ - Access the collection DataType
114
+ # - +ClassName.add_to_collection_name(instance)+ - Add instance to collection
115
+ # - +ClassName.remove_from_collection_name(instance)+ - Remove instance from collection
116
+ #
117
+ # ==== On Instances (Participant Methods, if bidirectional)
118
+ # - +instance.in_class_collection_name?+ - Check membership in class collection
119
+ # - +instance.add_to_class_collection_name+ - Add self to class collection
120
+ # - +instance.remove_from_class_collection_name+ - Remove self from class collection
121
+ #
122
+ # @param collection_name [Symbol] Name of the class-level collection (e.g., +:all_users+, +:active_members+)
123
+ # @param score [Symbol, Proc, Numeric, nil] Scoring strategy for sorted collections:
124
+ # - +Symbol+: Field name or method name (e.g., +:priority_level+, +:created_at+)
125
+ # - +Proc+: Dynamic calculation in instance context (e.g., +-> { status == 'premium' ? 100 : 0 }+)
126
+ # - +Numeric+: Static score for all instances (e.g., +50.0+)
127
+ # - +nil+: Use +current_score+ method fallback
128
+ # - +:remove+: Remove from collection on destruction (default)
129
+ # - +:ignore+: Leave in collection when destroyed
130
+ # @param type [Symbol] Valkey/Redis collection type:
131
+ # - +:sorted_set+: Ordered by score (default)
132
+ # - +:set+: Unordered unique membership
133
+ # - +:list+: Ordered sequence allowing duplicates
134
+ # @param bidirectional [Boolean] Whether to generate convenience methods on instances (default: +true+)
135
+ #
136
+ # @example Simple priority-based global collection
137
+ # class User < Familia::Horreum
138
+ # field :priority_level
139
+ # class_participates_in :all_users, score: :priority_level
140
+ # end
141
+ #
142
+ # User.all_users.first # Highest priority user
143
+ # user.in_class_all_users? # true if user is in collection
144
+ #
145
+ # @example Dynamic scoring based on status
146
+ # class Customer < Familia::Horreum
147
+ # field :status
148
+ # field :last_purchase
149
+ #
150
+ # class_participates_in :active_customers, score: -> {
151
+ # status == 'active' ? last_purchase.to_i : 0
152
+ # }
153
+ # end
154
+ #
155
+ # Customer.active_customers.to_a # All active customers, sorted by last purchase
156
+ #
157
+ # @see #participates_in for instance-level participation relationships
158
+ # @since 1.0.0
159
+ def class_participates_in(collection_name, score: nil,
160
+ type: :sorted_set, bidirectional: true)
161
+ # Store metadata for this participation relationship
162
+ participation_relationships << ParticipationRelationship.new(
163
+ target_class: self,
164
+ collection_name: collection_name,
165
+ score: score,
166
+
167
+ type: type,
168
+ bidirectional: bidirectional,
169
+ )
170
+
171
+ # STEP 1: Add collection management methods to the class itself
172
+ # e.g., User.all_users, User.add_to_all_users(user)
173
+ TargetMethods::Builder.build_class_level(self, collection_name, type)
174
+
175
+ # STEP 2: Add participation methods to instances (if bidirectional)
176
+ # e.g., user.in_class_all_users?, user.add_to_class_all_users
177
+ return unless bidirectional
178
+
179
+ ParticipantMethods::Builder.build(self, 'class', collection_name, type)
180
+ end
181
+
182
+ # Define an instance-level participation relationship between two classes.
183
+ #
184
+ # This method creates a bidirectional relationship where instances of the calling class
185
+ # (participants) can join collections owned by instances of the target class. This enables
186
+ # flexible multi-membership scenarios where objects can belong to multiple collections
187
+ # simultaneously with different scoring and management strategies.
188
+ #
189
+ # The relationship automatically handles reverse index tracking, allowing efficient
190
+ # lookup of all collections a participant belongs to via the +current_participations+ method.
191
+ #
192
+ # === Generated Methods
193
+ #
194
+ # ==== On Target Class (Collection Owner)
195
+ # - +target.collection_name+ - Access the collection DataType
196
+ # - +target.add_participant_class_name(participant)+ - Add participant to collection
197
+ # - +target.remove_participant_class_name(participant)+ - Remove participant from collection
198
+ # - +target.add_participant_class_names([participants])+ - Bulk add multiple participants
199
+ #
200
+ # ==== On Participant Class (if bidirectional)
201
+ # - +participant.in_target_collection_name?(target)+ - Check membership in target's collection
202
+ # - +participant.add_to_target_collection_name(target)+ - Add self to target's collection
203
+ # - +participant.remove_from_target_collection_name(target)+ - Remove self from target's collection
204
+ #
205
+ # === Reverse Index Tracking
206
+ #
207
+ # Automatically creates a +:participations+ set field on the participant class to track
208
+ # all collections the instance belongs to. This enables efficient membership queries
209
+ # and cleanup operations without scanning all possible collections.
210
+ #
211
+ # @param target_class [Class, Symbol, String] The class that owns the collection. Can be:
212
+ # - +Class+ object (e.g., +Customer+)
213
+ # - +Symbol+ referencing class name (e.g., +:customer+, +:Customer+)
214
+ # - +String+ class name (e.g., +"Customer"+)
215
+ # @param collection_name [Symbol] Name of the collection on the target class (e.g., +:domains+, +:members+)
216
+ # @param score [Symbol, Proc, Numeric, nil] Scoring strategy for sorted collections:
217
+ # - +Symbol+: Field name or method name (e.g., +:priority+, +:created_at+)
218
+ # - +Proc+: Dynamic calculation executed in participant instance context
219
+ # - +Numeric+: Static score applied to all participants
220
+ # - +nil+: Use +current_score+ method as fallback
221
+ # - +:remove+: Remove from all collections on destruction (default)
222
+ # - +:ignore+: Leave in collections when destroyed
223
+ # @param type [Symbol] Valkey/Redis collection type:
224
+ # - +:sorted_set+: Ordered by score, allows duplicates with different scores (default)
225
+ # - +:set+: Unordered unique membership
226
+ # - +:list+: Ordered sequence, allows duplicates
227
+ # @param bidirectional [Boolean] Whether to generate convenience methods on participant class (default: +true+)
228
+ #
229
+ # @example Basic domain-customer relationship
230
+ # class Domain < Familia::Horreum
231
+ # field :name
232
+ # field :created_at
233
+ #
234
+ # participates_in Customer, :domains, score: :created_at
235
+ # end
236
+ #
237
+ # # Usage:
238
+ # domain.add_to_customer_domains(customer) # Add domain to customer's collection
239
+ # customer.domains.first # Most recent domain
240
+ # domain.in_customer_domains?(customer) # true
241
+ # domain.current_participations # All collections domain belongs to
242
+ #
243
+ # @example Multi-collection participation with different types
244
+ # class Employee < Familia::Horreum
245
+ # field :hire_date
246
+ # field :skill_level
247
+ #
248
+ # # Sorted by hire date in department
249
+ # participates_in Department, :members, score: :hire_date
250
+ #
251
+ # # Simple set membership in teams
252
+ # participates_in Team, :contributors, score: :skill_level, type: :set
253
+ #
254
+ # # Complex scoring for project assignments
255
+ # participates_in Project, :assignees, score: -> {
256
+ # base_score = skill_level * 100
257
+ # seniority = (Time.now - hire_date) / 1.year
258
+ # base_score + seniority * 10
259
+ # }
260
+ # end
261
+ #
262
+ # # Employee can belong to department, multiple teams, and projects
263
+ # employee.add_to_department_members(engineering_dept)
264
+ # employee.add_to_team_contributors(frontend_team)
265
+ # employee.add_to_project_assignees(mobile_app_project)
266
+ #
267
+ # @see #class_participates_in for class-level participation
268
+ # @see ModelInstanceMethods#current_participations for membership queries
269
+ # @see ModelInstanceMethods#calculate_participation_score for scoring details
270
+ # @since 1.0.0
271
+ def participates_in(target_class, collection_name, score: nil,
272
+ type: :sorted_set, bidirectional: true)
273
+ # Handle class target using Familia.resolve_class
274
+ resolved_class = Familia.resolve_class(target_class)
275
+
276
+ # Store metadata for this participation relationship
277
+ participation_relationships << ParticipationRelationship.new(
278
+ target_class: target_class, # as passed to `participates_in`
279
+ collection_name: collection_name,
280
+ score: score,
281
+
282
+ type: type,
283
+ bidirectional: bidirectional,
284
+ )
285
+
286
+ # Resolve target class if it's a symbol/string
287
+ actual_target_class = if target_class.is_a?(Class)
288
+ target_class
289
+ else
290
+ Familia.member_by_config_name(target_class)
291
+ end
292
+
293
+ # STEP 0: Add participations tracking field to PARTICIPANT class (Domain)
294
+ # This creates the proper key: "domain:123:participations" (not "domain:123:object:participations")
295
+ set :participations unless method_defined?(:participations)
296
+
297
+ # STEP 1: Add collection management methods to TARGET class (Customer)
298
+ # Customer gets: domains, add_domain, remove_domain, etc.
299
+ TargetMethods::Builder.build(actual_target_class, collection_name, type)
300
+
301
+ # STEP 2: Add participation methods to PARTICIPANT class (Domain) - only if bidirectional
302
+ # Domain gets: in_customer_domains?, add_to_customer_domains, etc.
303
+ return unless bidirectional
304
+
305
+ ParticipantMethods::Builder.build(self, resolved_class.familia_name, collection_name, type)
306
+ end
307
+
308
+ # Get all participation relationships defined for this class.
309
+ #
310
+ # Returns an array of ParticipationRelationship objects containing metadata
311
+ # about each participation relationship, including target class, collection name,
312
+ # scoring strategy, and configuration options.
313
+ #
314
+ # @return [Array<ParticipationRelationship>] Array of relationship configurations
315
+ # @since 1.0.0
316
+ def participation_relationships
317
+ @participation_relationships ||= []
318
+ end
319
+ end
320
+
321
+ # Instance methods available on objects that participate in collections.
322
+ #
323
+ # These methods provide the core functionality for participation management,
324
+ # including score calculation, membership tracking, and participation queries.
325
+ module ModelInstanceMethods
326
+ # Calculate the appropriate score for a participation relationship based on configured scoring strategy.
327
+ #
328
+ # This method serves as the single source of truth for participation scoring across the entire
329
+ # relationship lifecycle. It supports multiple scoring strategies and provides robust fallback
330
+ # behavior for edge cases and error conditions.
331
+ #
332
+ # The calculated score determines the object's position within sorted collections and can be
333
+ # dynamically recalculated as object state changes, enabling responsive collection ordering
334
+ # based on real-time business logic.
335
+ #
336
+ # === Scoring Strategies
337
+ #
338
+ # [Symbol] Field name or method name - calls +send(symbol)+ on the instance
339
+ # * +:priority_level+ - Uses value of priority_level field
340
+ # * +:created_at+ - Uses timestamp for chronological ordering
341
+ # * +:calculate_importance+ - Calls custom method for complex logic
342
+ #
343
+ # [Proc] Dynamic calculation executed in instance context using +instance_exec+
344
+ # * +-> { skill_level * experience_years }+ - Combines multiple fields
345
+ # * +-> { active? ? 100 : 0 }+ - Conditional scoring based on state
346
+ # * +-> { Rails.cache.fetch("score:#{id}") { expensive_calculation } }+ - Cached computations
347
+ #
348
+ # [Numeric] Static score applied uniformly to all instances
349
+ # * +50.0+ - All instances get same floating-point score
350
+ # * +100+ - All instances get same integer score (converted to float)
351
+ #
352
+ # [nil] Uses +current_score+ method as fallback if available
353
+ #
354
+ # === Performance Considerations
355
+ #
356
+ # - Score calculations are performed on-demand during collection operations
357
+ # - Proc-based calculations should be efficient as they may be called frequently
358
+ # - Consider caching expensive calculations within the Proc itself
359
+ # - Static numeric scores have no performance overhead
360
+ #
361
+ # === Thread Safety
362
+ #
363
+ # Score calculations should be idempotent and thread-safe since they may be
364
+ # called concurrently during collection updates. Avoid modifying instance state
365
+ # within scoring Procs.
366
+ #
367
+ # @param target_class [Class, Symbol, String] The target class containing the collection
368
+ # @param collection_name [Symbol] The collection name within the target class
369
+ # @return [Float] Calculated score for sorted set positioning, falls back to current_score
370
+ #
371
+ # @example Field-based scoring
372
+ # class Task < Familia::Horreum
373
+ # field :priority # 1=low, 5=high
374
+ # participates_in Project, :tasks, score: :priority
375
+ # end
376
+ #
377
+ # task.priority = 5
378
+ # score = task.calculate_participation_score(Project, :tasks) # => 5.0
379
+ #
380
+ # @example Complex business logic with multiple factors
381
+ # class Employee < Familia::Horreum
382
+ # field :hire_date
383
+ # field :performance_rating
384
+ # field :salary
385
+ #
386
+ # participates_in Department, :members, score: -> {
387
+ # tenure_months = (Time.now - hire_date) / 1.month
388
+ # base_score = tenure_months * 10
389
+ # performance_bonus = performance_rating * 100
390
+ # salary_factor = salary / 1000.0
391
+ #
392
+ # (base_score + performance_bonus + salary_factor).round(2)
393
+ # }
394
+ # end
395
+ #
396
+ # # Score reflects seniority, performance, and compensation
397
+ # employee.performance_rating = 4.5
398
+ # employee.salary = 85000
399
+ # score = employee.calculate_participation_score(Department, :members) # => 1375.0
400
+ #
401
+ # @see #participates_in for relationship configuration
402
+ # @see #track_participation_in for reverse index management
403
+ # @since 1.0.0
404
+ def calculate_participation_score(target_class, collection_name)
405
+ # Find the participation configuration with robust type comparison
406
+ participation_config = self.class.participation_relationships.find do |details|
407
+ # Normalize both sides for comparison to handle Class, Symbol, and String types
408
+ config_target = details.target_class
409
+ config_target = config_target.name if config_target.is_a?(Class)
410
+ config_target = config_target.to_s
411
+
412
+ comparison_target = target_class
413
+ comparison_target = comparison_target.name if comparison_target.is_a?(Class)
414
+ comparison_target = comparison_target.to_s
415
+
416
+ config_target == comparison_target && details.collection_name == collection_name
417
+ end
418
+
419
+ return current_score unless participation_config
420
+
421
+ score_calculator = participation_config.score
422
+
423
+ # Get the raw result based on calculator type
424
+ result = case score_calculator
425
+ when Symbol
426
+ # Field name or method name
427
+ respond_to?(score_calculator) ? send(score_calculator) : nil
428
+ when Proc
429
+ # Execute proc in context of this instance
430
+ instance_exec(&score_calculator)
431
+ when Numeric
432
+ # Static numeric value
433
+ return score_calculator.to_f
434
+ else
435
+ # Unrecognized type
436
+ return current_score
437
+ end
438
+
439
+ # Convert result to appropriate score with unified logic
440
+ convert_to_score(result)
441
+ end
442
+
443
+ # Add participation tracking to the reverse index.
444
+ #
445
+ # This method maintains the reverse index that tracks which collections this object
446
+ # participates in. The reverse index enables efficient lookup of all memberships
447
+ # via +current_participations+ without requiring expensive scans.
448
+ #
449
+ # The collection key follows the pattern: +"targetclass:targetid:collectionname"+
450
+ #
451
+ # @param collection_key [String] Unique identifier for the collection (format: "class:id:collection")
452
+ # @example
453
+ # domain.track_participation_in("customer:123:domains")
454
+ # @see #untrack_participation_in for removal
455
+ # @see #current_participations for membership queries
456
+ # @since 1.0.0
457
+ def track_participation_in(collection_key)
458
+ # Use Horreum's DataType field instead of manual key construction
459
+ participations.add(collection_key)
460
+ end
461
+
462
+ # Remove participation tracking from the reverse index.
463
+ #
464
+ # This method removes the collection key from the reverse index when the object
465
+ # is removed from a collection. This keeps the reverse index accurate and prevents
466
+ # stale references from appearing in +current_participations+ results.
467
+ #
468
+ # @param collection_key [String] Collection identifier to remove from tracking
469
+ # @example
470
+ # domain.untrack_participation_in("customer:123:domains")
471
+ # @see #track_participation_in for addition
472
+ # @see #current_participations for membership queries
473
+ # @since 1.0.0
474
+ def untrack_participation_in(collection_key)
475
+ # Use Horreum's DataType field instead of manual key construction
476
+ participations.remove(collection_key)
477
+ end
478
+
479
+ # Get comprehensive information about all collections this object participates in.
480
+ #
481
+ # This method leverages the reverse index to efficiently retrieve membership details
482
+ # across all collections without requiring expensive scans. For each membership,
483
+ # it provides collection metadata, membership details, and type-specific information
484
+ # like scores or positions.
485
+ #
486
+ # The method handles missing target objects gracefully and validates membership
487
+ # using the actual DataType collections to ensure accuracy.
488
+ #
489
+ # === Return Format
490
+ #
491
+ # Returns an array of hashes, each containing:
492
+ # - +:target_class+ - Name of the class owning the collection
493
+ # - +:target_id+ - Identifier of the specific target instance
494
+ # - +:collection_name+ - Name of the collection within the target
495
+ # - +:type+ - Collection type (:sorted_set, :set, :list)
496
+ #
497
+ # Additional fields based on collection type:
498
+ # - +:score+ - Current score (sorted_set only)
499
+ # - +:decoded_score+ - Human-readable score if decode_score method exists
500
+ # - +:position+ - Zero-based position in the list (list only)
501
+ #
502
+ # @return [Array<Hash>] Array of membership details with collection metadata
503
+ #
504
+ # @example Employee participating in multiple collections
505
+ # class Employee < Familia::Horreum
506
+ # field :name
507
+ # participates_in Department, :members, score: :hire_date
508
+ # participates_in Team, :contributors, score: :skill_level, type: :set
509
+ # participates_in Project, :assignees, score: :priority, type: :list
510
+ # end
511
+ #
512
+ # employee.add_to_department_members(engineering)
513
+ # employee.add_to_team_contributors(frontend_team)
514
+ # employee.add_to_project_assignees(mobile_project)
515
+ #
516
+ # # Query all memberships
517
+ # memberships = employee.current_participations
518
+ # # => [
519
+ # # {
520
+ # # target_class: "Department",
521
+ # # target_id: "engineering",
522
+ # # collection_name: :members,
523
+ # # type: :sorted_set,
524
+ # # score: 1640995200.0,
525
+ # # decoded_score: "2022-01-01 00:00:00 UTC"
526
+ # # },
527
+ # # {
528
+ # # target_class: "Team",
529
+ # # target_id: "frontend",
530
+ # # collection_name: :contributors,
531
+ # # type: :set
532
+ # # },
533
+ # # {
534
+ # # target_class: "Project",
535
+ # # target_id: "mobile",
536
+ # # collection_name: :assignees,
537
+ # # type: :list,
538
+ # # position: 2
539
+ # # }
540
+ # # ]
541
+ #
542
+ # @see #track_participation_in for reverse index management
543
+ # @see #calculate_participation_score for scoring details
544
+ # @since 1.0.0
545
+ def current_participations
546
+ return [] unless self.class.respond_to?(:participation_relationships)
547
+
548
+ # Use the reverse index as the single source of truth
549
+ collection_keys = participations.members
550
+ return [] if collection_keys.empty?
551
+
552
+ memberships = []
553
+
554
+ # Check membership in each tracked collection using DataType methods
555
+ collection_keys.each do |collection_key|
556
+ # Parse the collection key to extract target info
557
+ # Expected format: "targetclass:targetid:collectionname"
558
+ key_parts = collection_key.split(':')
559
+ next unless key_parts.length >= 3
560
+
561
+ target_class_config = key_parts[0]
562
+ target_id = key_parts[1]
563
+ collection_name_from_key = key_parts[2]
564
+
565
+ # Find the matching participation configuration
566
+ # Note: target_class_config from key is snake_case
567
+ config = self.class.participation_relationships.find do |cfg|
568
+ cfg.target_class_config_name == target_class_config &&
569
+ cfg.collection_name.to_s == collection_name_from_key
570
+ end
571
+
572
+ next unless config
573
+
574
+ # Find the target instance and check membership using Horreum DataTypes
575
+ begin
576
+ target_class = Familia.resolve_class(config.target_class)
577
+ target_instance = target_class.find_by_id(target_id)
578
+ next unless target_instance
579
+
580
+ # Use Horreum's DataType accessor to get the collection
581
+ collection = target_instance.send(config.collection_name)
582
+
583
+ # Check membership using DataType methods
584
+ membership_data = {
585
+ target_class: config.target_class.familia_name,
586
+ target_id: target_id,
587
+ collection_name: config.collection_name,
588
+ type: config.type,
589
+ }
590
+
591
+ case config.type
592
+ when :sorted_set
593
+ score = collection.score(identifier)
594
+ next unless score
595
+
596
+ membership_data[:score] = score
597
+ membership_data[:decoded_score] = decode_score(score) if respond_to?(:decode_score)
598
+ when :set
599
+ is_member = collection.member?(identifier)
600
+ next unless is_member
601
+ when :list
602
+ position = collection.to_a.index(identifier)
603
+ next unless position
604
+
605
+ membership_data[:position] = position
606
+ end
607
+
608
+ memberships << membership_data
609
+ rescue StandardError => e
610
+ Familia.ld "[#{collection_key}] Error checking membership: #{e.message}"
611
+ next
612
+ end
613
+ end
614
+
615
+ memberships
616
+ end
617
+
618
+ private
619
+
620
+ # Convert a raw value to an appropriate participation score.
621
+ #
622
+ # This private method handles the final conversion step for participation scores,
623
+ # providing robust type coercion and fallback behavior for edge cases. It's called
624
+ # by +calculate_participation_score+ after the scoring strategy has produced a raw value.
625
+ #
626
+ # The method never raises exceptions, always returning a valid Float value
627
+ # suitable for use in Valkey/Redis sorted sets. Invalid or missing values
628
+ # gracefully fall back to the +current_score+ method.
629
+ #
630
+ # === Conversion Strategy
631
+ #
632
+ # [Numeric types] Convert using +to_f+ for floating-point precision
633
+ # [Integer-like] Use +encode_score+ if available, otherwise convert to float
634
+ # [nil values] Fall back to +current_score+ method
635
+ # [Other types] Fall back to +current_score+ method
636
+ #
637
+ # @param value [Object] The raw value to convert to a participation score
638
+ # @return [Float] Converted score suitable for sorted set operations
639
+ # @api private
640
+ # @since 1.0.0
641
+ def convert_to_score(value)
642
+ return current_score if value.nil?
643
+
644
+ if value.respond_to?(:to_f)
645
+ value.to_f
646
+ elsif value.respond_to?(:to_i)
647
+ encode_score(value, 0)
648
+ else
649
+ current_score
650
+ end
651
+ end
652
+ end
653
+ end
654
+ end
655
+ end
656
+ end
@@ -0,0 +1,31 @@
1
+ # lib/familia/features/relationships/participation_relationship.rb
2
+
3
+ module Familia
4
+ module Features
5
+ module Relationships
6
+ #
7
+ # ParticipationRelationship
8
+ #
9
+ # Stores metadata about participation relationships defined at class level.
10
+ # Used to configure code generation and runtime behavior for participates_in
11
+ # and class_participates_in declarations.
12
+ #
13
+ ParticipationRelationship = Data.define(
14
+ :target_class, # Class object that owns the collection
15
+ :collection_name, # Symbol name of the collection (e.g., :members, :domains)
16
+ :score, # Proc/Symbol/nil - score calculator for sorted sets
17
+ :type, # Symbol - collection type (:sorted_set, :set, :list)
18
+ :bidirectional, # Boolean - whether to generate reverse methods
19
+ ) do
20
+ #
21
+ # Get the normalized config name for the target class
22
+ #
23
+ # @return [String] The config name (e.g., "user", "perf_test_customer")
24
+ #
25
+ def target_class_config_name
26
+ target_class.config_name
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end