11
\$\begingroup\$

I'm currently working on a thing I needed this feature for. Essentially, these are two methods to convert DateTime objects to and from the Unix epoch time (two methods for each action).

There is a non-nullable, and a nullable version for each conversion.

/// <summary>
/// Converts a DateTime to the long representation which is the number of seconds since the unix epoch.
/// </summary>
/// <param name="dateTime">A DateTime to convert to epoch time.</param>
/// <returns>The long number of seconds since the unix epoch.</returns>
public static long ToEpoch(DateTime dateTime) => (long)(dateTime - new DateTime(1970, 1, 1)).TotalSeconds;

/// <summary>
/// Converts a long representation of time since the unix epoch to a DateTime.
/// </summary>
/// <param name="epoch">The number of seconds since Jan 1, 1970.</param>
/// <returns>A DateTime representing the time since the epoch.</returns>
public static DateTime FromEpoch(long epoch) => new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified).AddSeconds(epoch);

/// <summary>
/// Converts a DateTime? to the long? representation which is the number of seconds since the unix epoch.
/// </summary>
/// <param name="dateTime">A DateTime? to convert to epoch time.</param>
/// <returns>The long? number of seconds since the unix epoch.</returns>
public static long? ToEpoch(DateTime? dateTime) => dateTime.HasValue ? (long?)ToEpoch(dateTime.Value) : null;

/// <summary>
/// Converts a long? representation of time since the unix epoch to a DateTime?.
/// </summary>
/// <param name="epoch">The number of seconds since Jan 1, 1970.</param>
/// <returns>A DateTime? representing the time since the epoch.</returns>
public static DateTime? FromEpoch(long? epoch) => epoch.HasValue ? (DateTime?)FromEpoch(epoch.Value) : null;

And here are the relevant unit tests:

[TestMethod, TestCategory("Date Time Extensions Tests")]
public void EpochToDateTime_0()
{
    var expected = new DateTime(1970, 1, 1, 0, 0, 0, 0);
    var input = 0;

    var result = DateTimeExtensions.FromEpoch(input);

    Assert.AreEqual(expected, result);
}

[TestMethod, TestCategory("Date Time Extensions Tests")]
public void DateTimeToEpoch_1970_1_1_0_0_0_0()
{
    var expected = 0;
    var input = new DateTime(1970, 1, 1, 0, 0, 0, 0);

    var result = DateTimeExtensions.ToEpoch(input);

    Assert.AreEqual(expected, result);
}

[TestMethod, TestCategory("Date Time Extensions Tests")]
public void EpochToDateTime_1()
{
    var expected = new DateTime(1970, 1, 1, 0, 0, 1, 0);
    var input = 1;

    var result = DateTimeExtensions.FromEpoch(input);

    Assert.AreEqual(expected, result);
}

[TestMethod, TestCategory("Date Time Extensions Tests")]
public void DateTimeToEpoch_1970_1_1_0_0_1_0()
{
    var expected = 1;
    var input = new DateTime(1970, 1, 1, 0, 0, 1, 0);

    var result = DateTimeExtensions.ToEpoch(input);

    Assert.AreEqual(expected, result);
}

[TestMethod, TestCategory("Date Time Extensions Tests")]
public void EpochToDateTime_61()
{
    var expected = new DateTime(1970, 1, 1, 0, 1, 1, 0);
    var input = 61;

    var result = DateTimeExtensions.FromEpoch(input);

    Assert.AreEqual(expected, result);
}

[TestMethod, TestCategory("Date Time Extensions Tests")]
public void DateTimeToEpoch_1970_1_1_0_1_1_0()
{
    var expected = 61;
    var input = new DateTime(1970, 1, 1, 0, 1, 1, 0);

    var result = DateTimeExtensions.ToEpoch(input);

    Assert.AreEqual(expected, result);
}

[TestMethod, TestCategory("Date Time Extensions Tests")]
public void EpochToDateTime_3601()
{
    var expected = new DateTime(1970, 1, 1, 1, 0, 1, 0);
    var input = 3601;

    var result = DateTimeExtensions.FromEpoch(input);

    Assert.AreEqual(expected, result);
}

[TestMethod, TestCategory("Date Time Extensions Tests")]
public void DateTimeToEpoch_1970_1_1_1_0_1_0()
{
    var expected = 3601;
    var input = new DateTime(1970, 1, 1, 1, 0, 1, 0);

    var result = DateTimeExtensions.ToEpoch(input);

    Assert.AreEqual(expected, result);
}

[TestMethod, TestCategory("Date Time Extensions Tests")]
public void EpochToDateTime_86401()
{
    var expected = new DateTime(1970, 1, 2, 0, 0, 1, 0);
    var input = 86401;

    var result = DateTimeExtensions.FromEpoch(input);

    Assert.AreEqual(expected, result);
}

