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

Add embed support #12

Merged
merged 3 commits into from
Jun 30, 2022
Merged
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
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
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