Skip to content

Commit

Permalink
tracks deletions of nested associations
Browse files Browse the repository at this point in the history
  • Loading branch information
narrowtux committed Oct 20, 2017
1 parent d7cd387 commit 47c37fa
Show file tree
Hide file tree
Showing 4 changed files with 102 additions and 17 deletions.
2 changes: 1 addition & 1 deletion example/blog_post.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ defmodule ExAudit.Test.BlogPost do
belongs_to :author, ExAudit.Test.User
embeds_many :sections, ExAudit.Test.BlogPost.Section

has_many :comments, ExAudit.Test.Comment
has_many :comments, ExAudit.Test.Comment, on_delete: :delete_all

timestamps(type: :utc_datetime)
end
Expand Down
21 changes: 14 additions & 7 deletions lib/repo/schema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ defmodule ExAudit.Schema do
def delete(module, adapter, struct, opts) do
opts = augment_opts(opts)
augment_transaction(module, fn ->
ExAudit.Tracking.track_assoc_deletion(module, adapter, struct, opts)
result = Ecto.Repo.Schema.delete(module, adapter, struct, opts)

case result do
Expand Down Expand Up @@ -86,23 +87,29 @@ defmodule ExAudit.Schema do
def delete!(module, adapter, struct, opts) do
opts = augment_opts(opts)
augment_transaction(module, fn ->
ExAudit.Tracking.track_assoc_deletion(module, adapter, struct, opts)
result = Ecto.Repo.Schema.delete!(module, adapter, struct, opts)
ExAudit.Tracking.track_change(module, adapter, :deleted, struct, result, opts)
result
end)
end

@doc """
Cleans up the return value from repo.transaction
"""
defp augment_transaction(repo, fun) do
case repo.in_transaction?() do
true -> fun.()
false ->
case repo.transaction(fun) do
{:ok, value} -> value
other -> other
end
case repo.transaction(fun) do
{:ok, value} -> value
other -> other
end
end

@doc """
Gets the custom data from the ets store that stores it by PID, and adds
it to the list of custom data from the options list
This is done so it works inside a transaction (which happens when ecto mutates assocs at the same time)
"""
defp augment_opts(opts) do
opts
|> Keyword.put_new(:ex_audit_custom, [])
Expand Down
55 changes: 47 additions & 8 deletions lib/tracking/tracking.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ defmodule ExAudit.Tracking do
@version_schema Application.get_env(:ex_audit, :version_schema)
@ignored_fields [:__meta__, :__struct__]

import Ecto.Query

def find_changes(action, struct_or_changeset, resulting_struct) do
old = case {action, struct_or_changeset} do
{:created, _} -> %{}
Expand All @@ -19,7 +21,7 @@ defmodule ExAudit.Tracking do
compare_versions(action, old, new)
end

def compare_versions(guessed_action, old, new) do
def compare_versions(action, old, new) do
schema = Map.get(old, :__struct__, Map.get(new, :__struct__))

assocs = schema.__schema__(:associations)
Expand All @@ -28,25 +30,23 @@ defmodule ExAudit.Tracking do

patch = ExAudit.Diff.diff(Map.drop(old, ignored_fields), Map.drop(new, ignored_fields))

guessed_action = guessed_action || guess_action(old, new)

params = %{
entity_id: Map.get(old, :id) || Map.get(new, :id),
entity_schema: schema,
patch: patch,
action: guessed_action
action: action
}

[params]
end

def guess_action(%{id: id}, %{id: id}) when not is_nil(id), do: :updated
def guess_action(%{}, %{id: id}) when not is_nil(id), do: :created
def guess_action(%{id: id}, nil) when not is_nil(id), do: :deleted

def track_change(module, adapter, action, changeset, resulting_struct, opts) do
changes = find_changes(action, changeset, resulting_struct)

insert_versions(module, adapter, changes, opts)
end

def insert_versions(module, adapter, changes, opts) do
now = DateTime.utc_now
custom_fields =
Keyword.get(opts, :ex_audit_custom, [])
Expand All @@ -63,4 +63,43 @@ defmodule ExAudit.Tracking do
Ecto.Repo.Schema.insert_all(module, adapter, @version_schema, changes, opts)
end
end

def find_assoc_deletion(module, adapter, struct, repo_opts) do
schema = case struct do
%Ecto.Changeset{data: %{__struct__: schema}} -> schema
%{__struct__: schema} -> schema
end

id = case struct do
%Ecto.Changeset{data: %{id: id}} -> id
%{id: id} -> id
end

assocs =
schema.__schema__(:associations)
|> Enum.map(fn field -> {field, schema.__schema__(:association, field)} end)
|> Enum.filter(fn {_, opts} -> Map.get(opts, :on_delete) == :delete_all end)

assocs
|> Enum.flat_map(fn {field, opts} ->
assoc_schema = Map.get(opts, :related)

filter = [{Map.get(opts, :related_key), id}]

query =
from(s in assoc_schema)
|> where(^filter)

root = module.all(query)
root ++ Enum.map(root, &find_assoc_deletion(module, adapter, &1, repo_opts))
end)
|> List.flatten()
|> Enum.flat_map(&compare_versions(:deleted, &1, %{}))
end

def track_assoc_deletion(module, adapter, struct, opts) do
deleted_structs = find_assoc_deletion(module, adapter, struct, opts)

insert_versions(module, adapter, deleted_structs, opts)
end
end
41 changes: 40 additions & 1 deletion test/assoc_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ defmodule AssocTest do
test "comment lifecycle tracked" do
user = Util.create_user()

ExAudit.track(actor_id: user.id)

params = %{
title: "Controversial post",
author_id: user.id,
Expand All @@ -22,7 +24,44 @@ defmodule AssocTest do
changeset = BlogPost.changeset(%BlogPost{}, params)
{:ok, %{comments: [comment]} = blog_post} = Repo.insert(changeset)

comment_history = Repo.history(comment)
[%{actor_id: actor_id}] = comment_history = Repo.history(comment)
assert length(comment_history) == 1
assert actor_id == user.id
end

test "should track cascading deletions (before they happen)" do
user = Util.create_user()

ExAudit.track(actor_id: user.id)

params = %{
title: "Controversial post",
author_id: user.id,
comments: [
%{
body: "lorem impusdrfnia",
author_id: user.id
}, %{
body: "That's a nice article",
author_id: user.id
}, %{
body: "We want more of this CONTENT",
author_id: user.id
}
]
}

changeset = BlogPost.changeset(%BlogPost{}, params)
{:ok, %{comments: comments} = blog_post} = Repo.insert(changeset)

Repo.delete(blog_post)

comment_ids = Enum.map(comments, &(&1.id))

versions = Repo.all(from v in Version,
where: v.entity_id in ^comment_ids,
where: v.entity_schema == ^Comment)

assert length(versions) == 6 # 3 created, 3 deleted
end
end

0 comments on commit 47c37fa

Please sign in to comment.