7
\$\begingroup\$

For a new project I'm going to need to be able to serialize random types to TSV or CSV files, so I write a class which can be used to serialize any object to a TSV, CSV or any other _SV file you can think of. (You could literally serialize objects to files with the letter "B" or the word "Rawr" as the column or row delimiter.)

It's pretty simple, it starts with a DelimitedColumnAttribute.

/// <summary>
/// Represents a column which can be used in a <see cref="DelimitedSerializer"/>.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public class DelimitedColumnAttribute : Attribute
{
    /// <summary>
    /// The name of the column.
    /// </summary>
    public string Name { get; set; }

    /// <summary>
    /// The order the column should appear in.
    /// </summary>
    public int Order { get; set; }
}

Then there's a serializer:

/// <summary>
/// Represents a serializer that will serialize arbitrary objects to files with specific row and column separators.
/// </summar>
public class DelimitedSerializer
{
    /// <summary>
    /// The string to be used to separate columns.
    /// </summary>
    public string ColumnDelimiter { get; set; }

    /// <summary>
    /// The string to be used to separate rows.
    /// </summary>
    public string RowDelimiter { get; set; }

    /// <summary>
    /// Serializes an object to a delimited file. Throws an exception if any of the property names, column names, or values contain either the <see cref="ColumnDelimiter"/> or the <see cref="RowDelimiter"/>.
    /// </summary>
    /// <typeparam name="T">The type of the object to serialize.</typeparam>
    /// <param name="items">A list of the items to serialize.</param>
    /// <returns>The serialized string.</returns>
    public string Serialize<T>(List<T> items)
    {
        if (string.IsNullOrEmpty(ColumnDelimiter))
        {
            throw new ArgumentException($"The property '{nameof(ColumnDelimiter)}' cannot be null or an empty string.");
        }

        if (string.IsNullOrEmpty(RowDelimiter))
        {
            throw new ArgumentException($"The property '{nameof(RowDelimiter)}' cannot be null or an empty string.");
        }

        var result = new ExtendedStringBuilder();

        var properties = typeof(T).GetProperties()
            .Where(x => Attribute.IsDefined(x, typeof(DelimitedColumnAttribute)))
            .OrderBy(x => ((DelimitedColumnAttribute)x.GetCustomAttributes(typeof(DelimitedColumnAttribute), true)[0]).Order)
            .ThenBy(x => ((DelimitedColumnAttribute)x.GetCustomAttributes(typeof(DelimitedColumnAttribute), true)[0]).Name)
            .ThenBy(x => x.Name);

        foreach (var property in properties)
        {
            var attribute = (DelimitedColumnAttribute)property.GetCustomAttributes(typeof(DelimitedColumnAttribute), true)[0];

            var name = attribute.Name ?? property.Name;

            if (name.Contains(ColumnDelimiter))
            {
                throw new ArgumentException($"The column name string '{name}' contains an invalid character: '{ColumnDelimiter}'.");
            }
            if (name.Contains(RowDelimiter))
            {
                throw new ArgumentException($"The column name string '{name}' contains an invalid character: '{RowDelimiter}'.");
            }

            if (result.Length > 0)
            {
                result += ColumnDelimiter;
            }

            result += name;
        }

        foreach (var item in items)
        {
            var row = new ExtendedStringBuilder();

            foreach (var property in properties)
            {
                var value = property.GetValue(item).ToString();

                if (value.Contains(ColumnDelimiter))
                {
                    throw new ArgumentException($"The property value string '{value}' contains an invalid character: '{ColumnDelimiter}'.");
                }
                if (value.Contains(RowDelimiter))
                {
                    throw new ArgumentException($"The property value string '{value}' contains an invalid character: '{RowDelimiter}'.");
                }

                if (row.Length > 0)
                {
                    row += ColumnDelimiter;
                }

                row += value;
            }

            result += RowDelimiter;
            result += row;
        }

        return result;
    }

    /// <summary>
    /// Returns an instance of the <see cref="DelimitedSerializer"/> setup for Tab-Separated Value files.
    /// </summary>
    public static readonly DelimitedSerializer TsvSerializer = new DelimitedSerializer { ColumnDelimiter = "\t", RowDelimiter = Environment.NewLine };

    /// <summary>
    /// Returns an instance of the <see cref="DelimitedSerializer"/> setup for Comma-Separated Value files.
    /// </summary>
    public static readonly DelimitedSerializer CsvSerializer = new DelimitedSerializer { ColumnDelimiter = ",", RowDelimiter = Environment.NewLine };

    /// <summary>
    /// Returns an instance of the <see cref="DelimitedSerializer"/> setup for Pipe-Separated Value files.
    /// </summary>
    public static readonly DelimitedSerializer PsvSerializer = new DelimitedSerializer { ColumnDelimiter = "|", RowDelimiter = Environment.NewLine };
}

