Как правильно сравнивать сущности (Entity) в Entity Framework?

Ответ

Сравнение сущностей в EF Core имеет нюансы из-за механизма отслеживания изменений (change tracking). Вот основные подходы:

1. Сравнение по первичному ключу (Id) — основной способ для сущностей Сущность определяется своим идентификатором, а не значениями полей. Это согласуется с концепцией Entity в DDD.

public class Product : IEquatable<Product>
{
    public int Id { get; set; }
    public string Name { get; set; }

    public bool Equals(Product other)
    {
        if (ReferenceEquals(null, other)) return false;
        if (ReferenceEquals(this, other)) return true;
        // Сравниваем только Id, даже если сущности не отслеживаются
        return Id != 0 && Id == other.Id;
    }

    public override bool Equals(object obj) => Equals(obj as Product);
    public override int GetHashCode() => Id.GetHashCode();
}
// Использование
var product1 = await context.Products.FindAsync(1);
var product2 = new Product { Id = 1 };
bool areEqual = product1.Equals(product2); // true, т.к. Id совпадают

2. Учет состояния отслеживания (DbContext) EF Core считает две ссылки равными, если они представляют одну и ту же отслеживаемую сущность.

var p1 = await context.Products.FirstAsync(p => p.Id == 1);
var p2 = await context.Products.FirstAsync(p => p.Id == 1);
// p1 == p2 будет true, если запрос использовал отслеживание (AsNoTracking не применялся)
// и оба объекта получены в одном экземпляре DbContext.

3. Сравнение объектов-значений (Value Objects) Для вложенных объектов, которые определяются всеми своими полями, нужно сравнивать каждое свойство.

public record Address(string Street, string City, string ZipCode); // Используем record для автоматического сравнения
// Или вручную:
public class Address : IEquatable<Address>
{
    public string Street { get; init; }
    ...
    public bool Equals(Address other) => other != null && 
           Street == other.Street && 
           City == other.City && 
           ZipCode == other.ZipCode;
    public override int GetHashCode() => HashCode.Combine(Street, City, ZipCode);
}

Ключевое правило: Для сущностей сравнивайте по Id. Для объектов-значений сравнивайте по всем значимым полям. Всегда переопределяйте GetHashCode вместе с Equals.

Ответ 18+ 🔞

Ну ты смотри, какие тонкости всплывают, когда начинаешь сравнивать сущности в EF Core! Это ж не просто так, взять и через == их ткнуть. Там под капотом целый цирк с отслеживанием изменений разворачивается, блядь.

Смотри, главная идея, которую надо в башку вбить: сущность (Entity) и объект-значение (Value Object) — это две большие разницы, и сравниваются они по-разному, как водка и пиво. И если это перепутать, будет тебе веселье, в рот меня чих-пых.

Первый подход, для сущностей: сравниваем по ID, и всё тут. Сущность — это как паспорт. Её определяет номер, а не то, как она выглядит или как её зовут. Поменял имя — всё равно тот же человек. Поэтому сравниваем только по Id. Вот смотри, как это выглядит в коде:

public class Product : IEquatable<Product>
{
    public int Id { get; set; }
    public string Name { get; set; }

    public bool Equals(Product other)
    {
        // Если другой объект — пустое место, сразу false
        if (ReferenceEquals(null, other)) return false;
        // Если это один и тот же объект в памяти — очевидно, true
        if (ReferenceEquals(this, other)) return true;
        // А вот тут магия: сравниваем ТОЛЬКО ID.
        // Id != 0 проверяет, что это вообще сохранённая сущность, а не новая.
        return Id != 0 && Id == other.Id;
    }

    public override bool Equals(object obj) => Equals(obj as Product);
    // GetHashCode ОБЯЗАТЕЛЬНО переопределяем, иначе всё сломается в коллекциях!
    public override int GetHashCode() => Id.GetHashCode();
}

// Используем
var product1 = await context.Products.FindAsync(1); // Вытащили из базы
var product2 = new Product { Id = 1 }; // Создали вручную с таким же ID
bool areEqual = product1.Equals(product2); // true, ёпта! Потому что ID совпали.

Второй момент, про отслеживание (Change Tracking). Это вообще отдельная песня. EF Core, тот ещё затейник, он может считать две ссылки равными, если они указывают на одну и ту же отслеживаемую сущность в его памяти. Смотри:

var p1 = await context.Products.FirstAsync(p => p.Id == 1);
var p2 = await context.Products.FirstAsync(p => p.Id == 1);
// Интересный вопрос: p1 == p2?
// Будет true, если оба запроса были без AsNoTracking() и выполнены в рамках ОДНОГО И ТОГО ЖЕ экземпляра DbContext.
// Потому что контекст умный, он вернёт тебе ссылку на один и тот же объект, который уже отслеживает.
// А если контексты разные или стоит AsNoTracking — то это будут два разных объекта в памяти, и == даст false, хотя в базе-то одна запись!

Третий случай, для объектов-значений (Value Objects). Вот тут уже не отделаешься одним ID. Объект-значение определяется ВСЕМИ своими полями целиком. Адрес, деньги, цвет — если хоть что-то поменялось, это уже другое значение. Тут надо сравнивать всё подряд.

Самый лёгкий способ в современных шарагах — использовать record. Это такая волшебная структура, которая сама за тебя всё сравнение и хеш-код сделает.

public record Address(string Street, string City, string ZipCode);
// Всё! Equals, GetHashCode уже реализованы, сравниваются все поля.
// Красота, блядь.

Ну а если ты старовер и не доверяешь рекордам, делай вручную:

public class Address : IEquatable<Address>
{
    public string Street { get; init; }
    public string City { get; init; }
    public string ZipCode { get; init; }

    public bool Equals(Address other)
    {
        if (other is null) return false;
        // Сравниваем ВСЕ значимые поля, до последней запятой!
        return Street == other.Street &&
               City == other.City &&
               ZipCode == other.ZipCode;
    }
    // GetHashCode тоже ОБЯЗАТЕЛЬНО! Иначе в словарях и хеш-сетах будет полный пиздец.
    public override int GetHashCode() => HashCode.Combine(Street, City, ZipCode);
}

Итоговая мудрость, которую надо выжечь на подкорке:

  • Для сущностей (Entity) — сравнивай только по Id. Они как люди — важна суть, а не внешность.
  • Для объектов-значений (Value Object) — сравнивай по всем полям. Они как паззл — если деталь другая, то и картинка другая.
  • И никогда, слышишь, НИКОГДА не забывай переопределять GetHashCode(), когда переопределяешь Equals. Иначе твои объекты в коллекциях будут вести себя как угорелые, и ты потом полдня будешь искать, почему Dictionary не находит ключ, который вроде бы там есть.