familia 2.0.0.pre17 → 2.0.0.pre19

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 (249) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.rst +118 -6
  3. data/CLAUDE.md +43 -11
  4. data/Gemfile +2 -2
  5. data/Gemfile.lock +9 -47
  6. data/README.md +52 -0
  7. data/bin/irb +1 -1
  8. data/changelog.d/20251011_012003_delano_159_datatype_transaction_pipeline_support.rst +91 -0
  9. data/changelog.d/20251011_203905_delano_next.rst +30 -0
  10. data/changelog.d/20251011_212633_delano_next.rst +13 -0
  11. data/changelog.d/20251011_221253_delano_next.rst +26 -0
  12. data/docs/guides/core-field-system.md +48 -26
  13. data/docs/guides/feature-expiration.md +18 -18
  14. data/docs/migrating/v2.0.0-pre18.md +58 -0
  15. data/docs/migrating/v2.0.0-pre19.md +197 -0
  16. data/docs/qodo-merge-compliance.md +96 -0
  17. data/examples/datatype_standalone.rb +281 -0
  18. data/lib/familia/base.rb +0 -2
  19. data/lib/familia/connection/behavior.rb +252 -0
  20. data/lib/familia/connection/handlers.rb +95 -0
  21. data/lib/familia/connection/middleware.rb +58 -4
  22. data/lib/familia/connection/operation_core.rb +1 -1
  23. data/lib/familia/connection/{pipeline_core.rb → pipelined_core.rb} +2 -2
  24. data/lib/familia/connection/transaction_core.rb +7 -9
  25. data/lib/familia/connection.rb +2 -1
  26. data/lib/familia/data_type/connection.rb +151 -7
  27. data/lib/familia/data_type/{commands.rb → database_commands.rb} +9 -6
  28. data/lib/familia/data_type/serialization.rb +9 -5
  29. data/lib/familia/data_type/types/hashkey.rb +1 -1
  30. data/lib/familia/data_type.rb +2 -2
  31. data/lib/familia/encryption/encrypted_data.rb +12 -2
  32. data/lib/familia/encryption/manager.rb +11 -4
  33. data/lib/familia/errors.rb +51 -14
  34. data/lib/familia/features/autoloader.rb +3 -1
  35. data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +11 -3
  36. data/lib/familia/features/expiration/extensions.rb +8 -10
  37. data/lib/familia/features/expiration.rb +19 -19
  38. data/lib/familia/features/relationships/indexing/multi_index_generators.rb +45 -44
  39. data/lib/familia/features/relationships/indexing/unique_index_generators.rb +151 -65
  40. data/lib/familia/features/relationships/indexing.rb +37 -42
  41. data/lib/familia/features/relationships/indexing_relationship.rb +14 -4
  42. data/lib/familia/features/safe_dump.rb +2 -3
  43. data/lib/familia/field_type.rb +2 -1
  44. data/lib/familia/horreum/connection.rb +11 -35
  45. data/lib/familia/horreum/database_commands.rb +130 -11
  46. data/lib/familia/horreum/definition.rb +8 -38
  47. data/lib/familia/horreum/management.rb +38 -27
  48. data/lib/familia/horreum/persistence.rb +191 -67
  49. data/lib/familia/horreum/serialization.rb +94 -73
  50. data/lib/familia/horreum/utils.rb +0 -8
  51. data/lib/familia/horreum.rb +41 -18
  52. data/lib/familia/identifier_extractor.rb +60 -0
  53. data/lib/familia/logging.rb +268 -112
  54. data/lib/familia/refinements.rb +0 -1
  55. data/lib/familia/settings.rb +7 -7
  56. data/lib/familia/version.rb +1 -1
  57. data/lib/familia.rb +2 -2
  58. data/lib/middleware/{database_middleware.rb → database_logger.rb} +118 -14
  59. data/pr_agent.toml +31 -0
  60. data/pr_compliance_checklist.yaml +45 -0
  61. data/try/edge_cases/empty_identifiers_try.rb +1 -1
  62. data/try/edge_cases/hash_symbolization_try.rb +31 -31
  63. data/try/edge_cases/json_serialization_try.rb +2 -2
  64. data/try/edge_cases/legacy_data_detection/deserialization_edge_cases_try.rb +170 -0
  65. data/try/edge_cases/race_conditions_try.rb +1 -1
  66. data/try/edge_cases/reserved_keywords_try.rb +1 -1
  67. data/try/edge_cases/string_coercion_try.rb +5 -5
  68. data/try/edge_cases/ttl_side_effects_try.rb +1 -1
  69. data/try/features/encrypted_fields/aad_protection_try.rb +1 -1
  70. data/try/features/encrypted_fields/concealed_string_core_try.rb +1 -1
  71. data/try/features/encrypted_fields/context_isolation_try.rb +1 -1
  72. data/try/features/encrypted_fields/encrypted_fields_core_try.rb +1 -1
  73. data/try/features/encrypted_fields/encrypted_fields_integration_try.rb +1 -1
  74. data/try/features/encrypted_fields/encrypted_fields_no_cache_security_try.rb +1 -1
  75. data/try/features/encrypted_fields/encrypted_fields_security_try.rb +1 -1
  76. data/try/features/encrypted_fields/error_conditions_try.rb +1 -1
  77. data/try/features/encrypted_fields/fresh_key_derivation_try.rb +1 -1
  78. data/try/features/encrypted_fields/fresh_key_try.rb +1 -1
  79. data/try/features/encrypted_fields/key_rotation_try.rb +1 -1
  80. data/try/features/encrypted_fields/memory_security_try.rb +1 -1
  81. data/try/features/encrypted_fields/missing_current_key_version_try.rb +1 -1
  82. data/try/features/encrypted_fields/nonce_uniqueness_try.rb +1 -1
  83. data/try/features/encrypted_fields/secure_by_default_behavior_try.rb +1 -1
  84. data/try/features/encrypted_fields/thread_safety_try.rb +1 -1
  85. data/try/features/encrypted_fields/universal_serialization_safety_try.rb +1 -1
  86. data/try/{encryption → features/encryption}/config_persistence_try.rb +1 -1
  87. data/try/{encryption/encryption_core_try.rb → features/encryption/core_try.rb} +2 -2
  88. data/try/{encryption → features/encryption}/instance_variable_scope_try.rb +1 -1
  89. data/try/{encryption → features/encryption}/module_loading_try.rb +1 -1
  90. data/try/{encryption → features/encryption}/providers/aes_gcm_provider_try.rb +1 -1
  91. data/try/{encryption → features/encryption}/providers/xchacha20_poly1305_provider_try.rb +1 -1
  92. data/try/{encryption → features/encryption}/roundtrip_validation_try.rb +1 -1
  93. data/try/{encryption → features/encryption}/secure_memory_handling_try.rb +2 -2
  94. data/try/features/expiration/expiration_try.rb +2 -2
  95. data/try/features/external_identifier/external_identifier_try.rb +1 -1
  96. data/try/features/feature_dependencies_try.rb +1 -1
  97. data/try/features/feature_improvements_try.rb +1 -1
  98. data/try/features/object_identifier/object_identifier_integration_try.rb +1 -1
  99. data/try/features/object_identifier/object_identifier_try.rb +1 -1
  100. data/try/features/quantization/quantization_try.rb +1 -1
  101. data/try/features/real_feature_integration_try.rb +17 -14
  102. data/try/features/relationships/indexing_commands_verification_try.rb +8 -3
  103. data/try/features/relationships/indexing_try.rb +34 -5
  104. data/try/features/relationships/participation_commands_verification_spec.rb +1 -1
  105. data/try/features/relationships/participation_commands_verification_try.rb +4 -4
  106. data/try/features/relationships/participation_performance_improvements_try.rb +1 -1
  107. data/try/features/relationships/participation_reverse_index_try.rb +1 -1
  108. data/try/features/relationships/relationships_api_changes_try.rb +5 -5
  109. data/try/features/relationships/relationships_edge_cases_try.rb +3 -3
  110. data/try/features/relationships/relationships_performance_minimal_try.rb +1 -1
  111. data/try/features/relationships/relationships_performance_simple_try.rb +1 -1
  112. data/try/features/relationships/relationships_performance_try.rb +1 -1
  113. data/try/features/relationships/relationships_performance_working_try.rb +1 -1
  114. data/try/features/relationships/relationships_try.rb +1 -1
  115. data/try/features/safe_dump/safe_dump_advanced_try.rb +1 -1
  116. data/try/features/safe_dump/safe_dump_try.rb +1 -1
  117. data/try/features/transient_fields/redacted_string_try.rb +1 -1
  118. data/try/features/transient_fields/refresh_reset_try.rb +1 -1
  119. data/try/features/transient_fields/single_use_redacted_string_try.rb +1 -1
  120. data/try/features/transient_fields/transient_fields_core_try.rb +1 -1
  121. data/try/features/transient_fields/transient_fields_integration_try.rb +1 -1
  122. data/try/{connection → integration/connection}/fiber_context_preservation_try.rb +4 -4
  123. data/try/{connection → integration/connection}/handler_constraints_try.rb +1 -1
  124. data/try/{core → integration/connection}/isolated_dbclient_try.rb +1 -1
  125. data/try/integration/connection/middleware_reconnect_try.rb +87 -0
  126. data/try/{connection → integration/connection}/operation_mode_guards_try.rb +2 -2
  127. data/try/{connection → integration/connection}/pipeline_fallback_integration_try.rb +13 -13
  128. data/try/{core → integration/connection}/pools_try.rb +1 -1
  129. data/try/{connection → integration/connection}/responsibility_chain_tracking_try.rb +1 -1
  130. data/try/{connection → integration/connection}/transaction_fallback_integration_try.rb +1 -1
  131. data/try/{connection → integration/connection}/transaction_mode_permissive_try.rb +1 -1
  132. data/try/{connection → integration/connection}/transaction_mode_strict_try.rb +1 -1
  133. data/try/{connection → integration/connection}/transaction_mode_warn_try.rb +1 -1
  134. data/try/{connection → integration/connection}/transaction_modes_try.rb +1 -1
  135. data/try/{core → integration}/conventional_inheritance_try.rb +1 -1
  136. data/try/{core → integration}/create_method_try.rb +23 -23
  137. data/try/integration/cross_component_try.rb +1 -1
  138. data/try/integration/data_types/datatype_pipelines_try.rb +104 -0
  139. data/try/integration/data_types/datatype_transactions_try.rb +247 -0
  140. data/try/{core → integration}/database_consistency_try.rb +11 -8
  141. data/try/{core → integration}/familia_extended_try.rb +1 -1
  142. data/try/{core → integration}/familia_members_methods_try.rb +1 -1
  143. data/try/{models → integration/models}/customer_safe_dump_try.rb +6 -2
  144. data/try/{models → integration/models}/customer_try.rb +1 -1
  145. data/try/{models → integration/models}/datatype_base_try.rb +1 -1
  146. data/try/{models → integration/models}/familia_object_try.rb +2 -2
  147. data/try/{core → integration}/persistence_operations_try.rb +163 -11
  148. data/try/integration/relationships_persistence_round_trip_try.rb +441 -0
  149. data/try/{configuration → integration}/scenarios_try.rb +1 -1
  150. data/try/{core → integration}/secure_identifier_try.rb +1 -1
  151. data/try/{core → integration}/verifiable_identifier_try.rb +1 -1
  152. data/try/performance/benchmarks_try.rb +2 -2
  153. data/try/support/benchmarks/deserialization_benchmark.rb +180 -0
  154. data/try/support/benchmarks/deserialization_correctness_test.rb +237 -0
  155. data/try/{helpers → support/helpers}/test_helpers.rb +12 -3
  156. data/try/{core → unit/core}/autoloader_try.rb +1 -1
  157. data/try/{core → unit/core}/base_enhancements_try.rb +1 -9
  158. data/try/{core → unit/core}/connection_try.rb +1 -1
  159. data/try/{core → unit/core}/errors_try.rb +1 -1
  160. data/try/{core → unit/core}/extensions_try.rb +1 -1
  161. data/try/unit/core/familia_logger_try.rb +110 -0
  162. data/try/{core → unit/core}/familia_try.rb +1 -1
  163. data/try/{core → unit/core}/middleware_try.rb +41 -1
  164. data/try/{core → unit/core}/settings_try.rb +1 -1
  165. data/try/{core → unit/core}/time_utils_try.rb +1 -1
  166. data/try/{core → unit/core}/tools_try.rb +1 -1
  167. data/try/{core → unit/core}/utils_try.rb +17 -14
  168. data/try/{data_types → unit/data_types}/boolean_try.rb +2 -2
  169. data/try/{data_types → unit/data_types}/counter_try.rb +1 -1
  170. data/try/{data_types → unit/data_types}/datatype_base_try.rb +1 -1
  171. data/try/{data_types → unit/data_types}/hash_try.rb +1 -1
  172. data/try/{data_types → unit/data_types}/list_try.rb +1 -1
  173. data/try/{data_types → unit/data_types}/lock_try.rb +1 -1
  174. data/try/{data_types → unit/data_types}/sorted_set_try.rb +1 -1
  175. data/try/{data_types → unit/data_types}/sorted_set_zadd_options_try.rb +1 -1
  176. data/try/{data_types → unit/data_types}/string_try.rb +2 -2
  177. data/try/{data_types → unit/data_types}/unsortedset_try.rb +1 -1
  178. data/try/{horreum → unit/horreum}/auto_indexing_on_save_try.rb +33 -17
  179. data/try/unit/horreum/automatic_index_validation_try.rb +253 -0
  180. data/try/{horreum → unit/horreum}/base_try.rb +4 -4
  181. data/try/{horreum → unit/horreum}/class_methods_try.rb +3 -3
  182. data/try/{horreum → unit/horreum}/commands_try.rb +1 -1
  183. data/try/{horreum → unit/horreum}/defensive_initialization_try.rb +1 -1
  184. data/try/{horreum → unit/horreum}/destroy_related_fields_cleanup_try.rb +1 -1
  185. data/try/{horreum → unit/horreum}/enhanced_conflict_handling_try.rb +1 -1
  186. data/try/{horreum → unit/horreum}/field_categories_try.rb +27 -18
  187. data/try/{horreum → unit/horreum}/field_definition_try.rb +1 -1
  188. data/try/{horreum → unit/horreum}/initialization_try.rb +3 -3
  189. data/try/unit/horreum/json_type_preservation_try.rb +248 -0
  190. data/try/{horreum → unit/horreum}/relations_try.rb +5 -5
  191. data/try/{horreum → unit/horreum}/serialization_persistent_fields_try.rb +24 -18
  192. data/try/{horreum → unit/horreum}/serialization_try.rb +6 -6
  193. data/try/{horreum → unit/horreum}/settings_try.rb +1 -1
  194. data/try/unit/horreum/unique_index_edge_cases_try.rb +376 -0
  195. data/try/unit/horreum/unique_index_guard_validation_try.rb +281 -0
  196. data/try/{refinements → unit/refinements}/dear_json_array_methods_try.rb +1 -1
  197. data/try/{refinements → unit/refinements}/dear_json_hash_methods_try.rb +1 -1
  198. data/try/{refinements → unit/refinements}/time_literals_numeric_methods_try.rb +1 -1
  199. data/try/{refinements → unit/refinements}/time_literals_string_methods_try.rb +1 -1
  200. metadata +147 -126
  201. data/lib/familia/distinguisher.rb +0 -85
  202. data/lib/familia/refinements/logger_trace.rb +0 -60
  203. data/try/refinements/logger_trace_methods_try.rb +0 -44
  204. /data/try/{debugging → support/debugging}/README.md +0 -0
  205. /data/try/{debugging → support/debugging}/cache_behavior_tracer.rb +0 -0
  206. /data/try/{debugging → support/debugging}/debug_aad_process.rb +0 -0
  207. /data/try/{debugging → support/debugging}/debug_concealed_internal.rb +0 -0
  208. /data/try/{debugging → support/debugging}/debug_concealed_reveal.rb +0 -0
  209. /data/try/{debugging → support/debugging}/debug_context_aad.rb +0 -0
  210. /data/try/{debugging → support/debugging}/debug_context_simple.rb +0 -0
  211. /data/try/{debugging → support/debugging}/debug_cross_context.rb +0 -0
  212. /data/try/{debugging → support/debugging}/debug_database_load.rb +0 -0
  213. /data/try/{debugging → support/debugging}/debug_encrypted_json_check.rb +0 -0
  214. /data/try/{debugging → support/debugging}/debug_encrypted_json_step_by_step.rb +0 -0
  215. /data/try/{debugging → support/debugging}/debug_exists_lifecycle.rb +0 -0
  216. /data/try/{debugging → support/debugging}/debug_field_decrypt.rb +0 -0
  217. /data/try/{debugging → support/debugging}/debug_fresh_cross_context.rb +0 -0
  218. /data/try/{debugging → support/debugging}/debug_load_path.rb +0 -0
  219. /data/try/{debugging → support/debugging}/debug_method_definition.rb +0 -0
  220. /data/try/{debugging → support/debugging}/debug_method_resolution.rb +0 -0
  221. /data/try/{debugging → support/debugging}/debug_minimal.rb +0 -0
  222. /data/try/{debugging → support/debugging}/debug_provider.rb +0 -0
  223. /data/try/{debugging → support/debugging}/debug_secure_behavior.rb +0 -0
  224. /data/try/{debugging → support/debugging}/debug_string_class.rb +0 -0
  225. /data/try/{debugging → support/debugging}/debug_test.rb +0 -0
  226. /data/try/{debugging → support/debugging}/debug_test_design.rb +0 -0
  227. /data/try/{debugging → support/debugging}/encryption_method_tracer.rb +0 -0
  228. /data/try/{debugging → support/debugging}/provider_diagnostics.rb +0 -0
  229. /data/try/{helpers → support/helpers}/test_cleanup.rb +0 -0
  230. /data/try/{memory → support/memory}/memory_basic_test.rb +0 -0
  231. /data/try/{memory → support/memory}/memory_detailed_test.rb +0 -0
  232. /data/try/{memory → support/memory}/memory_docker_ruby_dump.sh +0 -0
  233. /data/try/{memory → support/memory}/memory_search_for_string.rb +0 -0
  234. /data/try/{memory → support/memory}/test_actual_redactedstring_protection.rb +0 -0
  235. /data/try/{prototypes → support/prototypes}/atomic_saves_v1_context_proxy.rb +0 -0
  236. /data/try/{prototypes → support/prototypes}/atomic_saves_v2_connection_switching.rb +0 -0
  237. /data/try/{prototypes → support/prototypes}/atomic_saves_v3_connection_pool.rb +0 -0
  238. /data/try/{prototypes → support/prototypes}/atomic_saves_v4.rb +0 -0
  239. /data/try/{prototypes → support/prototypes}/lib/atomic_saves_v2_connection_switching_helpers.rb +0 -0
  240. /data/try/{prototypes → support/prototypes}/lib/atomic_saves_v3_connection_pool_helpers.rb +0 -0
  241. /data/try/{prototypes → support/prototypes}/pooling/README.md +0 -0
  242. /data/try/{prototypes → support/prototypes}/pooling/configurable_stress_test.rb +0 -0
  243. /data/try/{prototypes → support/prototypes}/pooling/lib/atomic_saves_v3_connection_pool_helpers.rb +0 -0
  244. /data/try/{prototypes → support/prototypes}/pooling/lib/connection_pool_metrics.rb +0 -0
  245. /data/try/{prototypes → support/prototypes}/pooling/lib/connection_pool_stress_test.rb +0 -0
  246. /data/try/{prototypes → support/prototypes}/pooling/lib/connection_pool_threading_models.rb +0 -0
  247. /data/try/{prototypes → support/prototypes}/pooling/lib/visualize_stress_results.rb +0 -0
  248. /data/try/{prototypes → support/prototypes}/pooling/pool_siege.rb +0 -0
  249. /data/try/{prototypes → support/prototypes}/pooling/run_stress_tests.rb +0 -0
