diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d6bc777 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,47 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build & Test Commands + +```bash +# Build +dotnet build src --configuration Release --nologo + +# Run tests (dotnet test does NOT work on .NET 10 SDK — use dotnet run) +dotnet run --project src/EfToMermaid.Tests --configuration Release --no-build +dotnet run --project src/SqlServerToMermaid.Tests --configuration Release --no-build + +# Run a single test project during development (build + run) +dotnet run --project src/EfToMermaid.Tests + +# SqlServerToMermaidTool.Tests requires LocalDb and may not run in all environments +``` + +## Architecture + +Three libraries convert database schemas to Mermaid ER diagrams, all sharing the same rendering pipeline: + +``` +EfToMermaid (EF Core IModel) ──→ SchemaReader ──→ Database record ──→ DiagramRender +SqlServerToMermaid (SMO) ──→ SchemaReader ──→ Database record ──→ DiagramRender +SqlServerToMermaid (Scripts) ──→ ScriptParser ──→ Database record ──→ DiagramRender +SqlServerToMermaidTool ──→ CLI wrapper (CliFx) around SqlServerToMermaid +``` + +**Shared code** (`src/Shared/`) is included via `` in both library `.csproj` files (not a separate project reference). It contains `DiagramRender.cs` (Mermaid output), `SchemaModel.cs` (the `Database`/`Table`/`Column`/`ForeignKey` records), and `GlobalUsings.cs`. + +## Testing + +- **Framework:** TUnit with Verify (snapshot testing) and DiffPlex +- **Snapshots:** `*.verified.md` files are the expected outputs. When rendering changes, update verified files to match +- **EfToMermaid.Tests** use fake connection strings (no DB needed). Tests requiring resolved metadata (e.g. comments) use `IDesignTimeModel` +- **SqlServerToMermaid.Tests** use LocalDb for connection-based tests and `ScriptParser` for script-based tests +- **ModuleInitializer** pattern in each test project initializes Verify/DiffPlex + +## Code Style + +- C# 14, .NET 10, file-scoped namespaces (implicit), `TreatWarningsAsErrors` +- `.editorconfig` enforces strict style rules at build time +- `var` required everywhere, collection expressions, pattern matching preferred +- Use `_` for lambda parameters: `_.Name` not `fk.Name`, `.Select(_ => _.Build())` not `.Select(t => t.Build())` diff --git a/readme.md b/readme.md index 2c2ad9d..9f6b13b 100644 --- a/readme.md +++ b/readme.md @@ -28,46 +28,46 @@ Renders Mermaid ER diagrams directly from either: ```cs -CREATE TABLE Company +create table Company ( - Id INT IDENTITY(1,1) PRIMARY KEY, - Name NVARCHAR(200) NOT NULL, - TaxNumber VARCHAR(50) NULL, - Phone VARCHAR(30) NULL, - Email VARCHAR(255) NULL, - CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(), - ModifiedAt DATETIME2 NULL + Id int identity(1,1) primary key, + Name nvarchar(200) not null, + TaxNumber varchar(50) null, + Phone varchar(30) null, + Email varchar(255) null, + CreatedAt datetime2 not null default getutcdate(), + ModifiedAt datetime2 null ); -CREATE TABLE Employee +create table Employee ( - Id INT IDENTITY(1,1) PRIMARY KEY, - FirstName NVARCHAR(100) NOT NULL, - LastName NVARCHAR(100) NOT NULL, - Email VARCHAR(255) NOT NULL, - Phone VARCHAR(30) NULL, - HireDate DATE NOT NULL, - CompanyId INT NOT NULL, - CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(), - ModifiedAt DATETIME2 NULL, - - CONSTRAINT FK_Employee_Company - FOREIGN KEY (CompanyId) - REFERENCES Company(Id), + Id int identity(1,1) primary key, + FirstName nvarchar(100) not null, + LastName nvarchar(100) not null, + Email varchar(255) not null, + Phone varchar(30) null, + HireDate date not null, + CompanyId int not null, + CreatedAt datetime2 not null default getutcdate(), + ModifiedAt datetime2 null, + + constraint FK_Employee_Company + foreign key (CompanyId) + references Company(Id), ); -CREATE TABLE Manager +create table Manager ( - Id INT IDENTITY(1,1) PRIMARY KEY, - EmployeeId INT NOT NULL, - Department NVARCHAR(100) NOT NULL, - Level TINYINT NOT NULL DEFAULT 1, - StartDate DATE NOT NULL, - EndDate DATE NULL, - - CONSTRAINT FK_Manager_Employee - FOREIGN KEY (EmployeeId) - REFERENCES Employee(Id) + Id int identity(1,1) primary key, + EmployeeId int not null, + Department nvarchar(100) not null, + Level tinyint not null default 1, + StartDate date not null, + EndDate date null, + + constraint FK_Manager_Employee + foreign key (EmployeeId) + references Employee(Id) ); -- rest of schema omitted from docs ``` @@ -91,77 +91,77 @@ var markdown = await SqlServerToMermaid.RenderMarkdown(sqlConnection); ```mermaid erDiagram - Company { - int Id(pk) "not null" - nvarchar Name "not null" - varchar TaxNumber "null" - varchar Phone "null" - varchar Email "null" - datetime2 CreatedAt "not null" - datetime2 ModifiedAt "null" + Company["**Company**"] { + int Id pk + nvarchar Name + varchar(nullable) TaxNumber + varchar(nullable) Phone + varchar(nullable) Email + datetime2 CreatedAt + datetime2(nullable) ModifiedAt } - Customer { - int Id(pk) "not null" - nvarchar FirstName "not null" - nvarchar LastName "not null" - varchar Email "not null" - varchar Phone "null" - int CompanyId "null" - datetime2 CreatedAt "not null" - datetime2 ModifiedAt "null" + Customer["**Customer**"] { + int Id pk + nvarchar FirstName + nvarchar LastName + varchar Email + varchar(nullable) Phone + int(nullable) CompanyId + datetime2 CreatedAt + datetime2(nullable) ModifiedAt } - Employee { - int Id(pk) "not null" - nvarchar FirstName "not null" - nvarchar LastName "not null" - varchar Email "not null" - varchar Phone "null" - date HireDate "not null" - int CompanyId "not null" - datetime2 CreatedAt "not null" - datetime2 ModifiedAt "null" - int ManagerId "null" + Employee["**Employee**"] { + int Id pk + nvarchar FirstName + nvarchar LastName + varchar Email + varchar(nullable) Phone + date HireDate + int CompanyId + datetime2 CreatedAt + datetime2(nullable) ModifiedAt + int(nullable) ManagerId } - Manager { - int Id(pk) "not null" - int EmployeeId "not null" - nvarchar Department "not null" - tinyint Level "not null" - date StartDate "not null" - date EndDate "null" + Manager["**Manager**"] { + int Id pk + int EmployeeId + nvarchar Department + tinyint Level + date StartDate + date(nullable) EndDate } - Order { - int Id(pk) "not null" - varchar OrderNumber "not null" - int CustomerId "not null" - datetime2 OrderDate "not null" - varchar Status "not null" - decimal SubTotal "not null" - decimal Tax "not null" - decimal Total "not null" - nvarchar Notes "null" - datetime2 CreatedAt "not null" - datetime2 ModifiedAt "null" + Order["**Order**"] { + int Id pk + varchar OrderNumber + int CustomerId + datetime2 OrderDate + varchar Status + decimal SubTotal + decimal Tax + decimal Total + nvarchar(nullable) Notes + datetime2 CreatedAt + datetime2(nullable) ModifiedAt } - OrderItem { - int Id(pk) "not null" - int OrderId "not null" - int ProductId "not null" - int Quantity "not null" - decimal UnitPrice "not null" - decimal Discount "not null" - decimal LineTotal "null, computed" + OrderItem["**OrderItem**"] { + int Id pk + int OrderId + int ProductId + int Quantity + decimal UnitPrice + decimal Discount + decimal(nullable) LineTotal "computed" } - Product { - int Id(pk) "not null" - varchar Sku "not null" - nvarchar Name "not null" - nvarchar Description "null" - decimal UnitPrice "not null" - int StockQty "not null" - bit IsActive "not null" - datetime2 CreatedAt "not null" - datetime2 ModifiedAt "null" + Product["**Product**"] { + int Id pk + varchar Sku + nvarchar Name + nvarchar(nullable) Description + decimal UnitPrice + int StockQty + bit IsActive + datetime2 CreatedAt + datetime2(nullable) ModifiedAt } Company ||--o{ Customer : "FK_Customer_Company" Company ||--o{ Employee : "FK_Employee_Company" @@ -182,9 +182,9 @@ Diagrams can also be generated directly from a SQL script string without a datab ```cs var script = """ - CREATE TABLE Customers ( - Id INT PRIMARY KEY, - Name NVARCHAR(100) NOT NULL + create table Customers ( + Id int primary key, + Name nvarchar(100) not null ); """; @@ -218,7 +218,7 @@ sql2mermaid path/to/schema.sql -o diagram.mmd **From inline SQL:** ```bash -sql2mermaid "CREATE TABLE Users (Id INT PRIMARY KEY, Name NVARCHAR(100))" -o users.md +sql2mermaid "create table Users (Id int primary key, Name nvarchar(100))" -o users.md ``` ### Options @@ -296,8 +296,22 @@ sealed class Order public int CustomerId { get; set; } public Customer Customer { get; set; } = null!; } + +sealed class NullableCustomer +{ + public int CustomerId { get; set; } + public string? Name { get; set; } + public List Orders { get; set; } = []; +} + +sealed class NullableOrder +{ + public int OrderId { get; set; } + public int? CustomerId { get; set; } + public NullableCustomer? Customer { get; set; } +} ``` -snippet source | anchor +snippet source | anchor @@ -324,13 +338,13 @@ var markdown = await EfToMermaid.RenderMarkdown(context.Model); ```mermaid erDiagram - Customers { - int CustomerId(pk) "not null" - nvarchar Name "not null" + Customers["**Customers**"] { + int CustomerId pk + nvarchar Name } - Orders { - int OrderId(pk) "not null" - int CustomerId "not null" + Orders["**Orders**"] { + int OrderId pk + int CustomerId } Customers ||--o{ Orders : "FK_Orders_Customers" ``` @@ -341,9 +355,8 @@ erDiagram * Generates valid Mermaid `erDiagram` syntax * Includes all tables with columns and data types - * Marks primary keys with `(pk)` notation + * Marks primary keys with `pk` notation * Shows nullability for each column * Indicates computed columns with `computed` annotation * Renders foreign key relationships * Handles custom database schemas (prefixes table names when not `dbo`) - * Async-first API with cancellation token support diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 8de23d6..6d66eaa 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -10,7 +10,7 @@ - + diff --git a/src/EfToMermaid.Tests/GlobalUsings.cs b/src/EfToMermaid.Tests/GlobalUsings.cs index ddda695..0a40d52 100644 --- a/src/EfToMermaid.Tests/GlobalUsings.cs +++ b/src/EfToMermaid.Tests/GlobalUsings.cs @@ -1,4 +1,5 @@ global using System.Runtime.CompilerServices; global using DbToMermaid; global using Microsoft.EntityFrameworkCore; +global using Microsoft.EntityFrameworkCore.Infrastructure; global using VerifyTests.DiffPlex; diff --git a/src/EfToMermaid.Tests/Model.cs b/src/EfToMermaid.Tests/Model.cs index faa2fc0..182865a 100644 --- a/src/EfToMermaid.Tests/Model.cs +++ b/src/EfToMermaid.Tests/Model.cs @@ -48,3 +48,17 @@ sealed class Order public int CustomerId { get; set; } public Customer Customer { get; set; } = null!; } + +sealed class NullableCustomer +{ + public int CustomerId { get; set; } + public string? Name { get; set; } + public List Orders { get; set; } = []; +} + +sealed class NullableOrder +{ + public int OrderId { get; set; } + public int? CustomerId { get; set; } + public NullableCustomer? Customer { get; set; } +} \ No newline at end of file diff --git a/src/EfToMermaid.Tests/Tests.RenderMarkdown.verified.md b/src/EfToMermaid.Tests/Tests.RenderMarkdown.verified.md index 29b0305..3c98623 100644 --- a/src/EfToMermaid.Tests/Tests.RenderMarkdown.verified.md +++ b/src/EfToMermaid.Tests/Tests.RenderMarkdown.verified.md @@ -1,13 +1,13 @@ ```mermaid erDiagram - Customers { - int CustomerId(pk) "not null" - nvarchar Name "not null" + Customers["**Customers**"] { + int CustomerId pk + nvarchar Name } - Orders { - int OrderId(pk) "not null" - int CustomerId "not null" + Orders["**Orders**"] { + int OrderId pk + int CustomerId } Customers ||--o{ Orders : "FK_Orders_Customers" ``` diff --git a/src/EfToMermaid.Tests/Tests.WithComments.verified.md b/src/EfToMermaid.Tests/Tests.WithComments.verified.md new file mode 100644 index 0000000..264685f --- /dev/null +++ b/src/EfToMermaid.Tests/Tests.WithComments.verified.md @@ -0,0 +1,13 @@ + +```mermaid +erDiagram + Customers["**Customers**: Core customer information"] { + int CustomerId pk "Auto-generated identifier" + nvarchar Name "Customer full name" + } + Orders["**Orders**: Customer orders"] { + int OrderId pk "Auto-generated identifier" + int CustomerId + } + Customers ||--o{ Orders : "FK_Orders_Customers" +``` diff --git a/src/EfToMermaid.Tests/Tests.WithEscaping.verified.md b/src/EfToMermaid.Tests/Tests.WithEscaping.verified.md new file mode 100644 index 0000000..c90684a --- /dev/null +++ b/src/EfToMermaid.Tests/Tests.WithEscaping.verified.md @@ -0,0 +1,13 @@ + +```mermaid +erDiagram + Customers["**Customers**: Contains 'quotes' here"] { + int CustomerId pk "The 'primary' key" + nvarchar Name + } + Orders["**Orders**"] { + int OrderId pk + int CustomerId + } + Customers ||--o{ Orders : "FK_Orders_Customers" +``` diff --git a/src/EfToMermaid.Tests/Tests.WithNullable.verified.md b/src/EfToMermaid.Tests/Tests.WithNullable.verified.md new file mode 100644 index 0000000..3bb9b0c --- /dev/null +++ b/src/EfToMermaid.Tests/Tests.WithNullable.verified.md @@ -0,0 +1,13 @@ + +```mermaid +erDiagram + Customers["**Customers**"] { + int CustomerId pk + nvarchar(nullable) Name + } + Orders["**Orders**"] { + int OrderId pk + int(nullable) CustomerId + } + Customers ||--o{ Orders : "FK_Orders_Customers" +``` diff --git a/src/EfToMermaid.Tests/Tests.WithSchema.verified.md b/src/EfToMermaid.Tests/Tests.WithSchema.verified.md index a39b396..9e0c07c 100644 --- a/src/EfToMermaid.Tests/Tests.WithSchema.verified.md +++ b/src/EfToMermaid.Tests/Tests.WithSchema.verified.md @@ -1,13 +1,13 @@ ```mermaid erDiagram - sales_Customers { - int CustomerId(pk) "not null" - nvarchar Name "not null" + sales_Customers["**sales_Customers**"] { + int CustomerId pk + nvarchar Name } - sales_Orders { - int OrderId(pk) "not null" - int CustomerId "not null" + sales_Orders["**sales_Orders**"] { + int OrderId pk + int CustomerId } sales_Customers ||--o{ sales_Orders : "FK_Orders_Customers" ``` diff --git a/src/EfToMermaid.Tests/Tests.cs b/src/EfToMermaid.Tests/Tests.cs index 2f1a1f6..8dcb15a 100644 --- a/src/EfToMermaid.Tests/Tests.cs +++ b/src/EfToMermaid.Tests/Tests.cs @@ -20,6 +20,53 @@ await Verify(markdown, extension: "md") .AddScrubber(_ => _.Insert(0, '\n')); } + [Test] + public async Task WithNullable() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer("Fake") + .Options; + + await using var context = new WithNullableDbContext(options); + + var markdown = await EfToMermaid.RenderMarkdown(context.Model); + + await Verify(markdown, extension: "md") + .AddScrubber(_ => _.Insert(0, '\n')); + } + + [Test] + public async Task WithComments() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer("Fake") + .Options; + + await using var context = new WithCommentsDbContext(options); + + var model = context.GetService().Model; + var markdown = await EfToMermaid.RenderMarkdown(model); + + await Verify(markdown, extension: "md") + .AddScrubber(_ => _.Insert(0, '\n')); + } + + [Test] + public async Task WithEscaping() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer("Fake") + .Options; + + await using var context = new WithEscapingDbContext(options); + + var model = context.GetService().Model; + var markdown = await EfToMermaid.RenderMarkdown(model); + + await Verify(markdown, extension: "md") + .AddScrubber(_ => _.Insert(0, '\n')); + } + [Test] public async Task WithSchema() { diff --git a/src/EfToMermaid.Tests/WithCommentsDbContext.cs b/src/EfToMermaid.Tests/WithCommentsDbContext.cs new file mode 100644 index 0000000..683dd3d --- /dev/null +++ b/src/EfToMermaid.Tests/WithCommentsDbContext.cs @@ -0,0 +1,40 @@ +class WithCommentsDbContext(DbContextOptions options) : + DbContext(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder + .Entity(builder => + { + builder.ToTable("Customers", _ => _.HasComment("Core customer information")); + builder.HasKey(_ => _.CustomerId); + builder.Property(_ => _.CustomerId) + .HasColumnType("int") + .IsRequired() + .HasComment("Auto-generated identifier"); + builder.Property(_ => _.Name) + .HasColumnType("nvarchar(50)") + .IsRequired() + .HasComment("Customer full name"); + }); + + modelBuilder + .Entity(builder => + { + builder.ToTable("Orders", _ => _.HasComment("Customer orders")); + builder.HasKey(_ => _.OrderId); + builder.Property(_ => _.OrderId) + .HasColumnType("int") + .IsRequired() + .HasComment("Auto-generated identifier"); + builder.Property(_ => _.CustomerId) + .HasColumnType("int") + .IsRequired(); + + builder.HasOne(_ => _.Customer) + .WithMany(_ => _.Orders) + .HasForeignKey(_ => _.CustomerId) + .HasConstraintName("FK_Orders_Customers"); + }); + } +} \ No newline at end of file diff --git a/src/EfToMermaid.Tests/WithEscapingDbContext.cs b/src/EfToMermaid.Tests/WithEscapingDbContext.cs new file mode 100644 index 0000000..8665045 --- /dev/null +++ b/src/EfToMermaid.Tests/WithEscapingDbContext.cs @@ -0,0 +1,38 @@ +class WithEscapingDbContext(DbContextOptions options) : + DbContext(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder + .Entity(builder => + { + builder.ToTable("Customers", _ => _.HasComment("Contains \"quotes\" here")); + builder.HasKey(_ => _.CustomerId); + builder.Property(_ => _.CustomerId) + .HasColumnType("int") + .IsRequired() + .HasComment("The \"primary\" key"); + builder.Property(_ => _.Name) + .HasColumnType("nvarchar(50)") + .IsRequired(); + }); + + modelBuilder + .Entity(builder => + { + builder.ToTable("Orders"); + builder.HasKey(_ => _.OrderId); + builder.Property(_ => _.OrderId) + .HasColumnType("int") + .IsRequired(); + builder.Property(_ => _.CustomerId) + .HasColumnType("int") + .IsRequired(); + + builder.HasOne(_ => _.Customer) + .WithMany(_ => _.Orders) + .HasForeignKey(_ => _.CustomerId) + .HasConstraintName("FK_Orders_Customers"); + }); + } +} diff --git a/src/EfToMermaid.Tests/WithNullableDbContext.cs b/src/EfToMermaid.Tests/WithNullableDbContext.cs new file mode 100644 index 0000000..98dbf1b --- /dev/null +++ b/src/EfToMermaid.Tests/WithNullableDbContext.cs @@ -0,0 +1,34 @@ +class WithNullableDbContext(DbContextOptions options) : + DbContext(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder + .Entity(builder => + { + builder.ToTable("Customers"); + builder.HasKey(_ => _.CustomerId); + builder.Property(_ => _.CustomerId) + .HasColumnType("int").IsRequired(); + builder.Property(_ => _.Name) + .HasColumnType("nvarchar(50)"); + }); + + modelBuilder + .Entity(builder => + { + builder.ToTable("Orders"); + builder.HasKey(_ => _.OrderId); + builder.Property(_ => _.OrderId) + .HasColumnType("int") + .IsRequired(); + builder.Property(_ => _.CustomerId) + .HasColumnType("int"); + + builder.HasOne(_ => _.Customer) + .WithMany(_ => _.Orders) + .HasForeignKey(_ => _.CustomerId) + .HasConstraintName("FK_Orders_Customers"); + }); + } +} \ No newline at end of file diff --git a/src/EfToMermaid/SchemaReader.cs b/src/EfToMermaid/SchemaReader.cs index f5152c0..32e7a78 100644 --- a/src/EfToMermaid/SchemaReader.cs +++ b/src/EfToMermaid/SchemaReader.cs @@ -16,6 +16,7 @@ public static Database Read(IModel model) var schema = group.Key.Schema ?? "dbo"; var tableName = group.Key.Table; var storeObject = StoreObjectIdentifier.Table(tableName, group.Key.Schema); + var tableComment = group.First().FindAnnotation("Relational:Comment")?.Value?.ToString(); var properties = group .SelectMany(_ => _.GetProperties()) @@ -30,19 +31,19 @@ public static Database Read(IModel model) .Select(_ => _.GetColumnName(storeObject) ?? _.Name) .ToHashSet(StringComparer.Ordinal); - tables.Add(new(schema, tableName, properties, pkCols)); + tables.Add(new(schema, tableName, properties, pkCols, tableComment)); } var foreignKeys = model.GetEntityTypes() .Where(_ => !_.IsOwned() && _.GetTableName() is not null) .SelectMany(_ => _.GetForeignKeys()) - .Select(fk => + .Select(_ => { - var depSchema = fk.DeclaringEntityType.GetSchema() ?? "dbo"; - var depTable = fk.DeclaringEntityType.GetTableName()!; - var principalSchema = fk.PrincipalEntityType.GetSchema() ?? "dbo"; - var principalTable = fk.PrincipalEntityType.GetTableName()!; - var name = fk.GetConstraintName() ?? $"FK_{depTable}_{principalTable}"; + var depSchema = _.DeclaringEntityType.GetSchema() ?? "dbo"; + var depTable = _.DeclaringEntityType.GetTableName()!; + var principalSchema = _.PrincipalEntityType.GetSchema() ?? "dbo"; + var principalTable = _.PrincipalEntityType.GetTableName()!; + var name = _.GetConstraintName() ?? $"fk_{depTable}_{principalTable}"; return new ForeignKey(name, depSchema, depTable, principalSchema, principalTable); }) .OrderBy(_ => _.ReferencedSchema, StringComparer.Ordinal) @@ -61,7 +62,8 @@ private static Column BuildColumn(IProperty property, StoreObjectIdentifier stor var storeType = property.GetColumnType(storeObject); var type = FormatType(storeType, property.ClrType); var isComputed = property.GetComputedColumnSql(storeObject) is not null; - return new(0, name, type, property.IsNullable, isComputed); + var comment = property.FindAnnotation("Relational:Comment")?.Value?.ToString(); + return new(0, name, type, property.IsNullable, isComputed, comment); } static string FormatType(string? storeType, Type clrType) diff --git a/src/Shared/DiagramRender.cs b/src/Shared/DiagramRender.cs index 5b337fd..9d09bc5 100644 --- a/src/Shared/DiagramRender.cs +++ b/src/Shared/DiagramRender.cs @@ -41,7 +41,14 @@ public static async Task Render(TextWriter writer, Database database, Cancel can { cancel.ThrowIfCancellationRequested(); var tableId = ToMermaidId(table.Schema, table.Name); - await writer.WriteLineAsync($" {tableId} {{"); + if (table.Comment is not null) + { + await writer.WriteLineAsync($" {tableId}[\"**{tableId}**: {table.Comment.Replace("\"", "'")}\"] {{"); + } + else + { + await writer.WriteLineAsync($" {tableId}[\"**{tableId}**\"] {{"); + } foreach (var column in table.Columns .OrderBy(_ => table.PrimaryKeys?.Contains(_.Name) != true) @@ -75,16 +82,32 @@ static async Task RenderColumn(TextWriter writer, Column column, Table table, Ca await writer.WriteAsync(" "); await writer.WriteAsync(column.Type); + if (column.IsNullable) + { + await writer.WriteAsync("(nullable)"); + } await writer.WriteAsync(' '); - await writer.WriteAsync(isPrimaryKey ? $"{colId}(pk)" : colId); + await writer.WriteAsync(colId); + if (isPrimaryKey) + { + await writer.WriteAsync(" pk"); + } - await writer.WriteAsync(" \""); - await writer.WriteAsync(column.IsNullable ? "null" : "not null"); + var parts = new List(); if (column.Computed) { - await writer.WriteAsync(", computed"); + parts.Add("computed"); + } + if (column.Comment is not null) + { + parts.Add(column.Comment.Replace("\"", "'")); + } + if (parts.Count > 0) + { + await writer.WriteAsync(" \""); + await writer.WriteAsync(string.Join(": ", parts)); + await writer.WriteAsync('"'); } - await writer.WriteAsync('"'); await writer.WriteLineAsync(); } diff --git a/src/Shared/SchemaModel.cs b/src/Shared/SchemaModel.cs index 934af3e..f2339aa 100644 --- a/src/Shared/SchemaModel.cs +++ b/src/Shared/SchemaModel.cs @@ -1,7 +1,7 @@ sealed record Database(IReadOnlyList Tables, IReadOnlyList ForeignKeys); -sealed record Table(string Schema, string Name, IReadOnlyList Columns, IReadOnlySet? PrimaryKeys); +sealed record Table(string Schema, string Name, IReadOnlyList Columns, IReadOnlySet? PrimaryKeys, string? Comment = null); -sealed record Column(int Ordinal, string Name, string Type, bool IsNullable, bool Computed); +sealed record Column(int Ordinal, string Name, string Type, bool IsNullable, bool Computed, string? Comment = null); sealed record ForeignKey(string Name, string ParentSchema, string ParentTable, string ReferencedSchema, string ReferencedTable); diff --git a/src/SqlServerToMermaid.Tests/Tests.CustomSchema.verified.md b/src/SqlServerToMermaid.Tests/Tests.CustomSchema.verified.md index b3d47fc..b3207c5 100644 --- a/src/SqlServerToMermaid.Tests/Tests.CustomSchema.verified.md +++ b/src/SqlServerToMermaid.Tests/Tests.CustomSchema.verified.md @@ -1,12 +1,12 @@ ```mermaid erDiagram - sales_Customers { - int CustomerId(pk) "not null" - nvarchar Name "not null" + sales_Customers["**sales_Customers**"] { + int CustomerId pk + nvarchar Name } - sales_Orders { - int OrderId(pk) "not null" - int CustomerId "not null" + sales_Orders["**sales_Orders**"] { + int OrderId pk + int CustomerId } sales_Customers ||--o{ sales_Orders : "FK_Orders_Customers" ``` diff --git a/src/SqlServerToMermaid.Tests/Tests.RenderMarkdown.verified.md b/src/SqlServerToMermaid.Tests/Tests.RenderMarkdown.verified.md index 78f392b..a2d7ccf 100644 --- a/src/SqlServerToMermaid.Tests/Tests.RenderMarkdown.verified.md +++ b/src/SqlServerToMermaid.Tests/Tests.RenderMarkdown.verified.md @@ -1,77 +1,77 @@ ```mermaid erDiagram - Company { - int Id(pk) "not null" - nvarchar Name "not null" - varchar TaxNumber "null" - varchar Phone "null" - varchar Email "null" - datetime2 CreatedAt "not null" - datetime2 ModifiedAt "null" + Company["**Company**"] { + int Id pk + nvarchar Name + varchar(nullable) TaxNumber + varchar(nullable) Phone + varchar(nullable) Email + datetime2 CreatedAt + datetime2(nullable) ModifiedAt } - Customer { - int Id(pk) "not null" - nvarchar FirstName "not null" - nvarchar LastName "not null" - varchar Email "not null" - varchar Phone "null" - int CompanyId "null" - datetime2 CreatedAt "not null" - datetime2 ModifiedAt "null" + Customer["**Customer**"] { + int Id pk + nvarchar FirstName + nvarchar LastName + varchar Email + varchar(nullable) Phone + int(nullable) CompanyId + datetime2 CreatedAt + datetime2(nullable) ModifiedAt } - Employee { - int Id(pk) "not null" - nvarchar FirstName "not null" - nvarchar LastName "not null" - varchar Email "not null" - varchar Phone "null" - date HireDate "not null" - int CompanyId "not null" - datetime2 CreatedAt "not null" - datetime2 ModifiedAt "null" - int ManagerId "null" + Employee["**Employee**"] { + int Id pk + nvarchar FirstName + nvarchar LastName + varchar Email + varchar(nullable) Phone + date HireDate + int CompanyId + datetime2 CreatedAt + datetime2(nullable) ModifiedAt + int(nullable) ManagerId } - Manager { - int Id(pk) "not null" - int EmployeeId "not null" - nvarchar Department "not null" - tinyint Level "not null" - date StartDate "not null" - date EndDate "null" + Manager["**Manager**"] { + int Id pk + int EmployeeId + nvarchar Department + tinyint Level + date StartDate + date(nullable) EndDate } - Order { - int Id(pk) "not null" - varchar OrderNumber "not null" - int CustomerId "not null" - datetime2 OrderDate "not null" - varchar Status "not null" - decimal SubTotal "not null" - decimal Tax "not null" - decimal Total "not null" - nvarchar Notes "null" - datetime2 CreatedAt "not null" - datetime2 ModifiedAt "null" + Order["**Order**"] { + int Id pk + varchar OrderNumber + int CustomerId + datetime2 OrderDate + varchar Status + decimal SubTotal + decimal Tax + decimal Total + nvarchar(nullable) Notes + datetime2 CreatedAt + datetime2(nullable) ModifiedAt } - OrderItem { - int Id(pk) "not null" - int OrderId "not null" - int ProductId "not null" - int Quantity "not null" - decimal UnitPrice "not null" - decimal Discount "not null" - decimal LineTotal "null, computed" + OrderItem["**OrderItem**"] { + int Id pk + int OrderId + int ProductId + int Quantity + decimal UnitPrice + decimal Discount + decimal(nullable) LineTotal "computed" } - Product { - int Id(pk) "not null" - varchar Sku "not null" - nvarchar Name "not null" - nvarchar Description "null" - decimal UnitPrice "not null" - int StockQty "not null" - bit IsActive "not null" - datetime2 CreatedAt "not null" - datetime2 ModifiedAt "null" + Product["**Product**"] { + int Id pk + varchar Sku + nvarchar Name + nvarchar(nullable) Description + decimal UnitPrice + int StockQty + bit IsActive + datetime2 CreatedAt + datetime2(nullable) ModifiedAt } Company ||--o{ Customer : "FK_Customer_Company" Company ||--o{ Employee : "FK_Employee_Company" diff --git a/src/SqlServerToMermaid.Tests/Tests.RenderMarkdownFromScript.verified.md b/src/SqlServerToMermaid.Tests/Tests.RenderMarkdownFromScript.verified.md index 1d7e88e..649d5ba 100644 --- a/src/SqlServerToMermaid.Tests/Tests.RenderMarkdownFromScript.verified.md +++ b/src/SqlServerToMermaid.Tests/Tests.RenderMarkdownFromScript.verified.md @@ -1,18 +1,18 @@ ```mermaid erDiagram - Company { - int Id "not null" - nvarchar Name "not null" - datetime2 CreatedAt "not null" + Company["**Company**"] { + int Id + nvarchar Name + datetime2 CreatedAt } - Employee { - int Id "not null" - nvarchar FirstName "not null" - nvarchar LastName "not null" - int CompanyId "not null" - decimal Salary "not null" - decimal Bonus "not null" - unknown TotalPay "null, computed" + Employee["**Employee**"] { + int Id + nvarchar FirstName + nvarchar LastName + int CompanyId + decimal Salary + decimal Bonus + unknown(nullable) TotalPay "computed" } Company ||--o{ Employee : "FK_Employee_Company" ``` diff --git a/src/SqlServerToMermaid.Tests/Tests.RenderMarkdownFromScriptIgnoresIrrelevantExec.verified.md b/src/SqlServerToMermaid.Tests/Tests.RenderMarkdownFromScriptIgnoresIrrelevantExec.verified.md new file mode 100644 index 0000000..c5a4fe3 --- /dev/null +++ b/src/SqlServerToMermaid.Tests/Tests.RenderMarkdownFromScriptIgnoresIrrelevantExec.verified.md @@ -0,0 +1,7 @@ +```mermaid +erDiagram + hr_Employees["**hr_Employees**: Employee records"] { + int Id + nvarchar Name + } +``` diff --git a/src/SqlServerToMermaid.Tests/Tests.RenderMarkdownFromScriptWithAlterTable.verified.md b/src/SqlServerToMermaid.Tests/Tests.RenderMarkdownFromScriptWithAlterTable.verified.md index 8e938cb..e72111f 100644 --- a/src/SqlServerToMermaid.Tests/Tests.RenderMarkdownFromScriptWithAlterTable.verified.md +++ b/src/SqlServerToMermaid.Tests/Tests.RenderMarkdownFromScriptWithAlterTable.verified.md @@ -1,11 +1,11 @@ ```mermaid erDiagram - Child { - int Id "not null" - int ParentId "null" + Child["**Child**"] { + int Id + int(nullable) ParentId } - Parent { - int Id "not null" + Parent["**Parent**"] { + int Id } Parent ||--o{ Child : "FK_Child_Parent" ``` diff --git a/src/SqlServerToMermaid.Tests/Tests.RenderMarkdownFromScriptWithAlterTableAddColumn.verified.md b/src/SqlServerToMermaid.Tests/Tests.RenderMarkdownFromScriptWithAlterTableAddColumn.verified.md new file mode 100644 index 0000000..d867967 --- /dev/null +++ b/src/SqlServerToMermaid.Tests/Tests.RenderMarkdownFromScriptWithAlterTableAddColumn.verified.md @@ -0,0 +1,10 @@ +```mermaid +erDiagram + Items["**Items**"] { + int Id + nvarchar Name + } + NewTable["**NewTable**"] { + int(nullable) Value + } +``` diff --git a/src/SqlServerToMermaid.Tests/Tests.RenderMarkdownFromScriptWithComments.verified.md b/src/SqlServerToMermaid.Tests/Tests.RenderMarkdownFromScriptWithComments.verified.md new file mode 100644 index 0000000..dc69490 --- /dev/null +++ b/src/SqlServerToMermaid.Tests/Tests.RenderMarkdownFromScriptWithComments.verified.md @@ -0,0 +1,8 @@ +```mermaid +erDiagram + Customers["**Customers**: Core customer information"] { + int CustomerId "Auto-generated identifier" + nvarchar Name "Customer full name" + varchar(nullable) Email + } +``` diff --git a/src/SqlServerToMermaid.Tests/Tests.RenderMarkdownFromScriptWithEscaping.verified.md b/src/SqlServerToMermaid.Tests/Tests.RenderMarkdownFromScriptWithEscaping.verified.md new file mode 100644 index 0000000..87e06bc --- /dev/null +++ b/src/SqlServerToMermaid.Tests/Tests.RenderMarkdownFromScriptWithEscaping.verified.md @@ -0,0 +1,7 @@ +```mermaid +erDiagram + Customers["**Customers**: Contains 'quotes' here"] { + int CustomerId "The 'primary' key" + nvarchar Name + } +``` diff --git a/src/SqlServerToMermaid.Tests/Tests.RenderMarkdownFromScriptWithSchema.verified.md b/src/SqlServerToMermaid.Tests/Tests.RenderMarkdownFromScriptWithSchema.verified.md index 4049a7a..a505212 100644 --- a/src/SqlServerToMermaid.Tests/Tests.RenderMarkdownFromScriptWithSchema.verified.md +++ b/src/SqlServerToMermaid.Tests/Tests.RenderMarkdownFromScriptWithSchema.verified.md @@ -1,12 +1,12 @@ ```mermaid erDiagram - sales_Customers { - int CustomerId "not null" - nvarchar Name "not null" + sales_Customers["**sales_Customers**"] { + int CustomerId + nvarchar Name } - sales_Orders { - int OrderId "not null" - int CustomerId "not null" + sales_Orders["**sales_Orders**"] { + int OrderId + int CustomerId } sales_Customers ||--o{ sales_Orders : "FK_Orders_Customers" ``` diff --git a/src/SqlServerToMermaid.Tests/Tests.RenderMarkdownFromScriptWithTableLevelPrimaryKey.verified.md b/src/SqlServerToMermaid.Tests/Tests.RenderMarkdownFromScriptWithTableLevelPrimaryKey.verified.md new file mode 100644 index 0000000..d47c496 --- /dev/null +++ b/src/SqlServerToMermaid.Tests/Tests.RenderMarkdownFromScriptWithTableLevelPrimaryKey.verified.md @@ -0,0 +1,12 @@ +```mermaid +erDiagram + Child["**Child**"] { + int Id + int ParentId + } + Parent["**Parent**"] { + int(nullable) Id pk + emailaddress(nullable) Email + } + Parent ||--o{ Child : "fk_Child_Parent" +``` diff --git a/src/SqlServerToMermaid.Tests/Tests.RenderMarkdownWithComments.verified.md b/src/SqlServerToMermaid.Tests/Tests.RenderMarkdownWithComments.verified.md new file mode 100644 index 0000000..3d027fe --- /dev/null +++ b/src/SqlServerToMermaid.Tests/Tests.RenderMarkdownWithComments.verified.md @@ -0,0 +1,9 @@ + +```mermaid +erDiagram + Customers["**Customers**: Core customer information"] { + int CustomerId pk "Auto-generated identifier" + nvarchar Name "Customer full name" + varchar(nullable) Email + } +``` diff --git a/src/SqlServerToMermaid.Tests/Tests.RenderMarkdownWithEscaping.verified.md b/src/SqlServerToMermaid.Tests/Tests.RenderMarkdownWithEscaping.verified.md new file mode 100644 index 0000000..44ccee6 --- /dev/null +++ b/src/SqlServerToMermaid.Tests/Tests.RenderMarkdownWithEscaping.verified.md @@ -0,0 +1,8 @@ + +```mermaid +erDiagram + Customers["**Customers**: Contains 'quotes' here"] { + int CustomerId pk "The 'primary' key" + nvarchar Name + } +``` diff --git a/src/SqlServerToMermaid.Tests/Tests.cs b/src/SqlServerToMermaid.Tests/Tests.cs index 50487ee..882e4ec 100644 --- a/src/SqlServerToMermaid.Tests/Tests.cs +++ b/src/SqlServerToMermaid.Tests/Tests.cs @@ -23,9 +23,9 @@ static async Task ScriptUsage() #region SqlServerScriptUsage var script = """ - CREATE TABLE Customers ( - Id INT PRIMARY KEY, - Name NVARCHAR(100) NOT NULL + create table Customers ( + Id int primary key, + Name nvarchar(100) not null ); """; @@ -44,136 +44,136 @@ public async Task RenderMarkdown() """ -- begin-snippet: SampleSchema - CREATE TABLE Company + create table Company ( - Id INT IDENTITY(1,1) PRIMARY KEY, - Name NVARCHAR(200) NOT NULL, - TaxNumber VARCHAR(50) NULL, - Phone VARCHAR(30) NULL, - Email VARCHAR(255) NULL, - CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(), - ModifiedAt DATETIME2 NULL + Id int identity(1,1) primary key, + Name nvarchar(200) not null, + TaxNumber varchar(50) null, + Phone varchar(30) null, + Email varchar(255) null, + CreatedAt datetime2 not null default getutcdate(), + ModifiedAt datetime2 null ); - CREATE TABLE Employee + create table Employee ( - Id INT IDENTITY(1,1) PRIMARY KEY, - FirstName NVARCHAR(100) NOT NULL, - LastName NVARCHAR(100) NOT NULL, - Email VARCHAR(255) NOT NULL, - Phone VARCHAR(30) NULL, - HireDate DATE NOT NULL, - CompanyId INT NOT NULL, - CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(), - ModifiedAt DATETIME2 NULL, - - CONSTRAINT FK_Employee_Company - FOREIGN KEY (CompanyId) - REFERENCES Company(Id), + Id int identity(1,1) primary key, + FirstName nvarchar(100) not null, + LastName nvarchar(100) not null, + Email varchar(255) not null, + Phone varchar(30) null, + HireDate date not null, + CompanyId int not null, + CreatedAt datetime2 not null default getutcdate(), + ModifiedAt datetime2 null, + + constraint FK_Employee_Company + foreign key (CompanyId) + references Company(Id), ); - CREATE TABLE Manager + create table Manager ( - Id INT IDENTITY(1,1) PRIMARY KEY, - EmployeeId INT NOT NULL, - Department NVARCHAR(100) NOT NULL, - Level TINYINT NOT NULL DEFAULT 1, - StartDate DATE NOT NULL, - EndDate DATE NULL, - - CONSTRAINT FK_Manager_Employee - FOREIGN KEY (EmployeeId) - REFERENCES Employee(Id) + Id int identity(1,1) primary key, + EmployeeId int not null, + Department nvarchar(100) not null, + Level tinyint not null default 1, + StartDate date not null, + EndDate date null, + + constraint FK_Manager_Employee + foreign key (EmployeeId) + references Employee(Id) ); -- rest of schema omitted from docs -- end-snippet - ALTER TABLE Employee - ADD ManagerId INT NULL, - CONSTRAINT FK_Employee_Manager - FOREIGN KEY (ManagerId) - REFERENCES Manager(Id); + alter table Employee + add ManagerId int null, + constraint FK_Employee_Manager + foreign key (ManagerId) + references Manager(Id); - CREATE TABLE Customer + create table Customer ( - Id INT IDENTITY(1,1) PRIMARY KEY, - FirstName NVARCHAR(100) NOT NULL, - LastName NVARCHAR(100) NOT NULL, - Email VARCHAR(255) NOT NULL, - Phone VARCHAR(30) NULL, - CompanyId INT NULL, - CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(), - ModifiedAt DATETIME2 NULL, - - CONSTRAINT FK_Customer_Company - FOREIGN KEY (CompanyId) - REFERENCES Company(Id), + Id int identity(1,1) primary key, + FirstName nvarchar(100) not null, + LastName nvarchar(100) not null, + Email varchar(255) not null, + Phone varchar(30) null, + CompanyId int null, + CreatedAt datetime2 not null default getutcdate(), + ModifiedAt datetime2 null, + + constraint FK_Customer_Company + foreign key (CompanyId) + references Company(Id), ); - CREATE TABLE Product + create table Product ( - Id INT IDENTITY(1,1) PRIMARY KEY, - Sku VARCHAR(50) NOT NULL, - Name NVARCHAR(200) NOT NULL, - Description NVARCHAR(MAX) NULL, - UnitPrice DECIMAL(18,2) NOT NULL, - StockQty INT NOT NULL DEFAULT 0, - IsActive BIT NOT NULL DEFAULT 1, - CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(), - ModifiedAt DATETIME2 NULL, - - CONSTRAINT UQ_Product_Sku UNIQUE (Sku) + Id int identity(1,1) primary key, + Sku varchar(50) not null, + Name nvarchar(200) not null, + Description nvarchar(max) null, + UnitPrice decimal(18,2) not null, + StockQty int not null default 0, + IsActive bit not null default 1, + CreatedAt datetime2 not null default getutcdate(), + ModifiedAt datetime2 null, + + constraint UQ_Product_Sku unique (Sku) ); - CREATE TABLE [Order] + create table [Order] ( - Id INT IDENTITY(1,1) PRIMARY KEY, - OrderNumber VARCHAR(30) NOT NULL, - CustomerId INT NOT NULL, - OrderDate DATETIME2 NOT NULL DEFAULT GETUTCDATE(), - Status VARCHAR(20) NOT NULL DEFAULT 'Pending', - SubTotal DECIMAL(18,2) NOT NULL DEFAULT 0, - Tax DECIMAL(18,2) NOT NULL DEFAULT 0, - Total DECIMAL(18,2) NOT NULL DEFAULT 0, - Notes NVARCHAR(1000) NULL, - CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(), - ModifiedAt DATETIME2 NULL, - - CONSTRAINT UQ_Order_OrderNumber - UNIQUE (OrderNumber), - CONSTRAINT FK_Order_Customer - FOREIGN KEY (CustomerId) REFERENCES Customer(Id), + Id int identity(1,1) primary key, + OrderNumber varchar(30) not null, + CustomerId int not null, + OrderDate datetime2 not null default getutcdate(), + Status varchar(20) not null default 'Pending', + SubTotal decimal(18,2) not null default 0, + Tax decimal(18,2) not null default 0, + Total decimal(18,2) not null default 0, + Notes nvarchar(1000) null, + CreatedAt datetime2 not null default getutcdate(), + ModifiedAt datetime2 null, + + constraint UQ_Order_OrderNumber + unique (OrderNumber), + constraint FK_Order_Customer + foreign key (CustomerId) references Customer(Id), ); - CREATE TABLE OrderItem + create table OrderItem ( - Id INT IDENTITY(1,1) PRIMARY KEY, - OrderId INT NOT NULL, - ProductId INT NOT NULL, - Quantity INT NOT NULL, - UnitPrice DECIMAL(18,2) NOT NULL, - Discount DECIMAL(18,2) NOT NULL DEFAULT 0, - LineTotal AS (Quantity * UnitPrice - Discount) PERSISTED, - - CONSTRAINT FK_OrderItem_Order - FOREIGN KEY (OrderId) - REFERENCES [Order](Id) - ON DELETE CASCADE, - CONSTRAINT FK_OrderItem_Product - FOREIGN KEY (ProductId) - REFERENCES Product(Id), - CONSTRAINT CK_OrderItem_Quantity - CHECK (Quantity > 0) + Id int identity(1,1) primary key, + OrderId int not null, + ProductId int not null, + Quantity int not null, + UnitPrice decimal(18,2) not null, + Discount decimal(18,2) not null default 0, + LineTotal as (Quantity * UnitPrice - Discount) persisted, + + constraint FK_OrderItem_Order + foreign key (OrderId) + references [Order](Id) + on delete cascade, + constraint FK_OrderItem_Product + foreign key (ProductId) + references Product(Id), + constraint CK_OrderItem_Quantity + check (Quantity > 0) ); - CREATE INDEX IX_Employee_CompanyId ON Employee(CompanyId); - CREATE INDEX IX_Employee_ManagerId ON Employee(ManagerId); - CREATE INDEX IX_Customer_CompanyId ON Customer(CompanyId); - CREATE INDEX IX_Customer_Email ON Customer(Email); - CREATE INDEX IX_Order_CustomerId ON [Order](CustomerId); - CREATE INDEX IX_Order_OrderDate ON [Order](OrderDate); - CREATE INDEX IX_Order_Status ON [Order](Status); - CREATE INDEX IX_OrderItem_OrderId ON OrderItem(OrderId); - CREATE INDEX IX_OrderItem_ProductId ON OrderItem(ProductId); + create index IX_Employee_CompanyId on Employee(CompanyId); + create index IX_Employee_ManagerId on Employee(ManagerId); + create index IX_Customer_CompanyId on Customer(CompanyId); + create index IX_Customer_Email on Customer(Email); + create index IX_Order_CustomerId on [Order](CustomerId); + create index IX_Order_OrderDate on [Order](OrderDate); + create index IX_Order_Status on [Order](Status); + create index IX_OrderItem_OrderId on OrderItem(OrderId); + create index IX_OrderItem_ProductId on OrderItem(ProductId); """; await command.ExecuteNonQueryAsync(); } @@ -216,30 +216,147 @@ references sales.Customers(CustomerId) await Verify(markdown, extension: "md"); } + [Test] + public async Task RenderMarkdownWithComments() + { + await using var database = await instance.Build(); + await using (var command = database.Connection.CreateCommand()) + { + command.CommandText = + """ + create table Customers + ( + CustomerId int identity(1,1) primary key, + Name nvarchar(100) not null, + Email varchar(255) null + ); + + exec sp_addextendedproperty + @name = N'MS_Description', + @value = N'Core customer information', + @level0type = N'schema', @level0name = N'dbo', + @level1type = N'table', @level1name = N'Customers'; + + exec sp_addextendedproperty + @name = N'MS_Description', + @value = N'Auto-generated identifier', + @level0type = N'schema', @level0name = N'dbo', + @level1type = N'table', @level1name = N'Customers', + @level2type = N'column', @level2name = N'CustomerId'; + + exec sp_addextendedproperty + @name = N'MS_Description', + @value = N'Customer full name', + @level0type = N'schema', @level0name = N'dbo', + @level1type = N'table', @level1name = N'Customers', + @level2type = N'column', @level2name = N'Name'; + """; + await command.ExecuteNonQueryAsync(); + } + + var markdown = await SqlServerToMermaid.RenderMarkdown(database.Connection); + + await Verify(markdown, extension: "md") + .AddScrubber(_ => _.Insert(0, '\n')); + } + + [Test] + public async Task RenderMarkdownFromScriptWithComments() + { + var script = """ + create table Customers + ( + CustomerId int primary key, + Name nvarchar(100) not null, + Email varchar(255) null + ); + + exec sp_addextendedproperty + @name = N'MS_Description', + @value = N'Core customer information', + @level0type = N'schema', @level0name = N'dbo', + @level1type = N'table', @level1name = N'Customers'; + + exec sp_addextendedproperty + @name = N'MS_Description', + @value = N'Auto-generated identifier', + @level0type = N'schema', @level0name = N'dbo', + @level1type = N'table', @level1name = N'Customers', + @level2type = N'column', @level2name = N'CustomerId'; + + exec sp_addextendedproperty + @name = N'MS_Description', + @value = N'Customer full name', + @level0type = N'schema', @level0name = N'dbo', + @level1type = N'table', @level1name = N'Customers', + @level2type = N'column', @level2name = N'Name'; + """; + + var markdown = await SqlServerToMermaid.RenderMarkdownFromScript(script); + + await Verify(markdown, extension: "md"); + } + + [Test] + public async Task RenderMarkdownWithEscaping() + { + await using var database = await instance.Build(); + await using (var command = database.Connection.CreateCommand()) + { + command.CommandText = + """ + create table Customers + ( + CustomerId int identity(1,1) primary key, + Name nvarchar(100) not null + ); + + exec sp_addextendedproperty + @name = N'MS_Description', + @value = N'Contains "quotes" here', + @level0type = N'schema', @level0name = N'dbo', + @level1type = N'table', @level1name = N'Customers'; + + exec sp_addextendedproperty + @name = N'MS_Description', + @value = N'The "primary" key', + @level0type = N'schema', @level0name = N'dbo', + @level1type = N'table', @level1name = N'Customers', + @level2type = N'column', @level2name = N'CustomerId'; + """; + await command.ExecuteNonQueryAsync(); + } + + var markdown = await SqlServerToMermaid.RenderMarkdown(database.Connection); + + await Verify(markdown, extension: "md") + .AddScrubber(_ => _.Insert(0, '\n')); + } + [Test] public async Task RenderMarkdownFromScript() { var script = """ - CREATE TABLE Company + create table Company ( - Id INT PRIMARY KEY, - Name NVARCHAR(200) NOT NULL, - CreatedAt DATETIME2 NOT NULL + Id int primary key, + Name nvarchar(200) not null, + CreatedAt datetime2 not null ); - CREATE TABLE Employee + create table Employee ( - Id INT PRIMARY KEY, - FirstName NVARCHAR(100) NOT NULL, - LastName NVARCHAR(100) NOT NULL, - CompanyId INT NOT NULL, - Salary DECIMAL(18,2) NOT NULL, - Bonus DECIMAL(18,2) NOT NULL, - TotalPay AS (Salary + Bonus), - - CONSTRAINT FK_Employee_Company - FOREIGN KEY (CompanyId) - REFERENCES Company(Id) + Id int primary key, + FirstName nvarchar(100) not null, + LastName nvarchar(100) not null, + CompanyId int not null, + Salary decimal(18,2) not null, + Bonus decimal(18,2) not null, + TotalPay as (Salary + Bonus), + + constraint FK_Employee_Company + foreign key (CompanyId) + references Company(Id) ); """; @@ -248,24 +365,53 @@ REFERENCES Company(Id) await Verify(markdown, extension: "md"); } + [Test] + public async Task RenderMarkdownFromScriptWithEscaping() + { + var script = """ + create table Customers + ( + CustomerId int primary key, + Name nvarchar(100) not null + ); + + exec sp_addextendedproperty + @name = N'MS_Description', + @value = N'Contains "quotes" here', + @level0type = N'schema', @level0name = N'dbo', + @level1type = N'table', @level1name = N'Customers'; + + exec sp_addextendedproperty + @name = N'MS_Description', + @value = N'The "primary" key', + @level0type = N'schema', @level0name = N'dbo', + @level1type = N'table', @level1name = N'Customers', + @level2type = N'column', @level2name = N'CustomerId'; + """; + + var markdown = await SqlServerToMermaid.RenderMarkdownFromScript(script); + + await Verify(markdown, extension: "md"); + } + [Test] public async Task RenderMarkdownFromScriptWithSchema() { var script = """ - CREATE TABLE sales.Customers + create table sales.Customers ( - CustomerId INT PRIMARY KEY, - Name NVARCHAR(50) NOT NULL + CustomerId int primary key, + Name nvarchar(50) not null ); - CREATE TABLE sales.Orders + create table sales.Orders ( - OrderId INT PRIMARY KEY, - CustomerId INT NOT NULL, + OrderId int primary key, + CustomerId int not null, - CONSTRAINT FK_Orders_Customers - FOREIGN KEY (CustomerId) - REFERENCES sales.Customers(CustomerId) + constraint FK_Orders_Customers + foreign key (CustomerId) + references sales.Customers(CustomerId) ); """; @@ -278,21 +424,106 @@ REFERENCES sales.Customers(CustomerId) public async Task RenderMarkdownFromScriptWithAlterTable() { var script = """ - CREATE TABLE Parent + create table Parent + ( + Id int primary key + ); + + create table Child + ( + Id int primary key, + ParentId int null + ); + + alter table Child + add constraint FK_Child_Parent + foreign key (ParentId) + references Parent(Id); + """; + + var markdown = await SqlServerToMermaid.RenderMarkdownFromScript(script); + + await Verify(markdown, extension: "md"); + } + + [Test] + public async Task RenderMarkdownFromScriptWithTableLevelPrimaryKey() + { + var script = """ + create type EmailAddress from varchar(255); + + create table Parent + ( + Id int, + Email EmailAddress null, + primary key(Id) + ); + + create table Child + ( + Id int primary key, + ParentId int not null, + foreign key (ParentId) references Parent(Id) + ); + """; + + var markdown = await SqlServerToMermaid.RenderMarkdownFromScript(script); + + await Verify(markdown, extension: "md"); + } + + [Test] + public async Task RenderMarkdownFromScriptWithAlterTableAddColumn() + { + var script = """ + create table Items ( - Id INT PRIMARY KEY + Id int primary key ); - CREATE TABLE Child + alter table Items + add Name nvarchar(100) not null; + + alter table NewTable + add Value int null; + """; + + var markdown = await SqlServerToMermaid.RenderMarkdownFromScript(script); + + await Verify(markdown, extension: "md"); + } + + [Test] + public async Task RenderMarkdownFromScriptIgnoresIrrelevantExec() + { + var script = """ + create table hr.Employees ( - Id INT PRIMARY KEY, - ParentId INT NULL + Id int primary key, + Name nvarchar(100) not null ); - ALTER TABLE Child - ADD CONSTRAINT FK_Child_Parent - FOREIGN KEY (ParentId) - REFERENCES Parent(Id); + exec('select 1'); + + exec sp_rename @objname = N'old', @newname = N'new'; + + exec sp_addextendedproperty + @name = N'MS_Description', + @value = N'Employee records', + @level0type = N'schema', @level0name = N'hr', + @level1type = N'table', @level1name = N'Employees'; + + exec sp_addextendedproperty + @name = N'SomeOtherProperty', + @value = N'ignored', + @level0type = N'schema', @level0name = N'dbo', + @level1type = N'table', @level1name = N'Employees'; + + exec sp_addextendedproperty + @name = N'MS_Description', + @value = N'ghost', + @level0type = N'schema', @level0name = N'dbo', + @level1type = N'table', @level1name = N'NonExistent'; """; var markdown = await SqlServerToMermaid.RenderMarkdownFromScript(script); diff --git a/src/SqlServerToMermaid/GlobalUsings.cs b/src/SqlServerToMermaid/GlobalUsings.cs index 3399e38..59e4930 100644 --- a/src/SqlServerToMermaid/GlobalUsings.cs +++ b/src/SqlServerToMermaid/GlobalUsings.cs @@ -1,4 +1,6 @@ global using System.Data; +global using DbToMermaid; global using Microsoft.Data.SqlClient; global using Microsoft.SqlServer.Management.Common; global using Microsoft.SqlServer.Management.Smo; +global using Microsoft.SqlServer.TransactSql.ScriptDom; diff --git a/src/SqlServerToMermaid/SchemaReader.cs b/src/SqlServerToMermaid/SchemaReader.cs index 95cd014..882316e 100644 --- a/src/SqlServerToMermaid/SchemaReader.cs +++ b/src/SqlServerToMermaid/SchemaReader.cs @@ -21,6 +21,7 @@ public static async Task Read(SqlConnection connection, Cancel cancel) .Select(table => { var primaryKeys = GetPrimaryKeys(table); + var tableComment = table.ExtendedProperties["MS_Description"]?.Value?.ToString(); var columns = table.Columns .OrderBy(_ => _.ID) @@ -29,19 +30,20 @@ public static async Task Read(SqlConnection connection, Cancel cancel) Name: _.Name, Type: FormatType(_.DataType), IsNullable: _.Nullable, - Computed: _.Computed)) + Computed: _.Computed, + Comment: _.ExtendedProperties["MS_Description"]?.Value?.ToString())) .ToList(); - return new Table(table.Schema, table.Name, columns, primaryKeys); + return new Table(table.Schema, table.Name, columns, primaryKeys, tableComment); }) .ToList(); var foreignKeys = db.Tables .Where(_ => !_.IsSystemObject) .SelectMany(_ => _.ForeignKeys) - .Where(fk => + .Where(_ => { - var referenced = db.Tables[fk.ReferencedTable, fk.ReferencedTableSchema]; + var referenced = db.Tables[_.ReferencedTable, _.ReferencedTableSchema]; return referenced is not null && !referenced.IsSystemObject; }) .Select(_ => new ForeignKey( diff --git a/src/SqlServerToMermaid/ScriptParser.cs b/src/SqlServerToMermaid/ScriptParser.cs index 5db66a5..73e9028 100644 --- a/src/SqlServerToMermaid/ScriptParser.cs +++ b/src/SqlServerToMermaid/ScriptParser.cs @@ -1,5 +1,3 @@ -using Microsoft.SqlServer.TransactSql.ScriptDom; - static class ScriptParser { public static Database Parse(string script) @@ -10,8 +8,7 @@ public static Database Parse(string script) if (errors.Count > 0) { - var messages = string.Join(Environment.NewLine, errors.Select(e => $"Line {e.Line}: {e.Message}")); - throw new InvalidOperationException($"SQL parse errors:{Environment.NewLine}{messages}"); + throw new SqlParseException(errors); } var tables = new Dictionary<(string Schema, string Name), TableBuilder>(); @@ -26,17 +23,17 @@ public static Database Parse(string script) } var tableList = tables.Values - .OrderBy(t => t.Schema, StringComparer.Ordinal) - .ThenBy(t => t.Name, StringComparer.Ordinal) - .Select(t => t.Build()) + .OrderBy(_ => _.Schema, StringComparer.Ordinal) + .ThenBy(_ => _.Name, StringComparer.Ordinal) + .Select(_ => _.Build()) .ToList(); var fkList = foreignKeys - .OrderBy(fk => fk.ReferencedSchema, StringComparer.Ordinal) - .ThenBy(fk => fk.ReferencedTable, StringComparer.Ordinal) - .ThenBy(fk => fk.ParentSchema, StringComparer.Ordinal) - .ThenBy(fk => fk.ParentTable, StringComparer.Ordinal) - .ThenBy(fk => fk.Name, StringComparer.Ordinal) + .OrderBy(_ => _.ReferencedSchema, StringComparer.Ordinal) + .ThenBy(_ => _.ReferencedTable, StringComparer.Ordinal) + .ThenBy(_ => _.ParentSchema, StringComparer.Ordinal) + .ThenBy(_ => _.ParentTable, StringComparer.Ordinal) + .ThenBy(_ => _.Name, StringComparer.Ordinal) .ToList(); return new(tableList, fkList); @@ -52,6 +49,9 @@ static void ProcessStatement(TSqlStatement statement, Dictionary<(string Schema, case AlterTableAddTableElementStatement alterAdd: ProcessAlterTableAdd(alterAdd, tables, foreignKeys); break; + case ExecuteStatement exec: + ProcessExecute(exec, tables); + break; } } @@ -105,21 +105,112 @@ static void ProcessConstraint(ConstraintDefinition constraint, string schemaName { switch (constraint) { - case UniqueConstraintDefinition { IsPrimaryKey: true } pk: - foreach (var col in pk.Columns) + case UniqueConstraintDefinition { IsPrimaryKey: true } primaryKey: + foreach (var col in primaryKey.Columns) { builder.PrimaryKeys.Add(col.Column.MultiPartIdentifier.Identifiers.Last().Value); } break; - case ForeignKeyConstraintDefinition fk: - var fkName = fk.ConstraintIdentifier?.Value ?? $"FK_{tableName}_{fk.ReferenceTableName.BaseIdentifier.Value}"; - var refSchema = fk.ReferenceTableName.SchemaIdentifier?.Value ?? "dbo"; - var refTable = fk.ReferenceTableName.BaseIdentifier.Value; - foreignKeys.Add(new(fkName, schemaName, tableName, refSchema, refTable)); + case ForeignKeyConstraintDefinition foreignKey: + var name = foreignKey.ConstraintIdentifier?.Value ?? $"fk_{tableName}_{foreignKey.ReferenceTableName.BaseIdentifier.Value}"; + var schema = foreignKey.ReferenceTableName.SchemaIdentifier?.Value ?? "dbo"; + var table = foreignKey.ReferenceTableName.BaseIdentifier.Value; + foreignKeys.Add(new(name, schemaName, tableName, schema, table)); break; } } + static void ProcessExecute(ExecuteStatement exec, Dictionary<(string Schema, string Name), TableBuilder> tables) + { + var spec = exec.ExecuteSpecification; + if (spec.ExecutableEntity is not ExecutableProcedureReference procRef) + { + return; + } + + var procName = procRef.ProcedureReference.ProcedureReference.Name; + var name = procName.BaseIdentifier.Value; + if (!name.Equals("sp_addextendedproperty", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var parameters = spec.ExecutableEntity is ExecutableProcedureReference epr + ? epr.Parameters + : []; + + string? propName = null, propValue = null, level1Type = null, level1Name = null, level2Type = null, level2Name = null; + + foreach (var param in parameters) + { + var paramName = param.Variable?.Name?.ToLowerInvariant(); + var paramValue = GetLiteralValue(param.ParameterValue); + + switch (paramName) + { + case "@name": + propName = paramValue; + break; + case "@value": + propValue = paramValue; + break; + case "@level1type": + level1Type = paramValue; + break; + case "@level1name": + level1Name = paramValue; + break; + case "@level2type": + level2Type = paramValue; + break; + case "@level2name": + level2Name = paramValue; + break; + } + } + + if (propName is null || + !propName.Equals("MS_Description", StringComparison.OrdinalIgnoreCase) || + propValue is null || + level1Type is null || + !level1Type.Equals("table", StringComparison.OrdinalIgnoreCase) || + level1Name is null) + { + return; + } + + // Find table — try with dbo default schema + var key = ("dbo", level1Name); + if (!tables.TryGetValue(key, out var builder)) + { + // Try all schemas + builder = tables.Values.FirstOrDefault(_ => _.Name.Equals(level1Name, StringComparison.OrdinalIgnoreCase)); + if (builder is null) + { + return; + } + } + + if (level2Type is not null && + level2Type.Equals("column", StringComparison.OrdinalIgnoreCase) && + level2Name is not null) + { + builder.ColumnComments[level2Name] = propValue; + } + else + { + builder.Comment = propValue; + } + } + + static string? GetLiteralValue(ScalarExpression? expression) => + expression switch + { + StringLiteral str => str.Value, + IntegerLiteral intLit => intLit.Value, + _ => null + }; + static Column BuildColumn(ColumnDefinition columnDef, int ordinal) { var columnName = columnDef.ColumnIdentifier.Value; @@ -147,7 +238,7 @@ static string FormatDataType(DataTypeReference? dataType) static bool IsNullable(ColumnDefinition columnDef) { - // Check for explicit NOT NULL or NULL constraint + // Check for explicit not null or null constraint foreach (var constraint in columnDef.Constraints) { if (constraint is NullableConstraintDefinition nullableConstraint) @@ -156,7 +247,7 @@ static bool IsNullable(ColumnDefinition columnDef) } } - // Check if column is part of PRIMARY KEY (implicitly NOT NULL) + // Check if column is part of primary key (implicitly not null) foreach (var constraint in columnDef.Constraints) { if (constraint is UniqueConstraintDefinition { IsPrimaryKey: true }) @@ -169,13 +260,4 @@ static bool IsNullable(ColumnDefinition columnDef) return true; } - sealed class TableBuilder(string schema, string name) - { - public string Schema { get; } = schema; - public string Name { get; } = name; - public List Columns { get; } = []; - public HashSet PrimaryKeys { get; } = new(StringComparer.OrdinalIgnoreCase); - - public Table Build() => new(Schema, Name, Columns, PrimaryKeys.Count > 0 ? PrimaryKeys : null); - } -} +} \ No newline at end of file diff --git a/src/SqlServerToMermaid/SqlParseException.cs b/src/SqlServerToMermaid/SqlParseException.cs new file mode 100644 index 0000000..8331a18 --- /dev/null +++ b/src/SqlServerToMermaid/SqlParseException.cs @@ -0,0 +1,15 @@ +namespace DbToMermaid; + +public class SqlParseException(IList errors) : + Exception +{ + public IList Errors { get; } = errors; + + public override string ToString() + { + var messages = string.Join(Environment.NewLine, Errors.Select(_ => $"Line {_.Line}: {_.Message}")); + return $"SQL parse errors:{Environment.NewLine}{messages}"; + } + + public override string Message => ToString(); +} \ No newline at end of file diff --git a/src/SqlServerToMermaid/TableBuilder.cs b/src/SqlServerToMermaid/TableBuilder.cs new file mode 100644 index 0000000..56b52be --- /dev/null +++ b/src/SqlServerToMermaid/TableBuilder.cs @@ -0,0 +1,19 @@ +class TableBuilder(string schema, string name) +{ + public string Schema { get; } = schema; + public string Name { get; } = name; + public List Columns { get; } = []; + public HashSet PrimaryKeys { get; } = new(StringComparer.OrdinalIgnoreCase); + public string? Comment { get; set; } + public Dictionary ColumnComments { get; } = new(StringComparer.OrdinalIgnoreCase); + + public Table Build() + { + var columns = Columns.Select(_ => + ColumnComments.TryGetValue(_.Name, out var comment) + ? _ with { Comment = comment } + : _ + ).ToList(); + return new(Schema, Name, columns, PrimaryKeys.Count > 0 ? PrimaryKeys : null, Comment); + } +} \ No newline at end of file diff --git a/src/SqlServerToMermaidTool.Tests/ErrorHandlingTests.SqlScript_InvalidSyntax_ReturnsParseError.verified.txt b/src/SqlServerToMermaidTool.Tests/ErrorHandlingTests.SqlScript_InvalidSyntax_ReturnsParseError.verified.txt index 8111adc..7d06913 100644 --- a/src/SqlServerToMermaidTool.Tests/ErrorHandlingTests.SqlScript_InvalidSyntax_ReturnsParseError.verified.txt +++ b/src/SqlServerToMermaidTool.Tests/ErrorHandlingTests.SqlScript_InvalidSyntax_ReturnsParseError.verified.txt @@ -3,8 +3,7 @@ ExitCode: 1, ShowHelp: false, Message: -Invalid SQL schema: SQL parse errors: -Line 1: Incorrect syntax near 'TABL'., +Line 1: Incorrect syntax near 'tabl'., StackTrace: at RenderCommand.ExecuteAsync(IConsole console) } \ No newline at end of file diff --git a/src/SqlServerToMermaidTool.Tests/ErrorHandlingTests.cs b/src/SqlServerToMermaidTool.Tests/ErrorHandlingTests.cs index cb89071..c7b2b1d 100644 --- a/src/SqlServerToMermaidTool.Tests/ErrorHandlingTests.cs +++ b/src/SqlServerToMermaidTool.Tests/ErrorHandlingTests.cs @@ -44,7 +44,7 @@ public async Task OutputFile_Locked_ReturnsLockedFileError() var command = new RenderCommand { - Input = "CREATE TABLE Test (Id INT PRIMARY KEY)", + Input = "create table Test (Id int primary key)", Output = outputPath }; @@ -58,7 +58,7 @@ public async Task SqlScript_InvalidSyntax_ReturnsParseError() var console = new FakeInMemoryConsole(); var command = new RenderCommand { - Input = "CREATE TABL Test (Id INT PRIMARY KEY)", // Missing 'E' in TABLE + Input = "create tabl Test (Id int primary key)", // Missing 'E' in table Output = outputPath }; @@ -71,7 +71,7 @@ public async Task OutputFile_DirectoryNotFound_ReturnsDirectoryError() var console = new FakeInMemoryConsole(); var command = new RenderCommand { - Input = "CREATE TABLE Test (Id INT PRIMARY KEY)", + Input = "create table Test (Id int primary key)", Output = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString(), "nonexistent", "output.md") }; @@ -86,7 +86,7 @@ public async Task OutputFile_InvalidExtension_ReturnsExtensionError() var console = new FakeInMemoryConsole(); var command = new RenderCommand { - Input = "CREATE TABLE Test (Id INT PRIMARY KEY)", + Input = "create table Test (Id int primary key)", Output = outputPath }; diff --git a/src/SqlServerToMermaidTool.Tests/InputResolverTests.cs b/src/SqlServerToMermaidTool.Tests/InputResolverTests.cs index 48b80a6..a193645 100644 --- a/src/SqlServerToMermaidTool.Tests/InputResolverTests.cs +++ b/src/SqlServerToMermaidTool.Tests/InputResolverTests.cs @@ -42,7 +42,7 @@ public async Task FilePath_ExistingFile() [Test] public async Task RawSql_CreateTable() { - var input = "CREATE TABLE Test (Id INT PRIMARY KEY)"; + var input = "create table Test (Id int primary key)"; var result = InputResolver.Resolve(input); await Assert.That(result).IsEqualTo(InputType.RawSql); } @@ -59,9 +59,9 @@ public async Task RawSql_NonExistentPath() public async Task RawSql_MultiLineScript() { var input = """ - CREATE TABLE Company ( - Id INT PRIMARY KEY, - Name NVARCHAR(100) NOT NULL + create table Company ( + Id int primary key, + Name nvarchar(100) not null ); """; var result = InputResolver.Resolve(input); diff --git a/src/SqlServerToMermaidTool.Tests/IntegrationTests.ConnectionString_ToMarkdown.verified.md b/src/SqlServerToMermaidTool.Tests/IntegrationTests.ConnectionString_ToMarkdown.verified.md index 6402e07..e0ba76e 100644 --- a/src/SqlServerToMermaidTool.Tests/IntegrationTests.ConnectionString_ToMarkdown.verified.md +++ b/src/SqlServerToMermaidTool.Tests/IntegrationTests.ConnectionString_ToMarkdown.verified.md @@ -1,13 +1,13 @@ ```mermaid erDiagram - Company { - int Id(pk) "not null" - nvarchar Name "not null" + Company["**Company**"] { + int Id pk + nvarchar Name } - Employee { - int Id(pk) "not null" - nvarchar FirstName "not null" - int CompanyId "not null" + Employee["**Employee**"] { + int Id pk + nvarchar FirstName + int CompanyId } Company ||--o{ Employee : "FK_Employee_Company" ``` diff --git a/src/SqlServerToMermaidTool.Tests/IntegrationTests.ConnectionString_ToMermaid.verified.mmd b/src/SqlServerToMermaidTool.Tests/IntegrationTests.ConnectionString_ToMermaid.verified.mmd index 8db8b7c..1472e92 100644 --- a/src/SqlServerToMermaidTool.Tests/IntegrationTests.ConnectionString_ToMermaid.verified.mmd +++ b/src/SqlServerToMermaidTool.Tests/IntegrationTests.ConnectionString_ToMermaid.verified.mmd @@ -1,5 +1,5 @@ erDiagram - Users { - int Id(pk) "not null" - nvarchar Name "not null" + Users["**Users**"] { + int Id pk + nvarchar Name } diff --git a/src/SqlServerToMermaidTool.Tests/IntegrationTests.RawSql_ToMarkdown.verified.md b/src/SqlServerToMermaidTool.Tests/IntegrationTests.RawSql_ToMarkdown.verified.md index 943b7bc..2e28f95 100644 --- a/src/SqlServerToMermaidTool.Tests/IntegrationTests.RawSql_ToMarkdown.verified.md +++ b/src/SqlServerToMermaidTool.Tests/IntegrationTests.RawSql_ToMarkdown.verified.md @@ -1,7 +1,7 @@ ```mermaid erDiagram - Orders { - int Id "not null" - decimal Total "not null" + Orders["**Orders**"] { + int Id + decimal Total } ``` diff --git a/src/SqlServerToMermaidTool.Tests/IntegrationTests.SqlFile_ToMarkdown.verified.md b/src/SqlServerToMermaidTool.Tests/IntegrationTests.SqlFile_ToMarkdown.verified.md index d92bb83..54723a5 100644 --- a/src/SqlServerToMermaidTool.Tests/IntegrationTests.SqlFile_ToMarkdown.verified.md +++ b/src/SqlServerToMermaidTool.Tests/IntegrationTests.SqlFile_ToMarkdown.verified.md @@ -1,8 +1,8 @@ ```mermaid erDiagram - Products { - int Id "not null" - nvarchar Name "not null" - decimal Price "not null" + Products["**Products**"] { + int Id + nvarchar Name + decimal Price } ``` diff --git a/src/SqlServerToMermaidTool.Tests/IntegrationTests.cs b/src/SqlServerToMermaidTool.Tests/IntegrationTests.cs index 07d0f16..24506f5 100644 --- a/src/SqlServerToMermaidTool.Tests/IntegrationTests.cs +++ b/src/SqlServerToMermaidTool.Tests/IntegrationTests.cs @@ -10,21 +10,21 @@ public async Task ConnectionString_ToMarkdown() { command.CommandText = """ - CREATE TABLE Company + create table Company ( - Id INT PRIMARY KEY, - Name NVARCHAR(200) NOT NULL + Id int primary key, + Name nvarchar(200) not null ); - CREATE TABLE Employee + create table Employee ( - Id INT PRIMARY KEY, - FirstName NVARCHAR(100) NOT NULL, - CompanyId INT NOT NULL, + Id int primary key, + FirstName nvarchar(100) not null, + CompanyId int not null, - CONSTRAINT FK_Employee_Company - FOREIGN KEY (CompanyId) - REFERENCES Company(Id) + constraint FK_Employee_Company + foreign key (CompanyId) + references Company(Id) ); """; await command.ExecuteNonQueryAsync(); @@ -52,10 +52,10 @@ public async Task ConnectionString_ToMermaid() { command.CommandText = """ - CREATE TABLE Users + create table Users ( - Id INT PRIMARY KEY, - Name NVARCHAR(100) NOT NULL + Id int primary key, + Name nvarchar(100) not null ); """; await command.ExecuteNonQueryAsync(); @@ -82,11 +82,11 @@ public async Task SqlFile_ToMarkdown() using var outputPath = new TempFile(extension: ".md"); await File.WriteAllTextAsync(sqlPath, """ - CREATE TABLE Products + create table Products ( - Id INT PRIMARY KEY, - Name NVARCHAR(200) NOT NULL, - Price DECIMAL(18,2) NOT NULL + Id int primary key, + Name nvarchar(200) not null, + Price decimal(18,2) not null ); """); @@ -110,7 +110,7 @@ public async Task RawSql_ToMarkdown() var console = new FakeInMemoryConsole(); var cmd = new RenderCommand { - Input = "CREATE TABLE Orders (Id INT PRIMARY KEY, Total DECIMAL(18,2) NOT NULL)", + Input = "create table Orders (Id int primary key, Total decimal(18,2) not null)", Output = outputPath }; @@ -127,7 +127,7 @@ public async Task CustomNewLine() var console = new FakeInMemoryConsole(); var cmd = new RenderCommand { - Input = "CREATE TABLE Test (Id INT PRIMARY KEY)", + Input = "create table Test (Id int primary key)", Output = outputPath, NewLine = "\\n" }; diff --git a/src/SqlServerToMermaidTool/InputResolver.cs b/src/SqlServerToMermaidTool/InputResolver.cs index 82af903..d821a02 100644 --- a/src/SqlServerToMermaidTool/InputResolver.cs +++ b/src/SqlServerToMermaidTool/InputResolver.cs @@ -31,6 +31,6 @@ public static InputType Resolve(string input) } static bool LooksLikeConnectionString(string input) => - connectionStringKeywords.Any(keyword => - input.Contains(keyword, StringComparison.OrdinalIgnoreCase)); + connectionStringKeywords.Any(_ => + input.Contains(_, StringComparison.OrdinalIgnoreCase)); } diff --git a/src/SqlServerToMermaidTool/RenderCommand.cs b/src/SqlServerToMermaidTool/RenderCommand.cs index 0ba7542..1f22f75 100644 --- a/src/SqlServerToMermaidTool/RenderCommand.cs +++ b/src/SqlServerToMermaidTool/RenderCommand.cs @@ -42,49 +42,51 @@ public async ValueTask ExecuteAsync(IConsole console) (InputType.FilePath, false) => RenderFileRaw(writer), (InputType.RawSql, true) => RenderScriptMarkdown(writer, Input), (InputType.RawSql, false) => RenderScriptRaw(writer, Input), - _ => throw new InvalidOperationException("Unexpected input/output combination") + _ => throw new("Unexpected input/output combination") }; await task; await console.Output.WriteLineAsync($"Generated: {fullPath}"); } - catch (SqlException ex) when (IsTimeoutError(ex)) + catch (SqlException exception) + when (IsTimeoutError(exception)) { - throw new CommandException($"Database operation timed out: {ex.Message}"); + throw new CommandException($"Database operation timed out: {exception.Message}"); } - catch (SqlException ex) + catch (SqlException exception) { - throw new CommandException($"Database connection failed: {ex.Message}"); + throw new CommandException($"Database connection failed: {exception.Message}"); } catch (DirectoryNotFoundException) { var directory = Path.GetDirectoryName(fullPath); throw new CommandException($"Output directory does not exist: {directory}"); } - catch (IOException ex) when (IsFileLocked(ex)) + catch (IOException exception) when (IsFileLocked(exception)) { throw new CommandException($"Output file is locked by another process: {fullPath}"); } - catch (IOException ex) + catch (IOException exception) { - throw new CommandException($"File I/O error: {ex.Message}"); + throw new CommandException($"File I/O error: {exception.Message}"); } catch (UnauthorizedAccessException) { throw new CommandException($"Permission denied writing to: {fullPath}"); } - catch (InvalidOperationException ex) when (ex.Message.StartsWith("SQL parse errors")) + catch (SqlParseException exception) { - throw new CommandException($"Invalid SQL schema:\n{ex.Message}"); + throw new CommandException(exception.Message); } } - static bool IsTimeoutError(SqlException ex) => - ex.Number == -2 || ex.Message.Contains("timeout", StringComparison.OrdinalIgnoreCase); + static bool IsTimeoutError(SqlException exception) => + exception.Number == -2 || + exception.Message.Contains("timeout", StringComparison.OrdinalIgnoreCase); - static bool IsFileLocked(IOException ex) => - ex.HResult == unchecked((int)0x80070020) || // ERROR_SHARING_VIOLATION - ex.HResult == unchecked((int)0x80070021); // ERROR_LOCK_VIOLATION + static bool IsFileLocked(IOException exception) => + // ERROR_SHARING_VIOLATION and ERROR_LOCK_VIOLATION + exception.HResult is unchecked((int)0x80070020) or unchecked((int)0x80070021); static bool ValidateAndGetOutputFormat(string path) {