pinspec 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/CHANGELOG.md +133 -0
- data/LICENSE.txt +21 -0
- data/README.md +183 -0
- data/exe/pinspec +8 -0
- data/lib/pinspec/analyzer/app_profile_reader.rb +348 -0
- data/lib/pinspec/analyzer/factory_registry.rb +288 -0
- data/lib/pinspec/analyzer/inflector.rb +85 -0
- data/lib/pinspec/analyzer/schema_reader.rb +444 -0
- data/lib/pinspec/analyzer/source.rb +56 -0
- data/lib/pinspec/analyzer/target_parser.rb +710 -0
- data/lib/pinspec/cli.rb +585 -0
- data/lib/pinspec/emit/namer.rb +103 -0
- data/lib/pinspec/emit/spec_writer.rb +504 -0
- data/lib/pinspec/emit/stability_filter.rb +183 -0
- data/lib/pinspec/errors.rb +85 -0
- data/lib/pinspec/inputs/boundary.rb +112 -0
- data/lib/pinspec/inputs/corpus.rb +148 -0
- data/lib/pinspec/inputs/hydrator.rb +197 -0
- data/lib/pinspec/inputs/redactor.rb +138 -0
- data/lib/pinspec/inputs/sample_runner.rb +98 -0
- data/lib/pinspec/inputs/sampler.rb +187 -0
- data/lib/pinspec/report/summary.rb +348 -0
- data/lib/pinspec/runner/capture.rb +127 -0
- data/lib/pinspec/runner/probe_generator.rb +662 -0
- data/lib/pinspec/runner/sandbox.rb +121 -0
- data/lib/pinspec/setup/context_builder.rb +471 -0
- data/lib/pinspec/setup/dependency_resolver.rb +236 -0
- data/lib/pinspec/tags.rb +103 -0
- data/lib/pinspec/types.rb +497 -0
- data/lib/pinspec/validate/mutation_adapter.rb +108 -0
- data/lib/pinspec/validate/pin_scorer.rb +164 -0
- data/lib/pinspec/verify/verifier.rb +149 -0
- data/lib/pinspec/version.rb +8 -0
- data/lib/pinspec.rb +17 -0
- data/templates/factory_build.rb +54 -0
- data/templates/serializer.rb +243 -0
- data/templates/spec_support.rb +83 -0
- metadata +134 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinspec
|
|
4
|
+
module Analyzer
|
|
5
|
+
module Inflector
|
|
6
|
+
IRREGULAR_PLURALS = {
|
|
7
|
+
"person" => "people",
|
|
8
|
+
"child" => "children",
|
|
9
|
+
"man" => "men",
|
|
10
|
+
"woman" => "women",
|
|
11
|
+
"foot" => "feet",
|
|
12
|
+
"tooth" => "teeth",
|
|
13
|
+
"mouse" => "mice",
|
|
14
|
+
"goose" => "geese",
|
|
15
|
+
"ox" => "oxen",
|
|
16
|
+
"datum" => "data",
|
|
17
|
+
"medium" => "media",
|
|
18
|
+
"criterion" => "criteria",
|
|
19
|
+
"analysis" => "analyses",
|
|
20
|
+
"diagnosis" => "diagnoses",
|
|
21
|
+
"basis" => "bases",
|
|
22
|
+
"index" => "indices",
|
|
23
|
+
"matrix" => "matrices",
|
|
24
|
+
"vertex" => "vertices",
|
|
25
|
+
"status" => "statuses"
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
IRREGULAR_SINGULARS = IRREGULAR_PLURALS.invert.freeze
|
|
29
|
+
|
|
30
|
+
UNCOUNTABLE = %w[
|
|
31
|
+
data metadata series species information equipment money news
|
|
32
|
+
settings preferences credentials
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
def table_candidates(stem)
|
|
37
|
+
stem = stem.to_s
|
|
38
|
+
return [stem] if stem.empty?
|
|
39
|
+
|
|
40
|
+
candidates = []
|
|
41
|
+
candidates << IRREGULAR_PLURALS[stem] if IRREGULAR_PLURALS.key?(stem)
|
|
42
|
+
candidates << stem if UNCOUNTABLE.include?(stem)
|
|
43
|
+
candidates.concat(regular_plurals(stem))
|
|
44
|
+
candidates << stem
|
|
45
|
+
|
|
46
|
+
candidates.compact.uniq
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def singular_candidates(table)
|
|
50
|
+
table = table.to_s
|
|
51
|
+
return [table] if table.empty?
|
|
52
|
+
|
|
53
|
+
candidates = []
|
|
54
|
+
candidates << IRREGULAR_SINGULARS[table] if IRREGULAR_SINGULARS.key?(table)
|
|
55
|
+
candidates << table if UNCOUNTABLE.include?(table)
|
|
56
|
+
|
|
57
|
+
candidates << "#{table[0..-4]}y" if table.end_with?("ies") && table.length > 3
|
|
58
|
+
candidates << table[0..-2] if table.end_with?("s") && !table.end_with?("ss")
|
|
59
|
+
candidates << table[0..-3] if table.end_with?("es") && table.length > 2
|
|
60
|
+
candidates << table
|
|
61
|
+
|
|
62
|
+
candidates.compact.uniq
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def regular_plurals(stem)
|
|
68
|
+
out = []
|
|
69
|
+
|
|
70
|
+
if stem.match?(/[^aeiou]y\z/)
|
|
71
|
+
out << "#{stem[0..-2]}ies"
|
|
72
|
+
elsif stem.match?(/(s|x|z|ch|sh)\z/)
|
|
73
|
+
out << "#{stem}es"
|
|
74
|
+
elsif stem.match?(/(?:[^f]f|fe)\z/)
|
|
75
|
+
out << "#{stem.sub(/fe?\z/, '')}ves"
|
|
76
|
+
out << "#{stem}s"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
out << "#{stem}s"
|
|
80
|
+
out
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Pinspec
|
|
6
|
+
module Analyzer
|
|
7
|
+
class SchemaReader
|
|
8
|
+
include Source
|
|
9
|
+
|
|
10
|
+
KNOWN_TYPES = %i[
|
|
11
|
+
string text citext
|
|
12
|
+
integer bigint smallint tinyint float decimal numeric money
|
|
13
|
+
datetime timestamp timestamptz time date
|
|
14
|
+
binary blob boolean
|
|
15
|
+
json jsonb hstore xml
|
|
16
|
+
uuid inet cidr macaddr
|
|
17
|
+
daterange numrange tsrange tstzrange int4range int8range
|
|
18
|
+
interval bit bit_varying oid ltree
|
|
19
|
+
primary_key serial bigserial virtual enum
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
HANDLED_STATEMENTS = %i[create_table add_index add_foreign_key enable_extension].freeze
|
|
23
|
+
|
|
24
|
+
NON_COLUMN_CALLS = %i[index check_constraint exclusion_constraint unique_constraint].freeze
|
|
25
|
+
|
|
26
|
+
DEFAULT_ID_TYPE = :bigint
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
def read(app_root = ".")
|
|
30
|
+
schema = File.join(app_root, "db", "schema.rb")
|
|
31
|
+
return parse(schema) if File.file?(schema)
|
|
32
|
+
|
|
33
|
+
structure = File.join(app_root, "db", "structure.sql")
|
|
34
|
+
if File.file?(structure)
|
|
35
|
+
raise SchemaFormatUnsupported, structure_sql_message(structure)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
raise TargetNotFound,
|
|
39
|
+
"no db/schema.rb under #{app_root} (and no db/structure.sql either); " \
|
|
40
|
+
"is this a Rails application root?"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def parse(path)
|
|
44
|
+
new(path).parse
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def structure_sql_message(path)
|
|
48
|
+
"#{path} is a SQL-format schema; pinspec reads the Ruby schema DSL. " \
|
|
49
|
+
"Generate one with `bin/rails db:schema:dump` after setting " \
|
|
50
|
+
"`config.active_record.schema_format = :ruby`, or keep both formats " \
|
|
51
|
+
"while pinspec runs. SQL-format support is spec open question 2."
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def initialize(path)
|
|
56
|
+
@path = path
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def parse
|
|
60
|
+
raise SchemaFormatUnsupported, self.class.structure_sql_message(@path) if @path.end_with?(".sql")
|
|
61
|
+
|
|
62
|
+
read_source
|
|
63
|
+
collect_statements
|
|
64
|
+
|
|
65
|
+
tables = @raw_tables.map { |raw| build_table(raw) }
|
|
66
|
+
foreign_keys = resolve_foreign_keys(tables)
|
|
67
|
+
|
|
68
|
+
SchemaGraph.new(
|
|
69
|
+
tables: tables,
|
|
70
|
+
fk_map: foreign_keys.to_h { |fk| [fk.key, fk.to_table] },
|
|
71
|
+
foreign_keys: foreign_keys,
|
|
72
|
+
skipped_statements: build_skipped(tables.map(&:name))
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def read_source
|
|
79
|
+
raise TargetNotFound, "no such file: #{@path}" unless File.file?(@path)
|
|
80
|
+
|
|
81
|
+
result = Prism.parse(Source.read(@path))
|
|
82
|
+
|
|
83
|
+
unless result.success?
|
|
84
|
+
first = result.errors.first
|
|
85
|
+
raise UnparsableSource,
|
|
86
|
+
"#{@path} is not valid Ruby: #{first.message} (line #{first.location.start_line})"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
@program = result.value
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def collect_statements
|
|
93
|
+
@raw_tables = []
|
|
94
|
+
@raw_indexes = []
|
|
95
|
+
@raw_fks = []
|
|
96
|
+
@assoc_refs = []
|
|
97
|
+
@skipped = []
|
|
98
|
+
|
|
99
|
+
Array(define_block_body).each { |statement| visit_top_level(statement) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def define_block_body
|
|
103
|
+
found = nil
|
|
104
|
+
|
|
105
|
+
each_node(@program) do |node|
|
|
106
|
+
next if found
|
|
107
|
+
next unless node.is_a?(Prism::CallNode) && node.name == :define
|
|
108
|
+
next unless node.receiver&.slice.to_s.start_with?("ActiveRecord::Schema")
|
|
109
|
+
|
|
110
|
+
found = node.block
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
unless found
|
|
114
|
+
raise UnparsableSource,
|
|
115
|
+
"#{@path} has no ActiveRecord::Schema.define block; " \
|
|
116
|
+
"pinspec expected a Rails schema dump."
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
found.body&.body
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def visit_top_level(node)
|
|
123
|
+
return unless node.is_a?(Prism::CallNode)
|
|
124
|
+
|
|
125
|
+
unless HANDLED_STATEMENTS.include?(node.name)
|
|
126
|
+
return skip(
|
|
127
|
+
node.name,
|
|
128
|
+
table: first_string_argument(node),
|
|
129
|
+
line: node.location.start_line,
|
|
130
|
+
sql: keyword_options(Array(node.arguments&.arguments))[:sql_definition]
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
case node.name
|
|
135
|
+
when :create_table then collect_table(node)
|
|
136
|
+
when :add_index then collect_add_index(node)
|
|
137
|
+
when :add_foreign_key then collect_add_foreign_key(node)
|
|
138
|
+
when :enable_extension then nil
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def collect_table(node)
|
|
143
|
+
args = Array(node.arguments&.arguments)
|
|
144
|
+
name = decode(args.first).to_s
|
|
145
|
+
options = keyword_options(args)
|
|
146
|
+
|
|
147
|
+
raw = {
|
|
148
|
+
name: name,
|
|
149
|
+
options: options,
|
|
150
|
+
line: node.location.start_line,
|
|
151
|
+
columns: [],
|
|
152
|
+
indexes: []
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
Array(node.block&.body&.body).each { |statement| visit_column(statement, raw) }
|
|
156
|
+
|
|
157
|
+
@raw_tables << raw
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def visit_column(node, raw)
|
|
161
|
+
return unless node.is_a?(Prism::CallNode)
|
|
162
|
+
return unless node.receiver.is_a?(Prism::LocalVariableReadNode)
|
|
163
|
+
|
|
164
|
+
args = Array(node.arguments&.arguments)
|
|
165
|
+
options = keyword_options(args)
|
|
166
|
+
line = node.location.start_line
|
|
167
|
+
|
|
168
|
+
case node.name
|
|
169
|
+
when :index
|
|
170
|
+
raw[:indexes] << build_index(args, options)
|
|
171
|
+
when :references, :belongs_to
|
|
172
|
+
collect_reference(args, options, raw, line)
|
|
173
|
+
when :timestamps
|
|
174
|
+
%w[created_at updated_at].each do |timestamp|
|
|
175
|
+
raw[:columns] << column(timestamp, :datetime, options.merge(null: false), line: line)
|
|
176
|
+
end
|
|
177
|
+
when :column
|
|
178
|
+
raw[:columns] << typed_column(decode(args[0]).to_s, decode(args[1]), options, line: line)
|
|
179
|
+
when *NON_COLUMN_CALLS
|
|
180
|
+
nil
|
|
181
|
+
else
|
|
182
|
+
raw[:columns] << typed_column(decode(args.first).to_s, node.name, options, line: line)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def collect_reference(args, options, raw, line)
|
|
187
|
+
stem = decode(args.first).to_s
|
|
188
|
+
type = options.fetch(:type, DEFAULT_ID_TYPE)
|
|
189
|
+
type = type.to_sym if type.respond_to?(:to_sym)
|
|
190
|
+
|
|
191
|
+
raw[:columns] << column("#{stem}_id", type, options, line: line)
|
|
192
|
+
|
|
193
|
+
if options[:polymorphic] == true
|
|
194
|
+
raw[:columns] << column("#{stem}_type", :string, options, line: line)
|
|
195
|
+
|
|
196
|
+
return
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
@assoc_refs << {
|
|
200
|
+
from_table: raw[:name],
|
|
201
|
+
column: "#{stem}_id",
|
|
202
|
+
stem: stem,
|
|
203
|
+
line: line
|
|
204
|
+
}
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def collect_add_index(node)
|
|
208
|
+
args = Array(node.arguments&.arguments)
|
|
209
|
+
options = keyword_options(args)
|
|
210
|
+
|
|
211
|
+
@raw_indexes << {
|
|
212
|
+
table: decode(args.first).to_s,
|
|
213
|
+
index: build_index(args.drop(1), options)
|
|
214
|
+
}
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def collect_add_foreign_key(node)
|
|
218
|
+
args = Array(node.arguments&.arguments)
|
|
219
|
+
options = keyword_options(args)
|
|
220
|
+
from, to = decode(args[0]).to_s, decode(args[1]).to_s
|
|
221
|
+
|
|
222
|
+
@raw_fks << {
|
|
223
|
+
from_table: from,
|
|
224
|
+
to_table: to,
|
|
225
|
+
column: options[:column]&.to_s,
|
|
226
|
+
primary_key: options[:primary_key]&.to_s,
|
|
227
|
+
on_delete: options[:on_delete]
|
|
228
|
+
}
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def build_table(raw)
|
|
232
|
+
options = raw[:options]
|
|
233
|
+
|
|
234
|
+
columns = raw[:columns]
|
|
235
|
+
if implicit_id?(options)
|
|
236
|
+
id_column = column("id", id_type(options) || DEFAULT_ID_TYPE, { null: false }, line: raw[:line])
|
|
237
|
+
columns = [id_column] + columns
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
columns.each do |col|
|
|
241
|
+
next unless col.unknown_type?
|
|
242
|
+
|
|
243
|
+
skip(:unknown_column_type, table: raw[:name], column: col.name, line: col.line)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
Table.new(
|
|
247
|
+
name: raw[:name],
|
|
248
|
+
primary_key: primary_key(options),
|
|
249
|
+
id_type: id_type(options),
|
|
250
|
+
columns: columns,
|
|
251
|
+
indexes: raw[:indexes] + indexes_for(raw[:name])
|
|
252
|
+
)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def implicit_id?(options)
|
|
256
|
+
return false if options[:id] == false
|
|
257
|
+
return false if options.key?(:primary_key) && options[:id] == false
|
|
258
|
+
|
|
259
|
+
true
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def primary_key(options)
|
|
263
|
+
return options[:primary_key].then { |pk| pk.is_a?(Array) ? pk.map(&:to_s) : pk.to_s } if options[:primary_key]
|
|
264
|
+
return nil if options[:id] == false
|
|
265
|
+
|
|
266
|
+
"id"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def id_type(options)
|
|
270
|
+
declared = options[:id]
|
|
271
|
+
return nil if declared == false
|
|
272
|
+
return DEFAULT_ID_TYPE if declared.nil? || declared == true
|
|
273
|
+
|
|
274
|
+
declared.to_sym
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def indexes_for(table_name)
|
|
278
|
+
@raw_indexes.select { |entry| entry[:table] == table_name }.map { |entry| entry[:index] }
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def resolve_foreign_keys(tables)
|
|
282
|
+
table_names = tables.map(&:name)
|
|
283
|
+
out = {}
|
|
284
|
+
|
|
285
|
+
@raw_fks.each do |fk|
|
|
286
|
+
column = fk[:column] || implicit_fk_column(fk[:from_table], fk[:to_table], tables)
|
|
287
|
+
|
|
288
|
+
unless column
|
|
289
|
+
skip(:unattachable_foreign_key, table: fk[:from_table], line: 0)
|
|
290
|
+
next
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
out["#{fk[:from_table]}.#{column}"] = ForeignKey.new(
|
|
294
|
+
from_table: fk[:from_table],
|
|
295
|
+
column: column,
|
|
296
|
+
to_table: fk[:to_table],
|
|
297
|
+
primary_key: fk[:primary_key] || "id",
|
|
298
|
+
on_delete: fk[:on_delete],
|
|
299
|
+
source: :foreign_key
|
|
300
|
+
)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
@assoc_refs.each do |ref|
|
|
304
|
+
key = "#{ref[:from_table]}.#{ref[:column]}"
|
|
305
|
+
next if out.key?(key)
|
|
306
|
+
|
|
307
|
+
target = match_table(ref[:stem], table_names)
|
|
308
|
+
next unless target
|
|
309
|
+
|
|
310
|
+
out[key] = ForeignKey.new(
|
|
311
|
+
from_table: ref[:from_table],
|
|
312
|
+
column: ref[:column],
|
|
313
|
+
to_table: target,
|
|
314
|
+
primary_key: "id",
|
|
315
|
+
on_delete: nil,
|
|
316
|
+
source: :references
|
|
317
|
+
)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
heuristic_foreign_keys(table_names).each do |fk|
|
|
321
|
+
out[fk.key] = fk unless out.key?(fk.key)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
out.values.sort_by(&:key)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def heuristic_foreign_keys(table_names)
|
|
328
|
+
@raw_tables.flat_map do |raw|
|
|
329
|
+
column_names = raw[:columns].map(&:name)
|
|
330
|
+
|
|
331
|
+
raw[:columns].filter_map do |col|
|
|
332
|
+
next unless col.name.end_with?("_id")
|
|
333
|
+
next unless integerish?(col.type)
|
|
334
|
+
|
|
335
|
+
stem = col.name.delete_suffix("_id")
|
|
336
|
+
|
|
337
|
+
next if column_names.include?("#{stem}_type")
|
|
338
|
+
|
|
339
|
+
target = match_table(stem, table_names)
|
|
340
|
+
next unless target
|
|
341
|
+
|
|
342
|
+
ForeignKey.new(
|
|
343
|
+
from_table: raw[:name],
|
|
344
|
+
column: col.name,
|
|
345
|
+
to_table: target,
|
|
346
|
+
primary_key: "id",
|
|
347
|
+
on_delete: nil,
|
|
348
|
+
source: :heuristic
|
|
349
|
+
)
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def integerish?(type)
|
|
355
|
+
%i[integer bigint smallint uuid].include?(type)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def match_table(stem, table_names)
|
|
359
|
+
Inflector.table_candidates(stem).find { |candidate| table_names.include?(candidate) }
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def implicit_fk_column(from_table, to_table, tables)
|
|
363
|
+
existing = tables.find { |t| t.name == from_table }&.columns&.map(&:name) || []
|
|
364
|
+
|
|
365
|
+
Inflector.singular_candidates(to_table)
|
|
366
|
+
.map { |singular| "#{singular}_id" }
|
|
367
|
+
.find { |candidate| existing.include?(candidate) }
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def build_index(args, options)
|
|
371
|
+
columns = decode(args.first)
|
|
372
|
+
columns = [columns].compact unless columns.is_a?(Array)
|
|
373
|
+
|
|
374
|
+
Index.new(
|
|
375
|
+
columns: columns.map(&:to_s),
|
|
376
|
+
name: options[:name]&.to_s,
|
|
377
|
+
unique: options[:unique] == true,
|
|
378
|
+
where: options[:where]
|
|
379
|
+
)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def typed_column(name, type, options, line: 0)
|
|
383
|
+
normalized = normalize_type(type)
|
|
384
|
+
|
|
385
|
+
column(name, normalized || type, options, unknown: normalized.nil?, line: line)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def normalize_type(type)
|
|
389
|
+
text = type.to_s.strip.downcase
|
|
390
|
+
return nil if text.empty?
|
|
391
|
+
|
|
392
|
+
candidate = text.split(/[\s(]/).first.to_sym
|
|
393
|
+
KNOWN_TYPES.include?(candidate) ? candidate : nil
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def column(name, type, options, unknown: false, line: 0)
|
|
397
|
+
Column.new(
|
|
398
|
+
name: name,
|
|
399
|
+
type: type.to_s.to_sym,
|
|
400
|
+
null: options[:null],
|
|
401
|
+
default: options[:default],
|
|
402
|
+
limit: options[:limit],
|
|
403
|
+
precision: options[:precision],
|
|
404
|
+
scale: options[:scale],
|
|
405
|
+
array: options[:array] == true,
|
|
406
|
+
unknown_type: unknown,
|
|
407
|
+
line: line
|
|
408
|
+
)
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def first_string_argument(node)
|
|
412
|
+
Array(node.arguments&.arguments).grep(Prism::StringNode).first&.unescaped
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def skip(kind, table: nil, column: nil, line: 0, sql: nil)
|
|
416
|
+
@skipped << { kind: kind.to_sym, table: table, column: column, line: line, sql: sql }
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def build_skipped(table_names)
|
|
420
|
+
@skipped
|
|
421
|
+
.map do |raw|
|
|
422
|
+
SkippedStatement.new(
|
|
423
|
+
kind: raw[:kind],
|
|
424
|
+
table: raw[:table],
|
|
425
|
+
column: raw[:column],
|
|
426
|
+
references: referenced_tables(raw[:sql], table_names) - [raw[:table]],
|
|
427
|
+
file: @path,
|
|
428
|
+
line: raw[:line],
|
|
429
|
+
relevant: nil
|
|
430
|
+
)
|
|
431
|
+
end
|
|
432
|
+
.sort_by { |statement| [statement.line, statement.kind.to_s, statement.column.to_s] }
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def referenced_tables(sql, table_names)
|
|
436
|
+
return [] if sql.nil?
|
|
437
|
+
|
|
438
|
+
text = sql.to_s.downcase
|
|
439
|
+
table_names.select { |name| text.match?(/\b#{Regexp.escape(name.downcase)}\b/) }
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Pinspec
|
|
6
|
+
module Analyzer
|
|
7
|
+
module Source
|
|
8
|
+
def self.read(path)
|
|
9
|
+
File.read(path, mode: "rb:BOM|UTF-8").scrub
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def read_source(path)
|
|
15
|
+
Source.read(path)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def each_node(node, &block)
|
|
19
|
+
return unless node
|
|
20
|
+
|
|
21
|
+
block.call(node)
|
|
22
|
+
node.compact_child_nodes.each { |child| each_node(child, &block) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def keyword_options(args)
|
|
26
|
+
hash = Array(args).grep(Prism::KeywordHashNode).first
|
|
27
|
+
return {} unless hash
|
|
28
|
+
|
|
29
|
+
hash.elements.grep(Prism::AssocNode).each_with_object({}) do |assoc, out|
|
|
30
|
+
next unless assoc.key.is_a?(Prism::SymbolNode)
|
|
31
|
+
|
|
32
|
+
out[assoc.key.unescaped.to_sym] = decode(assoc.value)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def decode(node)
|
|
37
|
+
case node
|
|
38
|
+
when nil then nil
|
|
39
|
+
when Prism::TrueNode then true
|
|
40
|
+
when Prism::FalseNode then false
|
|
41
|
+
when Prism::NilNode then nil
|
|
42
|
+
when Prism::IntegerNode then node.value
|
|
43
|
+
when Prism::FloatNode then node.value
|
|
44
|
+
when Prism::StringNode then node.unescaped
|
|
45
|
+
when Prism::SymbolNode then node.unescaped.to_sym
|
|
46
|
+
when Prism::ArrayNode then node.elements.map { |element| decode(element) }
|
|
47
|
+
else node.slice
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def camelize(str)
|
|
52
|
+
str.to_s.split("_").map { |part| part.sub(/\A[a-z]/, &:upcase) }.join
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|