Skip to content

Commit

Permalink
allows diff only selected fields, inc tests
Browse files Browse the repository at this point in the history
  • Loading branch information
leftstanding committed Sep 28, 2023
1 parent a59c906 commit 062c29c
Show file tree
Hide file tree
Showing 3 changed files with 267 additions and 7 deletions.
76 changes: 76 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,4 +135,80 @@ iex> EctoDiff.diff(initial_pet, updated_pet)
>}
```

#### Returning Select Fields

To limit processing and returning the fields and associations for the diff the `:select_fields` option may be provided a keyword list of desired fields.

Any field not in `:select_fields` will be excluded from the diff.
```elixir
iex> EctoDiff.diff(initial_pet, updated_pet, select_fields: [pets: [:name, :id]])

{:ok,
#EctoDiff<
struct: Pet,
primary_key: %{id: 2},
effect: :changed,
previous: #Pet<>,
current: #Pet<>,
changes: %{
name: {"Spot", "Spots"},
}
>
}
```

The keyword list is a depth of 2 representation of the field and subfields for each selected field. If a field is given as a key, with subfields given as the values but is not provided as the appropriate value in a parent key it will be excluded.
```elixir
iex> EctoDiff.diff(initial_pet, updated_pet, select_fields: [pets: [:name, :id], skills: [:level]])

{:ok,
#EctoDiff<
struct: Pet,
primary_key: %{id: 2},
effect: :changed,
previous: #Pet<>,
current: #Pet<>,
changes: %{
name: {"Spot", "Spots"},
}
>
}

iex> EctoDiff.diff(initial_pet, updated_pet, select_fields: [pets: [:name, :id, :skills], skills: [:level]])

