EF Core脚手架:带有WHERE条件的唯一索引会产生1:1的映射,而不是1:n
我们正在使用.NET 10以及以下的NuGet包:
Microsoft.EntityFrameworkCore 10.0.3
Microsoft.EntityFrameworkCore.Design 10.0.3
Npgsql.EntityFrameworkCore.PostgreSQL 10.0.0
我们有一个简单的PostgreSQL数据库,其中包含2 张表:trgt.target 和 trgt.target_request。一个目标可以有多个目标请求,但同时只有一个处于活动状态(唯一索引 ux_target_request_active_per_target)。
-- 1. create schema
create schema if not exists trgt;
-- 2. create table trgt.target
create table trgt.target
(
id serial primary key,
name varchar(255) not null
);
-- 3. create table trgt.target_request
create table trgt.target_request
(
id serial primary key,
target_id integer not null,
is_active boolean not null,
constraint fk_target_request_target
foreign key (target_id)
references trgt.target(id)
on delete cascade
);
-- 4. ensure only one active request per target
create unique index ux_target_request_active_per_target
on trgt.target_request (target_id)
where is_active = true;
我们采取数据库优先的方法——将数据库表生成为C#模型。我们使用以下命令来脚手架 trgt 架构:
dotnet ef dbcontext scaffold "Host=localhost;Database=UniqueIndexScaffold;Username=postgres;Password=postgres" Npgsql.EntityFrameworkCore.PostgreSQL -c TrgtDbContext -o Models --schema trgt --force --verbose --data-annotations --no-onconfiguring
生成的 TrgtDbContext.cs 的内容如下:
public partial class TrgtDbContext : DbContext
{
public TrgtDbContext(DbContextOptions<TrgtDbContext> options)
: base(options)
{
}
public virtual DbSet<Target> Targets { get; set; }
public virtual DbSet<TargetRequest> TargetRequests { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Target>(entity =>
{
entity.HasKey(e => e.Id).HasName("target_pkey");
});
modelBuilder.Entity<TargetRequest>(entity =>
{
entity.HasKey(e => e.Id).HasName("target_request_pkey");
entity.HasIndex(e => e.TargetId, "ux_target_request_active_per_target")
.IsUnique()
.HasFilter("(is_active = true)");
entity.HasOne(d => d.Target).WithOne(p => p.TargetRequest).HasConstraintName("fk_target_request_target");
});
OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}
生成的 Target.cs 文件的内容如下:
[Table("target", Schema = "trgt")]
public partial class Target
{
[Key]
[Column("id")]
public int Id { get; set; }
[Column("name")]
[StringLength(255)]
public string Name { get; set; } = null!;
[InverseProperty("Target")]
public virtual TargetRequest? TargetRequest { get; set; }
}
我们觉得它包含属性 public virtual TargetRequest? TargetRequest 十分奇怪。尽管我们有一个唯一索引 ux_target_request_active_per_target,它允许每个目标只有一个 活跃的 目标请求,但该目标仍然可能拥有多个 非活跃的 目标请求。
如果我们移除 ux_target_request_active_per_target 的唯一索引并再次对数据库进行脚手架,Target.cs 将包含一个属性 public virtual ICollection<TargetRequest> TargetRequests,这是预期之中的。我们也希望在存在索引 ux_target_request_active_per_target 时,恰好也会出现同样的属性。
这是脚手架中的一个缺陷吗?有没有解决方法?
解决方案
首先,这是一个已知缺陷。EF Core Power Tools的 Reverse Engineer工具可以通过使用 excludedIndexes 选项来忽略该索引从而处理这个问题。这样会生成一个集合,但不会验证有多少项符合筛选条件。
其次,在EF Core中并不存在所谓的“数据库优先”(DB First)模式。脚手架器仅仅做它的名字所述的事——从现有数据库生成(脚手架)类,作为一种方便的方式。文档故意使用 Reverse Engineer 和 scaffolding 这两个术语。你将不得不以某种方式对模型进行修改。对象模型和关系模型差异极大,无法用神奇的方式将它们映射到对方。也并非可取——每个模型都有其自身希望具备的特性。你可以使用自定义代码生成模板来改变代码的创建方式,但在某些阶段,你最终会修改生成的实体和DbContext。
也就是说,将此模式映射到另一种模式没有简单的方法。
- 拥有一个
List<TargetRequest>属性并不能保证只有其中一个会将TargetRequest.IsActive设为真。调用SaveChanges时,你如何确保实体的有效性?你可能需要实现 IValidateObject,但EF Core在保存时并不执行验证。应用程序需要对实体进行验证。 - 何时对其进行验证?是在修改
List<TargetRequest>时,还是只有在保存时?对每次修改进行验证可能会让编辑变得非常困难——你必须始终在激活另一个请求之前先停用当前请求。想象在UI中绑定到一组单选按钮。根据UI事件的顺序,应用可能会抛出异常。 - 如何访问活动请求?也许一个
ActiveRequest=>TargetRequests.Single(t->IsActive);属性就能解决问题,在状态错误时给出提示。
如果你决定生成什么,可以使用 自定义脚手架模板 或EF Core Power Tools来定制生成的代码。
或者你也可以手动修改实体,例如使用 将默认接口方法作为混入 来定义一个可以应用到你只想要的实体上的 IUniqueActive<T> 接口?也许像下面这样:
public interface IHasIsActive
{
bool IsActive {get;set;}
}
public interface IUniqueActive<TItem> where TItem:IHasActive
{
public List<TItem> Request {get;}
public TItem ActiveRequest => Request.Single(it=>it.IsActive);
}
...
public partial class TargetRequest:IHasIsActive
{
public bool IsActive {get;set;}
}
public partial class Target:IUniqueActive<TargetRequest>
{
public List<TargetRequest> Requests {get;set;}
}