Разработка

Нужно ли вручную заполнять Id при BulkInsertOrUpdate в EFCore.BulkExtensions

admin 1 мин чтения
Нужно ли вручную заполнять Id при BulkInsertOrUpdate в EFCore.BulkExtensions

После импорта старые строки остались на месте, а рядом появились их копии. Дело часто не в транзакции и не в скорости записи: BulkInsertOrUpdate просто не узнал существующие записи по первичному ключу.

Правило короткое. Если строка уже есть в базе, передавай её реальный Id. Для новой строки оставляй числовой Id равным 0 — но только когда модель EF Core и провайдер базы генерируют ключ при добавлении.

Как BulkInsertOrUpdate выбирает между вставкой и обновлением

BulkInsertOrUpdate сравнивает первичный ключ объекта с ключами строк в таблице. Нашёл совпадение — обновляет строку. Не нашёл — вставляет новую. Именно так поведение метода описано в документации EFCore.BulkExtensions.

Серверный шкаф с открытой панелью в утреннем свете

Допустим, в таблице лежит товар с Id = 42. При импорте тот же товар пришёл с Id = 0, потому что ключ потерялся по дороге. Библиотека сама не станет искать совпадение по названию, артикулу или похожему полю. Для неё это новый объект.

На ревью интеграции проверяй четыре случая:

  • Id = 42, строка с таким ключом есть — библиотека обновит её;
  • Id = 42, такого ключа нет — библиотека перейдёт к вставке, а результат будет зависеть от модели и базы;
  • Id = 0, ключ генерирует база — появится новая строка с новым ключом;
  • Id = 0, ключ должно назначить приложение — объект не готов к вставке.

Bulk-операция ускоряет запись, но не восстанавливает потерянные связи. В бенчмарке EFCore.BulkExtensions вставка 100 тысяч строк на SQL Server 2019 заняла 3 секунды, обычный EF справился за 11 секунд. Тест запускали на Intel Core i7-10510U, 16 ГБ DDR3 и SSD Samsung объёмом 512 ГБ. Это сравнение показывает выигрыш на крупном пакете, но ничего не говорит о правильности сопоставления строк.

Когда новый объект можно передать с Id = 0

EF Core по соглашению генерирует значения одиночных первичных ключей типов short, int, long и Guid, если приложение не передало ключ. Для числового первичного ключа SQL Server обычно создаёт столбец IDENTITY. Механика описана в документации Microsoft о генерируемых значениях EF Core.

Для такой модели новый объект можно создать так:

var customer = new Customer
{
 Id = 0,
 Name = "ООО Север"
};

Ноль — стандартное значение CLR для int. Он показывает, что приложение не задало ключ явно. При вставке ключ назначит база.

Но EF Core и EFCore.BulkExtensions отвечают за разные части операции. Документация Microsoft объясняет генерацию ключа в модели, а документация библиотеки — выбор между вставкой и обновлением. Одинаковое поведение identity-значений для всех версий пакета, провайдеров и сочетаний BulkConfig из этого не следует.

Поэтому правило «всем новым объектам ставим 0» опасно своей широтой. Пользуйся им лишь тогда, когда проверил генерацию ключа при добавлении на своей модели и СУБД.

Когда Id должно назначить приложение

При настройке через DatabaseGeneratedOption.None или ValueGeneratedNever база ключ не создаёт. Его должно присвоить приложение до вставки.

Рабочее место у патч-панели в полутьме
modelBuilder.Entity<Customer>()
.Property(x => x.Id)
.ValueGeneratedNever();

Здесь объект с Id = 0 — уже не заготовка для identity-вставки. У него просто нет ключа, который обязано назначить приложение.

Одного типа свойства для проверки мало. Посмотри настройку ключа в модели:

var property = dbContext.Model
.FindEntityType(typeof(Customer))
 ?.FindProperty(nameof);

var generation = property?.ValueGenerated;

Для нового числового ключа с генерацией при вставке ожидается ValueGenerated.OnAdd. Если модель возвращает Never, назначь ключ до bulk-операции.

С GUID механика иная. Провайдер EF Core для SQL Server умеет создавать последовательный GUID на стороне клиента. Поэтому заполненный GUID ещё не доказывает, что объект прочитан из базы: значение могло появиться сразу после создания сущности.

Смешанный список допустим, потерянные ключи — нет

В одном пакете могут лежать существующие сущности с заполненными ключами и новые сущности с Id = 0. Сам по себе такой список корректен.

Дисковый массив в стеллаже на фоне бетонной стены

Проблема начинается, когда ноль получают строки, которые уже существуют. BulkInsertOrUpdate отправит их в ветку вставки. При следующем импорте они снова придут без локального ключа — и таблица снова вырастет, если операцию не остановит другое ограничение.

Признак Id != 0 тоже не доказывает, что строка существует. Внешняя система может прислать Id = 9001, которого нет в локальной таблице. Библиотека при отсутствии совпадения выберет вставку, но общий результат для явного значения в identity-столбце документация не определяет. На него влияют SQL Server, параметры bulk-операции и версия пакета.

Проверь этот сценарий отдельно — на той же СУБД и версии EFCore.BulkExtensions, что стоят в продакшене.

Если входные данные не содержат локального первичного ключа, остаются два рабочих пути:

  • прочитать соответствия между внешним стабильным ключом и локальным Id, а затем заполнить ключи существующих объектов;
  • настроить подтверждённый уникальный ключ для сопоставления вместо первичного, если нужную конфигурацию поддерживают твои версия EFCore.BulkExtensions и провайдер.

Название клиента или адрес электронной почты без ограничения уникальности не годятся. Две одинаковые строки сделают обновление неоднозначным.

Провайдер базы меняет механику вставки

EFCore.BulkExtensions работает с каждой СУБД через её собственные средства:

  • SQL Server — SqlBulkCopy и MERGE;
  • PostgreSQL 9.5 и новее — COPY BINARY и ON CONFLICT;
  • MySQL 8 и новее — MySqlBulkCopy и ON DUPLICATE;
  • SQLite — обычный SQL и UPSERT.

Эти механизмы перечислены в документации EFCore.BulkExtensions. Настройка ValueGeneratedOnAdd определяет, когда генерируется значение, но не задаёт сам способ генерации. Его выбирает провайдер EF Core — Microsoft оговаривает это отдельно.

Значит, вывод про IDENTITY относится к SQL Server. Не переноси его без теста на PostgreSQL, MySQL или SQLite. Даже на SQL Server поведение и скорость зависят не только от bulk-вставки: проблемы параллелизма отдельно разобраны в материале про работу 1С с SQL Server.

Что проверить перед рабочим импортом

Прогони четыре теста на реляционной тестовой базе:

  1. Передай существующую запись с её реальным первичным ключом. Убедись, что изменилась та же строка.
  2. Передай новый объект со стандартным значением ключа. Проверь, что после вставки он получил новый Id.
  3. Передай объект с ненулевым ключом, которого нет в таблице. Зафиксируй результат своей конфигурации: вставка или ошибка.
  4. Отправь тот же пакет повторно. Число строк не должно увеличиться.

UseInMemoryDatabase здесь не поможет: InMemory-провайдер не поддерживает реляционные методы EFCore.BulkExtensions. Используй контейнер или отдельную тестовую базу той же СУБД, что работает в продакшене.

Оставь в ревью одно правило: существующим строкам передавай первичный ключ из базы; новым — стандартное значение, если генерация ключа подтверждена; записи без стабильного ключа сначала сопоставляй и лишь затем отправляй в BulkInsertOrUpdate.