Skip to content

Commit

Permalink
Add embed support (#12)
Browse files Browse the repository at this point in the history
* wip: adding embbed anonymization

* feat: added embeds support
  • Loading branch information
quaresc authored Jun 30, 2022
1 parent 0815093 commit b599127
Show file tree
Hide file tree
Showing 12 changed files with 229 additions and 46 deletions.
7 changes: 4 additions & 3 deletions lib/ecto_anon.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
60 changes: 55 additions & 5 deletions lib/ecto_anon/anonymizer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
13 changes: 13 additions & 0 deletions lib/ecto_anon/query.ex
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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}
]
Expand Down
1 change: 1 addition & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
2 changes: 2 additions & 0 deletions test/db/priv/repo/migrations/20211213090654_create_users.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
14 changes: 10 additions & 4 deletions test/ecto_anon/anonymizer_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,21 @@ defmodule EctoAnon.AnonymizerTest do
test "returns the struct with anonymized fields" do
user = %User{email: "[email protected]", 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: "[email protected]", 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
2 changes: 2 additions & 0 deletions test/ecto_anon/schema_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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, []}
Expand Down
154 changes: 120 additions & 34 deletions test/ecto_anon_test.exs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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!()

Expand All @@ -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!()

Expand All @@ -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!()

Expand All @@ -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!()

Expand Down Expand Up @@ -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
Loading

0 comments on commit b599127

Please sign in to comment.