activerecord-quick_read 1.2.0 → 1.2.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1309eaf96974d8b58dcf9129d7562f9a4a768845f1b90ab14281cd71feeb9325
4
- data.tar.gz: 82eed52c3a1fe90420610c53a8f613ead78d7ae38adfa9da9fc95ae32bc1717b
3
+ metadata.gz: 6d2a22ddf1957e253d53140f7ede9c14b6e329d707f4e4be8c7c6752b9bedfc8
4
+ data.tar.gz: d7b7b455816cccc89bbe69a83a07ec448a4f84756469838f8ba614a2c84b0735
5
5
  SHA512:
6
- metadata.gz: 2881f4c609eed7c8758b0e7f40382e1f432be17fc0df25289d5a7a264bf657c8c9efd0e4801e0020780c2dc12289ff27c58e05a6a41a8fb557ce872cb4465463
7
- data.tar.gz: 5f45b9b3d261d5c16e40cc5ff4493de991ce5e73a206e2e655f7dde7607c8ee14eae776ac667334eb44c0e67586bdde414208a5bc4850e9c5d74a9f203081acc
6
+ metadata.gz: 34771e716d9b34b9e9f56437960206462f92157bf562f8e2a71d09ca2b3ec069300abd1d7a5aad5bee5e0f4f1e9a9a2dfbe04fd2886539e72b00a920df14b1e1
7
+ data.tar.gz: a3dc6d0a256c59f0f83763fded7aa4fa2d8fbdf724b5accdcb80e949f9f419aca1827243f6d70b29227197fbd4659f82f1ae2c1549435216d76d48013370ac25
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.2.1] - 2026-08-26
4
+
5
+ - Fixed a missing `require "benchmark"` that broke `quick_reads` on Rails 8.1
6
+ - Optimized `quick_build` (memoized the struct class and no longer mutates the input hash)
7
+ - Switched `quick_reads` to `select_rows` (positional) for full-column reads, avoiding per-row hash allocation
8
+ - Removed the runtime `benchmark` dependency in favor of `Process.clock_gettime`
9
+ - Added benchmarks and updated the README performance claims
10
+
3
11
  ## [1.2.0] - 2026-08-26
4
12
 
5
13
  - Added support for older Rails 5 (`parent` fallback for `module_parent`)
data/Gemfile CHANGED
@@ -9,7 +9,11 @@ gem "rake", "~> 13.0"
9
9
 
10
10
  gem "rspec", "~> 3.0"
11
11
 
12
- gem "sqlite3", "~> 1.6"
12
+ gem "benchmark-ips"
13
+
14
+ gem "activerecord", "~> 8.1"
15
+
16
+ gem "sqlite3", "~> 2.1"
13
17
 
14
18
  gem "standard", "~> 1.3"
15
19
 
@@ -18,3 +22,4 @@ gem "mutex_m"
18
22
  gem "base64"
19
23
  gem "bigdecimal"
20
24
  gem "racc"
25
+ gem "ostruct"
data/Gemfile.lock CHANGED
@@ -1,37 +1,53 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- activerecord-quick_read (1.2.0)
4
+ activerecord-quick_read (1.2.1)
5
5
  activerecord
6
6
 
7
7
  GEM
8
8
  remote: https://rubygems.org/
9
9
  specs:
10
- activemodel (7.0.4.3)
11
- activesupport (= 7.0.4.3)
12
- activerecord (7.0.4.3)
13
- activemodel (= 7.0.4.3)
14
- activesupport (= 7.0.4.3)
15
- activesupport (7.0.4.3)
16
- concurrent-ruby (~> 1.0, >= 1.0.2)
10
+ activemodel (8.1.3.1)
11
+ activesupport (= 8.1.3.1)
12
+ activerecord (8.1.3.1)
13
+ activemodel (= 8.1.3.1)
14
+ activesupport (= 8.1.3.1)
15
+ timeout (>= 0.4.0)
16
+ activesupport (8.1.3.1)
17
+ base64
18
+ bigdecimal
19
+ concurrent-ruby (~> 1.0, >= 1.3.1)
20
+ connection_pool (>= 2.2.5)
21
+ drb
17
22
  i18n (>= 1.6, < 2)
23
+ json
24
+ logger (>= 1.4.2)
18
25
  minitest (>= 5.1)
