Как настроить собственную плюрализацию имён таблиц в dotnet ef dbcontext scaffold через IPluralizer
Таблица с именем Gas превращается в сущность Ga. Замените встроенный плюрализатор Humanizer собственной реализацией IPluralizer, зарегистрируйте её через IDesignTimeServices в стартовом проекте и узнайте, почему TryAddSingleton там молча ничего не делает.
Короткий ответ: напишите класс, реализующий Microsoft.EntityFrameworkCore.Design.IPluralizer (два метода, Pluralize и Singularize), затем зарегистрируйте его из класса, реализующего IDesignTimeServices, в вашем стартовом проекте вызовом services.AddSingleton<IPluralizer, MyPluralizer>(). Используйте AddSingleton, но никогда TryAddSingleton: EF Core регистрирует HumanizerPluralizer до того, как выполнится ваш код, поэтому TryAdd оказывается молчаливой пустой операцией, и вы потратите час, гадая, почему ничего не изменилось. После этого dotnet ef dbcontext scaffold вызывает ваш Singularize для имён типов сущностей и ваш Pluralize для имён DbSet и навигаций-коллекций. Если вам также нужно убрать префикс tbl_, то это другой сервис, ICandidateNamingService, и он работает до плюрализатора.
Всё описанное ниже запускалось на .NET 11 RC 1 SDK (11.0.100-rc.1.26425.128) с dotnet-ef 11.0.0-rc.1.26425.128, Microsoft.EntityFrameworkCore.Sqlite и Microsoft.EntityFrameworkCore.Design 11.0.0-rc.1.26425.128, а также Humanizer.Core 3.0.10. База данных представляет собой локальный файл SQLite, потому что ничто в этой статье не зависит от провайдера: плюрализация происходит в Microsoft.EntityFrameworkCore.Design, выше уровня провайдера. Каждое приведённое здесь сгенерированное имя это реальный вывод dotnet ef, а не реконструкция.
Таблица с именем Gas превращается в сущность с именем Ga
Вот схема из шести таблиц с такими именами, какие реальные базы данных действительно содержат: два немецких слова, два английских слова, которые оканчиваются на s, оставаясь при этом в единственном числе, одно латинское множественное число и один устаревший префикс tbl_.
-- SQLite, shop.db
CREATE TABLE Kunde (KundeId INTEGER PRIMARY KEY, Name TEXT NOT NULL);
CREATE TABLE Bestellung (BestellungId INTEGER PRIMARY KEY, KundeId INTEGER NOT NULL REFERENCES Kunde(KundeId), Summe REAL NOT NULL);
CREATE TABLE Gas (GasId INTEGER PRIMARY KEY, Formula TEXT NOT NULL);
CREATE TABLE Canvas (CanvasId INTEGER PRIMARY KEY, Width INTEGER NOT NULL);
CREATE TABLE Media (MediaId INTEGER PRIMARY KEY, Url TEXT NOT NULL);
CREATE TABLE tbl_person (person_id INTEGER PRIMARY KEY, full_name TEXT NOT NULL);
Выполните scaffold вообще без какой-либо настройки:
dotnet ef dbcontext scaffold "Data Source=shop.db" Microsoft.EntityFrameworkCore.Sqlite -o Gen --context ShopContext -f
Сгенерированные файлы это Bestellung.cs, Canva.cs, Ga.cs, Kunde.cs, Medium.cs, TblPerson.cs, а контекст выглядит так:
// Generated by dotnet ef 11.0.0-rc.1.26425.128, default services
public virtual DbSet<Bestellung> Bestellungs { get; set; }
public virtual DbSet<Canva> Canvas { get; set; } // entity type is Canva
public virtual DbSet<Ga> Gas { get; set; } // entity type is Ga
public virtual DbSet<Kunde> Kundes { get; set; }
public virtual DbSet<Medium> Media { get; set; }
public virtual DbSet<TblPerson> TblPeople { get; set; }
Четыре разные проблемы на шести таблицах. Gas и Canvas это существительные в единственном числе, оканчивающиеся на s, и сингуляризатор всё равно отрезает s. К Kunde и Bestellung применяются английские правила образования множественного числа, хотя это немецкие слова. Media превращается в Medium, что корректно с точки зрения латыни и почти никогда не соответствует тому, что означает таблица Media. А tbl_person сохраняет свой префикс и затем плюрализуется в поистине неожиданное TblPeople.
Это не столько ошибка, сколько неизбежный результат применения морфологии одного языка к произвольным идентификаторам. Стоит посмотреть, насколько широк радиус поражения. Прямой вызов HumanizerPluralizer на списке слов даёт:
input Singularize Pluralize
Gas Ga Gas
Canvas Canva Canvas
Atlas Atla Atlas
Census Censu Census
Corpus Corpu Corpus
Bonus Bonu Bonus
Nexus Nexu Nexus
GPS GP GPs
Media Medium Media
Data Datum Data
Criteria Criterion Criteria
Index Index Indices
Focus Focus Foci
Radius Radius Radii
Status Status Statuses
Bestellung Bestellung Bestellungs
Usuario Usuario Usuarios
Из этой таблицы стоит выделить две вещи. Во-первых, семейство -us и -as стабильно калечится в направлении единственного числа: любая таблица, имя которой оканчивается на s, не являющееся показателем множественного числа, теряет символ. Во-вторых, латинские формы множественного числа (Indices, Foci, Radii) технически обоснованны и всё равно неверны для большинства кодовых баз, которые ожидают Indexes. Status раньше был в сломанной группе, а сейчас обрабатывается корректно.
Замечание о версиях: эти результаты идентичны на Humanizer.Core 2.14.1, от которого зависит Microsoft.EntityFrameworkCore.Design версий с 8.0.x по 10.0.x, и на 3.0.10, на который перешёл EF Core 11 RC 1. Обновление EF Core ничего из этого за вас не исправит.
Где плюрализатор находится в конвейере именования
Прежде чем писать замену, полезно точно знать, какую строку получают ваши методы, потому что из интерфейса это не очевидно.
RelationalScaffoldingModelFactory создаёт два именователя на каждый запуск scaffold:
// efcore, src/EFCore.Design/Scaffolding/Internal/RelationalScaffoldingModelFactory.cs, v11.0.0-rc.1.26425.128
_tableNamer = new CSharpUniqueNamer<DatabaseTable>(
options.UseDatabaseNames ? (t => t.Name) : _candidateNamingService.GenerateCandidateIdentifier,
_cSharpUtilities,
options.NoPluralize ? null : _pluralizer.Singularize,
caseSensitive: false);
_dbSetNamer = new CSharpUniqueNamer<DatabaseTable>(
options.UseDatabaseNames ? (t => t.Name) : _candidateNamingService.GenerateCandidateIdentifier,
_cSharpUtilities,
options.NoPluralize ? null : _pluralizer.Pluralize,
caseSensitive: true);
Итак, имя типа сущности это Singularize(candidateIdentifier), а имя свойства DbSet это Pluralize(candidateIdentifier), из одного и того же входа. Оба проходят через CSharpUtilities.GenerateCSharpIdentifier, который фиксирует порядок операций:
ICandidateNamingService.GenerateCandidateIdentifierпревращает сырое имя таблицы в кандидата в PascalCase (tbl_personстановитсяTblPerson), если не задан--use-database-names.- Символы, недопустимые в идентификаторе C#, заменяются на
_. - Работает ваш плюрализатор, над результатом шагов 1 и 2.
- В начало добавляется
_, если результат начинается с цифры или является ключевым словом C#. - Уникализатор дописывает
1,2и так далее, если имя уже занято.
Шаг 3 самый важный: вы получаете целый идентификатор в PascalCase, такой как CustomerAddress или TblPerson, а не одно слово. Словарь с ключами из голых существительных пропустит почти всё, если вы сначала не разобьёте идентификатор.
Именователь сущностей нечувствителен к регистру при проверке уникальности, а именователь DbSet чувствителен, и именно поэтому Media может быть одновременно и типом сущности, и именем DbSet без конфликта.
Навигации-коллекции плюрализуются отдельно, явным вызовом _pluralizer.Pluralize над именем-кандидатом навигации, а ссылочные навигации не приводятся к единственному числу вообще. Все четыре места вызова защищены условием if (!_options.NoPluralize).
IPluralizer, работающий с целыми идентификаторами
Реализация ниже оставляет Humanizer как запасной вариант (он прав гораздо чаще, чем ошибается) и переопределяет только те слова, которые действительно содержит ваша схема. Одна таблица пар управляет обоими направлениями, и это важнее, чем кажется: в первой версии, которую я написал, была только карта из единственного числа во множественное, она исправила все имена DbSet и оставила типы сущностей с именами Ga и Canva, потому что Singularize("Gas") никогда не находил совпадения.
// .NET 11, C# 14. EF Core 11.0.0-rc.1.26425.128, Humanizer.Core 3.0.10.
using Humanizer;
using Microsoft.EntityFrameworkCore.Design;
namespace Shared;
public sealed class DomainPluralizer : IPluralizer
{
// One row per word the English rules get wrong. Both directions come from this list.
private static readonly (string Singular, string Plural)[] Words =
[
("Kunde", "Kunden"),
("Bestellung", "Bestellungen"),
("Gas", "Gases"),
("Canvas", "Canvases"),
("Bonus", "Bonuses"),
("Status", "Statuses"),
("Media", "Media"),
("Aircraft", "Aircraft"),
("Person", "Persons")
];
private static readonly Dictionary<string, string> ToPlural = BuildToPlural();
private static readonly Dictionary<string, string> ToSingular = BuildToSingular();
private static Dictionary<string, string> BuildToPlural()
{
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var (singular, plural) in Words)
{
map[singular] = plural;
map[plural] = plural;
}
return map;
}
private static Dictionary<string, string> BuildToSingular()
{
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var (singular, plural) in Words)
{
map[plural] = singular;
map[singular] = singular;
}
return map;
}
public string Pluralize(string identifier)
=> Transform(identifier, ToPlural, word => word.Pluralize(inputIsKnownToBeSingular: false));
public string Singularize(string identifier)
=> Transform(identifier, ToSingular, word => word.Singularize(inputIsKnownToBePlural: false));
private static string Transform(
string identifier,
IReadOnlyDictionary<string, string> overrides,
Func<string, string> fallback)
{
if (string.IsNullOrEmpty(identifier))
{
return identifier;
}
if (overrides.TryGetValue(identifier, out var whole))
{
return whole;
}
var (prefix, trailing) = SplitTrailingWord(identifier);
return overrides.TryGetValue(trailing, out var mapped)
? prefix + mapped
: prefix + fallback(trailing);
}
private static (string Prefix, string Trailing) SplitTrailingWord(string identifier)
{
for (var i = identifier.Length - 1; i > 0; i--)
{
if (identifier[i] == '_')
{
return (identifier[..(i + 1)], identifier[(i + 1)..]);
}
if (char.IsUpper(identifier[i]) && !char.IsUpper(identifier[i - 1]))
{
return (identifier[..i], identifier[i..]);
}
}
return ("", identifier);
}
}
Три проектных решения, о которых стоит сказать отдельно. Отображение каждой формы единственного числа в саму себя в ToSingular (и каждой формы множественного числа в саму себя в ToPlural) это то, что делает методы идемпотентными, чего и требует документация интерфейса: “Returns the same identifier if it is already pluralized.” Разбиение по последней границе PascalCase означает, что InvoiceStatus и ShippingStatus оба подхватывают правило для Status без отдельных записей. А SplitTrailingWord также разбивает по _, что имеет значение только при --use-database-names, когда сырое tbl_person доходит до вашего кода неизменным.
Регистрация так, чтобы dotnet ef действительно её использовал
Microsoft.EntityFrameworkCore.Design это пакет с признаком DevelopmentDependency, поэтому обычный dotnet add package даёт вам ссылку, против которой нельзя компилироваться. Уберите метаданные IncludeAssets:
<!-- Shared.csproj, .NET 11 -->
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="11.0.0-rc.1.26425.128">
<PrivateAssets>none</PrivateAssets>
</PackageReference>
<PackageReference Include="Humanizer.Core" Version="3.0.10" />
Ссылайтесь на Humanizer.Core явно, если используете его как запасной вариант. Сегодня он приходит транзитивно, но полагаться на ту версию, которую EF Core случайно закрепил, это верный способ получить сюрприз при обновлении.
Теперь сама регистрация:
// .NET 11, C# 14. EF Core 11.0.0-rc.1.26425.128.
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.DependencyInjection;
namespace Shared;
public sealed class SharedDesignTimeServices : IDesignTimeServices
{
public void ConfigureDesignTimeServices(IServiceCollection services)
=> services.AddSingleton<IPluralizer, DomainPluralizer>();
}
Используйте AddSingleton, а не TryAddSingleton. DesignTimeServicesBuilder.CreateServiceCollection вызывает services.AddEntityFrameworkDesignTimeServices(...) и только потом ConfigureUserServices(services). Собственная регистрация EF это TryAddSingleton<IPluralizer, HumanizerPluralizer>(), поэтому к моменту выполнения вашего метода IPluralizer уже находится в коллекции. AddSingleton добавляет в конец, а Microsoft.Extensions.DependencyInjection разрешает последнюю регистрацию, так что побеждает ваша. TryAddSingleton видит существующую запись и не делает ничего. Я запускал scaffold обоими способами на одной и той же схеме: с TryAddSingleton вывод был байт в байт идентичен запуску по умолчанию, включая типы сущностей Ga и Canva, без какого-либо предупреждения.
Ещё два правила обнаружения, оба прямо из DesignTimeServicesBuilder:
ConfigureUserServicesсканирует только стартовую сборку. Если ваш классIDesignTimeServicesлежит в библиотеке классов, которую вы передали в--project, но не в той, которую вы передали в--startup-project, он никогда не будет найден.- Он вызывает
.FirstOrDefault()на подходящих типах. ВторойIDesignTimeServicesв той же сборке не вызывает ошибки, он молча игнорируется, и то, какой из них уцелеет, определяется порядком рефлексии.
Чтобы разделять плюрализатор между несколькими проектами, поместите его в отдельную сборку и укажите на неё атрибутом уровня сборки. Этот путь обрабатывается методом ConfigureReferencedServices, который сканирует и стартовую сборку, и целевую сборку на наличие DesignTimeServicesReferenceAttribute:
// AssemblyInfo.cs in the project you scaffold into
using Microsoft.EntityFrameworkCore.Design;
[assembly: DesignTimeServicesReference("Shared.SharedDesignTimeServices, Shared")]
Это выполняется до AddEntityFrameworkDesignTimeServices, поэтому на данном пути TryAddSingleton тоже сработал бы, ведь пустой операцией становится уже собственный TryAdd EF. Использование AddSingleton везде избавляет от необходимости держать это различие в голове.
Результат
Та же схема, та же команда, но с зарегистрированным DomainPluralizer:
| Таблица | Сущность по умолчанию | DbSet по умолчанию | Своя сущность | Свой DbSet |
|---|---|---|---|---|
Kunde | Kunde | Kundes | Kunde | Kunden |
Bestellung | Bestellung | Bestellungs | Bestellung | Bestellungen |
Gas | Ga | Gas | Gas | Gases |
Canvas | Canva | Canvas | Canvas | Canvases |
Media | Medium | Media | Media | Media |
tbl_person | TblPerson | TblPeople | TblPerson | TblPersons |
Навигация-коллекция на Kunde следует имени DbSet, потому что проходит через тот же вызов Pluralize:
public virtual ICollection<Bestellung> Bestellungen { get; set; } = new List<Bestellung>();
Удаление префикса tbl_ это другой сервис
TblPerson всё ещё на месте, и никакой плюрализатор этого не исправит, потому что к моменту вызова вашего кода префикс уже впечатан в идентификатор-кандидат. Удаление префиксов относится к ICandidateNamingService, который работает на шаге 1. Унаследуйтесь от встроенной реализации и переопределите один метод:
// .NET 11, C# 14. EF Core 11.0.0-rc.1.26425.128.
#pragma warning disable EF1001
using Microsoft.EntityFrameworkCore.Scaffolding.Internal;
using Microsoft.EntityFrameworkCore.Scaffolding.Metadata;
namespace Shared;
public sealed class TablePrefixNamingService : CandidateNamingService
{
private static readonly string[] Prefixes = ["tbl_", "tbl"];
public override string GenerateCandidateIdentifier(DatabaseTable table)
{
var name = table.Name;
foreach (var prefix in Prefixes)
{
if (name.Length > prefix.Length
&& name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
name = name[prefix.Length..];
break;
}
}
return GenerateCandidateIdentifier(name);
}
}
CandidateNamingService живёт в пространстве имён, оканчивающемся на .Internal, и помечен соответствующим образом, поэтому вам нужен #pragma warning disable EF1001 или <NoWarn>$(NoWarn);EF1001</NoWarn>. Это цена точки расширения, и то же самое предупреждение появляется в любой серьёзной настройке scaffolding, включая EF Core Power Tools, которые управляют теми же сервисами из интерфейса.
Зарегистрируйте его рядом с плюрализатором:
public void ConfigureDesignTimeServices(IServiceCollection services)
{
services.AddSingleton<IPluralizer, DomainPluralizer>();
services.AddSingleton<ICandidateNamingService, TablePrefixNamingService>();
}
Теперь шестая строка оказывается там, где нужно. Сгенерированная сущность это Person, DbSet это Persons, а сопоставление по-прежнему указывает на реальную таблицу:
modelBuilder.Entity<Person>(entity =>
{
entity.ToTable("tbl_person");
entity.Property(e => e.PersonId)
.ValueGeneratedNever()
.HasColumnName("person_id");
entity.Property(e => e.FullName).HasColumnName("full_name");
});
Четыре способа молча ничего не сделать
--no-pluralize полностью отключает ваш сервис. Каждое место вызова обёрнуто в if (!_options.NoPluralize), поэтому с этим флагом имена DbSet совпадают с именами таблиц, а ваш Pluralize не вызывается никогда. Проверено: с зарегистрированным DomainPluralizer и переданным --no-pluralize контекст получился с DbSet<Gas> Gas, DbSet<Kunde> Kunde, DbSet<TblPerson> TblPerson.
--use-database-names его не отключает. Этот флаг обходит только ICandidateNamingService; сингуляризатор и плюрализатор по-прежнему работают, теперь уже над сырым идентификатором базы данных. На той же схеме результатом было DbSet<tbl_Person> tbl_Persons, где регистр записи из словаря (Person) заменил сырое person. Если вам нужны сырые имена, потребуются оба флага.
Плюрализатор, схлопывающий имена, за вашей спиной получает уникализацию. CSharpUniqueNamer выполняет while (_usedNames.Contains(name)) name = input + suffix++;, поэтому если две таблицы отображаются в один идентификатор, вы получите Order и Order1, а не ошибку. Имена типов сущностей сравниваются без учёта регистра, имена DbSet с учётом регистра.
Ничто из этого не влияет на подход code-first. IPluralizer используется ровно одним классом во всей design-time сборке, RelationalScaffoldingModelFactory. dotnet ef migrations add его вообще не трогает, а EF Core изначально не плюрализует имена таблиц при генерации схемы из вашей модели. Если вы хотите управлять именами, которые EF Core записывает в базу данных, то это соглашение уровня построения модели, о чём рассказано в статье собственные соглашения об именовании ключей, внешних ключей и индексов.
Короткая история, потому что ответы в интернете устарели
IPluralizer появился в EF Core 2.0 как точка расширения без реализации за ней. Ещё в EF Core 3.1 регистрация всё ещё выглядела как AddSingleton<IPluralizer, NullPluralizer>(), и именно поэтому столько статей той эпохи советуют установить сторонний пакет-плюрализатор просто ради того, чтобы получить Customers из таблицы Customer. EF Core 5.0 сменил регистрацию на HumanizerPluralizer и добавил зависимость от Humanizer.Core 2.8.26, и с тех пор эта точка расширения работает одинаково. Всё написанное до 2020 года о том, что scaffolding вообще не плюрализует, было правдой на момент написания.
Если ваш вызов dotnet ef падает задолго до того, как дело доходит до именования, две обычные причины это design-time DbContext, который невозможно создать и версия инструмента, не совпадающая с пакетами среды выполнения. Конкретно в EF Core 11 в инструментарии также появилась одна команда, которая создаёт и применяет миграцию, а если вы переходите со старой мажорной версии, то сначала стоит просмотреть критические изменения при переходе с EF Core 6 на EF Core 11.
Правило, которое сохраняет всё это поддерживаемым: плюрализатор это словарь с запасным вариантом, а не алгоритм. Каждая добавленная вами запись это имя, о котором кто-то в команде однажды спорил, записанное там, где следующий запуск scaffold его учтёт.
Читайте далее
- Как задать собственные соглашения об именовании первичных ключей, внешних ключей и индексов в миграциях EF Core 11
- Fix: dotnet ef migrations add падает с “Unable to create an object of type DbContext”
- Исправление: MissingMethodException “AbstractionsStrings.ArgumentIsEmpty” после обновления EF Core Tools
- EF Core 11 позволяет создать и применить миграцию одной командой
- Миграция с EF Core 6 на EF Core 11: критические изменения, которые действительно бьют
Источники
- Design-time services, документация EF Core
- Reverse Engineering, документация EF Core
IPluralizer, браузер API .NETRelationalScaffoldingModelFactory.csпо тегуv11.0.0-rc.1.26425.128, dotnet/efcoreDesignTimeServicesBuilder.csпо тегуv11.0.0-rc.1.26425.128, dotnet/efcoreDesignTimeServiceCollectionExtensions.csпо тегуv11.0.0-rc.1.26425.128, dotnet/efcoreCSharpUtilities.csпо тегуv11.0.0-rc.1.26425.128, dotnet/efcore- Reverse Engineering: Enable pluralization/singularization when scaffolding a model from a database, issue 3060 в dotnet/efcore
- Humanizer, библиотека, стоящая за плюрализатором по умолчанию
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.