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; - return ExecuteStatement(sql, variables); + Transactions.Remove(transactionId); + transaction.Dispose(); } + return true; + } - public object ExecuteInTransaction(params object[] parameters) + public bool CommitTransaction(int transactionId) + { + var transaction = Transactions[transactionId]; + try { - var sql = Convert.ToString(parameters[0]); - var variables = parameters[1] as IDictionary; - var transactionId = Convert.ToInt32(parameters[2]); - var transaction = Transactions[transactionId]; - return ExecuteStatement(sql, variables, transaction); + transaction.Commit(); } - - public object ExecutePreparedStatement(params object[] parameters) + finally { - var sql = Convert.ToString(parameters[0]); - var variables = parameters[1] as IDictionary; - var statementId = Convert.ToInt32(parameters[2]); - var command = PreparedStatements[statementId]; - return ExecuteStatement(sql, variables, command: command); + Transactions.Remove(transactionId); + transaction.Dispose(); } + return true; + } + + public List> Execute(string sql, IDictionary variables) + { + return ExecuteStatement(sql, variables); + } + + public List> ExecuteInTransaction(string sql, IDictionary variables, int transactionId) + { + var transaction = Transactions[transactionId]; + return ExecuteStatement(sql, variables, transaction); + } + + public List> ExecutePreparedStatement(string sql, IDictionary variables, int statementId) + { + var command = PreparedStatements[statementId]; + return ExecuteStatement(sql, variables, command: command); + } + + public List> ExecutePreparedStatementInTransaction(string sql, IDictionary variables, int transactionId, int statementId) + { + var transaction = Transactions[transactionId]; + var command = PreparedStatements[statementId]; + return ExecuteStatement(sql, variables, transaction, command); + } + + public bool ClosePreparedStatement(int statementId) + { + var command = PreparedStatements[statementId]; + PreparedStatements.Remove(statementId); + command.Dispose(); + return true; + } - public object ExecutePreparedStatementInTransaction(params object[] parameters) + public int PrepareStatement(string sql) + { + var command = Connection.CreateCommand(); + try { - var sql = Convert.ToString(parameters[0]); - var variables = parameters[1] as IDictionary; - var transactionId = Convert.ToInt32(parameters[2]); - var statementId = Convert.ToInt32(parameters[3]); - var transaction = Transactions[transactionId]; - var command = PreparedStatements[statementId]; - return ExecuteStatement(sql, variables, transaction, command); + command.CommandText = sql; + command.Prepare(); } - - public object ClosePreparedStatement(params object[] parameters) + catch { - var statementId = Convert.ToInt32(parameters[0]); - var command = PreparedStatements[statementId]; - PreparedStatements.Remove(statementId); command.Dispose(); - return true; + throw; } + var statementId = System.Threading.Interlocked.Increment(ref _nextId); + PreparedStatements.Add(statementId, command); + return statementId; + } - public object PrepareStatement(params object[] parameters) + private List> ExecuteStatement(string sql, IDictionary variables, IDbTransaction transaction = null, IDbCommand command = null) + { + var results = new List>(); + var disposeCommand = command == null; + if (disposeCommand) { - var sql = Convert.ToString(parameters[0]); - var command = Connection.CreateCommand(); - try - { - command.CommandText = sql; - command.Prepare(); - } - catch - { - command.Dispose(); - throw; - } - var statementId = command.GetHashCode(); - PreparedStatements.Add(statementId, command); - return statementId; + command = Connection.CreateCommand(); + command.CommandText = sql; + } + else + { + command.Parameters.Clear(); } - private object ExecuteStatement(string sql, IDictionary variables, IDbTransaction transaction = null, IDbCommand command = null) + try { - var results = new List>(); - var disposeCommand = false; - if (command == null) - { - command = Connection.CreateCommand(); - command.CommandText = sql; - disposeCommand = true; - } - else - { - command.Parameters.Clear(); - } + if (transaction != null) + command.Transaction = transaction; - try + if (variables != null) { - if (transaction != null) + foreach (var pair in variables) { - command.Transaction = transaction; + var key = pair.Key.ToString(); + if (!IsValidParameterName(key)) + throw new ArgumentException($"Invalid parameter name: {key}"); + command.Parameters.Add(new SqlParameter("@" + key, NormalizeValue(pair.Value))); } + } - if (variables != null) - { - foreach (var pair in variables) - { - var parameter = new SqlParameter("@" + pair.Key.ToString(), pair.Value); - command.Parameters.Add(parameter); - } - } - var reader = command.ExecuteReader(); - var hasResults = true; - while (hasResults) + using (var reader = command.ExecuteReader()) + { + do { while (reader.Read()) { - results.Add(Enumerable.Range(0, reader.FieldCount) - .ToDictionary(i => - { - var name = reader.GetName(i); - if (string.IsNullOrEmpty(name)) - { - name = i.ToString(); - } - return name; - }, i => reader.IsDBNull(i) ? null : reader.GetValue(i))); + var row = new Dictionary(reader.FieldCount); + for (int i = 0; i < reader.FieldCount; i++) + { + var name = reader.GetName(i); + if (string.IsNullOrEmpty(name)) + name = i.ToString(); + row[name] = reader.IsDBNull(i) ? null : reader.GetValue(i); + } + results.Add(row); } - hasResults = reader.NextResult(); - } - reader.Close(); + } while (reader.NextResult()); } - catch - { - if (disposeCommand) - { - command.Dispose(); - } - throw; - } - return results; } + finally + { + if (disposeCommand) + command.Dispose(); + } + + return results; + } + + // MessagePack encodes integers using the minimum byte width, so Elixir + // integers arrive as byte/ushort/uint/ulong depending on magnitude. + // SQL Server rejects all unsigned integer types, so we promote them to + // their signed equivalents before building SqlParameter. + private static object NormalizeValue(object value) => value switch + { + null => DBNull.Value, + byte b => (int)b, + ushort us => (int)us, + uint ui => (long)ui, + ulong ul => (long)ul, + _ => value + }; + + private static bool IsValidParameterName(string name) + { + if (string.IsNullOrEmpty(name)) return false; + foreach (var c in name) + { + if (!char.IsLetterOrDigit(c) && c != '_') + return false; + } + return true; } -} +} \ No newline at end of file diff --git a/lib/ex_sql_client/protocol.ex b/lib/ex_sql_client/protocol.ex index e566ed1..ae1734d 100644 --- a/lib/ex_sql_client/protocol.ex +++ b/lib/ex_sql_client/protocol.ex @@ -7,14 +7,20 @@ defmodule ExSqlClient.Protocol do alias Netler.Client - defstruct client: nil, checked_out: false, status: :idle, transaction_id: nil + defstruct client: nil, status: :idle, transaction_id: nil @impl true def connect(opts) do connection_string = Keyword.get(opts, :connection_string) {:ok, client} = Client.start_link(:dotnet_sql_client) - {:ok, true} = Client.invoke(client, "Connect", [connection_string]) - {:ok, %__MODULE__{client: client}} + + case Client.invoke(client, "Connect", [connection_string]) do + {:ok, true} -> + {:ok, %__MODULE__{client: client}} + + {:error, reason} -> + {:error, reason} + end end @impl true @@ -59,6 +65,9 @@ defmodule ExSqlClient.Protocol do end end + @impl true + def handle_close(_query, _opts, state), do: {:ok, nil, state} + @impl true def handle_execute( query = %{statement_id: statement_id}, @@ -145,38 +154,23 @@ defmodule ExSqlClient.Protocol do end @impl true - def checkout(state) do - state = %{state | checked_out: true} - {:ok, state} - end - - @impl true - def checkin(_state) do - Logger.error("checkin not implemented") - :not_implemented - end + def checkout(state), do: {:ok, state} @impl true - def handle_deallocate(_query, _cursor, _opts, _state) do - Logger.error("handle_deallocate not implemented") - :not_implemented - end + def handle_status(_opts, state), do: {state.status, state} @impl true - def handle_declare(_query, _params, _opts, _state) do - Logger.error("handle_declare not implemented") - :not_implemented + def handle_declare(_query, _params, _opts, state) do + {:error, %DBConnection.ConnectionError{message: "cursors not supported"}, state} end @impl true - def handle_fetch(_query, _cursor, _opts, _state) do - Logger.error("handle_fetch not implemented") - :not_implemented + def handle_fetch(_query, _cursor, _opts, state) do + {:error, %DBConnection.ConnectionError{message: "cursors not supported"}, state} end @impl true - def handle_status(_opts, _state) do - Logger.error("handle_status not implemented") - :not_implemented + def handle_deallocate(_query, _cursor, _opts, state) do + {:error, %DBConnection.ConnectionError{message: "cursors not supported"}, state} end end diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..5c9dffe --- /dev/null +++ b/mise.toml @@ -0,0 +1,4 @@ +[tools] +erlang = "28" +elixir = "1.19.5-otp-28" +dotnet = "8" diff --git a/mix.exs b/mix.exs index 754f949..9d4b1eb 100644 --- a/mix.exs +++ b/mix.exs @@ -6,7 +6,7 @@ defmodule ExSqlClient.MixProject do app: :ex_sql_client, name: "ExSqlClient", source_url: "https://github.com/svan-jansson/ex_sql_client", - version: "0.1.1", + version: "0.0.0-dev", elixir: "~> 1.9", start_permanent: Mix.env() == :prod, deps: deps(), @@ -30,10 +30,12 @@ defmodule ExSqlClient.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ - {:netler, "~> 0.3"}, - {:db_connection, "~> 2.2"}, - {:ex_doc, "~> 0.21", only: :dev, runtime: false}, - {:credo, "~> 1.1.0", only: [:dev, :test], runtime: false} + {:netler, "~> 0.4"}, + {:db_connection, "~> 2.5"}, + {:ex_doc, "~> 0.34", only: :dev, runtime: false}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:testcontainers, "~> 1.0", only: [:dev, :test]}, + {:benchee, "~> 1.3", only: :dev, runtime: false} ] end diff --git a/mix.lock b/mix.lock index ff6ffda..be0439e 100644 --- a/mix.lock +++ b/mix.lock @@ -1,14 +1,34 @@ %{ - "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, + "benchee": {:hex, :benchee, "1.5.0", "4d812c31d54b0ec0167e91278e7de3f596324a78a096fd3d0bea68bb0c513b10", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.1", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "5b075393aea81b8ae74eadd1c28b1d87e8a63696c649d8293db7c4df3eb67535"}, + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"}, - "credo": {:hex, :credo, "1.1.5", "caec7a3cadd2e58609d7ee25b3931b129e739e070539ad1a0cd7efeeb47014f4", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0bbd3222607ccaaac5c0340f7f525c627ae4d7aee6c8c8c108922620c5b6446"}, - "db_connection": {:hex, :db_connection, "2.2.0", "e923e88887cd60f9891fd324ac5e0290954511d090553c415fbf54be4c57ee63", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "bdf196feedfa6b83071e808b2b086fb113f8a1c4c7761f6eff6fe4b96aba0086"}, + "credo": {:hex, :credo, "1.7.16", "a9f1389d13d19c631cb123c77a813dbf16449a2aebf602f590defa08953309d4", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0562af33756b21f248f066a9119e3890722031b6d199f22e3cf95550e4f1579"}, + "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, + "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, - "ex_doc": {:hex, :ex_doc, "0.21.3", "857ec876b35a587c5d9148a2512e952e24c24345552259464b98bfbb883c7b42", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "0db1ee8d1547ab4877c5b5dffc6604ef9454e189928d5ba8967d4a58a801f161"}, - "jason": {:hex, :jason, "1.2.0", "10043418c42d2493d0ee212d3fddd25d7ffe484380afad769a0a38795938e448", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "116747dbe057794c3a3e4e143b7c8390b29f634e16c78a7f59ba75bfa6852e7f"}, - "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a10c6eb62cca416019663129699769f0c2ccf39428b3bb3c0cb38c718a0c186d"}, - "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"}, - "msgpax": {:hex, :msgpax, "2.2.4", "7b3790ef684089076b63c0f08c2f4b079c6311daeb006b69e4ed2bf67518291e", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "b351b6d992d79624a8430a99d21a41b36b1b90edf84326a294e9f4a2de11f089"}, - "netler": {:hex, :netler, "0.3.1", "af43f4f991caea9699b2022f42ca138e7f40ad4e6e6e7e6f3e47a397049ba367", [:mix], [{:msgpax, "~> 2.0", [hex: :msgpax, repo: "hexpm", optional: false]}], "hexpm", "06714bf18fca6b052911eea3bf77c4756a700034a141bb0aef661dd1ab8ebc04"}, - "nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm", "589b5af56f4afca65217a1f3eb3fee7e79b09c40c742fddc1c312b3ac0b3399f"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, + "ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "fs": {:hex, :fs, "11.4.1", "11fb3153bb2e1de851b8263bb5698d526894853c73a525ebeb5e69108b2d25cd", [:rebar3], [], "hexpm", "dd00a61d89eac01d16d3fc51d5b0eb5f0722ef8e3c1a3a547cd086957f3260a9"}, + "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, + "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, + "msgpax": {:hex, :msgpax, "2.4.0", "4647575c87cb0c43b93266438242c21f71f196cafa268f45f91498541148c15d", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "ca933891b0e7075701a17507c61642bf6e0407bb244040d5d0a58597a06369d2"}, + "netler": {:hex, :netler, "0.4.75", "f76ba3655fa27a8180ebca6520e537a0c6090288b1d308027cb0349a86201802", [:mix], [{:msgpax, "~> 2.4", [hex: :msgpax, repo: "hexpm", optional: false]}], "hexpm", "51882d104b908fcec8e524301833cafea02ef4d8ad7ef0cf519c1e1045545ef0"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, + "statistex": {:hex, :statistex, "1.1.0", "7fec1eb2f580a0d2c1a05ed27396a084ab064a40cfc84246dbfb0c72a5c761e5", [:mix], [], "hexpm", "f5950ea26ad43246ba2cce54324ac394a4e7408fdcf98b8e230f503a0cba9cf5"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "tesla": {:hex, :tesla, "1.16.0", "de77d083aea08ebd1982600693ff5d779d68a4bb835d136a0394b08f69714660", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "eb3bdfc0c6c8a23b4e3d86558e812e3577acff1cb4acb6cfe2da1985a1035b89"}, + "testcontainers": {:hex, :testcontainers, "1.14.1", "1fa750712e558927a665946895aaf6c95b93487e671d724567adba70e129e9fb", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.3", [hex: :ecto_sql, repo: "hexpm", optional: true]}, {:fs, "~> 11.4", [hex: :fs, repo: "hexpm", optional: false]}, {:hackney, "~> 1.20", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:tesla, "~> 1.7", [hex: :tesla, repo: "hexpm", optional: false]}, {:uniq, "~> 0.6", [hex: :uniq, repo: "hexpm", optional: false]}], "hexpm", "4cfca95679e50eb96ccc634386c919a0be37b507275bdb5972cb022b6664a072"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, + "uniq": {:hex, :uniq, "0.6.2", "51846518c037134c08bc5b773468007b155e543d53c8b39bafe95b0af487e406", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "95aa2a41ea331ef0a52d8ed12d3e730ef9af9dbc30f40646e6af334fbd7bc0fc"}, } diff --git a/test/data_type_test.exs b/test/data_type_test.exs index 9512d95..09b6bfc 100644 --- a/test/data_type_test.exs +++ b/test/data_type_test.exs @@ -3,9 +3,7 @@ defmodule DataTypeTest do doctest ExSqlClient setup_all do - connection_string = - "Server=localhost; MultipleActiveResultSets=true; User Id=sa; Password=InsecurePassword123" - + connection_string = Application.fetch_env!(:ex_sql_client, :test_connection_string) {:ok, conn} = ExSqlClient.start_link(connection_string: connection_string) {:ok, %{conn: conn}} end diff --git a/test/prepared_statement_test.exs b/test/prepared_statement_test.exs index 9b7f0ec..8c526a2 100644 --- a/test/prepared_statement_test.exs +++ b/test/prepared_statement_test.exs @@ -2,9 +2,7 @@ defmodule PreparedStatementTest do use ExUnit.Case setup_all do - connection_string = - "Server=localhost; MultipleActiveResultSets=true; User Id=sa; Password=InsecurePassword123" - + connection_string = Application.fetch_env!(:ex_sql_client, :test_connection_string) {:ok, conn} = ExSqlClient.start_link(connection_string: connection_string) {:ok, %{conn: conn}} end @@ -17,12 +15,12 @@ defmodule PreparedStatementTest do {:ok, query} = ExSqlClient.prepare(conn, query) {:ok, _query, result} = ExSqlClient.execute(conn, query, %{type: "P"}) - assert Enum.count(result) > 0 + assert !Enum.empty?(result) {:ok, _query, result} = ExSqlClient.execute(conn, query, %{type: "U"}) {:ok, :closed} = ExSqlClient.close(conn, query) - assert Enum.count(result) > 0 + assert !Enum.empty?(result) end @tag :integration @@ -33,8 +31,7 @@ defmodule PreparedStatementTest do {:ok, query} = ExSqlClient.prepare(conn, query) {:ok, _query, result} = ExSqlClient.execute(conn, query, %{type: "P"}) - assert Enum.count(result) > 0 - + assert !Enum.empty?(result) {:ok, :closed} = ExSqlClient.close(conn, query) {atom, _reason} = ExSqlClient.execute(conn, query, %{type: "U"}) diff --git a/test/query_test.exs b/test/query_test.exs index fbedcf6..03d46b8 100644 --- a/test/query_test.exs +++ b/test/query_test.exs @@ -2,9 +2,7 @@ defmodule QueryTest do use ExUnit.Case setup_all do - connection_string = - "Server=localhost; MultipleActiveResultSets=true; User Id=sa; Password=InsecurePassword123" - + connection_string = Application.fetch_env!(:ex_sql_client, :test_connection_string) {:ok, conn} = ExSqlClient.start_link(connection_string: connection_string) {:ok, _} = diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e..44e74d3 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,36 @@ ExUnit.start() + +# Testcontainers defaults to /var/run/docker.sock. Under rootless Podman +# (common on Fedora/RHEL) the live socket is at $XDG_RUNTIME_DIR/podman/podman.sock. +# Auto-point DOCKER_HOST there when the variable is unset and the socket exists. +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() + +mssql_image = + System.get_env("MSSQL_IMAGE", "mcr.microsoft.com/mssql/server:2025-latest") + +{:ok, container} = + Testcontainers.Container.new(mssql_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) + +connection_string = + "Server=localhost,#{port}; MultipleActiveResultSets=true; User Id=sa; Password=InsecurePassword123; TrustServerCertificate=True" + +Application.put_env(:ex_sql_client, :test_connection_string, connection_string) diff --git a/test/transaction_test.exs b/test/transaction_test.exs index 6549bc5..8b4d2ed 100644 --- a/test/transaction_test.exs +++ b/test/transaction_test.exs @@ -2,9 +2,7 @@ defmodule TransactionTest do use ExUnit.Case setup_all do - connection_string = - "Server=localhost; MultipleActiveResultSets=true; User Id=sa; Password=InsecurePassword123" - + connection_string = Application.fetch_env!(:ex_sql_client, :test_connection_string) {:ok, conn} = ExSqlClient.start_link(connection_string: connection_string) {:ok, %{conn: conn}} end