diff --git a/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/Contracts/SaveResultsRequest.cs b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/Contracts/SaveResultsRequest.cs index 467003fc..cb286ea8 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/Contracts/SaveResultsRequest.cs +++ b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/Contracts/SaveResultsRequest.cs @@ -84,7 +84,7 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution.Contracts public string Delimiter { get; set; } /// - /// either CR, CRLF or LF to seperate rows in CSV + /// either CR, CRLF or LF to separate rows in CSV /// public string LineSeperator { get; set; } @@ -123,6 +123,28 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution.Contracts //TODO: define config for save as JSON } + /// + /// Parameters for saving results as a Markdown table + /// + public class SaveResultsAsMarkdownRequestParams : SaveResultsRequestParams + { + /// + /// Encoding of the CSV file + /// + public string Encoding { get; set; } + + /// + /// Whether to include column names as header for the table. + /// + public bool IncludeHeaders { get; set; } + + /// + /// Character sequence to separate a each row in the table. Should be either CR, CRLF, or + /// LF. If not provided, defaults to the system default line ending sequence. + /// + public string? LineSeparator { get; set; } + } + /// /// Parameters to save results as XML /// @@ -179,6 +201,16 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution.Contracts RequestType Type = RequestType.Create("query/saveJson"); } + + /// + /// Request type to save results as a Markdown table + /// + public class SaveResultsAsMarkdownRequest + { + public static readonly + RequestType Type = + RequestType.Create("query/saveMarkdown"); + } /// /// Request type to save results as XML diff --git a/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsCsvFileStreamWriter.cs b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsCsvFileStreamWriter.cs index 7e302392..cd3b60f9 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsCsvFileStreamWriter.cs +++ b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsCsvFileStreamWriter.cs @@ -61,18 +61,7 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution.DataStorage } textIdentifierString = textIdentifier.ToString(); - Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); - try - { - encoding = int.TryParse(requestParams.Encoding, out int codePage) - ? Encoding.GetEncoding(codePage) - : Encoding.GetEncoding(requestParams.Encoding); - } - catch - { - // Fallback encoding when specified codepage is invalid - encoding = Encoding.GetEncoding("utf-8"); - } + encoding = ParseEncoding(requestParams.Encoding, Encoding.UTF8); // Output the header if the user requested it if (requestParams.IncludeHeaders) diff --git a/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsMarkdownFileStreamFactory.cs b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsMarkdownFileStreamFactory.cs new file mode 100644 index 00000000..961dcf1a --- /dev/null +++ b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsMarkdownFileStreamFactory.cs @@ -0,0 +1,68 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Collections.Generic; +using System.IO; +using Microsoft.SqlTools.ServiceLayer.QueryExecution.Contracts; +using Microsoft.SqlTools.ServiceLayer.SqlContext; +using Microsoft.SqlTools.ServiceLayer.Utility; + +namespace Microsoft.SqlTools.ServiceLayer.QueryExecution.DataStorage +{ + public class SaveAsMarkdownFileStreamFactory : IFileStreamFactory + { + private readonly SaveResultsAsMarkdownRequestParams _saveRequestParams; + + /// + /// Constructs and initializes a new instance of . + /// + /// Parameters for the save as request + public SaveAsMarkdownFileStreamFactory(SaveResultsAsMarkdownRequestParams requestParams) + { + this._saveRequestParams = requestParams; + } + + /// + public QueryExecutionSettings QueryExecutionSettings { get; set; } + + /// + /// Throw at all times. + [Obsolete("Not implemented for export factories.")] + public string CreateFile() + { + throw new InvalidOperationException("CreateFile not implemented for export factories"); + } + + /// + /// + /// Returns an instance of the . + /// + public IFileStreamReader GetReader(string fileName) + { + return new ServiceBufferFileStreamReader( + new FileStream(fileName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite), + this.QueryExecutionSettings); + } + + /// + /// + /// Returns an instance of the . + /// + public IFileStreamWriter GetWriter(string fileName, IReadOnlyList columns) + { + return new SaveAsMarkdownFileStreamWriter( + new FileStream(fileName, FileMode.Create, FileAccess.ReadWrite, FileShare.ReadWrite), + this._saveRequestParams, + columns); + } + + /// + public void DisposeFile(string fileName) + { + FileUtilities.SafeFileDelete(fileName); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsMarkdownFileStreamWriter.cs b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsMarkdownFileStreamWriter.cs new file mode 100644 index 00000000..3581d47c --- /dev/null +++ b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsMarkdownFileStreamWriter.cs @@ -0,0 +1,101 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Web; +using Microsoft.SqlTools.ServiceLayer.QueryExecution.Contracts; + +namespace Microsoft.SqlTools.ServiceLayer.QueryExecution.DataStorage +{ + /// + /// Writer for exporting results to a Markdown table. + /// + public class SaveAsMarkdownFileStreamWriter : SaveAsStreamWriter + { + private const string Delimiter = "|"; + private static readonly Regex NewlineRegex = new Regex(@"(\r\n|\n|\r)", RegexOptions.Compiled); + + private readonly Encoding _encoding; + private readonly string _lineSeparator; + + public SaveAsMarkdownFileStreamWriter( + Stream stream, + SaveResultsAsMarkdownRequestParams requestParams, + IReadOnlyList columns) + : base(stream, requestParams, columns) + { + // Parse the request params + this._lineSeparator = string.IsNullOrEmpty(requestParams.LineSeparator) + ? Environment.NewLine + : requestParams.LineSeparator; + this._encoding = ParseEncoding(requestParams.Encoding, Encoding.UTF8); + + // Output the header if requested + if (requestParams.IncludeHeaders) + { + // Write the column header + IEnumerable selectedColumnNames = columns.Skip(this.ColumnStartIndex) + .Take(this.ColumnCount) + .Select(c => EncodeMarkdownField(c.ColumnName)); + string headerLine = string.Join(Delimiter, selectedColumnNames); + + this.WriteLine($"{Delimiter}{headerLine}{Delimiter}"); + + // Write the separator row + var separatorBuilder = new StringBuilder(Delimiter); + for (int i = 0; i < this.ColumnCount; i++) + { + separatorBuilder.Append($"---{Delimiter}"); + } + + this.WriteLine(separatorBuilder.ToString()); + } + } + + /// + public override void WriteRow(IList row, IReadOnlyList columns) + { + IEnumerable selectedCells = row.Skip(this.ColumnStartIndex) + .Take(this.ColumnCount) + .Select(c => EncodeMarkdownField(c.DisplayValue)); + string rowLine = string.Join(Delimiter, selectedCells); + + this.WriteLine($"{Delimiter}{rowLine}{Delimiter}"); + } + + internal static string EncodeMarkdownField(string? field) + { + // Special case for nulls + if (field == null) + { + return "NULL"; + } + + // Escape HTML entities, since Markdown supports inline HTML + field = HttpUtility.HtmlEncode(field); + + // Escape pipe delimiters + field = field.Replace(@"|", @"\|"); + + // @TODO: Allow option to encode multiple whitespace characters as   + + // Replace newlines with br tags, since cell values must be single line + field = NewlineRegex.Replace(field, @"
"); + + return field; + } + + private void WriteLine(string line) + { + byte[] bytes = this._encoding.GetBytes(line + this._lineSeparator); + this.FileStream.Write(bytes); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsWriterBase.cs b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsWriterBase.cs index 9fa71d7d..a93f1577 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsWriterBase.cs +++ b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsWriterBase.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Text; using Microsoft.SqlTools.ServiceLayer.QueryExecution.Contracts; using Microsoft.SqlTools.Utility; @@ -106,6 +107,37 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution.DataStorage FileStream.Flush(); } + /// + /// Attempts to parse the provided and return an encoding that + /// matches the encoding name or codepage number. + /// + /// Encoding name or codepage number to parse. + /// + /// Encoding to return if no encoding of provided name/codepage number exists. + /// + /// + /// Desired encoding object or the if the desired + /// encoding could not be found. + /// + protected static Encoding ParseEncoding(string encoding, Encoding fallbackEncoding) + { + // If the encoding is a number, we try to look up a codepage encoding using the + // parsed number as a codepage. If it is not a number, attempt to look up an + // encoding with the provided encoding name. If getting the encoding fails in + // either case, we will return the fallback encoding. + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + try + { + return int.TryParse(encoding, out int codePage) + ? Encoding.GetEncoding(codePage) + : Encoding.GetEncoding(encoding); + } + catch + { + return fallbackEncoding; + } + } + #region IDisposable Implementation private bool disposed; diff --git a/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/QueryExecutionService.cs b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/QueryExecutionService.cs index ac0d4d91..84c495a9 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/QueryExecutionService.cs +++ b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/QueryExecutionService.cs @@ -98,6 +98,12 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution ///
internal IFileStreamFactory JsonFileFactory { get; set; } + /// + /// File factory to be used to create Markdown files from result sets. + /// + /// Internal to allow overriding in unit testing. + internal IFileStreamFactory? MarkdownFileFactory { get; set; } + /// /// File factory to be used to create XML files from result sets. Set to internal in order /// to allow overriding in unit testing @@ -174,6 +180,7 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution serviceHost.SetRequestHandler(SaveResultsAsCsvRequest.Type, HandleSaveResultsAsCsvRequest); serviceHost.SetRequestHandler(SaveResultsAsExcelRequest.Type, HandleSaveResultsAsExcelRequest); serviceHost.SetRequestHandler(SaveResultsAsJsonRequest.Type, HandleSaveResultsAsJsonRequest); + serviceHost.SetRequestHandler(SaveResultsAsMarkdownRequest.Type, this.HandleSaveResultsAsMarkdownRequest); serviceHost.SetRequestHandler(SaveResultsAsXmlRequest.Type, HandleSaveResultsAsXmlRequest); serviceHost.SetRequestHandler(QueryExecutionPlanRequest.Type, HandleExecutionPlanRequest); serviceHost.SetRequestHandler(SimpleExecuteRequest.Type, HandleSimpleExecuteRequest); @@ -518,6 +525,25 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution await SaveResultsHelper(saveParams, requestContext, jsonFactory); } + /// + /// Processes a request to save a result set to a file in Markdown format. + /// + /// Parameters for the request + /// Context of the request + internal async Task HandleSaveResultsAsMarkdownRequest( + SaveResultsAsMarkdownRequestParams saveParams, + RequestContext requestContext) + { + // Use the default markdown file factory if we haven't overridden it + IFileStreamFactory markdownFactory = this.MarkdownFileFactory ?? + new SaveAsMarkdownFileStreamFactory(saveParams) + { + QueryExecutionSettings = this.Settings.QueryExecutionSettings, + }; + + await this.SaveResultsHelper(saveParams, requestContext, markdownFactory); + } + /// /// Process request to save a resultSet to a file in XML format /// diff --git a/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/SerializationService.cs b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/SerializationService.cs index c3b7a30a..56390cee 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/SerializationService.cs +++ b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/SerializationService.cs @@ -243,6 +243,9 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution SaveRequestParams = CreateCsvRequestParams() }; break; + case "markdown": + factory = new SaveAsMarkdownFileStreamFactory(CreateMarkdownRequestParams()); + break; case "xml": factory = new SaveAsXmlFileStreamFactory() { @@ -304,6 +307,18 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution MaxCharsToStore = this.requestParams.MaxCharsToStore }; } + + private SaveResultsAsMarkdownRequestParams CreateMarkdownRequestParams() => + new SaveResultsAsMarkdownRequestParams + { + FilePath = this.requestParams.FilePath, + BatchIndex = 0, + ResultSetIndex = 0, + IncludeHeaders = this.requestParams.IncludeHeaders, + LineSeparator = this.requestParams.LineSeparator, + Encoding = this.requestParams.Encoding, + }; + private SaveResultsAsXmlRequestParams CreateXmlRequestParams() { return new SaveResultsAsXmlRequestParams diff --git a/test/Microsoft.SqlTools.ServiceLayer.UnitTests/QueryExecution/DataStorage/SaveAsMarkdownFileStreamWriterTests.cs b/test/Microsoft.SqlTools.ServiceLayer.UnitTests/QueryExecution/DataStorage/SaveAsMarkdownFileStreamWriterTests.cs new file mode 100644 index 00000000..2f8a3882 --- /dev/null +++ b/test/Microsoft.SqlTools.ServiceLayer.UnitTests/QueryExecution/DataStorage/SaveAsMarkdownFileStreamWriterTests.cs @@ -0,0 +1,376 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.SqlTools.ServiceLayer.QueryExecution.Contracts; +using Microsoft.SqlTools.ServiceLayer.QueryExecution.DataStorage; +using Microsoft.SqlTools.ServiceLayer.UnitTests.Utility; +using NUnit.Framework; + +namespace Microsoft.SqlTools.ServiceLayer.UnitTests.QueryExecution.DataStorage +{ + public class SaveAsMarkdownFileStreamWriterTests + { + // Regex: Matches '|' not preceded by a '\' + private static readonly Regex UnescapedPipe = new Regex(@"(? _ = new SaveAsMarkdownFileStreamWriter( + null, + new SaveResultsAsMarkdownRequestParams(), + Array.Empty() + ); + + // Assert + Assert.That(action, Throws.ArgumentNullException); + } + + [Test] + public void Constructor_NullColumns() + { + // Act + TestDelegate action = () => _ = new SaveAsMarkdownFileStreamWriter( + Stream.Null, + new SaveResultsAsMarkdownRequestParams(), + null + ); + + // Assert + Assert.That(action, Throws.ArgumentNullException); + } + + [Test] + public void Constructor_WithoutSelectionWithHeader_WritesHeaderWithAllColumns() + { + // Setup: + // ... Create a request params that has no selection made, headers should be printed + // ... Create a set of columns + // --- Create a memory location to store the output + var requestParams = new SaveResultsAsMarkdownRequestParams { IncludeHeaders = true }; + var (columns, _) = GetTestValues(2); + byte[] output = new byte[8192]; + using var outputStream = new MemoryStream(output); + + // If: I construct a Markdown file writer + using var writer = new SaveAsMarkdownFileStreamWriter(outputStream, requestParams, columns); + + // Then: + // ... It should have written a line + string[] lines = ParseWriterOutput(output, Environment.NewLine); + Assert.That(lines.Length, Is.EqualTo(2), "Expected two lines of output"); + + // ... It should have written a header line like |col1|col2| + // ... It should have written a separator line like |---|---| + ValidateLine(lines[0], columns.Select(c => c.ColumnName)); + ValidateLine(lines[1], Enumerable.Repeat("---", columns.Length)); + } + + [Test] + public void Constructor_WithSelectionWithHeader_WritesHeaderWithSelectedColumns() + { + // Setup: + // ... Create a request params that has no selection made, headers should be printed + // ... Create a set of columns + // --- Create a memory location to store the output + var requestParams = new SaveResultsAsMarkdownRequestParams + { + IncludeHeaders = true, + ColumnStartIndex = 1, + ColumnEndIndex = 2, + RowStartIndex = 0, // Including b/c it is required to be a "save selection" + RowEndIndex = 10, + }; + var (columns, _) = GetTestValues(4); + byte[] output = new byte[8192]; + using var outputStream = new MemoryStream(output); + + // If: I construct a Markdown file writer + using var writer = new SaveAsMarkdownFileStreamWriter(outputStream, requestParams, columns); + + // Then: + // ... It should have written a line + string[] lines = ParseWriterOutput(output, Environment.NewLine); + Assert.That(lines.Length, Is.EqualTo(2), "Expected two lines of output"); + + // ... It should have written a header line like |col1|col2| + // ... It should have written a separator line like |---|---| + ValidateLine(lines[0], columns.Skip(1).Take(2).Select(c => c.ColumnName)); + ValidateLine(lines[1], Enumerable.Repeat("---", 2)); + } + + [Test] + public void Constructor_WithoutSelectionWithoutHeader_DoesNotWriteHeader() + { + // Setup: + // ... Create a request params that has no selection made, headers should not be printed + // ... Create a set of columns + // --- Create a memory location to store the output + var requestParams = new SaveResultsAsMarkdownRequestParams { IncludeHeaders = false }; + var (columns, _) = GetTestValues(2); + byte[] output = new byte[8192]; + using var outputStream = new MemoryStream(output); + + // If: I construct a Markdown file writer + using var writer = new SaveAsMarkdownFileStreamWriter(outputStream, requestParams, columns); + + // Then: + // ... It not have written anything + string[] lines = ParseWriterOutput(output, Environment.NewLine); + Assert.That(lines, Is.Empty); + } + + [TestCase("Something\rElse")] // Contains carriage return + [TestCase("Something\nElse")] // Contains line feed + [TestCase("Something\r\nElse")] // Contains carriage return + public void EncodeMarkdownField_ContainsNewlineCharacters_ShouldConvertToBr(string field) + { + // If: I Markdown encode a field that has a newline + string output = SaveAsMarkdownFileStreamWriter.EncodeMarkdownField(field); + + // Then: It should replace it the newline character(s) with a
+ const string expected = "Something
Else"; + Assert.That(output, Is.EqualTo(expected)); + } + + [Test] + public void EncodeMarkdownField_ContainsDelimiter_ShouldBeEscaped() + { + // If: I Markdown encode a field that has a pipe in it + const string input = "|Something|Else|"; + string output = SaveAsMarkdownFileStreamWriter.EncodeMarkdownField(input); + + // Then: It should escape the pipe character + const string expected = @"\|Something\|Else\|"; + Assert.AreEqual(expected, output); + } + + // @TODO: Convert excess whitespace to   on user choice + // [TestCase("\tSomething")] // Starts with tab + // [TestCase("Something\t")] // Ends with tab + // [TestCase(" Something")] // Starts with space + // [TestCase("Something ")] // Ends with space + // [TestCase(" Something ")] // Starts and ends with space + // [TestCase("Something else")] // Contains multiple consecutive spaces + // public void EncodeMarkdownField_WhitespaceAtFrontOrBack_ShouldBeWrapped(string field) + // { + // // Setup: Create MarkdownFileStreamWriter that specifies the text identifier and field separator + // var writer = GetWriterForEncodingTests(null, null, null); + // + // // If: I Markdown encode a field that has forbidden characters in it + // string output = writer.EncodeMarkdownField(field); + // + // // Then: It should wrap it in quotes + // Assert.True(Regex.IsMatch(output, "^\".*\"$", RegexOptions.Singleline)); + // } + + [Test] + public void EncodeMarkdownField_ContainsHtmlEntityCharacters_ShouldConvertToHtmlEntities() + { + // If: I Markdown encode a field that has html entity characters in it + const string input = "<<>>&®±ßüÁ"; + string output = SaveAsMarkdownFileStreamWriter.EncodeMarkdownField(input); + + // Then: The entity characters should be HTML encoded + const string expected = "<<>>&®±ßüÁ"; + Assert.That(output, Is.EqualTo(expected)); + } + + [Test] + public void EncodeMarkdownField_Null() + { + // If: I Markdown encode a null + string output = SaveAsMarkdownFileStreamWriter.EncodeMarkdownField(null); + + // Then: there should be a string version of null returned + Assert.That(output, Is.EqualTo("NULL")); + } + + [Test] + public void WriteRow_WithoutColumnSelection() + { + // Setup: + // ... Create a request params that has no selection made or header enabled + // ... Create a set of data to write + // ... Create a memory location to store the data + var requestParams = new SaveResultsAsMarkdownRequestParams(); + var (columns, data) = GetTestValues(2); + byte[] output = new byte[8192]; + using var outputStream = new MemoryStream(output); + + // If: I write a row + using (var writer = new SaveAsMarkdownFileStreamWriter(outputStream, requestParams, columns)) + { + writer.WriteRow(data, columns); + } + + // Then: It should write one line with the two cells + string[] lines = ParseWriterOutput(output, Environment.NewLine); + Assert.That(lines.Length, Is.EqualTo(1), "Expected one line of output"); + + ValidateLine(lines[0], data.Select(c => c.DisplayValue)); + } + + [Test] + public void WriteRow_WithColumnSelection() + { + // Setup: + // ... Create a request params that selects n-1 columns from the front and back + // ... Create a set of data to write + // ... Create a memory location to store the data + var requestParams = new SaveResultsAsMarkdownRequestParams + { + ColumnStartIndex = 1, + ColumnEndIndex = 2, + RowStartIndex = 0, // Including b/c it is required to be a "save selection" + RowEndIndex = 10 + }; + var (columns, data) = GetTestValues(4); + byte[] output = new byte[8192]; + using var outputStream = new MemoryStream(output); + + // If: I write a row + using (var writer = new SaveAsMarkdownFileStreamWriter(outputStream, requestParams, columns)) + { + writer.WriteRow(data, columns); + } + + // Then: + // ... It should have written one line with the two cells written + var lines = ParseWriterOutput(output, Environment.NewLine); + Assert.That(lines.Length, Is.EqualTo(1), "Expected one line of output"); + + ValidateLine(lines[0], data.Skip(1).Take(2).Select(c => c.DisplayValue)); + } + + [Test] + public void WriteRow_EncodingTest() + { + // Setup: + // ... Create default request params + // ... Create a set of data to write that contains characters that should be encoded + // ... Create a memory location to store the data + // @TODO: Add case to test string for non-breaking spaces + var requestParams = new SaveResultsAsMarkdownRequestParams(); + var columns = new[] { new DbColumnWrapper(new TestDbColumn("column")) }; + var data = new[] { new DbCellValue { DisplayValue = "|Something|\n|<<>>&|" } }; + byte[] output = new byte[8192]; + using var outputStream = new MemoryStream(output); + + // If: I write a row + using (var writer = new SaveAsMarkdownFileStreamWriter(outputStream, requestParams, columns)) + { + writer.WriteRow(data, columns); + } + + // Then: + // ... It should have written one line with the data properly encoded + string[] lines = ParseWriterOutput(output, Environment.NewLine); + Assert.That(lines.Length, Is.EqualTo(1), "Expected one line of output"); + + ValidateLine(lines[0], new[] { "\\|Something\\|
\\|<<>>&\\|" }); + } + + [Test] + public void WriteRow_CustomLineSeparator() + { + // Setup: + // ... Create a request params that has custom line separator + // ... Create a set of data to write + // ... Create a memory location to store the data + var requestParams = new SaveResultsAsMarkdownRequestParams + { + LineSeparator = "$$", + IncludeHeaders = true, + }; + var (columns, data) = GetTestValues(2); + byte[] output = new byte[8192]; + using var outputStream = new MemoryStream(output); + + // If: I set write a row + using (var writer = new SaveAsMarkdownFileStreamWriter(outputStream, requestParams, columns)) + { + writer.WriteRow(data, columns); + } + + // Then: + // ... The lines should be split by the custom line separator + var lines = ParseWriterOutput(output, "$$"); + Assert.That(lines.Length, Is.EqualTo(3), "Expected three lines of output"); + + // Note: Header output has been tested in constructor tests + } + + [Test] + public void WriteRow_CustomEncoding() + { + // Setup: + // ... Create a request params that uses a custom encoding + // ... Create a set of data to write + // ... Create a memory location to store the data + var requestParams = new SaveResultsAsMarkdownRequestParams { Encoding = "utf-16", }; + var data = new[] { new DbCellValue { DisplayValue = "ü" } }; + var columns = new[] { new DbColumnWrapper(new TestDbColumn("column1")) }; + byte[] output = new byte[8192]; + using var outputStream = new MemoryStream(output); + + // If: I write a row + using (var writer = new SaveAsMarkdownFileStreamWriter(outputStream, requestParams, columns)) + { + writer.WriteRow(data, columns); + } + + // Then: + // ... It should have written the umlaut as an HTML entity in utf-16le + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + string outputString = Encoding.Unicode.GetString(output).TrimEnd('\0', '\r', '\n'); + Assert.That(outputString, Is.EqualTo("|ü|")); + } + + private static (DbColumnWrapper[] columns, DbCellValue[] cells) GetTestValues(int columnCount) + { + var data = new DbCellValue[columnCount]; + var columns = new DbColumnWrapper[columnCount]; + for (int i = 0; i < columnCount; i++) + { + data[i] = new DbCellValue { DisplayValue = $"item{i}" }; + columns[i] = new DbColumnWrapper(new TestDbColumn($"column{i}")); + } + return (columns, data); + } + + private static string[] ParseWriterOutput(byte[] output, string lineSeparator) + { + string outputString = Encoding.UTF8.GetString(output).Trim('\0'); + string[] lines = outputString.Split(lineSeparator); + + // Make sure the file ends with a new line and return all but the meaningful lines + Assert.That(lines[^1], Is.Empty, "Output did not end with a newline"); + return lines.Take(lines.Length - 1).ToArray(); + } + + private static void ValidateLine(string line, IEnumerable expectedCells) + { + string[] cells = UnescapedPipe.Split(line); + string[] expectedCellsArray = expectedCells as string[] ?? expectedCells.ToArray(); + Assert.That(cells.Length - 2, Is.EqualTo(expectedCellsArray.Length), "Wrong number of cells in output"); + + Assert.That(cells[0], Is.Empty, "Row did not start with |"); + Assert.That(cells[^1], Is.Empty, "Row did not end with |"); + + for (int i = 0; i < expectedCellsArray.Length; i++) + { + Assert.That(cells[i + 1], Is.EqualTo(expectedCellsArray[i]), "Wrong cell value"); + } + } + } +} diff --git a/test/Microsoft.SqlTools.ServiceLayer.UnitTests/QueryExecution/SaveResults/SerializationServiceTests.cs b/test/Microsoft.SqlTools.ServiceLayer.UnitTests/QueryExecution/SaveResults/SerializationServiceTests.cs index 980137c5..bf650f4f 100644 --- a/test/Microsoft.SqlTools.ServiceLayer.UnitTests/QueryExecution/SaveResults/SerializationServiceTests.cs +++ b/test/Microsoft.SqlTools.ServiceLayer.UnitTests/QueryExecution/SaveResults/SerializationServiceTests.cs @@ -60,19 +60,9 @@ namespace Microsoft.SqlTools.ServiceLayer.UnitTests.QueryExecution.SaveResults protected Mock HostMock { get; private set; } protected SerializationService SerializationService { get; private set; } - [Test] - public async Task SaveResultsAsCsvNoHeaderSuccess() - { - await TestSaveAsCsvSuccess(false); - } - - [Test] - public async Task SaveResultsAsCsvWithHeaderSuccess() - { - await TestSaveAsCsvSuccess(true); - } - - private async Task TestSaveAsCsvSuccess(bool includeHeaders) + [TestCase(true)] + [TestCase(false)] + public async Task TestSaveAsCsvSuccess(bool includeHeaders) { await this.RunFileSaveTest(async (filePath) => { @@ -102,18 +92,47 @@ namespace Microsoft.SqlTools.ServiceLayer.UnitTests.QueryExecution.SaveResults }); } - [Test] - public async Task SaveResultsAsCsvNoHeaderMultiRequestSuccess() + [TestCase(true)] + [TestCase(false)] + public Task TestSaveAsMarkdownSuccess(bool includeHeaders) { - await TestSaveAsCsvMultiRequestSuccess(false); + return this.RunFileSaveTest(async filePath => + { + // Give: + // ... A simple data set that requires 1 message + var saveParams = new SerializeDataStartRequestParams + { + FilePath = filePath, + Columns = DefaultColumns, + Rows = DefaultData, + IsLastBatch = true, + SaveFormat = "markdown", + IncludeHeaders = includeHeaders, + }; + + // When: I attempt to save this to a file + var efv = new EventFlowValidator() + .AddStandardResultValidator() + .Complete(); + + await SerializationService.RunSerializeStartRequest(saveParams, efv.Object); + + // Then: + // ... There should not have been any errors + efv.Validate(); + + // ... And the file should look as expected + VerifyContents.VerifyMarkdownMatchesData( + saveParams.Rows, + saveParams.Columns, + saveParams.IncludeHeaders, + saveParams.FilePath); + }); } - [Test] - public async Task SaveResultsAsCsvWithHeaderMultiRequestSuccess() - { - await TestSaveAsCsvMultiRequestSuccess(true); - } - private async Task TestSaveAsCsvMultiRequestSuccess(bool includeHeaders) + [TestCase(true)] + [TestCase(false)] + public async Task TestSaveAsCsvMultiRequestSuccess(bool includeHeaders) { Action setParams = (serializeParams) => { serializeParams.SaveFormat = "csv"; @@ -149,6 +168,22 @@ namespace Microsoft.SqlTools.ServiceLayer.UnitTests.QueryExecution.SaveResults await this.TestSerializeDataMultiRequestSuccess(setParams, validation); } + [TestCase(true)] + [TestCase(false)] + public async Task SaveAsMarkdownMultiRequestSuccess(bool includeHeaders) + { + Action setParams = serializeParams => + { + serializeParams.SaveFormat = "markdown"; + serializeParams.IncludeHeaders = includeHeaders; + }; + Action validation = filePath => + { + VerifyContents.VerifyMarkdownMatchesData(DefaultData, DefaultColumns, includeHeaders, filePath); + }; + await this.TestSerializeDataMultiRequestSuccess(setParams, validation); + } + private async Task TestSerializeDataMultiRequestSuccess(Action setStandardParams, Action verify) { await this.RunFileSaveTest(async (filePath) => @@ -225,9 +260,9 @@ namespace Microsoft.SqlTools.ServiceLayer.UnitTests.QueryExecution.SaveResults { return efv.AddResultValidation(r => { - Assert.NotNull(r); - Assert.Null(r.Messages); - Assert.True(r.Succeeded); + Assert.That(r, Is.Not.Null, "Result should not be null"); + Assert.That(r.Messages, Is.Null, "No messages should be attached to the result"); + Assert.That(r.Succeeded, Is.True, "Result should indicate request succeeded"); }); } } @@ -236,10 +271,10 @@ namespace Microsoft.SqlTools.ServiceLayer.UnitTests.QueryExecution.SaveResults { public static void VerifyCsvMatchesData(DbCellValue[][] data, ColumnInfo[] columns, bool includeHeaders, string filePath) { - Assert.True(File.Exists(filePath), "Expected file to have been written"); + Assert.That(filePath, Does.Exist, "Expected file to have been written"); string[] lines = File.ReadAllLines(filePath); int expectedLength = includeHeaders ? data.Length + 1 : data.Length; - Assert.AreEqual(expectedLength, lines.Length); + Assert.That(lines.Length, Is.EqualTo(expectedLength), "Incorrect number of lines in result"); int lineIndex = 0; if (includeHeaders) { @@ -248,7 +283,7 @@ namespace Microsoft.SqlTools.ServiceLayer.UnitTests.QueryExecution.SaveResults } for (int dataIndex =0; dataIndex < data.Length && lineIndex < lines.Length; dataIndex++, lineIndex++) { - AssertLineEquals(lines[lineIndex], data[dataIndex].Select((d) => GetCsvPrintValue(d)).ToArray()); + AssertLineEquals(lines[lineIndex], data[dataIndex].Select(GetCsvPrintValue).ToArray()); } } @@ -260,17 +295,54 @@ namespace Microsoft.SqlTools.ServiceLayer.UnitTests.QueryExecution.SaveResults private static void AssertLineEquals(string line, string[] expected) { var actual = line.Split(','); - Assert.True(actual.Length == expected.Length, $"Line '{line}' does not match values {string.Join(",", expected)}"); + Assert.That(actual.Length, Is.EqualTo(expected.Length), + $"Line '{line}' does not match values {string.Join(",", expected)}"); for (int i = 0; i < actual.Length; i++) { - Assert.True(expected[i] == actual[i], $"Line '{line}' does not match values '{string.Join(",", expected)}' as '{expected[i]}' does not equal '{actual[i]}'"); + Assert.That(actual[i], Is.EqualTo(expected[i]), + $"Line '{line}' does not match values '{string.Join(",", expected)}' as '{expected[i]}' does not equal '{actual[i]}'"); } } + public static void VerifyMarkdownMatchesData( + DbCellValue[][] data, + ColumnInfo[] columns, + bool includeHeaders, + string filePath) + { + Assert.That(filePath, Does.Exist, "Expected file to be written"); + string[] lines = File.ReadAllLines(filePath); + + int expectedLength = includeHeaders ? data.Length + 2 : data.Length; + Assert.That(lines.Length, Is.EqualTo(expectedLength), "Incorrect number of lines in output"); + + int lineOffset = 0; + if (includeHeaders) + { + // First line is |col1|col2|... + var firstLineExpected = $"|{string.Join("|", columns.Select(c => c.Name))}|"; + Assert.That(lines[0], Is.EqualTo(firstLineExpected), "Header row does not match expected"); + // Second line is |---|---|... + var secondLineExpected = $"|{string.Join("", Enumerable.Repeat("---|", columns.Length))}"; + Assert.That(lines[1], Is.EqualTo(secondLineExpected), "Separator row does not match expected"); + + lineOffset = 2; + } + + for (int i = 0; i < data.Length; i++) + { + var expectedLine = $"|{string.Join("|", data[i].Select(GetMarkdownPrintValue).ToArray())}|"; + Assert.That(lines[i + lineOffset], Is.EqualTo(expectedLine), "Data row does not match expected"); + } + } + + private static string GetMarkdownPrintValue(DbCellValue d) => + d.IsNull ? "NULL" : d.DisplayValue; + public static void VerifyJsonMatchesData(DbCellValue[][] data, ColumnInfo[] columns, string filePath) { - // ... Upon deserialization to an array of dictionaries - Assert.True(File.Exists(filePath), "Expected file to have been written"); + // ... Upon deserialization to an array of dictionaries + Assert.That(filePath, Does.Exist, "Expected file to have been written"); string output = File.ReadAllText(filePath); Dictionary[] outputObject = JsonConvert.DeserializeObject[]>(output); @@ -278,18 +350,18 @@ namespace Microsoft.SqlTools.ServiceLayer.UnitTests.QueryExecution.SaveResults // ... There should be 2 items in the array, // ... The item should have three fields, and three values, assigned appropriately // ... The deserialized values should match the display value - Assert.AreEqual(data.Length, outputObject.Length); + Assert.That(outputObject.Length, Is.EqualTo(data.Length), "Incorrect number of records in output"); for (int rowIndex = 0; rowIndex < outputObject.Length; rowIndex++) { Dictionary item = outputObject[rowIndex]; - Assert.AreEqual(columns.Length, item.Count); + Assert.That(item.Count, Is.EqualTo(columns.Length), $"Incorrect number of cells for record {rowIndex}"); for (int columnIndex = 0; columnIndex < columns.Length; columnIndex++) { var key = columns[columnIndex].Name; - Assert.True(item.ContainsKey(key)); + Assert.That(item, Contains.Key(key), $"Record {rowIndex} does not contain column {key}"); DbCellValue value = data[rowIndex][columnIndex]; object expectedValue = GetJsonExpectedValue(value, columns[columnIndex]); - Assert.AreEqual(expectedValue, item[key]); + Assert.That(item[key], Is.EqualTo(expectedValue), $"Record {rowIndex}, column {key} contains incorrect value"); } } } @@ -313,8 +385,8 @@ namespace Microsoft.SqlTools.ServiceLayer.UnitTests.QueryExecution.SaveResults } public static void VerifyXmlMatchesData(DbCellValue[][] data, ColumnInfo[] columns, string filePath) { - // ... Upon deserialization to an array of dictionaries - Assert.True(File.Exists(filePath), "Expected file to have been written"); + // ... Upon deserialization to an array of dictionaries + Assert.That(filePath, Does.Exist, "Expected file to have been written"); string output = File.ReadAllText(filePath); XmlDocument xmlDoc = new XmlDocument(); xmlDoc.LoadXml(output); @@ -325,7 +397,7 @@ namespace Microsoft.SqlTools.ServiceLayer.UnitTests.QueryExecution.SaveResults string xpath = "data/row"; var rows = xmlDoc.SelectNodes(xpath); - Assert.AreEqual(data.Length, rows.Count); + Assert.That(rows.Count, Is.EqualTo(data.Length), "Incorrect number of records in output"); for (int rowIndex = 0; rowIndex < rows.Count; rowIndex++) { var rowValue = rows.Item(rowIndex); @@ -335,10 +407,10 @@ namespace Microsoft.SqlTools.ServiceLayer.UnitTests.QueryExecution.SaveResults { var columnName = columns[columnIndex].Name; var xmlColumn = xmlCols.FirstOrDefault(x => x.Name == columnName); - Assert.NotNull(xmlColumn); + Assert.That(xmlColumn, Is.Not.Null, $"Record {rowIndex} does not contain column {columnName}"); DbCellValue value = data[rowIndex][columnIndex]; object expectedValue = GetXmlExpectedValue(value); - Assert.AreEqual(expectedValue, xmlColumn.InnerText); + Assert.That(xmlColumn.InnerText, Is.EqualTo(expectedValue), $"Invalid value for record {rowIndex}, column {columnName}"); } } } diff --git a/test/Microsoft.SqlTools.ServiceLayer.UnitTests/QueryExecution/SaveResults/ServiceIntegrationTests.cs b/test/Microsoft.SqlTools.ServiceLayer.UnitTests/QueryExecution/SaveResults/ServiceIntegrationTests.cs index e84c4664..c51e126f 100644 --- a/test/Microsoft.SqlTools.ServiceLayer.UnitTests/QueryExecution/SaveResults/ServiceIntegrationTests.cs +++ b/test/Microsoft.SqlTools.ServiceLayer.UnitTests/QueryExecution/SaveResults/ServiceIntegrationTests.cs @@ -247,6 +247,129 @@ namespace Microsoft.SqlTools.ServiceLayer.UnitTests.QueryExecution.SaveResults #endregion + #region Markdown Tests + + [Test] + public async Task SaveResultsMarkdown_NonExistentQuery() + { + // Given: A working query and workspace service + WorkspaceService ws = Common.GetPrimedWorkspaceService(null); + QueryExecutionService qes = Common.GetPrimedExecutionService(null, false, false, false, ws); + + // If: I attempt to save a result set from a query that doesn't exist + var saveParams = new SaveResultsAsMarkdownRequestParams + { + OwnerUri = Constants.OwnerUri, // Won't exist because nothing has executed + }; + var evf = new EventFlowValidator() + .AddStandardErrorValidation() + .Complete(); + await qes.HandleSaveResultsAsMarkdownRequest(saveParams, evf.Object); + + // Then: + // ... An error event should have been fired + // ... No success event should have been fired + evf.Validate(); + } + + [Test] + public async Task SaveResultAsMarkdown_Failure() + { + // Given: + // ... A working query and workspace service + WorkspaceService ws = Common.GetPrimedWorkspaceService(Constants.StandardQuery); + QueryExecutionService qes = Common.GetPrimedExecutionService( + Common.ExecutionPlanTestDataSet, + true, + false, + false, + ws, + out ConcurrentDictionary storage); + + // ... The query execution service has executed a query with results + var executeParams = new ExecuteDocumentSelectionParams { QuerySelection = null, OwnerUri = Constants.OwnerUri }; + var executeRequest = RequestContextMocks.Create(null); + await qes.HandleExecuteRequest(executeParams, executeRequest.Object); + await qes.WorkTask; + await qes.ActiveQueries[Constants.OwnerUri].ExecutionTask; + + // If: I attempt to save a result set and get it to throw because of invalid column selection + var saveParams = new SaveResultsAsMarkdownRequestParams + { + BatchIndex = 0, + FilePath = "qqq", + OwnerUri = Constants.OwnerUri, + ResultSetIndex = 0, + ColumnStartIndex = -1, + ColumnEndIndex = 100, + RowStartIndex = 0, + RowEndIndex = 5 + }; + qes.MarkdownFileFactory = GetMarkdownStreamFactory(storage, saveParams); + var efv = new EventFlowValidator() + .AddStandardErrorValidation() + .Complete(); + + await qes.HandleSaveResultsAsMarkdownRequest(saveParams, efv.Object); + await qes.ActiveQueries[saveParams.OwnerUri] + .Batches[saveParams.BatchIndex] + .ResultSets[saveParams.ResultSetIndex] + .SaveTasks[saveParams.FilePath]; + + // Then: + // ... An error event should have been fired + // ... No success event should have been fired + efv.Validate(); + } + + [Test] + public async Task SaveResultsAsMarkdown_Success() + { + // Given: + // ... A working query and workspace service + WorkspaceService ws = Common.GetPrimedWorkspaceService(Constants.StandardQuery); + QueryExecutionService qes = Common.GetPrimedExecutionService( + Common.ExecutionPlanTestDataSet, + true, + false, + false, + ws, + out ConcurrentDictionary storage); + + // ... The query execution service has executed a query with results + var executeParams = new ExecuteDocumentSelectionParams { QuerySelection = null, OwnerUri = Constants.OwnerUri }; + var executeRequest = RequestContextMocks.Create(null); + await qes.HandleExecuteRequest(executeParams, executeRequest.Object); + await qes.WorkTask; + await qes.ActiveQueries[Constants.OwnerUri].ExecutionTask; + + // If: I attempt to save a result set from a query + var saveParams = new SaveResultsAsMarkdownRequestParams + { + OwnerUri = Constants.OwnerUri, + FilePath = "qqq", + BatchIndex = 0, + ResultSetIndex = 0 + }; + qes.MarkdownFileFactory = GetMarkdownStreamFactory(storage, saveParams); + var efv = new EventFlowValidator() + .AddStandardResultValidator() + .Complete(); + + await qes.HandleSaveResultsAsMarkdownRequest(saveParams, efv.Object); + await qes.ActiveQueries[saveParams.OwnerUri] + .Batches[saveParams.BatchIndex] + .ResultSets[saveParams.ResultSetIndex] + .SaveTasks[saveParams.FilePath]; + + // Then: + // ... I should have a successful result + // ... There should not have been an error + efv.Validate(); + } + + #endregion + #region XML tests [Test] @@ -507,6 +630,23 @@ namespace Microsoft.SqlTools.ServiceLayer.UnitTests.QueryExecution.SaveResults return mock.Object; } + private static IFileStreamFactory GetMarkdownStreamFactory( + IDictionary storage, + SaveResultsAsMarkdownRequestParams saveParams) + { + var mock = new Mock(); + mock.Setup(fsf => fsf.GetReader(It.IsAny())) + .Returns(output => new ServiceBufferFileStreamReader(new MemoryStream(storage[output]), new QueryExecutionSettings())); + mock.Setup(fsf => fsf.GetWriter(It.IsAny(), It.IsAny>())) + .Returns>((output, columns) => + { + storage.Add(output, new byte[8192]); + return new SaveAsMarkdownFileStreamWriter(new MemoryStream(storage[output]), saveParams, columns); + }); + + return mock.Object; + } + private static IFileStreamFactory GetXmlStreamFactory( IDictionary storage, SaveResultsAsXmlRequestParams saveParams)