Current section
Files
Jump to
Current section
Files
lib/dataloader.ex
defmodule Dataloader do
@moduledoc """
# Dataloader
Dataloader provides an easy way efficiently load data in batches. It's
inspired by https://github.com/facebook/dataloader, although it makes some
small API changes to better suit Elixir use cases.
Central to Dataloader is the idea of a source. A single Dataloader struct can
have many different sources, which represent different ways to load data.
Here's an example of a data loader using an ecto source, and then loading some
organization data.
```elixir
source = Dataloader.Ecto.new(MyApp.Repo)
# setup the loader
loader = Dataloader.new |> Dataloader.add_source(:db, source)
# load some things
loader =
loader
|> Dataloader.load(:db, Organization, 1)
|> Dataloader.load_many(:db, Organization, [4, 9])
# actually retrieve them
loader = Dataloader.run(loader)
# Now we can get whatever values out we want
organizations = Dataloader.get_many(loader, :db, Organization, [1,4])
```
This will do a single SQL query to get all organizations by ids 1,4, and 9.
You can load multiple batches from multiple sources, and then when `run/1` is
called batch will be loaded concurrently.
Here we named the source `:db` within our dataloader. More commonly though if
you're using Phoenix you'll want to name it after one of your contexts, and
have a different source used for each context. This provides an easy way to
enforce data access rules within each context. See the `DataLoader.Ecto`
moduledocs for more details
"""
defstruct sources: %{},
options: []
require Logger
alias Dataloader.Source
@type t :: %__MODULE__{
sources: %{source_name => Dataloader.Source.t()},
options: [option]
}
@type option :: {:timeout, pos_integer}
@type source_name :: any
@spec new([option]) :: t
def new(opts \\ []), do: %__MODULE__{options: opts}
@spec add_source(t, source_name, Dataloader.Source.t()) :: t
def add_source(%{sources: sources} = loader, name, source) do
sources = Map.put(sources, name, source)
%{loader | sources: sources}
end
@spec load_many(t, source_name, any, [any]) :: t | no_return()
def load_many(loader, source_name, batch_key, vals) when is_list(vals) do
source =
loader
|> get_source(source_name)
|> do_load(batch_key, vals)
put_in(loader.sources[source_name], source)
end
@spec load(t, source_name, any, any) :: t | no_return()
def load(loader, source_name, batch_key, val) do
load_many(loader, source_name, batch_key, [val])
end
defp do_load(source, batch_key, vals) do
Enum.reduce(vals, source, &Source.load(&2, batch_key, &1))
end
@spec run(t) :: t | no_return
def run(dataloader) do
if pending_batches?(dataloader) do
fun = fn {name, source} -> {name, Source.run(source)} end
sources =
dataloader.sources
|> pmap(
fun,
tag: "Source",
timeout: dataloader.options[:timeout] || 15_000
)
|> Map.new()
%{dataloader | sources: sources}
else
dataloader
end
end
@spec get(t, source_name, any, any) :: any | no_return()
def get(loader, source, batch_key, item_key) do
loader
|> get_source(source)
|> Source.fetch(batch_key, item_key)
|> do_get
end
defp do_get({:ok, val}), do: val
defp do_get(:error), do: nil
@spec get_many(t, source_name, any, any) :: [any] | no_return()
def get_many(loader, source, batch_key, item_keys) when is_list(item_keys) do
source = get_source(loader, source)
for key <- item_keys do
source
|> Source.fetch(batch_key, key)
|> do_get
end
end
def put(loader, source_name, batch_key, item_key, result) do
source =
loader
|> get_source(source_name)
|> Source.put(batch_key, item_key, result)
put_in(loader.sources[source_name], source)
end
@spec pending_batches?(t) :: boolean
def pending_batches?(loader) do
Enum.any?(loader.sources, fn {_name, source} -> Source.pending_batches?(source) end)
end
defp get_source(loader, source_name) do
loader.sources[source_name] || raise "Source does not exist: #{inspect(source_name)}"
end
@doc false
def pmap(items, fun, opts) do
options = [
timeout: opts[:timeout] || 15_000,
on_timeout: :kill_task
]
# This supervisor exists to help ensure that the spawned tasks will die as
# promptly as possible if the current process is killed.
{:ok, task_super} = Task.Supervisor.start_link([])
# The intermediary task is spawned here so that the `:trap_exit` flag does
# not lead to rogue behaviour within the current process. This could happen
# if the current process is linked to something, and then that something
# does in the middle of us loading stuff.
task =
Task.async(fn ->
# The purpose of `:trap_exit` here is so that we can ensure that any failures
# within the tasks do not kill the current process. We want to get results
# back no matter what.
Process.flag(:trap_exit, true)
task_super
|> Task.Supervisor.async_stream(items, fun, options)
|> Enum.reduce(%{}, fn
{:ok, {key, value}}, results ->
Map.put(results, key, value)
_, results ->
results
end)
end)
# The infinity is safe here because the internal
# tasks all have their own timeout.
Task.await(task, :infinity)
end
end