And that's it! It's super easy to use, as all the tests show.

[TestClass]
public class CsvSerializerTests
{
    class Config
    {
        public string[] Names { get; set; } = { "Mark", "Ralph", "Shawn" };
        public int[] Positions { get; set; } = { 1, 0, 3 };
        public string InvalidName { get; set; } = "Mark,";
    }

    class TestObject1
    {
        [DelimitedColumn]
        public string Name { get; set; }
    }

    [TestMethod, TestCategory("Csv Serializer Tests")]
    public void SerializeOneProperty()
    {
        var expected = "Name";
        var input = new List<TestObject1>();
        var config = new Config();

        for (int i = 0; i < config.Names.Length; i++)
        {
            input.Add(new TestObject1 { Name = config.Names[i] });
            expected += Environment.NewLine + config.Names[i];
        }

        var csvSerializer = DelimitedSerializer.CsvSerializer;
        var actual = csvSerializer.Serialize(input);

        Assert.AreEqual(expected, actual);
    }

    class TestObject2
    {
        [DelimitedColumn(Name = "n")]
        public string Name { get; set; }
    }

    [TestMethod, TestCategory("Csv Serializer Tests")]
    public void SerializeOnePropertyWithName()
    {
        var expected = "n";
        var input = new List<TestObject2>();
        var config = new Config();

        for (int i = 0; i < config.Names.Length; i++)
        {
            input.Add(new TestObject2 { Name = config.Names[i] });
            expected += Environment.NewLine + config.Names[i];
        }

        var csvSerializer = DelimitedSerializer.CsvSerializer;
        var actual = csvSerializer.Serialize(input);

        Assert.AreEqual(expected, actual);
    }

    class TestObject3
    {
        [DelimitedColumn]
        public string Name { get; set; }

        [DelimitedColumn]
        public int Position { get; set; }
    }

    [TestMethod, TestCategory("Csv Serializer Tests")]
    public void SerializeTwoProperties()
    {
        var expected = "Name,Position";
        var input = new List<TestObject3>();
        var config = new Config();

        for (int i = 0; i < config.Names.Length; i++)
        {
            input.Add(new TestObject3 { Name = config.Names[i], Position = config.Positions[i] });
            expected += Environment.NewLine + config.Names[i] + "," + config.Positions[i];
        }

        var csvSerializer = DelimitedSerializer.CsvSerializer;
        var actual = csvSerializer.Serialize(input);

        Assert.AreEqual(expected, actual);
    }

    class TestObject4
    {
        [DelimitedColumn(Order = 1)]
        public string Name { get; set; }

        [DelimitedColumn(Order = 0)]
        public int Position { get; set; }
    }

    [TestMethod, TestCategory("Csv Serializer Tests")]
    public void SerializeTwoPropertiesWithOrder()
    {
        var expected = "Position,Name";
        var input = new List<TestObject4>();
        var config = new Config();

        for (int i = 0; i < config.Names.Length; i++)
        {
            input.Add(new TestObject4 { Name = config.Names[i], Position = config.Positions[i] });
            expected += Environment.NewLine + config.Positions[i] + "," + config.Names[i];
        }

        var csvSerializer = DelimitedSerializer.CsvSerializer;
        var actual = csvSerializer.Serialize(input);

        Assert.AreEqual(expected, actual);
    }

    class TestObject5
    {
        [DelimitedColumn(Name = "n")]
        public string Name { get; set; }

        [DelimitedColumn(Name = "p")]
        public int Position { get; set; }
    }

    [TestMethod, TestCategory("Csv Serializer Tests")]
    public void SerializeTwoPropertiesWithName()
    {
        var expected = "n,p";
        var input = new List<TestObject5>();
        var config = new Config();

        for (int i = 0; i < config.Names.Length; i++)
        {
            input.Add(new TestObject5 { Name = config.Names[i], Position = config.Positions[i] });
            expected += Environment.NewLine + config.Names[i] + "," + config.Positions[i];
        }

        var csvSerializer = DelimitedSerializer.CsvSerializer;
        var actual = csvSerializer.Serialize(input);

        Assert.AreEqual(expected, actual);
    }

    class TestObject6
    {
        [DelimitedColumn(Name = "n", Order = 1)]
        public string Name { get; set; }

        [DelimitedColumn(Name = "p", Order = 0)]
        public int Position { get; set; }
    }

    [TestMethod, TestCategory("Csv Serializer Tests")]
    public void SerializeTwoPropertiesWithNameAndOrder()
    {
        var expected = "p,n";
        var input = new List<TestObject6>();
        var config = new Config();

        for (int i = 0; i < config.Names.Length; i++)
        {
            input.Add(new TestObject6 { Name = config.Names[i], Position = config.Positions[i] });
            expected += Environment.NewLine + config.Positions[i] + "," + config.Names[i];
        }

        var csvSerializer = DelimitedSerializer.CsvSerializer;
        var actual = csvSerializer.Serialize(input);

        Assert.AreEqual(expected, actual);
    }

