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,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Pinspec
|
|
6
|
+
module Validate
|
|
7
|
+
class PinScorer
|
|
8
|
+
ASPECTS = %i[return error jobs mail].freeze
|
|
9
|
+
SCRATCH_DIR = "tmp/pinspec/aspects"
|
|
10
|
+
|
|
11
|
+
STRONG = 80.0
|
|
12
|
+
WEAK = 40.0
|
|
13
|
+
|
|
14
|
+
Score = Data.define(:aspect, :score, :verdict, :killed, :survived, :survivors, :note) do
|
|
15
|
+
def strong?
|
|
16
|
+
verdict == :strong
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
Report = Data.define(:subject, :scores, :skipped) do
|
|
21
|
+
def scored
|
|
22
|
+
scores.reject { |score| score.score.nil? }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def strong_ratio
|
|
26
|
+
return 0.0 if scored.empty?
|
|
27
|
+
|
|
28
|
+
(scored.count(&:strong?).to_f / scored.size * 100).round(1)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def surviving_all_aspects
|
|
32
|
+
sets = scored.map { |score| score.survivors.map { |s| mutant_key(s) } }
|
|
33
|
+
return [] if sets.empty? || sets.any?(&:empty?)
|
|
34
|
+
|
|
35
|
+
shared = sets.reduce(:&)
|
|
36
|
+
scored.first.survivors.select { |s| shared.include?(mutant_key(s)) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def covered_by_another_aspect
|
|
40
|
+
all = scored.flat_map(&:survivors)
|
|
41
|
+
gaps = surviving_all_aspects.map { |s| mutant_key(s) }
|
|
42
|
+
|
|
43
|
+
all.reject { |s| gaps.include?(mutant_key(s)) }
|
|
44
|
+
.uniq { |s| mutant_key(s) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def mutant_key(survivor)
|
|
48
|
+
[survivor["operator"], survivor["line"], survivor["token"]].join(":")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
WRAPPER = "tmp/pinspec/run_suite.sh"
|
|
53
|
+
|
|
54
|
+
def initialize(app_root:, target:, plan:, corpus:, stability:, fk_map:, env: {}, test_command: nil)
|
|
55
|
+
@app_root = app_root
|
|
56
|
+
@target = target
|
|
57
|
+
@plan = plan
|
|
58
|
+
@corpus = corpus
|
|
59
|
+
@stability = stability
|
|
60
|
+
@fk_map = fk_map
|
|
61
|
+
@env = env
|
|
62
|
+
@test_command = test_command
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def run
|
|
66
|
+
MutationAdapter.refuse_unless_available!
|
|
67
|
+
@command = @test_command || generate_wrapper
|
|
68
|
+
|
|
69
|
+
scores = present_aspects.map { |aspect| score_aspect(aspect) }
|
|
70
|
+
|
|
71
|
+
Report.new(subject: @target.qualified_name, scores: scores, skipped: excluded_aspects)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def present_aspects
|
|
77
|
+
found = []
|
|
78
|
+
|
|
79
|
+
@stability.stable.each do |verdict|
|
|
80
|
+
observation = verdict.observation
|
|
81
|
+
found << (observation["status"] == "raised" ? :error : :return)
|
|
82
|
+
found << :jobs unless observation["enqueued_jobs"].to_a.empty?
|
|
83
|
+
found << :mail unless observation["mail_deliveries"].to_a.empty?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
found.uniq & ASPECTS
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def excluded_aspects
|
|
90
|
+
ASPECTS - present_aspects
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def score_aspect(aspect)
|
|
94
|
+
spec = write_variant(aspect)
|
|
95
|
+
|
|
96
|
+
outcome = MutationAdapter.new(
|
|
97
|
+
source_path: @target.file_path, subject: @target.qualified_name,
|
|
98
|
+
cwd: @app_root, test_command: @command
|
|
99
|
+
).score(spec)
|
|
100
|
+
|
|
101
|
+
return Score.new(aspect: aspect, score: nil, verdict: :unscored, killed: nil,
|
|
102
|
+
survived: nil, survivors: [], note: outcome.raw) unless outcome.scored?
|
|
103
|
+
|
|
104
|
+
Score.new(
|
|
105
|
+
aspect: aspect, score: outcome.score, verdict: verdict_for(aspect, outcome.score),
|
|
106
|
+
killed: outcome.killed, survived: outcome.survived, survivors: outcome.survivors,
|
|
107
|
+
note: caveat_for(aspect)
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def verdict_for(aspect, score)
|
|
112
|
+
return :weak if score >= STRONG && understated?(aspect)
|
|
113
|
+
return :strong if score >= STRONG
|
|
114
|
+
return :weak if score >= WEAK
|
|
115
|
+
|
|
116
|
+
:worthless
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def understated?(aspect)
|
|
120
|
+
@stability.stable.any? do |verdict|
|
|
121
|
+
rendered = verdict.observation.to_s
|
|
122
|
+
|
|
123
|
+
rendered.include?('"seq"') || rendered.include?('"truncated"')
|
|
124
|
+
end && %i[return error].include?(aspect)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def caveat_for(aspect)
|
|
128
|
+
return nil unless understated?(aspect)
|
|
129
|
+
|
|
130
|
+
"contains {\"t\":\"seq\"} or a truncated value, so it asserts less than a " \
|
|
131
|
+
"score alone suggests and is not counted as strong"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def generate_wrapper
|
|
135
|
+
return "bundle exec rspec %{files}" if @env.empty?
|
|
136
|
+
|
|
137
|
+
path = File.join(@app_root, WRAPPER)
|
|
138
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
139
|
+
|
|
140
|
+
exports = @env.map { |key, value| "export #{key}=#{value.to_s.inspect}" }.join("\n")
|
|
141
|
+
File.write(path, <<~SH)
|
|
142
|
+
#!/bin/bash
|
|
143
|
+
# Generated by pinspec. Establishes this application's own runtime, because
|
|
144
|
+
# the mutation backend runs in pinspec's Ruby and scrubs inherited pins.
|
|
145
|
+
#{exports}
|
|
146
|
+
exec bundle exec rspec "$@"
|
|
147
|
+
SH
|
|
148
|
+
FileUtils.chmod(0o755, path)
|
|
149
|
+
|
|
150
|
+
"./#{WRAPPER} %{files}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def write_variant(aspect)
|
|
154
|
+
FileUtils.mkdir_p(File.join(@app_root, SCRATCH_DIR))
|
|
155
|
+
|
|
156
|
+
Emit::SpecWriter.new(
|
|
157
|
+
app_root: @app_root, target: @target, plan: @plan, corpus: @corpus,
|
|
158
|
+
stability: @stability, fk_map: @fk_map, force: true,
|
|
159
|
+
only_aspect: aspect, spec_dir: SCRATCH_DIR
|
|
160
|
+
).write!.spec_path
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Pinspec
|
|
7
|
+
module Verify
|
|
8
|
+
class Verifier
|
|
9
|
+
CONFIGS = %i[isolated hostile neighbored].freeze
|
|
10
|
+
|
|
11
|
+
HOSTILE_TZ_CANDIDATES = ["Etc/GMT+8", "Etc/GMT-6"].freeze
|
|
12
|
+
|
|
13
|
+
Outcome = Data.define(:config, :status, :diagnosis, :detail, :examples, :failures) do
|
|
14
|
+
def green?
|
|
15
|
+
status == :green
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_s
|
|
19
|
+
green? ? "#{config}: green (#{examples} examples)" : "#{config}: #{status} (#{diagnosis})"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
DIAGNOSES = [
|
|
24
|
+
[/WebMock::NetConnectNotAllowedError|VCR::Errors/, :unpinnable_http],
|
|
25
|
+
[/captured with TZ=/, :tz_dependent],
|
|
26
|
+
[/uninitialized constant PinspecSerializer|PinspecSerializer.*NoMethodError/, :pinspec_internal],
|
|
27
|
+
[/RecordNotUnique|has already been taken/, :seed_collision],
|
|
28
|
+
[/DatabaseCleaner/, :db_cleaner_strategy],
|
|
29
|
+
[/cannot load such file -- (rails_helper|spec_helper)/, :spec_helper_missing],
|
|
30
|
+
[/LoadError|cannot load such file/, :rails_helper_variance]
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
def initialize(app_root:, spec_path:, env: {}, level: :isolated,
|
|
34
|
+
captured_tz: Runner::Sandbox::FORCED_ENV.fetch("TZ"))
|
|
35
|
+
@app_root = app_root
|
|
36
|
+
@spec_path = spec_path
|
|
37
|
+
@env = env
|
|
38
|
+
@level = level
|
|
39
|
+
@captured_tz = captured_tz
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def configs
|
|
43
|
+
@level == :full ? CONFIGS : [:isolated]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def verify
|
|
47
|
+
configs.map { |config| run(config) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def run(config)
|
|
53
|
+
args, extra_env = arguments_for(config)
|
|
54
|
+
stdout, stderr, _status = Open3.capture3(environment.merge(extra_env), *args, chdir: @app_root)
|
|
55
|
+
|
|
56
|
+
summary = parse(stdout)
|
|
57
|
+
return failure(config, stdout, stderr, summary) if summary.nil? || summary["failure_count"].to_i.positive?
|
|
58
|
+
|
|
59
|
+
if summary["example_count"].to_i.zero?
|
|
60
|
+
return Outcome.new(config: config, status: :failed, diagnosis: :no_examples_ran,
|
|
61
|
+
detail: excerpt(stdout, stderr), examples: 0, failures: 0)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
if summary["errors_outside_of_examples_count"].to_i.positive?
|
|
65
|
+
return Outcome.new(config: config, status: :failed, diagnosis: :spec_load_error,
|
|
66
|
+
detail: excerpt(stdout, stderr),
|
|
67
|
+
examples: summary["example_count"], failures: 0)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
Outcome.new(config: config, status: :green, diagnosis: nil, detail: nil,
|
|
71
|
+
examples: summary["example_count"], failures: 0)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def arguments_for(config)
|
|
75
|
+
relative = @spec_path.sub("#{@app_root}/", "")
|
|
76
|
+
base = ["bundle", "exec", "rspec", "--format", "json"]
|
|
77
|
+
|
|
78
|
+
case config
|
|
79
|
+
when :isolated
|
|
80
|
+
[base + [relative], {}]
|
|
81
|
+
when :hostile
|
|
82
|
+
[base + ["--seed", "7", relative], { "TZ" => hostile_tz, "LANG" => "C", "LC_ALL" => "C" }]
|
|
83
|
+
when :neighbored
|
|
84
|
+
[base + [relative, relative], {}]
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def hostile_tz
|
|
89
|
+
HOSTILE_TZ_CANDIDATES.find { |zone| zone != @captured_tz } || HOSTILE_TZ_CANDIDATES.first
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def environment
|
|
93
|
+
Runner::Sandbox::SCRUBBED_ENV
|
|
94
|
+
.merge("RAILS_ENV" => "test", "DISABLE_SPRING" => "1", "TZ" => @captured_tz)
|
|
95
|
+
.merge(@env)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def parse(stdout)
|
|
99
|
+
json = stdout.to_s.lines.reverse.find { |line| line.strip.start_with?("{") }
|
|
100
|
+
return nil if json.nil?
|
|
101
|
+
|
|
102
|
+
parsed = JSON.parse(json)
|
|
103
|
+
summary = parsed["summary"]
|
|
104
|
+
return nil if summary.nil?
|
|
105
|
+
|
|
106
|
+
summary.merge(
|
|
107
|
+
"errors_outside_of_examples_count" =>
|
|
108
|
+
summary["errors_outside_of_examples_count"] ||
|
|
109
|
+
parsed["errors_outside_of_examples_count"] || 0
|
|
110
|
+
)
|
|
111
|
+
rescue JSON::ParserError
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def failure(config, stdout, stderr, summary)
|
|
116
|
+
text = "#{stdout}\n#{stderr}"
|
|
117
|
+
found = DIAGNOSES.find { |pattern, _| text.match?(pattern) }
|
|
118
|
+
|
|
119
|
+
Outcome.new(
|
|
120
|
+
config: config,
|
|
121
|
+
status: :failed,
|
|
122
|
+
diagnosis: found ? found.last : :unknown,
|
|
123
|
+
detail: excerpt(stdout, stderr),
|
|
124
|
+
examples: summary && summary["example_count"],
|
|
125
|
+
failures: summary && summary["failure_count"]
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def excerpt(stdout, stderr)
|
|
130
|
+
messages = failed_examples(stdout).map do |example|
|
|
131
|
+
"#{example['full_description']}\n#{example.dig('exception', 'message').to_s.lines.first(6).join}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
return messages.first(2).join("\n\n") unless messages.empty?
|
|
135
|
+
|
|
136
|
+
(stderr.to_s.empty? ? stdout.to_s : stderr.to_s).lines.last(20).join
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def failed_examples(stdout)
|
|
140
|
+
json = stdout.to_s.lines.reverse.find { |line| line.strip.start_with?("{") }
|
|
141
|
+
return [] if json.nil?
|
|
142
|
+
|
|
143
|
+
JSON.parse(json)["examples"].to_a.reject { |example| example["status"] == "passed" }
|
|
144
|
+
rescue StandardError
|
|
145
|
+
[]
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
data/lib/pinspec.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zeitwerk"
|
|
4
|
+
|
|
5
|
+
module Pinspec
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
require_relative "pinspec/version"
|
|
9
|
+
require_relative "pinspec/errors"
|
|
10
|
+
require_relative "pinspec/types"
|
|
11
|
+
|
|
12
|
+
loader = Zeitwerk::Loader.for_gem
|
|
13
|
+
loader.inflector.inflect("cli" => "CLI")
|
|
14
|
+
loader.ignore("#{__dir__}/pinspec/version.rb")
|
|
15
|
+
loader.ignore("#{__dir__}/pinspec/errors.rb")
|
|
16
|
+
loader.ignore("#{__dir__}/pinspec/types.rb")
|
|
17
|
+
loader.setup
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# pinspec factory builder - generated. Do not hand-edit: re-run pinspec.
|
|
4
|
+
module PinspecFactory
|
|
5
|
+
MAX_ATTEMPTS = 8
|
|
6
|
+
|
|
7
|
+
def self.attempts
|
|
8
|
+
@attempts ||= {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.reset_attempts!
|
|
12
|
+
@attempts = {}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.default_module
|
|
16
|
+
return FactoryBot if defined?(FactoryBot)
|
|
17
|
+
return FactoryGirl if defined?(FactoryGirl)
|
|
18
|
+
|
|
19
|
+
raise "pinspec: the plan uses factories but neither FactoryBot nor FactoryGirl is loaded"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.create(name, attrs = {}, factory_module = nil)
|
|
23
|
+
factory_module ||= default_module
|
|
24
|
+
last_error = nil
|
|
25
|
+
|
|
26
|
+
1.upto(MAX_ATTEMPTS) do |attempt|
|
|
27
|
+
begin
|
|
28
|
+
record = if attrs.nil? || attrs.empty?
|
|
29
|
+
factory_module.create(name.to_sym)
|
|
30
|
+
else
|
|
31
|
+
factory_module.create(name.to_sym, attrs)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
attempts[name.to_s] = attempt
|
|
35
|
+
return record
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
raise unless retryable?(e)
|
|
38
|
+
|
|
39
|
+
last_error = e
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
attempts[name.to_s] = MAX_ATTEMPTS
|
|
44
|
+
raise last_error
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.retryable?(error)
|
|
48
|
+
defined?(ActiveRecord::RecordInvalid) && error.is_a?(ActiveRecord::RecordInvalid)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.fragile
|
|
52
|
+
attempts.select { |_name, count| count > 1 }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PinspecSerializer
|
|
4
|
+
MAX_DEPTH = 4
|
|
5
|
+
DEFAULT_MAX_COLLECTION = 50
|
|
6
|
+
|
|
7
|
+
VOLATILE = %w[created_at updated_at].freeze
|
|
8
|
+
DB_FUNCTION_DEFAULT = /\A(?:gen_random_uuid|now|uuid_generate_v4|CURRENT_|nextval)/i.freeze
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def normalize(value, refs:, fk_map:, max_collection: DEFAULT_MAX_COLLECTION)
|
|
12
|
+
encode(value, refs: refs, fk_map: fk_map, max_collection: max_collection)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def encode(value, refs: {}, fk_map: {}, max_collection: DEFAULT_MAX_COLLECTION)
|
|
16
|
+
@refs = refs || {}
|
|
17
|
+
@fk_map = fk_map || {}
|
|
18
|
+
@max_collection = max_collection
|
|
19
|
+
@id_index = build_id_index(@refs)
|
|
20
|
+
|
|
21
|
+
visit(value, 0, [])
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
RETURNED_REF = "__returned__"
|
|
25
|
+
|
|
26
|
+
def build_id_index(refs)
|
|
27
|
+
index = {}
|
|
28
|
+
|
|
29
|
+
refs.each do |key, ref|
|
|
30
|
+
id = key.to_s.split(":").last
|
|
31
|
+
next unless id =~ /\A\d+\z/
|
|
32
|
+
|
|
33
|
+
numeric = id.to_i
|
|
34
|
+
|
|
35
|
+
if !index.key?(numeric)
|
|
36
|
+
index[numeric] = ref
|
|
37
|
+
elsif ref == RETURNED_REF || index[numeric] == RETURNED_REF
|
|
38
|
+
index[numeric] = RETURNED_REF
|
|
39
|
+
else
|
|
40
|
+
index[numeric] = :ambiguous
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
index
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def visit(value, depth, seen)
|
|
50
|
+
return { "t" => "truncated" } if depth > MAX_DEPTH
|
|
51
|
+
|
|
52
|
+
case value
|
|
53
|
+
when nil then { "t" => "nil" }
|
|
54
|
+
when true then { "t" => "bool", "v" => true }
|
|
55
|
+
when false then { "t" => "bool", "v" => false }
|
|
56
|
+
when Integer then encode_integer(value)
|
|
57
|
+
when Float then encode_float(value)
|
|
58
|
+
when Symbol then { "t" => "sym", "v" => value.to_s }
|
|
59
|
+
when String then encode_string(value)
|
|
60
|
+
when Array then encode_array(value, depth, seen)
|
|
61
|
+
when Hash then encode_hash(value, depth, seen)
|
|
62
|
+
else encode_object(value, depth, seen)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def encode_integer(value)
|
|
67
|
+
ref = @id_index[value]
|
|
68
|
+
return { "t" => "ref", "v" => ref } if ref && ref != :ambiguous
|
|
69
|
+
|
|
70
|
+
{ "t" => "int", "v" => value }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def encode_float(value)
|
|
74
|
+
return { "t" => "nan" } if value.nan?
|
|
75
|
+
return { "t" => "inf", "sign" => (value < 0 ? -1 : 1) } if value.infinite?
|
|
76
|
+
|
|
77
|
+
{ "t" => "float", "v" => value.round(10) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
GLOBAL_ID = %r{\Agid://[^/]+/([A-Za-z0-9:_]+)/(.+)\z}.freeze
|
|
81
|
+
|
|
82
|
+
def encode_string(value)
|
|
83
|
+
if value.encoding == Encoding::ASCII_8BIT || !value.valid_encoding?
|
|
84
|
+
return { "t" => "bin", "v" => [value].pack("m0"), "enc" => value.encoding.to_s }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
match = GLOBAL_ID.match(value)
|
|
88
|
+
return encode_global_id(match[1], match[2]) if match
|
|
89
|
+
|
|
90
|
+
{ "t" => "str", "v" => value }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def encode_global_id(model, id)
|
|
94
|
+
out = { "t" => "gid", "model" => model }
|
|
95
|
+
|
|
96
|
+
ref = id =~ /\A\d+\z/ ? @id_index[id.to_i] : nil
|
|
97
|
+
if ref && ref != :ambiguous
|
|
98
|
+
out["ref"] = ref
|
|
99
|
+
else
|
|
100
|
+
out["id"] = "seq"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
out
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def encode_array(value, depth, seen)
|
|
107
|
+
if value.length > @max_collection
|
|
108
|
+
return {
|
|
109
|
+
"t" => "relation",
|
|
110
|
+
"total" => value.length,
|
|
111
|
+
"first" => value.first(@max_collection).map { |element| visit(element, depth + 1, seen) }
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
{ "t" => "array", "v" => value.map { |element| visit(element, depth + 1, seen) } }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def encode_hash(value, depth, seen)
|
|
119
|
+
pairs = value.map do |key, element|
|
|
120
|
+
[visit(key, depth + 1, seen), visit(element, depth + 1, seen)]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
{ "t" => "hash", "v" => pairs }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def encode_object(value, depth, seen)
|
|
127
|
+
return { "t" => "cycle", "class" => value.class.name.to_s } if seen.include?(value.object_id)
|
|
128
|
+
|
|
129
|
+
nested = seen + [value.object_id]
|
|
130
|
+
|
|
131
|
+
return encode_record(value, depth, nested) if active_record?(value)
|
|
132
|
+
return encode_relation(value, depth, nested) if relation?(value)
|
|
133
|
+
return { "t" => "decimal", "v" => value.to_s("F") } if big_decimal?(value)
|
|
134
|
+
return encode_time(value) if value.is_a?(Time)
|
|
135
|
+
return encode_time(value.to_time) if defined?(DateTime) && value.is_a?(DateTime)
|
|
136
|
+
return { "t" => "date", "v" => value.strftime("%Y-%m-%d") } if defined?(Date) && value.is_a?(Date)
|
|
137
|
+
return { "t" => "exception", "class" => value.class.name.to_s, "message" => value.message.to_s } if value.is_a?(Exception)
|
|
138
|
+
return { "t" => "unpinnable", "class" => value.class.name.to_s } if unpinnable?(value)
|
|
139
|
+
|
|
140
|
+
encode_plain_object(value, depth, nested)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def encode_time(value)
|
|
144
|
+
{ "t" => "time", "v" => value.getutc.strftime("%Y-%m-%dT%H:%M:%S.%6NZ") }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def encode_record(record, depth, seen)
|
|
148
|
+
table = record.class.table_name
|
|
149
|
+
out = {
|
|
150
|
+
"t" => "record",
|
|
151
|
+
"class" => record.class.name.to_s
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
ref = @refs[identity_key(table, record)]
|
|
155
|
+
out["ref"] = ref if ref
|
|
156
|
+
|
|
157
|
+
attributes = {}
|
|
158
|
+
record.attributes.each do |name, value|
|
|
159
|
+
next if name == record.class.primary_key
|
|
160
|
+
next if VOLATILE.include?(name)
|
|
161
|
+
next if db_function_default?(record.class, name)
|
|
162
|
+
|
|
163
|
+
attributes[name] = encode_attribute(table, name, value, depth, seen)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
out["attributes"] = attributes
|
|
167
|
+
out
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def encode_attribute(table, name, value, depth, seen)
|
|
171
|
+
target = @fk_map["#{table}.#{name}"]
|
|
172
|
+
|
|
173
|
+
if target && !value.nil?
|
|
174
|
+
ref = @refs["#{target}:#{value}"]
|
|
175
|
+
return { "t" => "ref", "v" => ref } if ref
|
|
176
|
+
|
|
177
|
+
return { "t" => "seq" }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
return { "t" => "seq" } if unresolvable_id?(name, value)
|
|
181
|
+
|
|
182
|
+
visit(value, depth + 1, seen)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def unresolvable_id?(name, value)
|
|
186
|
+
return false if value.nil?
|
|
187
|
+
return true if name.to_s =~ /_id\z/ && value.is_a?(Integer)
|
|
188
|
+
return true if name.to_s =~ /_ids\z/ && value.is_a?(Array)
|
|
189
|
+
|
|
190
|
+
false
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def identity_key(table, record)
|
|
194
|
+
"#{table}:#{record.id}"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def db_function_default?(klass, name)
|
|
198
|
+
column = klass.columns_hash[name]
|
|
199
|
+
return false if column.nil? || column.default_function.nil?
|
|
200
|
+
|
|
201
|
+
column.default_function.to_s =~ DB_FUNCTION_DEFAULT ? true : false
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def encode_relation(relation, depth, seen)
|
|
205
|
+
total = relation.size
|
|
206
|
+
rows = relation.first(@max_collection)
|
|
207
|
+
|
|
208
|
+
{
|
|
209
|
+
"t" => "relation",
|
|
210
|
+
"total" => total,
|
|
211
|
+
"first" => rows.map { |row| visit(row, depth + 1, seen) }
|
|
212
|
+
}
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def encode_plain_object(value, depth, seen)
|
|
216
|
+
state = {}
|
|
217
|
+
|
|
218
|
+
value.instance_variables.each do |name|
|
|
219
|
+
state[name.to_s] = visit(value.instance_variable_get(name), depth + 1, seen)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
{ "t" => "object", "class" => value.class.name.to_s, "ivars" => state }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def active_record?(value)
|
|
226
|
+
defined?(ActiveRecord::Base) && value.is_a?(ActiveRecord::Base)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def relation?(value)
|
|
230
|
+
defined?(ActiveRecord::Relation) && value.is_a?(ActiveRecord::Relation)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def big_decimal?(value)
|
|
234
|
+
defined?(BigDecimal) && value.is_a?(BigDecimal)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def unpinnable?(value)
|
|
238
|
+
value.is_a?(Proc) || value.is_a?(IO) ||
|
|
239
|
+
(defined?(Method) && value.is_a?(Method)) ||
|
|
240
|
+
(defined?(Thread) && value.is_a?(Thread))
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|