Start Debugging

Как использовать последовательность Oracle для генерации первичных ключей в EF Core

Привязка ключа EF Core к последовательности Oracle через UseSequence в Oracle.EntityFrameworkCore 10: какой DDL и какой SQL для INSERT на самом деле генерирует провайдер, как сослаться на существующую последовательность или унаследованный триггер, почему на кавычках и HiLo спотыкаются чаще всего и как перевести identity-столбец на последовательность без ORA-00001.

Коротко: в Oracle.EntityFrameworkCore 10.x объявите последовательность и направьте на нее ключ собственным методом провайдера UseSequence: modelBuilder.HasSequence<int>("ORDER_SEQ").StartsAt(1000);, затем modelBuilder.Entity<Order>().Property(o => o.Id).UseSequence("ORDER_SEQ");. Провайдер превращает это в значение столбца по умолчанию, "Id" NUMBER(10) DEFAULT ("ORDER_SEQ".NEXTVAL), не включает ключ в INSERT и считывает значение обратно через RETURNING "Id" INTO. Не ограничивайтесь одним ValueGeneratedOnAdd() (так вы получите identity-столбец, а не свою последовательность), пишите имя последовательности ровно в том регистре, в котором его хранит Oracle (обычно в верхнем), и не используйте UseHiLo для ключей int/long: собственный README Oracle называет это неподдерживаемым.

Все, что ниже, проверено на Oracle.EntityFrameworkCore 10.23.26301 (опубликован 2026-09-08, сборка 10.0.23.1) с EF Core 10 и .NET 10 SDK 10.0.302. На машине, где я писал эту статью, нет Oracle Database, поэтому показанный SQL это то, что генерирует провайдер, снятое офлайн: GenerateCreateScript() для DDL, IMigrationsSqlGenerator для миграций и перехватчик команд, который забирает текст команды SaveChanges до того, как она ушла бы по сети. Никаких замеров времени и реальных обращений к базе здесь не заявляется.

Почему ключи в Oracle оказываются на последовательностях

Последовательности появились в Oracle задолго до identity-столбцов (те пришли в 12c), поэтому в большинстве доставшихся вам схем Oracle используется по одной последовательности на таблицу, обычно <TABLE>_SEQ, и часто триггер BEFORE INSERT, который копирует NEXTVAL в ключ. Даже в новых схемах команды выбирают последовательность вместо identity, потому что именованную последовательность можно разделить между несколькими таблицами, другие приложения могут читать ее через SELECT ORDER_SEQ.NEXTVAL FROM DUAL, а явные значения ключа она принимает без какого-либо особого режима.

Провайдер Oracle для EF Core все это поддерживает, но по умолчанию смотрит в другую сторону. Из коробки ключ int с именем Id получает стратегию IdentityColumn:

-- Oracle.EntityFrameworkCore 10.23.26301, default convention for an int key
"Id" NUMBER(10) GENERATED BY DEFAULT ON NULL AS IDENTITY NOT NULL,

Это identity-столбец на основе скрытой системной последовательности (ISEQ$$_...), а не той, которой вы дали имя. Если DBA выдал вам ORDER_SEQ или на ваших таблицах уже есть триггеры, это нужно явно указать в модели.

API провайдера 10.x

Провайдер Oracle поставляет собственное перечисление стратегий генерации значений, отдельное от SQL Server. Рефлексия по сборке 10.23.26301 дает:

OracleValueGenerationStrategy: None, SequenceHiLo, IdentityColumn, Sequence
OracleSQLCompatibility:        DatabaseVersion19, DatabaseVersion21, DatabaseVersion23

и такие точки входа для последовательностей:

Обратите внимание, что перечисление совместимости начинается с 19. Старые версии провайдера принимали UseOracleSQLCompatibility("11"), и тогда провайдер создавал последовательность плюс триггер для каждого генерируемого ключа. В провайдере 10.x этого режима больше нет: значение DatabaseVersion19 дает тот же столбец GENERATED BY DEFAULT ON NULL AS IDENTITY, что и 23. Если нужны последовательности, их приходится запрашивать для каждого свойства.

Пошагово: привязка ключа к именованной последовательности

  1. Установите провайдер, соответствующий мажорной версии EF Core. Для EF Core 10 это Oracle.EntityFrameworkCore 10.23.x; nuspec фиксирует Microsoft.EntityFrameworkCore.Relational в диапазоне [10.0.0, 11.0.0), а сборки под EF Core 11 пока нет.
  2. Объявите последовательность через HasSequence, чтобы начальным значением, шагом и границами управляли миграции.
  3. Вызовите UseSequence для ключа с тем же именем (и схемой, если она есть).
  4. Добавьте миграцию и прочитайте сгенерированный SQL, прежде чем ее применять.
