From 3f8443c603e1548520cf1bd16ba106511dbbd41f Mon Sep 17 00:00:00 2001 From: Maqbool Date: Thu, 29 Oct 2026 22:34:34 +0400 Subject: [PATCH] Replace the 2018 protocol module with a DBConnection wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mysqlex.Client now does the connection phase properly, which left the 2018 handshake parsing in Mysqlex.Protocol as dead code. Deleted, and the module renamed to Mysqlex.Connection — settling decision 12, since "protocol" meant both the DBConnection implementation and the namespace holding the codecs. Its state is a %Mysqlex.Client{} and connect/1 delegates, so the behaviour is a wrapper rather than a participant. That is what keeps decision 15's option open: nothing below this file knows DBConnection exists. Mysqlex.Error becomes an exception, since DBConnection specifies its callbacks as returning Exception.t(). Failures that are not server errors — a dead socket, an auth method we cannot speak — are wrapped in one rather than returned raw, because the contract asks for an exception either way. An integration test connects through DBConnection itself, so the wrapper is covered rather than assumed. --- docs/decisions.md | 10 ++- lib/mysqlex.ex | 4 +- lib/mysqlex/connection.ex | 79 ++++++++++++++++++ lib/mysqlex/error.ex | 5 +- lib/mysqlex/protocol.ex | 139 -------------------------------- test/integration/login_test.exs | 12 +++ 6 files changed, 105 insertions(+), 144 deletions(-) create mode 100644 lib/mysqlex/connection.ex delete mode 100644 lib/mysqlex/protocol.ex diff --git a/docs/decisions.md b/docs/decisions.md index 0894766..aa3eceb 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -157,13 +157,19 @@ Provisional because none of it is built yet. ### 12. Rename `Mysqlex.Protocol` to `Mysqlex.Connection` -**2026-10-20 · open** +**2026-10-20 · settled 2026-10-29** Today `Mysqlex.Protocol` holds the DBConnection callbacks, but every other driver uses "protocol" for the codec layer. Keeping both meanings will be confusing once `protocol/` exists as a directory of codecs. -Not done yet. Cheap now at ~130 lines, expensive later. +Done once `Mysqlex.Client` took over the connection phase, which is also when +the 2018 handshake parsing in that module became dead code and was deleted. +`Mysqlex.Connection` is now a thin `DBConnection` wrapper whose state is a +`%Mysqlex.Client{}`, which is what keeps decision 15's swap cheap. + +`Mysqlex.Error` became an exception at the same time, because `DBConnection` +specifies its callbacks as returning `Exception.t()`. ### 13. Authentication order: `mysql_native_password` before `caching_sha2_password` diff --git a/lib/mysqlex.ex b/lib/mysqlex.ex index abda492..6fd9fe8 100644 --- a/lib/mysqlex.ex +++ b/lib/mysqlex.ex @@ -3,7 +3,7 @@ defmodule Mysqlex do MySQL native driver for Elixir. """ - alias Mysqlex.Protocol + alias Mysqlex.Connection @doc """ Start the connection process and connect to the MySQL server. @@ -25,6 +25,6 @@ defmodule Mysqlex do port: port ] - DBConnection.start_link(Protocol, opts) + DBConnection.start_link(Connection, opts) end end diff --git a/lib/mysqlex/connection.ex b/lib/mysqlex/connection.ex new file mode 100644 index 0000000..7a0b4da --- /dev/null +++ b/lib/mysqlex/connection.ex @@ -0,0 +1,79 @@ +defmodule Mysqlex.Connection do + @moduledoc """ + `DBConnection` callbacks over `Mysqlex.Client`. + + Thin on purpose. The protocol lives in `Mysqlex.Client` and knows nothing about + `DBConnection`, so this layer can be replaced — or dropped for a hand-written + pool — without touching anything below it. See decision 15 in + `docs/decisions.md`. + + The command phase callbacks still return an error; `COM_QUERY` is next. + """ + + use DBConnection + + alias Mysqlex.{Client, Error} + + @impl true + def connect(opts) do + case Client.connect(opts) do + {:ok, %Client{} = client} -> + {:ok, client} + + {:error, %Error{} = error} -> + {:error, error} + + # socket failures and unsupported auth methods are not server errors, but + # DBConnection's contract is an exception either way + {:error, reason} -> + {:error, Error.exception(message: "could not connect: #{inspect(reason)}")} + end + end + + @impl true + def disconnect(_reason, %Client{transport: transport, socket: socket}) do + transport.close(socket) + + :ok + end + + @impl true + def checkout(state), do: {:ok, state} + + @impl true + def ping(state), do: {:ok, state} + + @impl true + def handle_begin(_opts, state), do: {:error, state} + + @impl true + def handle_commit(_opts, state), do: {:error, state} + + @impl true + def handle_rollback(_opts, state), do: {:error, state} + + @impl true + def handle_status(_opts, state), do: {:idle, state} + + @impl true + def handle_prepare(_query, _opts, state), do: {:error, not_implemented(), state} + + @impl true + def handle_execute(_query, _params, _opts, state), do: {:error, not_implemented(), state} + + @impl true + def handle_close(_query, _opts, state), do: {:error, not_implemented(), state} + + @impl true + def handle_declare(_query, _params, _opts, state), do: {:error, not_implemented(), state} + + @impl true + def handle_fetch(_query, _cursor, _opts, state), do: {:error, not_implemented(), state} + + @impl true + def handle_deallocate(_query, _cursor, _opts, state), do: {:error, not_implemented(), state} + + defp not_implemented do + DBConnection.ConnectionError.exception("Mysqlex does not implement the command phase yet") + end +end diff --git a/lib/mysqlex/error.ex b/lib/mysqlex/error.ex index a210e70..425a6a2 100644 --- a/lib/mysqlex/error.ex +++ b/lib/mysqlex/error.ex @@ -5,7 +5,10 @@ defmodule Mysqlex.Error do `code` is MySQL's own error number and is precise; `sql_state` is the ANSI SQL five-character code and is portable across databases. `sql_state` is `nil` when the connection did not negotiate `CLIENT_PROTOCOL_41`. + + An exception because `DBConnection` specifies its callbacks as returning + `Exception.t()`, and because callers will want to raise it. """ - defstruct [:code, :sql_state, :message] + defexception [:code, :sql_state, :message] end diff --git a/lib/mysqlex/protocol.ex b/lib/mysqlex/protocol.ex deleted file mode 100644 index 6d904f9..0000000 --- a/lib/mysqlex/protocol.ex +++ /dev/null @@ -1,139 +0,0 @@ -defmodule Mysqlex.Protocol do - @moduledoc """ - `DBConnection` callbacks implementing the MySQL client/server protocol. - - The protocol reference this is built against is vendored under - `docs/mysql-protocol/`. - - Implemented so far: the connection phase up to reading the server's - `Protocol::HandshakeV10` packet. The command phase callbacks return an error - until that work lands. - """ - - use DBConnection - - require Logger - - alias Mysqlex.Tcp - - defstruct host: nil, - socket: nil, - username: nil, - password: nil, - database: nil, - port: nil, - timeout: nil, - opts: nil - - @default_port 3306 - @default_timeout 5_000 - - @doc """ - DBConnection callback for `Mysqlex.start_link/5`. - """ - @impl true - def connect(opts) do - state = %__MODULE__{ - host: opts |> Keyword.fetch!(:hostname) |> to_charlist(), - username: Keyword.fetch!(opts, :username), - password: Keyword.get(opts, :password), - database: Keyword.fetch!(opts, :database), - port: Keyword.get(opts, :port, @default_port), - timeout: Keyword.get(opts, :connect_timeout, @default_timeout), - opts: opts - } - - socket_opts = Keyword.get(opts, :socket_options, []) - - with {:ok, socket} <- Tcp.connect(state.host, state.port, socket_opts, state.timeout) do - Logger.debug(fn -> "mysqlex: server handshake #{inspect(handshake(socket))}" end) - {:ok, %{state | socket: socket}} - end - end - - @impl true - def disconnect(_reason, %__MODULE__{socket: nil}), do: :ok - - def disconnect(_reason, %__MODULE__{socket: socket}) do - Tcp.close(socket) - :ok - end - - @impl true - def checkout(state), do: {:ok, state} - - @impl true - def ping(state), do: {:ok, state} - - @impl true - def handle_begin(_opts, state), do: {:error, state} - - @impl true - def handle_commit(_opts, state), do: {:error, state} - - @impl true - def handle_rollback(_opts, state), do: {:error, state} - - @impl true - def handle_status(_opts, state), do: {:idle, state} - - @impl true - def handle_prepare(_query, _opts, state), do: {:error, not_implemented(), state} - - @impl true - def handle_execute(_query, _params, _opts, state), do: {:error, not_implemented(), state} - - @impl true - def handle_close(_query, _opts, state), do: {:error, not_implemented(), state} - - @impl true - def handle_declare(_query, _params, _opts, state), do: {:error, not_implemented(), state} - - @impl true - def handle_fetch(_query, _cursor, _opts, state), do: {:error, not_implemented(), state} - - @impl true - def handle_deallocate(_query, _cursor, _opts, state), do: {:error, not_implemented(), state} - - defp not_implemented do - DBConnection.ConnectionError.exception("Mysqlex does not implement the command phase yet") - end - - defp handshake(socket) do - {payload_length, _sequence_id} = parse_header(socket) - parse_payload_body(socket, payload_length) - end - - # Parsing of Initial handshake - defp parse_header(socket) do - {:ok, header} = recv_msg(socket, 4) - <> = header - {payload_length, sequence_id} - end - - defp parse_payload_body(socket, payload_length) do - {:ok, body} = recv_msg(socket, payload_length) - - <> = body - - [auth_plugin_name, _] = String.split(auth_plugin_name, <<0>>) - - {version, server_version, connection_id, auth_plugin_data_part_1, capability_flags_lower, - character_set, status_flags, capability_flags_upper, auth_plugin_data_len, - auth_plugin_data_part_2, auth_plugin_name} - end - - # Helper function - defp recv_msg(socket, bytes) do - case Tcp.recv(socket, bytes) do - {:ok, data} -> {:ok, data} - {:error, _reason} = error -> error - end - end -end diff --git a/test/integration/login_test.exs b/test/integration/login_test.exs index 98a5176..8ec251c 100644 --- a/test/integration/login_test.exs +++ b/test/integration/login_test.exs @@ -38,6 +38,18 @@ defmodule Mysqlex.LoginIntegrationTest do assert error.message =~ "Access denied" end + test "connects through DBConnection" do + assert {:ok, pid} = DBConnection.start_link(Mysqlex.Connection, @opts) + + on_exit(fn -> + # a DBConnection pool shuts down rather than stopping normally + Process.unlink(pid) + Process.exit(pid, :shutdown) + end) + + assert Process.alive?(pid) + end + test "a database the account cannot reach is refused after authentication" do assert {:error, %Error{} = error} = Client.connect(Keyword.put(@opts, :database, "nope"))