Skip to content

Commit

Permalink
feat: added cascade option (#9)
Browse files Browse the repository at this point in the history
* feat: added cascade option

Signed-off-by: Clément Quaresma <[email protected]>

* fix: added Ecto.Association.HasThrough

Signed-off-by: Clément Quaresma <[email protected]>

* Update lib/ecto_anon.ex

Co-authored-by: Thomas Battiston <[email protected]>

* test: renamed relationship for follower

* feat: moved from custom field to config macro

* feat: added associations in config for cascade

* feat: renamed anon_config to anon_schema

* feat: changed how custom function is called

* feat: PR comment

Co-authored-by: Thomas Battiston <[email protected]>
  • Loading branch information
quaresc and Thomas Battiston authored May 23, 2022
1 parent 1e20ff1 commit 24d3b13
Show file tree
Hide file tree
Showing 8 changed files with 252 additions and 40 deletions.
58 changes: 51 additions & 7 deletions lib/ecto_anon.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,51 @@ defmodule EctoAnon do

@doc """
Updates an Ecto struct with anonymized data based on its anon_fields declared in the struct schema.
defmodule User do
use Ecto.Schema
use EctoAnon
use EctoAnon.Schema
anon_schema [
:firstname,
email: &__MODULE__.anonymized_email/3
birthdate: [:anonymized_date, options: [:only_year]]
]
schema "user" do
# ... fields ...
anon_field :email, :string
field :fistname, :string
field :email, :string
field :birthdate, :utc_datetime
end
def anonymized_email(_type, _value, _opts) do
"[email protected]"
end
end
It returns {:ok, struct} if the struct has been successfully updated or {:error, :non_anonymizable_struct} if the struct has no anonymizable fields.
It returns `{:ok, struct}` if the struct has been successfully updated or `{:error, :non_anonymizable_struct}` if the struct has no anonymizable fields.
## Options
* `: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.
## Example
defmodule User do
use Ecto.Schema
use EctoAnon
use EctoAnon.Schema
anon_schema [
:email
]
schema "users" do
field :name, :string
field :age, :integer, default: 0
anon_field :email, :string
field :email, :string
end
end
Expand All @@ -41,7 +64,28 @@ defmodule EctoAnon do
"""
@spec run(struct(), Ecto.Repo.t(), keyword()) ::
{:ok, Ecto.Schema.t()} | {:error, :non_anonymizable_struct}
def run(struct, repo, _opts \\ []) do

def run(struct, repo, _opts \\ [])

def run(struct, _repo, _opts) when struct in [[], nil], do: {:error, :non_anonymizable_struct}
def run(struct, repo, opts) when is_list(struct), do: Enum.each(struct, &run(&1, repo, opts))

def run(%mod{} = struct, repo, cascade: true) do
anon_fields = mod.__anon_fields__() |> Enum.map(fn {field, _} -> field end)

associations =
mod.__schema__(:associations)
|> Enum.filter(&(&1 in anon_fields and EctoAnon.Anonymizer.is_association?(mod, &1)))

struct = repo.preload(struct, associations)

associations
|> Enum.each(&run(Map.get(struct, &1), repo, cascade: true))

run(struct, repo)
end

def run(struct, repo, _opts) do
case EctoAnon.Anonymizer.anonymized_data(struct) do
{:ok, data} -> EctoAnon.Query.run(data, repo, struct)
{:error, error} -> {:error, error}
Expand Down
14 changes: 14 additions & 0 deletions lib/ecto_anon/anonymizer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ defmodule EctoAnon.Anonymizer do

defp get_anonymized_data(%module{} = struct) do
module.__anon_fields__()
|> Enum.reject(fn {field, _} -> is_association?(module, field) end)
|> Enum.reduce([], fn {field, {func, opts}}, acc ->
type = module.__schema__(:type, field)

Expand All @@ -22,4 +23,17 @@ defmodule EctoAnon.Anonymizer do
Keyword.put(acc, field, value)
end)
end

def is_association?(mod, association) do
with association <- mod.__schema__(:association, association),
false <- is_nil(association) do
association.__struct__ in [
Ecto.Association.Has,
Ecto.Association.ManyToMany,
Ecto.Association.HasThrough
]
else
_ -> false
end
end
end
49 changes: 26 additions & 23 deletions lib/ecto_anon/schema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,8 @@ defmodule EctoAnon.Schema do
defmacro __using__(_) do
quote do
Module.register_attribute(__MODULE__, :anon_fields, accumulate: true)

import unquote(__MODULE__)

@before_compile unquote(__MODULE__)
import unquote(__MODULE__)
end
end

Expand All @@ -18,34 +16,39 @@ defmodule EctoAnon.Schema do
end
end

@doc """
Define an anonymizable field in your Ecto schema. Use it as a replacement of Ecto.Schema.field/3
"""
defmacro anon_field(name, type \\ :string, opts \\ []) do
defmacro anon_schema(fields_config) do
quote do
EctoAnon.Schema.__anon_field__(__MODULE__, unquote(name), unquote(type), unquote(opts))
EctoAnon.Schema.__anon_schema__(__MODULE__, unquote(fields_config))
end
end

def __anon_field__(mod, name, type, opts) do
anon_with = Keyword.get(opts, :anon_with) |> anon_with()
ecto_opts = Keyword.delete(opts, :anon_with)

Ecto.Schema.__field__(mod, name, type, ecto_opts)

Module.put_attribute(mod, :anon_fields, {name, anon_with})
def __anon_schema__(mod, fields_config) do
fields_config
|> Enum.each(&Module.put_attribute(mod, :anon_fields, anon_with(&1)))
end

defp anon_with(nil), do: {EctoAnon.Functions.get_function(:default), []}
defp anon_with(field) when is_atom(field),
do: {field, {EctoAnon.Functions.get_function(:default), []}}

defp anon_with(function) when is_atom(function),
do: {EctoAnon.Functions.get_function(function), []}
defp anon_with({field, function}) when is_atom(field) and is_atom(function),
do: {field, {EctoAnon.Functions.get_function(function), []}}

defp anon_with(function) when is_function(function), do: {function, []}
defp anon_with({field, function}) when is_atom(field) and is_function(function),
do: {field, {function, []}}

defp anon_with({function, opts}) when is_atom(function) and is_list(opts),
do: {EctoAnon.Functions.get_function(function), opts}
defp anon_with({field, [function | opts]}) when is_atom(field) do
function =
cond do
is_atom(function) -> EctoAnon.Functions.get_function(function)
is_function(function) -> function
end

defp anon_with({function, opts}) when is_function(function) and is_list(opts),
do: {function, opts}
params =
case Keyword.get(opts, :options) do
options when options in [nil, []] -> []
options -> options
end

{field, {function, params}}
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
defmodule EctoAnon.Repo.Migrations.CreateUsersAssociations do
use Ecto.Migration

def change do
create table(:comments) do
add(:content, :string)
add(:tag, :string)
add(:author_id, references(:users))
end

create table(:followers) do
add(:follower_id, references(:users))
add(:followee_id, references(:users))
timestamps()
end
end
end
15 changes: 11 additions & 4 deletions test/ecto_anon/schema_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,17 @@ defmodule EctoAnon.SchemaTest do
use Ecto.Schema
use EctoAnon.Schema

anon_schema([
:lastname,
email: :anonymized_email,
phone: &__MODULE__.custom_phone/3
])

schema "users" do
field(:firstname, :string)
anon_field(:lastname, :string)
anon_field(:email, :string, anon_with: :anonymized_email)
anon_field(:phone, :string, anon_with: &__MODULE__.custom_phone/3, virtual: true)
field(:lastname, :string)
field(:email, :string)
field(:phone, :string, virtual: true)
end

def custom_phone(_type, _value, _opts) do
Expand All @@ -31,7 +37,8 @@ defmodule EctoAnon.SchemaTest do
describe "__anon_fields__/0" do
test "returns a list of {field, function} tuples" do
assert [
{:last_sign_in_at, {&EctoAnon.Functions.AnonymizedDate.run/3, [:only_year]}},
last_sign_in_at: {&EctoAnon.Functions.AnonymizedDate.run/3, [:only_year]},
followers: {&EctoAnon.Functions.Default.run/3, []},
email: {&EctoAnon.Functions.Default.run/3, []},
lastname: {&EctoAnon.Functions.Default.run/3, []}
] == User.__anon_fields__()
Expand Down
89 changes: 86 additions & 3 deletions test/ecto_anon_test.exs
Original file line number Diff line number Diff line change
@@ -1,23 +1,61 @@
defmodule EctoAnonTest do
use ExUnit.Case, async: true
alias EctoAnon.{Repo, User}
alias EctoAnon.{Repo, User, Comment}

defmodule UnknownStruct do
defstruct name: "John", age: 27
end

describe "run/3" do
setup do
mick =
%User{
email: "[email protected]",
firstname: "Mick",
lastname: "Rogers",
last_sign_in_at: ~U[2021-01-03 00:00:00Z],
followers: []
}
|> Repo.insert!()

fred =
%User{
email: "[email protected]",
firstname: "Fred",
lastname: "Duncan",
last_sign_in_at: ~U[2023-03-20 00:00:00Z],
followers: []
}
|> Repo.insert!()

emilie =
%User{
email: "[email protected]",
firstname: "Emilie",
lastname: "Duncan",
last_sign_in_at: ~U[2018-09-04 00:00:00Z],
followers: [fred]
}
|> Repo.insert!()

user =
%User{
email: "[email protected]",
firstname: "John",
lastname: "Doe",
last_sign_in_at: ~U[2022-05-04 00:00:00Z]
last_sign_in_at: ~U[2022-05-04 00:00:00Z],
followers: [mick, emilie]
}
|> Repo.insert!()

{:ok, user: user}
%Comment{
content: "this is a comment",
tag: "tag",
author_id: user.id
}
|> Repo.insert!()

{:ok, user: user, mick: mick, emilie: emilie}
end

test "with struct with anonymizable fields, should return anonymized struct", %{user: user} do
Expand All @@ -32,5 +70,50 @@ defmodule EctoAnonTest do
test "with non-ecto struct, should return an error" do
assert {:error, _error} = EctoAnon.run(%UnknownStruct{}, Repo)
end

test "with cascade option, should anonymize struct and associations", %{
user: user,
mick: mick,
emilie: emilie
} do
assert {:ok, updated_user} =
Repo.get(User, user.id)
|> EctoAnon.run(Repo, cascade: true)

assert %User{
email: "redacted",
firstname: "John",
last_sign_in_at: ~U[2022-01-01 00:00:00Z],
lastname: "redacted"
} = updated_user

%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)

%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)
end
end
end
16 changes: 16 additions & 0 deletions test/support/comment.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
defmodule EctoAnon.Comment do
use Ecto.Schema
use EctoAnon.Schema

alias EctoAnon.User

anon_schema([
:content
])

schema "comments" do
field(:content, :string)
field(:tag, :string)
belongs_to(:users, User, foreign_key: :author_id)
end
end
Loading

0 comments on commit 24d3b13

Please sign in to comment.