Skip to content

Commit 2cdad61

Browse files
authored
Add spec for ar intergration and context (#62)
* Explicitly show no context set when using AR preloading * Raise error when n1_bind_to received invalid arguments * Refactor syntax and fix possible thread safe issue * Fix context propagation when using n1_bind_to
1 parent 6b541ac commit 2cdad61

8 files changed

Lines changed: 99 additions & 81 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
## [Unreleased]
2+
3+
- Fix a rare thread-safety issue for setting context
4+
- Raise errors if `n1_bind_to` received unexpected arguments
5+
- Fix context propagation to loaded objects when using `n1_bind_to`
6+
17
## [2.2.0] - 2026/03/16
28

39
- Support both identity and equality comparison in `Loader#for`. Identity lookup (via `object_id`) is tried first, with equality lookup as a fallback. Thanks [Alfonso Uceda](https://github.com/AlfonsoUceda) for reporting the issue!

lib/n1_loader.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ class NotLoaded < Error; end
1515
class NotFilled < Error; end
1616
class MissingArgument < Error; end
1717
class InvalidArgument < Error; end
18+
class InvalidBinding < Error; end
1819
end
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# frozen_string_literal: true
22

33
N1Loader::Loader.define_method :preloaded_records do
4-
@preloaded_records ||= loaded.values.flatten
4+
@preloaded_records ||= loaded? && loaded_by_value.values.flatten
55
end

lib/n1_loader/ar_lazy_preload/loader_patch.rb

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,22 @@ module ArLazyPreload
66
module LoaderPatch
77
attr_accessor :context_setup
88

9-
def loaded
10-
return @loaded_by_identity if @already_loaded && @already_context
9+
def loaded?
10+
return true if @already_loaded && @already_context
1111

1212
super
1313

14-
synchronize do
15-
context_setup&.call(@loaded_by_identity.values.flatten) unless @already_context
16-
end
14+
synchronize { non_thread_safe_context_setting unless @already_context }
15+
16+
true
17+
end
18+
19+
def non_thread_safe_context_setting
20+
return if @already_context
21+
22+
context_setup&.call(loaded_by_identity.values.flatten)
1723

1824
@already_context = true
19-
@loaded_by_identity
2025
end
2126
end
2227
end

lib/n1_loader/core/loadable.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,21 @@ def n1_loader(name)
3333
end
3434

3535
def n1_bind_to(collection)
36+
unless collection.is_a?(Array) && collection.any? do |obj|
37+
obj == self || obj.equal?(self)
38+
end
39+
40+
raise InvalidBinding,
41+
"assigned collection should be array and include object"
42+
end
43+
3644
@n1_binding = collection
3745
end
3846

47+
def n1_bind_to?
48+
!@n1_binding.nil?
49+
end
50+
3951
def n1_loader_reload(name)
4052
elements = @n1_binding || [self]
4153
collection = LoaderCollection.new(self.class.n1_loaders[name], elements)

lib/n1_loader/core/loader.rb

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,14 @@ def initialize(elements, **args)
4747
end
4848

4949
def for(element)
50-
identity_loaded = loaded
50+
return unless loaded?
5151

52-
if identity_loaded.empty? && elements.any?
52+
if loaded_by_identity.empty? && elements.any?
5353
raise NotFilled, "Nothing was preloaded, perhaps you forgot to use fulfill method"
5454
end
5555

56-
return identity_loaded[element] if identity_loaded.key?(element)
57-
return @loaded_by_value[element] if @loaded_by_value.key?(element)
56+
return loaded_by_identity[element] if loaded_by_identity.key?(element)
57+
return loaded_by_value[element] if loaded_by_value.key?(element)
5858

5959
raise NotLoaded, "The data was not preloaded for the given element"
6060
end
@@ -66,7 +66,7 @@ def cache_key
6666

6767
private
6868

69-
attr_reader :elements, :args
69+
attr_reader :elements, :args, :loaded_by_value, :loaded_by_identity
7070

7171
def check_missing_arguments!
7272
return unless (arguments = self.class.arguments)
@@ -107,22 +107,19 @@ def perform(_elements)
107107
end
108108

109109
def fulfill(element, value)
110-
@loaded_by_identity[element] = value
111-
@loaded_by_value[element] = value
110+
loaded_by_identity[element] = value
111+
loaded_by_value[element] = value
112112
end
113113

114-
def ensure_loaded
115-
return if @already_loaded
114+
def loaded?
115+
return true if @already_loaded
116116

117-
synchronize { non_thread_safe_loaded unless @already_loaded }
118-
end
117+
synchronize { non_thread_safe_loading unless @already_loaded }
119118

120-
def loaded
121-
ensure_loaded
122-
@loaded_by_identity
119+
true
123120
end
124121

125-
def non_thread_safe_loaded # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
122+
def non_thread_safe_loading # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
126123
return if @already_loaded
127124

128125
check_arguments!
@@ -133,8 +130,13 @@ def non_thread_safe_loaded # rubocop:disable Metrics/AbcSize, Metrics/MethodLeng
133130
if respond_to?(:single) && elements.size == 1
134131
fulfill(elements.first, single(elements.first))
135132
elsif elements.any?
136-
elements.each { |el| el.n1_bind_to(elements) if el.respond_to?(:n1_bind_to) }
137133
perform(elements)
134+
135+
# propagate context to loaded objects only when it was set
136+
if elements.first.respond_to?(:n1_bind_to?) && elements.first.n1_bind_to?
137+
loaded_objects = loaded_by_identity.values.flatten
138+
loaded_objects.each { |el| el.n1_bind_to(loaded_objects) if el.respond_to?(:n1_bind_to) }
139+
end
138140
end
139141

140142
@already_loaded = true

spec/activerecord_spec.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,21 @@ def perform(elements)
233233
.and change(Entity, :count).by(1)
234234
end
235235

236+
context "when preloading AR" do
237+
let(:objects) { Entity.includes(:company) }
238+
239+
it "doesn't set context further for N1Loader" do
240+
expect do
241+
objects.each do |object|
242+
expect(object.company.data).to eq(object)
243+
end
244+
.to make_database_queries(matching: /companies/, count: 1)
245+
.and make_database_queries(matching: /entities/, count: 2)
246+
.and make_database_queries(count: 3)
247+
end
248+
end
249+
end
250+
236251
context "with arguments" do
237252
before { skip "unsupported by ArLazyPreload" if ar_lazy_preload_defined? }
238253

spec/n1_loader_spec.rb

Lines changed: 35 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,16 @@ def perform(elements)
121121
end
122122
end
123123
end
124+
125+
n1_optimized :new_objects do |elements|
126+
elements.first.class.perform!
127+
128+
objects = elements.map { elements.first.class.new }
129+
130+
elements.each_with_index do |element, index|
131+
fulfill(element, objects[index])
132+
end
133+
end
124134
end
125135
end
126136

@@ -475,11 +485,14 @@ def perform(elements)
475485
end
476486

477487
describe "n1_bind_to" do
478-
it "returns correct data for each bound object" do
479-
objects.each { |obj| obj.n1_bind_to(objects) }
488+
it "raises error if invalid collection was passed" do
489+
expect do
490+
objects.each { |obj| obj.n1_bind_to([]) }
491+
end.to raise_error N1Loader::InvalidBinding
480492

481-
expect(objects.first.inline).to eq([objects.first])
482-
expect(objects.last.inline).to eq([objects.last])
493+
expect do
494+
objects.first.n1_bind_to([objects.last])
495+
end.to raise_error N1Loader::InvalidBinding
483496
end
484497

485498
it "loads all bound objects in a single batch" do
@@ -501,67 +514,31 @@ def perform(elements)
501514
expect { objects.first.inline }.to change(klass, :count).by(1)
502515
expect { objects.last.inline }.not_to change(klass, :count)
503516
end
504-
end
505-
506-
describe "automatic context binding" do
507-
let(:nested_klass) do
508-
Class.new do
509-
include N1Loader::Loadable
510-
511-
class << self
512-
def perform!(loader)
513-
@counts ||= {}
514-
@counts[loader] = (@counts[loader] || 0) + 1
515-
end
516517

517-
def count(loader)
518-
@counts&.fetch(loader, 0) || 0
519-
end
520-
end
521-
522-
n1_optimized :first_loader do |elements|
523-
elements.first.class.perform!(:first_loader)
524-
elements.each { |el| fulfill(el, el) }
525-
end
526-
527-
n1_optimized :second_loader do |elements|
528-
elements.first.class.perform!(:second_loader)
529-
elements.each { |el| fulfill(el, el) }
530-
end
531-
end
532-
end
533-
534-
let(:nested_objects) { [nested_klass.new, nested_klass.new] }
535-
536-
it "automatically sets shared context when loaded through N1Loader" do
537-
N1Loader::Preloader.new(nested_objects).preload(:first_loader)
538-
539-
# Accessing first_loader triggers perform([obj1, obj2]),
540-
# which automatically calls n1_bind_to on both objects
541-
nested_objects.first.first_loader
542-
543-
# Both objects are now auto-bound; second_loader should batch in one perform call
544-
expect { nested_objects.map(&:second_loader) }.to change { nested_klass.count(:second_loader) }.by(1)
545-
end
546-
547-
it "auto-binds so second access on sibling does not trigger another load" do
548-
N1Loader::Preloader.new(nested_objects).preload(:first_loader)
518+
it "propagates context to loaded objects" do
519+
objects.each { |obj| obj.n1_bind_to(objects) }
549520

550-
nested_objects.first.first_loader
521+
expect do
522+
objects.each(&:new_objects)
523+
end.to change(klass, :count).by(1)
551524

552-
nested_objects.first.second_loader
553-
expect { nested_objects.last.second_loader }.not_to change { nested_klass.count(:second_loader) } # rubocop:disable Lint/AmbiguousBlockAssociation
525+
expect do
526+
objects.map(&:new_objects).map(&:inline)
527+
end.to change(klass, :count).by(1)
554528
end
529+
end
555530

556-
it "auto-binds single element to a collection containing only itself" do
557-
single = nested_klass.new
558-
N1Loader::Preloader.new([single]).preload(:first_loader)
531+
describe "with preloader" do
532+
it "doesn't propagate context to loaded objects" do
533+
N1Loader::Preloader.new(objects).preload(:new_objects)
559534

560-
single.first_loader
535+
expect do
536+
objects.each(&:new_objects)
537+
end.to change(klass, :count).by(1)
561538

562-
# Accessing second_loader on the single element should still work correctly
563-
expect { single.second_loader }.to change { nested_klass.count(:second_loader) }.by(1)
564-
expect { single.second_loader }.not_to change { nested_klass.count(:second_loader) } # rubocop:disable Lint/AmbiguousBlockAssociation
539+
expect do
540+
objects.map(&:new_objects).map(&:inline)
541+
end.to change(klass, :count).by(2)
565542
end
566543
end
567544
end

0 commit comments

Comments
 (0)