Refactoring Database generators

Extract all the DB information (gems, dockerfile packages, devcontainer etc.) into an object. This let's us remove the growing number of case statements in this code.
This commit is contained in:
Andrew Novoselac 2024-05-08 07:40:53 -04:00
parent 5b91084f20
commit c1bc7ee82e
8 changed files with 317 additions and 214 deletions

View File

@ -12,7 +12,6 @@ require "active_support/core_ext/array/extract_options"
module Rails module Rails
module Generators module Generators
class AppBase < Base # :nodoc: class AppBase < Base # :nodoc:
include Database
include Devcontainer include Devcontainer
include AppName include AppName
@ -40,7 +39,7 @@ module Rails
desc: "Path to some #{name} template (can be a filesystem path or URL)" desc: "Path to some #{name} template (can be a filesystem path or URL)"
class_option :database, type: :string, aliases: "-d", default: "sqlite3", class_option :database, type: :string, aliases: "-d", default: "sqlite3",
enum: DATABASES, enum: Database::DATABASES,
desc: "Preconfigure for selected database" desc: "Preconfigure for selected database"
class_option :skip_git, type: :boolean, aliases: "-G", default: nil, class_option :skip_git, type: :boolean, aliases: "-G", default: nil,
@ -279,7 +278,7 @@ module Rails
def database_gemfile_entry # :doc: def database_gemfile_entry # :doc:
return if options[:skip_active_record] return if options[:skip_active_record]
gem_name, gem_version = gem_for_database gem_name, gem_version = database.gem
GemfileEntry.version gem_name, gem_version, GemfileEntry.version gem_name, gem_version,
"Use #{options[:database]} as the database for Active Record" "Use #{options[:database]} as the database for Active Record"
end end
@ -574,7 +573,7 @@ module Rails
packages = ["curl"] packages = ["curl"]
# ActiveRecord databases # ActiveRecord databases
packages << base_package_for_database unless skip_active_record? packages << database.base_package unless skip_active_record?
# ActiveStorage preview support # ActiveStorage preview support
packages << "libvips" unless skip_active_storage? packages << "libvips" unless skip_active_storage?
@ -590,7 +589,7 @@ module Rails
packages = %w(build-essential git pkg-config) packages = %w(build-essential git pkg-config)
# add database support # add database support
packages << build_package_for_database unless skip_active_record? packages << database.build_package unless skip_active_record?
packages << "unzip" if using_bun? packages << "unzip" if using_bun?
@ -772,6 +771,10 @@ module Rails
directories.sort directories.sort
end end
def database
@database ||= Database.build(options[:database])
end
end end
end end
end end

View File

