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



# 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 AppName

          BASE_PACKAGES = %w( curl libvips )
          BUILD_PACKAGES = %w( build-essential git )

          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 Database::DATABASES.include?(options[:to])
              raise Error, "Invalid value for --to option. Supported preconfigurations are: #{Database::DATABASES.join(", ")}."
            end

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

          def edit_database_config
            template(database.template, "config/database.yml")
          end

          def edit_gemfile
            name, version = database.gem
            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)

            gsub_file("Dockerfile", all_docker_bases_regex, docker_base_packages(database.base_package))
            gsub_file("Dockerfile", all_docker_builds_regex, docker_build_packages(database.build_package))
          end

          def edit_devcontainer_files
            return unless devcontainer?

            edit_devcontainer_json
            edit_compose_yaml
          end

          private
            def all_database_gems
              Database.all.map { |database| database.gem }
            end

            def all_docker_bases
              Database.all.map { |database| docker_base_packages(database.base_package) }.uniq
            end

            def docker_base_packages(database_package)
              if database_package
                [database_package].concat(BASE_PACKAGES).sort
              else
                BASE_PACKAGES
              end.join("\s")
            end

            def all_docker_builds
              Database.all.map { |database| docker_build_packages(database.build_package) }.uniq
            end

            def docker_build_packages(database_package)
              if database_package
                [database_package].concat(BUILD_PACKAGES).sort
              else
                BUILD_PACKAGES
              end.join("\s")
            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)

              Database.all.each do |database|
                compose_config["services"].delete(database.name)
                compose_config["volumes"]&.delete(database.volume)
                compose_config["services"]["rails-app"]["depends_on"]&.delete(database.name)
              end

              if database.service
                compose_config["services"][database.name] = database.service
                compose_config["volumes"] = { database.volume => nil }.merge(compose_config["volumes"] || {})
                compose_config["services"]["rails-app"]["depends_on"] = [
                  database.name,
                  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 = database.name

              if container_env["DB_HOST"]
                if database.service
                  container_env["DB_HOST"] = db_name
                else
                  container_env.delete("DB_HOST")
                end
              else
                if database.service
                  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 = database.feature

              Database.all.each do |database|
                features.delete(database.feature_name)
              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

            def database
              @database ||= Database.build(options[:database])
            end

            def devcontainer?
              return @devcontainer if defined?(@devcontainer)

              @devcontainer = File.exist?(File.expand_path(".devcontainer", destination_root))
            end
        end
      end
    end
  end
end