Current section

Files

Jump to
ecto_watch lib ecto_watch.ex
Raw

lib/ecto_watch.ex

defmodule EctoWatch do
@moduledoc """
A library to allow you to easily get notifications about database changes directly from PostgreSQL.
"""
alias EctoWatch.Helpers
alias EctoWatch.WatcherServer
alias EctoWatch.WatcherTriggerValidator
use Supervisor
def start_link(opts) do
case EctoWatch.Options.validate(opts) do
{:ok, validated_opts} ->
options = EctoWatch.Options.new(validated_opts)
validate_watcher_uniqueness(options.watchers)
Supervisor.start_link(__MODULE__, options, name: __MODULE__)
{:error, errors} ->
raise ArgumentError, "Invalid options: #{Exception.message(errors)}"
end
end
def init(options) do
# TODO:
# Allow passing in options specific to Postgrex.Notifications.start_link/1
# https://hexdocs.pm/postgrex/Postgrex.Notifications.html#start_link/1
postgrex_notifications_options =
options.repo_mod.config()
|> Keyword.put(:name, :ecto_watch_postgrex_notifications)
children = [
{Postgrex.Notifications, postgrex_notifications_options},
{EctoWatch.WatcherSupervisor, options},
{WatcherTriggerValidator, nil}
]
Supervisor.init(children, strategy: :rest_for_one)
end
@since "0.8.0"
@deprecated "subscribe/3 was removed in version 0.8.0. See the updated documentation"
def subscribe(schema_mod_or_label, update_type, id) when is_atom(schema_mod_or_label) do
if Helpers.ecto_schema_mod?(schema_mod_or_label) do
raise ArgumentError,
"""
This way of subscribing was removed in version 0.8.0. Instead call:
subscribe({#{inspect(schema_mod_or_label)}, #{inspect(update_type)}}, #{id})
IMPORTANT NOTE: The messages that you receive have also changed!
Before:
# labels:
def handle_info({:updated, User, %{id: id}}, socket) do
# schemas:
def handle_info({:user_contact_info_updated, :updated, %{id: id}}, socket) do
Now:
# labels:
def handle_info({{User, :updated}, %{id: id}}, socket) do
# schemas:
def handle_info({:user_contact_info_updated, %{id: id}}, socket) do
PLEASE NOTE the flipped order of `User` and `:updated` in the tuple!
See the updated documentation for subscribing."
"""
else
raise ArgumentError,
"""
This way of subscribing was removed in version 0.8.0. Instead call:
subscribe(#{inspect(schema_mod_or_label)}, #{id})
Before:
# labels:
def handle_info({:updated, User, %{id: id}}, socket) do
# schemas:
def handle_info({:user_contact_info_updated, :updated, %{id: id}}, socket) do
Now:
# labels:
def handle_info({{User, :updated}, %{id: id}}, socket) do
# schemas:
def handle_info({:user_contact_info_updated, %{id: id}}, socket) do
PLEASE NOTE the flipped order of `User` and `:updated` in the tuple!
See the updated documentation for subscribing."
"""
end
end
@type watcher_identifier() :: {atom(), atom()} | atom()
@doc """
Subscribe to notifications from watchers.
Examples:
iex> EctoWatch.subscribe({Comment, :updated})
When subscribing to a watcher with the `label` option specified as `:comment_updated_custom`:
iex> EctoWatch.subscribe(:comment_updated_custom)
You can subscribe to notifications just from specific primary key values:
iex> EctoWatch.subscribe({Comment, :updated}, user_id)
Or you can subscribe to notifications just from a specific foreign column (**the column must be in the watcher's `extra_columns` list):
iex> EctoWatch.subscribe({Comment, :updated}, {:post_id, post_id})
"""
@spec subscribe(watcher_identifier(), term()) :: :ok | {:error, term()}
def subscribe(watcher_identifier, id \\ nil) do
if is_atom(watcher_identifier) && id in ~w[inserted updated deleted]a do
if Helpers.ecto_schema_mod?(watcher_identifier) do
raise ArgumentError,
"""
This way of subscribing was removed in version 0.8.0. Instead call:
subscribe({#{inspect(watcher_identifier)}, #{inspect(id)}})
Before:
# labels:
def handle_info({:updated, User, %{id: id}}, socket) do
# schemas:
def handle_info({:user_contact_info_updated, :updated, %{id: id}}, socket) do
Now:
# labels:
def handle_info({{User, :updated}, %{id: id}}, socket) do
# schemas:
def handle_info({:user_contact_info_updated, %{id: id}}, socket) do
PLEASE NOTE the flipped order of `User` and `:updated` in the tuple!
See the updated documentation for subscribing."
"""
else
raise ArgumentError,
"""
This way of subscribing was removed in version 0.8.0. Instead call:
subscribe(#{inspect(watcher_identifier)})
Before:
# labels:
def handle_info({:updated, User, %{id: id}}, socket) do
# schemas:
def handle_info({:user_contact_info_updated, :updated, %{id: id}}, socket) do
Now:
# labels:
def handle_info({{User, :updated}, %{id: id}}, socket) do
# schemas:
def handle_info({:user_contact_info_updated, %{id: id}}, socket) do
PLEASE NOTE the flipped order of `User` and `:updated` in the tuple!
See the updated documentation for subscribing."
"""
end
end
validate_ecto_watch_running!()
with :ok <- validate_identifier(watcher_identifier),
{:ok, {pub_sub_mod, channel_name, debug?}} <-
WatcherServer.pub_sub_subscription_details(watcher_identifier, id) do
if(debug?, do: debug_log(watcher_identifier, "Subscribing to watcher"))
Phoenix.PubSub.subscribe(pub_sub_mod, channel_name)
else
{:error, error} ->
raise ArgumentError, error
end
end
@doc """
Returns details about a watcher for reflection purposes
For example if you need to know what the function/triggers are in the database.
Examples:
iex> EctoWatch.subscribe({Comment, :updated})
Or for a label:
iex> EctoWatch.subscribe(:comment_updated_custom)
"""
@spec details(watcher_identifier()) :: %{
repo_mod: module(),
schema_definition: %{
schema_prefix: binary(),
table_name: binary(),
primary_key: binary(),
columns: [atom()],
association_columns: [atom()],
label: term()
},
function_name: binary(),
trigger_name: binary(),
notify_channel: binary()
}
def details(watcher_identifier) do
WatcherServer.details(watcher_identifier)
end
defp validate_identifier({schema_mod, update_type})
when is_atom(schema_mod) and is_atom(update_type) do
cond do
!EctoWatch.Helpers.ecto_schema_mod?(schema_mod) ->
raise ArgumentError,
"Expected atom to be an Ecto schema module. Got: #{inspect(schema_mod)}"
update_type not in ~w[inserted updated deleted]a ->
raise ArgumentError,
"Unexpected update_type: #{inspect(update_type)}. Expected :inserted, :updated, or :deleted"
true ->
:ok
end
end
defp validate_identifier(label) when is_atom(label) do
:ok
end
defp validate_identifier(other) do
raise ArgumentError,
"Invalid subscription (expected either `{schema_module, :inserted | :updated | :deleted}` or a label): #{inspect(other)}"
end
defp validate_ecto_watch_running! do
if !Process.whereis(__MODULE__) do
raise "EctoWatch is not running. Please start it by adding it to your supervision tree or using EctoWatch.start_link/1"
end
end
defp validate_watcher_uniqueness(watcher_options) do
{without_labels, with_labels} = Enum.split_with(watcher_options, &(&1.label == nil))
duplicate_labels =
with_labels
|> Enum.map(& &1.label)
|> duplicate_values()
duplicate_schema_and_update_types =
without_labels
|> Enum.map(&{&1.schema_definition.label, &1.update_type})
|> duplicate_values()
error_messages =
[
if length(duplicate_labels) > 0 do
"""
The following labels are duplicated across watchers: #{Enum.join(duplicate_labels, ", ")}
"""
end,
if length(duplicate_schema_and_update_types) > 0 do
"""
The following schema and update type combinations are duplicated across watchers:
#{Enum.map_join(duplicate_schema_and_update_types, "\n\n ", &inspect/1)}
"""
end
]
|> Enum.reject(&is_nil/1)
if length(error_messages) > 0 do
raise ArgumentError, Enum.join(error_messages, "\n")
end
end
defp duplicate_values(values) do
values
|> Enum.group_by(&Function.identity/1)
|> Enum.filter(fn {_, values} -> length(values) >= 2 end)
|> Enum.map(fn {_, [value | _]} -> value end)
end
defp debug_log(watcher_identifier, message) do
EctoWatch.Helpers.debug_log(watcher_identifier, message)
end
end