philiprehberger-differ 0.1.2 → 0.2.2

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: a2895233828f37deff74861ac0c8c0fb7b7f678c644aeda4b4db14344760c38e
4
+ data.tar.gz: c1ebbe193bce2b499bbb9ad91f29a74949180a69de143440becb5d26b15acaf6
5
5
  SHA512:
6
- metadata.gz: a37315aa385705c3d8caa99774a13fe7ff65b3b53b1ce42616b804bdedac229788893bc5dbcfd514522cf35ed03392b3c513771e50b67bc23c215e3efb004130
7
- data.tar.gz: b49707780c6cbb629a65641cd40fdf7e80e8fa2bbeedd08b9a75556f16ce9749460123e82169ea2d20fc1ca67e43e925695f731bc60fa9afd671e8db594e9fce
6
+ metadata.gz: 7a4c3e61b19d56fcb837339e4789b38ee3a2cfc32000a98d6f7ab5dd1f09b8730d694431ccfdf8416a506f2f34cec2106825b9144979f06f481e589ef52629b7
7
+ data.tar.gz: 5ea7bea69ce7d9a0736cfda3fbd6034ecadb1b0d514908069a8ccc459064b7636fe47b16424739e6adc500f4222b106ab12d5da12c606887220c2e4f50004de1
data/CHANGELOG.md CHANGED
@@ -1,16 +1,33 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.2
3
+ ## 0.2.2
4
4
 
5
- - Add License badge to README
6
- - Add bug_tracker_uri to gemspec
7
- - Add Requirements section to README
5
+ - Revert gemspec to single-quoted strings per RuboCop default configuration
6
+
7
+ ## 0.2.1
8
+
9
+ - Fix RuboCop Style/StringLiterals violations in gemspec
8
10
 
9
11
  All notable changes to this gem will be documented in this file.
10
12
 
11
13
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
12
14
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
13
15
 
16
+ ## [0.2.0] - 2026-03-17
17
+
18
+ ### Added
19
+ - Ignore paths with symbol support: `Differ.diff(a, b, ignore: [:updated_at, "user.email"])`
20
+ - Similarity score: `Differ.similarity(a, b)` returns Float between 0.0 and 1.0
21
+ - Text formatter: `changeset.to_text` for human-readable +/- output
22
+ - JSON Patch formatter: `changeset.to_json_patch` for RFC 6902 operations
23
+ - Nested array diff by key: `Differ.diff(a, b, array_key: :id)` matches elements by field
24
+
25
+ ## [0.1.2]
26
+
27
+ - Add License badge to README
28
+ - Add bug_tracker_uri to gemspec
29
+ - Add Requirements section to README
30
+
14
31
  ## [Unreleased]
15
32
 
16
33
  ## [0.1.0] - 2026-03-15