    [TestMethod, TestCategory("Csv Serializer Tests")]
    [ExpectedException(typeof(ArgumentException))]
    public void SerializeWithInvalidValue()
    {
        string expected = null;
        var input = new List<TestObject1>();
        var config = new Config();

        for (int i = 0; i < config.Names.Length; i++)
        {
            if (i == 0)
            {
                input.Add(new TestObject1 { Name = config.InvalidName });
            }
            else
            {
                input.Add(new TestObject1 { Name = config.Names[i] });
            }
        }

        var csvSerializer = DelimitedSerializer.CsvSerializer;
        var actual = csvSerializer.Serialize(input);

        Assert.AreEqual(expected, actual);
    }
}

You can literally do:

var serializedString = DelimitedSerializer.CsvSerializer.Serialize(input);
var otherSerializedString = new DelimitedSerializer { ColumnDelimiter = "B", RowDelimiter = "Rawr" }.Serialize(input);

I created a local variable in each test for readability.

Do note: it does not yet support multi-level properties. (It will use the .ToString() method on the property to serialize it.)

\$\endgroup\$
  • \$\begingroup\$ I'd add a null to that Config.Names collection if I were you - It looks at first glance like that would give you a nice null reference exception. \$\endgroup\$ – RobH May 18 '16 at 9:15
10
\$\begingroup\$
  • readonly will not make members of your static serializers readonly. While you cannot reassign another serializer to replace it, its members can still be modified. Since you have access to C# you can use a get-only property to return a new instance :

    public static DelimitedSerializer TsvSerializer => new DelimitedSerializer { ColumnDelimiter = "\t", RowDelimiter = Environment.NewLine };
    
  • properties can be optimized as such :

    var properties = typeof(T).GetProperties()
        .Select((PropertyInfo p) => new
        {
            // caching the result, so you don't have to look it up repeatly
            Attribute = p.GetCustomAttribute<DelimitedColumnAttribute>(),
            Info = p,
        })
        .Where(x => x.Attribute != null)
        // ?. is not needed here, but it makes testing easier with anonymous class
        .OrderBy(x => x.Attribute?.Order)
        .OrderBy(x => x.Attribute?.Name)
        .OrderBy(x => x.Info.Name)
        // properties are used multiple times, so you want to avoid deferred execution here
        .ToList();
    
  • properties is never materialized. LINQ use deferred execution, meaning that the query is never done ahead of time, but only when being iterated. This means that everytime you loops throught properties via foreach, the above query is execute. Once for header, and once for every single row. So, materialize it with ToList().

  • What happens if a column is null? NullReferenceException!

    // NullReferenceException
    var value = property.GetValue(item).ToString();
    var value = property.Info.GetValue(item).ToString(); // (changed in previous bullet)
    
    // if the property is null, value will be null as well
    var value = property.Info.GetValue(item)?.ToString();
    
    // this also need to be fixed
    if (value?.Contains(ColumnDelimiter) == true)
    
  • The argument-guards seems a little repetitive, we can put them into a function :

    Action<string, string> checkForInvalidCharacters = (name, value) =>
    {
        if (value == null) return;
    
        if (value.Contains(ColumnDelimiter))
        {
            throw new ArgumentException($"The {name} string '{value}' contains an invalid character: '{ColumnDelimiter}'.");
        }
        if (value.Contains(RowDelimiter))
        {
            throw new ArgumentException($"The {name} string '{value}' contains an invalid character: '{RowDelimiter}'.");
        }
    };
    

    So, we can use it like :

    foreach (var property in properties)
    {
        var name = property.Attribute?.Name ?? property.Info.Name;
        checkForInvalidCharacters("column name", name);
    
        // ...
    }
    
    foreach (var item in items)
    {
        var row = new ExtendedStringBuilder();
    
        foreach (var property in properties)
        {
            var value = property.Info.GetValue(item)?.ToString();
            checkForInvalidCharacters("property value", value);
    
            // ...
        }
    
        //...
    }
    
  • Using row.Length > 0 to determine adding a column delimiter is wrong. If the first few properties are null, you will have trouble deserializing it later, as the column will be left shift by them. Take this example :

    // Yeah... I modified the function a bit to make testing easier...
    /*  //.Where(x => x.Attribute != null)
        .OrderBy(x => x.Attribute?.Order)
        .OrderBy(x => x.Attribute?.Name) */
    DelimitedSerializer.CsvSerializer
        .Serialize(new[]
        {
            new { A = "QQ", B = "qwe", C = 1 },
            new { A = (string)null, B = (string)null, C = 2 },
            new { A = "asd", B = "cc", C = 3 }
        })
    

    Expected output :

    A,B,C
    QQ,qwe,1
    ,,2
    asd,cc,3
    

    Actual output :

    A,B,C
    QQ,qwe,1
    2
    asd,cc,3
    

    You can use a small trick here, knowing that (string)null + (string)null = string.Empty:

    string row = null;
    
    foreach (var property in properties)
    {
        var value = property.Info.GetValue(item)?.ToString();
        checkForInvalidCharacters("property value", value);
    
        if (row != null)
            row += ColumnDelimiter;
    
        row += value;
    }
    

    Or, you can use string.Join:

    result += string.Join(ColumnDelimiter, properties
        .Select(x =>
        {
            var name = x.Attribute?.Name ?? x.Info.Name;
            checkForInvalidCharacters("column name", name);
    
            return name;
        }));
    

