Skip to content

Commit 1419851

Browse files
committed
Refactor Registry into modular components
Breaks down the 305-line Registry god object into focused, maintainable modules: Structure: - lib/registry.rb (179 lines) - Core class with public API - lib/registry/index_store.rb (63 lines) - Index management - lib/registry/query_cache.rb (48 lines) - Query caching logic - lib/registry/method_watcher.rb (95 lines) - Method watching & cleanup Benefits: - Single Responsibility Principle - each module has one clear purpose - Improved testability - modules can be tested in isolation - Better maintainability - easier to understand and modify - Reduced cognitive load - ~40% reduction in main class size Testing: - All 32 tests pass with 100% coverage - No breaking changes to public API - Full backward compatibility maintained
1 parent b6a28f8 commit 1419851

File tree

4 files changed

+281
-196
lines changed

4 files changed

+281
-196
lines changed

lib/registry.rb

Lines changed: 71 additions & 196 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
# frozen_string_literal: true
22

33
require 'set'
4+
require_relative 'registry/index_store'
5+
require_relative 'registry/query_cache'
6+
require_relative 'registry/method_watcher'
47

58
class Registry < Set
9+
include RegistryIndexStore
10+
include RegistryQueryCache
11+
include RegistryMethodWatcher
12+
613
# Exception classes for better error handling
714
class RegistryError < StandardError; end
815
class MoreThanOneRecordFound < RegistryError; end
@@ -14,15 +21,14 @@ class MissingAttributeError < RegistryError; end
1421
DEFAULT_INDEX = :object_id
1522

1623
def initialize(*args, indexes: [], thread_safe: false)
17-
@indexed = {}
1824
@thread_safe = thread_safe
1925
@mutex = Mutex.new if @thread_safe
20-
@watched_objects = Set.new # Track objects with watched methods for cleanup
21-
@method_cache = {} # Cache setter method lookups
22-
@batch_mode = false # For optimizing bulk operations
23-
@query_cache = {} # Cache frequent query results
24-
@cache_hits = 0 # Performance tracking
25-
@cache_misses = 0
26+
27+
# Initialize module-specific state
28+
initialize_index_store
29+
initialize_query_cache
30+
initialize_method_watcher
31+
2632
super(*args)
2733
reindex!(indexes)
2834
end
@@ -35,10 +41,6 @@ def to_h
3541
@indexed
3642
end
3743

38-
def indexes
39-
@indexed.keys - [:object_id]
40-
end
41-
4244
def delete(item)
4345
@indexed.each do |idx, store|
4446
ignore_setter(item, idx) if include?(item)
@@ -52,9 +54,8 @@ def delete(item)
5254
'is missing or not accessible.'
5355
end
5456
end
55-
@watched_objects.delete(item)
56-
# Invalidate query cache when registry changes
57-
@query_cache.clear
57+
remove_from_watched_objects(item)
58+
invalidate_cache
5859
super
5960
end
6061

@@ -69,9 +70,8 @@ def add(item)
6970
"Item #{item.inspect} cannot be added because indexable attribute '#{idx}' is missing or not accessible."
7071
end
7172
end
72-
@watched_objects.add(item) unless include?(item)
73-
# Invalidate query cache when registry changes
74-
@query_cache.clear
73+
add_to_watched_objects(item) unless include?(item)
74+
invalidate_cache
7575
super
7676
end
7777
alias << add
@@ -85,148 +85,33 @@ def find(search_criteria)
8585
end
8686

