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

fix(date): use available timezone if any #94

Merged
merged 9 commits into from
Dec 18, 2024
9 changes: 8 additions & 1 deletion .golangci.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
# Thanks to @ccoVeille for the configuration template from
# Thanks to @ccoVeille for the configuration template from
# https://github.com/ccoVeille/golangci-lint-config-examples
linters:
enable:
Expand All @@ -9,6 +9,7 @@ linters:
- gofumpt
- gosimple
- govet
- importas
- ineffassign
- staticcheck
- misspell
Expand All @@ -19,6 +20,12 @@ linters:
- usestdlibvars

linters-settings:
importas:
alias:
# prevent conflicts with first level std packages
- pkg: "[a-z][0-9a-z]+"
alias: ""

gofumpt:
module-path: github.com/go-sprout/sprout
misspell:
Expand Down
10 changes: 5 additions & 5 deletions benchmarks/comparison_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
"sync"
"testing"
"text/template"
gotime "time"
"time"

"github.com/Masterminds/sprig/v3"
"github.com/go-sprout/sprout"
Expand All @@ -29,7 +29,7 @@ import (
"github.com/go-sprout/sprout/registry/slices"
"github.com/go-sprout/sprout/registry/std"
"github.com/go-sprout/sprout/registry/strings"
"github.com/go-sprout/sprout/registry/time"
rtime "github.com/go-sprout/sprout/registry/time"
"github.com/go-sprout/sprout/registry/uniqueid"
"github.com/go-sprout/sprout/sprigin"
"github.com/stretchr/testify/assert"
Expand All @@ -48,8 +48,8 @@ var data = map[string]any{
"object": struct{ Name string }{"example object"},
"func": func() string { return "example function" },
"error": fmt.Errorf("example error"),
"time": gotime.Now(),
"duration": 5 * gotime.Second,
"time": time.Now(),
"duration": 5 * time.Second,
"channel": make(chan any),
"json": `{"foo": "bar"}`,
"yaml": "foo: bar",
Expand Down Expand Up @@ -136,7 +136,7 @@ func sproutBench(templatePath string) {
semver.NewRegistry(),
backward.NewRegistry(),
reflect.NewRegistry(),
time.NewRegistry(),
rtime.NewRegistry(),
strings.NewRegistry(),
random.NewRegistry(),
checksum.NewRegistry(),
Expand Down
2 changes: 1 addition & 1 deletion docs/registries/conversion.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ See more about Golang Layout on the [official documentation](https://go.dev/src/

toLocalDate converts a string to a time.Time object based on a format specification and the local timezone.

<table data-header-hidden><thead><tr><th width="162">Name</th><th>Value</th></tr></thead><tbody><tr><td>Signature</td><td><pre class="language-go"><code class="lang-go">ToLocalDate(fmt, timezone, str string) (time.Time, error)
<table data-header-hidden><thead><tr><th width="162">Name</th><th>Value</th></tr></thead><tbody><tr><td>Signature</td><td><pre class="language-go"><code class="lang-go">ToLocalDate(layout, timezone, value string) (time.Time, error)
</code></pre></td></tr></tbody></table>

{% tabs %}
Expand Down
6 changes: 3 additions & 3 deletions docs/registries/time.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import "github.com/go-sprout/sprout/registry/time"

The function formats a given date or the current time into a specified format string.

<table data-header-hidden><thead><tr><th width="174">Name</th><th>Value</th></tr></thead><tbody><tr><td>Signature</td><td><pre class="language-go"><code class="lang-go"> Date(fmt string, date any) (string, error)
<table data-header-hidden><thead><tr><th width="174">Name</th><th>Value</th></tr></thead><tbody><tr><td>Signature</td><td><pre class="language-go"><code class="lang-go"> Date(layout string, date any) (string, error)
</code></pre></td></tr></tbody></table>

{% tabs %}
Expand All @@ -34,7 +34,7 @@ The function formats a given date or the current time into a specified format st

The function formats a given date or the current time into a specified format string for a specified timezone.

<table data-header-hidden><thead><tr><th width="124">Name</th><th>Value</th></tr></thead><tbody><tr><td>Signature</td><td><pre class="language-go"><code class="lang-go">DateInZone(fmt string, date any, zone string) (string, error)
<table data-header-hidden><thead><tr><th width="124">Name</th><th>Value</th></tr></thead><tbody><tr><td>Signature</td><td><pre class="language-go"><code class="lang-go">DateInZone(layout string, date any, zone string) (string, error)
</code></pre></td></tr></tbody></table>

{% tabs %}
Expand Down Expand Up @@ -121,7 +121,7 @@ The function returns the Unix epoch timestamp for a given date.

The function adjusts a given date by a specified duration, returning the modified date. If the duration format is incorrect, it returns the original date without any changes, in case of must version, an error is returned.

<table data-header-hidden><thead><tr><th width="164">Name</th><th>Value</th></tr></thead><tbody><tr><td>Signature</td><td><pre class="language-go"><code class="lang-go">DateModify(fmt string, date time.Time) (time.Time, error)
<table data-header-hidden><thead><tr><th width="164">Name</th><th>Value</th></tr></thead><tbody><tr><td>Signature</td><td><pre class="language-go"><code class="lang-go">DateModify(layout string, date time.Time) (time.Time, error)
</code></pre></td></tr></tbody></table>

{% tabs %}
Expand Down
4 changes: 2 additions & 2 deletions handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package sprout
import (
"log/slog"
"slices"
gostrings "strings"
"strings"

"golang.org/x/text/cases"
"golang.org/x/text/language"
Expand Down Expand Up @@ -259,7 +259,7 @@ func safeFuncName(name string) string {
return ""
}

var b gostrings.Builder
var b strings.Builder
b.Grow(len(name) + 4)

b.WriteString("safe")
Expand Down
15 changes: 15 additions & 0 deletions pesticide/time_test_helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package pesticide

import (
"testing"
"time"
)

// ForceTimeLocal temporarily sets [time.Local] for test purpose.
func ForceTimeLocal(t *testing.T, local *time.Location) {
t.Helper()

originalLocal := time.Local
time.Local = local
t.Cleanup(func() { time.Local = originalLocal })
}
12 changes: 6 additions & 6 deletions registry/conversion/functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ func (cr *ConversionRegistry) ToString(value any) string {
//
// Parameters:
//
// fmt string - the date format string.
// layout string - the date format string.
// value string - the date string to parse.
//
// Returns:
Expand All @@ -181,16 +181,16 @@ func (cr *ConversionRegistry) ToString(value any) string {
// For an example of this function in a Go template, refer to [Sprout Documentation: toDate].
//
// [Sprout Documentation: toDate]: https://docs.atom.codes/sprout/registries/conversion#todate
func (cr *ConversionRegistry) ToDate(fmt, value string) (time.Time, error) {
return time.ParseInLocation(fmt, value, time.Local)
func (cr *ConversionRegistry) ToDate(layout, value string) (time.Time, error) {
return time.ParseInLocation(layout, value, time.Local)
}

// ToLocalDate converts a string to a time.Time object based on a format specification
// and the local timezone.
//
// Parameters:
//
// fmt string - the date format string.
// layout string - the date format string.
// value string - the date string to parse.
//
// Returns:
Expand All @@ -201,13 +201,13 @@ func (cr *ConversionRegistry) ToDate(fmt, value string) (time.Time, error) {
// For an example of this function in a Go template, refer to [Sprout Documentation: toLocalDate].
//
// [Sprout Documentation: toLocalDate]: https://docs.atom.codes/sprout/registries/conversion#tolocaldate
func (cr *ConversionRegistry) ToLocalDate(fmt, timezone, value string) (time.Time, error) {
func (cr *ConversionRegistry) ToLocalDate(layout, timezone, value string) (time.Time, error) {
location, err := time.LoadLocation(timezone)
if err != nil {
return time.Time{}, err
}

return time.ParseInLocation(fmt, value, location)
return time.ParseInLocation(layout, value, location)
}

// ToDuration converts a value to a time.Duration.
Expand Down
175 changes: 148 additions & 27 deletions registry/conversion/functions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ package conversion_test
import (
"fmt"
"testing"
"time"

"github.com/stretchr/testify/require"

"github.com/go-sprout/sprout/pesticide"
"github.com/go-sprout/sprout/registry/conversion"
Expand Down Expand Up @@ -120,34 +123,152 @@ func TestToString(t *testing.T) {
}

func TestToDate(t *testing.T) {
tc := []pesticide.TestCase{
{
Name: "TestDate",
Input: `{{$v := toDate "2006-01-02" .V }}{{typeOf $v}}-{{$v}}`,
Data: map[string]any{"V": "2024-05-09"},
ExpectedOutput: "time.Time-2024-05-09 00:00:00 +0000 UTC",
},
{
Name: "TestDate",
Input: `{{$v := toDate "2006-01-02 15:04:05 MST" .V }}{{typeOf $v}}-{{$v}}`,
Data: map[string]any{"V": "2024-05-09 00:00:00 UTC"},
ExpectedOutput: "time.Time-2024-05-09 00:00:00 +0000 UTC",
},
{
Name: "TestInvalidValue",
Input: `{{$v := toDate "2006-01-02" .V }}{{typeOf $v}}-{{$v}}`,
Data: map[string]any{"V": ""},
ExpectedErr: "cannot parse \"\" as \"2006\"",
},
{
Name: "TestInvalidLayout",
Input: `{{$v := toDate "invalid" .V }}{{typeOf $v}}-{{$v}}`,
Data: map[string]any{"V": "2024-05-09"},
ExpectedErr: "cannot parse \"2024-05-09\" as \"invalid\"",
},
}
t.Run("dates with numeric timezone offset", func(t *testing.T) {
// Please refer to https://pkg.go.dev/time#Parse
// When parsing a time with a zone offset like -0700,
// if the offset corresponds to a time zone used by the current location (Local),
// then Parse uses that location and zone in the returned time.
// Otherwise it records the time as being in a fabricated location with time fixed at the given zone offset.

pesticide.RunTestCases(t, conversion.NewRegistry(), tc)
// So we have to temporarily force time.Local a known timezone
// to validate the behavior of toDate function
local, err := time.LoadLocation("America/New_York")
require.NoError(t, err)

// temporarily force time.Local to New York
pesticide.ForceTimeLocal(t, local)

tc := []pesticide.TestCase{
{
Name: "date with UTC timezone",
Input: `{{$v := toDate "2006-01-02 15:04:05 -0700" .V }}{{typeOf $v}}-{{$v}}`,
Data: map[string]any{"V": "2024-05-09 00:00:00 +0000"},
ExpectedOutput: "time.Time-2024-05-09 00:00:00 +0000 +0000",
},
{
Name: "date with non-UTC timezone equal to local timezone",
Input: `{{$v := toDate "2006-01-02 15:04:05 -0700" .V }}{{typeOf $v}}-{{$v}}`,
Data: map[string]any{"V": "2024-05-09 00:00:00 -0400"},
ExpectedOutput: "time.Time-2024-05-09 00:00:00 -0400 EDT",
},
{
Name: "date with non-UTC timezone different than local",
Input: `{{$v := toDate "2006-01-02 15:04:05 -0700" .V }}{{typeOf $v}}-{{$v}}`,
Data: map[string]any{"V": "2024-05-09 00:00:00 -0700"},
ExpectedOutput: "time.Time-2024-05-09 00:00:00 -0700 -0700",
},
}

pesticide.RunTestCases(t, conversion.NewRegistry(), tc)
})

t.Run("dates with abbreviated timezone", func(t *testing.T) {
// Please refer to https://pkg.go.dev/time#Parse
// When parsing a time with a zone abbreviation like MST,
// if the zone abbreviation has a defined offset in the current location,
// then that offset is used.
// The zone abbreviation "UTC" is recognized as UTC regardless of location.
// To avoid such problems, prefer time layouts that use a numeric zone offset, or use ParseInLocation.

// So we have to temporarily force time.Local a known timezone
// to validate the behavior of toDate function
local, err := time.LoadLocation("America/New_York")
require.NoError(t, err)

// temporarily force time.Local to New York
pesticide.ForceTimeLocal(t, local)

tc := []pesticide.TestCase{
{
Name: "date with UTC timezone",
Input: `{{$v := toDate "2006-01-02 15:04:05 MST" .V }}{{typeOf $v}}-{{$v}}`,
Data: map[string]any{"V": "2024-05-09 00:00:00 UTC"},
ExpectedOutput: "time.Time-2024-05-09 00:00:00 +0000 UTC",
},
{
Name: "date with non-UTC timezone equal to local timezone",
Input: `{{$v := toDate "2006-01-02 15:04:05 MST" .V }}{{typeOf $v}}-{{$v}}`,
Data: map[string]any{"V": "2024-05-09 00:00:00 EDT"},
ExpectedOutput: "time.Time-2024-05-09 00:00:00 -0400 EDT",
},
{
Name: "date with non-UTC timezone different than local",
Input: `{{$v := toDate "2006-01-02 15:04:05 MST" .V }}{{typeOf $v}}-{{$v}}`,
Data: map[string]any{"V": "2024-05-09 00:00:00 MST"},
ExpectedOutput: "time.Time-2024-05-09 00:00:00 +0000 MST",
},
}

pesticide.RunTestCases(t, conversion.NewRegistry(), tc)
})

t.Run("dates without timezone (local time should be assumed)", func(t *testing.T) {
t.Run("UTC", func(t *testing.T) {
// temporarily force time.Local to UTC
pesticide.ForceTimeLocal(t, time.UTC)

tc := []pesticide.TestCase{
{
Name: "short date",
Input: `{{$v := toDate "2006-01-02" .V }}{{typeOf $v}}-{{$v}}`,
Data: map[string]any{"V": "2024-05-09"},
ExpectedOutput: "time.Time-2024-05-09 00:00:00 +0000 UTC",
},
{
Name: "datetime ",
Input: `{{$v := toDate "2006-01-02 15:04:05" .V }}{{typeOf $v}}-{{$v}}`,
Data: map[string]any{"V": "2024-05-09 01:02:03"},
ExpectedOutput: "time.Time-2024-05-09 01:02:03 +0000 UTC",
},
}

pesticide.RunTestCases(t, conversion.NewRegistry(), tc)
})

t.Run("New York timezone", func(t *testing.T) {
local, err := time.LoadLocation("America/New_York")
require.NoError(t, err)

// temporarily force time.Local to New York
pesticide.ForceTimeLocal(t, local)

tc := []pesticide.TestCase{
{
Name: "short date",
Input: `{{$v := toDate "2006-01-02" .V }}{{typeOf $v}}-{{$v}}`,
Data: map[string]any{"V": "2024-05-09"},
ExpectedOutput: "time.Time-2024-05-09 00:00:00 -0400 EDT",
},
{
Name: "datetime ",
Input: `{{$v := toDate "2006-01-02 15:04:05" .V }}{{typeOf $v}}-{{$v}}`,
Data: map[string]any{"V": "2024-05-09 01:02:03"},
ExpectedOutput: "time.Time-2024-05-09 01:02:03 -0400 EDT",
},
}

pesticide.RunTestCases(t, conversion.NewRegistry(), tc)
})
})

t.Run("invalid layout", func(t *testing.T) {
tc := []pesticide.TestCase{
{
Name: "TestInvalidValue",
Input: `{{$v := toDate "2006-01-02" .V }}{{typeOf $v}}-{{$v}}`,
Data: map[string]any{"V": ""},
ExpectedErr: `cannot parse "" as "2006"`,
},
{
Name: "TestInvalidLayout",
Input: `{{$v := toDate "invalid" .V }}{{typeOf $v}}-{{$v}}`,
Data: map[string]any{"V": "2024-05-09"},
ExpectedErr: `cannot parse "2024-05-09" as "invalid"`,
},
}

pesticide.RunTestCases(t, conversion.NewRegistry(), tc)
})
}

func TestToLocalDate(t *testing.T) {
Expand Down
Loading
Loading