diff --git a/lib/jsonpatch.ex b/lib/jsonpatch.ex index 63bd91d..841b0fb 100644 --- a/lib/jsonpatch.ex +++ b/lib/jsonpatch.ex @@ -12,7 +12,6 @@ defmodule Jsonpatch do alias Jsonpatch.Types alias Jsonpatch.Operation.{Add, Copy, Move, Remove, Replace, Test} - alias Jsonpatch.Utils @typedoc """ A valid Jsonpatch operation by RFC 6902 @@ -181,75 +180,109 @@ defmodule Jsonpatch do def diff(source, destination) def diff(%{} = source, %{} = destination) do - flat(destination) - |> do_diff(source, "") + do_map_diff(destination, source) end def diff(source, destination) when is_list(source) and is_list(destination) do - flat(destination) - |> do_diff(source, "") + do_list_diff(destination, source) end def diff(_, _) do [] end - defguardp are_unequal_maps(val1, val2) - when val1 != val2 and is_map(val2) and is_map(val1) + defguardp are_unequal_maps(val1, val2) when val1 != val2 and is_map(val2) and is_map(val1) + defguardp are_unequal_lists(val1, val2) when val1 != val2 and is_list(val2) and is_list(val1) - defguardp are_unequal_lists(val1, val2) - when val1 != val2 and is_list(val2) and is_list(val1) + defp do_diff(dest, source, path, key, patches) when are_unequal_lists(dest, source) do + # uneqal lists, let's use a specialized function for that + do_list_diff(dest, source, "#{path}/#{escape(key)}", patches) + end + + defp do_diff(dest, source, path, key, patches) when are_unequal_maps(dest, source) do + # uneqal maps, let's use a specialized function for that + do_map_diff(dest, source, "#{path}/#{escape(key)}", patches) + end - # Diff reduce loop - defp do_diff(destination, source, ancestor_path, acc \\ [], checked_keys \\ []) + defp do_diff(dest, source, path, key, patches) when dest != source do + # scalar values or change of type (map -> list etc), let's just make a replace patch + [%{op: "replace", path: "#{path}/#{escape(key)}", value: dest} | patches] + end + + defp do_diff(_dest, _source, _path, _key, patches) do + # no changes, return patches as is + patches + end - defp do_diff([], source, ancestor_path, patches, checked_keys) do + defp do_map_diff(%{} = destination, %{} = source, ancestor_path \\ "", patches \\ []) do + # entrypoint for map diff, let's convert the map to a list of {k, v} tuples + destination + |> Map.to_list() + |> do_map_diff(source, ancestor_path, patches, []) + end + + defp do_map_diff([], source, ancestor_path, patches, checked_keys) do # The complete desination was check. Every key that is not in the list of # checked keys, must be removed. - source - |> flat() - |> Stream.map(fn {k, _} -> escape(k) end) - |> Stream.filter(fn k -> k not in checked_keys end) - |> Stream.map(fn k -> %{op: "remove", path: "#{ancestor_path}/#{k}"} end) - |> Enum.reduce(patches, fn remove_patch, patches -> [remove_patch | patches] end) + Enum.reduce(source, patches, fn {k, _}, patches -> + if k in checked_keys do + patches + else + [%{op: "remove", path: "#{ancestor_path}/#{escape(k)}"} | patches] + end + end) end - defp do_diff([{key, val} | tail], source, ancestor_path, patches, checked_keys) do - current_path = "#{ancestor_path}/#{escape(key)}" - + defp do_map_diff([{key, val} | rest], source, ancestor_path, patches, checked_keys) do + # normal iteration through list of map {k, v} tuples. We track seen keys to later remove not seen keys. patches = - case Utils.fetch(source, key) do - # Key is not present in source - {:error, _} -> - [%{op: "add", path: current_path, value: val} | patches] + case Map.fetch(source, key) do + {:ok, source_val} -> do_diff(val, source_val, ancestor_path, key, patches) + :error -> [%{op: "add", path: "#{ancestor_path}/#{escape(key)}", value: val} | patches] + end - # Source has a different value but both (destination and source) value are lists or a maps - {:ok, source_val} when are_unequal_lists(source_val, val) -> - val |> flat() |> Enum.reverse() |> do_diff(source_val, current_path, patches, []) + # Diff next value of same level + do_map_diff(rest, source, ancestor_path, patches, [key | checked_keys]) + end - {:ok, source_val} when are_unequal_maps(source_val, val) -> - # Enter next level - set check_keys to empty list because it is a different level - val |> flat() |> do_diff(source_val, current_path, patches, []) + defp do_list_diff(destination, source, ancestor_path \\ "", patches \\ [], idx \\ 0) - # Scalar source val that is not equal - {:ok, source_val} when source_val != val -> - [%{op: "replace", path: current_path, value: val} | patches] + defp do_list_diff([], [], _path, patches, _idx), do: patches - _ -> - patches - end + defp do_list_diff([], [_item | source_rest], ancestor_path, patches, idx) do + # if we find any leftover items in source, we have to remove them + patches = [%{op: "remove", path: "#{ancestor_path}/#{idx}"} | patches] + do_list_diff([], source_rest, ancestor_path, patches, idx + 1) + end - # Diff next value of same level - do_diff(tail, source, ancestor_path, patches, [escape(key) | checked_keys]) + defp do_list_diff(items, [], ancestor_path, patches, idx) do + # we have to do it without recursion, because we have to keep the order of the items + items + |> Enum.map_reduce(idx, fn val, idx -> + {%{op: "add", path: "#{ancestor_path}/#{idx}", value: val}, idx + 1} + end) + |> elem(0) + |> Kernel.++(patches) + end + + defp do_list_diff([val | rest], [source_val | source_rest], ancestor_path, patches, idx) do + # case when there's an item in both desitation and source. Let's just compare them + patches = do_diff(val, source_val, ancestor_path, idx, patches) + do_list_diff(rest, source_rest, ancestor_path, patches, idx + 1) end - # Transforms a map into a tuple list and a list also into a tuple list with indizes - defp flat(val) when is_list(val), - do: Stream.with_index(val) |> Enum.map(fn {v, k} -> {k, v} end) + @compile {:inline, escape: 1} - defp flat(val) when is_map(val), - do: Map.to_list(val) + defp escape(fragment) when is_binary(fragment) do + fragment = + if :binary.match(fragment, "~") != :nomatch, + do: String.replace(fragment, "~", "~0"), + else: fragment + + if :binary.match(fragment, "/") != :nomatch, + do: String.replace(fragment, "/", "~1"), + else: fragment + end - defp escape(fragment) when is_binary(fragment), do: Utils.escape(fragment) defp escape(fragment), do: fragment end diff --git a/mix.exs b/mix.exs index 0515a0b..7a07e27 100644 --- a/mix.exs +++ b/mix.exs @@ -40,7 +40,8 @@ defmodule Jsonpatch.MixProject do {:credo, "~> 1.7.5", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 1.4", only: [:dev], runtime: false}, {:ex_doc, "~> 0.31", only: [:dev], runtime: false}, - {:jason, "~> 1.4", only: [:dev, :test]} + {:jason, "~> 1.4", only: [:dev, :test]}, + {:benchee, "~> 1.4", only: [:dev]} ] end diff --git a/mix.lock b/mix.lock index a99143c..8b50ff7 100644 --- a/mix.lock +++ b/mix.lock @@ -1,7 +1,9 @@ %{ + "benchee": {:hex, :benchee, "1.4.0", "9f1f96a30ac80bab94faad644b39a9031d5632e517416a8ab0a6b0ac4df124ce", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "299cd10dd8ce51c9ea3ddb74bb150f93d25e968f93e4c1fa31698a8e4fa5d715"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, "credo": {:hex, :credo, "1.7.5", "643213503b1c766ec0496d828c90c424471ea54da77c8a168c725686377b9545", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "f799e9b5cd1891577d8c773d245668aa74a2fcd15eb277f51a0131690ebfb3fd"}, + "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, @@ -19,5 +21,6 @@ "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, + "statistex": {:hex, :statistex, "1.1.0", "7fec1eb2f580a0d2c1a05ed27396a084ab064a40cfc84246dbfb0c72a5c761e5", [:mix], [], "hexpm", "f5950ea26ad43246ba2cce54324ac394a4e7408fdcf98b8e230f503a0cba9cf5"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, } diff --git a/test/benchmark.exs b/test/benchmark.exs new file mode 100644 index 0000000..98e2b3a --- /dev/null +++ b/test/benchmark.exs @@ -0,0 +1,254 @@ +# Jsonpatch Diff Performance Benchmark +# Run with: mix run test/benchmark.exs + +defmodule JsonpatchBenchmark do + @doc """ + Prepare complex test cases for benchmarking + """ + def prepare_test_cases() do + %{ + "Complex Maps - E-commerce Order" => %{ + doc: %{ + "order_id" => "12345", + "customer" => %{ + "name" => "John Doe", + "email" => "john@example.com", + "address" => %{ + "street" => "123 Main St", + "city" => "Springfield", + "country" => "USA" + } + }, + "items" => %{ + "item1" => %{"name" => "Laptop", "price" => 999.99, "quantity" => 1}, + "item2" => %{"name" => "Mouse", "price" => 29.99, "quantity" => 2} + }, + "status" => "pending", + "total" => 1059.97 + }, + expected: %{ + "order_id" => "12345", + "customer" => %{ + "name" => "John Doe", + "email" => "john.doe@example.com", + "address" => %{ + "street" => "456 Oak Ave", + "city" => "Springfield", + "country" => "USA", + "zipcode" => "12345" + }, + "phone" => "+1-555-0123" + }, + "items" => %{ + "item1" => %{"name" => "Gaming Laptop", "price" => 1299.99, "quantity" => 1}, + "item3" => %{"name" => "Keyboard", "price" => 79.99, "quantity" => 1} + }, + "status" => "confirmed", + "total" => 1379.98, + "discount" => 50.00 + } + }, + "Complex Lists - Task Management" => %{ + doc: [ + %{ + "id" => 1, + "task" => "Write documentation", + "priority" => "high", + "completed" => false + }, + %{"id" => 2, "task" => "Fix bug #123", "priority" => "medium", "completed" => true}, + %{"id" => 3, "task" => "Review PR", "priority" => "low", "completed" => false}, + %{"id" => 4, "task" => "Deploy to staging", "priority" => "high", "completed" => false}, + %{"id" => 5, "task" => "Update tests", "priority" => "medium", "completed" => true} + ], + expected: [ + %{ + "id" => 1, + "task" => "Write comprehensive documentation", + "priority" => "high", + "completed" => true + }, + %{ + "id" => 6, + "task" => "Optimize database queries", + "priority" => "high", + "completed" => false + }, + %{"id" => 3, "task" => "Review PR", "priority" => "medium", "completed" => false}, + %{"id" => 7, "task" => "Setup monitoring", "priority" => "low", "completed" => false}, + %{ + "id" => 4, + "task" => "Deploy to production", + "priority" => "critical", + "completed" => false + } + ] + }, + "Mixed Maps and Lists - Social Media Post" => %{ + doc: %{ + "post_id" => "abc123", + "content" => "Just had an amazing day!", + "author" => %{ + "username" => "johndoe", + "followers" => 1250, + "verified" => false + }, + "comments" => [ + %{"user" => "alice", "text" => "Great to hear!", "likes" => 5}, + %{"user" => "bob", "text" => "Awesome!", "likes" => 3} + ], + "tags" => ["happy", "life"], + "metadata" => %{ + "created_at" => "2023-01-01T10:00:00Z", + "location" => "New York", + "device" => "mobile" + } + }, + expected: %{ + "post_id" => "abc123", + "content" => "Just had an absolutely amazing day! #blessed", + "author" => %{ + "username" => "johndoe", + "followers" => 1275, + "verified" => true, + "display_name" => "John Doe" + }, + "comments" => [ + %{"user" => "alice", "text" => "Great to hear! So happy for you!", "likes" => 8}, + %{"user" => "charlie", "text" => "Inspiring!", "likes" => 2}, + %{"user" => "bob", "text" => "Awesome!", "likes" => 3, "reply_to" => "alice"} + ], + "tags" => ["happy", "life", "blessed", "inspiration"], + "metadata" => %{ + "created_at" => "2023-01-01T10:00:00Z", + "updated_at" => "2023-01-01T10:15:00Z", + "location" => "New York", + "device" => "mobile", + "engagement_score" => 8.5 + }, + "reactions" => %{ + "likes" => 45, + "shares" => 12, + "hearts" => 23 + } + } + }, + "Deep Nesting - Configuration Tree" => %{ + doc: %{ + "application" => %{ + "name" => "MyApp", + "version" => "1.0.0", + "modules" => %{ + "authentication" => %{ + "enabled" => true, + "providers" => %{ + "oauth" => %{ + "google" => %{"client_id" => "123", "scopes" => ["email", "profile"]}, + "github" => %{"client_id" => "456", "scopes" => ["user:email"]} + }, + "local" => %{"enabled" => true, "password_policy" => %{"min_length" => 8}} + } + }, + "database" => %{ + "primary" => %{ + "host" => "localhost", + "port" => 5432, + "name" => "myapp_db", + "pool" => %{"size" => 10, "timeout" => 5000} + }, + "replica" => %{ + "host" => "replica.example.com", + "port" => 5432, + "name" => "myapp_db" + } + } + } + } + }, + expected: %{ + "application" => %{ + "name" => "MyApp", + "version" => "1.1.0", + "modules" => %{ + "authentication" => %{ + "enabled" => true, + "providers" => %{ + "oauth" => %{ + "google" => %{ + "client_id" => "123", + "scopes" => ["email", "profile", "calendar"] + }, + "github" => %{"client_id" => "789", "scopes" => ["user:email", "read:user"]}, + "microsoft" => %{"client_id" => "999", "scopes" => ["User.Read"]} + }, + "local" => %{ + "enabled" => true, + "password_policy" => %{"min_length" => 12, "require_symbols" => true} + }, + "saml" => %{ + "enabled" => false, + "metadata_url" => "https://sso.example.com/metadata" + } + } + }, + "database" => %{ + "primary" => %{ + "host" => "db.example.com", + "port" => 5432, + "name" => "myapp_production", + "pool" => %{"size" => 20, "timeout" => 10_000, "idle_timeout" => 30_000} + }, + "cache" => %{ + "host" => "redis.example.com", + "port" => 6379, + "ttl" => 3600 + } + }, + "monitoring" => %{ + "metrics" => %{"enabled" => true, "interval" => 60}, + "logging" => %{"level" => "info", "format" => "json"} + } + }, + "features" => %{ + "feature_flags" => %{"new_ui" => true, "beta_features" => false} + } + } + } + } + } + end + + @doc """ + Run the benchmark + """ + def run_benchmark() do + Benchee.run( + %{ + # I was using it for performance comparision, now faster version is the default one + # "Faster JsonPatch" => fn %{doc: doc, expected: expected} -> + # Jsonpatch.Faster.diff(doc, expected) + # end, + "JsonPatch" => fn %{doc: doc, expected: expected} -> + Jsonpatch.diff(doc, expected) + end + }, + inputs: prepare_test_cases(), + warmup: 0.1, + time: 0.5, + memory_time: 0.2, + reduction_time: 0.2, + parallel: 2, + formatters: [ + Benchee.Formatters.Console + ], + print: [ + benchmarking: true, + configuration: false, + fast_warning: false + ] + ) + end +end + +# Run the benchmark +JsonpatchBenchmark.run_benchmark()