Repository: TomWright/dasel
Pull request #145 (2021-07-23 16:34:39)

Delete command


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 40f0341..d1f537c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
-- Nothing yet.
+### Added
+
+- Delete command.
 
 ## [v1.15.0] - 2021-05-06
 
diff --git a/internal/command/delete.go b/internal/command/delete.go
new file mode 100644
index 0000000..92115ef
--- /dev/null
+++ b/internal/command/delete.go
@@ -0,0 +1,160 @@
+package command
+
+import (
+	"fmt"
+	"github.com/spf13/cobra"
+	"github.com/tomwright/dasel"
+	"github.com/tomwright/dasel/internal/storage"
+	"io"
+)
+
+type deleteOptions struct {
+	File                string
+	Parser              string
+	ReadParser          string
+	WriteParser         string
+	Selector            string
+	Reader              io.Reader
+	Writer              io.Writer
+	Multi               bool
+	Compact             bool
+	MergeInputDocuments bool
+}
+
+func runDeleteMultiCommand(cmd *cobra.Command, rootNode *dasel.Node, opts deleteOptions, writeParser storage.WriteParser, writeOptions []storage.ReadWriteOption) error {
+	err := rootNode.DeleteMultiple(opts.Selector)
+
+	written, err := customErrorHandling(customErrorHandlingOpts{
+		File:     opts.File,
+		Writer:   opts.Writer,
+		Err:      err,
+		Cmd:      cmd,
+		NullFlag: false,
+	})
+	if err != nil {
+		return fmt.Errorf("could not delete multiple node: %w", err)
+	}
+	if written {
+		return nil
+	}
+
+	if err := writeNodeToOutput(writeNodeToOutputOpts{
+		Node:   rootNode,
+		Parser: writeParser,
+		Writer: opts.Writer,
+	}, cmd, writeOptions...); err != nil {
+		return fmt.Errorf("could not write output: %w", err)
+	}
+	return nil
+}
+
+func runDeleteCommand(opts deleteOptions, cmd *cobra.Command) error {
+	readParser, err := getReadParser(opts.File, opts.ReadParser, opts.Parser)
+	if err != nil {
+		return err
+	}
+	rootNode, err := getRootNode(getRootNodeOpts{
+		File:                opts.File,
+		Parser:              readParser,
+		Reader:              opts.Reader,
+		MergeInputDocuments: opts.MergeInputDocuments,
+	}, cmd)
+	if err != nil {
+		return err
+	}
+
+	if !rootNode.Value.IsValid() {
+		rootNode = dasel.New(&storage.BasicSingleDocument{
+			Value: map[string]interface{}{},
+		})
+	}
+
+	if opts.Writer == nil {
+		opts.Writer = cmd.OutOrStdout()
+	}
+
+	writeParser, err := getWriteParser(readParser, opts.WriteParser, opts.Parser, "-", opts.File)
+	if err != nil {
+		return err
+	}
+
+	writeOptions := make([]storage.ReadWriteOption, 0)
+
+	if opts.Compact {
+		writeOptions = append(writeOptions, storage.PrettyPrintOption(false))
+	}
+
+	if opts.Multi {
+		return runDeleteMultiCommand(cmd, rootNode, opts, writeParser, writeOptions)
+	}
+
+	if deleteErr := rootNode.Delete(opts.Selector); deleteErr != nil {
+		err = fmt.Errorf("could not delete node: %w", deleteErr)
+	}
+	written, err := customErrorHandling(customErrorHandlingOpts{
+		File:     opts.File,
+		Writer:   opts.Writer,
+		Err:      err,
+		Cmd:      cmd,
+		NullFlag: false,
+	})
+	if err != nil {
+		return err
+	}
+	if written {
+		return nil
+	}
+
+	if err := writeNodeToOutput(writeNodeToOutputOpts{
+		Node:   rootNode,
+		Parser: writeParser,
+		Writer: opts.Writer,
+	}, cmd, writeOptions...); err != nil {
+		return fmt.Errorf("could not write output: %w", err)
+	}
+
+	return nil
+}
+
+func deleteCommand() *cobra.Command {
+	var fileFlag, selectorFlag, parserFlag, readParserFlag, writeParserFlag string
+	var plainFlag, multiFlag, compactFlag, mergeInputDocumentsFlag bool
+
+	cmd := &cobra.Command{
+		Use:   "delete -f <file> -p <json,yaml> -s <selector>",
+		Short: "Delete properties from the given file.",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			if selectorFlag == "" && len(args) > 0 {
+				selectorFlag = args[0]
+				args = args[1:]
+			}
+			if plainFlag {
+				writeParserFlag = "-"
+			}
+			return runDeleteCommand(deleteOptions{
+				File:                fileFlag,
+				Parser:              parserFlag,
+				ReadParser:          readParserFlag,
+				WriteParser:         writeParserFlag,
+				Selector:            selectorFlag,
+				Multi:               multiFlag,
+				Compact:             compactFlag,
+				MergeInputDocuments: mergeInputDocumentsFlag,
+			}, cmd)
+		},
+	}
+
+	cmd.Flags().StringVarP(&fileFlag, "file", "f", "", "The file to delete from.")
+	cmd.Flags().StringVarP(&selectorFlag, "selector", "s", "", "The selector to use when deleting from the data structure.")
+	cmd.Flags().StringVarP(&parserFlag, "parser", "p", "", "Shorthand for -r FORMAT -w FORMAT.")
+	cmd.Flags().StringVarP(&readParserFlag, "read", "r", "", "The parser to use when reading.")
+	cmd.Flags().StringVarP(&writeParserFlag, "write", "w", "", "The parser to use when writing.")
+	cmd.Flags().BoolVar(&plainFlag, "plain", false, "Alias of -w plain")
+	cmd.Flags().BoolVarP(&multiFlag, "multiple", "m", false, "Delete multiple results.")
+	cmd.Flags().BoolVar(&mergeInputDocumentsFlag, "merge-input-documents", false, "Merge multiple input documents into an array.")
+	cmd.Flags().BoolVarP(&compactFlag, "compact", "c", false, "Compact the output by removing all pretty-printing where possible.")
+
+	_ = cmd.MarkFlagFilename("file")
+
+	return cmd
+}
diff --git a/internal/command/root.go b/internal/command/root.go
index d94e577..f25706d 100644
--- a/internal/command/root.go
+++ b/internal/command/root.go
@@ -17,6 +17,7 @@ func NewRootCMD() *cobra.Command {
 	cmd.AddCommand(
 		selectCommand(),
 		putCommand(),
+		deleteCommand(),
 		updateCommand(selfupdate.NewUpdater(internal.Version)),
 	)
 	return cmd
diff --git a/node_delete.go b/node_delete.go
new file mode 100644
index 0000000..cab4fc4
--- /dev/null
+++ b/node_delete.go
@@ -0,0 +1,93 @@
+package dasel
+
+import "reflect"
+
+// Delete uses the given selector to find and delete the final node from the current node.
+func (n *Node) Delete(selector string) error {
+	if isFinalSelector(selector) {
+		n.setReflectValue(initialiseEmptyOfType(n.Value))
+		return nil
+	}
+
+	n.Selector.Remaining = selector
+	rootNode := n
+
+	if err := buildFindChain(rootNode); err != nil {
+		return err
+	}
+
+	finalNode := lastNode(rootNode)
+
+	if err := deleteFromParent(finalNode); err != nil {
+		return err
+	}
+	if finalNode.Previous != nil {
+		if newSlice, changed := cleanupSliceDeletions(finalNode.Previous.Value); changed {
+			finalNode.Previous.setReflectValue(newSlice)
+		}
+
+		if finalNode.Previous.Selector.Type != "ROOT" {
+			if err := propagate(finalNode.Previous); err != nil {
+				return err
+			}
+		}
+	}
+
+	return nil
+}
+
+// DeleteMultiple uses the given selector to query the current node for every match
+// possible and deletes them from the current node.
+func (n *Node) DeleteMultiple(selector string) error {
+	if selector == "." {
+		n.setReflectValue(initialiseEmptyOfType(n.Value))
+		return nil
+	}
+
+	n.Selector.Remaining = selector
+
+	if err := buildFindMultipleChain(n); err != nil {
+		return err
+	}
+
+	lastNodes := lastNodes(n)
+	for _, lastNode := range lastNodes {
+		// delete properties and mark indexes for deletion
+		if err := deleteFromParent(lastNode); err != nil {
+			return err
+		}
+	}
+
+	for _, lastNode := range lastNodes {
+		// Cleanup indexes marked for deletion
+		if lastNode.Previous != nil {
+			if newSlice, changed := cleanupSliceDeletions(lastNode.Previous.Value); changed {
+				lastNode.Previous.setReflectValue(newSlice)
+			}
+		}
+	}
+	for _, lastNode := range lastNodes {
+		// Propagate values
+		if lastNode.Previous != nil {
+			if lastNode.Previous.Selector.Type != "ROOT" {
+				if err := propagate(lastNode.Previous); err != nil {
+					return err
+				}
+			}
+		}
+	}
+
+	return nil
+}
+
+func initialiseEmptyOfType(value reflect.Value) reflect.Value {
+	value = unwrapValue(value)
+	switch value.Kind() {
+	case reflect.Slice:
+		return reflect.MakeSlice(value.Type(), 0, 0)
+	case reflect.Map:
+		return reflect.MakeMap(value.Type())
+	default:
+		return reflect.ValueOf(map[string]interface{}{})
+	}
+}
diff --git a/node_propagate.go b/node_propagate.go
index afad2d5..1d455ce 100644
--- a/node_propagate.go
+++ b/node_propagate.go
@@ -86,3 +86,123 @@ func propagateValueNextAvailableIndex(n *Node) error {
 
 	return &UnsupportedTypeForSelector{Selector: n.Selector, Value: value}
 }
+
+// deleteFromParent deletes the given node from it's parent.
+func deleteFromParent(n *Node) error {
+	if n.Previous == nil {
+		return nil
+	}
+
+	switch n.Selector.Type {
+	case "PROPERTY":
+		return deleteFromParentProperty(n)
+	case "INDEX":
+		return deleteFromParentIndex(n)
+	default:
+		return &UnsupportedSelector{Selector: n.Selector.Type}
+	}
+}
+
+// deleteFromParentProperty sends the value of the current node up to the previous node in the chain.
+func deleteFromParentProperty(n *Node) error {
+	if !isValid(n.Previous.Value) {
+		return &UnexpectedPreviousNilValue{Selector: n.Previous.Selector.Current}
+	}
+
+	value := unwrapValue(n.Previous.Value)
+
+	if value.Kind() == reflect.Map {
+		value.SetMapIndex(reflect.ValueOf(n.Selector.Property), reflect.Value{})
+		return nil
+	}
+
+	return &UnsupportedTypeForSelector{Selector: n.Selector, Value: n.Previous.Value}
+}
+
+// deleteFromParentIndex sends the value of the current node up to the previous node in the chain.
+func deleteFromParentIndex(n *Node) error {
+	if !isValid(n.Previous.Value) {
+		return &UnexpectedPreviousNilValue{Selector: n.Previous.Selector.Current}
+	}
+
+	value := unwrapValue(n.Previous.Value)
+
+	if value.Kind() == reflect.Slice {
+		if n.Selector.Index >= 0 && n.Selector.Index < value.Len() {
+			// Mark this index for deletion.
+			// We can't just rewrite the slice here in-case other selectors also target it.
+			value.Index(n.Selector.Index).Set(getDeletePlaceholder(value.Index(n.Selector.Index)))
+		}
+		return nil
+	}
+
+	return &UnsupportedTypeForSelector{Selector: n.Selector, Value: value}
+}
+
+// cleanupSliceDeletions scans through the given reflect and removes any invalid reflect values.
+// Returns false if no modification was made.
+func cleanupSliceDeletions(input reflect.Value) (reflect.Value, bool) {
+	value := unwrapValue(input)
+	if value.Kind() != reflect.Slice {
+		return value, false
+	}
+	res := reflect.MakeSlice(value.Type(), 0, value.Len())
+
+	invalidCount := 0
+
+	for i := 0; i < value.Len(); i++ {
+		item := value.Index(i)
+		if !item.IsValid() || isDeletePlaceholder(item) {
+			invalidCount++
+			continue
+		}
+		res = reflect.Append(res, item)
+	}
+
+	if invalidCount == 0 {
+		return value, false
+	}
+
+	return res, true
+}
+
+const deletePlaceholderKey = "dasel:delete:key"
+const deletePlaceholder = "dasel:delete:me"
+
+func getDeletePlaceholder(item reflect.Value) reflect.Value {
+	switch unwrapValue(item).Kind() {
+	case reflect.Map:
+		return reflect.ValueOf(map[string]interface{}{
+			deletePlaceholderKey: deletePlaceholder,
+		})
+	case reflect.Slice:
+		return reflect.ValueOf([]interface{}{deletePlaceholder})
+	default:
+		return reflect.ValueOf(deletePlaceholder)
+	}
+}
+
+func isDeletePlaceholder(item reflect.Value) bool {
+	switch i := unwrapValue(item); i.Kind() {
+	case reflect.Map:
+		if val, ok := i.Interface().(map[string]interface{})[deletePlaceholderKey]; ok {
+			if val == deletePlaceholder {
+				return true
+			}
+		}
+	case reflect.Slice:
+		for _, val := range i.Interface().([]interface{}) {
+			if val == deletePlaceholder {
+				return true
+			}
+		}
+	default:
+		if val, ok := i.Interface().(string); ok {
+			if val == deletePlaceholder {
+				return true
+			}
+		}
+	}
+
+	return false
+}
diff --git a/node_put.go b/node_put.go
index 5a893a2..53c158c 100644
--- a/node_put.go
+++ b/node_put.go
@@ -66,7 +66,7 @@ func (n *Node) PutMultiple(selector string, newValue interface{}) error {
 }
 
 func buildPutChain(n *Node) error {
-	if n.Selector.Remaining == "" {
+	if isFinalSelector(n.Selector.Remaining) {
 		// We've reached the end
 		return nil
 	}
@@ -94,7 +94,7 @@ func buildPutChain(n *Node) error {
 }
 
 func buildPutMultipleChain(n *Node) error {
-	if n.Selector.Remaining == "" {
+	if isFinalSelector(n.Selector.Remaining) {
 		// We've reached the end
 		return nil
 	}
diff --git a/node_query.go b/node_query.go
index a823e5a..b7cca6a 100644
--- a/node_query.go
+++ b/node_query.go
@@ -29,8 +29,12 @@ func lastNode(n *Node) *Node {
 	}
 }
 
+func isFinalSelector(selector string) bool {
+	return selector == "" || selector == "."
+}
+
 func buildFindChain(n *Node) error {
-	if n.Selector.Remaining == "" {
+	if isFinalSelector(n.Selector.Remaining) {
 		// We've reached the end
 		return nil
 	}
diff --git a/node_query_multiple.go b/node_query_multiple.go
index 65c9955..ed2defd 100644
--- a/node_query_multiple.go
+++ b/node_query_multiple.go
@@ -30,7 +30,7 @@ func lastNodes(n *Node) []*Node {
 }
 
 func buildFindMultipleChain(n *Node) error {
-	if n.Selector.Remaining == "" {
+	if isFinalSelector(n.Selector.Remaining) {
 		// We've reached the end
 		return nil
 	}
diff --git a/selector.go b/selector.go
index 83212e8..2c9182b 100644
--- a/selector.go
+++ b/selector.go
@@ -88,23 +88,32 @@ func FindDynamicSelectorParts(selector string) DynamicSelectorParts {
 	i := 0
 	parts := DynamicSelectorParts{}
 	for _, v := range selector {
-		if v == '(' {
+		switch {
+
+		// Start of a group
+		case v == '(':
 			if parts.Comparison == "" {
 				parts.Key += string(v)
 			} else {
 				parts.Value += string(v)
 			}
 			i++
-		} else if v == ')' {
+
+		// End of a group
+		case v == ')':
 			i--
 			if parts.Comparison == "" {
 				parts.Key += string(v)
 			} else {
 				parts.Value += string(v)
 			}
-		} else if i == 0 && (v == '<' || v == '>' || v == '=') {
+
+		// Matches a comparison character
+		case i == 0 && (v == '<' || v == '>' || v == '=' || v == '!'):
 			parts.Comparison += string(v)
-		} else {
+
+		// Add to key or value based on comparison existence
+		default:
 			if parts.Comparison == "" {
 				parts.Key += string(v)
 			} else {


Test patch:
diff --git a/internal/command/root_delete_test.go b/internal/command/root_delete_test.go
new file mode 100644
index 0000000..85cfc81
--- /dev/null
+++ b/internal/command/root_delete_test.go
@@ -0,0 +1,166 @@
+package command_test
+
+import (
+	"bytes"
+	"fmt"
+	"github.com/tomwright/dasel/internal/command"
+	"io"
+	"strings"
+	"testing"
+)
+
+func TestRootCMD_Delete(t *testing.T) {
+	t.Run("InvalidFile", expectErr(
+		[]string{"delete", "-f", "bad.json", "-s", "x"},
+		"could not open input file",
+	))
+	t.Run("MissingParser", expectErr(
+		[]string{"delete", "-s", "x"},
+		"parser flag required when reading from stdin",
+	))
+
+	t.Run("InvalidSingleSelector", expectErrFromInput(
+		`{"name": "Tom"}`,
+		[]string{"delete", "-p", "json", "-s", "[-]"},
+		"invalid index: -",
+	))
+	t.Run("InvalidMultiSelector", expectErrFromInput(
+		`{"name": "Tom"}`,
+		[]string{"delete", "-p", "json", "-m", "-s", "[-]"},
+		"invalid index: -",
+	))
+	t.Run("ValueNotFound", expectErrFromInput(
+		`{"name": "Tom"}`,
+		[]string{"delete", "-p", "json", "-s", ".age"},
+		"no value found for selector: .age",
+	))
+	t.Run("InvalidRootNode", expectErrFromInput(
+		``,
+		[]string{"delete", "-p", "json", "-s", ".age"},
+		"no value found for selector: .age",
+	))
+	t.Run("InvalidRootNodeMulti", expectErrFromInput(
+		``,
+		[]string{"delete", "-p", "json", "-m", "-s", ".age"},
+		"no value found for selector: .age",
+	))
+}
+
+func deleteTest(in string, parser string, selector string, output string, expErr error, additionalArgs ...string) func(t *testing.T) {
+	return deleteTestCheck(in, parser, selector, func(out string) error {
+		if out != output {
+			return fmt.Errorf("expected %v, got %v", output, out)
+		}
+		return nil
+	}, expErr, additionalArgs...)
+}
+
+func deleteTestCheck(in string, parser string, selector string, checkFn func(out string) error, expErr error, additionalArgs ...string) func(t *testing.T) {
+	return func(t *testing.T) {
+		cmd := command.NewRootCMD()
+		outputBuffer := bytes.NewBuffer([]byte{})
+
+		args := []string{
+			"delete", "-p", parser,
+		}
+		if additionalArgs != nil {
+			args = append(args, additionalArgs...)
+		}
+		args = append(args, selector)
+
+		cmd.SetOut(outputBuffer)
+		cmd.SetIn(strings.NewReader(in))
+		cmd.SetArgs(args)
+
+		err := cmd.Execute()
+
+		if expErr == nil && err != nil {
+			t.Errorf("expected err %v, got %v", expErr, err)
+			return
+		}
+		if expErr != nil && err == nil {
+			t.Errorf("expected err %v, got %v", expErr, err)
+			return
+		}
+		if expErr != nil && err != nil && err.Error() != expErr.Error() {
+			t.Errorf("expected err %v, got %v", expErr, err)
+			return
+		}
+
+		output, err := io.ReadAll(outputBuffer)
+		if err != nil {
+			t.Errorf("unexpected error reading output buffer: %s", err)
+			return
+		}
+
+		if err := checkFn(string(output)); err != nil {
+			t.Errorf("unexpected output: %s", err)
+		}
+	}
+}
+
+func TestRootCmd_Delete_JSON(t *testing.T) {
+	t.Run("RootNodeObject", deleteTest(`{
+  "email": "tom@wright.com",
+  "name": "Tom"
+}`, "json", ".", newline(`{}`), nil))
+
+	t.Run("RootNodeObjectMulti", deleteTest(`{
+  "email": "tom@wright.com",
+  "name": "Tom"
+}`, "json", ".", newline(`{}`), nil, "-m"))
+
+	t.Run("RootNodeArray", deleteTest(`["a", "b", "c"]`, "json", ".",
+		newline(`[]`), nil))
+
+	t.Run("RootNodeArrayMulti", deleteTest(`["a", "b", "c"]`, "json", ".",
+		newline(`[]`), nil, "-m"))
+
+	t.Run("RootNodeUnknown", deleteTest(`false`, "json", ".",
+		newline(`{}`), nil))
+
+	t.Run("RootNodeUnknownMulti", deleteTest(`false`, "json", ".",
+		newline(`{}`), nil, "-m"))
+
+	t.Run("Property", deleteTest(`{
+  "email": "tom@wright.com",
+  "name": "Tom"
+}`, "json", ".email", newline(`{
+  "name": "Tom"
+}`), nil))
+
+	t.Run("PropertyCompact", deleteTest(`{
+  "email": "tom@wright.com",
+  "name": "Tom"
+}`, "json", ".email", newline(`{"name":"Tom"}`), nil, "-c"))
+
+	t.Run("Index", deleteTest(`{
+  "colours": ["blue", "green", "red"],
+  "name": "Tom"
+}`, "json", ".colours.[1]", newline(`{
+  "colours": [
+    "blue",
+    "red"
+  ],
+  "name": "Tom"
+}`), nil))
+
+	t.Run("AnyIndex", deleteTest(`{
+  "colours": ["blue", "green", "red"],
+  "name": "Tom"
+}`, "json", ".colours.[*]", newline(`{
+  "colours": [],
+  "name": "Tom"
+}`), nil, "-m"))
+
+	t.Run("RootObject", deleteTest(`{
+  "name": "Tom"
+}`, "json", ".", newline(`{}`), nil))
+	t.Run("RootObjectMulti", deleteTest(`{
+  "name": "Tom"
+}`, "json", ".", newline(`{}`), nil, "-m"))
+
+	t.Run("RootArray", deleteTest(`[1, 2, 3]`, "json", ".", newline(`[]`), nil))
+	t.Run("RootArrayMulti", deleteTest(`[1, 2, 3]`, "json", ".", newline(`[]`), nil, "-m"))
+
+}
diff --git a/internal/command/root_test.go b/internal/command/root_test.go
index efc5a64..ed5dd6b 100644
--- a/internal/command/root_test.go
+++ b/internal/command/root_test.go
@@ -86,7 +86,7 @@ func expectErrFromInput(in string, args []string, expErr string) func(t *testing
 		err := cmd.Execute()
 
 		if err == nil || !strings.Contains(err.Error(), expErr) {
-			t.Errorf("unexpected error: %v", err)
+			t.Errorf("unexpected error: %v: %s", err, outputBuffer.String())
 			return
 		}
 	}
diff --git a/node_test.go b/node_test.go
index 947d08a..3620439 100644
--- a/node_test.go
+++ b/node_test.go
@@ -793,6 +793,35 @@ func putQueryTest(rootNode *dasel.Node, putSelector string, newValue interface{}
 	}
 }
 
+func deleteTest(rootNode *dasel.Node, deleteSelector string, exp interface{}) func(t *testing.T) {
+	return func(t *testing.T) {
+		err := rootNode.Delete(deleteSelector)
+		if err != nil {
+			t.Errorf("unexpected delete error: %v", err)
+			return
+		}
+
+		gotVal := rootNode.InterfaceValue()
+		if !reflect.DeepEqual(exp, gotVal) && exp != gotVal {
+			t.Errorf("expected [%T] %v, got [%T] %v", exp, exp, gotVal, gotVal)
+		}
+	}
+}
+
+func deleteMultipleTest(rootNode *dasel.Node, deleteSelector string, exp interface{}) func(t *testing.T) {
+	return func(t *testing.T) {
+		err := rootNode.DeleteMultiple(deleteSelector)
+		if err != nil {
+			t.Errorf("unexpected delete error: %v", err)
+			return
+		}
+
+		if !reflect.DeepEqual(exp, rootNode.InterfaceValue()) {
+			t.Errorf("expected %v, got %v", exp, rootNode.InterfaceValue())
+		}
+	}
+}
+
 func putQueryMultipleTest(rootNode *dasel.Node, putSelector string, newValue interface{}, querySelector string) func(t *testing.T) {
 	return func(t *testing.T) {
 		err := rootNode.PutMultiple(putSelector, newValue)
@@ -955,3 +984,246 @@ func TestNode_PutMultiple_Query(t *testing.T) {
 	t.Run("NilChainToListIndex", putQueryMultipleTest(dasel.New(nil), "my.favourite.people.[0]", "Tom", "my.favourite.people.[0]"))
 	t.Run("NilChainToListNextAvailableIndex", putQueryMultipleTest(dasel.New(nil), "my.favourite.people.[]", "Tom", "my.favourite.people.[0]"))
 }
+
+func TestNode_Delete(t *testing.T) {
+	data := func() map[string]interface{} {
+		return map[string]interface{}{
+			"id": "123",
+			"names": []string{
+				"Tom",
+				"Jim",
+			},
+			"people": []map[string]interface{}{
+				{
+					"id":   1,
+					"name": "Tom",
+				},
+				{
+					"id":   2,
+					"name": "Jim",
+				},
+			},
+		}
+	}
+
+	t.Run("InvalidSelector", func(t *testing.T) {
+		err := dasel.New(data()).Delete("people.[a].name")
+		expErr := fmt.Errorf("failed to parse selector: %w", &dasel.InvalidIndexErr{Index: "a"})
+		if err == nil {
+			t.Errorf("expected err %v, got %v", expErr, err)
+			return
+		}
+		if err.Error() != expErr.Error() {
+			t.Errorf("expected err %v, got %v", expErr, err)
+			return
+		}
+	})
+	t.Run("ExistingSingleString", deleteTest(dasel.New(data()), "id", map[string]interface{}{
+		"names": []string{
+			"Tom",
+			"Jim",
+		},
+		"people": []map[string]interface{}{
+			{
+				"id":   1,
+				"name": "Tom",
+			},
+			{
+				"id":   2,
+				"name": "Jim",
+			},
+		},
+	}))
+	t.Run("ExistingStringValue", deleteTest(dasel.New(data()), "people.[0].name", map[string]interface{}{
+		"id": "123",
+		"names": []string{
+			"Tom",
+			"Jim",
+		},
+		"people": []map[string]interface{}{
+			{
+				"id": 1,
+			},
+			{
+				"id":   2,
+				"name": "Jim",
+			},
+		},
+	}))
+	t.Run("ExistingObjectInArray", deleteTest(dasel.New(data()), "people.[0]", map[string]interface{}{
+		"id": "123",
+		"names": []string{
+			"Tom",
+			"Jim",
+		},
+		"people": []map[string]interface{}{
+			{
+				"id":   2,
+				"name": "Jim",
+			},
+		},
+	}))
+	t.Run("ExistingIntValue", deleteTest(dasel.New(data()), "people.(id=1).id", map[string]interface{}{
+		"id": "123",
+		"names": []string{
+			"Tom",
+			"Jim",
+		},
+		"people": []map[string]interface{}{
+			{
+				"name": "Tom",
+			},
+			{
+				"id":   2,
+				"name": "Jim",
+			},
+		},
+	}))
+	t.Run("ExistingArray", deleteMultipleTest(dasel.New(data()), "names", map[string]interface{}{
+		"id": "123",
+		"people": []map[string]interface{}{
+			{
+				"id":   1,
+				"name": "Tom",
+			},
+			{
+				"id":   2,
+				"name": "Jim",
+			},
+		},
+	}))
+	t.Run("RootNodeObject", deleteTest(dasel.New(map[string]interface{}{
+		"name": "Tom",
+	}), ".", map[string]interface{}{}))
+	t.Run("RootNodeArray", deleteTest(dasel.New([]interface{}{
+		"name", "Tom",
+	}), ".", []interface{}{}))
+	t.Run("RootNodeUnknown", deleteTest(dasel.New(false), ".", map[string]interface{}{}))
+}
+
+func TestNode_DeleteMultiple_Query(t *testing.T) {
+	data := func() map[string]interface{} {
+		return map[string]interface{}{
+			"id": "123",
+			"names": []string{
+				"Tom",
+				"Jim",
+			},
+			"people": []map[string]interface{}{
+				{
+					"id":   1,
+					"name": "Tom",
+				},
+				{
+					"id":   2,
+					"name": "Jim",
+				},
+			},
+		}
+	}
+
+	t.Run("InvalidSelector", func(t *testing.T) {
+		err := dasel.New(data()).DeleteMultiple("people.[a].name")
+		expErr := fmt.Errorf("failed to parse selector: %w", &dasel.InvalidIndexErr{Index: "a"})
+		if err == nil {
+			t.Errorf("expected err %v, got %v", expErr, err)
+			return
+		}
+		if err.Error() != expErr.Error() {
+			t.Errorf("expected err %v, got %v", expErr, err)
+			return
+		}
+	})
+	t.Run("ExistingSingleString", deleteMultipleTest(dasel.New(data()), "id", map[string]interface{}{
+		"people": []map[string]interface{}{
+			{
+				"id":   1,
+				"name": "Tom",
+			},
+			{
+				"id":   2,
+				"name": "Jim",
+			},
+		},
+		"names": []string{
+			"Tom",
+			"Jim",
+		},
+	}))
+	t.Run("ExistingStringValue", deleteMultipleTest(dasel.New(data()), "people.[0].name", map[string]interface{}{
+		"id": "123",
+		"names": []string{
+			"Tom",
+			"Jim",
+		},
+		"people": []map[string]interface{}{
+			{
+				"id": 1,
+			},
+			{
+				"id":   2,
+				"name": "Jim",
+			},
+		},
+	}))
+	t.Run("WildcardProperty", deleteMultipleTest(dasel.New(data()), "people.[*].name", map[string]interface{}{
+		"id": "123",
+		"names": []string{
+			"Tom",
+			"Jim",
+		},
+		"people": []map[string]interface{}{
+			{
+				"id": 1,
+			},
+			{
+				"id": 2,
+			},
+		},
+	}))
+	t.Run("ExistingIntValue", deleteMultipleTest(dasel.New(data()), "people.(id=1).id", map[string]interface{}{
+		"id": "123",
+		"names": []string{
+			"Tom",
+			"Jim",
+		},
+		"people": []map[string]interface{}{
+			{
+				"name": "Tom",
+			},
+			{
+				"id":   2,
+				"name": "Jim",
+			},
+		},
+	}))
+	t.Run("AllObjectsInArray", deleteMultipleTest(dasel.New(data()), "people.[*]", map[string]interface{}{
+		"id": "123",
+		"names": []string{
+			"Tom",
+			"Jim",
+		},
+		"people": []map[string]interface{}{},
+	}))
+	t.Run("ExistingArray", deleteMultipleTest(dasel.New(data()), "names", map[string]interface{}{
+		"id": "123",
+		"people": []map[string]interface{}{
+			{
+				"id":   1,
+				"name": "Tom",
+			},
+			{
+				"id":   2,
+				"name": "Jim",
+			},
+		},
+	}))
+	t.Run("AllItemsInObject", deleteMultipleTest(dasel.New(data()), ".[*]", map[string]interface{}{}))
+	t.Run("RootNodeObject", deleteMultipleTest(dasel.New(map[string]interface{}{
+		"name": "Tom",
+	}), ".", map[string]interface{}{}))
+	t.Run("RootNodeArray", deleteMultipleTest(dasel.New([]interface{}{
+		"name", "Tom",
+	}), ".", []interface{}{}))
+	t.Run("RootNodeUnknown", deleteMultipleTest(dasel.New(false), ".", map[string]interface{}{}))
+}
diff --git a/selector_test.go b/selector_test.go
index a3d99fa..382fdd6 100644
--- a/selector_test.go
+++ b/selector_test.go
@@ -2,6 +2,7 @@ package dasel_test
 
 import (
 	"github.com/tomwright/dasel"
+	"reflect"
 	"testing"
 )
 
@@ -17,6 +18,28 @@ func testExtractNextSelector(in string, exp string, expRead int) func(t *testing
 	}
 }
 
+func testDynamicSelectorToGroups(in string, exp []string) func(t *testing.T) {
+	return func(t *testing.T) {
+		got, err := dasel.DynamicSelectorToGroups(in)
+		if err != nil {
+			t.Errorf("unexpected error: %v", err)
+			return
+		}
+		if !reflect.DeepEqual(exp, got) {
+			t.Errorf("expected %v, got %v", exp, got)
+		}
+	}
+}
+
+func testFindDynamicSelectorParts(in string, exp dasel.DynamicSelectorParts) func(t *testing.T) {
+	return func(t *testing.T) {
+		got := dasel.FindDynamicSelectorParts(in)
+		if !reflect.DeepEqual(exp, got) {
+			t.Errorf("expected %v, got %v", exp, got)
+		}
+	}
+}
+
 func TestExtractNextSelector(t *testing.T) {
 	t.Run("Simple", testExtractNextSelector(`.metadata.name`, `.metadata`, 9))
 	t.Run("EscapedDot", testExtractNextSelector(`.before\.after.name`, `.before.after`, 14))
@@ -27,3 +50,76 @@ func TestExtractNextSelector(t *testing.T) {
 	t.Run("SimpleIndex", testExtractNextSelector(`.[123]`, `.[123]`, 6))
 	t.Run("SimpleLength", testExtractNextSelector(`.[#]`, `.[#]`, 4))
 }
+
+func TestDynamicSelectorToGroups(t *testing.T) {
+	t.Run("Blank", testDynamicSelectorToGroups("", []string{}))
+	t.Run("Single", testDynamicSelectorToGroups("(a=1)", []string{
+		"a=1",
+	}))
+	t.Run("Double", testDynamicSelectorToGroups("(a=1)(b=2)", []string{
+		"a=1",
+		"b=2",
+	}))
+	t.Run("Many", testDynamicSelectorToGroups("(a=1)(b=2)(c=3)(d=4)", []string{
+		"a=1",
+		"b=2",
+		"c=3",
+		"d=4",
+	}))
+	t.Run("Nested", testDynamicSelectorToGroups("(a=1.(x=3)(y=4))(b=2)", []string{
+		"a=1.(x=3)(y=4)",
+		"b=2",
+	}))
+	t.Run("Dot", testDynamicSelectorToGroups("(a=.)(b=2)", []string{
+		"a=.",
+		"b=2",
+	}))
+}
+
+func TestFindDynamicSelectorParts(t *testing.T) {
+	t.Run("Blank", testFindDynamicSelectorParts("", dasel.DynamicSelectorParts{
+		Key:        "",
+		Comparison: "",
+		Value:      "",
+	}))
+	t.Run("Equal", testFindDynamicSelectorParts("a=b", dasel.DynamicSelectorParts{
+		Key:        "a",
+		Comparison: "=",
+		Value:      "b",
+	}))
+	t.Run("NotEqual", testFindDynamicSelectorParts("a!=b", dasel.DynamicSelectorParts{
+		Key:        "a",
+		Comparison: "!=",
+		Value:      "b",
+	}))
+	t.Run("MoreThanEqual", testFindDynamicSelectorParts("a>=b", dasel.DynamicSelectorParts{
+		Key:        "a",
+		Comparison: ">=",
+		Value:      "b",
+	}))
+	t.Run("LessThanEqual", testFindDynamicSelectorParts("a<=b", dasel.DynamicSelectorParts{
+		Key:        "a",
+		Comparison: "<=",
+		Value:      "b",
+	}))
+	t.Run("NestedGroupIgnored", testFindDynamicSelectorParts("(.(x=y)).x=1", dasel.DynamicSelectorParts{
+		Key:        "(.(x=y)).x",
+		Comparison: "=",
+		Value:      "1",
+	}))
+	t.Run("NestedGroupIgnored", testFindDynamicSelectorParts("a=(.(x=y))", dasel.DynamicSelectorParts{
+		Key:        "a",
+		Comparison: "=",
+		Value:      "(.(x=y))",
+	}))
+	t.Run("SelectorOnBothSides", testFindDynamicSelectorParts("(.a.b.c)=(.x.y.z)", dasel.DynamicSelectorParts{
+		Key:        "(.a.b.c)",
+		Comparison: "=",
+		Value:      "(.x.y.z)",
+	}))
+	t.Run("NestedWithSelectorOnBothSides", testFindDynamicSelectorParts("(.a.b.(1=2).c)=(.x.(3=4).y.z)", dasel.DynamicSelectorParts{
+		Key:        "(.a.b.(1=2).c)",
+		Comparison: "=",
+		Value:      "(.x.(3=4).y.z)",
+	}))
+}