seq_scanner 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -0,0 +1,4 @@
1
+ --require 'spec_helper'
2
+ --format documentation
3
+ --color
4
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,13 @@
1
+ AllCops:
2
+ TargetRubyVersion: 2.6
3
+
4
+ Style/StringLiterals:
5
+ Enabled: true
6
+ EnforcedStyle: double_quotes
7
+
8
+ Style/StringLiteralsInInterpolation:
9
+ Enabled: true
10
+ EnforcedStyle: double_quotes
11
+
12
+ Layout/LineLength:
13
+ Max: 120
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,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -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
@@ -0,0 +1,13 @@
1
+ module SeqScanner
2
+ module Switcher
3
+ class << self
4
+ def on
5
+ ActiveRecord::Base.connection.execute("SET LOCAL enable_seqscan = on")
6
+ end
7
+
8
+ def off
9
+ ActiveRecord::Base.connection.execute("SET LOCAL enable_seqscan = off")
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SeqScanner
4
+ VERSION = "0.1.0"
5
+ end
@@ -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
@@ -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
@@ -0,0 +1,4 @@
1
+ module SeqScanner
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
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: []