diff --git a/CSV Metrics Logger/CSVStorage.cs b/CSV Metrics Logger/CSVStorage.cs new file mode 100644 index 0000000..24b8f7b --- /dev/null +++ b/CSV Metrics Logger/CSVStorage.cs @@ -0,0 +1,164 @@ +using System.Collections.Concurrent; +using System.Text; + +// ReSharper disable StaticMemberInGenericType + +namespace CSV_Metrics_Logger; + +/// <summary> +/// The CSV storage class. +/// </summary> +public sealed class CSVStorage<T> : IAsyncDisposable where T : struct, IConvertToCSV +{ + private const char CSV_DEFAULT_DELIMITER = ';'; + + private static readonly TimeSpan WRITE_INTERVAL = TimeSpan.FromSeconds(8); + + private readonly object lockObject = new(); + private readonly FileStream fileStream; + private readonly TextWriter writer; + private readonly char csvDelimiter; + private readonly ConcurrentQueue<string> dataQueue = new(); + private readonly PeriodicTimer writeTimer = new(WRITE_INTERVAL); + + private bool isInitialized; + private bool writerRunning; + + private CSVStorage(string filename, char csvDelimiter) + { + this.csvDelimiter = csvDelimiter; + this.isInitialized = File.Exists(filename) && new FileInfo(filename).Length > 0; + this.fileStream = new FileStream(filename, FileMode.Append, FileAccess.Write, FileShare.Read); + this.writer = new StreamWriter(this.fileStream, Encoding.UTF8); + + _ = Task.Factory.StartNew(() => this.WriteDataWorker(), TaskCreationOptions.LongRunning); + } + + /// <summary> + /// Create a new CSV storage instance for the given type T and filename. + /// </summary> + /// <remarks> + /// When the file already exists, added data will be appended to the file. + /// The header will be written only once. + /// </remarks> + /// <param name="filename">The filename to store the CSV data.</param> + /// <param name="csvDelimiter">The CSV delimiter to use. Default is a semicolon.</param> + /// <returns>The CSV storage instance.</returns> + public static CSVStorage<T> Create(string filename, char csvDelimiter = CSV_DEFAULT_DELIMITER) => new(filename, csvDelimiter); + + /// <summary> + /// Write the given data to the CSV file. + /// </summary> + /// <param name="data">The data to write.</param> + /// <param name="cancellationToken">The cancellation token.</param> + public void Write(T data, CancellationToken cancellationToken = default) + { + if(!this.isInitialized) + { + lock (this.lockObject) + { + if(!this.isInitialized) + { + this.dataQueue.Enqueue(this.CreateCSVLine(data.GetCSVHeaders(), cancellationToken)); + this.isInitialized = true; + } + } + } + + this.dataQueue.Enqueue(this.CreateCSVLine(data.ConvertToCSVDataLine(), cancellationToken)); + } + + private async Task WriteDataWorker(CancellationToken cancellationToken = default) + { + this.writerRunning = true; + while (await this.writeTimer.WaitForNextTickAsync(cancellationToken)) + await this.WriteData(cancellationToken); + + this.writerRunning = false; + } + + private async ValueTask WriteData(CancellationToken cancellationToken = default) + { + if(this.dataQueue.IsEmpty) + return; + + while(this.dataQueue.TryDequeue(out var line)) + { + if(cancellationToken.IsCancellationRequested) + break; + + await this.writer.WriteLineAsync(line); + } + } + + private string CreateCSVLine(IEnumerable<string> elements, CancellationToken cancellationToken = default) + { + var sb = new StringBuilder(1_024); + foreach (var element in elements) + { + if(cancellationToken.IsCancellationRequested) + break; + + if (sb.Length > 0) + sb.Append(this.csvDelimiter); + + if (this.QuotationNeeded(element)) + { + sb.Append('"'); + sb.Append(element.Replace("\"", "\"\"")); + sb.Append('"'); + } + else + sb.Append(element); + } + + return sb.ToString(); + } + + private bool QuotationNeeded(string value) + { + // Rules: + // - If the value contains a comma, we need to use double quotes. + // - If the value contains the delimiter, we need to use double quotes. + // - If the value contains the escape character, we need to use double quotes. + // - If the value contains a newline character, we need to use double quotes. + // - If the value contains a double quote, we need to escape it by doubling it. + // - If the value contains spaces, we need to use double quotes. + // + if (value.Contains(',') || + value.Contains(this.csvDelimiter) || + value.Contains('"') || + value.Contains('\n') || + value.Contains(' ')) + { + return true; + } + + return false; + } + + #region Implementation of IAsyncDisposable + + /// <inheritdoc /> + public async ValueTask DisposeAsync() + { + // Stop the writer timer: + this.writeTimer.Dispose(); + + // Now, we wait for the last scheduled write operation to finish, if any: + while (this.writerRunning) + await Task.Delay(TimeSpan.FromMilliseconds(100)); + + // Ensure that all data is written: + await this.WriteData(); + + // Flush and dispose the writer: + await this.writer.FlushAsync(); + await this.writer.DisposeAsync(); + + // Dispose the file stream: + await this.fileStream.DisposeAsync(); + } + + #endregion +} \ No newline at end of file diff --git a/Tests/Tests.cs b/Tests/Tests.cs index ce84396..ee0d656 100644 --- a/Tests/Tests.cs +++ b/Tests/Tests.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using CSV_Metrics_Logger; namespace Tests; @@ -81,4 +82,39 @@ public sealed class Tests Assert.That(dataLine, Is.EquivalentTo(new[] { "New York", "35", "1.85" })); }); } + + [Test] + public async Task TestWritingCSV() + { + List<TestDataOneLine> testData = + [ + new TestDataOneLine("Name 1", 14), + new TestDataOneLine("Name 2", 25), + new TestDataOneLine("Name 3", 36), + new TestDataOneLine("Name 4", 47), + new TestDataOneLine("Name 5", 58), + ]; + + // Get a random file: + var fileName = Path.GetTempFileName(); + await using (var storage = CSVStorage<TestDataOneLine>.Create(fileName)) + { + foreach (var data in testData) + storage.Write(data); + } + + var lines = await File.ReadAllLinesAsync(fileName); + Assert.Multiple(() => + { + Assert.That(lines, Has.Length.EqualTo(6)); + Assert.That(lines[0], Is.EqualTo("Name;Age")); + Assert.That(lines[1], Is.EqualTo("\"Name 1\";14")); + Assert.That(lines[2], Is.EqualTo("\"Name 2\";25")); + Assert.That(lines[3], Is.EqualTo("\"Name 3\";36")); + Assert.That(lines[4], Is.EqualTo("\"Name 4\";47")); + Assert.That(lines[5], Is.EqualTo("\"Name 5\";58")); + }); + + File.Delete(fileName); + } } \ No newline at end of file