Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Support associations and embeds - cont. #60

Closed
wants to merge 27 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
63c8749
Added tests for assoc changes
narrowtux Jun 1, 2017
0ea226f
fix migration module name
narrowtux Jun 1, 2017
c527e9e
Wrote a function that extracts versions for each association inside a…
narrowtux Jun 1, 2017
a0ad3f6
Upgraded insert, insert!, update, update!
narrowtux Jun 2, 2017
a7267fd
Also log deleted assocs (+test)
narrowtux Jun 2, 2017
bb63552
check for nilify_all
narrowtux Jun 2, 2017
5551296
Clean up test warnings - not done completely
narrowtux Jun 2, 2017
12ea748
Fixed "redefining module" warnings so they don't pollute the test output
narrowtux Jun 2, 2017
da352eb
moved level: :info from config.exs to test.exs
narrowtux Jun 2, 2017
166ddda
Fix unused variable warning
narrowtux Jun 2, 2017
5db651a
skip the broken test
narrowtux Jun 2, 2017
f44c6a4
delete() returns correctly
narrowtux Jun 2, 2017
dacee4d
Test :nilify_all
narrowtux Jun 2, 2017
078869a
:embed_into_item_changes mode WIP
narrowtux Jun 6, 2017
c8d1b38
Began implementing tests for extract_version mode for embeds (UUID mode)
narrowtux Jun 6, 2017
cc50bd7
extract_version mode -> update
narrowtux Jun 6, 2017
89f57e4
Fixed merge errors.
paranojik Sep 13, 2019
a0cd622
Cleanup.
paranojik Sep 13, 2019
ad27b4f
Cleanup.
paranojik Sep 13, 2019
8e8c97d
Reverted file renames.
paranojik Sep 13, 2019
be4dc6b
Cleanup - removing unrelated changes.
paranojik Sep 13, 2019
a5b8999
Handle many_to_many associations.
paranojik Sep 17, 2019
fdb8b12
Don't remove item_type module prefixes.
paranojik Sep 19, 2019
bac6508
Don't remove item_type module prefixes.
paranojik Sep 19, 2019
2989bb0
Don't remove item_type module prefixes.
paranojik Sep 19, 2019
42c1881
Fixed item_type queries.
paranojik Sep 20, 2019
979d648
Only make assoc versions if there are changes but always make a versi…
paranojik Sep 20, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,24 @@ config :paper_trail, item_type: Ecto.UUID,

Remember to edit the types accordingly in the generated migration.

### How PaperTrail handles Embeds

PaperTrail can keep track of embeds in your schemas. There are 2 ways it can do so:

* `:extract_version` If the type of your schema IDs and that of the IDs in the embeds
is the same, you can tell PaperTrail to extract a version entry for each of your
embeds. This option is automatically used if you configured `:item_type` to
be `Ecto.UUID`
* `:embed_into_item_changes` (*default*) If the ID types of your schemas and
embeds doesn't match, PaperTrail can just render the whole embeds into the
`:item_changes` field in the version entry of the parent schema.

Note that you usually don't need to set this option manually.

```elixir
config :paper_trail, embed_mode: :extract_version
```

