active_record_scanner 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +14 -0
- data/.rspec +2 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +35 -0
- data/LICENSE.txt +21 -0
- data/README.md +38 -0
- data/Rakefile +6 -0
- data/active_record_scanner.gemspec +26 -0
- data/bin/active_record_scanner +22 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/lib/active_record_scanner/compactor.rb +33 -0
- data/lib/active_record_scanner/constants.rb +41 -0
- data/lib/active_record_scanner/parser.rb +65 -0
- data/lib/active_record_scanner/version.rb +3 -0
- data/lib/active_record_scanner.rb +50 -0
- metadata +105 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: c72aa49167eea88d5833e44a2b3b4a7ccf73235f
|
4
|
+
data.tar.gz: 0369e6f6fbb7512c42df9b0f58b239025f77e503
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: cbb013cf5922c304fe3a397fcfdf81dbdb3822f5c4e639c975df88ff6e05acacc41fd76bdfc65278983d1a1995ead0670cfd039b2c5044d9b03337536a55bd13
|
7
|
+
data.tar.gz: ce78065b3b0739fa714301840694d18b2dc0cebba739487011e2a2f4daff439411b8748fccd3a71c0abee91d8c0f8e8d2b6d92db357bc394d723f1d39dbfbd1d
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
active_record_scanner (0.1.0)
|
5
|
+
|
6
|
+
GEM
|
7
|
+
remote: https://rubygems.org/
|
8
|
+
specs:
|
9
|
+
diff-lcs (1.3)
|
10
|
+
rake (10.5.0)
|
11
|
+
rspec (3.8.0)
|
12
|
+
rspec-core (~> 3.8.0)
|
13
|
+
rspec-expectations (~> 3.8.0)
|
14
|
+
rspec-mocks (~> 3.8.0)
|
15
|
+
rspec-core (3.8.0)
|
16
|
+
rspec-support (~> 3.8.0)
|
17
|
+
rspec-expectations (3.8.1)
|
18
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
19
|
+
rspec-support (~> 3.8.0)
|
20
|
+
rspec-mocks (3.8.0)
|
21
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
22
|
+
rspec-support (~> 3.8.0)
|
23
|
+
rspec-support (3.8.0)
|
24
|
+
|
25
|
+
PLATFORMS
|
26
|
+
ruby
|
27
|
+
|
28
|
+
DEPENDENCIES
|
29
|
+
active_record_scanner!
|
30
|
+
bundler (~> 1.16)
|
31
|
+
rake (~> 10.0)
|
32
|
+
rspec (~> 3.0)
|
33
|
+
|
34
|
+
BUNDLED WITH
|
35
|
+
1.16.1
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2018 Sean Goedecke
|
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
|
13
|
+
all 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
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,38 @@
|
|
1
|
+
# Active Record Scanner
|
2
|
+
|
3
|
+
A static analysis tool that detects known ORM performance issues with ActiveRecord (e.g. queries inside inner loops).
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
$ gem install active_record_scanner
|
8
|
+
|
9
|
+
## Usage
|
10
|
+
|
11
|
+
`active_record_scanner "/full/path/to/file/**/*.rb` will recursively scan a directory for ActiveRecord queries inside loops. Consider replacing with methods that operate on collections: for instance, replace `find_by` inside a loop with a `where` call.
|
12
|
+
|
13
|
+
Example:
|
14
|
+
```
|
15
|
+
sgoedecke:parser/ (master*) $ active_record_scanner ./spec/fixtures/test_class.rb
|
16
|
+
.
|
17
|
+
./spec/fixtures/test_class.rb (line 24 column 4) -- called query method '#destroy' in a loop
|
18
|
+
```
|
19
|
+
|
20
|
+
## Development
|
21
|
+
|
22
|
+
Run the tests with `rake`.
|
23
|
+
|
24
|
+
## References
|
25
|
+
|
26
|
+
This tool was inspired by this paper by Junwen Yang: https://newtraell.cs.uchicago.edu/files/ms_paper/junwen.pdf
|
27
|
+
|
28
|
+
Unlike the static analysis tools described in the paper, this is (a) written in Ruby and (b) not reliant on a series of regexes.
|
29
|
+
|
30
|
+
## Todo
|
31
|
+
|
32
|
+
* Improve sexp tree traversal (e.g. avoid mutating the tree in `normalize!`)
|
33
|
+
* Improve nested array compacting. Remove that awful loop
|
34
|
+
* Add checks for inefficiences other than queries inside loops
|
35
|
+
* Add a homebrew formula for easier use
|
36
|
+
|
37
|
+
Contributions are welcome.
|
38
|
+
|
data/Rakefile
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
|
2
|
+
lib = File.expand_path("../lib", __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require "active_record_scanner/version"
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "active_record_scanner"
|
8
|
+
spec.version = ActiveRecordScanner::VERSION
|
9
|
+
spec.authors = ["Sean Goedecke"]
|
10
|
+
spec.email = ["sean.goedecke@gmail.com"]
|
11
|
+
|
12
|
+
spec.summary = %q{Scan your Rails project for inefficient AR queries}
|
13
|
+
spec.homepage = "https://github.com/sgoedecke/active_record_scanner"
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0").reject do |f|
|
17
|
+
f.match(%r{^(test|spec|features)/})
|
18
|
+
end
|
19
|
+
spec.bindir = "bin"
|
20
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
21
|
+
spec.require_paths = ["lib"]
|
22
|
+
|
23
|
+
spec.add_development_dependency "bundler", "~> 1.16"
|
24
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
25
|
+
spec.add_development_dependency "rspec", "~> 3.0"
|
26
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'active_record_scanner'
|
4
|
+
require 'optionparser'
|
5
|
+
|
6
|
+
options = {}
|
7
|
+
|
8
|
+
option_parser = OptionParser.new do |opts|
|
9
|
+
opts.banner = "Usage: ruby scanner.rb [options] [full-path]"
|
10
|
+
opts.on("-s", "--silent", "Suppress dot reporting in output") do |_s|
|
11
|
+
options[:silent] = true
|
12
|
+
end
|
13
|
+
end
|
14
|
+
option_parser.parse!
|
15
|
+
|
16
|
+
if ARGV[0]
|
17
|
+
results = ActiveRecordScanner::Scanner.new(ARGV[0], options).scan
|
18
|
+
puts "\r\n" unless options[:silent]
|
19
|
+
puts results.join("\n")
|
20
|
+
else
|
21
|
+
puts option_parser.help
|
22
|
+
end
|
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "active_record_scanner"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
class Compactor
|
2
|
+
# This is really bad. It works for now but I'd really like to figure out a better way.
|
3
|
+
def deep_compact(n)
|
4
|
+
10.times do # won't work if we get a tree with >10 depth
|
5
|
+
n = deep_compact_with_nesting(strip_nesting(n))
|
6
|
+
end
|
7
|
+
n
|
8
|
+
end
|
9
|
+
|
10
|
+
private
|
11
|
+
|
12
|
+
def deep_compact_with_nesting(n)
|
13
|
+
if n.respond_to? :compact
|
14
|
+
n.compact.map{ |c| deep_compact_with_nesting(c) }
|
15
|
+
else
|
16
|
+
n
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def strip_nesting(n)
|
21
|
+
if n.is_a?(Array)
|
22
|
+
if n.length == 0
|
23
|
+
return nil
|
24
|
+
elsif n.length == 1
|
25
|
+
return strip_nesting(n.first)
|
26
|
+
else
|
27
|
+
return n.map{ |n| strip_nesting(n) }
|
28
|
+
end
|
29
|
+
else
|
30
|
+
return n
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
AR_METHODS = %w(
|
2
|
+
find
|
3
|
+
find_by
|
4
|
+
find_by!
|
5
|
+
exists?
|
6
|
+
destroy
|
7
|
+
delete
|
8
|
+
create_with
|
9
|
+
distinct
|
10
|
+
eager_load
|
11
|
+
extending
|
12
|
+
from
|
13
|
+
group
|
14
|
+
having
|
15
|
+
includes
|
16
|
+
joins
|
17
|
+
left_outer_joins
|
18
|
+
limit
|
19
|
+
lock
|
20
|
+
none
|
21
|
+
offset
|
22
|
+
order
|
23
|
+
preload
|
24
|
+
readonly
|
25
|
+
references
|
26
|
+
reorder
|
27
|
+
reverse_order
|
28
|
+
select
|
29
|
+
where
|
30
|
+
)
|
31
|
+
|
32
|
+
LOOP_METHODS = %w(
|
33
|
+
map
|
34
|
+
each
|
35
|
+
times
|
36
|
+
for_each
|
37
|
+
flat_map
|
38
|
+
each_with_index
|
39
|
+
reduce
|
40
|
+
)
|
41
|
+
|
@@ -0,0 +1,65 @@
|
|
1
|
+
require 'ripper'
|
2
|
+
require 'active_record_scanner/compactor'
|
3
|
+
|
4
|
+
class Parser
|
5
|
+
def initialize(raw)
|
6
|
+
@raw = raw
|
7
|
+
@compactor = Compactor.new
|
8
|
+
end
|
9
|
+
|
10
|
+
def parse
|
11
|
+
raw_tree = Ripper.sexp(@raw)
|
12
|
+
return [] unless raw_tree # if the file isn't valid ruby, ignore it
|
13
|
+
filtered_tree = filter(raw_tree)
|
14
|
+
compact_tree = @compactor.deep_compact(filtered_tree)
|
15
|
+
return [] unless compact_tree # if the file has no queries or loops, ignore it
|
16
|
+
normalise!(compact_tree)
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def is_block?(node)
|
22
|
+
[:brace_block, :do_block].include?(node[0])
|
23
|
+
end
|
24
|
+
|
25
|
+
def is_query?(node)
|
26
|
+
node[0] == :@ident && AR_METHODS.include?(node[1])
|
27
|
+
end
|
28
|
+
|
29
|
+
def is_loop?(node)
|
30
|
+
node[0] == :@ident && LOOP_METHODS.include?(node[1])
|
31
|
+
end
|
32
|
+
|
33
|
+
# walk the tree and return a new tree with all nodes that aren't
|
34
|
+
# loops, blocks or queries turned to `nil`
|
35
|
+
def filter(new_tree = [], tree)
|
36
|
+
tree.map.with_index do |node, i|
|
37
|
+
if node.is_a?(Array) # we only care about non-leaf nodes
|
38
|
+
if is_loop?(node)
|
39
|
+
[ :loop, node, nil ]
|
40
|
+
elsif is_block?(node)
|
41
|
+
[ :block, node, filter(node)]
|
42
|
+
elsif is_query?(node)
|
43
|
+
# if the current node is a query, return it
|
44
|
+
[:query, node]
|
45
|
+
elsif node
|
46
|
+
filter(node)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
# walk the tree looking for blocks that immediately follow calls to
|
53
|
+
# loop methods, and tag these blocks as blocks that are run inside loops
|
54
|
+
def normalise!(tree)
|
55
|
+
tree.each.with_index do |node, i|
|
56
|
+
if node.is_a?(Array)
|
57
|
+
if node.first == :loop
|
58
|
+
tree[i+1][0] = :lblock if tree[i+1]
|
59
|
+
end
|
60
|
+
tree[i] = normalise!(node)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
tree
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
require "active_record_scanner/version"
|
2
|
+
require "active_record_scanner/constants"
|
3
|
+
require "active_record_scanner/parser"
|
4
|
+
|
5
|
+
module ActiveRecordScanner
|
6
|
+
class Scanner
|
7
|
+
def initialize(glob, options={})
|
8
|
+
@glob = glob
|
9
|
+
@options = options
|
10
|
+
@results = []
|
11
|
+
end
|
12
|
+
|
13
|
+
def scan
|
14
|
+
Dir.glob(@glob).each do |file|
|
15
|
+
scan_file(file)
|
16
|
+
end
|
17
|
+
@results
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def scan_file(file)
|
23
|
+
print "." unless @options[:silent]
|
24
|
+
@file = file
|
25
|
+
raw = IO.binread(file)
|
26
|
+
compact_tree = Parser.new(raw).parse
|
27
|
+
scan_for_errors(compact_tree)
|
28
|
+
end
|
29
|
+
|
30
|
+
def report_error(error)
|
31
|
+
node = error.last
|
32
|
+
@results << "#{@file} (line #{node[2][1]} column #{node[2][0]}) -- called query method '##{node[1]}' in a loop"
|
33
|
+
end
|
34
|
+
|
35
|
+
def scan_for_errors(node, inside_loop = false)
|
36
|
+
return unless node.is_a?(Array) # don't traverse into raw values
|
37
|
+
|
38
|
+
# if there's a loop higher up in the tree, set the flag
|
39
|
+
# TODO: turn this into a counter to flag queries inside inner loops as more serious
|
40
|
+
inside_loop = true if node[0] == :lblock
|
41
|
+
|
42
|
+
report_error(node) if node[0] == :query && inside_loop
|
43
|
+
|
44
|
+
node.each do |child|
|
45
|
+
scan_for_errors(child, inside_loop)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|
metadata
ADDED
@@ -0,0 +1,105 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: active_record_scanner
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Sean Goedecke
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2018-10-07 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: bundler
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.16'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.16'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rake
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '10.0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '10.0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rspec
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '3.0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '3.0'
|
55
|
+
description:
|
56
|
+
email:
|
57
|
+
- sean.goedecke@gmail.com
|
58
|
+
executables:
|
59
|
+
- active_record_scanner
|
60
|
+
- console
|
61
|
+
- setup
|
62
|
+
extensions: []
|
63
|
+
extra_rdoc_files: []
|
64
|
+
files:
|
65
|
+
- ".gitignore"
|
66
|
+
- ".rspec"
|
67
|
+
- Gemfile
|
68
|
+
- Gemfile.lock
|
69
|
+
- LICENSE.txt
|
70
|
+
- README.md
|
71
|
+
- Rakefile
|
72
|
+
- active_record_scanner.gemspec
|
73
|
+
- bin/active_record_scanner
|
74
|
+
- bin/console
|
75
|
+
- bin/setup
|
76
|
+
- lib/active_record_scanner.rb
|
77
|
+
- lib/active_record_scanner/compactor.rb
|
78
|
+
- lib/active_record_scanner/constants.rb
|
79
|
+
- lib/active_record_scanner/parser.rb
|
80
|
+
- lib/active_record_scanner/version.rb
|
81
|
+
homepage: https://github.com/sgoedecke/active_record_scanner
|
82
|
+
licenses:
|
83
|
+
- MIT
|
84
|
+
metadata: {}
|
85
|
+
post_install_message:
|
86
|
+
rdoc_options: []
|
87
|
+
require_paths:
|
88
|
+
- lib
|
89
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
90
|
+
requirements:
|
91
|
+
- - ">="
|
92
|
+
- !ruby/object:Gem::Version
|
93
|
+
version: '0'
|
94
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
95
|
+
requirements:
|
96
|
+
- - ">="
|
97
|
+
- !ruby/object:Gem::Version
|
98
|
+
version: '0'
|
99
|
+
requirements: []
|
100
|
+
rubyforge_project:
|
101
|
+
rubygems_version: 2.4.5.4
|
102
|
+
signing_key:
|
103
|
+
specification_version: 4
|
104
|
+
summary: Scan your Rails project for inefficient AR queries
|
105
|
+
test_files: []
|