minor improvements on x/jsonx.ISO8601 time parser

note that many new features are coming that I'm designing for the past 2-3 months, this is a commit with just one hotfix
This commit is contained in:
Gerasimos (Makis) Maropoulos 2024-04-08 20:21:21 +03:00
parent ddda4f6998
commit 94cfb5494f
No known key found for this signature in database
GPG Key ID: D6032D1840F48BEC
2 changed files with 196 additions and 27 deletions

View File

@ -32,11 +32,31 @@ const (
// ISO8601Layout holds the time layout for the the javascript iso time.
// Read more at: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString.
ISO8601Layout = "2006-01-02T15:04:05"
// ISO8601ZLayout same as ISO8601Layout but with the timezone suffix.
ISO8601ZLayout = "2006-01-02T15:04:05Z"
// ISO8601ZUTCOffsetLayout ISO 8601 format, with full time and zone with UTC offset.
// ISO8601LayoutWithTimezone same as ISO8601Layout but with the timezone suffix.
ISO8601LayoutWithTimezone = "2006-01-02T15:04:05Z"
// To match Gos standard time layout that pads zeroes for microseconds, you can use the format 2006-01-02T15:04:05.000000Z07:00.
// This layout uses 0s instead of 9s for the fractional second part, which ensures that the microseconds are
// always represented with six digits, padding with leading zeroes if necessary.
// ISO8601ZUTCOffsetLayoutWithMicroseconds = "2006-01-02T15:04:05.000000Z07:00"
// ISO8601ZUTCOffsetLayoutWithMicroseconds ISO 8601 format, with full time and zone with UTC offset.
// Example: 2022-08-10T03:21:00.000000+03:00, 2023-02-04T09:48:14+00:00, 2022-08-09T00:00:00.000000.
ISO8601ZUTCOffsetLayout = "2006-01-02T15:04:05.999999Z07:00" // -07:00
ISO8601ZUTCOffsetLayoutWithMicroseconds = "2006-01-02T15:04:05.999999Z07:00"
// ISO8601ZUTCOffsetLayoutWithoutMicroseconds ISO 8601 format, with full time and zone with UTC offset without microsecond precision.
ISO8601ZUTCOffsetLayoutWithoutMicroseconds = "2006-01-02T15:04:05Z07:00"
/*
The difference between the two time layouts "2006-01-02T15:04:05Z07:00" and "2006-01-02T15:04:05-07:00" is the presence of the Z character:
"2006-01-02T15:04:05Z07:00": The Z indicates that the time is in UTC (Coordinated Universal Time) if theres no offset specified.
When an offset is present, as in +03:00, it indicates the time is in a timezone that is 3 hours ahead of UTC.
The Z is combined with the offset (07:00), which can be positive or negative to represent the timezone difference from UTC.
"2006-01-02T15:04:05-07:00": This layout does not have the Z character and directly uses the offset (-07:00).
Its more straightforward and indicates that the time is in a timezone that is 7 hours behind UTC.
In summary, the Z in the first layout serves as a placeholder for UTC and is used when the time might be in UTC or might have an offset.
The second layout is used when youre directly specifying the offset without any reference to UTC.
Both layouts can parse the timestamp "2024-04-08T04:47:10+03:00" correctly, as they include placeholders for the timezone offset.
*/
)
// ISO8601 describes a time compatible with javascript time format.
@ -55,7 +75,28 @@ func ParseISO8601(s string) (ISO8601, error) {
err error
)
if idx := strings.LastIndexFunc(s, startUTCOffsetIndexFunc); idx > 18 /* should have some distance, with and without milliseconds */ {
// Check if the string contains a timezone offset after the 'T' character.
hasOffset := strings.Contains(s, "Z") || (strings.Index(s, "+") > strings.Index(s, "T")) || (strings.Index(s, "-") > strings.Index(s, "T"))
switch {
case strings.HasSuffix(s, "Z"):
tt, err = time.Parse(ISO8601LayoutWithTimezone, s)
case hasOffset && strings.Contains(s, "."):
tt, err = time.Parse(ISO8601ZUTCOffsetLayoutWithMicroseconds, s)
case hasOffset:
tt, err = parseWithOffset(s)
default:
tt, err = time.Parse(ISO8601Layout, s)
}
if err != nil {
return ISO8601{}, fmt.Errorf("ISO8601: %w", err)
}
return ISO8601(tt), nil
/*
if idx := strings.LastIndexFunc(s, startUTCOffsetIndexFunc); idx > 18 { // should have some distance, with and without milliseconds
length := parseSignedOffset(s[idx:])
if idx+1 > idx+length || len(s) <= idx+length+1 {
@ -71,11 +112,20 @@ func ParseISO8601(s string) (ISO8601, error) {
// E.g. offset of +0300 is returned as 10800 which is - (3 * 60 * 60).
secondsEastUTC := offset * 60 * 60
// fmt.Printf("parsing %s with offset %s, secondsEastUTC: %d, using time layout: %s\n", s, offsetText, secondsEastUTC, ISO8601ZUTCOffsetLayoutWithMicroseconds)
if loc, ok := fixedEastUTCLocations[secondsEastUTC]; ok { // Specific (fixed) zone.
if strings.Contains(s, ".") {
tt, err = time.ParseInLocation(ISO8601ZUTCOffsetLayoutWithMicroseconds, s, loc)
} else {
tt, err = time.ParseInLocation(ISO8601ZUTCOffsetLayout, s, loc)
}
} else { // Local or UTC.
if strings.Contains(s, ".") {
tt, err = time.Parse(ISO8601ZUTCOffsetLayoutWithMicroseconds, s)
} else {
tt, err = time.Parse(ISO8601ZUTCOffsetLayout, s)
}
}
} else if s[len(s)-1] == 'Z' {
tt, err = time.Parse(ISO8601ZLayout, s)
} else {
@ -85,8 +135,56 @@ func ParseISO8601(s string) (ISO8601, error) {
if err != nil {
return ISO8601{}, fmt.Errorf("ISO8601: %w", err)
}
return ISO8601(tt), nil
*/
}
func parseWithOffset(s string) (time.Time, error) {
idx := strings.LastIndexFunc(s, startUTCOffsetIndexFunc)
if idx == -1 {
return time.Time{}, fmt.Errorf("ISO8601: missing timezone offset")
}
offsetText := s[idx:]
secondsEastUTC, err := parseOffsetToSeconds(offsetText)
if err != nil {
return time.Time{}, err
}
loc, ok := fixedEastUTCLocations[secondsEastUTC]
if !ok {
loc = time.FixedZone("", secondsEastUTC)
}
return time.ParseInLocation(ISO8601ZUTCOffsetLayoutWithoutMicroseconds, s, loc)
}
func parseOffsetToSeconds(offsetText string) (int, error) {
if len(offsetText) < 6 {
return 0, fmt.Errorf("ISO8601: invalid timezone offset length: %s", offsetText)
}
sign := offsetText[0]
if sign != '-' && sign != '+' {
return 0, fmt.Errorf("ISO8601: invalid timezone offset sign: %c", sign)
}
hours, err := strconv.Atoi(offsetText[1:3])
if err != nil {
return 0, fmt.Errorf("ISO8601: %w", err)
}
minutes, err := strconv.Atoi(offsetText[4:6])
if err != nil {
return 0, fmt.Errorf("ISO8601: %w", err)
}
secondsEastUTC := (hours*60 + minutes) * 60
if sign == '-' {
secondsEastUTC = -secondsEastUTC
}
return secondsEastUTC, nil
}
// UnmarshalJSON parses the "b" into ISO8601 time.

View File

@ -6,6 +6,77 @@ import (
"time"
)
func TestParseISO8601(t *testing.T) {
tests := []struct {
name string
input string
want ISO8601
wantErr bool
}{
{
name: "Timestamp with microseconds",
input: "2024-01-02T15:04:05.999999Z",
want: ISO8601(time.Date(2024, 01, 02, 15, 04, 05, 999999*1000, time.UTC)),
wantErr: false,
},
{
name: "Timestamp with timezone but no microseconds",
input: "2024-01-02T15:04:05+07:00",
want: ISO8601(time.Date(2024, 01, 02, 15, 04, 05, 0, time.FixedZone("", 7*3600))),
wantErr: false,
},
{
name: "Timestamp with timezone of UTC with microseconds",
input: "2024-04-08T08:05:04.830140+00:00",
// time.Date function interprets the nanosecond parameter. The time.Date function expects the nanosecond parameter to be the entire nanosecond part of the time, not just the microsecond part.
// When we pass 830140 as the nanosecond argument, Go interprets this as 830140 nanoseconds,
// which is equivalent to 000830140 microseconds (padded with leading zeros to fill the nanosecond precision).
// This is why we see 2024-04-08 08:05:04.00083014 +0000 UTC as the output.
// To correctly represent 830140 microseconds, we need to convert it to nanoseconds by multiplying by 1000 (or set the value to 830140000).
want: ISO8601(time.Date(2024, 04, 8, 8, 05, 04, 830140*1000, time.UTC)),
wantErr: false,
},
{
name: "Timestamp with timezone but no microseconds (2)",
input: "2024-04-08T04:47:10+03:00",
want: ISO8601(time.Date(2024, 04, 8, 4, 47, 10, 0, time.FixedZone("", 3*3600))),
wantErr: false,
},
{
name: "Timestamp with Zulu time",
input: "2024-01-02T15:04:05Z",
want: ISO8601(time.Date(2024, 01, 02, 15, 04, 05, 0, time.UTC)),
wantErr: false,
},
{
name: "Basic ISO8601 layout",
input: "2024-01-02T15:04:05",
want: ISO8601(time.Date(2024, 01, 02, 15, 04, 05, 0, time.UTC)),
wantErr: false,
},
{
name: "Invalid format",
input: "2024-01-02",
want: ISO8601{},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseISO8601(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ParseISO8601() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && !time.Time(got).Equal(time.Time(tt.want)) {
t.Errorf("ParseISO8601() = %v (%s), want %v (%s)", got, got.ToTime().String(), tt.want, tt.want.ToTime().String())
}
})
}
}
func TestISO8601(t *testing.T) {
data := `{"start": "2021-08-20T10:05:01", "end": "2021-12-01T17:05:06", "nothing": null, "empty": ""}`
v := struct {