TDD and refactoring to patterns in C#: how to write a cron parser - Part 5

In the final article of this series, we will implement the Visitor design pattern to introduce a new feature to the application.

Imagine that we are tasked with presenting, in plain English, the meaning of a given cron expression. How can we implement this new feature ?

Modify the RootCronExpressionTests class

  • Add a new method in the RootCronExpression class with the following code.
1public string DisplayInPlainEnglish()
2{
3    return "";
4}
  • Add new tests in the RootCronExpressionTests class
 1[Test]
 2public void DisplayInPlainEnglishShouldReturnCorrectResultWithTestCase_1()
 3{
 4    // Arrange
 5    var expression = "5 0 * 11 6";
 6    var root = RootCronExpression.Parse(expression);
 7
 8    // Act
 9    var result = root.DisplayInPlainEnglish();
10
11    // Assert
12    var expected = "At 00:05 on Saturday in November";
13    Assert.IsTrue(result == expected);
14}
15
16[Test]
17public void DisplayInPlainEnglishShouldReturnCorrectResultWithTestCase_2()
18{
19    // Arrange
20    var expression = "0 22 * * 4";
21    var root = RootCronExpression.Parse(expression);
22
23    // Act
24    var result = root.DisplayInPlainEnglish();
25
26    // Assert
27    var expected = "At 22:00 on Thursday";
28    Assert.IsTrue(result == expected);
29}

We must now implement the DisplayInPlainEnglish method to ensure the success of these tests.

Approach 1: Add a new method in the ICronField interface

We will initially take the most straightforward approach to fulfill the requirement by introducing another method in the ICronField interface.

1 public interface ICronField
2 {
3     // ...
4
5     public string DisplayInPlainEnglish();
6 }

As usual, this method must now be implemented in each concrete class that implements this interface.

 1public class MonthCronField : ICronField
 2{
 3    //
 4	
 5    public string DisplayInPlainEnglish()
 6    {
 7        if (Pattern == "*") return "";
 8
 9        var month = Convert.ToInt32(Pattern);
10        var monthName = new DateTime(2000, month, 1).ToString("MMMM", CultureInfo.InvariantCulture);
11
12        return $"in {monthName}";
13    }
14}
 1public class DayOfMonthCronField : ICronField
 2{
 3	//
 4
 5    public string DisplayInPlainEnglish()
 6    {
 7        if (Pattern == "*") return "";
 8
 9        var dayOfMonth = Convert.ToInt32(Pattern);
10
11        return $"on day-of-month {dayOfMonth}";
12    }
13}
 1public class DayOfWeekCronField : ICronField
 2{
 3    //
 4
 5    public string DisplayInPlainEnglish()
 6    {
 7        if (Pattern == "*") return "";
 8
 9        var day = Convert.ToInt32(Pattern);
10        var dayName = Enum.GetName(typeof(DayOfWeek), day);
11
12        return $"on {dayName}";
13    }
14}
 1public class HourCronField : ICronField
 2{
 3    //
 4
 5    public string DisplayInPlainEnglish()
 6    {
 7        if (Pattern == "*") return "";
 8
 9        var hour = Convert.ToInt32(Pattern);
10
11        return $"past hour {hour}";
12    }
13}
 1public class MinuteCronField : ICronField
 2{
 3    //
 4
 5    public string DisplayInPlainEnglish()
 6    {
 7        if (Pattern == "*") return "At every minute";
 8
 9        var minute = Convert.ToInt32(Pattern);
10
11        return $"At minute {minute}";
12    }
13}
  • Modify the RootCronExpression class with the following code.
 1public class RootCronExpression
 2{
 3    ...
 4
 5    public string DisplayInPlainEnglish()
 6    {
 7        // Use a StringBuilder in a real-world application
 8
 9        var result = _minuteField.DisplayInPlainEnglish();
10        result = result.TrimEnd() + " " + _hourField.DisplayInPlainEnglish();
11        result = result.TrimEnd() + " " + _dayOfMonthField.DisplayInPlainEnglish();
12        result = result.TrimEnd() + " " + _monthField.DisplayInPlainEnglish();
13        result = result.TrimEnd() + " " + _dayOfWeekField.DisplayInPlainEnglish();
14
15        return result.TrimEnd();
16    }
17	
18	...
19}

