Migracja schematu bazy danych z Fluent Migratora

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");
}
}
view raw AddProduct.cs hosted with ❤ by GitHub

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:

fluent migrator 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; }
}
view raw Options.cs hosted with ❤ by GitHub

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();
}
}
}
view raw Program.cs hosted with ❤ by GitHub

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
view raw cmd hosted with ❤ by GitHub

Darmowy kurs Visual Studio

Pracując z setkami programistów, zauważyłem, że większość osób nie pracuje efektywnie w Visual Studio. W skrajnych przypadkach korzystali z kopiowania z wykorzystaniem menu Edit. Wiem, że to dziwne, ale naprawdę niektórzy tak pracują. Dlatego postanowiłem stworzyć kurs Visual Studio – aby pomóc koleżankom i kolegom w efektywniejszej pracy.

Przygotowałem 30 lekcji e-mail, w których pokażę Ci, w jaki sposób pracować efektywniej i szybciej w Visual Studio. Poznasz dodatki, bez których nie wyobrażam sobie pracy w tym IDE.

Po więcej informacji zapraszam na dedykowaną stronę kursu: Darmowy Kurs Visual Studio.

Quiz C#

Ostatnio przygotowałem również quiz C#, w którym możesz sprawdzić swoją wiedzę. Podejmiesz wyzwanie?

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");
}
}
view raw AddCategory.cs hosted with ❤ by GitHub

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
view raw log.txt hosted with ❤ by GitHub

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.

Szkolenie Entity Framework Core

Szkolenie Entity Framework Core

Zainteresował Ciebie ten temat? A może chcesz więcej? Jak tak to zapraszam na moje autorskie szkolenie z Entity Framework Core.

1 thought on “Migracja schematu bazy danych z Fluent Migratora

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *