From 8072e66d4675c6eb77bba49519106fc4dde51de4 Mon Sep 17 00:00:00 2001 From: Matthew Huxtable Date: Fri, 4 May 2018 16:46:58 +0100 Subject: [PATCH] Add delete command The delete (short option "d") will delete the YAML subtree at the provided path in the specified file (or STDIN), if it the node exists. More complex support is currently omitted, for example: - specify nodes to delete using an external script - deleting common elements from all elements of an array --- data_navigator.go | 121 +++++++++++++++++++++++++++++++++++++---- data_navigator_test.go | 77 ++++++++++++++++++++++++++ yq.go | 60 +++++++++++++++++++- yq_test.go | 8 +++ 4 files changed, 255 insertions(+), 11 deletions(-) diff --git a/data_navigator.go b/data_navigator.go index c98983b6..1961aca5 100644 --- a/data_navigator.go +++ b/data_navigator.go @@ -18,9 +18,7 @@ func entryInSlice(context yaml.MapSlice, key interface{}) *yaml.MapItem { return nil } -func writeMap(context interface{}, paths []string, value interface{}) yaml.MapSlice { - log.Debugf("writeMap for %v for %v with value %v\n", paths, context, value) - +func getMapSlice(context interface{}) yaml.MapSlice { var mapSlice yaml.MapSlice switch context.(type) { case yaml.MapSlice: @@ -28,6 +26,25 @@ func writeMap(context interface{}, paths []string, value interface{}) yaml.MapSl default: mapSlice = make(yaml.MapSlice, 0) } + return mapSlice +} + +func getArray(context interface{}) (array []interface{}, ok bool) { + switch context.(type) { + case []interface{}: + array = context.([]interface{}) + ok = true + default: + array = make([]interface{}, 0) + ok = false + } + return +} + +func writeMap(context interface{}, paths []string, value interface{}) yaml.MapSlice { + log.Debugf("writeMap for %v for %v with value %v\n", paths, context, value) + + mapSlice := getMapSlice(context) if len(paths) == 0 { return mapSlice @@ -66,13 +83,7 @@ func updatedChildValue(child interface{}, remainingPaths []string, value interfa func writeArray(context interface{}, paths []string, value interface{}) []interface{} { log.Debugf("writeArray for %v for %v with value %v\n", paths, context, value) - var array []interface{} - switch context.(type) { - case []interface{}: - array = context.([]interface{}) - default: - array = make([]interface{}, 0) - } + array, _ := getArray(context) if len(paths) == 0 { return array @@ -199,3 +210,93 @@ func mapToMapSlice(data map[interface{}]interface{}) yaml.MapSlice { sort.SliceStable(mapSlice, func(i, j int) bool { return mapSlice[i].Key.(string) < mapSlice[j].Key.(string) }) return mapSlice } + +func deleteMap(context interface{}, paths []string) yaml.MapSlice { + log.Debugf("deleteMap for %v for %v\n", paths, context) + + mapSlice := getMapSlice(context) + + if len(paths) == 0 { + return mapSlice + } + + var found bool + var index int + var child yaml.MapItem + for index, child = range mapSlice { + if child.Key == paths[0] { + found = true + break + } + } + + if !found { + return mapSlice + } + + remainingPaths := paths[1:] + + var newSlice yaml.MapSlice + if len(remainingPaths) > 0 { + newChild := yaml.MapItem{Key: child.Key} + newChild.Value = deleteChildValue(child.Value, remainingPaths) + + newSlice = make(yaml.MapSlice, len(mapSlice)) + for i := range mapSlice { + item := mapSlice[i] + if i == index { + item = newChild + } + newSlice[i] = item + } + } else { + // Delete item from slice at index + newSlice = append(mapSlice[:index], mapSlice[index+1:]...) + log.Debugf("\tDeleted item index %d from mapSlice", index) + } + + log.Debugf("\t\tlen: %d\tcap: %d\tslice: %v", len(mapSlice), cap(mapSlice), mapSlice) + log.Debugf("\tReturning mapSlice %v\n", mapSlice) + return newSlice +} + +func deleteArray(context interface{}, paths []string, index int64) interface{} { + log.Debugf("deleteArray for %v for %v\n", paths, context) + + array, ok := getArray(context) + if !ok { + // did not get an array + return context + } + + if index >= int64(len(array)) { + return array + } + + remainingPaths := paths[1:] + if len(remainingPaths) > 0 { + // Recurse into the array element at index + array[index] = deleteMap(array[index], remainingPaths) + } else { + // Delete the array element at index + array = append(array[:index], array[index+1:]...) + log.Debugf("\tDeleted item index %d from array, leaving %v", index, array) + } + + log.Debugf("\tReturning array: %v\n", array) + return array +} + +func deleteChildValue(child interface{}, remainingPaths []string) interface{} { + log.Debugf("deleteChildValue for %v for %v\n", remainingPaths, child) + + idx, nextIndexErr := strconv.ParseInt(remainingPaths[0], 10, 64) + if nextIndexErr != nil { + // must be a map + log.Debugf("\tdetected a map, invoking deleteMap\n") + return deleteMap(child, remainingPaths) + } + + log.Debugf("\tdetected an array, so traversing element with index %d\n", idx) + return deleteArray(child, remainingPaths, idx) +} diff --git a/data_navigator_test.go b/data_navigator_test.go index 540c737f..daae8798 100644 --- a/data_navigator_test.go +++ b/data_navigator_test.go @@ -308,3 +308,80 @@ func TestWriteArray_no_paths(t *testing.T) { result := writeArray(data, []string{}, 4) assertResult(t, fmt.Sprintf("%v", data), fmt.Sprintf("%v", result)) } + +func TestDelete_MapItem(t *testing.T) { + var data = parseData(` +a: 123 +b: 456 +`) + var expected = parseData(` +b: 456 +`) + + result := deleteMap(data, []string{"a"}) + assertResult(t, fmt.Sprintf("%v", expected), fmt.Sprintf("%v", result)) +} + +// Ensure deleting an index into a string does nothing +func TestDelete_index_to_string(t *testing.T) { + var data = parseData(` +a: mystring +`) + result := deleteMap(data, []string{"a", "0"}) + assertResult(t, fmt.Sprintf("%v", data), fmt.Sprintf("%v", result)) +} + +func TestDelete_list_index(t *testing.T) { + var data = parseData(` +a: [3, 4] +`) + var expected = parseData(` +a: [3] +`) + result := deleteMap(data, []string{"a", "1"}) + assertResult(t, fmt.Sprintf("%v", expected), fmt.Sprintf("%v", result)) +} + +func TestDelete_list_index_beyond_bounds(t *testing.T) { + var data = parseData(` +a: [3, 4] +`) + result := deleteMap(data, []string{"a", "5"}) + assertResult(t, fmt.Sprintf("%v", data), fmt.Sprintf("%v", result)) +} + +func TestDelete_list_index_out_of_bounds_by_1(t *testing.T) { + var data = parseData(` +a: [3, 4] +`) + result := deleteMap(data, []string{"a", "2"}) + assertResult(t, fmt.Sprintf("%v", data), fmt.Sprintf("%v", result)) +} + +func TestDelete_no_paths(t *testing.T) { + var data = parseData(` +a: [3, 4] +b: + - name: test +`) + result := deleteMap(data, []string{}) + assertResult(t, fmt.Sprintf("%v", data), fmt.Sprintf("%v", result)) +} + +func TestDelete_array_map_item(t *testing.T) { + var data = parseData(` +b: +- name: fred + value: blah +- name: john + value: test +`) + var expected = parseData(` +b: +- value: blah +- name: john + value: test +`) + result := deleteMap(data, []string{"b", "0", "name"}) + assertResult(t, fmt.Sprintf("%v", expected), fmt.Sprintf("%v", result)) +} diff --git a/yq.go b/yq.go index 64720102..6538fb25 100644 --- a/yq.go +++ b/yq.go @@ -64,7 +64,13 @@ func newCommandCLI() *cobra.Command { rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose mode") rootCmd.Flags().BoolVarP(&version, "version", "V", false, "Print version information and quit") - rootCmd.AddCommand(createReadCmd(), createWriteCmd(), createNewCmd(), createMergeCmd()) + rootCmd.AddCommand( + createReadCmd(), + createWriteCmd(), + createDeleteCmd(), + createNewCmd(), + createMergeCmd(), + ) rootCmd.SetOutput(os.Stdout) return rootCmd @@ -121,6 +127,26 @@ a.b.e: return cmdWrite } +func createDeleteCmd() *cobra.Command { + var cmdDelete = &cobra.Command{ + Use: "delete [yaml_file] [path]", + Aliases: []string{"d"}, + Short: "yq d [--inplace/-i] sample.yaml a.b.c", + Example: ` +yq delete things.yaml a.b.c +yq delete --inplace things.yaml a.b.c +yq d -i things.yaml a.b.c +yq d things.yaml a.b.c + `, + Long: `Deletes the given path from the YAML file. +Outputs to STDOUT unless the inplace flag is used, in which case the file is updated instead. +`, + RunE: deleteProperty, + } + cmdDelete.PersistentFlags().BoolVarP(&writeInplace, "inplace", "i", false, "update the yaml file inplace") + return cmdDelete +} + func createNewCmd() *cobra.Command { var cmdNew = &cobra.Command{ Use: "new [path] [value]", @@ -316,6 +342,38 @@ func write(cmd *cobra.Command, filename string, updatedData interface{}) error { return nil } +func deleteProperty(cmd *cobra.Command, args []string) error { + updatedData, err := deleteYaml(args) + if err != nil { + return err + } + return write(cmd, args[0], updatedData) +} + +func deleteYaml(args []string) (interface{}, error) { + var parsedData yaml.MapSlice + var deletePath string + + if len(args) < 2 { + return nil, errors.New("Must provide ") + } + + deletePath = args[1] + + if err := readData(args[0], &parsedData); err != nil { + var generalData interface{} + if err = readData(args[0], &generalData); err != nil { + return nil, err + } + item := yaml.MapItem{Key: "thing", Value: generalData} + parsedData = yaml.MapSlice{item} + deletePath = "thing." + deletePath + } + + path := parsePath(deletePath) + return deleteMap(parsedData, path), nil +} + func mergeProperties(cmd *cobra.Command, args []string) error { if len(args) < 2 { return errors.New("Must provide at least 2 yaml files") diff --git a/yq_test.go b/yq_test.go index b00cc4b4..9df68df3 100644 --- a/yq_test.go +++ b/yq_test.go @@ -122,3 +122,11 @@ func TestNewYaml_WithUnknownScript(t *testing.T) { expectedOutput := `open fake-unknown: no such file or directory` assertResult(t, expectedOutput, err.Error()) } + +func TestDeleteYaml(t *testing.T) { + result, _ := deleteYaml([]string{"examples/sample.yaml", "b.c"}) + formattedResult := fmt.Sprintf("%v", result) + assertResult(t, + "[{a Easy! as one two three} {b [{d [3 4]} {e [[{name fred} {value 3}] [{name sam} {value 4}]]}]}]", + formattedResult) +}