4
\$\begingroup\$

This is a follow up to my previous question: Serializing objects to delimited files

I've added some feature enhancements, and based on suggestions from rolfl in chat, I've fixed up a couple inconsistencies with the serializer.

First, if you don't mark any properties with DelimitedColumnAttribute, I added a DelimitedIgnoreAttribute which will blacklist columns instead. For objects with no properties marked with either, all properties are serialized instead, with the following exception:

No collection properties (except System.String) are serialized, period.

You can also replace invalid values in names/fields (values that have the RowDelimiter or ColumnDelimiter in them) with whatever you specify.

You can choose whether or not to include the header row.

You can choose to quote values/names. If you choose to, all values/names are quoted.

You can choose how double-quotes are escaped (necessary for quoted values/names).

DelimitedSerializer.cs:

/// <summary>
/// Represents a serializer that will serialize arbitrary objects to files with specific row and column separators.
/// </summary>
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>
    /// If not null, then sequences in values and names which are identical to the <see cref="ColumnDelimiter"/> will be replaced with this value.
    /// </summary>
    public string InvalidColumnReplace { get; set; }

    /// <summary>
    /// If not null, then sequences in values and names which are identical to the <see cref="RowDelimiter"/> will be replaced with this value.
    /// </summary>
    public string InvalidRowReplace { get; set; }

    /// <summary>
    /// If true, a trailing <see cref="ColumnDelimiter"/> will be included on each line. (Some legacy systems require this.)
    /// </summary>
    public bool IncludeTrailingDelimiter { get; set; }

    /// <summary>
    /// If true, an empty row will be included at the end of the response. (Some legacy systems require this.)
    /// </summary>
    public bool IncludeEmptyRow { get; set; }

    /// <summary>
    /// If true, then all values and columns will be quoted in double-quotes.
    /// </summary>
    public bool QuoteValues { get; set; }

    /// <summary>
    /// If not null, then double quotes appearing inside a value will be escaped with this value.
    /// </summary>
    public string DoubleQuoteEscape { get; set; }

    /// <summary>
    /// If true, then a header row will be output.
    /// </summary>
    public bool IncludeHeader { 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()
            .Select(p => new
            {
                Attribute = p.GetCustomAttribute<DelimitedColumnAttribute>(),
                Info = p
            })
            .Where(x => x.Attribute != null)
            .OrderBy(x => x.Attribute.Order)
            .ThenBy(x => x.Attribute.Name)
            .ThenBy(x => x.Info.Name)
            .ToList();

        if (properties.Count == 0)
        {
            properties = typeof(T).GetProperties()
                .Where(x => x.GetCustomAttribute<DelimitedIgnoreAttribute>() == null)
                .Select(p => new
                {
                    Attribute = new DelimitedColumnAttribute { Name = p.Name },
                    Info = p
                })
                .Where(x => x.Attribute != null)
                .OrderBy(x => x.Attribute.Order)
                .ThenBy(x => x.Attribute.Name)
                .ThenBy(x => x.Info.Name)
                .ToList();
        }

        Action<string, string, string> validateCharacters = (string name, string checkFor, string humanLocation) =>
        {
            if (name.Contains(checkFor))
            {
                throw new ArgumentException($"The {humanLocation} string '{name}' contains an invalid character: '{checkFor}'.");
            }
        };

        var columnLine = new ExtendedStringBuilder();

        foreach (var property in properties)
        {
            if (property.Info.PropertyType.IsArray || (property.Info.PropertyType != typeof(string) && property.Info.PropertyType.GetInterface(typeof(IEnumerable<>).FullName) != null))
            {
                continue;
            }

            var name = property.Attribute?.Name ?? property.Info.Name;

            if (InvalidColumnReplace != null)
            {
                name = name.Replace(ColumnDelimiter, InvalidColumnReplace);
            }
            if (InvalidRowReplace != null)
            {
                name = name.Replace(RowDelimiter, InvalidRowReplace);
            }
            if (DoubleQuoteEscape != null)
            {
                name = name.Replace("\"", DoubleQuoteEscape);
            }

            validateCharacters(name, ColumnDelimiter, "column name");
            validateCharacters(name, RowDelimiter, "column name");

            if (columnLine.HasBeenAppended)
            {
                columnLine += ColumnDelimiter;
            }

            if (QuoteValues)
            {
                columnLine += "\"";
            }
            columnLine += name;
            if (QuoteValues)
            {
                columnLine += "\"";
            }
        }

        if (IncludeTrailingDelimiter)
        {
            columnLine += ColumnDelimiter;
        }

        if (IncludeHeader)
        {
            result += columnLine;
        }

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

            foreach (var property in properties)
            {
                if (property.Info.PropertyType.IsArray || (property.Info.PropertyType != typeof(string) && property.Info.PropertyType.GetInterface(typeof(IEnumerable<>).FullName) != null))
                {
                    continue;
                }

                var value = property.Info.GetValue(item)?.ToString();

                if (property.Info.PropertyType == typeof(DateTime) || property.Info.PropertyType == typeof(DateTime?))
                {
                    value = ((DateTime?)property.Info.GetValue(item))?.ToString("u");
                }

                if (value != null)
                {
                    if (InvalidColumnReplace != null)
                    {
                        value = value.Replace(ColumnDelimiter, InvalidColumnReplace);
                    }
                    if (InvalidRowReplace != null)
                    {
                        value = value.Replace(RowDelimiter, InvalidRowReplace);
                    }
                    if (DoubleQuoteEscape != null)
                    {
                        value = value.Replace("\"", DoubleQuoteEscape);
                    }

                    validateCharacters(value, ColumnDelimiter, "property value");
                    validateCharacters(value, RowDelimiter, "property value");
                }

                if (row.HasBeenAppended)
                {
                    row += ColumnDelimiter;
                }

                if (QuoteValues)
                {
                    row += "\"";
                }
                row += value;
                if (QuoteValues)
                {
                    row += "\"";
                }
            }

            if (IncludeTrailingDelimiter)
            {
                row += ColumnDelimiter;
            }

            if (result.HasBeenAppended)
            {
                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 = "\r\n",
        InvalidColumnReplace = "\\t",
        IncludeHeader = true
    };

    /// <summary>
    /// Returns an instance of the <see cref="DelimitedSerializer"/> setup for Comma-Separated Value files.
    /// </summary>
    public static DelimitedSerializer CsvSerializer => new DelimitedSerializer
    {
        ColumnDelimiter = ",",
        RowDelimiter = "\r\n",
        InvalidColumnReplace = "\\u002C",
        IncludeHeader = true
    };

    /// <summary>
    /// Returns an instance of the <see cref="DelimitedSerializer"/> setup for Pipe-Separated Value files.
    /// </summary>
    public static DelimitedSerializer PsvSerializer => new DelimitedSerializer
    {
        ColumnDelimiter = "|",
        RowDelimiter = "\r\n",
        InvalidColumnReplace = "\\u007C",
        IncludeHeader = true
    };

    /// <summary>
    /// Returns an instance of the <see cref="DelimitedSerializer"/> from the RFC 4180 specification. See: https://tools.ietf.org/html/rfc4180
    /// </summary>
    public static DelimitedSerializer Rfc4180Serializer => new DelimitedSerializer
    {
        ColumnDelimiter = ",",
        RowDelimiter = "\r\n",
        IncludeHeader = true,
        IncludeTrailingDelimiter = true,
        QuoteValues = true,
        DoubleQuoteEscape = "\"\""
    };
}