// .NET 10, EF Core 10, Oracle.EntityFrameworkCore 10.23.26301
using Microsoft.EntityFrameworkCore;

public class Order
{
    public int Id { get; set; }
    public string Customer { get; set; } = "";
}

public class ShopContext : DbContext
{
    public DbSet<Order> Orders => Set<Order>();

    protected override void OnConfiguring(DbContextOptionsBuilder options) =>
        options.UseOracle(
            "User Id=app;Password=...;Data Source=dbhost:1521/FREEPDB1",
            o => o.UseOracleSQLCompatibility(OracleSQLCompatibility.DatabaseVersion23));

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.HasSequence<int>("ORDER_SEQ")
            .StartsAt(1000)
            .IncrementsBy(1);

        modelBuilder.Entity<Order>()
            .Property(o => o.Id)
            .UseSequence("ORDER_SEQ");
    }
}

DDL, который провайдер генерирует для этой модели:

-- Oracle.EntityFrameworkCore 10.23.26301, UseSequence("ORDER_SEQ")
CREATE SEQUENCE "ORDER_SEQ" START WITH 1000 INCREMENT BY 1 NOMINVALUE NOMAXVALUE NOCYCLE

BEGIN
EXECUTE IMMEDIATE 'CREATE TABLE
"Orders" (
    "Id" NUMBER(10) DEFAULT ("ORDER_SEQ".NEXTVAL) NOT NULL,
    "Customer" NVARCHAR2(2000) NOT NULL,
    CONSTRAINT "PK_Orders" PRIMARY KEY ("Id")
)';
END;

Последовательность становится обычным значением столбца по умолчанию, что Oracle допускает начиная с 12c. Это важно: все остальное, что вставляет строки в таблицу без указания Id (скрипт SQL*Plus, ETL-задание, другой сервис), получает ключ из той же последовательности, а не только EF.

Если пропустить HasSequence и вызвать только UseSequence("ORDER_SEQ"), провайдер сам добавит последовательность в модель с START WITH 1 INCREMENT BY 1. Для новой схемы это нормально, но только явное объявление позволяет задать StartsAt, HasMax или IsCyclic.

Что на самом деле отправляет SaveChanges

Когда ключ привязан к последовательности, db.Orders.Add(new Order { Customer = "ACME" }) оставляет Id равным 0 и помечает его как временное значение. Затем SaveChanges отправляет один анонимный блок PL/SQL:

-- captured from SaveChanges, Oracle.EntityFrameworkCore 10.23.26301
DECLARE
TYPE "rOrders_0" IS RECORD
(
"Id" NUMBER(10)
);
TYPE "tOrders_0" IS TABLE OF "rOrders_0";
"lOrders_0" "tOrders_0";
BEGIN
"lOrders_0" := "tOrders_0"();
"lOrders_0".extend(1);
INSERT INTO "Orders" ("Customer")
VALUES (:p0)
RETURNING "Id" INTO "lOrders_0"(1)."Id";
OPEN :cur0 FOR SELECT "lOrders_0"(1)."Id" FROM DUAL;
END;
-- params: :p0=ACME (Input), :cur0 (Output, ref cursor)

Ключевого столбца нет в списке столбцов, Oracle заполняет его из значения по умолчанию, а RETURNING ... INTO возвращает сгенерированное значение через ref cursor, чтобы EF мог записать его в order.Id. Это одно обращение к базе на пакет SaveChanges, и форма та же, что и в случае с identity-столбцом. С точки зрения EF и “identity”, и “значение по умолчанию из последовательности” означают “база генерирует значение при вставке”; вся разница живет в DDL.

Теперь задайте ключ сами (new Order { Id = 500, Customer = "ACME" }):

-- captured from SaveChanges when Id is set explicitly
BEGIN
INSERT INTO "Orders" ("Id", "Customer")
VALUES (:p0, :p1);
END;
-- params: :p0=500, :p1=ACME

Никакого RETURNING, никакого особого режима. EF отправляет значение ключа, отличное от значения по умолчанию, как есть, а значение столбца по умолчанию просто не срабатывает, когда значение передано. Имейте в виду, что последовательность этого тоже не замечает: ORDER_SEQ все равно выдаст 500, когда до него дойдет, и эта вставка упадет на первичном ключе. Identity-столбцы провайдера (BY DEFAULT ON NULL) ведут себя так же. Практическая разница в том, что поведение именованной последовательности легко понять, ее можно сдвинуть за импортированные данные одним ALTER SEQUENCE, а другой код может вызвать NEXTVAL, чтобы зарезервировать ключ до вставки.

