mirror of
https://github.com/neilotoole/sq.git
synced 2024-11-24 11:54:37 +03:00
Improvements to 'sq version' command (#137)
This commit is contained in:
parent
13cfc0e1c8
commit
66a8c39844
11
CHANGELOG.md
11
CHANGELOG.md
@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [v0.21.0] - 2022-12-30
|
||||
|
||||
### Changed
|
||||
|
||||
- `sq version` respects `--json` flag.
|
||||
- `sq version` respects `--verbose` flag. It also shows less info when `-v` is not set.
|
||||
- `sq version` shows `latest_version` info when `--verbose` and there's a newer version available.
|
||||
|
||||
## [v0.20.0] - 2022-12-29
|
||||
|
||||
### Added
|
||||
@ -92,7 +100,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- [#89]: Bug with SQL generated for joins.
|
||||
|
||||
|
||||
[v0.19.0]: https://github.com/neilotoole/sq/compare/v0.19.0...v0.20.0
|
||||
[v0.21.0]: https://github.com/neilotoole/sq/compare/v0.20.0...v0.21.0
|
||||
[v0.20.0]: https://github.com/neilotoole/sq/compare/v0.19.0...v0.20.0
|
||||
[v0.19.0]: https://github.com/neilotoole/sq/compare/v0.18.2...v0.19.0
|
||||
[v0.18.2]: https://github.com/neilotoole/sq/compare/v0.18.0...v0.18.2
|
||||
[v0.18.0]: https://github.com/neilotoole/sq/compare/v0.17.0...v0.18.0
|
||||
|
@ -1,14 +1,22 @@
|
||||
// Package buildinfo hosts build info variables populated via ldflags.
|
||||
package buildinfo
|
||||
|
||||
// defaultVersion is the default value for Version if not
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/neilotoole/sq/libsq/core/stringz"
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
// DefaultVersion is the default value for Version if not
|
||||
// set via ldflags.
|
||||
const defaultVersion = "v0.0.0-dev"
|
||||
const DefaultVersion = "v0.0.0-dev"
|
||||
|
||||
var (
|
||||
// Version is the build version. If not set at build time via
|
||||
// ldflags, Version takes the value of defaultVersion.
|
||||
Version = defaultVersion
|
||||
// ldflags, Version takes the value of DefaultVersion.
|
||||
Version = DefaultVersion
|
||||
|
||||
// Commit is the commit hash.
|
||||
Commit string
|
||||
@ -16,3 +24,56 @@ var (
|
||||
// Timestamp is the timestamp of when the cli was built.
|
||||
Timestamp string
|
||||
)
|
||||
|
||||
// BuildInfo encapsulates Version, Commit and Timestamp.
|
||||
type BuildInfo struct {
|
||||
Version string `json:"version"`
|
||||
Commit string `json:"commit,omitempty"`
|
||||
Timestamp string `json:"timestamp,omitempty"`
|
||||
}
|
||||
|
||||
// String returns a string representation of BuildInfo.
|
||||
func (bi BuildInfo) String() string {
|
||||
s := bi.Version
|
||||
if bi.Commit != "" {
|
||||
s += " " + bi.Commit
|
||||
}
|
||||
if bi.Timestamp != "" {
|
||||
s += " " + bi.Timestamp
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Info returns BuildInfo.
|
||||
func Info() BuildInfo {
|
||||
return BuildInfo{
|
||||
Version: Version,
|
||||
Commit: Commit,
|
||||
Timestamp: Timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
func init() { //nolint:gochecknoinits
|
||||
if strings.HasSuffix(Version, "~dev") {
|
||||
Version = strings.Replace(Version, "~dev", "-dev", 1)
|
||||
}
|
||||
|
||||
if Version != "" && !semver.IsValid(Version) {
|
||||
// We want to panic here because it is a pipeline/build failure
|
||||
// to have an invalid non-empty Version.
|
||||
panic(fmt.Sprintf("Invalid BuildInfo.Version value: %q", Version))
|
||||
}
|
||||
|
||||
if Timestamp != "" {
|
||||
// Make sure Timestamp is normalized
|
||||
t := stringz.TimestampToRFC3339(Timestamp)
|
||||
if t != "" {
|
||||
Timestamp = t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IsDefaultVersion returns true if Version is empty or DefaultVersion.
|
||||
func IsDefaultVersion() bool {
|
||||
return Version == "" || Version == DefaultVersion
|
||||
}
|
||||
|
@ -555,6 +555,7 @@ type writers struct {
|
||||
srcw output.SourceWriter
|
||||
errw output.ErrorWriter
|
||||
pingw output.PingWriter
|
||||
versionw output.VersionWriter
|
||||
}
|
||||
|
||||
// newWriters returns a writers instance configured per defaults and/or
|
||||
@ -577,6 +578,7 @@ func newWriters(log lg.Log, cmd *cobra.Command, defaults config.Defaults, out, e
|
||||
srcw: tablew.NewSourceWriter(out2, fm),
|
||||
pingw: tablew.NewPingWriter(out2, fm),
|
||||
errw: tablew.NewErrorWriter(errOut2, fm),
|
||||
versionw: tablew.NewVersionWriter(out2, fm),
|
||||
}
|
||||
|
||||
// Invoke getFormat to see if the format was specified
|
||||
@ -589,6 +591,7 @@ func newWriters(log lg.Log, cmd *cobra.Command, defaults config.Defaults, out, e
|
||||
w.recordw = jsonw.NewStdRecordWriter(out2, fm)
|
||||
w.metaw = jsonw.NewMetadataWriter(out2, fm)
|
||||
w.errw = jsonw.NewErrorWriter(log, errOut2, fm)
|
||||
w.versionw = jsonw.NewVersionWriter(out2, fm)
|
||||
|
||||
case config.FormatTable:
|
||||
// Table is the base format, already set above, no need to do anything.
|
||||
|
@ -1,9 +1,17 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/neilotoole/sq/libsq/core/errz"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/mod/semver"
|
||||
|
||||
"github.com/neilotoole/sq/cli/buildinfo"
|
||||
)
|
||||
@ -15,23 +23,110 @@ func newVersionCmd() *cobra.Command {
|
||||
RunE: execVersion,
|
||||
}
|
||||
|
||||
cmd.Flags().BoolP(flagJSON, flagJSONShort, false, flagJSONUsage)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func execVersion(cmd *cobra.Command, args []string) error {
|
||||
rc := RunContextFrom(cmd.Context())
|
||||
rc.writers.fm.Hilite.Fprintf(rc.Out, "sq %s", buildinfo.Version)
|
||||
|
||||
if len(buildinfo.Commit) > 0 {
|
||||
fmt.Fprint(rc.Out, " ")
|
||||
rc.writers.fm.Faint.Fprint(rc.Out, "#"+buildinfo.Commit)
|
||||
// We'd like to display that there's an update available, but
|
||||
// we don't want to wait around long for that.
|
||||
// So, we swallow (but log) any error from the goroutine.
|
||||
ctx, cancelFn := context.WithTimeout(cmd.Context(), time.Second*2)
|
||||
defer cancelFn()
|
||||
|
||||
resultCh := make(chan string)
|
||||
go func() {
|
||||
var err error
|
||||
v, err := fetchBrewVersion(ctx)
|
||||
if err != nil {
|
||||
rc.Log.Error(err)
|
||||
}
|
||||
|
||||
if len(buildinfo.Timestamp) > 0 {
|
||||
fmt.Fprint(rc.Out, " ")
|
||||
rc.writers.fm.Faint.Fprint(rc.Out, buildinfo.Timestamp)
|
||||
// OK if v is empty
|
||||
resultCh <- v
|
||||
}()
|
||||
|
||||
var latestVersion string
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case latestVersion = <-resultCh:
|
||||
if latestVersion != "" && !strings.HasPrefix(latestVersion, "v") {
|
||||
latestVersion = "v" + latestVersion
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintln(rc.Out)
|
||||
return nil
|
||||
return rc.writers.versionw.Version(buildinfo.Info(), latestVersion)
|
||||
}
|
||||
|
||||
func fetchBrewVersion(ctx context.Context) (string, error) {
|
||||
const u = `https://raw.githubusercontent.com/neilotoole/homebrew-sq/master/sq.rb`
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", u, http.NoBody)
|
||||
if err != nil {
|
||||
return "", errz.Err(err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", errz.Wrap(err, "failed to check edgectl brew repo")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if err != nil {
|
||||
return "", errz.Errorf("failed to check edgectl brew repo: %d %s",
|
||||
resp.StatusCode, http.StatusText(resp.StatusCode))
|
||||
}
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", errz.Wrap(err, "failed to read edgectl brew repo body")
|
||||
}
|
||||
|
||||
return getVersionFromBrewFormula(body)
|
||||
}
|
||||
|
||||
// getVersionFromBrewFormula returns the first brew version
|
||||
// from f, which is a brew ruby formula. The version is returned
|
||||
// without a "v" prefix, e.g. "0.1.2", not "v0.1.2".
|
||||
func getVersionFromBrewFormula(f []byte) (string, error) {
|
||||
var (
|
||||
line string
|
||||
val string
|
||||
err error
|
||||
)
|
||||
|
||||
sc := bufio.NewScanner(bytes.NewReader(f))
|
||||
for sc.Scan() {
|
||||
line = sc.Text()
|
||||
if err = sc.Err(); err != nil {
|
||||
return "", errz.Err(err)
|
||||
}
|
||||
|
||||
val = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(val, `version "`) {
|
||||
// found it
|
||||
val = val[9:]
|
||||
val = strings.TrimSuffix(val, `"`)
|
||||
if !semver.IsValid("v" + val) { // semver pkg requires "v" prefix
|
||||
return "", errz.Errorf("invalid brew formula: invalid semver")
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "bottle") {
|
||||
// Gone too far
|
||||
return "", errz.New("unable to parse brew formula")
|
||||
}
|
||||
}
|
||||
|
||||
if sc.Err() != nil {
|
||||
return "", errz.Wrap(err, "invalid brew formula")
|
||||
}
|
||||
|
||||
return "", errz.New("invalid brew formula")
|
||||
}
|
||||
|
26
cli/cmd_version_test.go
Normal file
26
cli/cmd_version_test.go
Normal file
@ -0,0 +1,26 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/mod/semver"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetVersionFromBrewFormula(t *testing.T) {
|
||||
f, err := os.ReadFile("testdata/sq-0.20.0.rb")
|
||||
require.NoError(t, err)
|
||||
|
||||
vers, err := getVersionFromBrewFormula(f)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "0.20.0", vers)
|
||||
}
|
||||
|
||||
func TestFetchBrewVersion(t *testing.T) {
|
||||
latest, err := fetchBrewVersion(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.True(t, semver.IsValid("v"+latest))
|
||||
}
|
@ -3,15 +3,42 @@ package jsonw
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/neilotoole/sq/cli/output"
|
||||
"github.com/neilotoole/sq/cli/output/jsonw/internal"
|
||||
jcolorenc "github.com/neilotoole/sq/cli/output/jsonw/internal/jcolorenc"
|
||||
"github.com/neilotoole/sq/libsq/core/errz"
|
||||
"github.com/neilotoole/sq/libsq/core/sqlz"
|
||||
)
|
||||
|
||||
// writeJSON prints a JSON representation of v to out, using specs
|
||||
// from fm.
|
||||
func writeJSON(out io.Writer, fm *output.Formatting, v any) error {
|
||||
buf := &bytes.Buffer{}
|
||||
|
||||
enc := jcolorenc.NewEncoder(buf)
|
||||
enc.SetColors(internal.NewColors(fm))
|
||||
enc.SetEscapeHTML(false)
|
||||
if fm.Pretty {
|
||||
enc.SetIndent("", fm.Indent)
|
||||
}
|
||||
|
||||
err := enc.Encode(v)
|
||||
if err != nil {
|
||||
return errz.Err(err)
|
||||
}
|
||||
|
||||
_, err = fmt.Fprint(out, buf.String())
|
||||
if err != nil {
|
||||
return errz.Err(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewStdRecordWriter returns a record writer that outputs each
|
||||
// record as a JSON object that is an element of JSON array. This is
|
||||
// to say, standard JSON. For example:
|
||||
|
36
cli/output/jsonw/versionw.go
Normal file
36
cli/output/jsonw/versionw.go
Normal file
@ -0,0 +1,36 @@
|
||||
package jsonw
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/neilotoole/sq/cli/buildinfo"
|
||||
"github.com/neilotoole/sq/cli/output"
|
||||
)
|
||||
|
||||
var _ output.VersionWriter = (*versionWriter)(nil)
|
||||
|
||||
// versionWriter implements output.VersionWriter for JSON.
|
||||
type versionWriter struct {
|
||||
out io.Writer
|
||||
fm *output.Formatting
|
||||
}
|
||||
|
||||
// NewVersionWriter returns a new output.VersionWriter instance
|
||||
// that outputs version info in JSON.
|
||||
func NewVersionWriter(out io.Writer, fm *output.Formatting) output.VersionWriter {
|
||||
return &versionWriter{out: out, fm: fm}
|
||||
}
|
||||
|
||||
func (w *versionWriter) Version(info buildinfo.BuildInfo, latestVersion string) error {
|
||||
type cliBuildInfo struct {
|
||||
buildinfo.BuildInfo
|
||||
LatestVersion string `json:"latest_version"`
|
||||
}
|
||||
|
||||
bi := cliBuildInfo{
|
||||
BuildInfo: info,
|
||||
LatestVersion: latestVersion,
|
||||
}
|
||||
|
||||
return writeJSON(w.out, w.fm, bi)
|
||||
}
|
54
cli/output/tablew/versionw.go
Normal file
54
cli/output/tablew/versionw.go
Normal file
@ -0,0 +1,54 @@
|
||||
package tablew
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/neilotoole/sq/cli/buildinfo"
|
||||
"github.com/neilotoole/sq/cli/output"
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
var _ output.VersionWriter = (*versionWriter)(nil)
|
||||
|
||||
// versionWriter implements output.VersionWriter for JSON.
|
||||
type versionWriter struct {
|
||||
out io.Writer
|
||||
fm *output.Formatting
|
||||
}
|
||||
|
||||
// NewVersionWriter returns a new output.VersionWriter instance
|
||||
// that outputs version info in JSON.
|
||||
func NewVersionWriter(out io.Writer, fm *output.Formatting) output.VersionWriter {
|
||||
return &versionWriter{out: out, fm: fm}
|
||||
}
|
||||
|
||||
func (w *versionWriter) Version(bi buildinfo.BuildInfo, latestVersion string) error {
|
||||
fmt.Fprintf(w.out, "sq %s", bi.Version)
|
||||
|
||||
// Only print more if --verbose is set.
|
||||
if !w.fm.Verbose {
|
||||
fmt.Fprintln(w.out)
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(bi.Commit) > 0 {
|
||||
fmt.Fprint(w.out, " ")
|
||||
w.fm.Faint.Fprint(w.out, "#"+bi.Commit)
|
||||
}
|
||||
|
||||
if len(bi.Timestamp) > 0 {
|
||||
fmt.Fprint(w.out, " ")
|
||||
w.fm.Faint.Fprint(w.out, bi.Timestamp)
|
||||
}
|
||||
|
||||
showUpdate := semver.Compare(latestVersion, bi.Version) > 0
|
||||
if showUpdate {
|
||||
fmt.Fprint(w.out, " ")
|
||||
w.fm.Faint.Fprint(w.out, "Update available: ")
|
||||
w.fm.Number.Fprint(w.out, latestVersion)
|
||||
}
|
||||
|
||||
fmt.Fprintln(w.out)
|
||||
return nil
|
||||
}
|
@ -9,6 +9,7 @@ package output
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/neilotoole/sq/cli/buildinfo"
|
||||
"github.com/neilotoole/sq/libsq/core/sqlz"
|
||||
"github.com/neilotoole/sq/libsq/driver"
|
||||
"github.com/neilotoole/sq/libsq/source"
|
||||
@ -81,6 +82,14 @@ type PingWriter interface {
|
||||
Close() error
|
||||
}
|
||||
|
||||
// VersionWriter prints the CLI version.
|
||||
type VersionWriter interface {
|
||||
// Version prints version info. Arg latestVersion is the latest
|
||||
// version available from the homebrew repository. The value
|
||||
// may be empty.
|
||||
Version(info buildinfo.BuildInfo, latestVersion string) error
|
||||
}
|
||||
|
||||
// FlushThreshold is the size in bytes after which a writer
|
||||
// should flush any internal buffer.
|
||||
const FlushThreshold = 1000
|
||||
|
68
cli/testdata/sq-0.20.0.rb
vendored
Normal file
68
cli/testdata/sq-0.20.0.rb
vendored
Normal file
@ -0,0 +1,68 @@
|
||||
# typed: false
|
||||
# frozen_string_literal: true
|
||||
|
||||
# This file was generated by GoReleaser. DO NOT EDIT.
|
||||
class Sq < Formula
|
||||
desc "sq: swiss-army knife for data"
|
||||
homepage "https://github.com/neilotoole/sq"
|
||||
version "0.20.0"
|
||||
license "MIT"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/neilotoole/sq/releases/download/v0.20.0/sq-0.20.0-macos-arm64.tar.gz"
|
||||
sha256 "3804a2cbb7789df4d0008af45dea59969a932b242ec7f9e18c71deeb4132d4b5"
|
||||
|
||||
def install
|
||||
bin.install "sq"
|
||||
bash_completion.install "completions/sq.bash" => "sq"
|
||||
zsh_completion.install "completions/sq.zsh" => "_sq"
|
||||
fish_completion.install "completions/sq.fish"
|
||||
man1.install "manpages/sq.1.gz"
|
||||
end
|
||||
end
|
||||
if Hardware::CPU.intel?
|
||||
url "https://github.com/neilotoole/sq/releases/download/v0.20.0/sq-0.20.0-macos-amd64.tar.gz"
|
||||
sha256 "5674316e5a8efd4430a30679cf5129e5c2e9966e30d294ba0901266ce8c9e212"
|
||||
|
||||
def install
|
||||
bin.install "sq"
|
||||
bash_completion.install "completions/sq.bash" => "sq"
|
||||
zsh_completion.install "completions/sq.zsh" => "_sq"
|
||||
fish_completion.install "completions/sq.fish"
|
||||
man1.install "manpages/sq.1.gz"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm? && Hardware::CPU.is_64_bit?
|
||||
url "https://github.com/neilotoole/sq/releases/download/v0.20.0/sq-0.20.0-linux-arm64.tar.gz"
|
||||
sha256 "0769b928a202cf0c7174b6e0fb4f086375600bba333e8bbc2ec45a8b8d43464d"
|
||||
|
||||
def install
|
||||
bin.install "sq"
|
||||
bash_completion.install "completions/sq.bash" => "sq"
|
||||
zsh_completion.install "completions/sq.zsh" => "_sq"
|
||||
fish_completion.install "completions/sq.fish"
|
||||
man1.install "manpages/sq.1.gz"
|
||||
end
|
||||
end
|
||||
if Hardware::CPU.intel?
|
||||
url "https://github.com/neilotoole/sq/releases/download/v0.20.0/sq-0.20.0-linux-amd64.tar.gz"
|
||||
sha256 "38263083d09dc5c0f5dd1d3cf24daac90fa7b013c865be7c9192fe7eca439486"
|
||||
|
||||
def install
|
||||
bin.install "sq"
|
||||
bash_completion.install "completions/sq.bash" => "sq"
|
||||
zsh_completion.install "completions/sq.zsh" => "_sq"
|
||||
fish_completion.install "completions/sq.fish"
|
||||
man1.install "manpages/sq.1.gz"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test do
|
||||
system "#{bin}/sq --version"
|
||||
end
|
||||
end
|
@ -402,3 +402,131 @@ func TrimLen(s string, maxLen int) string {
|
||||
|
||||
return s[:maxLen]
|
||||
}
|
||||
|
||||
const (
|
||||
// RFC3339Milli is an RFC3339 format with millisecond precision.
|
||||
RFC3339Milli = "2006-01-02T15:04:05.000Z07:00"
|
||||
|
||||
// RFC3339MilliZulu is the same as RFC3339Milli, but in zulu time.
|
||||
RFC3339MilliZulu = "2006-01-02T15:04:05.000Z"
|
||||
|
||||
// rfc3339variant is a variant using "-0700" suffix.
|
||||
rfc3339variant = "2006-01-02T15:04:05-0700"
|
||||
|
||||
// RFC3339Zulu is an RFC3339 format, in Zulu time.
|
||||
RFC3339Zulu = "2006-01-02T15:04:05Z"
|
||||
|
||||
// ISO8601 is similar to RFC3339Milli, but doesn't have the colon
|
||||
// in the timezone offset.
|
||||
ISO8601 = "2006-01-02T15:04:05.000Z0700"
|
||||
|
||||
// DateOnly is a date-only format.
|
||||
DateOnly = "2006-01-02"
|
||||
)
|
||||
|
||||
// TimestampUTC returns the RFC3339Milli representation of t in UTC.
|
||||
func TimestampUTC(t time.Time) string {
|
||||
return t.UTC().Format(RFC3339Milli)
|
||||
}
|
||||
|
||||
// DateUTC returns a date representation (2020-10-31) of t in UTC.
|
||||
func DateUTC(t time.Time) string {
|
||||
return t.UTC().Format(DateOnly)
|
||||
}
|
||||
|
||||
// TimestampToRFC3339 takes a RFC3339Milli, ISO8601 or RFC3339
|
||||
// timestamp, and returns RFC3339. That is, the milliseconds are dropped.
|
||||
// On error, the empty string is returned.
|
||||
func TimestampToRFC3339(s string) string {
|
||||
t, err := ParseTimestampUTC(s)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return t.UTC().Format(RFC3339Zulu)
|
||||
}
|
||||
|
||||
// TimestampToDate takes a RFC3339Milli, ISO8601 or RFC3339
|
||||
// timestamp, and returns just the date component.
|
||||
// On error, the empty string is returned.
|
||||
func TimestampToDate(s string) string {
|
||||
t, err := ParseTimestampUTC(s)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return t.UTC().Format(DateOnly)
|
||||
}
|
||||
|
||||
// ParseTimestampUTC is the counterpart of TimestampUTC. It attempts
|
||||
// to parse s first in RFC3339Milli, then time.RFC3339 format, falling
|
||||
// back to the subtly different ISO8601 format.
|
||||
func ParseTimestampUTC(s string) (time.Time, error) {
|
||||
t, err := time.Parse(RFC3339Milli, s)
|
||||
if err == nil {
|
||||
return t.UTC(), nil
|
||||
}
|
||||
|
||||
// Fallback to RFC3339
|
||||
t, err = time.Parse(time.RFC3339, s)
|
||||
if err == nil {
|
||||
return t.UTC(), nil
|
||||
}
|
||||
|
||||
// Fallback to ISO8601
|
||||
t, err = time.Parse(ISO8601, s)
|
||||
if err == nil {
|
||||
return t.UTC(), nil
|
||||
}
|
||||
|
||||
t, err = time.Parse(rfc3339variant, s)
|
||||
if err == nil {
|
||||
return t.UTC(), nil
|
||||
}
|
||||
|
||||
return time.Time{}, errz.Errorf("failed to parse timestamp {%s}", s)
|
||||
}
|
||||
|
||||
// ParseLocalDate accepts a date string s, returning the local midnight
|
||||
// time of that date. Arg s must in format "2006-01-02".
|
||||
func ParseLocalDate(s string) (time.Time, error) {
|
||||
if !strings.ContainsRune(s, 'T') {
|
||||
// It's a date
|
||||
t, err := time.ParseInLocation("2006-01-02", s, time.Local)
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// There's a 'T' in s, which means its probably a timestamp.
|
||||
return time.Time{}, errz.Errorf("invalid date format: %s", s)
|
||||
}
|
||||
|
||||
// ParseUTCDate accepts a date string s, returning the UTC midnight
|
||||
// time of that date. Arg s must in format "2006-01-02".
|
||||
func ParseUTCDate(s string) (time.Time, error) {
|
||||
if !strings.ContainsRune(s, 'T') {
|
||||
// It's a date
|
||||
t, err := time.ParseInLocation("2006-01-02", s, time.UTC)
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// There's a 'T' in s, which means its probably a timestamp.
|
||||
return time.Time{}, errz.Errorf("invalid date format: %s", s)
|
||||
}
|
||||
|
||||
// ParseDateOrTimestampUTC attempts to parse s as either
|
||||
// a date (see ParseUTCDate), or timestamp (see ParseTimestampUTC).
|
||||
// The returned time is in UTC.
|
||||
func ParseDateOrTimestampUTC(s string) (time.Time, error) {
|
||||
if strings.ContainsRune(s, 'T') {
|
||||
return ParseTimestampUTC(s)
|
||||
}
|
||||
|
||||
t, err := ParseUTCDate(s)
|
||||
return t.UTC(), err
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ package stringz_test
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/neilotoole/sq/testh/tutil"
|
||||
|
||||
@ -303,3 +304,54 @@ func TestLineCount(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimestampUTC(t *testing.T) {
|
||||
tm := time.Date(2021, 0o1, 0o1, 7, 7, 7, 0, time.UTC)
|
||||
s := stringz.TimestampUTC(tm)
|
||||
t.Log(s)
|
||||
require.Equal(t, "2021-01-01T07:07:07.000Z", s)
|
||||
|
||||
s = stringz.TimestampUTC(time.Now().UTC())
|
||||
t.Log(s)
|
||||
}
|
||||
|
||||
func TestParseDateOrTimestampUTC(t *testing.T) {
|
||||
testCases := []struct {
|
||||
in string
|
||||
want int64
|
||||
wantErr bool
|
||||
}{
|
||||
{in: "", wantErr: true},
|
||||
{in: "not_a_time", wantErr: true},
|
||||
{in: "2021-01-16T18:18:49.348-0700", want: 1610846329},
|
||||
{in: "2021-01-16T18:26:39.216-07:00", want: 1610846799},
|
||||
{in: "2021-01-16T18:26:39-07:00", want: 1610846799},
|
||||
{in: "2021-01-17T01:26:39.216Z", want: 1610846799},
|
||||
{in: "2021-01-17", want: 1610841600},
|
||||
}
|
||||
|
||||
for i, tc := range testCases {
|
||||
tc := tc
|
||||
t.Run(tutil.Name(i, tc.in), func(t *testing.T) {
|
||||
tm, err := stringz.ParseDateOrTimestampUTC(tc.in)
|
||||
if tc.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
t.Logf("Got: %s", stringz.TimestampUTC(tm))
|
||||
ut := tm.Unix()
|
||||
require.Equal(t, tc.want, ut)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestZuluTimestamp(t *testing.T) {
|
||||
const (
|
||||
input = `2022-12-30T09:36:31-0700`
|
||||
want = `2022-12-30T16:36:31Z`
|
||||
)
|
||||
got := stringz.TimestampToRFC3339(input)
|
||||
require.Equal(t, want, got)
|
||||
}
|
||||
|
15
testh/post-install-macos.sh
Executable file
15
testh/post-install-macos.sh
Executable file
@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
# ".completions.sh regenerates completions to "./completions".
|
||||
|
||||
set +e
|
||||
brew uninstall neilotoole/sq/sq
|
||||
|
||||
if [[ $(which sq) ]]; then
|
||||
echo "sq is still present"
|
||||
rm $(which sq)
|
||||
fi
|
||||
|
||||
set -e
|
||||
brew install neilotoole/sq/sq
|
||||
|
||||
# TODO: Test the version
|
Loading…
Reference in New Issue
Block a user