compare

package
v0.1.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Dec 7, 2025 License: MIT Imports: 4 Imported by: 0

README

Compare Package

A battle-tested aggregate state comparison utility built on Google's go-cmp library.

Features

  • Deep comparison of nested structs
  • Path-based field exclusion with wildcard support
  • Human-readable diff output
  • Comprehensive comparison options (ignore fields, types, sort slices, etc.)
  • High performance with minimal allocations

Installation

go get github.com/google/go-cmp/cmp

Usage

Basic Comparison
import "github.com/plaenen/steward-control/pkg/compare"

user1 := User{ID: "1", Email: "test@example.com"}
user2 := User{ID: "1", Email: "test@example.com"}

result := compare.CompareAggregates(user1, user2, nil)
if result.Equal {
    fmt.Println("Users are equal")
} else {
    fmt.Println(result.Diff)
}
Excluding Fields
// Exclude specific fields from comparison
result := compare.CompareAggregates(user1, user2, []string{
    "ID",           // Exact field
    "CreatedAt",    // Timestamp field
    "UpdatedAt",    // Another timestamp
})
Excluding Nested Fields
// Exclude nested fields using dot notation
result := compare.CompareAggregates(user1, user2, []string{
    "Profile.AvatarURL",  // Nested field
    "Metadata.Timestamp", // Another nested field
})
Wildcard Exclusions
// Exclude all fields under a path
result := compare.CompareAggregates(user1, user2, []string{
    "Metadata.*", // Exclude all metadata fields
})
Advanced Options
// Use custom comparison options
result := compare.CompareAggregatesWithOptions(
    user1, user2,
    compare.IgnoreFields(User{}, "ID", "CreatedAt"),
    compare.EquateEmpty(), // nil == empty slice
)
Available Options
  • IgnoreFields(type, fields...) - Ignore specific fields in a struct
  • IgnoreUnexportedFields(types...) - Ignore all unexported fields
  • IgnoreTypes(types...) - Ignore all values of specific types
  • SortSlices(lessFunc) - Sort slices before comparison
  • EquateEmpty() - Treat nil and empty containers as equal
Formatting Output
result := compare.CompareAggregates(expected, actual, nil)
fmt.Println(compare.FormatDiff(result))
// Output:
// ✓ Aggregates are equal
// or
// ✗ Aggregates differ:
// <detailed diff>

Performance

Benchmarks on Apple M1 Max:

BenchmarkCompareAggregates_Simple-10         226003    5340 ns/op    2045 B/op    32 allocs/op
BenchmarkCompareAggregates_WithExclusions-10 138656    9218 ns/op    4218 B/op   161 allocs/op
BenchmarkCompareAggregates_Nested-10         156056    7312 ns/op    3392 B/op    38 allocs/op
  • Simple comparison: ~5.3μs per operation
  • With exclusions: ~9.2μs per operation
  • Nested structs: ~7.3μs per operation

Use Cases

Event Sourcing Verification

Verify that rebuilding an aggregate from events produces the same state:

// Rebuild aggregate from events
rebuilt := RebuildFromEvents(events)

// Compare with expected state, excluding timestamps
result := compare.CompareAggregates(
    expected, rebuilt,
    []string{"Metadata.Timestamp", "Version"},
)

if !result.Equal {
    log.Error("Aggregate rebuild failed", "diff", result.Diff)
}
Testing

Compare expected vs actual states in tests:

func TestUserCreation(t *testing.T) {
    user := CreateUser("test@example.com")

    expected := User{
        Email: "test@example.com",
        Status: "active",
    }

    result := compare.CompareAggregates(
        expected, user,
        []string{"ID", "CreatedAt"}, // Ignore generated fields
    )

    if !result.Equal {
        t.Errorf("User creation failed:\n%s", result.Diff)
    }
}
Migration Validation

Verify data migrations preserve important state:

original := LoadFromOldDB(id)
migrated := LoadFromNewDB(id)

result := compare.CompareAggregates(
    original, migrated,
    []string{"Metadata.*"}, // Ignore metadata changes
)

if !result.Equal {
    return fmt.Errorf("migration validation failed: %s", result.Diff)
}

Path Format

  • Exact field: "FieldName"
  • Nested field: "Parent.Child.Field"
  • Wildcard: "Parent.*" (matches all fields under Parent)
  • Array element: Automatically handled by go-cmp