8787
def where(search_criteria)
88-
# Check cache first for frequent queries
89-
cache_key = [:where, search_criteria.sort]
90-
if @query_cache.key?(cache_key)
91-
@cache_hits += 1
92-
cached_items = @query_cache[cache_key]
93-
return Registry.new(cached_items.to_a, indexes: indexes, thread_safe: @thread_safe)
94-
end
95-
@cache_misses += 1
96-
97-
# Fast path for single criteria - avoid array creation and reduce operations
98-
if search_criteria.size == 1
99-
idx, value = search_criteria.first
100-
unless @indexed.include?(idx)
101-
raise IndexNotFound,
102-
"Index '#{idx}' not found. Available indexes: #{indexes.inspect}. Add it with '.index(:#{idx})'"
103-
end
104-
105-
result_set = @indexed.dig(idx, value) || Set.new
106-
# Cache the result set for future queries
107-
@query_cache[cache_key] = result_set.dup
108-
return Registry.new(result_set.to_a, indexes: indexes, thread_safe: @thread_safe)
109-
end
110-
111-
# Multi-criteria path - optimize intersection logic
112-
result_set = nil
113-
search_criteria.each do |idx, value|
114-
unless @indexed.include?(idx)
115-
raise IndexNotFound,
116-
"Index '#{idx}' not found. Available indexes: #{indexes.inspect}. Add it with '.index(:#{idx})'"
117-
end
118-
119-
current_set = @indexed.dig(idx, value) || Set.new
120-
result_set = result_set ? (result_set & current_set) : current_set
121-
122-
# Early exit if no intersection possible
123-
break if result_set.empty?
124-
end
125-
126-
final_result = result_set || Set.new
127-
# Cache the result set for future queries (limit cache size)
128-
if @query_cache.size < 1000
129-
@query_cache[cache_key] = final_result.dup
88+
with_thread_safety do
89+
cache_key = [:where, search_criteria.sort]
90+
cached_result = check_cache(cache_key)
91+
return new_registry_from_set(cached_result) if cached_result
92+
93+
result_set = if search_criteria.size == 1
94+
single_criteria_search(search_criteria)
95+
else
96+
multi_criteria_search(search_criteria)
97+
end
98+
store_in_cache(cache_key, result_set)
99+
new_registry_from_set(result_set)
130100
end
131-
132-
Registry.new(final_result.to_a, indexes: indexes, thread_safe: @thread_safe)
133101
end
134102

135103
# Check if any items exist matching the criteria
136104
def exists?(search_criteria)
137105
with_thread_safety do
138-
# Fast path for single criteria
139-
if search_criteria.size == 1
140-
idx, value = search_criteria.first
141-
raise IndexNotFound,
142-
"Index '#{idx}' not found. Available indexes: #{indexes.inspect}. " \
143-
"Add it with '.index(:#{idx})'" unless @indexed.include?(idx)
144-
145-
return @indexed.dig(idx, value)&.any? || false
146-
end
147-
148-
# Multi-criteria path with intersection logic
149-
result_set = nil
150-
search_criteria.each do |idx, value|
151-
raise IndexNotFound,
152-
"Index '#{idx}' not found. Available indexes: #{indexes.inspect}. " \
153-
"Add it with '.index(:#{idx})'" unless @indexed.include?(idx)
154-
155-
current_set = @indexed.dig(idx, value)
156-
return false if current_set.nil? || current_set.empty?
157-
158-
result_set = result_set ? (result_set & current_set) : current_set
159-
return false if result_set.empty?
160-
end
161-
162-
true
163-
end
164-
end
165-
166-
def index(*indexes)
167-
indexes.each do |idx|
168-
warn "Index #{idx} already exists!" and next if @indexed.key?(idx)
169-
170-
# Optimize: Build index hash directly instead of using group_by + transformation
171-
index_hash = {}
172-
each do |item|
173-
watch_setter(item, idx)
174-
@watched_objects.add(item) # Track watched objects
175-
176-
# Get the index value and build the index in one pass
177-
begin
178-
idx_value = item.send(idx)
179-
(index_hash[idx_value] ||= Set.new) << item
180-
rescue NoMethodError
181-
raise MissingAttributeError,
182-
"Item #{item.inspect} cannot be indexed because attribute '#{idx}' is missing or not accessible."
183-
end
184-
end
185-
@indexed[idx] = index_hash
106+
search_criteria.size == 1 ? single_criteria_exists?(search_criteria) : multi_criteria_exists?(search_criteria)
186107
end
187108
end
188109

189-
def reindex!(indexes = [])
110+
def reindex!(new_indexes = [])
190111
cleanup_watched_methods # Clean up before reindexing
191112
@indexed = {}
192-
index(*([DEFAULT_INDEX] | indexes))
193-
end
194-
195-
# Clean up method watching for memory management
196-
def cleanup_watched_methods
197-
@watched_objects.each do |item|
198-
@indexed.each_key { |idx| ignore_setter(item, idx) }
199-
end
200-
@watched_objects.clear
201-
end
202-
203-
# Manual cleanup method for long-lived registries
204-
def cleanup!
205-
cleanup_watched_methods
206-
end
207-
208-
# Cache statistics for performance monitoring
209-
def cache_stats
210-
total_queries = @cache_hits + @cache_misses
211-
return { hits: 0, misses: 0, hit_rate: 0.0, total_queries: 0 } if total_queries == 0
212-
213-
{
214-
hits: @cache_hits,
215-
misses: @cache_misses,
216-
hit_rate: (@cache_hits.to_f / total_queries * 100).round(2),
217-
total_queries: total_queries
218-
}
219-
end
220-
221-
protected
222-
223-
def reindex(idx, item, old_value, new_value)
224-
return unless new_value != old_value
225-
226-
@indexed[idx][old_value].delete item
227-
(@indexed[idx][new_value] ||= Set.new).add item
228-
# Invalidate query cache when items change
229-
@query_cache.clear
113+
@indexes = []
114+
index(*([DEFAULT_INDEX] | new_indexes))
230115
end
231116

