From 5fc39fb6ff70c2b5348c185ae77fdee589e4d46f Mon Sep 17 00:00:00 2001 From: Ethan Donowitz <134718600+ethan-readyset@users.noreply.github.com> Date: Wed, 17 Jan 2024 16:01:21 -0500 Subject: [PATCH] relation: Add .readyset_explain (#62) This commit adds a `.readyset_explain` method to our relation extension. This method invokes `EXPLAIN CREATE CACHE` upstream on ReadySet and returns information about the query from ReadySet. Closes #44 --- .gitignore | 2 + lib/readyset.rb | 16 +++- lib/readyset/caches.rb | 21 +++++ lib/readyset/configuration.rb | 3 +- lib/readyset/explain.rb | 60 +++++++++++++++ lib/readyset/query/cached_query.rb | 2 +- lib/readyset/railtie.rb | 4 + lib/readyset/relation_extension.rb | 12 ++- lib/tasks/readyset.rake | 75 ++++++++++++++++++ lib/templates/caches.rb.tt | 11 +++ readyset.gemspec | 2 + spec/caches_spec.rb | 40 ++++++++++ spec/configuration_spec.rb | 7 ++ spec/explain_spec.rb | 78 +++++++++++++++++++ spec/factories/cached_query.rb | 10 +++ spec/factories/explain.rb | 9 +++ spec/factories/proxied_query.rb | 4 +- spec/proxied_query_spec.rb | 6 +- spec/rake_spec.rb | 120 +++++++++++++++++++++++++++++ spec/ready_set_spec.rb | 20 +++++ spec/relation_extension_spec.rb | 32 +++++++- 21 files changed, 520 insertions(+), 14 deletions(-) create mode 100644 lib/readyset/caches.rb create mode 100644 lib/readyset/explain.rb create mode 100644 lib/tasks/readyset.rake create mode 100644 lib/templates/caches.rb.tt create mode 100644 spec/caches_spec.rb create mode 100644 spec/explain_spec.rb create mode 100644 spec/factories/explain.rb create mode 100644 spec/rake_spec.rb diff --git a/.gitignore b/.gitignore index d38669e..0aae463 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ Gemfile.lock spec/internal/tmp/ spec/internal/config/storage.yml spec/internal/db/*.sqlite +spec/internal/db/*.sqlite-shm +spec/internal/db/*.sqlite-wal diff --git a/lib/readyset.rb b/lib/readyset.rb index fd770a0..19e5469 100644 --- a/lib/readyset.rb +++ b/lib/readyset.rb @@ -1,8 +1,10 @@ # lib/readyset.rb +require 'readyset/caches' require 'readyset/configuration' require 'readyset/controller_extension' require 'readyset/model_extension' +require 'readyset/explain' require 'readyset/query' require 'readyset/query/cached_query' require 'readyset/query/proxied_query' @@ -82,12 +84,24 @@ def self.drop_cache!(name) nil end + # Gets information about the given query from ReadySet, including whether it's supported to be + # cached, its current status, the rewritten query text, and the query ID. + # + # The information about the given query is retrieved by invoking `EXPLAIN CREATE CACHE FROM` on + # ReadySet. + # + # @param [String] a query about which information should be retrieved + # @return [Explain] + def self.explain(query) + Explain.call(query) + end + # Executes a raw SQL query against ReadySet. The query is sanitized prior to being executed. # @note This method is not part of the public API. # @param sql_array [Array] the SQL array to be executed against ReadySet. # @return [PG::Result] the result of executing the SQL query. def self.raw_query(*sql_array) # :nodoc: - ActiveRecord::Base.connected_to(role: reading_role, shard: shard, prevent_writes: false) do + ActiveRecord::Base.connected_to(role: writing_role, shard: shard, prevent_writes: false) do ActiveRecord::Base.connection.execute(ActiveRecord::Base.sanitize_sql_array(sql_array)) end end diff --git a/lib/readyset/caches.rb b/lib/readyset/caches.rb new file mode 100644 index 0000000..94fef96 --- /dev/null +++ b/lib/readyset/caches.rb @@ -0,0 +1,21 @@ +module Readyset + # Defines the DSL used in the gem's "migration" files. The DSL should be used by inheriting + # from this class and invoking the `.cache` class method to define new caches. + class Caches + class << self + attr_reader :caches + end + + def self.cache(id:, always: false) + @caches ||= Set.new + + query = yield + + @caches << Query::CachedQuery.new( + id: id, + text: query.strip, + always: always, + ) + end + end +end diff --git a/lib/readyset/configuration.rb b/lib/readyset/configuration.rb index aec2d73..038a952 100644 --- a/lib/readyset/configuration.rb +++ b/lib/readyset/configuration.rb @@ -3,9 +3,10 @@ module Readyset class Configuration - attr_accessor :shard + attr_accessor :migration_path, :shard def initialize + @migration_path = File.join(Rails.root, 'db/readyset_caches.rb') @shard = :readyset end end diff --git a/lib/readyset/explain.rb b/lib/readyset/explain.rb new file mode 100644 index 0000000..7c1dcd1 --- /dev/null +++ b/lib/readyset/explain.rb @@ -0,0 +1,60 @@ +module Readyset + # Represents the result of an `EXPLAIN CREATE CACHE` invocation on ReadySet. + class Explain + attr_reader :id, :text, :supported + + # Gets information about the given query from ReadySet, including whether it's supported to be + # cached, its current status, the rewritten query text, and the query ID. + # + # The information about the given query is retrieved by invoking `EXPLAIN CREATE CACHE FROM` on + # ReadySet. + # + # @param [String] a query about which information should be retrieved + # @return [Explain] + def self.call(query) + raw_results = Readyset.raw_query('EXPLAIN CREATE CACHE FROM %s', query) + from_readyset_results(**raw_results.first.to_h.symbolize_keys) + end + + # Creates a new `Explain` with the given attributes. + # + # @param [String] id the ID of the query + # @param [String] text the query text + # @param [Symbol] supported the supported status of the query + # @return [Explain] + def initialize(id:, text:, supported:) # :nodoc: + @id = id + @text = text + @supported = supported + end + + # Compares `self` with another `Explain` by comparing them attribute-wise. + # + # @param [Explain] other the `Explain` to which `self` should be compared + # @return [Boolean] + def ==(other) + id == other.id && + text == other.text && + supported == other.supported + end + + # Returns true if the explain information returned by ReadySet indicates that the query is + # unsupported. + # + # @return [Boolean] + def unsupported? + supported == :unsupported + end + + private + + def self.from_readyset_results(**attributes) + new( + id: attributes[:'query id'], + text: attributes[:query], + supported: attributes[:'readyset supported'].to_sym, + ) + end + private_class_method :from_readyset_results + end +end diff --git a/lib/readyset/query/cached_query.rb b/lib/readyset/query/cached_query.rb index a0f9f18..6db21d5 100644 --- a/lib/readyset/query/cached_query.rb +++ b/lib/readyset/query/cached_query.rb @@ -45,7 +45,7 @@ def self.find(id) # @param [Hash] attributes the attributes from which the `CachedQuery` should be # constructed # @return [CachedQuery] - def initialize(id:, text:, name:, always:, count:) + def initialize(id: nil, text:, name: nil, always: nil, count: nil) @id = id @text = text @name = name diff --git a/lib/readyset/railtie.rb b/lib/readyset/railtie.rb index 3e3e043..4bacec8 100644 --- a/lib/readyset/railtie.rb +++ b/lib/readyset/railtie.rb @@ -14,5 +14,9 @@ class Railtie < Rails::Railtie ActiveRecord::Relation.prepend(Readyset::RelationExtension) end end + + rake_tasks do + Dir[File.join(File.dirname(__FILE__), '../tasks/*.rake')].each { |f| load f } + end end end diff --git a/lib/readyset/relation_extension.rb b/lib/readyset/relation_extension.rb index f3eaa16..75a8283 100644 --- a/lib/readyset/relation_extension.rb +++ b/lib/readyset/relation_extension.rb @@ -7,7 +7,7 @@ module RelationExtension # query already exists. # # NOTE: If the ActiveRecord query eager loads associations (e.g. via `#includes`), the - # the queries issues to do the eager loading will not have caches created. Those queries must + # the queries issued to do the eager loading will not have caches created. Those queries must # have their caches created separately. # # @return [void] @@ -19,13 +19,21 @@ def create_readyset_cache! # for the query already doesn't exist. # # NOTE: If the ActiveRecord query eager loads associations (e.g. via `#includes`), the - # the queries issues to do the eager loading will not have caches dropped. Those queries must + # the queries issued to do the eager loading will not have caches dropped. Those queries must # have their caches dropped separately. # # @return [void] def drop_readyset_cache! Readyset.drop_cache!(sql: to_sql) end + + # Gets information about this query from ReadySet, including the query's ID, the normalized + # query text, and whether the query is supported by ReadySet. + # + # @return [Readyset::Explain] + def readyset_explain + Readyset.explain(to_sql) + end end end end diff --git a/lib/tasks/readyset.rake b/lib/tasks/readyset.rake new file mode 100644 index 0000000..bdf110f --- /dev/null +++ b/lib/tasks/readyset.rake @@ -0,0 +1,75 @@ +require 'colorize' +require 'erb' +require 'progressbar' + +namespace :readyset do + namespace :caches do + desc 'Dumps the set of caches that currently exist on ReadySet to a file' + task dump: :environment do + Rails.application.eager_load! + + template = File.read(File.join(File.dirname(__FILE__), '../templates/caches.rb.tt')) + + queries = Readyset::Query::CachedQuery.all + f = File.new(Readyset.configuration.migration_path, 'w') + f.write(ERB.new(template, trim_mode: '-').result(binding)) + f.close + end + + desc 'Synchronizes the caches on ReadySet such that the caches on ReadySet match those ' \ + 'listed in db/readyset_caches.rb' + task migrate: :environment do + Rails.application.eager_load! + + file = Readyset.configuration.migration_path + + # We load the definition of the `Readyset::Caches` subclass in the context of a + # container object so we can be sure that we are never re-opening a previously-defined + # subclass of `Readyset::Caches`. When the container object is garbage collected, the + # definition of the `Readyset::Caches` subclass is garbage collected too + container = Object.new + container.instance_eval(File.read(file)) + caches = container.singleton_class::ReadysetCaches.caches + + caches_on_readyset = Readyset::Query::CachedQuery.all.index_by(&:id) + caches_on_readyset_ids = caches_on_readyset.keys.to_set + + caches_in_migration_file = caches.index_by(&:id) + caches_in_migration_file_ids = caches_in_migration_file.keys.to_set + + to_drop_ids = caches_on_readyset_ids - caches_in_migration_file_ids + to_create_ids = caches_in_migration_file_ids - caches_on_readyset_ids + + if to_drop_ids.size.positive? || to_create_ids.size.positive? + dropping = 'Dropping'.red + creating = 'creating'.green + print "#{dropping} #{to_drop_ids.size} caches and #{creating} #{to_create_ids.size} " \ + 'caches. Continue? (y/n) ' + $stdout.flush + y_or_n = STDIN.gets.strip + + if y_or_n == 'y' + if to_drop_ids.size.positive? + bar = ProgressBar.create(title: 'Dropping caches', total: to_drop_ids.size) + + to_drop_ids.each do |id| + bar.increment + Readyset.drop_cache!(name_or_id: id) + end + end + + if to_create_ids.size.positive? + bar = ProgressBar.create(title: 'Creating caches', total: to_create_ids.size) + + to_create_ids.each do |id| + bar.increment + Readyset.create_cache!(id: id) + end + end + end + else + puts 'Nothing to do' + end + end + end +end diff --git a/lib/templates/caches.rb.tt b/lib/templates/caches.rb.tt new file mode 100644 index 0000000..78a8c13 --- /dev/null +++ b/lib/templates/caches.rb.tt @@ -0,0 +1,11 @@ +class ReadysetCaches < Readyset::Caches +<% queries.each do |query| -%> + cache id: <%= query.id.dump %>, always: <%= query.always %> do + <<~SQL + <%= query.text.gsub("\n", "\n ") %> + SQL + end + +<%- end -%> +end + diff --git a/readyset.gemspec b/readyset.gemspec index a37b658..8a93c10 100644 --- a/readyset.gemspec +++ b/readyset.gemspec @@ -34,6 +34,8 @@ Gem::Specification.new do |spec| spec.add_dependency 'actionpack', '>= 6.1' spec.add_dependency 'activerecord', '>= 6.1' spec.add_dependency 'activesupport', '>= 6.1' + spec.add_dependency 'colorize', '~> 1.1' + spec.add_dependency 'progressbar', '~> 1.13' spec.add_dependency 'rake', '~> 13.0' spec.add_development_dependency 'combustion', '~> 1.3' diff --git a/spec/caches_spec.rb b/spec/caches_spec.rb new file mode 100644 index 0000000..e9a80d0 --- /dev/null +++ b/spec/caches_spec.rb @@ -0,0 +1,40 @@ +RSpec.describe Readyset::Caches do + describe '.cache' do + after(:each) do + Readyset::Caches.instance_variable_set(:@caches, nil) + end + + it 'adds a cache with the given attributes to the @caches ivar' do + query = build(:cached_query, always: true, count: nil, name: nil) + + Readyset::Caches.cache(always: true, id: query.id) { query.text } + + caches = Readyset::Caches.instance_variable_get(:@caches) + expect(caches.size).to eq(1) + expect(caches.first).to eq(query) + end + + context 'when no always parameter is passed' do + it 'defaults the always parameter to false' do + query = build(:cached_query, count: nil, name: nil) + + Readyset::Caches.cache(id: query.id) { query.text } + + always = Readyset::Caches.instance_variable_get(:@caches).first.always + expect(always).to eq(false) + end + end + end + + describe '.caches' do + it 'returns the caches stored in the @caches ivar' do + query = build(:cached_query, count: nil, name: nil) + Readyset::Caches.cache(always: query.always, id: query.id) { query.text } + + result = Readyset::Caches.caches + + expect(result.size).to eq(1) + expect(result.first).to eq(query) + end + end +end diff --git a/spec/configuration_spec.rb b/spec/configuration_spec.rb index 71a8c71..e20d512 100644 --- a/spec/configuration_spec.rb +++ b/spec/configuration_spec.rb @@ -9,5 +9,12 @@ config = Readyset::Configuration.new expect(config.shard).to eq(:readyset) end + + it 'initializes migration_path to be db/readyset_caches.rb' do + config = Readyset::Configuration.new + + expected = File.join(Rails.root, 'db/readyset_caches.rb') + expect(config.migration_path).to eq(expected) + end end end diff --git a/spec/explain_spec.rb b/spec/explain_spec.rb new file mode 100644 index 0000000..e90de20 --- /dev/null +++ b/spec/explain_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Readyset::Explain do + describe '.call' do + it 'retrieves the explain information from ReadySet' do + explain = build(:explain) + raw_result = { + :'query id' => explain.id, + :'readyset supported' => explain.supported, + query: explain.text, + } + allow(Readyset).to receive(:raw_query).with('EXPLAIN CREATE CACHE FROM %s', explain.text). + and_return([raw_result]) + + result = Readyset::Explain.call(explain.text) + + expect(result).to eq(explain) + end + end + + describe '.new' do + it 'creates a new `Explain` with the given attributes' do + attributes = attributes_for(:explain) + + explain = Readyset::Explain.new(**attributes) + + expect(explain).to eq(build(:explain)) + end + end + + describe '#==' do + context "when the other `Explain` has an attribute that doesn't match self's" do + it 'returns false' do + explain = build(:explain) + other = build(:explain, supported: :pending) + + result = explain == other + + expect(result).to eq(false) + end + end + + context 'when the attributes of the other `Explain` match those of `self`' do + it 'returns true' do + explain = build(:explain) + other = build(:explain) + + result = explain == other + + expect(result).to eq(true) + end + end + end + + describe '#unsupported?' do + context 'when the `Explain` indicates that the query is supported' do + it 'returns false' do + explain = build(:explain) + + result = explain.unsupported? + + expect(result).to eq(false) + end + end + + context 'when the `Explain` indicates that the query is unsupported' do + it 'returns false' do + explain = build(:explain, supported: :unsupported) + + result = explain.unsupported? + + expect(result).to eq(true) + end + end + end +end diff --git a/spec/factories/cached_query.rb b/spec/factories/cached_query.rb index b66950f..06677ba 100644 --- a/spec/factories/cached_query.rb +++ b/spec/factories/cached_query.rb @@ -8,4 +8,14 @@ initialize_with { new(**attributes) } end + + factory :cached_query_2, class: 'Readyset::Query::CachedQuery' do + id { 'q_8892818e62c34ecd' } + count { 5 } + text { 'SELECT * FROM "t" WHERE ("y" = $1)' } + name { 'q_8892818e62c34ecd' } + always { true } + + initialize_with { new(**attributes) } + end end diff --git a/spec/factories/explain.rb b/spec/factories/explain.rb new file mode 100644 index 0000000..c573e81 --- /dev/null +++ b/spec/factories/explain.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + factory :explain, class: 'Readyset::Explain' do + id { 'q_eafb620c78f5b9ac' } + text { 'SELECT * FROM "t" WHERE ("x" = $1)' } + supported { :yes } + + initialize_with { new(**attributes) } + end +end diff --git a/spec/factories/proxied_query.rb b/spec/factories/proxied_query.rb index 5e296fd..6799487 100644 --- a/spec/factories/proxied_query.rb +++ b/spec/factories/proxied_query.rb @@ -6,11 +6,11 @@ text { 'SELECT * FROM "t" WHERE ("x" = $1)' } supported { :yes } - factory :pending_query do + factory :pending_proxied_query do supported { :pending } end - factory :unsupported_query do + factory :unsupported_proxied_query do id { 'q_f9bfc11a043b2f75' } text { 'SHOW TIME ZONE' } supported { :unsupported } diff --git a/spec/proxied_query_spec.rb b/spec/proxied_query_spec.rb index be2fff1..e381562 100644 --- a/spec/proxied_query_spec.rb +++ b/spec/proxied_query_spec.rb @@ -46,8 +46,8 @@ end let(:unsupported_or_pending_queries) do [ - build(:unsupported_query), - build(:pending_query), + build(:unsupported_proxied_query), + build(:pending_proxied_query), ] end @@ -179,7 +179,7 @@ context 'when the query is unsupported' do subject { query.cache! } - let(:query) { build(:unsupported_query) } + let(:query) { build(:unsupported_proxied_query) } it 'raises a ProxiedQuery::UnsupportedError' do expect { subject }.to raise_error(Readyset::Query::ProxiedQuery::UnsupportedError) diff --git a/spec/rake_spec.rb b/spec/rake_spec.rb new file mode 100644 index 0000000..4f0abaf --- /dev/null +++ b/spec/rake_spec.rb @@ -0,0 +1,120 @@ +require 'colorize' +require 'rake' +require 'spec_helper' + +load './lib/tasks/readyset.rake' + +RSpec.describe 'readyset.rake' do + before do + Rake::Task.define_task(:environment) + end + + describe 'readyset' do + describe 'caches' do + describe 'dump' do + it 'dumps the current set of caches to a migration file' do + # Setup + allow(Readyset::Query::CachedQuery).to receive(:all). + and_return([build(:cached_query), build(:cached_query_2)]) + + # Execute + Rake::Task['readyset:caches:dump'].execute + + # Verify + load './spec/internal/db/readyset_caches.rb' + subclasses = Readyset::Caches.subclasses + expect(subclasses.size).to eq(1) + + caches = subclasses.first.caches + expect(caches.size).to eq(2) + expect(caches).to include(build(:cached_query, count: nil, name: nil)) + expect(caches).to include(build(:cached_query_2, count: nil, name: nil)) + end + end + + describe 'migrate' do + after(:each) do + if File.exist?('./spec/internal/db/readyset_caches.rb') + File.delete('./spec/internal/db/readyset_caches.rb') + end + end + + context "when the migration file contains caches that don't exist on ReadySet" do + it "creates the caches in the migration file that don't exist on ReadySet" do + # Setup + cache_to_create = build(:cached_query_2) + generate_migration_file([build(:cached_query), cache_to_create]) + + allow(Readyset::Query::CachedQuery).to receive(:all).and_return([build(:cached_query)]) + allow(Readyset).to receive(:create_cache!).with(id: cache_to_create.id) + allow(STDIN).to receive(:gets).and_return("y\n") + + # Execute + Rake::Task['readyset:caches:migrate'].execute + + # Verify + expect(Readyset).to have_received(:create_cache!).with(id: cache_to_create.id) + end + + it 'prints the expected output' do + # Setup + cache_to_create = build(:cached_query_2) + generate_migration_file([build(:cached_query), cache_to_create]) + + allow(Readyset::Query::CachedQuery).to receive(:all).and_return([build(:cached_query)]) + allow(Readyset).to receive(:create_cache!).with(id: cache_to_create.id) + allow(STDIN).to receive(:gets).and_return("y\n") + + # Execute + Verify + expected_message = "#{'Dropping'.red} 0 caches and #{'creating'.green} 1 caches. " \ + 'Continue? (y/n) ' + expect { Rake::Task['readyset:caches:migrate'].execute }.to output(expected_message). + to_stdout + end + end + + context "when ReadySet has caches that don't exist in the migration file" do + it 'drops the caches that exist on ReadySet that are not in the migration file' do + # Setup + generate_migration_file([build(:cached_query)]) + + cache_to_drop = build(:cached_query_2) + allow(Readyset::Query::CachedQuery).to receive(:all). + and_return([build(:cached_query), cache_to_drop]) + allow(Readyset).to receive(:drop_cache!).with(name_or_id: cache_to_drop.id) + allow(STDIN).to receive(:gets).and_return("y\n") + + # Execute + Rake::Task['readyset:caches:migrate'].execute + + # Verify + expect(Readyset).to have_received(:drop_cache!).with(name_or_id: cache_to_drop.id) + end + + it 'prints the expected output' do + # Setup + generate_migration_file([build(:cached_query)]) + + cache_to_drop = build(:cached_query_2) + allow(Readyset::Query::CachedQuery).to receive(:all). + and_return([build(:cached_query), cache_to_drop]) + allow(Readyset).to receive(:drop_cache!).with(name_or_id: cache_to_drop.id) + allow(STDIN).to receive(:gets).and_return("y\n") + + # Execute + Verify + expected_message = "#{'Dropping'.red} 1 caches and #{'creating'.green} 0 caches. " \ + 'Continue? (y/n) ' + expect { Rake::Task['readyset:caches:migrate'].execute }.to output(expected_message). + to_stdout + end + end + + def generate_migration_file(caches) + allow(Readyset::Query::CachedQuery).to receive(:all).and_return(caches) + Rake::Task['readyset:caches:dump'].execute + allow(Readyset::Query::CachedQuery).to receive(:all).and_call_original + end + end + end + end +end diff --git a/spec/ready_set_spec.rb b/spec/ready_set_spec.rb index 12d564f..1f87c00 100644 --- a/spec/ready_set_spec.rb +++ b/spec/ready_set_spec.rb @@ -76,6 +76,26 @@ it_behaves_like 'a wrapper around a ReadySet SQL extension', 'DROP CACHE "query_name"' end + describe '.explain' do + it 'invokes `Explain.call` with the given query' do + explain = build(:explain) + allow(Readyset::Explain).to receive(:call).with(explain.text).and_return(explain) + + Readyset.explain(explain.text) + + expect(Readyset::Explain).to have_received(:call).with(explain.text) + end + + it 'returns a `Explain`' do + explain = build(:explain) + allow(Readyset::Explain).to receive(:call).with(explain.text).and_return(explain) + + result = Readyset.explain(explain.text) + + expect(result).to eq(explain) + end + end + describe '.raw_query' do subject { Readyset.raw_query(*query) } diff --git a/spec/relation_extension_spec.rb b/spec/relation_extension_spec.rb index 476116c..a780888 100644 --- a/spec/relation_extension_spec.rb +++ b/spec/relation_extension_spec.rb @@ -12,8 +12,7 @@ subject end - it 'invokes `Readyset.create_cache!` with the parameterized query string that the ' \ - 'relation represents' do + it "invokes `Readyset.create_cache!` with the relation's query string" do expect(Readyset).to have_received(:create_cache!).with(sql: query_string) end end @@ -29,9 +28,34 @@ subject end - it 'invokes `Readyset.drop_cache!` with the parameterized query string that the relation ' \ - 'represents' do + it "invokes `Readyset.drop_cache!` with the relation's query string" do expect(Readyset).to have_received(:drop_cache!).with(sql: query_string) end end + + describe '#readyset_explain' do + it "invokes `Readyset.readyset_explain` with the relation's query string" do + query = Cat.where(id: 1) + query_string = query.to_sql + allow(Readyset).to receive(:explain).with(query_string). + and_return(instance_double(Readyset::Explain)) + + query.readyset_explain + + expect(Readyset).to have_received(:explain).with(query_string) + end + + it 'returns the expected explain information' do + query = Cat.where(id: 1) + query_string = query.to_sql + explain = Readyset::Explain.new(id: 'q_0000000000000000', + text: 'SELECT * FROM "cats" WHERE ("id" = $1)', + supported: :yes) + allow(Readyset).to receive(:explain).with(query_string).and_return(explain) + + output = query.readyset_explain + + expect(output).to eq(explain) + end + end end