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.