familia 2.0.0.pre15 → 2.0.0.pre17
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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +2 -2
- data/.github/workflows/code-quality.yml +138 -0
- data/.github/workflows/code-smells.yml +85 -0
- data/.github/workflows/docs.yml +31 -8
- data/.gitignore +3 -1
- data/.pre-commit-config.yaml +7 -1
- data/.reek.yml +98 -0
- data/.rubocop.yml +54 -10
- data/.talismanrc +9 -0
- data/.yardopts +18 -13
- data/CHANGELOG.rst +86 -4
- data/CLAUDE.md +39 -1
- data/Gemfile +6 -5
- data/Gemfile.lock +99 -23
- data/LICENSE.txt +1 -1
- data/README.md +285 -85
- data/changelog.d/README.md +2 -2
- data/docs/archive/FAMILIA_RELATIONSHIPS.md +22 -22
- data/docs/archive/FAMILIA_TECHNICAL.md +42 -42
- data/docs/archive/FAMILIA_UPDATE.md +3 -3
- data/docs/archive/README.md +3 -2
- data/docs/{guides/API-Reference.md → archive/api-reference.md} +87 -101
- data/docs/conf.py +29 -0
- data/docs/guides/{Field-System-Guide.md → core-field-system.md} +9 -9
- data/docs/guides/feature-encrypted-fields.md +785 -0
- data/docs/guides/{Expiration-Feature-Guide.md → feature-expiration.md} +11 -2
- data/docs/guides/feature-external-identifiers.md +637 -0
- data/docs/guides/feature-object-identifiers.md +435 -0
- data/docs/guides/{Quantization-Feature-Guide.md → feature-quantization.md} +94 -29
- data/docs/guides/feature-relationships-methods.md +684 -0
- data/docs/guides/feature-relationships.md +200 -0
- data/docs/guides/{Features-System-Developer-Guide.md → feature-system-devs.md} +4 -4
- data/docs/guides/{Feature-System-Guide.md → feature-system.md} +5 -5
- data/docs/guides/{Transient-Fields-Guide.md → feature-transient-fields.md} +2 -2
- data/docs/guides/{Implementation-Guide.md → implementation.md} +3 -3
- data/docs/guides/index.md +176 -0
- data/docs/guides/{Security-Model.md → security-model.md} +1 -1
- data/docs/migrating/v2.0.0-pre.md +1 -1
- data/docs/migrating/v2.0.0-pre11.md +2 -2
- data/docs/migrating/v2.0.0-pre12.md +2 -2
- data/docs/migrating/v2.0.0-pre5.md +33 -12
- data/docs/migrating/v2.0.0-pre6.md +2 -2
- data/docs/migrating/v2.0.0-pre7.md +8 -8
- data/docs/overview.md +624 -20
- data/docs/reference/api-technical.md +1365 -0
- data/examples/autoloader/mega_customer/features/deprecated_fields.rb +7 -0
- data/examples/autoloader/mega_customer/safe_dump_fields.rb +1 -1
- data/examples/autoloader/mega_customer.rb +3 -1
- data/examples/encrypted_fields.rb +378 -0
- data/examples/json_usage_patterns.rb +144 -0
- data/examples/relationships.rb +13 -13
- data/examples/safe_dump.rb +7 -7
- data/examples/single_connection_transaction_confusions.rb +379 -0
- data/lib/familia/base.rb +51 -10
- data/lib/familia/connection/handlers.rb +223 -0
- data/lib/familia/connection/individual_command_proxy.rb +64 -0
- data/lib/familia/connection/middleware.rb +75 -0
- data/lib/familia/connection/operation_core.rb +93 -0
- data/lib/familia/connection/operations.rb +277 -0
- data/lib/familia/connection/pipeline_core.rb +87 -0
- data/lib/familia/connection/transaction_core.rb +100 -0
- data/lib/familia/connection.rb +60 -186
- data/lib/familia/data_type/class_methods.rb +63 -0
- data/lib/familia/data_type/commands.rb +53 -51
- data/lib/familia/data_type/connection.rb +83 -0
- data/lib/familia/data_type/serialization.rb +108 -107
- data/lib/familia/data_type/settings.rb +96 -0
- data/lib/familia/data_type/types/counter.rb +1 -1
- data/lib/familia/data_type/types/hashkey.rb +15 -11
- data/lib/familia/data_type/types/{list.rb → listkey.rb} +13 -5
- data/lib/familia/data_type/types/lock.rb +3 -2
- data/lib/familia/data_type/types/sorted_set.rb +128 -14
- data/lib/familia/data_type/types/{string.rb → stringkey.rb} +7 -9
- data/lib/familia/data_type/types/unsorted_set.rb +20 -27
- data/lib/familia/data_type.rb +12 -171
- data/lib/familia/distinguisher.rb +85 -0
- data/lib/familia/encryption/encrypted_data.rb +15 -24
- data/lib/familia/encryption/manager.rb +6 -4
- data/lib/familia/encryption/providers/aes_gcm_provider.rb +1 -1
- data/lib/familia/encryption/providers/secure_xchacha20_poly1305_provider.rb +7 -9
- data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +4 -5
- data/lib/familia/encryption/request_cache.rb +7 -7
- data/lib/familia/encryption.rb +2 -3
- data/lib/familia/errors.rb +9 -3
- data/lib/familia/features/autoloader.rb +30 -12
- data/lib/familia/features/encrypted_fields/concealed_string.rb +3 -4
- data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +13 -14
- data/lib/familia/features/encrypted_fields.rb +71 -66
- data/lib/familia/features/expiration/extensions.rb +1 -1
- data/lib/familia/features/expiration.rb +31 -26
- data/lib/familia/features/external_identifier.rb +57 -19
- data/lib/familia/features/object_identifier.rb +134 -25
- data/lib/familia/features/quantization.rb +16 -21
- data/lib/familia/features/relationships/README.md +97 -0
- data/lib/familia/features/relationships/collection_operations.rb +104 -0
- data/lib/familia/features/relationships/indexing/multi_index_generators.rb +202 -0
- data/lib/familia/features/relationships/indexing/unique_index_generators.rb +306 -0
- data/lib/familia/features/relationships/indexing.rb +182 -256
- data/lib/familia/features/relationships/indexing_relationship.rb +35 -0
- data/lib/familia/features/relationships/participation/participant_methods.rb +164 -0
- data/lib/familia/features/relationships/participation/target_methods.rb +225 -0
- data/lib/familia/features/relationships/participation.rb +656 -0
- data/lib/familia/features/relationships/participation_relationship.rb +31 -0
- data/lib/familia/features/relationships/score_encoding.rb +20 -20
- data/lib/familia/features/relationships.rb +65 -266
- data/lib/familia/features/safe_dump.rb +127 -130
- data/lib/familia/features/transient_fields/redacted_string.rb +6 -6
- data/lib/familia/features/transient_fields/transient_field_type.rb +5 -5
- data/lib/familia/features/transient_fields.rb +10 -7
- data/lib/familia/features.rb +10 -14
- data/lib/familia/field_type.rb +6 -4
- data/lib/familia/horreum/connection.rb +297 -0
- data/lib/familia/horreum/{core/database_commands.rb → database_commands.rb} +27 -17
- data/lib/familia/horreum/{subclass/definition.rb → definition.rb} +139 -74
- data/lib/familia/horreum/{subclass/management.rb → management.rb} +73 -27
- data/lib/familia/horreum/{core/serialization.rb → persistence.rb} +108 -185
- data/lib/familia/horreum/{subclass/related_fields_management.rb → related_fields.rb} +104 -23
- data/lib/familia/horreum/serialization.rb +172 -0
- data/lib/familia/horreum/{shared/settings.rb → settings.rb} +2 -1
- data/lib/familia/horreum/{core/utils.rb → utils.rb} +2 -1
- data/lib/familia/horreum.rb +222 -119
- data/lib/familia/json_serializer.rb +0 -1
- data/lib/familia/logging.rb +11 -114
- data/lib/familia/refinements/dear_json.rb +122 -0
- data/lib/familia/refinements/logger_trace.rb +20 -17
- data/lib/familia/refinements/stylize_words.rb +65 -0
- data/lib/familia/refinements/time_literals.rb +60 -52
- data/lib/familia/refinements.rb +2 -1
- data/lib/familia/secure_identifier.rb +60 -28
- data/lib/familia/settings.rb +83 -7
- data/lib/familia/utils.rb +5 -87
- data/lib/familia/verifiable_identifier.rb +4 -4
- data/lib/familia/version.rb +1 -1
- data/lib/familia.rb +72 -14
- data/lib/middleware/database_middleware.rb +56 -14
- data/lib/{familia/multi_result.rb → multi_result.rb} +23 -16
- data/try/configuration/scenarios_try.rb +2 -2
- data/try/connection/fiber_context_preservation_try.rb +250 -0
- data/try/connection/handler_constraints_try.rb +59 -0
- data/try/connection/operation_mode_guards_try.rb +208 -0
- data/try/connection/pipeline_fallback_integration_try.rb +128 -0
- data/try/connection/responsibility_chain_tracking_try.rb +72 -0
- data/try/connection/transaction_fallback_integration_try.rb +288 -0
- data/try/connection/transaction_mode_permissive_try.rb +153 -0
- data/try/connection/transaction_mode_strict_try.rb +98 -0
- data/try/connection/transaction_mode_warn_try.rb +131 -0
- data/try/connection/transaction_modes_try.rb +249 -0
- data/try/core/autoloader_try.rb +120 -2
- data/try/core/connection_try.rb +10 -10
- data/try/core/conventional_inheritance_try.rb +130 -0
- data/try/core/create_method_try.rb +15 -23
- data/try/core/database_consistency_try.rb +11 -10
- data/try/core/errors_try.rb +11 -14
- data/try/core/familia_extended_try.rb +2 -2
- data/try/core/familia_members_methods_try.rb +76 -0
- data/try/core/familia_try.rb +1 -1
- data/try/core/isolated_dbclient_try.rb +165 -0
- data/try/core/middleware_try.rb +16 -16
- data/try/core/persistence_operations_try.rb +4 -4
- data/try/core/pools_try.rb +42 -26
- data/try/core/secure_identifier_try.rb +28 -24
- data/try/core/time_utils_try.rb +10 -10
- data/try/core/tools_try.rb +3 -3
- data/try/core/utils_try.rb +2 -2
- data/try/data_types/boolean_try.rb +4 -4
- data/try/data_types/datatype_base_try.rb +0 -2
- data/try/data_types/list_try.rb +10 -10
- data/try/data_types/sorted_set_try.rb +5 -5
- data/try/data_types/sorted_set_zadd_options_try.rb +625 -0
- data/try/data_types/string_try.rb +12 -12
- data/try/data_types/unsortedset_try.rb +33 -0
- data/try/debugging/cache_behavior_tracer.rb +7 -7
- data/try/debugging/debug_aad_process.rb +1 -1
- data/try/debugging/debug_concealed_internal.rb +1 -1
- data/try/debugging/debug_cross_context.rb +1 -1
- data/try/debugging/debug_fresh_cross_context.rb +1 -1
- data/try/debugging/encryption_method_tracer.rb +10 -10
- data/try/edge_cases/hash_symbolization_try.rb +1 -1
- data/try/edge_cases/ttl_side_effects_try.rb +1 -1
- data/try/encryption/config_persistence_try.rb +2 -2
- data/try/encryption/encryption_core_try.rb +19 -19
- data/try/encryption/instance_variable_scope_try.rb +1 -1
- data/try/encryption/module_loading_try.rb +2 -2
- data/try/encryption/providers/aes_gcm_provider_try.rb +1 -1
- data/try/encryption/providers/xchacha20_poly1305_provider_try.rb +1 -1
- data/try/encryption/secure_memory_handling_try.rb +1 -1
- data/try/features/encrypted_fields/concealed_string_core_try.rb +11 -7
- data/try/features/encrypted_fields/encrypted_fields_core_try.rb +1 -1
- data/try/features/encrypted_fields/encrypted_fields_integration_try.rb +3 -3
- data/try/features/encrypted_fields/encrypted_fields_no_cache_security_try.rb +10 -10
- data/try/features/encrypted_fields/encrypted_fields_security_try.rb +14 -14
- data/try/features/encrypted_fields/error_conditions_try.rb +7 -7
- data/try/features/encrypted_fields/fresh_key_try.rb +1 -1
- data/try/features/encrypted_fields/nonce_uniqueness_try.rb +1 -1
- data/try/features/encrypted_fields/secure_by_default_behavior_try.rb +7 -7
- data/try/features/encrypted_fields/universal_serialization_safety_try.rb +13 -20
- data/try/features/external_identifier/external_identifier_try.rb +1 -1
- data/try/features/feature_dependencies_try.rb +3 -3
- data/try/features/field_groups_try.rb +244 -0
- data/try/features/object_identifier/object_identifier_integration_try.rb +28 -34
- data/try/features/object_identifier/object_identifier_try.rb +10 -0
- data/try/features/quantization/quantization_try.rb +1 -1
- data/try/features/relationships/indexing_commands_verification_try.rb +136 -0
- data/try/features/relationships/indexing_try.rb +443 -0
- data/try/features/relationships/participation_commands_verification_spec.rb +102 -0
- data/try/features/relationships/participation_commands_verification_try.rb +105 -0
- data/try/features/relationships/participation_performance_improvements_try.rb +124 -0
- data/try/features/relationships/participation_reverse_index_try.rb +196 -0
- data/try/features/relationships/relationships_api_changes_try.rb +72 -71
- data/try/features/relationships/relationships_edge_cases_try.rb +15 -18
- data/try/features/relationships/relationships_performance_minimal_try.rb +2 -2
- data/try/features/relationships/relationships_performance_simple_try.rb +8 -8
- data/try/features/relationships/relationships_performance_try.rb +20 -20
- data/try/features/relationships/relationships_try.rb +27 -38
- data/try/features/safe_dump/safe_dump_advanced_try.rb +2 -2
- data/try/features/transient_fields/refresh_reset_try.rb +3 -1
- data/try/features/transient_fields/simple_refresh_test.rb +1 -1
- data/try/helpers/test_cleanup.rb +86 -0
- data/try/helpers/test_helpers.rb +6 -7
- data/try/horreum/auto_indexing_on_save_try.rb +212 -0
- data/try/horreum/base_try.rb +3 -2
- data/try/horreum/commands_try.rb +3 -1
- data/try/horreum/defensive_initialization_try.rb +86 -0
- data/try/horreum/destroy_related_fields_cleanup_try.rb +332 -0
- data/try/horreum/initialization_try.rb +11 -7
- data/try/horreum/relations_try.rb +21 -13
- data/try/horreum/serialization_try.rb +12 -11
- data/try/horreum/settings_try.rb +2 -0
- data/try/integration/cross_component_try.rb +3 -3
- data/try/memory/memory_basic_test.rb +1 -1
- data/try/memory/memory_docker_ruby_dump.sh +2 -2
- data/try/models/customer_safe_dump_try.rb +1 -1
- data/try/models/customer_try.rb +13 -15
- data/try/models/datatype_base_try.rb +3 -3
- data/try/models/familia_object_try.rb +9 -8
- data/try/performance/benchmarks_try.rb +2 -2
- data/try/prototypes/atomic_saves_v1_context_proxy.rb +2 -2
- data/try/prototypes/atomic_saves_v3_connection_pool.rb +3 -3
- data/try/prototypes/atomic_saves_v4.rb +1 -1
- data/try/prototypes/lib/atomic_saves_v2_connection_switching_helpers.rb +4 -4
- data/try/prototypes/lib/atomic_saves_v3_connection_pool_helpers.rb +4 -4
- data/try/prototypes/pooling/lib/atomic_saves_v3_connection_pool_helpers.rb +4 -4
- data/try/prototypes/pooling/lib/connection_pool_metrics.rb +5 -5
- data/try/prototypes/pooling/lib/connection_pool_stress_test.rb +26 -26
- data/try/prototypes/pooling/lib/connection_pool_threading_models.rb +7 -7
- data/try/prototypes/pooling/lib/visualize_stress_results.rb +1 -1
- data/try/prototypes/pooling/pool_siege.rb +11 -11
- data/try/prototypes/pooling/run_stress_tests.rb +7 -7
- data/try/refinements/dear_json_array_methods_try.rb +53 -0
- data/try/refinements/dear_json_hash_methods_try.rb +54 -0
- data/try/refinements/logger_trace_methods_try.rb +44 -0
- data/try/refinements/time_literals_numeric_methods_try.rb +141 -0
- data/try/refinements/time_literals_string_methods_try.rb +80 -0
- data/try/valkey.conf +26 -0
- metadata +92 -52
- data/.rubocop_todo.yml +0 -208
- data/docs/connection_pooling.md +0 -192
- data/docs/guides/Connection-Pooling-Guide.md +0 -437
- data/docs/guides/Encrypted-Fields-Overview.md +0 -101
- data/docs/guides/Feature-System-Autoloading.md +0 -198
- data/docs/guides/Home.md +0 -116
- data/docs/guides/Relationships-Guide.md +0 -737
- data/docs/guides/relationships-methods.md +0 -266
- data/docs/reference/auditing_database_commands.rb +0 -228
- data/examples/permissions.rb +0 -240
- data/lib/familia/features/relationships/cascading.rb +0 -437
- data/lib/familia/features/relationships/membership.rb +0 -497
- data/lib/familia/features/relationships/permission_management.rb +0 -264
- data/lib/familia/features/relationships/querying.rb +0 -615
- data/lib/familia/features/relationships/redis_operations.rb +0 -274
- data/lib/familia/features/relationships/tracking.rb +0 -418
- data/lib/familia/horreum/core/connection.rb +0 -73
- data/lib/familia/horreum/core.rb +0 -21
- data/lib/familia/refinements/snake_case.rb +0 -40
- data/lib/familia/validation/command_recorder.rb +0 -336
- data/lib/familia/validation/expectations.rb +0 -519
- data/lib/familia/validation/validation_helpers.rb +0 -443
- data/lib/familia/validation/validator.rb +0 -412
- data/lib/familia/validation.rb +0 -140
- data/try/data_types/set_try.rb +0 -33
- data/try/features/relationships/categorical_permissions_try.rb +0 -515
- data/try/features/safe_dump/module_based_extensions_try.rb +0 -100
- data/try/features/safe_dump/safe_dump_autoloading_try.rb +0 -107
- data/try/validation/atomic_operations_try.rb.disabled +0 -320
- data/try/validation/command_validation_try.rb.disabled +0 -207
- data/try/validation/performance_validation_try.rb.disabled +0 -324
- data/try/validation/real_world_scenarios_try.rb.disabled +0 -390
@@ -8,7 +8,7 @@ module Familia
|
|
8
8
|
#
|
9
9
|
# Object identifiers are:
|
10
10
|
# - Unique across the system
|
11
|
-
# - Persistent (stored in Redis
|
11
|
+
# - Persistent (stored in Valkey/Redis)
|
12
12
|
# - Lazily generated (only when first accessed)
|
13
13
|
# - Configurable (multiple generation strategies available)
|
14
14
|
# - Preserved during initialization (existing IDs never regenerated)
|
@@ -50,7 +50,7 @@ module Familia
|
|
50
50
|
#
|
51
51
|
# # Custom generation strategy
|
52
52
|
# class TimestampedItem < Familia::Horreum
|
53
|
-
# feature :object_identifier, generator: -> { "item_#{
|
53
|
+
# feature :object_identifier, generator: -> { "item_#{Familia.now.to_i}_#{SecureRandom.hex(4)}" }
|
54
54
|
# field :data
|
55
55
|
# end
|
56
56
|
#
|
@@ -60,9 +60,9 @@ module Familia
|
|
60
60
|
# Data Integrity Guarantees:
|
61
61
|
#
|
62
62
|
# The feature preserves the object identifier passed during initialization,
|
63
|
-
# ensuring that existing objects loaded from Redis maintain their IDs:
|
63
|
+
# ensuring that existing objects loaded from Valkey/Redis maintain their IDs:
|
64
64
|
#
|
65
|
-
# # Loading existing object from Redis preserves ID
|
65
|
+
# # Loading existing object from Valkey/Redis preserves ID
|
66
66
|
# existing = User.new(objid: 'existing-uuid-value', email: 'existing@example.com')
|
67
67
|
# existing.objid # => "existing-uuid-value" (preserved, not regenerated)
|
68
68
|
#
|
@@ -71,7 +71,7 @@ module Familia
|
|
71
71
|
# - Lazy Generation: IDs generated only when first accessed
|
72
72
|
# - Thread-Safe: Generator strategy configured once during initialization
|
73
73
|
# - Memory Efficient: No unnecessary ID generation for unused objects
|
74
|
-
# - Redis Efficient: Only persists non-nil values to conserve memory
|
74
|
+
# - Valkey/Redis Efficient: Only persists non-nil values to conserve memory
|
75
75
|
#
|
76
76
|
# Security Considerations:
|
77
77
|
#
|
@@ -86,12 +86,22 @@ module Familia
|
|
86
86
|
DEFAULT_GENERATOR = :uuid_v7
|
87
87
|
|
88
88
|
def self.included(base)
|
89
|
-
Familia.trace :LOADED, self, base
|
90
|
-
base.extend
|
89
|
+
Familia.trace :LOADED, self, base if Familia.debug?
|
90
|
+
base.extend ModelClassMethods
|
91
|
+
base.include ModelInstanceMethods
|
91
92
|
|
92
93
|
# Ensure default generator is set in feature options
|
93
94
|
base.add_feature_options(:object_identifier, generator: DEFAULT_GENERATOR)
|
94
95
|
|
96
|
+
# Add class-level mapping for objid -> id lookups.
|
97
|
+
#
|
98
|
+
# If the model uses objid as it's primary key, this mapping will be
|
99
|
+
# redundant to the builtin functionality of horreum clases, that
|
100
|
+
# automatically populate ModelClass.instances sorted set. However,
|
101
|
+
# if the model uses any other field as primary key, this mapping
|
102
|
+
# is necessary to lookup objects by their objid.
|
103
|
+
base.class_hashkey :objid_lookup
|
104
|
+
|
95
105
|
# Register the objid field using a simple custom field type
|
96
106
|
base.register_field_type(ObjectIdentifierFieldType.new(:objid, as: :objid, fast_method: false))
|
97
107
|
end
|
@@ -101,7 +111,7 @@ module Familia
|
|
101
111
|
# Object identifier fields automatically generate unique identifiers when first
|
102
112
|
# accessed if not already set. The generation strategy is configurable via
|
103
113
|
# feature options. These fields preserve any values set during initialization
|
104
|
-
# to ensure data integrity when loading existing objects from
|
114
|
+
# to ensure data integrity when loading existing objects from the database.
|
105
115
|
#
|
106
116
|
# The field type tracks the generator used for each objid to provide provenance
|
107
117
|
# information for security-sensitive operations like external identifier generation.
|
@@ -166,7 +176,7 @@ module Familia
|
|
166
176
|
# Override setter to preserve values during initialization
|
167
177
|
#
|
168
178
|
# This ensures that values passed during object initialization
|
169
|
-
# (e.g., when loading from Redis) are preserved and not overwritten
|
179
|
+
# (e.g., when loading from Valkey/Redis) are preserved and not overwritten
|
170
180
|
# by the lazy generation logic.
|
171
181
|
#
|
172
182
|
# @param klass [Class] The class to define the method on
|
@@ -177,13 +187,20 @@ module Familia
|
|
177
187
|
|
178
188
|
handle_method_conflict(klass, :"#{method_name}=") do
|
179
189
|
klass.define_method :"#{method_name}=" do |value|
|
190
|
+
# Remove old mapping if objid is changing
|
191
|
+
old_value = instance_variable_get(:"@#{field_name}")
|
192
|
+
if old_value && old_value != value
|
193
|
+
Familia.logger.info("Removing objid mapping for #{old_value}")
|
194
|
+
self.class.objid_lookup.remove_field(old_value)
|
195
|
+
end
|
196
|
+
|
180
197
|
instance_variable_set(:"@#{field_name}", value)
|
181
198
|
|
182
|
-
# When setting objid from external source (e.g., loading from Redis),
|
183
|
-
#
|
184
|
-
#
|
185
|
-
|
186
|
-
instance_variable_set(:"@#{field_name}_generator_used",
|
199
|
+
# When setting objid from external source (e.g., loading from Valkey/Redis),
|
200
|
+
# infer the generator type from the format to restore provenance tracking.
|
201
|
+
# This allows features like ExternalIdentifier to work correctly on loaded objects.
|
202
|
+
inferred_generator = infer_objid_generator(value)
|
203
|
+
instance_variable_set(:"@#{field_name}_generator_used", inferred_generator)
|
187
204
|
end
|
188
205
|
end
|
189
206
|
end
|
@@ -205,7 +222,7 @@ module Familia
|
|
205
222
|
end
|
206
223
|
end
|
207
224
|
|
208
|
-
module
|
225
|
+
module ModelClassMethods
|
209
226
|
# Generate a new object identifier using the configured strategy
|
210
227
|
#
|
211
228
|
# @return [String] A new unique identifier
|
@@ -220,7 +237,7 @@ module Familia
|
|
220
237
|
when :uuid_v4
|
221
238
|
SecureRandom.uuid_v4
|
222
239
|
when :hex
|
223
|
-
Familia.
|
240
|
+
Familia.generate_id(16)
|
224
241
|
when Proc
|
225
242
|
generator.call
|
226
243
|
else
|
@@ -243,18 +260,73 @@ module Familia
|
|
243
260
|
|
244
261
|
if Familia.debug?
|
245
262
|
reference = caller(1..1).first
|
246
|
-
Familia.trace :FIND_BY_OBJID,
|
263
|
+
Familia.trace :FIND_BY_OBJID, nil, objid, reference
|
247
264
|
end
|
248
265
|
|
249
|
-
#
|
250
|
-
|
251
|
-
|
252
|
-
|
266
|
+
# Look up the primary ID from the external ID mapping
|
267
|
+
primary_id = objid_lookup[objid]
|
268
|
+
|
269
|
+
# If there is no mapping for this instance's objid, perhaps
|
270
|
+
# the object dbkey is already using the objid.
|
271
|
+
primary_id = objid if primary_id.nil?
|
272
|
+
|
273
|
+
find_by_id(primary_id)
|
253
274
|
rescue Familia::NotFound
|
275
|
+
# If the object was deleted but mapping wasn't cleaned up
|
276
|
+
# we could autoclean here, as long as we log it.
|
277
|
+
# objid_lookup.remove_field(objid)
|
254
278
|
nil
|
255
279
|
end
|
256
280
|
end
|
257
281
|
|
282
|
+
# Instance methods for object identifier management
|
283
|
+
module ModelInstanceMethods
|
284
|
+
# Override save to update objid_lookup mapping
|
285
|
+
#
|
286
|
+
# This ensures the objid_lookup index is populated during save operations
|
287
|
+
# rather than during object initialization, preventing unwanted database
|
288
|
+
# writes when calling .new()
|
289
|
+
#
|
290
|
+
# @param update_expiration [Boolean] Whether to update key expiration
|
291
|
+
# @return [Boolean] True if save was successful
|
292
|
+
#
|
293
|
+
def save(update_expiration: true)
|
294
|
+
result = super
|
295
|
+
|
296
|
+
# Update objid_lookup mapping after successful save
|
297
|
+
if result && respond_to?(:objid) && respond_to?(:identifier)
|
298
|
+
current_objid = objid # Triggers lazy generation if needed
|
299
|
+
if current_objid && identifier
|
300
|
+
self.class.objid_lookup[current_objid] = identifier
|
301
|
+
end
|
302
|
+
end
|
303
|
+
|
304
|
+
result
|
305
|
+
end
|
306
|
+
|
307
|
+
# Override save_if_not_exists to update objid_lookup mapping
|
308
|
+
#
|
309
|
+
# This ensures the objid_lookup index is populated during create operations
|
310
|
+
# which use save_if_not_exists instead of save.
|
311
|
+
#
|
312
|
+
# @param update_expiration [Boolean] Whether to update key expiration
|
313
|
+
# @return [Boolean] True if save was successful
|
314
|
+
#
|
315
|
+
def save_if_not_exists(update_expiration: true)
|
316
|
+
result = super
|
317
|
+
|
318
|
+
# Update objid_lookup mapping after successful save
|
319
|
+
if result && respond_to?(:objid) && respond_to?(:identifier)
|
320
|
+
current_objid = objid # Triggers lazy generation if needed
|
321
|
+
if current_objid && identifier
|
322
|
+
self.class.objid_lookup[current_objid] = identifier
|
323
|
+
end
|
324
|
+
end
|
325
|
+
|
326
|
+
result
|
327
|
+
end
|
328
|
+
end
|
329
|
+
|
258
330
|
# Instance method for generating object identifier using configured strategy
|
259
331
|
#
|
260
332
|
# This method is called by the ObjectIdentifierFieldType when lazy generation
|
@@ -275,14 +347,52 @@ module Familia
|
|
275
347
|
objid
|
276
348
|
end
|
277
349
|
|
278
|
-
#
|
350
|
+
# Infers the generator type (:uuid_v7, :uuid_v4, :hex) from the format of an objid string.
|
279
351
|
#
|
280
|
-
#
|
352
|
+
# This method analyzes the objid format to restore provenance tracking when loading
|
353
|
+
# objects from Redis, allowing dependent features like ExternalIdentifier to work correctly.
|
281
354
|
#
|
355
|
+
# @param objid_value [String] The objid string to analyze
|
356
|
+
# @return [Symbol, nil] The inferred generator type or nil if unknown
|
357
|
+
def infer_objid_generator(objid_value)
|
358
|
+
return nil if objid_value.nil? || objid_value.to_s.empty?
|
359
|
+
|
360
|
+
objid_str = objid_value.to_s
|
361
|
+
|
362
|
+
# UUID format: xxxxxxxx-xxxx-Vxxx-xxxx-xxxxxxxxxxxx (36 chars with hyphens)
|
363
|
+
# where V is the version nibble at position 14
|
364
|
+
if objid_str.length == 36 && objid_str[8] == '-' && objid_str[13] == '-' && objid_str[18] == '-' && objid_str[23] == '-'
|
365
|
+
version_char = objid_str[14]
|
366
|
+
case version_char
|
367
|
+
when '7'
|
368
|
+
:uuid_v7
|
369
|
+
when '4'
|
370
|
+
:uuid_v4
|
371
|
+
else
|
372
|
+
nil # Unknown UUID version
|
373
|
+
end
|
374
|
+
# Hex format: pure hexadecimal without hyphens (32 or 64 chars typically)
|
375
|
+
elsif objid_str.match?(/\A[0-9a-fA-F]+\z/)
|
376
|
+
:hex
|
377
|
+
else
|
378
|
+
nil # Unknown format
|
379
|
+
end
|
380
|
+
end
|
381
|
+
private :infer_objid_generator
|
382
|
+
|
282
383
|
def object_identifier=(value)
|
283
384
|
self.objid = value
|
284
385
|
end
|
285
386
|
|
387
|
+
def destroy!
|
388
|
+
# Clean up objid mapping when object is destroyed
|
389
|
+
current_objid = instance_variable_get(:@objid)
|
390
|
+
|
391
|
+
self.class.objid_lookup.remove_field(current_objid) if current_objid
|
392
|
+
|
393
|
+
super if defined?(super)
|
394
|
+
end
|
395
|
+
|
286
396
|
# Initialize object identifier configuration
|
287
397
|
#
|
288
398
|
# Called during object initialization to set up the ID generation strategy.
|
@@ -300,9 +410,8 @@ module Familia
|
|
300
410
|
|
301
411
|
options = self.class.feature_options(:object_identifier)
|
302
412
|
generator = options[:generator] || DEFAULT_GENERATOR
|
303
|
-
Familia.trace :OBJID_INIT,
|
413
|
+
Familia.trace :OBJID_INIT, nil, "Generator strategy: #{generator}"
|
304
414
|
end
|
305
|
-
|
306
415
|
end
|
307
416
|
end
|
308
417
|
end
|
@@ -106,7 +106,7 @@ module Familia
|
|
106
106
|
# activity.save
|
107
107
|
# end
|
108
108
|
#
|
109
|
-
# def self.activity_for_hour(time =
|
109
|
+
# def self.activity_for_hour(time = Familia.now)
|
110
110
|
# bucket_id = "activity:#{qstamp(1.hour, time: time, pattern: '%Y%m%d%H')}"
|
111
111
|
# find(bucket_id)
|
112
112
|
# end
|
@@ -129,7 +129,7 @@ module Familia
|
|
129
129
|
# interval: interval.to_i)
|
130
130
|
# end
|
131
131
|
#
|
132
|
-
# metric.data_points.add(
|
132
|
+
# metric.data_points.add(value, timestamp)
|
133
133
|
# metric.timestamp = timestamp
|
134
134
|
# metric.value = value
|
135
135
|
# metric.save
|
@@ -175,13 +175,13 @@ module Familia
|
|
175
175
|
#
|
176
176
|
# def self.utc_hourly_key(metric_name)
|
177
177
|
# # Always use UTC for consistent global buckets
|
178
|
-
# timestamp = qstamp(1.hour, time:
|
178
|
+
# timestamp = qstamp(1.hour, time: Familia.now, pattern: '%Y%m%d%H')
|
179
179
|
# "global:#{metric_name}:#{timestamp}"
|
180
180
|
# end
|
181
181
|
#
|
182
182
|
# def self.local_daily_key(metric_name, timezone = 'America/New_York')
|
183
183
|
# # Use local timezone for region-specific buckets
|
184
|
-
# local_time =
|
184
|
+
# local_time = Familia.now.in_time_zone(timezone)
|
185
185
|
# timestamp = qstamp(1.day, time: local_time, pattern: '%Y%m%d')
|
186
186
|
# "#{timezone.gsub('/', '_')}:#{metric_name}:#{timestamp}"
|
187
187
|
# end
|
@@ -194,7 +194,7 @@ module Familia
|
|
194
194
|
#
|
195
195
|
# # Cache quantized timestamps to avoid repeated calculations
|
196
196
|
# def self.cached_qstamp(quantum, pattern: nil, time: nil)
|
197
|
-
# cache_key = "qstamp:#{quantum}:#{pattern}:#{(time ||
|
197
|
+
# cache_key = "qstamp:#{quantum}:#{pattern}:#{(time || Familia.now).to_i / quantum}"
|
198
198
|
# Rails.cache.fetch(cache_key, expires_in: quantum) do
|
199
199
|
# qstamp(quantum, pattern: pattern, time: time)
|
200
200
|
# end
|
@@ -245,19 +245,18 @@ module Familia
|
|
245
245
|
# NoDefault.qstamp() # Uses 10.minutes as fallback quantum
|
246
246
|
#
|
247
247
|
module Quantization
|
248
|
-
|
249
248
|
Familia::Base.add_feature self, :quantization
|
250
249
|
|
251
250
|
using Familia::Refinements::TimeLiterals
|
252
251
|
|
253
252
|
def self.included(base)
|
254
|
-
Familia.trace :LOADED, self, base
|
255
|
-
base.extend
|
253
|
+
Familia.trace :LOADED, self, base if Familia.debug?
|
254
|
+
base.extend ModelClassMethods
|
256
255
|
end
|
257
256
|
|
258
|
-
# Familia::Quantization::
|
257
|
+
# Familia::Quantization::ModelClassMethods
|
259
258
|
#
|
260
|
-
module
|
259
|
+
module ModelClassMethods
|
261
260
|
# Generates a quantized timestamp based on the given parameters
|
262
261
|
#
|
263
262
|
# This method rounds the current time to the nearest quantum and optionally
|
@@ -286,9 +285,7 @@ module Familia
|
|
286
285
|
#
|
287
286
|
def qstamp(quantum = nil, pattern: nil, time: nil)
|
288
287
|
# Handle array input format: [quantum, pattern]
|
289
|
-
if quantum.is_a?(Array)
|
290
|
-
quantum, pattern = quantum
|
291
|
-
end
|
288
|
+
quantum, pattern = quantum if quantum.is_a?(Array)
|
292
289
|
|
293
290
|
# Use default quantum if none specified
|
294
291
|
# Priority: provided quantum > class default_expiration > 10.minutes fallback
|
@@ -323,11 +320,11 @@ module Familia
|
|
323
320
|
end_bucket = qstamp(quantum, time: end_time)
|
324
321
|
|
325
322
|
while current <= end_bucket
|
326
|
-
if pattern
|
327
|
-
|
328
|
-
|
329
|
-
|
330
|
-
|
323
|
+
timestamps << if pattern
|
324
|
+
Time.at(current).strftime(pattern)
|
325
|
+
else
|
326
|
+
current
|
327
|
+
end
|
331
328
|
current += quantum
|
332
329
|
end
|
333
330
|
|
@@ -352,7 +349,7 @@ module Familia
|
|
352
349
|
bucket_start = qstamp(quantum, time: Time.at(bucket_time))
|
353
350
|
bucket_end = bucket_start + quantum - 1
|
354
351
|
|
355
|
-
timestamp
|
352
|
+
timestamp.between?(bucket_start, bucket_end)
|
356
353
|
end
|
357
354
|
end
|
358
355
|
|
@@ -397,8 +394,6 @@ module Familia
|
|
397
394
|
base_id = respond_to?(:identifier) ? identifier : object_id
|
398
395
|
"#{base_id}#{separator}#{timestamp}"
|
399
396
|
end
|
400
|
-
|
401
|
-
extend ClassMethods
|
402
397
|
end
|
403
398
|
end
|
404
399
|
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
<!--lib/familia/features/relationships/README.md-->
|
2
|
+
|
3
|
+
## Core Modules
|
4
|
+
|
5
|
+
**relationships.rb** - Main orchestrator that unifies all relationship functionality into a single feature, providing the public API and coordinating between all submodules.
|
6
|
+
|
7
|
+
**indexing.rb** - O(1) lookup capability via Valkey/Redis hashes and sets. Enables fast field-based searches when parent-scoped (within: ParentClass). Creates instance methods on parent class for scoped lookups.
|
8
|
+
|
9
|
+
**participation.rb** - Multi-presence management where objects can exist in multiple collections simultaneously with score-encoded metadata (timestamps, permissions, etc.). All add/remove operations use transactions for atomicity.
|
10
|
+
|
11
|
+
## Quick API Guide
|
12
|
+
|
13
|
+
**participates_in** - Collection membership ("this object belongs in that collection")
|
14
|
+
```ruby
|
15
|
+
participates_in Organization, :members, score: :joined_at, bidirectional: true
|
16
|
+
# Creates: org.members, org.add_member(), customer.add_to_organization_members()
|
17
|
+
```
|
18
|
+
|
19
|
+
**unique_index** - Fast unique lookups ("find object by unique field value")
|
20
|
+
```ruby
|
21
|
+
unique_index :email, :email_index, within: Organization # Scoped: org.find_by_email()
|
22
|
+
```
|
23
|
+
|
24
|
+
**multi_index** - Fast multi-value lookups ("find all objects by field value")
|
25
|
+
```ruby
|
26
|
+
multi_index :department, :dept_index, within: Organization
|
27
|
+
# Creates: org.sample_from_department(), org.find_all_by_department()
|
28
|
+
```
|
29
|
+
|
30
|
+
## Key Philosophy
|
31
|
+
|
32
|
+
The entire system embraces "where does this appear?" rather than "who owns this?" - enabling objects to exist in multiple contexts simultaneously while maintaining fast lookups and atomic operations.
|
33
|
+
|
34
|
+
## When to Use Which
|
35
|
+
|
36
|
+
<details>
|
37
|
+
<summary>📋 participates_in vs indexing - Decision Guide</summary>
|
38
|
+
|
39
|
+
### participates_in - Collection Membership
|
40
|
+
- **Purpose**: "This object belongs in that collection"
|
41
|
+
- **Storage**: SortedSet/Set/List of object IDs with optional scores
|
42
|
+
- **Use for**: Membership relationships, ordered lists, scored collections
|
43
|
+
- **Example**: Customers in an Organization, Tasks in a Project
|
44
|
+
- **Atomicity**: Transactions for all operations (collection + reverse index)
|
45
|
+
|
46
|
+
```ruby
|
47
|
+
participates_in Organization, :members, score: :joined_at
|
48
|
+
# Creates: org.members (SortedSet), org.add_member(), customer.add_to_organization_members()
|
49
|
+
```
|
50
|
+
|
51
|
+
### unique_index - Fast Unique Lookups
|
52
|
+
- **Purpose**: "Find THE object by unique field value"
|
53
|
+
- **Storage**: HashKey for O(1) field-to-object mapping
|
54
|
+
- **Use for**: Email lookups, username searches, unique IDs
|
55
|
+
- **Example**: Find customer by email, find employee by badge number
|
56
|
+
- **Atomicity**: Transactions for updates (remove old + add new)
|
57
|
+
|
58
|
+
```ruby
|
59
|
+
unique_index :email, :email_index, within: Organization
|
60
|
+
# Creates: org.find_by_email(), org.find_all_by_email()
|
61
|
+
```
|
62
|
+
|
63
|
+
### multi_index - Fast Multi-Value Lookups
|
64
|
+
- **Purpose**: "Find ALL objects by shared field value"
|
65
|
+
- **Storage**: UnsortedSet for O(1) field-to-objects mapping
|
66
|
+
- **Use for**: Grouping by department, status, category, tags
|
67
|
+
- **Example**: All employees in a department, all tasks with status
|
68
|
+
- **Atomicity**: Transactions for updates (remove from old set + add to new set)
|
69
|
+
|
70
|
+
```ruby
|
71
|
+
multi_index :department, :dept_index, within: Organization
|
72
|
+
# Creates: org.sample_from_department(dept, count), org.find_all_by_department(dept)
|
73
|
+
```
|
74
|
+
|
75
|
+
</details>
|
76
|
+
|
77
|
+
> [!NOTE]
|
78
|
+
> **Scoping Patterns**: `unique_index` and `multi_index` use the `within:` parameter for instance-scoping, while participation uses distinct method names (`participates_in` vs `class_participates_in`) to reflect fundamentally different semantics (instance collections vs auto-tracking all instances).
|
79
|
+
|
80
|
+
> [!TIP]
|
81
|
+
> **Quick Decision Guide**
|
82
|
+
> - Need to store a collection of objects? → `participates_in`
|
83
|
+
> - Need to find ONE object by unique field? → `unique_index`
|
84
|
+
> - Need to find MANY objects by shared field? → `multi_index`
|
85
|
+
> - Combination? → Use all three together (very common)
|
86
|
+
|
87
|
+
```ruby
|
88
|
+
class Customer < Familia::Horreum
|
89
|
+
feature :relationships
|
90
|
+
|
91
|
+
participates_in Organization, :members # Customer belongs to org
|
92
|
+
unique_index :email, :email_index, within: Organization # Find by unique email
|
93
|
+
end
|
94
|
+
```
|
95
|
+
|
96
|
+
> [!NOTE]
|
97
|
+
> **Key**: `participates_in` = collections, `unique_index` = unique lookups, `multi_index` = group lookups.
|
@@ -0,0 +1,104 @@
|
|
1
|
+
# lib/familia/features/relationships/collection_operations.rb
|
2
|
+
|
3
|
+
module Familia
|
4
|
+
module Features
|
5
|
+
module Relationships
|
6
|
+
# Shared collection operations for Participation module
|
7
|
+
# Provides common methods for working with Horreum-managed DataType collections
|
8
|
+
# Used by both ParticipantMethods and TargetMethods to reduce duplication
|
9
|
+
module CollectionOperations
|
10
|
+
using Familia::Refinements::StylizeWords
|
11
|
+
|
12
|
+
# Ensure a target class has the specified DataType field defined
|
13
|
+
# @param target_class [Class] The class that should have the collection
|
14
|
+
# @param collection_name [Symbol] Name of the collection field
|
15
|
+
# @param type [Symbol] Collection type (:sorted_set, :set, :list)
|
16
|
+
def ensure_collection_field(target_class, collection_name, type)
|
17
|
+
return if target_class.method_defined?(collection_name)
|
18
|
+
|
19
|
+
target_class.send(type, collection_name)
|
20
|
+
end
|
21
|
+
|
22
|
+
# Add an item to a collection, handling type-specific operations
|
23
|
+
# @param collection [Familia::DataType] The collection to add to
|
24
|
+
# @param item [Object] The item to add (must respond to identifier)
|
25
|
+
# @param score [Float, nil] Score for sorted sets
|
26
|
+
# @param type [Symbol] Collection type
|
27
|
+
def add_to_collection(collection, item, type:, score: nil, target_class: nil, collection_name: nil)
|
28
|
+
case type
|
29
|
+
when :sorted_set
|
30
|
+
# Ensure score is never nil for sorted sets
|
31
|
+
score ||= calculate_item_score(item, target_class, collection_name)
|
32
|
+
collection.add(item.identifier, score)
|
33
|
+
when :list
|
34
|
+
# Lists use push/unshift operations
|
35
|
+
collection.add(item.identifier)
|
36
|
+
when :set
|
37
|
+
# Sets use simple add
|
38
|
+
collection.add(item.identifier)
|
39
|
+
else
|
40
|
+
raise ArgumentError, "Unknown collection type: #{type}"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
# Remove an item from a collection
|
45
|
+
# @param collection [Familia::DataType] The collection to remove from
|
46
|
+
# @param item [Object] The item to remove (must respond to identifier)
|
47
|
+
# @param type [Symbol] Collection type
|
48
|
+
def remove_from_collection(collection, item, type: nil)
|
49
|
+
# All collection types support remove/delete
|
50
|
+
collection.remove(item.identifier)
|
51
|
+
end
|
52
|
+
|
53
|
+
# Check if an item is a member of a collection
|
54
|
+
# @param collection [Familia::DataType] The collection to check
|
55
|
+
# @param item [Object] The item to check (must respond to identifier)
|
56
|
+
# @return [Boolean] True if item is in collection
|
57
|
+
def member_of_collection?(collection, item)
|
58
|
+
collection.member?(item.identifier)
|
59
|
+
end
|
60
|
+
|
61
|
+
# Bulk add items to a collection using DataType methods
|
62
|
+
# @param collection [Familia::DataType] The collection to add to
|
63
|
+
# @param items [Array] Array of items to add
|
64
|
+
# @param type [Symbol] Collection type
|
65
|
+
def bulk_add_to_collection(collection, items, type:, target_class: nil, collection_name: nil)
|
66
|
+
return if items.empty?
|
67
|
+
|
68
|
+
case type
|
69
|
+
when :sorted_set
|
70
|
+
# Add items one by one for sorted sets to ensure proper scoring
|
71
|
+
items.each do |item|
|
72
|
+
score = calculate_item_score(item, target_class, collection_name)
|
73
|
+
collection.add(item.identifier, score)
|
74
|
+
end
|
75
|
+
when :set, :list
|
76
|
+
# For sets and lists, add items one by one using DataType methods
|
77
|
+
items.each do |item|
|
78
|
+
collection.add(item.identifier)
|
79
|
+
end
|
80
|
+
else
|
81
|
+
raise ArgumentError, "Unknown collection type: #{type}"
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
private
|
86
|
+
|
87
|
+
# Calculate score for an item
|
88
|
+
# @param item [Object] The item to score
|
89
|
+
# @param target_class [Class, nil] The target class for participation scoring
|
90
|
+
# @param collection_name [Symbol, nil] The collection name for participation scoring
|
91
|
+
# @return [Float] The calculated score
|
92
|
+
def calculate_item_score(item, target_class = nil, collection_name = nil)
|
93
|
+
if item.respond_to?(:calculate_participation_score) && target_class && collection_name
|
94
|
+
item.calculate_participation_score(target_class, collection_name)
|
95
|
+
elsif item.respond_to?(:current_score)
|
96
|
+
item.current_score
|
97
|
+
else
|
98
|
+
Familia.now.to_f
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|