Skip to content

Commit

Permalink
Merge pull request #148 from peek-travel/add-match-key-opt
Browse files Browse the repository at this point in the history
Add override option to `diff/3`
  • Loading branch information
vanvoljg authored Apr 7, 2022
2 parents 933d4e2 + 2371691 commit 23ea5a3
Show file tree
Hide file tree
Showing 13 changed files with 343 additions and 56 deletions.
25 changes: 24 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,28 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

- Nothing yet

## [0.4.0][]

### Added

- Added `EctoDiff.diff/3` to allow options to be specified when performing a diff.
- Added `:overrides` as an option for diffing. This option allows specification of an alternate primary key for a specific struct in a diff. Unspecified structs will use their default primary key.

### Updated

- Dependencies:

- credo 1.6.3 => 1.6.4
- db_connection 2.4.1 => 2.4.2
- dialyzer 1.0.0-rc.5 => 1.1.0
- earmark_parser 1.4.19 => 1.4.25
- ecto 3.7.1 => 3.7.2
- ex_doc 0.28.0 => 0.28.3
- makeup 1.0.5 => 1.1.0
- makeup_elixir 0.15.2 => 0.16.0 (minor)
- nimble_parsec 1.2.1 => 1.2.3
- postgrex 0.16.1 => 0.16.2

## [0.3.0][] - 2022-02-09

### Added
Expand Down Expand Up @@ -49,7 +71,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

### Initial release

[Unreleased]: https://github.com/peek-travel/ecto_diff/compare/0.3.0...HEAD
[Unreleased]: https://github.com/peek-travel/ecto_diff/compare/0.4.0...HEAD
[0.4.0]: https://github.com/peek-travel/ecto_diff/compare/0.3.0...0.4.0
[0.3.0]: https://github.com/peek-travel/ecto_diff/compare/0.2.2...0.3.0
[0.2.2]: https://github.com/peek-travel/ecto_diff/compare/0.2.1...0.2.2
[0.2.1]: https://github.com/peek-travel/ecto_diff/compare/0.2.0...0.2.1
Expand Down
2 changes: 1 addition & 1 deletion config/config.exs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use Mix.Config
import Config

config :ecto_diff, ecto_repos: [EctoDiff.Repo]

Expand Down
165 changes: 127 additions & 38 deletions lib/ecto_diff.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ defmodule EctoDiff do
@typedoc """
The type of change for a given struct.
Each `t:EctoDiff.t/0` struct will have a field describing what happened to the given ecto struct. The values can be one of
the following:
Each `t:EctoDiff.t/0` struct will have a field describing what happened to the given Ecto schema struct.
The values can be one of the following:
* `:added` - This struct is new and was not present previously. This happens when the primary struct, or an associated
struct, was added during an update or insert.
Expand Down Expand Up @@ -45,13 +45,52 @@ defmodule EctoDiff do
"""
@type t :: %__MODULE__{
struct: atom(),
primary_key: %{required(atom()) => any()},
changes: %{required(atom()) => any()},
primary_key: %{required(struct_field) => any()},
changes: %{required(struct_field) => any()},
effect: effect(),
previous: Ecto.Schema.t(),
current: Ecto.Schema.t()
}

@typedoc """
Configurable options for `diff/3`.
## Options
* `: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.
"""
@type diff_opts :: [
overrides: overrides
]

@typedoc """
A keyword list or a map which specifies an override from an Ecto schema to the desired
primary key, for use in comparing structs.
Structs that are not specified will be compared using their default primary key.
## Examples:
[{Pet, :refid}, {Skill, :id}]
or
%{Skill => :refid, Owner => [:id, :refid]}
"""
@type overrides :: [{module, primary_key}] | %{module => primary_key}

@typedoc """
A struct field or list of fields used to define a simple or composite primary key.
"""
@type primary_key :: struct_field | [struct_field]

@typedoc """
A field defined on a struct.
"""
@type struct_field :: atom

defstruct [:struct, :primary_key, :changes, :effect, :previous, :current]

@doc """
Expand Down Expand Up @@ -149,10 +188,36 @@ defmodule EctoDiff do
]
}
>
Using an override to specify a composite primary key:
iex> {:ok, pet} = %{name: "Spot", type: "Calico"} |> Pet.new() |> Repo.insert()
iex> {:ok, diff} = EctoDiff.diff(nil, pet, overrides: %{Pet => [:name, :type]})
iex> diff
#EctoDiff<
struct: Pet,
primary_key: %{name: "Spot", type: "Calico"},
effect: :added,
previous: #Pet<>,
current: #Pet<>,
changes: %{
id: {nil, 1},
name: {nil, "Spot"},
type: {"Cat", "Calico"}
}
>
"""
@spec diff(Ecto.Schema.t() | nil, Ecto.Schema.t() | nil) :: {:ok, t()} | {:ok, :unchanged}
def diff(previous, current) do
diff = do_diff(previous, current)
def diff(previous, current), do: diff(previous, current, [])

