博客 / 詳情

返回

【EF Core】未定義實體類的數據庫模型

不知道大夥伴們有沒有這樣的想法:如果我不定義實體類,那 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 寫錯,導致各種錯誤。

因此,不定義實體類的方案可以使用,但不建議全用,可以僅在部分表或視圖的映射中使用。

好了,今天咱們就水到這裏了。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.