railties/lib/rails/generators/rails/db/system/change/change_generator.rb

Summary

Maintainability
A
45 mins
Test Coverage
# frozen_string_literal: true

require "rails/generators/base"
require "yaml"
require "json"

module Rails
  module Generators
    module Db
      module System
        class ChangeGenerator < Base # :nodoc:
          include Database
          include Devcontainer
          include AppName

          class_option :to, required: true,
            desc: "The database system to switch to."

          def self.default_generator_root
            path = File.expand_path(File.join(base_name, "app"), base_root)
            path if File.exist?(path)
          end

          def initialize(*)
            super

            unless DATABASES.include?(options[:to])
              raise Error, "Invalid value for --to option. Supported preconfigurations are: #{DATABASES.join(", ")}."
            end

            opt = options.dup
            opt[:database] ||= opt[:to]
            self.options = opt.freeze
          end

          def edit_database_config
            template("config/databases/#{options[:database]}.yml", "config/database.yml")
          end

          def edit_gemfile
            name, version = gem_for_database
            gsub_file("Gemfile", all_database_gems_regex, name)
            gsub_file("Gemfile", gem_entry_regex_for(name), gem_entry_for(name, *version))
          end

          def edit_dockerfile
            dockerfile_path = File.expand_path("Dockerfile", destination_root)
            return unless File.exist?(dockerfile_path)

            base_name = docker_for_database_base
            build_name = docker_for_database_build
            if base_name
              gsub_file("Dockerfile", all_docker_bases_regex, base_name)
            end
            if build_name
              gsub_file("Dockerfile", all_docker_builds_regex, build_name)
            end
          end

          def edit_devcontainer_files
            devcontainer_path = File.expand_path(".devcontainer", destination_root)
            return unless File.exist?(devcontainer_path)

            edit_devcontainer_json
            edit_compose_yaml
          end

          private
            def all_database_gems
              DATABASES.map { |database| gem_for_database(database) }
            end

            def all_docker_bases
              DATABASES.map { |database| docker_for_database_base(database).nil? ? nil : docker_for_database_base(database) }.compact!
            end

            def all_docker_builds
              DATABASES.map { |database| docker_for_database_build(database).nil? ? nil : docker_for_database_build(database) }.compact!
            end

            def all_database_gems_regex
              all_database_gem_names = all_database_gems.map(&:first)
              /(\b#{all_database_gem_names.join('\b|\b')}\b)/
            end

            def all_docker_bases_regex
              /(\b#{all_docker_bases.join('\b|\b')}\b)/
            end

            def all_docker_builds_regex
              /(\b#{all_docker_builds.join('\b|\b')}\b)/
            end

            def gem_entry_regex_for(gem_name)
              /^gem.*\b#{gem_name}\b.*/
            end

            def gem_entry_for(*gem_name_and_version)
              gem_name_and_version.map! { |segment| "\"#{segment}\"" }
              "gem #{gem_name_and_version.join(", ")}"
            end

            def edit_devcontainer_json
              return unless devcontainer_json

              update_devcontainer_db_host
              update_devcontainer_db_feature
            end

            def edit_compose_yaml
              compose_yaml_path = File.expand_path(".devcontainer/compose.yaml", destination_root)
              return unless File.exist?(compose_yaml_path)

              compose_config = YAML.load_file(compose_yaml_path)

              db_service_names.each do |db_service_name|
                compose_config["services"].delete(db_service_name)
                compose_config["volumes"]&.delete("#{db_service_name}-data")
                compose_config["services"]["rails-app"]["depends_on"]&.delete(db_service_name)
              end

              db_service = db_service_for_devcontainer

              if db_service
                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"] = [
                  db_name_for_devcontainer,
                  compose_config["services"]["rails-app"]["depends_on"]
                ].flatten.compact
              end

              compose_config.delete("volumes") unless compose_config["volumes"]&.any?
              compose_config["services"]["rails-app"].delete("depends_on") unless compose_config["services"]["rails-app"]["depends_on"]&.any?

              File.write(compose_yaml_path, compose_config.to_yaml)
            end

            def update_devcontainer_db_host
              container_env = devcontainer_json["containerEnv"]
              db_name = db_name_for_devcontainer

              if container_env["DB_HOST"]
                if db_name
                  container_env["DB_HOST"] = db_name
                else
                  container_env.delete("DB_HOST")
                end
              else
                if db_name
                  container_env["DB_HOST"] = db_name
                end
              end

              new_json = JSON.pretty_generate(container_env, indent: "  ", object_nl: "\n  ")

              gsub_file(".devcontainer/devcontainer.json", /("containerEnv"\s*:\s*)(.|\n)*?(^\s{2}})/, "\\1#{new_json}")
            end

            def update_devcontainer_db_feature
              features = devcontainer_json["features"]
              db_feature = db_feature_for_devcontainer

              db_features.each do |feature|
                features.delete(feature)
              end

              features.merge!(db_feature) if db_feature

              new_json = JSON.pretty_generate(features, indent: "  ", object_nl: "\n  ")

              gsub_file(".devcontainer/devcontainer.json", /("features"\s*:\s*)(.|\n)*?(^\s{2}})/, "\\1#{new_json}")
            end

            def devcontainer_json
              return unless File.exist?(devcontainer_json_path)

              @devcontainer_json ||= JSON.parse(File.read(devcontainer_json_path))
            end

            def devcontainer_json_path
              File.expand_path(".devcontainer/devcontainer.json", destination_root)
            end
        end
      end
    end
  end
end