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"))