Skip to content

Commit

Permalink
Run db commands on test as well as development databases (#247)
Browse files Browse the repository at this point in the history
It's a frustrating experience if users have to remember to run each of their `hanami db` commands again with `-e test` after running them on their development database.

To improve this, update a number of commands to automatically re-run on the test database after completing their work on the development database:

- `db create`
- `db drop`
- `db migrate`
- `db prepare`
- `db structure load`

The re-run on test will only take place if these commands operate with the development environment. Running the commands in other environments (e.g. production or test) will run on the database in those environments **only.**

To achieve this "re-run on test" behaviour, at the end of these `db` commands, we invoke another process to call the relevant `hanami db` command again, but with the `HANAMI_ENV=test` environment variable set. For this re-run, all given CLI flags are preserved, but `-e` and `--env` are of course stripped.

The reason we have to take this approach is because right now there's no straightforward way to re-boot the Hanami app in a different environment, or stand up the database subsystem itself in a different environment.
  • Loading branch information
timriley authored Oct 26, 2024
1 parent b1481ec commit 712a1ad
Show file tree
Hide file tree
Showing 12 changed files with 308 additions and 9 deletions.
76 changes: 75 additions & 1 deletion lib/hanami/cli/commands/app/db/command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,23 @@ class Command < App::Command
option :app, required: false, type: :flag, default: false, desc: "Use app database"
option :slice, required: false, desc: "Use database for slice"

# @api private
attr_reader :system_call

# @api private
attr_reader :test_env_executor

def initialize(
out:, err:,
system_call: SystemCall.new,
test_env_executor: InteractiveSystemCall.new(out: out, err: err),
nested_command: false,
**opts
)
super(out: out, err: err, **opts)
@system_call = system_call
@test_env_executor = test_env_executor
@nested_command = nested_command
end

def run_command(klass, ...)
Expand All @@ -34,9 +42,15 @@ def run_command(klass, ...)
inflector: inflector,
fs: fs,
system_call: system_call,
test_env_executor: test_env_executor,
nested_command: true,
).call(...)
end

def nested_command?
@nested_command
end

private

def databases(app: false, slice: nil, gateway: nil)
Expand Down Expand Up @@ -77,7 +91,7 @@ def database_for_slice(slice, gateway: nil)
end
end

def all_databases # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity
def all_databases # rubocop:disable Metrics/AbcSize
slices = [app] + app.slices.with_nested

