diff --git a/src/Rowbot.ClosedXml/Rowbot.ClosedXml.csproj b/src/Rowbot.ClosedXml/Rowbot.ClosedXml.csproj index b792280..01594a5 100644 --- a/src/Rowbot.ClosedXml/Rowbot.ClosedXml.csproj +++ b/src/Rowbot.ClosedXml/Rowbot.ClosedXml.csproj @@ -2,6 +2,7 @@ netstandard2.0 + 8 diff --git a/src/Rowbot/CsvHelper/AsyncCsvHelperSource.cs b/src/Rowbot/CsvHelper/AsyncCsvHelperSource.cs new file mode 100644 index 0000000..7e7350d --- /dev/null +++ b/src/Rowbot/CsvHelper/AsyncCsvHelperSource.cs @@ -0,0 +1,87 @@ +using CsvHelper; +using CsvHelper.Configuration; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +namespace Rowbot.CsvHelper +{ + public class AsyncCsvHelperSource : IAsyncRowSource + { + private readonly CsvReader _csvReader; + private readonly bool _readFirstLineAsHeaders = false; + private int _readCallCount = 0; + + public AsyncCsvHelperSource(Stream stream, CsvConfiguration configuration, bool readFirstLineAsHeaders) : this(new CsvReader(new StreamReader(stream), configuration), readFirstLineAsHeaders: readFirstLineAsHeaders) + { + } + + public AsyncCsvHelperSource(CsvReader csvReader, bool readFirstLineAsHeaders) + { + _csvReader = csvReader; + _readFirstLineAsHeaders = readFirstLineAsHeaders; + } + + public void Dispose() + { + _csvReader.Dispose(); + } + + public Task CompleteAsync() + { + // Nothing to complete + return Task.CompletedTask; + } + + public async Task InitAndGetColumnsAsync() + { + await _csvReader.ReadAsync(); + _csvReader.ReadHeader(); + + if (_readFirstLineAsHeaders) + { + var columns = _csvReader.HeaderRecord.Select(header => new ColumnInfo(name: header, valueType: typeof(string))).ToArray(); + return columns; + } + else + { + var columns = new ColumnInfo[_csvReader.HeaderRecord.Length]; + for (var i = 0; i < _csvReader.HeaderRecord.Length; i++) + { + columns[i] = new ColumnInfo(name: $"Column{i + 1}", valueType: typeof(string)); + } + return columns; + } + } + + public async Task ReadRowAsync(object[] values) + { + _readCallCount++; + if (_readCallCount == 1 && !_readFirstLineAsHeaders) + { + // First line should not be read as headers but as data. Copy from the headers line onto the values buffer + for (var i = 0; i < values.Length; i++) + { + values[i] = _csvReader.HeaderRecord[i]; + } + return true; + } + else + { + if (await _csvReader.ReadAsync()) + { + + for (var i = 0; i < values.Length; i++) + { + values[i] = _csvReader.GetField(i); + } + return true; + } + else + { + return false; + } + } + } + } +} diff --git a/src/Rowbot/CsvHelper/AsyncCsvHelperTarget.cs b/src/Rowbot/CsvHelper/AsyncCsvHelperTarget.cs new file mode 100644 index 0000000..4fe058d --- /dev/null +++ b/src/Rowbot/CsvHelper/AsyncCsvHelperTarget.cs @@ -0,0 +1,80 @@ +using CsvHelper; +using CsvHelper.Configuration; +using System; +using System.IO; +using System.Threading.Tasks; + +namespace Rowbot.CsvHelper +{ + public sealed class AsyncCsvHelperTarget : IAsyncRowTarget, IDisposable + { + private readonly CsvWriter _csvWriter; + private readonly bool _writeHeaders; + private int _unflushedRowCount = 0; + private bool _firstWrite = true; + + public AsyncCsvHelperTarget(Stream stream, CsvConfiguration configuration, bool writeHeaders = true, bool leaveOpen = false) : this(new CsvWriter(new StreamWriter(stream), configuration, leaveOpen: leaveOpen)) + { + _writeHeaders = writeHeaders; + } + + public AsyncCsvHelperTarget(CsvWriter csvWriter) + { + _csvWriter = csvWriter; + } + + public void Dispose() + { + _csvWriter?.Dispose(); + } + + public async Task CompleteAsync() + { + await FlushAsync(); + _csvWriter?.Dispose(); + } + + public Task InitAsync(ColumnInfo[] columns) + { + if (_writeHeaders) + { + for (var i = 0; i < columns.Length; i++) + { + _csvWriter.WriteField(columns[i].Name); + } + _firstWrite = false; + } + + return Task.CompletedTask; + } + + public async Task WriteRowAsync(object[] values) + { + if (!_firstWrite) + { + await _csvWriter.NextRecordAsync(); + } + for (var i = 0; i < values.Length; i++) + { + _csvWriter.WriteField(values[i]); + } + _unflushedRowCount++; + _firstWrite = false; + await FlushIfNeeded(); + } + + private async Task FlushIfNeeded() + { + if (_unflushedRowCount > 1000) + { + await FlushAsync(); + } + } + + private async Task FlushAsync() + { + await _csvWriter.FlushAsync(); + _unflushedRowCount = 0; + } + } +} diff --git a/src/Rowbot/Execution/AsyncEnumerableTargetGuards.cs b/src/Rowbot/Execution/AsyncEnumerableTargetGuards.cs new file mode 100644 index 0000000..14697ee --- /dev/null +++ b/src/Rowbot/Execution/AsyncEnumerableTargetGuards.cs @@ -0,0 +1,63 @@ +using System; +using System.Threading.Tasks; + +namespace Rowbot.Execution +{ + public sealed class AsyncEnumerableTargetGuards : IAsyncEnumerableRowTarget, IDisposable + { + private readonly IAsyncEnumerableRowTarget _rowTarget; + + private bool Completed { get; set; } = false; + private bool Initialized { get; set; } = false; + + public AsyncEnumerableTargetGuards(IAsyncEnumerableRowTarget rowTarget) + { + _rowTarget = rowTarget ?? throw new ArgumentNullException(nameof(rowTarget)); + } + + public Task CompleteAsync() + { + if (!Initialized) + throw new InvalidOperationException("Init must be called before Complete()"); + if (Completed) + throw new InvalidOperationException("Complete already called and can only be called once."); + Completed = true; + + return _rowTarget.CompleteAsync(); + } + + public void Dispose() + { + (_rowTarget as IDisposable)?.Dispose(); + } + + public Task InitAsync(ColumnInfo[] columns) + { + if (columns is null) + { + throw new ArgumentNullException(nameof(columns)); + } + + if (Initialized) + throw new InvalidOperationException("Init has already been called and can only be called once."); + Initialized = true; + + return _rowTarget.InitAsync(columns); + } + + public Task WriteRowAsync(object[] values) + { + if (values is null) + { + throw new ArgumentNullException(nameof(values)); + } + + if (!Initialized) + throw new InvalidOperationException("Init must be called before WriteRows"); + if (Completed) + throw new InvalidOperationException("Complete already called. Not allowed to write more rows"); + + return _rowTarget.WriteRowAsync(values); + } + } +} diff --git a/src/Rowbot/Execution/AsyncSourceGuards.cs b/src/Rowbot/Execution/AsyncSourceGuards.cs new file mode 100644 index 0000000..87d9635 --- /dev/null +++ b/src/Rowbot/Execution/AsyncSourceGuards.cs @@ -0,0 +1,75 @@ +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Rowbot.Execution +{ + /// + /// This helper class wraps a source and handles all input and output parameter validation as well as ensuring methods not called out of order. + /// + public sealed class AsyncSourceGuards : IAsyncRowSource, IDisposable + { + private bool Initialized { get; set; } = false; + private bool Completed { get; set; } = false; + private int _columnCount = -1; + private bool? _previousReadResult = null; + private readonly IAsyncRowSource _rowSource; + + public AsyncSourceGuards(IAsyncRowSource rowSource) + { + _rowSource = rowSource ?? throw new ArgumentNullException(nameof(rowSource)); + } + + public async Task InitAndGetColumnsAsync() + { + if (Initialized) + throw new InvalidOperationException("Already initialized"); + Initialized = true; + var columns = await _rowSource.InitAndGetColumnsAsync(); + if (columns == null) + throw new InvalidOperationException("Null was returned by OnInitAndGetColumns() which is not valid."); + if (columns.Any(c => c == null)) + throw new InvalidOperationException("Returned columns array from OnInitAndGetColumns() contains one or more null values which is not allowed."); + + _columnCount = columns.Length; + return columns; + } + + public async Task ReadRowAsync(object[] values) + { + if (values is null) + { + throw new ArgumentNullException(nameof(values)); + } + + if (!Initialized) + throw new InvalidOperationException("Not initialized"); + + if (values.Length != _columnCount) + throw new InvalidOperationException($"Provided values object[] buffer contains {values.Length} slots, but column count returned earlier container {_columnCount} columns. These counts must match."); + + if (_previousReadResult == false) + throw new InvalidOperationException("It is not allowed to call Read after the method has already returned false in a previous call."); + + var readResult = await _rowSource.ReadRowAsync(values); + _previousReadResult = readResult; + + return readResult; + } + + public Task CompleteAsync() + { + if (!Initialized) + throw new InvalidOperationException("Not initialized"); + if (Completed) + throw new InvalidOperationException("Already completed"); + Completed = true; + return _rowSource.CompleteAsync(); + } + + public void Dispose() + { + (_rowSource as IDisposable)?.Dispose(); + } + } +} diff --git a/src/Rowbot/Execution/AsyncTargetGuards.cs b/src/Rowbot/Execution/AsyncTargetGuards.cs new file mode 100644 index 0000000..2377086 --- /dev/null +++ b/src/Rowbot/Execution/AsyncTargetGuards.cs @@ -0,0 +1,63 @@ +using System; +using System.Threading.Tasks; + +namespace Rowbot.Execution +{ + public sealed class AsyncTargetGuards : IAsyncRowTarget, IDisposable + { + private readonly IAsyncRowTarget _rowTarget; + + private bool Completed { get; set; } = false; + private bool Initialized { get; set; } = false; + + public AsyncTargetGuards(IAsyncRowTarget rowTarget) + { + _rowTarget = rowTarget ?? throw new ArgumentNullException(nameof(rowTarget)); + } + + public Task CompleteAsync() + { + if (!Initialized) + throw new InvalidOperationException("Init must be called before Complete()"); + if (Completed) + throw new InvalidOperationException("Complete already called and can only be called once."); + Completed = true; + + return _rowTarget.CompleteAsync(); + } + + public void Dispose() + { + (_rowTarget as IDisposable)?.Dispose(); + } + + public Task InitAsync(ColumnInfo[] columns) + { + if (columns is null) + { + throw new ArgumentNullException(nameof(columns)); + } + + if (Initialized) + throw new InvalidOperationException("Init has already been called and can only be called once."); + Initialized = true; + + return _rowTarget.InitAsync(columns); + } + + public Task WriteRowAsync(object[] values) + { + if (values is null) + { + throw new ArgumentNullException(nameof(values)); + } + + if (!Initialized) + throw new InvalidOperationException("Init must be called before WriteRows"); + if (Completed) + throw new InvalidOperationException("Complete already called. Not allowed to write more rows"); + + return _rowTarget.WriteRowAsync(values); + } + } +} diff --git a/src/Rowbot/Execution/RowbotAsyncExecutor.cs b/src/Rowbot/Execution/RowbotAsyncExecutor.cs new file mode 100644 index 0000000..9e707c8 --- /dev/null +++ b/src/Rowbot/Execution/RowbotAsyncExecutor.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Rowbot.Execution +{ + public sealed class RowbotAsyncExecutor : IDisposable + { + private readonly AsyncSourceGuards _source; + private readonly AsyncTargetGuards _target; + + public RowbotAsyncExecutor(IAsyncRowSource source, IAsyncRowTarget target) + { + _source = new AsyncSourceGuards(source); + _target = new AsyncTargetGuards(target); + } + + public void Dispose() + { +#pragma warning disable S2486 // Generic exceptions should not be ignored +#pragma warning disable S108 // Nested blocks of code should not be left empty + try + { + _source.Dispose(); + } + catch { } + + try + { + _target.Dispose(); + } + catch { } +#pragma warning restore S108 // Nested blocks of code should not be left empty +#pragma warning restore S2486 // Generic exceptions should not be ignored + } + + public async Task ExecuteAsync() + { + // Columns + var columnNames = await _source.InitAndGetColumnsAsync(); + await _target.InitAsync(columns: columnNames); + + // Rows + var valuesBuffer = new object[columnNames.Length]; + while (await _source.ReadRowAsync(valuesBuffer)) + { + await _target.WriteRowAsync(valuesBuffer); + } + + await _source.CompleteAsync(); + await _target.CompleteAsync(); + + Dispose(); + } + } + + public sealed class RowbotAsyncEnumerableExecutor : IDisposable + { + private readonly AsyncSourceGuards _source; + private readonly AsyncEnumerableTargetGuards _target; + + public RowbotAsyncEnumerableExecutor(IAsyncRowSource source, IAsyncEnumerableRowTarget target) + { + _source = new AsyncSourceGuards(source); + _target = new AsyncEnumerableTargetGuards(target); + } + + public Task ExecuteAsync(Func, Task> consumer) + { + return consumer(ExecuteInternal()); + } + + private async IAsyncEnumerable ExecuteInternal() + { + // Columns + var columnNames = await _source.InitAndGetColumnsAsync(); + await _target.InitAsync(columns: columnNames); + + // Rows + var valuesBuffer = new object[columnNames.Length]; + while (await _source.ReadRowAsync(valuesBuffer)) + { + yield return await _target.WriteRowAsync(valuesBuffer); + } + + await _source.CompleteAsync(); + await _target.CompleteAsync(); + + Dispose(); + } + + public void Dispose() + { +#pragma warning disable S2486 // Generic exceptions should not be ignored +#pragma warning disable S108 // Nested blocks of code should not be left empty + try + { + _source.Dispose(); + } + catch { } + + try + { + _target.Dispose(); + } + catch { } +#pragma warning restore S108 // Nested blocks of code should not be left empty +#pragma warning restore S2486 // Generic exceptions should not be ignored + } + } +} diff --git a/src/Rowbot/Execution/RowbotAsyncExecutorBuilder.cs b/src/Rowbot/Execution/RowbotAsyncExecutorBuilder.cs new file mode 100644 index 0000000..aa93a07 --- /dev/null +++ b/src/Rowbot/Execution/RowbotAsyncExecutorBuilder.cs @@ -0,0 +1,113 @@ +using CsvHelper.Configuration; +using Rowbot.CsvHelper; +using Rowbot.Sources; +using Rowbot.Targets; +using System; +using System.Collections.Generic; +using System.Data; +using System.IO; + +namespace Rowbot.Execution +{ + public class RowbotAsyncExecutorBuilder + { + private IAsyncRowSource _rowSource; + + public RowbotAsyncExecutorBuilder() + { + } + + public RowbotAsyncExecutorBuilder FromDataTable(DataTable dataTable) + { + return SetSource(new AsyncDataReaderSource(dataTable.CreateDataReader())); + } + public RowbotAsyncExecutorBuilder FromDataReader(IDataReader dataReader) + { + return SetSource(new AsyncDataReaderSource(dataReader)); + } + public RowbotAsyncExecutorBuilder FromObjects(IAsyncEnumerable objects) + { + return SetSource(AsyncPropertyReflectionSource.Create(objects)); + } + + public RowbotAsyncExecutorBuilder FromDynamic(IAsyncEnumerable objects) + { + return SetSource(new AsyncDynamicObjectSource(objects)); + } + + public RowbotAsyncExecutorBuilder FromCsvByCsvHelper(Stream inputStream, CsvConfiguration csvConfiguration, bool readFirstLineAsHeaders) + { + return SetSource(new AsyncCsvHelperSource(stream: inputStream, configuration: csvConfiguration, readFirstLineAsHeaders: readFirstLineAsHeaders)); + } + public RowbotAsyncExecutorBuilder FromCsvByCsvHelper(string filepath, CsvConfiguration csvConfiguration, bool readFirstLineAsHeaders) + { + var fs = File.Create(filepath); + return SetSource(new AsyncCsvHelperSource(stream: fs, configuration: csvConfiguration, readFirstLineAsHeaders: readFirstLineAsHeaders)); + } + public RowbotAsyncExecutorBuilder From(IAsyncRowSource customRowSource) + { + return SetSource(customRowSource); + } + + private RowbotAsyncExecutorBuilder SetSource(IAsyncRowSource rowSource) + { + if (rowSource is null) + { + throw new ArgumentNullException(nameof(rowSource)); + } + + if (_rowSource != null) + throw new ArgumentException("Source already defined in this builder"); + + _rowSource = rowSource; + return this; + } + + public RowbotAsyncExecutor ToCsvUsingCsvHelper(Stream outputStream, CsvConfiguration config, bool writeHeaders, bool leaveOpen = false) + { + return To(new AsyncCsvHelperTarget(stream: outputStream, configuration: config, writeHeaders: writeHeaders, leaveOpen: leaveOpen)); + } + + public RowbotAsyncExecutor ToCsvUsingCsvHelper(string filepath, CsvConfiguration config, bool writeHeaders) + { + var fs = File.Create(filepath); + return To(new AsyncCsvHelperTarget(stream: fs, configuration: config, writeHeaders: writeHeaders, leaveOpen: false)); + } + + public RowbotAsyncExecutor ToExcel(Stream outputStream, string sheetName, bool writeHeaders, bool leaveOpen = false) + { + return To(new AsyncExcelTarget(outputStream: outputStream, sheetName: sheetName, writeHeaders: writeHeaders, leaveOpen: leaveOpen)); + } + + public RowbotAsyncExecutor ToExcel(string filepath, string sheetName, bool writeHeaders) + { + var fs = File.Create(filepath); + return To(new AsyncExcelTarget(outputStream: fs, sheetName: sheetName, writeHeaders: writeHeaders, leaveOpen: false)); + } + + public RowbotAsyncExecutor ToDataTable(DataTable tableToFill) + { + return To(new AsyncDataTableTarget(tableToFill)); + } + + public RowbotAsyncExecutor ToDataReader() + { + throw new NotImplementedException(); + } + + public RowbotAsyncEnumerableExecutor ToObjects() where TObjectType : new() + { + return ToCustomTarget(new AsyncPropertyReflectionTarget()); + } + + public RowbotAsyncExecutor To(IAsyncRowTarget target) + { + return new RowbotAsyncExecutor(_rowSource, target); + } + + public RowbotAsyncEnumerableExecutor ToCustomTarget(IAsyncEnumerableRowTarget target) + { + return new RowbotAsyncEnumerableExecutor(source: _rowSource, target: target); + } + } +} diff --git a/src/Rowbot/IAsyncEnumerableRowTarget.cs b/src/Rowbot/IAsyncEnumerableRowTarget.cs new file mode 100644 index 0000000..c1b9ec3 --- /dev/null +++ b/src/Rowbot/IAsyncEnumerableRowTarget.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; + +namespace Rowbot +{ + public interface IAsyncEnumerableRowTarget + { + Task InitAsync(ColumnInfo[] columns); + Task WriteRowAsync(object[] values); + Task CompleteAsync(); + } +} diff --git a/src/Rowbot/IAsyncRowSource.cs b/src/Rowbot/IAsyncRowSource.cs new file mode 100644 index 0000000..5b1cc83 --- /dev/null +++ b/src/Rowbot/IAsyncRowSource.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; + +namespace Rowbot +{ + public interface IAsyncRowSource + { + Task InitAndGetColumnsAsync(); + Task ReadRowAsync(object[] values); + Task CompleteAsync(); + } +} diff --git a/src/Rowbot/IAsyncRowTarget.cs b/src/Rowbot/IAsyncRowTarget.cs new file mode 100644 index 0000000..a0ab6f8 --- /dev/null +++ b/src/Rowbot/IAsyncRowTarget.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; + +namespace Rowbot +{ + public interface IAsyncRowTarget + { + Task InitAsync(ColumnInfo[] columns); + Task WriteRowAsync(object[] values); + Task CompleteAsync(); + } +} diff --git a/src/Rowbot/Rowbot.csproj b/src/Rowbot/Rowbot.csproj index d0d3cf2..5471375 100644 --- a/src/Rowbot/Rowbot.csproj +++ b/src/Rowbot/Rowbot.csproj @@ -9,6 +9,7 @@ Stephan Moeller https://github.com/StephanMoeller/Rowbot Fastest possible excel-writer with extremely low memory consumption. + 8 diff --git a/src/Rowbot/Sources/AsyncDataReaderSource.cs b/src/Rowbot/Sources/AsyncDataReaderSource.cs new file mode 100644 index 0000000..c90e864 --- /dev/null +++ b/src/Rowbot/Sources/AsyncDataReaderSource.cs @@ -0,0 +1,65 @@ +using System; +using System.Linq; +using System.Data; +using System.Threading.Tasks; + +namespace Rowbot.Sources +{ + public sealed class AsyncDataReaderSource : IAsyncRowSource, IDisposable + { + private readonly IDataReader _dataReader; + private readonly bool _leaveOpen; + + public AsyncDataReaderSource(IDataReader dataReader, bool leaveOpen = false) + { + _dataReader = dataReader ?? throw new ArgumentNullException(nameof(dataReader)); + _leaveOpen = leaveOpen; + } + + public Task CompleteAsync() + { + if (!_leaveOpen) + { + _dataReader.Close(); + } + + return Task.CompletedTask; + } + + public void Dispose() + { + if (!_leaveOpen) + { + _dataReader.Dispose(); + } + } + + public Task InitAndGetColumnsAsync() + { + var columnInfos = _dataReader.GetSchemaTable().Rows.Cast().Select(row => new ColumnInfo(name: (string)row["ColumnName"], valueType: (Type)row["DataType"])).ToArray(); + return Task.FromResult(columnInfos); + } + + public Task ReadRowAsync(object[] values) + { + if (_dataReader.Read()) + { + _dataReader.GetValues(values); + + // Replace DBNull with null + for (var i = 0; i < values.Length; i++) + { + if (values[i] == DBNull.Value) + { + values[i] = null; + } + } + return Task.FromResult(true); + } + else + { + return Task.FromResult(false); + } + } + } +} diff --git a/src/Rowbot/Sources/AsyncDynamicObjectSource.cs b/src/Rowbot/Sources/AsyncDynamicObjectSource.cs new file mode 100644 index 0000000..4579116 --- /dev/null +++ b/src/Rowbot/Sources/AsyncDynamicObjectSource.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Rowbot.Sources +{ + public class AsyncDynamicObjectSource : IAsyncRowSource, IDisposable + { + private readonly IAsyncEnumerator _enumerator; + private string[] _keys; + private bool _hasCurrentItem = true; + public AsyncDynamicObjectSource(IAsyncEnumerable objects) + { + if (objects is null) + { + throw new ArgumentNullException(nameof(objects)); + } + + this._enumerator = objects.GetAsyncEnumerator(); + } + + public async Task InitAndGetColumnsAsync() + { + _hasCurrentItem = await _enumerator.MoveNextAsync(); + if (!_hasCurrentItem) + { + // There are no rows at all - return an empty column list as there are no dynamic objects available to reveal the properties + _keys = Array.Empty(); + return Array.Empty(); + } + + var current = (IDictionary)_enumerator.Current; + _keys = current.Keys.ToArray(); + return _keys.Select(k => new ColumnInfo(name: k, valueType: typeof(object))).ToArray(); + } + + public async Task ReadRowAsync(object[] values) + { + if (!_hasCurrentItem) + return false; + + var current = (IDictionary)_enumerator.Current; + + AssertEqualsExpectedKeysOrThrow(current.Keys); + + for (var i = 0; i < _keys.Length; i++) + { + values[i] = current[_keys[i]]; + } + + _hasCurrentItem = await _enumerator.MoveNextAsync(); + return true; + } + + private void AssertEqualsExpectedKeysOrThrow(ICollection keys) + { + if (keys.Count != _keys.Length) + throw new DynamicObjectsNotIdenticalException($"Two dynamic objects was not identical in collection. One had keys: [{string.Join(",", _keys)}] and another one had keys: [{string.Join(", ", keys)}]"); + + int i = 0; + foreach(var key in keys) + { + if (key != _keys[i]) + { + throw new DynamicObjectsNotIdenticalException($"Two dynamic objects was not identical in collection. One had keys: [{string.Join(",", _keys)}] and another one had keys: [{string.Join(", ", keys)}]"); + } + i++; + } + } + + public Task CompleteAsync() + { + // Nothing to complete + return Task.CompletedTask; + } + + public void Dispose() + { + // Nothing to dispose + } + } +} diff --git a/src/Rowbot/Sources/AsyncPropertyReflectionSource.cs b/src/Rowbot/Sources/AsyncPropertyReflectionSource.cs new file mode 100644 index 0000000..631973c --- /dev/null +++ b/src/Rowbot/Sources/AsyncPropertyReflectionSource.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +namespace Rowbot.Sources +{ + public class AsyncPropertyReflectionSource : IAsyncRowSource + { + private readonly IAsyncEnumerator _elements; + private readonly PropertyInfo[] _properties; + private readonly ColumnInfo[] _columns; + private AsyncPropertyReflectionSource(IAsyncEnumerator elements, Type elementType) + { + if (elements is null) + { + throw new ArgumentNullException(nameof(elements)); + } + + if (elementType is null) + { + throw new ArgumentNullException(nameof(elementType)); + } + + _elements = elements; + _properties = elementType.GetProperties(); + _columns = _properties.Select(p => new ColumnInfo(name: p.Name, valueType: p.PropertyType)).ToArray(); + } + + public static AsyncPropertyReflectionSource Create(IAsyncEnumerable elements) + { + var t = typeof(T); + if (t == typeof(object)) // Good enough. This will cover the dynamic case but also if someone adds raw object elements which would make no sense anyway. + throw new ArgumentException("Dynamic objects not supported in " + nameof(PropertyReflectionSource) + "."); + + if (elements is null) + { + throw new ArgumentNullException(nameof(elements)); + } + + return new AsyncPropertyReflectionSource(elements.GetAsyncEnumerator(), typeof(T)); + } + + public static AsyncPropertyReflectionSource Create(IAsyncEnumerator elements) + { + if (elements is null) + { + throw new ArgumentNullException(nameof(elements)); + } + + return new AsyncPropertyReflectionSource(elements, typeof(T)); + } + + + public void Dispose() + { + // Nothing to dispose + } + + public Task CompleteAsync() + { + // Nothing to complete in this source + return Task.CompletedTask; + } + + public Task InitAndGetColumnsAsync() + { + return Task.FromResult(_columns); + } + + public async Task ReadRowAsync(object[] values) + { + if (values.Length != _properties.Length) + throw new ArgumentException($"Object array of size {values.Length} provided, but {_properties.Length} columns exist"); + + if (await _elements.MoveNextAsync()) + { + for (var i = 0; i < _properties.Length; i++) + { + values[i] = _properties[i].GetValue(_elements.Current); + } + return true; + } + else + { + return false; + } + } + } +} diff --git a/src/Rowbot/Sources/DynamicObjectSource.cs b/src/Rowbot/Sources/DynamicObjectSource.cs index 447d5d6..2daabfd 100644 --- a/src/Rowbot/Sources/DynamicObjectSource.cs +++ b/src/Rowbot/Sources/DynamicObjectSource.cs @@ -79,9 +79,4 @@ public void Dispose() // Nothing to dispose } } - - public class DynamicObjectsNotIdenticalException : Exception - { - public DynamicObjectsNotIdenticalException(string message) : base(message) { } - } } diff --git a/src/Rowbot/Sources/DynamicObjectsNotIdenticalException.cs b/src/Rowbot/Sources/DynamicObjectsNotIdenticalException.cs new file mode 100644 index 0000000..20344ef --- /dev/null +++ b/src/Rowbot/Sources/DynamicObjectsNotIdenticalException.cs @@ -0,0 +1,9 @@ +using System; + +namespace Rowbot.Sources +{ + public class DynamicObjectsNotIdenticalException : Exception + { + public DynamicObjectsNotIdenticalException(string message) : base(message) { } + } +} \ No newline at end of file diff --git a/src/Rowbot/Targets/AsyncDataTableTarget.cs b/src/Rowbot/Targets/AsyncDataTableTarget.cs new file mode 100644 index 0000000..6a94347 --- /dev/null +++ b/src/Rowbot/Targets/AsyncDataTableTarget.cs @@ -0,0 +1,45 @@ +using System; +using System.Data; +using System.Threading.Tasks; + +namespace Rowbot.Targets +{ + public class AsyncDataTableTarget : IAsyncRowTarget + { + private readonly DataTable _table = null; + + public AsyncDataTableTarget(DataTable tableToFill) + { + if (tableToFill.Columns.Count > 0 || tableToFill.Rows.Count > 0) + throw new ArgumentException("Provided table must be empty. Columns and/or rows found."); + _table = tableToFill; + } + + public Task CompleteAsync() + { + return Task.CompletedTask; + } + + public Task InitAsync(ColumnInfo[] columns) + { + foreach (var columnInfo in columns) + { + _table.Columns.Add(new DataColumn(columnName: columnInfo.Name, dataType: columnInfo.ValueType)); + } + + return Task.CompletedTask; + } + + public Task WriteRowAsync(object[] values) + { + var row = _table.NewRow(); + for (var i = 0; i < values.Length; i++) + { + row[i] = values[i] ?? DBNull.Value; + } + _table.Rows.Add(row); + + return Task.CompletedTask; + } + } +} diff --git a/src/Rowbot/Targets/AsyncDynamicObjectTarget.cs b/src/Rowbot/Targets/AsyncDynamicObjectTarget.cs new file mode 100644 index 0000000..eb6b671 --- /dev/null +++ b/src/Rowbot/Targets/AsyncDynamicObjectTarget.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Dynamic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Rowbot.Targets +{ + public class AsyncDynamicObjectTarget : IDisposable // Not possible: : IEnumerableRowTarget + { + private string[] _columnNames; + + public AsyncDynamicObjectTarget() + { + } + + public void Dispose() + { + } + + public Task CompleteAsync() + { + return Task.CompletedTask; + } + + public Task InitAsync(ColumnInfo[] columns) + { + _columnNames = columns.Select(c => c.Name).ToArray(); + + return Task.CompletedTask; + } + + public Task WriteRowAsync(object[] values) + { + IDictionary newObject = new ExpandoObject(); + for (var i = 0; i < values.Length; i++) + { + newObject.Add(_columnNames[i], values[i]); + } + return Task.FromResult(newObject); + } + } +} diff --git a/src/Rowbot/Targets/AsyncExcelTarget.cs b/src/Rowbot/Targets/AsyncExcelTarget.cs new file mode 100644 index 0000000..64971ec --- /dev/null +++ b/src/Rowbot/Targets/AsyncExcelTarget.cs @@ -0,0 +1,338 @@ +using ICSharpCode.SharpZipLib.Zip; +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Rowbot.Targets +{ + public class AsyncExcelTarget : IAsyncRowTarget, IDisposable + { + private readonly Stream _outputStream; + private readonly string _sheetName; + private readonly bool _writeHeaders; + private readonly bool _leaveOpen; + private ColumnInfo[] _columns; + private readonly ZipOutputStream _zipOutputStream; + private readonly UTF8Encoding _utf8; + private byte[] _buffer = new byte[1024]; + private int _bufferIndex = 0; + private static readonly string _columnChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + private string[] _excelColumnNames = null; + private int _rowIndex = 0; + private string _cache_minMaxColString; + + /// + /// + /// + /// + /// + /// + /// + /// A number from 0-9 where 0 means no compression and 9 means max. The higher the number, the smaller output size, but the more execution time. + /// + public AsyncExcelTarget(Stream outputStream, string sheetName, bool writeHeaders, bool leaveOpen = false, int compressionLevel = 1) + { + if (string.IsNullOrEmpty(sheetName)) + { + throw new ArgumentException($"'{nameof(sheetName)}' cannot be null or empty.", nameof(sheetName)); + } + + _zipOutputStream = new ZipOutputStream(baseOutputStream: outputStream, bufferSize: 8_000_000); // 8MB buffer chosen out of blue air + _zipOutputStream.SetLevel(compressionLevel); + _zipOutputStream.IsStreamOwner = !leaveOpen; + _outputStream = outputStream; + _sheetName = sheetName; + _writeHeaders = writeHeaders; + _leaveOpen = leaveOpen; + _utf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + } + + public void Dispose() + { + _zipOutputStream?.Dispose(); + if (!_leaveOpen) + { + _outputStream.Dispose(); + } + } + + public static string GetColumnName(int oneBasedColumnIndex) + { + StringBuilder sb = new StringBuilder(); + var remainder = oneBasedColumnIndex; + while (remainder > 0) + { + var nextIndex = (remainder - 1) % 26; + sb.Insert(0, _columnChars[nextIndex]); + remainder -= nextIndex + 1; + remainder /= 26; + } + return sb.ToString(); + } + + public async Task InitAsync(ColumnInfo[] columns) + { + _columns = columns; + await WriteStaticFilesToArchiveAsync(); + + await _zipOutputStream.PutNextEntryAsync(new ZipEntry("xl/worksheets/sheet1.xml")); + + WriteSheetStartToSheetStream(); + _excelColumnNames = new string[columns.Length]; + + for (int i = 0; i < columns.Length; i++) + { + _excelColumnNames[i] = GetColumnName(oneBasedColumnIndex: i + 1); + }; + + _rowIndex = 1; + + int minCol = 1; + int maxCol = _columns.Length; + _cache_minMaxColString = $"{minCol}:{maxCol}"; + + if (_writeHeaders) + { + await WriteRowAsync(columns.Select(c => c.Name).Cast().ToArray()); + } + } + + public async Task CompleteAsync() + { + WriteSheetEndToSheetStream(); + await FlushAsync(); + _zipOutputStream.Close(); + if (!_leaveOpen) + { + _outputStream.Close(); + } + } + + private readonly CultureInfo _numberFormatter = new CultureInfo("en-US"); + public async Task WriteRowAsync(object[] values) + { + WriteSheetBytes(@""); + + // Blank: + // null + + // Boolean: + // bool + + // Number: + // SByte, Byte, Int16, UInt16, Int32, UInt32, Int64, UInt64 + // Single, Double, Decimal + + // Text + // String + // Fallback .ToString() + + // DateTime + // DateTime, DateTimeOffset + + // TimeSpan + // TimeSpan + + for (var i = 0; i < values.Length; i++) + { + var value = values[i]; + if (value == null) + { + WriteSheetBytes(@""); + } + else + { + switch (value) + { + + case bool boolVal: + if (boolVal) + { + WriteSheetBytes(@"1"); + } + else + { + WriteSheetBytes(@"0"); + } + break; + case sbyte val: + WriteSheetBytes(@"", val.ToString(), ""); + break; + case byte val: + WriteSheetBytes(@"", val.ToString(), ""); + break; + case short val: + WriteSheetBytes(@"", val.ToString(), ""); + break; + case ushort val: + WriteSheetBytes(@"", val.ToString(), ""); + break; + case int val: + WriteSheetBytes(@"", val.ToString(), ""); + break; + case uint val: + WriteSheetBytes(@"", val.ToString(), ""); + break; + case long val: + WriteSheetBytes(@"", val.ToString(), ""); + break; + case ulong val: + WriteSheetBytes(@"", val.ToString(), ""); + break; + case float val: + WriteSheetBytes(@"", val.ToString(_numberFormatter), ""); + break; + case double val: + WriteSheetBytes(@"", val.ToString(_numberFormatter), ""); + break; + case decimal val: + WriteSheetBytes(@"", val.ToString(_numberFormatter), ""); + break; + case string str: + WriteSheetBytes(@"", Escape(str), ""); + break; + case DateTime val: + WriteSheetBytes(@"", val.ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'Z'"), ""); + break; + default: + WriteSheetBytes(@"", Escape(value.ToString()), ""); + break; + } + }; + } + + WriteSheetBytes(""); + + _rowIndex++; + if (_rowIndex % 1000 == 0) + await FlushSheetStreamIfNeededAsync(); + } + + + private static string Escape(string text) + { + return text.Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace("'", "'").Replace("\"", """); + } + + + private void WriteSheetStartToSheetStream() + { + WriteSheetBytes(@" + + + + + + + + + "); + } + + private void WriteSheetEndToSheetStream() + { + WriteSheetBytes(@" + +"); + } + + private async Task WriteStaticFilesToArchiveAsync() + { + await WriteCompleteFileAsync(path: "[Content_Types].xml", @" + + + + + +"); + + await WriteCompleteFileAsync(path: "_rels/.rels", @" + + +"); + + await WriteCompleteFileAsync(path: "xl/_rels/workbook.xml.rels", @" + + +"); + + var escapedSheetname = _sheetName.Replace("<", "<").Replace(">", ">").Replace("\"", """); + await WriteCompleteFileAsync(path: "xl/workbook.xml", @" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +"); + } + + private async Task WriteCompleteFileAsync(string path, string completeContent) + { + await _zipOutputStream.PutNextEntryAsync(new ZipEntry(path)); + var buffer = _utf8.GetBytes(completeContent); + await _zipOutputStream.WriteAsync(buffer, 0, buffer.Length); + await _zipOutputStream.FlushAsync(); + } + + private void WriteSheetBytes(params string[] strings) + { + foreach (var str in strings) + { + // Grow buffer + if (_bufferIndex + str.Length * 4 > _buffer.Length - 1) + { + int newSize = Math.Max(_bufferIndex + str.Length * 4, _buffer.Length * 2); + Array.Resize(ref _buffer, newSize); + } + + var bytesWritten = _utf8.GetBytes(s: str, charIndex: 0, charCount: str.Length, bytes: _buffer, byteIndex: _bufferIndex); + _bufferIndex += bytesWritten; + } + } + + private async Task FlushSheetStreamIfNeededAsync() + { + if (_bufferIndex > 8_000_000) + { + await FlushAsync(); + } + } + + private async Task FlushAsync() + { + await _zipOutputStream.WriteAsync(_buffer, 0, _bufferIndex); + await _zipOutputStream.FlushAsync(); + _bufferIndex = 0; + } + } +} diff --git a/src/Rowbot/Targets/AsyncPropertyReflectionTarget.cs b/src/Rowbot/Targets/AsyncPropertyReflectionTarget.cs new file mode 100644 index 0000000..26ac22a --- /dev/null +++ b/src/Rowbot/Targets/AsyncPropertyReflectionTarget.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +namespace Rowbot.Targets +{ + public class AsyncPropertyReflectionTarget : IAsyncEnumerableRowTarget where T : new() + { + private PropertyInfo[] _propertiesByColumnIndex; + private int[] _supportedIndexes; + private bool[] _throwIfSourceValuesIsNullByIndex; + public AsyncPropertyReflectionTarget() + { + // Sanity check that there is anything to write on this object + var allWritableProperties = typeof(T).GetProperties().Where(p => p.CanWrite).ToArray(); + if (allWritableProperties.Length == 0) + throw new ArgumentException($"No writable properties found on type {typeof(T).FullName}"); + } + + public Task CompleteAsync() + { + // Nothing to complete in this source + return Task.CompletedTask; + } + + public Task InitAsync(ColumnInfo[] columns) + { + _propertiesByColumnIndex = new PropertyInfo[columns.Length]; + _throwIfSourceValuesIsNullByIndex = new bool[columns.Length]; + var supportedColumnIndexes = new List(); + var allProperties = typeof(T).GetProperties(); + for (var i = 0; i < columns.Length; i++) + { + var column = columns[i]; + var propertyInfoOrNull = FindPropertyOrNull(columnInfo: column, columnIndex: i, allProperties: allProperties); + if (propertyInfoOrNull != null) + { + _propertiesByColumnIndex[i] = propertyInfoOrNull; + supportedColumnIndexes.Add(i); + } + } + + _supportedIndexes = supportedColumnIndexes.ToArray(); + + return Task.CompletedTask; + } + + private PropertyInfo FindPropertyOrNull(ColumnInfo columnInfo, int columnIndex, PropertyInfo[] allProperties) + { + var matchingProperties = allProperties.Where(p => p.Name.Equals(columnInfo.Name, StringComparison.OrdinalIgnoreCase)).ToArray(); + if (matchingProperties.Length > 1) + throw new InvalidOperationException($"The type {typeof(T).FullName} has multiple properties matching column name {columnInfo.Name} when compared case insensitive. This is not supported."); + var property = matchingProperties.SingleOrDefault(); + if (property == null) + { + // No property matches + return null; + } + + if (!property.CanWrite) + { + // No setter on property + return null; + } + + if (columnInfo.ValueType == property.PropertyType) + return property; // Exact type match + + // Types are not exact matches. If property has a nullable value and input has a non-nullable, then this should be allowed + var propertyUnderlayingTypeOrNull = Nullable.GetUnderlyingType(property.PropertyType); + if (propertyUnderlayingTypeOrNull != null && columnInfo.ValueType == propertyUnderlayingTypeOrNull) + { + return property; + } + + var columnUnderlayingTypeOrNull = Nullable.GetUnderlyingType(columnInfo.ValueType); + if (columnUnderlayingTypeOrNull != null && columnUnderlayingTypeOrNull == property.PropertyType) + { + // Column type is nullable, property is not-nullable. This should only break if actual null value is copied + _throwIfSourceValuesIsNullByIndex[columnIndex] = true; + return property; + } + + throw new TypeMismatchException($"Column with name {columnInfo.Name} at index {columnIndex} is of type {columnInfo.ValueType.FullName} but matching property on type {typeof(T).FullName} is of type {property.PropertyType.FullName}"); + } + + public Task WriteRowAsync(object[] values) + { + var element = new T(); + for (var i = 0; i < _supportedIndexes.Length; i++) + { + var index = _supportedIndexes[i]; + var value = values[index]; + var property = _propertiesByColumnIndex[index]; + if (value == null && _throwIfSourceValuesIsNullByIndex[index]) + throw new ArgumentNullException($"Property {property.Name} on type {typeof(T).FullName} cannot be set to NULL"); + property.SetValue(element, value); + } + + return Task.FromResult(element); + } + } +} diff --git a/src/Rowbot/Targets/PropertyReflectionTarget.cs b/src/Rowbot/Targets/PropertyReflectionTarget.cs index 1f94fa3..a3ca220 100644 --- a/src/Rowbot/Targets/PropertyReflectionTarget.cs +++ b/src/Rowbot/Targets/PropertyReflectionTarget.cs @@ -98,9 +98,4 @@ public T WriteRow(object[] values) return element; } } - - public class TypeMismatchException : Exception - { - public TypeMismatchException(string msg) : base(msg) { } - } } diff --git a/src/Rowbot/Targets/TypeMismatchException.cs b/src/Rowbot/Targets/TypeMismatchException.cs new file mode 100644 index 0000000..ceccbfb --- /dev/null +++ b/src/Rowbot/Targets/TypeMismatchException.cs @@ -0,0 +1,9 @@ +using System; + +namespace Rowbot.Targets +{ + public class TypeMismatchException : Exception + { + public TypeMismatchException(string msg) : base(msg) { } + } +} \ No newline at end of file diff --git a/tests/Rowbot.Test/CsvHelper/AsyncCsvHelperSource_Test.cs b/tests/Rowbot.Test/CsvHelper/AsyncCsvHelperSource_Test.cs new file mode 100644 index 0000000..a18b777 --- /dev/null +++ b/tests/Rowbot.Test/CsvHelper/AsyncCsvHelperSource_Test.cs @@ -0,0 +1,104 @@ +using CsvHelper; +using CsvHelper.Configuration; +using System.Globalization; +using System.Text; + +namespace Rowbot.CsvHelper.Test +{ + public class AsyncCsvHelperSource_Test + { + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task EmptyInput_ExpectException(bool readFirstLineAsHeaders) + { + var utf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + var stream = new MemoryStream(); + stream.Write(utf8.GetBytes("")); + stream.Position = 0; + + var source = new AsyncCsvHelperSource(stream, new CsvConfiguration(CultureInfo.InvariantCulture), readFirstLineAsHeaders: readFirstLineAsHeaders); + _ = await Assert.ThrowsAsync(() => source.InitAndGetColumnsAsync()); + } + + [Fact] + public async Task NonEmptyInput_ReadFirstLineAsHeaders() + { + var utf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + var stream = new MemoryStream(); + stream.Write(utf8.GetBytes("Col1;Col2;Col3\r\nData1A;Data1B;Data1C\r\nData2A;Data2B;Data2C")); + stream.Position = 0; + + var source = new AsyncCsvHelperSource(stream, new CsvConfiguration(CultureInfo.InvariantCulture) { Delimiter = ";" }, readFirstLineAsHeaders: true); + var columns = await source.InitAndGetColumnsAsync(); + Assert.Equal(3, columns.Length); + Assert.Equal("Col1", columns[0].Name); + Assert.Equal(typeof(string), columns[0].ValueType); + + Assert.Equal("Col2", columns[1].Name); + Assert.Equal(typeof(string), columns[1].ValueType); + + Assert.Equal("Col3", columns[2].Name); + Assert.Equal(typeof(string), columns[2].ValueType); + + var lines = new List(); + var buffer = new object[3]; + while (await source.ReadRowAsync(buffer)) + { + lines.Add(buffer); + buffer = new object[3]; + } + + Assert.Equal(2, lines.Count); + Assert.Equal("Data1A", lines[0][0]); + Assert.Equal("Data1B", lines[0][1]); + Assert.Equal("Data1C", lines[0][2]); + + Assert.Equal("Data2A", lines[1][0]); + Assert.Equal("Data2B", lines[1][1]); + Assert.Equal("Data2C", lines[1][2]); + } + + [Fact] + public async Task NonEmptyInput_ReadFirstLineAsData() + { + var utf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + var stream = new MemoryStream(); + stream.Write(utf8.GetBytes("Data1A;Data1B;Data1C\r\nData2A;Data2B;Data2C\r\nData3A;Data3B;Data3C")); + stream.Position = 0; + + var source = new AsyncCsvHelperSource(stream, new CsvConfiguration(CultureInfo.InvariantCulture) { Delimiter = ";" }, readFirstLineAsHeaders: false); + var columns = await source.InitAndGetColumnsAsync(); + Assert.Equal(3, columns.Length); + Assert.Equal("Column1", columns[0].Name); + Assert.Equal(typeof(string), columns[0].ValueType); + + Assert.Equal("Column2", columns[1].Name); + Assert.Equal(typeof(string), columns[1].ValueType); + + Assert.Equal("Column3", columns[2].Name); + Assert.Equal(typeof(string), columns[2].ValueType); + + var lines = new List(); + var buffer = new object[3]; + while (await source.ReadRowAsync(buffer)) + { + lines.Add(buffer); + buffer = new object[3]; + } + + Assert.Equal(3, lines.Count); + Assert.Equal("Data1A", lines[0][0]); + Assert.Equal("Data1B", lines[0][1]); + Assert.Equal("Data1C", lines[0][2]); + + Assert.Equal("Data2A", lines[1][0]); + Assert.Equal("Data2B", lines[1][1]); + Assert.Equal("Data2C", lines[1][2]); + + Assert.Equal("Data3A", lines[2][0]); + Assert.Equal("Data3B", lines[2][1]); + Assert.Equal("Data3C", lines[2][2]); + } + } +} diff --git a/tests/Rowbot.Test/CsvHelper/AsyncCsvHelperTarget_Test.cs b/tests/Rowbot.Test/CsvHelper/AsyncCsvHelperTarget_Test.cs new file mode 100644 index 0000000..c0529ba --- /dev/null +++ b/tests/Rowbot.Test/CsvHelper/AsyncCsvHelperTarget_Test.cs @@ -0,0 +1,135 @@ +using CsvHelper.Configuration; +using System.Globalization; +using System.Text; + +namespace Rowbot.CsvHelper.Test +{ + public class AsyncCsvHelperTarget_Test + { + [Fact] + public async Task WriteColumnsAndRows_NoRows_ExpectEmptyTable_Test() + { + using (var ms = new MemoryStream()) + using (var target = new AsyncCsvHelperTarget(ms, new CsvConfiguration(CultureInfo.InvariantCulture) { Delimiter = ";", Quote = '\'', NewLine = "\r\n" }, writeHeaders: true, leaveOpen: true)) + { + await target.InitAsync(new ColumnInfo[]{ + new ColumnInfo(name: "Col1", valueType: typeof(string)), + new ColumnInfo(name: "Col2", valueType: typeof(decimal)), + new ColumnInfo(name: "Col æøå 3; and this is an inline quote: ' awdawd", valueType: typeof(int)), + }); + + await target.CompleteAsync(); + + var result = Encoding.UTF8.GetString(ms.ToArray()); + Assert.Equal("Col1;Col2;'Col æøå 3; and this is an inline quote: \"' awdawd'", result); + } + } + + [Fact] + public async Task WriteColumnsAndRows_WithHeaders_Test() + { + using (var ms = new MemoryStream()) + using (var target = new AsyncCsvHelperTarget(ms, new CsvConfiguration(CultureInfo.InvariantCulture) { Delimiter = ";", Quote = '\'', NewLine = "\r\n" }, writeHeaders: true, leaveOpen: true)) + { + + await target.InitAsync(new ColumnInfo[]{ + new ColumnInfo(name: "Col1", valueType: typeof(string)), + new ColumnInfo(name: "Col2", valueType: typeof(decimal)), + new ColumnInfo(name: "Col3", valueType: typeof(object)), + }); + + await target.WriteRowAsync(new object?[] { "Hello there æå 1", -12.45m, "hi", "there" }); // non-strings + await target.WriteRowAsync(new object?[] { "Hello there æå 2", 12.45m, null, null }); // null values + await target.WriteRowAsync(new object?[] { "Hello there æå 3", null, "This text has a ' in it.", "And this has a \r CR" }); // Quotes are double encoded (escaped) + + await target.CompleteAsync(); + + var result = Encoding.UTF8.GetString(ms.ToArray()); + Assert.Equal("Col1;Col2;Col3\r\nHello there æå 1;-12.45;hi;there\r\nHello there æå 2;12.45;;\r\nHello there æå 3;;'This text has a \"' in it.';And this has a \r CR", result); + } + } + + [Fact] + public async Task WriteColumnsAndRows_WithoutHeaders_Test() + { + using (var ms = new MemoryStream()) + using (var target = new AsyncCsvHelperTarget(ms, new CsvConfiguration(CultureInfo.InvariantCulture) { Delimiter = ";", Quote = '\'', NewLine = "\r\n" }, writeHeaders: false, leaveOpen: true)) + { + + await target.InitAsync(new ColumnInfo[]{ + new ColumnInfo(name: "Col1", valueType: typeof(string)), + new ColumnInfo(name: "Col2", valueType: typeof(decimal)), + new ColumnInfo(name: "Col3", valueType: typeof(object)), + }); + + await target.WriteRowAsync(new object?[] { "Hello there æå 1", -12.45m, "hi", "there" }); // non-strings + await target.WriteRowAsync(new object?[] { "Hello there æå 2", 12.45m, null, null }); // null values + await target.WriteRowAsync(new object?[] { "Hello there æå 3", null, "This text has a ' in it.", "And this has a \r CR" }); // Quotes are double encoded (escaped) + + await target.CompleteAsync(); + + var result = Encoding.UTF8.GetString(ms.ToArray()); + Assert.Equal("Hello there æå 1;-12.45;hi;there\r\nHello there æå 2;12.45;;\r\nHello there æå 3;;'This text has a \"' in it.';And this has a \r CR", result); + } + } + + public const int Call_Complete = 1; + public const int Call_Dispose = 2; + + [Theory] + [InlineData(Call_Complete, true, true)] + [InlineData(Call_Complete, false, false)] + [InlineData(Call_Complete, null, false)] + [InlineData(Call_Dispose, true, true)] + [InlineData(Call_Dispose, false, false)] + [InlineData(Call_Dispose, null, false)] + public async Task LeaveOpen_EnsureDisposingStreamAccordingToLeaveOpenValue_Test(int whatToCall, bool? leaveOpen, bool expectToLeaveOpen) + { + using (var ms = new MemoryStream()) + { + AsyncCsvHelperTarget target; + if (leaveOpen == null) + { + // Rely on default behaviour + target = new AsyncCsvHelperTarget(ms, new CsvConfiguration(CultureInfo.InvariantCulture) { Delimiter = ";", Quote = '\'', NewLine = "\r\n" }, writeHeaders: false); + } + else + { + target = new AsyncCsvHelperTarget(ms, new CsvConfiguration(CultureInfo.InvariantCulture) { Delimiter = ";", Quote = '\'', NewLine = "\r\n" }, writeHeaders: false, leaveOpen: leaveOpen.Value); + } + + using (target) + { + // Ensure init and writeWrote are allowed and stream not disposed as of yet + await target.InitAsync(new ColumnInfo[]{ + new ColumnInfo(name: "Col1", valueType: typeof(string)), + new ColumnInfo(name: "Col2", valueType: typeof(decimal)), + new ColumnInfo(name: "Col3", valueType: typeof(object)), + }); + + await target.WriteRowAsync(new object?[] { "Hello there æå 1", -12.45m, "hi", "there" }); // non-strings + + // Now call complete + if (whatToCall == Call_Complete) + { + await target.CompleteAsync(); + } + else + { + target.Dispose(); + } + + // And ensure memory stream is disposed only if leaveOpen was false + if (expectToLeaveOpen) + { + ms.WriteByte(1); + } + else + { + Assert.Throws(() => ms.WriteByte(1)); + } + } + } + } + } +} \ No newline at end of file diff --git a/tests/Rowbot.Test/Execution/AsyncEnumerableTargetGuards_Test.cs b/tests/Rowbot.Test/Execution/AsyncEnumerableTargetGuards_Test.cs new file mode 100644 index 0000000..1d58ad4 --- /dev/null +++ b/tests/Rowbot.Test/Execution/AsyncEnumerableTargetGuards_Test.cs @@ -0,0 +1,218 @@ +using Rowbot.Execution; + +namespace Rowbot.Test.Execution +{ + public class AsyncEnumerableTargetGuards_Test + { + [Fact] + public async Task CallingWriteRowAfterCompleted_ExpectException_Test() + { + var target = new UnitTestTarget(); + var guard = new AsyncEnumerableTargetGuards(target); + + await guard.InitAsync(new ColumnInfo[]{ + new ColumnInfo(name: "Col1", valueType: typeof(string)), + new ColumnInfo(name: "Col2", valueType: typeof(decimal)), + new ColumnInfo(name: "Col æøå 3", valueType: typeof(int)), + }); + + var result1 = await guard.WriteRowAsync(new object[] { "Hello there æå 1", -12.45m, 42 }); + var result2 = await guard.WriteRowAsync(new object[] { "Hello there æå 1", -12.45m, 42 }); + + Assert.Equal(1001, result1); + Assert.Equal(1002, result2); + await guard.CompleteAsync(); + + _ = await Assert.ThrowsAsync(() => guard.WriteRowAsync(new object[] { "Hello there æå 1", -12.45m, 42 })); + + Assert.Equal(2, target.OnWriteRowCallCount); + } + + [Fact] + public async Task CallingWriteRow_NullValues_ExpectException_Test() + { + var target = new UnitTestTarget(); + var guard = new AsyncEnumerableTargetGuards(target); + + await guard.InitAsync(new ColumnInfo[]{ + new ColumnInfo(name: "Col1", valueType: typeof(string)), + new ColumnInfo(name: "Col2", valueType: typeof(decimal)), + new ColumnInfo(name: "Col æøå 3", valueType: typeof(int)), + }); + + await guard.WriteRowAsync(new object[] { "Hello there æå 1", -12.45m, 42 }); + await guard.WriteRowAsync(new object[] { "Hello there æå 1", -12.45m, 42 }); + await guard.WriteRowAsync(new object[] { "Hello there æå 1", -12.45m, 42 }); + await guard.WriteRowAsync(new object[] { "Hello there æå 1", -12.45m, 42 }); + Assert.Equal(4, target.OnWriteRowCallCount); + + _ = await Assert.ThrowsAsync(() => guard.WriteRowAsync(null)); + Assert.Equal(4, target.OnWriteRowCallCount); + + // Ensure possible to call with non-values afterwards + await guard.WriteRowAsync(new object[] { "Hello there æå 1", -12.45m, 42 }); + await guard.WriteRowAsync(new object[] { "Hello there æå 1", -12.45m, 42 }); + + Assert.Equal(6, target.OnWriteRowCallCount); + } + + [Fact] + public async Task Call_WriteRowBeforeCallingInit_ExpectException_Test() + { + var target = new UnitTestTarget(); + var guard = new AsyncEnumerableTargetGuards(target); + + _ = await Assert.ThrowsAsync(() => guard.WriteRowAsync(new object[] { "Hello there æå 1", -12.45m, 42 })); + Assert.Equal(0, target.OnWriteRowCallCount); + } + + [Fact] + public async Task Call_InitTwice_ExpectException_Test() + { + var target = new UnitTestTarget(); + var guard = new AsyncEnumerableTargetGuards(target); + + await guard.InitAsync(new ColumnInfo[]{ + new ColumnInfo(name: "Col1", valueType: typeof(string)), + new ColumnInfo(name: "Col2", valueType: typeof(decimal)), + new ColumnInfo(name: "Col æøå 3", valueType: typeof(int)), + }); + + _ = await Assert.ThrowsAsync(() => guard.InitAsync(new ColumnInfo[]{ + new ColumnInfo(name: "Col1", valueType: typeof(string)), + new ColumnInfo(name: "Col2", valueType: typeof(decimal)), + new ColumnInfo(name: "Col æøå 3", valueType: typeof(int)), + })); + + Assert.Equal(1, target.OnInitCallCount); + } + + [Fact] + public async Task Call_Init_WithNull_ExpectException_Test() + { + var target = new UnitTestTarget(); + var guard = new AsyncEnumerableTargetGuards(target); + + _ = await Assert.ThrowsAsync(() => guard.InitAsync(null)); + Assert.Equal(0, target.OnInitCallCount); + + // Ensure possible to call init once afterwards + await guard.InitAsync(new ColumnInfo[]{ + new ColumnInfo(name: "Col1", valueType: typeof(string)), + new ColumnInfo(name: "Col2", valueType: typeof(decimal)), + new ColumnInfo(name: "Col æøå 3", valueType: typeof(int)), + }); + Assert.Equal(1, target.OnInitCallCount); + } + + [Fact] + public async Task Call_CompleteBeforeInit_ExpectException_Test() + { + var target = new UnitTestTarget(); + var guard = new AsyncEnumerableTargetGuards(target); + + _ = await Assert.ThrowsAsync(() => guard.CompleteAsync()); + + Assert.Equal(0, target.OnCompleteCallCount); + } + + [Fact] + public async Task Call_CompleteTwice_ExpectException_Test() + { + var target = new UnitTestTarget(); + var guard = new AsyncEnumerableTargetGuards(target); + + await guard.InitAsync(new ColumnInfo[]{ + new ColumnInfo(name: "Col1", valueType: typeof(string)), + new ColumnInfo(name: "Col2", valueType: typeof(decimal)), + new ColumnInfo(name: "Col æøå 3", valueType: typeof(int)), + }); + + await guard.CompleteAsync(); + + _ = await Assert.ThrowsAsync(() => guard.CompleteAsync()); + + Assert.Equal(1, target.OnCompleteCallCount); + } + + [Fact] + public async Task Dispose_EnsureCallingDisposeIfTargetImplementsIDisposable() + { + var target = new UnitTestTargetWithDispose(); + var guard = new AsyncEnumerableTargetGuards(target); + + await guard.InitAsync(new ColumnInfo[0]); + await guard.WriteRowAsync(new object[0]); + await guard.CompleteAsync(); + + Assert.Equal(0, target.DisposeCallCount); + + guard.Dispose(); + + Assert.Equal(1, target.DisposeCallCount); + +#pragma warning disable S3966 // Objects should not be disposed more than once + guard.Dispose(); +#pragma warning restore S3966 // Objects should not be disposed more than once + + Assert.Equal(2, target.DisposeCallCount); + } + + private sealed class UnitTestTarget : IAsyncEnumerableRowTarget + { + public int OnCompleteCallCount = 0; + public int OnInitCallCount = 0; + public int OnWriteRowCallCount = 0; + public int DisposeCallCount = 0; + + public void Dispose() + { + DisposeCallCount++; + } + + public Task CompleteAsync() + { + OnCompleteCallCount++; + return Task.CompletedTask; + } + + public Task InitAsync(ColumnInfo[] columns) + { + OnInitCallCount++; + return Task.CompletedTask; + } + + private int _writeCounter = 1000; + public Task WriteRowAsync(object[] values) + { + OnWriteRowCallCount++; + _writeCounter++; + return Task.FromResult(_writeCounter); + } + } + + private sealed class UnitTestTargetWithDispose : IAsyncEnumerableRowTarget, IDisposable + { + public int DisposeCallCount { get; private set; } = 0; + public void Dispose() + { + DisposeCallCount++; + } + + public Task CompleteAsync() + { + return Task.CompletedTask; + } + + public Task InitAsync(ColumnInfo[] columns) + { + return Task.CompletedTask; + } + + public Task WriteRowAsync(object[] values) + { + return Task.FromResult(42); + } + } + } +} diff --git a/tests/Rowbot.Test/Execution/AsyncSourceGuards_Test.cs b/tests/Rowbot.Test/Execution/AsyncSourceGuards_Test.cs new file mode 100644 index 0000000..52026c3 --- /dev/null +++ b/tests/Rowbot.Test/Execution/AsyncSourceGuards_Test.cs @@ -0,0 +1,220 @@ +using Rowbot.Execution; + +namespace Rowbot.Test.Execution +{ + public class AsyncSourceGuards_Test + { + [Fact] + public async Task Call_InitAndGetColumns_EnsureArrayPassedThrough() + { + var source = new UnitTestSource(); + var guard = new AsyncSourceGuards(source); + Assert.Equal(0, source.OnInitCallCount); + + var columns = await guard.InitAndGetColumnsAsync(); + Assert.Same(source.Columns, columns); + Assert.Equal(1, source.OnInitCallCount); + } + + [Fact] + public async Task Call_InitAndGetColumns_EmptyColumnArray_ExpectAllowed() + { + var emptyColumnArray = new ColumnInfo[0]; + + var source = new UnitTestSource(); + var guard = new AsyncSourceGuards(source); + source.Columns = emptyColumnArray; + + Assert.Equal(0, source.OnInitCallCount); + + var columns = await guard.InitAndGetColumnsAsync(); + Assert.Same(emptyColumnArray, columns); + } + + [Fact] + public async Task Call_InitTwice_ExpectException() + { + var source = new UnitTestSource(); + var guard = new AsyncSourceGuards(source); + Assert.Equal(0, source.OnInitCallCount); + + _ = await guard.InitAndGetColumnsAsync(); + Assert.Equal(1, source.OnInitCallCount); + _ = await Assert.ThrowsAsync(() => guard.InitAndGetColumnsAsync()); + Assert.Equal(1, source.OnInitCallCount); + } + + [Fact] + public async Task Call_CompleteBeforeInit_ExpectException() + { + var source = new UnitTestSource(); + var guard = new AsyncSourceGuards(source); + _ = await Assert.ThrowsAsync(() => guard.CompleteAsync()); + Assert.Equal(0, source.OnCompleteCallCount); + } + + [Fact] + public async Task Call_CompleteMultipleTimes_ExpectException() + { + var source = new UnitTestSource(); + var guard = new AsyncSourceGuards(source); + _ = await guard.InitAndGetColumnsAsync(); + await guard.CompleteAsync(); + Assert.Equal(1, source.OnCompleteCallCount); + + _ = await Assert.ThrowsAsync(() => guard.CompleteAsync()); + Assert.Equal(1, source.OnCompleteCallCount); + } + + [Fact] + public async Task Successful_Run_AssertAllMethodsCalled() + { + var source = new UnitTestSource(); + var guard = new AsyncSourceGuards(source); + + Assert.Equal(0, source.OnInitCallCount); + Assert.Equal(0, source.OnReadRowCallCount); + Assert.Equal(0, source.OnCompleteCallCount); + + var colums = await guard.InitAndGetColumnsAsync(); + + Assert.Equal(1, source.OnInitCallCount); + Assert.Equal(0, source.OnReadRowCallCount); + Assert.Equal(0, source.OnCompleteCallCount); + + Assert.True(await guard.ReadRowAsync(new object[colums.Length])); + + Assert.Equal(1, source.OnInitCallCount); + Assert.Equal(1, source.OnReadRowCallCount); + Assert.Equal(0, source.OnCompleteCallCount); + + Assert.True(await guard.ReadRowAsync(new object[colums.Length])); + + Assert.Equal(1, source.OnInitCallCount); + Assert.Equal(2, source.OnReadRowCallCount); + Assert.Equal(0, source.OnCompleteCallCount); + + Assert.True(await guard.ReadRowAsync(new object[colums.Length])); + + Assert.Equal(1, source.OnInitCallCount); + Assert.Equal(3, source.OnReadRowCallCount); + Assert.Equal(0, source.OnCompleteCallCount); + + source.SetNextReadReturnValue(false); + + Assert.False(await guard.ReadRowAsync(new object[colums.Length])); + + Assert.Equal(1, source.OnInitCallCount); + Assert.Equal(4, source.OnReadRowCallCount); + Assert.Equal(0, source.OnCompleteCallCount); + + await guard.CompleteAsync(); + + Assert.Equal(1, source.OnInitCallCount); + Assert.Equal(4, source.OnReadRowCallCount); + Assert.Equal(1, source.OnCompleteCallCount); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(3)] + [InlineData(4)] + public async Task ReadCalledWithWrongArraySize_ExpectExceptions(int arraySize) + { + var source = new UnitTestSource(); + var guard = new AsyncSourceGuards(source); + + var columns = await guard.InitAndGetColumnsAsync(); + + Assert.NotEqual(columns.Length, + arraySize); // Sanity testing that we don't test with the actual expected array size + Assert.True(await source.ReadRowAsync(new object[columns.Length])); + _ = await Assert.ThrowsAsync(() => guard.ReadRowAsync(new object[arraySize])); + } + + [Fact] + public async Task Dispose_EnsureCallingDisposeIfSourceImplementsIDisposable() + { + var source = new UnitTestSourceWithDispose(); + var guard = new AsyncSourceGuards(source); + + await guard.InitAndGetColumnsAsync(); + await guard.ReadRowAsync(new object[0]); + + await guard.CompleteAsync(); + + Assert.Equal(0, source.DisposeCallCount); + + guard.Dispose(); + + Assert.Equal(1, source.DisposeCallCount); + +#pragma warning disable S3966 // Objects should not be disposed more than once + guard.Dispose(); +#pragma warning restore S3966 // Objects should not be disposed more than once + + Assert.Equal(2, source.DisposeCallCount); + } + + private class UnitTestSource : IAsyncRowSource + { + public int OnCompleteCallCount = 0; + public int OnInitCallCount = 0; + public int OnReadRowCallCount = 0; + + public ColumnInfo[] Columns = new ColumnInfo[] + { new ColumnInfo("col1", typeof(string)), new ColumnInfo("col2", typeof(int)) }; + + private bool _nextReadReturnValue = true; + + public void SetNextReadReturnValue(bool nextReadReturnValue) + { + _nextReadReturnValue = nextReadReturnValue; + } + + public Task CompleteAsync() + { + OnCompleteCallCount++; + return Task.CompletedTask; + } + + public Task InitAndGetColumnsAsync() + { + OnInitCallCount++; + return Task.FromResult(Columns); + } + + public Task ReadRowAsync(object[] values) + { + OnReadRowCallCount++; + return Task.FromResult(_nextReadReturnValue); + } + } + + private sealed class UnitTestSourceWithDispose : IAsyncRowSource, IDisposable + { + public int DisposeCallCount { get; private set; } = 0; + + public Task CompleteAsync() + { + return Task.CompletedTask; + } + + public Task InitAndGetColumnsAsync() + { + return Task.FromResult(new ColumnInfo[0]); + } + + public Task ReadRowAsync(object[] values) + { + return Task.FromResult(true); + } + + public void Dispose() + { + DisposeCallCount++; + } + } + } +} \ No newline at end of file diff --git a/tests/Rowbot.Test/Execution/AsyncTargetGuards_Test.cs b/tests/Rowbot.Test/Execution/AsyncTargetGuards_Test.cs new file mode 100644 index 0000000..4eed1b5 --- /dev/null +++ b/tests/Rowbot.Test/Execution/AsyncTargetGuards_Test.cs @@ -0,0 +1,217 @@ +using Rowbot.Execution; + +namespace Rowbot.Test.Execution +{ + public class AsyncTargetGuards_Test + { + [Fact] + public async Task CallingWriteRowAfterCompleted_ExpectException_Test() + { + var target = new UnitTestTarget(); + var guard = new AsyncTargetGuards(target); + + await guard.InitAsync(new ColumnInfo[] + { + new ColumnInfo(name: "Col1", valueType: typeof(string)), + new ColumnInfo(name: "Col2", valueType: typeof(decimal)), + new ColumnInfo(name: "Col æøå 3", valueType: typeof(int)), + }); + + await guard.WriteRowAsync(new object[] { "Hello there æå 1", -12.45m, 42 }); + await guard.WriteRowAsync(new object[] { "Hello there æå 1", -12.45m, 42 }); + + await guard.CompleteAsync(); + + _ = await Assert.ThrowsAsync(() => + guard.WriteRowAsync(new object[] { "Hello there æå 1", -12.45m, 42 })); + + Assert.Equal(2, target.OnWriteRowCallCount); + } + + [Fact] + public async Task CallingWriteRow_NullValues_ExpectException_Test() + { + var target = new UnitTestTarget(); + var guard = new AsyncTargetGuards(target); + await guard.InitAsync(new ColumnInfo[] + { + new ColumnInfo(name: "Col1", valueType: typeof(string)), + new ColumnInfo(name: "Col2", valueType: typeof(decimal)), + new ColumnInfo(name: "Col æøå 3", valueType: typeof(int)), + }); + + await guard.WriteRowAsync(new object[] { "Hello there æå 1", -12.45m, 42 }); + await guard.WriteRowAsync(new object[] { "Hello there æå 1", -12.45m, 42 }); + await guard.WriteRowAsync(new object[] { "Hello there æå 1", -12.45m, 42 }); + await guard.WriteRowAsync(new object[] { "Hello there æå 1", -12.45m, 42 }); + Assert.Equal(4, target.OnWriteRowCallCount); + + _ = await Assert.ThrowsAsync(() => guard.WriteRowAsync(null)); + Assert.Equal(4, target.OnWriteRowCallCount); + + // Ensure possible to call with non-values afterwards + await guard.WriteRowAsync(new object[] { "Hello there æå 1", -12.45m, 42 }); + await guard.WriteRowAsync(new object[] { "Hello there æå 1", -12.45m, 42 }); + + Assert.Equal(6, target.OnWriteRowCallCount); + } + + [Fact] + public async Task Call_WriteRowBeforeCallingInit_ExpectException_Test() + { + var target = new UnitTestTarget(); + var guard = new AsyncTargetGuards(target); + + _ = await Assert.ThrowsAsync(() => + guard.WriteRowAsync(new object[] { "Hello there æå 1", -12.45m, 42 })); + Assert.Equal(0, target.OnWriteRowCallCount); + } + + [Fact] + public async Task Call_InitTwice_ExpectException_Test() + { + var target = new UnitTestTarget(); + var guard = new AsyncTargetGuards(target); + + await guard.InitAsync(new ColumnInfo[] + { + new ColumnInfo(name: "Col1", valueType: typeof(string)), + new ColumnInfo(name: "Col2", valueType: typeof(decimal)), + new ColumnInfo(name: "Col æøå 3", valueType: typeof(int)), + }); + + _ = await Assert.ThrowsAsync(() => guard.InitAsync(new ColumnInfo[] + { + new ColumnInfo(name: "Col1", valueType: typeof(string)), + new ColumnInfo(name: "Col2", valueType: typeof(decimal)), + new ColumnInfo(name: "Col æøå 3", valueType: typeof(int)), + })); + + Assert.Equal(1, target.OnInitCallCount); + } + + [Fact] + public async Task Call_Init_WithNull_ExpectException_Test() + { + var target = new UnitTestTarget(); + var guard = new AsyncTargetGuards(target); + + _ = await Assert.ThrowsAsync(() => guard.InitAsync(null)); + Assert.Equal(0, target.OnInitCallCount); + + // Ensure possible to call init once afterwards + await guard.InitAsync(new ColumnInfo[] + { + new ColumnInfo(name: "Col1", valueType: typeof(string)), + new ColumnInfo(name: "Col2", valueType: typeof(decimal)), + new ColumnInfo(name: "Col æøå 3", valueType: typeof(int)), + }); + Assert.Equal(1, target.OnInitCallCount); + } + + [Fact] + public async Task Call_CompleteBeforeInit_ExpectException_Test() + { + var target = new UnitTestTarget(); + var guard = new AsyncTargetGuards(target); + + _ = await Assert.ThrowsAsync(() => guard.CompleteAsync()); + + Assert.Equal(0, target.OnCompleteCallCount); + } + + [Fact] + public async Task Call_CompleteTwice_ExpectException_Test() + { + var target = new UnitTestTarget(); + var guard = new AsyncTargetGuards(target); + + await guard.InitAsync(new ColumnInfo[] + { + new ColumnInfo(name: "Col1", valueType: typeof(string)), + new ColumnInfo(name: "Col2", valueType: typeof(decimal)), + new ColumnInfo(name: "Col æøå 3", valueType: typeof(int)), + }); + + await guard.CompleteAsync(); + + _ = await Assert.ThrowsAsync(() => guard.CompleteAsync()); + + Assert.Equal(1, target.OnCompleteCallCount); + } + + [Fact] + public async Task Dispose_EnsureCallingDisposeIfTargetImplementsIDisposable() + { + var target = new UnitTestTargetWithDispose(); + var guard = new AsyncTargetGuards(target); + + await guard.InitAsync(new ColumnInfo[0]); + await guard.WriteRowAsync(new object[0]); + await guard.CompleteAsync(); + + Assert.Equal(0, target.DisposeCallCount); + + guard.Dispose(); + + Assert.Equal(1, target.DisposeCallCount); + +#pragma warning disable S3966 // Objects should not be disposed more than once + guard.Dispose(); +#pragma warning restore S3966 // Objects should not be disposed more than once + + Assert.Equal(2, target.DisposeCallCount); + } + + private sealed class UnitTestTarget : IAsyncRowTarget + { + public int OnCompleteCallCount = 0; + public int OnInitCallCount = 0; + public int OnWriteRowCallCount = 0; + public int DisposeCallCount = 0; + + public Task CompleteAsync() + { + OnCompleteCallCount++; + return Task.CompletedTask; + } + + public Task InitAsync(ColumnInfo[] columns) + { + OnInitCallCount++; + return Task.CompletedTask; + } + + public Task WriteRowAsync(object[] values) + { + OnWriteRowCallCount++; + return Task.CompletedTask; + } + } + + private sealed class UnitTestTargetWithDispose : IAsyncRowTarget, IDisposable + { + public int DisposeCallCount { get; private set; } = 0; + + public void Dispose() + { + DisposeCallCount++; + } + + public Task CompleteAsync() + { + return Task.CompletedTask; + } + + public Task InitAsync(ColumnInfo[] columns) + { + return Task.CompletedTask; + } + + public Task WriteRowAsync(object[] values) + { + return Task.CompletedTask; + } + } + } +} \ No newline at end of file diff --git a/tests/Rowbot.Test/Execution/EnumerableTargetGuards_Test.cs b/tests/Rowbot.Test/Execution/EnumerableTargetGuards_Test.cs index 751ef76..d5566ef 100644 --- a/tests/Rowbot.Test/Execution/EnumerableTargetGuards_Test.cs +++ b/tests/Rowbot.Test/Execution/EnumerableTargetGuards_Test.cs @@ -4,7 +4,7 @@ using System.Linq; using System.Text; using System.Threading.Tasks; -using static Rowbot.Test.Execution.TargetGuard_Test; +using static Rowbot.Test.Execution.TargetGuards_Test; namespace Rowbot.Test.Execution { diff --git a/tests/Rowbot.Test/Execution/RowbotAsyncExecutorBuilder_Test.cs b/tests/Rowbot.Test/Execution/RowbotAsyncExecutorBuilder_Test.cs new file mode 100644 index 0000000..25f7dbc --- /dev/null +++ b/tests/Rowbot.Test/Execution/RowbotAsyncExecutorBuilder_Test.cs @@ -0,0 +1,11 @@ +namespace Rowbot.Test.Execution +{ + public class RowbotAsyncExecutorBuilder_Test + { + [Fact(Skip = "To be implemented")] + public void Foo() + { + throw new NotImplementedException(); + } + } +} diff --git a/tests/Rowbot.Test/Execution/RowbotAsyncExecutor_Test.cs b/tests/Rowbot.Test/Execution/RowbotAsyncExecutor_Test.cs new file mode 100644 index 0000000..4cf463c --- /dev/null +++ b/tests/Rowbot.Test/Execution/RowbotAsyncExecutor_Test.cs @@ -0,0 +1,146 @@ +using Rowbot.Execution; +using Task = System.Threading.Tasks.Task; + +namespace Rowbot.Test.Execution +{ + public class RowbotAsyncExecutor_Test + { + [Fact] + public async Task ExecuteAsync_Test() + { + var source = new DisposableSource(); + var target = new DisposableTarget(); + var executor = new RowbotAsyncExecutor(source: source, target: target); + + await executor.ExecuteAsync(); + + Assert.True(source.Completed); + Assert.True(target.Completed); + + Assert.True(source.Disposed); + Assert.True(target.Disposed); + + Assert.Equal(4, target.CallValues.Count); + var columns = (ColumnInfo[])target.CallValues[0]; + Assert.Equal(2, columns.Length); + + // Assert columns + Assert.Equal("ColA", columns[0].Name); + Assert.Equal(typeof(string), columns[0].ValueType); + + Assert.Equal("ColB", columns[1].Name); + Assert.Equal(typeof(int), columns[1].ValueType); + + // Assert data + { + var row = (object[])target.CallValues[1]; + Assert.Equal(2, row.Length); + Assert.Equal("Hello1", row[0]); + Assert.Equal(1, row[1]); + } + + { + var row = (object[])target.CallValues[2]; + Assert.Equal(2, row.Length); + Assert.Equal("Hello2", row[0]); + Assert.Equal(2, row[1]); + } + + { + var row = (object[])target.CallValues[3]; + Assert.Equal(2, row.Length); + Assert.Equal("Hello3", row[0]); + Assert.Equal(3, row[1]); + } + } + + private sealed class DisposableTarget : IAsyncRowTarget, IDisposable + { + public bool Completed { get; private set; } = false; + public bool Disposed { get; private set; } = false; + public List CallValues { get; set; } = new List(); + + public Task CompleteAsync() + { + EnsureNotCompletedOrDisposed(); + Completed = true; + return Task.CompletedTask; + } + + private void EnsureNotCompletedOrDisposed() + { + Assert.False(Completed); + Assert.False(Disposed); + } + + public void Dispose() + { + Disposed = true; + } + + public Task InitAsync(ColumnInfo[] columns) + { + CallValues.Add(columns.ToArray()); + return Task.CompletedTask; + } + + public Task WriteRowAsync(object[] values) + { + CallValues.Add(values.ToArray()); + return Task.CompletedTask; + } + } + + private sealed class DisposableSource : IAsyncRowSource, IDisposable + { + public bool Completed { get; private set; } = false; + public bool Disposed { get; private set; } = false; + + public DisposableSource() + { + } + + public Task CompleteAsync() + { + EnsureNotCompletedOrDisposed(); + Completed = true; + return Task.CompletedTask; + } + + private void EnsureNotCompletedOrDisposed() + { + Assert.False(Completed); + Assert.False(Disposed); + } + + public void Dispose() + { + Disposed = true; + } + + public Task InitAndGetColumnsAsync() + { + EnsureNotCompletedOrDisposed(); + return Task.FromResult(new ColumnInfo[] + { + new ColumnInfo(name: "ColA", valueType: typeof(string)), + new ColumnInfo(name: "ColB", valueType: typeof(int)) + }); + } + + private int _rowCounter = 0; + public Task ReadRowAsync(object[] values) + { + EnsureNotCompletedOrDisposed(); + _rowCounter++; + if (_rowCounter > 3) + return Task.FromResult(false); + + Assert.Equal(2, values.Length); + values[0] = "Hello" + _rowCounter; + values[1] = _rowCounter; + return Task.FromResult(true); + } + } + } +} \ No newline at end of file diff --git a/tests/Rowbot.Test/Execution/RowbotExecutorBuilder_Test.cs b/tests/Rowbot.Test/Execution/RowbotExecutorBuilder_Test.cs index ffe2753..3c54d4e 100644 --- a/tests/Rowbot.Test/Execution/RowbotExecutorBuilder_Test.cs +++ b/tests/Rowbot.Test/Execution/RowbotExecutorBuilder_Test.cs @@ -1,10 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Rowbot.Test.Execution +namespace Rowbot.Test.Execution { public class RowbotExecutorBuilder_Test { diff --git a/tests/Rowbot.Test/Execution/RowbotExecutor_Test.cs b/tests/Rowbot.Test/Execution/RowbotExecutor_Test.cs index 9fe89da..ae5df6e 100644 --- a/tests/Rowbot.Test/Execution/RowbotExecutor_Test.cs +++ b/tests/Rowbot.Test/Execution/RowbotExecutor_Test.cs @@ -1,9 +1,4 @@ using Rowbot.Execution; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Rowbot.Test.Execution { @@ -57,91 +52,91 @@ public void Execute_Test() Assert.Equal(3, row[1]); } } - } - - public sealed class DisposableTarget : IRowTarget, IDisposable - { - public bool Completed { get; private set; } = false; - public bool Disposed { get; private set; } = false; - public List CallValues { get; set; } = new List(); - public void Complete() + private sealed class DisposableTarget : IRowTarget, IDisposable { - EnsureNotCompletedOrDisposed(); - Completed = true; - } + public bool Completed { get; private set; } = false; + public bool Disposed { get; private set; } = false; + public List CallValues { get; set; } = new List(); - private void EnsureNotCompletedOrDisposed() - { - Assert.False(Completed); - Assert.False(Disposed); - } + public void Complete() + { + EnsureNotCompletedOrDisposed(); + Completed = true; + } - public void Dispose() - { - Disposed = true; - } + private void EnsureNotCompletedOrDisposed() + { + Assert.False(Completed); + Assert.False(Disposed); + } - public void Init(ColumnInfo[] columns) - { - CallValues.Add(columns.ToArray()); - } + public void Dispose() + { + Disposed = true; + } - public void WriteRow(object[] values) - { - CallValues.Add(values.ToArray()); + public void Init(ColumnInfo[] columns) + { + CallValues.Add(columns.ToArray()); + } + + public void WriteRow(object[] values) + { + CallValues.Add(values.ToArray()); + } } - } - public sealed class DisposableSource : IRowSource, IDisposable - { - public bool Completed { get; private set; } = false; - public bool Disposed { get; private set; } = false; - public DisposableSource() + private sealed class DisposableSource : IRowSource, IDisposable { + public bool Completed { get; private set; } = false; + public bool Disposed { get; private set; } = false; - } + public DisposableSource() + { + } - public void Complete() - { - EnsureNotCompletedOrDisposed(); - Completed = true; - } + public void Complete() + { + EnsureNotCompletedOrDisposed(); + Completed = true; + } - private void EnsureNotCompletedOrDisposed() - { - Assert.False(Completed); - Assert.False(Disposed); - } + private void EnsureNotCompletedOrDisposed() + { + Assert.False(Completed); + Assert.False(Disposed); + } - public void Dispose() - { - Disposed = true; - } + public void Dispose() + { + Disposed = true; + } - public ColumnInfo[] InitAndGetColumns() - { - EnsureNotCompletedOrDisposed(); - return new ColumnInfo[] { - new ColumnInfo(name: "ColA", valueType: typeof(string)), - new ColumnInfo(name: "ColB", valueType: typeof(int)) - }; + public ColumnInfo[] InitAndGetColumns() + { + EnsureNotCompletedOrDisposed(); + return new ColumnInfo[] + { + new ColumnInfo(name: "ColA", valueType: typeof(string)), + new ColumnInfo(name: "ColB", valueType: typeof(int)) + }; + } - } + private int _rowCounter = 0; - private int _rowCounter = 0; - public bool ReadRow(object[] values) - { - EnsureNotCompletedOrDisposed(); - _rowCounter++; - if (_rowCounter > 3) - return false; - - Assert.Equal(2, values.Length); - values[0] = "Hello" + _rowCounter; - values[1] = _rowCounter; - return true; + public bool ReadRow(object[] values) + { + EnsureNotCompletedOrDisposed(); + _rowCounter++; + if (_rowCounter > 3) + return false; + + Assert.Equal(2, values.Length); + values[0] = "Hello" + _rowCounter; + values[1] = _rowCounter; + return true; + } } } -} - +} \ No newline at end of file diff --git a/tests/Rowbot.Test/Execution/SourceGuard_Test.cs b/tests/Rowbot.Test/Execution/SourceGuards_Test.cs similarity index 75% rename from tests/Rowbot.Test/Execution/SourceGuard_Test.cs rename to tests/Rowbot.Test/Execution/SourceGuards_Test.cs index 74699f5..4585404 100644 --- a/tests/Rowbot.Test/Execution/SourceGuard_Test.cs +++ b/tests/Rowbot.Test/Execution/SourceGuards_Test.cs @@ -1,14 +1,8 @@ using Rowbot.Execution; -using Rowbot.Targets; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Rowbot.Test.Execution { - public class SourceGuard_Test + public class SourceGuards_Test { [Fact] public void Call_InitAndGetColumns_EnsureArrayPassedThrough() @@ -133,7 +127,8 @@ public void ReadCalledWithWrongArraySize_ExpectExceptions(int arraySize) var columns = guard.InitAndGetColumns(); - Assert.NotEqual(columns.Length, arraySize);// Sanity testing that we don't test with the actual expected array size + Assert.NotEqual(columns.Length, + arraySize); // Sanity testing that we don't test with the actual expected array size Assert.True(source.ReadRow(new object[columns.Length])); Assert.Throws(() => guard.ReadRow(new object[arraySize])); } @@ -161,61 +156,64 @@ public void Dispose_EnsureCallingDisposeIfSourceImplementsIDisposable() Assert.Equal(2, source.DisposeCallCount); } - } - - public class UnitTestSource : IRowSource - { - public int OnCompleteCallCount = 0; - public int OnInitCallCount = 0; - public int OnReadRowCallCount = 0; - public ColumnInfo[] Columns = new ColumnInfo[] { new ColumnInfo("col1", typeof(string)), new ColumnInfo("col2", typeof(int)) }; - private bool _nextReadReturnValue = true; - public void SetNextReadReturnValue(bool nextReadReturnValue) + private class UnitTestSource : IRowSource { - _nextReadReturnValue = nextReadReturnValue; - } + public int OnCompleteCallCount = 0; + public int OnInitCallCount = 0; + public int OnReadRowCallCount = 0; - public void Complete() - { - OnCompleteCallCount++; - } + public ColumnInfo[] Columns = new ColumnInfo[] + { new ColumnInfo("col1", typeof(string)), new ColumnInfo("col2", typeof(int)) }; - public ColumnInfo[] InitAndGetColumns() - { - OnInitCallCount++; - return Columns; - } + private bool _nextReadReturnValue = true; - public bool ReadRow(object[] values) - { - OnReadRowCallCount++; - return _nextReadReturnValue; - } - } + public void SetNextReadReturnValue(bool nextReadReturnValue) + { + _nextReadReturnValue = nextReadReturnValue; + } - public sealed class UnitTestSourceWithDispose : IRowSource, IDisposable - { - public int DisposeCallCount { get; private set; } = 0; + public void Complete() + { + OnCompleteCallCount++; + } - public void Complete() - { - // - } + public ColumnInfo[] InitAndGetColumns() + { + OnInitCallCount++; + return Columns; + } - public ColumnInfo[] InitAndGetColumns() - { - return new ColumnInfo[0]; + public bool ReadRow(object[] values) + { + OnReadRowCallCount++; + return _nextReadReturnValue; + } } - public bool ReadRow(object[] values) + private sealed class UnitTestSourceWithDispose : IRowSource, IDisposable { - return true; - } + public int DisposeCallCount { get; private set; } = 0; - public void Dispose() - { - DisposeCallCount++; + public void Complete() + { + // + } + + public ColumnInfo[] InitAndGetColumns() + { + return new ColumnInfo[0]; + } + + public bool ReadRow(object[] values) + { + return true; + } + + public void Dispose() + { + DisposeCallCount++; + } } } -} +} \ No newline at end of file diff --git a/tests/Rowbot.Test/Execution/TargetGuard_Test.cs b/tests/Rowbot.Test/Execution/TargetGuards_Test.cs similarity index 89% rename from tests/Rowbot.Test/Execution/TargetGuard_Test.cs rename to tests/Rowbot.Test/Execution/TargetGuards_Test.cs index d38b42f..5e85538 100644 --- a/tests/Rowbot.Test/Execution/TargetGuard_Test.cs +++ b/tests/Rowbot.Test/Execution/TargetGuards_Test.cs @@ -1,13 +1,8 @@ using Rowbot.Execution; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Rowbot.Test.Execution { - public class TargetGuard_Test + public class TargetGuards_Test { [Fact] public void CallingWriteRowAfterCompleted_ExpectException_Test() @@ -15,7 +10,8 @@ public void CallingWriteRowAfterCompleted_ExpectException_Test() var target = new UnitTestTarget(); var guard = new TargetGuards(target); - guard.Init(new ColumnInfo[]{ + guard.Init(new ColumnInfo[] + { new ColumnInfo(name: "Col1", valueType: typeof(string)), new ColumnInfo(name: "Col2", valueType: typeof(decimal)), new ColumnInfo(name: "Col æøå 3", valueType: typeof(int)), @@ -26,7 +22,8 @@ public void CallingWriteRowAfterCompleted_ExpectException_Test() guard.Complete(); - Assert.Throws(() => guard.WriteRow(new object[] { "Hello there æå 1", -12.45m, 42 })); + Assert.Throws(() => + guard.WriteRow(new object[] { "Hello there æå 1", -12.45m, 42 })); Assert.Equal(2, target.OnWriteRowCallCount); } @@ -36,7 +33,8 @@ public void CallingWriteRow_NullValues_ExpectException_Test() { var target = new UnitTestTarget(); var guard = new TargetGuards(target); - guard.Init(new ColumnInfo[]{ + guard.Init(new ColumnInfo[] + { new ColumnInfo(name: "Col1", valueType: typeof(string)), new ColumnInfo(name: "Col2", valueType: typeof(decimal)), new ColumnInfo(name: "Col æøå 3", valueType: typeof(int)), @@ -64,7 +62,8 @@ public void Call_WriteRowBeforeCallingInit_ExpectException_Test() var target = new UnitTestTarget(); var guard = new TargetGuards(target); - Assert.Throws(() => guard.WriteRow(new object[] { "Hello there æå 1", -12.45m, 42 })); + Assert.Throws(() => + guard.WriteRow(new object[] { "Hello there æå 1", -12.45m, 42 })); Assert.Equal(0, target.OnWriteRowCallCount); } @@ -74,13 +73,15 @@ public void Call_InitTwice_ExpectException_Test() var target = new UnitTestTarget(); var guard = new TargetGuards(target); - guard.Init(new ColumnInfo[]{ + guard.Init(new ColumnInfo[] + { new ColumnInfo(name: "Col1", valueType: typeof(string)), new ColumnInfo(name: "Col2", valueType: typeof(decimal)), new ColumnInfo(name: "Col æøå 3", valueType: typeof(int)), }); - Assert.Throws(() => guard.Init(new ColumnInfo[]{ + Assert.Throws(() => guard.Init(new ColumnInfo[] + { new ColumnInfo(name: "Col1", valueType: typeof(string)), new ColumnInfo(name: "Col2", valueType: typeof(decimal)), new ColumnInfo(name: "Col æøå 3", valueType: typeof(int)), @@ -99,7 +100,8 @@ public void Call_Init_WithNull_ExpectException_Test() Assert.Equal(0, target.OnInitCallCount); // Ensure possible to call init once afterwards - guard.Init(new ColumnInfo[]{ + guard.Init(new ColumnInfo[] + { new ColumnInfo(name: "Col1", valueType: typeof(string)), new ColumnInfo(name: "Col2", valueType: typeof(decimal)), new ColumnInfo(name: "Col æøå 3", valueType: typeof(int)), @@ -124,7 +126,8 @@ public void Call_CompleteTwice_ExpectException_Test() var target = new UnitTestTarget(); var guard = new TargetGuards(target); - guard.Init(new ColumnInfo[]{ + guard.Init(new ColumnInfo[] + { new ColumnInfo(name: "Col1", valueType: typeof(string)), new ColumnInfo(name: "Col2", valueType: typeof(decimal)), new ColumnInfo(name: "Col æøå 3", valueType: typeof(int)), @@ -160,7 +163,7 @@ public void Dispose_EnsureCallingDisposeIfTargetImplementsIDisposable() Assert.Equal(2, target.DisposeCallCount); } - public sealed class UnitTestTarget : IRowTarget + private sealed class UnitTestTarget : IRowTarget { public int OnCompleteCallCount = 0; public int OnInitCallCount = 0; @@ -183,9 +186,10 @@ public void WriteRow(object[] values) } } - public sealed class UnitTestTargetWithDispose : IRowTarget, IDisposable + private sealed class UnitTestTargetWithDispose : IRowTarget, IDisposable { public int DisposeCallCount { get; private set; } = 0; + public void Dispose() { DisposeCallCount++; @@ -207,5 +211,4 @@ public void WriteRow(object[] values) } } } - -} +} \ No newline at end of file diff --git a/tests/Rowbot.Test/IntegrationTests/AsyncExecutor_Integration_Test.cs b/tests/Rowbot.Test/IntegrationTests/AsyncExecutor_Integration_Test.cs new file mode 100644 index 0000000..a7e8788 --- /dev/null +++ b/tests/Rowbot.Test/IntegrationTests/AsyncExecutor_Integration_Test.cs @@ -0,0 +1,39 @@ +using Rowbot.Execution; +using System.Data; + +namespace Rowbot.Test.IntegrationTests +{ + public class AsyncExecutor_Integration_Test + { + [Fact] + public async Task ExecuteAsync_AnonymousObjectsToDataTable_Test() + { + var objects = AsyncEnumerable.Range(0, 3).Select(num => new { MyNum = num, MyName = $"My name is {num}" }); + var table = new DataTable(); + await new RowbotAsyncExecutorBuilder() + .FromObjects(objects) + .ToDataTable(table) + .ExecuteAsync(); + + // Assert columns + Assert.Equal(2, table.Columns.Count); + + Assert.Equal("MyNum", table.Columns[0].ColumnName); + Assert.Equal(typeof(int), table.Columns[0].DataType); + + Assert.Equal("MyName", table.Columns[1].ColumnName); + Assert.Equal(typeof(string), table.Columns[1].DataType); + + // Assert rows + Assert.Equal(3, table.Rows.Count); + Assert.Equal(0, table.Rows[0]["MyNum"]); + Assert.Equal("My name is 0", table.Rows[0]["MyName"]); + + Assert.Equal(1, table.Rows[1]["MyNum"]); + Assert.Equal("My name is 1", table.Rows[1]["MyName"]); + + Assert.Equal(2, table.Rows[2]["MyNum"]); + Assert.Equal("My name is 2", table.Rows[2]["MyName"]); + } + } +} diff --git a/tests/Rowbot.Test/Rowbot.Test.csproj b/tests/Rowbot.Test/Rowbot.Test.csproj index 334b519..99b6f6a 100644 --- a/tests/Rowbot.Test/Rowbot.Test.csproj +++ b/tests/Rowbot.Test/Rowbot.Test.csproj @@ -12,6 +12,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/Rowbot.Test/Sources/AsyncDataReaderSource_Test.cs b/tests/Rowbot.Test/Sources/AsyncDataReaderSource_Test.cs new file mode 100644 index 0000000..4510061 --- /dev/null +++ b/tests/Rowbot.Test/Sources/AsyncDataReaderSource_Test.cs @@ -0,0 +1,118 @@ +using Rowbot.Sources; +using System.Data; + +namespace Rowbot.Test.Sources +{ + public class AsyncDataReaderSource_Test + { + [Fact] + public async Task GetColumnsAndRows_NoRows_Test() + { + var dataTable = new DataTable(); + dataTable.Columns.Add(new DataColumn("Col1", typeof(string))); + dataTable.Columns.Add(new DataColumn("Col2 æøå Φ", typeof(int))); + dataTable.Columns.Add(new DataColumn("Col3", typeof(decimal))); + dataTable.Columns.Add(new DataColumn("")); + dataTable.Columns.Add(new DataColumn("Column number four")); + dataTable.Columns.Add(new DataColumn("")); + + var source = new AsyncDataReaderSource(dataTable.CreateDataReader()); + var columns = (await source.InitAndGetColumnsAsync()).ToArray(); + Assert.Equal(6, columns.Length); + AssertColumn(columns[0], "Col1"); + AssertColumn(columns[1], "Col2 æøå Φ"); + AssertColumn(columns[2], "Col3"); + AssertColumn(columns[3], "Column1"); + AssertColumn(columns[4], "Column number four"); + AssertColumn(columns[5], "Column2"); + + var rows = await TestUtils.ReadAllLinesAsync(source, columnCount: columns.Length); + Assert.Empty(rows); + } + + private void AssertColumn(ColumnInfo col, string expectedName) + { + Assert.NotNull(col); + Assert.NotNull(expectedName); + + Assert.Equal(expectedName, col.Name); + Assert.Equal(typeof(T), col.ValueType); + } + + [Fact] + public async Task GetColumnsAndRows_NoColumns_ExpectException_Test() + { + var dataTable = new DataTable(); + var source = new AsyncDataReaderSource(dataTable.CreateDataReader()); + Assert.Empty(await source.InitAndGetColumnsAsync()); + } + + [Fact] + public async Task GetColumnsAndRows_Test() + { + var dataTable = new DataTable(); + dataTable.Columns.Add(new DataColumn("Col1", typeof(string))); + dataTable.Columns.Add(new DataColumn("Col2 æøå Φ", typeof(decimal))); + dataTable.Columns.Add(new DataColumn("Col3")); + dataTable.Columns.Add(new DataColumn("")); + dataTable.Columns.Add(new DataColumn("Column number four")); + dataTable.Columns.Add(new DataColumn("")); + + { + var row = dataTable.NewRow(); + row["Col1"] = "string_val_1"; + row["Col2 æøå Φ"] = -12.34m; + row["Col3"] = DBNull.Value; + row["Column1"] = new DateTime(2001, 02, 03, 04, 05, 06); + // left out on purpose: row["Column number four"] + row["Column2"] = ""; + dataTable.Rows.Add(row); + } + + { + // Empty row + var row = dataTable.NewRow(); + dataTable.Rows.Add(row); + } + + { + var row = dataTable.NewRow(); + row["Col1"] = 123; + row["Col2 æøå Φ"] = 112; + row["Col3"] = 1234; + row["Column1"] = 11; + row["Column number four"] = null; + row["Column2"] = 22; + dataTable.Rows.Add(row); + } + + var source = new AsyncDataReaderSource(dataTable.CreateDataReader()); + + // Ensure to call init event though we dont assert on the columns in this test + var columns = await source.InitAndGetColumnsAsync(); + + // Assert row elements + var rows = await TestUtils.ReadAllLinesAsync(source, columnCount: columns.Length); + + Assert.Equal(3, rows.Length); + AssertArraysEqual(new object?[] { "string_val_1", -12.34m, null, "03/02/2001 04.05.06", null, "" }, rows[0]); + AssertArraysEqual(new object?[] { null, null, null, null, null, null }, rows[1]); + AssertArraysEqual(new object?[] { "123", 112m, "1234", "11", null, "22" }, rows[2]); + } + + private static void AssertArraysEqual(object?[] val1, object[] val2) + { + Assert.Equal(val1.Length, val2.Length); + for (var i = 0; i < val1.Length; i++) + { + if (val1[i] == null && val2[i] == null) + return; + + if (!Equals(val1[i], val2[i])) + { + Assert.True(false, $"Value at position {i} has value {val1[i]} ({val1[i]?.GetType().Name ?? "NULL"}) in val1, but value {val2[i]} ({val2[i]?.GetType().Name ?? "NULL"}) in val2"); + } + } + } + } +} diff --git a/tests/Rowbot.Test/Sources/AsyncDynamicObjectSource_Test.cs b/tests/Rowbot.Test/Sources/AsyncDynamicObjectSource_Test.cs new file mode 100644 index 0000000..705efaf --- /dev/null +++ b/tests/Rowbot.Test/Sources/AsyncDynamicObjectSource_Test.cs @@ -0,0 +1,118 @@ +using Rowbot.Sources; +using System.Dynamic; + +namespace Rowbot.Test.Sources +{ + public class AsyncDynamicObjectSource_Test + { + [Fact] + public async Task GetColumnsAndRows_NoRows_Test() + { + var emptyList = AsyncEnumerable.Empty(); + + var source = new AsyncDynamicObjectSource(emptyList); + var columns = (await source.InitAndGetColumnsAsync()).ToArray(); + Assert.Empty(columns); + + var rows = await TestUtils.ReadAllLinesAsync(source, columnCount: columns.Length); + Assert.Empty(rows); + } + + [Fact] + public async Task GetColumnsAndRows_NoColumns_ExpectException_Test() + { + var dynamicObjects = AsyncEnumerable.Range(1, 10).Select(x => + { + dynamic runTimeObject = new ExpandoObject(); + return runTimeObject; + }); + + var source = new AsyncDynamicObjectSource(dynamicObjects); + + var columns = await source.InitAndGetColumnsAsync(); + Assert.Empty(columns); + + var rows = await TestUtils.ReadAllLinesAsync(source, columnCount: columns.Length); + Assert.Equal(10, rows.Length); + } + + [Fact] + public async Task GetColumnsAndRows_Test() + { + var dynamicObjects = AsyncEnumerable.Range(0, 3).Select(x => + { + dynamic runTimeObject = new ExpandoObject(); + runTimeObject.Value = x; + runTimeObject.Name = "Hello number " + x; + runTimeObject.NullValue = null; + return runTimeObject; + }); + + var source = new AsyncDynamicObjectSource(dynamicObjects); + + var columns = await source.InitAndGetColumnsAsync(); + Assert.Equal(3, columns.Length); + + Assert.Equal("Value", columns[0].Name); + Assert.Equal(typeof(object), columns[0].ValueType); + + Assert.Equal("Name", columns[1].Name); + Assert.Equal(typeof(object), columns[1].ValueType); + + Assert.Equal("NullValue", columns[2].Name); + Assert.Equal(typeof(object), columns[2].ValueType); + + // Assert row elements + var rows = await TestUtils.ReadAllLinesAsync(source, columnCount: columns.Length); + + Assert.Equal(3, rows.Length); + AssertArraysEqual(new object?[] { 0, "Hello number 0", null }, rows[0]); + AssertArraysEqual(new object?[] { 1, "Hello number 1", null }, rows[1]); + AssertArraysEqual(new object?[] { 2, "Hello number 2", null }, rows[2]); + } + + [Fact] + public async Task GetColumnsAndRows_DynamicObjectsHaveDifferentProperties_ExpectException_Test() + { + dynamic obj1 = new ExpandoObject(); + obj1.PropA = 10; + obj1.PropB = "Hello there"; + + dynamic obj2 = new ExpandoObject(); + obj2.PropB = 9876; + obj2.PropC = "Another field"; + + var objects = new [] { obj1, obj2 }; + var differentObjects = AsyncEnumerable.Range(0, 2).Select(index => objects[index]); + + var source = new AsyncDynamicObjectSource(differentObjects); + + var columns = await source.InitAndGetColumnsAsync(); + Assert.Equal(2, columns.Length); + + Assert.Equal("PropA", columns[0].Name); + Assert.Equal(typeof(object), columns[0].ValueType); + + Assert.Equal("PropB", columns[1].Name); + Assert.Equal(typeof(object), columns[1].ValueType); + + var ex = await Assert.ThrowsAsync(() => TestUtils.ReadAllLinesAsync(source, columnCount: columns.Length)); + Assert.Equal("Two dynamic objects was not identical in collection. One had keys: [PropA,PropB] and another one had keys: [PropB, PropC]", ex.Message); + } + + private static void AssertArraysEqual(object?[] val1, object[] val2) + { + Assert.Equal(val1.Length, val2.Length); + for (var i = 0; i < val1.Length; i++) + { + if (val1[i] == null && val2[i] == null) + return; + + if (!Equals(val1[i], val2[i])) + { + Assert.True(false, $"Value at position {i} has value {val1[i]} ({val1[i]?.GetType().Name ?? "NULL"}) in val1, but value {val2[i]} ({val2[i]?.GetType().Name ?? "NULL"}) in val2"); + } + } + } + } +} diff --git a/tests/Rowbot.Test/Sources/AsyncPropertyReflectionSource_Test.cs b/tests/Rowbot.Test/Sources/AsyncPropertyReflectionSource_Test.cs new file mode 100644 index 0000000..c6d65fd --- /dev/null +++ b/tests/Rowbot.Test/Sources/AsyncPropertyReflectionSource_Test.cs @@ -0,0 +1,122 @@ +using Rowbot.Sources; +using System.Dynamic; + +namespace Rowbot.Test.Sources +{ + public class AsyncPropertyReflectionSource_Test + { + [Fact] + public void TestWithDynamicObjects() + { + var dynamicObjects = AsyncEnumerable.Range(1, 10).Select(x => + { + dynamic runTimeObject = new ExpandoObject(); + runTimeObject.Name = "OnTheFlyFooObject"; + runTimeObject.Value = 123; + return runTimeObject; + }); + + Assert.Throws(() => AsyncPropertyReflectionSource.Create(elements: dynamicObjects)); + } + + [Fact] + public async Task GetColumnsAndRows_NoRows_Test() + { + var anonymousObjects = AsyncEnumerable.Range(0, 0).Select(e => new + { + myInt = 123, + myString = "Helloooo δ!2", + myDecimal = -12.34m, + myNullableDecimal = (decimal?)null, + æøÅÆØÅSpecial = "ok" + }); + + var source = AsyncPropertyReflectionSource.Create(elements: anonymousObjects); + var columns = await source.InitAndGetColumnsAsync(); + Assert.Equal(5, columns.Length); + Assert.Equal("myInt", columns[0].Name); + Assert.Equal(typeof(int), columns[0].ValueType); + + Assert.Equal("myString", columns[1].Name); + Assert.Equal(typeof(string), columns[1].ValueType); + + Assert.Equal("myDecimal", columns[2].Name); + Assert.Equal(typeof(decimal), columns[2].ValueType); + + Assert.Equal("myNullableDecimal", columns[3].Name); + Assert.Equal(typeof(decimal?), columns[3].ValueType); + + Assert.Equal("æøÅÆØÅSpecial", columns[4].Name); + Assert.Equal(typeof(string), columns[4].ValueType); + + var rows = await TestUtils.ReadAllLinesAsync(source, columnCount: columns.Length); + Assert.Empty(rows); + } + + [Fact] + public async Task GetColumnsAndRows_NoColumns_Test() + { + var anonymousObjects = AsyncEnumerable.Range(0, 3).Select(e => new { }); + var reader = AsyncPropertyReflectionSource.Create(elements: anonymousObjects); + Assert.Empty(await reader.InitAndGetColumnsAsync()); + } + + [Fact] + public async Task GetColumnsAndRows_Test() + { + var anonymousObjects = AsyncEnumerable.Range(0, 3).Select(e => new + { + myInt = e, + myString = "Hello number " + e, + myDecimal = e + -12.34m, + myNullableDecimal = (decimal?)null, + æøÅÆØÅSpecial = new DateTime(2000 + e, 02, 03, 14, 05, 06) + }); + + var source = AsyncPropertyReflectionSource.Create(elements: anonymousObjects); + + var columns = await source.InitAndGetColumnsAsync(); + Assert.Equal("myInt", columns[0].Name); + Assert.Equal(typeof(int), columns[0].ValueType); + + Assert.Equal("myString", columns[1].Name); + Assert.Equal(typeof(string), columns[1].ValueType); + + Assert.Equal("myDecimal", columns[2].Name); + Assert.Equal(typeof(decimal), columns[2].ValueType); + + Assert.Equal("myNullableDecimal", columns[3].Name); + Assert.Equal(typeof(decimal?), columns[3].ValueType); + + Assert.Equal("æøÅÆØÅSpecial", columns[4].Name); + Assert.Equal(typeof(DateTime), columns[4].ValueType); + + // Assert row elements + var rows = await TestUtils.ReadAllLinesAsync(source, columnCount: columns.Length); + + Assert.Equal(3, rows.Length); + AssertArraysEqual( + new object?[] { 0, "Hello number 0", -12.34m, null, new DateTime(2000, 02, 03, 14, 05, 06) }, rows[0]); + AssertArraysEqual( + new object?[] { 1, "Hello number 1", -11.34m, null, new DateTime(2001, 02, 03, 14, 05, 06) }, rows[1]); + AssertArraysEqual( + new object?[] { 2, "Hello number 2", -10.34m, null, new DateTime(2002, 02, 03, 14, 05, 06) }, rows[2]); + } + + private static void AssertArraysEqual(object?[] val1, object[] val2) + { + Assert.Equal(val1.Length, val2.Length); + for (var i = 0; i < val1.Length; i++) + { + if (val1[i] == null && val2[i] == null) + return; + + if (!Equals(val1[i], val2[i])) + { + Assert.True(false, + $"Value at position {i} has value {val1[i]} ({val1[i]?.GetType().Name ?? "NULL"}) in val1, but value {val2[i]} ({val2[i]?.GetType().Name ?? "NULL"}) in val2"); + } + } + } + } +} \ No newline at end of file diff --git a/tests/Rowbot.Test/Targets/AsyncDataTableTarget_Test.cs b/tests/Rowbot.Test/Targets/AsyncDataTableTarget_Test.cs new file mode 100644 index 0000000..5f0d1df --- /dev/null +++ b/tests/Rowbot.Test/Targets/AsyncDataTableTarget_Test.cs @@ -0,0 +1,104 @@ +using Rowbot.Targets; +using System.Data; + +namespace Rowbot.Test.Targets +{ + public class AsyncDataTableTarget_Test + { + [Fact] + public async Task WriteColumnsAndRows_NoRows_ExpectEmptyTable_Test() + { + var table = new DataTable(); + var target = new AsyncDataTableTarget(table); + + await target.InitAsync(new ColumnInfo[] + { + new ColumnInfo(name: "Col1", valueType: typeof(string)), + new ColumnInfo(name: "Col2", valueType: typeof(decimal)), + new ColumnInfo(name: "Col æøå 3", valueType: typeof(int)), + }); + + await target.CompleteAsync(); + + Assert.NotNull(table); + Assert.Equal(3, table.Columns.Count); + + Assert.Equal("Col1", table.Columns[0].ColumnName); + Assert.Equal(typeof(string), table.Columns[0].DataType); + + Assert.Equal("Col2", table.Columns[1].ColumnName); + Assert.Equal(typeof(decimal), table.Columns[1].DataType); + + Assert.Equal("Col æøå 3", table.Columns[2].ColumnName); + Assert.Equal(typeof(int), table.Columns[2].DataType); + + Assert.Equal(0, table.Rows.Count); + } + + [Fact] + public async Task WriteColumnsAndRows_Test() + { + var table = new DataTable(); + var target = new AsyncDataTableTarget(table); + + await target.InitAsync(new ColumnInfo[] + { + new ColumnInfo(name: "Col1", valueType: typeof(string)), + new ColumnInfo(name: "Col2", valueType: typeof(decimal)), + new ColumnInfo(name: "Col æøå 3", valueType: typeof(int)), + }); + + await target.WriteRowAsync(new object?[] { "Hello there æå 1", -12.45m, 42 }); + await target.WriteRowAsync(new object?[] { "Hello there æå 2", 10, null }); + await target.WriteRowAsync(new object?[] { "Hello there æå 3", null, int.MinValue }); + await target.WriteRowAsync(new object?[] { null, "123", int.MaxValue }); + + await target.CompleteAsync(); + + Assert.NotNull(table); + Assert.Equal(3, table.Columns.Count); + + Assert.Equal("Col1", table.Columns[0].ColumnName); + Assert.Equal(typeof(string), table.Columns[0].DataType); + + Assert.Equal("Col2", table.Columns[1].ColumnName); + Assert.Equal(typeof(decimal), table.Columns[1].DataType); + + Assert.Equal("Col æøå 3", table.Columns[2].ColumnName); + Assert.Equal(typeof(int), table.Columns[2].DataType); + + Assert.Equal(4, table.Rows.Count); + + AssertRowsValues(table.Rows[0], "Hello there æå 1", -12.45m, 42); + AssertRowsValues(table.Rows[1], "Hello there æå 2", 10m, DBNull.Value); + AssertRowsValues(table.Rows[2], "Hello there æå 3", DBNull.Value, int.MinValue); + AssertRowsValues(table.Rows[3], DBNull.Value, 123m, int.MaxValue); + } + + [Fact] + public async Task WriteColumnsAndRows_IncompatibleTypeInRow_ExpectExceptionFromDataTableRethrown_Test() + { + var table = new DataTable(); + var target = new AsyncDataTableTarget(table); + + await target.InitAsync(new ColumnInfo[] + { + new ColumnInfo(name: "Col1", valueType: typeof(string)), + new ColumnInfo(name: "Col2", valueType: typeof(decimal)), + new ColumnInfo(name: "Col æøå 3", valueType: typeof(int)), + }); + + await target.WriteRowAsync(new object[] { "Hello there æå 1", -12.45m, 42 }); + _ = await Assert.ThrowsAsync(() => target.WriteRowAsync(new object?[] + { "Hello there æå 2", "This string cannot be converted into an int", null })); + } + + private static void AssertRowsValues(DataRow row, params object[] expectedValues) + { + for (var i = 0; i < expectedValues.Length; i++) + { + Assert.Equal(expectedValues[i], row[i]); + } + } + } +} \ No newline at end of file diff --git a/tests/Rowbot.Test/Targets/AsyncDynamicObjectTarget_Test.cs b/tests/Rowbot.Test/Targets/AsyncDynamicObjectTarget_Test.cs new file mode 100644 index 0000000..05bb16e --- /dev/null +++ b/tests/Rowbot.Test/Targets/AsyncDynamicObjectTarget_Test.cs @@ -0,0 +1,63 @@ +using Rowbot.Targets; + +namespace Rowbot.Test.Targets +{ + public class AsyncDynamicObjectTarget_Test + { + [Fact] + public async Task NoColumns_Test() + { + using (var target = new AsyncDynamicObjectTarget()) + { + await target.InitAsync(new ColumnInfo[0]); + + var e = await target.WriteRowAsync(new object[0]); + Assert.NotNull(e); + + await target.CompleteAsync(); + } + } + + [Fact] + public async Task NoRows_Test() + { + using (var target = new AsyncDynamicObjectTarget()) + { + await target.InitAsync(new ColumnInfo[] + { + new ColumnInfo(name: "A", typeof(string)) + }); + + await target.CompleteAsync(); + } + } + + [Fact] + public async Task WithRowsAndColumns_Test() + { + using (var target = new AsyncDynamicObjectTarget()) + { + await target.InitAsync(new ColumnInfo[] + { + new ColumnInfo(name: "A", typeof(string)), + new ColumnInfo(name: "B", typeof(int)), + new ColumnInfo(name: "SomeOtherProperty", typeof(int?)) + }); + + { + var e = await target.WriteRowAsync(new object[] { "Hello", 42, 82 }); + Assert.Equal("Hello", e.A); + Assert.Equal(42, e.B); + Assert.Equal(82, e.SomeOtherProperty); + } + + { + var e = await target.WriteRowAsync(new object?[] { null, 42, null }); + Assert.Equal(null, e.A); + Assert.Equal(42, e.B); + Assert.Equal(null, e.SomeOtherProperty); + } + } + } + } +} \ No newline at end of file diff --git a/tests/Rowbot.Test/Targets/AsyncExcelTarget_Test.cs b/tests/Rowbot.Test/Targets/AsyncExcelTarget_Test.cs new file mode 100644 index 0000000..970765f --- /dev/null +++ b/tests/Rowbot.Test/Targets/AsyncExcelTarget_Test.cs @@ -0,0 +1,366 @@ +using ClosedXML.Excel; +using Rowbot.Targets; + +namespace Rowbot.Test.Targets +{ + public class AsyncExcelTarget_Test + { + [Theory] + [InlineData("A", 1)] + [InlineData("B", 2)] + [InlineData("C", 3)] + [InlineData("D", 4)] + [InlineData("E", 5)] + [InlineData("F", 6)] + [InlineData("G", 7)] + [InlineData("H", 8)] + [InlineData("I", 9)] + [InlineData("J", 10)] + [InlineData("K", 11)] + [InlineData("L", 12)] + [InlineData("M", 13)] + [InlineData("N", 14)] + [InlineData("O", 15)] + [InlineData("P", 16)] + [InlineData("Q", 17)] + [InlineData("R", 18)] + [InlineData("S", 19)] + [InlineData("T", 20)] + [InlineData("U", 21)] + [InlineData("V", 22)] + [InlineData("W", 23)] + [InlineData("X", 24)] + [InlineData("Y", 25)] + [InlineData("Z", 26)] + [InlineData("AA", 27)] + [InlineData("AB", 28)] + [InlineData("AC", 29)] + [InlineData("AY", 26 + 26 - 1)] + [InlineData("AZ", 26 + 26)] + [InlineData("BA", 26 + 26 + 1)] + [InlineData("BB", 26 + 26 + 2)] + [InlineData("BC", 26 + 26 + 3)] + [InlineData("CA", 26 + 26 + 26 + 1)] + public void GetColumnName(string expectedName, int oneBasedIndex) + { + var name = AsyncExcelTarget.GetColumnName(oneBasedIndex); + Assert.Equal(expectedName, name); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task EmptyColumns_ExpectNoRowsAsExcelDoesNotHaveTheConceptOfEmptySheetWithRows_Test( + bool writeHeaders) + { + using (var ms = new MemoryStream()) + using (var target = new AsyncExcelTarget(ms, sheetName: "My sheet < > \" and Φ╚", + writeHeaders: writeHeaders, + leaveOpen: true)) // Leave open to be able to read from the stream after completion + { + await target.InitAsync(Array.Empty()); + + await target.WriteRowAsync(Array.Empty()); + await target.WriteRowAsync(Array.Empty()); + + await target.CompleteAsync(); + + EnsureExcelContent(ms.ToArray(), expectedSheetName: "My sheet < > \" and Φ╚", + expectedRowValues: Array.Empty()); + } + } + + [Theory] + [InlineData("Me & My")] + [InlineData("Me and \"My\"")] + [InlineData("Me and 'My'")] + [InlineData("Me and < My")] + [InlineData("Me and > My")] + public async Task SpecialCharactersInCell_Test(string data) + { + using (var ms = new MemoryStream()) + using (var target = new AsyncExcelTarget(ms, sheetName: "My sheet < > \" and Φ╚", writeHeaders: false, + leaveOpen: true)) // Leave open to be able to read from the stream after completion + { + await target.InitAsync(new ColumnInfo[] + { new ColumnInfo(name: "MyColumn123", valueType: typeof(string)) }); + + await target.WriteRowAsync(new object[] { data }); + await target.WriteRowAsync(new object[] { data }); + + await target.CompleteAsync(); + + EnsureExcelContent(ms.ToArray(), expectedSheetName: "My sheet < > \" and Φ╚", + expectedRowValues: new XLCellValue[][] { new XLCellValue[] { data }, new XLCellValue[] { data } }); + } + } + + [Theory] + [InlineData("Me & My")] + [InlineData("Me and \"My\"")] + [InlineData("Me and 'My'")] + [InlineData("Me and < My")] + [InlineData("Me and > My")] + public async Task SpecialCharactersInColumn_Test(string columnName) + { + using (var ms = new MemoryStream()) + using (var target = new AsyncExcelTarget(ms, sheetName: "My sheet < > \" and Φ╚", writeHeaders: true, + leaveOpen: true)) // Leave open to be able to read from the stream after completion + { + await target.InitAsync(new ColumnInfo[] + { new ColumnInfo(name: columnName, valueType: typeof(string)) }); + + await target.WriteRowAsync(new object[] { "Hello1" }); + + await target.CompleteAsync(); + + EnsureExcelContent(ms.ToArray(), expectedSheetName: "My sheet < > \" and Φ╚", + expectedRowValues: new XLCellValue[][] + { new XLCellValue[] { columnName }, new XLCellValue[] { "Hello1" } }); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task SimpleTest_WithColumns(bool writeHeaders) + { + using (var ms = new MemoryStream()) + using (var target = + new AsyncExcelTarget(ms, sheetName: "My sheet", writeHeaders: writeHeaders, + leaveOpen: true)) // Leave open to be able to read from the stream after completion + { + await target.InitAsync(new ColumnInfo[] + { + new ColumnInfo(name: "ColA", typeof(string)), new ColumnInfo(name: "Hey ÆØÅ <", typeof(string)) + }); + + await target.WriteRowAsync(new object[] { "Hey \" and < and > in the text", "There" }); + await target.WriteRowAsync(new object[] { "There", "Over there" }); + + await target.CompleteAsync(); + + if (writeHeaders) + { + EnsureExcelContent(ms.ToArray(), expectedSheetName: "My sheet", + expectedRowValues: new XLCellValue[][] + { + new XLCellValue[] { "ColA", "Hey ÆØÅ <" }, + new XLCellValue[] { "Hey \" and < and > in the text", "There" }, + new XLCellValue[] { "There", "Over there" } + }); + } + else + { + EnsureExcelContent(ms.ToArray(), expectedSheetName: "My sheet", + expectedRowValues: new XLCellValue[][] + { + new XLCellValue[] { "Hey \" and < and > in the text", "There" }, + new XLCellValue[] { "There", "Over there" } + }); + } + } + } + + [Fact] + public async Task DataTypeTesting_Null() + { + await RunTypeTestAsync(null, expectedValue: Blank.Value); + await RunTypeTestAsync(null, expectedValue: Blank.Value); + await RunTypeTestAsync(null, expectedValue: Blank.Value); + } + + [Theory] + [InlineData("Hello < and > there")] + [InlineData("Hello \"there\"")] + public async Task DataTypeTesting_String(string value) + { + await RunTypeTestAsync(value: value, expectedValue: value); + } + + [Fact] + public async Task DataTypeTesting_NewLines() + { + //NOTE: This test uses ClosedXml to reload the excel data. + // On load, ClosedXml will adjust any line breaks (\n or \r\n) and replace them with the value of Environment.Newline. + // This causes different results when run on linux and windows machines. + await RunTypeTestAsync(value: "New\nline", expectedValue: $"New{Environment.NewLine}line"); + await RunTypeTestAsync(value: "CR\r\nLF", expectedValue: $"CR{Environment.NewLine}LF"); + } + + [InlineData("Carriage\rreturn")] + [InlineData("")] + [InlineData("CR\r\nLF")] +#pragma warning disable xUnit1005 // Fact methods should not have test data + [Fact] + public async Task DataTypeTesting_IntFamily() + { + await RunTypeTestAsync(12, 12); + await RunTypeTestAsync(12, 12); + await RunTypeTestAsync(12, 12); + await RunTypeTestAsync(12, 12); + await RunTypeTestAsync(12, 12); + await RunTypeTestAsync(12, 12); + await RunTypeTestAsync(12, 12); + await RunTypeTestAsync(12, 12); + } +#pragma warning restore xUnit1005 // Fact methods should not have test data + + [Fact] + public async Task DataTypeTesting_DecimalFamily() + { + await RunTypeTestAsync(12.3456m, 12.3456m); + await RunTypeTestAsync(12.3456f, 12.3456f, numberCompareTolerance: 0.00001); + await RunTypeTestAsync(12.3456, 12.3456, numberCompareTolerance: 0.00001); + } + + [Fact(Skip = "To be fixed")] + public async Task DataTypeTesting_DateTime() + { + await RunTypeTestAsync(value: new DateTime(2001, 02, 03, 04, 05, 06), + expectedValue: new DateTime(2001, 02, 03, 04, 05, 06)); + + throw new NotImplementedException("Why does this work??"); + } + + private async Task RunTypeTestAsync(T? value, XLCellValue expectedValue, double numberCompareTolerance = 0.0) + { + using (var ms = new MemoryStream()) + using (var target = + new AsyncExcelTarget(ms, sheetName: "My sheet", writeHeaders: true, + leaveOpen: true)) // Leave open to be able to read from the stream after completion + { + await target.InitAsync(new ColumnInfo[] + { + new ColumnInfo(name: "ColA", typeof(T)) + }); + + await target.WriteRowAsync(new object?[] { value }); + + await target.CompleteAsync(); + + EnsureExcelContent(ms.ToArray(), expectedSheetName: "My sheet", expectedRowValues: new XLCellValue[][] + { + new XLCellValue[] { "ColA" }, + new XLCellValue[] { expectedValue } + }, numberCompareTolerance: numberCompareTolerance); + } + } + + private const int Call_Complete = 1; + private const int Call_Dispose = 2; + + [Theory] + [InlineData(Call_Complete, true, true)] + [InlineData(Call_Complete, false, false)] + [InlineData(Call_Complete, null, false)] + [InlineData(Call_Dispose, true, true)] + [InlineData(Call_Dispose, false, false)] + [InlineData(Call_Dispose, null, false)] + public async Task LeaveOpen_EnsureDisposingStreamAccordingToLeaveOpenValue_Test(int whatToCall, bool? leaveOpen, + bool expectToLeaveOpen) + { + using (var ms = new MemoryStream()) + { + AsyncExcelTarget target; + if (leaveOpen == null) + { + // Rely on default behaviour + target = new AsyncExcelTarget(ms, sheetName: "My sheet", writeHeaders: true); + } + else + { + target = new AsyncExcelTarget(ms, sheetName: "My sheet", writeHeaders: true, + leaveOpen: leaveOpen.Value); + } + + using (target) + { + // Ensure init and writeWrote are allowed and stream not disposed as of yet + await target.InitAsync(new ColumnInfo[] + { + new ColumnInfo(name: "Col1", valueType: typeof(string)), + new ColumnInfo(name: "Col2", valueType: typeof(decimal)), + new ColumnInfo(name: "Col3", valueType: typeof(object)), + }); + + await target.WriteRowAsync(new object?[] { "Hello there æå 1", -12.45m, "hi" }); // non-strings + + // Now call complete + if (whatToCall == Call_Complete) + { + await target.CompleteAsync(); + } + else + { + target.Dispose(); + } + + // And ensure memory stream is disposed only if leaveOpen was false + if (expectToLeaveOpen) + { + ms.WriteByte(1); + } + else + { + Assert.Throws(() => ms.WriteByte(1)); + } + } + } + } + + private void EnsureExcelContent(byte[] excelData, string expectedSheetName, XLCellValue[][] expectedRowValues, + double numberCompareTolerance = 0.0) + { + using (var ms = new MemoryStream(excelData)) + { + // Code copied from https://www.aspsnippets.com/Articles/Read-and-Import-Excel-data-to-DataTable-using-ClosedXml-in-ASPNet-with-C-and-VBNet.aspx + + //Open the Excel file using ClosedXML. + using (XLWorkbook workBook = new XLWorkbook(ms)) + { + //Read the first Sheet from Excel file. + Assert.Equal(1, workBook.Worksheets.Count); // Ensure only one sheet in thing to open + + IXLWorksheet workSheet = workBook.Worksheet(1); + Assert.Equal(expectedSheetName, workSheet.Name); + + //Loop through the Worksheet rows. + for (var rowIndex = 0; rowIndex < expectedRowValues.Length; rowIndex++) + { + var expectedRow = expectedRowValues[rowIndex]; + for (var columnIndex = 0; columnIndex < expectedRow.Length; columnIndex++) + { + var value = workSheet.Cell(row: rowIndex + 1, column: columnIndex + 1).Value; + + // If value type is double or single, compare with a little tolerance + var expectedValue = expectedRow[columnIndex]; + if (expectedValue.IsNumber) + { + var expectedNumberValue = expectedValue.GetNumber(); + Assert.Equal(expectedNumberValue, (double)value, tolerance: numberCompareTolerance); + } + else + { + Assert.Equal(expectedValue, value); + } + } + + // Ensure no more columns than expected + var valueInNextCellToTheRight = + workSheet.Cell(row: rowIndex + 1, column: expectedRowValues.Length + 1).Value; + Assert.True(valueInNextCellToTheRight.IsBlank); + } + + // Ensure no more rows than expected + var valueInNextRow = workSheet.Cell(row: expectedRowValues.Length + 1, column: 1).Value; + Assert.True(valueInNextRow.IsBlank); + } + } + } + + private class UnitTestCustomType + { + } + } +} \ No newline at end of file diff --git a/tests/Rowbot.Test/Targets/AsyncPropertyReflectionTarget_Test.cs b/tests/Rowbot.Test/Targets/AsyncPropertyReflectionTarget_Test.cs new file mode 100644 index 0000000..9e442bf --- /dev/null +++ b/tests/Rowbot.Test/Targets/AsyncPropertyReflectionTarget_Test.cs @@ -0,0 +1,176 @@ +using Rowbot.Targets; + +namespace Rowbot.Test.Targets +{ + public class AsyncPropertyReflectionTarget_Test + { + [Fact] + public void Constructor_NoSettablePropertiesOnGenericType_ExpectException_Test() + { + Assert.Throws(() => new AsyncPropertyReflectionTarget()); + } + + [Fact] + public async Task NoMatchingPropertiesWithColumnNames_Test() + { + var target = new AsyncPropertyReflectionTarget(); + + await target.InitAsync(new ColumnInfo[] + { + new ColumnInfo(name: "A", typeof(string)), + new ColumnInfo(name: "B", typeof(int)) + }); + + { + var e = await target.WriteRowAsync(new object[] { "Hello", 42 }); + Assert.Null(e.String_GetOnly); + Assert.Null(e.Get_String_SetOnly_Value()); + Assert.Null(e.String_GetAndSet); + Assert.Equal(0, e.Int_GetAndSet); + Assert.Equal(0, e.AnotherInt_GetAndSet); + Assert.Null(e.NullableInt_GetAndSet); + } + + { + var e = await target.WriteRowAsync(new object[] { "Hello again", 82 }); + Assert.Null(e.String_GetOnly); + Assert.Null(e.Get_String_SetOnly_Value()); + Assert.Null(e.String_GetAndSet); + Assert.Equal(0, e.Int_GetAndSet); + Assert.Equal(0, e.AnotherInt_GetAndSet); + Assert.Null(e.NullableInt_GetAndSet); + } + + await target.CompleteAsync(); + } + + [Fact] + public async Task WithMatchingProperties_Test() + { + var target = new AsyncPropertyReflectionTarget(); + + await target.InitAsync(new ColumnInfo[] + { + new ColumnInfo(name: "string_SETONLY", typeof(string)), + new ColumnInfo(name: "and_this_one_does_not_match_anything", typeof(string)), + new ColumnInfo(name: "int_getandset", typeof(int)) + }); + + { + var e = await target.WriteRowAsync(new object[] { "Hello", "ignored", 42 }); + Assert.Null(e.String_GetOnly); + Assert.Equal("Hello", e.Get_String_SetOnly_Value()); + Assert.Null(e.String_GetAndSet); + Assert.Equal(42, e.Int_GetAndSet); + Assert.Equal(0, e.AnotherInt_GetAndSet); + Assert.Null(e.NullableInt_GetAndSet); + } + + { + var e = await target.WriteRowAsync(new object[] { "Hello again", "ignored", 82 }); + Assert.Null(e.String_GetOnly); + Assert.Equal("Hello again", e.Get_String_SetOnly_Value()); + Assert.Null(e.String_GetAndSet); + Assert.Equal(82, e.Int_GetAndSet); + Assert.Equal(0, e.AnotherInt_GetAndSet); + Assert.Null(e.NullableInt_GetAndSet); + } + + await target.CompleteAsync(); + } + + [Fact] + public async Task NullableIntOnProperty_NotNullableIntInSource_EnsureWorks_Test() + { + var target = new AsyncPropertyReflectionTarget(); + + await target.InitAsync(new ColumnInfo[] + { + new ColumnInfo(name: "NullableInt_GetAndSet", typeof(int)) + }); + + var dummy = await target.WriteRowAsync(new object[] { 42 }); + + await target.CompleteAsync(); + + Assert.Equal(42, dummy.NullableInt_GetAndSet); + } + + [Theory] + [InlineData(42)] + [InlineData(null)] + public async Task NullableIntOnProperty_NullableIntInSource_EnsureWorks_Test(int? inputValue) + { + var target = new AsyncPropertyReflectionTarget(); + + await target.InitAsync(new ColumnInfo[] + { + new ColumnInfo(name: "NullableInt_GetAndSet", typeof(int?)) + }); + + var dummy = await target.WriteRowAsync(new object?[] { inputValue }); + + await target.CompleteAsync(); + + Assert.Equal(inputValue, dummy.NullableInt_GetAndSet); + } + + [Theory] + [InlineData(42, true)] + [InlineData(null, false)] + public async Task IntOnProperty_NullableIntInSource_EnsureValuesAreCopiedWhenNotNullInSource_Test(int? inputValue, + bool expectSuccess) + { + var target = new AsyncPropertyReflectionTarget(); + + await target.InitAsync(new ColumnInfo[] + { + new ColumnInfo(name: "Int_GetAndSet", typeof(int?)) + }); + + if (expectSuccess) + { + var dummy = await target.WriteRowAsync(new object?[] { inputValue }); + + await target.CompleteAsync(); + + Assert.Equal(inputValue, dummy.Int_GetAndSet); + } + else + { + // Expect exception when setting to null + _ = await Assert.ThrowsAsync(() => target.WriteRowAsync(new object?[] { inputValue })); + } + } + + [Fact] + public async Task TypeMismatch_Test() + { + var target = new AsyncPropertyReflectionTarget(); + _ = await Assert.ThrowsAsync(() => + target.InitAsync(new ColumnInfo[] { new ColumnInfo(name: "string_SETONLY", typeof(int)) })); + } + +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + private class UnitTestDummy_NoSetters + { + public string GetOnly { get; } + } + + private class UnitTestDummy + { + public string String_GetOnly { get; private set; } + public string String_SetOnly { private get; set; } + public string String_GetAndSet { get; set; } + public int Int_GetAndSet { get; set; } + public int AnotherInt_GetAndSet { get; set; } + public int? NullableInt_GetAndSet { get; set; } + + public string Get_String_SetOnly_Value() + { + return String_SetOnly; + } + } +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + } +} \ No newline at end of file diff --git a/tests/Rowbot.Test/Targets/PropertyReflectionTarget_Test.cs b/tests/Rowbot.Test/Targets/PropertyReflectionTarget_Test.cs index a1f0040..29f914b 100644 --- a/tests/Rowbot.Test/Targets/PropertyReflectionTarget_Test.cs +++ b/tests/Rowbot.Test/Targets/PropertyReflectionTarget_Test.cs @@ -1,9 +1,4 @@ using Rowbot.Targets; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Rowbot.Test.Targets { @@ -20,10 +15,11 @@ public void NoMatchingPropertiesWithColumnNames_Test() { var target = new PropertyReflectionTarget(); - target.Init(new ColumnInfo[]{ - new ColumnInfo(name: "A", typeof(string)), - new ColumnInfo(name: "B", typeof(int)) - }); + target.Init(new ColumnInfo[] + { + new ColumnInfo(name: "A", typeof(string)), + new ColumnInfo(name: "B", typeof(int)) + }); { var e = target.WriteRow(new object[] { "Hello", 42 }); @@ -53,11 +49,12 @@ public void WithMatchingProperties_Test() { var target = new PropertyReflectionTarget(); - target.Init(new ColumnInfo[]{ - new ColumnInfo(name: "string_SETONLY", typeof(string)), - new ColumnInfo(name: "and_this_one_does_not_match_anything", typeof(string)), - new ColumnInfo(name: "int_getandset", typeof(int)) - }); + target.Init(new ColumnInfo[] + { + new ColumnInfo(name: "string_SETONLY", typeof(string)), + new ColumnInfo(name: "and_this_one_does_not_match_anything", typeof(string)), + new ColumnInfo(name: "int_getandset", typeof(int)) + }); { var e = target.WriteRow(new object[] { "Hello", "ignored", 42 }); @@ -87,16 +84,16 @@ public void NullableIntOnProperty_NotNullableIntInSource_EnsureWorks_Test() { var target = new PropertyReflectionTarget(); - target.Init(new ColumnInfo[]{ - new ColumnInfo(name: "NullableInt_GetAndSet", typeof(int)) - }); + target.Init(new ColumnInfo[] + { + new ColumnInfo(name: "NullableInt_GetAndSet", typeof(int)) + }); var dummy = target.WriteRow(new object[] { 42 }); target.Complete(); Assert.Equal(42, dummy.NullableInt_GetAndSet); - } [Theory] @@ -106,9 +103,10 @@ public void NullableIntOnProperty_NullableIntInSource_EnsureWorks_Test(int? inpu { var target = new PropertyReflectionTarget(); - target.Init(new ColumnInfo[]{ - new ColumnInfo(name: "NullableInt_GetAndSet", typeof(int?)) - }); + target.Init(new ColumnInfo[] + { + new ColumnInfo(name: "NullableInt_GetAndSet", typeof(int?)) + }); var dummy = target.WriteRow(new object?[] { inputValue }); @@ -120,13 +118,15 @@ public void NullableIntOnProperty_NullableIntInSource_EnsureWorks_Test(int? inpu [Theory] [InlineData(42, true)] [InlineData(null, false)] - public void IntOnProperty_NullableIntInSource_EnsureValuesAreCopiedWhenNotNullInSource_Test(int? inputValue, bool expectSuccess) + public void IntOnProperty_NullableIntInSource_EnsureValuesAreCopiedWhenNotNullInSource_Test(int? inputValue, + bool expectSuccess) { var target = new PropertyReflectionTarget(); - target.Init(new ColumnInfo[]{ - new ColumnInfo(name: "Int_GetAndSet", typeof(int?)) - }); + target.Init(new ColumnInfo[] + { + new ColumnInfo(name: "Int_GetAndSet", typeof(int?)) + }); if (expectSuccess) { @@ -139,10 +139,7 @@ public void IntOnProperty_NullableIntInSource_EnsureValuesAreCopiedWhenNotNullIn else { // Expect exception when setting to null - Assert.Throws(() => - { - target.WriteRow(new object?[] { inputValue }); - }); + Assert.Throws(() => { target.WriteRow(new object?[] { inputValue }); }); } } @@ -150,28 +147,30 @@ public void IntOnProperty_NullableIntInSource_EnsureValuesAreCopiedWhenNotNullIn public void TypeMismatch_Test() { var target = new PropertyReflectionTarget(); - Assert.Throws(() => target.Init(new ColumnInfo[] { new ColumnInfo(name: "string_SETONLY", typeof(int)) })); + Assert.Throws(() => + target.Init(new ColumnInfo[] { new ColumnInfo(name: "string_SETONLY", typeof(int)) })); } - } #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. - public class UnitTestDummy_NoSetters - { - public string GetOnly { get; } - } + private class UnitTestDummy_NoSetters + { + public string GetOnly { get; } + } - public class UnitTestDummy - { - public string String_GetOnly { get; private set; } - public string String_SetOnly { private get; set; } - public string String_GetAndSet { get; set; } - public int Int_GetAndSet { get; set; } - public int AnotherInt_GetAndSet { get; set; } - public int? NullableInt_GetAndSet { get; set; } - public string Get_String_SetOnly_Value() + private class UnitTestDummy { - return String_SetOnly; + public string String_GetOnly { get; private set; } + public string String_SetOnly { private get; set; } + public string String_GetAndSet { get; set; } + public int Int_GetAndSet { get; set; } + public int AnotherInt_GetAndSet { get; set; } + public int? NullableInt_GetAndSet { get; set; } + + public string Get_String_SetOnly_Value() + { + return String_SetOnly; + } } - } #pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. -} + } +} \ No newline at end of file diff --git a/tests/Rowbot.Test/TestUtils.cs b/tests/Rowbot.Test/TestUtils.cs index f56916b..d61335c 100644 --- a/tests/Rowbot.Test/TestUtils.cs +++ b/tests/Rowbot.Test/TestUtils.cs @@ -1,12 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Rowbot.Test +namespace Rowbot.Test { - internal class TestUtils + internal static class TestUtils { public static object[][] ReadAllLines(IRowSource source, int columnCount) { @@ -18,5 +12,16 @@ public static object[][] ReadAllLines(IRowSource source, int columnCount) } return allLines.ToArray(); } + + public static async Task ReadAllLinesAsync(IAsyncRowSource source, int columnCount) + { + var allLines = new List(); + var buffer = new object[columnCount]; + while (await source.ReadRowAsync(buffer)) + { + allLines.Add(buffer.ToArray()); + } + return allLines.ToArray(); + } } }