diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml new file mode 100644 index 0000000..7615a3f --- /dev/null +++ b/.github/workflows/publish-nuget.yml @@ -0,0 +1,37 @@ +name: Build and Publish NuGet + +on: + release: + types: [published] + workflow_dispatch: + push: + +jobs: + build-and-publish: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "6.0.x" + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore + + # - name: Test + # run: dotnet test --configuration Release --no-build --verbosity normal + + - name: Pack + run: dotnet pack --configuration Release --no-build --output ./artifacts + + - name: Publish to GitHub Packages + run: dotnet nuget push ./artifacts/*.nupkg --source https://nuget.pkg.github.com/cactusoft-ca/index.json --api-key ${{ secrets.GITHUB_TOKEN }} + env: + NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 35063fc..abeae43 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,19 @@ -## A streamlined .gitignore for modern .NET projects -## including temporary files, build results, and -## files generated by popular .NET tools. If you are -## developing with Visual Studio, the VS .gitignore -## https://github.com/github/gitignore/blob/main/VisualStudio.gitignore -## has more thorough IDE-specific entries. -## -## Get latest from https://github.com/github/gitignore/blob/main/Dotnet.gitignore +*.swp +*.*~ +project.lock.json +.DS_Store +*.pyc +nupkg/ +.idea/ + +# Visual Studio Code +.vscode + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates # Build results [Dd]ebug/ @@ -14,41 +22,14 @@ [Rr]eleases/ x64/ x86/ -[Ww][Ii][Nn]32/ -[Aa][Rr][Mm]/ -[Aa][Rr][Mm]64/ +build/ bld/ [Bb]in/ [Oo]bj/ -[Ll]og/ -[Ll]ogs/ - -# .NET Core -project.lock.json -project.fragment.lock.json -artifacts/ - -# ASP.NET Scaffolding -ScaffoldingReadMe.txt - -# NuGet Packages -*.nupkg -# NuGet Symbol Packages -*.snupkg - -# Others -~$* -*~ -CodeCoverage/ - -# MSBuild Binary and Structured Log -*.binlog - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* +[Oo]ut/ +msbuild.log +msbuild.err +msbuild.wrn -# NUnit -*.VisualState.xml -TestResult.xml -nunit-*.xml \ No newline at end of file +# Visual Studio 2015 +.vs/ \ No newline at end of file diff --git a/MIT.md b/MIT.md new file mode 100644 index 0000000..6151b9d --- /dev/null +++ b/MIT.md @@ -0,0 +1,25 @@ +The MIT License (MIT) +===================== + +Copyright © `2018` `Arthur Osmokiesku` + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the “Software”), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/MongoDBMigrations.SmokeTests/ComplexMigrationTests.cs b/MongoDBMigrations.SmokeTests/ComplexMigrationTests.cs new file mode 100644 index 0000000..d59a854 --- /dev/null +++ b/MongoDBMigrations.SmokeTests/ComplexMigrationTests.cs @@ -0,0 +1,62 @@ +using System.Reflection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using MongoDB.Bson; +using MongoDB.Driver; + +namespace MongoDBMigrations.SmokeTests +{ + [TestClass] + public class ComplexMigrationTests + { + MongoDaemon.ConnectionInfo _daemon; + + [TestInitialize] + public void SetUp() { + _daemon = MongoDaemon.Prepare(); + + var db = new MongoClient(_daemon.ConnectionString).GetDatabase(_daemon.DatabaseName); + //Create test collection with some data + db.CreateCollection("clients"); + db.GetCollection("clients") + .InsertMany(new[]{ + new BsonDocument{ {"name", "Alex"}, {"age", 17}}, + new BsonDocument{ {"name", "Max"}, {"age", 25}} + }); + + //Run up to the latest migration + new MigrationEngine() + .UseDatabase(_daemon.ConnectionString, _daemon.DatabaseName) + .UseAssembly(Assembly.GetExecutingAssembly()) + .UseSchemeValidation(false) + .Run(); + } + + [TestCleanup] + public void TearDown() + { + _daemon.Dispose(); + } + + [TestMethod] + public void SawLikeMigrationDownAndThenUp() + { + var downTarget = new Version("1.0.0"); + var downMigrationResult = new MigrationEngine() + .UseDatabase(_daemon.ConnectionString, _daemon.DatabaseName) + .UseAssembly(Assembly.GetExecutingAssembly()) + .UseSchemeValidation(false) + .Run(downTarget); + + Assert.AreEqual(downTarget, downMigrationResult.CurrentVersion); + Assert.IsTrue(downMigrationResult.Success); + + var upMigrationResult = new MigrationEngine() + .UseDatabase(_daemon.ConnectionString, _daemon.DatabaseName) + .UseAssembly(Assembly.GetExecutingAssembly()) + .UseSchemeValidation(false) + .Run(); + + Assert.IsTrue(upMigrationResult.Success); + } + } +} \ No newline at end of file diff --git a/MongoDBMigrations.SmokeTests/DatabaseCheckerTests.cs b/MongoDBMigrations.SmokeTests/DatabaseCheckerTests.cs new file mode 100644 index 0000000..a3d0980 --- /dev/null +++ b/MongoDBMigrations.SmokeTests/DatabaseCheckerTests.cs @@ -0,0 +1,62 @@ +using System.Reflection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using MongoDB.Bson; +using MongoDB.Driver; + +namespace MongoDBMigrations.SmokeTests +{ + [TestClass] + public class DatabaseCheckerTests + { + MongoDaemon.ConnectionInfo _daemon; + + [TestInitialize] + public void SetUp() + { + _daemon = MongoDaemon.Prepare(); + + var db = new MongoClient(_daemon.ConnectionString).GetDatabase(_daemon.DatabaseName); + //Create test collection with some data + db.CreateCollection("clients"); + db.GetCollection("clients") + .InsertMany(new[]{ + new BsonDocument{ {"name", "Alex"}, {"age", 17}}, + new BsonDocument{ {"name", "Max"}, {"age", 25}} + }); + } + + [TestCleanup] + public void TearDown() + { + _daemon.Dispose(); + } + + [TestMethod] + public void IsDatabaseOutdatedShouldReturnTrue() + { + var result = MongoDatabaseStateChecker.IsDatabaseOutdated(_daemon.ConnectionString, _daemon.DatabaseName, typeof(DatabaseCheckerTests).Assembly); + Assert.IsTrue(result); + } + + [TestMethod] + public void IsDatabaseOutdatedShoudReturnFalse() + { + var target = new Version("1.1.0"); + new MigrationEngine() + .UseDatabase(_daemon.ConnectionString, _daemon.DatabaseName) + .UseAssembly(Assembly.GetExecutingAssembly()) + .UseSchemeValidation(false) + .Run(target); + + var result = MongoDatabaseStateChecker.IsDatabaseOutdated(_daemon.ConnectionString, _daemon.DatabaseName, typeof(DatabaseCheckerTests).Assembly); + Assert.IsFalse(result); + } + + [TestMethod] + [ExpectedException(typeof(DatabaseOutdatedExcetion))] + public void ThrowIfDatabaseOutdatedShouldThrowException() + { + MongoDatabaseStateChecker.ThrowIfDatabaseOutdated(_daemon.ConnectionString, _daemon.DatabaseName, typeof(DatabaseCheckerTests).Assembly); + } + } +} \ No newline at end of file diff --git a/MongoDBMigrations.SmokeTests/Migrations/1_0_0_FirstMigrationTest.cs b/MongoDBMigrations.SmokeTests/Migrations/1_0_0_FirstMigrationTest.cs new file mode 100644 index 0000000..614fd98 --- /dev/null +++ b/MongoDBMigrations.SmokeTests/Migrations/1_0_0_FirstMigrationTest.cs @@ -0,0 +1,26 @@ +using MongoDB.Bson; +using MongoDB.Driver; + +namespace MongoDBMigrations.SmokeTests.Migrations +{ + public class _1_0_0_FirstMigrationTest : IMigration + { + public Version Version => new Version(1, 0, 0); + + public string Name => "Changing column name"; + + public void Down(MigrationContext context) + { + var collection = context.Database.GetCollection("clients"); + collection.UpdateMany(FilterDefinition.Empty, + Builders.Update.Rename("firstName", "name")); + } + + public void Up(MigrationContext context) + { + var collection = context.Database.GetCollection("clients"); + collection.UpdateMany(FilterDefinition.Empty, + Builders.Update.Rename("name", "firstName")); + } + } +} \ No newline at end of file diff --git a/MongoDBMigrations.SmokeTests/Migrations/1_1_0_SecondMigrationTest.cs b/MongoDBMigrations.SmokeTests/Migrations/1_1_0_SecondMigrationTest.cs new file mode 100644 index 0000000..d197c05 --- /dev/null +++ b/MongoDBMigrations.SmokeTests/Migrations/1_1_0_SecondMigrationTest.cs @@ -0,0 +1,36 @@ +using MongoDB.Bson; +using MongoDB.Driver; + +namespace MongoDBMigrations.SmokeTests.Migrations +{ + public class _1_1_0_SecondMigrationTest : IMigration + { + public Version Version => new Version(1, 1, 0); + + public string Name => "Changing type of age type"; + + public void Down(MigrationContext context) + { + var collection = context.Database.GetCollection("clients"); + var list = collection.Find(FilterDefinition.Empty).ToList(); + FieldDefinition fieldDefenition = "age"; + foreach (var item in list) + { + collection.UpdateOne(new BsonDocument("_id", item["_id"]), + Builders.Update.Set(fieldDefenition, item["age"].ToInt32())); + } + } + + public void Up(MigrationContext context) + { + var collection = context.Database.GetCollection("clients"); + var list = collection.Find(FilterDefinition.Empty).ToList(); + FieldDefinition fieldDefenition = "age"; + foreach (var item in list) + { + collection.UpdateOne(new BsonDocument("_id", item["_id"]), + Builders.Update.Set(fieldDefenition, item["age"].ToString())); + } + } + } +} \ No newline at end of file diff --git a/MongoDBMigrations.SmokeTests/MongoDBMigrations.SmokeTests.csproj b/MongoDBMigrations.SmokeTests/MongoDBMigrations.SmokeTests.csproj new file mode 100644 index 0000000..9571a01 --- /dev/null +++ b/MongoDBMigrations.SmokeTests/MongoDBMigrations.SmokeTests.csproj @@ -0,0 +1,31 @@ + + + + net6.0 + + false + + default + + + + + + + + + + + + + + + + + + PreserveNewest + true + + + + \ No newline at end of file diff --git a/MongoDBMigrations.SmokeTests/MongoDaemon.cs b/MongoDBMigrations.SmokeTests/MongoDaemon.cs new file mode 100644 index 0000000..bffe6ac --- /dev/null +++ b/MongoDBMigrations.SmokeTests/MongoDaemon.cs @@ -0,0 +1,53 @@ +using System; +using System.Runtime.InteropServices; +using Microsoft.Extensions.Configuration; +using Mongo2Go; + +namespace MongoDBMigrations.SmokeTests +{ + public static class MongoDaemon + { + public sealed class ConnectionInfo : IDisposable + { + readonly MongoDbRunner runner; + public ConnectionInfo(MongoDbRunner runner) { + this.runner = runner; + } + + public string ConnectionString => runner.ConnectionString; + public string DatabaseName { get; init; } + + public void Dispose() { + runner.Dispose(); + } + } + + // ReSharper disable InconsistentNaming + sealed class AppConfig + { + public string connectionString { get; init; } + public string databaseName { get; init; } + public string host { get; init; } + public string port { get; init; } + public bool isLocal { get; init; } + public string dbFolder { get; init; } + } + // ReSharper restore InconsistentNaming + + public static ConnectionInfo Prepare() { + var dbFolder = Configuration.Value.isLocal ? Configuration.Value.dbFolder : null; + return new (MongoDbRunner.Start(dbFolder)){ DatabaseName = Configuration.Value.databaseName }; + } + + static readonly Lazy Configuration = new(() => { + //This class is only for test/dev purposes. And supports only windows and osx platform as a development environment. + var section = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "windows" : "osx"; + + return new ConfigurationBuilder() + .AddJsonFile("local.json") + .Build() + .GetSection(section) + .Get(); + }); + } +} \ No newline at end of file diff --git a/MongoDBMigrations.SmokeTests/SchemaValidatorTests.cs b/MongoDBMigrations.SmokeTests/SchemaValidatorTests.cs new file mode 100644 index 0000000..81f7093 --- /dev/null +++ b/MongoDBMigrations.SmokeTests/SchemaValidatorTests.cs @@ -0,0 +1,71 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using MongoDB.Bson; +using MongoDB.Driver; + +namespace MongoDBMigrations.SmokeTests +{ + [TestClass] + public class SchemaValidatorTests + { + MongoDaemon.ConnectionInfo _daemon; + IMongoCollection db; + + [TestInitialize] + public void SetUp() { + _daemon = MongoDaemon.Prepare(); + var database = new MongoClient(_daemon.ConnectionString).GetDatabase(_daemon.DatabaseName); + //Create test collection with some data + database.CreateCollection("clients"); + db = database.GetCollection("clients"); + } + + [TestCleanup] + public void TearDown() + { + _daemon.Dispose(); + } + + static readonly Lazy ProjectPath = new(() => { + var finder = new DirectoryInfo(Directory.GetCurrentDirectory()); + FileInfo file; + while ((file = finder.EnumerateFiles("MongoDBMigrations.SmokeTests.csproj").FirstOrDefault()) is null) + finder = finder.Parent; + return file.FullName; + }); + + [TestMethod] + [ExpectedException(typeof(InvalidOperationException))] + public void ValidatorShouldThrowExceptionBecauseSchemaIsInconsistent() + { + db.InsertMany(new[]{ + new BsonDocument{ {"name", "Alex"}, {"isActive", true}}, + new BsonDocument{ {"name", "Max"}} + }); + var target = new Version(1,0,0); + new MigrationEngine().UseDatabase(_daemon.ConnectionString, _daemon.DatabaseName) + .UseAssembly(Assembly.GetExecutingAssembly()) + .UseSchemeValidation(true, ProjectPath.Value) + .Run(target); + } + + [TestMethod] + public void ValidatorShouldPass() { + db.InsertMany(new[]{ + new BsonDocument{ { "name", "Alex" },{ "age", 17 } }, + new BsonDocument{ { "name", "Max" },{ "age", 25 } } + }); + var target = new Version(1, 0, 0); + var result = new MigrationEngine().UseDatabase(_daemon.ConnectionString, _daemon.DatabaseName) + .UseAssembly(Assembly.GetExecutingAssembly()) + .UseSchemeValidation(true, ProjectPath.Value) + .Run(target); + + Assert.IsTrue(result.InterimSteps.Count > 0); + Assert.AreEqual(target, result.CurrentVersion); + } + } +} \ No newline at end of file diff --git a/MongoDBMigrations.SmokeTests/SimpleUpDownTests.cs b/MongoDBMigrations.SmokeTests/SimpleUpDownTests.cs new file mode 100644 index 0000000..bafd27a --- /dev/null +++ b/MongoDBMigrations.SmokeTests/SimpleUpDownTests.cs @@ -0,0 +1,119 @@ +using System.Collections.Generic; +using System.Reflection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using MongoDB.Bson; +using MongoDB.Driver; + +namespace MongoDBMigrations.SmokeTests +{ + [TestClass] + public class SimpleUpDownTests + { + MongoDaemon.ConnectionInfo _daemon; + + [TestInitialize] + public void SetUp() { + _daemon = MongoDaemon.Prepare(); + + var db = new MongoClient(_daemon.ConnectionString).GetDatabase(_daemon.DatabaseName); + //Create test collection with some data + db.CreateCollection("clients"); + db.GetCollection("clients") + .InsertMany(new[]{ + new BsonDocument{ {"name", "Alex"}, {"age", 17}}, + new BsonDocument{ {"name", "Max"}, {"age", 25}} + }); + } + + [TestCleanup] + public void TearDown() + { + _daemon.Dispose(); + } + + [DataTestMethod] + [DataRow("1.0.0")] + [DataRow("1.1.0")] + public void DefaultUpdateTestSuccess(string version) + { + var target = new Version(version); + var result = new MigrationEngine() + .UseDatabase(_daemon.ConnectionString, _daemon.DatabaseName) + .UseAssembly(Assembly.GetExecutingAssembly()) + .UseSchemeValidation(false) + .Run(target); + + Assert.IsTrue(result.InterimSteps.Count > 0); + Assert.AreEqual(target, result.CurrentVersion); + } + + [DataTestMethod] + [DataRow("1.0.0")] + [DataRow("1.1.0")] + public void WithProgressHandlingUpdateTestSuccess(string version) + { + var actions = new List(); + + var target = new Version(version); + var result = new MigrationEngine().UseDatabase(_daemon.ConnectionString, _daemon.DatabaseName) + .UseAssembly(Assembly.GetExecutingAssembly()) + .UseSchemeValidation(false) + .UseProgressHandler((i) => actions.Add(i.MigrationName)) + .Run(target); + + Assert.IsTrue(actions.Count == result.InterimSteps.Count); + Assert.IsTrue(result.InterimSteps.Count > 0); + Assert.AreEqual(target, result.CurrentVersion); + } + + [TestMethod] + [ExpectedException(typeof(MigrationNotFoundException))] + public void MigrationNotFoundShouldThrowException() + { + var target = new Version(99,99,99); + new MigrationEngine().UseDatabase(_daemon.ConnectionString, _daemon.DatabaseName) + .UseAssembly(Assembly.GetExecutingAssembly()) + .UseSchemeValidation(false) + .Run(target); + } + + /* + [TestMethod] + public void SimpleMigrationViaSSHTunnel() + { + var target = new Version(1, 0, 0); + + using(var fs = File.OpenRead("/Users/arthur_osmokiesku/Git/SSH keys/vm-mongodb-server_key.pem")) + { + var result = new MigrationEngine().UseSshTunnel( + new Document.ServerAdressConfig { Host = "40.127.203.104", Port = 22 }, + "azureuser", + fs, + new Document.ServerAdressConfig { Host = "127.0.0.1", Port = 27017 }) + .UseDatabase(_daemon.ConnectionString, _daemon.DatabaseName) + .UseAssembly(Assembly.GetExecutingAssembly()) + .UseSchemeValidation(false) + .Run(target); + + Assert.AreEqual(target, result.CurrentVersion); + } + } + + [TestMethod] + public void SimpleMigrationViaTls() + { + var target = new Version(1, 0, 0); + + var cert = new X509Certificate2("/Users/arthur_osmokiesku/Git/SSH keys/test-client.pfx", "Test1234", X509KeyStorageFlags.Exportable); + var result = new MigrationEngine() + .UseTls(cert) + .UseDatabase(_daemon.ConnectionString, _daemon.DatabaseName) + .UseAssembly(Assembly.GetExecutingAssembly()) + .UseSchemeValidation(false) + .Run(target); + + Assert.AreEqual(target, result.CurrentVersion); + } + */ + } +} \ No newline at end of file diff --git a/MongoDBMigrations.SmokeTests/local.json b/MongoDBMigrations.SmokeTests/local.json new file mode 100644 index 0000000..b933364 --- /dev/null +++ b/MongoDBMigrations.SmokeTests/local.json @@ -0,0 +1,30 @@ +{ + "windows": { + "dbFolder": "C:\\\\data\\db\\", + "connectionString": "mongodb://localhost:27017", + "databaseName": "test", + "host": "localhost", + "port": "27017", + "isLocal": true + }, + /* + "osx": { + "dbFolder": "/Users/arthur_osmokiesku/data/db/", + "connectionString": "mongodb://localhost:27017", + "databaseName": "test", + "host": "localhost", + "port": "27017", + "isLocal": true + }, */ + //OSX to Azure Ubuntu VM machine + + "osx": { + "connectionString": "mongodb://40.127.203.104:27017", + "databaseName": "test", + "host": "40.127.203.104", + "port": "27017", + "isLocal": false + } + +} + diff --git a/MongoDBMigrations.sln b/MongoDBMigrations.sln new file mode 100644 index 0000000..279772c --- /dev/null +++ b/MongoDBMigrations.sln @@ -0,0 +1,41 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.29215.179 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MongoDBMigrations", "MongoDBMigrations\MongoDBMigrations.csproj", "{AF4C7A1B-15DD-44B6-AE22-F88DCECC726C}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{D1E3EAAA-6BB4-486E-A8C5-4C1E0CAE72FB}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MongoDBMigrations.SmokeTests", "MongoDBMigrations.SmokeTests\MongoDBMigrations.SmokeTests.csproj", "{D6D945B1-F9E1-494F-BCFF-BA84BA42C42D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{3A384D14-2BD7-45CE-B5D1-D8D818DD0D71}" + ProjectSection(SolutionItems) = preProject + .gitignore = .gitignore + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {AF4C7A1B-15DD-44B6-AE22-F88DCECC726C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AF4C7A1B-15DD-44B6-AE22-F88DCECC726C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AF4C7A1B-15DD-44B6-AE22-F88DCECC726C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AF4C7A1B-15DD-44B6-AE22-F88DCECC726C}.Release|Any CPU.Build.0 = Release|Any CPU + {D6D945B1-F9E1-494F-BCFF-BA84BA42C42D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D6D945B1-F9E1-494F-BCFF-BA84BA42C42D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D6D945B1-F9E1-494F-BCFF-BA84BA42C42D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D6D945B1-F9E1-494F-BCFF-BA84BA42C42D}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {D6D945B1-F9E1-494F-BCFF-BA84BA42C42D} = {D1E3EAAA-6BB4-486E-A8C5-4C1E0CAE72FB} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {DC866BA0-68A2-4EE8-8579-4C655C3FFF50} + EndGlobalSection +EndGlobal diff --git a/MongoDBMigrations/Core/Contracts/ILocator.cs b/MongoDBMigrations/Core/Contracts/ILocator.cs new file mode 100644 index 0000000..f1089e0 --- /dev/null +++ b/MongoDBMigrations/Core/Contracts/ILocator.cs @@ -0,0 +1,12 @@ +using System; +using System.Reflection; + +namespace MongoDBMigrations +{ + public interface ILocator + { + ISchemeValidation UseAssemblyOfType(Type type); + ISchemeValidation UseAssemblyOfType(); + ISchemeValidation UseAssembly(Assembly assembly); + } +} \ No newline at end of file diff --git a/MongoDBMigrations/Core/Contracts/IMigration.cs b/MongoDBMigrations/Core/Contracts/IMigration.cs new file mode 100644 index 0000000..5d3f647 --- /dev/null +++ b/MongoDBMigrations/Core/Contracts/IMigration.cs @@ -0,0 +1,41 @@ +using System.Threading; +using MongoDB.Driver; + +namespace MongoDBMigrations +{ + public sealed class MigrationContext + { + public MigrationContext(IMongoDatabase database, IClientSessionHandle session, in CancellationToken token) { + Database = database; + Session = session; + CancellationToken = token; + } + public IMongoDatabase Database { get; } + public IClientSessionHandle Session { get; } + public CancellationToken CancellationToken { get; } + } + public interface IMigration + { + /// + /// Field which consist semantic version in format MAJOR.MINOR.REVISION. + /// + Version Version { get; } + + /// + /// Name of migration. + /// + string Name { get; } + + /// + /// Roll forward method. + /// + /// Migration context + void Up(MigrationContext context); + + /// + /// Roll back method. + /// + /// Migration context + void Down(MigrationContext context); + } +} \ No newline at end of file diff --git a/MongoDBMigrations/Core/Contracts/IMigrationRunner.cs b/MongoDBMigrations/Core/Contracts/IMigrationRunner.cs new file mode 100644 index 0000000..9c2c978 --- /dev/null +++ b/MongoDBMigrations/Core/Contracts/IMigrationRunner.cs @@ -0,0 +1,15 @@ +using System; +using MongoDBMigrations.Core; +using System.Threading; + +namespace MongoDBMigrations +{ + public interface IMigrationRunner + { + IMigrationRunner UseProgressHandler(Action action); + IMigrationRunner UseCancelationToken(CancellationToken token); + IMigrationRunner UseCustomSpecificationCollectionName(string name); + MigrationResult Run(Version version); + MigrationResult Run(); + } +} \ No newline at end of file diff --git a/MongoDBMigrations/Core/Contracts/IMigrationSession.cs b/MongoDBMigrations/Core/Contracts/IMigrationSession.cs new file mode 100644 index 0000000..4134d43 --- /dev/null +++ b/MongoDBMigrations/Core/Contracts/IMigrationSession.cs @@ -0,0 +1,16 @@ +using System; +using System.Threading; +using MongoDB.Driver; + +namespace MongoDBMigrations.Core.Contracts +{ + public interface IMigrationTransaction : IDisposable + { + IClientSessionHandle Session { get; } + void Commit(CancellationToken token = default); + } + public interface IMigrationSession + { + IMigrationTransaction BeginTransaction(); + } +} \ No newline at end of file diff --git a/MongoDBMigrations/Core/Contracts/ISchemeValidation.cs b/MongoDBMigrations/Core/Contracts/ISchemeValidation.cs new file mode 100644 index 0000000..d68a6a0 --- /dev/null +++ b/MongoDBMigrations/Core/Contracts/ISchemeValidation.cs @@ -0,0 +1,7 @@ +namespace MongoDBMigrations +{ + public interface ISchemeValidation + { + IMigrationRunner UseSchemeValidation(bool enabled, string migrationProjectLocation = null); + } +} \ No newline at end of file diff --git a/MongoDBMigrations/Core/Contracts/InterimMigrationResult.cs b/MongoDBMigrations/Core/Contracts/InterimMigrationResult.cs new file mode 100644 index 0000000..06f56ff --- /dev/null +++ b/MongoDBMigrations/Core/Contracts/InterimMigrationResult.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace MongoDBMigrations.Core +{ + public class InterimMigrationResult + { + public string MigrationName; + public Version TargetVersion; + public string ServerAdress; + public string DatabaseName; + public int CurrentNumber; + public int TotalCount; + } +} diff --git a/MongoDBMigrations/Core/Contracts/MigrationResult.cs b/MongoDBMigrations/Core/Contracts/MigrationResult.cs new file mode 100644 index 0000000..33e1ce5 --- /dev/null +++ b/MongoDBMigrations/Core/Contracts/MigrationResult.cs @@ -0,0 +1,14 @@ +using MongoDBMigrations.Core; +using System.Collections.Generic; + +namespace MongoDBMigrations +{ + public class MigrationResult + { + public Version CurrentVersion; + public List InterimSteps; + public string ServerAdress; + public string DatabaseName; + public bool Success; + } +} \ No newline at end of file diff --git a/MongoDBMigrations/Core/Contracts/SchemeValidationResult.cs b/MongoDBMigrations/Core/Contracts/SchemeValidationResult.cs new file mode 100644 index 0000000..2d06506 --- /dev/null +++ b/MongoDBMigrations/Core/Contracts/SchemeValidationResult.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.Linq; + +namespace MongoDBMigrations.Document +{ + public class SchemeValidationResult + { + private readonly IList _validCollections; + private readonly IList _invalidCollections; + + public IEnumerable ValidCollections => _validCollections.Distinct(); + public IEnumerable FailedCollections => _invalidCollections.Distinct(); + + public SchemeValidationResult() + { + _validCollections = new List(); + _invalidCollections = new List(); + } + + public void Add(string name, bool isFailed) + { + if (isFailed) + _invalidCollections.Add(name); + else + _validCollections.Add(name); + } + } +} diff --git a/MongoDBMigrations/Core/DatabaseManager.cs b/MongoDBMigrations/Core/DatabaseManager.cs new file mode 100644 index 0000000..d09edfc --- /dev/null +++ b/MongoDBMigrations/Core/DatabaseManager.cs @@ -0,0 +1,138 @@ +using System; +using System.Linq; +using System.Reflection; +using MongoDB.Bson.Serialization.Attributes; +using MongoDB.Driver; +using MongoDBMigrations.Document; + +namespace MongoDBMigrations +{ + /// + /// Works with applied migrations + /// + public class DatabaseManager + { + private const string SPECIFICATION_COLLECTION_DEFAULT_NAME = "_migrations"; + private string _specCollectionName; + private readonly IMongoDatabase _database; + + public string SpecCollectionName + { + set + { + if (!string.IsNullOrEmpty(value)) + _specCollectionName = value; + } + get + { + return string.IsNullOrEmpty(_specCollectionName) ? SPECIFICATION_COLLECTION_DEFAULT_NAME : _specCollectionName; + } + } + + #region Compatibility Checks + private bool IsAzureCosmosDBCompatible(bool isInitial) + { + if(_database == null) + { + throw new TypeInitializationException(nameof(DatabaseManager), new Exception($"{nameof(_database)} hasn't been initialized.")); + } + + if (isInitial) //If it's a fist migration run and there are no records in the _migrations collection. + { + //Just create an index + var indexOptions = new CreateIndexOptions(); + var indexKey = Builders.IndexKeys.Ascending(x => x.ApplyingDateTime); + var indexModel = new CreateIndexModel(indexKey, indexOptions); + var collection = _database.GetCollection(SpecCollectionName); + collection.Indexes.CreateOne(indexModel); + return true; + } + + //Check that index exisist and return true, otherwise false. + var indexes = _database.GetCollection(SpecCollectionName).Indexes.List().ToList(); + var targetIndex = typeof(SpecificationItem) + .GetProperty(nameof(SpecificationItem.ApplyingDateTime)) + .GetCustomAttribute() + .ElementName; + return indexes.Any(x => x.GetValue("name").ToString().StartsWith(targetIndex)); + } + + #endregion + + public DatabaseManager(IMongoDatabase database, MongoEmulationEnum emulation) + { + _database = database ?? throw new TypeInitializationException("Database can't be null", null); + bool isInitial = false; + if (!_database.ListCollectionNames().ToList().Contains(SpecCollectionName)) + { + _database.CreateCollection(SpecCollectionName); + isInitial = true; + } + + switch(emulation) + { + case MongoEmulationEnum.AzureCosmos when !IsAzureCosmosDBCompatible(isInitial): + throw new InvalidOperationException($@"Your current setup isn't ready for this migration run. + Please create an ascending index to the filed '{typeof(SpecificationItem).GetProperty(nameof(SpecificationItem.ApplyingDateTime)).GetCustomAttribute().ElementName}' + at collection '{SpecCollectionName}' manually and retry the migration run. Be aware that indexing may take some time."); + default: + return; + } + + } + private IMongoCollection GetAppliedMigrations() + { + return _database.GetCollection(SpecCollectionName); + } + + /// + /// Return database version based on last applied migration. + /// + /// Database version in semantic view. + public Version GetVersion() + { + var lastMigration = GetLastAppliedMigration(); + if (lastMigration == null || lastMigration.isUp) + return lastMigration?.Ver ?? Version.Zero(); + + var migration = GetAppliedMigrations() + .Find(item => item.isUp && item.Ver < lastMigration.Ver) + .Sort(Builders.Sort.Descending(x => x.ApplyingDateTime)) + .FirstOrDefault(); + + return migration?.Ver ?? Version.Zero(); + } + + /// + /// Find last applied migration by applying date and time. + /// + /// Applied migration. + public SpecificationItem GetLastAppliedMigration() + { + return GetAppliedMigrations() + .Find(FilterDefinition.Empty) + .Sort(Builders.Sort.Descending(x => x.ApplyingDateTime)) + .FirstOrDefault(); + } + + /// + /// Commit migration to the database. + /// + /// Migration instance. + /// True if roll forward otherwise roll back. + /// + /// Applied migration. + internal SpecificationItem SaveMigration(IMigration migration, bool isUp, IClientSessionHandle transaction) + { + var appliedMigration = new SpecificationItem + { + Name = migration.Name, + Ver = migration.Version, + isUp = isUp, + ApplyingDateTime = DateTime.UtcNow + }; + GetAppliedMigrations().InsertOne(transaction, appliedMigration); + return appliedMigration; + } + } +} \ No newline at end of file diff --git a/MongoDBMigrations/Core/IMongoClientExtensions.cs b/MongoDBMigrations/Core/IMongoClientExtensions.cs new file mode 100644 index 0000000..ff309f1 --- /dev/null +++ b/MongoDBMigrations/Core/IMongoClientExtensions.cs @@ -0,0 +1,28 @@ +using System; +using MongoDB.Driver; + +namespace MongoDBMigrations.Core +{ + internal static class IMongoClientExtensions + { + public static IMongoClient SetTls(this IMongoClient instance, SslSettings config) + { + if (config != null) + { + instance.Settings.UseTls = true; + instance.Settings.SslSettings = config; + } + + return instance; + } + + public static IMongoClient SetSsh(this IMongoClient instance, MigrationEngine.SshConfig config) + { + if(config != null) + { + instance.Settings.Server = new MongoServerAddress(config.BoundHost, checked((int)config.BoundPort)); + } + return instance; + } + } +} diff --git a/MongoDBMigrations/Core/IgnoreMigrationAttribute.cs b/MongoDBMigrations/Core/IgnoreMigrationAttribute.cs new file mode 100644 index 0000000..86c7eec --- /dev/null +++ b/MongoDBMigrations/Core/IgnoreMigrationAttribute.cs @@ -0,0 +1,8 @@ +using System; + +namespace MongoDBMigrations.Document +{ + public class IgnoreMigrationAttribute : Attribute + { + } +} diff --git a/MongoDBMigrations/Core/MigrationManager.cs b/MongoDBMigrations/Core/MigrationManager.cs new file mode 100644 index 0000000..a4f3b53 --- /dev/null +++ b/MongoDBMigrations/Core/MigrationManager.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using MongoDBMigrations.Document; + +namespace MongoDBMigrations +{ + /// + /// Works with local migrations + /// + internal class MigrationManager + { + private Assembly _assembly; + + public MigrationManager() + { + } + + /// + /// Sets assembly + /// + /// Assembly where migration classes located + public void SetAssembly(Assembly assembly) + { + if (assembly == null) + throw new ArgumentNullException(nameof(assembly)); + + _assembly = assembly; + } + + /// + /// Set assembly for finding migrations. + /// + /// Type in assembly with migrations. + public void LookInAssemblyOfType() + { + var assembly = typeof(T).Assembly; + _assembly = assembly; + } + + /// + /// Set assembly for finding migrations. + /// + /// Type in assembly with migrations. + public void LookInAssemblyOfType(Type type) + { + var assembly = type.Assembly; + _assembly = assembly; + } + + /// + /// Find all migrations in executing assembly or assembly whitch found by method. + /// + /// List of all found migrations. + public List GetAllMigrations() + { + if (_assembly != null) + { + return GetAllMigrations(_assembly); + } + + // Ok no problem let's try to find mingrations in excecuting assembly + var stackFrames = new StackTrace().GetFrames(); + if (stackFrames == null) + throw new InvalidOperationException("Can't find assembly with migrations. Try use LookInAssemblyOfType() method before."); + + var currentAssembly = Assembly.GetExecutingAssembly(); + Assembly trueCallingAssembly = stackFrames + .FirstOrDefault(a => a.GetMethod().DeclaringType.Assembly != currentAssembly).GetMethod().DeclaringType.Assembly; + + if (trueCallingAssembly == null) + throw new InvalidOperationException("Can't find assembly with migrations. Try use LookInAssemblyOfType() method before."); + + + return GetAllMigrations(trueCallingAssembly); + } + + /// + /// Find all migrations in specific assembly + /// + /// Assembly with migrations classes. + /// List of all found migrations. + public List GetAllMigrations(Assembly assembly) + { + List result; + try + { + result = assembly.GetTypes() + .Where(type => + typeof(IMigration).IsAssignableFrom(type) + && !type.IsAbstract + && type.GetCustomAttribute() == null) + .Select(Activator.CreateInstance) + .OfType() + .ToList(); + } + catch (Exception exception) + { + throw new InvalidOperationException($"Can't find migrations in assembly {assembly.FullName}", exception); + } + + if (!result.Any()) + throw new MigrationNotFoundException(assembly.FullName, null); + + return result; + } + + /// + /// Find all migrations in executing assembly or assembly whitch found by method. + /// Between current and target versions + /// + /// Version of database. + /// Target version for migrating. + /// List of all found migrations. + public List GetMigrations(Version currentVersion, Version targetVerstion) + { + var migrations = GetAllMigrations(); + if (migrations.All(x => x.Version != targetVerstion) && targetVerstion != Version.Zero()) + { + throw new MigrationNotFoundException(_assembly.FullName, null); + } + + if (targetVerstion > currentVersion) + { + migrations = migrations + .Where(x => x.Version > currentVersion && x.Version <= targetVerstion) + .OrderBy(x => x.Version).ToList(); + } + else if (targetVerstion < currentVersion) + { + migrations = migrations + .Where(x => x.Version <= currentVersion && x.Version > targetVerstion) + .OrderByDescending(x => x.Version).ToList(); + } + else + return Enumerable.Empty().ToList(); + + if (!migrations.Any()) + throw new MigrationNotFoundException(_assembly.FullName, null); + + return migrations; + } + + /// + /// Find the highest version of migrations. + /// + /// Highest version in semantic view. + public Version GetNewestLocalVersion() + { + var migrations = GetAllMigrations(); + if (!migrations.Any()) + { + return Version.Zero(); + } + + return migrations.Max(m => m.Version); + } + } +} \ No newline at end of file diff --git a/MongoDBMigrations/Core/MigrationSession.cs b/MongoDBMigrations/Core/MigrationSession.cs new file mode 100644 index 0000000..bcbd977 --- /dev/null +++ b/MongoDBMigrations/Core/MigrationSession.cs @@ -0,0 +1,39 @@ +using System.Threading; +using MongoDB.Driver; +using MongoDBMigrations.Core.Contracts; + +namespace MongoDBMigrations.Core +{ + public sealed class SimpleMigrationSession : IMigrationSession + { + public IMigrationTransaction BeginTransaction() => new SimpleTransaction(); + + sealed class SimpleTransaction : IMigrationTransaction + { + public void Dispose() { } + public IClientSessionHandle Session => null; + public void Commit(CancellationToken _) { } + } + } + public sealed class MongoMigrationSession : IMigrationSession + { + readonly IMongoClient client; + public MongoMigrationSession(IMongoClient client) { + this.client = client; + } + public IMigrationTransaction BeginTransaction() => new MongoMigrationTransaction(client.StartSession()); + + sealed class MongoMigrationTransaction : IMigrationTransaction + { + readonly IClientSessionHandle session; + + public MongoMigrationTransaction(IClientSessionHandle session) { + this.session = session; + session.StartTransaction(); + } + public void Dispose() => session.Dispose(); + public IClientSessionHandle Session => session; + public void Commit(CancellationToken token) => session.CommitTransaction(token); + } + } +} \ No newline at end of file diff --git a/MongoDBMigrations/Core/MongoSchemeValidator.cs b/MongoDBMigrations/Core/MongoSchemeValidator.cs new file mode 100644 index 0000000..86d8f4e --- /dev/null +++ b/MongoDBMigrations/Core/MongoSchemeValidator.cs @@ -0,0 +1,167 @@ +using Buildalyzer; +using Buildalyzer.Workspaces; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using MongoDB.Bson; +using MongoDB.Driver; +using MongoDBMigrations.Document; +using System; +using System.Collections.Generic; +using System.Linq; + + +namespace MongoDBMigrations.Core +{ + public class MongoSchemeValidator + { + + /// + /// List of method names in which the collection name is used. + /// They were taken from the IMongoDatabase interface. + /// + public List MethodMarkers { get; } = new List + { + "GetCollection", //IMongoDatabase + "CreateCollection", + "CreateCollectionAsync", + "DropCollection", + "DropCollectionAsync", + "ListCollectionNames", + "ListCollectionNamesAsync", + "ListCollections", + "ListCollectionsAsync", + "RenameCollection", + "RenameCollectionAsync", + "GetCollectionNames", //MongoDatabase + }; + + /// + /// Add new method name to MethodMarkers collection, it will be added if it not exist. + /// + /// Method name + public void RegisterMethodMarker(string methodName) + { + if (MethodMarkers.Any(i => i.Equals(methodName, StringComparison.CurrentCultureIgnoreCase))) + { + return; + } + + MethodMarkers.Add(methodName); + } + + /// + /// This method check documents which will be affected by migration. For successful result all + /// documents in collection must have the same scheme otherwise validation will be failed. + /// + /// List of migration that preparing for applying + /// Migration direction + /// Absolute path to *.csproj file with migration + /// Instance of mongo database + /// + public SchemeValidationResult Validate(IEnumerable migrations, bool isUp, string pathToMigrationProj, IMongoDatabase database) + { + if (string.IsNullOrEmpty(pathToMigrationProj)) + throw new ArgumentNullException(nameof(pathToMigrationProj)); + + if (database == null) + throw new ArgumentNullException(nameof(database)); + + if (migrations == null) + throw new ArgumentNullException(nameof(migrations)); + + if (!migrations.Any()) + return new SchemeValidationResult(); + + string methodName = isUp ? nameof(IMigration.Up) : nameof(IMigration.Down); + var allowedMigrationNames = migrations.Select(t => t.GetType().Name); + + var workspace = CreateRoslynWorkspace(pathToMigrationProj); + var project = workspace.CurrentSolution.Projects.Single(prj => prj.FilePath == pathToMigrationProj); + + var compilation = project.GetCompilationAsync().Result; + + var finder = new MigrationMethodsFinder(allowedMigrationNames, methodName); + var collectionNames = new List(); + foreach (var file in project.Documents) + { + var tree = file.GetSyntaxTreeAsync().Result; + var methods = finder.FindMethods(tree.GetRoot()); + if (methods.Any()) + { + var model = compilation.GetSemanticModel(tree); + collectionNames.AddRange(methods.SelectMany(item => FindCollectionNames(model, item))); + } + } + return Check(database, collectionNames); + } + + /// + /// Search collection names in migration method taking into account the list of method markers. + /// + /// Semantic model of migration class + /// Syntax node of migration method (Up or Down) + /// + protected virtual IEnumerable FindCollectionNames(SemanticModel semanticModel, SyntaxNode node) + { + var arguments = node + .DescendantNodes() + .OfType() + .Where(sn => MethodMarkers.Contains(semanticModel.GetSymbolInfo(sn).Symbol.Name)) + .SelectMany(sn => sn + .DescendantNodes() + .OfType() + .Where(lit => lit.IsKind(SyntaxKind.StringLiteralExpression)) + .Select(lit => lit.GetText().ToString())); + + return arguments.Distinct().Select(x => x.Trim('"')); + } + + /// + /// Create the Roslyn workspace for you project + /// + /// Absolute path to the *.csproj or project.json file + /// Roslyn workspace + protected virtual Workspace CreateRoslynWorkspace(string projLocation) + { + var manager = new AnalyzerManager(); + var analyzer = manager.GetProject(projLocation); + return analyzer.GetWorkspace(); + } + + private SchemeValidationResult Check(IMongoDatabase database, IEnumerable names) + { + var result = new SchemeValidationResult(); + foreach (var name in names.Distinct()) + { + bool isFailed = false; + var collection = database.GetCollection(name); + + if (collection == null || collection.CountDocuments(FilterDefinition.Empty) == 0) + continue; + + var doc = collection.Find(FilterDefinition.Empty) + .First(); + + var refScheme = doc.Elements.ToDictionary(i => i.Name, i => i.Value.BsonType); + + var cursor = collection.Find(FilterDefinition.Empty).ToCursor(); + while (cursor.MoveNext()) + { + if (isFailed) + break; + + IEnumerable batch = cursor.Current; + foreach (var document in batch) + { + isFailed = !document.Elements.ToDictionary(i => i.Name, i => i.Value.BsonType).SequenceEqual(refScheme); + } + } + + result.Add(name, isFailed); + } + + return result; + } + } +} diff --git a/MongoDBMigrations/Core/RoslynWalkers/MongoCollectionCallInMigrationWallker.cs b/MongoDBMigrations/Core/RoslynWalkers/MongoCollectionCallInMigrationWallker.cs new file mode 100644 index 0000000..3c5944c --- /dev/null +++ b/MongoDBMigrations/Core/RoslynWalkers/MongoCollectionCallInMigrationWallker.cs @@ -0,0 +1,56 @@ +using System; +using System.Linq; +using System.Collections.Generic; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace MongoDBMigrations.Core +{ + public class MigrationMethodsFinder : CSharpSyntaxWalker + { + private IEnumerable _allowedMigrationNames; + private string _methodName; + private List _classes = new List(); + private List _methods = new List(); + + public MigrationMethodsFinder(IEnumerable allowedMigrationNames, string methodName) + { + _allowedMigrationNames = allowedMigrationNames; + _methodName = methodName; + } + + public List FindMethods(SyntaxNode node) + { + base.Visit(node); + var result = new List(_methods); + _classes.Clear(); + _methods.Clear(); + return result; + } + + public override void VisitClassDeclaration(ClassDeclarationSyntax node) + { + if (_allowedMigrationNames.Contains(node.Identifier.ValueText)) + { + _classes.Add(node); + } + base.VisitClassDeclaration(node); + } + + public override void VisitMethodDeclaration(MethodDeclarationSyntax node) + { + if (!(node.Parent is ClassDeclarationSyntax classNode) + || _classes.Any(cn => cn.Identifier.ValueText != classNode.Identifier.ValueText)) + { + return; + } + + if (node.Identifier.ValueText == _methodName) + { + _methods.Add(node); + } + base.VisitMethodDeclaration(node); + } + } +} diff --git a/MongoDBMigrations/Document/MongoEmulationEnum.cs b/MongoDBMigrations/Document/MongoEmulationEnum.cs new file mode 100644 index 0000000..4834c10 --- /dev/null +++ b/MongoDBMigrations/Document/MongoEmulationEnum.cs @@ -0,0 +1,9 @@ +namespace MongoDBMigrations.Document +{ + public enum MongoEmulationEnum + { + None, + AzureCosmos, + AwsDocument + } +} diff --git a/MongoDBMigrations/Document/ServerAdressConfig.cs b/MongoDBMigrations/Document/ServerAdressConfig.cs new file mode 100644 index 0000000..df8472a --- /dev/null +++ b/MongoDBMigrations/Document/ServerAdressConfig.cs @@ -0,0 +1,39 @@ +using System; + +namespace MongoDBMigrations.Document +{ + public class ServerAdressConfig + { + private string _host; + private uint _port = 0; + + public string Host + { + get { return _host; } + set + { + if (string.IsNullOrEmpty(value)) + throw new ArgumentNullException(nameof(value)); + + _host = value; + } + } + + public uint Port + { + get { return _port; } + set + { + if (value > 65535) + throw new ArgumentOutOfRangeException("Port number must greater than 65535"); + + _port = value; + } + } + + public int PortAsInt + { + get { return (int)_port; } + } + } +} diff --git a/MongoDBMigrations/Document/SpecificationItem.cs b/MongoDBMigrations/Document/SpecificationItem.cs new file mode 100644 index 0000000..a5bc413 --- /dev/null +++ b/MongoDBMigrations/Document/SpecificationItem.cs @@ -0,0 +1,26 @@ +using System; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; +using MongoDB.Bson.Serialization.IdGenerators; + +namespace MongoDBMigrations.Document +{ + public class SpecificationItem + { + [BsonId(IdGenerator = typeof(StringObjectIdGenerator))] + public string Id { get; set; } + + [BsonElement("n")] + public string Name { get; set; } + + [BsonElement("v")] + public Version Ver { get; set; } + + [BsonElement("d")] + public bool isUp { get; set; } + + [BsonElement("applied")] + [BsonDateTimeOptions(Kind = DateTimeKind.Utc, Representation = BsonType.DateTime)] + public DateTime ApplyingDateTime {get;set;} + } +} \ No newline at end of file diff --git a/MongoDBMigrations/Document/Version.cs b/MongoDBMigrations/Document/Version.cs new file mode 100644 index 0000000..c1ecbf6 --- /dev/null +++ b/MongoDBMigrations/Document/Version.cs @@ -0,0 +1,136 @@ +using System; + +namespace MongoDBMigrations +{ + /// + /// Semantic versioning + /// + public struct Version : IComparable + { + private const char VERSION_SPLITTER = '.'; + private const int MAX_LENGTH = 3; + public readonly int Major; + public readonly int Minor; + public readonly int Revision; + + + public static Version Zero() + { + return new Version(0, 0, 0); + } + + public Version(string version) + { + string[] parts = version.Split(VERSION_SPLITTER); + + if (parts.Length > MAX_LENGTH) + { + throw new VersionStringTooLongException(version); + } + + ParseVersionPart(parts[0], out Major); + ParseVersionPart(parts[1], out Minor); + ParseVersionPart(parts[2], out Revision); + } + + public Version(int major, int minor, int revision) + { + Major = major; + Minor = minor; + Revision = revision; + } + + public static implicit operator Version(string version) + { + return new Version(version); + } + + public static implicit operator string(Version version) + { + return version.ToString(); + } + + public override string ToString() + { + return $"{Major}.{Minor}.{Revision}"; + } + + #region Compare + public int CompareTo(Version other) + { + if (Equals(other)) + return 0; + + return this > other ? 1 : -1; + } + + public static bool operator ==(Version a, Version b) + { + return a.Equals(b); + } + + public static bool operator !=(Version a, Version b) + { + return !(a == b); + } + + public static bool operator >(Version a, Version b) + { + return a.Major > b.Major + || (a.Major == b.Major && a.Minor > b.Minor) + || (a.Major == b.Major && a.Minor == b.Minor && a.Revision > b.Revision); + } + + public static bool operator <(Version a, Version b) + { + return a != b && !(a > b); + } + + public static bool operator <=(Version a, Version b) + { + return a == b || a < b; + } + + public static bool operator >=(Version a, Version b) + { + return a == b || a > b; + } + + public bool Equals(Version other) + { + return other.Major == Major && other.Minor == Minor && other.Revision == Revision; + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) + return false; + + if (obj.GetType() != typeof(Version)) + return false; + + return Equals((Version)obj); + } + + public override int GetHashCode() + { + unchecked + { + int result = Major; + result = (result * 397) ^ Minor; + result = (result * 397) ^ Revision; + return result; + } + } + + #endregion + + private static void ParseVersionPart(string value, out int target) + { + if (!int.TryParse(value, out target)) + { + throw new InvalidVersionException(value); + } + } + } +} \ No newline at end of file diff --git a/MongoDBMigrations/Document/VersionSerializer.cs b/MongoDBMigrations/Document/VersionSerializer.cs new file mode 100644 index 0000000..3e61edb --- /dev/null +++ b/MongoDBMigrations/Document/VersionSerializer.cs @@ -0,0 +1,19 @@ +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; + +namespace MongoDBMigrations +{ + public class VerstionStructSerializer : SerializerBase + { + public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, Version value) + { + context.Writer.WriteString(value.ToString()); + } + + public override Version Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) + { + var ver = context.Reader.ReadString(); + return new Version(ver); + } + } +} \ No newline at end of file diff --git a/MongoDBMigrations/Exception/DatabaseOutdatedException.cs b/MongoDBMigrations/Exception/DatabaseOutdatedException.cs new file mode 100644 index 0000000..68b0fcc --- /dev/null +++ b/MongoDBMigrations/Exception/DatabaseOutdatedException.cs @@ -0,0 +1,11 @@ +using System; + +namespace MongoDBMigrations +{ + public class DatabaseOutdatedExcetion : Exception + { + public DatabaseOutdatedExcetion(Version databaseVersion, Version targetVersion) + : base(string.Format("Current database version: {0}. You must update database to {1}.", databaseVersion, targetVersion)) + { } + } +} \ No newline at end of file diff --git a/MongoDBMigrations/Exception/InvalidVersionException.cs b/MongoDBMigrations/Exception/InvalidVersionException.cs new file mode 100644 index 0000000..b7f0be6 --- /dev/null +++ b/MongoDBMigrations/Exception/InvalidVersionException.cs @@ -0,0 +1,11 @@ +using System; + +namespace MongoDBMigrations +{ + public class InvalidVersionException : Exception + { + public InvalidVersionException(string version) + : base(string.Format("Invalid value: {0}", version)) + {} + } +} \ No newline at end of file diff --git a/MongoDBMigrations/Exception/MigrationNotFoundException.cs b/MongoDBMigrations/Exception/MigrationNotFoundException.cs new file mode 100644 index 0000000..dbdb91d --- /dev/null +++ b/MongoDBMigrations/Exception/MigrationNotFoundException.cs @@ -0,0 +1,11 @@ +using System; + +namespace MongoDBMigrations +{ + public class MigrationNotFoundException : Exception + { + public MigrationNotFoundException(string assemblyName, Exception innerException) + : base(string.Format("Migrations are not found in assembly {0}", assemblyName), innerException) + {} + } +} \ No newline at end of file diff --git a/MongoDBMigrations/Exception/VersionStringTooLongException.cs b/MongoDBMigrations/Exception/VersionStringTooLongException.cs new file mode 100644 index 0000000..91aef60 --- /dev/null +++ b/MongoDBMigrations/Exception/VersionStringTooLongException.cs @@ -0,0 +1,11 @@ +using System; + +namespace MongoDBMigrations +{ + public class VersionStringTooLongException : Exception + { + public VersionStringTooLongException(string version) + : base(string.Format("Versions must have the format: major.minor.revision, this doesn't match: {0}", version)) + {} + } +} \ No newline at end of file diff --git a/MongoDBMigrations/MigrationEngine.cs b/MongoDBMigrations/MigrationEngine.cs new file mode 100644 index 0000000..4425320 --- /dev/null +++ b/MongoDBMigrations/MigrationEngine.cs @@ -0,0 +1,285 @@ +using System; +using System.Collections.Generic; +using MongoDB.Driver; +using MongoDB.Bson.Serialization; +using MongoDBMigrations.Core; +using System.Reflection; +using System.Threading; +using System.Linq; +using System.Threading.Tasks; +using MongoDBMigrations.Document; +using Renci.SshNet; +using System.IO; +using System.Security.Cryptography.X509Certificates; +using MongoDBMigrations.Core.Contracts; + +namespace MongoDBMigrations +{ + public sealed class MigrationEngine : ILocator, ISchemeValidation, IMigrationRunner + { + internal class SshConfig + { + public SshClient SshClient; + public ForwardedPortLocal ForwardedPortLocal; + public uint BoundPort; + public string BoundHost; + } + + private const string LOCALHOST = "127.0.0.1"; + + private IMongoDatabase _database; + private MigrationManager _locator; + private DatabaseManager _status; + private bool _schemeValidationNeeded; + private string _migrationProjectLocation; + private CancellationToken _token; + private readonly IList> _progressHandlers = new List>(); + private bool _useTransaction; + + private SshConfig _sshConfig; + private SslSettings _tlsSettings; + + static MigrationEngine() + { + BsonSerializer.RegisterSerializer(typeof(Version), new VerstionStructSerializer()); + } + + public ILocator UseDatabase(string connectionString, string databaseName, MongoEmulationEnum emulation = MongoEmulationEnum.None) + { + var setting = MongoClientSettings.FromConnectionString(connectionString); + var client = new MongoClient(setting); + return UseDatabase(client, databaseName, emulation); + } + + public ILocator UseDatabase(IMongoClient mongoClient, string databaseName, MongoEmulationEnum emulation = MongoEmulationEnum.None) + { + var database = mongoClient + .SetTls(_tlsSettings) + .SetSsh(_sshConfig) + .GetDatabase(databaseName); + return new MigrationEngine + { + _database = database, + _locator = new MigrationManager(), + _status = new DatabaseManager(database, emulation) + }; + } + + public ILocator UseTransaction() { + _useTransaction = true; + return this; + } + + private MigrationEngine EstablishConnectionViaSsh(SshClient client, ServerAdressConfig mongoAdress) + { + client.Connect(); + var forwardedPortLocal = new ForwardedPortLocal(LOCALHOST, mongoAdress.Host, mongoAdress.Port); + client.AddForwardedPort(forwardedPortLocal); + forwardedPortLocal.Start(); + + _sshConfig = new SshConfig + { + SshClient = client, + ForwardedPortLocal = forwardedPortLocal, + BoundPort = forwardedPortLocal.BoundPort, + BoundHost = LOCALHOST + }; + + return this; + } + + public MigrationEngine UseTls(X509Certificate2 certificate) + { + if (certificate == null) + throw new ArgumentNullException(nameof(certificate)); + + _tlsSettings = new SslSettings + { + ClientCertificates = new[] { certificate }, + }; + return this; + } + + public MigrationEngine UseSshTunnel(ServerAdressConfig sshAdress, string sshUser, string sshPassword, ServerAdressConfig mongoAdress) + { + return EstablishConnectionViaSsh(new SshClient(sshAdress.Host, sshAdress.PortAsInt, sshUser, sshPassword), mongoAdress); + } + + public MigrationEngine UseSshTunnel(ServerAdressConfig sshAdress, string sshUser, Stream privateKeyFileStream, ServerAdressConfig mongoAdress, string keyFilePassPhrase = null) + { + var keyFile = keyFilePassPhrase == null ? new PrivateKeyFile(privateKeyFileStream) : new PrivateKeyFile(privateKeyFileStream, keyFilePassPhrase); + return EstablishConnectionViaSsh(new SshClient(sshAdress.Host, sshAdress.PortAsInt, sshUser, new[] { keyFile }), mongoAdress); + } + + public IMigrationRunner UseProgressHandler(Action action) + { + if (action == null) + throw new ArgumentNullException(nameof(action)); + + this._progressHandlers.Add(action); + + return this; + } + + private MigrationResult RunInternal(Version version) + { + try + { + var currentDatabaseVersion = _status.GetVersion(); + var migrations = _locator.GetMigrations(currentDatabaseVersion, version); + + var result = new MigrationResult + { + ServerAdress = string.Join(",", _database.Client.Settings.Servers), + DatabaseName = _database.DatabaseNamespace.DatabaseName, + InterimSteps = new List(), + Success = true + }; + + if (!migrations.Any()) + { + result.CurrentVersion = currentDatabaseVersion; + return result; + } + + if (_token.IsCancellationRequested) + { + _token.ThrowIfCancellationRequested(); + } + + var isUp = version > currentDatabaseVersion; + + if (_schemeValidationNeeded) + { + var validator = new MongoSchemeValidator(); + var validationResult = validator.Validate(migrations, isUp, _migrationProjectLocation, _database); + if (validationResult.FailedCollections.Any()) + { + result.Success = false; + var failedCollections = string.Join(Environment.NewLine, validationResult.FailedCollections); + throw new InvalidOperationException($"Some schema validation issues found in: {failedCollections}"); + } + } + + int counter = 0; + var session = _useTransaction ? (IMigrationSession) new MongoMigrationSession(_database.Client) : new SimpleMigrationSession(); + + using (var transaction = session.BeginTransaction()) { + var context = new MigrationContext(_database, transaction.Session, _token); + foreach (var m in migrations) { + if (_token.IsCancellationRequested) { + result.Success = false; + _token.ThrowIfCancellationRequested(); + } + + counter++; + var increment = new InterimMigrationResult(); + + try { + if (isUp) + m.Up(context); + else + m.Down(context); + + var insertedMigration = _status.SaveMigration(m, isUp, transaction.Session); + + increment.MigrationName = insertedMigration.Name; + increment.TargetVersion = insertedMigration.Ver; + increment.ServerAdress = result.ServerAdress; + increment.DatabaseName = result.DatabaseName; + increment.CurrentNumber = counter; + increment.TotalCount = migrations.Count; + result.InterimSteps.Add(increment); + } + catch (Exception ex) { + result.Success = false; + throw new InvalidOperationException("Something went wrong during migration", ex); + } + finally { + foreach (var action in _progressHandlers) { + action(increment); + } + result.CurrentVersion = _status.GetVersion(); + } + } + transaction.Commit(_token); + } + return result; + } + finally + { + if (_sshConfig != null && _sshConfig.SshClient.IsConnected) + { + _sshConfig.SshClient.Dispose(); + _sshConfig.ForwardedPortLocal.Dispose(); + } + } + } + + public MigrationResult Run(Version version) => + _token.CanBeCanceled + ? Task.Factory.StartNew(() => RunInternal(version), _token) + .ConfigureAwait(false) + .GetAwaiter() + .GetResult() + : RunInternal(version); + + public MigrationResult Run() + { + var targetVersion = this._locator.GetNewestLocalVersion(); + return Run(targetVersion); + } + + public ISchemeValidation UseAssembly(Assembly assembly) + { + if (assembly == null) + throw new ArgumentNullException(nameof(assembly)); + this._locator.SetAssembly(assembly); + return this; + } + + public ISchemeValidation UseAssemblyOfType(Type type) + { + if (type == null) + throw new ArgumentNullException(nameof(type)); + this._locator.LookInAssemblyOfType(type); + return this; + } + + public ISchemeValidation UseAssemblyOfType() + { + this._locator.LookInAssemblyOfType(); + return this; + } + + public IMigrationRunner UseCancelationToken(CancellationToken token) + { + if (!token.CanBeCanceled) + throw new ArgumentException($"Invalid token or it's canceled already.", nameof(token)); + this._token = token; + return this; + } + + public IMigrationRunner UseSchemeValidation(bool enabled, string location) + { + this._schemeValidationNeeded = enabled; + if (enabled) + { + if (string.IsNullOrEmpty(location)) + throw new ArgumentNullException(nameof(location)); + this._migrationProjectLocation = location; + } + return this; + } + + public IMigrationRunner UseCustomSpecificationCollectionName(string name) + { + if (string.IsNullOrEmpty(name)) + throw new ArgumentNullException(nameof(name)); + _status.SpecCollectionName = name; + + + return this; + } + } +} \ No newline at end of file diff --git a/MongoDBMigrations/MongoDBMigrations.csproj b/MongoDBMigrations/MongoDBMigrations.csproj new file mode 100644 index 0000000..a298844 --- /dev/null +++ b/MongoDBMigrations/MongoDBMigrations.csproj @@ -0,0 +1,67 @@ + + + + netstandard2.0 + + Library + + Cactusoft + Arthur Osmokiesku, Cactusoft + Copyright ©2018-2025 Arthur Osmokiesku, Cactusoft + https://github.com/cactusoft-ca/MongoDBMigrations + https://github.com/cactusoft-ca/MongoDBMigrations + git + Cactusoft.MongoDBMigrations + MongoDbMigrations uses the official MongoDB C# Driver to migrate your documents in + your mongo database via useful fluent API. + Supports up and down migrations with cancelation and progress handling. Also, this library is + able to check a schema of collections in your database during the migration run. + This version supports on-premise Mongo database either Azure CosmosDB (with Mongo-like API) or + even AWS DocumentDB. In addition you can use TLS and/or SHH tunnels in your migrations. + PS1 script for integration with CI/CD pipelines provides inside of the repository + mongo mongodb migration schema-migration csharp dotnet schema migrator + database-migration database .net fluent api ci/cd azure cosmos cosmosdb aws documentdb + + 2.2.0 + https://github.com/cactusoft-ca/MongoDBMigrations/blob/main/ReleaseNotes.md + 2.2.0.0 + 2.2.0.0 + MIT.md + true + 2.2.0 + false + + + + + all + + + all + + + all + + + all + + + all + + + + + + + + + + + + + True + + + + + \ No newline at end of file diff --git a/MongoDBMigrations/MongoDatabaseStateChecker.cs b/MongoDBMigrations/MongoDatabaseStateChecker.cs new file mode 100644 index 0000000..867b372 --- /dev/null +++ b/MongoDBMigrations/MongoDatabaseStateChecker.cs @@ -0,0 +1,37 @@ +using System.Reflection; +using MongoDB.Driver; +using MongoDBMigrations.Document; + +namespace MongoDBMigrations +{ + public static class MongoDatabaseStateChecker + { + public static void ThrowIfDatabaseOutdated(string connectionString, string databaseName, Assembly migrationAssambly = null, MongoEmulationEnum emulation = MongoEmulationEnum.None) + { + var (dbVersion, availableVersion) = GetCurrentVersions(connectionString, databaseName, migrationAssambly, emulation); + if (availableVersion > dbVersion) + throw new DatabaseOutdatedExcetion(dbVersion, availableVersion); + } + + public static bool IsDatabaseOutdated(string connectionString, string databaseName, Assembly migrationAssambly = null, MongoEmulationEnum emulation = MongoEmulationEnum.None) + { + var (dbVersion, availableVersion) = GetCurrentVersions(connectionString, databaseName, migrationAssambly, emulation); + return availableVersion > dbVersion; + } + + private static (Version dbVersion, Version availableVersion) GetCurrentVersions(string connectionString, string databaseName, Assembly migrationAssambly, MongoEmulationEnum emulation) + { + var locator = new MigrationManager(); + if(migrationAssambly != null) + { + locator.SetAssembly(migrationAssambly); + } + var highestAvailableVersion = locator.GetNewestLocalVersion(); + + var dbStatus = new DatabaseManager(new MongoClient(connectionString).GetDatabase(databaseName), emulation); + var currectDbVersion = dbStatus.GetVersion(); + + return (currectDbVersion, highestAvailableVersion); + } + } +} diff --git a/MongoDBRunMigration.ps1 b/MongoDBRunMigration.ps1 new file mode 100644 index 0000000..b8ea1a1 --- /dev/null +++ b/MongoDBRunMigration.ps1 @@ -0,0 +1,24 @@ +param($connectionString, $databaseName, $backupLocation, $migrationsAssemblyPath) + +#Make a backup of database +$backupLocation = Join-Path $backupLocation (Get-Date -f yyyy_MM_dd_HH_mm_ss) +mongodump --host="`"$connectionString`"" --db="`"$databaseName`"" --out="`"$backupLocation`"" + +#Load assembly with migrations and migration engine +$migrationAssembly = [System.Reflection.Assembly]::LoadFrom($migrationsAssemblyPath) +$migrationEnginePath = Join-Path([System.IO.Path]::GetDirectoryName($migrationsAssemblyPath)) 'MongoDBMigrations.dll' +[System.Reflection.Assembly]::LoadFrom($migrationEnginePath) + +#Start migration +try { + $engine = New-Object MongoDBMigrations.MigrationEngine + $engine.UseDatabase($connectionString, $databaseName).UseAssembly($migrationAssembly).UseSchemaValidation(0).Run() +} +catch [System.Exception] { + #Attemp restore on failure + Write-Output "Migration failed: " + Write-Host $_.Exception.ToString(); + Write-Output "Attempting restore from " + $backupLocation + mongorestore --host="`"$connectionString`"" --dir="`"$backupLocation`"" --db="`"$databaseName`"" --drop + throw +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..f2500dc --- /dev/null +++ b/README.md @@ -0,0 +1,194 @@ + +# MongoDBMigrations [![NuGet](https://img.shields.io/badge/nuget%20package-v2.2.0-brightgreen.svg)](https://www.nuget.org/packages/MongoDBMigrations/) + +You can support me in the development of this useful library. I have big plans you can find them in todo below. I will appreciate pizza and beer;) + +[Just follow the link, and donate me any amount you want :)](https://send.monobank.ua/PRwBX6QKN) + +MongoDBMigrations using the official [MongoDB C# Driver](https://github.com/mongodb/mongo-csharp-driver) to migrate your documents in database. This library supports on-premis Mongo instances, Azure CosmosDB (MongoAPI) and AWS DocumentDB. + +No more downtime for schema-migrations. Just write small and simple `migrations`. + +We need migrations when: + +**1.** Rename collections + +**2.** Rename keys + +**3.** Manipulate data types + +**4.** Index manipulation + +**5.** Removing collections / data + + + +### New Features! +- Added: SSH support +- Added: TLS/SSL support (experimental feature) +- Added: Custom specification collection name +- Added: New overload for the method `UseDatabase` +- Changed: Upgraded to the newest version of .NET Mongo Driver +- Chnaged: Fixed [list of bugs](https://bitbucket.org/i_am_a_kernel/mongodbmigrations/issues?version=v2.1.0) + +- [See more...](https://bitbucket.org/i_am_a_kernel/mongodbmigrations/src/master/ReleaseNotes.md) + +### Contribution +Guys, unfortunately, I can't spend much time on this project, that is way **since now you are able to create a pull request** and develop some features, which can be useful. I will review that requests and merge them. Please don't forget about unit tests :joy: I hope this step will speed up the evolution of this project. And just to set a vector below I specified a list of preferred features for the community: + +- Support replicas +- Migration inside transsaction +- Check the availability to work with Mongo Atlas +- Extend functionality for scheme validator (base implementation is already in place) + +### Next Feature/Todo +- Diff calculation +- Detailed migration report +- Auto generated migrations + +### Installation +MongoDBMigrations tested with .NET Core 2.0+ +https://www.nuget.org/packages/MongoDBMigrations/ + +``` + +PM> Install-Package MongoDBMigrations -Version 2.2.0 + +``` + +### How to use + +Create a migration by implementing the interface `IMigration`. The best practice for the version is to use [Semantic Versioning](http://semver.org/) but ultimately it is up to you. You could simply use the patch version to count the number of migrations. If there is a duplicate for a specific type an exception is thrown on initialization. + +This is a simple migration template. Method Up is used to migrate your database forward and Down to rollback thus these methods must do the opposite things. Please keep it in mind. You can use any version number greater than `0.0.0`. In case you already have some migrations you should choose a version upper than the existing ones. + + + +```csharp + +//Create migration + +public class MyTestMigration : IMigration + +{ + +public MongoDBMigrations.Version Version => new MongoDBMigrations.Version(1, 1, 0); + +public string Name => "Some descrioption about this migration."; + +public void Up(IMongoDatabase database) + +{ + +// ... + +} + +public void Down(IMongoDatabase database) + +{ + +// ... + +} + +} + +``` + +#### It is really easy to use this library, just follow all these steps below (you should use *one or more* methods from each step): +|Step #0|Step #1|Step #2|Step #3|Step #4|Step #5| +|:---|:---|:---|:---|:---|:---| +|Create an engine|Database, connection features|Migration classes| Validations|Hadling features|Excecution +|`new MigrationEngine()`|`UseSshTunnel(...)` `UseTls(...)` `UseDatabase(...)`|`UseAssemblyOfType(...)` `UseAssemblyOfType()` `UseAssembly(...)`|`UseSchemeValidation(...)`| `UseProgressHandler(...)` `UseCancelationToken(...)` `UseCustomSpecificationCollectionName(...)`|`Run()`| + + +Use the following code for initialize `MigrationEngine` and start migration. + +```csharp + +new MigrationEngine() + +.UseSshTunnel(sshServerAdress, user, privateKeyFileStream, mongoAdress, keyFilePassPhrase) //Use if you want to connect to your DB via SSH tunel. keyFilePassPhrase is optional. + +.UseTls(cert) //Use if your database requires TLS. Please use X509Certificate2 instance as a cert value + +.UseDatabase(connectionString, databaseName) //Required to use specific db + +.UseAssembly(assemblyWithMigrations) //Required + +.UseSchemeValidation(bool, string) //Optional if you want to ensure that all documents in collections, that will be affected in the current run, has a consistent structure. Set a true and absolute path to *.csproj file with migration classes or just false. + +.UseCancelationToken(token) //Optional if you wanna have the possibility to cancel the migration process. Might be useful when you have many migrations and some interaction with the user. + +.UseProgressHandler(Action<> action) // Optional some delegate that will be called each migration + +.Run(targetVersion) // Execution call. Might be called without targetVersion, in that case, the engine will choose the latest available version. + +``` + +**In case if the handler does not found and validation has failed** - migration process will be canceled automatically. +If you haven't tested your migration yet, mark it with `IgnoreMigration` attribute, and the runner will skip it. +You can't check if the database is outdated by dint of static class `MongoDatabaseStateChecker` + + + +| Method | Description | +| :--- | :--- | +| `ThrowIfDatabaseOutdated(connectionString, databaseName, migrationAssambly, emulation)` | Check is DB outdated and throw `DatabaseOutdatedExcetion` if yes. MigrationAssambly is optional. If not set method will find migration in executing assembly. Emulation has a `None` value by default for Mongo databases, but you should use the `AzureCosmos` option in case of Azure Cosmos DB | +|`IsDatabaseOutdated(connectionString, databaseName, migrationAssambly, emulation)`|Returns `true` if DB outdated (you have unapplied migrations) otherwise `false`. MigrationAssambly is optional. If not set method will find migration in executing assembly. Emulation has a `None` value by default for Mongo databases, but you should use the `AzureCosmos` option in case of Azure Cosmos DB| + + + +#### Azure CosmosDB support +Begins from `v2.1.0` this library supports databases in Azure CosmosDB service. There might be two cases: +* You haven't use this library before. No manual action needed, everithing will work ok. +* You already have some executed migrations with earlier version of this library. In this case you should ensure that you have an ascending index for filed `applied` in `_migrations` collection. If you don't have this index please create them prior you strart the migration run. + +#### AWS DocumentDB support +Bagins from `v2.2.0` this library supports databases in AWS DocumentDB service. + + + +#### CI/CD +Now you have a chance to integrate the mongo database migration engine in your CI pipeline. In repository you can found `MongoDBRunMigration.ps1` script. This approach allows you to have some backup rollback in case of any failure during migration. + +Call the following commands prior to using this PS1 file: + +```ps1 + +Set-Alias mongodump + +Set-Alias mongorestore + +``` + +Paths should lead to executable files (*.exe). Please, modify the PS1 file if you have any authorization in your database. + + + +|Parameter|Description| +|:---|:---| +|connectionString|Database connection string e.g. localhost:27017| +|databaseName|Name of the database| +|backupLocation|Folder for the backup that will be created befor migration| +|migrationsAssemblyPath|Path to the assembly with migration classes| + + +---- +Tips + +1. Use **{migrationVerstion}_{migrationName}.cs** pattern of you migration classes. +2. Save your migrations in non-production assemblies and use the method `LookInAssemblyOfType()` of `MigratiotionLocator` to finding them. +3. Keep migrations as simple as possible +4. Do not couple migrations to your domain types, they will be brittle to change, and the point of migration is to update the data representation when your model changes. +5. Stick to the mongo BsonDocument interface or use javascript based mongo commands for migrations, much like with SQL, the mongo javascript API is less likely to change which might break migrations +6. Add an application startup check that the database is at the correct version +7. Write tests of your migrations, TDD them from existing data scenarios to new forms. Use `IgnoreMigration`attribute while WIP. +8. Automate the deployment of migrations + +---- +License +MongoDbMigrations is licensed under [MIT](https://bitbucket.org/i_am_a_kernel/mongodbmigrations/src/master/MIT.md "Read more about the MIT license form"). Refer to license.txt for more information. + +**Free Software, Hell Yeah!** \ No newline at end of file diff --git a/ReleaseNotes.md b/ReleaseNotes.md new file mode 100644 index 0000000..bd16588 --- /dev/null +++ b/ReleaseNotes.md @@ -0,0 +1,65 @@ +# MongoDBMigrations + +### v2.2.0 + - Added: SSH support + - Added: TLS/SSL support (experimental feature) + - Added: Custom specification collection name + - Added: New overloads for the method `UseDatabase` + - Changed: Upgrade to the newest version of .NET Mongo Driver + - Chnaged: Fixed [list of bugs](https://bitbucket.org/i_am_a_kernel/mongodbmigrations/issues?version=v2.1.0) + +### v2.1.0 + - Added: Azure CosmosDB support + - Added: Increased C# Mongo Driver API coverage in schema validator feature + - Changed: Fixed CI/CD script + - Changed: Updated dependencies + - Changed: Fixed [list of bugs](https://bitbucket.org/i_am_a_kernel/mongodbmigrations/issues?version=v2.0.0) + +*** + +### v2.0.0 + - Added: Totaly brand new fluent API + - Added: Callback for steps + - Added: On-flight cancelation + - Added: PowerShell script that can be integrated in CI/CD flow to make migration + - Removed: All obsolete APIs + - Removed: Async version of methods + - Removed: Confiramtion event in case of failed database scheeme validation (Now if validations failed migration process will be stoped) + - Fixed: Some amount of bugs + - Fixed: All test has been refactored to increase quality of library. + +*** + +### v1.1.2 + - Added: Ignore migration attribute + - Fixed: Supporting migrations with version less then 1.0.0 + - Fixed: Critical bugs + +*** + +### v1.1.1 + - Change target framework from netcoreapp2.1 to netstandard2.0 + +*** + +### v1.1.0 + - Added: MongoDB document schema uniformity validation + - Added: Async impl for runner and database locator + - Added: Progress returning mechanism + - Added: Cancelation mechanism + +*** + +### v1.0.1 + - Fixed: Search assemble with migrations when method `LookInAssemblyOfType()` doesn't used + - Fixed: Runner crash when `runner.UpdateTo()` called without result handling + - Fixed: Behavior when target migration not found + - Added: Testable migrations + - Added: Overload for `LookInAssemblyOfType` method + - Added: Fields in `MigrationResult` for progress handling + +*** + +### v1.0.0 + - Roll forward/back manual created migrations + - Auto find migrations in assemblies for migration beetwen current and target versions. \ No newline at end of file diff --git a/bitbucket-pipelines.yml b/bitbucket-pipelines.yml new file mode 100644 index 0000000..7cdb5eb --- /dev/null +++ b/bitbucket-pipelines.yml @@ -0,0 +1,12 @@ +image: microsoft/dotnet:2.1-sdk +pipelines: + custom: + nuget: + - step: + caches: + - dotnetcore + script: + - dotnet restore + - dotnet build --configuration Release + - dotnet pack MongoDBMigrations/MongoDBMigrations.csproj --configuration ${BUILD_CONFIGURATION} + - dotnet nuget push MongoDBMigrations/bin/${BUILD_CONFIGURATION}/*.nupkg -k ${MYGET_NUGET_APIKEY} -s https://api.nuget.org/v3/index.json \ No newline at end of file