From 6f5641ae6a236e21d788c4b12c44c7339d115ef8 Mon Sep 17 00:00:00 2001 From: Oliver Brandford Date: Wed, 17 Dec 2025 14:32:29 +0100 Subject: [PATCH] Add `.editorconfig` for consistent coding standards; refactor null checks and fix formatting inconsistencies --- .editorconfig | 156 ++++++++++++++++++ .../PathValidatorTests.cs | 14 +- .../DTOs/SchemaExportRequest.cs | 2 +- .../Formatters/CustomTextFormatter.cs | 7 +- .../Services/SchemaExportService.cs | 14 +- .../Validation/PathValidator.cs | 6 +- src/DbDiff.Cli/Program.cs | 17 +- src/DbDiff.Domain/Column.cs | 10 +- src/DbDiff.Domain/DataType.cs | 2 +- src/DbDiff.Domain/Table.cs | 2 +- .../MsSqlSchemaExtractor.cs | 9 +- 11 files changed, 197 insertions(+), 42 deletions(-) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5ae693e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,156 @@ +# Top-level EditorConfig for DbDiff (.NET 10 / C# 14) +# Docs: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/configuration-options + +root = true + +# Global defaults +[*] +charset = utf-8 +end_of_line = crlf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 +max_line_length = 120 + +# C# files +[*.cs] +######## Formatting ######## +# Braces and new lines +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# Indentation +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_switch_labels = true +csharp_indent_case_contents = true +csharp_indent_labels = one_less_than_current +csharp_indent_case_contents_when_block = true + +# Spacing +csharp_space_after_cast = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_around_binary_operators = before_and_after + +# Using directives +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = true +csharp_using_directive_placement = inside_namespace:suggestion + +######## Code style ######## +# Qualification +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_event = false:suggestion + +# 'var' preferences +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = false:suggestion + +# Expression-bodied members +csharp_style_expression_bodied_methods = when_on_single_line:suggestion +csharp_style_expression_bodied_properties = when_on_single_line:suggestion +csharp_style_expression_bodied_accessors = when_on_single_line:suggestion + +# Pattern matching and null handling +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_local_over_anonymous_function = true:suggestion +csharp_style_prefer_switch_expression = true:suggestion +csharp_style_prefer_pattern_matching = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion + +# Other modern C# features +csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_prefer_readonly_struct = true:suggestion +csharp_style_prefer_readonly_struct_member = true:suggestion +csharp_style_prefer_utf8_string_literals = true:suggestion +csharp_style_prefer_top_level_statements = true:suggestion + +# Using 'nameof' where appropriate +dotnet_style_prefer_nameof_expression = true:suggestion + +# Prefer collection/object initializers +dotnet_style_collection_initializer = true:suggestion +dotnet_style_object_initializer = true:suggestion + +# Access modifiers +dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion + +# Parentheses +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity +dotnet_style_parentheses_in_other_operators = never_if_unnecessary + +# File header (optional). Not enforced by default. + +######## Naming ######## +# Symbols +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private +dotnet_naming_symbols.private_fields.required_modifiers = + +dotnet_naming_symbols.static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.static_readonly_fields.required_modifiers = static, readonly + +dotnet_naming_symbols.constants.applicable_kinds = field +dotnet_naming_symbols.constants.required_modifiers = const + +dotnet_naming_symbols.interfaces.applicable_kinds = interface + +dotnet_naming_symbols.types.applicable_kinds = class, struct, enum, delegate + +# Styles +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.camel_case.capitalization = camel_case + +dotnet_naming_style.camel_case_underscore.capitalization = camel_case +dotnet_naming_style.camel_case_underscore.required_prefix = _ + +dotnet_naming_style.prefix_i_pascal.capitalization = pascal_case +dotnet_naming_style.prefix_i_pascal.required_prefix = I + +# Rules +dotnet_naming_rule.private_fields_underscore.symbols = private_fields +dotnet_naming_rule.private_fields_underscore.style = camel_case_underscore +dotnet_naming_rule.private_fields_underscore.severity = suggestion + +dotnet_naming_rule.static_readonly_fields_style.symbols = static_readonly_fields +dotnet_naming_rule.static_readonly_fields_style.style = camel_case +dotnet_naming_rule.static_readonly_fields_style.severity = suggestion + +dotnet_naming_rule.constants_pascal.symbols = constants +dotnet_naming_rule.constants_pascal.style = pascal_case +dotnet_naming_rule.constants_pascal.severity = suggestion + +dotnet_naming_rule.interfaces_with_i_prefix.symbols = interfaces +dotnet_naming_rule.interfaces_with_i_prefix.style = prefix_i_pascal +dotnet_naming_rule.interfaces_with_i_prefix.severity = suggestion + +dotnet_naming_rule.types_pascal.symbols = types +dotnet_naming_rule.types_pascal.style = pascal_case +dotnet_naming_rule.types_pascal.severity = suggestion + +######## Analyzer severities ######## +# Start with info/warnings to avoid noisy builds; adjust per team preference. +dotnet_analyzer_diagnostic.category-Style.severity = suggestion +dotnet_analyzer_diagnostic.category-Design.severity = warning +dotnet_analyzer_diagnostic.category-Maintainability.severity = warning +dotnet_analyzer_diagnostic.category-Naming.severity = suggestion +dotnet_analyzer_diagnostic.category-Reliability.severity = warning +dotnet_analyzer_diagnostic.category-Security.severity = warning + +# Treat compiler warnings as build warnings (not errors) by default +dotnet_analyzer_diagnostic.severity = warning diff --git a/src/DbDiff.Application.Tests/PathValidatorTests.cs b/src/DbDiff.Application.Tests/PathValidatorTests.cs index 3d318cd..33a42a5 100644 --- a/src/DbDiff.Application.Tests/PathValidatorTests.cs +++ b/src/DbDiff.Application.Tests/PathValidatorTests.cs @@ -80,7 +80,7 @@ public void ValidateOutputPath_WithAllowedBasePath_WhenPathIsOutsideBase_ThrowsU var outputPath = Path.Combine(Path.GetTempPath(), "outside", "output.txt"); // Act & Assert - Assert.Throws(() => + Assert.Throws(() => PathValidator.ValidateOutputPath(outputPath, basePath)); } @@ -104,7 +104,7 @@ public void ValidateOutputPath_WithSystemDirectory_ThrowsUnauthorizedAccessExcep } // Act & Assert - Assert.Throws(() => + Assert.Throws(() => PathValidator.ValidateOutputPath(systemPath)); } @@ -112,7 +112,7 @@ public void ValidateOutputPath_WithSystemDirectory_ThrowsUnauthorizedAccessExcep public void ValidateOutputPath_WithInvalidCharacters_ThrowsArgumentException() { // Arrange - var invalidPath = OperatingSystem.IsWindows() + var invalidPath = OperatingSystem.IsWindows() ? "output/file<>*.txt" // <, >, * are invalid on Windows : "output/file\0.txt"; // null character is invalid on Unix @@ -160,7 +160,7 @@ public void ValidateConfigPath_WithNonExistentFile_ThrowsFileNotFoundException() var nonExistentPath = Path.Combine(Path.GetTempPath(), "nonexistent-config.json"); // Act & Assert - Assert.Throws(() => + Assert.Throws(() => PathValidator.ValidateConfigPath(nonExistentPath)); } @@ -174,7 +174,7 @@ public void ValidateConfigPath_WithNonJsonFile_ThrowsArgumentException() try { // Act & Assert - Assert.Throws(() => + Assert.Throws(() => PathValidator.ValidateConfigPath(tempTextFile)); } finally @@ -196,7 +196,7 @@ public void ValidateConfigPath_WithAllowedBasePath_WhenFileIsOutsideBase_ThrowsU try { // Act & Assert - Assert.Throws(() => + Assert.Throws(() => PathValidator.ValidateConfigPath(configFile, basePath)); } finally @@ -251,7 +251,7 @@ public void ValidateLogPath_WithSystemDirectory_ThrowsUnauthorizedAccessExceptio } // Act & Assert - Assert.Throws(() => + Assert.Throws(() => PathValidator.ValidateLogPath(systemLogPath)); } diff --git a/src/DbDiff.Application/DTOs/SchemaExportRequest.cs b/src/DbDiff.Application/DTOs/SchemaExportRequest.cs index 9ceb9fa..d727f3a 100644 --- a/src/DbDiff.Application/DTOs/SchemaExportRequest.cs +++ b/src/DbDiff.Application/DTOs/SchemaExportRequest.cs @@ -11,7 +11,7 @@ public SchemaExportRequest(string connectionString, string outputPath) { if (string.IsNullOrWhiteSpace(connectionString)) throw new ArgumentException("Connection string cannot be null or empty.", nameof(connectionString)); - + // Validate and sanitize the output path to prevent path traversal attacks OutputPath = PathValidator.ValidateOutputPath(outputPath); ConnectionString = connectionString; diff --git a/src/DbDiff.Application/Formatters/CustomTextFormatter.cs b/src/DbDiff.Application/Formatters/CustomTextFormatter.cs index cf540d5..f84c79d 100644 --- a/src/DbDiff.Application/Formatters/CustomTextFormatter.cs +++ b/src/DbDiff.Application/Formatters/CustomTextFormatter.cs @@ -8,8 +8,7 @@ public class CustomTextFormatter : ISchemaFormatter public string Format(DatabaseSchema schema) { - if (schema == null) - throw new ArgumentNullException(nameof(schema)); + ArgumentNullException.ThrowIfNull(schema); var sb = new StringBuilder(); @@ -36,10 +35,10 @@ public string Format(DatabaseSchema schema) foreach (var column in sortedColumns) { sb.AppendLine($" COLUMN: {column.Name}"); - + if (IncludeOrdinalPosition) sb.AppendLine($" OrdinalPosition: {column.OrdinalPosition}"); - + sb.AppendLine($" Type: {column.DataType}"); sb.AppendLine($" Nullable: {(column.IsNullable ? "Yes" : "No")}"); diff --git a/src/DbDiff.Application/Services/SchemaExportService.cs b/src/DbDiff.Application/Services/SchemaExportService.cs index 450ce78..5c896bd 100644 --- a/src/DbDiff.Application/Services/SchemaExportService.cs +++ b/src/DbDiff.Application/Services/SchemaExportService.cs @@ -1,5 +1,6 @@ using DbDiff.Application.DTOs; using DbDiff.Application.Formatters; + using Microsoft.Extensions.Logging; namespace DbDiff.Application.Services; @@ -11,7 +12,7 @@ public class SchemaExportService private readonly ILogger _logger; public SchemaExportService( - ISchemaExtractor schemaExtractor, + ISchemaExtractor schemaExtractor, ISchemaFormatter schemaFormatter, ILogger logger) { @@ -21,11 +22,10 @@ public SchemaExportService( } public async Task ExportSchemaAsync( - SchemaExportRequest request, + SchemaExportRequest request, CancellationToken cancellationToken = default) { - if (request == null) - throw new ArgumentNullException(nameof(request)); + ArgumentNullException.ThrowIfNull(request); try { @@ -33,11 +33,11 @@ public async Task ExportSchemaAsync( // Extract schema from database var schema = await _schemaExtractor.ExtractSchemaAsync( - request.ConnectionString, + request.ConnectionString, cancellationToken); - _logger.LogInformation("Successfully extracted schema from database {DatabaseName} with {TableCount} tables", - schema.DatabaseName, + _logger.LogInformation("Successfully extracted schema from database {DatabaseName} with {TableCount} tables", + schema.DatabaseName, schema.Tables.Count); // Format schema to text diff --git a/src/DbDiff.Application/Validation/PathValidator.cs b/src/DbDiff.Application/Validation/PathValidator.cs index 29217bc..68f461c 100644 --- a/src/DbDiff.Application/Validation/PathValidator.cs +++ b/src/DbDiff.Application/Validation/PathValidator.cs @@ -178,8 +178,8 @@ private static bool IsPathWithinDirectory(string path, string directory) var normalizedPath = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); var normalizedDirectory = Path.GetFullPath(directory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - return normalizedPath.StartsWith(normalizedDirectory + Path.DirectorySeparatorChar, - GetPathComparison()) || + return normalizedPath.StartsWith(normalizedDirectory + Path.DirectorySeparatorChar, + GetPathComparison()) || normalizedPath.Equals(normalizedDirectory, GetPathComparison()); } @@ -198,7 +198,7 @@ private static bool IsRestrictedSystemPath(string path) // Check system drive root var systemDrive = Path.GetPathRoot(Environment.SystemDirectory); - if (!string.IsNullOrEmpty(systemDrive) && + if (!string.IsNullOrEmpty(systemDrive) && normalizedPath.Equals(systemDrive.TrimEnd(Path.DirectorySeparatorChar), StringComparison.OrdinalIgnoreCase)) return true; } diff --git a/src/DbDiff.Cli/Program.cs b/src/DbDiff.Cli/Program.cs index 582ed42..e616031 100644 --- a/src/DbDiff.Cli/Program.cs +++ b/src/DbDiff.Cli/Program.cs @@ -1,14 +1,15 @@ -using DbDiff.Application.DTOs; +using DbDiff.Application.DTOs; using DbDiff.Application.Formatters; using DbDiff.Application.Services; using DbDiff.Application.Validation; using DbDiff.Domain; using DbDiff.Infrastructure; + using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; + using Serilog; -using Serilog.Extensions.Logging; // Build configuration var configuration = new ConfigurationBuilder() @@ -123,9 +124,9 @@ { // Validate config file path - restrict to current directory and subdirectories for security var validatedConfigPath = PathValidator.ValidateConfigPath( - configFile, + configFile, allowedBasePath: Directory.GetCurrentDirectory()); - + configuration = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) .AddJsonFile(validatedConfigPath, optional: false, reloadOnChange: false) @@ -140,13 +141,13 @@ } // Priority: CLI args > Environment variables > Config file - var connectionString = connection + var connectionString = connection ?? Environment.GetEnvironmentVariable("DBDIFF_ConnectionStrings__Default") ?? configuration["ConnectionStrings:Default"]; - var outputPath = output + var outputPath = output ?? Environment.GetEnvironmentVariable("DBDIFF_Export__OutputPath") - ?? configuration["Export:OutputPath"] + ?? configuration["Export:OutputPath"] ?? "schema.txt"; if (string.IsNullOrWhiteSpace(connectionString)) @@ -173,7 +174,7 @@ } var exportService = serviceProvider.GetRequiredService(); - + // Note: Path validation happens in SchemaExportRequest constructor SchemaExportRequest request; try diff --git a/src/DbDiff.Domain/Column.cs b/src/DbDiff.Domain/Column.cs index f1c8239..f53fbc6 100644 --- a/src/DbDiff.Domain/Column.cs +++ b/src/DbDiff.Domain/Column.cs @@ -11,12 +11,12 @@ public class Column public int OrdinalPosition { get; init; } public Column( - string name, - DataType dataType, - bool isNullable, + string name, + DataType dataType, + bool isNullable, int ordinalPosition, - int? maxLength = null, - int? precision = null, + int? maxLength = null, + int? precision = null, int? scale = null) { if (string.IsNullOrWhiteSpace(name)) diff --git a/src/DbDiff.Domain/DataType.cs b/src/DbDiff.Domain/DataType.cs index 59d785c..b65be25 100644 --- a/src/DbDiff.Domain/DataType.cs +++ b/src/DbDiff.Domain/DataType.cs @@ -18,7 +18,7 @@ public override bool Equals(object? obj) { if (obj is DataType other) return TypeName.Equals(other.TypeName, StringComparison.OrdinalIgnoreCase); - + return false; } diff --git a/src/DbDiff.Domain/Table.cs b/src/DbDiff.Domain/Table.cs index aee55b6..326a87d 100644 --- a/src/DbDiff.Domain/Table.cs +++ b/src/DbDiff.Domain/Table.cs @@ -10,7 +10,7 @@ public Table(string schemaName, string tableName, IEnumerable columns) { if (string.IsNullOrWhiteSpace(schemaName)) throw new ArgumentException("Schema name cannot be null or empty.", nameof(schemaName)); - + if (string.IsNullOrWhiteSpace(tableName)) throw new ArgumentException("Table name cannot be null or empty.", nameof(tableName)); diff --git a/src/DbDiff.Infrastructure/MsSqlSchemaExtractor.cs b/src/DbDiff.Infrastructure/MsSqlSchemaExtractor.cs index 96f857b..5c58140 100644 --- a/src/DbDiff.Infrastructure/MsSqlSchemaExtractor.cs +++ b/src/DbDiff.Infrastructure/MsSqlSchemaExtractor.cs @@ -5,7 +5,7 @@ namespace DbDiff.Infrastructure; public class MsSqlSchemaExtractor : ISchemaExtractor { public async Task ExtractSchemaAsync( - string connectionString, + string connectionString, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(connectionString)) @@ -22,8 +22,8 @@ public async Task ExtractSchemaAsync( return new DatabaseSchema(databaseName, extractedAt, tables); } - private async Task> ExtractTablesAsync( - SqlConnection connection, + private static async Task> ExtractTablesAsync( + SqlConnection connection, CancellationToken cancellationToken) { var tables = new List(); @@ -60,7 +60,7 @@ FROM INFORMATION_SCHEMA.TABLES return tables; } - private async Task> ExtractColumnsAsync( + private static async Task> ExtractColumnsAsync( SqlConnection connection, string schemaName, string tableName, @@ -116,4 +116,3 @@ FROM INFORMATION_SCHEMA.COLUMNS c return columns; } } -