From 062c29cbbb823203f9a048e716eb1ffae2479db6 Mon Sep 17 00:00:00 2001 From: BAKER Date: Thu, 28 Sep 2023 15:28:32 -0400 Subject: [PATCH] allows diff only selected fields, inc tests --- README.md | 76 +++++++++++++++++++++++ lib/ecto_diff.ex | 66 +++++++++++++++++--- test/ecto_diff_test.exs | 132 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 267 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 75846b7..0f0edf9 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/lib/ecto_diff.ex b/lib/ecto_diff.ex index 5955413..7e51fec 100644 --- a/lib/ecto_diff.ex +++ b/lib/ecto_diff.ex @@ -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 """ @@ -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. """ @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/test/ecto_diff_test.exs b/test/ecto_diff_test.exs index 1c56419..1132deb 100644 --- a/test/ecto_diff_test.exs +++ b/test/ecto_diff_test.exs @@ -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