-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Danny Hermes
committed
Feb 5, 2024
1 parent
e9095c6
commit b391cca
Showing
16 changed files
with
1,570 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,45 @@ | ||
# go-date | ||
Native type for dealing with dates in Go | ||
# `go-date` | ||
|
||
[![GoDoc][1]][2] | ||
[![Go ReportCard][3]][4] | ||
|
||
The `go-date` package provides a dedicated `Date{}` struct to emulate the | ||
standard library `time.Time{}` behavior. | ||
|
||
## API | ||
|
||
This package provides helpers for: | ||
|
||
- conversion: `ToTime()`, `date.FromTime()`, `date.FromString()` | ||
- serialization: JSON and SQL | ||
- emulating `time.Time{}`: `After()`, `Before()`, `Sub()`, etc. | ||
- explicit null handling: `NullDate{}` and an analog of `sql.NullTime{}` | ||
- emulating `time` helpers: `Today()` as an analog of `time.Now()` | ||
|
||
## Background | ||
|
||
The Go standard library contains no native type for dates without times. | ||
Instead, common convention is to use a `time.Time{}` with only the year, month, | ||
and day set. For example, this convention is followed when a timestamp of the | ||
form YYYY-MM-DD is parsed via `time.Parse(time.DateOnly, s)`. | ||
|
||
## Alternatives | ||
|
||
This package is intended to be simple to understand and only needs to cover | ||
"modern" dates (i.e. dates between 1900 and 2100). As a result, the core | ||
`Date{}` struct directly exposes the year, month, and day as fields. | ||
|
||
There are several alternative date packages which cover wider date ranges. | ||
(These packages all use the [proleptic Gregorian calendar][6] to cover the | ||
historical date ranges.) Some existing packages: | ||
|
||
- `github.com/fxtlabs/date` [package][7] | ||
- `github.com/rickb777/date` [package][5] | ||
|
||
[1]: https://godoc.org/github.com/hardfinhq/go-date?status.svg | ||
[2]: http://godoc.org/github.com/hardfinhq/go-date | ||
[3]: https://goreportcard.com/badge/hardfinhq/go-date | ||
[4]: https://goreportcard.com/report/hardfinhq/go-date | ||
[5]: https://pkg.go.dev/github.com/rickb777/date | ||
[6]: https://en.wikipedia.org/wiki/Proleptic_Gregorian_calendar | ||
[7]: https://pkg.go.dev/github.com/fxtlabs/date |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,95 @@ | ||
// Copyright 2024 Hardfin, Inc. | ||
// | ||
// Licensed under the Apache License, Version 2.0 (the "License"); | ||
// you may not use this file except in compliance with the License. | ||
// You may obtain a copy of the License at | ||
// | ||
// https://www.apache.org/licenses/LICENSE-2.0 | ||
// | ||
// Unless required by applicable law or agreed to in writing, software | ||
// distributed under the License is distributed on an "AS IS" BASIS, | ||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
// See the License for the specific language governing permissions and | ||
// limitations under the License. | ||
|
||
package date | ||
|
||
import ( | ||
"database/sql" | ||
"fmt" | ||
"time" | ||
) | ||
|
||
// ConvertConfig helps customize the behavior of conversion functions like | ||
// `NullTimeFromPtr()`. | ||
type ConvertConfig struct { | ||
Timezone *time.Location | ||
} | ||
|
||
// ConvertOption defines a function that will be applied to a convert config. | ||
type ConvertOption func(*ConvertConfig) | ||
|
||
// OptConvertTimezone returns an option that sets the timezone on a convert | ||
// config. | ||
func OptConvertTimezone(tz *time.Location) ConvertOption { | ||
return func(cc *ConvertConfig) { | ||
cc.Timezone = tz | ||
} | ||
} | ||
|
||
// NullDateFromPtr converts a `Date` pointer into a `NullDate`. | ||
func NullDateFromPtr(d *Date) NullDate { | ||
if d == nil { | ||
return NullDate{Valid: false} | ||
} | ||
|
||
return NullDate{Date: *d, Valid: true} | ||
} | ||
|
||
// NullTimeFromPtr converts a date to a native Go `sql.NullTime`; the | ||
// convention in Go is that a **date-only** is parsed (via `time.DateOnly`) as | ||
// `time.Date(YYYY, MM, DD, 0, 0, 0, 0, time.UTC)`. | ||
func NullTimeFromPtr(d *Date, opts ...ConvertOption) sql.NullTime { | ||
if d == nil { | ||
return sql.NullTime{Valid: false} | ||
} | ||
|
||
t := d.ToTime(opts...) | ||
return sql.NullTime{Time: t, Valid: true} | ||
} | ||
|
||
// FromString parses a string of the form YYYY-MM-DD into a `Date{}`. | ||
func FromString(s string) (Date, error) { | ||
t, err := time.Parse(time.DateOnly, s) | ||
if err != nil { | ||
return Date{}, err | ||
} | ||
|
||
year, month, day := t.Date() | ||
d := Date{Year: year, Month: month, Day: day} | ||
return d, nil | ||
} | ||
|
||
// FromTime validates that a `time.Time{}` contains a date and converts it to a | ||
// `Date{}`. | ||
func FromTime(t time.Time) (Date, error) { | ||
if t.Hour() != 0 || | ||
t.Minute() != 0 || | ||
t.Second() != 0 || | ||
t.Nanosecond() != 0 || | ||
t.Location() != time.UTC { | ||
return Date{}, fmt.Errorf("timestamp contains more than just date information; %s", t.Format(time.RFC3339Nano)) | ||
} | ||
|
||
year, month, day := t.Date() | ||
d := Date{Year: year, Month: month, Day: day} | ||
return d, nil | ||
} | ||
|
||
// InTimezone translates a timestamp into a timezone and then captures the date | ||
// in that timezone. | ||
func InTimezone(t time.Time, tz *time.Location) Date { | ||
tLocal := t.In(tz) | ||
year, month, day := tLocal.Date() | ||
return Date{Year: year, Month: month, Day: day} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,150 @@ | ||
// Copyright 2024 Hardfin, Inc. | ||
// | ||
// Licensed under the Apache License, Version 2.0 (the "License"); | ||
// you may not use this file except in compliance with the License. | ||
// You may obtain a copy of the License at | ||
// | ||
// https://www.apache.org/licenses/LICENSE-2.0 | ||
// | ||
// Unless required by applicable law or agreed to in writing, software | ||
// distributed under the License is distributed on an "AS IS" BASIS, | ||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
// See the License for the specific language governing permissions and | ||
// limitations under the License. | ||
|
||
package date_test | ||
|
||
import ( | ||
"database/sql" | ||
"fmt" | ||
"testing" | ||
"time" | ||
|
||
testifyrequire "github.com/stretchr/testify/require" | ||
|
||
date "github.com/hardfinhq/go-date" | ||
) | ||
|
||
func TestNullDateFromPtr(t *testing.T) { | ||
t.Parallel() | ||
assert := testifyrequire.New(t) | ||
|
||
d1 := &date.Date{Year: 2000, Month: time.January, Day: 1} | ||
nd1 := date.NullDateFromPtr(d1) | ||
expected := date.NullDate{Date: *d1, Valid: true} | ||
assert.Equal(expected, nd1) | ||
|
||
var d2 *date.Date | ||
nd2 := date.NullDateFromPtr(d2) | ||
expected = date.NullDate{Valid: false} | ||
assert.Equal(expected, nd2) | ||
} | ||
|
||
func TestNullTimeFromPtr(t *testing.T) { | ||
t.Parallel() | ||
assert := testifyrequire.New(t) | ||
|
||
var d *date.Date | ||
nt := date.NullTimeFromPtr(d) | ||
expected := sql.NullTime{Valid: false} | ||
assert.Equal(expected, nt) | ||
|
||
d = &date.Date{Year: 2000, Month: time.January, Day: 1} | ||
nt = date.NullTimeFromPtr(d) | ||
expected = sql.NullTime{Time: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC), Valid: true} | ||
assert.Equal(expected, nt) | ||
|
||
tz, err := time.LoadLocation("America/Chicago") | ||
assert.Nil(err) | ||
nt = date.NullTimeFromPtr(d, date.OptConvertTimezone(tz)) | ||
expected = sql.NullTime{Time: time.Date(2000, time.January, 1, 0, 0, 0, 0, tz), Valid: true} | ||
assert.Equal(expected, nt) | ||
} | ||
|
||
func TestFromTime(base *testing.T) { | ||
base.Parallel() | ||
|
||
type testCase struct { | ||
Time string | ||
Date date.Date | ||
Error string | ||
} | ||
|
||
cases := []testCase{ | ||
{ | ||
Time: "2020-05-11T07:10:55.209309302Z", | ||
Error: "timestamp contains more than just date information; 2020-05-11T07:10:55.209309302Z", | ||
}, | ||
{ | ||
Time: "2022-01-31T00:00:00.000Z", | ||
Date: date.Date{Year: 2022, Month: time.January, Day: 31}, | ||
}, | ||
{ | ||
Time: "2022-01-31T00:00:00.000-05:00", | ||
Error: "timestamp contains more than just date information; 2022-01-31T00:00:00-05:00", | ||
}, | ||
} | ||
|
||
for i := range cases { | ||
// NOTE: Assign to loop-local (instead of declaring the `tc` variable in | ||
// `range`) to avoid capturing reference to loop variable. | ||
tc := cases[i] | ||
base.Run(tc.Time, func(t *testing.T) { | ||
t.Parallel() | ||
assert := testifyrequire.New(t) | ||
|
||
timestamp, err := time.Parse(time.RFC3339Nano, tc.Time) | ||
assert.Nil(err) | ||
|
||
d, err := date.FromTime(timestamp) | ||
if tc.Error == "" { | ||
assert.Nil(err) | ||
assert.Equal(tc.Date, d) | ||
} else { | ||
assert.Equal(tc.Error, fmt.Sprintf("%v", err)) | ||
assert.Equal(date.Date{}, d) | ||
} | ||
}) | ||
} | ||
} | ||
|
||
func TestInTimezone(base *testing.T) { | ||
base.Parallel() | ||
|
||
type testCase struct { | ||
Time string | ||
Timezone string | ||
Date string | ||
} | ||
|
||
cases := []testCase{ | ||
{Time: "2024-02-01T06:41:35.540349Z", Timezone: "America/Los_Angeles", Date: "2024-01-31"}, | ||
{Time: "2024-02-01T06:41:35.540349Z", Timezone: "America/Denver", Date: "2024-01-31"}, | ||
{Time: "2024-02-01T06:41:35.540349Z", Timezone: "America/Chicago", Date: "2024-02-01"}, | ||
{Time: "2024-02-01T06:41:35.540349Z", Timezone: "America/New_York", Date: "2024-02-01"}, | ||
{Time: "2024-02-01T06:41:35.540349Z", Timezone: "UTC", Date: "2024-02-01"}, | ||
} | ||
|
||
for i := range cases { | ||
// NOTE: Assign to loop-local (instead of declaring the `tc` variable in | ||
// `range`) to avoid capturing reference to loop variable. | ||
tc := cases[i] | ||
description := fmt.Sprintf("%s::%s", tc.Time, tc.Timezone) | ||
base.Run(description, func(t *testing.T) { | ||
t.Parallel() | ||
assert := testifyrequire.New(t) | ||
|
||
timestamp, err := time.Parse(time.RFC3339Nano, tc.Time) | ||
assert.Nil(err) | ||
|
||
tz, err := time.LoadLocation(tc.Timezone) | ||
assert.Nil(err) | ||
|
||
expected, err := date.FromString(tc.Date) | ||
assert.Nil(err) | ||
|
||
d := date.InTimezone(timestamp, tz) | ||
assert.Equal(expected, d) | ||
}) | ||
} | ||
} |
Oops, something went wrong.