From c8a693ae1798e0dfad447f6fa93f892736f7c737 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Fri, 11 Sep 2026 23:56:19 +1000 Subject: [PATCH] feat(tools): add SQLite to PostgreSQL migration tool Existing installs hold their whole library in jellyfin.db, so switching to the PostgreSQL provider needs a one-shot data move. - add Jellyfin.DbMigrator reading SQLite tables and bulk-writing them to PostgreSQL - validate table names against the source schema before generating SQL - upload a pre-migration copy of the SQLite file to S3 when S3_BACKUP_BUCKET is set - emit a per-table row-count report and support a dry-run mode --- Directory.Packages.props | 1 + Jellyfin.sln | 9 + .../Jellyfin.DbMigrator.csproj | 19 ++ tools/Jellyfin.DbMigrator/MigrationReport.cs | 58 ++++ .../Jellyfin.DbMigrator/PostgresBulkWriter.cs | 261 ++++++++++++++++++ tools/Jellyfin.DbMigrator/Program.cs | 244 ++++++++++++++++ .../Jellyfin.DbMigrator/SqliteTableReader.cs | 97 +++++++ .../Jellyfin.DbMigrator/TableNameValidator.cs | 39 +++ 8 files changed, 728 insertions(+) create mode 100644 tools/Jellyfin.DbMigrator/Jellyfin.DbMigrator.csproj create mode 100644 tools/Jellyfin.DbMigrator/MigrationReport.cs create mode 100644 tools/Jellyfin.DbMigrator/PostgresBulkWriter.cs create mode 100644 tools/Jellyfin.DbMigrator/Program.cs create mode 100644 tools/Jellyfin.DbMigrator/SqliteTableReader.cs create mode 100644 tools/Jellyfin.DbMigrator/TableNameValidator.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index bf6f9eaebd..a00f24b1a7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,6 +8,7 @@ + diff --git a/Jellyfin.sln b/Jellyfin.sln index 28ec04f50d..c8ef244319 100644 --- a/Jellyfin.sln +++ b/Jellyfin.sln @@ -103,6 +103,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Implement EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.CodeAnalysis", "src\Jellyfin.CodeAnalysis\Jellyfin.CodeAnalysis.csproj", "{11643D0F-6761-4EF7-AB71-6F9F8DE00714}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{3C85DA50-31AC-40D3-BCF4-F1B14C420996}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.DbMigrator", "tools\Jellyfin.DbMigrator\Jellyfin.DbMigrator.csproj", "{6F7187CB-E1CB-4583-98CF-0FB87F21E844}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Drawing.Skia.Tests", "tests\Jellyfin.Drawing.Skia.Tests\Jellyfin.Drawing.Skia.Tests.csproj", "{E24A279C-9A37-419A-8F9C-853C11FBE753}" EndProject Global @@ -279,6 +283,10 @@ Global {11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Debug|Any CPU.Build.0 = Debug|Any CPU {11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Release|Any CPU.ActiveCfg = Release|Any CPU {11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Release|Any CPU.Build.0 = Release|Any CPU + {6F7187CB-E1CB-4583-98CF-0FB87F21E844}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6F7187CB-E1CB-4583-98CF-0FB87F21E844}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6F7187CB-E1CB-4583-98CF-0FB87F21E844}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6F7187CB-E1CB-4583-98CF-0FB87F21E844}.Release|Any CPU.Build.0 = Release|Any CPU {E24A279C-9A37-419A-8F9C-853C11FBE753}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E24A279C-9A37-419A-8F9C-853C11FBE753}.Debug|Any CPU.Build.0 = Debug|Any CPU {E24A279C-9A37-419A-8F9C-853C11FBE753}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -317,6 +325,7 @@ Global {B3C4D5E6-F7A8-4B9C-0D1E-2F3A4B5C6D7E} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD} {8C9F9221-8415-496C-B1F5-E7756F03FA59} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD} {11643D0F-6761-4EF7-AB71-6F9F8DE00714} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} + {6F7187CB-E1CB-4583-98CF-0FB87F21E844} = {3C85DA50-31AC-40D3-BCF4-F1B14C420996} {E24A279C-9A37-419A-8F9C-853C11FBE753} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution diff --git a/tools/Jellyfin.DbMigrator/Jellyfin.DbMigrator.csproj b/tools/Jellyfin.DbMigrator/Jellyfin.DbMigrator.csproj new file mode 100644 index 0000000000..dffc1ca5b8 --- /dev/null +++ b/tools/Jellyfin.DbMigrator/Jellyfin.DbMigrator.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + true + enable + enable + true + Jellyfin.DbMigrator + + + + + + + + + diff --git a/tools/Jellyfin.DbMigrator/MigrationReport.cs b/tools/Jellyfin.DbMigrator/MigrationReport.cs new file mode 100644 index 0000000000..9b8f4d27a7 --- /dev/null +++ b/tools/Jellyfin.DbMigrator/MigrationReport.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; + +namespace Jellyfin.DbMigrator; + +/// +/// Represents the migration result for a single table. +/// +/// The name of the table. +/// The number of rows read from SQLite. +/// The number of rows verified in PostgreSQL after migration. +/// The error message if migration failed, or on success. +public sealed record TableReport( + string TableName, + long SqliteRowCount, + long PostgresRowCount, + string? Error); + +/// +/// Provides utilities for collecting and printing the migration report. +/// +public static class MigrationReport +{ + /// + /// Prints a formatted summary of per-table migration results to the console. + /// + /// The collection of per-table results. + public static void Print(IReadOnlyList reports) + { + Console.WriteLine(); + Console.WriteLine("=== Migration Report ==="); + Console.WriteLine( + $"{"Table",-40} {"SQLite",10} {"PostgreSQL",10} {"Status",-10}"); + Console.WriteLine(new string('-', 74)); + + int failed = 0; + foreach (var r in reports) + { + string status = r.Error is null ? "OK" : "FAILED"; + if (r.Error is not null) + { + failed++; + } + + Console.WriteLine( + $"{r.TableName,-40} {r.SqliteRowCount,10} {r.PostgresRowCount,10} {status,-10}"); + + if (r.Error is not null) + { + Console.WriteLine($" Error: {r.Error}"); + } + } + + Console.WriteLine(new string('-', 74)); + Console.WriteLine( + $"Total: {reports.Count} tables, {failed} failed, {reports.Count - failed} succeeded."); + } +} diff --git a/tools/Jellyfin.DbMigrator/PostgresBulkWriter.cs b/tools/Jellyfin.DbMigrator/PostgresBulkWriter.cs new file mode 100644 index 0000000000..b78e6cbe63 --- /dev/null +++ b/tools/Jellyfin.DbMigrator/PostgresBulkWriter.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace Jellyfin.DbMigrator; + +/// +/// Writes rows to a PostgreSQL database using batched INSERT statements. +/// +public static class PostgresBulkWriter +{ + /// + /// The maximum number of rows per INSERT batch. + /// + private const int BatchSize = 500; + + /// + /// Inserts all rows into the specified PostgreSQL table using batched INSERT statements. + /// When is , logs what would be inserted without writing. + /// + /// An open . + /// The name of the target PostgreSQL table. + /// The rows to insert, as dictionaries mapping column name to value. + /// When , skips actual writes. + /// A token to cancel the operation. + /// The number of rows that were inserted (or would have been inserted in dry-run mode). + public static async Task WriteTableAsync( + NpgsqlConnection connection, + string tableName, + IReadOnlyList> rows, + bool isDryRun, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(connection); + ArgumentException.ThrowIfNullOrWhiteSpace(tableName); + TableNameValidator.EnsureSafe(tableName); + ArgumentNullException.ThrowIfNull(rows); + + if (rows.Count == 0) + { + return 0L; + } + + // Collect column names from the first row. + var columns = new List(rows[0].Keys); + + if (isDryRun) + { + Console.WriteLine( + $" [dry-run] Would insert {rows.Count} rows into \"{tableName}\" " + + $"({string.Join(", ", columns)})."); + return rows.Count; + } + + long inserted = 0L; + + for (int offset = 0; offset < rows.Count; offset += BatchSize) + { + int end = Math.Min(offset + BatchSize, rows.Count); + int batchCount = end - offset; + + var sql = BuildInsertSql(tableName, columns, batchCount); + + var cmd = connection.CreateCommand(); + await using (cmd.ConfigureAwait(false)) + { + cmd.CommandText = sql; + + int paramIndex = 0; + for (int rowIdx = offset; rowIdx < end; rowIdx++) + { + var row = rows[rowIdx]; + foreach (var col in columns) + { + string paramName = $"p{paramIndex.ToString(CultureInfo.InvariantCulture)}"; + row.TryGetValue(col, out object? val); + cmd.Parameters.AddWithValue(paramName, val ?? DBNull.Value); + paramIndex++; + } + } + + await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + inserted += batchCount; + } + } + + return inserted; + } + + /// + /// Advances the PostgreSQL integer sequence for each table that contains an Id column, + /// so that future auto-generated primary keys do not conflict with migrated data. + /// + /// An open . + /// The names of the tables whose sequences should be advanced. + /// When , logs the SQL without executing it. + /// A token to cancel the operation. + public static async Task AdvanceSequencesAsync( + NpgsqlConnection connection, + IEnumerable tableNames, + bool isDryRun, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(connection); + ArgumentNullException.ThrowIfNull(tableNames); + + foreach (var tableName in tableNames) + { + // Check if the table has an "Id" column. + bool hasIdColumn = await TableHasColumnAsync( + connection, tableName, "Id", cancellationToken).ConfigureAwait(false); + + if (!hasIdColumn) + { + continue; + } + + string sql = + $"SELECT setval(pg_get_serial_sequence('{tableName}', 'Id'), " + + $"COALESCE((SELECT MAX(\"Id\") FROM \"{tableName}\"), 1))"; + + if (isDryRun) + { + Console.WriteLine($" [dry-run] Would advance sequence: {sql}"); + continue; + } + + var cmd = connection.CreateCommand(); + await using (cmd.ConfigureAwait(false)) + { + cmd.CommandText = sql; + try + { + await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + // Sequence may not exist for tables without serial PK — log and continue. + Console.WriteLine( + $" Warning: Could not advance sequence for \"{tableName}\": {ex.Message}"); + } + } + } + } + + /// + /// Returns the number of rows currently in the specified PostgreSQL table. + /// + /// An open . + /// The name of the table to count. + /// A token to cancel the operation. + /// The row count, or -1 if the table does not exist. + public static async Task CountRowsAsync( + NpgsqlConnection connection, + string tableName, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(connection); + ArgumentException.ThrowIfNullOrWhiteSpace(tableName); + TableNameValidator.EnsureSafe(tableName); + + var cmd = connection.CreateCommand(); + await using (cmd.ConfigureAwait(false)) + { + cmd.CommandText = $"SELECT COUNT(*) FROM \"{tableName}\""; + try + { + var result = await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + return result is long count ? count : Convert.ToInt64(result, CultureInfo.InvariantCulture); + } + catch (NpgsqlException) + { + return -1L; + } + } + } + + /// + /// Builds a parameterised bulk INSERT SQL statement for the given table, columns, and row count. + /// + /// The target table name. + /// The ordered list of column names. + /// The number of value-rows to include. + /// A parameterised INSERT statement. + private static string BuildInsertSql(string tableName, IReadOnlyList columns, int rowCount) + { + var sb = new StringBuilder(); + sb.Append(CultureInfo.InvariantCulture, $"INSERT INTO \"{tableName}\" ("); + + for (int i = 0; i < columns.Count; i++) + { + if (i > 0) + { + sb.Append(", "); + } + + sb.Append(CultureInfo.InvariantCulture, $"\"{columns[i]}\""); + } + + sb.Append(") VALUES "); + + int paramIndex = 0; + for (int row = 0; row < rowCount; row++) + { + if (row > 0) + { + sb.Append(", "); + } + + sb.Append('('); + for (int col = 0; col < columns.Count; col++) + { + if (col > 0) + { + sb.Append(", "); + } + + sb.Append(CultureInfo.InvariantCulture, $"@p{paramIndex.ToString(CultureInfo.InvariantCulture)}"); + paramIndex++; + } + + sb.Append(')'); + } + + sb.Append(" ON CONFLICT DO NOTHING"); + + return sb.ToString(); + } + + /// + /// Checks whether a given column exists in a PostgreSQL table. + /// + /// An open . + /// The table name to check. + /// The column name to look for. + /// A token to cancel the operation. + /// if the column exists; otherwise, . + private static async Task TableHasColumnAsync( + NpgsqlConnection connection, + string tableName, + string columnName, + CancellationToken cancellationToken = default) + { + var cmd = connection.CreateCommand(); + await using (cmd.ConfigureAwait(false)) + { + cmd.CommandText = + "SELECT COUNT(*) FROM information_schema.columns " + + "WHERE table_name = @table AND column_name = @col"; + cmd.Parameters.AddWithValue("table", tableName); + cmd.Parameters.AddWithValue("col", columnName); + var result = await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + long count = result is long l ? l : Convert.ToInt64(result, CultureInfo.InvariantCulture); + return count > 0; + } + } +} diff --git a/tools/Jellyfin.DbMigrator/Program.cs b/tools/Jellyfin.DbMigrator/Program.cs new file mode 100644 index 0000000000..245979e26f --- /dev/null +++ b/tools/Jellyfin.DbMigrator/Program.cs @@ -0,0 +1,244 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Amazon; +using Amazon.S3; +using Amazon.S3.Transfer; +using Jellyfin.DbMigrator; +using Microsoft.Data.Sqlite; +using Npgsql; + +// --------------------------------------------------------------------------- +// Ordered table list (respects FK constraints). +// --------------------------------------------------------------------------- +string[] tableOrder = +[ + // Group 1 – no FK dependencies + "Users", + "ApiKeys", + "Devices", + "DeviceOptions", + + // Group 2 – BaseItems (self-referencing FK only) + "BaseItems", + + // Group 3 – children of BaseItems + ItemValues + "AncestorIds", + "BaseItemImageInfos", + "BaseItemMetadataFields", + "BaseItemTrailerTypes", + "BaseItemProviders", + "Chapters", + "ItemValues", + "ItemValuesMap", + "MediaStreamInfos", + "AttachmentStreamInfos", + "KeyframeData", + + // Group 4 – People + "Peoples", + "PeopleBaseItemMap", + + // Group 5 – User-related data + "UserData", + "MediaSegments", + "TrickplayInfos", + + // Group 6 – Misc / user preferences + "ActivityLogs", + "AccessSchedules", + "Permissions", + "Preferences", + "DisplayPreferences", + "ItemDisplayPreferences", + "CustomItemDisplayPreferences", + "ImageInfos", +]; + +// --------------------------------------------------------------------------- +// Parse command-line arguments. +// --------------------------------------------------------------------------- +string? sqlitePath = null; +string? postgresConnectionString = null; +bool isDryRun = false; + +for (int i = 0; i < args.Length; i++) +{ + switch (args[i]) + { + case "--sqlite" when i + 1 < args.Length: + sqlitePath = args[++i]; + break; + case "--postgres" when i + 1 < args.Length: + postgresConnectionString = args[++i]; + break; + case "--dry-run": + isDryRun = true; + break; + } +} + +if (string.IsNullOrWhiteSpace(sqlitePath) || string.IsNullOrWhiteSpace(postgresConnectionString)) +{ + await Console.Error.WriteLineAsync( + "Usage: Jellyfin.DbMigrator --sqlite --postgres [--dry-run]") + .ConfigureAwait(false); + return 2; +} + +if (!File.Exists(sqlitePath)) +{ + await Console.Error.WriteLineAsync($"SQLite database not found: {sqlitePath}") + .ConfigureAwait(false); + return 2; +} + +if (isDryRun) +{ + await Console.Out.WriteLineAsync("[dry-run] No data will be written to PostgreSQL.") + .ConfigureAwait(false); +} + +// --------------------------------------------------------------------------- +// Pre-migration S3 backup. +// --------------------------------------------------------------------------- +string? s3Bucket = Environment.GetEnvironmentVariable("S3_BACKUP_BUCKET"); +string? awsRegion = Environment.GetEnvironmentVariable("AWS_DEFAULT_REGION"); + +if (!string.IsNullOrWhiteSpace(s3Bucket) && !string.IsNullOrWhiteSpace(awsRegion)) +{ + await Console.Out.WriteLineAsync($"Uploading {sqlitePath} to s3://{s3Bucket}/ in region {awsRegion}…") + .ConfigureAwait(false); + try + { + await UploadToS3Async(sqlitePath, s3Bucket, awsRegion, isDryRun).ConfigureAwait(false); + await Console.Out.WriteLineAsync("S3 backup complete.").ConfigureAwait(false); + } + catch (Exception ex) + { + await Console.Error.WriteLineAsync($"S3 backup failed (continuing): {ex.Message}") + .ConfigureAwait(false); + } +} +else +{ + await Console.Out.WriteLineAsync( + "S3_BACKUP_BUCKET or AWS_DEFAULT_REGION not set – skipping pre-migration backup.") + .ConfigureAwait(false); +} + +// --------------------------------------------------------------------------- +// Open connections. +// --------------------------------------------------------------------------- +var sqliteConnectionString = new SqliteConnectionStringBuilder +{ + DataSource = sqlitePath, + Mode = SqliteOpenMode.ReadOnly, +}.ToString(); + +await using var sqliteConnection = new SqliteConnection(sqliteConnectionString); +await sqliteConnection.OpenAsync(CancellationToken.None).ConfigureAwait(false); + +await using var pgConnection = new NpgsqlConnection(postgresConnectionString); +await pgConnection.OpenAsync(CancellationToken.None).ConfigureAwait(false); + +// --------------------------------------------------------------------------- +// Migrate tables. +// --------------------------------------------------------------------------- +var reports = new List(); +bool anyFailure = false; + +foreach (var tableName in tableOrder) +{ + await Console.Out.WriteLineAsync($"Migrating table: {tableName}").ConfigureAwait(false); + + long sqliteCount = 0L; + long pgCount = 0L; + string? error = null; + + try + { + // Read from SQLite. + sqliteCount = await SqliteTableReader.CountRowsAsync( + sqliteConnection, tableName).ConfigureAwait(false); + + if (sqliteCount < 0) + { + await Console.Out.WriteLineAsync($" Table \"{tableName}\" not found in SQLite – skipping.") + .ConfigureAwait(false); + reports.Add(new TableReport(tableName, 0L, 0L, null)); + continue; + } + + await Console.Out.WriteLineAsync($" SQLite rows: {sqliteCount}").ConfigureAwait(false); + + var rows = await SqliteTableReader.ReadAllRowsAsync( + sqliteConnection, tableName).ConfigureAwait(false); + + // Write to PostgreSQL. + long inserted = await PostgresBulkWriter.WriteTableAsync( + pgConnection, tableName, rows, isDryRun).ConfigureAwait(false); + + await Console.Out.WriteLineAsync($" Inserted: {inserted}").ConfigureAwait(false); + + // Verify row count in PostgreSQL. + pgCount = isDryRun + ? 0L + : await PostgresBulkWriter.CountRowsAsync(pgConnection, tableName).ConfigureAwait(false); + } + catch (Exception ex) + { + error = ex.Message; + anyFailure = true; + await Console.Error.WriteLineAsync($" ERROR migrating \"{tableName}\": {ex.Message}") + .ConfigureAwait(false); + } + + reports.Add(new TableReport(tableName, sqliteCount, pgCount, error)); +} + +// --------------------------------------------------------------------------- +// Advance PostgreSQL sequences. +// --------------------------------------------------------------------------- +await Console.Out.WriteLineAsync("Advancing PostgreSQL sequences…").ConfigureAwait(false); +await PostgresBulkWriter.AdvanceSequencesAsync( + pgConnection, tableOrder, isDryRun).ConfigureAwait(false); + +// --------------------------------------------------------------------------- +// Print report. +// --------------------------------------------------------------------------- +MigrationReport.Print(reports); + +return anyFailure ? 1 : 0; + +// --------------------------------------------------------------------------- +// Local functions. +// --------------------------------------------------------------------------- + +// Uploads a file to the configured S3 bucket before migration starts. +static async Task UploadToS3Async( + string filePath, + string bucket, + string region, + bool isDryRun) +{ + if (isDryRun) + { + await Console.Out.WriteLineAsync( + $" [dry-run] Would upload \"{filePath}\" to s3://{bucket}/{Path.GetFileName(filePath)}") + .ConfigureAwait(false); + return; + } + + var regionEndpoint = RegionEndpoint.GetBySystemName(region); + using var s3Client = new AmazonS3Client(regionEndpoint); + using var transferUtility = new TransferUtility(s3Client); + + string key = $"jellyfin-db-backups/{Path.GetFileName(filePath)}-{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}.bak"; + + await transferUtility.UploadAsync(filePath, bucket, key).ConfigureAwait(false); + await Console.Out.WriteLineAsync($" Uploaded to s3://{bucket}/{key}").ConfigureAwait(false); +} + diff --git a/tools/Jellyfin.DbMigrator/SqliteTableReader.cs b/tools/Jellyfin.DbMigrator/SqliteTableReader.cs new file mode 100644 index 0000000000..77e6468dff --- /dev/null +++ b/tools/Jellyfin.DbMigrator/SqliteTableReader.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Data.Sqlite; + +namespace Jellyfin.DbMigrator; + +/// +/// Reads rows from a SQLite database table using raw ADO.NET. +/// +public static class SqliteTableReader +{ + /// + /// Returns all rows from the specified SQLite table as a list of column-name-to-value dictionaries. + /// + /// An open . + /// The name of the table to read. + /// A token to cancel the operation. + /// A list where each element is a dictionary mapping column name to its value (may be ). + public static async Task>> ReadAllRowsAsync( + SqliteConnection connection, + string tableName, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(connection); + ArgumentException.ThrowIfNullOrWhiteSpace(tableName); + TableNameValidator.EnsureSafe(tableName); + + var rows = new List>(); + + var cmd = connection.CreateCommand(); + await using (cmd.ConfigureAwait(false)) + { + cmd.CommandText = $"SELECT * FROM \"{tableName}\""; + + var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + await using (reader.ConfigureAwait(false)) + { + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + var row = new Dictionary(reader.FieldCount, StringComparer.Ordinal); + for (int i = 0; i < reader.FieldCount; i++) + { + string col = reader.GetName(i); + bool isNull = await reader.IsDBNullAsync(i, cancellationToken).ConfigureAwait(false); + object? val = isNull ? null : reader.GetValue(i); + row[col] = val; + } + + rows.Add(row); + } + } + } + + return rows; + } + + /// + /// Returns the row count for the specified table in the SQLite database. + /// + /// An open . + /// The name of the table to count. + /// A token to cancel the operation. + /// The number of rows in the table, or -1 if the table does not exist. + public static async Task CountRowsAsync( + SqliteConnection connection, + string tableName, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(connection); + ArgumentException.ThrowIfNullOrWhiteSpace(tableName); + TableNameValidator.EnsureSafe(tableName); + + // Check if the table exists first. + var checkCmd = connection.CreateCommand(); + await using (checkCmd.ConfigureAwait(false)) + { + checkCmd.CommandText = + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=$name"; + checkCmd.Parameters.AddWithValue("$name", tableName); + var exists = await checkCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + if (exists is not long existsLong || existsLong == 0) + { + return -1L; + } + } + + var cmd = connection.CreateCommand(); + await using (cmd.ConfigureAwait(false)) + { + cmd.CommandText = $"SELECT COUNT(*) FROM \"{tableName}\""; + var result = await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + return result is long count ? count : Convert.ToInt64(result, System.Globalization.CultureInfo.InvariantCulture); + } + } +} diff --git a/tools/Jellyfin.DbMigrator/TableNameValidator.cs b/tools/Jellyfin.DbMigrator/TableNameValidator.cs new file mode 100644 index 0000000000..cb83f86666 --- /dev/null +++ b/tools/Jellyfin.DbMigrator/TableNameValidator.cs @@ -0,0 +1,39 @@ +using System; +using System.Text.RegularExpressions; + +namespace Jellyfin.DbMigrator; + +/// +/// Validates database table names to prevent SQL injection when names are +/// interpolated into raw SQL strings. +/// +internal static partial class TableNameValidator +{ + /// + /// Gets the compiled regular expression that matches safe table names. + /// A safe name consists only of ASCII letters, decimal digits, and underscores. + /// + [GeneratedRegex(@"^[A-Za-z0-9_]+$", RegexOptions.CultureInvariant)] + private static partial Regex SafeNameRegex(); + + /// + /// Throws an when + /// contains characters that are not safe to embed inside a quoted SQL identifier. + /// + /// The candidate table name. + /// + /// Thrown when contains characters outside + /// [A-Za-z0-9_]. + /// + public static void EnsureSafe(string tableName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tableName); + + if (!SafeNameRegex().IsMatch(tableName)) + { + throw new ArgumentException( + $"Table name '{tableName}' contains characters that are not allowed in a SQL identifier.", + nameof(tableName)); + } + } +}