Search code examples
c#csvhelper

Adding custom field attributes to CsvHelper


I'm using the excellent CsvHelper library (currently v12.2.2) for CSV file generation, and I'm trying to add my own custom attributes to specify special formatting directly in the class.

The record I'm writing looks like this (though with ~200 numeric fields as required by the integration):

class PayrollRecord {
    public int EmployeeID { get; set; }

    public decimal RegularPay   { get; set; }
    public decimal RegularHours { get; set; }
    public decimal RegularRate  { get; set; }

    public decimal OvertimePay   { get; set; }
    public decimal OvertimeHours { get; set; }
    public decimal OvertimeRate  { get; set; }

    // many many more
}

and I need to insure that Pay is written with 2 decimal places, hours with 3, and pay rate to 4; the integration requires this.

What works now

I created a decimal converter that I attach to the classmap:

using CsvHelper;
using CsvHelper.TypeConversion;

    // convert decimal to the given number of places, and zeros are
    // emitted as blank.
    public abstract class MyDecimalConverter : DefaultTypeConverter
    {
        protected virtual string getFormat() => "";

        public override string ConvertToString(object value, IWriterRow row, MemberMapData memberMapData)
        {
            if (value is decimal d)
                return (d == 0) ? string.Empty : string.Format(getFormat(), d);

            return base.ConvertToString(value, row, memberMapData);
        }
    }

    public class DollarsConverter : MyDecimalConverter
    {
        protected override string getFormat() => "{0:0.00}";  // 2 decimal places
    }
    public class HoursConverter : MyDecimalConverter
    {
        protected override string getFormat() => "{0:0.000}"; // 3 decimal places
    }
    public class PayRateConverter : MyDecimalConverter
    {
        protected override string getFormat() => "{0:0.0000}"; // 4 decimal places
    }

and then I apply these when I create the writer:

    CsvWriter Writer = new CsvWriter( /* stuff */ );

    var classMap = new DefaultClassMap<PayrollRecord>();
    classMap.AutoMap();

    classMap.Map(m => m.RegularPay).TypeConverter<DollarsConverter>();
    classMap.Map(m => m.RegularHours).TypeConverter<HoursConverter>();
    classMap.Map(m => m.RegularRate).TypeConverter<PayRateConverter>();

    classMap.Map(m => m.OvertimePay).TypeConverter<DollarsConverter>();
    classMap.Map(m => m.OvertimeHours).TypeConverter<HoursConverter>();
    classMap.Map(m => m.OvertimeRate).TypeConverter<PayRateConverter>();

    // many more

    Writer.Configuration.RegisterClassMap(classMap);
    ...

This does everything correctly, but it doesn't scale well: with ~200 fields it's going to be a challenge to keep the mapping stuff in sync with the actual field definitions, and I very much expect the record structure to change until we nail the integration down.

Side note: it's possible to annotate each field with the [Format("..")] attribute, but to get the zero suppression I'm looking for, the format string is a three-part ugly thing that looks super easy to get wrong and very tedious to change.

What I'd like

I'd like to create my own custom attributes that I can apply to each field member to specify this, so it would look something like:

// custom attribute
public enum NumericType { Dollars, Hours, PayRate };

public class DecimalFormatAttribute : System.Attribute
{
    public NumericType Type { get; }

    public DecimalFormatAttribute(NumericType t) => Type = t;
}

// then later
class PayrollRecord {

   [DecimalFormat(NumericType.Dollars)] public decimal RegularPay { get; set; }
   [DecimalFormat(NumericType.Hours)]   public decimal RegularHours { get; set; }
   [DecimalFormat(NumericType.PayRate)] public decimal RegularRate { get; set; }

   // etc.
}

Where I'm stuck is how to glue my custom attribute to the class map, and I think the code would look something like this:

    var classMap = new DefaultClassMap<PayrollRecord>();
    classMap.AutoMap();

    foreach (var prop in typeof(PayrollRecord).GetProperties())
    {
        var myattr = (DecimalFormatAttribute)prop.GetCustomAttribute(typeof(DecimalFormatAttribute));

        if (myattr != null)
        {
            // prop.Name is the base name of the field
            // WHAT GOES HERE?
        }
    }

