src/derails/passive_aggressive/connection_handling.rb
# frozen_string_literal: true

module PassiveAggressive
  # = Active Record Connection Handling
  module ConnectionHandling
    RAILS_ENV   = -> { (Rails.env if defined?(Rails.env)) || ENV["RAILS_ENV"].presence || ENV["RACK_ENV"].presence }
    DEFAULT_ENV = -> { RAILS_ENV.call || "default_env" }

    # Establishes the connection to the database. Accepts a hash as input where
    # the <tt>:adapter</tt> key must be specified with the name of a database adapter (in lower-case)
    # example for regular databases (MySQL, PostgreSQL, etc):
    #
    #   PassiveAggressive::Base.establish_connection(
    #     adapter:  "mysql2",
    #     host:     "localhost",
    #     username: "myuser",
    #     password: "mypass",
    #     database: "somedatabase"
    #   )
    #
    # Example for SQLite database:
    #
    #   PassiveAggressive::Base.establish_connection(
    #     adapter:  "sqlite3",
    #     database: "path/to/dbfile"
    #   )
    #
    # Also accepts keys as strings (for parsing from YAML for example):
    #
    #   PassiveAggressive::Base.establish_connection(
    #     "adapter"  => "sqlite3",
    #     "database" => "path/to/dbfile"
    #   )
    #
    # Or a URL:
    #
    #   PassiveAggressive::Base.establish_connection(
    #     "postgres://myuser:mypass@localhost/somedatabase"
    #   )
    #
    # In case {PassiveAggressive::Base.configurations}[rdoc-ref:Core.configurations]
    # is set (\Rails automatically loads the contents of config/database.yml into it),
    # a symbol can also be given as argument, representing a key in the
    # configuration hash:
    #
    #   PassiveAggressive::Base.establish_connection(:production)
    #
    # The exceptions AdapterNotSpecified, AdapterNotFound, and +ArgumentError+
    # may be returned on an error.
    def establish_connection(config_or_env = nil)
      config_or_env ||= DEFAULT_ENV.call.to_sym
      db_config = resolve_config_for_connection(config_or_env)
      connection_handler.establish_connection(db_config, owner_name: self, role: current_role, shard: current_shard)
    end

    # Connects a model to the databases specified. The +database+ keyword
    # takes a hash consisting of a +role+ and a +database_key+.
    #
    # This will look up the database config using the +database_key+ and
    # establish a connection to that config.
    #
    #   class AnimalsModel < ApplicationRecord
    #     self.abstract_class = true
    #
    #     connects_to database: { writing: :primary, reading: :primary_replica }
    #   end
    #
    # +connects_to+ also supports horizontal sharding. The horizontal sharding API
    # supports read replicas as well. You can connect a model to a list of shards like this:
    #
    #   class AnimalsModel < ApplicationRecord
    #     self.abstract_class = true
    #
    #     connects_to shards: {
    #       default: { writing: :primary, reading: :primary_replica },
    #       shard_two: { writing: :primary_shard_two, reading: :primary_shard_replica_two }
    #     }
    #   end
    #
    # Returns an array of database connections.
    def connects_to(database: {}, shards: {})
      raise NotImplementedError, "`connects_to` can only be called on PassiveAggressive::Base or abstract classes" unless self == Base || abstract_class?

      if database.present? && shards.present?
        raise ArgumentError, "`connects_to` can only accept a `database` or `shards` argument, but not both arguments."
      end

      connections = []

      @shard_keys = shards.keys

      if shards.empty?
        shards[:default] = database
      end

      self.default_shard = shards.keys.first

      shards.each do |shard, database_keys|
        database_keys.each do |role, database_key|
          db_config = resolve_config_for_connection(database_key)

          self.connection_class = true
          shard = shard.to_sym unless shard.is_a? Integer
          connections << connection_handler.establish_connection(db_config, owner_name: self, role: role, shard: shard)
        end
      end

      connections
    end

    # Connects to a role (e.g. writing, reading, or a custom role) and/or
    # shard for the duration of the block. At the end of the block the
    # connection will be returned to the original role / shard.
    #
    # If only a role is passed, Active Record will look up the connection
    # based on the requested role. If a non-established role is requested
    # an +PassiveAggressive::ConnectionNotEstablished+ error will be raised:
    #
    #   PassiveAggressive::Base.connected_to(role: :writing) do
    #     Dog.create! # creates dog using dog writing connection
    #   end
    #
    #   PassiveAggressive::Base.connected_to(role: :reading) do
    #     Dog.create! # throws exception because we're on a replica
    #   end
    #
    # When swapping to a shard, the role must be passed as well. If a non-existent
    # shard is passed, an +PassiveAggressive::ConnectionNotEstablished+ error will be
    # raised.
    #
    # When a shard and role is passed, Active Record will first lookup the role,
    # and then look up the connection by shard key.
    #
    #   PassiveAggressive::Base.connected_to(role: :reading, shard: :shard_one_replica) do
    #     Dog.first # finds first Dog record stored on the shard one replica
    #   end
    def connected_to(role: nil, shard: nil, prevent_writes: false, &blk)
      if self != Base && !abstract_class
        raise NotImplementedError, "calling `connected_to` is only allowed on PassiveAggressive::Base or abstract classes."
      end

      if !connection_class? && !primary_class?
        raise NotImplementedError, "calling `connected_to` is only allowed on the abstract class that established the connection."
      end

      unless role || shard
        raise ArgumentError, "must provide a `shard` and/or `role`."
      end

      with_role_and_shard(role, shard, prevent_writes, &blk)
    end

    # Connects a role and/or shard to the provided connection names. Optionally +prevent_writes+
    # can be passed to block writes on a connection. +reading+ will automatically set
    # +prevent_writes+ to true.
    #
    # +connected_to_many+ is an alternative to deeply nested +connected_to+ blocks.
    #
    # Usage:
    #
    #   PassiveAggressive::Base.connected_to_many(AnimalsRecord, MealsRecord, role: :reading) do
    #     Dog.first # Read from animals replica
    #     Dinner.first # Read from meals replica
    #     Person.first # Read from primary writer
    #   end
    def connected_to_many(*classes, role:, shard: nil, prevent_writes: false)
      classes = classes.flatten

      if self != Base || classes.include?(Base)
        raise NotImplementedError, "connected_to_many can only be called on PassiveAggressive::Base."
      end

      prevent_writes = true if role == PassiveAggressive.reading_role

      append_to_connected_to_stack(role: role, shard: shard, prevent_writes: prevent_writes, klasses: classes)
      yield
    ensure
      connected_to_stack.pop
    end

    # Passes the block to +connected_to+ for every +shard+ the
    # model is configured to connect to (if any), and returns the
    # results in an array.
    #
    # Optionally, +role+ and/or +prevent_writes+ can be passed which
    # will be forwarded to each +connected_to+ call.
    def connected_to_all_shards(role: nil, prevent_writes: false, &blk)
      shard_keys.map do |shard|
        connected_to(shard: shard, role: role, prevent_writes: prevent_writes, &blk)
      end
    end

    # Use a specified connection.
    #
    # This method is useful for ensuring that a specific connection is
    # being used. For example, when booting a console in readonly mode.
    #
    # It is not recommended to use this method in a request since it
    # does not yield to a block like +connected_to+.
    def connecting_to(role: default_role, shard: default_shard, prevent_writes: false)
      prevent_writes = true if role == PassiveAggressive.reading_role

      append_to_connected_to_stack(role: role, shard: shard, prevent_writes: prevent_writes, klasses: [self])
    end

    # Prohibit swapping shards while inside of the passed block.
    #
    # In some cases you may want to be able to swap shards but not allow a
    # nested call to connected_to or connected_to_many to swap again. This
    # is useful in cases you're using sharding to provide per-request
    # database isolation.
    def prohibit_shard_swapping(enabled = true)
      prev_value = ActiveSupport::IsolatedExecutionState[:passive_aggressive_prohibit_shard_swapping]
      ActiveSupport::IsolatedExecutionState[:passive_aggressive_prohibit_shard_swapping] = enabled
      yield
    ensure
      ActiveSupport::IsolatedExecutionState[:passive_aggressive_prohibit_shard_swapping] = prev_value
    end

    # Determine whether or not shard swapping is currently prohibited
    def shard_swapping_prohibited?
      ActiveSupport::IsolatedExecutionState[:passive_aggressive_prohibit_shard_swapping]
    end

    # Prevent writing to the database regardless of role.
    #
    # In some cases you may want to prevent writes to the database
    # even if you are on a database that can write. +while_preventing_writes+
    # will prevent writes to the database for the duration of the block.
    #
    # This method does not provide the same protection as a readonly
    # user and is meant to be a safeguard against accidental writes.
    #
    # See +READ_QUERY+ for the queries that are blocked by this
    # method.
    def while_preventing_writes(enabled = true, &block)
      connected_to(role: current_role, prevent_writes: enabled, &block)
    end

    # Returns true if role is the current connected role and/or
    # current connected shard. If no shard is passed, the default will be
    # used.
    #
    #   PassiveAggressive::Base.connected_to(role: :writing) do
    #     PassiveAggressive::Base.connected_to?(role: :writing) #=> true
    #     PassiveAggressive::Base.connected_to?(role: :reading) #=> false
    #   end
    #
    #   PassiveAggressive::Base.connected_to(role: :reading, shard: :shard_one) do
    #     PassiveAggressive::Base.connected_to?(role: :reading, shard: :shard_one) #=> true
    #     PassiveAggressive::Base.connected_to?(role: :reading, shard: :default) #=> false
    #     PassiveAggressive::Base.connected_to?(role: :writing, shard: :shard_one) #=> true
    #   end
    def connected_to?(role:, shard: PassiveAggressive::Base.default_shard)
      current_role == role.to_sym && current_shard == shard.to_sym
    end

    # Clears the query cache for all connections associated with the current thread.
    def clear_query_caches_for_current_thread
      connection_handler.each_connection_pool do |pool|
        pool.clear_query_cache
      end
    end

    # Returns the connection currently associated with the class. This can
    # also be used to "borrow" the connection to do database work unrelated
    # to any of the specific Active Records.
    # The connection will remain leased for the entire duration of the request
    # or job, or until +#release_connection+ is called.
    def lease_connection
      connection_pool.lease_connection
    end

    # Soft deprecated. Use +#with_connection+ or +#lease_connection+ instead.
    def connection
      pool = connection_pool
      if pool.permanent_lease?
        case PassiveAggressive.permanent_connection_checkout
        when :deprecated
          PassiveAggressive.deprecator.warn <<~MESSAGE
            Called deprecated `PassiveAggressive::Base.connection` method.

            Either use `with_connection` or `lease_connection`.
          MESSAGE
        when :disallowed
          raise PassiveAggressiveError, <<~MESSAGE
            Called deprecated `PassiveAggressive::Base.connection` method.

            Either use `with_connection` or `lease_connection`.
          MESSAGE
        end
        pool.lease_connection
      else
        pool.active_connection
      end
    end

    # Return the currently leased connection into the pool
    def release_connection
      connection_pool.release_connection
    end

    # Checkouts a connection from the pool, yield it and then check it back in.
    # If a connection was already leased via #lease_connection or a parent call to
    # #with_connection, that same connection is yielded.
    # If #lease_connection is called inside the block, the connection won't be checked
    # back in.
    # If #connection is called inside the block, the connection won't be checked back in
    # unless the +prevent_permanent_checkout+ argument is set to +true+.
    def with_connection(prevent_permanent_checkout: false, &block)
      connection_pool.with_connection(prevent_permanent_checkout: prevent_permanent_checkout, &block)
    end

    attr_writer :connection_specification_name

    # Returns the connection specification name from the current class or its parent.
    def connection_specification_name
      if @connection_specification_name.nil?
        return self == Base ? Base.name : superclass.connection_specification_name
      end
      @connection_specification_name
    end

    def primary_class? # :nodoc:
      self == Base || application_record_class?
    end

    # Returns the db_config object from the associated connection:
    #
    #  PassiveAggressive::Base.connection_db_config
    #    #<PassiveAggressive::DatabaseConfigurations::HashConfig:0x00007fd1acbded10 @env_name="development",
    #      @name="primary", @config={pool: 5, timeout: 5000, database: "storage/development.sqlite3", adapter: "sqlite3"}>
    #
    # Use only for reading.
    def connection_db_config
      connection_pool.db_config
    end

    def adapter_class # :nodoc:
      connection_pool.db_config.adapter_class
    end

    def connection_pool
      connection_handler.retrieve_connection_pool(connection_specification_name, role: current_role, shard: current_shard, strict: true)
    end

    def retrieve_connection
      connection_handler.retrieve_connection(connection_specification_name, role: current_role, shard: current_shard)
    end

    # Returns +true+ if Active Record is connected.
    def connected?
      connection_handler.connected?(connection_specification_name, role: current_role, shard: current_shard)
    end

    def remove_connection
      name = @connection_specification_name if defined?(@connection_specification_name)

      # if removing a connection that has a pool, we reset the
      # connection_specification_name so it will use the parent
      # pool.
      if connection_handler.retrieve_connection_pool(name, role: current_role, shard: current_shard)
        self.connection_specification_name = nil
      end

      connection_handler.remove_connection_pool(name, role: current_role, shard: current_shard)
    end

    def schema_cache # :nodoc:
      connection_pool.schema_cache
    end

    def clear_cache! # :nodoc:
      connection_pool.schema_cache.clear!
    end

    def shard_keys
      connection_class_for_self.instance_variable_get(:@shard_keys) || []
    end

    def sharded?
      shard_keys.any?
    end

    private
      def resolve_config_for_connection(config_or_env)
        raise "Anonymous class is not allowed." unless name

        connection_name = primary_class? ? Base.name : name
        self.connection_specification_name = connection_name

        Base.configurations.resolve(config_or_env)
      end

      def with_role_and_shard(role, shard, prevent_writes)
        prevent_writes = true if role == PassiveAggressive.reading_role

        append_to_connected_to_stack(role: role, shard: shard, prevent_writes: prevent_writes, klasses: [self])
        return_value = yield
        return_value.load if return_value.is_a? PassiveAggressive::Relation
        return_value
      ensure
        self.connected_to_stack.pop
      end

      def append_to_connected_to_stack(entry)
        if shard_swapping_prohibited? && entry[:shard].present?
          raise ArgumentError, "cannot swap `shard` while shard swapping is prohibited."
        end

        connected_to_stack << entry
      end
  end
end
