RaspberryIO/Unosquare.Swan.Lite/Formatters/CsvWriter.cs
2019-12-04 17:10:06 +01:00

463 lines
16 KiB
C#

using Unosquare.Swan.Reflection;
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
namespace Unosquare.Swan.Formatters {
/// <summary>
/// A CSV writer useful for exporting a set of objects.
/// </summary>
/// <example>
/// The following code describes how to save a list of objects into a CSV file.
/// <code>
/// using System.Collections.Generic;
/// using Unosquare.Swan.Formatters;
///
/// class Example
/// {
/// class Person
/// {
/// public string Name { get; set; }
/// public int Age { get; set; }
/// }
///
/// static void Main()
/// {
/// // create a list of people
/// var people = new List&lt;Person&gt;
/// {
/// new Person { Name = "Artyom", Age = 20 },
/// new Person { Name = "Aloy", Age = 18 }
/// }
///
/// // write items inside file.csv
/// CsvWriter.SaveRecords(people, "C:\\Users\\user\\Documents\\file.csv");
///
/// // output
/// // | Name | Age |
/// // | Artyom | 20 |
/// // | Aloy | 18 |
/// }
/// }
/// </code>
/// </example>
public class CsvWriter : IDisposable {
private static readonly PropertyTypeCache TypeCache = new PropertyTypeCache();
private readonly Object _syncLock = new Object();
private readonly Stream _outputStream;
private readonly Encoding _encoding;
private readonly Boolean _leaveStreamOpen;
private Boolean _isDisposing;
private UInt64 _mCount;
#region Constructors
/// <summary>
/// Initializes a new instance of the <see cref="CsvWriter" /> class.
/// </summary>
/// <param name="outputStream">The output stream.</param>
/// <param name="leaveOpen">if set to <c>true</c> [leave open].</param>
/// <param name="encoding">The encoding.</param>
public CsvWriter(Stream outputStream, Boolean leaveOpen, Encoding encoding) {
this._outputStream = outputStream;
this._encoding = encoding;
this._leaveStreamOpen = leaveOpen;
}
/// <summary>
/// Initializes a new instance of the <see cref="CsvWriter"/> class.
/// It automatically closes the stream when disposing this writer.
/// </summary>
/// <param name="outputStream">The output stream.</param>
/// <param name="encoding">The encoding.</param>
public CsvWriter(Stream outputStream, Encoding encoding)
: this(outputStream, false, encoding) {
// placeholder
}
/// <summary>
/// Initializes a new instance of the <see cref="CsvWriter"/> class.
/// It uses the Windows 1252 encoding and automatically closes
/// the stream upon disposing this writer.
/// </summary>
/// <param name="outputStream">The output stream.</param>
public CsvWriter(Stream outputStream)
: this(outputStream, false, Definitions.Windows1252Encoding) {
// placeholder
}
/// <summary>
/// Initializes a new instance of the <see cref="CsvWriter"/> class.
/// It opens the file given file, automatically closes the stream upon
/// disposing of this writer, and uses the Windows 1252 encoding.
/// </summary>
/// <param name="filename">The filename.</param>
public CsvWriter(String filename)
: this(File.OpenWrite(filename), false, Definitions.Windows1252Encoding) {
// placeholder
}
/// <summary>
/// Initializes a new instance of the <see cref="CsvWriter"/> class.
/// It opens the file given file, automatically closes the stream upon
/// disposing of this writer, and uses the given text encoding for output.
/// </summary>
/// <param name="filename">The filename.</param>
/// <param name="encoding">The encoding.</param>
public CsvWriter(String filename, Encoding encoding)
: this(File.OpenWrite(filename), false, encoding) {
// placeholder
}
#endregion
#region Properties
/// <summary>
/// Gets or sets the field separator character.
/// </summary>
/// <value>
/// The separator character.
/// </value>
public Char SeparatorCharacter { get; set; } = ',';
/// <summary>
/// Gets or sets the escape character to use to escape field values.
/// </summary>
/// <value>
/// The escape character.
/// </value>
public Char EscapeCharacter { get; set; } = '"';
/// <summary>
/// Gets or sets the new line character sequence to use when writing a line.
/// </summary>
/// <value>
/// The new line sequence.
/// </value>
public String NewLineSequence { get; set; } = Environment.NewLine;
/// <summary>
/// Defines a list of properties to ignore when outputting CSV lines.
/// </summary>
/// <value>
/// The ignore property names.
/// </value>
public List<String> IgnorePropertyNames { get; } = new List<String>();
/// <summary>
/// Gets number of lines that have been written, including the headings line.
/// </summary>
/// <value>
/// The count.
/// </value>
public UInt64 Count {
get {
lock(this._syncLock) {
return this._mCount;
}
}
}
#endregion
#region Helpers
/// <summary>
/// Saves the items to a stream.
/// It uses the Windows 1252 text encoding for output.
/// </summary>
/// <typeparam name="T">The type of enumeration.</typeparam>
/// <param name="items">The items.</param>
/// <param name="stream">The stream.</param>
/// <param name="truncateData"><c>true</c> if stream is truncated, default <c>false</c>.</param>
/// <returns>Number of item saved.</returns>
public static Int32 SaveRecords<T>(IEnumerable<T> items, Stream stream, Boolean truncateData = false) {
// truncate the file if it had data
if(truncateData && stream.Length > 0) {
stream.SetLength(0);
}
using(CsvWriter writer = new CsvWriter(stream)) {
writer.WriteHeadings<T>();
writer.WriteObjects(items);
return (Int32)writer.Count;
}
}
/// <summary>
/// Saves the items to a CSV file.
/// If the file exits, it overwrites it. If it does not, it creates it.
/// It uses the Windows 1252 text encoding for output.
/// </summary>
/// <typeparam name="T">The type of enumeration.</typeparam>
/// <param name="items">The items.</param>
/// <param name="filePath">The file path.</param>
/// <returns>Number of item saved.</returns>
public static Int32 SaveRecords<T>(IEnumerable<T> items, String filePath) => SaveRecords(items, File.OpenWrite(filePath), true);
#endregion
#region Generic, main Write Line Method
/// <summary>
/// Writes a line of CSV text. Items are converted to strings.
/// If items are found to be null, empty strings are written out.
/// If items are not string, the ToStringInvariant() method is called on them.
/// </summary>
/// <param name="items">The items.</param>
public void WriteLine(params Object[] items)
=> this.WriteLine(items.Select(x => x == null ? String.Empty : x.ToStringInvariant()));
/// <summary>
/// Writes a line of CSV text. Items are converted to strings.
/// If items are found to be null, empty strings are written out.
/// If items are not string, the ToStringInvariant() method is called on them.
/// </summary>
/// <param name="items">The items.</param>
public void WriteLine(IEnumerable<Object> items)
=> this.WriteLine(items.Select(x => x == null ? String.Empty : x.ToStringInvariant()));
/// <summary>
/// Writes a line of CSV text.
/// If items are found to be null, empty strings are written out.
/// </summary>
/// <param name="items">The items.</param>
public void WriteLine(params String[] items) => this.WriteLine((IEnumerable<String>)items);
/// <summary>
/// Writes a line of CSV text.
/// If items are found to be null, empty strings are written out.
/// </summary>
/// <param name="items">The items.</param>
public void WriteLine(IEnumerable<String> items) {
lock(this._syncLock) {
Int32 length = items.Count();
Byte[] separatorBytes = this._encoding.GetBytes(new[] { this.SeparatorCharacter });
Byte[] endOfLineBytes = this._encoding.GetBytes(this.NewLineSequence);
// Declare state variables here to avoid recreation, allocation and
// reassignment in every loop
Boolean needsEnclosing;
String textValue;
Byte[] output;
for(Int32 i = 0; i < length; i++) {
textValue = items.ElementAt(i);
// Determine if we need the string to be enclosed
// (it either contains an escape, new line, or separator char)
needsEnclosing = textValue.IndexOf(this.SeparatorCharacter) >= 0
|| textValue.IndexOf(this.EscapeCharacter) >= 0
|| textValue.IndexOf('\r') >= 0
|| textValue.IndexOf('\n') >= 0;
// Escape the escape characters by repeating them twice for every instance
textValue = textValue.Replace($"{this.EscapeCharacter}",
$"{this.EscapeCharacter}{this.EscapeCharacter}");
// Enclose the text value if we need to
if(needsEnclosing) {
textValue = String.Format($"{this.EscapeCharacter}{textValue}{this.EscapeCharacter}", textValue);
}
// Get the bytes to write to the stream and write them
output = this._encoding.GetBytes(textValue);
this._outputStream.Write(output, 0, output.Length);
// only write a separator if we are moving in between values.
// the last value should not be written.
if(i < length - 1) {
this._outputStream.Write(separatorBytes, 0, separatorBytes.Length);
}
}
// output the newline sequence
this._outputStream.Write(endOfLineBytes, 0, endOfLineBytes.Length);
this._mCount += 1;
}
}
#endregion
#region Write Object Method
/// <summary>
/// Writes a row of CSV text. It handles the special cases where the object is
/// a dynamic object or and array. It also handles non-collection objects fine.
/// If you do not like the way the output is handled, you can simply write an extension
/// method of this class and use the WriteLine method instead.
/// </summary>
/// <param name="item">The item.</param>
/// <exception cref="System.ArgumentNullException">item.</exception>
public void WriteObject(Object item) {
if(item == null) {
throw new ArgumentNullException(nameof(item));
}
lock(this._syncLock) {
switch(item) {
case IDictionary typedItem:
this.WriteLine(this.GetFilteredDictionary(typedItem));
return;
case ICollection typedItem:
this.WriteLine(typedItem.Cast<Object>());
return;
default:
this.WriteLine(this.GetFilteredTypeProperties(item.GetType())
.Select(x => x.ToFormattedString(item)));
break;
}
}
}
/// <summary>
/// Writes a row of CSV text. It handles the special cases where the object is
/// a dynamic object or and array. It also handles non-collection objects fine.
/// If you do not like the way the output is handled, you can simply write an extension
/// method of this class and use the WriteLine method instead.
/// </summary>
/// <typeparam name="T">The type of object to write.</typeparam>
/// <param name="item">The item.</param>
public void WriteObject<T>(T item) => this.WriteObject(item as Object);
/// <summary>
/// Writes a set of items, one per line and atomically by repeatedly calling the
/// WriteObject method. For more info check out the description of the WriteObject
/// method.
/// </summary>
/// <typeparam name="T">The type of object to write.</typeparam>
/// <param name="items">The items.</param>
public void WriteObjects<T>(IEnumerable<T> items) {
lock(this._syncLock) {
foreach(T item in items) {
this.WriteObject(item);
}
}
}
#endregion
#region Write Headings Methods
/// <summary>
/// Writes the headings.
/// </summary>
/// <param name="type">The type of object to extract headings.</param>
/// <exception cref="System.ArgumentNullException">type.</exception>
public void WriteHeadings(Type type) {
if(type == null) {
throw new ArgumentNullException(nameof(type));
}
IEnumerable<Object> properties = this.GetFilteredTypeProperties(type).Select(p => p.Name).Cast<Object>();
this.WriteLine(properties);
}
/// <summary>
/// Writes the headings.
/// </summary>
/// <typeparam name="T">The type of object to extract headings.</typeparam>
public void WriteHeadings<T>() => this.WriteHeadings(typeof(T));
/// <summary>
/// Writes the headings.
/// </summary>
/// <param name="dictionary">The dictionary to extract headings.</param>
/// <exception cref="System.ArgumentNullException">dictionary.</exception>
public void WriteHeadings(IDictionary dictionary) {
if(dictionary == null) {
throw new ArgumentNullException(nameof(dictionary));
}
this.WriteLine(this.GetFilteredDictionary(dictionary, true));
}
#if NET452
/// <summary>
/// Writes the headings.
/// </summary>
/// <param name="item">The object to extract headings.</param>
/// <exception cref="ArgumentNullException">item</exception>
/// <exception cref="ArgumentException">Unable to cast dynamic object to a suitable dictionary - item</exception>
public void WriteHeadings(dynamic item) {
if(item == null) {
throw new ArgumentNullException(nameof(item));
}
if(!(item is IDictionary<global::System.String, global::System.Object> dictionary)) {
throw new ArgumentException("Unable to cast dynamic object to a suitable dictionary", nameof(item));
}
this.WriteHeadings(dictionary);
}
#else
/// <summary>
/// Writes the headings.
/// </summary>
/// <param name="obj">The object to extract headings.</param>
/// <exception cref="ArgumentNullException">obj.</exception>
public void WriteHeadings(object obj)
{
if (obj == null)
throw new ArgumentNullException(nameof(obj));
WriteHeadings(obj.GetType());
}
#endif
#endregion
#region IDisposable Support
/// <inheritdoc />
public void Dispose() => this.Dispose(true);
/// <summary>
/// Releases unmanaged and - optionally - managed resources.
/// </summary>
/// <param name="disposeAlsoManaged"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
protected virtual void Dispose(Boolean disposeAlsoManaged) {
if(this._isDisposing) {
return;
}
if(disposeAlsoManaged) {
if(this._leaveStreamOpen == false) {
this._outputStream.Dispose();
}
}
this._isDisposing = true;
}
#endregion
#region Support Methods
private IEnumerable<String> GetFilteredDictionary(IDictionary dictionary, Boolean filterKeys = false)
=> dictionary
.Keys
.Cast<Object>()
.Select(key => key == null ? String.Empty : key.ToStringInvariant())
.Where(stringKey => !this.IgnorePropertyNames.Contains(stringKey))
.Select(stringKey =>
filterKeys
? stringKey
: dictionary[stringKey] == null ? String.Empty : dictionary[stringKey].ToStringInvariant());
private IEnumerable<PropertyInfo> GetFilteredTypeProperties(Type type)
=> TypeCache.Retrieve(type, t =>
t.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanRead))
.Where(p => !this.IgnorePropertyNames.Contains(p.Name));
#endregion
}
}