Repository: TomWright/dasel
Pull request #398 (2024-03-14 14:53:41)

Handle null values
Fixes https://github.com/TomWright/dasel/issues/346.

Correctly handle null values.

This allows you to select null values as expected, rather then treating them as not found.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c8f715f..63196d2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,9 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ## [Unreleased]
-
 - Nothing yet.
 
+## [v2.7.0] - 2024-03-14
+
+### Added
+
+- `null()` function. [See docs](https://daseldocs.tomwright.me/functions/null)
+
+### Fixed
+
+- Dasel now correctly handles `null` values.
+
 ## [v2.6.0] - 2024-02-15
 
 ### Added
@@ -655,7 +664,8 @@ See [documentation](https://daseldocs.tomwright.me) for all changes.
 
 - Everything!
 
-[unreleased]: https://github.com/TomWright/dasel/compare/v2.6.0...HEAD
+[unreleased]: https://github.com/TomWright/dasel/compare/v2.7.0...HEAD
+[v2.7.0]: https://github.com/TomWright/dasel/compare/v2.6.0...v2.7.0
 [v2.6.0]: https://github.com/TomWright/dasel/compare/v2.5.0...v2.6.0
 [v2.5.0]: https://github.com/TomWright/dasel/compare/v2.4.1...v2.5.0
 [v2.4.1]: https://github.com/TomWright/dasel/compare/v2.4.0...v2.4.1
diff --git a/func.go b/func.go
index f95c214..7f01e2a 100644
--- a/func.go
+++ b/func.go
@@ -60,6 +60,7 @@ func standardFunctions() *FunctionCollection {
 		TypeFunc,
 		JoinFunc,
 		StringFunc,
+		NullFunc,
 
 		// Selectors
 		IndexFunc,
diff --git a/func_null.go b/func_null.go
new file mode 100644
index 0000000..aacec2c
--- /dev/null
+++ b/func_null.go
@@ -0,0 +1,24 @@
+package dasel
+
+import (
+	"reflect"
+)
+
+var NullFunc = BasicFunction{
+	name: "null",
+	runFn: func(c *Context, s *Step, args []string) (Values, error) {
+		if err := requireNoArgs("null", args); err != nil {
+			return nil, err
+		}
+
+		input := s.inputs()
+
+		res := make(Values, len(input))
+
+		for k, _ := range args {
+			res[k] = ValueOf(reflect.ValueOf(new(any)).Elem())
+		}
+
+		return res, nil
+	},
+}
diff --git a/func_or_default.go b/func_or_default.go
index 49fc8dd..0b26f07 100644
--- a/func_or_default.go
+++ b/func_or_default.go
@@ -20,26 +20,40 @@ var OrDefaultFunc = BasicFunction{
 
 		runSubselect := func(value Value, selector string, defaultSelector string) (Value, error) {
 			gotValues, err := c.subSelect(value, selector)
+			notFound := false
 			if err != nil {
-				notFound := false
 				if errors.Is(err, &ErrPropertyNotFound{}) {
 					notFound = true
 				} else if errors.Is(err, &ErrIndexNotFound{Index: -1}) {
 					notFound = true
-				}
-				if notFound {
-					gotValues, err = c.subSelect(value, defaultSelector)
 				} else {
 					return Value{}, err
 				}
 			}
-			if len(gotValues) == 1 && err == nil {
-				return gotValues[0], nil
+
+			if !notFound {
+				// Check result of first query
+				if len(gotValues) != 1 {
+					return Value{}, fmt.Errorf("orDefault expects selector to return exactly 1 value")
+				}
+
+				// Consider nil values as not found
+				if gotValues[0].IsNil() {
+					notFound = true
+				}
 			}
-			if err != nil {
-				return Value{}, err
+
+			if notFound {
+				gotValues, err = c.subSelect(value, defaultSelector)
+				if err != nil {
+					return Value{}, err
+				}
+				if len(gotValues) != 1 {
+					return Value{}, fmt.Errorf("orDefault expects selector to return exactly 1 value")
+				}
 			}
-			return Value{}, fmt.Errorf("orDefault expects selector to return exactly 1 value")
+
+			return gotValues[0], nil
 		}
 
 		res := make(Values, 0)
diff --git a/value.go b/value.go
index 0745c10..da7081b 100644
--- a/value.go
+++ b/value.go
@@ -1,9 +1,10 @@
 package dasel
 
 import (
+	"reflect"
+
 	"github.com/tomwright/dasel/v2/dencoding"
 	"github.com/tomwright/dasel/v2/util"
-	"reflect"
 )
 
 // Value is a wrapper around reflect.Value that adds some handy helper funcs.
@@ -84,6 +85,15 @@ func (v Value) IsEmpty() bool {
 	return isEmptyReflectValue(unpackReflectValue(v.Value))
 }
 
+func (v Value) IsNil() bool {
+	switch v.Kind() {
+	case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice:
+		return v.Value.IsNil()
+	default:
+		return false
+	}
+}
+
 func isEmptyReflectValue(v reflect.Value) bool {
 	if (v == reflect.Value{}) {
 		return true
@@ -123,6 +133,9 @@ func unpackReflectValue(value reflect.Value, kinds ...reflect.Kind) reflect.Valu
 		if !containsKind(kinds, res.Kind()) {
 			return res
 		}
+		if res.IsNil() {
+			return res
+		}
 		res = res.Elem()
 	}
 }
@@ -137,6 +150,9 @@ func (v Value) FirstAddressable() reflect.Value {
 
 // Unpack returns the underlying reflect.Value after resolving any pointers or interface types.
 func (v Value) Unpack(kinds ...reflect.Kind) reflect.Value {
+	if !v.Value.IsValid() {
+		return reflect.ValueOf(new(any)).Elem()
+	}
 	return unpackReflectValue(v.Value, kinds...)
 }
 
@@ -181,6 +197,9 @@ func (v Value) dencodingMapIndex(key Value) Value {
 		if v, ok := om.Get(key.Value.String()); !ok {
 			return reflect.Value{}
 		} else {
+			if v == nil {
+				return reflect.ValueOf(new(any)).Elem()
+			}
 			return reflect.ValueOf(v)
 		}
 	}
@@ -498,7 +517,7 @@ func (v Values) Interfaces() []interface{} {
 func (v Values) initEmptydencodingMaps() Values {
 	res := make(Values, len(v))
 	for k, value := range v {
-		if value.IsEmpty() {
+		if value.IsEmpty() || value.IsNil() {
 			res[k] = value.initEmptydencodingMap()
 		} else {
 			res[k] = value
@@ -510,7 +529,7 @@ func (v Values) initEmptydencodingMaps() Values {
 func (v Values) initEmptySlices() Values {
 	res := make(Values, len(v))
 	for k, value := range v {
-		if value.IsEmpty() {
+		if value.IsEmpty() || value.IsNil() {
 			res[k] = value.initEmptySlice()
 		} else {
 			res[k] = value


Test patch:
diff --git a/func_null_test.go b/func_null_test.go
new file mode 100644
index 0000000..e68a65b
--- /dev/null
+++ b/func_null_test.go
@@ -0,0 +1,29 @@
+package dasel
+
+import (
+	"testing"
+)
+
+func TestNullFunc(t *testing.T) {
+	t.Run("Args", selectTestErr(
+		"null(1)",
+		map[string]interface{}{},
+		&ErrUnexpectedFunctionArgs{
+			Function: "null",
+			Args:     []string{"1"},
+		}),
+	)
+
+	original := map[string]interface{}{}
+
+	t.Run(
+		"Null",
+		selectTest(
+			"null()",
+			original,
+			[]interface{}{
+				nil,
+			},
+		),
+	)
+}
diff --git a/internal/command/delete_test.go b/internal/command/delete_test.go
index f06aa55..9399d00 100644
--- a/internal/command/delete_test.go
+++ b/internal/command/delete_test.go
@@ -61,4 +61,14 @@ func TestDeleteCommand(t *testing.T) {
 		nil,
 		nil,
 	))
+
+	t.Run("Issue346", func(t *testing.T) {
+		t.Run("DeleteNullValue", runTest(
+			[]string{"delete", "-r", "json", "foo"},
+			[]byte(`{"foo":null}`),
+			newline([]byte("{}")),
+			nil,
+			nil,
+		))
+	})
 }
diff --git a/internal/command/select_test.go b/internal/command/select_test.go
index c9fccc0..87a0c2e 100644
--- a/internal/command/select_test.go
+++ b/internal/command/select_test.go
@@ -441,4 +441,36 @@ d.e.f`)),
 		}
 	})
 
+	t.Run("Issue346", func(t *testing.T) {
+		t.Run("Select null or default string", runTest(
+			[]string{"-r", "json", "orDefault(foo,string(nope))"},
+			[]byte(`{
+  "foo": null
+}`),
+			newline([]byte(`"nope"`)),
+			nil,
+			nil,
+		))
+
+		t.Run("Select null or default null", runTest(
+			[]string{"-r", "json", "orDefault(foo,null())"},
+			[]byte(`{
+  "foo": null
+}`),
+			newline([]byte(`null`)),
+			nil,
+			nil,
+		))
+
+		t.Run("Select null value", runTest(
+			[]string{"-r", "json", "foo"},
+			[]byte(`{
+  "foo": null
+}`),
+			newline([]byte(`null`)),
+			nil,
+			nil,
+		))
+	})
+
 }