Comparison with reflect.DeepEqual

Feature compare.CompareAggregates reflect.DeepEqual
Exclude fields ✅ Yes ❌ No
Detailed diff ✅ Yes ❌ No
Wildcard paths ✅ Yes ❌ No
Custom options ✅ Yes ❌ No
Performance ✅ Fast ✅ Fast
Battle-tested ✅ Google's go-cmp ✅ Go stdlib

Why go-cmp?

This package uses Google's go-cmp library because it:

  • Is widely used and battle-tested across the Go ecosystem
  • Provides detailed, human-readable diffs
  • Supports extensive customization options
  • Has excellent performance characteristics
  • Is actively maintained by Google
  • Supports protocol buffers and other complex types

Contributing

When adding new comparison features, ensure:

  1. Tests cover the new functionality
  2. Benchmarks demonstrate performance impact
  3. Documentation includes usage examples
  4. The API remains simple and intuitive

License

MIT

Documentation

Overview

Example (BasicComparison)
package main

import (
	"fmt"
	"time"

	"github.com/plaenen/eventstore/pkg/compare"
)

// Example types
type Account struct {
	ID        string
	Email     string
	Name      string
	Balance   int64
	Profile   Profile
	CreatedAt time.Time
	UpdatedAt time.Time
	Metadata  Metadata
}

type Profile struct {
	Bio       string
	AvatarURL string
	Location  string
}

type Metadata struct {
	Version   int
	Timestamp time.Time
	Tags      []string
}

func main() {
	account1 := Account{
		ID:    "acc-1",
		Email: "user@example.com",
		Name:  "John Doe",
	}

	account2 := Account{
		ID:    "acc-1",
		Email: "user@example.com",
		Name:  "John Doe",
	}

	result := compare.CompareAggregates(account1, account2, nil)
	fmt.Println(compare.FormatDiff(result))
}
Output:
✓ Aggregates are equal
Example (EventSourcingVerification)
package main

import (
	"fmt"
	"time"

	"github.com/plaenen/eventstore/pkg/compare"
)

// Example types
type Account struct {
	ID        string
	Email     string
	Name      string
	Balance   int64
	Profile   Profile
	CreatedAt time.Time
	UpdatedAt time.Time
	Metadata  Metadata
}

type Profile struct {
	Bio       string
	AvatarURL string
	Location  string
}

type Metadata struct {
	Version   int
	Timestamp time.Time
	Tags      []string
}

func main() {
	// Simulate rebuilding an aggregate from events
	original := Account{
		ID:      "acc-1",
		Email:   "user@example.com",
		Name:    "John Doe",
		Balance: 10000,
	}

	// After rebuilding from events
	rebuilt := Account{
		ID:      "acc-1",
		Email:   "user@example.com",
		Name:    "John Doe",
		Balance: 10000,
	}

	// Compare, excluding runtime-generated fields
	result := compare.CompareAggregates(original, rebuilt, []string{
		"CreatedAt",
		"UpdatedAt",
		"Metadata.Timestamp",
	})

	if result.Equal {
		fmt.Println("✓ Aggregate rebuild successful")
	} else {
		fmt.Printf("✗ Rebuild failed:\n%s\n", result.Diff)
	}
}
Output:
✓ Aggregate rebuild successful
Example (ExcludeFields)
package main

import (
	"fmt"
	"time"

	"github.com/plaenen/eventstore/pkg/compare"
)

// Example types
type Account struct {
	ID        string
	Email     string
	Name      string
	Balance   int64
	Profile   Profile
	CreatedAt time.Time
	UpdatedAt time.Time
	Metadata  Metadata
}

type Profile struct {
	Bio       string
	AvatarURL string
	Location  string
}

type Metadata struct {
	Version   int
	Timestamp time.Time
	Tags      []string
}

func main() {
	now := time.Now()

	account1 := Account{
		ID:        "acc-1",
		Email:     "user@example.com",
		CreatedAt: now,
		UpdatedAt: now,
	}

	account2 := Account{
		ID:        "acc-2", // Different ID
		Email:     "user@example.com",
		CreatedAt: now.Add(time.Hour), // Different timestamp
		UpdatedAt: now.Add(time.Hour), // Different timestamp
	}

	// Compare, but ignore ID and timestamps
	result := compare.CompareAggregates(account1, account2, []string{
		"ID",
		"CreatedAt",
		"UpdatedAt",
	})

	fmt.Println(compare.FormatDiff(result))
}
Output:
✓ Aggregates are equal
Example (ExcludeNestedFields)
package main

