diff --git a/.github/workflows/build-test-publish.yml b/.github/workflows/build-test-publish.yml
new file mode 100644
index 0000000..5024322
--- /dev/null
+++ b/.github/workflows/build-test-publish.yml
@@ -0,0 +1,167 @@
+name: Build, Test & Publish
+permissions:
+ contents: write
+
+on:
+ push:
+ branches: [master]
+ pull_request:
+
+defaults:
+ run:
+ shell: bash
+
+jobs:
+ build:
+ name: Build & Test (${{ matrix.mssql_image }})
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ mssql_image:
+ - mcr.microsoft.com/mssql/server:2025-latest
+ - mcr.microsoft.com/mssql/server:2022-latest
+ - mcr.microsoft.com/mssql/server:2019-latest
+ - mcr.microsoft.com/mssql/server:2017-latest
+
+ env:
+ MIX_ENV: test
+ MSSQL_IMAGE: ${{ matrix.mssql_image }}
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: jdx/mise-action@v2
+
+ - name: Install dependencies
+ run: mix deps.get
+
+ - name: Compile
+ run: mix compile --warnings-as-errors
+
+ - name: Static analysis
+ run: mix credo
+
+ - name: Run integration tests
+ run: mix test --only integration
+
+ benchmark:
+ name: Benchmark
+ runs-on: ubuntu-latest
+ needs: build
+ if: github.ref_name == github.event.repository.default_branch && !failure() && !cancelled()
+ # Non-blocking: a flaky benchmark must never prevent a release from shipping.
+ continue-on-error: true
+
+ env:
+ MIX_ENV: dev
+ MSSQL_IMAGE: mcr.microsoft.com/mssql/server:2022-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: jdx/mise-action@v2
+
+ - name: Cache Mix dependencies
+ uses: actions/cache@v4
+ with:
+ path: |
+ deps
+ _build
+ key: ${{ runner.os }}-mix-dev-${{ hashFiles('mix.lock') }}
+ restore-keys: ${{ runner.os }}-mix-dev-
+
+ - name: Install dependencies
+ run: mix deps.get
+
+ - name: Compile
+ run: mix compile
+
+ - name: Run benchmarks
+ # stderr carries Logger/Testcontainers debug noise; stdout has the results.
+ run: mix run bench/benchmarks.exs > bench_results.txt 2>/dev/null
+
+ - name: Upload benchmark results
+ uses: actions/upload-artifact@v4
+ with:
+ name: benchmark-results
+ path: bench_results.txt
+
+ publish:
+ name: Publish
+ runs-on: ubuntu-latest
+ needs: [build, benchmark]
+ if: github.ref_name == github.event.repository.default_branch && !failure() && !cancelled()
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - uses: jdx/mise-action@v2
+
+ - name: Cache Mix dependencies
+ uses: actions/cache@v4
+ with:
+ path: |
+ deps
+ _build
+ key: ${{ runner.os }}-mix-${{ hashFiles('mix.lock') }}
+ restore-keys: ${{ runner.os }}-mix-
+
+ - name: Install dependencies
+ run: mix deps.get
+
+ - name: Get version
+ id: version
+ run: |
+ BASE=$(cat VERSION)
+ BASE_COMMIT=$(git log -1 --format="%H" -- VERSION)
+ HEIGHT=$(git rev-list --count "${BASE_COMMIT}..HEAD")
+ VERSION="${BASE}.${HEIGHT}"
+ sed -i "s/version: \"[^\"]*\"/version: \"${VERSION}\"/" mix.exs
+ echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
+
+ - name: Generate release notes
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ gh api repos/${{ github.repository }}/releases/generate-notes \
+ -f tag_name="v${{ steps.version.outputs.version }}" \
+ --jq .body > release_notes.md
+
+ - name: Download benchmark results
+ uses: actions/download-artifact@v4
+ # Non-blocking: publish proceeds even if the benchmark artifact is absent.
+ continue-on-error: true
+ with:
+ name: benchmark-results
+
+ - name: Append benchmarks to release notes
+ run: |
+ if [ -f bench_results.txt ]; then
+ {
+ echo ""
+ echo "## Benchmarks"
+ echo ""
+ echo "Measured against \`mcr.microsoft.com/mssql/server:2022-latest\` on \`ubuntu-latest\`."
+ echo ""
+ echo '```'
+ cat bench_results.txt
+ echo '```'
+ } >> release_notes.md
+ fi
+
+ - name: Publish to Hex.pm
+ env:
+ HEX_API_KEY: ${{ secrets.HEX_API_KEY }}
+ run: mix hex.publish --yes
+
+ - name: Create GitHub Release
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ gh release create "v${{ steps.version.outputs.version }}" \
+ --title "v${{ steps.version.outputs.version }}" \
+ --notes-file release_notes.md
diff --git a/.testcontainers.properties b/.testcontainers.properties
new file mode 100644
index 0000000..2254a45
--- /dev/null
+++ b/.testcontainers.properties
@@ -0,0 +1,4 @@
+# Disable the Ryuk reaper container.
+# Ryuk requires privileged mode and does not work with rootless Podman.
+# Containers are still stopped when the test process exits cleanly.
+ryuk.disabled=true
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 5a3b704..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,43 +0,0 @@
-language: elixir
-
-sudo: enabled
-
-elixir:
- - "1.9.2"
-
-otp_release:
- - "22.0"
-
-env:
- - DOCKER_COMPOSE_VERSION=1.25.0
-
-before_install:
- - sudo rm /usr/local/bin/docker-compose
- - curl -L https://github.com/docker/compose/releases/download/${DOCKER_COMPOSE_VERSION}/docker-compose-`uname -s`-`uname -m` > docker-compose
- - chmod +x docker-compose
- - sudo mv docker-compose /usr/local/bin
-
-install:
- - wget -q https://packages.microsoft.com/config/ubuntu/18.04/packages-microsoft-prod.deb
- - sudo dpkg -i packages-microsoft-prod.deb
- - sudo add-apt-repository universe
- - sudo apt-get install apt-transport-https
- - sudo apt-get update
- - sudo apt-get install dotnet-sdk-3.1
- - mix local.hex --force
-
-jobs:
- include:
- - stage: test
- script:
- - mix deps.get
- - mix compile --warnings-as-errors
- - mix credo --strict
- - docker-compose up -d
- - mix test --only integration
-
- - stage: deploy
- if: branch = master AND type = push AND fork = false
- script:
- - mix deps.get
- - mix hex.publish --yes
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..dda7689
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,161 @@
+# AGENTS.md
+
+Guidelines for AI agents and automated tools contributing to **ex_sql_client**.
+
+---
+
+## What this project is
+
+`ex_sql_client` is an Elixir Microsoft SQL Server driver. It is a thin Elixir
+wrapper over a .NET worker process (via [Netler](https://github.com/svan-jansson/netler))
+that uses `Microsoft.Data.SqlClient` to communicate with SQL Server.
+
+The library requires a running SQL Server instance for its integration tests.
+
+---
+
+## Repository layout
+
+```
+lib/
+ ex_sql_client.ex # Public API – the main entry point
+ ex_sql_client/
+ connection.ex # DBConnection behaviour implementation
+ protocol.ex # Netler RPC call helpers
+ query.ex # Query struct
+ result.ex # Result struct
+dotnet/dotnet_sql_client/
+ DotnetSqlClient.csproj # .NET 8 project file
+ Program.cs # Netler.NET server bootstrap
+ SqlAdapter.cs # SQL Server operations via Microsoft.Data.SqlClient
+test/
+ query_test.exs
+ transaction_test.exs
+ prepared_statement_test.exs
+ data_type_test.exs
+ test_helper.exs
+mix.exs # Build config and project metadata
+docker-compose.yml # Local SQL Server for integration tests
+.github/workflows/ # GitHub Actions CI (build + test + publish)
+```
+
+---
+
+## Building
+
+Prerequisites: Elixir ≥ 1.9, Erlang/OTP, .NET 8 SDK, running SQL Server instance.
+
+```bash
+mix deps.get
+mix compile --warnings-as-errors
+```
+
+The `--warnings-as-errors` flag is enforced in CI; treat compiler warnings as
+bugs.
+
+The Netler compiler (`mix compile.netler`) automatically builds the .NET project
+in `dotnet/dotnet_sql_client/` and places the binary in `priv/`.
+
+---
+
+## Testing
+
+Integration tests spin up a SQL Server container automatically via
+[Testcontainers](https://hex.pm/packages/testcontainers). Docker (or a
+compatible runtime) must be available on the machine.
+
+```bash
+mix test --only integration
+```
+
+The container is started once in `test/test_helper.exs` and the connection
+string is shared with all test modules via `Application.put_env`. All tests are
+tagged with `@tag :integration`. There are no unit tests that run without a
+live database.
+
+---
+
+## Code conventions
+
+### Elixir
+
+- Format every file with `mix format` before committing. The formatter config
+ lives in `.formatter.exs`.
+- Follow standard Elixir naming: `snake_case` functions, `CamelCase` modules.
+- Public functions should have `@doc` and `@spec` annotations.
+- Return values use tagged tuples (`{:ok, value}` / `{:error, reason}`). Do not
+ raise exceptions across module boundaries.
+- The Netler route names in `Program.cs` must match the atoms used in the
+ Elixir protocol layer exactly.
+
+### C\#
+
+- The .NET project targets `net8.0`. Do not downgrade the target framework.
+- Use `Microsoft.Data.SqlClient` (not the deprecated `System.Data.SqlClient`).
+- Follow existing naming conventions: `PascalCase` for methods and classes,
+ `camelCase` for locals.
+- Route handler methods in `SqlAdapter.cs` must have the signature
+ `public object MethodName(params object[] parameters)`.
+
+---
+
+## Architecture notes
+
+The call chain for a query is:
+
+```
+Elixir caller
+ → ExSqlClient (DBConnection behaviour)
+ → Netler RPC (TCP socket, port process)
+ → Program.cs (Netler.NET server)
+ → SqlAdapter.cs
+ → Microsoft.Data.SqlClient
+ → SQL Server
+```
+
+Key invariants:
+- The .NET process is started by Netler as a port. It listens on a TCP port
+ passed as `args[0]`; the Elixir PID is passed as `args[1]`.
+- All route names in `Program.cs` must be registered and match what the
+ Elixir layer calls via Netler.
+- Connection and transaction state is held in `SqlAdapter` — one instance per
+ connection process.
+
+---
+
+## What to work on
+
+Good first contributions:
+- Expanding test coverage for edge-case data types.
+- Adding `@spec` / `@type` annotations to the Elixir modules.
+- Improving error propagation from the .NET layer to Elixir.
+- Updating dependencies as new versions are released.
+
+Areas requiring extra care:
+- Anything touching `Program.cs` or `SqlAdapter.cs` — changes must compile
+ with the pinned .NET version and be verified against a live SQL Server.
+- Netler version upgrades — the RPC protocol may change between major versions;
+ always check `Program.cs` against the new Netler.NET API.
+- `DBConnection` behaviour callbacks — maintain compatibility with the
+ `db_connection` contract.
+
+---
+
+## Pull request checklist
+
+- [ ] `mix compile --warnings-as-errors` passes.
+- [ ] `mix test --only integration` passes against a local SQL Server.
+- [ ] `mix format --check-formatted` passes.
+- [ ] `mix credo` passes.
+- [ ] New public functions have `@doc` and `@spec`.
+- [ ] Commit messages are concise and in the imperative mood.
+
+---
+
+## Out of scope
+
+- Windows CI — the SQL Server Docker container is Linux-only in GitHub Actions;
+ the library itself is cross-platform but CI runs on Linux.
+- Supporting databases other than Microsoft SQL Server.
+- Changing the `DBConnection` protocol — maintain compatibility with standard
+ Elixir database tooling (Ecto, etc.).
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..3b04cfb
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+0.2
diff --git a/bench/benchmarks.exs b/bench/benchmarks.exs
new file mode 100644
index 0000000..1fed546
--- /dev/null
+++ b/bench/benchmarks.exs
@@ -0,0 +1,176 @@
+# Run with: mix run bench/benchmarks.exs
+#
+# By default this script starts a SQL Server container via Testcontainers.
+# Set MSSQL_CONNECTION_STRING to skip Testcontainers and use an existing server.
+# Set MSSQL_IMAGE to change the SQL Server version (default: 2022-latest).
+
+# ---------------------------------------------------------------------------
+# Connection setup
+# ---------------------------------------------------------------------------
+
+connection_string =
+ case System.get_env("MSSQL_CONNECTION_STRING") do
+ nil ->
+ # Auto-detect rootless Podman socket (common on Fedora / RHEL).
+ unless System.get_env("DOCKER_HOST") do
+ xdg = System.get_env("XDG_RUNTIME_DIR", "")
+ podman_sock = Path.join(xdg, "podman/podman.sock")
+
+ if xdg != "" and File.exists?(podman_sock) do
+ System.put_env("DOCKER_HOST", "unix://#{podman_sock}")
+ end
+ end
+
+ {:ok, _} = Application.ensure_all_started(:hackney)
+ {:ok, _} = Testcontainers.start_link()
+
+ image = System.get_env("MSSQL_IMAGE", "mcr.microsoft.com/mssql/server:2022-latest")
+ IO.puts("Starting SQL Server container (#{image}) …")
+
+ {:ok, container} =
+ Testcontainers.Container.new(image)
+ |> Testcontainers.Container.with_environment("SA_PASSWORD", "InsecurePassword123")
+ |> Testcontainers.Container.with_environment("ACCEPT_EULA", "Y")
+ |> Testcontainers.Container.with_exposed_port(1433)
+ |> Testcontainers.Container.with_waiting_strategy(
+ Testcontainers.LogWaitStrategy.new(~r/SQL Server is now ready/, 120_000)
+ )
+ |> Testcontainers.start_container()
+
+ port = Testcontainers.Container.mapped_port(container, 1433)
+
+ "Server=localhost,#{port}; MultipleActiveResultSets=true; User Id=sa; Password=InsecurePassword123; TrustServerCertificate=True"
+
+ cs ->
+ cs
+ end
+
+# Give SQL Server a moment to finish authentication setup after the log
+# "ready" message fires — without this the pool workers hit a transient
+# "Login failed" on their first connection attempt and must retry.
+Process.sleep(2_000)
+
+IO.puts("SQL Server ready. Starting benchmarks …\n")
+
+# ---------------------------------------------------------------------------
+# Connection pools
+#
+# bench_pool – pool_size: 5 for general query benchmarks
+# prep_pool – pool_size: 1 so the prepared statement always lives on the
+# same .NET worker process and its statement_id stays valid
+# ---------------------------------------------------------------------------
+
+{:ok, bench_pool} =
+ ExSqlClient.start_link(connection_string: connection_string, pool_size: 5)
+
+{:ok, prep_pool} =
+ ExSqlClient.start_link(connection_string: connection_string, pool_size: 1)
+
+# Raw Netler client — starts the .NET binary but never calls Connect, so it
+# never touches SQL Server. Used to measure pure IPC overhead in isolation.
+{:ok, raw_netler} = Netler.Client.start_link(:dotnet_sql_client)
+
+# ---------------------------------------------------------------------------
+# Schema setup
+# ---------------------------------------------------------------------------
+
+{:ok, _} =
+ ExSqlClient.query(bench_pool, """
+ CREATE TABLE bench_rows (
+ id INT IDENTITY(1,1) PRIMARY KEY,
+ label NVARCHAR(100) NOT NULL,
+ value INT NOT NULL
+ )
+ """)
+
+IO.puts("Seeding 1 000 rows …")
+
+for i <- 1..1_000 do
+ {:ok, _} =
+ ExSqlClient.query(
+ bench_pool,
+ "INSERT INTO bench_rows (label, value) VALUES (@label, @value)",
+ %{"label" => "row-#{i}", "value" => i}
+ )
+end
+
+IO.puts("Done seeding.\n")
+
+# ---------------------------------------------------------------------------
+# Prepared statement (reused across all iterations)
+# Pinned to prep_pool (pool_size: 1) to keep the statement_id valid.
+# ---------------------------------------------------------------------------
+
+{:ok, prepared_select} =
+ ExSqlClient.prepare(
+ prep_pool,
+ %ExSqlClient.Query{
+ statement: "SELECT TOP 1 label, value FROM bench_rows WHERE id = @id"
+ }
+ )
+
+# ---------------------------------------------------------------------------
+# Benchmarks
+# ---------------------------------------------------------------------------
+
+Benchee.run(
+ %{
+ # Pure Netler IPC round-trip — no SQL Server involvement.
+ # Subtract this from any query scenario to isolate the SQL Server + ADO.NET cost.
+ "netler round-trip (no SQL)" => fn ->
+ {:ok, true} = Netler.Client.invoke(raw_netler, "NoOp", [])
+ end,
+ "select constant" => fn ->
+ {:ok, _} = ExSqlClient.query(bench_pool, "SELECT 1 AS n")
+ end,
+ "select 1 row" => fn ->
+ {:ok, _} = ExSqlClient.query(bench_pool, "SELECT TOP 1 label, value FROM bench_rows")
+ end,
+ "select 1 row (parameterized)" => fn ->
+ {:ok, _} =
+ ExSqlClient.query(
+ bench_pool,
+ "SELECT TOP 1 label, value FROM bench_rows WHERE id = @id",
+ %{"id" => :rand.uniform(1_000)}
+ )
+ end,
+ "select 10 rows" => fn ->
+ {:ok, _} = ExSqlClient.query(bench_pool, "SELECT TOP 10 label, value FROM bench_rows")
+ end,
+ "select 100 rows" => fn ->
+ {:ok, _} = ExSqlClient.query(bench_pool, "SELECT TOP 100 label, value FROM bench_rows")
+ end,
+ "prepared statement (select)" => fn ->
+ {:ok, _query, _rows} =
+ ExSqlClient.execute(prep_pool, prepared_select, %{"id" => :rand.uniform(1_000)})
+ end,
+ "insert" => fn ->
+ {:ok, _} =
+ ExSqlClient.query(
+ bench_pool,
+ "INSERT INTO bench_rows (label, value) VALUES (@label, @value)",
+ %{"label" => "bench", "value" => 0}
+ )
+ end,
+ "transaction (insert + commit)" => fn ->
+ {:ok, _} =
+ ExSqlClient.transaction(bench_pool, fn tx ->
+ ExSqlClient.query(
+ tx,
+ "INSERT INTO bench_rows (label, value) VALUES (@label, @value)",
+ %{"label" => "bench", "value" => 0}
+ )
+ end)
+ end
+ },
+ time: 10,
+ warmup: 2,
+ memory_time: 2
+)
+
+# ---------------------------------------------------------------------------
+# Cleanup
+# ---------------------------------------------------------------------------
+
+ExSqlClient.close(prep_pool, prepared_select)
+{:ok, _} = ExSqlClient.query(bench_pool, "DROP TABLE bench_rows")
diff --git a/docker-compose.yml b/docker-compose.yml
deleted file mode 100644
index 5e5014e..0000000
--- a/docker-compose.yml
+++ /dev/null
@@ -1,10 +0,0 @@
-version: "3.2"
-services:
- sql-server-db:
- container_name: sql-server-db
- image: microsoft/mssql-server-linux:latest
- ports:
- - "1433:1433"
- environment:
- SA_PASSWORD: "InsecurePassword123"
- ACCEPT_EULA: "Y"
diff --git a/dotnet/dotnet_sql_client/DotnetSqlClient.csproj b/dotnet/dotnet_sql_client/DotnetSqlClient.csproj
index 428168f..31cac77 100644
--- a/dotnet/dotnet_sql_client/DotnetSqlClient.csproj
+++ b/dotnet/dotnet_sql_client/DotnetSqlClient.csproj
@@ -2,12 +2,12 @@
Exe
- netcoreapp3.1
+ net8.0
-
-
+
+
diff --git a/dotnet/dotnet_sql_client/ElixirLogger.cs b/dotnet/dotnet_sql_client/ElixirLogger.cs
new file mode 100644
index 0000000..dca4889
--- /dev/null
+++ b/dotnet/dotnet_sql_client/ElixirLogger.cs
@@ -0,0 +1,64 @@
+#nullable enable
+using Microsoft.Extensions.Logging;
+using System;
+
+namespace DotnetSqlClient;
+
+///
+/// Routes .NET log messages to stderr so they appear inline in the Elixir / IEx console.
+///
+/// Netler starts the .NET process via Elixir's System.cmd/3, which captures stdout
+/// (so Console.Out goes nowhere visible) but lets stderr pass through to the BEAM's
+/// stderr — i.e. the same terminal that IEx is running in.
+///
+/// Output is formatted to match Elixir Logger's default timestamp layout:
+/// HH:mm:ss.fff [level] category: message
+///
+internal sealed class ElixirLogger : ILogger
+{
+ private readonly string _category;
+ private readonly LogLevel _minLevel;
+
+ public ElixirLogger(string category, LogLevel minLevel = LogLevel.Information)
+ {
+ _category = category;
+ _minLevel = minLevel;
+ }
+
+ public IDisposable? BeginScope(TState _) where TState : notnull
+ => NullScope.Instance;
+
+ public bool IsEnabled(LogLevel logLevel) => logLevel >= _minLevel;
+
+ public void Log(
+ LogLevel logLevel,
+ EventId _,
+ TState state,
+ Exception? exception,
+ Func formatter)
+ {
+ if (!IsEnabled(logLevel))
+ return;
+
+ var level = logLevel switch
+ {
+ LogLevel.Trace or LogLevel.Debug => "debug",
+ LogLevel.Information => "info",
+ LogLevel.Warning => "warning",
+ LogLevel.Error or LogLevel.Critical => "error",
+ _ => "info"
+ };
+
+ var timestamp = DateTime.Now.ToString("HH:mm:ss.fff");
+ Console.Error.WriteLine($"{timestamp} [{level}] {_category}: {formatter(state, exception)}");
+
+ if (exception is not null)
+ Console.Error.WriteLine($"{timestamp} [error] {_category}: {exception}");
+ }
+
+ private sealed class NullScope : IDisposable
+ {
+ public static readonly NullScope Instance = new();
+ public void Dispose() { }
+ }
+}
\ No newline at end of file
diff --git a/dotnet/dotnet_sql_client/Program.cs b/dotnet/dotnet_sql_client/Program.cs
index 49d4477..9bb70d6 100644
--- a/dotnet/dotnet_sql_client/Program.cs
+++ b/dotnet/dotnet_sql_client/Program.cs
@@ -1,39 +1,42 @@
-using DotnetSqlClient;
-using Netler;
using System;
+using System.Collections.Generic;
using System.Threading.Tasks;
+using DotnetSqlClient;
+using Netler;
+using Netler.Contracts;
-namespace Dotnetadapter
+namespace Dotnetadapter;
+
+class Program
{
- class Program
+ private static async Task Main(string[] args)
{
- static async Task Main(string[] args)
- {
- var port = Convert.ToInt32(args[0]);
- var clientPid = Convert.ToInt32(args[1]);
- var adapter = new SqlAdapter();
+ var port = Convert.ToInt32(args[0]);
+ var clientPid = Convert.ToInt32(args[1]);
+ var adapter = new SqlAdapter();
- var server = Server.Create((config) =>
- {
- config.UsePort(port);
- config.UseClientPid(clientPid);
- config.UseRoutes((routes) =>
- {
- routes.Add("Connect", adapter.Connect);
- routes.Add("Disconnect", adapter.Disconnect);
- routes.Add("Execute", adapter.Execute);
- routes.Add("ExecuteInTransaction", adapter.ExecuteInTransaction);
- routes.Add("ExecutePreparedStatement", adapter.ExecutePreparedStatement);
- routes.Add("ExecutePreparedStatementInTransaction", adapter.ExecutePreparedStatementInTransaction);
- routes.Add("BeginTransaction", adapter.BeginTransaction);
- routes.Add("RollbackTransaction", adapter.RollbackTransaction);
- routes.Add("CommitTransaction", adapter.CommitTransaction);
- routes.Add("PrepareStatement", adapter.PrepareStatement);
- routes.Add("ClosePreparedStatement", adapter.ClosePreparedStatement);
- });
- });
+ var server = Server.Create(config =>
+ {
+ config.UsePort(port);
+ config.UseClientPid(clientPid);
+ config.UseLogger(new ElixirLogger("dotnet_sql_client"));
+ config.UseRoutes(routes =>
+ {
+ routes.AddTyped("NoOp", SqlAdapter.NoOp);
+ routes.AddTyped("Connect", adapter.Connect);
+ routes.AddTyped("Disconnect", adapter.Disconnect);
+ routes.AddTyped, List>>("Execute", adapter.Execute);
+ routes.AddTyped, int, List>>("ExecuteInTransaction", adapter.ExecuteInTransaction);
+ routes.AddTyped, int, List>>("ExecutePreparedStatement", adapter.ExecutePreparedStatement);
+ routes.AddTyped, int, int, List>>("ExecutePreparedStatementInTransaction", adapter.ExecutePreparedStatementInTransaction);
+ routes.AddTyped("BeginTransaction", adapter.BeginTransaction);
+ routes.AddTyped("RollbackTransaction", adapter.RollbackTransaction);
+ routes.AddTyped("CommitTransaction", adapter.CommitTransaction);
+ routes.AddTyped("PrepareStatement", adapter.PrepareStatement);
+ routes.AddTyped("ClosePreparedStatement", adapter.ClosePreparedStatement);
+ });
+ });
- await server.Start();
- }
+ await server.Start();
}
-}
+}
\ No newline at end of file
diff --git a/dotnet/dotnet_sql_client/SqlAdapter.cs b/dotnet/dotnet_sql_client/SqlAdapter.cs
index 2c43d48..e84a317 100644
--- a/dotnet/dotnet_sql_client/SqlAdapter.cs
+++ b/dotnet/dotnet_sql_client/SqlAdapter.cs
@@ -1,209 +1,214 @@
using System;
using System.Collections.Generic;
using System.Data;
-using System.Data.SqlClient;
-using System.Linq;
+using Microsoft.Data.SqlClient;
-namespace DotnetSqlClient
+namespace DotnetSqlClient;
+
+public class SqlAdapter
{
- public class SqlAdapter
- {
- private IDbConnection Connection { get; set; }
- private Dictionary Transactions { get; set; }
- private Dictionary PreparedStatements { get; set; }
+ private IDbConnection Connection { get; set; }
+ private Dictionary Transactions { get; set; }
+ private Dictionary PreparedStatements { get; set; }
+ private int _nextId = 0;
- public object Connect(params object[] parameters)
- {
- if (Connection == null)
- {
- var connectionString = Convert.ToString(parameters[0]);
- Connection = new SqlConnection(connectionString);
- Connection.Open();
- }
- Transactions = new Dictionary();
- PreparedStatements = new Dictionary();
- return Connection.State == ConnectionState.Open;
- }
+ // Returns immediately without touching SQL Server.
+ // Used by the Elixir benchmarks to isolate pure Netler IPC overhead.
+ public static bool NoOp() => true;
- public object Disconnect(params object[] _)
+ public bool Connect(string connectionString)
+ {
+ if (Connection == null)
{
- if (Connection != null)
- {
- Connection.Close();
- Connection.Dispose();
- Connection = null;
- Transactions = null;
- PreparedStatements = null;
- }
- return true;
+ Connection = new SqlConnection(connectionString);
+ Connection.Open();
}
+ Transactions = new Dictionary();
+ PreparedStatements = new Dictionary();
+ return Connection.State == ConnectionState.Open;
+ }
- public object BeginTransaction(params object[] _)
+ public bool Disconnect()
+ {
+ if (Connection != null)
{
- var transaction = Connection.BeginTransaction();
- var transactionId = transaction.GetHashCode();
- Transactions.Add(transactionId, transaction);
- return transactionId;
+ Connection.Close();
+ Connection.Dispose();
+ Connection = null;
+ Transactions = null;
+ PreparedStatements = null;
}
+ return true;
+ }
- public object RollbackTransaction(params object[] parameters)
- {
- var transactionId = Convert.ToInt32(parameters[0]);
- var transaction = Transactions[transactionId];
- try
- {
- transaction.Rollback();
- }
- catch
- {
- Transactions.Remove(transactionId);
- transaction.Dispose();
- throw;
- }
- return true;
- }
+ public int BeginTransaction()
+ {
+ var transaction = Connection.BeginTransaction();
+ var transactionId = System.Threading.Interlocked.Increment(ref _nextId);
+ Transactions.Add(transactionId, transaction);
+ return transactionId;
+ }
- public object CommitTransaction(params object[] parameters)
+ public bool RollbackTransaction(int transactionId)
+ {
+ var transaction = Transactions[transactionId];
+ try
{
- var transactionId = Convert.ToInt32(parameters[0]);
- var transaction = Transactions[transactionId];
- try
- {
- transaction.Commit();
- }
- catch
- {
- Transactions.Remove(transactionId);
- transaction.Dispose();
- throw;
- }
- return true;
+ transaction.Rollback();
}
-
- public object Execute(params object[] parameters)
+ finally
{
- var sql = Convert.ToString(parameters[0]);
- var variables = parameters[1] as IDictionary