diff --git a/lib/readyset.rb b/lib/readyset.rb index dd60b26..ce2af87 100644 --- a/lib/readyset.rb +++ b/lib/readyset.rb @@ -3,6 +3,7 @@ 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' @@ -83,6 +84,18 @@ def self.drop_cache!(name_or_id: nil, sql: nil) 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. diff --git a/lib/readyset/explain.rb b/lib/readyset/explain.rb new file mode 100644 index 0000000..92efded --- /dev/null +++ b/lib/readyset/explain.rb @@ -0,0 +1,59 @@ +module 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/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/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/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/ready_set_spec.rb b/spec/ready_set_spec.rb index e0b5aef..ddca651 100644 --- a/spec/ready_set_spec.rb +++ b/spec/ready_set_spec.rb @@ -125,6 +125,26 @@ end 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