Running the tests

It remains to verify that no regressions have been introduced by this code change.

Our application is still functional and provides accurate results.

This approach is correct and would be perfectly acceptable if we had to freeze development at that stage. Nonetheless, if we wanted to add more and more features, it suffers from the fact that the different additions (DisplayInPlainEnglish or GiveNextOccurrencesFrom) bloat the code of ICronField. In an extreme case, the classes will be more filled with these technical features than with their own logic.

Moreover, the code for DisplayInPlainEnglish and GiveNextOccurrencesFrom is dispatched among 5 different classes, which can make the global comprehension challenging. It would be better if all the code were centralized in a unique class. In fact, there is a design pattern for that.

Approach 2: Implement the Visitor design pattern

What is the Visitor design pattern ?

The Visitor design pattern is a behavioral design pattern that allows us to separate algorithms from the objects on which they operate. It defines a way to represent an operation to be performed on the elements of an object structure. The Visitor pattern lets define a new operation without changing the classes of the elements on which it operates.

In simpler terms, the Visitor pattern provides a mechanism to define new operations on a set of related classes without altering their structure. It achieves this by defining a separate visitor class that encapsulates the new operation, and this visitor class can traverse the object structure and apply the operation to each element.

Additional information regarding the applicability, advantages, and drawbacks of the Visitor pattern can be found in the book Design Patterns.

Code in Visual Studio

  • Add a new interface named ICronFieldVisitor with the following code.
 1public interface ICronFieldVisitor
 2{
 3    void VisitMonthCronField(MonthCronField field);
 4
 5    void VisitDayOfMonthCronField(DayOfMonthCronField field);
 6
 7    void VisitDayOfWeekCronField(DayOfWeekCronField field);
 8
 9    void VisitHourCronField(HourCronField field);
10
11    void VisitMinuteCronField(MinuteCronField field);
12}
  • Add a new class named GiveNextOccurrencesFromCronFieldVisitor with the following code.
 1public class GiveNextOccurrencesCronFieldVisitor : ICronFieldVisitor
 2{
 3    public List<DateTime> Candidates { get; set; }
 4
 5    public GiveNextOccurrencesCronFieldVisitor(DateTime reference)
 6    {
 7        Candidates = GetDates(reference);
 8    }
 9
10    public void VisitMonthCronField(MonthCronField field)
11    {
12        var pattern = field.Pattern;
13
14        if (pattern == "*") return;
15
16        var month = Convert.ToInt32(pattern);
17
18        Candidates = Candidates.Where(t => t.Month == month).ToList();            
19    }
20
21    public void VisitDayOfMonthCronField(DayOfMonthCronField field)
22    {
23        var pattern = field.Pattern;
24
25        if (pattern == "*") return;
26
27        var dayOfMonth = Convert.ToInt32(pattern);
28
29        Candidates = Candidates.Where(t => t.Day == dayOfMonth).ToList();
30    }
31
32    public void VisitDayOfWeekCronField(DayOfWeekCronField field)
33    {
34        var pattern = field.Pattern;
35
36        if (pattern == "*") return;
37
38        var day = Convert.ToInt32(pattern);
39
40        Candidates = Candidates.Where(t => (int)t.DayOfWeek == day).ToList();
41    }
42
43    public void VisitHourCronField(HourCronField field)
44    {
45        var pattern = field.Pattern;
46
47        if (pattern == "*") return;
48
49        var hour = Convert.ToInt32(pattern);
50
51        Candidates = Candidates.Select(t => new DateTime(t.Year, t.Month, t.Day, hour, 0, 0)).ToList();
52    }
53
54    public void VisitMinuteCronField(MinuteCronField field)
55    {
56        var pattern = field.Pattern;
57
58        if (pattern == "*") return;
59
60        var minute = Convert.ToInt32(pattern);
61
62        Candidates = Candidates.Select(t => new DateTime(t.Year, t.Month, t.Day, t.Hour, minute, 0)).ToList();
63    }
64
65    private List<DateTime> GetDates(DateTime reference)
66    {
67        var endDate = new DateTime(2099, 12, 31);
68        return Enumerable.Range(0, 1 + endDate.Subtract(reference).Days).Select(offset => reference.AddDays(offset)).ToList();
69    }
70}

