Class: Exwiw::Adapter::MysqlClient

Inherits:
Object
  • Object
show all
Defined in:
lib/exwiw/adapter/mysql_client.rb

Overview

Thin wrapper over the MySQL driver so MysqlAdapter does not care whether the host app ships the ‘mysql2` gem or the `trilogy` gem. exwiw only runs simple SELECT / EXPLAIN queries, so both drivers are normalized to the same shape: rows as arrays of String|nil plus the column names.

Values are normalized to strings to match mysql2’s ‘cast: false` mode, where every column comes back as a raw string and is quoted uniformly downstream (see MysqlAdapter#escape_value). mysql2 already returns strings in that mode; trilogy always casts to Ruby types (Integer / BigDecimal / Time / Date / …), so its values are stringified back into the same literal form here.

Defined Under Namespace

Classes: Result

Constant Summary collapse

DRIVERS =
[:mysql2, :trilogy].freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(connection_config, driver: nil) ⇒ MysqlClient

‘driver:` is mainly a test seam to force a specific driver; in normal use it is auto-detected.



98
99
100
101
102
# File 'lib/exwiw/adapter/mysql_client.rb', line 98

def initialize(connection_config, driver: nil)
  @connection_config = connection_config
  @driver = (driver || self.class.detect_driver).to_sym
  ensure_driver_loaded!
end

Instance Attribute Details

#driverObject (readonly)

Returns the value of attribute driver.



94
95
96
# File 'lib/exwiw/adapter/mysql_client.rb', line 94

def driver
  @driver
end

Class Method Details

.detect_driverObject

Pick the available driver, preferring mysql2 (exwiw’s historical default). require returns false when already loaded, so this is safe to call repeatedly.

Set EXWIW_MYSQL_DRIVER=trilogy to force the pure-Ruby trilogy driver. This is useful when the mysql2 gem is linked against a libmysqlclient that can no longer load the server’s auth plugin (e.g. MySQL 9.x client dropped the ‘mysql_native_password` plugin .so, raising “Authentication plugin ’mysql_native_password’ cannot be loaded” on connect).



31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# File 'lib/exwiw/adapter/mysql_client.rb', line 31

def self.detect_driver
  forced = ENV['EXWIW_MYSQL_DRIVER']
  if forced && !forced.empty?
    sym = forced.to_sym
    unless DRIVERS.include?(sym)
      raise ArgumentError,
            "EXWIW_MYSQL_DRIVER must be one of #{DRIVERS.join(', ')}, got #{forced.inspect}."
    end
    return sym
  end

  require 'mysql2'
  :mysql2
rescue LoadError
  begin
    require 'trilogy'
    :trilogy
  rescue LoadError
    raise LoadError,
          "exwiw needs the 'mysql2' or 'trilogy' gem to connect to MySQL. " \
          "Add `gem \"mysql2\"` (or `gem \"trilogy\"`) to your Gemfile."
  end
end

.normalize_encoding(str) ⇒ Object

Re-tag a value string as UTF-8 when it comes back as ASCII-8BIT (BINARY). Both drivers tag values from binary-collation / VARBINARY / BLOB columns as ASCII-8BIT even when the bytes are really UTF-8 text. When exwiw runs inside a host process whose Encoding.default_internal is UTF-8 (e.g. a Rails app, or RUBYOPT=-EUTF-8), IO#write enables conversion, so writing such a binary string carrying multi-byte bytes (e.g. Japanese “xE4…”) to the INSERT file raises “xE4 from ASCII-8BIT to UTF-8” (Encoding::UndefinedConversionError). Re-tagging makes that write a UTF-8 -> UTF-8 no-op; only the tag changes, the bytes pass through.



88
89
90
91
92
# File 'lib/exwiw/adapter/mysql_client.rb', line 88

def self.normalize_encoding(str)
  return str unless str.encoding == Encoding::ASCII_8BIT

  str.dup.force_encoding(Encoding::UTF_8)
end

.stringify_value(value) ⇒ Object

Render a driver-returned value as the raw string mysql2’s ‘cast: false` would have produced, so trilogy’s typed values quote identically.



57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# File 'lib/exwiw/adapter/mysql_client.rb', line 57

def self.stringify_value(value)
  case value
  when nil then nil
  when String then normalize_encoding(value)
  when Time
    # Emit fractional seconds only when present. A Time can't tell us the
    # column's declared precision, so a zero fraction on a DATETIME(6)
    # column comes out as "...:00" here whereas mysql2's `cast: false`
    # echoes the raw "...:00.000000"; both re-insert to the same instant.
    value.nsec.zero? ? value.strftime('%Y-%m-%d %H:%M:%S') : value.strftime('%Y-%m-%d %H:%M:%S.%6N')
  when Date then value.strftime('%Y-%m-%d')
  when true then '1'
  when false then '0'
  else
    if defined?(BigDecimal) && value.is_a?(BigDecimal)
      value.to_s('F')
    else
      value.to_s
    end
  end
end

Instance Method Details

#query(sql) ⇒ Result

Returns fields (Array<String>) and rows (Array<Array<String|nil>>).

Parameters:

  • sql (String)

Returns:

  • (Result)

    fields (Array<String>) and rows (Array<Array<String|nil>>)



106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/exwiw/adapter/mysql_client.rb', line 106

def query(sql)
  case @driver
  when :mysql2
    res = raw.query(sql, cast: false, as: :array)
    rows = res.to_a.map { |row| row.map { |value| self.class.stringify_value(value) } }
    Result.new(res.fields, rows)
  when :trilogy
    res = raw.query(sql)
    rows = res.rows.map { |row| row.map { |value| self.class.stringify_value(value) } }
    Result.new(res.fields, rows)
  else
    raise "Unsupported MySQL driver: #{@driver.inspect}"
  end
end

#stream_rows(sql) {|row| ... } ⇒ Object

Stream a query’s rows one at a time, yielding each as an Array<String|nil> (the same row shape as #query) instead of buffering the whole result set. This keeps a large dump’s dominant memory cost — a Ruby array as big as the table — from materializing.

mysql2 streams server-side (‘stream: true` + `cache_rows: false`). Its contract: a streamed result MUST be fully consumed before the next query on this connection, or the driver raises “Commands out of sync”. The Runner consumes every row (it writes them all), but if the consumer block raises mid-stream we drain the remaining rows so the same connection is still usable for the next table’s query.

trilogy has no streaming cursor (no QUERY_FLAGS_STREAMING), so it buffers the result and yields from it — parity, but without the memory win (the same situation as the sqlite adapter). trilogy is a test-only driver; production connects via mysql2.

Parameters:

  • sql (String)

Yield Parameters:

  • row (Array<String|nil>)


140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
# File 'lib/exwiw/adapter/mysql_client.rb', line 140

def stream_rows(sql)
  return enum_for(:stream_rows, sql) unless block_given?

  case @driver
  when :mysql2
    res = raw.query(sql, cast: false, as: :array, stream: true, cache_rows: false)
    begin
      res.each { |row| yield row.map { |value| self.class.stringify_value(value) } }
    rescue StandardError
      begin
        res.each { |_row| } # drain the remainder so the connection stays usable
      rescue StandardError
        nil
      end
      raise
    end
  when :trilogy
    raw.query(sql).rows.each { |row| yield row.map { |value| self.class.stringify_value(value) } }
  else
    raise "Unsupported MySQL driver: #{@driver.inspect}"
  end
  self
end