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

Select fields option #154

Closed
Closed
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
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.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.
The keyword list is a depth of 2 representations 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)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Due to the recursive nature of EctoDiff, we'll be selecting these specific fields regardless of the nesting. So a :pet that is under [:foo] and :pet under [:bar, :baz] will both be filtered. What if the user only wants to filter the first pet but not the second?

Not a big fan of this PR, tbh. The point of EctoDiff is to show what changed between two structs. If the caller wants to ignore certain changes, they should be able to it after the diff has already been calculated.

We could expose some helper functions that operate on an existing EctoDiff in order to do that. For example, we can define a def drop_changes/1 function which takes an EctoDiff and drops any changes to non-association and non-embedded fields. We can also a drop_assoc_changes/3 and drop_embed_changes/3 which take the EctoDiff, the name of the field, and a function that takes an EctoDiff and returns and EctoDiff. This would give us a nice API for recursively dropping changes from EctoDiffs in a more granular manner.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the reason that this is a non-default behavior that has to be explicitly selected, and configured, and that EctoDiff does not change.
If you for some reason have the data structure:

%{
  bar: %{pet: %{...}},
  baz: %{bar: %{pet: %{...}}}
}

There is no way to only select one of the bar.pet instances. You can select both, everything, or none.
However if this is not the case, then you gain the ability of not selecting to diff a pet that you don't want so long as the parent's are different.


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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ Typo:

Suggested change
# embeds are limited to the embeded field selection, not embeded fields selection
# embeds are limited to the embedded field selection, not embedded 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
Loading