├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── .overcommit.yml ├── .projections.json ├── Appraisals ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Gemfile ├── MIT-LICENSE ├── README.md ├── Rakefile ├── db-query-matchers.gemspec ├── gemfiles ├── rails_6_1.gemfile ├── rails_7_0.gemfile ├── rails_7_1.gemfile ├── rails_7_2.gemfile └── rails_8_0.gemfile ├── lib ├── db-query-matchers.rb ├── db_query_matchers.rb └── db_query_matchers │ ├── configuration.rb │ ├── make_database_queries.rb │ ├── query_counter.rb │ └── version.rb └── spec ├── db_query_matchers └── make_database_queries_spec.rb ├── spec_helper.rb └── support └── models └── cat.rb /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: Ruby specs 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | name: Specs 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | ruby-version: ['3.1', '3.2', '3.3'] 16 | gemfile: [ rails_6_1, rails_7_0, rails_7_1, rails_7_2, rails_8_0 ] 17 | experimental: [false] 18 | exclude: 19 | - ruby-version: "3.1" 20 | gemfile: "rails_8_0" 21 | 22 | env: 23 | BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile 24 | 25 | continue-on-error: ${{ matrix.experimental }} 26 | 27 | steps: 28 | - uses: actions/checkout@v2 29 | - name: Set up Ruby 30 | uses: ruby/setup-ruby@v1 31 | with: 32 | ruby-version: ${{ matrix.ruby-version }} 33 | bundler-cache: true 34 | - name: Run tests 35 | run: bundle exec rake 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle 2 | Gemfile.lock 3 | gemfiles/*.gemfile.lock 4 | -------------------------------------------------------------------------------- /.overcommit.yml: -------------------------------------------------------------------------------- 1 | CommitMsg: 2 | GerritChangeId: 3 | enabled: true 4 | -------------------------------------------------------------------------------- /.projections.json: -------------------------------------------------------------------------------- 1 | { 2 | "lib/db_query_matchers/*.rb": { 3 | "alternate": "spec/db_query_matchers/{}_spec.rb", 4 | "type": "source" 5 | }, 6 | "spec/db_query_matchers/*_spec.rb": { 7 | "alternate": "lib/db_query_matchers/{}.rb", 8 | "type": "test" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise "rails_6_1" do 2 | version = "~> 6.1.0" 3 | gem "activesupport", version 4 | gem "sqlite3", "~> 1.4" 5 | end 6 | 7 | appraise "rails_7_0" do 8 | version = "~> 7.0.0" 9 | gem "activesupport", version 10 | gem "sqlite3", "~> 1.4" 11 | end 12 | 13 | appraise "rails_7_1" do 14 | version = "~> 7.1.0" 15 | gem "activesupport", version 16 | gem "sqlite3", ">= 1.4" 17 | end 18 | 19 | appraise "rails_7_2" do 20 | version = "~> 7.2.0" 21 | gem "activesupport", version 22 | gem "sqlite3", ">= 1.4" 23 | end 24 | 25 | appraise "rails_8_0" do 26 | version = "~> 8.0.0" 27 | gem "activesupport", version 28 | gem "sqlite3", ">= 1.4" 29 | end 30 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.14.0 2 | 3 | - Relax gem constraints to allow Rails 8.0 4 | 5 | ## 0.13.0 6 | 7 | - Drop support for Ruby 3.0 (3.1+ still supported) 8 | 9 | ## 0.12.0 10 | 11 | - Drop support for EOL software (Ruby 2.x, Rails 6.0) 12 | - Add `database_role` option to scope checks for replicas 13 | 14 | ## 0.11.0 15 | 16 | - Relax dependencies for compatibility with Rails 7.0 17 | - Move CI to GitHub actions 18 | - Add new `ignore_cached` option to exclude queries previously cached by 19 | 20 | ## 0.10.0 21 | 22 | - Relax dependencies for compatibility with Rails 6.0 23 | - Add `unscoped` option for counting queries with no `WHERE` / `LIMIT` 24 | 25 | ## 0.9.0 26 | 27 | - Add `rspec` and `activesupport` as proper runtime dependencies 28 | 29 | ## 0.8.0 30 | 31 | - No longer require `rspec/mocks`. 32 | 33 | ## 0.7.0 34 | 35 | - Add new `db_event` configuration option to allow non-ActiveRecord ORMs. 36 | Thanks, @sethjeffery. [#20] 37 | 38 | ## 0.6.0 39 | 40 | - Add new `log_backtrace` and `backtrace_filter` options 41 | 42 | ## 0.5.0 43 | 44 | - Add new `schemaless` option 45 | 46 | ## 0.4.2 47 | 48 | - Support a `on_query_counted` configuration option that is a callback for 49 | arbitrary code. 50 | 51 | ## 0.4.1 52 | 53 | - Fix wrong error messages for nested block expectations. 54 | 55 | ## 0.4.0 56 | 57 | - Support passing a range to the count: option, by calling the case 58 | equality operator on the argument. 59 | 60 | ## 0.3.1 61 | 62 | - Add `matching` option that allows you to target certain queries. 63 | 64 | ## 0.3.0 65 | 66 | - Restore RSpec 2 support. 67 | - Add manipulative option to match CREATE, UPDATE and DELETE FROM queries. 68 | - Add .projections.json configuration file. 69 | 70 | ## 0.2.3 71 | 72 | - Fix issue #2. 73 | 74 | ## 0.2.2 75 | 76 | - Add configuration option that will allow you to ignore certain queries. 77 | 78 | ## 0.2.1 79 | 80 | - Fix Bundler auto-requiring. 81 | 82 | ## 0.2.0 83 | 84 | - Update for RSpec 3. 85 | 86 | ## 0.1.2 87 | 88 | - Fixed file inclusions in gemspec file. 89 | 90 | ## 0.1.1 91 | 92 | - Fix bug preventing proper inclusion in external projects. 93 | 94 | ## 0.1.0 95 | 96 | - Initial release. 97 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 1. Fork it ( https://github.com/brigade/db-query-matchers/fork ) 2 | 2. Create your feature branch (`git checkout -b my-new-feature`) 3 | 3. Commit your changes (`git commit -am 'Add some feature'`) 4 | 4. Push to the branch (`git push origin my-new-feature`) 5 | 5. Create new Pull Request 6 | 7 | ## Code of conduct 8 | 9 | This project adheres to the [Open Code of Conduct][code-of-conduct]. By 10 | participating, you are expected to honor this code. 11 | 12 | [code-of-conduct]: https://github.com/brigade/code-of-conduct 13 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Brigade 2 | https://www.brigade.com/ 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | this software and associated documentation files (the "Software"), to deal in 6 | the Software without restriction, including without limitation the rights to 7 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 8 | of the Software, and to permit persons to whom the Software is furnished to do 9 | so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # db-query-matchers 2 | 3 | [![Gem Version](https://badge.fury.io/rb/db-query-matchers.svg)](https://badge.fury.io/rb/db-query-matchers) 4 | [![Build Status](https://github.com/civiccc/db-query-matchers/actions/workflows/ci.yaml/badge.svg)](https://github.com/civiccc/db-query-matchers/actions) 5 | 6 | RSpec matchers for database queries made by ActiveRecord. 7 | 8 | ## Installation 9 | 10 | Add this line to your application's Gemfile, preferably in your `test` group: 11 | 12 | ```ruby 13 | gem 'db-query-matchers' 14 | ``` 15 | 16 | And then execute: 17 | 18 | ```bash 19 | bundle 20 | ``` 21 | 22 | Or install it yourself as: 23 | 24 | ```bash 25 | gem install db-query-matchers 26 | ``` 27 | 28 | ## Usage 29 | 30 | ```ruby 31 | describe 'MyCode' do 32 | context 'when we expect no queries' do 33 | it 'does not make database queries' do 34 | expect { subject.make_no_queries }.to_not make_database_queries 35 | end 36 | end 37 | 38 | context 'when we expect queries' do 39 | it 'makes database queries' do 40 | expect { subject.make_some_queries }.to make_database_queries 41 | end 42 | end 43 | 44 | context 'when we expect exactly 1 query' do 45 | it 'makes database queries' do 46 | expect { subject.make_one_query }.to make_database_queries(count: 1) 47 | end 48 | end 49 | 50 | context 'when we expect max 3 queries' do 51 | it 'makes database queries' do 52 | expect { subject.make_several_queries }.to make_database_queries(count: 0..3) 53 | end 54 | end 55 | 56 | context 'when we expect a possible range of queries' do 57 | it 'makes database queries' do 58 | expect { subject.make_several_queries }.to make_database_queries(count: 3..5) 59 | end 60 | end 61 | 62 | context 'when we only care about manipulative queries (INSERT, UPDATE, DELETE)' do 63 | it 'makes a destructive database query' do 64 | expect { subject.make_one_query }.to make_database_queries(manipulative: true) 65 | end 66 | end 67 | 68 | context 'when we only care about unscoped queries (SELECT without a WHERE or LIMIT clause))' do 69 | it 'makes an unscoped database query' do 70 | expect { subject.make_one_query }.to make_database_queries(unscoped: true) 71 | end 72 | end 73 | 74 | context 'when we only care about queries matching a certain pattern' do 75 | it 'makes a destructive database query' do 76 | expect { subject.make_special_queries }.to make_database_queries(matching: 'DELETE * FROM') 77 | end 78 | 79 | it 'makes a destructive database query matched with a regexp' do 80 | expect { subject.make_special_queries }.to make_database_queries(matching: /DELETE/) 81 | end 82 | end 83 | end 84 | ``` 85 | 86 | ## Configuration 87 | 88 | To exclude certain types of queries from being counted, specify an 89 | `ignores` configuration consisting of an array of regular expressions. If 90 | a query matches one of the patterns in this array, it will not be 91 | counted in the `make_database_queries` matcher. 92 | 93 | To exclude queries previously cached by ActiveRecord from being counted, 94 | add `ignore_cached` to the configuration. 95 | 96 | To exclude SCHEMA queries, add `schemaless` to the configuration. This will 97 | help avoid failing specs due to ActiveRecord load order. 98 | 99 | To log more about the queries being made, you can set the `log_backtrace` 100 | option to `true`. And to control what parts of the backtrace is logged, 101 | you can use `backtrace_filter`. 102 | 103 | ```ruby 104 | DBQueryMatchers.configure do |config| 105 | config.ignores = [/SHOW TABLES LIKE/] 106 | config.ignore_cached = true 107 | config.schemaless = true 108 | 109 | # the payload argument is described here: 110 | # http://edgeguides.rubyonrails.org/active_support_instrumentation.html#sql-active-record 111 | config.on_query_counted do |payload| 112 | # do something arbitrary with the query 113 | end 114 | 115 | config.log_backtrace = true 116 | config.backtrace_filter = Proc.new do |backtrace| 117 | backtrace.select { |line| line.start_with?(Rails.root.to_s) } 118 | end 119 | end 120 | ``` 121 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "appraisal" 2 | require "bundler" 3 | require "rspec/core/rake_task" 4 | 5 | Bundler::GemHelper.install_tasks 6 | 7 | RSpec::Core::RakeTask.new(:spec) 8 | 9 | if !ENV["APPRAISAL_INITIALIZED"] && !ENV["CI"] 10 | task :default do 11 | sh "appraisal install && rake appraisal spec" 12 | end 13 | else 14 | task default: [:spec] 15 | end 16 | -------------------------------------------------------------------------------- /db-query-matchers.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | $LOAD_PATH << File.expand_path('../lib', __FILE__) 3 | require 'db_query_matchers/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'db-query-matchers' 7 | spec.version = DBQueryMatchers::VERSION 8 | spec.authors = ['Brigade Engineering', 'Henric Trotzig', 'Joe Lencioni'] 9 | spec.email = ['eng@brigade.com', 'henric.trotzig@brigade.com', 10 | 'joe.lencioni@brigade.com'] 11 | spec.summary = 'RSpec matchers for database queries' 12 | spec.homepage = 'https://github.com/brigade/db-query-matchers' 13 | spec.license = 'MIT' 14 | 15 | spec.metadata = { 16 | 'changelog_uri' => 'https://github.com/sds/db-query-matchers/blob/main/CHANGELOG.md' 17 | } 18 | 19 | spec.files = Dir['lib/**/*.rb'] 20 | spec.require_paths = ['lib'] 21 | 22 | spec.add_runtime_dependency 'activesupport', '>= 4.0', "< 8.1" 23 | spec.add_runtime_dependency 'rspec', '>= 3.0' 24 | 25 | spec.add_development_dependency 'activerecord', '>= 4.0', "< 8.1" 26 | spec.add_development_dependency 'sqlite3' 27 | spec.add_development_dependency "appraisal", "~> 2.0" 28 | 29 | spec.required_ruby_version = ">= 3.0" 30 | end 31 | -------------------------------------------------------------------------------- /gemfiles/rails_6_1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activesupport", "~> 6.1.0" 6 | gem "sqlite3", "~> 1.4" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/rails_7_0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activesupport", "~> 7.0.0" 6 | gem "sqlite3", "~> 1.4" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/rails_7_1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activesupport", "~> 7.1.0" 6 | gem "sqlite3", ">= 1.4" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/rails_7_2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activesupport", "~> 7.2.0" 6 | gem "sqlite3", ">= 1.4" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/rails_8_0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activesupport", "~> 8.0.0" 6 | gem "sqlite3", ">= 1.4" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /lib/db-query-matchers.rb: -------------------------------------------------------------------------------- 1 | require 'db_query_matchers' 2 | -------------------------------------------------------------------------------- /lib/db_query_matchers.rb: -------------------------------------------------------------------------------- 1 | require 'db_query_matchers/version' 2 | require 'db_query_matchers/make_database_queries' 3 | require 'db_query_matchers/query_counter' 4 | require 'db_query_matchers/configuration' 5 | require 'active_support' 6 | 7 | # Main module that holds global configuration. 8 | module DBQueryMatchers 9 | class << self 10 | attr_writer :configuration 11 | end 12 | 13 | # Gets the current configuration 14 | # @return [DBQueryMatchers::Configuration] the active configuration 15 | def self.configuration 16 | @configuration ||= Configuration.new 17 | end 18 | 19 | # Resets the current configuration. 20 | # @return [DBQueryMatchers::Configuration] the active configuration 21 | def self.reset_configuration 22 | @configuration = Configuration.new 23 | end 24 | 25 | # Updates the current configuration. 26 | # @example 27 | # DBQueryMatchers.configure do |config| 28 | # config.ignores = [/SELECT.*FROM.*users/] 29 | # end 30 | # 31 | def self.configure 32 | yield(configuration) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/db_query_matchers/configuration.rb: -------------------------------------------------------------------------------- 1 | module DBQueryMatchers 2 | # Configuration for the DBQueryMatcher module. 3 | class Configuration 4 | attr_accessor :ignores, :ignore_cached, :on_query_counted, :schemaless, :log_backtrace, :backtrace_filter, :db_event 5 | 6 | def initialize 7 | @db_event = "sql.active_record" 8 | @ignores = [] 9 | @on_query_counted = Proc.new { } 10 | @schemaless = false 11 | @ignore_cached = false 12 | @log_backtrace = false 13 | @backtrace_filter = Proc.new { |backtrace| backtrace } 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/db_query_matchers/make_database_queries.rb: -------------------------------------------------------------------------------- 1 | require 'rspec/expectations' 2 | 3 | # Custom matcher to check for database queries performed by a block of code. 4 | # 5 | # @example 6 | # expect { subject }.to_not make_database_queries 7 | # 8 | # @example 9 | # expect { subject }.to make_database_queries(count: 1) 10 | # 11 | # @example 12 | # expect { subject }.to make_database_queries(manipulative: true) 13 | # 14 | # @example 15 | # expect { subject }.to make_database_queries(unscoped: true) 16 | # 17 | # @see DBQueryMatchers::QueryCounter 18 | RSpec::Matchers.define :make_database_queries do |options = {}| 19 | if RSpec::Core::Version::STRING =~ /^2/ 20 | def self.failure_message_when_negated(&block) 21 | failure_message_for_should_not(&block) 22 | end 23 | 24 | def self.failure_message(&block) 25 | failure_message_for_should(&block) 26 | end 27 | 28 | def supports_block_expectations? 29 | true 30 | end 31 | else 32 | supports_block_expectations 33 | end 34 | 35 | # Taken from ActionView::Helpers::TextHelper 36 | def pluralize(count, singular, plural = nil) 37 | word = if count == 1 || count.to_s =~ /^1(\.0+)?$/ 38 | singular 39 | else 40 | plural || singular.pluralize 41 | end 42 | 43 | "#{count || 0} #{word}" 44 | end 45 | 46 | define_method :matches? do |block| 47 | counter_options = {} 48 | if options[:manipulative] 49 | counter_options[:matches] = [/^\ *(INSERT|UPDATE|DELETE\ FROM)/] 50 | end 51 | if options[:unscoped] 52 | counter_options[:matches] = [ 53 | %r{ 54 | (?: # Any of these appear 55 | SELECT(?!\sCOUNT).*FROM| # SELECT ... FROM (not SELECT ... COUNT) 56 | DELETE\sFROM| # DELETE ... FROM 57 | UPDATE.*SET # UPDATE ... SET 58 | ) 59 | (?!.*(WHERE|LIMIT)) # Followed by WHERE and/or LIMIT 60 | }mx # Ignore whitespace and newlines 61 | ] 62 | end 63 | if options[:matching] 64 | counter_options[:matches] ||= [] 65 | case options[:matching] 66 | when Regexp 67 | counter_options[:matches] << options[:matching] 68 | when String 69 | counter_options[:matches] << Regexp.new(Regexp.escape(options[:matching])) 70 | end 71 | end 72 | 73 | counter_options[:database_role] = options[:database_role] 74 | @counter = DBQueryMatchers::QueryCounter.new(counter_options) 75 | ActiveSupport::Notifications.subscribed(@counter.to_proc, 76 | DBQueryMatchers.configuration.db_event, 77 | &block) 78 | if absolute_count = options[:count] 79 | absolute_count === @counter.count 80 | else 81 | @counter.count > 0 82 | end 83 | end 84 | 85 | failure_message_when_negated do |_| 86 | <<-EOS 87 | expected no queries, but #{@counter.count} were made: 88 | #{@counter.log.join("\n")} 89 | EOS 90 | end 91 | 92 | failure_message do |_| 93 | if options[:count] 94 | expected = pluralize(options[:count], 'query') 95 | actual = pluralize(@counter.count, 'was', 'were') 96 | 97 | output = "expected #{expected}, but #{actual} made" 98 | if @counter.count > 0 99 | output += ":\n#{@counter.log.join("\n")}" 100 | end 101 | output 102 | else 103 | 'expected queries, but none were made' 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /lib/db_query_matchers/query_counter.rb: -------------------------------------------------------------------------------- 1 | module DBQueryMatchers 2 | # Counter to keep track of the number of queries caused by running a piece of 3 | # code. Closely tied to the `:make_database_queries` matcher, this class is 4 | # designed to be a consumer of `sql.active_record` events. 5 | # 6 | # @example 7 | # counter = DBQueryMatchers::QueryCounter.new 8 | # ActiveSupport::Notifications.subscribed(counter.to_proc, 9 | # 'sql.active_record') do 10 | # # run code here 11 | # end 12 | # puts counter.count # prints the number of queries made 13 | # puts counter.log.join(', ') # prints all queries made 14 | # 15 | # @see http://api.rubyonrails.org/classes/ActiveSupport/Notifications.html#module-ActiveSupport::Notifications-label-Temporary+Subscriptions 16 | class QueryCounter 17 | attr_reader :count, :log 18 | 19 | def initialize(options = {}) 20 | @matches = options[:matches] 21 | @database_role = options[:database_role] 22 | @count = 0 23 | @log = [] 24 | end 25 | 26 | # Turns a QueryCounter instance into a lambda. Designed to be used when 27 | # subscribing to events through the ActiveSupport::Notifications module. 28 | # 29 | # @return [Proc] 30 | def to_proc 31 | lambda(&method(:callback)) 32 | end 33 | 34 | # Method called from the ActiveSupport::Notifications module (through the 35 | # lambda created by `to_proc`) when an SQL query is made. 36 | # 37 | # @param _name [String] name of the event 38 | # @param _start [Time] when the instrumented block started execution 39 | # @param _finish [Time] when the instrumented block ended execution 40 | # @param _message_id [String] unique ID for this notification 41 | # @param payload [Hash] the payload 42 | def callback(_name, _start, _finish, _message_id, payload) 43 | return if @database_role && (ActiveRecord::Base.current_role != @database_role) 44 | return if @matches && !any_match?(@matches, payload[:sql]) 45 | return if any_match?(DBQueryMatchers.configuration.ignores, payload[:sql]) 46 | return if DBQueryMatchers.configuration.ignore_cached && payload[:cached] 47 | return if DBQueryMatchers.configuration.schemaless && payload[:name] == "SCHEMA" 48 | 49 | count_query 50 | log_query(payload[:sql]) 51 | 52 | DBQueryMatchers.configuration.on_query_counted.call(payload) 53 | end 54 | 55 | private 56 | 57 | def any_match?(patterns, sql) 58 | patterns.any? { |pattern| sql =~ pattern } 59 | end 60 | 61 | def count_query 62 | @count += 1 63 | end 64 | 65 | def log_query(sql) 66 | log_entry = sql.strip 67 | 68 | if DBQueryMatchers.configuration.log_backtrace 69 | raw_backtrace = caller 70 | filtered_backtrace = DBQueryMatchers.configuration.backtrace_filter.call(raw_backtrace) 71 | log_entry += "\n#{filtered_backtrace.join("\n")}\n" 72 | end 73 | 74 | @log << log_entry 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/db_query_matchers/version.rb: -------------------------------------------------------------------------------- 1 | # Defines the gem version. 2 | module DBQueryMatchers 3 | VERSION = '0.14.0' 4 | end 5 | -------------------------------------------------------------------------------- /spec/db_query_matchers/make_database_queries_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe '#make_database_queries' do 4 | context 'when queries are made' do 5 | subject { Cat.first } 6 | 7 | it 'matches true when using `to`' do 8 | expect { subject }.to make_database_queries 9 | end 10 | 11 | context 'when using `to_not`' do 12 | it 'raises an error' do 13 | expect do 14 | expect { subject }.to_not make_database_queries 15 | end.to raise_error(RSpec::Expectations::ExpectationNotMetError) 16 | end 17 | 18 | it 'lists the queries made in the error message' do 19 | expect do 20 | expect { subject }.to_not make_database_queries 21 | end.to raise_error(RSpec::Expectations::ExpectationNotMetError, 22 | /SELECT.*FROM.*cats/) 23 | end 24 | end 25 | 26 | context 'when there is an on_query_counted callback configured' do 27 | before do 28 | @callback_called = false 29 | 30 | DBQueryMatchers.configure do |config| 31 | config.on_query_counted = lambda do |payload| 32 | @callback_called = true 33 | end 34 | end 35 | end 36 | 37 | after { DBQueryMatchers.reset_configuration } 38 | 39 | it 'is called' do 40 | expect { subject }.to make_database_queries 41 | expect(@callback_called).to eq(true) 42 | end 43 | 44 | context 'with an `ignores` pattern' do 45 | before do 46 | DBQueryMatchers.configure do |config| 47 | config.ignores = ignores 48 | end 49 | end 50 | 51 | let(:ignores) { [/SELECT.*FROM.*cats/] } 52 | 53 | it 'is not called' do 54 | expect { subject }.not_to make_database_queries 55 | expect(@callback_called).to eq(false) 56 | end 57 | end 58 | end 59 | 60 | context 'when an `ignores` pattern is configured' do 61 | before do 62 | DBQueryMatchers.configure do |config| 63 | config.ignores = ignores 64 | end 65 | end 66 | 67 | after { DBQueryMatchers.reset_configuration } 68 | 69 | context 'when the pattern matches the query' do 70 | let(:ignores) { [/SELECT.*FROM.*cats/] } 71 | 72 | it 'ignores the query' do 73 | expect { subject }.to_not make_database_queries 74 | end 75 | end 76 | 77 | context 'when the pattern does not match the query' do 78 | let(:ignores) { [/SELECT.*FROM.*dogs/] } 79 | 80 | it 'does not ignore the query' do 81 | expect { subject }.to make_database_queries(count: 1) 82 | end 83 | end 84 | 85 | context 'with multiple patterns' do 86 | let(:ignores) { [/SELECT.*FROM.*cats/, /SELECT.*FROM.*dogs/] } 87 | 88 | it 'ignores the query' do 89 | expect { subject }.to_not make_database_queries 90 | end 91 | end 92 | end 93 | 94 | context 'when a `count` option is specified' do 95 | context 'when the count is a range' do 96 | context 'and it matches' do 97 | it 'matches true' do 98 | expect { subject }.to make_database_queries(count: 1..2) 99 | end 100 | end 101 | 102 | context 'and it does not match' do 103 | it 'raises an error' do 104 | expect do 105 | expect { subject }.to make_database_queries(count: 2..3) 106 | end.to raise_error(RSpec::Expectations::ExpectationNotMetError) 107 | end 108 | end 109 | end 110 | 111 | context 'when the count is an integer' do 112 | context 'and it matches' do 113 | it 'matches true' do 114 | expect { subject }.to make_database_queries(count: 1) 115 | end 116 | end 117 | 118 | context 'and it does not match' do 119 | it 'raises an error' do 120 | expect do 121 | expect { subject }.to make_database_queries(count: 2) 122 | end.to raise_error(RSpec::Expectations::ExpectationNotMetError) 123 | end 124 | 125 | it 'mentions the expected number of queries' do 126 | expect do 127 | expect { subject }.to make_database_queries(count: 2) 128 | end.to raise_error(RSpec::Expectations::ExpectationNotMetError, 129 | /expected 2 queries/) 130 | end 131 | 132 | it 'mentions the actual number of queries' do 133 | expect do 134 | expect { subject }.to make_database_queries(count: 2) 135 | end.to raise_error(RSpec::Expectations::ExpectationNotMetError, 136 | /but 1 was made/) 137 | end 138 | 139 | it 'lists the queries made in the error message' do 140 | expect do 141 | expect { subject }.to make_database_queries(count: 2) 142 | end.to raise_error(RSpec::Expectations::ExpectationNotMetError, 143 | /SELECT.*FROM.*cats/) 144 | end 145 | end 146 | end 147 | end 148 | 149 | context 'when a `manipulative` option is as true' do 150 | context 'and there is a create query' do 151 | subject { Cat.create } 152 | 153 | it 'matches true' do 154 | expect { subject }.to make_database_queries(manipulative: true) 155 | end 156 | end 157 | 158 | context 'and there is an update query' do 159 | before do 160 | Cat.create if Cat.count == 0 161 | end 162 | 163 | subject { Cat.last.update name: 'Felix' } 164 | 165 | it 'matches true' do 166 | expect { subject }.to make_database_queries(manipulative: true) 167 | end 168 | end 169 | 170 | context 'and there is a destroy query' do 171 | before do 172 | Cat.create if Cat.count == 0 173 | end 174 | 175 | subject { Cat.last.destroy } 176 | 177 | it 'matches true' do 178 | expect { subject }.to make_database_queries(manipulative: true) 179 | end 180 | end 181 | 182 | context 'and there are no manipulative queries' do 183 | it 'raises an error' do 184 | expect do 185 | expect { subject }.to make_database_queries(manipulative: true) 186 | end.to raise_error(RSpec::Expectations::ExpectationNotMetError, 187 | /expected queries, but none were made/) 188 | end 189 | end 190 | end 191 | 192 | context 'when a `unscoped` option is true' do 193 | shared_examples 'it raises an error' do 194 | it 'raises an error' do 195 | expect do 196 | expect { subject }.to make_database_queries(unscoped: true) 197 | end.to raise_error(RSpec::Expectations::ExpectationNotMetError, 198 | /expected queries, but none were made/) 199 | end 200 | 201 | it 'does not raise with `to_not`' do 202 | expect { subject }.to_not make_database_queries(unscoped: true) 203 | end 204 | end 205 | 206 | before do 207 | Cat.create if Cat.count == 0 208 | end 209 | 210 | context 'and there is a query without a WHERE or LIMIT clause' do 211 | context 'SELECT' do 212 | subject { Cat.all.to_a } 213 | 214 | it 'matches true' do 215 | expect { subject }.to make_database_queries(unscoped: true) 216 | end 217 | 218 | it 'raises an error with `to_not`' do 219 | expect do 220 | expect { subject }.to_not make_database_queries(unscoped: true) 221 | end.to raise_error(RSpec::Expectations::ExpectationNotMetError, 222 | /expected no queries, but 1 were made/) 223 | end 224 | end 225 | 226 | context 'DELETE' do 227 | subject { Cat.delete_all } 228 | 229 | it 'matches true' do 230 | expect { subject }.to make_database_queries(unscoped: true) 231 | end 232 | 233 | it 'raises an error with `to_not`' do 234 | expect do 235 | expect { subject }.to_not make_database_queries(unscoped: true) 236 | end.to raise_error(RSpec::Expectations::ExpectationNotMetError, 237 | /expected no queries, but 1 were made/) 238 | end 239 | end 240 | 241 | context 'UPDATE' do 242 | subject { Cat.update_all(name: 'Nombre') } 243 | 244 | it 'matches true' do 245 | expect { subject }.to make_database_queries(unscoped: true) 246 | end 247 | 248 | it 'raises an error with `to_not`' do 249 | expect do 250 | expect { subject }.to_not make_database_queries(unscoped: true) 251 | end.to raise_error(RSpec::Expectations::ExpectationNotMetError, 252 | /expected no queries, but 1 were made/) 253 | end 254 | end 255 | 256 | context 'INSERT' do 257 | context 'without INTO SELECT' do 258 | subject { Cat.create name: 'Joe' } 259 | 260 | it 'matches false' do 261 | expect { subject }.to_not make_database_queries(unscoped: true) 262 | end 263 | end 264 | 265 | context 'with INTO SELECT' do 266 | subject do 267 | Cat.connection.execute <<-SQL 268 | INSERT INTO "cats" SELECT * FROM "dogs"; 269 | SQL 270 | end 271 | it 'matches true' do 272 | expect { subject }.to make_database_queries(unscoped: true) 273 | end 274 | 275 | it 'raises an error with `to`' do 276 | expect do 277 | expect { subject }.to_not make_database_queries(unscoped: true) 278 | end.to raise_error(RSpec::Expectations::ExpectationNotMetError, 279 | /expected no queries, but 1 were made/) 280 | end 281 | end 282 | end 283 | end 284 | 285 | context 'there is a limit clause' do 286 | context 'SELECT' do 287 | subject { Cat.all.limit(100).to_a } 288 | include_examples 'it raises an error' 289 | end 290 | 291 | context 'UPDATE' do 292 | subject { Cat.limit(10).update_all(name: 'Nombre') } 293 | include_examples 'it raises an error' 294 | end 295 | 296 | context 'DELETE' do 297 | subject do 298 | begin 299 | Cat.limit(100).delete_all 300 | rescue ActiveRecord::ActiveRecordError => e 301 | pending("delete_all doesn't support limits prior to ActiveRecord 5.2") if e.message.include?("delete_all doesn't support limit") 302 | raise 303 | end 304 | end 305 | 306 | include_examples 'it raises an error' 307 | end 308 | 309 | context 'INTO SELECT' do 310 | subject do 311 | Cat.connection.execute <<-SQL 312 | INSERT INTO "cats" SELECT * FROM "dogs" LIMIT 100; 313 | SQL 314 | end 315 | include_examples 'it raises an error' 316 | end 317 | end 318 | 319 | context 'there is a where clause' do 320 | context 'SELECT' do 321 | subject { Cat.where(name: 'Bob').to_a } 322 | include_examples 'it raises an error' 323 | end 324 | 325 | context 'UPDATE' do 326 | subject { Cat.where(name: 'Bob').update_all(name: 'Nombre') } 327 | include_examples 'it raises an error' 328 | end 329 | 330 | context 'DELETE' do 331 | subject { Cat.where(name: 'Bob').delete_all } 332 | include_examples 'it raises an error' 333 | end 334 | 335 | context 'INTO SELECT' do 336 | subject do 337 | Cat.connection.execute <<-SQL 338 | INSERT INTO "cats" SELECT * FROM "dogs" WHERE "dogs"."name" = 'Fido'; 339 | SQL 340 | end 341 | include_examples 'it raises an error' 342 | end 343 | end 344 | 345 | context 'there is a where and limit clause' do 346 | context 'SELECT' do 347 | subject { Cat.where(name: 'Bob').limit(10).to_a } 348 | include_examples 'it raises an error' 349 | end 350 | 351 | context 'UPDATE' do 352 | subject { Cat.where(name: 'Bob').limit(10).update_all(name: 'Nombre') } 353 | include_examples 'it raises an error' 354 | end 355 | 356 | context 'DELETE' do 357 | subject do 358 | begin 359 | Cat.where(name: 'Bob').limit(10).delete_all 360 | rescue ActiveRecord::ActiveRecordError => e 361 | pending("delete_all doesn't support limits prior to ActiveRecord 5.2") if e.message.include?("delete_all doesn't support limit") 362 | raise 363 | end 364 | end 365 | 366 | include_examples 'it raises an error' 367 | end 368 | 369 | context 'INTO SELECT' do 370 | subject do 371 | Cat.connection.execute <<-SQL 372 | INSERT INTO "cats" SELECT * FROM "dogs" WHERE "dogs"."name" = 'Fido' LIMIT 10; 373 | SQL 374 | end 375 | include_examples 'it raises an error' 376 | end 377 | end 378 | end 379 | 380 | context 'when a `matching` option is specified' do 381 | context 'with a string matcher' do 382 | context 'and there is a query matching the matcher specified' do 383 | subject { Cat.create } 384 | 385 | it 'matches true' do 386 | expect { subject }.to make_database_queries(matching: 'INSERT') 387 | end 388 | end 389 | 390 | context 'and there are no queries matching the matcher specified' do 391 | it 'raises an error' do 392 | expect do 393 | expect { subject }.to make_database_queries(matching: 'INSERT') 394 | end.to raise_error(RSpec::Expectations::ExpectationNotMetError, 395 | /expected queries, but none were made/) 396 | end 397 | end 398 | end 399 | 400 | context 'with a regexp matcher' do 401 | context 'and there is a query matching the matcher specified' do 402 | subject { Cat.create } 403 | 404 | it 'matches true' do 405 | expect { subject }.to make_database_queries(matching: /^\ *INSERT/) 406 | end 407 | end 408 | 409 | context 'and there are no queries matching the matcher specified' do 410 | it 'raises an error' do 411 | expect do 412 | expect { subject }.to make_database_queries(matching: /^\ *INSERT/) 413 | end.to raise_error(RSpec::Expectations::ExpectationNotMetError, 414 | /expected queries, but none were made/) 415 | end 416 | end 417 | end 418 | end 419 | 420 | if ActiveRecord::VERSION::MAJOR > 6 || 421 | (ActiveRecord::VERSION::MAJOR == 6 && ActiveRecord::VERSION::MINOR > 0) 422 | context 'when the database_role option is used' do 423 | context 'and a query is using the specified role' do 424 | subject { Cat.create } 425 | it 'matches true' do 426 | expect { subject }.to make_database_queries(database_role: :writing) 427 | end 428 | end 429 | 430 | context 'and no queries are made matching the role' do 431 | it 'raises an error' do 432 | expect do 433 | expect { subject }.to make_database_queries(database_role: :reading) 434 | end.to raise_error(RSpec::Expectations::ExpectationNotMetError, 435 | /expected queries, but none were made/) 436 | end 437 | end 438 | end 439 | end 440 | 441 | context 'when a `schemaless` option is true' do 442 | before do 443 | DBQueryMatchers.configure do |config| 444 | config.schemaless = true 445 | end 446 | end 447 | 448 | it 'does not count column information queries' do 449 | Cat.connection.schema_cache.clear! 450 | Cat.reset_column_information 451 | expect { subject }.to make_database_queries(count: 1) 452 | end 453 | end 454 | 455 | context 'when a `schemaless` option is false' do 456 | before do 457 | DBQueryMatchers.configure do |config| 458 | config.schemaless = false 459 | end 460 | end 461 | 462 | it 'does count column information queries' do 463 | Cat.reset_column_information 464 | expect { subject }.to make_database_queries(count: 2..4) 465 | end 466 | end 467 | 468 | context 'with ActiveRecord cache' do 469 | let(:pattern) { /SELECT.*FROM.*cats/ } 470 | subject do 471 | ActiveRecord::Base.connection.cache do 472 | 2.times { Cat.first } 473 | end 474 | end 475 | 476 | context 'when a `ignore_cached` option is true' do 477 | before do 478 | DBQueryMatchers.configure do |config| 479 | config.ignore_cached = true 480 | end 481 | end 482 | 483 | it 'ignores cached queries' do 484 | expect { subject }.to make_database_queries(count: 1, matching: pattern) 485 | end 486 | end 487 | 488 | context 'when a `ignore_cached` option is false' do 489 | before do 490 | DBQueryMatchers.configure do |config| 491 | config.ignore_cached = false 492 | end 493 | end 494 | 495 | it 'counts cached queries' do 496 | expect { subject }.to make_database_queries(count: 2, matching: pattern) 497 | end 498 | end 499 | end 500 | 501 | context 'when a `log_backtrace` option is true' do 502 | before do 503 | DBQueryMatchers.configure do |config| 504 | config.log_backtrace = true 505 | config.backtrace_filter = Proc.new do |backtrace| 506 | backtrace.select { |line| line.start_with?(__FILE__) } # only show lines in this file 507 | end 508 | end 509 | end 510 | 511 | it 'logs the backtrace for the query' do 512 | expect do 513 | expect { subject }.not_to make_database_queries 514 | end.to raise_error(RSpec::Expectations::ExpectationNotMetError) do |e| 515 | expect(e.message).to match(/SELECT/) 516 | expect(e.message).to include(__FILE__) 517 | end 518 | end 519 | end 520 | 521 | context 'when a different db_event is configured' do 522 | before do 523 | DBQueryMatchers.configure do |config| 524 | config.db_event = 'other_event' 525 | end 526 | end 527 | 528 | after { DBQueryMatchers.reset_configuration } 529 | 530 | it 'does not respond to normal events' do 531 | expect { subject }.not_to make_database_queries 532 | end 533 | 534 | it 'responds to custom event' do 535 | expect { 536 | ActiveSupport::Notifications.publish 'other_event', Time.now, Time.now, 1, { sql: "FOO" } 537 | }.to make_database_queries(count: 1) 538 | end 539 | end 540 | end 541 | 542 | context 'when no queries are made' do 543 | subject { 'hi' } 544 | 545 | it 'matches true when using `to_not`' do 546 | expect { subject }.to_not make_database_queries 547 | end 548 | 549 | it 'raises an error when using `to`' do 550 | expect do 551 | expect { subject }.to make_database_queries 552 | end.to raise_error(RSpec::Expectations::ExpectationNotMetError) 553 | end 554 | 555 | it 'has a readable error message' do 556 | expect do 557 | expect { subject }.to make_database_queries 558 | end.to raise_error(RSpec::Expectations::ExpectationNotMetError, 559 | /expected queries, but none were made/) 560 | end 561 | end 562 | 563 | context 'when some other expectation in the block fails' do 564 | subject { 565 | Cat.first 566 | raise RSpec::Expectations::ExpectationNotMetError.new('other') 567 | } 568 | 569 | it 'reraises the error' do 570 | expect do 571 | expect { subject }.to make_database_queries(count: 1) 572 | end.to raise_error(RSpec::Expectations::ExpectationNotMetError, /other/) 573 | end 574 | end 575 | 576 | end 577 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'db_query_matchers' 2 | require 'active_record' 3 | 4 | Dir[File.dirname(__FILE__) + '/support/**/*.rb'].each { |f| require f } 5 | 6 | RSpec.configure do |config| 7 | if ActiveRecord::VERSION::MAJOR > 5 8 | ActiveRecord::Base.establish_connection adapter: 'sqlite3', 9 | database: ':memory:', 10 | role: :writing 11 | else 12 | ActiveRecord::Base.establish_connection adapter: 'sqlite3', 13 | database: ':memory:' 14 | end 15 | 16 | ActiveRecord::Schema.define do 17 | self.verbose = false 18 | 19 | create_table :cats, :force => true do |t| 20 | t.column :name, :string 21 | end 22 | 23 | create_table :dogs, :force => true do |t| 24 | t.column :name, :string 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/support/models/cat.rb: -------------------------------------------------------------------------------- 1 | # Test models 2 | class Cat < ActiveRecord::Base; end 3 | class Dog < ActiveRecord::Base; end 4 | 5 | --------------------------------------------------------------------------------