Skip to content

Commit e1bcd14

Browse files
authored
Merge pull request #218 from onflow/bastian/keep-order
Preserve flow.json key order across load/save
2 parents 43a9f0d + 699dfe6 commit e1bcd14

12 files changed

Lines changed: 591 additions & 81 deletions

config/json/account.go

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ import (
3434
"github.com/onflow/flowkit/v2/config"
3535
)
3636

37-
type jsonAccounts map[string]account
37+
type jsonAccounts struct {
38+
orderedMap[account]
39+
}
3840

3941
const (
4042
defaultHashAlgo = crypto.SHA3_256
@@ -239,36 +241,33 @@ func transformAdvancedToConfig(accountName string, a advancedAccount) (*config.A
239241
func (j jsonAccounts) transformToConfig() (config.Accounts, error) {
240242
accounts := make(config.Accounts, 0)
241243

242-
for accountName, a := range j {
243-
var account *config.Account
244+
for accountName, a := range j.All {
245+
var acc *config.Account
244246
var err error
245247
if a.Simple.Address != "" {
246-
account, err = transformSimpleToConfig(accountName, a.Simple)
247-
if err != nil {
248-
return nil, err
249-
}
248+
acc, err = transformSimpleToConfig(accountName, a.Simple)
250249
} else { // advanced format
251-
account, err = transformAdvancedToConfig(accountName, a.Advanced)
252-
if err != nil {
253-
return nil, err
254-
}
250+
acc, err = transformAdvancedToConfig(accountName, a.Advanced)
251+
}
252+
if err != nil {
253+
return nil, err
255254
}
256255

257-
accounts = append(accounts, *account)
256+
accounts = append(accounts, *acc)
258257
}
259258

260259
return accounts, nil
261260
}
262261

263262
// transformToJSON transforms config structure to json structures for saving.
264263
func transformAccountsToJSON(accounts config.Accounts) jsonAccounts {
265-
jsonAccounts := jsonAccounts{}
264+
var jsonAccounts jsonAccounts
266265

267266
for _, a := range accounts {
268267
if a.Key.IsDefault() {
269-
jsonAccounts[a.Name] = transformSimpleAccountToJSON(a)
268+
jsonAccounts.Set(a.Name, transformSimpleAccountToJSON(a))
270269
} else {
271-
jsonAccounts[a.Name] = transformAdvancedAccountToJSON(a)
270+
jsonAccounts.Set(a.Name, transformAdvancedAccountToJSON(a))
272271
}
273272
}
274273

config/json/account_test.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,10 @@ func Test_ConfigAccountKeysAdvancedFile(t *testing.T) {
119119
assert.Equal(t, "", key.ResourceID)
120120

121121
jsonAccs := transformAccountsToJSON(accounts)
122-
assert.Equal(t, "./test.pkey", jsonAccs["test"].Advanced.Key.Location)
123-
assert.Equal(t, "", jsonAccs["test"].Advanced.Key.PrivateKey)
122+
testAcc, ok := jsonAccs.Get("test")
123+
assert.True(t, ok)
124+
assert.Equal(t, "./test.pkey", testAcc.Advanced.Key.Location)
125+
assert.Equal(t, "", testAcc.Advanced.Key.PrivateKey)
124126
}
125127

126128
func Test_ConfigAccountKeysAdvancedKMS(t *testing.T) {

config/json/config.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,19 @@ import (
2626
)
2727

2828
// jsonConfig implements JSON format for persisting and parsing configuration.
29+
//
30+
// Sections use ordered maps to preserve user-chosen key ordering across
31+
// load/save cycles. Both `omitempty` and `omitzero` are set: `omitzero` is what
32+
// actually skips empty sections during marshaling (since `omitempty` is a no-op
33+
// for struct values), while `omitempty` is read by the invopop jsonschema
34+
// reflector to mark these fields as optional in the generated schema.
2935
type jsonConfig struct {
30-
Emulators jsonEmulators `json:"emulators,omitempty"`
31-
Contracts jsonContracts `json:"contracts,omitempty"`
32-
Dependencies jsonDependencies `json:"dependencies,omitempty"`
33-
Networks jsonNetworks `json:"networks,omitempty"`
34-
Accounts jsonAccounts `json:"accounts,omitempty"`
35-
Deployments jsonDeployments `json:"deployments,omitempty"`
36+
Emulators jsonEmulators `json:"emulators,omitempty,omitzero"`
37+
Contracts jsonContracts `json:"contracts,omitempty,omitzero"`
38+
Dependencies jsonDependencies `json:"dependencies,omitempty,omitzero"`
39+
Networks jsonNetworks `json:"networks,omitempty,omitzero"`
40+
Accounts jsonAccounts `json:"accounts,omitempty,omitzero"`
41+
Deployments jsonDeployments `json:"deployments,omitempty,omitzero"`
3642
}
3743

3844
func (j *jsonConfig) transformToConfig() (*config.Config, error) {

config/json/config_test.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
package json
1919

2020
import (
21+
"encoding/json"
2122
"testing"
2223

2324
"github.com/onflow/flow-go-sdk"
@@ -255,3 +256,136 @@ func Test_SerializeConfigToJsonEmulatorNotDefault(t *testing.T) {
255256
assert.JSONEq(t, string(configJson), string(conf))
256257

257258
}
259+
260+
// Test_RoundTripPreservesKeyOrder verifies that loading a flow.json and then
261+
// re-serializing it preserves the user's chosen ordering of keys within each
262+
// section, including nested deployments. This was the motivating bug for the
263+
// orderedMap data structure.
264+
func Test_RoundTripPreservesKeyOrder(t *testing.T) {
265+
input := []byte(`{
266+
"contracts": {
267+
"ZetaContract": "./z.cdc",
268+
"AlphaContract": "./a.cdc",
269+
"MuContract": "./m.cdc"
270+
},
271+
"dependencies": {
272+
"Burner": "testnet://9a0766d93b6608b7.Burner",
273+
"AAA": "testnet://9a0766d93b6608b7.AAA"
274+
},
275+
"networks": {
276+
"mainnet": "access.mainnet.nodes.onflow.org:9000",
277+
"testnet": "access.testnet.nodes.onflow.org:9000",
278+
"emulator": "127.0.0.1:3569"
279+
},
280+
"accounts": {
281+
"zoo-account": {
282+
"address": "f8d6e0586b0a20c7",
283+
"key": "11c5dfdeb0ff03a7a73ef39788563b62c89adea67bbb21ab95e5f710bd1d40b7"
284+
},
285+
"alpha-account": {
286+
"address": "f8d6e0586b0a20c7",
287+
"key": "11c5dfdeb0ff03a7a73ef39788563b62c89adea67bbb21ab95e5f710bd1d40b7"
288+
}
289+
},
290+
"deployments": {
291+
"testnet": {
292+
"zoo-account": ["ZetaContract"]
293+
},
294+
"emulator": {
295+
"alpha-account": ["MuContract", "AlphaContract"]
296+
}
297+
}
298+
}`)
299+
300+
parser := NewParser()
301+
conf, err := parser.Deserialize(input)
302+
assert.NoError(t, err)
303+
304+
out, err := parser.Serialize(conf)
305+
assert.NoError(t, err)
306+
307+
// Re-parse into the JSON layer directly to compare structural ordering
308+
// without depending on whitespace.
309+
var original, roundTripped jsonConfig
310+
assert.NoError(t, json.Unmarshal(input, &original))
311+
assert.NoError(t, json.Unmarshal(out, &roundTripped))
312+
313+
assert.Equal(t,
314+
sectionKeys(original.Contracts.orderedMap),
315+
sectionKeys(roundTripped.Contracts.orderedMap),
316+
"contracts order changed")
317+
assert.Equal(t,
318+
sectionKeys(original.Dependencies.orderedMap),
319+
sectionKeys(roundTripped.Dependencies.orderedMap),
320+
"dependencies order changed")
321+
assert.Equal(t,
322+
sectionKeys(original.Networks.orderedMap),
323+
sectionKeys(roundTripped.Networks.orderedMap),
324+
"networks order changed")
325+
assert.Equal(t,
326+
sectionKeys(original.Accounts.orderedMap),
327+
sectionKeys(roundTripped.Accounts.orderedMap),
328+
"accounts order changed")
329+
assert.Equal(t,
330+
sectionKeys(original.Deployments.orderedMap),
331+
sectionKeys(roundTripped.Deployments.orderedMap),
332+
"deployments order changed")
333+
334+
// Verify inner deployment account ordering is also preserved (this is the
335+
// nested orderedMap[[]deployment] case).
336+
originalEmu, _ := original.Deployments.Get("emulator")
337+
roundTrippedEmu, _ := roundTripped.Deployments.Get("emulator")
338+
assert.Equal(t,
339+
sectionKeys(originalEmu.orderedMap),
340+
sectionKeys(roundTrippedEmu.orderedMap),
341+
"deployments.emulator account order changed")
342+
}
343+
344+
// Test_NewKeysAppendAtEnd verifies that when a new entry is added programmatically
345+
// (e.g. a new dependency is fetched), it is appended at the end rather than
346+
// inserted alphabetically.
347+
func Test_NewKeysAppendAtEnd(t *testing.T) {
348+
input := []byte(`{
349+
"contracts": {
350+
"ZetaContract": "./z.cdc",
351+
"AlphaContract": "./a.cdc"
352+
},
353+
"networks": {
354+
"emulator": "127.0.0.1:3569"
355+
},
356+
"accounts": {
357+
"emulator-account": {
358+
"address": "f8d6e0586b0a20c7",
359+
"key": "11c5dfdeb0ff03a7a73ef39788563b62c89adea67bbb21ab95e5f710bd1d40b7"
360+
}
361+
}
362+
}`)
363+
364+
parser := NewParser()
365+
conf, err := parser.Deserialize(input)
366+
assert.NoError(t, err)
367+
368+
conf.Contracts.AddOrUpdate(config.Contract{
369+
Name: "MuContract",
370+
Location: "./m.cdc",
371+
})
372+
373+
out, err := parser.Serialize(conf)
374+
assert.NoError(t, err)
375+
376+
var result jsonConfig
377+
assert.NoError(t, json.Unmarshal(out, &result))
378+
379+
assert.Equal(t,
380+
[]string{"ZetaContract", "AlphaContract", "MuContract"},
381+
sectionKeys(result.Contracts.orderedMap),
382+
"new contract should be appended at the end, not inserted alphabetically")
383+
}
384+
385+
func sectionKeys[V any](m orderedMap[V]) []string {
386+
keys := make([]string, 0, m.Len())
387+
for k := range m.All {
388+
keys = append(keys, k)
389+
}
390+
return keys
391+
}

config/json/contract.go

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -29,44 +29,45 @@ import (
2929
"github.com/onflow/flowkit/v2/config"
3030
)
3131

32-
type jsonContracts map[string]jsonContract
32+
type jsonContracts struct {
33+
orderedMap[jsonContract]
34+
}
3335

3436
// transformToConfig transforms json structures to config structure.
3537
func (j jsonContracts) transformToConfig() (config.Contracts, error) {
3638
contracts := make(config.Contracts, 0)
3739

38-
for contractName, c := range j {
40+
for contractName, c := range j.All {
3941
if c.Simple != "" {
40-
contract := config.Contract{
42+
contracts = append(contracts, config.Contract{
4143
Name: contractName,
4244
Location: c.Simple,
43-
}
45+
})
46+
continue
47+
}
4448

45-
contracts = append(contracts, contract)
46-
} else {
47-
contract := config.Contract{
48-
Name: contractName,
49-
Location: c.Advanced.Source,
50-
Canonical: c.Advanced.Canonical,
49+
contract := config.Contract{
50+
Name: contractName,
51+
Location: c.Advanced.Source,
52+
Canonical: c.Advanced.Canonical,
53+
}
54+
for network, alias := range c.Advanced.Aliases {
55+
address := flow.HexToAddress(alias)
56+
if address == flow.EmptyAddress {
57+
return nil, fmt.Errorf("invalid alias address for a contract")
5158
}
52-
for network, alias := range c.Advanced.Aliases {
53-
address := flow.HexToAddress(alias)
54-
if address == flow.EmptyAddress {
55-
return nil, fmt.Errorf("invalid alias address for a contract")
56-
}
5759

58-
contract.Aliases.Add(network, address)
59-
}
60-
contracts = append(contracts, contract)
60+
contract.Aliases.Add(network, address)
6161
}
62+
contracts = append(contracts, contract)
6263
}
6364

6465
return contracts, nil
6566
}
6667

6768
// transformToJSON transforms config structure to json structures for saving.
6869
func transformContractsToJSON(contracts config.Contracts) jsonContracts {
69-
jsonContracts := jsonContracts{}
70+
var jsonContracts jsonContracts
7071

7172
for _, c := range contracts {
7273
// If it's a dependency, skip. These are used under the hood and should not be saved.
@@ -76,23 +77,23 @@ func transformContractsToJSON(contracts config.Contracts) jsonContracts {
7677

7778
// if simple case (no aliases and no canonical)
7879
if !c.IsAliased() && c.Canonical == "" {
79-
jsonContracts[c.Name] = jsonContract{
80+
jsonContracts.Set(c.Name, jsonContract{
8081
Simple: filepath.ToSlash(c.Location),
81-
}
82+
})
8283
} else { // if advanced config
8384
// check if we already created for this name then add or create
8485
aliases := make(map[string]string)
8586
for _, alias := range c.Aliases {
8687
aliases[alias.Network] = alias.Address.String()
8788
}
8889

89-
jsonContracts[c.Name] = jsonContract{
90+
jsonContracts.Set(c.Name, jsonContract{
9091
Advanced: jsonContractAdvanced{
9192
Source: filepath.ToSlash(c.Location),
9293
Aliases: aliases,
9394
Canonical: c.Canonical,
9495
},
95-
}
96+
})
9697
}
9798
}
9899

config/json/dependency.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,14 @@ import (
3030
"github.com/onflow/flowkit/v2/config"
3131
)
3232

33-
type jsonDependencies map[string]jsonDependency
33+
type jsonDependencies struct {
34+
orderedMap[jsonDependency]
35+
}
3436

3537
func (j jsonDependencies) transformToConfig() (config.Dependencies, error) {
3638
deps := make(config.Dependencies, 0)
3739

38-
for dependencyName, dependency := range j {
40+
for dependencyName, dependency := range j.All {
3941
var dep config.Dependency
4042

4143
if dependency.Simple != "" {
@@ -87,7 +89,7 @@ func (j jsonDependencies) transformToConfig() (config.Dependencies, error) {
8789
}
8890

8991
func transformDependenciesToJSON(configDependencies config.Dependencies, configContracts config.Contracts) jsonDependencies {
90-
jsonDeps := jsonDependencies{}
92+
var jsonDeps jsonDependencies
9193

9294
for _, dep := range configDependencies {
9395
aliases := make(map[string]string)
@@ -99,15 +101,15 @@ func transformDependenciesToJSON(configDependencies config.Dependencies, configC
99101
}
100102
}
101103

102-
jsonDeps[dep.Name] = jsonDependency{
104+
jsonDeps.Set(dep.Name, jsonDependency{
103105
Extended: jsonDependencyExtended{
104106
Source: buildSourceString(dep.Source),
105107
Hash: dep.Hash,
106108
BlockHeight: dep.BlockHeight,
107109
Aliases: aliases,
108110
Canonical: dep.Canonical,
109111
},
110-
}
112+
})
111113
}
112114

113115
return jsonDeps

0 commit comments

Comments
 (0)