Packages

Ecto Observable brings observable functionality to an Ecto Repo.

Current section

Files

Jump to
ecto_observable lib ecto observable observable.ex
Raw

lib/ecto/observable/observable.ex

defmodule Ecto.Observable do
@moduledoc """
Defines observable functionality for an `Ecto.Repo`.
Observable functionality is defined as the ability to hook into the lifecyle
of a struct to perform some kind of work based on the repo action performed.
Lets start of with an simple example. Lets say we have a `Post` schema.
Each post can have many topics. Users can subscribe to a given topic. Whenever
a post is created for a given topic, we are responsible for informing the
subscribed users.
Given the above, lets setup our new "observable" repo.
defmodule Repo do
use Ecto.Repo, otp_app: :my_app
use Ecto.Observable
@observers [SubscribersObserver]
def init(_args, config) do
init_observable(@observers)
{:ok, config}
end
end
We have defined our repo as normal - but with a few additions. First, we must
`use Ecto.Observable` to bring in the required observable functionality. Second,
from within our `c:Ecto.Repo.init/2` callback, we must invoke the `c:init_observable/1`
function to setup our repo with the observers we want.
Lets create our new observer now.
defmodule SubscribersObserver do
use Ecto.Observer
def observations do
[{Post, :insert}]
end
# Lets ignore posts that dont have any topics.
def handle_insert(%Post{topics: []}) do
:ok
end
def handle_insert(%Post{topics: topics}) do
# Do work required to inform subscribed users.
end
end
Now that we are starting to use "observable" behaviour, we must modify the way
in which we insert posts with our repo.
def create_post(params \\ %{}) do
%Post{}
|> Post.changeset(params)
|> Repo.insert_and_notify()
end
Instead of the normal `Ecto.Repo.insert/2` function being called, we instead
use `c:insert_and_notify/2`. This performs the exact same action as `Ecto.Repo.insert/2`
(and returns the same results). The only change is that upon successful insertion
to the database, our observers have their callbacks invoked.
Lets say we want to let our users know when a posts topic changes to to something
they have subscribed to. We must modify our oberver for this functionality.
def observations do
[
{Post, :insert},
{Post, :update}
]
end
def handle_update(%Post{topics: old_topics}, %Post{topics: new_topics})
when old_topics != new_topics do
# Get any additional topics and inform subscribed users.
end
# Define a "catch all"
def handle_update(_old, _new) do
:ok
end
Given the above, we can now notify users during updates.
def update_post(post, params \\ %{}) do
post
|> Post.changeset(params)
|> Repo.update_and_notify()
end
All of the functionality above can be carried over with a `{Post, :delete}`
observation and the `c:delete_and_notify/2` function being invoked.
### Dynamic Observers
We can dymnaically add, remove, and inspect observers of our repo if required.
Please see the docs for `add_observer`, `delete_observer` and `get_observer`
for further details.
### Function Observers
Rather than defining module-based observers, we can also use function-based ones.
Lets add some more oberservers to our repo `Post` schema.
Repo.add_obeserver(fn action, [struct] -> :ok end, Post, :insert)
Repo.add_obeserver(fn action, [old, new] -> :ok end, Post, :update)
Now, whenever a `Post` is inserted or updated, our functions above will be
invoked.
"""
@type action :: :insert | :update | :delete
@type observation :: Ecto.Observer.t() | {Ecto.Observer.t(), module, action}
@type observations :: [observation]
@doc """
Returns the observers ETS table name tied to the repository.
"""
@callback __observers__ :: atom
@doc """
A function that is required to be invoked within the `c:Ecto.Repo.init/2` callback.
`Ecto.Observable` will not be able to function for your repo without this
invoked.
This will perform two actions:
1. Sets up the ETS table used to store the observers of the repo.
2. Adds the observers passed to the function so they will be ready when
the repo is started.
## Example
defmodule MyRepo do
use Ecto.Repo, otp_app: :my_app
use Ecto.Observable
def init(_arg, config) do
init_observable([MyObserver])
{:ok, config}
end
end
"""
@callback init_observable(observations) :: atom
@doc """
Inserts a struct defined via `Ecto.Schema` or a changeset and informs observers.
Upon success, the newly insterted struct is passed to any `Ecto.Observable`
that was assigned to observe the `:insert` action for the structs schema.
This will return whatever response that `c:Ecto.Repo.insert/2` returns. Please
see its documentation for further details.
"""
@callback insert_and_notify(
struct_or_changeset :: Ecto.Schema.t() | Ecto.Changeset.t(),
opts :: Keyword.t()
) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()}
@doc """
Same as `c:insert_and_notify/2` but returns the struct or raises if the changeset is invalid.
"""
@callback insert_and_notify!(
struct_or_changeset :: Ecto.Schema.t() | Ecto.Changeset.t(),
opts :: Keyword.t()
) :: Ecto.Schema.t() | no_return
@doc """
Updates a changeset using its primary key and informs observers.
Upon success, the old struct and updated struct are passed to any `Ecto.Observable`
that was assigned to observe the `:update` action for the structs schema.
This will return whatever response that `c:Ecto.Repo.update/2` returns. Please
see its documentation for further details.
"""
@callback update_and_notify(changeset :: Ecto.Changeset.t(), opts :: Keyword.t()) ::
{:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()}
@doc """
Same as `c:update_and_notify/2` but returns the struct or raises if the changeset is invalid.
"""
@callback update_and_notify!(changeset :: Ecto.Changeset.t(), opts :: Keyword.t()) ::
{:ok, Ecto.Schema.t()} | no_return
@doc """
Deletes a struct using its primary key and informs observers.
Upon success, the deleted struct is passed to any `Ecto.Observable`
that was assigned to observe the `:delete` action for the structs schema.
This will return whatever response that `c:Ecto.Repo.delete/2` returns. Please
see its documentation for further details.
"""
@callback delete_and_notify(
struct_or_changeset :: Ecto.Schema.t() | Ecto.Changeset.t(),
opts :: Keyword.t()
) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()}
@doc """
Same as `c:delete_and_notify/2` but returns the struct or raises if the changeset is invalid.
"""
@callback delete_and_notify!(
struct_or_changeset :: Ecto.Schema.t() | Ecto.Changeset.t(),
opts :: Keyword.t()
) :: {:ok, Ecto.Schema.t()} | no_return
@doc """
Adds a new observer to the repo.
This will use the callback defined in `c:Ecto.Observer.observations/0` to get
all the observations defined within the observer module.
Please see the docs for `Ecto.Observer` for more details on what an observer
should look like.
## Example
MyRepo.add_observer(Myobserver)
"""
@callback add_observer(observer :: Ecto.Observer.t()) :: [boolean]
@doc """
Adds a new observer to the repo for the given struct and action.
An action is simply an atom representation of the database operation being
performed. This currently includes: `:insert`, `:update` or `:delete`.
An observer is any `Ecto.Observer`. Observers can either be module or function
based.
Please see the docs for `Ecto.Observer` for more details on what an observer
should look like.
## Example
MyRepo.add_observer(Myobserver, Post, :insert)
MyRepo.add_observer(fn action, [struct] -> :ok end, Post, :insert)
"""
@callback add_observer(observer :: Ecto.Observer.t(), module, action) :: boolean
@doc """
Adds multiple observations to the repo.
An action is simply an atom representation of the database operation being
performed. This currently includes: `:insert`, `:update` or `:delete`.
An observer is any `Ecto.Observer`. Observers can either be module or function
based.
Please see the docs for `Ecto.Observer` for more details on what an observer
should look like.
## Example
MyRepo.add_observer(Myobserver, Post, :insert)
MyRepo.add_observer(fn action, [struct] -> :ok end, Post, :insert)
"""
@callback add_observers(observations) :: [boolean | [boolean]]
@doc """
Gets all the observers of the repo for the given schema.
"""
@callback get_observers(module) :: [{action, Ecto.Observer.t()}]
@doc """
Gets all the observers of the repo for the given schema and action.
"""
@callback get_observers(module, action) :: [Ecto.Observer.t()]
@doc """
Deletes all observers of the repo.
"""
@callback delete_observers() :: :ok
@doc """
Deletes all observers of the repo for the given schema.
"""
@callback delete_observers(module) :: [{action, boolean}]
@doc """
Deletes all observers of the repo for the given schema and action.
"""
@callback delete_observers(module, action) :: boolean
@doc """
Deletes a particular observers schema and action for the repo.
"""
@callback delete_observer(observer :: Ecto.Observer.t(), module, action) :: boolean
defmacro __using__(_opts) do
quote do
@behaviour Ecto.Observable
@observers :"#{__MODULE__}.Observers"
def __observers__ do
@observers
end
def init_observable(observers \\ []) do
Ecto.Observable.init_observable(__MODULE__, observers)
end
def insert_and_notify(struct, opts \\ []) do
with {:ok, struct} <- insert(struct, opts) do
Ecto.Observable.notify_observers(__MODULE__, :insert, [struct])
{:ok, struct}
end
end
def insert_and_notify!(struct, opts \\ []) do
struct = insert!(struct, opts)
Ecto.Observable.notify_observers(__MODULE__, :insert, [struct])
struct
end
def update_and_notify(struct, opts \\ []) do
with {:ok, new_struct} <- update(struct, opts) do
Ecto.Observable.notify_observers(__MODULE__, :update, [struct.data, new_struct])
{:ok, new_struct}
end
end
def update_and_notify!(struct, opts \\ []) do
new_struct = update!(struct, opts)
Ecto.Observable.notify_observers(__MODULE__, :update, [struct.data, new_struct])
new_struct
end
def delete_and_notify(struct, opts \\ []) do
with {:ok, struct} <- delete(struct, opts) do
Ecto.Observable.notify_observers(__MODULE__, :delete, [struct])
{:ok, struct}
end
end
def delete_and_notify!(struct, opts \\ []) do
struct = delete!(struct, opts)
Ecto.Observable.notify_observers(__MODULE__, :delete, [struct])
struct
end
def add_observers(observers) do
Ecto.Observable.add_observers(__MODULE__, observers)
end
def add_observer(observer) do
Ecto.Observable.add_observer(__MODULE__, observer)
end
def add_observer(observer, schema, action) do
Ecto.Observable.add_observer(__MODULE__, observer, schema, action)
end
def get_observers(schema) do
Ecto.Observable.get_observers(__MODULE__, schema)
end
def get_observers(schema, action) do
Ecto.Observable.get_observers(__MODULE__, schema, action)
end
def delete_observers do
Ecto.Observable.delete_observers(__MODULE__)
end
def delete_observers(schema) do
Ecto.Observable.delete_observers(__MODULE__, schema)
end
def delete_observers(schema, action) do
Ecto.Observable.delete_observers(__MODULE__, schema, action)
end
def delete_observer(observer, schema, action) do
Ecto.Observable.delete_observer(__MODULE__, observer, schema, action)
end
end
end
@doc false
def init_observable(name, observers \\ []) do
table = name.__observers__
case :ets.info(table) do
:undefined -> :ets.new(table, [:public, :named_table, {:read_concurrency, true}])
_ -> table
end
add_observers(name, observers)
:ok
end
@doc """
Passes the provided structs to any observers that have subscribed to the given
action and schema for the repo.
Typically, the function does not need to be manually invoked. Please see
`c:insert_and_notify/2`, `c:update_and_notify/2` or `c:delete_and_notify/2` for
further details.
## Example
MyRepo.add_observer(Myobserver, Post, :insert)
MyRepo.add_observer(Myobserver, Post, :update)
Ecto.Observable.notify_observers(MyRepo, :insert, [%Post{}])
Ecto.Observable.notify_observers(MyRepo, :update, [%Post{}, %Post{}])
"""
def notify_observers(name, action, [struct | _] = structs) do
schema = struct.__struct__
for observer <- get_observers(name, schema, action) do
notify_observer(observer, action, structs)
end
end
@doc false
def notify_observer(observer, action, structs) when is_function(observer) do
apply(observer, [action, structs])
end
@doc false
def notify_observer(observer, action, structs) when is_atom(observer) do
apply(observer, :"handle_#{action}", structs)
end
@doc false
def add_observers(name, observers) do
for observer <- observers do
case observer do
{observer, schema, action} -> add_observer(name, observer, schema, action)
observer when is_atom(observer) -> add_observer(name, observer)
end
end
end
@doc false
def add_observer(name, observer) do
observations = observer.observations
for {schema, action} <- observations do
add_observer(name, observer, schema, action)
end
end
@doc false
def add_observer(name, observer, schema, action) do
table = name.__observers__
old_observers = get_observers(name, schema, action)
new_observers = [observer | old_observers]
:ets.insert(table, {{schema, action}, new_observers})
end
@doc false
def get_observers(name, schema) do
Enum.reduce([:delete, :update, :insert], [], fn action, results ->
observers = get_observers(name, schema, action)
[{action, observers} | results]
end)
end
@doc false
def get_observers(name, schema, action) do
table = name.__observers__
case :ets.lookup(table, {schema, action}) do
[{_, observers}] -> observers
_ -> []
end
end
@doc false
def delete_observers(name) do
table = name.__observers__
table
|> :ets.tab2list()
|> Enum.each(fn {{schema, action}, _} ->
delete_observers(name, schema, action)
end)
end
@doc false
def delete_observers(name, schema) do
for action <- [:delete, :update, :insert] do
{action, delete_observers(name, schema, action)}
end
end
@doc false
def delete_observers(name, schema, action) do
table = name.__observers__
:ets.insert(table, {{schema, action}, []})
end
@doc false
def delete_observer(name, observer, schema, action) do
table = name.__observers__
old_observers = get_observers(name, schema, action)
new_observers = List.delete(old_observers, observer)
:ets.insert(table, {{schema, action}, new_observers})
end
end