At this point, we observe that the code for computing the next occurrence is centralized in a single file and is no longer scattered throughout. Essentially, the Visitor design pattern encapsulates the behavior we are attempting to implement in a unified location.

  • Add a new class named DisplayInPlainEnglishCronFieldVisitor with the following code.
 1public class DisplayInPlainEnglishCronFieldVisitor : ICronFieldVisitor
 2{
 3    public string Text { get; set; }
 4
 5    public DisplayInPlainEnglishCronFieldVisitor()
 6    {
 7        Text = "{minutePattern} {hourPattern} {dayOfMonthPattern} {monthPattern} {dayOfWeekPattern}";
 8    }
 9
10    public void VisitMonthCronField(MonthCronField field)
11    {
12        var pattern = field.Pattern;
13
14        if (pattern == "*")
15        {
16            Text = Text.Replace("{monthPattern}", "");
17            return;
18        }
19
20        var month = Convert.ToInt32(pattern);
21        var monthName = new DateTime(2000, month, 1).ToString("MMMM", CultureInfo.InvariantCulture);
22
23        Text = Text.Replace("{monthPattern}", $"in {monthName}");
24        Text = OnlyOneSpace(Text); 
25    }
26
27    public void VisitDayOfMonthCronField(DayOfMonthCronField field)
28    {
29        var pattern = field.Pattern;
30
31        if (pattern == "*")
32        {
33            Text = Text.Replace("{dayOfMonthPattern}", "");
34            return;
35        }
36
37        var dayOfMonth = Convert.ToInt32(pattern);
38
39        Text = Text.Replace("{monthPattern}", $"on day-of-month {dayOfMonth}");
40        Text = OnlyOneSpace(Text);
41    }
42
43    public void VisitDayOfWeekCronField(DayOfWeekCronField field)
44    {
45        var pattern = field.Pattern;
46
47        if (pattern == "*")
48        {
49            Text = Text.Replace("{dayOfWeekPattern}", "");
50            return;
51        }
52
53        var day = Convert.ToInt32(pattern);
54        var dayName = Enum.GetName(typeof(DayOfWeek), day);
55
56        Text = Text.Replace("{dayOfWeekPattern}", $"on {dayName}");
57        Text = OnlyOneSpace(Text);
58    }
59
60    public void VisitHourCronField(HourCronField field)
61    {
62        var pattern = field.Pattern;
63
64        if (pattern == "*")
65        {
66            Text = Text.Replace("{hourPattern}", "");
67            return;
68        }
69
70        var hour = Convert.ToInt32(pattern);
71
72        Text = Text.Replace("{hourPattern}", $"past hour {hour}");
73        Text = OnlyOneSpace(Text);
74    }
75
76    public void VisitMinuteCronField(MinuteCronField field)
77    {
78        var pattern = field.Pattern;
79
80        if (pattern == "*")
81        {
82            Text = Text.Replace("{minutePattern}", "At every minute");
83            return;
84        }
85
86        var minute = Convert.ToInt32(pattern);
87
88        Text = Text.Replace("{minutePattern}", $"At minute {minute}");
89        Text = OnlyOneSpace(Text);
90    }
91
92    private string OnlyOneSpace(string text)
93    {
94        var regex = new Regex(@"[ ]{2,}", RegexOptions.None);
95        return regex.Replace(text, @" ");
96    }
97}
  • Modify the ICronField interface with the following code.
1public interface ICronField
2{
3    public void Accept(ICronFieldVisitor visitor);
4}

