diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b2d12c..11ec6d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Unreleased +### Improvements +* Surface the last MSSQL error token alongside connection-close errors. When the server sends an error token immediately before closing the connection (login failure, fatal severity, kill-by-DBA), the token detail is preserved on the protocol state and combined with the transport-level reason in the resulting `Tds.Error`, so callers no longer see only "Connection closed." / "tcp closed". + ## v2.4.0 (2026-08-19) ### Fixes * Fix `unique_constraint` support in Ecto for en-US error messages: MSSQL error metadata is now preserved when re-raising from `query!/4`, `prepare!/3`, `execute!/4` and `close!/2` (#173) @@ -334,11 +339,7 @@ could not determine if binary is of uuid type, it interpreted such values as raw ### Enhancements * Added API for ATTN call -<<<<<<< HEAD -## v0.1.5 -======= ## v0.1.5 - 2015-02-19 ->>>>>>> 1007dc1 (Misc doc changes) ### Bug Fixes * Fixed issue where driver would not call Connection.next when setting the state to :ready * Fixed UCS2 Encoding diff --git a/lib/tds/error.ex b/lib/tds/error.ex index 0125fa7..331e6c3 100644 --- a/lib/tds/error.ex +++ b/lib/tds/error.ex @@ -5,8 +5,13 @@ defmodule Tds.Error do The struct has two fields: * `:message`: expected to be a string - * `:mssql`: expected to be a keyword list with the fields `line_number`, - `number` and `msg_text` + * `:mssql`: expected to be a map carrying the fields decoded from a + SQL Server error token: `number`, `state`, `class`, `msg_text`, + `server_name`, `proc_name`, and `line_number` + + When both fields are populated — for example, when a connection-close is + preceded by a server-side error token — `message/1` combines them so the + underlying server error is surfaced alongside the transport-level reason. ## Usage @@ -21,8 +26,16 @@ defmodule Tds.Error do """ - @type error_details :: %{line_number: integer(), number: integer(), msg_text: String.t()} - @type t :: %__MODULE__{message: String.t(), mssql: error_details} + @type error_details :: %{ + optional(:state) => integer(), + optional(:class) => integer(), + optional(:server_name) => String.t(), + optional(:proc_name) => String.t(), + required(:line_number) => integer(), + required(:number) => integer(), + required(:msg_text) => String.t() + } + @type t :: %__MODULE__{message: String.t() | nil, mssql: error_details | nil} defexception [:message, :mssql] @@ -45,8 +58,13 @@ defmodule Tds.Error do end @spec message(%__MODULE__{}) :: String.t() + def message(%__MODULE__{mssql: mssql, message: message}) + when is_map(mssql) and is_binary(message) do + "#{message} Line #{mssql.line_number} (Error #{mssql.number}): #{mssql.msg_text}" + end + def message(%__MODULE__{mssql: mssql}) when is_map(mssql) do - "Line #{mssql[:line_number]} (Error #{mssql[:number]}): #{mssql[:msg_text]}" + "Line #{mssql.line_number} (Error #{mssql.number}): #{mssql.msg_text}" end def message(%__MODULE__{message: message}) when is_binary(message) do diff --git a/lib/tds/protocol.ex b/lib/tds/protocol.ex index f89e153..9fe745f 100644 --- a/lib/tds/protocol.ex +++ b/lib/tds/protocol.ex @@ -42,7 +42,8 @@ defmodule Tds.Protocol do result: nil | list(), query: nil | String.t(), transaction: transaction, - env: env + env: env, + last_mssql_error: nil | Tds.Error.error_details() } defstruct sock: nil, @@ -59,7 +60,8 @@ defmodule Tds.Protocol do savepoint: 0, collation: %Tds.Protocol.Collation{}, packetsize: 4096 - } + }, + last_mssql_error: nil @spec connect(opts :: Keyword.t()) :: {:ok, state :: t()} | {:error, Exception.t()} def connect(opts) do @@ -100,7 +102,7 @@ defmodule Tds.Protocol do {:ok, s} {:disconnect, :closed, s} -> - {:disconnect, %Tds.Error{message: "Connection closed."}, s} + {:disconnect, disconnect_error("Connection closed.", s), s} {:error, err, s} -> err = @@ -126,6 +128,7 @@ defmodule Tds.Protocol do def checkout(%{sock: {mod, _sock}} = s) do sock_mod = inspect(mod) + s = %{s | last_mssql_error: nil} case setopts(s.sock, active: false) do :ok -> @@ -460,11 +463,11 @@ defmodule Tds.Protocol do end def handle_info({tag, _}, s) when tag in [:tcp_closed, :ssl_closed] do - {:stop, Tds.Error.exception("tcp closed"), s} + {:stop, disconnect_error("tcp closed", s), s} end def handle_info({tag, _, reason}, s) when tag in [:tcp_error, :ssl_error] do - {:stop, Tds.Error.exception("tcp error: #{reason}"), s} + {:stop, disconnect_error("tcp error: #{reason}", s), s} end def handle_info(msg, s) do @@ -505,10 +508,10 @@ defmodule Tds.Protocol do {:ok, s} {:tcp_closed, ^sock} -> - {:disconnect, %Tds.Error{message: "tcp closed"}, s} + {:disconnect, disconnect_error("tcp closed", s), s} {:tcp_error, ^sock, reason} -> - {:disconnect, %Tds.Error{message: "tcp error: #{reason}"}, s} + {:disconnect, disconnect_error("tcp error: #{reason}", s), s} after 0 -> # There might not be any socket messages. @@ -796,7 +799,7 @@ defmodule Tds.Protocol do ## Error def message(_, msg_error(error: e), %{} = s) do error = %Tds.Error{mssql: e} - {:error, error, mark_ready(s)} + {:error, error, mark_ready(%{s | last_mssql_error: e})} end ## ATTN Ack @@ -810,6 +813,15 @@ defmodule Tds.Protocol do %{s | state: :ready} end + @spec disconnect_error(String.t(), t()) :: Tds.Error.t() + defp disconnect_error(message, %__MODULE__{last_mssql_error: nil}) do + %Tds.Error{message: message} + end + + defp disconnect_error(message, %__MODULE__{last_mssql_error: %{} = mssql}) do + %Tds.Error{message: message, mssql: mssql} + end + # Send Command To Sql Server defp login_send(msg, %{sock: {mod, sock}, env: env, opts: opts} = s) do paks = encode_msg(msg, env) @@ -866,9 +878,7 @@ defmodule Tds.Protocol do {:error, error} -> {:disconnect, - %Tds.Error{ - message: "Connection failed to receive packet due #{inspect(error)}" - }, s} + disconnect_error("Connection failed to receive packet due #{inspect(error)}", s), s} end catch {:error, error} -> {:disconnect, error, s} diff --git a/test/error_test.exs b/test/error_test.exs index 9d15472..e245529 100644 --- a/test/error_test.exs +++ b/test/error_test.exs @@ -13,6 +13,16 @@ defmodule ErrorTest do end end + test "combines :message and :mssql when both are populated" do + error = %Tds.Error{ + message: "Connection closed.", + mssql: %{line_number: 1, number: 18_456, msg_text: "Login failed for user 'sa'"} + } + + assert Tds.Error.message(error) == + "Connection closed. Line 1 (Error 18456): Login failed for user 'sa'" + end + test "raises a Tds.Error with a default message as a fallback" do # no arguments assert_raise Tds.Error, "An error occured.", fn ->