[TestMethod, TestCategory("Date Time Extensions Tests")]
public void DateTimeToEpoch_1970_1_2_0_0_1_0()
{
    var expected = 86401;
    var input = new DateTime(1970, 1, 2, 0, 0, 1, 0);

    var result = DateTimeExtensions.ToEpoch(input);

    Assert.AreEqual(expected, result);
}

[TestMethod, TestCategory("Date Time Extensions Tests")]
public void EpochToDateTime_2678401()
{
    var expected = new DateTime(1970, 2, 1, 0, 0, 1, 0);
    var input = 2678401;

    var result = DateTimeExtensions.FromEpoch(input);

    Assert.AreEqual(expected, result);
}

[TestMethod, TestCategory("Date Time Extensions Tests")]
public void DateTimeToEpoch_1970_2_1_0_0_1_0()
{
    var expected = 2678401;
    var input = new DateTime(1970, 2, 1, 0, 0, 1, 0);

    var result = DateTimeExtensions.ToEpoch(input);

    Assert.AreEqual(expected, result);
}

[TestMethod, TestCategory("Date Time Extensions Tests")]
public void EpochToDateTime_31536001()
{
    var expected = new DateTime(1971, 1, 1, 0, 0, 1, 0);
    var input = 31536001;

    var result = DateTimeExtensions.FromEpoch(input);

    Assert.AreEqual(expected, result);
}

[TestMethod, TestCategory("Date Time Extensions Tests")]
public void DateTimeToEpoch_1971_1_1_0_0_1_0()
{
    var expected = 31536001;
    var input = new DateTime(1971, 1, 1, 0, 0, 1, 0);

    var result = DateTimeExtensions.ToEpoch(input);

    Assert.AreEqual(expected, result);
}

[TestMethod, TestCategory("Date Time Extensions Tests")]
public void EpochToDateTime_315532801()
{
    var expected = new DateTime(1980, 1, 1, 0, 0, 1, 0);
    var input = 315532801;

    var result = DateTimeExtensions.FromEpoch(input);

    Assert.AreEqual(expected, result);
}

[TestMethod, TestCategory("Date Time Extensions Tests")]
public void DateTimeToEpoch_1980_1_1_0_0_1_0()
{
    var expected = 315532801;
    var input = new DateTime(1980, 1, 1, 0, 0, 1, 0);

    var result = DateTimeExtensions.ToEpoch(input);

    Assert.AreEqual(expected, result);
}

[TestMethod, TestCategory("Date Time Extensions Tests")]
public void EpochToDateTime_1420070401()
{
    var expected = new DateTime(2015, 1, 1, 0, 0, 1, 0);
    var input = 1420070401;

    var result = DateTimeExtensions.FromEpoch(input);

    Assert.AreEqual(expected, result);
}

[TestMethod, TestCategory("Date Time Extensions Tests")]
public void DateTimeToEpoch_2015_1_1_0_0_1_0()
{
    var expected = 1420070401;
    var input = new DateTime(2015, 1, 1, 0, 0, 1, 0);

    var result = DateTimeExtensions.ToEpoch(input);

    Assert.AreEqual(expected, result);
}

[TestMethod, TestCategory("Date Time Extensions Tests")]
public void EpochToDateTime_Null()
{
    var expected = (DateTime?)null;
    var input = (long?)null;

    var result = DateTimeExtensions.FromEpoch(input);

    Assert.AreEqual(expected, result);
}

[TestMethod, TestCategory("Date Time Extensions Tests")]
public void DateTimeToEpoch_Null()
{
    var expected = (long?)null;
    var input = (DateTime?)null;

    var result = DateTimeExtensions.ToEpoch(input);

    Assert.AreEqual(expected, result);
}

Every test passes exactly as it should. I've yet to write tests for negative epochs.

\$\endgroup\$
2
  • \$\begingroup\$ How do you handle Leap seconds? \$\endgroup\$ Apr 10, 2016 at 8:53
  • \$\begingroup\$ @IsmaelMiguel The way all unix timestamps do -- assume they don't exist and that a day has always 60*60*24 seconds. \$\endgroup\$ Apr 10, 2016 at 10:44

4 Answers 4

6
\$\begingroup\$

You don't round inputs before the epoch start correctly. You should round towards minus infinity, not towards the epoch start.

  • When you add x seconds to a DateTime is should add x seconds to the unix time stamp. Switching between upwards and downwards rounding breaks that property.
  • Obtaining the date from a DateTime should give the same result as obtaining it from the unix time stamp. Only rounding down accomplishes that.

Microsoft's implementation does that correctly:

