diff --git a/.gitattributes b/.gitattributes index 738dfdd..4da7422 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,7 @@ *.snk binary *.verified.* text eol=lf working-tree-encoding=UTF-8 +*.verified.png binary .editorconfig text eol=lf working-tree-encoding=UTF-8 *.sln.DotSettings text eol=lf working-tree-encoding=UTF-8 diff --git a/readme.md b/readme.md index b31c12d..be9c28b 100644 --- a/readme.md +++ b/readme.md @@ -69,7 +69,7 @@ await Verify(connection) // include only tables and views .SchemaIncludes(DbObjects.Tables | DbObjects.Views); ``` -snippet source | anchor +snippet source | anchor Available values: @@ -107,10 +107,95 @@ await Verify(connection) _ => _ is TableViewBase || _.Name == "MyTrigger"); ``` -snippet source | anchor +snippet source | anchor +#### Diagram + +The tables of a schema can instead be verified as a [Mermaid ER diagram](https://mermaid.js.org/syntax/entityRelationshipDiagram.html), using [DbToMermaid](https://github.com/SimonCropp/DbToMermaid). Requires net10.0 or higher. + + + +```cs +await Verify(connection) + .SchemaAsDiagram(); +``` +snippet source | anchor + + +Result: [Tests.SchemaAsDiagram.verified.md](/src/Tests/Tests.SchemaAsDiagram.verified.md) + +The diagram can be verified as markdown, svg, png, or any combination. One file is verified for each format. + + + +```cs +await Verify(connection) + .SchemaAsDiagram( + DiagramFormat.Markdown | + DiagramFormat.Svg | + DiagramFormat.Png); +``` +snippet source | anchor + + +Result: + + * [Tests.SchemaAsDiagramFormats.verified.md](/src/Tests/Tests.SchemaAsDiagramFormats.verified.md) + * [Tests.SchemaAsDiagramFormats.verified.svg](/src/Tests/Tests.SchemaAsDiagramFormats.verified.svg) + * [Tests.SchemaAsDiagramFormats.verified.png](/src/Tests/Tests.SchemaAsDiagramFormats.verified.png) + +Tests.SchemaAsDiagramFormats.verified.png + +Available values: + + + +```cs +namespace VerifyTests.SqlServer; + +/// +/// The files produced when a schema is verified as a diagram. Can be combined. +/// +[Flags] +public enum DiagramFormat +{ + /// + /// A Mermaid ER diagram in a markdown code block. Uses the `md` extension. + /// + Markdown = 1, + + /// + /// The diagram rendered to an image. Uses the `svg` extension. + /// + Svg = 2, + + /// + /// The diagram rendered to an image. Uses the `png` extension. + /// + Png = 4, + + All = Markdown | Svg | Png +} +``` +snippet source | anchor + + +`SchemaIncludes` and `SchemaFilter` do not apply to a diagram. All tables are included. + +A rendered png may not be pixel identical across machines, so compare using [ssim](https://github.com/VerifyTests/Verify/blob/main/docs/comparer.md): + + + +```cs +VerifierSettings.UseSsimForPng(); +``` +snippet source | anchor + + +The svg and png are rendered by [Naiad](https://github.com/Papyrine/Naiad). Its build check requires a sponsorship, or exemption, property to be set in the consuming project. The build error lists the options. + ### Recording @@ -129,7 +214,7 @@ command.CommandText = "select Value from MyTable"; var value = await command.ExecuteScalarAsync(); await Verify(value!); ``` -snippet source | anchor +snippet source | anchor Will result in the following verified file: @@ -184,7 +269,7 @@ await Verify( sqlEntries = entries }); ``` -snippet source | anchor +snippet source | anchor @@ -212,7 +297,7 @@ var sqlErrorsViaType = entries .Select(_ => _.Data) .OfType(); ``` -snippet source | anchor +snippet source | anchor diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 1b677cc..e20115f 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ CS1591;CS0649;CS8632;NU1608;NU1109 - 12.2.0 + 12.3.0 preview 1.0.0 SqlServer, Verify @@ -13,5 +13,7 @@ true OpenSource 2027-08 + OpenSource + 2027-08 diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index cc1094e..6e937d9 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -12,6 +12,7 @@ + diff --git a/src/Tests/ModuleInit.cs b/src/Tests/ModuleInit.cs index a58af6f..84592a6 100644 --- a/src/Tests/ModuleInit.cs +++ b/src/Tests/ModuleInit.cs @@ -9,6 +9,14 @@ public static void Init() => #endregion [ModuleInitializer] - public static void InitOther() => + public static void InitOther() + { + #region UseSsimForPng + + VerifierSettings.UseSsimForPng(); + + #endregion + VerifierSettings.InitializePlugins(); + } } \ No newline at end of file diff --git a/src/Tests/Tests.SchemaAsDiagram.verified.md b/src/Tests/Tests.SchemaAsDiagram.verified.md new file mode 100644 index 0000000..9c968e3 --- /dev/null +++ b/src/Tests/Tests.SchemaAsDiagram.verified.md @@ -0,0 +1,78 @@ +```mermaid +erDiagram + TestSchema_SchemaTable["**TestSchema_SchemaTable**"] { + int Id pk + nvarchar(50) Name + } + ChildTable["**ChildTable**"] { + int Id pk + int ParentId + int Value + } + ColumnTypes["**ColumnTypes**"] { + int Id pk + nvarchar(100) Name + nvarchar(max)(nullable) Description + varchar(20)(nullable) Code + decimal(18,4) Amount + bit IsActive + datetime2 Created + timestamp RowVersion + money(nullable) Price + float(nullable) Ratio + smallint(nullable) SmallNum + tinyint(nullable) TinyNum + bigint(nullable) BigNum + uniqueidentifier(nullable) UniqueCode + text(nullable) Notes + varbinary(500)(nullable) BinaryData + varbinary(max)(nullable) MaxBinary + char(10)(nullable) FixedChar + binary(16)(nullable) FixedBinary + time(nullable) TimeOnly + date(nullable) DateOnly + ntext(nullable) NText + xml(nullable) Xml + } + CompositeKeyTable["**CompositeKeyTable**"] { + int Key1 pk + int Key2 pk + nvarchar(200)(nullable) Data + } + MultiIndexTable["**MultiIndexTable**"] { + int Id pk + nvarchar(100) Name + nvarchar(50) Category + int Status + } + MultiTriggerTable["**MultiTriggerTable**"] { + int Id pk + nvarchar(100)(nullable) Name + } + MyOtherTable["**MyOtherTable**"] { + int(nullable) Value + } + MyTable["**MyTable**"] { + int(nullable) Value + } + ParentTable["**ParentTable**"] { + int Id pk + nvarchar(100) Name + } + WithComputed["**WithComputed**"] { + int Id pk + nvarchar(50) FirstName + nvarchar(50) LastName + nvarchar(101) FullName "computed" + int Quantity + decimal(10,2) UnitPrice + decimal(21,2)(nullable) TotalPrice "computed" + } + WithDefaults["**WithDefaults**"] { + int Id pk + varchar(20) Status + datetime2 Created + int Score + } + ParentTable ||--o{ ChildTable : "FK_ChildTable_ParentTable" +``` \ No newline at end of file diff --git a/src/Tests/Tests.SchemaAsDiagramFormats.verified.md b/src/Tests/Tests.SchemaAsDiagramFormats.verified.md new file mode 100644 index 0000000..9c968e3 --- /dev/null +++ b/src/Tests/Tests.SchemaAsDiagramFormats.verified.md @@ -0,0 +1,78 @@ +```mermaid +erDiagram + TestSchema_SchemaTable["**TestSchema_SchemaTable**"] { + int Id pk + nvarchar(50) Name + } + ChildTable["**ChildTable**"] { + int Id pk + int ParentId + int Value + } + ColumnTypes["**ColumnTypes**"] { + int Id pk + nvarchar(100) Name + nvarchar(max)(nullable) Description + varchar(20)(nullable) Code + decimal(18,4) Amount + bit IsActive + datetime2 Created + timestamp RowVersion + money(nullable) Price + float(nullable) Ratio + smallint(nullable) SmallNum + tinyint(nullable) TinyNum + bigint(nullable) BigNum + uniqueidentifier(nullable) UniqueCode + text(nullable) Notes + varbinary(500)(nullable) BinaryData + varbinary(max)(nullable) MaxBinary + char(10)(nullable) FixedChar + binary(16)(nullable) FixedBinary + time(nullable) TimeOnly + date(nullable) DateOnly + ntext(nullable) NText + xml(nullable) Xml + } + CompositeKeyTable["**CompositeKeyTable**"] { + int Key1 pk + int Key2 pk + nvarchar(200)(nullable) Data + } + MultiIndexTable["**MultiIndexTable**"] { + int Id pk + nvarchar(100) Name + nvarchar(50) Category + int Status + } + MultiTriggerTable["**MultiTriggerTable**"] { + int Id pk + nvarchar(100)(nullable) Name + } + MyOtherTable["**MyOtherTable**"] { + int(nullable) Value + } + MyTable["**MyTable**"] { + int(nullable) Value + } + ParentTable["**ParentTable**"] { + int Id pk + nvarchar(100) Name + } + WithComputed["**WithComputed**"] { + int Id pk + nvarchar(50) FirstName + nvarchar(50) LastName + nvarchar(101) FullName "computed" + int Quantity + decimal(10,2) UnitPrice + decimal(21,2)(nullable) TotalPrice "computed" + } + WithDefaults["**WithDefaults**"] { + int Id pk + varchar(20) Status + datetime2 Created + int Score + } + ParentTable ||--o{ ChildTable : "FK_ChildTable_ParentTable" +``` \ No newline at end of file diff --git a/src/Tests/Tests.SchemaAsDiagramFormats.verified.png b/src/Tests/Tests.SchemaAsDiagramFormats.verified.png new file mode 100644 index 0000000..ff86532 Binary files /dev/null and b/src/Tests/Tests.SchemaAsDiagramFormats.verified.png differ diff --git a/src/Tests/Tests.SchemaAsDiagramFormats.verified.svg b/src/Tests/Tests.SchemaAsDiagramFormats.verified.svg new file mode 100644 index 0000000..487d537 --- /dev/null +++ b/src/Tests/Tests.SchemaAsDiagramFormats.verified.svg @@ -0,0 +1 @@ +FK_ChildTable_ParentTableTestSchema_SchemaTablePKint Idnvarchar(50) NameChildTablePKint Idint ParentIdint ValueColumnTypesPKint Idnvarchar(100) Namenvarchar(max)(nullable) Descriptionvarchar(20)(nullable) Codedecimal(18,4) Amountbit IsActivedatetime2 Createdtimestamp RowVersionmoney(nullable) Pricefloat(nullable) Ratiosmallint(nullable) SmallNumtinyint(nullable) TinyNumbigint(nullable) BigNumuniqueidentifier(nullable) UniqueCodetext(nullable) Notesvarbinary(500)(nullable) BinaryDatavarbinary(max)(nullable) MaxBinarychar(10)(nullable) FixedCharbinary(16)(nullable) FixedBinarytime(nullable) TimeOnlydate(nullable) DateOnlyntext(nullable) NTextxml(nullable) XmlCompositeKeyTablePKint Key1PKint Key2nvarchar(200)(nullable) DataMultiIndexTablePKint Idnvarchar(100) Namenvarchar(50) Categoryint StatusMultiTriggerTablePKint Idnvarchar(100)(nullable) NameMyOtherTableint(nullable) ValueMyTableint(nullable) ValueParentTablePKint Idnvarchar(100) NameWithComputedPKint Idnvarchar(50) FirstNamenvarchar(50) LastNamenvarchar(101) FullNamecomputedint Quantitydecimal(10,2) UnitPricedecimal(21,2)(nullable) TotalPricecomputedWithDefaultsPKint Idvarchar(20) Statusdatetime2 Createdint Score \ No newline at end of file diff --git a/src/Tests/Tests.SchemaAsDiagramSvgOnly.verified.svg b/src/Tests/Tests.SchemaAsDiagramSvgOnly.verified.svg new file mode 100644 index 0000000..487d537 --- /dev/null +++ b/src/Tests/Tests.SchemaAsDiagramSvgOnly.verified.svg @@ -0,0 +1 @@ +FK_ChildTable_ParentTableTestSchema_SchemaTablePKint Idnvarchar(50) NameChildTablePKint Idint ParentIdint ValueColumnTypesPKint Idnvarchar(100) Namenvarchar(max)(nullable) Descriptionvarchar(20)(nullable) Codedecimal(18,4) Amountbit IsActivedatetime2 Createdtimestamp RowVersionmoney(nullable) Pricefloat(nullable) Ratiosmallint(nullable) SmallNumtinyint(nullable) TinyNumbigint(nullable) BigNumuniqueidentifier(nullable) UniqueCodetext(nullable) Notesvarbinary(500)(nullable) BinaryDatavarbinary(max)(nullable) MaxBinarychar(10)(nullable) FixedCharbinary(16)(nullable) FixedBinarytime(nullable) TimeOnlydate(nullable) DateOnlyntext(nullable) NTextxml(nullable) XmlCompositeKeyTablePKint Key1PKint Key2nvarchar(200)(nullable) DataMultiIndexTablePKint Idnvarchar(100) Namenvarchar(50) Categoryint StatusMultiTriggerTablePKint Idnvarchar(100)(nullable) NameMyOtherTableint(nullable) ValueMyTableint(nullable) ValueParentTablePKint Idnvarchar(100) NameWithComputedPKint Idnvarchar(50) FirstNamenvarchar(50) LastNamenvarchar(101) FullNamecomputedint Quantitydecimal(10,2) UnitPricedecimal(21,2)(nullable) TotalPricecomputedWithDefaultsPKint Idvarchar(20) Statusdatetime2 Createdint Score \ No newline at end of file diff --git a/src/Tests/Tests.cs b/src/Tests/Tests.cs index d036596..621fc03 100644 --- a/src/Tests/Tests.cs +++ b/src/Tests/Tests.cs @@ -333,6 +333,54 @@ await Verify(connection) #endregion } + [Test] + public async Task SchemaAsDiagram() + { + await using var database = await sqlInstance.Build(); + var connection = database.Connection; + + #region SqlServerSchemaAsDiagram + + await Verify(connection) + .SchemaAsDiagram(); + + #endregion + } + + [Test] + public async Task SchemaAsDiagramFormats() + { + await using var database = await sqlInstance.Build(); + var connection = database.Connection; + + #region SqlServerSchemaAsDiagramFormats + + await Verify(connection) + .SchemaAsDiagram( + DiagramFormat.Markdown | + DiagramFormat.Svg | + DiagramFormat.Png); + + #endregion + } + + [Test] + public async Task SchemaAsDiagramSvgOnly() + { + await using var database = await sqlInstance.Build(); + var connection = database.Connection; + await Verify(connection) + .SchemaAsDiagram(DiagramFormat.Svg); + } + + [Test] + public async Task SchemaAsDiagramNoFormat() + { + var settings = new VerifySettings(); + await Assert.That(() => settings.SchemaAsDiagram(0)) + .Throws(); + } + [Test] public async Task SchemaInDynamic() { diff --git a/src/Verify.SqlServer/SchemaValidation/DiagramBuilder.cs b/src/Verify.SqlServer/SchemaValidation/DiagramBuilder.cs new file mode 100644 index 0000000..18b8bf5 --- /dev/null +++ b/src/Verify.SqlServer/SchemaValidation/DiagramBuilder.cs @@ -0,0 +1,31 @@ +#if NET10_0_OR_GREATER +using DbToMermaid; + +static class DiagramBuilder +{ + public static async Task Build(SqlConnection connection, DiagramFormat formats) + { + var targets = new List(); + + if (formats.HasFlag(DiagramFormat.Markdown)) + { + var markdown = await SqlServerToMermaid.RenderMarkdown(connection); + targets.Add(new("md", markdown)); + } + + if (formats.HasFlag(DiagramFormat.Svg)) + { + var svg = await SqlServerToMermaid.RenderSvg(connection); + targets.Add(new("svg", svg)); + } + + if (formats.HasFlag(DiagramFormat.Png)) + { + var png = await SqlServerToMermaid.RenderPng(connection); + targets.Add(new("png", new MemoryStream(png))); + } + + return new(null, targets); + } +} +#endif diff --git a/src/Verify.SqlServer/SchemaValidation/DiagramFormat.cs b/src/Verify.SqlServer/SchemaValidation/DiagramFormat.cs new file mode 100644 index 0000000..567463d --- /dev/null +++ b/src/Verify.SqlServer/SchemaValidation/DiagramFormat.cs @@ -0,0 +1,25 @@ +namespace VerifyTests.SqlServer; + +/// +/// The files produced when a schema is verified as a diagram. Can be combined. +/// +[Flags] +public enum DiagramFormat +{ + /// + /// A Mermaid ER diagram in a markdown code block. Uses the `md` extension. + /// + Markdown = 1, + + /// + /// The diagram rendered to an image. Uses the `svg` extension. + /// + Svg = 2, + + /// + /// The diagram rendered to an image. Uses the `png` extension. + /// + Png = 4, + + All = Markdown | Svg | Png +} diff --git a/src/Verify.SqlServer/SchemaValidation/SchemaSettings.cs b/src/Verify.SqlServer/SchemaValidation/SchemaSettings.cs index 7ffe213..d4ccc4c 100644 --- a/src/Verify.SqlServer/SchemaValidation/SchemaSettings.cs +++ b/src/Verify.SqlServer/SchemaValidation/SchemaSettings.cs @@ -3,5 +3,9 @@ class SchemaSettings public DbObjects Includes { get; set; } = DbObjects.All; public Format Format { get; set; } = Format.Md; public bool IsMd => Format == Format.Md; +#if NET10_0_OR_GREATER + // when set, the schema is verified as a diagram instead of as scripts + public DiagramFormat? Diagram { get; set; } +#endif public Func IncludeItem { get; set; } = _ => true; } \ No newline at end of file diff --git a/src/Verify.SqlServer/SchemaValidation/VerifySettingsExtensions.cs b/src/Verify.SqlServer/SchemaValidation/VerifySettingsExtensions.cs index 038bb59..7ac3030 100644 --- a/src/Verify.SqlServer/SchemaValidation/VerifySettingsExtensions.cs +++ b/src/Verify.SqlServer/SchemaValidation/VerifySettingsExtensions.cs @@ -54,6 +54,35 @@ public static void SchemaAsSql( GetOrAddSettings(settings) .Format = Format.Sql; +#if NET10_0_OR_GREATER + /// + /// Verify the schema as a Mermaid ER diagram of the tables, instead of as scripts. + /// + /// The settings to apply to. + /// The files to produce. Can be combined, with one file verified per format. + public static SettingsTask SchemaAsDiagram( + this SettingsTask settings, + DiagramFormat formats = DiagramFormat.Markdown) + { + settings.CurrentSettings.SchemaAsDiagram(formats); + return settings; + } + + /// + public static void SchemaAsDiagram( + this VerifySettings settings, + DiagramFormat formats = DiagramFormat.Markdown) + { + if ((formats & DiagramFormat.All) == 0) + { + throw new ArgumentException("At least one format is required.", nameof(formats)); + } + + GetOrAddSettings(settings) + .Diagram = formats; + } +#endif + static SchemaSettings GetOrAddSettings(VerifySettings settings) { var context = settings.Context; diff --git a/src/Verify.SqlServer/Verify.SqlServer.csproj b/src/Verify.SqlServer/Verify.SqlServer.csproj index f903682..58ea391 100644 --- a/src/Verify.SqlServer/Verify.SqlServer.csproj +++ b/src/Verify.SqlServer/Verify.SqlServer.csproj @@ -12,5 +12,7 @@ + + diff --git a/src/Verify.SqlServer/VerifySqlServer.cs b/src/Verify.SqlServer/VerifySqlServer.cs index 55f037f..23acbf8 100644 --- a/src/Verify.SqlServer/VerifySqlServer.cs +++ b/src/Verify.SqlServer/VerifySqlServer.cs @@ -44,13 +44,19 @@ public static void Initialize(bool recordCommands) } } - static ConversionResult ToSql(SqlConnection connection, IReadOnlyDictionary context) + static Task ToSql(SqlConnection connection, IReadOnlyDictionary context) { var settings = context.GetSchemaSettings(); +#if NET10_0_OR_GREATER + if (settings.Diagram is { } formats) + { + return DiagramBuilder.Build(connection, formats); + } +#endif var builder = new SqlScriptBuilder(settings); var content = builder.BuildContent(connection); var extension = GetExtension(settings); - return new(null, extension, content); + return Task.FromResult(new ConversionResult(null, extension, content)); } static string GetExtension(SchemaSettings settings)