Skip to content

Commit ecca88a

Browse files
authored
feat(args): add support for empty slice un/marshahaling (#692)
1 parent 5f7ce59 commit ecca88a

File tree

7 files changed

+136
-5
lines changed

7 files changed

+136
-5
lines changed

internal/args/args.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99
// validArgNameRegex regex to check that args words are lower-case or digit starting and ending with a letter.
1010
var validArgNameRegex = regexp.MustCompile(`^([a-z][a-z0-9-]*)(\.[a-z0-9-]*)*$`)
1111

12+
const emptySliceValue = "none"
13+
1214
// RawArgs allows to retrieve a simple []string using UnmarshalStruct()
1315
type RawArgs []string
1416

internal/args/args_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type Basic struct {
2525
type Slice struct {
2626
Strings []string
2727
StringsPtr []*string
28+
SlicePtr *[]string
2829
Basics []Basic
2930
}
3031

@@ -129,3 +130,8 @@ func unmarshalHeight(value string, dest interface{}) error {
129130
_, err := fmt.Sscanf(value, "%dcm", h)
130131
return err
131132
}
133+
134+
type SamePrefixArgName struct {
135+
IP string
136+
IPv6 string
137+
}

internal/args/errors.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,17 @@ func (e *CannotUnmarshalError) Error() string {
161161
return fmt.Sprintf("%T is not unmarshalable: %s", e.Dest, e.Err)
162162
}
163163

164+
// ConflictArgError is return when two args that are in conflict with each other are used together.
165+
// e.g cluster=prod cluster.name=test are conflicting args
166+
type ConflictArgError struct {
167+
ArgName1 string
168+
ArgName2 string
169+
}
170+
171+
func (e *ConflictArgError) Error() string {
172+
return fmt.Sprintf("arguments '%s' and '%s' cannot be used simultaneously", e.ArgName1, e.ArgName2)
173+
}
174+
164175
// missingIndices returns a string of all the missing indices between index and length.
165176
// e.g.: missingIndices(index=5, length=0) should return "0,1,2,3"
166177
// e.g.: missingIndices(index=5, length=2) should return "2,3"

internal/args/marshal.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,11 +118,21 @@ func marshal(src reflect.Value, keys []string) (args []string, err error) {
118118
if src.IsNil() {
119119
return nil, nil
120120
}
121+
122+
// When:
123+
// - dest is a pointer to a slice
124+
// - The slice is empty
125+
// we return slice=none
126+
if src.Elem().Kind() == reflect.Slice && src.Elem().Len() == 0 {
127+
return append(args, marshalKeyValue(keys, emptySliceValue)), nil
128+
}
129+
121130
// If type is a pointer we Marshal pointer.Elem()
122131
return marshal(src.Elem(), keys)
123132

124133
case reflect.Slice:
125134
// If type is a slice:
135+
126136
// We loop through all items and marshal them with key = key.0, key.1, ....
127137
args := []string(nil)
128138
for i := 0; i < src.Len(); i++ {

internal/args/marshal_test.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ func TestMarshal(t *testing.T) {
1515
}
1616

1717
stringPtr := "test"
18+
slicePtr := []string{"0", "1", "2"}
1819

1920
run := func(testCase TestCase) func(t *testing.T) {
2021
return func(t *testing.T) {
@@ -76,6 +77,7 @@ func TestMarshal(t *testing.T) {
7677
data: &Slice{
7778
Strings: []string{"1", "2", "", "3"},
7879
StringsPtr: []*string{&stringPtr, &stringPtr},
80+
SlicePtr: &slicePtr,
7981
Basics: []Basic{
8082
{
8183
String: "test",
@@ -94,13 +96,27 @@ func TestMarshal(t *testing.T) {
9496
"strings.3=3",
9597
"strings-ptr.0=test",
9698
"strings-ptr.1=test",
99+
"slice-ptr.0=0",
100+
"slice-ptr.1=1",
101+
"slice-ptr.2=2",
97102
"basics.0.string=test",
98103
"basics.0.int=42",
99104
"basics.2.string=test",
100105
"basics.2.int=42",
101106
},
102107
}))
103108

109+
t.Run("empty-slice", run(TestCase{
110+
data: &Slice{
111+
Strings: []string{},
112+
SlicePtr: scw.StringsPtr(nil),
113+
StringsPtr: []*string{},
114+
},
115+
expected: []string{
116+
"slice-ptr=none",
117+
},
118+
}))
119+
104120
t.Run("well-known-types", run(TestCase{
105121
data: &WellKnownTypes{
106122
Size: 20 * scw.GB,
@@ -247,11 +263,16 @@ func TestMarshalValue(t *testing.T) {
247263
expected: "",
248264
}))
249265

250-
t.Run("typed-nil", run(TestCase{
266+
t.Run("nil-slice", run(TestCase{
251267
data: []string(nil),
252268
expected: "",
253269
}))
254270

271+
t.Run("empty-slice", run(TestCase{
272+
data: []string{},
273+
expected: "none",
274+
}))
275+
255276
t.Run("custom-func", run(TestCase{
256277
data: height(42),
257278
expected: "42cm",

internal/args/unmarshal.go

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ func UnmarshalStruct(args []string, data interface{}) error {
7575
// Loop through all arguments
7676
for _, kv := range argsSlice {
7777
argName, argValue := kv[0], kv[1]
78+
argNameWords := strings.Split(argName, ".")
7879

7980
// Make sure argument name is correct.
8081
// We enforce this check to avoid not well formatted argument name to work by "accident"
@@ -102,10 +103,30 @@ func UnmarshalStruct(args []string, data interface{}) error {
102103
Err: &DuplicateArgError{},
103104
}
104105
}
106+
107+
// We check that we did not already handle an argument value set on a child or a parent
108+
// Example `cluster=premium cluster.volume.size=12` cannot be valid as both args are in conflict.
109+
// Example `cluster.volume.size=12 cluster=premium` should also be invalid.
110+
for processedArgName := range processedArgNames {
111+
// We put the longest argName in long and the shortest in short.
112+
short, long := argName, processedArgName
113+
if len(long) < len(short) {
114+
short, long = long, short
115+
}
116+
117+
// We check if the longest starts with short+"."
118+
// If it does this mean we have a conflict.
119+
if strings.HasPrefix(long, short+".") {
120+
return &ConflictArgError{
121+
ArgName1: processedArgName,
122+
ArgName2: argName,
123+
}
124+
}
125+
}
105126
processedArgNames[argName] = true
106127

107128
// Set will recursively find the correct field to set.
108-
err := set(dest, strings.Split(argName, "."), argValue)
129+
err := set(dest, argNameWords, argValue)
109130
if err != nil {
110131
return &UnmarshalArgError{
111132
ArgName: argName,
@@ -172,19 +193,27 @@ func set(dest reflect.Value, argNameWords []string, value string) error {
172193
dest.Set(reflect.New(dest.Type().Elem()))
173194
}
174195

196+
// When:
197+
// - dest is a pointer to a slice
198+
// - there is no more argNameWords left
199+
// - value == none
200+
// we let the slice empty and return
201+
if dest.Elem().Kind() == reflect.Slice && len(argNameWords) == 0 && value == emptySliceValue {
202+
return nil
203+
}
204+
175205
// Call set with the pointer.Elem()
176206
return set(dest.Elem(), argNameWords, value)
177207

178208
case reflect.Slice:
179209
// If type is a slice:
180-
// We check if argNameWords[0] is an number to handle cases like keys.0.value=12
181210

182211
// We cannot handle slice without an index notation.
183212
if len(argNameWords) == 0 {
184213
return &MissingIndexOnArrayError{}
185214
}
186215

187-
// Make sure index is a positive integer.
216+
// We check if argNameWords[0] is a positive integer to handle cases like keys.0.value=12
188217
index, err := strconv.ParseUint(argNameWords[0], 10, 64)
189218
if err != nil {
190219
return &InvalidIndexError{Index: argNameWords[0]}

internal/args/unmarshal_test.go

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ func TestUnmarshalStruct(t *testing.T) {
1717
}
1818

1919
stringPtr := "test"
20+
slicePtr := []string{"0", "1", "2"}
2021

2122
run := func(testCase TestCase) func(t *testing.T) {
2223
return func(t *testing.T) {
@@ -164,6 +165,9 @@ func TestUnmarshalStruct(t *testing.T) {
164165
"strings.3=test",
165166
"strings-ptr.0=test",
166167
"strings-ptr.1=test",
168+
"slice-ptr.0=0",
169+
"slice-ptr.1=1",
170+
"slice-ptr.2=2",
167171
"basics.0.string=test",
168172
"basics.0.int=42",
169173
"basics.1.string=test",
@@ -172,6 +176,7 @@ func TestUnmarshalStruct(t *testing.T) {
172176
expected: &Slice{
173177
Strings: []string{"1", "2", "3", "test"},
174178
StringsPtr: []*string{&stringPtr, &stringPtr},
179+
SlicePtr: &slicePtr,
175180
Basics: []Basic{
176181
{
177182
String: "test",
@@ -185,6 +190,43 @@ func TestUnmarshalStruct(t *testing.T) {
185190
},
186191
}))
187192

193+
t.Run("empty-slice", run(TestCase{
194+
args: []string{
195+
"slice-ptr=none",
196+
},
197+
expected: &Slice{
198+
Strings: []string(nil),
199+
SlicePtr: scw.StringsPtr(nil),
200+
StringsPtr: []*string(nil),
201+
},
202+
}))
203+
204+
t.Run("none-on-non-pointer-slice", run(TestCase{
205+
args: []string{
206+
"strings=none",
207+
},
208+
error: "cannot unmarshal arg 'strings=none': missing index on the array",
209+
data: &Slice{},
210+
}))
211+
212+
t.Run("simple-parent-child-conflict", run(TestCase{
213+
args: []string{
214+
"slice-ptr=none",
215+
"slice-ptr.0=none",
216+
},
217+
error: "arguments 'slice-ptr' and 'slice-ptr.0' cannot be used simultaneously",
218+
data: &Slice{},
219+
}))
220+
221+
t.Run("simple-child-parent-conflict", run(TestCase{
222+
args: []string{
223+
"slice-ptr.0=none",
224+
"slice-ptr=none",
225+
},
226+
error: "arguments 'slice-ptr.0' and 'slice-ptr' cannot be used simultaneously",
227+
data: &Slice{},
228+
}))
229+
188230
t.Run("well-known-types", run(TestCase{
189231
args: []string{
190232
"size=20gb",
@@ -377,8 +419,18 @@ func TestUnmarshalStruct(t *testing.T) {
377419
},
378420
},
379421
}))
380-
}
381422

423+
t.Run("common-prefix-args", run(TestCase{
424+
args: []string{
425+
"ip=ip",
426+
"ipv6=ipv6",
427+
},
428+
expected: &SamePrefixArgName{
429+
IP: "ip",
430+
IPv6: "ipv6",
431+
},
432+
}))
433+
}
382434
func TestIsUmarshalableValue(t *testing.T) {
383435
type TestCase struct {
384436
expected bool

0 commit comments

Comments
 (0)