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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +133 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +183 -0
  5. data/exe/pinspec +8 -0
  6. data/lib/pinspec/analyzer/app_profile_reader.rb +348 -0
  7. data/lib/pinspec/analyzer/factory_registry.rb +288 -0
  8. data/lib/pinspec/analyzer/inflector.rb +85 -0
  9. data/lib/pinspec/analyzer/schema_reader.rb +444 -0
  10. data/lib/pinspec/analyzer/source.rb +56 -0
  11. data/lib/pinspec/analyzer/target_parser.rb +710 -0
  12. data/lib/pinspec/cli.rb +585 -0
  13. data/lib/pinspec/emit/namer.rb +103 -0
  14. data/lib/pinspec/emit/spec_writer.rb +504 -0
  15. data/lib/pinspec/emit/stability_filter.rb +183 -0
  16. data/lib/pinspec/errors.rb +85 -0
  17. data/lib/pinspec/inputs/boundary.rb +112 -0
  18. data/lib/pinspec/inputs/corpus.rb +148 -0
  19. data/lib/pinspec/inputs/hydrator.rb +197 -0
  20. data/lib/pinspec/inputs/redactor.rb +138 -0
  21. data/lib/pinspec/inputs/sample_runner.rb +98 -0
  22. data/lib/pinspec/inputs/sampler.rb +187 -0
  23. data/lib/pinspec/report/summary.rb +348 -0
  24. data/lib/pinspec/runner/capture.rb +127 -0
  25. data/lib/pinspec/runner/probe_generator.rb +662 -0
  26. data/lib/pinspec/runner/sandbox.rb +121 -0
  27. data/lib/pinspec/setup/context_builder.rb +471 -0
  28. data/lib/pinspec/setup/dependency_resolver.rb +236 -0
  29. data/lib/pinspec/tags.rb +103 -0
  30. data/lib/pinspec/types.rb +497 -0
  31. data/lib/pinspec/validate/mutation_adapter.rb +108 -0
  32. data/lib/pinspec/validate/pin_scorer.rb +164 -0
  33. data/lib/pinspec/verify/verifier.rb +149 -0
  34. data/lib/pinspec/version.rb +8 -0
  35. data/lib/pinspec.rb +17 -0
  36. data/templates/factory_build.rb +54 -0
  37. data/templates/serializer.rb +243 -0
  38. data/templates/spec_support.rb +83 -0
  39. 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
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinspec
4
+ VERSION = "0.1.0"
5
+
6
+ PROBE_VERSION = 3
7
+ SERIALIZER_VERSION = 3
8
+ 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