tbf/TBF/Rig/Output/DataStorage/UniDataStorageWriter/Writers/CsvWriter .cs
Marek Frniak a210a0d1f2 Upgrade (UniDataStorageWriter): improve writer configuration UX and CSV handling
- 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
2026-07-21 11:18:11 +02:00

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
};
}
}
}