@ -2,59 +2,84 @@
module Rails module Rails
module Generators module Generators
module Database # :nodoc: class Database
DATABASES = %w( mysql trilogy postgresql sqlite3 ) DATABASES = %w( mysql trilogy postgresql sqlite3 )
def gem_for_database(database = options[:database]) class << self
case database def build(database_name)
when "mysql" then ["mysql2", ["~> 0.5"]] case database_name
when "trilogy" then ["trilogy", ["~> 2.7"]] when "mysql" then MySQL.new
when "postgresql" then ["pg", ["~> 1.1"]] when "postgresql" then PostgreSQL.new
when "sqlite3" then ["sqlite3", [">= 1.4"]] when "trilogy" then MariaDB.new
else [database, nil] when "sqlite3" then SQLite3.new
else Null.new
end
end
def all
@all ||= [
MySQL.new,
PostgreSQL.new,
MariaDB.new,
SQLite3.new,
]
end end
end end
def docker_for_database_base(database = options[:database]) def name
case database raise NotImplementedError
when "mysql" then "curl default-mysql-client libvips"
when "trilogy" then "curl libvips"
when "postgresql" then "curl libvips postgresql-client"
when "sqlite3" then "curl libsqlite3-0 libvips"
else nil
end
end end
def docker_for_database_build(database = options[:database]) def service
case database raise NotImplementedError
when "mysql" then "build-essential default-libmysqlclient-dev git"
when "trilogy" then "build-essential git"
when "postgresql" then "build-essential git libpq-dev"
when "sqlite3" then "build-essential git"
else nil
end
end end
def base_package_for_database(database = options[:database]) def port
case database raise NotImplementedError
when "mysql" then "default-mysql-client"
when "postgresql" then "postgresql-client"
when "sqlite3" then "libsqlite3-0"
else nil
end
end end
def build_package_for_database(database = options[:database]) def feature_name
case database raise NotImplementedError
when "mysql" then "default-libmysqlclient-dev"
when "postgresql" then "libpq-dev"
else nil
end
end end
private def gem
def mysql_socket raise NotImplementedError
@mysql_socket ||= [ end
def docker_base
raise NotImplementedError
end
def docker_build
raise NotImplementedError
end
def base_package
raise NotImplementedError
end
def build_package
raise NotImplementedError
end
def socket; end
def host; end
def feature
return unless feature_name
{ feature_name => {} }
end
def volume
return unless service
"#{name}-data"
end
module MySqlSocket
def socket
@socket ||= [
"/tmp/mysql.sock", # default "/tmp/mysql.sock", # default
"/var/run/mysqld/mysqld.sock", # debian/gentoo "/var/run/mysqld/mysqld.sock", # debian/gentoo
"/var/tmp/mysql.sock", # freebsd "/var/tmp/mysql.sock", # freebsd
@ -67,13 +92,204 @@ module Rails
].find { |f| File.exist?(f) } unless Gem.win_platform? ].find { |f| File.exist?(f) } unless Gem.win_platform?
end end
def mysql_database_host def host
if options[:skip_devcontainer] "localhost"
"localhost"
else
"<%= ENV.fetch(\"DB_HOST\") { \"localhost\" } %>"
end
end end
end
class MySQL < Database
include MySqlSocket
def name
"mysql"
end
def service
{
"image" => "mysql/mysql-server:8.0",
"restart" => "unless-stopped",
"environment" => {
"MYSQL_ALLOW_EMPTY_PASSWORD" => "true",
"MYSQL_ROOT_HOST" => "%"
},
"volumes" => ["mysql-data:/var/lib/mysql"],
"networks" => ["default"],
}
end
def port
3306
end
def gem
["mysql2", ["~> 0.5"]]
end
def docker_base
"curl default-mysql-client libvips"
end
def docker_build
"build-essential default-libmysqlclient-dev git"
end
def base_package
"default-mysql-client"
end
def build_package
"default-libmysqlclient-dev"
end
def feature_name
"ghcr.io/rails/devcontainer/features/mysql-client"
end
end
class PostgreSQL < Database
def name
"postgres"
end
def service
{
"image" => "postgres:16.1",
"restart" => "unless-stopped",
"networks" => ["default"],
"volumes" => ["postgres-data:/var/lib/postgresql/data"],
"environment" => {
"POSTGRES_USER" => "postgres",
"POSTGRES_PASSWORD" => "postgres"
}
}
end
def port
5432
end
def gem
["pg", ["~> 1.1"]]
end
def docker_base
"curl libvips postgresql-client"
end
def docker_build
"build-essential git libpq-dev"
end
def base_package
"postgresql-client"
end
def build_package
"libpq-dev"
end
def feature_name
"ghcr.io/rails/devcontainer/features/postgres-client"
end
end
class MariaDB < Database
include MySqlSocket
def name
"mariadb"
end
def service
{
"image" => "mariadb:10.5",
"restart" => "unless-stopped",
"networks" => ["default"],
"volumes" => ["mariadb-data:/var/lib/mysql"],
"environment" => {
"MARIADB_ALLOW_EMPTY_ROOT_PASSWORD" => "true",
},
}
end
def port
3306
end
def gem
["trilogy", ["~> 2.7"]]
end
def docker_base
"curl libvips"
end
def docker_build
"build-essential git"
end
def base_package
nil
end
def build_package
nil
end
def feature_name
nil
end
end
class SQLite3 < Database
def name
"sqlite3"
end
def service
nil
end
def port
nil
end
def gem
["sqlite3", [">= 1.4"]]
end
def docker_base
"curl libsqlite3-0 libvips"
end
def docker_build
"build-essential git"
end
def base_package
"libsqlite3-0"
end
def build_package
nil
end
def feature_name
"ghcr.io/rails/devcontainer/features/sqlite3"
end
end
class Null < Database
def name; end
def service; end
def port; end
def volume; end
def docker_base; end
def docker_build; end
def base_package; end
def build_package; end
def feature_name; end
end
end end
end end
end end