@doc """
An alternate form of `diff/2` which allows options to be specified.
See `t:diff_opts/0` for available options.
"""
@spec diff(Ecto.Schema.t() | nil, Ecto.Schema.t() | nil, diff_opts) :: {:ok, t()} | {:ok, :unchanged}
def diff(previous, current, opts) do
diff = do_diff(previous, current, opts)

if no_changes?(diff) do
{:ok, :unchanged}
Expand All @@ -161,14 +226,14 @@ defmodule EctoDiff do
end
end

defp do_diff(nil, %struct{} = current) do
defp do_diff(nil, %struct{} = current, opts) do
previous = struct!(struct)
diff = do_diff(previous, current)
diff = do_diff(previous, current, opts)
%{diff | effect: :added}
end

defp do_diff(%struct{} = previous, nil) do
primary_key_fields = struct.__schema__(:primary_key)
defp do_diff(%struct{} = previous, nil, opts) do
primary_key_fields = get_primary_key_fields(struct, opts)

%__MODULE__{
struct: struct,
Expand All @@ -180,15 +245,15 @@ defmodule EctoDiff do
}
end

defp do_diff(%struct{} = previous, %struct{} = current) do
primary_key_fields = struct.__schema__(:primary_key)
defp do_diff(%struct{} = previous, %struct{} = current, opts) do
primary_key_fields = get_primary_key_fields(struct, opts)

field_changes = fields(previous, current)

changes =
field_changes
|> Map.merge(associations(previous, current))
|> Map.merge(embeds(previous, current))
|> Map.merge(associations(previous, current, opts))
|> Map.merge(embeds(previous, current, opts))

previous_primary_key = Map.take(previous, primary_key_fields)
current_primary_key = Map.take(current, primary_key_fields)
Expand Down Expand Up @@ -230,15 +295,15 @@ defmodule EctoDiff do
end
end

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

embed_names
|> Enum.reduce([], &embed(previous, current, &1, &2))
|> Enum.reduce([], &embed(previous, current, &1, &2, opts))
|> Map.new()
end

defp embed(%struct{} = previous, %struct{} = current, embed, acc) do
defp embed(%struct{} = previous, %struct{} = current, embed, acc, opts) do
embed_details = struct.__schema__(:embed, embed)

previous_embed = Map.get(previous, embed)
Expand All @@ -247,53 +312,53 @@ defmodule EctoDiff do
if is_nil(previous_embed) && is_nil(current_embed) do
acc
else
diff_association(previous_embed, current_embed, embed_details, acc)
diff_association(previous_embed, current_embed, embed_details, acc, opts)
end
end

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

association_names
|> Enum.reduce([], &association(previous, current, &1, &2))
|> Enum.reduce([], &association(previous, current, &1, &2, opts))
|> Map.new()
end

defp association(%struct{} = previous, %struct{} = current, association, acc) do
defp association(%struct{} = previous, %struct{} = current, association, acc, opts) do
association_details = struct.__schema__(:association, association)

previous_value = Map.get(previous, association)
current_value = Map.get(current, association)

diff_association(previous_value, current_value, association_details, acc)
diff_association(previous_value, current_value, association_details, acc, opts)
end

defp diff_association(%NotLoaded{}, %NotLoaded{}, %{cardinality: :one} = assoc, acc) do
diff_association(nil, nil, assoc, acc)
defp diff_association(%NotLoaded{}, %NotLoaded{}, %{cardinality: :one} = assoc, acc, opts) do
diff_association(nil, nil, assoc, acc, opts)
end

defp diff_association(%NotLoaded{}, %NotLoaded{}, %{cardinality: :many} = assoc, acc) do
diff_association([], [], assoc, acc)
defp diff_association(%NotLoaded{}, %NotLoaded{}, %{cardinality: :many} = assoc, acc, opts) do
diff_association([], [], assoc, acc, opts)
end

defp diff_association(_previous, %NotLoaded{}, %{field: field}, _acc) do
defp diff_association(_previous, %NotLoaded{}, %{field: field}, _acc, _opts) do
raise "previously loaded association `#{field}` not loaded in current struct"
end

