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.
Files changed (76) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +88 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +515 -0
  5. data/exe/constable +7 -0
  6. data/lib/constable/case.rb +336 -0
  7. data/lib/constable/cli.rb +475 -0
  8. data/lib/constable/cold_case/minitest.rb +342 -0
  9. data/lib/constable/cold_case/rspec.rb +334 -0
  10. data/lib/constable/cold_case.rb +280 -0
  11. data/lib/constable/config.rb +125 -0
  12. data/lib/constable/coverage.rb +951 -0
  13. data/lib/constable/diff.rb +212 -0
  14. data/lib/constable/dsl.rb +833 -0
  15. data/lib/constable/identity.rb +121 -0
  16. data/lib/constable/importer/modernizer.rb +860 -0
  17. data/lib/constable/importer/reopener.rb +468 -0
  18. data/lib/constable/importer.rb +51 -0
  19. data/lib/constable/investigation.rb +67 -0
  20. data/lib/constable/isolation.rb +171 -0
  21. data/lib/constable/jail.rb +399 -0
  22. data/lib/constable/log_router.rb +197 -0
  23. data/lib/constable/matchers.rb +834 -0
  24. data/lib/constable/order_audit.rb +130 -0
  25. data/lib/constable/rails_support.rb +213 -0
  26. data/lib/constable/railtie.rb +36 -0
  27. data/lib/constable/registry.rb +57 -0
  28. data/lib/constable/reporter.rb +625 -0
  29. data/lib/constable/result.rb +149 -0
  30. data/lib/constable/runner.rb +697 -0
  31. data/lib/constable/selection.rb +205 -0
  32. data/lib/constable/storage/adapter.rb +91 -0
  33. data/lib/constable/storage/mysql_adapter.rb +125 -0
  34. data/lib/constable/storage/postgres_adapter.rb +125 -0
  35. data/lib/constable/storage/sqlite_adapter.rb +84 -0
  36. data/lib/constable/storage.rb +847 -0
  37. data/lib/constable/version.rb +5 -0
  38. data/lib/constable/warrants.rb +290 -0
  39. data/lib/constable-rails.rb +16 -0
  40. data/lib/constable.rb +151 -0
  41. data/lib/generators/constable/base.rb +99 -0
  42. data/lib/generators/constable/channel/channel_generator.rb +20 -0
  43. data/lib/generators/constable/channel/templates/channel_case.rb.tt +29 -0
  44. data/lib/generators/constable/controller/controller_generator.rb +25 -0
  45. data/lib/generators/constable/controller/templates/controller_case.rb.tt +32 -0
  46. data/lib/generators/constable/generator/generator_generator.rb +31 -0
  47. data/lib/generators/constable/generator/templates/generator_case.rb.tt +28 -0
  48. data/lib/generators/constable/helper/helper_generator.rb +23 -0
  49. data/lib/generators/constable/helper/templates/helper_case.rb.tt +19 -0
  50. data/lib/generators/constable/import_generator.rb +137 -0
  51. data/lib/generators/constable/install_generator.rb +188 -0
  52. data/lib/generators/constable/integration/integration_generator.rb +27 -0
  53. data/lib/generators/constable/integration/templates/request_case.rb.tt +22 -0
  54. data/lib/generators/constable/job/job_generator.rb +20 -0
  55. data/lib/generators/constable/job/templates/job_case.rb.tt +33 -0
  56. data/lib/generators/constable/mailbox/mailbox_generator.rb +20 -0
  57. data/lib/generators/constable/mailbox/templates/mailbox_case.rb.tt +26 -0
  58. data/lib/generators/constable/mailer/mailer_generator.rb +32 -0
  59. data/lib/generators/constable/mailer/templates/mailer_case.rb.tt +34 -0
  60. data/lib/generators/constable/mailer/templates/preview.rb.tt +14 -0
  61. data/lib/generators/constable/model/model_generator.rb +31 -0
  62. data/lib/generators/constable/model/templates/model_case.rb.tt +37 -0
  63. data/lib/generators/constable/resource/resource_generator.rb +27 -0
  64. data/lib/generators/constable/scaffold/scaffold_generator.rb +42 -0
  65. data/lib/generators/constable/scaffold/templates/api_controller_case.rb.tt +54 -0
  66. data/lib/generators/constable/scaffold/templates/controller_case.rb.tt +70 -0
  67. data/lib/generators/constable/scaffold/templates/system_case.rb.tt +53 -0
  68. data/lib/generators/constable/system/system_generator.rb +20 -0
  69. data/lib/generators/constable/system/templates/system_case.rb.tt +18 -0
  70. data/lib/generators/constable/templates/authenticatable.rb.tt +31 -0
  71. data/lib/generators/constable/templates/case_helper.rb.tt +179 -0
  72. data/lib/generators/constable/templates/config.yml.tt +67 -0
  73. data/lib/generators/constable/templates/example_case.rb.tt +56 -0
  74. data/lib/generators/constable/templates/matchers.rb.tt +36 -0
  75. data/lib/generators/constable/templates/rubocop.yml.tt +12 -0
  76. 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