View File

@ -3,12 +3,6 @@
module Rails module Rails
module Generators module Generators
module Devcontainer module Devcontainer
DB_FEATURES = {
"mysql" => "ghcr.io/rails/devcontainer/features/mysql-client",
"postgresql" => "ghcr.io/rails/devcontainer/features/postgres-client",
"sqlite3" => "ghcr.io/rails/devcontainer/features/sqlite3"
}
private private
def devcontainer_dependencies def devcontainer_dependencies
return @devcontainer_dependencies if @devcontainer_dependencies return @devcontainer_dependencies if @devcontainer_dependencies
@ -17,7 +11,7 @@ module Rails
@devcontainer_dependencies << "selenium" if depends_on_system_test? @devcontainer_dependencies << "selenium" if depends_on_system_test?
@devcontainer_dependencies << "redis" if devcontainer_needs_redis? @devcontainer_dependencies << "redis" if devcontainer_needs_redis?
@devcontainer_dependencies << db_name_for_devcontainer if db_name_for_devcontainer @devcontainer_dependencies << database.name if database.service
@devcontainer_dependencies @devcontainer_dependencies
end end
@ -29,7 +23,7 @@ module Rails
@devcontainer_variables["CAPYBARA_SERVER_PORT"] = "45678" if depends_on_system_test? @devcontainer_variables["CAPYBARA_SERVER_PORT"] = "45678" if depends_on_system_test?
@devcontainer_variables["SELENIUM_HOST"] = "selenium" if depends_on_system_test? @devcontainer_variables["SELENIUM_HOST"] = "selenium" if depends_on_system_test?
@devcontainer_variables["REDIS_URL"] = "redis://redis:6379/1" if devcontainer_needs_redis? @devcontainer_variables["REDIS_URL"] = "redis://redis:6379/1" if devcontainer_needs_redis?
@devcontainer_variables["DB_HOST"] = db_name_for_devcontainer if db_name_for_devcontainer @devcontainer_variables["DB_HOST"] = database.name if database.service
@devcontainer_variables @devcontainer_variables
end end
@ -40,7 +34,7 @@ module Rails
@devcontainer_volumes = [] @devcontainer_volumes = []
@devcontainer_volumes << "redis-data" if devcontainer_needs_redis? @devcontainer_volumes << "redis-data" if devcontainer_needs_redis?
@devcontainer_volumes << db_volume_name_for_devcontainer if db_volume_name_for_devcontainer @devcontainer_volumes << database.volume if database.volume
@devcontainer_volumes @devcontainer_volumes
end end
@ -55,7 +49,7 @@ module Rails
@devcontainer_features["ghcr.io/rails/devcontainer/features/activestorage"] = {} unless options[:skip_active_storage] @devcontainer_features["ghcr.io/rails/devcontainer/features/activestorage"] = {} unless options[:skip_active_storage]
@devcontainer_features["ghcr.io/devcontainers/features/node:1"] = {} if using_node? @devcontainer_features["ghcr.io/devcontainers/features/node:1"] = {} if using_node?
@devcontainer_features.merge!(db_feature_for_devcontainer) if db_feature_for_devcontainer @devcontainer_features.merge!(database.feature) if database.feature
@devcontainer_features @devcontainer_features
end end
@ -74,7 +68,7 @@ module Rails
return @devcontainer_forward_ports if @devcontainer_forward_ports return @devcontainer_forward_ports if @devcontainer_forward_ports
@devcontainer_forward_ports = [3000] @devcontainer_forward_ports = [3000]
@devcontainer_forward_ports << db_port_for_devcontainer if db_port_for_devcontainer @devcontainer_forward_ports << database.port if database.port
@devcontainer_forward_ports << 6379 if devcontainer_needs_redis? @devcontainer_forward_ports << 6379 if devcontainer_needs_redis?
@devcontainer_forward_ports @devcontainer_forward_ports
@ -84,120 +78,10 @@ module Rails
!(options.skip_action_cable? && options.skip_active_job?) !(options.skip_action_cable? && options.skip_active_job?)
end end
def db_port_for_devcontainer(database = options[:database])
case database
when "mysql", "trilogy" then 3306
when "postgresql" then 5432
end
end
def db_name_for_devcontainer(database = options[:database])
case database
when "mysql" then "mysql"
when "trilogy" then "mariadb"
when "postgresql" then "postgres"
end
end
def db_volume_name_for_devcontainer(database = options[:database])
case database
when "mysql" then "mysql-data"
when "trilogy" then "mariadb-data"
when "postgresql" then "postgres-data"
end
end
def db_package_for_dockerfile(database = options[:database])
case database
when "mysql" then "default-libmysqlclient-dev"
when "postgresql" then "libpq-dev"
end
end
def devcontainer_db_service_yaml(**options) def devcontainer_db_service_yaml(**options)
return unless service = db_service_for_devcontainer return unless service = database.service
service.to_yaml(**options)[4..-1] { database.name => service }.to_yaml(**options)[4..-1]
end
def db_service_for_devcontainer(database = options[:database])
case database
when "mysql" then mysql_service
when "trilogy" then mariadb_service
when "postgresql" then postgres_service
end
end
def db_feature_for_devcontainer(database = options[:database])
case database
when "sqlite3" then sqlite3_feature
when "mysql" then mysql_feature
when "postgresql" then postgres_feature
end
end
def postgres_service
{
"postgres" => {
"image" => "postgres:16.1",
"restart" => "unless-stopped",
"networks" => ["default"],
"volumes" => ["postgres-data:/var/lib/postgresql/data"],
"environment" => {
"POSTGRES_USER" => "postgres",
"POSTGRES_PASSWORD" => "postgres"
}
}
}
end
def mysql_service
{
"mysql" => {
"image" => "mysql/mysql-server:8.0",
"restart" => "unless-stopped",
"environment" => {
"MYSQL_ALLOW_EMPTY_PASSWORD" => "true",
"MYSQL_ROOT_HOST" => "%"
},
"volumes" => ["mysql-data:/var/lib/mysql"],
"networks" => ["default"],
}
}
end
def mariadb_service
{
"mariadb" => {
"image" => "mariadb:10.5",
"restart" => "unless-stopped",
"networks" => ["default"],
"volumes" => ["mariadb-data:/var/lib/mysql"],
"environment" => {
"MARIADB_ALLOW_EMPTY_ROOT_PASSWORD" => "true",
},
}
}
end
def db_service_names
["mysql", "mariadb", "postgres"]
end
def mysql_feature
{ DB_FEATURES["mysql"] => {} }
end
def postgres_feature
{ DB_FEATURES["postgresql"] => {} }
end
def sqlite3_feature
{ DB_FEATURES["sqlite3"] => {} }
end
def db_features
@db_features ||= DB_FEATURES.values
end end
def local_rails_mount def local_rails_mount

