philiprehberger-differ

Tests Gem Version Last updated

Deep structural diff for hashes, arrays, and nested objects

Requirements

  • Ruby >= 3.1

Installation

Add to your Gemfile:

gem "philiprehberger-differ"

Or install directly:

gem install philiprehberger-differ

Usage

require "philiprehberger/differ"

old_data = { name: 'Alice', age: 30, address: { city: 'Berlin' } }
new_data = { name: 'Alice', age: 31, address: { city: 'Vienna' }, email: 'alice@example.com' }

changeset = Philiprehberger::Differ.diff(old_data, new_data)

changeset.changed?   # => true
changeset.changes    # => [Change, Change, ...]
changeset.added      # => changes where type == :added
changeset.removed    # => changes where type == :removed
changeset.changed    # => changes where type == :changed

# Apply changes to produce new version from old
result = changeset.apply(old_data)

# Revert changes to produce old version from new
original = changeset.revert(new_data)

Ignore Paths

Exclude specific keys from comparison. Supports both symbols and dot-notation strings for nested paths:

changeset = Philiprehberger::Differ.diff(old_data, new_data, ignore: [:updated_at, :metadata])

# Ignore nested paths
changeset = Philiprehberger::Differ.diff(old_data, new_data, ignore: ['user.email', 'meta.version'])

Similarity Score

Get a ratio of unchanged fields to total fields, returned as a Float between 0.0 and 1.0:

score = Philiprehberger::Differ.similarity(old_data, new_data)
# => 0.5  (half the fields are identical)

score = Philiprehberger::Differ.similarity(old_data, old_data)
# => 1.0  (identical)

Text Formatter

Human-readable text output with +/- prefixes:

changeset = Philiprehberger::Differ.diff(
  { name: 'Alice', age: 30 },
  { name: 'Bob', email: 'bob@example.com' }
)

puts changeset.to_text
# ~ name: "Alice" -> "Bob"
# - age: 30
# + email: "bob@example.com"

JSON Patch Format

Returns an array of RFC 6902 JSON Patch operations:

changeset = Philiprehberger::Differ.diff(
  { name: 'Alice', age: 30 },
  { name: 'Bob' }
)

changeset.to_json_patch
# => [
#   { op: "replace", path: "/name", value: "Bob" },
#   { op: "remove", path: "/age" }
# ]

Nested Array Diff by Key

Match array elements by a key field instead of index for smarter array comparison:

old_data = { users: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }] }
new_data = { users: [{ id: 2, name: 'Bobby' }, { id: 1, name: 'Alice' }] }

# Without array_key: detects changes at every index (order-sensitive)
# With array_key: matches by :id, only detects Bob -> Bobby change
changeset = Philiprehberger::Differ.diff(old_data, new_data, array_key: :id)

Subset Filtering

Filter a diff to only changes under a specific path:

changeset = Philiprehberger::Differ.diff(old_data, new_data)
user_changes = Philiprehberger::Differ.subset(changeset, 'user')

Three-Way Merge

result = Philiprehberger::Differ.merge(base, theirs, ours)
result[:merged]     # => merged hash
result[:conflicts]  # => [{ path:, theirs:, ours: }]

Breaking Change Detection

Philiprehberger::Differ.breaking_changes?(changeset)  # => true/false

Change Statistics

Get a structured count summary of a changeset:

changeset = Philiprehberger::Differ.diff(
  { name: 'Alice', age: 30, id: 1 },
  { name: 'Bob', email: 'bob@example.com' }
)

Philiprehberger::Differ.stats(changeset)
# => { added: 1, removed: 2, changed: 1, total: 4, paths: 4 }

API

Philiprehberger::Differ.diff(old_val, new_val, ignore: [], array_key: nil)

Returns a Changeset representing all structural differences.

Philiprehberger::Differ.similarity(old_val, new_val, ignore: [], array_key: nil)

Returns a Float between 0.0 (completely different) and 1.0 (identical).

Changeset

Method Description
changed? Returns true if any differences exist
count Number of changes
paths Array of all changed paths
include?(path) Check if a specific path was changed
summary { added:, removed:, changed: } counts
each Iterate over changes (includes Enumerable)
changes All Change objects
added Changes where type == :added
removed Changes where type == :removed
changed Changes where type == :changed
apply(hash) Applies changes to produce the new version
revert(hash) Reverts changes to produce the old version
to_h Serializable hash representation
to_text Human-readable text with +/- prefixes
to_json_patch Array of RFC 6902 JSON Patch operations
Method Description
Differ.subset(changeset, path) Filter changes to a specific path prefix
Differ.merge(base, theirs, ours) Three-way merge with conflict detection
Differ.breaking_changes?(changeset) Detect removals and type changes
.stats(changeset) Count summary of a changeset

Change

Attribute Description
path Dot-notation path to the changed value
type :added, :removed, or :changed
old_value Previous value (nil for additions)
new_value New value (nil for removals)
to_s Human-readable string
to_h Serializable hash

Development

bundle install
bundle exec rspec
bundle exec rubocop

Support

If you find this project useful:

Star the repo

🐛 Report issues

💡 Suggest features

❤️ Sponsor development

🌐 All Open Source Projects

💻 GitHub Profile

🔗 LinkedIn Profile

License

MIT