diff options
| author | Andreas Hochsteger <andreas.hochsteger@gmail.com> | 2022-12-29 23:01:05 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2022-12-29 22:01:05 +0000 |
| commit | e045af5d9c9106594bc2a5f609b14dbc603f7253 (patch) | |
| tree | 7591781fd92cafd19bd5483b8e1c2fedae7ebc85 /coll | |
| parent | f8a636898f316ce0d4a9a12335e9633c218f9e8a (diff) | |
Add coll.JQ using gojq library (#1585)
* feat: add coll.JQ using gojq library
* fix: jq function naming (gojq -> jq)
* test: add tests (take from jsonpath_test.go)
* chore: add TODO for nil values (are they allowed?)
* refactor: use fmt.Errorf instead of errors.Wrapf
Co-authored-by: Dave Henderson <dhenderson@gmail.com>
* fix: wrong alias for coll.JQ
Co-authored-by: Dave Henderson <dhenderson@gmail.com>
* docs: add links to JQ
Co-authored-by: Dave Henderson <dhenderson@gmail.com>
* test: add assertions after json marshal/unmarshal
Co-authored-by: Dave Henderson <dhenderson@gmail.com>
* refactor: use fmt.Errorf instead of errors.Wrapf
Co-authored-by: Dave Henderson <dhenderson@gmail.com>
* fix: test syntax and null handling
* docs: improve documentation
* docs: add blank line
* Support cancellation
Signed-off-by: Dave Henderson <dhenderson@gmail.com>
* Support (almost) all types, not just map[string]interface{} and []interface{}
Signed-off-by: Dave Henderson <dhenderson@gmail.com>
* add an integration test for coll.JQ
Signed-off-by: Dave Henderson <dhenderson@gmail.com>
Signed-off-by: Dave Henderson <dhenderson@gmail.com>
Co-authored-by: Andreas Hochsteger <andreas.hochsteger@oeamtc.at>
Co-authored-by: Dave Henderson <dhenderson@gmail.com>
Diffstat (limited to 'coll')
| -rw-r--r-- | coll/jq.go | 96 | ||||
| -rw-r--r-- | coll/jq_test.go | 236 |
2 files changed, 332 insertions, 0 deletions
diff --git a/coll/jq.go b/coll/jq.go new file mode 100644 index 00000000..16dc8f0e --- /dev/null +++ b/coll/jq.go @@ -0,0 +1,96 @@ +package coll + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + + "github.com/itchyny/gojq" +) + +// JQ - +func JQ(ctx context.Context, jqExpr string, in interface{}) (interface{}, error) { + query, err := gojq.Parse(jqExpr) + if err != nil { + return nil, fmt.Errorf("jq parsing expression %q: %w", jqExpr, err) + } + + // convert input to a supported type, if necessary + in, err = jqConvertType(in) + if err != nil { + return nil, fmt.Errorf("jq type conversion: %w", err) + } + + iter := query.RunWithContext(ctx, in) + var out interface{} + a := []interface{}{} + for { + v, ok := iter.Next() + if !ok { + break + } + if err, ok := v.(error); ok { + return nil, fmt.Errorf("jq execution: %w", err) + } + a = append(a, v) + } + if len(a) == 1 { + out = a[0] + } else { + out = a + } + + return out, nil +} + +// jqConvertType converts the input to a map[string]interface{}, []interface{}, +// or other supported primitive JSON types. +func jqConvertType(in interface{}) (interface{}, error) { + // if it's already a supported type, pass it through + switch in.(type) { + case map[string]interface{}, []interface{}, + string, []byte, + nil, bool, + int, int8, int16, int32, int64, + uint, uint8, uint16, uint32, uint64, + float32, float64: + return in, nil + } + + inType := reflect.TypeOf(in) + value := reflect.ValueOf(in) + + // pointers need to be dereferenced first + if inType.Kind() == reflect.Ptr { + inType = inType.Elem() + value = value.Elem() + } + + mapType := reflect.TypeOf(map[string]interface{}{}) + sliceType := reflect.TypeOf([]interface{}{}) + // if it can be converted to a map or slice, do that + if inType.ConvertibleTo(mapType) { + return value.Convert(mapType).Interface(), nil + } else if inType.ConvertibleTo(sliceType) { + return value.Convert(sliceType).Interface(), nil + } + + // if it's a struct, the simplest (though not necessarily most efficient) + // is to JSON marshal/unmarshal it + if inType.Kind() == reflect.Struct { + b, err := json.Marshal(in) + if err != nil { + return nil, fmt.Errorf("json marshal struct: %w", err) + } + var m map[string]interface{} + err = json.Unmarshal(b, &m) + if err != nil { + return nil, fmt.Errorf("json unmarshal struct: %w", err) + } + return m, nil + } + + // we maybe don't need to convert the value, so return it as-is + return in, nil +} diff --git a/coll/jq_test.go b/coll/jq_test.go new file mode 100644 index 00000000..7f71891c --- /dev/null +++ b/coll/jq_test.go @@ -0,0 +1,236 @@ +package coll + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestJQ(t *testing.T) { + ctx := context.Background() + in := map[string]interface{}{ + "store": map[string]interface{}{ + "book": []interface{}{ + map[string]interface{}{ + "category": "reference", + "author": "Nigel Rees", + "title": "Sayings of the Century", + "price": 8.95, + }, + map[string]interface{}{ + "category": "fiction", + "author": "Evelyn Waugh", + "title": "Sword of Honour", + "price": 12.99, + }, + map[string]interface{}{ + "category": "fiction", + "author": "Herman Melville", + "title": "Moby Dick", + "isbn": "0-553-21311-3", + "price": 8.99, + }, + map[string]interface{}{ + "category": "fiction", + "author": "J. R. R. Tolkien", + "title": "The Lord of the Rings", + "isbn": "0-395-19395-8", + "price": 22.99, + }, + }, + "bicycle": map[string]interface{}{ + "color": "red", + "price": 19.95, + }, + }, + } + out, err := JQ(ctx, ".store.bicycle.color", in) + assert.NoError(t, err) + assert.Equal(t, "red", out) + + out, err = JQ(ctx, ".store.bicycle.price", in) + assert.NoError(t, err) + assert.Equal(t, 19.95, out) + + out, err = JQ(ctx, ".store.bogus", in) + assert.NoError(t, err) + assert.Nil(t, out) + + _, err = JQ(ctx, "{.store.unclosed", in) + assert.Error(t, err) + + out, err = JQ(ctx, ".store", in) + assert.NoError(t, err) + assert.EqualValues(t, in["store"], out) + + out, err = JQ(ctx, ".store.book[].author", in) + assert.NoError(t, err) + assert.Len(t, out, 4) + assert.Contains(t, out, "Nigel Rees") + assert.Contains(t, out, "Evelyn Waugh") + assert.Contains(t, out, "Herman Melville") + assert.Contains(t, out, "J. R. R. Tolkien") + + out, err = JQ(ctx, ".store.book[]|select(.price < 10.0 )", in) + assert.NoError(t, err) + expected := []interface{}{ + map[string]interface{}{ + "category": "reference", + "author": "Nigel Rees", + "title": "Sayings of the Century", + "price": 8.95, + }, + map[string]interface{}{ + "category": "fiction", + "author": "Herman Melville", + "title": "Moby Dick", + "isbn": "0-553-21311-3", + "price": 8.99, + }, + } + assert.EqualValues(t, expected, out) + + in = map[string]interface{}{ + "a": map[string]interface{}{ + "aa": map[string]interface{}{ + "foo": map[string]interface{}{ + "aaa": map[string]interface{}{ + "aaaa": map[string]interface{}{ + "bar": 1234, + }, + }, + }, + }, + "ab": map[string]interface{}{ + "aba": map[string]interface{}{ + "foo": map[string]interface{}{ + "abaa": true, + "abab": "baz", + }, + }, + }, + }, + } + out, err = JQ(ctx, `tostream|select((.[0]|index("foo")) and (.[0][-1]!="foo") and (.[1])) as $s|($s[0]|index("foo")+1) as $ind|($ind|truncate_stream($s)) as $newstream|$newstream|reduce . as [$p,$v] ({};setpath($p;$v))|add`, in) + assert.NoError(t, err) + assert.Len(t, out, 3) + assert.Contains(t, out, map[string]interface{}{"aaaa": map[string]interface{}{"bar": 1234}}) + assert.Contains(t, out, true) + assert.Contains(t, out, "baz") +} + +func TestJQ_typeConversions(t *testing.T) { + ctx := context.Background() + + type bicycleType struct { + Color string + } + type storeType struct { + Bicycle *bicycleType + safe interface{} + } + + structIn := &storeType{ + Bicycle: &bicycleType{ + Color: "red", + }, + safe: "hidden", + } + + out, err := JQ(ctx, ".Bicycle.Color", structIn) + assert.NoError(t, err) + assert.Equal(t, "red", out) + + out, err = JQ(ctx, ".safe", structIn) + assert.NoError(t, err) + assert.Nil(t, out) + + _, err = JQ(ctx, ".*", structIn) + assert.Error(t, err) + + // a type with an underlying type of map[string]interface{}, just like + // gomplate.tmplctx + type mapType map[string]interface{} + + out, err = JQ(ctx, ".foo", mapType{"foo": "bar"}) + assert.NoError(t, err) + assert.Equal(t, "bar", out) + + // sometimes it'll be a pointer... + out, err = JQ(ctx, ".foo", &mapType{"foo": "bar"}) + assert.NoError(t, err) + assert.Equal(t, "bar", out) + + // underlying slice type + type sliceType []interface{} + + out, err = JQ(ctx, ".[1]", sliceType{"foo", "bar"}) + assert.NoError(t, err) + assert.Equal(t, "bar", out) + + out, err = JQ(ctx, ".[2]", &sliceType{"foo", "bar", "baz"}) + assert.NoError(t, err) + assert.Equal(t, "baz", out) + + // other basic types + out, err = JQ(ctx, ".", []byte("hello")) + assert.NoError(t, err) + assert.EqualValues(t, "hello", out) + + out, err = JQ(ctx, ".", "hello") + assert.NoError(t, err) + assert.EqualValues(t, "hello", out) + + out, err = JQ(ctx, ".", 1234) + assert.NoError(t, err) + assert.EqualValues(t, 1234, out) + + out, err = JQ(ctx, ".", true) + assert.NoError(t, err) + assert.EqualValues(t, true, out) + + out, err = JQ(ctx, ".", nil) + assert.NoError(t, err) + assert.Nil(t, out) + + // underlying basic types + type intType int + out, err = JQ(ctx, ".", intType(1234)) + assert.NoError(t, err) + assert.EqualValues(t, 1234, out) + + type byteArrayType []byte + out, err = JQ(ctx, ".", byteArrayType("hello")) + assert.NoError(t, err) + assert.EqualValues(t, "hello", out) +} + +func TestJQConvertType_passthroughTypes(t *testing.T) { + // non-marshalable values, like recursive structs, can't be used + type recursive struct{ Self *recursive } + v := &recursive{} + v.Self = v + _, err := jqConvertType(v) + assert.Error(t, err) + + testdata := []interface{}{ + map[string]interface{}{"foo": 1234}, + []interface{}{"foo", "bar", "baz", 1, 2, 3}, + "foo", + []byte("foo"), + json.RawMessage(`{"foo": "bar"}`), + true, + nil, + int(1234), int8(123), int16(123), int32(123), int64(123), + uint(123), uint8(123), uint16(123), uint32(123), uint64(123), + float32(123.45), float64(123.45), + } + + for _, d := range testdata { + out, err := jqConvertType(d) + assert.NoError(t, err) + assert.Equal(t, d, out) + } +} |
