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 { /// /// Provides data writing support for Microsoft Excel Open XML files. /// /// /// /// The writer supports files using the .xlsx extension. /// Microsoft Excel does not need to be installed on the target computer. /// /// /// 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. /// /// public class XlsxWriter : IDataStorageWriter { private const string DefaultWorksheetName = "Data"; private readonly WriterCfg cfg; /// /// Initializes a new instance of the class. /// /// /// Writer configuration containing the path of the target Excel file. /// /// /// Thrown when is . /// public XlsxWriter(WriterCfg cfg) { this.cfg = cfg ?? throw new ArgumentNullException(nameof(cfg)); } /// /// Gets the storage types, technology types and write modes supported /// by this writer. /// /// /// The capabilities of the XLSX writer. /// 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; } } /// /// Validates the configured XLSX source. /// /// /// to validate the configured path without creating /// missing directories or files; otherwise, . /// /// /// A diagnostic result describing whether the XLSX source is valid /// and accessible. /// /// /// When is , /// a missing directory and XLSX file are created automatically. /// 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); } } /// /// Writes data to the configured XLSX file. /// /// /// Request containing the write mode and the data to be written. /// /// /// A diagnostic result describing the outcome of the write operation. /// /// /// Thrown when is . /// 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); } } /// /// Appends one data row to the first worksheet. /// /// /// Request containing the insert items. /// /// /// A diagnostic result describing the inserted row. /// /// /// /// When the worksheet does not yet contain headers, the first row is /// automatically created from the insert item column names. /// /// /// When headers already exist, every insert item column must be present /// in the header row. /// /// 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 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 }; } } /// /// Updates cells in rows matching the specified conditions. /// /// /// Request containing the update items. /// /// /// A diagnostic result containing the number of updated rows. /// /// /// Each update item is processed separately. All rows whose value in /// equals /// are updated. /// String comparison is case-sensitive. /// 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 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() }; } } /// /// Validates the configured data source path and file extension. /// /// /// A successful result when the data source is valid; otherwise, /// a failed diagnostic result. /// 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."); } /// /// Opens the configured workbook. /// /// /// An opened . /// private XLWorkbook OpenWorkbook() { return new XLWorkbook(cfg.DataSource); } /// /// Creates an empty workbook containing the default worksheet. /// /// /// Destination path of the workbook. /// private void CreateEmptyWorkbook(string path) { using (XLWorkbook workbook = new XLWorkbook()) { workbook.Worksheets.Add(DefaultWorksheetName); workbook.SaveAs(path); } } /// /// Gets the first worksheet from the specified workbook. /// /// /// Workbook containing the worksheet. /// /// /// The first worksheet in the workbook. /// /// /// Thrown when the workbook does not contain a worksheet. /// 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; } /// /// Reads header names from the first worksheet row. /// /// /// Worksheet containing the header row. /// /// /// A case-insensitive dictionary mapping column names to their /// one-based worksheet column numbers. /// private Dictionary GetHeaderColumns( IXLWorksheet worksheet) { Dictionary columns = new Dictionary( 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; } /// /// Reads existing worksheet headers or creates them when the worksheet /// is empty. /// /// /// Worksheet containing the header row. /// /// /// Column names required by the insert operation. /// /// /// A dictionary mapping column names to worksheet column numbers. /// private Dictionary GetOrCreateHeaderColumns( IXLWorksheet worksheet, IEnumerable requiredColumnNames) { Dictionary 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; } /// /// Determines the next available data row number. /// /// /// Worksheet into which data will be inserted. /// /// /// The one-based row number following the last used row. /// private int GetNextDataRowNumber(IXLWorksheet worksheet) { IXLRow lastUsedRow = worksheet.LastRowUsed(); if (lastUsedRow == null) return 2; return Math.Max(lastUsedRow.RowNumber() + 1, 2); } /// /// Validates a single update item. /// /// /// Update item to validate. /// /// /// Available worksheet columns. /// /// /// A successful result when the update item is valid; otherwise, /// a failed diagnostic result. /// private WriterDiagnosticResult ValidateUpdateItem( UpdateWriteItem item, IDictionary 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."); } /// /// Updates all worksheet rows matching the specified value. /// /// /// Worksheet containing the data. /// /// /// One-based column number used to locate matching rows. /// /// /// Value that must match the current cell value. /// /// /// One-based column number of the cell to update. /// /// /// New value assigned to the target cell. /// /// /// The number of updated rows. /// 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; } /// /// Converts an XLSX cell value to a string used for update comparison. /// /// /// Cell whose value is converted. /// /// /// The formatted cell value, or an empty string for an empty cell. /// private string GetCellComparisonValue(IXLCell cell) { if (cell == null || cell.IsEmpty()) return string.Empty; return cell.GetFormattedString(); } /// /// Assigns a request value to an XLSX cell. /// /// /// Target worksheet cell. /// /// /// Value to assign. A value clears the cell. /// private void SetCellValue(IXLCell cell, string value) { if (value == null) { cell.Clear(XLClearOptions.Contents); return; } cell.Value = value; } /// /// Builds a diagnostic description of an insert operation. /// /// /// Name of the target worksheet. /// /// /// Row number written by the operation. /// /// /// Inserted values. /// /// /// A human-readable insert diagnostic string. /// private string BuildInsertDiagnostic( string worksheetName, int rowNumber, IEnumerable items) { string values = string.Join( "; ", items.Select( item => item.ColumnName + "=" + ToDiagnosticValue(item.Value))); return string.Format( "INSERT [{0}] ROW {1}: {2}", worksheetName, rowNumber, values); } /// /// Builds a diagnostic description of an update operation. /// /// /// Name of the target worksheet. /// /// /// Executed update item. /// /// /// Number of rows updated by the operation. /// /// /// A human-readable update diagnostic string. /// 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); } /// /// Converts a value to a diagnostic representation. /// /// /// Value to represent. /// /// /// The escaped diagnostic value. /// private string ToDiagnosticValue(string value) { if (value == null) return "NULL"; return "'" + value.Replace("'", "''") + "'"; } /// /// Creates a successful diagnostic result. /// /// /// Diagnostic message. /// /// /// A successful writer diagnostic result. /// private WriterDiagnosticResult Ok(string message) { return new WriterDiagnosticResult { Success = true, Message = message }; } /// /// Creates a failed diagnostic result. /// /// /// Diagnostic error message. /// /// /// A failed writer diagnostic result. /// private WriterDiagnosticResult Fail(string message) { return new WriterDiagnosticResult { Success = false, Message = message }; } /// /// Resolves the worksheet name from the configured XLSX write template. /// /// /// Template containing either a worksheet definition in the format /// Sheet=WorksheetName or an SQL-like insert/update command. /// /// /// The worksheet name resolved from the template. /// /// /// Thrown when the template is empty or the worksheet name cannot be resolved. /// 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); } /// /// Normalizes and validates an XLSX worksheet name. /// /// /// Worksheet name to normalize and validate. /// /// /// A valid worksheet name. /// /// /// Thrown when the worksheet name is empty, too long, /// or contains an invalid character. /// 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; } /// /// Gets an existing worksheet or creates a new worksheet with the specified name. /// /// /// Workbook containing the worksheet. /// /// /// Name of the worksheet. /// /// /// The existing or newly created worksheet. /// 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); } } }