Что такое дискриминатор (Discriminator) в Entity Framework Core?

Ответ

Дискриминатор — это специальный столбец, который Entity Framework Core автоматически добавляет в таблицу базы данных при использовании стратегии наследования TPH (Table Per Hierarchy). Значение в этом столбце определяет, к какому конкретному типу-наследнику относится каждая строка в таблице.

Зачем это нужно? TPH позволяет хранить всю иерархию классов в одной таблице, что упрощает запросы (не нужны JOIN), но требует способа различать типы. Дискриминатор решает эту проблему.

Пример настройки:

// Модель
public abstract class BillingDetail
{
    public int Id { get; set; }
    public string Owner { get; set; }
    public string Number { get; set; }
}

public class BankAccount : BillingDetail
{
    public string BankName { get; set; }
    public string Swift { get; set; }
}

public class CreditCard : BillingDetail
{
    public int CardType { get; set; }
    public string ExpiryMonth { get; set; }
}

// Конфигурация в DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<BillingDetail>()
        .HasDiscriminator<string>(nameof(BillingDetailType)) // Имя столбца
        .HasValue<BankAccount>("BankAccount") // Значение для BankAccount
        .HasValue<CreditCard>("CreditCard");  // Значение для CreditCard
}

Результирующая таблица в БД:

CREATE TABLE BillingDetails (
    Id INT PRIMARY KEY,
    Owner NVARCHAR(MAX),
    Number NVARCHAR(MAX),
    BankName NVARCHAR(MAX), -- NULL для CreditCard
    Swift NVARCHAR(MAX),    -- NULL для CreditCard
    CardType INT,           -- NULL для BankAccount
    ExpiryMonth NVARCHAR(MAX), -- NULL для BankAccount
    BillingDetailType NVARCHAR(MAX) NOT NULL -- Дискриминатор
);

Важные моменты:

  • Имя столбца и значения можно кастомизировать, как показано выше.
  • Тип дискриминатора может быть string, int, enum.
  • При запросе к DbSet<BillingDetail> EF Core автоматически добавляет в SQL условие WHERE Discriminator = '...' в зависимости от того, запрашиваете вы базовый класс или конкретного наследника.
  • Недостаток TPH: Столбцы, специфичные для наследников, должны допускать NULL, что может привести к "раздуванию" таблицы при глубоких иерархиях.

Ответ 18+ 🔞

Давай разжую про этот самый дискриминатор, а то звучит как что-то из инквизиции, а на деле — простая штука.

Представь, есть у тебя абстрактная сущность BillingDetail — типа платёжка какая-то. А от неё уже торчат два конкретных наследника: BankAccount (банковский счёт) и CreditCard (кредитка). И вот Entity Framework, такой хитрожопый, предлагает: "А давайте, ребята, мы всю эту семейку будем хранить в одной таблице в базе, а?" Это и есть стратегия TPH — одна таблица на всю иерархию.

И тут же встаёт вопрос: ну ладно, слили всё в одну кучу, но как потом, выгребая данные, понять — эта строка про банковский счёт или про кредитку? Вот для этого и вводится дискриминатор — специальный столбец-метка, который EF Core сам добавляет в таблицу и сам же им рулит.

По сути, это такой светофор в каждой строке. Значение "BankAccount" — значит, тут лежат данные счёта, "CreditCard" — ну, ты понял. Без этого столбца была бы полная анархия, все сущности перемешались, и ни черта не разберёшь.

Как это настраивается? Да элементарно, в переопределении OnModelCreating:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<BillingDetail>()
        // Говорим: "Добавь столбец-дискриминатор с типом string и назови его 'BillingDetailType'"
        .HasDiscriminator<string>("BillingDetailType")
        // "Если это BankAccount — пиши в тот столбец 'BankAccount'"
        .HasValue<BankAccount>("BankAccount")
        // "Если CreditCard — пиши 'CreditCard'"
        .HasValue<CreditCard>("CreditCard");
}

Что в базе получится? Одна большая таблица, где будут все поля от родителя и от всех детей, плюс наш волшебный столбец.

CREATE TABLE BillingDetails (
    Id INT PRIMARY KEY,
    Owner NVARCHAR(MAX),          -- поле из BillingDetail
    Number NVARCHAR(MAX),         -- поле из BillingDetail
    BankName NVARCHAR(MAX),       -- поле из BankAccount (будет NULL для кредиток)
    Swift NVARCHAR(MAX),          -- поле из BankAccount (будет NULL для кредиток)
    CardType INT,                 -- поле из CreditCard (будет NULL для счетов)
    ExpiryMonth NVARCHAR(MAX),    -- поле из CreditCard (будет NULL для счетов)
    BillingDetailType NVARCHAR(MAX) NOT NULL -- Вот он, наш дискриминатор!
);

Как это работает в жизни? Когда ты пишешь context.BillingDetails.OfType<BankAccount>().ToList(), EF Core генерит умный SQL, который сам добавит условие WHERE BillingDetailType = 'BankAccount'. И ты получишь только счета, без всякой возни. Красота!

Но есть, конечно, и ложка дёгтя. Главный косяк TPH — таблица быстро превращается в ёлку с игрушками. Для каждого наследника добавляются его уникальные поля, и они автоматически nullable. Если наследников много и у каждого по 5-10 своих полей — таблица становится широкой, как Сибирь, и половина её будет забита NULL'ами. Зато JOIN'ов не нужно — все данные в одном месте. Выбор между производительностью на чтение и эффективностью хранения.

Короче, дискриминатор — это не страшно. Это просто метка, которая помогает EF Core не запутаться в собственном огороде из сущностей, когда они все живут в одной таблице.