diff --git a/README.md b/README.md index feffe15..50daf4b 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ standard library `time.Time{}` behavior. This package provides helpers for: - conversion: `ToTime()`, `date.FromTime()`, `date.FromString()` -- serialization: JSON and SQL +- serialization: text, 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()` @@ -22,7 +22,190 @@ This package provides helpers for: 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)`. +form YYYY-MM-DD is parsed via `time.Parse(time.DateOnly, value)`. + +## Conversion + +For cases where existing code produces a "conventional" +`time.Date(YYYY, MM, DD, 0, 0, 0, 0, time.UTC)` value, it can be validated +and converted to a `Date{}` via: + +```go +t := time.Date(2024, time.March, 1, 0, 0, 0, 0, time.UTC) +d, err := date.FromTime(t) +fmt.Println(d, err) +// 2024-03-01 +``` + +If there is any deviation from the "conventional" format, this will error. +For example: + +```text +timestamp contains more than just date information; 2020-05-11T01:00:00Z +timestamp contains more than just date information; 2022-01-31T00:00:00-05:00 +``` + +For cases where we have a discrete timestamp (e.g. "last updated datetime") and +a relevant timezone for a given request, we can extract the date within that +timezone: + +```go +t := time.Date(2023, time.April, 14, 3, 55, 4, 777000100, time.UTC) +tz, _ := time.LoadLocation("America/Chicago") +d := date.InTimezone(t, tz) +fmt.Println(d) +// 2023-04-13 +``` + +For conversion in the **other** direction, a `Date{}` can be converted back +into a `time.Time{}`: + +```go +d := date.NewDate(2017, time.July, 3) +t := d.ToTime() +fmt.Println(t) +// 2017-07-03 00:00:00 +0000 UTC +``` + +By default this will use the "conventional" format, but any of the values +(other than year, month, day) can also be set: + +```go +d := date.NewDate(2017, time.July, 3) +tz, _ := time.LoadLocation("America/Chicago") +t := d.ToTime(date.OptConvertHour(12), date.OptConvertTimezone(tz)) +fmt.Println(t) +// 2017-07-03 12:00:00 -0500 CDT +``` + +## Equivalent methods + +There are a number of methods from `time.Time{}` that directly translate over: + +```go +d := date.NewDate(2020, time.February, 29) +fmt.Println(d.Year) +// 2020 +fmt.Println(d.Month) +// February +fmt.Println(d.Day) +// 29 +fmt.Println(d.ISOWeek()) +// 2020 9 +fmt.Println(d.Weekday()) +// Saturday + +fmt.Println(d.IsZero()) +// false +fmt.Println(d.String()) +// 2020-02-29 +fmt.Println(d.Format("Jan 2006")) +// Feb 2020 +fmt.Println(d.GoString()) +// date.NewDate(2020, time.February, 29) + +d2 := date.NewDate(2021, time.February, 28) +fmt.Println(d2.Equal(d)) +// false +fmt.Println(d2.Before(d)) +// false +fmt.Println(d2.After(d)) +// true +fmt.Println(d2.Compare(d)) +// 1 +``` + +However, some methods translate over only approximately. For example, it's much +more natural for `Sub()` to return the **number of days** between two dates: + +```go +d := date.NewDate(2020, time.February, 29) +d2 := date.NewDate(2021, time.February, 28) +fmt.Println(d2.Sub(d)) +// 365 +``` + +## Divergent methods + +We've elected to **translate** the `time.Time{}.AddDate()` method rather +than providing it directly: + +```go +d := date.NewDate(2020, time.February, 29) +fmt.Println(d.AddDays(1)) +// 2020-03-01 +fmt.Println(d.AddDays(100)) +// 2020-06-08 +fmt.Println(d.AddMonths(1)) +// 2020-03-29 +fmt.Println(d.AddMonths(3)) +// 2020-05-29 +fmt.Println(d.AddYears(1)) +// 2021-02-28 +``` + +This is in part because of the behavior of the standard library's +`AddDate()`. In particular, it "overflows" a target month if the number +of days in that month is less than the number of desired days. As a result, +we provide `*Stdlib()` variants of the date addition helpers: + +```go +d := date.NewDate(2020, time.February, 29) +fmt.Println(d.AddMonths(12)) +// 2021-02-28 +fmt.Println(d.AddMonthsStdlib(12)) +// 2021-03-01 +fmt.Println(d.AddYears(1)) +// 2021-02-28 +fmt.Println(d.AddYearsStdlib(1)) +// 2021-03-01 +``` + +In the same line of thinking as the divergent `AddMonths()` behavior, a +`MonthEnd()` method is provided that can pinpoint the number of days in +the current month: + +```go +d := date.NewDate(2022, time.January, 14) +fmt.Println(d.MonthEnd()) +// 2022-01-31 +fmt.Println(d.MonthStart()) +// 2022-01-01 +``` + +## Integrating with `sqlc` + +Out of the box, the `sqlc` [library][10] uses a Go `time.Time{}` both for +columns of type `TIMESTAMPTZ` and `DATE`. When reading `DATE` values (which come +over the wire in the form YYYY-MM-DD), the Go standard library produces values +of the form: + +```go +time.Date(YYYY, MM, DD, 0, 0, 0, 0, time.UTC) +``` + +Instead, we can instruct `sqlc` to **globally** use `date.Date` and +`date.NullDate` when parsing `DATE` columns: + +```yaml +--- +version: '2' +overrides: + go: + overrides: + - go_type: + import: github.com/hardfinhq/go-date + package: date + type: NullDate + db_type: date + nullable: true + - go_type: + import: github.com/hardfinhq/go-date + package: date + type: Date + db_type: date + nullable: false +``` ## Alternatives @@ -37,6 +220,12 @@ historical date ranges.) Some existing packages: - `github.com/fxtlabs/date` [package][7] - `github.com/rickb777/date` [package][5] +Additionally, there is a `Date{}` type provided by the `github.com/jackc/pgtype` +[package][11] that is part of the `pgx` ecosystem. However, this type is very +focused on being useful for database serialization and deserialization and +doesn't implement a wider set of methods present on `time.Time{}` (e.g. +`After()`). + [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 @@ -46,3 +235,5 @@ historical date ranges.) Some existing packages: [7]: https://pkg.go.dev/github.com/fxtlabs/date [8]: https://github.com/hardfinhq/go-date/actions/workflows/ci.yaml/badge.svg?branch=main [9]: https://github.com/hardfinhq/go-date/actions/workflows/ci.yaml +[10]: https://docs.sqlc.dev +[11]: https://pkg.go.dev/github.com/jackc/pgtype diff --git a/convert.go b/convert.go index 886fc88..f3c3b46 100644 --- a/convert.go +++ b/convert.go @@ -22,13 +22,55 @@ import ( // ConvertConfig helps customize the behavior of conversion functions like // `NullTimeFromPtr()`. +// +// It allows setting the fields in a `time.Time{}` **other** than year, month, +// and day (i.e. the fields that aren't present in a date). By default, these +// are: +// - hour=0 +// - minute=0 +// - second=0 +// - nanosecond=0 +// - timezone/loc=time.UTC type ConvertConfig struct { - Timezone *time.Location + Hour int + Minute int + Second int + Nanosecond int + Timezone *time.Location } // ConvertOption defines a function that will be applied to a convert config. type ConvertOption func(*ConvertConfig) +// OptConvertHour returns an option that sets the hour on a convert config. +func OptConvertHour(hour int) ConvertOption { + return func(cc *ConvertConfig) { + cc.Hour = hour + } +} + +// OptConvertMinute returns an option that sets the minute on a convert config. +func OptConvertMinute(minute int) ConvertOption { + return func(cc *ConvertConfig) { + cc.Minute = minute + } +} + +// OptConvertSecond returns an option that sets the second on a convert config. +func OptConvertSecond(second int) ConvertOption { + return func(cc *ConvertConfig) { + cc.Second = second + } +} + +// OptConvertNanosecond returns an option that sets the nanosecond on a convert +// config. +func OptConvertNanosecond(nanosecond int) ConvertOption { + return func(cc *ConvertConfig) { + cc.Nanosecond = nanosecond + } +} + // OptConvertTimezone returns an option that sets the timezone on a convert // config. func OptConvertTimezone(tz *time.Location) ConvertOption { diff --git a/convert_test.go b/convert_test.go index 11532e7..172e0c6 100644 --- a/convert_test.go +++ b/convert_test.go @@ -59,6 +59,16 @@ func TestNullTimeFromPtr(t *testing.T) { 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) + + nt = date.NullTimeFromPtr( + d, + date.OptConvertHour(12), + date.OptConvertMinute(30), + date.OptConvertSecond(35), + date.OptConvertNanosecond(123456789), + ) + expected = sql.NullTime{Time: time.Date(2000, time.January, 1, 12, 30, 35, 123456789, time.UTC), Valid: true} + assert.Equal(expected, nt) } func TestFromTime(base *testing.T) { @@ -71,18 +81,34 @@ func TestFromTime(base *testing.T) { } 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: "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.000-05:00", Error: "timestamp contains more than just date information; 2022-01-31T00:00:00-05:00", }, + { + Time: "2020-05-11T00:00:00.000000001Z", + Error: "timestamp contains more than just date information; 2020-05-11T00:00:00.000000001Z", + }, + { + Time: "2020-05-11T00:00:01Z", + Error: "timestamp contains more than just date information; 2020-05-11T00:00:01Z", + }, + { + Time: "2020-05-11T00:01:00Z", + Error: "timestamp contains more than just date information; 2020-05-11T00:01:00Z", + }, + { + Time: "2020-05-11T01:00:00Z", + Error: "timestamp contains more than just date information; 2020-05-11T01:00:00Z", + }, } for i := range cases { diff --git a/date.go b/date.go index 85eb180..0e0c028 100644 --- a/date.go +++ b/date.go @@ -17,23 +17,30 @@ package date import ( "database/sql" "database/sql/driver" + "encoding" "encoding/json" "fmt" "time" ) // NOTE: Ensure that -// - `*Date` satisfies `fmt.Stringer`. +// - `Date` satisfies `fmt.Stringer`. +// - `Date` satisfies `fmt.GoStringer`. +// - `Date` satisfies `encoding.TextMarshaler`. // - `Date` satisfies `json.Marshaler`. +// - `*Date` satisfies `encoding.TextUnmarshaler`. // - `*Date` satisfies `json.Unmarshaler`. // - `*Date` satisfies `sql.Scanner`. // - `Date` satisfies `driver.Valuer`. var ( - _ fmt.Stringer = (*Date)(nil) - _ json.Marshaler = Date{} - _ json.Unmarshaler = (*Date)(nil) - _ sql.Scanner = (*Date)(nil) - _ driver.Valuer = Date{} + _ fmt.Stringer = Date{} + _ fmt.GoStringer = Date{} + _ encoding.TextMarshaler = Date{} + _ json.Marshaler = Date{} + _ encoding.TextUnmarshaler = (*Date)(nil) + _ json.Unmarshaler = (*Date)(nil) + _ sql.Scanner = (*Date)(nil) + _ driver.Valuer = Date{} ) // Date is a simple date (i.e. without timestamp). This is intended to be @@ -103,12 +110,6 @@ func monthsChange(month time.Month, monthDelta int) (time.Month, int) { return time.Month(monthsInYear), yearDelta } -// MonthEnd returns the last date in the month of the current date. -func (d Date) MonthEnd() Date { - endDay := daysIn(d.Month, d.Year) - return Date{Year: d.Year, Month: d.Month, Day: endDay} -} - // AddYears returns the date corresponding to adding the given number of // years, using `time.Time{}.AddDate()` from the standard library. This may // "overshoot" if the target date is not a valid date in that month, e.g. @@ -123,7 +124,10 @@ func (d Date) MonthEnd() Date { // NOTE: This behavior is very similar to but distinct from // `time.Time{}.AddDate()` specialized to `years` only. func (d Date) AddYears(years int) Date { - return d.AddMonths(12 * years) + updatedMonth := d.Month + updatedYear := d.Year + years + updatedDay := minInt(d.Day, daysIn(updatedMonth, updatedYear)) + return Date{Year: updatedYear, Month: updatedMonth, Day: updatedDay} } // AddYearsStdlib returns the date corresponding to adding the given number of @@ -140,16 +144,45 @@ func (d Date) AddYears(years int) Date { // NOTE: This behavior is very similar to but distinct from // `time.Time{}.AddDate()` specialized to `years` only. func (d Date) AddYearsStdlib(years int) Date { - return d.AddMonthsStdlib(12 * years) + t := d.ToTime().AddDate(years, 0, 0) + return Date{Year: t.Year(), Month: t.Month(), Day: t.Day()} } -// String implements `fmt.Stringer`. -func (d *Date) String() string { - if d == nil { - return "" +// Sub returns the number of days `d - other`; this converts both dates to +// a `time.Time{}` UTC and then dispatches to `time.Time{}.Sub()`. +func (d Date) Sub(other Date) int64 { + days, err := d.SubErr(other) + mustNil(err) + return int64(days) +} + +// SubErr returns the number of days `d - other`; this converts both dates to +// a `time.Time{}` UTC and then dispatches to `time.Time{}.Sub()`. +// +// If the number of days is not a whole number (due to overflow), an error is +// returned. +func (d Date) SubErr(other Date) (int64, error) { + duration := d.ToTime().Sub(other.ToTime()) + + day := 24 * time.Hour + days := duration / day + remainder := duration % day + if remainder != 0 { + return 0, fmt.Errorf("duration is not a whole number of days; duration=%s", duration) } - return d.Format(time.DateOnly) + return int64(days), nil +} + +// MonthStart returns the first date in the month of the current date. +func (d Date) MonthStart() Date { + return Date{Year: d.Year, Month: d.Month, Day: 1} +} + +// MonthEnd returns the last date in the month of the current date. +func (d Date) MonthEnd() Date { + endDay := daysIn(d.Month, d.Year) + return Date{Year: d.Year, Month: d.Month, Day: endDay} } // Before returns true if the date is before the other date. @@ -175,35 +208,35 @@ func (d Date) Equal(other Date) bool { return d.Year == other.Year && d.Month == other.Month && d.Day == other.Day } -// IsZero returns true if the date is the zero value. -func (d Date) IsZero() bool { - return d.Year == 0 && d.Month == 0 && d.Day == 0 -} +func compareInt(i1, i2 int) int { + if i1 < i2 { + return -1 + } -// Sub returns the number of days `d - other`; this converts both dates to -// a `time.Time{}` UTC and then dispatches to `time.Time{}.Sub()`. -func (d Date) Sub(other Date) int64 { - days, err := d.SubErr(other) - mustNil(err) - return int64(days) + if i1 > i2 { + return 1 + } + + return 0 } -// SubErr returns the number of days `d - other`; this converts both dates to -// a `time.Time{}` UTC and then dispatches to `time.Time{}.Sub()`. -// -// If the number of days is not a whole number (due to overflow), an error is -// returned. -func (d Date) SubErr(other Date) (int64, error) { - duration := d.ToTime().Sub(other.ToTime()) +// Compare compares the date d with other. If d is before other, it returns +// -1; if d is after other, it returns +1; if they're the same, it returns 0. +func (d Date) Compare(other Date) int { + if d.Year != other.Year { + return compareInt(d.Year, other.Year) + } - day := 24 * time.Hour - days := duration / day - remainder := duration % day - if remainder != 0 { - return 0, fmt.Errorf("duration is not a whole number of days; duration=%s", duration) + if d.Month != other.Month { + return compareInt(int(d.Month), int(other.Month)) } - return int64(days), nil + return compareInt(d.Day, other.Day) +} + +// IsZero returns true if the date is the zero value. +func (d Date) IsZero() bool { + return d.Year == 0 && d.Month == 0 && d.Day == 0 } // ToTime converts the date to a native Go `time.Time`; the convention in Go is @@ -215,7 +248,25 @@ func (d Date) ToTime(opts ...ConvertOption) time.Time { opt(&cc) } - return time.Date(d.Year, d.Month, d.Day, 0, 0, 0, 0, cc.Timezone) + return time.Date(d.Year, d.Month, d.Day, cc.Hour, cc.Minute, cc.Second, cc.Nanosecond, cc.Timezone) +} + +// ISOWeek returns the ISO 8601 year and week number in which `d` occurs. +// Week ranges from 1 to 53. Jan 01 to Jan 03 of year `n` might belong to +// week 52 or 53 of year `n-1`, and Dec 29 to Dec 31 might belong to week 1 +// of year `n+1`. +func (d Date) ISOWeek() (year, week int) { + return d.ToTime().ISOWeek() +} + +// Weekday returns the day of the week specified by `d`. +func (d Date) Weekday() time.Weekday { + return d.ToTime().Weekday() +} + +// MarshalText implements the encoding.TextMarshaler interface. +func (d Date) MarshalText() ([]byte, error) { + return []byte(d.String()), nil } // MarshalJSON implements `json.Marshaler`; formats the date as YYYY-MM-DD. @@ -224,6 +275,18 @@ func (d Date) MarshalJSON() ([]byte, error) { return json.Marshal(s) } +// UnmarshalText implements the encoding.TextUnmarshaler interface. The time +// must be in the format YYYY-MM-DD. +func (d *Date) UnmarshalText(data []byte) error { + parsed, err := FromString(string(data)) + if err != nil { + return err + } + + *d = parsed + return nil +} + // UnmarshalJSON implements `json.Unmarshaler`; parses the date as YYYY-MM-DD. func (d *Date) UnmarshalJSON(data []byte) error { s := "" @@ -268,9 +331,19 @@ func (d Date) Value() (driver.Value, error) { return d.ToTime(), nil } +// String implements `fmt.Stringer`. +func (d Date) String() string { + return d.Format(time.DateOnly) +} + // Format returns a textual representation of the date value formatted according // to the provided layout. This uses `time.Time{}.Format()` directly and is // provided here for convenience. func (d Date) Format(layout string) string { return d.ToTime().Format(layout) } + +// GoString implements `fmt.GoStringer`. +func (d Date) GoString() string { + return fmt.Sprintf("date.NewDate(%d, time.%s, %d)", d.Year, d.Month, d.Day) +} diff --git a/date_test.go b/date_test.go index 7dbc509..b9a0e9c 100644 --- a/date_test.go +++ b/date_test.go @@ -163,38 +163,6 @@ func TestDate_AddMonthsStdlib(base *testing.T) { } } -func TestDate_MonthEnd(base *testing.T) { - base.Parallel() - - type testCase struct { - Date string - Expected string - } - - cases := []testCase{ - {Date: "2020-02-16", Expected: "2020-02-29"}, - {Date: "2021-02-16", Expected: "2021-02-28"}, - {Date: "2023-01-01", Expected: "2023-01-31"}, - } - 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.Date, func(t *testing.T) { - t.Parallel() - assert := testifyrequire.New(t) - - d, err := date.FromString(tc.Date) - assert.Nil(err) - expected, err := date.FromString(tc.Expected) - assert.Nil(err) - - shifted := d.MonthEnd() - assert.Equal(expected, shifted) - }) - } -} - func TestDate_AddYears(base *testing.T) { base.Parallel() @@ -281,30 +249,125 @@ func TestDate_AddYearsStdlib(base *testing.T) { } } -func TestDate_String(base *testing.T) { +func TestDate_Sub(base *testing.T) { base.Parallel() type testCase struct { - Date *date.Date + Date string + Other string + Expected int64 + } + + cases := []testCase{ + {Date: "2020-05-11", Other: "2020-05-11", Expected: 0}, + {Date: "2020-05-11", Other: "2020-05-12", Expected: -1}, + {Date: "2020-05-11", Other: "2020-05-10", Expected: 1}, + {Date: "2020-05-11", Other: "2002-05-11", Expected: 6575}, + {Date: "2020-05-11", Other: "2022-05-11", Expected: -730}, + {Date: "2020-05-11", Other: "2012-01-31", Expected: 3023}, + {Date: "2016-04-17", Other: "2020-05-12", Expected: -1486}, + {Date: "2020-05-22", Other: "2020-05-10", Expected: 12}, + {Date: "2023-05-03", Other: "2002-05-11", Expected: 7662}, + {Date: "2013-05-19", Other: "2022-05-11", Expected: -3279}, + {Date: "2012-02-28", Other: "2012-01-31", Expected: 28}, + } + + 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.Date, tc.Other) + base.Run(description, func(t *testing.T) { + t.Parallel() + assert := testifyrequire.New(t) + + d, err := date.FromString(tc.Date) + assert.Nil(err) + + other, err := date.FromString(tc.Other) + assert.Nil(err) + + computed := d.Sub(other) + assert.Equal(tc.Expected, computed) + }) + } +} + +func TestDate_Sub_Panic(t *testing.T) { + t.Parallel() + assert := testifyrequire.New(t) + + d1 := date.Date{Year: 1, Month: time.January, Day: 1} + d2 := date.Date{Year: 1_000_000, Month: time.January, Day: 1} + + assert.Panics(func() { d1.Sub(d2) }) + + days, err := d1.SubErr(d2) + assert.Equal(int64(0), days) + assert.NotNil(err) + assert.Equal("duration is not a whole number of days; duration=-2562047h47m16.854775808s", fmt.Sprintf("%v", err)) +} + +func TestDate_MonthStart(base *testing.T) { + base.Parallel() + + type testCase struct { + Date string Expected string } cases := []testCase{ - {Date: &date.Date{Year: 2020, Month: time.May, Day: 11}, Expected: "2020-05-11"}, - {Date: &date.Date{Year: 2022, Month: time.January, Day: 31}, Expected: "2022-01-31"}, - {Date: &date.Date{Year: 1999, Month: time.December, Day: 24}, Expected: "1999-12-24"}, - {Date: nil, Expected: ""}, + {Date: "2020-02-16", Expected: "2020-02-01"}, + {Date: "2021-02-16", Expected: "2021-02-01"}, + {Date: "2023-01-01", Expected: "2023-01-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] + base.Run(tc.Date, func(t *testing.T) { + t.Parallel() + assert := testifyrequire.New(t) + + d, err := date.FromString(tc.Date) + assert.Nil(err) + expected, err := date.FromString(tc.Expected) + assert.Nil(err) + shifted := d.MonthStart() + assert.Equal(expected, shifted) + }) + } +} + +func TestDate_MonthEnd(base *testing.T) { + base.Parallel() + + type testCase struct { + Date string + Expected string + } + + cases := []testCase{ + {Date: "2020-02-16", Expected: "2020-02-29"}, + {Date: "2021-02-16", Expected: "2021-02-28"}, + {Date: "2023-01-01", Expected: "2023-01-31"}, + } 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.Expected, func(t *testing.T) { + base.Run(tc.Date, func(t *testing.T) { t.Parallel() assert := testifyrequire.New(t) - assert.Equal(tc.Expected, tc.Date.String()) + d, err := date.FromString(tc.Date) + assert.Nil(err) + expected, err := date.FromString(tc.Expected) + assert.Nil(err) + + shifted := d.MonthEnd() + assert.Equal(expected, shifted) }) } } @@ -357,6 +420,22 @@ func TestDate_Equal(t *testing.T) { assert.False(d1.Equal(d2)) } +func TestDate_Compare(t *testing.T) { + t.Parallel() + assert := testifyrequire.New(t) + + d1 := date.Date{Year: 2023, Month: time.July, Day: 27} + d2 := date.Date{Year: 2018, Month: time.January, Day: 1} + d3 := date.Date{Year: 2023, Month: time.July, Day: 27} + d4 := date.Date{Year: 2023, Month: time.August, Day: 27} + assert.Equal(0, d1.Compare(d1)) + assert.Equal(0, d2.Compare(d2)) + assert.Equal(0, d1.Compare(d3)) + assert.Equal(-1, d2.Compare(d1)) + assert.Equal(1, d1.Compare(d2)) + assert.Equal(-1, d1.Compare(d4)) +} + func TestDate_IsZero(t *testing.T) { t.Parallel() assert := testifyrequire.New(t) @@ -369,79 +448,92 @@ func TestDate_IsZero(t *testing.T) { assert.False(d3.IsZero()) } -func TestDate_Sub(base *testing.T) { +func TestDate_ToTime(t *testing.T) { + t.Parallel() + assert := testifyrequire.New(t) + + d := date.Date{Year: 2006, Month: time.February, Day: 16} + converted := d.ToTime() + expected := time.Time(time.Date(2006, time.February, 16, 0, 0, 0, 0, time.UTC)) + assert.Equal(expected, converted) + + tz, err := time.LoadLocation("America/Chicago") + assert.Nil(err) + converted = d.ToTime(date.OptConvertTimezone(tz)) + expected = time.Time(time.Date(2006, time.February, 16, 0, 0, 0, 0, tz)) + assert.Equal(expected, converted) +} + +func TestDate_ISOWeek(t *testing.T) { + t.Parallel() + assert := testifyrequire.New(t) + + d := date.Date{Year: 2006, Month: time.February, Day: 16} + year, week := d.ISOWeek() + assert.Equal(2006, year) + assert.Equal(7, week) +} + +func TestDate_Weekday(base *testing.T) { base.Parallel() type testCase struct { - Date string - Other string - Expected int64 + Date date.Date + Expected time.Weekday } cases := []testCase{ - {Date: "2020-05-11", Other: "2020-05-11", Expected: 0}, - {Date: "2020-05-11", Other: "2020-05-12", Expected: -1}, - {Date: "2020-05-11", Other: "2020-05-10", Expected: 1}, - {Date: "2020-05-11", Other: "2002-05-11", Expected: 6575}, - {Date: "2020-05-11", Other: "2022-05-11", Expected: -730}, - {Date: "2020-05-11", Other: "2012-01-31", Expected: 3023}, - {Date: "2016-04-17", Other: "2020-05-12", Expected: -1486}, - {Date: "2020-05-22", Other: "2020-05-10", Expected: 12}, - {Date: "2023-05-03", Other: "2002-05-11", Expected: 7662}, - {Date: "2013-05-19", Other: "2022-05-11", Expected: -3279}, - {Date: "2012-02-28", Other: "2012-01-31", Expected: 28}, + {Date: date.Date{Year: 2023, Month: time.January, Day: 1}, Expected: time.Sunday}, + {Date: date.Date{Year: 2023, Month: time.January, Day: 2}, Expected: time.Monday}, + {Date: date.Date{Year: 2023, Month: time.January, Day: 3}, Expected: time.Tuesday}, + {Date: date.Date{Year: 2023, Month: time.January, Day: 4}, Expected: time.Wednesday}, + {Date: date.Date{Year: 2023, Month: time.January, Day: 5}, Expected: time.Thursday}, + {Date: date.Date{Year: 2023, Month: time.January, Day: 6}, Expected: time.Friday}, + {Date: date.Date{Year: 2023, Month: time.January, Day: 7}, Expected: time.Saturday}, + {Date: date.Date{Year: 2023, Month: time.January, Day: 8}, Expected: time.Sunday}, } 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.Date, tc.Other) - base.Run(description, func(t *testing.T) { + base.Run(tc.Date.String(), func(t *testing.T) { t.Parallel() assert := testifyrequire.New(t) - d, err := date.FromString(tc.Date) - assert.Nil(err) - - other, err := date.FromString(tc.Other) - assert.Nil(err) - - computed := d.Sub(other) - assert.Equal(tc.Expected, computed) + weekday := tc.Date.Weekday() + assert.Equal(tc.Expected, weekday) }) } } -func TestDate_Sub_Panic(t *testing.T) { - t.Parallel() - assert := testifyrequire.New(t) - - d1 := date.Date{Year: 1, Month: time.January, Day: 1} - d2 := date.Date{Year: 1_000_000, Month: time.January, Day: 1} - - assert.Panics(func() { d1.Sub(d2) }) +func TestDate_MarshalText(base *testing.T) { + base.Parallel() - days, err := d1.SubErr(d2) - assert.Equal(int64(0), days) - assert.NotNil(err) - assert.Equal("duration is not a whole number of days; duration=-2562047h47m16.854775808s", fmt.Sprintf("%v", err)) -} + type testCase struct { + Name string + Date date.Date + Expected string + } -func TestDate_ToTime(t *testing.T) { - t.Parallel() - assert := testifyrequire.New(t) + cases := []testCase{ + {Name: "Remote past", Date: date.Date{Year: 1997, Month: time.July, Day: 15}, Expected: "1997-07-15"}, + {Name: "Recent past", Date: date.Date{Year: 2020, Month: time.February, Day: 20}, Expected: "2020-02-20"}, + } - d := date.Date{Year: 2006, Month: time.February, Day: 16} - converted := d.ToTime() - expected := time.Time(time.Date(2006, time.February, 16, 0, 0, 0, 0, time.UTC)) - assert.Equal(expected, converted) + 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.Name, func(t *testing.T) { + t.Parallel() + assert := testifyrequire.New(t) - tz, err := time.LoadLocation("America/Chicago") - assert.Nil(err) - converted = d.ToTime(date.OptConvertTimezone(tz)) - expected = time.Time(time.Date(2006, time.February, 16, 0, 0, 0, 0, tz)) - assert.Equal(expected, converted) + asBytes, err := tc.Date.MarshalText() + assert.Nil(err) + assert.Equal(tc.Expected, string(asBytes)) + }) + } } func TestDate_MarshalJSON(base *testing.T) { @@ -474,6 +566,44 @@ func TestDate_MarshalJSON(base *testing.T) { } } +func TestDate_UnmarshalText(base *testing.T) { + base.Parallel() + + type testCase struct { + Input []byte + Date date.Date + Error string + } + + cases := []testCase{ + {Input: []byte(`x`), Error: `parsing time "x" as "2006-01-02": cannot parse "x" as "2006"`}, + {Input: []byte(`10`), Error: `parsing time "10" as "2006-01-02": cannot parse "10" as "2006"`}, + {Input: []byte("01/26/2018"), Error: `parsing time "01/26/2018" as "2006-01-02": cannot parse "01/26/2018" as "2006"`}, + {Input: []byte("1997-07-15"), Date: date.Date{Year: 1997, Month: time.July, Day: 15}}, + {Input: []byte("2020-02-20"), Date: date.Date{Year: 2020, Month: time.February, Day: 20}}, + } + + 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(string(tc.Input), func(t *testing.T) { + t.Parallel() + assert := testifyrequire.New(t) + + d := date.Date{} + err := d.UnmarshalText(tc.Input) + if err != nil { + assert.Equal(tc.Error, fmt.Sprintf("%v", err)) + assert.Equal(date.Date{}, d) + } else { + assert.Equal("", tc.Error) + assert.Equal(tc.Date, d) + } + }) + } +} + func TestDate_UnmarshalJSON(base *testing.T) { base.Parallel() @@ -553,3 +683,57 @@ func TestDate_Value(t *testing.T) { expected := time.Date(1991, time.April, 26, 0, 0, 0, 0, time.UTC) assert.Equal(expected, v) } + +func TestDate_String(base *testing.T) { + base.Parallel() + + type testCase struct { + Date date.Date + Expected string + } + + cases := []testCase{ + {Date: date.Date{Year: 2020, Month: time.May, Day: 11}, Expected: "2020-05-11"}, + {Date: date.Date{Year: 2022, Month: time.January, Day: 31}, Expected: "2022-01-31"}, + {Date: date.Date{Year: 1999, Month: time.December, Day: 24}, Expected: "1999-12-24"}, + } + + 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.Expected, func(t *testing.T) { + t.Parallel() + assert := testifyrequire.New(t) + + assert.Equal(tc.Expected, tc.Date.String()) + }) + } +} + +func TestDate_GoString(base *testing.T) { + base.Parallel() + + type testCase struct { + Date date.Date + Expected string + } + + cases := []testCase{ + {Date: date.Date{Year: 2020, Month: time.May, Day: 11}, Expected: "date.NewDate(2020, time.May, 11)"}, + {Date: date.Date{Year: 2022, Month: time.January, Day: 31}, Expected: "date.NewDate(2022, time.January, 31)"}, + {Date: date.Date{Year: 1999, Month: time.December, Day: 24}, Expected: "date.NewDate(1999, time.December, 24)"}, + } + + 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.Expected, func(t *testing.T) { + t.Parallel() + assert := testifyrequire.New(t) + + assert.Equal(tc.Expected, tc.Date.GoString()) + }) + } +} diff --git a/doc.go b/doc.go index 5f13890..d12a25e 100644 --- a/doc.go +++ b/doc.go @@ -25,5 +25,5 @@ // 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)`. +// `time.Parse(time.DateOnly, value)`. package date diff --git a/go.mod b/go.mod index c16ce3f..7203697 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/hardfinhq/go-date -go 1.21.6 +go 1.21.4 require github.com/stretchr/testify v1.8.4