Skip to content

Commit b967fdb

Browse files
authored
Add structpath.Parse; modify map keys representation (#3620)
## Changes - Added Parse() function to decode paths. - Changed String() representation of PathNode to use single quotes. ## Why We need path to be serializable without loss because they are going to be encoded in JSON plan. The single quotes were chosen as default representation because they look good inside JSON string. ## Tests Unit tests. The structwalk tests are also extended to test parsing round trip for all patterns from Config type.
1 parent f75a722 commit b967fdb

File tree

5 files changed

+558
-69
lines changed

5 files changed

+558
-69
lines changed

libs/structs/structdiff/diff_test.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ func TestGetStructDiff(t *testing.T) {
132132
name: "map diff",
133133
a: A{M: map[string]int{"a": 1}},
134134
b: A{M: map[string]int{"a": 2}},
135-
want: []ResolvedChange{{Field: "m[\"a\"]", Old: 1, New: 2}},
135+
want: []ResolvedChange{{Field: "m['a']", Old: 1, New: 2}},
136136
},
137137
{
138138
name: "slice diff",
@@ -243,9 +243,9 @@ func TestGetStructDiff(t *testing.T) {
243243
a: map[string]C{"key1": {Title: "title", ForceSendFields: []string{"Name", "IsEnabled", "Title"}}},
244244
b: map[string]C{"key1": {Title: "title", ForceSendFields: []string{"Age"}}},
245245
want: []ResolvedChange{
246-
{Field: "[\"key1\"].name", Old: "", New: nil},
247-
{Field: "[\"key1\"].age", Old: nil, New: 0},
248-
{Field: "[\"key1\"].is_enabled", Old: false, New: nil},
246+
{Field: "['key1'].name", Old: "", New: nil},
247+
{Field: "['key1'].age", Old: nil, New: 0},
248+
{Field: "['key1'].is_enabled", Old: false, New: nil},
249249
},
250250
},
251251

@@ -255,9 +255,9 @@ func TestGetStructDiff(t *testing.T) {
255255
a: map[string]*C{"key1": {Title: "title", ForceSendFields: []string{"Name", "IsEnabled", "Title"}}},
256256
b: map[string]*C{"key1": {Title: "title", ForceSendFields: []string{"Age"}}},
257257
want: []ResolvedChange{
258-
{Field: "[\"key1\"].name", Old: "", New: nil},
259-
{Field: "[\"key1\"].age", Old: nil, New: 0},
260-
{Field: "[\"key1\"].is_enabled", Old: false, New: nil},
258+
{Field: "['key1'].name", Old: "", New: nil},
259+
{Field: "['key1'].age", Old: nil, New: 0},
260+
{Field: "['key1'].is_enabled", Old: false, New: nil},
261261
},
262262
},
263263
}

libs/structs/structpath/path.go

Lines changed: 227 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package structpath
22

33
import (
4+
"errors"
45
"fmt"
56
"reflect"
67
"strconv"
8+
"strings"
79

810
"github.com/databricks/cli/libs/structs/structtag"
911
)
@@ -145,6 +147,12 @@ func NewAnyIndex(prev *PathNode) *PathNode {
145147
}
146148

147149
// String returns the string representation of the path.
150+
// The map keys are encoded in single quotes: tags['name']. Single quote can escaped by placing two single quotes: tags[””] (map key is one single quote).
151+
// This encoding is chosen over traditional double quotes because when encoded in JSON it does not need to be escaped:
152+
//
153+
// {
154+
// "resources.jobs.foo.tags['cost-center']": {}
155+
// }
148156
func (p *PathNode) String() string {
149157
if p == nil {
150158
return ""
@@ -166,7 +174,224 @@ func (p *PathNode) String() string {
166174
return prev + "." + p.key
167175
}
168176

169-
return fmt.Sprintf("%s[%q]", p.prev.String(), p.key)
177+
// Format map key with single quotes, escaping single quotes by doubling them
178+
escapedKey := strings.ReplaceAll(p.key, "'", "''")
179+
return fmt.Sprintf("%s['%s']", p.prev.String(), escapedKey)
180+
}
181+
182+
// Parse parses a string representation of a path using a state machine.
183+
//
184+
// State Machine for Path Parsing:
185+
//
186+
// States:
187+
// - START: Beginning of parsing, expects field name or "["
188+
// - FIELD_START: After a dot, expects field name only
189+
// - FIELD: Reading field name characters
190+
// - BRACKET_OPEN: Just encountered "[", expects digit, "'" or "*"
191+
// - INDEX: Reading array index digits, expects more digits or "]"
192+
// - MAP_KEY: Reading map key content, expects any char or "'"
193+
// - MAP_KEY_QUOTE: Encountered "'" in map key, expects "'" (escape) or "]" (end)
194+
// - WILDCARD: Reading "*" in brackets, expects "]"
195+
// - EXPECT_DOT_OR_END: After bracket close, expects ".", "[" or end of string
196+
// - END: Successfully completed parsing
197+
//
198+
// Transitions:
199+
// - START: [a-zA-Z_-] -> FIELD, "[" -> BRACKET_OPEN, EOF -> END
200+
// - FIELD_START: [a-zA-Z_-] -> FIELD, other -> ERROR
201+
// - FIELD: [a-zA-Z0-9_-] -> FIELD, "." -> FIELD_START, "[" -> BRACKET_OPEN, EOF -> END
202+
// - BRACKET_OPEN: [0-9] -> INDEX, "'" -> MAP_KEY, "*" -> WILDCARD
203+
// - INDEX: [0-9] -> INDEX, "]" -> EXPECT_DOT_OR_END
204+
// - MAP_KEY: (any except "'") -> MAP_KEY, "'" -> MAP_KEY_QUOTE
205+
// - MAP_KEY_QUOTE: "'" -> MAP_KEY (escape), "]" -> EXPECT_DOT_OR_END (end key)
206+
// - WILDCARD: "]" -> EXPECT_DOT_OR_END
207+
// - EXPECT_DOT_OR_END: "." -> FIELD_START, "[" -> BRACKET_OPEN, EOF -> END
208+
func Parse(s string) (*PathNode, error) {
209+
if s == "" {
210+
return nil, nil
211+
}
212+
213+
// State machine states
214+
const (
215+
stateStart = iota
216+
stateFieldStart
217+
stateField
218+
stateBracketOpen
219+
stateIndex
220+
stateMapKey
221+
stateMapKeyQuote
222+
stateWildcard
223+
stateExpectDotOrEnd
224+
stateEnd
225+
)
226+
227+
state := stateStart
228+
var result *PathNode
229+
var currentToken strings.Builder
230+
pos := 0
231+
232+
for pos < len(s) {
233+
ch := s[pos]
234+
235+
switch state {
236+
case stateStart:
237+
if ch == '[' {
238+
state = stateBracketOpen
239+
} else if !isReservedFieldChar(ch) {
240+
currentToken.WriteByte(ch)
241+
state = stateField
242+
} else {
243+
return nil, fmt.Errorf("unexpected character '%c' at position %d", ch, pos)
244+
}
245+
246+
case stateFieldStart:
247+
if !isReservedFieldChar(ch) {
248+
currentToken.WriteByte(ch)
249+
state = stateField
250+
} else {
251+
return nil, fmt.Errorf("expected field name after '.' but got '%c' at position %d", ch, pos)
252+
}
253+
254+
case stateField:
255+
if ch == '.' {
256+
result = NewStructField(result, reflect.StructTag(""), currentToken.String())
257+
currentToken.Reset()
258+
state = stateFieldStart
259+
} else if ch == '[' {
260+
result = NewStructField(result, reflect.StructTag(""), currentToken.String())
261+
currentToken.Reset()
262+
state = stateBracketOpen
263+
} else if !isReservedFieldChar(ch) {
264+
currentToken.WriteByte(ch)
265+
} else {
266+
return nil, fmt.Errorf("invalid character '%c' in field name at position %d", ch, pos)
267+
}
268+
269+
case stateBracketOpen:
270+
if ch >= '0' && ch <= '9' {
271+
currentToken.WriteByte(ch)
272+
state = stateIndex
273+
} else if ch == '\'' {
274+
state = stateMapKey
275+
} else if ch == '*' {
276+
state = stateWildcard
277+
} else {
278+
return nil, fmt.Errorf("unexpected character '%c' after '[' at position %d", ch, pos)
279+
}
280+
281+
case stateIndex:
282+
if ch >= '0' && ch <= '9' {
283+
currentToken.WriteByte(ch)
284+
} else if ch == ']' {
285+
index, err := strconv.Atoi(currentToken.String())
286+
if err != nil {
287+
return nil, fmt.Errorf("invalid index '%s' at position %d", currentToken.String(), pos-len(currentToken.String()))
288+
}
289+
result = NewIndex(result, index)
290+
currentToken.Reset()
291+
state = stateExpectDotOrEnd
292+
} else {
293+
return nil, fmt.Errorf("unexpected character '%c' in index at position %d", ch, pos)
294+
}
295+
296+
case stateMapKey:
297+
switch ch {
298+
case '\'':
299+
state = stateMapKeyQuote
300+
default:
301+
currentToken.WriteByte(ch)
302+
}
303+
304+
case stateMapKeyQuote:
305+
switch ch {
306+
case '\'':
307+
// Escaped quote - add single quote to key and continue
308+
currentToken.WriteByte('\'')
309+
state = stateMapKey
310+
case ']':
311+
// End of map key
312+
result = NewMapKey(result, currentToken.String())
313+
currentToken.Reset()
314+
state = stateExpectDotOrEnd
315+
default:
316+
return nil, fmt.Errorf("unexpected character '%c' after quote in map key at position %d", ch, pos)
317+
}
318+
319+
case stateWildcard:
320+
if ch == ']' {
321+
// Note, since we're parsing this without type info present, we don't know if it's AnyKey or AnyIndex
322+
// Perhaps structpath should be simplified to have Wildcard as merged representation of AnyKey/AnyIndex
323+
result = NewAnyKey(result)
324+
state = stateExpectDotOrEnd
325+
} else {
326+
return nil, fmt.Errorf("unexpected character '%c' after '*' at position %d", ch, pos)
327+
}
328+
329+
case stateExpectDotOrEnd:
330+
switch ch {
331+
case '.':
332+
state = stateFieldStart
333+
case '[':
334+
state = stateBracketOpen
335+
default:
336+
return nil, fmt.Errorf("unexpected character '%c' at position %d", ch, pos)
337+
}
338+
339+
case stateEnd:
340+
return result, nil
341+
342+
default:
343+
return nil, fmt.Errorf("parser error at position %d", pos)
344+
}
345+
346+
pos++
347+
}
348+
349+
// Handle end-of-input based on final state
350+
switch state {
351+
case stateStart:
352+
return result, nil // Empty path, result is nil
353+
case stateField:
354+
result = NewStructField(result, reflect.StructTag(""), currentToken.String())
355+
return result, nil
356+
case stateExpectDotOrEnd:
357+
return result, nil
358+
case stateFieldStart:
359+
return nil, errors.New("unexpected end of input after '.'")
360+
case stateBracketOpen:
361+
return nil, errors.New("unexpected end of input after '['")
362+
case stateIndex:
363+
return nil, errors.New("unexpected end of input while parsing index")
364+
case stateMapKey:
365+
return nil, errors.New("unexpected end of input while parsing map key")
366+
case stateMapKeyQuote:
367+
return nil, errors.New("unexpected end of input after quote in map key")
368+
case stateWildcard:
369+
return nil, errors.New("unexpected end of input after wildcard '*'")
370+
case stateEnd:
371+
return result, nil
372+
default:
373+
return nil, fmt.Errorf("parser error at position %d", pos)
374+
}
375+
}
376+
377+
// isReservedFieldChar checks if character is reserved and cannot be used in field names
378+
func isReservedFieldChar(ch byte) bool {
379+
switch ch {
380+
case ',': // Cannot appear in Golang JSON struct tag
381+
return true
382+
case '"': // Cannot appear in Golang struct tag
383+
return true
384+
case '`': // Cannot appear in Golang struct tag
385+
return true
386+
case '.': // Path separator
387+
return true
388+
case '[': // Bracket notation start
389+
return true
390+
case ']': // Bracket notation end
391+
return true
392+
default:
393+
return false
394+
}
170395
}
171396

172397
// Path in libs/dyn format
@@ -192,6 +417,7 @@ func (p *PathNode) DynPath() string {
192417
return p.prev.DynPath() + "[*]"
193418
}
194419

420+
// Both struct fields and map keys use dot notation in DynPath
195421
prev := p.prev.DynPath()
196422
if prev == "" {
197423
return p.key

0 commit comments

Comments
 (0)