familia 2.0.0.pre23 → 2.0.0.pre25
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/CHANGELOG.rst +71 -0
- data/CLAUDE.md +1 -1
- data/Gemfile.lock +1 -1
- data/docs/guides/feature-relationships-indexing.md +104 -9
- data/docs/guides/feature-relationships-methods.md +37 -5
- data/docs/overview.md +9 -0
- data/lib/familia/base.rb +0 -2
- data/lib/familia/data_type/serialization.rb +8 -9
- data/lib/familia/data_type/settings.rb +0 -8
- data/lib/familia/data_type/types/json_stringkey.rb +155 -0
- data/lib/familia/data_type.rb +5 -4
- data/lib/familia/features/relationships/indexing/multi_index_generators.rb +281 -15
- data/lib/familia/features/relationships/indexing.rb +57 -27
- data/lib/familia/features/safe_dump.rb +0 -3
- data/lib/familia/horreum/management.rb +36 -3
- data/lib/familia/horreum/persistence.rb +4 -1
- data/lib/familia/horreum/settings.rb +2 -10
- data/lib/familia/horreum.rb +1 -2
- data/lib/familia/version.rb +1 -1
- data/try/edge_cases/find_by_dbkey_race_condition_try.rb +248 -0
- data/try/features/relationships/class_level_multi_index_auto_try.rb +318 -0
- data/try/features/relationships/class_level_multi_index_rebuild_try.rb +393 -0
- data/try/features/relationships/class_level_multi_index_try.rb +349 -0
- data/try/integration/familia_extended_try.rb +1 -1
- data/try/integration/scenarios_try.rb +4 -3
- data/try/unit/data_types/json_stringkey_try.rb +431 -0
- data/try/unit/horreum/settings_try.rb +0 -11
- metadata +7 -1
|
@@ -29,6 +29,48 @@ module Familia
|
|
|
29
29
|
|
|
30
30
|
using Familia::Refinements::StylizeWords
|
|
31
31
|
|
|
32
|
+
# Maximum recommended length for field values used in index keys.
|
|
33
|
+
# Longer values are allowed but will trigger a warning.
|
|
34
|
+
MAX_FIELD_VALUE_LENGTH = 256
|
|
35
|
+
|
|
36
|
+
# Validates a field value for use in index key construction.
|
|
37
|
+
# This is primarily for data quality and debugging clarity, not security.
|
|
38
|
+
#
|
|
39
|
+
# Security note: Redis SCAN patterns use the namespace prefix (e.g.,
|
|
40
|
+
# "customer:role_index:") which is derived from class/index metadata,
|
|
41
|
+
# not user input. Glob characters in stored field values are treated
|
|
42
|
+
# as literal characters in key names, not as pattern wildcards.
|
|
43
|
+
#
|
|
44
|
+
# @param field_value [Object] The field value to validate
|
|
45
|
+
# @param context [String] Description for warning messages
|
|
46
|
+
# @return [String, nil] The validated string value, or nil if invalid
|
|
47
|
+
def validate_field_value(field_value, context: 'index')
|
|
48
|
+
return nil if field_value.nil?
|
|
49
|
+
|
|
50
|
+
str_value = field_value.to_s
|
|
51
|
+
return nil if str_value.strip.empty?
|
|
52
|
+
|
|
53
|
+
# Warn on values containing Redis glob pattern characters
|
|
54
|
+
# These are legal but can be confusing when debugging key patterns
|
|
55
|
+
if str_value.match?(/[*?\[\]]/)
|
|
56
|
+
Familia.warn "[#{context}] Field value contains glob pattern characters: #{str_value.inspect}. " \
|
|
57
|
+
'These are stored as literal characters but may be confusing during debugging.'
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Warn on control characters (except common whitespace)
|
|
61
|
+
if str_value.match?(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/)
|
|
62
|
+
Familia.warn "[#{context}] Field value contains control characters: #{str_value.inspect}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Warn on excessively long values
|
|
66
|
+
if str_value.length > MAX_FIELD_VALUE_LENGTH
|
|
67
|
+
Familia.warn "[#{context}] Field value exceeds #{MAX_FIELD_VALUE_LENGTH} characters " \
|
|
68
|
+
"(#{str_value.length} chars): #{str_value[0..50]}..."
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
str_value
|
|
72
|
+
end
|
|
73
|
+
|
|
32
74
|
# Main setup method that orchestrates multi-value index creation
|
|
33
75
|
#
|
|
34
76
|
# @param indexed_class [Class] The class being indexed (e.g., Employee)
|
|
@@ -37,32 +79,36 @@ module Familia
|
|
|
37
79
|
# @param within [Class, Symbol] Scope class for instance-scoped index (required)
|
|
38
80
|
# @param query [Boolean] Whether to generate query methods
|
|
39
81
|
def setup(indexed_class:, field:, index_name:, within:, query:)
|
|
40
|
-
#
|
|
41
|
-
scope_class = within
|
|
42
|
-
|
|
82
|
+
# Determine scope type: class-level or instance-scoped
|
|
83
|
+
scope_class, scope_type = if within == :class
|
|
84
|
+
[indexed_class, :class]
|
|
85
|
+
else
|
|
86
|
+
k = Familia.resolve_class(within)
|
|
87
|
+
[k, :instance]
|
|
88
|
+
end
|
|
43
89
|
|
|
44
90
|
# Store metadata for this indexing relationship
|
|
45
91
|
indexed_class.indexing_relationships << IndexingRelationship.new(
|
|
46
92
|
field: field,
|
|
47
93
|
scope_class: scope_class,
|
|
48
|
-
within: within,
|
|
94
|
+
within: within, # Preserve original (:class or actual class)
|
|
49
95
|
index_name: index_name,
|
|
50
96
|
query: query,
|
|
51
97
|
cardinality: :multi,
|
|
52
98
|
)
|
|
53
99
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
100
|
+
case scope_type
|
|
101
|
+
when :instance
|
|
102
|
+
# Instance-scoped multi-index (existing behavior)
|
|
103
|
+
generate_factory_method(scope_class, index_name)
|
|
104
|
+
generate_query_methods_destination(indexed_class, field, scope_class, index_name) if query
|
|
105
|
+
generate_mutation_methods_self(indexed_class, field, scope_class, index_name)
|
|
106
|
+
when :class
|
|
107
|
+
# Class-level multi-index (new behavior)
|
|
108
|
+
generate_factory_method_class(indexed_class, index_name)
|
|
109
|
+
generate_query_methods_class(indexed_class, field, index_name) if query
|
|
110
|
+
generate_mutation_methods_class(indexed_class, field, index_name)
|
|
62
111
|
end
|
|
63
|
-
|
|
64
|
-
# Generate mutation methods on the indexed class
|
|
65
|
-
generate_mutation_methods_self(indexed_class, field, resolved_class, index_name)
|
|
66
112
|
end
|
|
67
113
|
|
|
68
114
|
# Generates the factory method ON THE SCOPE CLASS (Company when within: Company):
|
|
@@ -325,6 +371,226 @@ module Familia
|
|
|
325
371
|
end
|
|
326
372
|
end
|
|
327
373
|
end
|
|
374
|
+
|
|
375
|
+
# =========================================================================
|
|
376
|
+
# CLASS-LEVEL MULTI-INDEX GENERATORS
|
|
377
|
+
# =========================================================================
|
|
378
|
+
#
|
|
379
|
+
# When within: :class is used, these generators create class-level methods
|
|
380
|
+
# instead of instance-scoped methods.
|
|
381
|
+
#
|
|
382
|
+
# Example:
|
|
383
|
+
# multi_index :role, :role_index # within: :class is default
|
|
384
|
+
#
|
|
385
|
+
# Generates on Customer (class methods):
|
|
386
|
+
# - Customer.role_index_for('admin') -> UnsortedSet factory
|
|
387
|
+
# - Customer.find_all_by_role('admin') -> [Customer, ...]
|
|
388
|
+
# - Customer.sample_from_role('admin', 3) -> random sample
|
|
389
|
+
# - Customer.rebuild_role_index -> rebuild index
|
|
390
|
+
#
|
|
391
|
+
# Generates on Customer (instance methods, auto-called on save):
|
|
392
|
+
# - customer.add_to_class_role_index
|
|
393
|
+
# - customer.remove_from_class_role_index
|
|
394
|
+
# - customer.update_in_class_role_index(old_value)
|
|
395
|
+
|
|
396
|
+
# Generates class-level factory method:
|
|
397
|
+
# - Customer.role_index_for(field_value) -> UnsortedSet
|
|
398
|
+
#
|
|
399
|
+
# The factory validates field values for data quality. Glob pattern
|
|
400
|
+
# characters (*, ?, [, ]) in field values are allowed but trigger
|
|
401
|
+
# warnings since they can be confusing during debugging.
|
|
402
|
+
#
|
|
403
|
+
# @param indexed_class [Class] The class being indexed (e.g., Customer)
|
|
404
|
+
# @param index_name [Symbol] Name of the index (e.g., :role_index)
|
|
405
|
+
def generate_factory_method_class(indexed_class, index_name)
|
|
406
|
+
# Capture index_name for use in validation context
|
|
407
|
+
idx_name = index_name
|
|
408
|
+
indexed_class.define_singleton_method(:"#{index_name}_for") do |field_value|
|
|
409
|
+
# Validate field value and use the validated string for consistent key format.
|
|
410
|
+
# Validation returns nil for nil/empty values, string otherwise.
|
|
411
|
+
# We allow nil through (creates a "null" index key) but use the validated
|
|
412
|
+
# string to ensure consistent type handling in key construction.
|
|
413
|
+
validated = MultiIndexGenerators.validate_field_value(field_value, context: "#{name}.#{idx_name}")
|
|
414
|
+
index_key = Familia.join(index_name, validated)
|
|
415
|
+
Familia::UnsortedSet.new(index_key, parent: self)
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Generates class-level query methods:
|
|
420
|
+
# - Customer.find_all_by_role(value) -> [Customer, ...]
|
|
421
|
+
# - Customer.sample_from_role(value, count) -> random sample
|
|
422
|
+
# - Customer.rebuild_role_index -> rebuild index
|
|
423
|
+
#
|
|
424
|
+
# @param indexed_class [Class] The class being indexed (e.g., Customer)
|
|
425
|
+
# @param field [Symbol] The field to index (e.g., :role)
|
|
426
|
+
# @param index_name [Symbol] Name of the index (e.g., :role_index)
|
|
427
|
+
def generate_query_methods_class(indexed_class, field, index_name)
|
|
428
|
+
# find_all_by_role(value)
|
|
429
|
+
# Uses load_multi for efficient batch loading (avoids N+1 queries)
|
|
430
|
+
indexed_class.define_singleton_method(:"find_all_by_#{field}") do |field_value|
|
|
431
|
+
index_set = send("#{index_name}_for", field_value)
|
|
432
|
+
identifiers = index_set.members
|
|
433
|
+
load_multi(identifiers).compact
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# sample_from_role(value, count)
|
|
437
|
+
# Uses load_multi for efficient batch loading (avoids N+1 queries)
|
|
438
|
+
indexed_class.define_singleton_method(:"sample_from_#{field}") do |field_value, count = 1|
|
|
439
|
+
return [] if field_value.nil? || field_value.to_s.strip.empty?
|
|
440
|
+
|
|
441
|
+
index_set = send("#{index_name}_for", field_value)
|
|
442
|
+
identifiers = index_set.sample(count)
|
|
443
|
+
load_multi(identifiers).compact
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# rebuild_role_index(batch_size:, &progress)
|
|
447
|
+
# For class-level indexes, we iterate all instances of the class
|
|
448
|
+
indexed_class.define_singleton_method(:"rebuild_#{index_name}") do |batch_size: 100, &progress_block|
|
|
449
|
+
# PHASE 1: Discover all field values and collect objects
|
|
450
|
+
progress_block&.call(phase: :discovering, current: 0, total: 0)
|
|
451
|
+
|
|
452
|
+
# Use class-level instances collection if available
|
|
453
|
+
unless respond_to?(:instances) && instances.respond_to?(:members)
|
|
454
|
+
Familia.warn "[Rebuild] Cannot rebuild class-level multi-index #{index_name}: " \
|
|
455
|
+
"no instances collection found. " \
|
|
456
|
+
"Ensure #{name} has class_sorted_set :instances or similar."
|
|
457
|
+
return 0 # Return 0 for consistency - always return integer count
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
field_values = Set.new
|
|
461
|
+
cached_objects = []
|
|
462
|
+
processed = 0
|
|
463
|
+
total_count = instances.size
|
|
464
|
+
|
|
465
|
+
progress_block&.call(phase: :loading, current: 0, total: total_count)
|
|
466
|
+
|
|
467
|
+
instances.members.each_slice(batch_size) do |identifiers|
|
|
468
|
+
objects = load_multi(identifiers).compact
|
|
469
|
+
cached_objects.concat(objects)
|
|
470
|
+
|
|
471
|
+
objects.each do |obj|
|
|
472
|
+
value = obj.send(field)
|
|
473
|
+
field_values << value.to_s if value && !value.to_s.strip.empty?
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
processed += identifiers.size
|
|
477
|
+
progress_block&.call(phase: :loading, current: processed, total: total_count)
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# PHASE 2: Clear existing index sets using SCAN
|
|
481
|
+
progress_block&.call(phase: :clearing, current: 0, total: field_values.size)
|
|
482
|
+
|
|
483
|
+
# Get pattern for all index keys: "customer:role_index:*"
|
|
484
|
+
#
|
|
485
|
+
# Security note: The SCAN pattern is safe because:
|
|
486
|
+
# 1. The namespace prefix (e.g., "customer:role_index:") is derived from
|
|
487
|
+
# class metadata and index name, not user input
|
|
488
|
+
# 2. The "*" wildcard only matches keys within this namespace
|
|
489
|
+
# 3. Glob characters stored IN field values (e.g., a role named "admin*")
|
|
490
|
+
# are literal characters in the key name, not SCAN wildcards
|
|
491
|
+
# 4. SCAN cannot match keys outside the namespace prefix
|
|
492
|
+
sample_index = send(:"#{index_name}_for", "*")
|
|
493
|
+
index_pattern = sample_index.dbkey
|
|
494
|
+
|
|
495
|
+
cleared_count = 0
|
|
496
|
+
dbclient.scan_each(match: index_pattern) do |key|
|
|
497
|
+
dbclient.del(key)
|
|
498
|
+
cleared_count += 1
|
|
499
|
+
progress_block&.call(phase: :clearing, current: cleared_count, total: field_values.size, key: key)
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# PHASE 3: Rebuild from cached objects
|
|
503
|
+
progress_block&.call(phase: :rebuilding, current: 0, total: cached_objects.size)
|
|
504
|
+
|
|
505
|
+
processed = 0
|
|
506
|
+
cached_objects.each_slice(batch_size) do |objects|
|
|
507
|
+
dbclient.multi do |conn|
|
|
508
|
+
objects.each do |obj|
|
|
509
|
+
field_value = obj.send(field)
|
|
510
|
+
next unless field_value && !field_value.to_s.strip.empty?
|
|
511
|
+
|
|
512
|
+
index_set = send("#{index_name}_for", field_value)
|
|
513
|
+
# Use JsonSerializer for consistent serialization with update method
|
|
514
|
+
serialized_id = Familia::JsonSerializer.dump(obj.identifier)
|
|
515
|
+
conn.sadd(index_set.dbkey, serialized_id)
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
processed += objects.size
|
|
520
|
+
progress_block&.call(phase: :rebuilding, current: processed, total: cached_objects.size)
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
Familia.info "[Rebuild] Class-level multi-index #{index_name} rebuilt: " \
|
|
524
|
+
"#{field_values.size} field values, #{processed} objects"
|
|
525
|
+
|
|
526
|
+
processed
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# Generates instance mutation methods for class-level indexes:
|
|
531
|
+
# - customer.add_to_class_role_index
|
|
532
|
+
# - customer.remove_from_class_role_index
|
|
533
|
+
# - customer.update_in_class_role_index(old_value)
|
|
534
|
+
#
|
|
535
|
+
# These are auto-called on save/destroy when auto-indexing is enabled.
|
|
536
|
+
#
|
|
537
|
+
# @param indexed_class [Class] The class being indexed (e.g., Customer)
|
|
538
|
+
# @param field [Symbol] The field to index (e.g., :role)
|
|
539
|
+
# @param index_name [Symbol] Name of the index (e.g., :role_index)
|
|
540
|
+
def generate_mutation_methods_class(indexed_class, field, index_name)
|
|
541
|
+
indexed_class.class_eval do
|
|
542
|
+
method_name = :"add_to_class_#{index_name}"
|
|
543
|
+
Familia.debug("[MultiIndexGenerators] #{name} class method #{method_name}")
|
|
544
|
+
|
|
545
|
+
define_method(method_name) do
|
|
546
|
+
field_value = send(field)
|
|
547
|
+
return unless field_value && !field_value.to_s.strip.empty?
|
|
548
|
+
|
|
549
|
+
index_set = self.class.send("#{index_name}_for", field_value)
|
|
550
|
+
index_set.add(identifier)
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
method_name = :"remove_from_class_#{index_name}"
|
|
554
|
+
Familia.debug("[MultiIndexGenerators] #{name} class method #{method_name}")
|
|
555
|
+
|
|
556
|
+
define_method(method_name) do
|
|
557
|
+
field_value = send(field)
|
|
558
|
+
return unless field_value && !field_value.to_s.strip.empty?
|
|
559
|
+
|
|
560
|
+
index_set = self.class.send("#{index_name}_for", field_value)
|
|
561
|
+
index_set.remove(identifier)
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
method_name = :"update_in_class_#{index_name}"
|
|
565
|
+
Familia.debug("[MultiIndexGenerators] #{name} class method #{method_name}")
|
|
566
|
+
|
|
567
|
+
define_method(method_name) do |old_field_value|
|
|
568
|
+
return unless old_field_value
|
|
569
|
+
|
|
570
|
+
new_field_value = send(field)
|
|
571
|
+
return if old_field_value == new_field_value
|
|
572
|
+
|
|
573
|
+
# Get the index sets for old and new values
|
|
574
|
+
old_set = self.class.send("#{index_name}_for", old_field_value)
|
|
575
|
+
|
|
576
|
+
# Use DataType's serialize_value for consistency with add/remove methods.
|
|
577
|
+
# This ensures the same serialization path is used across all index operations.
|
|
578
|
+
serialized_id = old_set.serialize_value(identifier)
|
|
579
|
+
|
|
580
|
+
# Use transaction for atomic remove + add to prevent data inconsistency
|
|
581
|
+
transaction do |conn|
|
|
582
|
+
# Remove from old index
|
|
583
|
+
conn.srem(old_set.dbkey, serialized_id)
|
|
584
|
+
|
|
585
|
+
# Add to new index if present
|
|
586
|
+
if new_field_value && !new_field_value.to_s.strip.empty?
|
|
587
|
+
new_set = self.class.send("#{index_name}_for", new_field_value)
|
|
588
|
+
conn.sadd(new_set.dbkey, serialized_id)
|
|
589
|
+
end
|
|
590
|
+
end
|
|
591
|
+
end
|
|
592
|
+
end
|
|
593
|
+
end
|
|
328
594
|
end
|
|
329
595
|
end
|
|
330
596
|
end
|
|
@@ -98,7 +98,7 @@ module Familia
|
|
|
98
98
|
# @example Instance-scoped multi-value indexing
|
|
99
99
|
# multi_index :department, :dept_index, within: Company
|
|
100
100
|
#
|
|
101
|
-
def multi_index(field, index_name, within
|
|
101
|
+
def multi_index(field, index_name, within: :class, query: true)
|
|
102
102
|
MultiIndexGenerators.setup(
|
|
103
103
|
indexed_class: self,
|
|
104
104
|
field: field,
|
|
@@ -159,12 +159,14 @@ module Familia
|
|
|
159
159
|
index_name = config.index_name
|
|
160
160
|
old_field_value = old_values[field]
|
|
161
161
|
|
|
162
|
-
# Determine which update method to call
|
|
163
|
-
|
|
164
|
-
|
|
162
|
+
# Determine which update method to call based on scope type
|
|
163
|
+
# Class-level: within is nil (unique_index default) or :class (multi_index default)
|
|
164
|
+
# Instance-scoped: within is a specific class
|
|
165
|
+
if config.within.nil? || config.within == :class
|
|
166
|
+
# Class-level index (auto-indexed on save)
|
|
165
167
|
send("update_in_class_#{index_name}", old_field_value)
|
|
166
168
|
else
|
|
167
|
-
# Instance-scoped index
|
|
169
|
+
# Instance-scoped index - requires explicit scope context
|
|
168
170
|
next unless scope_context
|
|
169
171
|
|
|
170
172
|
# Use config_name for method naming
|
|
@@ -183,12 +185,14 @@ module Familia
|
|
|
183
185
|
self.class.indexing_relationships.each do |config|
|
|
184
186
|
index_name = config.index_name
|
|
185
187
|
|
|
186
|
-
# Determine which remove method to call
|
|
187
|
-
|
|
188
|
-
|
|
188
|
+
# Determine which remove method to call based on scope type
|
|
189
|
+
# Class-level: within is nil (unique_index default) or :class (multi_index default)
|
|
190
|
+
# Instance-scoped: within is a specific class
|
|
191
|
+
if config.within.nil? || config.within == :class
|
|
192
|
+
# Class-level index (auto-indexed on save)
|
|
189
193
|
send("remove_from_class_#{index_name}")
|
|
190
194
|
else
|
|
191
|
-
# Instance-scoped index
|
|
195
|
+
# Instance-scoped index - requires explicit scope context
|
|
192
196
|
next unless scope_context
|
|
193
197
|
|
|
194
198
|
# Use config_name for method naming
|
|
@@ -216,20 +220,38 @@ module Familia
|
|
|
216
220
|
|
|
217
221
|
next unless field_value
|
|
218
222
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
+
# Class-level indexes have within: nil (unique_index) or within: :class (multi_index)
|
|
224
|
+
# Instance-scoped indexes have within: SomeClass (a specific class)
|
|
225
|
+
if config.within.nil? || config.within == :class
|
|
226
|
+
if cardinality == :unique
|
|
227
|
+
# Class-level unique index - check hash key using DataType
|
|
228
|
+
index_hash = self.class.send(index_name)
|
|
229
|
+
next unless index_hash.key?(field_value.to_s)
|
|
223
230
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
231
|
+
memberships << {
|
|
232
|
+
scope_class: 'class',
|
|
233
|
+
index_name: index_name,
|
|
234
|
+
field: field,
|
|
235
|
+
field_value: field_value,
|
|
236
|
+
index_key: index_hash.dbkey,
|
|
237
|
+
cardinality: cardinality,
|
|
238
|
+
type: 'unique_index',
|
|
239
|
+
}
|
|
240
|
+
else
|
|
241
|
+
# Class-level multi index - check set membership using factory method
|
|
242
|
+
index_set = self.class.send("#{index_name}_for", field_value)
|
|
243
|
+
next unless index_set.member?(identifier)
|
|
244
|
+
|
|
245
|
+
memberships << {
|
|
246
|
+
scope_class: 'class',
|
|
247
|
+
index_name: index_name,
|
|
248
|
+
field: field,
|
|
249
|
+
field_value: field_value,
|
|
250
|
+
index_key: index_set.dbkey,
|
|
251
|
+
cardinality: cardinality,
|
|
252
|
+
type: 'multi_index',
|
|
253
|
+
}
|
|
254
|
+
end
|
|
233
255
|
else
|
|
234
256
|
# Instance-scoped index (unique_index or multi_index with within:) - cannot check without scope instance
|
|
235
257
|
# This would require scanning all possible scope instances
|
|
@@ -250,7 +272,7 @@ module Familia
|
|
|
250
272
|
end
|
|
251
273
|
|
|
252
274
|
# Check if this object is indexed in a specific scope
|
|
253
|
-
# For class-level indexes, checks the hash key
|
|
275
|
+
# For class-level indexes, checks the hash key (unique) or set membership (multi)
|
|
254
276
|
# For instance-scoped indexes, returns false (requires scope instance)
|
|
255
277
|
def indexed_in?(index_name)
|
|
256
278
|
return false unless self.class.respond_to?(:indexing_relationships)
|
|
@@ -262,10 +284,18 @@ module Familia
|
|
|
262
284
|
field_value = send(field)
|
|
263
285
|
return false unless field_value
|
|
264
286
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
287
|
+
# Class-level indexes have within: nil (unique_index) or within: :class (multi_index)
|
|
288
|
+
# Instance-scoped indexes have within: SomeClass (a specific class)
|
|
289
|
+
if config.within.nil? || config.within == :class
|
|
290
|
+
if config.cardinality == :unique
|
|
291
|
+
# Class-level unique index - check hash key using DataType
|
|
292
|
+
index_hash = self.class.send(index_name)
|
|
293
|
+
index_hash.key?(field_value.to_s)
|
|
294
|
+
else
|
|
295
|
+
# Class-level multi index - check set membership using factory method
|
|
296
|
+
index_set = self.class.send("#{index_name}_for", field_value)
|
|
297
|
+
index_set.member?(identifier)
|
|
298
|
+
end
|
|
269
299
|
else
|
|
270
300
|
# Instance-scoped index (with within:) - cannot verify without scope instance
|
|
271
301
|
false
|
|
@@ -156,7 +156,10 @@ module Familia
|
|
|
156
156
|
# doesn't, we return nil. If it does, we proceed to load the object.
|
|
157
157
|
# Otherwise, hgetall will return an empty hash, which will be passed to
|
|
158
158
|
# the constructor, which will then be annoying to debug.
|
|
159
|
-
|
|
159
|
+
unless does_exist
|
|
160
|
+
cleanup_stale_instance_entry(objkey)
|
|
161
|
+
return nil
|
|
162
|
+
end
|
|
160
163
|
else
|
|
161
164
|
# Optimized mode: Skip existence check
|
|
162
165
|
Familia.debug "[find_by_key] #{self} from key #{objkey} (check_exists: false)"
|
|
@@ -166,12 +169,42 @@ module Familia
|
|
|
166
169
|
obj = dbclient.hgetall(objkey) # horreum objects are persisted as database hashes
|
|
167
170
|
Familia.trace :FIND_BY_DBKEY_INSPECT, nil, "#{objkey}: #{obj.inspect}"
|
|
168
171
|
|
|
169
|
-
#
|
|
170
|
-
|
|
172
|
+
# Always check for empty hash to handle race conditions where the key
|
|
173
|
+
# expires between EXISTS check and HGETALL (when check_exists: true),
|
|
174
|
+
# or simply doesn't exist (when check_exists: false).
|
|
175
|
+
if obj.empty?
|
|
176
|
+
cleanup_stale_instance_entry(objkey)
|
|
177
|
+
return nil
|
|
178
|
+
end
|
|
171
179
|
|
|
172
180
|
# Create instance and deserialize fields using shared helper method
|
|
173
181
|
instantiate_from_hash(obj)
|
|
174
182
|
end
|
|
183
|
+
|
|
184
|
+
# Removes a stale entry from the instances sorted set.
|
|
185
|
+
# Called when find_by_dbkey detects that an object no longer exists
|
|
186
|
+
# (either EXISTS returned false, or HGETALL returned empty hash).
|
|
187
|
+
#
|
|
188
|
+
# This provides lazy cleanup of phantom instance entries that can
|
|
189
|
+
# accumulate when objects expire via TTL without explicit destroy!
|
|
190
|
+
#
|
|
191
|
+
# @param objkey [String] The full database key (prefix:identifier:suffix)
|
|
192
|
+
# @return [void]
|
|
193
|
+
# @api private
|
|
194
|
+
def cleanup_stale_instance_entry(objkey)
|
|
195
|
+
return unless respond_to?(:instances)
|
|
196
|
+
|
|
197
|
+
# Key format is prefix:identifier:suffix, so identifier is at index 1
|
|
198
|
+
parts = Familia.split(objkey)
|
|
199
|
+
return unless parts.length >= 2
|
|
200
|
+
|
|
201
|
+
identifier = parts[1]
|
|
202
|
+
return if identifier.nil? || identifier.empty?
|
|
203
|
+
|
|
204
|
+
instances.remove(identifier)
|
|
205
|
+
Familia.debug "[find_by_dbkey] Removed stale instance entry: #{identifier}"
|
|
206
|
+
end
|
|
207
|
+
private :cleanup_stale_instance_entry
|
|
175
208
|
alias find_by_key find_by_dbkey
|
|
176
209
|
|
|
177
210
|
# Retrieves and instantiates an object from Database using its identifier.
|
|
@@ -589,7 +589,10 @@ module Familia
|
|
|
589
589
|
# Skip instance-scoped indexes (require scope context)
|
|
590
590
|
# Instance-scoped indexes must be manually populated because they need
|
|
591
591
|
# the scope instance reference (e.g., employee.add_to_company_badge_index(company))
|
|
592
|
-
|
|
592
|
+
#
|
|
593
|
+
# Class-level indexes have within: nil (unique_index) or within: :class (multi_index)
|
|
594
|
+
# Instance-scoped indexes have within: SomeClass (a specific class)
|
|
595
|
+
if rel.within && rel.within != :class
|
|
593
596
|
Familia.debug <<~LOG_MESSAGE
|
|
594
597
|
[auto_update_class_indexes] Skipping #{rel.index_name} (requires scope context)
|
|
595
598
|
LOG_MESSAGE
|
|
@@ -10,10 +10,10 @@ module Familia
|
|
|
10
10
|
#
|
|
11
11
|
class Horreum
|
|
12
12
|
# Settings - Instance-level configuration methods for Horreum models
|
|
13
|
-
# Provides per-instance settings like logical_database,
|
|
13
|
+
# Provides per-instance settings like logical_database, suffix
|
|
14
14
|
#
|
|
15
15
|
module Settings
|
|
16
|
-
attr_writer :
|
|
16
|
+
attr_writer :suffix
|
|
17
17
|
|
|
18
18
|
def opts
|
|
19
19
|
@opts ||= {}
|
|
@@ -40,14 +40,6 @@ module Familia
|
|
|
40
40
|
def suffix
|
|
41
41
|
@suffix || self.class.suffix
|
|
42
42
|
end
|
|
43
|
-
|
|
44
|
-
def dump_method
|
|
45
|
-
@dump_method || self.class.dump_method
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def load_method
|
|
49
|
-
@load_method || self.class.load_method
|
|
50
|
-
end
|
|
51
43
|
end
|
|
52
44
|
end
|
|
53
45
|
end
|
data/lib/familia/horreum.rb
CHANGED
|
@@ -80,13 +80,12 @@ module Familia
|
|
|
80
80
|
# Class-Level Attributes:
|
|
81
81
|
# * @parent - Parent object reference for nested relationships
|
|
82
82
|
# * @dbclient - Database connection override for this class
|
|
83
|
-
# * @dump_method/@load_method - Serialization method configuration
|
|
84
83
|
# * @has_related_fields - Flag indicating if DataType relationships are defined
|
|
85
84
|
#
|
|
86
85
|
class << self
|
|
87
86
|
attr_accessor :parent
|
|
88
87
|
# TODO: Where are we calling dbclient= from now with connection pool?
|
|
89
|
-
attr_writer :dbclient
|
|
88
|
+
attr_writer :dbclient
|
|
90
89
|
attr_reader :has_related_fields
|
|
91
90
|
|
|
92
91
|
# Extends ClassMethods to subclasses and tracks Familia members
|
data/lib/familia/version.rb
CHANGED