19
- tzinfo (~> 2.0)
26
+ securerandom (>= 0.3)
27
+ tzinfo (~> 2.0, >= 2.0.5)
28
+ uri (>= 0.13.1)
20
29
  ast (2.4.2)
21
30
  base64 (0.3.0)
31
+ benchmark-ips (2.15.1)
22
32
  bigdecimal (4.1.2)
23
- concurrent-ruby (1.2.2)
33
+ concurrent-ruby (1.3.8)
34
+ connection_pool (3.0.2)
24
35
  diff-lcs (1.5.0)
25
- i18n (1.12.0)
36
+ drb (2.2.3)
37
+ i18n (1.15.2)
26
38
  concurrent-ruby (~> 1.0)
27
39
  json (2.6.3)
28
40
  language_server-protocol (3.17.0.3)
29
- mini_portile2 (2.8.9)
30
- minitest (5.18.0)
41
+ logger (1.7.0)
42
+ minitest (6.0.6)
43
+ drb (~> 2.0)
44
+ prism (~> 1.5)
31
45
  mutex_m (0.3.0)
46
+ ostruct (0.6.3)
32
47
  parallel (1.23.0)
33
48
  parser (3.2.2.0)
34
49
  ast (~> 2.4.1)
50
+ prism (1.9.0)
35
51
  racc (1.8.1)
36
52
  rainbow (3.1.1)
37
53
  rake (13.0.6)
@@ -66,30 +82,35 @@ GEM
66
82
  rubocop (>= 1.7.0, < 2.0)
67
83
  rubocop-ast (>= 0.4.0)
68
84
  ruby-progressbar (1.13.0)
69
- sqlite3 (1.7.3)
70
- mini_portile2 (~> 2.8.0)
85
+ securerandom (0.4.1)
86
+ sqlite3 (2.9.6-arm64-darwin)
71
87
  standard (1.27.0)
72
88
  language_server-protocol (~> 3.17.0.2)
73
89
  rubocop (~> 1.50.2)
74
90
  rubocop-performance (~> 1.16.0)
91
+ timeout (0.6.1)
75
92
  tzinfo (2.0.6)
76
93
  concurrent-ruby (~> 1.0)
77
94
  unicode-display_width (2.4.2)
95
+ uri (1.1.1)
78
96
 
79
97
  PLATFORMS
80
98
  arm64-darwin-22
81
- ruby
99
+ arm64-darwin-23
82
100
 
83
101
  DEPENDENCIES
102
+ activerecord (~> 8.1)
84
103
  activerecord-quick_read!
85
104
  base64
105
+ benchmark-ips
86
106
  bigdecimal
87
107
  mutex_m
108
+ ostruct
88
109
  racc
89
110
  rake (~> 13.0)
90
111
  rspec (~> 3.0)
91
- sqlite3 (~> 1.6)
112
+ sqlite3 (~> 2.1)
92
113
  standard (~> 1.3)
93
114
 
94
115
  BUNDLED WITH
