diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f70829e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +[*.csproj] +indent_style = space +indent_size = 2 + +[*.cs] +indent_style = space +indent_size = 4 + +# Visual Studio + +# IDE0130: Namespace does not match folder structure +dotnet_diagnostic.IDE0130.severity = none + +# IDE0290: Use primary constructor +dotnet_diagnostic.IDE0290.severity = none \ No newline at end of file diff --git a/.github/workflows/github-actions-ci.yaml b/.github/workflows/github-actions-ci.yaml new file mode 100644 index 0000000..7a499a8 --- /dev/null +++ b/.github/workflows/github-actions-ci.yaml @@ -0,0 +1,49 @@ +name: Continuous Integration + +on: + pull_request: + branches: [ "main" ] + push: + branches: [ "releases/**" ] + +jobs: + build: + runs-on: ubuntu-latest + services: + sqlserver: + image: mcr.microsoft.com/mssql/server:2022-latest + env: + SA_PASSWORD: "P@ssw0rd12345!" + ACCEPT_EULA: "Y" + ports: + - 1433:1433 + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Restore dependencies + run: dotnet restore PosInformatique.Database.Updater.sln + + - name: Build solution + run: dotnet build PosInformatique.Database.Updater.sln --configuration Release --no-restore + + - name: Run tests + run: | + dotnet test PosInformatique.Database.Updater.sln \ + --configuration Release \ + --no-build \ + --logger "trx;LogFileName=test_results.trx" \ + --results-directory ./TestResults + env: + DATABASE_UPDATER_UNIT_TESTS_CONNECTION_STRING: "Data Source=localhost,1433;Database=master;User Id=sa;Password=P@ssw0rd12345!;TrustServerCertificate=True;" + + - name: Publish Test Results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: (!cancelled()) + with: + files: | + TestResults/**/*.trx diff --git a/.github/workflows/github-actions-release.yml b/.github/workflows/github-actions-release.yml new file mode 100644 index 0000000..3ab803b --- /dev/null +++ b/.github/workflows/github-actions-release.yml @@ -0,0 +1,36 @@ +name: Release + +on: + workflow_dispatch: + inputs: + VersionPrefix: + type: string + description: The version of the library + required: true + default: 1.0.0 + VersionSuffix: + type: string + description: The version suffix of the library (for example rc.1) + +run-name: ${{ inputs.VersionSuffix && format('{0}-{1}', inputs.VersionPrefix, inputs.VersionSuffix) || inputs.VersionPrefix }} + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 8.x + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.x' + + - name: Build PosInformatique.Database.Updater + run: dotnet pack + --property:Configuration=Release + --property:VersionPrefix=${{ github.event.inputs.VersionPrefix }} + --property:VersionSuffix=${{ github.event.inputs.VersionSuffix }} + "src/Database.Updater/Database.Updater.csproj" + + - name: Publish the package to nuget.org + run: dotnet nuget push "src/**/bin/Release/*.nupkg" --api-key "${{ secrets.NUGET_APIKEY }}" --source https://api.nuget.org/v3/index.json --skip-duplicate diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..1178601 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,49 @@ + + + + + Gilles TOURREAU + P.O.S Informatique + P.O.S Informatique + Copyright (c) P.O.S Informatique. All rights reserved. + https://github.com/PosInformatique/PosInformatique.Database.Updater + git + + + latest + + + enable + + + false + + + $(NoWarn);SA0001 + + + PosInformatique.$(MSBuildProjectName) + PosInformatique.$(MSBuildProjectName.Replace(" ", "_")) + + + + + stylecop.json + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..7e851cf --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,18 @@ + + + true + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/PosInformatique.Database.Updater.sln b/PosInformatique.Database.Updater.sln new file mode 100644 index 0000000..210b30b --- /dev/null +++ b/PosInformatique.Database.Updater.sln @@ -0,0 +1,91 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36408.4 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Database.Updater", "src\Database.Updater\Database.Updater.csproj", "{961D82E3-18B9-4CB4-B290-8146D874AB39}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + .gitignore = .gitignore + Directory.Build.props = Directory.Build.props + Directory.Packages.props = Directory.Packages.props + LICENSE = LICENSE + README.md = README.md + stylecop.json = stylecop.json + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Database.Updater.Tests", "tests\Database.Updater.Tests\Database.Updater.Tests.csproj", "{8788FC9A-8CAE-4A45-8832-18E29AF38838}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Database.Updater.IntegrationTests", "tests\Database.Updater.IntegrationTests\Database.Updater.IntegrationTests.csproj", "{64B1BA81-69E9-AE03-F57A-BE4A0B20A9E8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Database.Updater.Tests.MigrationsAssembly", "tests\Database.Updater.Tests.MigrationsAssembly\Database.Updater.Tests.MigrationsAssembly.csproj", "{FFFBF4C0-674C-EA8E-7714-76A92E6973E3}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{DF126F11-BF03-4B7E-B2F2-3A23E7FE6BF0}" + ProjectSection(SolutionItems) = preProject + tests\.editorconfig = tests\.editorconfig + tests\Directory.Build.props = tests\Directory.Build.props + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{BF73C1D5-EF4C-4F34-9944-0D74667BE6A8}" + ProjectSection(SolutionItems) = preProject + src\Directory.Build.props = src\Directory.Build.props + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{E9100678-42A3-461A-B8F0-DF12DA892979}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Database.Updater.Tests.MigrationsErrorAssembly", "tests\Database.Updater.Tests.MigrationsErrorAssembly\Database.Updater.Tests.MigrationsErrorAssembly.csproj", "{6C0F0292-EB9E-EB63-C1D8-1C8B63E59FC8}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{25236396-6913-4183-B770-631C6FDACA15}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{20A16198-31B4-4155-AE36-AB1942F382A6}" + ProjectSection(SolutionItems) = preProject + .github\workflows\github-actions-ci.yaml = .github\workflows\github-actions-ci.yaml + .github\workflows\github-actions-release.yml = .github\workflows\github-actions-release.yml + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {961D82E3-18B9-4CB4-B290-8146D874AB39}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {961D82E3-18B9-4CB4-B290-8146D874AB39}.Debug|Any CPU.Build.0 = Debug|Any CPU + {961D82E3-18B9-4CB4-B290-8146D874AB39}.Release|Any CPU.ActiveCfg = Release|Any CPU + {961D82E3-18B9-4CB4-B290-8146D874AB39}.Release|Any CPU.Build.0 = Release|Any CPU + {8788FC9A-8CAE-4A45-8832-18E29AF38838}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8788FC9A-8CAE-4A45-8832-18E29AF38838}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8788FC9A-8CAE-4A45-8832-18E29AF38838}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8788FC9A-8CAE-4A45-8832-18E29AF38838}.Release|Any CPU.Build.0 = Release|Any CPU + {64B1BA81-69E9-AE03-F57A-BE4A0B20A9E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {64B1BA81-69E9-AE03-F57A-BE4A0B20A9E8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {64B1BA81-69E9-AE03-F57A-BE4A0B20A9E8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {64B1BA81-69E9-AE03-F57A-BE4A0B20A9E8}.Release|Any CPU.Build.0 = Release|Any CPU + {FFFBF4C0-674C-EA8E-7714-76A92E6973E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FFFBF4C0-674C-EA8E-7714-76A92E6973E3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FFFBF4C0-674C-EA8E-7714-76A92E6973E3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FFFBF4C0-674C-EA8E-7714-76A92E6973E3}.Release|Any CPU.Build.0 = Release|Any CPU + {6C0F0292-EB9E-EB63-C1D8-1C8B63E59FC8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6C0F0292-EB9E-EB63-C1D8-1C8B63E59FC8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6C0F0292-EB9E-EB63-C1D8-1C8B63E59FC8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6C0F0292-EB9E-EB63-C1D8-1C8B63E59FC8}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {8788FC9A-8CAE-4A45-8832-18E29AF38838} = {E9100678-42A3-461A-B8F0-DF12DA892979} + {64B1BA81-69E9-AE03-F57A-BE4A0B20A9E8} = {E9100678-42A3-461A-B8F0-DF12DA892979} + {FFFBF4C0-674C-EA8E-7714-76A92E6973E3} = {E9100678-42A3-461A-B8F0-DF12DA892979} + {DF126F11-BF03-4B7E-B2F2-3A23E7FE6BF0} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {BF73C1D5-EF4C-4F34-9944-0D74667BE6A8} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {6C0F0292-EB9E-EB63-C1D8-1C8B63E59FC8} = {E9100678-42A3-461A-B8F0-DF12DA892979} + {25236396-6913-4183-B770-631C6FDACA15} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {20A16198-31B4-4155-AE36-AB1942F382A6} = {25236396-6913-4183-B770-631C6FDACA15} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {30DF5A1B-3B00-40E8-90F0-FA6F5846B215} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md index eba06bf..12249d1 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,284 @@ # PosInformatique.Database.Updater -A simple console tool to apply Entity Framework Core migrations. Focused on SQL Server with support for AccessToken authentication. Outputs logs to the console, ideal for CI/CD scenarios. +[![NuGet](https://img.shields.io/nuget/v/PosInformatique.Database.Updater)](https://www.nuget.org/packages/PosInformatique.Database.Updater/) +[![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Database.Updater)](https://www.nuget.org/packages/PosInformatique.Database.Updater/) +[![License](https://img.shields.io/github/license/Nonanti/MathFlow?style=flat-square)](LICENSE) +[![Build Status](https://img.shields.io/github/actions/workflow/status/PosInformatique/PosInformatique.Database.Updater/github-actions-ci.yaml?style=flat-square)](https://github.com/PosInformatique/PosInformatique.Database.Updater/actions) +[![.NET 8.0+](https://img.shields.io/badge/.NET-8.0%2B-512BD4?style=flat-square)](https://learn.microsoft.com/en-us/dotnet/standard/net-standard?tabs=net-standard-2-0) + +A tiny console-oriented helper to run Entity Framework Core migrations in a predictable, CI/CD-friendly way. +It parses a simple command line, executes pending migrations against your target database, and can optionally throw +on error for strict pipelines. + +## Installing from NuGet + +- Library: [PosInformatique.Database.Updater](https://www.nuget.org/packages/PosInformatique.Database.Updater/) +- Install with: +```bash +dotnet add package PosInformatique.Database.Updater +``` + +## Why use this? + +- Clean separation of concerns: keep migrations in a small console app dedicated to database updates, invoked by your CD pipeline. +- Consistent CLI: same arguments locally and in CI/CD, using standard .NET command-line syntax. +- EF Core under the hood: executes your existing EF Core migrations generated by developers. +- Entra ID token support: pass an access token for Entra ID authentication (perfect for Azure Pipelines). +- Logging support: uses the standard .NET logging abstraction, including EF Core logs, and writes to standard output. + +## Example usage + +Just create a simple console application: + +```csharp +public static class Program +{ + public static async Task Main(string[] args) + { + var updater = new DatabaseUpdaterBuilder("MyApplication") + .UseSqlServer() + .UseMigrationsAssembly(typeof(Program).Assembly) + .Build(); + + return await updater.UpgradeAsync(args); + } +} +``` + +The code above runs the Entity Framework Core migrations located in the same assembly as the `Program` class. + +Developers can run this application locally to upgrade the database, or integrate it into a CD pipeline when deploying +an application. + +## API usage + +The `DatabaseUpdaterBuilder` creates an internal `IHost`, allowing developers to configure additional services. + +After configuring the `DatabaseUpdaterBuilder`, call `Build()` to get an `IDatabaseUpdater`, then +call `IDatabaseUpdater.UpgradeAsync(string[])` to run the migration process with the `args` from `Main()`. + +`IDatabaseUpdater.UpgradeAsync(string[])` returns an `int` error code that you can return from `Main()` and use in calling +scripts (e.g., `%ERRORLEVEL%`). + +### Name of the application + +When instantiating `DatabaseUpdaterBuilder`, pass your application name. +This is only used for help/diagnostics in the command-line output and has no impact on the migration process. + +For example: +```csharp +var updater = new DatabaseUpdaterBuilder("MyApplication"); +``` + +### Specify the database provider + +Currently, only SQL Server is supported. To use SQL Server, call `UseSqlServer()` during builder setup. + +For example: +```csharp +var updater = new DatabaseUpdaterBuilder("MyApplication") + .UseSqlServer() + // ... + .Build(); +``` + +### Specify the assembly that contains Entity Framework Core migrations + +To point to the assembly that contains your EF Core migrations, call `UseMigrationsAssembly()` with the appropriate `Assembly`. + +For example: +```csharp +var updater = new DatabaseUpdaterBuilder("MyApplication") + .UseSqlServer() + .UseMigrationsAssembly(typeof(Program).Assembly) + // ... + .Build(); +``` + +### Logging configuration + +To configure logging produced by `IDatabaseUpdater`, call `ConfigureLogging()` during builder setup. +This provides an `ILoggingBuilder` to configure the .NET logging infrastructure. + +For example: +```csharp +var updater = new DatabaseUpdaterBuilder("MyApplication") + .UseSqlServer() + .UseMigrationsAssembly(typeof(Program).Assembly) + .ConfigureLogging(builder => + { + builder.AddJsonConsole(); + }) + // ... + .Build(); +``` + +### Configure options of the updater + +To configure updater options, call `Configure()` during builder setup. +This provides a `DatabaseUpdaterOptions` instance to control behavior. + +For example: +```csharp +var updater = new DatabaseUpdaterBuilder("MyApplication") + .UseSqlServer() + .UseMigrationsAssembly(typeof(Program).Assembly) + .Configure(opt => + { + opt.ThrowExceptionOnError = true; + }) + // ... + .Build(); +``` + +## Parsed command line + +When calling `IDatabaseUpdater.UpgradeAsync()` with the `args` from the command line, the following syntax is parsed: + +```cmd +dotnet run "" [--access-token ""] [--command-timeout ] +``` + +- `connection-string` + - Description: The connection string to the database to upgrade. It is recommended to wrap it in double quotes. + - Required: Yes + - Example: `dotnet run my-updater.dll "Server=tcp:myserver.database.windows.net,1433;Initial Catalog=mydb;Encrypt=True;"` + +- Option: `--access-token` + - Description: Access token to connect to Azure SQL using Entra ID. + - Required: No + - Example: `dotnet run my-updater.dll "Server=tcp:myserver.database.windows.net,1433;Initial Catalog=mydb;Encrypt=True;" --access-token "xxxxxxxx"` + +- Option: `--command-timeout` + - Description: Maximum time in seconds to execute each SQL statement. If not specified, the default is 30 seconds. + Use this to extend timeouts for long-running migrations. + - Required: No + - Example: `dotnet run my-updater.dll "Server=tcp:myserver.database.windows.net,1433;Initial Catalog=mydb;Encrypt=True;" --command-timeout 600` + +### Error code returned + +`IDatabaseUpdater.UpgradeAsync()` returns the following error codes: + +| Error code | Description | +| ---------- | ----------- | +| 0 | The database upgrade completed successfully. | +| 1 | An invalid command-line argument was provided. | +| 99 | An exception occurred during the upgrade process (when `DatabaseUpdaterOptions.ThrowExceptionOnError = false`). | +| -532462766 | Standard .NET unhandled exception code (when `DatabaseUpdaterOptions.ThrowExceptionOnError = true`). | + +> We recommend returning the error code from `IDatabaseUpdater.UpgradeAsync()` directly from `Main()`. + +## Advanced scenarios + +### Throw an exception when an error occurs + +By default, when an exception occurs (timeout, SQL syntax error, etc.), no exception is propagated. The exception is logged and `IDatabaseUpdater.UpgradeAsync()` returns error code `99`. + +To propagate exceptions, set `DatabaseUpdaterOptions.ThrowExceptionOnError` to `true`. + +```csharp +var updater = new DatabaseUpdaterBuilder("MyApplication") + .UseSqlServer() + .UseMigrationsAssembly(typeof(Program).Assembly) + .Configure(opt => + { + opt.ThrowExceptionOnError = true; // Throw instead of returning error code 99. + }) + // ... + .Build(); +``` + +### Increase the timeout for SQL command execution + +During the upgrade, some SQL commands can take a long time—especially DML on large tables or DDL that rebuilds indexes. + +To increase the per-command timeout, use the optional `--command-timeout` argument: + +```cmd +dotnet run my-updater.dll "Server=tcp:myserver.database.windows.net,1433;Initial Catalog=mydb;Encrypt=True;" --command-timeout 600 +``` + +> Do not confuse the SQL command execution timeout with the SQL connection timeout (used to connect to SQL Server). +To change the connection timeout, set `Connection Timeout` in the connection string. +Example: `"Server=tcp:myserver.database.windows.net,1433;Initial Catalog=mydb;Encrypt=True;Connection Timeout=600"` + +## Typical CI/CD flow (Azure Pipelines) + +Recommended practice: keep migrations in a dedicated console app that references your DbContext and migrations assembly. +Your release pipeline calls this console to upgrade the database. + +Example Azure Pipelines YAML: + +```yaml +trigger: +- main + +pool: + vmImage: ubuntu-latest + +steps: +- task: UseDotNet@2 + inputs: + packageType: 'sdk' + version: '8.x' + +- script: dotnet restore + displayName: Restore + +- script: dotnet build -c Release + displayName: Build + +# Acquire an Entra ID token for Azure SQL (adjust resource if needed) +- task: AzureCLI@2 + displayName: Get Azure SQL access token + inputs: + azureSubscription: 'Your-Service-Connection' + scriptType: 'bash' + scriptLocation: 'inlineScript' + inlineScript: | + TOKEN=$(az account get-access-token --resource https://database.windows.net/ --query accessToken -o tsv) + echo "##vso[task.setvariable variable=SQL_ACCESS_TOKEN;issecret=true]$TOKEN" + +# Run the updater console +- script: | + dotnet run --project src/YourUpdaterConsole/YourUpdaterConsole.csproj -- \ + "Server=tcp:$(sql_server),1433;Initial Catalog=$(sql_database);Encrypt=True;" \ + --access-token "$(SQL_ACCESS_TOKEN)" \ + --command-timeout 600 + displayName: Run database updater +``` + +## Recommendations + +- Use `--access-token` with Entra ID to avoid embedding credentials +(you can use SQL authentication with username/password, but ensure secrets are stored securely, e.g., in Azure Key Vault). +- Set `ThrowExceptionOnError = true` for CI runs to fail fast on migration errors, display the stack trace in Azure Pipelines + output, and enable developers to hit exceptions directly in Visual Studio during development. +- Keep the updater console small and focused; it should reference the migrations assembly via `.UseMigrationsAssembly(...)` + or include the migrations itself. + +## Requirements + +- .NET 8.0 or later. +- EF Core 8.0 or later. +- SQL Server is currently the only supported provider. + +### Provider support + +- Supported: SQL Server. +- Roadmap: Other providers may be added in the future; for now, only SQL Server is available. + +# Links + +## PosInformatique.Testing.Databases + +If you want to test (assert) your database migrations, consider using +[PosInformatique.Testing.Databases](https://github.com/PosInformatique/PosInformatique.Testing.Databases). +This library provides tools for database unit/integration tests and includes a comparer to verify the migration state of a database. + +## System.CommandLine + +This tool uses Microsoft .NET [System.CommandLine](https://learn.microsoft.com/en-us/dotnet/standard/commandline/) library, which standardizes command-line syntax for .NET tools. + +# Contribute + +If you need additional switches, providers, or features, feel free to open an issue or PR. \ No newline at end of file diff --git a/src/Database.Updater/Database.Updater.csproj b/src/Database.Updater/Database.Updater.csproj new file mode 100644 index 0000000..5237ce1 --- /dev/null +++ b/src/Database.Updater/Database.Updater.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + True + + A simple console tool to apply Entity Framework Core migrations. Focused on SQL Server with support for AccessToken authentication. Outputs logs to the console, ideal for CI/CD scenarios. + database sqlserver efcore migration ci cd console + + + + + + + + + + + + + + + + + + diff --git a/src/Database.Updater/DatabaseConnectionStringArgument.cs b/src/Database.Updater/DatabaseConnectionStringArgument.cs new file mode 100644 index 0000000..eea00fc --- /dev/null +++ b/src/Database.Updater/DatabaseConnectionStringArgument.cs @@ -0,0 +1,37 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Database.Updater +{ + using System.CommandLine; + using System.CommandLine.Parsing; + using System.Diagnostics.CodeAnalysis; + + [ExcludeFromCodeCoverage] + internal class DatabaseConnectionStringArgument : Argument + { + private readonly IDatabaseProvider databaseProvider; + + public DatabaseConnectionStringArgument(IDatabaseProvider databaseProvider, string name) + : base(name) + { + this.databaseProvider = databaseProvider; + + this.Validators.Add(this.Validate); + } + + private void Validate(ArgumentResult result) + { + var connectionString = result.GetValue(this); + var validationResult = this.databaseProvider.ValidateConnectionString(connectionString!, this.Name); + + if (validationResult is not null) + { + result.AddError(validationResult); + } + } + } +} diff --git a/src/Database.Updater/DatabaseMigrationContext.cs b/src/Database.Updater/DatabaseMigrationContext.cs new file mode 100644 index 0000000..b42cb47 --- /dev/null +++ b/src/Database.Updater/DatabaseMigrationContext.cs @@ -0,0 +1,27 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Database.Updater +{ + using System.Collections.ObjectModel; + + internal sealed class DatabaseMigrationContext : IDatabaseMigrationContext + { + public DatabaseMigrationContext(string connectionString, IList assemblies) + { + this.Assemblies = new ReadOnlyCollection(assemblies); + this.ConnectionString = connectionString; + } + + public string? AccessToken { get; set; } + + public ReadOnlyCollection Assemblies { get; } + + public int CommandTimeout { get; set; } + + public string ConnectionString { get; } + } +} diff --git a/src/Database.Updater/DatabaseUpdaterBuilder.cs b/src/Database.Updater/DatabaseUpdaterBuilder.cs new file mode 100644 index 0000000..4c2ff09 --- /dev/null +++ b/src/Database.Updater/DatabaseUpdaterBuilder.cs @@ -0,0 +1,265 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Database.Updater +{ + using System.CommandLine; + using System.Reflection; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Data.SqlClient; + using Microsoft.EntityFrameworkCore.Migrations; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Hosting; + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Options; + using PosInformatique.Database.Updater.SqlServer; + + /// + /// Allows to setup builder which will create instance + /// to perform the migration of the database. + /// + /// By default the will configure and create instances + /// with the following behavior: + /// + /// It will use the calling assembly as the assembly which contains the to execute if the methods is not used. + /// It will use SQL Server provider for the migration. + /// + /// + /// + /// + public sealed class DatabaseUpdaterBuilder + { + private static readonly int DefaultCommandTimeout = new SqlConnectionStringBuilder().CommandTimeout; + + private readonly string applicationName; + + private readonly Assembly callingAssembly; + + private readonly List migrationsAssemblies; + + private readonly IHostBuilder hostBuilder; + + /// + /// Initializes a new instance of the class. + /// + /// Name of the application which the database will be updated for. + /// If the specified argument is . + /// If the specified argument is empty or contains only white spaces. + public DatabaseUpdaterBuilder(string applicationName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(applicationName); + + this.callingAssembly = Assembly.GetCallingAssembly(); + + this.applicationName = applicationName; + this.migrationsAssemblies = new List(); + + this.hostBuilder = Host.CreateDefaultBuilder(); + this.hostBuilder.ConfigureServices(services => + { + services.AddSingleton(); + }); + } + + /// + /// Configures the options of the database upgrade process. + /// + /// Callback which allows to configure the options of the database upgrade process. + /// The current instance to continue the configuration. + public DatabaseUpdaterBuilder Configure(Action options) + { + this.hostBuilder.ConfigureServices(services => + { + services.Configure(options); + }); + + return this; + } + + /// + /// Configures the logging for the upgrade database operation. + /// Use the to capture the logs in memory. + /// + /// which allows to configure the logging. + /// The current instance to continue the configuration. + public DatabaseUpdaterBuilder ConfigureLogging(Action builder) + { + this.hostBuilder.ConfigureLogging(builder); + + return this; + } + + /// + /// Use a specific Entity Framework Core assembly which contains the to execute. + /// + /// Name of the assembly which contains the to execute. + /// The current instance to continue the configuration. + public DatabaseUpdaterBuilder UseMigrationsAssembly(string assembly) + { + this.migrationsAssemblies.Add(assembly); + + return this; + } + + /// + /// Use a specific Entity Framework Core assembly which contains the to execute. + /// + /// which contains the to execute. + /// The current instance to continue the configuration. + public DatabaseUpdaterBuilder UseMigrationsAssembly(Assembly assembly) + { + return this.UseMigrationsAssembly(assembly.GetName().Name!); + } + + /// + /// Builds an instance of the to perform the migration of the database. + /// + /// An instance of the to perform the migration of the database. + /// No database provider has been configured. + public IDatabaseUpdater Build() + { + return new CommandLineDatabaseUpdater(this); + } + + internal DatabaseUpdaterBuilder UseDatabaseProvider() + where TDatabaseProvider : class, IDatabaseProvider + { + this.hostBuilder.ConfigureServices(services => + { + services.AddSingleton(); + }); + + return this; + } + + private sealed class CommandLineDatabaseUpdater : IDatabaseUpdater + { + private readonly DatabaseUpdaterBuilder builder; + + private readonly RootCommand commandLine; + + private readonly DatabaseConnectionStringArgument connectionStringArgument; + + private readonly Option accessTokenOption; + + private readonly Option commandTimeoutOption; + + private IHost? host; + + public CommandLineDatabaseUpdater(DatabaseUpdaterBuilder builder) + { + this.builder = builder; + this.host = builder.hostBuilder.Build(); + + var databaseProvider = this.host.Services.GetService(); + + if (databaseProvider == null) + { + throw new InvalidOperationException("No database provider has been configured."); + } + + this.commandLine = new RootCommand($"Upgrade the {this.builder.applicationName} database."); + + // Connection string argument + this.connectionStringArgument = new DatabaseConnectionStringArgument(databaseProvider, "connection-string") + { + Description = "The connection string to the database to upgrade", + }; + + this.commandLine.Add(this.connectionStringArgument); + + // Access token option "--access-token" + this.accessTokenOption = new Option("--access-token") + { + Description = "Access token to connect to the SQL database.", + Required = false, + }; + + this.commandLine.Options.Add(this.accessTokenOption); + + // Command timeout option "--command-timeout" + this.commandTimeoutOption = new Option("--command-timeout") + { + DefaultValueFactory = _ => DefaultCommandTimeout, + Description = "Maximum time in seconds to execute each SQL statements.", + Required = false, + }; + + this.commandLine.Options.Add(this.commandTimeoutOption); + + this.commandLine.SetAction(this.ExecuteMigrationAsync); + } + + public void Dispose() + { + if (this.host is not null) + { + this.host.Dispose(); + this.host = null; + } + } + + public async Task UpgradeAsync(IReadOnlyList args, CancellationToken cancellationToken = default) + { + var parseResult = this.commandLine.Parse(args); + + var invocationConfiguration = new InvocationConfiguration() { EnableDefaultExceptionHandler = false }; + + return await parseResult.InvokeAsync(invocationConfiguration, cancellationToken: cancellationToken); + } + + private async Task ExecuteMigrationAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(this.host is null, this); + + var logger = this.host.Services.GetRequiredService>(); + + try + { + await this.host.StartAsync(cancellationToken); + + // Gets the migration assembly and add the current assembly if not specified. + var migrationsAssemblies = this.builder.migrationsAssemblies.ToList(); + + if (migrationsAssemblies.Count == 0) + { + migrationsAssemblies.Add(this.builder.callingAssembly.GetName().Name!); + } + + var context = new DatabaseMigrationContext( + parseResult.GetRequiredValue(this.connectionStringArgument), + migrationsAssemblies) + { + AccessToken = parseResult.GetValue(this.accessTokenOption), + CommandTimeout = parseResult.GetValue(this.commandTimeoutOption), + }; + + var migrationEngine = this.host.Services.GetRequiredService(); + + return await migrationEngine.UpgradeAsync(context, cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, exception.Message); + + var options = this.host.Services.GetRequiredService>(); + + if (options.Value.ThrowExceptionOnError) + { + throw; + } + + return 99; + } + finally + { + await this.host.StopAsync(cancellationToken); + } + } + } + } +} diff --git a/src/Database.Updater/DatabaseUpdaterOptions.cs b/src/Database.Updater/DatabaseUpdaterOptions.cs new file mode 100644 index 0000000..54c7d7a --- /dev/null +++ b/src/Database.Updater/DatabaseUpdaterOptions.cs @@ -0,0 +1,20 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Database.Updater +{ + /// + /// Options for the . + /// + public class DatabaseUpdaterOptions + { + /// + /// Gets or sets a value indicating whether an exception should be thrown if an error occurs during the migration + /// instead to return an error code. + /// + public bool ThrowExceptionOnError { get; set; } + } +} diff --git a/src/Database.Updater/EntityFrameworkDatabaseMigrationEngine.cs b/src/Database.Updater/EntityFrameworkDatabaseMigrationEngine.cs new file mode 100644 index 0000000..5b2b19c --- /dev/null +++ b/src/Database.Updater/EntityFrameworkDatabaseMigrationEngine.cs @@ -0,0 +1,41 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Database.Updater +{ + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Logging; + + internal sealed class EntityFrameworkDatabaseMigrationEngine : IDatabaseMigrationEngine + { + private readonly IDatabaseProvider databaseProvider; + + private readonly ILoggerFactory loggerFactory; + + public EntityFrameworkDatabaseMigrationEngine(IDatabaseProvider databaseProvider, ILoggerFactory loggerFactory) + { + this.databaseProvider = databaseProvider; + this.loggerFactory = loggerFactory; + } + + public async Task UpgradeAsync(IDatabaseMigrationContext context, CancellationToken cancellationToken = default) + { + using (var connection = this.databaseProvider.CreateConnection(context)) + { + var builder = this.databaseProvider.CreateDbContextOptionsBuilder(connection, context); + + builder.UseLoggerFactory(this.loggerFactory); + + using (var dbContext = new DbContext(builder.Options)) + { + await dbContext.Database.MigrateAsync(cancellationToken); + } + } + + return 0; + } + } +} diff --git a/src/Database.Updater/IDatabaseMigrationContext.cs b/src/Database.Updater/IDatabaseMigrationContext.cs new file mode 100644 index 0000000..d517900 --- /dev/null +++ b/src/Database.Updater/IDatabaseMigrationContext.cs @@ -0,0 +1,36 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Database.Updater +{ + using System.Collections.ObjectModel; + + /// + /// Represents the context of the current database migration performed by the . + /// + internal interface IDatabaseMigrationContext + { + /// + /// Gets the access token (if need) used to authenticate on database server. + /// + string? AccessToken { get; } + + /// + /// Gets the assemblies which contains the Entity Framework Core migrations to execute. + /// + ReadOnlyCollection Assemblies { get; } + + /// + /// Gets the timeout allowed to a SQL command to be executed. + /// + int CommandTimeout { get; } + + /// + /// Gets the connection string to the database to upgrade. + /// + string ConnectionString { get; } + } +} diff --git a/src/Database.Updater/IDatabaseMigrationEngine.cs b/src/Database.Updater/IDatabaseMigrationEngine.cs new file mode 100644 index 0000000..d72f4c9 --- /dev/null +++ b/src/Database.Updater/IDatabaseMigrationEngine.cs @@ -0,0 +1,22 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Database.Updater +{ + /// + /// Represents the engine which will perform the migration of the database. + /// + internal interface IDatabaseMigrationEngine + { + /// + /// Performs the migration to the database. + /// + /// Migration database context. + /// which allows to cancel the migration. + /// A instance which represents the asynchronous operation and contains the exit code of the application. + Task UpgradeAsync(IDatabaseMigrationContext context, CancellationToken cancellationToken = default); + } +} diff --git a/src/Database.Updater/IDatabaseProvider.cs b/src/Database.Updater/IDatabaseProvider.cs new file mode 100644 index 0000000..dcef04f --- /dev/null +++ b/src/Database.Updater/IDatabaseProvider.cs @@ -0,0 +1,42 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Database.Updater +{ + using System.Data.Common; + using Microsoft.EntityFrameworkCore; + + /// + /// Represents a database provider. + /// + internal interface IDatabaseProvider + { + /// + /// Creates a to the database. + /// + /// Migration database context. + /// The which allows to connect to the database. + DbConnection CreateConnection(IDatabaseMigrationContext migrationContext); + + /// + /// Creates an instance of the to create a + /// which will be used for the Entity Framework migrations. + /// + /// to the database. + /// Migration database context. + /// An instance of the to create a + /// which will be used for the Entity Framework migrations. + DbContextOptionsBuilder CreateDbContextOptionsBuilder(DbConnection connection, IDatabaseMigrationContext migrationContext); + + /// + /// Validates the specified connection string. + /// + /// Connection string to validate. + /// Command line argument which contains the connection string to validate. + /// An error message if the is invalid. in otherwise. + string? ValidateConnectionString(string connectionString, string argumentName); + } +} diff --git a/src/Database.Updater/IDatabaseUpdater.cs b/src/Database.Updater/IDatabaseUpdater.cs new file mode 100644 index 0000000..036118d --- /dev/null +++ b/src/Database.Updater/IDatabaseUpdater.cs @@ -0,0 +1,22 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Database.Updater +{ + /// + /// Allows to perform the migration of the database. + /// + public interface IDatabaseUpdater : IDisposable + { + /// + /// Performs the migration of the database. + /// + /// Command line argument of the migration tool. + /// which allows to cancel the asynchronous operation. + /// An instance of which represents the asynchronous operation. + Task UpgradeAsync(IReadOnlyList args, CancellationToken cancellationToken = default); + } +} diff --git a/src/Database.Updater/Icon.png b/src/Database.Updater/Icon.png new file mode 100644 index 0000000..ca541c4 Binary files /dev/null and b/src/Database.Updater/Icon.png differ diff --git a/src/Database.Updater/Logging/InMemoryLoggingProvider.cs b/src/Database.Updater/Logging/InMemoryLoggingProvider.cs new file mode 100644 index 0000000..53a0ac2 --- /dev/null +++ b/src/Database.Updater/Logging/InMemoryLoggingProvider.cs @@ -0,0 +1,93 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Database.Updater +{ + using Microsoft.Extensions.Logging; + + /// + /// Implementation of the which stores the log messages in memory. + /// The logs are accessible through the property. + /// + public class InMemoryLoggingProvider : ILoggerProvider + { + private TextWriter? output; + + /// + /// Initializes a new instance of the class. + /// + public InMemoryLoggingProvider() + { + this.output = new StringWriter(); + } + + /// + /// Gets the logs output. + /// + public string Output + { + get + { + ObjectDisposedException.ThrowIf(this.output == null, this); + + return this.output.ToString()!; + } + } + + /// + public ILogger CreateLogger(string categoryName) + { + ObjectDisposedException.ThrowIf(this.output == null, this); + + return new StringDumpLogger(this, categoryName); + } + + /// + public void Dispose() + { + if (this.output is not null) + { + this.output.Dispose(); + this.output = null; + } + } + + private sealed class StringDumpLogger : ILogger + { + private readonly InMemoryLoggingProvider provider; + + private readonly string categoryName; + + public StringDumpLogger(InMemoryLoggingProvider provider, string categoryName) + { + this.provider = provider; + this.categoryName = categoryName; + } + + public IDisposable? BeginScope(TState state) + where TState : notnull + { + ObjectDisposedException.ThrowIf(this.provider.output == null, this.provider); + + return null; + } + + public bool IsEnabled(LogLevel logLevel) + { + ObjectDisposedException.ThrowIf(this.provider.output == null, this.provider); + + return true; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + ObjectDisposedException.ThrowIf(this.provider.output == null, this.provider); + + this.provider.output.WriteLine($"[{this.categoryName}] ({logLevel}) : {formatter(state, exception)}"); + } + } + } +} diff --git a/src/Database.Updater/SqlServer/SqlServerDatabaseProvider.cs b/src/Database.Updater/SqlServer/SqlServerDatabaseProvider.cs new file mode 100644 index 0000000..7973348 --- /dev/null +++ b/src/Database.Updater/SqlServer/SqlServerDatabaseProvider.cs @@ -0,0 +1,63 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Database.Updater.SqlServer +{ + using System.Data.Common; + using Microsoft.Data.SqlClient; + using Microsoft.EntityFrameworkCore; + + internal sealed class SqlServerDatabaseProvider : IDatabaseProvider + { + public SqlServerDatabaseProvider() + { + } + + public DbConnection CreateConnection(IDatabaseMigrationContext migrationContext) + { + var connectionStringBuilder = new SqlConnectionStringBuilder(migrationContext.ConnectionString); + connectionStringBuilder.CommandTimeout = migrationContext.CommandTimeout; + + return new SqlConnection(connectionStringBuilder.ToString()) + { + AccessToken = migrationContext.AccessToken, + }; + } + + public DbContextOptionsBuilder CreateDbContextOptionsBuilder(DbConnection connection, IDatabaseMigrationContext migrationContext) + { + return new DbContextOptionsBuilder().UseSqlServer( + connection, + opt => + { + foreach (var assembly in migrationContext.Assemblies) + { + opt.MigrationsAssembly(assembly); + } + + opt.CommandTimeout(migrationContext.CommandTimeout); + }); + } + + public string? ValidateConnectionString(string connectionString, string argumentName) + { + try + { +#pragma warning disable S1848 // Objects should not be created to be dropped immediately without being used +#pragma warning disable CA1806 // Do not ignore method results + new SqlConnectionStringBuilder(connectionString); +#pragma warning restore CA1806 // Do not ignore method results +#pragma warning restore S1848 // Objects should not be created to be dropped immediately without being used + } + catch (ArgumentException) + { + return $"The SQL Server connection string specified in the '{argumentName}' argument is invalid."; + } + + return null; + } + } +} diff --git a/src/Database.Updater/SqlServer/SqlServerDatabaseUpdaterBuilderExtensions.cs b/src/Database.Updater/SqlServer/SqlServerDatabaseUpdaterBuilderExtensions.cs new file mode 100644 index 0000000..480c8fc --- /dev/null +++ b/src/Database.Updater/SqlServer/SqlServerDatabaseUpdaterBuilderExtensions.cs @@ -0,0 +1,26 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Database.Updater +{ + using PosInformatique.Database.Updater.SqlServer; + + /// + /// Contains extensions methods for the class to use SQL Server database provider. + /// + public static class SqlServerDatabaseUpdaterBuilderExtensions + { + /// + /// Configures the to use SQL Server database provider. + /// + /// to configure. + /// The instance to continue the configuration. + public static DatabaseUpdaterBuilder UseSqlServer(this DatabaseUpdaterBuilder builder) + { + return builder.UseDatabaseProvider(); + } + } +} diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..1d9e55f --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,28 @@ + + + + + + + enable + + Icon.png + https://github.com/PosInformatique/PosInformatique.Database.Updater + README.md + MIT + + 1.0.0 + - Initial version + + + + + + + <_Parameter1>$(AssemblyName).Tests + + + <_Parameter1>DynamicProxyGenAssembly2 + + + \ No newline at end of file diff --git a/stylecop.json b/stylecop.json new file mode 100644 index 0000000..b747ad1 --- /dev/null +++ b/stylecop.json @@ -0,0 +1,9 @@ +{ + "settings": { + "documentationRules": { + "companyName": "P.O.S Informatique", + "copyrightText": "Copyright (c) {companyName}. All rights reserved.", + "documentInternalElements": false + } + } +} \ No newline at end of file diff --git a/tests/.editorconfig b/tests/.editorconfig new file mode 100644 index 0000000..c54730c --- /dev/null +++ b/tests/.editorconfig @@ -0,0 +1,12 @@ +[*.cs] + +# StyleCop + +# SA1600: Elements should be documented +dotnet_diagnostic.SA1600.severity = none + +# SA1601: Partial elements should be documented +dotnet_diagnostic.SA1601.severity = none + +# SA1602: Enumeration items should be documented +dotnet_diagnostic.SA1602.severity = none \ No newline at end of file diff --git a/tests/Database.Updater.IntegrationTests/Database.Updater.IntegrationTests.csproj b/tests/Database.Updater.IntegrationTests/Database.Updater.IntegrationTests.csproj new file mode 100644 index 0000000..6fecf8e --- /dev/null +++ b/tests/Database.Updater.IntegrationTests/Database.Updater.IntegrationTests.csproj @@ -0,0 +1,13 @@ + + + + Exe + net8.0 + + + + + + + + diff --git a/tests/Database.Updater.IntegrationTests/Program.cs b/tests/Database.Updater.IntegrationTests/Program.cs new file mode 100644 index 0000000..b9a0f7a --- /dev/null +++ b/tests/Database.Updater.IntegrationTests/Program.cs @@ -0,0 +1,23 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Database.Updater.IntegrationTests +{ + internal class Program + { + public static async Task Main(string[] args) + { + var databaseUpdaterBuilder = new DatabaseUpdaterBuilder("MyApplication"); + + var updater = databaseUpdaterBuilder + .UseSqlServer() + .UseMigrationsAssembly(typeof(MigrationsAssembly.PersonDbContext).Assembly) + .Build(); + + await updater.UpgradeAsync(args); + } + } +} diff --git a/tests/Database.Updater.IntegrationTests/Properties/launchSettings.json b/tests/Database.Updater.IntegrationTests/Properties/launchSettings.json new file mode 100644 index 0000000..910d6fc --- /dev/null +++ b/tests/Database.Updater.IntegrationTests/Properties/launchSettings.json @@ -0,0 +1,16 @@ +{ + "profiles": { + "OK": { + "commandName": "Project", + "commandLineArgs": "\"Data Source=(localDB)\\posinfo-tests; Initial Catalog=IntegrationTests; Integrated Security=True\"" + }, + "InvalidConnectionString": { + "commandName": "Project", + "commandLineArgs": "\"Data Source=(localDB)\\notexists; Initial Catalog=IntegrationTests; Integrated Security=True\"" + }, + "NoArguments": { + "commandName": "Project", + "commandLineArgs": "" + } + } +} \ No newline at end of file diff --git a/tests/Database.Updater.Tests.MigrationsAssembly/Database.Updater.Tests.MigrationsAssembly.csproj b/tests/Database.Updater.Tests.MigrationsAssembly/Database.Updater.Tests.MigrationsAssembly.csproj new file mode 100644 index 0000000..42631ed --- /dev/null +++ b/tests/Database.Updater.Tests.MigrationsAssembly/Database.Updater.Tests.MigrationsAssembly.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/tests/Database.Updater.Tests.MigrationsAssembly/DbContext/Person.cs b/tests/Database.Updater.Tests.MigrationsAssembly/DbContext/Person.cs new file mode 100644 index 0000000..91589fb --- /dev/null +++ b/tests/Database.Updater.Tests.MigrationsAssembly/DbContext/Person.cs @@ -0,0 +1,17 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Database.Updater.MigrationsAssembly +{ + public class Person + { + public int Id { get; set; } + + public string Name { get; set; } + + public bool? IsActive { get; set; } + } +} diff --git a/tests/Database.Updater.Tests.MigrationsAssembly/DbContext/PersonDbContext.cs b/tests/Database.Updater.Tests.MigrationsAssembly/DbContext/PersonDbContext.cs new file mode 100644 index 0000000..0f4c40f --- /dev/null +++ b/tests/Database.Updater.Tests.MigrationsAssembly/DbContext/PersonDbContext.cs @@ -0,0 +1,28 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Database.Updater.MigrationsAssembly +{ + using Microsoft.EntityFrameworkCore; + + public class PersonDbContext : DbContext + { + public PersonDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .ToTable("Person") + .Property(p => p.Name) + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false); + } + } +} diff --git a/tests/Database.Updater.Tests.MigrationsAssembly/DbContext/PersonDbContextFactory.cs b/tests/Database.Updater.Tests.MigrationsAssembly/DbContext/PersonDbContextFactory.cs new file mode 100644 index 0000000..e3c10ab --- /dev/null +++ b/tests/Database.Updater.Tests.MigrationsAssembly/DbContext/PersonDbContextFactory.cs @@ -0,0 +1,22 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Database.Updater.MigrationsAssembly +{ + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Design; + + internal sealed class PersonDbContextFactory : IDesignTimeDbContextFactory + { + public PersonDbContext CreateDbContext(string[] args) + { + var options = new DbContextOptionsBuilder() + .UseSqlServer(b => b.MigrationsAssembly(this.GetType().Assembly.GetName().Name)); + + return new PersonDbContext(options.Options); + } + } +} \ No newline at end of file diff --git a/tests/Database.Updater.Tests.MigrationsAssembly/Migrations/PersonDbContextModelSnapshot.cs b/tests/Database.Updater.Tests.MigrationsAssembly/Migrations/PersonDbContextModelSnapshot.cs new file mode 100644 index 0000000..9cd241e --- /dev/null +++ b/tests/Database.Updater.Tests.MigrationsAssembly/Migrations/PersonDbContextModelSnapshot.cs @@ -0,0 +1,47 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace PosInformatique.Database.Updater.MigrationsAssembly +{ + [DbContext(typeof(PersonDbContext))] + partial class PersonDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("PosInformatique.Database.Updater.MigrationsAssembly.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.HasKey("Id"); + + b.ToTable("Person", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/tests/Database.Updater.Tests.MigrationsAssembly/Migrations/Version1.cs b/tests/Database.Updater.Tests.MigrationsAssembly/Migrations/Version1.cs new file mode 100644 index 0000000..f5682cc --- /dev/null +++ b/tests/Database.Updater.Tests.MigrationsAssembly/Migrations/Version1.cs @@ -0,0 +1,29 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Database.Updater.MigrationsAssembly +{ + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Infrastructure; + using Microsoft.EntityFrameworkCore.Migrations; + + [DbContext(typeof(DbContext))] + [Migration("Version1")] + public class Version1 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Person", + columns: table => new + { + Id = table.Column(type: "int", nullable: false), + Name = table.Column(type: "varchar(20)", unicode: false, maxLength: 20, nullable: false), + }); + } + } +} diff --git a/tests/Database.Updater.Tests.MigrationsAssembly/Migrations/Version2.cs b/tests/Database.Updater.Tests.MigrationsAssembly/Migrations/Version2.cs new file mode 100644 index 0000000..6ad0c0b --- /dev/null +++ b/tests/Database.Updater.Tests.MigrationsAssembly/Migrations/Version2.cs @@ -0,0 +1,27 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Database.Updater.MigrationsAssembly +{ + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Infrastructure; + using Microsoft.EntityFrameworkCore.Migrations; + + [DbContext(typeof(DbContext))] + [Migration("Version2")] + public class Version2 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsActive", + table: "Person", + type: "bit", + nullable: true); + } + } +} diff --git a/tests/Database.Updater.Tests.MigrationsErrorAssembly/Database.Updater.Tests.MigrationsErrorAssembly.csproj b/tests/Database.Updater.Tests.MigrationsErrorAssembly/Database.Updater.Tests.MigrationsErrorAssembly.csproj new file mode 100644 index 0000000..42631ed --- /dev/null +++ b/tests/Database.Updater.Tests.MigrationsErrorAssembly/Database.Updater.Tests.MigrationsErrorAssembly.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/tests/Database.Updater.Tests.MigrationsErrorAssembly/DbContext/Person.cs b/tests/Database.Updater.Tests.MigrationsErrorAssembly/DbContext/Person.cs new file mode 100644 index 0000000..643a05a --- /dev/null +++ b/tests/Database.Updater.Tests.MigrationsErrorAssembly/DbContext/Person.cs @@ -0,0 +1,17 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Database.Updater.MigrationsErrorAssembly +{ + public class Person + { + public int Id { get; set; } + + public string Name { get; set; } + + public bool? IsActive { get; set; } + } +} diff --git a/tests/Database.Updater.Tests.MigrationsErrorAssembly/DbContext/PersonDbContext.cs b/tests/Database.Updater.Tests.MigrationsErrorAssembly/DbContext/PersonDbContext.cs new file mode 100644 index 0000000..7cd75a3 --- /dev/null +++ b/tests/Database.Updater.Tests.MigrationsErrorAssembly/DbContext/PersonDbContext.cs @@ -0,0 +1,28 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Database.Updater.MigrationsErrorAssembly +{ + using Microsoft.EntityFrameworkCore; + + public class PersonDbContext : DbContext + { + public PersonDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .ToTable("Person") + .Property(p => p.Name) + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false); + } + } +} diff --git a/tests/Database.Updater.Tests.MigrationsErrorAssembly/DbContext/PersonDbContextFactory.cs b/tests/Database.Updater.Tests.MigrationsErrorAssembly/DbContext/PersonDbContextFactory.cs new file mode 100644 index 0000000..29066be --- /dev/null +++ b/tests/Database.Updater.Tests.MigrationsErrorAssembly/DbContext/PersonDbContextFactory.cs @@ -0,0 +1,22 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Database.Updater.MigrationsErrorAssembly +{ + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Design; + + internal sealed class PersonDbContextFactory : IDesignTimeDbContextFactory + { + public PersonDbContext CreateDbContext(string[] args) + { + var options = new DbContextOptionsBuilder() + .UseSqlServer(b => b.MigrationsAssembly(this.GetType().Assembly.GetName().Name)); + + return new PersonDbContext(options.Options); + } + } +} \ No newline at end of file diff --git a/tests/Database.Updater.Tests.MigrationsErrorAssembly/Migrations/PersonDbContextModelSnapshot.cs b/tests/Database.Updater.Tests.MigrationsErrorAssembly/Migrations/PersonDbContextModelSnapshot.cs new file mode 100644 index 0000000..82d5e25 --- /dev/null +++ b/tests/Database.Updater.Tests.MigrationsErrorAssembly/Migrations/PersonDbContextModelSnapshot.cs @@ -0,0 +1,47 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace PosInformatique.Database.Updater.MigrationsErrorAssembly +{ + [DbContext(typeof(PersonDbContext))] + partial class PersonDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("PosInformatique.Database.Updater.MigrationsAssembly.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.HasKey("Id"); + + b.ToTable("Person", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/tests/Database.Updater.Tests.MigrationsErrorAssembly/Migrations/Version1.cs b/tests/Database.Updater.Tests.MigrationsErrorAssembly/Migrations/Version1.cs new file mode 100644 index 0000000..fdf0d28 --- /dev/null +++ b/tests/Database.Updater.Tests.MigrationsErrorAssembly/Migrations/Version1.cs @@ -0,0 +1,29 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Database.Updater.MigrationsErrorAssembly +{ + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Infrastructure; + using Microsoft.EntityFrameworkCore.Migrations; + + [DbContext(typeof(DbContext))] + [Migration("Version1")] + public class Version1 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Person", + columns: table => new + { + Id = table.Column(type: "int", nullable: false), + Name = table.Column(type: "varchar(20)", unicode: false, maxLength: 20, nullable: false), + }); + } + } +} diff --git a/tests/Database.Updater.Tests.MigrationsErrorAssembly/Migrations/Version2.cs b/tests/Database.Updater.Tests.MigrationsErrorAssembly/Migrations/Version2.cs new file mode 100644 index 0000000..7a98c29 --- /dev/null +++ b/tests/Database.Updater.Tests.MigrationsErrorAssembly/Migrations/Version2.cs @@ -0,0 +1,29 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Database.Updater.MigrationsErrorAssembly +{ + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Infrastructure; + using Microsoft.EntityFrameworkCore.Migrations; + + [DbContext(typeof(DbContext))] + [Migration("Version2")] + public class Version2 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsActive", + table: "Person", + type: "bit", + nullable: true); + + throw new DivideByZeroException("Some errors occured during the migration..."); + } + } +} diff --git a/tests/Database.Updater.Tests/ConnectionStrings.cs b/tests/Database.Updater.Tests/ConnectionStrings.cs new file mode 100644 index 0000000..f8ea23d --- /dev/null +++ b/tests/Database.Updater.Tests/ConnectionStrings.cs @@ -0,0 +1,32 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Database.Updater +{ + using Microsoft.Data.SqlClient; + + public static class ConnectionStrings + { + public static string Default { get; } = Get(); + + private static string Get(string databaseName = "master") + { + var connectionString = Environment.GetEnvironmentVariable("DATABASE_UPDATER_UNIT_TESTS_CONNECTION_STRING"); + + if (connectionString is null) + { + connectionString = $"Data Source=(localDB)\\posinfo-tests; Integrated Security=True"; + } + + var connectionStringBuilder = new SqlConnectionStringBuilder(connectionString) + { + InitialCatalog = databaseName, + }; + + return connectionStringBuilder.ToString(); + } + } +} diff --git a/tests/Database.Updater.Tests/Database.Updater.Tests.csproj b/tests/Database.Updater.Tests/Database.Updater.Tests.csproj new file mode 100644 index 0000000..4d38d8b --- /dev/null +++ b/tests/Database.Updater.Tests/Database.Updater.Tests.csproj @@ -0,0 +1,35 @@ + + + + net8.0 + Exe + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + diff --git a/tests/Database.Updater.Tests/DatabaseUpdaterBuilderTest.cs b/tests/Database.Updater.Tests/DatabaseUpdaterBuilderTest.cs new file mode 100644 index 0000000..60fc186 --- /dev/null +++ b/tests/Database.Updater.Tests/DatabaseUpdaterBuilderTest.cs @@ -0,0 +1,43 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Database.Updater.Tests +{ + public class DatabaseUpdaterBuilderTest + { + [Fact] + public void Constructor_NoApplicationName() + { + var action = () => new DatabaseUpdaterBuilder(null); + + action.Should().ThrowExactly() + .WithParameterName("applicationName") + .WithMessage("Value cannot be null. (Parameter 'applicationName')"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Constructor_ApplicationName_EmptyOrWhitespace(string applicationName) + { + var action = () => new DatabaseUpdaterBuilder(applicationName); + + action.Should().ThrowExactly() + .WithParameterName("applicationName") + .WithMessage("The value cannot be an empty string or composed entirely of whitespace. (Parameter 'applicationName')"); + } + + [Fact] + public void Build_NoDatabaseProvider() + { + var builder = new DatabaseUpdaterBuilder("MyApplication"); + + builder.Invoking(b => b.Build()) + .Should().ThrowExactly() + .WithMessage("No database provider has been configured."); + } + } +} \ No newline at end of file diff --git a/tests/Database.Updater.Tests/DatabaseUpdaterTest.cs b/tests/Database.Updater.Tests/DatabaseUpdaterTest.cs new file mode 100644 index 0000000..0f2f67b --- /dev/null +++ b/tests/Database.Updater.Tests/DatabaseUpdaterTest.cs @@ -0,0 +1,201 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Database.Updater.Tests +{ + using Microsoft.Extensions.Logging; + using PosInformatique.Testing.Databases.SqlServer; + + [Collection(nameof(DatabaseUpdaterTest))] + public class DatabaseUpdaterTest + { + static DatabaseUpdaterTest() + { + License.Accepted = true; + } + + [Fact] + public async Task UpgradeAsync_WithExplicitMigrationsAssembly() + { + var server = new SqlServer(ConnectionStrings.Default); + + var database = await server.CreateEmptyDatabaseAsync("DatabaseUpdaterTest_UpgradeAsync_WithExplicitMigrationsAssembly", cancellationToken: TestContext.Current.CancellationToken); + + var databaseUpdaterBuilder = new DatabaseUpdaterBuilder("MyApplication") + .UseSqlServer() + .UseMigrationsAssembly(typeof(MigrationsAssembly.Version1).Assembly); + + using var databaseUpdater = databaseUpdaterBuilder + .Build(); + + var result = await databaseUpdater.UpgradeAsync([database.ConnectionString], TestContext.Current.CancellationToken); + + result.Should().Be(0); + + var tables = await database.GetTablesAsync(TestContext.Current.CancellationToken); + + tables.Should().HaveCount(2); + + tables[0].Name.Should().Be("__EFMigrationsHistory"); + + tables[1].Columns.Should().HaveCount(3); + tables[1].Columns[0].Name.Should().Be("Id"); + tables[1].Columns[1].Name.Should().Be("Name"); + tables[1].Columns[2].Name.Should().Be("IsActive"); + tables[1].Name.Should().Be("Person"); + } + + [Fact] + public async Task UpgradeAsync_WithErrorMigrationsAssembly() + { + using var output = new StringWriter(); + Console.SetOut(output); + + var server = new SqlServer(ConnectionStrings.Default); + + var database = await server.CreateEmptyDatabaseAsync("DatabaseUpdaterTest_UpgradeAsync_WithErrorMigrationsAssembly", cancellationToken: TestContext.Current.CancellationToken); + + var loggingProvider = new InMemoryLoggingProvider(); + + var databaseUpdaterBuilder = new DatabaseUpdaterBuilder("MyApplication") + .ConfigureLogging(l => + { + l.AddProvider(loggingProvider) + .SetMinimumLevel(LogLevel.Error); + }) + .UseSqlServer() + .UseMigrationsAssembly(typeof(MigrationsErrorAssembly.Version1).Assembly); + + using var databaseUpdater = databaseUpdaterBuilder + .Build(); + + var result = await databaseUpdater.UpgradeAsync([database.ConnectionString], TestContext.Current.CancellationToken); + + result.Should().Be(99); + + loggingProvider.Output.Should().Be($"[PosInformatique.Database.Updater.IDatabaseUpdater] (Error) : Some errors occured during the migration...{Environment.NewLine}"); + + output.ToString().Should().StartWith("fail: PosInformatique.Database.Updater.IDatabaseUpdater[0]"); + } + + [Fact] + public async Task UpgradeAsync_WithThrowException() + { + using var output = new StringWriter(); + Console.SetOut(output); + + var server = new SqlServer(ConnectionStrings.Default); + + var database = await server.CreateEmptyDatabaseAsync("DatabaseUpdaterTest_UpgradeAsync_WithErrorMigrationsAssembly", cancellationToken: TestContext.Current.CancellationToken); + + var loggingProvider = new InMemoryLoggingProvider(); + + var databaseUpdaterBuilder = new DatabaseUpdaterBuilder("MyApplication") + .Configure(opt => + { + opt.ThrowExceptionOnError = false; + }) + .Configure(opt => + { + opt.ThrowExceptionOnError = true; + }) + .ConfigureLogging(l => + { + l.AddProvider(loggingProvider) + .SetMinimumLevel(LogLevel.Error); + }) + .UseSqlServer() + .UseMigrationsAssembly(typeof(MigrationsErrorAssembly.Version1).Assembly); + + using var databaseUpdater = databaseUpdaterBuilder + .Build(); + + await databaseUpdater.Invoking(du => du.UpgradeAsync([database.ConnectionString])) + .Should().ThrowExactlyAsync() + .WithMessage("Some errors occured during the migration..."); + + loggingProvider.Output.Should().Be($"[PosInformatique.Database.Updater.IDatabaseUpdater] (Error) : Some errors occured during the migration...{Environment.NewLine}"); + + output.ToString().Should().StartWith("fail: PosInformatique.Database.Updater.IDatabaseUpdater[0]"); + } + + [Fact] + public async Task UpgradeAsync_NoArguments() + { + using var output = new StringWriter(); + Console.SetOut(output); + + var databaseUpdaterBuilder = new DatabaseUpdaterBuilder("MyApplication"); + + using var databaseUpdater = databaseUpdaterBuilder + .UseSqlServer() + .Build(); + + var result = await databaseUpdater.UpgradeAsync([], TestContext.Current.CancellationToken); + + result.Should().Be(1); + + output.ToString().Should().Be( + """ + Description: + Upgrade the MyApplication database. + + Usage: + PosInformatique.Database.Updater.Tests [options] + + Arguments: + The connection string to the database to upgrade + + Options: + --access-token Access token to connect to the SQL database. + --command-timeout Maximum time in seconds to execute each SQL statements. [default: 30] + -?, -h, --help Show help and usage information + --version Show version information + + + """); + } + + [Theory] + [InlineData("NotConnectionString")] + [InlineData("Data Source=some_server;", "--command-timeout=abcd")] + public async Task UpgradeAsync_WrongArguments(params string[] args) + { + using var output = new StringWriter(); + Console.SetOut(output); + + var databaseUpdaterBuilder = new DatabaseUpdaterBuilder("MyApplication"); + + using var databaseUpdater = databaseUpdaterBuilder + .UseSqlServer() + .Build(); + + var result = await databaseUpdater.UpgradeAsync(args, TestContext.Current.CancellationToken); + + result.Should().Be(1); + + output.ToString().Should().Be( + """ + Description: + Upgrade the MyApplication database. + + Usage: + PosInformatique.Database.Updater.Tests [options] + + Arguments: + The connection string to the database to upgrade + + Options: + --access-token Access token to connect to the SQL database. + --command-timeout Maximum time in seconds to execute each SQL statements. [default: 30] + -?, -h, --help Show help and usage information + --version Show version information + + + """); + } + } +} \ No newline at end of file diff --git a/tests/Database.Updater.Tests/Logging/InMemoryLoggingProviderTest.cs b/tests/Database.Updater.Tests/Logging/InMemoryLoggingProviderTest.cs new file mode 100644 index 0000000..18dff8f --- /dev/null +++ b/tests/Database.Updater.Tests/Logging/InMemoryLoggingProviderTest.cs @@ -0,0 +1,93 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- +namespace PosInformatique.Database.Updater.Tests +{ + using Microsoft.Extensions.Logging; + + public class InMemoryLoggingProviderTest + { + [Fact] + public void CreateLogger_Log() + { + var provider = new InMemoryLoggingProvider(); + + var logger1 = provider.CreateLogger("The category 1"); + var logger2 = provider.CreateLogger("The category 2"); + + logger1.LogInformation("The information"); + + provider.Output.Should().Be($"[The category 1] (Information) : The information{Environment.NewLine}"); + + logger2.LogError("The error"); + + provider.Output.Should().Be($"[The category 1] (Information) : The information{Environment.NewLine}[The category 2] (Error) : The error{Environment.NewLine}"); + } + + [Fact] + public void CreateLogger_BeginScope() + { + var provider = new InMemoryLoggingProvider(); + + var logger1 = provider.CreateLogger("The category 1"); + + logger1.BeginScope(null).Should().BeNull(); + } + + [Theory] + [InlineData(LogLevel.Critical)] + [InlineData(LogLevel.Debug)] + [InlineData(LogLevel.Error)] + [InlineData(LogLevel.Information)] + [InlineData(LogLevel.None)] + [InlineData(LogLevel.Trace)] + [InlineData(LogLevel.Warning)] + public void CreateLogger_IsEnabled(LogLevel logLevel) + { + var provider = new InMemoryLoggingProvider(); + + var logger = provider.CreateLogger("The category 1"); + + logger.IsEnabled(logLevel).Should().BeTrue(); + } + + [Fact] + public void Dispose() + { + var provider = new InMemoryLoggingProvider(); + + var logger = provider.CreateLogger("The category 1"); + + logger.LogInformation("The information"); + + provider.Dispose(); + + provider.Invoking(p => p.CreateLogger(default)) + .Should().ThrowExactly() + .WithMessage("Cannot access a disposed object.\r\nObject name: 'PosInformatique.Database.Updater.InMemoryLoggingProvider'.") + .Which.ObjectName.Should().Be("PosInformatique.Database.Updater.InMemoryLoggingProvider"); + + provider.Invoking(p => p.Output) + .Should().ThrowExactly() + .WithMessage("Cannot access a disposed object.\r\nObject name: 'PosInformatique.Database.Updater.InMemoryLoggingProvider'.") + .Which.ObjectName.Should().Be("PosInformatique.Database.Updater.InMemoryLoggingProvider"); + + logger.Invoking(l => l.BeginScope(default)) + .Should().ThrowExactly() + .WithMessage("Cannot access a disposed object.\r\nObject name: 'PosInformatique.Database.Updater.InMemoryLoggingProvider'.") + .Which.ObjectName.Should().Be("PosInformatique.Database.Updater.InMemoryLoggingProvider"); + + logger.Invoking(l => l.IsEnabled(default)) + .Should().ThrowExactly() + .WithMessage("Cannot access a disposed object.\r\nObject name: 'PosInformatique.Database.Updater.InMemoryLoggingProvider'.") + .Which.ObjectName.Should().Be("PosInformatique.Database.Updater.InMemoryLoggingProvider"); + + logger.Invoking(l => l.LogInformation("The log")) + .Should().ThrowExactly() + .WithMessage("Cannot access a disposed object.\r\nObject name: 'PosInformatique.Database.Updater.InMemoryLoggingProvider'.") + .Which.ObjectName.Should().Be("PosInformatique.Database.Updater.InMemoryLoggingProvider"); + } + } +} \ No newline at end of file diff --git a/tests/Database.Updater.Tests/TestTools.cs b/tests/Database.Updater.Tests/TestTools.cs new file mode 100644 index 0000000..8464185 --- /dev/null +++ b/tests/Database.Updater.Tests/TestTools.cs @@ -0,0 +1,22 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Database.Updater.Tests +{ + using System.Reflection; + + public static class TestTools + { + public static T GetFieldValue(this object obj, string fieldName) + { + var field = obj.GetType().GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance); + + var value = field!.GetValue(obj); + + return (T)value!; + } + } +} diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props new file mode 100644 index 0000000..7b2d1a3 --- /dev/null +++ b/tests/Directory.Build.props @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file