DelimitedColumnAttribute.cs:

/// <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; }
}

DelimitedIgnoreAttribute.cs:

[AttributeUsage(AttributeTargets.Property)]
public class DelimitedIgnoreAttribute : Attribute
{
}
\$\endgroup\$
4
  • \$\begingroup\$ Could you also post an example how to use this and maybe some results? \$\endgroup\$
    – t3chb0t
    Aug 29, 2016 at 19:09
  • \$\begingroup\$ @t3chb0t Yeah, when I get home from work I will. \$\endgroup\$ Aug 29, 2016 at 19:11
  • \$\begingroup\$ ok, and maybe the ExtendedStringBuilder too ;-] \$\endgroup\$
    – t3chb0t
    Aug 29, 2016 at 19:12
  • \$\begingroup\$ @t3chb0t That's in a different question on here. \$\endgroup\$ Aug 29, 2016 at 19:23

1 Answer 1

2
\$\begingroup\$

To me the Serialize method is too big. I'd split it in three parts.

The first part would be the reflection stuff where you read and sort the properties - this could be a new class like ElementReflector or maybe an extension.

The second part (maybe a method) would be the first foreach that I cannot figure out what it does.

The third part (maybe a method too) would be the second foreach that does something but I'm not sure what.

Putting them in methods would give them a meaning without you having to explain each time what they are good for.

public string Serialize<T>(List<T> items)
{
    ... prepare sort etc properties

    var result = new StringBuilder()
        .Append(CreateHeader(properties))
        .Append(SerializeData(items, properties));

    return result.ToString();
}

ifs

if (property.Info.PropertyType.IsArray || (property.Info.PropertyType != typeof(string) && property.Info.PropertyType.GetInterface(typeof(IEnumerable<>).FullName) != null))

You could use a variable for that to tell what this condition is for. Or even create an extension for it.

The same applies for many other ifs where the intentions is not clear.

Besides you use the same condition twice (in both loops) so an extenstion would definitely help.


ColumnDelimiter & RowDelimiter

If these two properties cannot be null you should requrie them via the constructor instead of checking them in the Serilize method.

Also if the user can change them later then you should check the values in the property setter instead of throwing exeptions later and causing astonishment why isn't this working.


DelimitedColumnAttribute

It is possible to create an invalid attribute because there is no construtor that enforces the required values. I guess the name is required if the attribute is specified.


properties

properties = typeof(T).GetProperties()
  .Where(x => x.GetCustomAttribute<DelimitedIgnoreAttribute>() == null)
  .Select(p => new
  {
      Attribute = new DelimitedColumnAttribute { Name = p.Name },
      Info = p
  })
  .Where(x => x.Attribute != null)
  .OrderBy(x => x.Attribute.Order)
  .ThenBy(x => x.Attribute.Name)
  .ThenBy(x => x.Info.Name)
  .ToList();

You are always adding the attribute so this .Where(x => x.Attribute != null) isn't necessary.

As the Attribute.Name is equal p.Name either the .ThenBy(x => x.Attribute.Name) or .ThenBy(x => x.Info.Name) can be removed.

\$\endgroup\$
2
  • \$\begingroup\$ The first foreach builds the header, the second builds all the rows. \$\endgroup\$ Aug 29, 2016 at 19:31
  • \$\begingroup\$ There are no required values in DelimitedColumnAttribute. It can be left empty. \$\endgroup\$ Aug 30, 2016 at 5:57

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

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