mutineer 1.5.0 → 1.7.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.
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+ require "uri"
5
+
3
6
  module Mutineer
4
7
  # Per-worker database isolation for the daemon path.
5
8
  #
@@ -13,17 +16,87 @@ module Mutineer
13
16
  # Isolation model: each parallel worker gets its OWN database so concurrent
14
17
  # forks cannot clobber each other's transactional fixtures. {after_fork} runs
15
18
  # inside a freshly-forked child and points that child's connection at the
16
- # worker's database BEFORE any test loads; transactional fixtures then
17
- # repopulate that isolated database per test.
19
+ # worker's database BEFORE any test loads.
18
20
  #
19
- # Scope: SQLite adapter only (per-worker file, hermetic). Postgres per-worker
20
- # DBs (`CREATE DATABASE <db>-<worker>`) are not implemented yet; a non-SQLite
21
- # config raises a clear NotImplementedError rather than silently mis-routing.
21
+ # SQLite copies the base file on a slot's first use ({after_fork} with
22
+ # `seed: true`). Postgres and MySQL copy once, before any worker daemon starts
23
+ # ({provision}). A later fork only connects. Both servers roll back a killed
24
+ # fork's open transaction, so the SQLite re-seed after `error` or `timeout` is
25
+ # skipped. Rows a test commits outside a transaction stay in the slot.
22
26
  #
23
- # Routing failures surface as `error` via {verify_connection!}. Tagging an
24
- # in-test DB failure as `error` (not `killed`) is only observable under
25
- # concurrent load and is not yet implemented.
27
+ # MySQL has no template database. Each slot is created empty, the app schema
28
+ # is loaded with ActiveRecord::Tasks::DatabaseTasks, and rows are copied
29
+ # table by table. `mysql2` and `trilogy` are accepted. Any other adapter
30
+ # raises NotImplementedError rather than routing at a database that does not exist.
26
31
  module RailsWorkerDb
32
+ # Postgres cuts an unquoted identifier at this many bytes.
33
+ POSTGRES_NAME_LIMIT = 63
34
+ # MySQL rejects a database name longer than this many bytes. The shortening
35
+ # rule matches Postgres. A byte limit stays inside the 64-character limit.
36
+ MYSQL_NAME_LIMIT = 64
37
+ # How many worker daemons can hold a lock at once. MySQL GET_LOCK is
38
+ # exclusive, so each daemon takes one name from this pool. A separate guard
39
+ # name covers the handoff and is not one of these slots.
40
+ MYSQL_LOCK_SLOTS = 256
41
+ # Actions information_schema may report for a foreign key. Anything else is
42
+ # rejected so a bad row cannot change the ALTER TABLE statement.
43
+ MYSQL_REFERENTIAL_ACTIONS = ["CASCADE", "SET NULL", "SET DEFAULT", "RESTRICT", "NO ACTION"].freeze
44
+ # TLS and timeout keys from the app's MySQL config that reach the
45
+ # maintenance client unchanged. ActiveRecord passes them to the client the
46
+ # same way. The first group is mysql2's, the second trilogy's. Each client
47
+ # ignores the other's keys.
48
+ MYSQL_CLIENT_KEYS = %i[
49
+ connect_timeout read_timeout write_timeout
50
+ ssl_mode sslkey sslcert sslca sslcapath sslcipher sslverify get_server_public_key
51
+ ssl ssl_ca ssl_capath ssl_cert ssl_key ssl_cipher ssl_crl ssl_crlpath
52
+ tls_ciphersuites tls_min_version tls_max_version
53
+ ].freeze
54
+ # Infix that keeps a worker database off Rails' own `<base>-<n>` names.
55
+ WORKER_NAME_MARK = "-mutineer-"
56
+ # Database we connect to while the test database is a template. Advisory
57
+ # locks are per database, so every run must take them here, not on a slot.
58
+ MAINTENANCE_DATABASE = "postgres"
59
+ # Server catalogs a worker copy must never drop, even if a name bug
60
+ # produced one of them.
61
+ RESERVED_DATABASES = %w[postgres template0 template1].freeze
62
+ # Rails keys copied onto the maintenance `PG::Connection`. `database` is
63
+ # not here: that session always uses {MAINTENANCE_DATABASE}. `username` is
64
+ # Rails' spelling of libpq `user`. Anything else (adapter, variables,
65
+ # schema_search_path) is a Rails setting and would make libpq reject the
66
+ # connection.
67
+ LIBPQ_CONNECTION_KEYS = {
68
+ host: :host,
69
+ hostaddr: :hostaddr,
70
+ port: :port,
71
+ user: :user,
72
+ username: :user,
73
+ password: :password,
74
+ service: :service,
75
+ sslmode: :sslmode,
76
+ sslcert: :sslcert,
77
+ sslkey: :sslkey,
78
+ sslrootcert: :sslrootcert,
79
+ sslcrl: :sslcrl,
80
+ sslcompression: :sslcompression,
81
+ ssl_min_protocol_version: :ssl_min_protocol_version,
82
+ ssl_max_protocol_version: :ssl_max_protocol_version,
83
+ connect_timeout: :connect_timeout,
84
+ client_encoding: :client_encoding,
85
+ options: :options,
86
+ application_name: :application_name,
87
+ fallback_application_name: :fallback_application_name,
88
+ keepalives: :keepalives,
89
+ keepalives_idle: :keepalives_idle,
90
+ keepalives_interval: :keepalives_interval,
91
+ keepalives_count: :keepalives_count,
92
+ channel_binding: :channel_binding,
93
+ gssencmode: :gssencmode,
94
+ krbsrvname: :krbsrvname,
95
+ gsslib: :gsslib,
96
+ requiressl: :requiressl,
97
+ target_session_attrs: :target_session_attrs
98
+ }.freeze
99
+
27
100
  # True when the app has ActiveRecord loaded. The only condition under which
28
101
  # any other method here may touch AR. Never triggers an autoload/require of
29
102
  # AR itself.
@@ -33,10 +106,9 @@ module Mutineer
33
106
  defined?(ActiveRecord::Base) ? true : false
34
107
  end
35
108
 
36
- # Derive a per-worker database path from a base path by inserting `-<worker>`
37
- # before the extension. Pure string transform (no AR) so it is unit-testable
38
- # in the zero-dep suite. `storage/test.sqlite3`, worker 1 ->
39
- # `storage/test-1.sqlite3`.
109
+ # Derive a per-worker SQLite path by inserting `-<worker>` before the
110
+ # extension. Pure string transform (no AR). `storage/test.sqlite3`, worker
111
+ # 1 -> `storage/test-1.sqlite3`.
40
112
  #
41
113
  # @param database [String] the base database path.
42
114
  # @param worker [Integer] the worker slot index (0..N-1).
@@ -46,71 +118,347 @@ module Mutineer
46
118
  "#{database.delete_suffix(ext)}-#{worker}#{ext}"
47
119
  end
48
120
 
