Current section

Files

Jump to
ecto_sync lib ecto_sync.ex
Raw

lib/ecto_sync.ex

defmodule EctoSync do
@moduledoc """
A Cache updater and router for events emitted when database entries are updated. Subscribers can provide a list of records that they want to receive updates on. Additionally, they can provide a function that will act as a means of authorization on the updates they should get.
Using the subscribe function a process can subscribe to all messages for a given struct.
"""
@type subscriptions() :: list({EctoWatch.watcher_identifier(), term()})
@type schema_or_list_of_schemas() :: Ecto.Schema.t() | list(Ecto.Schema.t())
@events ~w/inserted updated deleted/a
@cache_name :ecto_sync
defstruct pub_sub: nil,
repo: nil,
cache_name: nil,
watchers: [],
schemas: nil
use Supervisor
require Logger
alias EctoSync.{Config, PubSub, Subscriber, Syncer, Watcher}
alias Ecto.Association.{BelongsTo, Has, ManyToMany}
import EctoSync.Helpers
@doc """
Starts EctoSync.
## Options
- `:cache_name`, the name of the cache that used to cache changes
- `:repo`, the repo to track changes in.
- `:watchers`, a list of watchers that EctoSync will pass on to EctoWatch.
- `:pub_sub`, the PubSub module to use for sending events, defaults to `:ecto_sync_pub_sub`.
"""
def start_link(opts \\ [name: __MODULE__]) do
state =
%__MODULE__{
cache_name: opts[:cache_name] || @cache_name,
repo: opts[:repo],
pub_sub: opts[:pub_sub] || :ecto_sync_pub_sub,
watchers: opts[:watchers]
}
Supervisor.start_link(__MODULE__, state, name: __MODULE__)
end
@impl true
@doc false
def init(state) do
schemas =
state.watchers
|> Enum.map(fn
{%{table_name: table}, _, _} ->
table
tuple ->
elem(tuple, 0)
end)
|> Enum.uniq()
|> EctoGraph.new()
:persistent_term.put(__MODULE__, %{state | schemas: schemas})
children = [
{Cachex, state.cache_name},
{Phoenix.PubSub, name: state.pub_sub, adapter: PubSub},
{Watcher, [repo: state.repo, pub_sub: state.pub_sub, watchers: state.watchers]},
{Registry, keys: :duplicate, name: EventRegistry}
]
Supervisor.init(children, strategy: :one_for_one)
end
@spec watchers(list(), module(), list()) :: list()
@doc """
Create watcher specifications for a given schema.
* `schema` - The Ecto.Schema to subscribe to.
### Options
- `:extra_columns`, which extra columns should be included
- `:assocs`, a preload like keyword list of associations to subscribe to.
If assocs are specified in the options, the necessary extra_columns will be added
or merged to the `:extra_columns` option.
### Examples
Assuming the same schemas are present as in the Use Cases page.
```elixir
# Generate events for a schema without associations
watchers(MyApp.User)
# => [
# {MyApp.User, :inserted, []},
# {MyApp.User, :updated, []},
# {MyApp.User, :deleted, []}
# ]
# Generate events for all associations
watchers(User, assocs: [posts: [:comments, :tags, :labels]])
# => Includes events for:
# [
# {Label, :deleted, [extra_columns: []]},
# {Label, :inserted, [extra_columns: []]},
# {Label, :updated, [extra_columns: []]},
# {Person, :deleted, [extra_columns: []]},
# {Person, :inserted, [extra_columns: []]},
# {Person, :updated, [extra_columns: []]},
# {Post, :deleted, [extra_columns: [:person_id]]},
# {Post, :deleted, [extra_columns: [:post_id]]},
# {Post, :inserted, [extra_columns: [:person_id]]},
# {Post, :inserted, [extra_columns: [:post_id]]},
# {Post, :updated, [extra_columns: [:person_id]]},
# {Post, :updated, [extra_columns: [:post_id]]},
# {PostsTags, :deleted, [extra_columns: [:tag_id, :post_id]]},
# {PostsTags, :inserted, [extra_columns: [:tag_id, :post_id]]},
# {PostsTags, :updated, [extra_columns: [:tag_id, :post_id]]},
# {Tag, :deleted, [extra_columns: []]},
# {Tag, :inserted, [extra_columns: []]},
# {Tag, :updated, [extra_columns: []]}
# ]
```
"""
def watchers(watchers \\ [], schema)
def watchers(watchers, schema) when is_list(watchers) and is_atom(schema),
do: watchers(watchers, schema, [])
def watchers(schema, opts) when is_atom(schema), do: watchers([], schema, opts)
@doc "See `watchers/2`."
def watchers(watchers, schema, opts) do
unless ecto_schema_mod?(schema) do
raise ArgumentError, "Expected a module alias to an Ecto Schema"
end
do_watchers(watchers, schema, opts)
end
@spec subscriptions(EctoWatch.watcher_identifier(), term()) :: [{pid(), Registry.value()}]
@doc """
Returns a list of pids that are subscribed to the given watcher identifier.
"""
defdelegate subscriptions(watcher_identifier, id \\ nil), to: Subscriber
@doc """
Subscribe to Ecto.Schema(s) provided. The input can be one of following:
- an Ecto.Schema struct,
- a list of Ecto.Schema struct,
- an EctoWatch identifier.
When an Ecto.Schema struct or list of structs is provided, the process is subscribed to all `:updated` and `:deleted` events for the Ecto.Schema that represents the struct.
### Examples
iex> defmodule Test do
...> use Ecto.Schema
...> schema do
...> field :name, :string
...> end
...> end
iex> EctoSync.subscribe(Test)
[{{Test, :inserted}, nil}, {{Test, :updated}, nil}, {{Test, :deleted}, nil}]
iex> EctoSync.subscribe(%Test{id: 1})
[{{Test, :updated}, 1}, {{Test, :deleted}, 1}]
"""
@spec subscribe(schema_or_list_of_schemas() | EctoWatch.watcher_identifier(), list()) ::
list(term())
defdelegate subscribe(values, opts \\ []), to: Subscriber
@doc """
Performs the actual syncing of a given value. Based on the input and the event, certain behaviour can be expected.
## `:inserted` event
|struct|input|output|
|------------|-----|------|
|`%Post{id: 1}`|`[]`|` [%Post{id: 1}]`|
|`%Post{id: 2}`|`[%Post{id: 1}]`|` [%Post{id: 1}, %Post{id: 2}]`|
|`%Comment{id: 1, post_id: 1}`|`[%Post{id: 1, comments: []}]`|`[%Post{id: 1, comments: [%Comment{id: 1}]}]`|
## `:updated` event
|struct|input|output|
|------------|-----|------|
|`%Post{id: 1, name: "Updated name"}`|`%Post{id:1, name: "Name"}`|`%Post{id: 1}, name: "Updated name"}`|
|`%Post{id: 2}`|`[%Post{id: 1}]`|`[%Post{id: 1}, %Post{id: 2}]`|
|`%Comment{id: 1, post_id: 1}`|`[%Post{id: 1, comments: []}]`|` [%Post{id: 1, comments: [%Comment{id: 1}]}]`|
|`%Comment{id: 1, post_id: 2}`|`[%Post{id: 1, comments: [%Comment{id: 1}]}, %Post{id: 2, comments: []}]`|` [%Post{id: 1, comments: []}, %Post{id: 2, comments: [%Comment{id: 1}]}]`|
## `:deleted` event
|struct|input|output|
|------------|-----|------|
|`%Post{id: 1}`|`[]`|`[]`|
|`%Post{id: 1}`|`%Post{id: 1}`|`%Post{id: 1}`|
|`%Post{id: 2}`|`[%Post{id: 1}, %Post{id: 2}]`|`[%Post{id: 1}]`|
|`%Comment{id: 1, post_id: 1}`|` [%Post{id: 1, comments: [%Comment{id: 1}]}]`|`[%Post{id: 1, comments: []}]`|
"""
@type syncable() :: term() | Ecto.Schema.t() | list(Ecto.Schema.t())
@spec sync(syncable(), {{struct(), atom()}, {integer() | String.t(), reference()}}) ::
syncable()
def sync(value, event, opts \\ [])
def sync(value, event, opts)
when is_list(value) or is_struct(value) or is_nil(value) or is_map(value) or
value == :cached do
config = Config.new(event, opts)
Syncer.sync(value, config)
end
def sync(value, _sync_config, _opts), do: value
@doc """
"""
defdelegate subscribed?(value, opts), to: Subscriber
@doc """
Unsubscribe the current process from events. Possible inputs are:
- `Ecto.Schema` struct
- a list of `Ecto.Schema` structs
- watcher_identifier tuple and id
### Examples
iex> EctoSync.unsubscribe(person)
:ok
iex> EctoSync.unsubscribe([person1, person2])
:ok
iex> EctoSync.unsubscribe({Person, :updated}, 1)
:ok
iex> EctoSync.unsubscribe({Person, :inserted}, nil)
:ok
"""
@spec unsubscribe(schema_or_list_of_schemas() | Watcher.watcher_identifier(), term()) ::
list(term())
defdelegate unsubscribe(value, id \\ []), to: Subscriber
defp do_watchers(
watchers,
%ManyToMany{join_through: join_through, related: related, join_keys: join_keys},
opts
)
when is_atom(join_through) do
[{owner_key, _}, {related_key, _}] = join_keys
[{join_through, extra_columns: [owner_key, related_key]}, {related, opts}]
|> Enum.reduce(watchers, fn {schema, opts}, watchers ->
do_watchers(watchers, schema, opts)
end)
end
defp do_watchers(
watchers,
%ManyToMany{
join_through: join_through,
related: related,
join_keys: join_keys
},
opts
)
when is_binary(join_through) do
[{owner_key, _}, {related_key, _}] = join_keys
association_columns = [owner_key, related_key]
@events
|> Enum.reduce(watchers, fn event, watchers ->
label = String.to_atom("#{join_through}_#{event}")
[
{%{
table_name: join_through,
primary_key: :id,
columns: association_columns,
association_columns: association_columns
}, event, extra_columns: association_columns, label: label}
| watchers
]
end)
|> do_watchers(related, opts)
end
defp do_watchers(watchers, %BelongsTo{related: related}, opts) do
do_watchers(watchers, related, opts)
end
defp do_watchers(watchers, %Has{related: related, related_key: related_key}, opts) do
do_watchers(watchers, related, Keyword.put(opts, :extra_columns, [related_key]))
end
defp do_watchers(watchers, nil, _opts), do: watchers
defp do_watchers(watchers, schema, opts) do
{assoc_fields, opts} =
Keyword.pop(opts, :assocs, [])
{columns, opts} = Keyword.pop(opts, :extra_columns, [])
extra_columns = merge_extra_columns(schema, columns, assoc_fields)
opts = Keyword.put(opts, :extra_columns, extra_columns)
watchers =
(watchers ++
Enum.map(@events, fn event ->
label = encode_watcher_identifier({Keyword.get(opts, :label, schema), event})
:persistent_term.put({EctoSync, {schema, event}}, label)
:persistent_term.put({EctoSync, label}, {schema, event})
opts = Keyword.put(opts, :label, label)
{schema, event, opts}
end))
|> Enum.uniq()
Enum.reduce(assoc_fields, watchers, fn
key, watchers when is_tuple(key) or is_atom(key) ->
{key, nested} =
case key do
{_, _} -> key
key -> {key, []}
end
assoc = schema.__schema__(:association, key)
do_watchers(watchers, assoc, Keyword.put([], :assocs, nested))
_, watchers ->
watchers
end)
end
defp merge_extra_columns(schema, columns, assoc_fields) do
Enum.reduce(assoc_fields, columns, fn key, columns ->
key =
case key do
{key, _} -> key
_ -> key
end
assoc_info = schema.__schema__(:association, key)
case assoc_info do
%BelongsTo{owner_key: key} ->
[key | columns]
nil ->
Logger.warning("#{schema} does not have associated key: #{inspect(key)}")
columns
_ ->
columns
end
end)
|> Enum.reverse()
end
end