Thursday, July 16, 2015

Use C# attributes to sanity check instances of objects and not just dress up types with metadata.

It's taken me a while to understand why I should really care about attributes in C#. All of the examples I have typically seen show an attribute like so...

using System;
namespace Atty.Core
{
   [AttributeUsage(AttributeTargets.Property)]
   public class MinimumLengthAttribute : Attribute
   {
      public int Requirement { get; private set; }
      public MinimumLengthAttribute(int requirement)
      {
         Requirement = requirement;
      }
   }
}

 
 

...dressing up a class like so:

namespace Atty.Core
{
   public class Person
   {
      [MinimumLength(5)]
      public string Name { get; set; }
      
      [MinimumLength(4)]
      public string UsStateOfResidence { get; set; }
      
      public string Mantra { get; set; }
      
      [MinimumLength(3)]
      public string FavoriteColor { get; set; }
   }
}

 
 

...and then the type being inspected by reflection as suggested here to fish the metadata back out, but... who cares? That really isn't that useful is it? The decorations we are applying to the type tell us that the Name of a Person should be at least five characters long, but that doesn't help us as there is no way to get at the attribute save for with reflection and in doing so we are only having at the type and not instances of the type. Basically we are suggesting that everything growing in a forest should stand at least so tall while measuring none of the trees making an ignorable and ignored law of no consequence.

What do we do about this? There is a solution, but it's not intuitive at all. We need to basically compare the forest to its trees to make sense of sanity checking. Given that MinimumLengthAttribute may decorate getsetters, we need to hand a type (and that could be any type, but not necessarily a Person for a Person-specific sanity checker doesn't benefit from a pattern that uses slap-me-on-at-any-class attributes) into a static method that will make a dictionary of all getsetters decorated by MinimumLengthAttribute and the minimum lengths they propose like so:

using System;
using System.Collections.Generic;
using System.Reflection;
namespace Atty.Core
{
   public static class SanityChecker
   {
      public static void Check(object perishable)
      {
         Type type = perishable.GetType();
         PropertyInfo[] propertyInfo = type.GetProperties();
         Dictionary<string, int> metadata = new Dictionary<string, int>();
         int counter = 0;
         while (counter < propertyInfo.Length)
         {
            MinimumLengthAttribute minimumLengthAttribute =
                  (MinimumLengthAttribute)Attribute.GetCustomAttribute(propertyInfo[counter],
                  typeof(MinimumLengthAttribute));
            if (minimumLengthAttribute != null)
            {
               metadata.Add(propertyInfo[counter].Name,
                     minimumLengthAttribute.Requirement);
            }
            counter++;
         }
         foreach (KeyValuePair<string, int> metadatum in metadata)
         {
            object setting = type.GetProperty(metadatum.Key).GetValue(perishable);
            if (metadatum.Value > 0)
            {
               if (setting == null || ((string)setting).Length < metadatum.Value)
               {
                  throw new Exception("The " + metadatum.Key + " for the " + type.Name + "
                        is inappropriately less than " + metadatum.Value + " characters.");
               }
            }
         }
      }
   }
}

 
 

Alright, as also suggested above, once we have fished a dictionary of rules out of a type with reflection, we are no longer forced to focus on the forest exclusively and we may now move beyond (the few things we may do with the type standalone) to inspect the trees. All that is left is to compare the dictionary to the class instances and throw exceptions when a rule is broken. This allows us to use attributes to define sanity checks and while I've always seen this sort of stuff in applications, it was never anything I had authored myself or really understood the magic of. I get it now. The following tests will all pass given the three classes above:

using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Atty.Core.Tests
{
   [TestClass]
   public class Tests
   {
      [TestMethod]
      public void happy_pass_should_pass_happily()
      {
         Exception exception = null;
         Person person = new Person()
         {
            Name = "Ty Ng",
            UsStateOfResidence = "Iowa",
            Mantra = "It's OK to cry.",
            FavoriteColor = "Red"
         };
         try
         {
            SanityChecker.Check(person);
         }
         catch (Exception explosion)
         {
            exception = explosion;
         }
         Assert.AreEqual(exception, null);
      }
      
      [TestMethod]
      public void lack_of_a_name_will_throw_an_exception()
      {
         Exception exception = null;
         Person person = new Person()
         {
            UsStateOfResidence = "Iowa",
            Mantra = "It's OK to cry.",
            FavoriteColor = "Red"
         };
         try
         {
            SanityChecker.Check(person);
         }
         catch (Exception explosion)
         {
            exception = explosion;
         }
         Assert.AreNotEqual(exception, null);
         Assert.AreEqual(exception.Message, "The Name for the Person is inappropriately
               less than 5 characters.");
      }
      
      [TestMethod]
      public void state_abbreviations_for_state_names_are_unacceptable()
      {
         Exception exception = null;
         Person person = new Person()
         {
            Name = "Ty Ng",
            UsStateOfResidence = "IA",
            Mantra = "It's OK to cry.",
            FavoriteColor = "Red"
         };
         try
         {
            SanityChecker.Check(person);
         }
         catch (Exception explosion)
         {
            exception = explosion;
         }
         Assert.AreNotEqual(exception, null);
         Assert.AreEqual(exception.Message, "The UsStateOfResidence for the Person is
               inappropriately less than 4 characters.");
      }
      
      [TestMethod]
      public void missing_mantra_should_cause_no_problems()
      {
         Exception exception = null;
         Person person = new Person()
         {
            Name = "Ty Ng",
            UsStateOfResidence = "Iowa",
            FavoriteColor = "Red"
         };
         try
         {
            SanityChecker.Check(person);
         }
         catch (Exception explosion)
         {
            exception = explosion;
         }
         Assert.AreEqual(exception, null);
      }
      
      [TestMethod]
      public void oy_is_not_a_color()
      {
         Exception exception = null;
         Person person = new Person()
         {
            Name = "Ty Ng",
            UsStateOfResidence = "Iowa",
            Mantra = "It's OK to cry.",
            FavoriteColor = "oy"
         };
         try
         {
            SanityChecker.Check(person);
         }
         catch (Exception explosion)
         {
            exception = explosion;
         }
         Assert.AreNotEqual(exception, null);
         Assert.AreEqual(exception.Message, "The FavoriteColor for the Person is
               inappropriately less than 3 characters.");
      }
   }
}

 
 

I asked a coworker what he thought of this sort of thing and he suggested that one probably doesn't want to roll their own thing and that using a pre-made, third party solution like Microsoft DataAnnotations was a better way to go. There are going to have to be a lot of rules (attribute types) you can apply and prewritten code like my static method above that you don't have to maintain or think about yourself for this sort of thing to even be worth your time given the grotesque workaround that is happening beneath the hood, right? My coworker suggested that in PostSharp the AOP capabilities allow one to specify a method to be run before a regular method and that in such a forerunner one will have accessible as a part of the framework both the metadata from applicable attributes and the instances of classes to interface with them. This architecture must be doing out-of-sight what I am doing above in a more complicated/elegant way.

No comments:

Post a Comment