Skip to content

Commit

Permalink
fix(date): use available timezone if any (#94)
Browse files Browse the repository at this point in the history
## Description

It's important to use the date timezone if it has any.

## Changes

Do not switch back to Local timezone if not needed

## Fixes #81 

## Checklist
- [X] I have read the **CONTRIBUTING.md** document.
- [X] My code follows the code style of this project.
- [X] I have added tests to cover my changes.
- [X] All new and existing tests passed.
- [ ] I have updated the documentation accordingly.
- [ ] This change requires a change to the documentation on the website.

## Additional Information
<!-- Any additional information regarding this pull request. -->

Timezones are always fun

---------

Signed-off-by: ccoVeille <[email protected]>
Co-authored-by: Atomys <[email protected]>
  • Loading branch information
ccoVeille and 42atomys authored Dec 18, 2024
1 parent 1fb3882 commit 5f8850e
Show file tree
Hide file tree
Showing 13 changed files with 402 additions and 112 deletions.
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

0 comments on commit 5f8850e

Please sign in to comment.