import (
	"fmt"
	"time"

	"github.com/plaenen/eventstore/pkg/compare"
)

// Example types
type Account struct {
	ID        string
	Email     string
	Name      string
	Balance   int64
	Profile   Profile
	CreatedAt time.Time
	UpdatedAt time.Time
	Metadata  Metadata
}

type Profile struct {
	Bio       string
	AvatarURL string
	Location  string
}

type Metadata struct {
	Version   int
	Timestamp time.Time
	Tags      []string
}

func main() {
	account1 := Account{
		ID:    "acc-1",
		Email: "user@example.com",
		Profile: Profile{
			Bio:       "Software Engineer",
			AvatarURL: "https://example.com/avatar1.jpg",
			Location:  "San Francisco",
		},
	}

	account2 := Account{
		ID:    "acc-1",
		Email: "user@example.com",
		Profile: Profile{
			Bio:       "Software Engineer",
			AvatarURL: "https://example.com/avatar2.jpg", // Different
			Location:  "San Francisco",
		},
	}

	// Exclude nested field
	result := compare.CompareAggregates(account1, account2, []string{
		"Profile.AvatarURL",
	})

	fmt.Println(compare.FormatDiff(result))
}
Output:
✓ Aggregates are equal
Example (MultipleOptions)
package main

import (
	"fmt"
	"time"

	"github.com/plaenen/eventstore/pkg/compare"
)

// Example types
type Account struct {
	ID        string
	Email     string
	Name      string
	Balance   int64
	Profile   Profile
	CreatedAt time.Time
	UpdatedAt time.Time
	Metadata  Metadata
}

type Profile struct {
	Bio       string
	AvatarURL string
	Location  string
}

type Metadata struct {
	Version   int
	Timestamp time.Time
	Tags      []string
}

func main() {
	now := time.Now()

	account1 := Account{
		ID:        "acc-1",
		Email:     "user@example.com",
		Balance:   10000,
		CreatedAt: now,
		UpdatedAt: now,
	}

	account2 := Account{
		ID:        "acc-2", // Different
		Email:     "user@example.com",
		Balance:   10000,
		CreatedAt: now.Add(time.Hour), // Different
		UpdatedAt: now.Add(time.Hour), // Different
	}

	// Use multiple options
	result := compare.CompareAggregatesWithOptions(
		account1, account2,
		compare.IgnoreFields(Account{}, "ID", "CreatedAt", "UpdatedAt"),
		compare.EquateEmpty(),
	)

	fmt.Println(compare.FormatDiff(result))
}
Output:
✓ Aggregates are equal
Example (WildcardExclusion)
package main

import (
	"fmt"
	"time"

	"github.com/plaenen/eventstore/pkg/compare"
)

// Example types
type Account struct {
	ID        string
	Email     string
	Name      string
	Balance   int64
	Profile   Profile
	CreatedAt time.Time
	UpdatedAt time.Time
	Metadata  Metadata
}

type Profile struct {
	Bio       string
	AvatarURL string
	Location  string
}

type Metadata struct {
	Version   int
	Timestamp time.Time
	Tags      []string
}

func main() {
	now := time.Now()

	account1 := Account{
		ID:    "acc-1",
		Email: "user@example.com",
		Metadata: Metadata{
			Version:   1,
			Timestamp: now,
			Tags:      []string{"active"},
		},
	}

	account2 := Account{
		ID:    "acc-1",
		Email: "user@example.com",
		Metadata: Metadata{
			Version:   2,                   // Different
			Timestamp: now.Add(time.Hour),  // Different
			Tags:      []string{"premium"}, // Different
		},
	}

	// Exclude all metadata fields using wildcard
	result := compare.CompareAggregates(account1, account2, []string{
		"Metadata.*",
	})

	fmt.Println(compare.FormatDiff(result))
}
Output:
✓ Aggregates are equal
Example (WithCustomOptions)
package main

import (
	"fmt"
	"time"

	"github.com/plaenen/eventstore/pkg/compare"
)

