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 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
+ [![Gem Version](https://badge.fury.io/rb/philiprehberger-differ.svg)](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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module Differ
5
+ VERSION = '0.1.1'
6
+ end
7
+ 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: []