pipeloader 0.0.1 → 0.0.3
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/DATALOADERS.md +379 -0
- data/LICENSE +21 -0
- data/README.md +243 -161
- data/lib/pipeloader/ar_patch.rb +3 -1
- data/lib/pipeloader/batch/batch_loader.rb +63 -0
- data/lib/pipeloader/batch/batch_proxy.rb +204 -0
- data/lib/pipeloader/batch/context.rb +43 -0
- data/lib/pipeloader/batch/fetcher.rb +30 -0
- data/lib/pipeloader/batch/fetcher_state.rb +27 -0
- data/lib/pipeloader/batch/load_grouping.rb +28 -0
- data/lib/pipeloader/batch/load_interceptor.rb +44 -0
- data/lib/pipeloader/batch/model.rb +170 -0
- data/lib/pipeloader/batch/relationship.rb +68 -0
- data/lib/pipeloader/batch.rb +44 -0
- data/lib/pipeloader/field_exact.rb +235 -14
- data/lib/pipeloader/pipeliner.rb +114 -26
- data/lib/pipeloader/source.rb +27 -3
- data/lib/pipeloader/version.rb +1 -1
- data/lib/pipeloader.rb +32 -1
- metadata +47 -4
|
@@ -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)
|