- added technology-specific default configuration for new writers - auto-fill default SQL/XLSX write templates - disabled write templates for CSV technology - added technology-specific data source hints - changed test parameter format to Column=Value and Where=Value;Set=Value - improved parameter validation to prevent invalid input crashes - added automatic CSV header creation and header validation - aligned CSV writer behaviour with XLSX writer - Increase revision number
436 lines
14 KiB
C#
436 lines
14 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text;
|
|
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>
|
|
/// CSV writer implementation for UniDataStorageWriter.
|
|
///
|
|
/// Insert:
|
|
/// request.InsertItems define one output CSV row.
|
|
///
|
|
/// Update:
|
|
/// currently implemented as append-only audit/log style output,
|
|
/// because true in-place CSV row update requires read-modify-rewrite logic.
|
|
/// </summary>
|
|
public class CsvWriter : IDataStorageWriter
|
|
{
|
|
private readonly WriterCfg cfg;
|
|
|
|
public CsvWriter(WriterCfg cfg)
|
|
{
|
|
this.cfg = cfg ?? throw new ArgumentNullException(nameof(cfg));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates access to CSV file (and optionally creates it).
|
|
/// </summary>
|
|
public WriterDiagnosticResult TestSource(bool validateOnly)
|
|
{
|
|
try
|
|
{
|
|
if (string.IsNullOrWhiteSpace(cfg.DataSource))
|
|
return Fail("CSV file path is not defined.");
|
|
|
|
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("File does not exist but path is valid.");
|
|
|
|
using (File.Create(path))
|
|
{
|
|
}
|
|
}
|
|
|
|
return Ok("CSV source is ready.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return Fail("CSV source test failed: " + ex.Message);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes data into CSV file using current request mode.
|
|
/// </summary>
|
|
public WriterDiagnosticResult WriteData(DataWriteRequest request)
|
|
{
|
|
if (request == null)
|
|
throw new ArgumentNullException(nameof(request));
|
|
|
|
WriterDiagnosticResult test = TestSource(false);
|
|
if (!test.Success)
|
|
return test;
|
|
|
|
try
|
|
{
|
|
switch (request.Mode)
|
|
{
|
|
case WriteMode.Insert:
|
|
return ExecuteInsert(request);
|
|
|
|
case WriteMode.Update:
|
|
return ExecuteUpdate(request);
|
|
|
|
default:
|
|
return Fail("CSV mode not supported: " + request.Mode);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return Fail("CSV write failed: " + ex.Message);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Appends one data row to the CSV file.
|
|
/// </summary>
|
|
/// <param name="request">
|
|
/// Request containing the insert items.
|
|
/// </param>
|
|
/// <returns>
|
|
/// A diagnostic result describing the inserted row.
|
|
/// </returns>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// When the CSV file is empty or contains only empty lines, the first row is
|
|
/// automatically created from the insert item column names.
|
|
/// </para>
|
|
/// <para>
|
|
/// When the CSV file already contains a header, values are ordered according
|
|
/// to the existing header.
|
|
/// </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));
|
|
}
|
|
|
|
const string delimiter = ";";
|
|
|
|
Dictionary<string, string> valuesByColumn =
|
|
request.InsertItems.ToDictionary(
|
|
item => item.ColumnName,
|
|
item => item.Value,
|
|
StringComparer.OrdinalIgnoreCase);
|
|
|
|
string firstNonEmptyLine = null;
|
|
|
|
if (File.Exists(cfg.DataSource))
|
|
{
|
|
firstNonEmptyLine = File
|
|
.ReadLines(cfg.DataSource, Encoding.UTF8)
|
|
.FirstOrDefault(line => !string.IsNullOrWhiteSpace(line));
|
|
}
|
|
|
|
string[] headerColumns;
|
|
|
|
if (string.IsNullOrWhiteSpace(firstNonEmptyLine))
|
|
{
|
|
headerColumns = request.InsertItems
|
|
.Select(item => item.ColumnName)
|
|
.ToArray();
|
|
|
|
string headerLine = string.Join(
|
|
delimiter,
|
|
headerColumns.Select(EscapeCsvValue));
|
|
|
|
// The file is empty or contains only blank lines.
|
|
// Rewrite it so that the header is always the first row.
|
|
File.WriteAllText(
|
|
cfg.DataSource,
|
|
headerLine + Environment.NewLine,
|
|
Encoding.UTF8);
|
|
}
|
|
else
|
|
{
|
|
headerColumns = ParseCsvLine(
|
|
firstNonEmptyLine,
|
|
delimiter[0])
|
|
.Select(value => value.Trim())
|
|
.ToArray();
|
|
|
|
if (headerColumns.Length == 0 ||
|
|
headerColumns.All(string.IsNullOrWhiteSpace))
|
|
{
|
|
return Fail("CSV file does not contain a valid header.");
|
|
}
|
|
|
|
if (headerColumns.Any(string.IsNullOrWhiteSpace))
|
|
{
|
|
return Fail(
|
|
"CSV header contains an empty column name.");
|
|
}
|
|
|
|
string[] duplicateHeaderColumns = headerColumns
|
|
.GroupBy(
|
|
name => name,
|
|
StringComparer.OrdinalIgnoreCase)
|
|
.Where(g => g.Count() > 1)
|
|
.Select(g => g.Key)
|
|
.ToArray();
|
|
|
|
if (duplicateHeaderColumns.Length > 0)
|
|
{
|
|
return Fail(
|
|
"CSV header contains duplicate columns: " +
|
|
string.Join(", ", duplicateHeaderColumns));
|
|
}
|
|
}
|
|
|
|
string[] missingColumns = request.InsertItems
|
|
.Select(item => item.ColumnName)
|
|
.Where(name => !headerColumns.Contains(
|
|
name,
|
|
StringComparer.OrdinalIgnoreCase))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToArray();
|
|
|
|
if (missingColumns.Length > 0)
|
|
{
|
|
return Fail(
|
|
"CSV header does not contain columns: " +
|
|
string.Join(", ", missingColumns));
|
|
}
|
|
|
|
string[] values = headerColumns
|
|
.Select(columnName =>
|
|
{
|
|
string value;
|
|
|
|
return valuesByColumn.TryGetValue(columnName, out value)
|
|
? EscapeCsvValue(value)
|
|
: string.Empty;
|
|
})
|
|
.ToArray();
|
|
|
|
string line = string.Join(delimiter, values);
|
|
|
|
File.AppendAllText(
|
|
cfg.DataSource,
|
|
line + Environment.NewLine,
|
|
Encoding.UTF8);
|
|
|
|
return new WriterDiagnosticResult
|
|
{
|
|
Success = true,
|
|
Message = "CSV insert OK. Written 1 row.",
|
|
ExecutedTemplate = line
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses a single CSV line while respecting quoted values.
|
|
/// </summary>
|
|
/// <param name="line">
|
|
/// CSV line to parse.
|
|
/// </param>
|
|
/// <param name="delimiter">
|
|
/// Character separating individual fields.
|
|
/// </param>
|
|
/// <returns>
|
|
/// Parsed CSV field values.
|
|
/// </returns>
|
|
/// <exception cref="InvalidOperationException">
|
|
/// Thrown when the line contains an unterminated quoted value.
|
|
/// </exception>
|
|
private string[] ParseCsvLine(string line, char delimiter)
|
|
{
|
|
if (line == null)
|
|
return new string[0];
|
|
|
|
List<string> values = new List<string>();
|
|
StringBuilder currentValue = new StringBuilder();
|
|
|
|
bool insideQuotes = false;
|
|
|
|
for (int index = 0; index < line.Length; index++)
|
|
{
|
|
char character = line[index];
|
|
|
|
if (character == '"')
|
|
{
|
|
bool escapedQuote =
|
|
insideQuotes &&
|
|
index + 1 < line.Length &&
|
|
line[index + 1] == '"';
|
|
|
|
if (escapedQuote)
|
|
{
|
|
currentValue.Append('"');
|
|
index++;
|
|
}
|
|
else
|
|
{
|
|
insideQuotes = !insideQuotes;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if (character == delimiter && !insideQuotes)
|
|
{
|
|
values.Add(currentValue.ToString());
|
|
currentValue.Clear();
|
|
continue;
|
|
}
|
|
|
|
currentValue.Append(character);
|
|
}
|
|
|
|
if (insideQuotes)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"CSV line contains an unterminated quoted value.");
|
|
}
|
|
|
|
values.Add(currentValue.ToString());
|
|
|
|
return values.ToArray();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update mode:
|
|
/// CSV has no natural SQL-style row update, so current implementation writes
|
|
/// an audit/log style line for each update item.
|
|
///
|
|
/// Example input:
|
|
/// SerialNumber=SN001;Result=PASS
|
|
/// Produces:
|
|
/// UPDATE;SerialNumber;SN001;Result;PASS
|
|
/// </summary>
|
|
private WriterDiagnosticResult ExecuteUpdate(DataWriteRequest request)
|
|
{
|
|
if (request.UpdateItems == null || request.UpdateItems.Count == 0)
|
|
return Fail("No update items provided.");
|
|
|
|
string delimiter = ";";
|
|
StringBuilder sb = new StringBuilder();
|
|
|
|
foreach (UpdateWriteItem item in request.UpdateItems)
|
|
{
|
|
string line = string.Join(
|
|
delimiter,
|
|
EscapeCsvValue("UPDATE"),
|
|
EscapeCsvValue(item != null ? item.WhereParameterName : null),
|
|
EscapeCsvValue(item != null ? item.WhereValue : null),
|
|
EscapeCsvValue(item != null ? item.SetParameterName : null),
|
|
EscapeCsvValue(item != null ? item.SetValue : null));
|
|
|
|
File.AppendAllText(cfg.DataSource, line + Environment.NewLine, Encoding.UTF8);
|
|
sb.AppendLine(line);
|
|
}
|
|
|
|
return new WriterDiagnosticResult
|
|
{
|
|
Success = true,
|
|
Message = "CSV update log OK. Written rows: " + request.UpdateItems.Count,
|
|
ExecutedTemplate = sb.ToString().TrimEnd()
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Escapes one CSV value.
|
|
/// </summary>
|
|
private string EscapeCsvValue(string value)
|
|
{
|
|
if (value == null)
|
|
return string.Empty;
|
|
|
|
bool mustQuote =
|
|
value.Contains("\"") ||
|
|
value.Contains(";") ||
|
|
value.Contains(",") ||
|
|
value.Contains("\n") ||
|
|
value.Contains("\r");
|
|
|
|
if (value.Contains("\""))
|
|
value = value.Replace("\"", "\"\"");
|
|
|
|
if (mustQuote)
|
|
value = "\"" + value + "\"";
|
|
|
|
return value;
|
|
}
|
|
|
|
public WriterCapabilities Capabilities
|
|
{
|
|
get
|
|
{
|
|
WriterCapabilities caps = new WriterCapabilities();
|
|
|
|
caps.SupportedStorageTypes.Add(StorageTypes.LocalFile);
|
|
caps.SupportedStorageTypes.Add(StorageTypes.RemoteFile);
|
|
|
|
caps.SupportedTechnologyTypes.Add(TechnologyTypes.Csv);
|
|
|
|
caps.SupportedWriteModes.Add(WriteMode.Insert);
|
|
caps.SupportedWriteModes.Add(WriteMode.Update);
|
|
|
|
return caps;
|
|
}
|
|
}
|
|
|
|
private WriterDiagnosticResult Ok(string msg)
|
|
{
|
|
return new WriterDiagnosticResult
|
|
{
|
|
Success = true,
|
|
Message = msg
|
|
};
|
|
}
|
|
|
|
private WriterDiagnosticResult Fail(string msg)
|
|
{
|
|
return new WriterDiagnosticResult
|
|
{
|
|
Success = false,
|
|
Message = msg
|
|
};
|
|
}
|
|
}
|
|
} |