Ссылка на уже существующую последовательность

В существующей схеме обычно не нужно, чтобы EF создавал последовательность, нужно, чтобы он использовал ту, что уже есть. Здесь важны три вещи.

Регистр. Oracle переводит идентификаторы без кавычек в верхний регистр, поэтому CREATE SEQUENCE order_seq создает ORDER_SEQ. Провайдер EF всегда ставит кавычки. UseSequence("order_seq") дает:

CREATE SEQUENCE "order_seq" START WITH 1 INCREMENT BY 1 NOMINVALUE NOMAXVALUE NOCYCLE
"Id" NUMBER(10) DEFAULT ("order_seq".NEXTVAL) NOT NULL,

"order_seq" и ORDER_SEQ это два разных объекта. На существующей базе такое значение по умолчанию падает с ORA-02289: sequence does not exist. Используйте имя ровно в том виде, в каком его выводит SELECT sequence_name FROM user_sequences.

Схема. Если последовательность находится в другой схеме, передайте ее в оба вызова:

// .NET 10, EF Core 10, Oracle.EntityFrameworkCore 10.23.26301
modelBuilder.HasSequence<long>("ORDER_SEQ", "SALES")
    .StartsAt(1000)
    .HasMax(99999999);

modelBuilder.Entity<Order>()
    .Property(o => o.Id)
    .UseSequence("ORDER_SEQ", "SALES");

Сгенерированный скрипт сначала выполняет проверку на PL/SQL, которая выбрасывает ORA-01435, если пользователя SALES не существует, затем создает "SALES"."ORDER_SEQ" и значение столбца по умолчанию "SALES"."ORDER_SEQ".NEXTVAL. Пользователю вашего приложения нужно право SELECT на эту последовательность, иначе вставки будут падать во время выполнения, хотя миграция прошла успешно под более привилегированной учетной записью.

Миграции. У EF нет ExcludeFromMigrations для последовательностей. Если последовательность уже существует, удалите вызов CreateSequence из первой миграции после ее генерации (снимок модели по-прежнему будет его содержать, и это именно то, что нужно) или зафиксируйте базовое состояние базы пустой начальной миграцией.

Унаследованные таблицы, заполняемые триггером

Во многих схемах Oracle значения по умолчанию у столбца нет вовсе. Вместо этого работу выполняет триггер:

CREATE OR REPLACE TRIGGER ORDERS_BI
BEFORE INSERT ON "Orders" FOR EACH ROW
BEGIN
  :NEW."Id" := ORDER_SEQ.NEXTVAL;
END;

Это можно отобразить, не трогая базу: сообщите EF, что значение генерируется при добавлении, и отключите соглашение об identity, чтобы будущая миграция не попыталась добавить к столбцу GENERATED ... AS IDENTITY.

// .NET 10, EF Core 10, Oracle.EntityFrameworkCore 10.23.26301
using Oracle.EntityFrameworkCore.Metadata;

modelBuilder.Entity<Order>()
    .Property(o => o.Id)
    .ValueGeneratedOnAdd()
    .Metadata.SetValueGenerationStrategy(OracleValueGenerationStrategy.None);

С таким отображением вставка, которую отправляет EF, байт в байт совпадает с блоком INSERT ... RETURNING "Id" INTO, показанным выше. Триггер срабатывает до записи строки, а RETURNING читает итоговую строку, так что EF получает значение из триггера.

Две ловушки с триггерами:

В долгосрочной перспективе чище перенести логику триггера в значение столбца по умолчанию и отобразить его через UseSequence: один механизм, видимый в DDL и понятный миграциям.

UseKeySequences для всей модели

Если каждая таблица должна получить собственную последовательность, UseKeySequences() применяет стратегию Sequence ко всем генерируемым ключам:

// .NET 10, EF Core 10, Oracle.EntityFrameworkCore 10.23.26301
protected override void OnModelCreating(ModelBuilder modelBuilder) =>
    modelBuilder.UseKeySequences();

Для Order создаются "OrderSequence" и DEFAULT ("OrderSequence".NEXTVAL). Имя состоит из имени сущности и суффикса и сохраняет смешанный регистр, потому что провайдер берет его в кавычки. Теперь любой, кто пишет SQL вручную, должен набирать "OrderSequence".NEXTVAL с кавычками. Если ваш стандарт именования это ORDERS_SEQ, привязывайте последовательности для каждого свойства отдельно или сочетайте этот подход с соглашением об именовании вроде описанных в статье о пользовательских соглашениях об именовании для ключей и индексов.

