idxfence 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/DETAILS.md +293 -0
- data/Gemfile +3 -0
- data/LICENSE +21 -0
- data/README.md +95 -0
- data/docs/USAGE.md +107 -0
- data/examples/app/models/membership.rb +8 -0
- data/examples/app/models/user.rb +11 -0
- data/examples/db/schema.rb +26 -0
- data/exe/idxfence +7 -0
- data/idxfence.gemspec +39 -0
- data/lib/idxfence/checker.rb +81 -0
- data/lib/idxfence/cli.rb +64 -0
- data/lib/idxfence/finding.rb +26 -0
- data/lib/idxfence/inflector.rb +69 -0
- data/lib/idxfence/model_parser.rb +219 -0
- data/lib/idxfence/render.rb +28 -0
- data/lib/idxfence/schema_parser.rb +127 -0
- data/lib/idxfence/version.rb +3 -0
- data/lib/idxfence.rb +40 -0
- metadata +91 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
require_relative "model_parser"
|
|
2
|
+
require_relative "schema_parser"
|
|
3
|
+
require_relative "inflector"
|
|
4
|
+
require_relative "finding"
|
|
5
|
+
|
|
6
|
+
module Idxfence
|
|
7
|
+
# Joins the two passes: every uniqueness validation ModelParser found
|
|
8
|
+
# in app/models/*.rb, resolved to a table name (via
|
|
9
|
+
# Inflector.table_name_for, unless the model set `self.table_name`
|
|
10
|
+
# explicitly), against every unique index SchemaParser found in
|
|
11
|
+
# db/schema.rb for that same table. A validation with no matching
|
|
12
|
+
# unique index -- one covering exactly its column set (the validated
|
|
13
|
+
# column plus any `scope:` column(s), unordered) -- is a real gap: the
|
|
14
|
+
# AR validation can be raced, and only a DB-level unique index closes
|
|
15
|
+
# it. See DETAILS.md for the full algorithm and its documented
|
|
16
|
+
# limitations (case-insensitive uniqueness, irregular pluralization,
|
|
17
|
+
# etc).
|
|
18
|
+
class Checker
|
|
19
|
+
def initialize(model_paths:, schema_path:)
|
|
20
|
+
@model_paths = model_paths
|
|
21
|
+
@schema_path = schema_path
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [Array<Finding>, String] findings, and a warning string
|
|
25
|
+
# (nil if none) when db/schema.rb was missing/unparseable -- in
|
|
26
|
+
# that case findings is always [] since nothing can be
|
|
27
|
+
# cross-referenced without it.
|
|
28
|
+
def call
|
|
29
|
+
begin
|
|
30
|
+
schema_tables = SchemaParser.parse(schema_path)
|
|
31
|
+
rescue SchemaParser::MissingSchemaError => e
|
|
32
|
+
return [[], "idxfence: warning: #{e.message} -- skipping uniqueness cross-check for #{model_paths.size} model file(s)"]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
findings = model_paths.flat_map do |model_path|
|
|
36
|
+
ModelParser.new(path: model_path).call.flat_map do |parsed_model|
|
|
37
|
+
check_model(parsed_model, schema_tables)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
[findings, nil]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
attr_reader :model_paths, :schema_path
|
|
47
|
+
|
|
48
|
+
def check_model(parsed_model, schema_tables)
|
|
49
|
+
return [] if parsed_model.validations.empty?
|
|
50
|
+
|
|
51
|
+
table = parsed_model.table_name_override || Inflector.table_name_for(parsed_model.class_name)
|
|
52
|
+
unique_indexes = schema_tables[table] || []
|
|
53
|
+
|
|
54
|
+
parsed_model.validations.filter_map do |validation|
|
|
55
|
+
required = ([validation.column] + validation.scope).sort
|
|
56
|
+
next if unique_indexes.any? { |index_cols| index_cols.sort == required }
|
|
57
|
+
|
|
58
|
+
Finding.new(
|
|
59
|
+
code: "IX001",
|
|
60
|
+
model: parsed_model.class_name,
|
|
61
|
+
table: table,
|
|
62
|
+
column: validation.column,
|
|
63
|
+
scope: validation.scope,
|
|
64
|
+
file: parsed_model.file,
|
|
65
|
+
line: validation.line,
|
|
66
|
+
message: build_message(parsed_model.class_name, table, validation)
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def build_message(class_name, table, validation)
|
|
72
|
+
scope_desc = validation.scope.empty? ? "" : " scoped to #{validation.scope.join(', ')}"
|
|
73
|
+
columns_desc = ([validation.column] + validation.scope).join(", ")
|
|
74
|
+
"#{class_name} validates uniqueness of :#{validation.column}#{scope_desc} at the application layer only -- " \
|
|
75
|
+
"db/schema.rb has no unique index on #{table}(#{columns_desc}). Two concurrent requests can both pass " \
|
|
76
|
+
"the validation's SELECT check before either INSERT commits, producing a genuine duplicate row. Add a " \
|
|
77
|
+
"matching `add_index :#{table}, [#{[validation.column, *validation.scope].map { |c| ":#{c}" }.join(', ')}], unique: true` " \
|
|
78
|
+
"migration. See DETAILS.md."
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
data/lib/idxfence/cli.rb
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
require "optparse"
|
|
2
|
+
|
|
3
|
+
module Idxfence
|
|
4
|
+
module CLI
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def run(argv, out: $stdout, err: $stderr)
|
|
8
|
+
command, *rest = argv
|
|
9
|
+
|
|
10
|
+
case command
|
|
11
|
+
when "check"
|
|
12
|
+
check(rest, out: out, err: err)
|
|
13
|
+
else
|
|
14
|
+
err.puts usage
|
|
15
|
+
2
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def usage
|
|
20
|
+
"usage: idxfence check <rails-project-dir> [<rails-project-dir> ...] [--json]\n" \
|
|
21
|
+
" (each <rails-project-dir> must contain app/models/ and db/schema.rb)"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def check(argv, out:, err:)
|
|
25
|
+
options = { json: false }
|
|
26
|
+
|
|
27
|
+
parser = OptionParser.new do |opts|
|
|
28
|
+
opts.banner = usage
|
|
29
|
+
opts.on("--json", "emit JSON instead of text") { options[:json] = true }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
begin
|
|
33
|
+
parser.parse!(argv)
|
|
34
|
+
rescue OptionParser::ParseError => e
|
|
35
|
+
err.puts "idxfence: #{e.message}"
|
|
36
|
+
return 2
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if argv.empty?
|
|
40
|
+
err.puts usage
|
|
41
|
+
return 2
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
all_findings = []
|
|
45
|
+
warnings = []
|
|
46
|
+
|
|
47
|
+
argv.each do |project_dir|
|
|
48
|
+
findings, warning = Idxfence.check(project_dir: project_dir)
|
|
49
|
+
all_findings.concat(findings)
|
|
50
|
+
warnings << warning if warning
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
warning_text = warnings.empty? ? nil : warnings.join("\n")
|
|
54
|
+
|
|
55
|
+
if options[:json]
|
|
56
|
+
out.puts Render.json(all_findings, warning: warning_text)
|
|
57
|
+
else
|
|
58
|
+
out.puts Render.text(all_findings, warning: warning_text)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
all_findings.empty? ? 0 : 1
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Idxfence
|
|
2
|
+
# A single ActiveRecord `uniqueness: true` validation (application-layer
|
|
3
|
+
# only) with no matching database-level unique index to back it up --
|
|
4
|
+
# meaning two concurrent requests can both pass the validation's
|
|
5
|
+
# `SELECT ... WHERE` check before either one's `INSERT` commits, landing
|
|
6
|
+
# a genuine duplicate row despite the validation "working." See
|
|
7
|
+
# DETAILS.md for the exact race and why only a DB-level unique index
|
|
8
|
+
# closes it.
|
|
9
|
+
#
|
|
10
|
+
# code: "IX001" every finding uses this single code (one rule: an AR
|
|
11
|
+
# uniqueness validation with no matching DB unique index)
|
|
12
|
+
# model: the model class name, e.g. "User"
|
|
13
|
+
# table: the resolved table name the validation's column(s) were
|
|
14
|
+
# checked against, e.g. "users"
|
|
15
|
+
# column: the column the validation checks, e.g. "email"
|
|
16
|
+
# scope: Array<String> of scope column(s), e.g. ["account_id"]; empty
|
|
17
|
+
# array for an unscoped validation
|
|
18
|
+
# file: path to the model source file
|
|
19
|
+
# line: line number of the `validates`/`validates_uniqueness_of` call
|
|
20
|
+
# message: a human-readable description
|
|
21
|
+
Finding = Struct.new(:code, :model, :table, :column, :scope, :file, :line, :message, keyword_init: true) do
|
|
22
|
+
def to_h
|
|
23
|
+
{ code: code, model: model, table: table, column: column, scope: scope, file: file, line: line, message: message }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
module Idxfence
|
|
2
|
+
# A small, deliberately conservative stand-in for ActiveSupport::Inflector
|
|
3
|
+
# -- just enough of Rails' own table-name-inference convention
|
|
4
|
+
# (`Model.name.demodulize.underscore.pluralize`) to resolve a model's
|
|
5
|
+
# default table name without requiring an actual Rails/ActiveSupport
|
|
6
|
+
# installation. See DETAILS.md's "table-name inference" section for
|
|
7
|
+
# exactly which pluralization rules are (and are not) implemented, and
|
|
8
|
+
# why `self.table_name = "..."` is honored as an explicit override
|
|
9
|
+
# rather than something idxfence tries to out-guess.
|
|
10
|
+
module Inflector
|
|
11
|
+
UNCOUNTABLE = %w[series species equipment information news].freeze
|
|
12
|
+
|
|
13
|
+
IRREGULAR = {
|
|
14
|
+
"person" => "people",
|
|
15
|
+
"man" => "men",
|
|
16
|
+
"woman" => "women",
|
|
17
|
+
"child" => "children",
|
|
18
|
+
"tooth" => "teeth",
|
|
19
|
+
"foot" => "feet",
|
|
20
|
+
"mouse" => "mice",
|
|
21
|
+
"goose" => "geese"
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# "Admin::UserAccount" -> "UserAccount" (only the last namespace
|
|
27
|
+
# segment -- matches Rails' default `table_name_prefix`-less
|
|
28
|
+
# behavior; a project that sets a custom `table_name_prefix` isn't
|
|
29
|
+
# modeled here, see DETAILS.md).
|
|
30
|
+
def demodulize(class_name)
|
|
31
|
+
class_name.to_s.split("::").last.to_s
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# "UserAccount" -> "user_account"
|
|
35
|
+
def underscore(camel)
|
|
36
|
+
camel.to_s
|
|
37
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
38
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
39
|
+
.downcase
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# "user_account" -> "user_accounts" (a deliberately small rule set,
|
|
43
|
+
# not a full English pluralizer -- see DETAILS.md limitations).
|
|
44
|
+
def pluralize(word)
|
|
45
|
+
return word if UNCOUNTABLE.include?(word)
|
|
46
|
+
return IRREGULAR[word] if IRREGULAR.key?(word)
|
|
47
|
+
|
|
48
|
+
case word
|
|
49
|
+
when /(ch|sh|ss|x|z)\z/
|
|
50
|
+
"#{word}es"
|
|
51
|
+
when /[^aeiou]y\z/
|
|
52
|
+
"#{word[0..-2]}ies"
|
|
53
|
+
when /(fe)\z/
|
|
54
|
+
"#{word[0..-3]}ves"
|
|
55
|
+
when /[^f]f\z/
|
|
56
|
+
"#{word[0..-2]}ves"
|
|
57
|
+
when /s\z/
|
|
58
|
+
word
|
|
59
|
+
else
|
|
60
|
+
"#{word}s"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Rails' own default: `Model.name.demodulize.underscore.pluralize`.
|
|
65
|
+
def table_name_for(class_name)
|
|
66
|
+
pluralize(underscore(demodulize(class_name)))
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
require "ripper"
|
|
2
|
+
|
|
3
|
+
module Idxfence
|
|
4
|
+
# Parses one Ruby source file with Ripper (structural boundaries --
|
|
5
|
+
# class start/end -- come from the s-expression, never from `do`/`end`
|
|
6
|
+
# counting; a narrower regex pass then runs only over the *text* of a
|
|
7
|
+
# class body already located structurally, the same hybrid technique
|
|
8
|
+
# this workspace's mailstall/jobclash/lockstall packages use) looking
|
|
9
|
+
# for a Rails model class (superclass `ApplicationRecord` or
|
|
10
|
+
# `ActiveRecord::Base`) and, within its own body:
|
|
11
|
+
#
|
|
12
|
+
# - an explicit `self.table_name = "..."` override, if present
|
|
13
|
+
# - every `validates :col[, :col2, ...], uniqueness: true` (or
|
|
14
|
+
# `uniqueness: { scope: ..., ... }`) and
|
|
15
|
+
# `validates_uniqueness_of :col[, :col2, ...][, scope: ...]`
|
|
16
|
+
# declaration, one UniquenessValidation per column named.
|
|
17
|
+
#
|
|
18
|
+
# This is deliberately one file's worth of information -- resolving it
|
|
19
|
+
# against a database-level unique index requires a second pass over
|
|
20
|
+
# db/schema.rb, done separately by Idxfence::SchemaParser and joined by
|
|
21
|
+
# Idxfence::Checker. See DETAILS.md's "two passes" section.
|
|
22
|
+
class ModelParser
|
|
23
|
+
MODEL_SUPERCLASS = /\A(?:ApplicationRecord|ActiveRecord::Base)\z/.freeze
|
|
24
|
+
|
|
25
|
+
UniquenessValidation = Struct.new(:column, :scope, :line, keyword_init: true)
|
|
26
|
+
ParsedModel = Struct.new(:class_name, :table_name_override, :validations, :file, keyword_init: true)
|
|
27
|
+
|
|
28
|
+
# One `validates`/`validates_uniqueness_of` statement's leading list
|
|
29
|
+
# of bare `:symbol` column arguments, e.g. the `:email, :username` in
|
|
30
|
+
# `validates :email, :username, uniqueness: true`.
|
|
31
|
+
LEADING_COLUMNS = /\A\s*(?:validates(?:_uniqueness_of)?)\s+((?::\w+\s*,\s*)*:\w+)/.freeze
|
|
32
|
+
|
|
33
|
+
SCOPE_SYMBOL = /scope:\s*:(\w+)/.freeze
|
|
34
|
+
SCOPE_ARRAY = /scope:\s*\[([^\]]*)\]/.freeze
|
|
35
|
+
SCOPE_PCT_I = /scope:\s*%i\[([^\]]*)\]/.freeze
|
|
36
|
+
|
|
37
|
+
SELF_TABLE_NAME = /self\.table_name\s*=\s*["']([^"']+)["']/.freeze
|
|
38
|
+
|
|
39
|
+
def initialize(path:)
|
|
40
|
+
@path = path
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def call
|
|
44
|
+
source = File.read(path)
|
|
45
|
+
sexp = Ripper.sexp(source)
|
|
46
|
+
return [] unless sexp
|
|
47
|
+
|
|
48
|
+
lines = source.lines
|
|
49
|
+
models = []
|
|
50
|
+
|
|
51
|
+
each_class_node(sexp) do |class_name, class_node|
|
|
52
|
+
next unless model_class?(class_node)
|
|
53
|
+
|
|
54
|
+
start_line = min_line(class_node)
|
|
55
|
+
end_line = max_line(class_node)
|
|
56
|
+
next unless start_line && end_line
|
|
57
|
+
|
|
58
|
+
body_source = lines[(start_line - 1)...end_line].join
|
|
59
|
+
|
|
60
|
+
models << ParsedModel.new(
|
|
61
|
+
class_name: class_name,
|
|
62
|
+
table_name_override: (m = body_source.match(SELF_TABLE_NAME)) && m[1],
|
|
63
|
+
validations: uniqueness_validations(body_source, lines, start_line),
|
|
64
|
+
file: path
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
models
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
attr_reader :path
|
|
74
|
+
|
|
75
|
+
# Walks the class body's own source text statement-by-statement,
|
|
76
|
+
# buffering lines from a `validates`/`validates_uniqueness_of` call
|
|
77
|
+
# until its parens/braces balance (so an options hash wrapped across
|
|
78
|
+
# multiple lines, e.g. `uniqueness: { scope: ..., \n case_sensitive:
|
|
79
|
+
# false }`, is still read as one statement), then extracts every
|
|
80
|
+
# uniqueness-checked column out of that buffered statement.
|
|
81
|
+
def uniqueness_validations(body_source, all_lines, class_start_line)
|
|
82
|
+
validations = []
|
|
83
|
+
buffer = nil
|
|
84
|
+
buffer_start_line = nil
|
|
85
|
+
depth = 0
|
|
86
|
+
body_line_count = 0
|
|
87
|
+
|
|
88
|
+
body_source.each_line.with_index do |line, idx|
|
|
89
|
+
body_line_count = idx + 1
|
|
90
|
+
absolute_line = class_start_line + idx
|
|
91
|
+
|
|
92
|
+
if buffer.nil?
|
|
93
|
+
next unless line =~ /\bvalidates(?:_uniqueness_of)?\b/
|
|
94
|
+
|
|
95
|
+
buffer = +""
|
|
96
|
+
buffer_start_line = absolute_line
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
buffer << line
|
|
100
|
+
depth += line.count("{") + line.count("(") + line.count("[")
|
|
101
|
+
depth -= line.count("}") + line.count(")") + line.count("]")
|
|
102
|
+
|
|
103
|
+
next if depth > 0
|
|
104
|
+
|
|
105
|
+
validations.concat(parse_statement(buffer, buffer_start_line))
|
|
106
|
+
buffer = nil
|
|
107
|
+
buffer_start_line = nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Ripper.sexp's leaf position tracking only covers tokens that
|
|
111
|
+
# carry their own content (identifiers, literals, keywords) --
|
|
112
|
+
# a class body whose captured span ends on a line containing only
|
|
113
|
+
# closing punctuation (`}`, `end`) is not reflected in that span,
|
|
114
|
+
# so a statement buffer still open here (an options hash whose
|
|
115
|
+
# closing `}` fell on such a line) is finished by reading straight
|
|
116
|
+
# past the class node's own detected span, from the underlying
|
|
117
|
+
# file, until it balances or the file runs out.
|
|
118
|
+
if buffer
|
|
119
|
+
(class_start_line - 1 + body_line_count...all_lines.length).each do |i|
|
|
120
|
+
line = all_lines[i]
|
|
121
|
+
break unless line
|
|
122
|
+
|
|
123
|
+
buffer << line
|
|
124
|
+
depth += line.count("{") + line.count("(") + line.count("[")
|
|
125
|
+
depth -= line.count("}") + line.count(")") + line.count("]")
|
|
126
|
+
break if depth <= 0
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
validations.concat(parse_statement(buffer, buffer_start_line))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
validations
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def parse_statement(statement, line)
|
|
136
|
+
return [] unless statement =~ /\buniqueness:/ || statement =~ /\bvalidates_uniqueness_of\b/
|
|
137
|
+
|
|
138
|
+
columns_match = statement.match(LEADING_COLUMNS)
|
|
139
|
+
return [] unless columns_match
|
|
140
|
+
|
|
141
|
+
columns = columns_match[1].scan(/:(\w+)/).flatten
|
|
142
|
+
scope = extract_scope(statement)
|
|
143
|
+
|
|
144
|
+
columns.map { |col| UniquenessValidation.new(column: col, scope: scope, line: line) }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def extract_scope(statement)
|
|
148
|
+
if (m = statement.match(SCOPE_PCT_I))
|
|
149
|
+
return m[1].split(/\s+/).reject(&:empty?)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
if (m = statement.match(SCOPE_ARRAY))
|
|
153
|
+
return m[1].scan(/:(\w+)/).flatten
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
if (m = statement.match(SCOPE_SYMBOL))
|
|
157
|
+
return [m[1]]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
[]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def model_class?(class_node)
|
|
164
|
+
superclass_node = class_node[2]
|
|
165
|
+
return false unless superclass_node
|
|
166
|
+
|
|
167
|
+
name = const_name(superclass_node)
|
|
168
|
+
!!(name && name.match?(MODEL_SUPERCLASS))
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def each_class_node(node, &block)
|
|
172
|
+
return unless node.is_a?(Array)
|
|
173
|
+
|
|
174
|
+
if node[0] == :class
|
|
175
|
+
name = const_name(node[1])
|
|
176
|
+
block.call(name, node)
|
|
177
|
+
return
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
node.each { |child| each_class_node(child, &block) }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def const_name(node)
|
|
184
|
+
parts = []
|
|
185
|
+
collect_const_leaves(node, parts)
|
|
186
|
+
parts.empty? ? nil : parts.join("::")
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def collect_const_leaves(node, acc)
|
|
190
|
+
return unless node.is_a?(Array)
|
|
191
|
+
|
|
192
|
+
acc << node[1] if node[0] == :@const && node[1].is_a?(String)
|
|
193
|
+
node.each { |child| collect_const_leaves(child, acc) }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def min_line(node)
|
|
197
|
+
lines = []
|
|
198
|
+
collect_lines(node, lines)
|
|
199
|
+
lines.min
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def max_line(node)
|
|
203
|
+
lines = []
|
|
204
|
+
collect_lines(node, lines)
|
|
205
|
+
lines.max
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def collect_lines(node, acc)
|
|
209
|
+
return unless node.is_a?(Array)
|
|
210
|
+
|
|
211
|
+
if node.length == 2 && node.all? { |x| x.is_a?(Integer) }
|
|
212
|
+
acc << node[0]
|
|
213
|
+
return
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
node.each { |child| collect_lines(child, acc) }
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Idxfence
|
|
4
|
+
module Render
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def text(findings, warning: nil)
|
|
8
|
+
lines = []
|
|
9
|
+
lines << "idxfence: #{warning}" if warning
|
|
10
|
+
|
|
11
|
+
if findings.empty?
|
|
12
|
+
lines << "idxfence: no unguarded uniqueness validations found" unless warning
|
|
13
|
+
return lines.join("\n")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
lines << "idxfence: #{findings.size} finding(s)"
|
|
17
|
+
findings.each do |f|
|
|
18
|
+
scope_desc = f.scope.empty? ? "" : " (scope: #{f.scope.join(', ')})"
|
|
19
|
+
lines << "[#{f.code}] #{f.file}:#{f.line} #{f.model}##{f.column}#{scope_desc} -> #{f.table} has no matching unique index: #{f.message}"
|
|
20
|
+
end
|
|
21
|
+
lines.join("\n")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def json(findings, warning: nil)
|
|
25
|
+
JSON.generate(findings: findings.map(&:to_h), warning: warning)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
require "ripper"
|
|
2
|
+
|
|
3
|
+
module Idxfence
|
|
4
|
+
# Parses `db/schema.rb` (Rails' generated, declarative snapshot of the
|
|
5
|
+
# database schema) with Ripper looking for each `create_table` block's
|
|
6
|
+
# boundary structurally, then a narrower regex pass over that block's
|
|
7
|
+
# own text extracting every `t.index [...], unique: true` line -- the
|
|
8
|
+
# same hybrid technique Idxfence::ModelParser uses on model files. See
|
|
9
|
+
# DETAILS.md's "two passes" section for why this has to be a second,
|
|
10
|
+
# separate pass over a different file rather than something
|
|
11
|
+
# ModelParser could infer on its own.
|
|
12
|
+
#
|
|
13
|
+
# Returns a Hash of table_name => Array<Array<String>>, one entry per
|
|
14
|
+
# *unique* index found in that table's `create_table` block, each an
|
|
15
|
+
# array of the index's own column names in the order `t.index` declared
|
|
16
|
+
# them (order doesn't matter for matching -- Checker compares as sets).
|
|
17
|
+
# A non-unique `t.index` (no `unique: true`) is not collected at all --
|
|
18
|
+
# it can't enforce uniqueness at the database level regardless of what
|
|
19
|
+
# columns it covers.
|
|
20
|
+
class SchemaParser
|
|
21
|
+
CREATE_TABLE_IDENT = "create_table".freeze
|
|
22
|
+
TABLE_NAME_LINE = /create_table\s+["']([^"']+)["']/.freeze
|
|
23
|
+
INDEX_LINE = /t\.index\s+\[([^\]]*)\](?:[^\n]*?unique:\s*true)?/.freeze
|
|
24
|
+
|
|
25
|
+
class MissingSchemaError < StandardError; end
|
|
26
|
+
|
|
27
|
+
def self.parse(path)
|
|
28
|
+
raise MissingSchemaError, "#{path}: no such file" unless File.exist?(path)
|
|
29
|
+
|
|
30
|
+
new(path: path).call
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(path:)
|
|
34
|
+
@path = path
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def call
|
|
38
|
+
source = File.read(path)
|
|
39
|
+
sexp = Ripper.sexp(source)
|
|
40
|
+
raise MissingSchemaError, "#{path}: could not parse (invalid Ruby syntax)" unless sexp
|
|
41
|
+
|
|
42
|
+
lines = source.lines
|
|
43
|
+
tables = Hash.new { |h, k| h[k] = [] }
|
|
44
|
+
|
|
45
|
+
each_create_table_block(sexp) do |block_node|
|
|
46
|
+
start_line = min_line(block_node)
|
|
47
|
+
end_line = max_line(block_node)
|
|
48
|
+
next unless start_line && end_line
|
|
49
|
+
|
|
50
|
+
block_source = lines[(start_line - 1)...end_line].join
|
|
51
|
+
table_name = (m = block_source.match(TABLE_NAME_LINE)) && m[1]
|
|
52
|
+
next unless table_name
|
|
53
|
+
|
|
54
|
+
block_source.each_line do |line|
|
|
55
|
+
next unless line =~ /t\.index\b/
|
|
56
|
+
|
|
57
|
+
idx_match = line.match(INDEX_LINE)
|
|
58
|
+
next unless idx_match
|
|
59
|
+
next unless line.match?(/unique:\s*true/)
|
|
60
|
+
|
|
61
|
+
columns = idx_match[1].scan(/["']([^"']+)["']/).flatten
|
|
62
|
+
tables[table_name] << columns unless columns.empty?
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
tables
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
attr_reader :path
|
|
72
|
+
|
|
73
|
+
# Yields the block node of every `create_table "...", ... do |t| ...
|
|
74
|
+
# end` call (a `:method_add_block` s-expression whose call target's
|
|
75
|
+
# method name is exactly `create_table`).
|
|
76
|
+
def each_create_table_block(node, &block)
|
|
77
|
+
return unless node.is_a?(Array)
|
|
78
|
+
|
|
79
|
+
if node[0] == :method_add_block && create_table_call?(node[1])
|
|
80
|
+
block.call(node[2])
|
|
81
|
+
return
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
node.each { |child| each_create_table_block(child, &block) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def create_table_call?(call_node)
|
|
88
|
+
ident = find_first_ident(call_node)
|
|
89
|
+
ident == CREATE_TABLE_IDENT
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def find_first_ident(node)
|
|
93
|
+
return nil unless node.is_a?(Array)
|
|
94
|
+
return node[1] if node[0] == :@ident && node[1].is_a?(String)
|
|
95
|
+
|
|
96
|
+
node.each do |child|
|
|
97
|
+
found = find_first_ident(child)
|
|
98
|
+
return found if found
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def min_line(node)
|
|
105
|
+
lines = []
|
|
106
|
+
collect_lines(node, lines)
|
|
107
|
+
lines.min
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def max_line(node)
|
|
111
|
+
lines = []
|
|
112
|
+
collect_lines(node, lines)
|
|
113
|
+
lines.max
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def collect_lines(node, acc)
|
|
117
|
+
return unless node.is_a?(Array)
|
|
118
|
+
|
|
119
|
+
if node.length == 2 && node.all? { |x| x.is_a?(Integer) }
|
|
120
|
+
acc << node[0]
|
|
121
|
+
return
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
node.each { |child| collect_lines(child, acc) }
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
data/lib/idxfence.rb
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
require_relative "idxfence/version"
|
|
2
|
+
require_relative "idxfence/finding"
|
|
3
|
+
require_relative "idxfence/inflector"
|
|
4
|
+
require_relative "idxfence/model_parser"
|
|
5
|
+
require_relative "idxfence/schema_parser"
|
|
6
|
+
require_relative "idxfence/checker"
|
|
7
|
+
require_relative "idxfence/render"
|
|
8
|
+
|
|
9
|
+
module Idxfence
|
|
10
|
+
class << self
|
|
11
|
+
# Scans a Rails project root for ActiveRecord `uniqueness: true` /
|
|
12
|
+
# `uniqueness: { ... }` / `validates_uniqueness_of` validations under
|
|
13
|
+
# `app/models/**/*.rb` with no matching unique index in
|
|
14
|
+
# `db/schema.rb`.
|
|
15
|
+
#
|
|
16
|
+
# @param project_dir [String] path to a Rails project root (the
|
|
17
|
+
# directory containing `app/` and `db/`)
|
|
18
|
+
# @return [Array(Array<Finding>, String, nil)] findings, and a
|
|
19
|
+
# warning string (nil if none) when db/schema.rb was missing or
|
|
20
|
+
# unparseable
|
|
21
|
+
def check(project_dir:)
|
|
22
|
+
model_paths = Dir.glob(File.join(project_dir, "app", "models", "**", "*.rb"))
|
|
23
|
+
schema_path = File.join(project_dir, "db", "schema.rb")
|
|
24
|
+
|
|
25
|
+
check_paths(model_paths: model_paths, schema_path: schema_path)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Lower-level entry point: an explicit list of model file paths and
|
|
29
|
+
# one schema.rb path, for callers that already know their own
|
|
30
|
+
# layout (or for testing against fixtures that aren't laid out as a
|
|
31
|
+
# full Rails project).
|
|
32
|
+
#
|
|
33
|
+
# @param model_paths [Array<String>]
|
|
34
|
+
# @param schema_path [String]
|
|
35
|
+
# @return [Array(Array<Finding>, String, nil)]
|
|
36
|
+
def check_paths(model_paths:, schema_path:)
|
|
37
|
+
Checker.new(model_paths: model_paths, schema_path: schema_path).call
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|