### Version origin references:
PaperTrail records have a string field called ```origin```. ```PaperTrail.insert/2```, ```PaperTrail.update/2```, ```PaperTrail.delete/2``` functions accept a second argument to describe the origin of this version:
```elixir
Expand Down
2 changes: 2 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,5 @@ config :paper_trail, PaperTrail.UUIDRepo,
database: "paper_trail_uuid_test",
hostname: System.get_env("POSTGRES_HOST"),
poolsize: 10

config :logger, level: :info
256 changes: 186 additions & 70 deletions lib/paper_trail.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ defmodule PaperTrail do
defdelegate get_versions(model, id, options), to: PaperTrail.VersionQueries
defdelegate get_current_model(version), to: PaperTrail.VersionQueries

@embed_mode (case Application.get_env(:paper_trail, :item_type, :integer) do
Ecto.UUID ->
:extract_version
_ ->
:embed_into_item_changes
end)
@embed_mode Application.get_env(:paper_trail, :embed_mode, @embed_mode)

@doc """
Inserts a record to the database with a related version insertion in one transaction
"""
Expand Down Expand Up @@ -57,8 +65,14 @@ defmodule PaperTrail do
Multi.new()
|> Multi.insert(:model, changeset)
|> Multi.run(:version, fn repo, %{model: model} ->
version = make_version_struct(%{event: "insert"}, model, options)
repo.insert(version)
versions = make_version_structs(%{event: "insert"}, model, changeset, options)

results = case versions do
[nil | rest] -> [{:ok, nil} | Enum.map(rest, &repo.insert/1)]
_ -> Enum.map(versions, &repo.insert/1)
end

format_multiple_results(results)
end)
end

Expand All @@ -80,6 +94,14 @@ defmodule PaperTrail do
_ ->
case transaction do
{:error, :model, changeset, %{}} -> {:error, Map.merge(changeset, %{repo: repo})}
{:ok, map} ->
versions = Map.get(map, :version)

map =
map
|> Map.put(:version, hd(versions))
|> Map.put(:assoc_versions, tl(versions))
{:ok, map}
_ -> transaction
end
end
Expand Down Expand Up @@ -122,7 +144,9 @@ defmodule PaperTrail do

_ ->
model = repo.insert!(changeset)
make_version_struct(%{event: "insert"}, model, options) |> repo.insert!
%{event: "insert"}
|> make_version_structs(model, changeset, options)
|> Enum.each(&repo.insert!/1)
model
end
end)
Expand Down Expand Up @@ -168,9 +192,15 @@ defmodule PaperTrail do
_ ->
Multi.new()
|> Multi.update(:model, changeset)
|> Multi.run(:version, fn repo, %{model: _model} ->
version = make_version_struct(%{event: "update"}, changeset, options)
repo.insert(version)
|> Multi.run(:version, fn repo, %{model: model} ->
versions = make_version_structs(%{event: "update"}, model, changeset, options)

results = case versions do
[nil | rest] -> [{:ok, nil} | Enum.map(rest, &repo.insert/1)]
_ -> Enum.map(versions, &repo.insert/1)
end

format_multiple_results(results)
end)
end

Expand All @@ -190,6 +220,14 @@ defmodule PaperTrail do
_ ->
case transaction do
{:error, :model, changeset, %{}} -> {:error, Map.merge(changeset, %{repo: repo})}
{:ok, map} ->
versions = Map.get(map, :version)

map =
map
|> Map.put(:version, hd(versions))
|> Map.put(:assoc_versions, tl(versions))
{:ok, map}
_ -> transaction
end
end
Expand Down Expand Up @@ -228,8 +266,9 @@ defmodule PaperTrail do

_ ->
model = repo.update!(changeset)
version_struct = make_version_struct(%{event: "update"}, changeset, options)
repo.insert!(version_struct)
%{event: "update"}
|> make_version_structs(model, changeset, options)
|> Enum.each(&repo.insert!/1)
model
end
end)
Expand All @@ -241,14 +280,29 @@ defmodule PaperTrail do
"""
def delete(struct, options \\ [origin: nil, meta: nil, originator: nil, prefix: nil]) do
repo = PaperTrail.RepoClient.repo()
deleted_assocs = PaperTrail.AssociationUtils.get_all_children(struct)

transaction =
Multi.new()
|> Multi.delete(:model, struct, options)
|> Multi.run(:version, fn repo, %{} ->
version = make_version_struct(%{event: "delete"}, struct, options)
|> Multi.run(:version, fn repo, %{model: model} ->
version = make_version_struct(%{event: "delete"}, model, options)
repo.insert(version, options)
end)
|> Multi.run(:assoc_versions, fn repo, %{} ->
results =
deleted_assocs
|> Enum.map(fn
{:delete_all, _, struct} ->
make_version_struct(%{event: "delete"}, struct, options)
{:nilify_all, owner_field, struct} ->
changeset = Ecto.Changeset.change(struct, [{owner_field, nil}])
make_version_struct(%{event: "update"}, changeset, options)
end)
|> Enum.map(&repo.insert/1)

format_multiple_results(results)
end)
|> repo.transaction(options)

case transaction do
Expand All @@ -272,64 +326,75 @@ defmodule PaperTrail do
|> elem(1)
end

defp make_version_struct(%{event: "insert"}, model, options) do
originator = PaperTrail.RepoClient.originator()
originator_ref = options[originator[:name]] || options[:originator]

%Version{
event: "insert",
item_type: get_item_type(model),
item_id: get_model_id(model),
item_changes: serialize(model),
originator_id:
case originator_ref do
nil -> nil
_ -> originator_ref |> Map.get(:id)
end,
origin: options[:origin],
meta: options[:meta]
}
|> add_prefix(options[:prefix])
defp make_version_structs(%{event: event}, model, changeset, options, only_if_changes \\ false) do
model_version = case event do
"update" -> make_version_struct(%{event: event}, changeset, options, only_if_changes)
_ -> make_version_struct(%{event: event}, model, options, only_if_changes)
end

assoc_versions =
changeset.changes
|> Enum.flat_map(fn {key, value} ->
model = Map.get(model, key)
case value do
%Ecto.Changeset{} = changeset ->
{changeset, model}
list when is_list(list) ->
[list, model]
|> List.zip()
|> Enum.filter(fn
{%Ecto.Changeset{}, _} -> true
_ -> false
end)
|> Enum.filter(fn
{_, %{__struct__: schema}} -> not(is_embed?(schema)) or @embed_mode == :extract_version
_ -> false
end)
_ -> []
end
end)
|> Enum.flat_map(fn {changeset, model} ->
make_version_structs(
%{event: changeset.action |> Atom.to_string},
model,
changeset,
options,
true
)
end)

[ model_version | assoc_versions ]
end

defp make_version_struct(%{event: "update"}, changeset, options) do
originator = PaperTrail.RepoClient.originator()
originator_ref = options[originator[:name]] || options[:originator]

%Version{
event: "update",
item_type: get_item_type(changeset),
item_id: get_model_id(changeset),
item_changes: changeset.changes,
originator_id:
case originator_ref do
nil -> nil
_ -> originator_ref |> Map.get(:id)
end,
origin: options[:origin],
meta: options[:meta]
}
|> add_prefix(options[:prefix])
defp make_version_struct(%{event: event}, model, options, only_if_changes \\ false) when event in ~w[insert delete update replace] do
changes = serialize(model)

if ((not only_if_changes) or (Enum.count(changes) > 0)) do
originator = PaperTrail.RepoClient.originator()
originator_ref = options[originator[:name]] || options[:originator]

%Version{
event: event,
item_type: get_item_type(model),
item_id: get_model_id(model),
item_changes: changes,
originator_id:
case originator_ref do
nil -> nil
_ -> originator_ref |> Map.get(:id)
end,
origin: options[:origin],
meta: options[:meta]
}
|> add_prefix(options[:prefix])
end
end

defp make_version_struct(%{event: "delete"}, model_or_changeset, options) do
originator = PaperTrail.RepoClient.originator()
originator_ref = options[originator[:name]] || options[:originator]

%Version{
event: "delete",
item_type: get_item_type(model_or_changeset),
item_id: get_model_id(model_or_changeset),
item_changes: serialize(model_or_changeset),
originator_id:
case originator_ref do
nil -> nil
_ -> originator_ref |> Map.get(:id)
end,
origin: options[:origin],
meta: options[:meta]
}
|> add_prefix(options[:prefix])
defp format_multiple_results(results) do
case Keyword.get_values(results, :error) do
[] -> {:ok, Keyword.get_values(results, :ok)}
errors -> {:error, errors}
end
end

defp get_sequence_from_model(changeset) do
Expand All @@ -348,18 +413,63 @@ defmodule PaperTrail do
|> List.first()
end

defp serialize(%Ecto.Changeset{data: data}), do: serialize(data)

defp serialize(%Ecto.Changeset{action: :update, changes: %{}} = changeset) do
serialize(changeset.data.__struct__, changeset.data)
end
defp serialize(%Ecto.Changeset{} = changeset) do
model = case changeset.action do
:update -> changeset.changes
:insert -> changeset.changes
:delete -> changeset.data
:replace -> changeset.data
nil -> changeset.changes
end
serialize(changeset.data.__struct__, model)
end
defp serialize(model) do
relationships = model.__struct__.__schema__(:associations)
Map.drop(model, [:__struct__, :__meta__] ++ relationships)
serialize(model.__struct__, model)
end
defp serialize(struct, model) do
relationships = struct.__schema__(:associations) ++ struct.__schema__(:embeds)

model
|> Map.drop([:__struct__, :__meta__] ++ relationships)
|> Enum.filter(fn
{_, %Ecto.Association.NotLoaded{}} -> false
_ -> true
end)
|> Enum.into(%{}, fn
{key, %Ecto.Changeset{} = changeset} ->
{key, serialize(changeset)}
{key, %{__struct__: struct} = model} ->
if is_schema?(struct) do
{key, serialize(model)}
else
{key, model}
end
{key, list} when is_list(list) ->
list = Enum.map(list, fn
%Ecto.Changeset{} = changeset ->
serialize(changeset)
%{__struct__: struct} = model ->
if is_schema?(struct) do
serialize(model)
else
model
end
value ->
value
end)
{key, list}
other -> other
end)
end

defp add_prefix(changeset, nil), do: changeset
defp add_prefix(changeset, prefix), do: Ecto.put_meta(changeset, prefix: prefix)

defp get_item_type(%Ecto.Changeset{data: data}), do: get_item_type(data)
defp get_item_type(model), do: model.__struct__ |> Module.split() |> List.last()
def get_item_type(%Ecto.Changeset{data: data}), do: get_item_type(data)
def get_item_type(model), do: model.__struct__ |> Module.split() |> Enum.join(".")

def get_model_id(%Ecto.Changeset{data: data}), do: get_model_id(data)

Expand All @@ -373,4 +483,10 @@ defmodule PaperTrail do
"#{model_id}"
end
end

defp is_schema?(struct) do
:functions |> struct.__info__ |> Keyword.get(:__schema__, :undef) != :undef
end

defp is_embed?(schema), do: is_nil(schema.__schema__(:source))
end
Loading