Save As Excel (#279)

* Fix dispose pattern usage in SaveAsWriterBase

* Add SaveAsExcel feature

This adds the save as excel function to the backend. To reduce large dependency and run on dotnet core now, this implementation use a raw excel writer (the SaveAsExcelFileStreamWriterHelper.cs) instrad of popular excel library, such as EPPlus or OpenXmlSdk.

* Fix can not open the generated excel file in google sheet

For the file name inside excel, google uses a case sensitive path while
Excel doesn't. This change fix the case, so that the file name matches
the one in x1/_rels/workbook.xml.rels

* Fix datetime doesn't recognized by google sheet

Google doesn't support cell type t="d" with ISO 8601 date. (From stackoverflow thread and testing), thus use the old way of excel datetime, which uses double to present datetime

* update to use xmlwriter

* Add basic unit tests for SaveAsExcelFileStreamWriterHelper

* refactor: simplify the public interface of the SaveAsExcelFileStreamWriterHelper

* update private fields names based on the name convention

* Add comments to classes of SaveAsExcel feature

* clean up SaveAsExcelFileStreamWriterHelper

- change SaveAsExcelFileStreamWriterHelper from public to internal
- remove the PenddingRowEndTag function from referenceManager
- change the SaveAsExcelFileStreamWriterHelper(stream) to default leaveOpen to false to match the normal behavior
- change the rowreference to use XmlConvert to convert int to string
- rename writeSetting to writerSetting and add private

* fix CI test error for SaveAsExcel

* remove ExporterException in SaveAsExcel

* fix lefe over CSV to Excel in the comments

* refactor to be consistent with JsonWriter and remove the comment

* remove commented out test

The test is too slow to run

* fix typo in comment

* refactor SaveAsExcel to the coding standard

* refactor rewrite the WriteStyle with XmlWriter

* Add licence header

* reverse mistakenly checked-in changes

* fix: left-over CSV in commets

* remove duplicate check

The check was done in the IncreaseColumnReference, but that check is too late in case of too many columns. All the addCell do the check at the begining now

* fix TimeSpan more than 24 hours

* fix AddRowMustBeCalledBeforeAddCellException test

This is  due to remove duplicate call to AssureColumnReference in  WriteAndIncreaseColumnReference

* fix: TimeSpan will write twice

* style: change retun in the switch to break

* Add bool format

* remove todo in comment

This provides extra safeguard in the cost of one memory access when null.
This commit is contained in:
Wujun Zhou
2017-03-16 17:33:12 -04:00
committed by Benjamin Russell
parent 8d017368f9
commit 8d47d5c7b3
14 changed files with 1364 additions and 15 deletions

View File

@@ -80,6 +80,17 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution.Contracts
public bool IncludeHeaders { get; set; }
}
/// <summary>
/// Parameters to save results as Excel
/// </summary>
public class SaveResultsAsExcelRequestParams : SaveResultsRequestParams
{
/// <summary>
/// Include headers of columns in Excel
/// </summary>
public bool IncludeHeaders { get; set; }
}
/// <summary>
/// Parameters to save results as JSON
/// </summary>
@@ -120,6 +131,16 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution.Contracts
RequestType<SaveResultsAsCsvRequestParams, SaveResultRequestResult>.Create("query/saveCsv");
}
/// <summary>
/// Request type to save results as Excel
/// </summary>
public class SaveResultsAsExcelRequest
{
public static readonly
RequestType<SaveResultsAsExcelRequestParams, SaveResultRequestResult> Type =
RequestType<SaveResultsAsExcelRequestParams, SaveResultRequestResult>.Create("query/saveExcel");
}
/// <summary>
/// Request type to save results as JSON
/// </summary>

View File

