Recently I asked a question on Programming SE about public vs private methods fo unit testing, which generated a lot of interesting responses.
I only posted a skeleton of the code there, as it was a question about structure and not about the code itself. However, several respondants queried whether my code might be overly complex, violate the SRP and that it might be "degenerate" because it only needed a single public method.
It seemed like too good a learning opportunity to miss, so I thought I'd post it here for review, with particular attention to above points and the structure and testability of the class.
I found the idea that it was poor design to have just one public method particularly interesting. On reflection, while it's clearly odd to have a class with just one public method, it seems to me that a class which keeps within the SRP probably ought to have very few.
Some notes on the external dependencies of the code. FileContents is just a DTO, a way of keeping useful properties together - in this instance a file that's read into FileRow objects and a Mappings collection that indicates which fields contain which data. ModelHelper contains some methods to work with these DTOs. JaroWinkler is a static class that implements that well known text similarity algorithm - it's pretty much the same as this.
public static class DeduplicateIndividuals
{
public static void DedupeFile(FileContents fc)
{
BuildNameKeys(fc);
SetExactDuplicates(fc);
FuzzyMatching(fc);
}
private static bool SurnameMatch(string surname1, string surname2)
{
return JaroWinkler.Proximity(surname1, surname2) >= 0.75;
}
private static bool ForenameMatch(string forename1, string forename2)
{
if(forename1.FirstCharacter() == forename2.FirstCharacter())
{
return true;
}
return JaroWinkler.Proximity(forename1, forename2) >= 0.85;
}
private static bool TitleMatch(string title1, string title2)
{
if(MrAndMrs(title1, title2) || MrAndMrs(title2, title1))
{
return false;
}
return JaroWinkler.Proximity(title1, title2) >= 0.9;
}
// without this, JW can think that Mr is the same as Ms, Mrs and Miss
private static bool MrAndMrs(string title1, string title2)
{
return title1 == "Mr" && (title2 == "Mrs" || title2 == "Ms" || title2 == "Miss");
}
public static void BuildNameKeys(FileContents fc)
{
int? dobOrdinal = ModelHelper.GetOrdinalByAssignedLabel(fc.Mappings, "DOB");
foreach (FileRow fr in fc.FileRows)
{
fr.CleanedName = string.Format("{0} {1} {2}", fr.GetFieldByOrdinal(fc.TitleOrdinal),
RemoveMiddleFromForename(fr.GetFieldByOrdinal(fc.ForenameOrdinal)), fr.GetFieldByOrdinal(fc.SurnameOrdinal));
if (dobOrdinal != null)
{
fr.CleanedName += " " + fr.Fields[(int)dobOrdinal];
}
if(fr.CleanedAddress == "")
{
fr.CleanedAddress = ModelHelper.GetAddressFromMappings(fc, fr).ToString();
}
}
}
private static void SetExactDuplicates(FileContents fc)
{
var hashSet = new HashSet<string>();
foreach (FileRow fr in fc.FileRows)
{
if (!hashSet.Add(fr.CleanedName + fr.CleanedAddress))
{
fr.PossibleDuplicate = true;
}
}
}
private static void FuzzyMatching(FileContents fc, int maxParallels = 32)
{
List<FileRow> copiedRows = new List<FileRow>(fc.FileRows.Where(f => f.PossibleDuplicate == false));
Parallel.ForEach(fc.FileRows.Where(f => f.PossibleDuplicate == false), new ParallelOptions { MaxDegreeOfParallelism = maxParallels }, (fr) =>
{
foreach (FileRow cr in copiedRows)
{
// don't compare a row against its copy, or if it's already been processed
if (cr.OrderBy != fr.OrderBy && cr.PossibleDuplicate == false)
{
// check criteria individially to save speed. Titles are fastest
if (TitleMatch(fr.GetFieldByOrdinal((int)fc.TitleOrdinal), cr.GetFieldByOrdinal((int)fc.TitleOrdinal)))
{
// if true, it's worth checking surnames
if (SurnameMatch(fr.GetFieldByOrdinal(fc.SurnameOrdinal), cr.GetFieldByOrdinal(fc.SurnameOrdinal)))
{
// forenames have most lax criteria, so check last
if (ForenameMatch(fr.GetFieldByOrdinal(fc.ForenameOrdinal), cr.GetFieldByOrdinal(fc.ForenameOrdinal))
&& fr.CleanedAddress == cr.CleanedAddress )
{
fr.PossibleDuplicate = true;
break;
}
}
}
}
}
});
}
}