2016-05-02

PHP DateTime: Mind the exclamation mark

A software I am currently working on manages employee schedules. Employees can request certain shifts and once a month a shift schedule is generated which tries to fulfill those requests. As soon as a plan is published, requests for that month should obviously no longer be accepted.

On the 31st of March (mind the date, it will become important later on), I received a strange error description from a customer using the software: Users complained that they could not enter their preferences for May, but they could enter preferences for the already published month of April. They said that this error had not been there the day before and they had not worked on the plans. Similarly, I knew that we did not deploy a new software release in the last 24 hours. So what was going on?

After a bit of debugging, I stumbled upon the piece of code that calculates which months are published and which are not. It takes an array of month/year strings in the format "2015-01" for months which have a published plan and transforms it into an array of DateTime objects, which represent the first day of each month with a published plan. This is then passed to the GUI so it can decide whether users can enter preferences for a certain month. Here is a simplified version of that function:

public function getPublishedStatePerMonth(array $publishedMonths) {
    return array_map(function ($element) {
        return \DateTime::createFromFormat("Y-m", $element);
    }, $publishedMonths);
}

Seems innocent enough: We just take a string like "2016-04" and parse it to a DateTime which should then contain "2016-04-01 00:00:00". However, there is a little quirk to how this function handles dates: When you do not specify an exclamation mark in your format string, PHP only reads the values which are present in the format string and takes the current date and time for all other values. So, on 31 March the string "2016-04" was passed into the function. PHP created a DateTime object for "2016-04-31" and because April has only 30 days, this was converted to "2016-05-01". The GUI therefore believed that the plan for May had already been published, whereas there was no entry for April, so users could enter preferences for April, but not for May.

Of course, there is a way to prevent this problem. PHP allows you to overwrite the behavior of always defaulting all values to the current value. If you prefix your format string with an exclamation mark, all values which are not read form the format string default to the Unix epoch, giving you zeroed values for all time fields and the first day of the month for your day field.

What we can learn from this is that you should unit-test even the simplest methods. This method had never worked as intended, as the date part was always the current day and never the first of the month. However, it turns out that this was not read anywhere in the GUI. A unit test would have caught that problem and probably prompted a developer to fix it by adding the exclamation mark. In the end, we refactored this method. As correct date handling is hard in PHP, we created a helper class to take care of recurring problems for us. One of the methods in this class is now fittingly named "getFirstDayOfMonth".

No comments:

Post a Comment