@@ -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
{
/// <summary>
/// Factory for creating a reader/writer pair that will read from the temporary buffer file
/// and output to a Excel file.
/// </summary>
public class SaveAsExcelFileStreamFactory : IFileStreamFactory
{
#region Properties
/// <summary>
/// Settings for query execution
/// </summary>
public QueryExecutionSettings QueryExecutionSettings { get; set; }
/// <summary>
/// Parameters for the save as Excel request
/// </summary>
public SaveResultsAsExcelRequestParams SaveRequestParams { get; set; }
#endregion
/// <summary>
/// File names are not meant to be created with this factory.
/// </summary>
/// <exception cref="NotImplementedException">Thrown all times</exception>
[Obsolete]
public string CreateFile()
{
throw new NotImplementedException();
}
/// <summary>
/// Returns a new service buffer reader for reading results back in from the temporary buffer files
/// </summary>
/// <param name="fileName">Path to the temp buffer file</param>
/// <returns>Stream reader</returns>
public IFileStreamReader GetReader(string fileName)
{
return new ServiceBufferFileStreamReader(new FileStream(fileName, FileMode.Open, FileAccess.Read), QueryExecutionSettings);
}
/// <summary>
/// Returns a new Excel writer for writing results to a Excel file
/// </summary>
/// <param name="fileName">Path to the Excel output file</param>
/// <returns>Stream writer</returns>
public IFileStreamWriter GetWriter(string fileName)
{
return new SaveAsExcelFileStreamWriter(new FileStream(fileName, FileMode.Create, FileAccess.ReadWrite), SaveRequestParams);
}
/// <summary>
/// Safely deletes the file
/// </summary>
/// <param name="fileName">Path to the file to delete</param>
public void DisposeFile(string fileName)
{
FileUtilities.SafeFileDelete(fileName);
}
}
}

View File

@@ -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
{
/// <summary>
/// Writer for writing rows of results to a Excel file
/// </summary>
public class SaveAsExcelFileStreamWriter : SaveAsStreamWriter
{
#region Member Variables
private readonly SaveResultsAsExcelRequestParams saveParams;
private bool headerWritten;
private SaveAsExcelFileStreamWriterHelper helper;
private SaveAsExcelFileStreamWriterHelper.ExcelSheet sheet;
#endregion
/// <summary>
/// Constructor, stores the Excel specific request params locally, chains into the base
/// constructor
/// </summary>
/// <param name="stream">FileStream to access the Excel file output</param>
/// <param name="requestParams">Excel save as request parameters</param>
public SaveAsExcelFileStreamWriter(Stream stream, SaveResultsAsExcelRequestParams requestParams)
: base(stream, requestParams)
{
saveParams = requestParams;
helper = new SaveAsExcelFileStreamWriterHelper(stream);
sheet = helper.AddSheet();
}
/// <summary>
/// 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.
/// </summary>
/// <param name="row">The data of the row to output to the file</param>
/// <param name="columns">
/// The entire list of columns for the result set. They will be filtered down as per the
/// request params.
/// </param>
public override void WriteRow(IList<DbCellValue> row, IList<DbColumnWrapper> 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);
}
}
}

View File