Full code :

/// <summary>
/// Represents a serializer that will serialize arbitrary objects to files with specific row and column separators.
/// </summar>
public class DelimitedSerializer
{
    /// <summary>
    /// The string to be used to separate columns.
    /// </summary>
    public string ColumnDelimiter { get; set; }

    /// <summary>
    /// The string to be used to separate rows.
    /// </summary>
    public string RowDelimiter { get; set; }

    /// <summary>
    /// Serializes an object to a delimited file. Throws an exception if any of the property names, column names, or values contain either the <see cref="ColumnDelimiter"/> or the <see cref="RowDelimiter"/>.
    /// </summary>
    /// <typeparam name="T">The type of the object to serialize.</typeparam>
    /// <param name="items">A list of the items to serialize.</param>
    /// <returns>The serialized string.</returns>
    public string Serialize<T>(List<T> items)
    {
        if (string.IsNullOrEmpty(ColumnDelimiter))
        {
            throw new ArgumentException($"The property '{nameof(ColumnDelimiter)}' cannot be null or an empty string.");
        }
        if (string.IsNullOrEmpty(RowDelimiter))
        {
            throw new ArgumentException($"The property '{nameof(RowDelimiter)}' cannot be null or an empty string.");
        }

        var properties = typeof(T).GetProperties()
            .Select((PropertyInfo p) => new
            {
                // caching the result, so you don't have to look it up repeatly
                Attribute = p.GetCustomAttribute<DelimitedColumnAttribute>(),
                Info = p,
            })
            .Where(x => x.Attribute != null)
            .OrderBy(x => x.Attribute?.Order)
            // ?. is not needed here, but it makes testing easier with anonymous class
            .OrderBy(x => x.Attribute?.Name)
            .OrderBy(x => x.Info.Name)
            // properties are used multiple times, so you want to avoid deferred execution here
            .ToList();

        Action<string, string> checkForInvalidCharacters = (name, value) =>
        {
            if (value == null) return;

            if (value.Contains(ColumnDelimiter))
            {
                throw new ArgumentException($"The {name} string '{value}' contains an invalid character: '{ColumnDelimiter}'.");
            }
            if (value.Contains(RowDelimiter))
            {
                throw new ArgumentException($"The {name} string '{value}' contains an invalid character: '{RowDelimiter}'.");
            }
        };

        var result = new ExtendedStringBuilder();
        result += string.Join(ColumnDelimiter, properties
            .Select(x =>
            {
                var name = x.Attribute?.Name ?? x.Info.Name;
                checkForInvalidCharacters("column name", name);

                return name;
            }));

        foreach (var item in items)
        {
            string row = null;

            foreach (var property in properties)
            {
                var value = property.Info.GetValue(item)?.ToString();
                checkForInvalidCharacters("property value", value);

                if (row != null)
                    row += ColumnDelimiter;

                row += value;
            }

            result += RowDelimiter;
            result += row;
        }

        return result;
    }

    /// <summary>
    /// Returns an instance of the <see cref="DelimitedSerializer"/> setup for Tab-Separated Value files.
    /// </summary>
    public static DelimitedSerializer TsvSerializer => new DelimitedSerializer { ColumnDelimiter = "\t", RowDelimiter = Environment.NewLine };

    /// <summary>
    /// Returns an instance of the <see cref="DelimitedSerializer"/> setup for Comma-Separated Value files.
    /// </summary>
    public static DelimitedSerializer CsvSerializer => new DelimitedSerializer { ColumnDelimiter = ",", RowDelimiter = Environment.NewLine };

    /// <summary>
    /// Returns an instance of the <see cref="DelimitedSerializer"/> setup for Pipe-Separated Value files.
    /// </summary>
    public static DelimitedSerializer PsvSerializer => new DelimitedSerializer { ColumnDelimiter = "|", RowDelimiter = Environment.NewLine };
}
|improve this answer|||||
\$\endgroup\$

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.