Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement unix time and rfc3339 parsing #23

Merged
merged 3 commits into from
Nov 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions date.libsonnet
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,131 @@ local d = import 'doc-util/main.libsonnet';
if month > 2 && self.isLeapYear(year)
then 1
else 0,

// yearSeconds returns the number of seconds in the given year.
local yearSeconds(year) = (
if $.isLeapYear(year)
then 366 * 24 * 3600
else 365 * 24 * 3600
),

// monthSeconds returns the number of seconds in the given month of a given year.
local monthSeconds(year, month) = (
commonYearMonthLength[month - 1] * 24 * 3600
+ if month == 2 && $.isLeapYear(year) then 86400 else 0
),

// sumYearsSeconds returns the number of seconds in all years since 1970 up to year-1.
local sumYearsSeconds(year) = std.foldl(
function(acc, y) acc + yearSeconds(y),
std.range(1970, year - 1),
0,
),

// sumMonthsSeconds returns the number of seconds in all months up to month-1 of the given year.
local sumMonthsSeconds(year, month) = std.foldl(
function(acc, m) acc + monthSeconds(year, m),
std.range(1, month - 1),
0,
),

// sumDaysSeconds returns the number of seconds in all days up to day-1.
local sumDaysSeconds(day) = (day - 1) * 24 * 3600,

'#toUnixTimestamp': d.fn(
|||
`toUnixTimestamp` calculates the unix timestamp of a given date.
|||,
[
d.arg('year', d.T.number),
d.arg('month', d.T.number),
d.arg('day', d.T.number),
d.arg('hour', d.T.number),
d.arg('minute', d.T.number),
d.arg('second', d.T.number),
],
),
toUnixTimestamp(year, month, day, hour, minute, second)::
sumYearsSeconds(year) + sumMonthsSeconds(year, month) + sumDaysSeconds(day) + hour * 3600 + minute * 60 + second,
Comment on lines +89 to +103
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was already in github.com/grafana/jsonnet-libs/date, now I'm bringing here as it's useful once we've parsed the RFC3339.


// isNumeric checks that the input is a non-empty string containing only digit characters.
local isNumeric(input) =
assert std.type(input) == 'string' : 'isNumeric() only operates on string inputs, got %s' % std.type(input);
std.foldl(
function(acc, char) acc && std.codepoint('0') <= std.codepoint(char) && std.codepoint(char) <= std.codepoint('9'),
std.stringChars(input),
std.length(input) > 0,
),
Comment on lines +105 to +112
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jdbaldry I borrowed the implementation from your comment here :)


// parseSeparatedNumbers parses input which has part `names` separated by `sep`.
// Returns an object which has one field for each name in `names` with its integer value.
local parseSeparatedNumbers(input, sep, names) = (
assert std.type(input) == 'string' : 'parseSeparatedNumbers() only operates on string inputs, got %s' % std.type(input);
assert std.type(sep) == 'string' : 'parseSeparatedNumbers() only operates on string separators, got %s' % std.type(sep);
assert std.type(names) == 'array' : 'parseSeparatedNumbers() only operates on arrays of names, got input %s' % std.type(names);

local parts = std.split(input, sep);
assert std.length(parts) == std.length(names) : 'expected %(expected)d parts separated by %(sep)s in %(format)s formatted input "%(input)s", but got %(got)d' % {
expected: std.length(names),
sep: sep,
format: std.join(sep, names),
input: input,
got: std.length(parts),
};

{
[names[i]]:
// Fail with meaningful message if not numeric, otherwise it will be a hell to debug.
assert isNumeric(parts[i]) : '%(name)%s part "%(part)s" of %(format)s of input "%(input)s" is not numeric' % {
name: names[i],
part: parts[i],
format: std.join(sep, names),
input: input,
};
std.parseInt(parts[i])
for i in std.range(0, std.length(parts) - 1)
}
),

// stringContains is a helper function to check whether a string contains a given substring.
local stringContains(haystack, needle) = std.length(std.findSubstr(needle, haystack)) > 0,

'#parseRFC3339': d.fn(
|||
`parseRFC3339` parses an RFC3339-formatted date & time string into an object containing the 'year', 'month', 'day', 'hour', 'minute' and 'second fields.
This is a limited implementation that does not support timezones (so it requires an UTC input ending in 'Z' or 'z') nor sub-second precision.
The returned object has a `toUnixTimestamp()` method that can be used to obtain the unix timestamp of the parsed date.
|||,
[
d.arg('input', d.T.string),
],
),
parseRFC3339(input)::
// Basic input type check.
assert std.type(input) == 'string' : 'parseRFC3339() only operates on string inputs, got %s' % std.type(input);

// Sub-second precision isn't implemented yet, warn the user about that instead of returning wrong results.
assert !stringContains(input, '.') : 'the provided RFC3339 input "%s" has a dot, most likely representing a sub-second precision, but this function does not support that' % input;

// We don't support timezones, so string should end with 'Z' or 'z'.
assert std.endsWith(input, 'Z') || std.endsWith(input, 'z') : 'the provided RFC3339 "%s" should end with "Z" or "z". This implementation does not currently support timezones' % input;

// RFC3339 can separate date and time using 'T', 't' or ' '.
// Find out which one it is and use it.
local sep =
if stringContains(input, 'T') then 'T'
else if stringContains(input, 't') then 't'
else if stringContains(input, ' ') then ' '
else error 'the provided RFC3339 input "%s" should contain either a "T", or a "t" or space " " as a separator for date and time parts' % input;
Comment on lines +161 to +173
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Duologic as you asked in grafana/jsonnet-libs#1095, I added support for lowercase t/z and also for space separator.