@@ -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
/// <summary>
/// A helper class for write xlsx file base on ECMA-376. It tries to be minimal,
/// both in implementation and runtime allocation.
/// </summary>
/// <example>
/// This sample shows how to use the class
/// <code>
/// 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");
/// }
/// }
/// }
/// </code>
/// </example>
internal sealed class SaveAsExcelFileStreamWriterHelper : IDisposable
{
/// <summary>
/// Present a Excel sheet
/// </summary>
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;
/// <summary>
/// Initializes a new instance of the ExcelSheet class.
/// </summary>
/// <param name="writer">XmlWriter to write the sheet data</param>
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);
}
/// <summary>
/// Start a new row
/// </summary>
public void AddRow()
{
EndRowIfNeeded();
hasOpenRowTag = true;
referenceManager.AssureRowReference();
writer.WriteStartElement("row");
referenceManager.WriteAndIncreaseRowReference();
}
/// <summary>
/// Write a string cell
/// </summary>
/// <param name="value">string value to write</param>
public void AddCell(string value)
{
// string needs <c t="inlineStr"><is><t>string</t></is></c>
// 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();
}
/// <summary>
/// Write a object cell
/// </summary>
/// The program will try to output number/datetime, otherwise, call the ToString
/// <param name="o"></param>
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;
}
}
/// <summary>
/// Close the <row><sheetData><worksheet> tags and close the stream
/// </summary>
public void Dispose()
{
EndRowIfNeeded();
writer.WriteEndElement(); // <sheetData>
writer.WriteEndElement(); // <worksheet>
writer.Dispose();
}
/// <summary>
/// Write a empty cell
/// </summary>
/// This only increases the internal bookmark and doesn't arcturally write out anything.
private void AddCellEmpty()
{
referenceManager.IncreaseColumnReference();
}
/// <summary>
/// Write a bool cell.
/// </summary>
/// <param name="time"></param>
private void AddCell(bool value)
{
// Excel FALSE: <c r="A1" t="b"><v>0</v></c>
// Excel TRUE: <c r="A1" t="b"><v>1</v></c>
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();
}
/// <summary>
/// Write a TimeSpan cell.
/// </summary>
/// <param name="time"></param>
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);
}
}
/// <summary>
/// Write a DateTime cell.
/// </summary>
/// <param name="dateTime">Datetime</param>
/// <remark>
/// 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
/// </remark>
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 <c r="A1"><v>12.5</v></c>
private void AddCellBoxedNumber(object number)
{
referenceManager.AssureColumnReference();
writer.WriteStartElement("c");
referenceManager.WriteAndIncreaseColumnReference();
writer.WriteStartElement("v");
writer.WriteValue(number);
writer.WriteEndElement();
writer.WriteEndElement();
}
// datetime needs <c r="A1" s="2"><v>26012.451</v></c>
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(); // <row>
}
}
}
/// <summary>
/// Helper class to track the current cell reference.
/// </summary>
/// <remarks>
/// SpreadsheetML cell needs a reference attribute. (e.g. r="A1"). This class is used
/// to track the current cell reference.
/// </remarks>
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;
/// <summary>
/// Initializes a new instance of the ReferenceManager class.
/// </summary>
/// <param name="writer">XmlWriter to write the reference attribute to.</param>
public ReferenceManager(XmlWriter writer)
{
this.writer = writer;
}
/// <summary>
/// Check that we have not write too many columns. (xlsx has a limit of 16384 columns)
/// </summary>
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");
}
}
/// <summary>
/// Write out the r="A1" attribute and increase the column number of internal bookmark
/// </summary>
public void WriteAndIncreaseColumnReference()
{
writer.WriteStartAttribute("r");
writer.WriteChars(currReference, 3 - currReferenceColumnLength, currReferenceRowLength + currReferenceColumnLength);
writer.WriteEndAttribute();
IncreaseColumnReference();
}
/// <summary>
/// Increase the column of internal bookmark.
/// </summary>
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;
}
}
}
/// <summary>
/// Check that we have not write too many rows. (xlsx has a limit of 1048576 rows)
/// </summary>
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");
}
}
/// <summary>
/// Write out the r="1" attribute and increase the row number of internal bookmark
/// </summary>
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<string> sheetNames = new List<string>();
private XmlWriterSettings writerSetting = new XmlWriterSettings()
{
CloseOutput = true,
};
/// <summary>
/// Initializes a new instance of the SaveAsExcelFileStreamWriterHelper class.
/// </summary>
/// <param name="stream">The input or output stream.</param>
public SaveAsExcelFileStreamWriterHelper(Stream stream)
{
zipArchive = new ZipArchive(stream, ZipArchiveMode.Create, false);
}
/// <summary>
/// Initializes a new instance of the SaveAsExcelFileStreamWriterHelper class.
/// </summary>
/// <param name="stream">The input or output stream.</param>
/// <param name="leaveOpen">true to leave the stream open after the
/// SaveAsExcelFileStreamWriterHelper object is disposed; otherwise, false.</param>
public SaveAsExcelFileStreamWriterHelper(Stream stream, bool leaveOpen)
{
zipArchive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen);
}
/// <summary>
/// Add sheet inside the Xlsx file.
/// </summary>
/// <param name="sheetName">Sheet name</param>
/// <returns>ExcelSheet for writing the sheet content</returns>
/// <remarks>
/// When the sheetName is null, sheet1,shhet2,..., will be used.
/// The following charactors are not allowed in the sheetName
/// '\', '/','*','[',']',':','?'
/// </remarks>
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);
}
/// <summary>
/// Write out the rest of the xlsx files and release the resources used by the current instance
/// </summary>
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();
}
/// <summary>
/// write [Content_Types].xml
/// </summary>
/// <remarks>
/// This file need to describe all the files in the zip.
/// </remarks>
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();
}
}
/// <summary>
/// Write _rels/.rels. This file only need to reference main workbook
/// </summary>
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}");
}
}
/// <summary>
/// Write xl/workbook.xml. This file will references the sheets through ids in xl/_rels/workbook.xml.rels
/// </summary>
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();
}
}
/// <summary>
/// Write xl/_rels/workbook.xml.rels. This file will have the paths of the style and sheets.
/// </summary>
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)
// <c>(in sheet1.xml) --> (by s) <cellXfs> --> (by xfId) <cellStyleXfs>
// --> (by numFmtId) <numFmts>
// that is <c s="1"></c> will reference the second element of <cellXfs> <xf numFmtId=""162"" xfId=""0"" applyNumberFormat=""1""/>
// 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
}
}
}
}

