defmodule ExTeal.Api.ManyToMany do
@moduledoc """
API Responder that manages the many to many relationships via:
- `GET attachable` - Querying the potentially related
- `POST attach` - Adding a related resource
- `DELETE detach` - Removing a related resource
- `GET creation-pivot-fields` - Fetch the pivot fields associated with a relationship
"""
import Ecto.Query, only: [from: 2]
alias Ecto.{Changeset, Multi}
alias ExTeal.Api.ErrorSerializer
alias ExTeal.Field
alias ExTeal.Resource.{Fields, Index, Serializer}
@doc """
Given a request of `GET /api/resource_uri/resource_id/attachable/field_name`
This function returns a response that contains the attachable schemas associated with the many to many relationship at `field_name`
"""
def attachable(conn, resource_uri, resource_id, field_name) do
with {:ok, _resource, _model, field} <- attached(conn, resource_uri, resource_id, field_name),
{:ok, related_resource} <- related_for(field) do
related_resource.model()
|> Index.search(conn.params, related_resource)
|> Index.query_by_related(conn.params, related_resource)
|> related_resource.repo().all()
|> Serializer.render_related(related_resource, conn)
else
{:error, :not_found} = resp -> ErrorSerializer.handle_error(conn, resp)
end
end
@doc """
Given a request of `POST /api/posts/1/attach/tags`
that contains a set of params like:
%{
"viaRelationship" => "tags",
"tags" => "2"
}
This function attaches the tag with id 2 to the posts tags relationship.
"""
def attach(conn, resource_uri, resource_id, field_name) do
with {:ok, resource, model, field} <- attached(conn, resource_uri, resource_id, field_name),
{:ok, _result} <-
attach_via(
field.private_options.rel.join_through,
conn,
resource,
model,
field,
field_name
) do
{:ok, body} = Jason.encode(%{attached: true})
Serializer.as_json(conn, body, 201)
else
resp -> ErrorSerializer.handle_error(conn, resp)
end
end
defp attach_via(joined, conn, resource, model, field, field_name) when is_bitstring(joined) do
with {:ok, related_resource} <- related_for(field),
{:ok, related_id} <- Map.fetch(conn.params, field_name) do
referenced_field = field.private_options.rel.field
model = resource.repo().preload(model, referenced_field)
result = related_resource.handle_show(conn, related_id)
new_content = Map.get(model, referenced_field) ++ [result]
model
|> Changeset.cast(%{}, [])
|> Changeset.put_assoc(field.private_options.rel.field, new_content)
|> resource.repo().update()
else
:error -> {:error, :not_found}
{:error, reason} -> {:error, reason}
end
end
defp attach_via(module, conn, resource, model, field, field_name) do
with {:ok, related_id} <- Map.fetch(conn.params, field_name) do
[{inner, _}, {outer, _}] = field.private_options.rel.join_keys
params =
Map.merge(
conn.params,
Enum.into([{Atom.to_string(inner), model.id}, {Atom.to_string(outer), related_id}], %{})
)
module
|> struct()
|> module.changeset(params)
|> resource.repo().insert()
end
end
@doc """
Given a request of `DELETE /api/posts/1/attach/tags/2`
that contains a set of params like:
This function detaches the tag with id 2 from the posts tags relationship.
"""
def detach(conn, resource_uri, resource_id, field_name) do
with {:ok, resource, model, field} <- attached(conn, resource_uri, resource_id, field_name),
{:ok, related_resource} <- related_for(field) do
referenced_field = field.private_options.rel.field
model = resource.repo().preload(model, referenced_field)
field_id = conn.params |> Map.get("resources")
result = related_resource.handle_show(conn, field_id)
current_ids = model |> Map.get(referenced_field) |> Enum.map(& &1.id)
if Enum.member?(current_ids, result.id) do
new_content =
model
|> Map.get(referenced_field)
|> Enum.reject(fn existing -> existing.id == result.id end)
{:ok, _} =
model
|> Changeset.cast(%{}, [])
|> Changeset.put_assoc(referenced_field, new_content)
|> resource.repo().update()
{:ok, body} = Jason.encode(%{detached: true})
Serializer.as_json(conn, body, 200)
else
ErrorSerializer.handle_error(conn, {:error, :not_found})
end
else
{:error, :not_found} = resp -> ErrorSerializer.handle_error(conn, resp)
end
end
@doc """
Returns a list of fields associated with the pivot table of a many to many
relationship. Fields are only for attaching to a many to many
"""
def creation_pivot_fields(conn, resource_uri, field_name) do
case resource_and_field(resource_uri, field_name) do
{:ok, resource, field} ->
updated_field =
field.type.apply_options_for(field, struct(resource.model()), conn, :create)
pivot_fields =
updated_field.private_options
|> Map.get(:pivot_fields, [])
|> Enum.filter(& &1.show_on_new)
{:ok, body} = Jason.encode(%{fields: pivot_fields})
Serializer.as_json(conn, body, 200)
{:error, :not_found} = resp ->
ErrorSerializer.handle_error(conn, resp)
end
end
@doc """
Returns a list of fields associated with the pivot table of a many to many relationship
for an existing relationship.
"""
def update_pivot_fields(conn, resource_uri, resource_id, field_name, related_id) do
with {:ok, resource, field} <- resource_and_field(resource_uri, field_name),
schema when not is_nil(schema) <- resource.handle_show(conn, resource_id),
pivot <- field.type.apply_options_for(field, schema, conn, :update),
{:ok, related_resource} <- ExTeal.resource_for_model(pivot.private_options.rel.related),
related when not is_nil(related) <- related_resource.handle_show(conn, related_id),
{:ok, pivot_fields} <- Map.fetch(pivot.private_options, :pivot_fields),
{:ok, pivot_data} <- find_pivot_for(pivot, schema, related, resource.repo()) do
fields =
pivot_fields
|> Enum.filter(& &1.show_on_edit)
|> Enum.map(fn field ->
value = field.type.value_for(field, pivot_data, :pivot)
Map.put(field, :value, value)
end)
{:ok, body} = Jason.encode(%{fields: fields})
Serializer.as_json(conn, body, 200)
else
nil ->
ErrorSerializer.handle_error(conn, {:error, :not_found})
:error ->
{:ok, body} = Jason.encode(%{fields: []})
Serializer.as_json(conn, body, 200)
{:error, :not_found} = resp ->
ErrorSerializer.handle_error(conn, resp)
end
end
@doc """
Returns a list of fields associated with the pivot table of a many to many relationship
for an existing relationship.
"""
def update_pivot(conn, resource_uri, resource_id, field_name, related_id) do
with {:ok, resource, field} <- resource_and_field(resource_uri, field_name),
schema when not is_nil(schema) <- resource.handle_show(conn, resource_id),
pivot <- field.type.apply_options_for(field, schema, conn, :update),
{:ok, related_resource} <- ExTeal.resource_for_model(pivot.private_options.rel.related),
related when not is_nil(related) <- related_resource.handle_show(conn, related_id),
{:ok, pivot_fields} <- Map.fetch(pivot.private_options, :pivot_fields),
{:ok, pivot_data} <- find_pivot_for(pivot, schema, related, resource.repo()) do
updates =
pivot_fields
|> Enum.reduce(%{}, fn field, acc ->
value = field.type.value_for(field, pivot_data, :pivot)
new_value = Map.get(conn.params, field.attribute)
cond do
is_nil(new_value) ->
acc
value == new_value ->
acc
true ->
Map.put_new(acc, String.to_existing_atom(field.attribute), new_value)
end
end)
|> Enum.into([])
if !Enum.empty?(updates) do
{_updated, nil} =
pivot
|> pivot_query(schema, related)
|> resource.repo().update_all(set: updates)
end
{:ok, body} = Jason.encode(%{updated: true})
Serializer.as_json(conn, body, 200)
else
nil ->
ErrorSerializer.handle_error(conn, {:error, :not_found})
:error ->
{:ok, body} = Jason.encode(%{fields: []})
Serializer.as_json(conn, body, 200)
{:error, :not_found} = resp ->
ErrorSerializer.handle_error(conn, resp)
end
end
@doc """
Batch Updates a pivot schema's fields for reordering a relationship
"""
def reorder(conn, resource_uri, resource_id, field_name) do
with {:ok, resource, field} <- resource_and_field(resource_uri, field_name),
schema when not is_nil(schema) <- resource.handle_show(conn, resource_id),
pivot <- field.type.apply_options_for(field, schema, conn, :update),
{:ok, _related_resource} <- ExTeal.resource_for_model(pivot.private_options.rel.related) do
multi =
conn.params["data"]
|> Enum.reduce(Multi.new(), fn element, acc ->
id = Map.get(element, pivot.options.uri)
identifier = String.to_atom("update_#{pivot.options.uri}_#{id}")
field_name = Atom.to_string(pivot.options.sortable_by)
field = Map.get(element, field_name)
query = reorder_query(pivot, schema, id)
Multi.update_all(acc, identifier, query, set: [{pivot.options.sortable_by, field}])
end)
case resource.repo().transaction(multi) do
{:ok, _updated} ->
{:ok, body} = Jason.encode(%{updated: true})
Serializer.as_json(conn, body, 200)
{:error, _, _, _} ->
ErrorSerializer.handle_error(conn, {:error, :not_found})
end
else
nil ->
ErrorSerializer.handle_error(conn, {:error, :not_found})
{:error, :not_found} = resp ->
ErrorSerializer.handle_error(conn, resp)
end
end
defp find_pivot_for(%Field{} = pivot, primary, secondary, repo) do
field_identifiers =
pivot.private_options.pivot_fields
|> Enum.map(& &1.field)
query = pivot_query(pivot, primary, secondary)
from(
q in query,
limit: 1,
select: map(q, ^field_identifiers)
)
result = repo.one(query)
case result do
nil -> {:error, :not_found}
result -> {:ok, result}
end
end
defp reorder_query(pivot, primary, secondary_id) do
rel = pivot.private_options.rel
[{primary_pivot, primary_fetch}, {secondary_pivot, _secondary_fetch}] = rel.join_keys
primary_id = Map.get(primary, primary_fetch)
from(
r in rel.join_through,
where: field(r, ^primary_pivot) == ^primary_id,
where: field(r, ^secondary_pivot) == ^secondary_id,
select: r
)
end
defp pivot_query(pivot, primary, secondary) do
rel = pivot.private_options.rel
[{primary_pivot, primary_fetch}, {secondary_pivot, secondary_fetch}] = rel.join_keys
primary_id = Map.get(primary, primary_fetch)
secondary_id = Map.get(secondary, secondary_fetch)
from(
r in rel.join_through,
where: field(r, ^primary_pivot) == ^primary_id,
where: field(r, ^secondary_pivot) == ^secondary_id
)
end
defp resource_and_field(resource_uri, field_name) do
with {:ok, resource} <- ExTeal.resource_for(resource_uri),
{:ok, found_field} <- Fields.field_for(resource, field_name) do
{:ok, resource, found_field}
else
_ -> {:error, :not_found}
end
end
defp attached(conn, resource_uri, resource_id, field_name) do
with {:ok, resource, field} <- resource_and_field(resource_uri, field_name),
model when not is_nil(model) <- resource.handle_show(conn, resource_id),
%Field{} = updated_field <- field.type.apply_options_for(field, model, conn, :show) do
{:ok, resource, model, updated_field}
else
_ -> {:error, :not_found}
end
end
defp related_for(field) do
ExTeal.resource_for_model(field.private_options.rel.queryable)
end
end