philiprehberger-differ 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4f3a7ba9a12fd481dc78033e4038612fd500b186ae2c908112e22b792d6f954c
4
- data.tar.gz: 42758eac8c39f509de50f40f06d21b7d283c8e9c74c7eec8e0ae68b49cdc700a
3
+ metadata.gz: be3438fd21e7f26548ccf9052163ac43a8a41231f95e772830d1311f4d9fbd9b
4
+ data.tar.gz: c8bb69a3e019c8ea86204a09011070bb813ef03491f7f5bb95e3a381909687b5
5
5
  SHA512:
6
- metadata.gz: a37315aa385705c3d8caa99774a13fe7ff65b3b53b1ce42616b804bdedac229788893bc5dbcfd514522cf35ed03392b3c513771e50b67bc23c215e3efb004130
7
- data.tar.gz: b49707780c6cbb629a65641cd40fdf7e80e8fa2bbeedd08b9a75556f16ce9749460123e82169ea2d20fc1ca67e43e925695f731bc60fa9afd671e8db594e9fce
6
+ metadata.gz: '074148fad05b65128d63112b61383e9589f35560e8a3cb990b9689e432e68380b4f52a56052c31468763c88c130624ec1583fb984a4d14320c87eb56a8afdfe8'
7
+ data.tar.gz: e2972dff73dc4dc4f2f3270e0537ce049885b7e003a793136fce6cb4ae6fa2c07ff0740f2bef7bbb4d5b9e3634b5baf6abb25a06edd8586e286c1f9f429b71da
data/CHANGELOG.md CHANGED
@@ -1,16 +1,25 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.2
4
-
5
- - Add License badge to README
6
- - Add bug_tracker_uri to gemspec
7
- - Add Requirements section to README
8
-
9
3
  All notable changes to this gem will be documented in this file.
10
4
 
11
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
12
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
13
7
 
8
+ ## [0.2.0] - 2026-03-17
9
+
10
+ ### Added
11
+ - Ignore paths with symbol support: `Differ.diff(a, b, ignore: [:updated_at, "user.email"])`
12
+ - Similarity score: `Differ.similarity(a, b)` returns Float between 0.0 and 1.0
13
+ - Text formatter: `changeset.to_text` for human-readable +/- output
14
+ - JSON Patch formatter: `changeset.to_json_patch` for RFC 6902 operations
15
+ - Nested array diff by key: `Differ.diff(a, b, array_key: :id)` matches elements by field
16
+
17
+ ## [0.1.2]
18
+
19
+ - Add License badge to README
20
+ - Add bug_tracker_uri to gemspec
21
+ - Add Requirements section to README
22
+
14
23
  ## [Unreleased]
15
24
 
16
25
  ## [0.1.0] - 2026-03-15
data/README.md CHANGED
@@ -44,17 +44,87 @@ result = changeset.apply(old_data)
44
44
 
45
45
  # Revert changes to produce old version from new
46
46
  original = changeset.revert(new_data)
47
+ ```
48
+
49
+ ### Ignore Paths
50
+
51
+ Exclude specific keys from comparison. Supports both symbols and dot-notation strings for nested paths:
52
+
53
+ ```ruby
54
+ changeset = Philiprehberger::Differ.diff(old_data, new_data, ignore: [:updated_at, :metadata])
47
55
 
