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 #21

Closed
wants to merge 16 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 21 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,11 @@ The library source code is minimal and well tested. It is suggested to read the

Your application is now ready to collect some history!

#### Does this work with phoenix?
### Does this work with phoenix?

YES! Make sure you do the steps above.

### %PaperTrail.Version{} fields:
## %PaperTrail.Version{} fields:

| Column Name | Type | Description | Entry Method |
| ------------- | ------- | -------------------------- | ------------------------ |
Expand All @@ -150,7 +150,7 @@ YES! Make sure you do the steps above.
| meta | Map | any extra optional meta information about the version(eg. %{slug: "ausername", important: true}) | Optionally set |
| inserted_at | Date | inserted_at timestamp | Ecto generates |

#### Configuring the types
### Configuring the types

If you are using UUID or another type for your primary keys, you can configure
the PaperTrail.Version schema to use it.
Expand All @@ -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 @@ -19,3 +19,5 @@ config :paper_trail, PaperTrail.UUIDRepo,
database: "paper_trail_uuid_test",
hostname: "localhost",
poolsize: 10

config :logger, level: :info
249 changes: 197 additions & 52 deletions lib/paper_trail.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ defmodule PaperTrail do
@client PaperTrail.RepoClient
@originator @client.originator()
@repo @client.repo()
@item_type Application.get_env(:paper_trail, :item_type, :integer)

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


@doc """
Gets all the versions of a record given a module and its id
Expand Down Expand Up @@ -83,8 +93,17 @@ defmodule PaperTrail do
Multi.new
|> Multi.insert(:model, changeset)
|> Multi.run(:version, fn %{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

case Keyword.get_values(results, :error) do
[] -> {:ok, Keyword.get_values(results, :ok)}
errors -> {:error, errors}
end
end)
end

Expand All @@ -101,6 +120,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 @@ -137,7 +164,9 @@ defmodule PaperTrail do
model
_ ->
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) |> elem(1)
Expand Down Expand Up @@ -171,9 +200,15 @@ defmodule PaperTrail do
_ ->
Multi.new
|> Multi.update(:model, changeset)
|> Multi.run(:version, fn %{model: _model} ->
version = make_version_struct(%{event: "update"}, changeset, options)
@repo.insert(version)
|> Multi.run(:version, fn %{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 +225,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 @@ -217,8 +260,9 @@ defmodule PaperTrail do
model
_ ->
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) |> elem(1)
Expand All @@ -228,12 +272,28 @@ defmodule PaperTrail do
Deletes a record from the database with a related version insertion in one transaction
"""
def delete(struct, options \\ [origin: nil, meta: nil, originator: nil]) do
deleted_assocs = PaperTrail.AssociationUtils.get_all_children(struct)

transaction = Multi.new
|> Multi.delete(:model, struct)
|> Multi.run(:version, fn %{} ->
version = make_version_struct(%{event: "delete"}, struct, options)
@repo.insert(version)
end)
|> Multi.run(:assoc_versions, fn %{} ->
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

case transaction do
Expand All @@ -254,50 +314,83 @@ defmodule PaperTrail do
end) |> elem(1)
end

defp make_version_struct(%{event: "insert"}, model, options) do
originator_ref = options[@originator[:name]] || options[:originator]
%Version{
event: "insert",
item_type: model.__struct__ |> Module.split |> List.last,
item_id: model.id,
item_changes: serialize(model),
originator_id: case originator_ref do
nil -> nil
_ -> originator_ref |> Map.get(:id)
end,
origin: options[:origin],
meta: options[:meta]
}
defp make_version_structs(%{event: event}, model, changeset, options) do
model_version = case event do
"update" -> make_version_struct(%{event: event}, changeset, options)
_ -> make_version_struct(%{event: event}, model, options)
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
)
end)

[ model_version | assoc_versions ]
end
defp make_version_struct(%{event: "update"}, changeset, options) do
originator_ref = options[@originator[:name]] || options[:originator]
%Version{
event: "update",
item_type: changeset.data.__struct__ |> Module.split |> List.last,
item_id: changeset.data.id,
item_changes: changeset.changes,
originator_id: case originator_ref do
nil -> nil
_ -> originator_ref |> Map.get(:id)
end,
origin: options[:origin],
meta: options[:meta]
}

defp make_version_struct(%{event: event}, model, options) when event in ~w[insert delete update] do
changes = serialize(model)

case Enum.count(changes) do
0 -> nil
_ ->
item_type = case event do
"update" -> model.data.__struct__
_ -> model.__struct__
end |> Module.split |> List.last

item_id = case event do
"update" -> model.data.id
_ -> model.id
end

originator_ref = options[@originator[:name]] || options[:originator]

%Version{
event: event,
item_type: item_type,
item_id: item_id,
item_changes: changes,
originator_id: case originator_ref do
nil -> nil
_ -> originator_ref |> Map.get(:id)
end,
origin: options[:origin],
meta: options[:meta]
}
end
end
defp make_version_struct(%{event: "delete"}, model, options) do
originator_ref = options[@originator[:name]] || options[:originator]
%Version{
event: "delete",
item_type: model.__struct__ |> Module.split |> List.last,
item_id: model.id,
item_changes: serialize(model),
originator_id: case originator_ref do
nil -> nil
_ -> originator_ref |> Map.get(:id)
end,
origin: options[:origin],
meta: options[:meta]
}

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 @@ -314,8 +407,60 @@ defmodule PaperTrail do
|> List.first
end

defp serialize(%Ecto.Changeset{} = changeset) do
model = case changeset.action do
:update -> changeset.changes
:insert -> changeset.changes
:delete -> 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)
relationships = if @embed_mode == :extract_version do
relationships ++ struct.__schema__(:embeds)
else
relationships
end

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