{:ok,
#EctoDiff<
struct: Pet,
primary_key: %{id: 2},
effect: :changed,
previous: #Pet<>,
current: #Pet<>,
changes: %{
name: {"Spot", "Spots"},
skills: [
#EctoDiff<
struct: Skill,
primary_key: %{id: 5},
effect: :changed,
previous: #Skill<>,
current: #Skill<>,
changes: %{level: {1, 2}}
>,
]
}
>
}
```

#### Using `:all`
If `:all` is provided then all fields will be included. At the top level `:all` returns the same result as not using the `:select_fields` option. When used as the value for a key `:all` returns all fields for that key.

For example, the following will return all fields for `Pet` and `Toys`, and only `id` for `Resources`
```elixir
select_fields: [pets: [:all], resources: [:id, :toys], toys: [:all]]
```

#### Default Behavior
When the `:select_fields` option is not provided all fields are returned. This is equal to using `select_fields: :all`.

Detailed documentation can be found at [https://hexdocs.pm/ecto_diff](https://hexdocs.pm/ecto_diff).
66 changes: 59 additions & 7 deletions lib/ecto_diff.ex
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,13 @@ defmodule EctoDiff do
* `:overrides` - A keyword list or map which provides a reference from a struct
to a key (or list of keys) on that struct which will be used as the primary key
(simple or composite) for diffing.
* `:select_fields` - A keyword list to select fields for diffing.
This is useful when you only want to diff a subset of fields on a struct.
"""
@type diff_opts :: [
overrides: overrides
overrides: overrides,
select_fields: select_fields
]

@typedoc """
Expand All @@ -81,6 +85,26 @@ defmodule EctoDiff do
"""
@type overrides :: [{module, primary_key}] | %{module => primary_key}

@typedoc """
A keyword list specifying a subset of fields to diff on a struct, or associated struct.
Fields that are not specified will not be diffed, or returned.
Use `:all` to explicitly specify all fields. This is equivalent to not providing a `:select_fields` option.
Use `[field: [:all]]` to specify all fields on a given struct.
Selecting an association and keys without selecting that association field in the parent fields
results in the associated struct being excluded, as it is not selected.
Note on Embedded Schemas: fields within an embeds are all or nothing. You cannot select individual fields
## Examples:
[pet: [:name, :type, :skill], skill: [:all]]
"""

@type select_fields :: [{struct_field, :all} | {struct_field, [struct_field]}] | :all

@typedoc """
A struct field or list of fields used to define a simple or composite primary key.
"""
Expand Down Expand Up @@ -248,7 +272,7 @@ defmodule EctoDiff do
defp do_diff(%struct{} = previous, %struct{} = current, opts) do
primary_key_fields = get_primary_key_fields(struct, opts)

field_changes = fields(previous, current)
field_changes = fields(previous, current, opts)

changes =
field_changes
Expand Down Expand Up @@ -276,10 +300,12 @@ defmodule EctoDiff do
}
end

defp fields(%struct{} = previous, %struct{} = current) do
field_names = struct.__schema__(:fields) ++ (struct.__schema__(:virtual_fields) -- struct.__schema__(:embeds))
defp fields(%struct{} = previous, %struct{} = current, opts) do
all_fields = struct.__schema__(:fields) ++ (struct.__schema__(:virtual_fields) -- struct.__schema__(:embeds))

field_names
selected_fields = if_only_select_fields(all_fields, current, opts)

selected_fields
|> Enum.reduce([], &field(previous, current, &1, &2))
|> Map.new()
end
Expand All @@ -295,10 +321,34 @@ defmodule EctoDiff do
end
end

defp if_only_select_fields(fields, %struct{}, opts) do
source = struct.__schema__(:source)
select_fields = opts[:select_fields]

if_only_select_fields(fields, source, select_fields)
end

defp if_only_select_fields(fields, source, select_fields) when is_nil(source) or is_nil(select_fields),
do: fields

defp if_only_select_fields(fields, _source, :all), do: fields

defp if_only_select_fields(fields, source, select_fields) do
key = String.to_atom(source)

case select_fields[key] do
[:all] -> fields
[_ | _] = selected_fields -> Enum.filter(fields, &(&1 in selected_fields))
_ -> []
end
end

defp embeds(%struct{} = previous, %struct{} = current, opts) do
embed_names = struct.__schema__(:embeds)

embed_names
selected_embeds = if_only_select_fields(embed_names, current, opts)

selected_embeds
|> Enum.reduce([], &embed(previous, current, &1, &2, opts))
|> Map.new()
end
Expand All @@ -319,7 +369,9 @@ defmodule EctoDiff do
defp associations(%struct{} = previous, %struct{} = current, opts) do
association_names = struct.__schema__(:associations)

association_names
selected_associations = if_only_select_fields(association_names, current, opts)

selected_associations
|> Enum.reduce([], &association(previous, current, &1, &2, opts))
|> Map.new()
end
Expand Down
132 changes: 132 additions & 0 deletions test/ecto_diff_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,138 @@ defmodule EctoDiffTest do
}
} = diff
end

test "can limit return to selected fields when select_fields specified in opts" do
{:ok, pet} =
%{
name: "Spot",
skills: [%{name: "Eating"}, %{name: "Sleeping"}],
owner: %{name: "Samuel"},
details: %{description: "It's a kitty!"},
resources: [%{toys: [%{name: "Ball", type: "Toy", quantity: 3}, %{name: "Bone", type: "Treat", quantity: 2}]}]
}
|> Pet.new()
|> Repo.insert()

pet = Repo.preload(pet, [:resources, :toys])
pet_id = pet.id
details_id = pet.details.id
[%{id: resource_id}] = pet.resources
[ball_id, bone_id] = Enum.map(pet.toys, & &1.id)

only_pet = [pets: [:name, :id]]
{:ok, pet_diff} = EctoDiff.diff(nil, pet, select_fields: only_pet)
assert %{id: {nil, pet_id}, name: {nil, "Spot"}} == pet_diff.changes

# embeds are limited to the embeded field selection, not embeded fields selection
# e.g. [pets: [:details]], [pets: [:details], details: [:all]], [pets: [:details], details: [:id]]
# will all produce the same result
only_details = [pets: [:details]]
{:ok, details_diff} = EctoDiff.diff(nil, pet, select_fields: only_details)

assert %{description: {nil, "It's a kitty!"}, id: {nil, details_id}} == details_diff.changes.details.changes
assert Map.keys(details_diff.changes) == [:details]

only_toys = [pets: [:toys], toys: [:all]]
{:ok, toys_diff} = EctoDiff.diff(nil, pet, select_fields: only_toys)

assert %{
toys: [
%EctoDiff{
changes: %{name: {nil, "Ball"}, quantity: {1, 3}, type: {nil, "Toy"}},
effect: :added,
primary_key: %{id: ^ball_id}
},
%EctoDiff{
changes: %{name: {nil, "Bone"}, quantity: {1, 2}, type: {nil, "Treat"}},
effect: :added,
primary_key: %{id: ^bone_id}
}
]
} = toys_diff.changes

assert Map.keys(toys_diff.changes) == [:toys]

only_pet_resources_and_toys = [
pets: [:name, :id, :resources],
toys: [:name, :type, :quantity],
resources: [:id, :toys]
]

{:ok, pet_resources_and_toys_diff} = EctoDiff.diff(nil, pet, select_fields: only_pet_resources_and_toys)

assert %{
id: {nil, ^pet_id},
name: {nil, "Spot"},
resources: [
%EctoDiff{
changes: %{
id: {nil, ^resource_id},
toys: [
%EctoDiff{
changes: %{name: {nil, "Ball"}, quantity: {1, 3}, type: {nil, "Toy"}},
effect: :added,
primary_key: %{id: ^ball_id}
},
%EctoDiff{
changes: %{name: {nil, "Bone"}, quantity: {1, 2}, type: {nil, "Treat"}},
effect: :added,
primary_key: %{id: ^bone_id}
}
]
},
effect: :added,
primary_key: %{id: ^resource_id}
}
]
} = pet_resources_and_toys_diff.changes

[resources] = pet_resources_and_toys_diff.changes.resources
assert Map.keys(pet_resources_and_toys_diff.changes) == [:id, :name, :resources]
assert Map.keys(resources.changes) == [:id, :toys]
end

test ":all arg in :select_fields returns all fields" do
{:ok, pet} =
%{
name: "Spot",
skills: [%{name: "Eating"}, %{name: "Sleeping"}],
owner: %{name: "Samuel"},
details: %{description: "It's a kitty!"},
resources: [%{toys: [%{name: "Ball", type: "Toy", quantity: 3}, %{name: "Bone", type: "Treat", quantity: 2}]}]
}
|> Pet.new()
|> Repo.insert()

pet = Repo.preload(pet, [:resources, :toys])

explicit_all = [pets: [:all], skills: [:all], owners: [:all], details: [:all], resources: [:all], toys: [:all]]
implicit_all = :all
{:ok, explicit_all_diff} = EctoDiff.diff(nil, pet, select_fields: explicit_all)
{:ok, implicit_all_diff} = EctoDiff.diff(nil, pet, select_fields: implicit_all)
{:ok, default_all_diff} = EctoDiff.diff(nil, pet)

assert explicit_all_diff == implicit_all_diff
assert explicit_all_diff == default_all_diff

assert Map.keys(explicit_all_diff.changes) == [
:details,
:id,
:name,
:owner,
:owner_id,
:refid,
:resources,
:skills,
:toys
]

{:ok, pets_only_diff} = EctoDiff.diff(nil, pet, select_fields: [pets: [:all]])
assert Map.keys(pets_only_diff.changes) == [:details, :id, :name, :owner_id, :refid]

{:ok, toys_only_diff} = EctoDiff.diff(nil, pet, select_fields: [pets: [:toys], toys: [:all]])
assert Map.keys(toys_only_diff.changes) == [:toys]
end
end

describe "diff/2" do
Expand Down

0 comments on commit 062c29c

Please sign in to comment.