From d6e8f8c4bd43703ddff3c8f2bdc85315f285cfb4 Mon Sep 17 00:00:00 2001 From: Oliver Brandford Date: Fri, 19 Dec 2025 14:24:05 +0100 Subject: [PATCH 1/2] Implement PostgreSQL support: add PostgreSqlSchemaExtractor, update SchemaExportService to use factory pattern, and enhance CLI with database type detection. --- CHANGELOG.md | 15 +++ README.md | 46 ++++++- .../DTOs/SchemaExportRequest.cs | 4 +- .../Services/SchemaExportService.cs | 13 +- src/DbDiff.Cli/Program.cs | 79 +++++++++++- src/DbDiff.Domain/DatabaseType.cs | 8 ++ .../DbDiff.Infrastructure.csproj | 1 + .../PostgreSqlSchemaExtractor.cs | 120 ++++++++++++++++++ .../SchemaExtractorFactory.cs | 15 +++ 9 files changed, 285 insertions(+), 16 deletions(-) create mode 100644 src/DbDiff.Domain/DatabaseType.cs create mode 100644 src/DbDiff.Infrastructure/PostgreSqlSchemaExtractor.cs create mode 100644 src/DbDiff.Infrastructure/SchemaExtractorFactory.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index f780ee4..de217f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **PostgreSQL database support** - Full schema extraction for PostgreSQL databases +- `PostgreSqlSchemaExtractor` implementing `ISchemaExtractor` for PostgreSQL connectivity +- `SchemaExtractorFactory` to dynamically instantiate the correct extractor based on database type +- `DatabaseType` enum in Domain layer (`SqlServer`, `PostgreSql`) +- `--database-type` (`-d`) CLI parameter to explicitly specify database type +- Automatic database type detection from connection string patterns +- Cross-database schema comparison capability (compare SQL Server and PostgreSQL schemas) +- Npgsql package dependency for PostgreSQL connectivity + +### Changed +- Updated `SchemaExportRequest` DTO to include `DatabaseType` property +- Refactored `SchemaExportService` to use factory pattern for extractor instantiation +- Updated CLI help text with PostgreSQL examples and database type parameter + ## [0.0.1] - 2025-12-16 ### Added diff --git a/README.md b/README.md index b94f940..c501430 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,11 @@ Current version: **0.0.1** ## Features -- Export MSSQL database schemas to text format +- Export database schemas to text format from SQL Server and PostgreSQL - Deterministic, diff-friendly output format - Alphabetically sorted tables and columns for easy comparison +- Cross-database schema comparison (compare SQL Server vs PostgreSQL schemas) +- Automatic database type detection from connection strings - Configurable via CLI arguments, environment variables, or configuration files - Structured logging with Serilog - **Comprehensive security features:** @@ -93,18 +95,33 @@ cd src/DbDiff.Cli/bin/Release/net10.0/publish ### Basic Usage +**SQL Server:** ```bash dbdiff --connection "Server=localhost;Database=MyDb;Trusted_Connection=true;" --output schema.txt ``` +**PostgreSQL:** +```bash +dbdiff --connection "Host=localhost;Database=mydb;Username=user;Password=pass" --output schema.txt +``` + ### Command-Line Options - `-c, --connection `: Database connection string (required) - `-o, --output `: Output file path (default: schema.txt) +- `-d, --database-type `: Database type: `sqlserver`, `postgresql` (auto-detected if not specified) - `--config `: Path to configuration file (default: appsettings.json) - `--ignore-position`: Exclude column ordinal positions from output - `-h, --help`: Show help information +### Database Type Detection + +DbDiff can automatically detect the database type from your connection string: +- **SQL Server** keywords: `Server=`, `Data Source=`, `Initial Catalog=`, `Integrated Security=` +- **PostgreSQL** keywords: `Host=`, `Username=` + +You can override auto-detection using the `--database-type` parameter. + ### Configuration Connection strings and output paths can be configured in multiple ways (priority order): @@ -196,18 +213,37 @@ The project follows **Hexagonal Architecture** (Ports & Adapters) with clear sep ## Supported Databases - ✅ Microsoft SQL Server (MSSQL) -- 🔄 PostgreSQL (planned) +- ✅ PostgreSQL + +### Cross-Database Schema Comparison + +DbDiff uses a consistent output format for all database types, making it possible to compare schemas across different database platforms: + +```bash +# Export SQL Server schema +dbdiff --connection "Server=localhost;Database=MyDb;Trusted_Connection=true;" --output sqlserver-schema.txt + +# Export PostgreSQL schema +dbdiff --connection "Host=localhost;Database=mydb;Username=user;Password=pass" --output postgres-schema.txt + +# Compare using your favorite diff tool +diff sqlserver-schema.txt postgres-schema.txt +``` + +**Note:** Data types will differ between platforms (e.g., SQL Server's `nvarchar` vs PostgreSQL's `character varying`), but the consistent format allows you to easily identify structural differences. ## Development ### Prerequisites - .NET 10.0 SDK or later -- SQL Server (for MSSQL support) +- SQL Server (for SQL Server support) +- PostgreSQL (for PostgreSQL support) ### Dependencies - **Microsoft.Data.SqlClient**: SQL Server connectivity +- **Npgsql**: PostgreSQL connectivity - **Serilog**: Structured logging - **Microsoft.Extensions.***: Configuration and dependency injection @@ -283,11 +319,11 @@ See [CHANGELOG.md](CHANGELOG.md) for version history and planned features. ### Future Enhancements -- PostgreSQL support - Schema comparison (diff) functionality -- Support for additional database objects (indexes, foreign keys, views, etc.) +- Support for additional database objects (indexes, foreign keys, views, stored procedures, etc.) - GUI application using AvaloniaUI - Multiple output formats (JSON, YAML, SQL DDL) +- Support for additional databases (MySQL, Oracle, etc.) ## Security diff --git a/src/DbDiff.Application/DTOs/SchemaExportRequest.cs b/src/DbDiff.Application/DTOs/SchemaExportRequest.cs index d727f3a..1cdab92 100644 --- a/src/DbDiff.Application/DTOs/SchemaExportRequest.cs +++ b/src/DbDiff.Application/DTOs/SchemaExportRequest.cs @@ -6,8 +6,9 @@ public class SchemaExportRequest { public string ConnectionString { get; init; } public string OutputPath { get; init; } + public DatabaseType DatabaseType { get; init; } - public SchemaExportRequest(string connectionString, string outputPath) + public SchemaExportRequest(string connectionString, string outputPath, DatabaseType databaseType) { if (string.IsNullOrWhiteSpace(connectionString)) throw new ArgumentException("Connection string cannot be null or empty.", nameof(connectionString)); @@ -15,6 +16,7 @@ public SchemaExportRequest(string connectionString, string outputPath) // Validate and sanitize the output path to prevent path traversal attacks OutputPath = PathValidator.ValidateOutputPath(outputPath); ConnectionString = connectionString; + DatabaseType = databaseType; } } diff --git a/src/DbDiff.Application/Services/SchemaExportService.cs b/src/DbDiff.Application/Services/SchemaExportService.cs index 5c896bd..a488180 100644 --- a/src/DbDiff.Application/Services/SchemaExportService.cs +++ b/src/DbDiff.Application/Services/SchemaExportService.cs @@ -7,16 +7,16 @@ namespace DbDiff.Application.Services; public class SchemaExportService { - private readonly ISchemaExtractor _schemaExtractor; private readonly ISchemaFormatter _schemaFormatter; private readonly ILogger _logger; + private readonly Func _extractorFactory; public SchemaExportService( - ISchemaExtractor schemaExtractor, + Func extractorFactory, ISchemaFormatter schemaFormatter, ILogger logger) { - _schemaExtractor = schemaExtractor ?? throw new ArgumentNullException(nameof(schemaExtractor)); + _extractorFactory = extractorFactory ?? throw new ArgumentNullException(nameof(extractorFactory)); _schemaFormatter = schemaFormatter ?? throw new ArgumentNullException(nameof(schemaFormatter)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -29,10 +29,13 @@ public async Task ExportSchemaAsync( try { - _logger.LogInformation("Starting schema extraction from database"); + _logger.LogInformation("Starting schema extraction from {DatabaseType} database", request.DatabaseType); + + // Get the appropriate extractor for the database type + var schemaExtractor = _extractorFactory(request.DatabaseType); // Extract schema from database - var schema = await _schemaExtractor.ExtractSchemaAsync( + var schema = await schemaExtractor.ExtractSchemaAsync( request.ConnectionString, cancellationToken); diff --git a/src/DbDiff.Cli/Program.cs b/src/DbDiff.Cli/Program.cs index e616031..2517372 100644 --- a/src/DbDiff.Cli/Program.cs +++ b/src/DbDiff.Cli/Program.cs @@ -1,3 +1,5 @@ +using System.Reflection; + using DbDiff.Application.DTOs; using DbDiff.Application.Formatters; using DbDiff.Application.Services; @@ -11,6 +13,11 @@ using Serilog; +// Get version from assembly +var version = Assembly.GetExecutingAssembly() + .GetCustomAttribute() + ?.InformationalVersion ?? "Unknown"; + // Build configuration var configuration = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) @@ -52,8 +59,15 @@ }); // Register application services -services.AddSingleton(); services.AddSingleton(); + +// Register factory function for schema extractors +services.AddSingleton>(serviceProvider => +{ + var factory = new SchemaExtractorFactory(); + return (databaseType) => factory.CreateExtractor(databaseType); +}); + services.AddSingleton(); var serviceProvider = services.BuildServiceProvider(); @@ -62,6 +76,7 @@ string? connection = null; string? output = null; string? configFile = null; +string? databaseTypeArg = null; bool showHelp = false; bool ignorePosition = false; @@ -81,6 +96,10 @@ if (i + 1 < args.Length) configFile = args[++i]; break; + case "--database-type" or "-d": + if (i + 1 < args.Length) + databaseTypeArg = args[++i]; + break; case "--ignore-position": ignorePosition = true; break; @@ -92,7 +111,7 @@ if (showHelp || args.Length == 0) { - Console.WriteLine("DbDiff - Database Schema Comparison Tool v0.0.1"); + Console.WriteLine($"DbDiff - Database Schema Comparison Tool v{version}"); Console.WriteLine(); Console.WriteLine("Usage:"); Console.WriteLine(" dbdiff --connection [options]"); @@ -100,6 +119,7 @@ Console.WriteLine("Options:"); Console.WriteLine(" -c, --connection Database connection string (required)"); Console.WriteLine(" -o, --output Output file path (default: schema.txt)"); + Console.WriteLine(" -d, --database-type Database type: sqlserver, postgresql (auto-detected if not specified)"); Console.WriteLine(" --config Configuration file path"); Console.WriteLine(" --ignore-position Exclude column ordinal positions from output"); Console.WriteLine(" -h, --help Show help information"); @@ -110,8 +130,14 @@ Console.WriteLine(" - appsettings.json file"); Console.WriteLine(); Console.WriteLine("Examples:"); + Console.WriteLine(" # SQL Server"); Console.WriteLine(" dbdiff --connection \"Server=localhost;Database=MyDb;Trusted_Connection=true;\" --output schema.txt"); - Console.WriteLine(" dbdiff --connection \"Server=localhost;Database=MyDb;Trusted_Connection=true;\" --ignore-position"); + Console.WriteLine(); + Console.WriteLine(" # PostgreSQL"); + Console.WriteLine(" dbdiff --connection \"Host=localhost;Database=mydb;Username=user;Password=pass\" --database-type postgresql"); + Console.WriteLine(); + Console.WriteLine(" # Auto-detect database type and ignore column positions"); + Console.WriteLine(" dbdiff --connection \"Host=localhost;Database=mydb;Username=user;Password=pass\" --ignore-position"); return 0; } @@ -162,7 +188,27 @@ return 1; } - Console.WriteLine("DbDiff - Database Schema Export v0.0.1"); + // Determine database type + DatabaseType databaseType; + if (!string.IsNullOrWhiteSpace(databaseTypeArg)) + { + // Explicit database type specified + if (!Enum.TryParse(databaseTypeArg, ignoreCase: true, out databaseType)) + { + Console.Error.WriteLine($"Error: Invalid database type '{databaseTypeArg}'."); + Console.Error.WriteLine("Valid values: sqlserver, postgresql"); + return 1; + } + } + else + { + // Auto-detect database type from connection string + databaseType = DetectDatabaseType(connectionString); + Console.WriteLine($"Auto-detected database type: {databaseType}"); + } + + Console.WriteLine($"DbDiff - Database Schema Export v{version}"); + Console.WriteLine($"Database Type: {databaseType}"); Console.WriteLine($"Output: {outputPath}"); Console.WriteLine(); @@ -179,7 +225,7 @@ SchemaExportRequest request; try { - request = new SchemaExportRequest(connectionString, outputPath); + request = new SchemaExportRequest(connectionString, outputPath, databaseType); } catch (UnauthorizedAccessException ex) { @@ -212,3 +258,26 @@ { await Log.CloseAndFlushAsync(); } + +static DatabaseType DetectDatabaseType(string connectionString) +{ + // Check for PostgreSQL keywords + if (connectionString.Contains("Host=", StringComparison.OrdinalIgnoreCase) || + connectionString.Contains("Username=", StringComparison.OrdinalIgnoreCase)) + { + return DatabaseType.PostgreSql; + } + + // Check for SQL Server keywords + if (connectionString.Contains("Server=", StringComparison.OrdinalIgnoreCase) || + connectionString.Contains("Data Source=", StringComparison.OrdinalIgnoreCase) || + connectionString.Contains("Initial Catalog=", StringComparison.OrdinalIgnoreCase) || + connectionString.Contains("Integrated Security=", StringComparison.OrdinalIgnoreCase)) + { + return DatabaseType.SqlServer; + } + + // Default to SQL Server for backward compatibility + Console.WriteLine("Warning: Could not auto-detect database type. Defaulting to SQL Server."); + return DatabaseType.SqlServer; +} diff --git a/src/DbDiff.Domain/DatabaseType.cs b/src/DbDiff.Domain/DatabaseType.cs new file mode 100644 index 0000000..30ae015 --- /dev/null +++ b/src/DbDiff.Domain/DatabaseType.cs @@ -0,0 +1,8 @@ +namespace DbDiff.Domain; + +public enum DatabaseType +{ + SqlServer, + PostgreSql +} + diff --git a/src/DbDiff.Infrastructure/DbDiff.Infrastructure.csproj b/src/DbDiff.Infrastructure/DbDiff.Infrastructure.csproj index 22c9d67..1a4fd4c 100644 --- a/src/DbDiff.Infrastructure/DbDiff.Infrastructure.csproj +++ b/src/DbDiff.Infrastructure/DbDiff.Infrastructure.csproj @@ -6,6 +6,7 @@ + diff --git a/src/DbDiff.Infrastructure/PostgreSqlSchemaExtractor.cs b/src/DbDiff.Infrastructure/PostgreSqlSchemaExtractor.cs new file mode 100644 index 0000000..1e94855 --- /dev/null +++ b/src/DbDiff.Infrastructure/PostgreSqlSchemaExtractor.cs @@ -0,0 +1,120 @@ +using Npgsql; + +namespace DbDiff.Infrastructure; + +public class PostgreSqlSchemaExtractor : ISchemaExtractor +{ + public async Task ExtractSchemaAsync( + string connectionString, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(connectionString)) + throw new ArgumentException("Connection string cannot be null or empty.", nameof(connectionString)); + + await using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(cancellationToken); + + var databaseName = connection.Database; + var extractedAt = DateTime.UtcNow; + + var tables = await ExtractTablesAsync(connection, cancellationToken); + + return new DatabaseSchema(databaseName, extractedAt, tables); + } + + private static async Task> ExtractTablesAsync( + NpgsqlConnection connection, + CancellationToken cancellationToken) + { + var tables = new List(); + + // Query to get all user tables (excluding system schemas) + const string tableQuery = @" + SELECT + table_schema, + table_name + FROM information_schema.tables + WHERE table_type = 'BASE TABLE' + AND table_schema NOT IN ('pg_catalog', 'information_schema') + ORDER BY table_schema, table_name"; + + await using var tableCommand = new NpgsqlCommand(tableQuery, connection); + await using var tableReader = await tableCommand.ExecuteReaderAsync(cancellationToken); + + var tableInfoList = new List<(string Schema, string Name)>(); + while (await tableReader.ReadAsync(cancellationToken)) + { + var schemaName = tableReader.GetString(0); + var tableName = tableReader.GetString(1); + tableInfoList.Add((schemaName, tableName)); + } + + await tableReader.CloseAsync(); + + // Extract columns for each table + foreach (var (schemaName, tableName) in tableInfoList) + { + var columns = await ExtractColumnsAsync(connection, schemaName, tableName, cancellationToken); + tables.Add(new Table(schemaName, tableName, columns)); + } + + return tables; + } + + private static async Task> ExtractColumnsAsync( + NpgsqlConnection connection, + string schemaName, + string tableName, + CancellationToken cancellationToken) + { + var columns = new List(); + + const string columnQuery = @" + SELECT + c.column_name, + c.data_type, + c.is_nullable, + c.character_maximum_length, + c.numeric_precision, + c.numeric_scale, + c.ordinal_position + FROM information_schema.columns c + WHERE c.table_schema = @SchemaName + AND c.table_name = @TableName + ORDER BY c.ordinal_position"; + + await using var columnCommand = new NpgsqlCommand(columnQuery, connection); + columnCommand.Parameters.AddWithValue("@SchemaName", schemaName); + columnCommand.Parameters.AddWithValue("@TableName", tableName); + + await using var columnReader = await columnCommand.ExecuteReaderAsync(cancellationToken); + + while (await columnReader.ReadAsync(cancellationToken)) + { + var columnName = columnReader.GetString(0); + var dataTypeName = columnReader.GetString(1); + var isNullableStr = columnReader.GetString(2); + var isNullable = isNullableStr.Equals("YES", StringComparison.OrdinalIgnoreCase); + + int? maxLength = columnReader.IsDBNull(3) ? null : columnReader.GetInt32(3); + int? precision = columnReader.IsDBNull(4) ? null : columnReader.GetInt32(4); + int? scale = columnReader.IsDBNull(5) ? null : columnReader.GetInt32(5); + var ordinalPosition = columnReader.GetInt32(6); + + var dataType = new DataType(dataTypeName); + var column = new Column( + columnName, + dataType, + isNullable, + ordinalPosition, + maxLength, + precision, + scale); + + columns.Add(column); + } + + return columns; + } +} + diff --git a/src/DbDiff.Infrastructure/SchemaExtractorFactory.cs b/src/DbDiff.Infrastructure/SchemaExtractorFactory.cs new file mode 100644 index 0000000..5c63d97 --- /dev/null +++ b/src/DbDiff.Infrastructure/SchemaExtractorFactory.cs @@ -0,0 +1,15 @@ +namespace DbDiff.Infrastructure; + +public class SchemaExtractorFactory +{ + public ISchemaExtractor CreateExtractor(DatabaseType databaseType) + { + return databaseType switch + { + DatabaseType.SqlServer => new MsSqlSchemaExtractor(), + DatabaseType.PostgreSql => new PostgreSqlSchemaExtractor(), + _ => throw new ArgumentException($"Unsupported database type: {databaseType}", nameof(databaseType)) + }; + } +} + From 11b43f21b951cf23cd1bfeb049e8676901d4e964 Mon Sep 17 00:00:00 2001 From: Oliver Brandford Date: Fri, 19 Dec 2025 14:29:55 +0100 Subject: [PATCH 2/2] Refactor SchemaExtractorFactory: change CreateExtractor method to static and update CLI to use the new static method. --- src/DbDiff.Cli/Program.cs | 2 +- src/DbDiff.Infrastructure/SchemaExtractorFactory.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/DbDiff.Cli/Program.cs b/src/DbDiff.Cli/Program.cs index 2517372..2a944b8 100644 --- a/src/DbDiff.Cli/Program.cs +++ b/src/DbDiff.Cli/Program.cs @@ -65,7 +65,7 @@ services.AddSingleton>(serviceProvider => { var factory = new SchemaExtractorFactory(); - return (databaseType) => factory.CreateExtractor(databaseType); + return (databaseType) => SchemaExtractorFactory.CreateExtractor(databaseType); }); services.AddSingleton(); diff --git a/src/DbDiff.Infrastructure/SchemaExtractorFactory.cs b/src/DbDiff.Infrastructure/SchemaExtractorFactory.cs index 5c63d97..9da8646 100644 --- a/src/DbDiff.Infrastructure/SchemaExtractorFactory.cs +++ b/src/DbDiff.Infrastructure/SchemaExtractorFactory.cs @@ -2,7 +2,7 @@ namespace DbDiff.Infrastructure; public class SchemaExtractorFactory { - public ISchemaExtractor CreateExtractor(DatabaseType databaseType) + public static ISchemaExtractor CreateExtractor(DatabaseType databaseType) { return databaseType switch {