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.
+[](https://www.nuget.org/packages/PosInformatique.Database.Updater/)
+[](https://www.nuget.org/packages/PosInformatique.Database.Updater/)
+[](LICENSE)
+[](https://github.com/PosInformatique/PosInformatique.Database.Updater/actions)
+[](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