49
- # Build the AR connection config for one worker by copying the app's current
50
- # (default test) config and swapping in the per-worker database path. SQLite
51
- # only this pass: a non-SQLite adapter raises so the SQLite-first scope fails
52
- # loud instead of mis-routing.
121
+ # Worker database name: `<base>-mutineer-<slot>`. When that is longer than
122
+ # `limit` bytes, the base is shortened on a character boundary and a hash
123
+ # of the full base is added so two slots, and two long bases that share a
124
+ # prefix, stay distinct.
125
+ #
126
+ # @param base [String] the test database name.
127
+ # @param slot [Integer] the worker slot index.
128
+ # @param limit [Integer] maximum name length in bytes.
129
+ # @return [String] a name of at most `limit` bytes, or the hash suffix
130
+ # alone when that suffix itself exceeds the limit.
131
+ def self.worker_database_name(base, slot, limit)
132
+ suffix = "#{WORKER_NAME_MARK}#{slot}"
133
+ full = "#{base}#{suffix}"
134
+ return full if full.bytesize <= limit
135
+
136
+ hash = Digest::SHA256.hexdigest(base)[0, 8]
137
+ tail = "-#{hash}#{suffix}"
138
+ room = limit - tail.bytesize
139
+ "#{utf8_prefix(base, room)}#{tail}"
140
+ end
141
+
142
+ # Postgres worker database name. See {worker_database_name}.
143
+ #
144
+ # @param base [String] the test database name.
145
+ # @param slot [Integer] the worker slot index.
146
+ # @return [String] a name of at most {POSTGRES_NAME_LIMIT} bytes.
147
+ def self.postgres_worker_database(base, slot)
148
+ worker_database_name(base, slot, POSTGRES_NAME_LIMIT)
149
+ end
150
+
151
+ # MySQL worker database name. See {worker_database_name}.
152
+ #
153
+ # @param base [String] the test database name.
154
+ # @param slot [Integer] the worker slot index.
155
+ # @return [String] a name of at most {MYSQL_NAME_LIMIT} bytes.
156
+ def self.mysql_worker_database(base, slot)
157
+ worker_database_name(base, slot, MYSQL_NAME_LIMIT)
158
+ end
159
+
160
+ # Leading characters of `text` that fit in `max_bytes`. Stops before a
161
+ # character that would cross the limit, so a multibyte name stays valid
162
+ # text. PostgreSQL and MySQL count identifier length in bytes.
163
+ #
164
+ # @param text [String]
165
+ # @param max_bytes [Integer]
166
+ # @return [String]
167
+ def self.utf8_prefix(text, max_bytes)
168
+ return "" if max_bytes <= 0
169
+
170
+ source = text.to_s
171
+ return source if source.bytesize <= max_bytes
172
+
173
+ kept = +""
174
+ source.each_char do |char|
175
+ break if kept.bytesize + char.bytesize > max_bytes
176
+
177
+ kept << char
178
+ end
179
+ kept
180
+ end
181
+
182
+ # True when `name` is one of this run's exact worker database names.
183
+ # Equality only: a SQL `LIKE` would treat `_` in the base as a wildcard,
184
+ # so `my_app_test-mutineer-1` must not match base `myXappXtest`.
185
+ # `limit` defaults to the Postgres limit so existing callers stay valid.
186
+ #
187
+ # @param base [String] the test database name.
188
+ # @param name [String] a database name we might drop.
189
+ # @param slots [Integer] how many worker slots this run owns.
190
+ # @param limit [Integer] name limit in bytes. {POSTGRES_NAME_LIMIT} or {MYSQL_NAME_LIMIT}.
191
+ # @return [Boolean]
192
+ def self.owned_database?(base, name, slots:, limit: POSTGRES_NAME_LIMIT)
193
+ (0...slots).any? { |slot| worker_database_name(base, slot, limit) == name }
194
+ end
195
+
196
+ # True when `name` is a database this tool may drop. It must be the exact
197
+ # name {postgres_worker_database} would build for `base` and the slot
198
+ # written at the end of `name`. The source database, a server catalog, and
199
+ # any other name are refused.
200
+ #
201
+ # @param base [String] the test database name.
202
+ # @param name [String] a database name we might drop.
203
+ # @return [Boolean]
204
+ def self.worker_database_name?(base, name)
205
+ return false if base.nil? || name.nil?
206
+
207
+ text = name.to_s
208
+ source = base.to_s
209
+ return false if text.empty? || text == source
210
+ return false if RESERVED_DATABASES.include?(text)
211
+
212
+ slot = text[/#{Regexp.escape(WORKER_NAME_MARK)}(\d+)\z/, 1]
213
+ return false unless slot
214
+
215
+ postgres_worker_database(source, Integer(slot, 10)) == text
216
+ end
217
+
218
+ # Build the AR connection config for one worker from the app's current
219
+ # test config. SQLite, Postgres, and MySQL (mysql2 or trilogy) get a
220
+ # per-worker database name. Anything else raises.
53
221
  #
54
222
  # @param worker [Integer] the worker slot index.
55
223
  # @return [Hash] a symbol-keyed AR configuration hash for the worker database.
56
- # @raise [NotImplementedError] when the app's database is non-SQLite or in-memory.
224
+ # @raise [NotImplementedError] when the adapter has no worker database yet, or the database is in-memory.
57
225
  def self.worker_db_config(worker)
58
- hash = ActiveRecord::Base.connection_db_config.configuration_hash
59
- adapter = hash[:adapter].to_s
60
- # Config shaping (per_worker_config) is already adapter-general: it derives
61
- # correct SQLite and Postgres worker-DB names. What is gated is runtime
62
- # provisioning: SQLite files are created on connect, but Postgres needs an
63
- # explicit `CREATE DATABASE` per worker. Until that lands, refuse non-SQLite
64
- # loudly rather than route to a database that does not exist.
65
- unless adapter.start_with?("sqlite")
66
- raise NotImplementedError,
67
- "worker-DB isolation currently provisions SQLite only (got adapter #{adapter.inspect}); " \
68
- "Postgres per-worker provisioning is not yet supported. Use a SQLite test DB with --daemon, or drop --daemon to run serially."
69
- end
226
+ per_worker_config(routing_config_hash, worker)
227
+ end
70
228
 
71
- per_worker_config(hash, worker)
229
+ # Config used to name a worker database. MySQL schema load points
230
+ # ActiveRecord at the last slot. Coverage forks must keep the original test
231
+ # database, or the next name is nested and was never created.
232
+ #
233
+ # @return [Hash]
234
+ def self.routing_config_hash
235
+ @mysql_base_config || current_config_hash
72
236
  end
73
237
 
74
- # Pure config-shaping (no AR): given a connection config hash, return the
75
- # per-worker variant with its database swapped to the worker's own name.
76
- # Adapter-general: SQLite (`storage/test.sqlite3` -> `storage/test-<w>.sqlite3`)
77
- # and Postgres (`myapp_test` -> `myapp_test-<w>`, Rails `parallelize` naming)
78
- # both fall out of {worker_database_path}. Extracted and unit-tested so the
79
- # Postgres shape is proven ready without a live database.
238
+ # Remember the test database config before schema load switches the connection.
239
+ #
240
+ # @param config_hash [Hash]
241
+ # @return [void]
242
+ def self.remember_mysql_base_config(config_hash)
243
+ @mysql_base_config = config_hash.transform_keys(&:to_sym)
244
+ nil
245
+ end
246
+
247
+ # Drop the remembered MySQL test config. The next route reads ActiveRecord again.
248
+ #
249
+ # @return [void]
250
+ def self.forget_mysql_base_config
251
+ @mysql_base_config = nil
252
+ end
253
+
254
+ # Pure config shaping (no AR): swap in the worker database name.
255
+ # SQLite keeps {worker_database_path}. Postgres uses {postgres_worker_database}.
256
+ # MySQL (`mysql2` or `trilogy`) uses {mysql_worker_database}.
80
257
  #
81
258
  # @param config_hash [Hash] a connection config hash (symbol or string keys).
82
259
  # @param worker [Integer] the worker slot index.
83
260
  # @return [Hash] the per-worker config (symbol keys), database swapped.
84
- # @raise [NotImplementedError] for an in-memory or empty database (no per-worker split).
261
+ # @raise [NotImplementedError] for an in-memory database, an empty name, or an adapter other than SQLite, Postgres, or MySQL.
85
262
  def self.per_worker_config(config_hash, worker)
86
263
  hash = config_hash.transform_keys(&:to_sym)
264
+ adapter = hash[:adapter].to_s
87
265
  database = hash[:database].to_s
266
+ if database.empty? && postgres_adapter?(adapter)
267
+ database = database_name_from_url(hash[:url])
268
+ end
88
269
  if database.empty? || database == ":memory:"
89
270
  raise NotImplementedError,
90
- "worker-DB isolation needs a file/name-backed database (got #{database.inspect})."
271
+ "worker-DB isolation needs a file or named database (got #{database.inspect})."
272
+ end
273
+
274
+ name =
275
+ if sqlite_adapter?(adapter)
276
+ worker_database_path(database, worker)
277
+ elsif postgres_adapter?(adapter)
278
+ postgres_worker_database(database, worker)
279
+ elsif mysql_adapter?(adapter)
280
+ mysql_worker_database(database, worker)
281
+ else
282
+ raise NotImplementedError,
283
+ "worker-DB isolation does not support adapter #{adapter.inspect} yet."
284
+ end
285
+ hash.merge(database: name)
286
+ end
287
+
288
+ # Create each worker database before any worker daemon starts.
289
+ # SQLite is a no-op: the file is copied on first use. Postgres drops and
290
+ # recreates each exact slot name from the test database. MySQL creates each
291
+ # slot, loads the schema, and copies rows. The caller must be the only
292
+ # daemon connected to that test database.
293
+ #
294
+ # @param slots [Integer] how many worker slots to create (0..slots-1).
295
+ # @return [void]
296
+ # @raise [NotImplementedError] for an adapter other than SQLite, Postgres, or MySQL.
297
+ # @raise [RuntimeError] when the copy cannot be made, or another run holds the lock.
298
+ def self.provision(slots)
299
+ return unless available?
300
+
301
+ count = Integer(slots)
302
+ return if count < 1
303
+ return if sqlite_adapter?(current_adapter)
304
+
305
+ if postgres_adapter?(current_adapter)
306
+ provision_postgres(count)
307
+ return
308
+ end
309
+ if mysql_adapter?(current_adapter)
310
+ provision_mysql(count)
311
+ return
312
+ end
313
+
314
+ raise NotImplementedError,
315
+ "worker-DB isolation does not support adapter #{current_adapter.inspect} yet."
316
+ end
317
+
318
+ # Take the shared run lock and drop this process's connection to the test
319
+ # database. Worker daemons call this at boot. They do not create databases.
320
+ # SQLite has no lock here. The file copy still happens in {after_fork}.
321
+ #
322
+ # @return [void]
323
+ # @raise [RuntimeError] when another run holds the exclusive lock.
324
+ def self.prepare_worker!
325
+ return unless available?
326
+ return if sqlite_adapter?(current_adapter)
327
+
328
+ if mysql_adapter?(current_adapter)
329
+ prepare_mysql_worker!
330
+ return
331
+ end
332
+ return unless postgres_adapter?(current_adapter)
333
+
334
+ hash = current_config_hash
335
+ base = source_database_name!(hash)
336
+ conn = open_maintenance_connection(hash, base)
337
+ unless try_lock(conn, base, shared: true)
338
+ conn.close
339
+ raise "another Mutineer run is using #{base}"
91
340
  end
92
341
 
93
- hash.merge(database: worker_database_path(database, worker))
342
+ @lock_connection = conn
343
+ disconnect_app!
344
+ end
345
+
346
+ # Point this process at one worker database. Postgres and MySQL only connect.
347
+ # The slot was copied at {provision}.
348
+ #
349
+ # @param slot [Integer] the worker slot index.
350
+ # @return [void]
351
+ def self.connect(slot)
352
+ ActiveRecord::Base.establish_connection(worker_db_config(slot))
353
+ end
354
+
355
+ # How many database configs the test environment declares. One is normal.
356
+ # More than one means the run must use a single worker.
357
+ #
358
+ # @return [Integer]
359
+ def self.database_count
360
+ return 1 unless available?
361
+
362
+ env = ENV["RAILS_ENV"].to_s
363
+ env = "test" if env.empty?
364
+ list = ActiveRecord::Base.configurations.configs_for(env_name: env)
365
+ count = list.size
366
+ count < 1 ? 1 : count
367
+ rescue StandardError
368
+ 1
94
369
  end
95
370
 
96
371
  # Child-side (after fork): route this process's ActiveRecord at the worker's
97
372
  # own database and confirm it is reachable, so a routing failure reads as
98
- # `error` (via the daemon's child rescue) rather than a false verdict. Loads
99
- # the schema into the worker database when a schema path is given
100
- # (idempotent: schema.rb runs with `force: true`), covering a fresh worker
101
- # file.
373
+ # `error` rather than a false verdict.
374
+ #
375
+ # SQLite with `seed: true` copies the base file first, then loads `schema.rb`
376
+ # only when the copy differs from that file. Postgres and MySQL ignore `seed`
377
+ # and the schema path: the slot was copied at {provision}, and loading
378
+ # `schema.rb` again would drop those rows.
102
379
  #
103
380
  # @param worker [Integer] the worker slot index.
104
381
  # @param schema_path [String, nil] absolute path to `db/schema.rb`, or nil to skip.
382
+ # @param seed [Boolean] copy the base SQLite file into the worker file first.
105
383
  # @return [void]
106
- def self.after_fork(worker, schema_path = nil)
384
+ def self.after_fork(worker, schema_path = nil, seed: false)
107
385
  return unless available?
108
386
 
109
- ActiveRecord::Base.establish_connection(worker_db_config(worker))
110
- load_schema(schema_path) if schema_path
387
+ forget_lock_connection_after_fork!
388
+ if postgres_adapter?(current_adapter) || mysql_adapter?(current_adapter)
389
+ connect(worker)
390
+ verify_connection!
391
+ return
392
+ end
393
+
394
+ config = worker_db_config(worker)
395
+ base = ActiveRecord::Base.connection.select_value("SELECT file FROM pragma_database_list WHERE name = 'main'") if seed
396
+ ActiveRecord::Base.establish_connection(config)
397
+ seed_from(base) if seed
398
+ load_schema(schema_path) if schema_path && !schema_current?(schema_path)
111
399
  verify_connection!
112
400
  end
113
401
 
402
+ # Copy the base test database file into the worker database this fork is
403
+ # now connected to, with the SQLite online backup API: one consistent
404
+ # snapshot of the committed data (WAL included) that replaces whatever the
405
+ # worker file held (an earlier run, or a copy a timeout interrupted). Both
406
+ # ends are files SQLite itself opened, so no path rules are re-derived here.
407
+ #
408
+ # @param base_file [String] the base database file, from `pragma_database_list`.
409
+ # @return [void]
410
+ def self.seed_from(base_file)
411
+ codes = SQLite3::Constants::ErrorCode
412
+ source = SQLite3::Database.new(base_file, readonly: true)
413
+ source.busy_timeout = 5000
414
+ backup = SQLite3::Backup.new(ActiveRecord::Base.connection.raw_connection, "main", source, "main")
415
+ # Another worker's process can hold a lock on the base file for a moment;
416
+ # BUSY/LOCKED steps are retried for up to 5 seconds, then the copy fails
417
+ # with a message that names the database (scored `error`).
418
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
419
+ until (status = backup.step(-1)) == codes::DONE
420
+ retry_ok = [codes::BUSY, codes::LOCKED].include?(status) && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
421
+ raise "copying #{base_file} into the worker database failed (SQLite code #{status})" unless retry_ok
422
+
423
+ sleep 0.01
424
+ end
425
+ ensure
426
+ backup&.finish
427
+ source&.close
428
+ end
429
+
430
+ # True when the current database already holds the schema that `schema.rb`
431
+ # declares: its newest `schema_migrations` row matches the declared version,
432
+ # and the `schema_sha1` Rails stores in `ar_internal_metadata` matches the
433
+ # file, which catches an edited schema with the same version. No stored
434
+ # checksum counts as out of date, as in Rails' own `schema_up_to_date?`.
435
+ #
436
+ # @param schema_path [String] absolute path to `db/schema.rb`.
437
+ # @return [Boolean]
438
+ def self.schema_current?(schema_path)
439
+ text = File.read(schema_path)
440
+ version = schema_file_version(text)
441
+ conn = ActiveRecord::Base.connection
442
+ base = ActiveRecord::Base
443
+ migrations = "#{base.table_name_prefix}#{base.schema_migrations_table_name}#{base.table_name_suffix}"
444
+ metadata = "#{base.table_name_prefix}#{base.internal_metadata_table_name}#{base.table_name_suffix}"
445
+ return false unless version && conn.table_exists?(migrations)
446
+ return false unless conn.select_values("SELECT version FROM #{conn.quote_table_name(migrations)}").map(&:to_i).max == version
447
+ return false unless conn.table_exists?(metadata)
448
+
449
+ conn.select_value("SELECT value FROM #{conn.quote_table_name(metadata)} WHERE key = 'schema_sha1'") ==
450
+ Digest::SHA1.hexdigest(text)
451
+ end
452
+
453
+ # The version a `schema.rb` declares in `define(version: ...)`, or nil. Pure
454
+ # string parse (no AR) so it is unit-testable in the zero-dep suite.
455
+ #
456
+ # @param text [String] the `schema.rb` source.
457
+ # @return [Integer, nil]
458
+ def self.schema_file_version(text)
459
+ text[/define\(version:\s*([\d_]+)/, 1]&.delete("_")&.to_i
460
+ end
461
+
114
462
  # Load a Rails `schema.rb` into the current connection with output silenced
115
463
  # (fork child stdout is already File::NULL; this is belt-and-braces).
116
464
  #
@@ -127,12 +475,1093 @@ module Mutineer
127
475
  end
128
476
 
129
477
  # Force a round-trip to the freshly-routed connection so a broken route fails
130
- # HERE (→ `error`) instead of later masquerading as a test failure
131
- # (→ false `killed`).
478
+ # HERE (then `error`) instead of later masquerading as a test failure
479
+ # (then a false `killed`).
132
480
  #
133
481
  # @return [void]
134
482
  def self.verify_connection!
135
483
  ActiveRecord::Base.connection.execute("SELECT 1")
136
484
  end
485
+
486
+ # Symbol-keyed connection hash for the database the app booted. Does not
487
+ # open a new connection.
488
+ #
489
+ # @return [Hash]
490
+ def self.current_config_hash
491
+ ActiveRecord::Base.connection_db_config.configuration_hash.transform_keys(&:to_sym)
492
+ end
493
+
494
+ # Adapter name from {current_config_hash}.
495
+ #
496
+ # @return [String]
497
+ def self.current_adapter
498
+ current_config_hash[:adapter].to_s
499
+ end
500
+
501
+ # @param adapter [String]
502
+ # @return [Boolean]
503
+ def self.sqlite_adapter?(adapter)
504
+ adapter.start_with?("sqlite")
505
+ end
506
+
507
+ # @param adapter [String]
508
+ # @return [Boolean]
509
+ def self.postgres_adapter?(adapter)
510
+ adapter == "postgres" || adapter.start_with?("postgres")
511
+ end
512
+
513
+ # True for the mysql2 and trilogy adapters. The old `mysql` gem is not included.
514
+ #
515
+ # @param adapter [String]
516
+ # @return [Boolean]
517
+ def self.mysql_adapter?(adapter)
518
+ adapter == "mysql2" || adapter == "trilogy"
519
+ end
520
+
521
+ # Drop and recreate slots `0...count` from the booted test database.
522
+ # Holds the exclusive advisory lock only for the copy, then a shared lock
523
+ # for the rest of this process so a second run cannot drop those databases.
524
+ #
525
+ # @param count [Integer]
526
+ # @return [void]
527
+ def self.provision_postgres(count)
528
+ hash = current_config_hash
529
+ base = source_database_name!(hash)
530
+ conn = open_maintenance_connection(hash, base)
531
+ unless try_lock(conn, base, shared: false)
532
+ conn.close
533
+ raise "another Mutineer run is using #{base}"
534
+ end
535
+
536
+ begin
537
+ disconnect_app!
538
+ count.times do |slot|
539
+ name = postgres_worker_database(base, slot)
540
+ recreate_from_template(conn, name, base)
541
+ end
542
+ rescue StandardError => e
543
+ unlock(conn, base)
544
+ conn.close
545
+ raise provision_wrapped?(e) ? e : provision_failure(base, base, e.message)
546
+ end
547
+
548
+ unlock(conn, base)
549
+ unless try_lock(conn, base, shared: true)
550
+ conn.close
551
+ raise "another Mutineer run is using #{base}"
552
+ end
553
+ @lock_connection = conn
554
+ warn "[mutineer] provisioned #{count} postgres worker database(s) from #{base}"
555
+ end
556
+
557
+ # Open a session on {MAINTENANCE_DATABASE} with the app's Postgres credentials.
558
+ # Uses the `pg` client the app already loaded. The gem does not depend on it.
559
+ # Endpoint options from the Rails config, including `service` and `url`, are
560
+ # kept. Only the database name changes, to {MAINTENANCE_DATABASE}.
561
+ #
562
+ # @param config_hash [Hash]
563
+ # @param base [String] test database name, named in the error if this fails.
564
+ # @return [PG::Connection]
565
+ def self.open_maintenance_connection(config_hash, base)
566
+ unless defined?(PG::Connection)
567
+ raise "could not provision #{base}: the app's pg client is not loaded"
568
+ end
569
+
570
+ PG::Connection.open(maintenance_connection_options(config_hash))
571
+ rescue StandardError => e
572
+ raise e if e.message.start_with?("could not provision")
573
+
574
+ raise "could not provision #{base}: #{e.message}"
575
+ end
576
+
577
+ # libpq options for the maintenance session. A Rails `url:` fills anything
578
+ # the hash does not set itself. Explicit keys win. `dbname` is always
579
+ # {MAINTENANCE_DATABASE}, never the test database and never a name taken
580
+ # from the URL path.
581
+ #
582
+ # @param config_hash [Hash] symbol or string keys, as Rails stores them.
583
+ # @return [Hash] symbol keys for `PG::Connection.open`.
584
+ # @raise [RuntimeError] when `url:` is present and is not a Postgres URL.
585
+ def self.maintenance_connection_options(config_hash)
586
+ hash = config_hash.transform_keys(&:to_sym)
587
+ opts = options_from_database_url(hash[:url])
588
+ opts.delete(:database)
589
+ LIBPQ_CONNECTION_KEYS.each do |config_key, pg_key|
590
+ next unless hash.key?(config_key)
591
+
592
+ value = hash[config_key]
593
+ next if blank_option?(value)
594
+
595
+ opts[pg_key] = value
596
+ end
597
+ opts[:dbname] = MAINTENANCE_DATABASE
598
+ opts
599
+ end
600
+
601
+ # The test database this run copies from. `database:` wins. A Postgres URL
602
+ # supplies the name when the hash has none. A `service:` entry does not
603
+ # carry the name, so that shape fails here instead of guessing.
604
+ #
605
+ # @param config_hash [Hash]
606
+ # @return [String]
607
+ # @raise [RuntimeError] when no database name is available.
608
+ def self.source_database_name!(config_hash)
609
+ hash = config_hash.transform_keys(&:to_sym)
610
+ name = hash[:database].to_s
611
+ name = database_name_from_url(hash[:url]) if name.empty?
612
+ return name unless name.empty?
613
+
614
+ raise "could not provision: the test database name is missing. " \
615
+ "Set database: or use a URL that includes the database name."
616
+ end
617
+
618
+ # Database name from a postgres URL path, or "" when `url` is blank.
619
+ #
620
+ # @param url [String, nil]
621
+ # @return [String]
622
+ # @raise [RuntimeError] when `url` is present and is not a Postgres URL.
623
+ def self.database_name_from_url(url)
624
+ options_from_database_url(url)[:database].to_s
625
+ end
626
+
627
+ # Host, credentials, database name, and query options from a Rails `url:`.
628
+ # Blank when `url` is blank. The database is returned under `:database`
629
+ # so callers can separate it from the maintenance `dbname`.
630
+ #
631
+ # @param url [String, nil]
632
+ # @return [Hash]
633
+ # @raise [RuntimeError] when `url` is present and is not a Postgres URL.
634
+ def self.options_from_database_url(url)
635
+ return {} if blank_option?(url)
636
+
637
+ uri = URI.parse(url.to_s)
638
+ scheme = uri.scheme.to_s.downcase
639
+ unless scheme == "postgres" || scheme == "postgresql"
640
+ raise "could not provision: the database URL must start with postgres:// or postgresql://"
641
+ end
642
+
643
+ opts = {}
644
+ host = uri.host
645
+ opts[:host] = host if host && !host.empty?
646
+ opts[:port] = uri.port if uri.port
647
+ user = decode_uri_component(uri.user)
648
+ opts[:user] = user if user && !user.empty?
649
+ password = decode_uri_component(uri.password)
650
+ opts[:password] = password if password && !password.empty?
651
+ database = uri.path.to_s.sub(%r{\A/}, "")
652
+ database = decode_uri_component(database) unless database.empty?
653
+ opts[:database] = database if database && !database.empty?
654
+ URI.decode_www_form(uri.query.to_s).each do |key, value|
655
+ pg_key = LIBPQ_CONNECTION_KEYS[key.to_sym]
656
+ next unless pg_key
657
+ next if blank_option?(value)
658
+
659
+ opts[pg_key] = value
660
+ end
661
+ opts
662
+ rescue URI::InvalidURIError, URI::InvalidComponentError, ArgumentError
663
+ # The parser's message repeats the URL, password included. `cause: nil`
664
+ # keeps Ruby from printing that original error under this one.
665
+ raise "could not provision: the database URL is not valid. " \
666
+ "The URL is not shown because it can contain a password. " \
667
+ "Check each part of DATABASE_URL or url: in config/database.yml. " \
668
+ "Percent-encode the user name, password and database name. Encode each " \
669
+ "character that is not an ASCII letter, a digit, -, ., _ or ~. " \
670
+ "For example, write % as %25, @ as %40 and a space as %20.", cause: nil
671
+ end
672
+
673
+ # Percent-decode one URL component. Nil when `text` is blank.
674
+ #
675
+ # @param text [String, nil]
676
+ # @return [String, nil]
677
+ def self.decode_uri_component(text)
678
+ return if blank_option?(text)
679
+
680
+ # `unescape` keeps a literal `+`. The form decoder would turn it into a space.
681
+ # The daemon runs on the app's Ruby, which may be older than 3.4.
682
+ URI::DEFAULT_PARSER.unescape(text.to_s)
683
+ end
684
+
685
+ # True for nil and empty strings. Numbers and false are present values.
686
+ #
687
+ # @param value [Object]
688
+ # @return [Boolean]
689
+ def self.blank_option?(value)
690
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
691
+ end
692
+
693
+ # @param conn [PG::Connection]
694
+ # @param base [String]
695
+ # @param shared [Boolean] shared lock when true, exclusive when false.
696
+ # @return [Boolean]
697
+ def self.try_lock(conn, base, shared:)
698
+ k1, k2 = lock_keys(base)
699
+ sql = shared ? "SELECT pg_try_advisory_lock_shared($1, $2)" : "SELECT pg_try_advisory_lock($1, $2)"
700
+ conn.exec_params(sql, [k1, k2]).getvalue(0, 0) == "t"
701
+ end
702
+
703
+ # Release one advisory lock this session holds for `base`.
704
+ #
705
+ # @param conn [PG::Connection]
706
+ # @param base [String]
707
+ # @return [void]
708
+ def self.unlock(conn, base)
709
+ k1, k2 = lock_keys(base)
710
+ conn.exec_params("SELECT pg_advisory_unlock($1, $2)", [k1, k2])
711
+ end
712
+
713
+ # Two signed 32-bit keys for `pg_try_advisory_lock(int, int)`, from the base name.
714
+ #
715
+ # @param base [String]
716
+ # @return [Array(Integer, Integer)]
717
+ def self.lock_keys(base)
718
+ Digest::SHA256.digest("mutineer-run:#{base}").unpack("l>l>")
719
+ end
720
+
721
+ # Replace `name` with a template copy of `base`. Terminates other sessions
722
+ # on the slot only. A session on `base` is left alone and fails the copy.
723
+ #
724
+ # @param conn [PG::Connection]
725
+ # @param name [String] exact worker database name.
726
+ # @param base [String] test database used as the template.
727
+ # @return [void]
728
+ def self.recreate_from_template(conn, name, base)
729
+ unless worker_database_name?(base, name)
730
+ raise "refusing to drop #{name}"
731
+ end
732
+
733
+ conn.exec("SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = #{conn.escape_literal(name)} AND pid <> pg_backend_pid()")
734
+ conn.exec("DROP DATABASE IF EXISTS #{quote_ident(name)}")
735
+ conn.exec("CREATE DATABASE #{quote_ident(name)} TEMPLATE #{quote_ident(base)}")
736
+ rescue StandardError => e
737
+ raise e if e.message.start_with?("refusing to drop")
738
+
739
+ raise provision_failure(base, name, e.message)
740
+ end
741
+
742
+ # Close every connection this process holds through ActiveRecord, so the
743
+ # test database can be used as a Postgres template and a MySQL slot can be
744
+ # dropped.
745
+ #
746
+ # @return [void]
747
+ def self.disconnect_app!
748
+ handler = ActiveRecord::Base.connection_handler
749
+ if handler.respond_to?(:clear_all_connections!)
750
+ handler.clear_all_connections!
751
+ else
752
+ ActiveRecord::Base.connection_pool.disconnect!
753
+ end
754
+ end
755
+
756
+ # The child inherited the parent's lock session. Drop the Ruby wrapper
757
+ # without sending a disconnect, so the parent's locks stay held.
758
+ # Clearing the held-name list stops the child from sending RELEASE_LOCK
759
+ # on that shared session.
760
+ #
761
+ # @return [void]
762
+ def self.forget_lock_connection_after_fork!
763
+ @mysql_held_locks = []
764
+ conn = @lock_connection
765
+ @lock_connection = nil
766
+ return unless conn
767
+
768
+ detach_inherited_connection(conn)
769
+ ObjectSpace.undefine_finalizer(conn)
770
+ end
771
+
772
+ # Make a fork child's copy of the lock connection safe to free. The
773
+ # client's free function runs at the child's next GC. Left as it is, that
774
+ # sends QUIT (mysql2) or Terminate (pg) on the socket the parent shares,
775
+ # so the server ends the parent's session and every lock it holds.
776
+ # These are the steps ActiveRecord's adapters take in `discard!`.
777
+ #
778
+ # @param conn [Mysql2::Client, Trilogy, PG::Connection]
779
+ # @return [void]
780
+ def self.detach_inherited_connection(conn)
781
+ if conn.respond_to?(:automatic_close=)
782
+ conn.automatic_close = false # mysql2: GC drops only this process's fd
783
+ elsif conn.respond_to?(:discard!)
784
+ conn.discard! # trilogy: closes only this process's fd
785
+ elsif conn.respond_to?(:socket_io)
786
+ conn.socket_io.reopen(IO::NULL) # pg: Terminate goes to /dev/null
787
+ end
788
+ end
789
+
790
+ # Quote a database name for a Postgres identifier. `CREATE DATABASE` cannot
791
+ # take a bound parameter for the name.
792
+ #
793
+ # @param name [String]
794
+ # @return [String]
795
+ def self.quote_ident(name)
796
+ raise ArgumentError, "database name is empty" if name.nil? || name.empty?
797
+
798
+ %("#{name.gsub('"', '""')}")
799
+ end
800
+
801
+ # Failure text for a copy that did not happen. Names the database and the
802
+ # server's cause. A busy template also says to close other sessions.
803
+ #
804
+ # @param base [String]
805
+ # @param name [String]
806
+ # @param cause [String]
807
+ # @return [String]
808
+ def self.provision_failure(base, name, cause)
809
+ message = "could not provision #{name} from #{base}: #{cause}"
810
+ message += ". Close other sessions on #{base}." if cause.match?(/other users|being accessed/i)
811
+ message
812
+ end
813
+
814
+ # Create MySQL slots `0...count`, load the app schema into each, and copy
815
+ # rows from the test database. Holds the guard lock and every pool name
816
+ # while copying, then keeps the guard lock. Pool names are released without
817
+ # a gap so another run cannot drop the new databases. Worker daemons then
818
+ # take one pool name each.
819
+ #
820
+ # @param count [Integer]
821
+ # @return [void]
822
+ # @raise [RuntimeError] when `count` exceeds {MYSQL_LOCK_SLOTS}, or another run holds the lock.
823
+ def self.provision_mysql(count)
824
+ if count > MYSQL_LOCK_SLOTS
825
+ raise "MySQL worker databases support at most #{MYSQL_LOCK_SLOTS} workers"
826
+ end
827
+
828
+ hash = routing_config_hash
829
+ remember_mysql_base_config(hash)
830
+ base = hash[:database].to_s
831
+ conn = open_mysql_connection(hash, base)
832
+ unless try_mysql_lock(conn, base, shared: false)
833
+ conn.close
834
+ raise "another Mutineer run is using #{base}"
835
+ end
836
+
837
+ begin
838
+ disconnect_app!
839
+ count.times do |slot|
840
+ name = mysql_worker_database(base, slot)
841
+ begin
842
+ recreate_mysql_database(conn, name, base)
843
+ load_mysql_schema(hash, name, base)
844
+ copy_mysql_rows(conn, base, name)
845
+ rescue StandardError => e
846
+ raise e if provision_wrapped?(e)
847
+
848
+ raise provision_failure(base, name, e.message)
849
+ end
850
+ end
851
+ raise "another Mutineer run is using #{base}" unless downgrade_mysql_lock_to_shared(conn, base)
852
+ rescue StandardError => e
853
+ release_held_mysql_locks(conn)
854
+ conn.close
855
+ raise e if provision_wrapped?(e)
856
+
857
+ raise provision_failure(base, base, e.message)
858
+ end
859
+
860
+ @lock_connection = conn
861
+ warn "[mutineer] provisioned #{count} mysql worker database(s) from #{base}"
862
+ end
863
+
864
+ # Take one shared GET_LOCK name and drop this process's app connection.
865
+ #
866
+ # @return [void]
867
+ def self.prepare_mysql_worker!
868
+ hash = current_config_hash
869
+ base = hash[:database].to_s
870
+ conn = open_mysql_connection(hash, base)
871
+ unless try_mysql_lock(conn, base, shared: true)
872
+ conn.close
873
+ raise "another Mutineer run is using #{base}"
874
+ end
875
+
876
+ @lock_connection = conn
877
+ disconnect_app!
878
+ end
879
+
880
+ # Open a session with the app's MySQL client. It starts with no default
881
+ # database, so it is not tied to the test database while it drops and
882
+ # creates slots. {restore_mysql_triggers} later selects a slot. Every
883
+ # other statement names its database. The gem does not depend on the client.
884
+ #
885
+ # @param config_hash [Hash]
886
+ # @param base [String] test database name, named in the error if this fails.
887
+ # @return [Mysql2::Client, Trilogy]
888
+ def self.open_mysql_connection(config_hash, base)
889
+ adapter = config_hash[:adapter].to_s
890
+ opts = mysql_client_options(config_hash)
891
+ if adapter == "trilogy"
892
+ raise "could not provision #{base}: the app's trilogy client is not loaded" unless defined?(Trilogy)
893
+
894
+ # Rails' adapter turns a `ssl_mode` name into the constant trilogy wants.
895
+ if defined?(ActiveRecord::ConnectionAdapters::TrilogyAdapter)
896
+ ActiveRecord::ConnectionAdapters::TrilogyAdapter.new_client(opts)
897
+ else
898
+ Trilogy.new(opts)
899
+ end
900
+ else
901
+ raise "could not provision #{base}: the app's mysql2 client is not loaded" unless defined?(Mysql2::Client)
902
+
903
+ Mysql2::Client.new(opts)
904
+ end
905
+ rescue StandardError => e
906
+ raise e if e.message.start_with?("could not provision ")
907
+
908
+ raise "could not provision #{base}: #{e.message}"
909
+ end
910
+
911
+ # Connection options shared by mysql2 and trilogy. The database key is left
912
+ # out so the session is not tied to the test database. A socket wins over
913
+ # host: trilogy ignores `socket` when `host` is set, and would then use its
914
+ # compiled-in default instead of the app's socket. TLS and timeout keys in
915
+ # {MYSQL_CLIENT_KEYS} are kept.
916
+ #
917
+ # @param config_hash [Hash]
918
+ # @return [Hash]
919
+ def self.mysql_client_options(config_hash)
920
+ opts = {}
921
+ socket = config_hash[:socket].to_s
922
+ if socket.empty?
923
+ host = config_hash[:host].to_s
924
+ opts[:host] = host unless host.empty?
925
+ port = config_hash[:port]
926
+ opts[:port] = Integer(port) if port && !port.to_s.empty?
927
+ else
928
+ opts[:socket] = socket
929
+ end
930
+ user = config_hash[:username] || config_hash[:user]
931
+ opts[:username] = user.to_s if user && !user.to_s.empty?
932
+ password = config_hash[:password]
933
+ opts[:password] = password.to_s unless password.nil?
934
+ MYSQL_CLIENT_KEYS.each { |key| opts[key] = config_hash[key] unless config_hash[key].nil? }
935
+ # `:encoding` is an ActiveRecord key. mysql2 and trilogy do not share it.
936
+ # Row copy runs in the server, so the client charset does not change it.
937
+ opts
938
+ end
939
+
940
+ # Take one pool name, or the guard plus every pool name when `shared` is
941
+ # false. A miss releases any names this call already took.
942
+ #
943
+ # @param conn [Mysql2::Client, Trilogy]
944
+ # @param base [String]
945
+ # @param shared [Boolean]
946
+ # @return [Boolean]
947
+ def self.try_mysql_lock(conn, base, shared:)
948
+ return try_mysql_shared_lock(conn, base) if shared
949
+
950
+ try_mysql_exclusive_lock(conn, base)
951
+ end
952
+
953
+ # Take the guard, then every pool name. The guard stays held across the
954
+ # later downgrade so another run cannot start a copy in that gap.
955
+ #
956
+ # @param conn [Mysql2::Client, Trilogy]
957
+ # @param base [String]
958
+ # @return [Boolean]
959
+ def self.try_mysql_exclusive_lock(conn, base)
960
+ guard = mysql_guard_lock_name(base)
961
+ return false unless mysql_get_lock(conn, guard)
962
+
963
+ held = [guard]
964
+ mysql_lock_names(base).each do |name|
965
+ if mysql_get_lock(conn, name)
966
+ held << name
967
+ else
968
+ held.each { |got| mysql_release_lock(conn, got) }
969
+ @mysql_held_locks = []
970
+ return false
971
+ end
972
+ end
973
+ @mysql_held_locks = held
974
+ true
975
+ end
976
+
977
+ # Take the first free pool name. Does not take the guard, so worker daemons
978
+ # can connect while the provisioning process still holds that guard.
979
+ #
980
+ # @param conn [Mysql2::Client, Trilogy]
981
+ # @param base [String]
982
+ # @return [Boolean]
983
+ def self.try_mysql_shared_lock(conn, base)
984
+ mysql_lock_names(base).each do |name|
985
+ next unless mysql_get_lock(conn, name)
986
+
987
+ @mysql_held_locks = [name]
988
+ return true
989
+ end
990
+ @mysql_held_locks = []
991
+ false
992
+ end
993
+
994
+ # Release every pool name and keep the guard. The guard is never released
995
+ # first, so this process always holds a lock the next copy needs.
996
+ #
997
+ # @param conn [Mysql2::Client, Trilogy]
998
+ # @param base [String]
999
+ # @return [Boolean] false when this session does not hold the guard.
1000
+ def self.downgrade_mysql_lock_to_shared(conn, base)
1001
+ guard = mysql_guard_lock_name(base)
1002
+ held = Array(@mysql_held_locks)
1003
+ return false unless held.include?(guard)
1004
+
1005
+ (held - [guard]).each { |name| mysql_release_lock(conn, name) }
1006
+ @mysql_held_locks = [guard]
1007
+ true
1008
+ end
1009
+
1010
+ # Release the GET_LOCK names this session took for the current run.
1011
+ #
1012
+ # @param conn [Mysql2::Client, Trilogy]
1013
+ # @return [void]
1014
+ def self.release_held_mysql_locks(conn)
1015
+ Array(@mysql_held_locks).each { |name| mysql_release_lock(conn, name) }
1016
+ @mysql_held_locks = []
1017
+ nil
1018
+ end
1019
+
1020
+ # One GET_LOCK name per worker daemon. The digest keeps each name inside 64
1021
+ # characters for any base database name.
1022
+ #
1023
+ # @param base [String]
1024
+ # @return [Array<String>]
1025
+ def self.mysql_lock_names(base)
1026
+ digest = mysql_lock_digest(base)
1027
+ (0...MYSQL_LOCK_SLOTS).map { |slot| "m:#{digest}:#{slot}" }
1028
+ end
1029
+
1030
+ # Lock held for the whole copy and until worker daemons hold pool names.
1031
+ # Not part of {mysql_lock_names}, so it does not reduce the worker count.
1032
+ #
1033
+ # @param base [String]
1034
+ # @return [String]
1035
+ def self.mysql_guard_lock_name(base)
1036
+ "m:#{mysql_lock_digest(base)}:g"
1037
+ end
1038
+
1039
+ # Shared prefix for the guard and the pool. 16 hex characters keep the full
1040
+ # lock name inside MySQL's 64-character limit.
1041
+ #
1042
+ # @param base [String]
1043
+ # @return [String]
1044
+ def self.mysql_lock_digest(base)
1045
+ Digest::SHA256.hexdigest("mutineer-run:#{base}")[0, 16]
1046
+ end
1047
+
1048
+ # @param conn [Mysql2::Client, Trilogy]
1049
+ # @param name [String]
1050
+ # @return [Boolean]
1051
+ def self.mysql_get_lock(conn, name)
1052
+ rows = mysql_rows(conn, "SELECT GET_LOCK(#{mysql_quote_string(conn, name)}, 0) AS got")
1053
+ rows.dig(0, "got").to_i == 1
1054
+ end
1055
+
1056
+ # @param conn [Mysql2::Client, Trilogy]
1057
+ # @param name [String]
1058
+ # @return [void]
1059
+ def self.mysql_release_lock(conn, name)
1060
+ mysql_exec(conn, "SELECT RELEASE_LOCK(#{mysql_quote_string(conn, name)})")
1061
+ end
1062
+
1063
+ # Drop and create `name`. Refuses any name that is not one of `base`'s
1064
+ # worker database names, so a name bug can never drop the test database.
1065
+ #
1066
+ # @param conn [Mysql2::Client, Trilogy]
1067
+ # @param name [String]
1068
+ # @param base [String] the test database name.
1069
+ # @return [void]
1070
+ def self.recreate_mysql_database(conn, name, base)
1071
+ raise "refusing to drop #{name}" unless owned_database?(base, name, slots: MYSQL_LOCK_SLOTS, limit: MYSQL_NAME_LIMIT)
1072
+
1073
+ quoted = quote_mysql_ident(name)
1074
+ charset, collation = mysql_database_defaults(conn, base)
1075
+ mysql_exec(conn, "DROP DATABASE IF EXISTS #{quoted}")
1076
+ mysql_exec(conn, "CREATE DATABASE #{quoted} CHARACTER SET #{charset} COLLATE #{collation}")
1077
+ end
1078
+
1079
+ # The test database's default character set and collation. A table the
1080
+ # schema creates without its own collation then compares text the same way
1081
+ # in each worker database as in the test database.
1082
+ #
1083
+ # @param conn [Mysql2::Client, Trilogy]
1084
+ # @param base [String] the test database name.
1085
+ # @return [Array(String, String)] the character set and the collation.
1086
+ # @raise [RuntimeError] when the test database is missing or reports an unexpected name.
1087
+ def self.mysql_database_defaults(conn, base)
1088
+ row = mysql_rows(conn, "SELECT DEFAULT_CHARACTER_SET_NAME AS charset, DEFAULT_COLLATION_NAME AS collation " \
1089
+ "FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = #{mysql_quote_string(conn, base)}").first
1090
+ values = row && [row["charset"].to_s, row["collation"].to_s]
1091
+ raise "cannot read the character set of #{base}" unless values&.all? { |value| value.match?(/\A\w+\z/) }
1092
+
1093
+ values
1094
+ end
1095
+
1096
+ # Load `schema.rb` or `structure.sql` into the empty slot with the app's
1097
+ # DatabaseTasks. A missing file leaves the slot empty so the row copy can
1098
+ # still clone tables that exist only on the test database.
1099
+ #
1100
+ # @param base_hash [Hash] symbol-keyed config for the test database.
1101
+ # @param name [String] worker database name.
1102
+ # @param base [String] test database name, used in the failure text.
1103
+ # @return [void]
1104
+ def self.load_mysql_schema(base_hash, name, base)
1105
+ return unless defined?(ActiveRecord::Tasks::DatabaseTasks)
1106
+
1107
+ switched = false
1108
+ failure = nil
1109
+ begin
1110
+ slot_hash = base_hash.merge(database: name)
1111
+ ActiveRecord::Base.establish_connection(slot_hash)
1112
+ switched = true
1113
+ db_config = mysql_db_config(slot_hash)
1114
+ format = current_schema_format
1115
+ file = mysql_schema_file(db_config, format)
1116
+ ActiveRecord::Tasks::DatabaseTasks.load_schema(db_config, format, file) if file
1117
+ rescue StandardError => e
1118
+ failure = e
1119
+ ensure
1120
+ if switched
1121
+ begin
1122
+ ActiveRecord::Base.establish_connection(base_hash)
1123
+ rescue StandardError => e
1124
+ failure ||= e
1125
+ end
1126
+ end
1127
+ end
1128
+ return unless failure
1129
+ raise failure if provision_wrapped?(failure)
1130
+
1131
+ raise provision_failure(base, name, failure.message)
1132
+ end
1133
+
1134
+ # A database config whose name is the slot, so DatabaseTasks loads into that
1135
+ # database and not the test database.
1136
+ #
1137
+ # @param slot_hash [Hash]
1138
+ # @return [ActiveRecord::DatabaseConfigurations::HashConfig]
1139
+ def self.mysql_db_config(slot_hash)
1140
+ env = ENV["RAILS_ENV"].to_s
1141
+ env = "test" if env.empty?
1142
+ ActiveRecord::DatabaseConfigurations::HashConfig.new(env, "primary", slot_hash)
1143
+ end
1144
+
1145
+ # Schema format the booted app is using.
1146
+ #
1147
+ # @return [Symbol]
1148
+ def self.current_schema_format
1149
+ if ActiveRecord.respond_to?(:schema_format)
1150
+ ActiveRecord.schema_format
1151
+ else
1152
+ ActiveRecord::Base.schema_format
1153
+ end
1154
+ end
1155
+
1156
+ # Absolute path of the schema file for `format`, or nil when it is absent.
1157
+ # DatabaseTasks aborts the process when the file is missing, so the caller
1158
+ # skips the load instead.
1159
+ #
1160
+ # @param db_config [ActiveRecord::DatabaseConfigurations::HashConfig]
1161
+ # @param format [Symbol]
1162
+ # @return [String, nil]
1163
+ def self.mysql_schema_file(db_config, format)
1164
+ path = ActiveRecord::Tasks::DatabaseTasks.schema_dump_path(db_config, format)
1165
+ path if path && File.exist?(path)
1166
+ end
1167
+
1168
+ # Copy every base table from `base` into `dest`. Foreign key checks are off.
1169
+ # Generated columns are left out of the INSERT list. A table the schema load
1170
+ # did not create is cloned with CREATE TABLE ... LIKE, then its foreign keys
1171
+ # are added. LIKE does not copy them. Each table's AUTO_INCREMENT is set
1172
+ # from the base after the rows, so a deleted high id is not reused.
1173
+ # Triggers the schema load created are dropped for the copy and created
1174
+ # again after it, so the copied rows match the base.
1175
+ #
1176
+ # @param conn [Mysql2::Client, Trilogy]
1177
+ # @param base [String]
1178
+ # @param dest [String]
1179
+ # @return [void]
1180
+ def self.copy_mysql_rows(conn, base, dest)
1181
+ mysql_exec(conn, "SET FOREIGN_KEY_CHECKS = 0")
1182
+ # MySQL 8 caches information_schema.TABLES values, AUTO_INCREMENT among
1183
+ # them, for a day by default. The copy reads them live.
1184
+ begin
1185
+ mysql_exec(conn, "SET SESSION information_schema_stats_expiry = 0")
1186
+ rescue StandardError
1187
+ nil # MariaDB has no such cache or variable.
1188
+ end
1189
+ triggers = drop_mysql_triggers(conn, dest)
1190
+ tables = mysql_rows(conn, <<~SQL)
1191
+ SELECT TABLE_NAME AS table_name
1192
+ FROM information_schema.TABLES
1193
+ WHERE TABLE_SCHEMA = #{mysql_quote_string(conn, base)}
1194
+ AND TABLE_TYPE = 'BASE TABLE'
1195
+ SQL
1196
+ names = []
1197
+ cloned = []
1198
+ tables.each do |row|
1199
+ table = row["table_name"].to_s
1200
+ next if table.empty?
1201
+
1202
+ names << table
1203
+ cloned << table if ensure_mysql_table(conn, base, dest, table)
1204
+ columns = copyable_mysql_columns(conn, base, table)
1205
+ next if columns.empty?
1206
+
1207
+ dest_table = "#{quote_mysql_ident(dest)}.#{quote_mysql_ident(table)}"
1208
+ base_table = "#{quote_mysql_ident(base)}.#{quote_mysql_ident(table)}"
1209
+ listed = columns.map { |column| quote_mysql_ident(column) }.join(", ")
1210
+ mysql_exec(conn, "DELETE FROM #{dest_table}")
1211
+ mysql_exec(conn, "INSERT INTO #{dest_table} (#{listed}) SELECT #{listed} FROM #{base_table}")
1212
+ end
1213
+ cloned.each { |table| copy_mysql_foreign_keys(conn, base, dest, table) }
1214
+ names.each { |table| copy_mysql_auto_increment(conn, base, dest, table) }
1215
+ restore_mysql_triggers(conn, dest, triggers)
1216
+ ensure
1217
+ begin
1218
+ mysql_exec(conn, "SET FOREIGN_KEY_CHECKS = 1")
1219
+ rescue StandardError
1220
+ nil
1221
+ end
1222
+ end
1223
+
1224
+ # Drop every trigger on `dest` and return what {restore_mysql_triggers}
1225
+ # needs to create each one again. Triggers on one table event come back in
1226
+ # ACTION_ORDER. MySQL 8's CREATE text has no FOLLOWS or PRECEDES, so
1227
+ # creating them in that order keeps their order.
1228
+ #
1229
+ # The CREATE text is read as utf8mb4 with an empty sql_mode. mysql2 0.5.6
1230
+ # and older open utf8mb3, which turns a 4-byte character into "?". Under
1231
+ # ANSI_QUOTES, MySQL quotes the names with double quotes, which the
1232
+ # trigger's own sql_mode may not read. Backticks read in every sql_mode.
1233
+ # The session stays on utf8mb4, so {restore_mysql_triggers} sends the
1234
+ # text back unchanged.
1235
+ #
1236
+ # @param conn [Mysql2::Client, Trilogy]
1237
+ # @param dest [String]
1238
+ # @return [Array<Hash>] `:name`, `:sql`, `:sql_mode` and `:collation` for each trigger.
1239
+ # @raise [RuntimeError] when SHOW CREATE TRIGGER returns no row for a trigger.
1240
+ def self.drop_mysql_triggers(conn, dest)
1241
+ rows = mysql_rows(conn, <<~SQL)
1242
+ SELECT TRIGGER_NAME AS trigger_name
1243
+ FROM information_schema.TRIGGERS
1244
+ WHERE TRIGGER_SCHEMA = #{mysql_quote_string(conn, dest)}
1245
+ ORDER BY EVENT_OBJECT_TABLE, ACTION_TIMING, EVENT_MANIPULATION, ACTION_ORDER
1246
+ SQL
1247
+ return [] if rows.empty?
1248
+
1249
+ sql_mode = mysql_rows(conn, "SELECT @@SESSION.sql_mode AS sql_mode").first["sql_mode"]
1250
+ mysql_exec(conn, "SET NAMES utf8mb4")
1251
+ mysql_exec(conn, "SET SESSION sql_mode = ''")
1252
+ # Limit: the text comes back as utf8mb4. In a trigger saved with another
1253
+ # client charset, a _binary or _latin1 literal that holds non-ASCII bytes
1254
+ # changes bytes on replay.
1255
+ triggers = rows.map do |row|
1256
+ name = row["trigger_name"].to_s
1257
+ shown = mysql_rows(conn, "SHOW CREATE TRIGGER #{quote_mysql_ident(dest)}.#{quote_mysql_ident(name)}").first
1258
+ raise "cannot read trigger #{name} on #{dest}" unless shown
1259
+
1260
+ { name: name, sql: shown["sql original statement"].to_s, sql_mode: shown["sql_mode"].to_s,
1261
+ collation: shown["collation_connection"].to_s }
1262
+ end
1263
+ # Every definition is saved before the first drop.
1264
+ triggers.each { |trigger| mysql_exec(conn, "DROP TRIGGER #{quote_mysql_ident(dest)}.#{quote_mysql_ident(trigger[:name])}") }
1265
+ mysql_exec(conn, "SET SESSION sql_mode = #{mysql_quote_string(conn, sql_mode)}")
1266
+ triggers
1267
+ end
1268
+
1269
+ # Create the triggers {drop_mysql_triggers} dropped. Each one is created
1270
+ # with its own sql_mode and connection collation, which decide how MySQL
1271
+ # reads its body. The CREATE text names no database, so this session
1272
+ # switches to `dest`. The sql_mode and collation go back to their values at
1273
+ # the start of this call. The character set stays utf8mb4. A failed create
1274
+ # names the trigger, for example when the DEFINER account needs SET_USER_ID.
1275
+ #
1276
+ # @param conn [Mysql2::Client, Trilogy]
1277
+ # @param dest [String]
1278
+ # @param triggers [Array<Hash>]
1279
+ # @return [void]
1280
+ # @raise [RuntimeError] when MySQL refuses to create a trigger.
1281
+ def self.restore_mysql_triggers(conn, dest, triggers)
1282
+ return if triggers.empty?
1283
+
1284
+ saved = mysql_rows(conn, "SELECT @@SESSION.sql_mode AS sql_mode, @@SESSION.collation_connection AS collation").first
1285
+ mysql_exec(conn, "USE #{quote_mysql_ident(dest)}")
1286
+ triggers.each do |trigger|
1287
+ mysql_exec(conn, mysql_session_modes_sql(conn, trigger[:sql_mode], trigger[:collation]))
1288
+ mysql_exec(conn, trigger[:sql])
1289
+ rescue StandardError => e
1290
+ raise "cannot recreate trigger #{trigger[:name]} on #{dest}: #{e.message}"
1291
+ end
1292
+ mysql_exec(conn, mysql_session_modes_sql(conn, saved["sql_mode"], saved["collation"]))
1293
+ end
1294
+
1295
+ # SET statement for the session's sql_mode and connection collation.
1296
+ #
1297
+ # @param conn [Mysql2::Client, Trilogy]
1298
+ # @param sql_mode [String]
1299
+ # @param collation [String]
1300
+ # @return [String]
1301
+ def self.mysql_session_modes_sql(conn, sql_mode, collation)
1302
+ "SET SESSION sql_mode = #{mysql_quote_string(conn, sql_mode)}, " \
1303
+ "collation_connection = #{mysql_quote_string(conn, collation)}"
1304
+ end
1305
+
1306
+ # Create `table` on `dest` when the schema load did not. LIKE copies columns,
1307
+ # including generated columns, and does not copy foreign keys. The caller
1308
+ # adds those keys after every missing table exists.
1309
+ #
1310
+ # @param conn [Mysql2::Client, Trilogy]
1311
+ # @param base [String]
1312
+ # @param dest [String]
1313
+ # @param table [String]
1314
+ # @return [Boolean] true when this call created the table.
1315
+ def self.ensure_mysql_table(conn, base, dest, table)
1316
+ rows = mysql_rows(conn, <<~SQL)
1317
+ SELECT TABLE_NAME AS table_name
1318
+ FROM information_schema.TABLES
1319
+ WHERE TABLE_SCHEMA = #{mysql_quote_string(conn, dest)}
1320
+ AND TABLE_NAME = #{mysql_quote_string(conn, table)}
1321
+ AND TABLE_TYPE = 'BASE TABLE'
1322
+ SQL
1323
+ return false unless rows.empty?
1324
+
1325
+ mysql_exec(conn, "CREATE TABLE #{quote_mysql_ident(dest)}.#{quote_mysql_ident(table)} LIKE #{quote_mysql_ident(base)}.#{quote_mysql_ident(table)}")
1326
+ true
1327
+ end
1328
+
1329
+ # Add foreign keys from `base.table` onto `dest.table`. Keys that already
1330
+ # exist on `dest` are left alone. A referenced table in `base` is retargeted
1331
+ # at `dest` so the worker does not point back at the test database.
1332
+ #
1333
+ # @param conn [Mysql2::Client, Trilogy]
1334
+ # @param base [String]
1335
+ # @param dest [String]
1336
+ # @param table [String]
1337
+ # @return [void]
1338
+ def self.copy_mysql_foreign_keys(conn, base, dest, table)
1339
+ existing = mysql_rows(conn, <<~SQL)
1340
+ SELECT CONSTRAINT_NAME AS constraint_name
1341
+ FROM information_schema.TABLE_CONSTRAINTS
1342
+ WHERE TABLE_SCHEMA = #{mysql_quote_string(conn, dest)}
1343
+ AND TABLE_NAME = #{mysql_quote_string(conn, table)}
1344
+ AND CONSTRAINT_TYPE = 'FOREIGN KEY'
1345
+ SQL
1346
+ have = existing.map { |row| row["constraint_name"].to_s }
1347
+ rows = mysql_rows(conn, <<~SQL)
1348
+ SELECT k.CONSTRAINT_NAME AS constraint_name,
1349
+ k.COLUMN_NAME AS column_name,
1350
+ k.ORDINAL_POSITION AS ordinal_position,
1351
+ k.REFERENCED_TABLE_SCHEMA AS referenced_table_schema,
1352
+ k.REFERENCED_TABLE_NAME AS referenced_table_name,
1353
+ k.REFERENCED_COLUMN_NAME AS referenced_column_name,
1354
+ r.UPDATE_RULE AS update_rule,
1355
+ r.DELETE_RULE AS delete_rule,
1356
+ r.MATCH_OPTION AS match_option
1357
+ FROM information_schema.KEY_COLUMN_USAGE k
1358
+ INNER JOIN information_schema.REFERENTIAL_CONSTRAINTS r
1359
+ ON r.CONSTRAINT_SCHEMA = k.CONSTRAINT_SCHEMA
1360
+ AND r.CONSTRAINT_NAME = k.CONSTRAINT_NAME
1361
+ AND r.TABLE_NAME = k.TABLE_NAME
1362
+ WHERE k.TABLE_SCHEMA = #{mysql_quote_string(conn, base)}
1363
+ AND k.TABLE_NAME = #{mysql_quote_string(conn, table)}
1364
+ AND k.REFERENCED_TABLE_NAME IS NOT NULL
1365
+ ORDER BY k.CONSTRAINT_NAME, k.ORDINAL_POSITION
1366
+ SQL
1367
+ rows = rows.reject { |row| have.include?(row["constraint_name"].to_s) }
1368
+ sql = mysql_add_foreign_key_sql(dest, table, rows, base: base)
1369
+ mysql_exec(conn, sql) if sql
1370
+ end
1371
+
1372
+ # One ALTER TABLE that adds every foreign key in `rows`.
1373
+ #
1374
+ # @param dest [String]
1375
+ # @param table [String]
1376
+ # @param rows [Array<Hash>]
1377
+ # @param base [String] test database name. References to it are rewritten to `dest`.
1378
+ # @return [String, nil]
1379
+ def self.mysql_add_foreign_key_sql(dest, table, rows, base:)
1380
+ clauses = mysql_foreign_key_clauses(rows, base: base, dest: dest)
1381
+ return nil if clauses.empty?
1382
+
1383
+ "ALTER TABLE #{quote_mysql_ident(dest)}.#{quote_mysql_ident(table)} #{clauses.join(', ')}"
1384
+ end
1385
+
1386
+ # ADD CONSTRAINT fragments for `rows`, grouped by constraint name.
1387
+ #
1388
+ # @param rows [Array<Hash>]
1389
+ # @param base [String]
1390
+ # @param dest [String]
1391
+ # @return [Array<String>]
1392
+ def self.mysql_foreign_key_clauses(rows, base:, dest:)
1393
+ grouped = {}
1394
+ Array(rows).each do |row|
1395
+ name = row["constraint_name"].to_s
1396
+ next if name.empty?
1397
+
1398
+ (grouped[name] ||= []) << row
1399
+ end
1400
+ grouped.filter_map do |name, cols|
1401
+ ordered = cols.sort_by { |row| row["ordinal_position"].to_i }
1402
+ local_cols = ordered.map { |row| row["column_name"].to_s }
1403
+ ref_cols = ordered.map { |row| row["referenced_column_name"].to_s }
1404
+ ref_table = ordered.first["referenced_table_name"].to_s
1405
+ next if ref_table.empty? || local_cols.any?(&:empty?) || ref_cols.any?(&:empty?)
1406
+
1407
+ ref_schema = ordered.first["referenced_table_schema"].to_s
1408
+ ref_schema = dest if ref_schema.empty? || ref_schema == base
1409
+ delete_rule = mysql_referential_action(ordered.first["delete_rule"])
1410
+ update_rule = mysql_referential_action(ordered.first["update_rule"])
1411
+ match = mysql_match_clause(ordered.first["match_option"])
1412
+ local_sql = local_cols.map { |column| quote_mysql_ident(column) }.join(", ")
1413
+ ref_sql = ref_cols.map { |column| quote_mysql_ident(column) }.join(", ")
1414
+ target = "#{quote_mysql_ident(ref_schema)}.#{quote_mysql_ident(ref_table)}"
1415
+ "ADD CONSTRAINT #{quote_mysql_ident(name)} FOREIGN KEY (#{local_sql}) REFERENCES #{target} (#{ref_sql})#{match} ON DELETE #{delete_rule} ON UPDATE #{update_rule}"
1416
+ end
1417
+ end
1418
+
1419
+ # A foreign-key action safe to interpolate. Unknown text raises.
1420
+ #
1421
+ # @param value [String, nil]
1422
+ # @return [String]
1423
+ # @raise [ArgumentError] when `value` is not one of {MYSQL_REFERENTIAL_ACTIONS}.
1424
+ def self.mysql_referential_action(value)
1425
+ action = value.to_s.strip.upcase.tr("_", " ")
1426
+ return action if MYSQL_REFERENTIAL_ACTIONS.include?(action)
1427
+
1428
+ raise ArgumentError, "unsupported foreign key action #{value.inspect}"
1429
+ end
1430
+
1431
+ # MATCH fragment, or an empty string for the default NONE.
1432
+ #
1433
+ # @param value [String, nil]
1434
+ # @return [String]
1435
+ # @raise [ArgumentError] when `value` is not NONE, FULL, or PARTIAL.
1436
+ def self.mysql_match_clause(value)
1437
+ match = value.to_s.strip.upcase
1438
+ return "" if match.empty? || match == "NONE"
1439
+ return " MATCH #{match}" if %w[FULL PARTIAL].include?(match)
1440
+
1441
+ raise ArgumentError, "unsupported foreign key match #{value.inspect}"
1442
+ end
1443
+
1444
+ # Set `dest.table`'s AUTO_INCREMENT from the base table when the base has one.
1445
+ #
1446
+ # @param conn [Mysql2::Client, Trilogy]
1447
+ # @param base [String]
1448
+ # @param dest [String]
1449
+ # @param table [String]
1450
+ # @return [void]
1451
+ def self.copy_mysql_auto_increment(conn, base, dest, table)
1452
+ rows = mysql_rows(conn, <<~SQL)
1453
+ SELECT AUTO_INCREMENT AS auto_increment
1454
+ FROM information_schema.TABLES
1455
+ WHERE TABLE_SCHEMA = #{mysql_quote_string(conn, base)}
1456
+ AND TABLE_NAME = #{mysql_quote_string(conn, table)}
1457
+ SQL
1458
+ sql = mysql_auto_increment_sql(dest, table, rows.dig(0, "auto_increment"))
1459
+ mysql_exec(conn, sql) if sql
1460
+ end
1461
+
1462
+ # ALTER TABLE statement that sets AUTO_INCREMENT, or nil when `value` is blank.
1463
+ #
1464
+ # @param dest [String]
1465
+ # @param table [String]
1466
+ # @param value [Object]
1467
+ # @return [String, nil]
1468
+ def self.mysql_auto_increment_sql(dest, table, value)
1469
+ return nil if value.nil? || value.to_s.empty?
1470
+
1471
+ number = Integer(value)
1472
+ return nil if number < 1
1473
+
1474
+ "ALTER TABLE #{quote_mysql_ident(dest)}.#{quote_mysql_ident(table)} AUTO_INCREMENT = #{number}"
1475
+ rescue ArgumentError, TypeError
1476
+ nil
1477
+ end
1478
+
1479
+ # Column names that an INSERT may set. Stored and virtual generated columns
1480
+ # are omitted. Expression defaults stay, because they are real columns.
1481
+ #
1482
+ # @param conn [Mysql2::Client, Trilogy]
1483
+ # @param base [String]
1484
+ # @param table [String]
1485
+ # @return [Array<String>]
1486
+ def self.copyable_mysql_columns(conn, base, table)
1487
+ rows = mysql_rows(conn, <<~SQL)
1488
+ SELECT COLUMN_NAME AS column_name, EXTRA AS extra
1489
+ FROM information_schema.COLUMNS
1490
+ WHERE TABLE_SCHEMA = #{mysql_quote_string(conn, base)}
1491
+ AND TABLE_NAME = #{mysql_quote_string(conn, table)}
1492
+ ORDER BY ORDINAL_POSITION
1493
+ SQL
1494
+ rows.filter_map do |row|
1495
+ name = row["column_name"].to_s
1496
+ next if name.empty?
1497
+ next if row["extra"].to_s.match?(/VIRTUAL GENERATED|STORED GENERATED/i)
1498
+
1499
+ name
1500
+ end
1501
+ end
1502
+
1503
+ # Run a statement that does not need its result rows.
1504
+ #
1505
+ # @param conn [Mysql2::Client, Trilogy]
1506
+ # @param sql [String]
1507
+ # @return [void]
1508
+ def self.mysql_exec(conn, sql)
1509
+ conn.query(sql)
1510
+ nil
1511
+ end
1512
+
1513
+ # Result rows as string-keyed hashes. mysql2 yields hashes from `each`.
1514
+ # trilogy yields them from `each_hash`. Keys are downcased so aliases match.
1515
+ #
1516
+ # @param conn [Mysql2::Client, Trilogy]
1517
+ # @param sql [String]
1518
+ # @return [Array<Hash>]
1519
+ def self.mysql_rows(conn, sql)
1520
+ result = conn.query(sql)
1521
+ return [] if result.nil?
1522
+
1523
+ rows = []
1524
+ if result.respond_to?(:each_hash)
1525
+ result.each_hash { |row| rows << stringify_mysql_row(row) }
1526
+ else
1527
+ result.each(as: :hash) { |row| rows << stringify_mysql_row(row) }
1528
+ end
1529
+ rows
1530
+ end
1531
+
1532
+ # @param row [Hash]
1533
+ # @return [Hash]
1534
+ def self.stringify_mysql_row(row)
1535
+ row.to_h.transform_keys { |key| key.to_s.downcase }
1536
+ end
1537
+
1538
+ # Quote a string for a SQL literal with the client the app loaded.
1539
+ #
1540
+ # @param conn [Mysql2::Client, Trilogy]
1541
+ # @param value [String]
1542
+ # @return [String]
1543
+ def self.mysql_quote_string(conn, value)
1544
+ "'#{conn.escape(value.to_s)}'"
1545
+ end
1546
+
1547
+ # Quote a MySQL identifier. Backticks inside the name are doubled.
1548
+ #
1549
+ # @param name [String]
1550
+ # @return [String]
1551
+ def self.quote_mysql_ident(name)
1552
+ raise ArgumentError, "database name is empty" if name.nil? || name.empty?
1553
+ raise ArgumentError, "database name contains a null" if name.include?("\0")
1554
+
1555
+ "`#{name.to_s.gsub('`', '``')}`"
1556
+ end
1557
+
1558
+ # True when `error` is already a provision failure this module raised.
1559
+ #
1560
+ # @param error [StandardError]
1561
+ # @return [Boolean]
1562
+ def self.provision_wrapped?(error)
1563
+ message = error.message
1564
+ message.start_with?("could not provision", "another Mutineer run", "refusing to drop")
1565
+ end
137
1566
  end
138
1567
  end