6
\$\begingroup\$

I want to be able to convert a list of objects into a string csv format. I've written this extension method below but have a feeling I'm missing something as this seems like potentially a common thing to want to do.

private static readonly char[] csvChars = new[] { ',', '"', ' ', '\n', '\r' };

public static string ToCsv<T>(this IEnumerable<T> source, Func<T, object> getItem)
{
    if ((source == null) || (getItem == null))
    {
        return string.Empty;
    }

    var builder = new StringBuilder();
    var items = from item in source.Select(getItem) 
                where item != null 
                select item.ToString();

    foreach (var str in items)
    {
        if (str.IndexOfAny(csvChars) > 0)
        {
            builder.Append("\"").Append(str).Append("\"").Append(", ");
        }
        else
        {
            builder.Append(str).Append(", ");
        }
    }

    var csv = builder.ToString();

    return csv.Length > 0 ? csv.TrimEnd(", ".ToCharArray()) : csv;
}

Is there anything I can do to improve this or refactor to a more elegant or working solution. Or even an existing method out there already that I may have missed.

UPDATE: Updated to take into account quotations as per Jesse comments below.

\$\endgroup\$
  • \$\begingroup\$ You are excluding null values. Shouldn't a null value still be represented in the CSV output? Depends on what you're using this for, I suppose. \$\endgroup\$ – Dr. Wily's Apprentice Jan 24 '12 at 17:48
7
\$\begingroup\$

If your items contain a comma, carriage return or other special CSV character, you must delimit it with quotation marks.

namespace CsvStuff
{
    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Text;

    internal static class CsvConstants
    {
        public static char[] TrimEnd { get; } = { ' ', ',' };

        public static char[] CsvChars { get; } = { ',', '"', ' ', '\n', '\r' };
    }

    public abstract class CsvBase<T>
    {
        private readonly IEnumerable<T> values;

        private readonly Func<T, object> getItem;

        protected CsvBase(IEnumerable<T> values, Func<T, object> getItem)
        {
            this.values = values;
            this.getItem = getItem;
        }

        public override string ToString()
        {
            var builder = new StringBuilder();

            foreach (var item in
                from element in this.values.Select(this.getItem)
                where element != null
                select element.ToString())
            {
                this.Build(builder, item).Append(", ");
            }

            return builder.ToString().TrimEnd(CsvConstants.TrimEnd);
        }

        protected abstract StringBuilder Build(StringBuilder builder, string item);
    }

    public class CsvBare<T> : CsvBase<T>
    {
        public CsvBare(IEnumerable<T> values, Func<T, object> getItem) : base(values, getItem)
        {
        }

        protected override StringBuilder Build(StringBuilder builder, string item)
        {
            return builder.Append(item);
        }
    }

    public sealed class CsvTrimBare<T> : CsvBare<T>
    {
        public CsvTrimBare(IEnumerable<T> values, Func<T, object> getItem) : base(values, getItem)
        {
        }

        protected override StringBuilder Build(StringBuilder builder, string item)
        {
            return base.Build(builder, item.Trim());
        }
    }

    public class CsvRfc4180<T> : CsvBase<T>
    {
        public CsvRfc4180(IEnumerable<T> values, Func<T, object> getItem) : base(values, getItem)
        {
        }

        protected override StringBuilder Build(StringBuilder builder, string item)
        {
            item = item.Replace("\"", "\"\"");
            return item.IndexOfAny(CsvConstants.CsvChars) >= 0
                ? builder.Append("\"").Append(item).Append("\"")
                : builder.Append(item);
        }
    }

    public sealed class CsvTrimRfc4180<T> : CsvRfc4180<T>
    {
        public CsvTrimRfc4180(IEnumerable<T> values, Func<T, object> getItem) : base(values, getItem)
        {
        }

        protected override StringBuilder Build(StringBuilder builder, string item)
        {
            return base.Build(builder, item.Trim());
        }
    }

    public class CsvAlwaysQuote<T> : CsvBare<T>
    {
        public CsvAlwaysQuote(IEnumerable<T> values, Func<T, object> getItem) : base(values, getItem)
        {
        }

        protected override StringBuilder Build(StringBuilder builder, string item)
        {
            return builder.Append("\"").Append(item.Replace("\"", "\"\"")).Append("\"");
        }
    }

    public sealed class CsvTrimAlwaysQuote<T> : CsvAlwaysQuote<T>
    {
        public CsvTrimAlwaysQuote(IEnumerable<T> values, Func<T, object> getItem) : base(values, getItem)
        {
        }

