diff --git a/Shoko.IntegrationTests/DatabaseMigrationFixture.cs b/Shoko.IntegrationTests/DatabaseMigrationFixture.cs
index 971a1752a6..bef459be3c 100644
--- a/Shoko.IntegrationTests/DatabaseMigrationFixture.cs
+++ b/Shoko.IntegrationTests/DatabaseMigrationFixture.cs
@@ -42,7 +42,7 @@ public DatabaseMigrationFixture()
// SystemService() bootstraps ISettingsProvider.Instance with default settings (FirstRun=true).
// No settings file yet — defaults are valid and pass schema validation.
- var systemService = new SystemService();
+ var systemService = new SystemService(["--home", _tempDir.Replace('\\', '/')]);
// Mutate the live settings: disable first-run, inject fake AniDB credentials so the
// settings custom-validator is satisfied, and move the web port away from 8111 so this
diff --git a/Shoko.Server/Properties/AssemblyInfo.cs b/Shoko.Server/Properties/AssemblyInfo.cs
index 6ce951098b..eaad376852 100755
--- a/Shoko.Server/Properties/AssemblyInfo.cs
+++ b/Shoko.Server/Properties/AssemblyInfo.cs
@@ -1,4 +1,5 @@
using System.Reflection;
+using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
// General Information about an assembly is controlled through the following
@@ -11,6 +12,7 @@
// COM, set the ComVisible attribute to true on that type.
[assembly: ComVisible(false)]
+[assembly: InternalsVisibleTo("Shoko.Tests")]
//In order to begin building localizable applications, set
//CultureYouAreCodingWith in your .csproj file
diff --git a/Shoko.Server/Services/ApplicationPaths.cs b/Shoko.Server/Services/ApplicationPaths.cs
index 6b87ac13a8..8a1da894d4 100644
--- a/Shoko.Server/Services/ApplicationPaths.cs
+++ b/Shoko.Server/Services/ApplicationPaths.cs
@@ -2,6 +2,7 @@
using System;
using System.IO;
using System.Reflection;
+using System.Threading;
using Shoko.Abstractions.Plugin;
using Shoko.Abstractions.Utilities;
using Shoko.Server.Settings;
@@ -23,10 +24,13 @@ public string ApplicationPath
=> _applicationPath ??= Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
private string? _webPath = null;
+ private static readonly AsyncLocal s_dataPathOverride = new();
///
public string WebPath
- => _webPath ??= Path.Combine(DataPath, ISettingsProvider.Instance.GetSettings().Web.WebUIPath);
+ => s_dataPathOverride.Value is { Length: > 0 }
+ ? Path.Combine(DataPath, ISettingsProvider.Instance.GetSettings().Web.WebUIPath)
+ : _webPath ??= Path.Combine(DataPath, ISettingsProvider.Instance.GetSettings().Web.WebUIPath);
private static string? _dataPath = null;
@@ -37,6 +41,9 @@ public static string StaticDataPath
{
get
{
+ if (s_dataPathOverride.Value is { Length: > 0 } overriddenPath)
+ return overriddenPath;
+
if (_dataPath != null)
return _dataPath;
@@ -94,9 +101,13 @@ public static void SetHome(string[] args)
///
public string ImagesPath
- => _imagesPath ??= ISettingsProvider.Instance.GetSettings().ImagesPath is { Length: > 0 } imagePath
- ? Path.Combine(DataPath, imagePath)
- : DefaultImagePath;
+ => s_dataPathOverride.Value is { Length: > 0 }
+ ? ISettingsProvider.Instance.GetSettings().ImagesPath is { Length: > 0 } configuredImagePath
+ ? Path.Combine(DataPath, configuredImagePath)
+ : DefaultImagePath
+ : _imagesPath ??= ISettingsProvider.Instance.GetSettings().ImagesPath is { Length: > 0 } cachedConfiguredImagePath
+ ? Path.Combine(DataPath, cachedConfiguredImagePath)
+ : DefaultImagePath;
public static string DefaultImagePath => Path.Combine(StaticDataPath, "images");
@@ -114,4 +125,28 @@ public string ConfigurationsPath
///
public string LogsPath
=> Path.Combine(DataPath, "logs");
+
+ internal static IDisposable PushDataPathOverride(string absolutePath)
+ => new ScopedDataPathOverride(absolutePath);
+
+ private sealed class ScopedDataPathOverride : IDisposable
+ {
+ private readonly string? _previousValue;
+ private bool _disposed;
+
+ public ScopedDataPathOverride(string value)
+ {
+ _previousValue = s_dataPathOverride.Value;
+ s_dataPathOverride.Value = value;
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+
+ s_dataPathOverride.Value = _previousValue;
+ _disposed = true;
+ }
+ }
}
diff --git a/Shoko.Server/Services/DatabaseConversionService.cs b/Shoko.Server/Services/DatabaseConversionService.cs
new file mode 100644
index 0000000000..635d494b0f
--- /dev/null
+++ b/Shoko.Server/Services/DatabaseConversionService.cs
@@ -0,0 +1,1500 @@
+using System;
+using System.Collections.Generic;
+using System.Data;
+using System.Data.Common;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using System.IO;
+using System.Collections;
+using System.Linq;
+using System.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+using Force.DeepCloner;
+using Microsoft.Data.SqlClient;
+using Microsoft.Data.Sqlite;
+using Microsoft.Extensions.DependencyInjection;
+using MySqlConnector;
+using Shoko.Abstractions.Plugin;
+using Shoko.Server.Databases;
+using Shoko.Server.Repositories;
+
+using ISettingsProvider = Shoko.Server.Settings.ISettingsProvider;
+
+#nullable enable
+namespace Shoko.Server.Services;
+
+internal sealed class DatabaseConversionOptions
+{
+ public DatabaseConversionService.SourceDatabaseType SourceType { get; set; } = DatabaseConversionService.SourceDatabaseType.SqlServer;
+ public bool SourceTypeProvided { get; set; }
+ public string SourceConnectionString { get; set; } = string.Empty;
+ public bool SourceConnectionStringProvided { get; set; }
+ public string TargetFile { get; set; } = string.Empty;
+ public bool TargetFileProvided { get; set; }
+ public bool Overwrite { get; set; }
+ public bool ShowHelp { get; set; }
+
+ public static bool TryParse(string[] args, [NotNullWhen(true)] out DatabaseConversionOptions? options)
+ {
+ options = null;
+ var conversionModeDetected = DetectConversionMode(args);
+ if (!conversionModeDetected)
+ {
+ return false;
+ }
+
+ var parsed = new DatabaseConversionOptions();
+ for (var index = 0; index < args.Length; index++)
+ {
+ var argument = args[index];
+ switch (argument)
+ {
+ case "--convert-db":
+ break;
+ case "--source-type":
+ parsed.SourceType = DatabaseConversionService.ParseSourceType(GetRequiredValue(args, ref index, argument));
+ parsed.SourceTypeProvided = true;
+ break;
+ case "--source-connection-string":
+ parsed.SourceConnectionString = GetRequiredValue(args, ref index, argument);
+ parsed.SourceConnectionStringProvided = true;
+ break;
+ case "--target-file":
+ parsed.TargetFile = GetRequiredValue(args, ref index, argument);
+ parsed.TargetFileProvided = true;
+ break;
+ case "--overwrite":
+ parsed.Overwrite = true;
+ break;
+ case "--help":
+ case "-h":
+ case "/?":
+ parsed.ShowHelp = true;
+ break;
+ }
+ }
+
+ options = parsed;
+ return true;
+ }
+
+ private static bool DetectConversionMode(string[] args)
+ => args.Any(argument => string.Equals(argument, "--convert-db", StringComparison.OrdinalIgnoreCase));
+
+ private static string GetRequiredValue(string[] args, ref int index, string argumentName)
+ {
+ if (index + 1 >= args.Length)
+ {
+ throw new ArgumentException($"Missing value for {argumentName}");
+ }
+
+ index++;
+ return args[index];
+ }
+}
+
+internal static class DatabaseConversionService
+{
+ private const string TableNameParameter = "@tableName";
+
+ internal enum SourceDatabaseType
+ {
+ SqlServer,
+ MySql,
+ }
+
+ internal readonly record struct ResolvedSource(SourceDatabaseType SourceType, string SourceConnectionString);
+ internal readonly record struct ResolvedTarget(string TargetFile);
+ internal readonly record struct ConversionRuntimeContext(
+ DatabaseConversionOptions Options,
+ ResolvedSource Source,
+ string PreparedTargetFile,
+ string TargetConnectionString,
+ string TemporaryHomePath,
+ ISettingsProvider RuntimeSettingsProvider);
+
+ private static readonly HashSet ExcludedTables = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "Versions",
+ };
+
+ internal static ConversionRuntimeContext PrepareRuntime(DatabaseConversionOptions options, Shoko.Server.Settings.ServerSettings realSettings)
+ {
+ if (options.ShowHelp)
+ {
+ throw new InvalidOperationException("Help output does not require conversion runtime preparation.");
+ }
+
+ var resolvedSource = ResolveSource(options, realSettings);
+ var resolvedTarget = ResolveTarget(options, realSettings);
+ var preparedTargetFile = PrepareTargetPath(resolvedTarget.TargetFile, options.Overwrite);
+ var targetConnectionString = BuildSqliteConnectionString(preparedTargetFile);
+ var temporaryHomePath = Path.Combine(Path.GetTempPath(), $"shoko-convert-home-{Guid.NewGuid():N}");
+ var runtimeSettings = realSettings.DeepClone();
+ runtimeSettings.Database.Type = Shoko.Server.Server.Constants.DatabaseType.SQLite;
+ runtimeSettings.Database.OverrideConnectionString = targetConnectionString;
+ runtimeSettings.Quartz.DatabaseType = Shoko.Server.Server.Constants.DatabaseType.SQLite;
+ runtimeSettings.Quartz.ConnectionString = BuildTemporaryQuartzConnectionString(temporaryHomePath);
+ return new(options, resolvedSource, preparedTargetFile, targetConnectionString, temporaryHomePath, new InMemorySettingsProvider(runtimeSettings));
+ }
+
+ internal static IDisposable BeginIsolatedRuntime(ConversionRuntimeContext context)
+ => new ConversionIsolationScope(context);
+
+ internal static async Task RunAsync(SystemService systemService, IServiceProvider services, ConversionRuntimeContext context, CancellationToken cancellationToken)
+ {
+ await ConvertAsync(systemService, services, context.Source.SourceType, context.Source.SourceConnectionString, context.PreparedTargetFile, cancellationToken);
+ }
+
+ internal static ResolvedSource ResolveSource(DatabaseConversionOptions options, Shoko.Server.Settings.IServerSettings settings)
+ {
+ var configuredSourceType = TryResolveConfiguredSourceType(settings.Database.Type);
+ var resolvedSourceType = options.SourceTypeProvided
+ ? options.SourceType
+ : configuredSourceType ?? throw new InvalidOperationException(
+ "The current configured source database is SQLite. Conversion only supports SQL Server/MySQL/MariaDB -> SQLite. " +
+ "Provide both --source-type and --source-connection-string to convert from an external supported source.");
+
+ var resolvedConnectionString = options.SourceConnectionStringProvided
+ ? options.SourceConnectionString
+ : ResolveConfiguredSourceConnectionString(settings.Database, configuredSourceType, resolvedSourceType);
+
+ return new(resolvedSourceType, resolvedConnectionString);
+ }
+
+ internal static ResolvedTarget ResolveTarget(DatabaseConversionOptions options, Shoko.Server.Settings.IServerSettings settings)
+ {
+ if (options.TargetFileProvided)
+ {
+ if (string.IsNullOrWhiteSpace(options.TargetFile))
+ {
+ throw new InvalidOperationException(GetUsage());
+ }
+
+ return new(Path.GetFullPath(options.TargetFile));
+ }
+
+ return new(GetDefaultSqliteTargetPath(settings.Database));
+ }
+
+ private static string ResolveConfiguredSourceConnectionString(
+ Shoko.Server.Settings.DatabaseSettings settings,
+ SourceDatabaseType? configuredSourceType,
+ SourceDatabaseType requestedSourceType)
+ {
+ if (!configuredSourceType.HasValue)
+ {
+ throw new InvalidOperationException(
+ "The current configured source database is SQLite. Conversion only supports SQL Server/MySQL/MariaDB -> SQLite. " +
+ "Provide --source-connection-string to override the source details.");
+ }
+
+ if (configuredSourceType.Value != requestedSourceType)
+ {
+ throw new InvalidOperationException(
+ $"The current configured source database is {GetSourceTypeDisplayName(configuredSourceType.Value)}, but the requested source type is {GetSourceTypeDisplayName(requestedSourceType)}. " +
+ "Provide --source-connection-string when overriding the source type.");
+ }
+
+ return BuildConfiguredSourceConnectionString(settings, configuredSourceType.Value);
+ }
+
+ private static SourceDatabaseType? TryResolveConfiguredSourceType(Shoko.Server.Server.Constants.DatabaseType configuredType)
+ {
+ return configuredType switch
+ {
+ Shoko.Server.Server.Constants.DatabaseType.SQLServer => SourceDatabaseType.SqlServer,
+ Shoko.Server.Server.Constants.DatabaseType.MySQL => SourceDatabaseType.MySql,
+ Shoko.Server.Server.Constants.DatabaseType.SQLite => null,
+ _ => null,
+ };
+ }
+
+ private static string BuildConfiguredSourceConnectionString(Shoko.Server.Settings.DatabaseSettings settings, SourceDatabaseType sourceType)
+ {
+ if (!string.IsNullOrWhiteSpace(settings.OverrideConnectionString))
+ {
+ return settings.OverrideConnectionString;
+ }
+
+ return sourceType switch
+ {
+ SourceDatabaseType.SqlServer =>
+ $"data source={settings.Hostname},{settings.Port};Initial Catalog={settings.Schema};user id={settings.Username};password={settings.Password};persist security info=True;MultipleActiveResultSets=True;TrustServerCertificate=True",
+ SourceDatabaseType.MySql =>
+ $"Server={settings.Hostname};Port={settings.Port};Database={settings.Schema};User ID={settings.Username};Password={settings.Password};Default Command Timeout=3600;Allow User Variables=true",
+ _ => throw new InvalidOperationException($"Unsupported source database type: {sourceType}"),
+ };
+ }
+
+ private static string GetDefaultSqliteTargetPath(Shoko.Server.Settings.DatabaseSettings settings)
+ {
+ var databaseDirectory = string.IsNullOrWhiteSpace(settings.MySqliteDirectory)
+ ? ApplicationPaths.StaticDataPath
+ : Path.Combine(ApplicationPaths.StaticDataPath, settings.MySqliteDirectory);
+ var databaseFile = string.IsNullOrWhiteSpace(settings.SQLite_DatabaseFile) ? "Shoko.db3" : settings.SQLite_DatabaseFile;
+ return Path.GetFullPath(Path.Combine(databaseDirectory, databaseFile));
+ }
+
+ private static async Task ConvertAsync(SystemService systemService, IServiceProvider services, SourceDatabaseType sourceType, string sourceConnectionString, string targetFile, CancellationToken cancellationToken)
+ {
+ Console.WriteLine($"Resolved target SQLite path: {targetFile}");
+
+ await using var source = await OpenSourceConnectionAsync(sourceType, sourceConnectionString);
+ await EnsureSourceVersionSupportedAsync(systemService, source, sourceType);
+
+ await InitializeSqliteDatabaseAsync(systemService, services, cancellationToken);
+
+ await using var target = new SqliteConnection(BuildSqliteConnectionString(targetFile));
+ await target.OpenAsync();
+
+ await ConfigureSqliteAsync(target);
+
+ var sourceTables = await GetSourceTablesAsync(source, sourceType);
+ var targetTables = await GetSqliteTablesAsync(target);
+ var tablesToCopy = targetTables
+ .Where(sourceTables.Contains)
+ .Where(tableName => !ExcludedTables.Contains(tableName))
+ .ToList();
+ var sourceOnlyTables = sourceTables
+ .Except(targetTables)
+ .OrderBy(a => a, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ var targetOnlyTables = targetTables
+ .Except(sourceTables)
+ .Where(tableName => !ExcludedTables.Contains(tableName))
+ .OrderBy(a => a, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ Console.WriteLine($"Source tables found: {sourceTables.Count}");
+ Console.WriteLine($"Target tables found: {targetTables.Count}");
+ Console.WriteLine($"Tables to copy: {tablesToCopy.Count}");
+
+ if (sourceOnlyTables.Count > 0)
+ {
+ Console.WriteLine("Source-only tables not present in the generated SQLite schema:");
+ foreach (var skippedTable in sourceOnlyTables)
+ {
+ Console.WriteLine($" {skippedTable}");
+ }
+ }
+
+ if (targetOnlyTables.Count > 0)
+ {
+ Console.WriteLine("Target-only tables missing from the source database:");
+ foreach (var targetOnlyTable in targetOnlyTables)
+ {
+ Console.WriteLine($" {targetOnlyTable}");
+ }
+
+ await ReportSourceObjectsAsync(source, sourceType, targetOnlyTables);
+ }
+
+ if (ExcludedTables.Count > 0)
+ {
+ Console.WriteLine("Excluded control tables:");
+ foreach (var excludedTable in ExcludedTables.OrderBy(a => a, StringComparer.OrdinalIgnoreCase))
+ {
+ Console.WriteLine($" {excludedTable}");
+ }
+ }
+
+ foreach (var tableName in tablesToCopy)
+ {
+ await CopyTableAsync(source, sourceType, target, tableName);
+ }
+
+ await VerifyCopyAsync(source, sourceType, target, tablesToCopy);
+ Console.WriteLine($"Conversion completed successfully: {targetFile}");
+ }
+
+ internal static string PrepareTargetPath(string targetFile, bool overwrite)
+ {
+ var fullTargetPath = Path.GetFullPath(targetFile);
+ if (File.Exists(fullTargetPath))
+ {
+ if (!overwrite)
+ {
+ throw new InvalidOperationException($"Target file already exists: {fullTargetPath}. Use --overwrite to replace it.");
+ }
+
+ File.Delete(fullTargetPath);
+ }
+
+ var targetDirectory = Path.GetDirectoryName(fullTargetPath);
+ if (!string.IsNullOrWhiteSpace(targetDirectory))
+ {
+ Directory.CreateDirectory(targetDirectory);
+ }
+
+ return fullTargetPath;
+ }
+
+ private static async Task EnsureSourceVersionSupportedAsync(SystemService systemService, DbConnection source, SourceDatabaseType sourceType)
+ {
+ var sourceVersion = await GetSourceDatabaseVersionAsync(source, sourceType);
+ if (sourceVersion is null)
+ {
+ throw new InvalidOperationException("The source database does not contain a current Database version entry in Versions. Upgrade it with the matching Shoko Server build before conversion.");
+ }
+
+ var expectedVersion = GetExpectedSourceDatabaseVersion(systemService, sourceType);
+ if (sourceVersion.Value.Version != expectedVersion.Version || sourceVersion.Value.Revision != expectedVersion.Revision)
+ {
+ throw new InvalidOperationException(
+ $"Unsupported source database version for {GetSourceTypeDisplayName(sourceType)}. " +
+ $"Found {sourceVersion.Value.Version}.{sourceVersion.Value.Revision} " +
+ $"(program: {sourceVersion.Value.Program ?? "unknown"}), expected {expectedVersion.Version}.{expectedVersion.Revision} " +
+ $"for this Shoko build. Upgrade the source database with the matching Shoko Server build before conversion.");
+ }
+ }
+
+ private static string GetSourceTypeDisplayName(SourceDatabaseType sourceType)
+ {
+ return sourceType switch
+ {
+ SourceDatabaseType.SqlServer => "SQL Server",
+ SourceDatabaseType.MySql => "MySQL/MariaDB",
+ _ => sourceType.ToString(),
+ };
+ }
+
+ private static async Task InitializeSqliteDatabaseAsync(SystemService systemService, IServiceProvider services, CancellationToken cancellationToken)
+ {
+ var databaseFactory = services.GetRequiredService();
+ var repositoryFactory = services.GetRequiredService();
+ // Conversion mode enters its isolated temp-home/settings scope before host build, so
+ // Quartz and any other early services already point at the conversion runtime. Database
+ // bootstrap can therefore reuse the existing isolated runtime without touching the real
+ // Shoko home or source database settings.
+ databaseFactory.CloseSessionFactory();
+ databaseFactory.Instance = null;
+
+ if (!systemService.InitializeDatabaseForConversion(databaseFactory, repositoryFactory, cancellationToken))
+ {
+ throw new InvalidOperationException(systemService.StartupMessage ?? "Shoko database bootstrap failed.");
+ }
+
+ databaseFactory.CloseSessionFactory();
+ databaseFactory.Instance = null;
+ }
+
+ private sealed class InMemorySettingsProvider(Shoko.Server.Settings.ServerSettings settings) : ISettingsProvider
+ {
+ private Shoko.Server.Settings.ServerSettings _settings = settings;
+
+ public Shoko.Server.Settings.IServerSettings GetSettings(bool copy = false)
+ => copy ? _settings.DeepClone() : _settings;
+
+ public void SaveSettings(Shoko.Server.Settings.IServerSettings settings)
+ {
+ if (settings is Shoko.Server.Settings.ServerSettings serverSettings)
+ {
+ _settings = serverSettings;
+ }
+ }
+
+ public void SaveSettings()
+ {
+ }
+
+ public void DebugSettingsToLog()
+ {
+ }
+ }
+
+ private sealed class ConversionIsolationScope : IDisposable
+ {
+ private readonly ISettingsProvider _previousSettingsProvider;
+ private readonly IDisposable _dataPathOverride;
+ private readonly string _temporaryHomePath;
+ private bool _disposed;
+
+ public ConversionIsolationScope(ConversionRuntimeContext context)
+ {
+ _temporaryHomePath = context.TemporaryHomePath;
+ Directory.CreateDirectory(_temporaryHomePath);
+
+ _previousSettingsProvider = ISettingsProvider.Instance;
+ ISettingsProvider.Instance = context.RuntimeSettingsProvider;
+
+ _dataPathOverride = ApplicationPaths.PushDataPathOverride(_temporaryHomePath);
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _dataPathOverride.Dispose();
+ ISettingsProvider.Instance = _previousSettingsProvider;
+
+ try
+ {
+ if (Directory.Exists(_temporaryHomePath))
+ {
+ Directory.Delete(_temporaryHomePath, true);
+ }
+ }
+ catch
+ {
+ // Best-effort cleanup only.
+ }
+
+ _disposed = true;
+ }
+ }
+
+ private static string BuildTemporaryQuartzConnectionString(string temporaryHomePath)
+ => $"Data Source={Path.Combine(temporaryHomePath, "SQLite", "Quartz.db3")};Mode=ReadWriteCreate;Pooling=True";
+
+ private static string BuildSqliteConnectionString(string targetFile)
+ => new SqliteConnectionStringBuilder
+ {
+ DataSource = targetFile,
+ Mode = SqliteOpenMode.ReadWriteCreate,
+ Pooling = false,
+ }.ToString();
+
+ private static async Task ConfigureSqliteAsync(SqliteConnection connection)
+ {
+ var commands = new[]
+ {
+ "PRAGMA foreign_keys = OFF;",
+ "PRAGMA journal_mode = WAL;",
+ "PRAGMA synchronous = OFF;",
+ };
+
+ foreach (var commandText in commands)
+ {
+ await using var command = connection.CreateCommand();
+ command.CommandText = commandText;
+ await command.ExecuteNonQueryAsync();
+ }
+ }
+
+ private static async Task OpenSourceConnectionAsync(SourceDatabaseType sourceType, string connectionString)
+ {
+ DbConnection connection = sourceType switch
+ {
+ SourceDatabaseType.SqlServer => new SqlConnection(connectionString),
+ SourceDatabaseType.MySql => new MySqlConnection(connectionString),
+ _ => throw new InvalidOperationException($"Unsupported source database type: {sourceType}"),
+ };
+
+ await connection.OpenAsync();
+ return connection;
+ }
+
+ private static async Task GetSourceDatabaseVersionAsync(DbConnection connection, SourceDatabaseType sourceType)
+ {
+ return sourceType switch
+ {
+ SourceDatabaseType.SqlServer => await GetSqlServerDatabaseVersionAsync((SqlConnection)connection),
+ SourceDatabaseType.MySql => await GetMySqlDatabaseVersionAsync((MySqlConnection)connection),
+ _ => throw new InvalidOperationException($"Unsupported source database type: {sourceType}"),
+ };
+ }
+
+ private static async Task GetSqlServerDatabaseVersionAsync(SqlConnection connection)
+ {
+ const string sql = """
+ SELECT TOP 1 VersionValue, VersionRevision, VersionProgram
+ FROM Versions
+ WHERE VersionType = 'Database'
+ ORDER BY TRY_CONVERT(int, VersionValue) DESC, TRY_CONVERT(int, VersionRevision) DESC
+ """;
+
+ await using var command = new SqlCommand(sql, connection);
+ await using var reader = await command.ExecuteReaderAsync();
+ if (!await reader.ReadAsync())
+ {
+ return null;
+ }
+
+ return new DatabaseVersionInfo(
+ ParseVersionPart(reader.GetString(0), "VersionValue"),
+ ParseVersionPart(reader.GetString(1), "VersionRevision"),
+ await reader.IsDBNullAsync(2) ? null : reader.GetString(2));
+ }
+
+ private static async Task GetMySqlDatabaseVersionAsync(MySqlConnection connection)
+ {
+ const string sql = """
+ SELECT VersionValue, VersionRevision, VersionProgram
+ FROM Versions
+ WHERE VersionType = 'Database'
+ ORDER BY CAST(VersionValue AS SIGNED) DESC, CAST(VersionRevision AS SIGNED) DESC
+ LIMIT 1
+ """;
+
+ await using var command = new MySqlCommand(sql, connection);
+ await using var reader = await command.ExecuteReaderAsync();
+ if (!await reader.ReadAsync())
+ {
+ return null;
+ }
+
+ return new DatabaseVersionInfo(
+ ParseVersionPart(reader.GetString(0), "VersionValue"),
+ ParseVersionPart(reader.GetString(1), "VersionRevision"),
+ await reader.IsDBNullAsync(2) ? null : reader.GetString(2));
+ }
+
+ private static DatabaseVersionInfo GetExpectedSourceDatabaseVersion(SystemService systemService, SourceDatabaseType sourceType)
+ {
+ object database = sourceType switch
+ {
+ SourceDatabaseType.SqlServer => new SQLServer(systemService),
+ SourceDatabaseType.MySql => new MySQL(systemService),
+ _ => throw new InvalidOperationException($"Unsupported source database type: {sourceType}"),
+ };
+
+ var latest = GetDatabaseCommands(database)
+ .Where(command => command.Version > 0)
+ .Select(command => new DatabaseVersionInfo(command.Version, command.Revision, null))
+ .OrderByDescending(command => command.Version)
+ .ThenByDescending(command => command.Revision)
+ .First();
+
+ return latest;
+ }
+
+ [SuppressMessage("Major Code Smell", "S3011", Justification = "The converter inspects internal database command containers to determine the expected backend migration version without duplicating migration metadata.")]
+ private static IEnumerable GetDatabaseCommands(object database)
+ {
+ foreach (var field in database.GetType().GetFields(BindingFlags.Instance | BindingFlags.NonPublic))
+ {
+ var value = field.GetValue(database);
+ if (value is DatabaseCommand singleCommand)
+ {
+ yield return singleCommand;
+ continue;
+ }
+
+ if (value is not IEnumerable enumerable)
+ {
+ continue;
+ }
+
+ foreach (var item in enumerable)
+ {
+ if (item is DatabaseCommand command)
+ {
+ yield return command;
+ }
+ }
+ }
+ }
+
+ private static int ParseVersionPart(string value, string columnName)
+ {
+ if (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
+ {
+ throw new InvalidOperationException($"Invalid {columnName} value in Versions: {value}");
+ }
+
+ return parsed;
+ }
+
+ private static async Task CopyTableAsync(DbConnection source, SourceDatabaseType sourceType, SqliteConnection target, string tableName)
+ {
+ var sourceColumns = await GetSourceColumnsAsync(source, sourceType, tableName);
+ var sourceColumnTypes = await GetSourceColumnTypesAsync(source, sourceType, tableName);
+ var targetColumns = await GetSqliteColumnsAsync(target, tableName);
+ var sourceColumnSet = sourceColumns.ToHashSet(StringComparer.OrdinalIgnoreCase);
+ var sourceBackedColumns = targetColumns.Where(column => sourceColumnSet.Contains(column.Name)).ToList();
+ var guidColumns = sourceBackedColumns
+ .Where(column => IsGuidColumn(sourceType, sourceColumnTypes, column))
+ .Select(column => column.Name)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ var fallbackColumns = targetColumns
+ .Where(column => !sourceColumnSet.Contains(column.Name) && column.NotNull && string.IsNullOrWhiteSpace(column.DefaultValue))
+ .ToList();
+ var insertColumns = sourceBackedColumns
+ .Concat(fallbackColumns)
+ .ToList();
+
+ if (insertColumns.Count == 0)
+ {
+ Console.WriteLine($"Skipping {tableName}: no shared columns.");
+ return;
+ }
+
+ if (fallbackColumns.Count > 0)
+ {
+ Console.WriteLine($"Applying fallback values for {tableName}: {string.Join(", ", fallbackColumns.Select(column => column.Name))}");
+ }
+
+ await using var transaction = (SqliteTransaction)await target.BeginTransactionAsync();
+ await using (var deleteCommand = target.CreateCommand())
+ {
+ deleteCommand.Transaction = transaction;
+ deleteCommand.CommandText = $"DELETE FROM {QuoteSqliteIdentifier(tableName)};";
+ await deleteCommand.ExecuteNonQueryAsync();
+ }
+
+ var selectSql = $"SELECT {string.Join(", ", sourceBackedColumns.Select(column => QuoteSourceIdentifier(sourceType, column.Name)))} FROM {QuoteSourceIdentifier(sourceType, tableName)};";
+ await using var selectCommand = source.CreateCommand();
+ selectCommand.CommandText = selectSql;
+ await using var reader = await selectCommand.ExecuteReaderAsync(CommandBehavior.SequentialAccess);
+
+ await using var insertCommand = target.CreateCommand();
+ insertCommand.Transaction = transaction;
+ insertCommand.CommandText =
+ $"INSERT INTO {QuoteSqliteIdentifier(tableName)} ({string.Join(", ", insertColumns.Select(column => QuoteSqliteIdentifier(column.Name)))}) VALUES ({string.Join(", ", insertColumns.Select((_, index) => $"@p{index}"))});";
+
+ for (var index = 0; index < insertColumns.Count; index++)
+ {
+ insertCommand.Parameters.Add(new SqliteParameter($"@p{index}", DbType.Object));
+ }
+
+ var rowCount = 0;
+ while (await reader.ReadAsync())
+ {
+ for (var index = 0; index < sourceBackedColumns.Count; index++)
+ {
+ var value = await reader.IsDBNullAsync(index) ? DBNull.Value : reader.GetValue(index);
+ insertCommand.Parameters[index].Value = NormalizeValue(value, guidColumns.Contains(sourceBackedColumns[index].Name));
+ }
+
+ for (var index = 0; index < fallbackColumns.Count; index++)
+ {
+ var column = fallbackColumns[index];
+ insertCommand.Parameters[sourceBackedColumns.Count + index].Value = GetFallbackValue(tableName, column);
+ }
+
+ await insertCommand.ExecuteNonQueryAsync();
+ rowCount++;
+ }
+
+ await transaction.CommitAsync();
+ Console.WriteLine($"Copied {tableName}: {rowCount} rows, {insertColumns.Count} columns.");
+ }
+
+ private static async Task VerifyCopyAsync(DbConnection source, SourceDatabaseType sourceType, SqliteConnection target, IReadOnlyList tablesToCopy)
+ {
+ Console.WriteLine("Verifying migrated data...");
+
+ foreach (var tableName in tablesToCopy)
+ {
+ var sourceColumns = await GetSourceColumnsAsync(source, sourceType, tableName);
+ var targetColumns = await GetSqliteColumnsAsync(target, tableName);
+ var sharedColumns = targetColumns
+ .Where(column => sourceColumns.Contains(column.Name, StringComparer.OrdinalIgnoreCase))
+ .Select(column => column.Name)
+ .ToList();
+
+ if (sharedColumns.Count == 0)
+ {
+ Console.WriteLine($"Verified {tableName}: skipped, no shared columns.");
+ continue;
+ }
+
+ var sourceCount = await GetRowCountAsync(source, sourceType, tableName);
+ var targetCount = await GetRowCountAsync(target, tableName);
+ if (sourceCount != targetCount)
+ {
+ throw new InvalidOperationException($"Verification failed for {tableName}: row count mismatch. Source={sourceCount}, Target={targetCount}.");
+ }
+
+ await VerifyTableContentAsync(source, sourceType, target, tableName, sharedColumns);
+
+ Console.WriteLine($"Verified {tableName}: {sourceCount} rows, {sharedColumns.Count} shared columns.");
+ }
+ }
+
+ private static async Task GetRowCountAsync(DbConnection connection, SourceDatabaseType sourceType, string tableName)
+ {
+ await using var command = connection.CreateCommand();
+ command.CommandText = $"SELECT COUNT(*) FROM {QuoteSourceIdentifier(sourceType, tableName)};";
+ return Convert.ToInt64(await command.ExecuteScalarAsync(), CultureInfo.InvariantCulture);
+ }
+
+ private static async Task GetRowCountAsync(SqliteConnection connection, string tableName)
+ {
+ await using var command = connection.CreateCommand();
+ command.CommandText = $"SELECT COUNT(*) FROM {QuoteSqliteIdentifier(tableName)};";
+ return Convert.ToInt64(await command.ExecuteScalarAsync(), CultureInfo.InvariantCulture);
+ }
+
+ private static async Task VerifyTableContentAsync(DbConnection source, SourceDatabaseType sourceType, SqliteConnection target, string tableName, IReadOnlyList sharedColumns)
+ {
+ var sourceColumnTypes = await GetSourceColumnTypesAsync(source, sourceType, tableName);
+ var targetColumns = await GetSqliteColumnsAsync(target, tableName);
+ var targetColumnLookup = targetColumns.ToDictionary(column => column.Name, StringComparer.OrdinalIgnoreCase);
+ var guidColumns = sharedColumns
+ .Where(column => targetColumnLookup.TryGetValue(column, out var targetColumn) && IsGuidColumn(sourceType, sourceColumnTypes, targetColumn))
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ var orderColumns = await ResolveOrderColumnsAsync(source, sourceType, target, tableName, sharedColumns, sourceColumnTypes);
+
+ var sourceSql = BuildSourceOrderedSelectSql(sourceType, tableName, sharedColumns, orderColumns, sourceColumnTypes);
+ var targetSql = BuildSqliteOrderedSelectSql(tableName, sharedColumns, orderColumns);
+
+ await using var sourceCommand = source.CreateCommand();
+ sourceCommand.CommandText = sourceSql;
+ await using var sourceReader = await sourceCommand.ExecuteReaderAsync(CommandBehavior.SequentialAccess);
+ await using var targetCommand = target.CreateCommand();
+ targetCommand.CommandText = targetSql;
+ await using var targetReader = await targetCommand.ExecuteReaderAsync();
+
+ var rowNumber = 0L;
+ while (true)
+ {
+ var sourceHasRow = await sourceReader.ReadAsync();
+ var targetHasRow = await targetReader.ReadAsync();
+ if (sourceHasRow != targetHasRow)
+ {
+ throw new InvalidOperationException($"Verification failed for {tableName}: row stream length mismatch near row {rowNumber + 1}.");
+ }
+
+ if (!sourceHasRow)
+ {
+ return;
+ }
+
+ rowNumber++;
+ var sourceRow = await ReadNormalizedRowAsync(sourceReader, sharedColumns, guidColumns);
+ var targetRow = await ReadNormalizedRowAsync(targetReader, sharedColumns, guidColumns);
+ ThrowIfRowMismatch(tableName, sharedColumns, orderColumns, rowNumber, sourceRow, targetRow);
+ }
+ }
+
+ private static async Task> ResolveOrderColumnsAsync(
+ DbConnection source,
+ SourceDatabaseType sourceType,
+ SqliteConnection target,
+ string tableName,
+ IReadOnlyList sharedColumns,
+ IReadOnlyDictionary sourceColumnTypes)
+ {
+ var sourcePrimaryKeys = await GetSourcePrimaryKeyColumnsAsync(source, sourceType, tableName);
+ var targetPrimaryKeys = await GetSqlitePrimaryKeyColumnsAsync(target, tableName);
+ var orderColumns = sourcePrimaryKeys
+ .Where(column => targetPrimaryKeys.Contains(column, StringComparer.OrdinalIgnoreCase))
+ .Where(column => sharedColumns.Contains(column, StringComparer.OrdinalIgnoreCase))
+ .ToList();
+
+ if (orderColumns.Count > 0)
+ {
+ return orderColumns;
+ }
+
+ orderColumns = sharedColumns
+ .Where(column => sourceColumnTypes.TryGetValue(column, out var type) && IsSourceOrderableType(sourceType, type))
+ .ToList();
+
+ if (orderColumns.Count > 0)
+ {
+ return orderColumns;
+ }
+
+ throw new InvalidOperationException($"Verification failed for {tableName}: no primary key or SQL-orderable shared columns are available for deterministic comparison.");
+ }
+
+ private static async Task ReadNormalizedRowAsync(DbDataReader reader, IReadOnlyList columnNames, IReadOnlySet guidColumns)
+ {
+ var row = new string[columnNames.Count];
+ for (var index = 0; index < columnNames.Count; index++)
+ {
+ row[index] = NormalizeComparisonValue(await reader.IsDBNullAsync(index) ? null : reader.GetValue(index), guidColumns.Contains(columnNames[index]));
+ }
+
+ return row;
+ }
+
+ private static void ThrowIfRowMismatch(
+ string tableName,
+ IReadOnlyList sharedColumns,
+ IReadOnlyList orderColumns,
+ long rowNumber,
+ IReadOnlyList sourceRow,
+ IReadOnlyList targetRow)
+ {
+ for (var index = 0; index < sharedColumns.Count; index++)
+ {
+ var sourceValue = sourceRow[index];
+ var targetValue = targetRow[index];
+ if (string.Equals(sourceValue, targetValue, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ var keyDescription = string.Join(", ", orderColumns.Select(column =>
+ {
+ var keyIndex = -1;
+ for (var sharedColumnIndex = 0; sharedColumnIndex < sharedColumns.Count; sharedColumnIndex++)
+ {
+ if (!string.Equals(sharedColumns[sharedColumnIndex], column, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ keyIndex = sharedColumnIndex;
+ break;
+ }
+
+ var keyValue = keyIndex >= 0 ? sourceRow[keyIndex] : "";
+ return $"{column}={keyValue}";
+ }));
+ throw new InvalidOperationException($"Verification failed for {tableName}: column {sharedColumns[index]} mismatch at row {rowNumber} ({keyDescription}). Source={sourceValue}, Target={targetValue}.");
+ }
+ }
+
+ private static object NormalizeValue(object value, bool isGuidColumn)
+ {
+ return value switch
+ {
+ DBNull => DBNull.Value,
+ Guid guid => NormalizeGuid(guid),
+ string text when isGuidColumn && TryNormalizeGuidString(text, out var normalizedGuidText) => normalizedGuidText,
+ DateTimeOffset offset => offset.UtcDateTime,
+ _ => value,
+ };
+ }
+
+ private static string NormalizeDateTime(DateTime value)
+ {
+ if (value.TimeOfDay == TimeSpan.Zero)
+ {
+ return value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
+ }
+
+ return value.ToString("O", CultureInfo.InvariantCulture);
+ }
+
+ private static bool TryNormalizeTemporalString(string value, out string normalized)
+ {
+ if (DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dateTime))
+ {
+ normalized = NormalizeDateTime(dateTime);
+ return true;
+ }
+
+ if (DateOnly.TryParseExact(value, ["yyyy-MM-dd"], CultureInfo.InvariantCulture, DateTimeStyles.None, out var dateOnly))
+ {
+ normalized = dateOnly.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
+ return true;
+ }
+
+ normalized = string.Empty;
+ return false;
+ }
+
+ private static bool TryNormalizeGuidString(string value, out string normalized)
+ {
+ if (Guid.TryParse(value, out var guid))
+ {
+ normalized = NormalizeGuid(guid);
+ return true;
+ }
+
+ normalized = string.Empty;
+ return false;
+ }
+
+ private static string NormalizeGuid(Guid guid)
+ {
+ return guid.ToString("D").ToUpperInvariant();
+ }
+
+ private static bool IsGuidColumn(SourceDatabaseType sourceType, IReadOnlyDictionary sourceColumnTypes, SqliteColumnInfo targetColumn)
+ {
+ if (IsGuidType(targetColumn.Type))
+ {
+ return true;
+ }
+
+ return sourceColumnTypes.TryGetValue(targetColumn.Name, out var sourceTypeName) && IsGuidType(sourceType, sourceTypeName);
+ }
+
+ private static bool IsGuidType(SourceDatabaseType sourceType, string typeName)
+ {
+ return sourceType switch
+ {
+ SourceDatabaseType.SqlServer => typeName.Equals("uniqueidentifier", StringComparison.OrdinalIgnoreCase),
+ SourceDatabaseType.MySql => false,
+ _ => false,
+ };
+ }
+
+ private static bool IsGuidType(string typeName)
+ => typeName.Contains("UNIQUEIDENTIFIER", StringComparison.OrdinalIgnoreCase);
+
+ private static async Task> GetSourceTablesAsync(DbConnection connection, SourceDatabaseType sourceType)
+ {
+ return sourceType switch
+ {
+ SourceDatabaseType.SqlServer => await GetSqlServerTablesAsync((SqlConnection)connection),
+ SourceDatabaseType.MySql => await GetMySqlTablesAsync((MySqlConnection)connection),
+ _ => throw new InvalidOperationException($"Unsupported source database type: {sourceType}"),
+ };
+ }
+
+ private static async Task> GetSqlServerTablesAsync(SqlConnection connection)
+ {
+ const string sql = """
+ SELECT TABLE_NAME
+ FROM INFORMATION_SCHEMA.TABLES
+ WHERE TABLE_TYPE = 'BASE TABLE'
+ """;
+
+ var tables = new HashSet(StringComparer.OrdinalIgnoreCase);
+ await using var command = new SqlCommand(sql, connection);
+ await using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ tables.Add(reader.GetString(0));
+ }
+
+ return tables;
+ }
+
+ private static async Task> GetMySqlTablesAsync(MySqlConnection connection)
+ {
+ const string sql = """
+ SELECT TABLE_NAME
+ FROM INFORMATION_SCHEMA.TABLES
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_TYPE = 'BASE TABLE'
+ """;
+
+ var tables = new HashSet(StringComparer.OrdinalIgnoreCase);
+ await using var command = new MySqlCommand(sql, connection);
+ await using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ tables.Add(reader.GetString(0));
+ }
+
+ return tables;
+ }
+
+ private static async Task> GetSqliteTablesAsync(SqliteConnection connection)
+ {
+ const string sql = """
+ SELECT name
+ FROM sqlite_master
+ WHERE type = 'table'
+ AND name NOT LIKE 'sqlite_%'
+ ORDER BY name
+ """;
+
+ var tables = new List();
+ await using var command = connection.CreateCommand();
+ command.CommandText = sql;
+ await using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ tables.Add(reader.GetString(0));
+ }
+
+ return tables;
+ }
+
+ private static async Task> GetSourceColumnsAsync(DbConnection connection, SourceDatabaseType sourceType, string tableName)
+ {
+ return sourceType switch
+ {
+ SourceDatabaseType.SqlServer => await GetSqlServerColumnsAsync((SqlConnection)connection, tableName),
+ SourceDatabaseType.MySql => await GetMySqlColumnsAsync((MySqlConnection)connection, tableName),
+ _ => throw new InvalidOperationException($"Unsupported source database type: {sourceType}"),
+ };
+ }
+
+ private static async Task> GetSqlServerColumnsAsync(SqlConnection connection, string tableName)
+ {
+ var sql = $"""
+ SELECT COLUMN_NAME
+ FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_NAME = {TableNameParameter}
+ ORDER BY ORDINAL_POSITION
+ """;
+
+ var columns = new List();
+ await using var command = new SqlCommand(sql, connection);
+ command.Parameters.AddWithValue(TableNameParameter, tableName);
+ await using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ columns.Add(reader.GetString(0));
+ }
+
+ return columns;
+ }
+
+ private static async Task> GetMySqlColumnsAsync(MySqlConnection connection, string tableName)
+ {
+ var sql = $"""
+ SELECT COLUMN_NAME
+ FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = {TableNameParameter}
+ ORDER BY ORDINAL_POSITION
+ """;
+
+ var columns = new List();
+ await using var command = new MySqlCommand(sql, connection);
+ command.Parameters.AddWithValue(TableNameParameter, tableName);
+ await using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ columns.Add(reader.GetString(0));
+ }
+
+ return columns;
+ }
+
+ private static async Task> GetSourcePrimaryKeyColumnsAsync(DbConnection connection, SourceDatabaseType sourceType, string tableName)
+ {
+ return sourceType switch
+ {
+ SourceDatabaseType.SqlServer => await GetSqlServerPrimaryKeyColumnsAsync((SqlConnection)connection, tableName),
+ SourceDatabaseType.MySql => await GetMySqlPrimaryKeyColumnsAsync((MySqlConnection)connection, tableName),
+ _ => throw new InvalidOperationException($"Unsupported source database type: {sourceType}"),
+ };
+ }
+
+ private static async Task> GetSqlServerPrimaryKeyColumnsAsync(SqlConnection connection, string tableName)
+ {
+ var sql = $"""
+ SELECT COLUMN_NAME
+ FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
+ WHERE OBJECTPROPERTY(OBJECT_ID(CONSTRAINT_SCHEMA + '.' + QUOTENAME(CONSTRAINT_NAME)), 'IsPrimaryKey') = 1
+ AND TABLE_NAME = {TableNameParameter}
+ ORDER BY ORDINAL_POSITION
+ """;
+
+ var columns = new List();
+ await using var command = new SqlCommand(sql, connection);
+ command.Parameters.AddWithValue(TableNameParameter, tableName);
+ await using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ columns.Add(reader.GetString(0));
+ }
+
+ return columns;
+ }
+
+ private static async Task> GetMySqlPrimaryKeyColumnsAsync(MySqlConnection connection, string tableName)
+ {
+ var sql = $"""
+ SELECT COLUMN_NAME
+ FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = {TableNameParameter}
+ AND CONSTRAINT_NAME = 'PRIMARY'
+ ORDER BY ORDINAL_POSITION
+ """;
+
+ var columns = new List();
+ await using var command = new MySqlCommand(sql, connection);
+ command.Parameters.AddWithValue(TableNameParameter, tableName);
+ await using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ columns.Add(reader.GetString(0));
+ }
+
+ return columns;
+ }
+
+ private static async Task> GetSourceColumnTypesAsync(DbConnection connection, SourceDatabaseType sourceType, string tableName)
+ {
+ return sourceType switch
+ {
+ SourceDatabaseType.SqlServer => await GetSqlServerColumnTypesAsync((SqlConnection)connection, tableName),
+ SourceDatabaseType.MySql => await GetMySqlColumnTypesAsync((MySqlConnection)connection, tableName),
+ _ => throw new InvalidOperationException($"Unsupported source database type: {sourceType}"),
+ };
+ }
+
+ private static async Task> GetSqlServerColumnTypesAsync(SqlConnection connection, string tableName)
+ {
+ var sql = $"""
+ SELECT COLUMN_NAME, DATA_TYPE
+ FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_NAME = {TableNameParameter}
+ """;
+
+ var columns = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ await using var command = new SqlCommand(sql, connection);
+ command.Parameters.AddWithValue(TableNameParameter, tableName);
+ await using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ columns[reader.GetString(0)] = reader.GetString(1);
+ }
+
+ return columns;
+ }
+
+ private static async Task> GetMySqlColumnTypesAsync(MySqlConnection connection, string tableName)
+ {
+ var sql = $"""
+ SELECT COLUMN_NAME, DATA_TYPE
+ FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = {TableNameParameter}
+ """;
+
+ var columns = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ await using var command = new MySqlCommand(sql, connection);
+ command.Parameters.AddWithValue(TableNameParameter, tableName);
+ await using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ columns[reader.GetString(0)] = reader.GetString(1);
+ }
+
+ return columns;
+ }
+
+ private static async Task> GetSqlitePrimaryKeyColumnsAsync(SqliteConnection connection, string tableName)
+ {
+ var columns = new List<(int Position, string Name)>();
+ await using var command = connection.CreateCommand();
+ command.CommandText = $"PRAGMA table_info({QuoteSqliteLiteral(tableName)});";
+ await using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ var position = reader.GetInt32(5);
+ if (position > 0)
+ {
+ columns.Add((position, reader.GetString(1)));
+ }
+ }
+
+ return columns.OrderBy(column => column.Position).Select(column => column.Name).ToList();
+ }
+
+ private static async Task ReportSourceObjectsAsync(DbConnection connection, SourceDatabaseType sourceType, IReadOnlyList objectNames)
+ {
+ switch (sourceType)
+ {
+ case SourceDatabaseType.SqlServer:
+ await ReportSqlServerObjectsAsync((SqlConnection)connection, objectNames);
+ return;
+ case SourceDatabaseType.MySql:
+ await ReportMySqlObjectsAsync((MySqlConnection)connection, objectNames);
+ return;
+ default:
+ throw new InvalidOperationException($"Unsupported source database type: {sourceType}");
+ }
+ }
+
+ private static async Task ReportSqlServerObjectsAsync(SqlConnection connection, IReadOnlyList objectNames)
+ {
+ const string sql = """
+ SELECT s.name AS SchemaName, o.name AS ObjectName, o.type AS ObjectType, o.type_desc AS ObjectTypeDescription
+ FROM sys.objects o
+ INNER JOIN sys.schemas s ON s.schema_id = o.schema_id
+ WHERE o.name = @objectName
+ ORDER BY s.name, o.type_desc
+ """;
+
+ Console.WriteLine("Source object lookup for target-only names:");
+ foreach (var objectName in objectNames)
+ {
+ await using var command = new SqlCommand(sql, connection);
+ command.Parameters.AddWithValue("@objectName", objectName);
+ await using var reader = await command.ExecuteReaderAsync();
+ if (!reader.HasRows)
+ {
+ Console.WriteLine($" {objectName}: not found in sys.objects");
+ continue;
+ }
+
+ while (await reader.ReadAsync())
+ {
+ Console.WriteLine($" {objectName}: schema={reader.GetString(0)}, type={reader.GetString(2)}, type_desc={reader.GetString(3)}");
+ }
+ }
+ }
+
+ private static async Task ReportMySqlObjectsAsync(MySqlConnection connection, IReadOnlyList objectNames)
+ {
+ const string sql = """
+ SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE
+ FROM INFORMATION_SCHEMA.TABLES
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = @objectName
+ ORDER BY TABLE_TYPE, TABLE_NAME
+ """;
+
+ Console.WriteLine("Source object lookup for target-only names:");
+ foreach (var objectName in objectNames)
+ {
+ await using var command = new MySqlCommand(sql, connection);
+ command.Parameters.AddWithValue("@objectName", objectName);
+ await using var reader = await command.ExecuteReaderAsync();
+ if (!reader.HasRows)
+ {
+ Console.WriteLine($" {objectName}: not found in information_schema.tables");
+ continue;
+ }
+
+ while (await reader.ReadAsync())
+ {
+ Console.WriteLine($" {objectName}: schema={reader.GetString(0)}, type={reader.GetString(2)}");
+ }
+ }
+ }
+
+ private static object GetFallbackValue(string tableName, SqliteColumnInfo column)
+ {
+ if (string.Equals(tableName, "CrossRef_File_Episode", StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(column.Name, "CrossRefSource", StringComparison.OrdinalIgnoreCase))
+ {
+ return 1;
+ }
+
+ var normalizedType = (column.Type ?? string.Empty).ToUpperInvariant();
+ if (normalizedType.Contains("INT", StringComparison.Ordinal) ||
+ normalizedType.Contains("REAL", StringComparison.Ordinal) ||
+ normalizedType.Contains("NUM", StringComparison.Ordinal) ||
+ normalizedType.Contains("DEC", StringComparison.Ordinal) ||
+ normalizedType.Contains("BOOL", StringComparison.Ordinal))
+ {
+ return 0;
+ }
+
+ if (normalizedType.Contains("DATE", StringComparison.Ordinal) || normalizedType.Contains("TIME", StringComparison.Ordinal))
+ {
+ return new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ }
+
+ if (normalizedType.Contains("BLOB", StringComparison.Ordinal))
+ {
+ return Array.Empty();
+ }
+
+ return string.Empty;
+ }
+
+ private static async Task> GetSqliteColumnsAsync(SqliteConnection connection, string tableName)
+ {
+ var columns = new List();
+ await using var command = connection.CreateCommand();
+ command.CommandText = $"PRAGMA table_info({QuoteSqliteLiteral(tableName)});";
+ await using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ columns.Add(new()
+ {
+ Name = reader.GetString(1),
+ Type = await reader.IsDBNullAsync(2) ? string.Empty : reader.GetString(2),
+ NotNull = reader.GetInt32(3) != 0,
+ DefaultValue = await reader.IsDBNullAsync(4) ? null : reader.GetString(4),
+ });
+ }
+
+ return columns;
+ }
+
+ private static string BuildSourceOrderedSelectSql(SourceDatabaseType sourceType, string tableName, IReadOnlyList selectColumns, IReadOnlyList orderColumns, IReadOnlyDictionary columnTypes)
+ {
+ return sourceType switch
+ {
+ SourceDatabaseType.SqlServer => BuildSqlServerOrderedSelectSql(tableName, selectColumns, orderColumns, columnTypes),
+ SourceDatabaseType.MySql => BuildMySqlOrderedSelectSql(tableName, selectColumns, orderColumns),
+ _ => throw new InvalidOperationException($"Unsupported source database type: {sourceType}"),
+ };
+ }
+
+ private static string BuildSqlServerOrderedSelectSql(string tableName, IReadOnlyList selectColumns, IReadOnlyList orderColumns, IReadOnlyDictionary columnTypes)
+ {
+ var selectList = string.Join(", ", selectColumns.Select(column => BuildSqlServerSelectExpression(column, columnTypes)));
+ var orderList = string.Join(", ", orderColumns.Select(column => BuildSqlServerOrderExpression(column, columnTypes)));
+ return $"SELECT {selectList} FROM {QuoteSqlServerIdentifier(tableName)} ORDER BY {orderList};";
+ }
+
+ private static string BuildMySqlOrderedSelectSql(string tableName, IReadOnlyList selectColumns, IReadOnlyList orderColumns)
+ {
+ var selectList = string.Join(", ", selectColumns.Select(BuildMySqlSelectExpression));
+ var orderList = string.Join(", ", orderColumns.Select(BuildMySqlOrderExpression));
+ return $"SELECT {selectList} FROM {QuoteMySqlIdentifier(tableName)} ORDER BY {orderList};";
+ }
+
+ private static string BuildSqliteOrderedSelectSql(string tableName, IReadOnlyList selectColumns, IReadOnlyList orderColumns)
+ {
+ return $"SELECT {string.Join(", ", selectColumns.Select(QuoteSqliteIdentifier))} FROM {QuoteSqliteIdentifier(tableName)} ORDER BY {string.Join(", ", orderColumns.Select(QuoteSqliteIdentifier))};";
+ }
+
+ private static string BuildSqlServerSelectExpression(string columnName, IReadOnlyDictionary columnTypes)
+ {
+ var expression = BuildSqlServerOrderExpression(columnName, columnTypes);
+ return $"{expression} AS {QuoteSqlServerIdentifier(columnName)}";
+ }
+
+ private static string BuildSqlServerOrderExpression(string columnName, IReadOnlyDictionary columnTypes)
+ {
+ var identifier = QuoteSqlServerIdentifier(columnName);
+ if (columnTypes.TryGetValue(columnName, out var type) &&
+ type.Equals("uniqueidentifier", StringComparison.OrdinalIgnoreCase))
+ {
+ return $"LOWER(CONVERT(varchar(36), {identifier}))";
+ }
+
+ return identifier;
+ }
+
+ private static string BuildMySqlSelectExpression(string columnName)
+ {
+ return QuoteMySqlIdentifier(columnName);
+ }
+
+ private static string BuildMySqlOrderExpression(string columnName)
+ {
+ return QuoteMySqlIdentifier(columnName);
+ }
+
+ private static bool IsSourceOrderableType(SourceDatabaseType sourceType, string dataType)
+ {
+ return sourceType switch
+ {
+ SourceDatabaseType.SqlServer => IsSqlServerOrderableType(dataType),
+ SourceDatabaseType.MySql => IsMySqlOrderableType(dataType),
+ _ => false,
+ };
+ }
+
+ private static bool IsSqlServerOrderableType(string dataType)
+ => !dataType.Equals("text", StringComparison.OrdinalIgnoreCase) &&
+ !dataType.Equals("ntext", StringComparison.OrdinalIgnoreCase) &&
+ !dataType.Equals("image", StringComparison.OrdinalIgnoreCase);
+
+ private static bool IsMySqlOrderableType(string dataType)
+ => !dataType.Contains("blob", StringComparison.OrdinalIgnoreCase) &&
+ !dataType.Equals("json", StringComparison.OrdinalIgnoreCase) &&
+ !dataType.Equals("geometry", StringComparison.OrdinalIgnoreCase);
+
+ private static string NormalizeComparisonValue(object? value, bool isGuidColumn)
+ {
+ return value switch
+ {
+ null or DBNull => "",
+ byte[] bytes => Convert.ToHexString(bytes),
+ Guid guid => NormalizeGuid(guid),
+ string text when isGuidColumn && TryNormalizeGuidString(text, out var normalizedGuidText) => normalizedGuidText,
+ string text when TryNormalizeTemporalString(text, out var normalizedText) => normalizedText,
+ DateTime dateTime => NormalizeDateTime(dateTime),
+ DateTimeOffset offset => offset.ToString("O", CultureInfo.InvariantCulture),
+ bool boolean => boolean ? "1" : "0",
+ sbyte number => NormalizeNumericValue(number),
+ byte number => NormalizeNumericValue(number),
+ short number => NormalizeNumericValue(number),
+ ushort number => NormalizeNumericValue(number),
+ int number => NormalizeNumericValue(number),
+ uint number => NormalizeNumericValue(number),
+ long number => NormalizeNumericValue(number),
+ ulong number => NormalizeNumericValue(number),
+ decimal number => NormalizeNumericValue(number),
+ double number => NormalizeNumericValue(number),
+ float number => NormalizeNumericValue(number),
+ IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture) ?? string.Empty,
+ _ => value.ToString() ?? string.Empty,
+ };
+ }
+
+ private static string NormalizeNumericValue(T value)
+ where T : struct, ISpanFormattable
+ {
+ return value switch
+ {
+ decimal decimalValue => decimalValue.ToString("G29", CultureInfo.InvariantCulture),
+ double doubleValue => doubleValue.ToString("R", CultureInfo.InvariantCulture),
+ float floatValue => floatValue.ToString("R", CultureInfo.InvariantCulture),
+ _ => value.ToString(null, CultureInfo.InvariantCulture),
+ };
+ }
+
+ private static string ValidateIdentifier(string identifier)
+ {
+ if (string.IsNullOrWhiteSpace(identifier))
+ {
+ throw new InvalidOperationException("SQL identifier cannot be null or empty.");
+ }
+
+ if (!(char.IsLetter(identifier[0]) || identifier[0] == '_'))
+ {
+ throw new InvalidOperationException($"Unsupported SQL identifier: {identifier}");
+ }
+
+ for (var index = 1; index < identifier.Length; index++)
+ {
+ var character = identifier[index];
+ if (char.IsLetterOrDigit(character) || character == '_')
+ {
+ continue;
+ }
+
+ throw new InvalidOperationException($"Unsupported SQL identifier: {identifier}");
+ }
+
+ return identifier;
+ }
+
+ private static string QuoteSqlServerIdentifier(string identifier)
+ => $"[{ValidateIdentifier(identifier).Replace("]", "]]", StringComparison.Ordinal)}]";
+
+ private static string QuoteMySqlIdentifier(string identifier)
+ => $"`{ValidateIdentifier(identifier).Replace("`", "``", StringComparison.Ordinal)}`";
+
+ private static string QuoteSourceIdentifier(SourceDatabaseType sourceType, string identifier)
+ {
+ return sourceType switch
+ {
+ SourceDatabaseType.SqlServer => QuoteSqlServerIdentifier(identifier),
+ SourceDatabaseType.MySql => QuoteMySqlIdentifier(identifier),
+ _ => throw new InvalidOperationException($"Unsupported source database type: {sourceType}"),
+ };
+ }
+
+ private static string QuoteSqliteIdentifier(string identifier)
+ => $"\"{ValidateIdentifier(identifier).Replace("\"", "\"\"", StringComparison.Ordinal)}\"";
+
+ private static string QuoteSqliteLiteral(string value)
+ => $"'{ValidateIdentifier(value).Replace("'", "''", StringComparison.Ordinal)}'";
+
+ internal static SourceDatabaseType ParseSourceType(string value)
+ {
+ return value.Trim().ToLowerInvariant() switch
+ {
+ "mssql" or "sqlserver" or "sql-server" => SourceDatabaseType.SqlServer,
+ "mysql" or "mariadb" or "maria" => SourceDatabaseType.MySql,
+ _ => throw new ArgumentException($"Unsupported source type: {value}. Expected mssql or mariadb."),
+ };
+ }
+
+ internal static string GetUsage()
+ => """
+ Usage:
+ ShokoServer --convert-db [--source-type mssql|mariadb] [--source-connection-string ""] [--target-file "/path/to/Shoko.sqlite"] [--overwrite]
+
+ Notes:
+ - If omitted, source type and connection details default to the current ServerSettings.Database configuration.
+ - If omitted, the target SQLite file defaults to Shoko's normal SQLite database path under the current Shoko home/data directory.
+ - Explicit --source-type and/or --source-connection-string override the configured source details.
+ - Explicit --target-file overrides the default SQLite target path.
+ - The resolved source database must be SQL Server or MySQL/MariaDB. SQLite cannot be used as a source.
+ - This creates a fresh SQLite database using Shoko's built-in SQLite schema commands.
+ - Supported source backends: SQL Server and MySQL/MariaDB.
+ - It copies tables and columns shared by the source schema and target SQLite schema.
+ - Quartz tables are not included because Quartz uses a separate database configuration.
+ """;
+
+ private readonly record struct DatabaseVersionInfo(int Version, int Revision, string? Program);
+
+ private sealed class SqliteColumnInfo
+ {
+ public string Name { get; set; } = string.Empty;
+ public string Type { get; set; } = string.Empty;
+ public bool NotNull { get; set; }
+ public string? DefaultValue { get; set; }
+ }
+}
diff --git a/Shoko.Server/Services/SystemService.cs b/Shoko.Server/Services/SystemService.cs
index 4a37d5087d..3e9a6bb21c 100644
--- a/Shoko.Server/Services/SystemService.cs
+++ b/Shoko.Server/Services/SystemService.cs
@@ -5,6 +5,7 @@
using System.IO;
using System.Net;
using System.Runtime.InteropServices;
+using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Timers;
@@ -66,6 +67,7 @@ namespace Shoko.Server.Services;
public class SystemService : ISystemService
{
private readonly ILogger _logger;
+ private readonly DatabaseConversionOptions? _databaseConversionOptions;
private readonly PluginManager _pluginManager;
@@ -78,14 +80,18 @@ public class SystemService : ISystemService
private Timer? _autoUpdateTimer;
private IHost? _webHost;
+ private bool _hostStarted;
+ private bool _oneShotModeCompleted;
- public SystemService()
+ public SystemService(string[]? startupArgs = null)
{
var now = DateTime.UtcNow;
- var args = Environment.GetCommandLineArgs();
+ var args = startupArgs ?? Environment.GetCommandLineArgs().Skip(1).ToArray();
ApplicationPaths.SetHome(args);
+ _databaseConversionOptions = DatabaseConversionOptions.TryParse(args, out var conversionOptions) ? conversionOptions : null;
+
LogService.InitLogger(ApplicationPaths.Instance);
var loggerFactory = LoggerFactory.Create(o => o.AddNLog());
@@ -197,6 +203,7 @@ private set
///
public async Task StartAsync()
{
+ IDisposable? conversionIsolation = null;
try
{
// Check if any of the DLL are blocked, common issue with daily builds.
@@ -208,6 +215,27 @@ private set
}
var settings = _settingsProvider.GetSettings();
+ ISettingsProvider effectiveSettingsProvider = _settingsProvider;
+
+ if (_databaseConversionOptions?.ShowHelp == true)
+ {
+ Console.WriteLine(DatabaseConversionService.GetUsage());
+ CompleteOneShotShutdown();
+ return null;
+ }
+
+ DatabaseConversionService.ConversionRuntimeContext? conversionRuntime = null;
+ if (_databaseConversionOptions is not null)
+ {
+ var realSettings = (ServerSettings)_settingsProvider.GetSettings(copy: true);
+ conversionRuntime = DatabaseConversionService.PrepareRuntime(_databaseConversionOptions, realSettings);
+ // Enter conversion isolation before starting log maintenance, plugin scanning,
+ // host building, or Quartz registration so any startup side effects are scoped
+ // to the temporary conversion home instead of the user's real Shoko home.
+ conversionIsolation = DatabaseConversionService.BeginIsolatedRuntime(conversionRuntime.Value);
+ effectiveSettingsProvider = conversionRuntime.Value.RuntimeSettingsProvider;
+ settings = effectiveSettingsProvider.GetSettings();
+ }
LogService.ApplyLoggingSettings(settings.Logging);
@@ -248,7 +276,7 @@ private set
StartupMessage = "Initializing Web Host & Services.";
- _webHost = InitWebHost(settings);
+ _webHost = InitWebHost(settings, effectiveSettingsProvider);
#pragma warning disable CS0618 // Type or member is obsolete
ISystemService.StaticServices = _webHost.Services;
@@ -263,10 +291,17 @@ private set
StartupMessage = "Plugins initialized.";
+ if (conversionRuntime is not null)
+ {
+ await RunDatabaseConversionModeAsync(conversionRuntime.Value, hostStarted: false);
+ return _webHost;
+ }
+
StartupMessage = "Starting Web Hosts.";
// Start the web server and all IHostedService services.
await _webHost.StartAsync();
+ _hostStarted = true;
StartupMessage = "Web Host started.";
@@ -301,6 +336,43 @@ private set
StartupFailedException = new(innerException: ex);
return null;
}
+ finally
+ {
+ conversionIsolation?.Dispose();
+ }
+ }
+
+ private async Task RunDatabaseConversionModeAsync(DatabaseConversionService.ConversionRuntimeContext context, bool hostStarted)
+ {
+ try
+ {
+ var cancellationToken = hostStarted
+ ? CancellationTokenSource.CreateLinkedTokenSource(_webHost!.Services.GetRequiredService().ApplicationStopping, _shutdownTokenSource.Token).Token
+ : _shutdownTokenSource.Token;
+ if (cancellationToken.IsCancellationRequested)
+ return;
+
+ await DatabaseConversionService.RunAsync(this, _webHost!.Services, context, cancellationToken);
+
+ _startupTaskSource?.SetResult();
+ _startupTaskSource = null;
+ }
+ catch (Exception ex)
+ {
+ StartupMessage = "Database conversion failed.";
+ StartupFailedException = new($"Database conversion failed. Error Message: {ex.Message}", innerException: ex);
+ }
+ finally
+ {
+ if (hostStarted)
+ {
+ _webHost?.Services.GetRequiredService().StopApplication();
+ }
+ else
+ {
+ CompleteOneShotShutdown();
+ }
+ }
}
public Task WaitForStartupAsync()
@@ -339,14 +411,14 @@ private bool CheckBlockedFiles()
#region Startup | Services
- private IHost InitWebHost(IServerSettings settings)
+ private IHost InitWebHost(IServerSettings settings, ISettingsProvider settingsProvider)
=> new HostBuilder()
.ConfigureWebHost(webHostBuilder =>
webHostBuilder
.UseKestrel(options => options.ListenAnyIP(settings.Web.Port))
.ConfigureApp()
.ConfigureServiceProvider()
- .UseStartup(_ => new Startup(this, _logService, _configurationService, _settingsProvider, _pluginManager))
+ .UseStartup(_ => new Startup(this, _logService, _configurationService, settingsProvider, _pluginManager))
.ConfigureLogging(logging =>
{
logging.ClearProviders();
@@ -358,7 +430,7 @@ private IHost InitWebHost(IServerSettings settings)
#endif
})
.UseNLog()
- .UseSentryConfig(_settingsProvider)
+ .UseSentryConfig(settingsProvider)
)
.Build();
@@ -698,6 +770,9 @@ private bool InitializeDatabase(DatabaseFactory databaseFactory, RepoFactory rep
}
}
+ internal bool InitializeDatabaseForConversion(DatabaseFactory databaseFactory, RepoFactory repositoryFactory, CancellationToken cancellationToken)
+ => InitializeDatabase(databaseFactory, repositoryFactory, cancellationToken);
+
#endregion
#endregion
@@ -756,7 +831,28 @@ public bool RequestShutdown()
///
public Task WaitForShutdownAsync()
- => _webHost?.WaitForShutdownAsync() ?? Task.CompletedTask;
+ => _oneShotModeCompleted ? Task.CompletedTask : _webHost?.WaitForShutdownAsync() ?? Task.CompletedTask;
+
+ private void CompleteOneShotShutdown()
+ {
+ lock (_logger)
+ {
+ if (!RestartPending && !ShutdownPending)
+ ShutdownPending = true;
+ }
+
+ _shutdownTokenSource.Cancel();
+ _oneShotModeCompleted = true;
+
+ try
+ {
+ Shutdown?.Invoke(this, EventArgs.Empty);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error while invoking Shutdown");
+ }
+ }
///
internal void OnShutdown()
@@ -769,7 +865,7 @@ internal void OnShutdown()
}
_shutdownTokenSource.Cancel();
- if (_webHost is not null)
+ if (_hostStarted && _webHost is not null)
{
_autoUpdateTimer?.Stop();
diff --git a/Shoko.Tests/DatabaseConversionOptionsTests.cs b/Shoko.Tests/DatabaseConversionOptionsTests.cs
new file mode 100644
index 0000000000..b3c14c5961
--- /dev/null
+++ b/Shoko.Tests/DatabaseConversionOptionsTests.cs
@@ -0,0 +1,276 @@
+using System;
+using System.IO;
+using Shoko.Server.Services;
+using Shoko.Server.Settings;
+using Shoko.Server.Server;
+using Xunit;
+
+namespace Shoko.Tests;
+
+public class DatabaseConversionOptionsTests
+{
+ public static TheoryData ConversionModeArguments =>
+ [
+ ["--convert-db"],
+ ["--home", "/tmp/shoko", "--convert-db"],
+ ];
+
+ [Theory]
+ [MemberData(nameof(ConversionModeArguments))]
+ public void ShouldDetectConversionModeIndependentOfPosition(string[] args)
+ {
+ var detected = DatabaseConversionOptions.TryParse(args, out var options);
+
+ Assert.True(detected);
+ Assert.NotNull(options);
+ }
+
+ [Fact]
+ public void ShouldParseKnownConversionArgumentsWhileIgnoringHostArguments()
+ {
+ var args = new[]
+ {
+ "--convert-db",
+ "--home", "/tmp/shoko",
+ "--source-type", "mariadb",
+ "--source-connection-string", "Server=127.0.0.1;Database=shoko;",
+ "--target-file", "/tmp/Shoko.db3",
+ "--overwrite",
+ };
+
+ var detected = DatabaseConversionOptions.TryParse(args, out var options);
+
+ Assert.True(detected);
+ Assert.NotNull(options);
+ Assert.Equal(DatabaseConversionService.SourceDatabaseType.MySql, options.SourceType);
+ Assert.Equal("Server=127.0.0.1;Database=shoko;", options.SourceConnectionString);
+ Assert.Equal("/tmp/Shoko.db3", options.TargetFile);
+ Assert.True(options.Overwrite);
+ }
+
+ [Fact]
+ public void ShouldNotDetectConversionModeWhenModeArgumentIsMissing()
+ {
+ var detected = DatabaseConversionOptions.TryParse(["--home", "/tmp/shoko"], out var options);
+
+ Assert.False(detected);
+ Assert.Null(options);
+ }
+
+ [Fact]
+ public void ShouldNotDetectConversionModeWhenHomeValueMatchesModeToken()
+ {
+ var detected = DatabaseConversionOptions.TryParse(["--home", "convert-db"], out var options);
+
+ Assert.False(detected);
+ Assert.Null(options);
+ }
+
+ [Fact]
+ public void ShouldNotTreatTargetFileValueAsModeTokenWithoutActualModeArgument()
+ {
+ var detected = DatabaseConversionOptions.TryParse(["--target-file", "convert-db"], out var options);
+
+ Assert.False(detected);
+ Assert.Null(options);
+ }
+
+ [Fact]
+ public void ShouldNotDetectConversionModeWhenFutureOptionValueMatchesModeToken()
+ {
+ var detected = DatabaseConversionOptions.TryParse(["--some-future-option", "convert-db"], out var options);
+
+ Assert.False(detected);
+ Assert.Null(options);
+ }
+
+ [Fact]
+ public void ShouldNotDetectConversionModeForBareConvertDbToken()
+ {
+ var detected = DatabaseConversionOptions.TryParse(["convert-db"], out var options);
+
+ Assert.False(detected);
+ Assert.Null(options);
+ }
+
+ [Fact]
+ public void ShouldUseConfiguredSettingsAsSourceWhenSourceArgsAreOmitted()
+ {
+ var settings = new ServerSettings
+ {
+ Database =
+ {
+ Type = Constants.DatabaseType.MySQL,
+ Host = "db.example:3307",
+ Username = "user",
+ Password = "pass",
+ Schema = "shoko",
+ }
+ };
+ var options = new DatabaseConversionOptions
+ {
+ TargetFile = "/tmp/Shoko.db3",
+ };
+
+ var resolved = DatabaseConversionService.ResolveSource(options, settings);
+
+ Assert.Equal(DatabaseConversionService.SourceDatabaseType.MySql, resolved.SourceType);
+ Assert.Equal("Server=db.example;Port=3307;Database=shoko;User ID=user;Password=pass;Default Command Timeout=3600;Allow User Variables=true", resolved.SourceConnectionString);
+ }
+
+ [Fact]
+ public void ExplicitSourceArgsShouldOverrideConfiguredSettings()
+ {
+ var settings = new ServerSettings
+ {
+ Database =
+ {
+ Type = Constants.DatabaseType.MySQL,
+ Host = "db.example:3307",
+ Username = "user",
+ Password = "pass",
+ Schema = "shoko",
+ }
+ };
+ var options = new DatabaseConversionOptions
+ {
+ SourceType = DatabaseConversionService.SourceDatabaseType.SqlServer,
+ SourceTypeProvided = true,
+ SourceConnectionString = "Server=override;Database=override;",
+ SourceConnectionStringProvided = true,
+ TargetFile = "/tmp/Shoko.db3",
+ };
+
+ var resolved = DatabaseConversionService.ResolveSource(options, settings);
+
+ Assert.Equal(DatabaseConversionService.SourceDatabaseType.SqlServer, resolved.SourceType);
+ Assert.Equal("Server=override;Database=override;", resolved.SourceConnectionString);
+ }
+
+ [Fact]
+ public void ConfiguredSqliteSourceShouldFailClearly()
+ {
+ var settings = new ServerSettings
+ {
+ Database =
+ {
+ Type = Constants.DatabaseType.SQLite,
+ }
+ };
+ var options = new DatabaseConversionOptions
+ {
+ TargetFile = "/tmp/Shoko.db3",
+ };
+
+ var ex = Assert.Throws(() => DatabaseConversionService.ResolveSource(options, settings));
+
+ Assert.Contains("SQLite", ex.Message);
+ }
+
+ [Fact]
+ public void OmittedTargetFileShouldResolveToDefaultSqlitePath()
+ {
+ var settings = new ServerSettings
+ {
+ Database =
+ {
+ MySqliteDirectory = "SQLite",
+ SQLite_DatabaseFile = "Shoko.db3",
+ }
+ };
+ var options = new DatabaseConversionOptions();
+
+ var resolved = DatabaseConversionService.ResolveTarget(options, settings);
+
+ Assert.Equal(Path.GetFullPath(Path.Combine(ApplicationPaths.StaticDataPath, "SQLite", "Shoko.db3")), resolved.TargetFile);
+ }
+
+ [Fact]
+ public void ExplicitTargetFileShouldOverrideDefaultSqlitePath()
+ {
+ var settings = new ServerSettings
+ {
+ Database =
+ {
+ MySqliteDirectory = "SQLite",
+ SQLite_DatabaseFile = "Shoko.db3",
+ }
+ };
+ var options = new DatabaseConversionOptions
+ {
+ TargetFile = "/tmp/custom.db3",
+ TargetFileProvided = true,
+ };
+
+ var resolved = DatabaseConversionService.ResolveTarget(options, settings);
+
+ Assert.Equal(Path.GetFullPath("/tmp/custom.db3"), resolved.TargetFile);
+ }
+
+ [Fact]
+ public void ExistingTargetFileShouldFailWithoutOverwrite()
+ {
+ var targetFile = Path.Combine(Path.GetTempPath(), $"shoko-converter-target-{Guid.NewGuid():N}.db3");
+ try
+ {
+ File.WriteAllText(targetFile, "existing");
+
+ var ex = Assert.Throws(() => DatabaseConversionService.PrepareTargetPath(targetFile, overwrite: false));
+
+ Assert.Contains("already exists", ex.Message);
+ }
+ finally
+ {
+ if (File.Exists(targetFile))
+ {
+ File.Delete(targetFile);
+ }
+ }
+ }
+
+ [Fact]
+ public void PreparedConversionRuntimeShouldIsolateQuartzAndTargetBootstrapPaths()
+ {
+ var realQuartzConnectionString = $"Data Source={Path.Combine(ApplicationPaths.StaticDataPath, "SQLite", "Quartz.db3")};Mode=ReadWriteCreate;Pooling=True";
+ var settings = new ServerSettings
+ {
+ Database =
+ {
+ Type = Constants.DatabaseType.MySQL,
+ Host = "db.example:3306",
+ Username = "user",
+ Password = "pass",
+ Schema = "shoko",
+ },
+ Quartz =
+ {
+ DatabaseType = Constants.DatabaseType.SQLServer,
+ ConnectionString = realQuartzConnectionString,
+ },
+ };
+ var options = new DatabaseConversionOptions
+ {
+ TargetFile = Path.Combine(Path.GetTempPath(), $"shoko-converter-target-{Guid.NewGuid():N}.db3"),
+ TargetFileProvided = true,
+ };
+
+ try
+ {
+ var runtime = DatabaseConversionService.PrepareRuntime(options, settings);
+ var runtimeSettings = (ServerSettings)runtime.RuntimeSettingsProvider.GetSettings(copy: true);
+
+ Assert.Equal(Constants.DatabaseType.SQLite, runtimeSettings.Database.Type);
+ Assert.Contains(runtime.PreparedTargetFile, runtimeSettings.Database.OverrideConnectionString, StringComparison.Ordinal);
+ Assert.Equal(Constants.DatabaseType.SQLite, runtimeSettings.Quartz.DatabaseType);
+ Assert.Contains(runtime.TemporaryHomePath, runtimeSettings.Quartz.ConnectionString, StringComparison.Ordinal);
+ Assert.DoesNotContain(realQuartzConnectionString, runtimeSettings.Quartz.ConnectionString, StringComparison.Ordinal);
+ }
+ finally
+ {
+ if (File.Exists(options.TargetFile))
+ {
+ File.Delete(options.TargetFile);
+ }
+ }
+ }
+}