philiprehberger-differ 0.1.1
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 +7 -0
- data/CHANGELOG.md +17 -0
- data/LICENSE +21 -0
- data/README.md +86 -0
- data/lib/philiprehberger/differ/change.rb +28 -0
- data/lib/philiprehberger/differ/changeset.rb +98 -0
- data/lib/philiprehberger/differ/comparator.rb +64 -0
- data/lib/philiprehberger/differ/version.rb +7 -0
- data/lib/philiprehberger/differ.rb +17 -0
- metadata +55 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 96c88d008a0fb8168a7038993cc1f189f2a8aa62088945665d24e7b37737d761
|
|
4
|
+
data.tar.gz: a79dd26f9cad34c542d8676b280788ae05b92f1e1a5f20d1dc82658b0d551890
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: bd3744a25e0395061247952151faafbd37559ca515e2bef30ad0a248cfa06ca3bed731027681eccec1fc6d9d53b20c4774ddd68d414082696dfde83dd3536040
|
|
7
|
+
data.tar.gz: '014894c94e4bb8409eb6badcb157c43bdbdc4878fe0c9521e882fd4bc44ef721e099821aa087623e06de83e201fb79ec0c84e163066db2c003ef5787c5490d48'
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this gem will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-03-15
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Initial release
|
|
14
|
+
- Deep comparison of hashes arrays and nested structures
|
|
15
|
+
- Path-based change descriptions
|
|
16
|
+
- Patch and unpatch support
|
|
17
|
+
- Serializable changeset output
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 philiprehberger
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# philiprehberger-differ
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/philiprehberger-differ)
|
|
4
|
+
|
|
5
|
+
Deep structural diff for hashes, arrays, and nested objects in Ruby.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Add to your Gemfile:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem 'philiprehberger-differ'
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Or install directly:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
gem install philiprehberger-differ
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
require 'philiprehberger/differ'
|
|
25
|
+
|
|
26
|
+
old_data = { name: 'Alice', age: 30, address: { city: 'Berlin' } }
|
|
27
|
+
new_data = { name: 'Alice', age: 31, address: { city: 'Vienna' }, email: 'alice@example.com' }
|
|
28
|
+
|
|
29
|
+
changeset = Philiprehberger::Differ.diff(old_data, new_data)
|
|
30
|
+
|
|
31
|
+
changeset.changed? # => true
|
|
32
|
+
changeset.changes # => [Change, Change, ...]
|
|
33
|
+
changeset.added # => changes where type == :added
|
|
34
|
+
changeset.removed # => changes where type == :removed
|
|
35
|
+
changeset.changed # => changes where type == :changed
|
|
36
|
+
|
|
37
|
+
# Apply changes to produce new version from old
|
|
38
|
+
result = changeset.apply(old_data)
|
|
39
|
+
|
|
40
|
+
# Revert changes to produce old version from new
|
|
41
|
+
original = changeset.revert(new_data)
|
|
42
|
+
|
|
43
|
+
# Ignore specific paths
|
|
44
|
+
changeset = Philiprehberger::Differ.diff(old_data, new_data, ignore: ['age'])
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## API
|
|
48
|
+
|
|
49
|
+
### `Philiprehberger::Differ.diff(old_val, new_val, ignore: [])`
|
|
50
|
+
|
|
51
|
+
Returns a `Changeset` representing all structural differences.
|
|
52
|
+
|
|
53
|
+
### `Changeset`
|
|
54
|
+
|
|
55
|
+
| Method | Description |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `changed?` | Returns `true` if any differences exist |
|
|
58
|
+
| `changes` | All `Change` objects |
|
|
59
|
+
| `added` | Changes where `type == :added` |
|
|
60
|
+
| `removed` | Changes where `type == :removed` |
|
|
61
|
+
| `changed` | Changes where `type == :changed` |
|
|
62
|
+
| `apply(hash)` | Applies changes to produce the new version |
|
|
63
|
+
| `revert(hash)` | Reverts changes to produce the old version |
|
|
64
|
+
| `to_h` | Serializable hash representation |
|
|
65
|
+
|
|
66
|
+
### `Change`
|
|
67
|
+
|
|
68
|
+
| Attribute | Description |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `path` | Dot-notation path to the changed value |
|
|
71
|
+
| `type` | `:added`, `:removed`, or `:changed` |
|
|
72
|
+
| `old_value` | Previous value (`nil` for additions) |
|
|
73
|
+
| `new_value` | New value (`nil` for removals) |
|
|
74
|
+
| `to_s` | Human-readable string |
|
|
75
|
+
| `to_h` | Serializable hash |
|
|
76
|
+
|
|
77
|
+
## Development
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
bundle install
|
|
81
|
+
bundle exec rspec
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Philiprehberger
|
|
4
|
+
module Differ
|
|
5
|
+
class Change
|
|
6
|
+
attr_reader :path, :type, :old_value, :new_value
|
|
7
|
+
|
|
8
|
+
def initialize(path:, type:, old_value: nil, new_value: nil)
|
|
9
|
+
@path = path
|
|
10
|
+
@type = type
|
|
11
|
+
@old_value = old_value
|
|
12
|
+
@new_value = new_value
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def to_s
|
|
16
|
+
case type
|
|
17
|
+
when :added then "Added #{path}: #{new_value.inspect}"
|
|
18
|
+
when :removed then "Removed #{path}: #{old_value.inspect}"
|
|
19
|
+
when :changed then "Changed #{path}: #{old_value.inspect} -> #{new_value.inspect}"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_h
|
|
24
|
+
{ path: path, type: type, old_value: old_value, new_value: new_value }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Philiprehberger
|
|
4
|
+
module Differ
|
|
5
|
+
class Changeset
|
|
6
|
+
attr_reader :changes
|
|
7
|
+
|
|
8
|
+
def initialize(changes = [])
|
|
9
|
+
@changes = changes
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def changed?
|
|
13
|
+
!@changes.empty?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def added
|
|
17
|
+
@changes.select { |c| c.type == :added }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def removed
|
|
21
|
+
@changes.select { |c| c.type == :removed }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def changed
|
|
25
|
+
@changes.select { |c| c.type == :changed }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def apply(hash)
|
|
29
|
+
result = deep_dup(hash)
|
|
30
|
+
@changes.each { |c| apply_change(result, c) }
|
|
31
|
+
result
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def revert(hash)
|
|
35
|
+
result = deep_dup(hash)
|
|
36
|
+
@changes.each { |c| revert_change(result, c) }
|
|
37
|
+
result
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def to_h
|
|
41
|
+
{ changes: @changes.map(&:to_h) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def apply_change(hash, change)
|
|
47
|
+
keys = parse_path(change.path)
|
|
48
|
+
target = dig_to_parent(hash, keys)
|
|
49
|
+
key = coerce_key(target, keys.last)
|
|
50
|
+
|
|
51
|
+
case change.type
|
|
52
|
+
when :added, :changed then target[key] = change.new_value
|
|
53
|
+
when :removed then target.is_a?(Hash) ? target.delete(key) : target.delete_at(key)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def revert_change(hash, change)
|
|
58
|
+
keys = parse_path(change.path)
|
|
59
|
+
target = dig_to_parent(hash, keys)
|
|
60
|
+
key = coerce_key(target, keys.last)
|
|
61
|
+
|
|
62
|
+
case change.type
|
|
63
|
+
when :added then target.is_a?(Hash) ? target.delete(key) : target.delete_at(key)
|
|
64
|
+
when :removed, :changed then target[key] = change.old_value
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def parse_path(path)
|
|
69
|
+
path.to_s.split('.')
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def dig_to_parent(hash, keys)
|
|
73
|
+
parent = hash
|
|
74
|
+
keys[0..-2].each { |k| parent = parent[coerce_key(parent, k)] }
|
|
75
|
+
parent
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def coerce_key(target, key)
|
|
79
|
+
return key.to_i if target.is_a?(Array)
|
|
80
|
+
return key.to_sym if target.is_a?(Hash) && (target.key?(key.to_sym) || symbol_keys?(target))
|
|
81
|
+
|
|
82
|
+
key
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def symbol_keys?(hash)
|
|
86
|
+
hash.keys.any?(Symbol)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def deep_dup(obj)
|
|
90
|
+
case obj
|
|
91
|
+
when Hash then obj.each_with_object({}) { |(k, v), h| h[k] = deep_dup(v) }
|
|
92
|
+
when Array then obj.map { |v| deep_dup(v) }
|
|
93
|
+
else obj
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Philiprehberger
|
|
4
|
+
module Differ
|
|
5
|
+
class Comparator
|
|
6
|
+
def self.call(old_val, new_val, path: '', ignore: [])
|
|
7
|
+
return [] if ignore.include?(path)
|
|
8
|
+
|
|
9
|
+
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)
|
|
12
|
+
else compare_scalars(old_val, new_val, path)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.compare_hashes(old_hash, new_hash, path, ignore)
|
|
17
|
+
(old_hash.keys + new_hash.keys).uniq.each_with_object([]) do |key, changes|
|
|
18
|
+
full_path = path.empty? ? key.to_s : "#{path}.#{key}"
|
|
19
|
+
next if ignore.include?(full_path)
|
|
20
|
+
|
|
21
|
+
changes.concat(hash_key_diff(old_hash, new_hash, key, full_path, ignore))
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.hash_key_diff(old_hash, new_hash, key, full_path, ignore)
|
|
26
|
+
if !old_hash.key?(key)
|
|
27
|
+
[Change.new(path: full_path, type: :added, new_value: new_hash[key])]
|
|
28
|
+
elsif !new_hash.key?(key)
|
|
29
|
+
[Change.new(path: full_path, type: :removed, old_value: old_hash[key])]
|
|
30
|
+
else
|
|
31
|
+
call(old_hash[key], new_hash[key], path: full_path, ignore: ignore)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.compare_arrays(old_arr, new_arr, path, ignore)
|
|
36
|
+
[old_arr.length, new_arr.length].max.times.each_with_object([]) do |idx, changes|
|
|
37
|
+
full_path = "#{path}.#{idx}"
|
|
38
|
+
next if ignore.include?(full_path)
|
|
39
|
+
|
|
40
|
+
changes.concat(array_idx_diff(old_arr, new_arr, idx, full_path, ignore))
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.array_idx_diff(old_arr, new_arr, idx, full_path, ignore)
|
|
45
|
+
if idx >= old_arr.length
|
|
46
|
+
[Change.new(path: full_path, type: :added, new_value: new_arr[idx])]
|
|
47
|
+
elsif idx >= new_arr.length
|
|
48
|
+
[Change.new(path: full_path, type: :removed, old_value: old_arr[idx])]
|
|
49
|
+
else
|
|
50
|
+
call(old_arr[idx], new_arr[idx], path: full_path, ignore: ignore)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.compare_scalars(old_val, new_val, path)
|
|
55
|
+
return [] if old_val == new_val
|
|
56
|
+
|
|
57
|
+
[Change.new(path: path, type: :changed, old_value: old_val, new_value: new_val)]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private_class_method :compare_hashes, :compare_arrays, :compare_scalars,
|
|
61
|
+
:hash_key_diff, :array_idx_diff
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'differ/version'
|
|
4
|
+
require_relative 'differ/change'
|
|
5
|
+
require_relative 'differ/changeset'
|
|
6
|
+
require_relative 'differ/comparator'
|
|
7
|
+
|
|
8
|
+
module Philiprehberger
|
|
9
|
+
module Differ
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
|
|
12
|
+
def self.diff(old_val, new_val, ignore: [])
|
|
13
|
+
changes = Comparator.call(old_val, new_val, ignore: ignore)
|
|
14
|
+
Changeset.new(changes)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: philiprehberger-differ
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Philip Rehberger
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-03-15 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description:
|
|
14
|
+
email:
|
|
15
|
+
- philiprehberger@gmail.com
|
|
16
|
+
executables: []
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- CHANGELOG.md
|
|
21
|
+
- LICENSE
|
|
22
|
+
- README.md
|
|
23
|
+
- lib/philiprehberger/differ.rb
|
|
24
|
+
- lib/philiprehberger/differ/change.rb
|
|
25
|
+
- lib/philiprehberger/differ/changeset.rb
|
|
26
|
+
- lib/philiprehberger/differ/comparator.rb
|
|
27
|
+
- lib/philiprehberger/differ/version.rb
|
|
28
|
+
homepage: https://github.com/philiprehberger/rb-differ
|
|
29
|
+
licenses:
|
|
30
|
+
- MIT
|
|
31
|
+
metadata:
|
|
32
|
+
homepage_uri: https://github.com/philiprehberger/rb-differ
|
|
33
|
+
source_code_uri: https://github.com/philiprehberger/rb-differ
|
|
34
|
+
changelog_uri: https://github.com/philiprehberger/rb-differ/blob/main/CHANGELOG.md
|
|
35
|
+
rubygems_mfa_required: 'true'
|
|
36
|
+
post_install_message:
|
|
37
|
+
rdoc_options: []
|
|
38
|
+
require_paths:
|
|
39
|
+
- lib
|
|
40
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
41
|
+
requirements:
|
|
42
|
+
- - ">="
|
|
43
|
+
- !ruby/object:Gem::Version
|
|
44
|
+
version: 3.1.0
|
|
45
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '0'
|
|
50
|
+
requirements: []
|
|
51
|
+
rubygems_version: 3.5.22
|
|
52
|
+
signing_key:
|
|
53
|
+
specification_version: 4
|
|
54
|
+
summary: Deep structural diff for hashes, arrays, and nested objects
|
|
55
|
+
test_files: []
|