diff --git a/go.work b/go.work index d3de252..66da1f6 100644 --- a/go.work +++ b/go.work @@ -3,4 +3,5 @@ go 1.19 use ( . ./dur + ./ts ) diff --git a/ts/.gitignore b/ts/.gitignore new file mode 100644 index 0000000..2e64ef0 --- /dev/null +++ b/ts/.gitignore @@ -0,0 +1,2 @@ +bin/* +coverage.* diff --git a/ts/.golangci.yml b/ts/.golangci.yml new file mode 100644 index 0000000..1269134 --- /dev/null +++ b/ts/.golangci.yml @@ -0,0 +1,94 @@ +linters-settings: + funlen: + lines: 100 + statements: 450 + golint: + min-confidence: 0 + govet: + enable-all: true + disable: + - fieldalignment + - shadow + lll: + line-length: 80 + tab-width: 4 + maligned: + suggest-new: true + misspell: + locale: US + paralleltest: + ignore-missing: true + +linters: + disable-all: true + enable: + - asciicheck + - bodyclose + - depguard + - durationcheck + - errcheck + - errorlint + - exhaustive + - exportloopref + - funlen + - gochecknoinits + - goconst + - gocritic + - godot + - gofumpt + - goimports + - goprintffuncname + - gosec + - gosimple + - govet + - importas + - ineffassign + - lll + - misspell + - nakedret + - nilerr + - noctx + - nolintlint + - paralleltest + - prealloc + - predeclared + - revive + - rowserrcheck + - sqlclosecheck + - staticcheck + - typecheck + - unconvert + - unparam + - unused + - wastedassign + - whitespace + +issues: + exclude: + - Using the variable on range scope `tt` in function literal + - Using the variable on range scope `tc` in function literal + exclude-rules: + - path: "_test\\.go" + linters: + - funlen + - dupl + - source: "^//go:generate " + linters: + - lll + - source: "`env:" + linters: + - lll + - source: "`json:" + linters: + - lll + - source: "`xml:" + linters: + - lll + - source: "`yaml:" + linters: + - lll + +run: + timeout: 2m + allow-parallel-runners: true + modules-download-mode: readonly diff --git a/ts/Makefile b/ts/Makefile new file mode 100644 index 0000000..8895cd7 --- /dev/null +++ b/ts/Makefile @@ -0,0 +1,191 @@ +GOMODNAME := $(shell grep 'module' go.mod | sed -e 's/^module //') +SOURCES := $(shell find . -name "*.go" -or -name "go.mod" -or -name "go.sum" \ + -or -name "Makefile" -or -name "*.golden") + +# Verbose output +ifdef VERBOSE +V = -v +endif + +# +# Environment +# + +BINDIR := bin +TOOLDIR := $(BINDIR)/tools + +# Global environment variables for all targets +SHELL ?= /bin/bash +SHELL := env \ + GO111MODULE=on \ + GOBIN=$(CURDIR)/$(TOOLDIR) \ + CGO_ENABLED=1 \ + PATH='$(CURDIR)/$(BINDIR):$(CURDIR)/$(TOOLDIR):$(PATH)' \ + $(SHELL) + +# +# Defaults +# + +# Default target +.DEFAULT_GOAL := test + +# +# Tools +# + +# external tool +define tool # 1: binary-name, 2: go-import-path +TOOLS += $(TOOLDIR)/$(1) + +$(TOOLDIR)/$(1): Makefile + GOBIN="$(CURDIR)/$(TOOLDIR)" go install "$(2)" +endef + +$(eval $(call tool,godoc,golang.org/x/tools/cmd/godoc@latest)) +$(eval $(call tool,gofumpt,mvdan.cc/gofumpt@latest)) +$(eval $(call tool,goimports,golang.org/x/tools/cmd/goimports@latest)) +$(eval $(call tool,golangci-lint,github.com/golangci/golangci-lint/cmd/golangci-lint@v1.50)) +$(eval $(call tool,gomod,github.com/Helcaraxan/gomod@latest)) +$(eval $(call tool,mockgen,github.com/golang/mock/mockgen@v1.6.0)) + +.PHONY: tools +tools: $(TOOLS) + +# +# Development +# + +BENCH ?= . +TESTARGS ?= + +.PHONY: clean +clean: + rm -f $(TOOLS) + rm -f ./coverage.out ./go.mod.tidy-check ./go.sum.tidy-check + +.PHONY: test +test: + go test $(V) -count=1 -race $(TESTARGS) ./... + +.PHONY: test-deps +test-deps: + go test all + +.PHONY: lint +lint: $(TOOLDIR)/golangci-lint + golangci-lint $(V) run + +.PHONY: format +format: $(TOOLDIR)/goimports $(TOOLDIR)/gofumpt + goimports -w . && gofumpt -w . + +.SILENT: bench +.PHONY: bench +bench: + go test $(V) -count=1 -bench=$(BENCH) $(TESTARGS) ./... + +# +# Code Generation +# + +.PHONY: generate +generate: $(TOOLDIR)/mockgen + go generate ./... + +.PHONY: check-generate +check-generate: $(TOOLDIR)/mockgen + $(eval CHKDIR := $(shell mktemp -d)) + cp -av . "$(CHKDIR)" + make -C "$(CHKDIR)/" generate + ( diff -rN . "$(CHKDIR)" && rm -rf "$(CHKDIR)" ) || \ + ( rm -rf "$(CHKDIR)" && exit 1 ) + +# +# Coverage +# + +.PHONY: cov +cov: coverage.out + +.PHONY: cov-html +cov-html: coverage.out + go tool cover -html=./coverage.out + +.PHONY: cov-func +cov-func: coverage.out + go tool cover -func=./coverage.out + +coverage.out: $(SOURCES) + go test $(V) -count=1 -race \ + -covermode=atomic -coverprofile=./coverage.out ./... + +# +# Dependencies +# + +.PHONY: deps +deps: + go mod download + +.PHONY: deps-update +deps-update: + go get -u -t ./... + +.PHONY: deps-analyze +deps-analyze: $(TOOLDIR)/gomod + gomod analyze + +.PHONY: tidy +tidy: + go mod tidy $(V) + +.PHONY: verify +verify: + go mod verify + +.SILENT: check-tidy +.PHONY: check-tidy +check-tidy: + cp go.mod go.mod.tidy-check + cp go.sum go.sum.tidy-check + go mod tidy + ( \ + diff go.mod go.mod.tidy-check && \ + diff go.sum go.sum.tidy-check && \ + rm -f go.mod go.sum && \ + mv go.mod.tidy-check go.mod && \ + mv go.sum.tidy-check go.sum \ + ) || ( \ + rm -f go.mod go.sum && \ + mv go.mod.tidy-check go.mod && \ + mv go.sum.tidy-check go.sum; \ + exit 1 \ + ) + +# +# Documentation +# + +# Serve docs +.PHONY: docs +docs: $(TOOLDIR)/godoc + $(info serviing docs on http://127.0.0.1:6060/pkg/$(GOMODNAME)/) + @godoc -http=127.0.0.1:6060 + +# +# Release +# + +.PHONY: new-version +new-version: check-npx + npx standard-version + +.PHONY: next-version +next-version: check-npx + npx standard-version --dry-run + +.PHONY: check-npx +check-npx: + $(if $(shell which npx),,\ + $(error No npx found in PATH, please install NodeJS)) diff --git a/ts/go.mod b/ts/go.mod new file mode 100644 index 0000000..c4ab78d --- /dev/null +++ b/ts/go.mod @@ -0,0 +1,14 @@ +module github.com/jimeh/go-tyme/ts + +go 1.18 + +require ( + github.com/jimeh/go-tyme/dur v0.0.0-20221030033507-5d31aa674303 + github.com/stretchr/testify v1.8.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect +) diff --git a/ts/go.sum b/ts/go.sum new file mode 100644 index 0000000..15cb50e --- /dev/null +++ b/ts/go.sum @@ -0,0 +1,19 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jimeh/go-tyme/dur v0.0.0-20221030033507-5d31aa674303 h1:nTg0rfEObislvl5SOmEyjE2iV/BTNCG2ts36cjXMNfk= +github.com/jimeh/go-tyme/dur v0.0.0-20221030033507-5d31aa674303/go.mod h1:9zwXRzQlr7JTL5wUVkdCnZY0NR08ZtFMaehZNpZNV+w= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/ts/marshal_test.go b/ts/marshal_test.go new file mode 100644 index 0000000..8c1c7a8 --- /dev/null +++ b/ts/marshal_test.go @@ -0,0 +1,248 @@ +package ts + +import "time" + +var ( + marshalUnmarshalTestCases = []struct { + name string + t time.Time + second string + millisecond string + microsecond string + nanosecond string + }{ + { + name: "UTC-8", + t: time.Date( + 2022, 10, 29, 6, 40, 34, 934349003, + time.FixedZone("UTC-8", -8*60*60), + ), + second: "1667054434", + millisecond: "1667054434934", + microsecond: "1667054434934349", + nanosecond: "1667054434934349003", + }, + { + name: "UTC-2", + t: time.Date( + 2022, 10, 29, 12, 40, 34, 934349003, + time.FixedZone("UTC-2", -2*60*60), + ), + second: "1667054434", + millisecond: "1667054434934", + microsecond: "1667054434934349", + nanosecond: "1667054434934349003", + }, + { + name: "UTC", + t: time.Date( + 2022, 10, 29, 14, 40, 34, 934349003, time.UTC, + ), + second: "1667054434", + millisecond: "1667054434934", + microsecond: "1667054434934349", + nanosecond: "1667054434934349003", + }, + { + name: "UTC+2", + t: time.Date( + 2022, 10, 29, 16, 40, 34, 934349003, + time.FixedZone("UTC+2", 2*60*60), + ), + second: "1667054434", + millisecond: "1667054434934", + microsecond: "1667054434934349", + nanosecond: "1667054434934349003", + }, + { + name: "UTC+8", + t: time.Date( + 2022, 10, 29, 22, 40, 34, 934349003, + time.FixedZone("UTC+8", 8*60*60), + ), + second: "1667054434", + millisecond: "1667054434934", + microsecond: "1667054434934349", + nanosecond: "1667054434934349003", + }, + { + name: "epoch", + t: time.Date( + 1970, 1, 1, 0, 0, 0, 0, time.UTC, + ), + second: "0", + millisecond: "0", + microsecond: "0", + nanosecond: "0", + }, + { + name: "min second", + t: time.Date(292277026596, 12, 4, 15, 30, 8, 0, time.UTC), + second: "-9223372036854775808", + millisecond: "0", + microsecond: "0", + nanosecond: "0", + }, + { + name: "max second", + t: time.Date(292277026596, 12, 4, 15, 30, 7, 0, time.UTC), + second: "9223372036854775807", + millisecond: "-1000", + microsecond: "-1000000", + nanosecond: "-1000000000", + }, + { + name: "min millisecond", + t: time.Date( + -292275055, 5, 16, 16, 47, 4, 192000000, time.UTC, + ), + second: "-9223372036854776", + millisecond: "-9223372036854775808", + microsecond: "0", + nanosecond: "0", + }, + { + name: "max millisecond", + t: time.Date( + 292278994, 8, 17, 7, 12, 55, 807000000, time.UTC, + ), + second: "9223372036854775", + millisecond: "9223372036854775807", + microsecond: "-1000", + nanosecond: "-1000000", + }, + { + name: "min microseconds", + t: time.Date( + -290308, 12, 21, 19, 59, 5, 224192000, time.UTC, + ), + second: "-9223372036855", + millisecond: "-9223372036854776", + microsecond: "-9223372036854775808", + nanosecond: "0", + }, + { + name: "max microseconds", + t: time.Date( + 294247, 1, 10, 4, 0, 54, 775807000, time.UTC, + ), + second: "9223372036854", + millisecond: "9223372036854775", + microsecond: "9223372036854775807", + nanosecond: "-1000", + }, + { + name: "min nanoseconds", + t: time.Date(1677, 9, 21, 0, 12, 43, 145224192, time.UTC), + second: "-9223372037", + millisecond: "-9223372036855", + microsecond: "-9223372036854776", + nanosecond: "-9223372036854775808", + }, + { + name: "max nanoseconds", + t: time.Date( + 2262, 4, 11, 23, 47, 16, 854775807, time.UTC, + ), + second: "9223372036", + millisecond: "9223372036854", + microsecond: "9223372036854775", + nanosecond: "9223372036854775807", + }, + { + name: "year 1092", + t: time.Date(1092, 3, 23, 3, 52, 8, 734829384, time.UTC), + second: "-27699912472", + millisecond: "-27699912471266", + microsecond: "-27699912471265171", + nanosecond: "9193575676153932616", + }, + { + name: "year -1000", + t: time.Date( + -1000, 10, 29, 22, 40, 34, 934349003, + time.FixedZone("UTC+8", 8*60*60), + ), + second: "-93698068766", + millisecond: "-93698068765066", + microsecond: "-93698068765065651", + nanosecond: "-1464348396517892917", + }, + { + name: "year 10449", + t: time.Date( + 10449, 10, 29, 22, 40, 34, 934349003, + time.FixedZone("UTC+8", 8*60*60), + ), + second: "267597528034", + millisecond: "267597528034934", + microsecond: "267597528034934349", + nanosecond: "-9103633070708925237", + }, + { + name: "year 1044938", + t: time.Date( + 1044938, 10, 29, 22, 40, 34, 934349003, + time.FixedZone("UTC+8", 8*60*60), + ), + second: "32912917195234", + millisecond: "32912917195234934", + microsecond: "-3980570952184168883", + nanosecond: "3925767737094266059", + }, + } + unmarshalTestCases = []struct { + name string + second string + millisecond string + microsecond string + nanosecond string + t time.Time + wantErr string + }{ + { + name: "string", + second: `"2019-01-01T00:00:00Z"`, + millisecond: `"2019-01-01T00:00:00Z"`, + microsecond: `"2019-01-01T00:00:00Z"`, + nanosecond: `"2019-01-01T00:00:00Z"`, + wantErr: "invalid numeric timestamp", + }, + { + name: "array", + second: `[1, "true", false]`, + millisecond: `[1, "true", false]`, + microsecond: `[1, "true", false]`, + nanosecond: `[1, "true", false]`, + wantErr: "invalid numeric timestamp", + }, + { + name: "object", + second: `{"object": "Object"}`, + millisecond: `{"object": "Object"}`, + microsecond: `{"object": "Object"}`, + nanosecond: `{"object": "Object"}`, + wantErr: "invalid numeric timestamp", + }, + { + name: "whitespace", + second: " 1667054434 ", + millisecond: " 1667054434934 ", + microsecond: " 1667054434934349 ", + nanosecond: " 1667054434934349003 ", + t: time.Date( + 2022, 10, 29, 14, 40, 34, 934349003, time.UTC, + ), + }, + { + name: "float", + second: "1667054434.123456789", + millisecond: "1667054434934.123456", + microsecond: "1667054434934349.123", + nanosecond: "", + t: time.Date( + 2022, 10, 29, 14, 40, 34, 934349003, time.UTC, + ), + }, + } +) diff --git a/ts/microsecond.go b/ts/microsecond.go new file mode 100644 index 0000000..617cc62 --- /dev/null +++ b/ts/microsecond.go @@ -0,0 +1,88 @@ +package ts + +import ( + "strconv" + "time" + + "gopkg.in/yaml.v3" +) + +// Microsecond is a wrapper around time.Time for marshaling to/from JSON/YAML as +// microsecond-based numeric Unix timestamps. +// +// It marshals to a JSON/YAML number representing the number of microseconds +// since the Unix time epoch. +// +// It unmarshals from a JSON/YAML number representing the number of microseconds +// since the Unix time epoch. +type Microsecond time.Time + +// Time returns the time.Time corresponding to the microsecond instant s. +func (ms Microsecond) Time() time.Time { + return time.Time(ms) +} + +// Local returns the local time corresponding to the microsecond instant s. +func (ms Microsecond) Local() Microsecond { + return Microsecond(time.Time(ms).Local()) +} + +// GoString implements the fmt.GoStringer interface. +func (ms Microsecond) GoString() string { + return time.Time(ms).GoString() +} + +// IsDST reports whether the microsecond instant s occurs within Daylight Saving +// Time. +func (ms Microsecond) IsDST() bool { + return time.Time(ms).IsDST() +} + +// IsZero returns true if the Microsecond is the zero value. +func (ms Microsecond) IsZero() bool { + return time.Time(ms).IsZero() +} + +// String calls time.Time.String. +func (ms Microsecond) String() string { + return time.Time(ms).String() +} + +// UTC returns a copy of the Microsecond with the location set to UTC. +func (ms Microsecond) UTC() Microsecond { + return Microsecond(time.Time(ms).UTC()) +} + +// MarshalJSON implements the json.Marshaler interface. +func (ms Microsecond) MarshalJSON() ([]byte, error) { + return []byte(strconv.FormatInt(time.Time(ms).UnixMicro(), 10)), nil +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (ms *Microsecond) UnmarshalJSON(data []byte) error { + i, err := unmarshalBytes(data) + if err != nil { + return err + } + + *ms = UnixMicro(i) + + return nil +} + +// MarshalJSON implements the yaml.Marshaler interface. +func (ms Microsecond) MarshalYAML() (interface{}, error) { + return time.Time(ms).UnixMicro(), nil +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface. +func (ms *Microsecond) UnmarshalYAML(node *yaml.Node) error { + i, err := unmarshalYAMLNode(node) + if err != nil { + return err + } + + *ms = UnixMicro(i) + + return nil +} diff --git a/ts/microsecond_test.go b/ts/microsecond_test.go new file mode 100644 index 0000000..0527d56 --- /dev/null +++ b/ts/microsecond_test.go @@ -0,0 +1,168 @@ +package ts + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +var ( + minMicro = time.UnixMicro(-9223372036854775808).UTC() + maxMicro = time.UnixMicro(9223372036854775807).UTC() +) + +func microsecondSkipTestCase(t *testing.T, ti time.Time) bool { + t.Helper() + + return ti.Before(minMicro) || ti.After(maxMicro) +} + +func TestMicrosecond_MarshalUnmarshalJSON(t *testing.T) { + for _, tt := range marshalUnmarshalTestCases { + t.Run(tt.name, func(t *testing.T) { + v := Microsecond(tt.t) + + b, err := json.Marshal(v) + require.NoError(t, err) + + assert.Equal(t, tt.microsecond, string(b)) + + if microsecondSkipTestCase(t, tt.t) { + return + } + + var got Microsecond + err = json.Unmarshal(b, &got) + require.NoError(t, err) + + want := tt.t.Truncate(time.Microsecond) + + assert.Equal(t, want.UTC(), time.Time(got).UTC()) + assert.Equal(t, time.Local, time.Time(got).Location()) + }) + } +} + +func TestMicrosecond_MarshalUnmarshalYAML(t *testing.T) { + for _, tt := range marshalUnmarshalTestCases { + t.Run(tt.name, func(t *testing.T) { + v := Microsecond(tt.t) + + b, err := yaml.Marshal(v) + require.NoError(t, err) + + assert.Equal(t, tt.microsecond+"\n", string(b)) + + if microsecondSkipTestCase(t, tt.t) { + return + } + + var got Microsecond + err = yaml.Unmarshal(b, &got) + require.NoError(t, err) + + want := tt.t.Truncate(time.Microsecond) + + assert.Equal(t, want.UTC(), time.Time(got).UTC()) + assert.Equal(t, time.Local, time.Time(got).Location()) + }) + } +} + +func TestMicrosecond_MarshalJSON(t *testing.T) { + for _, tt := range marshalUnmarshalTestCases { + t.Run(tt.name, func(t *testing.T) { + ts := Microsecond(tt.t) + + b, err := json.Marshal(ts) + require.NoError(t, err) + + assert.Equal(t, tt.microsecond, string(b)) + }) + } +} + +func TestMicrosecond_MarshalYAML(t *testing.T) { + for _, tt := range marshalUnmarshalTestCases { + t.Run(tt.name, func(t *testing.T) { + ts := Microsecond(tt.t) + + b, err := yaml.Marshal(ts) + require.NoError(t, err) + + assert.Equal(t, tt.microsecond+"\n", string(b)) + }) + } +} + +func TestMicrosecond_UnmarshalJSON(t *testing.T) { + for _, tt := range marshalUnmarshalTestCases { + if microsecondSkipTestCase(t, tt.t) { + continue + } + t.Run(tt.name, func(t *testing.T) { + var ts Microsecond + + err := json.Unmarshal([]byte(tt.microsecond), &ts) + require.NoError(t, err) + + want := tt.t.Truncate(time.Microsecond) + + assert.Equal(t, want.UTC(), time.Time(ts).UTC()) + }) + } + for _, tt := range unmarshalTestCases { + t.Run(tt.name, func(t *testing.T) { + var ts Microsecond + + err := json.Unmarshal([]byte(tt.microsecond), &ts) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + want := tt.t.Truncate(time.Microsecond) + assert.Equal(t, want.UTC(), time.Time(ts).UTC()) + } + }) + } +} + +func TestMicrosecond_UnmarshalYAML(t *testing.T) { + for _, tt := range marshalUnmarshalTestCases { + if microsecondSkipTestCase(t, tt.t) { + continue + } + t.Run(tt.name, func(t *testing.T) { + var ts Microsecond + + err := yaml.Unmarshal([]byte(tt.microsecond), &ts) + require.NoError(t, err) + + want := tt.t.Truncate(time.Microsecond) + + assert.Equal(t, want.UTC(), time.Time(ts).UTC()) + }) + } + for _, tt := range unmarshalTestCases { + t.Run(tt.name, func(t *testing.T) { + var ts Microsecond + + err := yaml.Unmarshal([]byte(tt.microsecond), &ts) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + want := tt.t.Truncate(time.Microsecond) + assert.Equal(t, want.UTC(), time.Time(ts).UTC()) + } + }) + } +} diff --git a/ts/millisecond.go b/ts/millisecond.go new file mode 100644 index 0000000..69aa1b3 --- /dev/null +++ b/ts/millisecond.go @@ -0,0 +1,88 @@ +package ts + +import ( + "strconv" + "time" + + "gopkg.in/yaml.v3" +) + +// Millisecond is a wrapper around time.Time for marshaling to/from JSON/YAML as +// millisecond-based numeric Unix timestamps. +// +// It marshals to a JSON/YAML number representing the number of milliseconds +// since the Unix time epoch. +// +// It unmarshals from a JSON/YAML number representing the number of milliseconds +// since the Unix time epoch. +type Millisecond time.Time + +// Time returns the time.Time corresponding to the millisecond instant s. +func (ms Millisecond) Time() time.Time { + return time.Time(ms) +} + +// Local returns the local time corresponding to the millisecond instant s. +func (ms Millisecond) Local() Millisecond { + return Millisecond(time.Time(ms).Local()) +} + +// GoString implements the fmt.GoStringer interface. +func (ms Millisecond) GoString() string { + return time.Time(ms).GoString() +} + +// IsDST reports whether the millisecond instant s occurs within Daylight Saving +// Time. +func (ms Millisecond) IsDST() bool { + return time.Time(ms).IsDST() +} + +// IsZero returns true if the Millisecond is the zero value. +func (ms Millisecond) IsZero() bool { + return time.Time(ms).IsZero() +} + +// String calls time.Time.String. +func (ms Millisecond) String() string { + return time.Time(ms).String() +} + +// UTC returns a copy of the Millisecond with the location set to UTC. +func (ms Millisecond) UTC() Millisecond { + return Millisecond(time.Time(ms).UTC()) +} + +// MarshalJSON implements the json.Marshaler interface. +func (ms Millisecond) MarshalJSON() ([]byte, error) { + return []byte(strconv.FormatInt(time.Time(ms).UnixMilli(), 10)), nil +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (ms *Millisecond) UnmarshalJSON(data []byte) error { + i, err := unmarshalBytes(data) + if err != nil { + return err + } + + *ms = UnixMilli(i) + + return nil +} + +// MarshalJSON implements the yaml.Marshaler interface. +func (ms Millisecond) MarshalYAML() (interface{}, error) { + return time.Time(ms).UnixMilli(), nil +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface. +func (ms *Millisecond) UnmarshalYAML(node *yaml.Node) error { + i, err := unmarshalYAMLNode(node) + if err != nil { + return err + } + + *ms = UnixMilli(i) + + return nil +} diff --git a/ts/millisecond_test.go b/ts/millisecond_test.go new file mode 100644 index 0000000..6dcea63 --- /dev/null +++ b/ts/millisecond_test.go @@ -0,0 +1,168 @@ +package ts + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +var ( + minMilli = time.UnixMilli(-9223372036854775808).UTC() + maxMilli = time.UnixMilli(9223372036854775807).UTC() +) + +func millisecondSkipTestCase(t *testing.T, ti time.Time) bool { + t.Helper() + + return ti.Before(minMilli) || ti.After(maxMilli) +} + +func TestMillisecond_MarshalUnmarshalJSON(t *testing.T) { + for _, tt := range marshalUnmarshalTestCases { + t.Run(tt.name, func(t *testing.T) { + v := Millisecond(tt.t) + + b, err := json.Marshal(v) + require.NoError(t, err) + + assert.Equal(t, tt.millisecond, string(b)) + + if millisecondSkipTestCase(t, tt.t) { + return + } + + var got Millisecond + err = json.Unmarshal(b, &got) + require.NoError(t, err) + + want := tt.t.Truncate(time.Millisecond) + + assert.Equal(t, want.UTC(), time.Time(got).UTC()) + assert.Equal(t, time.Local, time.Time(got).Location()) + }) + } +} + +func TestMillisecond_MarshalUnmarshalYAML(t *testing.T) { + for _, tt := range marshalUnmarshalTestCases { + t.Run(tt.name, func(t *testing.T) { + v := Millisecond(tt.t) + + b, err := yaml.Marshal(v) + require.NoError(t, err) + + assert.Equal(t, tt.millisecond+"\n", string(b)) + + if millisecondSkipTestCase(t, tt.t) { + return + } + + var got Millisecond + err = yaml.Unmarshal(b, &got) + require.NoError(t, err) + + want := tt.t.Truncate(time.Millisecond) + + assert.Equal(t, want.UTC(), time.Time(got).UTC()) + assert.Equal(t, time.Local, time.Time(got).Location()) + }) + } +} + +func TestMillisecond_MarshalJSON(t *testing.T) { + for _, tt := range marshalUnmarshalTestCases { + t.Run(tt.name, func(t *testing.T) { + ts := Millisecond(tt.t) + + b, err := json.Marshal(ts) + require.NoError(t, err) + + assert.Equal(t, tt.millisecond, string(b)) + }) + } +} + +func TestMillisecond_MarshalYAML(t *testing.T) { + for _, tt := range marshalUnmarshalTestCases { + t.Run(tt.name, func(t *testing.T) { + ts := Millisecond(tt.t) + + b, err := yaml.Marshal(ts) + require.NoError(t, err) + + assert.Equal(t, tt.millisecond+"\n", string(b)) + }) + } +} + +func TestMillisecond_UnmarshalJSON(t *testing.T) { + for _, tt := range marshalUnmarshalTestCases { + if millisecondSkipTestCase(t, tt.t) { + continue + } + t.Run(tt.name, func(t *testing.T) { + var ts Millisecond + + err := json.Unmarshal([]byte(tt.millisecond), &ts) + require.NoError(t, err) + + want := tt.t.Truncate(time.Millisecond) + + assert.Equal(t, want.UTC(), time.Time(ts).UTC()) + }) + } + for _, tt := range unmarshalTestCases { + t.Run(tt.name, func(t *testing.T) { + var ts Millisecond + + err := json.Unmarshal([]byte(tt.millisecond), &ts) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + want := tt.t.Truncate(time.Millisecond) + assert.Equal(t, want.UTC(), time.Time(ts).UTC()) + } + }) + } +} + +func TestMillisecond_UnmarshalYAML(t *testing.T) { + for _, tt := range marshalUnmarshalTestCases { + if millisecondSkipTestCase(t, tt.t) { + continue + } + t.Run(tt.name, func(t *testing.T) { + var ts Millisecond + + err := yaml.Unmarshal([]byte(tt.millisecond), &ts) + require.NoError(t, err) + + want := tt.t.Truncate(time.Millisecond) + + assert.Equal(t, want.UTC(), time.Time(ts).UTC()) + }) + } + for _, tt := range unmarshalTestCases { + t.Run(tt.name, func(t *testing.T) { + var ts Millisecond + + err := yaml.Unmarshal([]byte(tt.millisecond), &ts) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + want := tt.t.Truncate(time.Millisecond) + assert.Equal(t, want.UTC(), time.Time(ts).UTC()) + } + }) + } +} diff --git a/ts/nanosecond.go b/ts/nanosecond.go new file mode 100644 index 0000000..9791d3b --- /dev/null +++ b/ts/nanosecond.go @@ -0,0 +1,92 @@ +package ts + +import ( + "strconv" + "time" + + "gopkg.in/yaml.v3" +) + +// Nanosecond is a wrapper around time.Time for marshaling to/from JSON/YAML as +// nanosecond-based numeric Unix timestamps. +// +// It marshals to a JSON/YAML number representing the number of nanoseconds +// since the Unix time epoch. +// +// It unmarshals from a JSON/YAML number representing the number of nanoseconds +// since the Unix time epoch. +type Nanosecond time.Time + +// Time returns the time.Time corresponding to the nanosecond instant s. +func (ns Nanosecond) Time() time.Time { + return time.Time(ns) +} + +// Local returns the local time corresponding to the nanosecond instant s. +func (ns Nanosecond) Local() Nanosecond { + return Nanosecond(time.Time(ns).Local()) +} + +// GoString implements the fmt.GoStringer interface. +func (ns Nanosecond) GoString() string { + return time.Time(ns).GoString() +} + +// IsDST reports whether the nanosecond instant s occurs within Daylight Saving +// Time. +func (ns Nanosecond) IsDST() bool { + return time.Time(ns).IsDST() +} + +// IsZero returns true if the Nanosecond is the zero value. +func (ns Nanosecond) IsZero() bool { + return time.Time(ns).IsZero() +} + +// String calls time.Time.String. +func (ns Nanosecond) String() string { + return time.Time(ns).String() +} + +// UTC returns a copy of the Nanosecond with the location set to UTC. +func (ns Nanosecond) UTC() Nanosecond { + return Nanosecond(time.Time(ns).UTC()) +} + +// MarshalJSON implements the json.Marshaler interface. +func (ns Nanosecond) MarshalJSON() ([]byte, error) { + return []byte(strconv.FormatInt(time.Time(ns).UnixNano(), 10)), nil +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (ns *Nanosecond) UnmarshalJSON(data []byte) error { + i, err := unmarshalBytes(data) + if err != nil { + return err + } + + *ns = UnixNano(i) + + return nil +} + +// MarshalJSON implements the yaml.Marshaler interface. +func (ns Nanosecond) MarshalYAML() (interface{}, error) { + return time.Time(ns).UnixNano(), nil +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface. +func (ns *Nanosecond) UnmarshalYAML(node *yaml.Node) error { + i, err := unmarshalYAMLNode(node) + if err != nil { + return err + } + + *ns = UnixNano(i) + + return nil +} + +func unixNano(ts int64) time.Time { + return time.Unix(ts/1e9, ts%1e9) +} diff --git a/ts/nanosecond_test.go b/ts/nanosecond_test.go new file mode 100644 index 0000000..4ace497 --- /dev/null +++ b/ts/nanosecond_test.go @@ -0,0 +1,174 @@ +package ts + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +var ( + minNano = unixNano(-9223372036854775808).UTC() + maxNano = unixNano(9223372036854775807).UTC() +) + +func nanosecondSkipTestCase(t *testing.T, ti time.Time) bool { + t.Helper() + + return ti.Before(minNano) || ti.After(maxNano) +} + +func TestNanosecond_MarshalUnmarshalJSON(t *testing.T) { + for _, tt := range marshalUnmarshalTestCases { + t.Run(tt.name, func(t *testing.T) { + v := Nanosecond(tt.t) + + b, err := json.Marshal(v) + require.NoError(t, err) + + assert.Equal(t, tt.nanosecond, string(b)) + + if nanosecondSkipTestCase(t, tt.t) { + return + } + + var got Nanosecond + err = json.Unmarshal(b, &got) + require.NoError(t, err) + + want := tt.t.Truncate(time.Nanosecond) + + assert.Equal(t, want.UTC(), time.Time(got).UTC()) + assert.Equal(t, time.Local, time.Time(got).Location()) + }) + } +} + +func TestNanosecond_MarshalUnmarshalYAML(t *testing.T) { + for _, tt := range marshalUnmarshalTestCases { + t.Run(tt.name, func(t *testing.T) { + v := Nanosecond(tt.t) + + b, err := yaml.Marshal(v) + require.NoError(t, err) + + assert.Equal(t, tt.nanosecond+"\n", string(b)) + + if nanosecondSkipTestCase(t, tt.t) { + return + } + + var got Nanosecond + err = yaml.Unmarshal(b, &got) + require.NoError(t, err) + + want := tt.t.Truncate(time.Nanosecond) + + assert.Equal(t, want.UTC(), time.Time(got).UTC()) + assert.Equal(t, time.Local, time.Time(got).Location()) + }) + } +} + +func TestNanosecond_MarshalJSON(t *testing.T) { + for _, tt := range marshalUnmarshalTestCases { + t.Run(tt.name, func(t *testing.T) { + ts := Nanosecond(tt.t) + + b, err := json.Marshal(ts) + require.NoError(t, err) + + assert.Equal(t, tt.nanosecond, string(b)) + }) + } +} + +func TestNanosecond_MarshalYAML(t *testing.T) { + for _, tt := range marshalUnmarshalTestCases { + t.Run(tt.name, func(t *testing.T) { + ts := Nanosecond(tt.t) + + b, err := yaml.Marshal(ts) + require.NoError(t, err) + + assert.Equal(t, tt.nanosecond+"\n", string(b)) + }) + } +} + +func TestNanosecond_UnmarshalJSON(t *testing.T) { + for _, tt := range marshalUnmarshalTestCases { + if nanosecondSkipTestCase(t, tt.t) { + continue + } + t.Run(tt.name, func(t *testing.T) { + var ts Nanosecond + + err := json.Unmarshal([]byte(tt.nanosecond), &ts) + require.NoError(t, err) + + want := tt.t.Truncate(time.Nanosecond) + + assert.Equal(t, want.UTC(), time.Time(ts).UTC()) + }) + } + for _, tt := range unmarshalTestCases { + if tt.nanosecond == "" { + continue + } + t.Run(tt.name, func(t *testing.T) { + var ts Nanosecond + + err := json.Unmarshal([]byte(tt.nanosecond), &ts) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + want := tt.t.Truncate(time.Nanosecond) + assert.Equal(t, want.UTC(), time.Time(ts).UTC()) + } + }) + } +} + +func TestNanosecond_UnmarshalYAML(t *testing.T) { + for _, tt := range marshalUnmarshalTestCases { + if nanosecondSkipTestCase(t, tt.t) { + continue + } + t.Run(tt.name, func(t *testing.T) { + var ts Nanosecond + + err := yaml.Unmarshal([]byte(tt.nanosecond), &ts) + require.NoError(t, err) + + want := tt.t.Truncate(time.Nanosecond) + + assert.Equal(t, want.UTC(), time.Time(ts).UTC()) + }) + } + for _, tt := range unmarshalTestCases { + if tt.nanosecond == "" { + continue + } + t.Run(tt.name, func(t *testing.T) { + var ts Nanosecond + + err := yaml.Unmarshal([]byte(tt.nanosecond), &ts) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + want := tt.t.Truncate(time.Nanosecond) + assert.Equal(t, want.UTC(), time.Time(ts).UTC()) + } + }) + } +} diff --git a/ts/second.go b/ts/second.go new file mode 100644 index 0000000..291b2df --- /dev/null +++ b/ts/second.go @@ -0,0 +1,88 @@ +package ts + +import ( + "strconv" + "time" + + "gopkg.in/yaml.v3" +) + +// Second is a wrapper around time.Time for marshaling to/from JSON/YAML as +// second-based numeric Unix timestamps. +// +// It marshals to a JSON/YAML number representing the number of seconds since +// the Unix time epoch. +// +// It unmarshals from a JSON/YAML number representing the number of seconds +// since the Unix time epoch. +type Second time.Time + +// Time returns the time.Time corresponding to the second instant s. +func (s Second) Time() time.Time { + return time.Time(s) +} + +// Local returns the local time corresponding to the second instant s. +func (s Second) Local() Second { + return Second(time.Time(s).Local()) +} + +// GoString implements the fmt.GoStringer interface. +func (s Second) GoString() string { + return time.Time(s).GoString() +} + +// IsDST reports whether the second instant s occurs within Daylight Saving +// Time. +func (s Second) IsDST() bool { + return time.Time(s).IsDST() +} + +// IsZero returns true if the Second is the zero value. +func (s Second) IsZero() bool { + return time.Time(s).IsZero() +} + +// String calls time.Time.String. +func (s Second) String() string { + return time.Time(s).String() +} + +// UTC returns a copy of the Second with the location set to UTC. +func (s Second) UTC() Second { + return Second(time.Time(s).UTC()) +} + +// MarshalJSON implements the json.Marshaler interface. +func (s Second) MarshalJSON() ([]byte, error) { + return []byte(strconv.FormatInt(time.Time(s).Unix(), 10)), nil +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (s *Second) UnmarshalJSON(data []byte) error { + i, err := unmarshalBytes(data) + if err != nil { + return err + } + + *s = UnixSecond(i) + + return nil +} + +// MarshalJSON implements the yaml.Marshaler interface. +func (s Second) MarshalYAML() (interface{}, error) { + return time.Time(s).Unix(), nil +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface. +func (s *Second) UnmarshalYAML(node *yaml.Node) error { + i, err := unmarshalYAMLNode(node) + if err != nil { + return err + } + + *s = UnixSecond(i) + + return nil +} diff --git a/ts/second_test.go b/ts/second_test.go new file mode 100644 index 0000000..b3e17a6 --- /dev/null +++ b/ts/second_test.go @@ -0,0 +1,143 @@ +package ts + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestSecond_MarshalUnmarshalJSON(t *testing.T) { + for _, tt := range marshalUnmarshalTestCases { + t.Run(tt.name, func(t *testing.T) { + v := Second(tt.t) + + b, err := json.Marshal(v) + require.NoError(t, err) + + assert.Equal(t, tt.second, string(b)) + + var got Second + err = json.Unmarshal(b, &got) + require.NoError(t, err) + + want := tt.t.Truncate(time.Second) + + assert.Equal(t, want.UTC(), time.Time(got).UTC()) + assert.Equal(t, time.Local, time.Time(got).Location()) + }) + } +} + +func TestSecond_MarshalUnmarshalYAML(t *testing.T) { + for _, tt := range marshalUnmarshalTestCases { + t.Run(tt.name, func(t *testing.T) { + v := Second(tt.t) + + b, err := yaml.Marshal(v) + require.NoError(t, err) + + assert.Equal(t, tt.second+"\n", string(b)) + + var got Second + err = yaml.Unmarshal(b, &got) + require.NoError(t, err) + + want := tt.t.Truncate(time.Second) + + assert.Equal(t, want.UTC(), time.Time(got).UTC()) + assert.Equal(t, time.Local, time.Time(got).Location()) + }) + } +} + +func TestSecond_MarshalJSON(t *testing.T) { + for _, tt := range marshalUnmarshalTestCases { + t.Run(tt.name, func(t *testing.T) { + ts := Second(tt.t) + + b, err := json.Marshal(ts) + require.NoError(t, err) + + assert.Equal(t, tt.second, string(b)) + }) + } +} + +func TestSecond_MarshalYAML(t *testing.T) { + for _, tt := range marshalUnmarshalTestCases { + t.Run(tt.name, func(t *testing.T) { + ts := Second(tt.t) + + b, err := yaml.Marshal(ts) + require.NoError(t, err) + + assert.Equal(t, tt.second+"\n", string(b)) + }) + } +} + +func TestSecond_UnmarshalJSON(t *testing.T) { + for _, tt := range marshalUnmarshalTestCases { + t.Run(tt.name, func(t *testing.T) { + var ts Second + + err := json.Unmarshal([]byte(tt.second), &ts) + require.NoError(t, err) + + want := tt.t.Truncate(time.Second) + + assert.Equal(t, want.UTC(), time.Time(ts).UTC()) + }) + } + for _, tt := range unmarshalTestCases { + t.Run(tt.name, func(t *testing.T) { + var ts Second + + err := json.Unmarshal([]byte(tt.second), &ts) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + want := tt.t.Truncate(time.Second) + assert.Equal(t, want.UTC(), time.Time(ts).UTC()) + } + }) + } +} + +func TestSecond_UnmarshalYAML(t *testing.T) { + for _, tt := range marshalUnmarshalTestCases { + t.Run(tt.name, func(t *testing.T) { + var ts Second + + err := yaml.Unmarshal([]byte(tt.second), &ts) + require.NoError(t, err) + + want := tt.t.Truncate(time.Second) + + assert.Equal(t, want.UTC(), time.Time(ts).UTC()) + }) + } + for _, tt := range unmarshalTestCases { + t.Run(tt.name, func(t *testing.T) { + var ts Second + + err := yaml.Unmarshal([]byte(tt.second), &ts) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + want := tt.t.Truncate(time.Second) + assert.Equal(t, want.UTC(), time.Time(ts).UTC()) + } + }) + } +} diff --git a/ts/ts.go b/ts/ts.go new file mode 100644 index 0000000..6b0d468 --- /dev/null +++ b/ts/ts.go @@ -0,0 +1,69 @@ +// Package ts provides wrapper types around time.Time, with support for +// JSON/YAML marshaling to/from numeric Unix timestamps of various precisions. +// +// Unmarshaling supports both numeric and string values. Marshaling always +// produces a integer value. +package ts + +import ( + "time" + + "github.com/jimeh/go-tyme/dur" +) + +// Timestamp is a type constraint that matches against time.Time, Second, +// Millisecond, Microsecond, and Nanosecond. +type Timestamp interface { + time.Time | Second | Millisecond | Microsecond | Nanosecond +} + +// Duration is a type constraint that matches against time.Duration and +// dur.Duration. +type Duration interface { + time.Duration | dur.Duration +} + +// Add returns a new Timestamp with given Duration added to it, using +// time.Time.Add. +func Add[T Timestamp, D Duration](ts T, d D) T { + var t time.Time + t = time.Time(ts) + t = t.Add(time.Duration(d)) + + return T(t) +} + +// Sub returns the dur.Duration between two Timestamps, using time.Time.Sub. +func Sub[T, U Timestamp](t T, u U) dur.Duration { + return dur.Duration(time.Time(t).Sub(time.Time(u))) +} + +// After reports whether the Timestamp instant t is after u, using +// time.Time.After. +func After[T, U Timestamp](t T, u U) bool { + return time.Time(t).After(time.Time(u)) +} + +// Before reports whether the Timestamp instant t is before u, using +// time.Time.Before. +func Before[T, U Timestamp](t T, u U) bool { + return time.Time(t).Before(time.Time(u)) +} + +// Equal reports whether t and u represent the same Timestamp instant, using +// time.Time.Equal. +func Equal[T, U Timestamp](t T, u U) bool { + return time.Time(t).Equal(time.Time(u)) +} + +// Round returns the result of rounding t to the nearest multiple of d, using +// time.Time.Round. +func Round[T Timestamp, D Duration](t T, d D) T { + return T(time.Time(t).Round(time.Duration(d))) +} + +// Truncate returns the result of trucating t down to the nearest multiple of d, +// using time.Time.Truncate. +func Truncate[T Timestamp, D Duration](t T, d D) T { + return T(time.Time(t).Truncate(time.Duration(d))) +} diff --git a/ts/ts_test.go b/ts/ts_test.go new file mode 100644 index 0000000..761951f --- /dev/null +++ b/ts/ts_test.go @@ -0,0 +1,524 @@ +package ts + +import ( + "fmt" + "testing" + "time" + + "github.com/jimeh/go-tyme/dur" + "github.com/stretchr/testify/assert" +) + +func TestAdd(t *testing.T) { + testAdd[time.Time, time.Duration](t) + testAdd[time.Time, dur.Duration](t) + + testAdd[Second, time.Duration](t) + testAdd[Second, dur.Duration](t) + + testAdd[Millisecond, time.Duration](t) + testAdd[Millisecond, dur.Duration](t) + + testAdd[Microsecond, time.Duration](t) + testAdd[Microsecond, dur.Duration](t) + + testAdd[Nanosecond, time.Duration](t) + testAdd[Nanosecond, dur.Duration](t) +} + +func testAdd[T Timestamp, D Duration](t *testing.T) { + t.Run( + fmt.Sprintf("[T %T, D %T]", T(time.Time{}), D(0)), + func(t *testing.T) { + tests := []struct { + name string + t T + d D + want T + }{ + { + name: "add 1s", + t: T( + time.Date(2020, 1, 1, 0, 0, 2, 0, time.UTC), + ), + d: D(time.Second), + want: T( + time.Date(2020, 1, 1, 0, 0, 3, 0, time.UTC), + ), + }, + { + name: "remove 1s", + t: T( + time.Date(2020, 1, 1, 0, 0, 2, 0, time.UTC), + ), + d: D(-time.Second), + want: T( + time.Date(2020, 1, 1, 0, 0, 1, 0, time.UTC), + ), + }, + { + name: "add 250ms", + t: T( + time.Date(2020, 1, 1, 0, 0, 2, 0, time.UTC), + ), + d: D(250 * time.Millisecond), + want: T( + time.Date(2020, 1, 1, 0, 0, 2, 250000000, time.UTC), + ), + }, + { + name: "remove 250ms", + t: T( + time.Date(2020, 1, 1, 0, 0, 2, 0, time.UTC), + ), + d: D(-250 * time.Millisecond), + want: T( + time.Date(2020, 1, 1, 0, 0, 1, 750000000, time.UTC), + ), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Add(tt.t, tt.d) + + assert.Equal(t, tt.want, got) + }) + } + }, + ) +} + +func TestSub(t *testing.T) { + testSub[time.Time, time.Time](t) + testSub[time.Time, Second](t) + testSub[time.Time, Millisecond](t) + testSub[time.Time, Microsecond](t) + testSub[time.Time, Nanosecond](t) + + testSub[Second, time.Time](t) + testSub[Second, Second](t) + testSub[Second, Millisecond](t) + testSub[Second, Microsecond](t) + testSub[Second, Nanosecond](t) + + testSub[Millisecond, time.Time](t) + testSub[Millisecond, Millisecond](t) + testSub[Millisecond, Second](t) + testSub[Millisecond, Microsecond](t) + testSub[Millisecond, Nanosecond](t) + + testSub[Microsecond, time.Time](t) + testSub[Microsecond, Second](t) + testSub[Microsecond, Millisecond](t) + testSub[Microsecond, Microsecond](t) + testSub[Microsecond, Nanosecond](t) + + testSub[Nanosecond, time.Time](t) + testSub[Nanosecond, Second](t) + testSub[Nanosecond, Millisecond](t) + testSub[Nanosecond, Microsecond](t) + testSub[Nanosecond, Nanosecond](t) +} + +func testSub[T, U Timestamp](t *testing.T) { + t.Run( + fmt.Sprintf("[T %T, U %T]", T(time.Time{}), U(time.Time{})), + func(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + t T + u U + want time.Duration + }{ + { + name: "-1s", + t: T(now), + u: U(now.Add(time.Second)), + want: -time.Second, + }, + { + name: "1s", + t: T(now), + u: U(now.Add(-time.Second)), + want: time.Second, + }, + { + name: "-250ms", + t: T(now), + u: U(now.Add(250 * time.Millisecond)), + want: -250 * time.Millisecond, + }, + { + name: "250ms", + t: T(now), + u: U(now.Add(-250 * time.Millisecond)), + want: 250 * time.Millisecond, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Sub(tt.t, tt.u) + + assert.Equal(t, tt.want, time.Duration(got)) + }) + } + + t1 := T(now) + u1 := U(now.Add(-time.Second)) + got1 := Sub(t1, u1) + assert.Equal(t, time.Second, time.Duration(got1)) + + t2 := T(now) + u2 := U(now.Add(time.Second)) + got2 := Sub(t2, u2) + assert.Equal(t, -time.Second, time.Duration(got2)) + }, + ) +} + +func TestAfter(t *testing.T) { + testAfter[time.Time, time.Time](t) + testAfter[time.Time, Second](t) + testAfter[time.Time, Millisecond](t) + testAfter[time.Time, Microsecond](t) + testAfter[time.Time, Nanosecond](t) + + testAfter[Second, time.Time](t) + testAfter[Second, Second](t) + testAfter[Second, Millisecond](t) + testAfter[Second, Microsecond](t) + testAfter[Second, Nanosecond](t) + + testAfter[Millisecond, time.Time](t) + testAfter[Millisecond, Millisecond](t) + testAfter[Millisecond, Second](t) + testAfter[Millisecond, Microsecond](t) + testAfter[Millisecond, Nanosecond](t) + + testAfter[Microsecond, time.Time](t) + testAfter[Microsecond, Second](t) + testAfter[Microsecond, Millisecond](t) + testAfter[Microsecond, Microsecond](t) + testAfter[Microsecond, Nanosecond](t) + + testAfter[Nanosecond, time.Time](t) + testAfter[Nanosecond, Second](t) + testAfter[Nanosecond, Millisecond](t) + testAfter[Nanosecond, Microsecond](t) + testAfter[Nanosecond, Nanosecond](t) +} + +func testAfter[T, u Timestamp](t *testing.T) { + t.Run( + fmt.Sprintf("[T %T, U %T]", T(time.Time{}), u(time.Time{})), + func(t *testing.T) { + t1 := T(time.Now()) + o1 := u(time.Now().Add(-time.Second)) + got1 := After(t1, o1) + assert.True(t, got1) + + t2 := T(time.Now()) + o2 := u(time.Now().Add(time.Second)) + got2 := After(t2, o2) + assert.False(t, got2) + }, + ) +} + +func TestBefore(t *testing.T) { + testBefore[time.Time, time.Time](t) + testBefore[time.Time, Second](t) + testBefore[time.Time, Millisecond](t) + testBefore[time.Time, Microsecond](t) + testBefore[time.Time, Nanosecond](t) + + testBefore[Second, time.Time](t) + testBefore[Second, Second](t) + testBefore[Second, Millisecond](t) + testBefore[Second, Microsecond](t) + testBefore[Second, Nanosecond](t) + + testBefore[Millisecond, time.Time](t) + testBefore[Millisecond, Millisecond](t) + testBefore[Millisecond, Second](t) + testBefore[Millisecond, Microsecond](t) + testBefore[Millisecond, Nanosecond](t) + + testBefore[Microsecond, time.Time](t) + testBefore[Microsecond, Second](t) + testBefore[Microsecond, Millisecond](t) + testBefore[Microsecond, Microsecond](t) + testBefore[Microsecond, Nanosecond](t) + + testBefore[Nanosecond, time.Time](t) + testBefore[Nanosecond, Second](t) + testBefore[Nanosecond, Millisecond](t) + testBefore[Nanosecond, Microsecond](t) + testBefore[Nanosecond, Nanosecond](t) +} + +func testBefore[T, U Timestamp](t *testing.T) { + t.Run( + fmt.Sprintf("[T %T, U %T]", T(time.Time{}), U(time.Time{})), + func(t *testing.T) { + t1 := T(time.Now()) + o1 := U(time.Now().Add(-time.Second)) + got1 := Before(t1, o1) + assert.False(t, got1) + + t2 := T(time.Now()) + o2 := U(time.Now().Add(time.Second)) + got2 := Before(t2, o2) + assert.True(t, got2) + }, + ) +} + +func TestEqual(t *testing.T) { + testEqual[time.Time, time.Time](t) + testEqual[time.Time, Second](t) + testEqual[time.Time, Millisecond](t) + testEqual[time.Time, Microsecond](t) + testEqual[time.Time, Nanosecond](t) + + testEqual[Second, time.Time](t) + testEqual[Second, Second](t) + testEqual[Second, Millisecond](t) + testEqual[Second, Microsecond](t) + testEqual[Second, Nanosecond](t) + + testEqual[Millisecond, time.Time](t) + testEqual[Millisecond, Millisecond](t) + testEqual[Millisecond, Second](t) + testEqual[Millisecond, Microsecond](t) + testEqual[Millisecond, Nanosecond](t) + + testEqual[Microsecond, time.Time](t) + testEqual[Microsecond, Second](t) + testEqual[Microsecond, Millisecond](t) + testEqual[Microsecond, Microsecond](t) + testEqual[Microsecond, Nanosecond](t) + + testEqual[Nanosecond, time.Time](t) + testEqual[Nanosecond, Second](t) + testEqual[Nanosecond, Millisecond](t) + testEqual[Nanosecond, Microsecond](t) + testEqual[Nanosecond, Nanosecond](t) +} + +func testEqual[T, U Timestamp](t *testing.T) { + t.Run( + fmt.Sprintf("[T %T, U %T]", T(time.Time{}), U(time.Time{})), + func(t *testing.T) { + t1 := T(time.Now()) + o1 := U(t1) + got1 := Equal(t1, o1) + assert.True(t, got1) + + t2 := T(time.Now()) + o2 := U(time.Now().Add(time.Second)) + got2 := Equal(t2, o2) + assert.False(t, got2) + }, + ) +} + +func TestRound(t *testing.T) { + testRound[time.Time, time.Duration](t) + testRound[time.Time, dur.Duration](t) + + testRound[Second, time.Duration](t) + testRound[Second, dur.Duration](t) + + testRound[Millisecond, time.Duration](t) + testRound[Millisecond, dur.Duration](t) + + testRound[Microsecond, time.Duration](t) + testRound[Microsecond, dur.Duration](t) + + testRound[Nanosecond, time.Duration](t) + testRound[Nanosecond, dur.Duration](t) +} + +func testRound[T Timestamp, D Duration](t *testing.T) { + t.Run( + fmt.Sprintf("[T %T, D %T]", T(time.Time{}), D(0)), + func(t *testing.T) { + tests := []struct { + name string + t T + d D + want T + }{ + { + name: "round up to nearest hour", + t: T( + time.Date(2020, 1, 1, 0, 30, 0, 0, time.UTC), + ), + d: D(time.Hour), + want: T(time.Date(2020, 1, 1, 1, 0, 0, 0, time.UTC)), + }, + { + name: "round down to nearest hour", + t: T( + time.Date(2020, 1, 1, 0, 29, 0, 0, time.UTC), + ), + d: D(time.Hour), + want: T(time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)), + }, + { + name: "round up to nearest minute", + t: T( + time.Date(2020, 1, 1, 0, 0, 30, 0, time.UTC), + ), + d: D(time.Minute), + want: T(time.Date(2020, 1, 1, 0, 1, 0, 0, time.UTC)), + }, + { + name: "round down to nearest minute", + t: T( + time.Date(2020, 1, 1, 0, 0, 29, 0, time.UTC), + ), + d: D(time.Minute), + want: T(time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)), + }, + { + name: "round up to nearest second", + t: T( + time.Date(2020, 1, 1, 0, 0, 2, 500000000, time.UTC), + ), + d: D(time.Second), + want: T(time.Date(2020, 1, 1, 0, 0, 3, 0, time.UTC)), + }, + { + name: "round down to nearest second", + t: T( + time.Date(2020, 1, 1, 0, 0, 2, 499999999, time.UTC), + ), + d: D(time.Second), + want: T(time.Date(2020, 1, 1, 0, 0, 2, 0, time.UTC)), + }, + { + name: "round up to nearest millisecond", + t: T( + time.Date(2020, 1, 1, 0, 0, 2, 500000, time.UTC), + ), + d: D(time.Millisecond), + want: T(time.Date(2020, 1, 1, 0, 0, 2, 1000000, time.UTC)), + }, + { + name: "round down to nearest millisecond", + t: T( + time.Date(2020, 1, 1, 0, 0, 2, 499999, time.UTC), + ), + d: D(time.Millisecond), + want: T(time.Date(2020, 1, 1, 0, 0, 2, 0, time.UTC)), + }, + { + name: "round up to nearest microsecond", + t: T( + time.Date(2020, 1, 1, 0, 0, 2, 500, time.UTC), + ), + d: D(time.Microsecond), + want: T(time.Date(2020, 1, 1, 0, 0, 2, 1000, time.UTC)), + }, + { + name: "round down to nearest microsecond", + t: T( + time.Date(2020, 1, 1, 0, 0, 2, 499, time.UTC), + ), + d: D(time.Microsecond), + want: T(time.Date(2020, 1, 1, 0, 0, 2, 0, time.UTC)), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Round(tt.t, tt.d) + + assert.Equal(t, tt.want, got) + }) + } + }, + ) +} + +func TestTruncate(t *testing.T) { + testTruncate[time.Time, time.Duration](t) + testTruncate[time.Time, dur.Duration](t) + + testTruncate[Second, time.Duration](t) + testTruncate[Second, dur.Duration](t) + + testTruncate[Millisecond, time.Duration](t) + testTruncate[Millisecond, dur.Duration](t) + + testTruncate[Microsecond, time.Duration](t) + testTruncate[Microsecond, dur.Duration](t) + + testTruncate[Nanosecond, time.Duration](t) + testTruncate[Nanosecond, dur.Duration](t) +} + +func testTruncate[T Timestamp, D Duration](t *testing.T) { + t.Run( + fmt.Sprintf("[T %T, D %T]", T(time.Time{}), D(0)), + func(t *testing.T) { + tests := []struct { + name string + t T + d D + want T + }{ + { + name: "truncate down to nearest hour", + t: T( + time.Date(2020, 1, 1, 0, 29, 0, 0, time.UTC), + ), + d: D(time.Hour), + want: T(time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)), + }, + { + name: "truncate down to nearest minute", + t: T( + time.Date(2020, 1, 1, 0, 0, 29, 0, time.UTC), + ), + d: D(time.Minute), + want: T(time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)), + }, + { + name: "truncate down to nearest second", + t: T( + time.Date(2020, 1, 1, 0, 0, 2, 499999999, time.UTC), + ), + d: D(time.Second), + want: T(time.Date(2020, 1, 1, 0, 0, 2, 0, time.UTC)), + }, + { + name: "truncate down to nearest millisecond", + t: T( + time.Date(2020, 1, 1, 0, 0, 2, 499999, time.UTC), + ), + d: D(time.Millisecond), + want: T(time.Date(2020, 1, 1, 0, 0, 2, 0, time.UTC)), + }, + { + name: "truncate down to nearest microsecond", + t: T( + time.Date(2020, 1, 1, 0, 0, 2, 499, time.UTC), + ), + d: D(time.Microsecond), + want: T(time.Date(2020, 1, 1, 0, 0, 2, 0, time.UTC)), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Truncate(tt.t, tt.d) + + assert.Equal(t, tt.want, got) + }) + } + }, + ) +} diff --git a/ts/unix.go b/ts/unix.go new file mode 100644 index 0000000..84b1d1c --- /dev/null +++ b/ts/unix.go @@ -0,0 +1,23 @@ +package ts + +import "time" + +// UnixSecond parses a given int64 as a Unix timestamp with second accuracy. +func UnixSecond(ts int64) Second { + return Second(time.Unix(ts, 0)) +} + +// UnixMilli parses a given int64 as a Unix timestamp with millisecond accuracy. +func UnixMilli(ts int64) Millisecond { + return Millisecond(time.UnixMilli(ts)) +} + +// UnixMicro parses a given int64 as a Unix timestamp with microsecond accuracy. +func UnixMicro(ts int64) Microsecond { + return Microsecond(time.UnixMicro(ts)) +} + +// UnixNano parses a given int64 as a Unix timestamp with nanosecond accuracy. +func UnixNano(ts int64) Nanosecond { + return Nanosecond(unixNano(ts)) +} diff --git a/ts/unix_test.go b/ts/unix_test.go new file mode 100644 index 0000000..0791930 --- /dev/null +++ b/ts/unix_test.go @@ -0,0 +1,81 @@ +package ts + +import ( + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestUnixSecond(t *testing.T) { + for _, tt := range marshalUnmarshalTestCases { + i, err := strconv.ParseInt(tt.second, 10, 64) + if err != nil { + continue + } + + t.Run(tt.name, func(t *testing.T) { + got := UnixSecond(i) + + assert.IsType(t, Second(time.Time{}), got) + + want := tt.t.Truncate(time.Second) + assert.Equal(t, want.UTC(), time.Time(got).UTC()) + }) + } +} + +func TestUnixMilli(t *testing.T) { + for _, tt := range marshalUnmarshalTestCases { + i, err := strconv.ParseInt(tt.millisecond, 10, 64) + if err != nil || millisecondSkipTestCase(t, tt.t) { + continue + } + + t.Run(tt.name, func(t *testing.T) { + got := UnixMilli(i) + + assert.IsType(t, Millisecond(time.Time{}), got) + + want := tt.t.Truncate(time.Millisecond) + assert.Equal(t, want.UTC(), time.Time(got).UTC()) + }) + } +} + +func TestUnixMicro(t *testing.T) { + for _, tt := range marshalUnmarshalTestCases { + i, err := strconv.ParseInt(tt.microsecond, 10, 64) + if err != nil || microsecondSkipTestCase(t, tt.t) { + continue + } + + t.Run(tt.name, func(t *testing.T) { + got := UnixMicro(i) + + assert.IsType(t, Microsecond(time.Time{}), got) + + want := tt.t.Truncate(time.Microsecond) + assert.Equal(t, want.UTC(), time.Time(got).UTC()) + }) + } +} + +func TestUnixNano(t *testing.T) { + for _, tt := range marshalUnmarshalTestCases { + i, err := strconv.ParseInt(tt.nanosecond, 10, 64) + if err != nil || nanosecondSkipTestCase(t, tt.t) { + continue + } + + t.Run(tt.name, func(t *testing.T) { + got := UnixNano(i) + + assert.IsType(t, Nanosecond(time.Time{}), got) + + want := tt.t.Truncate(time.Nanosecond) + assert.Equal(t, want.UTC(), time.Time(got).UTC()) + }) + } +} diff --git a/ts/unmarshal.go b/ts/unmarshal.go new file mode 100644 index 0000000..3593e9d --- /dev/null +++ b/ts/unmarshal.go @@ -0,0 +1,51 @@ +package ts + +import ( + "fmt" + "strconv" + + "gopkg.in/yaml.v3" +) + +func unmarshalBytes(data []byte) (int64, error) { + s, err := strconv.Unquote(string(data)) + if err == nil { + data = []byte(s) + } + + i, err := strconv.ParseInt(string(data), 10, 64) + if err != nil { + var f float64 + f, err = strconv.ParseFloat(string(data), 64) + i = int64(f) + } + + if err != nil { + return 0, fmt.Errorf("invalid numeric timestamp: %s", string(data)) + } + + return i, nil +} + +func unmarshalYAMLNode(node *yaml.Node) (int64, error) { + var i int64 + var err error + var invalid bool + + switch node.Tag { + case "!!int", "!!str": + i, err = strconv.ParseInt(node.Value, 10, 64) + case "!!float": + var f float64 + f, err = strconv.ParseFloat(node.Value, 64) + i = int64(f) + default: + invalid = true + } + + if err != nil || invalid { + return 0, &yaml.TypeError{Errors: []string{"invalid numeric timestamp"}} + } + + return i, nil +}