95
- 2.4.12
116
+ 4.0.19
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/activerecord-quick_read.svg)](https://badge.fury.io/rb/activerecord-quick_read)
4
4
 
5
- Makes rails go _faster_. Improve read times 4x!
5
+ Makes Rails go faster. Speedups scale with the size and complexity of your models, up to ~13x.
6
6
 
7
7
  ### How does it work?
8
8
 
@@ -12,18 +12,20 @@ typecasting each column, setting up dirty tracking, and evaluating any `after_in
12
12
  For large result sets that per-row work adds up fast, and you end up paying for features you may not
13
13
  even use (like attribute change tracking) when all you wanted was to *read* the data.
14
14
 
15
- `quick_read` sidesteps all of that. It still runs the exact same scoped SQL through
16
- `connection.select_all`, which returns each row as a plain hash. Then, instead of building model
17
- objects, each hash is wrapped in a lightweight `Struct` whose members mirror the model's columns:
15
+ `quick_read` sidesteps all of that. It runs the exact same scoped SQL, but reads the raw rows with
16
+ `connection.select_rows`, which returns each row as a plain array of values instead of the
17
+ column-keyed hashes that `select_all` builds. Each array is then mapped positionally to a lightweight
18
+ `Struct` whose members mirror the model's columns:
18
19
 
19
20
  ```ruby
20
21
  # the Lite class generated for your model, e.g.:
21
22
  Report::Lite = Struct.new(:id, :message, :status, :created_at, :updated_at)
22
23
  ```
23
24
 
24
- Because structs are plain Ruby objects there's no typecasting, no dirty tracking, no
25
- `after_initialize` callbacks, and far less allocation. The result is the same data you asked for,
26
- returned 4x faster.
25
+ Most of the speed comes from what's *not* allocated: no model objects, no per-row hash, no
26
+ typecasting, no dirty tracking, and no `after_initialize` callbacks. The struct is built directly
27
+ from the raw row values. The result is the same data you asked for, returned faster when you read
28
+ and serialize it. The speedup grows with the size and complexity of your model.
27
29
 
28
30
  The structs are *upgradeable*, not throwaway. Each `Lite` instance knows its source model and can
29
31
  lazily materialize a full ActiveRecord object on demand (via `method_missing`). So when you call a
@@ -72,6 +74,10 @@ Because models aren't fully defined until boot, the `Lite` structs are built laz
72
74
  initializes (via the included railtie). Any class that inherits from `ApplicationRecord`, even ones
73
75
  defined in engines or gems, automatically gets a `Lite` struct.
74
76
 
77
+ As a side benefit, defining a struct requires reading the model's `column_names`, which warms
78
+ ActiveRecord's schema cache and touches each model at boot. That in turn pre-warms the models, so
79
+ your first real web requests don't pay the one-time schema-loading cost.
80
+
75
81
  ### Reading
76
82
 
77
83
  ```ruby
@@ -94,7 +100,7 @@ Report.order(:created_at).quick_reads.map(&:to_h)
94
100
 
95
101
  ### Batching
96
102
 
97
- Load your batches 4x quicker:
103
+ Load your batches faster, with bigger wins on larger, more complex models:
98
104
 
99
105
  ```ruby
100
106
  Report.in_batches do |batch|
@@ -143,6 +149,27 @@ report.message # => updated value
143
149
 
144
150
  It just works.
145
151
 
152
+ ## Benchmarks
153
+
154
+ Measured against ActiveRecord 8.1 and SQLite with 10,000 records (see `benchmarks/read_benchmark.rb`).
155
+ The model includes string, integer, date, decimal, text, and JSON-serialized columns to exercise
156
+ typecasting:
157
+
158
+ | Scenario | ActiveRecord | quick_read | Speedup |
159
+ | --- | --- | --- | --- |
160
+ | Read fields | 7.6 i/s | 63.7 i/s | ~8.3x |
161
+ | Convert to hashes | 4.7 i/s | 56.4 i/s | ~13.7x |
162
+ | Access associations | 1.1 i/s | 1.0 i/s | ~same |
163
+
164
+ The win comes from skipping typecasting and instantiation when reading. The speedup depends on the
165
+ size and complexity of your models: more columns, and heavier typecasting (dates, decimals, JSON,
166
+ etc.), mean a bigger gap. When you access associations, each lite struct upgrades to a full
167
+ ActiveRecord object (via `method_missing`), so performance is comparable to a plain ActiveRecord read.
168
+
169
+ Run it yourself:
170
+
171
+ $ bundle exec ruby benchmarks/read_benchmark.rb
172
+
146
173
  ## Development
147
174
 
148
175
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "benchmark/ips"
5
+ require "active_record"
6
+ require "activerecord/quick_read"
7
+
8
+ ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
9
+ ActiveRecord::Base.logger = nil
10
+
11
+ ActiveRecord::Schema.define do
12
+ create_table :authors do |t|
13
+ t.string :name
14
+ end
15
+
16
+ create_table :reports do |t|
17
+ t.integer :author_id
18
+ t.string :message
19
+ t.string :status
20
+ t.integer :score
21
+ t.date :published_on
22
+ t.decimal :amount, precision: 10, scale: 2
23
+ t.text :body
24
+ t.text :payload
25
+ t.timestamps
26
+ end
27
+ end
28
+
29
+ class Author < ActiveRecord::Base
30
+ has_many :reports
31
+ end
32
+
33
+ class Report < ActiveRecord::Base
34
+ belongs_to :author
35
+
36
+ serialize :payload, coder: JSON
37
+
38
+ extend ActiveRecord::QuickRead
39
+ end
40
+
41
+ ROWS = Integer(ENV.fetch("ROWS", "10_000"))
42
+ AUTHORS = Integer(ENV.fetch("AUTHORS", "100"))
43
+ now = Time.now
44
+
45
+ Author.insert_all(AUTHORS.times.map { |i| {name: "Author #{i}"} })
46
+
47
+ Report.insert_all(
48
+ ROWS.times.map do |i|
49
+ {
50
+ message: "message #{i}",
51
+ status: i.even? ? "done" : "queued",
52
+ score: i,
53
+ author_id: (i % AUTHORS) + 1,
54
+ published_on: "2026-08-26",
55
+ amount: 1234.56,
56
+ body: "lorem ipsum dolor sit amet, consectetur adipiscing elit #{i}",
57
+ payload: {foo: "bar", baz: i, nested: {a: [1, 2, 3]}},
58
+ created_at: now,
59
+ updated_at: now
60
+ }
61
+ end
62
+ )
63
+
64
+ puts "Benchmarking #{ROWS} records\n\n"
65
+
66
+ Benchmark.ips do |x|
67
+ x.config(time: 5, warmup: 2)
68
+
69
+ x.report("activerecord read") do
70
+ Report.all.each do |r|
71
+ r.message
72
+ r.status
73
+ r.score
74
+ r.published_on
75
+ r.amount
76
+ r.body
77
+ r.payload
78
+ r.created_at
79
+ r.updated_at
80
+ end
81
+ end
82
+
83
+ x.report("quick_reads read") do
84
+ Report.quick_reads.each do |r|
85
+ r.message
86
+ r.status
87
+ r.score
88
+ r.published_on
89
+ r.amount
90
+ r.body
91
+ r.payload
92
+ r.created_at
93
+ r.updated_at
94
+ end
95
+ end
96
+
97
+ x.report("activerecord attributes") { Report.all.map(&:attributes) }
98
+ x.report("quick_reads to_h") { Report.quick_reads.map(&:to_h) }
99
+
100
+ x.report("activerecord association") { Report.all.each { |r| r.author&.name } }
101
+ x.report("quick_reads association") { Report.quick_reads.each { |r| r.author&.name } }
102
+
103
+ x.compare!
104
+ end
@@ -4,7 +4,7 @@ module ActiveRecord
4
4
  config.after_initialize do
5
5
  QuickRead.define_lite_structs
6
6
  rescue => e
7
- ActiveRecord::Base.logger.debug("QuickRead") { "Failed to define lite structs #{e.message}" }
7
+ ActiveRecord::Base.logger.debug { "QuickRead: Failed to define lite structs #{e.message}" }
8
8
  end
9
9
  end
10
10
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveRecord
4
4
  module QuickRead
5
- VERSION = "1.2.0"
5
+ VERSION = "1.2.1"
6
6
  end
7
7
  end
@@ -23,7 +23,7 @@ module ActiveRecord
23
23
 
24
24
  QuickRead.define_lite_struct(model)
25
25
  rescue => e
26
- ActiveRecord::Base.logger.debug("QuickRead") { "Failed to define quick models #{e.message}" }
26
+ ActiveRecord::Base.logger.debug { "QuickRead: Failed to define quick models #{e.message}" }
27
27
  end
28
28
 
29
29
  def self.models
@@ -33,13 +33,13 @@ module ActiveRecord
33
33
  def self.define_lite_structs
34
34
  return if QuickRead.models.empty?
35
35
 
36
- ActiveRecord::Base.logger.debug("QuickRead") { "Defining #{QuickRead.models.size} quick models" }
37
- time = Benchmark.realtime do
38
- while (model = models.pop)
39
- define_lite_struct(model)
40
- end
36
+ ActiveRecord::Base.logger.debug { "QuickRead: Defining #{QuickRead.models.size} quick models" }
37
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
38
+ while (model = models.pop)
39
+ define_lite_struct(model)
41
40
  end
42
- ActiveRecord::Base.logger.debug("QuickRead") { "Defining quick models took #{time.round(4)}s" }
41
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
42
+ ActiveRecord::Base.logger.debug { "QuickRead: Defining quick models took #{elapsed.round(4)}s" }
43
43
  end
44
44
 
45
45
  def self.define_lite_struct(model)
@@ -59,13 +59,18 @@ module ActiveRecord
59
59
  end
60
60
 
61
61
  def quick_build(attrs = {})
62
- klass = const_get(:Lite)
63
- klass.new(*attrs.symbolize_keys!.values_at(*klass.members))
62
+ klass = @quick_read_lite ||= const_get(:Lite)
63
+ klass.new(*klass.members.map { |member| attrs.fetch(member) { attrs[member.to_s] } })
64
64
  end
65
65
 
66
66
  def quick_reads
67
67
  scope = current_scope || all
68
- connection.select_all(scope.to_sql).map { |attrs| quick_build(attrs) }
68
+ klass = @quick_read_lite ||= const_get(:Lite)
69
+ if scope.select_values.empty?
70
+ connection.select_rows(scope.to_sql).map { |values| klass.new(*values) }
71
+ else
72
+ connection.select_all(scope.to_sql).map { |attrs| quick_build(attrs) }
73
+ end
69
74
  end
70
75
 
71
76
  def quick_read
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-quick_read
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ryan Buckley
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2026-08-26 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activerecord
@@ -44,7 +43,7 @@ files:
44
43
  - LICENSE.txt
45
44
  - README.md
46
45
  - Rakefile
47
- - activerecord-quick_read.gemspec
46
+ - benchmarks/read_benchmark.rb
48
47
  - lib/activerecord/quick_read.rb
49
48
  - lib/activerecord/quick_read/lite_base.rb
50
49
  - lib/activerecord/quick_read/railtie.rb
@@ -56,7 +55,6 @@ metadata:
56
55
  homepage_uri: https://github.com/ridiculous/activerecord-quick_reads
57
56
  source_code_uri: https://github.com/ridiculous/activerecord-quick_reads
58
57
  changelog_uri: https://github.com/ridiculous/activerecord-quick_reads/CHANGELOG.md
59
- post_install_message:
60
58
  rdoc_options: []
61
59
  require_paths:
62
60
  - lib
@@ -71,8 +69,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
71
69
  - !ruby/object:Gem::Version
72
70
  version: '0'
73
71
  requirements: []
74
- rubygems_version: 3.3.7
75
- signing_key:
72
+ rubygems_version: 3.7.2
76
73
  specification_version: 4
77
74
  summary: Speed up ActiveRecord reads by skipping model instantiation.
78
75
  test_files: []
@@ -1,40 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "lib/activerecord/quick_read/version"
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = "activerecord-quick_read"
7
- spec.version = ActiveRecord::QuickRead::VERSION
8
- spec.authors = ["Ryan Buckley"]
9
- spec.email = ["ridiculous@hey.com"]
10
-
11
- spec.summary = "Speed up ActiveRecord reads by skipping model instantiation."
12
- spec.description = "Faster ActiveRecord reads. Skips model instantiation and returns lightweight objects (with untypecast values) " \
13
- "just for reading. Fallback to full-fledged ActiveRecords on method_missing."
14
- spec.homepage = "https://github.com/ridiculous/activerecord-quick_reads"
15
- spec.license = "MIT"
16
- spec.required_ruby_version = ">= 2.6.0"
17
-
18
- # spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'"
19
-
20
- spec.metadata["homepage_uri"] = spec.homepage
21
- spec.metadata["source_code_uri"] = "https://github.com/ridiculous/activerecord-quick_reads"
22
- spec.metadata["changelog_uri"] = "https://github.com/ridiculous/activerecord-quick_reads/CHANGELOG.md"
23
-
24
- # Specify which files should be added to the gem when it is released.
25
- # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
26
- spec.files = Dir.chdir(__dir__) do
27
- `git ls-files -z`.split("\x0").reject do |f|
28
- (File.expand_path(f) == __FILE__) || f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor])
29
- end
30
- end
31
- spec.bindir = "exe"
32
- spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
33
- spec.require_paths = ["lib"]
34
-
35
- # Uncomment to register a new dependency of your gem
36
- spec.add_dependency "activerecord"
37
-
38
- # For more information and examples about making a new gem, check out our
39
- # guide at: https://bundler.io/guides/creating_gem.html
40
- end