ActiveRecord::QuickRead

Gem Version

Makes Rails go faster. Speedups scale with the size and complexity of your models, up to ~13x.

How does it work?

A normal ActiveRecord query does more than run SQL. Once the rows come back, ActiveRecord instantiates a model object for every row: allocating the object, running initialize, typecasting each column, setting up dirty tracking, and evaluating any after_initialize callbacks. For large result sets that per-row work adds up fast, and you end up paying for features you may not even use (like attribute change tracking) when all you wanted was to read the data.

quick_read sidesteps all of that. It runs the exact same scoped SQL, but reads the raw rows with connection.select_rows, which returns each row as a plain array of values instead of the column-keyed hashes that select_all builds. Each array is then mapped positionally to a lightweight Struct whose members mirror the model's columns:

# the Lite class generated for your model, e.g.:
Report::Lite = Struct.new(:id, :message, :status, :created_at, :updated_at)

Most of the speed comes from what's not allocated: no model objects, no per-row hash, no typecasting, no dirty tracking, and no after_initialize callbacks. The struct is built directly from the raw row values. The result is the same data you asked for, returned faster when you read and serialize it. The speedup grows with the size and complexity of your model.

The structs are upgradeable, not throwaway. Each Lite instance knows its source model and can lazily materialize a full ActiveRecord object on demand (via method_missing). So when you call a method the struct doesn't have (like save, update, or an association such as report.author) it transparently builds the real model behind the scenes and delegates to it. You only pay the cost of instantiation when you actually need to write, not when you're just reading.

Installation

Install the gem and add to the application's Gemfile by executing:

$ bundle add activerecord-quick_read

If bundler is not being used to manage dependencies, install the gem by executing:

$ gem install activerecord-quick_read

Usage

Add quickness to your models, then call #quick_read on an ActiveRecord relation to get a single record, or #quick_reads to get all scoped records. Use #quick_build on your models to build a lite instance from a hash.

Enable on a single model

class Report < ApplicationRecord
  extend ActiveRecord::QuickRead
end

Enable for every model via ApplicationRecord

Instead of extending each model individually, extend ApplicationRecord once and every subclass inherits the quick read behavior:

class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true

  extend ActiveRecord::QuickRead
end

Because models aren't fully defined until boot, the Lite structs are built lazily after Rails initializes (via the included railtie). Any class that inherits from ApplicationRecord, even ones defined in engines or gems, automatically gets a Lite struct.

As a side benefit, defining a struct requires reading the model's column_names, which warms ActiveRecord's schema cache and touches each model at boot. That in turn pre-warms the models, so your first real web requests don't pay the one-time schema-loading cost.

Reading

# A single record
report = Report.where(id: params[:id]).quick_read
report.id          # => 1
report.message     # => "All done"
report.class       # => Report::Lite

# All scoped records
reports = Report.where(status: "done").quick_reads
reports.first.title

The returned Report::Lite is a struct with the same attributes (and #to_h) as your model:

Report.order(:created_at).quick_reads.map(&:to_h)

Batching

Load your batches faster, with bigger wins on larger, more complex models:

Report.in_batches do |batch|
  batch.quick_reads.each do |report|
    puts report.message
  end
end

Building

Build a lite instance from attributes without hitting the database:

draft = Report.quick_build(id: 1, message: "hello")

Upgrading to a full Active Record object

Lite instances lazily upgrade to full-fledged ActiveRecord objects on demand. Calling any method the struct doesn't have (like save, update, or association accessors) transparently materializes the underlying model:

Report.where(status: "queued").quick_reads.each do |report|
  report.update(status: "done")
end
report = Report.first.quick_read
report.author          # => loads the belongs_to association on the full model
report.save!

Reloading

Refresh a lite instance with fresh data from the database:

report = Report.first.quick_read
# ... the underlying row changes elsewhere ...
report.reload
report.message # => updated value

It just works.

Benchmarks

Measured against ActiveRecord 8.1 and SQLite with 10,000 records (see benchmarks/read_benchmark.rb). The model includes string, integer, date, decimal, text, and JSON-serialized columns to exercise typecasting:

Scenario ActiveRecord quick_read Speedup
Read fields 7.6 i/s 63.7 i/s ~8.3x
Convert to hashes 4.7 i/s 56.4 i/s ~13.7x
Access associations 1.1 i/s 1.0 i/s ~same

The win comes from skipping typecasting and instantiation when reading. The speedup depends on the size and complexity of your models: more columns, and heavier typecasting (dates, decimals, JSON, etc.), mean a bigger gap. When you access associations, each lite struct upgrades to a full ActiveRecord object (via method_missing), so performance is comparable to a plain ActiveRecord read.

Run it yourself:

$ bundle exec ruby benchmarks/read_benchmark.rb

Development

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.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/ridiculous/activerecord-quick_reads. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.

License

The gem is available as open source under the terms of the MIT License.

Code of Conduct

Everyone interacting in the ActiveRecord::QuickRead project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.