diff --git a/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/Contracts/SaveResultsRequest.cs b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/Contracts/SaveResultsRequest.cs index 517bfe88..5f79112b 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/Contracts/SaveResultsRequest.cs +++ b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/Contracts/SaveResultsRequest.cs @@ -80,6 +80,17 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution.Contracts public bool IncludeHeaders { get; set; } } + /// + /// Parameters to save results as Excel + /// + public class SaveResultsAsExcelRequestParams : SaveResultsRequestParams + { + /// + /// Include headers of columns in Excel + /// + public bool IncludeHeaders { get; set; } + } + /// /// Parameters to save results as JSON /// @@ -120,6 +131,16 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution.Contracts RequestType.Create("query/saveCsv"); } + /// + /// Request type to save results as Excel + /// + public class SaveResultsAsExcelRequest + { + public static readonly + RequestType Type = + RequestType.Create("query/saveExcel"); + } + /// /// Request type to save results as JSON /// diff --git a/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsExcelFileStreamFactory.cs b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsExcelFileStreamFactory.cs new file mode 100644 index 00000000..d8a1423a --- /dev/null +++ b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsExcelFileStreamFactory.cs @@ -0,0 +1,72 @@ +// +// 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; + +namespace Microsoft.SqlTools.ServiceLayer.QueryExecution.DataStorage +{ + /// + /// Factory for creating a reader/writer pair that will read from the temporary buffer file + /// and output to a Excel file. + /// + public class SaveAsExcelFileStreamFactory : IFileStreamFactory + { + #region Properties + + /// + /// Settings for query execution + /// + public QueryExecutionSettings QueryExecutionSettings { get; set; } + + /// + /// Parameters for the save as Excel request + /// + public SaveResultsAsExcelRequestParams SaveRequestParams { get; set; } + + #endregion + + /// + /// File names are not meant to be created with this factory. + /// + /// Thrown all times + [Obsolete] + public string CreateFile() + { + throw new NotImplementedException(); + } + + /// + /// 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 Excel writer for writing results to a Excel file + /// + /// Path to the Excel output file + /// Stream writer + public IFileStreamWriter GetWriter(string fileName) + { + return new SaveAsExcelFileStreamWriter(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/SaveAsExcelFileStreamWriter.cs b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsExcelFileStreamWriter.cs new file mode 100644 index 00000000..f6ac59e5 --- /dev/null +++ b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsExcelFileStreamWriter.cs @@ -0,0 +1,90 @@ +// +// 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 Microsoft.SqlTools.ServiceLayer.QueryExecution.Contracts; + +namespace Microsoft.SqlTools.ServiceLayer.QueryExecution.DataStorage +{ + /// + /// Writer for writing rows of results to a Excel file + /// + public class SaveAsExcelFileStreamWriter : SaveAsStreamWriter + { + + #region Member Variables + + private readonly SaveResultsAsExcelRequestParams saveParams; + private bool headerWritten; + private SaveAsExcelFileStreamWriterHelper helper; + private SaveAsExcelFileStreamWriterHelper.ExcelSheet sheet; + + #endregion + + /// + /// Constructor, stores the Excel specific request params locally, chains into the base + /// constructor + /// + /// FileStream to access the Excel file output + /// Excel save as request parameters + public SaveAsExcelFileStreamWriter(Stream stream, SaveResultsAsExcelRequestParams requestParams) + : base(stream, requestParams) + { + saveParams = requestParams; + helper = new SaveAsExcelFileStreamWriterHelper(stream); + sheet = helper.AddSheet(); + } + + /// + /// Writes a row of data as a Excel row. If this is the first row and the user has requested + /// it, the headers for the column will be emitted as well. + /// + /// 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) + { + int columnStart = ColumnStartIndex ?? 0; + int columnEnd = (ColumnEndIndex != null) ? ColumnEndIndex.Value + 1 : columns.Count; + + // Write out the header if we haven't already and the user chose to have it + if (saveParams.IncludeHeaders && !headerWritten) + { + sheet.AddRow(); + for (int i = columnStart; i < columnEnd; i++) + { + sheet.AddCell(columns[i].ColumnName); + } + headerWritten = true; + } + + sheet.AddRow(); + for (int i = columnStart; i < columnEnd; i++) + { + sheet.AddCell(row[i]); + } + } + + private bool disposed; + override protected void Dispose(bool disposing) + { + if (disposed) + return; + + sheet.Dispose(); + helper.Dispose(); + + disposed = true; + base.Dispose(disposing); + } + + } +} \ No newline at end of file diff --git a/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsExcelFileStreamWriterHelper.cs b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsExcelFileStreamWriterHelper.cs new file mode 100644 index 00000000..e276f559 --- /dev/null +++ b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsExcelFileStreamWriterHelper.cs @@ -0,0 +1,805 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.SqlTools.ServiceLayer.QueryExecution.Contracts; +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Xml; + +namespace Microsoft.SqlTools.ServiceLayer.QueryExecution.DataStorage +{ + // A xlsx file is a zip with specific folder structure. + // http://www.ecma-international.org/publications/standards/Ecma-376.htm + + // The page number in the comments are based on + // ECMA-376, Fifth Edition, Part 1 - Fundamentals And Markup Language Reference + + // Page 75, SpreadsheetML package structure + // |- [Content_Types].xml + // |- _rels + // |- .rels + // |- xl + // |- workbook.xml + // |- styles.xml + // |- _rels + // |- workbook.xml.rels + // |- worksheets + // |- sheet1.xml + + /// + /// A helper class for write xlsx file base on ECMA-376. It tries to be minimal, + /// both in implementation and runtime allocation. + /// + /// + /// This sample shows how to use the class + /// + /// public class TestClass + /// { + /// public static int Main() + /// { + /// using (Stream stream = File.Create("test.xlsx")) + /// using (var helper = new SaveAsExcelFileStreamWriterHelper(stream, false)) + /// using (var sheet = helper.AddSheet()) + /// { + /// sheet.AddRow(); + /// sheet.AddCell("string"); + /// } + /// } + /// } + /// + /// + + internal sealed class SaveAsExcelFileStreamWriterHelper : IDisposable + { + /// + /// Present a Excel sheet + /// + public sealed class ExcelSheet : IDisposable + { + // The excel epoch is 1/1/1900, but it has 1/0/1900 and 2/29/1900 + // which is equal to set the epoch back two days to 12/30/1899 + // new DateTime(1899,12,30).Ticks + private const long ExcelEpochTick = 599264352000000000L; + + // Excel can not use date before 1/0/1900 and + // date before 3/1/1900 is wrong, off by 1 because of 2/29/1900 + // thus, for any date before 3/1/1900, use string for date + // new DateTime(1900,3,1).Ticks + private const long ExcelDateCutoffTick = 599317056000000000L; + + // new TimeSpan(24,0,0).Ticks + private const long TicksPerDay = 864000000000L; + + private XmlWriter writer; + private ReferenceManager referenceManager; + private bool hasOpenRowTag; + + /// + /// Initializes a new instance of the ExcelSheet class. + /// + /// XmlWriter to write the sheet data + internal ExcelSheet(XmlWriter writer) + { + this.writer = writer; + writer.WriteStartDocument(); + writer.WriteStartElement("worksheet", "http://schemas.openxmlformats.org/spreadsheetml/2006/main"); + writer.WriteAttributeString("xmlns", "r", null, "http://schemas.openxmlformats.org/officeDocument/2006/relationships"); + writer.WriteStartElement("sheetData"); + referenceManager = new ReferenceManager(writer); + } + + /// + /// Start a new row + /// + public void AddRow() + { + EndRowIfNeeded(); + hasOpenRowTag = true; + + referenceManager.AssureRowReference(); + + writer.WriteStartElement("row"); + referenceManager.WriteAndIncreaseRowReference(); + } + + /// + /// Write a string cell + /// + /// string value to write + public void AddCell(string value) + { + // string needs string + // This class uses inlineStr instead of more common shared string table + // to improve write performance and reduce implementation complexity + referenceManager.AssureColumnReference(); + if (value == null) + { + AddCellEmpty(); + return; + } + + writer.WriteStartElement("c"); + + referenceManager.WriteAndIncreaseColumnReference(); + + writer.WriteAttributeString("t", "inlineStr"); + + writer.WriteStartElement("is"); + writer.WriteStartElement("t"); + writer.WriteValue(value); + writer.WriteEndElement(); + writer.WriteEndElement(); + + writer.WriteEndElement(); + } + + /// + /// Write a object cell + /// + /// The program will try to output number/datetime, otherwise, call the ToString + /// + public void AddCell(DbCellValue dbCellValue) + { + object o = dbCellValue.RawObject; + if (dbCellValue.IsNull || o == null) + { + AddCellEmpty(); + return; + } + switch (Type.GetTypeCode(o.GetType())) + { + case TypeCode.Boolean: + AddCell((bool)o); + break; + case TypeCode.Byte: + case TypeCode.Int16: + case TypeCode.Int32: + case TypeCode.Int64: + case TypeCode.Single: + case TypeCode.Double: + case TypeCode.Decimal: + AddCellBoxedNumber(o); + break; + case TypeCode.DateTime: + AddCell((DateTime)o); + break; + case TypeCode.String: + AddCell((string)o); + break; + default: + if (o is TimeSpan) //TimeSpan doesn't have TypeCode + { + AddCell((TimeSpan)o); + break; + } + AddCell(dbCellValue.DisplayValue); + break; + } + } + + /// + /// Close the tags and close the stream + /// + public void Dispose() + { + EndRowIfNeeded(); + writer.WriteEndElement(); // + writer.WriteEndElement(); // + writer.Dispose(); + } + + /// + /// Write a empty cell + /// + /// This only increases the internal bookmark and doesn't arcturally write out anything. + private void AddCellEmpty() + { + referenceManager.IncreaseColumnReference(); + } + + /// + /// Write a bool cell. + /// + /// + private void AddCell(bool value) + { + // Excel FALSE: 0 + // Excel TRUE: 1 + referenceManager.AssureColumnReference(); + + writer.WriteStartElement("c"); + + referenceManager.WriteAndIncreaseColumnReference(); + + writer.WriteAttributeString("t", "b"); + + writer.WriteStartElement("v"); + if (value) + { + writer.WriteValue("1"); //use string to avoid convert + } + else + { + writer.WriteValue("0"); + } + writer.WriteEndElement(); + + writer.WriteEndElement(); + } + + /// + /// Write a TimeSpan cell. + /// + /// + private void AddCell(TimeSpan time) + { + referenceManager.AssureColumnReference(); + double excelDate = (double)time.Ticks / (double)TicksPerDay; + // The default hh:mm:ss format do not support more than 24 hours + // For that case, use the format string [h]:mm:ss + if (time.Ticks >= TicksPerDay) + { + AddCellDateTimeInternal(excelDate, Style.TimeMoreThan24Hours); + } + else + { + AddCellDateTimeInternal(excelDate, Style.Time); + } + } + + /// + /// Write a DateTime cell. + /// + /// Datetime + /// + /// If the DateTime does not have date part, it will be written as datetime and show as time only + /// If the DateTime is before 1900-03-01, save as string because excel doesn't support them. + /// Otherwise, save as datetime, and if the time is 00:00:00, show as yyyy-MM-dd. + /// Show the datetime as yyyy-MM-dd HH:mm:ss if none of the previous situations + /// + private void AddCell(DateTime dateTime) + { + referenceManager.AssureColumnReference(); + long ticks = dateTime.Ticks; + Style style = Style.DateTime; + double excelDate; + if (ticks < TicksPerDay) //date empty, time only + { + style = Style.Time; + excelDate = ((double)ticks) / (double)TicksPerDay; + } + else if (ticks < ExcelDateCutoffTick) //before excel cut-off, use string + { + AddCell(dateTime.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture)); + return; + } + else + { + if (ticks % TicksPerDay == 0) //time empty, date only + { + style = Style.Date; + } + excelDate = ((double)(ticks - ExcelEpochTick)) / (double)TicksPerDay; + } + AddCellDateTimeInternal(excelDate, style); + } + + // number needs 12.5 + private void AddCellBoxedNumber(object number) + { + referenceManager.AssureColumnReference(); + + writer.WriteStartElement("c"); + + referenceManager.WriteAndIncreaseColumnReference(); + + writer.WriteStartElement("v"); + writer.WriteValue(number); + writer.WriteEndElement(); + + writer.WriteEndElement(); + } + + + // datetime needs 26012.451 + private void AddCellDateTimeInternal(double excelDate, Style style) + { + writer.WriteStartElement("c"); + + referenceManager.WriteAndIncreaseColumnReference(); + + writer.WriteStartAttribute("s"); + writer.WriteValue((int)style); + writer.WriteEndAttribute(); + + writer.WriteStartElement("v"); + writer.WriteValue(excelDate); + writer.WriteEndElement(); + + writer.WriteEndElement(); + } + + private void EndRowIfNeeded() + { + if (hasOpenRowTag) + { + writer.WriteEndElement(); // + } + } + + + } + + /// + /// Helper class to track the current cell reference. + /// + /// + /// SpreadsheetML cell needs a reference attribute. (e.g. r="A1"). This class is used + /// to track the current cell reference. + /// + internal class ReferenceManager + { + private int currColumn; // 0 is invalid, the first AddRow will set to 1 + private int currRow = 1; + + // In order to reduce allocation, current reference is saved in this array, + // and write to the XmlWriter through WriteChars. + // For example, when the reference has value AA15, + // The content of this array will be @AA15xxxxx, with currReferenceRowLength=2 + // and currReferenceColumnLength=2 + private char[] currReference = new char[3 + 7]; //maximal XFD1048576 + private int currReferenceRowLength; + private int currReferenceColumnLength; + + private XmlWriter writer; + + /// + /// Initializes a new instance of the ReferenceManager class. + /// + /// XmlWriter to write the reference attribute to. + public ReferenceManager(XmlWriter writer) + { + this.writer = writer; + } + + /// + /// Check that we have not write too many columns. (xlsx has a limit of 16384 columns) + /// + public void AssureColumnReference() + { + if (currColumn == 0) + { + throw new InvalidOperationException("AddRow must be called before AddCell"); + + } + if (currColumn > 16384) + { + throw new InvalidOperationException("max column number is 16384, see https://support.office.com/en-us/article/Excel-specifications-and-limits-1672b34d-7043-467e-8e27-269d656771c3"); + } + } + + /// + /// Write out the r="A1" attribute and increase the column number of internal bookmark + /// + public void WriteAndIncreaseColumnReference() + { + writer.WriteStartAttribute("r"); + writer.WriteChars(currReference, 3 - currReferenceColumnLength, currReferenceRowLength + currReferenceColumnLength); + writer.WriteEndAttribute(); + IncreaseColumnReference(); + } + + /// + /// Increase the column of internal bookmark. + /// + public void IncreaseColumnReference() + { + // This function change the first three chars of currReference array + // The logic is simple, when a start a new row, the array is reset to @@A + // where @='A'-1. At each increase, check if the current reference is Z + // and move to AA if needed, since the maximal is 16384, or XFD, the code + // manipulates the array element directly instead of loop + char[] reference = currReference; + currColumn++; + if ('Z' == reference[2]++) + { + reference[2] = 'A'; + if (currReferenceColumnLength < 2) + { + currReferenceColumnLength = 2; + } + if ('Z' == reference[1]++) + { + reference[0]++; + reference[1] = 'A'; + currReferenceColumnLength = 3; + } + } + } + + /// + /// Check that we have not write too many rows. (xlsx has a limit of 1048576 rows) + /// + public void AssureRowReference() + { + if (currRow > 1048576) + { + throw new InvalidOperationException("max row number is 1048576, see https://support.office.com/en-us/article/Excel-specifications-and-limits-1672b34d-7043-467e-8e27-269d656771c3"); + } + } + /// + /// Write out the r="1" attribute and increase the row number of internal bookmark + /// + public void WriteAndIncreaseRowReference() + { + writer.WriteStartAttribute("r"); + writer.WriteValue(currRow); + writer.WriteEndAttribute(); + + ResetColumnReference(); //This need to be called before the increase + + currRow++; + } + + // Reset the Column Reference + // This will reset the first three chars of currReference array to '@@A' + // and the rest to the array to the string presentation of the current row. + private void ResetColumnReference() + { + currColumn = 1; + currReference[0] = currReference[1] = (char)('A' - 1); + currReference[2] = 'A'; + currReferenceColumnLength = 1; + + string rowReference = XmlConvert.ToString(currRow); + currReferenceRowLength = rowReference.Length; + rowReference.CopyTo(0, currReference, 3, rowReference.Length); + } + } + + private enum Style + { + Normal = 0, + Date = 1, + Time = 2, + DateTime = 3, + TimeMoreThan24Hours = 4, + } + + private ZipArchive zipArchive; + private List sheetNames = new List(); + private XmlWriterSettings writerSetting = new XmlWriterSettings() + { + CloseOutput = true, + }; + + /// + /// Initializes a new instance of the SaveAsExcelFileStreamWriterHelper class. + /// + /// The input or output stream. + public SaveAsExcelFileStreamWriterHelper(Stream stream) + { + zipArchive = new ZipArchive(stream, ZipArchiveMode.Create, false); + } + + /// + /// Initializes a new instance of the SaveAsExcelFileStreamWriterHelper class. + /// + /// The input or output stream. + /// true to leave the stream open after the + /// SaveAsExcelFileStreamWriterHelper object is disposed; otherwise, false. + public SaveAsExcelFileStreamWriterHelper(Stream stream, bool leaveOpen) + { + zipArchive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen); + } + + /// + /// Add sheet inside the Xlsx file. + /// + /// Sheet name + /// ExcelSheet for writing the sheet content + /// + /// When the sheetName is null, sheet1,shhet2,..., will be used. + /// The following charactors are not allowed in the sheetName + /// '\', '/','*','[',']',':','?' + /// + public ExcelSheet AddSheet(string sheetName = null) + { + string sheetFileName = "sheet" + (sheetNames.Count + 1); + if (sheetName == null) + { + sheetName = sheetFileName; + } + EnsureValidSheetName(sheetName); + + sheetNames.Add(sheetName); + XmlWriter sheetWriter = AddEntry($"xl/worksheets/{sheetFileName}.xml"); + return new ExcelSheet(sheetWriter); + } + + /// + /// Write out the rest of the xlsx files and release the resources used by the current instance + /// + public void Dispose() + { + WriteMinimalTemplate(); + zipArchive.Dispose(); + } + + + private XmlWriter AddEntry(string entryName) + { + ZipArchiveEntry entry = zipArchive.CreateEntry(entryName, CompressionLevel.Fastest); + return XmlWriter.Create(entry.Open(), writerSetting); + } + + //ECMA-376 page 75 + private void WriteMinimalTemplate() + { + WriteTopRel(); + WriteWorkbook(); + WriteStyle(); + WriteContentType(); + WriteWorkbookRel(); + } + + /// + /// write [Content_Types].xml + /// + /// + /// This file need to describe all the files in the zip. + /// + private void WriteContentType() + { + using (XmlWriter xw = AddEntry("[Content_Types].xml")) + { + xw.WriteStartDocument(); + xw.WriteStartElement("Types", "http://schemas.openxmlformats.org/package/2006/content-types"); + + xw.WriteStartElement("Default"); + xw.WriteAttributeString("Extension", "rels"); + xw.WriteAttributeString("ContentType", "application/vnd.openxmlformats-package.relationships+xml"); + xw.WriteEndElement(); + + xw.WriteStartElement("Override"); + xw.WriteAttributeString("PartName", "/xl/workbook.xml"); + xw.WriteAttributeString("ContentType", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"); + xw.WriteEndElement(); + + xw.WriteStartElement("Override"); + xw.WriteAttributeString("PartName", "/xl/styles.xml"); + xw.WriteAttributeString("ContentType", "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"); + xw.WriteEndElement(); + + for (int i = 1; i <= sheetNames.Count; ++i) + { + xw.WriteStartElement("Override"); + xw.WriteAttributeString("PartName", "/xl/worksheets/sheet" + i + ".xml"); + xw.WriteAttributeString("ContentType", "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"); + xw.WriteEndElement(); + } + xw.WriteEndElement(); + xw.WriteEndDocument(); + } + } + + /// + /// Write _rels/.rels. This file only need to reference main workbook + /// + private void WriteTopRel() + { + using (XmlWriter xw = AddEntry("_rels/.rels")) + { + xw.WriteStartDocument(); + + xw.WriteStartElement("Relationships", "http://schemas.openxmlformats.org/package/2006/relationships"); + + xw.WriteStartElement("Relationship"); + xw.WriteAttributeString("Id", "rId1"); + xw.WriteAttributeString("Type", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"); + xw.WriteAttributeString("Target", "xl/workbook.xml"); + xw.WriteEndElement(); + + xw.WriteEndElement(); + + xw.WriteEndDocument(); + } + } + + private static char[] invalidSheetNameCharacters = new char[] + { + '\\', '/','*','[',']',':','?' + }; + private void EnsureValidSheetName(string sheetName) + { + if (sheetName.IndexOfAny(invalidSheetNameCharacters) != -1) + { + throw new ArgumentException($"Invalid sheetname: sheetName"); + } + if (sheetNames.IndexOf(sheetName) != -1) + { + throw new ArgumentException($"Duplicate sheetName: {sheetName}"); + } + } + + /// + /// Write xl/workbook.xml. This file will references the sheets through ids in xl/_rels/workbook.xml.rels + /// + private void WriteWorkbook() + { + using (XmlWriter xw = AddEntry("xl/workbook.xml")) + { + xw.WriteStartDocument(); + xw.WriteStartElement("workbook", "http://schemas.openxmlformats.org/spreadsheetml/2006/main"); + xw.WriteAttributeString("xmlns", "r", null, "http://schemas.openxmlformats.org/officeDocument/2006/relationships"); + xw.WriteStartElement("sheets"); + for (int i = 1; i <= sheetNames.Count; i++) + { + xw.WriteStartElement("sheet"); + xw.WriteAttributeString("name", sheetNames[i - 1]); + xw.WriteAttributeString("sheetId", i.ToString()); + xw.WriteAttributeString("r", "id", null, "rId" + i); + xw.WriteEndElement(); + } + xw.WriteEndDocument(); + } + } + + /// + /// Write xl/_rels/workbook.xml.rels. This file will have the paths of the style and sheets. + /// + private void WriteWorkbookRel() + { + using (XmlWriter xw = AddEntry("xl/_rels/workbook.xml.rels")) + { + xw.WriteStartDocument(); + xw.WriteStartElement("Relationships", "http://schemas.openxmlformats.org/package/2006/relationships"); + + xw.WriteStartElement("Relationship"); + xw.WriteAttributeString("Id", "rId0"); + xw.WriteAttributeString("Type", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles"); + xw.WriteAttributeString("Target", "styles.xml"); + xw.WriteEndElement(); + + for (int i = 1; i <= sheetNames.Count; i++) + { + xw.WriteStartElement("Relationship"); + xw.WriteAttributeString("Id", "rId" + i); + xw.WriteAttributeString("Type", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"); + xw.WriteAttributeString("Target", "worksheets/sheet" + i + ".xml"); + xw.WriteEndElement(); + } + xw.WriteEndElement(); + xw.WriteEndDocument(); + } + } + + // Write the xl/styles.xml + private void WriteStyle() + { + // the style 0 is used for general case, style 1 for date, style 2 for time and style 3 for datetime see Enum Style + // reference chain: (index start with 0) + // (in sheet1.xml) --> (by s) --> (by xfId) + // --> (by numFmtId) + // that is will reference the second element of + // then, this xf reference numFmt by name and get formatCode "hh:mm:ss" + + using (XmlWriter xw = AddEntry("xl/styles.xml")) + { + xw.WriteStartElement("styleSheet", "http://schemas.openxmlformats.org/spreadsheetml/2006/main"); + + xw.WriteStartElement("numFmts"); + xw.WriteAttributeString("count", "4"); + xw.WriteStartElement("numFmt"); + xw.WriteAttributeString("numFmtId", "166"); + xw.WriteAttributeString("formatCode", "yyyy-mm-dd"); + xw.WriteEndElement(); + xw.WriteStartElement("numFmt"); + xw.WriteAttributeString("numFmtId", "167"); + xw.WriteAttributeString("formatCode", "hh:mm:ss"); + xw.WriteEndElement(); + xw.WriteStartElement("numFmt"); + xw.WriteAttributeString("numFmtId", "168"); + xw.WriteAttributeString("formatCode", "yyyy-mm-dd hh:mm:ss"); + xw.WriteEndElement(); + xw.WriteStartElement("numFmt"); + xw.WriteAttributeString("numFmtId", "169"); + xw.WriteAttributeString("formatCode", "[h]:mm:ss"); + xw.WriteEndElement(); + xw.WriteEndElement(); //mumFmts + + + xw.WriteStartElement("fonts"); + xw.WriteAttributeString("count", "1"); + xw.WriteStartElement("font"); + xw.WriteStartElement("sz"); + xw.WriteAttributeString("val", "11"); + xw.WriteEndElement(); + xw.WriteStartElement("color"); + xw.WriteAttributeString("theme", "1"); + xw.WriteEndElement(); + xw.WriteStartElement("name"); + xw.WriteAttributeString("val", "Calibri"); + xw.WriteEndElement(); + xw.WriteStartElement("family"); + xw.WriteAttributeString("val", "2"); + xw.WriteEndElement(); + xw.WriteStartElement("scheme"); + xw.WriteAttributeString("val", "minor"); + xw.WriteEndElement(); + xw.WriteEndElement(); // font + xw.WriteEndElement(); // fonts + + xw.WriteStartElement("fills"); + xw.WriteAttributeString("count", "1"); + xw.WriteStartElement("fill"); + xw.WriteStartElement("patternFill"); + xw.WriteAttributeString("patternType", "none"); + xw.WriteEndElement(); // patternFill + xw.WriteEndElement(); // fill + xw.WriteEndElement(); // fills + + xw.WriteStartElement("borders"); + xw.WriteAttributeString("count", "1"); + xw.WriteStartElement("border"); + xw.WriteElementString("left", null); + xw.WriteElementString("right", null); + xw.WriteElementString("top", null); + xw.WriteElementString("bottom", null); + xw.WriteElementString("diagonal", null); + xw.WriteEndElement(); // board + xw.WriteEndElement(); // borders + + xw.WriteStartElement("cellStyleXfs"); + xw.WriteAttributeString("count", "1"); + xw.WriteStartElement("xf"); + xw.WriteAttributeString("numFmtId", "0"); + xw.WriteAttributeString("fontId", "0"); + xw.WriteAttributeString("fillId", "0"); + xw.WriteAttributeString("borderId", "0"); + xw.WriteEndElement(); // xf + xw.WriteEndElement(); // cellStyleXfs + + xw.WriteStartElement("cellXfs"); + xw.WriteAttributeString("count", "5"); + xw.WriteStartElement("xf"); + xw.WriteAttributeString("xfId", "0"); + xw.WriteEndElement(); + xw.WriteStartElement("xf"); + xw.WriteAttributeString("numFmtId", "166"); + xw.WriteAttributeString("xfId", "0"); + xw.WriteAttributeString("applyNumberFormat", "1"); + xw.WriteEndElement(); + xw.WriteStartElement("xf"); + xw.WriteAttributeString("numFmtId", "167"); + xw.WriteAttributeString("xfId", "0"); + xw.WriteAttributeString("applyNumberFormat", "1"); + xw.WriteEndElement(); + xw.WriteStartElement("xf"); + xw.WriteAttributeString("numFmtId", "168"); + xw.WriteAttributeString("xfId", "0"); + xw.WriteAttributeString("applyNumberFormat", "1"); + xw.WriteEndElement(); + xw.WriteStartElement("xf"); + xw.WriteAttributeString("numFmtId", "169"); + xw.WriteAttributeString("xfId", "0"); + xw.WriteAttributeString("applyNumberFormat", "1"); + xw.WriteEndElement(); + xw.WriteEndElement(); // cellXfs + + xw.WriteStartElement("cellStyles"); + xw.WriteAttributeString("count", "1"); + xw.WriteStartElement("cellStyle"); + xw.WriteAttributeString("name", "Normal"); + xw.WriteAttributeString("builtinId", "0"); + xw.WriteAttributeString("xfId", "0"); + xw.WriteEndElement(); // cellStyle + xw.WriteEndElement(); // cellStyles + } + } + } +} diff --git a/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsJsonFileStreamWriter.cs b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsJsonFileStreamWriter.cs index cd9fdc79..e13f42db 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsJsonFileStreamWriter.cs +++ b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsJsonFileStreamWriter.cs @@ -57,10 +57,10 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution.DataStorage { // Write the header for the object jsonWriter.WriteStartObject(); - + // Write the items out as properties int columnStart = ColumnStartIndex ?? 0; - int columnEnd = (ColumnEndIndex != null) ? ColumnEndIndex.Value + 1 : columns.Count; + int columnEnd = (ColumnEndIndex != null) ? ColumnEndIndex.Value + 1 : columns.Count; for (int i = columnStart; i < columnEnd; i++) { jsonWriter.WritePropertyName(columns[i].ColumnName); @@ -78,16 +78,24 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution.DataStorage jsonWriter.WriteEndObject(); } + private bool disposed = false; /// /// Disposes the writer by closing up the array that contains the row objects /// - public new void Dispose() + protected override void Dispose(bool disposing) { - // Write the footer of the file - jsonWriter.WriteEndArray(); - // This closes the underlying stream, so we needn't call close on the underlying stream explicitly - jsonWriter.Close(); - base.Dispose(); + if (disposed) + return; + + if (disposing) + { + // Write the footer of the file + jsonWriter.WriteEndArray(); + // This closes the underlying stream, so we needn't call close on the underlying stream explicitly + jsonWriter.Close(); + } + disposed = true; + base.Dispose(disposing); } } } diff --git a/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsWriterBase.cs b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsWriterBase.cs index 1c70e51b..12ff6b46 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsWriterBase.cs +++ b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/DataStorage/SaveAsWriterBase.cs @@ -100,17 +100,19 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution.DataStorage /// Disposes the instance by flushing and closing the file stream /// /// - private void Dispose(bool disposing) + protected virtual void Dispose(bool disposing) { - if (disposed || !disposing) - { - disposed = true; + if (disposed) return; - } - FileStream.Dispose(); + if (disposing) + { + FileStream.Dispose(); + } + disposed = true; } - public virtual void Dispose() + + public void Dispose() { Dispose(true); GC.SuppressFinalize(this); diff --git a/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/QueryExecutionService.cs b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/QueryExecutionService.cs index 3829f5a2..a9f0a012 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/QueryExecutionService.cs +++ b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/QueryExecutionService.cs @@ -83,6 +83,12 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution /// internal IFileStreamFactory CsvFileFactory { get; set; } + /// + /// File factory to be used to create Excel files from result sets. Set to internal in order + /// to allow overriding in unit testing + /// + internal IFileStreamFactory ExcelFileFactory { get; set; } + /// /// File factory to be used to create JSON files from result sets. Set to internal in order /// to allow overriding in unit testing @@ -128,6 +134,7 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution serviceHost.SetRequestHandler(QueryDisposeRequest.Type, HandleDisposeRequest); serviceHost.SetRequestHandler(QueryCancelRequest.Type, HandleCancelRequest); serviceHost.SetRequestHandler(SaveResultsAsCsvRequest.Type, HandleSaveResultsAsCsvRequest); + serviceHost.SetRequestHandler(SaveResultsAsExcelRequest.Type, HandleSaveResultsAsExcelRequest); serviceHost.SetRequestHandler(SaveResultsAsJsonRequest.Type, HandleSaveResultsAsJsonRequest); serviceHost.SetRequestHandler(QueryExecutionPlanRequest.Type, HandleExecutionPlanRequest); @@ -308,6 +315,21 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution await SaveResultsHelper(saveParams, requestContext, csvFactory); } + /// + /// Process request to save a resultSet to a file in Excel format + /// + internal async Task HandleSaveResultsAsExcelRequest(SaveResultsAsExcelRequestParams saveParams, + RequestContext requestContext) + { + // Use the default Excel file factory if we haven't overridden it + IFileStreamFactory excelFactory = ExcelFileFactory ?? new SaveAsExcelFileStreamFactory + { + SaveRequestParams = saveParams, + QueryExecutionSettings = Settings.QueryExecutionSettings + }; + await SaveResultsHelper(saveParams, requestContext, excelFactory); + } + /// /// Process request to save a resultSet to a file in JSON format /// diff --git a/test/Microsoft.SqlTools.ServiceLayer.Test.Common/TestData/DataStorage/SaveAsExcelFileStreamWriterHelperTests/[Content_Types].xml b/test/Microsoft.SqlTools.ServiceLayer.Test.Common/TestData/DataStorage/SaveAsExcelFileStreamWriterHelperTests/[Content_Types].xml new file mode 100644 index 00000000..f4d8e54f --- /dev/null +++ b/test/Microsoft.SqlTools.ServiceLayer.Test.Common/TestData/DataStorage/SaveAsExcelFileStreamWriterHelperTests/[Content_Types].xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/test/Microsoft.SqlTools.ServiceLayer.Test.Common/TestData/DataStorage/SaveAsExcelFileStreamWriterHelperTests/_rels/.rels b/test/Microsoft.SqlTools.ServiceLayer.Test.Common/TestData/DataStorage/SaveAsExcelFileStreamWriterHelperTests/_rels/.rels new file mode 100644 index 00000000..9946abe7 --- /dev/null +++ b/test/Microsoft.SqlTools.ServiceLayer.Test.Common/TestData/DataStorage/SaveAsExcelFileStreamWriterHelperTests/_rels/.rels @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/test/Microsoft.SqlTools.ServiceLayer.Test.Common/TestData/DataStorage/SaveAsExcelFileStreamWriterHelperTests/xl/_rels/workbook.xml.rels b/test/Microsoft.SqlTools.ServiceLayer.Test.Common/TestData/DataStorage/SaveAsExcelFileStreamWriterHelperTests/xl/_rels/workbook.xml.rels new file mode 100644 index 00000000..14d5e053 --- /dev/null +++ b/test/Microsoft.SqlTools.ServiceLayer.Test.Common/TestData/DataStorage/SaveAsExcelFileStreamWriterHelperTests/xl/_rels/workbook.xml.rels @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/test/Microsoft.SqlTools.ServiceLayer.Test.Common/TestData/DataStorage/SaveAsExcelFileStreamWriterHelperTests/xl/styles.xml b/test/Microsoft.SqlTools.ServiceLayer.Test.Common/TestData/DataStorage/SaveAsExcelFileStreamWriterHelperTests/xl/styles.xml new file mode 100644 index 00000000..1a3bb372 --- /dev/null +++ b/test/Microsoft.SqlTools.ServiceLayer.Test.Common/TestData/DataStorage/SaveAsExcelFileStreamWriterHelperTests/xl/styles.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/Microsoft.SqlTools.ServiceLayer.Test.Common/TestData/DataStorage/SaveAsExcelFileStreamWriterHelperTests/xl/workbook.xml b/test/Microsoft.SqlTools.ServiceLayer.Test.Common/TestData/DataStorage/SaveAsExcelFileStreamWriterHelperTests/xl/workbook.xml new file mode 100644 index 00000000..ee9b4af9 --- /dev/null +++ b/test/Microsoft.SqlTools.ServiceLayer.Test.Common/TestData/DataStorage/SaveAsExcelFileStreamWriterHelperTests/xl/workbook.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/test/Microsoft.SqlTools.ServiceLayer.Test.Common/TestData/DataStorage/SaveAsExcelFileStreamWriterHelperTests/xl/worksheets/sheet1.xml b/test/Microsoft.SqlTools.ServiceLayer.Test.Common/TestData/DataStorage/SaveAsExcelFileStreamWriterHelperTests/xl/worksheets/sheet1.xml new file mode 100644 index 00000000..0037e7b1 --- /dev/null +++ b/test/Microsoft.SqlTools.ServiceLayer.Test.Common/TestData/DataStorage/SaveAsExcelFileStreamWriterHelperTests/xl/worksheets/sheet1.xml @@ -0,0 +1,21 @@ + + + + + + test string + 3 + 3.5 + 0 + 1 + + + 1900-02-28 + 61 + 61.625 + 0.625 + 0.625 + 1 + + + \ No newline at end of file diff --git a/test/Microsoft.SqlTools.ServiceLayer.UnitTests/QueryExecution/DataStorage/SaveAsExcelFileStreamWriterHelperTests.cs b/test/Microsoft.SqlTools.ServiceLayer.UnitTests/QueryExecution/DataStorage/SaveAsExcelFileStreamWriterHelperTests.cs new file mode 100644 index 00000000..c1e28fcc --- /dev/null +++ b/test/Microsoft.SqlTools.ServiceLayer.UnitTests/QueryExecution/DataStorage/SaveAsExcelFileStreamWriterHelperTests.cs @@ -0,0 +1,241 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.SqlTools.ServiceLayer.QueryExecution.Contracts; +using Microsoft.SqlTools.ServiceLayer.QueryExecution.DataStorage; +using Microsoft.SqlTools.ServiceLayer.Test.Common; +using Moq; +using System; +using System.IO; +using System.IO.Compression; +using System.Text.RegularExpressions; +using System.Xml; +using Xunit; + +namespace Microsoft.SqlTools.ServiceLayer.UnitTests.QueryExecution.DataStorage +{ + public class SaveAsExcelFileStreamWriterHelperTests : IDisposable + { + private Stream _stream; + public SaveAsExcelFileStreamWriterHelperTests() + { + _stream = new MemoryStream(); + using (var helper = new SaveAsExcelFileStreamWriterHelper(_stream, true)) + using (var sheet = helper.AddSheet()) + { + DbCellValue value = new DbCellValue(); + sheet.AddRow(); + + value.IsNull = true; + sheet.AddCell(value); + + value.IsNull = false; + value.RawObject = ""; + sheet.AddCell(value); + + value.RawObject = "test string"; + sheet.AddCell(value); + + value.RawObject = 3; + sheet.AddCell(value); + + value.RawObject = 3.5; + sheet.AddCell(value); + + value.RawObject = false; + sheet.AddCell(value); + + value.RawObject = true; + sheet.AddCell(value); + + sheet.AddRow(); + + value.RawObject = new DateTime(1900, 2, 28); + sheet.AddCell(value); + + value.RawObject = new DateTime(1900, 3, 1); + sheet.AddCell(value); + + value.RawObject = new DateTime(1900, 3, 1, 15, 00, 00); + sheet.AddCell(value); + + value.RawObject = new DateTime(1, 1, 1, 15, 00, 00); + sheet.AddCell(value); + + value.RawObject = new TimeSpan(15, 00, 00); + sheet.AddCell(value); + + value.RawObject = new TimeSpan(24, 00, 00); + sheet.AddCell(value); + } + } + Regex contentRemoveLinebreakLeadingSpace = new Regex(@"\r?\n\s*"); + private void ContentMatch(string fileName) + { + string referencePath = Path.Combine(RunEnvironmentInfo.GetTestDataLocation(), + "DataStorage", + "SaveAsExcelFileStreamWriterHelperTests", + fileName); + string referenceContent = File.ReadAllText(referencePath); + referenceContent = contentRemoveLinebreakLeadingSpace.Replace(referenceContent, ""); + + using (ZipArchive zip = new ZipArchive(_stream, ZipArchiveMode.Read, true)) + { + using (var reader = new StreamReader(zip.GetEntry(fileName).Open())) + { + string realContent = reader.ReadToEnd(); + Assert.Equal(referenceContent, realContent); + } + } + } + [Fact] + public void CheckContentType() + { + ContentMatch("[Content_Types].xml"); + } + [Fact] + public void CheckTopRels() + { + ContentMatch("_rels/.rels"); + } + [Fact] + public void CheckWorkbookRels() + { + ContentMatch("xl/_rels/workbook.xml.rels"); + } + [Fact] + public void CheckStyles() + { + ContentMatch("xl/styles.xml"); + } + [Fact] + public void CheckWorkbook() + { + ContentMatch("xl/workbook.xml"); + } + [Fact] + public void CheckSheet1() + { + ContentMatch("xl/worksheets/sheet1.xml"); + } + + public void Dispose() + { + _stream.Dispose(); + } + } + + public class SaveAsExcelFileStreamWriterHelperReferenceManagerTests + { + private Mock _xmlWriterMock; + private string LastWrittenReference { get; set; } + private int LastWrittenRow { get; set; } + + public SaveAsExcelFileStreamWriterHelperReferenceManagerTests() + { + _xmlWriterMock = new Mock(MockBehavior.Strict); + _xmlWriterMock + .Setup(_xmlWriter => _xmlWriter.WriteChars( + It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((array, index, count) => + LastWrittenReference = new string(array, index, count)); + _xmlWriterMock.Setup(a => a.WriteStartAttribute(null, "r", null)); + _xmlWriterMock.Setup(a => a.WriteEndAttribute()); + _xmlWriterMock.Setup(a => a.WriteValue(It.IsAny())) + .Callback(row => LastWrittenRow = row); + + } + + [Fact] + public void ReferenceA1() + { + var xmlWriter = _xmlWriterMock.Object; + var manager = new SaveAsExcelFileStreamWriterHelper.ReferenceManager(xmlWriter); + manager.WriteAndIncreaseRowReference(); + manager.WriteAndIncreaseColumnReference(); + Assert.Equal("A1", LastWrittenReference); + } + [Fact] + public void ReferenceZ1() + { + var xmlWriter = _xmlWriterMock.Object; + var manager = new SaveAsExcelFileStreamWriterHelper.ReferenceManager(xmlWriter); + manager.WriteAndIncreaseRowReference(); + for (int i = 0; i < 26 - 1; i++) + { + manager.IncreaseColumnReference(); + } + manager.WriteAndIncreaseColumnReference(); + Assert.Equal("Z1", LastWrittenReference); + manager.WriteAndIncreaseColumnReference(); + Assert.Equal("AA1", LastWrittenReference); + } + [Fact] + public void ReferenceZZ1() + { + var xmlWriter = _xmlWriterMock.Object; + var manager = new SaveAsExcelFileStreamWriterHelper.ReferenceManager(xmlWriter); + manager.WriteAndIncreaseRowReference(); + + for (int i = 0; i < 27 * 26 - 1; i++) + { + manager.IncreaseColumnReference(); + } + manager.WriteAndIncreaseColumnReference(); + Assert.Equal("ZZ1", LastWrittenReference); + manager.WriteAndIncreaseColumnReference(); + Assert.Equal("AAA1", LastWrittenReference); + } + [Fact] + public void ReferenceXFD() + { + var xmlWriter = _xmlWriterMock.Object; + var manager = new SaveAsExcelFileStreamWriterHelper.ReferenceManager(xmlWriter); + manager.WriteAndIncreaseRowReference(); + + for (int i = 0; i < 16384 - 1; i++) + { + manager.IncreaseColumnReference(); + } + //The 16384 should be the maximal column and not throw + manager.AssureColumnReference(); + manager.WriteAndIncreaseColumnReference(); + Assert.Equal("XFD1", LastWrittenReference); + var ex = Assert.Throws( + () => manager.AssureColumnReference()); + Assert.Contains("max column number is 16384", ex.Message); + } + [Fact] + public void ReferenceRowReset() + { + var xmlWriter = _xmlWriterMock.Object; + var manager = new SaveAsExcelFileStreamWriterHelper.ReferenceManager(xmlWriter); + manager.WriteAndIncreaseRowReference(); + Assert.Equal(1, LastWrittenRow); + manager.WriteAndIncreaseColumnReference(); + Assert.Equal("A1", LastWrittenReference); + manager.WriteAndIncreaseColumnReference(); + Assert.Equal("B1", LastWrittenReference); + + //add row should reset column reference + manager.WriteAndIncreaseRowReference(); + Assert.Equal(2, LastWrittenRow); + manager.WriteAndIncreaseColumnReference(); + Assert.Equal("A2", LastWrittenReference); + } + + [Fact] + public void AddRowMustBeCalledBeforeAddCellException() + { + var xmlWriter = _xmlWriterMock.Object; + var manager = new SaveAsExcelFileStreamWriterHelper.ReferenceManager(xmlWriter); + + var ex = Assert.Throws( + () => manager.AssureColumnReference()); + Assert.Contains("AddRow must be called before AddCell", ex.Message); + } + + } +}