@@ -49,11 +49,11 @@ module Familia
49
49
  # @param indexed_class [Class] The class being indexed (e.g., Employee)
50
50
  # @param field [Symbol] The field to index
51
51
  # @param index_name [Symbol] Name of the index
52
- # @param within [Class, Symbol, nil] Parent class for instance-scoped index
52
+ # @param within [Class, Symbol, nil] Scope class for instance-scoped index
53
53
  # @param query [Boolean] Whether to generate query methods
54
54
  def setup(indexed_class:, field:, index_name:, within:, query:)
55
55
  # Normalize parameters and determine scope type
56
- target_class, scope_type = if within
56
+ scope_class, scope_type = if within
57
57
  k = Familia.resolve_class(within)
58
58
  [k, :instance]
59
59
  else
@@ -63,7 +63,8 @@ module Familia
63
63
  # Store metadata for this indexing relationship
64
64
  indexed_class.indexing_relationships << IndexingRelationship.new(
65
65
  field: field,
66
- target_class: target_class,
66
+ scope_class: scope_class,
67
+ within: within,
67
68
  index_name: index_name,
68
69
  query: query,
69
70
  cardinality: :unique,
@@ -73,10 +74,10 @@ module Familia
73
74
  case scope_type
74
75
  when :instance
75
76
  # Instance-scoped index (within: Company)
76
- if query && target_class.is_a?(Class)
77
- generate_query_methods_destination(indexed_class, field, target_class, index_name)
77
+ if query && scope_class.is_a?(Class)
78
+ generate_query_methods_destination(indexed_class, field, scope_class, index_name)
78
79
  end
79
- generate_mutation_methods_self(indexed_class, field, target_class, index_name)
80
+ generate_mutation_methods_self(indexed_class, field, scope_class, index_name)
80
81
  when :class
81
82
  # Class-level index (no within:)
82
83
  indexed_class.send(:ensure_index_field, indexed_class, index_name, :class_hashkey)
@@ -85,7 +86,8 @@ module Familia
85
86
  end
86
87
  end
87
88
 
88
- # Generates query methods ON THE PARENT CLASS (Company when within: Company):
89
+ # Generates query methods ON THE SCOPE CLASS (Company when within: Company)
90
+ #
89
91
  # - company.find_by_badge_number(badge) - find by field value
90
92
  # - company.find_all_by_badge_number([badges]) - batch lookup
91
93
  # - company.badge_index - DataType accessor
@@ -93,47 +95,57 @@ module Familia
93
95
  #
94
96
  # @param indexed_class [Class] The class being indexed (e.g., Employee)
95
97
  # @param field [Symbol] The field to index (e.g., :badge_number)
96
- # @param target_class [Class] The parent class (e.g., Company)
98
+ # @param scope_class [Class] The scope class providing uniqueness context (e.g., Company)
97
99
  # @param index_name [Symbol] Name of the index (e.g., :badge_index)
98
- def generate_query_methods_destination(indexed_class, field, target_class, index_name)
99
- # Resolve target class using Familia pattern
100
- actual_target_class = Familia.resolve_class(target_class)
100
+ def generate_query_methods_destination(indexed_class, field, scope_class, index_name)
101
+ # Resolve scope class using Familia pattern
102
+ actual_scope_class = Familia.resolve_class(scope_class)
101
103
 
102
104
  # Ensure the index field is declared (creates accessor that returns DataType)
103
- actual_target_class.send(:ensure_index_field, actual_target_class, index_name, :hashkey)
105
+ actual_scope_class.send(:ensure_index_field, actual_scope_class, index_name, :hashkey)
104
106
 
105
107
  # Generate instance query method (e.g., company.find_by_badge_number)
106
- actual_target_class.class_eval do
107
- define_method("find_by_#{field}") do |field_value|
108
+ actual_scope_class.class_eval do
109
+ define_method(:"find_by_#{field}") do |provided_value|
108
110
  # Use declared field accessor instead of manual instantiation
109
111
  index_hash = send(index_name)
110
112
 
111
- # Get the identifier from the hash
112
- object_id = index_hash[field_value.to_s]
113
- return nil unless object_id
113
+ # Get the identifier from the hash using .get method.
114
+ # We use .get instead of [] because it's part of the standard interface
115
+ # common across all DataType classes (List, UnsortedSet, SortedSet, HashKey).
116
+ # While unique indexes always use HashKey, using .get maintains consistency
117
+ # with the broader DataType API patterns used throughout Familia.
118
+ record_id = index_hash.get(provided_value)
119
+ return nil unless record_id
114
120
 
115
- indexed_class.new(object_id)
121
+ indexed_class.find_by_identifier(record_id)
116
122
  end
117
123
 
118
124
  # Generate bulk query method (e.g., company.find_all_by_badge_number)
119
- define_method("find_all_by_#{field}") do |field_values|
120
- field_values = Array(field_values)
121
- return [] if field_values.empty?
125
+ define_method(:"find_all_by_#{field}") do |provided_ids|
126
+ # Convert to array and filter nil inputs before querying Redis.
127
+ # This prevents wasteful lookups for empty string keys (nil.to_s → "").
128
+ # Output may contain fewer elements than input (standard ORM behavior).
129
+ provided_ids = Array(provided_ids).compact
130
+ return [] if provided_ids.empty?
122
131
 
123
132
  # Use declared field accessor instead of manual instantiation
124
133
  index_hash = send(index_name)
125
134
 
126
135
  # Get all identifiers from the hash
127
- object_ids = index_hash.values_at(*field_values.map(&:to_s))
128
- # Filter out nil values and instantiate objects
129
- object_ids.compact.map { |object_id| indexed_class.new(object_id) }
136
+ record_ids = index_hash.values_at(*provided_ids.map(&:to_s))
137
+
138
+ # Filter out nil values (non-existent records) and instantiate objects
139
+ record_ids.compact.map { |record_id|
140
+ indexed_class.find_by_identifier(record_id)
141
+ }
130
142
  end
131
143
 
132
144
  # Accessor method already created by ensure_index_field above
133
145
  # No need to manually define it here
134
146
 
135
147
  # Generate method to rebuild the unique index for this parent instance
136
- define_method("rebuild_#{index_name}") do
148
+ define_method(:"rebuild_#{index_name}") do
137
149
  # Use declared field accessor instead of manual instantiation
138
150
  index_hash = send(index_name)
139
151
 
@@ -147,63 +159,102 @@ module Familia
147
159
  end
148
160
  end
149
161
 
150
- # Generates mutation methods ON THE INDEXED CLASS (Employee):
151
- # Instance methods for parent-scoped unique index operations:
152
- # - employee.add_to_company_badge_index(company)
162
+ # Generates mutation methods ON THE INDEXED CLASS (Employee)
163
+ #
164
+ # Instance methods for scope-scoped unique index operations:
165
+ # - employee.add_to_company_badge_index(company) - automatically validates uniqueness
153
166
  # - employee.remove_from_company_badge_index(company)
154
167
  # - employee.update_in_company_badge_index(company, old_badge)
168
+ # - employee.guard_unique_company_badge_index!(company) - manual validation
155
169
  #
156
170
  # @param indexed_class [Class] The class being indexed (e.g., Employee)
157
171
  # @param field [Symbol] The field to index (e.g., :badge_number)
158
- # @param target_class [Class] The parent class (e.g., Company)
172
+ # @param scope_class [Class] The scope class providing uniqueness context (e.g., Company)
159
173
  # @param index_name [Symbol] Name of the index (e.g., :badge_index)
160
- def generate_mutation_methods_self(indexed_class, field, target_class, index_name)
161
- target_class_config = target_class.config_name
174
+ def generate_mutation_methods_self(indexed_class, field, scope_class, index_name)
175
+ scope_class_config = scope_class.config_name
162
176
  indexed_class.class_eval do
163
- method_name = "add_to_#{target_class_config}_#{index_name}"
177
+ method_name = :"add_to_#{scope_class_config}_#{index_name}"
164
178
  Familia.ld("[UniqueIndexGenerators] #{name} method #{method_name}")
165
179
 
166
- define_method(method_name) do |target_instance|
167
- return unless target_instance
180
+ define_method(method_name) do |scope_instance|
181
+ return unless scope_instance
168
182
 
169
183
  field_value = send(field)
170
184
  return unless field_value
171
185
 
172
- # Use declared field accessor on target instance
173
- index_hash = target_instance.send(index_name)
186
+ # Automatically validate uniqueness before adding to index.
187
+ # Skip validation inside transactions since guard methods require read
188
+ # operations not available in MULTI/EXEC blocks.
189
+ unless Fiber[:familia_transaction]
190
+ guard_method = :"guard_unique_#{scope_class_config}_#{index_name}!"
191
+ send(guard_method, scope_instance) if respond_to?(guard_method)
192
+ end
193
+
194
+ # Use declared field accessor on scope instance
195
+ index_hash = scope_instance.send(index_name)
174
196
 
175
- # Use HashKey DataType method
197
+ # Set the value (guard already validated uniqueness)
176
198
  index_hash[field_value.to_s] = identifier
177
199
  end
178
200
 
179
- method_name = "remove_from_#{target_class_config}_#{index_name}"
201
+ # Add a guard method to enforce unique constraint on this instance-scoped index
202
+ #
203
+ # @param scope_instance [Object] The scope instance providing uniqueness context (e.g., a Company)
204
+ # @raise [Familia::RecordExistsError] if a record with the same field value
205
+ # exists in the scope's index. Values are compared as strings.
206
+ # @return [void]
207
+ #
208
+ # @example
209
+ # employee.guard_unique_company_badge_index!(company)
210
+ #
211
+ method_name = :"guard_unique_#{scope_class_config}_#{index_name}!"
180
212
  Familia.ld("[UniqueIndexGenerators] #{name} method #{method_name}")
181
213
 
182
- define_method(method_name) do |target_instance|
183
- return unless target_instance
214
+ define_method(method_name) do |scope_instance|
215
+ return unless scope_instance
184
216
 
185
217
  field_value = send(field)
186
218
  return unless field_value
187
219
 
188
- # Use declared field accessor on target instance
189
- index_hash = target_instance.send(index_name)
220
+ # Use declared field accessor on scope instance
221
+ index_hash = scope_instance.send(index_name)
222
+ existing_id = index_hash.get(field_value.to_s)
223
+
224
+ if existing_id && existing_id != identifier
225
+ raise Familia::RecordExistsError,
226
+ "#{self.class} exists in #{scope_instance.class} with #{field}=#{field_value}"
227
+ end
228
+ end
229
+
230
+ method_name = :"remove_from_#{scope_class_config}_#{index_name}"
231
+ Familia.ld("[UniqueIndexGenerators] #{name} method #{method_name}")
232
+
233
+ define_method(method_name) do |scope_instance|
234
+ return unless scope_instance
235
+
236
+ field_value = send(field)
237
+ return unless field_value
238
+
239
+ # Use declared field accessor on scope instance
240
+ index_hash = scope_instance.send(index_name)
190
241
 
191
242
  # Remove using HashKey DataType method
192
243
  index_hash.remove(field_value.to_s)
193
244
  end
194
245
 
195
- method_name = "update_in_#{target_class_config}_#{index_name}"
246
+ method_name = :"update_in_#{scope_class_config}_#{index_name}"
196
247
  Familia.ld("[UniqueIndexGenerators] #{name} method #{method_name}")
197
248
 
198
- define_method(method_name) do |target_instance, old_field_value = nil|
199
- return unless target_instance
249
+ define_method(method_name) do |scope_instance, old_field_value = nil|
250
+ return unless scope_instance
200
251
 
201
252
  new_field_value = send(field)
202
253
 
203
254
  # Use Familia's transaction method for atomicity with DataType abstraction
204
- target_instance.transaction do |_tx|
205
- # Use declared field accessor on target instance
206
- index_hash = target_instance.send(index_name)
255
+ scope_instance.transaction do |_tx|
256
+ # Use declared field accessor on scope instance
257
+ index_hash = scope_instance.send(index_name)
207
258
 
208
259
  # Remove old value if provided
209
260
  index_hash.remove(old_field_value.to_s) if old_field_value
@@ -222,31 +273,47 @@ module Familia
222
273
  # - Employee.email_index
223
274
  # - Employee.rebuild_email_index
224
275
  def generate_query_methods_class(field, index_name, indexed_class)
225
- indexed_class.define_singleton_method("find_by_#{field}") do |field_value|
226
- index_hash = send(index_name) # Access the class-level hashkey DataType
227
- object_id = index_hash[field_value.to_s]
276
+ # Generate class-level single record method
277
+ indexed_class.define_singleton_method(:"find_by_#{field}") do |provided_id|
278
+ index_hash = send(index_name) # access the class-level hashkey DataType
279
+
280
+ # Get the identifier from the db hashkey using .get method.
281
+ #
282
+ # We use .get instead of [] because it's part of the standard interface
283
+ # common across all DataType classes (List, UnsortedSet, SortedSet, HashKey).
284
+ # While unique indexes always use HashKey, using .get maintains consistency
285
+ # with the broader DataType API patterns used throughout Familia.
286
+ record_id = index_hash.get(provided_id)
228
287
 
229
- return nil unless object_id
288
+ return nil unless record_id
230
289
 
231
- new(object_id)
290
+ indexed_class.find_by_identifier(record_id)
232
291
  end
233
292
 
234
293
  # Generate class-level bulk query method
235
- indexed_class.define_singleton_method("find_all_by_#{field}") do |field_values|
236
- field_values = Array(field_values)
237
- return [] if field_values.empty?
238
-
239
- index_hash = send(index_name) # Access the class-level hashkey DataType
240
- object_ids = index_hash.values_at(*field_values.map(&:to_s))
241
- # Filter out nil values and instantiate objects
242
- object_ids.compact.map { |object_id| new(object_id) }
294
+ indexed_class.define_singleton_method(:"find_all_by_#{field}") do |provided_ids|
295
+ # Convert to array and filter nil inputs before querying Redis.
296
+ # This prevents wasteful lookups for empty string keys (nil.to_s → "").
297
+ # Output may contain fewer elements than input (standard ORM behavior).
298
+ provided_ids = Array(provided_ids).compact
299
+ return [] if provided_ids.empty?
300
+
301
+ index_hash = send(index_name) # access the class-level hashkey DataType
302
+
303
+ # Get multiple identifiers from the db hashkey using .values_at
304
+ record_ids = index_hash.values_at(*provided_ids.map(&:to_s))
305
+
306
+ # Filter out nil values (non-existent records) and instantiate objects
307
+ record_ids.compact.map { |record_id|
308
+ indexed_class.find_by_identifier(record_id)
309
+ }
243
310
  end
244
311
 
245
312
  # The index accessor method is already created by the class_hashkey declaration
246
313
  # No need to manually create it - Horreum handles this automatically
247
314
 
248
315
  # Generate method to rebuild the class-level index
249
- indexed_class.define_singleton_method("rebuild_#{index_name}") do
316
+ indexed_class.define_singleton_method(:"rebuild_#{index_name}") do
250
317
  index_hash = send(index_name) # Access the class-level hashkey DataType
251
318
 
252
319
  # Clear existing index using DataType method
@@ -265,16 +332,35 @@ module Familia
265
332
  # - employee.update_in_class_email_index(old_email)
266
333
  def generate_mutation_methods_class(field, index_name, indexed_class)
267
334
  indexed_class.class_eval do
268
- define_method("add_to_class_#{index_name}") do
335
+ define_method(:"add_to_class_#{index_name}") do
269
336
  index_hash = self.class.send(index_name) # Access the class-level hashkey DataType
270
337
  field_value = send(field)
271
338
 
272
339
  return unless field_value
273
340
 
341
+ # Just set the value - uniqueness should be validated before save
274
342
  index_hash[field_value.to_s] = identifier
275
343
  end
276
344
 
277
- define_method("remove_from_class_#{index_name}") do
345
+ # Add a guard method to enforce unique constraint on this specific index
346
+ #
347
+ # @raise [Familia::RecordExistsError] if a record with the same
348
+ # field value exists. Values are compared as strings.
349
+ #
350
+ # @return [void]
351
+ define_method(:"guard_unique_#{index_name}!") do
352
+ field_value = send(field)
353
+ return unless field_value
354
+
355
+ index_hash = self.class.send(index_name)
356
+ existing_id = index_hash.get(field_value.to_s)
357
+
358
+ if existing_id && existing_id != identifier
359
+ raise Familia::RecordExistsError, "#{self.class} exists #{field}=#{field_value}"
360
+ end
361
+ end
362
+
363
+ define_method(:"remove_from_class_#{index_name}") do
278
364
  index_hash = self.class.send(index_name) # Access the class-level hashkey DataType
279
365
  field_value = send(field)
280
366
 
@@ -283,7 +369,7 @@ module Familia
283
369
  index_hash.remove(field_value.to_s)
284
370
  end
285
371
 
286
- define_method("update_in_class_#{index_name}") do |old_field_value = nil|
372
+ define_method(:"update_in_class_#{index_name}") do |old_field_value = nil|
287
373
  new_field_value = send(field)
288
374
 
289
375
  # Use class-level transaction for atomicity with DataType abstraction
@@ -50,7 +50,7 @@ module Familia
50
50
  # Terminology:
51
51
  # - unique_index: 1:1 field-to-object mapping (HashKey)
52
52
  # - multi_index: 1:many field-to-objects mapping (UnsortedSet, no scores)
53
- # - within: parent class for instance-scoped indexes
53
+ # - within: scope class providing uniqueness boundary for instance-scoped indexes
54
54
  # - query: whether to generate find_by_* methods (default: true)
55
55
  #
56
56
  # Key Patterns:
@@ -89,7 +89,7 @@ module Familia
89
89
  #
90
90
  # @param field [Symbol] The field to index on
91
91
  # @param index_name [Symbol] Name of the index
92
- # @param within [Class, Symbol] The parent class that owns the index
92
+ # @param within [Class, Symbol] The scope class providing uniqueness context
93
93
  # @param query [Boolean] Whether to generate query methods
94
94
  #
95
95
  # @example Instance-scoped multi-value indexing
@@ -109,7 +109,7 @@ module Familia
109
109
  #
110
110
  # @param field [Symbol] The field to index on
111
111
  # @param index_name [Symbol] Name of the index hash
112
- # @param within [Class, Symbol] Optional parent class for instance-scoped unique index
112
+ # @param within [Class, Symbol] Optional scope class for instance-scoped unique index
113
113
  # @param query [Boolean] Whether to generate query methods
114
114
  #
115
115
  # @example Class-level unique index
@@ -136,70 +136,68 @@ module Familia
136
136
 
137
137
  # Ensure proper DataType field is declared for index
138
138
  # Similar to ensure_collection_field in participation system
139
- def ensure_index_field(target_class, index_name, field_type)
140
- return if target_class.method_defined?(index_name) || target_class.respond_to?(index_name)
139
+ def ensure_index_field(scope_class, index_name, field_type)
140
+ return if scope_class.method_defined?(index_name) || scope_class.respond_to?(index_name)
141
141
 
142
- target_class.send(field_type, index_name)
142
+ scope_class.send(field_type, index_name)
143
143
  end
144
144
  end
145
145
 
146
146
  # Instance methods for indexed objects
147
147
  module ModelInstanceMethods
148
- # Update all indexes for a given parent context
149
- # For class-level indexes (class_indexed_by), parent_context should be nil
150
- # For relationship indexes (indexed_by), parent_context should be the parent instance
151
- def update_all_indexes(old_values = {}, parent_context = nil)
148
+ # Update all indexes for a given scope context
149
+ # For class-level indexes (unique_index without within:), scope_context should be nil
150
+ # For instance-scoped indexes (with within:), scope_context should be the scope instance
151
+ def update_all_indexes(old_values = {}, scope_context = nil)
152
152
  return unless self.class.respond_to?(:indexing_relationships)
153
153
 
154
154
  self.class.indexing_relationships.each do |config|
155
155
  field = config.field
156
156
  index_name = config.index_name
157
- target_class = config.target_class
158
157
  old_field_value = old_values[field]
159
158
 
160
159
  # Determine which update method to call
161
- if target_class == self.class
160
+ if config.within.nil?
162
161
  # Class-level index (unique_index without within:)
163
162
  send("update_in_class_#{index_name}", old_field_value)
164
163
  else
165
- # Relationship index (unique_index or multi_index with within:) - requires parent context
166
- next unless parent_context
164
+ # Instance-scoped index (unique_index or multi_index with within:) - requires scope context
165
+ next unless scope_context
167
166
 
168
167
  # Use config_name for method naming
169
- target_class_config = Familia.resolve_class(config.target_class).config_name
170
- send("update_in_#{target_class_config}_#{index_name}", parent_context, old_field_value)
168
+ scope_class_config = Familia.resolve_class(config.scope_class).config_name
169
+ send("update_in_#{scope_class_config}_#{index_name}", scope_context, old_field_value)
171
170
  end
172
171
  end
173
172
  end
174
173
 
175
- # Remove from all indexes for a given parent context
176
- # For class-level indexes (class_indexed_by), parent_context should be nil
177
- # For relationship indexes (indexed_by), parent_context should be the parent instance
178
- def remove_from_all_indexes(parent_context = nil)
174
+ # Remove from all indexes for a given scope context
175
+ # For class-level indexes (unique_index without within:), scope_context should be nil
176
+ # For instance-scoped indexes (with within:), scope_context should be the scope instance
177
+ def remove_from_all_indexes(scope_context = nil)
179
178
  return unless self.class.respond_to?(:indexing_relationships)
180
179
 
181
180
  self.class.indexing_relationships.each do |config|
182
181
  index_name = config.index_name
183
- target_class = config.target_class
184
182
 
185
183
  # Determine which remove method to call
186
- if target_class == self.class
184
+ if config.within.nil?
187
185
  # Class-level index (unique_index without within:)
188
186
  send("remove_from_class_#{index_name}")
189
187
  else
190
- # Relationship index (unique_index or multi_index with within:) - requires parent context
191
- next unless parent_context
188
+ # Instance-scoped index (unique_index or multi_index with within:) - requires scope context
189
+ next unless scope_context
192
190
 
193
191
  # Use config_name for method naming
194
- target_class_config = Familia.resolve_class(config.target_class).config_name
195
- send("remove_from_#{target_class_config}_#{index_name}", parent_context)
192
+ scope_class_config = Familia.resolve_class(config.scope_class).config_name
193
+ send("remove_from_#{scope_class_config}_#{index_name}", scope_context)
196
194
  end
197
195
  end
198
196
  end
199
197
 
200
198
  # Get all indexes this object appears in
201
- # Note: For target-scoped indexes, this only shows class-level indexes
202
- # since target-scoped indexes require a specific target instance
199
+ # Note: For instance-scoped indexes, this only shows class-level indexes
200
+ # since instance-scoped indexes require a specific scope instance
203
201
  #
204
202
  # @return [Array<Hash>] Array of index information
205
203
  def current_indexings
@@ -210,19 +208,18 @@ module Familia
210
208
  self.class.indexing_relationships.each do |config|
211
209
  field = config.field
212
210
  index_name = config.index_name
213
- target_class = config.target_class
214
211
  cardinality = config.cardinality
215
212
  field_value = send(field)
216
213
 
217
214
  next unless field_value
218
215
 
219
- if target_class == self.class
216
+ if config.within.nil?
220
217
  # Class-level index (unique_index without within:) - check hash key using DataType
221
218
  index_hash = self.class.send(index_name)
222
219
  next unless index_hash.key?(field_value.to_s)
223
220
 
224
221
  memberships << {
225
- target_class: 'class',
222
+ scope_class: 'class',
226
223
  index_name: index_name,
227
224
  field: field,
228
225
  field_value: field_value,
@@ -231,17 +228,17 @@ module Familia
231
228
  type: 'unique_index',
232
229
  }
233
230
  else
234
- # Instance-scoped index (unique_index or multi_index with within:) - cannot check without target instance
235
- # This would require scanning all possible target instances
231
+ # Instance-scoped index (unique_index or multi_index with within:) - cannot check without scope instance
232
+ # This would require scanning all possible scope instances
236
233
  memberships << {
237
- target_class: config.target_class_config_name,
234
+ scope_class: config.scope_class_config_name,
238
235
  index_name: index_name,
239
236
  field: field,
240
237
  field_value: field_value,
241
- index_key: 'target_dependent',
238
+ index_key: 'scope_dependent',
242
239
  cardinality: cardinality,
243
240
  type: cardinality == :unique ? 'unique_index' : 'multi_index',
244
- note: 'Requires target instance for verification',
241
+ note: 'Requires scope instance for verification',
245
242
  }
246
243
  end
247
244
  end
@@ -249,9 +246,9 @@ module Familia
249
246
  memberships
250
247
  end
251
248
 
252
- # Check if this object is indexed in a specific target
249
+ # Check if this object is indexed in a specific scope
253
250
  # For class-level indexes, checks the hash key
254
- # For target-scoped indexes, returns false (requires target instance)
251
+ # For instance-scoped indexes, returns false (requires scope instance)
255
252
  def indexed_in?(index_name)
256
253
  return false unless self.class.respond_to?(:indexing_relationships)
257
254
 
@@ -262,14 +259,12 @@ module Familia
262
259
  field_value = send(field)
263
260
  return false unless field_value
264
261
 
265
- target_class = config.target_class
266
-
267
- if target_class == self.class
262
+ if config.within.nil?
268
263
  # Class-level index (class_indexed_by) - check hash key using DataType
269
264
  index_hash = self.class.send(index_name)
270
265
  index_hash.key?(field_value.to_s)
271
266
  else
272
- # Target-scoped index (indexed_by) - cannot verify without target instance
267
+ # Instance-scoped index (with within:) - cannot verify without scope instance
273
268
  false
274
269
  end
275
270
  end
@@ -14,20 +14,30 @@ module Familia
14
14
  # Similar to ParticipationRelationship but for attribute-based lookups
15
15
  # rather than collection membership.
16
16
  #
17
+ # Terminology:
18
+ # - `scope_class`: The class that provides the uniqueness boundary for
19
+ # instance-scoped indexes. For example, in `unique_index :badge_number,
20
+ # :badge_index, within: Company`, the Company is the scope class.
21
+ # - `within`: Preserves the original DSL parameter to explicitly distinguish
22
+ # class-level indexes (within: nil) from instance-scoped indexes (within:
23
+ # SomeClass). This avoids brittle class comparisons and prevents issues
24
+ # with inheritance scenarios.
25
+ #
17
26
  IndexingRelationship = Data.define(
18
27
  :field, # Symbol - field being indexed (e.g., :email, :department)
19
28
  :index_name, # Symbol - name of the index (e.g., :email_index, :dept_index)
20
- :target_class, # Class/Symbol - parent class for instance-scoped indexes (within:)
29
+ :scope_class, # Class/Symbol - scope class for instance-scoped indexes (within:)
30
+ :within, # Class/Symbol/nil - within: parameter (nil for class-level, Class for instance-scoped)
21
31
  :cardinality, # Symbol - :unique (1:1) or :multi (1:many)
22
32
  :query # Boolean - whether to generate query methods
23
33
  ) do
24
34
  #
25
- # Get the normalized config name for the target class
35
+ # Get the normalized config name for the scope class
26
36
  #
27
37
  # @return [String] The config name (e.g., "user", "company", "test_company")
28
38
  #
29
- def target_class_config_name
30
- target_class.config_name
39
+ def scope_class_config_name
40
+ scope_class.config_name
31
41
  end
32
42
  end
33
43
  end
@@ -1,9 +1,8 @@
1
1
  # lib/familia/features/safe_dump.rb
2
2
 
3
3
  #
4
- # Class instance variables are used here for feature configuration
5
- # (e.g., @dump_method, @load_method). These are set once and not mutated
6
- # at runtime, so thread safety is not a concern for this feature.
4
+ # Class instance variables are used here for configuration. These are set
5
+ # once at loadtime and not mutated, so thread safety is not an issue here.
7
6
  #
8
7
  module Familia
9
8
  module Features
@@ -143,7 +143,8 @@ module Familia
143
143
  val = args.first
144
144
 
145
145
  # If no value provided, return current stored value
146
- return hget(field_name) if val.nil?
146
+ # Handle Redis::Future objects during transactions
147
+ return hget(field_name) if val.nil? || val.is_a?(Redis::Future)
147
148
 
148
149
  begin
149
150
  # Trace the operation if debugging is enabled