I've been noodling around with this for a few hours and can't find how to get this done.


Solution

  • Rather than your own custom attribute, you could apply CsvHelper.Configuration.Attributes.TypeConverterAttribute to your model to specify an appropriate converter:

    class PayrollRecord 
    {
        public int EmployeeID { get; set; }
    
        [TypeConverter(typeof(DollarsConverter))]
        public decimal RegularPay   { get; set; }
        [TypeConverter(typeof(HoursConverter))]
        public decimal RegularHours { get; set; }
        [TypeConverter(typeof(PayRateConverter))]
        public decimal RegularRate  { get; set; }
    
        [TypeConverter(typeof(DollarsConverter))]
        public decimal OvertimePay   { get; set; }
        [TypeConverter(typeof(HoursConverter))]
        public decimal OvertimeHours { get; set; }
        [TypeConverter(typeof(PayRateConverter))]
        public decimal OvertimeRate  { get; set; }
    
        // many many more
    }
    

    Demo fiddle #1 here.

    Alternatively, if you don't want to apply CsvHelper attributes to your data model, you could use a custom attribute as follows:

    public static class NumericType
    {
        public const string Dollars = "{0:0.00}";
        public const string Hours = "{0:0.000}";
        public const string PayRate = "{0:0.0000}";
    }
    
    [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
    public class DecimalFormatAttribute : System.Attribute
    {
        public string Format { get; } = "{0}";
    
        public DecimalFormatAttribute(string format) => Format = format;
    }
    
    public class MyDecimalConverter : DefaultTypeConverter
    {
        public string Format { get; } = "{0}";
        
        public MyDecimalConverter(string format) => Format = format;
    
        public override string ConvertToString(object value, IWriterRow row, MemberMapData memberMapData)
        {
            if (value is decimal d)
                return (d == 0) ? string.Empty : string.Format(Format, d);
    
            return base.ConvertToString(value, row, memberMapData);
        }
    }
    
    public static class CsvHelpExtensions
    {
        public static void RegisterDecimalFormats<T>(this ClassMap<T> map)
        {
            foreach (var property in typeof(T).GetProperties())
            {
                var attr = property.GetCustomAttribute<DecimalFormatAttribute>();
                if (attr != null)
                    map.Map(typeof(T), property, true).TypeConverter(new MyDecimalConverter(attr.Format));
            }
        }
    }
    

    Which can be applied as follows:

    class PayrollRecord 
    {
        public int EmployeeID { get; set; }
    
        [DecimalFormat(NumericType.Dollars)]
        public decimal RegularPay   { get; set; }
        [DecimalFormat(NumericType.Hours)]
        public decimal RegularHours { get; set; }
        [DecimalFormat(NumericType.PayRate)]
        public decimal RegularRate  { get; set; }
    
        [DecimalFormat(NumericType.Dollars)]
        public decimal OvertimePay   { get; set; }
        [DecimalFormat(NumericType.Hours)]
        public decimal OvertimeHours { get; set; }
        [DecimalFormat(NumericType.PayRate)]
        public decimal OvertimeRate  { get; set; }
    
        // many many more
    }
    

    And used as follows:

    var classMap = new DefaultClassMap<PayrollRecord>();
    classMap.AutoMap(); // Do this before RegisterDecimalFormats
    classMap.RegisterDecimalFormats();
    

    Notes:

    • Rather than an enum for decimal formats, I used a series of const string formats for simplicity.

    • The attribute is currently only implemented for properties but could be extended to fields.

    • The code may need to be tweaked to properly handle inheritance hierarchies.

    Lightly tested demo fiddle #2 here.

    As a final alternative, you wrote Side note: it's possible to annotate each field with the [Format("..")] attribute, but to get the zero suppression I'm looking for, the format string is a three-part ugly thing that looks super easy to get wrong and very tedious to change.

    In such a situation, a static class with a fixed set of public const string formats as shown above can be used to simplify the code and avoid duplicated format strings.