        protected override StringBuilder Build(StringBuilder builder, string item)
        {
            return base.Build(builder, item.Trim());
        }
    }

    public static class CsvExtensions
    {
        public static string ToCsv<T>(this IEnumerable<T> source, Func<T, object> getItem, Type csvProcessorType)
        {
            if ((source == null)
                || (getItem == null)
                || (csvProcessorType == null)
                || !csvProcessorType.IsSubclassOf(typeof(CsvBase<T>)))
            {
                return string.Empty;
            }

            return csvProcessorType
                .GetConstructor(new[] { source.GetType(), getItem.GetType() })
                ?.Invoke(new object[] { source, getItem })
                .ToString();
        }

        private static void Main()
        {
            var words = new[] { ",this", "   is   ", "a", "test", "Super, \"luxurious\" truck" };

            Console.WriteLine(words.ToCsv(word => word, typeof(CsvAlwaysQuote<string>)));
            Console.WriteLine(words.ToCsv(word => word, typeof(CsvRfc4180<string>)));
            Console.WriteLine(words.ToCsv(word => word, typeof(CsvBare<string>)));
            Console.WriteLine(words.ToCsv(word => word, typeof(CsvTrimAlwaysQuote<string>)));
            Console.WriteLine(words.ToCsv(word => word, typeof(CsvTrimRfc4180<string>)));
            Console.WriteLine(words.ToCsv(word => word, typeof(CsvTrimBare<string>)));
            Console.ReadLine();
        }
    }
}
\$\endgroup\$
  • \$\begingroup\$ Excellent. Have edited my post to reflect your suggestions. \$\endgroup\$ – dreza Jan 23 '12 at 21:43
  • \$\begingroup\$ I made an edit to my code. IndexOfAny() should be >= 0 instead of > 0. Whoops! \$\endgroup\$ – Jesse C. Slicer Jan 23 '12 at 22:22
  • \$\begingroup\$ There's a chance that I may not want to include quotations. Would you suggest just adding a boolean flag to the method parameter to handle this case. \$\endgroup\$ – dreza Jan 23 '12 at 22:34
  • \$\begingroup\$ Hm. Boolean flag would likely be the quickest, but it's not a very object-oriented solution. Let me noodle on that for a few and put something up. \$\endgroup\$ – Jesse C. Slicer Jan 23 '12 at 22:37
  • 1
    \$\begingroup\$ @JesseC.Slicer +1 for the comprehensive example! But I think there's one more thing to add for it to be complete: if the value enclosed in quotes contains a quote itself, then that quote has to be doubled. \$\endgroup\$ – Cristian Lupascu Jan 24 '12 at 15:14
3
\$\begingroup\$

Here is my suggested version, which you should take as coming from a fellow learner.

  1. ToCsv is doing too much. I would be inclined to keep it very simple and perform only its very narrow responsibility: properly transforming a sequence of strings to a properly formatted csv string. Let the caller transform its sequence of arbitrary objects into strings.

  2. String.Join exists in the BCL, which is exactly suited to this purpose.

  3. Properly encodes the quote character

private class Program
{
    private static void Main(string[] args)
    {
        int[] someInts = {1, 2, 3, 4, 10, 9, 8};
        string[] someStrings = {"one", "two", "seven", "eight"};
        string[] specialStrings = {"o'ne", "\"tw\"o", ",,three", "fo,ur", "five"};

        Console.WriteLine(someInts.Select(s => s.ToString()).ToCsv());
        Console.WriteLine(someStrings.ToCsv());
        Console.WriteLine(specialStrings.ToCsv());
    }
}

public static class CsvHelpers
{
    private static readonly char[] csvSpecialChars = new[] { ',', '"', ' ', '\n', '\r' };

    public static string ToCsv(this IEnumerable<string> source)
    {
        if (source == null)
        {
            return string.Empty;
        }

        var encodedStrings = from item in source
                     select EncodeCsvField(item);

        return string.Join(",", encodedStrings);
    }

    private static string EncodeCsvField(string input)
    {
        string encodedString = input.Replace("\"", "\"\"");
        if (input.IndexOfAny(csvSpecialChars) >= 0)
        {
            encodedString = "\"" + encodedString + "\"";
        }

        return encodedString;
    }
}
\$\endgroup\$
  • \$\begingroup\$ I sort of wanted to stay away from putting the dependancy on the string object itself in the IEnumerable hence the use of generics. However didn't really know about the Join operater. Thanks. \$\endgroup\$ – dreza Jan 24 '12 at 20:13
  • \$\begingroup\$ My reasoning was that you either provide the function to project from your object to string or just do the projection (possibly through LINQ so you could defer the execution) at the call site. Seemed simpler to just do the projection at the call site if needed so that the extension method has only one responsibility. Cheers. \$\endgroup\$ – WorkerThread Jan 24 '12 at 21:13
2
\$\begingroup\$

A couple of things:

  1. Although you are using a StringBuilder (which is good in this case), you're generating a string for each row of data. If you're intending to use this to create multiple rows of data, then it will not quite as efficient as it could be. Maybe this is no big deal, although that might depend on how much data you're processing.

  2. Although I don't feel it's a major concern, I don't like stripping off the trailing comma. I always like to avoid messing up my output in the first place, rather than having additional code at the end that fixes the problem. One thing to realize, though, is that your StringBuilder generates an entire string in memory to contain the row of data, but when you call TrimEnd, I believe that this will create yet another string containing the entire row of data. Not very efficient.

I imagine that in most cases, this might be used to output to a file, or perhaps some other kind of stream. Therefore, it would make more sense to me to rewrite this functionality to write to a stream.

I've provided an alternate implementation below. The core functionality is in the WriteAsCsv method for the TextWriter. There are a few overloads to make this functionality easy to use for someone who has access to a FileStream object, a StringBuilder object, or just the original IEnumerable object.

This approach uses an enumerator in order to avoid the issue with the trailing comma.

    static void WriteCsvValue(System.IO.TextWriter writer, string str)
    {
        if (str.IndexOfAny(csvChars) > 0)
        {
            writer.Write("\"");
            writer.Write(str); // TODO: perform any necessary escaping
            writer.Write("\"");
        }
        else
        {
            writer.Write(str);
        }
    }

    public static void WriteAsCsv<T>(this System.IO.TextWriter writer, IEnumerable<T> source, Func<T, object> getItem)
    {
        if ((source == null) || (getItem == null))
        {
            return;
        }

        var items = from item in source.Select(getItem)
                    where item != null
                    select item.ToString();

        using (var enumerator = items.GetEnumerator())
        {
            if (enumerator.MoveNext())
            {
                WriteCsvValue(writer, enumerator.Current);

                while (enumerator.MoveNext())
                {
                    writer.Write(", ");
                    WriteCsvValue(writer, enumerator.Current);
                }
            }
        }
    }

    public static void WriteAsCsv<T>(this System.IO.Stream stream, IEnumerable<T> source, Func<T, object> getItem)
    {
        if ((source == null) || (getItem == null))
        {
            return;
        }

        using (var writer = new System.IO.StreamWriter(stream))
        {
            writer.WriteAsCsv(source, getItem);
        }
    }

    public static void AppendAsCsv<T>(this StringBuilder builder, IEnumerable<T> source, Func<T, object> getItem)
    {
        if ((source == null) || (getItem == null))
        {
            return;
        }

        using (var writer = new System.IO.StringWriter(builder))
        {
            writer.WriteAsCsv(source, getItem);
        }
    }

    public static string ToCsv<T>(this IEnumerable<T> source, Func<T, object> getItem)
    {
        StringBuilder builder = new StringBuilder();
        builder.AppendAsCsv(source, getItem);
        return builder.ToString();
    }
  • A StreamWriter is a TextWriter that wraps a Stream object.
  • A StringWriter is a TextWriter that wraps a StringBuilder object.

The gist of what I've done here is to decompose your original approach into separate pieces, making it easier to consume in multiple different ways.

\$\endgroup\$
  • \$\begingroup\$ Although efficiency in the particular case I'm using it for isn't an issue as I'm not dealing with 100's of lines etc it's still something I didn't take into account. thanks. \$\endgroup\$ – dreza Jan 24 '12 at 20:15
  • \$\begingroup\$ @dreza if you don't care to use an enumerator (I know it feels clunky, at least until you get used to it), you could avoid having to strip off the trailing comma by prepending the comma: if(builder.Length>0)builder.Append(", "); \$\endgroup\$ – Dr. Wily's Apprentice Jan 24 '12 at 20:20
  • \$\begingroup\$ @dreza - See my answer on StackOverflow to a similar question. \$\endgroup\$ – Dr. Wily's Apprentice Jan 24 '12 at 20:54

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.