# frozen-string-literal: true

require 'mysql'
raise(LoadError, "require 'mysql' did not define Mysql::CLIENT_MULTI_RESULTS!, so it not supported. Please install the mysql or ruby-mysql gem.\n") unless defined?(Mysql::CLIENT_MULTI_RESULTS)

require_relative 'utils/mysql_mysql2'
require_relative 'utils/mysql_prepared_statements'

module Sequel
  module MySQL
    boolean = Object.new
    def boolean.call(s) s.to_i != 0 end
    TYPE_TRANSLATOR_BOOLEAN = boolean.freeze
    integer = Object.new
    def integer.call(s) s.to_i end
    TYPE_TRANSLATOR_INTEGER = integer.freeze
    float = Object.new
    def float.call(s) s.to_f end

    # Hash with integer keys and callable values for converting MySQL types.
    MYSQL_TYPES = {}
    {
      [0, 246] => ::Kernel.method(:BigDecimal),
      [2, 3, 8, 9, 13, 247, 248] => integer,
      [4, 5] => float,
      [249, 250, 251, 252] => ::Sequel::SQL::Blob
    }.each do |k,v|
      k.each{|n| MYSQL_TYPES[n] = v}
    end
    MYSQL_TYPES.freeze

    RUBY_MYSQL_3 = !Mysql.respond_to?(:init)
    RUBY_MYSQL_4 = RUBY_MYSQL_3 && ::Mysql::VERSION.to_i >= 4

    if RUBY_MYSQL_3
      class Adapter < ::Mysql
        alias real_connect connect
        alias use_result store_result
        if RUBY_MYSQL_4
          def initialize(**opts)
            super(**opts.merge(:cast=>false))
          end
        end
      end
    end

    class Database < Sequel::Database
      include Sequel::MySQL::DatabaseMethods
      include Sequel::MySQL::MysqlMysql2::DatabaseMethods
      include Sequel::MySQL::PreparedStatements::DatabaseMethods
      
      set_adapter_scheme :mysql

      # Hash of conversion procs for the current database
      attr_reader :conversion_procs

      # Whether to convert tinyint columns to bool for the current database
      attr_reader :convert_tinyint_to_bool

      # By default, Sequel raises an exception if in invalid date or time is used.
      # However, if this is set to nil or :nil, the adapter treats dates
      # like 0000-00-00 and times like 838:00:00 as nil values.  If set to :string,
      # it returns the strings as is.
      attr_reader :convert_invalid_date_time
      
      # Connect to the database.  In addition to the usual database options,
      # the following options have effect:
      #
      # :auto_is_null :: Set to true to use MySQL default behavior of having
      #                  a filter for an autoincrement column equals NULL to return the last
      #                  inserted row.
      # :charset :: Same as :encoding (:encoding takes precendence)
      # :compress :: Set to false to not compress results from the server
      # :config_default_group :: The default group to read from the in
      #                          the MySQL config file.
      # :config_local_infile :: If provided, sets the Mysql::OPT_LOCAL_INFILE
      #                         option on the connection with the given value.
      # :connect_timeout :: Set the timeout in seconds before a connection
      #                     attempt is abandoned.
      # :encoding :: Set all the related character sets for this
      #              connection (connection, client, database, server, and results).
      # :read_timeout :: Set the timeout in seconds for reading back results
      #                  to a query.
      # :socket :: Use a unix socket file instead of connecting via TCP/IP.
      # :timeout :: Set the timeout in seconds before the server will
      #             disconnect this connection (a.k.a @@wait_timeout).
      def connect(server)
        opts = server_opts(server)

        if !RUBY_MYSQL_3
          conn = Mysql.init
          conn.options(Mysql::READ_DEFAULT_GROUP, opts[:config_default_group] || "client")
          conn.options(Mysql::OPT_LOCAL_INFILE, opts[:config_local_infile]) if opts.has_key?(:config_local_infile)
          if encoding = opts[:encoding] || opts[:charset]
            # Set encoding before connecting so that the mysql driver knows what
            # encoding we want to use, but this can be overridden by READ_DEFAULT_GROUP.
            conn.options(Mysql::SET_CHARSET_NAME, encoding)
          end
          if read_timeout = opts[:read_timeout] and defined? Mysql::OPT_READ_TIMEOUT
            conn.options(Mysql::OPT_READ_TIMEOUT, read_timeout)
          end
          if connect_timeout = opts[:connect_timeout] and defined? Mysql::OPT_CONNECT_TIMEOUT
            conn.options(Mysql::OPT_CONNECT_TIMEOUT, connect_timeout)
          end
        else
          # ruby-mysql 3+ API
          conn = Adapter.new
          # no support for default group
          conn.local_infile = opts[:config_local_infile] if opts.has_key?(:config_local_infile)
          if encoding = opts[:encoding] || opts[:charset]
            conn.charset = encoding
          end
          if read_timeout = opts[:read_timeout]
            conn.read_timeout = read_timeout
          end
          if connect_timeout = opts[:connect_timeout]
            conn.connect_timeout = connect_timeout
          end
          opts[:compress] = false
        end

        conn.ssl_set(opts[:sslkey], opts[:sslcert], opts[:sslca], opts[:sslcapath], opts[:sslcipher]) if opts[:sslca] || opts[:sslkey]
        conn.real_connect(
          opts[:host] || 'localhost',
          opts[:user],
          opts[:password],
          opts[:database],
          (opts[:port].to_i if opts[:port]),
          opts[:socket],
          Mysql::CLIENT_MULTI_RESULTS +
          Mysql::CLIENT_MULTI_STATEMENTS +
          (opts[:compress] == false ? 0 : Mysql::CLIENT_COMPRESS)
        )
        sqls = mysql_connection_setting_sqls

        # Set encoding a slightly different way after connecting,
        # in case the READ_DEFAULT_GROUP overrode the provided encoding.
        # Doesn't work across implicit reconnects, but Sequel doesn't turn on
        # that feature.
        sqls.unshift("SET NAMES #{literal(encoding.to_s)}") if encoding

        sqls.each{|sql| log_connection_yield(sql, conn){conn.query(sql)}}

        add_prepared_statements_cache(conn)
        conn
      end
      
      def disconnect_connection(c)
        c.close
      rescue Mysql::Error
        nil
      end
      
      # Modify the type translators for the date, time, and timestamp types
      # depending on the value given.
      def convert_invalid_date_time=(v)
        m0 = ::Sequel.method(:string_to_time)
        @conversion_procs[11] = (v != false) ?  lambda{|val| convert_date_time(val, &m0)} : m0
        m1 = ::Sequel.method(:string_to_date) 
        m = (v != false) ? lambda{|val| convert_date_time(val, &m1)} : m1
        [10, 14].each{|i| @conversion_procs[i] = m}
        m2 = method(:to_application_timestamp)
        m = (v != false) ? lambda{|val| convert_date_time(val, &m2)} : m2
        [7, 12].each{|i| @conversion_procs[i] = m}
        @convert_invalid_date_time = v
      end

      # Modify the type translator used for the tinyint type based
      # on the value given.
      def convert_tinyint_to_bool=(v)
        @conversion_procs[1] = v ? TYPE_TRANSLATOR_BOOLEAN : TYPE_TRANSLATOR_INTEGER
        @convert_tinyint_to_bool = v
      end

      def execute_dui(sql, opts=OPTS)
        execute(sql, opts){|c| return affected_rows(c)}
      end

      def execute_insert(sql, opts=OPTS)
        execute(sql, opts){|c| return c.insert_id}
      end

      def freeze
        server_version
        @conversion_procs.freeze
        super
      end

      private
      
      # Execute the given SQL on the given connection.  If the :type
      # option is :select, yield the result of the query, otherwise
      # yield the connection if a block is given.
      def _execute(conn, sql, opts)
        r = log_connection_yield((log_sql = opts[:log_sql]) ? sql + log_sql : sql, conn){conn.query(sql)}
        if opts[:type] == :select
          yield r if r
        elsif defined?(yield)
          yield conn
        end
        if conn.respond_to?(:more_results?)
          while conn.more_results? do
            if r
              r.free
              r = nil
            end
            begin
              conn.next_result
              r = conn.use_result
            rescue Mysql::Error => e
              raise_error(e, :disconnect=>true) if MYSQL_DATABASE_DISCONNECT_ERRORS.match(e.message)
              break
            end
            yield r if opts[:type] == :select
          end
        end
      rescue Mysql::Error => e
        raise_error(e)
      ensure
        r.free if r
        # Use up all results to avoid a commands out of sync message.
        if conn.respond_to?(:more_results?)
          while conn.more_results? do
            begin
              conn.next_result
              r = conn.use_result
            rescue Mysql::Error => e
              raise_error(e, :disconnect=>true) if MYSQL_DATABASE_DISCONNECT_ERRORS.match(e.message)
              break
            end
            r.free if r
          end
        end
      end
      
      def adapter_initialize
        @conversion_procs = MYSQL_TYPES.dup
        self.convert_tinyint_to_bool = true
        self.convert_invalid_date_time = false
      end

      # Try to get an accurate number of rows matched using the query
      # info.  Fall back to affected_rows if there was no match, but
      # that may be inaccurate.
      def affected_rows(conn)
        s = conn.info
        if s && s =~ /Rows matched:\s+(\d+)\s+Changed:\s+\d+\s+Warnings:\s+\d+/
          $1.to_i
        else
          conn.affected_rows
        end
      end

      # MySQL connections use the query method to execute SQL without a result
      def connection_execute_method
        :query
      end
      
      # If convert_invalid_date_time is nil, :nil, or :string and
      # the conversion raises an InvalidValue exception, return v
      # if :string and nil otherwise.
      def convert_date_time(v)
        yield v
      rescue InvalidValue
        case @convert_invalid_date_time
        when nil, :nil
          nil
        when :string
          v
        else 
          raise
        end
      end
    
      def database_error_classes
        [Mysql::Error]
      end

      def database_exception_sqlstate(exception, opts)
        exception.sqlstate
      end

      def dataset_class_default
        Dataset
      end

      def disconnect_error?(e, opts)
        super || (e.is_a?(::Mysql::Error) && MYSQL_DATABASE_DISCONNECT_ERRORS.match(e.message))
      end
      
      # Convert tinyint(1) type to boolean if convert_tinyint_to_bool is true
      def schema_column_type(db_type)
        convert_tinyint_to_bool && db_type =~ /\Atinyint\(1\)/ ? :boolean : super
      end
    end
    
    class Dataset < Sequel::Dataset
      include Sequel::MySQL::DatasetMethods
      include Sequel::MySQL::MysqlMysql2::DatasetMethods
      include Sequel::MySQL::PreparedStatements::DatasetMethods

      # Yield all rows matching this dataset.  If the dataset is set to
      # split multiple statements, yield arrays of hashes one per statement
      # instead of yielding results for all statements as hashes.
      def fetch_rows(sql)
        execute(sql) do |r|
          i = -1
          cps = db.conversion_procs
          cols = r.fetch_fields.map do |f| 
            # Pretend tinyint is another integer type if its length is not 1, to
            # avoid casting to boolean if convert_tinyint_to_bool is set.
            type_proc = f.type == 1 && cast_tinyint_integer?(f) ? cps[2] : cps[f.type]
            [output_identifier(f.name), type_proc, i+=1]
          end
          self.columns = cols.map(&:first)
          if opts[:split_multiple_result_sets]
            s = []
            yield_rows(r, cols){|h| s << h}
            yield s
          else
            yield_rows(r, cols){|h| yield h}
          end
        end
        self
      end
      
      # Don't allow graphing a dataset that splits multiple statements
      def graph(*)
        raise(Error, "Can't graph a dataset that splits multiple result sets") if opts[:split_multiple_result_sets]
        super
      end
      
      # Makes each yield arrays of rows, with each array containing the rows
      # for a given result set.  Does not work with graphing.  So you can submit
      # SQL with multiple statements and easily determine which statement
      # returned which results.
      #
      # Modifies the row_proc of the returned dataset so that it still works
      # as expected (running on the hashes instead of on the arrays of hashes).
      # If you modify the row_proc afterward, note that it will receive an array
      # of hashes instead of a hash.
      def split_multiple_result_sets
        raise(Error, "Can't split multiple statements on a graphed dataset") if opts[:graph]
        ds = clone(:split_multiple_result_sets=>true)
        ds = ds.with_row_proc(proc{|x| x.map{|h| row_proc.call(h)}}) if row_proc
        ds
      end
      
      private

      # Whether a tinyint field should be casted as an integer.  By default,
      # casts to integer if the field length is not 1.  Can be overwritten
      # to make tinyint casting dataset dependent.
      def cast_tinyint_integer?(field)
        field.length != 1
      end
      
      def execute(sql, opts=OPTS)
        opts = Hash[opts]
        opts[:type] = :select
        super
      end
      
      # Handle correct quoting of strings using ::MySQL.quote.
      def literal_string_append(sql, v)
        sql << "'" << ::Mysql.quote(v) << "'"
      end
      
      # Yield each row of the given result set r with columns cols
      # as a hash with symbol keys
      def yield_rows(r, cols)
        while row = r.fetch_row
          h = {}
          cols.each{|n, p, i| v = row[i]; h[n] = (v && p) ? p.call(v) : v}
          yield h
        end
      end
    end
  end
end