View File

@@ -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;
/// <summary>
/// Disposes the writer by closing up the array that contains the row objects
/// </summary>
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);
}
}
}

View File

@@ -100,17 +100,19 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution.DataStorage
/// Disposes the instance by flushing and closing the file stream
/// </summary>
/// <param name="disposing"></param>
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);

View File

@@ -83,6 +83,12 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution
/// </summary>
internal IFileStreamFactory CsvFileFactory { get; set; }
/// <summary>
/// File factory to be used to create Excel files from result sets. Set to internal in order
/// to allow overriding in unit testing
/// </summary>
internal IFileStreamFactory ExcelFileFactory { get; set; }
/// <summary>
/// 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);
}
/// <summary>
/// Process request to save a resultSet to a file in Excel format
/// </summary>
internal async Task HandleSaveResultsAsExcelRequest(SaveResultsAsExcelRequestParams saveParams,
RequestContext<SaveResultRequestResult> 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);
}
/// <summary>
/// Process request to save a resultSet to a file in JSON format
/// </summary>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml" />
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml" />
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml" />
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml" />
</Types>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml" />
</Relationships>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId0" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml" />
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml" />
</Relationships>

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<numFmts count="4">
<numFmt numFmtId="166" formatCode="yyyy-mm-dd" />
<numFmt numFmtId="167" formatCode="hh:mm:ss" />
<numFmt numFmtId="168" formatCode="yyyy-mm-dd hh:mm:ss" />
<numFmt numFmtId="169" formatCode="[h]:mm:ss" />
</numFmts>
<fonts count="1">
<font>
<sz val="11" />
<color theme="1" />
<name val="Calibri" />
<family val="2" />
<scheme val="minor" />
</font>
</fonts>
<fills count="1">
<fill>
<patternFill patternType="none" />
</fill>
</fills>
<borders count="1">
<border>
<left />
<right />
<top />
<bottom />
<diagonal />
</border>
</borders>
<cellStyleXfs count="1">
<xf numFmtId="0" fontId="0" fillId="0" borderId="0" />
</cellStyleXfs>
<cellXfs count="5">
<xf xfId="0" />
<xf numFmtId="166" xfId="0" applyNumberFormat="1" />
<xf numFmtId="167" xfId="0" applyNumberFormat="1" />
<xf numFmtId="168" xfId="0" applyNumberFormat="1" />
<xf numFmtId="169" xfId="0" applyNumberFormat="1" />
</cellXfs>
<cellStyles count="1">
<cellStyle name="Normal" builtinId="0" xfId="0" />
</cellStyles>
</styleSheet>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<workbook xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheets>
<sheet name="sheet1" sheetId="1" r:id="rId1" />
</sheets>
</workbook>

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<worksheet xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheetData>
<row r="1">
<c r="B1" t="inlineStr"><is><t></t></is></c>
<c r="C1" t="inlineStr"><is><t>test string</t></is></c>
<c r="D1"><v>3</v></c>
<c r="E1"><v>3.5</v></c>
<c r="F1" t="b"><v>0</v></c>
<c r="G1" t="b"><v>1</v></c>
</row>
<row r="2">
<c r="A2" t="inlineStr"><is><t>1900-02-28</t></is></c>
<c r="B2" s="1"><v>61</v></c>
<c r="C2" s="3"><v>61.625</v></c>
<c r="D2" s="2"><v>0.625</v></c>
<c r="E2" s="2"><v>0.625</v></c>
<c r="F2" s="4"><v>1</v></c>
</row>
</sheetData>
</worksheet>

View File

@@ -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<XmlWriter> _xmlWriterMock;
private string LastWrittenReference { get; set; }
private int LastWrittenRow { get; set; }
public SaveAsExcelFileStreamWriterHelperReferenceManagerTests()
{
_xmlWriterMock = new Mock<XmlWriter>(MockBehavior.Strict);
_xmlWriterMock
.Setup(_xmlWriter => _xmlWriter.WriteChars(
It.IsAny<char[]>(), It.IsAny<int>(), It.IsAny<int>()))
.Callback<char[], int, int>((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<int>()))
.Callback<int>(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<InvalidOperationException>(
() => 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<InvalidOperationException>(
() => manager.AssureColumnReference());
Assert.Contains("AddRow must be called before AddCell", ex.Message);
}
}
}