Wprowadzenie
W ubiegłym tygodniu pokazałem Ci, w jaki sposób migrować schemat bazy w Entity Framework (zachęcam do przeczytania najpierw tamtego wpisu). W tym wpisie pokażę Ci inną bibliotekę, której możesz użyć do migracji schematu bazy, w momencie gdy z jakiegoś powodu nie możesz użyć Entity Framework. Biblioteka nazywa się Fluent Migrator (https://fluentmigrator.github.io/) i w swoim działaniu jest bardzo podobna do mechanizmu migracji z Entity Framework. Największą różnicą miedzy tymi narzędziami jest to, że w Fluent Migratorze musimy sami napisać całą migrację – a nie tak, jak w Entity Framework, gdzie migracja jest generowana na podstawie zmian w modelu.
Fluent Migrator
Migracje w Fluent Migratorze są zorganizowane w podobny sposób jak w Entity Framework – czyli dodajemy nową klasę dla każdej migracji. Klasa migracji musi dziedziczyć po klasie Migration i implementować dwie metody: Up oraz Down. Pierwsza metoda podnosi schemat bazy danych, druga natomiast służy do cofania zmian.
Fluent Migrator udostępnia wiele metod, za pomocą których możemy definiować schemat bazy danych. Nie musimy wszystkiego pisać w sqlu i korzystamy z dostarczonego fluent api (stąd nazwa biblioteki). Plusem takiego podejścia jest to, że Fluent Migrator generuje odpowiedniego sqla dla każdego silnika baz danych, jaki wspiera. Jak zobaczysz później – trochę tego jest.
Migracja
Najlepiej od razu zobaczyć pierwszą migrację w akcji. Będzie to ten sam przykład, co w poprzednim wpisie. Poniżej migracja, która dodaje tabelę Products z trzema kolumnami:
[Migration(201810030605)] | |
public class AddProduct : Migration | |
{ | |
public override void Up() | |
{ | |
Create.Table("Products") | |
.WithColumn("Id").AsInt32().NotNullable().PrimaryKey().Identity() | |
.WithColumn("Name").AsString() | |
.WithColumn("Category").AsString(); | |
Insert.IntoTable("Products").Row(new {Name = "Product 1.1", Category = "Category 1" }); | |
Insert.IntoTable("Products").Row(new {Name = "Product 1.2", Category = "Category 1" }); | |
Insert.IntoTable("Products").Row(new {Name = "Product 1.3", Category = "Category 1" }); | |
Insert.IntoTable("Products").Row(new {Name = "Product 2.1", Category = "Category 2" }); | |
Insert.IntoTable("Products").Row(new {Name = "Product 2.2", Category = "Category 2" }); | |
} | |
public override void Down() | |
{ | |
Delete.Table("Products"); | |
} | |
} |
Na pierwszy rzut oka widać, że powyższa migracja jest podobna do migracji wygenerowanych przez Entity Framework. Również korzystamy z dostępnych metod, za pomocą których określamy zmiany w strukturze bazy danych. Na podstawie tych metod Fluent Migrator wygeneruje później odpowiedniego SQLa, który zostanie wykonany na bazie danych.
W migracji warto zwrócić uwagę na dwie rzeczy. Po pierwsze widać, że klasa dziedziczy po klasie Migration i nadpisuje dwie metody Up oraz Down.
Po drugie migracja jest udekorowana atrybutem Migration z dziwnym numerem. Numer ten określa wersję migracji. W przypadku Fluent Migratora jest to liczba typu long. Biblioteka na podstawie tej liczby określa później między innymi kolejność wykonywania migracji. Osobiście w określeniu wersji migracji stosuję datę dodania migracji – rok-miesiąc-dzień-godzina-minuta (oczywiście bez łącznika). Dzięki temu dużo łatwiej pracuje mi się na migracjach w różnych branchach, niż gdybym stosował kolejne liczby naturalne. Z drugiej strony przedział minutowy jest na tyle mały, że szansa dodania dwóch migracji z tą samą wersją przez dwie różne osoby jest bardzo mała.
Wersję migracji dodaję również do nazwy plików z migracjami, podobnie jak to robi Entity Framework. Dzięki temu łatwiej później widać kolejność migracji w Solution Explorer:
Uruchomienie migracji
Podobnie jak w przypadku migracji w Entity Framework, również w przypadku Fluent Migrator do uruchamiania migracji wykorzystuje dedykowaną aplikację konsolową. W tym przypadku również klasy migracji znajdują się w projekcie migratora (co widać na zrzucie ekranu powyżej).
Aby utworzyć migratora, należy w pierwszej kolejności zainstalować pakiet FluentMigrator.Runner. Zainstaluje on wszystkie inne niezbędne pakiety. Pakiet ten ma również jeden minus. Instaluje on runnery dla wszystkich wspieranych przez Fluent Migrator baz danych. Ale z racji, że jest to oddzielna aplikacja, nie jest to dla mnie duży problem.
Testowy projekt korzysta z CommandLineParser do parsowania parametrów aplikacji oraz nloga do obsług logów.
Tym razem obiekt parametrów rozszerzyłem o dodatkowy parametr (v-version), który umożliwia przekazanie numeru wersji, do której trzeba cofnąć schemat bazy danych:
public class Options | |
{ | |
[Option('c', "connectionString", Required = true, HelpText = "The connection string to database that needs to be updated.")] | |
public string ConnectionString { get; set; } | |
[Option('v', "version", Required = false, HelpText = "The database version.")] | |
public long? Version { get; set; } | |
} |
Natomiast sama klasa Program z uruchomieniem migracji wygląda tak:
class Program | |
{ | |
static void Main(string[] args) | |
{ | |
var result = Parser.Default.ParseArguments<Options>(args); | |
result | |
.WithParsed(r => Migrate(r)); | |
} | |
private static void Migrate(Options options) | |
{ | |
var serviceProvider = CreateServices(options.ConnectionString); | |
using (var scope = serviceProvider.CreateScope()) | |
{ | |
UpdateDatabase(scope.ServiceProvider, options); | |
} | |
} | |
private static IServiceProvider CreateServices(string connectionString) | |
{ | |
return new ServiceCollection() | |
.AddFluentMigratorCore() | |
.ConfigureRunner(rb => rb | |
.AddSqlServer2016() | |
.WithGlobalConnectionString(connectionString) | |
.ScanIn(typeof(Program).Assembly).For.Migrations()) | |
.AddLogging(lb => lb.AddFluentMigratorConsole().AddNLog()) | |
.BuildServiceProvider(true); | |
} | |
private static void UpdateDatabase(IServiceProvider serviceProvider, Options options) | |
{ | |
var runner = serviceProvider.GetRequiredService<IMigrationRunner>(); | |
if (options.Version.HasValue) | |
{ | |
runner.MigrateDown(options.Version.Value); | |
} | |
else | |
{ | |
runner.MigrateUp(); | |
} | |
} | |
} |
Metoda CreateServices jest odpowiedzialna za skonfigurowanie runnera migracji. W niej określamy między innymi to, dla jakiego silnika mają zostać wygenerowane sqle, jakiego użyć connection stringa, czy gdzie znajdują się migracje.
Metoda UpdateDatabase wykonuje natomiast aktualizacje lub cofnięcie schematu bazy danych – w zależności od parametru version przekazanego do aplikacji.
Jak widać, kod nie jest jakoś bardzo mocno skomplikowany. Można go rozbudować o jakieś dodatkowe funkcjonalności, których potrzebowalibyśmy w systemie – na przykład dodawanie testowych danych.
Przykłady uruchomienia aplikacji z poziomu wiersza poleceń:
//aktualizacja bazy do najnowszej struktury | |
FluentMigratorExample.Migrator.exe -c Server=.\sqlexpress;Database=FluentMigratorExample;Trusted_Connection=True; | |
//cofnięcie bazy danych do wersji 201810030605 - same tabela Products | |
FluentMigratorExample.Migrator.exe -c Server=.\sqlexpress;Database=FluentMigratorExample;Trusted_Connection=True; -v 201810030605 |
Druga migracja
Jak widać na powyższym zrzucie ekranu, testowy przykład posiada dwie migracje. Podobnie jak w poprzednim wpisie druga migracja jest odpowiedzialna za utworzenie dedykowanej tabeli dla kategorii, przekopiowanie danych z kolumny Category z tabeli Products oraz ustawienie ID kategorii (kolumna CategoryId) w tabeli z produktami. Sama migracja wygląda tak:
[Migration(201810030717)] | |
public class AddCategory : Migration | |
{ | |
public override void Up() | |
{ | |
Create.Table("Categories") | |
.WithColumn("Id").AsInt32().NotNullable().PrimaryKey().Identity() | |
.WithColumn("Name").AsString(); | |
Execute.Sql("INSERT INTO dbo.Categories SELECT DISTINCT Category FROM dbo.Products;"); | |
Alter.Table("Products") | |
.AddColumn("CategoryId") | |
.AsInt32() | |
.Nullable(); | |
Execute.Sql("UPDATE p SET p.CategoryId = (SELECT c.Id FROM dbo.Categories c WHERE c.Name = p.Category) FROM dbo.Products p;"); | |
Alter.Column("CategoryId") | |
.OnTable("Products") | |
.AsInt32() | |
.NotNullable() | |
.ForeignKey("Categories", "Id") | |
.Indexed(); | |
Delete.Column("Category") | |
.FromTable("Products"); | |
} | |
public override void Down() | |
{ | |
Alter.Table("Products") | |
.AddColumn("Category") | |
.AsString() | |
.Nullable(); | |
Execute.Sql("UPDATE p SET p.Category = (SELECT c.Name FROM dbo.Categories c WHERE c.Id = p.CategoryId) FROM dbo.Products p;"); | |
Alter.Column("Category") | |
.OnTable("Products") | |
.AsString() | |
.NotNullable(); | |
Delete.ForeignKey() | |
.FromTable("Products") | |
.ForeignColumn("CategoryId") | |
.ToTable("Categories") | |
.PrimaryColumn("Id"); | |
Delete.Index() | |
.OnTable("Products") | |
.OnColumn("CategoryId"); | |
Delete.Column("CategoryId") | |
.FromTable("Products"); | |
Delete.Table("Categories"); | |
} | |
} |
Dalej większość kodu migracji to korzystanie z dostępnego API. Jedynie samo skopiowanie danych jest zrealizowane przez użycie czystego SQL-a.
Wygenerowany SQL
Warto przejrzeć jeszcze, jaki sql zostanie wygenerowany przez Fluent Migratora. Tutaj wspomnę o kolejnej różnicy między Entity Framework a Fluent Migratorem. Entity Framework jest w stanie utworzyć nam bazę danych, w przypadku gdy jej nie ma. Natomiast Fluent Migrator zakłada, że jest już ona utworzona. Dlatego przed uruchomieniem testowej aplikacji upewnij się, czy testowa baza istnieje.
Na listingu poniżej znajduje się log z działania aplikacji, która została uruchomiona na pustej (dopiero co utworzonej) bazie danych:
2018-10-08 05:28:59.7465 INFO Using connection string Server=.\sqlexpress;Database=FluentMigratorExample;Trusted_Connection=True; | |
2018-10-08 05:29:00.0251 INFO VersionMigration migrating | |
2018-10-08 05:29:00.0680 INFO Beginning Transaction | |
2018-10-08 05:29:00.0861 INFO BEGIN TRANSACTION | |
2018-10-08 05:29:00.1290 INFO CreateTable VersionInfo | |
2018-10-08 05:29:00.1540 INFO CREATE TABLE [dbo].[VersionInfo] ([Version] BIGINT NOT NULL) | |
2018-10-08 05:29:00.2182 INFO => 0,0758923s | |
2018-10-08 05:29:00.2182 INFO Committing Transaction | |
2018-10-08 05:29:00.2502 INFO COMMIT TRANSACTION | |
2018-10-08 05:29:00.2502 INFO VersionMigration migrated | |
2018-10-08 05:29:00.2690 INFO => 0,1227984s | |
2018-10-08 05:29:00.3641 INFO VersionUniqueMigration migrating | |
2018-10-08 05:29:00.3740 INFO Beginning Transaction | |
2018-10-08 05:29:00.3740 INFO BEGIN TRANSACTION | |
2018-10-08 05:29:00.4100 INFO CreateIndex VersionInfo (Version) | |
2018-10-08 05:29:00.4771 INFO CREATE UNIQUE CLUSTERED INDEX [UC_Version] ON [dbo].[VersionInfo] ([Version] ASC) | |
2018-10-08 05:29:00.4941 INFO => 0,0741045s | |
2018-10-08 05:29:00.5030 INFO AlterTable VersionInfo | |
2018-10-08 05:29:00.5161 INFO | |
2018-10-08 05:29:00.5161 INFO => 0,0110072s | |
2018-10-08 05:29:00.5371 INFO CreateColumn VersionInfo AppliedOn DateTime | |
2018-10-08 05:29:00.5461 INFO ALTER TABLE [dbo].[VersionInfo] ADD [AppliedOn] DATETIME | |
2018-10-08 05:29:00.5461 INFO => 0,0149699s | |
2018-10-08 05:29:00.5690 INFO Committing Transaction | |
2018-10-08 05:29:00.5830 INFO COMMIT TRANSACTION | |
2018-10-08 05:29:00.5930 INFO VersionUniqueMigration migrated | |
2018-10-08 05:29:00.5930 INFO => 0,0479151s | |
2018-10-08 05:29:00.6380 INFO VersionDescriptionMigration migrating | |
2018-10-08 05:29:00.6490 INFO Beginning Transaction | |
2018-10-08 05:29:00.6600 INFO BEGIN TRANSACTION | |
2018-10-08 05:29:00.6700 INFO AlterTable VersionInfo | |
2018-10-08 05:29:00.6910 INFO | |
2018-10-08 05:29:00.7081 INFO => 0,0170768s | |
2018-10-08 05:29:00.7260 INFO CreateColumn VersionInfo Description String | |
2018-10-08 05:29:00.7410 INFO ALTER TABLE [dbo].[VersionInfo] ADD [Description] NVARCHAR(1024) | |
2018-10-08 05:29:00.7561 INFO => 0,0194427s | |
2018-10-08 05:29:00.7651 INFO Committing Transaction | |
2018-10-08 05:29:00.7651 INFO COMMIT TRANSACTION | |
2018-10-08 05:29:00.7830 INFO VersionDescriptionMigration migrated | |
2018-10-08 05:29:00.7973 INFO => 0,0453261s | |
2018-10-08 05:29:00.7973 INFO 201810030605: AddProduct migrating | |
2018-10-08 05:29:00.8230 INFO Beginning Transaction | |
2018-10-08 05:29:00.8350 INFO BEGIN TRANSACTION | |
2018-10-08 05:29:00.8550 INFO CreateTable Products | |
2018-10-08 05:29:00.8699 INFO CREATE TABLE [dbo].[Products] ([Id] INT NOT NULL IDENTITY(1,1), [Name] NVARCHAR(255) NOT NULL, [Category] NVARCHAR(255) NOT NULL, CONSTRAINT [PK_Products] PRIMARY KEY ([Id])) | |
2018-10-08 05:29:00.8888 INFO => 0,0218413s | |
2018-10-08 05:29:00.8888 INFO INSERT INTO [dbo].[Products] ([Name], [Category]) VALUES (N'Product 1.1', N'Category 1') | |
2018-10-08 05:29:00.9161 INFO INSERT INTO [dbo].[Products] ([Name], [Category]) VALUES (N'Product 1.2', N'Category 1') | |
2018-10-08 05:29:00.9270 INFO INSERT INTO [dbo].[Products] ([Name], [Category]) VALUES (N'Product 1.3', N'Category 1') | |
2018-10-08 05:29:00.9270 INFO INSERT INTO [dbo].[Products] ([Name], [Category]) VALUES (N'Product 2.1', N'Category 2') | |
2018-10-08 05:29:00.9450 INFO INSERT INTO [dbo].[Products] ([Name], [Category]) VALUES (N'Product 2.2', N'Category 2') | |
2018-10-08 05:29:00.9580 INFO -> 5 Insert operations completed in 00:00:00.0580001 taking an average of 00:00:00.0116000 | |
2018-10-08 05:29:00.9678 INFO INSERT INTO [dbo].[VersionInfo] ([Version], [AppliedOn], [Description]) VALUES (201810030605, '2018-10-08T03:29:00', N'AddProduct') | |
2018-10-08 05:29:00.9874 INFO Committing Transaction | |
2018-10-08 05:29:01.0000 INFO COMMIT TRANSACTION | |
2018-10-08 05:29:01.0000 INFO 201810030605: AddProduct migrated | |
2018-10-08 05:29:01.0230 INFO => 0,063068s | |
2018-10-08 05:29:01.0490 INFO 201810030717: AddCategory migrating | |
2018-10-08 05:29:01.0682 INFO Beginning Transaction | |
2018-10-08 05:29:01.0797 INFO BEGIN TRANSACTION | |
2018-10-08 05:29:01.1142 INFO CreateTable Categories | |
2018-10-08 05:29:01.1260 INFO CREATE TABLE [dbo].[Categories] ([Id] INT NOT NULL IDENTITY(1,1), [Name] NVARCHAR(255) NOT NULL, CONSTRAINT [PK_Categories] PRIMARY KEY ([Id])) | |
2018-10-08 05:29:01.1467 INFO => 0,0206976s | |
2018-10-08 05:29:01.1611 INFO ExecuteSqlStatement INSERT INTO dbo.Categories SELECT DISTINCT Category FROM dbo.Products; | |
2018-10-08 05:29:01.1717 INFO INSERT INTO dbo.Categories SELECT DISTINCT Category FROM dbo.Products; | |
2018-10-08 05:29:01.1900 INFO => 0,0199321s | |
2018-10-08 05:29:01.2050 INFO AlterTable Products | |
2018-10-08 05:29:01.2050 INFO | |
2018-10-08 05:29:01.2260 INFO => 0,0109935s | |
2018-10-08 05:29:01.2400 INFO CreateColumn Products CategoryId Int32 | |
2018-10-08 05:29:01.2500 INFO ALTER TABLE [dbo].[Products] ADD [CategoryId] INT | |
2018-10-08 05:29:01.2640 INFO => 0,014039s | |
2018-10-08 05:29:01.2640 INFO ExecuteSqlStatement UPDATE p SET p.CategoryId = (SELECT c.Id FROM dbo.Categories c WHERE c.Name = p.Category) FROM dbo.Products p; | |
2018-10-08 05:29:01.2910 INFO UPDATE p SET p.CategoryId = (SELECT c.Id FROM dbo.Categories c WHERE c.Name = p.Category) FROM dbo.Products p; | |
2018-10-08 05:29:01.3050 INFO => 0,0140096s | |
2018-10-08 05:29:01.3180 INFO AlterColumn Products CategoryId Int32 | |
2018-10-08 05:29:01.3330 INFO ALTER TABLE [dbo].[Products] ALTER COLUMN [CategoryId] INT NOT NULL | |
2018-10-08 05:29:01.3456 INFO => 0,0151768s | |
2018-10-08 05:29:01.3583 INFO CreateForeignKey FK_Products_CategoryId_Categories_Id Products(CategoryId) Categories(Id) | |
2018-10-08 05:29:01.3730 INFO ALTER TABLE [dbo].[Products] ADD CONSTRAINT [FK_Products_CategoryId_Categories_Id] FOREIGN KEY ([CategoryId]) REFERENCES [dbo].[Categories] ([Id]) | |
2018-10-08 05:29:01.3966 INFO => 0,0264217s | |
2018-10-08 05:29:01.4112 INFO CreateIndex Products (CategoryId) | |
2018-10-08 05:29:01.4215 INFO CREATE INDEX [IX_Products_CategoryId] ON [dbo].[Products] ([CategoryId] ASC) | |
2018-10-08 05:29:01.4410 INFO => 0,0194277s | |
2018-10-08 05:29:01.4540 INFO DeleteColumn Products Category | |
2018-10-08 05:29:01.4690 INFO DECLARE @default sysname, @sql nvarchar(max); | |
-- get name of default constraint | |
SELECT @default = name | |
FROM sys.default_constraints | |
WHERE parent_object_id = object_id('[dbo].[Products]') | |
AND type = 'D' | |
AND parent_column_id = ( | |
SELECT column_id | |
FROM sys.columns | |
WHERE object_id = object_id('[dbo].[Products]') | |
AND name = 'Category' | |
); | |
-- create alter table command to drop constraint as string and run it | |
SET @sql = N'ALTER TABLE [dbo].[Products] DROP CONSTRAINT ' + QUOTENAME(@default); | |
EXEC sp_executesql @sql; | |
-- now we can finally drop column | |
ALTER TABLE [dbo].[Products] DROP COLUMN [Category]; | |
2018-10-08 05:29:01.5502 INFO => 0,0842406s | |
2018-10-08 05:29:01.5620 INFO INSERT INTO [dbo].[VersionInfo] ([Version], [AppliedOn], [Description]) VALUES (201810030717, '2018-10-08T03:29:01', N'AddCategory') | |
2018-10-08 05:29:01.5620 INFO Committing Transaction | |
2018-10-08 05:29:01.5851 INFO COMMIT TRANSACTION | |
2018-10-08 05:29:01.5992 INFO 201810030717: AddCategory migrated | |
2018-10-08 05:29:01.6113 INFO => 0,1299746s |
Biblioteka w pierwszych kilku zapytaniach sqla tworzy specjalną tabelę (VersionInfo), do której później zapisuje informacje o wykonanych migracjach. Dzięki temu wie, jakie migracje zostały wykonane na bazie i jakie jeszcze trzeba wykonać.
Kolejne zapytania są już właściwymi zapytaniami z migracji. Polecam przejrzeć wykonane zapytania i porównać to z tym, jak samemu by się je napisało.
Entity Framework vs Fluent Migrator
Na koniec warto zastanowić się, z której biblioteki skorzystać. Niewątpliwą zaletą Entity Framework jest to, że sama biblioteka generuje większość kodu migracji na podstawie zmian modelu. My jako programiści musimy jedynie dodać pojedyncze sqle, które są odpowiedzialne za skopiowanie danych. W przypadku Fluent Migratora niestety musimy wszystko napisać sami.
Dlatego w moim przypadku w pierwszej kolejności staram się wybrać migracje w Entity Framework, gdy mam taką możliwość. Jeśli z jakichś powodów nie mogę w projekcie skorzystać z Entity Framework (np. jest użyty innym ORM np. NHibernate), wtedy korzystam z Fluent Migratora.
Przykład
Na githubie (https://github.com/danielplawgo/FluentMigratorExample) znajduje się przykład do tego wpisu. Praktycznie cały kod znajduje się w projekcie FluentMigratorExample.Migrator, który należy uruchomić. We właściwościach projektu w zakładce Debug znajduje się connection string, który domyślnie wykorzystuje aplikację, gdy jest uruchomiona z poziomu Visual Studio. Aby aplikacja się uruchomiła poprawnie, należy upewnić się, że baza określona przez connection string istnieje, lub zmienić go na właściwą bazę danych.
Gorąco zachęcam do pobrania przykładu i sprawdzenia Fluent Migratora w praktyce.
Podsumowanie
W tym wpisie pokazałem Ci inną bibliotekę, którą możesz wykorzystać do tworzenie migracji. Fluent Migrator, o którym mowa, jest bardzo podobny w swym działaniu do mechanizmu migracji w Entity Framework. Dlatego jest to mój pierwszy wybór w momencie, gdy nie mogę skorzystać z Entity Framework w swoim projekcie.
Za tydzień kolejna biblioteka (DbUp), która tym razem będzie działać nieco inaczej. Przydaje się ona, gdy migracje mamy zapisane w wielu plikach sql. Ale o tym już w kolejnym wpisie.
1 thought on “Migracja schematu bazy danych z Fluent Migratora”