public long ToUnixTimeSeconds() {
    // Truncate sub-second precision before offsetting by the Unix Epoch to avoid
    // the last digit being off by one for dates that result in negative Unix times.
    //
    // For example, consider the DateTimeOffset 12/31/1969 12:59:59.001 +0
    //   ticks            = 621355967990010000
    //   ticksFromEpoch   = ticks - UnixEpochTicks                   = -9990000
    //   secondsFromEpoch = ticksFromEpoch / TimeSpan.TicksPerSecond = 0
    //
    // Notice that secondsFromEpoch is rounded *up* by the truncation induced by integer division,
    // whereas we actually always want to round *down* when converting to Unix time. This happens
    // automatically for positive Unix time values. Now the example becomes:
    //   seconds          = ticks / TimeSpan.TicksPerSecond = 62135596799
    //   secondsFromEpoch = seconds - UnixEpochSeconds      = -1
    //
    // In other words, we want to consistently round toward the time 1/1/0001 00:00:00,
    // rather than toward the Unix Epoch (1/1/1970 00:00:00).
    long seconds = UtcDateTime.Ticks / TimeSpan.TicksPerSecond;
    return seconds - UnixEpochSeconds;
}
\$\endgroup\$
1
  • \$\begingroup\$ I've fixed this particular issue. Thanks for pointing it out. :) \$\endgroup\$ Apr 10, 2016 at 18:44
10
\$\begingroup\$

Note that DateTimeOffset.ToUnixTimeSeconds and DateTimeOffset.FromUnixTimeSeconds exist as of .NET 4.6.

Consider using those instead.

If you really need a nullable, you can still wrap the framework methods in an extension method.

Also, generally you should be using DateTimeOffset instead of DateTime unless you explicitly want to ignore timezones (e.g. local noon regardless of timezone). (Somewhat debatable, see comments. Still, as long as you make sure you work in UTC then converting between DateTime and DateTimeOffset shouldn't be a problem for Unix time conversion - and certainly no more than it would be a problem with manual calculations.)

\$\endgroup\$
6
  • 1
    \$\begingroup\$ Really? About time! \$\endgroup\$ Apr 10, 2016 at 9:00
  • \$\begingroup\$ And they even managed to implement it correctly for pre-epoch times. \$\endgroup\$ Apr 10, 2016 at 10:31
  • \$\begingroup\$ ...except Offset != TimeZone. So if you have something that's supposed to be in a TimeZone with DST, guess what? It ain't changin'. They picked the wrong concept to model, most people do not want to manage transitions themselves. Personally, .Net's date/time handling is very poor with regards to non-local timezones (and not much better with regards to DST for the local timezone). \$\endgroup\$ Apr 10, 2016 at 12:22
  • \$\begingroup\$ @Clockwork-Muse Perhaps. I've added a note. I still think the DTO methods should be used at least for Unix time conversion. As far as programmer's expectations go, I don't think any library can handle DST in a way that won't be wrong for many uses. Time is a funny thing. \$\endgroup\$
    – Bob
    Apr 11, 2016 at 0:36
  • \$\begingroup\$ @Bob I know a bit of this is irrelevant to this question, but I have updated the code to mandate the use of a DateTimeKind.Utc for these methods. I always use UTC dates in all my projects. \$\endgroup\$ Apr 11, 2016 at 0:42
5
\$\begingroup\$

The Unix Epoch is defined as the number of seconds since January 1, 1970, 00:00 UTC.

The DateTime(Int32, Int32, Int32) constructor, on the other hand, creates a DateTime whose Kind property is DateTimeKind.Unspecified rather than DateTimeKind.Utc.

You might be able to get away with using an unspecified time zone, but I don't think that it's correct.

\$\endgroup\$
1
  • \$\begingroup\$ You're absolutely right. That's my fault. Thanks for finding that. :) \$\endgroup\$ Apr 10, 2016 at 4:08
3
\$\begingroup\$

The main issue I see here is that you're creating an instance of the epoch-base point on every call. It would make more sense to create one static readonly DateTime EpochBase = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified) and use it as a base for all the methods.

Secondly, concerning readability, I still haven't gotten used to C# 6's expression-bodied methods, and I don't know if standard formatting has emerged, but for me, these one-liners are very clunky and have a bad comment-to-logic ratio, so the actual code gets lost.

Adding an EpochBase constant will help, but still consider breaking it up to two lines:

/// <summary>
/// Converts a long representation of time since the unix epoch to a DateTime.
/// </summary>
/// <param name="epoch">The number of seconds since Jan 1, 1970.</param>
/// <returns>A DateTime representing the time since the epoch.</returns>
public static DateTime FromEpoch(long epoch) 
       => EpochBase.AddSeconds(epoch);
\$\endgroup\$
1
  • \$\begingroup\$ I completely agree with the first point, though I do happen to like the one liner expression-bodied members. (I fold those summary blocks away in Visual Studio anyway.) In any case, +1 from me. :) \$\endgroup\$ Apr 10, 2016 at 4:09

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.