232117
private
@@ -237,67 +122,57 @@ def _find(search_criteria)
237122
results.first
238123
end
239124

240-
def watch_setter(item, idx)
241-
return if item.frozen?
125+
def new_registry_from_set(set)
126+
Registry.new(set.to_a, indexes: indexes, thread_safe: @thread_safe)
127+
end
242128

243-
# Use cached method lookup
244-
item_class = item.class
245-
cache_key = [item_class, idx]
246-
247-
setter_method = @method_cache[cache_key] ||= begin
248-
method_name = :"#{idx}="
249-
item_class.instance_methods.include?(method_name) ? method_name : nil
250-
end
251-
252-
return unless setter_method
253-
254-
watched_method = :"__watched_#{setter_method}"
255-
return if item.methods.include?(watched_method)
129+
def validate_index_exists!(idx)
130+
return if index_exists?(idx)
256131

257-
# Optimize: Reduce closure overhead by storing registry reference directly on item
258-
item.instance_variable_set(:@__registry__, self) unless item.instance_variable_defined?(:@__registry__)
259-
original_method = setter_method
260-
renamed_method = :"__unwatched_#{original_method}"
132+
raise IndexNotFound,
133+
"Index '#{idx}' not found. Available indexes: #{indexes.inspect}. Add it with '.index(:#{idx})'"
134+
end
261135

262-
item.singleton_class.class_eval do
263-
define_method(watched_method) do |*args|
264-
old_value = send(idx) # Use direct send instead of item.send
265-
send(renamed_method, *args).tap do |new_value|
266-
instance_variable_get(:@__registry__).send(:reindex, idx, self, old_value, new_value)
267-
end
268-
end
269-
alias_method renamed_method, original_method
270-
alias_method original_method, watched_method
271-
end
136+
def single_criteria_search(search_criteria)
137+
idx, value = search_criteria.first
138+
validate_index_exists!(idx)
139+
lookup_index(idx, value)
272140
end
273141

274-
def ignore_setter(item, idx)
275-
return if item.frozen?
142+
def multi_criteria_search(search_criteria)
143+
result_set = nil
144+
search_criteria.each do |idx, value|
145+
validate_index_exists!(idx)
146+
current_set = lookup_index(idx, value)
147+
result_set = result_set ? (result_set & current_set) : current_set
148+
break if result_set.empty?
149+
end
150+
result_set || Set.new
151+
end
276152

277-
# Use cached method lookup
278-
item_class = item.class
279-
cache_key = [item_class, idx]
280-
setter_method = @method_cache[cache_key]
281-
282-
return unless setter_method
153+
def single_criteria_exists?(search_criteria)
154+
idx, value = search_criteria.first
155+
validate_index_exists!(idx)
156+
lookup_index(idx, value).any?
157+
end
283158

284-
original_method = setter_method
285-
watched_method = :"__watched_#{original_method}"
286-
renamed_method = :"__unwatched_#{original_method}"
287-
288-
return unless item.methods.include?(watched_method)
159+
def multi_criteria_exists?(search_criteria)
160+
result_set = nil
161+
search_criteria.each do |idx, value|
162+
validate_index_exists!(idx)
163+
current_set = lookup_index(idx, value)
164+
return false if current_set.nil? || current_set.empty?
289165

290-
item.singleton_class.class_eval do
291-
alias_method original_method, renamed_method
292-
remove_method(watched_method)
293-
remove_method(renamed_method)
166+
result_set = result_set ? (result_set & current_set) : current_set
167+
return false if result_set.empty?
294168
end
169+
true
295170
end
296171

297172
# Thread safety wrapper
298-
def with_thread_safety
173+
def with_thread_safety(&block)
299174
if @thread_safe
300-
@mutex.synchronize { yield }
175+
@mutex.synchronize(&block)
301176
else
302177
yield
303178
end

0 commit comments

Comments
 (0)