diff --git a/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/Contracts/SaveResultsRequest.cs b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/Contracts/SaveResultsRequest.cs index 25bcf9ef..53aa4030 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/Contracts/SaveResultsRequest.cs +++ b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/Contracts/SaveResultsRequest.cs @@ -118,6 +118,22 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution.Contracts //TODO: define config for save as JSON } + /// + /// Parameters to save results as XML + /// + public class SaveResultsAsXmlRequestParams: SaveResultsRequestParams + { + /// + /// Formatting of the XML file + /// + public bool Formatted { get; set; } + + /// + /// Encoding of the XML file + /// + public string Encoding { get; set; } + } + /// /// Parameters for the save results result /// @@ -158,5 +174,15 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution.Contracts RequestType Type = RequestType.Create("query/saveJson"); } + + /// + /// Request type to save results as XML + /// + public class SaveResultsAsXmlRequest + { + public static readonly + RequestType Type = + RequestType.Create("query/saveXml"); + } } \ No newline at end of file diff --git a/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsXmlFileStreamFactory.cs b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsXmlFileStreamFactory.cs new file mode 100644 index 00000000..26371e0b --- /dev/null +++ b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsXmlFileStreamFactory.cs @@ -0,0 +1,71 @@ +// +// 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.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 SaveAsXmlFileStreamFactory : IFileStreamFactory + { + + #region Properties + + /// + /// Settings for query execution + /// + public QueryExecutionSettings QueryExecutionSettings { get; set; } + + /// + /// Parameters for the save as XML request + /// + public SaveResultsAsXmlRequestParams SaveRequestParams { get; set; } + + #endregion + + /// + /// File names are not meant to be created with this factory. + /// + /// Thrown all times + [Obsolete] + public string CreateFile() + { + throw new InvalidOperationException(); + } + + /// + /// Returns a new service buffer reader for reading results back in from the temporary buffer files + /// + /// Path to the temp buffer file + /// Stream reader + public IFileStreamReader GetReader(string fileName) + { + return new ServiceBufferFileStreamReader(new FileStream(fileName, FileMode.Open, FileAccess.Read), QueryExecutionSettings); + } + + /// + /// Returns a new XML writer for writing results to a XML file + /// + /// Path to the XML output file + /// Stream writer + public IFileStreamWriter GetWriter(string fileName) + { + return new SaveAsXmlFileStreamWriter(new FileStream(fileName, FileMode.Create, FileAccess.ReadWrite), SaveRequestParams); + } + + /// + /// Safely deletes the file + /// + /// Path to the file to delete + public void DisposeFile(string fileName) + { + FileUtilities.SafeFileDelete(fileName); + } + + } +} diff --git a/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsXmlFileStreamWriter.cs b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsXmlFileStreamWriter.cs new file mode 100644 index 00000000..b772a9a8 --- /dev/null +++ b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsXmlFileStreamWriter.cs @@ -0,0 +1,142 @@ +// +// 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.Text; +using System.Xml; +using Microsoft.SqlTools.ServiceLayer.QueryExecution.Contracts; + +namespace Microsoft.SqlTools.ServiceLayer.QueryExecution.DataStorage +{ + /// + /// Writer for writing rows of results to a XML file. + /// + /// + /// This implements its own IDisposable because the cleanup logic closes the element that was + /// created when the writer was created. Since this behavior is different than the standard + /// file stream cleanup, the extra Dispose method was added. + /// + public class SaveAsXmlFileStreamWriter : SaveAsStreamWriter, IDisposable + { + // Root element name for the output XML + private const string RootElementTag = "data"; + + // Item element name which will be used for every row + private const string ItemElementTag = "row"; + + #region Member Variables + + private readonly XmlTextWriter xmlTextWriter; + + #endregion + + /// + /// Constructor, writes the header to the file, chains into the base constructor + /// + /// FileStream to access the JSON file output + /// XML save as request parameters + public SaveAsXmlFileStreamWriter(Stream stream, SaveResultsAsXmlRequestParams requestParams) + : base(stream, requestParams) + { + // Setup the internal state + var encoding = GetEncoding(requestParams); + xmlTextWriter = new XmlTextWriter(stream, encoding); + xmlTextWriter.Formatting = requestParams.Formatted ? Formatting.Indented : Formatting.None; + + //Start the document and the root element + xmlTextWriter.WriteStartDocument(); + xmlTextWriter.WriteStartElement(RootElementTag); + } + + /// + /// Writes a row of data as a XML object + /// + /// The data of the row to output to the file + /// + /// The entire list of columns for the result set. They will be filtered down as per the + /// request params. + /// + public override void WriteRow(IList row, IList columns) + { + // Write the header for the object + xmlTextWriter.WriteStartElement(ItemElementTag); + + // Write the items out as properties + int columnStart = ColumnStartIndex ?? 0; + int columnEnd = ColumnEndIndex + 1 ?? columns.Count; + for (int i = columnStart; i < columnEnd; i++) + { + // Write the column name as item tag + xmlTextWriter.WriteStartElement(columns[i].ColumnName); + + if (row[i].RawObject != null) + { + xmlTextWriter.WriteString(row[i].DisplayValue); + } + + // End the item tag + xmlTextWriter.WriteEndElement(); + } + + // Write the footer for the object + xmlTextWriter.WriteEndElement(); + } + + /// + /// Get the encoding for the XML file according to + /// + /// XML save as request parameters + /// + private Encoding GetEncoding(SaveResultsAsXmlRequestParams requestParams) + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + Encoding encoding; + try + { + if (int.TryParse(requestParams.Encoding, out var codepage)) + { + encoding = Encoding.GetEncoding(codepage); + } + else + { + encoding = Encoding.GetEncoding(requestParams.Encoding); + } + } + catch + { + // Fallback encoding when specified codepage is invalid + encoding = Encoding.GetEncoding("utf-8"); + } + + return encoding; + } + + private bool disposed = false; + + /// + /// Disposes the writer by closing up the element that contains the row objects + /// + protected override void Dispose(bool disposing) + { + if (disposed) + return; + + if (disposing) + { + // Write the footer of the file + xmlTextWriter.WriteEndElement(); + xmlTextWriter.WriteEndDocument(); + + xmlTextWriter.Close(); + xmlTextWriter.Dispose(); + } + + disposed = true; + base.Dispose(disposing); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/QueryExecutionService.cs b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/QueryExecutionService.cs index 625cd460..e64df616 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/QueryExecutionService.cs +++ b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/QueryExecutionService.cs @@ -2,9 +2,9 @@ // 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.IO; -using System.Linq; using System.Collections.Concurrent; using System.Threading.Tasks; using Microsoft.SqlTools.Hosting.Protocol; @@ -98,6 +98,12 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution /// internal IFileStreamFactory JsonFileFactory { 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 + /// + internal IFileStreamFactory XmlFileFactory { get; set; } + /// /// The collection of active queries /// @@ -151,6 +157,7 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution serviceHost.SetRequestHandler(SaveResultsAsCsvRequest.Type, HandleSaveResultsAsCsvRequest); serviceHost.SetRequestHandler(SaveResultsAsExcelRequest.Type, HandleSaveResultsAsExcelRequest); serviceHost.SetRequestHandler(SaveResultsAsJsonRequest.Type, HandleSaveResultsAsJsonRequest); + serviceHost.SetRequestHandler(SaveResultsAsXmlRequest.Type, HandleSaveResultsAsXmlRequest); serviceHost.SetRequestHandler(QueryExecutionPlanRequest.Type, HandleExecutionPlanRequest); serviceHost.SetRequestHandler(SimpleExecuteRequest.Type, HandleSimpleExecuteRequest); @@ -448,6 +455,21 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution await SaveResultsHelper(saveParams, requestContext, jsonFactory); } + /// + /// Process request to save a resultSet to a file in XML format + /// + internal async Task HandleSaveResultsAsXmlRequest(SaveResultsAsXmlRequestParams saveParams, + RequestContext requestContext) + { + // Use the default XML file factory if we haven't overridden it + IFileStreamFactory xmlFactory = XmlFileFactory ?? new SaveAsXmlFileStreamFactory + { + SaveRequestParams = saveParams, + QueryExecutionSettings = Settings.QueryExecutionSettings + }; + await SaveResultsHelper(saveParams, requestContext, xmlFactory); + } + #endregion #region Inter-Service API Handlers diff --git a/test/Microsoft.SqlTools.ServiceLayer.Test.Common/TestServiceDriverProvider.cs b/test/Microsoft.SqlTools.ServiceLayer.Test.Common/TestServiceDriverProvider.cs index a2a4720f..bed93500 100644 --- a/test/Microsoft.SqlTools.ServiceLayer.Test.Common/TestServiceDriverProvider.cs +++ b/test/Microsoft.SqlTools.ServiceLayer.Test.Common/TestServiceDriverProvider.cs @@ -523,6 +523,23 @@ namespace Microsoft.SqlTools.ServiceLayer.Test.Common return result; } + /// + /// Request to save query results as XML + /// + public async Task SaveAsXml(string ownerUri, string filename, int batchIndex, int resultSetIndex) + { + var saveParams = new SaveResultsAsXmlRequestParams + { + OwnerUri = ownerUri, + BatchIndex = batchIndex, + ResultSetIndex = resultSetIndex, + FilePath = filename + }; + + var result = await Driver.SendRequest(SaveResultsAsXmlRequest.Type, saveParams); + return result; + } + /// /// Request a subset of results from a query /// diff --git a/test/Microsoft.SqlTools.ServiceLayer.UnitTests/QueryExecution/SaveResults/ServiceIntegrationTests.cs b/test/Microsoft.SqlTools.ServiceLayer.UnitTests/QueryExecution/SaveResults/ServiceIntegrationTests.cs index 4f1a8334..37b7cda2 100644 --- a/test/Microsoft.SqlTools.ServiceLayer.UnitTests/QueryExecution/SaveResults/ServiceIntegrationTests.cs +++ b/test/Microsoft.SqlTools.ServiceLayer.UnitTests/QueryExecution/SaveResults/ServiceIntegrationTests.cs @@ -244,6 +244,117 @@ namespace Microsoft.SqlTools.ServiceLayer.UnitTests.QueryExecution.SaveResults #endregion + #region XML tests + + [Fact] + public async Task SaveResultsXmlNonExistentQuery() + { + // 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 + SaveResultsAsXmlRequestParams saveParams = new SaveResultsAsXmlRequestParams + { + OwnerUri = Constants.OwnerUri // Won't exist because nothing has executed + }; + var efv = new EventFlowValidator() + .AddStandardErrorValidation() + .Complete(); + await qes.HandleSaveResultsAsXmlRequest(saveParams, efv.Object); + + // Then: + // ... An error event should have been fired + // ... No success event should have been fired + efv.Validate(); + } + + [Fact] + public async Task SaveResultAsXmlFailure() + { + // Given: + // ... A working query and workspace service + WorkspaceService ws = Common.GetPrimedWorkspaceService(Constants.StandardQuery); + ConcurrentDictionary storage; + QueryExecutionService qes = Common.GetPrimedExecutionService(Common.StandardTestDataSet, true, false, false, ws, out 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.ActiveQueries[Constants.OwnerUri].ExecutionTask; + + // If: I attempt to save a result set and get it to throw because of invalid column selection + SaveResultsAsXmlRequestParams saveParams = new SaveResultsAsXmlRequestParams + { + BatchIndex = 0, + FilePath = "qqq", + OwnerUri = Constants.OwnerUri, + ResultSetIndex = 0, + ColumnStartIndex = -1, + ColumnEndIndex = 100, + RowStartIndex = 0, + RowEndIndex = 5 + }; + qes.XmlFileFactory = GetXmlStreamFactory(storage, saveParams); + var efv = new EventFlowValidator() + .AddStandardErrorValidation() + .Complete(); + await qes.HandleSaveResultsAsXmlRequest(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(); + } + + [Fact] + public async Task SaveResultsAsXmlSuccess() + { + // Given: + // ... A working query and workspace service + WorkspaceService ws = Common.GetPrimedWorkspaceService(Constants.StandardQuery); + ConcurrentDictionary storage; + QueryExecutionService qes = Common.GetPrimedExecutionService(Common.StandardTestDataSet, true, false, false, ws, out 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.ActiveQueries[Constants.OwnerUri].ExecutionTask; + + // If: I attempt to save a result set from a query + SaveResultsAsXmlRequestParams saveParams = new SaveResultsAsXmlRequestParams + { + OwnerUri = Constants.OwnerUri, + FilePath = "qqq", + BatchIndex = 0, + ResultSetIndex = 0, + Formatted = true + }; + qes.XmlFileFactory = GetXmlStreamFactory(storage, saveParams); + + var efv = new EventFlowValidator() + .AddStandardResultValidator() + .Complete(); + await qes.HandleSaveResultsAsXmlRequest(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 Excel Tests [Fact] @@ -385,6 +496,22 @@ namespace Microsoft.SqlTools.ServiceLayer.UnitTests.QueryExecution.SaveResults return mock.Object; } + private static IFileStreamFactory GetXmlStreamFactory(IDictionary storage, + SaveResultsAsXmlRequestParams saveParams) + { + Mock 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())) + .Returns(output => + { + storage.Add(output, new byte[8192]); + return new SaveAsXmlFileStreamWriter(new MemoryStream(storage[output]), saveParams); + }); + + return mock.Object; + } + private static IFileStreamFactory GetExcelStreamFactory(IDictionary storage, SaveResultsAsExcelRequestParams saveParams) {