lib/ecto/adapters/tds.ex

defmodule Ecto.Adapters.Tds do
  @moduledoc """
  Adapter module for MSSQL Server using the TDS protocol.

  ## Options

  Tds options split in different categories described
  below. All options can be given via the repository
  configuration.

  ### Connection options

    * `:hostname` - Server hostname
    * `:port` - Server port (default: 1433)
    * `:username` - Username
    * `:password` - User password
    * `:database` - the database to connect to
    * `:pool` - The connection pool module, may be set to `Ecto.Adapters.SQL.Sandbox`
    * `:ssl` - Set to true if ssl should be used (default: false)
    * `:ssl_opts` - A list of ssl options, see Erlang's `ssl` docs
    * `:show_sensitive_data_on_connection_error` - show connection data and
      configuration whenever there is an error attempting to connect to the
      database

  We also recommend developers to consult the `Tds.start_link/1` documentation
  for a complete list of all supported options for driver.

  ### Storage options

    * `:collation` - the database collation. Used during database creation but
      it is ignored later

  If you need collation other than Latin1, add `tds_encoding` as dependency to
  your project `mix.exs` file then amend `config/config.ex` by adding:

      config :tds, :text_encoder, Tds.Encoding

  This should give you extended set of most encoding. For complete list check
  `Tds.Encoding` [documentation](https://hexdocs.pm/tds_encoding).

  ### After connect flags

  After connecting to MSSQL server, TDS will check if there are any flags set in
  connection options that should affect connection session behaviour. All flags are
  MSSQL standard *SET* options. The following flags are currently supported:

    * `:set_language` - sets session language (consult stored procedure output
       `exec sp_helplanguage` for valid values)
    * `:set_datefirst` - number in range 1..7
    * `:set_dateformat` - atom, one of `:mdy | :dmy | :ymd | :ydm | :myd | :dym`
    * `:set_deadlock_priority` - atom, one of `:low | :high | :normal | -10..10`
    * `:set_lock_timeout` - number in milliseconds > 0
    * `:set_remote_proc_transactions` - atom, one of `:on | :off`
    * `:set_implicit_transactions` - atom, one of `:on | :off`
    * `:set_allow_snapshot_isolation` - atom, one of `:on | :off`
       (required if `Repo.transaction(fn -> ... end, isolation_level: :snapshot)` is used)
    * `:set_read_committed_snapshot` - atom, one of `:on | :off`

  ## Limitations

  ### UUIDs

  MSSQL server has slightly different binary storage format for UUIDs (`uniqueidentifier`).
  If you use `:binary_id`, the proper choice is made. Otherwise you must use the `Tds.Ecto.UUID`
  type. Avoid using `Ecto.UUID` since it may cause unpredictable application behaviour.

  ### SQL `Char`, `VarChar` and `Text` types

  When working with binaries and strings,there are some limitations you should be aware of:

    - Strings that should be stored in mentioned sql types must be encoded to column
      codepage (defined in collation). If collation is different than database collation,
      it is not possible to store correct value into database since the connection
      respects the database collation. Ecto does not provide way to override parameter
      codepage.

    - If you need other than Latin1 or other than your database default collation, as
      mentioned in "Storage Options" section, then manually encode strings using
      `Tds.Encoding.encode/2` into desired codepage and then tag parameter as `:binary`.
      Please be aware that queries that use this approach in where clauses can be 10x slower
      due increased logical reads in database.

    - You can't store VarChar codepoints encoded in one collation/codepage to column that
      is encoded in different collation/codepage. You will always get wrong result. This is
      not adapter or driver limitation but rather how string encoding works for single byte
      encoded strings in MSSQL server. Don't be confused if you are always seeing latin1 chars,
      they are simply in each codepoint table.

  In particular, if a field has the type `:text`, only raw binaries will be allowed.
  To avoid above limitations always use `:string` (NVarChar) type for text if possible.
  If you really need to use VarChar's column type, you can use the `Tds.Ecto.VarChar`
  Ecto type.

  ### JSON support

  Even though the adapter will convert `:map` fields into JSON back and forth,
  actual value is stored in NVarChar column.

  ### Query hints and table hints

  MSSQL supports both query hints and table hints: https://docs.microsoft.com/en-us/sql/t-sql/queries/hints-transact-sql-query

  For Ecto compatibility, the query hints must be given via the `lock` option, and they
  will be translated to MSSQL's "OPTION". If you need to pass multiple options, you
  can separate them by comma:

      from query, lock: "HASH GROUP, FAST 10"

  Table hints are specified as a list alongside a `from` or `join`:

      from query, hints: ["INDEX (IX_Employee_ManagerID)"]

  The `:migration_lock` will be treated as a table hint and defaults to "UPDLOCK".

  ### Multi Repo calls in transactions

  To avoid deadlocks in your app, we exposed `:isolation_level`  repo transaction option.
  This will tell to SQL Server Transaction Manager how to begin transaction.
  By default, if this option is omitted, isolation level is set to `:read_committed`.

  Any attempt to manually set the transaction isolation via queries, such as

      Ecto.Adapter.SQL.query("SET TRANSACTION ISOLATION LEVEL XYZ")

  will fail once explicit transaction is started using `c:Ecto.Repo.transaction/2`
  and reset back to :read_committed.

  There is `Ecto.Query.lock/3` function can help by setting it to `WITH(NOLOCK)`.
  This should allow you to do eventually consistent reads and avoid locks on given
  table if you don't need to write to database.

  NOTE: after explicit transaction ends (commit or rollback) implicit transactions
  will run as READ_COMMITTED.
  """

  use Ecto.Adapters.SQL,
    driver: :tds

  require Logger
  require Ecto.Query

  @behaviour Ecto.Adapter.Storage

  @doc false
  def autogenerate(:binary_id), do: Tds.Ecto.UUID.bingenerate()
  def autogenerate(:embed_id), do: Tds.Ecto.UUID.generate()
  def autogenerate(type), do: super(type)

  @doc false
  @impl true
  def loaders({:map, _}, type), do: [&json_decode/1, &Ecto.Type.embedded_load(type, &1, :json)]
  def loaders(:map, type), do: [&json_decode/1, type]
  def loaders(:boolean, type), do: [&bool_decode/1, type]
  def loaders(:binary_id, type), do: [Tds.Ecto.UUID, type]
  def loaders(_, type), do: [type]

  @impl true
  def dumpers({:map, _}, type), do: [&Ecto.Type.embedded_dump(type, &1, :json)]
  def dumpers(:binary_id, type), do: [type, Tds.Ecto.UUID]
  def dumpers(_, type), do: [type]

  defp bool_decode(<<0>>), do: {:ok, false}
  defp bool_decode(<<1>>), do: {:ok, true}
  defp bool_decode(0), do: {:ok, false}
  defp bool_decode(1), do: {:ok, true}
  defp bool_decode(x), do: {:ok, x}

  defp json_decode(x) when is_binary(x), do: {:ok, Tds.json_library().decode!(x)}
  defp json_decode(x), do: {:ok, x}

  # Storage API
  @doc false
  @impl true
  def storage_up(opts) do
    database = Keyword.fetch!(opts, :database)

    command =
      ~s(CREATE DATABASE [#{database}])
      |> concat_if(opts[:collation], &"COLLATE=#{&1}")

    case run_query(Keyword.put(opts, :database, "master"), command) do
      {:ok, _} ->
        :ok

      {:error, %{mssql: %{number: 1801}}} ->
        {:error, :already_up}

      {:error, error} ->
        {:error, Exception.message(error)}
    end
  end

  defp concat_if(content, nil, _fun), do: content
  defp concat_if(content, value, fun), do: content <> " " <> fun.(value)

  @doc false
  @impl true
  def storage_down(opts) do
    database = Keyword.fetch!(opts, :database)

    case run_query(Keyword.put(opts, :database, "master"), "DROP DATABASE [#{database}]") do
      {:ok, _} ->
        :ok

      {:error, %{mssql: %{number: 3701}}} ->
        {:error, :already_down}

      {:error, error} ->
        {:error, Exception.message(error)}
    end
  end

  @impl Ecto.Adapter.Storage
  def storage_status(opts) do
    database = Keyword.fetch!(opts, :database)

    opts = Keyword.put(opts, :database, "master")

    check_database_query =
      "SELECT [name] FROM [master].[sys].[databases] WHERE [name] = '#{database}'"

    case run_query(opts, check_database_query) do
      {:ok, %{num_rows: 0}} -> :down
      {:ok, %{num_rows: _}} -> :up
      other -> {:error, other}
    end
  end

  defp run_query(opts, sql_command) do
    {:ok, _} = Application.ensure_all_started(:ecto_sql)
    {:ok, _} = Application.ensure_all_started(:tds)

    timeout = Keyword.get(opts, :timeout, 15_000)

    opts =
      opts
      |> Keyword.drop([:name, :log, :pool, :pool_size])
      |> Keyword.put(:backoff_type, :stop)
      |> Keyword.put(:max_restarts, 0)

    {:ok, pid} = Task.Supervisor.start_link()

    task =
      Task.Supervisor.async_nolink(pid, fn ->
        {:ok, conn} = Tds.start_link(opts)
        value = Ecto.Adapters.Tds.Connection.execute(conn, sql_command, [], opts)
        GenServer.stop(conn)
        value
      end)

    case Task.yield(task, timeout) || Task.shutdown(task) do
      {:ok, {:ok, result}} ->
        {:ok, result}

      {:ok, {:error, error}} ->
        {:error, error}

      {:exit, {%{__struct__: struct} = error, _}}
      when struct in [Tds.Error, DBConnection.Error] ->
        {:error, error}

      {:exit, reason} ->
        {:error, RuntimeError.exception(Exception.format_exit(reason))}

      nil ->
        {:error, RuntimeError.exception("command timed out")}
    end
  end

  @impl true
  def supports_ddl_transaction? do
    true
  end

  @impl true
  def lock_for_migrations(meta, opts, fun) do
    %{opts: adapter_opts, repo: repo} = meta

    if Keyword.fetch(adapter_opts, :pool_size) == {:ok, 1} do
      Ecto.Adapters.SQL.raise_migration_pool_size_error()
    end

    opts = Keyword.merge(opts, timeout: :infinity, telemetry_options: [schema_migration: true])

    {:ok, result} =
      transaction(meta, opts, fn ->
        query =
          "exec sp_getapplock @Resource = 'ecto_#{inspect(repo)}', @LockMode = 'Exclusive', @LockOwner = 'Transaction', @LockTimeout = -1"

        Ecto.Adapters.SQL.query!(meta, query, [], opts)
        fun.()
      end)

    result
  end
end