diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..24f6a9e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Go xsd:duration + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6c49d55 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.sr.ht/~mariusor/go-xsd-duration + +go 1.14 diff --git a/xsd_duration.go b/xsd_duration.go new file mode 100644 index 0000000..1027350 --- /dev/null +++ b/xsd_duration.go @@ -0,0 +1,244 @@ +// Handles xsd:duration to time.Duration conversion + +package xsd + +import ( + "bytes" + "fmt" + "strconv" + "time" +) + +// Extending time constants with the values for day, week, month, year +const ( + Day = time.Hour * 24 + // These values are not precise, probably why the time package does not implement them + // We need them here because xsd:duration uses them + Monthish = Day * 30 + Yearish = Day * 356 +) + +// durationPair holds information about a pair of (uint/ufloat)(PeriodTag) values in a xsd:duration string +type durationPair struct { + v time.Duration + typ byte +} + +func getTimeBaseDuration(b byte) time.Duration { + switch b { + case tagHour: + return time.Hour + case tagMinute: + return time.Minute + case tagSecond: + return time.Second + } + return 0 +} + +func getDateBaseDuration(b byte) time.Duration { + switch b { + case tagYear: + return Yearish + case tagMonth: + return Monthish + case tagDay: + return Day + } + return 0 +} + +// validByteForFloats checks +func validByteForFloats(b byte) bool { + // + , - . 0 1 2 3 4 5 6 7 8 9 + return (b >= 43 && b <= 46) || (b >= 48 && b <= 57) +} + +func parseTagWithValue(data []byte, start, tagPos int, isTime bool) (*durationPair, error) { + d := new(durationPair) + d.typ = data[tagPos] + if d.typ == tagSecond { + // seconds can be represented in float, we need to parse accordingly + if v, err := strconv.ParseFloat(string(data[start:tagPos]), 32); err == nil { + d.v = time.Duration(float64(time.Second) * v) + } + } else { + if v, err := strconv.ParseInt(string(data[start:tagPos]), 10, 32); err == nil { + if isTime { + d.v = getTimeBaseDuration(d.typ) * time.Duration(v) + } else { + d.v = getDateBaseDuration(d.typ) * time.Duration(v) + } + } + } + if d.v < 0 { + return nil, fmt.Errorf("the minus sign must appear first in the duration") + } + return d, nil +} + +// loadUintVal receives the data and position at which to try to load the duration element +// isTime is used for distinguishing between P1M - 1 month, and PT1M - 1 minute +// it returns a duration pair if it can read one at the start of data, +// and the number of bytes read from data +func loadUintVal(data []byte, start int, isTime bool) (*durationPair, int, error) { + for i := start; i < len(data); i++ { + chr := data[i] + if validTag(data[i]) { + d, err := parseTagWithValue(data, start, i, isTime) + return d, i, err + } + if validByteForFloats(chr) { + continue + } + return nil, i, fmt.Errorf("invalid character %c at pos %d", chr, i) + } + return nil, len(data), fmt.Errorf("unable to recognize any duration value") +} + +const ( + tagDuration = 'P' + tagTime = 'T' + tagYear = 'Y' + tagMonth = 'M' + tagDay = 'D' + tagHour = 'H' + tagMinute = 'M' + tagSecond = 'S' +) + +func validTag(b byte) bool { + return b == tagYear || b == tagMonth || b == tagDay || b == tagHour || b == tagMinute || b == tagSecond +} + +// Unmarshal takes a byte array and unmarshals it to a time.Duration value +// It is used to parse values in the following format: -PuYuMuDTuHuMufS, where: +// * - shows if the duration is negative +// * P is the duration tag +// * T is the time tag separator +// * Y,M,D,H,M,S are tags for year, month, day, hour, minute, second values +// * u is an unsigned integer value +// * uf is an unsigned float value (just for seconds) +func Unmarshal(data []byte, d *time.Duration) error { + if len(data) == 0 { + return fmt.Errorf("invalid xsd:duration: empty value") + } + pos := 0 + // loading if the value is negative + negative := data[pos] == '-' + if negative { + // skipping over the minus + pos++ + } + if data[pos] != tagDuration { + return fmt.Errorf("invalid xsd:duration: first character must be %q", 'P') + } + // skipping over the "P" + pos++ + + onePastEnd := len(data) + if pos >= onePastEnd { + return fmt.Errorf("invalid xsd:duration: at least one number and designator are required") + } + isTime := false + duration := time.Duration(0) + for { + if data[pos] == tagTime { + pos++ + isTime = true + } + p, cnt, err := loadUintVal(data, pos, isTime) + if err != nil { + return fmt.Errorf("invalid xsd:duration: %w", err) + } + duration += p.v + pos = cnt + 1 + if pos+1 >= onePastEnd { + break + } + } + if negative { + duration *= -1 + } + if onePastEnd > pos+1 { + return fmt.Errorf("data contains more bytes than we are able to parse") + } + if d == nil { + return fmt.Errorf("unable to store time.Duration to nil pointer") + } + *d = duration + return nil +} + +func Days(d time.Duration) float64 { + dd := d / Day + h := d % Day + return float64(dd) + float64(h)/(24*60*60*1e9) +} + +func Months(d time.Duration) float64 { + m := d / Monthish + w := d % Monthish + return float64(m) + float64(w)/(4*7*24*60*60*1e9) +} + +func Years(d time.Duration) float64 { + y := d / Yearish + m := d % Yearish + return float64(y) + float64(m)/(12*4*7*24*60*60*1e9) +} + +func Marshal(d time.Duration) ([]byte, error) { + if d == 0 { + return []byte{tagDuration, tagTime, '0', tagSecond}, nil + } + + neg := d < 0 + if neg { + d = -d + } + y := Years(d) + d -= time.Duration(y) * Yearish + m := Months(d) + d -= time.Duration(m) * Monthish + dd := Days(d) + d -= time.Duration(dd) * Day + H := d.Hours() + d -= time.Duration(H) * time.Hour + M := d.Minutes() + d -= time.Duration(M) * time.Minute + s := d.Seconds() + d -= time.Duration(s) * time.Second + b := bytes.Buffer{} + if neg { + b.Write([]byte{'-'}) + } + b.Write([]byte{'P'}) + if int(y) > 0 { + b.WriteString(fmt.Sprintf("%d%c", int64(y), tagYear)) + } + if int(m) > 0 { + b.WriteString(fmt.Sprintf("%d%c", int64(m), tagMonth)) + } + if int(dd) > 0 { + b.WriteString(fmt.Sprintf("%d%c", int64(dd), tagDay)) + } + + if H+M+s > 0 { + b.Write([]byte{tagTime}) + if int(H) > 0 { + b.WriteString(fmt.Sprintf("%d%c", int64(H), tagHour)) + } + if int(M) > 0 { + b.WriteString(fmt.Sprintf("%d%c", int64(M), tagMinute)) + } + if int(s) > 0 { + if s-float64(int(s)) < 0.01 { + b.WriteString(fmt.Sprintf("%d%c", int(s), tagSecond)) + } else { + b.WriteString(fmt.Sprintf("%.1f%c", s, tagSecond)) + } + } + } + return b.Bytes(), nil +} diff --git a/xsd_duration_test.go b/xsd_duration_test.go new file mode 100644 index 0000000..87189e3 --- /dev/null +++ b/xsd_duration_test.go @@ -0,0 +1,224 @@ +package xsd + +import ( + "bytes" + "testing" + "time" +) + +const ( + P2Y6M5DT12H35M30S = 2*Yearish + 6*Monthish + 5*Day + 12*time.Hour + 35*time.Minute + 30*time.Second + P1DT2H = Day + 2*time.Hour + P20M = 20 * Monthish + PT20M = 20 * time.Minute + P0Y = time.Duration(0) + NegP60D = -1 * (60 * Day) + PT1M30_5S = time.Minute + time.Duration(30.5*float64(time.Second)) +) + +func TestMarshal(t *testing.T) { + type args struct { + d time.Duration + } + tests := []struct { + name string + args args + want []byte + wantErr bool + }{ + { + name: "2 years, 6 months, 5 days, 12 hours, 35 minutes, 30 seconds", + args: args{P2Y6M5DT12H35M30S}, + wantErr: false, + want: []byte("P2Y6M5DT12H35M30S"), + }, + { + name: "1 day, 2 hours", + args: args{P1DT2H}, + wantErr: false, + want: []byte("P1DT2H"), + }, + { + name: "20 months (the number of months can be more than 12)", + args: args{P20M}, + want: []byte("P1Y8M4D"), + wantErr: false, + }, + { + name: "20 minutes", + args: args{PT20M}, + want: []byte("PT20M"), + wantErr: false, + }, + { + name: "0 years", + args: args{P0Y}, + want: []byte("PT0S"), + wantErr: false, + }, + { + name: "minus 60 days", + args: args{NegP60D}, + want: []byte("-P2M"), + wantErr: false, + }, + { + name: "1 minute, 30.5 seconds", + args: args{PT1M30_5S}, + want: []byte("PT1M30.5S"), + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Marshal(tt.args.d) + if (err != nil) != tt.wantErr { + t.Errorf("Marshal() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !bytes.Equal(got, tt.want) { + t.Errorf("Marshal() got = %s, want %s", got, tt.want) + } + }) + } +} + +func TestUnmarshal(t *testing.T) { + type args struct { + data []byte + d *time.Duration + } + tests := []struct { + name string + args args + want time.Duration + wantErr bool + }{ + { + name: "an empty value should not be valid", + args: args{[]byte{}, nil}, + wantErr: true, + }, + { + name: "at least one number and designator are required", + args: args{[]byte("P"), nil}, + wantErr: true, + }, + { + name: "missing type", + args: args{[]byte("PT"), nil}, + wantErr: true, + }, + { + name: "minus sign must appear first", + args: args{[]byte("P-20M"), nil}, + wantErr: true, + }, + { + name: "no time items are present, so \"T\" must not be present", + args: args{[]byte("P20MT"), nil}, + wantErr: true, + }, + { + name: "no value is specified for months, so \"M\" must not be present", + args: args{[]byte("P1YM5D"), nil}, + wantErr: true, + }, + { + name: "only the seconds can be expressed as a decimal", + args: args{[]byte("P15.5Y"), nil}, + wantErr: true, + }, + { + name: "\"T\" must be present to separate days and hours", + args: args{[]byte("P1D2H"), nil}, + wantErr: true, + }, + { + name: "\"P\" must always be present", + args: args{[]byte("1Y2M"), nil}, + wantErr: true, + }, + { + name: "years must appear before months", + args: args{[]byte("P2M1Y"), nil}, + wantErr: true, + }, + { + name: "at least one digit must follow the decimal point if it appears", + args: args{[]byte("PT15.S"), nil}, + wantErr: true, + }, + { + name: "invalid data at the end", + args: args{[]byte("P2Y6M5DT12H35M30Stest"), nil}, + wantErr: true, + }, + { + name: "invalid data at the start", + args: args{[]byte("testP2Y6M5DT12H35M30S"), nil}, + wantErr: true, + }, + { + name: "2 years, 6 months, 5 days, 12 hours, 35 minutes, 30 seconds", + args: args{[]byte("P2Y6M5DT12H35M30S"), new(time.Duration)}, + want: P2Y6M5DT12H35M30S, + wantErr: false, + }, + { + name: "1 day, 2 hours", + args: args{[]byte("P1DT2H"), new(time.Duration)}, + want: P1DT2H, + wantErr: false, + }, + { + name: "20 months (the number of months can be more than 12)", + args: args{[]byte("P20M"), new(time.Duration)}, + want: P20M, + wantErr: false, + }, + { + name: "20 minutes", + args: args{[]byte("PT20M"), new(time.Duration)}, + want: PT20M, + wantErr: false, + }, + { + name: "20 months (0 is permitted as a number, but is not required)", + args: args{[]byte("P0Y20M0D"), new(time.Duration)}, + want: P20M, + wantErr: false, + }, + { + name: "0 years", + args: args{[]byte("P0Y"), new(time.Duration)}, + want: P0Y, + wantErr: false, + }, + { + name: "minus 60 days", + args: args{[]byte("-P60D"), new(time.Duration)}, + want: NegP60D, + wantErr: false, + }, + { + name: "1 minute, 30.5 seconds", + args: args{[]byte("PT1M30.5S"), new(time.Duration)}, + want: PT1M30_5S, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := Unmarshal(tt.args.data, tt.args.d); (err != nil) != tt.wantErr { + t.Errorf("Unmarshal() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.want == 0 { + return + } + if *tt.args.d != tt.want { + t.Errorf("Marshal() got = %s, want %s", tt.args.d, tt.want) + } + }) + } +}