Az Entity Framework alapvetően Code First megközelítéssel dolgozik. Ez azt jelenti, hogy a kódunk alapján generálja nekünk a DB sémát. Ennek előnye, hogy nem kell a DB tervezés nagyon mély bugyraiba belemennünk, illetve a kód módosítása után lehetőségünk van úgynevezett migrációk létrehozására. A migrációk a kódhoz igazítják a DB sémát és elvégzik a szükséges transzformációkat és nem kell manuálisan DB frissítő szkripteket, programokat létrehoznunk.
A programjaimban a DB eléréshez kapcsolódó kódokat egy külön szerelvényt szoktam létrehozni, hogy jobban elkülönüljenek. Ezt érdemes követni még kis projektek esetén is, mivel azok előbb-utóbb nagyobbra nőnek.
Ennek a projektnek egy DB vagy Database végződést szoktam adni. Mivel egy könyvkezelő rendszert készítünk, ezért a projekt neve Konyvek.Database lesz. Az alábbi parancsok a szerelvény létrehozására szolgálnak, valamint telepítik a szükséges Entity Framework csomagokat:
dotnet new classlib -n Konyvek.Database
cd Konyvek.Database
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet add package Microsoft.EntityFrameworkCore.Sqlite
A NuGet csomagok esetén, mivel nem került verzió megadásra, a legfrissebb fog települni.
Model létrehozása
Az EF használatbavételének első lépése, hogy modelleznünk kell az adatbázisunk entitásait, amik majd táblák soraivá alakulnak. Emlékeztetőül, az alábbi táblákat szeretnénk modellezni:
Ez alapján az alábbi entitások készíthetők el:
namespace Konyvek.Database.Entities;
internal class Author
{
public int Id { get; set; }
public string FirstName { get; set; } = null!;
public string LastName { get; set; } = null!;
public ICollection<Book>? Books { get; set; } = new HasSet<Book>();
}
internal class Publisher
{
public int Id { get; set; }
public string Name { get; set; } = null!;
public string Address { get; set; } = null!;
public ICollection<Book>? Books { get; set; } = new HasSet<Book>();
}
internal class Book
{
public int ISBN { get; set; };
public string Title { get; set; } = null!;
public int PublishYear { get; set; }
public Author? Author { get; set; }
public Publisher? Publisher { get; set; }
public int AuthorId { get; set; }
public int PublisherId { get; set; }
}
Feltűnhet, hogy az elérés módosítója ezeknek az osztályoknak internal. Ennek az oka az, a DB modellező entitásokat biztonsági okokból nem érdemes a projekten kívül vinni, illetve ritkán van az, hogy egy objektum megfelelően reprezentálná az adatot, amit ki szeretnénk nyerni az adatbázisunkból.
A másik dolog ami érdekes, az a sok = null!; utasítás. Ennek oka az, hogy nem jelöltem nullable típusnak az adatokat, amik a modellben szerepelnek, de konstruktor írásának sincs értelme, mert ezek az adatok úgyis majd a DB-ből lesznek feltöltve.
Észrevehető továbbá még az is, hogy az entitások egymásra hivatkoznak. Például a szerző tartalmaz egy könyvek listáját és a kiadó is, míg a könyv egy kiadóra és egy szerzőre hivatkozik. Ezek az úgynevezett navigation propertijeink, ami alapján majd navigálni tudunk az adatbázisban a LINQ segítségével, illetve ezek írják le a kapcsolatot is. A navigation property-k mellett szokás felvenni a külső kulcsokat is, mint ahogy azt láthatjuk a Book esetén. Ennek oka az, hogy így ha létrehozunk egy új Book entitást, akkor elég csak egy meglévő szerző és kiadó id-t megadnunk a létrehozáshoz, nincs szükség a teljes objektumra.
A modellben feltűnhetett, hogy a navigation property-k nem rendelkeznek a = null!; kitétellel és nullable megjelölést kaptak. Ennak az oka az, hogy ha például a könyvekből kérdezünk le, akkor join alkalmazása nélkül (később lesz róla szó) csak a könyvek táblából előállítható Book objektumot fogjuk megkapni. Ha ezen is alkalmaztuk volna a = null!; kitételt, akkor futási időben könnyen szaladhatnánk NullReferenceException-re.
Ugyanígy fontos megjegyezni, hogy a kiadó és a szerző esetén alkalmazott navigaton property-k kollekciók. Kollekciók esetén az ajánlás az, hogy az ICollection<T> interfész segítségével definiáljuk a kollekciót konkrét implementációhoz kötés helyett és inicializáljuk az értékét egy új HashSet<T> típusú objektumra.
DB Context
Az Entity Framework fő osztálya a DBContext. Ebből kell egy osztályt örököltetnünk, ami majd az adatbázisunkat reprezentálja:
using Konyvek.Database.Entities;
using Microsoft.EntityFrameworkCore;
namespace Konyvek.Database;
internal sealed class KonyvekContext : DbContext
{
public DbSet<Publisher> Publishers { get; set; }
public DbSet<Author> Authors { get; set; }
public DbSet<Book> Books { get; set; }
public string DbFile
=> Path.Combine(AppContext.BaseDirectory, "database.sqlite");
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlite($"Data Source={DbFile}");
}
}
Az osztályban DbSet<T> típusú kollekciók reprezentálják a táblákat. Az OnConfiguring metódus felülírásával konfigurálhatjuk az adatbázis kapcsolatunkat. Jelen esetben mivel SQLite-ot alkalmazunk, ezért egy fájl nevet kell megadnunk. Ha SQL szerverhez kapcsolódnánk, akkor szerver IP címre és egy tanúsítványra és/vagy felhasználónévre és jelszóra lenne szükségünk, amivel kapcsolódni tudnánk.
Konfiguráció
Ezt követően a DBContext segítségével már tudnánk is DB műveletek végezni. De ne siessünk ennyire. Mi van akkor, ha például indexelni szeretnénk a szerző nevét a gyorsabb keresés érdekében, vagy például ki szeretnénk kényszeríteni bizonyos mezők megadását létrehozáskor?
Ebben az esetben annotálhatjuk megfelelő attribútumokkal a modellünket:
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
namespace Konyvek.Database.Entities;
[Index(nameof(FirstName), nameof(LastName), IsUnique = false)]
internal class Author
{
[Key]
public int Id { get; set; }
[Required]
public string FirstName { get; set; } = null!;
[Required]
public string LastName { get; set; } = null!;
public ICollection<Book>? Books { get; set; } = new HasSet<Book>();
}
[Index(nameof(Name), IsUnique = false)]
internal class Publisher
{
[Key]
public int Id { get; set; }
[Required]
public string Name { get; set; } = null!;
[Required]
public string Address { get; set; } = null!;
public ICollection<Book>? Books { get; set; } = new HasSet<Book>();
}
[Index(nameof(Title), IsUnique = false)]
internal class Book
{
[Key]
public int ISBN { get; set; };
[Required]
public string Title { get; set; } = null!;
[Required]
public int PublishYear { get; set; }
public Author? Author { get; set; }
public Publisher? Publisher { get; set; }
public int AuthorId { get; set; }
public int PublisherId { get; set; }
}
A megfelelő annotációkkal ellátva már úgy fog működni a DB modellünk, ahogy szeretnénk, de ennek ára van, mégpedig az, hogy így innentől kezdve a modell nem csak a modellért felelős, hanem a tárolási sémájáért is, ami valahol sérti a Single responsibility elvet. Továbbá az attribútumok miatt az EntityFrameworkCore csomag függősége lesz a modelleket tartalmazó projektnek.
Egy egy jól rétegelt architektúrában nem a legszerencsésebb megközelítés. Jelen esetben ugyan az a projekt tartalmazza az entitásokat, mint a DB context-et, de előfordulhat, hogy egy külön projekt ez a programunkban és ráadásul más rétegben.
Ebben az esetben nem opció a modellek számára a EntityFrameworkCore függőség bevezetés, illetve a Single responsibility miatt is célszerűbb, ha ezek elkülönülnek.
Ilyenkor a DBContext osztályunkon a OnModelCreating metódus felülírásával lehetőségünk van konfigurálni az entitásokat:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
//konfigurálás
}
Ezt a paraméterként kapott modelBuilder megfelelő metódusaival tudjuk megtenni, vagy külön készíthetünk konfigurációs osztályokat, amelyek ezt megteszik számunkra. Ebben az esetben a konfiguráló osztályainknak az IEntityTypeConfiguration<T> interfészt kell implementálniuk. Ez az interfész egyetlen egy Configure(EntityTypeBuilder<T> builder) metódust tartalmaz, amit implementálnunk kell.
Itt is a kapott builder osztályon keresztül tudjuk elvégezni a konfigurációt. A példa adatbázisunk konfigurációja így fog kinézni:
using Konyvek.Database.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Konyvek.Database.EntityConfigurations;
internal sealed class PublisherConfig : IEntityTypeConfiguration<Publisher>
{
public void Configure(EntityTypeBuilder<Publisher> builder)
{
builder.HasKey(publisher => publisher.Id);
builder.Property(publisher => publisher.Name).IsRequired();
builder.Property(publisher => publisher.Address).IsRequired();
builder.HasIndex(publisher => publisher.Name).IsUnique(false);
}
}
internal sealed class AuthorConfig : IEntityTypeConfiguration<Author>
{
public void Configure(EntityTypeBuilder<Author> builder)
{
builder.HasKey(author => author.Id);
builder.Property(author => author.FirstName).IsRequired();
builder.Property(author => author.LastName).IsRequired();
builder.HasIndex(author => author.FirstName).IsUnique(false);
builder.HasIndex(author => author.LastName).IsUnique(false);
}
}
internal sealed class BookConfig : IEntityTypeConfiguration<Book>
{
public void Configure(EntityTypeBuilder<Book> builder)
{
builder.HasKey(book => book.ISBN);
builder.Property(book => book.Title).IsRequired();
builder.Property(book => book.PublishYear).IsRequired();
builder.HasIndex(book => book.Title).IsUnique(false);
builder
.HasOne(book => book.Author)
.WithMany(author => author.Books)
.HasForeignKey(book => book.AuthorId);
builder
.HasOne(book => book.Author)
.WithMany(author => author.Books);
builder
.HasOne(book => book.Publisher)
.WithMany(publisher => publisher.Books);
builder
.HasOne(book => book.Publisher)
.WithMany(publisher => publisher.Books)
.HasForeignKey(book => book.PublisherId);
}
}
Ezt a szintaxist nevezi az Entity Framework dokumentációja Fluent konfigurációnak. Ez jóval több személyre szabási lehetőséget biztosít, mint az attribútumos annotáció. Itt a kapcsolatokat is tudjuk definiálni.
A kapcsolatokat egyébként a jelenlegi modell esetén nem is lett volna muszáj definiálni, mivel a modell tulajdonság elnevezéseiből (többes szám vs. egyes szám) ki tudja következtetni az Entity Framework a kapcsolatok típusát.
A kész konfigurációs osztályokat vagy manuálisan hívjuk be a OnModelCreating metódusban, vagy az összes a szerelvényben találhatót betölthetjük a ApplyConfigurationsFromAssembly metódussal. Ennek paraméterként egy szerelvényt kell megadnunk. Mivel jelen esetben ugyan abban a szerelvényben találhatóak az entitások, mint a KonyvekContext osztályom, ezért ezt felhasználva töltettem be a konfigurációkat.
A külön konfigurációs osztályok előnye, hogy ezek segítéségével elérhető a teljes Entity Framework funkcionalitás. Ugyan ez attribútumokról azok nyelvi korlátai miatt azonban nem mondható el.
Konfigurációs metódusok
A kódban a tulajdonságok után az IsRequired() kikényszeríti, hogy ki kell tölteni az adott mezőt. Ha ez nem történik meg, akkor kivételt fogunk kapni. Számos hasonló konfigurációs metódust biztosít az Entity Framework. A teljesség igénye nélkül egy pár:
-
HasColumnName(string? name)Megadja a mező nevét a táblában. Ha nem használjuk, akkor a modellben meghatározott tulajdonságnév lesz használva.
-
HasComment(string? comment)A mező leírását adja meg. Hasznos, ha a sémát dokumentálni szeretnénk az adatbázisban.
-
HasColumnOrder(int? order)Alapértelmezetten a tábla létrehozásakor az oszlop sorrend a következő lesz: az elsődleges kulcs oszlopai, a modell tulajdonságai, majd a modell ősének tulajdonságai. Ha ettől eltérő oszlop rendezést szeretnénk kialakítani a modellt leíró táblában, akkor ezzel a metódussal ezt felülbírálhatjuk.
-
IsRequired()Az adatbázis mező kitöltése kötelező lesz.
-
HasDefaultValue(object value)Ha a mező értékét nem adták meg, akkor meghatározza az alapértelmezett értéket.
-
HasMaxLength(int maxLength)Szöveges mezők esetén hasznos, konfigurálja a szöveg mező maximális megengedett hosszát. Megjegyzés: ha a felhasználótól közvetlenül érkező szöveget tárolunk az adatbázisban, akkor a
HasMaxLength()segítségével mindig konfiguráljunk egy észszerű maximális hosszúságot a szövegnek. -
HasPrecision(int precision, int scale)Lebegőpontos mezők esetén és idő mezőknél hasznos. Konfigurálja a mező pontosságát és a skálát. A pontosság a számjegyek száma. A skála a számban lévő tizedesvesszőtől jobbra lévő számjegyek száma. Például az 123,45 szám pontossága 5, a skála pedig 2. Ezt érdemes konfigurálni lebegőpontos számok esetén, mivel enélkül a beállítás nélkül az adatbázis-kezelő rendszerünk alapértelmezett beállítását kapjuk, ami kerekítési hibákhoz vezethet.
-
IsFixedLength(bool fixedLength = false)Szöveg mezők esetén hasznos. Fix hosszúságúra lehet vele állítani a szöveget.
-
IsUnicode(bool unicode = true)Szöveg mezők esetén használható. Meghatározza, hogy a szöveg Unicode karakterekből áll-e vagy sem. Erre azért van szükség, mert egyes relációs adatbázisokban különböző típusok léteznek a Unicode és a nem Unicode szöveges adatok megjelenítésére. Ha nem állítjuk be, akkor a szövegünk Unicode szövegként fog tárolódni.
-
UseCollation(string? collation)Beállítja, hogy milyen karakterillesztési módot használjon a mező. Ez rendezésnél és keresésnél tud fontos lenni szöveges mezők esetén. A karakterkódolás és az ABC sorrendje között az angol nyelv kivételével nincsen kapcsolat. Ezért ha sorba rendezünk egy szöveges mező alapján, akkor a karakterillesztés meghatározása nélkül szinte biztos, hogy nem a mező nyelvének megfelelő ABC sorrendet kapunk. Kereséskor is érdekes lehet ez, hogy különbséget tegyen-e a rendszer az ékezetes és ékezet nélküli megadásban. Például a megfelelő illesztés használatával elérhető, hogy a DB-ben tárolt
Óbudaés a keresőfeltételben megadottobudaazonosnak számítson.A metódusnak átadandó illesztés azért szöveges típusú, mert adatbázis megoldásonként itt is eltérés lehet a támogatottak között.
-
HasComputedColumnSql(string? sql)A mező értéke számítás alapján jön létre. A paraméterben megadhatjuk az SQL kifejezést, ami alapján a mező értéke előáll.
Adatbázis létrehozása és migrálás
Már majdnem a célegyenesben vagyunk. A korábban létrehozott DB context szépséghibája, hogy ha nem létezik a DB fájl, akkor nem is jön létre és cserébe kapunk egy kivételt. Ezt elkerülendő a konstruktorban hívhatunk egy Database.EnsureCreated() metódust, ami létrehozza nekünk üresen azt.
Ez a megoldás akkor jó, ha tuti biztosak vagyunk benne, hogy az adatbázist és a benne tárolt adatokat nem kell megtartanunk, ha változtatunk valamit a modellek sémáján.
Abban az esetben, ha meg szeretnénk tartani az adatokat, akkor migrációra van szükségünk. A migráció úgy működik, hogy létrehoz egy táblát ami a DB verzióját tárolja és az alapján a szükséges modell változtatásokat elvégzi.
Migráció generálásához az Entity Framework parancssori programjára lesz szükségünk és az alábbi parancsot kell kiadnunk a DB Context osztályunkat tartalmazó projekt könyvtárában:
dotnet ef migrations add InitialCreate
Az InitialCreate lesz a kiinduló sémánk a kódban. A DB ezen parancs létrehozása után a következő parancs segítségével inicializálható/frissíthető:
dotnet ef database update
A migráció létrehozásához és a DB frissítéséhez a kódunknak forduló állapotban kell lennie. Ha ezek után bármikor módosítanánk az entitásainkat, akkor egy új migrációs lépést hasonlóan tudunk felvenni: dotnet ef migrations add [Migráció neve]
Ezt követően szintén frissíteni kell az adatbázist a dotnet ef database update parancs kiadásával.
Ez a manuális DB séma frissítés automatizálható. Ha a DB context osztályunk konstruktorában elhelyezzük a Database.Migrate() hívást, akkor a Context létrejöttekor a szükséges migrációk automatikusan alkalmazásra kerülnek.
Megjegyzés: Ha korábban a DB-t a Database.EnsureCreated() segítségével hoztuk létre, akkor a migrációk nem fognak működni, mivel nem jött létre a speciális migrációs lépések kezeléséhez szükséges tábla.