pipeloader 0.0.1 → 0.0.2

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.
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pipeloader
4
+ module Batch
5
+ # A lazy, chainable stand-in for a has_many association whose LOAD is batched
6
+ # across all live siblings of the owner. Query builders (where/order/limit/
7
+ # select/...) accumulate an ActiveRecord::Relation; materializing (each/to_a/
8
+ # map/...) runs ONE query for every sibling, applies the accumulated scope,
9
+ # partitions rows by foreign key, and slices limit/offset PER GROUP.
10
+ #
11
+ # It duck-types the common Relation + Enumerable surface (~80% of a real
12
+ # CollectionProxy). Anything not handled here falls through to the real AR
13
+ # association, so writers (<<, build, create, ...) still work.
14
+ class BatchProxy
15
+ include Enumerable
16
+
17
+ # Pure query refinements that map 1:1 onto AR::Relation and return a new proxy.
18
+ # `where` is defined explicitly (below) so `where.not(...)` chains too.
19
+ QUERY_METHODS = %i[
20
+ rewhere order reorder reselect distinct group having
21
+ joins left_outer_joins includes preload eager_load references unscope readonly
22
+ ].freeze
23
+
24
+ # The ONLY methods delegated to the real AR association. These are writes, so
25
+ # they can't escape the (batched) read path. Everything not handled by this
26
+ # proxy raises NoMethodError instead of silently falling through to a
27
+ # per-record query.
28
+ WRITE_METHODS = %i[
29
+ << concat push create create! build new
30
+ delete destroy delete_all destroy_all clear replace
31
+ ].freeze
32
+
33
+ def initialize(owner, reflection, relation: nil, limit_value: nil, offset_value: nil)
34
+ @owner = owner
35
+ @reflection = reflection
36
+ @relation = relation
37
+ @limit_value = limit_value
38
+ @offset_value = offset_value
39
+ end
40
+
41
+ attr_reader :owner, :limit_value, :offset_value
42
+
43
+ def name
44
+ @reflection.name
45
+ end
46
+
47
+ def foreign_key
48
+ @reflection.foreign_key.to_s
49
+ end
50
+
51
+ def owner_key
52
+ @reflection.active_record_primary_key.to_sym
53
+ end
54
+
55
+ def relation
56
+ @relation ||= apply_reflection_scope(@reflection.klass.all)
57
+ end
58
+
59
+ QUERY_METHODS.each do |meth|
60
+ define_method(meth) do |*args, **kwargs, &block|
61
+ spawn(relation: relation.public_send(meth, *args, **kwargs, &block))
62
+ end
63
+ end
64
+
65
+ WRITE_METHODS.each do |meth|
66
+ define_method(meth) do |*args, **kwargs, &block|
67
+ real_association.public_send(meth, *args, **kwargs, &block)
68
+ end
69
+ end
70
+
71
+ # limit/offset are honored PER GROUP (top-N per owner), applied after the
72
+ # single batched query — not as a global SQL LIMIT that would collapse every
73
+ # owner into one window.
74
+ def limit(value)
75
+ spawn(limit_value: value)
76
+ end
77
+
78
+ def offset(value)
79
+ spawn(offset_value: value)
80
+ end
81
+
82
+ # where(conditions) refines the batched query; bare `where` returns a chain so
83
+ # `where.not(...)` works like a relation's.
84
+ def where(*args, **kwargs, &block)
85
+ return WhereChain.new(self) if args.empty? && kwargs.empty? && block.nil?
86
+
87
+ spawn(relation: relation.where(*args, **kwargs, &block))
88
+ end
89
+
90
+ # select(:cols) refines the query; select { block } filters loaded records
91
+ # (matching ActiveRecord::Relation#select's dual behavior).
92
+ def select(*columns, &block)
93
+ return records.select(&block) if block
94
+
95
+ spawn(relation: relation.select(*columns))
96
+ end
97
+
98
+ def each(&block)
99
+ records.each(&block)
100
+ end
101
+
102
+ def records
103
+ return @records if defined?(@records)
104
+
105
+ @records = Pipeloader::Batch::BatchLoader.load(self)
106
+ end
107
+ alias_method :to_a, :records
108
+ alias_method :to_ary, :records
109
+ alias_method :load, :records
110
+
111
+ def size
112
+ records.size
113
+ end
114
+ alias_method :length, :size
115
+
116
+ def empty?
117
+ records.empty?
118
+ end
119
+
120
+ def last(*args)
121
+ records.last(*args)
122
+ end
123
+
124
+ def [](*args)
125
+ records[*args]
126
+ end
127
+
128
+ def ids
129
+ records.map(&:id)
130
+ end
131
+
132
+ def pluck(*columns)
133
+ records.map { |record| columns.one? ? record[columns.first] : columns.map { |column| record[column] } }
134
+ end
135
+
136
+ # Batched, under our control: both reuse the batched scope rather than
137
+ # issuing a per-owner query.
138
+ def find_by(*args, **kwargs)
139
+ where(*args, **kwargs).first
140
+ end
141
+
142
+ def exists?(*args, **kwargs)
143
+ return !empty? if args.empty? && kwargs.empty?
144
+
145
+ where(*args, **kwargs).any?
146
+ end
147
+
148
+ # Signature of the accumulated scope (without the per-owner FK filter), so
149
+ # siblings asking for the same scope share one batch.
150
+ def cache_signature
151
+ [relation.to_sql, @limit_value, @offset_value]
152
+ end
153
+
154
+ def inspect
155
+ records.inspect
156
+ end
157
+
158
+ private
159
+
160
+ def spawn(relation: nil, limit_value: @limit_value, offset_value: @offset_value)
161
+ self.class.new(
162
+ @owner, @reflection,
163
+ relation: relation || @relation, limit_value: limit_value, offset_value: offset_value
164
+ )
165
+ end
166
+
167
+ # Start from the association's own scope, so `batch_has_many :open, -> { where(state: "open") }`
168
+ # actually narrows. An owner-dependent scope (arity > 0) can't be applied uniformly
169
+ # across owners, so refuse it rather than silently drop or misapply it.
170
+ def apply_reflection_scope(base)
171
+ scope = @reflection.scope
172
+ return base unless scope
173
+ unless scope.arity.zero?
174
+ raise Pipeloader::Batch::Error,
175
+ "#{@reflection.name}'s scope takes the owner as an argument, which can't be batched; " \
176
+ "filter at the call site (owner.#{@reflection.name}.where(...)) instead"
177
+ end
178
+
179
+ base.instance_exec(&scope)
180
+ end
181
+
182
+ # The real AR association reader — used ONLY to delegate the WRITE_METHODS
183
+ # above. The read path never touches it, so reads cannot silently escape
184
+ # batching.
185
+ def real_association
186
+ @real_association ||= @owner.association(@reflection.name).reader
187
+ end
188
+
189
+ # Backs bare `where` so `where.not(...)` / `.missing` / `.associated` chain:
190
+ # forward to the relation's own WhereChain, then re-wrap the refined relation.
191
+ class WhereChain
192
+ def initialize(proxy)
193
+ @proxy = proxy
194
+ end
195
+
196
+ %i[not missing associated].each do |meth|
197
+ define_method(meth) do |*args, **kwargs|
198
+ @proxy.__send__(:spawn, relation: @proxy.relation.where.public_send(meth, *args, **kwargs))
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pipeloader
4
+ module Batch
5
+ # A sibling group: the records loaded together by one query. It lives ON the
6
+ # records (each carries a reference, see Model#_pipeloader_batch_context), not in
7
+ # any thread/fiber/request storage. That is what makes batching correct under
8
+ # GraphQL::Dataloader fibers, fiber-per-request servers, and plain threads alike:
9
+ # the context travels with the data instead of being looked up ambiently.
10
+ class Context
11
+ # Re-entrancy guard for the singular interceptor: while a Preloader fills an
12
+ # association, its own load_target calls must not re-enter. Fiber-local, since a
13
+ # preload runs synchronously in one fiber and sibling fibers preload on their own.
14
+ PRELOADING_KEY = :pipeloader_batch_preloading
15
+
16
+ class << self
17
+ def preloading?
18
+ Thread.current[PRELOADING_KEY] || false
19
+ end
20
+
21
+ def while_preloading
22
+ Thread.current[PRELOADING_KEY] = true
23
+ yield
24
+ ensure
25
+ Thread.current[PRELOADING_KEY] = false
26
+ end
27
+ end
28
+
29
+ def initialize
30
+ @objs = Hash.new { |hash, key| hash[key] = [] }
31
+ end
32
+
33
+ def add(instance)
34
+ @objs[instance.class] << instance
35
+ end
36
+
37
+ # The records of class `cls` in this group (an empty array if none).
38
+ def all(cls)
39
+ @objs[cls]
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pipeloader
4
+ module Batch
5
+ # A class-level relationship definition. `fetch` gathers `owner`'s sibling group,
6
+ # runs the loader ONCE for all their keys, then distributes each slice into the
7
+ # siblings' FetcherStates — the configured `default` when a key has no value.
8
+ class Fetcher
9
+ attr_reader :target_class, :name, :getter, :loader, :default
10
+
11
+ def initialize(target_class, name, getter, loader, default: nil)
12
+ @target_class = target_class
13
+ @name = name
14
+ @getter = getter
15
+ @loader = loader
16
+ @default = default
17
+ end
18
+
19
+ def fetch(owner)
20
+ siblings = owner._pipeloader_batch_context.all(target_class)
21
+ ids = siblings.map { |sibling| sibling.send(getter) }
22
+ results = loader.call(ids, self)
23
+ siblings.each_with_index do |sibling, i|
24
+ value = results[ids[i]]
25
+ sibling._fetcher_state(name).results = value.nil? ? @default : value
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pipeloader
4
+ module Batch
5
+ # Per-instance, per-relationship cache. The first access triggers the
6
+ # (batched) fetch; subsequent accesses return the cached slice.
7
+ class FetcherState
8
+ attr_reader :results, :fetched
9
+
10
+ def initialize(fetcher)
11
+ @fetcher = fetcher
12
+ @fetched = false
13
+ @results = nil
14
+ end
15
+
16
+ def fetch(owner)
17
+ @fetcher.fetch(owner) unless @fetched
18
+ @results
19
+ end
20
+
21
+ def results=(results)
22
+ @results = results
23
+ @fetched = true
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pipeloader
4
+ module Batch
5
+ # Prepended onto ActiveRecord::Relation: every set of records loaded by ONE query
6
+ # is stamped with a shared Context (its sibling group), carried on the records.
7
+ # One hook covers every path that matters — a root collection, the IN query a
8
+ # batched has_many runs, and the queries AR's Preloader runs for a batched
9
+ # belongs_to / has_one all materialize through here. A record loaded on its own
10
+ # (find / find_by, one row) gets no group and falls back to a solo one
11
+ # (Model#_pipeloader_batch_context), which is correct: it has nothing to batch with.
12
+ module LoadGrouping
13
+ def records
14
+ recs = super
15
+ if recs.length > 1 && klass.include?(Pipeloader::Batch::Model)
16
+ group = Context.new
17
+ recs.each do |record|
18
+ next if record.instance_variable_defined?(:@_pipeloader_batch_context)
19
+
20
+ record.instance_variable_set(:@_pipeloader_batch_context, group)
21
+ group.add(record)
22
+ end
23
+ end
24
+ recs
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pipeloader
4
+ module Batch
5
+ # Prepended onto AR's SingularAssociation (belongs_to / has_one). When a
6
+ # batch-declared association's target is first loaded for one record, it is
7
+ # loaded for ALL live siblings in the current Context at once (via AR's own
8
+ # Preloader). Non-batch associations, and models that don't include
9
+ # Pipeloader::Batch::Model, fall straight through to normal AR.
10
+ module LoadInterceptor
11
+ def load_target
12
+ Pipeloader::Batch.batch_fill(self) unless loaded?
13
+ super
14
+ end
15
+ end
16
+
17
+ # The singular interceptor's hook: only fire for an association this owner has
18
+ # batch-declared, then defer to fill_association.
19
+ def self.batch_fill(association)
20
+ klass = association.owner.class
21
+ return unless klass.respond_to?(:pipeloader_batched_association?)
22
+ return unless klass.pipeloader_batched_association?(association.reflection.name)
23
+
24
+ fill_association(association.owner, association.reflection.name)
25
+ end
26
+
27
+ # Preload `name` across every live sibling of `owner` in one shot, so each
28
+ # sibling's target is set without a per-record query. Uses AR's own Preloader,
29
+ # which walks plain, polymorphic, and :through associations alike. After this,
30
+ # the caller's `load_target` finds the target loaded and returns it.
31
+ def self.fill_association(owner, name)
32
+ return if Context.preloading?
33
+
34
+ siblings = owner._pipeloader_batch_context.all(owner.class)
35
+ records = siblings.select { |record| record.persisted? && !record.association(name).loaded? }
36
+ records << owner if owner.persisted? && records.none? { |record| record.equal?(owner) }
37
+ return if records.empty?
38
+
39
+ Context.while_preloading do
40
+ ::ActiveRecord::Associations::Preloader.new(records: records, associations: name).call
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "active_support/concern"
5
+ require_relative "relationship"
6
+
7
+ module Pipeloader
8
+ module Batch
9
+ # Include into an ActiveRecord model to enable batch-loaded relationships.
10
+ #
11
+ # class Author < ApplicationRecord
12
+ # include Pipeloader::Batch::Model
13
+ # batch_has_many :books # chainable, batched (where/order/limit)
14
+ # batch_has_one :profile
15
+ # batch_belongs_to :publisher
16
+ # batch_count :books_count
17
+ # batch_aggregate :total_pages, of: :books, function: :sum, column: :pages
18
+ # end
19
+ #
20
+ # `batch_has_many` returns a BatchProxy: a lazy, chainable relation-like object
21
+ # whose load is batched across every live instance of the class in the current
22
+ # Context — and whose .where/.order/.limit are applied INSIDE that one batched
23
+ # query (limit/offset per group). belongs_to/has_one return a single native
24
+ # record, batched via the load interceptor. Aggregates return batched scalars.
25
+ module Model
26
+ extend ActiveSupport::Concern
27
+
28
+ included do
29
+ class_attribute :_pipeloader_batch_associations, instance_accessor: false, default: Set.new
30
+ class_attribute :_pipeloader_batch_fetchers, instance_accessor: false, default: {}
31
+ end
32
+
33
+ module ClassMethods
34
+ # Sentinel marking "no explicit default given" so we can pick a sensible
35
+ # per-function default (0 for count/sum, nil for min/max/average).
36
+ AGGREGATE_DEFAULTS = { count: 0, sum: 0 }.freeze
37
+
38
+ # --- Collection: a real has_many, but the reader returns a BatchProxy ---
39
+
40
+ def batch_has_many(name, scope = nil, **options, &extension)
41
+ has_many(name, scope, **options, &extension)
42
+ reflection = reflect_on_association(name)
43
+ if reflection.through_reflection
44
+ # A :through collection's target has no direct FK to the owner, so the
45
+ # flat-FK BatchProxy can't build its query. Batch it through AR's
46
+ # Preloader (which walks the join), filling every live sibling at once;
47
+ # the reader returns a plain loaded array (no chainable proxy).
48
+ define_method(name) do
49
+ assoc = association(name)
50
+ Pipeloader::Batch.fill_association(self, name) unless assoc.loaded?
51
+ assoc.load_target
52
+ end
53
+ else
54
+ define_method(name) { Pipeloader::Batch::BatchProxy.new(self, reflection) }
55
+ end
56
+ end
57
+
58
+ # --- Singular: a real association whose load is batched (single record) ---
59
+
60
+ def batch_has_one(name, scope = nil, **options, &extension)
61
+ has_one(name, scope, **options, &extension)
62
+ _pipeloader_batch_association(name)
63
+ end
64
+
65
+ def batch_belongs_to(name, scope = nil, **options, &extension)
66
+ belongs_to(name, scope, **options, &extension)
67
+ _pipeloader_batch_association(name)
68
+ end
69
+
70
+ def pipeloader_batched_association?(name)
71
+ _pipeloader_batch_associations.include?(name)
72
+ end
73
+
74
+ # --- Scalar aggregates: COUNT / SUM / AVERAGE / MINIMUM / MAXIMUM ---
75
+
76
+ def batch_count(name, of: nil, class_name: nil, foreign_key: nil, primary_key: nil, default: 0)
77
+ _pipeloader_batch_register_aggregate(name, Relationship.aggregate(
78
+ self, name,
79
+ of: of, function: :count, column: nil,
80
+ class_name: class_name, foreign_key: foreign_key, primary_key: primary_key,
81
+ default: default
82
+ ))
83
+ end
84
+
85
+ # function: :sum / :average / :minimum / :maximum (or :count). Empty groups
86
+ # default to 0 for count/sum and nil for the rest, override with :default.
87
+ # For anything a plain GROUP BY can't express, use `batch` with a loader.
88
+ def batch_aggregate(name, function:, of: nil, column: nil, class_name: nil, foreign_key: nil,
89
+ primary_key: nil, default: AGGREGATE_DEFAULTS)
90
+ if function != :count && column.nil?
91
+ raise Pipeloader::Batch::Error, "batch_aggregate #{name.inspect} (#{function}) requires a :column"
92
+ end
93
+
94
+ resolved_default = default.equal?(AGGREGATE_DEFAULTS) ? AGGREGATE_DEFAULTS[function] : default
95
+ _pipeloader_batch_register_aggregate(name, Relationship.aggregate(
96
+ self, name,
97
+ of: of, function: function, column: column,
98
+ class_name: class_name, foreign_key: foreign_key, primary_key: primary_key,
99
+ default: resolved_default
100
+ ))
101
+ end
102
+
103
+ # --- General: any batched value, computed once per Context ---
104
+
105
+ # Define `name` as a value loaded ONCE across every live sibling. The
106
+ # loader gets the array of owner keys (the `key:` column, default the
107
+ # primary key) and returns a `{ key => value }` Hash; each instance reads
108
+ # its own value, or `default` when its key is absent. This is the escape
109
+ # hatch behind batch_count / batch_aggregate, surfaced for everything that
110
+ # isn't a plain association — existence checks, viewer-scoped flags,
111
+ # lookups by a non-PK column, derived rows:
112
+ #
113
+ # batch :viewer_has_starred, default: false do |repo_ids|
114
+ # Star.where(user_id: Current.user.id, repo_id: repo_ids)
115
+ # .pluck(:repo_id).index_with(true) # missing -> false
116
+ # end
117
+ def batch(name, key: nil, default: nil, &loader)
118
+ raise Pipeloader::Batch::Error, "batch #{name.inspect} requires a loader block" unless loader
119
+
120
+ getter = (key || primary_key).to_sym
121
+ _pipeloader_batch_register_fetcher(name, getter, ->(keys, _fetcher) { loader.call(keys) }, default)
122
+ end
123
+
124
+ def _pipeloader_batch_association(name)
125
+ # merge (not mutate) so subclasses get their own copy — STI-safe.
126
+ self._pipeloader_batch_associations = _pipeloader_batch_associations + [name]
127
+ end
128
+
129
+ def _pipeloader_batch_register_aggregate(name, relationship)
130
+ _pipeloader_batch_register_fetcher(name, relationship.getter, relationship.loader, relationship.default)
131
+ end
132
+
133
+ def _pipeloader_batch_register_fetcher(name, getter, loader, default)
134
+ self._pipeloader_batch_fetchers = _pipeloader_batch_fetchers.merge(
135
+ name => Pipeloader::Batch::Fetcher.new(self, name, getter, loader, default: default)
136
+ )
137
+ define_method(name) { batch_load(name) }
138
+ end
139
+ end
140
+
141
+ # This record's sibling group: the records loaded with it (stamped at load time
142
+ # by Pipeloader::Batch::LoadGrouping), or a solo group when it was loaded or
143
+ # built on its own. Every batch method reads its siblings from here.
144
+ def _pipeloader_batch_context
145
+ @_pipeloader_batch_context ||= Pipeloader::Batch::Context.new.tap { |c| c.add(self) }
146
+ end
147
+
148
+ # Aggregate readers route through here; record associations use BatchProxy
149
+ # (has_many) or AR's own reader (belongs_to/has_one).
150
+ def batch_load(name)
151
+ _fetcher_state(name).fetch(self)
152
+ end
153
+
154
+ def _fetcher_state(name)
155
+ ivar = :"@_pipeloader_batch_state_#{name}"
156
+ return instance_variable_get(ivar) if instance_variable_defined?(ivar)
157
+
158
+ fetcher = self.class._pipeloader_batch_fetchers[name]
159
+ raise Pipeloader::Batch::Error, "no batch relationship named #{name.inspect}" unless fetcher
160
+
161
+ instance_variable_set(ivar, Pipeloader::Batch::FetcherState.new(fetcher))
162
+ end
163
+
164
+ # Per-instance cache for batched collection scopes, keyed by [name, scope].
165
+ def _pipeloader_batch_scope_cache
166
+ @_pipeloader_batch_scope_cache ||= {}
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string/inflections"
4
+
5
+ module Pipeloader
6
+ module Batch
7
+ # Builds the pieces an aggregate Fetcher needs: a `getter` (the owner key read
8
+ # off each sibling), a `loader` lambda (one GROUP BY query returning a
9
+ # { key => scalar } hash), and a `default` for keys with no rows.
10
+ #
11
+ # The child class and keys are resolved with this precedence:
12
+ # explicit option > AR reflection (if the source names a real association) > convention.
13
+ # The target class constant is resolved lazily so autoload order doesn't matter.
14
+ class Relationship
15
+ attr_reader :getter, :loader, :default
16
+
17
+ def self.aggregate(owner, name, of:, function:, column:, class_name:, foreign_key:, primary_key:, default:)
18
+ source = of || default_source(name, function)
19
+ resolve, fk, getter = child_keys(owner, source, class_name, foreign_key, primary_key)
20
+
21
+ derived = lambda do |ids, _fetcher|
22
+ scope = resolve.call.where(fk => ids).group(fk)
23
+ function == :count ? scope.count : scope.public_send(function, column)
24
+ end
25
+ new(getter, derived, default)
26
+ end
27
+
28
+ # Resolve [class-resolver, foreign-key, owner-getter] for a has_many-style
29
+ # source rooted at `assoc_name` on `owner`.
30
+ def self.child_keys(owner, assoc_name, class_name, foreign_key, primary_key)
31
+ reflection = owner.reflect_on_association(assoc_name)
32
+ fk = (foreign_key || reflection&.foreign_key || owner.model_name.element.foreign_key).to_s
33
+ getter = (primary_key || reflection&.active_record_primary_key || owner.primary_key).to_sym
34
+ resolve = class_resolver(class_name, reflection) { assoc_name.to_s.singularize.camelize.constantize }
35
+ [resolve, fk, getter]
36
+ end
37
+
38
+ # For `batch_count :books_count`, derive the source association ("books") by
39
+ # stripping the function suffix when present.
40
+ def self.default_source(name, function)
41
+ suffix = "_#{function}"
42
+ name.to_s.end_with?(suffix) ? name.to_s.delete_suffix(suffix) : name.to_s
43
+ end
44
+
45
+ # Memoized lambda producing the target class constant. The convention block
46
+ # is only used when neither class_name nor a reflection is available.
47
+ def self.class_resolver(class_name, reflection, &convention)
48
+ resolved = nil
49
+ lambda do
50
+ resolved ||=
51
+ if class_name
52
+ class_name.to_s.constantize
53
+ elsif reflection
54
+ reflection.klass
55
+ else
56
+ convention.call
57
+ end
58
+ end
59
+ end
60
+
61
+ def initialize(getter, loader, default)
62
+ @getter = getter
63
+ @loader = loader
64
+ @default = default
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ require_relative "batch/context"
6
+ require_relative "batch/fetcher"
7
+ require_relative "batch/fetcher_state"
8
+ require_relative "batch/relationship"
9
+ require_relative "batch/batch_proxy"
10
+ require_relative "batch/batch_loader"
11
+ require_relative "batch/load_interceptor"
12
+ require_relative "batch/load_grouping"
13
+ require_relative "batch/model"
14
+
15
+ module Pipeloader
16
+ # Automatic N+1 elimination for plain ActiveRecord — no GraphQL, no `includes`,
17
+ # no DataLoader keys. Declare a relationship with a batch macro, then traverse
18
+ # records one at a time; the first access loads the relationship for every record
19
+ # loaded alongside it (its sibling group) in a single query.
20
+ #
21
+ # class Author < ApplicationRecord
22
+ # include Pipeloader::Batch::Model
23
+ # batch_has_many :books # chainable, batched (where/order/limit)
24
+ # batch_has_one :profile
25
+ # batch_count :books_count
26
+ # end
27
+ #
28
+ # Author.all.to_a.each { |a| a.books.to_a } # ONE query for everyone's books
29
+ #
30
+ # Siblings are the records loaded by the same query: the group is stamped onto them
31
+ # as they load (Pipeloader::Batch::LoadGrouping) and carried on the records, so it
32
+ # needs no surrounding block and is correct under threads, fibers, and
33
+ # fiber-per-request servers alike.
34
+ module Batch
35
+ class Error < StandardError; end
36
+ end
37
+ end
38
+
39
+ # belongs_to / has_one (SingularAssociation) batch their load via the interceptor +
40
+ # AR's Preloader. has_many returns a Pipeloader::Batch::BatchProxy instead (so
41
+ # where/order/limit stay batched), so CollectionAssociation needs no hook.
42
+ ActiveRecord::Associations::SingularAssociation.prepend(Pipeloader::Batch::LoadInterceptor)
43
+ # Stamp co-loaded records with their sibling group as they materialize.
44
+ ActiveRecord::Relation.prepend(Pipeloader::Batch::LoadGrouping)