slice_gateways_by_database_url = slices.each_with_object({}) { |slice, hsh|
Expand Down Expand Up @@ -140,6 +154,66 @@ def warn_on_misconfigured_database(database, slices) # rubocop:disable Metrics/A
STR
end
end

# Invokes the currently executing `hanami` CLI command again, but with any `--env` args
# removed and the `HANAMI_ENV=test` env var set.
#
# This is called by certain `db` commands only, and runs only if the Hanami env is
# `:development`. This behavior important to streamline the local development
# experience, making sure that the test databases are kept in sync with operations run
# on the development databases.
#
# Spawning an entirely new process to change the env is a compromise approach until we
# can have an API for reinitializing the DB subsystem in-process with a different env.
def re_run_development_command_in_test
# Only invoke a new process if we've been called as `hanami`. This avoids awkward
# failures when testing commands via RSpec, for which the $0 is "/full/path/to/rspec".
return unless $0.end_with?("hanami")

# If this special env key is set, then a re-run has already been invoked. This would
# mean the current command is actually a nested command run by another db command. In
# this case, don't trigger a re-runs, because one is already in process.
return if nested_command?

# Re-runs in test are for development-env commands only.
return unless Hanami.env == :development

cmd = $0
cmd = "bundle exec #{cmd}" if ENV.key?("BUNDLE_BIN_PATH")

test_env_executor.call(
cmd, *argv_without_env_args,
env: {
"HANAMI_ENV" => "test",
"HANAMI_CLI_DB_COMMAND_RE_RUN_IN_TEST" => "true"
}
)
end

def re_running_in_test?
ENV.key?("HANAMI_CLI_DB_COMMAND_RE_RUN_IN_TEST")
end

# Returns the `ARGV` with every option argument included, but the `-e` or `--env` args
# removed.
def argv_without_env_args
new_argv = ARGV.dup

env_arg_index = new_argv.index {
_1 == "-e" || _1 == "--env" || _1.start_with?("-e=") || _1.start_with?("--env=")
}

if env_arg_index
# Remove the env argument
env_arg = new_argv.delete_at(env_arg_index)

# If the env argument is not in combined form ("--env foo" rather than "--env=foo"),
# then remove the following argument too
new_argv.delete_at(env_arg_index) if ["-e", "--env"].include?(env_arg)
end

new_argv
end
end
end
end
Expand Down
2 changes: 2 additions & 0 deletions lib/hanami/cli/commands/app/db/create.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ def call(app: false, slice: nil, gateway: nil, command_exit: method(:exit), **)
exit_codes.each do |code|
break command_exit.(code) if code > 0
end

re_run_development_command_in_test
end
end
end
Expand Down
2 changes: 2 additions & 0 deletions lib/hanami/cli/commands/app/db/drop.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ def call(app: false, slice: nil, gateway: nil, **)
exit_codes.each do |code|
break exit code if code > 0
end

re_run_development_command_in_test
end
end
end
Expand Down
11 changes: 10 additions & 1 deletion lib/hanami/cli/commands/app/db/migrate.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,16 @@ def call(target: nil, app: false, slice: nil, gateway: nil, dump: true, command_
end
end

run_command(Structure::Dump, app: app, slice: slice, gateway: gateway, command_exit: command_exit) if dump
# Only dump for the initial command, not a re-run of the command in test env
if dump && !re_running_in_test?
run_command(
Structure::Dump,
app: app, slice: slice, gateway: gateway,
command_exit: command_exit
)
end

re_run_development_command_in_test
end

private
Expand Down
4 changes: 3 additions & 1 deletion lib/hanami/cli/commands/app/db/prepare.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ def call(app: false, slice: nil, **)
end

# Finally, load the seeds for the slice overall, which is a once-per-slice operation.
run_command(DB::Seed, app: app, slice: slice)
run_command(DB::Seed, app: app, slice: slice) unless re_running_in_test?

re_run_development_command_in_test
end
end
end
Expand Down
2 changes: 2 additions & 0 deletions lib/hanami/cli/commands/app/db/structure/load.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ def call(app: false, slice: nil, gateway: nil, command_exit: method(:exit), **)
exit_codes.each do |code|
break command_exit.(code) if code > 0
end

re_run_development_command_in_test
end
end
end
Expand Down
31 changes: 31 additions & 0 deletions spec/support/hanami_cli_environment.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# frozen_string_literal: true

module RSpec
module Support
module HanamiCLIEnvironment
# Adjusts $0 and ARGV to match the values expected when the `hanami` CLI is invoked in
# ordinary usage.
#
# This is a workaround for our current (comrpomise) approach of re-executing DB CLI commands
# in test mode.
#
# @see Hanami::CLI::Commands::App::DB::Command#re_run_development_command_in_test
def as_hanami_cli_with_args(args)
original_0 = $0.dup
original_argv = ARGV.dup

$0 = "hanami"
ARGV.replace(args)

yield

$0 = original_0
ARGV.replace(original_argv)
end
end
end
end

RSpec.configure do |config|
config.include RSpec::Support::HanamiCLIEnvironment
end
39 changes: 38 additions & 1 deletion spec/unit/hanami/cli/commands/app/db/create_spec.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
# frozen_string_literal: true

RSpec.describe Hanami::CLI::Commands::App::DB::Create, :app_integration do
subject(:command) { described_class.new(system_call: system_call, out: out) }
subject(:command) {
described_class.new(
system_call: system_call,
test_env_executor: test_env_executor,
out: out
)
}

let(:system_call) { Hanami::CLI::SystemCall.new }
let(:test_env_executor) { instance_spy(Hanami::CLI::InteractiveSystemCall) }

let(:out) { StringIO.new }
def output = out.string
Expand Down Expand Up @@ -287,5 +294,35 @@ def before_prepare
expect(command).to have_received(:exit).with(2).once
end
end

describe "automatic test env execution" do
before do
ENV["DATABASE_URL"] = "sqlite://db/app.sqlite3"
end

around do |example|
as_hanami_cli_with_args(%w[db create]) { example.run }
end

it "re-executes the command in test env when run with development env" do
command.call(env: "development")

expect(test_env_executor).to have_received(:call).with(
"bundle exec hanami",
"db", "create",
{
env: hash_including("HANAMI_ENV" => "test")
}
)
end

it "does not re-execute the command when run with other environments" do
command.call(env: "test")
expect(test_env_executor).not_to have_received(:call)

command.call(env: "production")
expect(test_env_executor).not_to have_received(:call)
end
end
end
end
39 changes: 38 additions & 1 deletion spec/unit/hanami/cli/commands/app/db/drop_spec.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
# frozen_string_literal: true

RSpec.describe Hanami::CLI::Commands::App::DB::Drop, :app_integration do
subject(:command) { described_class.new(system_call: system_call, out: out) }
subject(:command) {
described_class.new(
system_call: system_call,
test_env_executor: test_env_executor,
out: out
)
}

let(:system_call) { Hanami::CLI::SystemCall.new }
let(:test_env_executor) { instance_spy(Hanami::CLI::InteractiveSystemCall) }

let(:out) { StringIO.new }
def output = out.string
Expand Down Expand Up @@ -410,4 +417,34 @@ def before_prepare
expect(command).to have_received(:exit).with(2).once
end
end

describe "automatic test env execution" do
before do
ENV["DATABASE_URL"] = "sqlite://db/app.sqlite3"
end

around do |example|
as_hanami_cli_with_args(%w[db drop]) { example.run }
end

it "re-executes the command in test env when run with development env" do
command.call(env: "development")

expect(test_env_executor).to have_received(:call).with(
"bundle exec hanami",
"db", "drop",
{
env: hash_including("HANAMI_ENV" => "test")
}
)
end

it "does not re-execute the command when run with other environments" do
command.call(env: "test")
expect(test_env_executor).not_to have_received(:call)

command.call(env: "production")
expect(test_env_executor).not_to have_received(:call)
end
end
end
35 changes: 34 additions & 1 deletion spec/unit/hanami/cli/commands/app/db/migrate_spec.rb
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
# frozen_string_literal: true

RSpec.describe Hanami::CLI::Commands::App::DB::Migrate, :app_integration do
subject(:command) { described_class.new(out: out) }
subject(:command) { described_class.new(out: out, test_env_executor: test_env_executor) }

let(:out) { StringIO.new }
def output = out.string

let(:test_env_executor) { instance_spy(Hanami::CLI::InteractiveSystemCall) }

let(:dump_command) { instance_spy(Hanami::CLI::Commands::App::DB::Structure::Dump) }

before do
Expand Down Expand Up @@ -418,4 +420,35 @@ def before_prepare
expect(output).not_to include "migrated"
end
end

describe "automatic test env execution" do
before do
ENV["DATABASE_URL"] = "sqlite://db/app.sqlite3"
db_create
end

around do |example|
as_hanami_cli_with_args(%w[db migrate]) { example.run }
end

it "re-executes the command in test env when run with development env" do
command.call(env: "development")

expect(test_env_executor).to have_received(:call).with(
"bundle exec hanami",
"db", "migrate",
{
env: hash_including("HANAMI_ENV" => "test")
}
)
end

it "does not re-execute the command when run with other environments" do
command.call(env: "test")
expect(test_env_executor).not_to have_received(:call)

command.call(env: "production")
expect(test_env_executor).not_to have_received(:call)
end
end
end
Loading

0 comments on commit 712a1ad

Please sign in to comment.