seq_scanner 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.rspec +4 -0
- data/.rubocop.yml +13 -0
- data/LICENCE +21 -0
- data/README.md +55 -0
- data/Rakefile +12 -0
- data/lib/seq_scanner/checker.rb +42 -0
- data/lib/seq_scanner/errors/seq_scan_detected_error.rb +32 -0
- data/lib/seq_scanner/plan_formatter.rb +16 -0
- data/lib/seq_scanner/query_explainer.rb +14 -0
- data/lib/seq_scanner/query_plan.rb +42 -0
- data/lib/seq_scanner/switcher.rb +13 -0
- data/lib/seq_scanner/version.rb +5 -0
- data/lib/seq_scanner.rb +37 -0
- data/seq_scanner.gemspec +42 -0
- data/sig/seq_scanner.rbs +4 -0
- metadata +115 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 1f0e35a3ccc03dc43a1c23626f5638fa2cc5702a7766d7e26d8fbd0460e07550
|
4
|
+
data.tar.gz: 9a0da447c4dc74af1b032426c6251fbe20f68735c6ce1192505afcfcb693a4d4
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: aec043f84166a6a0e0b41d2306f743a276a420181da71835c4f26493c323cb8c5d3c78dab0e134e74682f87a8027c4030e780106101bfc5271d023a6afc82cbd
|
7
|
+
data.tar.gz: 02b1fd9aa29ee300957d73933969ad0ca9c6b6ddd8a681e0da3d344dfb41e7aa48e1476ae3cca9f75968d4bad113df345249ea2ddc2dd8aab4c80e8ea99b06ee
|
data/.rspec
ADDED
data/.rubocop.yml
ADDED
data/LICENCE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2023 Thomas Broomfield
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,55 @@
|
|
1
|
+
# SeqScanner
|
2
|
+
|
3
|
+
## SeqScanner is a testing tool for ActiveRecord models to verify that your queries are using the correct indexes.
|
4
|
+
|
5
|
+
## Overview
|
6
|
+
|
7
|
+
In development and test, with a small database, postgres will often use a sequence scan even when there is an index available, making it difficult to verify that your queries will use the correct indexes in production. SeqScanner is a testing tool for ActiveRecord models to verify that your queries are using the correct indexes.
|
8
|
+
|
9
|
+
### Installation
|
10
|
+
|
11
|
+
Add this line to your application's Gemfile in the test (or development) group:
|
12
|
+
|
13
|
+
```ruby
|
14
|
+
group :test do
|
15
|
+
gem 'seq_scanner'
|
16
|
+
end
|
17
|
+
```
|
18
|
+
|
19
|
+
### Usage
|
20
|
+
|
21
|
+
```ruby
|
22
|
+
SeqScanner.scan do
|
23
|
+
User.order(:name).first
|
24
|
+
end
|
25
|
+
```
|
26
|
+
|
27
|
+
This will do the following:
|
28
|
+
|
29
|
+
* Tell postgres to not use any sequence scans, if possible.
|
30
|
+
|
31
|
+
* Examine the query plan for the given block of code and raise an error if a sequence scan is used.
|
32
|
+
|
33
|
+
* Reset postgres to default settings.
|
34
|
+
|
35
|
+
Under these conditions, postgres will only use a sequence scan if there is no index that can be used to satisfy the query. This will raise an error if your query would not use the correct index in production.
|
36
|
+
|
37
|
+
### In tests
|
38
|
+
|
39
|
+
For rspec, you can wrap your tests in a `SeqScanner.scan` block:
|
40
|
+
|
41
|
+
```ruby
|
42
|
+
RSpec.configure do |config|
|
43
|
+
# ...
|
44
|
+
|
45
|
+
config.around(:each) do |example|
|
46
|
+
SeqScanner.scan do
|
47
|
+
example.run
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# ...
|
52
|
+
end
|
53
|
+
```
|
54
|
+
|
55
|
+
This will ensure that all queries in your tests have appropriate indexes.
|
data/Rakefile
ADDED
@@ -0,0 +1,42 @@
|
|
1
|
+
require_relative 'query_explainer'
|
2
|
+
require_relative 'query_plan'
|
3
|
+
|
4
|
+
module SeqScanner
|
5
|
+
class Checker
|
6
|
+
def initialize(**opts)
|
7
|
+
self.queries = []
|
8
|
+
self.explainer = opts.fetch(:explainer) { QueryExplainer }
|
9
|
+
end
|
10
|
+
|
11
|
+
def check_query_plan(&block)
|
12
|
+
subscribe
|
13
|
+
result = block.call
|
14
|
+
unsubscribe
|
15
|
+
|
16
|
+
execute_query_plans.each(&:validate)
|
17
|
+
|
18
|
+
result
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def execute_query_plans
|
24
|
+
queries.map do |query|
|
25
|
+
QueryPlan.new(query, explainer.explain(query))
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def subscribe
|
30
|
+
self.subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |_, _, _, _, details|
|
31
|
+
queries << details
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def unsubscribe
|
36
|
+
ActiveSupport::Notifications.unsubscribe(subscriber)
|
37
|
+
self.subscriber = nil
|
38
|
+
end
|
39
|
+
|
40
|
+
attr_accessor :queries, :query_plans, :explainer, :validator, :subscriber
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
require_relative '../plan_formatter'
|
2
|
+
require 'paint'
|
3
|
+
|
4
|
+
module SeqScanner
|
5
|
+
class SeqScanDetectedError < StandardError
|
6
|
+
def initialize(query_plan)
|
7
|
+
msg = <<~ERROR
|
8
|
+
#{white("Sequential scan detected in query plan for the #{yellow(query_plan.name)} query:")}
|
9
|
+
|
10
|
+
#{white("Query:")}
|
11
|
+
#{white(query_plan.sql)}
|
12
|
+
|
13
|
+
#{white("Query plan:")}
|
14
|
+
#{PlanFormatter.format(query_plan.query_plan)}
|
15
|
+
|
16
|
+
#{white("Bindings")}
|
17
|
+
#{query_plan.binds.map { |bind| white("#{bind[:name]}: #{bind[:value]}") }.join("\n")}
|
18
|
+
ERROR
|
19
|
+
super(msg)
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def white(text)
|
25
|
+
Paint[text, :white]
|
26
|
+
end
|
27
|
+
|
28
|
+
def yellow(text)
|
29
|
+
Paint[text, :yellow]
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
require 'paint'
|
2
|
+
module SeqScanner
|
3
|
+
module PlanFormatter
|
4
|
+
class << self
|
5
|
+
def format(query_plan)
|
6
|
+
query_plan.map do |line|
|
7
|
+
if line =~ /Seq Scan/
|
8
|
+
Paint[line, :red]
|
9
|
+
else
|
10
|
+
Paint[line, :white]
|
11
|
+
end
|
12
|
+
end.join("\n")
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
module SeqScanner
|
2
|
+
module QueryExplainer
|
3
|
+
class << self
|
4
|
+
def explain(query)
|
5
|
+
return false if query[:name] == "SCHEMA"
|
6
|
+
ActiveRecord::Base.connection.unprepared_statement do
|
7
|
+
ActiveRecord::Base.connection.exec_query("EXPLAIN #{query[:sql]}", 'SQL', query[:binds]).to_a.map do |row|
|
8
|
+
row['QUERY PLAN']
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
require_relative 'errors/seq_scan_detected_error'
|
2
|
+
|
3
|
+
module SeqScanner
|
4
|
+
class QueryPlan
|
5
|
+
attr_reader :name, :sql, :binds, :query_plan
|
6
|
+
|
7
|
+
def initialize(input, result)
|
8
|
+
self.sql = input[:sql]
|
9
|
+
self.name = input[:name]
|
10
|
+
self.binds = input[:binds].map do |bind|
|
11
|
+
{
|
12
|
+
name: bind.name,
|
13
|
+
value: bind.value,
|
14
|
+
}
|
15
|
+
end
|
16
|
+
self.query_plan = result
|
17
|
+
end
|
18
|
+
|
19
|
+
def validate
|
20
|
+
return true if schema_migrations?
|
21
|
+
return true unless seq?
|
22
|
+
|
23
|
+
raise SeqScanDetectedError.new(self)
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def schema_migrations?
|
29
|
+
name == 'SCHEMA'
|
30
|
+
end
|
31
|
+
|
32
|
+
def seq?
|
33
|
+
return true if query_plan.find do |line|
|
34
|
+
line =~ /Seq Scan/
|
35
|
+
end
|
36
|
+
|
37
|
+
false
|
38
|
+
end
|
39
|
+
|
40
|
+
attr_writer :sql, :name, :binds, :query_plan
|
41
|
+
end
|
42
|
+
end
|
data/lib/seq_scanner.rb
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "seq_scanner/version"
|
4
|
+
require_relative "seq_scanner/switcher"
|
5
|
+
require_relative "seq_scanner/checker"
|
6
|
+
# require_relative "seq_scanner/errors/"
|
7
|
+
|
8
|
+
module SeqScanner
|
9
|
+
class << self
|
10
|
+
def scan(&block)
|
11
|
+
transact do
|
12
|
+
discourage_seqscan do
|
13
|
+
validate &block
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def discourage_seqscan
|
19
|
+
SeqScanner::Switcher.on
|
20
|
+
yield
|
21
|
+
ensure
|
22
|
+
SeqScanner::Switcher.off
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def transact
|
28
|
+
# return yield
|
29
|
+
return yield if ActiveRecord::Base.connection.transaction_open?
|
30
|
+
ActiveRecord::Base.transaction { yield }
|
31
|
+
end
|
32
|
+
|
33
|
+
def validate(&block)
|
34
|
+
Checker.new.check_query_plan(&block)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
data/seq_scanner.gemspec
ADDED
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "lib/seq_scanner/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = "seq_scanner"
|
7
|
+
spec.version = SeqScanner::VERSION
|
8
|
+
spec.authors = ["Thomas Broomfield"]
|
9
|
+
spec.email = ["tomplbroomfield@gmail.com"]
|
10
|
+
|
11
|
+
spec.summary = "A small ActiveRecord & Postgres extension for managing sequential scans"
|
12
|
+
spec.homepage = "https://github.com/tombroomfield/seq_scanner"
|
13
|
+
spec.required_ruby_version = ">= 2.6.0"
|
14
|
+
|
15
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
16
|
+
spec.metadata["source_code_uri"] = "https://github.com/tombroomfield"
|
17
|
+
# spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here."
|
18
|
+
|
19
|
+
# Specify which files should be added to the gem when it is released.
|
20
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
21
|
+
spec.files = Dir.chdir(__dir__) do
|
22
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
23
|
+
(File.expand_path(f) == __FILE__) ||
|
24
|
+
f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor Gemfile])
|
25
|
+
end
|
26
|
+
end
|
27
|
+
spec.bindir = "exe"
|
28
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
29
|
+
spec.require_paths = ["lib"]
|
30
|
+
|
31
|
+
spec.add_dependency "activerecord", ">= 6.1"
|
32
|
+
|
33
|
+
spec.add_dependency "activesupport", ">= 6.1"
|
34
|
+
spec.add_dependency "pg", ">= 1.2"
|
35
|
+
spec.add_dependency "paint", ">= 2.0"
|
36
|
+
|
37
|
+
# Uncomment to register a new dependency of your gem
|
38
|
+
# spec.add_dependency "example-gem", "~> 1.0"
|
39
|
+
|
40
|
+
# For more information and examples about making a new gem, check out our
|
41
|
+
# guide at: https://bundler.io/guides/creating_gem.html
|
42
|
+
end
|
data/sig/seq_scanner.rbs
ADDED
metadata
ADDED
@@ -0,0 +1,115 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: seq_scanner
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Thomas Broomfield
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2023-11-07 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: activerecord
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '6.1'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '6.1'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: activesupport
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '6.1'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '6.1'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: pg
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '1.2'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '1.2'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: paint
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '2.0'
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '2.0'
|
69
|
+
description:
|
70
|
+
email:
|
71
|
+
- tomplbroomfield@gmail.com
|
72
|
+
executables: []
|
73
|
+
extensions: []
|
74
|
+
extra_rdoc_files: []
|
75
|
+
files:
|
76
|
+
- ".rspec"
|
77
|
+
- ".rubocop.yml"
|
78
|
+
- LICENCE
|
79
|
+
- README.md
|
80
|
+
- Rakefile
|
81
|
+
- lib/seq_scanner.rb
|
82
|
+
- lib/seq_scanner/checker.rb
|
83
|
+
- lib/seq_scanner/errors/seq_scan_detected_error.rb
|
84
|
+
- lib/seq_scanner/plan_formatter.rb
|
85
|
+
- lib/seq_scanner/query_explainer.rb
|
86
|
+
- lib/seq_scanner/query_plan.rb
|
87
|
+
- lib/seq_scanner/switcher.rb
|
88
|
+
- lib/seq_scanner/version.rb
|
89
|
+
- seq_scanner.gemspec
|
90
|
+
- sig/seq_scanner.rbs
|
91
|
+
homepage: https://github.com/tombroomfield/seq_scanner
|
92
|
+
licenses: []
|
93
|
+
metadata:
|
94
|
+
homepage_uri: https://github.com/tombroomfield/seq_scanner
|
95
|
+
source_code_uri: https://github.com/tombroomfield
|
96
|
+
post_install_message:
|
97
|
+
rdoc_options: []
|
98
|
+
require_paths:
|
99
|
+
- lib
|
100
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
101
|
+
requirements:
|
102
|
+
- - ">="
|
103
|
+
- !ruby/object:Gem::Version
|
104
|
+
version: 2.6.0
|
105
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
106
|
+
requirements:
|
107
|
+
- - ">="
|
108
|
+
- !ruby/object:Gem::Version
|
109
|
+
version: '0'
|
110
|
+
requirements: []
|
111
|
+
rubygems_version: 3.4.10
|
112
|
+
signing_key:
|
113
|
+
specification_version: 4
|
114
|
+
summary: A small ActiveRecord & Postgres extension for managing sequential scans
|
115
|
+
test_files: []
|