jit_preloader 2.0.0 → 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '08fbd77fdcfc580733fbb24995cd80a1535bada8471f2dace6620ad000c25778'
4
- data.tar.gz: bfb07a3212433bc5dd2a5a557ed05d0472f0ec227593a798a1b9442976815d57
3
+ metadata.gz: bca887aa87a283d55ee26b1fe606948733ecfecc7ce40d4cedc8b89d90efb834
4
+ data.tar.gz: 9a7c463ccacf8daeebc23d2eaa375d8c4f908b333c492baaa4c4c931bbc7d1b3
5
5
  SHA512:
6
- metadata.gz: c7fe821295fc841a48992f4fa93bbc372516509435a49455bf9e37543173fe089714198eaa9f0c355cf656d5c2696160c4c82bd657fad79edb9961d5e13e49c7
7
- data.tar.gz: 33ae22de3c658a714be7b5c61705dcb82bdc1d762af3ee0d4ed084d29c6c9e8b6685fa3a353756f653c3287806d1eb946beb95df532983ce85536a82a8fab7fa
6
+ metadata.gz: 30920baa0426de8bc5188fc80ccfacf6e040f6bf1f7c934214bb75600080a090756ca957da9a59327a63b30550d7a4efdf5f4e66c873783e0c5aa627fe558e9f
7
+ data.tar.gz: 9adfa72af9bfcc684c3c22f6e82b353af9b36081224cae92e248e5c52db7f028a9c6f49dc3760d059f476980a48f4852b6f94c5e5faf82dbef28087e0b8dd3cd
@@ -0,0 +1,32 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [master]
6
+ pull_request:
7
+ branches: [master]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ fail-fast: false
14
+ matrix:
15
+ gemfile:
16
+ - Gemfile
17
+ - Gemfile.5.2
18
+ - Gemfile.6.0
19
+ - Gemfile.6.1
20
+ env:
21
+ BUNDLE_GEMFILE: ${{ matrix.gemfile }}
22
+ steps:
23
+ - uses: actions/checkout@v4
24
+ - name: Set up Ruby ${{ matrix.ruby-version }}
25
+ uses: ruby/setup-ruby@v1
26
+ with:
27
+ ruby-version: 2.7
28
+ - name: Install dependencies
29
+ run: bundle install
30
+ - name: Run tests
31
+ run:
32
+ bundle exec rspec
@@ -12,11 +12,11 @@ jobs:
12
12
  contents: read
13
13
 
14
14
  steps:
15
- - uses: actions/checkout@v2
15
+ - uses: actions/checkout@v4
16
16
  - name: Set up Ruby 2.7
17
- uses: actions/setup-ruby@v1
17
+ uses: ruby/setup-ruby@v1
18
18
  with:
19
- ruby-version: 2.7.x
19
+ ruby-version: 2.7
20
20
 
21
21
  - name: Publish to RubyGems
22
22
  env:
data/.gitignore CHANGED
@@ -10,6 +10,7 @@
10
10
  /test/tmp/
11
11
  /test/version_tmp/
12
12
  /tmp/
