diff --git a/Rakefile b/Rakefile index c92b11e..f5c0232 100644 --- a/Rakefile +++ b/Rakefile @@ -1,6 +1,13 @@ -require "bundler/gem_tasks" +require "bundler/setup" +Bundler::GemHelper.install_tasks + require "rspec/core/rake_task" -RSpec::Core::RakeTask.new(:spec) +# Adds dummy:db tasks. +load "spec/dummy/Rakefile" -task default: :spec +# Declares gem's own tasks. +desc "Runs test suite." +task default: %w(dummy:db:migrate) do + system "bundle exec rspec spec" +end diff --git a/lib/rspec/sqlimit.rb b/lib/rspec/sqlimit.rb index 382f67f..d5921f6 100644 --- a/lib/rspec/sqlimit.rb +++ b/lib/rspec/sqlimit.rb @@ -1,11 +1,51 @@ require "active_record" require "rspec" -module RSpec::SQLimit - require_relative "sqlimit/counter" -end +module RSpec + module SQLimit + require_relative "sqlimit/counter" + require_relative "sqlimit/reporter" + end + + Matchers.define :exceed_query_limit do |expected, matcher = nil| + def counter + @counter ||= RSpec::SQLimit::Counter.new + end + + def message(expected, matcher, negation = false) + if matcher + condition = negation ? "maximum" : "more than" + restriction = " that match #{matcher}" + suffix = " among others (see mark ->)" + end + + reporter = RSpec::SQLimit::Reporter.new(counter, matcher) + + <<-MESSAGE.gsub(/ +\|/, "") + |Expected to run #{condition} #{expected} queries#{restriction} + |The following #{reporter.count} queries were invoked#{suffix}: + |#{reporter.lines.join("\n")} + MESSAGE + end + + match do |block| + counter.count(&block) + RSpec::SQLimit::Reporter.new(counter, matcher).count > expected + end + + match_when_negated do |block| + counter.count(&block) + RSpec::SQLimit::Reporter.new(counter, matcher).count <= expected + end + + failure_message do |_| + message(expected, matcher) + end + + failure_message_when_negated do |_| + message(expected, matcher, true) + end -RSpec::Matchers.define :exceed_query_limit do - match do |block| + supports_block_expectations end end diff --git a/lib/rspec/sqlimit/counter.rb b/lib/rspec/sqlimit/counter.rb index e69de29..a38afeb 100644 --- a/lib/rspec/sqlimit/counter.rb +++ b/lib/rspec/sqlimit/counter.rb @@ -0,0 +1,26 @@ +module RSpec::SQLimit + class Counter + attr_reader :queries + + def initialize + @queries = [] + end + + # FIXME add mutex to ensure thread-safety + def count + @queries = [] + ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do + yield + end + end + + private + + def callback + @callback ||= lambda(_name, start, finish, _message_id, values) do + return if %w(CACHE SCHEMA).include? values[:name] + queries << { sql: values[:sql], duration: (finish - start) * 1_000 } + end + end + end +end diff --git a/lib/rspec/sqlimit/reporter.rb b/lib/rspec/sqlimit/reporter.rb new file mode 100644 index 0000000..c8b28bd --- /dev/null +++ b/lib/rspec/sqlimit/reporter.rb @@ -0,0 +1,30 @@ +module RSpec::SQLimit + class Reporter + attr_reader :matcher + + def initialize(counter, matcher = nil) + @counter = counter + @matcher = matcher + end + + def count + return queries.count unless @matcher + queries.count { |query| query[:sql] =~ @matcher } + end + + def lines + queries.map.with_index { |*args| line(*args) } + end + + private + + def line(query, index) + prefix = @matcher && query =~ @matcher ? "->" : " " + "#{prefix} #{index}) #{query[:sql]} (#{query[:duration].round(3)} ms)" + end + + def queries + @queries ||= @counter.queries + end + end +end diff --git a/rspec-sqlimit.gemspec b/rspec-sqlimit.gemspec index a5144c0..0f2e318 100644 --- a/rspec-sqlimit.gemspec +++ b/rspec-sqlimit.gemspec @@ -13,9 +13,12 @@ Gem::Specification.new do |gem| gem.required_ruby_version = ">= 2.3" - gem.add_runtime_dependency "activerecord", "> 4.0", "< 6.0" + gem.add_runtime_dependency "rails", "> 4.0", "< 6.0" gem.add_runtime_dependency "rspec", "~> 3.0" - gem.add_development_dependency "rake" + gem.add_development_dependency "rspec", "~> 3.0" + gem.add_development_dependency "rake", "> 10.0" + gem.add_development_dependency "sqlite3", "~> 1.3" + gem.add_development_dependency "database_cleaner", "~> 1.5" gem.add_development_dependency "rubocop", "~> 0.44" end diff --git a/spec/dummy/Rakefile b/spec/dummy/Rakefile new file mode 100644 index 0000000..3b9a8cb --- /dev/null +++ b/spec/dummy/Rakefile @@ -0,0 +1,14 @@ +require "active_record" +require "bundler/setup" + +namespace :dummy do + task :environment do + require_relative "lib/dummy" + end + + namespace :db do + task load_config: :environment + end + + load "active_record/railties/databases.rake" +end diff --git a/spec/dummy/app/models/post.rb b/spec/dummy/app/models/post.rb new file mode 100644 index 0000000..542c073 --- /dev/null +++ b/spec/dummy/app/models/post.rb @@ -0,0 +1,3 @@ +class Post < ActiveRecord::Base + belongs_to :user +end diff --git a/spec/dummy/app/models/user.rb b/spec/dummy/app/models/user.rb new file mode 100644 index 0000000..7f4f994 --- /dev/null +++ b/spec/dummy/app/models/user.rb @@ -0,0 +1,3 @@ +class User < ActiveRecord::Base + has_many :posts +end diff --git a/spec/dummy/config/database.yml b/spec/dummy/config/database.yml new file mode 100644 index 0000000..5c2dda4 --- /dev/null +++ b/spec/dummy/config/database.yml @@ -0,0 +1,6 @@ +--- +test: + adapter: :sqlite3 + database: spec/dummy/db/test.sqlite3 + pool: 5 + timeout: 5000 diff --git a/spec/dummy/config/environment.rb b/spec/dummy/config/environment.rb new file mode 100644 index 0000000..5a3ae0d --- /dev/null +++ b/spec/dummy/config/environment.rb @@ -0,0 +1,10 @@ +Dummy::Application.configure do |config| + dummy = File.expand_path "../..", __FILE__ + database_yml = File.join(dummy, "config/database.yml") + + config.database_configuration = YAML.load File.read(database_yml) + config.db_dir = File.join(dummy, "db") + config.env = :test + config.migrations_paths = [File.join(dummy, "db/migrate")] + config.root = dummy +end diff --git a/spec/dummy/db/migrate/20170211104800_create_users.rb b/spec/dummy/db/migrate/20170211104800_create_users.rb new file mode 100644 index 0000000..20f0c89 --- /dev/null +++ b/spec/dummy/db/migrate/20170211104800_create_users.rb @@ -0,0 +1,7 @@ +class CreateUsers < ActiveRecord::Migration + def change + create_table :users do |t| + t.string :name, index: true + end + end +end diff --git a/spec/dummy/db/migrate/20170211104823_create_posts.rb b/spec/dummy/db/migrate/20170211104823_create_posts.rb new file mode 100644 index 0000000..6567ae4 --- /dev/null +++ b/spec/dummy/db/migrate/20170211104823_create_posts.rb @@ -0,0 +1,9 @@ +class CreatePosts < ActiveRecord::Migration + def change + create_table :posts do |t| + t.references :users + t.string :title + t.text :text + end + end +end diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb new file mode 100644 index 0000000..d7a23e0 --- /dev/null +++ b/spec/dummy/db/schema.rb @@ -0,0 +1,26 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# Note that this schema.rb definition is the authoritative source for your +# database schema. If you need to create the application database on another +# system, you should be using db:schema:load, not running all the migrations +# from scratch. The latter is a flawed and unsustainable approach (the more migrations +# you'll amass, the slower it'll run and the greater likelihood for issues). +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema.define(version: 20170211104823) do + + create_table "posts", force: :cascade do |t| + t.integer "users_id" + t.string "title" + t.text "text" + end + + create_table "users", force: :cascade do |t| + t.string "name" + t.index ["name"], name: "index_users_on_name" + end + +end diff --git a/spec/dummy/db/test.sqlite3 b/spec/dummy/db/test.sqlite3 new file mode 100644 index 0000000..0161783 Binary files /dev/null and b/spec/dummy/db/test.sqlite3 differ diff --git a/spec/dummy/lib/dummy.rb b/spec/dummy/lib/dummy.rb new file mode 100644 index 0000000..7037671 --- /dev/null +++ b/spec/dummy/lib/dummy.rb @@ -0,0 +1,9 @@ +require "active_record" +require "bundler/setup" + +module Dummy + require_relative "dummy/application" + require_relative "../config/environment" + require_relative "../app/models/user.rb" + require_relative "../app/models/post.rb" +end diff --git a/spec/dummy/lib/dummy/application.rb b/spec/dummy/lib/dummy/application.rb new file mode 100644 index 0000000..cea568b --- /dev/null +++ b/spec/dummy/lib/dummy/application.rb @@ -0,0 +1,26 @@ +module Dummy + class Application + class << self + # Configuration settings wrapper for the + # ActiveRecord::Tasks::DatabaseTasks. + # + # Establishes AR connection after configuration. + # + def configure + yield tasks + base.configurations = tasks.database_configuration + base.establish_connection(tasks.env) + end + + private + + def base + @base ||= ActiveRecord::Base + end + + def tasks + @tasks ||= ActiveRecord::Tasks::DatabaseTasks + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index d390cb1..8626533 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,8 +1,16 @@ require "pry" require "rspec-sqlimit" +require "database_cleaner" + +require_relative "dummy/lib/dummy" + +DatabaseCleaner.strategy = :truncation RSpec.configure do |config| config.order = :random config.filter_run focus: true config.run_all_when_everything_filtered = true + + # Prepare the Test namespace for constants defined in specs + config.after(:each) { DatabaseCleaner.clean } end