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,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinspec
|
|
4
|
+
module Setup
|
|
5
|
+
class DependencyResolver
|
|
6
|
+
PLACEHOLDERS = {
|
|
7
|
+
string: "pinspec",
|
|
8
|
+
text: "pinspec",
|
|
9
|
+
citext: "pinspec",
|
|
10
|
+
integer: 1,
|
|
11
|
+
bigint: 1,
|
|
12
|
+
smallint: 1,
|
|
13
|
+
tinyint: 1,
|
|
14
|
+
float: 1.0,
|
|
15
|
+
decimal: "1.0",
|
|
16
|
+
numeric: "1.0",
|
|
17
|
+
money: "1.0",
|
|
18
|
+
boolean: false,
|
|
19
|
+
json: {},
|
|
20
|
+
jsonb: {},
|
|
21
|
+
hstore: {},
|
|
22
|
+
xml: "<pinspec/>",
|
|
23
|
+
uuid: "00000000-0000-4000-8000-000000000001",
|
|
24
|
+
inet: "127.0.0.1",
|
|
25
|
+
cidr: "127.0.0.0/8",
|
|
26
|
+
macaddr: "00:00:00:00:00:01",
|
|
27
|
+
binary: "pinspec",
|
|
28
|
+
blob: "pinspec",
|
|
29
|
+
interval: "P1D",
|
|
30
|
+
ltree: "pinspec",
|
|
31
|
+
oid: 1,
|
|
32
|
+
bit: "0",
|
|
33
|
+
bit_varying: "0"
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
TEMPORAL_TYPES = %i[datetime timestamp timestamptz date time].freeze
|
|
37
|
+
|
|
38
|
+
def initialize(schema, factories)
|
|
39
|
+
@schema = schema
|
|
40
|
+
@factories = factories
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def table_for(model_name)
|
|
44
|
+
return nil if model_name.nil?
|
|
45
|
+
|
|
46
|
+
table_candidates_for(model_name).each do |candidate|
|
|
47
|
+
table = @schema.table(candidate)
|
|
48
|
+
return table if table
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def table_for_type_hint(hint)
|
|
55
|
+
return nil if hint.nil?
|
|
56
|
+
|
|
57
|
+
words = hint.to_s.scan(/[A-Z][a-z0-9]*/)
|
|
58
|
+
|
|
59
|
+
if words.size < 2
|
|
60
|
+
direct = table_for(hint)
|
|
61
|
+
return direct if direct
|
|
62
|
+
else
|
|
63
|
+
words.each_index do |index|
|
|
64
|
+
table = table_for(words[index..].join)
|
|
65
|
+
return table if table
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
table_from_factory_class(hint) || table_from_unique_prefix(hint)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def table_from_factory_class(hint)
|
|
73
|
+
wanted = underscore(hint.to_s.split("::").last)
|
|
74
|
+
|
|
75
|
+
@factories.factories.each do |factory|
|
|
76
|
+
next unless factory.name.to_s == wanted
|
|
77
|
+
next if factory.model.nil?
|
|
78
|
+
|
|
79
|
+
table = table_for(factory.model)
|
|
80
|
+
return table if table
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def table_from_unique_prefix(hint)
|
|
87
|
+
candidates = Analyzer::Inflector.table_candidates(underscore(hint.to_s.split("::").last))
|
|
88
|
+
matches = @schema.tables.select do |table|
|
|
89
|
+
candidates.any? { |candidate| table.name.end_with?("_#{candidate}") }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
matches.size == 1 ? matches.first : nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def table_candidates_for(model_name)
|
|
96
|
+
parts = model_name.to_s.split("::")
|
|
97
|
+
demodulized = underscore(parts.last)
|
|
98
|
+
namespaced = parts.map { |part| underscore(part) }.join("_")
|
|
99
|
+
|
|
100
|
+
(Analyzer::Inflector.table_candidates(demodulized) +
|
|
101
|
+
Analyzer::Inflector.table_candidates(namespaced)).uniq
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def model_for(table_name)
|
|
105
|
+
declared = @factories.factories.find { |f| table_for(f.model)&.name == table_name.to_s }
|
|
106
|
+
return declared.model if declared
|
|
107
|
+
|
|
108
|
+
camelize(Analyzer::Inflector.singular_candidates(table_name).first)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def factory_for(table_name)
|
|
112
|
+
candidates = @factories.factories.select { |f| table_for(f.model)&.name == table_name.to_s }
|
|
113
|
+
|
|
114
|
+
candidates.find(&:persists?)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def declined_factory_for(table_name)
|
|
118
|
+
@factories.factories
|
|
119
|
+
.select { |f| table_for(f.model)&.name == table_name.to_s }
|
|
120
|
+
.reject(&:persists?)
|
|
121
|
+
.first
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def required_associations(table)
|
|
125
|
+
return [] if table.nil?
|
|
126
|
+
|
|
127
|
+
table.required_columns.filter_map do |column|
|
|
128
|
+
fk = @schema.fk_for(table.name, column.name)
|
|
129
|
+
next unless fk
|
|
130
|
+
|
|
131
|
+
[column.name, fk.to_table]
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def required_scalars(table)
|
|
136
|
+
return [] if table.nil?
|
|
137
|
+
|
|
138
|
+
association_columns = required_associations(table).map(&:first)
|
|
139
|
+
table.required_columns.reject { |column| association_columns.include?(column.name) }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def creation_order(root_tables, prune: ->(_table) { false })
|
|
143
|
+
ordered = []
|
|
144
|
+
state = {}
|
|
145
|
+
|
|
146
|
+
walk = lambda do |table_name, path|
|
|
147
|
+
case state[table_name]
|
|
148
|
+
when :done then return
|
|
149
|
+
when :visiting
|
|
150
|
+
raise UnresolvableSetup.new(
|
|
151
|
+
:association_cycle,
|
|
152
|
+
"#{path.join(' -> ')} -> #{table_name}: these tables require each " \
|
|
153
|
+
"other through NOT NULL foreign keys, so no row can be created " \
|
|
154
|
+
"first. Making one side optional would break the cycle."
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
state[table_name] = :visiting
|
|
159
|
+
|
|
160
|
+
unless prune.call(table_name)
|
|
161
|
+
table = @schema.table(table_name)
|
|
162
|
+
required_associations(table).each do |_column, parent|
|
|
163
|
+
next if parent == table_name
|
|
164
|
+
|
|
165
|
+
walk.call(parent, path + [table_name])
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
state[table_name] = :done
|
|
170
|
+
ordered << table_name
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
Array(root_tables).compact.uniq.each { |name| walk.call(name, []) }
|
|
174
|
+
ordered
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def self_referential_required?(table)
|
|
178
|
+
required_associations(table).any? { |_column, parent| parent == table&.name }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def placeholder_for(column, frozen_time:, uniquifier: nil)
|
|
182
|
+
return frozen_value(column, frozen_time) if TEMPORAL_TYPES.include?(column.type)
|
|
183
|
+
|
|
184
|
+
base = PLACEHOLDERS[column.type]
|
|
185
|
+
base = apply_uniquifier(base, column, uniquifier) if uniquifier
|
|
186
|
+
clamp_to_limit(base, column)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def unique_columns(table)
|
|
190
|
+
return [] if table.nil?
|
|
191
|
+
|
|
192
|
+
table.unique_indexes.flat_map(&:columns).uniq
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
private
|
|
196
|
+
|
|
197
|
+
def frozen_value(column, frozen_time)
|
|
198
|
+
case column.type
|
|
199
|
+
when :date then frozen_time[0, 10]
|
|
200
|
+
when :time then frozen_time[11, 8]
|
|
201
|
+
else frozen_time
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def apply_uniquifier(base, column, uniquifier)
|
|
206
|
+
case base
|
|
207
|
+
when String then "#{base}-#{uniquifier}"
|
|
208
|
+
when Integer then base + uniquifier_ordinal(uniquifier)
|
|
209
|
+
else base
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def uniquifier_ordinal(uniquifier)
|
|
214
|
+
uniquifier.to_s.scan(/\d+/).map(&:to_i).sum
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def clamp_to_limit(value, column)
|
|
218
|
+
return value unless value.is_a?(String)
|
|
219
|
+
return value if column.limit.nil? || !column.limit.is_a?(Integer)
|
|
220
|
+
|
|
221
|
+
value[0, column.limit]
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def underscore(str)
|
|
225
|
+
str.to_s
|
|
226
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
227
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
228
|
+
.downcase
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def camelize(str)
|
|
232
|
+
str.to_s.split("_").map { |part| part.sub(/\A[a-z]/, &:upcase) }.join
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
data/lib/pinspec/tags.rb
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Pinspec
|
|
6
|
+
module Tags
|
|
7
|
+
VALUED = %w[int float str sym decimal date time bin ref seq relation cycle inf].freeze
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def encode(value)
|
|
11
|
+
case value
|
|
12
|
+
when nil then { "t" => "nil" }
|
|
13
|
+
when true then { "t" => "bool", "v" => true }
|
|
14
|
+
when false then { "t" => "bool", "v" => false }
|
|
15
|
+
when Integer then { "t" => "int", "v" => value }
|
|
16
|
+
when Float then encode_float(value)
|
|
17
|
+
when Symbol then { "t" => "sym", "v" => value.to_s }
|
|
18
|
+
when String then encode_string(value)
|
|
19
|
+
when Array then { "t" => "array", "v" => value.map { |element| encode(element) } }
|
|
20
|
+
when Hash then encode_hash(value)
|
|
21
|
+
when Date then { "t" => "date", "v" => value.iso8601 }
|
|
22
|
+
else { "t" => "str", "v" => value.to_s }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def ref(name)
|
|
27
|
+
{ "t" => "ref", "v" => name.to_s }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def decimal(value)
|
|
31
|
+
{ "t" => "decimal", "v" => value.to_s }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def tagged?(value)
|
|
35
|
+
value.is_a?(Hash) && value.key?("t")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def type_of(value)
|
|
39
|
+
tagged?(value) ? value["t"] : nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def decode(tagged)
|
|
43
|
+
return tagged unless tagged?(tagged)
|
|
44
|
+
|
|
45
|
+
case tagged["t"]
|
|
46
|
+
when "nil" then nil
|
|
47
|
+
when "bool" then tagged["v"]
|
|
48
|
+
when "int" then tagged["v"]
|
|
49
|
+
when "float" then tagged["v"]
|
|
50
|
+
when "nan" then Float::NAN
|
|
51
|
+
when "inf" then tagged["sign"].to_i.negative? ? -Float::INFINITY : Float::INFINITY
|
|
52
|
+
when "sym" then tagged["v"].to_sym
|
|
53
|
+
when "str" then tagged["v"]
|
|
54
|
+
when "decimal" then tagged["v"]
|
|
55
|
+
when "date" then tagged["v"]
|
|
56
|
+
when "time" then tagged["v"]
|
|
57
|
+
when "bin" then tagged["v"].unpack1("m0").force_encoding(tagged["enc"] || "ASCII-8BIT")
|
|
58
|
+
when "array" then tagged["v"].map { |element| decode(element) }
|
|
59
|
+
when "hash" then tagged["v"].to_h { |key, element| [decode(key), decode(element)] }
|
|
60
|
+
when "ref" then tagged["v"]
|
|
61
|
+
else tagged["v"]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def describe(tagged)
|
|
66
|
+
return tagged.inspect unless tagged?(tagged)
|
|
67
|
+
|
|
68
|
+
case tagged["t"]
|
|
69
|
+
when "nil" then "nil"
|
|
70
|
+
when "ref" then tagged["v"]
|
|
71
|
+
when "sym" then ":#{tagged['v']}"
|
|
72
|
+
when "str" then tagged["v"].inspect
|
|
73
|
+
when "array" then "[#{tagged['v'].map { |element| describe(element) }.join(', ')}]"
|
|
74
|
+
when "hash" then "{#{tagged['v'].map { |k, v| "#{describe(k)}=>#{describe(v)}" }.join(', ')}}"
|
|
75
|
+
when "nan" then "NaN"
|
|
76
|
+
when "inf" then tagged["sign"].to_i.negative? ? "-Infinity" : "Infinity"
|
|
77
|
+
else tagged["v"].to_s
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def encode_float(value)
|
|
84
|
+
return { "t" => "nan" } if value.nan?
|
|
85
|
+
return { "t" => "inf", "sign" => value.negative? ? -1 : 1 } if value.infinite?
|
|
86
|
+
|
|
87
|
+
{ "t" => "float", "v" => value.round(10) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def encode_string(value)
|
|
91
|
+
if value.encoding == Encoding::ASCII_8BIT || !value.valid_encoding?
|
|
92
|
+
return { "t" => "bin", "v" => [value].pack("m0"), "enc" => value.encoding.to_s }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
{ "t" => "str", "v" => value }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def encode_hash(value)
|
|
99
|
+
{ "t" => "hash", "v" => value.map { |key, element| [encode(key), encode(element)] } }
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|