13
+ .idea/*
13
14
 
14
15
  # Used by dotenv library to load environment variables.
15
16
  # .env
data/Gemfile CHANGED
@@ -1,4 +1,5 @@
1
1
  source 'https://rubygems.org'
2
2
 
3
+ gem "activerecord", ">=7"
3
4
  # Specify your gem's dependencies in jit_preloader.gemspec
4
5
  gemspec
data/README.md CHANGED
@@ -173,6 +173,20 @@ end
173
173
 
174
174
  ```
175
175
 
176
+ Furthermore, there is an argument `max_ids_per_query` setting max ids per query. This helps prevent running a single query with too large list of ids which may be less efficient than splitting into multiple queries.
177
+ ```ruby
178
+ class Contact < ActiveRecord::Base
179
+ has_many :addresses
180
+ has_many_aggregate :addresses, :count_all, :count, "*", max_ids_per_query: 10
181
+ end
182
+
183
+ Contact.jit_preload.each do |contact|
184
+ contact.addresses_count_all
185
+ end
186
+ # SELECT contact_id, COUNT(*) FROM addresses WHERE contact_id IN (1, 2, 3, ... ,10) GROUP BY contact_id
187
+ # SELECT contact_id, COUNT(*) FROM addresses WHERE contact_id IN (11, 12, 13) GROUP BY contact_id
188
+ ```
189
+
176
190
  ### Preloading a subset of an association
177
191
 
178
192
  There are often times when you want to preload a subset of an association, or change how the SQL statement is generated. For example, if a `Contact` model has
@@ -213,6 +227,7 @@ end
213
227
  ### Jit preloading globally across your application
214
228
 
215
229
  The JitPreloader can be globally enabled, in which case most N+1 queries in your app should just disappear. It is off by default.
230
+ The `max_ids_per_query` argument on loading aggregate methods can also apply on a global level.
216
231
 
217
232
  ```ruby
218
233
  # Can be true or false
@@ -223,12 +238,24 @@ JitPreloader.globally_enabled = true
223
238
  # so that you can turn it on or off dynamically.
224
239
  JitPreloader.globally_enabled = ->{ $redis.get('always_jit_preload') == 'on' }
225
240
 
241
+ # Setting global max ids constraint on all aggregation methods.
242
+ JitPreloader.max_ids_per_query = 10
243
+
244
+ class Contact < ActiveRecord::Base
245
+ has_many :emails
246
+ has_many_aggregate :emails, :count_all, :count, "*"
247
+ end
248
+
226
249
  # When enabled globally, this would not generate an N+1 query.
227
250
  Contact.all.each do |contact|
228
251
  contact.emails.each do |email|
229
252
  # do something
230
253
  end
254
+ # When max_ides_per_query is set globally, the aggregate method will split query base on the limit.
255
+ contact.emails_count_all
231
256
  end
257
+
258
+
232
259
  ```
233
260
 
234
261
  ## What it doesn't solve
@@ -18,7 +18,7 @@ Gem::Specification.new do |spec|
18
18
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
19
  spec.require_paths = ["lib"]
20
20
 
21
- spec.add_dependency "activerecord", ">= 7", "< 8"
21
+ spec.add_dependency "activerecord", "< 8"
22
22
  spec.add_dependency "activesupport"
23
23
 
24
24
  spec.add_development_dependency "bundler"
@@ -0,0 +1,63 @@
1
+ module JitPreloader
2
+ module PreloaderAssociation
3
+
4
+ # A monkey patch to ActiveRecord. The old method looked like the snippet
5
+ # below. Our changes here are that we remove records that are already
6
+ # part of the target, then attach all of the records to a new jit preloader.
7
+ #
8
+ # def run
9
+ # records = records_by_owner
10
+
11
+ # owners.each do |owner|
12
+ # associate_records_to_owner(owner, records[owner] || [])
13
+ # end if @associate
14
+
15
+ # self
16
+ # end
17
+
18
+ def run
19
+ return unless (reflection.scope.nil? || reflection.scope.arity == 0) && klass.ancestors.include?(ActiveRecord::Base)
20
+
21
+ super.tap do
22
+ if preloaded_records.any? && preloaded_records.none?(&:jit_preloader)
23
+ JitPreloader::Preloader.attach(preloaded_records) if owners.any?(&:jit_preloader) || JitPreloader.globally_enabled?
24
+ end
25
+ end
26
+ end
27
+
28
+ # Original method:
29
+ # def associate_records_to_owner(owner, records)
30
+ # return if loaded?(owner)
31
+ #
32
+ # association = owner.association(reflection.name)
33
+ #
34
+ # if reflection.collection?
35
+ # association.target = records
36
+ # else
37
+ # association.target = records.first
38
+ # end
39
+ # end
40
+ def associate_records_to_owner(owner, records)
41
+ return if loaded?(owner)
42
+
43
+ association = owner.association(reflection.name)
44
+
45
+ if reflection.collection?
46
+ new_records = association.target.any? ? records - association.target : records
47
+ association.target.concat(new_records)
48
+ association.loaded!
49
+ else
50
+ association.target = records.first
51
+ end
52
+ end
53
+
54
+ def build_scope
55
+ super.tap do |scope|
56
+ scope.jit_preload! if owners.any?(&:jit_preloader) || JitPreloader.globally_enabled?
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ ActiveRecord::Associations::Preloader::Association.prepend(JitPreloader::PreloaderAssociation)
63
+ ActiveRecord::Associations::Preloader::ThroughAssociation.prepend(JitPreloader::PreloaderAssociation)
@@ -0,0 +1,22 @@
1
+ module JitPreloader
2
+ module PreloaderBranch
3
+ """
4
+ ActiveRecord version >= 7.x.x introduced an improvement for preloading associations in batches:
5
+ https://github.com/rails/rails/blob/main/activerecord/lib/active_record/associations/preloader.rb#L121
6
+
7
+ Our existing monkey-patches will ignore associations whose classes are not descendants of
8
+ ActiveRecord::Base (example: https://github.com/clio/jit_preloader/blob/master/lib/jit_preloader/active_record/associations/preloader/ar6_association.rb#L19).
9
+ But this change breaks that behaviour because now Batch is calling `klass.base_class` (a method defined by ActiveRecord::Base)
10
+ before we have a chance to filter out the non-AR classes.
11
+ This patch for AR 7.x makes the Branch class ignore any association loaders that aren't for ActiveRecord::Base subclasses.
12
+ """
13
+
14
+ def loaders
15
+ @loaders = super.find_all do |loader|
16
+ loader.klass < ::ActiveRecord::Base
17
+ end
18
+ end
19
+ end
20
+ end
21
+
22
+ ActiveRecord::Associations::Preloader::Branch.prepend(JitPreloader::PreloaderBranch)
@@ -18,7 +18,7 @@ module JitPreloadExtension
18
18
  end
19
19
  end
20
20
 
21
- if Gem::Version.new(ActiveRecord::VERSION::STRING) >= Gem::Version.new("6.0.0")
21
+ if Gem::Version.new(ActiveRecord::VERSION::STRING) >= Gem::Version.new("7.0.0")
22
22
  def preload_scoped_relation(name:, base_association:, preload_scope: nil)
23
23
  return jit_preload_scoped_relations[name] if jit_preload_scoped_relations&.key?(name)
24
24
 
@@ -90,7 +90,7 @@ module JitPreloadExtension
90
90
  class << base
91
91
  delegate :jit_preload, to: :all
92
92
 
93
- def has_many_aggregate(assoc, name, aggregate, field, table_alias_name: nil, default: 0)
93
+ def has_many_aggregate(assoc, name, aggregate, field, table_alias_name: nil, default: 0, max_ids_per_query: nil)
94
94
  method_name = "#{assoc}_#{name}"
95
95
 
96
96
  define_method(method_name) do |conditions={}|
@@ -101,6 +101,13 @@ module JitPreloadExtension
101
101
  if jit_preloader
102
102
  reflection = association(assoc).reflection
103
103
  primary_ids = jit_preloader.records.collect{|r| r[reflection.active_record_primary_key] }
104
+ max_ids_per_query = max_ids_per_query || JitPreloader.max_ids_per_query
105
+ if max_ids_per_query
106
+ slices = primary_ids.each_slice(max_ids_per_query)
107
+ else
108
+ slices = [primary_ids]
109
+ end
110
+
104
111
  klass = reflection.klass
105
112
 
106
113
  aggregate_association = reflection
@@ -115,15 +122,13 @@ module JitPreloadExtension
115
122
  table_reference = table_alias_name
116
123
  table_reference ||= association_scope.references_values.first || aggregate_association.table_name
117
124
 
118
- conditions[table_reference] = { aggregate_association.foreign_key => primary_ids }
119
-
120
125
  # If the association is a STI child model, specify its type in the condition so that it
121
126
  # doesn't include results from other child models
122
127
  parent_is_base_class = aggregate_association.klass.superclass.abstract_class? || aggregate_association.klass.superclass == ActiveRecord::Base
123
128
  has_type_column = aggregate_association.klass.column_names.include?(aggregate_association.klass.inheritance_column)
124
129
  is_child_sti_model = !parent_is_base_class && has_type_column
125
130
  if is_child_sti_model
126
- conditions[table_reference].merge!({ aggregate_association.klass.inheritance_column => aggregate_association.klass.sti_name })
131
+ conditions[table_reference] = { aggregate_association.klass.inheritance_column => aggregate_association.klass.sti_name }
127
132
  end
128
133
 
129
134
  if reflection.type.present?
@@ -131,11 +136,15 @@ module JitPreloadExtension
131
136
  end
132
137
  group_by = "#{table_reference}.#{aggregate_association.foreign_key}"
133
138
 
134
- preloaded_data = Hash[association_scope
135
- .where(conditions)
136
- .group(group_by)
137
- .send(aggregate, field)
138
- ]
139
+ preloaded_data = {}
140
+ slices.each do |slice|
141
+ data = Hash[association_scope
142
+ .where(conditions.deep_merge(table_reference => { aggregate_association.foreign_key => slice }))
143
+ .group(group_by)
144
+ .send(aggregate, field)
145
+ ]
146
+ preloaded_data.merge!(data)
147
+ end
139
148
 
140
149
  jit_preloader.records.each do |record|
141
150
  record.jit_preload_aggregates ||= {}
@@ -3,21 +3,47 @@ module JitPreloader
3
3
 
4
4
  attr_accessor :records
5
5
 
6
- def self.attach(records)
7
- new(records: records.dup, associations: nil).tap do |loader|
8
- records.each do |record|
9
- record.jit_preloader = loader
6
+ if Gem::Version.new(ActiveRecord::VERSION::STRING) >= Gem::Version.new("7.0.0")
7
+ def self.attach(records)
8
+ new(records: records.dup, associations: nil).tap do |loader|
9
+ records.each do |record|
10
+ record.jit_preloader = loader
11
+ end
10
12
  end
11
13
  end
12
- end
13
14
 
14
- def jit_preload(associations)
15
- # It is possible that the records array has multiple different classes (think single table inheritance).
16
- # Thus, it is possible that some of the records don't have an association.
17
- records_with_association = records.reject{|r| r.class.reflect_on_association(associations).nil? }
18
- self.class.new(records: records_with_association, associations: associations).call
15
+ def jit_preload(associations)
16
+ # It is possible that the records array has multiple different classes (think single table inheritance).
17
+ # Thus, it is possible that some of the records don't have an association.
18
+ records_with_association = records.reject{|r| r.class.reflect_on_association(associations).nil? }
19
+
20
+ # Some of the records may already have the association loaded and we should not load them again
21
+ records_requiring_loading = records_with_association.select{|r| !r.association(associations).loaded? }
22
+
23
+ self.class.new(records: records_requiring_loading, associations: associations).call
24
+ end
25
+ else
26
+ def self.attach(records)
27
+ new.tap do |loader|
28
+ loader.records = records.dup
29
+ records.each do |record|
30
+ record.jit_preloader = loader
31
+ end
32
+ end
33
+ end
34
+
35
+ def jit_preload(associations)
36
+ # It is possible that the records array has multiple different classes (think single table inheritance).
37
+ # Thus, it is possible that some of the records don't have an association.
38
+ records_with_association = records.reject{ |record| record.class.reflect_on_association(associations).nil? }
39
+
40
+ # Some of the records may already have the association loaded and we should not load them again
41
+ records_requiring_loading = records_with_association.select{ |record| !record.association(associations).loaded? }
42
+ preload records_with_association, associations
43
+ end
19
44
  end
20
45
 
46
+
21
47
  # We do not want the jit_preloader to be dumpable
22
48
  # If you dump a ActiveRecord::Base object that has a jit_preloader instance variable
23
49
  # you will also end up dumping all of the records the preloader has reference to.
@@ -1,3 +1,3 @@
1
1
  module JitPreloader
2
- VERSION = "2.0.0"
2
+ VERSION = "2.1.0"
3
3
  end
data/lib/jit_preloader.rb CHANGED
@@ -8,7 +8,10 @@ require 'jit_preloader/active_record/base'
8
8
  require 'jit_preloader/active_record/relation'
9
9
  require 'jit_preloader/active_record/associations/collection_association'
10
10
  require 'jit_preloader/active_record/associations/singular_association'
11
- if Gem::Version.new(ActiveRecord::VERSION::STRING) >= Gem::Version.new("6.0.0")
11
+ if Gem::Version.new(ActiveRecord::VERSION::STRING) >= Gem::Version.new("7.0.0")
12
+ require 'jit_preloader/active_record/associations/preloader/ar7_association'
13
+ require 'jit_preloader/active_record/associations/preloader/ar7_branch'
14
+ elsif Gem::Version.new(ActiveRecord::VERSION::STRING) >= Gem::Version.new("6.0.0")
12
15
  require 'jit_preloader/active_record/associations/preloader/ar6_association'
13
16
  elsif Gem::Version.new(ActiveRecord::VERSION::STRING) >= Gem::Version.new("5.2.2")
14
17
  require 'jit_preloader/active_record/associations/preloader/ar5_association'
@@ -23,6 +26,16 @@ module JitPreloader
23
26
  @enabled = value
24
27
  end
25
28
 
29
+ def self.max_ids_per_query=(max_ids)
30
+ if max_ids && max_ids >= 1
31
+ @max_ids_per_query = max_ids
32
+ end
33
+ end
34
+
35
+ def self.max_ids_per_query
36
+ @max_ids_per_query
37
+ end
38
+
26
39
  def self.globally_enabled?
27
40
  if @enabled && @enabled.respond_to?(:call)
28
41
  @enabled.call
@@ -1,4 +1,5 @@
1
1
  require "spec_helper"
2
+ require "db-query-matchers"
2
3
 
3
4
  RSpec.describe JitPreloader::Preloader do
4
5
  let!(:contact1) do
@@ -479,6 +480,69 @@ RSpec.describe JitPreloader::Preloader do
479
480
  end
480
481
  end
481
482
  end
483
+
484
+ context "with dive limit set" do
485
+ let!(:contact_book_1) { ContactBook.create(name: "The Yellow Pages") }
486
+ let!(:contact_book_2) { ContactBook.create(name: "The Yellow Pages") }
487
+ let!(:contact_book_3) { ContactBook.create(name: "The Yellow Pages") }
488
+ let!(:company1) { Company.create(name: "Company1", contact_book: contact_book_1) }
489
+ let!(:company2) { Company.create(name: "Company2", contact_book: contact_book_1) }
490
+ let!(:company3) { Company.create(name: "Company2", contact_book: contact_book_2) }
491
+ let!(:company4) { Company.create(name: "Company4", contact_book: contact_book_3) }
492
+ let!(:company5) { Company.create(name: "Company5", contact_book: contact_book_3) }
493
+
494
+ context "from the global value" do
495
+ before do
496
+ JitPreloader.max_ids_per_query = 2
497
+ end
498
+
499
+ after do
500
+ JitPreloader.max_ids_per_query = nil
501
+ end
502
+
503
+ it "can handle queries" do
504
+ contact_books = ContactBook.jit_preload.to_a
505
+
506
+ expect(contact_books.first.companies_count).to eq 2
507
+ expect(contact_books.second.companies_count).to eq 1
508
+ expect(contact_books.last.companies_count).to eq 2
509
+ end
510
+
511
+ it "makes the right number of queries based on dive limit" do
512
+ contact_books = ContactBook.jit_preload.to_a
513
+ expect do
514
+ contact_books.first.companies_count
515
+ end.to make_database_queries(count: 2)
516
+
517
+ expect do
518
+ contact_books.second.companies_count
519
+ contact_books.last.companies_count
520
+ end.to_not make_database_queries
521
+ end
522
+ end
523
+
524
+ context "from aggregate argument" do
525
+ it "can handle queries" do
526
+ contact_books = ContactBook.jit_preload.to_a
527
+
528
+ expect(contact_books.first.companies_count_with_max_ids_set).to eq 2
529
+ expect(contact_books.second.companies_count_with_max_ids_set).to eq 1
530
+ expect(contact_books.last.companies_count_with_max_ids_set).to eq 2
531
+ end
532
+
533
+ it "makes the right number of queries based on dive limit" do
534
+ contact_books = ContactBook.jit_preload.to_a
535
+ expect do
536
+ contact_books.first.companies_count_with_max_ids_set
537
+ end.to make_database_queries(count: 2)
538
+
539
+ expect do
540
+ contact_books.second.companies_count_with_max_ids_set
541
+ contact_books.last.companies_count_with_max_ids_set
542
+ end.to_not make_database_queries
543
+ end
544
+ end
545
+ end
482
546
  end
483
547
 
484
548
  end
@@ -10,6 +10,7 @@ class ContactBook < ActiveRecord::Base
10
10
  has_many :children, through: :parents
11
11
 
12
12
  has_many_aggregate :companies, :count, :count, "*"
13
+ has_many_aggregate :companies, :count_with_max_ids_set, :count, "*", max_ids_per_query: 2
13
14
  has_many_aggregate :employees, :count, :count, "*"
14
15
  has_many_aggregate :company_employees, :count, :count, "*"
15
16
  has_many_aggregate :children, :count, :count, "*"
metadata CHANGED
@@ -1,22 +1,19 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jit_preloader
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kyle d'Oliveira
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-05-18 00:00:00.000000000 Z
11
+ date: 2024-04-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - ">="
18
- - !ruby/object:Gem::Version
19
- version: '7'
20
17
  - - "<"
21
18
  - !ruby/object:Gem::Version
22
19
  version: '8'
@@ -24,9 +21,6 @@ dependencies:
24
21
  prerelease: false
25
22
  version_requirements: !ruby/object:Gem::Requirement
26
23
  requirements:
27
- - - ">="
28
- - !ruby/object:Gem::Version
29
- version: '7'
30
24
  - - "<"
31
25
  - !ruby/object:Gem::Version
32
26
  version: '8'
@@ -152,6 +146,7 @@ extensions: []
152
146
  extra_rdoc_files: []
153
147
  files:
154
148
  - ".github/CODEOWNERS"
149
+ - ".github/workflows/ci.yml"
155
150
  - ".github/workflows/gem-push.yml"
156
151
  - ".gitignore"
157
152
  - ".rspec"
@@ -170,6 +165,8 @@ files:
170
165
  - lib/jit_preloader/active_record/associations/collection_association.rb
171
166
  - lib/jit_preloader/active_record/associations/preloader/ar5_association.rb
172
167
  - lib/jit_preloader/active_record/associations/preloader/ar6_association.rb
168
+ - lib/jit_preloader/active_record/associations/preloader/ar7_association.rb
169
+ - lib/jit_preloader/active_record/associations/preloader/ar7_branch.rb
173
170
  - lib/jit_preloader/active_record/associations/preloader/collection_association.rb
174
171
  - lib/jit_preloader/active_record/associations/preloader/singular_association.rb
175
172
  - lib/jit_preloader/active_record/associations/singular_association.rb