Skip to content

Commit

Permalink
Add go-date package source.
Browse files Browse the repository at this point in the history
  • Loading branch information
Danny Hermes committed Feb 5, 2024
1 parent e9095c6 commit b391cca
Show file tree
Hide file tree
Showing 16 changed files with 1,570 additions and 2 deletions.
47 changes: 45 additions & 2 deletions README.md
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
95 changes: 95 additions & 0 deletions convert.go
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}
}
150 changes: 150 additions & 0 deletions convert_test.go
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)
})
}
}
Loading

0 comments on commit b391cca

Please sign in to comment.