This interface now comprises only one method, universally named "Accept" in the Visitor pattern terminology, which takes a visitor as an argument. Consequently, the concrete classes (such as MonthCronField and others) will not be cluttered with code unrelated to their concerns.

  • Modify all the classes that implement the ICronField interface.
 1public class MonthCronField : ICronField
 2{
 3    public RootCronExpression Expression { get; set; }
 4
 5    public string Pattern { get; set; }
 6
 7    public MonthCronField(RootCronExpression expression)
 8    {
 9        Expression = expression;
10        Pattern = expression.Pattern.Split(' ')[3];
11    }
12
13    public void Accept(ICronFieldVisitor visitor)
14    {
15        visitor.VisitMonthCronField(this);
16    }
17}
 1public class DayOfMonthCronField : ICronField
 2{
 3    public RootCronExpression Expression { get; set; }
 4
 5    public string Pattern { get; set; }
 6
 7    public DayOfMonthCronField(RootCronExpression expression)
 8    {
 9        Expression = expression;
10        Pattern = expression.Pattern.Split(' ')[2];
11    }
12
13    public void Accept(ICronFieldVisitor visitor)
14    {
15        visitor.VisitDayOfMonthCronField(this);
16    }
17}
 1public class DayOfWeekCronField : ICronField
 2{
 3    public RootCronExpression Expression { get; set; }
 4
 5    public string Pattern { get; set; }
 6
 7    public DayOfWeekCronField(RootCronExpression expression)
 8    {
 9        Expression = expression;
10        Pattern = expression.Pattern.Split(' ')[4];
11    }
12
13    public void Accept(ICronFieldVisitor visitor)
14    {
15        visitor.VisitDayOfWeekCronField(this);
16    }
17}
 1public class HourCronField : ICronField
 2{
 3    public RootCronExpression Expression { get; set; }
 4
 5    public string Pattern { get; set; }
 6
 7    public HourCronField(RootCronExpression expression)
 8    {
 9        Expression = expression;
10        Pattern = expression.Pattern.Split(' ')[1];
11    }
12
13    public void Accept(ICronFieldVisitor visitor)
14    {
15        visitor.VisitHourCronField(this);
16    }
17}
 1public class MinuteCronField : ICronField
 2{
 3    public RootCronExpression Expression { get; set; }
 4
 5    public string Pattern { get; set; }
 6
 7    public MinuteCronField(RootCronExpression expression)
 8    {
 9        Expression = expression;
10        Pattern = expression.Pattern.Split(' ')[0];
11    }
12
13    public void Accept(ICronFieldVisitor visitor)
14    {
15        visitor.VisitMinuteCronField(this);
16    }
17}
  • Modify the RootCronExpression class with the following code.
 1public class RootCronExpression
 2{
 3    public string Pattern { get; set; }
 4
 5    private readonly MinuteCronField _minuteField;
 6    private readonly HourCronField _hourField;
 7    private readonly DayOfMonthCronField _dayOfMonthField;
 8    private readonly MonthCronField _monthField;
 9    private readonly DayOfWeekCronField _dayOfWeekField;
10
11    private RootCronExpression(string pattern)
12    {
13        Pattern = pattern;
14        
15        _minuteField = new MinuteCronField(this);
16        _hourField = new HourCronField(this);
17        _dayOfMonthField = new DayOfMonthCronField(this);
18        _monthField = new MonthCronField(this);
19        _dayOfWeekField = new DayOfWeekCronField(this);
20    }
21
22    public static RootCronExpression Parse(string pattern)
23    {
24        return new RootCronExpression(pattern);
25    }
26
27    public DateTime GiveNextExecutionFrom(DateTime reference)
28    {
29        var visitor = new GiveNextOccurrencesCronFieldVisitor(reference);
30        ExecuteVisitor(visitor);
31
32        return visitor.Candidates.Where(x => x >= reference).Min();
33    }
34
35    public string DisplayInPlainEnglish()
36    {
37        var visitor = new DisplayInPlainEnglishCronFieldVisitor();
38        ExecuteVisitor(visitor);
39
40        return visitor.Text;
41    }
42
43    #region Private Methods
44    
45
46    private void ExecuteVisitor(ICronFieldVisitor visitor)
47    {
48        _monthField.Accept(visitor);
49        _dayOfMonthField.Accept(visitor);
50        _dayOfWeekField.Accept(visitor);
51        _hourField.Accept(visitor);
52        _minuteField.Accept(visitor);
53    }
54
55    #endregion
56}

Running the tests

It remains to verify that no regressions have been introduced by this code change.

Our application is still functional and provides accurate results.

Final words

If you wish to delve deeper into this topic, acquire the following books, which encompasse all the concepts emphasized in this series and delves into more advanced ones.

Do not hesitate to contact me shoud you require further information.