using Bit.Seeder.Migration.Models;
using Bit.Seeder.Migration.Utils;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
namespace Bit.Seeder.Migration.Databases;
///
/// SQL Server database exporter that handles schema discovery and data export.
///
public class SqlServerExporter(DatabaseConfig config, ILogger logger) : IDisposable
{
private readonly ILogger _logger = logger;
private readonly string _host = config.Host;
private readonly int _port = config.Port;
private readonly string _database = config.Database;
private readonly string _username = config.Username;
private readonly string _password = config.Password;
private SqlConnection? _connection;
private bool _disposed = false;
///
/// Connects to the SQL Server database.
///
public bool Connect()
{
try
{
var safeConnectionString = $"Server={_host},{_port};Database={_database};" +
$"User Id={_username};Password={DbSeederConstants.REDACTED_PASSWORD};" +
$"TrustServerCertificate=True;" +
$"Connection Timeout={DbSeederConstants.DEFAULT_CONNECTION_TIMEOUT};";
var actualConnectionString = safeConnectionString.Replace(DbSeederConstants.REDACTED_PASSWORD, _password);
_connection = new SqlConnection(actualConnectionString);
_connection.Open();
_logger.LogInformation("Connected to SQL Server: {Host}/{Database}", _host, _database);
return true;
}
catch (Exception ex)
{
_logger.LogError("Failed to connect to SQL Server: {Message}", ex.Message);
return false;
}
}
///
/// Disconnects from the SQL Server database.
///
public void Disconnect()
{
if (_connection != null)
{
_connection.Close();
_connection.Dispose();
_connection = null;
_logger.LogInformation("Disconnected from SQL Server");
}
}
///
/// Discovers all tables in the SQL Server database.
///
/// Whether to exclude system tables
/// List of table names
public List DiscoverTables(bool excludeSystemTables = true)
{
if (_connection == null)
throw new InvalidOperationException("Not connected to database");
try
{
var query = @"
SELECT TABLE_NAME
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_TYPE = 'BASE TABLE'";
if (excludeSystemTables)
{
query += @"
AND TABLE_SCHEMA = 'dbo'
AND TABLE_NAME NOT IN ('sysdiagrams', '__EFMigrationsHistory')";
}
query += " ORDER BY TABLE_NAME";
using var command = new SqlCommand(query, _connection);
using var reader = command.ExecuteReader();
var tables = new List();
while (reader.Read())
{
var tableName = reader.GetString(0);
// Validate table name immediately to prevent second-order SQL injection
IdentifierValidator.ValidateOrThrow(tableName, "table name");
tables.Add(tableName);
}
_logger.LogInformation("Discovered {Count} tables: {Tables}", tables.Count, string.Join(", ", tables));
return tables;
}
catch (Exception ex)
{
_logger.LogError("Error discovering tables: {Message}", ex.Message);
throw;
}
}
///
/// Gets detailed information about a table including columns, types, and row count.
///
/// The name of the table to query
/// TableInfo containing schema and metadata
public TableInfo GetTableInfo(string tableName)
{
if (_connection == null)
throw new InvalidOperationException("Not connected to database");
IdentifierValidator.ValidateOrThrow(tableName, "table name");
try
{
// Get column information
var columnQuery = @"
SELECT
COLUMN_NAME,
DATA_TYPE,
IS_NULLABLE,
CHARACTER_MAXIMUM_LENGTH,
NUMERIC_PRECISION,
NUMERIC_SCALE
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = @TableName
ORDER BY ORDINAL_POSITION";
var columns = new List();
var columnTypes = new Dictionary();
using (var command = new SqlCommand(columnQuery, _connection))
{
command.Parameters.AddWithValue("@TableName", tableName);
using var reader = command.ExecuteReader();
while (reader.Read())
{
var colName = reader.GetString(0);
// Validate column name immediately to prevent second-order SQL injection
IdentifierValidator.ValidateOrThrow(colName, "column name");
var dataType = reader.GetString(1);
var isNullable = reader.GetString(2);
var maxLength = reader.IsDBNull(3) ? (int?)null : reader.GetInt32(3);
var precision = reader.IsDBNull(4) ? (byte?)null : reader.GetByte(4);
var scale = reader.IsDBNull(5) ? (int?)null : reader.GetInt32(5);
columns.Add(colName);
// Build type description
var typeDesc = dataType.ToUpper();
if (maxLength.HasValue && dataType.ToLower() is "varchar" or "nvarchar" or "char" or "nchar")
{
typeDesc += $"({maxLength})";
}
else if (precision.HasValue && dataType.ToLower() is "decimal" or "numeric")
{
typeDesc += $"({precision},{scale})";
}
typeDesc += isNullable == "YES" ? " NULL" : " NOT NULL";
columnTypes[colName] = typeDesc;
}
}
if (columns.Count == 0)
{
throw new InvalidOperationException($"Table '{tableName}' not found");
}
var countQuery = $"SELECT COUNT(*) FROM [{tableName}]";
int rowCount;
using (var command = new SqlCommand(countQuery, _connection))
{
rowCount = command.GetScalarValue(0, _logger, $"row count for {tableName}");
}
_logger.LogInformation("Table {TableName}: {ColumnCount} columns, {RowCount} rows", tableName, columns.Count, rowCount);
return new TableInfo
{
Name = tableName,
Columns = columns,
ColumnTypes = columnTypes,
RowCount = rowCount
};
}
catch (Exception ex)
{
_logger.LogError("Error getting table info for {TableName}: {Message}", tableName, ex.Message);
throw;
}
}
///
/// Exports all data from a table with streaming to avoid memory exhaustion.
///
/// The name of the table to export
/// Batch size for progress reporting
/// Tuple of column names and data rows
public (List Columns, List