1033 lines
34 KiB
C#
1033 lines
34 KiB
C#
|
|
using System;
|
|||
|
|
using System.Collections.Generic;
|
|||
|
|
using System.IO;
|
|||
|
|
using System.Linq;
|
|||
|
|
using System.Text;
|
|||
|
|
using ClosedXML.Excel;
|
|||
|
|
using TBF.Rig.Output.DataStorage.UniDataStorageWriter.Diagnostic;
|
|||
|
|
using TBF.Rig.Output.DataStorage.UniDataStorageWriter.Interfaces;
|
|||
|
|
using static TBF.Rig.Output.DataStorage.UniDataStorageWriter.Types;
|
|||
|
|
|
|||
|
|
namespace TBF.Rig.Output.DataStorage.UniDataStorageWriter.Writers
|
|||
|
|
{
|
|||
|
|
/// <summary>
|
|||
|
|
/// Provides data writing support for Microsoft Excel Open XML files.
|
|||
|
|
/// </summary>
|
|||
|
|
/// <remarks>
|
|||
|
|
/// <para>
|
|||
|
|
/// The writer supports files using the <c>.xlsx</c> extension.
|
|||
|
|
/// Microsoft Excel does not need to be installed on the target computer.
|
|||
|
|
/// </para>
|
|||
|
|
/// <para>
|
|||
|
|
/// The first row of the first worksheet is used as the column header row.
|
|||
|
|
/// Insert operations append a new row to the worksheet. Update operations
|
|||
|
|
/// locate rows by a specified column value and update the requested cell.
|
|||
|
|
/// </para>
|
|||
|
|
/// </remarks>
|
|||
|
|
public class XlsxWriter : IDataStorageWriter
|
|||
|
|
{
|
|||
|
|
private const string DefaultWorksheetName = "Data";
|
|||
|
|
|
|||
|
|
private readonly WriterCfg cfg;
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Initializes a new instance of the <see cref="XlsxWriter"/> class.
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="cfg">
|
|||
|
|
/// Writer configuration containing the path of the target Excel file.
|
|||
|
|
/// </param>
|
|||
|
|
/// <exception cref="ArgumentNullException">
|
|||
|
|
/// Thrown when <paramref name="cfg"/> is <see langword="null"/>.
|
|||
|
|
/// </exception>
|
|||
|
|
public XlsxWriter(WriterCfg cfg)
|
|||
|
|
{
|
|||
|
|
this.cfg = cfg ?? throw new ArgumentNullException(nameof(cfg));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Gets the storage types, technology types and write modes supported
|
|||
|
|
/// by this writer.
|
|||
|
|
/// </summary>
|
|||
|
|
/// <value>
|
|||
|
|
/// The capabilities of the XLSX writer.
|
|||
|
|
/// </value>
|
|||
|
|
public WriterCapabilities Capabilities
|
|||
|
|
{
|
|||
|
|
get
|
|||
|
|
{
|
|||
|
|
WriterCapabilities caps = new WriterCapabilities();
|
|||
|
|
|
|||
|
|
caps.SupportedStorageTypes.Add(StorageTypes.LocalFile);
|
|||
|
|
caps.SupportedStorageTypes.Add(StorageTypes.RemoteFile);
|
|||
|
|
|
|||
|
|
caps.SupportedTechnologyTypes.Add(TechnologyTypes.Xlsx);
|
|||
|
|
|
|||
|
|
caps.SupportedWriteModes.Add(WriteMode.Insert);
|
|||
|
|
caps.SupportedWriteModes.Add(WriteMode.Update);
|
|||
|
|
|
|||
|
|
return caps;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Validates the configured XLSX source.
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="validateOnly">
|
|||
|
|
/// <see langword="true"/> to validate the configured path without creating
|
|||
|
|
/// missing directories or files; otherwise, <see langword="false"/>.
|
|||
|
|
/// </param>
|
|||
|
|
/// <returns>
|
|||
|
|
/// A diagnostic result describing whether the XLSX source is valid
|
|||
|
|
/// and accessible.
|
|||
|
|
/// </returns>
|
|||
|
|
/// <remarks>
|
|||
|
|
/// When <paramref name="validateOnly"/> is <see langword="false"/>,
|
|||
|
|
/// a missing directory and XLSX file are created automatically.
|
|||
|
|
/// </remarks>
|
|||
|
|
public WriterDiagnosticResult TestSource(bool validateOnly)
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
WriterDiagnosticResult pathValidation = ValidateDataSource();
|
|||
|
|
if (!pathValidation.Success)
|
|||
|
|
return pathValidation;
|
|||
|
|
|
|||
|
|
string path = cfg.DataSource;
|
|||
|
|
string directory = Path.GetDirectoryName(path);
|
|||
|
|
|
|||
|
|
if (!string.IsNullOrWhiteSpace(directory) &&
|
|||
|
|
!Directory.Exists(directory))
|
|||
|
|
{
|
|||
|
|
if (validateOnly)
|
|||
|
|
return Fail("Directory does not exist: " + directory);
|
|||
|
|
|
|||
|
|
Directory.CreateDirectory(directory);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!File.Exists(path))
|
|||
|
|
{
|
|||
|
|
if (validateOnly)
|
|||
|
|
return Ok("XLSX file does not exist but the path is valid.");
|
|||
|
|
|
|||
|
|
CreateEmptyWorkbook(path);
|
|||
|
|
|
|||
|
|
return Ok("XLSX source was created successfully.");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
using (XLWorkbook workbook = new XLWorkbook(path))
|
|||
|
|
{
|
|||
|
|
if (!workbook.Worksheets.Any())
|
|||
|
|
return Fail("XLSX file does not contain any worksheet.");
|
|||
|
|
|
|||
|
|
IXLWorksheet worksheet = workbook.Worksheet(1);
|
|||
|
|
|
|||
|
|
if (worksheet == null)
|
|||
|
|
return Fail("The first XLSX worksheet could not be opened.");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return Ok("XLSX source is ready.");
|
|||
|
|
}
|
|||
|
|
catch (IOException ex)
|
|||
|
|
{
|
|||
|
|
return Fail(
|
|||
|
|
"XLSX source cannot be accessed. The file may be opened " +
|
|||
|
|
"or locked by another process: " + ex.Message);
|
|||
|
|
}
|
|||
|
|
catch (UnauthorizedAccessException ex)
|
|||
|
|
{
|
|||
|
|
return Fail(
|
|||
|
|
"Access to the XLSX source was denied: " + ex.Message);
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
return Fail("XLSX source test failed: " + ex.Message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Writes data to the configured XLSX file.
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="request">
|
|||
|
|
/// Request containing the write mode and the data to be written.
|
|||
|
|
/// </param>
|
|||
|
|
/// <returns>
|
|||
|
|
/// A diagnostic result describing the outcome of the write operation.
|
|||
|
|
/// </returns>
|
|||
|
|
/// <exception cref="ArgumentNullException">
|
|||
|
|
/// Thrown when <paramref name="request"/> is <see langword="null"/>.
|
|||
|
|
/// </exception>
|
|||
|
|
public WriterDiagnosticResult WriteData(DataWriteRequest request)
|
|||
|
|
{
|
|||
|
|
if (request == null)
|
|||
|
|
throw new ArgumentNullException(nameof(request));
|
|||
|
|
|
|||
|
|
WriterDiagnosticResult sourceTest = TestSource(false);
|
|||
|
|
if (!sourceTest.Success)
|
|||
|
|
return sourceTest;
|
|||
|
|
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
switch (request.Mode)
|
|||
|
|
{
|
|||
|
|
case WriteMode.Insert:
|
|||
|
|
return ExecuteInsert(request);
|
|||
|
|
|
|||
|
|
case WriteMode.Update:
|
|||
|
|
return ExecuteUpdate(request);
|
|||
|
|
|
|||
|
|
default:
|
|||
|
|
return Fail(
|
|||
|
|
"XLSX mode is not supported: " + request.Mode);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
catch (IOException ex)
|
|||
|
|
{
|
|||
|
|
return Fail(
|
|||
|
|
"XLSX file cannot be written. The file may be opened " +
|
|||
|
|
"or locked by another process: " + ex.Message);
|
|||
|
|
}
|
|||
|
|
catch (UnauthorizedAccessException ex)
|
|||
|
|
{
|
|||
|
|
return Fail(
|
|||
|
|
"Access to the XLSX file was denied: " + ex.Message);
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
return Fail("XLSX write failed: " + ex.Message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Appends one data row to the first worksheet.
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="request">
|
|||
|
|
/// Request containing the insert items.
|
|||
|
|
/// </param>
|
|||
|
|
/// <returns>
|
|||
|
|
/// A diagnostic result describing the inserted row.
|
|||
|
|
/// </returns>
|
|||
|
|
/// <remarks>
|
|||
|
|
/// <para>
|
|||
|
|
/// When the worksheet does not yet contain headers, the first row is
|
|||
|
|
/// automatically created from the insert item column names.
|
|||
|
|
/// </para>
|
|||
|
|
/// <para>
|
|||
|
|
/// When headers already exist, every insert item column must be present
|
|||
|
|
/// in the header row.
|
|||
|
|
/// </para>
|
|||
|
|
/// </remarks>
|
|||
|
|
private WriterDiagnosticResult ExecuteInsert(DataWriteRequest request)
|
|||
|
|
{
|
|||
|
|
if (request.InsertItems == null ||
|
|||
|
|
request.InsertItems.Count == 0)
|
|||
|
|
{
|
|||
|
|
return Fail("No insert items provided.");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (request.InsertItems.Any(i => i == null))
|
|||
|
|
return Fail("Insert items contain a null item.");
|
|||
|
|
|
|||
|
|
if (request.InsertItems.Any(
|
|||
|
|
i => string.IsNullOrWhiteSpace(i.ColumnName)))
|
|||
|
|
{
|
|||
|
|
return Fail("Insert item contains an empty column name.");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
string[] duplicateColumns = request.InsertItems
|
|||
|
|
.GroupBy(
|
|||
|
|
i => i.ColumnName,
|
|||
|
|
StringComparer.OrdinalIgnoreCase)
|
|||
|
|
.Where(g => g.Count() > 1)
|
|||
|
|
.Select(g => g.Key)
|
|||
|
|
.ToArray();
|
|||
|
|
|
|||
|
|
if (duplicateColumns.Length > 0)
|
|||
|
|
{
|
|||
|
|
return Fail(
|
|||
|
|
"Insert items contain duplicate columns: " +
|
|||
|
|
string.Join(", ", duplicateColumns));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
using (XLWorkbook workbook = OpenWorkbook())
|
|||
|
|
{
|
|||
|
|
string template = cfg.GetTemplate(request.Mode);
|
|||
|
|
string worksheetName = ParseWorksheetName(template);
|
|||
|
|
|
|||
|
|
IXLWorksheet worksheet = GetOrCreateWorksheet(
|
|||
|
|
workbook,
|
|||
|
|
worksheetName);
|
|||
|
|
|
|||
|
|
Dictionary<string, int> columns =
|
|||
|
|
GetOrCreateHeaderColumns(
|
|||
|
|
worksheet,
|
|||
|
|
request.InsertItems.Select(i => i.ColumnName));
|
|||
|
|
|
|||
|
|
string[] missingColumns = request.InsertItems
|
|||
|
|
.Select(i => i.ColumnName)
|
|||
|
|
.Where(name => !columns.ContainsKey(name))
|
|||
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|||
|
|
.ToArray();
|
|||
|
|
|
|||
|
|
if (missingColumns.Length > 0)
|
|||
|
|
{
|
|||
|
|
return Fail(
|
|||
|
|
"XLSX worksheet does not contain columns: " +
|
|||
|
|
string.Join(", ", missingColumns));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
int targetRowNumber = GetNextDataRowNumber(worksheet);
|
|||
|
|
IXLRow targetRow = worksheet.Row(targetRowNumber);
|
|||
|
|
|
|||
|
|
foreach (InsertWriteItem item in request.InsertItems)
|
|||
|
|
{
|
|||
|
|
int columnNumber = columns[item.ColumnName];
|
|||
|
|
|
|||
|
|
SetCellValue(
|
|||
|
|
targetRow.Cell(columnNumber),
|
|||
|
|
item.Value);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
workbook.Save();
|
|||
|
|
|
|||
|
|
string diagnostic = BuildInsertDiagnostic(
|
|||
|
|
worksheet.Name,
|
|||
|
|
targetRowNumber,
|
|||
|
|
request.InsertItems);
|
|||
|
|
|
|||
|
|
return new WriterDiagnosticResult
|
|||
|
|
{
|
|||
|
|
Success = true,
|
|||
|
|
Message = "XLSX insert OK. Written 1 row.",
|
|||
|
|
ExecutedTemplate = diagnostic
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Updates cells in rows matching the specified conditions.
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="request">
|
|||
|
|
/// Request containing the update items.
|
|||
|
|
/// </param>
|
|||
|
|
/// <returns>
|
|||
|
|
/// A diagnostic result containing the number of updated rows.
|
|||
|
|
/// </returns>
|
|||
|
|
/// <remarks>
|
|||
|
|
/// Each update item is processed separately. All rows whose value in
|
|||
|
|
/// <see cref="UpdateWriteItem.WhereParameterName"/> equals
|
|||
|
|
/// <see cref="UpdateWriteItem.WhereValue"/> are updated.
|
|||
|
|
/// String comparison is case-sensitive.
|
|||
|
|
/// </remarks>
|
|||
|
|
private WriterDiagnosticResult ExecuteUpdate(DataWriteRequest request)
|
|||
|
|
{
|
|||
|
|
if (request.UpdateItems == null ||
|
|||
|
|
request.UpdateItems.Count == 0)
|
|||
|
|
{
|
|||
|
|
return Fail("No update items provided.");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (request.UpdateItems.Any(i => i == null))
|
|||
|
|
return Fail("Update items contain a null item.");
|
|||
|
|
|
|||
|
|
using (XLWorkbook workbook = OpenWorkbook())
|
|||
|
|
{
|
|||
|
|
string template = cfg.GetTemplate(request.Mode);
|
|||
|
|
string worksheetName = ParseWorksheetName(template);
|
|||
|
|
|
|||
|
|
IXLWorksheet worksheet = GetOrCreateWorksheet(
|
|||
|
|
workbook,
|
|||
|
|
worksheetName);
|
|||
|
|
|
|||
|
|
Dictionary<string, int> columns =
|
|||
|
|
GetHeaderColumns(worksheet);
|
|||
|
|
|
|||
|
|
if (columns.Count == 0)
|
|||
|
|
return Fail("XLSX worksheet does not contain a header row.");
|
|||
|
|
|
|||
|
|
int totalUpdatedRows = 0;
|
|||
|
|
StringBuilder diagnostics = new StringBuilder();
|
|||
|
|
|
|||
|
|
foreach (UpdateWriteItem item in request.UpdateItems)
|
|||
|
|
{
|
|||
|
|
WriterDiagnosticResult validation =
|
|||
|
|
ValidateUpdateItem(item, columns);
|
|||
|
|
|
|||
|
|
if (!validation.Success)
|
|||
|
|
return validation;
|
|||
|
|
|
|||
|
|
int whereColumnNumber =
|
|||
|
|
columns[item.WhereParameterName];
|
|||
|
|
|
|||
|
|
int setColumnNumber =
|
|||
|
|
columns[item.SetParameterName];
|
|||
|
|
|
|||
|
|
int updatedRows = UpdateMatchingRows(
|
|||
|
|
worksheet,
|
|||
|
|
whereColumnNumber,
|
|||
|
|
item.WhereValue,
|
|||
|
|
setColumnNumber,
|
|||
|
|
item.SetValue);
|
|||
|
|
|
|||
|
|
totalUpdatedRows += updatedRows;
|
|||
|
|
|
|||
|
|
diagnostics.AppendLine(
|
|||
|
|
BuildUpdateDiagnostic(
|
|||
|
|
worksheet.Name,
|
|||
|
|
item,
|
|||
|
|
updatedRows));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
workbook.Save();
|
|||
|
|
|
|||
|
|
return new WriterDiagnosticResult
|
|||
|
|
{
|
|||
|
|
Success = true,
|
|||
|
|
Message =
|
|||
|
|
"XLSX update OK. Rows: " + totalUpdatedRows,
|
|||
|
|
ExecutedTemplate =
|
|||
|
|
diagnostics.ToString().TrimEnd()
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Validates the configured data source path and file extension.
|
|||
|
|
/// </summary>
|
|||
|
|
/// <returns>
|
|||
|
|
/// A successful result when the data source is valid; otherwise,
|
|||
|
|
/// a failed diagnostic result.
|
|||
|
|
/// </returns>
|
|||
|
|
private WriterDiagnosticResult ValidateDataSource()
|
|||
|
|
{
|
|||
|
|
if (string.IsNullOrWhiteSpace(cfg.DataSource))
|
|||
|
|
return Fail("XLSX file path is not defined.");
|
|||
|
|
|
|||
|
|
string extension = Path.GetExtension(cfg.DataSource);
|
|||
|
|
|
|||
|
|
if (!string.Equals(
|
|||
|
|
extension,
|
|||
|
|
".xlsx",
|
|||
|
|
StringComparison.OrdinalIgnoreCase))
|
|||
|
|
{
|
|||
|
|
return Fail(
|
|||
|
|
"Invalid file extension. Expected .xlsx.");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return Ok("XLSX data source is valid.");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Opens the configured workbook.
|
|||
|
|
/// </summary>
|
|||
|
|
/// <returns>
|
|||
|
|
/// An opened <see cref="XLWorkbook"/>.
|
|||
|
|
/// </returns>
|
|||
|
|
private XLWorkbook OpenWorkbook()
|
|||
|
|
{
|
|||
|
|
return new XLWorkbook(cfg.DataSource);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Creates an empty workbook containing the default worksheet.
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="path">
|
|||
|
|
/// Destination path of the workbook.
|
|||
|
|
/// </param>
|
|||
|
|
private void CreateEmptyWorkbook(string path)
|
|||
|
|
{
|
|||
|
|
using (XLWorkbook workbook = new XLWorkbook())
|
|||
|
|
{
|
|||
|
|
workbook.Worksheets.Add(DefaultWorksheetName);
|
|||
|
|
workbook.SaveAs(path);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Gets the first worksheet from the specified workbook.
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="workbook">
|
|||
|
|
/// Workbook containing the worksheet.
|
|||
|
|
/// </param>
|
|||
|
|
/// <returns>
|
|||
|
|
/// The first worksheet in the workbook.
|
|||
|
|
/// </returns>
|
|||
|
|
/// <exception cref="InvalidOperationException">
|
|||
|
|
/// Thrown when the workbook does not contain a worksheet.
|
|||
|
|
/// </exception>
|
|||
|
|
private IXLWorksheet GetFirstWorksheet(XLWorkbook workbook)
|
|||
|
|
{
|
|||
|
|
if (workbook == null)
|
|||
|
|
throw new ArgumentNullException(nameof(workbook));
|
|||
|
|
|
|||
|
|
IXLWorksheet worksheet =
|
|||
|
|
workbook.Worksheets.FirstOrDefault();
|
|||
|
|
|
|||
|
|
if (worksheet == null)
|
|||
|
|
{
|
|||
|
|
throw new InvalidOperationException(
|
|||
|
|
"XLSX workbook does not contain any worksheet.");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return worksheet;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Reads header names from the first worksheet row.
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="worksheet">
|
|||
|
|
/// Worksheet containing the header row.
|
|||
|
|
/// </param>
|
|||
|
|
/// <returns>
|
|||
|
|
/// A case-insensitive dictionary mapping column names to their
|
|||
|
|
/// one-based worksheet column numbers.
|
|||
|
|
/// </returns>
|
|||
|
|
private Dictionary<string, int> GetHeaderColumns(
|
|||
|
|
IXLWorksheet worksheet)
|
|||
|
|
{
|
|||
|
|
Dictionary<string, int> columns =
|
|||
|
|
new Dictionary<string, int>(
|
|||
|
|
StringComparer.OrdinalIgnoreCase);
|
|||
|
|
|
|||
|
|
IXLRow headerRow = worksheet.Row(1);
|
|||
|
|
IXLCell lastUsedCell = headerRow.LastCellUsed();
|
|||
|
|
|
|||
|
|
if (lastUsedCell == null)
|
|||
|
|
return columns;
|
|||
|
|
|
|||
|
|
int lastColumnNumber = lastUsedCell.Address.ColumnNumber;
|
|||
|
|
|
|||
|
|
for (int columnNumber = 1;
|
|||
|
|
columnNumber <= lastColumnNumber;
|
|||
|
|
columnNumber++)
|
|||
|
|
{
|
|||
|
|
string columnName = headerRow
|
|||
|
|
.Cell(columnNumber)
|
|||
|
|
.GetFormattedString()
|
|||
|
|
.Trim();
|
|||
|
|
|
|||
|
|
if (string.IsNullOrWhiteSpace(columnName))
|
|||
|
|
continue;
|
|||
|
|
|
|||
|
|
if (columns.ContainsKey(columnName))
|
|||
|
|
{
|
|||
|
|
throw new InvalidOperationException(
|
|||
|
|
"XLSX header contains duplicate column: " +
|
|||
|
|
columnName);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
columns.Add(columnName, columnNumber);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return columns;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Reads existing worksheet headers or creates them when the worksheet
|
|||
|
|
/// is empty.
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="worksheet">
|
|||
|
|
/// Worksheet containing the header row.
|
|||
|
|
/// </param>
|
|||
|
|
/// <param name="requiredColumnNames">
|
|||
|
|
/// Column names required by the insert operation.
|
|||
|
|
/// </param>
|
|||
|
|
/// <returns>
|
|||
|
|
/// A dictionary mapping column names to worksheet column numbers.
|
|||
|
|
/// </returns>
|
|||
|
|
private Dictionary<string, int> GetOrCreateHeaderColumns(
|
|||
|
|
IXLWorksheet worksheet,
|
|||
|
|
IEnumerable<string> requiredColumnNames)
|
|||
|
|
{
|
|||
|
|
Dictionary<string, int> columns =
|
|||
|
|
GetHeaderColumns(worksheet);
|
|||
|
|
|
|||
|
|
if (columns.Count > 0)
|
|||
|
|
return columns;
|
|||
|
|
|
|||
|
|
string[] columnNames = requiredColumnNames
|
|||
|
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
|||
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|||
|
|
.ToArray();
|
|||
|
|
|
|||
|
|
for (int index = 0; index < columnNames.Length; index++)
|
|||
|
|
{
|
|||
|
|
int columnNumber = index + 1;
|
|||
|
|
|
|||
|
|
worksheet.Cell(1, columnNumber).Value =
|
|||
|
|
columnNames[index];
|
|||
|
|
|
|||
|
|
columns.Add(
|
|||
|
|
columnNames[index],
|
|||
|
|
columnNumber);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return columns;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Determines the next available data row number.
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="worksheet">
|
|||
|
|
/// Worksheet into which data will be inserted.
|
|||
|
|
/// </param>
|
|||
|
|
/// <returns>
|
|||
|
|
/// The one-based row number following the last used row.
|
|||
|
|
/// </returns>
|
|||
|
|
private int GetNextDataRowNumber(IXLWorksheet worksheet)
|
|||
|
|
{
|
|||
|
|
IXLRow lastUsedRow = worksheet.LastRowUsed();
|
|||
|
|
|
|||
|
|
if (lastUsedRow == null)
|
|||
|
|
return 2;
|
|||
|
|
|
|||
|
|
return Math.Max(lastUsedRow.RowNumber() + 1, 2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Validates a single update item.
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="item">
|
|||
|
|
/// Update item to validate.
|
|||
|
|
/// </param>
|
|||
|
|
/// <param name="columns">
|
|||
|
|
/// Available worksheet columns.
|
|||
|
|
/// </param>
|
|||
|
|
/// <returns>
|
|||
|
|
/// A successful result when the update item is valid; otherwise,
|
|||
|
|
/// a failed diagnostic result.
|
|||
|
|
/// </returns>
|
|||
|
|
private WriterDiagnosticResult ValidateUpdateItem(
|
|||
|
|
UpdateWriteItem item,
|
|||
|
|
IDictionary<string, int> columns)
|
|||
|
|
{
|
|||
|
|
if (string.IsNullOrWhiteSpace(item.WhereParameterName))
|
|||
|
|
return Fail("Update WHERE column name is empty.");
|
|||
|
|
|
|||
|
|
if (string.IsNullOrWhiteSpace(item.SetParameterName))
|
|||
|
|
return Fail("Update SET column name is empty.");
|
|||
|
|
|
|||
|
|
if (!columns.ContainsKey(item.WhereParameterName))
|
|||
|
|
{
|
|||
|
|
return Fail(
|
|||
|
|
"XLSX worksheet does not contain WHERE column: " +
|
|||
|
|
item.WhereParameterName);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!columns.ContainsKey(item.SetParameterName))
|
|||
|
|
{
|
|||
|
|
return Fail(
|
|||
|
|
"XLSX worksheet does not contain SET column: " +
|
|||
|
|
item.SetParameterName);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return Ok("Update item is valid.");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Updates all worksheet rows matching the specified value.
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="worksheet">
|
|||
|
|
/// Worksheet containing the data.
|
|||
|
|
/// </param>
|
|||
|
|
/// <param name="whereColumnNumber">
|
|||
|
|
/// One-based column number used to locate matching rows.
|
|||
|
|
/// </param>
|
|||
|
|
/// <param name="whereValue">
|
|||
|
|
/// Value that must match the current cell value.
|
|||
|
|
/// </param>
|
|||
|
|
/// <param name="setColumnNumber">
|
|||
|
|
/// One-based column number of the cell to update.
|
|||
|
|
/// </param>
|
|||
|
|
/// <param name="setValue">
|
|||
|
|
/// New value assigned to the target cell.
|
|||
|
|
/// </param>
|
|||
|
|
/// <returns>
|
|||
|
|
/// The number of updated rows.
|
|||
|
|
/// </returns>
|
|||
|
|
private int UpdateMatchingRows(
|
|||
|
|
IXLWorksheet worksheet,
|
|||
|
|
int whereColumnNumber,
|
|||
|
|
string whereValue,
|
|||
|
|
int setColumnNumber,
|
|||
|
|
string setValue)
|
|||
|
|
{
|
|||
|
|
IXLRow lastUsedRow = worksheet.LastRowUsed();
|
|||
|
|
|
|||
|
|
if (lastUsedRow == null ||
|
|||
|
|
lastUsedRow.RowNumber() < 2)
|
|||
|
|
{
|
|||
|
|
return 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
int updatedRows = 0;
|
|||
|
|
int lastRowNumber = lastUsedRow.RowNumber();
|
|||
|
|
|
|||
|
|
for (int rowNumber = 2;
|
|||
|
|
rowNumber <= lastRowNumber;
|
|||
|
|
rowNumber++)
|
|||
|
|
{
|
|||
|
|
IXLCell whereCell =
|
|||
|
|
worksheet.Cell(rowNumber, whereColumnNumber);
|
|||
|
|
|
|||
|
|
string currentValue =
|
|||
|
|
GetCellComparisonValue(whereCell);
|
|||
|
|
|
|||
|
|
if (!string.Equals(
|
|||
|
|
currentValue,
|
|||
|
|
whereValue ?? string.Empty,
|
|||
|
|
StringComparison.Ordinal))
|
|||
|
|
{
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
IXLCell setCell =
|
|||
|
|
worksheet.Cell(rowNumber, setColumnNumber);
|
|||
|
|
|
|||
|
|
SetCellValue(setCell, setValue);
|
|||
|
|
updatedRows++;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return updatedRows;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Converts an XLSX cell value to a string used for update comparison.
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="cell">
|
|||
|
|
/// Cell whose value is converted.
|
|||
|
|
/// </param>
|
|||
|
|
/// <returns>
|
|||
|
|
/// The formatted cell value, or an empty string for an empty cell.
|
|||
|
|
/// </returns>
|
|||
|
|
private string GetCellComparisonValue(IXLCell cell)
|
|||
|
|
{
|
|||
|
|
if (cell == null || cell.IsEmpty())
|
|||
|
|
return string.Empty;
|
|||
|
|
|
|||
|
|
return cell.GetFormattedString();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Assigns a request value to an XLSX cell.
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="cell">
|
|||
|
|
/// Target worksheet cell.
|
|||
|
|
/// </param>
|
|||
|
|
/// <param name="value">
|
|||
|
|
/// Value to assign. A <see langword="null"/> value clears the cell.
|
|||
|
|
/// </param>
|
|||
|
|
private void SetCellValue(IXLCell cell, string value)
|
|||
|
|
{
|
|||
|
|
if (value == null)
|
|||
|
|
{
|
|||
|
|
cell.Clear(XLClearOptions.Contents);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
cell.Value = value;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Builds a diagnostic description of an insert operation.
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="worksheetName">
|
|||
|
|
/// Name of the target worksheet.
|
|||
|
|
/// </param>
|
|||
|
|
/// <param name="rowNumber">
|
|||
|
|
/// Row number written by the operation.
|
|||
|
|
/// </param>
|
|||
|
|
/// <param name="items">
|
|||
|
|
/// Inserted values.
|
|||
|
|
/// </param>
|
|||
|
|
/// <returns>
|
|||
|
|
/// A human-readable insert diagnostic string.
|
|||
|
|
/// </returns>
|
|||
|
|
private string BuildInsertDiagnostic(
|
|||
|
|
string worksheetName,
|
|||
|
|
int rowNumber,
|
|||
|
|
IEnumerable<InsertWriteItem> items)
|
|||
|
|
{
|
|||
|
|
string values = string.Join(
|
|||
|
|
"; ",
|
|||
|
|
items.Select(
|
|||
|
|
item =>
|
|||
|
|
item.ColumnName +
|
|||
|
|
"=" +
|
|||
|
|
ToDiagnosticValue(item.Value)));
|
|||
|
|
|
|||
|
|
return string.Format(
|
|||
|
|
"INSERT [{0}] ROW {1}: {2}",
|
|||
|
|
worksheetName,
|
|||
|
|
rowNumber,
|
|||
|
|
values);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Builds a diagnostic description of an update operation.
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="worksheetName">
|
|||
|
|
/// Name of the target worksheet.
|
|||
|
|
/// </param>
|
|||
|
|
/// <param name="item">
|
|||
|
|
/// Executed update item.
|
|||
|
|
/// </param>
|
|||
|
|
/// <param name="updatedRows">
|
|||
|
|
/// Number of rows updated by the operation.
|
|||
|
|
/// </param>
|
|||
|
|
/// <returns>
|
|||
|
|
/// A human-readable update diagnostic string.
|
|||
|
|
/// </returns>
|
|||
|
|
private string BuildUpdateDiagnostic(
|
|||
|
|
string worksheetName,
|
|||
|
|
UpdateWriteItem item,
|
|||
|
|
int updatedRows)
|
|||
|
|
{
|
|||
|
|
return string.Format(
|
|||
|
|
"UPDATE [{0}] SET {1}={2} WHERE {3}={4}; Rows={5}",
|
|||
|
|
worksheetName,
|
|||
|
|
item.SetParameterName,
|
|||
|
|
ToDiagnosticValue(item.SetValue),
|
|||
|
|
item.WhereParameterName,
|
|||
|
|
ToDiagnosticValue(item.WhereValue),
|
|||
|
|
updatedRows);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Converts a value to a diagnostic representation.
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="value">
|
|||
|
|
/// Value to represent.
|
|||
|
|
/// </param>
|
|||
|
|
/// <returns>
|
|||
|
|
/// The escaped diagnostic value.
|
|||
|
|
/// </returns>
|
|||
|
|
private string ToDiagnosticValue(string value)
|
|||
|
|
{
|
|||
|
|
if (value == null)
|
|||
|
|
return "NULL";
|
|||
|
|
|
|||
|
|
return "'" + value.Replace("'", "''") + "'";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Creates a successful diagnostic result.
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="message">
|
|||
|
|
/// Diagnostic message.
|
|||
|
|
/// </param>
|
|||
|
|
/// <returns>
|
|||
|
|
/// A successful writer diagnostic result.
|
|||
|
|
/// </returns>
|
|||
|
|
private WriterDiagnosticResult Ok(string message)
|
|||
|
|
{
|
|||
|
|
return new WriterDiagnosticResult
|
|||
|
|
{
|
|||
|
|
Success = true,
|
|||
|
|
Message = message
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Creates a failed diagnostic result.
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="message">
|
|||
|
|
/// Diagnostic error message.
|
|||
|
|
/// </param>
|
|||
|
|
/// <returns>
|
|||
|
|
/// A failed writer diagnostic result.
|
|||
|
|
/// </returns>
|
|||
|
|
private WriterDiagnosticResult Fail(string message)
|
|||
|
|
{
|
|||
|
|
return new WriterDiagnosticResult
|
|||
|
|
{
|
|||
|
|
Success = false,
|
|||
|
|
Message = message
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Resolves the worksheet name from the configured XLSX write template.
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="template">
|
|||
|
|
/// Template containing either a worksheet definition in the format
|
|||
|
|
/// <c>Sheet=WorksheetName</c> or an SQL-like insert/update command.
|
|||
|
|
/// </param>
|
|||
|
|
/// <returns>
|
|||
|
|
/// The worksheet name resolved from the template.
|
|||
|
|
/// </returns>
|
|||
|
|
/// <exception cref="InvalidOperationException">
|
|||
|
|
/// Thrown when the template is empty or the worksheet name cannot be resolved.
|
|||
|
|
/// </exception>
|
|||
|
|
private string ParseWorksheetName(string template)
|
|||
|
|
{
|
|||
|
|
if (string.IsNullOrWhiteSpace(template))
|
|||
|
|
throw new InvalidOperationException(
|
|||
|
|
"XLSX write template is empty.");
|
|||
|
|
|
|||
|
|
string value = template.Trim();
|
|||
|
|
|
|||
|
|
const string sheetPrefix = "Sheet=";
|
|||
|
|
|
|||
|
|
if (value.StartsWith(
|
|||
|
|
sheetPrefix,
|
|||
|
|
StringComparison.OrdinalIgnoreCase))
|
|||
|
|
{
|
|||
|
|
return ValidateAndNormalizeWorksheetName(
|
|||
|
|
value.Substring(sheetPrefix.Length));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const string insertPrefix = "INSERT INTO ";
|
|||
|
|
|
|||
|
|
if (value.StartsWith(
|
|||
|
|
insertPrefix,
|
|||
|
|
StringComparison.OrdinalIgnoreCase))
|
|||
|
|
{
|
|||
|
|
string remaining = value
|
|||
|
|
.Substring(insertPrefix.Length)
|
|||
|
|
.Trim();
|
|||
|
|
|
|||
|
|
int endIndex = remaining.IndexOfAny(
|
|||
|
|
new[] { ' ', '(' });
|
|||
|
|
|
|||
|
|
string worksheetName = endIndex >= 0
|
|||
|
|
? remaining.Substring(0, endIndex)
|
|||
|
|
: remaining;
|
|||
|
|
|
|||
|
|
return ValidateAndNormalizeWorksheetName(
|
|||
|
|
worksheetName);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const string updatePrefix = "UPDATE ";
|
|||
|
|
|
|||
|
|
if (value.StartsWith(
|
|||
|
|
updatePrefix,
|
|||
|
|
StringComparison.OrdinalIgnoreCase))
|
|||
|
|
{
|
|||
|
|
string remaining = value
|
|||
|
|
.Substring(updatePrefix.Length)
|
|||
|
|
.Trim();
|
|||
|
|
|
|||
|
|
int endIndex = remaining.IndexOfAny(
|
|||
|
|
new[] { ' ', '(' });
|
|||
|
|
|
|||
|
|
string worksheetName = endIndex >= 0
|
|||
|
|
? remaining.Substring(0, endIndex)
|
|||
|
|
: remaining;
|
|||
|
|
|
|||
|
|
return ValidateAndNormalizeWorksheetName(
|
|||
|
|
worksheetName);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
throw new InvalidOperationException(
|
|||
|
|
"XLSX worksheet name could not be resolved from template: " +
|
|||
|
|
template);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Normalizes and validates an XLSX worksheet name.
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="worksheetName">
|
|||
|
|
/// Worksheet name to normalize and validate.
|
|||
|
|
/// </param>
|
|||
|
|
/// <returns>
|
|||
|
|
/// A valid worksheet name.
|
|||
|
|
/// </returns>
|
|||
|
|
/// <exception cref="InvalidOperationException">
|
|||
|
|
/// Thrown when the worksheet name is empty, too long,
|
|||
|
|
/// or contains an invalid character.
|
|||
|
|
/// </exception>
|
|||
|
|
private string ValidateAndNormalizeWorksheetName(
|
|||
|
|
string worksheetName)
|
|||
|
|
{
|
|||
|
|
string result = (worksheetName ?? string.Empty).Trim();
|
|||
|
|
|
|||
|
|
if (result.Length >= 2)
|
|||
|
|
{
|
|||
|
|
bool squareBrackets =
|
|||
|
|
result[0] == '[' &&
|
|||
|
|
result[result.Length - 1] == ']';
|
|||
|
|
|
|||
|
|
bool singleQuotes =
|
|||
|
|
result[0] == '\'' &&
|
|||
|
|
result[result.Length - 1] == '\'';
|
|||
|
|
|
|||
|
|
bool doubleQuotes =
|
|||
|
|
result[0] == '"' &&
|
|||
|
|
result[result.Length - 1] == '"';
|
|||
|
|
|
|||
|
|
if (squareBrackets || singleQuotes || doubleQuotes)
|
|||
|
|
{
|
|||
|
|
result = result.Substring(
|
|||
|
|
1,
|
|||
|
|
result.Length - 2);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
result = result.Trim();
|
|||
|
|
|
|||
|
|
if (string.IsNullOrWhiteSpace(result))
|
|||
|
|
{
|
|||
|
|
throw new InvalidOperationException(
|
|||
|
|
"XLSX worksheet name is empty.");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (result.Length > 31)
|
|||
|
|
{
|
|||
|
|
throw new InvalidOperationException(
|
|||
|
|
"XLSX worksheet name cannot contain more than 31 characters.");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
char[] invalidCharacters =
|
|||
|
|
{
|
|||
|
|
'\\',
|
|||
|
|
'/',
|
|||
|
|
'?',
|
|||
|
|
'*',
|
|||
|
|
'[',
|
|||
|
|
']',
|
|||
|
|
':'
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
if (result.IndexOfAny(invalidCharacters) >= 0)
|
|||
|
|
{
|
|||
|
|
throw new InvalidOperationException(
|
|||
|
|
"XLSX worksheet name contains an invalid character: " +
|
|||
|
|
result);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Gets an existing worksheet or creates a new worksheet with the specified name.
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="workbook">
|
|||
|
|
/// Workbook containing the worksheet.
|
|||
|
|
/// </param>
|
|||
|
|
/// <param name="worksheetName">
|
|||
|
|
/// Name of the worksheet.
|
|||
|
|
/// </param>
|
|||
|
|
/// <returns>
|
|||
|
|
/// The existing or newly created worksheet.
|
|||
|
|
/// </returns>
|
|||
|
|
private IXLWorksheet GetOrCreateWorksheet(
|
|||
|
|
XLWorkbook workbook,
|
|||
|
|
string worksheetName)
|
|||
|
|
{
|
|||
|
|
if (workbook == null)
|
|||
|
|
throw new ArgumentNullException(nameof(workbook));
|
|||
|
|
|
|||
|
|
IXLWorksheet worksheet;
|
|||
|
|
|
|||
|
|
if (workbook.Worksheets.TryGetWorksheet(
|
|||
|
|
worksheetName,
|
|||
|
|
out worksheet))
|
|||
|
|
{
|
|||
|
|
return worksheet;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return workbook.Worksheets.Add(worksheetName);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|