// Split date and time using the selected separator.
// Remove the last character as we know it's 'Z' or 'z' and it's not useful to us.
local datetime = std.split(std.substr(input, 0, std.length(input) - 1), sep);
assert std.length(datetime) == 2 : 'the provided RFC3339 timestamp "%(input)s" does not have date and time parts separated by the character "%(sep)s"' % { input: input, sep: sep };

local date = parseSeparatedNumbers(datetime[0], '-', ['year', 'month', 'day']);
local time = parseSeparatedNumbers(datetime[1], ':', ['hour', 'minute', 'second']);
date + time + {
toUnixTimestamp():: $.toUnixTimestamp(self.year, self.month, self.day, self.hour, self.minute, self.second),
},
Comment on lines +182 to +184
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't the most functional approach, but it's easy to use. I'm happy to listen for your suggestions.

}
23 changes: 22 additions & 1 deletion docs/date.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ local date = import "github.com/jsonnet-libs/xtd/date.libsonnet"
* [`fn dayOfWeek(year, month, day)`](#fn-dayofweek)
* [`fn dayOfYear(year, month, day)`](#fn-dayofyear)
* [`fn isLeapYear(year)`](#fn-isleapyear)
* [`fn parseRFC3339(input)`](#fn-parserfc3339)
* [`fn toUnixTimestamp(year, month, day, hour, minute, second)`](#fn-tounixtimestamp)

## Fields

Expand Down Expand Up @@ -42,4 +44,23 @@ for common years, and 1-366 for leap years.
isLeapYear(year)
```

`isLeapYear` returns true if the given year is a leap year.
`isLeapYear` returns true if the given year is a leap year.

### fn parseRFC3339

```ts
parseRFC3339(input)
```

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be useful to have an example of an RFC3339 date/time for those of us that know the format but not the RFC number.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, followed up in #24

`parseRFC3339` parses an RFC3339-formatted date & time string into an object containing the 'year', 'month', 'day', 'hour', 'minute' and 'second fields.
This is a limited implementation that does not support timezones (so it requires an UTC input ending in 'Z' or 'z') nor sub-second precision.
The returned object has a `toUnixTimestamp()` method that can be used to obtain the unix timestamp of the parsed date.


### fn toUnixTimestamp

```ts
toUnixTimestamp(year, month, day, hour, minute, second)
```

`toUnixTimestamp` calculates the unix timestamp of a given date.
88 changes: 88 additions & 0 deletions test/date_test.jsonnet
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,91 @@ test.new(std.thisFile)
expected=166,
)
)

+ test.case.new(
name='toUnixTimestamp of 1970-01-01 00:00:00 (zero)',
test=test.expect.eq(
actual=xtd.date.toUnixTimestamp(1970, 1, 1, 0, 0, 0),
expected=0,
),
)

+ test.case.new(
name='toUnixTimestamp of 1970-01-02 00:00:00 (one day)',
test=test.expect.eq(
actual=xtd.date.toUnixTimestamp(1970, 1, 2, 0, 0, 0),
expected=86400,
),
)

+ test.case.new(
name='toUnixTimestamp of 1971-01-01 00:00:00 (one year)',
test=test.expect.eq(
actual=xtd.date.toUnixTimestamp(1971, 1, 1, 0, 0, 0),
expected=365 * 24 * 3600,
),
)

+ test.case.new(
name='toUnixTimestamp of 1972-03-01 00:00:00 (month of leap year)',
test=test.expect.eq(
actual=xtd.date.toUnixTimestamp(1972, 3, 1, 0, 0, 0),
expected=2 * 365 * 24 * 3600 + 31 * 24 * 3600 + 29 * 24 * 3600,
),
)

+ test.case.new(
name='toUnixTimestamp of 1974-01-01 00:00:00 (incl leap year)',
test=test.expect.eq(
actual=xtd.date.toUnixTimestamp(1974, 1, 1, 0, 0, 0),
expected=(4 * 365 + 1) * 24 * 3600,
),
)

+ test.case.new(
name='toUnixTimestamp of 2020-01-02 03:04:05 (full date)',
test=test.expect.eq(
actual=xtd.date.toUnixTimestamp(2020, 1, 2, 3, 4, 5),
expected=1577934245,
),
)

+ test.case.new(
name='parseRFC3339 of 1970-01-01T00:00:00Z (standard unix zero)',
test=test.expect.eq(
actual=xtd.date.parseRFC3339('1970-01-01T00:00:00Z'),
expected={ year: 1970, month: 1, day: 1, hour: 0, minute: 0, second: 0 },
),
)

+ test.case.new(
name='parseRFC3339 of 2020-01-02T03:04:05Z (non-zero date)',
test=test.expect.eq(
actual=xtd.date.parseRFC3339('2020-01-02T03:04:05Z'),
expected={ year: 2020, month: 1, day: 2, hour: 3, minute: 4, second: 5 },
),
)

+ test.case.new(
name='parseRFC3339 of 2020-01-02 03:04:05Z (space separator)',
test=test.expect.eq(
actual=xtd.date.parseRFC3339('2020-01-02 03:04:05Z'),
expected={ year: 2020, month: 1, day: 2, hour: 3, minute: 4, second: 5 },
),
)

+ test.case.new(
name='parseRFC3339 of 2020-01-02t03:04:05Z (lowercase t separator and lowercase z)',
test=test.expect.eq(
actual=xtd.date.parseRFC3339('2020-01-02t03:04:05z'),
expected={ year: 2020, month: 1, day: 2, hour: 3, minute: 4, second: 5 },
),
)

+ test.case.new(
name='parseRFC3339(..).toUnixTimestamp()',
test=test.expect.eq(
actual=xtd.date.parseRFC3339('2020-01-02T03:04:05Z').toUnixTimestamp(),
expected=1577934245,
),
)
Loading