Repository: TomWright/dasel
Pull request #235 (2022-07-07 04:45:27)

add optional search selector '(#:<key>=<value>)'
Optional Search selectors recursively all the data below the current
node and return all of the results, if at least one result is found.

This is different from standard search, where a search selector must
exist in _all_ documents passed through dasel. The difference with
this selector is that so long as one match is found, dasel will
print the result.

Based on dicussion at https://github.com/TomWright/dasel/discussions/234


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fb218e5..fdc85f1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,11 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
-- Nothing yet
+### Added
+
+- Search optional selector - `(#:key=value)`
 
 ## [v1.25.1] - 2022-06-29
 
-## Added
+### Added
 
 - Pre-commit hooks for validate command.
 
diff --git a/node.go b/node.go
index 7c05b75..94ea9bd 100644
--- a/node.go
+++ b/node.go
@@ -2,11 +2,12 @@ package dasel
 
 import (
 	"fmt"
-	"github.com/tomwright/dasel/storage"
 	"io"
 	"os"
 	"reflect"
 	"regexp"
+
+	"github.com/tomwright/dasel/storage"
 )
 
 // Selector represents the selector for a node.
@@ -106,7 +107,7 @@ func nilValue() reflect.Value {
 }
 
 func unwrapValue(value reflect.Value) reflect.Value {
-	if value.Kind() == reflect.Interface {
+	if value.Kind() == reflect.Interface && !value.IsNil() {
 		return value.Elem()
 	}
 	return value
diff --git a/node_query_multiple.go b/node_query_multiple.go
index 57a80d1..29ca7a1 100644
--- a/node_query_multiple.go
+++ b/node_query_multiple.go
@@ -454,7 +454,9 @@ func findNodesSearchRecursiveWork(selector Selector, previousNode *Node, createI
 // If any of those nodes match the checks they are returned.
 func findNodesSearchRecursive(selector Selector, previousNode *Node, createIfNotExists bool, firstNode bool) ([]*Node, error) {
 	if !isValid(previousNode.Value) {
-		return nil, &UnexpectedPreviousNilValue{Selector: selector.Raw}
+		if selector.Type != "SEARCH_OPTIONAL" {
+			return nil, &UnexpectedPreviousNilValue{Selector: selector.Raw}
+		}
 	}
 	return findNodesSearchRecursiveWork(selector, previousNode, createIfNotExists, firstNode, unwrapValue(previousNode.Value))
 }
@@ -688,6 +690,8 @@ func findNodes(selector Selector, previousNode *Node, createIfNotExists bool) ([
 		res, err = findNodesDynamic(selector, previousNode.Value, createIfNotExists)
 	case "SEARCH":
 		res, err = findNodesSearch(selector, previousNode, createIfNotExists)
+	case "SEARCH_OPTIONAL":
+		res, err = findNodesSearch(selector, previousNode, createIfNotExists)
 	default:
 		err = &UnsupportedSelector{Selector: selector.Raw}
 	}
diff --git a/parse_selector.go b/parse_selector.go
index 2e3d032..66f623b 100644
--- a/parse_selector.go
+++ b/parse_selector.go
@@ -29,6 +29,8 @@ func ParseSelector(selector string) (Selector, error) {
 	switch {
 	case strings.HasPrefix(nextSel, "(?:") && strings.HasSuffix(nextSel, ")"):
 		sel, err = processParseSelectorSearch(nextSel, sel)
+	case strings.HasPrefix(nextSel, "(#:") && strings.HasSuffix(nextSel, ")"):
+		sel, err = processParseSelectorSearchOptional(nextSel, sel)
 	case strings.HasPrefix(nextSel, "(") && strings.HasSuffix(nextSel, ")"):
 		sel, err = processParseSelectorDynamic(nextSel, sel)
 	case nextSel == "[]":
@@ -155,6 +157,29 @@ func processParseSelectorSearch(selector string, sel Selector) (Selector, error)
 	return sel, nil
 }
 
+func processParseSelectorSearchOptional(selector string, sel Selector) (Selector, error) {
+	sel.Type = "SEARCH_OPTIONAL"
+
+	dynamicGroups, err := DynamicSelectorToGroups(selector)
+	if err != nil {
+		return sel, err
+	}
+
+	for _, g := range dynamicGroups {
+		parts := FindDynamicSelectorParts(g)
+		parts.Key = strings.TrimPrefix(parts.Key, "#:")
+		cond, err := getCondition(parts)
+		if err != nil {
+			return sel, err
+		}
+		if cond != nil {
+			sel.Conditions = append(sel.Conditions, cond)
+		}
+	}
+
+	return sel, nil
+}
+
 func processParseSelectorNextAvailableIndex(selector string, sel Selector) (Selector, error) {
 	sel.Type = "NEXT_AVAILABLE_INDEX"
 	return sel, nil


Test patch:
diff --git a/internal/command/root_select_test.go b/internal/command/root_select_test.go
index a37e910..bb74e65 100644
--- a/internal/command/root_select_test.go
+++ b/internal/command/root_select_test.go
@@ -531,6 +531,23 @@ func TestRootCmd_Select_JSON(t *testing.T) {
 ]
 `, nil, "-m"))
 
+	t.Run("SearchOptional", selectTest(`{
+  "users": [
+    {
+      "name": "Tom",
+      "blocked": true
+    },
+    {
+      "name": "Jim",
+      "blocked": false
+    },
+    {
+      "name": "Frank"
+    }
+  ]
+}`, "json", `.users.(#:blocked=true).name`, `Tom
+`, nil, "-m", "--plain"))
+
 }
 
 func TestRootCmd_Select_YAML(t *testing.T) {
diff --git a/node_query_multiple_internal_test.go b/node_query_multiple_internal_test.go
index b937108..c422178 100644
--- a/node_query_multiple_internal_test.go
+++ b/node_query_multiple_internal_test.go
@@ -371,6 +371,19 @@ func TestFindNodesSearch(t *testing.T) {
 		got, err := findNodesSearch(selector, previousNode, false)
 		assertQueryMultipleResult(t, []reflect.Value{}, &ValueNotFound{Selector: selector.Current, PreviousValue: previousNode.Value}, got, err)
 	})
+	t.Run("NotFoundOptional", func(t *testing.T) {
+		previousNode := New(map[string]interface{}{})
+		selector := Selector{
+			Type:    "SEARCH_OPTIONAL",
+			Raw:     ".(#:name=x)",
+			Current: ".(#:name=x)",
+			Conditions: []Condition{
+				&EqualCondition{Key: "name", Value: "x"},
+			},
+		}
+		got, err := findNodesSearch(selector, previousNode, false)
+		assertQueryMultipleResult(t, []reflect.Value{}, &ValueNotFound{Selector: selector.Current, PreviousValue: previousNode.Value}, got, err)
+	})
 	t.Run("FoundAllMatches", func(t *testing.T) {
 		users := []interface{}{
 			map[string]interface{}{
@@ -411,6 +424,46 @@ func TestFindNodesSearch(t *testing.T) {
 			t.Errorf("expected %v, got %v", exp, gotVals)
 		}
 	})
+	t.Run("FoundAllMatchesOptional", func(t *testing.T) {
+		users := []interface{}{
+			map[string]interface{}{
+				"id":   1,
+				"name": "Tom",
+			},
+			map[string]interface{}{
+				"id":   2,
+				"name": "Jim",
+			},
+			map[string]interface{}{
+				"id":   3,
+				"name": "Tom",
+			},
+		}
+		previousNode := New(users)
+		selector := Selector{
+			Type:    "SEARCH_OPTIONAL",
+			Raw:     ".(#:name=Tom).name",
+			Current: ".(#:name=Tom)",
+			Conditions: []Condition{
+				&EqualCondition{Key: "name", Value: "Tom"},
+			},
+		}
+		got, err := findNodesSearch(selector, previousNode, false)
+		if err != nil {
+			t.Errorf("unexpected error: %s", err)
+			return
+		}
+		exp := []interface{}{
+			users[0], users[2],
+		}
+		gotVals := make([]interface{}, len(got))
+		for i, val := range got {
+			gotVals[i] = val.Value.Interface()
+		}
+		if !reflect.DeepEqual(exp, gotVals) {
+			t.Errorf("expected %v, got %v", exp, gotVals)
+		}
+	})
 }
 
 func TestFindNodes(t *testing.T) {