Introduction
This article focuses on how I've created a time zone aware DateTime
wrapper. I do not claim to be an expert on the field, but I have spent some weeks of research on the topic, and I think I understand some of the complexities we need to address. The biggest problem we have today when working with .NET DateTime
instances are that they, by default, represent local time, and they do not carry any information about which time zone they were originally created in. Working with DateTime
instances created in another time zone than your own seems very difficult and awkward. A usual workaround has been to convert values to UTC, together with some time zone information, an offset, and if daylight saving time is in use, before serializing the information (to disk, database, or over the network). These are some of the issues this library tries to address.
Converting DateTime
instances to UTC is, of course, supported by this library. This is currently the standard way of persisting date and time data to the database. When retrieving these DateTime
instances, one would typically send the UTC date and time to some factory, together with all the meta data (such as offset, time zone definition, and a flag indicating if daylight saving is in use), and be able to produce a correct LocalDateTime
instance with time local to the requested time zone. Then, instances of LocalDateTime
will be exposed internally in the rest of the application to ease date and time manipulations. This is currently what we are doing where I work, and we have the challenge that some Use Cases and reports are working with different DateTime
instances representing different time zones concurrently in the same report (view), and it is very important for our customers that this logic works correctly.
Background
If you start Googling around on the topic of DateTime
implementation, you will come across these URLs:
Basically, all three BCL blog entries try to explain why DateTime
became what it is, and that they do understand that a lot of API consumers are not very satisfied. They also delve into how they are trying to amend the situation with .NET 3.5's new date time structure called DateTimeOffset
. Reading the blog comments is very interesting as most of the comments indicate the users are not satisfied with the current approach. Interestingly, the new DateTimeOffset
does not carry time zone information, only a TimeSpan
to represent the offset. Thus, this will not solve the problem of representing local time transparently and retrieving it as such somewhere else in the world. Also, when converting from one time zone to another, the DST rules are needed. Without including the time zone information, the receiver working with a serialized DateTimeOffset
must guess about this. Now, most developers work around this problem as described earlier. But then again, are they any better off with the new structures offered by .NET 3.5 than they were with DateTime
?
Another thing, you can write some code to look up time zone services or the tz-database to keep your time zone definitions up to date. This is pretty much similar to how Java bundles their time zones internally in the JRE. Take a look at the implementation of the TimeZone
class to see how this can be done.
Using the Code
Now, let's look at how the LocalDateTime
implementation works. I have provided many constructors; some constructors derive the time zone meta data from the Operating System and identify the TimeZone
instance to use. But, if you would like to create a DateTime
instance rooted in another time zone, then you should use the constructor below:
public LocalDateTime(DateTime time, ITimeZone zone, bool isDST) : this()
{
IsLocalTimeBased = false;
ticks = time.Ticks;
timeZoneId = zone.CanonicalID;
this.isDST = isDST;
NullInitSummerWinterDST();
}
For instance, using this constructor would look like this:
LocalDateTime cetDateTime =
new LocalDateTime(new DateTime(2008, 12, 1), TimeZones.CET, false);
I used Reflector on System.DateTime
to create an IDateTime
interface including all the public
instance methods. Here is just a small excerpt of the interface:
public interface IDateTime
{
#region Properties
IDateTime Date { get; }
int Day { get; }
DayOfWeek DayOfWeek { get; }
int DayOfYear { get; }
int Hour { get; }
DateTimeKind Kind { get; }
int Millisecond { get; }
int Minute { get; }
int Month { get; }
IDateTime Now { get; }
This interface is, of course, implemented on the LocalDateTime
so that functions that API programmers are used to are available on LocalDateTime
. The type is also implemented as an immutable struct to behave as close to DateTime
as possible. Thus, the instance created above could also look like:
IDateTime cetDateTime =
new LocalDateTime(new DateTime(2008, 12, 1), TimeZones.CET, false);
When creating one hundred million date time instances compared to creating one hundred million LocalDateTime
instances, the creation of LocalDateTime
instances had an extra cost if constructors that need to derive time zone information were used. But, by using the constructor meant for MSSQL or a factory inside your application that produces LocalDateTime
s, there was no noticeable time difference (the DateTime
struct was 600 milliseconds faster on creation of one hundred million instances).
public LocalDateTime(long ticks, string timeZoneId, bool isDST) : this()
{
this.ticks = ticks;
this.timeZoneId = timeZoneId;
this.isDST = isDST;
}
This constructor also reveals what columns you must create if you won't use LocalDateTime
as a User Defined Type. By using a user defined type, these values will be stored along with the struct instance in a single column. To make this possible, I had to add some attributes to the struct and also implement the nullable interface:
[Serializable]
[SqlUserDefinedType(Format.Native)]
public struct LocalDateTime : IDateTime, INullable
{
....
Below is a passing Unit Test demonstrating that the LocalDateTime
implementation is time-zone aware:
[TestMethod]
public void TestAmerica_NewYorkEndDSTByConvertingToCETAndAddingOneSecondAndConvertingBack()
{
TimeZone tzNewYork = TimeZones.America__New_York;
DaylightSavingTime winter = tzNewYork.WinterChange;
int dayInMonth = DateUtil.FindDayInMonth(2009, winter.Month,
winter.GetDayOfWeek(), winter.GetDayOccurrenceInMonth());
DateTime winterDateTime = new DateTime(2009, winter.Month, dayInMonth);
TimeSpan oneSecondBefore2AM = new TimeSpan(1, 59, 59);
DateTime dateTime = winterDateTime.Add(oneSecondBefore2AM);
LocalDateTime ldt = new LocalDateTime(dateTime, tzNewYork, true);
Assert.AreEqual(ldt.GetDateTime(), dateTime);
Assert.IsTrue(ldt.IsDaylightSavingTime());
LocalDateTime cet = LocalDateTime.ConvertTo(ldt, TimeZones.CET);
dateTime = dateTime.AddSeconds(1);
IDateTime localDateTime = cet.AddSeconds(1);
ldt = LocalDateTime.ConvertTo((LocalDateTime)localDateTime, ldt.TimeZone);
Assert.AreNotEqual(ldt.GetDateTime(), dateTime);
Assert.AreEqual(ldt.GetDateTime(), dateTime.AddHours(-1));
Assert.IsFalse(ldt.IsDaylightSavingTime());
}
Since I like to use NHibernate as my object relational mapper, I have created a CompositeUserType
to make it possible to save this using the usual NHibernate mappings. This works for both UDT and the multicolumn approach.
This is the first version of this small library. It may contain bugs, and there are probably a lot of things to improve. If you have any feedback, please contact me so that I can improve this little library.
Policy Changes
Another approach worth looking into (which we currently have abandoned) is the possibility of registering the LocalDateTime
struct as a 'User Defined Type' (UDT) in your favorite database. This would at least speed up data retrieval, since you would not have to convert UTC time to the offset time value for the time zone it should be expressed in. By utilizing user defined types, the information you usually use three or four columns to store can now be represented in a single column of type LocalDateTime
.
After some discussion, we decided against utilizing a UDT in MSSQL. In the end, it may prove an extra burden upgrading customers if we need to re-register a UDT. Also, sorting and ordering based on date and time will not work as long as the LocalDateTime
data is represented in local time to the defined time zone. At least, if UDT is used, the LocalDateTime
should convert the date time to UTC and store it as such for consistent SQL manipulations in the DBMS. Although we consider these issues as such drawbacks that we ended up using the "three column approach", this doesn't mean that UDT might not be the best fit in other applications.
History
Added Unit Test project and more tests demonstrating how LocalDateTime
works.