Skip to main content

lib/ex_sql/codegen/cache.ex

defmodule ExSQL.Codegen.Cache do
  @moduledoc """
  Shape-keyed cache + compile gate for `ExSQL.Codegen`.

  Maps a query *shape* (the param-lifted IR — same shape for any literal values)
  to either a sighting count or a compiled module. A shape is only compiled once
  it has been seen `threshold` times (default 2): one-shot ad-hoc queries never
  pay the compile cost, while repeated/prepared queries amortize it. The compiled
  modules are global (a `Module.create` side effect), so the cache lives in a
  `:public` ETS table owned by this process — like `ExSQL.Registry`.
  """

  use GenServer

  @table :exsql_codegen_cache
  @default_threshold 2

  def start_link(opts \\ []), do: GenServer.start_link(__MODULE__, :ok, opts)

  @impl true
  def init(:ok) do
    :ets.new(@table, [:named_table, :public, :set, read_concurrency: true])
    {:ok, %{}}
  end

  @doc """
  Records a sighting of `shape_key` and decides what to do:

    * `{:hit, module}` — already compiled, run it.
    * `{:compile, n}` — seen enough times; the caller should compile + `store/2`.
    * `:too_few` — not seen enough yet; fall back to the tree walker this time.
  """
  @spec fetch(term()) :: {:hit, module()} | {:compile, pos_integer()} | :too_few
  def fetch(shape_key) do
    case :ets.lookup(@table, shape_key) do
      [{_, {:module, module}}] ->
        {:hit, module}

      [{_, count}] when is_integer(count) ->
        n = :ets.update_counter(@table, shape_key, 1)
        if n >= threshold(), do: {:compile, n}, else: :too_few

      [] ->
        :ets.insert(@table, {shape_key, 1})
        if threshold() <= 1, do: {:compile, 1}, else: :too_few
    end
  rescue
    ArgumentError -> :too_few
  end

  @doc "Stores the compiled module for a shape (subsequent `fetch/1` returns `{:hit, module}`)."
  @spec store(term(), module()) :: :ok
  def store(shape_key, module) do
    :ets.insert(@table, {shape_key, {:module, module}})
    :ok
  rescue
    ArgumentError -> :ok
  end

  defp threshold do
    case System.get_env("EXSQL_CODEGEN_THRESHOLD") do
      nil -> @default_threshold
      str -> String.to_integer(str)
    end
  end
end