// Example types
type Account struct {
	ID        string
	Email     string
	Name      string
	Balance   int64
	Profile   Profile
	CreatedAt time.Time
	UpdatedAt time.Time
	Metadata  Metadata
}

type Profile struct {
	Bio       string
	AvatarURL string
	Location  string
}

type Metadata struct {
	Version   int
	Timestamp time.Time
	Tags      []string
}

func main() {
	account1 := Account{
		ID:       "acc-1",
		Email:    "user@example.com",
		Metadata: Metadata{Tags: nil},
	}

	account2 := Account{
		ID:       "acc-1",
		Email:    "user@example.com",
		Metadata: Metadata{Tags: []string{}}, // Empty slice vs nil
	}

	// Without EquateEmpty, these would be different
	result := compare.CompareAggregatesWithOptions(
		account1, account2,
		compare.EquateEmpty(), // Treat nil == empty
	)

	fmt.Println(compare.FormatDiff(result))
}
Output:
✓ Aggregates are equal

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func EquateEmpty

func EquateEmpty() cmp.Option

EquateEmpty returns an option that considers nil and empty containers as equal.

Example:

result := CompareAggregatesWithOptions(
    expected, actual,
    EquateEmpty(), // nil slice == empty slice
)

func FormatDiff

func FormatDiff(result *ComparisonResult) string

FormatDiff returns a formatted comparison result

func IgnoreFields

func IgnoreFields(structType any, fields ...string) cmp.Option

IgnoreFields returns an option that ignores specific fields in a struct type.

Example:

result := CompareAggregatesWithOptions(
    expected, actual,
    IgnoreFields(User{}, "ID", "CreatedAt", "UpdatedAt"),
)

func IgnoreTypes

func IgnoreTypes(types ...any) cmp.Option

IgnoreTypes ignores all values of the specified types during comparison.

Example:

result := CompareAggregatesWithOptions(
    expected, actual,
    IgnoreTypes(time.Time{}), // Ignore all time.Time fields
)

func IgnoreUnexportedFields

func IgnoreUnexportedFields(types ...any) cmp.Option

IgnoreUnexportedFields returns an option that ignores all unexported fields in the given struct types.

Example:

result := CompareAggregatesWithOptions(
    expected, actual,
    IgnoreUnexportedFields(User{}, Profile{}),
)

func SortSlices

func SortSlices(less any) cmp.Option

SortSlices returns an option that sorts slices before comparison. Useful when slice order doesn't matter.

Example:

result := CompareAggregatesWithOptions(
    expected, actual,
    SortSlices(func(a, b string) bool { return a < b }),
)

Types

type ComparisonResult

type ComparisonResult struct {
	Equal bool
	Diff  string
}

ComparisonResult contains the result of comparing two values

func CompareAggregates

func CompareAggregates(expected, actual any, excludePaths []string) *ComparisonResult

CompareAggregates deeply compares two aggregate states, excluding specified paths

This function uses Google's go-cmp library for robust, battle-tested comparison.

Parameters:

  • expected: The expected aggregate state
  • actual: The actual aggregate state to compare against
  • excludePaths: List of paths to exclude from comparison (e.g., "Metadata.UpdatedAt", "ID")

Returns:

  • ComparisonResult with Equal=true if states match, false otherwise
  • Diff contains a human-readable diff if not equal

Example:

result := CompareAggregates(agg1.State(), agg2.State(), []string{"Metadata.Timestamp", "Version"})
if !result.Equal {
    fmt.Println(result.Diff)
}

Path Format:

  • Use dot notation for nested fields: "User.Profile.Email"
  • Use wildcards for all fields in a struct: "Metadata.*"
  • Use array notation for slices: "Items[0].Name"

func CompareAggregatesWithOptions

func CompareAggregatesWithOptions(expected, actual any, opts ...cmp.Option) *ComparisonResult

CompareAggregatesWithOptions compares two aggregates with custom cmp.Options

This allows full control over the comparison behavior using go-cmp options.

Example:

opts := []cmp.Option{
    cmpopts.IgnoreFields(User{}, "ID", "CreatedAt"),
    cmpopts.EquateApproxTime(time.Second),
}
result := CompareAggregatesWithOptions(expected, actual, opts...)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL