惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
MongoDB | Blog
MongoDB | Blog
博客园_首页
博客园 - 三生石上(FineUI控件)
博客园 - 聂微东
B
Blog RSS Feed
D
Docker
IT之家
IT之家
大猫的无限游戏
大猫的无限游戏
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
阮一峰的网络日志
阮一峰的网络日志
罗磊的独立博客
Recent Announcements
Recent Announcements
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
A
About on SuperTechFans
The GitHub Blog
The GitHub Blog
G
Google Developers Blog
V
V2EX
量子位
雷峰网
雷峰网
月光博客
月光博客
云风的 BLOG
云风的 BLOG
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
T
Tailwind CSS Blog

博客园 - 东邪独孤

【ASP.NET Core】Cookie 验证中回调用 URL 自动跳转的问题 【ASP.NET Core】在 Windows Forms 中运行 Web 服务器 【EF Core】级联删除行为 【EF Core】继承策略——TPC 【EF Core】继承策略——TPT 【EF Core】继承策略——TPH 【EF Core】使用自定义的值比较器 【EF Core】值转换器 【EF Core】直接更新数据 【EF Core】实体追踪——Entry中记录的数据 【EF Core】实体状态与变更追踪 【EF Core】将一个实体映射到多个表的正确方法 【EF Core】“Code First”方案下以编程方式生成迁移 【EF Core】“DB First”方案下用编程方式生成数据库模型代码 【EF Core】三种方法记录生成的 SQL 语句 【EF Core】“多对多”关系与跳跃导航 【EF Core】FromExpression 方法有什么用? 【EF Core】通过 DbContext 选项扩展框架 【EF Core】框架底层的数据库连接管理 【EF Core】再谈普通实体关系与 Owned 关系的区别 【EF Core】实体类的依赖注入 【EF Core】优化后的模型 【EF Core】使用外部 Model 【EF Core】聊聊“复合”属性 【EF Core】为 DatabaseFacade 扩展“创建”与“删除”数据表功能 【EF Core】带主键实体与无主键实体 【EF Core】框架是如何识别实体类的属性和主键的
【EF Core】未定义实体类的数据库模型
东邪独孤 · 2025-11-16 · via 博客园 - 东邪独孤

不知道大伙伴们有没有这样的想法:如果我不定义实体类,那 EF Core 能建模吗?能正常映射数据库吗?能正常增删改查吗?

虽然一般开发场景很少这么干,但有时候,尤其是数据库中的某些视图,就不太想给它定义实体类。好消息,EF Core 还真支持不定义实体类的。可是,你一定会疑惑了,不定义实体类,那还怎么面向对象呢?不急,咱们一个个去探寻真相。

先看看这个自定义的上下文类。

public class MyDbContext : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlServer("server=...");
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        EntityTypeBuilder ent = modelBuilder.Entity("Student");
        // 先把它标记为无主键,避免报错
        ent.HasNoKey();
    }
}

这里我给数据库模型添加了一个叫 Student 的实体,我可没有定义对应的类。然后我们打印一下模型信息,看看能不能建模。

internal class Program
{
    static void Main(string[] args)
    {
        using var context = new MyDbContext();
        // 打印模型信息
        Console.WriteLine(context.Model.ToDebugString());
    }
}

运行一下,好家伙,你看,还真的能建模了。

Model:
  EntityType: Student (Dictionary<string, object>) CLR Type: Dictionary<string, object> Keyless

注意 CLR Type 后面的信息。这下懂了,当你不给 EF Core 提供实体类时,它会默认使用字典类型,Key 是字符串,Value 是 object 类型。

我们继续验证,既然能建模了,那定义些属性,包括主键。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    EntityTypeBuilder ent = modelBuilder.Entity("Student");
    // 添加属性
    ent.Property<int>("StuID");
    ent.Property<string>("Name").IsRequired(true);
    ent.Property<int>("Age").IsRequired();
    ent.Property<string>("Major").IsRequired(false);
    // 主键
    ent.HasKey("StuID");
}

Property 方法要指定属性的类型,因为没有对应的 CLR 属性,不然 EF 不知道这个属性是什么类型。这个其实就像影子属性。

现在再看看模型的信息。

Model:
  EntityType: Student (Dictionary<string, object>) CLR Type: Dictionary<string, object>
    Properties:
      StuID (no field, int) Indexer Required PK AfterSave:Throw ValueGenerated.OnAdd
      Age (no field, int) Indexer Required
      Major (no field, string) Indexer
      Name (no field, string) Indexer Required
    Keys:
      StuID PK

看来是没问题的。

好,进入下一个验证阶段。我们用这个模型去创建数据库(这里我用的是 SQL Server)。

public class MyDbContext : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlServer("server=(localdb)\\MSSQLLocalDB;database=my_school");
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        ……
    }
}

internal class Program
{
    static void Main(string[] args)
    {
        using var context = new MyDbContext();
        ……
        // 创建数据库
        context.Database.EnsureCreated();
    }
}

运行后,成功创建数据库,包含数据表 Student。

CREATE TABLE [dbo].[Student] (
    [StuID] INT            IDENTITY (1, 1) NOT NULL,
    [Age]   INT            NOT NULL,
    [Major] NVARCHAR (MAX) NULL,
    [Name]  NVARCHAR (MAX) NOT NULL,
    CONSTRAINT [PK_Student] PRIMARY KEY CLUSTERED ([StuID] ASC)
);

默认它使用了 Student 为表名。

咱们的数据库上下文还少了一个 DbSet<> 属性,为了能访问数据,应当定义此属性。不过,这样定义是错误的。

public class MyDbContext : DbContext
{
      ……

    public DbSet<Dictionary<string, object>> Students { get; set; }
}

虽然在运行的时候没有抛出异常,但是,Students 属性在 DbContext 初始化时,DbSet<> 没有被正确设置,即内部的 InternalDbSet 类初始化失败。说白了这个 DbSet 类型的属性你不能正常访问。

什么原因?当我们通过 LINQ 查询时,由于 DbSet 本身就是实现了 IQueryable<> 接口的,因此,EntityQueryable 属性会被 IEnumerable.GetEnumerator() 等方法访问。

image

然后我们再看 EntityQueryable 属性的代码。

private EntityQueryable<TEntity> EntityQueryable
{
    get
    {
        CheckState();

        return NonCapturingLazyInitializer.EnsureInitialized(
            ref field,
            this,
            static internalSet => internalSet.CreateEntityQueryable());
    }
}

这里触发了CheckState 方法。

private void CheckState()
    // ReSharper disable once AssignmentIsFullyDiscarded
    => _ = EntityType;

CheckState 方法又访问了 EntityType 属性。

public override IEntityType EntityType
{
    get
    {
        if (field != null)
        {
            return field;
        }

        field = _entityTypeName != null
            ? _context.Model.FindEntityType(_entityTypeName)
            : _context.Model.FindEntityType(typeof(TEntity));

        if (field == null)
        {
            if (_context.Model.IsShared(typeof(TEntity)))
            {
                throw new InvalidOperationException(CoreStrings.InvalidSetSharedType(typeof(TEntity).ShortDisplayName()));
            }

            ……

        return field;
    }
}

其他地方不用看,重点是发现中间有一段是判断实体的类型是否为共享类型。IsShared 方法是 Model 类中定义的。

public virtual bool IsShared([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] Type type)
    => FindIsSharedConfigurationSource(type) != null
        || Configuration?.GetConfigurationType(type) == TypeConfigurationType.SharedTypeEntityType;


public virtual ConfigurationSource? FindIsSharedConfigurationSource(Type type)
    => _sharedTypes.TryGetValue(type, out var existingTypes) ? existingTypes.ConfigurationSource : null;

_sharedTypes 是 Model 类的字段,从初始化时,它就把 Dictionary<string, object> 设定为共享类型。

    public static readonly Type DefaultPropertyBagType = typeof(Dictionary<string, object>);

    private readonly Dictionary<Type, (ConfigurationSource ConfigurationSource, SortedSet<EntityType> Types)> _sharedTypes =
        new() { { DefaultPropertyBagType, (ConfigurationSource.Explicit, new SortedSet<EntityType>(TypeBaseNameComparer.Instance)) } };

看到没,_sharedTypes 字段从 new 那一刻起,它里面就包含了 Dictionary<string, object> 类型。

共享类型的特点是允许多个实体使用同一个 .NET 类,而字典类型就是默认的共享类型。而使用 DbSet<...> Students { get; set; } 这种格式定义的属性,在 DbContext 类的内部,只用实体类的 Type 作为索引的 Key,没有命名。一旦 Type 是多个实体共享的类型,就破坏了唯一性。这时候必须同时用 Type 和 name 来标识 DbSet 才能做到唯一区分。看看 DbContext 类内部的 DbSet 列表是怎么缓存的。

private Dictionary<(Type Type, string? Name), object>? _sets;

说白了,DbContext 类的内部是用一个字典来缓存 DbSet 的(看,字典类型真是好用,哪儿都能用得上它),其中 Key 是由两个值构成的:实体的类型 + 实体的名字。对于常见的实体类,因为类是唯一的,不共享的,所以,Name 的值可以忽略;而共享类型则不同,多个实体的 Type 是相同的,不用 Name 的话无法区分。所以,对于咱们这个未定义实体类的 Student 实体,只能调用 Set 方法来返回,并且要显式地指定一个名字,名字必须和模型中注册的实体名相同,否则,查询的时候还是找不到映射的。

废话了那么多,正确的属性定义应当是这样的。

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

 咱们把表映射完善一下。

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        EntityTypeBuilder ent = modelBuilder.Entity("Student");
        // 添加属性
        ent.Property<int>("StuID")
                                .HasColumnName("f_stuid");
        ent.Property<string>("Name")
                                .IsRequired(true)
                                .HasMaxLength(15)
                                .HasColumnName("f_name");
        ent.Property<int>("Age")
                                .IsRequired()
                                .HasColumnName("f_age");
        ent.Property<string>("Major")
                                .IsRequired(false)
                                .HasMaxLength(30)
                                .HasColumnName("f_major");
        // 主键
        ent.HasKey("StuID").HasName("PK_stu_id");
        // 表名
        ent.ToTable("tb_students");
    }

然后创建的数据库是这样的。

image

逐渐对味了,这一关可以 pass 了。

接下来咱们还要验证一下,增删改查是否可行。

A、先插入五条记录。

// 插入数据
// Name:姓名;Age:年龄;Major:专业
Dictionary<string, object>[] newRecs =
[
    new()
    {
        ["Name"] = "刘桂圆",
        ["Age"] = 19,
        ["Major"] = "人力资源管理裁员方向"
    },
    new()
    {
        ["Name"] = "方小同",
        ["Age"] = 20,
        ["Major"] = "调酒师"
    },
    new()
    {
        ["Name"] = "程河洛",
        ["Age"] = 20,
        ["Major"] = "兽医"
    },
    new()
    {
        ["Name"] = "史地分",
        ["Age"] = 22,
        ["Major"] = "堪舆学"
    },
    new()
    {
        ["Name"] = "吴胜隆",
        ["Age"] = 18,
        ["Major"] = "行星科学"
    }
];

context.Students.AddRange(newRecs);
// 提交更改
int n = context.SaveChanges();
Console.WriteLine("已更新{0}条数据", n);

结果如下:

image

B、现在看一下数据更新。把 ID=3 的同学的专业改为“土狗工程”。

int n = context.Students
        .Where(stu => (int)stu["StuID"] == 3)
        .ExecuteUpdate(setter => setter.SetProperty(s => s["Major"], "土狗工程"));
Console.WriteLine("更新了{0}条记录", n);

这个写法可能有大伙伴没看懂,老周解释一下。

首先,用 ExecuteUpdate 方法的好处是只生成一条 UPDATE 语句,提高了效率。如果你先查询 ID=3 的数据,然后修改其属性,然后再提交更改。那样 EF 会先生成 SELECT 语句,返回数据后,在内存中修改,再生成 UPDATE 语句,这样不太必要。

ExecuteUpdate 方法生成的 UPDATE 语句,它的 Where 子句是和 LINQ 查询匹配的,由于我们要改 id=3 的记录,所以要先调用 Where 方法,让 EF 记住有筛选条件,然后再调用 ExecuteUpdate 方法生成 SET 子句。

ExecuteUpdate 是扩展方法,这里我调用的以下重载:

public static int ExecuteUpdate<TSource>(this IQueryable<TSource> source, Action<UpdateSettersBuilder<TSource>> setPropertyCalls)

参数是一只委托,委托有个输入参数,类型是 UpdateSettersBuilder<TSource>。UpdateSettersBuilder 类的功能是帮助框架生成更新语句的 SET 子句。即调用它的 SetProperty 方法给实体的属性赋值。为了保持高度的灵活性和可扩展性,SetProperty 方法采用表达树的方式传参。此处,我使用的是以下重载:

public UpdateSettersBuilder<TSource> SetProperty<TProperty>(
    Expression<Func<TSource, TProperty>> propertyExpression,
    TProperty valueExpression
)

根据 C# 语句可以隐式转化为 Expression 的原则,propertyExpression 参数可以简化理解为 Func<TSource, TProperty>,即你要告 EF 我要更新实体的哪个属性,比如,我要更新 Car.Speed 属性,那么就是 c => c.Speed,其中,c 是 Car 实例。不过,这里我们的 Student 实体是没有定义类的,它是个字典,但没关系,我们就告诉框架要更新哪个 Key 的值就好了。也就是 s => s["Major"]。

第二个参数 valueExpression 就是属性的新的值。

所以,综合起来,整个 ExecuteUpdate 方法的调用就是

ExecuteUpdate(
     setter => setter.SetProperty(s => s["Major"], 
     "土狗工程"
));

等等啊,各位,别忙着按【F5】,上面代码还有个问题,如果你直接运行,就会报类型映射失败的错误。问题就出在这个 Lambda 表达式:s => s["Major"]。我们还记得,它的类型是 Dictionary<string, object>,也就是说,s["Major"] 给表达式树处理引擎提供的类型是 object,而 Major 属性其实要的是 string。

怎么解决呢,简单啊,把它强制转换为 string 就完事了。

ExecuteUpdate(
    setter => setter.SetProperty(s => (string)s["Major"], 
    "土狗工程"
));

现在运行代码就不会出错了。生成的 SQL 语句如下:

 UPDATE [t]
      SET [t].[f_major] = @p
      FROM [tb_students] AS [t]
      WHERE [t].[f_stuid] = 3

咱们验证一下,看到底改了没有。

image

嗯,已经改了。

C、删除数据。现在咱们把 ID=3 的数据记录删除。为了提高效率,我们用 ExecuteDelete 方法。

 int n = context.Students
             .Where(stu => (int)stu["StuID"] == 3)
             .ExecuteDelete();
 Console.WriteLine("已删除{0}条记录", n);

ExecuteDelete 方法不需要参数,因为生成 DELTE FROM ... 语句一般只要带个 WHERE 子句作为筛选条件就可以了。所以,要先调用 Where 方法做筛选,然后才调用 ExecuteDelete 方法。不然会把整个表的记录删除。

生成的 SQL 语句如下:

DELETE FROM [t]
      FROM [tb_students] AS [t]
      WHERE [t].[f_stuid] = 3

运行代码后,ID=3 的记录就没了。

image

严重注意:凡是调用 ExecuteXXX 方法处理数据的,不要再调用 SaveChanges 方法了。ExecuteXXX 方法不需要跟踪实体更改,而是直接生成 SQL 发送到数据库执行的。 

D、查询数据。就剩下最后一个操作了。咱们把所有记录查询出来,并输出到控制台。

var stuArr = context.Students.ToArray();
// 打印
var q = from s in stuArr
        select new
        {
            StuID = (int)s["StuID"],
            Name = (string)s["Name"],
            Age = (int)s["Age"],
            Major = (string)s["Major"]
        };
foreach(var x in q)
{
    Console.WriteLine($"{x.Name}({x.StuID})\t{x.Age}岁,{x.Major}专业");
}

context.Students.ToArray 由于调用了 ToArray 扩展方法,所以查询会执行,从而触发翻译为 SQL 语句,并发送到数据库,返回查询结果。生成 SQL 如下:

 SELECT [t].[f_stuid], [t].[f_age], [t].[f_major], [t].[f_name]
      FROM [tb_students] AS [t]

控制台输出结果如下:

刘桂圆(1)     19岁,人力资源管理裁员方向专业
方小同(2)     20岁,调酒师专业
史地分(4)     22岁,堪舆学专业
吴胜隆(5)     18岁,行星科学专业

在查询的时候,你也可以直接用 SQL 语句,例如查询 ID=1 的记录。

int qid = 1;
var stuArr = context
                 .Students
                 .FromSql($"select * from tb_students where f_stuid = {qid}")
                 .ToArray();

建议使用这种格式化的方式来组织 SQL 语句,而不用 Raw SQL 来拼接,因为用格式化字符串构建 SQL 语句默认使用参数化查询,可以避免特殊字符注入攻击,保证安全。

经过咱们一系列验证,表明不定义类的实体也可以正常完成增删改查操作的。只是使用 Dictionary<string, object> 类型在书写查询的时候,Key 的名称容易写错。尤其是项目你做了一半,让后别人接盘,这种情况很容易把 Key 写错,导致各种错误。

因此,不定义实体类的方案可以使用,但不建议全用,可以仅在部分表或视图的映射中使用。

好了,今天咱们就水到这里了。