Почему не UseHiLo

Провайдер принимает UseHiLo("ORDER_HILO") для ключа int и делает то, что делает hi/lo: создает "ORDER_HILO" START WITH 1 INCREMENT BY 10 без значения столбца по умолчанию и при первом Add синхронно выполняет SELECT "ORDER_HILO".NEXTVAL FROM DUAL, чтобы зарезервировать блок. В моем перехвате три добавления получили id 1, 2, 3 еще до SaveChanges, а вставка отправила все три ключа явно.

Но README 10.23.26301 в разделе о последовательностях говорит, что методы расширения HiLo не поддерживаются, “за исключением столбцов с типами данных Char, UInt, ULong и UByte”. Строить стратегию ключей на том, что поставщик документирует как неподдерживаемое для int и long, это плохая сделка, а у hi/lo в Oracle есть еще две издержки: другие клиенты, которые вставляют строки не через EF, ничего не знают о схеме блоков, а дополнительный запрос NEXTVAL выполняется внутри Add, то есть это синхронное обращение к базе даже из асинхронного кода. Если ключи нужны до SaveChanges, генерируйте GUID или ULID на стороне клиента.

Перевод identity-столбца на последовательность

Именно эта миграция нужна большинству команд: таблица была создана более ранней версией EF с identity по умолчанию, а теперь должна использовать ORDER_SEQ. Измените отображение, добавьте миграцию, и провайдер сгенерирует:

-- IMigrationsSqlGenerator output, identity -> UseSequence("ORDER_SEQ")
CREATE SEQUENCE "ORDER_SEQ" START WITH 1000 INCREMENT BY 1 NOMINVALUE NOMAXVALUE NOCYCLE

DECLARE
   v_Count INTEGER;
BEGIN
  SELECT COUNT(*) INTO v_Count
  FROM ALL_TAB_IDENTITY_COLS T
  WHERE T.TABLE_NAME = N'Orders'
  AND T.COLUMN_NAME = 'Id';
  IF v_Count > 0 THEN
    EXECUTE IMMEDIATE 'ALTER  TABLE "Orders" MODIFY "Id" DROP IDENTITY';
  END IF;
END;

-- followed by a block that runs:
-- ALTER TABLE "Orders" MODIFY "Id" DEFAULT ("ORDER_SEQ".NEXTVAL)

Она удаляет identity и добавляет значение по умолчанию без пересоздания таблицы, что и требуется. Чего она знать не может, так это сколько строк уже есть в таблице. StartsAt(1000) для таблицы, у которой MAX("Id") равен 48210, означает, что первая вставка после развёртывания упадет с ORA-00001: unique constraint (PK_Orders) violated, как и следующие 47210. Перед генерацией миграции запросите текущий максимум и задайте StartsAt с хорошим запасом выше него или добавьте после CreateSequence шаг migrationBuilder.Sql(...), который сдвигает последовательность за пределы данных.

Последующее изменение StartsAt порождает RestartSequenceOperation, которую провайдер выдает как:

ALTER SEQUENCE "ORDER_SEQ" RESTART START WITH 5000

ALTER SEQUENCE ... RESTART задокументирован начиная с Oracle 19c. В README провайдера до сих пор есть старая строка о том, что “последовательность нельзя перезапустить”, поэтому проверьте эту инструкцию, прежде чем полагаться на нее в продакшн-миграции, особенно если вы запускаете ее через бандл миграций, где во время развёртывания SQL никто не читает.

Пропуски, кеширование и RAC

Последовательность никогда не возвращает значения обратно. Справочник Oracle по CREATE SEQUENCE говорит об этом прямо: по умолчанию действует CACHE 20, кешированные значения теряются при сбое экземпляра, а номера, использованные в транзакции, которая откатилась, пропускаются. Провайдер генерирует CREATE SEQUENCE без предложения CACHE, так что вы получаете это значение по умолчанию. Пропуски нормальны и ничего не стоят; не добавляйте NOCACHE, чтобы их “исправить”: это сериализует каждую вставку на обновлении словаря данных.

В RAC каждый экземпляр кеширует собственный диапазон, поэтому ключи с двух узлов перемежаются не по порядку. Oracle отмечает, что порядок “обычно не важен для последовательностей, генерирующих первичные ключи”. Если нужны номера без пропусков, видимые людям (номера счетов с юридическими требованиями), это другая задача: последовательность для нее неподходящий инструмент в любой базе данных.

Что почитать дальше

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад