From 5a630f3aae3620102aa2adb92def5b5b02baaf4c Mon Sep 17 00:00:00 2001 From: Milan Jaric <327155+mjaric@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:14:46 +0200 Subject: [PATCH 01/10] Fix unique_constraint support for en-US error messages The en-US row for MSSQL error 2627 in errors.csv used the literal server placeholder '%.*ls' instead of a regex capture group, so Tds.Error.get_constraint_violations/2 returned [] for every en-US message and Ecto.Changeset.unique_constraint never fired (issue #173). The row now uses the same '(\\S+?)' capture as all other locales. query!/prepare!/execute!/close! no longer re-raise a new Tds.Error built from msg_text only; the original error is re-raised so the :mssql metadata (number, msg_text) survives to callers such as Ecto's constraint mapping. Adds unit tests for constraint extraction (2627/2601 en-US, unknown code) and integration tests asserting query!/prepare!/execute! re-raise Tds.Error with mssql metadata intact. --- lib/tds.ex | 4 -- lib/tds/errors.csv | 2 +- test/error_test.exs | 18 +++++++++ test/query_test.exs | 99 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 5 deletions(-) diff --git a/lib/tds.ex b/lib/tds.ex index caa0cc10..bf3e7d1b 100644 --- a/lib/tds.ex +++ b/lib/tds.ex @@ -82,7 +82,6 @@ defmodule Tds do case DBConnection.prepare_execute(conn, query, params, opts) do {:ok, _query, result} -> result - {:error, %{mssql: %{msg_text: msg}}} -> raise Tds.Error, msg {:error, err} -> raise err end end @@ -125,7 +124,6 @@ defmodule Tds do case DBConnection.prepare(conn, query, opts) do {:ok, query} -> query - {:error, %{mssql: %{msg_text: msg}}} -> raise Tds.Error, msg {:error, err} -> raise err end end @@ -145,7 +143,6 @@ defmodule Tds do def execute!(conn, query, params, opts \\ []) do case DBConnection.execute(conn, query, params, opts) do {:ok, _q, result} -> result - {:error, %{mssql: %{msg_text: msg}}} -> raise Tds.Error, msg {:error, err} -> raise err end end @@ -162,7 +159,6 @@ defmodule Tds do def close!(conn, query, opts \\ []) do case DBConnection.close(conn, query, opts) do {:ok, result} -> result - {:error, %{mssql: %{msg_text: msg}}} -> raise Tds.Error, msg {:error, err} -> raise err end end diff --git a/lib/tds/errors.csv b/lib/tds/errors.csv index 45281c5a..7959acf5 100644 --- a/lib/tds/errors.csv +++ b/lib/tds/errors.csv @@ -1,4 +1,4 @@ -unique,2627,Violation of UNIQUE KEY constraint '%.*ls'\..* +unique,2627,Violation of UNIQUE KEY constraint '(\S+?)'\..* unique,2627,Verletzung der UNIQUE KEY-Einschränkung "(\S+?)"\..* unique,2627,Violation de la contrainte UNIQUE KEY « (\S+?) »\..* unique,2627,制約 '(\S+?)' の UNIQUE KEY 違反.* diff --git a/test/error_test.exs b/test/error_test.exs index 8dceb835..9d154729 100644 --- a/test/error_test.exs +++ b/test/error_test.exs @@ -24,4 +24,22 @@ defmodule ErrorTest do raise Tds.Error, profession: "crocodile hunter" end end + + test "extracts unique constraint name from en-US error 2627" do + message = + "Violation of UNIQUE KEY constraint 'UQ_users_email'. Details: key violation (key value string = (email=foo@bar.com))." + + assert [unique: "UQ_users_email"] = Tds.Error.get_constraint_violations(2627, message) + end + + test "extracts unique index name from en-US error 2601" do + message = + "Cannot insert duplicate key row in object 'dbo.users' with unique index 'IX_users_email'. The duplicate key value is (foo@bar.com)." + + assert [unique: "IX_users_email"] = Tds.Error.get_constraint_violations(2601, message) + end + + test "returns no constraints for unknown error code" do + assert [] = Tds.Error.get_constraint_violations(9999, "some error") + end end diff --git a/test/query_test.exs b/test/query_test.exs index 3544493e..d2b49d0d 100644 --- a/test/query_test.exs +++ b/test/query_test.exs @@ -142,6 +142,105 @@ defmodule QueryTest do assert [[1]] = query("SELECT 1", []) end + test "query returns Tds.Error with MSSQL error metadata on unique violation", context do + query("DROP TABLE UniqueBangTable", []) + + assert :ok = + query( + "CREATE TABLE UniqueBangTable (id INT, email NVARCHAR(100), " <> + "CONSTRAINT UQ_UniqueBangTable_email UNIQUE (email))", + [] + ) + + assert :ok = query("INSERT INTO UniqueBangTable (id, email) VALUES (1, 'foo@bar.com')", []) + + err = query("INSERT INTO UniqueBangTable (id, email) VALUES (2, 'foo@bar.com')", []) + assert %{number: 2627, msg_text: msg} = err.mssql + assert msg =~ "UQ_UniqueBangTable_email" + + query("DROP TABLE dbo.UniqueBangTable", []) + end + + test "query! re-raises Tds.Error preserving MSSQL error metadata", context do + pid = context[:pid] + + query("DROP TABLE UniqueBangTable2", []) + + assert :ok = + query( + "CREATE TABLE UniqueBangTable2 (id INT, email NVARCHAR(100), " <> + "CONSTRAINT UQ_UniqueBangTable2_email UNIQUE (email))", + [] + ) + + assert :ok = query("INSERT INTO UniqueBangTable2 (id, email) VALUES (1, 'foo@bar.com')", []) + + err = + try do + Tds.query!(pid, "INSERT INTO UniqueBangTable2 (id, email) VALUES (2, 'foo@bar.com')", []) + flunk("expected Tds.Error to be raised") + rescue + e in Tds.Error -> e + end + + assert %{number: 2627, msg_text: msg} = err.mssql + assert msg =~ "UQ_UniqueBangTable2_email" + + query("DROP TABLE dbo.UniqueBangTable2", []) + end + + test "prepare! re-raises Tds.Error preserving MSSQL error metadata", context do + pid = context[:pid] + + err = + try do + Tds.prepare!(pid, "SELECT * FROM UniqueBangPrepareMissingTable", []) + flunk("expected Tds.Error to be raised") + rescue + e in Tds.Error -> e + end + + assert %{number: 208, msg_text: msg} = err.mssql + assert msg =~ "UniqueBangPrepareMissingTable" + end + + test "execute! re-raises Tds.Error preserving MSSQL error metadata on unique violation", + context do + pid = context[:pid] + + query("DROP TABLE UniqueBangExecuteTable", []) + + assert :ok = + query( + "CREATE TABLE UniqueBangExecuteTable (id INT, email NVARCHAR(100), " <> + "CONSTRAINT UQ_UniqueBangExecuteTable_email UNIQUE (email))", + [] + ) + + assert :ok = + query("INSERT INTO UniqueBangExecuteTable (id, email) VALUES (1, 'foo@bar.com')", []) + + {:ok, q} = + Tds.prepare( + pid, + "INSERT INTO UniqueBangExecuteTable (id, email) VALUES (2, 'foo@bar.com')", + [] + ) + + err = + try do + Tds.execute!(pid, q, [], []) + flunk("expected Tds.Error to be raised") + rescue + e in Tds.Error -> e + end + + assert %{number: 2627, msg_text: msg} = err.mssql + assert msg =~ "UQ_UniqueBangExecuteTable_email" + + query("DROP TABLE dbo.UniqueBangExecuteTable", []) + end + test "char nulls", context do assert [[nil]] = query("SELECT CAST(NULL as nvarchar(255))", []) end From e9ce17494423f8d9e6d96806842d92860ab94a60 Mon Sep 17 00:00:00 2001 From: Milan Jaric <327155+mjaric@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:15:03 +0200 Subject: [PATCH 02/10] Fix close! crash on sp_unprepare server error handle_close now runs with state :executing, matching the internal auto-close path in handle_execute. Previously a server error response to sp_unprepare (e.g. closing an already-closed handle) was decoded with state :ready, for which Tds.Messages.parse/3 has no clause, so the connection process crashed instead of returning {:error, %Tds.Error{}}. --- lib/tds/protocol.ex | 2 +- test/query_test.exs | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/tds/protocol.ex b/lib/tds/protocol.ex index 2fc918e1..f89e1534 100644 --- a/lib/tds/protocol.ex +++ b/lib/tds/protocol.ex @@ -222,7 +222,7 @@ defmodule Tds.Protocol do | {:error | :disconnect, Exception.t(), new_state :: t()} def handle_close(query, opts, s) do params = opts[:parameters] - send_close(query, params, s) + send_close(query, params, %{s | state: :executing}) end @spec handle_begin(Keyword.t(), t) :: diff --git a/test/query_test.exs b/test/query_test.exs index d2b49d0d..ec3ce667 100644 --- a/test/query_test.exs +++ b/test/query_test.exs @@ -241,6 +241,24 @@ defmodule QueryTest do query("DROP TABLE dbo.UniqueBangExecuteTable", []) end + test "close! re-raises Tds.Error preserving MSSQL error metadata", context do + pid = context[:pid] + + {:ok, q} = Tds.prepare(pid, "SELECT 1", []) + assert %Tds.Result{} = Tds.close!(pid, q, []) + + err = + try do + Tds.close!(pid, q, []) + flunk("expected Tds.Error to be raised") + rescue + e in Tds.Error -> e + end + + assert %{number: 8179, msg_text: msg} = err.mssql + assert msg =~ "Could not find prepared statement" + end + test "char nulls", context do assert [[nil]] = query("SELECT CAST(NULL as nvarchar(255))", []) end From 47bf78fee33fa5e9dc5f04984c2ae36dd450778b Mon Sep 17 00:00:00 2001 From: Milan Jaric <327155+mjaric@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:28:13 +0200 Subject: [PATCH 03/10] Add timeouts and retries to apt install step apt has no download timeout by default, so runner network flakes hang 'apt-get update' indefinitely (observed on run 32232000436: the lint job's apt step sat idle for ~18 minutes until cancellation while the other three jobs finished the same step in under a minute). Add a 30s acquire timeout with up to 3 attempts and cap the step at 10 minutes. --- .github/workflows/elixir.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index d2ce63ce..b000b546 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -50,12 +50,17 @@ jobs: steps: - name: Install MSSql Client Tools + timeout-minutes: 10 run: | curl -sSL https://packages.microsoft.com/keys/microsoft.asc \ | sudo gpg --dearmor --yes -o /usr/share/keyrings/microsoft-prod.gpg echo "deb [arch=amd64,arm64,armhf signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/ubuntu/22.04/prod jammy main" \ | sudo tee /etc/apt/sources.list.d/mssql-release.list - sudo apt-get update + # apt has no download timeout by default; runner network flakes hang it indefinitely + for i in 1 2 3; do + if sudo apt-get update -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30; then break; fi + sleep 5 + done sudo apt-get install mssql-tools18 unixodbc-dev - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 From 98a00a6a3294a7a935f3e0aef5c47c99c7fa1239 Mon Sep 17 00:00:00 2001 From: Milan Jaric <327155+mjaric@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:29:47 +0200 Subject: [PATCH 04/10] Add SQL Server 2024 to CI test matrix --- .github/workflows/elixir.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index b000b546..f985f3c9 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -25,6 +25,7 @@ jobs: mssql: - version: "mssql/server:2017-latest" - version: "mssql/server:2022-latest" + - version: "mssql/server:2024-latest" - version: "azure-sql-edge:latest" pair: - otp: 24.3 From 9c1ba5f339ba83cf302a453fa98846278328138e Mon Sep 17 00:00:00 2001 From: Milan Jaric <327155+mjaric@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:31:35 +0200 Subject: [PATCH 05/10] Use SQL Server 2025 image in CI matrix There is no mssql/server:2024-latest tag; Microsoft skipped from 2022 to 2025 (available tags: 2017-latest, 2019-latest, 2022-latest, 2025-latest). Use 2025-latest as the newest major version. --- .github/workflows/elixir.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index f985f3c9..0f403ccb 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -25,7 +25,7 @@ jobs: mssql: - version: "mssql/server:2017-latest" - version: "mssql/server:2022-latest" - - version: "mssql/server:2024-latest" + - version: "mssql/server:2025-latest" - version: "azure-sql-edge:latest" pair: - otp: 24.3 From 35f1b5e1d35be290f08df86be34084e463bce265 Mon Sep 17 00:00:00 2001 From: Milan Jaric <327155+mjaric@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:32:29 +0200 Subject: [PATCH 06/10] Enforce hard watchdog timeouts on CI apt commands --- .github/workflows/elixir.yml | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 0f403ccb..58edddf8 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -51,18 +51,28 @@ jobs: steps: - name: Install MSSql Client Tools - timeout-minutes: 10 + timeout-minutes: 20 run: | - curl -sSL https://packages.microsoft.com/keys/microsoft.asc \ + curl --max-time 60 -sSL https://packages.microsoft.com/keys/microsoft.asc \ | sudo gpg --dearmor --yes -o /usr/share/keyrings/microsoft-prod.gpg echo "deb [arch=amd64,arm64,armhf signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/ubuntu/22.04/prod jammy main" \ | sudo tee /etc/apt/sources.list.d/mssql-release.list - # apt has no download timeout by default; runner network flakes hang it indefinitely + # apt can stall mid-transfer on runner network flakes: Acquire::*::Timeout only + # bounds idle connections, so enforce a hard watchdog on every command instead. + apt_ok=0 for i in 1 2 3; do - if sudo apt-get update -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30; then break; fi - sleep 5 + if timeout --kill-after=10 180 sudo apt-get update \ + -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30; then + apt_ok=1 + break + fi + echo "apt-get update attempt $i/3 failed; retrying in 5s" done - sudo apt-get install mssql-tools18 unixodbc-dev + if [ "$apt_ok" -ne 1 ]; then + echo "::error::apt-get update failed after 3 attempts" + exit 1 + fi + timeout --kill-after=10 300 sudo apt-get install mssql-tools18 unixodbc-dev - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 From a3fb10bd53cf313393764ec91226b321780daa28 Mon Sep 17 00:00:00 2001 From: Milan Jaric <327155+mjaric@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:33:12 +0200 Subject: [PATCH 07/10] Remove SQL Server 2022 from CI matrix --- .github/workflows/elixir.yml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 58edddf8..cb080505 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -24,7 +24,6 @@ jobs: matrix: mssql: - version: "mssql/server:2017-latest" - - version: "mssql/server:2022-latest" - version: "mssql/server:2025-latest" - version: "azure-sql-edge:latest" pair: @@ -32,17 +31,11 @@ jobs: elixir: 1.14.3 include: - mssql: - version: "mssql/server:2022-latest" + version: "mssql/server:2025-latest" pair: otp: 25.3 elixir: 1.18.4 lint: lint - - mssql: - version: "mssql/server:2022-latest" - pair: - otp: 24.3 - elixir: 1.14.3 - lint: skip_lint env: ACCEPT_EULA: Y From 98aa11e63d9e328e854d38ab08608589cad84c58 Mon Sep 17 00:00:00 2001 From: Milan Jaric <327155+mjaric@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:41:37 +0200 Subject: [PATCH 08/10] Refresh only Microsoft apt repo in CI install step --- .github/workflows/elixir.yml | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index cb080505..da161ea2 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -44,28 +44,35 @@ jobs: steps: - name: Install MSSql Client Tools - timeout-minutes: 20 + timeout-minutes: 10 run: | curl --max-time 60 -sSL https://packages.microsoft.com/keys/microsoft.asc \ | sudo gpg --dearmor --yes -o /usr/share/keyrings/microsoft-prod.gpg echo "deb [arch=amd64,arm64,armhf signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/ubuntu/22.04/prod jammy main" \ | sudo tee /etc/apt/sources.list.d/mssql-release.list - # apt can stall mid-transfer on runner network flakes: Acquire::*::Timeout only - # bounds idle connections, so enforce a hard watchdog on every command instead. + # Refresh only the Microsoft repo: a full `apt-get update` also downloads the + # large Ubuntu archive indexes, which stall mid-transfer on runner network + # flakes. pkill first clears any apt-get orphaned by a watchdog kill, since + # it keeps holding the lists lock and would fail every retry. Ubuntu-side + # dependencies (unixodbc, libkrb5, openssl) ship with the runner image. apt_ok=0 for i in 1 2 3; do - if timeout --kill-after=10 180 sudo apt-get update \ - -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30; then + sudo pkill -9 apt-get 2>/dev/null || true + if timeout --kill-after=10 90 sudo apt-get update \ + -o Dir::Etc::sourcelist=sources.list.d/mssql-release.list \ + -o Dir::Etc::sourceparts=/dev/null \ + -o APT::Get::List-Cleanup=0; then apt_ok=1 break fi echo "apt-get update attempt $i/3 failed; retrying in 5s" + sleep 5 done if [ "$apt_ok" -ne 1 ]; then echo "::error::apt-get update failed after 3 attempts" exit 1 fi - timeout --kill-after=10 300 sudo apt-get install mssql-tools18 unixodbc-dev + timeout --kill-after=10 120 sudo apt-get install mssql-tools18 - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 From 09403ed2d51baaa573a87a410c8e1e0c34c03a48 Mon Sep 17 00:00:00 2001 From: Milan Jaric <327155+mjaric@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:45:36 +0200 Subject: [PATCH 09/10] Use sqlcmd preinstalled on the runner image --- .github/workflows/elixir.yml | 35 +---------------------------------- 1 file changed, 1 insertion(+), 34 deletions(-) diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index da161ea2..c8b9d039 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -43,37 +43,6 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - - name: Install MSSql Client Tools - timeout-minutes: 10 - run: | - curl --max-time 60 -sSL https://packages.microsoft.com/keys/microsoft.asc \ - | sudo gpg --dearmor --yes -o /usr/share/keyrings/microsoft-prod.gpg - echo "deb [arch=amd64,arm64,armhf signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/ubuntu/22.04/prod jammy main" \ - | sudo tee /etc/apt/sources.list.d/mssql-release.list - # Refresh only the Microsoft repo: a full `apt-get update` also downloads the - # large Ubuntu archive indexes, which stall mid-transfer on runner network - # flakes. pkill first clears any apt-get orphaned by a watchdog kill, since - # it keeps holding the lists lock and would fail every retry. Ubuntu-side - # dependencies (unixodbc, libkrb5, openssl) ship with the runner image. - apt_ok=0 - for i in 1 2 3; do - sudo pkill -9 apt-get 2>/dev/null || true - if timeout --kill-after=10 90 sudo apt-get update \ - -o Dir::Etc::sourcelist=sources.list.d/mssql-release.list \ - -o Dir::Etc::sourceparts=/dev/null \ - -o APT::Get::List-Cleanup=0; then - apt_ok=1 - break - fi - echo "apt-get update attempt $i/3 failed; retrying in 5s" - sleep 5 - done - if [ "$apt_ok" -ne 1 ]; then - echo "::error::apt-get update failed after 3 attempts" - exit 1 - fi - timeout --kill-after=10 120 sudo apt-get install mssql-tools18 - - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - name: Setup elixir @@ -106,6 +75,4 @@ jobs: if: ${{ matrix.lint == 'lint' }} - name: Run Tests - run: | - export PATH=/opt/mssql-tools18/bin:$PATH - mix test + run: mix test From ad699c17ca9be7c6a7a4d0ab11c3e5614f04924d Mon Sep 17 00:00:00 2001 From: Milan Jaric <327155+mjaric@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:10:00 +0200 Subject: [PATCH 10/10] Release v2.4.0 --- CHANGELOG.md | 5 +++++ mix.exs | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2098ba0a..4b2d12c0 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). +## 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) +* Fix connection process crash when the server responds with an error to `sp_unprepare`, e.g. calling `close!/2` on an already closed statement + ## v2.3.8 (2026-05-18) ### Improvements * Relax decimal dependency diff --git a/mix.exs b/mix.exs index c26cc9a6..a58ff1ac 100644 --- a/mix.exs +++ b/mix.exs @@ -2,7 +2,7 @@ defmodule Tds.Mixfile do use Mix.Project @source_url "https://github.com/elixir-ecto/tds" - @version "2.3.8" + @version "2.4.0" def project do [