Skip to main content

lib/arex/query.ex

defmodule Arex.Query do
  @moduledoc """
  Read-oriented query helpers.

  `Arex.Query` is the lowest-friction way to execute read statements without
  dropping all the way to `Arex.Http`. It keeps option resolution, normalized
  error handling, and paging behavior aligned with the rest of the library.

  Use `sql/3` for ordinary ArcadeDB SQL queries and `run/3` when you want the
  query language to come from call options or application config.

  `Arex.Query` is statement-oriented. It does not infer record
  types or append tenant and scope predicates automatically. If you want
  type-aware or boundary-aware CRUD semantics, prefer `Arex.Record`.
  """

  alias Arex.Error
  alias Arex.Http

  @doc """
  Executes a raw query using the resolved query language.

  `language` comes from call options, application config, or the default
  `"sql"`. This helper is useful when you want `Arex` to honor the resolved
  language instead of forcing SQL.
  """
  def run(statement, params \\ %{}, opts \\ []) do
    Http.query_raw(statement, params, opts)
  end

  @doc """
  Executes a SQL query.

  This helper always forces `language: "sql"` regardless of any configured
  default language.
  """
  def sql(statement, params \\ %{}, opts \\ []) do
    run(statement, params, put_language(opts, "sql"))
  end

  @doc """
  Returns the first row from a paged query or `nil` when the query is empty.

  Internally this delegates to `page/3` with `limit: 1`.
  """
  def first(statement, params \\ %{}, opts \\ []) do
    case page(statement, params, Keyword.merge([limit: 1, offset: 0], opts)) do
      {:ok, %{entries: [row | _]}} -> {:ok, row}
      {:ok, %{entries: []}} -> {:ok, nil}
      {:error, error} -> {:error, error}
    end
  end

  @doc """
  Returns exactly one row or `nil`.

  When the query returns more than one row, the function fails with a normalized
  `:multiple_results` error.
  """
  def one(statement, params \\ %{}, opts \\ []) do
    case page(statement, params, Keyword.merge([limit: 2, offset: 0], opts)) do
      {:ok, %{entries: []}} ->
        {:ok, nil}

      {:ok, %{entries: [row]}} ->
        {:ok, row}

      {:ok, %{entries: [_first, _second | _]}} ->
        {:error,
         Error.multiple_results(
           "expected one row but query returned more than one",
           %{method: :post, path: "/api/v1/query/:db"}
         )}

      {:error, error} ->
        {:error, error}
    end
  end

  @doc """
  Returns one page of rows using ArcadeDB `skip` and `limit` semantics.

  The returned map contains `:entries`, `:limit`, `:offset`, `:count`, and
  `:has_more?`. Arex fetches one extra row internally so it can answer the
  `has_more?` question without requiring a separate count query.
  """
  def page(statement, params \\ %{}, opts \\ []) do
    {limit, opts} = Keyword.pop(opts, :limit, 100)
    {offset, opts} = Keyword.pop(opts, :offset, 0)

    cond do
      not (is_integer(limit) and limit > 0) ->
        {:error, Error.bad_opts("limit must be a positive integer", %{method: nil, path: nil})}

      not (is_integer(offset) and offset >= 0) ->
        {:error,
         Error.bad_opts("offset must be a non-negative integer", %{method: nil, path: nil})}

      true ->
        paged_statement = statement <> " skip :__arex_offset limit :__arex_limit"

        paged_params =
          params
          |> Map.new()
          |> Map.put("__arex_limit", limit + 1)
          |> Map.put("__arex_offset", offset)

        with {:ok, rows} <- run(paged_statement, paged_params, opts) do
          entries = Enum.take(rows, limit)

          {:ok,
           %{
             entries: entries,
             limit: limit,
             offset: offset,
             has_more?: length(rows) > limit,
             count: length(entries)
           }}
        end
    end
  end

  @doc """
  Streams query pages until there are no more rows.

  The stream yields `{:ok, page_map}` tuples and stops at the first error,
  which is yielded as `{:error, error_map}`.
  """
  def stream_pages(statement, params \\ %{}, opts \\ []) do
    {limit, opts} = Keyword.pop(opts, :limit, 100)
    {offset, opts} = Keyword.pop(opts, :offset, 0)

    if not (is_integer(limit) and limit > 0 and is_integer(offset) and offset >= 0) do
      {:error,
       Error.bad_opts(
         "limit must be positive and offset must be non-negative",
         %{method: nil, path: nil}
       )}
    else
      {:ok,
       Stream.resource(
         fn -> offset end,
         fn current_offset ->
           case page(statement, params, Keyword.merge(opts, limit: limit, offset: current_offset)) do
             {:ok, %{entries: []}} ->
               {:halt, current_offset}

             {:ok, page_map} ->
               next_offset = current_offset + limit

               if page_map.has_more? do
                 {[{:ok, page_map}], next_offset}
               else
                 {[{:ok, page_map}], :halt}
               end

             {:error, error} ->
               {[{:error, error}], :halt}
           end
         end,
         fn _state -> :ok end
       )}
    end
  end

  defp put_language(opts, language) when is_list(opts), do: Keyword.put(opts, :language, language)
  defp put_language(%{} = opts, language), do: Map.put(opts, :language, language)
end