diff --git a/lib/ecto_anon.ex b/lib/ecto_anon.ex index a2ea55c..98ce723 100644 --- a/lib/ecto_anon.ex +++ b/lib/ecto_anon.ex @@ -4,7 +4,8 @@ defmodule EctoAnon do """ @doc """ - Updates an Ecto struct with anonymized data based on its anon_fields declared in the struct schema. + Updates an Ecto struct with anonymized data based on its anon_schema declaration. + It also updates any embedded schema declared in anon_schema. defmodule User do use Ecto.Schema @@ -31,10 +32,10 @@ defmodule EctoAnon do ## Options - * `:cascade` - When set to `true`, allows ecto-anon to preload and anonymize + * `:cascade` - When set to `true`, allows ecto_anon to preload and anonymize all associations (and associations of these associations) automatically in cascade. Could be used to anonymize all data related a struct in a single call. - Note that this won't traverse `belongs_to` associations to avoid infinite and cyclic anonymizations.. + Note that this won't traverse `belongs_to` associations to avoid infinite and cyclic anonymizations. Default: false * `:log`- When set to `true`, it will set `anonymized` field when EctoAnon.run diff --git a/lib/ecto_anon/anonymizer.ex b/lib/ecto_anon/anonymizer.ex index 857c1de..bcd7548 100644 --- a/lib/ecto_anon/anonymizer.ex +++ b/lib/ecto_anon/anonymizer.ex @@ -6,24 +6,61 @@ defmodule EctoAnon.Anonymizer do end end - defp anonymizable_struct?(%module{}), do: {:__anon_fields__, 0} in module.__info__(:functions) + defp anonymizable_struct?(%module{}) do + {:__anon_fields__, 0} in module.__info__(:functions) + end + + defp get_anonymized_data(struct) do + fields = anonymize_fields(struct) + embeds = anonymize_embeds(struct) - defp get_anonymized_data(%module{} = struct) do + %{fields: fields, embeds: embeds} + end + + defp anonymize_fields(%module{} = struct) do module.__anon_fields__() - |> Enum.reject(fn {field, _} -> is_association?(module, field) end) + |> Enum.reject(fn {field, _} -> + is_association?(module, field) or is_embed?(module, field) + end) |> Enum.reduce([], fn {field, {func, opts}}, acc -> type = module.__schema__(:type, field) value = case Map.get(struct, field) do - nil -> nil - value -> func.(type, value, opts) + nil -> + nil + + value -> + func.(type, value, opts) end Keyword.put(acc, field, value) end) end + defp anonymize_embeds(%module{} = struct) do + module.__anon_fields__() + |> Enum.filter(fn {field, _} -> is_embed?(module, field) end) + |> Enum.reduce([], fn {field, _}, acc -> + case Map.get(struct, field) do + nil -> acc + value -> Keyword.put(acc, field, anonymize_embed(value)) + end + end) + end + + defp anonymize_embed(field) when is_list(field) do + field + |> Enum.map(&anonymize_embed/1) + end + + defp anonymize_embed(field) do + data = anonymize_fields(field) + + field + |> Ecto.Changeset.change(data) + end + def is_association?(mod, association) do with association <- mod.__schema__(:association, association), false <- is_nil(association) do @@ -36,4 +73,17 @@ defmodule EctoAnon.Anonymizer do _ -> false end end + + def is_embed?(mod, field) do + case mod.__schema__(:type, field) do + {_, Ecto.Embedded, %Ecto.Embedded{}} -> + true + + {_, {_, Ecto.Embedded, %Ecto.Embedded{}}} -> + true + + _ -> + false + end + end end diff --git a/lib/ecto_anon/query.ex b/lib/ecto_anon/query.ex index 2c46d8b..e926602 100644 --- a/lib/ecto_anon/query.ex +++ b/lib/ecto_anon/query.ex @@ -1,6 +1,19 @@ defmodule EctoAnon.Query do import Ecto.Query + @spec apply(%{:fields => List.t(), :embeds => List.t()}, Ecto.Repo.t(), struct()) :: + {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} + def apply(%{fields: fields, embeds: embeds}, repo, struct) do + changeset = Ecto.Changeset.change(struct, fields) + + embeds + |> Enum.reduce(changeset, fn {field, data}, acc -> + acc + |> Ecto.Changeset.put_embed(field, data) + end) + |> repo.update() + end + @spec apply(keyword(), Ecto.Repo.t(), struct()) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} def apply(data, repo, struct) do diff --git a/mix.exs b/mix.exs index 3bed12a..de71aaa 100644 --- a/mix.exs +++ b/mix.exs @@ -30,6 +30,7 @@ defmodule EctoAnon.MixProject do [ {:ecto_sql, "~> 3.0"}, {:ecto_sqlite3, "~> 0.7.1", only: :test}, + {:jason, "~> 1.3", only: :test}, {:mix_test_watch, "~> 1.0", only: [:dev, :test], runtime: false}, {:ex_doc, "~> 0.27", only: :dev, runtime: false} ] diff --git a/mix.lock b/mix.lock index e292076..303f028 100644 --- a/mix.lock +++ b/mix.lock @@ -10,6 +10,7 @@ "ex_doc": {:hex, :ex_doc, "0.28.4", "001a0ea6beac2f810f1abc3dbf4b123e9593eaa5f00dd13ded024eae7c523298", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "bf85d003dd34911d89c8ddb8bda1a958af3471a274a4c2150a9c01c78ac3f8ed"}, "exqlite": {:hex, :exqlite, "0.11.2", "147414ff3790de24cb23941937ce909c9a3ad4b1d96e1510418af2709230f578", [:make, :mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "156f46ac206f56055043e4725f2139a5ec3c90bb668fc9a32edb4ad13fe71f14"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, + "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, diff --git a/test/db/priv/repo/migrations/20211213090654_create_users.exs b/test/db/priv/repo/migrations/20211213090654_create_users.exs index 4ba6583..a883c09 100644 --- a/test/db/priv/repo/migrations/20211213090654_create_users.exs +++ b/test/db/priv/repo/migrations/20211213090654_create_users.exs @@ -7,6 +7,8 @@ defmodule EctoAnon.Repo.Migrations.CreateUsers do add(:email, :string) add(:firstname, :string) add(:lastname, :string) + add(:favorite_quote, :map) + add(:quotes, {:array, :map}) add(:last_sign_in_at, :utc_datetime) anonymized() diff --git a/test/db/priv/repo/migrations/20220105150654_create_users_associations.exs b/test/db/priv/repo/migrations/20220105150654_create_users_associations.exs index edd4d3c..9db3c42 100644 --- a/test/db/priv/repo/migrations/20220105150654_create_users_associations.exs +++ b/test/db/priv/repo/migrations/20220105150654_create_users_associations.exs @@ -6,6 +6,7 @@ defmodule EctoAnon.Repo.Migrations.CreateUsersAssociations do create table(:comments) do add(:content, :string) add(:tag, :string) + add(:quote, :map) add(:author_id, references(:users)) anonymized() diff --git a/test/ecto_anon/anonymizer_test.exs b/test/ecto_anon/anonymizer_test.exs index 2fb2566..553c695 100644 --- a/test/ecto_anon/anonymizer_test.exs +++ b/test/ecto_anon/anonymizer_test.exs @@ -6,15 +6,21 @@ defmodule EctoAnon.AnonymizerTest do test "returns the struct with anonymized fields" do user = %User{email: "john.doe@example.com", firstname: "John", lastname: "Doe"} - assert {:ok, [lastname: "redacted", email: "redacted", last_sign_in_at: nil]} = - EctoAnon.Anonymizer.anonymized_data(user) + assert {:ok, + %{ + embeds: [quotes: []], + fields: [lastname: "redacted", email: "redacted", last_sign_in_at: nil] + }} = EctoAnon.Anonymizer.anonymized_data(user) end test "returns anonymized fields only for non-nil fields" do user = %User{email: "john.doe@example.com", lastname: nil} - assert {:ok, [lastname: nil, email: "redacted", last_sign_in_at: nil]} = - EctoAnon.Anonymizer.anonymized_data(user) + assert {:ok, + %{ + embeds: [quotes: []], + fields: [lastname: nil, email: "redacted", last_sign_in_at: nil] + }} = EctoAnon.Anonymizer.anonymized_data(user) end end end diff --git a/test/ecto_anon/schema_test.exs b/test/ecto_anon/schema_test.exs index 02d531e..e95bc4b 100644 --- a/test/ecto_anon/schema_test.exs +++ b/test/ecto_anon/schema_test.exs @@ -41,6 +41,8 @@ defmodule EctoAnon.SchemaTest do test "returns a list of {field, function} tuples" do assert [ last_sign_in_at: {&EctoAnon.Functions.AnonymizedDate.run/3, [:only_year]}, + quotes: {&EctoAnon.Functions.Default.run/3, []}, + favorite_quote: {&EctoAnon.Functions.Default.run/3, []}, followers: {&EctoAnon.Functions.Default.run/3, []}, email: {&EctoAnon.Functions.Default.run/3, []}, lastname: {&EctoAnon.Functions.Default.run/3, []} diff --git a/test/ecto_anon_test.exs b/test/ecto_anon_test.exs index 793dd07..459c584 100644 --- a/test/ecto_anon_test.exs +++ b/test/ecto_anon_test.exs @@ -1,6 +1,6 @@ defmodule EctoAnonTest do use ExUnit.Case, async: true - alias EctoAnon.{Repo, User, Comment} + alias EctoAnon.{Repo, User, Comment, User.Quote} defmodule UnknownStruct do defstruct name: "John", age: 27 @@ -14,7 +14,21 @@ defmodule EctoAnonTest do firstname: "Mick", lastname: "Rogers", last_sign_in_at: ~U[2021-01-03 00:00:00Z], - followers: [] + followers: [], + favorite_quote: %Quote{ + quote: "this is a quote", + author: "author" + }, + quotes: [ + %Quote{ + quote: "this is a quote", + author: "author" + }, + %Quote{ + quote: "this is a quote", + author: "author" + } + ] } |> Repo.insert!() @@ -24,7 +38,9 @@ defmodule EctoAnonTest do firstname: "Fred", lastname: "Duncan", last_sign_in_at: ~U[2023-03-20 00:00:00Z], - followers: [] + followers: [], + favorite_quote: nil, + quotes: [] } |> Repo.insert!() @@ -34,7 +50,21 @@ defmodule EctoAnonTest do firstname: "Emilie", lastname: "Duncan", last_sign_in_at: ~U[2018-09-04 00:00:00Z], - followers: [fred] + followers: [fred], + favorite_quote: %Quote{ + quote: "this is a quote", + author: "author" + }, + quotes: [ + %Quote{ + quote: "this is a quote", + author: "author" + }, + %Quote{ + quote: "this is a quote", + author: "author" + } + ] } |> Repo.insert!() @@ -44,7 +74,21 @@ defmodule EctoAnonTest do firstname: "John", lastname: "Doe", last_sign_in_at: ~U[2022-05-04 00:00:00Z], - followers: [mick, emilie] + followers: [mick, emilie], + favorite_quote: %Quote{ + quote: "this is a quote", + author: "author" + }, + quotes: [ + %Quote{ + quote: "this is a quote", + author: "author" + }, + %Quote{ + quote: "this is a quote", + author: "author" + } + ] } |> Repo.insert!() @@ -76,44 +120,86 @@ defmodule EctoAnonTest do mick: mick, emilie: emilie } do - assert {:ok, updated_user} = - Repo.get(User, user.id) - |> EctoAnon.run(Repo, cascade: true) + assert {:ok, updated_user} = EctoAnon.run(user, Repo, cascade: true) assert %User{ email: "redacted", firstname: "John", last_sign_in_at: ~U[2022-01-01 00:00:00Z], - lastname: "redacted" + lastname: "redacted", + favorite_quote: %Quote{ + quote: "redacted", + author: "redacted" + }, + quotes: [ + %Quote{ + quote: "redacted", + author: "redacted" + }, + %Quote{ + quote: "redacted", + author: "redacted" + } + ] } = updated_user - %Comment{ - content: "this is a comment", - tag: "tag" - } = Repo.get_by(Comment, author_id: user.id) + assert %Comment{ + content: "this is a comment", + tag: "tag" + } = Repo.get_by(Comment, author_id: user.id) - %User{ - email: "redacted", - firstname: "Mick", - last_sign_in_at: ~U[2021-01-01 00:00:00Z], - lastname: "redacted", - followers: [] - } = Repo.get(User, mick.id) |> Repo.preload(:followers) + assert %User{ + email: "redacted", + firstname: "Mick", + last_sign_in_at: ~U[2021-01-01 00:00:00Z], + lastname: "redacted", + followers: [], + favorite_quote: %Quote{ + quote: "redacted", + author: "redacted" + }, + quotes: [ + %Quote{ + quote: "redacted", + author: "redacted" + }, + %Quote{ + quote: "redacted", + author: "redacted" + } + ] + } = Repo.get(User, mick.id) |> Repo.preload(:followers) - %User{ - email: "redacted", - firstname: "Emilie", - last_sign_in_at: ~U[2018-01-01 00:00:00Z], - lastname: "redacted", - followers: [ - %User{ - email: "redacted", - firstname: "Fred", - last_sign_in_at: ~U[2023-01-01 00:00:00Z], - lastname: "redacted" - } - ] - } = Repo.get(User, emilie.id) |> Repo.preload(:followers) + assert %User{ + email: "redacted", + firstname: "Emilie", + last_sign_in_at: ~U[2018-01-01 00:00:00Z], + lastname: "redacted", + favorite_quote: %Quote{ + quote: "redacted", + author: "redacted" + }, + followers: [ + %User{ + email: "redacted", + firstname: "Fred", + last_sign_in_at: ~U[2023-01-01 00:00:00Z], + lastname: "redacted", + favorite_quote: nil, + quotes: [] + } + ], + quotes: [ + %Quote{ + quote: "redacted", + author: "redacted" + }, + %Quote{ + quote: "redacted", + author: "redacted" + } + ] + } = Repo.get(User, emilie.id) |> Repo.preload(:followers) end end end diff --git a/test/support/comment.ex b/test/support/comment.ex index 8cda26f..fb6b1c4 100644 --- a/test/support/comment.ex +++ b/test/support/comment.ex @@ -11,6 +11,7 @@ defmodule EctoAnon.Comment do schema "comments" do field(:content, :string) field(:tag, :string) + belongs_to(:users, User, foreign_key: :author_id) end end diff --git a/test/support/user.ex b/test/support/user.ex index 4578545..fb3c99a 100644 --- a/test/support/user.ex +++ b/test/support/user.ex @@ -8,6 +8,8 @@ defmodule EctoAnon.User do :lastname, :email, :followers, + :favorite_quote, + :quotes, last_sign_in_at: [:anonymized_date, options: [:only_year]] ]) @@ -18,6 +20,8 @@ defmodule EctoAnon.User do field(:last_sign_in_at, :utc_datetime) has_many(:comments, Comment, foreign_key: :author_id, references: :id) + embeds_one(:favorite_quote, EctoAnon.User.Quote) + embeds_many(:quotes, EctoAnon.User.Quote) many_to_many( :followers, @@ -30,6 +34,21 @@ defmodule EctoAnon.User do end end +defmodule EctoAnon.User.Quote do + use Ecto.Schema + use EctoAnon.Schema + + anon_schema([ + :quote, + :author + ]) + + embedded_schema do + field(:quote, :string) + field(:author, :string) + end +end + defmodule EctoAnon.User.Follower do use Ecto.Schema