Export to Markdown Support (#1705)

* Adding file writer for Markdown tables. No testing yet.

* Unit tests for the markdown writer

* Wiring up the factory and and request types

* Wiring up changes for Markdown serialization in serialization service

* Couple last minute tweaks

* Changes as per PR comments

* Revert temp testing code. 🙈

* Fluent assertions in SerializationServiceTests.cs

Co-authored-by: Ben Russell <russellben@microsoft.com>
This commit is contained in:
Benjamin Russell
2022-09-27 13:55:43 -05:00
committed by GitHub
parent 5c20f92312
commit af2c0c77e7
10 changed files with 905 additions and 54 deletions

View File

@@ -84,7 +84,7 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution.Contracts
public string Delimiter { get; set; }
/// <summary>
/// either CR, CRLF or LF to seperate rows in CSV
/// either CR, CRLF or LF to separate rows in CSV
/// </summary>
public string LineSeperator { get; set; }
@@ -123,6 +123,28 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution.Contracts
//TODO: define config for save as JSON
}
/// <summary>
/// Parameters for saving results as a Markdown table
/// </summary>
public class SaveResultsAsMarkdownRequestParams : SaveResultsRequestParams
{
/// <summary>
/// Encoding of the CSV file
/// </summary>
public string Encoding { get; set; }
/// <summary>
/// Whether to include column names as header for the table.
/// </summary>
public bool IncludeHeaders { get; set; }
/// <summary>
/// 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.
/// </summary>
public string? LineSeparator { get; set; }
}
/// <summary>
/// Parameters to save results as XML
/// </summary>
@@ -179,6 +201,16 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution.Contracts
RequestType<SaveResultsAsJsonRequestParams, SaveResultRequestResult> Type =
RequestType<SaveResultsAsJsonRequestParams, SaveResultRequestResult>.Create("query/saveJson");
}
/// <summary>
/// Request type to save results as a Markdown table
/// </summary>
public class SaveResultsAsMarkdownRequest
{
public static readonly
RequestType<SaveResultsAsMarkdownRequestParams, SaveResultRequestResult> Type =
RequestType<SaveResultsAsMarkdownRequestParams, SaveResultRequestResult>.Create("query/saveMarkdown");
}
/// <summary>
/// Request type to save results as XML

View File

@@ -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)

View File

@@ -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;
/// <summary>
/// Constructs and initializes a new instance of <see cref="SaveAsMarkdownFileStreamFactory"/>.
/// </summary>
/// <param name="requestParams">Parameters for the save as request</param>
public SaveAsMarkdownFileStreamFactory(SaveResultsAsMarkdownRequestParams requestParams)
{
this._saveRequestParams = requestParams;
}
/// <inheritdoc />
public QueryExecutionSettings QueryExecutionSettings { get; set; }
/// <inheritdoc />
/// <exception cref="InvalidOperationException">Throw at all times.</exception>
[Obsolete("Not implemented for export factories.")]
public string CreateFile()
{
throw new InvalidOperationException("CreateFile not implemented for export factories");
}
/// <inheritdoc />
/// <remarks>
/// Returns an instance of the <see cref="ServiceBufferFileStreamreader"/>.
/// </remarks>
public IFileStreamReader GetReader(string fileName)
{
return new ServiceBufferFileStreamReader(
new FileStream(fileName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite),
this.QueryExecutionSettings);
}
/// <inheritdoc />
/// <remarks>
/// Returns an instance of the <see cref="SaveAsMarkdownFileStreamWriter"/>.
/// </remarks>
public IFileStreamWriter GetWriter(string fileName, IReadOnlyList<DbColumnWrapper> columns)
{
return new SaveAsMarkdownFileStreamWriter(
new FileStream(fileName, FileMode.Create, FileAccess.ReadWrite, FileShare.ReadWrite),
this._saveRequestParams,
columns);
}
/// <inheritdoc />
public void DisposeFile(string fileName)
{
FileUtilities.SafeFileDelete(fileName);
}
}
}

View File

@@ -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
{
/// <summary>
/// Writer for exporting results to a Markdown table.
/// </summary>
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<DbColumnWrapper> 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<string> 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());
}
}
/// <inheritdoc />
public override void WriteRow(IList<DbCellValue> row, IReadOnlyList<DbColumnWrapper> columns)
{
IEnumerable<string> 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 &nbsp;
// Replace newlines with br tags, since cell values must be single line
field = NewlineRegex.Replace(field, @"<br />");
return field;
}
private void WriteLine(string line)
{
byte[] bytes = this._encoding.GetBytes(line + this._lineSeparator);
this.FileStream.Write(bytes);
}
}
}

View File

@@ -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();
}
/// <summary>
/// Attempts to parse the provided <paramref name="encoding"/> and return an encoding that
/// matches the encoding name or codepage number.
/// </summary>
/// <param name="encoding">Encoding name or codepage number to parse.</param>
/// <param name="fallbackEncoding">
/// Encoding to return if no encoding of provided name/codepage number exists.
/// </param>
/// <returns>
/// Desired encoding object or the <paramref name="fallbackEncoding"/> if the desired
/// encoding could not be found.
/// </returns>
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;

View File

@@ -98,6 +98,12 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution
/// </summary>
internal IFileStreamFactory JsonFileFactory { get; set; }
/// <summary>
/// File factory to be used to create Markdown files from result sets.
/// </summary>
/// <remarks>Internal to allow overriding in unit testing.</remarks>
internal IFileStreamFactory? MarkdownFileFactory { get; set; }
/// <summary>
/// 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);
}
/// <summary>
/// Processes a request to save a result set to a file in Markdown format.
/// </summary>
/// <param name="saveParams">Parameters for the request</param>
/// <param name="requestContext">Context of the request</param>
internal async Task HandleSaveResultsAsMarkdownRequest(
SaveResultsAsMarkdownRequestParams saveParams,
RequestContext<SaveResultRequestResult> 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);
}
/// <summary>
/// Process request to save a resultSet to a file in XML format
/// </summary>

View File

@@ -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