diff --git a/CHANGELOG.md b/CHANGELOG.md index de217f3..1463db8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,11 +16,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 +- View extraction and export functionality for SQL Server +- View SQL definitions included in exports (using sys.sql_modules for complete definitions) +- View column structures with full metadata (data types, nullability, precision, etc.) +- Alphabetically sorted views in output for deterministic comparison +- Support for encrypted views (gracefully handles NULL definitions) +- `--exclude-view-definitions` CLI flag to optionally exclude SQL definitions from output ### 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 +- Updated `DatabaseSchema` domain entity to include `Views` collection +- Enhanced `CustomTextFormatter` to display view definitions and columns +- Improved logging to report both table and view counts +- Updated `SchemaExportResult` DTO with separate `TableCount` and `ViewCount` properties +- Made view definitions optional in export output via `IncludeViewDefinitions` formatter property ## [0.0.1] - 2025-12-16 diff --git a/README.md b/README.md index c501430..37b9590 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,9 @@ Current version: **0.0.1** ## Features - Export database schemas to text format from SQL Server and PostgreSQL +- **Complete schema export including:** + - Tables with full column definitions + - Views with SQL definitions and column structures - Deterministic, diff-friendly output format - Alphabetically sorted tables and columns for easy comparison - Cross-database schema comparison (compare SQL Server vs PostgreSQL schemas) @@ -112,6 +115,7 @@ dbdiff --connection "Host=localhost;Database=mydb;Username=user;Password=pass" - - `-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 +- `--exclude-view-definitions`: Exclude view SQL definitions from output (column structure still included) - `-h, --help`: Show help information ### Database Type Detection @@ -174,12 +178,31 @@ TABLE: dbo.Users Nullable: Yes MaxLength: 100 -TABLE: dbo.Orders - ... +VIEW: dbo.ActiveUsers + DEFINITION: + CREATE VIEW dbo.ActiveUsers AS + SELECT Id, Name, Email + FROM dbo.Users + WHERE IsActive = 1 + COLUMN: Email + OrdinalPosition: 3 + Type: nvarchar + Nullable: Yes + MaxLength: 255 + COLUMN: Id + OrdinalPosition: 1 + Type: int + Nullable: No + COLUMN: Name + OrdinalPosition: 2 + Type: nvarchar + Nullable: Yes + MaxLength: 100 ``` Features: -- Tables and columns are alphabetically sorted +- Tables, views, and columns are alphabetically sorted +- Views include complete SQL definitions for comparison - Consistent formatting and indentation - One property per line - Deterministic output for reliable diffing @@ -320,7 +343,7 @@ See [CHANGELOG.md](CHANGELOG.md) for version history and planned features. ### Future Enhancements - Schema comparison (diff) functionality -- Support for additional database objects (indexes, foreign keys, views, stored procedures, etc.) +- Support for additional database objects (indexes, foreign keys, stored procedures, functions, triggers, etc.) - GUI application using AvaloniaUI - Multiple output formats (JSON, YAML, SQL DDL) - Support for additional databases (MySQL, Oracle, etc.) diff --git a/src/DbDiff.Application/DTOs/SchemaExportResult.cs b/src/DbDiff.Application/DTOs/SchemaExportResult.cs index 4bc5d26..f83f359 100644 --- a/src/DbDiff.Application/DTOs/SchemaExportResult.cs +++ b/src/DbDiff.Application/DTOs/SchemaExportResult.cs @@ -4,25 +4,28 @@ public class SchemaExportResult { public bool Success { get; init; } public string ExportedFilePath { get; init; } - public int ObjectCount { get; init; } + public int TableCount { get; init; } + public int ViewCount { get; init; } + public int ObjectCount => TableCount + ViewCount; public string? ErrorMessage { get; init; } - private SchemaExportResult(bool success, string exportedFilePath, int objectCount, string? errorMessage) + private SchemaExportResult(bool success, string exportedFilePath, int tableCount, int viewCount, string? errorMessage) { Success = success; ExportedFilePath = exportedFilePath; - ObjectCount = objectCount; + TableCount = tableCount; + ViewCount = viewCount; ErrorMessage = errorMessage; } - public static SchemaExportResult SuccessResult(string exportedFilePath, int objectCount) + public static SchemaExportResult SuccessResult(string exportedFilePath, int tableCount, int viewCount) { - return new SchemaExportResult(true, exportedFilePath, objectCount, null); + return new SchemaExportResult(true, exportedFilePath, tableCount, viewCount, null); } public static SchemaExportResult FailureResult(string errorMessage) { - return new SchemaExportResult(false, string.Empty, 0, errorMessage); + return new SchemaExportResult(false, string.Empty, 0, 0, errorMessage); } } diff --git a/src/DbDiff.Application/Formatters/CustomTextFormatter.cs b/src/DbDiff.Application/Formatters/CustomTextFormatter.cs index f84c79d..4e666dc 100644 --- a/src/DbDiff.Application/Formatters/CustomTextFormatter.cs +++ b/src/DbDiff.Application/Formatters/CustomTextFormatter.cs @@ -5,6 +5,9 @@ namespace DbDiff.Application.Formatters; public class CustomTextFormatter : ISchemaFormatter { public bool IncludeOrdinalPosition { get; set; } = true; + public bool IncludeViewDefinitions { get; set; } = true; + + private static readonly string[] separator = new[] { "\r\n", "\r", "\n" }; public string Format(DatabaseSchema schema) { @@ -26,36 +29,65 @@ public string Format(DatabaseSchema schema) foreach (var table in sortedTables) { sb.AppendLine($"TABLE: {table.FullName}"); + FormatColumns(sb, table.Columns); + sb.AppendLine(); + } - // Sort columns alphabetically by name - var sortedColumns = table.Columns - .OrderBy(c => c.Name) - .ToList(); + // Sort views alphabetically by full name for deterministic output + var sortedViews = schema.Views + .OrderBy(v => v.SchemaName) + .ThenBy(v => v.ViewName) + .ToList(); + + foreach (var view in sortedViews) + { + sb.AppendLine($"VIEW: {view.FullName}"); - foreach (var column in sortedColumns) + // Include view definition if available and enabled + if (IncludeViewDefinitions && !string.IsNullOrWhiteSpace(view.Definition)) { - sb.AppendLine($" COLUMN: {column.Name}"); + sb.AppendLine(" DEFINITION:"); + // Indent each line of the definition + var definitionLines = view.Definition.Split(separator, StringSplitOptions.None); + foreach (var line in definitionLines) + { + sb.AppendLine($" {line}"); + } + } - if (IncludeOrdinalPosition) - sb.AppendLine($" OrdinalPosition: {column.OrdinalPosition}"); + FormatColumns(sb, view.Columns); + sb.AppendLine(); + } - sb.AppendLine($" Type: {column.DataType}"); - sb.AppendLine($" Nullable: {(column.IsNullable ? "Yes" : "No")}"); + return sb.ToString(); + } - if (column.MaxLength.HasValue) - sb.AppendLine($" MaxLength: {column.MaxLength.Value}"); + private void FormatColumns(StringBuilder sb, IReadOnlyList columns) + { + // Sort columns alphabetically by name + var sortedColumns = columns + .OrderBy(c => c.Name) + .ToList(); - if (column.Precision.HasValue) - sb.AppendLine($" Precision: {column.Precision.Value}"); + foreach (var column in sortedColumns) + { + sb.AppendLine($" COLUMN: {column.Name}"); - if (column.Scale.HasValue) - sb.AppendLine($" Scale: {column.Scale.Value}"); - } + if (IncludeOrdinalPosition) + sb.AppendLine($" OrdinalPosition: {column.OrdinalPosition}"); - sb.AppendLine(); - } + sb.AppendLine($" Type: {column.DataType}"); + sb.AppendLine($" Nullable: {(column.IsNullable ? "Yes" : "No")}"); - return sb.ToString(); + if (column.MaxLength.HasValue) + sb.AppendLine($" MaxLength: {column.MaxLength.Value}"); + + if (column.Precision.HasValue) + sb.AppendLine($" Precision: {column.Precision.Value}"); + + if (column.Scale.HasValue) + sb.AppendLine($" Scale: {column.Scale.Value}"); + } } } diff --git a/src/DbDiff.Application/Services/SchemaExportService.cs b/src/DbDiff.Application/Services/SchemaExportService.cs index a488180..e3c7b3e 100644 --- a/src/DbDiff.Application/Services/SchemaExportService.cs +++ b/src/DbDiff.Application/Services/SchemaExportService.cs @@ -39,9 +39,10 @@ public async Task ExportSchemaAsync( request.ConnectionString, cancellationToken); - _logger.LogInformation("Successfully extracted schema from database {DatabaseName} with {TableCount} tables", + _logger.LogInformation("Successfully extracted schema from database {DatabaseName} with {TableCount} tables and {ViewCount} views", schema.DatabaseName, - schema.Tables.Count); + schema.Tables.Count, + schema.Views.Count); // Format schema to text var formattedSchema = _schemaFormatter.Format(schema); @@ -59,7 +60,7 @@ public async Task ExportSchemaAsync( _logger.LogInformation("Schema exported successfully to {OutputPath}", request.OutputPath); - return SchemaExportResult.SuccessResult(request.OutputPath, schema.Tables.Count); + return SchemaExportResult.SuccessResult(request.OutputPath, schema.Tables.Count, schema.Views.Count); } catch (Exception ex) { diff --git a/src/DbDiff.Cli/Program.cs b/src/DbDiff.Cli/Program.cs index 2a944b8..441b35a 100644 --- a/src/DbDiff.Cli/Program.cs +++ b/src/DbDiff.Cli/Program.cs @@ -79,6 +79,7 @@ string? databaseTypeArg = null; bool showHelp = false; bool ignorePosition = false; +bool excludeViewDefinitions = false; for (int i = 0; i < args.Length; i++) { @@ -103,6 +104,9 @@ case "--ignore-position": ignorePosition = true; break; + case "--exclude-view-definitions": + excludeViewDefinitions = true; + break; case "--help" or "-h" or "-?": showHelp = true; break; @@ -122,6 +126,7 @@ 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(" --exclude-view-definitions Exclude view SQL definitions from output"); Console.WriteLine(" -h, --help Show help information"); Console.WriteLine(); Console.WriteLine("Configuration:"); @@ -217,6 +222,7 @@ if (formatter is CustomTextFormatter customFormatter) { customFormatter.IncludeOrdinalPosition = !ignorePosition; + customFormatter.IncludeViewDefinitions = !excludeViewDefinitions; } var exportService = serviceProvider.GetRequiredService(); diff --git a/src/DbDiff.Domain/DatabaseSchema.cs b/src/DbDiff.Domain/DatabaseSchema.cs index d12291f..c3ecd4f 100644 --- a/src/DbDiff.Domain/DatabaseSchema.cs +++ b/src/DbDiff.Domain/DatabaseSchema.cs @@ -5,8 +5,9 @@ public class DatabaseSchema public string DatabaseName { get; init; } public DateTime ExtractedAt { get; init; } public IReadOnlyList Tables { get; init; } + public IReadOnlyList Views { get; init; } - public DatabaseSchema(string databaseName, DateTime extractedAt, IEnumerable
tables) + public DatabaseSchema(string databaseName, DateTime extractedAt, IEnumerable
tables, IEnumerable views) { if (string.IsNullOrWhiteSpace(databaseName)) throw new ArgumentException("Database name cannot be null or empty.", nameof(databaseName)); @@ -14,6 +15,7 @@ public DatabaseSchema(string databaseName, DateTime extractedAt, IEnumerable Columns { get; init; } + + public View(string schemaName, string viewName, IEnumerable columns, string? definition = null) + { + if (string.IsNullOrWhiteSpace(schemaName)) + throw new ArgumentException("Schema name cannot be null or empty.", nameof(schemaName)); + + if (string.IsNullOrWhiteSpace(viewName)) + throw new ArgumentException("View name cannot be null or empty.", nameof(viewName)); + + SchemaName = schemaName; + ViewName = viewName; + Definition = definition; + Columns = columns?.ToList() ?? throw new ArgumentNullException(nameof(columns)); + } + + public string FullName => $"{SchemaName}.{ViewName}"; +} + diff --git a/src/DbDiff.Infrastructure/MsSqlSchemaExtractor.cs b/src/DbDiff.Infrastructure/MsSqlSchemaExtractor.cs index 5c58140..88e46ac 100644 --- a/src/DbDiff.Infrastructure/MsSqlSchemaExtractor.cs +++ b/src/DbDiff.Infrastructure/MsSqlSchemaExtractor.cs @@ -18,8 +18,9 @@ public async Task ExtractSchemaAsync( var extractedAt = DateTime.UtcNow; var tables = await ExtractTablesAsync(connection, cancellationToken); + var views = await ExtractViewsAsync(connection, cancellationToken); - return new DatabaseSchema(databaseName, extractedAt, tables); + return new DatabaseSchema(databaseName, extractedAt, tables, views); } private static async Task> ExtractTablesAsync( @@ -60,6 +61,49 @@ FROM INFORMATION_SCHEMA.TABLES return tables; } + private static async Task> ExtractViewsAsync( + SqlConnection connection, + CancellationToken cancellationToken) + { + var views = new List(); + + // Query to get all user views with their definitions (excluding system views) + // Using sys.views and sys.sql_modules to get the full definition (no 4000 char limit) + const string viewQuery = @" + SELECT + s.name AS SchemaName, + v.name AS ViewName, + m.definition AS Definition + FROM sys.views v + INNER JOIN sys.schemas s ON v.schema_id = s.schema_id + LEFT JOIN sys.sql_modules m ON v.object_id = m.object_id + WHERE s.name NOT IN ('sys', 'INFORMATION_SCHEMA') + ORDER BY s.name, v.name"; + + await using var viewCommand = new SqlCommand(viewQuery, connection); + await using var viewReader = await viewCommand.ExecuteReaderAsync(cancellationToken); + + var viewInfoList = new List<(string Schema, string Name, string? Definition)>(); + while (await viewReader.ReadAsync(cancellationToken)) + { + var schemaName = viewReader.GetString(0); + var viewName = viewReader.GetString(1); + var definition = viewReader.IsDBNull(2) ? null : viewReader.GetString(2); + viewInfoList.Add((schemaName, viewName, definition)); + } + + await viewReader.CloseAsync(); + + // Extract columns for each view + foreach (var (schemaName, viewName, definition) in viewInfoList) + { + var columns = await ExtractColumnsAsync(connection, schemaName, viewName, cancellationToken); + views.Add(new View(schemaName, viewName, columns, definition)); + } + + return views; + } + private static async Task> ExtractColumnsAsync( SqlConnection connection, string schemaName, diff --git a/src/DbDiff.Infrastructure/PostgreSqlSchemaExtractor.cs b/src/DbDiff.Infrastructure/PostgreSqlSchemaExtractor.cs index 1e94855..43340f1 100644 --- a/src/DbDiff.Infrastructure/PostgreSqlSchemaExtractor.cs +++ b/src/DbDiff.Infrastructure/PostgreSqlSchemaExtractor.cs @@ -18,8 +18,9 @@ public async Task ExtractSchemaAsync( var extractedAt = DateTime.UtcNow; var tables = await ExtractTablesAsync(connection, cancellationToken); + var views = await ExtractViewsAsync(connection, cancellationToken); - return new DatabaseSchema(databaseName, extractedAt, tables); + return new DatabaseSchema(databaseName, extractedAt, tables, views); } private static async Task> ExtractTablesAsync( @@ -61,6 +62,46 @@ AND table_schema NOT IN ('pg_catalog', 'information_schema') return tables; } + private static async Task> ExtractViewsAsync( + NpgsqlConnection connection, + CancellationToken cancellationToken) + { + var views = new List(); + + // Query to get all user views with their definitions (excluding system schemas) + const string viewQuery = @" + SELECT + table_schema, + table_name, + view_definition + FROM information_schema.views + WHERE table_schema NOT IN ('pg_catalog', 'information_schema') + ORDER BY table_schema, table_name"; + + await using var viewCommand = new NpgsqlCommand(viewQuery, connection); + await using var viewReader = await viewCommand.ExecuteReaderAsync(cancellationToken); + + var viewInfoList = new List<(string Schema, string Name, string? Definition)>(); + while (await viewReader.ReadAsync(cancellationToken)) + { + var schemaName = viewReader.GetString(0); + var viewName = viewReader.GetString(1); + var definition = viewReader.IsDBNull(2) ? null : viewReader.GetString(2); + viewInfoList.Add((schemaName, viewName, definition)); + } + + await viewReader.CloseAsync(); + + // Extract columns for each view + foreach (var (schemaName, viewName, definition) in viewInfoList) + { + var columns = await ExtractColumnsAsync(connection, schemaName, viewName, cancellationToken); + views.Add(new View(schemaName, viewName, columns, definition)); + } + + return views; + } + private static async Task> ExtractColumnsAsync( NpgsqlConnection connection, string schemaName,