View File

@ -15,10 +15,10 @@ default: &default
pool: <%%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> pool: <%%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
username: root username: root
password: password:
<% if mysql_socket -%> <% if database.socket -%>
socket: <%= mysql_socket %> socket: <%= database.socket %>
<% else -%> <% else -%>
host: <%= mysql_database_host %> host: <%%= ENV.fetch("DB_HOST") { "<%= database.host %>" } %>
<% end -%> <% end -%>
development: development:

View File

@ -15,10 +15,10 @@ default: &default
pool: <%%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> pool: <%%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
username: root username: root
password: password:
<% if mysql_socket -%> <% if database.socket -%>
socket: <%= mysql_socket %> socket: <%= database.socket %>
<% else -%> <% else -%>
host: <%= mysql_database_host %> host: <%%= ENV.fetch("DB_HOST") { "<%= database.host %>" } %>
<% end -%> <% end -%>
development: development:

View File

@ -100,7 +100,7 @@ jobs:
<%- end -%> <%- end -%>
steps: steps:
- name: Install packages - name: Install packages
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable <%= (dockerfile_base_packages + [build_package_for_database]).join(" ") %> run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable <%= (dockerfile_base_packages + [database.base_package]).join(" ") %>
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4

View File

@ -9,8 +9,6 @@ module Rails
module Db module Db
module System module System
class ChangeGenerator < Base # :nodoc: class ChangeGenerator < Base # :nodoc:
include Database
include Devcontainer
include AppName include AppName
class_option :to, required: true, class_option :to, required: true,
@ -24,8 +22,8 @@ module Rails
def initialize(*) def initialize(*)
super super
unless DATABASES.include?(options[:to]) unless Database::DATABASES.include?(options[:to])
raise Error, "Invalid value for --to option. Supported preconfigurations are: #{DATABASES.join(", ")}." raise Error, "Invalid value for --to option. Supported preconfigurations are: #{Database::DATABASES.join(", ")}."
end end
opt = options.dup opt = options.dup
@ -38,7 +36,7 @@ module Rails
end end
def edit_gemfile def edit_gemfile
name, version = gem_for_database name, version = database.gem
gsub_file("Gemfile", all_database_gems_regex, name) gsub_file("Gemfile", all_database_gems_regex, name)
gsub_file("Gemfile", gem_entry_regex_for(name), gem_entry_for(name, *version)) gsub_file("Gemfile", gem_entry_regex_for(name), gem_entry_for(name, *version))
end end
@ -47,8 +45,8 @@ module Rails
dockerfile_path = File.expand_path("Dockerfile", destination_root) dockerfile_path = File.expand_path("Dockerfile", destination_root)
return unless File.exist?(dockerfile_path) return unless File.exist?(dockerfile_path)
base_name = docker_for_database_base base_name = database.docker_base
build_name = docker_for_database_build build_name = database.docker_build
if base_name if base_name
gsub_file("Dockerfile", all_docker_bases_regex, base_name) gsub_file("Dockerfile", all_docker_bases_regex, base_name)
end end
@ -67,15 +65,15 @@ module Rails
private private
def all_database_gems def all_database_gems
DATABASES.map { |database| gem_for_database(database) } Database.all.map { |database| database.gem }
end end
def all_docker_bases def all_docker_bases
DATABASES.filter_map { |database| docker_for_database_base(database) } Database.all.filter_map { |database| database.docker_base }
end end
def all_docker_builds def all_docker_builds
DATABASES.filter_map { |database| docker_for_database_build(database) } Database.all.filter_map { |database| database.docker_build }
end end
def all_database_gems_regex def all_database_gems_regex
@ -113,19 +111,17 @@ module Rails
compose_config = YAML.load_file(compose_yaml_path) compose_config = YAML.load_file(compose_yaml_path)
db_service_names.each do |db_service_name| Database.all.each do |database|
compose_config["services"].delete(db_service_name) compose_config["services"].delete(database.name)
compose_config["volumes"]&.delete("#{db_service_name}-data") compose_config["volumes"]&.delete(database.volume)
compose_config["services"]["rails-app"]["depends_on"]&.delete(db_service_name) compose_config["services"]["rails-app"]["depends_on"]&.delete(database.name)
end end
db_service = db_service_for_devcontainer if database.service
compose_config["services"][database.name] = database.service
if db_service compose_config["volumes"] = { database.volume => nil }.merge(compose_config["volumes"] || {})
compose_config["services"].merge!(db_service)
compose_config["volumes"] = { db_volume_name_for_devcontainer => nil }.merge(compose_config["volumes"] || {})
compose_config["services"]["rails-app"]["depends_on"] = [ compose_config["services"]["rails-app"]["depends_on"] = [
db_name_for_devcontainer, database.name,
compose_config["services"]["rails-app"]["depends_on"] compose_config["services"]["rails-app"]["depends_on"]
].flatten.compact ].flatten.compact
end end
@ -138,16 +134,16 @@ module Rails
def update_devcontainer_db_host def update_devcontainer_db_host
container_env = devcontainer_json["containerEnv"] container_env = devcontainer_json["containerEnv"]
db_name = db_name_for_devcontainer db_name = database.name
if container_env["DB_HOST"] if container_env["DB_HOST"]
if db_name if database.service
container_env["DB_HOST"] = db_name container_env["DB_HOST"] = db_name
else else
container_env.delete("DB_HOST") container_env.delete("DB_HOST")
end end
else else
if db_name if database.service
container_env["DB_HOST"] = db_name container_env["DB_HOST"] = db_name
end end
end end
@ -159,10 +155,10 @@ module Rails
def update_devcontainer_db_feature def update_devcontainer_db_feature
features = devcontainer_json["features"] features = devcontainer_json["features"]
db_feature = db_feature_for_devcontainer db_feature = database.feature
db_features.each do |feature| Database.all.each do |database|
features.delete(feature) features.delete(database.feature_name)
end end
features.merge!(db_feature) if db_feature features.merge!(db_feature) if db_feature
@ -181,6 +177,10 @@ module Rails
def devcontainer_json_path def devcontainer_json_path
File.expand_path(".devcontainer/devcontainer.json", destination_root) File.expand_path(".devcontainer/devcontainer.json", destination_root)
end end
def database
@database ||= Database.build(options[:database])
end
end end
end end
end end

View File

@ -1240,9 +1240,9 @@ class AppGeneratorTest < Rails::Generators::TestCase
assert_equal "redis://redis:6379/1", content["containerEnv"]["REDIS_URL"] assert_equal "redis://redis:6379/1", content["containerEnv"]["REDIS_URL"]
assert_equal "45678", content["containerEnv"]["CAPYBARA_SERVER_PORT"] assert_equal "45678", content["containerEnv"]["CAPYBARA_SERVER_PORT"]
assert_equal "selenium", content["containerEnv"]["SELENIUM_HOST"] assert_equal "selenium", content["containerEnv"]["SELENIUM_HOST"]
assert_equal({}, content["features"]["ghcr.io/rails/devcontainer/features/activestorage"]) assert_includes content["features"].keys, "ghcr.io/rails/devcontainer/features/activestorage"
assert_equal({}, content["features"]["ghcr.io/devcontainers/features/github-cli:1"]) assert_includes content["features"].keys, "ghcr.io/devcontainers/features/github-cli:1"
assert_equal({}, content["features"]["ghcr.io/rails/devcontainer/features/sqlite3"]) assert_includes content["features"].keys, "ghcr.io/rails/devcontainer/features/sqlite3"
assert_includes(content["forwardPorts"], 3000) assert_includes(content["forwardPorts"], 3000)
assert_includes(content["forwardPorts"], 6379) assert_includes(content["forwardPorts"], 6379)
end end
@ -1320,8 +1320,8 @@ class AppGeneratorTest < Rails::Generators::TestCase
end end
assert_devcontainer_json_file do |content| assert_devcontainer_json_file do |content|
assert_equal "postgres", content["containerEnv"]["DB_HOST"] assert_equal "postgres", content["containerEnv"]["DB_HOST"]
assert_equal({}, content["features"]["ghcr.io/rails/devcontainer/features/postgres-client"]) assert_includes content["features"].keys, "ghcr.io/rails/devcontainer/features/postgres-client"
assert_includes(content["forwardPorts"], 5432) assert_includes content["forwardPorts"], 5432
end end
assert_file("config/database.yml") do |content| assert_file("config/database.yml") do |content|
assert_match(/host: <%= ENV\["DB_HOST"\] %>/, content) assert_match(/host: <%= ENV\["DB_HOST"\] %>/, content)
@ -1350,8 +1350,8 @@ class AppGeneratorTest < Rails::Generators::TestCase
end end
assert_devcontainer_json_file do |content| assert_devcontainer_json_file do |content|
assert_equal "mysql", content["containerEnv"]["DB_HOST"] assert_equal "mysql", content["containerEnv"]["DB_HOST"]
assert_equal({}, content["features"]["ghcr.io/rails/devcontainer/features/mysql-client"]) assert_includes content["features"].keys, "ghcr.io/rails/devcontainer/features/mysql-client"
assert_includes(content["forwardPorts"], 3306) assert_includes content["forwardPorts"], 3306
end end
assert_file("config/database.yml") do |content| assert_file("config/database.yml") do |content|
assert_match(/host: <%= ENV.fetch\("DB_HOST"\) \{ "localhost" } %>/, content) assert_match(/host: <%= ENV.fetch\("DB_HOST"\) \{ "localhost" } %>/, content)