using System; using System.Collections; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using System.Text; using Swan.Reflection; namespace 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 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<Person> /// { /// 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)); } /// <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)); } this.WriteHeadings(obj.GetType()); } #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 } }