data/README.md CHANGED
@@ -1,9 +1,10 @@
1
1
  # philiprehberger-differ
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/philiprehberger-differ.svg)](https://rubygems.org/gems/philiprehberger-differ)
4
+ [![Tests](https://github.com/philiprehberger/rb-differ/actions/workflows/ci.yml/badge.svg)](https://github.com/philiprehberger/rb-differ/actions/workflows/ci.yml)
4
5
  [![License](https://img.shields.io/github/license/philiprehberger/rb-differ)](LICENSE)
5
6
 
6
- Deep structural diff for hashes, arrays, and nested objects in Ruby.
7
+ Deep structural diff for hashes, arrays, and nested objects in Ruby
7
8
 
8
9
  ## Requirements
9
10
 
@@ -19,7 +20,7 @@ gem 'philiprehberger-differ'
19
20
 
20
21
  Or install directly:
21
22
 
22
- ```sh
23
+ ```bash
23
24
  gem install philiprehberger-differ
24
25
  ```
25
26
 
@@ -44,17 +45,87 @@ result = changeset.apply(old_data)
44
45
 
45
46
  # Revert changes to produce old version from new
46
47
  original = changeset.revert(new_data)
48
+ ```
49
+
50
+ ### Ignore Paths
51
+
52
+ Exclude specific keys from comparison. Supports both symbols and dot-notation strings for nested paths:
53
+
54
+ ```ruby
55
+ changeset = Philiprehberger::Differ.diff(old_data, new_data, ignore: [:updated_at, :metadata])
47
56
 
48
- # Ignore specific paths
49
- changeset = Philiprehberger::Differ.diff(old_data, new_data, ignore: ['age'])
57
+ # Ignore nested paths
58
+ changeset = Philiprehberger::Differ.diff(old_data, new_data, ignore: ['user.email', 'meta.version'])
59
+ ```
60
+
61
+ ### Similarity Score
62
+
63
+ Get a ratio of unchanged fields to total fields, returned as a Float between 0.0 and 1.0:
64
+
65
+ ```ruby
66
+ score = Philiprehberger::Differ.similarity(old_data, new_data)
67
+ # => 0.5 (half the fields are identical)
68
+
69
+ score = Philiprehberger::Differ.similarity(old_data, old_data)
70
+ # => 1.0 (identical)
71
+ ```
72
+
73
+ ### Text Formatter
74
+
75
+ Human-readable text output with +/- prefixes:
76
+
77
+ ```ruby
78
+ changeset = Philiprehberger::Differ.diff(
79
+ { name: 'Alice', age: 30 },
80
+ { name: 'Bob', email: 'bob@example.com' }
81
+ )
82
+
83
+ puts changeset.to_text
84
+ # ~ name: "Alice" -> "Bob"
85
+ # - age: 30
86
+ # + email: "bob@example.com"
87
+ ```
88
+
89
+ ### JSON Patch Format
90
+
91
+ Returns an array of RFC 6902 JSON Patch operations:
92
+
93
+ ```ruby
94
+ changeset = Philiprehberger::Differ.diff(
95
+ { name: 'Alice', age: 30 },
96
+ { name: 'Bob' }
97
+ )
98
+
99
+ changeset.to_json_patch
100
+ # => [
101
+ # { op: "replace", path: "/name", value: "Bob" },
102
+ # { op: "remove", path: "/age" }
103
+ # ]
104
+ ```
105
+
106
+ ### Nested Array Diff by Key
107
+
108
+ Match array elements by a key field instead of index for smarter array comparison:
109
+
110
+ ```ruby
111
+ old_data = { users: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }] }
112
+ new_data = { users: [{ id: 2, name: 'Bobby' }, { id: 1, name: 'Alice' }] }
113
+
114
+ # Without array_key: detects changes at every index (order-sensitive)
115
+ # With array_key: matches by :id, only detects Bob -> Bobby change
116
+ changeset = Philiprehberger::Differ.diff(old_data, new_data, array_key: :id)
50
117
  ```
51
118
 
52
119
  ## API
53
120
 
54
- ### `Philiprehberger::Differ.diff(old_val, new_val, ignore: [])`
121
+ ### `Philiprehberger::Differ.diff(old_val, new_val, ignore: [], array_key: nil)`
55
122
 
56
123
  Returns a `Changeset` representing all structural differences.
57
124
 
125
+ ### `Philiprehberger::Differ.similarity(old_val, new_val, ignore: [], array_key: nil)`
126
+
127
+ Returns a Float between 0.0 (completely different) and 1.0 (identical).
128
+
58
129
  ### `Changeset`
59
130
 
60
131
  | Method | Description |
@@ -67,6 +138,8 @@ Returns a `Changeset` representing all structural differences.
67
138
  | `apply(hash)` | Applies changes to produce the new version |
68
139
  | `revert(hash)` | Reverts changes to produce the old version |
69
140
  | `to_h` | Serializable hash representation |
141
+ | `to_text` | Human-readable text with +/- prefixes |
142
+ | `to_json_patch` | Array of RFC 6902 JSON Patch operations |
70
143
 
71
144
  ### `Change`
72
145
 
@@ -81,9 +154,10 @@ Returns a `Changeset` representing all structural differences.
81
154
 
82
155
  ## Development
83
156
 
84
- ```sh
157
+ ```bash
85
158
  bundle install
86
- bundle exec rspec
159
+ bundle exec rspec # Run tests
160
+ bundle exec rubocop # Check code style
87
161
  ```
88
162
 
89
163
  ## License
@@ -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.2'
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,18 +1,20 @@
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.2
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-19 00:00:00.000000000 Z
12
12
  dependencies: []
13
- description:
13
+ description: A Ruby library for deep structural diffing of hashes, arrays, and nested
14
+ objects with apply/revert, JSON Patch output, similarity scoring, and configurable
15
+ ignore paths.
14
16
  email:
15
- - philiprehberger@gmail.com
17
+ - me@philiprehberger.com
16
18
  executables: []
17
19
  extensions: []
18
20
  extra_rdoc_files: []
@@ -24,6 +26,8 @@ files:
24
26
  - lib/philiprehberger/differ/change.rb
25
27
  - lib/philiprehberger/differ/changeset.rb
26
28
  - lib/philiprehberger/differ/comparator.rb
29
+ - lib/philiprehberger/differ/formatters.rb
30
+ - lib/philiprehberger/differ/similarity.rb
27
31
  - lib/philiprehberger/differ/version.rb
28
32
  homepage: https://github.com/philiprehberger/rb-differ
29
33
  licenses: