Новые возможности EF Core 7.0

EF Core 7.0 (EF7) — это следующий выпуск после EF Core 6.0, который планируется выпустить в ноябре 2022 г. Дополнительные сведения см. в разделе Plan for Entity Framework Core 7.0 и .NET Data Biweekly Обновления (2022).

EF7 в настоящее время находится на этапе предварительной версии. Последний выпуск NuGet — EF Core 7.0, предварительная версия 7.

EF7 также доступен в виде ежедневных сборок , которые содержат все последние функции EF7 и настройки API. В приведенных здесь примерах используются эти ежедневные сборки.

Совет

Вы можете запустить примеры и выполнить отладку, скачав пример кода из GitHub. Каждый раздел ссылается на исходный код, относящееся к данному разделу.

EF7 предназначен для .NET 6, поэтому его можно использовать с .NET 6 (LTS) или .NET 7.

Образец модели

Во многих приведенных ниже примерах используется простая модель с блогами, записями, тегами и авторами:

public class Blog
{
    public Blog(string name)
    {
        Name = name;
    }

    public int Id { get; private set; }
    public string Name { get; set; }
    public List<Post> Posts { get; } = new();
}

public class Post
{
    public Post(string title, string content, DateTime publishedOn)
    {
        Title = title;
        Content = content;
        PublishedOn = publishedOn;
    }

    public int Id { get; private set; }
    public string Title { get; set; }
    public string Content { get; set; }
    public DateTime PublishedOn { get; set; }
    public Blog Blog { get; set; } = null!;
    public List<Tag> Tags { get; } = new();
    public Author? Author { get; set; }
    public PostMetadata? Metadata { get; set; }
}

public class FeaturedPost : Post
{
    public FeaturedPost(string title, string content, DateTime publishedOn, string promoText)
        : base(title, content, publishedOn)
    {
        PromoText = promoText;
    }

    public string PromoText { get; set; }
}

public class Tag
{
    public Tag(string id, string text)
    {
        Id = id;
        Text = text;
    }

    public string Id { get; private set; }
    public string Text { get; set; }
    public List<Post> Posts { get; } = new();
}

public class Author
{
    public Author(string name)
    {
        Name = name;
    }

    public int Id { get; private set; }
    public string Name { get; set; }
    public ContactDetails Contact { get; set; } = null!;
    public List<Post> Posts { get; } = new();
}

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

public class ContactDetails
{
    public Address Address { get; set; } = null!;
    public string? Phone { get; set; }
}

public class Address
{
    public Address(string street, string city, string postcode, string country)
    {
        Street = street;
        City = city;
        Postcode = postcode;
        Country = country;
    }

    public string Street { get; set; }
    public string City { get; set; }
    public string Postcode { get; set; }
    public string Country { get; set; }
}

И второй агрегатный тип для метаданных post:

public class PostMetadata
{
    public PostMetadata(int views)
    {
        Views = views;
    }

    public int Views { get; set; }
    public List<SearchTerm> TopSearches { get; } = new();
    public List<Visits> TopGeographies { get; } = new();
    public List<PostUpdate> Updates { get; } = new();
}

public class SearchTerm
{
    public SearchTerm(string term, int count)
    {
        Term = term;
        Count = count;
    }

    public string Term { get; private set; }
    public int Count { get; private set; }
}

public class Visits
{
    public Visits(double latitude, double longitude, int count)
    {
        Latitude = latitude;
        Longitude = longitude;
        Count = count;
    }

    public double Latitude { get; private set; }
    public double Longitude { get; private set; }
    public int Count { get; private set; }
    public List<string>? Browsers { get; set; }
}

public class PostUpdate
{
    public PostUpdate(IPAddress postedFrom, DateTime updatedOn)
    {
        PostedFrom = postedFrom;
        UpdatedOn = updatedOn;
    }

    public IPAddress PostedFrom { get; private set; }
    public string? UpdatedBy { get; init; }
    public DateTime UpdatedOn { get; private set; }
    public List<Commit> Commits { get; } = new();
}

public class Commit
{
    public Commit(DateTime committedOn, string comment)
    {
        CommittedOn = committedOn;
        Comment = comment;
    }

    public DateTime CommittedOn { get; private set; }
    public string Comment { get; set; }
}

Совет

Пример модели можно найти в файле BlogsContext.cs.

Столбцы JSON

Большинство реляционных баз данных поддерживают столбцы, содержащие документы JSON. Json в этих столбцах можно детализировать с помощью запросов. Это позволяет, например, фильтровать и сортировать элементы документов, а также проекцию элементов из документов в результаты. Столбцы JSON позволяют реляционным базам данных принимать некоторые характеристики баз данных документов, создавая полезный гибрид между ними.

EF7 содержит поддержку столбцов JSON, не зависящих от поставщика, с реализацией для SQL Server. Эта поддержка позволяет сопоставлять статистические выражения, созданные из типов .NET, с документами JSON. Для статистических выражений можно использовать обычные запросы LINQ, которые будут преобразованы в соответствующие конструкции запросов, необходимые для детализации JSON. EF7 также поддерживает обновление и сохранение изменений в документах JSON.

Примечание

Поддержка SQLite для JSON планируется после ef7. Поставщики PostgreSQL и Pomelo MySQL уже содержат некоторую поддержку столбцов JSON. Мы будем работать с авторами этих поставщиков, чтобы согласовать поддержку JSON для всех поставщиков.

Сопоставление со столбцами JSON

В EF Core агрегатные типы определяются с помощью OwnsOne и OwnsMany. Например, рассмотрим агрегатный тип из примера модели, используемой для хранения контактных данных:

public class ContactDetails
{
    public Address Address { get; set; } = null!;
    public string? Phone { get; set; }
}

public class Address
{
    public Address(string street, string city, string postcode, string country)
    {
        Street = street;
        City = city;
        Postcode = postcode;
        Country = country;
    }

    public string Street { get; set; }
    public string City { get; set; }
    public string Postcode { get; set; }
    public string Country { get; set; }
}

Затем его можно использовать в типе сущности "владелец", например для хранения контактных данных автора:

public class Author
{
    public int Id { get; set; }
    public string Name { get; set; }
    public ContactDetails Contact { get; set; }
}

Тип агрегата настраивается в OnModelCreating с помощью OwnsOne:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Author>().OwnsOne(
        author => author.Contact, ownedNavigationBuilder =>
        {
            ownedNavigationBuilder.OwnsOne(contactDetails => contactDetails.Address);
        });
}

Совет

Приведенный здесь код получен из файла JsonColumnsSample.cs.

По умолчанию поставщики реляционных баз данных сопоставляют статистические типы, подобные этому, с той же таблицей, что и тип сущности-владельцем. То есть каждое ContactDetails свойство классов и Address сопоставляется со столбцом в Authors таблице.

Некоторые сохраненные авторы с контактными данными будут выглядеть следующим образом:

Авторы

Идентификатор Имя Contact_Address_Street Contact_Address_City Contact_Address_Postcode Contact_Address_Country Contact_Phone
1 Мэдди Монтакила 1 Main St Камбервик Грин CW1 5ZH Великобритания 01632 12345
2 Джереми Ликнесс 2 Main St Чигли CW1 5ZH Великобритания 01632 12346
3 Дэниэл Рот (Daniel Roth) 3 Main St Камбервик Грин CW1 5ZH Великобритания 01632 12347
4 Артур Викерс 15a Main St Чигли CW1 5ZH Соединенное Королевство 01632 22345
5 Брайс Ламсон 4 Main St Чигли CW1 5ZH Великобритания 01632 12349

При необходимости каждый тип сущности, составляющий агрегат, можно сопоставить с собственной таблицей:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Author>().OwnsOne(
        author => author.Contact, ownedNavigationBuilder =>
        {
            ownedNavigationBuilder.ToTable("Contacts");
            ownedNavigationBuilder.OwnsOne(
                contactDetails => contactDetails.Address, ownedOwnedNavigationBuilder =>
                {
                    ownedOwnedNavigationBuilder.ToTable("Addresses");
                });
        });
}

Затем те же данные хранятся в трех таблицах:

Авторы

Идентификатор Имя
1 Мэдди Монтакила
2 Джереми Ликнесс
3 Дэниэл Рот (Daniel Roth)
4 Артур Викерс
5 Брайс Ламсон

Контакты

AuthorId Номер телефона
1 01632 12345
2 01632 12346
3 01632 12347
4 01632 22345
5 01632 12349

Адреса

ContactDetailsAuthorId Улица Город Индекс Страна или регион
1 1 Main St Камбервик Грин CW1 5ZH Великобритания
2 2 Main St Чигли CW1 5ZH Великобритания
3 3 Main St Камбервик Грин CW1 5ZH Великобритания
4 15a Main St Чигли CW1 5ZH Соединенное Королевство
5 4 Main St Чигли CW1 5ZH Великобритания

Теперь, для интересной части. В EF7 тип агрегата ContactDetails можно сопоставить со столбцом JSON. Для этого требуется только один вызов при ToJson() настройке типа агрегата:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Author>().OwnsOne(
        author => author.Contact, ownedNavigationBuilder =>
        {
            ownedNavigationBuilder.ToJson();
            ownedNavigationBuilder.OwnsOne(contactDetails => contactDetails.Address);
        });
}

Теперь Authors таблица будет содержать столбец JSON, ContactDetails заполненный документом JSON для каждого автора:

Авторы

Идентификатор Имя Contact
1 Мэдди Монтакила {
  "Телефон":"01632 12345",
  "Address": {
    "City":"Camberwick Green",
    "Страна":"Великобритания",
    "Почтовый индекс":"CW1 5ZH",
    "Street":"1 Main St"
  }
}
2 Джереми Ликнесс {
  "Телефон":"01632 12346",
  "Address": {
    "City":"Chigley",
    "Страна":"Великобритания",
    "Почтовый индекс":"CH1 5ZH",
    "Street":"2 Main St"
  }
}
3 Дэниэл Рот (Daniel Roth) {
  "Телефон":"01632 12347",
  "Address": {
    "City":"Camberwick Green",
    "Страна":"Великобритания",
    "Почтовый индекс":"CW1 5ZH",
    "Street":"3 Main St"
  }
}
4 Артур Викерс {
  "Телефон":"01632 12348",
  "Address": {
    "City":"Chigley",
    "Страна":"Великобритания",
    "Почтовый индекс":"CH1 5ZH",
    "Street":"15a Main St"
  }
}
5 Брайс Ламсон {
  "Телефон":"01632 12349",
  "Address": {
    "City":"Chigley",
    "Страна":"Великобритания",
    "Почтовый индекс":"CH1 5ZH",
    "Street":"4 Main St"
  }
}

Совет

Такое использование статистических выражений очень похоже на то, как сопоставляются документы JSON при использовании поставщика EF Core для Azure Cosmos DB. Столбцы JSON позволяют использовать EF Core для баз данных документов к документам, внедренным в реляционную базу данных.

Приведенные выше документы JSON очень просты, но эту возможность сопоставления также можно использовать с более сложными структурами документов. Например, рассмотрим другой агрегатный тип из примера модели, который используется для представления метаданных о записи:

public class PostMetadata
{
    public PostMetadata(int views)
    {
        Views = views;
    }

    public int Views { get; set; }
    public List<SearchTerm> TopSearches { get; } = new();
    public List<Visits> TopGeographies { get; } = new();
    public List<PostUpdate> Updates { get; } = new();
}

public class SearchTerm
{
    public SearchTerm(string term, int count)
    {
        Term = term;
        Count = count;
    }

    public string Term { get; private set; }
    public int Count { get; private set; }
}

public class Visits
{
    public Visits(double latitude, double longitude, int count)
    {
        Latitude = latitude;
        Longitude = longitude;
        Count = count;
    }

    public double Latitude { get; private set; }
    public double Longitude { get; private set; }
    public int Count { get; private set; }
    public List<string>? Browsers { get; set; }
}

public class PostUpdate
{
    public PostUpdate(IPAddress postedFrom, DateTime updatedOn)
    {
        PostedFrom = postedFrom;
        UpdatedOn = updatedOn;
    }

    public IPAddress PostedFrom { get; private set; }
    public string? UpdatedBy { get; init; }
    public DateTime UpdatedOn { get; private set; }
    public List<Commit> Commits { get; } = new();
}

public class Commit
{
    public Commit(DateTime committedOn, string comment)
    {
        CommittedOn = committedOn;
        Comment = comment;
    }

    public DateTime CommittedOn { get; private set; }
    public string Comment { get; set; }
}

Этот агрегатный тип содержит несколько вложенных типов и коллекций. OwnsOne Вызовы и OwnsMany используются для сопоставления этого агрегатного типа:

modelBuilder.Entity<Post>().OwnsOne(
    post => post.Metadata, ownedNavigationBuilder =>
    {
        ownedNavigationBuilder.ToJson();
        ownedNavigationBuilder.OwnsMany(metadata => metadata.TopSearches);
        ownedNavigationBuilder.OwnsMany(metadata => metadata.TopGeographies);
        ownedNavigationBuilder.OwnsMany(
            metadata => metadata.Updates,
            ownedOwnedNavigationBuilder => ownedOwnedNavigationBuilder.OwnsMany(update => update.Commits));
    });

Совет

ToJson требуется только в корневом каталоге агрегата, чтобы сопоставить всю статистическую обработку с документом JSON.

С помощью этого сопоставления EF7 может создавать и запрашивать сложный документ JSON следующим образом:

{
  "Views": 5085,
  "TopGeographies": [
    {
      "Browsers": "Firefox, Netscape",
      "Count": 924,
      "Latitude": 110.793,
      "Longitude": 39.2431
    },
    {
      "Browsers": "Firefox, Netscape",
      "Count": 885,
      "Latitude": 133.793,
      "Longitude": 45.2431
    }
  ],
  "TopSearches": [
    {
      "Count": 9359,
      "Term": "Search #1"
    }
  ],
  "Updates": [
    {
      "PostedFrom": "127.0.0.1",
      "UpdatedBy": "Admin",
      "UpdatedOn": "1996-02-17T19:24:29.5429092Z",
      "Commits": []
    },
    {
      "PostedFrom": "127.0.0.1",
      "UpdatedBy": "Admin",
      "UpdatedOn": "2019-11-24T19:24:29.5429093Z",
      "Commits": [
        {
          "Comment": "Commit #1",
          "CommittedOn": "2022-08-21T00:00:00+01:00"
        }
      ]
    },
    {
      "PostedFrom": "127.0.0.1",
      "UpdatedBy": "Admin",
      "UpdatedOn": "1997-05-28T19:24:29.5429097Z",
      "Commits": [
        {
          "Comment": "Commit #1",
          "CommittedOn": "2022-08-21T00:00:00+01:00"
        },
        {
          "Comment": "Commit #2",
          "CommittedOn": "2022-08-21T00:00:00+01:00"
        }
      ]
    }
  ]
}

Примечание

Сопоставление пространственных типов непосредственно с JSON пока не поддерживается. В приведенном выше документе в качестве обходного решения используются double значения. Если вы заинтересованы в этом, проголосуйте за поддержку пространственных типов в столбцах JSON .

Примечание

Сопоставление коллекций примитивных типов с JSON пока не поддерживается. В приведенном выше документе используется преобразователь значений для преобразования коллекции в строку, разделенную запятыми. Проголосуйте за Json: добавьте поддержку коллекции примитивных типов , если это то, что вас интересует.

Примечание

Сопоставление собственных типов с JSON пока не поддерживается в сочетании с наследованием TPT или TPC. Проголосуйте за поддержку свойств JSON с сопоставлением наследования TPT/TPC , если это то, что вас интересует.

Запросы к столбцам JSON

Запросы к столбцам JSON работают так же, как запросы к любому другому агрегатному типу в EF Core. То есть просто используйте LINQ! Рассмотрим некоторые примеры.

Запрос для всех авторов, живущих в Chigley:

var authorsInChigley = await context.Authors
    .Where(author => author.Contact.Address.City == "Chigley")
    .ToListAsync();

При использовании SQL Server этот запрос создает следующий КОД SQL:

SELECT [a].[Id], [a].[Name], JSON_QUERY([a].[Contact],'$')
FROM [Authors] AS [a]
WHERE CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max)) = N'Chigley'

Обратите внимание на использование JSON_VALUE для получения City из Address внутри документа JSON.

Select можно использовать для извлечения и проецирования элементов из документа JSON:

var postcodesInChigley = await context.Authors
    .Where(author => author.Contact.Address.City == "Chigley")
    .Select(author => author.Contact.Address.Postcode)
    .ToListAsync();

Этот запрос создает следующий код SQL:

SELECT CAST(JSON_VALUE([a].[Contact],'$.Address.Postcode') AS nvarchar(max))
FROM [Authors] AS [a]
WHERE CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max)) = N'Chigley'

Ниже приведен пример, который выполняет дополнительные действия в фильтре и проекции, а также заказ по номеру телефона в документе JSON:

var orderedAddresses = await context.Authors
    .Where(
        author => (author.Contact.Address.City == "Chigley"
                   && author.Contact.Phone != null)
                  || author.Name.StartsWith("D"))
    .OrderBy(author => author.Contact.Phone)
    .Select(
        author => author.Name + " (" + author.Contact.Address.Street
                  + ", " + author.Contact.Address.City
                  + " " + author.Contact.Address.Postcode + ")")
    .ToListAsync();

Этот запрос создает следующий код SQL:

SELECT (((((([a].[Name] + N' (') + CAST(JSON_VALUE([a].[Contact],'$.Address.Street') AS nvarchar(max))) + N', ') + CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max))) + N' ') + CAST(JSON_VALUE([a].[Contact],'$.Address.Postcode') AS nvarchar(max))) + N')'
FROM [Authors] AS [a]
WHERE (CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max)) = N'Chigley' AND CAST(JSON_VALUE([a].[Contact],'$.Phone') AS nvarchar(max)) IS NOT NULL) OR ([a].[Name] LIKE N'D%')
ORDER BY CAST(JSON_VALUE([a].[Contact],'$.Phone') AS nvarchar(max))

Если документ JSON содержит коллекции, их можно проецировать в результатах:

var postsWithViews = await context.Posts.Where(post => post.Metadata!.Views > 3000)
    .AsNoTracking()
    .Select(
        post => new
        {
            post.Author!.Name, post.Metadata!.Views, Searches = post.Metadata.TopSearches, Commits = post.Metadata.Updates
        })
    .ToListAsync();

Этот запрос создает следующий код SQL:

SELECT [a].[Name], CAST(JSON_VALUE([p].[Metadata],'$.Views') AS int), JSON_QUERY([p].[Metadata],'$.TopSearches'), [p].[Id], JSON_QUERY([p].[Metadata],'$.Updates')
FROM [Posts] AS [p]
LEFT JOIN [Authors] AS [a] ON [p].[AuthorId] = [a].[Id]
WHERE CAST(JSON_VALUE([p].[Metadata],'$.Views') AS int) > 3000

Примечание

Более сложные запросы, включающие коллекции JSON, требуют jsonpath поддержки. Проголосуйте за запросы jsonpath поддержки , если это то, что вас интересует.

Совет

Рассмотрите возможность создания индексов для повышения производительности запросов в документах JSON. Например, см. статью Индексирование данных JSON при использовании SQL Server.

Обновление столбцов JSON

SaveChanges и SaveChangesAsync работают обычным способом, чтобы обновить столбец JSON. В случае значительных изменений будет обновлен весь документ. Например, при замене большей Contact части документа для автора:

var jeremy = await context.Authors.SingleAsync(author => author.Name.StartsWith("Jeremy"));

jeremy.Contact = new() { Address = new("2 Riverside", "Trimbridge", "TB1 5ZS", "UK"), Phone = "01632 88346" };

await context.SaveChangesAsync();

В этом случае весь новый документ передается в качестве параметра:

info: 8/30/2022 20:21:24.392 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (2ms) [Parameters=[@p0='{"Phone":"01632 88346","Address":{"City":"Trimbridge","Country":"UK","Postcode":"TB1 5ZS","Street":"2 Riverside"}}' (Nullable = false) (Size = 114), @p1='2'], CommandType='Text', CommandTimeout='30']

Затем используется в UPDATE SQL:

UPDATE [Authors] SET [Contact] = @p0
OUTPUT 1
WHERE [Id] = @p1;

Однако если изменяется только вложенный документ, EF Core будет использовать JSON_MODIFY команду для обновления только поддокумента. Например, изменение внутри AddressContact документа:

var brice = await context.Authors.SingleAsync(author => author.Name.StartsWith("Brice"));

brice.Contact.Address = new("4 Riverside", "Trimbridge", "TB1 5ZS", "UK");

await context.SaveChangesAsync();

Создает следующие параметры:

info: 10/2/2022 15:51:15.895 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (2ms) [Parameters=[@p0='{"City":"Trimbridge","Country":"UK","Postcode":"TB1 5ZS","Street":"4 Riverside"}' (Nullable = false) (Size = 80), @p1='5'], CommandType='Text', CommandTimeout='30']

Который используется в с UPDATE помощью JSON_MODIFY вызова:

UPDATE [Authors] SET [Contact] = JSON_MODIFY([Contact], 'strict $.Address', JSON_QUERY(@p0))
OUTPUT 1
WHERE [Id] = @p1;

Наконец, если изменяется только одно свойство, EF Core снова использует команду "JSON_MODIFY", на этот раз для исправления только измененного значения свойства. Пример:

var arthur = await context.Authors.SingleAsync(author => author.Name.StartsWith("Arthur"));

arthur.Contact.Address.Country = "United Kingdom";

await context.SaveChangesAsync();

Создает следующие параметры:

info: 10/2/2022 15:54:05.112 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (2ms) [Parameters=[@p0='["United Kingdom"]' (Nullable = false) (Size = 18), @p1='4'], CommandType='Text', CommandTimeout='30']

Которые снова используются с :JSON_MODIFY

UPDATE [Authors] SET [Contact] = JSON_MODIFY([Contact], 'strict $.Address.Country', JSON_VALUE(@p0, '$[0]'))
OUTPUT 1
WHERE [Id] = @p1;

ExecuteUpdate и ExecuteDelete (массовые обновления)

По умолчанию EF Core отслеживает изменения сущностей, а затем отправляет обновления в базу данных при вызове SaveChanges одного из методов. Изменения отправляются только для свойств и связей, которые действительно изменились. Кроме того, отслеживаемые сущности остаются синхронизированными с изменениями, отправленными в базу данных. Этот механизм является эффективным и удобным способом отправки вставок, обновлений и удалений общего назначения в базу данных. Эти изменения также пакетируются для уменьшения количества круговых путей базы данных.

Однако иногда бывает полезно выполнять команды обновления или удаления в базе данных без использования средства отслеживания изменений. EF7 позволяет использовать новые ExecuteUpdate методы и ExecuteDelete . Эти методы применяются к запросу LINQ и обновляют или удаляют сущности в базе данных на основе результатов этого запроса. Многие сущности можно обновить с помощью одной команды, и сущности не загружаются в память, что означает, что это может привести к более эффективному обновлению и удалению.

Однако помните, что:

  • Конкретные изменения должны быть указаны явным образом; ef Core не обнаруживает их автоматически.
  • Все отслеживаемые сущности не будут синхронизированы.
  • Дополнительные команды могут быть отправлены в правильном порядке, чтобы не нарушать ограничения базы данных. Например, удаление зависимых элементов перед удалением субъекта.

Все это означает, что ExecuteUpdate методы и ExecuteDelete дополняют, а не заменяют существующий SaveChanges механизм.

Основные ExecuteDelete примеры

Совет

Приведенный здесь код получен из файла ExecuteDeleteSample.cs.

Вызов ExecuteDelete или ExecuteDeleteAsync в немедленном DbSet удалении всех сущностей этого объекта DbSet из базы данных. Например, чтобы удалить все Tag сущности, выполните приведенные ниже действия.

await context.Tags.ExecuteDeleteAsync();

При использовании SQL Server выполняется следующий код SQL:

DELETE FROM [t]
FROM [Tags] AS [t]

Что еще более интересно, запрос может содержать фильтр. Пример:

await context.Tags.Where(t => t.Text.Contains(".NET")).ExecuteDeleteAsync();

При этом выполняется следующий код SQL:

DELETE FROM [t]
FROM [Tags] AS [t]
WHERE [t].[Text] LIKE N'%.NET%'

Запрос также может использовать более сложные фильтры, включая переходы к другим типам. Например, чтобы удалить теги только из старых записей блога, выполните приведенные ниже действия.

await context.Tags.Where(t => t.Posts.All(e => e.PublishedOn.Year < 2022)).ExecuteDeleteAsync();

Выполняется:

DELETE FROM [t]
FROM [Tags] AS [t]
WHERE NOT EXISTS (
    SELECT 1
    FROM [PostTag] AS [p]
    INNER JOIN [Posts] AS [p0] ON [p].[PostsId] = [p0].[Id]
    WHERE [t].[Id] = [p].[TagsId] AND NOT (DATEPART(year, [p0].[PublishedOn]) < 2022))

Основные ExecuteUpdate примеры

Совет

Приведенный здесь код получен из файла ExecuteUpdateSample.cs.

ExecuteUpdate и ExecuteUpdateAsync ведут себя очень похоже на методы ExecuteDelete . Основное отличие заключается в том, что для обновления требуется знать, какие свойства следует обновлять и как их обновлять. Это достигается с помощью одного или нескольких вызовов .SetProperty Например, чтобы обновить для каждого блога, выполните следующие действия Name .

await context.Blogs.ExecuteUpdateAsync(
    s => s.SetProperty(b => b.Name, b => b.Name + " *Featured!*"));

Первый параметр SetProperty указывает, какое свойство следует обновить; в данном случае — Blog.Name. Второй параметр указывает способ вычисления нового значения; в этом случае путем принятия существующего значения и добавления "*Featured!*". Результирующий SQL:

UPDATE [b]
    SET [b].[Name] = [b].[Name] + N' *Featured!*'
FROM [Blogs] AS [b]

Как и в случае с ExecuteDelete, запрос можно использовать для фильтрации обновляемых сущностей. Кроме того, несколько вызовов можно SetProperty использовать для обновления нескольких свойств целевой сущности. Например, чтобы обновить Title и Content всех записей, опубликованных до 2022 года, выполните следующие действия:

await context.Posts
    .Where(p => p.PublishedOn.Year < 2022)
    .ExecuteUpdateAsync(s => s
        .SetProperty(b => b.Title, b => b.Title + " (" + b.PublishedOn.Year + ")")
        .SetProperty(b => b.Content, b => b.Content + " ( This content was published in " + b.PublishedOn.Year + ")"));

В этом случае созданный SQL немного сложнее:

UPDATE [p]
    SET [p].[Content] = (([p].[Content] + N' ( This content was published in ') + COALESCE(CAST(DATEPART(year, [p].[PublishedOn]) AS nvarchar(max)), N'')) + N')',
    [p].[Title] = (([p].[Title] + N' (') + COALESCE(CAST(DATEPART(year, [p].[PublishedOn]) AS nvarchar(max)), N'')) + N')'
FROM [Posts] AS [p]
WHERE DATEPART(year, [p].[PublishedOn]) < 2022

Наконец, опять же, как и в случае с ExecuteDelete, фильтр может ссылаться на другие таблицы. Например, чтобы обновить все теги из старых записей, выполните приведенные ниже действия.

await context.Tags
    .Where(t => t.Posts.All(e => e.PublishedOn.Year < 2022))
    .ExecuteUpdateAsync(s => s.SetProperty(t => t.Text, t => t.Text + " (old)"));

Это приводит к возникновению:

UPDATE [t]
    SET [t].[Text] = [t].[Text] + N' (old)'
FROM [Tags] AS [t]
WHERE NOT EXISTS (
    SELECT 1
    FROM [PostTag] AS [p]
    INNER JOIN [Posts] AS [p0] ON [p].[PostsId] = [p0].[Id]
    WHERE [t].[Id] = [p].[TagsId] AND NOT (DATEPART(year, [p0].[PublishedOn]) < 2022))

Наследование и несколько таблиц

ExecuteUpdate и ExecuteDelete могут работать только с одной таблицей. Это влияет на работу с различными стратегиями сопоставления наследования. Как правило, при использовании стратегии сопоставления TPH проблем не возникает, так как требуется изменить только одну таблицу. Например, удаление всех FeaturedPost сущностей:

await context.Set<FeaturedPost>().ExecuteDeleteAsync();

Создает следующий код SQL при использовании сопоставления TPH:

DELETE FROM [p]
FROM [Posts] AS [p]
WHERE [p].[Discriminator] = N'FeaturedPost'

Кроме того, при использовании стратегии сопоставления TPC в этом случае нет проблем, так как опять же требуются только изменения в одной таблице:

DELETE FROM [f]
FROM [FeaturedPosts] AS [f]

Однако попытка выполнить это при использовании стратегии сопоставления TPT завершится ошибкой, так как потребуется удалить строки из двух разных таблиц.

Добавление фильтра в запрос часто означает, что операция завершится сбоем при использовании стратегий TPC и TPT. Это связано с тем, что может потребоваться удалить строки из нескольких таблиц. Например, следующий запрос:

await context.Posts.Where(p => p.Author!.Name.StartsWith("Arthur")).ExecuteDeleteAsync();

Создает следующий sql при использовании TPH:

DELETE FROM [p]
FROM [Posts] AS [p]
LEFT JOIN [Authors] AS [a] ON [p].[AuthorId] = [a].[Id]
WHERE [a].[Name] IS NOT NULL AND ([a].[Name] LIKE N'Arthur%')

Но происходит сбой при использовании TPC или TPT.

Совет

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

ExecuteDelete связи и

Как упоминалось выше, перед удалением субъекта связи может потребоваться удалить или обновить зависимые сущности. Например, каждый из них Post зависит от связанного .Author Это означает, что автор не может быть удален, если публикация по-прежнему ссылается на него; Это приведет к нарушению ограничения внешнего ключа в базе данных. Например, при попытке выполнить следующее:

await context.Authors.ExecuteDeleteAsync();

В SQL Server будет создано следующее исключение:

Microsoft.Data.SqlClient.SqlException (0x80131904): инструкция DELETE конфликтует с ограничением REFERENCE "FK_Posts_Authors_AuthorId". Конфликт произошел в базе данных "TphBlogsContext", таблице "dbo. Сообщения", столбец "AuthorId". Выполнение данной инструкции было прервано.

Чтобы устранить эту проблему, сначала необходимо либо удалить записи, либо разорвать связь между каждой записью и ее автором, задав AuthorId свойству внешнего ключа значение NULL. Например, при использовании параметра delete:

await context.Posts.TagWith("Deleting posts...").ExecuteDeleteAsync();
await context.Authors.TagWith("Deleting authors...").ExecuteDeleteAsync();

Совет

TagWith может использоваться для добавления тегов ExecuteDelete или ExecuteUpdate так же, как и для обычных запросов.

Это приводит к двум отдельным командам; первый для удаления зависимых элементов:

-- Deleting posts...

DELETE FROM [p]
FROM [Posts] AS [p]

И второй для удаления субъектов:

-- Deleting authors...

DELETE FROM [a]
FROM [Authors] AS [a]

Важно!

Несколько ExecuteDelete команд и ExecuteUpdate не будут содержаться в одной транзакции по умолчанию. Однако API-интерфейсы транзакций DbContext можно использовать обычным способом для упаковки этих команд в транзакцию.

Совет

Отправка этих команд в рамках одного кругового пути зависит от проблемы No 10879. Проголосуйте за эту проблему, если вы хотите, чтобы она была реализована.

Настройка каскадных удалений в базе данных может быть очень полезной. В нашей модели связь между Blog и Post является обязательной, что приводит к тому, что EF Core настраивает каскадное удаление по соглашению. Это означает, что при удалении блога в базе данных также будут удалены все его зависимые записи. Затем следует, что для удаления всех блогов и записей нам нужно только удалить блоги:

await context.Blogs.ExecuteDeleteAsync();

Это приводит к следующему sql:

DELETE FROM [b]
FROM [Blogs] AS [b]

При удалении блога все связанные записи также будут удалены настроенным каскадным удалением.

Быстрый saveChanges

В EF7 производительность SaveChanges и SaveChangesAsync была значительно улучшена. В некоторых сценариях сохранение изменений теперь выполняется в четыре раза быстрее, чем в EF Core 6.0!

Большинство из этих улучшений происходят от:

  • Выполнение меньшего количества циклов к базе данных
  • Создание более быстрого SQL

Ниже приведены некоторые примеры этих улучшений.

Примечание

Подробное обсуждение этих изменений см. в статье Объявление о выпуске Entity Framework Core 7 Preview 6: Performance Edition в блоге .NET.

Совет

Приведенный здесь код поступает из файла SaveChangesPerformanceSample.cs.

Ненужные транзакции исключаются

Все современные реляционные базы данных гарантируют транзакцию для (большинства) отдельных инструкций SQL. То есть инструкция никогда не будет завершена только частично, даже если возникнет ошибка. В этих случаях EF7 не запускает явную транзакцию.

Например, при просмотре журнала для следующего вызова :SaveChanges

await context.AddAsync(new Blog { Name = "MyBlog" });
await context.SaveChangesAsync();

Показывает, что в EF Core 6.0 команда упаковывается командами, INSERT чтобы начать, а затем зафиксировать транзакцию:

dbug: 9/29/2022 11:43:09.196 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 9/29/2022 11:43:09.265 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (27ms) [Parameters=[@p0='MyBlog' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Name])
      VALUES (@p0);
      SELECT [Id]
      FROM [Blogs]
      WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();
dbug: 9/29/2022 11:43:09.297 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

EF7 обнаруживает, что транзакция здесь не требуется, и поэтому удаляет следующие вызовы:

info: 9/29/2022 11:42:34.776 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (25ms) [Parameters=[@p0='MyBlog' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Name])
      OUTPUT INSERTED.[Id]
      VALUES (@p0);

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

Улучшен sql для простой вставки удостоверений

В приведенном выше случае вставляется одна строка с ключевым столбцом IDENTITY и никакими другими значениями, созданными базой данных. EF7 упрощает SQL в этом случае с помощью OUTPUT INSERTED. Хотя это упрощение не является допустимым для многих других случаев, по-прежнему важно улучшить, так как такой тип вставки из одной строки очень распространен во многих приложениях.

Вставка нескольких строк

В EF Core 6.0 подход по умолчанию для вставки нескольких строк был обусловлен ограничениями в SQL Server поддержки таблиц с триггерами. Мы хотели убедиться, что интерфейс по умолчанию работает даже для меньшинства пользователей с триггерами в таблицах. Это означало, что мы не могли использовать простое OUTPUT предложение, так как в SQL Server это не работает с триггерами. Вместо этого при вставке нескольких сущностей EF Core 6.0 создал несколько довольно запутал SQL. Например, этот вызов :SaveChanges

for (var i = 0; i < 4; i++)
{
    await context.AddAsync(new Blog { Name = "Foo" + i });
}

await context.SaveChangesAsync();

При выполнении SQL Server с EF Core 6.0 выполняются следующие действия:

dbug: 9/30/2022 17:19:51.919 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 9/30/2022 17:19:51.993 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (27ms) [Parameters=[@p0='Foo0' (Nullable = false) (Size = 4000), @p1='Foo1' (Nullable = false) (Size = 4000), @p2='Foo2' (Nullable = false) (Size = 4000), @p3='Foo3' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      DECLARE @inserted0 TABLE ([Id] int, [_Position] [int]);
      MERGE [Blogs] USING (
      VALUES (@p0, 0),
      (@p1, 1),
      (@p2, 2),
      (@p3, 3)) AS i ([Name], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([Name])
      VALUES (i.[Name])
      OUTPUT INSERTED.[Id], i._Position
      INTO @inserted0;

      SELECT [i].[Id] FROM @inserted0 i
      ORDER BY [i].[_Position];
dbug: 9/30/2022 17:19:52.023 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

Важно!

Несмотря на то, что это сложно, пакетная обработка нескольких таких вставок по-прежнему значительно быстрее, чем отправка одной команды для каждой вставки.

В EF7 вы по-прежнему можете получить этот SQL, если таблицы содержат триггеры, но в общем случае мы создаем гораздо более эффективные, хотя и несколько сложные команды:

info: 9/30/2022 17:40:37.612 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (4ms) [Parameters=[@p0='Foo0' (Nullable = false) (Size = 4000), @p1='Foo1' (Nullable = false) (Size = 4000), @p2='Foo2' (Nullable = false) (Size = 4000), @p3='Foo3' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      MERGE [Blogs] USING (
      VALUES (@p0, 0),
      (@p1, 1),
      (@p2, 2),
      (@p3, 3)) AS i ([Name], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([Name])
      VALUES (i.[Name])
      OUTPUT INSERTED.[Id], i._Position;

Транзакция исчезла, как в случае с одной вставкой, так как MERGE является одной инструкцией, защищенной неявной транзакцией. Кроме того, временная таблица исчезла, и теперь предложение OUTPUT отправляет созданные идентификаторы непосредственно клиенту. Это может быть в четыре раза быстрее, чем в EF Core 6.0, в зависимости от факторов среды, таких как задержка между приложением и базой данных.

Триггеры

Если в таблице есть триггеры, вызов SaveChanges в приведенном выше коде вызовет исключение:

Необработанное исключение. Microsoft.EntityFrameworkCore.DbUpdateException:
не удалось сохранить изменения, так как в целевой таблице есть триггеры базы данных. Настройте тип сущности соответствующим образом. Дополнительные сведения см. в этой статье https://aka.ms/efcore-docs-sqlserver-save-changes-and-triggers .
>--- Microsoft.Data.SqlClient.SqlException (0x80131904):
целевая таблица BlogsWithTriggers инструкции DML не может иметь включенных триггеров, если инструкция содержит предложение OUTPUT без предложения INTO.

Следующий код можно использовать для информирования EF Core о том, что в таблице есть триггер:

modelBuilder
    .Entity<BlogWithTrigger>()
    .ToTable(tb => tb.HasTrigger("TRG_InsertUpdateBlog"));

Затем EF7 вернется к EF Core 6.0 SQL при отправке команд вставки и обновления для этой таблицы.

Дополнительные сведения, включая соглашение об автоматической настройке всех сопоставленных таблиц с триггерами, см. в статье SQL Server таблицам с триггерами теперь требуется специальная конфигурация EF Core в документации по критическим изменениям EF7.

Меньше циклов для вставки графов

Рассмотрите возможность вставки графа сущностей, содержащих новую основную сущность, а также новые зависимые сущности с внешними ключами, ссылающимися на новый субъект. Пример:

await context.AddAsync(
    new Blog { Name = "MyBlog", Posts = { new() { Title = "My first post" }, new() { Title = "My second post" } } });
await context.SaveChangesAsync();

Если первичный ключ субъекта создается базой данных, то значение, заданное для внешнего ключа в зависимом, неизвестно до тех пор, пока субъект не будет вставлен. EF Core создает для этого две круглые пути: одна для вставки субъекта и возврата нового первичного ключа, а вторая — для вставки зависимых элементов с заданным значением внешнего ключа. А так как для этого существует два оператора, требуется транзакция, то есть в общей сложности имеется четыре циклического цикла:

dbug: 10/1/2022 13:12:02.517 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 10/1/2022 13:12:02.517 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[@p0='MyBlog' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Name])
      OUTPUT INSERTED.[Id]
      VALUES (@p0);
info: 10/1/2022 13:12:02.529 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (5ms) [Parameters=[@p1='6', @p2='My first post' (Nullable = false) (Size = 4000), @p3='6', @p4='My second post' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      MERGE [Post] USING (
      VALUES (@p1, @p2, 0),
      (@p3, @p4, 1)) AS i ([BlogId], [Title], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([BlogId], [Title])
      VALUES (i.[BlogId], i.[Title])
      OUTPUT INSERTED.[Id], i._Position;
dbug: 10/1/2022 13:12:02.531 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

Однако в некоторых случаях значение первичного ключа известно до вставки субъекта. В том числе:

  • Значения ключей, которые не создаются автоматически
  • Значения ключей, созданные на клиенте, например Guid ключи
  • Значения ключей, создаваемые на сервере пакетами, например при использовании генератора значений hi-lo

В EF7 эти варианты теперь оптимизированы для одного кругового пути. Например, в приведенном выше случае на SQL Server первичный Blog.Id ключ можно настроить для использования стратегии создания hi-lo:

modelBuilder.Entity<Blog>().Property(e => e.Id).UseHiLo();
modelBuilder.Entity<Post>().Property(e => e.Id).UseHiLo();

Вызов SaveChanges из приведенного выше теперь оптимизирован для одной круговой передачи для вставок.

dbug: 10/1/2022 21:51:55.805 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 10/1/2022 21:51:55.806 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[@p0='9', @p1='MyBlog' (Nullable = false) (Size = 4000), @p2='10', @p3='9', @p4='My first post' (Nullable = false) (Size = 4000), @p5='11', @p6='9', @p7='My second post' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Id], [Name])
      VALUES (@p0, @p1);
      INSERT INTO [Posts] ([Id], [BlogId], [Title])
      VALUES (@p2, @p3, @p4),
      (@p5, @p6, @p7);
dbug: 10/1/2022 21:51:55.807 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

Обратите внимание, что здесь по-прежнему требуется транзакция. Это связано с тем, что вставки вставляются в две отдельные таблицы.

EF7 также использует один пакет в других случаях, когда EF Core 6.0 создает несколько. Например, при удалении и вставке строк в одну таблицу.

Значение SaveChanges

Как показано в некоторых примерах, сохранение результатов в базе данных может быть сложной задачей. Здесь использование чего-то вроде EF Core действительно показывает свою ценность. EF Core:

  • Пакетная обработка нескольких команд вставки, обновления и удаления для сокращения циклов
  • Определяет, требуется ли явная транзакция
  • Определяет порядок вставки, обновления и удаления сущностей, чтобы не нарушать ограничения базы данных.
  • Гарантирует, что созданные базы данных значения возвращаются эффективно и распространяются обратно в сущности.
  • Автоматически задает значения внешнего ключа с помощью значений, созданных для первичных ключей
  • Обнаружение конфликтов параллелизма

Кроме того, для разных систем баз данных для многих из этих случаев требуется другой SQL. Поставщик базы данных EF Core работает с EF Core, чтобы обеспечить отправку правильных и эффективных команд для каждого случая.

Сопоставление наследования таблицы на конкретный тип (TPC)

По умолчанию в EF Core иерархия наследования типов .NET сопоставляется с одной таблицей базы данных. Это называется стратегией сопоставления "таблица на иерархию" (TPH ). В EF Core 5.0 появилась стратегия "таблица на тип" (TPT ), которая поддерживает сопоставление каждого типа .NET с отдельной таблицей базы данных. В EF7 представлена стратегия "таблица на конкретный тип" (TPC). TPC также сопоставляет типы .NET с разными таблицами, но таким образом, чтобы устранить некоторые распространенные проблемы с производительностью в стратегии TPT.

Совет

Приведенный здесь код поступает из файла TpcInheritanceSample.cs.

Совет

Команда EF продемонстрировала и подробно рассказала о сопоставлении TPC в эпизоде стенда сообщества данных .NET. Как и все эпизоды Сообщества Standup, вы можете смотреть TPC эпизод сейчас на YouTube.

Схема базы данных TPC

Стратегия TPC похожа на стратегию TPT, за исключением того, что для каждого конкретного типа в иерархии создается другая таблица, но таблицы не создаются для абстрактных типов— отсюда и название table-per-concrete-type. Как и в случае с TPT, сама таблица указывает тип сохраненного объекта. Однако, в отличие от сопоставления TPT, каждая таблица содержит столбцы для каждого свойства в конкретном типе и его базовых типах. Схемы баз данных TPC денормализованы.

Например, рассмотрите возможность сопоставления этой иерархии:

public abstract class Animal
{
    protected Animal(string name)
    {
        Name = name;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public abstract string Species { get; }

    public Food? Food { get; set; }
}

public abstract class Pet : Animal
{
    protected Pet(string name)
        : base(name)
    {
    }

    public string? Vet { get; set; }

    public ICollection<Human> Humans { get; } = new List<Human>();
}

public class FarmAnimal : Animal
{
    public FarmAnimal(string name, string species)
        : base(name)
    {
        Species = species;
    }

    public override string Species { get; }

    [Precision(18, 2)]
    public decimal Value { get; set; }

    public override string ToString()
        => $"Farm animal '{Name}' ({Species}/{Id}) worth {Value:C} eats {Food?.ToString() ?? "<Unknown>"}";
}

public class Cat : Pet
{
    public Cat(string name, string educationLevel)
        : base(name)
    {
        EducationLevel = educationLevel;
    }

    public string EducationLevel { get; set; }
    public override string Species => "Felis catus";

    public override string ToString()
        => $"Cat '{Name}' ({Species}/{Id}) with education '{EducationLevel}' eats {Food?.ToString() ?? "<Unknown>"}";
}

public class Dog : Pet
{
    public Dog(string name, string favoriteToy)
        : base(name)
    {
        FavoriteToy = favoriteToy;
    }

    public string FavoriteToy { get; set; }
    public override string Species => "Canis familiaris";

    public override string ToString()
        => $"Dog '{Name}' ({Species}/{Id}) with favorite toy '{FavoriteToy}' eats {Food?.ToString() ?? "<Unknown>"}";
}

public class Human : Animal
{
    public Human(string name)
        : base(name)
    {
    }

    public override string Species => "Homo sapiens";

    public Animal? FavoriteAnimal { get; set; }
    public ICollection<Pet> Pets { get; } = new List<Pet>();

    public override string ToString()
        => $"Human '{Name}' ({Species}/{Id}) with favorite animal '{FavoriteAnimal?.Name ?? "<Unknown>"}'" +
           $" eats {Food?.ToString() ?? "<Unknown>"}";
}

При использовании SQL Server для этой иерархии создаются следующие таблицы:

CREATE TABLE [Cats] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [Name] nvarchar(max) NOT NULL,
    [FoodId] uniqueidentifier NULL,
    [Vet] nvarchar(max) NULL,
    [EducationLevel] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Cats] PRIMARY KEY ([Id]));

CREATE TABLE [Dogs] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [Name] nvarchar(max) NOT NULL,
    [FoodId] uniqueidentifier NULL,
    [Vet] nvarchar(max) NULL,
    [FavoriteToy] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Dogs] PRIMARY KEY ([Id]));

CREATE TABLE [FarmAnimals] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [Name] nvarchar(max) NOT NULL,
    [FoodId] uniqueidentifier NULL,
    [Value] decimal(18,2) NOT NULL,
    [Species] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_FarmAnimals] PRIMARY KEY ([Id]));

CREATE TABLE [Humans] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [Name] nvarchar(max) NOT NULL,
    [FoodId] uniqueidentifier NULL,
    [FavoriteAnimalId] int NULL,
    CONSTRAINT [PK_Humans] PRIMARY KEY ([Id]));

Обратите внимание на указанные ниже моменты.

  • Таблицы для Animal типов или Pet отсутствуют, так как они находятся abstract в объектной модели. Помните, что C# не разрешает экземпляры абстрактных типов, поэтому нет ситуации, когда экземпляр абстрактного типа будет сохранен в базе данных.

  • Сопоставление свойств базовых типов повторяется для каждого конкретного типа. Например, в каждой Name таблице есть столбец, а для кошек и собак — Vet столбец.

  • Сохранение некоторых данных в этой базе данных приводит к следующим результатам:

Таблица "Кошки"

Идентификатор Имя FoodId Ветеринар EducationLevel
1 Алиса 99ca3e98-b26d-4a0c-d4ae-08da7aca624f Пенджелли Mba
2 Mac 99ca3e98-b26d-4a0c-d4ae-08da7aca624f Пенджелли Дошкольного
8 Бакстер 5dc5019e-6f72-454b-d4b0-08da7aca624f Ботелл Пэт Больницы Bsc

Таблица "Собаки"

Идентификатор Имя FoodId Ветеринар FavoriteToy
3 Всплывающее 011aaf6f-d588-4fad-d4ac-08da7aca624f Пенджелли Г-н Свирель

Таблица FarmAnimals

Идентификатор Имя FoodId Значение Вид
4 Клайд 1d495075-f527-4498-d4af-08da7aca624f 100,00 Equus africanus asinus

Таблица "Люди"

Идентификатор Имя FoodId FavoriteAnimalId
5 Венди 5418fd81-7660-432f-d4b1-08da7aca624f 2
6 Артур 59b495d4-0414-46bf-d4ad-08da7aca624f 1
9 Кэти null 8

Обратите внимание, что в отличие от сопоставления TPT вся информация для одного объекта содержится в одной таблице. И, в отличие от сопоставления TPH, в любой таблице нет сочетания столбцов и строк, где они никогда не используются моделью. Ниже мы покажем, как эти характеристики могут быть важны для запросов и хранилища.

Настройка наследования TPC

Все типы в иерархии наследования должны быть явно включены в модель при сопоставлении иерархии с EF Core. Это можно сделать, создав DbSet свойства в для DbContext каждого типа:

public DbSet<Animal> Animals => Set<Animal>();
public DbSet<Pet> Pets => Set<Pet>();
public DbSet<FarmAnimal> FarmAnimals => Set<FarmAnimal>();
public DbSet<Cat> Cats => Set<Cat>();
public DbSet<Dog> Dogs => Set<Dog>();
public DbSet<Human> Humans => Set<Human>();

Или с помощью Entity метода в OnModelCreating:

modelBuilder.Entity<Animal>();
modelBuilder.Entity<Pet>();
modelBuilder.Entity<Cat>();
modelBuilder.Entity<Dog>();
modelBuilder.Entity<FarmAnimal>();
modelBuilder.Entity<Human>();

Важно!

Это отличается от устаревшего поведения EF6, когда производные типы сопоставленных базовых типов автоматически обнаруживаются, если они содержатся в одной сборке.

Больше ничего не нужно делать, чтобы сопоставить иерархию с TPH, так как это стратегия по умолчанию. Однако, начиная с EF7, TPH можно сделать явным путем вызова UseTphMappingStrategy для базового типа иерархии:

modelBuilder.Entity<Animal>().UseTphMappingStrategy();

Чтобы вместо этого использовать TPT, измените значение на UseTptMappingStrategy:

modelBuilder.Entity<Animal>().UseTptMappingStrategy();

Аналогичным образом используется UseTpcMappingStrategy для настройки TPC:

modelBuilder.Entity<Animal>().UseTpcMappingStrategy();

В каждом случае имя таблицы, используемое для каждого типа, берется из DbSet имени свойства в DbContextили может быть настроено с помощью ToTable метода построителя или атрибута [Table] .

Производительность запросов TPC

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

Sql, созданный для трех простых запросов LINQ, можно использовать для наблюдения за тем, где TPC работает хорошо по сравнению с TPH и TPT. Ниже приведены следующие запросы:

  1. Запрос, возвращающий сущности всех типов в иерархии:

    context.Animals.ToList();
    
  2. Запрос, возвращающий сущности из подмножества типов в иерархии:

    context.Pets.ToList();
    
  3. Запрос, возвращающий только сущности из одного конечного типа в иерархии:

    context.Cats.ToList();
    

Запросы TPH

При использовании TPH все три запроса запрашивают только одну таблицу, но с разными фильтрами в столбце дискриминатора:

  1. TPH SQL возвращает сущности всех типов в иерархии:

    SELECT [a].[Id], [a].[Discriminator], [a].[FoodId], [a].[Name], [a].[Species], [a].[Value], [a].[FavoriteAnimalId], [a].[Vet], [a].[EducationLevel], [a].[FavoriteToy]
    FROM [Animals] AS [a]
    
  2. TPH SQL, возвращающий сущности из подмножества типов в иерархии:

    SELECT [a].[Id], [a].[Discriminator], [a].[FoodId], [a].[Name], [a].[Vet], [a].[EducationLevel], [a].[FavoriteToy]
    FROM [Animals] AS [a]
    WHERE [a].[Discriminator] IN (N'Cat', N'Dog')
    
  3. TPH SQL возвращает только сущности из одного конечного типа в иерархии:

    SELECT [a].[Id], [a].[Discriminator], [a].[FoodId], [a].[Name], [a].[Vet], [a].[EducationLevel]
    FROM [Animals] AS [a]
    WHERE [a].[Discriminator] = N'Cat'
    

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

Запросы TPT

При использовании TPT все эти запросы требуют объединения нескольких таблиц, так как данные для любого конкретного типа разделены между несколькими таблицами:

  1. TPT SQL возвращает сущности всех типов в иерархии:

    SELECT [a].[Id], [a].[FoodId], [a].[Name], [f].[Species], [f].[Value], [h].[FavoriteAnimalId], [p].[Vet], [c].[EducationLevel], [d].[FavoriteToy], CASE
        WHEN [d].[Id] IS NOT NULL THEN N'Dog'
        WHEN [c].[Id] IS NOT NULL THEN N'Cat'
        WHEN [h].[Id] IS NOT NULL THEN N'Human'
        WHEN [f].[Id] IS NOT NULL THEN N'FarmAnimal'
    END AS [Discriminator]
    FROM [Animals] AS [a]
    LEFT JOIN [FarmAnimals] AS [f] ON [a].[Id] = [f].[Id]
    LEFT JOIN [Humans] AS [h] ON [a].[Id] = [h].[Id]
    LEFT JOIN [Pets] AS [p] ON [a].[Id] = [p].[Id]
    LEFT JOIN [Cats] AS [c] ON [a].[Id] = [c].[Id]
    LEFT JOIN [Dogs] AS [d] ON [a].[Id] = [d].[Id]
    
  2. TPT SQL возвращает сущности из подмножества типов в иерархии:

    SELECT [a].[Id], [a].[FoodId], [a].[Name], [p].[Vet], [c].[EducationLevel], [d].[FavoriteToy], CASE
        WHEN [d].[Id] IS NOT NULL THEN N'Dog'
        WHEN [c].[Id] IS NOT NULL THEN N'Cat'
    END AS [Discriminator]
    FROM [Animals] AS [a]
    INNER JOIN [Pets] AS [p] ON [a].[Id] = [p].[Id]
    LEFT JOIN [Cats] AS [c] ON [a].[Id] = [c].[Id]
    LEFT JOIN [Dogs] AS [d] ON [a].[Id] = [d].[Id]
    
  3. TPT SQL возвращает только сущности из одного конечного типа в иерархии:

    SELECT [a].[Id], [a].[FoodId], [a].[Name], [p].[Vet], [c].[EducationLevel]
    FROM [Animals] AS [a]
    INNER JOIN [Pets] AS [p] ON [a].[Id] = [p].[Id]
    INNER JOIN [Cats] AS [c] ON [a].[Id] = [c].[Id]
    

Примечание

EF Core использует "дискриминаторный синтез", чтобы определить, из какой таблицы поступают данные, и, следовательно, правильный тип для использования. Это работает, так как функция LEFT JOIN возвращает значения NULL для столбца зависимого идентификатора ("вложенные таблицы"), которые не являются правильным типом. Таким образом, для собаки, [d].[Id] будет не null, а все остальные (конкретные) идентификаторы будут иметь значение NULL.

Все эти запросы могут страдать от проблем с производительностью из-за объединения таблиц. Именно поэтому TPT никогда не является хорошим выбором для производительности запросов.

Запросы TPC

TPC улучшает TPT для всех этих запросов, так как количество таблиц, которые необходимо запрашивать, сокращается. Кроме того, результаты из каждой таблицы объединяются с помощью UNION ALL, что может быть значительно быстрее, чем соединение таблицы, так как ему не нужно выполнять сопоставление между строками или отмену дублирования строк.

  1. TPC SQL возвращает сущности всех типов в иерархии:

    SELECT [f].[Id], [f].[FoodId], [f].[Name], [f].[Species], [f].[Value], NULL AS [FavoriteAnimalId], NULL AS [Vet], NULL AS [EducationLevel], NULL AS [FavoriteToy], N'FarmAnimal' AS [Discriminator]
    FROM [FarmAnimals] AS [f]
    UNION ALL
    SELECT [h].[Id], [h].[FoodId], [h].[Name], NULL AS [Species], NULL AS [Value], [h].[FavoriteAnimalId], NULL AS [Vet], NULL AS [EducationLevel], NULL AS [FavoriteToy], N'Human' AS [Discriminator]
    FROM [Humans] AS [h]
    UNION ALL
    SELECT [c].[Id], [c].[FoodId], [c].[Name], NULL AS [Species], NULL AS [Value], NULL AS [FavoriteAnimalId], [c].[Vet], [c].[EducationLevel], NULL AS [FavoriteToy], N'Cat' AS [Discriminator]
    FROM [Cats] AS [c]
    UNION ALL
    SELECT [d].[Id], [d].[FoodId], [d].[Name], NULL AS [Species], NULL AS [Value], NULL AS [FavoriteAnimalId], [d].[Vet], NULL AS [EducationLevel], [d].[FavoriteToy], N'Dog' AS [Discriminator]
    FROM [Dogs] AS [d]
    
  2. TPC SQL возвращает сущности из подмножества типов в иерархии:

    SELECT [c].[Id], [c].[FoodId], [c].[Name], [c].[Vet], [c].[EducationLevel], NULL AS [FavoriteToy], N'Cat' AS [Discriminator]
    FROM [Cats] AS [c]
    UNION ALL
    SELECT [d].[Id], [d].[FoodId], [d].[Name], [d].[Vet], NULL AS [EducationLevel], [d].[FavoriteToy], N'Dog' AS [Discriminator]
    FROM [Dogs] AS [d]
    
  3. TPC SQL возвращает только сущности из одного конечного типа в иерархии:

    SELECT [c].[Id], [c].[FoodId], [c].[Name], [c].[Vet], [c].[EducationLevel]
    FROM [Cats] AS [c]
    

Несмотря на то, что TPC лучше, чем TPT для всех этих запросов, запросы TPH по-прежнему лучше при возвращении экземпляров нескольких типов. Это одна из причин того, что TPH является стратегией по умолчанию, используемой EF Core.

Как показывает SQL для запроса No 3, TPC действительно преуспевает при запросе сущностей одного конечного типа. Запрос использует только одну таблицу и не нуждается в фильтрации.

Вставка и обновление TPC

TPC также хорошо работает при сохранении новой сущности, так как для этого требуется вставить только одну строку в одну таблицу. Это также относится к TPH. При использовании TPT строки должны вставляться во множество таблиц, что будет работать менее хорошо.

То же самое часто верно и для обновлений, хотя в этом случае, если все обновляемые столбцы находятся в одной таблице, даже для TPT, разница может быть не значительной.

Рекомендации по пространству

И TPT, и TPC могут использовать меньше памяти, чем TPH, если существует много подтипов со многими свойствами, которые часто не используются. Это связано с тем, что каждая строка в таблице TPH должна хранить для NULL каждого из этих неиспользуемых свойств. На практике это редко возникает проблема, но ее стоит учитывать при хранении больших объемов данных с этими характеристиками.

Совет

Если система базы данных поддерживает его (например, SQL Server), рассмотрите возможность использования "разреженных столбцов" для столбцов TPH, которые будут заполняться редко.

Создание ключа

Выбранная стратегия сопоставления наследования влияет на то, как создаются значения первичного ключа и управляются ими. Ключи в TPH просты, так как каждый экземпляр сущности представлен одной строкой в одной таблице. Можно использовать любой тип создания значения ключа, и никаких дополнительных ограничений не требуется.

Для стратегии TPT в таблице всегда есть строка, сопоставленная с базовым типом иерархии. В этой строке можно использовать любой тип создания ключей, а ключи для других таблиц связываются с этой таблицей с помощью ограничений внешнего ключа.

Все немного сложнее для TPC. Во-первых, важно понимать, что EF Core требует, чтобы все сущности в иерархии имели уникальное значение ключа, даже если сущности имеют разные типы. Таким образом, в нашем примере модели собака не может иметь то же значение ключа id, что и cat. Во-вторых, в отличие от TPT, нет общей таблицы, которая могла бы выступать в качестве единого места, где живут и могут быть созданы значения ключей. Это означает, что нельзя использовать простой Identity столбец.

Для баз данных, поддерживающих последовательности, значения ключей можно создать с помощью одной последовательности, на которую ссылается ограничение по умолчанию для каждой таблицы. Это стратегия, используемая в таблицах TPC, показанных выше, где каждая таблица имеет следующее:

[Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence])

AnimalSequence — это последовательность базы данных, созданная EF Core. Эта стратегия используется по умолчанию для иерархий TPC при использовании поставщика базы данных EF Core для SQL Server. Поставщики баз данных для других баз данных, поддерживающих последовательности, должны иметь аналогичные значения по умолчанию. Другие стратегии создания ключей, использующие последовательности, такие как шаблоны Hi-Lo, также могут использоваться с TPC.

Хотя стандартные столбцы идентификаторов не будут работать с TPC, можно использовать столбцы идентификаторов, если каждая таблица настроена с соответствующим начальным значением и приращением, чтобы значения, созданные для каждой таблицы, никогда не конфликтовали. Пример:

modelBuilder.Entity<Cat>().ToTable("Cats", tb => tb.Property(e => e.Id).UseIdentityColumn(1, 4));
modelBuilder.Entity<Dog>().ToTable("Dogs", tb => tb.Property(e => e.Id).UseIdentityColumn(2, 4));
modelBuilder.Entity<FarmAnimal>().ToTable("FarmAnimals", tb => tb.Property(e => e.Id).UseIdentityColumn(3, 4));
modelBuilder.Entity<Human>().ToTable("Humans", tb => tb.Property(e => e.Id).UseIdentityColumn(4, 4));

SQLite не поддерживает последовательности или начальное/добавочное значение идентификатора, поэтому при использовании SQLite со стратегией TPC не поддерживается создание целочисленного ключа. Однако поколение на стороне клиента или глобально уникальные ключи, например ключи GUID, поддерживаются в любой базе данных, включая SQLite.

Ограничения внешнего ключа

Стратегия сопоставления TPC создает денормализованную схему SQL. Это одна из причин, по которой некоторые пуристы баз данных вы против нее. Например, рассмотрим внешний ключевой столбец FavoriteAnimalId. Значение в этом столбце должно соответствовать значению первичного ключа некоторого животного. Это можно применить в базе данных с помощью простого ограничения FK при использовании TPH или TPT. Пример:

CONSTRAINT [FK_Animals_Animals_FavoriteAnimalId] FOREIGN KEY ([FavoriteAnimalId]) REFERENCES [Animals] ([Id])

Но при использовании TPC первичный ключ для животного хранится в таблице для конкретного типа этого животного. Например, первичный ключ кошки хранится в Cats.Id столбце, а первичный ключ собаки — в столбце Dogs.Id и т. д. Это означает, что для этой связи невозможно создать ограничение FK.

На практике это не является проблемой, если приложение не пытается вставить недопустимые данные. Например, если EF Core вставляет все данные и использует навигации для связи сущностей, то столбец FK всегда будет содержать допустимое значение PK.

Сводка и руководство

Таким образом, TPC — это хорошая стратегия сопоставления, используемая, когда код в основном запрашивает сущности одного конечного типа. Это связано с тем, что требования к хранилищу меньше, и столбец дискриминатора, для которых может потребоваться индекс, нет. Операции вставки и обновления также эффективны.

Тем не менее, TPH обычно подходит для большинства приложений и является хорошим по умолчанию для широкого спектра сценариев, поэтому не добавляйте сложность TPC, если она вам не нужна. В частности, если ваш код будет в основном запрашивать сущности многих типов, например писать запросы к базовому типу, то склоняйтесь к TPH по сравнению с TPC.

Используйте TPT, только если это ограничено внешними факторами.

Пользовательские шаблоны реконструирования

Теперь вы можете настроить шаблонный код при реконструироваи модели EF из базы данных. Для начала добавьте в проект шаблоны по умолчанию:

dotnet new install Microsoft.EntityFrameworkCore.Templates
dotnet new ef-templates

Затем шаблоны можно настроить, и они будут автоматически использоваться dotnet ef dbcontext scaffold и .Scaffold-DbContext

Дополнительные сведения см. в разделе Пользовательские шаблоны реконструирования.

Совет

Команда EF продемонстрировала и подробно рассказала о шаблонах реконструирования в эпизоде стенда сообщества .NET Data Community. Как и в случае со всеми эпизодами стендап сообщества, вы можете смотреть шаблоны T4 эпизод теперь на YouTube.

Соглашения о создании моделей

EF Core использует модель метаданных для описания сопоставления типов сущностей приложения с базовой базой данных. Эта модель построена с использованием набора из 60 "соглашений". Затем модель, созданную на основе соглашений, можно настроить с помощью атрибутов сопоставления (т. е. "заметок к DbModelBuilder данным") и (или) вызовов API в OnModelCreating.

Начиная с EF7 приложения теперь могут удалять или заменять любые из этих соглашений, а также добавлять новые соглашения. Соглашения о создании моделей — это мощный способ управления конфигурацией модели, но они могут быть сложными и сложными. Во многих случаях вместо этого можно использовать существующую конфигурацию модели до соглашения , чтобы легко указать общую конфигурацию для свойств и типов.

Изменения в соглашениях, используемых , DbContext вносятся путем переопределения DbContext.ConfigureConventions метода . Пример:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Remove(typeof(ForeignKeyIndexConvention));
}

Совет

Чтобы найти все встроенные соглашения о создании модели, найдите каждый класс, реализующий IConvention интерфейс .

Совет

Приведенный здесь код получен из файла ModelBuildingConventionsSample.cs.

Удаление существующего соглашения

Иногда одно из встроенных соглашений может не подходить для приложения, и в этом случае его можно удалить.

Пример. Не создавайте индексы для внешних ключевых столбцов

Обычно имеет смысл создавать индексы для столбцов внешнего ключа (FK), поэтому для этого есть встроенное соглашение: ForeignKeyIndexConvention. Просмотрев представление отладки модели для Post типа сущности со связями Blog с и Author, мы видим, что создаются два индекса: один для BlogId FK, а другой для AuthorId FK.

  EntityType: Post
    Properties:
      Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      AuthorId (no field, int?) Shadow FK Index
      BlogId (no field, int) Shadow Required FK Index
    Navigations:
      Author (Author) ToPrincipal Author Inverse: Posts
      Blog (Blog) ToPrincipal Blog Inverse: Posts
    Keys:
      Id PK
    Foreign keys:
      Post {'AuthorId'} -> Author {'Id'} ToDependent: Posts ToPrincipal: Author ClientSetNull
      Post {'BlogId'} -> Blog {'Id'} ToDependent: Posts ToPrincipal: Blog Cascade
    Indexes:
      AuthorId
      BlogId

Однако индексы имеют накладные расходы, и, как указано здесь, не всегда может быть целесообразно создавать их для всех столбцов FK. Для этого ForeignKeyIndexConvention можно удалить при сборке модели:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Remove(typeof(ForeignKeyIndexConvention));
}

В отладочном представлении модели Post мы видим, что индексы в пакетах FK не созданы:

  EntityType: Post
    Properties:
      Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      AuthorId (no field, int?) Shadow FK
      BlogId (no field, int) Shadow Required FK
    Navigations:
      Author (Author) ToPrincipal Author Inverse: Posts
      Blog (Blog) ToPrincipal Blog Inverse: Posts
    Keys:
      Id PK
    Foreign keys:
      Post {'AuthorId'} -> Author {'Id'} ToDependent: Posts ToPrincipal: Author ClientSetNull
      Post {'BlogId'} -> Blog {'Id'} ToDependent: Posts ToPrincipal: Blog Cascade

При необходимости индексы по-прежнему можно явно создавать для столбцов внешнего ключа с помощью IndexAttribute:

[Index("BlogId")]
public class Post
{
    // ...
}

Или с конфигурацией в OnModelCreating:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>(entityTypeBuilder => entityTypeBuilder.HasIndex("BlogId"));
}

При повторном просмотре Post типа сущности он теперь содержит BlogId индекс, но не AuthorId индекс:

  EntityType: Post
    Properties:
      Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      AuthorId (no field, int?) Shadow FK
      BlogId (no field, int) Shadow Required FK Index
    Navigations:
      Author (Author) ToPrincipal Author Inverse: Posts
      Blog (Blog) ToPrincipal Blog Inverse: Posts
    Keys:
      Id PK
    Foreign keys:
      Post {'AuthorId'} -> Author {'Id'} ToDependent: Posts ToPrincipal: Author ClientSetNull
      Post {'BlogId'} -> Blog {'Id'} ToDependent: Posts ToPrincipal: Blog Cascade
    Indexes:
      BlogId

Совет

Если модель не использует атрибуты сопоставления (заметки к данным) для настройки, все соглашения, заканчивающиеся AttributeConvention на , можно безопасно удалить, чтобы ускорить создание модели.

Добавление нового соглашения

Удаление существующих соглашений — это начало, но как насчет добавления совершенно новых соглашений о создании моделей? EF7 также поддерживает эту функцию!

Пример. Ограничение длины свойств дискриминатора

Для стратегии сопоставления наследования "таблица на иерархию" требуется столбец дискриминатора, чтобы указать тип, представленный в любой строке. По умолчанию EF использует неограниченный строковый столбец для дискриминатора, что гарантирует, что он будет работать для любой длины дискриминатора. Однако ограничение максимальной длины строк дискриминатора может повысить эффективность хранения и запросов. Давайте создадим новое соглашение, которое будет делать это.

Соглашения о создании модели EF Core активируются на основе изменений, внесенных в модель во время ее сборки. Это позволяет поддерживать модель в актуальном состоянии по мере создания явной конфигурации, применения атрибутов сопоставления и выполнения других соглашений. Для участия в этом каждое соглашение реализует один или несколько интерфейсов, которые определяют, когда будет активировано соглашение. Например, соглашение, реализующее IEntityTypeAddedConvention , будет срабатывать при каждом добавлении нового типа сущности в модель. Аналогичным образом соглашение, реализующее и IForeignKeyAddedConvention , IKeyAddedConvention будет активироваться всякий раз, когда в модель добавляется ключ или внешний ключ.

Знать, какие интерфейсы следует реализовать, может быть сложно, так как конфигурация, созданная в модели в какой-то момент, может быть изменена или удалена позже. Например, ключ может быть создан по соглашению, но затем заменен, если другой ключ настроен явно.

Давайте сделаем это немного более конкретным, сделав первую попытку реализации соглашения о дискриминаторной длине:

public class DiscriminatorLengthConvention1 : IEntityTypeBaseTypeChangedConvention
{
    public void ProcessEntityTypeBaseTypeChanged(
        IConventionEntityTypeBuilder entityTypeBuilder,
        IConventionEntityType? newBaseType,
        IConventionEntityType? oldBaseType,
        IConventionContext<IConventionEntityType> context)
    {
        var discriminatorProperty = entityTypeBuilder.Metadata.FindDiscriminatorProperty();
        if (discriminatorProperty != null
            && discriminatorProperty.ClrType == typeof(string))
        {
            discriminatorProperty.Builder.HasMaxLength(24);
        }
    }
}

Это соглашение реализует IEntityTypeBaseTypeChangedConvention, что означает, что он будет запускаться при каждом изменении сопоставленной иерархии наследования для типа сущности. Затем соглашение находит и настраивает свойство дискриминатора строки для иерархии.

Затем это соглашение используется при вызове Add в ConfigureConventions:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Add(_ =>  new DiscriminatorLengthConvention1());
}

Совет

Вместо того чтобы добавлять экземпляр соглашения напрямую, Add метод принимает фабрику для создания экземпляров соглашения. Это позволяет соглашению использовать зависимости от внутреннего поставщика служб EF Core. Так как это соглашение не имеет зависимостей, параметр поставщика службы называется _, что указывает, что он никогда не используется.

Создание модели и просмотр типа сущности Post показывают, что это сработало. Теперь свойству дискриминатора задано значение с максимальной длиной 24:

 Discriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(24)

Но что произойдет, если теперь мы явно настроим другое свойство дискриминатора? Пример:

modelBuilder.Entity<Post>()
    .HasDiscriminator<string>("PostTypeDiscriminator")
    .HasValue<Post>("Post")
    .HasValue<FeaturedPost>("Featured");

Просмотрев представление отладки модели, мы обнаружили, что длина дискриминатора больше не настроена.

 PostTypeDiscriminator (no field, string) Shadow Required AfterSave:Throw

Это связано с тем, что свойство дискриминатора, настроенное в нашем соглашении, было позже удалено при добавлении пользовательского дискриминатора. Мы могли бы попытаться исправить эту проблему, реализовав другой интерфейс в нашем соглашении, чтобы реагировать на дискриминаторные изменения, но выяснить, какой интерфейс следует реализовать, непросто.

К счастью, существует другой способ подхода к этому, который делает все гораздо проще. В течение длительного времени не имеет значения, как выглядит модель во время ее сборки, если окончательная модель является правильной. Кроме того, конфигурация, которую мы хотим применить, часто не требует активации других соглашений для реагирования. Таким образом, наше соглашение может реализовывать IModelFinalizingConvention. Соглашения о завершении модели выполняются после завершения всех остальных моделей, поэтому у них есть доступ к конечному состоянию модели. Соглашение о завершении модели обычно выполняет итерацию по всей настройке элементов модели по мере выполнения. Таким образом, в этом случае мы найдем каждый дискриминатор в модели и настроим его:

public class DiscriminatorLengthConvention2 : IModelFinalizingConvention
{
    public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
    {
        foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()
                     .Where(entityType => entityType.BaseType == null))
        {
            var discriminatorProperty = entityType.FindDiscriminatorProperty();
            if (discriminatorProperty != null
                && discriminatorProperty.ClrType == typeof(string))
            {
                discriminatorProperty.Builder.HasMaxLength(24);
            }
        }
    }
}

После создания модели с помощью этого нового соглашения мы обнаружили, что длина дискриминатора теперь настроена правильно, несмотря на то, что она была настроена:

PostTypeDiscriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(24)

Для удовольствия давайте пойдем еще дальше и настроим максимальную длину как длину самого длинного дискриминатора.

public class DiscriminatorLengthConvention3 : IModelFinalizingConvention
{
    public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
    {
        foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()
                     .Where(entityType => entityType.BaseType == null))
        {
            var discriminatorProperty = entityType.FindDiscriminatorProperty();
            if (discriminatorProperty != null
                && discriminatorProperty.ClrType == typeof(string))
            {
                var maxDiscriminatorValueLength =
                    entityType.GetDerivedTypesInclusive().Select(e => ((string)e.GetDiscriminatorValue()!).Length).Max();

                discriminatorProperty.Builder.HasMaxLength(maxDiscriminatorValueLength);
            }
        }
    }
}

Теперь максимальная длина столбца дискриминатора составляет 8, что является длиной "Подборка", что является самым длинным используемым значением дискриминатора.

PostTypeDiscriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(8)

Совет

Может возникнуть вопрос о том, следует ли в соглашении также создать индекс для столбца дискриминатора. На сайте GitHub обсуждается этот вопрос. Короткий ответ заключается в том, что иногда индекс может быть полезен, но в большинстве случаев он, вероятно, не будет. Поэтому лучше создавать здесь соответствующие индексы по мере необходимости, а не иметь соглашение делать это всегда. Но если вы не согласны с этим, то приведенное выше соглашение можно легко изменить для создания индекса.

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

Давайте рассмотрим другой пример, в котором можно использовать соглашение о завершении— на этот раз, установив максимальную длину по умолчанию для любого свойства строки, как указано в GitHub. Соглашение выглядит примерно так же, как в предыдущем примере:

public class MaxStringLengthConvention : IModelFinalizingConvention
{
    public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
    {
        foreach (var property in modelBuilder.Metadata.GetEntityTypes()
                     .SelectMany(
                         entityType => entityType.GetDeclaredProperties()
                             .Where(
                                 property => property.ClrType == typeof(string))))
        {
            property.Builder.HasMaxLength(512);
        }
    }
}

Это соглашение довольно простое. Он находит каждое строковое свойство в модели и устанавливает для него максимальную длину 512. Просмотрев в представлении отладки свойства для Post, мы видим, что все строковые свойства теперь имеют максимальную длину 512.

EntityType: Post
  Properties:
    Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
    AuthorId (no field, int?) Shadow FK Index
    BlogId (no field, int) Shadow Required FK Index
    Content (string) Required MaxLength(512)
    Discriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(512)
    PublishedOn (DateTime) Required
    Title (string) Required MaxLength(512)

Но свойство Content , вероятно, должно позволить более 512 символов, или все наши сообщения будут довольно короткими! Это можно сделать без изменения соглашения, явно настроив максимальную длину только для этого свойства с помощью атрибута сопоставления:

[MaxLength(4000)]
public string Content { get; set; }

Или с кодом в OnModelCreating:

modelBuilder.Entity<Post>()
    .Property(post => post.Content)
    .HasMaxLength(4000);

Теперь все свойства имеют максимальную длину 512, за исключением Content того, что было явно настроено с 4000:

EntityType: Post
  Properties:
    Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
    AuthorId (no field, int?) Shadow FK Index
    BlogId (no field, int) Shadow Required FK Index
    Content (string) Required MaxLength(4000)
    Discriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(512)
    PublishedOn (DateTime) Required
    Title (string) Required MaxLength(512)

Так почему же наше соглашение не переопределяет явно настроенную максимальную длину? Ответ заключается в том, что EF Core отслеживает, как была создана каждая часть конфигурации. Это представляется перечислением ConfigurationSource . Ниже перечислены различные типы конфигурации.

  • Explicit: элемент модели был явно настроен в OnModelCreating
  • DataAnnotation: элемент модели был настроен с помощью атрибута сопоставления (заметка к данным) для типа CLR.
  • Convention: элемент модели был настроен в соответствии с соглашением о создании модели.

Соглашения никогда не переопределяют конфигурацию, помеченную как DataAnnotation или Explicit. Для этого используется построитель соглашений, например IConventionPropertyBuilder, который получается из Builder свойства . Пример:

property.Builder.HasMaxLength(512);

Вызов HasMaxLength в построителе соглашений задает максимальную длину, только если она не была настроена атрибутом сопоставления или в OnModelCreating.

Методы построителя, подобные этому, также имеют второй параметр: fromDataAnnotation. Присвойте этому параметру значение , true если соглашение устанавливает конфигурацию от имени атрибута сопоставления. Пример:

property.Builder.HasMaxLength(512, fromDataAnnotation: true);

При этом свойству ConfigurationSource присваивается значение DataAnnotation, что означает, что теперь значение можно переопределить путем явного сопоставления в OnModelCreating, но не с помощью соглашений об атрибутах, не относящихся к сопоставлению.

Наконец, прежде чем оставить этот пример, что произойдет, если мы будем использовать и MaxStringLengthConventionDiscriminatorLengthConvention3 одновременно? Ответ заключается в том, что это зависит от порядка добавления, так как соглашения о завершении модели выполняются в том порядке, в котором они добавляются. Таким образом, если MaxStringLengthConvention добавляется последним, то он будет выполняться последним и установит максимальную длину свойства дискриминатора 512. Поэтому в этом случае лучше добавить DiscriminatorLengthConvention3 последнее, чтобы можно было переопределить максимальную длину по умолчанию для только дискриминаторных свойств, оставив все остальные строковые свойства как 512.

Замена существующего соглашения

Иногда вместо того, чтобы полностью удалить существующее соглашение, мы хотим заменить его на соглашение, которое делает в основном то же самое, но с измененным поведением. Это полезно, так как существующее соглашение уже реализует необходимые интерфейсы для соответствующего запуска.

Пример. Сопоставление свойств "Согласие"

EF Core сопоставляет все общедоступные свойства чтения и записи по соглашению. Возможно, это не подходит для определения типов сущностей. Чтобы изменить это, можно заменить PropertyDiscoveryConvention на собственную реализацию, которая не сопоставляет ни одно свойство, если оно явно не сопоставлено в OnModelCreating или не отмечено новым атрибутом с именем Persist:

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public sealed class PersistAttribute : Attribute
{
}

Вот новое соглашение:

public class AttributeBasedPropertyDiscoveryConvention : PropertyDiscoveryConvention
{
    public AttributeBasedPropertyDiscoveryConvention(ProviderConventionSetBuilderDependencies dependencies)
        : base(dependencies)
    {
    }

    public override void ProcessEntityTypeAdded(
        IConventionEntityTypeBuilder entityTypeBuilder,
        IConventionContext<IConventionEntityTypeBuilder> context)
        => Process(entityTypeBuilder);

    public override void ProcessEntityTypeBaseTypeChanged(
        IConventionEntityTypeBuilder entityTypeBuilder,
        IConventionEntityType? newBaseType,
        IConventionEntityType? oldBaseType,
        IConventionContext<IConventionEntityType> context)
    {
        if ((newBaseType == null
             || oldBaseType != null)
            && entityTypeBuilder.Metadata.BaseType == newBaseType)
        {
            Process(entityTypeBuilder);
        }
    }

    private void Process(IConventionEntityTypeBuilder entityTypeBuilder)
    {
        foreach (var memberInfo in GetRuntimeMembers())
        {
            if (Attribute.IsDefined(memberInfo, typeof(PersistAttribute), inherit: true))
            {
                entityTypeBuilder.Property(memberInfo);
            }
            else if (memberInfo is PropertyInfo propertyInfo
                     && Dependencies.TypeMappingSource.FindMapping(propertyInfo) != null)
            {
                entityTypeBuilder.Ignore(propertyInfo.Name);
            }
        }

        IEnumerable<MemberInfo> GetRuntimeMembers()
        {
            var clrType = entityTypeBuilder.Metadata.ClrType;

            foreach (var property in clrType.GetRuntimeProperties()
                         .Where(p => p.GetMethod != null && !p.GetMethod.IsStatic))
            {
                yield return property;
            }

            foreach (var property in clrType.GetRuntimeFields())
            {
                yield return property;
            }
        }
    }
}

Совет

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

Затем соглашение регистрируется с помощью Replace метода в ConfigureConventions:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Replace<PropertyDiscoveryConvention>(
        serviceProvider => new AttributeBasedPropertyDiscoveryConvention(
            serviceProvider.GetRequiredService<ProviderConventionSetBuilderDependencies>()));
}

Совет

Это случай, когда существующее соглашение имеет зависимости, представленные ProviderConventionSetBuilderDependencies объектом зависимостей. Они получены от внутреннего поставщика служб с помощью GetRequiredService и передаются конструктору соглашений.

Это соглашение работает путем получения всех доступных для чтения свойств и полей из заданного типа сущности. Если члену присваивается [Persist]атрибут , то он сопоставляется путем вызова:

entityTypeBuilder.Property(memberInfo);

С другой стороны, если элемент является свойством, которое в противном случае было бы сопоставлено, он исключается из модели с помощью:

entityTypeBuilder.Ignore(propertyInfo.Name);

Обратите внимание, что это соглашение позволяет сопоставлять поля (в дополнение к пропетиям) при условии, что они помечены с помощью [Persist]. Это означает, что мы можем использовать закрытые поля в качестве скрытых ключей в модели.

Рассмотрим следующие типы сущностей:

public class LaundryBasket
{
    [Persist]
    [Key]
    private readonly int _id;

    [Persist]
    public int TenantId { get; init; }

    public bool IsClean { get; set; }

    public List<Garment> Garments { get; } = new();
}

public class Garment
{
    public Garment(string name, string color)
    {
        Name = name;
        Color = color;
    }

    [Persist]
    [Key]
    private readonly int _id;

    [Persist]
    public int TenantId { get; init; }

    [Persist]
    public string Name { get; }

    [Persist]
    public string Color { get; }

    public bool IsClean { get; set; }

    public LaundryBasket? Basket { get; set; }
}

Модель, созданная на основе этих типов сущностей:

Model:
  EntityType: Garment
    Properties:
      _id (_id, int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      Basket_id (no field, int?) Shadow FK Index
      Color (string) Required
      Name (string) Required
      TenantId (int) Required
    Navigations:
      Basket (LaundryBasket) ToPrincipal LaundryBasket Inverse: Garments
    Keys:
      _id PK
    Foreign keys:
      Garment {'Basket_id'} -> LaundryBasket {'_id'} ToDependent: Garments ToPrincipal: Basket ClientSetNull
    Indexes:
      Basket_id
  EntityType: LaundryBasket
    Properties:
      _id (_id, int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      TenantId (int) Required
    Navigations:
      Garments (List<Garment>) Collection ToDependent Garment Inverse: Basket
    Keys:
      _id PK

Обратите внимание, что, как правило, IsClean были бы сопоставлены, но поскольку он не помечен с [Perist] (предположительно, потому, что чистота не является постоянным свойством прачечной), он теперь рассматривается как не сопоставленное свойство.

Совет

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

Сопоставление хранимых процедур

По умолчанию EF Core создает команды вставки, обновления и удаления, которые работают непосредственно с таблицами или обновляемыми представлениями. В EF7 реализована поддержка сопоставления этих команд с хранимыми процедурами.

Совет

EF Core всегда поддерживал запросы через хранимые процедуры. Новая поддержка в EF7 явно связана с использованием хранимых процедур для вставки, обновления и удаления.

Важно!

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

Хранимые процедуры сопоставляются в OnModelCreating с помощью InsertUsingStoredProcedure, UpdateUsingStoredProcedureи DeleteUsingStoredProcedure. Например, чтобы сопоставить хранимые процедуры с типом сущности:Person

modelBuilder.Entity<Person>()
    .InsertUsingStoredProcedure(
        "People_Insert",
        storedProcedureBuilder =>
        {
            storedProcedureBuilder.HasParameter(a => a.Name);
            storedProcedureBuilder.HasResultColumn(a => a.Id);
        })
    .UpdateUsingStoredProcedure(
        "People_Update",
        storedProcedureBuilder =>
        {
            storedProcedureBuilder.HasOriginalValueParameter(person => person.Id);
            storedProcedureBuilder.HasOriginalValueParameter(person => person.Name);
            storedProcedureBuilder.HasParameter(person => person.Name);
            storedProcedureBuilder.HasRowsAffectedResultColumn();
        })
    .DeleteUsingStoredProcedure(
        "People_Delete",
        storedProcedureBuilder =>
        {
            storedProcedureBuilder.HasOriginalValueParameter(person => person.Id);
            storedProcedureBuilder.HasOriginalValueParameter(person => person.Name);
            storedProcedureBuilder.HasRowsAffectedResultColumn();
        });

Эта конфигурация сопоставляется со следующими хранимыми процедурами при использовании SQL Server:

Для вставок

CREATE PROCEDURE [dbo].[People_Insert]
    @Name [nvarchar](max)
AS
BEGIN
      INSERT INTO [People] ([Name])
      OUTPUT INSERTED.[Id]
      VALUES (@Name);
END

Для обновлений

CREATE PROCEDURE [dbo].[People_Update]
    @Id [int],
    @Name_Original [nvarchar](max),
    @Name [nvarchar](max)
AS
BEGIN
    UPDATE [People] SET [Name] = @Name
    WHERE [Id] = @Id AND [Name] = @Name_Original
    SELECT @@ROWCOUNT
END

Для удаления

CREATE PROCEDURE [dbo].[People_Delete]
    @Id [int],
    @Name_Original [nvarchar](max)
AS
BEGIN
    DELETE FROM [People]
    OUTPUT 1
    WHERE [Id] = @Id AND [Name] = @Name_Original;
END

Совет

Хранимые процедуры не нужно использовать для каждого типа в модели или для всех операций с заданным типом. Например, если указан только DeleteUsingStoredProcedure для данного типа, EF Core создаст SQL в обычном режиме для операций вставки и обновления и будет использовать только хранимую процедуру для удаления.

Первым аргументом, передаваемым каждому методу, является имя хранимой процедуры. Это значение можно опустить. В этом случае EF Core будет использовать имя таблицы с добавлением "_Insert", "_Update" или "_Delete". Таким образом, в приведенном выше примере, так как таблица называется "Люди", имена хранимых процедур можно удалить без изменения функциональных возможностей.

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

Параметры

Параметры должны добавляться в построитель в том же порядке, что и в определении хранимой процедуры.

Примечание

Параметры можно называть, но EF Core всегда вызывает хранимые процедуры с помощью позиционных аргументов, а не именованных аргументов. Проголосуйте за параметр Разрешить настройку сопоставления sproc для использования имен параметров для вызова, если вас интересует вызов по имени.

Первый аргумент для каждого метода построителя параметров указывает свойство в модели, к которой привязан параметр. Это может быть лямбда-выражение:

storedProcedureBuilder.HasParameter(a => a.Name);

Или строка, которая особенно полезна при сопоставлении свойств тени:

storedProcedureBuilder.HasParameter("Name");

Параметры по умолчанию настроены для ввода. Параметры output или input/output можно настроить с помощью вложенного построителя. Пример:

storedProcedureBuilder.HasParameter(
    document => document.RetrievedOn, 
    parameterBuilder => parameterBuilder.IsOutput());

Существует три разных метода построителя для различных вариантов параметров:

  • HasParameter задает обычный параметр, привязанный к текущему значению данного свойства.
  • HasOriginalValueParameter задает параметр, привязанный к исходному значению данного свойства. Исходное значение — это значение, которое было у свойства при запросе из базы данных, если оно известно. Если это значение неизвестно, вместо него используется текущее значение. Исходные параметры значений полезны для маркеров параллелизма.
  • HasRowsAffectedParameter задает параметр, используемый для возврата количества строк, затронутых хранимой процедурой.

Совет

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

Возврат значений

EF Core поддерживает три механизма для возврата значений из хранимых процедур:

  • Выходные параметры, как показано выше.
  • Столбцы результатов, которые задаются с помощью метода построителя HasResultColumn .
  • Возвращаемое значение, которое ограничивается возвратом количества затронутых строк и указывается с помощью метода построителя HasRowsAffectedReturnValue .

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

entityTypeBuilder.InsertUsingStoredProcedure(
        storedProcedureBuilder =>
        {
            storedProcedureBuilder.HasParameter(document => document.Title);
            storedProcedureBuilder.HasResultColumn(document => document.Id);
            storedProcedureBuilder.HasResultColumn(document => document.FirstRecordedOn);
            storedProcedureBuilder.HasResultColumn(document => document.RetrievedOn);
            storedProcedureBuilder.HasResultColumn(document => document.RowVersion);
        });

Они используются для возврата:

  • Созданное значение ключа для Id свойства .
  • Значение по умолчанию, созданное базой данных для FirstRecordedOn свойства .
  • Вычисленное значение, созданное базой данных для RetrievedOn свойства .
  • Автоматически создаваемый rowversion маркер параллелизма для RowVersion свойства .

Эта конфигурация сопоставляется со следующей хранимой процедурой при использовании SQL Server:

CREATE PROCEDURE [dbo].[Documents_Insert]
    @Title [nvarchar](max)
AS
BEGIN
    INSERT INTO [Documents] ([Title])
    OUTPUT INSERTED.[Id], INSERTED.[FirstRecordedOn], INSERTED.[RetrievedOn], INSERTED.[RowVersion]
    VALUES (@Title);
END

Оптимистическая блокировка

Оптимистичный параллелизм работает так же, как и без хранимых процедур. Хранимая процедура должна:

  • Используйте маркер параллелизма в WHERE предложении , чтобы убедиться, что строка обновляется только при наличии допустимого маркера. Как правило, для маркера параллелизма используется исходное значение свойства маркера параллелизма, но не обязательно.
  • Возвращает количество затронутых строк, чтобы EF Core сравните это с ожидаемым количеством затронутых строк и вызовите , DbUpdateConcurrencyException если значения не совпадают.

Например, следующая SQL Server хранимая процедура использует маркер автоматического rowversion параллелизма:

CREATE PROCEDURE [dbo].[Documents_Update]
    @Id [int],
    @RowVersion_Original [rowversion],
    @Title [nvarchar](max),
    @RowVersion [rowversion] OUT
AS
BEGIN
    DECLARE @TempTable table ([RowVersion] varbinary(8));
    UPDATE [Documents] SET
        [Title] = @Title
    OUTPUT INSERTED.[RowVersion] INTO @TempTable
    WHERE [Id] = @Id AND [RowVersion] = @RowVersion_Original
    SELECT @@ROWCOUNT;
    SELECT @RowVersion = [RowVersion] FROM @TempTable;
END

Это настраивается в EF Core с помощью:

.UpdateUsingStoredProcedure(
    storedProcedureBuilder =>
    {
        storedProcedureBuilder.HasOriginalValueParameter(document => document.Id);
        storedProcedureBuilder.HasOriginalValueParameter(document => document.RowVersion);
        storedProcedureBuilder.HasParameter(document => document.Title);
        storedProcedureBuilder.HasParameter(document => document.RowVersion, parameterBuilder => parameterBuilder.IsOutput());
        storedProcedureBuilder.HasRowsAffectedResultColumn();
    });

Обратите внимание на указанные ниже моменты.

  • Используется исходное RowVersion значение маркера параллелизма.
  • Хранимая процедура использует WHERE предложение , чтобы гарантировать, что строка обновляется только в том случае, если исходное RowVersion значение совпадает.
  • Новое созданное RowVersion значение вставляется во временную таблицу.
  • Возвращается количество затронутых строк (@@ROWCOUNT) и созданное RowVersion значение.

Сопоставление иерархий наследования с хранимыми процедурами

EF Core требует, чтобы хранимые процедуры следовали макету таблицы для типов в иерархии. Это означает следующее.

  • Иерархия, сопоставленная с помощью TPH, должна иметь одну хранимую процедуру вставки, обновления и (или) удаления, предназначенную для одной сопоставленной таблицы. Хранимые процедуры вставки и обновления должны иметь параметр для значения дискриминатора.
  • Иерархия, сопоставленная с помощью TPT, должна иметь хранимую процедуру вставки, обновления и (или) удаления для каждого типа, включая абстрактные типы. EF Core выполнит несколько вызовов для обновления, вставки и удаления строк во всех таблицах.
  • Иерархия, сопоставленная с помощью TPC, должна иметь хранимую процедуру вставки, обновления и (или) удаления для каждого конкретного типа, но не для абстрактных типов.

Примечание

Если вам нужно использовать одну хранимую процедуру для каждого конкретного типа независимо от стратегии сопоставления, проголосуйте за Поддержку использования одного sproc для каждого конкретного типа независимо от стратегии сопоставления наследования.

Сопоставление принадлежащих типов с хранимыми процедурами

Настройка хранимых процедур для принадлежащих типов выполняется во вложенном построителе собственных типов. Пример:

modelBuilder.Entity<Person>(
    entityTypeBuilder =>
    {
        entityTypeBuilder.OwnsOne(
            author => author.Contact,
            ownedNavigationBuilder =>
            {
                ownedNavigationBuilder.ToTable("Contacts");
                ownedNavigationBuilder
                    .InsertUsingStoredProcedure(
                        storedProcedureBuilder =>
                        {
                            storedProcedureBuilder.HasParameter("PersonId");
                            storedProcedureBuilder.HasParameter(contactDetails => contactDetails.Phone);
                        })
                    .UpdateUsingStoredProcedure(
                        storedProcedureBuilder =>
                        {
                            storedProcedureBuilder.HasOriginalValueParameter("PersonId");
                            storedProcedureBuilder.HasParameter(contactDetails => contactDetails.Phone);
                            storedProcedureBuilder.HasRowsAffectedResultColumn();
                        })
                    .DeleteUsingStoredProcedure(
                        storedProcedureBuilder =>
                        {
                            storedProcedureBuilder.HasOriginalValueParameter("PersonId");
                            storedProcedureBuilder.HasRowsAffectedResultColumn();
            });
    });

Примечание

В настоящее время хранимые процедуры для вставки, обновления и удаления поддерживают только принадлежащие типы, должны быть сопоставлены с отдельными таблицами. То есть собственный тип не может быть представлен столбцами в таблице владельца. Проголосуйте за добавление поддержки разделения таблиц для сопоставления sproc cuD , если это ограничение, которое вы хотите удалить.

Сопоставление сущностей соединения "многие ко многим" с хранимыми процедурами

Настройка сущностей соединения "многие ко многим" хранимых процедур может выполняться как часть конфигурации "многие ко многим". Пример:

modelBuilder.Entity<Book>(
    entityTypeBuilder =>
    {
        entityTypeBuilder
            .HasMany(document => document.Authors)
            .WithMany(author => author.PublishedWorks)
            .UsingEntity<Dictionary<string, object>>(
                "BookPerson",
                builder => builder.HasOne<Person>().WithMany().OnDelete(DeleteBehavior.Cascade),
                builder => builder.HasOne<Book>().WithMany().OnDelete(DeleteBehavior.ClientCascade),
                joinTypeBuilder =>
                {
                    joinTypeBuilder
                        .InsertUsingStoredProcedure(
                            storedProcedureBuilder =>
                            {
                                storedProcedureBuilder.HasParameter("AuthorsId");
                                storedProcedureBuilder.HasParameter("PublishedWorksId");
                            })
                        .DeleteUsingStoredProcedure(
                            storedProcedureBuilder =>
                            {
                                storedProcedureBuilder.HasOriginalValueParameter("AuthorsId");
                                storedProcedureBuilder.HasOriginalValueParameter("PublishedWorksId");
                                storedProcedureBuilder.HasRowsAffectedResultColumn();
                            });
                });
    });

Новые и улучшенные перехватчики и события

Перехватчики EF Core позволяют перехватывать, изменять и /или подавлять операции EF Core. EF Core также включает традиционные события .NET и ведение журнала.

EF7 включает следующие усовершенствования перехватчиков:

Кроме того, EF7 включает новые традиционные события .NET для:

В следующих разделах приведены некоторые примеры использования этих новых возможностей перехвата.

Простые действия при создании сущности

Совет

Приведенный здесь код получен из файла SimpleMaterializationSample.cs.

Новый IMaterializationInterceptor объект поддерживает перехват до и после создания экземпляра сущности, а также до и после инициализации свойств этого экземпляра. Перехватчик может изменять или заменять экземпляр сущности в каждой точке. Это позволяет:

  • Задание несопоставленных свойств или вызов методов, необходимых для проверки, вычисляемых значений или флагов.
  • Использование фабрики для создания экземпляров.
  • Создание экземпляра сущности, отличного от экземпляра EF, обычно создается, например экземпляра из кэша или типа прокси-сервера.
  • Внедрение служб в экземпляр сущности.

Например, предположим, что мы хотим отслеживать время извлечения сущности из базы данных, возможно, чтобы ее можно было отобразить для пользователя, изменяющего данные. Для этого сначала определим интерфейс :

public interface IHasRetrieved
{
    DateTime Retrieved { get; set; }
}

Использование интерфейса является общим для перехватчиков, так как он позволяет одному и тому же перехватчику работать со многими различными типами сущностей. Пример:

public class Customer : IHasRetrieved
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public string? PhoneNumber { get; set; }

    [NotMapped]
    public DateTime Retrieved { get; set; }
}

Обратите внимание, что [NotMapped] атрибут используется для указания того, что это свойство используется только при работе с сущностью и не должно сохраняться в базе данных.

Затем перехватчик должен реализовать соответствующий метод из IMaterializationInterceptor и задать полученное время:

public class SetRetrievedInterceptor : IMaterializationInterceptor
{
    public object InitializedInstance(MaterializationInterceptionData materializationData, object instance)
    {
        if (instance is IHasRetrieved hasRetrieved)
        {
            hasRetrieved.Retrieved = DateTime.UtcNow;
        }

        return instance;
    }
}

Экземпляр этого перехватчика регистрируется при настройке DbContext:

public class CustomerContext : DbContext
{
    private static readonly SetRetrievedInterceptor _setRetrievedInterceptor = new();

    public DbSet<Customer> Customers
        => Set<Customer>();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder
            .AddInterceptors(_setRetrievedInterceptor)
            .UseSqlite("Data Source = customers.db");
}

Совет

Этот перехватчик используется без отслеживания состояния, что является общим, поэтому создается один экземпляр и совместно используется всеми DbContext экземплярами.

Теперь при каждом запросе Customer из базы данных Retrieved свойство задается автоматически. Пример:

await using (var context = new CustomerContext())
{
    var customer = await context.Customers.SingleAsync(e => e.Name == "Alice");
    Console.WriteLine($"Customer '{customer.Name}' was retrieved at '{customer.Retrieved.ToLocalTime()}'");
}

Создает выходные данные:

Customer 'Alice' was retrieved at '9/22/2022 5:25:54 PM'

Внедрение служб в сущности

Совет

Приведенный здесь код получен из файла InjectLoggerSample.cs.

EF Core уже имеет встроенную поддержку внедрения некоторых специальных служб в экземпляры контекста; Например, см . раздел Отложенная загрузка без прокси-серверов, которая работает путем внедрения ILazyLoader службы.

Можно IMaterializationInterceptor использовать для обобщения для любой службы. В следующем примере показано, как внедрить ILogger в сущности так, чтобы они могли выполнять собственное ведение журнала.

Примечание

Внедрение служб в сущности объединяет эти типы сущностей с внедренными службами, которые некоторые люди считают антишаблоны.

Как и ранее, интерфейс используется для определения того, что можно сделать.

public interface IHasLogger
{
    ILogger? Logger { get; set; }
}

Типы сущностей, которые будут регистрировать, должны реализовывать этот интерфейс. Пример:

public class Customer : IHasLogger
{
    private string? _phoneNumber;

    public int Id { get; set; }
    public string Name { get; set; } = null!;

    public string? PhoneNumber
    {
        get => _phoneNumber;
        set
        {
            Logger?.LogInformation(1, $"Updating phone number for '{Name}' from '{_phoneNumber}' to '{value}'.");

            _phoneNumber = value;
        }
    }

    [NotMapped]
    public ILogger? Logger { get; set; }
}

На этот раз перехватчик должен реализовать IMaterializationInterceptor.InitializedInstance, который вызывается после создания каждого экземпляра сущности и инициализации его значений свойств. Перехватчик получает ILogger из контекста и инициализирует IHasLogger.Logger с ним:

public class LoggerInjectionInterceptor : IMaterializationInterceptor
{
    private ILogger? _logger;

    public object InitializedInstance(MaterializationInterceptionData materializationData, object instance)
    {
        if (instance is IHasLogger hasLogger)
        {
            _logger ??= materializationData.Context.GetService<ILoggerFactory>().CreateLogger("CustomersLogger");
            hasLogger.Logger = _logger;
        }

        return instance;
    }
}

На этот раз для каждого DbContext экземпляра используется новый экземпляр перехватчика, так как ILogger полученный может изменяться для каждого DbContext экземпляра, а ILogger кэшируется в перехватчике:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder.AddInterceptors(new LoggerInjectionInterceptor());

Теперь при каждом Customer.PhoneNumber изменении это изменение будет регистрироваться в журнале приложения. Пример:

info: CustomersLogger[1]
      Updating phone number for 'Alice' from '+1 515 555 0123' to '+1 515 555 0125'.

Перехват дерева выражений LINQ

Совет

Приведенный здесь код получен из файла QueryInterceptionSample.cs.

EF Core использует запросы .NET LINQ. Обычно это подразумевает использование компилятора C#, VB или F# для создания дерева выражений, которое затем преобразуется EF Core в соответствующий SQL. Например, рассмотрим метод, который возвращает страницу клиентов:

Task<List<Customer>> GetPageOfCustomers(string sortProperty, int page)
{
    using var context = new CustomerContext();

    return context.Customers
        .OrderBy(e => EF.Property<object>(e, sortProperty))
        .Skip(page * 20).Take(20).ToListAsync();
}

Совет

Этот запрос использует метод , EF.Property чтобы указать свойство для сортировки. Это позволяет приложению динамически передавать имя свойства, что позволяет выполнять сортировку по любому свойству типа сущности. Имейте в виду, что сортировка по неиндексированных столбцам может выполняться медленно.

Это будет работать нормально, если свойство, используемое для сортировки, всегда возвращает стабильный порядок. Но это может быть не всегда так. Например, приведенный выше запрос LINQ создает следующее в SQLite при упорядочении по Customer.City:

SELECT "c"."Id", "c"."City", "c"."Name", "c"."PhoneNumber"
FROM "Customers" AS "c"
ORDER BY "c"."City"
LIMIT @__p_1 OFFSET @__p_0

При наличии нескольких клиентов с одинаковым City, порядок этого запроса не является стабильным. Это может привести к отсутствовать или дублировать результаты, когда пользователь просматривает данные.

Распространенный способ устранить эту проблему — выполнить вторичную сортировку по первичному ключу. Однако вместо того, чтобы вручную добавлять его в каждый запрос, EF7 позволяет перехватывать дерево выражений запроса, в котором можно динамически добавлять вторичные упорядочения. Чтобы упростить эту задачу, мы снова будем использовать интерфейс, на этот раз для любой сущности, которая имеет целочисленный первичный ключ:

public interface IHasIntKey
{
    int Id { get; }
}

Этот интерфейс реализуется с помощью интересующих типов сущностей:

public class Customer : IHasIntKey
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public string? City { get; set; }
    public string? PhoneNumber { get; set; }
}

Затем нам потребуется перехватчик, реализующий IQueryExpressionInterceptor:

public class KeyOrderingExpressionInterceptor : IQueryExpressionInterceptor
{
    public Expression QueryCompilationStarting(Expression queryExpression, QueryExpressionEventData eventData)
        => new KeyOrderingExpressionVisitor().Visit(queryExpression);

    private class KeyOrderingExpressionVisitor : ExpressionVisitor
    {
        private static readonly MethodInfo ThenByMethod
            = typeof(Queryable).GetMethods()
                .Single(m => m.Name == nameof(Queryable.ThenBy) && m.GetParameters().Length == 2);

        protected override Expression VisitMethodCall(MethodCallExpression? methodCallExpression)
        {
            var methodInfo = methodCallExpression!.Method;
            if (methodInfo.DeclaringType == typeof(Queryable)
                && methodInfo.Name == nameof(Queryable.OrderBy)
                && methodInfo.GetParameters().Length == 2)
            {
                var sourceType = methodCallExpression.Type.GetGenericArguments()[0];
                if (typeof(IHasIntKey).IsAssignableFrom(sourceType))
                {
                    var lambdaExpression = (LambdaExpression)((UnaryExpression)methodCallExpression.Arguments[1]).Operand;
                    var entityParameterExpression = lambdaExpression.Parameters[0];

                    return Expression.Call(
                        ThenByMethod.MakeGenericMethod(
                            sourceType,
                            typeof(int)),
                        base.VisitMethodCall(methodCallExpression),
                        Expression.Lambda(
                            typeof(Func<,>).MakeGenericType(entityParameterExpression.Type, typeof(int)),
                            Expression.Property(entityParameterExpression, nameof(IHasIntKey.Id)),
                            entityParameterExpression));
                }
            }

            return base.VisitMethodCall(methodCallExpression);
        }
    }
}

Это, вероятно, выглядит довольно сложно - и это! Работать с деревьями выражений обычно непросто. Давайте посмотрим, что происходит:

  • По сути, перехватчик инкапсулирует ExpressionVisitor. Посетитель переопределяет VisitMethodCall, который будет вызываться при каждом вызове метода в дереве выражения запроса.

  • Посетитель проверяет, является ли это вызовом интересующего OrderBy нас метода.

  • Если это так, посетитель дополнительно проверяет, является ли вызов универсального метода типом, реализующим наш IHasIntKey интерфейс.

  • На этом этапе мы знаем, что вызов метода имеет вид OrderBy(e => ...). Мы извлекаем лямбда-выражение из этого вызова и получаем параметр, используемый в этом выражении, eто есть .

  • Теперь мы создадим новый MethodCallExpression объект с помощью метода построителя Expression.Call . В этом случае вызывается ThenBy(e => e.Id)метод . Мы создадим это с помощью параметра, извлеченного выше, и доступа свойства к свойству IdIHasIntKey интерфейса.

  • Входные данные в этом вызове являются исходными OrderBy(e => ...), поэтому конечным результатом является выражение для OrderBy(e => ...).ThenBy(e => e.Id).

  • Это измененное выражение возвращается от посетителя, что означает, что запрос LINQ был изменен соответствующим образом, чтобы включить ThenBy вызов.

  • EF Core продолжает и компилирует это выражение запроса в соответствующий SQL для используемой базы данных.

Этот перехватчик регистрируется так же, как и в первом примере. Теперь при выполнении GetPageOfCustomers создается следующий КОД SQL:

SELECT "c"."Id", "c"."City", "c"."Name", "c"."PhoneNumber"
FROM "Customers" AS "c"
ORDER BY "c"."City", "c"."Id"
LIMIT @__p_1 OFFSET @__p_0

Теперь это всегда приведет к стабильному заказу, даже если есть несколько клиентов с одинаковым City.

Наконец-то. Это много кода для простого изменения запроса. И что еще хуже, он может даже не работать для всех запросов. Как известно, трудно написать посетителя выражения, который распознает все формы запроса, которые он должен, и ни один из тех, которые он не должен. Например, это, скорее всего, не сработает, если заказ выполняется во вложенном запросе.

Это приводит нас к критической точке о перехватчиках - всегда спрашивайте себя, есть ли более простой способ делать то, что вы хотите. Перехватчики мощные, но легко ошибиться. Они, как говорится, простой способ выстрелить себе в ногу.

Например, представьте, что вместо этого мы изменили метод GetPageOfCustomers следующим образом:

Task<List<Customer>> GetPageOfCustomers2(string sortProperty, int page)
{
    using var context = new CustomerContext();

    return context.Customers
        .OrderBy(e => EF.Property<object>(e, sortProperty))
        .ThenBy(e => e.Id)
        .Skip(page * 20).Take(20).ToListAsync();
}

В этом случае ThenBy просто добавляется в запрос. Да, это может потребоваться выполнить отдельно для каждого запроса, но это просто, легко понять и всегда будет работать.

Перехват оптимистичного параллелизма

Совет

Приведенный здесь код получен из файла OptimisticConcurrencyInterceptionSample.cs.

EF Core поддерживает шаблон оптимистичного параллелизма , проверяя, что количество строк, фактически затронутых обновлением или удалением, совпадает с числом строк, которые должны быть затронуты. Часто это связано с токеном параллелизма; т. е. значение столбца, которое будет соответствовать ожидаемому значению, только если строка не была обновлена с момента считывания ожидаемого значения.

EF сигнализирует о нарушении оптимистичного параллелизма, вызывая DbUpdateConcurrencyExceptionисключение . В EF7 имеет новые методы ThrowingConcurrencyException и ThrowingConcurrencyExceptionAsync , ISaveChangesInterceptor которые вызываются перед вызовом DbUpdateConcurrencyException . Эти точки перехвата позволяют подавлять исключение, возможно, в сочетании с асинхронными изменениями базы данных для устранения нарушения.

Например, если два запроса пытаются удалить одну и ту же сущность почти в одно и то же время, то второй запрос может завершиться ошибкой, так как строка в базе данных больше не существует. Это может быть в порядке— конечный результат заключается в том, что сущность в любом случае была удалена. Следующий перехватчик демонстрирует, как это можно сделать:

public class SuppressDeleteConcurrencyInterceptor : ISaveChangesInterceptor
{
    public InterceptionResult ThrowingConcurrencyException(
        ConcurrencyExceptionEventData eventData,
        InterceptionResult result)
    {
        if (eventData.Entries.All(e => e.State == EntityState.Deleted))
        {
            Console.WriteLine("Suppressing Concurrency violation for command:");
            Console.WriteLine(((RelationalConcurrencyExceptionEventData)eventData).Command.CommandText);

            return InterceptionResult.Suppress();
        }

        return result;
    }

    public ValueTask<InterceptionResult> ThrowingConcurrencyExceptionAsync(
        ConcurrencyExceptionEventData eventData,
        InterceptionResult result,
        CancellationToken cancellationToken = default)
        => new(ThrowingConcurrencyException(eventData, result));
}

Есть несколько вещей, которые стоит отметить об этом перехватчике:

  • Реализованы синхронные и асинхронные методы перехвата. Это важно, если приложение может вызывать или SaveChangesSaveChangesAsync. Однако если весь код приложения является асинхронным, то необходимо только ThrowingConcurrencyExceptionAsync реализовать. Аналогичным образом, если приложение никогда не использует асинхронные методы базы данных, необходимо только ThrowingConcurrencyException реализовать. Обычно это верно для всех перехватчиков с синхронными и асинхронными методами. (Возможно, стоит реализовать метод, который приложение не использует для вызова, только в том случае, если код синхронизации или асинхронной синхронизации заползает.)
  • Перехватчик имеет доступ к EntityEntry объектам для сохраняемых сущностей. В этом случае используется для проверки того, происходит ли нарушение параллелизма для операции удаления.
  • Если приложение использует поставщик реляционной базы данных, объект ConcurrencyExceptionEventData можно привести к объекту RelationalConcurrencyExceptionEventData . Это предоставляет дополнительные реляционные сведения о выполняемой операции базы данных. В этом случае текст реляционной команды выводится в консоль.
  • InterceptionResult.Suppress() Возврат сообщает EF Core о том, что нужно отключить действие, для выполнения. В этом случае вызывается DbUpdateConcurrencyExceptionисключение . Эта возможность изменять поведение EF Core, а не просто наблюдать за тем, что делает EF Core, является одной из самых мощных функций перехватчиков.

Отложенная инициализация строки подключения

Совет

Приведенный здесь код получен из Файла LazyConnectionStringSample.cs.

Строки подключения часто являются статическими ресурсами, считываемыми из файла конфигурации. Их можно легко передать UseSqlServer в или аналогично при настройке DbContext. Однако иногда строка подключения может изменяться для каждого экземпляра контекста. Например, каждый клиент в мультитенантной системе может иметь разные строки подключения.

EF7 упрощает обработку динамических подключений и строк подключения благодаря усовершенствованиям IDbConnectionInterceptor. Это начинается с возможности настройки DbContext без какой-либо строки подключения. Пример:

services.AddDbContext<CustomerContext>(
    b => b.UseSqlServer();

Затем можно реализовать один из IDbConnectionInterceptor методов для настройки подключения перед его использованием. ConnectionOpeningAsync является хорошим выбором, так как он может выполнять асинхронную операцию для получения строки подключения, поиска маркера доступа и т. д. Например, представьте службу с областью действия текущего запроса, который понимает текущий клиент:

services.AddScoped<ITenantConnectionStringFactory, TestTenantConnectionStringFactory>();

Предупреждение

Выполнение асинхронного поиска строки подключения, маркера доступа или аналогичного при каждом необходимости может быть очень медленным. Рассмотрите возможность кэширования этих элементов и периодического обновления только кэшированных строк или маркеров. Например, маркеры доступа часто можно использовать в течение значительного периода времени, прежде чем их необходимо обновить.

Его можно внедрить в каждый DbContext экземпляр с помощью внедрения конструктора:

public class CustomerContext : DbContext
{
    private readonly ITenantConnectionStringFactory _connectionStringFactory;

    public CustomerContext(
        DbContextOptions<CustomerContext> options,
        ITenantConnectionStringFactory connectionStringFactory)
        : base(options)
    {
        _connectionStringFactory = connectionStringFactory;
    }

    // ...
}

Затем эта служба используется при создании реализации перехватчика для контекста:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder.AddInterceptors(
        new ConnectionStringInitializationInterceptor(_connectionStringFactory));

Наконец, перехватчик использует эту службу для асинхронного получения строки подключения и установки ее при первом использовании подключения:

public class ConnectionStringInitializationInterceptor : DbConnectionInterceptor
{
    private readonly IClientConnectionStringFactory _connectionStringFactory;

    public ConnectionStringInitializationInterceptor(IClientConnectionStringFactory connectionStringFactory)
    {
        _connectionStringFactory = connectionStringFactory;
    }

    public override InterceptionResult ConnectionOpening(
        DbConnection connection,
        ConnectionEventData eventData,
        InterceptionResult result)
        => throw new NotSupportedException("Synchronous connections not supported.");

    public override async ValueTask<InterceptionResult> ConnectionOpeningAsync(
        DbConnection connection, ConnectionEventData eventData, InterceptionResult result,
        CancellationToken cancellationToken = new())
    {
        if (string.IsNullOrEmpty(connection.ConnectionString))
        {
            connection.ConnectionString = (await _connectionStringFactory.GetConnectionStringAsync(cancellationToken));
        }

        return result;
    }
}

Примечание

Строка подключения получается только при первом использовании подключения. После этого строка подключения, хранящуюся в , DbConnection будет использоваться без поиска новой строки подключения.

Совет

Этот перехватчик переопределяет неасинхронный ConnectionOpening метод для вызова, так как служба для получения строки подключения должна вызываться из пути асинхронного кода.

Ведение журнала SQL Server статистики запросов

Совет

Приведенный здесь код получен из файла QueryStatisticsLoggerSample.cs.

Наконец, давайте создадим два перехватчика, которые совместно отправляют SQL Server статистику запросов в журнал приложения. Чтобы создать статистику, нам нужно IDbCommandInterceptor выполнить два действия.

Во-первых, перехватчик будет префиксировать команды с SET STATISTICS IO ON, который сообщает SQL Server отправлять статистику клиенту после использования результирующих наборов:

public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
    DbCommand command,
    CommandEventData eventData,
    InterceptionResult<DbDataReader> result,
    CancellationToken cancellationToken = default)
{
    command.CommandText = "SET STATISTICS IO ON;" + Environment.NewLine + command.CommandText;

    return new(result);
}

Во-вторых, перехватчик реализует новый DataReaderClosingAsync метод, который вызывается после DbDataReader завершения использования результатов, но до закрытия. Когда SQL Server отправляет статистику, она помещает ее во второй результат для средства чтения, поэтому на этом этапе перехватчик считывает этот результат путем вызова NextResultAsync , который заполняет статистику на соединение.

public override async ValueTask<InterceptionResult> DataReaderClosingAsync(
    DbCommand command,
    DataReaderClosingEventData eventData,
    InterceptionResult result)
{
    await eventData.DataReader.NextResultAsync();

    return result;
}

Второй перехватчик необходим для получения статистики из подключения и записи их в средство ведения журнала приложения. Для этого мы будем использовать , реализующий IDbConnectionInterceptorновый ConnectionCreated метод . ConnectionCreated вызывается сразу после того, как EF Core создаст подключение, и поэтому может использоваться для выполнения дополнительной настройки этого подключения. В этом случае перехватчик получает ILogger , а затем перехватывает SqlConnection.InfoMessage событие для регистрации сообщений.

public override DbConnection ConnectionCreated(ConnectionCreatedEventData eventData, DbConnection result)
{
    var logger = eventData.Context!.GetService<ILoggerFactory>().CreateLogger("InfoMessageLogger");
    ((SqlConnection)eventData.Connection).InfoMessage += (_, args) =>
    {
        logger.LogInformation(1, args.Message);
    };
    return result;
}

Важно!

Методы ConnectionCreating и ConnectionCreated вызываются только при создании DbConnectionEF Core . Они не будут вызываться, если приложение создает DbConnection и передает в EF Core.

При выполнении кода, использующего эти перехватчики, в журнале отображаются SQL Server статистики запросов:

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (4ms) [Parameters=[@p0='?' (Size = 4000), @p1='?' (Size = 4000), @p2='?' (Size = 4000), @p3='?' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET STATISTICS IO ON;
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      MERGE [Customers] USING (
      VALUES (@p0, @p1, 0),
      (@p2, @p3, 1)) AS i ([Name], [PhoneNumber], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([Name], [PhoneNumber])
      VALUES (i.[Name], i.[PhoneNumber])
      OUTPUT INSERTED.[Id], i._Position;
info: InfoMessageLogger[1]
      Table 'Customers'. Scan count 0, logical reads 5, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SET STATISTICS IO ON;
      SELECT TOP(2) [c].[Id], [c].[Name], [c].[PhoneNumber]
      FROM [Customers] AS [c]
      WHERE [c].[Name] = N'Alice'
info: InfoMessageLogger[1]
      Table 'Customers'. Scan count 1, logical reads 2, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.

Усовершенствования запросов

EF7 содержит множество улучшений в переводе запросов LINQ.

GroupBy в качестве окончательного оператора

Совет

Приведенный здесь код получен из файла GroupByFinalOperatorSample.cs.

EF7 поддерживает использование GroupBy в качестве окончательного оператора в запросе. Например, следующий LINQ-запрос:

var query = context.Books.GroupBy(s => s.Price);

Преобразуется в следующий SQL при использовании SQL Server:

SELECT [b].[Price], [b].[Id], [b].[AuthorId]
FROM [Books] AS [b]
ORDER BY [b].[Price]

Примечание

Этот тип GroupBy не преобразуется напрямую в SQL, поэтому EF Core выполняет группирование по возвращаемым результатам. Однако это не приводит к передаче каких-либо дополнительных данных с сервера.

GroupJoin в качестве окончательного оператора

Совет

Приведенный здесь код получен из файла GroupJoinFinalOperatorSample.cs.

EF7 поддерживает использование GroupJoin в качестве окончательного оператора в запросе. Например, следующий LINQ-запрос:

var query = context.Customers.GroupJoin(
    context.Orders, c => c.Id, o => o.CustomerId, (c, os) => new { Customer = c, Orders = os });

Преобразуется в следующий SQL при использовании SQL Server:

SELECT [c].[Id], [c].[Name], [t].[Id], [t].[Amount], [t].[CustomerId]
FROM [Customers] AS [c]
OUTER APPLY (
    SELECT [o].[Id], [o].[Amount], [o].[CustomerId]
    FROM [Orders] AS [o]
    WHERE [c].[Id] = [o].[CustomerId]
) AS [t]
ORDER BY [c].[Id]

Тип сущности GroupBy

Совет

Приведенный здесь код получен из файла GroupByEntityTypeSample.cs.

EF7 поддерживает группирование по типу сущности. Например, следующий LINQ-запрос:

var query = context.Books
    .GroupBy(s => s.Author)
    .Select(s => new { Author = s.Key, MaxPrice = s.Max(p => p.Price) });

При использовании SQLite преобразуется в следующий КОД SQL:

SELECT [a].[Id], [a].[Name], MAX([b].[Price]) AS [MaxPrice]
FROM [Books] AS [b]
INNER JOIN [Author] AS [a] ON [b].[AuthorId] = [a].[Id]
GROUP BY [a].[Id], [a].[Name]

Помните, что группирование по уникальному свойству, например первичному ключу, всегда будет более эффективным, чем группирование по типу сущности. Однако группирование по типам сущностей можно использовать как для типов сущностей с ключом, так и для типов сущностей без ключа.

Кроме того, при группировке по типу сущности с первичным ключом всегда будет по одной группе на экземпляр сущности, так как каждая сущность должна иметь уникальное значение ключа. Иногда стоит переключить источник запроса, чтобы группирование не требовалось. Например, следующий запрос возвращает те же результаты, что и предыдущий запрос:

var query = context.Authors
    .Select(a => new { Author = a, MaxPrice = a.Books.Max(b => b.Price) });

Этот запрос преобразуется в следующий SQL при использовании SQLite:

SELECT [a].[Id], [a].[Name], (
    SELECT MAX([b].[Price])
    FROM [Books] AS [b]
    WHERE [a].[Id] = [b].[AuthorId]) AS [MaxPrice]
FROM [Authors] AS [a]

Вложенные запросы не ссылаются на несгруппированные столбцы из внешнего запроса

Совет

Приведенный здесь код взят из файла UngroupedColumnsQuerySample.cs.

В EF Core 6.0 GROUP BY предложение будет ссылать на столбцы во внешнем запросе, который завершается сбоем в некоторых базах данных и неэффективен в других. Например, рассмотрим следующий запрос:

var query = from s in (from i in context.Invoices
                       group i by i.History.Month
                       into g
                       select new { Month = g.Key, Total = g.Sum(p => p.Amount), })
            select new
            {
                s.Month, s.Total, Payment = context.Payments.Where(p => p.History.Month == s.Month).Sum(p => p.Amount)
            };

В EF Core 6.0 на SQL Server это было преобразовано в:

SELECT DATEPART(month, [i].[History]) AS [Month], COALESCE(SUM([i].[Amount]), 0.0) AS [Total], (
    SELECT COALESCE(SUM([p].[Amount]), 0.0)
    FROM [Payments] AS [p]
    WHERE DATEPART(month, [p].[History]) = DATEPART(month, [i].[History])) AS [Payment]
FROM [Invoices] AS [i]
GROUP BY DATEPART(month, [i].[History])

В EF7 перевод будет следующим:

SELECT [t].[Key] AS [Month], COALESCE(SUM([t].[Amount]), 0.0) AS [Total], (
    SELECT COALESCE(SUM([p].[Amount]), 0.0)
    FROM [Payments] AS [p]
    WHERE DATEPART(month, [p].[History]) = [t].[Key]) AS [Payment]
FROM (
    SELECT [i].[Amount], DATEPART(month, [i].[History]) AS [Key]
    FROM [Invoices] AS [i]
) AS [t]
GROUP BY [t].[Key]

Коллекции, доступные только для чтения, можно использовать для Contains

Совет

Приведенный здесь код взят из файла ReadOnlySetQuerySample.cs.

EF7 поддерживает использование Contains , если элементы для поиска содержатся в IReadOnlySet или IReadOnlyCollectionили или IReadOnlyList. Например, следующий LINQ-запрос:

IReadOnlySet<int> searchIds = new HashSet<int> { 1, 3, 5 };
var query = context.Customers.Where(p => p.Orders.Any(l => searchIds.Contains(l.Id)));

Преобразуется в следующий SQL при использовании SQL Server:

SELECT [c].[Id], [c].[Name]
FROM [Customers] AS [c]
WHERE EXISTS (
    SELECT 1
    FROM [Orders] AS [o]
    WHERE [c].[Id] = [o].[Customer1Id] AND [o].[Id] IN (1, 3, 5))

Переводы агрегатных функций

EF7 обеспечивает улучшенную расширяемость для поставщиков для преобразования агрегатных функций. Эта и другие работы в этой области привели к созданию нескольких новых переводов между поставщиками, в том числе:

Примечание

Агрегатные функции, которые работают с IEnumerable аргументами, обычно переводятся только в GroupBy запросы. Если вы хотите удалить это ограничение, проголосуйте за поддержку пространственных типов в столбцах JSON .

Строковые агрегатные функции

Совет

Приведенный здесь код получен из файла StringAggregateFunctionsSample.cs.

Запросы, использующие Join и Concat , теперь переводятся при необходимости. Пример:

var query = context.Posts
    .GroupBy(post => post.Author)
    .Select(grouping => new { Author = grouping.Key, Books = string.Join("|", grouping.Select(post => post.Title)) });

При использовании SQL Server этот запрос преобразуется в следующее:

SELECT [a].[Id], [a].[Name], COALESCE(STRING_AGG([p].[Title], N'|'), N'') AS [Books]
FROM [Posts] AS [p]
LEFT JOIN [Authors] AS [a] ON [p].[AuthorId] = [a].[Id]
GROUP BY [a].[Id], [a].[Name]

В сочетании с другими строковыми функциями эти переводы позволяют выполнять некоторые сложные операции со строками на сервере. Пример:

var query = context.Posts
    .GroupBy(post => post.Author!.Name)
    .Select(
        grouping =>
            new
            {
                PostAuthor = grouping.Key,
                Blogs = string.Concat(
                    grouping
                        .Select(post => post.Blog.Name)
                        .Distinct()
                        .Select(postName => "'" + postName + "' ")),
                ContentSummaries = string.Join(
                    " | ",
                    grouping
                        .Where(post => post.Content.Length >= 10)
                        .Select(post => "'" + post.Content.Substring(0, 10) + "' "))
            });

При использовании SQL Server этот запрос преобразуется в следующее:

SELECT [t].[Name], (N'''' + [t0].[Name]) + N''' ', [t0].[Name], [t].[c]
FROM (
    SELECT [a].[Name], COALESCE(STRING_AGG(CASE
        WHEN CAST(LEN([p].[Content]) AS int) >= 10 THEN COALESCE((N'''' + COALESCE(SUBSTRING([p].[Content], 0 + 1, 10), N'')) + N''' ', N'')
    END, N' | '), N'') AS [c]
    FROM [Posts] AS [p]
    LEFT JOIN [Authors] AS [a] ON [p].[AuthorId] = [a].[Id]
    GROUP BY [a].[Name]
) AS [t]
OUTER APPLY (
    SELECT DISTINCT [b].[Name]
    FROM [Posts] AS [p0]
    LEFT JOIN [Authors] AS [a0] ON [p0].[AuthorId] = [a0].[Id]
    INNER JOIN [Blogs] AS [b] ON [p0].[BlogId] = [b].[Id]
    WHERE [t].[Name] = [a0].[Name] OR ([t].[Name] IS NULL AND [a0].[Name] IS NULL)
) AS [t0]
ORDER BY [t].[Name]

Пространственные агрегатные функции

Совет

Приведенный здесь код получен из файла SpatialAggregateFunctionsSample.cs.

Теперь поставщики баз данных, поддерживающие NetTopologySuite , могут преобразовывать следующие пространственные агрегатные функции:

Совет

Эти переводы были реализованы командой для SQL Server и SQLite. Для других поставщиков обратитесь к поставщику услуг поддержки, чтобы добавить поддержку, если она была реализована для этого поставщика.

Пример:

var query = context.Caches
    .Where(cache => cache.Location.X < -90)
    .GroupBy(cache => cache.Owner)
    .Select(
        grouping => new { Id = grouping.Key, Combined = GeometryCombiner.Combine(grouping.Select(cache => cache.Location)) });

При использовании SQL Server этот запрос преобразуется в следующий SQL:

SELECT [c].[Owner] AS [Id], geography::CollectionAggregate([c].[Location]) AS [Combined]
FROM [Caches] AS [c]
WHERE [c].[Location].Long < -90.0E0
GROUP BY [c].[Owner]

Статистические агрегатные функции

Совет

Приведенный здесь код получен из файла StatisticalAggregateFunctionsSample.cs.

SQL Server переводы реализованы для следующих статистических функций:

Совет

Эти переводы были реализованы командой для SQL Server. Для других поставщиков обратитесь к поставщику услуг поддержки, чтобы добавить поддержку, если она была реализована для этого поставщика.

Пример:

var query = context.Downloads
    .GroupBy(download => download.Uploader.Id)
    .Select(
        grouping => new
        {
            Author = grouping.Key,
            TotalCost = grouping.Sum(d => d.DownloadCount),
            AverageViews = grouping.Average(d => d.DownloadCount),
            VariancePopulation = EF.Functions.VariancePopulation(grouping.Select(d => d.DownloadCount)),
            VarianceSample = EF.Functions.VarianceSample(grouping.Select(d => d.DownloadCount)),
            StandardDeviationPopulation = EF.Functions.StandardDeviationPopulation(grouping.Select(d => d.DownloadCount)),
            StandardDeviationSample = EF.Functions.StandardDeviationSample(grouping.Select(d => d.DownloadCount))
        });

При использовании SQL Server этот запрос преобразуется в следующий SQL:

SELECT [u].[Id] AS [Author], COALESCE(SUM([d].[DownloadCount]), 0) AS [TotalCost], AVG(CAST([d].[DownloadCount] AS float)) AS [AverageViews], VARP([d].[DownloadCount]) AS [VariancePopulation], VAR([d].[DownloadCount]) AS [VarianceSample], STDEVP([d].[DownloadCount]) AS [StandardDeviationPopulation], STDEV([d].[DownloadCount]) AS [StandardDeviationSample]
FROM [Downloads] AS [d]
INNER JOIN [Uploader] AS [u] ON [d].[UploaderId] = [u].[Id]
GROUP BY [u].[Id]

Перевод string.IndexOf

Совет

Приведенный здесь код получен из файла MiscellaneousTranslationsSample.cs.

EF7 теперь преобразуется String.IndexOf в запросы LINQ. Пример:

var query = context.Posts
    .Select(post => new { post.Title, IndexOfEntity = post.Content.IndexOf("Entity") })
    .Where(post => post.IndexOfEntity > 0);

При использовании SQL Server этот запрос преобразуется в следующий SQL:

SELECT [p].[Title], CAST(CHARINDEX(N'Entity', [p].[Content]) AS int) - 1 AS [IndexOfEntity]
FROM [Posts] AS [p]
WHERE (CAST(CHARINDEX(N'Entity', [p].[Content]) AS int) - 1) > 0

Преобразование для GetType типов сущностей

Совет

Приведенный здесь код получен из файла MiscellaneousTranslationsSample.cs.

EF7 теперь преобразуется Object.GetType() в запросы LINQ. Пример:

var query = context.Posts.Where(post => post.GetType() == typeof(Post));

Этот запрос преобразуется в следующий SQL при использовании SQL Server с наследованием TPH:

SELECT [p].[Id], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText]
FROM [Posts] AS [p]
WHERE [p].[Discriminator] = N'Post'

Обратите внимание, что этот запрос возвращает только Post экземпляры, которые фактически относятся к типу Post, а не экземпляры каких-либо производных типов. Это отличается от запроса, использующего is или OfType, который также возвращает экземпляры любых производных типов. Например, рассмотрим запрос:

var query = context.Posts.OfType<Post>();

Который преобразуется в другой SQL:

      SELECT [p].[Id], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText]
      FROM [Posts] AS [p]

И возвратит сущности Post и FeaturedPost .

Поддержка AT TIME ZONE

Совет

Приведенный здесь код получен из файла MiscellaneousTranslationsSample.cs.

В EF7 представлена новая платформа EF. Функции Functions.AtTimeZone для DateTime и DateTimeOffset. Эти функции преобразуются AT TIME ZONE в предложения в созданном SQL. Пример:

var query = context.Posts
    .Select(
        post => new
        {
            post.Title,
            PacificTime = EF.Functions.AtTimeZone(post.PublishedOn, "Pacific Standard Time"),
            UkTime = EF.Functions.AtTimeZone(post.PublishedOn, "GMT Standard Time"),
        });

При использовании SQL Server этот запрос преобразуется в следующий SQL:

SELECT [p].[Title], [p].[PublishedOn] AT TIME ZONE 'Pacific Standard Time' AS [PacificTime], [p].[PublishedOn] AT TIME ZONE 'GMT Standard Time' AS [UkTime]
FROM [Posts] AS [p]

Совет

Эти переводы были реализованы командой для SQL Server. Для других поставщиков обратитесь к поставщику услуг поддержки, чтобы добавить поддержку, если она была реализована для этого поставщика.

Отфильтрованное включение для скрытых навигаций

Совет

Приведенный здесь код получен из файла MiscellaneousTranslationsSample.cs.

Методы Include теперь можно использовать с EF.Property. Это позволяет выполнять фильтрацию и упорядочивание даже для свойств частной навигации или частных навигаций, представленных полями. Пример:

var query = context.Blogs.Include(
    blog => EF.Property<ICollection<Post>>(blog, "Posts")
        .Where(post => post.Content.Contains(".NET"))
        .OrderBy(post => post.Title));

Это соответствует следующей записи:

var query = context.Blogs.Include(
    blog => Posts
        .Where(post => post.Content.Contains(".NET"))
        .OrderBy(post => post.Title));

Но не требует Blog.Posts быть общедоступным.

При использовании SQL Server оба приведенных выше запроса преобразуют в:

SELECT [b].[Id], [b].[Name], [t].[Id], [t].[AuthorId], [t].[BlogId], [t].[Content], [t].[Discriminator], [t].[PublishedOn], [t].[Title], [t].[PromoText]
FROM [Blogs] AS [b]
LEFT JOIN (
    SELECT [p].[Id], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText]
    FROM [Posts] AS [p]
    WHERE [p].[Content] LIKE N'%.NET%'
) AS [t] ON [b].[Id] = [t].[BlogId]
ORDER BY [b].[Id], [t].[Title]

Преобразование Cosmos для Regex.IsMatch

Совет

Приведенный здесь код получен из Файла CosmosQueriesSample.cs.

EF7 поддерживает использование Regex.IsMatch в запросах LINQ к Azure Cosmos DB. Пример:

var containsInnerT = await context.Triangles
    .Where(o => Regex.IsMatch(o.Name, "[a-z]t[a-z]", RegexOptions.IgnoreCase))
    .ToListAsync();

Преобразуется в следующий SQL:

SELECT c
FROM root c
WHERE ((c["Discriminator"] = "Triangle") AND RegexMatch(c["Name"], "[a-z]t[a-z]", "i"))

Усовершенствования API DbContext и поведения

EF7 содержит ряд небольших улучшений для DbContext и связанных классов.

Совет

Код для примеров в этом разделе поступает из файла DbContextApiSample.cs.

Подавитель для неинициализированных свойств DbSet

Открытые настраиваемые DbSet свойства в DbContext автоматически инициализируются EF Core при DbContext создании . Например, рассмотрим следующее DbContext определение:

public class SomeDbContext : DbContext
{
    public DbSet<Blog> Blogs { get; set; }
}

Свойству Blogs будет присвоено значение экземпляра DbSet<Blog> при создании экземпляра DbContext . Это позволяет использовать контекст для запросов без каких-либо дополнительных действий.

Однако после введения ссылочных типов C#, допускающих значение NULL, компилятор теперь предупреждает, что свойство Blogs , не допускающее значение NULL, не инициализировано:

[CS8618] Non-nullable property 'Blogs' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.

Это фиктивное предупреждение; ef Core задает для свойства значение, отличное от NULL. Кроме того, объявление свойства как допускающего значение NULL приведет к тому, что предупреждение будет отключено, но это не рекомендуется, так как, по сути, свойство не допускает значение NULL и никогда не будет иметь значение NULL.

EF7 содержит DiagnosticSuppressor для DbSet свойств объекта , DbContext который останавливает компилятор, создающий это предупреждение.

Совет

Этот шаблон возник в те времена, когда автосвойства C# были очень ограничены. В современном C# рекомендуется сделать свойства автосвойства доступны только для чтения, а затем либо явно инициализировать их в DbContext конструкторе, либо получить кэшированный DbSet экземпляр из контекста, когда это необходимо. Например, public DbSet<Blog> Blogs => Set<Blog>().

Отличить отмену от сбоя в журналах

Иногда приложение явно отменяет запрос или другую операцию базы данных. Обычно это делается с помощью , передаваемого CancellationToken в метод, выполняющий операцию.

В EF Core 6 события, регистрированные при отмене операции, совпадают с событиями, зарегистрированными при сбое операции по какой-либо другой причине. EF7 представляет новые события журнала специально для отмененных операций с базой данных. Эти новые события по умолчанию регистрируются на Debug уровне . В следующей таблице показаны соответствующие события и уровни их ведения журнала по умолчанию.

Событие Описание Уровень ведения журнала по умолчанию
CoreEventId.QueryIterationFailed Произошла ошибка при обработке результатов запроса. LogLevel.Error
CoreEventId.SaveChangesFailed Произошла ошибка при попытке сохранить изменения в базе данных. LogLevel.Error
RelationalEventId.CommandError Во время выполнения команды базы данных произошла ошибка. LogLevel.Error
CoreEventId.QueryCanceled Запрос был отменен. LogLevel.Debug
CoreEventId.SaveChangesCanceled Команда базы данных была отменена при попытке сохранить изменения. LogLevel.Debug
SaveChangesCanceled.CommandCanceled Выполнение DbCommand объекта отменено. LogLevel.Debug

Примечание

Отмена определяется путем просмотра исключения, а не проверки маркера отмены. Это означает, что отмены, не активированные с помощью маркера отмены, будут по-прежнему обнаруживаться и регистрироваться таким образом.

Новые IProperty перегрузки и INavigation для EntityEntry методов

Код, работающий с моделью EF, часто имеет IProperty или INavigation , представляющий свойства или метаданные навигации. Затем EntityEntry используется для получения значения свойства или навигации или запроса его состояния. Однако до EF7 требовалось передать имя свойства или навигации в методы EntityEntry, которые затем будут повторно искать IProperty или INavigation. В EF7 IProperty вместо INavigation этого или можно передать напрямую, избегая дополнительного поиска.

Например, рассмотрим метод для поиска всех одноуровневых элементов данной сущности:

public static IEnumerable<TEntity> FindSiblings<TEntity>(
    this DbContext context, TEntity entity, string navigationToParent)
    where TEntity : class
{
    var parentEntry = context.Entry(entity).Reference(navigationToParent);

    return context.Entry(parentEntry.CurrentValue!)
        .Collection(parentEntry.Metadata.Inverse!)
        .CurrentValue!
        .OfType<TEntity>()
        .Where(e => !ReferenceEquals(e, entity));
}

Этот метод находит родительский объект данной сущности, а затем передает обратное INavigationCollection в метод родительской записи. Затем эти метаданные используются для возврата всех одноуровневых элементов данного родительского элемента. Ниже приведен пример его использования:


Console.WriteLine($"Siblings to {post.Id}: '{post.Title}' are...");
foreach (var sibling in context.FindSiblings(post, nameof(post.Blog)))
{
    Console.WriteLine($"    {sibling.Id}: '{sibling.Title}'");
}

И выходные данные:

Siblings to 1: 'Announcing Entity Framework 7 Preview 7: Interceptors!' are...
    5: 'Productivity comes to .NET MAUI in Visual Studio 2022'
    6: 'Announcing .NET 7 Preview 7'
    7: 'ASP.NET Core updates in .NET 7 Preview 7'

EntityEntry для типов сущностей общего типа

EF Core может использовать один и тот же тип СРЕДЫ CLR для нескольких разных типов сущностей. Они называются "общими типами сущностей" и часто используются для сопоставления словарного типа с парами "ключ-значение", используемыми для свойств типа сущности. Например, тип сущности BuildMetadata можно определить без определения выделенного типа СРЕДЫ CLR:

modelBuilder.SharedTypeEntity<Dictionary<string, object>>(
    "BuildMetadata", b =>
    {
        b.IndexerProperty<int>("Id");
        b.IndexerProperty<string>("Tag");
        b.IndexerProperty<Version>("Version");
        b.IndexerProperty<string>("Hash");
        b.IndexerProperty<bool>("Prerelease");
    });

Обратите внимание, что тип сущности общего типа должен иметь имя . В данном случае это BuildMetadataимя . Доступ к этим типам сущностей осуществляется с помощью DbSet для типа сущности, который получается с помощью имени. Пример:

public DbSet<Dictionary<string, object>> BuildMetadata
    => Set<Dictionary<string, object>>("BuildMetadata");

Его DbSet можно использовать для отслеживания экземпляров сущностей:

await context.BuildMetadata.AddAsync(
    new Dictionary<string, object>
    {
        { "Tag", "v7.0.0-rc.1.22426.7" },
        { "Version", new Version(7, 0, 0) },
        { "Prerelease", true },
        { "Hash", "dc0f3e8ef10eb1464b27f0fd4704f53c01226036" }
    });

И выполните запросы:

var builds = await context.BuildMetadata
    .Where(metadata => !EF.Property<bool>(metadata, "Prerelease"))
    .OrderBy(metadata => EF.Property<string>(metadata, "Tag"))
    .ToListAsync();

Теперь в EF7 также Entry есть метод, с DbSet помощью которого можно получить состояние экземпляра, даже если оно еще не отслеживается. Пример:

var state = context.BuildMetadata.Entry(build).State;

ContextInitialized теперь регистрируется как Debug

В EF7 ContextInitialized событие регистрируется на Debug уровне . Пример:

dbug: 10/7/2022 12:27:52.379 CoreEventId.ContextInitialized[10403] (Microsoft.EntityFrameworkCore.Infrastructure)
      Entity Framework Core 7.0.0 initialized 'BlogsContext' using provider 'Microsoft.EntityFrameworkCore.SqlServer:7.0.0' with options: SensitiveDataLoggingEnabled using NetTopologySuite

В предыдущих выпусках он регистрировался на Information уровне . Пример:

info: 10/7/2022 12:30:34.757 CoreEventId.ContextInitialized[10403] (Microsoft.EntityFrameworkCore.Infrastructure)
      Entity Framework Core 7.0.0 initialized 'BlogsContext' using provider 'Microsoft.EntityFrameworkCore.SqlServer:7.0.0' with options: SensitiveDataLoggingEnabled using NetTopologySuite

При необходимости уровень ведения журнала можно изменить обратно на Information:

optionsBuilder.ConfigureWarnings(
    builder =>
    {
        builder.Log((CoreEventId.ContextInitialized, LogLevel.Information));
    });

IEntityEntryGraphIterator является общедоступным

В EF7 IEntityEntryGraphIterator служба может использоваться приложениями. Эта служба используется внутри организации при обнаружении графа сущностей для отслеживания, а также с помощью TrackGraph. Ниже приведен пример, который выполняет итерацию по всем сущностям, доступным из какой-либо начальной сущности:

var blogEntry = context.ChangeTracker.Entries<Blog>().First();
var found = new HashSet<object>();
var iterator = context.GetService<IEntityEntryGraphIterator>();
iterator.TraverseGraph(new EntityEntryGraphNode<HashSet<object>>(blogEntry, found, null, null), node =>
{
    if (node.NodeState.Contains(node.Entry.Entity))
    {
        return false;
    }

    Console.Write($"Found with '{node.Entry.Entity.GetType().Name}'");

    if (node.InboundNavigation != null)
    {
        Console.Write($" by traversing '{node.InboundNavigation.Name}' from '{node.SourceEntry!.Entity.GetType().Name}'");
    }

    Console.WriteLine();

    node.NodeState.Add(node.Entry.Entity);

    return true;
});

Console.WriteLine();
Console.WriteLine($"Finished iterating. Found {found.Count} entities.");
Console.WriteLine();

Примечание:

  • Итератор останавливает обход с заданного узла, когда делегат обратного вызова возвращает false. В этом примере отслеживается посещенные сущности и возвращается, false когда сущность уже была посещена. Это предотвращает бесконечные циклы, возникающие в результате циклов в графе.
  • Объект EntityEntryGraphNode<TState> позволяет передавать состояние без записи в делегат.
  • Для каждого посещенного узла, отличного от первого, в обратный вызов передаются узел, с помощью которых он был обнаружен, и навигация была обнаружена с помощью.

Усовершенствования в построении моделей

EF7 содержит ряд небольших улучшений в создании моделей.

Совет

Код для примеров в этом разделе поступает из Файла ModelBuildingSample.cs.

Индексы могут быть по возрастанию или по убыванию

По умолчанию EF Core создает индексы по возрастанию. EF7 также поддерживает создание индексов по убыванию. Пример:

modelBuilder
    .Entity<Post>()
    .HasIndex(post => post.Title)
    .IsDescending();

Или с помощью атрибута Index сопоставления:

[Index(nameof(Title), AllDescending = true)]
public class Post
{
    public int Id { get; set; }

    [MaxLength(64)]
    public string? Title { get; set; }
}

Это редко бывает полезно для индексов по одному столбцу, так как база данных может использовать один и тот же индекс для упорядочения в обоих направлениях. Однако это не относится к составным индексам по нескольким столбцам, где порядок в каждом столбце может быть важен. EF Core поддерживает это, позволяя нескольким столбцам иметь разный порядок, определенный для каждого столбца. Пример:

modelBuilder
    .Entity<Blog>()
    .HasIndex(blog => new { blog.Name, blog.Owner })
    .IsDescending(false, true);

Или с помощью атрибута сопоставления:

[Index(nameof(Name), nameof(Owner), IsDescending = new[] { false, true })]
public class Blog
{
    public int Id { get; set; }

    [MaxLength(64)]
    public string? Name { get; set; }

    [MaxLength(64)]
    public string? Owner { get; set; }

    public List<Post> Posts { get; } = new();
}

При использовании SQL Server это приводит к следующему коду SQL:

CREATE INDEX [IX_Blogs_Name_Owner] ON [Blogs] ([Name], [Owner] DESC);

Наконец, для одного и того же упорядоченного набора столбцов можно создать несколько индексов, указав имена индексов. Пример:

modelBuilder
    .Entity<Blog>()
    .HasIndex(blog => new { blog.Name, blog.Owner }, "IX_Blogs_Name_Owner_1")
    .IsDescending(false, true);

modelBuilder
    .Entity<Blog>()
    .HasIndex(blog => new { blog.Name, blog.Owner }, "IX_Blogs_Name_Owner_2")
    .IsDescending(true, true);

Или с помощью атрибутов сопоставления:

[Index(nameof(Name), nameof(Owner), IsDescending = new[] { false, true }, Name = "IX_Blogs_Name_Owner_1")]
[Index(nameof(Name), nameof(Owner), IsDescending = new[] { true, true }, Name = "IX_Blogs_Name_Owner_2")]
public class Blog
{
    public int Id { get; set; }

    [MaxLength(64)]
    public string? Name { get; set; }

    [MaxLength(64)]
    public string? Owner { get; set; }

    public List<Post> Posts { get; } = new();
}

В SQL Server создается следующий код SQL:

CREATE INDEX [IX_Blogs_Name_Owner_1] ON [Blogs] ([Name], [Owner] DESC);
CREATE INDEX [IX_Blogs_Name_Owner_2] ON [Blogs] ([Name] DESC, [Owner] DESC);

Атрибут сопоставления для составных ключей

В EF7 представлен новый атрибут сопоставления (он же "заметка к данным") для указания свойства первичного ключа или свойств любого типа сущности. В отличие от System.ComponentModel.DataAnnotations.KeyAttribute, PrimaryKeyAttribute размещается в классе типа сущности, а не в свойстве key. Пример:

[PrimaryKey(nameof(PostKey))]
public class Post
{
    public int PostKey { get; set; }
}

Это делает его естественным подходом для определения составных ключей:

[PrimaryKey(nameof(PostId), nameof(CommentId))]
public class Comment
{
    public int PostId { get; set; }
    public int CommentId { get; set; }
    public string CommentText { get; set; } = null!;
}

Определение индекса в классе также означает, что его можно использовать для указания закрытых свойств или полей в качестве ключей, даже если они обычно игнорируются при построении модели EF. Пример:

[PrimaryKey(nameof(_id))]
public class Tag
{
    private readonly int _id;
}

DeleteBehavior атрибут сопоставления

В EF7 представлен атрибут сопоставления (он же "заметка к данным"), указывающий DeleteBehavior для связи. Например, по умолчанию необходимые связи создаются с DeleteBehavior.Cascade . Его можно изменить на по DeleteBehavior.NoAction умолчанию с помощью DeleteBehavior:

public class Post
{
    public int Id { get; set; }
    public string? Title { get; set; }

    [DeleteBehavior(DeleteBehavior.NoAction)]
    public Blog Blog { get; set; } = null!;
}

Это приведет к отключению каскадных удалений для связи Blog-Posts.

Свойства, сопоставленные с разными именами столбцов

Некоторые шаблоны сопоставления приводят к тому, что одно и то же свойство CLR сопоставляется со столбцом в каждой из нескольких разных таблиц. EF7 позволяет этим столбцам иметь разные имена. Например, рассмотрим простую иерархию наследования:

public abstract class Animal
{
    public int Id { get; set; }
    public string Breed { get; set; } = null!;
}

public class Cat : Animal
{
    public string? EducationalLevel { get; set; }
}

public class Dog : Animal
{
    public string? FavoriteToy { get; set; }
}

При использовании стратегии сопоставления наследования TPT эти типы будут сопоставлены с тремя таблицами. Однако первичный ключевой столбец в каждой таблице может иметь разные имена. Пример:

CREATE TABLE [Animals] (
    [Id] int NOT NULL IDENTITY,
    [Breed] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Animals] PRIMARY KEY ([Id])
);

CREATE TABLE [Cats] (
    [CatId] int NOT NULL,
    [EducationalLevel] nvarchar(max) NULL,
    CONSTRAINT [PK_Cats] PRIMARY KEY ([CatId]),
    CONSTRAINT [FK_Cats_Animals_CatId] FOREIGN KEY ([CatId]) REFERENCES [Animals] ([Id]) ON DELETE CASCADE
);

CREATE TABLE [Dogs] (
    [DogId] int NOT NULL,
    [FavoriteToy] nvarchar(max) NULL,
    CONSTRAINT [PK_Dogs] PRIMARY KEY ([DogId]),
    CONSTRAINT [FK_Dogs_Animals_DogId] FOREIGN KEY ([DogId]) REFERENCES [Animals] ([Id]) ON DELETE CASCADE
);

EF7 позволяет настроить это сопоставление с помощью построителя вложенных таблиц:

modelBuilder.Entity<Animal>().ToTable("Animals");

modelBuilder.Entity<Cat>()
    .ToTable(
        "Cats",
        tableBuilder => tableBuilder.Property(cat => cat.Id).HasColumnName("CatId"));

modelBuilder.Entity<Dog>()
    .ToTable(
        "Dogs",
        tableBuilder => tableBuilder.Property(dog => dog.Id).HasColumnName("DogId"));

С помощью сопоставления Breed наследования TPC свойство также можно сопоставить с разными именами столбцов в разных таблицах. Например, рассмотрим следующие таблицы TPC:

CREATE TABLE [Cats] (
    [CatId] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [CatBreed] nvarchar(max) NOT NULL,
    [EducationalLevel] nvarchar(max) NULL,
    CONSTRAINT [PK_Cats] PRIMARY KEY ([CatId])
);

CREATE TABLE [Dogs] (
    [DogId] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [DogBreed] nvarchar(max) NOT NULL,
    [FavoriteToy] nvarchar(max) NULL,
    CONSTRAINT [PK_Dogs] PRIMARY KEY ([DogId])
);

EF7 поддерживает следующее сопоставление таблиц:

modelBuilder.Entity<Animal>().UseTpcMappingStrategy();

modelBuilder.Entity<Cat>()
    .ToTable(
        "Cats",
        builder =>
        {
            builder.Property(cat => cat.Id).HasColumnName("CatId");
            builder.Property(cat => cat.Breed).HasColumnName("CatBreed");
        });

modelBuilder.Entity<Dog>()
    .ToTable(
        "Dogs",
        builder =>
        {
            builder.Property(dog => dog.Id).HasColumnName("DogId");
            builder.Property(dog => dog.Breed).HasColumnName("DogBreed");
        });

Однонаправленные связи "многие ко многим"

EF7 поддерживает связи "многие ко многим ", когда одна сторона или другая сторона не имеет свойства навигации. Например, рассмотрим Post типы и Tag :

public class Post
{
    public int Id { get; set; }
    public string? Title { get; set; }
    public Blog Blog { get; set; } = null!;
    public List<Tag> Tags { get; } = new();
}
public class Tag
{
    public int Id { get; set; }
    public string TagName { get; set; } = null!;
}

Обратите внимание, что тип Post имеет свойство навигации для списка тегов, но Tag тип не имеет свойства навигации для записей. В EF7 это можно по-прежнему настроить как связь "многие ко многим", что позволяет использовать один и тот же Tag объект для множества различных записей. Пример:

modelBuilder
    .Entity<Post>()
    .HasMany(post => post.Tags)
    .WithMany();

Это приводит к сопоставлению с соответствующей таблицей соединения:

CREATE TABLE [Tags] (
    [Id] int NOT NULL IDENTITY,
    [TagName] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Tags] PRIMARY KEY ([Id])
);

CREATE TABLE [Posts] (
    [Id] int NOT NULL IDENTITY,
    [Title] nvarchar(64) NULL,
    [BlogId] int NOT NULL,
    CONSTRAINT [PK_Posts] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Posts_Blogs_BlogId] FOREIGN KEY ([BlogId]) REFERENCES [Blogs] ([Id])
);

CREATE TABLE [PostTag] (
    [PostId] int NOT NULL,
    [TagsId] int NOT NULL,
    CONSTRAINT [PK_PostTag] PRIMARY KEY ([PostId], [TagsId]),
    CONSTRAINT [FK_PostTag_Posts_PostId] FOREIGN KEY ([PostId]) REFERENCES [Posts] ([Id]) ON DELETE CASCADE,
    CONSTRAINT [FK_PostTag_Tags_TagsId] FOREIGN KEY ([TagsId]) REFERENCES [Tags] ([Id]) ON DELETE CASCADE
);

И связь может быть использована как многие ко многим в обычном режиме. Например, при вставке некоторых записей, которые совместно используют различные теги из общего набора:

var tags = new Tag[] { new() { TagName = "Tag1" }, new() { TagName = "Tag2" }, new() { TagName = "Tag2" }, };

await context.AddRangeAsync(new Blog { Posts =
{
    new Post { Tags = { tags[0], tags[1] } },
    new Post { Tags = { tags[1], tags[0], tags[2] } },
    new Post()
} });

await context.SaveChangesAsync();

Разбиение сущностей

Разделение сущностей сопоставляет один тип сущности с несколькими таблицами. Например, рассмотрим базу данных с тремя таблицами, которые содержат данные клиентов:

  • Customers Таблица со сведениями о клиенте
  • PhoneNumbers Таблица для номера телефона клиента
  • Addresses Таблица для адреса клиента

Ниже приведены определения для этих таблиц в SQL Server:

CREATE TABLE [Customers] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Customers] PRIMARY KEY ([Id])
);
    
CREATE TABLE [PhoneNumbers] (
    [CustomerId] int NOT NULL,
    [PhoneNumber] nvarchar(max) NULL,
    CONSTRAINT [PK_PhoneNumbers] PRIMARY KEY ([CustomerId]),
    CONSTRAINT [FK_PhoneNumbers_Customers_CustomerId] FOREIGN KEY ([CustomerId]) REFERENCES [Customers] ([Id]) ON DELETE CASCADE
);

CREATE TABLE [Addresses] (
    [CustomerId] int NOT NULL,
    [Street] nvarchar(max) NOT NULL,
    [City] nvarchar(max) NOT NULL,
    [PostCode] nvarchar(max) NULL,
    [Country] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Addresses] PRIMARY KEY ([CustomerId]),
    CONSTRAINT [FK_Addresses_Customers_CustomerId] FOREIGN KEY ([CustomerId]) REFERENCES [Customers] ([Id]) ON DELETE CASCADE
);

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

public class Customer
{
    public Customer(string name, string street, string city, string? postCode, string country)
    {
        Name = name;
        Street = street;
        City = city;
        PostCode = postCode;
        Country = country;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public string? PhoneNumber { get; set; }
    public string Street { get; set; }
    public string City { get; set; }
    public string? PostCode { get; set; }
    public string Country { get; set; }
}

Это достигается в EF7 путем вызова SplitToTable для каждого разбиения в типе сущности. Например, следующий код разделяет тип сущности на CustomerCustomersтаблицы , и Addresses , PhoneNumbersпоказанные выше:

modelBuilder.Entity<Customer>(
    entityBuilder =>
    {
        entityBuilder
            .ToTable("Customers")
            .SplitToTable(
                "PhoneNumbers",
                tableBuilder =>
                {
                    tableBuilder.Property(customer => customer.Id).HasColumnName("CustomerId");
                    tableBuilder.Property(customer => customer.PhoneNumber);
                })
            .SplitToTable(
                "Addresses",
                tableBuilder =>
                {
                    tableBuilder.Property(customer => customer.Id).HasColumnName("CustomerId");
                    tableBuilder.Property(customer => customer.Street);
                    tableBuilder.Property(customer => customer.City);
                    tableBuilder.Property(customer => customer.PostCode);
                    tableBuilder.Property(customer => customer.Country);
                });
    });

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

SQL Server строк UTF-8

SQL Server строки Юникода, представленные nchar типами данных и nvarchar , хранятся в формате UTF-16. Кроме того, char типы данных и varchar используются для хранения строк, отличных от Юникода, с поддержкой различных наборов символов.

Начиная с SQL Server 2019 года char типы данных и varchar можно использовать для хранения строк Юникода с кодировкой UTF-8. Значение достигается путем установки одного из параметров сортировки UTF-8. Например, следующий код настраивает переменную длину SQL Server строке UTF-8 для столбцаCommentText:

modelBuilder
    .Entity<Comment>()
    .Property(comment => comment.CommentText)
    .HasColumnType("varchar(max)")
    .UseCollation("LATIN1_GENERAL_100_CI_AS_SC_UTF8");

Эта конфигурация создает следующее определение столбца SQL Server:

CREATE TABLE [Comment] (
    [PostId] int NOT NULL,
    [CommentId] int NOT NULL,
    [CommentText] varchar(max) COLLATE LATIN1_GENERAL_100_CI_AS_SC_UTF8 NOT NULL,
    CONSTRAINT [PK_Comment] PRIMARY KEY ([PostId], [CommentId])
);

Темпоральные таблицы поддерживают принадлежащие сущности

В EF7 улучшено сопоставление темпоральных таблиц EF Core SQL Server темпоральных таблиц для поддержки общего доступа к таблицам. В частности, сопоставление по умолчанию для принадлежащих отдельных сущностей использует общий доступ к таблицам.

Например, рассмотрим тип Employee сущности владельца и его собственный тип EmployeeInfoсущности :

public class Employee
{
    public Guid EmployeeId { get; set; }
    public string Name { get; set; } = null!;

    public EmployeeInfo Info { get; set; } = null!;
}

public class EmployeeInfo
{
    public string Position { get; set; } = null!;
    public string Department { get; set; } = null!;
    public string? Address { get; set; }
    public decimal? AnnualSalary { get; set; }
}

Если эти типы сопоставлены с одной и той же таблицей, то в EF7 ее можно сделать темпоральной таблицей:

modelBuilder
    .Entity<Employee>()
    .ToTable(
        "Employees",
        tableBuilder =>
        {
            tableBuilder.IsTemporal();
            tableBuilder.Property<DateTime>("PeriodStart").HasColumnName("PeriodStart");
            tableBuilder.Property<DateTime>("PeriodEnd").HasColumnName("PeriodEnd");
        })
    .OwnsOne(
        employee => employee.Info,
        ownedBuilder => ownedBuilder.ToTable(
            "Employees",
            tableBuilder =>
            {
                tableBuilder.IsTemporal();
                tableBuilder.Property<DateTime>("PeriodStart").HasColumnName("PeriodStart");
                tableBuilder.Property<DateTime>("PeriodEnd").HasColumnName("PeriodEnd");
            }));

Примечание

Упрощение этой конфигурации отслеживается по проблеме No 29303. Проголосуйте за эту проблему, если вы хотите, чтобы она была реализована.

Улучшенное создание значений

EF7 включает два существенных улучшения в автоматическом создании значений для ключевых свойств.

Совет

Код для примеров в этом разделе получен из файла ValueGenerationSample.cs.

Создание значений для защищенных типов DDD

В архитектуре на основе домена (DDD) "защищенные ключи" могут повысить безопасность типов свойств ключей. Это достигается путем упаковки типа ключа в другой тип, характерный для использования ключа. Например, следующий код определяет тип для ключей ProductId продуктов и тип для ключей CategoryId категорий.

public readonly struct ProductId
{
    public ProductId(int value) => Value = value;
    public int Value { get; }
}

public readonly struct CategoryId
{
    public CategoryId(int value) => Value = value;
    public int Value { get; }
}

Затем они используются в Product типах сущностей и Category :

public class Product
{
    public Product(string name) => Name = name;
    public ProductId Id { get; set; }
    public string Name { get; set; }
    public CategoryId CategoryId { get; set; }
    public Category Category { get; set; } = null!;
}

public class Category
{
    public Category(string name) => Name = name;
    public CategoryId Id { get; set; }
    public string Name { get; set; }
    public List<Product> Products { get; } = new();
}

Это делает невозможным случайное назначение идентификатора категории продукту или наоборот.

Предупреждение

Как и многие другие концепции DDD, повышение безопасности типов происходит за счет дополнительной сложности кода. Стоит рассмотреть вопрос о том, является ли, например, назначение идентификатора продукта категории чем-либо, что может произойти. Обеспечение простоты может быть в целом более полезным для базы кода.

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

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Properties<ProductId>().HaveConversion<ProductIdConverter>();
    configurationBuilder.Properties<CategoryId>().HaveConversion<CategoryIdConverter>();
}

private class ProductIdConverter : ValueConverter<ProductId, int>
{
    public ProductIdConverter()
        : base(v => v.Value, v => new(v))
    {
    }
}

private class CategoryIdConverter : ValueConverter<CategoryId, int>
{
    public CategoryIdConverter()
        : base(v => v.Value, v => new(v))
    {
    }
}

Примечание

В приведенном здесь коде используются struct типы. Это означает, что они имеют соответствующую семантику типа значения для использования в качестве ключей. Если class вместо этого используются типы, они должны либо переопределить семантику равенства, либо также указать компаратор значений.

В EF7 типы ключей, основанные на преобразователях значений, могут использовать автоматически созданные значения ключей, если базовый тип поддерживает это. Это настраивается обычным способом с помощью ValueGeneratedOnAdd:

modelBuilder.Entity<Product>().Property(product => product.Id).ValueGeneratedOnAdd();
modelBuilder.Entity<Category>().Property(category => category.Id).ValueGeneratedOnAdd();

По умолчанию это приводит к столбцам IDENTITY при использовании с SQL Server:

CREATE TABLE [Categories] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Categories] PRIMARY KEY ([Id]));

CREATE TABLE [Products] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    [CategoryId] int NOT NULL,
    CONSTRAINT [PK_Products] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Products_Categories_CategoryId] FOREIGN KEY ([CategoryId]) REFERENCES [Categories] ([Id]) ON DELETE CASCADE);

Которые используются обычным способом для создания значений ключей при вставке сущностей:

MERGE [Categories] USING (
VALUES (@p0, 0),
(@p1, 1)) AS i ([Name], _Position) ON 1=0
WHEN NOT MATCHED THEN
INSERT ([Name])
VALUES (i.[Name])
OUTPUT INSERTED.[Id], i._Position;

Создание ключей на основе последовательностей для SQL Server

EF Core поддерживает создание значения ключа с помощью SQL Server IDENTITY столбцов или шаблона Hi-Lo на основе блоков ключей, созданных последовательностью базы данных. В EF7 реализована поддержка последовательности базы данных, подключенной к ограничению по умолчанию столбца ключа. В простейшей форме для этого просто требуется сообщить EF Core, чтобы использовать последовательность для свойства ключа:

modelBuilder.Entity<Product>().Property(product => product.Id).UseSequence();

Это приводит к определению последовательности в базе данных:

CREATE SEQUENCE [ProductSequence] START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE NO CYCLE;

Затем используется в ограничении ключевого столбца по умолчанию:

CREATE TABLE [Products] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [ProductSequence]),
    [Name] nvarchar(max) NOT NULL,
    [CategoryId] int NOT NULL,
    CONSTRAINT [PK_Products] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Products_Categories_CategoryId] FOREIGN KEY ([CategoryId]) REFERENCES [Categories] ([Id]) ON DELETE CASCADE);

Примечание

Эта форма создания ключей используется по умолчанию для созданных ключей в иерархиях типа сущностей с помощью стратегии сопоставления TPC.

При необходимости последовательности можно присвоить другое имя и схему. Пример:

modelBuilder
    .Entity<Product>()
    .Property(product => product.Id)
    .UseSequence("ProductsSequence", "northwind");

Дальнейшая конфигурация последовательности формируется путем явной настройки в модели. Пример:

modelBuilder
    .HasSequence<int>("ProductsSequence", "northwind")
    .StartsAt(1000)
    .IncrementsBy(2);

Улучшения средств миграции

EF7 включает два существенных улучшения при использовании средств командной строки миграции EF Core.

UseSqlServer и т. д. принимает значение NULL

Очень часто считывает строку подключения из файла конфигурации, а затем передает ее UseSqlServerв , UseSqliteили эквивалентный метод для другого поставщика. Пример:

services.AddDbContext<BloggingContext>(options =>
    options.UseSqlServer(Configuration.GetConnectionString("BloggingDatabase")));

При применении миграции также часто передается строка подключения. Пример:

dotnet ef database update --connection "Server=(localdb)\mssqllocaldb;Database=MyAppDb"

Или при использовании пакета миграций.

./bundle.exe --connection "Server=(localdb)\mssqllocaldb;Database=MyAppDb"

В этом случае, даже если строка подключения, считываемая из конфигурации, не используется, код запуска приложения по-прежнему пытается считывать ее из конфигурации и передавать ее в UseSqlServer. Если конфигурация недоступна, это приводит к передаче null в UseSqlServer. В EF7 это разрешено, если строка подключения будет в конечном итоге задана позже, например путем передачи --connection в программу командной строки.

Примечание

Это изменение было внесено для UseSqlServer и UseSqlite. Для других поставщиков обратитесь к поддержку поставщика, чтобы внести эквивалентные изменения, если это еще не сделано для этого поставщика.

Обнаружение запуска инструментов

EF Core запускает код приложения при dotnet-ef использовании команд Или PowerShell . Иногда может потребоваться обнаружить эту ситуацию, чтобы предотвратить несоответствующее выполнение кода во время разработки. Например, код, который автоматически применяет миграции при запуске, скорее всего, не должен делать этого во время разработки. В EF7 это можно определить с помощью флага EF.IsDesignTime :

if (!EF.IsDesignTime)
{
    await context.Database.MigrateAsync();
}

EF Core задает для IsDesignTime значение , true когда код приложения выполняется от имени средств.

Повышение производительности для прокси-серверов

EF Core поддерживает динамически создаваемые прокси-серверы для отложенной загрузки и отслеживания изменений. EF7 содержит два улучшения производительности при использовании этих прокси-серверов:

  • Типы прокси-серверов теперь создаются отложенно. Это означает, что начальное время сборки модели при использовании прокси-серверов в EF7 может быть значительно быстрее, чем в EF Core 6.0.
  • Теперь прокси-серверы можно использовать с скомпилированными моделями.

Ниже приведены некоторые результаты производительности для модели с 449 типами сущностей, 6390 свойствами и 720 связями.

Сценарий Метод Среднее значение Ошибка StdDev
EF Core 6.0 без прокси-серверов TimeToFirstQuery 1,085 с 0,0083 с 0,0167 с
EF Core 6.0 с прокси-серверами отслеживания изменений TimeToFirstQuery 13,01 с 0,2040 с 0,4110 с
EF Core 7.0 без прокси-серверов TimeToFirstQuery 1,442 с 0,0134 с 0,0272 с
EF Core 7.0 с прокси-серверами отслеживания изменений TimeToFirstQuery 1,446 с 0,0160 с 0,0323 с
EF Core 7.0 с прокси-серверами отслеживания изменений и скомпилированной моделью TimeToFirstQuery 0,162 с 0,0062 с 0,0125 с

Таким образом, в этом случае модель с прокси-серверами отслеживания изменений может быть готова к выполнению первого запроса в 80 раз быстрее в EF7, чем это было возможно в EF Core 6.0.

Первоклассная привязка данных Windows Forms

Команда Windows Forms вносит некоторые значительные улучшения в visual Studio Designer. Сюда входят новые возможности привязки данных , которые хорошо интегрируются с EF Core.

Вкратце, новый интерфейс предоставляет Visual Studio U.I. для создания ObjectDataSource:

Выбор типа источника данных категории

Затем его можно привязать к EF Core DbSet с помощью простого кода:

public partial class MainForm : Form
{
    private ProductsContext? dbContext;

    public MainForm()
    {
        InitializeComponent();
    }

    protected override void OnLoad(EventArgs e)
    {
        base.OnLoad(e);

        this.dbContext = new ProductsContext();

        this.dbContext.Categories.Load();
        this.categoryBindingSource.DataSource = dbContext.Categories.Local.ToBindingList();
    }

    protected override void OnClosing(CancelEventArgs e)
    {
        base.OnClosing(e);

        this.dbContext?.Dispose();
        this.dbContext = null;
    }
}

Полное пошаговое руководство и скачиваемый пример приложения WinForms см. в разделе начало работы с Windows Forms.