defmodule Needle.Tables do
@moduledoc """
A Global cache of Tables to be queried by their (Pointer) IDs, table
names or Ecto Schema module names.
Use of the Table Service requires:
1. You have run the migrations shipped with this library.
2. You have started `Needle.Tables` before querying.
3. All OTP applications with pointable Ecto Schemata to be added to the schema path.
4. OTP 21.2 or greater, though we recommend using the most recent release available.
While this module is a GenServer, it is only responsible for setup
of the cache and then exits with :ignore having done so. It is not
recommended to restart the service as this will lead to a stop the
world garbage collection of all processes and the copying of the
entire cache to each process that has queried it since its last
local garbage collection.
"""
alias Needle.{NotFound, Table, ULID}
require Logger
use GenServer, restart: :transient
@typedoc """
A query is either a table's (database) name or (Pointer) ID as a
binary or the name of its Ecto Schema Module as an atom.
"""
@type query :: binary | atom
@spec start_link(ignored :: term) :: GenServer.on_start()
@doc "Populates the global cache with table data via introspection."
def start_link(_), do: GenServer.start_link(__MODULE__, [])
def data(), do: :persistent_term.get(__MODULE__)
@spec table(query :: query) :: {:ok, Table.t()} | {:error, NotFound.t()}
@doc "Get a Table identified by name, id or module."
def table(query) when is_binary(query) or is_atom(query) do
case Map.get(data(), query) do
nil -> {:error, NotFound.new(query)}
other -> {:ok, other}
end
end
@spec table!(query) :: Table.t()
@doc "Look up a Table by name or id, raise NotFound if not found."
def table!(query), do: Map.get(data(), query) || not_found(query)
@spec id(query) :: {:ok, integer()} | {:error, NotFound.t()}
@doc "Look up a table id by id, name or schema."
def id(query), do: with({:ok, val} <- table(query), do: {:ok, val.id})
@spec id!(query) :: integer()
@doc "Look up a table id by id, name or schema, raise NotFound if not found."
def id!(query) when is_atom(query) or is_binary(query), do: id!(query, data())
@spec ids!([binary | atom]) :: [binary]
@doc "Look up many ids at once, raise NotFound if any of them are not found"
def ids!(ids) do
data = data()
Enum.map(ids, &id!(&1, data))
end
# called by id!/1, ids!/1
defp id!(query, data), do: Map.get(data, query).id || not_found(query)
@spec schema(query) :: {:ok, atom} | {:error, NotFound.t()}
@doc "Look up a schema module by id, name or schema"
def schema(query), do: with({:ok, val} <- table(query), do: {:ok, val.schema})
@spec schema!(query) :: atom
@doc "Look up a schema module by id, name or schema, raise NotFound if not found"
def schema!(query), do: table!(query).schema
# GenServer callback
@doc false
def init(_) do
if Code.ensure_loaded?(:telemetry),
do: :telemetry.span([:needle, :tables], %{}, &init/0),
else: init()
:ignore
end
defp init() do
indexed = build_index()
:persistent_term.put(__MODULE__, indexed)
Logger.info("An index of Needle.Tables has been built")
{indexed, indexed}
end
defp search_modules() do
search_path()
|> Enum.flat_map(&app_modules/1)
end
def schema_modules() do
search_modules()
|> Enum.filter(&schema?/1)
end
def mixin_modules() do
search_modules()
|> Enum.filter(&in_roles?(&1, [:mixin]))
end
defp build_index() do
search_modules()
|> Enum.filter(&in_roles?(&1, [:pointable, :virtual]))
|> Enum.reduce(%{}, &index/2)
end
defp app_modules(app), do: app_modules(app, Application.spec(app, :modules))
defp app_modules(_, nil), do: []
defp app_modules(_, mods), do: mods
# called by init/1
defp search_path(),
do: [:needle | search_path_config()]
defp search_path_config do
case Application.get_env(:needle, :search_path_fun) do
{mod, fun} -> apply(mod, fun, [])
_ -> Application.fetch_env!(:needle, :search_path)
end
end
def schema?(module) do
Code.ensure_loaded?(module) and
function_exported?(module, :__schema__, 1)
end
# called by init/1
defp in_roles?(module, roles) do
schema?(module) and
function_exported?(module, :__pointers__, 1) and
module.__pointers__(:role) in roles
end
# called by init/1
defp index(mod, acc), do: index(mod, acc, mod.__schema__(:primary_key))
# called by index/2
defp index(mod, acc, [:id]), do: index(mod, acc, mod.__schema__(:type, :id))
# called by index/3, the line above
defp index(mod, acc, ULID),
do: index(mod, acc, mod.__pointers__(:table_id), mod.__schema__(:source))
# doesn't look right, skip it
defp index(_, acc, _wat), do: acc
# called by index/3
defp index(mod, acc, id, table) do
t = %Table{id: id, schema: mod, table: table}
log_indexed(t)
Map.merge(acc, %{id => t, table => t, mod => t})
end
defp log_indexed(table) do
if Code.ensure_loaded?(:telemetry),
do: :telemetry.execute([:needle, :tables, :indexed], %{}, %{table: table})
end
defp not_found(table) do
Logger.error("Needle Table `#{table}` not found")
raise(NotFound)
end
end