lib/ecto_dbg.ex

defmodule EctoDbg do
  @moduledoc """
  This module exposes a macro that you can inject into your Repo module so
  that you can pretty print SQL queries with the parameters espaced into the
  query. Once you `use` this module in your Repo module, you'll be able to
  call the injected functions with your query and you can see the formatted
  SQL that Ecto generates.

  > #### Warning {: .warning}
  >
  > This library makes use of [pgFormatter](https://github.com/darold/pgFormatter)
  > (which is a Perl script) in order to format your SQL queries. This is meant to
  > be a tool that is used during development and should probably not be shipped
  > with your production application. It is recommended that you add this library
  > as only a dev/test dependency.
  """

  require Logger

  alias Ecto.Adapters.SQL
  alias Ecto.DevLogger.PrintableParameter

  @doc """
  By using this macro in your Repo module, you will get 6 additional functions added
  to your repo module. This functions are:

  * `all_and_log/1` and `all_and_log/2`
  * `one_and_log/1` and `one_and_log/2`
  * `update_all_and_log/1` and `update_all_and_log/2`
  * `delete_all_and_log/1` and `delete_all_and_log/2`
  * `log_all_query/1`
  * `log_one_query/1`
  * `log_update_all_query/1`
  * `log_delete_all_query/1`

  The `*_and_log` functions will output the query and will also execute it with the
  corresponding Repo function (`all`, `update_all`, and `delete_all`). The one and two
  arity versions of these functions align with the Repo functions so that these functions
  can be used as direct drop in replacements when you need to see what query is actually
  being executed by Ecto. The `log_*_query` functions will simply log out the generated
  query without executing them.

  This macro currently supports the following options that can be used to configured
  how it behaves:

  * `:level` - Specifies the log level for when the SQL query is logged (default: `:debug`).

  * `:logger_function` - Specifies the module+function that will be invoked when logging an SQL query.
    The provided module+function must have an arity of 4 and accept the following
    parameters: `repo, action, formatted_sql, opts` where opts are the options that
    were passed to the `use EctoDbg` macro. The default value is: `{EctoDbg, :default_logger}`.
  """
  defmacro __using__(opts \\ []) do
    default_opts = [
      level: :debug,
      logger_function: {EctoDbg, :default_logger},
      only: [:test, :dev]
    ]

    dbg_opts =
      default_opts
      |> Keyword.merge(opts)
      |> Keyword.update!(:only, fn
        envs when is_list(envs) -> envs
        env when is_atom(env) -> [env]
      end)

    if Mix.env() in dbg_opts[:only] do
      quote do
        @doc """
        Run the `Repo.all` function and also log the raw SQL query.
        """
        def all_and_log(query, query_opts \\ []) do
          EctoDbg.run_and_log_query(__MODULE__, :all, unquote(dbg_opts), query, query_opts)
        end

        @doc """
        Run the `Repo.one` function and also log the raw SQL query.
        """
        def one_and_log(query, query_opts \\ []) do
          EctoDbg.run_and_log_query(__MODULE__, :one, unquote(dbg_opts), query, query_opts)
        end

        @doc """
        Run the `Repo.update_all` function and also log the raw SQL query.
        """
        def update_all_and_log(query, query_opts \\ []) do
          EctoDbg.run_and_log_query(__MODULE__, :update_all, unquote(dbg_opts), query, query_opts)
        end

        @doc """
        Run the `Repo.delete_all` function and also log the raw SQL query.
        """
        def delete_all_and_log(query, query_opts \\ []) do
          EctoDbg.run_and_log_query(__MODULE__, :delete_all, unquote(dbg_opts), query, query_opts)
        end

        @doc """
        Log the raw SQL query that would be passed to the `Repo.all` function.
        """
        # The following functions only log the query and do not run it
        def log_all_query(query) do
          EctoDbg.log_query(__MODULE__, :all, unquote(dbg_opts), query)
        end

        @doc """
        Log the raw SQL query that would be passed to the `Repo.one` function.
        """
        # The following functions only log the query and do not run it
        def log_one_query(query) do
          EctoDbg.log_query(__MODULE__, :one, unquote(dbg_opts), query)
        end

        @doc """
        Log the raw SQL query that would be passed to the `Repo.update_all` function.
        """
        def log_update_all_query(query) do
          EctoDbg.log_query(__MODULE__, :update_all, unquote(dbg_opts), query)
        end

        @doc """
        Log the raw SQL query that would be passed to the `Repo.delete_all` function.
        """
        def log_delete_all_query(query) do
          EctoDbg.log_query(__MODULE__, :delete_all, unquote(dbg_opts), query)
        end
      end
    end
  end

  @doc false
  def log_query(repo, action, dbg_opts, query) do
    normalized_action = if action == :one, do: :all, else: action
    {binary_query, params} = SQL.to_sql(normalized_action, repo, query)

    # Generate a formatted query
    formatted_sql =
      binary_query
      |> inline_params(params, repo.__adapter__())
      |> format_sql()
      |> format_log_message()

    # Log the query
    {logger_module, logger_function} = Keyword.fetch!(dbg_opts, :logger_function)
    apply(logger_module, logger_function, [repo, action, formatted_sql, dbg_opts])

    :ok
  end

  @doc false
  def run_and_log_query(repo, action, dbg_opts, query, query_opts \\ []) do
    # Log the Repo query
    :ok = log_query(repo, action, dbg_opts, query)

    # Run the intended Repo function
    apply(repo, action, [query, query_opts])
  end

  @doc false
  def format_log_message(formatted_sql) do
    split_query = String.split(formatted_sql, "\n")

    max_line =
      split_query
      |> Enum.max_by(fn line ->
        String.length(line)
      end)
      |> String.length()

    trimmed_query =
      split_query
      |> Enum.reduce([], fn line, acc ->
        trimmed_line = String.trim(line)

        if String.length(trimmed_line) == 0 do
          acc
        else
          [line | acc]
        end
      end)
      |> Enum.reverse()
      |> Enum.join("\n")

    """
    #{String.duplicate("=", max_line)}
    #{trimmed_query}
    #{String.duplicate("=", max_line)}
    """
  end

  @doc false
  def inline_params(query, [], _repo_adapter) do
    query
  end

  def inline_params(query, params, repo_adapter)
      when repo_adapter in [Ecto.Adapters.Postgres, Ecto.Adapters.Tds] do
    params_by_index =
      params
      |> Enum.with_index(1)
      |> Map.new(fn {value, index} -> {index, value} end)

    placeholder_with_number_regex = placeholder_with_number_regex(repo_adapter)

    query
    |> String.replace(placeholder_with_number_regex, fn
      <<_prefix::utf8, index::binary>> = replacement ->
        case Map.fetch(params_by_index, String.to_integer(index)) do
          {:ok, value} ->
            PrintableParameter.to_expression(value)

          :error ->
            replacement
        end
    end)
  end

  def inline_params(query, params, Ecto.Adapters.SQLite3) do
    params_by_index =
      params
      |> Enum.with_index()
      |> Map.new(fn {value, index} -> {index, value} end)

    query
    |> String.split("?")
    |> Enum.map_reduce(0, fn elem, index ->
      formatted_value =
        case Map.fetch(params_by_index, index) do
          {:ok, value} ->
            PrintableParameter.to_expression(value)

          :error ->
            []
        end

      {[elem, formatted_value], index + 1}
    end)
    |> elem(0)
    |> List.flatten()
    |> Enum.join()
  end

  def inline_params(query, params, Ecto.Adapters.MyXQL) do
    params_by_index =
      params
      |> Enum.with_index()
      |> Map.new(fn {value, index} -> {index, value} end)

    query
    |> String.split("?")
    |> Enum.map_reduce(0, fn elem, index ->
      formatted_value =
        case Map.fetch(params_by_index, index) do
          {:ok, value} ->
            PrintableParameter.to_expression(value)

          :error ->
            []
        end

      {[elem, formatted_value], index + 1}
    end)
    |> elem(0)
  end

  @doc false
  def format_sql(raw_sql) do
    {:ok, pid, os_pid} =
      :ecto_dbg
      |> :code.priv_dir()
      |> Path.join("/pg_format")
      |> :exec.run([
        :stdin,
        :stdout,
        :stderr,
        :monitor
      ])

    :exec.send(pid, raw_sql)
    :exec.send(pid, :eof)

    # Initial state for reduce
    initial_reduce_results = %{
      stdout: "",
      stderr: []
    }

    result =
      [nil]
      |> Stream.cycle()
      |> Enum.reduce_while(initial_reduce_results, fn _, acc ->
        receive do
          {:DOWN, ^os_pid, _, ^pid, {:exit_status, exit_status}} when exit_status != 0 ->
            error = "pg_format exited with status code #{inspect(exit_status)}"
            existing_errors = Map.get(acc, :stderr, [])
            {:halt, Map.put(acc, :stderr, [error | existing_errors])}

          {:DOWN, ^os_pid, _, ^pid, _} ->
            {:halt, acc}

          {:stderr, ^os_pid, error} ->
            error = String.trim(error)
            existing_errors = Map.get(acc, :stderr, [])
            {:cont, Map.put(acc, :stderr, [error | existing_errors])}

          {:stdout, ^os_pid, compiled_template_fragment} ->
            aggregated_template = Map.get(acc, :stdout, "")
            {:cont, Map.put(acc, :stdout, aggregated_template <> compiled_template_fragment)}
        after
          10_000 ->
            :exec.kill(os_pid, :sigterm)
            error = "pg_format timed out after 10 second(s)"
            existing_errors = Map.get(acc, :stderr, [])
            {:halt, Map.put(acc, :stderr, [error | existing_errors])}
        end
      end)

    case result do
      %{stderr: [], stdout: formatted_sql} ->
        formatted_sql

      %{stderr: errors} ->
        {:error, Enum.join(errors, "\n")}
    end
  end

  defp placeholder_with_number_regex(Ecto.Adapters.Postgres), do: ~r/\$\d+/
  defp placeholder_with_number_regex(Ecto.Adapters.Tds), do: ~r/@\d+/

  @doc false
  def default_logger(repo, action, formatted_sql, opts) do
    level = Keyword.fetch!(opts, :level)

    Logger.log(
      level,
      """
      Logging raw SQL query for #{inspect(repo)}.#{Atom.to_string(action)}
      #{formatted_sql}
      """
    )
  end
end