constable-rails 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 +88 -0
- data/LICENSE.txt +21 -0
- data/README.md +515 -0
- data/exe/constable +7 -0
- data/lib/constable/case.rb +336 -0
- data/lib/constable/cli.rb +475 -0
- data/lib/constable/cold_case/minitest.rb +342 -0
- data/lib/constable/cold_case/rspec.rb +334 -0
- data/lib/constable/cold_case.rb +280 -0
- data/lib/constable/config.rb +125 -0
- data/lib/constable/coverage.rb +951 -0
- data/lib/constable/diff.rb +212 -0
- data/lib/constable/dsl.rb +833 -0
- data/lib/constable/identity.rb +121 -0
- data/lib/constable/importer/modernizer.rb +860 -0
- data/lib/constable/importer/reopener.rb +468 -0
- data/lib/constable/importer.rb +51 -0
- data/lib/constable/investigation.rb +67 -0
- data/lib/constable/isolation.rb +171 -0
- data/lib/constable/jail.rb +399 -0
- data/lib/constable/log_router.rb +197 -0
- data/lib/constable/matchers.rb +834 -0
- data/lib/constable/order_audit.rb +130 -0
- data/lib/constable/rails_support.rb +213 -0
- data/lib/constable/railtie.rb +36 -0
- data/lib/constable/registry.rb +57 -0
- data/lib/constable/reporter.rb +625 -0
- data/lib/constable/result.rb +149 -0
- data/lib/constable/runner.rb +697 -0
- data/lib/constable/selection.rb +205 -0
- data/lib/constable/storage/adapter.rb +91 -0
- data/lib/constable/storage/mysql_adapter.rb +125 -0
- data/lib/constable/storage/postgres_adapter.rb +125 -0
- data/lib/constable/storage/sqlite_adapter.rb +84 -0
- data/lib/constable/storage.rb +847 -0
- data/lib/constable/version.rb +5 -0
- data/lib/constable/warrants.rb +290 -0
- data/lib/constable-rails.rb +16 -0
- data/lib/constable.rb +151 -0
- data/lib/generators/constable/base.rb +99 -0
- data/lib/generators/constable/channel/channel_generator.rb +20 -0
- data/lib/generators/constable/channel/templates/channel_case.rb.tt +29 -0
- data/lib/generators/constable/controller/controller_generator.rb +25 -0
- data/lib/generators/constable/controller/templates/controller_case.rb.tt +32 -0
- data/lib/generators/constable/generator/generator_generator.rb +31 -0
- data/lib/generators/constable/generator/templates/generator_case.rb.tt +28 -0
- data/lib/generators/constable/helper/helper_generator.rb +23 -0
- data/lib/generators/constable/helper/templates/helper_case.rb.tt +19 -0
- data/lib/generators/constable/import_generator.rb +137 -0
- data/lib/generators/constable/install_generator.rb +188 -0
- data/lib/generators/constable/integration/integration_generator.rb +27 -0
- data/lib/generators/constable/integration/templates/request_case.rb.tt +22 -0
- data/lib/generators/constable/job/job_generator.rb +20 -0
- data/lib/generators/constable/job/templates/job_case.rb.tt +33 -0
- data/lib/generators/constable/mailbox/mailbox_generator.rb +20 -0
- data/lib/generators/constable/mailbox/templates/mailbox_case.rb.tt +26 -0
- data/lib/generators/constable/mailer/mailer_generator.rb +32 -0
- data/lib/generators/constable/mailer/templates/mailer_case.rb.tt +34 -0
- data/lib/generators/constable/mailer/templates/preview.rb.tt +14 -0
- data/lib/generators/constable/model/model_generator.rb +31 -0
- data/lib/generators/constable/model/templates/model_case.rb.tt +37 -0
- data/lib/generators/constable/resource/resource_generator.rb +27 -0
- data/lib/generators/constable/scaffold/scaffold_generator.rb +42 -0
- data/lib/generators/constable/scaffold/templates/api_controller_case.rb.tt +54 -0
- data/lib/generators/constable/scaffold/templates/controller_case.rb.tt +70 -0
- data/lib/generators/constable/scaffold/templates/system_case.rb.tt +53 -0
- data/lib/generators/constable/system/system_generator.rb +20 -0
- data/lib/generators/constable/system/templates/system_case.rb.tt +18 -0
- data/lib/generators/constable/templates/authenticatable.rb.tt +31 -0
- data/lib/generators/constable/templates/case_helper.rb.tt +179 -0
- data/lib/generators/constable/templates/config.yml.tt +67 -0
- data/lib/generators/constable/templates/example_case.rb.tt +56 -0
- data/lib/generators/constable/templates/matchers.rb.tt +36 -0
- data/lib/generators/constable/templates/rubocop.yml.tt +12 -0
- metadata +209 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Constable
|
|
4
|
+
# Decides what a given `constable test` invocation actually runs.
|
|
5
|
+
#
|
|
6
|
+
# The default with no arguments is deliberately *not* the whole suite: locally, the
|
|
7
|
+
# interesting tests are the ones covering what you just changed, so the default is
|
|
8
|
+
# git-diff scoped. CI passes --full and gets everything, every time. When git can't
|
|
9
|
+
# answer -- no repo, no commits, a detached checkout -- the answer is the full suite
|
|
10
|
+
# rather than a confidently empty one. Running too much is a slow day; running too
|
|
11
|
+
# little is a false green.
|
|
12
|
+
class Selection
|
|
13
|
+
# A single resolved target: a file, optionally narrowed to one investigation by line.
|
|
14
|
+
Target = Struct.new(:path, :line, :kind, keyword_init: true) do
|
|
15
|
+
def native? = kind == :native
|
|
16
|
+
def cold? = kind == :cold
|
|
17
|
+
def to_s = line ? "#{path}:#{line}" : path.to_s
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
NATIVE_GLOBS = [
|
|
21
|
+
"test/cases/**/*.rb",
|
|
22
|
+
"spec/cases/**/*.rb",
|
|
23
|
+
"test/**/*_case.rb",
|
|
24
|
+
"spec/**/*_case.rb"
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
attr_reader :config, :root, :args, :reason
|
|
28
|
+
|
|
29
|
+
def initialize(args = [], config: Constable.config, root: Constable.root,
|
|
30
|
+
full: false, unsafe_only: false, tier: nil)
|
|
31
|
+
@args = Array(args)
|
|
32
|
+
@config = config
|
|
33
|
+
@root = root.to_s
|
|
34
|
+
@full = full
|
|
35
|
+
@unsafe_only = unsafe_only
|
|
36
|
+
@tier = tier&.to_sym
|
|
37
|
+
@reason = nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def full? = @full
|
|
41
|
+
def unsafe_only? = @unsafe_only
|
|
42
|
+
|
|
43
|
+
# => [Target]
|
|
44
|
+
def targets
|
|
45
|
+
@targets ||= begin
|
|
46
|
+
list =
|
|
47
|
+
if @args.any?
|
|
48
|
+
explicit_targets
|
|
49
|
+
elsif @unsafe_only
|
|
50
|
+
cold_targets
|
|
51
|
+
elsif @full
|
|
52
|
+
all_targets
|
|
53
|
+
else
|
|
54
|
+
diff_targets
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
list = list.select(&:cold?) if @unsafe_only
|
|
58
|
+
list = list.select { |t| tier_matches?(t) } if @tier
|
|
59
|
+
list.uniq { |t| [t.path, t.line] }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def native_targets = targets.select(&:native?)
|
|
64
|
+
def cold_targets_selected = targets.select(&:cold?)
|
|
65
|
+
def empty? = targets.empty?
|
|
66
|
+
|
|
67
|
+
# A specific investigation was named (PATH:LINE), so only that one should run.
|
|
68
|
+
def line_filter_for(path)
|
|
69
|
+
targets.select { |t| t.path == path && t.line }.map(&:line)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# "spec/cases/sessions_case.rb:12" -- a file, or one investigation inside it.
|
|
75
|
+
def explicit_targets
|
|
76
|
+
@reason = "explicit paths"
|
|
77
|
+
@args.flat_map do |arg|
|
|
78
|
+
path, line = split_line(arg)
|
|
79
|
+
absolute = absolutize(path)
|
|
80
|
+
|
|
81
|
+
if File.directory?(absolute)
|
|
82
|
+
files_under(absolute).map { |f| target_for(f, nil) }
|
|
83
|
+
else
|
|
84
|
+
[target_for(absolute, line)]
|
|
85
|
+
end
|
|
86
|
+
end.compact
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def all_targets
|
|
90
|
+
@reason = "full suite"
|
|
91
|
+
(native_files + cold_files).map { |f| target_for(f, nil) }.compact
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def cold_targets
|
|
95
|
+
@reason = "cold cases only"
|
|
96
|
+
cold_files.map { |f| Target.new(path: f, line: nil, kind: :cold) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Changed files map to their own case files plus any case file that looks like it
|
|
100
|
+
# covers them (app/models/user.rb -> **/user_case.rb, **/users_*_case.rb).
|
|
101
|
+
def diff_targets
|
|
102
|
+
unless Diff.available?(root: @root)
|
|
103
|
+
@reason = "full suite (git unavailable)"
|
|
104
|
+
return all_targets
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
changed = Diff.changed_files(root: @root)
|
|
108
|
+
if changed.empty?
|
|
109
|
+
@reason = "full suite (no changes detected)"
|
|
110
|
+
return all_targets
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
matched = changed.flat_map { |file| cases_covering(file) }.uniq
|
|
114
|
+
if matched.empty?
|
|
115
|
+
@reason = "full suite (no cases matched the diff)"
|
|
116
|
+
return all_targets
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
@reason = "#{matched.size} #{matched.size == 1 ? "case" : "cases"} touched by the diff"
|
|
120
|
+
matched.map { |f| target_for(f, nil) }.compact
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Maps one changed source file to the case files that plausibly exercise it. A changed
|
|
124
|
+
# test file is itself a target; a changed app file is matched by name.
|
|
125
|
+
def cases_covering(changed_file)
|
|
126
|
+
absolute = absolutize(changed_file)
|
|
127
|
+
known = native_files + cold_files
|
|
128
|
+
return [absolute] if known.include?(absolute)
|
|
129
|
+
|
|
130
|
+
stem = File.basename(changed_file, ".rb")
|
|
131
|
+
return [] if stem.empty?
|
|
132
|
+
|
|
133
|
+
singular = stem.sub(/s\z/, "")
|
|
134
|
+
known.select do |case_file|
|
|
135
|
+
base = File.basename(case_file, ".rb")
|
|
136
|
+
base.start_with?(stem) || base.start_with?(singular) ||
|
|
137
|
+
base.sub(/_(case|spec|test)\z/, "") == stem
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def target_for(file, line)
|
|
142
|
+
return nil unless File.file?(file)
|
|
143
|
+
|
|
144
|
+
Target.new(path: file, line: line, kind: kind_of(file))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def kind_of(file)
|
|
148
|
+
return :cold if @config.cold_case?(file)
|
|
149
|
+
return :cold if cold_by_content?(file)
|
|
150
|
+
|
|
151
|
+
:native
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# A file that declares itself a ColdCase is one, whatever the globs say.
|
|
155
|
+
def cold_by_content?(file)
|
|
156
|
+
head = File.foreach(file).first(40).join
|
|
157
|
+
head.include?("Constable::ColdCase")
|
|
158
|
+
rescue StandardError
|
|
159
|
+
false
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def native_files
|
|
163
|
+
@native_files ||= glob(NATIVE_GLOBS).reject { |f| @config.cold_case?(f) }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def cold_files
|
|
167
|
+
@cold_files ||= begin
|
|
168
|
+
from_config = glob(@config.cold_cases)
|
|
169
|
+
declared = glob(["test/**/*_spec.rb", "spec/**/*_spec.rb", "test/**/*_test.rb"])
|
|
170
|
+
.select { |f| cold_by_content?(f) }
|
|
171
|
+
(from_config + declared).uniq
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def files_under(dir)
|
|
176
|
+
Dir.glob(File.join(dir, "**", "*.rb"))
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def glob(patterns)
|
|
180
|
+
Array(patterns).flat_map { |p| Dir.glob(File.join(@root, p.to_s)) }
|
|
181
|
+
.select { |f| File.file?(f) }
|
|
182
|
+
.sort
|
|
183
|
+
.uniq
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def tier_matches?(target)
|
|
187
|
+
return true unless @tier
|
|
188
|
+
|
|
189
|
+
@config.tier_for(target.path) == @tier
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# "path/to/file.rb:12" -> ["path/to/file.rb", 12]
|
|
193
|
+
def split_line(arg)
|
|
194
|
+
if (match = arg.to_s.match(/\A(.*?):(\d+)\z/))
|
|
195
|
+
[match[1], match[2].to_i]
|
|
196
|
+
else
|
|
197
|
+
[arg.to_s, nil]
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def absolutize(path)
|
|
202
|
+
File.absolute_path?(path) ? path : File.expand_path(path, @root)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Constable
|
|
4
|
+
module Storage
|
|
5
|
+
# The pluggable storage interface -- "the blotter".
|
|
6
|
+
#
|
|
7
|
+
# All operational data (flake history, jail/parole state, warrants) lives in a store
|
|
8
|
+
# Constable owns entirely. Never the app's real database, and never the app's
|
|
9
|
+
# transactional test connection, for three reasons:
|
|
10
|
+
#
|
|
11
|
+
# 1. Native cases wrap each test in a rolled-back transaction. Writing through the
|
|
12
|
+
# app's connection would roll this data back along with everything else.
|
|
13
|
+
# 2. :unit-tier runs skip booting the Rails/DB stack for speed. Requiring a live
|
|
14
|
+
# Postgres just to record "did this test pass" would undo that.
|
|
15
|
+
# 3. The workload is a handful of tables and ~one row per test per run. It does not
|
|
16
|
+
# need a client-server database.
|
|
17
|
+
#
|
|
18
|
+
# Adapters must be safe to open from the parent process; workers never write. Results
|
|
19
|
+
# travel back over a pipe and the parent is the only writer, which keeps every adapter
|
|
20
|
+
# free of cross-process write contention.
|
|
21
|
+
class Adapter
|
|
22
|
+
class NotSupported < StandardError; end
|
|
23
|
+
|
|
24
|
+
attr_reader :config
|
|
25
|
+
|
|
26
|
+
def initialize(config)
|
|
27
|
+
@config = config
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.build(config)
|
|
31
|
+
case config.storage_adapter.to_s
|
|
32
|
+
when "sqlite", "sqlite3", nil, "" then SqliteAdapter.new(config)
|
|
33
|
+
when "postgres", "postgresql" then PostgresAdapter.new(config)
|
|
34
|
+
when "mysql", "mysql2" then MysqlAdapter.new(config)
|
|
35
|
+
else
|
|
36
|
+
raise ArgumentError, "unknown storage adapter #{config.storage_adapter.inspect} " \
|
|
37
|
+
"(expected one of: sqlite, postgres, mysql)"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# --- lifecycle -------------------------------------------------------------
|
|
42
|
+
def setup! = raise(NotImplementedError, "#{self.class}#setup!")
|
|
43
|
+
def close = nil
|
|
44
|
+
def reset! = raise(NotImplementedError, "#{self.class}#reset!")
|
|
45
|
+
|
|
46
|
+
# --- runs ------------------------------------------------------------------
|
|
47
|
+
def start_run(seed:, mode:, full:) = raise(NotImplementedError)
|
|
48
|
+
def finish_run(run_id, totals:) = raise(NotImplementedError)
|
|
49
|
+
def runs(limit: 30) = raise(NotImplementedError)
|
|
50
|
+
|
|
51
|
+
# --- flake history ---------------------------------------------------------
|
|
52
|
+
# Every test's pass/fail result is recorded, native and cold alike.
|
|
53
|
+
def record_result(run_id, result) = raise(NotImplementedError)
|
|
54
|
+
def history_for(identity, limit: 50) = raise(NotImplementedError)
|
|
55
|
+
def last_status(identity) = raise(NotImplementedError)
|
|
56
|
+
def known_identities = raise(NotImplementedError)
|
|
57
|
+
# Native-vs-cold counts per run, newest run first. Backs the trend in `constable
|
|
58
|
+
# status`, which is the one place the split between the two matters over time.
|
|
59
|
+
def kind_totals(limit: 30) = raise(NotImplementedError)
|
|
60
|
+
def relink(old_identity, new_identity) = raise(NotImplementedError)
|
|
61
|
+
|
|
62
|
+
# --- jail docket -----------------------------------------------------------
|
|
63
|
+
def jail(identity, label:, file:, line:, reason:) = raise(NotImplementedError)
|
|
64
|
+
def jailed = raise(NotImplementedError)
|
|
65
|
+
def jail_entry(identity) = raise(NotImplementedError)
|
|
66
|
+
def jailed?(identity) = !jail_entry(identity).nil?
|
|
67
|
+
def parole(identity) = raise(NotImplementedError)
|
|
68
|
+
def release(identity) = raise(NotImplementedError)
|
|
69
|
+
def record_parole_pass(identity) = raise(NotImplementedError)
|
|
70
|
+
def record_parole_violation(identity) = raise(NotImplementedError)
|
|
71
|
+
def paroled = raise(NotImplementedError)
|
|
72
|
+
|
|
73
|
+
# --- warrants --------------------------------------------------------------
|
|
74
|
+
def issue_warrant(identity, label:, file:, line:, reason: nil) = raise(NotImplementedError)
|
|
75
|
+
def warrants = raise(NotImplementedError)
|
|
76
|
+
def warrant_entry(identity) = raise(NotImplementedError)
|
|
77
|
+
def warranted?(identity) = !warrant_entry(identity).nil?
|
|
78
|
+
def clear_warrant(identity) = raise(NotImplementedError)
|
|
79
|
+
def touch_warrant(identity, cleared:) = raise(NotImplementedError)
|
|
80
|
+
|
|
81
|
+
# --- durations (worker load balancing, slowest list) -----------------------
|
|
82
|
+
def record_duration(identity, duration) = raise(NotImplementedError)
|
|
83
|
+
def duration_index = raise(NotImplementedError)
|
|
84
|
+
def slowest(limit: 10) = raise(NotImplementedError)
|
|
85
|
+
|
|
86
|
+
# --- coverage --------------------------------------------------------------
|
|
87
|
+
def record_coverage(run_id, percent:, files:) = raise(NotImplementedError)
|
|
88
|
+
def coverage_trend(limit: 30) = raise(NotImplementedError)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require "constable/storage"
|
|
5
|
+
|
|
6
|
+
module Constable
|
|
7
|
+
module Storage
|
|
8
|
+
# Optional blotter, same trade-off as the Postgres adapter: worth it only when one
|
|
9
|
+
# store has to be shared across many CI machines. Locally, SQLite wins on every axis.
|
|
10
|
+
#
|
|
11
|
+
# == A separate connection, always
|
|
12
|
+
#
|
|
13
|
+
# This adapter opens its *own* connection from +storage.url+ and never borrows
|
|
14
|
+
# ActiveRecord's. The reasoning is the same as everywhere else in the storage layer:
|
|
15
|
+
#
|
|
16
|
+
# 1. Native cases run inside a transaction that is rolled back after every test.
|
|
17
|
+
# Writing the blotter through the app's connection would roll the blotter back
|
|
18
|
+
# too, and flake history would never survive a run.
|
|
19
|
+
# 2. :unit-tier runs never boot the DB stack, so there may be no app connection at
|
|
20
|
+
# all to borrow.
|
|
21
|
+
# 3. Reloading the app's test schema must never take the jail docket with it.
|
|
22
|
+
#
|
|
23
|
+
# The +mysql2+ gem is not a dependency of constable-rails; it is required lazily so
|
|
24
|
+
# nobody installs a driver they do not use.
|
|
25
|
+
class MysqlAdapter < RelationalAdapter
|
|
26
|
+
# MySQL's "index already exists". CREATE INDEX has no IF NOT EXISTS in MySQL, so an
|
|
27
|
+
# idempotent setup! means creating and forgiving the duplicate.
|
|
28
|
+
DUPLICATE_INDEX = 1061
|
|
29
|
+
|
|
30
|
+
def close
|
|
31
|
+
@connection&.close
|
|
32
|
+
super
|
|
33
|
+
rescue StandardError
|
|
34
|
+
super
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def connect!
|
|
40
|
+
# Config first, driver second: a missing url is a typo in config.yml and worth
|
|
41
|
+
# saying so before we go looking for a gem.
|
|
42
|
+
url = config.storage_url
|
|
43
|
+
if url.nil? || url.to_s.empty?
|
|
44
|
+
raise Constable::ConfigurationError,
|
|
45
|
+
"storage.adapter is \"mysql\" but storage.url is not set in .constable/config.yml " \
|
|
46
|
+
"(expected something like mysql2://user:pass@host/constable_metadata -- and it must " \
|
|
47
|
+
"point at a database separate from your app's, never the app's test database)"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
require_driver!
|
|
51
|
+
|
|
52
|
+
# Deliberately Mysql2::Client and not ActiveRecord: this connection lives outside
|
|
53
|
+
# the app's rolled-back test transaction. See the class comment.
|
|
54
|
+
@connection = Mysql2::Client.new(**connection_options(url.to_s))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def require_driver!
|
|
58
|
+
require "mysql2"
|
|
59
|
+
rescue LoadError => e
|
|
60
|
+
raise Constable::ConfigurationError,
|
|
61
|
+
"the mysql2 gem is required for Constable's mysql storage adapter " \
|
|
62
|
+
"(add `gem \"mysql2\"` to your Gemfile and run `bundle install`, or set " \
|
|
63
|
+
"storage.adapter back to \"sqlite\" in .constable/config.yml) -- #{e.message}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def connection_options(url)
|
|
67
|
+
uri = URI.parse(url)
|
|
68
|
+
{
|
|
69
|
+
host: uri.host || "127.0.0.1",
|
|
70
|
+
port: uri.port || 3306,
|
|
71
|
+
username: uri.user && URI.decode_www_form_component(uri.user),
|
|
72
|
+
password: uri.password && URI.decode_www_form_component(uri.password),
|
|
73
|
+
database: uri.path.to_s.delete_prefix("/"),
|
|
74
|
+
cast_booleans: false,
|
|
75
|
+
reconnect: true
|
|
76
|
+
}.compact
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def types
|
|
80
|
+
super.merge(
|
|
81
|
+
pk: "BIGINT AUTO_INCREMENT PRIMARY KEY",
|
|
82
|
+
int: "BIGINT",
|
|
83
|
+
float: "DOUBLE"
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def create_index(name, table, columns)
|
|
88
|
+
execute_raw("CREATE INDEX #{name} ON #{table} (#{columns.join(", ")})")
|
|
89
|
+
rescue StandardError => e
|
|
90
|
+
# Mysql2::Error::ConnectionError et al. all respond to #error_number.
|
|
91
|
+
raise unless e.respond_to?(:error_number) && e.error_number == DUPLICATE_INDEX
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def execute_raw(sql)
|
|
95
|
+
connection.query(sql)
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def query(sql, binds = [])
|
|
100
|
+
return connection.query(sql).to_a if binds.empty?
|
|
101
|
+
|
|
102
|
+
connection.prepare(sql).execute(*binds).to_a
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def execute(sql, binds = [])
|
|
106
|
+
if binds.empty?
|
|
107
|
+
connection.query(sql)
|
|
108
|
+
else
|
|
109
|
+
connection.prepare(sql).execute(*binds)
|
|
110
|
+
end
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def insert(sql, binds, _table)
|
|
115
|
+
execute(sql, binds)
|
|
116
|
+
connection.last_id
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def connection
|
|
120
|
+
connect! unless @connection
|
|
121
|
+
@connection
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "constable/storage"
|
|
4
|
+
|
|
5
|
+
module Constable
|
|
6
|
+
module Storage
|
|
7
|
+
# Optional blotter for teams that genuinely need one queryable store shared across
|
|
8
|
+
# many CI machines -- a cross-repo flaky-test dashboard, which is a different problem
|
|
9
|
+
# from local bookkeeping. For everything else the default SQLite adapter is faster,
|
|
10
|
+
# simpler and dependency-free.
|
|
11
|
+
#
|
|
12
|
+
# == A separate connection, always
|
|
13
|
+
#
|
|
14
|
+
# This adapter opens its *own* connection from +storage.url+ and never touches
|
|
15
|
+
# ActiveRecord, the app's connection pool, or the app's database. That is not a style
|
|
16
|
+
# preference, it is the whole reason the blotter exists as a separate store:
|
|
17
|
+
#
|
|
18
|
+
# 1. Native cases wrap each test in a transaction that is rolled back afterwards.
|
|
19
|
+
# Writing flake history through the app's connection would enlist it in that
|
|
20
|
+
# transaction and roll the history back along with the test's fixtures -- the
|
|
21
|
+
# blotter would come out of every run empty.
|
|
22
|
+
# 2. :unit-tier runs skip booting the Rails/DB stack entirely. There may not *be* an
|
|
23
|
+
# app connection to borrow.
|
|
24
|
+
# 3. Even pointed at the same server, this must be a separate database (or at least
|
|
25
|
+
# a separate connection) so that dropping and reloading the test schema never
|
|
26
|
+
# takes the flake history with it.
|
|
27
|
+
#
|
|
28
|
+
# The +pg+ gem is not a dependency of constable-rails; it is required lazily, right
|
|
29
|
+
# where it is needed, so nobody pays for a driver they did not ask for.
|
|
30
|
+
class PostgresAdapter < RelationalAdapter
|
|
31
|
+
def close
|
|
32
|
+
@connection&.close
|
|
33
|
+
super
|
|
34
|
+
rescue StandardError
|
|
35
|
+
super
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def connect!
|
|
41
|
+
# Config first, driver second: a missing url is a typo in config.yml and worth
|
|
42
|
+
# saying so before we go looking for a gem.
|
|
43
|
+
url = config.storage_url
|
|
44
|
+
if url.nil? || url.to_s.empty?
|
|
45
|
+
raise Constable::ConfigurationError,
|
|
46
|
+
"storage.adapter is \"postgres\" but storage.url is not set in .constable/config.yml " \
|
|
47
|
+
"(expected something like postgres://user:pass@host/constable_metadata -- and it must " \
|
|
48
|
+
"point at a database separate from your app's, never the app's test database)"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
require_driver!
|
|
52
|
+
|
|
53
|
+
# Deliberately PG::Connection.new and not ActiveRecord: this connection must be
|
|
54
|
+
# outside the app's rolled-back test transaction. See the class comment.
|
|
55
|
+
@connection = PG::Connection.new(url.to_s)
|
|
56
|
+
# "relation already exists, skipping" on every idempotent setup! would otherwise
|
|
57
|
+
# land on stderr. stdout and stderr belong to the run's results.
|
|
58
|
+
@connection.exec("SET client_min_messages TO warning")
|
|
59
|
+
@connection
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def require_driver!
|
|
63
|
+
require "pg"
|
|
64
|
+
rescue LoadError => e
|
|
65
|
+
raise Constable::ConfigurationError,
|
|
66
|
+
"the pg gem is required for Constable's postgres storage adapter " \
|
|
67
|
+
"(add `gem \"pg\"` to your Gemfile and run `bundle install`, or set " \
|
|
68
|
+
"storage.adapter back to \"sqlite\" in .constable/config.yml) -- #{e.message}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def types
|
|
72
|
+
super.merge(
|
|
73
|
+
pk: "BIGSERIAL PRIMARY KEY",
|
|
74
|
+
int: "BIGINT",
|
|
75
|
+
float: "DOUBLE PRECISION"
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def execute_raw(sql)
|
|
80
|
+
connection.exec(sql)
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def query(sql, binds = [])
|
|
85
|
+
connection.exec_params(to_pg(sql), binds.map { |b| cast_bind(b) }).to_a
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def execute(sql, binds = [])
|
|
89
|
+
connection.exec_params(to_pg(sql), binds.map { |b| cast_bind(b) })
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Postgres has no "last insert id" on the connection, so the id comes back from the
|
|
94
|
+
# statement itself -- but only the tables that actually have a generated id.
|
|
95
|
+
def insert(sql, binds, table)
|
|
96
|
+
sql = to_pg(sql)
|
|
97
|
+
sql = "#{sql} RETURNING id" if AUTO_ID_TABLES.include?(table)
|
|
98
|
+
result = connection.exec_params(sql, binds.map { |b| cast_bind(b) })
|
|
99
|
+
AUTO_ID_TABLES.include?(table) ? result.first&.fetch("id", nil)&.to_i : nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Postgres numbers its placeholders. The SQL in RelationalAdapter is written with
|
|
103
|
+
# "?" (no literal question marks appear in it), so a positional rewrite is exact.
|
|
104
|
+
def to_pg(sql)
|
|
105
|
+
index = 0
|
|
106
|
+
sql.gsub("?") { "$#{index += 1}" }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# exec_params sends every bind as text; booleans need spelling out, everything else
|
|
110
|
+
# is fine as its default to_s. Values are still bound, never interpolated.
|
|
111
|
+
def cast_bind(value)
|
|
112
|
+
case value
|
|
113
|
+
when true then 1
|
|
114
|
+
when false then 0
|
|
115
|
+
else value
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def connection
|
|
120
|
+
connect! unless @connection
|
|
121
|
+
@connection
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "constable/storage"
|
|
5
|
+
|
|
6
|
+
module Constable
|
|
7
|
+
module Storage
|
|
8
|
+
# The default blotter: a single self-contained file at .constable/constable.sqlite3.
|
|
9
|
+
#
|
|
10
|
+
# SQLite is the right shape for this workload and the spec says why: a handful of
|
|
11
|
+
# tables, roughly one row per test per run, a dozen-ish workers. It also keeps the
|
|
12
|
+
# promise that a :unit-tier run boots without a database server -- requiring a live
|
|
13
|
+
# Postgres just to record "did this test pass" would undo the speed it is there for.
|
|
14
|
+
#
|
|
15
|
+
# WAL mode plus a busy_timeout is belt and braces rather than necessity: only the
|
|
16
|
+
# parent process writes (workers ship results back over a pipe), but a reader that
|
|
17
|
+
# wanders in -- an editor plugin, a second terminal running `constable jail` -- should
|
|
18
|
+
# never see "database is locked" while a run is writing.
|
|
19
|
+
class SqliteAdapter < RelationalAdapter
|
|
20
|
+
# How long a statement waits on a lock before giving up. Generous, because the only
|
|
21
|
+
# thing it ever waits on is a concurrent *reader* finishing.
|
|
22
|
+
BUSY_TIMEOUT_MS = 5_000
|
|
23
|
+
|
|
24
|
+
def path = config.storage_path
|
|
25
|
+
|
|
26
|
+
# Drops the handle as well as closing it, so a later query reconnects instead of
|
|
27
|
+
# reaching for a closed database. The Runner closes before forking workers, because
|
|
28
|
+
# SQLite is explicit that a connection must not be carried across a fork.
|
|
29
|
+
def close
|
|
30
|
+
@connection.close if @connection && !@connection.closed?
|
|
31
|
+
@connection = nil
|
|
32
|
+
super
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def connect!
|
|
38
|
+
require_driver!
|
|
39
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
40
|
+
@connection = SQLite3::Database.new(path)
|
|
41
|
+
@connection.results_as_hash = true
|
|
42
|
+
@connection.busy_timeout = BUSY_TIMEOUT_MS
|
|
43
|
+
# Readers never block the writer and the writer never blocks readers.
|
|
44
|
+
@connection.execute("PRAGMA journal_mode = WAL")
|
|
45
|
+
# WAL + NORMAL is durable across process crashes, which is the only failure that
|
|
46
|
+
# matters here; a machine losing power mid-run costs us one run's bookkeeping.
|
|
47
|
+
@connection.execute("PRAGMA synchronous = NORMAL")
|
|
48
|
+
@connection
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def require_driver!
|
|
52
|
+
require "sqlite3"
|
|
53
|
+
rescue LoadError => e
|
|
54
|
+
raise Constable::ConfigurationError,
|
|
55
|
+
"the sqlite3 gem is required for Constable's default storage adapter " \
|
|
56
|
+
"(add `gem \"sqlite3\"` to your Gemfile and run `bundle install`) -- #{e.message}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def execute_raw(sql)
|
|
60
|
+
connection.execute(sql)
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def query(sql, binds = [])
|
|
65
|
+
connection.execute(sql, binds)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def execute(sql, binds = [])
|
|
69
|
+
connection.execute(sql, binds)
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def insert(sql, binds, _table)
|
|
74
|
+
connection.execute(sql, binds)
|
|
75
|
+
connection.last_insert_row_id
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def connection
|
|
79
|
+
connect! if @connection.nil? || @connection.closed?
|
|
80
|
+
@connection
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|