48
- # Ignore specific paths
49
- changeset = Philiprehberger::Differ.diff(old_data, new_data, ignore: ['age'])
56
+ # Ignore nested paths
57
+ changeset = Philiprehberger::Differ.diff(old_data, new_data, ignore: ['user.email', 'meta.version'])
58
+ ```
59
+
60
+ ### Similarity Score
61
+
62
+ Get a ratio of unchanged fields to total fields, returned as a Float between 0.0 and 1.0:
63
+
64
+ ```ruby
65
+ score = Philiprehberger::Differ.similarity(old_data, new_data)
66
+ # => 0.5 (half the fields are identical)
67
+
68
+ score = Philiprehberger::Differ.similarity(old_data, old_data)
69
+ # => 1.0 (identical)
70
+ ```
71
+
72
+ ### Text Formatter
73
+
74
+ Human-readable text output with +/- prefixes:
75
+
76
+ ```ruby
77
+ changeset = Philiprehberger::Differ.diff(
78
+ { name: 'Alice', age: 30 },
79
+ { name: 'Bob', email: 'bob@example.com' }
80
+ )
81
+
82
+ puts changeset.to_text
83
+ # ~ name: "Alice" -> "Bob"
84
+ # - age: 30
85
+ # + email: "bob@example.com"
86
+ ```
87
+
88
+ ### JSON Patch Format
89
+
90
+ Returns an array of RFC 6902 JSON Patch operations:
91
+
92
+ ```ruby
93
+ changeset = Philiprehberger::Differ.diff(
94
+ { name: 'Alice', age: 30 },
95
+ { name: 'Bob' }
96
+ )
97
+
98
+ changeset.to_json_patch
99
+ # => [
100
+ # { op: "replace", path: "/name", value: "Bob" },
101
+ # { op: "remove", path: "/age" }
102
+ # ]
103
+ ```
104
+
105
+ ### Nested Array Diff by Key
106
+
107
+ Match array elements by a key field instead of index for smarter array comparison:
108
+
109
+ ```ruby
110
+ old_data = { users: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }] }
111
+ new_data = { users: [{ id: 2, name: 'Bobby' }, { id: 1, name: 'Alice' }] }
112
+
113
+ # Without array_key: detects changes at every index (order-sensitive)
114
+ # With array_key: matches by :id, only detects Bob -> Bobby change
115
+ changeset = Philiprehberger::Differ.diff(old_data, new_data, array_key: :id)
50
116
  ```
51
117
 
52
118
  ## API
53
119
 
54
- ### `Philiprehberger::Differ.diff(old_val, new_val, ignore: [])`
120
+ ### `Philiprehberger::Differ.diff(old_val, new_val, ignore: [], array_key: nil)`
55
121
 
56
122
  Returns a `Changeset` representing all structural differences.
57
123
 
124
+ ### `Philiprehberger::Differ.similarity(old_val, new_val, ignore: [], array_key: nil)`
125
+
126
+ Returns a Float between 0.0 (completely different) and 1.0 (identical).
127
+
58
128
  ### `Changeset`
59
129
 
60
130
  | Method | Description |
@@ -67,6 +137,8 @@ Returns a `Changeset` representing all structural differences.
67
137
  | `apply(hash)` | Applies changes to produce the new version |
68
138
  | `revert(hash)` | Reverts changes to produce the old version |
69
139
  | `to_h` | Serializable hash representation |
140
+ | `to_text` | Human-readable text with +/- prefixes |
141
+ | `to_json_patch` | Array of RFC 6902 JSON Patch operations |
70
142
 
71
143
  ### `Change`
72
144
 
@@ -41,6 +41,14 @@ module Philiprehberger
41
41
  { changes: @changes.map(&:to_h) }
42
42
  end
43
43
 
44
+ def to_text
45
+ Formatters::Text.format(self)
46
+ end
47
+
48
+ def to_json_patch
49
+ Formatters::JsonPatch.format(self)
50
+ end
51
+
44
52
  private
45
53
 
46
54
  def apply_change(hash, change)
@@ -3,51 +3,88 @@
3
3
  module Philiprehberger
4
4
  module Differ
5
5
  class Comparator
6
- def self.call(old_val, new_val, path: '', ignore: [])
7
- return [] if ignore.include?(path)
6
+ def self.call(old_val, new_val, path: '', ignore: [], array_key: nil)
7
+ return [] if ignored?(path, ignore)
8
8
 
9
+ opts = { ignore: ignore, array_key: array_key }
9
10
  case [old_val, new_val]
10
- in [Hash, Hash] then compare_hashes(old_val, new_val, path, ignore)
11
- in [Array, Array] then compare_arrays(old_val, new_val, path, ignore)
11
+ in [Hash, Hash] then compare_hashes(old_val, new_val, path, opts)
12
+ in [Array, Array] then compare_arrays(old_val, new_val, path, opts)
12
13
  else compare_scalars(old_val, new_val, path)
13
14
  end
14
15
  end
15
16
 
16
- def self.compare_hashes(old_hash, new_hash, path, ignore)
17
+ def self.ignored?(path, ignore)
18
+ ignore.any? { |p| p.to_s == path }
19
+ end
20
+
21
+ def self.compare_hashes(old_hash, new_hash, path, opts)
17
22
  (old_hash.keys + new_hash.keys).uniq.each_with_object([]) do |key, changes|
18
23
  full_path = path.empty? ? key.to_s : "#{path}.#{key}"
19
- next if ignore.include?(full_path)
24
+ next if ignored?(full_path, opts[:ignore])
20
25
 
21
- changes.concat(hash_key_diff(old_hash, new_hash, key, full_path, ignore))
26
+ changes.concat(hash_key_diff(old_hash, new_hash, key, full_path, opts))
22
27
  end
23
28
  end
24
29
 
25
- def self.hash_key_diff(old_hash, new_hash, key, full_path, ignore)
30
+ def self.hash_key_diff(old_hash, new_hash, key, full_path, opts)
26
31
  if !old_hash.key?(key)
27
32
  [Change.new(path: full_path, type: :added, new_value: new_hash[key])]
28
33
  elsif !new_hash.key?(key)
29
34
  [Change.new(path: full_path, type: :removed, old_value: old_hash[key])]
30
35
  else
31
- call(old_hash[key], new_hash[key], path: full_path, ignore: ignore)
36
+ call(old_hash[key], new_hash[key], path: full_path, **opts)
37
+ end
38
+ end
39
+
40
+ def self.compare_arrays(old_arr, new_arr, path, opts)
41
+ if opts[:array_key] && old_arr.first.is_a?(Hash)
42
+ compare_arrays_by_key(old_arr, new_arr, path, opts)
43
+ else
44
+ compare_arrays_by_index(old_arr, new_arr, path, opts)
32
45
  end
33
46
  end
34
47
 
35
- def self.compare_arrays(old_arr, new_arr, path, ignore)
48
+ def self.compare_arrays_by_index(old_arr, new_arr, path, opts)
36
49
  [old_arr.length, new_arr.length].max.times.each_with_object([]) do |idx, changes|
37
50
  full_path = "#{path}.#{idx}"
38
- next if ignore.include?(full_path)
51
+ next if ignored?(full_path, opts[:ignore])
39
52
 
40
- changes.concat(array_idx_diff(old_arr, new_arr, idx, full_path, ignore))
53
+ changes.concat(array_idx_diff(old_arr, new_arr, idx, full_path, opts))
41
54
  end
42
55
  end
43
56
 
44
- def self.array_idx_diff(old_arr, new_arr, idx, full_path, ignore)
57
+ def self.array_idx_diff(old_arr, new_arr, idx, full_path, opts)
45
58
  if idx >= old_arr.length
46
59
  [Change.new(path: full_path, type: :added, new_value: new_arr[idx])]
47
60
  elsif idx >= new_arr.length
48
61
  [Change.new(path: full_path, type: :removed, old_value: old_arr[idx])]
49
62
  else
50
- call(old_arr[idx], new_arr[idx], path: full_path, ignore: ignore)
63
+ call(old_arr[idx], new_arr[idx], path: full_path, **opts)
64
+ end
65
+ end
66
+
67
+ def self.compare_arrays_by_key(old_arr, new_arr, path, opts)
68
+ old_map = index_by_key(old_arr, opts[:array_key])
69
+ new_map = index_by_key(new_arr, opts[:array_key])
70
+ all_keys = (old_map.keys + new_map.keys).uniq
71
+ all_keys.each_with_object([]) do |key_val, changes|
72
+ changes.concat(keyed_element_diff(old_map, new_map, key_val, path, opts))
73
+ end
74
+ end
75
+
76
+ def self.index_by_key(arr, key)
77
+ arr.to_h { |item| [item[key], item] }
78
+ end
79
+
80
+ def self.keyed_element_diff(old_map, new_map, key_val, path, opts)
81
+ full_path = "#{path}.#{key_val}"
82
+ if !old_map.key?(key_val)
83
+ [Change.new(path: full_path, type: :added, new_value: new_map[key_val])]
84
+ elsif !new_map.key?(key_val)
85
+ [Change.new(path: full_path, type: :removed, old_value: old_map[key_val])]
86
+ else
87
+ call(old_map[key_val], new_map[key_val], path: full_path, **opts)
51
88
  end
52
89
  end
53
90
 
@@ -58,7 +95,9 @@ module Philiprehberger
58
95
  end
59
96
 
60
97
  private_class_method :compare_hashes, :compare_arrays, :compare_scalars,
61
- :hash_key_diff, :array_idx_diff
98
+ :hash_key_diff, :array_idx_diff, :ignored?,
99
+ :compare_arrays_by_index, :compare_arrays_by_key,
100
+ :index_by_key, :keyed_element_diff
62
101
  end
63
102
  end
64
103
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Philiprehberger
6
+ module Differ
7
+ module Formatters
8
+ module Text
9
+ def self.format(changeset)
10
+ changeset.changes.map { |c| format_change(c) }.join("\n")
11
+ end
12
+
13
+ def self.format_change(change)
14
+ case change.type
15
+ when :added then "+ #{change.path}: #{change.new_value.inspect}"
16
+ when :removed then "- #{change.path}: #{change.old_value.inspect}"
17
+ when :changed then changed_line(change)
18
+ end
19
+ end
20
+
21
+ def self.changed_line(change)
22
+ "~ #{change.path}: #{change.old_value.inspect} -> #{change.new_value.inspect}"
23
+ end
24
+
25
+ private_class_method :format_change, :changed_line
26
+ end
27
+
28
+ module JsonPatch
29
+ def self.format(changeset)
30
+ changeset.changes.map { |c| format_op(c) }
31
+ end
32
+
33
+ def self.format_op(change)
34
+ path = "/#{change.path.gsub('.', '/')}"
35
+ case change.type
36
+ when :added then { op: 'add', path: path, value: change.new_value }
37
+ when :removed then { op: 'remove', path: path }
38
+ when :changed then { op: 'replace', path: path, value: change.new_value }
39
+ end
40
+ end
41
+
42
+ private_class_method :format_op
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module Differ
5
+ module Similarity
6
+ def self.call(old_val, new_val, ignore: [], array_key: nil)
7
+ total = count_fields(old_val, new_val, ignore: ignore, array_key: array_key)
8
+ return 1.0 if total.zero?
9
+
10
+ changes = Comparator.call(old_val, new_val, ignore: ignore, array_key: array_key)
11
+ changed = changes.length
12
+ (total - changed).to_f / total
13
+ end
14
+
15
+ def self.count_fields(old_val, new_val, ignore: [], array_key: nil, path: '')
16
+ case [old_val, new_val]
17
+ in [Hash, Hash] then count_hash_fields(old_val, new_val, ignore, array_key, path)
18
+ in [Array, Array] then count_array_fields(old_val, new_val, ignore, array_key, path)
19
+ else 1
20
+ end
21
+ end
22
+
23
+ def self.count_hash_fields(old_hash, new_hash, ignore, array_key, path)
24
+ opts = { ignore: ignore, array_key: array_key }
25
+ (old_hash.keys + new_hash.keys).uniq.sum do |key|
26
+ full_path = path.empty? ? key.to_s : "#{path}.#{key}"
27
+ next 0 if ignore.any? { |p| p.to_s == full_path }
28
+
29
+ count_hash_key(old_hash, new_hash, key, full_path, opts)
30
+ end
31
+ end
32
+
33
+ def self.count_hash_key(old_hash, new_hash, key, full_path, opts)
34
+ if old_hash.key?(key) && new_hash.key?(key)
35
+ count_fields(old_hash[key], new_hash[key],
36
+ ignore: opts[:ignore], array_key: opts[:array_key], path: full_path)
37
+ else
38
+ 1
39
+ end
40
+ end
41
+
42
+ def self.count_array_fields(old_arr, new_arr, ignore, array_key, path)
43
+ if array_key && old_arr.first.is_a?(Hash)
44
+ count_keyed_array(old_arr, new_arr, ignore, array_key, path)
45
+ else
46
+ count_indexed_array(old_arr, new_arr, ignore, array_key, path)
47
+ end
48
+ end
49
+
50
+ def self.count_indexed_array(old_arr, new_arr, ignore, array_key, path)
51
+ [old_arr.length, new_arr.length].max.times.sum do |idx|
52
+ full_path = "#{path}.#{idx}"
53
+ next 0 if ignore.any? { |p| p.to_s == full_path }
54
+ next 1 if idx >= old_arr.length || idx >= new_arr.length
55
+
56
+ count_fields(old_arr[idx], new_arr[idx], ignore: ignore, array_key: array_key, path: full_path)
57
+ end
58
+ end
59
+
60
+ def self.count_keyed_array(old_arr, new_arr, ignore, array_key, path)
61
+ old_map = old_arr.to_h { |item| [item[array_key], item] }
62
+ new_map = new_arr.to_h { |item| [item[array_key], item] }
63
+ count_keyed_array_fields(old_map, new_map, ignore, array_key, path)
64
+ end
65
+
66
+ def self.count_keyed_array_fields(old_map, new_map, ignore, array_key, path)
67
+ (old_map.keys + new_map.keys).uniq.sum do |key_val|
68
+ full_path = "#{path}.#{key_val}"
69
+ next 1 unless old_map.key?(key_val) && new_map.key?(key_val)
70
+
71
+ count_fields(old_map[key_val], new_map[key_val], ignore: ignore, array_key: array_key, path: full_path)
72
+ end
73
+ end
74
+
75
+ private_class_method :count_fields, :count_hash_fields, :count_hash_key,
76
+ :count_array_fields, :count_indexed_array, :count_keyed_array,
77
+ :count_keyed_array_fields
78
+ end
79
+ end
80
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module Differ
5
- VERSION = '0.1.2'
5
+ VERSION = '0.2.0'
6
6
  end
7
7
  end
@@ -4,14 +4,20 @@ require_relative 'differ/version'
4
4
  require_relative 'differ/change'
5
5
  require_relative 'differ/changeset'
6
6
  require_relative 'differ/comparator'
7
+ require_relative 'differ/formatters'
8
+ require_relative 'differ/similarity'
7
9
 
8
10
  module Philiprehberger
9
11
  module Differ
10
12
  class Error < StandardError; end
11
13
 
12
- def self.diff(old_val, new_val, ignore: [])
13
- changes = Comparator.call(old_val, new_val, ignore: ignore)
14
+ def self.diff(old_val, new_val, ignore: [], array_key: nil)
15
+ changes = Comparator.call(old_val, new_val, ignore: ignore, array_key: array_key)
14
16
  Changeset.new(changes)
15
17
  end
18
+
19
+ def self.similarity(old_val, new_val, ignore: [], array_key: nil)
20
+ Similarity.call(old_val, new_val, ignore: ignore, array_key: array_key)
21
+ end
16
22
  end
17
23
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: philiprehberger-differ
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Philip Rehberger
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-16 00:00:00.000000000 Z
11
+ date: 2026-03-17 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description:
14
14
  email:
@@ -24,6 +24,8 @@ files:
24
24
  - lib/philiprehberger/differ/change.rb
25
25
  - lib/philiprehberger/differ/changeset.rb
26
26
  - lib/philiprehberger/differ/comparator.rb
27
+ - lib/philiprehberger/differ/formatters.rb
28
+ - lib/philiprehberger/differ/similarity.rb
27
29
  - lib/philiprehberger/differ/version.rb
28
30
  homepage: https://github.com/philiprehberger/rb-differ
29
31
  licenses: