From a1e383827a5e6e105f821921ae4aa3ae3a18885d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20M=C3=A4nnchen?= Date: Mon, 30 Nov 2020 18:26:21 +0100 Subject: [PATCH] Support Ecto Embed --- lib/paper_trail.ex | 135 ++++++------------ lib/paper_trail/serializer.ex | 49 ++++++- .../20160619190938_add_simple_people.exs | 2 + .../bang_functions_simple_mode_test.exs | 131 ++++++++++++++++- .../bang_functions_strict_mode_test.exs | 4 +- test/paper_trail/base_test.exs | 12 +- test/support/simple_models.exs | 20 +++ 7 files changed, 250 insertions(+), 103 deletions(-) diff --git a/lib/paper_trail.ex b/lib/paper_trail.ex index 1fea2558..f6ede331 100644 --- a/lib/paper_trail.ex +++ b/lib/paper_trail.ex @@ -1,8 +1,5 @@ defmodule PaperTrail do - import Ecto.Changeset - alias PaperTrail.Version - alias PaperTrail.RepoClient alias PaperTrail.Serializer defdelegate get_version(record), to: PaperTrail.VersionQueries @@ -22,6 +19,9 @@ defmodule PaperTrail do @doc """ Inserts a record to the database with a related version insertion in one transaction """ + @spec insert(changeset :: Ecto.Changeset.t(model), options :: Keyword.t()) :: + {:ok, %{model: model, version: Version.t()}} | {:error, Ecto.Changeset.t(model) | term} + when model: struct def insert( changeset, options \\ [ @@ -42,6 +42,8 @@ defmodule PaperTrail do @doc """ Same as insert/2 but returns only the model struct or raises if the changeset is invalid. """ + @spec insert!(changeset :: Ecto.Changeset.t(model), options :: Keyword.t()) :: model + when model: struct def insert!( changeset, options \\ [ @@ -54,50 +56,17 @@ defmodule PaperTrail do ecto_options: [] ] ) do - repo = RepoClient.repo() - ecto_options = options[:ecto_options] || [] - - repo.transaction(fn -> - case RepoClient.strict_mode() do - true -> - version_id = get_sequence_id("versions") + 1 - - changeset_data = - Map.get(changeset, :data, changeset) - |> Map.merge(%{ - id: get_sequence_id(changeset) + 1, - first_version_id: version_id, - current_version_id: version_id - }) - - initial_version = - make_version_struct(%{event: "insert"}, changeset_data, options) - |> repo.insert! - - updated_changeset = - changeset - |> change(%{ - first_version_id: initial_version.id, - current_version_id: initial_version.id - }) - - model = repo.insert!(updated_changeset, ecto_options) - target_version = make_version_struct(%{event: "insert"}, model, options) |> serialize() - Version.changeset(initial_version, target_version) |> repo.update! - model - - _ -> - model = repo.insert!(changeset, ecto_options) - make_version_struct(%{event: "insert"}, model, options) |> repo.insert! - model - end - end) - |> elem(1) + changeset + |> insert(options) + |> model_or_error() end @doc """ Updates a record from the database with a related version insertion in one transaction """ + @spec update(changeset :: Ecto.Changeset.t(model), options :: Keyword.t()) :: + {:ok, %{model: model, version: Version.t()}} | {:error, Ecto.Changeset.t(model) | term} + when model: struct def update(changeset, options \\ [origin: nil, meta: nil, originator: nil, prefix: nil]) do PaperTrail.Multi.new() |> PaperTrail.Multi.update(changeset, options) @@ -107,65 +76,55 @@ defmodule PaperTrail do @doc """ Same as update/2 but returns only the model struct or raises if the changeset is invalid. """ + @spec update!(changeset :: Ecto.Changeset.t(model), options :: Keyword.t()) :: model + when model: struct def update!(changeset, options \\ [origin: nil, meta: nil, originator: nil, prefix: nil]) do - repo = PaperTrail.RepoClient.repo() - client = PaperTrail.RepoClient - - repo.transaction(fn -> - case client.strict_mode() do - true -> - version_data = - changeset.data - |> Map.merge(%{ - current_version_id: get_sequence_id("versions") - }) - - target_changeset = changeset |> Map.merge(%{data: version_data}) - target_version = make_version_struct(%{event: "update"}, target_changeset, options) - initial_version = repo.insert!(target_version) - updated_changeset = changeset |> change(%{current_version_id: initial_version.id}) - model = repo.update!(updated_changeset) - - new_item_changes = - initial_version.item_changes - |> Map.merge(%{ - current_version_id: initial_version.id - }) - - initial_version |> change(%{item_changes: new_item_changes}) |> repo.update! - model - - _ -> - model = repo.update!(changeset) - version_struct = make_version_struct(%{event: "update"}, changeset, options) - repo.insert!(version_struct) - model - end - end) - |> elem(1) + changeset + |> update(options) + |> model_or_error() end @doc """ Deletes a record from the database with a related version insertion in one transaction """ - def delete(struct, options \\ [origin: nil, meta: nil, originator: nil, prefix: nil]) do + @spec delete(model_or_changeset :: model | Ecto.Changeset.t(model), options :: Keyword.t()) :: + {:ok, %{model: model, version: Version.t()}} | {:error, Ecto.Changeset.t(model) | term} + when model: struct + def delete( + model_or_changeset, + options \\ [origin: nil, meta: nil, originator: nil, prefix: nil] + ) do PaperTrail.Multi.new() - |> PaperTrail.Multi.delete(struct, options) + |> PaperTrail.Multi.delete(model_or_changeset, options) |> PaperTrail.Multi.commit() end @doc """ Same as delete/2 but returns only the model struct or raises if the changeset is invalid. """ - def delete!(struct, options \\ [origin: nil, meta: nil, originator: nil, prefix: nil]) do - repo = PaperTrail.RepoClient.repo() + @spec delete!(model_or_changeset :: model | Ecto.Changeset.t(model), options :: Keyword.t()) :: + model + when model: struct + def delete!( + model_or_changeset, + options \\ [origin: nil, meta: nil, originator: nil, prefix: nil] + ) do + model_or_changeset + |> delete(options) + |> model_or_error() + end + + @spec model_or_error({:ok, %{model: model}}) :: model when model: struct() + defp model_or_error({:ok, %{model: model}}) do + model + end + + @spec model_or_error({:error, reason :: term}) :: no_return + defp model_or_error({:error, %Ecto.Changeset{} = changeset}) do + raise Ecto.InvalidChangesetError, action: :update, changeset: changeset + end - repo.transaction(fn -> - model = repo.delete!(struct, options) - version_struct = make_version_struct(%{event: "delete"}, struct, options) - repo.insert!(version_struct, options) - model - end) - |> elem(1) + defp model_or_error({:error, reason}) do + raise reason end end diff --git a/lib/paper_trail/serializer.ex b/lib/paper_trail/serializer.ex index 9073457d..36f0673d 100644 --- a/lib/paper_trail/serializer.ex +++ b/lib/paper_trail/serializer.ex @@ -106,9 +106,9 @@ defmodule PaperTrail.Serializer do Dumps changes using Ecto fields """ @spec serialize_changes(Ecto.Changeset.t()) :: map() - def serialize_changes(%Ecto.Changeset{data: %schema{}, changes: changes}) do - changes - |> schema.__struct__() + def serialize_changes(%Ecto.Changeset{changes: changes} = changeset) do + changeset + |> serialize_model_changes() |> serialize() |> Map.take(Map.keys(changes)) end @@ -144,4 +144,47 @@ defmodule PaperTrail.Serializer do "#{model_id}" end end + + @spec serialize_model_changes(Ecto.Changeset.t()) :: map() + defp serialize_model_changes(%Ecto.Changeset{data: %schema{}} = changeset) do + field_values = serialize_model_field_changes(changeset) + embed_values = serialize_model_embed_changes(changeset) + + field_values + |> Map.merge(embed_values) + |> schema.__struct__() + end + + defp serialize_model_field_changes(%Ecto.Changeset{data: %schema{}, changes: changes}) do + change_keys = changes |> Map.keys() |> MapSet.new() + + field_keys = + :fields + |> schema.__schema__() + |> MapSet.new() + |> MapSet.intersection(change_keys) + |> MapSet.to_list() + + Map.take(changes, field_keys) + end + + defp serialize_model_embed_changes(%Ecto.Changeset{data: %schema{}, changes: changes}) do + change_keys = changes |> Map.keys() |> MapSet.new() + + embed_keys = + :embeds + |> schema.__schema__() + |> MapSet.new() + |> MapSet.intersection(change_keys) + |> MapSet.to_list() + + changes + |> Map.take(embed_keys) + |> Map.new(fn {key, value} -> + case schema.__schema__(:embed, key) do + %Ecto.Embedded{cardinality: :one} -> {key, serialize_model_changes(value)} + %Ecto.Embedded{cardinality: :many} -> {key, Enum.map(value, &serialize_model_changes/1)} + end + end) + end end diff --git a/priv/repo/migrations/20160619190938_add_simple_people.exs b/priv/repo/migrations/20160619190938_add_simple_people.exs index e8e30058..0e69e5f8 100644 --- a/priv/repo/migrations/20160619190938_add_simple_people.exs +++ b/priv/repo/migrations/20160619190938_add_simple_people.exs @@ -8,6 +8,8 @@ defmodule Repo.Migrations.CreateSimplePeople do add :visit_count, :integer add :gender, :boolean add :birthdate, :date + add :singular, :map + add :plural, {:array, :map} add :company_id, references(:simple_companies), null: false diff --git a/test/paper_trail/bang_functions_simple_mode_test.exs b/test/paper_trail/bang_functions_simple_mode_test.exs index cde4779d..4e026fc5 100644 --- a/test/paper_trail/bang_functions_simple_mode_test.exs +++ b/test/paper_trail/bang_functions_simple_mode_test.exs @@ -326,7 +326,9 @@ defmodule PaperTrailTest.SimpleModeBangFunctions do gender: true, visit_count: nil, birthdate: nil, - company_id: second_company.id + company_id: second_company.id, + plural: [], + singular: nil } assert Map.drop(version, [:id, :inserted_at]) == %{ @@ -342,6 +344,39 @@ defmodule PaperTrailTest.SimpleModeBangFunctions do assert person == first(Person, :id) |> repo().one |> serialize end + test "creating a person with embeds creates a person version with correct attributes" do + company = create_company_with_version() + + %Person{ + id: person_id, + plural: [%{id: _, name: "Plural"}], + singular: %{id: _, name: "Singular"} + } = + person = + %Person{} + |> Person.changeset(%{ + first_name: "Izel", + last_name: "Nakri", + gender: true, + company_id: company.id, + plural: [%{name: "Plural"}], + singular: %{name: "Singular"} + }) + |> PaperTrail.insert!() + + version = PaperTrail.get_version(person) + person_change = person |> serialize() |> convert_to_string_map + + assert %{ + event: "insert", + item_type: "SimplePerson", + item_id: ^person_id, + item_changes: ^person_change + } = version + + assert person == first(Person, :id) |> repo().one + end + test "updating a person creates a person version with correct attributes" do inserted_initial_company = create_company_with_version(%{ @@ -391,7 +426,9 @@ defmodule PaperTrailTest.SimpleModeBangFunctions do visit_count: 10, birthdate: ~D[1992-04-01], last_name: "Nakri", - gender: true + gender: true, + plural: [], + singular: nil } assert Map.drop(version, [:id, :inserted_at]) == %{ @@ -413,6 +450,44 @@ defmodule PaperTrailTest.SimpleModeBangFunctions do assert person == first(Person, :id) |> repo().one |> serialize end + test "updating a person with embeds creates a person version with correct attributes" do + company = create_company_with_version() + + %Person{ + id: person_id, + plural: [%{id: _, name: "Plural"}], + singular: %{id: _, name: "Singular"} + } = + person = + %Person{} + |> Person.changeset(%{ + first_name: "Izel", + last_name: "Nakri", + gender: true, + company_id: company.id + }) + |> PaperTrail.insert!() + |> Person.changeset(%{ + plural: [%{name: "Plural"}], + singular: %{name: "Singular"} + }) + |> PaperTrail.update!() + + version = PaperTrail.get_version(person) + + assert %{ + event: "update", + item_type: "SimplePerson", + item_id: ^person_id, + item_changes: %{ + "plural" => [%{"id" => _, "name" => "Plural"}], + "singular" => %{"id" => _, "name" => "Singular"} + } + } = version + + assert person == first(Person, :id) |> repo().one + end + test "deleting a person creates a person version with correct attributes" do create_company_with_version(%{name: "Acme LLC", website: "http://www.acme.com"}) @@ -475,7 +550,9 @@ defmodule PaperTrailTest.SimpleModeBangFunctions do gender: true, visit_count: 10, birthdate: ~D[1992-04-01], - company_id: inserted_target_company.id + company_id: inserted_target_company.id, + plural: [], + singular: nil }), originator_id: nil, origin: "admin", @@ -485,6 +562,40 @@ defmodule PaperTrailTest.SimpleModeBangFunctions do assert old_person == person_before_deletion end + test "deleting a person with embeds creates a person version with correct attributes" do + company = create_company_with_version() + + %Person{ + id: person_id, + plural: [%{id: _, name: "Plural"}], + singular: %{id: _, name: "Singular"} + } = + person = + %Person{} + |> Person.changeset(%{ + first_name: "Izel", + last_name: "Nakri", + gender: true, + company_id: company.id, + plural: [%{name: "Plural"}], + singular: %{name: "Singular"} + }) + |> PaperTrail.insert!() + |> PaperTrail.delete!() + + version = PaperTrail.get_version(person) + person_change = person |> serialize() |> convert_to_string_map + + assert %{ + event: "delete", + item_type: "SimplePerson", + item_id: ^person_id, + item_changes: ^person_change + } = version + + assert is_nil(first(Person, :id) |> repo().one) + end + # Multi tenant tests test "[multi tenant] creating a company creates a company version with correct attributes" do tenant = MultiTenant.tenant() @@ -791,7 +902,9 @@ defmodule PaperTrailTest.SimpleModeBangFunctions do gender: true, visit_count: nil, birthdate: nil, - company_id: second_company.id + company_id: second_company.id, + plural: [], + singular: nil } assert Map.drop(version, [:id, :inserted_at]) == %{ @@ -863,7 +976,9 @@ defmodule PaperTrailTest.SimpleModeBangFunctions do visit_count: 10, birthdate: ~D[1992-04-01], last_name: "Nakri", - gender: true + gender: true, + plural: [], + singular: nil } assert Map.drop(version, [:id, :inserted_at]) == %{ @@ -955,7 +1070,9 @@ defmodule PaperTrailTest.SimpleModeBangFunctions do gender: true, visit_count: 10, birthdate: ~D[1992-04-01], - company_id: inserted_target_company.id + company_id: inserted_target_company.id, + plural: [], + singular: nil }), originator_id: nil, origin: "admin", @@ -989,7 +1106,7 @@ defmodule PaperTrailTest.SimpleModeBangFunctions do |> PaperTrail.insert!(opts_with_prefix) end - defp update_company_with_version(company, params \\ @update_company_params, options \\ nil) do + defp update_company_with_version(company, params \\ @update_company_params, options \\ []) do Company.changeset(company, params) |> PaperTrail.update!(options) end diff --git a/test/paper_trail/bang_functions_strict_mode_test.exs b/test/paper_trail/bang_functions_strict_mode_test.exs index 1d3af3a9..baa91e3f 100644 --- a/test/paper_trail/bang_functions_strict_mode_test.exs +++ b/test/paper_trail/bang_functions_strict_mode_test.exs @@ -1006,14 +1006,14 @@ defmodule PaperTrailTest.StrictModeBangFunctions do |> PaperTrail.insert!(opts_with_prefix) end - defp update_company_with_version(company, params \\ @update_company_params, options \\ nil) do + defp update_company_with_version(company, params \\ @update_company_params, options \\ []) do Company.changeset(company, params) |> PaperTrail.update!(options) end defp update_company_with_version_multi( company, params \\ @update_company_params, - options \\ nil + options \\ [] ) do opts_with_prefix = Keyword.put(options || [], :prefix, MultiTenant.tenant()) diff --git a/test/paper_trail/base_test.exs b/test/paper_trail/base_test.exs index bebb065b..de01e8b4 100644 --- a/test/paper_trail/base_test.exs +++ b/test/paper_trail/base_test.exs @@ -411,7 +411,9 @@ defmodule PaperTrailTest do gender: true, visit_count: nil, birthdate: nil, - company_id: new_company_result[:model].id + company_id: new_company_result[:model].id, + plural: [], + singular: nil } assert Map.drop(version, [:id, :inserted_at]) == %{ @@ -475,7 +477,9 @@ defmodule PaperTrailTest do visit_count: 10, birthdate: ~D[1992-04-01], last_name: "Nakri", - gender: true + gender: true, + plural: [], + singular: nil } assert Map.drop(version, [:id, :inserted_at]) == %{ @@ -557,7 +561,9 @@ defmodule PaperTrailTest do gender: true, visit_count: 10, birthdate: ~D[1992-04-01], - company_id: target_company_insertion[:model].id + company_id: target_company_insertion[:model].id, + plural: [], + singular: nil }, originator_id: nil, origin: "admin", diff --git a/test/support/simple_models.exs b/test/support/simple_models.exs index 8b1bf402..59cb75ed 100644 --- a/test/support/simple_models.exs +++ b/test/support/simple_models.exs @@ -97,6 +97,9 @@ defmodule SimplePerson do belongs_to(:company, SimpleCompany, foreign_key: :company_id) + embeds_one(:singular, SimpleEmbed) + embeds_many(:plural, SimpleEmbed) + timestamps() end @@ -113,6 +116,8 @@ defmodule SimplePerson do model |> cast(params, @optional_fields) |> foreign_key_constraint(:company_id) + |> cast_embed(:singular) + |> cast_embed(:plural) end def count do @@ -125,3 +130,18 @@ defmodule SimplePerson do |> PaperTrail.RepoClient.repo().one end end + +defmodule SimpleEmbed do + use Ecto.Schema + + import Ecto.Changeset + + embedded_schema do + field(:name, :string) + end + + def changeset(model, params \\ %{}) do + model + |> cast(params, [:name]) + end +end