defp diff_association(%NotLoaded{}, current, %{cardinality: :one} = assoc, acc) do
diff_association(nil, current, assoc, acc)
defp diff_association(%NotLoaded{}, current, %{cardinality: :one} = assoc, acc, opts) do
diff_association(nil, current, assoc, acc, opts)
end

defp diff_association(%NotLoaded{}, current, %{cardinality: :many} = assoc, acc) do
diff_association([], current, assoc, acc)
defp diff_association(%NotLoaded{}, current, %{cardinality: :many} = assoc, acc, opts) do
diff_association([], current, assoc, acc, opts)
end

defp diff_association(nil, nil, %{cardinality: :one}, acc), do: acc
defp diff_association(nil, nil, %{cardinality: :one}, acc, _opts), do: acc

defp diff_association([], [], %{cardinality: :many}, acc), do: acc
defp diff_association([], [], %{cardinality: :many}, acc, _opts), do: acc

defp diff_association(previous, current, %{cardinality: :one, field: field}, acc) do
assoc_diff = do_diff(previous, current)
defp diff_association(previous, current, %{cardinality: :one, field: field}, acc, opts) do
assoc_diff = do_diff(previous, current, opts)

if no_changes?(assoc_diff) do
acc
Expand All @@ -302,8 +367,8 @@ defmodule EctoDiff do
end
end

defp diff_association(previous, current, %{cardinality: :many, field: field, related: struct}, acc) do
primary_key_fields = struct.__schema__(:primary_key)
defp diff_association(previous, current, %{cardinality: :many, field: field, related: struct}, acc, opts) do
primary_key_fields = get_primary_key_fields(struct, opts)

if primary_key_fields == [],
do: raise("cannot determine difference in many association with no primary key for `#{struct}`")
Expand All @@ -328,7 +393,7 @@ defmodule EctoDiff do
prev_child = Map.get(previous_map, key)
current_child = Map.get(current_map, key)

do_diff(prev_child, current_child)
do_diff(prev_child, current_child, opts)
end)
|> Enum.reject(&no_changes?/1)

Expand All @@ -339,6 +404,30 @@ defmodule EctoDiff do
end
end

defp get_primary_key_fields(struct, opts) do
overrides = Keyword.get(opts, :overrides, [])

case overrides[struct] do
nil -> struct.__schema__(:primary_key)
keys -> validate_keys!(struct, List.wrap(keys))
end
end

defp validate_keys!(struct, []) do
raise "no keys specified in override for #{inspect(struct)}"
end

defp validate_keys!(struct, keys) do
struct_fields = struct.__schema__(:fields) || []
missing = Enum.filter(keys, &(&1 not in struct_fields))

if missing != [] do
raise "the keys #{inspect(missing)} for #{inspect(struct)} are invalid or missing"
end

keys
end

defp no_changes?(%{effect: :changed, changes: map}) when map == %{}, do: true
defp no_changes?(_), do: false

Expand Down
11 changes: 6 additions & 5 deletions mix.exs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
defmodule EctoDiff.MixProject do
use Mix.Project

@version "0.3.0"
@version "0.4.0"
@source_url "https://github.com/peek-travel/ecto_diff"

def project do
Expand Down Expand Up @@ -54,6 +54,7 @@ defmodule EctoDiff.MixProject do
main: "EctoDiff",
source_ref: @version,
source_url: @source_url,
main: ["readme"],
extras: ["README.md", "CHANGELOG.md", "LICENSE.md"]
]
end
Expand All @@ -80,11 +81,11 @@ defmodule EctoDiff.MixProject do
defp deps do
[
{:credo, "~> 1.0", only: [:dev, :test], runtime: false},
{:dialyxir, "~> 1.0.0-rc.5", only: [:dev, :test], runtime: false},
{:ecto_sql, "~> 3.0", only: [:dev, :test]},
{:dialyxir, "~> 1.1", only: [:dev, :test], runtime: false},
{:ecto_sql, "~> 3.7", only: [:dev, :test]},
{:ecto, "~> 3.0"},
{:ex_doc, "~> 0.18", only: :dev, runtime: false},
{:excoveralls, "~> 0.10", only: :test},
{:ex_doc, "~> 0.28", only: :dev, runtime: false},
{:excoveralls, "~> 0.14", only: :test},
{:jason, ">= 1.0.0", only: [:dev, :test]},
{:postgrex, ">= 0.0.0", only: [:dev, :test]}
]
Expand Down
Loading

0 comments on commit 23ea5a3

Please sign in to comment.