diff --git a/RecordParser.Test/FileReaderTest.cs b/RecordParser.Test/FileReaderTest.cs index d6e78fe..c4404df 100644 --- a/RecordParser.Test/FileReaderTest.cs +++ b/RecordParser.Test/FileReaderTest.cs @@ -39,7 +39,7 @@ public void Given_record_is_too_large_for_default_buffer_size_then_exception_sho // construct a CSV with a header row // and a *single* data row, where the 4th column contains a large inlined, CSV file enclosed in quotes. // this is an extreme case, but is a valid CSV according to the spec. - var tw = new StringWriter(); + using var tw = new StringWriter(); tw.WriteLine("A,B,C,D"); tw.Write("1,2,3,\""); @@ -51,7 +51,7 @@ public void Given_record_is_too_large_for_default_buffer_size_then_exception_sho tw.WriteLine("\""); var fileContent = tw.ToString(); - var reader = new StringReader(fileContent); + using var reader = new StringReader(fileContent); // Act @@ -138,7 +138,7 @@ public void Given_record_is_too_large_for_custom_buffer_size_then_exception_shou (88,89,90,91), }; - var reader = new StringReader(fileContent); + using var reader = new StringReader(fileContent); var parser = new VariableLengthReaderSequentialBuilder<(int A, int B, int C, int D)>() .Map(x => x.A) @@ -639,7 +639,7 @@ public void Read_csv_file_with_autobind_should_support_additional_configuration( 88,89,90,91 """; - var reader = new StringReader(fileContent); + using var reader = new StringReader(fileContent); var expected = new RegularCaseRecord[] { new(10,20,30,40), @@ -679,7 +679,7 @@ public void Read_csv_file_with_autobind_should_support_nested_fields() 2020.05.23 ; son name ; 1980.01.15 ; mother name """; - var reader = new StringReader(fileContent); + using var reader = new StringReader(fileContent); var expected = new Person[] { new Person @@ -721,7 +721,7 @@ public void Read_csv_file_with_autobind_should_support_inherited_properties() 99 ; 2020.05.23 ; son name ; 100 ; 1980.01.15 ; mother name """; - var reader = new StringReader(fileContent); + using var reader = new StringReader(fileContent); var expected = new PersonDerivated[] { new () @@ -769,7 +769,7 @@ public void Read_csv_file_with_autobind_should_throw_when_missing_header(string 99 ; 2020.05.23 ; son name ; 100 ; 1980.01.15 ; mother name """; - var reader = new StringReader(fileContent); + using var reader = new StringReader(fileContent); var expected = new Person[] { new () @@ -824,7 +824,7 @@ public void Read_csv_file_with_autobind_should_detect_common_separators(string s """ .Replace(",", separator); - var reader = new StringReader(fileContent); + using var reader = new StringReader(fileContent); var expected = new RegularCaseRecord[] { new(1,2,3,4), @@ -888,7 +888,7 @@ public void Read_csv_file_with_autobind_should_support_explict_separators(string """ .Replace(",", fileSeparator); - var reader = new StringReader(fileContent); + using var reader = new StringReader(fileContent); var expected = new RegularCaseRecord[] { new(1,2,3,4), diff --git a/RecordParser.Test/FileWriterTest.cs b/RecordParser.Test/FileWriterTest.cs index 408176c..8d10b2d 100644 --- a/RecordParser.Test/FileWriterTest.cs +++ b/RecordParser.Test/FileWriterTest.cs @@ -9,6 +9,7 @@ using System.IO; using System.Linq; using Xunit; +using static RecordParser.Test.FileReaderTest; namespace RecordParser.Test { @@ -95,6 +96,131 @@ public void Write_csv_file(int repeat, bool parallel, bool ordered) items.Should().BeEquivalentTo(expectedItems, cfg => cfg.WithStrictOrdering()); else items.Should().BeEquivalentTo(expectedItems); - } + } + + [Fact] + public void Write_csv_file_with_autobind_should_support_regular_properties() + { + // Arrange + + var items = new RegularCaseRecord[] + { + new(1,2,3,4), + new(5,6,7,8), + new(9,10,11,12), + new(13,14,15,16), + new(87,88,89,100), + new(89,99,100,101), + new(88,89,90,91), + }; + + var expected = $""" + Aaa;Bbb;Ccc;Ddd + 1;2;3;4 + 5;6;7;8 + 9;10;11;12 + 13;14;15;16 + 87;88;89;100 + 89;99;100;101 + 88;89;90;91 + + """; + + // Act + + using var memory = new MemoryStream(); + using var textWriter = new StreamWriter(memory); + + textWriter.WriteRecords(items); + textWriter.Flush(); + + // Assert + + memory.Seek(0, SeekOrigin.Begin); + using var textReader = new StreamReader(memory); + var content = textReader.ReadToEnd(); + content.Should().Be(expected); + } + + [Fact] + public void Write_csv_file_with_autobind_should_respect_max_depth_configuration() + { + // Arrange + + var items = new Person[] + { + new("Bob",22,new("CPF", "123")), + new("Carla",26, new("Other", "ABC")), + }; + + var expected = $""" + Name;Age + Bob;22 + Carla;26 + + """; + + // Act + + using var memory = new MemoryStream(); + using var textWriter = new StreamWriter(memory); + var options = new VariableLengthWriterAutoBindOptions + { + MaxDepth = 1 + }; + + textWriter.WriteRecords(items, options); + textWriter.Flush(); + + // Assert + + memory.Seek(0, SeekOrigin.Begin); + using var textReader = new StreamReader(memory); + var content = textReader.ReadToEnd(); + content.Should().Be(expected); + } + + + [Fact] + public void Write_csv_file_with_autobind_should_support_nested_properties() + { + // Arrange + + var items = new Person[] + { + new("Bob",22,new("CPF", "123")), + new("Carla",26, new("Other", "ABC")), + }; + + var expected = $""" + Name;Age;Document.Type;Document.Value + Bob;22;CPF;123 + Carla;26;Other;ABC + + """; + + // Act + + using var memory = new MemoryStream(); + using var textWriter = new StreamWriter(memory); + + textWriter.WriteRecords(items); + textWriter.Flush(); + + // Assert + + memory.Seek(0, SeekOrigin.Begin); + using var textReader = new StreamReader(memory); + var content = textReader.ReadToEnd(); + content.Should().Be(expected); + + memory.Seek(0, SeekOrigin.Begin); + var opt = new VariableLengthReaderAutoBindOptions() { HasHeader = true }; + var readItems = textReader.ReadRecords(opt); + readItems.Should().BeEquivalentTo(items); + } + + public record class Person(string Name, int Age, Documento Document); + public record class Documento(string Type, string Value); } } \ No newline at end of file diff --git a/RecordParser/Engines/Reader/PrimitiveTypeReaderEngine.cs b/RecordParser/Engines/Reader/PrimitiveTypeReaderEngine.cs index 1f1c0b1..7610e3f 100644 --- a/RecordParser/Engines/Reader/PrimitiveTypeReaderEngine.cs +++ b/RecordParser/Engines/Reader/PrimitiveTypeReaderEngine.cs @@ -47,6 +47,9 @@ static PrimitiveTypeReaderEngine() dic = mapping; } + public static bool IsPrimitiveType(Type type) => + type.IsPrimitive || type.IsEnum || dic.Any(kvp => kvp.Key.to == type); + private static char ToChar(ReadOnlySpan span) => span[0]; private static void AddMapForReadOnlySpan( diff --git a/RecordParser/Extensions/FileWriter/WriterExtensions.cs b/RecordParser/Extensions/FileWriter/WriterExtensions.cs index 04f8345..daae5e3 100644 --- a/RecordParser/Extensions/FileWriter/WriterExtensions.cs +++ b/RecordParser/Extensions/FileWriter/WriterExtensions.cs @@ -1,8 +1,13 @@ -using System; +using RecordParser.Builders.Writer; +using RecordParser.Engines.Reader; +using RecordParser.Parsers; +using System; using System.Buffers; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Linq.Expressions; +using System.Reflection; namespace RecordParser.Extensions { @@ -18,6 +23,19 @@ namespace RecordParser.Extensions /// public delegate bool TryFormat(T instance, Span destination, out int charsWritten); + public record class VariableLengthWriterAutoBindOptions + { + /// + /// Maximum depth to search for nested properties. + /// + public int MaxDepth { get; set; } = 3; + + /// + /// Options to configure parallel processing + /// + public ParallelismOptions ParallelismOptions { get; set; } + } + public static class WriterExtensions { private const int initialPow = 10; @@ -118,5 +136,108 @@ private static void WriteSequential(TextWriter textWriter, IEnumerable ite ArrayPool.Shared.Return(buffer); } } + + private class NodeState + { + public Type Type { get; set; } + public Expression CurrentExpression { get; set; } + public int Depth { get; set; } + } + + /// + /// Writes each item of the sequence as a csv record into the as well the header. + /// + /// Type of items in the sequence. + /// The TextWriter where the items will be written into. + /// Sequence of the elements. + public static void WriteRecords(this TextWriter textWriter, IEnumerable items) => + WriteRecords(textWriter, items, default(VariableLengthWriterAutoBindOptions)); + + /// + /// Writes each item of the sequence as a csv record into the as well the header. + /// + /// Type of items in the sequence. + /// The TextWriter where the items will be written into. + /// Sequence of the elements. + /// Options to configure autobind processing. + public static void WriteRecords(this TextWriter textWriter, IEnumerable items, VariableLengthWriterAutoBindOptions options) + { + options ??= new(); + var parallel = options.ParallelismOptions ?? new(); + + const string separator = ";"; + var members = GetPropertyExpressions(typeof(T), options.MaxDepth); + var builder = new VariableLengthWriterSequentialBuilder(); + + foreach (var item in members.Select(x => x.exp)) + if (item.ReturnType == typeof(string)) + builder.Map((dynamic)item, converter: default(FuncSpanTIntBool)); + else + builder.Map((dynamic)item); + + var parser = builder.Build(separator); + var header = string.Join(separator, members.Select(x => x.column)); + + textWriter.WriteLine(header); + + WriteRecords(textWriter, items, parser.TryFormat, parallel); + } + + private static IReadOnlyList<(LambdaExpression exp, string column)> GetPropertyExpressions(Type type, int maxDepth) + { + var expressions = new List<(LambdaExpression, string)>(); + + if (maxDepth < 1) + return expressions; + + var paramText = Guid.NewGuid().ToString(); + var rootParameter = Expression.Parameter(type, paramText); + + var queue = new Queue(); + + queue.Enqueue(new NodeState + { + Type = type, + CurrentExpression = rootParameter, + Depth = 1 + }); + + // loop BFS + while (queue.Count > 0) + { + var currentState = queue.Dequeue(); + + if (currentState.Depth > maxDepth) + continue; + + var properties = currentState.Type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + + foreach (var prop in properties) + { + if (prop.CanRead == false) + continue; + + var propertyAccess = Expression.Property(currentState.CurrentExpression, prop); + + if (PrimitiveTypeReaderEngine.IsPrimitiveType(prop.PropertyType)) + { + var lambda = Expression.Lambda(propertyAccess, rootParameter); + var column = propertyAccess.ToString().Replace(paramText + ".", string.Empty); + expressions.Add((lambda, column)); + } + else + { + queue.Enqueue(new NodeState + { + Type = prop.PropertyType, + CurrentExpression = propertyAccess, + Depth = currentState.Depth + 1 + }); + } + } + } + + return expressions; + } } }